首发公众号:Rand_cs
启动代码部分
本文来说码,实打实地来看看计算机到底是如何启动的,先来看看 $xv6$ 启动的整体流程图,好有个大概认识:
不要被这么一大坨吓到,$xv6$ 的启动过程算简单的,在启动方面做了很多简化,应该说整个操作系统都做了简化,不然整体也就不会才几千行代码。因为做了一些简化,可能过程不像前面理论一样那么分明,但也是类似相通的。另外关于内核对各部分初始化操作不讲,这留待后面一部分一部分慢慢讲述,在这里看看图有个了解就好。
虽说 $xv6$ 的启动过程相对简单,但也是有些复杂的,弯弯绕绕有些多,看代码和看下面文章时可以对照着这张图,免得走掉。
$RESET$ && $BIOS$
启动时强制设置 $CS=0xf000, IP=0xfff0$,这是 $BIOS$ 程序入口点,入口点是一跳转指令 $jmp \ \ f000:e05b$,然后开始执行 $BIOS$ 的代码,内存低 $1M$ 的顶部 $64KB$ 都是分配给 系统$BIOS$ 的,所以此时内存布局为:
$BIOS$ 是一个只读的 $ROM$ 区域,操作系统无能为力,一般是不能改动 $BIOS$ 程序的,但是我们知道它的执行流程,从 $0xffff0$ 开始执行 BIOS 的代码,然后将启动盘上的第 0 扇区($LBA$ 寻址方式)也就是最开始那个扇区的 MBR 加载到 $0x7c00$,然后开始执行。
此时内存布局为:
$Bootblock$($MBR$ && $Bootloader$)
接下来的代码操作系统就可以来掌控了,但 $xv6$ 并没有实际构造 $MBR$ 结构,从 $Makefile$ 中可以知晓最开始那个扇区写入的是 $bootblock$,$bootblock$ 又是由 $bootasm.S$ 和 $bootmain.c$ 两文件经过一些列编译汇编链接再转换格式得来,这两个文件组成的程序就是充当前面说的 $MBR$ 以及 $Bootloader$ 的角色。
bootblock: bootasm.S bootmain.c
################################
dd if=bootblock of=xv6.img conv=notrunc
这是从 $Makefile$ 中截取的两句,$bootblock$ 依赖 $bootasm.S$ 和 $bootmain.c$ 生成,然后使用 dd
命令将其写入 $xv6.img$。这个 $xv6.img$ 可以看作是磁盘映像。
dd
命令简单解释:
- $if=FILE$,指定要读取的文件
- $of=FILE$,指定要将数据输出到的文件
- $bs=BYTES$,指定块的大小,dd 操作 IO 的基本单位为一个块,未指定是默认 512 字节
- $count=BLOCKS$,指定操作的块数
- $seek=BLOCKS$,指定把块输出到文件时要跳过多少块
- $conv=CONVS$,指定如何转换文件,一般指定为 notrunc,一维不打断截短文件
因此上述的 dd
命令就是将 $bootblock$ 写到 $xv6.img$,没有指定 $seek$,所以不跳过,那就是写到第零块/扇区,相当于写到磁盘最开始的扇区。
$bootasm.S$
这一节来具体分析 $bootasm.S $,主要做了一件事:进入保护模式,主要分三步:
- 打开 $A20$
- 构建加载 $GDT$
- 设置 $CR0$ 寄存器
启动其实涉及了很多后面的东西,比如硬盘,$APIC$,各种机制的建立等等,一些地方的细节不做详细说明留待后面讲解,废话不再多说,一个一个的来看:
Ⅰ打开 A20
前面说过有三种方法打开 $A20$,其中使用系统端口 $0x92$,这种方法很简单,但是非常危险容易导致和其他硬件冲突而强制关机。$xv6$ 使用了另一种方法:使用键盘控制器来打开 $A20$,来看码:
seta20.1: # Wait for not busy 等待i8042缓冲区为空
inb $0x64,%al # 从0x64端口读出键盘状态
testb $0x2,%al # 测试键盘是否忙
jnz seta20.1 # 忙的话跳转到seta20.1,循环等待
movb $0xd1,%al # 发送0xd1到端口0x64,表示准备向0x60端口写入命令
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy 同上
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 向端口0x60写入0xdf,打开A20
outb %al,$0x60
有关键盘的操作在后面会专门讲述,这里只是过过眼,简单来说就是向端口 $0x64$ 写入 $0xd1$,然后向端口 $0x60$ 写入 $0xdf$ 就打开了 $A20$
打开 $A20$ 后,地址总线可以使用 $32$ 根,寻址范围达到 $2^{32} = 4G$。
Ⅱ 构建加载临时 $GDT$
①构建$GDT(asm.h)$
# 设置段描述符的宏
#define SEG_NULLASM \
.word 0, 0; \
.byte 0, 0, 0, 0
#define SEG_ASM(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
#构建GDT
gdt:
SEG_NULLASM # null seg GDT中第一个段描述符不用
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg 代码段描述符 执行,读权限
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg 数据段描述符 写权限
段选择子($mmu.h$):
#define SEG_KCODE 1 // kernel code
#define SEG_KDATA 2 // kernel data+stack
根据 $SEG_ASM$ 宏构建了两个段描述符:代码段描述符和数据段描述符,因为代码段在 GDT 中的索引设为 1,所以先构建的代码段描述符。$GDT$ 第一个描述符是没用的,所以直接设置为 0。
②构建 $GDT$ 位置信息
$CPU$ 需要知道构建的 $GDT$ 在哪,所以需要将 $GDT$ 的起始地址和界限这两样信息加载到 $GDTR$寄存器
gdtdesc: # 构造gdtr用到的6字节数据
.word (gdtdesc - gdt - 1) # sizeof(gdt) - 1 界限=大小-1
.long gdt # address gdt gdt起始地址
上述的 $gdtdesc$ 即为 $GDTR$ 需要的 48 位位置信息指针,它包括了 $GDT$ 的起始位置和界限
③加载GDT
lgdt gdtdesc #加载gdt
加载 $GDT$ 有专门的指令 $lgdt$,使用方法很简单,如上图所示
Ⅲ 设置 $CR0$ 寄存器 $PE$ 位
将 $CR0$ 寄存器的 PE 位置 1 开启保护模式
movl %cr0, %eax #CR0 当前值
orl $CR0_PE, %eax #或操作将PE位置1
movl %eax, %cr0 #将值写回到CR0
从此开始进入保护模式,16 位的 CPU 变成了 32 位的 CPU,此刻前后的指令格式也是不一样的,在此之前使用的 16 位指令,在此之后使用的 32 位指令,这里所说的多少位的指令不是说这个指令的长度,而是两种模式下指令的编码都不一样,也就是说同一条指令在两种模式下的机器码可能不一样。
但是我们应该都知道,为了加快 CPU 执行指令的效率,存在着一种机制:流水线,简单来说,就是把多条指令加载到流水线上,同时运行不同指令不同部分。问题就出在这儿,进入保护模式后流水线上可能还存在 16位的指令,所以进入保护模式后需要清空流水线,无条件跳转 $jmp$ 指令可以用来清空流水线:
ljmp $(SEG_KCODE<<3), $start32 #跳到 CS=(SEG_KODE<<3) EIP=start32,段基址为0,所以就是跳到start32处
#使用长跳刷新流水线,因为目前的流水线里面有16位实模式下的指令,而后面应该用32位保护模式下的指令
这里就是使用了一个长跳指令来刷新流水线,顺便设置 $CS$ 和 $EIP$ 寄存器,因为现在是保护模式了,段寄存器的可见部分应存放的是段选择子,所以将 $SEG_KCODE$ 内核代码段选择子写进 $CS$,这里的 $SEG_KCODE$ 相当于选择子的 $index$ 域,所以左移 3 位。左移操作右边添 0,这页说明 $TI$ 位为 0 表示 $GDT$,$RPL$ 位域为 0,表特权级 0,也就是内核态。详见前面理论部分。
Ⅳ $start32$
上面那个长跳跳转到下面的代码:
movw $(SEG_KDATA<<3), %ax # Our data segment selector 设置段寄存器,DS,ES,SS共用一个段选择子
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 FS,GS设为0,不用
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
没什么说的,设置段寄存器 $DS,ES,SS$ 位 $SEG_KDATA<<3$,即将他们都设置为内核数据段。上面没有设置 $CS$ 寄存器,CS 在长跳那个指令设置的。$FS,GS$ 没有使用,直接置 0,指向第 0 个空描述符,使用第 0 个空描述符是会出现一般保护性错的。详见前面理论部分。
$bootasm.S$ 最后做的工作:
movl $start, %esp #将start0x7c00设为栈顶
call bootmain
设置栈顶为 $0x7c00$,然后调用 $bootmain$。一个操作系统栈的变化一直是一个很迷很迷很迷的过程,要把握好栈的变化。
bootmain.c
相当于 $bootloader$,主要就是加载内核,整个内核就是一个$elf$ 文件,加载内核,内核在哪?在磁盘上,所以要先读取磁盘。$bootmain.c$ 里面有三个关于磁盘操作的函数,目前细节看不懂没关系,我们先了解三个函数的具体意思就可以了,实现细节放后面磁盘部分讲解。
void waitdisk(void); //等待磁盘空闲就绪
void readsect(void *dst, uint offset); //读取单个扇区 offset 到 dst
/*从offset所在的扇区读取count字节到pa,
offset是相对于内核elf起始位置的偏移量
所以实际扇区位置需要转化,这里先略过*/
void readseg(unchar *pa, uint count, uint offset)
有了上面三个函数的了解来看函数 $bootmain$:
void bootmain(void){
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar* pa;
elf = (struct elfhdr*)0x10000; // scratch space 将内核elf文件加载到这个位置
// Read 1st page off disk
readseg((uchar*)elf, 4096, 0); //从扇区 1 开始读,读4096个字节到0x10000,即8个扇区
// Is this an ELF executable?
if(elf->magic != ELF_MAGIC) //判断是否是elf文件
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++){
//for循环读取程序段
pa = (uchar*)ph->paddr; //程序段的位置
readseg(pa, ph->filesz, ph->off); //off是该段相对于elf的偏移量,filesz是该段的大小,即从off所在的扇区读取filesz到内存地址为pa的地方
if(ph->memsz > ph->filesz) //因为 bss节的存在,elf文件并不需要存在bss的实体,但是内存中需要占位,所以可能大些
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz); //调用 stosb 将段的剩余部分置零
}
// Call the entry point from the ELF header.
// Does not return!
entry = (void(*)(void))(elf->entry); //entry,内核程序的入口点
entry(); //调用entry
}
如果对 $elf$ 文件很熟悉的话,上面程序应该很好理解,有详细的注释我就不解释了,如果有哪儿不懂,可回去熟悉熟悉 ELF文件格式
所以 $bootmain$ 就做了一件事,将内核加载到内存,加载内核又分了两步:
- 读取内核 $elf$ 文件头到 $0x10000$
- 将 $elf$ 文件展开到正确的内存区域
来捋捋加载内核时的内存变化情况,$bootmain$ 加载内核,怎么加载的,加载到哪儿。
elf = (struct elfhdr*)0x10000;
readseg((uchar*)elf, 4096, 0); //从磁盘读4096字节到物理地址 0x10000
这里 $readseg$ 函数的意思是从磁盘的 1 扇区读取 4096字节到物理地址 $0x10000$ 处。内核文件在磁盘的扇区 1 ,注意这里虽然参数传的是 0,但是函数内部加了 1,所以是从扇区 1 读取的。这个函数后面讲述磁盘再详述,这里知道作用就行。
$0x10000$ 有什么意义?再来看一眼内存低 $1M$ 的布局图:
所以没什么特殊意义,就是找了一块空闲地儿,来存放内核的开始的 4096 字节。
那这 4096 字节有什么用?这就加载内核了?当然不是,$xv6$ 的内核有 200 多 KB,开始的 4096 字节只是包括了 $elf$ 文件的一些头部信息:
这是从我虚拟机上截的图,使用 readelf -h kernel
命令来查看内核的 $elf$ 头信息,从截图上可知程序头的相对 $elf$ 文件开始的偏移量为 52 字节,有 3 个程序头,每个 32 字节,所以这 4096 字节至少包括内核的 $elf$ 头和程序头表,而这是我们加载内核正需要的信息。
此时内存中的布局:
运行 $bootmain.c$ 的时候是将 $0x7c00$ 以下作为栈使用,根据内存低 1M 布局图可以看出,$0x7c00$ 以下有大约 30K 的空闲空间可用,这段代码很少,栈空间用不了多少,30K 太足够了,不会有什么问题。
下面就开始正式加载内核了,加载到哪儿是一个问题,这就需要程序头中记载的信息了:
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); //从ph->off所在的扇区读取ph->filesz字节到物理地址pa
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz); //调用 stosb 将段的剩余部分置零
上下结合来看得知将内核加载到了物理地址的 $0x100000$ 处。
此时的内存布局:
$end$ 为内核末尾地址,不同版本有稍许不同,可以在 $kernel.sym$ 文件中查找,也可以直接读取 $elf$ 的程序头,根据 $PhysAddr + MemSize$ 计算出来。
KERNEL
前面所做的事情都可以看作是引导部分,目的就是将 $xv6$ 的内核加载到内存,然后将权力交给内核。而启动的后半部分都交由内核来做,主要三件事:
- 开启分页机制
- 对系统的各个部分进行初始化
- $BSP$ 启动 $APs$
$entry.S$
首先便是开启分页机制,前面 $bootmain$ 将内核加载到内存之后,就跳到内核的入口点执行,入口点就是开启分页机制,分三步:
- 创建临时页表
- 加载页表
- 设置 $CR0$ 寄存器 $PG$ 位
Ⅰ 创建临时页表
临时页表的创建不在 $entry.S$ 文件中,而是在内核的 $main.c$ 文件里面,创建页表就是创建一个个目录项,临时页表就只有两项:
#define NPDENTRIES 1024 // # directory entries per page directory
pde_t entrypgdir[NPDENTRIES] = {
// 将虚拟地址的[0,4M)映射到物理地址[0,4M)
[0] = (0) | PTE_P | PTE_W | PTE_PS,
// 将虚拟地址[800 0000,800 0000+40 0000)映射到[0,4M)
[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};
$xv6$ 定义虚拟地址 $0x800 0000$ 以上为内核部分,这两项表示的映射关系如下:
这里注意每个页的大小是 $4M$ 而不是常见的 $4K$,所以这里是开启了页面大小扩展。页面大小扩展还需要设置 $CR4$ 寄存器 $PSE$ 位,这在 $entry.S$ 文件里面:
.globl entry
entry:
# Turn on page size extension for 4Mbyte pages
# 开启页面大小扩展,每页 4 M
movl %cr4, %eax
orl $(CR4_PSE), %eax
movl %eax, %cr4
还记得页面大小扩展之后如何进行地址转换的吗?它是将虚拟地址的高 10 位作为页表的索引,得到页框的物理地址,加上低 22 位的偏移量得到最终目标的物理地址,示意图如下:
所以这就解释了为什么 $entrypgdir$ 简简单单的两项,两条语句就映射了 $4M$ 的地址空间。那为什么要使用页面大小扩展呢?我合理的猜测下:就是简单方便,语句少,想想如果使用二级页表,页面大小不进行扩展只有 4K 的情况要怎么映射,两部分地址空间,得有两个页目录项,对应两个页表,$4M$ 有 1024 个 $4K$,得有 $1024$ 个页表项。虽然 $4M$ 没有全用,不用全映射,但是总的来说使用页面大小扩展之后更加简单方便。
Ⅱ 加载页表位置信息到$CR3$
# 将页目录地址加载到CR3寄存器
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3
V2P_WO 宏将 entrypgdir 转化为物理地址,然后加载到 CR3 寄存器
Ⅲ 设置$CR0$寄存器$PG$位
# 设置CR0寄存器的PG位开启分页机制
movl %cr0, %eax
orl $(CR0_PG|CR0_WP), %eax
movl %eax, %cr0
将 $CR0$ 寄存器 $PG$ 位置 1 开启分页机制
Ⅳ 跳去 $main$ 函数:
# Set up the stack pointer.
movl $(stack + KSTACKSIZE), %esp #栈顶设置到分配的一页空间顶部
mov $main, %eax #跳转到main
jmp *%eax
.comm stack, KSTACKSIZE #使用.comm伪指令分配一段空间
这里又有一次换栈,这次这个栈是使用伪指令 .comm
来分配的,它在 $bss$ 申请一段名为 $stack$ 的空间,长度为 $STACKSIZE$,链接器在链接的时候会为其留出空间。每个处理器都有这么一个栈,$BSP$ 在这里使用 .comm
分配,$APs$ 是 $BSP$ 分配的。
Ⅴ FAQ
$entry.S$ 的主体说完,这里来看看 $entry.S$ 和 $bootmain.c$ 中比较迷惑的几个点:
①哪些指令能够改变 EIP 寄存器的值?
能够改变 $EIP$ 寄存器值的指令就只有 $jmp$,$call$,$ret$ 三者,其他时候 $EIP$ 都是根据当前值,当前指令长度来修改 $EIP$。
②低地址,高地址问题,$bootmain.c$ 中的 $entry()$ 是怎么跳过来的
引导程序在物理内存的低 1M,内核程序在物理内存的 $[1M, end]$,后来临时页表将物理地址空间的 $[0, 4M) $映射到了虚拟地址空间的 $[0, 4M)$ 和 $[KERNEL, KERNEL+4M)$,所以其实引导程序和内核程序在虚拟地址空间都有个低地址区域和高地址区域。当然这只是临时的,因为这是临时页表。
引导程序在运行的时候还没有分页,没有虚拟内存虚拟地址空间一说,它运行在低地址,就是说引导程序的各种标记(变量名,函数名)都是在低地址 $4M$ 以下。这没什么问题,因为引导程序本身就是在 $4M$ 低地址以下。
$However$,内核程序在链接的时候设置的链接地址为 $KERNELBASE$: $0x8000000$,所以内核程序的所有标记都是高地址,在 $0x80000000$ 之上,这个可以在 $kernel.sym$ 中查询,举几个例子:
8010000c entry
801039c2 main
0010000c _start
内核程序本身存在物理地址空间的低地址,但是运行使用的各种标记都在虚拟地址空间的高地址,要想正确运行,就需要页表来映射,所以映射了物理地址空间的 $[0, 4M)$ 到了虚拟地址空间的 $[KERNEL, KERNEL+4M)$,如此就没问题。
可主要问题是,$entry$ 这个标记也是高地址,但是我们在 $bootmain$ 就执行了,要知道执行 $bootmain$ 的时候还没有开启分页机制,没有页表这个东西来映射的,所以 $bootmain$ 函数中的 $entry()$ 这个语句是如何正确执行跳到内核程序来的呢?
注意到上面列出来的第三个标记 $_start$ 了吗,它的值是个低地址(物理地址),主要就是靠它,来看相关语句代码
#####entry.S#####
.globl _start
_start = V2P_WO(entry)
.globl entry
entry:
#开启分页机制的代码
####kernel.ld####
ENTRY(_start)
$entry.S$ 定义了两个标记,汇编代码里面标记就是个地址值,可能是变量名的地址,也可能是个函数的地址。这里 entry 就表示开启分页机制的代码,是个虚拟地址,而 $_start$ 表示 $entry$ 的物理地址。为什么 $V2P$ 一个简单的宏就表示地址转化了见后面的内存管理部分,这里知道运用了这么一个宏就将虚拟地址转换为物理地址且赋给了 $_start$ 即可。
$kernel.ld$ 是内核文件的链接脚本,所谓链接脚本就是告诉链接器你要按照我写的脚本规则来链接。$ENTRY(_start)$ 就是将 $_start$ 当作入口点。
现在来看 $entry()$ 这个语句,他的反汇编:
entry();
7d91: ff 15 18 00 01 00 call *0x10018
内核 $elf$ 头被加载到 $0x10000$,$0x10018$ 就是 $elf \rightarrow entry$ 的地址,这个地址存放的就是 $_start$,这个 $call$ 语句的意思就是跳去执行 $_start$ 程序。$_start$ 本身是个物理地址,取指运行没什么问题。
$_start$ ($entry$)这个函数本身应在高地址运行,虽然去 $_start$ 这个物理地址上取指运行没什么问题, 那后续的执行有没有什么问题呢?也没问题, $entry$ 程序本身就在物理地址空间低地址处,前面说过修改 $EIP$ 的指令就只有 $jmp$,$call$,$ret$,而 $entry$ 里面在开启分页机制前没有这些指令将 $EIP$ 修改为高地址,也没有访存高地址指令,就只是普通的 $EIP+=当前指令长度$ 这种普通的取指执行的方式,没有用到 $entry$ 程序虚拟的高地址,因此没得问题。
③临时页表为什么要映射两部分,将虚拟地址空间的 $[0,4M)$ 映射到物理地址 $[0,4M)$,$[KERNEL, KERNEL+4M)$ 映射到 $[0,4M)$?
开启分页机制后就会跳转到 $main$ 函数执行,$main$ 这个标记就是高地址,此后都是使用内核程序虚拟的高地址,需要映射到实际的物理低地址,所以有了页表项 虚拟地址空间$[KERNEL, KERNEL+4M)$ 映射到物理地址空间 $[0,4M)$
开启分页机制那一刻到跳转到 $main$ 执行这之间的指令还是用的低地址,因为没有任何修改 $EIP$ 为高地址的指令,也没有访问高地址的访存指令,所以这时候使用的还是低地址。不过注意这个时候的低地址就不是物理地址了,而是虚拟地址,虽然它们的值是相同的。虚拟地址到物理地址需要转换,但又因为值是相同的,所以有了页表项: 虚拟地址空间的 $[0,4M)$ 映射到物理地址 $[0,4M)$
④跳到 $main$ 函数执行时,为什么使用间接跳转 jmp *%eax?
jmp *%eax
,使用间接跳转,直接从 $eax$ 中获取目的地的绝对地址,否则使用直接跳转的话,会生成相对寻址的编码,也就是会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码
$main$
终于来到 $main$ 函数,主要是对各种机制的初始化,本文大部分都不会讲,留待后面相应的部分,本文主要来看三个与 $CPU$ 启动相关的:
int main(void)
{
mpinit(); // detect other processors 检测其他CPU
startothers(); // start other processors 启动其他APs
mpmain(); // finish this processor's setup 完成该BSP的启动
}
$mpinit$
函数 $mpinit$ 就是从 $MP$ $Configuration$ $Table$ 获取 $CPU$ 的配置信息,根据前面的理论知识,首先要找到 $floating$ $pointer$,根据其中记录的多处理器配置表的地址找到该表,而 $floating$ $pointer$ 又只可能出现在那三个位置,那么依次寻找就完事了。$mpinit$ 在文件 $mp.c$ 里面,我们从上至下依次看看里面的函数
static uchar sum(unchar *addr, int len); //计算addr-addr+len这一段的和
static struct mp* mpsearch1(uint a, int len) //在a~a+len这一段寻找floating pointer 结构
{
uchar *e, *p, *addr;
addr = P2V(a); //转换成虚拟地址
e = addr+len; //结尾
for(p = addr; p < e; p += sizeof(struct mp))
if(memcmp(p, "_MP_", 4) == 0 && sum(p, sizeof(struct mp)) == 0) //比较签名和校验和,如果符合则存在floating pointer
return (struct mp*)p;
return 0;
}
static struct mp* mpsearch(void) //寻找mp floating pointer 结构
{
uchar *bda;
uint p;
struct mp *mp;
bda = (uchar *) P2V(0x400); //BIOS Data Area地址
if((p = ((bda[0x0F]<<8)| bda[0x0E]) << 4)){
//在EBDA中最开始1K中寻找
if((mp = mpsearch1(p, 1024)))
return mp;
} else {
//在基本内存的最后1K中查找
p = ((bda[0x14]<<8)|bda[0x13])*1024;
if((mp = mpsearch1(p-1024, 1024)))
return mp;
}
return mpsearch1(0xF0000, 0x10000); //在0xf0000~0xfffff中查找
}
上述代码就是用来寻找 $floating$ $pointer$ 结构,应该不难理解,可能迷惑点就在于 $mpsearch$ 函数中 $EBDA$ 和 基本内存
的位置,这我们从直接 $BDA$ 中获取位置信息,$BDA$ 是 $BIOS$ 的数据区域,位置固定在 $0x400$ 的地方,里面包括了我们需要的信息。
看我标注出来的两项,从地址 $0x040E$ 开始的两字节为 $EBDA$ 的地址右移 4 位。上面代码定义的 $bda$ 为指向 unsigned char
类型的指针,起始地址为 $0x400$,当然啊转化成虚拟地址了,所以 $bda[0xE]$ 表示 $EBDA$ 地址低 8 位,$bda[0xF] << 8$ 表示 EBDA 的高 8 位,两者合起来再左移 4 位就是 $EBDA$ 的地址
从地址 $0x0413$ 开始的两字节表示 $EBDA$ 前面一共多少个字节,这个数就是基本内存的大小,也是基本内存的末尾地址,代码同样的操作就不再解释。
找到了 $floating$ $pointer$ 结构之后就可以根据其元素 $physaddr$ 找到 $MP$ $Configuration$ $Table$,这个表又是根据两部分组成,表头和表项,表项中目前很多都用不到,我们只关注处理器的部分,简单来说 $mpinit$ 函数有关处理器的部分就是寻找有多少个处理器表项,多少个处理器表项就代表有多少个处理器,然后将相关信息填进全局的 $CPU$ 数据结构:
struct cpu cpus[NCPU]; //全局CPU数据结构,NCPU表示支持多少个CPU
int ncpu; //CPU数量
for(p=(uchar*)(conf+1), e=(uchar*)conf+conf->length; p<e; ){
//跳过表头,从第一个表项开始for循环
switch(*p){
//选取当前表项
case MPPROC: //如果是处理器
proc = (struct mpproc*)p;
if(ncpu < NCPU) {
cpus[ncpu].apicid = proc->apicid; // apic id可以来标识一个CPU
ncpu++; //找到一个CPU表项,CPU数量加1
}
p += sizeof(struct mpproc); //跳过当前CPU表项继续循环
continue;
虽然是截取的代码部分,加上注释应该也还是没什么问题,$xv6$ 定义了一个全局的 $CPU$ 数据结构,这个 $mpinit$ 函数就是探寻有多少个 $CPU$ 然后初始化 $CPU$ 的 $LAPIC$ $ID$,每个 $CPU$ 都对应着一个 $LAPIC$,$LAPIC$ 的 $ID$ 也就可以用来唯一标识一个 $CPU$。$APIC$ 在中断部分再详述。
$startothers$
寻到了有多少个 $CPU$,而且也有了每个 $CPU$ 的标识信息,就可以去启动它们了,直接来看 $startothers$ 的代码:
static void
startothers(void)
{
extern uchar _binary_entryother_start[], _binary_entryother_size[];
uchar *code;
struct cpu *c;
char *stack;
//entryother.S 是APs启动时要运行的代码,链接器将映像放在_binary_entryother_start
//然后将其移动到0x7000处
code = P2V(0x7000);
memmove(code, _binary_entryother_start, (uint)_binary_entryother_size);
for(c = cpus; c < cpus+ncpu; c++){
//for循环启动APs
if(c == mycpu()) // 排除自个儿
continue;
// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
stack = kalloc(); //给每个AP分配一个栈
*(void**)(code-4) = stack + KSTACKSIZE; //code-4的位置填写栈顶地址
*(void(**)(void))(code-8) = mpenter; //code-8的位置填写mpenter地址
*(int**)(code-12) = (void *) V2P(entrypgdir); //code-12的位置填写页目录地址
lapicstartap(c->apicid, V2P(code)); //调用lapicstartap启动AP,传递参数apic id和要执行的代码地址
// wait for cpu to finish mpmain()
while(c->started == 0) //等待当前AP启动好再进行下一次循环
;
}
}
看起来这函数有点儿复杂啊,咱们一步步来,首先是 $entryother.S$ 这个汇编代码,我就不贴出来了,大家可以自己看看源码,就是 $BSP$ 执行的 $bootasm.S$ 与 $entry.S$ 的结合体,这是 APs 要执行启动程序,主要的工作就是进入保护模式,开启分页机制,然后调用 $mpenter$ 函数,$mpenter$ 函数就是完成最后的启动工作的,我们后面看。
接着就是一个 $for$ 循环来启动 $APs$,它会跳过自个儿 $BSP$,然后循环次数就是 $CPU$ 的数量,这在前面 $mpinit$ 初始化过了。对于每个 $CPU$ 都有一个栈,$BSP$ 是用 .comm
语句让链接器来分配的,$APs$ 使用 $kalloc$ 函数来分配,$kalloc$ 同样放在后面内存管理的时候叙述,现在只需要知道 $kalloc$ 可以分配一个物理页,然后返回起始虚拟地址。
然后在 $entryother$ 的代码下方,也就是 $0x7000$ 的下方依次填写栈顶地址,$mpenter$ 地址,页目录地址,因为这三个地址 $entryother.S$ 都要用到,所以先准备好。
所以此时物理内存低 4M 的内存映像为:
最后再调用 $lapicstartap$ 函数来启动 APs,来看这个函数
$lapicstartap$
$BSP$ 启动 $APs$ 分为三步:
- $BSP$ 设置 $CMOS$ 的 $shutdown$ $code$ 为 $0xA$,$shutdown$ $code$ 位于 $CMOS$ 第 $0xF$ 个寄存器
- 设置 $warm$ $reset$ $vector$,位于 $40:67$,这是 $段基址:段内偏移$ 形式的地址,实际地址就是 $0x467$,我们需要在这个地址填写 AP 要执行的启动程序代码地址,也是以 $段基址:段内偏移$ 的形式填写地址
- $BSP$ 发送 $INIT-SPIP-SIPI$ 消息给 $AP$
$BSP$ 设置 $CMOS$ $shutdown$ $code$
void lapicstartap(uchar apicid, uint addr)
{
int i;
ushort *wrv;
//BSP必须将CMOS shutdown code 设置为0x0A,这样后面就会跳到40:67h记录的程序入口点执行
outb(CMOS_PORT, 0xF); // offset 0xF is shutdown code
outb(CMOS_PORT+1, 0x0A);
$outb$ 向端口写一个字节,$CMOS$ 有很多寄存器,每个寄存器都有个索引,我们可以向 $0x70$ 端口写这个索引值来选取这个寄存器,然后从 $0x71$ 这个端口读写相应的寄存器,不止 $CMOS$,很多硬件都是这样操作寄存器的,后面讲述显卡,$APIC$ 等等都有相似情况。
所以这里向 $0x70$ 端口写 $0xF$ 来选取 $shutdown$ $code$,然后向 $0x71$ 端口写 $0xA$ 就表示将 $shutdown$ $code$ 设置为 $0xA$
设置 $warm$ $reset$ $vector$
wrv = (ushort*)P2V((0x40<<4 | 0x67)); // Warm reset vector
wrv[0] = 0;
wrv[1] = addr >> 4;
$warm$ $reset$ $vector$ 位于 $40:67$ 即位于 $0x467$,$warm$ $reset$ $vector$ 是个双字,也是以 段基址:段内偏移
的方式填写地址,所以段基址写为 $addr>>4$,段内偏移写为 0。
$BSP$ 发送 $INIT-SIPI-SIPI$ 消息给 $AP$
////发送 INIT 消息
lapicw(ICRHI, apicid<<24);
lapicw(ICRLO, INIT | LEVEL | ASSERT);
microdelay(200);
lapicw(ICRLO, INIT | LEVEL);
microdelay(100); // should be 10ms, but too slow in Bochs!
// 发送两次 STARTUP IPI 消息
for(i = 0; i < 2; i++){
lapicw(ICRHI, apicid<<24);
lapicw(ICRLO, STARTUP | (addr>>12));
microdelay(200);
}
}
$lapicw$ 就是写 $lapic$ 的一个函数,发送 $INIT-SIPI-SIPI$ 消息就是靠写 $lapic$ 的 $ICR$ 寄存器实现的,关于 $lapic$ 的具体操作这里就不详述了,后面中断的时候再讲述。
发送 $INIT-SIPI-SIPI$ 的规则挺多,都是按照规范来的:
INIT IPI is an Interprocessor Interrupt with trigger mode set to level and delivery mode set to “101” (bits 8 to 10 of the ICR). INIT IPIs should always be programmed as level triggered; the operating system must perform two writes to the ICR to assert and then deassert this delivery mode.
These local APICs recognize the STARTUP IPI, which is an APIC Interprocessor Interrupt with trigger mode set to edge and delivery mode set to “110” (bits 8 through 10 of the ICR).
主要就是根据这两条来的,大致为发送 $INIT$ 消息必须要设置为边沿触发,还要连续写两次一次 $assert$ 一次 $disassert$,发送 $SIPI$ 需要电平触发,每次等待 $200us$,这些种种规则,坦白讲一些东西我也不太清楚,规范上就是这么写的,关于这个我也就没深入研究下去的,知道发送 $BSP$ 发送 $INIT-SIPI-SIPI$ 中断消息给 $AP$ 来启动 $AP$ 就行了。
$entryother$
$AP$ 启动时会执行 $[40:67]$ 处的启动程序,这个启动程序在就是 $entryother.S$,这个汇编代码跟前面的 $bootasm.S$ 和 $entry.S$ 大都是相同的,就是进入保护模式和开启分页机制,只来看最后两句:
movl (start-4), %esp #将栈顶赋给esp
call *(start-8) #调用mpenter()
前面设置的栈顶地址,$mpenter$ 地址在这儿就体现作用了,现在 $AP$ 也有了自己的栈了,然后运行 $mpenter$ 完成启动
$mpenter$
static void mpenter(void)
{
switchkvm(); //切换到内核页表
seginit(); //重新设置和加载GDT
lapicinit(); //初始化APIC
mpmain(); //见下
}
static void mpmain(void)
{
cprintf("cpu%d: starting %d\n", cpuid(), cpuid());
idtinit(); // 加载IDT
xchg(&(mycpu()->started), 1); // 将started置1表启动完成了
scheduler(); // 开始调度进程执行程序了
}
可以看到,这里面所做的工作主要还是初始化建立自己的环境,一些共享的东西比如磁盘,内存的初始化操作 $BSP$ 已经做了,$AP$ 就不需要重复了。最后 $CPU$ 这个结构体中的元素 $started$ 置 1 表示这个 $CPU$ 已经启动好了,这里就会通知 $startothers$ 函数,可以启动下一个 $AP$ 了。最后就是调用 $scheduler$ 可以开始调度执行程序了。
执行完 $startothers$,所有的 $APs$ 就启动好了,最后 $BSP$ 本身再执行 $mpenter$ 自身完成启动,到此所有的 $CPU$ 都已经完成启动,也就是计算机的启动工作正式完成,各种环境已经建立好,可以执行各种程序,完成各种任务了。
最后再来看一下 $xv6$ 的启动流程图:
上面的启动流程图是很完整的一个图,将各种初始化也画出来了,但是本文只讲述了一小部分,其他部分留待相应章节讲述。
好了本节就这样吧,有什么问题还请批评指正,也欢迎大家来同我讨论交流学习进步。
首发公众号:Rand_cs
本文参考:
Intel® 64 and IA-32 Architectures Software Developer Manuals
还有各类手册,主要是 $i386$, $intel$ 开发手册卷3,$MP$ $spec$,可在后台回复手册获取