ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RISC-V启动流程与Bootloader实战:从复位向量到内核交棒

RISC-V启动流程与Bootloader实战:从复位向量到内核交棒 RISC-V 的启动流程是我接触过的所有处理器体系里最“拆得开”的一条链路上电复位、Bootloader 多级接力、固件特权级切换、最终把内核加载进内存再交棒。可它也是最容易让人懵圈的链路——因为 RISC-V 指令集规范本身并不规定上电后第一步该干什么只规定了复位后处理器处于机器态M-mode、从复位向量取指剩下的全部交给芯片厂商自由发挥。这意味着你从 STM32 或者 ARM 生态转过来时那些“复位后自动跑厂商固件、然后把你的程序搬进内存”的思维惯性基本都会失效。这篇文章按“上电→Boot ROM→SPL→OpenSBI/U-Boot→内核”这条完整路径把 RISC-V 启动流程和 Bootloader 实战里的关键节点、分工逻辑、寄存器约定、常见坑位逐一拆开讲。内容包括复位向量与特权级切换的具体机制、为什么需要多级引导而不是一个程序干到底、外设初始化的先后顺序、跳转内核前的 a0/a1 握手协议以及我在真实调试中反复踩过的排错案例。无论你是刚拿到 RISC-V 开发板想跑通 Linux还是在做裸机、RT-Thread 或 U-Boot 移植这篇都能帮你把启动链路从“能跑”提升到“真正理解”的层面。1. 别拿 STM32 的经验硬套 RISC-V——启动链路的前两跳就不同1.1 规范只规定了复位状态没规定复位去哪先看 ARM 那边。Cortex-M 的启动逻辑几乎是“有手就行”上电后硬件自动从 0x00000000 读取初始栈指针从 0x00000004 读取复位函数地址然后跳进去。开发者大多数时候根本不用关心启动细节直接在 main() 里写业务就行。就算换到 Cortex-A 平台厂商也会把 ATF、FIP 这些底层固件打包好虽然启动过程复杂但至少有明确文档和现成工程。RISC-V 不是这样。RISC-V 特权级规范Privileged ISA Spec明确了一个大前提所有 hart 复位后进入 M-mode从 mtvec 指定的复位向量开始取指。但这里有个关键的“留白”——复位向量到底在哪、第一段代码是从片上 ROM 读出还是从外部 Flash 直接执行、由谁把下一级引导程序搬运到内存规范统统不管交给芯片厂商自己设计。这种自由度是 RISC-V 开放生态的卖点但也直接导致了一个结果不同厂商甚至同一厂商不同系列的芯片启动路径可能完全不同。另一个和 STM32 系感知差异很大的是中断模型。Cortex-M 上硬件会自动压栈、自动从向量表取中断处理函数RISC-V 上 mtvec 只是指向一个 trap 入口地址CPU 不会帮你压栈保存上下文、判断异常来源、恢复现场全要软件自己完成。所以在 RISC-V 启动代码里你看到的往往是“一大段汇编负责保存寄存器、再切栈、再调 C 函数”的结构而不是一张整齐的向量表。1.2 认识你的复位向量三个平台的例子我最早在 RISC-V 上踩的第一个坑就是默认复位向量在 0x00000000结果用 STM32 的地址观念去读手册怎么都对不上。列几个常见平台的复位向量和第一段代码来源能很直观地看出差异芯片/平台复位向量第一段代码来源典型用途蜂鸟 E203 系列0x00000000片上 Flash/ITCM 直接执行裸机、RT-ThreadSiFive FU540 系列0x00001000片上 Mask ROMLinux、U-BootQEMU virt 模拟器0x00001000ROM 中一段跳转指令跳到 0x80000000固件/内核开发验证K210 系列内部 Boot ROM 引导从 SPI Flash 拷贝到 0x80000000 的 SRAM裸机、RT-Thread看到没有同样是“RISC-V”有的从 0 地址直接跑 Flash有的从 0x1000 的 Mask ROM 开始还有的先用 ROM 代码去外部 Flash 搬数据。所以做任何一块 RISC-V 板子第一件事不是写代码而是把芯片手册的启动章节和内存映射表读透。Reserved 区域是 ROM、哪里是 SRAM、哪里是外部存储控制器映射这些决定了你的链接脚本怎么写、第一段代码放在哪。这里也顺带纠正一个常见误解很多人以为“上电之后 PC 必须指向 Flash 里的用户代码”这在 RISC-V 上并不成立。很多应用处理器的第一段代码是在芯片内部 Mask ROM 里执行外部 Flash 此时可能连时钟都没初始化。Mask ROM 里的代码要做的事不多但也至关重要初始化最基本的时钟、配置启动介质相关的引脚、把下一级引导代码从 Flash/SD 卡拷贝到片上 SRAM然后跳过去。2. 复位向量之后M-mode 固件与特权级切换的接力2.1 Boot ROM 在通电后的几十毫秒里干了什么现代 RISC-V SoC 的 Boot ROM或者叫 ZSBLZero Stage Boot Loader通常只有几十 KB掩膜在芯片内部用户改不了。它的职责边界很清晰做最少的事把下一级代码搬进来。具体到动作上大致是三板斧。第一设置异常/中断入口和栈空间——虽然 Boot ROM 自身运行在 M-mode可以访问全部 CSR但它仍然需要一套可靠的运行环境。第二初始化启动介质相关的基础外设比如 SPI Flash 控制器或者 SDIO 控制器同时把时钟配到能用的程度。第三从启动介质读取固定偏移位置的下一级引导程序搬运到 SRAM校验可选最后跳转。一个容易被忽略的细节是Boot ROM 阶段通常没有任何调试输出。它不像 U-Boot 那样可以开个串口打印 banner。芯片厂商不会在 Mask ROM 里放一个完整串口驱动即使放了也未必引出到你的板子引脚。所以 Boot ROM 内部发生了什么对开发者来说基本是黑盒。我见过不少人在“串口完全没有输出”的困境里怀疑 Boot ROM 坏了但其实 Boot ROM 大概率正常工作只是没有输出通道而已。后面第 6 章会专门讲这个阶段的排错思路。2.2 mret 如何完成从 M-mode 到 S-mode 的交棒Boot ROM 把 SPL 搬到 SRAM 后会以某种方式跳过去。最简单的跳转是直接jal但这只改变 PC不改变特权级。如果后续代码想运行在 S-mode比如 Linux 内核就必须走一次真正的特权级切换而 RISC-V 里完成这个操作的指令是mret。mret的行为需要拆开看它会把 PC 设置为mepc的值把特权级设置为mstatus.MPP字段指定的级别同时把mstatus.MPIE恢复到MIE。所以跳转之前软件要提前准备好两个寄存器mepc写上目标代码的入口地址mstatus.MPP写上目标特权级。下面这段是典型的复位启动汇编骨架.section .start .globl _start _start: /* 多核环境下只有 hart0 负责引导 */ csrr t0, mhartid bnez t0, secondary_halt /* 设置栈指针这一步最先做 */ la sp, _estack /* 设置 trap 入口 */ la t0, trap_vector csrw mtvec, t0 /* 低层硬件初始化时钟、串口、存储控制器 */ call lowlevel_init call uart_init /* 准备跳转到 S-mode 入口 */ la t0, s_mode_entry csrw mepc, t0 /* MPP01(S-mode), MPIE1, MIE1 0x888 */ li t0, (1 11) | (1 7) | (1 3) csrw mstatus, t0 mret secondary_halt: wfi j secondary_halt这里有个很容易翻车的点mstatus.MPP的值必须和你要跳转的目标特权级一致。如果 MPP 写成 0U-modemret之后你就在用户态执行代码任何特权操作立刻触发异常表现为跑飞、死循环或者莫名其妙的 fault。调试时如果发现代码“明明跳过去了但马上挂掉”第一件事就是检查 MPP。2.3 多核启动为什么第一个动作是读 mhartid多核是 RISC-V 启动流程里躲不开的话题。规范没有规定“只有 hart0 可以引导”但它规定了每个 hart 都有自己的mhartid并且复位后所有 hart 理论上都会从复位向量开始执行。如果所有核同时去初始化同一个 UART、写同一个 PLL 寄存器后果不堪设想。所以通用做法和上面的汇编骨架一致每个 hart 启动后第一件事就是读自己的 mhartid非引导核立刻进 WFI 休眠只有指定的引导核继续干活。引导核完成所有初始化、把下一级固件准备好之后再通过中断软件中断唤醒其他核让它们各自找到自己的入口开始执行。Linux 在 RISC-V 上的多核拉起本质上靠的就是这种“先休眠、后唤醒”的模型。还有一个很多人忽视的 ABI 要求RISC-V 的 C 运行环境依赖全局指针寄存器 gp 来访问小数据段sdata/sbss。如果启动汇编里没有设置 gp编译器生成的对全局变量的访问就可能落到错误地址造成“明明变量赋了值读出来却是乱码”这种诡异问题。正确做法是在链接脚本里定义__global_pointer$然后在启动代码里执行la gp, __global_pointer$。绝大多数 SDK 模板会帮你做好但如果你是自己从零写链接脚本这个坑一定会踩。3. SPL、OpenSBI、U-Boot 三段接力——为什么不能一个程序干到底3.1 为什么不能只写一个 Bootloader 解决所有问题从工程角度理想情况当然是只写一个 Bootloader从复位开始一路初始化到加载内核。但现实里这个方案基本行不通原因有三个。第一片上 SRAM 不够大。Boot ROM 能用的内存通常只有几十到几百 KB而一个带完整驱动的 U-Boot 编译出来轻松上 MB。第二DDR 在复位后不可用。DDR 控制器需要时钟、引脚复用、时序参数还要做训练training这一整套流程不是几条指令能搞定的而 U-Boot 主体本来设计成在 DDR 里跑。第三启动介质接口速度太慢且不可靠。SPI NOR Flash 在复位初期的时钟频率可能只有几十 MHz直接 XIP 执行大型固件会慢到无法接受还可能因为 Flash 没握手成功而莫名失败。所以 RISC-V 应用处理器普遍采用“多级接力”模式。每一级的体积、运行位置、职责都不同级与级之间是明确的交棒关系前一级负责初始化好后一级的运行条件然后跳转后一级一旦跑起来前一级基本可以“退休”。3.2 三段接力各管什么Boot ROM、SPL、U-Boot/OpenSBI我把最常见的 RISC-V Linux 启动链路整理成一张表开发时可以作为对照阶段形态运行位置主要职责典型实现Boot ROM / ZSBLMask ROM约几十 KB芯片内部基础时钟、启动介质初始化、搬运 SPL厂商固化不可修改SPL / FSBL小体积固件约几十到几百 KB片上 SRAMDDR 初始化、串口初始化、加载 U-Boot 到 DDRU-Boot SPL、厂商 FSBLOpenSBIM-mode 运行时固件DDR提供 SBI 服务、PMP 配置、中断委派、定时器/IPIOpenSBIU-Boot Proper完整 Bootloader约 1~2 MBDDR环境变量、设备树、内核/文件系统加载、启动命令U-BootLinux Kernel内核镜像DDR接管中断、内存管理、驱动、调度Linux这里值得多说一句 OpenSBI。RISC-V 的目标是让 Linux 直接跑在 S-mode不与具体的 M-mode 固件实现耦合于是定义了 SBISupervisor Binary Interface规范S-mode 代码通过ecall陷入 M-mode请求定时器、IPI、远程 fence、控制台等服务。OpenSBI 是应用最广的参考实现它启动时会做三件关键的事配置 PMP 把物理内存权限交给 S-mode、设置mideleg/medeleg把大部分中断和异常委派给 S-mode、保留必要的 M-mode trap 处理逻辑。还有一个细节值得注意不同厂商的接力顺序不完全一样。有的平台是 Boot ROM → SPL → OpenSBI → U-Boot → KernelOpenSBI 作为 U-Boot 的前置固件有的平台是 Boot ROM → SPL → U-BootM-mode→ OpenSBI → KernelU-Boot 在跳转前先把 OpenSBI 加载好。拿 SiFive 的 HiFive Unmatched 来说Boot ROM 里运行 ZSBL之后是 FSBL再之后 OpenSBI 和 U-Boot 依次接力最后才到内核。理解“交棒点”比死记某一家的顺序重要得多因为每个交棒点都对应一次明确的寄存器约定和跳转动作。3.3 MCU 级系统的例外RT-Thread 和裸机不需要这么多上面说的是应用处理器跑 Linux 的场景。如果目标是 MCU 级 RISC-V 芯片跑裸机或 RT-Thread链路会被砍掉大半。原因很简单没有 MMU、不涉及特权级隔离、不需要 DDR 训练Boot ROM 直接把用户代码从 Flash 拷贝到 SRAM然后跳到entry就行。以 RT-Thread 在 RISC-V 上的启动初始化流程为例复位向量执行启动汇编start_gcc.S设置栈和 trap 入口后调用entry函数再由entry跳转到rtthread_startup。rtthread_startup依次完成rt_hw_board_init时钟、串口、堆内存初始化、调度器初始化、信号量/定时器等系统组件初始化最后创建 main 线程并启动调度器。整个过程没有 DTB、没有 SBI、没有特权级切换思路和 STM32 的启动流程更像。所以做 RISC-V 开发之前先想清楚目标跑 Linux 和跑 RTOS 是两套完全不同的启动链路别拿 A 的固件思路去套 B。4. 点亮板子的正确外设顺序时钟、串口、DDR 一个都不能乱4.1 时钟永远先于一切外设Bootloader 早期初始化有个铁律时钟树不通后面全是白搭。很多外设的寄存器不是不能写而是写了之后因为没时钟而没有任何反应反过来把问题伪装成“寄存器配置错误”。典型的时钟树路径是外部晶振 → PLL 倍频 → 总线分频 → 外设门控时钟。启动初期晶振起振需要时间PLL 锁定也需要时间所以规范的 SPL 代码都会在读取 PLL 锁定标志后继续执行而不是写完配置寄存器就往下走。我见过有人图省事写完 PLL 配置不等锁定就直接初始化 UART结果串口波特率完全不对打印出来全是乱码。一个实用的排查方法是在时钟初始化前后各留一个可见的标记。比如初始化前把某个 GPIO 拉高初始化完成后拉低用示波器看这段间隔是否符合预期。如果间隔远大于预期多半是在等待某个硬件标志位卡住了如果间隔接近零可能是晶振没起振或者 PLL 配置根本没生效。4.2 串口没输出先查 pinmux 而不是波特率拿到新板子第一步基本都是调串口因为只有串口通了后面才能“看到”系统在干嘛。但串口不通的原因里pinmux 配置错误的比例远高于波特率配置错误。现在的 RISC-V SoC 几乎都有引脚复用功能同一个物理引脚可以接 UART、SPI、I2C 或者 GPIO。如果你只配置了 UART 控制器的寄存器忘了把对应引脚切换到 UART 功能那么数据根本送不到芯片外部。更隐蔽的是有些开发板的调试串口 TX 引脚默认是开漏输出需要外部上拉才能正常输出高电平。如果板子设计时漏了上拉电阻或者上拉电阻虚焊你会看到 TX 引脚被拉低串口工具里一个字都收不到。所以我的习惯是串口不出字的排查顺序固定为 pinmux → 引脚电平 → 波特率 → UART 寄存器。先读芯片手册确认调试串口用的是哪组引脚再拿示波器或者万用表量 TX 引脚有没有电平翻转最后才去怀疑寄存器配置。顺序反了的话很容易在错误的坑里浪费一整天。4.3 DDR 初始化是启动流程里最不讨喜但最关键的一步如果 U-Boot 是“跑 Linux 的必经之路”那 DDR 初始化就是“进 U-Boot 之前的鬼门关”。DDR 控制器涉及大量时序参数tRFC、tRCD、tRP、CAS Latency还有 DQS/DQ 的相位训练、odt 配置、片内终结电阻。不同容量的颗粒、不同 PCB 布线长度都会影响最终参数。这就是为什么 D 等级芯片的厂商 SPL 代码里会有一个专门针对自家开发板的 DDR 配置表而第三方板卡厂商往往要花大量时间在 DDR 调参上。DDR 初始化失败的表现五花八门可能在训练阶段就死循环可能训练通过但一读写就随机出现 bit 翻转也可能“看起来正常”但跑一段时间后随机崩溃。我经历过一个项目板子偶尔能进 U-Boot偶尔卡死在“Starting kernel...”之前最后定位是 DDR 跑在过高频率导致偶发性读写错误把 DDR 时钟降一档后问题消失。所以在 bring-up 阶段先按保守参数跑稳再逐步提高频率和压缩时序不要一开始就追求标称最高频率。另外DDR 初始化成功之后一定要做一次内存读写自检而不是直接跳转。最简单的方法是在 SPL 里对几个关键地址反复写 0x5A5A5A5A 再读回校验。这一步能帮你区分“DDR 没初始化好”和“后续加载/跳转逻辑有问题”把故障边界切得很干净。整个外设 bring-up 的层次结构其实是嵌套的时钟是地基pinmux 是管线UART 是观察窗DDR 是正式的运行场地存储控制器是通往下一级的桥梁。每完成一层就固化一层再往上搭。这样即使后面出了问题也能快速二分定位到具体哪一层。5. 跳进内核的握手hartid、DTB 与寄存器约定5.1 Linux 的 RISC-V 启动协议a0、a1 和那一次跳转Bootloader 辛辛苦苦把内核镜像加载进内存之后真正“交棒”的动作其实很简单设置好几个通用寄存器把 PC 指到内核入口跳过去。但就这么简单的动作背后有一套必须严格遵守的 ABI 约定。RISC-V Linux 的启动协议见内核文档Documentation/riscv/boot.rst明确规定a0 当前 hart 的 hartida1 设备树二进制DTB的物理地址内核期望在 S-mode 下被进入MMU 关闭satp为零或处于 bare 模式内核镜像头部带有自己的 header包含 magic 和 load_offset 等字段加载器需要据此确定正确的加载地址。a2在经典引导流程里不使用EFI 引导等特殊场景另说。这个约定非常重要因为内核启动早期没有任何“参数链表”或者“启动信息结构体”它完全依靠从 a0/a1 寄存器里拿到的两个数值来完成最开始的 CPU 识别和设备树解析。如果 bootloader 跳转时忘传 a0或者传错了 hartid内核的 SMP 初始化就会出问题表现为“只有一个核在跑”或者“secondary hart 无法唤醒”。内核为什么要求 MMU 关闭因为此时内核还没建立自己的页表它需要直接从物理地址访问内存来解析 DTB、建立早期页表。如果 bootloader 在跳转前开了 MMU 又没提前告诉内核内核用物理地址访问时会直接踩到异常。这一点和 ARM64 的启动协议思路一致都是“SIMPLEST POSSIBLE”。5.2 设备树的存放位置与常见“隐性覆盖”DTB 是 bootloader 必须传给内核的另一个关键数据。它描述了内存大小、CPU 数量、中断控制器、串口地址等硬件信息内核早期完全依赖它来 probe 设备。常见的问题是 DTB 放在内存中的哪个位置。一个典型做法是放在内核镜像之后的高地址处比如 QEMU virt 平台上OpenSBI 固件在 0x80000000内核加载在 0x80200000DTB 放在 0x82200000。这样保证了内核镜像解压/自解压时不会覆盖 DTB。但如果你手写 U-Boot 环境变量时把 DTB 地址设得太低而内核镜像又特别大自解压过程中就可能把 DTB 直接覆盖掉现象就是内核启动早期打印“No DTB passed”或者 device tree 解析异常。更隐蔽的是“DMA 覆盖”——某些设备驱动初始化时会启动 DMA如果 DTB 所在的物理内存没有被内核保留没有在 DTB 里声明为 reserved-memory那么驱动申请的 DMA 缓冲区可能恰好覆盖 DTB 区域导致内核启动到一半突然读不到设备信息。所以正确的做法是在 DTB 中显式声明 reserved-memory 节点把 DTB 自己占用的区域保护起来或者至少在内存分配时避开。我调过的板子里至少有两块出现“Starting kernel... 之后静默死亡”最后都是这一类问题。5.3 U-Boot 里的实际操作booti、FIT 与加载地址在 U-Boot 里加载 RISC-V Linux 内核常用的命令是booti或者bootm。booti适用于直接加载 Linux Image 格式的内核附带 DTBbootm则适用于加载 FITFlattened Image Tree镜像FIT 把内核、DTB、ramdisk 打成一个大包并且可以带 SHA 校验适合正式产品和无法保证存储介质完整性的场景。一组典型命令如下# 从 virtio 磁盘加载内核和 dtb 到指定地址 load virtio 0:1 0x80200000 /boot/Image load virtio 0:1 0x82200000 /boot/board.dtb # booti 参数依次是 kernel 地址、ramdisk 地址- 表示无、dtb 地址 booti 0x80200000 - 0x82200000为什么内核加载地址通常是 0x80200000 而不是 0x80000000因为 0x80000000 往往是固件/OpenSBI 的驻留地址。QEMU virt 平台上-bios 传进去的固件就放在 0x80000000内核如果也放到这里会在启动瞬间把正在执行的固件代码覆盖掉必死无疑。所以内核一般往后让 2MB 的空间。另外RISC-V 内核通常用-mcmodelmedany编译结合内核 Image 头部的 load_offset 字段U-Boot 可以在合理范围内灵活选择加载地址不必像 32 位时代那样严格锁定一个固定物理地址。但这不意味着你可以随意乱放——地址至少要遵循三条原则不与固件驻留区域重叠、不与 DTB 重叠、满足内核头部要求的对齐。6. 上电后串口一句话没有排错实战与定位套路6.1 串口一个字都没有逐级排除的检查清单我刚接触 RISC-V 时最崩溃的场景就是上电、接好串口、按复位然后屏幕上什么都没有。这时候千万不要慌着去改代码加打印——先按层次把问题定位到具体某一段再动手。我的检查顺序是电源和复位用万用表确认所有电源轨电压正常复位引脚处于释放状态。很多开发板有多个电源域某个 LDO 没焊好或者使能引脚悬空会导致 SoC 根本没跑起来。启动模式/拨码开关很多 SoC 有多路启动源SPI Flash、SD 卡、UART 下载引脚 strap 没拉对芯片可能从你没想到的地方启动。JTAG 能否连接如果 OpenOCD 能连上并读取到正确 hartid说明 CPU 已经跑起来了如果连不上大概率是电源、时钟或者调试端口配置问题。确认 PC 是否在动用 JTAG 连续读几次 PC看是否停滞在同一个地址。如果 PC 停在 0x1000 不动可能是 Boot ROM 在等 Flash 响应如果 PC 停在一个明显是“死循环”的地址说明已经跳进了 SPL 但卡在某个初始化函数里。LED 大法如果 UART 完全不可用就在 SPL 第一条 C 函数里加一个 GPIO 翻转用示波器或者肉眼观察。这虽然原始但在 bootloader 早期阶段往往是最快的“可见性”手段。这里要强调一个反直觉的事实Boot ROM 阶段不出串口是正常的。很多芯片的 Boot ROM 根本不初始化外部调试串口所以你看不到任何输出。不要因为“屏幕没字”就怀疑上电流程全错了先把 JTAG 和 LED 手段用起来把故障边界缩小到“Boot ROM 是否把 SPL 搬到了 SRAM”这一问题上。6.2 串口乱码与“Starting kernel...”之后卡死的两个案例串口有输出但全是乱码问题基本锁定在波特率不匹配。但波特率不匹配背后的原因往往不是 U-Boot 配错了而是时钟频率和你预期的不同。比如代码注释写着 50MHz PLL实际晶振频率是 24MHz 而非 25MHzUART 分频出来自然对不上。这时候不要去改 U-Boot 的波特率而是先确认 PLL 的实际输出再重新计算分频系数。另一个更隐蔽的情况是串口输出的前几个字符正常后面突然乱码。这通常是因为 SPL 初始化了更高频率的 PLL而 UART 的时钟源被切到了这个新 PLL波特率分频没有同步更新。也就是说你改了系统时钟就必须重新计算 UART 分频系数否则必然乱码。这个坑在从“低速调试模式”切换到“高性能模式”时特别容易出现。至于“Starting kernel...”之后卡死十个里有八个是 DTB 传递或者内存布局问题。我调过一块板卡现象是 U-Boot 正常打印到 “Starting kernel...”然后世界安静了。最初我以为是内核没编译对后来用 JTAG 看了一眼 DTB 地址处的内存发现里面根本不是合法的 FDT magic0xd00dfeed而是一堆随机数据——原来加载 DTB 时用的源地址在 Flash 里但 U-Boot 在加载内核时把那段 Flash 区域的内容覆盖了。解决方法是把 DTB 先读到 DDR 里、放在内核镜像之后的高地址处再从 DDR 地址传给内核。6.3 OpenOCD GDB没有串口时的救命稻草当串口彻底不可用时OpenOCD GDB 是最后一道防线。绝大多数 RISC-V SoC 都带 JTAG 接口通过riscv.cfg配置好目标之后可以用 GDB 远程调试。对我帮助最大的几个操作# 连接目标 target extended-remote :3333 # 查看当前所有通用寄存器 info registers # 查看关键 CSR需要 OpenOCD 支持 p/x $mhartid p/x $pc p/x $mstatus p/x $mepc如果在某个异常发生后去读mepc就能拿到触发异常的指令地址。配合addr2line或者objdump反汇编可以快速定位是哪个函数、哪一行出了问题。比如内存访问异常mepc指向的指令往往就是访问非法地址的那一条。硬件断点也很实用。在 U-Boot 的某个关键函数入口设置断点如果断点命中了说明前一级交棒成功如果没有命中说明根本没走到这一步。这样一层层往下很快就能把故障隔离到某个交棒点附近。6.4 把每一次交棒都变成可见的里程碑做了这么多 RISC-V 启动开发之后我最想分享的教训是启动链路里的每一级交棒都应该有一个可见的里程碑。这个里程碑可以是串口打印、LED 闪烁、GPIO 电平跳变甚至是往某个约定内存地址写一个 magic 值。它不需要复杂但必须存在。这样当你面对一个“不知道挂在哪”的故障时就能沿着这些里程碑做二分定位Boot ROM 有没有把 SPL 搬进来SPL 有没有完成 DDR 初始化U-Boot 有没有正常加载内核DDR 之前的故障用 JTAG 查DDR 之后的故障用打印查问题边界一下子就清晰了。我在自己的固件工程里固定了一个习惯每完成一个 bootloader 阶段就往一个保留内存地址写一个魔数并按阶段递增。调试的时候隔一段时间读一次那个地址就知道系统实际跑到了哪一步比猜要高效得多。这也是为什么我始终建议拿到一块新 RISC-V 板子第一周不要急着移植功能先把启动链路的每个交棒点跑通、打印出来、存档固化后面所有调试都会受益于这个“已知良好的基线”。
返回列表