RISC-V Linux内核启动:relocate汇编函数与MMU页表切换深度解析
1. 项目概述与核心价值
最近在调试一个基于RISC-V架构的嵌入式Linux内核启动问题,卡在了relocate这个汇编函数上。现象很典型:内核在开启MMU(内存管理单元)的瞬间就“死”了,没有任何错误输出。这让我不得不重新深入梳理了一遍Linux内核启动早期,从物理地址到虚拟地址切换的完整逻辑。relocate,这个常常被一笔带过的启动重定向过程,实际上是内核能否成功“起跳”到虚拟内存世界的关键一跃。它涉及汇编、MMU页表、异常处理等多个底层概念的精密配合,任何一个细节的疏漏都会导致启动失败。
这篇文章,我就结合RISC-V平台的源码(以Linux 5.10+为例),彻底拆解relocate的汇编实现。我会重点解释为什么需要两次开启MMU,trampoline_pg_dir和early_pg_dir这两张临时页表各自扮演什么角色,以及计算地址偏移、设置异常向量这些看似晦涩的操作背后的深刻用意。无论你是正在学习操作系统底层,还是和我一样在解决具体的启动问题,希望这篇基于实战的深度分析,能帮你建立起对内核启动初期内存管理切换的清晰图景。
2. 核心概念与前置知识解析
在深入代码之前,我们必须统一几个核心概念,这是理解后续所有操作的基础。很多启动失败的问题,根源就在于对这些概念的模糊或误解。
2.1 MMU、虚拟地址与物理地址
MMU是CPU中的一个硬件单元,它的核心工作是进行地址翻译。程序代码(包括内核)中使用的地址(虚拟地址,VA)需要经过MMU的翻译,才能找到真正的物理内存位置(物理地址,PA)。开启MMU,就是告诉CPU:“从现在开始,所有内存访问都要经过翻译”。
在开启MMU的瞬间,会发生一个根本性的变化:CPU取指令的地址行为改变了。假设当前执行指令的物理地址是0x80200000,其对应的虚拟地址可能是0xffffffc080200000。在MMU开启前,pc寄存器指向0x80200000。当一条写satp寄存器(RISC-V中控制MMU的寄存器)的指令执行后,MMU立即生效。此时,下一条指令的pc值在硬件上会被当作虚拟地址处理。如果MMU页表没有正确建立0xffffffc080200000到0x80200000的映射,CPU就会取指错误,通常触发一个访问异常。如果异常处理程序也没准备好,系统就彻底挂起。
2.2 页表与satp寄存器
页表是存放在内存中的数据结构,定义了虚拟地址到物理地址的映射关系。RISC-V的satp寄存器是MMU的“总开关”,其结构如下:
| 63 60 | 59 44 | 43 0 | |------------|---------------------|-------------------------------------| | MODE | ASID | PPN (页表基址) |- MODE: 决定MMU模式。
0表示关闭MMU(Bare模式),8表示Sv39模式(39位虚拟地址)。我们主要关注这个字段。 - PPN: 存放根页表(一级页表)的物理页号。所谓“开启MMU”,本质上就是将根页表的物理地址右移12位(因为低12位是页内偏移)后,与MODE字段组合,写入
satp寄存器。
2.3 临时页表:trampoline_pg_dir 与 early_pg_dir
内核在启动初期,没有完整的内存管理设施,无法动态创建复杂的页表。因此,它使用了两张静态编译到内核镜像中的临时页表:
- trampoline_pg_dir: 直译为“蹦床页目录”。它的映射极其简单,通常只恒等映射内核代码开头的一小段物理内存(例如前2MB)到相同的虚拟地址。所谓“恒等映射”,就是VA = PA。它的唯一使命,就是安全地度过第一次开启MMU后执行的那几条关键指令,像一个“蹦床”一样把CPU弹到下一个稳定状态。
- early_pg_dir: 早期页目录。它在
trampoline_pg_dir的基础上,建立了内核运行所需的完整早期虚拟地址空间映射。这包括将内核的代码、数据段映射到高地址(如0xffffffc080200000),可能还包括设备树DTB所在的物理内存区域。这张页表是内核在setup_vm()函数中建立的,是内核进入C语言世界并初始化完整内存管理之前所依赖的“临时住所”。
理解这两张表的关系是理解relocate的关键:先用trampoline_pg_dir安全地打开MMU大门,然后立即切换到功能完备的early_pg_dir上。
3. relocate汇编代码逐行深度解析
现在,我们来到最核心的部分,结合RISC-V汇编和源码进行逐行分析。我将以Linux内核中arch/riscv/kernel/head.S的relocate函数为蓝本进行讲解。
3.1 计算运行时地址偏移量
/* Relocate return address */ li a1, PAGE_OFFSET la a2, _start sub a1, a1, a2 add ra, ra, a1- 目的: 修正
ra(返回地址寄存器)的值,使其在MMU开启后(虚拟地址空间生效)依然有效。 - 详解:
PAGE_OFFSET: 这是一个内核配置的常量,代表内核虚拟地址空间的起始地址。例如在RISC-V的Sv39中,可能是0xffffffc000000000。_start: 内核镜像的起始虚拟地址,也是一个链接时确定的常量。注意,_start是虚拟地址,但内核一开始是以物理地址加载运行的。sub a1, a1, a2: 计算PAGE_OFFSET - _start。这得到了内核的“虚拟地址偏移基数”。因为内核被链接到高虚拟地址运行,但其物理加载地址可能很低(如0x80200000)。这个差值就是物理地址到其预期运行虚拟地址的固定偏移。add ra, ra, a1: 当前的ra寄存器里保存的是调用relocate函数的返回地址,但这个地址是物理地址。加上偏移量a1后,就将它转换成了对应的虚拟地址。这样,当relocate函数执行ret指令返回时,程序就能跳转到正确的虚拟地址位置继续执行。
实操心得: 这里最容易混淆的是“链接地址”和“加载地址”。
_start是链接脚本里定义的,是“我希望内核在哪里运行”;而内核实际被加载的物理地址是“它现在实际在哪”。relocate的核心工作之一就是弥合这个差距。在调试时,如果发现开启MMU后返回出错,首先应该检查PAGE_OFFSET和_start的定义是否符合你的内存布局。
3.2 预置异常处理入口
/* Point stvec to virtual address of intruction after satp write */ la a2, 1f add a2, a2, a1 csrw CSR_TVEC, a2- 目的: 为第一次开启MMU可能立即触发的异常做好准备。
- 详解:
la a2, 1f: 将标签1:所在位置的下一条指令的**当前地址(物理地址)**加载到a2。add a2, a2, a1: 同样,将这个地址加上偏移量,计算出它对应的虚拟地址,存入a2。csrw CSR_TVEC, a2: 将a2(即1:标签处的虚拟地址)写入stvec寄存器。stvec是RISC-V的异常入口基址寄存器。当异常(例如取指或访存错误)发生时,CPU会跳转到stvec指向的地址执行。- 为什么这么做?因为第一次用
trampoline_pg_dir开启MMU后,当前pc之后的指令地址会被MMU当作虚拟地址翻译。如果trampoline_pg_dir的映射设置错误(比如VA != PA),那么对下一条指令的取指就会失败,触发异常。此时,CPU就会跳转到我们刚刚设置的、位于1:标签处的异常处理程序。这是一个极其精巧的“安全网”设计。
3.3 计算最终页表的satp值
/* Compute satp for kernel page tables, but don't load it yet */ srl a2, a0, PAGE_SHIFT li a1, SATP_MODE or a2, a2, a1- 目的: 预先计算好切换到
early_pg_dir页表时需要写入satp的值,并暂存在a2寄存器中,以备后续使用。 - 详解:
- 进入
relocate时,调用者已经将early_pg_dir的物理地址传入了a0寄存器。 srl a2, a0, PAGE_SHIFT:PAGE_SHIFT通常为12(因为一页4KB)。这条指令将页表基地址右移12位,得到物理页号(PPN),存入a2。这是satp寄存器要求的格式。li a1, SATP_MODE: 加载MMU模式,对于Sv39,SATP_MODE就是0x8。or a2, a2, a1: 将PPN和MODE位组合,形成最终要写入satp的完整值,结果保存在a2中。注意,此时并不写入satp。
- 进入
3.4 第一次开启MMU:使用trampoline页表
这是整个流程中最惊险的一步。
la a0, trampoline_pg_dir srl a0, a0, PAGE_SHIFT or a0, a0, a1 sfence.vma csrw CSR_SATP, a0- 计算trampoline页表的satp值: 和上一步类似,获取
trampoline_pg_dir的物理地址,计算其PPN,并与MODE组合,结果存入a0。 sfence.vma: 这是一条非常重要的内存屏障指令。它确保在此指令之前的所有页表更新(即setup_vm()函数对trampoline_pg_dir的写入)对后续的MMU操作是可见的。没有这条指令,CPU可能使用旧的、未初始化的页表项进行地址翻译,导致不可预知的错误。csrw CSR_SATP, a0:关键操作!将trampoline_pg_dir的satp值写入satp寄存器。就在这条指令退休的瞬间,MMU被正式开启。
此时,CPU对下一条指令的取指就会使用trampoline_pg_dir进行地址翻译。如果trampoline_pg_dir正确建立了当前执行流所在内存区域的恒等映射(VA == PA),那么CPU会顺利取到指令,继续向下执行。如果映射错误,则会触发异常,跳转到之前设置在stvec中的地址(即1:标签处)。
3.5 异常处理与第二次开启MMU
.align 2 1: /* Set trap vector to spin forever to help debug */ la a0, .Lsecondary_park csrw CSR_TVEC, a0 /* Reload the global pointer */ .option push .option norelax la gp, __global_pointer$ .option pop /* Switch to kernel page tables. */ csrw CSR_SATP, a2 sfence.vma ret- 标签
1:: 这就是之前预设的异常入口地址。无论第一次开启MMU是否触发异常,CPU都会继续执行到这里。如果没有异常,是顺序执行到达;如果触发了异常,则是异常处理后跳转回来。这是一个统一的汇合点。 - 重置异常向量: 将
stvec设置为.Lsecondary_park。这是一个简单的死循环(通常包含wfi指令)。这样做的目的是,如果后续操作(尤其是第二次开启MMU)再发生异常,CPU会陷入这个循环,方便调试定位问题,而不是产生不可控的行为。 - 重载全局指针: 重新加载
gp寄存器。因为地址空间已经切换,之前基于物理地址计算的gp值可能失效,需要根据新的虚拟地址空间重新计算。 - 第二次开启MMU:
csrw CSR_SATP, a2。这里写入satp的a2,就是我们在第3.3步预先计算好的、基于early_pg_dir的值。这条指令执行后,MMU的页表基址就从trampoline_pg_dir切换到了early_pg_dir。 - 再次内存屏障:
sfence.vma。确保本次satp的更新以及对应的新页表early_pg_dir对所有后续操作立即可见。 - 返回:
ret。此时ra寄存器已经在第3.1步被修正为虚拟地址,MMU也使用了映射完整的early_pg_dir页表。因此,这次返回将正确地跳转到内核的虚拟地址空间继续执行,标志着relocate过程圆满完成。
4. 关键问题与调试技巧实录
在实际移植和调试中,relocate阶段是问题高发区。下面我总结几个最常见的问题和排查思路。
4.1 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 开启MMU后立即死机,无任何输出 | 1.trampoline_pg_dir映射错误。2. 第一次 csrw satp后,下一条指令的VA无映射或映射错误。 | 1. 检查setup_vm()中trampoline_pg_dir的初始化代码,确认其是否恒等映射了内核入口点附近至少2MB的物理内存。2. 使用仿真器或调试器,在 csrw satp指令处设断点,单步执行,观察是否触发异常或PC值是否跳转到异常处理程序(1:标签处)。 |
在1:标签处之后死机(如停在.Lsecondary_park) | 1.early_pg_dir页表映射错误。2. 内核代码/数据段未正确映射到高虚拟地址。 3. DTB区域未映射或映射错误。 | 1. 仔细检查setup_vm()中early_pg_dir的构建逻辑,特别是kernel_map和dtb_map区域。2. 核对链接脚本( vmlinux.lds)中内核的虚拟地址布局,与PAGE_OFFSET等常量是否匹配。3. 确认传递给内核的DTB物理地址是否正确,以及在 early_pg_dir中是否为其建立了可读映射。 |
| 返回后出现非法指令或数据访问错误 | 1. 返回地址ra修正错误。2. gp全局指针未正确重载。3. early_pg_dir页表权限设置错误(如代码段不可执行)。 | 1. 检查PAGE_OFFSET和_start的计算是否正确,确保ra修正后的地址是有效的内核虚拟地址。2. 确认 __global_pointer$符号定义正确,且重载gp的代码在地址空间切换后执行。3. 检查页表项中的 X(可执行)、W(可写)、R(可读)权限位设置。 |
4.2 调试技巧与实操心得
1. 利用QEMU和GDB进行单步跟踪这是最强大的调试手段。在QEMU启动时加入-s -S参数,然后通过GDB连接。
# Terminal 1 qemu-system-riscv64 -machine virt -kernel ./arch/riscv/boot/Image -nographic -s -S # Terminal 2 riscv64-linux-gnu-gdb vmlinux (gdb) target remote localhost:1234 (gdb) b *relocate # 在relocate函数入口设断点 (gdb) c (gdb) layout asm # 查看汇编代码 (gdb) si # 单步执行汇编指令在csrw satp, a0指令执行前后,重点观察pc寄存器的值变化,以及是否跳转到stvec。同时,可以打印satp寄存器和相关页表内存的内容,验证其值是否符合预期。
2. 打印关键变量和地址在内核启动早期,printk可能还不可用,但可以通过修改汇编代码,将关键值存入某个寄存器或特定内存位置,然后在QEMU中通过监视点或内存查看来获取。例如,可以在relocate中计算完偏移量a1后,将其值存入一个预留的全局变量,在后续初始化完成的代码中再打印出来。
3. 核对链接脚本与映射逻辑这是预防性调试的关键。务必确保arch/riscv/kernel/vmlinux.lds.S中定义的内核加载地址(LOAD_ADDR)和虚拟地址(_start符号位置)与arch/riscv/include/asm/page.h中定义的PAGE_OFFSET等宏协调一致。一个典型的错误是链接地址和PAGE_OFFSET不匹配,导致计算出的偏移量a1错误。
4. 理解“恒等映射”的精确含义trampoline_pg_dir的恒等映射,并不仅仅是“VA = PA”。它必须精确覆盖从csrw satp指令之后,到安全切换到early_pg_dir之前,这段代码执行流所访问的所有指令和数据所在的物理内存范围。这通常包括relocate函数尾部以及1:标签后的若干条指令。在64位系统上,如果内核被加载到物理地址0x80200000,那么trampoline_pg_dir可能需要建立0x80200000到0x80200000(或一个对应的低虚拟地址)的映射,并且这个映射的虚拟地址必须与pc计算出的下一个指令地址相匹配。这里的概念非常微妙,需要结合具体架构的MMU翻译流程来理解。
5. 页表建立流程的关联分析
relocate能否成功,完全依赖于setup_vm()函数是否正确建立了那两张临时页表。这里简要分析其关键点,作为relocate分析的补充。
setup_vm()通常在relocate之前,由汇编代码调用。它的核心工作有两个:
- 创建 trampoline_pg_dir: 为内核起始的物理内存(例如
load_pa开始的2MB)创建恒等映射。这个映射的虚拟地址基址选择很有讲究,在RISC-V中通常使用一个专门的低地址区域(如CONFIG_PAGE_OFFSET对应的某个固定偏移),确保在开启MMU后,CPU能无缝地继续执行接下来的几条指令。 - 创建 early_pg_dir:
- 内核映射: 将内核的代码、数据、BSS等段,从它们的物理地址
load_pa,映射到高虚拟地址load_pa + PAGE_OFFSET。这是内核预期运行的虚拟地址。 - DTB映射: 将设备树Blob(DTB)所在的物理内存区域映射到一块固定的虚拟地址,以便内核早期代码可以解析设备树。
- 可能的内存映射: 有时还会提前映射一些早期的I/O内存。
- 内核映射: 将内核的代码、数据、BSS等段,从它们的物理地址
避坑指南:
early_pg_dir的映射范围一定要足够。除了内核镜像本身,还要考虑内核启动后立即访问的初始数据、栈空间等。一个常见的错误是只映射了.text代码段,而忽略了.data或.bss段,导致内核刚进入C语言环境就发生数据访问错误。务必根据链接脚本中各个段的结束地址来计算映射的结束边界。
relocate汇编重定向过程,是操作系统内核从物理地址的“蛮荒世界”迈入虚拟地址“文明时代”的临门一脚。它通过精心设计的两段式页表切换(trampoline_pg_dir->early_pg_dir)和预置的异常处理安全网,实现了这一切换的平滑与稳健。理解这个过程,不仅对解决内核启动问题至关重要,更是深入理解计算机系统如何管理内存的绝佳范例。下次当你看到内核在开启MMU后安静地继续执行时,你会知道,在这背后发生了一场多么精密而优雅的地址空间“魔术”。
