RISC-V Sv39页表深度解析:从MMU硬件机制到Linux地址空间实现
2026/9/7 14:09:53 网站建设 项目流程

如果你亲手在RISC-V平台上开过MMU,大概率经历过这种场面:裸机程序关着MMU一切正常,往SATP寄存器写入精心构造的页表基址后,程序直接跑飞,PC跳到某个匪夷所思的地址。我第一次在调试器里看到这种画面时,第一反应是编译器出了问题,第二反应是硬件时序不稳,排查了两天才发现,罪魁祸首只是某个页表项的RWX权限位组合不合法,硬件在逐级遍历页表时当场抛了一个page fault。

RISC-V的MMU就是这样一套看起来直白、实际处处是细节的硬件机制。Sv39页表格式、地址翻译流程、TLB刷新策略、异常入口处理,每一层都有专门的坑等着你。而Linux内核作为RISC-V平台上最典型的OS客户,又把这套机制用到了极致:进程地址空间划分、vm_area_struct管理、缺页异常、ASID分配,全部建立在Sv39这棵三级页表树之上。这篇文章想做的,就是把"Sv39页表格式"和"Linux地址空间"这条线完整串起来——从硬件手册里的位定义,一路讲到你在QEMU上真正看到的页表项。适合正在做RISC-V裸机/OS开发、芯片验证,或者想深入理解Linux虚拟内存底层实现的读者。

1. 为什么是Sv39:三级页表的设计逻辑与地址拆分

1.1 Sv39到底解决了什么问题

RISC-V的特权规范里,地址翻译模式从最简单的Bare(不翻译)一路排到Sv39、Sv48、Sv57。Sv39的意思很直白:虚拟地址有效位是39位,处理器把每一个虚拟地址都当成39位来切分和翻译。为什么不是32位也不是64位?两个考量:一是39位能覆盖512GB地址空间,对绝大多数嵌入式场景和通用操作系统已经足够;二是RISC-V采用了一种非常规整的多级页表结构,每级页表占用一个4KB物理页,每级索引恰好9位,配合12位页内偏移,三级刚好凑出39位虚拟地址。

很多人第一次看Sv39会问:为什么不直接做一张大页表?一张覆盖512GB空间的页表,如果每4KB一个页表项,需要1.34亿个条目,每个8字节,总共超过1GB内存——这显然不能接受。三级页表的本质是用"按需分配"换"空间覆盖":顶层页表只有512个条目,固定占4KB。进程实际用到的地址区域越多,才逐渐分配第二级、第三级页表页。大多数进程的地址空间其实是稀疏的,三级结构能把页表内存压到几十KB级别。

1.2 虚拟地址如何拆成三段索引

Sv39的虚拟地址从高到低可以拆成这样:

63 39 38 30 29 21 20 12 11 0 +----------------+---------+---------+---------+-----------+ | 符号扩展(全1) | VPN[2] | VPN[1] | VPN[0] | offset | +----------------+---------+---------+---------+-----------+ 9位 9位 9位 9位 12位

低12位是页内偏移,和架构无关;往上的27位分成三个9位索引,分别记为VPN[2]、VPN[1]、VPN[0]。9位索引能索引512个页表项,每个页表项8字节,所以每一级页表恰好512 * 8 = 4096字节,正好一页。这是一个很关键的"对齐强迫症"设计:页表本身占用一个物理页,页表分配器不需要考虑跨页拆分,地址转页号只需要一个右移。

用一个具体例子走一遍。假设虚拟地址是0x0000000040000123

  • offset = 0x123
  • VPN[0] = (addr >> 12) & 0x1ff = 0,落在第三级页表的第0项
  • VPN[1] = (addr >> 21) & 0x1ff = 0,落在第二级页表的第0项
  • VPN[2] = (addr >> 30) & 0x1ff = 1,落在顶层页表的第1项

也就是说,这个地址位于整个地址空间中"第1个1GB区域"的起始位置附近。VPN[2]=1意味着顶层页表第1项指向第二级页表,VPN[1]=0意味着第二级页表第0项指向第三级页表,VPN[0]=0意味着第三级页表第0项才是最终描述这个4KB物理页的条目。这个拆分逻辑会贯穿整篇文章,后面Linux地址空间的很多现象都源于这一套三段索引。

2. Sv39页表项拆解:PTE里每个bit都不是多余的

2.1 PTE的位布局与权限组合

RISC-V Sv39的页表项(PTE)一共64位,格式非常紧凑:

位段名称含义
bit 0V有效位,0表示该条目未使用
bit 1R可读
bit 2W可写
bit 3X可执行
bit 4U用户态可访问
bit 5G全局映射,TLB中带G标记的条目忽略ASID匹配
bit 6AAccessed,已被访问过
bit 7DDirty,已被写入过
bit 8-9RSW留给操作系统软件使用
bit 10-53PPN物理页号,共44位
bit 54-63保留必须为0

最有意思的是R/W/X三位的语义。RISC-V规定:当R、W、X全部为0时,这个PTE不是叶子页表项,而是一个指向下一级页表的指针;只要R或X至少有一个为1,它就是叶子页表项,直接描述物理页映射。换句话说,硬件靠这一组权限位判断"该继续往下走还是直接出结果",不需要额外的类型标志。

这里有个新手容易踩的坑:W=1而R=0是非法组合。规范要求W和R必须同时有效,因为RISC-V的设计逻辑是"能写必然能读"。如果代码里不小心写出了这种组合,硬件在walk到该条目时会直接判定为非法PTE,触发page fault,而且异常原因不会明确告诉你是"权限位非法",排查起来非常难受。

2.2 叶子PTE如何拼出物理地址

当PTE是叶子时,物理地址的拼接规则很简单:

物理地址 = PTE.PPN << 12 | 虚拟地址的offset

例如PTE.PPN =0x00000084321,当前访问的虚拟地址低12位是0x123,那么物理地址就是0x84321000 | 0x123 = 0x84321123。PPN字段44位,加上12位偏移,共56位物理地址空间,这是Sv39模式支持的物理地址上限。

大页的情况要额外注意。Sv39支持三种页面粒度:

  • 第三级PTE是叶子:4KB页,PPN 44位全用来表示物理页号
  • 第二级PTE是叶子:2MB大页,此时页内偏移是21位,PPN低9位应当视为0
  • 第一级PTE是叶子:1GB大页,此时页内偏移是30位,PPN低18位应当视为0

大页在Linux里对应HugeTLB和透明大页(THP),核心收益是减少TLB占用。TLB条目数量是固定的硬件资源,用2MB大页映射一段1GB内存只需要512个TLB条目,而用4KB小页需要262144个条目——这就是为什么数据库这类大内存应用对大页如此执着。但代价是灵活性下降,1GB大页意味着这一整块区域必须是连续物理内存,碎片化严重时分配会失败。

2.3 A/D位与硬件的"贴心"行为

PTE中的A位和D位是硬件自动维护的。当CPU访问一个PTE时,如果A位为0,硬件会在完成访问的同时把A位置1;如果是写访问且D位为0,硬件也会置位D位。这意味着软件不需要在每次缺页时都去做"标记已访问"这件事,Linux内核可以直接利用A/D位实现页回收算法:通过定期检查A位判断页面最近是否被使用,通过D位判断是否脏页、是否需要写回磁盘。

RSW位是留给软件的,Linux在RISC-V上会利用RSW位保存自己的一些软件状态,比如_PAGE_SPECIAL等标志。这些细节在读内核代码时容易迷惑,看到PTE中某些bit在Linux里含义和RISC-V手册不完全一致,多半是软件复用RSW的结果。

3. 硬件怎么走这三趟内存:MMU翻译流程与TLB行为

3.1 SATP寄存器:MMU的开关与页表根

SATP是S-mode下的CSR,控制着整个地址翻译。64位模式下它的格式是:

63 60 59 44 43 0 +----------+------------------+------------------------+ | MODE | ASID | PPN | +----------+------------------+------------------------+ 4位 16位 44位

MODE字段是翻译模式开关:0表示Bare(不翻译,虚拟地址直接当作物理地址用),8表示Sv39,9表示Sv48,10表示Sv57。ASID是地址空间标识符,后面讲Linux进程切换时会用到。PPN是根页表(顶层页表)的物理页号,注意必须是物理地址的页号,不是虚拟地址。

写SATP等于一键切换整个地址空间。关闭MMU时你读写的是物理地址;写入Sv39对应的MODE值后,CPU接下来的每一次取指、每一次load/store都会经过页表翻译。这也解释了为什么开MMU这个动作必须非常小心:如果页表还没构建好,或者SATP.PPN指向一个错误的物理页,CPU立刻进入地狱模式,取指都可能异常。

3.2 三级walk的完整流程

当CPU收到一个虚拟地址时,MMU的硬件遍历过程大致是这样:

  1. 检查虚拟地址的符号扩展:63:39位必须全部等于bit38。如果bit38是0,高27位必须全0;如果bit38是1,高27位必须全1。不满足则不算合法Sv39虚拟地址,抛出access fault。
  2. 从SATP.PPN得到根页表物理地址,加上VPN[2] * 8得到顶层PTE。
  3. 检查顶层PTE的V位。若V=0,page fault;若该PTE是叶子(R或X为1),说明用了1GB大页,直接拼物理地址;否则继续。
  4. 从顶层PTE的PPN得到第二级页表物理地址,加上VPN[1] * 8得到第二级PTE。
  5. 检查第二级PTE。若V=0,page fault;若为叶子,说明用了2MB大页;否则继续。
  6. 从第二级PTE的PPN得到第三级页表物理地址,加上VPN[0] * 8得到第三级PTE。
  7. 第三级PTE必须是叶子,检查权限位、A/D位,拼出最终物理地址。

这个过程叫页表遍历(page table walk)。它由硬件自动完成,不需要软件干预。每次walk最多会访问三次物理内存(三级页表),这也是为什么TLB的性能如此重要——没有TLB的话,每次内存访问都伴随额外三次内存读取,性能会不可接受。

3.3 TLB缓存与SFENCE.VMA刷新的正确姿势

TLB(Translation Lookaside Buffer)是MMU内部的翻译缓存,把虚拟页号直接映射到物理页号。CPU访问一个地址时,先查TLB,命中就直接得到物理地址,不命中才去走三级walk。对于频繁访问的页面,TLB命中率通常在99%以上。

问题在于:软件修改页表之后,TLB里可能还留着旧的翻译结果。这时候必须显式刷新TLB,RISC-V提供的指令是SFENCE.VMA。这是一个非常容易踩坑的点——很多人第一次写OS,更新了页表之后发现访问的还是旧数据,就是忘了刷TLB。

SFENCE.VMA的用法有几种:

sfence.vma # 刷新全部TLB条目 sfence.vma t0 # 刷新虚拟地址t0对应的条目 sfence.vma t0, t1 # 刷新ASID为t1、虚拟地址t0对应的条目

比较大的坑在于多核场景。SFENCE.VMA只刷新当前hart的TLB,其他核的TLB不会受影响。RISC-V没有硬件TLB shootdown机制,操作系统需要软件通过IPI(核间中断)通知其他核执行SFENCE.VMA。Linux的flush_tlb_mmflush_tlb_page等接口在SMP下都会发起IPI,这也是多核OS在频繁munmap/mprotect时性能会下降的原因之一。

另外,如果修改的是可执行页的PTE,还要注意指令缓存一致性。RISC-V规范要求:修改可执行页映射后,除了SFENCE.VMA,还需要FENCE.I来保证指令缓存不会命中旧指令。这一步在写JIT、动态加载器或自修改代码时必须做,否则CPU可能执行到旧代码。

3.4 Page Fault与Access Fault:两种异常别搞混

MMU相关异常在scause寄存器里有不同的编码,调试时第一件事就是看scause:

scause值类型
1Instruction access fault
5Load access fault
7Store/AMO access fault
12Instruction page fault
13Load page fault
15Store/AMO page fault

Page fault表示页表遍历过程失败:PTE的V位为0、权限不足、非法PTE组合、TLB缺失后页表没有有效条目。Access fault则通常是物理地址层面的问题:虚拟地址符号扩展错误、walk出来的物理地址不满足PMP策略、访问了不存在的物理内存。实战中有一类经典误区:程序跳到一个符号扩展错误的高地址时,scause往往不是13而是5,很多人对着页表查半天,其实问题根本不在页表里,而是地址本身不合法。

无论哪种异常,stval寄存器都会记录触发异常的虚拟地址,这是无比重要的线索。写第一个trap handler时,一定要把scause、stval、sepc、sATP全部打印出来,这四个值基本能定位90%的MMU问题。

4. Linux怎么用这套硬件:从进程地址空间到pgd

4.1 Linux地址空间在Sv39下的切分

Linux在RISC-V 64位下一般使用Sv39作为默认翻译模式。整个虚拟地址空间被分成两个半区:低半区给用户态,高半区给内核态。

以常见配置为例,用户空间从0x0000000000000000开始,向上增长;内核空间从PAGE_OFFSET开始。RISC-V Linux常见的PAGE_OFFSET0xffffffff00000000,内核代码链接地址是0xffffffff80000000。乍一看这两个地址关系不大,实际上非常精巧:QEMU virt平台上物理内存通常从0x80000000开始,那么物理地址0x80000000加上PAGE_OFFSET就得到虚拟地址0xffffffff80000000——正好是内核链接地址。内核的线性映射区就是这么简单:虚拟地址 = 物理地址 + PAGE_OFFSET

这解释了一个很有意思的现象:Sv39的合法虚拟地址范围分成两半,低半区(bit38=0)和高半区(bit38=1)。用户态程序千变万化的地址,最高也就是0x0000007fffffffff;而内核随便一个全局变量的地址都是0xffffffff开头。两者永远不可能混淆,因为硬件层面的符号扩展规则已经把它们分得清清楚楚。

4.2 mm_struct、vm_area_struct与页表树的关系

Linux里每个进程有一个mm_struct,其中pgd字段指向该进程顶级页表的物理地址。每次进程切换时,内核把新进程的pgd写入SATP.PPN,这就完成了地址空间的切换。

vm_area_struct描述进程地址空间中的一段连续区间,比如堆、栈、mmap区域。这里有个极其重要的认知:mmap分配虚拟地址时,内核只创建一个vm_area_struct并插入红黑树,根本不会分配物理页、不会填充页表项。真正的物理内存分配发生在首次访问该地址时,CPU触发page fault,内核在缺页异常处理里去分配物理页并填充PTE。这就是所谓的内存惰性分配(demand paging)。

从Sv39角度看,Linux的vm_area_struct是"虚拟地址空间的顶层视图",页表是"底层的硬件映射视图",两者通过缺页异常联系。写一个大型数组然后memset,观察系统内存占用会逐步上升——因为memset逐页触发缺页,页表项逐页被填充。这就是mmap惰性分配最直观的演示。

4.3 ASID与进程切换:TLB如何高效复用

如果不使用ASID,每次进程切换都必须全量刷TLB,否则A进程的地址翻译会被B进程错误命中。全量刷TLB的代价在大型应用上非常明显。RISC-V为此在SATP里提供了16位ASID字段:

  • 每个进程被分配一个唯一的ASID
  • TLB条目在缓存翻译结果时,会连同ASID一起保存
  • CPU在查TLB时,要求条目中的ASID与当前SATP.ASID一致才命中,除非该条目带有G(Global)标记

内核映射通常设置G位,因为所有进程共享同一份内核页表。用户态映射则按ASID隔离。这样进程切换时,只要新进程的ASID和TLB中已有的旧进程ASID不同,硬件不会误命中,TLB里旧进程的条目还可以继续保留。ASID空间耗尽时,Linux才需要"周转"ASID,强制刷掉所有进程的TLB。

RISC-V Linux的ASID分配器有一套完整的分配-回收策略,涉及上下文的世代计数等机制。如果你想在自己写的OS里偷懒,不做ASID直接每次切换进程全量SFENCE.VMA,也能正确运行,只是性能会难看一些。真要做性能优化,ASID是优先级很高的一环。

4.4 用pagemap反查一个用户地址对应的物理页

理解Linux地址空间和Sv39页表最好的办法,是亲手把一个用户态虚拟地址翻译成物理地址。Linux提供了/proc/self/pagemap接口,每个虚拟页面在pagemap里对应一个64位条目。其中bit63表示页面是否驻留内存,bit0-54表示物理页帧号PFN。

在QEMU的RISC-V Linux guest里,可以写一个很小的C程序:

#include <stdio.h> #include <stdint.h> #include <fcntl.h> #include <unistd.h> volatile unsigned long payload = 0x12345678; int main(void) { uint64_t va = (uint64_t)&payload; int fd = open("/proc/self/pagemap", O_RDONLY); if (fd < 0) { perror("open"); return 1; } uint64_t entry = 0; off_t pos = (va / 4096) * 8; if (pread(fd, &entry, sizeof(entry), pos) != sizeof(entry)) { perror("pread"); return 1; } if (entry & (1ULL << 63)) { uint64_t pfn = entry & ((1ULL << 55) - 1); printf("va=0x%lx pfn=0x%lx phys=0x%lx\n", va, pfn, pfn * 4096 + (va & 0xfff)); } else { printf("va=0x%lx not present\n", va); } close(fd); return 0; }

编译运行后,输出类似:

va=0x12000 pfn=0x84321 phys=0x84321000

得到物理地址后,进入QEMU monitor(Ctrl+A后按C),用xp命令直接读物理内存:

(qemu) xp /1gx 0x84321000 0000000084321000: 0x0000000012345678

看到0x12345678这个值出现在物理地址里,就说明虚拟地址到物理地址的翻译链路完全是通的。这一步把Linux地址空间、Sv39页表、物理内存三个层次真实地串联起来了,比任何理论讲解都有说服力。注意pagemap的读取在某些系统上需要root权限,QEMU guest里直接root跑就行。

5. 踩坑实录:一轮完整的MMU故障排查链路

5.1 故障现象:开MMU后PC直接跑飞

我在QEMU里调试自研的引导代码时遇到过这么一个问题:关闭MMU时,程序跳转到物理地址0x80200000执行,一切正常;一旦往SATP写入开启Sv39的值并跳转到高地址0xffffffff80200000,程序立刻异常。trap handler打印出的信息非常有限:scause=13(Load page fault),stval指向了一个全局变量的地址。

第一反应是怀疑页表没有构建正确。但页表构建代码是照着手册一行行写的,每个PTE都确认过V位、R位、W位、X位都置位了,为什么还是page fault?

5.2 定位过程:scause、stval、satp、页表四级联查

排查链路是这样的:

第一步,确认scause。13表示Load page fault,不是access fault,说明虚拟地址本身的符号扩展没有问题,问题出在页表遍历或权限检查。

第二步,看stval。stval记录的是触发异常的虚拟地址。这个虚拟地址落在代码中已映射的段内,看起来没有问题。

第三步,看SATP。用调试器读出SATP的值,确认MODE字段是8,PPN指向的物理页正确。SATP没问题。

第四步,手动walk页表。这是关键。在QEMU里,页表在物理内存中,可以用monitor的xp命令直接查看。假设SATP.PPN =0x84000,那么根页表物理地址就是0x84000000。计算stval中虚拟地址对应的VPN[2],在根页表里找到对应PTE,发现:PTE的V位是1,R位是1,W位是1,X位是1——看起来正常。但继续往下一级走,发现第二级页表的PTE依然全部置位。

问题出在第三级。第三级页表的PTE里,V位竟然是0。也就是说,代码构建了第一级和第二级页表,但在填充第三级页表时漏掉了一部分地址区间。为什么漏了?排查填充代码发现,页表分配器返回的第三级页表物理页在填充后被某个初始化函数意外清零了。具体是内存初始化顺序问题:分配页表的内存区域被后面的bss清零逻辑覆盖了。

5.3 修复方案与验证

修复方法很直接:把页表内存区域的初始化顺序调整到bss清零之后,并且给页表分配器增加一个简单的magic值检查,每次填充PTE前验证页表页未被破坏。改完之后重新跑,程序顺利进入高地址,全局变量读写正常,用户态切换成功。

这次排查给我的教训是:MMU问题不一定出在PTE格式上,还可能是页表页本身的生命周期管理出了问题。页表是物理内存中的普通数据,任何其他代码都可能踩踏它。在实际的OS开发中,页表页必须由专门的内存分配器管理,并且要有明确的ownership,绝不能让通用分配器随便复用还在使用的页表页。

5.4 常见MMU故障速查表

症状scause可能原因
访问固定地址触发page fault13/15PTE.V=0、PTE权限不足、页表未建完
跳转后指令异常12可执行PTE未设置X位、FENCE.I未执行
访问高地址报access fault5/7虚拟地址符号扩展错误、PMP拒绝物理地址
更新页表后仍读到旧数据无异常TLB未刷新,缺SFENCE.VMA
某段内存内容被莫名清零无异常页表页被其他代码踩踏

这张表基本覆盖了裸机/OS初期开发最容易遇到的MMU问题。每次排查都从scause开始,确认问题在"地址合法性-页表结构-物理地址访问权限"哪一层,比盲目翻页表高效得多。

6. 在QEMU上做实验:从构建环境到验证TLB行为

6.1 环境准备与最小可复现实验

QEMU是验证RISC-V MMU最好的平台,既能模拟完整Linux,又能在monitor里直接操作物理内存。准备一个RISC-V Linux环境通常走这几步:

# 安装QEMU与交叉编译工具链 apt install qemu-system-misc gcc-riscv64-linux-gnu # 编译Linux内核 git clone https://github.com/torvalds/linux cd linux make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- defconfig make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- -j$(nproc) # 准备rootfs(用buildroot或下载现成镜像)

启动命令:

qemu-system-riscv64 -M virt -smp 4 -m 2G \ -kernel arch/riscv/boot/Image \ -drive file=rootfs.ext2,format=raw,id=hd0 \ -device virtio-blk-device,drive=hd0 \ -append "root=/dev/vda rw console=ttyS0" \ -nographic

启动后,用第4.4节的pagemap程序做第一个实验。这个实验建议在rootfs里预置一个静态编译的riscv64二进制,省去guest内编译工具的麻烦。程序输出物理地址后,在QEMU monitor里用xp验证对应物理内存的内容。这基本是"Linux地址空间到物理内存"最快速、最直观的端到端验证。

6.2 实验:从SATP出发观察Linux内核页表

第二个实验稍微进阶一点。在QEMU monitor里输入info registers,可以看到当前CPU的寄存器,其中包含SATP。SATP里的PPN就是当前进程的根页表物理地址。

假设当前SATP的PPN是0x84000,可以手动推算某个用户态虚拟地址的页表索引。以虚拟地址0x12000为例,VPN[2]=0VPN[1]=0VPN[0]=1。在monitor里:

(qemu) xp /8gx 0x84000000

查看根页表第0项,找到第二级页表的物理地址;再看第二级页表第0项,找到第三级页表的物理地址;最后看第三级页表第1项,得到的PPN应当和第4.4节pagemap程序输出的PFN一致。这个手动walk的过程会逼着你把前三节的原理全部用一遍,做完之后Sv39页表的结构就刻在脑子里了。

6.3 一个关于TLB行为的小技巧

实验时可以顺便观察TLB的一个特性:第一次访问一个刚映射的页面时,由于TLB里没有缓存,CPU会去内存里走页表遍历;把同一页面连续访问几百万次后,TLB命中率会变得极高。在QEMU里可以粗略感受这个差异——虽然在模拟器里时间精度一般,但如果你给某个函数计时,第一次调用明显比后面的调用慢,其中有一部分就来自TLB冷启动的页表遍历和缓存填充。

另一个实用技巧是:在调试阶段,如果怀疑某个虚拟地址的翻译结果有问题,在QEMU monitor里先xp读页表物理内存确认PTE内容,再用xp读PTE指向的物理地址。这相当于手动绕过了MMU,直接看物理世界的真相。遇到"软件觉得映射了、硬件说没映射"的矛盾时,这招几乎一击致命。

QEMU还支持-d mmu之类的日志选项,可以打印MMU访问和页表遍历的调试信息。我自己调试早期启动代码时会用这个选项观察每一条地址翻译请求,配合SATP的变化时间点,很快就能锁定是哪个阶段、哪个地址出了问题。对于在真实FPGA或芯片上还没有这么方便的调试手段的场景,QEMU的这套玩法能帮你把逻辑先完全调通,再移植到硬件上,能省掉大量对着示波器抓头发的时间。

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

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

立即咨询