7 min read

xv6 booting

xv6를 한 달 정도 다루다 보면 커널의 일부 부분에는 익숙해지셨을 겁니다. 예를 들어 syscall을 만드는 법 같은 건 이제 아시겠죠. 하지만 여전히 커널이 어떻게 로드되고 실행되는지 궁금하신 분들이 계실 겁니다.

이 글은 부팅 과정과 Virtual Memory 초기화 과정을 간략하게 다룹니다. 강의에서는 자세히 배우지 않았지만, P4 과제를 완료하는 데 필요할 수 있는 내용들입니다.

부팅 버튼부터 entry()까지

Bootloader 로드하기

프로젝트에서는 QEMU를 사용하지만, 베어메탈(Bare Metal) 머신을 사용한다고 가정해 봅시다. 컴퓨터를 부팅하기 위해 전원 버튼을 누릅니다.

이 전원 버튼은 마더보드에게 BIOS를 RAM으로 로드하고 실행하라는 신호를 보냅니다.

그러면 BIOS는 MBR(Master Boot Record, 512바이트)을 지정된 메모리 영역인 0x7c00으로 로드합니다. 이는 Makefile에서 확인할 수 있습니다.

bootblock: bootasm.S bootmain.c
    $(CC) $(CFLAGS) -fno-pic -O -nostdinc -I. -c bootmain.c
    $(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c bootasm.S
    $(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o
    $(OBJDUMP) -S bootblock.o > bootblock.asm
    $(OBJCOPY) -S -O binary -j .text bootblock.o bootblock
    ./sign.pl bootblock 
    # sign.pl will put a boot signature and make it 512 bytes.

이제 머신은 %cs=0, %ip=7c00 상태로 bootasm.S를 시작합니다.

현재 CPU는 하위 호환성을 위해 Real Mode(16비트) 상태입니다. 하지만 xv6는 32비트 운영체제이므로, 32-bit Protected Mode로 전환해야 합니다.

Real Mode에서 32-bit Protected Mode로

Real Mode의 간략한 특징은 다음과 같습니다.

  • 16비트
  • 20비트 메모리 주소 -> 최대 메모리 크기 1MB
  • Memory Segmentation
    • [segment:offset] 방식으로 메모리 주소 지정
    • Segment Register(cs, ds, es, ss) 사용 -> 16비트 세그먼트 번호
    • 각 세그먼트는 64KB 영역을 가짐 -> 겹치는(overlapping) 영역 존재

Protected Mode로 전환하기 위해 수행해야 할 몇 가지 단계가 있습니다. xv6가 이를 어떻게 처리하는지는 bootasm.S에서 확인할 수 있습니다.

  1. Interrupt를 비활성화해야 합니다.
  2. A20 line을 활성화해야 합니다.
  3. Global Descriptor Table을 로드합니다.
    • 이는 lgdt 명령어로 수행됩니다.
    • GDT 내용은 bootasm.S 하단에 gdtgdtdesc 레이블로 정의되어 있습니다.

이제 드디어 Protected Mode로 진입할 준비가 되었습니다.

  movl    %cr0, %eax
  orl     $CR0_PE, %eax
  movl    %eax, %cr0

cr0 레지스터의 CR0_PE 비트를 설정하여 모드를 활성화하고, 마침내 bootmain()을 호출합니다.

커널을 메모리로 로드하기

bootmain은 단순히 커널을 메모리로 로드하는 역할을 합니다. 부팅 시에는 512바이트만 로드할 수 있기 때문에, 커널 전체를 로드하기 위해 아주 작은 부트로더를 사용하는 것입니다. 자세한 내용은 bootmain.c를 참고하세요.

커널을 메모리에 성공적으로 로드한 후, entry.S에 정의된 entry()를 호출하여 마침내 커널로 점프합니다.

entry()에서 main()까지

방금 entry()를 호출했습니다. entry의 주된 작업은 Paging을 켜고, 아주 초기 단계의 Page Table을 만드는 것입니다.

xv6에서 초기 페이지는 하나의 4MB Huge Page와 하나의 Page Directory로 구성됩니다. 이 임시 Page Directory는 main.centrypgdir로 정의되어 있습니다.

entrypgdir의 코드는 다음과 같습니다.

__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = {
  // 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,
};

보시다시피 PDE에서 PTE_PS 플래그가 활성화되어 있어 4MB Huge Page를 사용하고 있습니다. 이 Physical Page는 0번지와 KERNBASE에서 시작하는 두 영역에 매핑됩니다.

entry()cr3 레지스터를 덮어써서 이 Page Directory를 사용하게 합니다.

  # Set page directory
  movl    $(V2P_WO(entrypgdir)), %eax
  movl    %eax, %cr3

이제 Page Directory가 있으니 드디어 Paging을 켤 수 있습니다.

  # Turn on paging.
  movl    %cr0, %eax
  orl     $(CR0_PG|CR0_WP), %eax
  movl    %eax, %cr0

%esp 레지스터를 로드하여 스택을 설정한 후, 마침내 main()으로 점프할 준비가 되었습니다.

main()

  • 여기서는 main()의 Kernel Virtual Memory 초기화 부분만 다룹니다.

지금까지 Paging을 활성화하고 단일 4MB Huge Page를 매핑했습니다. kallockfree로 이 4MB 영역을 사용하려면, end(커널 ELF의 끝)부터 4MB까지의 메모리 영역을 추가해야 합니다.

kinit1이 이 작업을 수행합니다. 이 함수는 freerange()를 사용해 4MB 영역의 페이지들을 Free List에 넣습니다. 아직 다른 스레드가 없고 인터럽트 컨트롤러도 설정하지 않았으므로, kmem 자료구조를 수정할 때 Lock을 사용할 필요가 없습니다.

그런 다음 이 4MB 페이지들을 가지고, 모든 물리 메모리를 커버할 수 있는 Page Table을 설정합니다.

kvmalloc()이 커널용 Page Table을 생성합니다. 내부적으로 setupkvm()을 호출하여 kmap을 기반으로 Virtual Memory 주소를 Physical Memory 주소에 매핑합니다. 이제 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
};

다른 모든 컴포넌트를 초기화한 후, 4MB부터 PHYSTOP까지의 메모리 영역을 Free List에 추가해야 합니다. 이것이 kinit2()가 하는 일입니다.

이제 모든 물리 페이지(Physical Page)를 커널 가상 주소(Kernel Virtual Address)에 성공적으로 매핑했습니다. 이제부터 모든 메모리를 사용할 수 있습니다.

마지막으로 xv6는 userinit()을 호출하여 첫 번째 유저 프로세스인 initcode를 생성합니다. 그 후 스케줄러를 로드하는 mpmain()을 호출합니다.

이로써 부팅 과정이 완료됩니다.

Q&A

    • 실제 시스템에서는 0xe820을 사용합니다. 하지만 xv6의 경우, 항상 충분한 물리 메모리가 있다고 가정하는 것으로 보입니다.
    • 물리 메모리 자체는 QEMUOPTS의 일부로 정의됩니다. Makefile에서 확인할 수 있습니다.
    • P4에서는 Huge Page 영역을 지원하기 위해 메모리를 2GB로 갖도록 옵션을 수정했습니다.

Q: 0부터 PHYSTOP까지의 메모리가 사용 가능하다는 걸 어떻게 알아요?

QEMUOPTS = -drive file=fs.img,index=1,media=disk,format=raw -drive file=xv6.img,index=0,media=disk,format=raw -smp $(CPUS) -m 512 $(QEMUEXTRA)
QEMUOPTS = -drive file=fs.img,index=1,media=disk,format=raw -drive file=xv6.img,index=0,media=disk,format=raw -smp $(CPUS) -m 2048 $(QEMUEXTRA)