RISC-V 64 位 Linux 虚拟内存布局详解:以 Sv39 为例

先把 Sv39 的地址规则看懂,再看 Linux 为什么把内核虚拟地址切成一层层固定区域。

导读

RISC-V 的虚拟内存布局,第一次看很容易只记住几个名词:PAGE_OFFSETvmallocvmemmapfixmapmodules。但如果不先理解 Sv39 的地址规则,这些名字会显得像一串彼此孤立的符号。

这篇文章只解决一件事:Linux 6.11 在 RISC-V 64 位上,为什么要把虚拟地址空间按 Sv39 的规则切成上下两半,并且把内核相关区域固定排在高地址一侧。

本文以 Documentation/arch/riscv/vm-layout.rstarch/riscv/mm/init.carch/riscv/include/asm/pgtable.harch/riscv/kernel/head.S 为主线,面向已经知道内核基本启动流程、但还没有系统梳理过内核虚拟地址布局的读者。

先看 Sv39 的规则

Sv39 的关键不是“39 位地址”这四个字,而是canonical address 规则。

对于 64 位 RISC-V,Sv39 只真正使用 39 个虚拟地址位。也就是说,bit 38 才是最高有效地址位,bit 63 到 bit 39 必须全部复制 bit 38。只要不满足这个符号扩展规则,地址就是非 canonical,CPU 会直接报页错误。

这会自然把地址空间分成两块:

  • 低半区:bit 38 为 0,给用户态地址使用
  • 高半区:bit 38 为 1,给内核和共享的内核虚拟区使用

中间那一大段地址不是“空闲”,而是非 canonical 的洞。这就是为什么 Sv39 看起来像“上下两层,中间隔着巨大空洞”。

Linux 在 arch/riscv/include/asm/processor.h 里也按这个边界约束用户态 mmap 的返回范围。源码不是靠经验猜地址,而是直接围绕 BIT(VA_BITS - 1) 这条界线做控制。

Sv39 下的总布局

Documentation/arch/riscv/vm-layout.rst 里,Sv39 的布局顺序是固定的。实际起始地址可能受 KASLR 影响,但区域顺序和大小关系不变

可以把 64 位 RISC-V Linux 的内核虚拟地址从高到低理解成这样:

kernel mapping
modules / BPF mapping
kasan shadow (如果启用)
direct map
vmalloc / ioremap
vmemmap
PCI I/O
fixmap
non-canonical hole
user space

如果把它换成更接近源码的表达,arch/riscv/include/asm/pgtable.h 里的关系大致就是:

  • VMALLOC_END = PAGE_OFFSET
  • VMALLOC_START = PAGE_OFFSET - VMALLOC_SIZE
  • VMEMMAP_END = VMALLOC_START
  • VMEMMAP_START = VMALLOC_START - VMEMMAP_SIZE
  • PCI_IO_END = VMEMMAP_START
  • FIXADDR_TOP = PCI_IO_START
  • MODULES 区域占据内核映射前面的 2GB 窗口

这个顺序不是随便排的。它体现的是 Linux 对每类地址的用途分层:

  • direct map 负责把物理内存线性映射成内核可直接访问的虚拟地址
  • vmalloc/ioremap 负责稀疏、非连续的内核虚拟映射
  • vmemmap 负责 struct page 数组的虚拟地址空间
  • fixmap 负责启动早期那些“地址必须固定,但物理页稍后再绑定”的入口
  • PCI I/O 给 PCI I/O 空间保留固定窗口
  • modules / BPF 给可加载代码留独立区域
  • 最顶部的 kernel mapping 则是内核镜像本身的高地址映射

为什么要这样切

Linux 这样切地址空间,核心原因不是“好看”,而是每一层都在解决不同的问题。

direct map 是最快的路径。它把 DRAM 直接按固定偏移映射到内核虚拟地址,适合快速做 __va() / __pa() 转换和大多数常规内存访问。

vmalloc 不是给连续物理内存用的,而是给“虚拟连续、物理不连续”的对象用的。ioremap()、大块动态内核映射、某些调试和特性路径都依赖这里。

vmemmapstruct page 的家。对于 sparsemem-vmemmap,内核要有一段足够大的固定虚拟区来容纳页描述符数组,所以它被直接放在 vmalloc 下面。

fixmap 的作用更特殊:它要求虚拟地址在编译期固定,物理页在启动时再决定。这也是早期 FDT 解析、early ioremap、临时页表访问最常用的工具。

modulesBPF 之所以单独留出 2GB,是因为它们属于可加载代码区,和直接映射的物理内存不是同一种概念。

关键源码怎么对应

下面这几行关系,是理解 RISC-V 64 位布局最有用的一组“骨架”:

PAGE_OFFSET
  -> direct map 起点
  -> vmalloc 终点
  -> vmemmap、PCI I/O、fixmap 的锚点

KERNEL_LINK_ADDR
  -> 内核镜像链接到的高地址基准

MODULES_VADDR / MODULES_END
  -> modules / BPF 的 2GB 窗口

FIXADDR_START / FIXADDR_TOP
  -> fixmap 的固定虚拟地址区

如果你把 arch/riscv/mm/init.c 里的 print_vm_layout()arch/riscv/mm/ptdump.c 里的地址标记放在一起看,会发现它们打印的顺序和 pgtable.h 的布局顺序完全一致。也就是说,源码并不是“临时拼起来的”,而是有一套稳定的分层设计。

早期页表如何走到最终布局

Sv39 的布局不是一上电就完整存在的。Linux 先靠早期页表把自己送到可以继续建表的状态,然后才把完整布局搭出来。

arch/riscv/mm/init.c 里,setup_vm() 负责早期阶段。它会先决定最终采用哪种 satp 模式。对 64 位非 XIP 内核来说,代码会先尝试更高层级分页;如果硬件不接受,再退回到 Sv39。也就是说,Sv39 是一个可靠的基线布局,而不是所有机器上的默认上限。

setup_vm() 做的事大致可以概括为:

choose satp mode
build early_pg_dir
build trampoline_pg_dir
map kernel image
map fixmap
map early FDT
prepare enough page-table access to reach paging_init()

这里最重要的设计点有两个。

第一,早期页表必须能在 MMU 关闭的上下文里被建立,所以它依赖 PC-relative 访问方式和很少的前置条件。

第二,fixmap 在这里不是配角,而是早期访问页表、FDT 和临时映射的桥梁。setup_vm() 里先把 FIXADDR_START 对应的页表搭起来,后面才能靠固定虚拟地址去访问临时需要的物理页。

真正把系统送进最终布局的是 setup_vm_final()。它在 setup_bootmem() 之后执行,这时 Linux 已经知道了真实的系统内存布局,于是可以做三件更完整的事:

  • 建立 swapper_pg_dir 下的线性映射
  • 把 64 位内核镜像本身也映射到最终高地址区
  • 清理早期 fixmap 的临时页表,并切换到最终 satp

对应到 head.Srelocate_enable_mmu() 的作用就是在切换 satp 的那个瞬间,先把返回地址和异常向量放到正确的虚拟地址上,再借助 trampoline 页表平稳跨过去。它解决的不是“怎么建表”,而是“怎么在换表的瞬间不把 CPU 丢进地址黑洞”。

最容易混淆的两个点

第一个是 kernel mappingdirect map

它们不是同一件事。direct map 是物理内存的线性映射;kernel mapping 是内核镜像本身的高地址映射。前者服务于物理内存访问,后者服务于内核文本和数据段的稳定执行。Linux 在 64 位上把它们分开,正是为了让内核镜像不被绑死在物理内存起点上。

第二个是“虚拟地址空间很大”不等于“每一段都能随便用”。

Sv39 的 64 位地址里,真正可用的 canonical 地址只有上下两块。Linux 的这些固定区域是在这两块里做分工,不是在整片 64 位地址上任意铺开。理解这一点,就不会把 vmallocvmemmapfixmapmodules 看成互相冲突的重复空间。

小结

如果用一句话总结 Sv39 下的 RISC-V Linux 虚拟内存布局,我会说:

它不是“把所有地址都填满”,而是利用 canonical 规则,把高半区切成几个职责清晰的固定模块,让内核先有落脚点,再有完整内存视图。

读源码时,先记住三件事就够了:

  1. 1. Sv39 的地址空间天然分成用户态低半区和内核高半区,中间是 non-canonical 大洞。
  2. 2. pgtable.h 负责布局规则,head.S 负责跨过 MMU 切换,init.c 负责把最终页表搭完整。
  3. 3. direct mapvmallocvmemmapfixmapPCI I/Omoduleskernel mapping 是一套分层设计,不是平铺的地址清单。

如果你下一步想继续读,可以直接顺着 arch/riscv/mm/init.csetup_vm()setup_vm_final()arch/riscv/kernel/head.Srelocate_enable_mmu() 往下跟。那条链路会把这张布局图真正变成能运行的内核。

吉ICP备2024014750号-1 备案图标 京公网安备11011202100605号