从实模式演进到保护模式,x86 体系架构的内存管理发生了重大的变化,最大的不同就体现在段式管理和中断的管理上。
8086 和 i386 对 x86 架构的 CPU 影响巨大。直到今天,x86 架构的 CPU 在上电以后,为了与 8086 保持兼容,还是运行在 16 位实模式下,也就是说所有访存指令访问的都是物理内存地址。在启动操作系统后,才会切换到保护模式下进行工作。

8086 中的实模式

8086 芯片是 Intel 公司在 1978 年推出的 CPU 芯片,它定义的指令集对计算机的发展历程影响十分巨大,之后的 286、386、486、奔腾处理器等等都是在 8086 的基础上演变而来,这一套指令集也被称为 x86 指令集。
在实模式下,程序员是不能通过内存管理单元(Memory Management Unit, MMU)访问地址的,程序必须直接访问物理内存。8086 的寄存器只有 16 位,通常称 8086 的工作模式是 16 位模式。

实模式下如何访问物理内存?

8086 的寄存器位宽是 16 位,但地址总线却有 20 位,地址的编码可以从 20 位 0 到 20 位 1,这意味着 8086 的寻址空间是 2^20 = 1M。但是在写程序的时候,我们没有办法把一个地址完整地放到一个寄存器里,因为它的寄存器相比地址少了 4 位
为了解决这个问题,8086 就引入了段寄存器,例如 cs、ds、es、gs、ss 等。段寄存器中记录了一个段基地址,通过计算可以得到我们访问的真实地址,也就是物理地址。物理地址可以使用“段寄存器: 段内偏移”这样的格式来表示(也成为逻辑地址),计算的公式是:物理地址 = 段寄存器 << 4 + 段内偏移
CPU 没有强制规定代码段和数据段分离,也就意味着,你使用 ds 段寄存器去访问指令,CPU 也是允许的。但在实际编程时,我们还是会把数据和代码分到不同的段里,并且将数据段的起始地址放到 ds 寄存器,把代码段的地址放到 cs 寄存器,这种按功能分段的管理内存方式就是段式管理。

i386 中的保护模式

i386 与 8086 的一个很大的不同,就是它采用了全新的保护模式。i386 中的段式管理机制,相比 8086 发生了重大变化;同时,i386 芯片在段式管理的基础上,还引入了页式管理
i386 在完成各种初始化动作以后,就会开启页表,从此用户就不必再直接操作物理内存的地址空间了,代替它的是线性地址空间,而且由于段和页都能提供对内存的保护,安全性也得到了提升,所以这种工作模式被称为保护模式(Protection Mode)。i386 的保护模式是一种段式管理和页式管理混合使用的模式。

变化一:段选择子和全局描述符表

在 i386 上,地址总线是 32 位的,通用寄存器也变成 32 位的,这就意味着因为寄存器位数不够而产生的段基址寄存器已经失去了作用。但是 i386 没有直接放弃掉 16 位的段寄存器,而是将它进化成了新的段式内存管理。段寄存器中存的不再是段基址,而是被称为段选择子的东西。
相比 8086 芯片,i386 中多了一个叫全局描述符表(Global Descriptor Table, GDT)的结构。它本质上是一个数组,其中的每一项都是一个全局描述符,32 位的段基址就存储在这个描述符里。段选择子本质上就是这个数组的下标。
image.png

变化二:段寄存器对段的保护能力增强

在 8086 中,段寄存器只起到了段基址的作用,对于段的各种属性并没有加以定义。例如,在实模式下,任何指令都可以对代码段进行随意地更改。但在 i386 中,对段的保护能力加强了,下图是 i386 中段描述符(也就是 GDT 中的每一项)的结构。
image.png
描述符中除了记录了段基址之外,还记录了段的长度,以及定义了一些与段相关的属性,其中比较重要的属性有 P 位(指示了段在内存中是否存在,1 表示段在内存中存在,0 则表示不存在)、DPL(指的是描述符特权级,英文是 Descriptor Privilege Level。Intel 规定了 CPU 工作的 4 个特权级,分别是 0、1、2、3,数字越小,权限越高)、S 位(S 为 1 代表该描述符是数据段 / 代码段描述符,为 0 则代表系统段 / 门描述符。门是 i386 提供的用于切换特权级的机制,有调用门、陷阱门、中断门、任务门等。在 Linux 系统中,只使用了中断门描述符)、G 位(指的是定义段颗粒度(Granularity),它的值为 0 时,段界限的单位是字节,为 1 时段界限以 4KB 为单位,也就是一页)和 Type(定义了描述符类型)。
image.png

段式管理对比页式管理

段式管理会按功能把内存空间分割成不同段,有代码段、数据段、只读数据段、堆栈段,等等,为不同的段赋予了不同的读写权限和特权级。通过段式管理,操作系统可以进一步区分内核数据段、内核代码段、用户态数据段、用户态代码段等,为系统提供了更好的安全性。
但是段的长度往往不能固定,例如不同的应用程序中,代码段的长度各不相同。如果以段为单位进行内存的分配和回收的话,数据结构非常难于设计,而且难免会造成各种内存空间的浪费。页式管理则不按照功能区分,而是按照固定大小将内存分割成很多大小相同的页面,不管是存放数据,还是存放代码,都要先分配一个页,再将内容存进页里。
而页式管理的优点是大小固定,分配回收都比较容易,而且段式管理所能提供的安全性,在现代 CPU 上也可以被页表项中的属性替代,所以现在段式管理已经变得越来越不重要了。
总的来说,现代的操作系统都是采用段式管理来做基本的权限管理,而对于内存的分配、回收、调度都是依赖页式管理。

中断描述符表

中断描述符表(Interruption Description Table, IDT),是 i386 中一个非常重要的描述符表,它也是保护模式对比实模式的另一大不同。
CPU 与外设之间的协同工作是以中断机制来进行的,硬件负责产生中断,CPU 会响应中断,但是中断来了以后要做什么事情是由操作系统定义的。操作系统要通过设置某个中断号的中断描述符,来指定中断到达以后要调用的函数。中断描述符表(IDT)的作用就体现在这了,它的本质就是中断描述符的数组。而IDT 的基地址存储在 idtr 寄存器中,这和 GDTR 、CR3 的设计如出一辙。并且每个中断都有一个编号与其对应,称为中断向量号,中断向量号是 CPU 提前分配好的。
image.png

  1. // compile command : gcc -o hello hello.c
  2. void sayHello() {
  3. const char* s = "hello\n";
  4. __asm__("int $0x80\n\r"
  5. ::"a"(4), "b"(1), "c"(s), "d"(6):); // x86 的内联汇编
  6. }
  7. int main() {
  8. sayHello();
  9. return 0
  10. }

这里使用了 0x80 号中断执行了 Linux 系统调用。系统调用号在 eax 中,也就是 4,代表 write 这个调用。第一个参数在 ebx 中,其值为 1,代表控制台的标准输出;第二个参数是字符串”hello”的地址,在 rcx 中;第三个参数是字符串的长度,也就是 6,存储在 edx 中

思考题

段式管理和页式管理会出现内存碎片吗?

段式管理可以做到段根据实际需求分配空间,所以有多少需求就分配多大的段。那么在段的内部就不会产生空间浪费,也就没有碎片,但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,也就是说段与段之间还是会产生碎片。
而页则是固定大小的,通常是 4K,假设我们要为代码准备空间,即使这一段机器码不足 4K,我们也要为它分配 4K,因为一个页的大小就是 4K,我们最少只能分配一个页。所以页式管理,页与页之间紧密排列,但是页内会出现内存浪费,也就是内存碎片。