一、引言:虚拟内存是系统软件的基石
裸机固件与轻量级 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 冲刷与页表性能优化。