4. 内存访问日志分析:-d mmu 参数详解、地址转换与页表遍历日志解读

内存访问出问题,是 RISC-V 系统里最让人头疼的故障之一。我见过不少开发者,程序跑飞了第一反应是查代码逻辑,折腾半天才发现是页表配置错了。其实 QEMU 的 -d mmu 日志,能直接把地址转换的每一步都摊开给你看。

这一节,我们就来彻底搞懂它。

4.1 -d mmu 到底输出了什么?

简单说,-d mmu 会打印每一次虚拟地址到物理地址的转换过程。包括页表遍历的每一级、页表项的权限检查、以及最终的映射结果。

启动 QEMU 时加上这个参数:

qemu-system-riscv64 -kernel my_os.elf -nographic -d mmu

你会看到类似这样的输出:

mmu: vaddr=0x80001200 paddr=0x80001200 size=4 prot=r-x
mmu: vaddr=0x80001204 paddr=0x80001204 size=4 prot=r-x
mmu: vaddr=0x80001208 paddr=0x80001208 size=4 prot=r-x
...

嗯,这里要注意:如果系统刚启动时还没开 MMU,日志里全是这种 恒等映射(vaddr == paddr)的记录。等软件执行了 satp 寄存器的写入,真正的页表遍历日志才会出现。

我的习惯: 先跑一小段不带 -d mmu 的日志,确认系统能正常启动到开启 MMU 的代码位置。然后再加上参数,否则日志量太大,反而淹没了关键信息。

4.2 地址转换的核心流程

RISC-V 的 Sv39 分页机制,说白了就是三级页表遍历。每次 CPU 访问一个虚拟地址,硬件都会自动完成以下步骤:

  1. 拆分虚拟地址:把 39 位虚拟地址拆成 3 个 9 位的索引(VPN[2]、VPN[1]、VPN[0])和 12 位的页内偏移。
  2. 读根页表:从 satp 寄存器拿到根页表的物理地址,用 VPN[2] 作为索引,找到第二级页表的基址。
  3. 遍历中间级:用 VPN[1] 索引第二级页表,找到第三级页表的基址。
  4. 读叶子页表:用 VPN[0] 索引第三级页表,拿到最终的物理页号(PPN)。
  5. 拼出物理地址:PPN 左移 12 位,加上页内偏移,得到完整的物理地址。

下面这张图,把整个流程串起来了:

Sv39 地址转换与页表遍历流程 Sv39 地址转换与页表遍历流程 虚拟地址 (39位): VPN[2] (9位) | VPN[1] (9位) | VPN[0] (9位) | 页内偏移 (12位) 第1级页表 (根) satp 寄存器指向的物理地址 VPN[2] 索引 → 找到第2级页表基址 第2级页表 VPN[1] 索引 → 找到第3级页表基址 第3级页表 (叶子) VPN[0] 索引 → 得到物理页号 (PPN) 物理地址 = PPN << 12 + 页内偏移 最终访问的物理内存位置 每级页表 512 个表项

4.3 日志中的页表遍历细节

当 MMU 开启后,-d mmu 的日志会变得详细得多。我截取一段真实日志来分析:

mmu: translate: vaddr=0xffffffc000000000
mmu: 1st pte: paddr=0x80002000 vpn2=0x1ff pte=0x80002801
mmu: 2nd pte: paddr=0x80002800 vpn1=0x000 pte=0x80003001
mmu: 3rd pte: paddr=0x80003000 vpn0=0x000 pte=0x8000400f
mmu: result: paddr=0x80004000 prot=rw-

每一行都对应一次页表项的读取。我们来拆解一下:

日志字段 含义 我的解读
vaddr=0xffffffc000000000 要转换的虚拟地址 这是内核空间的高地址,通常映射到物理内存的起始位置
1st pte: paddr=0x80002000 vpn2=0x1ff 根页表项位于物理地址 0x80002000,索引为 0x1ff 0x1ff 是 9 位全 1,对应虚拟地址的最高 9 位
pte=0x80002801 页表项的值 低 12 位是标志位,0x001 表示有效且可读
result: paddr=0x80004000 prot=rw- 最终物理地址和权限 映射成功,可读可写
关键点: 页表项的低 12 位是标志位。常见的标志组合:
  • 0x01 (V) — 有效位,必须置 1
  • 0x0f (V|R|W|X) — 可读可写可执行
  • 0x11 (V|U) — 用户态可访问
如果标志位不对,QEMU 会直接报 page fault。

4.4 常见故障模式与日志特征

我在项目中遇到过几次典型的页表问题,日志特征非常明显:

4.4.1 页表项无效(Page Not Present)

mmu: translate: vaddr=0x0000000080004000
mmu: 1st pte: paddr=0x80002000 vpn2=0x000 pte=0x80002801
mmu: 2nd pte: paddr=0x80002800 vpn1=0x000 pte=0x80003001
mmu: 3rd pte: paddr=0x80003000 vpn0=0x004 pte=0x00000000
mmu: page fault: vaddr=0x80004000 cause=12 (page not present)

看到 pte=0x00000000 了吗?这就是问题所在。第三级页表项全零,表示这个虚拟页面没有映射物理内存。

我曾经踩过的坑: 在写内核时,我分配了物理页但忘了在页表里填上对应的 PPN。结果 QEMU 一直报 page fault,我盯着日志看了半小时才发现第三级页表项是 0。后来我养成了一个习惯:每次分配物理页后,立刻检查页表项是否写入了正确的物理地址。

4.4.2 权限违规(Permission Fault)

mmu: translate: vaddr=0x0000000080004000
mmu: 1st pte: paddr=0x80002000 vpn2=0x000 pte=0x80002801
mmu: 2nd pte: paddr=0x80002800 vpn1=0x000 pte=0x80003001
mmu: 3rd pte: paddr=0x80003000 vpn0=0x004 pte=0x80004001
mmu: page fault: vaddr=0x80004000 cause=15 (store access fault)

这里第三级页表项是 0x80004001,只有 V 位(0x01),没有 W 位。但程序试图写这个地址,所以触发了 store access fault。

你想想看,如果页表项里没有 W 位,CPU 是不会允许写入的。这是硬件强制检查的,软件一点办法都没有。

4.4.3 大页(Huge Page)的日志特征

RISC-V 支持 2MB 和 1GB 的大页。如果中间级页表项设置了 R、W 或 X 位,遍历就会提前终止:

mmu: translate: vaddr=0xffffffc000000000
mmu: 1st pte: paddr=0x80002000 vpn2=0x1ff pte=0x80002801
mmu: 2nd pte: paddr=0x80002800 vpn1=0x000 pte=0x800000cf
mmu: result: paddr=0x80000000 prot=rw- (2MB huge page)

注意第二级页表项 0x800000cf,低 8 位是 0xcf,包含了 V、R、W、X 位。这意味着遍历在第二级就结束了,不需要再查第三级页表。日志里明确标注了 2MB huge page。

4.5 实战排查步骤

如果你遇到内存访问异常,我建议按这个顺序排查:

  1. 确认 MMU 是否已开启:检查日志中是否有 satp 寄存器的写入记录。如果没有,所有地址都是恒等映射。
  2. 定位出错的虚拟地址:找到 page fault 那一行,记下 vaddr 和 cause。
  3. 逐级检查页表项:从 1st pte 开始,确认每一级的页表项是否有效、权限是否正确。
  4. 验证物理地址:如果页表遍历成功,检查最终 paddr 是否落在合法的物理内存范围内。
  5. 检查 TLB:如果页表看起来没问题,可能是 TLB 没有刷新。试试在修改页表后执行 sfence.vma 指令。
我的小技巧: 在 QEMU 命令行加上 -d mmu,cpu_reset,可以同时看到 MMU 日志和 CPU 状态。这样能更直观地看到异常发生时,CPU 的当前特权级和寄存器值。

4.6 总结

-d mmu 是排查内存映射问题的利器。它能让你看到地址转换的每一个细节,从虚拟地址拆分到页表项读取,再到最终的物理地址拼装。掌握了这些日志的解读方法,你就能快速定位页表配置错误、权限设置不当、物理地址越界等常见问题。

说白了,内存访问故障排查,就是一场和页表项的博弈。日志已经把答案摆在你面前了,剩下的就是你能不能读懂它。