注:
这部分代码是使用typora写的,里面嵌套的一些内容可能在简书里面显示不出来,所以如有格式问题,建议下载typora复制粘贴查看。
另:这些内容基本都是从网上各个网站找的资料,印象比较深刻的有以下几个:
https://blog.csdn.net/vally1989/article/details/71796482
http://leenjewel.github.io/blog/2015/11/11/%5B%28xue-xi-xv6%29%5D-nei-he-gai-lan/
http://leenjewel.github.io/blog/2015/05/26/%5B%28xue-xi-xv6%29%5D-jia-zai-bing-yun-xing-nei-he/
https://blog.csdn.net/qq_25426415/article/details/54583835
https://blog.csdn.net/qq_25426415/article/details/54619488
感谢上述各个文章。
如有需要,联系contact@fhao.top
引导
BIOS需要执行系统的启动扇区上的东西,故首先需要将启动扇区复制到内存中,并且将其执行后进入操作系统的内核之中。
BIOS必须通过实模式启动,但当需要我们引入xv6自己的内核时,需要从实模式转换到保护模式(因为保护模式地址总线为32位,可访问地址位更多,可以利用4G内存)。
打开A20
A20简介:(来自https://en.wikipedia.org/wiki/A20_line)
- A20总线,是x86体系的扩充电子线路之一。A20总线是专门用来转换地址总线的第二十一位。
- 由于16位操作系统数据线宽与寄存器是16位,这样只能支持$2^{16}$即64K的内存,为了扩展,在8086CPU上将地址线宽度增加到20位,这样能寻址到$2^{20}$即1M(16位+4位的偏移)。一个 16 位的寄存器来表示“段基址”(CS、DS、SS、ES四个寄存器),具体的做法是先将 16 位的“段基址”左移 4 位,然后加上 16 位的“偏移量”最终得到 20 位的内存地址送入地址线。
- 但是到了80286CPU时已经有了24根总线,为了能够访问1M之后的内容并且与之前的8086/8088兼容,故使用键盘控制器上剩余的一些输出线来管理A20,被称为A20 Gate,若A20 Gate被打开,则0x100000-0x10FFEF的内存就可以正常访问。否则,就将超过1M的内存取模再次回到了1M以内。
- 保护模式有32位,肯定不能关闭A20,无法访问4G地址空间。只能访问1M,3M,5M等。
BIOS设置CS寄存器为0x0,ip寄存器为0x7c00,开始执行boot loader程序。
在bootasm.S
中:
1 | .code16 |
一句说明现在是16位状态。之后一句:
1 | .globl start |
.globl
指示告诉汇编器,start
这个符号要被链接器用到,所以要在目标文件的符号表中标记它是一个全局符号。start
就像C程序的main
函数一样特殊,是整个程序的入口,链接器在链接时会查找目标文件中的start
符号代表的地址,把它设置为整个程序的入口地址,所以每个汇编程序都要提供一个start
符号并且用.globl
声明。如果一个符号没有用.globl
声明,就表示这个符号不会被链接器用到。
之后使用cli
命令来禁用中断。
接下来四句:
1 | xorw %ax,%ax # 使用异或操作,直接将%ax设0 |
将寄存器DS, ES, SS全部设置为0。其中取值会用到 %cs
,读写数据会用到 %ds
,读写栈会用到 %ss
。
但是实模式刚运行完,很多遗留下的一些寄存器的值需要我们整理下,所以我们需要“将 %ax 置零,然后把这个零值拷贝到三个段寄存器中”。
接下来,打开A20开关:
1 | seta20.1: |
xv6打开A20采用的方法是键盘控制器法,通过输出命令到键盘控制器的IO接口,控制A20的开启与关闭。
与键盘控制器有关的IO接口是0x60和0x64。其中0x64起到一个状态控制的功能,0x60则是数据端口。首先需要检查键盘控制器是否忙碌,例如是否正有键盘输入等等。这个状态检查是通过读取0x64实现的。通过检查该状态数据的低第二个比特位是否为高,来判断是否忙碌。等到该比特位为低,就可以向0x64写命令了。
上述代码中
1 | inb $0x64,%al |
之中的inb
代表读入数据,即将0x64
地址上的数据放入%al
。$
代表常量。
1 | testb $0x2,%al |
之中的testb
将两个操作数进行逻辑与运算,并根据运算结果设置相关的标志位。但两个操作数不会被改变。运算结果在设置过相关标记位后会被丢弃。这一句将0x2
与%al
中的内容求与,即得到了%al
中内容的第二位,如果是高,则执行下一句
1 | jnz seta20.1 |
也就是再次执行seta20.1
,重新检测。否则,进行下面的操作
1 | movb $0xd1,%al # 把0xd1写入0x64 |
使用%al
作为中介,将0xd1
写入0x64
。该命令用于指示即将向键盘控制器的输出端口写一个字节的数据。
下面的一段含义类似,只是最后将0xdf
写入0x60
。该数据代表开启A20。
进入保护模式
GDT介绍:
内存地址是段地址+段内偏移。在实模式下,段地址就放在段寄存器中,而到了保护模式下,段寄存器中保存的不再是段地址,而是GDT(global descriptor table,全局描述符表)中的偏移量,所以进入保护模式时首先需要创建GDT。
GDT每个段对应一个表项,保存段基址、段大小、访问权限等,让CPU可以定位到具体的物理地址去,并且检查内存访问是否合法,因此被叫做保护模式。GDT整体储存很多段描述符,故类似一个数组。
每个“段描述符”占 8 字节,如图:
三段基地址组合形成32位的基地址(8+8+16)
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 字节,最大长度 1MBDB: 1 表示地址和操作数是 32 位,0 表示地址和操作数是 16 位
XX: 保留位永远是 0
AA: 给系统提供的保留位
规定GDT的第一个表项必须是空的。
CPU 单独为我们准备了一个寄存器叫做 GDTR
用来保存我们 GDT 在内存中的位置和我们 GDT 的长度。GDTR
寄存器一共 48 位,其中高 32 位用来存储我们的 GDT 在内存中的位置,其余的低 16 位用来存我们的 GDT 有多少个段描述符。 16 位最大可以表示 65536 个数,这里我们把单位换成字节,而一个段描述符是 8 字节,所以 GDT 最多可以有 8192 个段描述符。不仅 CPU 用了一个单独的寄存器 GDTR
来存储我们的 GDT,而且还专门提供了一个指令用来让我们把 GDT 的地址和长度传给 GDTR 寄存器。lgdt
代表加载全局描述符。
1 | # 将实模式转换为保护模式,使用一个引导GDT让虚拟地址直接映射到真实地址去,过渡期内这个GDT的内存映射不会改变。 |
其中
1 | lgdt gdtdesc |
之中用到的gdtdesc
的定义如下:
1 | gdtdesc: |
加起来48位。
其中gdt
定义如下:
1 | # Bootstrap GDT |
宏定义在asm.h
中,将他们再替换回来,得到如下:
1 | gdt: |
代码段空间如下:
数据段空间如下:
首先说说这两个内存段的共同点,DB = 1,G = 1,基地址都是 0x00000000,内存分段长度都是 0xfffff,这说明他们都是用于 32 位寻址,所使用的内存是从 0 开始到 4GB 结束(全部内存)。这里是这么算出来的,段长度是 0xfffff = $2^{20}$,G = 1 表示段界限单位是 4k,所以 4k * $2^{20}$ = 4GB。
再说说他们的不同点,代码段的 E = 1 而数据段的 E = 0 这表明了他们的身份,身份不同 RW 的值虽然相同,但代表的意义也就不相同了,代码段的 RW = 1 代表可读取,数据段的 RW = 1 表示可读可写。这也和我们上面解释的保护模式所能够达到的目的相吻合。
代码段和数据段都启用了从 0 到 4GB 的全部内存寻址。其实这种内存规划方法叫做“平坦内存模型”,即便是 Linux 也是用的这样的方式规划内存的,并没有做到真正的“分段”。这是因为 x86 的分页机制是基于分段的,Linux 选用了更先进的分页机制来管理内存,所以在分段这里只是走一个必要的形式罢了。
后面的几句:
1 | movl %cr0, %eax |
因为我们无法直接操作 cr0
,所以我们首先要用一个通用寄存器来保存当前 cr0
寄存器的值,这里第一行就是用通用寄存器 eax
来保存 cr0 寄存器的值;然后 CR0_PE 这个宏的定义在 mmu.h
文件中,是个数值 0x00000001,将这个数值与 eax
中的 cr0
寄存器的值做“或”运算后,就保证将 cr0
的第 0 位设置成了 1 即 PE = 1
保证打开了保护模式的开关。而 cr0
的第 31 位 PG = 0 表示我们只使用分段式,不使用分页,这时再将新的计算后的 eax
寄存器中的值写回到 cr0
寄存器中就完成了到保护模式的切换。
之后进入32位:
1 | ljmp $(SEG_KCODE<<3), $start32 |
ljmp
的语法:ljmp segment offset
,它会同时设置段寄存器和段内偏移(EIP寄存器),而在32位模式下,段寄存器保存的是GDT里的偏移量,所以这里段寄存器的值是SEG_KCODE<<3
,即数字8(SEG_KCODE=1
),因为GDT的第一个表项必须为空,所以代码段占据第二个表项。
这个跳转语句的两个参数就是“基地址” + “偏移量”的方式告诉 CPU 要跳转到内存的什么位置去继续执行指令。
进入32位模式之后,设置各个段寄存器。
1 | .code32 # 现在是32位 |
上述代码中,数据段占用GDT第三个表项,ES, SS等同于数据段,其他不用的段置零。
1 | # Set up the stack pointer and call into C. |
现在准备执行.c
代码,首先要设置stack,movl $start, %esp
,即给esp
寄存器设置,start
为bootasm.S
最开始的一个label,但是为何要将esp
设置为start
呢,这是因为start
是代码的开始位置,代码是向高内存地址处放置的,而stack是向低内存地址增长的,所以这样设置之后,代码和栈向相反方向扩张,两者互不干涉。BIOS 会将引导扇区的引导程序加载到 0x7C00
处并引导 CPU 从此处开始运行,故栈顶即被设置在了和引导程序一致的内存位置上。我们知道栈是自栈顶开始向下增长的,所以这里栈会逐渐远离引导程序,所以这里这样安置栈顶的位置并无什么问题。另外,当call bootmain
的时候,首先要做的就是movl %esp %ebp
,即将esp
的值设置给ebp
,这样ebp
保存的就是start
的值,也就是函数栈中的第一个函数是start
,这符合逻辑。
现在不应该执行接下来的这些语句,但是如果异常的话,bootmain
返回并且执行下面的操作:
1 | # If bootmain returns (it shouldn't), trigger a Bochs |
进入.c
的内容
由于之前曾经使用了call bootmain
,所以现在会执行bootmain.c
里面的bootmain()
函数,该函数如下所示:
1 |
|
上述过程其实只是加载了内核(把内核从磁盘上搬到了内存中),然后执行内核中的程序。其中反复被用到的readseg()
函数的定义如下所示:
1 | // 将'count'字节的位于距离内核'offset'的代码写入内存地址'pa'中 |
要知道内核二进制文件到底是如何链接的,首先需要了解:
ld
表示GCC连接脚本,链接的过程实际上是为了解决多个文件之间符号引用的问题(symbol resolution)。编译时编译器只对单个文件进行处理,如果该文件里面需要引用到其他文件中的符号(例如全局变量或者函数),那么这时在这个文件中该符号的地址是没法确定的,只能等链接器把所有的目标文件连接到一起的时候才能确定最终的地址。
打开kernel.ld
文件,可以发现,内核入口地址为标号_start
地址
1 | OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386") |
这个_start
的地址其实是在内核代码文件entry.S
是内核入口虚拟地址entry
对应的物理地址,由于此时虚拟地址直接等于物理地址,_start
将作为ELF
文件头中的elf->entry
的值。
内核文件中加载地址和链接地址是不一样的,链接地址是程序中所有标号、各种符号的地址,一般也就是内存中的虚拟地址,但是加载地址是为了在生成ELF
文件时,指定各个段应该为加载的物理地址,这个地址作为每个段的p->paddr
的值。
上面readseg
函数之中的readsect
函数定义如下:
1 | // 从offset位置读取单个扇区到dst |
里面一堆神奇(莫名其妙)的outb
函数就是一个写入一比特的数据(一个uchar
)。这样设置的原因如下:
IDE 标准定义了 8 个寄存器来操作硬盘。PC 体系结构将第一个硬盘控制器映射到端口 1F0-1F7 处,而第二个硬盘控制器则被映射到端口 170-177 处。这几个寄存器的描述如下(以第一个控制器为例):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
191F0 - 数据寄存器。读写数据都必须通过这个寄存器
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 上一次命令执行失败上面代码中已经把这些内容翻译到注释里面去了。
xv6内核二进制文件是elf
格式,在维基上有这个格式的具体说明。前两部分是ELF头部和程序头表,在elf.h
文件里面有具体的声明,如下所示:
1 |
|
现在打开xv6.img
文件,读取第二个扇区位置(第一个扇区即第一个512字节是bootblock
)的开头数据并且与上面内容相对应,得到如下结果:
字段名称 | 大小 | 数值 | 意义 |
---|---|---|---|
magic | 4字节 | 7F 45 4C 46 | ELF 格式文件 |
elf | 12字节 | 01 01 01 00 00 00 00 00 00 00 00 00 | 32 位小端模式,目标操作系统为 System V |
type | 2字节 | 02 00 | 可执行文件 |
machine | 2字节 | 03 00 | 指定计算机体系架构为 x86 |
version | 4字节 | 01 00 00 00 | 版本号为 1 |
entry | 4字节 | 0C 00 10 00 | 该可执行文件入口地址 |
phoff | 4字节 | 34 00 00 00 | 程序头表相对于文件的起始位置是 52 字节 |
shoff | 4字节 | 00 F6 01 00 | 节区头表相对于文件的起始位置是 128512 字节 |
flags | 4字节 | 00 00 00 00 | 无特定处理器标志 |
ehsize | 2字节 | 34 00 | ELF 头大小为 52 字节 |
phentsize | 2字节 | 20 00 | 程序头表一个入口的大小是 32 字节 |
phnum | 2字节 | 02 00 | 程序头表入口个数是 2 个 |
shentsize | 2字节 | 28 00 | 节区头表入口大小是 40 字节 |
shnum | 2字节 | 12 00 | 节区头表入口个数是 18 个 |
shstrndx | 2字节 | 0F 00 | 字符表入口在节区头表的索引是 15 |
之后是程序头表,一共两个,每个32字节,如下:
- 程序头表 1
字段名称 | 大小 | 数值 | 意义 |
---|---|---|---|
type | 4字节 | 01 00 00 00 | 可载入的段 |
off | 4字节 | 00 10 00 00 | 段在文件中的偏移是 4096 字节 |
vaddr | 4字节 | 00 00 10 80 | 段在内存中的虚拟地址 |
paddr | 4字节 | 00 10 00 00 | 同段在文件中的偏移量 |
filesz | 4字节 | 96 B5 00 00 | 段在文件中的大小是 46486 字节 |
memsz | 4字节 | FC 26 01 00 | 段在内存中的大小是 75516 字节 |
flags | 4字节 | 07 00 00 00 | 段的权限是可写、可读、可执行 |
align | 4字节 | 00 10 00 00 | 段的对齐方式是 4096 字节,即4kb |
- 程序头表 2
字段名称 | 大小 | 数值 | 意义 |
---|---|---|---|
type | 4字节 | 51 E5 74 64 | PT_GNU_STACK 段 |
off | 4字节 | 00 00 00 00 | 段在文件中的偏移是 0 字节 |
vaddr | 4字节 | 00 00 00 00 | 段在内存中的虚拟地址 |
paddr | 4字节 | 00 00 00 00 | 同段在文件中的偏移量 |
filesz | 4字节 | 00 00 00 00 | 段在文件中的大小是 0 字节 |
memsz | 4字节 | 00 00 00 00 | 段在内存中的大小是 0 字节 |
flags | 4字节 | 07 00 00 00 | 段的权限是可写、可读、可执行 |
align | 4字节 | 04 00 00 00 | 段的对齐方式是 4 字节 |
因此只有第一个程序头表是需要加载的。这个程序头表指出的加载位置 0x80100000
和内核程序的代码段 .text
的位置是一样的。
而要加载的段是 .text .rodata .stab .stabstr .data .bss
,这些段在内存中的大小总和是0x008111 + 0x000672 + 0x000001 + 0x000001 + 0x002596 + 0x00715c = 73335 即 0x11e77
按照对齐要求 0x1000
对齐后为 75516
即 0x000126fc
(注意大小端转换,FC 26 01 00 是按照小端排列的,转换成正常的十六进制数为 0x000126fc)和 ELF 程序头表中的内存大小信息一致。
我们再算算这些段在文件中的大小,由于这些段在文件中是顺序排列的,所以用 .bss段
的文件偏移量减去 .text段
的文件偏移量 0x00c596 - 0x001000 = 46486
这也是和 ELF 程序头表中段在文件中大小的信息一致。
进入内核
在kernel.ld
文件中,找到:
1 | ENTRY(_start) # 内核的代码为段执行入口:_start |
因此就可以让系统正确加载内核。
上面提到的_start
在entry.S
这个汇编文件里面。
1 | # 多系统加载头。运行多系统加载程序的部分。 |
multiboot_header
这部分是为了方便通过GNU GRUB来引导 xv6 系统的。而V2P_WO
的定义在 memlayout.h
文件中:
1 |
|
它的作用是将内存虚拟地址转换成物理地址。里面KERNBASE
定义为:
1 |
entry
部分如下所示:
1 | # 进入xv6的启动程序,这时候还没有开启分页 |
开启 4MB 内存分页支持
这是通过设置寄存器 cr4
的 PSE
位来完成的。cr4
寄存器是个 32 位的寄存器目前只用到低 21 位,每一位的至位都控制这一些功能的状态,所以 cr4
寄存器又叫做控制寄存器。
PSE
位是 cr4
控制寄存器的第 5 位,当该位置为 1 时表示内存页大小为 4MB,当置为 0 时表示内存页大小为 4KB。
建立内存页表
和分段机制一样,分页机制同样是管理内存的一种方式,只不过这种方式相对于分段式来说更为先进,也是目前主流的现代操作系统所使用的内存管理方式。
通过分页将虚拟地址转换为物理地址这项工作是由 MMU
(内存管理单元)负责的,以 x86 32 位架构来说它支持两级分页(Pentium Pro下分页可以是三级),这也是由 MMU
决定的。同时 x86 架构支持 4KB、2MB 和 4MB 单位页面大小的分页。当然无论以多少级进行分页,分页机制的原理是相通的,我们就以两级分页来说。
以 32 位系统为例,其内存虚拟地址是 32 位的,这里我们先假设页面大小是 4KB ,此时 32 位的虚拟地址被分为三个部分,从高位到低位分别是:10 位的一级页表索引,10 位的二级页表索引,12 位的页内偏移量。
在 4KB 页面大小情况下 32 位的虚拟地址被分为三个部分,从高位到低位分别是:10 位的一级页表索引,10 位的二级页表索引,12 位的页内偏移量。
cr3
寄存器中保存着一级页表所在的内存物理地址,当给出一个虚拟地址后,根据 cr3
的地址首先找到一级页表在内存上的存放位置。上面我们说到虚拟地址的高 10 位为一级页表的索引,所以 2^10 = 1024
即一级页表一共有 1024 个元素,通过虚拟地址高 10 位的索引我们找到其中一个元素,而这个元素的值指向二级页表在内存中的物理地址。
同理,虚拟地址中紧挨着高 10 位后面的 10 位是二级页表索引,因此二级页表也有 1024 个元素,通过虚拟地址的二级页表索引找到其中的一个元素,该元素指向内存分页中的一个页的地址。
通过二级页表我们现在找到了内存上的一页物理页。根据现在的设定,一物理页的大小是 4KB,在虚拟地址上的低 12 位索引找到想要的数据(2^12 = 4096 = 4KB
)。
而根据二级页表和每页内存的大小我们也不难算出:1024个一级页表项 x 1024个二级页表项 x 4KB页面大小 = 4GB,正好是 32 位系统的最大内存寻址。
这里我们再额外算一笔账,二级页表中每个表项占 32 位,所以一个一级页表的总体积是 4byte x 1024 = 4KB
,而每个一级页表项都对应一个二级页表,所以全部二级页表的总体积是 4KB x 1024 = 4MB
,即二级页表分页机制自身内存占用也要约 4MB 外加 4KB。
我们还要额外提一下页表项。上面刚说过每个页表项占 32 位,它也是分两个部分:高 20 位是基地址,低 12 位是控制标记位。所以当我们通过一级页表索引在一级页表中查找时是这样的:一级页表项地址 = cr3寄存器高20位 + ( 10位一级页表索引 << 2 );通过二级页表索引在二级页表中查找时是这样的:二级页表项地址 = 一级页表项高20位 + ( 10位二级页表索引 << 2 )。
- 注:页表项地址 = 基地址 + ( 索引 x 页表项大小 ) = 基地址 + ( 索引 x 4字节 ) = 基地址 + ( 索引 << 2 )
最后我们再看看页表项低 12 位控制位都代表什么意义:
1 | + 11 + 10 + 9 + 8 + 7 + 6 + 5 + 4 + 3 + 2 + 1 + 0 + |
然后我们再说回 xv6。
到目前为止我们知道 xv6 开启了 4MB 内存页大小,在 x86 架构下当通过 cr4
控制寄存器的 PSE
位打开了 4MB 分页后 MMU 内存管理单元的分页机制就会从二级分页简化为一级分页。
即虚拟地址的高 10 位仍然是一级页表项索引,但是后面的 22 位则全部变为页内偏移量(因为一页有 2^22 = 4MB
大小)。
我们来看看这个一级页表的结构:
1 | # Set page directory |
通过代码我们知道页表地址是存在一个叫做 entrypgdir
的变量中了,通过文本搜索可以在 main.c
文件的最后找到这个变量,我们看一下:
1 | // Boot page table used in entry.S and entryother.S. |
将这些宏定义都转义过来我们看看这个页表的样子:
1 | unsigned int entrypgdir[1024] = { |
可见这个页表非常简单,只有两个页表项 0x00000000
和 0x80000000
,而且两个页表项索引的内存物理地址都是 0 ~ 4MB
,其他页表项全部未作设置。而且通过这两个页表项的值也可以清楚的看出这段基地址为 0 的 4MB 大小的内存页还是特级权限内存页(低 12 位的控制位对应关系已经附在上面解释控制位的示意图里了)。它仅仅只是一个临时页表,它只保证内核在即将打开内存分页支持后内核可以正常执行接下来的代码,而内核在紧接着执行 main
方法时会马上再次重新分配新的页表,而且最终的页表是 4KB 单位页面的精细页表。
开启内存分页机制
将 cr0
寄存器的第 31 位置为 1 就可以启动内存分页。
这里还要在提一句,至此我们开启了内存分页机制,接下来内核的代码执行和数据读写都要经过 MMU 通过分页机制对内核指令地址和数据地址的转换,那么目前的页表是如何保证在转换后的物理地址是正确的,如何保证内核可以继续正常运行的呢?
我们来分析一下。
根据 kernel.ld
链接器脚本的设定,内核的虚拟地址起始于 0x80100000
即内核代码段的起始处,而内核的代码段被放置在内存物理地址 0x100000
处。我们刚刚看到目前的临时页表将虚拟地址 0x80000000
映射到物理内存的 0x0
处,所以我们来尝试用刚刚了解到的内存分页机制来解析一下 0x80100000
虚拟地址最后转换成物理地址是多少。
1 | 0x80100000 = 1000 0000 00|01 0000 0000 0000 0000 0000 |
说白了就是通过页表将内核高端的虚拟地址直接映射到内核真实所在的低端物理内存位置。
这样虽然保证了在分页机制开启的情况下内核也可以正常运行,但也限制了内核最多只能使用 4MB 的内存,不过对于现在的内核来说 4MB 足够了。
设置内核栈顶位置并跳转到 main 执行
这里通过 .comm
在内核 bbs 段开辟了一段 KSTACKSIZE = 4096 = 4KB
大小的内核栈并将栈顶设置为这段数据区域的末尾处(栈是自上而下的嘛),最后通过 jmp
语句跳转到 main
方法处继续执行。
其他内核的运行
另外一个启动xv6内核的代码是entryother.S
,将每一个非引导CPU从16位切换至32位,然后进入mpenter()
函数中,最终开始CPU协同调度。其中很大一部分都与bootloader.S
相同。
具体工作在main()
函数中被调用,因此在下一部分介绍。
进入main()函数
现在还需要将CPU的第二个核启动,这是在main()
函数中调用的mpinit()
函数负责。
mpinit()
函数在mp.c
文件中。具体定义如下:
1 | void |
其中结构体cpu
的定义如下(在proc.h
中):
1 | // 每个CPU的状态 |
并且定义:
1 | extern struct cpu cpus[NCPU]; |
后面两句其实就是通过指针引用到当前的cpu
,我觉得应该是相当于给这两个变量赋值?
因为每个CPU都有独立的gdt
,所以在设置gdt
的时候可以这样设置(见vm.c
中的seginit()
函数):
1 | // CPU和进程的映射,对于每一个CPU都是私有的 |
通过提前设置gs
寄存器和gdt
的描述符,便能直接通过cpu
和proc
变量访问当前CPU的结构体和运行进程。
main()
函数调用的mpinit()
初始化了cpus
结构体数组,并确定了lapic
地址和ioapicid
。
1 | uchar ioapicid; |
具体的初始过程如下:
1 | lapic = (uint*)conf->lapicaddr; |
通过在地址空间中找到mp
的数据结构后,得到每个CPU的apicid
和CPU数量。
通过mpinit()
函数,有关的cpus
结构体也初始完成了,接下来在main()
函数中调用startothers
函数启动其他CPU。
startothers
让其他CPU执行名为entryother.S
对应的代码.
entryother.S
是作为独立的二进制文件与内核二进制文件一起组成整体的ELF文件,通过在LD链接器中-b参数来整合一个独立的二进制文件,在内核中通过_binary_entryother_start
和_binary_entryother_size
来引用,具体的makefile
如下:
1 | $(LD) $(LDFLAGS) -T kernel.ld -o kernelmemfs entry.o $(MEMFSOBJS) -b binary initcode entryother fs.img |
entryothers
在生成二进制文件时指定入口点为start以及加载地址和链接地址都为0x7000
1 | entryother: entryother.S |
在startothers
中,首先将entryothers
移动到物理地址0x7000
处使其能正常运行,在这里需要注意的是此时entryothers
相当于CPU刚上电的情形,因为这是其他CPU最初运行的内核代码,所以没有开启保护模式和分页机制,entryothers
将页表设置为entrypgdir
,在设置页表前,虚拟地址等于物理地址
1 | // Write entry code to unused memory at 0x7000. |
然后循环逐个开启每个CPU让每个CPU从entryothers
中start
标号开始运行:
1 | for(c = cpus; c < cpus+ncpu; c++){ |
在这里同时还设置了每个CPU特有的内核栈以及共有的页表,并设置mpenter
为entryothers
最后跳转回内核的地址,entryothers
主要进行CPU必要的初始化:从实模式切换到保护模式,开启分页机制,最后跳转到mpenter
处,跳回内核。
1 | for(c = cpus; c < cpus+ncpu; c++){ |
mpter
设置新的内核页表和进行段初始化,最后调用mpmain
开始调度进程。
1 | switchkvm(); |
至此,所有的CPU都已经正常工作。