☰
x86分页机制详解:从386页表到TLB与缺页处理
2026/10/21 4:28:46 网站建设 项目流程

很多年前我刚开始写操作系统内核的分页模块时,总是把CR3、页目录、页表这几个概念背得滚瓜烂熟,但一到自己动手设置页面映射,程序就毫无悬念地缺页崩溃。后来耐下性子把i386的页式内存管理机制从头走查了一遍,才发现问题根本不是“背没背住”,而是没搞懂这条地址转换链路上每个环节到底在干什么。这篇文章就把我梳理过的思路完整写出来,按一次内存访问的视角,从线性地址怎么拆、页表怎么查、物理地址怎么拼,一直聊到TLB、缺页处理、自引用页表这些系统软件真正关心的点。适合正在写内核、做模拟器、或者单纯想把x86虚拟内存吃透的读者。

1. 386为何要绕这么大一圈:分页机制的由来与两级设计

1.1 分页解决的根本矛盾

在386出现之前,x86系处理器主要靠分段来管理内存。分段有个很直观的好处:程序不用关心自己加载到哪个物理地址,只要把段基址换一下,整个段就“搬”过去了。但分段解决不了另一个问题——进程需要的不是一整块连续内存,而是很多小块。比如一个进程要加载代码、数据、堆、栈,它们各自生长、各自消亡,如果要求所有段都连续分配,内存碎片会很快把空间吃掉。

分页就是在分段之下再叠一层粒度更细的映射。它把物理内存切成4KB大小的页框,把进程的线性地址空间也切成4KB大小的页面。每个页面可以独立映射到任意一个物理页框,相同物理页框还能同时出现在多个进程的线性地址空间里。这样进程看到的虚拟空间是连续、干净、私有的,背后真实物理内存却可以乱序、共享、按需分配。

站在386当年那个时间点看,分页带来的收益非常直接:

  • 物理内存不够用时,可以把页面换到磁盘,等用到了再换回来;
  • 进程中长期不用的代码和数据,不需要留在物理内存里;
  • 两个进程可以通过映射同一个物理页框来共享代码段;
  • 每个页面单独设权限位,用户态进程碰不到内核页。

这些能力,单靠分段给不了。分段的最小单位是段,而段在逻辑上通常很大;换一个段里的几个页,得对整个段做状态管理,太笨重。所以386在保护模式下保留了分段,同时新增了分页,两者串联使用,这才构成完整的虚拟地址转换流程。

1.2 为什么是两级,而不是一张大表

386的线性地址宽度是32位,按4KB页面算,一共需要1M个页表项。一个页表项占4字节,单级页表就是4MB。听起来4MB不多,但386时代物理内存普遍只有几MB到几十MB,如果每个进程都要预先拿出4MB连续物理内存做页表,系统还没跑业务就先被页表拖垮了。

更麻烦的是,很多进程实际只用到线性地址空间里很小的一部分。单级页表必须把整个4GB空间的映射一次性铺开,哪怕大部分表项都标记“不存在”,那4MB空间也已经预占了。二级页表的设计则把这张大表拆成了页目录和页表两层:

  • 页目录:1024项,每项4字节,共4KB;
  • 每个页表:1024项,每项4字节,共4KB;
  • 最多1024个页表。

页目录的每一项指向一个页表。如果某个4MB区间完全没用到,对应的页目录项直接置为“不存在”,这一整块就不需要分配任何物理页表。只有真正需要映射的区域,才按需为它分配一个4KB页表。这样一个小进程可能只需要一个页目录外加一个页表,总共8KB,而不是4MB。这个“按需分配下级表”的思想,后来直到x86-64的四级页表也在沿用。

1.3 先分段再分页:一次内存访问的完整前置流程

386的分页不是独立工作的。CPU取指或访存时,先由分段单元把逻辑地址变成线性地址,再由分页单元把线性地址变成物理地址。如果分页被禁用,线性地址就直接当物理地址用;只有CR0的PG位置1后,分页才参与转换。

这里有个很多初学者忽略的点:分页打开的前提是CPU已经处于保护模式。386手册里明确要求PG置位时PE必须同时为1,也就是“保护模式+分页”才是完整形态。分段负责提供段基址、段界限、特权级检查,分页负责把线性地址继续映射到物理页框。两层各自有各自的权限检查,一次内存访问要同时通过段级检查和页级检查才能真正落到DRAM上。

理解这个串联关系很重要。调试分页问题时,如果线性地址本身在段转换那一步就已经不是一个合理的值,后面查页表查得再仔细也白搭。所以排查分页故障的第一步,永远是确认进入分页单元的线性地址确实是预期值。

2. 页式转换中的每一步:手工走查胜过死记硬背

2.1 线性地址的拆解:10位、10位、12位

386把一个32位线性地址切成三段:

  • bit31到位22,共10位,用作页目录索引;
  • bit21到位12,共10位,用作页表索引;
  • bit11到位0,共12位,用作页内偏移。

三个字段各司其职。页目录索引决定查页目录里的第几项,页表索引决定查对应页表里的第几项,页内偏移决定最终落在物理页框内的哪个字节。一个物理页框4KB,正好需要12位偏移,也就是4096种字节位置。

这种拆法还有个好处:知道一个线性地址的十六进制值,就能直接看出它的目录索引和页表索引。以前调试时我喜欢把关键地址写在纸上,比如0x00401000,高10位是0,中间10位也是0,说明它走页目录第0项、页表第0项,一目了然。地址到页表树的关系,本质上就是位域关系,看清位域比背地址范围可靠得多。

2.2 从页目录到页表再到页框的查表路线

一次完整的页式转换,硬件做的事情可以用五步描述:

  1. 从CR3寄存器读出当前页目录的物理基地址。CR3保存的必须是物理地址,因为分页单元正在做线性地址到物理地址的转换,不能再依赖分页本身去取页目录。
  2. 用线性地址的页目录索引乘以4,加到页目录基地址上,读出对应的页目录项。读取之后先检查P位,如果为0就产生缺页异常。
  3. 页目录项的高20位给出页表的物理基地址。页表本身也是4KB对齐的,所以基地址低12位直接视为0。
  4. 用线性地址的页表索引乘以4,加到页表基地址上,读出对应的页表项。同样先检查P位。
  5. 页表项的高20位给出物理页框号,把这个页框号左移12位,再拼接线性地址的低12位偏移,得到最终物理地址。

整个过程CPU会自动完成,不需要软件逐条干预。但理解这个查表顺序非常有用:任何一步的表项权限不满足,或者P位为0,CPU就会中断当前指令,转入缺页处理。CR3相当于整棵页表树的“根”,页目录项是“一级索引”,页表项是“二级索引”,物理页框是“叶子”,偏移是“叶子里的位置”。

2.3 CR3与进程切换:每个进程都有一本独立“账本”

页表是进程私有的。每个进程都有自己的页目录,所以每个进程都有自己的CR3值。操作系统在做进程切换时,会把新进程的页目录物理地址写进CR3。仅仅这一条指令,就让整个地址空间从旧进程换成了新进程。

CR3还有一个名称叫页目录基址寄存器,386里它一般按照4KB对齐存放页目录。写CR3的操作既是设置新页表树的根,也是告诉TLB“之前的映射已经作废”。所以进程切换的代价不只是保存寄存器,还包含整套TLB失效带来的后续重填成本。后面讲性能时会专门展开。

多进程共享物理页框也是靠页表实现的。两个进程的页表项可以指向同一个物理页框,但权限位各自独立。比如共享只读代码,两边都把R/W位清成0,谁也别想改;共享读写数据,两边都置1。页框本身不知道自己被谁映射,映射关系完全由页表这张“账本”说了算。

2.4 缺页时硬件把现场留在哪里:CR2与错误码

当页表项P位为0,或者权限不满足时,硬件会产生页错误异常。此时CPU会把导致异常的线性地址保存到CR2寄存器,同时把一组错误码压入内核栈。错误码里的关键位含义如下:

  • 第0位表示“页不存在”:为1说明页存在但权限不合规,为0说明P位本身就是0;
  • 第1位表示访问类型:1是写,0是读;
  • 第2位表示当前特权级:1是用户态,0是内核态。

这个组合是定位问题的最快入口。比如错误码是7,二进制是111,翻译过来就是“页不存在、写操作、用户态”。内核拿到这个信息后,可以判断应该正常分配页面、执行写时复制,还是直接向进程发送段错误信号。CR2和错误码缺一不可,只看到CR2不知道访问意图,只看到错误码找不到出错地址。

3. 页表项逐位拆解:权限的裁决与硬件自维护的位

3.1 一个页表项就4个字节,但每个位都派得上用场

页目录项和页表项在386上都是32位。两者格式几乎一样,只是在个别位的含义上有差别。把它们按位拆开看:

位名称作用
0P存在位,1表示映射有效
1R/W读写位,0只读,1可读写
2U/S用户/系统位,0仅内核,1用户可访问
3PWT直写缓存提示
4PCD禁用缓存提示
5A访问位,页面被读取或写入时硬件置1
6D脏位,页面被写入时硬件置1,页表项通常才维护
7PS386保留,须为0;后来用于大页
8-11AVL软件可用位,硬件不修改
12-31物理页框号物理地址的高20位

页目录项和页表项的差异在于:页目录项指向的是页表的物理基地址,页表项指向的是页框的物理基地址;页目录项的D位在386上不参与CPU自动维护,更多是留给软件自己定义;而页表项的D位由硬件在写操作发生时自动置位。

3.2 保护位组合:U/S与R/W如何形成四象限

U/S和R/W两个位组合起来,可以覆盖大部分页面级保护需求:

  • U/S=0,R/W=0:仅内核可读可写;
  • U/S=0,R/W=1:仅内核可读可写;
  • U/S=1,R/W=0:用户可读,写操作受限;
  • U/S=1,R/W=1:用户可读可写。

这里面有个重要细节:386上内核态访问页面时,R/W约束并不像后来那么严格。当CPL小于3时,CPU默认内核有更多权力,对只读页面的写限制在386上不像用户态那样强制生效。后来的处理器增加了CR0的WP位,才让内核也对只读页面严格遵守写保护,这也成了写时复制机制能够高效实现的关键基础。

用户态则没有这种宽松。用户写一个R/W为0的页面,权限检查直接失败,CPU按写缺页处理。这个设计让操作系统可以把内核代码页设为“仅系统可读”,把用户数据页设为“用户可读写”,彼此物理隔离,互不越界。

3.3 A位与D位:为页面置换算法准备的硬件记账

A位和D位是硬件替软件做的记账。每当一个映射的页面对应的物理页被访问,CPU就自动把对应页表项的A位置1。写访问还会把D位置1。这两个位对操作系统实现虚拟内存意义重大:

  • 回收页面时,先看A位:如果很长时间没被访问,优先换出;
  • 写回磁盘时,先看D位:只有D位为1的页才需要写回,干净的页面可以直接丢弃。

一个典型流程是:内核周期性把A位清零,之后再观察哪些页面又被置位。被重新置位的说明最近活跃,没被置位的说明可能是冷页,可以进入回收候选。这种近似LRU的做法不需要额外硬件支持,全靠页表项里这两个位自动积累信息,成本极低。

还有一个容易忽略的点:CPU更新A/D位时,是在页表项所在内存里直接改。也就是说页表内存本身必须能被硬件写入。如果页表被映射成用户只读无关紧要,因为页表通常都放在内核地址空间,以系统权限维护。操作系统开发时如果把页表页错误地映射成用户可写,那就是自找麻烦。

3.4 缓存提示位与保留位:软件可用域和必须为零的域

PWT和PCD是缓存行为提示位,分别表示“页面写入时是否直写”和“这个页面是否禁止缓存”。386时代缓存体系还在进化,这两位主要给外部缓存提供参考。到了有内部缓存和各级Cache的处理器上,它们依然沿用,用于标记frame buffer这类不适合普通缓存的内存区域。

AVL位是专门留给软件自由使用的。操作系统可以在里面记录页面属于哪个内核子系统、是否需要写回等信息。硬件不碰这4位,软件可以安全读写。

保留位则是当时规定“必须为0”的位,比如386页表项的PS位。原版386不支持大页,如果软件把这个位写成了1,硬件会判定保留位违规,产生页错误。这个“保留位必须为0”的设计后来发展成了一种兼容性检查机制:新处理器可以填充保留位来表达新特性,旧软件因为不会构造这些位而安全运行,新特性又不会被旧软件误触发。

4. 转换很贵,于是有了TLB;表很大,于是有了更大的页

4.1 没有TLB的情况下,一次普通内存读要付出3倍代价

按前面的查表流程,一次普通内存读,CPU先查页目录、再查页表,最后才访问目标物理地址。也就是说,如果没有缓存机制,一次读要经历三次内存访问:两次是页表结构,一次是真实数据。顺序执行的话,性能直接降到三分之一,这是不可接受的。

TLB就是一个专门缓存“线性地址到物理地址”转换结果的小型快速表。它缓存的不再是页表项本身,而是已经算好的映射关系。命中TLB时,CPU直接拿到物理页框号,拼上页内偏移就能访问,不用再去内存里读页目录和页表。

TLB容量都不大,386上的TLB只有几十个条目,大约32项左右。但局部性原理保证了大多数程序重复使用的页面就那么几个,32项覆盖128KB的连续映射已经能支撑相当一部分工作负载。真正的麻烦不在顺序访问,而在工作集过大或者频繁切换进程。

4.2 32个TLB条目与失配问题:为什么会“抖动”

TLB空间有限,当程序的工作集超过TLB能覆盖的范围,就会反复出现未命中。每次未命中都要回到页表做两级查表,刚刚替换出去的映射下一次又被访问到,于是TLB不断失效、不断重填,整体性能明显下滑。

这种抖动在数据库扫描、大数组遍历这类场景里最容易出现。顺序访问4KB页面,只要跨越几十个页面,TLB就要持续替换。后来操作系统想了不少办法,比如使用大页、调整页表布局、给关键内核数据结构做永久映射,本质上都是在增加“每个TLB条目覆盖的地址范围”。

软件还能控制的是减少无谓的TLB刷新。修改页表项之后,如果只是改动一个页面的映射,理想情况是只让对应条目失效。386没有单独的无效化指令,软件通常通过重新加载CR3来强制刷新整个TLB,代价是后续所有映射都要重新建立。这个操作在内核里属于“慢路径”,不能放在热路径上频繁执行。

4.3 4MB/2MB大页机制:把TLB覆盖范围瞬间放大

386本身只有4KB一种页面,但它的页表结构给后来的大页扩展留了接口。后来的处理器在页目录项上启用了一个新的能力:当页目录项的PS位置1时,这一项不再指向页表,而是直接指向一个物理大页。32位PSE模式下这个大页是4MB,PAE模式下变成2MB。

大页的效果非常直观:本来需要512个4KB页面才能覆盖2MB空间,现在一个TLB条目就能覆盖2MB。工作集大、随机访问多的应用,大页能把TLB未命中率降到非常低。数据库共享内存、JVM堆、大容量缓存这类场景,后来的操作系统基本都会主动映射大页。

当然大页也带来新问题:分配粒度变大,物理页凑不出连续大块时就得预留或者依靠更底层的内存回收手段;权限检查变成整块统一,页内没法再细粒度分权。工程上通常按“大页池”方式预先分配,专门供高价值区域使用,而不是所有内存都切大页。

4.4 后续CPU在386基础上的分页演进:PSE、PAE

从386往后,x86分页一直在往两个方向扩展:一个是页面更大,一个是物理地址更宽。PSE让页目录项可以直接映射4MB大页;PAE则将物理地址从32位扩展到36位,把页目录项和页表项从32位变成64位,地址转换中间多了一层页目录指针表。

这些扩展全部保持了和386两级页表向后兼容的基本骨架。老软件只要不碰新控制位,照旧可以跑;新软件启用新特性时,页表结构依然遵循“逐级索引查出物理页框号”的核心思路。理解386的页式机制,等于理解了后续所有x86分页变体的公共底座。哪怕看现代四级页表,也只是把“目录、页表”扩展成“四级、三级、二级”多层索引,位域拆解的思维完全一样。

5. 在真实操作系统里,这些机制被用到了极致

5.1 用户态/内核态隔离:高低地址划分与权限门的配合

最常见的做法是把4GB线性地址空间一分为二,低地址留给用户进程,高地址放内核映射。用户页面的U/S位置1,内核页面的U/S位清0。用户态访问高地址时,权限检查失败,立即缺页;内核态访问用户页时,权限检查通常都能通过。

这种划分使得进程切换时,内核部分映射可以保持不变,只有低地址部分换成新进程的页表。操作系统只需要为每个进程准备一个页目录,把内核相关的高位目录项在创建页表时统一复制进去,就可以保证内核映射全局一致。等到进程真正进入用户态,U/S位自然把内核页面挡住,用户程序没法直接读写内核数据。

5.2 自引用页目录:把自己的页表映射进自己的地址空间

页表本身是物理内存里的数据结构,操作系统要维护它,就得能访问到页表内容。一个经典技巧是把页目录的最后一个目录项指向页目录自身的物理页,形成一个“自引用”。这样处理器的地址转换过程就能把页表结构映射到线性地址空间里。

以最高目录项自引用为例,自映射区域通常占用线性地址空间的最高4MB窗口。使用这个技巧后:

  • 想读页目录第i项,访问0xFFFFF000 + i*4;
  • 想读第i个页表里的第j项,访问0xFFC00000 + i*4096 + j*4。

这套映射让内核可以在不开CR3、不切换页表的情况下,通过普通读写指令直接修改当前页表树。实现mmap、mprotect、写时复制这些操作时,内核只要算出对应的线性地址,改掉页表项里的页面框号或权限位就行。缺点是自引用占掉了4MB线性地址空间的一段,用户进程可用的高地址会被压缩一点,但相对它能省下的代码复杂度和性能优势,完全值得。

5.3 缺页处理的实际流程:缺页、校验、按需加载、重新执行

缺页异常不是错误,而是虚拟内存机制正常工作的入口。一次典型缺页处理流程如下:

  1. 硬件把出错线性地址写入CR2,把错误码压栈;
  2. 内核保存现场后,取出CR2和错误码;
  3. 内核在进程的虚拟内存区域表里查找这个地址,判断它是否属于合法映射;
  4. 如果不合法,发送段错误信号并终止进程;
  5. 如果合法,根据情况决定动作:分配新物理页并清零、从磁盘换入旧页、执行写时复制等等;
  6. 修改页表项,填上物理页框号和权限位;
  7. 恢复现场,重新执行触发缺页的指令。

“重放”这一步非常关键。缺页发生在指令执行中间,CPU并不知道前面做了多少工作,它只会把整个指令重新来过。所以页表更新必须保证指令重放时能看到完整、有效的映射,否则就会反复缺页。

5.4 进程切换的代价:CR3一换,TLB全清

每次切换进程,操作系统都会把新进程页目录的物理地址写入CR3。这一写,TLB里的旧映射全部失效。如果系统频繁切换进程,TLB就会不断被清空,后续用户态访问又得重新查页表。早期386性能调优时,减少不必要的进程切换和避免频繁写CR3是两条最直接的优化路径。

同进程内的用户态/内核态切换则不需要换CR3。内核映射已经固定在页表里,中断、系统调用发生时,只需改变特权级,不改变页表树根。因此系统调用本身不携带TLB清空代价,真正昂贵的是进程上下文切换。理解这一点,就不会把“系统调用很慢”简单归因于页表刷新。

6. 上手排错时容易踩的坑与后续架构的修复方向

6.1 页错误排查链路:从CR2和错误码开始逐级核对

写内核或模拟器时,页错误是常客。我的排查顺序固定成了一条链路:

  1. 先读CR2,拿到触发缺页的线性地址;
  2. 再读错误码,确认是不存在、权限、读/写、内核/用户中的哪一类;
  3. 从CR3读出当前页目录;
  4. 按线性地址的10位目录索引找到页目录项;
  5. 如果目录项存在,再按中间10位找到页表项;
  6. 最后检查页表项存在位和权限位。

大多数问题出在两处:一是缺页的线性地址根本不是预期地址,说明段转换或页表树根选错,CR3给错了;二是页表项本身存在,但权限位不满足,比如用户态访问了U/S=0的页面。这类问题靠打印CR2和一级级页表项内容,往往几分钟就能定位。最难缠的是访问了自引用区域却没弄清映射公式,线性地址算错,查来查去都指向同一个错误页表。

6.2 386没有的东西,按386写代码时会踩的坑

386之后很多指令和特性,在386上并不存在。最容易踩的坑包括:

  • 386没有单独的TLB无效化指令,修改页表后只能重载CR3整表刷新;
  • 386没有大页位,想用大页得等支持PSE的后续处理器;
  • 386没有执行权限位,代码页和数据页的保护只能靠段和读写位近似实现;
  • 386对内核写只读用户页的限制不像后来WP位开启时那么严格,写时复制实现需要格外小心。

这些“没有”不是缺陷,而是硬件演进的自然过程。写兼容老CPU的代码时,不能假设新特性一定可用,必须先探测CPU能力再启用对应控制位。反过来,在支持新特性的CPU上写386模拟器时,也得把保留位违规当成正常情况处理,否则老软件会遇到预期之外的行为。

6.3 页式内存管理机制的遗产:今天的x86-64仍在沿用骨架

386的页式内存管理,奠定了x86平台虚拟内存最基本的模型:线性地址经多级页表转换、TLB缓存映射结果、权限位控制访问等级、缺页机制驱动按需分配、CR3作为地址空间标识。今天的x86-64虽然用四级甚至五级页表扩展了地址宽度,加入了NX、全局页、PCID等新能力,但只要把386这套模型弄清,看新一代分页方案依然只是位数的变化和层数的增加,核心思想并没有变。

学习386分页最大的价值,不是去拿那些旧参数写代码,而是获得一套适合理解所有分页系统的思维框架。拿到任何一款新处理器,第一件事看它的线性地址怎么拆、页表有几级、每级表项里哪些位是硬件维护、哪些是软件自由定义,整个系统的内存管理结构就能迅速建立起来。这些年我做各种底层调试,靠的仍然是这套从i386页式机制里长出来的底层语感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询