RISC-V 虚拟内存与 MMU 实战:从 Sv39 页表到 Linux 地址空间

一、引言:虚拟内存是系统软件的基石

裸机固件与轻量级 RTOS 可以不开启 MMU,直接以物理地址运行;Linux 则完全不同。现代操作系统的进程隔离、按需分页、写时复制(COW)、共享库映射与内存保护等核心机制,全部构建于虚拟内存之上。对 RISC-V 开发者而言,MMU 是从裸机编程走向操作系统开发的关键一跃。

与 x86、ARM 相比,RISC-V 的分页机制呈现显著简化特征:页表格式统一、地址翻译由硬件完成,但 TLB 一致性维护明确交由软件负责。RV64 平台定义了 Sv39、Sv48、Sv57 三种分页模式,分别支持 39、48、57 位虚拟地址,Linux 在 RV64 上默认采用 Sv39,部分场景可选用 Sv48。

二、地址翻译流水与关键寄存器

开启分页后,处理器执行的每一条指令、每一次数据访问均需经过地址翻译:虚拟地址(VA)以 satp 寄存器指向的根页表为起点,由硬件逐级遍历页表得到物理地址(PA);若命中 TLB 缓存则直接完成翻译。

satp 是翻译的总开关,其布局为三个字段:

  • MODE(bit 63-60):分页模式选择,Sv39 对应 8,0 表示 Bare(关闭分页,VA 直通 PA);
  • ASID(bit 59-44):地址空间标识符,用于区分不同进程的 TLB 缓存项;
  • PPN(bit 43-0):根页表的物理页号,即根页表地址右移 12 位。

S 模式与 U 模式的访存均受 satp 约束,M 模式默认旁路分页。翻译失败时,S 模式通过 scause(原因编码 12/13/15 分别对应指令取指、读、写页故障)与 stval(出错虚拟地址)获知异常详情,交由内核缺页处理程序响应。

三、Sv39:三层页表解析

Sv39 将 39 位虚拟地址切分为四级:VA[38:30]VA[29:21]VA[20:12] 三段索引加上 12 位页内偏移。每级页表含 512 个 8 字节页表项(PTE),恰占一个 4KB 页面。物理页大小为 4KB,地址空间总量 512GiB。

PTE 的位域定义如下:

位域 名称 含义
bit 0 V 页表项有效
bit 1-3 R/W/X 可读 / 可写 / 可执行
bit 4 U 用户模式可访问
bit 5 G 全局映射(跨 ASID 有效)
bit 6 A 已被访问(硬件置位)
bit 7 D 已被写入(硬件置位)
bit 8-9 RSW 保留给操作系统软件使用
bit 10-53 PPN 物理页号
bit 54-63 --- 保留

判定叶子节点(最终映射)的规则是:R/W/X 三者至少一位置 1。若三者全为 0,则该 PTE 指向下一级页表。利用这一规则,中间层 PTE 可直接映射 2MB(Sv39 第二级)或 1GB(第一级)大页,显著减少 TLB 未命中。A/D 位由硬件在访问时自动置位,Linux 的内存回收与换页机制正是依据这两位的状态判断页面使用情况。

四、TLB 一致性与 SFENCE.VMA

页表驻留内存,而 TLB 是翻译结果的缓存。页表更新后,TLB 中的旧缓存不会自动失效 ,若不清除,处理器可能继续使用过期映射,这正是 Meltdown 类侧信道攻击的根源之一。RISC-V 将刷新义务明确交给软件,通过 sfence.vma 指令完成。

该指令支持三种粒度:

  • 无操作数:刷新全部 TLB 项;
  • 仅 rs1:仅刷新指定虚拟地址对应的缓存项;
  • rs1 与 rs2 同时给出:刷新指定虚拟地址且限定 ASID 的缓存项。

多核场景下问题进一步复杂化:页表由主核修改后,其他 Hart 的 TLB 并不知情,需要通过 IPI(核间中断)触发各 Hart 执行刷新,Linux 中由 flush_tlb_* 系列接口统一封装。实践中"修改页表后忘记执行 fence"是最隐蔽也最常见的虚拟内存缺陷之一。

五、实战:QEMU virt 手工建立 Sv39 映射

本节在 QEMU virt 平台上实现最小可用的 Sv39 恒等映射。程序运行于 0x80000000,目标是将运行段与 UART(0x10000000)通过手工构建的页表映射到位,开启 MMU 后通过虚拟地址继续正常执行与打印。

链接脚本将程序固定到 DDR 起始地址:

ld复制

scss 复制代码
OUTPUT_ARCH(riscv)
ENTRY(_start)
SECTIONS
{
    . = 0x80000000;
    .text : { *(.text*) }
    .rodata : { *(.rodata*) }
    .data : { *(.data*) }
    .bss : { *(.bss*) }
}

页表构造与 MMU 开启逻辑:

c复制

arduino 复制代码
#define SATP_MODE_SV39  (8UL << 60)
#define PTE_V   (1UL << 0)
#define PTE_R   (1UL << 1)
#define PTE_W   (1UL << 2)
#define PTE_X   (1UL << 3)
#define PTE_A   (1UL << 6)
#define PTE_D   (1UL << 7)

uint64_t pt_root[512] __attribute__((aligned(4096)));
uint64_t pt_l1[512]   __attribute__((aligned(4096)));

/* 在第二级页表中映射一个 2MB 大页 */
static void map_2m(uint64_t *l1, unsigned long va, unsigned long pa)
{
    unsigned idx = (va >> 21) & 0x1ff;
    l1[idx] = (pa & 0xfffff00000UL) | PTE_V | PTE_R | PTE_W | PTE_X
            | PTE_A | PTE_D;
}

void mmu_init(void)
{
    map_2m(pt_l1, 0x80000000UL, 0x80000000UL); /* 恒等映射运行段 */
    map_2m(pt_l1, 0x10000000UL, 0x10000000UL); /* 映射 UART 外设 */

    pt_root[(0x80000000UL >> 30) & 0x1ff] =
        ((uintptr_t)pt_l1) | PTE_V;            /* 两根索引共享 L1 */
    pt_root[(0x10000000UL >> 30) & 0x1ff] =
        ((uintptr_t)pt_l1) | PTE_V;

    asm volatile("sfence.vma");                /* 切换前先刷新 */
    unsigned long satp = SATP_MODE_SV39 | ((uintptr_t)pt_root >> 12);
    asm volatile("csrw satp, %0" :: "r"(satp));
    asm volatile("sfence.vma");                /* 切换后再刷新 */
}

主程序中先调用 mmu_init,再通过虚拟地址写读验证一致性并输出引导信息。编译与运行命令:

bash复制

ini 复制代码
riscv64-linux-gnu-gcc -march=rv64gc -mabi=lp64d -nostdlib -fno-builtin \
    -T mmu.lds -o mmu.elf start.S main.c
qemu-system-riscv64 -machine virt -nographic -bios mmu.elf

预期输出:

log复制

ini 复制代码
[MMU] Sv39 enabled, satp=0x8000000000801000
[MMU] VA 0x80000000 <-> PA 0x80000000 : OK
[MMU] UART works through MMU

值得说明的是,页表项的权限位、A/D 位与大页支持在不同厂商的 RV64 SoC 内核实现上保持一致,但具体支持的物理内存范围与平台内存布局存在差异,权威描述以各家处理器内核手册为准,例如玄铁 C 系列处理器内核手册对 MMU 物理内存属性与平台相关细节有系统阐述,相关资料可在玄铁资源中心获取。

六、Linux 视角:地址空间如何长成

Linux 内核启动早期即在 head.S 中建立临时页表并开启 MMU(此阶段细节可回溯本系列启动流程一文)。进入 C 代码后,内核建立完整的内核页表;每个用户进程通过 mm_struct 维护独立的地址空间。fork 时子进程共享父进程页表并标记只读,触发写时复制后在缺页路径完成物理页复制。

调度器切换进程时写入新进程的 satp(含独立 ASID),使 TLB 得以保留并命中其他地址空间的缓存项。Sv39 下 Linux 的典型地址空间布局如下:

虚拟地址区间 归属 典型用途
0x0000000000000000 - 0x0000003fffffffff 用户空间 低 256GiB,进程代码、堆、栈、共享库
0xffffffe000000000 - 0xffffffffffffffff 内核空间 线性映射、vmalloc、模块与 fixmap 区

当进程访问未映射地址或权限不足的页面时,硬件报告页故障,内核依据 stval 与 VMA(虚拟内存区域)判定是合法缺页、写时复制还是非法访问。非法访问将向进程发送 SIGSEGV------这正是裸机程序与操作系统在内存语义上的根本分野。

七、常见坑位与调优建议

  • satp.MODE 未生效:写入 satp 前未确认 MODE 字段取值正确(Sv39 为 8),或根页表未按 4KB 对齐导致 PPN 被截断。开启后立即读取 satp 校验。
  • 忘记 sfence.vma:页表更新后未刷新 TLB,新旧映射并存,表现为偶发页故障或访问到陈旧数据。修改映射后务必按粒度执行 fence。
  • 权限位配置遗漏 :U 模式程序访问未置 U 位的页面,或内核访问用户页时未设置 sstatus.SUM,均产生页故障。逐位核对 R/W/X/U。
  • A/D 位缺失影响回收:Linux 依赖 A/D 位追踪页面使用状态,若平台未正确置位,内存回收与换页行为将异常并伴随性能劣化。
  • 小页过多导致 TLB 抖动:内核线性映射区建议使用 2MB 甚至 1GB 大页映射,可显著降低 TLB 未命中率,属收益最明显的调优点。

八、总结

本文沿"地址翻译---页表结构---TLB 一致性---进程地址空间"的主线,系统解析了 RISC-V 虚拟内存机制的完整脉络,并通过 QEMU virt 平台的实战验证了从手工建表到开启 MMU 的端到端路径。值得反复咀嚼的三个结论:

  • Sv39 的心智模型是 9+9+9+12:三段索引定位页表项,页内偏移定位物理地址,一切翻译皆由此展开;
  • 硬件翻译、软件一致:RISC-V 把 TLB 刷新义务交给软件,sfence.vma 的恰当执行是正确性的前提;
  • MMU 是一切系统内存机制的底座:进程隔离、COW、按需分页与共享库,均是页表之上的工程表达。

本系列至此已覆盖启动流程、中断子系统与虚拟内存三大底层主题。后续可进一步探索上下文切换与调度器、AIA 中断架构,或深入真实 SoC 平台验证多核场景下的 TLB 冲刷与页表性能优化。

相关推荐
步行cgn16 分钟前
@SpringBootTest 详解:Spring Boot 测试的核心注解
后端
对象存储与RustFS21 分钟前
给 RustFS 拆多租户权限:IAM 用户、组与策略的实战
后端·rust·开源
ZGG0031 小时前
MCP 02:MCP 的各类比对——和 Function Calling、A2A、Skill 到底什么关系
人工智能·后端·python
IT_陈寒1 小时前
Vite热更新突然失效?可能是这个配置在捣鬼
前端·人工智能·后端
SelectDB技术团队2 小时前
统一全文检索与 SQL 分析:Apache Doris 日志分析实践
大数据·数据结构·后端·python·全文检索·doris·日志分析
霸道流氓气质2 小时前
Spring AI多模型路由与动态切换
java·后端·spring
艺杯羹2 小时前
全栈信创落地实录:基于银河麒麟V10与达梦数据库DM8的SpringBoot工业级适配指南
java·数据库·spring boot·后端·spring
学长毕业设计10 小时前
基于SpringBoot的公益基金管理系统(源码+文档+讲解视频)
java·spring boot·后端
东小西10 小时前
【SAA实战】第 3 篇 · 工具调用全攻略:把业务能力交给 Agent 自己调度
java·后端·spring