-
[xv6] Page tables 상세 분석 1프로젝트/운영체제 만들기 2023. 7. 12. 03:11
글의 참고
- book-rev11.pdf
- https://github.com/mit-pdos/xv6-public
- https://pdos.csail.mit.edu/6.828/2022/xv6.html
글의 전제
- 밑줄로 작성된 글은 강조 표시를 의미한다.
- 그림 출처는 항시 그림 아래에 표시했다.
글의 내용
- Paging hardware
: 32비트 x86에서 페이지 테이블은 2^20개의 페이지 엔트리와 같다. 하위 12비트는 오프셋으로 변환이 필요없는 실제 물리 주소이다. 그러므로, 32비트 x86에서 변경되는 부분은 상위 20비트가 변경된다. PTE 하나는 4KB를 차지하므로, 이게 2^20개 있으면 4GB가 된다. 그러나, `xv6`는 페이지 사이즈를 4KB로 전제하고 얘기를 하고 있다. 실제로 PSE 및 PAE 까지 들어가면, 4MB 페이징에 물리 주소가 36비트 확장되면서 더 많은 페이지 테이블이 등장한다. 일단은 `xv6`는 2단계 페이징 구조와 4KB 페이지 사이즈를 전제로 한다.
As a reminder, x86 instructions (both user and kernel) manipulate virtual addresses. The machine’s RAM, or physical memory, is indexed with physical addresses. The x86 page table hardware connects these two kinds of addresses, by mapping each virtual address to a physical address.
An x86 page table is logically an array of 2^20 (1,048,576) page table entries (PTEs). Each PTE contains a 20-bit physical page number (PPN) and some flags. The paging hardware translates a virtual address by using its top 20 bits to index into the page table to find a PTE, and replacing the address’s top 20 bits with the PPN in the PTE. The paging hardware copies the low 12 bits unchanged from the virtual to the translated physical address. Thus a page table gives the operating system control over virtual-to-physical address translations at the granularity of aligned chunks of 4096 (2^12) bytes. Such a chunk is called a page.
As shown in Figure 2-1, the actual translation happens in two steps. A page table is stored in physical memory as a two-level tree. The root of the tree is a 4096-byte page directory that contains 1024 PTE-like references to page table pages. Each page table page is an array of 1024 32-bit PTEs. The paging hardware uses the top 10 bits of a virtual address to select a page directory entry. If the page directory entry is present, the paging hardware uses the next 10 bits of the virtual address to select a PTE from the page table page that the page directory entry refers to. If either the page directory entry or the PTE is not present, the paging hardware raises a fault. This two-level structure allows a page table to omit entire page table pages in the common case in which large ranges of virtual addresses have no mappings.: `xv6`에서 페이징 관련해서 주의할 점이 있다. `xv6` 에서 페이징 관련해서 부르는 용어가 조금 다를 수가 있어서 아래와 같이 정리해본다. `xv6`는 전체 페이징 트리 구조를 `페이지 테이블`이라고 표현한다. 그리고 실제 인텔에서 말하는 `페이지 테이블`을 `페이지 테이블 페이지`라고 부른다.
인텔 xv6 페이지 트리(전체 페이지 구조 자체) X 페이지 테이블 페이지 디렉토리 페이지 디렉토리 페이지 디렉토리 페이지 디렉토리 엔트리 페이지 디렉토리 엔트리 페이지 디렉토리 엔트리 페이지 테이블 페이지 테이블 페이지 테이블 페이지 페이지 테이블 엔트리 페이지 테이블 엔트리 페이지 테이블 엔트리 - Process address space
: `xv6`는 커널의 시작 지점인 `entry.S` 파일에서 `entrypgdir`을 통해서 4MB라는 영역을 할당받는다. 그리고 4MB 앞쪽은 커널 텍스트, 데이터 등의 영역을 할당하고 그 뒤부터 4MB 직전까지 페이지를 할당받기 위한 영역으로 할당한다. 그리고 `xv6`는 가상 메모리의 지원으로 각 프로세스가 자신만의 페이지 테이블을 갖게 한다. 그래서 컨택스트 스위칭시에 자신의 페이지 테이블 교체를 한다.
The page table created by `entry` has enough mappings to allow the kernel’s C code to start running. However, `main` immediately changes to a new page table by calling `kvmalloc` (1840), because kernel has a more elaborate plan for describing process address spaces.
Each process has a separate page table, and xv6 tells the page table hardware to switch page tables when xv6 switches between processes. As shown in Figure 2-2, a process’s user memory starts at virtual address zero and can grow up to KERNBASE, allowing a process to address up to 2 gigabytes of memory. The file memlayout.h (0200) declares the constants for xv6’s memory layout, and macros to convert virtual to physical addresses.When a process asks xv6 for more memory, xv6 first finds free physical pages to provide the storage, and then adds PTEs to the process’s page table that point to the new physical pages. xv6 sets the PTE_U, PTE_W, and PTE_P flags in these PTEs. Most processes do not use the entire user address space; xv6 leaves PTE_P clear in unused PTEs. Different processes’ page tables translate user addresses to different pages of physical memory, so that each process has private user memory
Xv6 includes all mappings needed for the kernel to run in every process’s page table; these mappings all appear above KERNBASE. It maps virtual addresses KERNBASE:KERNBASE+PHYSTOP to 0:PHYSTOP. One reason for this mapping is so that the kernel can use its own instructions and data. Another reason is that the kernel sometimes needs to be able to write a given page of physical memory, for example when creating page table pages; having every physical page appear at a predictable virtual address makes this convenient. A defect of this arrangement is that xv6 cannot make use of more than 2 gigabytes of physical memory, because the kernel part of the address space is 2 gigabytes. Thus, xv6 requires that PHYSTOP be smaller than 2 gigabytes, even if the computer has more than 2 gigabytes of physical memory.: 4GB의 물리적 메모리를 페이지 테이블에 할당하기 위해서는 페이징 관련 메타 데이터는 4MB가 필요하다. 페이지 테이블 한개가 4MB를 커버하므로(4096B * 1024), 이게 1024개면 4GB가 된다. 페이지 테이블 1024개를 커버하려면, 페이지 디렉토리 한 개면 된다. 결국 페이징 관련 메타 데이터는 `페이지 디렉토리 1개와 페이지 테이블 1024개면 4GB를 커버할 수 있다.
: 아래의 xv6가 말하는 부분이 이 내용이다. 페이징을 관리하기 위해서는 페이징 관련 메타 데이터가 필요한데, 이 메타 데이터를 할당하기 위해서 초기 시점에 4MB를 할당하겠다는 얘기다.
There is a bootstrap problem: all of physical memory must be mapped in order for the allocator to initialize the free list, but creating a page table with those mappings involves allocating page-table pages. xv6 solves this problem by using a separate page allocator during entry, which allocates memory just after the end of the kernel’s data segment. This allocator does not support freeing and is limited by the 4 MB mapping in the entrypgdir, but that is sufficient to allocate the first kernel page table
: `entrypgdir`은 main.c에 선언되어 있다. 메모리 구조 전략은 상위 절반 전략(Higher-half)을 사용한다. 그리고 1024(NPDENTRIES)개의 페이지 디렉토리 중 0번째 페이지 디렉토리 엔트리와 512(KERNBASE>>PDXSHIFT)번째 페이지 디렉터리 엔트리를 `아이텐티티 매핑`을 이용하여 초기 부팅과 상위 절반으로 점프했을 때, 발생할 수 있는 페이징 관련 이슈를 방지하고 있다.
// The boot page table used in entry.S and entryother.S.
// Page directories (and page tables) must start on page boundaries,
// hence the __aligned__ attribute.
// PTE_PS in a page directory entry enables 4Mbyte pages.
__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = { // #define NPDENTRIES 1024 // # directory entries per page directory
// Map VA's [0, 4MB) to PA's [0, 4MB)
[0] = (0) | PTE_P | PTE_W | PTE_PS,
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS, // #define PDXSHIFT 22 // offset of PDX in a linear address
};: 위 main.c에서 선언된 `entrypgdir`을 entry.S에서 사용하고 있다. 그런데, 어떻게 `main.c`에 선언된 파일을 사용할 수 있을까?
// entry.S
#include "asm.h"
#include "memlayout.h"
#include "mmu.h"
#include "param.h"
# Multiboot header. Data to direct multiboot loader.
.p2align 2
.text
.globl multiboot_header
multiboot_header:
#define magic 0x1badb002
#define flags 0
.long magic
.long flags
.long (-magic-flags)
# By convention, the _start symbol specifies the ELF entry point.
# Since we haven't set up virtual memory yet, our entry point is
# the physical address of 'entry'.
.globl _start
_start = V2P_WO(entry)
# Entering xv6 on boot processor, with paging off.
.globl entry
entry:
# Turn on page size extension for 4Mbyte pages
movl %cr4, %eax
orl $(CR4_PSE), %eax
movl %eax, %cr4
# Set page directory
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PG|CR0_WP), %eax
movl %eax, %cr0
# Set up the stack pointer.
movl $(stack + KSTACKSIZE), %esp
# Jump to main(), and switch to executing at
# high addresses. The indirect call is needed because
# the assembler produces a PC-relative instruction
# for a direct jump.
mov $main, %eax
jmp *%eax
.comm stack, KSTACKSIZE- Code: creating an address space
: `kvmalloc`을 통해서 커널이 실제로 동작할 수 있는 커널 영역 `KERNBASE(0x8000_0000)` 에 관한 페이지 테이블을 생성한다.
main calls `kvmalloc` (1840) to create and switch to a page table with the mappings above KERNBASE required for the kernel to run. Most of the work happens in `setupkvm` (1818). It first allocates a page of memory to hold the page directory. Then it calls `mappages` to install the translations that the kernel needs, which are described in the `kmap` (1809) array. The translations include the kernel’s instructions and data, physical memory up to PHYSTOP, and memory ranges which are actually I/O devices. setupkvm does not install any mappings for the user memory; this will happen later.
: `kvmalloc`은 단순하게 구현되어 있다. 핵심은 `setupkvm` 함수다.
// Allocate one page table for the machine for the kernel address
// space for scheduler processes.
void
kvmalloc(void)
{
kpgdir = setupkvm();
switchkvm();
}: `setupkvm`은 먼저 `kalloc()`을 통해서 페이지 사이즈(4KB)만큼 메모리 공간을 할당받는다. `setupkvm`은 이 영역을페이지 디렉토리를 하나 할당받는다. 페이지 디렉토리를 통해 `kmap`에 정적으로 할당되어 있는 4개의 영역에 대해 가상 물리 주소를 할당할 계획인 것이다. `kmap` 을 통해 가상 주소가 할당되는 커널 영역은 사실 많지 않다. 커널 영역이 물리 메모리상에서 2GB를 차지하지만, 실제로 가상 주소를 할당되는 영역은 4가지 밖에 없는 것이다. 이 사이즈를 재볼 필요가 있다.
// vm.c
...
...
// There is one page table per process, plus one that's used when
// a CPU is not running any process (kpgdir). The kernel uses the
// current process's page table during system calls and interrupts;
// page protection bits prevent user code from using the kernel's
// mappings.
//
// setupkvm() and exec() set up every page table like this:
//
// 0..KERNBASE: user memory (text+data+stack+heap), mapped to
// phys memory allocated by the kernel
// KERNBASE..KERNBASE+EXTMEM: mapped to 0..EXTMEM (for I/O space)
// KERNBASE+EXTMEM..data: mapped to EXTMEM..V2P(data)
// for the kernel's instructions and r/o data
// data..KERNBASE+PHYSTOP: mapped to V2P(data)..PHYSTOP,
// rw data + free physical memory
// 0xfe000000..0: mapped direct (devices such as ioapic)
//
// The kernel allocates physical memory for its heap and for user memory
// between V2P(end) and the end of physical memory (PHYSTOP)
// (directly addressable from end..P2V(PHYSTOP)).
// This table defines the kernel's mappings, which are present in
// every process's page table.
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};
// Set up kernel part of a page table.
pde_t*
setupkvm(void)
{
pde_t *pgdir;
struct kmap *k;
if((pgdir = (pde_t*)kalloc()) == 0)
return 0;
memset(pgdir, 0, PGSIZE);
if (P2V(PHYSTOP) > (void*)DEVSPACE)
panic("PHYSTOP too high");
for(k = kmap; k < &kmap[NELEM(kmap)]; k++)
if(mappages(pgdir, k->virt, k->phys_end - k->phys_start, (uint)k->phys_start, k->perm) < 0) {
freevm(pgdir);
return 0;
}
return pgdir;
}
...
...
####################################################################################################
// kalloc.c
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
char*
kalloc(void)
{
struct run *r;
if(kmem.use_lock)
acquire(&kmem.lock);
r = kmem.freelist;
if(r)
kmem.freelist = r->next;
if(kmem.use_lock)
release(&kmem.lock);
return (char*)r;
}: 아래 코드는 `xv6`의 커널 영역의 메모리 맵 변수들이다. `kmap`은 아래의 변수들을 차이를 가상 주소로 할당한다. 자 이제 계산을 해보자. 그런데, ` data` 라는 영역은 `kernel.ld`에 선언되어 있고, 이 영역은 커널의 데이터 영역 시작 주소를 나타낸다. `그림 2-2` 를 참고하자. 그런데, `data` 라는 영역은 고정값이 아니다.
// kernel.ld
...
. = 0x80100000;
.text : AT(0x100000) {
*(.text .stub .text.* .gnu.linkonce.t.*)
}
PROVIDE(etext = .); /* Define the 'etext' symbol to this value */
.rodata : {
*(.rodata .rodata.* .gnu.linkonce.r.*)
}
/* Include debugging information in kernel memory */
.stab : {
PROVIDE(__STAB_BEGIN__ = .);
*(.stab);
PROVIDE(__STAB_END__ = .);
}
.stabstr : {
PROVIDE(__STABSTR_BEGIN__ = .);
*(.stabstr);
PROVIDE(__STABSTR_END__ = .);
}
PROVIDE(data = .);
/* The data segment */
.data : {
*(.data)
}
...: 위와 같이 `data` 섹션 앞에는 몇 개의 섹션들이 존재한다. 그 중에서 가장 중요한 `text` 영역의크기에 따라 `data` 섹션의 시작 주소 또한 달라진다. 그러나, 일반적으로 작은 커널이 아무리 커도 4MB를 넘지 않는다. `xv6` 같은 경우는 1MB도 넘지 못할 것이다.그래도 혹시 모르니 텍스트 섹션이 최대 4MB(0x400000)라고 전제하자. `KERNLINK`(0x8010_0000)는 32비트 커널이 로드되는 주소다. 32비트 커널의 앞 영역은 BIOS가 사용하는 영역이라고 보면 된다. 이 값은 거의 모든 GRUB 등의 범용 부트로더를 사용한다면 암묵적으로 고정된 값이라고 생각하면 된다.
EXTMEM(0x100000) - 0 = 0x100000
data(0x400000) - KERNLINK(0x100000) = 0x300000
PHYSTOP(0xE000000) - data(0x400000) = 0xDC00000
0x100000000 - DEVSPACE(0xFE000000) = 0x2000000
총합 = 0x10000000 = 256MB: `xv6` 커널은 커녈 영역을 사용하기 먼저 256MB를 기본적으로 할당한다. 결국, `xv6`는 0x70000000(0xFE000000 - 0xE000000) - 0x80000000) 양은 사용하지 않는다. 이 건 `PHYSTOP`을 어떻게 설정하냐에 따라 달라진다. 더 많은 메모리를 사용하고 싶다면, PHYSTOP의 값을 변경하면 된다.
: 그리고 `mappages` 함수를 진행할 때, `kmap` 의 마지막 인자를 보면 마지막 물리 주소를 `0`으로 할당했다. 그래서 setupkvm에서 `mappages` 함수로 가상 주소를 얼마나 할당할지에 대한 사이즈를 전달할 때, `0 - DEVSPACE` 를 진행한다. 근데, 이 값은 `0 - 0xFE000000`로 음수가 된다. 그런데, 자료형은 `unsigned int`로 받음로써 2의 보수를 취해서 0x2000000가 된다. 참고로, 2의 보수에 규칙이 있다.
-0xFE00_0000 = 0x200_0000
-0xC000_0000 = 0x400_0000
-1 = 0xFFFF_FFFF: 위의 규칙이 이해가 될 것이다. 이제 `setupkvm` 안에 `mappages`를 자세히 보자.
// memlayout.h
// Memory layout
#define EXTMEM 0x100000 // Start of extended memory
#define PHYSTOP 0xE000000 // Top physical memory
#define DEVSPACE 0xFE000000 // Other devices are at high addresses
// Key addresses for address space layout (see kmap in vm.c for layout)
#define KERNBASE 0x80000000 // First kernel virtual address
#define KERNLINK (KERNBASE+EXTMEM) // Address where kernel is linked
#define V2P(a) (((uint) (a)) - KERNBASE)
#define P2V(a) ((void *)(((char *) (a)) + KERNBASE))
#define V2P_WO(x) ((x) - KERNBASE) // same as V2P, but without casts
#define P2V_WO(x) ((x) + KERNBASE) // same as P2V, but without casts
...
...
####################################################################################################
// vm.c
...
...
// This table defines the kernel's mappings, which are present in
// every process's page table.
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};: 가장 중요한 함수는 `mappages`다. 주석에 이미 `mappages` 함수에 대한 설명이 모두 나와있다. 즉, `va`는 가상 메모리의 시작 주소를 의미한다. 그리고, `pa`는 물리 메모리의 시작 주소를 의미한다. 그리고 가상 메모리를 물리 메모리에 `아이텐티티 매핑`을 진행한다.
: 처음 이 함수에 진입하면 `PGROUNDDOWN` 함수가 보이는데, 이 함수는 인자로 들어온 주소를 4KB로 정렬시켜준다고 보면 된다. 프로그래밍적으로 정렬시키는 방법 이 글을 참고하자. `last` 변수에 `size - 1`을 이유는 최종 페이지는 받으면 안되기 때문이다. 예를 들어, `kmap`의 첫 번째 인자가 `mappages`로 들어왔다고 치자. 그리고 `-1`을 않았다고 가정하자. 그러면, va는 `0`이고, `size`는 0x100000이 될 것이다. 여기서, 그럼 `last` 변수에는 처음 들어왔던 0x100000이 그대로 반환된다. 왜냐면, 1MB는 이미 4KB 정렬이 되어있기 때문이다. 여기서 이제 아래 루프를 돈다고 치자. `a`는 `last`와 값이 같아질 때 까지 계속해서 PTE를 할당받고, 성공할 경우 PGSIZE(0x1000) 만큼 더해진다.
: 그러다가 `a`가 0x800FF000 이 되었고, 현재 위치는 `for(;;)` 이라고 치자. 그러면 다시 `walkpgdir`을 통해서 새로운 PTE를 할당받는다. `if(a == last)` 조건문을 만족하지 않으니, `a`에 다시 PGSIZE를 더한다. 그러면, 이제 `a`는 0x80010000이 된다. 다시 `walkpgdir`을 돌아아야 하는데, 여기서 문제가 발생한다. 문제의 원인은 그 전 루프에서 끝났어야 한다는 것이다. `a`가 `walkpgdir`로 전달되는 목적은 반환되는 가상 주소의 시작 주소를 전달하는 것이다. `xv6`는 페이지 사이즈를 암묵적으로 4KB로 사용하고 있기 때문에, `walkpgdir`은 [0x8000_0000:0x8010_0000] = [0x0000_0000:0x0010_0000] 에 매핑시키는 역할을 한다. 즉, `a`의 마지막값은 0x800F_F000이 되어야 한다는 것이다. 그래서 `last`를 구할 때, `size - 1`을 할 경우, 0x800F_FFFF가 전달된다. 이걸 4KB로 내림정렬을 할 경우, 0x800F_F000가 된다.
: `mappages`는 커널이 요청한 영역을 모두 할당할 때 까지, 계속해서 `가상 주소를 물리 주소에 아이텐티티 매핑`을 진행한다. `mappages`의 핵심은 2개의 주소를 요청된 사이즈만큼 계속해서 1:1 매핑한다는 것을 알면된다. 그럼 이제 `walkpgdir`로 넘어가자.
// vm.c
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned.
static int
mappages(pde_t *pgdir, void *va, uint size, uint pa, int perm)
{
char *a, *last;
pte_t *pte;
a = (char*)PGROUNDDOWN((uint)va);
last = (char*)PGROUNDDOWN(((uint)va) + size - 1);
for(;;){
if((pte = walkpgdir(pgdir, a, 1)) == 0)
return -1;
if(*pte & PTE_P)
panic("remap");
*pte = pa | perm | PTE_P;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
...
...
// mmu.h
...
...
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1)): `walkpgdir`은 전달받은 페이지 디렉토리안에 있는 디렉토리 엔트리(PDE)들의 값을 채워준다. 결국 페이지 디렉토리 엔트리에 값을 채워준다는 것은 새로운 페이지 테이블을 할당한다는 것이 된다.
1. 먼저 가상 주소에서 `페이지 디렉토리 엔트리 오프셋`을 추출한다. 가상 주소의 상위 10비트는 디렉토리 엔트리 오프셋, 중간 10비트는 페이지 테이블 엔트리 오프셋, 마지막 12비트는 실제 물리 주소의 오프셋이 된다. 가상 주소에서 디렉토리 오프셋을 추출하려면, 오른쪽 쉬프트 연산을 22번 해서 상위 10비트가 하위 10비트에 위치하게 하면 된다. 이렇게 디렉토리 엔트리가 추출된다.
2. 디렉토리 엔트리가 유효한 값이면 해당 엔트리가 가지고 있는 페이지 테이블을 추출해낸다. 여기서 디렉토리 엔트리가 유효하다는 것은 디렉토리 엔트리에 페이지 테이블의 주소가 할당됬다는 말과 동의어다. 디렉토리 엔트리안에 페이지 테이블의 주소가 없다면, 그 디렉토리 엔트리는 의미가 없으므로, 유효하지 않다고 판단한다.
3. 만약, 디렉토리 엔트리에 페이지 테이블이 할당되어 있지 않다면(유효하지 않다면), 새로운 페이지 테이블을 하나 만든다.
4. `3` 번째 과정에서 만든 페이지 테이블의 주소를 디렉토리 엔트리에 할당한다. 몇개의 추가 플래그들이 보일 것이다. 그중에 `PTE_U`를 주목해야 한다. 이 값은 `유저 테이블`이라는 뜻이다.
5. 전달받은 가상 주소에서 `페이지 테이블 오프셋`을 추출한다(PTX(pa)). 추출된 페이지 테이블 엔트리를 반환한다.: 페이지 디렉토리 엔트리의 값이 유효하다면, 2번째 과정을 하고 5번째 과정으로 끝이다. 그런데, 페이지 디렉토리 엔트리가 유효하지 않다면 3번, 4번을 거쳐서 5번이 된다. 여기서 후자로 언급한 부분들에 대해 자세히 볼 필요가 있다.
1" 인자로 가상 주소(va)가 `0xC0012000`가 들어왔다고 치자. `0b1100000000` 페이지 디렉토리 엔트리가 유효하지 않으면, `768번째 디렉토리 엔트리 유효하지 않다는 말과 동의어다. 그러므로, 해당 디렉토리 엔트리에 페이지 테이블을 새로 할당한다. 그리고 방금 새로 만든 페이지 테이블에서 가상 주소(va)의 페이지 테이블 오프셋을 추출해서 해당 페이지 테이블 엔트리를 반환한다. 여기까지가 페이지 디렉토리 엔트리가 유효하지 않을 경우의 루틴이다.
2" `mappages`는 요청된 사이즈만큼 계속 가상 주소를 물리 주소로 매핑하기 때문에, `walkpgdir`이 다시 호출됬다고 치자. 그러면, 이번에는 가상 주소(va)가 `0xC0013000`이 들어올 것이다. 그렇면 다시, 가상 주소에서 디렉토리 엔트리 오프셋을 추출한다. 이전과 같이 `0b1100000000` 임이 확인된다. 그런데, 이번에는 디렉토리 엔트리가 유효하다. 왜냐면, 이전 `walkpgdir`에서 해당 디렉토리 엔트리에 페이지 테이블을 할당했기 때문이다.: `walkpagid`에서 에러를 발생시키는 경우는 `0`을 리턴하는 경우다. 이 경우는 커널에 할당된 메모리가 부족해서 페이지를 할당할 수 없다는 것인다. 그럼 이제 이 내용을 알아보자.
// vm.c
...
// Return the address of the PTE in page table pgdir
// that corresponds to virtual address va. If alloc!=0,
// create any required page table pages.
static pte_t *
walkpgdir(pde_t *pgdir, const void *va, int alloc)
{
pde_t *pde;
pte_t *pgtab;
pde = &pgdir[PDX(va)]; --- 1
if(*pde & PTE_P){
pgtab = (pte_t*)P2V(PTE_ADDR(*pde)); --- 2
} else {
if(!alloc || (pgtab = (pte_t*)kalloc()) == 0) --- 3
return 0;
// Make sure all those PTE_P bits are zero.
memset(pgtab, 0, PGSIZE);
// The permissions here are overly generous, but they can
// be further restricted by the permissions in the page table
// entries, if necessary.
*pde = V2P(pgtab) | PTE_P | PTE_W | PTE_U; --- 4
}
return &pgtab[PTX(va)]; --- 5
}
...
####################################################################################################
// vm.c
// page directory index
#define PDX(va) (((uint)(va) >> PDXSHIFT) & 0x3FF)
...
#define PTXSHIFT 12 // offset of PTX in a linear address
#define PDXSHIFT 22 // offset of PDX in a linear address
...: `xv6`는 상위 절반 초기에 4MB 영역에 가상 주소를 할당한다. 그리고 main()으로 점프한다. `main` 의 시작은 `kinit1`이라는 함수가 담당한다. 이 함수가 역할은 역할은 최초 할당된 4MB 중에서 커널 텍스트 및 데이터를 제외한 나머지 영역에 페이지 할당을 영역으로 설정한다는 것이다. 먼저 `kinit1`의 인자로 `end`와 `4MB`를 전달하고 있다. 앞에 `end`는 링커스크립트에 정의되어 있는 값이다.
// main.c
...
...
extern pde_t *kpgdir;
extern char end[]; // first address after kernel loaded from ELF file
// Bootstrap processor starts running C code here.
// Allocate a real stack and switch to it, first
// doing some setup required for memory allocator to work.
int
main(void)
{
kinit1(end, P2V(4*1024*1024)); // phys page allocator
kvmalloc(); // kernel page table
mpinit(); // detect other processors
lapicinit(); // interrupt controller
seginit(); // segment descriptors
picinit(); // disable pic
ioapicinit(); // another interrupt controller
consoleinit(); // console hardware
uartinit(); // serial port
pinit(); // process table
tvinit(); // trap vectors
binit(); // buffer cache
fileinit(); // file table
ideinit(); // disk
startothers(); // start other processors
kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()
userinit(); // first user process
mpmain(); // finish this processor's setup
}
...
...: `end` 심볼은 커널 텍스트, 디버깅, 데이터 영역이 모두 처리가 되고나서 가장 마지막 주소를 할당받는 심볼이다. 이 값을 정확히 알 수는 없다. 이 값은 빌드 및 링킹까지 진행되봐야 알 수 있는 값이다.
// kernel.ld
ENTRY(_start)
SECTIONS
{
/* Link the kernel at this address: "." means the current address */
/* Must be equal to KERNLINK */
. = 0x80100000;
.text : AT(0x100000) {
*(.text .stub .text.* .gnu.linkonce.t.*)
}
...
...
/* The data segment */
.data : {
*(.data)
}
PROVIDE(edata = .);
.bss : {
*(.bss)
}
PROVIDE(end = .);
...: `kinit1` 함수는 내부적으로 다시 `freerange` 함수를 호출한다. 이 때, 전달받았던 인자를 그대로 다시 전달한다. `freerange` 함수에서는 `vend` 까지 루프를 돌면서, 4MB 중에서 커널 텍스트, 디버깅, 데이터 영역을 제외한 나머지 뒤쪽 영역에 페이지 할당 영역을 형성한다. 여기서 `vstart1`가 `PGROUNDUP`으로 올림되서 페이지 크기(4KB) 정렬하고 있다. 왜 페이지 정렬을 하고 하필이면 내림이 아니고 올림으로 할까?
: 인텔문서에서는 모든 페이지 디렉토리 페이지 테이블은 4KB로 정렬되어 있어야 한다고 정의되어 있다. 각 테이블이 4KB로 정렬되어있지 않을 경우, 페이지 디렉토리 및 페이지 테이블의 맨 처음 페이지 엔트리의 플래그 값이 깨져버린다. 그러므로, 페이지 디렉토리 및 테이블은 반드시 4KB에 정렬되어 있어야 한다.
: 그리고 올림으로 하는 이유는 커널 영역을 오버라이트 할 수 있기 때문이다. 만약, 커널이 영역이 0x8010_0000 ~ 0x8023_5228 영역까지 할당받았다고 해보자. 그렇면 `vstart`의 값은 0x8023_5228가 된다. 여기서 만약 페이지 내림을 했다고 해보자. 그렇면, `vstart`는 0x8023_5000이된다. 그런데, 커널은 0x8023_5228까지 공간을 차지하고 있다. 즉, 뒤쪽 0x228B가 충돌이 되버린다. 올림을 할 경우, 0x8023_6000가 되므로, 패딩은 좀 있겠지만 안전하게 페이지 테이블 할당이 가능하다. \
: 위에 `mappages` 조건문에서 한 번 봐겠지만, 여기서는 루프문의 조건을 잘 봐야한다. `p + PGSIZE <= (char*)vend` 를 보면 실제 페이지 사이즈를 더하지 않고, 임시로 더한 페이지 사이즈 값과 `vend`를 비교해서 작거나 값을 때 까지만 처리한다. 이렇게 하면,`p`가 0x803F_F000까지만 진행한다. 즉, 0x8040_0000은 진행하지 않는다.
// kalloc.c
// Initialization happens in two phases.
// 1. main() calls kinit1() while still using entrypgdir to place just
// the pages mapped by entrypgdir on free list.
// 2. main() calls kinit2() with the rest of the physical pages
// after installing a full page table that maps them on all cores.
void
kinit1(void *vstart, void *vend)
{
initlock(&kmem.lock, "kmem");
kmem.use_lock = 0;
freerange(vstart, vend);
}
void
kinit2(void *vstart, void *vend)
{
freerange(vstart, vend);
kmem.use_lock = 1;
}
void
freerange(void *vstart, void *vend)
{
char *p;
p = (char*)PGROUNDUP((uint)vstart);
for(; p + PGSIZE <= (char*)vend; p += PGSIZE)
kfree(p);
}
//PAGEBREAK: 21
// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc(). (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(char *v)
{
struct run *r;
if((uint)v % PGSIZE || v < end || V2P(v) >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
memset(v, 1, PGSIZE);
if(kmem.use_lock)
acquire(&kmem.lock);
r = (struct run*)v;
r->next = kmem.freelist;
kmem.freelist = r;
if(kmem.use_lock)
release(&kmem.lock);
}
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
char*
kalloc(void)
{
struct run *r;
if(kmem.use_lock)
acquire(&kmem.lock);
r = kmem.freelist;
if(r)
kmem.freelist = r->next;
if(kmem.use_lock)
release(&kmem.lock);
return (char*)r;
}: 조건들이 여러개 있지만, 대부분 직관적인 조건들이다. 전달받은 가상 주소는 당연히 페이지 사이즈(PGSIZE)에 정렬되어 있어야 한다. 가상 주소 `v`를 물리 주소로 변환된 값은 `PHYSTOP`보다 작아야 한다. `PHYSTOP`의 기본값은 0xE000000 으로, 이 값의 의미는 커널이 사용할 수 있는 물리 주소의 한계선을 정의한 값이다. 그러므로, 전달받은 가상 주소 `v`를 먼저 물리 주소로 변환한 뒤, 그 값이 `PHYSTOP`보다 작은지를 판단한다. 당연히, 물리 주소의 한계선보다 큰 주소가 들어오면 실패다.
: `v < end`가 조금 직관적이지 못할 수 있는데, 여기서 `end`는 최초 `kinit1`에서 전달받은 `vend`와는 전혀 다른값이다. 여기서 `end`는 `kernel.ld`에 작성되어 있는 커널의 텍스트, 디버깅, 데이터 영역이 끝난 지점을 가라킨다. 여기서 `xv6`가 하려는 것은 `end` 부터 시작해서 0x8040_0000 안쪽에 최대한 많은 페이지를 할당받으려고 하는 것이다. `kinit1`의 첫 번째 인자가 바로 `end` 였다. `end` 값은 `freerange` 에서 페이지 올림으로 `0x1000`이 더해진다. 그러므로, 절대 `v`는 `end`보다 작을 수 가 없다.
: 그리고 나서 해당 가상 주소를 페이지 프리 리스트(`kmem.freelist`)에 저장한다. `xv6`의 페이지 할당 알고리즘은 굉장히 간단하지만, 빠르고 직관적인 알고리즘이다. 이 알고리즘 내가 올려놓은 글 중에 `고정 크기 할당 알고리즘`이라는 글의 방식과 유사한 알고리즘이다. 쉽게 설명하면, 다음에 할당할 주소들을 연결 리스트로 연결하여 O(1) 성능이 나오는 알고리즘이다. 일반 루프문으로 탐색하는 것과 속도 차이가 많이 날 것이다.
: 이 알고리즘을 쉽게 이해하려면, 연결 리스트에 대해 아주 조금만 알고있으면 된다. 연결리스트는 대개 `헤드`라고 노드가 존재한다. 이 노드는 마치 트리에서 `루트 노드`와 같다. 연결 리스트는 `헤드`를 통해 탐색이 시작된다. 그러면 이제 `kmem.freelist`를 연결 리스트의 헤드로 보고, `r`을 단일 연결 리스트의 노드로 보면 된다.
1. `r = (struct run*)v;` : 새로운 노드를 할당한다.
2. `r->next = kmem.freelist;` : 새로운 노드를 이전 노드 앞에 삽입한다.
3. `kmem.freelist = r;` : 헤드가 새로운 노드를 가리키도록 한다.: 이해가 가지 않을 수 있다. 그럴 경우, 내가 말한 `고정 크기 할당 알고리즘` 글을 보고 오면 좋을 것 같다. `kalloc`에서 주목할 점은 딱 하나다.
1. `kmem.freelist = r->next;` : 헤드가 다음 노드를 가리키도록 한다.
- User part of an address space
: 유저 스페이스 메모리 관리는 main.c의 `userinit` 함수를 통해 시작된다.
// main.c
...
...
extern pde_t *kpgdir;
extern char end[]; // first address after kernel loaded from ELF file
// Bootstrap processor starts running C code here.
// Allocate a real stack and switch to it, first
// doing some setup required for memory allocator to work.
int
main(void)
{
kinit1(end, P2V(4*1024*1024)); // phys page allocator
kvmalloc(); // kernel page table
mpinit(); // detect other processors
lapicinit(); // interrupt controller
seginit(); // segment descriptors
picinit(); // disable pic
ioapicinit(); // another interrupt controller
consoleinit(); // console hardware
uartinit(); // serial port
pinit(); // process table
tvinit(); // trap vectors
binit(); // buffer cache
fileinit(); // file table
ideinit(); // disk
startothers(); // start other processors
kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()
userinit(); // first user process
mpmain(); // finish this processor's setup
}
...
...
####################################################################################################
// proc.c
...
...
//PAGEBREAK: 32
// Set up first user process.
void
userinit(void)
{
struct proc *p;
extern char _binary_initcode_start[], _binary_initcode_size[]; // --- 0
p = allocproc(); // --- 1
initproc = p;
if((p->pgdir = setupkvm()) == 0) // --- 2
panic("userinit: out of memory?");
inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size); // --- 3
p->sz = PGSIZE;
memset(p->tf, 0, sizeof(*p->tf));
p->tf->cs = (SEG_UCODE << 3) | DPL_USER;
p->tf->ds = (SEG_UDATA << 3) | DPL_USER;
p->tf->es = p->tf->ds;
p->tf->ss = p->tf->ds;
p->tf->eflags = FL_IF;
p->tf->esp = PGSIZE;
p->tf->eip = 0; // beginning of initcode.S
safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");
// this assignment to p->state lets other cores
// run this process. the acquire forces the above
// writes to be visible, and the lock is also needed
// because the assignment might not be atomic.
acquire(&ptable.lock);
p->state = RUNNABLE;
release(&ptable.lock);
}
...
...: 먼저 주목할 만한 부분은 유저 프로세스 또한 `alloproc`을 통해서 커널 영역을 할당받는 것이다. `alloproc`은 커널 스택 영역을 할당해주는 함수다. 프로세스의 `트랩 프레임`, `컨택스트`, `초기 시작 함수(forkret)`등을 설정한다. 그리고 `setupkvm`을 통해서 `KERNBASE(0x8000_0000)` 위쪽에 256MB(`kmap` 참고) 커널 영역에 대한 가상 주소를 할당한다. 이걸 유저 프로세스에 할당하고 있다.
: 그리고 나서 `inituvm`을 통해서 가상 주소 0번지에 모든 유저 프로세스의 시작 프로그램인 `initcode` 바이너리를 할당한다. 해당내용은 이 글을 참고하자. `inituvm`을 살펴보자.
// vm.c
...
...
// Load the initcode into address 0 of pgdir.
// sz must be less than a page.
void
inituvm(pde_t *pgdir, char *init, uint sz)
{
char *mem;
if(sz >= PGSIZE)
panic("inituvm: more than a page");
mem = kalloc();
memset(mem, 0, PGSIZE);
mappages(pgdir, 0, PGSIZE, V2P(mem), PTE_W|PTE_U);
memmove(mem, init, sz);
}
...
...: 이제 조금 재미있는 부분이 나온다. `mappages`에 전달하는 인자들을 보면 가상 주소의 시작 주소로 0을 집어넣고, 방금 새로 할당받은 `mem`의 물리 시작 주소를 인자로 보낸다. 즉, 이 의미는 가상 주소 `[0:4095] - [mem:mem+4095]` 에 매핑시키는 것이다.
: 이해를 위해 `mem`의 값이 `0x80C0_C000` 이라고 해보자. 여기서 `mappages`를 거치면, 다음과 같은 과정이 진행된다.
1" 가상 주소 `0` 의 상위 10비트로 페이지 디렉토리 엔트리를 검색(PDE[0 >> 22]). 그리고 해당 엔트리에서 페이지 테이블을 시작 주소를 참조.
2" 가상 주소 `0`의 중간 12비트로 페이지 테이블 엔트리를 검색(PTE[0 >> 12]). 그리고 해당 PTE를 반환.
3" `mappages`에서 `walkpgdir`에서 반환한 PTE에 mem의 물리 주소 `0xC0_C000`을 할당.: 위의 과정을 통해 가상 주소 `[0:4095]`는 물리 주소 `[0xC0_C000:0xC0_CFFF]`에 대응되게 된다. `mappages`를 거치지지 않고, `memmove`를 할 경우 제일 먼저 CPU는 `mem`의 물리 주소를 찾게되는데, 이 `mem`에 대응하는 PDE[0 >> 22]의 `P`비트가 0인 것을 확인하고는 페이지 폴트를 발생시킬 것이다. 그래서 반드시 순서는 `mappages`후에 `memmove`가 되어야 한다. 참고로, `memmove`는 `memcpy`와 동일한 함수이다.
: 그렇면, `userinit` 함수를 통해 만들어진 최초의 유저 프로세스는 어떻게 실행될까? `xv6`는 `main` 함수에서 `userinit` 후에, `mpmain`을 호출한다. 그리고 `mpmain` 함수안에는 `scheduler` 함수가 있는데, 이 함수를 호출하면 프로세스 스케줄러가 동작한다.
// main.c
...
main(void)
{
kinit1(end, P2V(4*1024*1024)); // phys page allocator
...
...
userinit(); // first user process
mpmain(); // finish this processor's setup
}
...
...
// Common CPU setup code.
static void
mpmain(void)
{
cprintf("cpu%d: starting %d\n", cpuid(), cpuid());
idtinit(); // load idt register
xchg(&(mycpu()->started), 1); // tell startothers() we're up
scheduler(); // start running processes
}
...
...: 스케줄러는 `p->state = RUNNABLE` 인 상태의 프로세스가 존재하면, 해당 프로세스를 실행시킨다. `userinit`에서 봤겠지만, `userinit`은 최초로 만든 유저 프로세스의 상태를 `RUNNABLE`로 설정한다. 즉, 만들자 마자 곧 바로 스케줄러의 대상이 될 수 있게한다. 그래서 스케줄러를 실행되지마자 최초의 유저 프로세스를 실행하게 된다.
: 스케줄러의 상세한 내용은 다른 글을 통해서 설명한다. 여기서는 `switchuvm` 함수에 대해 설명한다. `switchuvm`은 유저 프로세스가 실행되기 전에 TSS를 설정하는 함수다. TSS는 x86 기반의 시스템 하드웨어 자료 구조다. 스케줄러는 프로세스를 실행시키기 전에 이걸 매번 설정하는 이유가 뭘까? 몇 가지가 이유가 있다.
1" x86에서 TSS는 `낮은 권한 -> 높은 권한`으로 바뀌는 구간에서만 참조된다. 인텔문서 `스택 스위칭`에 따르면, 유저 레벨에서 커널 레벨로 실행 제어가 바뀔 경우, 새로운 스택을 생성한다고 한다. 즉, 앞에서 언급한 새로운 스택은 TSS에 SS0, ESP0을 참고해서 만들어진다. 여기서 SS0, ESP0은 커널의 스택 영역을 가리키고 있다. 시스템 콜이 `int n`으로 만들어지기 때문에, 인터럽트에 의한 권한 변경으로 실행되는 프로세스라는 점을 참고하자. 자세한 내용은 `익셉션`, `xv6 시스템 콜`과 `TSS`를 참고하자.
2" 그리고 `switchkvm`을 보면, `scheduler` 함수에서 `RUNNABLE` 프로세스가 있을 때 마다, 수행한다. 즉, 컨택스트 스위칭이 발생할 때마다, 새로운 TSS를 생성한다. 재사용은 왜 안하는 것일까? 그 이유는 스택 포인터(SSn, ESPn)는 TSS에서 정적 필드에 속하기 때문이다. TSS는 동적 필드와 정적 필드가 있는데, 정적 필드는 TSS가 최초에 생성될 때, 한 번 쓰고 값이 바뀌지 않는 필드다. 정적 필드가 TSS가 생성되는 시점에 한 번만 쓰여지고 계속 바뀌지 않고 사용되기 때문에, 프로세스가 만들어질 때마다 매번 새로 생성하는 것이다. 왜냐면, 커널 프로세스는 자신만의 고유한 스택 영역이 있기 때문이다.: TSS를 생성한 후에, `LTR`을 통해서 새로운 TSS를 로드하고, 현재 CPU에 CR3에 로드되어 있을 스케줄러 페이지 테이블을 이제 동작할 프로세스의 페이지 테이블로 교체한다.
// proc.c
//PAGEBREAK: 42
// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns. It loops, doing:
// - choose a process to run
// - swtch to start running that process
// - eventually that process transfers control
// via swtch back to the scheduler.
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
// Enable interrupts on this processor.
sti();
// Loop over process table looking for process to run.
acquire(&ptable.lock);
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->state != RUNNABLE)
continue;
// Switch to chosen process. It is the process's job
// to release ptable.lock and then reacquire it
// before jumping back to us.
c->proc = p;
switchuvm(p);
p->state = RUNNING;
swtch(&(c->scheduler), p->context);
switchkvm();
// Process is done running for now.
// It should have changed its p->state before coming back.
c->proc = 0;
}
release(&ptable.lock);
}
}
...
...
####################################################################################################
// vm.c
...
...
// Switch TSS and h/w page table to correspond to process p.
void
switchuvm(struct proc *p)
{
if(p == 0)
panic("switchuvm: no process");
if(p->kstack == 0)
panic("switchuvm: no kstack");
if(p->pgdir == 0)
panic("switchuvm: no pgdir");
pushcli();
mycpu()->gdt[SEG_TSS] = SEG16(STS_T32A, &mycpu()->ts,
sizeof(mycpu()->ts)-1, 0);
mycpu()->gdt[SEG_TSS].s = 0;
mycpu()->ts.ss0 = SEG_KDATA << 3;
mycpu()->ts.esp0 = (uint)p->kstack + KSTACKSIZE;
// setting IOPL=0 in eflags *and* iomb beyond the tss segment limit
// forbids I/O instructions (e.g., inb and outb) from user space
mycpu()->ts.iomb = (ushort) 0xFFFF;
ltr(SEG_TSS << 3);
lcr3(V2P(p->pgdir)); // switch to process's address space
popcli();
}: 그러면, 유저 프로세스는 무엇을 실행하게 될까? `userinit`에서 보면, 최초의 유저 프로세스의 p->context->eip = 0`임을 볼 수 있다. 즉, 가상 주소 0번지를 실행한다는 것이다. `inituvm`에서 봤겠지만, 가상 주소 0 번지에는 `initcode.S`의 내용이 들어있다. 그러면, 이제 `initcode.S`를 살펴보자.
// initcode.S
# Initial process execs /init.
# This code runs in user space.
#include "syscall.h"
#include "traps.h"
# exec(init, argv)
.globl start
start:
pushl $argv
pushl $init
pushl $0 // where caller pc would be
movl $SYS_exec, %eax
int $T_SYSCALL
# for(;;) exit();
exit:
movl $SYS_exit, %eax
int $T_SYSCALL
jmp exit
# char init[] = "/init\0";
init:
.string "/init\0"
# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0
####################################################################################################
// traps.h
...
#define T_SYSCALL 64 // system call
...
####################################################################################################
// syscall.h
...
#define SYS_exec 7
...
####################################################################################################
// makefile
...
...
initcode: initcode.S
$(CC) $(CFLAGS) -nostdinc -I. -c initcode.S
$(LD) $(LDFLAGS) -N -e start -Ttext 0 -o initcode.out initcode.o
$(OBJCOPY) -S -O binary initcode.out initcode
$(OBJDUMP) -S initcode.o > initcode.asm
kernel: $(OBJS) entry.o entryother initcode kernel.ld
$(LD) $(LDFLAGS) -T kernel.ld -o kernel entry.o $(OBJS) -b binary initcode entryother
$(OBJDUMP) -S kernel > kernel.asm
$(OBJDUMP) -t kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > kernel.sym
...
...: `initcode.S`의 맨 처음 시작은 아쉽게도 `start` 심볼이 아니다. 실제 `initcodeS`의 시작은 `#include "syscall.h"` 내용들이 된다. 그런데, 이 내용들은 텍스트가 아닌 데이터다. 어셈블리에서 아무 섹션도 선언하지 않으면, 기본적으로 해당 어셈블리 파일은 전부 텍스트 섹션으로 처리된다. 문제는 `initcode.S`에서 텍스트(명령어)는 실제로 얼마되지 않는다는 것과 CPU는 텍스트 섹션으로 선언된 모든 부분들을 실행하는데, 그러다보니 `initcode.S`안에 텍스트가 아닌 데이터들까지 텍스트로 인식해서 실행시켜버린다. 이러면 시스템 크래쉬가 발생한다. 그래서 `Makefile`에 이 문제에 대한 해결책이 명시되어 있다.
: 타겟 `initcode`를 보면 링킹 옵션에 `-e start`와 `-Ttext 0`임에 주목해야 한다. 링커가 실행 파일을 만들 때, 여러 목적 파일들을 받아서 재배치를 진행하는데, 그 때 `-Ttext 0` 옵션을 통해서 `initcode`를 가상 주소 0 번지에 배치하도록 한다. 그리고 순수 바이너리 파일인 `BIN` 파일로 만들경우 프로그램의 시작 주소는 자동으로 프로그램의 가장 맨 앞 부분이 되는데, 목적 파일 포맷으로 생성해서 프로그램의 시작을 임의의로 지정하도록 할 수 있다. 그 때 `-e start`를 통해서 `initcode.S`의 엔트리 포인트를 `start` 심볼로 설정하고 있다.
: 그리고 최초 유저 프로세스가 `initcode.S`에서 하는 일은 `exec 시스템 콜`을 호출하는 것이다. `시스템 콜`의 호출 과정은 이 글을 참고하자. 우리는 `exec 시스템 콜`이 어떻게 호출되는지를 알아보자. 근데, 글이 너무 길어졌으니 2부로 넘어간다.
// syscall.c
...
...
static int (*syscalls[])(void) = {
...
[SYS_exec] sys_exec,
...
};
void
syscall(void)
{
int num;
struct proc *curproc = myproc();
num = curproc->tf->eax;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
curproc->tf->eax = syscalls[num]();
} else {
cprintf("%d %s: unknown sys call %d\n",
curproc->pid, curproc->name, num);
curproc->tf->eax = -1;
}
}'프로젝트 > 운영체제 만들기' 카테고리의 다른 글
xv6 - System Call & Traps (0) 2023.07.12 xv6 - Init process (0) 2023.07.12 TSS (0) 2023.07.06 [x86] Unreal mode (0) 2023.06.28 [x86] 멀티 부트 (0) 2023.06.27