xv6的系统引导过程。这部分主要介绍从系统上电到进入C语言的世界(main())的过程,
内容包括引导扇区、内核img的加载、保护模式以及MMU的开启等。
启动扇区
系统上电之后,无论从硬盘启动还是从光驱启动,或者其他别的启动方式,BIOS都会把
启动扇区的512个字节复制到内存的0x7c00处,然后CPU从%cs=0 %ip=0x7c00
处开始执行,
所以操作系统需要做的就是制作一个512字节大小的启动扇区,这里的代码做些必要的工作
之后就跳转到操作系统内核处开始执行,这样操作系统就开始运行起来了。
启动扇区的工作主要有:
- enable A20
- set
protection mode bit
incr0
- 进入32位模式(保护模式)并复制系统内核到内存
- 跳转到内核代码继续执行
对于xv6来说,前两个阶段在bootmasm.S
中完成,后两个在bootmain.c
中完成。
打开A20
什么是A20?详见维基百科页面。
A20就是”Adress line 20”,以前的x86 CPU是16位的,但是Intel将其地址总线扩展
到20条,即A0~A19,这样内存寻址就能达到1MB(2^20),由于其采用分段机制,即
段地址+段内偏移=实际内存地址,段内偏移用16位表示,而段寄存器也是16位的,
这样两者组合起来很容易就超过1MB,但是只有20条地址总线,于是超过1MB的部分
被自然而然的忽略掉了,即地址自动回旋,如0x0010:0x0001
等于0x0001
,
当时很多代码会依赖于此。
但是到了保护模式,地址总线达到32位,以上的两个地址不再相等,于是在real mode
下,第21位地址总线(A20)是被禁用的,这样两者再次等同。
如果要进入32位模式,必须要把A20打开,否则寻址能力达不到4GB。这就是原因所在。
1 | # Physical address line A20 is tied to zero so that the first PCs # with 2 MB would run software that assumed 1 MB. Undo that. seta20.1: inb $0x64,%al # Wait for not busy testb $0x2,%al jnz seta20.1 movb $0xd1,%al # 0xd1 -> port 0x64 outb %al,$0x64 seta20.2: inb $0x64,%al # Wait for not busy testb $0x2,%al jnz seta20.2 movb $0xdf,%al # 0xdf -> port 0x60 outb %al,$0x60 |
启动保护模式
内存地址是段地址+段内偏移,在real mode下,段地址就放在段寄存器中,而到了保护模式下,
段寄存器中保存的不再是段地址,而是GDT(global descriptor table)中的偏移量,所以进入
保护模式时首先需要创建GDT。
GDT,全局描述符表,它类似于一个数组,每个项占用8个字节,每个段对应于一个表项,表项中
保存着段的基址,段的大小,访问权限等信息,每次访问内存时,CPU根据内存地址定位到具体
的段,然后根据这些信息检查内存访问是否合法,如往一个只读段写入数据就会导致CPU抗议进而
罢工,这就是保护模式名称的由来。
我们首先来创建GDT,它的第一个表项必须是空的,这是规定,作为一个临时的GDT,我们只为
code和data创建两个段即可,
1 | # Bootstrap GDT .p2align 2 # force 4 byte alignment gdt: SEG_NULLASM # null seg SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg gdtdesc: .word (gdtdesc - gdt - 1) # sizeof(gdt) - 1 .long gdt # address gdt |
然后就可以启动保护模式了,启动方式也很简单,只需把cr0
寄存器中的PE
位置位即可,
1 | lgdt gdtdesc movl %cr0, %eax orl $CR0_PE, %eax movl %eax, %cr0 |
进入32位模式
保护模式已经启动,接下来通过一个ljmp
跳转,ljmp $(SEG_KCODE<<3), $start32
,ljmp
的语法:ljmp segment offset
,它会同时设置段寄存器和段内偏移(EIP寄存器),
而在32位模式下,段寄存器保存的是GDT里的偏移量,所以这里段寄存器的值是SEG_KCODE<<3
,
即数字8(SEG_KCODE等于1),因为GDT的第一个表项必须为空,所以代码段占据第二个表项。
进入32位模式之后,设置各个段寄存器,数据段占用GDT第三个表项,ES, SS等同于数据段,
其他不用的段置零。
1 | movw $(SEG_KDATA<<3), %ax # Our data segment selector movw %ax, %ds # -> DS: Data Segment movw %ax, %es # -> ES: Extra Segment movw %ax, %ss # -> SS: Stack Segment movw $0, %ax # Zero segments not ready for use movw %ax, %fs # -> FS movw %ax, %gs # -> GS |
然后进入C语言的世界复制内核镜像,进入C的世界需要调用函数,必不可少的就是stack,
所以首先要设置stack,movl $start, %esp
,即给esp寄存器设置,$start为bootasm.S
最开始的一个label,但是为何要将esp设置为$start呢,这是因为$start是代码的开始
位置,代码是向高内存地址处放置的,而stack是向低内存地址增长的,所以这样设置之后,
代码和栈向相反方向扩张,两者互不干涉。另外,当call bootmain
的时候,首先要做的
就是movl %esp %ebp
,即将esp的值设置给ebp,这样ebp保存的就是$start的值,也就是
函数栈中的第一个函数是$start
,这符合逻辑。
复制内核镜像
接下来进入C语言的世界,复制内核镜像,复制是通过IO port进行的,一个扇区一个扇区的
复制,这个我们不管,我们关注的是:内核镜像放在内存的什么位置。
内核镜像是ELF格式的,elf文件中保存着代码的物理内存地址,它规定了该把内核放置到
哪里,于是我们读取扇区数据,并按照elf格式解析数据,并将其放置到它要求的物理内存
处即可,这个物理内存是在链接脚本(kernel.ld)中设置的,即1MB处。
1 | /* 内核入口点,保存在elf header的entry属性中 */ |
也就是说,内核代码中用的都是虚拟地址,如main函数的地址就是0x801xxxxx
,objdump -d kernel
看看就知道了,所有函数、变量都是虚拟地址,但是ENTRY保存的
却是物理地址,所以可以直接调用_start,而不用担心找不到它。
1 | // 调用_start,进入内核的世界,它位于entry.S文件中 |
到此为止,内存中的布局大致是这个样子的,
1 | /* |
\x55\xAA
是什么?它是启动扇区的标志,所有启动扇区最后的两个字节必须是这两个字节。
启动扇区怎么制作呢?
如果生成启动扇区,如何生成内核镜像,这些要到Makefile文件中去寻找答案。
1 | xv6.img: bootblock kernel fs.img |
kernel就是内核镜像文件,而bootblock是启动扇区的内容。
系统引导部分至此结束,接下来从entry.S::_start开始分析内核代码。
(over)