内存学习:x86体系中的实模式和保护模式

📅 2026/7/4 13:59:40
内存学习:x86体系中的实模式和保护模式
引言上一章我们讲了虚拟内存的概念分析了线性地址虚拟地址是如何映射到物理地址上的。不过在 x86 架构诞生之初其实是没有虚拟内存的概念的。1978 年发行的 8086 芯片是 x86 架构的首款芯片它在内存管理上使用的是直接访问物理内存的方式这种工作方式有一个专门的名称那就是实模式Real Mode。上节我们也曾简单提到过直接访问物理内存的工作方式让程序员必须要关心自己使用的内存会不会与其他进程产生冲突为程序员带来极大的心智负担。后来CPU 上就出现虚拟内存的概念它可以将每个进程的地址空间都隔离开极大地减轻了程序员的负担同时由于页表项中有多种权限保护标志极大地提高了应用程序的数据安全。所以人们把 CPU 的这种工作模式称为保护模式Protection Mode。从实模式演进到保护模式x86 体系架构的内存管理发生了重大的变化最大的不同就体现在段式管理和中断的管理上。所以今天这节课我们会围绕这两个重点让你彻底理解 x86 体系架构下的内存管理演进。你也能通过这节课的学习学会阅读 Linux 内核源码的段管理和中断管理的相关部分还可以增加调试 coredump 文件的能力。这里我们就按照时间顺序从 8086 芯片中的实模式开始讲起。8086 中的实模式8086 芯片是 Intel 公司在 1978 年推出的 CPU 芯片它定义的指令集对计算机的发展历程影响十分巨大之后的 286、386、486、奔腾处理器等等都是在 8086 的基础上演变而来。这一套指令集也被称为 x86 指令集。直到今天很多大学里的微机原理课和汇编语言课还是使用 8086 进行讲解。8086 的寄存器只有 16 位我们也习惯于称 8086 的工作模式是 16 位模式。而且后面的 CPU 为了保持兼容在芯片上电了以后还必须运行在 16 位模式之下这种模式有个正式的名字叫做实模式Real Mode。在实模式下程序员是不能通过内存管理单元Memory Management Unit, MMU访问地址的程序必须直接访问物理内存。那实模式下我们是怎么访问存储的物理地址的呢8086 的寄存器位宽是 16 位但地址总线却有 20 位地址的编码可以从 20 位 0 到 20 位 1这意味着 8086 的寻址空间是 2^20 1M。但是在写程序的时候我们没有办法把一个地址完整地放到一个寄存器里因为它的寄存器相比地址少了 4 位。为了解决这个问题8086 就引入了段寄存器例如 cs、ds、es、gs、ss 等。段寄存器中记录了一个段基地址通过计算可以得到我们存储的真实地址也就是物理地址。物理地址可以使用“段寄存器: 段内偏移”这样的格式来表示计算的公式是物理地址 段寄存器 4 段内偏移不过在我们写汇编代码的时候也不一定就要使用段寄存器来表示段基址也可以使用“段基址: 段内偏移”这样的立即数的写法比如你可以看下这个节选自 Linux 的 bootsect 中的代码BOOTSEG 0x7c0 _start: jmpl $BOOTSEG, $start2 start2: movw $BOOTSEG, %ax movw %ax, %ds ...这块代码里它跳转的目标地址就是 0x7c0 4 OFFSET(start2)。跳转成功以后cs 段寄存器中的值就是段基址 0x7c0start2 的偏移值是 8所以记录当前执行指令地址的 ip 寄存器中的值就是实际地址 0x7c08。而且这块代码里也包含了段基址和段内偏移值这种地址形式这显然有别于我们所讲的虚拟地址。这种包含了段基址和段内偏移值的地址形式有一个专门的名字叫做逻辑地址。你可以看到虚拟地址是一个整数而逻辑地址是一对整数。所以说在 8086 芯片中逻辑地址要经过一步计算才可以得到物理地址。在 8086 中cs 被用来做为代码段基址寄存器比如上面示例代码中的 jmp 指令跳转成功就会把段基址自动存入 cs 寄存器。ds 被用来做为数据段基址寄存器你可以看看下面这个代码INITSEG 0x9000 .... movw $INITSEG, %ax movw %ax, %ds movb $0x03, %ah xor %bh, %bh int $0x10 movw %dx, (0) movb $0x88, %ah int $0x15 movw %ax, (2)上述代码的第 7 行执行 0x10 号 BIOS 中断它的结果存放在 dx 寄存器中然后第 8 行将结果存入内存 0x900009 至 11 行再把 0x15 号 BIOS 中断的结果存到 0x90002 处。在寻址时我们并没有明确地声明数据段基址存储在段寄存器 ds 中但是 CPU 在执行时会默认使用 ds 做为数据段寄存器。类似的还有 ss它是做为栈基址寄存器当我们在使用 push 指令的时候要保存的数据会放在 ss:(sp) 的位置。CPU 没有强制规定代码段和数据段分离也就意味着你使用 ds 段寄存器去访问指令CPU 也是允许的。但在实际编程时我们还是会把数据和代码分到不同的段里并且将数据段的起始地址放到 ds 寄存器把代码段的地址放到 cs 寄存器。这种按功能分段的管理内存方式就是段式管理。关于段式管理和页式管理的对比我们稍后会加以介绍。到这里 8086 的实模式我们已经基本讲完了。8086 是最古老的 x86 芯片在实模式下它只能直接操作物理内存非常不便于编程这一点我们在之前也提到了。接下来我们把目光转向 x86 体系架构中的保护模式它是实模式的进一步发展。i386 中的保护模式经过十年的发展x86 CPU 迎来了历史上使用最广泛、影响力最大的 32 位 CPU这就是 i386 芯片。i386 与 8086 的一个很大的不同就是它采用了全新的保护模式。这个体现在i386 中的段式管理机制相比 8086 发生了重大变化同时i386 芯片在段式管理的基础上还引入了页式管理。i386 在完成各种初始化动作以后就会开启页表从此程序员就不必再直接操作物理内存的地址空间了代替它的是线性地址空间。而且由于段和页都能提供对内存的保护安全性也得到了提升所以这种工作模式被称为保护模式Protection Mode。i386 的保护模式是一种段式管理和页式管理混合使用的模式。至于页式管理我们之前已经讲过了所以这里我们就来看一下相比 8086段式管理在 i386 上有了哪些变化。变化一段选择子和全局描述符表在 i386 上地址总线是 32 位的通用寄存器也变成 32 位的这就意味着因为寄存器位数不够而产生的段基址寄存器已经失去了作用。但是 i386 没有直接放弃掉段寄存器而是将它进化成了新的段式内存管理。段寄存器仍然是 16 位寄存器但是其中存的不再是段基址而是被称为段选择子的东西。相比 8086 芯片i386 中多了一个叫全局描述符表Global Descriptor Table, GDT的结构。它本质上是一个数组其中的每一项都是一个全局描述符32 位的段基址就存储在这个描述符里。段选择子本质上就是这个数组的下标。具体你可以看看下面这张图GDT 的地址也要保存在寄存器里这个寄存器就是 GDTR这个做法和第 1 节课我们讲到的 CR3 寄存器的做法十分相似。在上面这张图中CPU 在处理一个逻辑地址“cs:offset”的时候就会将 GDTR 中的基址加上 cs 中的下标值来得到一个段描述符再从这个段描述符中取出段基址最后将段基址与偏移值相加这样就可以得到线性地址了。这个线性地址就是我们之前所讲的虚拟地址。得到线性地址以后剩下的工作我们就非常熟悉了由 CPU 的 MMU 将线性地址映射为物理地址然后就可以交给地址总线去进行读写了。变化二段寄存器对段的保护能力增强在 8086 中段寄存器只起到了段基址的作用对于段的各种属性并没有加以定义。例如在实模式下任何指令都可以对代码段进行随意地更改。但在 i386 中对段的保护能力加强了我们先来看一下 i386 中段描述符也就是 GDT 中的每一项的结构。你会看到描述符中除了记录了段基址之外还记录了段的长度以及定义了一些与段相关的属性其中比较重要的属性有 P 位、DPL、S 位、G 位和 Type。我们接下来一个个来分析。P 位是一个比特指示了段在内存中是否存在1 表示段在内存中存在0 则表示不存在。DPL占据了两个比特指的是描述符特权级英文是 Descriptor Privilege Level。Intel 规定了 CPU 工作的 4 个特权级分别是 0、1、2、3数字越小权限越高。以 Linux 为例Linux 只使用了 0 和 3 两个特权级并且规定 0 是内核态3 是用户态。特权级的切换是比较复杂的一种机制但 Linux 只使用了中断这一种后面我们会再讲到中断。接下来我们再看 S 位S 为 1 代表该描述符是数据段 / 代码段描述符为 0 则代表系统段 / 门描述符。门是 i386 提供的用于切换特权级的机制有调用门、陷阱门、中断门、任务门等。在 Linux 系统中只使用了中断门描述符。然后是 G 位它指的是定义段颗粒度Granularity它的值为 0 时段界限的单位是字节为 1 时段界限以 4KB 为单位也就是一页。我们也可以从图中看出定义段长度的“段界限”字段并不是连续的它一共有 20 位分散在两个地方。当 G1 时段界限的最大值是 2^20 * 4K 4G这是 i386 一个段的最大长度。最后是 Type 属性它定义了描述符类型我把比较重要的类型用表列在了下面你可以看看。到这里我们已经解释清楚了i386 中保护模式相比 8086 实模式在段式管理上的升级。那么在现代的 CPU 和操作系统中段式管理和页式管理又是怎样的关系呢要讲清楚这一点就要先对比这两种内存管理方式的优缺点。段式管理对比页式管理段式管理会按功能把内存空间分割成不同段有代码段、数据段、只读数据段、堆栈段等等为不同的段赋予了不同的读写权限和特权级。通过段式管理操作系统可以进一步区分内核数据段、内核代码段、用户态数据段、用户态代码段等为系统提供了更好的安全性。但是段的长度往往是不能固定的例如不同的应用程序中代码段的长度各不相同。如果以段为单位进行内存的分配和回收的话数据结构非常难于设计而且难免会造成各种内存空间的浪费。页式管理则不按照功能区分而是按照固定大小将内存分割成很多大小相同的页面不管是存放数据还是存放代码都要先分配一个页再将内容存进页里。所以你可以看到相比页式管理段式管理的优点是提供更好的安全性按照内存的用途进行划分更符合人的直观思维。它的缺点就是由于不定长难于进行分配、回收调度。而页式管理的优点是大小固定分配回收都比较容易。而且段式管理所能提供的安全性在现代 CPU 上也可以被页表项中的属性替代所以现在段式管理已经变得越来越不重要了。像 64 位 Linux 系统它把所有段的基地址都设成了从 0 开始段长度设置为最大。这样段式管理的重要性就大大下降了。但是如果我们以 x86 的历史演进来看你会发现段式管理其实是最早出现的8086 芯片然后才出现了页式管理i386 芯片。而且我们现代的 x86 架构的 CPU也同时兼容段式管理和页式管理我们可以认为是一种混合的段页式管理当然并不是所有人都认可这种命名方式。总的来说现代的操作系统都是采用段式管理来做基本的权限管理而对于内存的分配、回收、调度都是依赖页式管理。到这里我们就讲清楚了 8086 实模式到 i386 保护模式下段式管理的演进并且进一步分析了段式管理和页式管理的对比和现状。保护模式相比实模式发生重大变化的不止是内存管理同时还有中断管理。因为管理中断的结构与段式管理的全局描述符表的结构非常相似所以我们在讲保护模式时也一起讲一下。你可以将中断机制与段管理机制比较着一起学习。中断描述符表中断描述符表Interruption Description Table, IDT是 i386 中一个非常重要的描述符表它也是保护模式对比实模式的另一大不同。你在后面学习 fork、execve 的实现时涉及到的写保护中断缺页中断等机制都要依赖它。CPU 与外设之间的协同工作是以中断机制来进行的。例如我们敲击键盘的时候键盘的控制器就会向 CPU 发起一个中断请求。CPU 在接到请求以后就会停下正在做的工作把当前的寄存器状态全部保存好然后去调用中断服务程序。当然这个过程中有一些是 CPU 的工作有一些是操作系统的工作但因为我们关注的重点是内存所以就没必要计较这里面细微的差别了。中断根据中断来源的不同又可以细分为 Fault、Trap、Abort 以及普通中断。我们这门课对它们也不加区分例如执行除法的时候除数为 0 的情况、访问数据时权限不足引发的保护错误、由用户使用 int 指令产生的中断等虽然中断源不同它们的类型也不相同但我们统一称它们为中断。硬件负责产生中断CPU 会响应中断但是中断来了以后要做什么事情是由操作系统定义的。操作系统要通过设置某个中断号的中断描述符来指定中断到达以后要调用的函数。中断描述符表IDT的作用就体现在这了它的本质就是中断描述符的数组。IDT 的基地址存储在 idtr 寄存器中这和 GDTR 的设计如出一辙。每个中断都有一个编号与其对应我们称之为中断向量号。中断向量号是 CPU 提前分配好的我也把比较重要的中断向量号放在了下表里你可以看看。在这个表里我们没有看到前边所提到的键盘中断这是因为键盘中断都是由一个名为 8259A 的芯片在管理。两片级联的 8259A 芯片可以管理 16 个中断其中包括了时钟中断、键盘中断还有软盘、硬盘、鼠标的中断等等。这些中断的中断向量号是可以通过对 8259A 编程进行设置的。虽然 8259A 的编程比较繁琐但好在只需要操作系统开机引导时设置一次。你也可以看到Linux 系统把中断向量表的 32 号中断用户自定义中断的第一位设置成 8259A 的 0 号中断也就是说 IDT 的 32 号至 47 号都分配给了 8259A 所管理的中断。键盘、软盘、硬盘、鼠标的中断服务程序就设置在这里。关于中断我们掌握这么多就已经足够了更多的知识我们会在后面的课程按需讲解。现在我们可以通过一个例子体验一下中断的使用。在 Linux 系统上我们把下面这个代码保存到文件 hello.c 中并且使用gcc -o hello hello.c编译得到可执行程序 hello。再运行它你就可以看到屏幕上打印出一行hello。// compile command : gcc -o hello hello.c void sayHello() { const char* s hello\n; __asm__(int $0x80\n\r ::a(4), b(1), c(s), d(6):); } int main() { sayHello(); return 0 }相比于使用 printf 进行打印需要引入头文件stdio.h我们这段代码里没有使用任何头文件但一样可以在控制台上进行打印。这是因为我们使用了 0x80 号中断进行了 Linux 系统调用。系统调用号在 eax 中也就是 4代表 write 这个调用。第一个参数在 ebx 中其值为 1代表控制台的标准输出第二个参数是字符串hello的地址在 rcx 中第三个参数是字符串的长度也就是 6存储在 edx 中。这样我们就通过中断就不必再使用 C 语言的 printf 进行输出这就绕过了 C 语言的基础库完成了向控制台打印的功能。总结今天我们拆解了 x86 体系架构下的实模式和保护模式也认识了两个 x86 演进史上非常重要的 CPU。8086 是 16 位的 CPU我们称 8086 的工作模式为实模式它的特点是直接操作物理内存内存管理容易出错要十分小心代码编写和调试都很困难。之后出现的 i386则采用了和实模式不同的保护模式。相比实模式i386 中的保护模式采用了页式管理但它没有彻底放弃 8086 的段式管理而是将段寄存器中的值由段基址变成了段选择子。段选择子本质是 GDT 表的下标值段基址都转移到 GDT 中去了。段式管理负责将逻辑地址转换为线性地址或者称为虚拟地址页式管理负责将线性地址映射到物理地址。i386 的保护模式采用了段页式混合管理的模式兼具了段式管理和页式管理的优点。除了段页式内存管理这个不同之外保护模式和实模式的区别还体现在中断描述符表IDT上。IDT 是保护模式的一个重要组成部分它保存着 i386 中断服务程序的入口地址。8086 和 i386 对 x86 架构的 CPU 影响巨大。直到今天x86 架构的 CPU 在上电以后为了与 8086 保持兼容还是运行在 16 位实模式下也就是说所有访存指令访问的都是物理内存地址。在启动操作系统后才会切换到保护模式下进行工作。