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. MMU 关闭,只依赖物理地址
- 2. 过渡期,靠早期页表和 trampoline 继续执行
- 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_dirtrampoline_pg_dirswapper_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. 它不是为了覆盖更多地址,而是为了让内核跨过地址规则改变的瞬间。
- 2. 它先解决“我怎么继续执行”,再解决“我怎么完整映射内存”。
- 3. 它的任务一旦完成,就会被
swapper_pg_dir接管。
所以,早期页表真正体现的是一种内核设计思维:
在最不稳定的阶段,不追求完美,只追求可控;
在可控之后,再把系统慢慢补成完整形态。
这就是 Linux 在 RISC-V 上处理早期页表的方式。
如果你想继续往下读源码,下一步最值得看的文件是:
arch/riscv/kernel/head.Sarch/riscv/mm/init.carch/riscv/include/asm/fixmap.h
从这三处开始,基本就能把 RISC-V 上 Linux 的早期虚拟内存自举过程串起来。
京公网安备11011202100605号