32位操作系统——《操作系统真象还原》
操作系统真相还原:-),9月份开始念拖到现在,抽出空闲时间尽量做吧。
BIOS & 中断实现MBR
BIOS是计算机上第一个运行的软件,由只读存储器ROM加载。程序通电后cs:ip寄存器强制初始化为0xf000:0xfff0,就是地址0xffff0,这段代码会跳转到0xfeb50处,就是BIOS代码真正开始的地方。BIOS会检查外设信息、初始化硬件、引导加载程序。它的最后一项工作就是校验启动盘里0盘0道1扇区的内容,就是去找主引导记录MBR,确认上去末尾两个字节分别是0x55和0xaa后就表明该扇区是可加载的程序。
MBR用来引导操作系统,在被确认可引导后会被加载到物理地址0x7c00的位置(为什么是这个位置是个历史遗留问题),然后BIOS跳转到这个位置。mbr是干嘛的?

然后书上给了个实例汇编代码……用来实现mbr……这应该不是传统意义上的mbr,毕竟他的作用就是清屏、定位光标、打印一行字符串,但是模拟了BIOS启动到加载mbr、运行mbr的过程
1 | section MBR vstart=0x7c00 |
注意的点
不能把直接数赋给cs:ip寄存器,可以通过其他寄存器暂存的方法间接赋予。不然就会这样:
1 | mbr.S:22: error: invalid combination of opcode and operands |
ax寄存器高8位ah用来作为调用功能号存储,低8位al用来传递数据或参数
1 | mov ax, 0x600 ;ah功能号=6,向上或向下滚动行,即为清屏,al=0即为全部行 |
$代表当前指令地址,$$代表当前section起始地址,两个相减就是本扇区已用空间
1 | times 510-($-$$) db 0 ;填充0,使程序长度为512字节,-($-$$)表示当前地址减去该section的起始地址 |
通过nasm编译出二进制文件
1 | nasm -o mbr.bin mbr.S |
放ida里看一眼,嘿嘿

在偏移0x21的地方是汇编指令“mov ax, 7C33h”,对应源码中的“mov ax, message”,而起始虚拟地址为0x7c00的条件下message的偏移确为0x7c33,所以程序确是以0x7c00为起始编址
不能直接./mbr.bin运行(蠢比),需要BIOS引导

这么来看必须要在我的wsl上搞一个虚拟环境了。。。
————————————————————————————————————————————————————————————
安装bochs报错解决
跟着书上的步骤安装bochs,再make install步骤中可能会报错:
1 | gtk_enh_dbg_osdep.cc:20:10: fatal error: gtk/gtk.h: No such file or directory |
解决办法:
1 | sudo apt-get update |
然后重新把包删掉重新解压,make clean不是很干净
启动bochs加载bochhsrc.disk还会遇到这样的报错:
1 | Bochs is exiting with the following message: |
将bochsrc.disk中的keyboard_mapping字段改为keyboard
然后过了艰难的一段调bochs环境,最终我原封不动的下了和书上相同的bochs版本,原封不动的把配置流程走了一遍,但是并没有“没有启动盘”的报错,有点心虚。。。
首先创建一个虚拟硬盘作为我们的启动盘:
1 | bin/bximage -hd -mode="flat" -size=60 -q hd60M.img |
然后使用磁盘操作命令dd将我们的二进制文件mbr.bin数据输入到这个虚拟硬盘里:

1 | dd if=/home/marcel/OS/mbr.bin of=/home/marcel/OS/bochs/hd60M.img bs=512 count=1 conv=notrunc |
启动bochs,6,回车,c(和gdb一样的命令)启动

打印出来了一行绿色的字,从初始的回显可以看出起始地址在0xffff0,然后跳转到0xfe05b的位置,就是BIOS的起始地址,然后BIOS巴拉巴拉……最终检测到0盘0道1扇区末尾有魔数0x55aa,直接跳转到0x7c00的位置,执行了我们自己写的mbr。
再小小改进一下……
1 | section MBR vstart=0x7c00 |

Hello, OS world!
始于足下。
mbr硬件♂交互
一点点汇编&硬件补充
然后的第三章作者用两小节补充了汇编相关的内容。之前有一点点pwn基础,少说也读过(心虚)csapp,所以像看小说一样很轻松的就看完了,包括实模式、地址分段、指令执行、寄存器、栈与相关操作、寻址方式与jmp、call的调用方式、flags寄存器这些内容。巩固了一些知识,也的确有额外的收获:
在ida反汇编的时候,常常能看到这样的语句:

在之前我只是直接理解为他会无条件跳转到0x187a这个位置,short是什么?
相似的还有这样:
1 | jmp near loc_15E3 |
near和short都是表明,在汇编成机器语言时这里是相对跳转而不是直接跳转,比如上面那个jmp short 187a在机器码中就是“EB 27”,eb表示相对近转移,27就是操作数,实际上等于 当前位置与0x187a之差-当前指令长度,near也是同理,他们在机器码中都是基于当前位置(在与目标位置同段,或者说相对比较近的情况下)进行相对跳转,你看机器码中的操作数并没有出现7a18,都不是直接跳转。
书中将几种跳转方式分为相对短转移、相对近转移、间接绝对近转移、直接绝对远转移、间接绝对远转移,相对与绝对表示目标是与当前位置的偏移还是实际地址,直接间接表示目标数是直接数还是寄存器/内存引用。
这些东西知道了就是知道了,和之前不知道好像没区别。实际上有什么用呢?
想起了之前暑假的时候考研学长和我说的话,的确如此……
为了解决cpu与各个硬件之间的不协调,就有了IO接口这一中间层,帮助cpu处理硬件,就像收作业的小组长。书中说到看不到声卡和显卡是因为他们集成在主板里面了,就是集成声卡集成显卡。那时候还是2013年,ai和3A游戏在我们眼里还是很遥远的词汇,想来真是感慨。书中说03年时作者看过一个特大的显卡,就像主板插在显卡上一样,这是不是当年的独显呢?
硬件那些不多说了,看过就是看过。这个学期我甚至没报上数电实验(悲
显存MBR
之前的实践都是通过中断让BIOS给我们打印字符,而且中断向量表只存在于实模式中,那么如何不通过中断打印呢?
我们直接对显存下手。
显存文本模式下内存地址为0xb8000,我们往上面写东西就会落到显存中,显存上有了数据就会往屏幕上面打印,某种意义上来说我们就可以不通过中断调用BIOS打印字符了
1 | SECTION MBR vstart=0x7c00 |
与中断实现相比,这一版的代码改变了两处:一处是我们把gs定为显存的段寄存器,把它设为0xb800,实际上[gs:0xab]就是0xb80ab;另一处把通过中断打印的内容改成往显存文本模式内存中写字符显示的数据,就是通过gs寄存器做段寄存器寻址实现的
然后重新编译,写入虚拟硬盘,启动bochs,看看效果如何:

左上角有一个很小的“hello OS”
实际上它在不断闪烁。
mbr之can can need 硬盘
到现在好像都没做什么实事,但是大的药来了——用mbr读取硬盘内容。
这里说的硬盘是机械硬盘,就是那个有盘子,用磁头读写的硬盘。
【【硬核科普】固态硬盘为什么比机械硬盘快?快在哪里?快的这些地方影响什么体验?】https://www.bilibili.com/video/BV1dE411k7tU?vd_source=1f48e47dc1ff12763001a0341edf7ebd
如果你不知道什么是机械硬盘,什么是固态硬盘,这个科普视频讲的非常好。2024的今天机械硬盘已经被淘汰,你我的电脑里大多装的都是固态硬盘,如果上网搜索“双十一硬盘选购”,那么结果也是固态硬盘占绝大多数。会装机的室友向我展示了他的固态与机械硬盘,固态就是小小的食指大小一条,机械硬盘有我手掌大。
”固态比机械快?“
”机械完全不能用。“
这本书还是2013年成书,让人不得不感慨时代的变迁,仅仅过去了十年。
之前我们模拟的hd60M.img就是虚拟机械硬盘。
只不过还是很有意思的,不通过中断的方式直接和硬件交互的就像不断探索底层,从c到汇编到机械码,从函数调用到中断到硬件交互,就像剥洋葱,如果你愿意一层一层一层一层……
之前提到过硬件与cpu交互需要媒介,就是所谓的IO接口。硬盘的IO接口就是硬盘控制器,我们读写硬盘就是读写硬盘控制器的端口,而IO接口上的端口就是他的寄存器,换句话说我们读写硬盘、查看硬盘的状态之类的就是和硬盘控制器上的寄存器打交道。
有很多的硬盘端口……但是我们只用其中的一部分:

端口作用在读写时不同,同一个端口在读和写时可能有不同的作用。不用在意通道啥的,就是主盘接口和从盘接口,接下来都一样的
data寄存器就是管理数据的,可出也可进,既然是闸门必须大,只他一个可以存两个字节大小,其他全是8位寄存器。写硬盘时数据源源不断的送到此端口,硬盘控制器发现里面有内容了就往扇区上写,读硬盘时就是不断读这个寄存器
error读时记录失败信息,写时存储额外参数
sector count存储待读写的扇区数量
接下来三个端口(也是寄存器)用来存储读取或写入的目标硬盘地址。当然可以通过柱面-磁头-扇区的方式来存储硬盘地址,这种存储方式叫做CHS,但是这样mbr又会说看不懂思密达,那就直接简单粗暴的把每个扇区12345地编号,这样就称作LBA。这里的LBA用28位存储一个扇区的地址,low mid high分别对应地址中的低位中位高位,3*8=24不是我还有4位呢?
device出场,他的作用比较杂,首先作为一个8位寄存器它的低4位补齐LBA存储的最后一部分,其次第4位用来表示主盘(0)还是从盘(1),第6位表示LBA模式还是CHS模式,5位和7位都置0
status寄存器保存各种状态,第0位是ERR位,它是1就去找error寄存器,第3位表示数据是否准备好,第6位表示硬盘是否就绪,第7位表示硬盘是否繁忙。

command寄存器可以识别很多指令,这是其中三个:
- 0xec,硬盘识别
- 0x20,读硬盘
- 0x30,写硬盘
接下来……我们只用到这三个
实际上为了方便cpu直接从内存中拿数据其他什么都不用管,已经有了MBA(直接存储器)这种东西,甚至还有更牛逼的IO处理器,但是他们都是单独的硬件,我们的目的是手搓模拟。那就上吧!
1 | %include "boot.inc" |
1 | nasm -o mbr1.bin mbr1.S |
这段测试代码的目的就是读取从地址2开始的内容,然后写到0x900处,最后跳转到0x900处。但是我们并没有loader内容……让我们再编一个二进制文件,给他放上点东西
1 | %include "boot.inc" |
这就是loader.S,编译成loader.bin后我们把它放到第二扇区上去
1 | nasm -o loader.bin loader.S |
写入虚拟硬盘中的时候seek=2,意思就是跳过两个块,在第二扇区
启动bochs,如果一切顺利,mbr将把第二扇区上的数据读取到0x900地址上并跳转到那里,往显存上写入2 loader这几个字符并出现在窗口上

真好。绿底的2 loader不断闪动,就像20世纪的单机游戏通关彩蛋一样。
保护模式
之前我们都是在实模式上学习和实践的,对标的就是8086处理器,16位,段寄存器+偏移寄存器寻址,但是实模式没有权限的概念,操作系统能到的内存用户也能到,安全性比较差,而且一次性只能运行一个程序,最大内存也只有1mb,所以之后出现了平坦模式,用一个32位寄存器就能寻址所有的地址,还有保护模式,寄存器增宽,使用段描述符寻址。但是由于兼容的原因,所以会出现16bit和32bit操作的相互转换,就是反转前缀,0x66和0x67,这里不多说。
段描述符
为了区别各个内存片段之间的特征和权限,比如代码段就可读可执行不可写,数据段就可读可写不可执行,我们需要给各个内存段一点空间描述它的级别、大小之类的约束属性。,这就是段描述符

这样的一个段描述符一共8字节,这本书提到的各类描述符都是8字节。
首先能注意到段基址和段界限,保护模式下地址总线宽度32位,所以必须要32位来描述这个段的位置,就是段基址,但是你会发现这32位段基址居然还不在一起,历史遗留问题。
段界限就是这个段最大有多大,假如在这个段中访问的位置超过了段基址+段界限,那么就会触发异常。同时对于数据段和代码段,数据往高的内存地址增长,对于栈,数据往低的内存地址增长,相对应的段界限计算也不同。你可能还会注意到,段界限也是分散的。实际上还有一个G位和段界限有关系,如果G位是0,那么段边界就是段基址+段界限,如果是1,就是段基址+段界限*4kb
对于高32位来说,首先S为0代表该段为系统段,为1就是数据段。根据S代表不同的系统与数据段,占4位的type会代表的段类型也会不同,详细如下

书中主要说明了非系统段的type作用,RC位代表的读写执行是不是有种熟悉的感觉……
X代表代码段或数据段,代码段不可写,数据段不可执行。A由cpu设置,代表有没有被cpu访问过,如果有就置1。C代表一致性代码段,,,
一致性代码段是指如果自己是转移的目标段,并且自己是一致性代码段,自己的特权级一定要高于当前特权级,转移后的特权级不与自己的 DPL 为主,而是与转移前的低特权级一致,也就是听从、依从转移前的低特权级。C 为 1 时则表示该段是一致性代码段,C 为 0 时则表示该段为非一致性代码段。
好抽象……
E代表向下或者向上拓展,对应之前说的代码段、数据段和栈段。
段描述符的13-14位是DPL段,Descriptor Privilege Level,描述特权等级,分为0、1、2、3,数字越小特权越大,用户程序处于3,操作系统处于0。
P段表示present,是否存在,如果检查到为0CPU就会抛出异常。有种绕过失败的既视感……
AVL字段用来……搞笑的,没有专门用途。
L字段表示是否为64位代码段,我们在32位cpu下编程,一般置0.
D/B,对于代码段此为D位,D为0,指令的有效地址和操作数就是16位,D为1就是32位;对于栈段,此为B位。B为0,使用sp寄存器,就是16位,B为1,使用esp,32位。
G字段,用来指定段界限的单位,1字节或者4kb,前文已述。
对于系统段中的描述符,之后讲到中断再展开。
描述符表及选择子
段描述符放在哪里?全局描述符表。

全局描述符表(GDT)就是一个大数组,它存在于内存中,位置由一个48位的大寄存器GDTR保存。GDTR的前16位像段描述符一样表示全局描述符表的界限,只不过单位就是字节,以此可以计算出全局描述符表最多可容纳2^16/8=8192个描述符;16-47位表示他在内存中的地址,32位对应总线长度。需要时通过lgdt指令和内存中的gdt_ptr初始化并加载gdt
那么GDTR通过什么连接全局描述符表呢呢?选择子。

选择子用来确认段描述符,每个选择子16位,位于内存中。它的作用相当于段基址寄存器:偏移地址寄存器中的段寄存器(其实作用还要多一点),也有一点区别,他存放的不是段基址,而是索引值。索引值就是该段在GDT或者LDT(局部描述符表)描述符数组中的下标,2^12=8192,与最多存放的描述符数量对应。TI用来指示实在GDT还是LDT中。RPL的作用类似于前文提到过的DPL,表示0、1、2、3四个特权级。
但是每次访问段描述符都很耗费时间,所以当段基址,不对,此处应当说段描述符没有改变时就用一个段描述符缓冲寄存器存储段描述符,每次访问相同的段时就直接读取该段对应的段描述符缓冲寄存器。虽然段描述符缓冲寄存器是保护模式下的产物,但它也可以用在实模式下,为什么要提及这一点呢?之后在实现保护模式的时候涉及流水线刷新的时候我们再详谈
原文举出的例子很生动
例如选择子是 0x8,将其加载到 ds 寄存器后,访问 ds:0x9 这样的内存,其过程是:0x8 的低 2 位是RPL,其值为 00。第 2 是 TI,其值 0,表示是在 GDT 中索引段描述符。用 0x8 的高 13 位 0x1 在 GDT 中索引,也就是 GDT 中的第 1 个段描述符(GDT 中第 0 个段描述符不可用)。假设第 1 个段描述符中的 3个段基址部分,其值为 0x1234。CPU 将 0x1234 作为段基址,与段内偏移地址 0x9 相加,0x1234+0x9=0x123d。用所得的和 0x123d 作为访存地址。
之所以GDT0不可用,是因为需要通过选择子是否为0来判断他有没有初始化。
LDT点到为止,说是没用过。
步入保护模式
首先为了兼容实模式,默认还是打开了20位的地址回绕。我们需要把20位的物理地址限制打开
1 | in al, 0x92 |
其次,我们需要设置控制寄存器(CRx)。控制寄存器可以展示cpu的内部状态,我们也可以通过它来控制cpu的运行机制。把CR0的PE(protection enable)位置1,从实模式切换为保护模式
1 | mov eax, cr0 |
复习一下我们的执行流程,BIOS检查并处理完各个硬件后步入mbr,我们编写的mbr从硬盘中读取加载一部分内存后再跳转到对应的代码段执行。我们需要做的就是将保护模式相关的代码更新进被加载的硬盘内容中,再更改mbr读入的扇区数,因为loader.bin变长了;并且还要更新boot.inc里的一些配置信息
全局描述符表设置 & 描述符实现
1 | ;boot.inc |
这么来看我们调到32位需要调的东西有:取消地址回绕、cr0控制寄存器、全局描述符表还有选择子。
然后就是正菜:加载内容
1 | %include "boot.inc" |
注意到最后跳转到p_mode_start正常来说是完全没必要的,但是去掉程序运行就会出错。之前在看csapp的时候接触过流水线,但是由于看得走马观花只入不出,现在几乎忘光了。前后文是一个16位指令格式到32位指令格式的跳转,流水线中提前加载的还是16位译码方式,所以需要通过无条件跳转清空流水线
另一方面由于段描述符缓冲寄存器的存在,段基址不变缓冲寄存器的内容还是不变,但是它也还是遵循先前16位时20位段基址的实模式存储模式,因此需要把它也换成32位,这同样能通过无条件跳转命令来解决
1 | nasm -o loader.bin loader.S |
由于代码变长了,所以我们的mbr加载时读入的硬盘扇区数也要增加,记得count改为4!(不然在loader的第一个跳转命令之后就全都是0000,别问我怎么知道的
从实际代码我们可以看出来,选择子和初始化GDTR寄存器的gdt_ptr都预先写好在内存中,选择子的下标分别对应全局段描述符表中的code、data和video,同时结合头文件的信息会发现栈地址就是数据段基址,而且这里栈描述符中规定栈是向高地址增长的——很违反直觉,之前我在pwn里接触过的elf可重定位文件都是把栈和数据段代码段分开,而且栈是向低地址增长的,这里一并写作数据段了,是为了图方便。
但是结合boot.inc来看,书中原文的VEDIO_DSC的基址定在了0x8000处而不是0xb8000处(DESC_LIMIT_VIDEO2和DATA_STACK_DESC,这样真的能把选择子对应的地址定位到显存中吗?
还有一件事!

就在这里,将mbr.S这个片段中记录待读入扇区的寄存器cx也改为4,再重新编译、写入虚拟磁盘,原因同样是读入的硬盘扇区数增加了,不然的话被加载的程序不全,cpu会执行一些奇奇怪怪的代码
1 | nasm -o mbr1.bin mbr1.S |
看看成果:

通过creg命令查看控制寄存器,PE字段的确被设为了1,我们成功进入了保护模式

哪里有问题?
但是这样其实是有问题的,你会发现我们步入保护模式后往显存上写的’p’字符并没有展现到屏幕上。现在程序已经进入jmp -2的死循环,左下角也打印出了2 loader in real,可见已经完全将loader加载进了内存并且初始化了GDTR寄存器,现在ctrl+c打断程序,再使用命令info gdt查看肯定就可以全局段描述符到实际的地址:

以上所有步骤我都是照抄书本原文代码,差别只在于我自己重写了注释或者改变了输出,你可以看到下标为3的GDT,对应着我们原本预想的显存段,实际地址是0x8000而不是0xb8000!
一开始我通读boot.inc的时候就提出了疑问:DESC_VIDEO_HIGH4的最后8位,对应段基地址的16-24位为0,再结合loader1.S中VIDEO段的低16位是0x8000,得到的只有0x8000而不是0xb8000。不知道这的确是书中的错漏还是我忽略了书中的某些内容,但是照抄源码会导致显存段的地址错误,要是想把最后的p打印出来,必须要设置boot.inc中DESC_VIDEO_HIGH4最后一个字节为b
1 | DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + \ |
重新编译,写入虚拟硬盘再启动,可以看到:

小p被打印出来了,这印证了我的猜想,也证明了我们的确进入了保护模式,段寄存器通过选择子找到全局段描述符表中显存的实际地址并成功写入。
也不枉我一行一行对着代码确认,这段时间看书还是很用心仔细的(挺胸
如果当初没发现这个错误,最快的找出错误的方法也是查看段描述符表对应的显存地址是否有误,如果有误再去溯源段描述符表上段基址的设置,最终还是能够找到boot.inc中最后的那个字节,这是后话。
处理器微架构浅探
流水线

总的来说就是多核处理器帮着把接下来的事情都做了,重叠执行接下来几个命令的执行步骤。csapp中亦有涉及且更加详细,通常完成一个汇编指令抱回取指、译码、访存、执行这四步,流水线通过这种多个指令重叠的方式可以使指令执行效率提高好几倍
乱序执行
当有不涉及上下文的指令时,可以让不相关的指令不按顺序执行;甚至有一定相关度的也可以,我们可以将一个指令拆分成微指令,只要不影响程序执行就可以
1 | mov eax, [0x1234] |
比如上面三条指令,第一条需要访问内存,寻址需要花费比较多的时间,我们就可以利用这儿时间做其他事;通过接下来两个指令我们可以预知esp的位置,因此我们可以像sub esp, 4然后直接call function,不必等待eax。这样做就可以持续不断地往流水线上放接下来的指令步骤,不会遇到单个指令等待就拉屎夹断的情况
缓存
此事在csapp第六章存储器层次结构中亦有提及,同样是花了一大章专门讲解。只不过大概是我当初学昏过去了,现在竟记不得一点。考虑日后再补
唉,刚刚找到pdf看了两眼,写得真好
DRAM相对于直接访问硬盘来说已经快了很多,但对于cpu来说还是马达马达,因此我们需要更快的——静态随机存取存储器,它位于主存与cpu之间,实际上速度和寄存器差不多快

还有一个很重要的缓存策略和适应缓存的程序设计原理,局部性(下图仍然引自csapp,沃日,写的真好。可惜当初的我不懂

唉,实现完这个操作系统之后真想把csapp再读一遍
分支预测
此事依然在csapp中的第四章处理器体系结构中亦有提及,之不过大概是我当初学得睡过去了,现在只留了个印象

上面的流水线操作,指将指令译码后转换为微操作在此处亦有提及。原来,你一直在我身边。
本书中提及的分支预测有两种方法:第一种二位预测法,通过条件跳转的结果来改变预测是否跳转的权重,从而进行条件跳转的分支预测,这种方法通过BTB分支目标缓冲器实现;第二种,静态预测器,它相当于一个程序,写满了预测策略,由大量统计总结而来,比如跳转位置在当前位置之前,则大概率需要进行跳转
内存段保护
保护二字体现在哪里?
第一是通过段描述符对内存的保护,除了多出了很多字段来说明段的各种属性,段描述符还有很大一部分空间被设为段界限,每次cpu对数据段检查时,就会查看地址是否超越了段界限,比如写入是否越界。实际上不仅仅是数据段,代码段栈段也一样。前文提到虽然栈是向低地址增长的,但是仍然能够把栈段设为向上拓展,这实际上并没有干涉到push、pop之类命令的具体实现,比如push eax,esp的地址还是乖乖减少四字节,这个增长方向只是为了确认段界限方向,进一步方便检查栈是否越界。
第二是对于寄存器的保护,保护模式下段寄存器(就是存储选择子的寄存器)一共包括CS、DS、ES、FS、GS、SS,通常cs:ip用来设置当前要读取的指令的位置,SS主要作为栈段寄存器,相对应的,他们存储的段描述符中type字段也有对应的检查,CS必须位于代码段并且可执行,SS只能位于数据段并且必须可读写;对于其他四个寄存器而言,他们在代码段必须可读可执行,如果在数据段,只读或者可读写都可以。
Then how about…… kernel?
章节介绍说本章以接触各类硬件为主,pretty cool
你想知道……内存吗?
之后我们会接触到虚拟内存、内存管理之类。第一个问题就是……如何搞定内存?先从查看物理内存开始
中断0x15获取内存
如前所述,中断0x10主要和显示相关,内存获取则可以通过中断0x15的三个子功能决定。和之前相同,他们的功能号需要放在寄存器eax中。
虽然说进入了保护模式就没办法BIOS中断了,但我们还是做做样子。
EAX=0xE820
0xE820是最灵活的中断内存获取方式,不仅能获取内存容量,还能获取内存布局。它返回的数据是一种叫做地址范围描述符的结构:


调用中断就需要传入参数:


EAX=0xE801
这种方法只能获取内存大小,最大只能识别4GB的内存的同时,内存大小的表达方式也……颇具特色

比较鬼畜的是返回的内存容量通过四个寄存器存储,而且两两一样……
计算方法是:
1 | AX*1024+BX*64*1024+1MB |
而且实际计算出的内存大小要+1mb才等于实际的内存大小
AX=0x88
这种方式最简陋,只能识别最大64MB的内存大小,大于64就只显示63

代码 && 实践
mbr.S和之前引导加载硬盘的部分一样,这里就补上loader.S
1 | %include "boot.inc" |
之后编译为.bin文件,写入模拟硬盘
1 | dd if=check_memory/mbr.bin of=hd60M.img bs=512 count=1 conv=notrunc |

成功读入ards
内存分页
一级页表
为什么要用页表?
在我们潜意识中,程序中的线性地址应该在物理地址中也是线性的——比如有一个程序需要占用0x2000000的内存,实际的物理地址中也需要腾出0x2000000的连续空闲内存来用作该程序的内存,这样不是太麻烦了吗?
现实情境下空闲内存常常是大小错落的,像东南亚上支离破碎的群岛,很难找到一块很大的面积而且又刚好空闲,此时如果有程序有这么大的内存空间占用要求,那么要么等已经用完的内存一个个抽出来直到拼凑出一整块足够大的内存,要么就将现有的内存使用频率较低的返回硬盘,再临时抽给新的需要占用空间的程序使用
如果一个程序需要占用的内存可以分散开来,不必在物理地址中保持线性,那么空间就可以得到充分的利用,需要较大内存时开销也不那么大。有一个想法,我们能否像哈希表那样,用程序地址->实际地址的方式通过一个数据结构保存这种一一对应关系,在需要寻址的时候再一个一个对应呢?
这大概就是页表的雏形。但是如果真的逐字节一个一个对应,在拥有4G内存的情况下需要花费的存储大概是4GB*32位地址表示=16GB,完全得不偿失。但是我们可以将单位地址块增大,单位地址块、所需页表项、总内存有这样的代数关系:
单位地址块*所需页表项=总内存
换句话说,单位地址块越大,所需页表项越小。但是这是由CPU规定的,一页大小4KB,2^32/(4*2^10)=2^20计算可得我们在32位架构下需要2^20个下标,每个下标又需要32位来表示对应的物理地址,也就是4字节,一共4MB来存储页表。
这样子我们同样可以推出:索引页表下标的方式就是取高20位的地址,对应到相应页表项之后从页表项取出对应的物理地址块的起始点,再将物理地址块加上低12位就可以得到实际的物理地址

这你就要问了,那我怎么知道页表地址在哪里呢?
还记得设置段描述符基址的lgdt命令和gdt_ptr吗?我们需要一个地方在初始化页表的时候保存页表的基址方便索引,只不过这不放在内存中,而是控制寄存器cr3。记得我们之前设置cr0标志进入保护模式吗?cr3和cr0一样,也是控制寄存器。
二级页表
以上说的其实是一级页表,现代操作系统普遍使用二级页表。
每个进程都需要一个自己的页表来索引实际物理地址,这样进程一多,光页表占用的空间就很可观了;同时操作系统需要固定占用内存中的高1GB,剩下的留给用户,所有页表项必须提前建好(前后逻辑我也不明白),我们需要动态创建页表项。
如果32位地址空间全部用单一页表表示,一个页表占用的空间高达4MB,每一个进程都要花费4MB空间来记录页表,这会造成极大的空间浪费,同时很多地址根本不会被访问到,解决方案就是像利用页表把内存空间打散一样——把页表打散,用所谓的“页目录表”管理页表,二级页表应运而生。
读到这里我自然有一个疑问,不同进程之间不能共用一个页表吗?就像动态链接那样,共享一个内存空间。想象动态链接的区别,库代码一般是只读的,而多个进程之间如果共用一个页表的话可能会导致这个进程访问到另一个进程的地址,这会导致权限和安全问题。
与一级页表相同,二级页表的每个页目录项对应的页表块都是4kb,页目录表占用的内存空间也一共为4kb,包含1024个页表,4kb*4kb*1024=4GB,刚好能表示32位架构中的所有地址。
前文所述,一级页表的每一个页表项用4字节记录,二级页表的每一个页目录项也一样,然而无论是页表块还是每个页表项对应的内存块都是4kb,实际上完整地存储地址的话32位里面低12位都是0,这自然不会浪费,会被用作记录页表/内存块的各种属性

和之前的段描述符很相似,也有不少位代表的作用有重合。
P,存在位,表示是否存在于物理地址位中,如果为0仍然被访问了则会抛出pagefault异常
RW,1代表可读可写,0代表可读不可写
US,user/supervisor,1表示user级,什么程序都能寻址看到这一段内存,0表示supervisor,需要特权级别0、1、2的程序才能访问
PWT,1表示这一块内存非比寻常,是高速缓存
PCD,1表示该页启用高速缓存
A,1表示该页已经被cpu访问过了,该位会被cpu定时清零,记录1的次数可以反映出使用频率,从而在内存空间不够时挑选出使用频率低的内存块暂时返回硬盘中,抽出给迫切需要的内存用
D表示脏页,1表示这个内存页已经被写过了
PAT,在页面级上设置内存属性,比较复杂,置零即可
G,global,内存会把很常用的内存块放入高速缓存TLB,如果该位为1直接去TLB里找该内存块即可,不用浪费时间寻址
avl,可用位。cpu并不会理会该位的值?
以上就是页表项具体的存储内容,那么一个虚拟地址是具体如何被寻址的呢?
一个32位的虚拟地址会被拆分成10+10+12

高10位用来索引页目录表中的页表,中10位用来索引页表中对应的物理地址块,得到的物理地址块加上最后的12位偏移得到的就是实际地址。
启动分页机制,我们需要做三件事:
- 准备好页表和页目录表
- 将页表地址放入cr3
- 将cr0的PG位置1
说到cr0的PG位,上次我们通过设置PE位标志进入保护模式,回顾一下cr0的字段:

PG位标志着paging,大概以为通过页表进行虚拟地址寻址。
同样的,页表基址被放入cr3,单个页表块为4kb,地址对齐的情况下cr3可以留出12位的空间不必用作保存页表基址,当然要物尽其用

PWT位和PCD位在之前的页表项已经介绍过了,用于设置高速缓存相关。其他位都没用。
代码&&实践
首先,我们要做的第一步:初始化页目录表和页表
1 | ; loader.S |
1 | ; boot.inc |
值得注意的是,一开始我们将页目录表项中的第0项和第0xc00项都指向的页表的第0项,为什么要这样设置呢?原因是我们要保证分页前的线性地址与分页后的虚拟地址对应的物理地址一直,我们在加载内核之前程序中运行的loader地址范围都在1mb范围之内,我们也预备将来巴操作系统内核放在低端1mb的物理地址空间内,但是操作系统的虚拟地址是0xc0000000以上,所以我们需要将第0xc00表项指向页表的第0项
我们需要用到的也就只有1mb空间,一个页表可以涵盖4kb*1024=4mb的内存,我们只要用到1/4的表项,就是256个表项即可
至于建立第二个页表的部分——为什么要逐一设置目录项769-1022?之前说过,虚拟4GB内存空间中高1GB都必须指向内核所在的物理地址空间,内核空间作为陷入内核给用户调用的部分可以给所有进程调用,因此是给所有用户进程共享的。我们要在一开始将所有的对应页目录表项与页表地址对应,这样才能确保内核空间新增页表时不必将新的内核页表同步到所有进程中。
为什么操作系统的虚拟地址在0xc0000000上?
32位虚拟地址空间一共4GB,用户空间占用了低位的3GB,内核空间占用的3GB以上,对应的0xc0000000-0xffffffff就是第四个1GB内存,对应的就是内核地址起始位置。我们从线性地址转变为分页模式,引入虚拟地址相应的对内核空间内容的寻址也将变为0xc0000000为基址,所以我们需要将页目录表寻址中0xc00下标对应的页表索引设置为0,也就是实际上我们还是不动之前的那些全局描述符表的实际位置,只是寻址目的地产生了改变,我们顺应将页目录表对应的表项改变而已
1 | dd if=paging/mbr of=./hd60M.img bs=512 count=1 conv=notrunc |

一直步进到重新设置lgdt寄存器,确认此时页表模式已经打开,cr3被设置在地址0x100000,gdt寄存器被设置好了,此时我们查看一下页表

哦nice,可以看到访问3GB以上内核地址,也就是0xc0000000以上的地址时的确被引导到起始地址了

访问页表
到现在我们已经实现了页表结构并进入了页表模式,也通过访问虚拟地址在屏幕上打印出了“V”。但是页表是一种动态结构,在放入新内存时需要更新对应的虚拟地址映射,在返回硬盘时也要将对应的虚拟地址空间释放,我们除了通过页表的虚拟地址访问物理地址之外,也需要通过虚拟地址访问到页表本身。
还记得之前在部署几个页目录项时将最后一个页目录项指向了页目录表的起始地址吗?
1 | mov [PAGE_DIR_TABLE_POS + 4092], eax |
这是通过虚拟地址访问页表的关键。对你没听错,页目录表指向页目录表,当通过虚拟地址访问页表时,也就是高10位全为1,此时会将页目录表当作页表使用,所以会有下面这三个奇怪的页表索引:

中位为0,访问到的是页目录表的第一项,指向0x101000,中位为0xc00,指向的是内核区域,页目录表仍然指向页表的起始位置0x101000;中位全为1,页目录表的最后一项,又指回来了,页目录表的起始地址0x100000。
因此高10位全为1时,可以通过0xfffffxxx可以访问到页目录表,xxx为页目录表中的偏移地址;或者通过高10为为0x3ff访问到页表,此时中10位是页表的下表索引而不是偏移地址,低12位是页表偏移地址
快表TLB
之前提到过页表会有设置缓存的描述位,如果G位为一就可以在快表TLB中直接取走

快表也有一些属性位,RW啥的。
快表中的缓存更新后对应的数据源也要更新,但是他不是每次一有改变就缓存刷新,也不是定时刷新,而是交给操作系统的开发人员设置。
TLB对我们不可见,但是可以通过间接的方式更新TLB
- 重新刷新页表基址寄存器CR3,比如将CR3数据读出来再写进去
- 使用指令invlpg,这个方法可以针对TLB中的某个条目
特权级……还有?
TSS & 特权栈
TSS,Task State Segment,任务状态段,一开始是一个硬件层面支持多任务的方式,由处理器厂商提供给操作系统开发人员,但是好像在实际的多任务管理没起多大作用。这是后话,重要的是,它能帮助我们管理特权级

之前提到过,操作系统将数据分为0 1 2 3四个特权级,数字越小特权越大,mbr和BIOS一开始就处于0特权级。每个特权级都有各自独立的栈,当陷入某个特权级时就改变SS(段基址寄存器) 和 esp为对应的特权栈,与上图中低28位中的SSx espx一一对应。TSS的地址保存方式和之前提到过的全局段描述符表类似,由一个类似于GDTR寄存器的TR(task register)保存
实际上tss中只保存了3个较高的特权级栈地址——0、1、2,一般状态下的用户特权级栈呢?当从低特权级转入高特权级时,处理器会自动将低特权级的栈地址压入转移后的高特权级栈中,当从高特权级返回时,再通过retf或者iret命令获取栈中保存的低特权级栈段选择子和偏移量
每次从高特权级返回时,处理器不会自动向tss中更新高特权级栈地址,需要手动更新。
CPL DPL RPL
DPL位于段描述符中,占两个字节,用来表示特权级0123,如图:

RPL位于选择子中,占两个字节,也用来表示特权级0123,如图:

CPL代表当前的特权级,current privilege level,如果当前运行的指令属于某个代码段,该代码段的特权级就是当前CPU的特权级,即当前DPL=CPL,同样也可也理解为CPL保存在CS选择子中的RPL部分。
当访问一个段时,会对比目标段和CPL是否合规:
如果这个段是数据段,要求当前特权比目标特权高
如果这个段是代码段,要求当前特权等于目标特权级,即只能同级转换。唯一的例外是中断时从高特权级返回低特权级。中断本身在0特权级下运行。
####特权转移方式
处理器提供了多种方式击碎高低特权级中的屏障,使低特权级的代码也能转移到高特权级的代码
一致性代码段,一种方便不改变CPL也能访问高特权级代码段的代码段,他有自己的特权级,但是能够在特权级低于一致性代码段特权级的情况下访问。
调用门,是类似于gdt一样的描述符,只不过指向的是代码段,可以理解为一个个段描述符指向的是内存区域,而一个个门指向对应的是函数。

boch调试指令备忘
traceon|off 如果此项设为 on,每次执行一条指令,bochs 都会将反汇编的代码打印到控制台,这样在
单步调试时免得看源码了。
取消断点:d [断点编号]
查看gdt寄存器:info gdt
r 显示一般寄存器
info flags|eflags 显示状态寄存器
sreg 显示所有段寄存器的值。
dreg 显示所有调试寄存器的值。
creg 显示所有控制寄存器的值。
info tab 显示页表中线性地址到物理地址的映射。
page line_addr 显示线性地址到物理地址间的映射。
info 是个指令族,执行 help info 时可查看其所有支持的子命令,如下:
info pb|pbreak|b|break 查看断点信息,等同于 blist。
info CPU 显示 CPU 所有寄存器的值,包括不可见寄存器。
info fpu 显示 FPU 状态。
info idt 显示中断向量表 IDT。
info gdt [num]显示全局描述符表 GDT,如果加了 num,只显示 gdt 中第 num 项描述符。
info ldt 显示局部描述符表 LDT。
info tss 显示任务状态段 TSS。
info ivt [num]显示中断向量表 IVT。
show 是指令族,有很多子功能,咱们常用的就下面这 3 个。
1.show mode
每次 CPU 变换模式时就提示,模式是指保护模式、实模式,比如从实模式进入到保护模式时会有提示。
2.show int
每次有中断时就提示,同时显示三种中断类型,这三种中断类型包括“softint”、“extint”和“iret”。
可以单独显示某类中断,如执行 show softint 只显示软件主动触发的中断,show extint 则只显示来自
外部设备的中断,show iret 只显示 iretd 指令有关的信息。
3.show call
每次有函数调用发生时就会提示。