拓冰建站拓冰建站
首页 / 资讯中心 / 正文

RISC-V Linux移植实战:深入Sv39页表与MMU地址翻译机制

讲真RISC-V 的 MMU 和页表这块属于那种“书上看十遍不如动手调一遍”的知识点。最近我在一个 RISC-V 64 位的平台上面做 Linux 内核移植把 Sv39 页表和 Linux 地址空间整个链路从头到尾摸了一遍踩了不少坑也积累了一些模板级的操作手法。这篇文章就把这些东西整理出来——从 Sv39 的地址拆分、页表项布局到 Linux 内核如何把用户态和内核态的虚拟地址安排明白一一展开。适合正在搞 RISC-V 内核、嵌入式虚拟化或者纯粹想把地址空间机制吃透的读者。我默认你已经有 RISC-V 指令集和 Linux 内核的基本概念知道 satp 寄存器是干嘛的知道 Linux 里进程地址空间是咋回事。如果你还比较陌生也不慌我会把每个必要的背景都用大白话解释但重心永远放在“怎么用、怎么调、怎么避坑”上。1. 为什么必须理解 MMU从裸机到操作系统的关键一跃1.1 虚拟内存解决了什么问题在没有开 MMU 的裸机环境里CPU 拿到一个地址就直接丢到总线上访问物理内存这看起来很直接但用一段时间你就会撞上三堵墙。第一堵墙是隔离。两个程序如果同时跑在物理地址上谁都能改谁的数据一个野指针就能把整个系统打崩。第二堵墙是连续性。物理内存可能有很多碎片但你希望给程序提供一个从 0 到 N 看起来整整齐齐的地址空间不然编译器、链接器、加载器全都得跟着遭殃。第三堵墙是按需分配。程序启动时往往不会立刻用完所有内存你不想为它可能用到的一大片空间提前买单。而 MMU 做的事情本质上就是一张“地址翻译表”CPU 发出的每一个虚拟地址都会经过页表查表最终换算成物理地址再访问内存。有了这层翻译每个进程都能拥有自己独立的“虚拟地址空间”物理内存怎么摆放、怎么分配全部由内核说了算进程根本感知不到。在 RISC-V 64 位上这个机制由 satpSupervisor Address Translation and Protection寄存器开启。它里面有个 MODE 字段写成 8 就代表启用 Sv39写成 9 就是 Sv48写成 0 就是裸机模式直接物理寻址。日常开发中你最常见到的还是 Sv39因为它是 64 位 RISC-V 的默认基线。1.2 和 x86、ARM 相比RISC-V 的 MMU 有什么不一样的脾气如果你是从 x86 或者 ARM 转到 RISC-V第一感觉是“清爽”但第二感觉是“这也太素了”。x86 有两级页表结构其实是四级PML4、PDPT、PD、PT每一级都有各自的格式和标志位再加上 CR3、CR4、EFER 一堆控制寄存器学起来负担很重。ARM64 用了 4KB/64KB 粒度混淆选项还有 TCR_EL1 里一堆像 T0SZ、T1SZ 这种让人头疼的字段配上 MAIR 属性寄存器光初始化内存序就够写几千行。RISC-V 的 Sv39 则把页表简化到了极致三级页表每级 512 项每项 8 字节页表项的标志位就那么几个V/R/W/X/U/G/A/D看一眼就能记住。没有乱七八糟的属性寄存器没有特殊的页表缓存指令TLB 维护就是一条 sfence.vma。但这个“素”也意味着很多事情要自己做。x86 和 ARM 在硬件上做了很多辅助功能比如 ARM64 的硬件页表遍历器HW Walker、x86 的 PCID这些在 RISC-V 上基本得靠软件想办法。好在 Linux 内核已经把这些都封装好了你平时写驱动、调 BSP真正需要动手写页表的场景其实集中在启动阶段和特殊模块里。但如果不懂底层纱线遇到问题就只能是黑盒排查很锻炼心态。2. Sv39 页表格式深度拆解2.1 虚拟地址与物理地址的位段划分Sv39 这个名字里的“39”指的是虚拟地址的有效位数为 39 位。为什么偏偏是 39因为它是这么拆的低 12 位是页内偏移page offset中间的 27 位分成三组每组 9 位分别叫 VPN[0]、VPN[1]、VPN[2]。如果你把一个 64 位虚拟地址写成二进制从高到低大概是这个感觉位段宽度名称作用bit 63 – 3925符号扩展必须等于 bit 38否则触发异常bit 38 – 309VPN[2]索引根页表二级页表bit 29 – 219VPN[1]索引一级页表中间页表bit 20 – 129VPN[0]索引零级页表叶子页表bit 11 – 012offset4KB 页内偏移注意那个符号扩展。这是很多人第一次调 RIPL 的时候翻车的地方——Sv39 虽然说有效地址只有 39 位但 CPU 要求 bit 63 到 bit 39 全部等于 bit 38。也就是说用户态地址bit 38 0看起来是0x0000000000000000到0x0000003fffffffff内核态地址bit 38 1则必须符号扩展到0xffffffc000000000以上。如果你在内核代码里手写了一个“看起来是 39 位”的地址但高 25 位没扩好一访问就是异常。物理地址这边Sv39 支持最大 56 位物理地址。物理页号 PPN 在页表项里占 44 位因为 56 位物理地址减去 12 位页内偏移就是 44 位。这也就意味着将来如果你要接超过 56 位物理地址的板子SV39 是撑不住的得上 Sv48 或 Sv57。每级页表都是一个 4KB 物理页里面有 512 个 8 字节的页表项PTE。根页表的物理地址放在 satp 寄存器里注意是物理地址不是虚拟地址。在内核启动早期还没有建立完整的页表映射时你写的代码必须保证能直接访问到这块物理内存不然就死锁了。2.2 PTE 的结构与权限位语义一个标准的 Sv39 页表项共 64 位关键字段如下位段宽度名称说明bit 01V有效位0 表示该表项不存在bit 11R可读bit 21W可写bit 31X可执行bit 41U用户态可访问为 0 时仅 S 模式可访问bit 51G全局映射通常用于内核映射bit 61AAccessed硬件在访问时置 1bit 71DDirty硬件在写入时置 1bit 9:82RSW给操作系统软件使用的保留位bit 53:1044PPN物理页号bit 63:5410保留必须为 0否则报错R、W、X 这三个位单独看没什么组合起来就是权限模型的全部了。比如 R0、W0、X1 就是“只执行不可读”R1、W0、X0 就是“只读”R1、W1、X0 就是“可读可写但不可执行”。如果三个全是 0那这个 PTE 就不是叶节点而是指向下一级页表的指针。这个规则非常反直觉但重要到了极点一旦 R、W、X 全为 0MMU 就会认为这个表项不指向最终物理页面而是指向下一级页表反过来说如果你想把某个物理页设置为“完全不可访问”直接设 V0 而不是 RWX0。下面讲 Linux 缺页异常的时候你还会再见到这个规则。A 和 D 位是硬件帮你维护的访存信息。读页面时硬件自动把 A 置 1写页面时硬件把 A 和 D 都置 1。操作系统内核换页和写回文件系统时需要依赖这两个标志位比如判断一个页是不是“脏页”。要注意的是对于非叶页表项A/D 位并没有硬件强制意义但 Linux 在遍历页表时也会更新它们作为页表本身的访问热度参考。物理页号 PPN 字段并没有对齐到字节边界这一点也很容易错。比如你有一个物理地址 0x8000_a000想把它塞进 PTE正确的做法是先右移 12 位得到物理页号0x8000a再左移 10 位放进 bit 53:10。如果直接把物理页号左移 12 位再放那结果是往 PPN 的高位串了 2 位MMU 解析出来的物理地址就是错的表现成访问到一块完全不对的内存非常难查。3. 从零构建一个 Sv39 页表实操代码与调试手法3.1 手工映射一段虚拟地址光看格式不写代码都是白搭。下面这段 C 代码是我在调试 RISC-V 平台时用来验证页表逻辑的最小示例能帮你把整个流程在脑子里跑一遍。#include stdio.h #include stdint.h #include stdlib.h #include string.h #define PAGE_SIZE 4096 #define PTE_V 0x001 #define PTE_R 0x002 #define PTE_W 0x004 #define PTE_X 0x008 #define PTE_U 0x010 #define PTE_G 0x020 #define PTE_A 0x040 #define PTE_D 0x080 // 分配一个 4KB 对齐的零页 static uint64_t *alloc_zero_page(void) { uint64_t *page NULL; if (posix_memalign((void **)page, PAGE_SIZE, PAGE_SIZE) ! 0) { perror(posix_memalign); exit(1); } memset(page, 0, PAGE_SIZE); return page; } static void split_va(uint64_t va, uint64_t *vpn2, uint64_t *vpn1, uint64_t *vpn0, uint64_t *offset) { *vpn2 (va 30) 0x1ff; *vpn1 (va 21) 0x1ff; *vpn0 (va 12) 0x1ff; *offset va 0xfff; } // 叶子 PTE把物理地址和权限组合成一个 PTE static uint64_t make_leaf_pte(uint64_t pa, uint64_t perm) { uint64_t ppn (pa 12) 0xfffffffffffULL; // 44 位 return (ppn 10) | perm | PTE_A | PTE_D; } // 在三级页表中映射一页 static void map_page(uint64_t *root, uint64_t va, uint64_t pa, uint64_t perm) { uint64_t vpn2, vpn1, vpn0, off; split_va(va, vpn2, vpn1, vpn0, off); // 第 2 级根页表项如果不存在则分配中间页表 uint64_t *pte2 root[vpn2]; uint64_t *level1; if ((*pte2 PTE_V) 0) { level1 alloc_zero_page(); // 注意非叶 PTE 不能设置 R/W/X *pte2 ((uint64_t)level1 12) 10 | PTE_V; } else { level1 (uint64_t *)((*pte2 10) 12); } // 第 1 级中间页表项 uint64_t *pte1 level1[vpn1]; uint64_t *level0; if ((*pte1 PTE_V) 0) { level0 alloc_zero_page(); *pte1 ((uint64_t)level0 12) 10 | PTE_V; } else { level0 (uint64_t *)((*pte1 10) 12); } // 第 0 级叶子页表项 uint64_t *pte0 level0[vpn0]; *pte0 make_leaf_pte(pa, perm); } void walk_pt(uint64_t *root, uint64_t va) { uint64_t vpn2, vpn1, vpn0, off; split_va(va, vpn2, vpn1, vpn0, off); uint64_t pte2 root[vpn2]; printf(L2 PTE[%lu] 0x%016lx\n, vpn2, pte2); if (!(pte2 PTE_V)) return; uint64_t *level1 (uint64_t *)((pte2 10) 12); uint64_t pte1 level1[vpn1]; printf(L1 PTE[%lu] 0x%016lx\n, vpn1, pte1); if (!(pte1 PTE_V)) return; uint64_t *level0 (uint64_t *)((pte1 10) 12); uint64_t pte0 level0[vpn0]; printf(L0 PTE[%lu] 0x%016lx\n, vpn0, pte0); if (!(pte0 PTE_V)) return; uint64_t pa ((pte0 10) 12) | off; printf(VA 0x%lx - PA 0x%lx\n, va, pa); } int main(void) { uint64_t *root alloc_zero_page(); uint64_t va 0x0000000000201000; uint64_t pa 0x8000a000; map_page(root, va, pa, PTE_R | PTE_W | PTE_U); walk_pt(root, va); return 0; }这个代码在普通 Linux 机器上就能编译运行因为它只是纯计算不会真的去设置 satp。你运行之后会看到三级 PTE 依次展开最后打印出 VA 到 PA 的翻译结果。试着手改几个偏移位比如把va换成0x0000004000000000你会看到 VPN[2] 变成了 512超出索引范围就会明白为什么内核空间和用户空间被限制在各自的半区里。3.2 页表遍历与 TLB 维护的实操要点页表构建出来之后真正的硬件切换和 TLB 维护是另一个坑。先说要切换地址空间你需要往 satp 里写根页表的物理地址和 MODE 字段。假设你的根页表物理地址是pa_root那么// MODE 8 表示 Sv39 uint64_t satp (8ULL 60) | (pa_root 12); asm volatile(csrw satp, %0 : : r(satp)); asm volatile(sfence.vma);csrw satp是写 CSR 寄存器sfence.vma是刷 TLB。这两条命令经常成对出现。但要注意sfence.vma默认刷的是整个 TLB性能很差。如果你只改了一个页面的映射可以加上虚拟地址参数做局部刷sfence.vma x0, 虚拟地址在 Linux 内核里你很少直接写这些汇编因为架构相关的代码层已经把flush_tlb_page、flush_tlb_mm都封装好了。但自己写裸机代码或者启动早期代码时忘记刷 TLB 是最常见的 bug你改了 PTE但 CPU 还拿着旧的 TLB 条目翻译地址结果访问的还是老内存。另外提一个容易忽略的点在 RISC-V 里satp 切换后是否需要立即刷 TLB 取决于你写的是不是同一个地址空间。如果切到另一个 mm因为根页表都换了理论上不刷 TLB 也问题不大但如果你改的是当前地址空间里的某个 PTE那就必须刷。保守起见切换后统一刷一次性能损失在多数场景下可以接受。等遇到性能瓶颈了再去做精细的sfence.vma rs1。4. Linux 如何基于 Sv39 组织地址空间4.1 内核态与用户态的地址划分Linux 在 RISC-V 64 位上的地址空间布局可以概括为一句话用户态占低半区内核态占高半区中间夹着黑洞。在 Sv39 模式下用户态虚拟地址范围是0x0000000000000000到0x0000003fffffffff一共 256GB。内核态的起始地址是0xffffffc000000000这看起来离用户态非常远其实对应的有效位就是 bit38 为 1 的高半区 256GB只不过 64 位地址的高 25 位全部符号扩展成了 1。Linux 在这个内核半区里又划分了几个功能区。以 RISC-V 的典型配置为例区域起始地址用途线性映射区direct mapPAGE_OFFSET把物理内存直接映射到内核虚拟地址空间内核代码/数据、伙伴系统的页面都在这vmalloc 区VMALLOC_START – VMALLOC_END非连续内存分配驱动里用的vmalloc就落在这fixmap 区FIXADDR_START固定映射用于早期启动和短暂映射内核代码段链接脚本指定内核自身的 .text/.data 等所以你在内核里经常看到“物理地址转虚拟地址”对一个struct page或者物理地址pa直接加上PAGE_OFFSET就能得到它的内核虚拟地址。反过来虚拟地址减去PAGE_OFFSET就得到物理地址。这个设计非常朴素但也非常高效前提是这块物理内存在线性映射区里有覆盖。用户态这边每个进程都有自己的mm_struct里面挂着一个pgd也就是 Sv39 的根页表。不同进程的根页表不同所以地址翻译互不影响。进程创建、fork、exec 时Linux 会通过pgd_alloc、pud_alloc、pmd_alloc、pte_alloc_map这一套通用接口逐步往页表里加内容RISC-V 架构只需要提供底层原语比如set_pte、pte_clear、pte_write这些。4.2 从 mmap 到缺页异常一条完整链路理解 Linux 地址空间光看布局还不够得跟着一次实际的内存访问走一遍才能把上面所有知识点串起来。假设用户程序调用了mmap想映射 1MB 的匿名内存。内核在mmap阶段做的事情非常轻量只是创建一个vm_area_struct记录起始地址、长度和权限挂在进程的地址空间红黑树上。注意这一步并不会真正分配物理页也不会建立页表项。页表是空的但vm_area_struct已经告诉你这块区域合法可以访问。然后程序第一次访问这块区域里的某个地址CPU 去查页表发现对应 PTE 的 V 位是 0MMU 就触发一个 page fault。在 RISC-V 上根据访问类型不同scause会是 12取指缺页、13读缺页、15写缺页。硬件把出错的虚拟地址放在stval寄存器里然后跳转到异常向量。Linux 的异常入口拿到scause和stval后最终会调用到do_page_fault。这里有一个关键判断这个缺页地址是否在进程的某个vm_area_struct范围内如果在就调用handle_mm_fault如果不在就是典型的段错误直接给进程发 SIGSEGV。handle_mm_fault往下走会根据缺页地址再次遍历页表逐级创建缺失的中间页表最后通过do_anonymous_page或do_fault分配一个物理页填上 PTE然后返回。此时 A/D 位会被硬件在后续访问时自动更新但 PTE 刚建立时必须注意把_PAGE_PRESENT和权限位一起写进去。这里我想强调一个 Rust 的老坑在 RISC-V 上Linux 内核早期启动时会建立一份临时页表用的也是 Sv39 格式。如果你要修改早期启动代码比如打开 MMU 的时机不对或者临时页表只映射了 2MB 但实际代码跳转超过了这个范围系统会直接卡死在启动阶段串口上连个报错都没有。排查这种问题只能用 JTAG 或 QEMU 的 debug 模式看 PC 寄存器和 satp一步步走。5. 常见问题与排查技巧实录5.1 页表访问异常与调试方法在 RISC-V Linux 上调试内存问题第一步永远是看scause和stval。scause告诉你异常类型stval告诉你出错的虚拟地址。我见过很多人一上来就盯着一堆日志看其实只要先看这两个寄存器问题就能缩小一半。如果是 page fault接下来的区分是“访问了非法地址”还是“权限不够”。内核日志里的unhandled signal 11或者segfault at ...通常意味着地址不在任何vm_area_struct里如果出现Unable to handle kernel paging request at virtual address ...则多半是内核自身访问了一个没有映射的地址。如果要手工检查某个进程的页表QEMU 的info mem命令非常方便它会把整个虚拟地址空间的映射情况列出来。如果用 GDB 调试裸机程序可以直接读 satp(gdb) p/x $satp (gdb) x/512gx $phys_base # 查看根页表内容更实操一点的技巧是在内核代码里临时加一个 dump 函数把某个虚拟地址的三级 PTE 全部打出来。我一般写法是static void dump_pte(struct mm_struct *mm, unsigned long addr) { pgd_t *pgd pgd_offset(mm, addr); p4d_t *p4d p4d_offset(pgd, addr); pud_t *pud pud_offset(p4d, addr); pmd_t *pmd pmd_offset(pud, addr); pte_t *pte pte_offset_kernel(pmd, addr); pr_err(pgd:%lx p4d:%lx pud:%lx pmd:%lx pte:%lx\n, pgd_val(*pgd), p4d_val(*p4d), pud_val(*pud), pmd_val(*pmd), pte_val(*pte)); }把这串日志打开对照 Sv39 的 PTE 格式一眼就能看出是哪一级页表丢了还是权限位配错。5.2 性能与正确性之间的小坑除了查错MMU 相关的性能问题也值得单独说。第一个坑是 A/D 位导致的高频页表写。Linux 在回收内存或者做页迁移时会频繁检查 A/D 位。如果硬件没有这些位比如某些模拟器只实现了最小 MMU软件就得每次访问都模拟性能影响极大。RISC-V 的 spec 规定硬件可以更新 A/D但有些低成本的核可能偷懒不实现接到 Linux 上跑起来会有一堆 warn 日志。遇到这种情况第一步是查你的 core 到底支持不支持硬件 A/D 更新。第二个坑是大页huge page的使用。Sv39 除了标准 4KB 页面还支持 2MB 和 1GB 的大页。大页的好处是减少 TLB miss但代价是灵活性下降。在 QEMU 里模拟 RISC-V 时开启大页可能因为 TLB 模拟不够精细而出现奇怪的问题表现是“性能没提升反而更卡”。如果你做验证性开发建议先用标准 4KB 页把功能跑通再优化。第三个容易踩的坑是物理内存探测不完整导致线性映射区覆盖不到。你有一块 2GB 的内存条但设备树里只声明了 1GB那么高地址部分在 Linux 里就消失了。vmalloc访问这些地址时会直接触发内核 panic而且报错地址看起来很像野指针容易误判。排查时用cat /proc/iomem看看各个内存节点的范围再对照PAGE_OFFSET计算一下映射覆盖能省很多时间。第四个坑是权限位的组合使用。我调试过一个问题某个驱动把内核缓冲区映射到用户态用户程序一读就段错误。查了半天发现 PTE 里只设了PTE_U和PTE_R但忘了 PTE 的全局位G和内核态访问位是否需要导致内核自身的 copy_to_user 路径出了问题。RISC-V 的规则是用户态页必须设 U1内核态访问用户页时还要注意 satp 里的 SUMSupervisor User Memory access位如果 SUM0S 模式代码访问 U1 的页面也会触发异常。这个 SUM 位在有些调试场景下经常被忽略导致内核态访问用户缓冲区时报权限异常。最后再分享一个调试建议页表相关的 bug 有个共同特点一旦定位到根因修复往往只有一行但定位过程可能耗掉一整天。我现在的做法是在每次修改页表相关代码之前先在纸上把“地址拆分 PTE 位布局”完整写一遍哪怕是 5 分钟也比事后对着寄存器猜半天高效得多。另外一个习惯是在写 PTE 的地方加一个短日志钩子打印新旧 PTE 值和目标虚拟地址启动阶段用 earlycon 拉出来。虽然日志本身会带来一点开销但在开发前期这点开销远比一个需要反汇编才能找到的页表错误来得划算。如果你也在调试 RISC-V 平台上的 Linux希望这篇整理能帮你少走几步弯路。Sv39 到 Linux 地址空间这条链路说复杂也复杂说简单也简单——无非是拆地址、填表、刷 TLB、处理异常。真正把它跑通之后你再回头看 x86 和 ARM64 的 MMU会突然觉得各自的设计其实都是一脉相承的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门