分页机制详解:页表结构、页框、页内偏移、多级页表
分页机制,说白了就是操作系统给进程发的一张「虚拟地址 → 物理地址」的翻译地图。我做了这么多年内核,见过太多因为分页没搞明白导致系统崩溃的案例。今天咱们就把这张地图彻底拆开看看。
一、页框与页:两个世界的基本单位
虚拟内存被切成固定大小的块,叫「页」(Page)。物理内存也被切成同样大小的块,叫「页框」(Page Frame)。
为什么非要切成一样大?你想想看,如果虚拟页是4KB,物理页框也是4KB,那映射关系就简单了——一个萝卜一个坑。我在项目中遇到过有人试图用不同大小的页和页框,结果地址翻译逻辑写得跟蜘蛛网一样乱,最后不得不推倒重来。
核心对应关系:
- 虚拟地址空间 → 分成若干「页」
- 物理地址空间 → 分成若干「页框」
- 页的大小 = 页框的大小(通常4KB)
- 页框是物理内存分配的最小单位
嗯,这里要注意:页框是物理内存的「砖块」,操作系统按需把砖块分配给各个进程。进程以为自己拥有连续的虚拟地址空间,其实背后的物理页框可能散落在内存的各个角落。
二、页内偏移:地址里的「门牌号」
一个虚拟地址怎么拆?我习惯把它看成两部分:页号 + 页内偏移。
假设页大小是4KB(2¹²字节),那么地址的低12位就是页内偏移。高20位是虚拟页号。举个例子:
虚拟地址:0x12345678
页大小:4KB(0x1000)
页号 = 0x12345(高20位)
偏移 = 0x678(低12位)
为什么偏移量这么重要?因为CPU拿到虚拟地址后,先查页表找到页框号,然后直接把偏移量拼接到页框的起始地址后面。整个过程是硬件完成的,快得离谱。
我的经验:调试内存问题时,我经常手动计算页内偏移。有一次发现一个野指针访问,偏移量是0xFFF——刚好踩在页的最后一个字节上。这种边界问题最容易出bug。
三、页表结构:翻译官的工作手册
页表就是一张表,每一行记录一个虚拟页对应的物理页框号。但页表本身也放在内存里,CPU通过一个叫「页表基址寄存器」的东西找到它。
每个页表项(PTE)里存什么?我列一下关键字段:
| 字段 | 位数 | 说明 |
|---|---|---|
| 页框号 | 20位 | 物理页框的起始地址高位 |
| 存在位 | 1位 | 该页是否在物理内存中 |
| 读写位 | 1位 | 是否可写 |
| 用户位 | 1位 | 用户态能否访问 |
| 脏位 | 1位 | 该页是否被写过 |
| 访问位 | 1位 | 该页是否被访问过 |
我曾经调试过一个性能问题,发现某个进程的页表项频繁被换入换出。查了半天,原来是存在位没设置好,导致每次访问都触发缺页中断。嗯,这种低级错误在代码审查时很容易漏掉。
四、多级页表:为什么需要它?
32位系统下,虚拟地址空间是4GB。如果页大小4KB,那页表就有2²⁰ = 1,048,576项。每个页表项4字节,光一张页表就要4MB内存。
你想想看,每个进程都要一张4MB的页表,100个进程就是400MB。而且大多数进程只用了地址空间的一小部分,大部分页表项都是空的——这太浪费了。
多级页表就是为了解决这个问题。我以二级页表为例:
虚拟地址 = 页目录索引(10位) + 页表索引(10位) + 页内偏移(12位)
第一步:用页目录索引查「页目录表」,找到对应的页表
第二步:用页表索引查「页表」,找到物理页框号
第三步:拼接页框号和偏移量,得到物理地址
多级页表的精髓:页目录表只有1024项(4KB),只占一页。如果某个页目录项为空,对应的整个页表都不用分配。这样,进程只为自己真正用到的虚拟地址范围分配页表,内存占用大幅降低。
我记得在x86架构上,二级页表的页目录索引和页表索引各占10位。后来64位系统来了,地址空间大到离谱,二级不够用了,就变成了四级页表。每一级都只存下一级的索引,像俄罗斯套娃一样层层嵌套。
五、多级页表的代价与优化
多级页表省了内存,但多了查表次数。二级页表要查两次内存,四级页表要查四次。每次查表都是内存访问,性能损失不小。
硬件怎么解决这个问题?TLB(快表)登场了。TLB是CPU内部的一个小缓存,专门存最近用过的页表项。我见过一个极端案例:某个程序频繁切换进程,每次切换都刷新TLB,结果性能直接腰斩。后来我们改用了进程绑核,TLB命中率才上来。
避坑指南:我曾经在嵌入式系统上做过一个项目,为了省内存用了三级页表。结果没注意TLB的容量,频繁的页表遍历导致系统响应时间超标。后来我学乖了——设计之初就要算清楚页表层级和TLB命中率的平衡点。
六、实战:手动模拟一次地址翻译
光说不练假把式。咱们手动走一遍二级页表的翻译过程:
假设:
- 页大小:4KB(偏移12位)
- 页目录索引:10位
- 页表索引:10位
虚拟地址:0x12345678
二进制:0001 0010 0011 0100 0101 0110 0111 1000
拆解:
- 页目录索引(高10位):0001 0010 00 → 0x48
- 页表索引(中间10位):11 0100 0101 → 0x345
- 页内偏移(低12位):0110 0111 1000 → 0x678
翻译步骤:
1. CPU从页目录基址寄存器拿到页目录表地址
2. 用索引0x48找到对应的页目录项
3. 页目录项指向一个页表
4. 用索引0x345找到页表中的页表项
5. 页表项里存着物理页框号,假设是0xABC
6. 物理地址 = 0xABC000 + 0x678 = 0xABC678
你看,整个过程就是查表、拼接,没什么玄乎的。但实际系统中,这一步每秒要执行几十亿次,所以硬件必须用流水线和TLB来加速。
七、总结一下
分页机制的核心就三件事:页框是物理单位,页是虚拟单位,页表是翻译工具。多级页表用空间换时间,TLB用时间换空间——嗯,计算机系统里到处都是这种trade-off。
我个人觉得,理解分页最好的方法就是动手算一次地址翻译。下次你遇到段错误或者内存泄漏,不妨先看看是不是页表出了问题。我曾经花了一周时间追一个bug,最后发现是页表项里的权限位设错了——这种坑,踩过一次就记住了。