个人一直对硬件、操作系统等底层技术感兴趣,无奈x86架构实在过于复杂,虽然国内外很多计算机通识教育已经将主要平台迁移至更简单的risc-v,但不可否认,很多优秀的参考资料依旧是基于x86的。当你打开这些资料,一大堆眼花缭乱的新名词直接砸到你脑袋上,什么实模式、保护模式、长模式、段寄存器、平坦模型等等等。
不难猜到,这些名词大概都是由历史原因导致的,每一个名词的背后一定有其被创造出来的原因以及解决的具体问题,但没人会讲这些历史,只是机械的告诉你打开保护模式会怎样,和实模式有啥区别等等,这让人难以接受。
所以我决定尝试理解这些内容,并写出一篇从历史角度出发来解释这些名词的文章。
从8086开始
1978年诞生,16位、支持1MB内存寻址空间
8086是下x86架构下的第一个CPU,我从网上随便掏出来一张它的内部结构图,可以看到其内部结构是非常非常简单的,比较接近大学的课程中讨论的CPU:
简单说一下,CPU大概可以分为几个部分:控制器负责指令执行过程中整个流程的控制;运算器(ALU)则负责执行指令中的计算部分;寄存器是运算的参数或结果存放的位置;通过外部总线来读取内存。
CPU一条指令的执行过程大概有:取指令、指令译码、执行指令、访问内存(非必须)、写回(非必须)
上图中,左侧的内容和我们在教科书中接触到的CPU几乎一致,右侧这个内部暂存器(CS、DS、SS、ES)和地址加法器我们不知道是啥,一会再说。
从这张简单的图来看,8086中并没有什么MMU、TLB等器件,这是CPU用于支持现代操作系统实现虚拟内存的核心。而经过查阅,8086几乎都被用在嵌入式系统中,它的姊妹8088被IBM用在了第一代个人计算机IBM PC上,而该计算机搭载的IBM PC DOS 1.0操作系统是一个单任务的命令行操作系统,所以自然没有进程的概念,也不需要虚拟内存在多个并发的进程间进行内存隔离。
虽然没有虚拟内存支持,但从右上角也可以看出,8086的地址还是经过了一些转换的,比如指令中的0x12这个地址,经过地址加法器会被映射成另一个地址。这就是常说的段寻址,我们下面来从历史角度理解它。
从8086引出的段寻址
不知道你是否还记得上文中提到8086是16位CPU,一个16位的地址空间有多大呢?只有可怜的65535字节,也就是64KB。同时上文还提到了8086支持1MB的寻址空间,这是怎么做到的呢?
下文是GPT给我的讲解:
想象你住在一个大型公寓大楼里,这栋大楼里有许多套房子,而每套房子都有一个编号,比如“01室”、“02室”等。如果每个房子只能有两位数字的编号(如01到99),那么你最多只能有99套房子。
但现在,假设公寓的设计者想容纳更多的房子,而不是重新设计整个编号系统。他们决定将公寓划分为几个“楼层”,每个楼层有自己的编号范围。于是,每个房子的地址就变成了“楼层号 + 房子号”,比如“2楼的08号房子”就是“208室”。
- 楼层号:类似于8086中的段寄存器。
- 房子号:类似于8086中的偏移量。
如果公寓有10个楼层,每个楼层有99套房子,那么总共可以容纳990套房子。尽管房子号仍然只有两位数字(最多99),但通过引入楼层号(段寄存器),你大幅扩展了公寓的容量。
段寻址的思想就是利用一个寄存器作为段的选择器,你往这个寄存器中写值就是在选择段,最终的地址为段选择器的值左移4位(乘以16)得到20位的段基址,加上指令中给定的16位物理内存地址,最终得到20位的内存地址,即1MB的寻址空间。
这种设计引入了一定复杂性,但也许是当时最好的选择了吧。
实模式(real mode / real address mode)
8086工作的方式就被称作实模式,这是在后面有了更高级的设计之后为了区别新的寻址方式而创造出来的名词。
其实有了上面的历史知识,理解实模式变得无比自然,实模式中没有虚拟地址转换,程序中指定的地址就是物理地址。
为了应对16位地址空间满足不了需求的问题,x86又造出了复杂的段寻址,x86中实际的地址是楼层号(段寄存器)与房间号(地址)的结合,而机器代码中的地址只不过是在段内的偏移量。
80286和保护模式
我们已经介绍了8086CPU的历史,从现在的视角来看,那真是一个古老物件了,要想实现现代操作系统,8086缺失了很多功能:
- 内存保护机制:实模式不存在内存保护,无法建立隔离的内存空间。现代操作系统依赖其在多个任务之间提供内存隔离。
- 多任务处理能力缺失:没有一个简单的结构可以给操作系统来实现多任务处理。
- 无特权级别:现代操作系统通过特权级别来隔离内核态和用户态,保证系统的安全、稳定
这些在80286上通过一种被称作保护模式的机制解决,同时为了兼容旧软件,x86CPU reset时一定是处于实模式的,需要转到保护模式。
内存保护——进阶的分段机制
实模式的分段很简单,即段基址 + 偏移量,但对于段基址和偏移量没有任何限制,代码可以通过组合两个变量访问任何内存空间。
保护模式下,段寄存器中不再直接存储段基址,而是存储一种称作段选择器(Segment Selector)的东西,更专业的说法是段选择子,我觉得这个命名垃圾,所以我直译了,读者在其它资料上看到段选择子知道是一个东西就好。
段选择器是一个位置信息,这个位置就是选择的段对应的描述符位置。描述符被存储在内存中,有一个全局描述符表(GDT)和一个局部描述符表(LDT)。
有趣,我们要通过段寄存器中存储的值和机器代码中的偏移量来计算出最终的内存位置,但在这之前却要先访问内存读出段描述符才能继续
段描述符中包含了:
- 段基址:描述段的实际起始位置
- 段大小:描述段的大小,确保最终段基址 + 偏移量不会超出该段,从而达到段间隔离
- 访问权限:权限控制
- 其它属性:...
下图是一个内存中的段描述符布局,但这并非80286的,80286的要更加简单:
该图片来自极客时间的《操作系统实战45讲》,作者彭东,直达链接:https://time.geekbang.org/column/article/375278
好了好了,忽略一切细节,在保护模式下的内存访问流程如下:
- 读取段选择器
- 查找段描述符表(GDT、LDT),拿到段基址、限长、权限等属性
- 段基址 + 偏移量计算实际物理地址
- 校验权限
- 访存
- 异常处理,如访问越界(物理地址超出段边界)
段描述符表是谁设置的呢?当然是运行在其上的操作系统,在从实模式进入到保护模式之前,操作系统必须设置号GDT,并将段选择器设置妥当,再启用保护模式
特权级别
在实模式下,任何指令都可以无差别的被执行,这在现代操作系统上是很危险的,因为一个坏人可以轻易的摧毁你的系统。
保护模式提供了四个特权级别,分别为R0到R3,权限依次减少:
该图片来自极客时间的《操作系统实战45讲》,作者彭东,直达链接:https://time.geekbang.org/column/article/375278
平坦模型——我脑子不够用了,来点简单的吧
我一直在说分段,也讲了实模式和保护模式下的分段规则,但我一直没有阐述细节,比如使用哪些寄存器作为段寄存器,我也没有拿出一个实际的例子来计算,因为我脑子不行。
通过精心设计GDT,比如我们把段基址设置成0,段限长设置为4GB,此时系统中只有一个段,该段的大小就是全部的物理地址空间。这样可以简化内存的管理,告别地址转换。