本系列文章主要记录阅读《操作系统真相还原》一书的笔记,主要是记录实现部分,如果您觉得看着很唐突的话很正常,因为我主要是记录代码和实现的过程,如果您能直接看懂的话,那功力是比较深厚的了,不过如果您没看过这本书的话,我还是非常建议您看着这本书和我一起做实验。
很久之前就想要实现一个内核,就算是抄也想要抄一遍。虽然这是一件重复造轮子的事情,但我个人认为这是任何一个想深入理解内核的人都需要走的一步,Windows和Linux在很多方面是类似的,深入了解其底层原理,你会发现不过也就是一个软件罢了。至于为何要写一篇文章来记录这繁琐枯燥的过程,一方面是因为自己喜欢记录一些学习过程,之后不说100%,至少80%可能是会参考到的。另一方面自己很久之前也答应了一些人要写个内核,却迟迟没有开始,说到这我都不好意思了。
关于操作系统实现的书籍我自己的阅读顺序如下,我自己认为先从Linux平台下手再到Windows比较好,当然也有很多其他很好的书籍,像《一个64位操作系统的设计与实现》、《30天自制操作系统》等,我认为选个一两本就足够了,带着目的去读书最重要。Anyway 希望这系列文章能够帮到你 :)
《操作系统真相还原》
《x86汇编语言从实模式到保护模式》
环境搭建
实验环境如下
主机 | 虚拟机(Vmware 15.5.0 build) | 实验机(Ubantu中安装) |
---|---|---|
Windows 10 1903 x64 | Ubantu 16.04 x64 | Bochs 2.6.2 |
首先安装一系列依赖
1 | sudo apt-get install build-essential |
放入网上下载好的bochs 2.6.2版本,解压安装
1 | tar zxvf bochs-2.6.2.tar.gz |
设置环境属性
1 | ./configure \ |
直接sudo make
编译正常情况会出现以下错误
1 | [...] |
找到Makefile文件LIBS =
这句最后面添加上-lpthread
1 | LIBS = -lm -lgtk-x11-2.0 -lgdk-x11-2.0 -lpangocairo-1.0 -latk-1.0 -lcairo -lgdk_pixbuf-2.0 -lgio-2.0 -lpangoft2-1.0 -lpango-1.0 -lgobject-2.0 -lglib-2.0 -lfontconfig -lfreetype -lpthread |
重新sudo make
编译,然后sudo make install
安装,在bochs目录下创建一个bochsrc.disk
配置文件
1 | # Configuration file for Bochs |
运行即可,路径为/home/guang/soft/bochs-2.6.2/bin
,之后的命令能加sudo
的都加上,避免不必要的错误
第一次输入直接回车,第二次输入我们的bochsrc.disk即可设置我们初始化文件
1 | You can also start bochs with the -q option to skip these menus. |
运行之后会中断提示Mouse capture off
,这个时候输入c继续运行即可,运行成功如下图,这里会提示没有设置设备信息
设置设备需要运行bximage
进行模拟,使用方法如下
1 | Usage: bximage [options] [filename] |
如下方式创建名为hd60M.img
的虚拟镜像
在之前的bochsrc.disk
配置文件中添加一行ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63
,重新指定配置文件运行
1 | sudo ./bochs -f bochsrc.disk |
再次报错,这次提示的错误和之前的不太一样,意思是这不是一个启动盘,后面我们需要编写具体的启动盘,故完成到这里环境搭建完毕
实模式
BIOS
BIOS即输入输出系统,是按下主机键之后第一个运行的软件,其主要工作有
- 调用检测、初始化硬件功能
- 建立中断向量表(IVT)
- 校验启动盘中位于0盘0道1扇区的内容
实模式下的1MB内存布局如下,其中0~0x9FFFF处是DRAM,即动态随机访问内存,我们所装的物理内存就是DRAM,如DDR、DDR2等。顶部的0xF0000~0xFFFFF,这64KB的内存是ROM。
起始 | 结束 | 大小 | 用途 |
---|---|---|---|
FFFF0 | FFFFF | 16B | BIOS入口地址,此地址也属于BIOS代码,同样属于顶部的640KB字节。只是为了强调其入口地址才单独贴出来。此处16字节的内容是跳转指令jmp f000:e05b |
F0000 | FFFEF | 64KB-16B | 系统BIOS范围是F0000~FFFFF共640KB,为说明入口地址,将最上面的16字节从此处去掉了,所以此处终止地址是0XFFFEF |
C8000 | EFFFF | 160KB | 映射硬件适配器的ROM或内存映射式I/O |
C0000 | C7FFF | 32KB | 显示适配器BIOS |
B8000 | BFFFF | 32KB | 用于文本模式显示适配器 |
B0000 | B7FFF | 32KB | 用于黑白显示适配器 |
A0000 | AFFFF | 64KB | 用于彩色显示适配器 |
9FC00 | 9FFFF | 1KB | EBDA(Extended BIOS Data Area)扩展BIOS数据区 |
7E00 | 9FBFF | 622080B约608KB | 可用区域 |
7C00 | 7DFF | 512B | MBR被BIOS加载到此处,共512字节 |
500 | 7BFF | 30464B约30KB | 可用区域 |
400 | 4FF | 256B | BIOS Data Area(BIOS数据区) |
000 | 3FF | 1KB | Interrupt Vector Table(中断向量表) |
BIOS因为是第一个运行的软件,故需要用硬件对其加载到ROM(0xF0000~0xFFFFF)中,其入口点是0xFFFF0(CPU通过段地址+偏移地址即可访问),因为自己还没有加载起来,想要直接定位到0xFFFF0靠自己肯定是不行的,故也需要硬件来操作,使开机的时候强制将CS:IP
置为0xF000:0xFFF0
,实模式段基址需要乘16(左移四位),故起始地址为0xFFFF0
1 | (0xF000 << 4) + 0xFFF0 = 0xFFFF0 |
这个起始地址距离1MB内存只有16字节大小,所以这里肯定不是真正实现BIOS的地方,这里肯定只是一个类似于函数索引表的跳板,跳转到真正执行BIOS的地方。
BIOS最后的工作就是校验启动盘中位于0盘0道1扇区的内容,这里面其实主要校验的是MBR,如果此扇区末尾两个字节为0x55和0xaa,BIOS即认定这里为MBR,便将其加载到0x7c00处,然后跳转到这个地方继续执行。至于为什么这里是0x7c00书中也有提到,主要是考虑到不能覆盖中断向量表、预留栈空间等,BIOS大致流程也差不多总结到这里。下一步就是做实验。
第一个MBR
这里用NASM实现一个简单的MBR,功能是在屏幕上打印字符串”1 MBR”,背景色黑色,前景色绿色,因为有中文格式问题,复制的时候建议去除所有中文以及注释,当然最好是自己敲一遍
1 | ; mbr.S |
命令sudo nasm -o mbr.bin mbr.S
编译生成mbr.bin
文件,然后用dd命令将其写入我们镜像中的第0行,512字节大小,也就是写入一开始BIOS执行的MBR
再次运行sudo ./bochs -f bochsrc.disk
即可显示出我们写的内容,断下的时候输入c即可运行
完善MBR
这里介绍一些显存相关内容,显存地址分布
起始 | 结束 | 大小 | 用途 |
---|---|---|---|
C0000 | C7FFF | 32KB | 显示适配器BIOS |
B8000 | BFFFF | 32KB | 用于文本模式显示适配器 |
B0000 | B7FFF | 32KB | 用于黑白显示适配器 |
A0000 | AFFFF | 64KB | 用于彩色显示适配器 |
根据上表地址直接操作显卡显示文本
1 | ;主引导程序 |
效果如下,红色字体,绿色背景闪烁
上面MBR实际上没做什么事情,只是单纯的实现了和显卡交互,和打印hello world区别不是很大,我们需要不断增加新的有实际用处的功能,MBR只有512字节,无法实现对内核的加载,所以我们下一步需要让其增加读写磁盘的功能,在硬盘中加载loader,然后用loader来加载我们的内核。
MBR在第0扇区(逻辑LBA编号),loader理论上可以在1扇区,这里为了安全起见放在2扇区,预留出1扇区的空位。MBR将二扇区的内容读出来,放入实模式1MB内存分布中的可用区域(参见BIOS处的表格),因为loader中还会加载一些GDT等的描述符表,这些表不能被覆盖,随着内核越来越完整,loader的内核也不断从低地址向高地址发展,所以需要选择一个稍安全的地方,留出一些空位,这里选择0x900,大致步骤如下:
先选择通道,往该通道的sector count寄存器中写入待操作的扇区数,参考如下表格找到端口
往该通道上的三个LBA寄存器写入扇区起始地址的低24位。
往device寄存器中写入LBA地址的24~27位,并置第6位为1,使其为LBA模式,设置第4位,选择操作的硬盘(master硬盘或slave硬盘)。
往该通道上的command寄存器写入操作命令。
读取该通道上的status寄存器,判断硬盘工作是否完成。
如果以上步骤是读硬盘,进入下一个步骤。否则,完工。
将硬盘数据读出。
1 | ;主引导程序 |
我们需要在boot.inc
中指定两句头文件参数,如下所示
1 | LOADER_BASE_ADDR equ 0x900 |
这里编译需要加一个-I
参数,这里我将boot.inc
放在include
目录下
编译成功之后,发现我们还没有写loader,这会导致CPU跳转到0x900
处的地方,所以下一步我们就需要实现一个简单的loader,至少保证能简单运行下去。复习一下现在位置我们所知道的开机流程:BIOS -> MBR -> Loader
loader中的内容我们用之前MBR的即可,这里编译也是需要sudo nasm -I include/ -o loader.bin loader.S
1 | %include "boot.inc" |
dd命令指定seek参数将其放入第二个扇区
1 | sudo dd if=./loader.bin of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img bs=512 count=1 seek=2 conv=notrunc |
最后的运行效果如下
实模式的安全缺陷总结:
- 操作系统和用户属于同一特权级
- 用户程序引用的地址都是指向真实的物理地址
- 用户程序可以自由修改段基址,自由访问所有内存
保护模式初探
32位CPU在16位模式下运行的状态为实模式,当CPU发展到32位的时候出现保护模式,保护模式下CPU变成了32根地址总线,32根地址总线足够访问4GB的空间,为了满足4GB空间寻址,寄存器宽度也增加了一倍,从原来的2字节变为4字节32位。除了段寄存器仍然使用16位,其余通用寄存器都提升到32位。
寄存器要保持向下兼容,不会重新构造原来的基础设备而是在原有的寄存器基础上进行了拓展。经过拓展后的寄存器在原有名字上加了个e,如图所示
段描述符
保护模式中的段基址不再是像实模式那样直接存放物理地址,段寄存器中要记录32位地址的数据段基址,16位肯定是装不下的,所以段基址都存储在一个数据结构中——全局描述符表。其中每个表项称为段描述符,其大小为64字节,用来描述各个内存段的起始地址、大小、权限等信息。而这里段寄存器中存放的是段选择子 selector 。如果把全局描述符表当作数组来看的话,段选择子就是数组的下标,用来索引段描述符。该全局描述符表很大,所以放在内存中,由GDTR寄存器指向它。
Tip:因为段描述符是在内存中,CPU访问较慢,效率不高,故在80286的保护模式中增加了一个段描述符缓冲寄存器用来提高效率。CPU每次将获取到的内存信息整理之后存入此寄存器,之后每次访问相同的段时,直接读取对应的段描述符缓冲寄存器即可。
因为80286始终是16位CPU,通用寄存器还是16位宽,寻址空间为2的24次方也就是16MB,单个寄存器依旧无法访问到全部内存空间,这就有了80386的登场,参数总结如下
版本 | CPU位数 | 寄存器宽 | 地址线宽 | 寻址空间 |
---|---|---|---|---|
8086 | 16 | 16 | 20 | 2^20 = 1MB |
80286 | 16 | 16 | 24 | 2^24 = 16MB |
80386 | 32 | 32 | 32 | 2^32 = 4GB |
实模式和保护模式的内存寻址方式如下图所示
32位CPU既支持实模式有支持保护模式,为了区分当前指令到底是哪个模式下运行的,编译器提供了伪指令bits
指令格式:[bits 16]或[bits 32],分别对应16位和32位
如下面的例子
1 | [bits 16] |
模式之间可以相互使用对方环境下的资源。比如,16位实模式下可以使用32位保护模式下的寄存器。如果要用另一模式下的操作数大小,需要在指令前添加指令前缀0x66,将当前指令模式临时转变为另一种模式。这就是反转的意义,不管当前模式是什么,总是转变成相反的运行模式。这个转换是临时的,只有在当前指令才有效。如下图
比如,在指令中添加了0x66反转前缀后:
假如当前运行模式是16位实模式,操作数大小变为32位。
假设当前运行模式是32位保护模式,操作数大小变为16位。
操作数可以在模式间相互转换,那么寻址方式一样可以,只需要在它的指令前加上0x67反转前缀即可。如下图
下面总结一下,保护模式首先是必须向前兼容的,故其访问内存依然是段基址:段内偏移
的方式,结合前面总结过实模式的一些安全问题,想要解决这些问题就得既保证向前兼容,又保证安全性。CPU工程师想到的方法就是增加更多的安全属性位,下图即是段描述符格式:
其实对于各个字段的解释,我更倾向于用的时候去查,因为随着CPU的更新换代,如今的一些位可能有变化,要参考当然是参考最新的比较好,比如参考intel手册之类的权威资料,无非就是保存一些段的属性(可读、可写、是否存在等),权限(Ring0-Ring3),基址,界限范围等信息。其访问内存的形式如下图所示
全局描述符
全局描述符表GDT相当于是一个描述符的数组,数组每一个元素都是8个字节的描述符,而选择子则是提供下标在GDT中索引描述符。假设 A[10] 数组即为GDT表,则
- GDT表相当于数组A
- 数组中每个数据A[0]~A[10]相当于描述符
- A[0]~A[10]中的0~10索引下标则是选择子
全局描述符表是公用的,GDTR这个专门的寄存器则存放GDT表的内存地址和大小,是一个48位的寄存器,对这个寄存器操作无法用mov等指令,这里用的是lgdt
指令初始化,指令格式是:lgdt 48位内存数据
其中前16位是GDT以字节为单位的界限值,相当于GDT字节大小减1。后32位是GDT的起始地址。由于GDT的大小是16位二进制,表示范围是2^16 = 65536字节。每个描述符大小是8字节,故GDT中最多可容纳的描述符数量是65536/8 = 8198
,也就是可以容纳8192个段或门。
局部描述符表
按照CPU的设想,一个任务对应一个局部描述符表LDT,切换任务的时候也会切换LDT,LDT也存放在内存中,由LDTR寄存器指向,加载的指令为lldt
。对于操作系统来说,每个系统必须定义一个GDT,用于系统中的所有任务和程序。可选择性定义若干个LDT。LDT本身是一个段,而GDT不是。这种表在这里并不常用所以就不继续展开了,感兴趣的小伙伴可以自行百度。
段选择子
首先复习一下段寄存器CS、DS、ES、FS、GS、SS,保护模式下段寄存器中存放的即是段选择子,结构如下,其中0-1位表示特权级,2位TI表示选择子是在GDT中,还是在LDT中索引描述符,剩下的13位就是索引部分,2^13 = 8192,这也刚好和GDT最多容纳的段或门的数量相符。
举个访问内存的例子,例如选择子是 0x8,将其加载到 ds 寄存器后,访问 ds: 0x9 这样的内存,其过程是首先拆分 0x8 为二进制 0000 0000 0000 1000
然后得到 0x8 的低 2 位是RPL,其值为 00。第 2 是 TI ,其值 0,表示是在 GOT 中索引段描述符。用 0x8 的高 13 位 0x1 在 GOT 中索引,也就是 GOT 中的第 1 个段描述符(GDT 中第 0 个段描述符不可用)。假设第 1 个段描述符中的 3个段基址部分,其值为 0xl234oCPU 将 0xl234 作为段基址,与段内偏移地址 0x9 相加, 0x1234 + 0x9 = 0x123d
。用所得的和 0x123d 作为访存地址。
Tip:GDT中第0个段描述符不可用是为了防止未初始化段选择子,如果未初始化段选择子就会访问到第0个段描述符从而抛出异常。
为了让段基址:段内偏移
策略继续可用,CPU采取的做法是将超过1MB的部分自动绕回到0地址,继续从0地址开始映射。相当于把地址对1MB求模。超过1MB多余出来的内存被称为高端内存区HMA。
这种地址绕回的做法需要通过两种情况分别讨论:
对于只有20位地址线的CPU,不需要任何操作便能自动实现地址绕回
当其他有更多地址总线的时候,因为CPU可以访问更多的内存,所以不会产生地址回滚。这种情况下的解决方案就是对第21根地址线进行操作。开启A20则直接访问物理地址即可,关闭A20则使用回绕方式访问。
打开A20的操作方法有以下三个步骤,主要是将0x92端口第一位置一即可
1 | in al, 0x92 |
CR0寄存器
CRx系列寄存器属于控制寄存器一类,这里主要介绍CR0寄存器,这个寄存器如下图所示,其中第0位PE位表示是否开启保护模式
其他位如下图所示,这里暂时不深入讨论
对CR0的PE位操作如下所示
1 | mov eax,cr0 |
进入保护模式
现在基础知识总结的差不多了,进入下一个实验阶段,更新我们的mbr和loader,因为我们的loader.bin会超过512字节,所以要把mbr.S中加载loader.bin的读入扇区数增大,目前是1扇区,这里直接改为4扇区
1 | ... |
如下图所示,cx 寄存器中存放的这个参数非常重要,代表读入扇区数,如果loader.bin
的大小超过mbr读入的扇区数,就需要对这个参数进行修改
接下来就是更新boot.inc
,里面存放的是loader.S
的一些符号信息,相当于头文件,比之前主要多定义了GDT描述符的属性和选择子的属性。Linux使用的是平坦模型,整个内存都在一个段里,这里平坦模型在我们定义的描述符中,段基址是0,段界限 * 粒度 = 4G
粒度选的是4k,故段界限是 0xFFFFF
1 | ;--------------------- loader 和 kernel--------------------- |
下面修改 loader.S
1 | %include "boot.inc" |
同之前的方法编译,注意这里loader.bin编译后为615个字节,需要2个扇区大小,写入磁盘时要给count赋值为2
运行结果如下,其中1 MBR
来自实模式下的mbr.S,2 loader in real
来自实模式下用BIOS中断0x10实现的,左上角第二行的P
是在保护模式下输出的。
查看GDT表中的内容和我们设置的相符,其中第0个不可用。查看寄存器信息PE位设置为1表示已经进入保护模式。
保护模式对内存的保护体现在如下几个方面,这里简单总结一下,更详细的内容网上有很多更详细的说明,当然最权威的还是intel手册。
向段寄存器加载段选择子时的保护
当引用一个内存段时,实际上就是往段寄存器中加载个段选择子,为了避免非法引用内存段的情况,会检查选择子是否合理,判断方法就是通过验证索引值是否出现越界,越界则抛出异常。有如下表达式
描述符表基地址+选择子中的索引值*8+7<=描述符表基地址+描述符表界限值
总结如下图
检查完选择子就该检查段描述符中 type 字段,也就是段的类型,如下图所示
检查完类型后检查P位,P位表示该段是否存在,1表示存在,0表示不存在。
代码段和数据段的保护
代码段和数据段主要保护措施是当CPU访问一个地址的时候,判断该地址不能超过所在内存段的范围。简单总结如下图所示,出现这种跨段操作就会出现异常。
栈段的保护
段描述符type中的e位表示扩展方向,栈可以向上扩展和向下扩展,下面就是检查方式
- 对于向上拓展的段,实际段界限是段内可以访问的最后一个字节
- 对于向下拓展的段,实际段界限是段内不可以访问的第一个字节
等价于如下表达式
1 | 实际段界限+1<=esp-操作数大小<=0xFFFFFFFF |
保护模式进阶
获取物理内存容量
Linux获取内存容量方法有三种,本质上分别是BIOS中断0x15的3个子功能,BIOS是实模式下的方法,只能在保护模式之前调用。总结如下
利用BIOS中断0x15子功能0xe820获取内存
此方法最灵活,返回的内容也最丰富,内存信息的内容是地址范围描述符来描述的(ARDS),每个字段4字节,一共20字节,调用0x15返回的也就是这个结构。其中Type字段表示内存类型,1表示这段内存可以使用;2表示不可用使用此内存;其它表示未定义,将来会用到
字节偏移量 | 属性名称 | 描述 |
---|---|---|
0 | BaseAddrLow | 基地址的低32位 |
4 | BaseAddrHigh | 基地址的高32位 |
8 | LengthLow | 内存长度的低32位,以字节为单位 |
12 | LengthHigh | 内存长度的高32位,以字节为单位 |
16 | Type | 本段内存的类型 |
用0x15子功能0xe820调用说明和调用步骤如下
- 填写好”调用前输入”中列出的寄存器
- 执行中断调用 int 0x15
- 在CF位为0的情况下,”返回后输出”中对应的寄存器中就有结果
利用BIOS中断0x15子功能0xe801获取内存
此方法最多识别4G的内存,结果存放在两组寄存器中,操作起来要简便一些,调用说明和调用步骤如下
- AX寄存器写入0xE801
- 执行中断调用 int 0x15
- 在CF位为0的情况下,”返回后输出”中对应的寄存器中就有结果
利用BIOS中断0x15子功能0x88获取内存
此方法最多识别64MB内存,操作起来最简单,调用说明和调用步骤如下
- AX寄存器写入0x88
- 执行中断调用 int 0x15
- 在CF位为0的情况下,”返回后输出”中对应的寄存器中就有结果
下面结合这三种方式改进我们的实验代码,下面是loader
,我们将结果保存在了total_mem_bytes
中,重要的一些地方都有注释,更详细的内容建议参考书中P183
1 | %include "boot.inc" |
在mbr.S
中也需要修改一处内容,我们跳转的内容要加上0x300,原因是在 loader.S 中loader_start
计算如下
(4个段描述符 + 60个段描述符槽位) * 8字节 = total_mem_bytes_offset
(4 + 60) * 8 = 512 = 0x200
total_mem_bytes + gdt_ptr + ards_buf + adrs_nr + total_mem_bytes_offset = loader_start
4 + 6 + 244 + 2 + 0x200 = 0x300
修改片断如下
1 | [...] |
运行结果如下,这里我们用xp 0xb00
查看我们的结果,0x02000000
换算过来刚好是我们bochsrc.disk
中 megs 设置的32MB大小
启动分页机制
分页机制是当物理内存不足时,或者内存碎片过多无法容纳新进程等情况的一种应对措施。假如说此时未开启分页功能,而物理内存空间又不足,如下图所示,此时线性地址和物理地址一一对应,没有满足进程C的内存大小,可以选择等待进程B或者A执行完获得连续的内存空间,也可以将A3或者B1段换到硬盘上,腾出一部分空间,然而这些IO操作过多会使机器响应速度很慢,用户体验很差。
出现这种情况的本质其实是在分段机制下,线性地址等价于物理地址。那么即使在进程B的下面还有10M的可用空间,但因为两块可用空间并不连续,所以进程C无法使用进程B下面的10M可用空间。
按照这种思路,只需要通过某种映射关系,将线性地址映射到任意的物理地址,就可以解决这种问题了。实现线性地址的连续,而物理地址不需要连续,于是分页机制就诞生了。
一级页表
在保护模式下寻址依旧是通过段基址:段内偏移
组成的线性地址,计算出线性地址后再通过判断分页位是否打开,若打开则开启分页机制进行检索,如下图所示
分页机制的作用有
- 将线性地址转换成物理地址
- 用大小相等的页代替大小不等的段
分页机制的作用如下图所示,分页机制来映射的线性地址便是我们经常说的虚拟地址
因为页大小 * 页数量 = 4GB
,想要减少页表的大小,只能增加一页的大小。最终通过数学求极限,定下4KB为最佳页大小。页表将线性地址转换成物理地址的过程总结如下图,首先通过计算线性地址高20位索引出页表中的基址,然后加上低12位计算出最终的物理地址,下图中0x9234即是最终的物理地址
二级页表
无论是几级页表,标准页的尺寸都是4KB。所以4GB的线性地址空间最多有1M个标准页。一级页表是将这1M个标准页放置到一张页表中,二级页表是将这1M个标准页平均放置1K个页表中,每个页表包含有1K个页表项。页表项是4字节大小,页表包含1K个页表项,故页表的大小同样为4KB,刚好为一页。
为了管理页表的物理地址,专门有一个页目录表来存放这些页表。页目录表中存储的页表称为页目录项(PDE),页目录项同样为4KB,且最多有1K个页目录项,所以页目录表也是4KB,如下图所示
二级页表中虚拟地址到物理地址的转换也有很大的变化,具体步骤如下
- 用虚拟地址的高 10 位乘以 4,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。
- 用虚拟地址的中间 10 位乘以 4,作为页表内的偏移地址,加上在第 1 步中得到的页表物理地址,所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
- 虚拟地址的高 10 位和中间 10 位分别是 PDE PIE 的索引值,所以它们需要乘以 4。但低 12 位就不是索引值了,其表示的范围是 0~0xfff,作为页内偏移最合适,所以虚拟地址的低 12 位加上第二步中得到的物理页地址,所得的和便是最终转换的物理地址。
还是用书中的图最直观,下图表示mov ax, [0x1234567]
的转换过程,可以发现cr3寄存器其实指向的是页目录表基地址
PDE和PTE的结构如下图所示
从右到左各属性总结如下表
属性位 | 意义 |
---|---|
P | 存在位,为1时表示该页在物理内存中,为0表示不在物理内存中 |
RW | 读写位,为1时可读可写,为0是可读不可写 |
US | 特权位,为1时表示处于普通用户,0~3特权级可访问,为0表示超级用户,0~2特权级可访问 |
PWT | 页级通写位,为1表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存 |
PCD | 页级高速缓存禁止位,为1表示该页启用高速缓存 |
A | 访问位,为1表示该页被CPU访问过 |
D | 脏页位,当CPU对一个页面执行写操作,此为被赋1 |
PAT | 页属性表位,能够在页面一级的粒度上设置内存属性 |
G | 全局位,为1表示该页在高速缓存TLB中一直保存 |
AVL | 表示软件,系统可用该位,和CPU无关 |
总结这些步骤,我们启用分页机制需要做的事情如下
- 准备好页目录表及页表
- 将页表地址写入控制寄存器cr3
- 寄存器cr0的PG位置1
下面是创建页目录及页表的代码
1 | ; 创建页目录及页表 |
在boot.inc中添加如下信息
1 | ; loader 和 kernel |
进行完第一步的内容,之后的操作相对就简单了,将页表地址写入控制寄存器cr3寄存器和将cr0的PG位置1的操作整合起来的loader.S
如下所示
1 | %include "boot.inc" |
编译运行,其中编译count的参数根据实际大小调整,这里我编译设置的是3,运行结果如下图,其中红框中gdt段基址已经修改为大于0xc0000000
,也就是3GB之上的内核地址空间,通过info tab
可查看地址映射关系,其中箭头左边是虚拟地址,右边是对应的物理地址
总结虚拟地址获取物理地址的过程:
先要从 CR3 寄存器中获取页目录表物理地址,然后用虚拟地址的高 10 位乘以 4 的积作为在页目录表中的偏移量去寻址目录项 pde ,从 pde 中读出页表物理地址,然后再用虚拟地址的中间 10 位乘以 4 的积作为在该页表中的偏移量去寻址页表项 pte,从该 pte 中读出页框物理地址,用虚拟地址的低 12 位作为该物理页框的偏移量。
快表TLB
因为从虚拟地址映射到物理地址确实比较麻烦,所以为了提高效率,intel自然想得到用一个缓存装置TLB。结构如下,更新TLB的方法有两种,重新加载CR3和指令invlpg m
,其中m表示操作数为虚拟内存地址,如更新虚拟地址0x1234对应的条目指令为invlpg [0x1234]
虚拟地址高20位(虚拟页框号) | 属性位 | 物理地址高20位(物理页框号) |
---|---|---|
… | … | … |
ELF格式浅析
我们下一步的目标是在内核中使用C语言,因为C语言是高级语言,在内核中的C语言用gcc编译需要指定很多参数,避免编译器添加许多不必要的函数。然而在Linux下C语言编译而成的可执行文件格式为ELF,想在我们的内核中运行ELF程序首先需要对其进行解析,下面简单介绍一下ELF文件格式,ELF文件格式分为文件头和文件体部分,文件头存放程序中其他的一些头表信息,文件体则具体的对这些表进行描述。ELF格式的作用体现在链接阶段和运行阶段两个方面,其布局如下图所示
其中elf header的结构如下所示,这里的很多结构都来自Linux源码/usr/include/elf.h
1 | /* 32位elf头 */ |
其中的一些数据类型如下
数据类型名称 | 字节大小 | 对齐 | 意义 |
---|---|---|---|
Elf32_Half | 2 | 2 | 无符号中等大小的整数 |
Elf32_Word | 4 | 4 | 无符号大整数 |
Elf32_Addr | 4 | 4 | 无符号程序运行地址 |
Elf32_Off | 4 | 4 | 无符号的文件偏移量 |
下面介绍一些关键成员,其中e_ident[16]
数组功能如下,其大小是16字节,存放一些文件属性信息
e_type
占用2字节,指定 elf 目标文件的类型
elf目标文件类型 | 取值 | 意义 |
---|---|---|
ET_NONE | 0 | 未知目标文件格式 |
ET_REL | 1 | 可重定位文件 |
ET_EXEC | 2 | 可执行文件 |
ET_DYN | 3 | 动态共享目标文件 |
ET_CORE | 4 | core文件,即程序崩溃时其内存映像的转储格式 |
ET_LOPROC | 0xff00 | 特定处理器文件的扩展下边界 |
ET_HIPROC | 0xffff | 特定处理器文件的扩展上边界 |
剩下的一些字段如下,想更具体了解的可以自己百度
字段 | 大小(字节) | 意义 |
---|---|---|
e_machine | 2 | 支持的硬件平台 |
e_version | 4 | 表示版本信息 |
e_entry | 4 | 操作系统运行该程序时,将控制权转交到的虚拟地址 |
e_phoff | 4 | 程序头表在文件内的字节偏移量。如果没有程序头表,该值为0 |
e_shoff | 4 | 节头表在文件内的字节偏移量。若没有节头表,该值为0 |
e_flags | 4 | 与处理器相关的标志 |
e_ehsize | 2 | 指明 elf header 的字节大小 |
e_phentsize | 2 | 指明程序头表(program header table )中每个条目(entry)的字节大小 |
e_phnum | 2 | 指明程序头表中条目的数量。实际上就是段的个数 |
e_shentsize | 2 | 节头表中每个条目的字节大小,即每个用来描述节信息的数据结构的字节大小 |
e_shnum | 2 | 指明节头表中条目的数量。实际上就是节的个数 |
e_shstrndx | 2 | 指明 string name table 在节头表中的索引 index |
下面再介绍一下Elf32_Phdr
结构,此段是指程序中的某个数据或代码的区域段落,例如代码段或数据段,这个段不是内存中的段,此段是磁盘上程序中的一个段,下面是其结构
1 | struct Elf32_Phdr |
各个字段的意义如下表
字段 | 意义 |
---|---|
p_type | 段类型 |
p_offset | 本段在文件的偏移量 |
p_vaddr | 本段在内存中起始的虚拟地址 |
p_paddr | 仅用于与物理地址相关的系统中 |
p_filesz | 本段在文件中的大小 |
p_memsz | 本段在内存中的大小 |
p_flags | 本段相关的标志 |
p_align | 本段在文件和内存中的对齐方式 |
载入内核
Linux下可以用readelf
命令解析ELF文件,下面是我们在kernel目录下新添加的测试代码,因为是64位操作系统,编译命令需要如下修改,我们下一步就是将这个简单的elf文件加载入内核,物理内存中0x900是loader.bin的加载地址,其开始部分是不能覆盖的GDT,预计其大小是小于2000字节,保守起见这里选起始的物理地址为0x1500,所以链接命令指定虚拟起始地址0xc0001500
下面通过dd
命令将其写入磁盘,为了不纠结count的赋值,这里直接赋值为200,seek赋值为9,写在第9扇区
1 | sudo dd if=./kernel.bin of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img bs=512 count=200 seek=9 conv=notrunc |
写完之后我们需要修改loader.S中的内容,分两步完成
- 加载内核:内核文件加载到内存缓冲区
- 初始化内核:需要在分页后,将加载进来的elf内核文件安置到相应的虚拟内存地址,然后跳过去执行,从此loader的工作结束
内核的加载地址选取的是0x7e00~0x9fbff
范围中的0x70000,添加如下片断
1 | ; ------------------ 加载内核 ------------------ |
下一步是初始化内核的工作,我们需要遍历kernel.bin
程序中所有的段,因为它们才是程序运行的实质指令和数据的所在地,然后将各段拷贝到自己被编译的虚拟地址中,如下添加的是在loader.S
中的内容,注释已经很详细了
1 | ; ------------------------- 加载kernel ---------------------- |
最终的一个内存布局如下,参考之前的1MB实模式地址图来对应就明白了
特权管理
特权级按照权力分为0、1、2、3级,数字越小,级别越高。计算机启动之初就在0级特权运行,MBR则就是0级权限,谈到权限就得提到TSS任务状态段,程序拥有此结构才能运行,相当于一个任务的身份证,结构如下图所示,大小为104字节,其中有很多寄存器信息,而TSS则是由TR寄存器加载的
每个特权级只能有一个栈,特权级在变换的时候需要用到不同特权级下的栈,特权转移分为两类,一类是中断门和调用门实现低权限到高权限,另一类是由调用返回指令从高权限到低权限,这是唯一一种让处理器降低权限的方法。
对于低权限到高权限的情况,处理器需要提前记录目标栈的地方,更新SS和ESP,也就是说我们只需要提前在TSS中记录好高特权级的栈地址即可,也就是说TSS不需要记录3级特权的栈,因为它的权限最低。
对于高权限到低权限的情况,一方面因为处理器不需要在TSS中寻找低特权级目标栈的,也就是说TSS也不需要记录3级特权的栈,另一方面因为低权限的栈地址已经存在了,这是由处理器的向高特权级转移指令(int、call等)实现机制决定的。下面就介绍一下权限相关的一些知识点:
CPL、DPL、RPL
CPL是当前进程的权限级别(Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于cs寄存器的低两位。
RPL是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL,它说明的是进程对段访问的请求权限,有点像函数参数。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样它对该段仍然只有特权为3的访问权限。
DPL存储在段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。当进程访问一个段时,需要进程特权级检查,一般要求DPL >= max {CPL, RPL}
门结构
处理器只有通过门结构才能由低特权级转移到高特权级,也可以通过门结构进行平级跳转,所以门相当于一个跳板,当前特权级首先需要大于门的DPL特权级,然后才能使用门来跳到想去的特权级,处理器就是这样设计的,四种门结构分别是:任务门、中断门、陷阱门、调用门。门描述符和段描述符类似,都是8字节大小的数据结构,用来描述门通向的代码,如下所示
任务门可以放在GDT、LDT、IDT中,调用门位于GDT、LDT中,中断门和陷阱门仅位于IDT中调用方法如下
调用门
call 和 jmp 指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。 call 指令使用调用门可以实现向高特权代码转移, jmp 指令使用调用门只能实现向平级代码转移。若需要参数传递,则0~4位表示参数个数,然后在权限切换的时候自动在栈中复制参数。关于调用门的过程保护,参考P240
中断门
以 int 指令主动发中断的形式实现从低特权向高特权转移, Linux 系统调用便用此中断门实现。
陷阱门
以 int3 指令主动发中断的形式实现从低特权向高特权转移,这一般是编译器在调试时用。
任务门
任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务门的选择子或任务 TSS 的选择子。
IO特权级
保护模式下,处理器中的”阶级”不仅体现在数据和代码的访问,还体现在以下只有在0特权级下被执行的特权指令
1 | hlt、lgdt、ltr、popf等 |
还有一些IO敏感指令如in、out、cli、sti
等访问端口的指令也需要在相应的特权级下操作,如果当前特权级小于 IOPL 时就会产生异常,IOTL 在 eflags 寄存器中,没有特殊的指令设置 eflags 寄存器,只有用 popf 结合 iretd 指令,在栈中修改,当然也只有在0特权下才能操作,eflags 寄存器中的 IOTL 位如下所示