1. 项目概述与核心价值最近在调试一个基于RISC-V架构的嵌入式Linux内核启动问题卡在了relocate这个汇编函数上。现象很典型内核在开启MMU内存管理单元的瞬间就“死”了没有任何错误输出。这让我不得不重新深入梳理了一遍Linux内核启动早期从物理地址到虚拟地址切换的完整逻辑。relocate这个常常被一笔带过的启动重定向过程实际上是内核能否成功“起跳”到虚拟内存世界的关键一跃。它涉及汇编、MMU页表、异常处理等多个底层概念的精密配合任何一个细节的疏漏都会导致启动失败。这篇文章我就结合RISC-V平台的源码以Linux 5.10为例彻底拆解relocate的汇编实现。我会重点解释为什么需要两次开启MMUtrampoline_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表示关闭MMUBare模式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 dont 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_SHIFTPAGE_SHIFT通常为12因为一页4KB。这条指令将页表基地址右移12位得到物理页号PPN存入a2。这是satp寄存器要求的格式。li a1, SATP_MODE 加载MMU模式对于Sv39SATP_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值可能失效需要根据新的虚拟地址空间重新计算。第二次开启MMUcsrw 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_park1.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映射 将设备树BlobDTB所在的物理内存区域映射到一块固定的虚拟地址以便内核早期代码可以解析设备树。可能的内存映射 有时还会提前映射一些早期的I/O内存。避坑指南early_pg_dir的映射范围一定要足够。除了内核镜像本身还要考虑内核启动后立即访问的初始数据、栈空间等。一个常见的错误是只映射了.text代码段而忽略了.data或.bss段导致内核刚进入C语言环境就发生数据访问错误。务必根据链接脚本中各个段的结束地址来计算映射的结束边界。relocate汇编重定向过程是操作系统内核从物理地址的“蛮荒世界”迈入虚拟地址“文明时代”的临门一脚。它通过精心设计的两段式页表切换trampoline_pg_dir-early_pg_dir和预置的异常处理安全网实现了这一切换的平滑与稳健。理解这个过程不仅对解决内核启动问题至关重要更是深入理解计算机系统如何管理内存的绝佳范例。下次当你看到内核在开启MMU后安静地继续执行时你会知道在这背后发生了一场多么精密而优雅的地址空间“魔术”。
RISC-V Linux内核启动:relocate汇编函数与MMU页表切换深度解析
发布时间:2026/5/23 20:50:05
1. 项目概述与核心价值最近在调试一个基于RISC-V架构的嵌入式Linux内核启动问题卡在了relocate这个汇编函数上。现象很典型内核在开启MMU内存管理单元的瞬间就“死”了没有任何错误输出。这让我不得不重新深入梳理了一遍Linux内核启动早期从物理地址到虚拟地址切换的完整逻辑。relocate这个常常被一笔带过的启动重定向过程实际上是内核能否成功“起跳”到虚拟内存世界的关键一跃。它涉及汇编、MMU页表、异常处理等多个底层概念的精密配合任何一个细节的疏漏都会导致启动失败。这篇文章我就结合RISC-V平台的源码以Linux 5.10为例彻底拆解relocate的汇编实现。我会重点解释为什么需要两次开启MMUtrampoline_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表示关闭MMUBare模式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 dont 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_SHIFTPAGE_SHIFT通常为12因为一页4KB。这条指令将页表基地址右移12位得到物理页号PPN存入a2。这是satp寄存器要求的格式。li a1, SATP_MODE 加载MMU模式对于Sv39SATP_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值可能失效需要根据新的虚拟地址空间重新计算。第二次开启MMUcsrw 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_park1.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映射 将设备树BlobDTB所在的物理内存区域映射到一块固定的虚拟地址以便内核早期代码可以解析设备树。可能的内存映射 有时还会提前映射一些早期的I/O内存。避坑指南early_pg_dir的映射范围一定要足够。除了内核镜像本身还要考虑内核启动后立即访问的初始数据、栈空间等。一个常见的错误是只映射了.text代码段而忽略了.data或.bss段导致内核刚进入C语言环境就发生数据访问错误。务必根据链接脚本中各个段的结束地址来计算映射的结束边界。relocate汇编重定向过程是操作系统内核从物理地址的“蛮荒世界”迈入虚拟地址“文明时代”的临门一脚。它通过精心设计的两段式页表切换trampoline_pg_dir-early_pg_dir和预置的异常处理安全网实现了这一切换的平滑与稳健。理解这个过程不仅对解决内核启动问题至关重要更是深入理解计算机系统如何管理内存的绝佳范例。下次当你看到内核在开启MMU后安静地继续执行时你会知道在这背后发生了一场多么精密而优雅的地址空间“魔术”。