xv6启动过程(2)
进入kernel后最先执行的代码位于entry.S
中。
该文件的最开头是一个multiboot_header,它遵循Multiboot标准[1]以让一个boot loader(比如grub)可以引导多种操作系统。
entry.S的第一个任务就是启动分页机制,从而可以使用虚拟地址空间。xv6的虚拟地址空间如图所示[2]:
kernel.ld
脚本中指定了kernel应被链接到0x80100000处,即在虚拟地址空间中应处在的位置。但加载内核时并没有把kernel放在物理内存中的这个位置,而是放在了0x100000处(同样在kernel.ld
中指定),这是因为硬件不一定有这么大的物理内存。
1 | # By convention, the _start symbol specifies the ELF entry point. |
在启动分页机制之前,虚拟地址和物理地址之间是恒等映射,故不能通过pc=0x80100000来访存,必须使用物理地址。在entry.S
中将_start
符号定义为首条代码的物理地址(0x10000c),该符号就是ELF的入口点。
1 | # Entering xv6 on boot processor, with paging off. |
通过设置CR4中的PSE位,使分页机制同时支持4KB和4MB的页。随后将一个初始页表entrypgdir
的(物理)地址读入CR3寄存器,再通过打开CR0中的PG位(和WP位)启动分页。
1 | // The boot page table used in entry.S and entryother.S. |
entrypgdir
的内容如图所示,它将[0,
4MB]的虚拟地址做了一个恒等映射,这是因为分页机制已经启动,但代码目前还在这里执行,如果没有这个映射MMU无法工作。另一个映射是将KERNBASE
(0x80100000)起始的4字节页映射到[0,
4MB]的物理地址处,至此,内核(的起始4MB)在虚拟地址空间中已经位于正确的位置。
1 | # Set up the stack pointer. |
.comm
声明了一个名为stack
,大小为KSTACKSIZE
的变量作为kernel
stack供main
使用[5]。作为一个未赋初值的全局变量,它位于.bss
节(可以用readelf
验证)。随后将esp寄存器设置为kernel
stack的顶部,并跳转到main
函数入口(注意没有直接使用call main
,而是直接取了main
函数的入口地址并跳转过去)。
TIPS:
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位。
与分页机制相关的部分结构如图所示[4]:
之所以最后跳转到
main
函数时没有使用call main
,是因为这样会生成一个PC-relative的跳转指令:1
8010002d: e8 0e 30 00 00 call 80103040 <main>
但实际执行时,PC并非0x8010002d,而是0x10002d,因此这个指令会跳转到
main
入口的物理地址而非虚拟地址处,不符合预期。