第4章:段页式管理——x86的内存管理模型

好,咱们今天聊段页式管理。说实话,这是很多操作系统教材里讲得最绕的部分。我当年学的时候也晕过一阵子——分段、分页,两个东西为什么要混在一起?直接分页不香吗?

嗯,等我真的去调x86的内存管理单元(MMU)时,才明白这背后的设计哲学。说白了,段页式管理就是「既要又要还要」的产物——既要分段带来的逻辑隔离,又要分页带来的物理内存高效利用。

4.1 为什么需要段页式?

先说说分段和分页各自的短板。

  • 分段的问题:每个段大小不一,时间长了内存里全是碎片。你想想看,一个程序退出后留下个几百KB的空洞,另一个程序想加载进来,发现大小对不上——这就是外部碎片。
  • 分页的问题:虽然解决了碎片,但页表是扁平的,进程之间的保护全靠页表权限位。你想让一个进程的代码段只读、数据段可读写?分页能做到,但得在页表里逐页设置,麻烦。

段页式管理的思路很简单:先分段,再分页。每个段内部再切成页。这样,段负责逻辑隔离和权限控制,页负责物理内存的分配和交换。

核心思想:逻辑地址 → 段选择子 → 段基址 + 段内偏移 → 线性地址 → 页表 → 物理地址

我在项目中遇到过一个问题:一个老式的数据库系统,要求数据段必须连续,但物理内存已经碎片化了。用段页式,我只需要在段描述符里把基址设成一个很大的虚拟地址,实际物理页可以分散——完美解决了「逻辑连续、物理离散」的需求。

4.2 x86的段页式实现

x86架构从80386开始就支持段页式。我个人习惯把它的地址转换流程画成三步走:

  1. 逻辑地址 → 线性地址:通过段选择子查GDT/LDT,得到段基址,加上偏移量。
  2. 线性地址 → 物理地址:通过页表(两级或三级)完成映射。
  3. 权限检查:段级检查(CPL、DPL、RPL)+ 页级检查(U/S、R/W位)。

这里有个关键点:段页式下,分段是必须的,但可以「弱化」。什么意思?Linux内核的做法是:把所有段的基址都设为0,段限长设为4GB。这样逻辑地址就等于线性地址,分段相当于被「绕过」了。但页表依然在工作。

避坑指南:我曾经在移植一个嵌入式系统时,忘了设置段描述符的DPL位,结果用户态程序一访问数据段就触发#GP异常。查了两天才发现——段级权限检查比页级更早触发,段描述符错了,页表配得再好也没用。

4.3 段描述符与页表的结构

咱们看看x86里具体的数据结构。段描述符是8字节的,放在GDT或LDT里。我挑几个关键字段说说:

字段 位数 说明
基址 32位 段的起始线性地址
限长 20位 段的大小(粒度可调)
DPL 2位 描述符特权级(0~3)
Type 4位 代码段/数据段/系统段等
G位 1位 粒度(0=字节,1=4KB)

页表呢?x86的经典两级页表结构:

  • 页目录(Page Directory):1024项,每项4字节,指向页表。
  • 页表(Page Table):1024项,每项4字节,指向物理页帧。
  • 页大小:4KB(也可以开4MB大页)。

线性地址的拆分是这样的:高10位索引页目录,中间10位索引页表,低12位是页内偏移。

// 一个简化的地址转换示例(伪代码)
uint32_t linear_to_physical(uint32_t linear_addr) {
    uint32_t pdi = (linear_addr >> 22) & 0x3FF;  // 页目录索引
    uint32_t pti = (linear_addr >> 12) & 0x3FF;  // 页表索引
    uint32_t offset = linear_addr & 0xFFF;       // 页内偏移

    uint32_t *pd = (uint32_t *)CR3;              // CR3存页目录基址
    uint32_t pde = pd[pdi];
    if (!(pde & 0x1)) return -1;                 // 页目录项不存在

    uint32_t *pt = (uint32_t *)(pde & 0xFFFFF000);
    uint32_t pte = pt[pti];
    if (!(pte & 0x1)) return -1;                 // 页表项不存在

    return (pte & 0xFFFFF000) | offset;
}

注意:上面的代码只是教学演示。实际x86的页表项还有PAT、PCD、PWT、Accessed、Dirty等标志位。我在调试一个性能问题时,发现Dirty位没及时更新,导致页面换出时数据丢失——嗯,血的教训。

4.4 段页式下的权限管理

段页式的权限检查是两层的。我画个流程图帮你理解:

  1. CPU先检查段级权限:当前CPL是否小于等于段描述符的DPL?不是的话,直接#GP。
  2. 通过段级检查后,再查页级权限:页表项的U/S位(用户/超级用户)和R/W位(读/写)。
  3. 两级都通过,才能访问。

你想想看,这种设计有什么好处?举个例子:一个用户态进程(CPL=3)想访问内核数据段(DPL=0),段级检查就把它拦住了,根本不用走到页表。这比纯分页系统多了一层保护。

我记得有一次做安全加固,需要在用户态禁止执行某些内存区域。纯分页系统得在页表里逐页设置NX位(不可执行),但段页式下,我直接在代码段描述符里把Type设为「只读代码段」,用户态就自动不能执行了——省了不少页表项。

4.5 现代操作系统的实际用法

现在的主流操作系统,比如Linux和Windows,对段页式的使用其实很「偷懒」:

  • Linux:所有段的基址都是0,限长4GB。分段基本被架空,主要靠分页。但段机制依然存在,用于任务切换(TSS段)和权限切换(调用门)。
  • Windows:类似,但保留了更多的段描述符用于兼容性(比如WOW64的32位模拟)。

为什么会这样?说白了,分页已经能满足大部分需求了。分段的历史包袱太重——段寄存器切换开销大,段描述符缓存(段描述符缓存)还容易引发性能问题。

我的建议:如果你在写自己的内核,别急着把分段完全去掉。至少保留一个GDT,里面放几个空段描述符用于异常处理。我曾经试过完全不用分段,结果在调试时连#PF异常的CS值都读不出来——尴尬。

4.6 段页式的性能代价

段页式不是没有代价的。每次内存访问,CPU都要走一遍段转换+页转换。虽然TLB(转译后备缓冲器)能缓存大部分结果,但TLB miss时的开销还是不小。

我做过一个测试:在x86上,一次TLB miss的代价大约是100~200个CPU周期。如果程序的工作集很大,频繁TLB miss,性能会下降10%~20%。

怎么优化?

  • 使用大页:4MB或2MB的大页,能减少页表层级,提高TLB命中率。
  • 段描述符缓存:x86会把最近用过的段描述符缓存在CPU内部,避免每次查GDT。
  • 避免频繁切换段:段寄存器切换会刷新段描述符缓存,能少做就少做。

总结一下:段页式管理是x86内存管理的基石。分段提供逻辑隔离和权限控制,分页提供物理内存的灵活分配。虽然现代操作系统弱化了分段,但理解它的原理,对调试内存问题、做性能优化、甚至写内核驱动都大有裨益。

嗯,这一章就到这里。下一章咱们聊聊TLB和缓存一致性——那又是一个让人头秃的话题。