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-Thread |
| SiFive FU540 系列 | 0x00001000 | 片上 Mask ROM | Linux、U-Boot |
| QEMU virt 模拟器 | 0x00001000 | ROM 中一段跳转指令,跳到 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(或者叫 ZSBL,Zero 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 /* MPP=01(S-mode), MPIE=1, MIE=1 => 0x888 */ li t0, (1 << 11) | (1 << 7) | (1 << 3) csrw mstatus, t0 mret secondary_halt: wfi j secondary_halt这里有个很容易翻车的点:mstatus.MPP的值必须和你要跳转的目标特权级一致。如果 MPP 写成 0(U-mode),mret之后你就在用户态执行代码,任何特权操作立刻触发异常,表现为跑飞、死循环或者莫名其妙的 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 / ZSBL | Mask ROM,约几十 KB | 芯片内部 | 基础时钟、启动介质初始化、搬运 SPL | 厂商固化,不可修改 |
| SPL / FSBL | 小体积固件,约几十到几百 KB | 片上 SRAM | DDR 初始化、串口初始化、加载 U-Boot 到 DDR | U-Boot SPL、厂商 FSBL |
| OpenSBI | M-mode 运行时固件 | DDR | 提供 SBI 服务、PMP 配置、中断委派、定时器/IPI | OpenSBI |
| U-Boot Proper | 完整 Bootloader,约 1~2 MB | DDR | 环境变量、设备树、内核/文件系统加载、启动命令 | U-Boot |
| Linux Kernel | 内核镜像 | DDR | 接管中断、内存管理、驱动、调度 | Linux |
这里值得多说一句 OpenSBI。RISC-V 的目标是让 Linux 直接跑在 S-mode,不与具体的 M-mode 固件实现耦合,于是定义了 SBI(Supervisor 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 → Kernel,OpenSBI 作为 U-Boot 的前置固件;有的平台是 Boot ROM → SPL → U-Boot(M-mode)→ OpenSBI → Kernel,U-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 的 hartid;a1= 设备树二进制(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,内核加载在 0x80200000,DTB 放在 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 格式的内核,附带 DTB;bootm则适用于加载 FIT(Flattened 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 内核通常用-mcmodel=medany编译,结合内核 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 而非 25MHz,UART 分频出来自然对不上。这时候不要去改 U-Boot 的波特率,而是先确认 PLL 的实际输出,再重新计算分频系数。
另一个更隐蔽的情况是:串口输出的前几个字符正常,后面突然乱码。这通常是因为 SPL 初始化了更高频率的 PLL,而 UART 的时钟源被切到了这个新 PLL,波特率分频没有同步更新。也就是说,你改了系统时钟,就必须重新计算 UART 分频系数,否则必然乱码。这个坑在从“低速调试模式”切换到“高性能模式”时特别容易出现。
至于“Starting kernel...”之后卡死,十个里有八个是 DTB 传递或者内存布局问题。我调过一块板卡,现象是 U-Boot 正常打印到 “Starting kernel...”,然后世界安静了。最初我以为是内核没编译对,后来用 JTAG 看了一眼 DTB 地址处的内存,发现里面根本不是合法的 FDT magic(0xd00dfeed),而是一堆随机数据——原来加载 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 板子,第一周不要急着移植功能,先把启动链路的每个交棒点跑通、打印出来、存档固化,后面所有调试都会受益于这个“已知良好的基线”。