计算机体系结构这几门课我前前后后翻过不少遍最值得反复琢磨的知识点之一就是 i386 的页式内存管理机制。它正好卡在操作系统和 CPU 的交界处一边是虚拟内存、进程隔离、缺页换页这些软概念一边是寄存器、页表、TLB 这些硬细节。说一句不过分的话把这一块搞透操作系统内存管理的一大半底层逻辑就通了。这篇文章不打算按教材目录给你念一遍而是从“为什么非要用分页”讲到“两级页表到底怎么翻译地址”再讲到页表项里的标志位、TLB 性能问题最后给出一套能自己动手做的模拟实验和排查思路。适合对保护模式有一定概念、想彻底弄懂虚拟内存的读者也适合准备自己写小型内核、做 OS 实验的人。你会发现很多所谓的高级特性理解到 i386 这一层之后全都能串起来。1. 为什么搞懂 i386 的分页机制值得花时间1.1 从分段到分页一条地址的两级翻译i386 同时提供段机制和页机制而且这两套机制是串联工作的。CPU 先把逻辑地址通过段描述符转换成线性地址再把线性地址通过分页机制转换成物理地址。CR0 寄存器里的 PG 位是个总开关PG1 时开启分页PG0 时线性地址直接当物理地址用段机制之后的那一步就跳过去了。所以你会发现在实模式下根本没有“分页”这回事地址就是地址程序说访问 0x1000CPU 就去物理内存 0x1000 找数据。进入保护模式之后分段是基础但真正的内存“玩法”是从分页开始的。也就是说保护模式解决的是“段的权限和边界”而分页解决的是“虚拟地址空间怎么映射到物理内存”。很多人以为保护模式就等于分页这是第一个需要纠正的认知。分页机制的存在意味着软件看到的地址可以是一个完全不真实的地址。它会经过 MMU 的查表翻译最终落到某个物理页框上。物理页框可以零零散散地分布在物理内存各处但只要页表映射正确软件眼里它就是连续的一块。这种“造出一个整齐假象”的能力正是现代操作系统一切内存管理技巧的地基。1.2 分页解决的三个核心问题第一个是进程隔离。每个进程拥有独立的虚拟地址空间A 进程的 0x1000 和 B 进程的 0x1000 可以映射到完全不同的物理页。只要操作系统在切换进程时把页表切过去进程之间就天然隔开了。第二个是物理内存超售。物理内存可能只有 1GB但每个进程的虚拟地址空间可以大到 4GB。因为一个进程真正用到的内存只占它地址空间的一小部分每个虚拟页按需分配物理页即可。某一段虚拟地址空间空了物理页还能回收给别的任务。第三个是换页和虚拟内存。物理内存不够时把暂时用不到的页换到磁盘上页表项里把 P 位清零。之后程序再访问这个页CPU 就会抛缺页异常操作系统从磁盘把数据加载回物理内存重新置位然后让程序继续跑。用户根本感知不到这个过程这就是“假内存”的真相。用生活类比就是每个程序住进了写字楼里属于自己的“虚拟楼层”楼层指南写得很漂亮但真实房间是物业按需分配、随时调换的。只要房间号被正确登记在册你感觉不到有什么区别。1.3 为什么页大小偏偏是 4KBi386 的页大小是 4KB对应页内偏移 12 位。这不是随便定的而是长期工程权衡的结果。页太小了页表项数量暴增页表本身就要吃掉大量内存TLB 的覆盖范围也小页太大了内部碎片严重换页粒度太粗可能为了一个字节的数据把整块大内存搬到磁盘。4KB 恰好是一个折中既保证页表开销不会太离谱又能让分配器按小粒度管理内存。而且从位运算角度看4KB 对齐非常舒服物理地址低 12 位天然为 0页表项里可以省去这 12 位用来存放标志位。这一点你写代码时会有切身体会。后来为了大内存场景出现了 2MB、1GB 的大页那是另一个维度的优化并不代表 4KB 错了。先理解 4KB 这套基础逻辑后面再看其他页大小都会很顺。2. 两级页表目录、页表与线性地址的拆解2.1 1024×1024一张覆盖 4GB 地址空间的“地图”i386 是 32 位处理器线性地址空间有 4GB。它把 32 位线性地址拆成三部分高 10 位作为页目录索引中间 10 位作为页表索引低 12 位作为页内偏移。页目录有 1024 项每项 4 字节总共恰好 4KB占一个物理页。页目录项PDE指向一个页表而页表同样有 1024 项、占 4KB。每个页表项PTE指向一个 4KB 的物理页框。页目录的一项管理的不是一个页而是一个 4MB 的“窗口”高 10 位决定了你落到哪一个 4MB 窗口中 10 位决定在这个窗口里选哪一个 4KB 页低 12 位就是在页内部的偏移。1024 个页表 × 1024 个页 1M 个物理页正好覆盖 4GB。很多人问为什么不用一张一级大页表那样需要 1M 个页表项每个 4 字节总共 4MB。4MB 听着不大但那意味着每个进程都要为页表连续占用 4MB 物理内存而且这张表必须在物理上连续。两级结构的好处是页表可以离散存放按需分配。进程只用了 100MB 内存那就只需要给它分配几十个页表大部分页目录项直接置为“无效”即可。这种懒加载的思路让大地址空间变得实际可用。2.2 用实例走一遍地址翻译流程举个例子线性地址 0x00401000。先按位拆高 10 位0x00401000 22 0x1所以页目录索引是 1。中 10 位(0x00401000 12) 0x3FF 0x1所以页表索引是 1。低 12 位0x00401000 0xFFF 0x1000页内偏移是 4096。翻译过程分三步。第一步CPU 从 CR3 寄存器里拿到页目录的物理基址读取第 1 个页目录项验证它是否有效。第二步根据这个页目录项指向的页表物理基址读取页表中第 1 个页表项验证它是否有效。第三步把页表项里的物理页基址和低 12 位偏移拼在一起得到最终物理地址。如果你自己动手写翻译函数会发现整个过程无非就是“两次查表一次拼地址”。真正的难点不在算法而在你要时刻记住页目录项和页表项里存的都是物理地址不是线性地址。CPU 在翻译阶段完全不理解虚拟地址CR3 里放虚拟地址更是典型的菜鸟错误。2.3 CR3、按需分配与进程切换CR3 寄存器保存当前任务页目录的物理基址因此也常被称为 PDBR。进程切换时操作系统把新进程的页目录物理基址写入 CR3理论上后面的地址翻译就全部切到新地址空间了。页目录和页表都要求物理地址 4KB 对齐因为它们本身各自占用一整个物理页。这个细节在写裸机代码时反复坑人你定义了一个结构体数组当页目录结果编译器把它放到了非对齐地址CR3 一加载翻译立刻错乱。解决方式要么用对齐属性要么自己管理物理页分配器把页目录放到已知的 4KB 边界上。按需分配on-demand paging是理解页表使用率的钥匙。一个新进程创建时操作系统通常不会给它建立完整的 1024 个页表只建立页目录所有项默认无效。随着进程实际访问内存缺页异常被触发操作系统才一步步填充页目录项和页表项。所以一个进程即使虚拟空间很大实际页表数量也远小于 1024 个。2.4 4MB 大页PDE 里的 PS 位页目录项的低 12 位里有一个 PS 位Page Size位于 bit 7。当 PS1 时页目录项不再指向下一级页表而是直接指向一个 4MB 的物理页。此时线性地址只拆两段高 10 位页目录索引 低 22 位页内偏移中间那 10 位页表索引直接合并进偏移。这样做少查一级页表也少了一份页表的内存开销。一页 4MB 大页覆盖 1024 个 4KB 普通页TLB 一条记录就能覆盖更大地址范围。适合映射大块连续物理内存比如内核线性映射区、显存帧缓冲、大块 DMA 缓冲区。代价也明显4MB 粒度太粗不适合按页换进换出。如果程序只要 100KB用 4MB 大页会造成巨大的内部碎片。现代系统依然保留这个思路动辄 2MB、1GB 的大页就是为了减少页表层级、加大 TLB 覆盖但用途都偏向特定场景不是默认配置。3. 页表项里那些决定生死的 flag 位3.1 P 位有效还是无效页表项最低位是 P 位表示该映射是否有效。P0 时CPU 访问这个线性地址会触发 Page Fault 异常。异常发生后CR2 寄存器会保存触发异常的线性地址错误码里则记录更细的原因比如页不存在、写操作引发、特权级不够等。操作系统拿到这个信息后会根据线性地址找到对应的虚拟内存区域分配物理页填写页表项刷新 TLB然后返回用户态。回到哪回到触发异常的那条指令让它重新执行一遍这次就能成功了。这个机制最容易被忽视的点在于缺页异常不只是“访问了空指针”才会出现。内存换出、写时复制COW、栈自动增长全都要靠缺页异常来完成。可以说现代操作系统内存管理的入口就是分页异常处理程序。我调试裸机内核时遇到最多的一个低级错误就是物理页明明分配了页表项也写进去了唯独忘了置 P 位于是访问一个“看起来映射好了”的地址时照样崩。3.2 R/W 与 U/S两级权限的组成逻辑页表项 bit 1 是 R/Wbit 2 是 U/S。R/W0 表示只读R/W1 表示可读写U/S0 表示只有超级用户特权级 0能访问U/S1 表示用户态也能访问。这里有个容易混淆的地方页目录和页表各有自己的 R/W 和 U/S最终生效权限是“两级取更严格的那个”。比如页目录项 U/S0那这个 4MB 窗口内所有页用户态都不许碰哪怕某个页表项 U/S1 也没用。反过来如果页表项 U/S0页目录 U/S1结果还是只有超级用户能访问。换句话说权限检查是“AND”逻辑只有两级都允许最终才允许。R/W 位也一样页目录只读而页表可写实际只读页目录可写而页表只读实际还是只读。这个设计让你可以用页目录一次性把一大块区域设成只读或者内核专属效率很高。我还想强调一点页表权限和段权限是叠加的。即使页表允许这次访问如果段的权限检查不过关同样拒绝。排查权限异常时要记得分页之外还有一层段保护别只盯着 PTE。3.3 A 位与 D 位软件维护的“访问痕迹”页表项 bit 5 是 A 位Accessedbit 6 是 D 位Dirty。CPU 在访问某页时会自动把 A 位置 1发生写入时会把 D 位置 1。这些位由硬件置位但由操作系统软件负责清零和统计。A 位是换页算法的核心素材。操作系统可以周期性地把 A 位清零过一段时间检查哪些页 A 位又变成 1就知道这些页最近被用过。这就是 LRU 的近似实现不是精确地记录访问顺序而是用“最近是否被访问”作为页面活跃度的粗略判断。D 位则决定换出策略。如果一页被换出时 D0说明内容从加载到现在没被改过磁盘上还有原样副本直接丢弃物理页就行不用写回。如果 D1则必须先写回磁盘否则数据就丢了。写小型内核时最容易被忽略的就是处理 D 位导致页面回收后数据不完整排查起来非常痛苦。3.4 保留位和后继扩展原始 i386 页表项里还有 PWT、PCD 这些缓存控制位用来控制页级缓存策略平时很少单独修改。有些位在 386 时代被规定为“保留位”要求软件写入时必须置 0CPU 检测到异常值会触发异常。这些保留位后来被一步步利用了起来。比如 bit 7 在 PTE 里后来被定义为 PAT 位用于扩展页面缓存类型bit 8 后来成为全局页 G 位表示该页在 CR3 切换时不清理 TLB再后来某些架构在页表项高位置了新语义比如禁止执行位。理解了这些背景你会看到硬件设计的一种常见策略先留空位等需要时再定义语义同时保持向前兼容。写页表项时拿不准的位一律清零是最安全的做法。4. TLB那些“隐藏”的性能问题4.1 未命中 TLB 时的三次内存访问如果没有 TLB分页机制会让每一次访存都变成多次内存访问。要翻译一个线性地址CPU 必须先查页目录、再查页表、最后才访问目标数据。粗略算下来一次数据访问要产生三次真正的内存读写。指令执行本来就要大量访存分页一旦全走查表流程系统会慢得没法用。TLB 就是来解决这个问题的。它是一张小的、按虚拟页号索引的硬件缓存保存最近用过的“线性地址→物理地址”映射。命中 TLB 时一次访问就完成了完全不需要再查页目录和页表。这也是为什么 MMU 硬件性能对系统整体至关重要。你可以把 TLB 理解成一个“翻译速查表”第一次查页表的结果被记下来下次遇到相同页面直接翻答案只有答案没被记录时才去走完整查表流程。4.2 TLB 的命中率与失效时机因为程序访问存在时间局部性和空间局部性TLB 命中率通常非常高普遍能达到 99% 以上。但 TLB 大小很有限早期处理器只有几十个条目。4KB 普通页在一个只有 64 条记录的 TLB 里最多覆盖 256KB 地址空间一旦程序的工作集超过这个范围TLB 就会频繁失效性能断崖式下降。失效之后要怎么办呢最常见的处理方式是当 CR3 寄存器被重新加载时整个 TLB 中与当前地址空间相关的条目全部失效。这意味着每次进程切换都可能带来一轮 TLB 冲刷。早期架构没有 ASID 这类标识符无法区分不同进程的相同虚拟地址在 TLB 里的映射关系只能全冲这是上下文切换开销的重要来源之一。后来也出现了更细的指令比如精刷新一条映射的 INVLPG可以只让特定虚拟地址的 TLB 条目失效而不是全冲。写内核时如果能用这个指令能省掉很多不必要的性能损失。4.3 修改页表后一定要“刷新”吗必须刷。这是个非常容易踩的坑。你的软件改了页表项比如把一个物理页收回、把 P 位清零、或者把一个页面改成只读。如果 TLB 里还存着这条映射的旧条目CPU 可能会继续使用旧物理地址这就和当前页表内容不一致了。访问一个已经“无效”的页却不报缺页异常排查出来往往让人怀疑人生。正确做法是在修改页表项之后执行 INVLPG 精确失效对应的虚拟地址或者在安全的情况下重新加载 CR3让 TLB 整体换新。多核环境下更麻烦一个核改了页表其他核的 TLB 里还留着旧映射必须通过 IPI 让每个核都执行一次刷新。实践里“改完页表没刷 TLB”是内核内存管理 bug 的重灾区值得反复检查。4.4 大页给 TLB 带来的收益大页为什么能提升性能因为 TLB 一条记录能映射更大的地址空间。4MB 大页一条 TLB 记录覆盖 1024 个普通 4KB 页等于一次缓存了 1024 个页的翻译结果。如果 TLB 有 64 条4KB 页最多覆盖 256KB而 4MB 大页能覆盖 256MB差距上千倍。这也是为什么现代系统里数据库、虚拟机等内存占用大、访问频繁的应用偏好大页。它们的工作集动辄几个 GB普通页会导致 TLB 频繁失效而大页让 TLB 轻松覆盖整个工作集。大页的代价我前面说过分配粒度粗、内部碎片明显、不适合频繁换页。所以它更适合用作系统启动后长期驻留的映射而不是每个进程默认都开。i386 的 4MB 大页其实已经包含了现代大页技术的所有核心逻辑。5. 动手做实验把页表翻译“看得见”5.1 30 行 C 代码模拟线性地址翻译学习分页机制最好的办法不是听我讲而是自己写一个模拟器。不需要去操作真实硬件只需要用 C 语言模拟页目录、页表和地址翻译过程就能把概念落到实处。下面这段代码就是个简化版教学演示。我只保留了一个页表方便直观看到“拆地址、查两级表、拼物理地址”这个过程。它不能模拟多个页表同时存在但能帮你看清每一步到底发生了什么。#include stdio.h #define PD_INDEX(addr) (((addr) 22) 0x3FF) #define PT_INDEX(addr) (((addr) 12) 0x3FF) #define OFFSET(addr) ((addr) 0xFFF) unsigned int page_dir[1024] {0}; unsigned int page_tbl[1024] {0}; void map_4k(unsigned int virt, unsigned int phys, unsigned int flags) { unsigned int pdi PD_INDEX(virt); unsigned int pti PT_INDEX(virt); if ((page_dir[pdi] 1) 0) { /* 简化只演示一个页表PD项直接指向page_tbl */ page_dir[pdi] (unsigned int)page_tbl[0] | 1 | flags; } page_tbl[pti] (phys ~0xFFF) | 1 | flags; } unsigned int virt_to_phys(unsigned int virt) { unsigned int pdi PD_INDEX(virt); unsigned int pti PT_INDEX(virt); unsigned int pde page_dir[pdi]; unsigned int pte; if ((pde 1) 0) { printf(PDE not present, pdi%u\n, pdi); return 0xFFFFFFFF; } pte page_tbl[pti]; if ((pte 1) 0) { printf(PTE not present, pti%u\n, pti); return 0xFFFFFFFF; } return (pte ~0xFFF) | OFFSET(virt); } int main(void) { unsigned int virt 0x00401000; unsigned int phys 0x0012b000; map_4k(virt, phys, 0x3); printf(virt 0x%08x - phys 0x%08x\n, virt, virt_to_phys(virt)); printf(PD index%u, PT index%u, offset0x%x\n, PD_INDEX(virt), PT_INDEX(virt), OFFSET(virt)); return 0; }编译运行后你会看到virt 0x00401000 - phys 0x0012b000同时打印出 PD index1、PT index1、offset0x1000。这时你可以手动修改 map_4k 的参数映射同一个虚拟地址到另一个物理页看看翻译结果怎么变。也可以故意把某个页表项的 P 位清零观察 virt_to_phys 输出“PTE not present”。这个过程虽然简单但会把“两级表查表”这个动作刻进脑子里。5.2 在 QEMU 里真正开启 CR0.PG模拟器跑通之后下一步是到真实硬件环境下感受一下开启分页的瞬间。推荐用 QEMU 跑一个裸机程序因为崩溃了可以直接重启没有任何风险。开启分页的步骤其实不复杂先准备好 GDT 进入保护模式再准备好页目录和页表把它们放到已知物理地址加载 CR3最后把 CR0 的 PG 位置 1。关键汇编大致是这样; 假设当前已经处于保护模式GDT 已经设置好 mov eax, PAGE_DIR_BASE ; 必须是 4KB 对齐的物理地址 mov cr3, eax mov eax, cr0 or eax, 0x80000000 ; CR0.PG 1 mov cr0, eax jmp flush_prefetch ; 清空流水线 flush_prefetch: ; 继续执行这里最大的坑是开启分页的一瞬间下一条指令的线性地址也必须能被页表翻译。如果当前代码所在的地址没有映射CPU 执行到下一步就直接触发缺页异常。所以裸机实验里我强烈建议你把低 1MB 或者代码所在的物理页做一个“恒等映射”也就是虚拟地址等于物理地址。这样开启分页后指令取指才不受影响。如果你用 QEMU 配合调试器可以这么做在开启分页的代码处下断点查看 CR3 的值再去看 CR3 指向的物理内存里页目录项和页表项的内容。手动算一遍地址拆分和调试器看到的内存内容对照比任何 PPT 都直观。5.3 故意制造一次 Page Fault分页机制里很多概念不看异常现场就体会不深。最有效的教学实验是制造一个缺页异常然后观察异常发生前后的一切。我先按正常方式映射一页然后写程序去访问它没问题。接着我把页表项的 P 位清零再访问同一个虚拟地址。此时 CPU 会发起缺页异常如果你在裸机环境里还没写异常处理程序程序会复位或者反复重启如果在写好的内核里你会看到 CR2 的值就是刚才访问的那个线性地址。异常错误码也值得研究。错误码 bit 0 归零说明页不存在bit 1 表示是不是写操作引发的bit 2 表示是不是用户态访问引发的。把这几位的含义记清楚以后内核日志里的 Page Fault 信息就能看懂了。我见过太多人学完分页却说不出缺页异常是什么感觉。亲手用调试器观察一次 CR2、错误码和异常处理流程你就会明白为什么操作系统能把“不可用内存”包装成“可用内存”。6. 常见问题排查与避坑速查6.1 症状对照表从现象到根因现象可能原因排查方法刚开启分页就异常重启当前 EIP 对应线性地址未映射为代码段所在区域做恒等映射访问已映射地址仍报缺页页表项 P 位未置 1检查 PTE 低 bit确认物理页是否有效用户态程序能读内核页U/S 位设置错误检查涉及页的 PDE/PTE 的 U/S 位两个进程地址空间“串了”切换 CR3 时没有保存/恢复页目录确认进程切换流程里 CR3 是否被重载修改页表后行为异常TLB 残留旧映射执行 INVLPG 或重载 CR3换出页面后数据丢失页表项 D 位未处理导致没写回确保回收物理页时检查 D 位权限明明够却访问失败段权限和页权限叠加导致同时检查段描述符权限和页表权限这张表基本覆盖了我自己做实验时踩过的大多数问题。每个问题背后的原理前文都已经讲到了排查时按表去对即可。6.2 我踩过的几个典型坑页目录和页表必须物理地址 4KB 对齐。这个问题在 C 里很隐蔽因为你定义数组的时候编译器不保证对齐。用普通数组当页目录然后直接加载进 CR3轻则部分地址翻译错误重则直接崩溃。解决办法是给数组加对齐属性或者从自己管理的物理页分配器中分配内存。写入 PTE 时物理地址字段要清掉低 12 位。页表项低 12 位是标志位物理页基址从第 12 位才开始算。如果不做phys ~0xFFF就对低位置 1标志位和物理地址就会互相污染翻译结果完全错乱。这是新手写页表代码最常见的 bug 之一。修改页表之后没有刷新 TLB。这个我前面专门提过再强调一次不算多。页表是软件写的但 CPU 实际用的是 TLB两者必须同步。很多“神秘”问题最后查出来都是 TLB 没刷干净。递归页表的理解要小心。有些内核实现会把某个页目录项指向页目录本身让页表也映射到虚拟地址空间里这样内核态代码就可以直接修改页表。初次接触时很容易绕晕但只要想清楚“页目录项指向的物理页本身也是页目录”这一层就顺了。它算是一个进阶玩法理解之后对地址空间布局很有帮助。6.3 一个验证页表翻译的用户态小技巧如果你在某个带用户态接口的系统上学习可以尝试读取/proc/self/pagemap这类接口查询当前进程虚拟页对应的物理页框号和标志位。它的原理就是去内核的数据结构里读页表信息非常适合验证你对分页翻译的理解。大致思路是这样对某个虚拟地址virt先计算出它对应的虚拟页号virt 12然后读取该页对应的 64 位入口。从入口里可以解析出物理页框号PFN再拼上页内偏移就得到物理地址。大致伪代码如下off (virt / 4096) * 8; lseek(fd, off, SEEK_SET); read(fd, entry, 8); pfn entry ((1ULL 55) - 1); phys pfn * 4096 (virt 0xFFF);不同系统对内细节略有差异但整体思路一致。当年我写模拟器时翻译结果怎么都不对后来靠这种用户态接口把同一虚拟地址的“模拟物理地址”和“系统真实物理地址”对照才发现是自己把页目录索引和页表索引顺序搞反了。这种交叉验证的方法排查内存问题非常好用。最后聊点个人体会我最早学 i386 分页时和大多数人一样抱着书硬啃页目录 1024 项、页表项的标志位背得熟练可一到实际调试照样抓瞎。后来我把方法换成了“从零写一个能完成地址翻译的模拟器”再配合裸机环境真正开启分页才把 CR3、PDE、PTE、TLB 这些概念彻底串起来。这个机制本身不难难的是把教材里的框图变成自己手里的操作流程。建议你也亲手写一遍那个 30 行的模拟器或者用调试器盯着页表一步步走。哪怕花一晚上也值得。这条线打通之后再看后面的 PAE、大页、NX 位、进程地址空间隔离这些主题都会觉得顺理成章。分页不是孤立的知识点它是整个现代操作系统内存管理的主干道。