3. 页表详解:页表项(PTE)结构、页表层级(多级页表)、TLB(快表)原理
好,咱们接着聊内存管理里最核心的一块——页表。
说实话,我早年刚接触嵌入式Linux的时候,觉得页表这东西就是个地址翻译器,没啥大不了的。直到有一次调试一个多媒体播放器的内存问题,发现系统动不动就卡顿,查来查去,最后定位到是TLB miss太频繁了。嗯,从那以后,我再也不敢小看页表的设计了。
3.1 页表项(PTE)结构
页表项,英文叫Page Table Entry,简称PTE。说白了,它就是页表里的一行记录,告诉MMU:这个虚拟页面对应哪个物理页面,以及这个页面有什么权限。
我习惯把PTE想象成一个“门牌号+门禁卡”的组合。门牌号告诉你物理地址在哪,门禁卡告诉你谁能进、能干啥。
一个典型的ARMv8-A架构下的PTE,64位宽,结构大致如下:
63 48 47 12 11 10 9 8 7 6 5 4 3 2 1 0
+-------------+------------+---+---+---+---+---+---+---+---+---+---+---+---+
| RESERVED | 物理页帧号 | NG| AF| SH| AP| NS| XN| PXN| UXN| D | A | F |
+-------------+------------+---+---+---+---+---+---+---+---+---+---+---+---+
各位看官,别被这串二进制吓到。咱们挑几个关键的说说:
| 字段 | 位宽 | 含义 | 我的经验 |
|---|---|---|---|
| 物理页帧号(PFN) | 36位 | 物理地址的高36位,指向4KB页面的起始地址 | 这个字段决定了你能访问多大的物理内存。36位对应64GB,够用了吧? |
| AP(Access Permission) | 2位 | 访问权限:00=EL3可读写,01=EL1/EL2可读写... | 我踩过坑:AP设错了,用户态程序直接访问内核空间,系统秒崩 |
| XN(Execute Never) | 1位 | 禁止执行代码 | 这是防止代码注入攻击的关键,我建议所有数据页都置1 |
| AF(Access Flag) | 1位 | 访问标志,硬件自动置1表示该页被访问过 | Linux内核用这个做页面回收的参考,挺巧妙的 |
| D(Dirty) | 1位 | 脏页标志,页面被写过 | 写回磁盘时就看这个位,省得把没改过的页也写回去 |
重点提醒:PTE里的权限位不是摆设。我见过不少驱动开发者图省事,直接把权限设成“可读可写可执行”,结果被安全审计骂得狗血淋头。记住:最小权限原则,能不给的权限就别给。
3.2 页表层级(多级页表)
你想想看,如果只有一个页表,那得有多大?
以32位系统为例,虚拟地址空间4GB,每页4KB,就需要2^20个PTE。每个PTE 4字节,光一级页表就要4MB。每个进程一个页表,10个进程就是40MB。这在嵌入式系统里简直是灾难。
所以,多级页表应运而生。说白了,就是分层次查表,像查字典一样——先查部首,再查笔画,最后找到具体字。
ARMv8-A支持4级页表,层级关系如下:
虚拟地址: [63:48] [47:39] [38:30] [29:21] [20:12] [11:0]
| | | | | |
v v v v v v
L0索引 L1索引 L2索引 L3索引 页内偏移
| | | |
v v v v
L0表 -> L1表 -> L2表 -> L3表 -> 物理页面
我画个图,帮你理清这个流程:
多级页表的好处很明显:
- 节省内存:大部分L1/L2表项是空的,不需要分配物理内存。只有真正用到的虚拟地址范围才分配页表。
- 支持大页:如果L2表项直接指向一个2MB的块,那就不需要L3表了。这就是所谓的“大页”或“巨页”。
- 进程切换快:切换进程只需要换掉根页表指针(TTBR0_EL1),不用重建整个页表。
我的小技巧:在嵌入式系统里,如果内存紧张,可以考虑使用2MB或1GB的大页。我曾在某个视频编解码项目里用大页,TLB miss减少了30%,性能提升很明显。
3.3 TLB(快表)原理
好了,页表结构讲完了。但你想想看,每次访问内存都要查4级页表,那得慢成什么样?
每次查页表就是一次内存访问,4级页表就是4次内存访问。再加上真正的数据访问,总共5次内存访问。这性能谁受得了?
所以,硬件工程师们想了个办法——TLB(Translation Lookaside Buffer),翻译过来叫“快表”或“转译后备缓冲器”。
TLB说白了就是页表的硬件缓存。它把最近用过的虚拟地址到物理地址的映射关系存起来。下次再访问同一个虚拟地址,直接查TLB,不用再走页表了。
TLB的工作原理可以用一个简单的流程图表示:
TLB命中率通常能达到99%以上。为什么?因为程序有两大特性:
- 时间局部性:刚访问过的地址,很快还会再访问。比如循环里的变量。
- 空间局部性:刚访问过的地址附近的地址,很快也会被访问。比如数组遍历。
注意:TLB不是万能的。我曾经在一个网络数据包处理项目里,数据流是随机的,TLB命中率掉到80%,性能直接腰斩。后来用了大页映射,才把命中率提上来。
TLB还有个坑——TLB shootdown。在多核系统里,如果一个核修改了页表,其他核的TLB里还存着旧映射。这时候就需要发IPI中断通知其他核刷新TLB。这个过程叫TLB shootdown。
我建议:如果频繁修改页表(比如动态内存映射),尽量批量操作,减少TLB shootdown的次数。否则性能会很难看。
3.4 页表与TLB的协同工作
最后,咱们把页表和TLB串起来看。整个地址翻译流程是这样的:
- CPU发出虚拟地址
- MMU先查TLB
- 如果TLB命中,直接得到物理地址,跳到第6步
- 如果TLB未命中,MMU遍历多级页表,找到物理地址
- 把新的映射关系写入TLB(硬件自动完成)
- 用物理地址访问内存
整个过程对软件是透明的。但作为驱动开发者,你得知道什么时候该操心:
- 修改页表后,记得刷TLB(ARM上用DSB+ISB指令)
- 进程切换时,TLB自动失效(因为ASID不同)
- 使用DMA时,注意TLB和Cache的一致性
总结一下:页表是地址翻译的“数据库”,TLB是“缓存”。数据库再大,也得靠缓存提速。理解这两者的配合,你就能写出高效的内存管理代码。
好了,页表这块就聊到这儿。记住:多级页表省内存,TLB提速度,PTE管权限。这三样东西,是内存管理的基石。