计算机体系结构这几门课,我前前后后翻过不少遍,最值得反复琢磨的知识点之一,就是 i386 的页式内存管理机制。它正好卡在操作系统和 CPU 的交界处:一边是虚拟内存、进程隔离、缺页换页这些软概念,一边是寄存器、页表、TLB 这些硬细节。说一句不过分的话,把这一块搞透,操作系统内存管理的一大半底层逻辑就通了。这篇文章不打算按教材目录给你念一遍,而是从“为什么非要用分页”讲到“两级页表到底怎么翻译地址”,再讲到页表项里的标志位、TLB 性能问题,最后给出一套能自己动手做的模拟实验和排查思路。适合对保护模式有一定概念、想彻底弄懂虚拟内存的读者,也适合准备自己写小型内核、做 OS 实验的人。你会发现,很多所谓的高级特性,理解到 i386 这一层之后,全都能串起来。1. 为什么搞懂 i386 的分页机制值得花时间1.1 从分段到分页:一条地址的两级翻译i386 同时提供段机制和页机制,而且这两套机制是串联工作的。CPU 先把逻辑地址通过段描述符转换成线性地址,再把线性地址通过分页机制转换成物理地址。CR0 寄存器里的 PG 位是个总开关:PG=1 时开启分页,PG=0 时线性地址直接当物理地址用,段机制之后的那一步就跳过去了。所以你会发现,在实模式下根本没有“分页”这回事,地址就是地址,程序说访问 0x1000,CPU 就去物理内存 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。当 PS=1 时,页目录项不再指向下一级页表,而是直接指向一个 4MB 的物理页。此时线性地址只拆两段:高 10 位页目录索引 + 低 22 位页内偏移,中间那 10 位页表索引直接合并进偏移。这样做少查一级页表,也少了一份页表的内存开销。一页 4MB 大页覆盖 1024 个 4KB 普通页,TLB 一条记录就能覆盖更大地址范围。适合映射大块连续物理内存,比如内核线性映射区、显存帧缓冲、大块 DMA 缓冲区。代价也明显:4MB 粒度太粗,不适合按页换进换出。如果程序只要 100KB,用 4MB 大页会造成巨大的内部碎片。现代系统依然保留这个思路,动辄 2MB、1GB 的大页就是为了减少页表层级、加大 TLB 覆盖,但用途都偏向特定场景,不是默认配置。3. 页表项里那些决定生死的 flag 位3.1 P 位:有效还是无效页表项最低位是 P 位,表示该映射是否有效。P=0 时,CPU 访问这个线性地址会触发 Page Fault 异常。异常发生后,CR2 寄存器会保存触发异常的线性地址,错误码里则记录更细的原因,比如页不存在、写操作引发、特权级不够等。操作系统拿到这个信息后,会根据线性地址找到对应的虚拟内存区域,分配物理页,填写页表项,刷新 TLB,然后返回用户态。回到哪?回到触发异常的那条指令,让它重新执行一遍,这次就能成功了。这个机制最容易被忽视的点在于:缺页异常不只是“访问了空指针”才会出现。内存换出、写时复制(COW)、栈自动增长,全都要靠缺页异常来完成。可以说,现代操作系统内存管理的入口就是分页异常处理程序。我调试裸机内核时,遇到最多的一个低级错误就是:物理页明明分配了,页表项也写进去了,唯独忘了置 P 位,于是访问一个“看起来映射好了”的地址时照样崩。3.2 R/W 与 U/S:两级权限的组成逻辑页表项 bit 1 是 R/W,bit 2 是 U/S。R/W=0 表示只读,R/W=1 表示可读写;U/S=0 表示只有超级用户(特权级 0)能访问,U/S=1 表示用户态也能访问。这里有个容易混淆的地方:页目录和页表各有自己的 R/W 和 U/S,最终生效权限是“两级取更严格的那个”。比如页目录项 U/S=0,那这个 4MB 窗口内所有页用户态都不许碰;哪怕某个页表项 U/S=1 也没用。反过来,如果页表项 U/S=0,页目录 U/S=1,结果还是只有超级用户能访问。换句话说,权限检查是“AND”逻辑:只有两级都允许,最终才允许。R/W 位也一样,页目录只读而页表可写,实际只读;页目录可写而页表只读,实际还是只读。这个设计让你可以用页目录一次性把一大块区域设成只读或者内核专属,效率很高。我还想强调一点:页表权限和段权限是叠加的。即使页表允许这次访问,如果段的权限检查不过关,同样拒绝。排查权限异常时,要记得分页之外还有一层段保护,别只盯着 PTE。3.3 A 位与 D 位:软件维护的“访问痕迹”页表项 bit 5 是 A 位(Accessed),bit 6 是 D 位(Dirty)。CPU 在访问某页时会自动把 A 位置 1;发生写入时会把 D 位置 1。这些位由硬件置位,但由操作系统软件负责清零和统计。A 位是换页算法的核心素材。操作系统可以周期性地把 A 位清零,过一段时间检查哪些页 A 位又变成 1,就知道这些页最近被用过。这就是 LRU 的近似实现:不是精确地记录访问顺序,而是用“最近是否被访问”作为页面活跃度的粗略判断。D 位则决定换出策略。如果一页被换出时 D=0,说明内容从加载到现在没被改过,磁盘上还有原样副本,直接丢弃物理页就行,不用写回。如果 D=1,则必须先写回磁盘,否则数据就丢了。写小型内核时最容易被忽略的就是处理 D 位,导致页面回收后数据不完整,排查起来非常痛苦。3.4 保留位和后继扩展原始 i386 页表项里还有 PWT、PCD 这些缓存控制位,用来控制页级缓存策略,平时很少单独修改。有些位在 386 时代被规定为“保留位”,要求软件写入时必须置 0,CPU 检测到异常值会触发异常。这些保留位后来被一步步利用了起来。比如 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, offset=0x%x\n", PD_INDEX(virt), PT_INDEX(virt), OFFSET(virt)); return 0; }编译运行后,你会看到virt 0x00401000 -> phys 0x0012b000,同时打印出 PD index=1、PT index=1、offset=0x1000。这时你可以手动修改 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 位、进程地址空间隔离这些主题,都会觉得顺理成章。分页不是孤立的知识点,它是整个现代操作系统内存管理的主干道。
计算机体系结构这几门课,我前前后后翻过不少遍,最值得反复琢磨的知识点之一,就是 i386 的页式内存管理机制。它正好卡在操作系统和 CPU 的交界处:一边是虚拟内存、进程隔离、缺页换页这些软概念,一边是寄存器、页表、TLB 这些硬细节。说一句不过分的话,把这一块搞透,操作系统内存管理的一大半底层逻辑就通了。这篇文章不打算按教材目录给你念一遍,而是从“为什么非要用分页”讲到“两级页表到底怎么翻译地址”,再讲到页表项里的标志位、TLB 性能问题,最后给出一套能自己动手做的模拟实验和排查思路。适合对保护模式有一定概念、想彻底弄懂虚拟内存的读者,也适合准备自己写小型内核、做 OS 实验的人。你会发现,很多所谓的高级特性,理解到 i386 这一层之后,全都能串起来。1. 为什么搞懂 i386 的分页机制值得花时间1.1 从分段到分页:一条地址的两级翻译i386 同时提供段机制和页机制,而且这两套机制是串联工作的。CPU 先把逻辑地址通过段描述符转换成线性地址,再把线性地址通过分页机制转换成物理地址。CR0 寄存器里的 PG 位是个总开关:PG=1 时开启分页,PG=0 时线性地址直接当物理地址用,段机制之后的那一步就跳过去了。所以你会发现,在实模式下根本没有“分页”这回事,地址就是地址,程序说访问 0x1000,CPU 就去物理内存 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。当 PS=1 时,页目录项不再指向下一级页表,而是直接指向一个 4MB 的物理页。此时线性地址只拆两段:高 10 位页目录索引 + 低 22 位页内偏移,中间那 10 位页表索引直接合并进偏移。这样做少查一级页表,也少了一份页表的内存开销。一页 4MB 大页覆盖 1024 个 4KB 普通页,TLB 一条记录就能覆盖更大地址范围。适合映射大块连续物理内存,比如内核线性映射区、显存帧缓冲、大块 DMA 缓冲区。代价也明显:4MB 粒度太粗,不适合按页换进换出。如果程序只要 100KB,用 4MB 大页会造成巨大的内部碎片。现代系统依然保留这个思路,动辄 2MB、1GB 的大页就是为了减少页表层级、加大 TLB 覆盖,但用途都偏向特定场景,不是默认配置。3. 页表项里那些决定生死的 flag 位3.1 P 位:有效还是无效页表项最低位是 P 位,表示该映射是否有效。P=0 时,CPU 访问这个线性地址会触发 Page Fault 异常。异常发生后,CR2 寄存器会保存触发异常的线性地址,错误码里则记录更细的原因,比如页不存在、写操作引发、特权级不够等。操作系统拿到这个信息后,会根据线性地址找到对应的虚拟内存区域,分配物理页,填写页表项,刷新 TLB,然后返回用户态。回到哪?回到触发异常的那条指令,让它重新执行一遍,这次就能成功了。这个机制最容易被忽视的点在于:缺页异常不只是“访问了空指针”才会出现。内存换出、写时复制(COW)、栈自动增长,全都要靠缺页异常来完成。可以说,现代操作系统内存管理的入口就是分页异常处理程序。我调试裸机内核时,遇到最多的一个低级错误就是:物理页明明分配了,页表项也写进去了,唯独忘了置 P 位,于是访问一个“看起来映射好了”的地址时照样崩。3.2 R/W 与 U/S:两级权限的组成逻辑页表项 bit 1 是 R/W,bit 2 是 U/S。R/W=0 表示只读,R/W=1 表示可读写;U/S=0 表示只有超级用户(特权级 0)能访问,U/S=1 表示用户态也能访问。这里有个容易混淆的地方:页目录和页表各有自己的 R/W 和 U/S,最终生效权限是“两级取更严格的那个”。比如页目录项 U/S=0,那这个 4MB 窗口内所有页用户态都不许碰;哪怕某个页表项 U/S=1 也没用。反过来,如果页表项 U/S=0,页目录 U/S=1,结果还是只有超级用户能访问。换句话说,权限检查是“AND”逻辑:只有两级都允许,最终才允许。R/W 位也一样,页目录只读而页表可写,实际只读;页目录可写而页表只读,实际还是只读。这个设计让你可以用页目录一次性把一大块区域设成只读或者内核专属,效率很高。我还想强调一点:页表权限和段权限是叠加的。即使页表允许这次访问,如果段的权限检查不过关,同样拒绝。排查权限异常时,要记得分页之外还有一层段保护,别只盯着 PTE。3.3 A 位与 D 位:软件维护的“访问痕迹”页表项 bit 5 是 A 位(Accessed),bit 6 是 D 位(Dirty)。CPU 在访问某页时会自动把 A 位置 1;发生写入时会把 D 位置 1。这些位由硬件置位,但由操作系统软件负责清零和统计。A 位是换页算法的核心素材。操作系统可以周期性地把 A 位清零,过一段时间检查哪些页 A 位又变成 1,就知道这些页最近被用过。这就是 LRU 的近似实现:不是精确地记录访问顺序,而是用“最近是否被访问”作为页面活跃度的粗略判断。D 位则决定换出策略。如果一页被换出时 D=0,说明内容从加载到现在没被改过,磁盘上还有原样副本,直接丢弃物理页就行,不用写回。如果 D=1,则必须先写回磁盘,否则数据就丢了。写小型内核时最容易被忽略的就是处理 D 位,导致页面回收后数据不完整,排查起来非常痛苦。3.4 保留位和后继扩展原始 i386 页表项里还有 PWT、PCD 这些缓存控制位,用来控制页级缓存策略,平时很少单独修改。有些位在 386 时代被规定为“保留位”,要求软件写入时必须置 0,CPU 检测到异常值会触发异常。这些保留位后来被一步步利用了起来。比如 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, offset=0x%x\n", PD_INDEX(virt), PT_INDEX(virt), OFFSET(virt)); return 0; }编译运行后,你会看到virt 0x00401000 -> phys 0x0012b000,同时打印出 PD index=1、PT index=1、offset=0x1000。这时你可以手动修改 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 位、进程地址空间隔离这些主题,都会觉得顺理成章。分页不是孤立的知识点,它是整个现代操作系统内存管理的主干道。