xv6 bootstrap部分源代码分析

前言

  xv6是x86处理器上用ANSI标准C重新实现的Unix第六版(Unix V6,通常直接被称为V6),本文主要对xv6的bootstrap实现部分的源代码进行分析,分析一下机器开机之后,从BIOS引导boot loader,boot loader从磁盘加载内核到内存,到执行内核程序的全过程。

计算机内存布局

  在涉及源代码前,先介绍一下计算机内存布局:

memory laytou

  在早期的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区域的顶部。

  这条指令是:

1
ljmp $0xf000,$0xe05b

  跳转到物理地址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程序接管作准备。

基本初始化
1
2
3
4
5
6
7
8
9
10
.code16 # Assemble for 16-bit mode
.globl start
start:
cli # BIOS enabled interrupts; disable
# Zero data segment registers DS, ES, and SS.
xorw %ax,%ax # Set %ax to zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment

  这是boot loader最开始的代码。首先禁用中断响应。在BIOS执行时,是开启了中断的,这里必须把中断禁用,以防引导过程被干扰。

  然后把ax,ds,es,ss寄存器都清零,作为初始化。

打开A20 Gate

  在段寄存器加偏移量的内存地址计算方式里,最多可以用到21位的地址,但在实时模式下CPU只支持20位的地址寻址。那么多出来的一位是通过地址卷绕机制忽略的,简单来说就是忽略第21位地址,例如物理地址0x10ffef会被当做0x0ffef。

  在实时模式下,每个地址段的只能用16位偏移量表示,只有64KB大小,这样如果执行大型程序的话则很不方便,往往需要跨多个地址段。而且寻址空间是在太小,完全不能满足现代计算机的需要。因此,现代的CPU还提供了一种保护模式,能够大幅度提升寻址空间,并且用32位来表示物理地址。

  要开启保护模式,首先要禁用地址卷绕,也就是开启编号为20的地址线,即第21位地址。xv6采用的方法是键盘控制器法,通过输出命令到键盘控制器的IO接口,控制A20的开启与关闭。

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

  与键盘控制器有关的IO接口是0x60和0x64。其中0x64起到一个状态控制的功能,0x60则是数据端口。首先需要检查键盘控制器是否忙碌,例如是否正有键盘输入等等。这个状态检查是通过读取0x64实现的。通过检查该状态数据的低第二个比特位是否为高,来判断是否忙碌。等到该比特位为低,就可以向0x64写命令了。

  向0x64写入命令0xd1,该命令用于指示即将向键盘控制器的输出端口写一个字节的数据。

  再检查0x64,判断键盘控制器是否忙碌。等不忙碌后,就可以向0x60写入数据0xdf。该数据代表开启A20。

GDT的设置

  前面说到,在实时模式下,每个地址段只有64KB。而保护模式则采用了段模式进行内存管理。该模式会通过一个全局的段描述符表(GDT)来进行内存分段。这里的段不再有64KB的限制,而可以指定更大的段。该段描述符表的每一项叫做段描述符,每项占8个字节。在保护模式下,物理地址的计算不再采用段寄存器加偏移量的方式,而用段寄存器来存储段描述符的索引,通过该索引找到段描述符,再通过段描述符找到物理地址段。下面先给出段描述符的结构,该结构参考自leenjewel Blog里的【学习xv6】从实模式到保护模式

gdt item

  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。

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

  这里的gdt标号就是GDT的地址。现在把宏全部翻译过来,看看xv6的GDT是怎么样的。

1
2
3
4
5
6
7
gdt:
.word 0, 0;
.byte 0, 0, 0, 0 # 空
.word 0xffff, 0x0000;
.byte 0x00, 0x9a, 0xcf, 0x00 # 代码段
.word 0xffff, 0x0000;
.byte 0x00, 0x92, 0xcf, 0x00 # 数据段

  先定义了一个空的段描述符,64位全0。

  然后定义代码段。下面看看代码段的结构:

code segment

  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。

  数据段的定义也是类似,下面是数据段的结构:

data segment

  这里与代码段不同的就是E=0,代表数据段。RW=1虽然与代码段相同,但表示的意义不同,对于数据段表示可读可写。

  不难发现,代码段与数据段的寻址空间都是0~4GB。其实这里的段模式只是走个形式,并没有真正采用该模式来进行内存管理,而是简单地进行全部内存寻址。xv6实际上是用分页模式来管理内存的,这在后面会看到。

开启保护模式

  GDT设置好以后,需要加载。CPU单独准备了一个寄存器GDTR来保存GDT在内存中的位置和我们GDT的长度,它共有48位。

1
2
3
4
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

  lgdt指令会把gdtdesc地址的48比特内容加载到GDTR寄存器里。其中低16位用来存储GDT有多少个段描述符信息,单位是字节。16位可以表示65536个数,而每个段描述符需要8个字节,所以最多可以有8192个段描述符。高32位就是GDT的物理地址。

  要开启保护模式,需要打开一个开关。该开关用cr0寄存器的最低位表示,该寄存器为1代表开启保护模式。

  单纯启用保护模式,处理器仍然不能马上利用保护模式进行寻址。只有当cs段寄存器的值被更新以后,才会读取GDT表来进行逻辑地址到物理地址的映射。xv6采用以下方式更新cs段寄存器的值:

1
ljmp $(SEG_KCODE<<3), $start32

  SEG_KCODE这个宏是1,该语句会把cs寄存器的值设为0x08。前面说到,最多可以有8192个段描述符,而16位的寄存器只需要用高13位即可表示8192个数。所以这里的索引是1,代表标号为1的内存段,这正是代码段。这时候会执行start32地址里的代码,正式开启32位模式与保护模式。

引导内核前的最后准备
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
start32:
# Set up the protected-mode data segment registers
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
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
# If bootmain returns (it shouldn't), trigger a Bochs
# breakpoint if running under Bochs, then loop.
movw $0x8a00, %ax # 0x8a00 -> port 0x8a00
movw %ax, %dx
outw %ax, %dx
movw $0x8ae0, %ax # 0x8ae0 -> port 0x8a00
outw %ax, %dx
spin:
jmp spin

  把数据段的索引写入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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void
bootmain(void)
{
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar* pa;
elf = (struct elfhdr*)0x10000; // scratch space
// Read 1st page off disk
readseg((uchar*)elf, 4096, 0);
// Is this an ELF executable?
if(elf->magic != ELF_MAGIC)
return; // let bootasm.S handle error
// Load each program segment (ignores ph flags).
ph = (struct proghdr*)((uchar*)elf + elf->phoff);
eph = ph + elf->phnum;
for(; ph < eph; ph++){
pa = (uchar*)ph->paddr;
readseg(pa, ph->filesz, ph->off);
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
}
// Call the entry point from the ELF header.
// Does not return!
entry = (void(*)(void))(elf->entry);
entry();
}

  xv6把内核文件加载到物理内存0x10000开始的位置,也就是本文第一张图里的Extended memory部分,正好与low memory里的boot loader和堆栈区错开。bootmain函数首先加载ELF头部,从磁盘里加载ELF头部主要就是用的readseg()这个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void
readseg(uchar* pa, uint count, uint offset)
{
uchar* epa;
epa = pa + count;
// Round down to sector boundary.
pa -= offset % SECTSIZE;
// Translate from bytes to sectors; kernel starts at sector 1.
offset = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
for(; pa < epa; pa += SECTSIZE, offset++)
readsect(pa, offset);
}

  这个函数的功能是从磁盘里某个扇区开始,把数据加载到内存的pa位置。从代码可以看出,是一个扇区一个扇区地按顺序读数据的。首先要保证pa在完整扇区的头部,如果不是,则通过对扇区头部的偏移量来计算出完整扇区头部的地址。这里读ELF头部时,是从磁盘第一个扇区的头部开始的,读4096字节。读某个扇区的数据由readsect()函数完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void
readsect(void *dst, uint offset)
{
// Issue command.
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// Read data.
waitdisk();
insl(0x1F0, dst, SECTSIZE/4);
}

  xv6作为精简的Unix操作系统,只针对IDE接口的磁盘读写,默认启动磁盘就是采用IDE接口的。要阐述从磁盘读数据的方法,先给出IDE的IO接口对应的寄存器参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1F0 - 数据寄存器。读写数据都必须通过这个寄存器
1F1 - 错误寄存器,每一位代表一类错误。全零表示操作成功。
1F2 - 扇区计数。这里面存放你要操作的扇区数量
1F3 - 扇区LBA地址的0-7位
1F4 - 扇区LBA地址的8-15位
1F5 - 扇区LBA地址的16-23位
1F6 (低4位) - 扇区LBA地址的24-27位
1F6 (第4位) - 0表示选择主盘,1表示选择从盘
1F6 (5-7位) - 必须为1
1F7 (写) - 命令寄存器
1F7 (读) - 状态寄存器
bit 7 = 1 控制器忙
bit 6 = 1 驱动器就绪
bit 5 = 1 设备错误
bit 4 N/A
bit 3 = 1 扇区缓冲区错误
bit 2 = 1 磁盘已被读校验
bit 1 N/A
bit 0 = 1 上一次命令执行失败

  在读取扇区数据之前,先用waitdisk()函数检查磁盘是否准备好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void
waitdisk(void)
{
// Wait for disk ready.
while((inb(0x1F7) & 0xC0) != 0x40)
;
}
static inline uchar
inb(ushort port)
{
uchar data;
asm volatile("in %1,%0" : "=a" (data) : "d" (port));
return data;
}

  这里在c语言里调用了汇编命令来进行磁盘读写。读取IO接口0x1F7,检查IDE磁盘的状态寄存器高两位。如果最高为为0,高二位为1,说明磁盘准备好,可以读数据了。

  磁盘就绪后,用LBA寻址方式来寻找扇区,offset变量就是表示第几个扇区,从第一个扇区开始,拷贝4KB数据到内存,一个一个扇区地拷贝。需要先向接口0x1F2,0x1F3,0x1F4,0x1F5,0x1F6写入扇区编号。然后向0x1F7写入命令0x20,代表读扇区。

  等待磁盘再次准备好后,就可以用insl()函数读取扇区数据了。

1
2
3
4
5
6
7
8
static inline void
insl(int port, void *addr, int cnt)
{
asm volatile("cld; rep insl" :
"=D" (addr), "=c" (cnt) :
"d" (port), "0" (addr), "1" (cnt) :
"memory", "cc");
}

  这段代码仍然是调用了汇编命令,做了以下几件事:

  1. 把DF寄存器清零。
  2. 把循环次数写进cx计数寄存器中。
  3. 循环执行insl指令,把IO接口0x1F0的数据读取并写到对应的内存区域上,每次读取4个字节。
  4. 每次循环会让cx计数寄存器的值减1,并更新DI寄存器的值,让它加4。
  5. 这样,在循环结束时候,刚好读取完1个扇区的所有数据。

  读取完ELF头部后,检查magic字段,看是否真的是一个ELF文件。

加载内核程序

  ELF头部与程序头表加载到内存以后,下一步就是加载内核程序了。这里再引用一下bootmain()函数的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void
bootmain(void)
{
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar* pa;
elf = (struct elfhdr*)0x10000; // scratch space
// Read 1st page off disk
readseg((uchar*)elf, 4096, 0);
// Is this an ELF executable?
if(elf->magic != ELF_MAGIC)
return; // let bootasm.S handle error
// Load each program segment (ignores ph flags).
ph = (struct proghdr*)((uchar*)elf + elf->phoff);
eph = ph + elf->phnum;
for(; ph < eph; ph++){
pa = (uchar*)ph->paddr;
readseg(pa, ph->filesz, ph->off);
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
}
// Call the entry point from the ELF header.
// Does not return!
entry = (void(*)(void))(elf->entry);
entry();
}

  利用ELF头部的phoff字段,找出程序头表的内存地址。然后用ELF头部的phnum字段找出一共有多少个程序入口。循环每个程序入口,用程序头表的paddr参数找出相应内核程序的位置。然后还是用readseg()函数把磁盘里的程序加载到内存中。filesz字段代表程序在文件中的长度,off代表程序在文件中的偏移量,可以通过该偏移量计算出程序在哪个扇区。

  memsz字段代表程序在内存中的长度,如果在内存中长度比在文件中长,则要用stosb()函数在后面补0。

1
2
3
4
5
6
7
8
static inline void
stosb(void *addr, int data, int cnt)
{
asm volatile("cld; rep stosb" :
"=D" (addr), "=c" (cnt) :
"0" (addr), "1" (cnt), "a" (data) :
"memory", "cc");
}

  这个函数的循环逻辑类似insl()函数,循环地向内存中程序段后面填0,直到程序段大小与memsz一致。

  在这些都加载完成后,就可以执行内核入口程序了!入口程序的物理地址记录在elf头部中。之后,就是内核接管计算机的时代了!bootstrap引导操作正式完成!