前言
xv6是x86处理器上用ANSI标准C重新实现的Unix第六版(Unix V6,通常直接被称为V6),本文主要对xv6的bootstrap实现部分的源代码进行分析,分析一下机器开机之后,从BIOS引导boot loader,boot loader从磁盘加载内核到内存,到执行内核程序的全过程。
计算机内存布局
在涉及源代码前,先介绍一下计算机内存布局:
在早期的x86计算机里,用的是16位的Intel 8088处理器,寻址空间只有1MB,也就是图中所示0x00000000到0x000FFFFF的地址空间。其中还有一部分空间是系统保留的,其中最主要的就是BIOS ROM。而用户能用到的内存空间主要就是0x00000000到0x000A0000这个640KB段。
现代计算机内存空间远远不止1M,而CPU字长也从16位发展到32位,64位。本文介绍的xv6操作系统,就是32位操作系统,运行在32位处理器上。32位处理器寻址空间可到4GB,这部分地址超出1MB的地址,就是图中的Extended Memory。
但所有的x86计算机都保持了向下兼容的特性,因此在刚开机的时候,CPU是模仿Intel 8088的工作模式工作的,最大寻址空间只有1M。xv6就需要在boot loader里把原始工作模式进行升级,使得寻址能力达到4GB。
BIOS引导
在计算机刚开机时候,内存除了BIOS ROM里的程序外没有任何程序。因此最开始必须由BIOS进行引导。当我们按下开机键,CPU执行的第一条指令位于内存F000:FFF0位置。此时CPU工作于实时模式,该模式会通过段寄存器CS与指令寄存器IP共同寻找指令所在的物理地址。计算方法是CS里的内容左移4位再加上IP里的内容,得到实际物理地址,这里BIOS第一条指令的物理地址是0xffff0。与上面的内存布局图对照,这个位置正是BIOS ROM区域的顶部。
这条指令是:
|
|
跳转到物理地址0xfe05b位置,执行后续的指令。这个也比较好理解,因为0xffff0比较接近0xfffff这个物理内存地址的最顶端,这么少的内存空间做不了什么事,这时候就转移一下代码的所在位置。然后,BIOS会进行一系列的硬件初始化工作。当这些工作都完成了,计算机的硬件都处在一个基础的就绪状态,就可以进行操作系统的引导了。xv6作为一个精简的unix操作系统,其boot loader在可启动磁盘上的第一个扇区,即第一个512字节的区域。BIOS会把这段代码拷贝到物理地址0x7c00到0x7dff的内存空间中。这段代码就叫做boot loader,主要用于引导操作系统内核。
boot loader
BIOS设置cs寄存器为0x0,ip寄存器为0x7c00,开始执行boot loader程序。该程序可分为两部分,第一部分是汇编语言编写的,源代码在https://github.com/wjqwsp/xv6-public/blob/master/bootasm.S。第二部分是c语言编写的,源代码在https://github.com/wjqwsp/xv6-public/blob/master/bootmain.c。
bootasm.S
在引导内核之前,boot loader必须对CPU进行一些必要的设置,这部分代码就是完成这些初始化的工作,为后续c程序接管作准备。
基本初始化
|
|
这是boot loader最开始的代码。首先禁用中断响应。在BIOS执行时,是开启了中断的,这里必须把中断禁用,以防引导过程被干扰。
然后把ax,ds,es,ss寄存器都清零,作为初始化。
打开A20 Gate
在段寄存器加偏移量的内存地址计算方式里,最多可以用到21位的地址,但在实时模式下CPU只支持20位的地址寻址。那么多出来的一位是通过地址卷绕机制忽略的,简单来说就是忽略第21位地址,例如物理地址0x10ffef会被当做0x0ffef。
在实时模式下,每个地址段的只能用16位偏移量表示,只有64KB大小,这样如果执行大型程序的话则很不方便,往往需要跨多个地址段。而且寻址空间是在太小,完全不能满足现代计算机的需要。因此,现代的CPU还提供了一种保护模式,能够大幅度提升寻址空间,并且用32位来表示物理地址。
要开启保护模式,首先要禁用地址卷绕,也就是开启编号为20的地址线,即第21位地址。xv6采用的方法是键盘控制器法,通过输出命令到键盘控制器的IO接口,控制A20的开启与关闭。
|
|
与键盘控制器有关的IO接口是0x60和0x64。其中0x64起到一个状态控制的功能,0x60则是数据端口。首先需要检查键盘控制器是否忙碌,例如是否正有键盘输入等等。这个状态检查是通过读取0x64实现的。通过检查该状态数据的低第二个比特位是否为高,来判断是否忙碌。等到该比特位为低,就可以向0x64写命令了。
向0x64写入命令0xd1,该命令用于指示即将向键盘控制器的输出端口写一个字节的数据。
再检查0x64,判断键盘控制器是否忙碌。等不忙碌后,就可以向0x60写入数据0xdf。该数据代表开启A20。
GDT的设置
前面说到,在实时模式下,每个地址段只有64KB。而保护模式则采用了段模式进行内存管理。该模式会通过一个全局的段描述符表(GDT)来进行内存分段。这里的段不再有64KB的限制,而可以指定更大的段。该段描述符表的每一项叫做段描述符,每项占8个字节。在保护模式下,物理地址的计算不再采用段寄存器加偏移量的方式,而用段寄存器来存储段描述符的索引,通过该索引找到段描述符,再通过段描述符找到物理地址段。下面先给出段描述符的结构,该结构参考自leenjewel Blog里的【学习xv6】从实模式到保护模式
3块基地址组合起来刚好是32位地址,两块limit段共20个比特,代表该内存段的长度。剩余其他比特位都是一些属性信息。
- P: 0 本段不在内存中
- DPL: 访问该段内存所需权限等级 00 — 11,0为最大权限级别
- S: 1 代表数据段、代码段或堆栈段,0 代表系统段如中断门或调用门
- E: 1 代表代码段,可执行标记,0 代表数据段
- ED: 0 代表忽略特权级,1 代表遵守特权级
- RW: 如果是数据段(E=0)则1 代表可写入,0 代表只读;如果是代码段(E=1)则1 代表可读取,0 代表不可读取
- A: 1 表示该段内存访问过,0 表示没有被访问过
- G: 1 表示 20 位段界限单位是 4KB,最大长度 4GB;0 表示 20 位段界限单位是 1 字节,最大长度 1MB
- DB: 1 表示地址和操作数是 32 位,0 表示地址和操作数是 16 位
- XX: 保留位永远是 0
- AA: 给系统提供的保留位
要启用保护模式,首先需要设置好GDT,下面看一下xv6的bootloader如何设置GDT。
|
|
这里的gdt标号就是GDT的地址。现在把宏全部翻译过来,看看xv6的GDT是怎么样的。
|
|
先定义了一个空的段描述符,64位全0。
然后定义代码段。下面看看代码段的结构:
DB=1表示地址和操作数都是32位,通过这项正式启用32位模式。G=1表示段界限单位是4KB,由于界限是0xfffff,则代码段的总长度为4KB*2^20=4GB,说明该代码段的寻址空间是4GB。DPL代表该段内存权限最高,相当于root。这是因为xv6是一款精简的操作系统,并没有高级的用户功能,因此这里不作特别设置。S=1与E=1代表是代码段。RW=1说明该代码段可读取。基地址是0x00000000,说明该段的物理地址范围是0~4GB。
数据段的定义也是类似,下面是数据段的结构:
这里与代码段不同的就是E=0,代表数据段。RW=1虽然与代码段相同,但表示的意义不同,对于数据段表示可读可写。
不难发现,代码段与数据段的寻址空间都是0~4GB。其实这里的段模式只是走个形式,并没有真正采用该模式来进行内存管理,而是简单地进行全部内存寻址。xv6实际上是用分页模式来管理内存的,这在后面会看到。
开启保护模式
GDT设置好以后,需要加载。CPU单独准备了一个寄存器GDTR来保存GDT在内存中的位置和我们GDT的长度,它共有48位。
|
|
lgdt指令会把gdtdesc地址的48比特内容加载到GDTR寄存器里。其中低16位用来存储GDT有多少个段描述符信息,单位是字节。16位可以表示65536个数,而每个段描述符需要8个字节,所以最多可以有8192个段描述符。高32位就是GDT的物理地址。
要开启保护模式,需要打开一个开关。该开关用cr0寄存器的最低位表示,该寄存器为1代表开启保护模式。
单纯启用保护模式,处理器仍然不能马上利用保护模式进行寻址。只有当cs段寄存器的值被更新以后,才会读取GDT表来进行逻辑地址到物理地址的映射。xv6采用以下方式更新cs段寄存器的值:
|
|
SEG_KCODE这个宏是1,该语句会把cs寄存器的值设为0x08。前面说到,最多可以有8192个段描述符,而16位的寄存器只需要用高13位即可表示8192个数。所以这里的索引是1,代表标号为1的内存段,这正是代码段。这时候会执行start32地址里的代码,正式开启32位模式与保护模式。
引导内核前的最后准备
|
|
把数据段的索引写入ds,es,ss寄存器。把ax,fs,gs寄存器清零。将start代表的地址写入堆栈的偏移寄存器,代表堆栈指针。start是boot loader的第一条指令所在内存位置,即物理地址0x7c00,而堆栈是向下生长的,就不会与boot loader所在内存产生重叠。然后就可以调用bootmain函数,正式进入c程序阶段。
bootmain一般是不返回的,除非出现错误。这时候就可以在bootmain.S里编写一些错误处理或者日志汇报的代码。xv6并没有做多少这方面的工作,在向0x8a00这个IO接口写某些数据之后,主要就是进入一个死循环。
bootmain.c
这部分c代码的主要作用是加载内核文件到内存中。
加载ELF头部与程序头表
kernel是一个ELF格式的可执行文件,它遵守标准的ELF格式。我们暂时关心的就是ELF头部与程序头表,通过把它们从磁盘里加载到内存中,就可以让内核正式接管计算机了!
kernel文件的ELF头部从启动磁盘的第二个扇区开始。前面已经说到,第一个扇区512字节就是boot loader。ELF头部与程序头表大小是4KB。
|
|
xv6把内核文件加载到物理内存0x10000开始的位置,也就是本文第一张图里的Extended memory部分,正好与low memory里的boot loader和堆栈区错开。bootmain函数首先加载ELF头部,从磁盘里加载ELF头部主要就是用的readseg()这个函数。
|
|
这个函数的功能是从磁盘里某个扇区开始,把数据加载到内存的pa位置。从代码可以看出,是一个扇区一个扇区地按顺序读数据的。首先要保证pa在完整扇区的头部,如果不是,则通过对扇区头部的偏移量来计算出完整扇区头部的地址。这里读ELF头部时,是从磁盘第一个扇区的头部开始的,读4096字节。读某个扇区的数据由readsect()函数完成。
|
|
xv6作为精简的Unix操作系统,只针对IDE接口的磁盘读写,默认启动磁盘就是采用IDE接口的。要阐述从磁盘读数据的方法,先给出IDE的IO接口对应的寄存器参数。
|
|
在读取扇区数据之前,先用waitdisk()函数检查磁盘是否准备好。
|
|
这里在c语言里调用了汇编命令来进行磁盘读写。读取IO接口0x1F7,检查IDE磁盘的状态寄存器高两位。如果最高为为0,高二位为1,说明磁盘准备好,可以读数据了。
磁盘就绪后,用LBA寻址方式来寻找扇区,offset变量就是表示第几个扇区,从第一个扇区开始,拷贝4KB数据到内存,一个一个扇区地拷贝。需要先向接口0x1F2,0x1F3,0x1F4,0x1F5,0x1F6写入扇区编号。然后向0x1F7写入命令0x20,代表读扇区。
等待磁盘再次准备好后,就可以用insl()函数读取扇区数据了。
|
|
这段代码仍然是调用了汇编命令,做了以下几件事:
- 把DF寄存器清零。
- 把循环次数写进cx计数寄存器中。
- 循环执行insl指令,把IO接口0x1F0的数据读取并写到对应的内存区域上,每次读取4个字节。
- 每次循环会让cx计数寄存器的值减1,并更新DI寄存器的值,让它加4。
- 这样,在循环结束时候,刚好读取完1个扇区的所有数据。
读取完ELF头部后,检查magic字段,看是否真的是一个ELF文件。
加载内核程序
ELF头部与程序头表加载到内存以后,下一步就是加载内核程序了。这里再引用一下bootmain()函数的代码。
|
|
利用ELF头部的phoff字段,找出程序头表的内存地址。然后用ELF头部的phnum字段找出一共有多少个程序入口。循环每个程序入口,用程序头表的paddr参数找出相应内核程序的位置。然后还是用readseg()函数把磁盘里的程序加载到内存中。filesz字段代表程序在文件中的长度,off代表程序在文件中的偏移量,可以通过该偏移量计算出程序在哪个扇区。
memsz字段代表程序在内存中的长度,如果在内存中长度比在文件中长,则要用stosb()函数在后面补0。
|
|
这个函数的循环逻辑类似insl()函数,循环地向内存中程序段后面填0,直到程序段大小与memsz一致。
在这些都加载完成后,就可以执行内核入口程序了!入口程序的物理地址记录在elf头部中。之后,就是内核接管计算机的时代了!bootstrap引导操作正式完成!