RISC-V 上 Linux 的早期页表:内核为什么要先搭一张临时地图

从内核设计者的角度,看 Linux 6.11 在 RISC-V 上如何靠 setup_vm()relocate_enable_mmu()setup_vm_final() 把自己送进完整的虚拟内存世界。

导读

如果只用一句话概括 RISC-V 上 Linux 的早期页表,我会说:

它不是为了“更早用上虚拟内存”,而是为了让内核在地址规则即将改变的时候,仍然有地方落脚。

这听起来有点抽象,但它其实是在解决一个很现实的矛盾。

Linux 启动时,CPU 还处在 MMU-off 的状态,地址就是物理地址。可 Linux 一旦真正进入内核世界,就希望尽快切到虚拟地址空间:这样才能建立统一的内核视图、隐藏物理内存布局、准备线性映射、fixmap、vmalloc、模块区域,以及后续一切内存管理工作。

问题是,你不能一边改变地址解释规则,一边要求正在执行的代码不受影响。这就是早期页表存在的原因。

在 RISC-V 上,早期页表的任务不是“把所有东西都映射好”,而是只做最少但最关键的几件事:

  • 让内核在 satp 切换之后还能继续跑下去
  • 让内核代码、栈和关键数据在切换前后都可达
  • 让设备树 DTB 这类早期输入在还没有完整线性映射时也能被读取
  • 让后续的最终页表搭建有一个安全的起点

所以从设计者角度看,早期页表不是补丁,而是 Linux 自举过程里的第一块基础设施。

先看结论:Linux 不是一口气建完页表的

RISC-V 的启动路径可以压缩成下面这条线:

head.S:_start_kernel
    -> setup_vm()
    -> relocate_enable_mmu()
    -> start_kernel()
    -> paging_init()
    -> setup_vm_final()

这条链路里,setup_vm() 负责把系统从“还不能放心使用虚拟地址”的状态,推到“已经能切进 MMU,但还没进入最终内存布局”的中间态;setup_vm_final() 才负责把真正完整的内核地址空间搭起来。

也就是说,Linux 在 RISC-V 上至少经历了三种状态:

  1. 1. MMU 关闭,只依赖物理地址
  2. 2. 过渡期,靠早期页表和 trampoline 继续执行
  3. 3. 稳定期,切到 swapper_pg_dir,进入最终地址空间

理解这一点之后,后面的实现就顺了。

为什么不能直接上最终页表

这个问题特别适合从“内核设计”角度来回答。

如果你站在内核设计者的立场上,会发现启动时有三个约束同时存在:

1. 你还不知道最终内存布局长什么样

在真正完成 setup_bootmem() 之前,Linux 还没有拿到完整的内存银行信息,也不能随便把所有区域都按最终方案建完。setup_vm_final() 需要结合 memblock 和系统内存布局来做更合理的映射,而这一步显然不能放在最早期。

2. 你必须先验证硬件支持哪种分页模式

RISC-V 的 satp 模式不只有一种。Linux 6.11 的 64 位内核要面对 Sv39、Sv48、Sv57 这些不同层级。set_satp_mode() 里最有意思的地方,就是它不是先假设答案,而是先搭一个小的身份映射,再写 satp 读回来确认硬件到底接受哪一级。源码里的思路很直接:页表本身成了能力探针

3. 你还不能依赖“正常的内核世界”

这时候普通分配器、线性映射、vmalloc、甚至某些调试路径都还没准备好。也就是说,页表的搭建过程,不能再去依赖已经建好的页表。

这就是早期页表的精髓:用极少的前置条件,把自己送到可以建立前置条件的地方。

三张表,各干各的活

RISC-V 这套实现里,至少有三张表值得记住:

  • early_pg_dir
  • trampoline_pg_dir
  • swapper_pg_dir

它们不是重复劳动,而是三个阶段各自使用的工具。

early_pg_dir:让内核先“站起来”

early_pg_dir 出现在 arch/riscv/mm/init.c 里,它的目标不是长期驻留,而是保证内核从 head.S 进入 C 代码、再切到虚拟地址时不掉下去。

setup_vm() 会用它去映射:

  • 当前内核镜像自身
  • fixmap 所在的固定虚拟区
  • 早期要读取的 FDT

这张表的原则很朴素:够用就行,但必须可靠。

trampoline_pg_dir:跨过 satp 切换时的落脚点

relocate_enable_mmu()head.S 里做的事情非常像“跳板”。

它先把返回地址和 stvec 调整到虚拟地址世界里,然后把 satp 指向 trampoline_pg_dir。这样一来,satp 一旦生效,CPU 仍然有一小段可执行路径继续走下去,不会因为地址解释突然变化而迷失。

这个跳板的作用不是“提供更多映射”,而是提供一个可控的过渡瞬间

swapper_pg_dir:真正的最终地址空间

paging_init() 之后,Linux 会进入 setup_vm_final(),开始构造真正的内核页表。

这时候才会把:

  • 线性映射
  • 最终的 fixmap
  • 64 位下的内核映射

一并整理进 swapper_pg_dir,然后把 satp 切过去。

从此以后,早期页表可以退场了。

setup_vm() 里最值得读的不是映射量,而是思路

setup_vm() 的代码很长,但真正值得初学者抓住的,不是“它映射了多少页”,而是“它为什么这样分阶段做”。

它大致做了这几件事:

setup_vm(dtb_pa):
    choose satp mode
    compute kernel virtual address layout
    build early_pg_dir / trampoline_pg_dir
    map kernel image and FDT
    prepare fixmap
    return to head.S

这里最关键的设计味道有两个。

第一个味道:先探测能力,再确定布局

set_satp_mode() 不是简单写个固定常量,而是先根据命令行和硬件行为判断当前应该用哪一级分页。源码里甚至会故意构造一个临时 identity mapping 来验证硬件是否真正接受了更高层级。

这说明 Linux 在这里考虑的不是“理论上能不能”,而是“机器现在到底能不能安全接住”。

第二个味道:把“建立页表”和“访问页表”分开处理

create_pgd_mapping() 的抽象很漂亮。

它不是直接假定自己已经能随便解引用所有层级,而是通过 pt_ops 去决定:

  • 早期阶段怎么分配页表页
  • 中间阶段怎么借助 fixmap 临时访问物理页
  • 后期阶段怎么交给常规内存分配路径

从设计上说,这比在每个阶段复制一份建表逻辑要干净得多。代码只有一套,底层访问方式按阶段切换。

fixmap 为什么这么重要

fixmap 是早期页表里另一个特别值得理解的点。

arch/riscv/include/asm/fixmap.h 里定义了一组编译期固定的虚拟地址,它们的意义不是“这些地址永远对应同一个物理页”,而是:

这些虚拟地址永远存在,至于映射到哪一页,可以在启动过程里再决定。

这就给了内核一个非常宝贵的能力:在还没有完整线性映射之前,也能用固定虚拟地址临时访问某些物理页。

RISC-V 上的早期 FDT 读取就是靠这个思路完成的。setup_vm() 会为 FIX_FDT 建立早期映射,让内核在进入正式内存初始化之前,就能解析设备树。

这类设计的本质是:先保住入口,后补全全局视图。

relocate_enable_mmu():真正危险的那一刻

如果说 setup_vm() 是铺路,那 relocate_enable_mmu() 就是跨桥。

arch/riscv/kernel/head.S 里,这段逻辑做了几件事:

  • 先把返回地址重定位到虚拟地址世界
  • stvec 指向切换后的落点
  • 计算即将写入的 satp
  • 先切到 trampoline_pg_dir
  • 再进入新的执行位置

你可以把它理解成下面这段伪代码:

stvec = address_after_satp_write;
satp  = trampoline_pg_dir;
sfence.vma();
write_satp(satp);
continue_execution();

这里最重要的不是“切了 satp”,而是切完以后还能继续执行正确的下一步

早期页表如果没有把这个过渡点设计好,内核会在最脆弱的时刻直接失去代码落点。

setup_vm_final():早期页表只是桥,不是终点

真正完整的地址空间是在 setup_vm_final() 里搭起来的。

这一步会做的事很像“正式装修”:

  • 把 fixmap 迁到 swapper_pg_dir
  • 建立线性映射
  • 64 位下再补上内核映射
  • 清掉临时的 fixmap 叶子页表
  • satp 切到最终的 swapper_pg_dir
  • 让页表分配辅助接口进入晚期模式

源码里有一个很重要的信号:paging_init() 先调用 setup_bootmem(),再调用 setup_vm_final()。这说明最终页表不是凭空生成的,它是建立在“系统内存布局已经知道了”这个前提上的。

这也解释了为什么早期页表不能设计得太重。

它要做的是:

  • 活下来
  • 过渡过去
  • 把控制权交给最终页表

而不是提前替最终页表做完全部工作。

一个更好记的理解方式

如果你刚开始读 RISC-V 的 Linux 启动代码,我建议你把早期页表记成三句话:

  1. 1. 它不是为了覆盖更多地址,而是为了让内核跨过地址规则改变的瞬间。
  2. 2. 它先解决“我怎么继续执行”,再解决“我怎么完整映射内存”。
  3. 3. 它的任务一旦完成,就会被 swapper_pg_dir 接管。

所以,早期页表真正体现的是一种内核设计思维:

在最不稳定的阶段,不追求完美,只追求可控;

在可控之后,再把系统慢慢补成完整形态。

这就是 Linux 在 RISC-V 上处理早期页表的方式。

如果你想继续往下读源码,下一步最值得看的文件是:

  • arch/riscv/kernel/head.S
  • arch/riscv/mm/init.c
  • arch/riscv/include/asm/fixmap.h

从这三处开始,基本就能把 RISC-V 上 Linux 的早期虚拟内存自举过程串起来。

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