2. 页表与地址转换:页表结构,四级页表(PGD/PUD/PMD/PTE),TLB缓存

大家好,我是你们的Linux内核讲师。今天我们来聊聊内存管理里最核心、也最绕的一个话题——页表与地址转换。

说实话,我刚入行那会儿,看到“四级页表”这四个字就头大。什么PGD、PUD、PMD、PTE,感觉像是一堆缩写堆在一起。但后来我在一个嵌入式项目里,因为页表配置错了,导致系统跑起来后随机崩溃,查了整整三天才定位到问题。从那以后,我就把页表这块啃得透透的。

嗯,咱们今天就把这块硬骨头啃下来。

2.1 为什么需要页表?

先问个问题:CPU发出的虚拟地址,怎么变成物理地址?

你想想看,每个进程都以为自己拥有4GB的连续内存空间(32位系统),但实际上物理内存可能只有512MB。这中间的“骗局”,就是靠页表来实现的。

页表说白了就是一张映射表。它把虚拟页号映射到物理页框号。每个进程都有自己的页表,所以进程A看到的地址0x1000,和进程B看到的地址0x1000,实际上指向的是不同的物理内存。

我在项目中遇到过一个问题:两个进程共享内存时,它们的页表项会指向同一个物理页框。这个机制在实现进程间通信时特别有用。

2.2 四级页表结构

早期的Linux用的是两级页表,后来为了支持64位系统的大地址空间,演变成了四级页表。现在ARM64和x86_64上都是四级。

四级页表的结构是这样的:

虚拟地址分解(以4KB页大小为例):
| 63 ... 48 | 47 ... 39 | 38 ... 30 | 29 ... 21 | 20 ... 12 | 11 ... 0 |
|  未使用   |   PGD索引 |  PUD索引  |  PMD索引  |  PTE索引  |  页内偏移 |

每一级索引都指向下一级页表,最后一级PTE指向物理页框。我习惯把这个过程叫做“剥洋葱”——一层层剥下去,最后找到真正的物理地址。

2.2.1 PGD(Page Global Directory)

PGD是页表的根。每个进程的task_struct里都有一个mm_struct,里面存着pgd_t *pgd指针。这个指针指向的就是进程的PGD基地址。

当CPU要访问一个虚拟地址时,先从CR3寄存器(x86)或者TTBR0_EL1(ARM64)里拿到PGD的基地址,然后用虚拟地址的PGD索引去查表。

关键点:每次进程切换时,内核都会把新进程的PGD基地址加载到页表基址寄存器里。这就是为什么不同进程的地址空间是隔离的。

2.2.2 PUD(Page Upper Directory)

PUD是第二级。在32位系统上,PUD其实被“折叠”掉了,直接由PGD跳到PMD。但在64位系统上,PUD是实实在在存在的一级。

我记得有一次调试一个KVM虚拟化的问题,发现客户机的PUD表项被错误地标记为可缓存,导致虚拟机里的DMA操作出了问题。嗯,这种问题不查个两天根本找不到原因。

2.2.3 PMD(Page Middle Directory)

PMD是第三级。它管理的内存范围比PUD小,比PTE大。在实现大页(HugePage)时,PMD可以直接指向一个2MB的物理块,跳过PTE这一级。

我曾经在项目中用大页来优化数据库的性能。把PMD直接映射到2MB的连续物理内存,TLB的命中率一下子就上去了。效果立竿见影。

2.2.4 PTE(Page Table Entry)

PTE是最后一级。每个PTE对应一个4KB的物理页框。PTE里不光有物理地址,还有各种权限位:

位域含义
Bit 0 (Present)页是否在内存中
Bit 1 (RW)可读/可写
Bit 2 (User)用户态是否可访问
Bit 3 (PWT)页级写透缓存
Bit 4 (PCD)页级缓存禁用
Bit 5 (Accessed)页是否被访问过
Bit 6 (Dirty)页是否被写过

注意:PTE的Present位如果为0,说明该页不在物理内存中。这时候CPU会触发缺页异常(Page Fault),内核需要从磁盘的swap分区把数据换进来。我曾经在内存压力测试时,因为swap分区配置太小,导致系统频繁缺页,性能直接掉到谷底。

2.3 地址转换过程

好了,现在我们把整个流程串起来。假设CPU要访问虚拟地址0x7f1234567890

  1. 从CR3寄存器拿到PGD基地址
  2. 提取虚拟地址的PGD索引(bit 47:39),在PGD表中找到对应的PUD表地址
  3. 提取PUD索引(bit 38:30),在PUD表中找到对应的PMD表地址
  4. 提取PMD索引(bit 29:21),在PMD表中找到对应的PTE表地址
  5. 提取PTE索引(bit 20:12),在PTE表中找到物理页框号
  6. 物理页框号 + 页内偏移(bit 11:0)= 最终的物理地址

这个过程在硬件上是由MMU(内存管理单元)自动完成的。软件只需要维护好页表结构就行。

我建议你在写驱动时,如果涉及到用户空间的内存访问,一定要搞清楚当前进程的页表状态。用copy_from_usercopy_to_user这些内核API,它们会自动处理页表相关的检查。

2.4 TLB缓存

每次地址转换都要查四级页表,这得访问好几次内存。如果每次都这么干,性能就太差了。

所以硬件上搞了个TLB(Translation Lookaside Buffer)。它是个高速缓存,专门存最近用过的虚拟地址到物理地址的映射关系。

TLB的工作原理很简单:

  • CPU发出虚拟地址后,先查TLB
  • 如果TLB命中(Hit),直接拿到物理地址,不用查页表
  • 如果TLB未命中(Miss),才去查页表,然后把结果缓存到TLB里

TLB的命中率对性能影响极大。我做过一个测试:在同一个系统上,TLB命中率从99%降到95%,内存访问延迟就增加了将近一倍。

小技巧:在写驱动时,如果你要频繁访问同一块物理内存,可以考虑用ioremapkmap建立持久映射。这样TLB会缓存这个映射,后续访问就快多了。

2.4.1 TLB刷新

当页表内容发生变化时,TLB里的缓存就失效了。这时候需要刷新TLB。

在Linux内核里,刷新TLB的函数有:

// 刷新单个虚拟地址的TLB
void flush_tlb_page(struct vm_area_struct *vma, unsigned long addr);

// 刷新整个进程的TLB
void flush_tlb_mm(struct mm_struct *mm);

// 刷新所有TLB(所有进程)
void flush_tlb_all(void);

我曾经在写一个内核模块时,修改了页表后忘了刷新TLB,结果程序跑着跑着就访问到了旧的物理地址。数据全乱了。从那以后,我每次修改页表都会在注释里写上“别忘了flush TLB”。

2.4.2 TLB的局限性

TLB虽然快,但容量有限。一般的CPU只有几十到几百个TLB条目。如果程序访问的内存范围太大,TLB就会频繁失效,出现“抖动”现象。

解决这个问题的方法有两个:

  • 使用大页(HugePage):一个TLB条目可以映射2MB甚至1GB的内存
  • 优化数据局部性:让程序尽量访问连续的内存地址

我建议在做性能优化时,先用perf stat看看TLB的缺失率。如果缺失率超过1%,就该考虑用大页或者优化内存访问模式了。

2.5 实战经验总结

最后,我把自己在项目中踩过的坑总结一下:

  • 页表同步问题:多核系统上,一个核修改了页表,其他核的TLB不会自动失效。需要用IPI(核间中断)通知所有核刷新TLB。
  • 页表分配失败:在内存紧张时,分配新的页表页可能会失败。内核里用pte_allocpmd_alloc这些函数时,一定要检查返回值。
  • 权限位设置:给用户空间映射内存时,别忘了设置User位。否则用户态程序一访问就触发段错误。

页表这块内容确实有点抽象,但它是理解Linux内存管理的基石。你想想看,整个操作系统的内存隔离、共享内存、内存映射文件、虚拟内存交换,全都是建立在页表机制之上的。

下一节课,我们会讲缺页异常的处理流程。到时候你会看到,页表里的Present位为0时,内核是怎么把数据从磁盘换进来的。

好,今天就到这里。有什么问题欢迎在课程群里讨论。