进入kernel后最先执行的代码位于entry.S中。

该文件的最开头是一个multiboot_header,它遵循Multiboot标准[1]以让一个boot loader(比如grub)可以引导多种操作系统。

entry.S的第一个任务就是启动分页机制,从而可以使用虚拟地址空间。xv6的虚拟地址空间如图所示[2]:

img

kernel.ld脚本中指定了kernel应被链接到0x80100000处,即在虚拟地址空间中应处在的位置。但加载内核时并没有把kernel放在物理内存中的这个位置,而是放在了0x100000处(同样在kernel.ld中指定),这是因为硬件不一定有这么大的物理内存。

1
2
3
4
5
# 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)

在启动分页机制之前,虚拟地址和物理地址之间是恒等映射,故不能通过pc=0x80100000来访存,必须使用物理地址。在entry.S中将_start符号定义为首条代码的物理地址(0x10000c),该符号就是ELF的入口点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 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

通过设置CR4中的PSE位,使分页机制同时支持4KB和4MB的页。随后将一个初始页表entrypgdir的(物理)地址读入CR3寄存器,再通过打开CR0中的PG位(和WP位)启动分页。

1
2
3
4
5
6
7
8
9
10
11
12
// 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] = {
// 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,
};

entrypgdir的内容如图所示,它将[0, 4MB]的虚拟地址做了一个恒等映射,这是因为分页机制已经启动,但代码目前还在这里执行,如果没有这个映射MMU无法工作。另一个映射是将KERNBASE(0x80100000)起始的4字节页映射到[0, 4MB]的物理地址处,至此,内核(的起始4MB)在虚拟地址空间中已经位于正确的位置。

1
2
3
4
5
6
7
8
9
10
11
  # 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

.comm声明了一个名为stack,大小为KSTACKSIZE的变量作为kernel stack供main使用[5]。作为一个未赋初值的全局变量,它位于.bss节(可以用readelf验证)。随后将esp寄存器设置为kernel stack的顶部,并跳转到main函数入口(注意没有直接使用call main,而是直接取了main函数的入口地址并跳转过去)。

TIPS:

  1. CR0寄存器中WP(write protect)的作用可参见[3],主要用于实现copy-on-write(COW)机制。COW的一个例子是fork时不会将父进程的整个地址空间拷贝一份给子进程,而是将共享的页在页表中标记为read only(not writable),对应同样的物理地址。一旦某个进程尝试写入这些共享页,就会因无写入权限而触发page fault,由内核将共享页copy后进行写入。

    然而,进程可能在内核态下触发COW(比如在read()系统调用中写入用户的输入),而内核不会受到页表read only的限制,不会触发page fault,也就无从得知COW已经发生且需要进行页拷贝。引入WP位后,若WP置位,内核态下对read only page的访问也会受限:内核可以得知自己正在修改只读页,并执行COW动作。若WP不置位,内核可以对read only page进行修改。

    至于为什么不让内核一直受到页表只读的限制,而要引入WP位呢?如果这样的话,最高一级页表中囊括了其自身物理地址的页表项一旦被标记为not writable,就永远无法被修改。因为这个页表项物理上就位于这一页内部,想要修改就要把writable置位,而这个写操作又被not writable限制了,形成了死锁。有了WP位后,内核可以先将WP清零再修改writable位。

  2. 与分页机制相关的部分结构如图所示[4]:

img
  1. 之所以最后跳转到main函数时没有使用call main,是因为这样会生成一个PC-relative的跳转指令:

    1
    8010002d:	e8 0e 30 00 00       	call   80103040 <main>

    但实际执行时,PC并非0x8010002d,而是0x10002d,因此这个指令会跳转到main入口的物理地址而非虚拟地址处,不符合预期。


  1. Multiboot Specification
  2. xv6-book
  3. whats the purpose of x86 cr0 WP bit?
  4. Understanding 4M Page Size Extensions on the Pentium Processor
  5. x86 Assembly Language Reference Manual