拿到一块 RISC-V 开发板,上电之后到底发生了什么?这个问题我当年也困惑过很久。习惯了 ARM 那套"固定复位向量 + BootROM 引导 U-Boot"的路径之后,第一次调 RISC-V 启动流程时,光是"复位后 PC 指向哪里"就折腾了我好几天。RISC-V 的启动流程和 Bootloader 设计与 ARM 有一个根本性差异:它的特权级交接、复位向量的实现定义、以及 M 模式和 S 模式之间的固件接口,决定了整套启动路径不是一条直线,而是一层层"从最高权限往下降"的接力。这篇文章我就用实战视角,把从上电到内核加载的完整路径拆开讲清楚,适合正在上手 RISC-V Linux、想搞明白 Bootloader 怎么选怎么配、或者被 OpenSBI 和 U-Boot 的层级关系绕晕的朋友。
1. 上电瞬间:RISC-V 复位向量与第一条指令在哪里
1.1 复位向量的地址不是固定的,这才是第一道坎
如果你带着 ARM 的经验来看 RISC-V,踩的第一个坑大概率是"找不到固定的复位地址"。Cortex-M 系列复位后从0x00000000取向量表,Cortex-A 系列也有约定俗成的启动入口,但 RISC-V 规范并没有把复位地址写死。它只规定了两件硬性的事情:复位后特权级必须是 M 模式,以及复位向量具体落在哪个地址由实现自行决定。
这是什么概念?意味着同样是 RISC-V 内核,A 芯片可能从0x00001000开始取指,B 芯片可能从0x00000000开始,C 芯片则跳到一个片内 ROM 的私有地址。比如 QEMU 的 virt 平台,复位向量就是0x00001000,第一条指令在0x00001000处放了一个跳转;而不少真实的 SoC 芯片,复位后是从片内 BootROM 开始跑的,BootROM 的基地址在芯片数据手册的内存映射表里才能找到。
所以调试 RISC-V 启动问题的第一步,不是打开反汇编窗口,而是先回答三个问题:复位后 PC 指向哪里?那段地址上放了什么代码?这段代码又是通过什么方式烧录进去的?我有一次调一块开发板,串口完全没有输出,排查了半天发现自己一直在盯外部 Flash 里的 U-Boot,却忘了芯片复位后先进的是片内 BootROM,BootROM 因为找不到合法的启动镜像,一直死循环在那个"检查镜像签名"的流程里。这就是典型的"连起点都搞错了"的排障现场。
1.2 为什么复位后一定是 M 模式:特权级与 CSR 的规则
RISC-V 定义了三层特权级:M 模式(Machine Mode)、S 模式(Supervisor Mode)、U 模式(User Mode),权限依次降低。规范明确要求复位后 CPU 进入 M 模式,这不是随口一说的设计,而是因为 M 模式是唯一能访问全部 CSR(控制状态寄存器)的模式。
这些 CSR 在启动流程里一个比一个重要:misa记录了 CPU 支持哪些指令集扩展,mstatus保存机器状态,mtvec设置异常向量基地址,mepc存放异常返回地址。上电之后 CPU 对世界一无所知,它不知道你是谁、想跑什么操作系统,所以必须先以最高权限的 M 模式起步,把硬件状态摸清楚,再一步步把控制权交出去。这个"交出去"的动作,在硬件层面就是一条mret指令:把mepc的值写入 PC,同时把mstatus里 MPP 字段指定的新模式写到当前特权级,一条指令同时完成"跳转"和"降权"。
理解这个设计对后续看 Bootloader 代码特别重要。你会在 OpenSBI 的源码里频繁看到csrw mepc、csrw mstatus、mret这三连击,这基本就是 M 模式固件向 S 模式跳转的固定套路。而在 U-Boot 这一层,因为跑在 S 模式,特权操作不能直接碰 CSR,必须通过ecall陷入 M 模式,让 OpenSBI 来执行。这套"高层级提供服务,低层级发起调用"的机制,就是 SBI 接口存在的意义,也是整个 RISC-V 启动链路不同于 ARM 的底层逻辑。
1.3 片内 BootROM、复位向量与 Flash 的三角关系
真实 SoC 的上电启动,通常不是从你烧录的 U-Boot 开始的,而是从片内 BootROM 开始。BootROM 是芯片出厂固化的只读代码,体积很小,可能只有几十 KB,它的任务非常克制:初始化最基础的外设(比如 SPI 控制器、串口),检测启动介质(SD 卡、SPI NOR、USB 等),把存放在外部介质里的下一级镜像加载到 SRAM 或者 DDR 里,最终跳转过去。
以一颗常见的 RISC-V Linux SoC 为例,典型的启动介质和镜像布局是这样的:
| 阶段 | 存放位置 | 运行位置 | 职责 |
|---|---|---|---|
| BootROM | 片内 ROM | 片内 ROM | 初始化 SPI/串口,加载 SPL |
| SPL(Secondary Program Loader) | SPI NOR Flash / SD 卡 | SRAM | 初始化 DDR 控制器 |
| U-Boot 主镜像 | SPI NOR Flash / SD 卡 | DDR | 加载内核与设备树 |
| Linux 内核 | SPI NOR Flash / SD 卡 / 网络 | DDR | 接管系统 |
这个链路解释了为什么 RISC-V 板子的启动分区里往往躺着好几个镜像文件。SPL 阶段的可执行空间很有限,代码必须非常克制,只做 DDR 初始化这类"不做就活不下去"的事情;把自己搬运到 DDR 之后,才有足够的空间展开完整的 U-Boot。如果你在移植一块新板子时发现 U-Boot 在 DDR 初始化之前就跑了,大概率是 SPL 阶段的链接地址和实际运行地址没对齐,这也是一个非常经典的启动类 bug。
2. Bootloader 为什么需要分级:RISC-V 启动链路的层次逻辑
2.1 一次启动中的多次特权级交接
一条完整的 RISC-V Linux 启动路径,本质上就是特权级不断"降级"的过程:M 模式起步,最终到 U 模式跑应用程序。但实际工程里,M 模式阶段可能不止一段代码,S 模式阶段也不是直接进内核,而是先跑一个 S 模式下的 Bootloader。
最常见的启动链条长这样:
上电复位 -> BootROM(M 模式) -> OpenSBI(M 模式) -> U-Boot(S 模式) -> Linux 内核(S 模式) -> 应用程序(U 模式)
每次交接都需要完成两件敏感的事情:地址对准,特权级切换。BootROM 要跳到 OpenSBI 的入口地址,同时保证 CPU 还在 M 模式;OpenSBI 初始化完硬件和 SBI 服务之后,通过mret降权跳到 U-Boot;U-Boot 加载好内核镜像,把 a0、a1 寄存器按约定填好,再次降权跳进内核。
有些朋友会问:能不能少几级?比如 BootROM 直接加载内核?可以,但代价是你得把存储介质驱动、文件系统解析、DDR 初始化全部塞进 BootROM 或者一个超级大的 SPL 里,维护成本极高。RISC-V 生态选择了"每层只干一件事,层间用标准接口对接"的分工方式,OpenSBI 负责提供 M 模式服务,U-Boot 负责加载镜像,内核负责资源管理。换内核不用动 OpenSBI,换 OpenSBI 也不用重编 U-Boot,松耦合带来的工程便利,用过就回不去。
2.2 OpenSBI 与 U-Boot 的分工边界
项目里很多人把 OpenSBI 和 U-Boot 的职责混在一起,遇到启动问题不知道找谁。我举个实际例子:有人在 U-Boot 阶段发现定时器不准,花了好几天查 U-Boot 的 timer 驱动,最后发现问题在 OpenSBI 的定时器中断没有正确委托给 S 模式。反过来,也有人 OpenSBI 打印完一片死寂,却去翻内核代码找原因,完全跑偏。
这两层之间的分工,用一张表说清楚:
| 能力 | OpenSBI | U-Boot |
|---|---|---|
| 运行特权级 | M 模式 | S 模式 |
| DDR 初始化 | 不做(由前级完成) | 部分平台会做 |
| 存储介质访问 | 不做 | 支持 SD/eMMC/NVMe/USB/网络 |
| 文件系统解析 | 不支持 | FAT/ext4 等 |
| 与内核对接 | 只提供 SBI 服务 | 加载镜像、传设备树 |
| 交互调试 | 基本没有 | 完整命令行 |
排查启动问题时,先通过日志判断当前卡在哪一层。OpenSBI 的日志通常以OpenSBI v1.x开头,内容简短,主要是版本、平台名和固件配置;U-Boot 日志以U-Boot 20xx.xx开头,后面跟着一长串板级初始化信息;内核日志则以Linux version开头。这三段日志之间如果有断层,断层在哪,问题基本就锁定在哪一层。
2.3 为什么内核不能直接从 OpenSBI 手里接棒
有人会问,OpenSBI 已经把 M 模式的事干完了,为什么中间还非要插一个 U-Boot?直接让内核接棒不是更短吗?这个问题的答案,我在实际项目中体会很深。
第一个原因是加载能力。OpenSBI 作为固件,它不关心也不应该关心"从哪里读取内核镜像"这件事。内核可能在 SPI NOR Flash 的某个分区里,可能在一个 FAT32 的 SD 卡上,也可能要通过 TFTP 从网络下载。这些能力 OpenSBI 都不会提供,而 U-Boot 恰好是这方面的行家。你可以用fatload、ext4load、tftp等命令从各种介质加载镜像,这种灵活度在开发阶段几乎是刚需。
第二个原因是调试灵活性。U-Boot 提供了一个完整的命令行,启动之前你可以停下来改启动参数、手动加载一份新的设备树、换一个内核版本再启动。内核启动参数想加一个console=?嵌入式开发中这种需求太频繁了,总不能每次改参数都重新编译一次 OpenSBI。
第三个原因是生态惯性。U-Boot 在 ARM、x86 等架构上积累了海量驱动和板级代码,RISC-V 的移植大量复用了这些框架。对做产品的人来说,用 U-Boot 是一种"投入产出比最高"的选择。当然,生产环境也有跳过 U-Boot 的做法,比如用 OpenSBI 的 FW_PAYLOAD 模式把内核镜像和 OpenSBI 打包成一个文件,上电后直接跑内核,但这通常意味着你放弃了调试灵活性,我一般只在产品定型后才会这么干。
3. OpenSBI 的三种固件形态与 U-Boot 在 S 模式下的实战配合
3.1 FW_JUMP、FW_PAYLOAD、FW_DYNAMIC 该如何选
OpenSBI 编译时最关键的一个配置项是固件形态,也就是 FW_TYPE。我接触过不少同事,第一次看到FW_JUMP、FW_PAYLOAD、FW_DYNAMIC这三个宏的时候都是一脸懵,这里我用自己的理解把它们捋一遍。
FW_DYNAMIC 是"被动型"固件。OpenSBI 不关心下一级是谁、在哪个地址,它通过前一级加载器传递过来的信息决定跳转目标。这种模式在 QEMU 和虚拟化场景里很常用,因为前一级可以灵活指定下一级地址。
FW_JUMP 是"跳转型"固件。你在编译时通过FW_JUMP_ADDR指定下一级代码的地址,OpenSBI 初始化完成之后直接跳过去。这种模式很适合"OpenSBI 和 U-Boot 分开烧写"的场景,我个人的开发板调试也优先用这个模式。它的灵活性在于:只要 U-Boot 的加载地址不变,你随时可以单独替换 U-Boot 镜像,而 OpenSBI 完全不用动。
FW_PAYLOAD 是"打包型"固件。编译时把下一级镜像(U-Boot、内核或 RT-Thread)直接拼在 OpenSBI 二进制的后面,OpenSBI 跑完初始化后跳进 payload 区域。好处是只需要烧录一个文件,部署简单;坏处是每次改次级镜像都要重新打包 OpenSBI,迭代效率低,生产环境追求简单才建议这么干。
三种模式的对比总结如下:
| 模式 | 跳转目标 | 灵活性 | 适用场景 |
|---|---|---|---|
| FW_DYNAMIC | 由前级传入 | 高 | QEMU、虚拟化 |
| FW_JUMP | 编译时指定 | 中 | 开发调试、OpenSBI 与 U-Boot 分离烧写 |
| FW_PAYLOAD | 打包在固件尾部 | 低 | 生产固化、最小化部署 |
3.2 U-Boot 针对 RISC-V 的编译与配置要点
U-Boot 官方主线对 RISC-V 的支持已经相当成熟,但你不要指望拿一个通用的 defconfig 编译出来就能跑,不同 SoC 平台的串口、定时器、网卡驱动差异很大,必须用平台专属配置。
交叉编译工具链方面,我习惯用riscv64-linux-gnu-gcc系列。需要说明的是,U-Boot 运行在 S 模式,这是 RISC-V 平台和 ARM 平台的一个显著区别。这个区别带来的约束是:U-Boot 里所有涉及特权操作的地方,不能直接读写 CSR,必须通过 SBI 接口间接实现。
比如 U-Boot 的定时器驱动,底层调用的是 SBI 的定时器接口;多核启动时需要唤醒其他 hart,底层用的是 SBI 的核间中断接口。如果 OpenSBI 没配对,或者 SBI 接口版本不匹配,U-Boot 的表现往往是"卡死在没有任何日志最开始"。这种问题排查起来比较头疼,因为连一点输出都没有,很难判断是 CPU 没跑起来,还是跑起来了但串口驱动没工作。我自己的做法是:先编译一个带早期调试串口输出(CONFIG_DEBUG_UART)的 U-Boot,把debug_uart_init打开,至少能看到"U-Boot 已经进入 C 环境"之类的信息,再往后定位。
另外一个容易忽略的点是 U-Boot 的链接地址CONFIG_TEXT_BASE。RISC-V 平台通常设定在0x80200000附近,这个地址必须和 OpenSBI 的跳转地址、以及你实际加载 U-Boot 的内存地址保持一致。地址对不上,你会在启动过程中看到指令异常或者完全无响应。
3.3 一条真实启动日志的逐段拆解
启动日志是排查问题最直接的抓手。下面我用一条典型的 RISC-V Linux 启动日志,把三个阶段拆开讲。
OpenSBI 阶段的日志开头通常是这样的:
OpenSBI v1.2 ____ _____ ____ _____ / __ \ / ____| _ \_ _| ... Platform Name : riscv-virtio,qemu Platform Features : medeleg这段日志告诉你 OpenSBI 已经跑起来了,平台名、特性、PMP 条数都打了出来。如果卡在这一段之前,优先怀疑 BootROM 到 OpenSBI 的加载链路。
U-Boot 阶段的日志:
U-Boot 2023.04 (Apr 05 2024 - 10:23:45 +0800) CPU: rv64imafdc Model: riscv-virtio,qemu DRAM: 1 GiB这里能看到 U-Boot 已经接管,CPU 特性、内存大小都识别出来了。如果 OpenSBI 正常打印而 U-Boot 一个字都没有,就去查跳转地址和 U-Boot 的串口初始化。
内核阶段的日志:
Linux version 6.1.0 ... Machine model: riscv-virtio,qemu earlycon: uart0 at MMIO 0x10000000到这一步,说明 U-Boot 已经把控制权顺利交给了内核。内核早期打印出现后,剩下的就是内核自己的初始化流程了。
这三段日志共同构成了一条完整的启动时间线。哪一段缺失,问题就在哪一段对应的层级。记住这个观察顺序,能帮你省掉大量瞎猜的时间。
4. 内核启动交接:a0、a1、a2 与设备树背后的约定
4.1 三个寄存器里的启动协议
Bootloader 把控制权交给 RISC-V Linux 内核之前,必须遵守一套 ABI 约定。这个约定在 Linux 内核文档里写得非常清楚,核心信息通过几个寄存器传递:
a0:当前 hart 的 hartid。a1:设备树二进制(DTB)在物理内存中的地址。a2:早期内核曾用它传递 initrd 的物理地址,后来规范建议把 initrd 信息写进设备树,a2就逐渐弃用了,但很多旧代码和文档里还会见到。
这套约定非常简洁,但也意味着 bootloader 一旦传错,内核会在启动早期就出问题。比如手动用 U-Boot 的go命令跳转内核时,如果忘记设置a1为正确的 DTB 地址,内核去读设备树时拿到的是错误内存,轻则内存大小识别错误,重则直接 panic。
所以日常调试我强烈建议用bootm这类 U-Boot 标准引导命令,而不是手动go。U-Boot 的bootm会根据镜像头、环境变量和bootargs自动安排好 a0、a1 等寄存器的值,你只需要保证loadaddr、fdtaddr这些环境变量没有写错。
4.2 设备树在 RISC-V 平台的特殊地位
如果你做过 ARM 平台的 bring-up,对设备树肯定不陌生。但在 RISC-V 平台,设备树的重要性比 ARM 还要再高一个级别。原因是 RISC-V 的 CPU 特性高度碎片化:不同核心支持的指令集扩展不同,hart 数量可能不同,中断控制器可能是 PLIC、CLINT、APLIC 中的任意一种,如果不通过设备树把这些信息告诉内核,内核根本无法对这台机器建立正确的硬件视图。
设备树里几个关键节点分别是:
cpus节点:列出每个 hart,每个cpu子节点的reg属性对应 hartid,riscv,isa属性描述指令集扩展,比如rv64imafdc。这里写错一个 hartid,多核启动就会出问题。memory节点:描述内存的物理地址范围和大小。内核的内存管理完全依赖这个节点,写错会导致可用内存与物理内存不一致。soc下的中断控制器节点:必须把中断类型、地址、中断号描述清楚,否则外设驱动在 probe 阶段就会失败。
遇到设备树问题,我通常会在 U-Boot 里先把 DTB 从存储介质读到内存,用 U-Boot 的fdt命令查看当前内容是否正确,比如fdt print /cpus看 CPU 节点。如果怀疑某个地址不对,把 DTB dump 出来,拿到主机上执行dtc -I dtb -O dts反编译成可读的 dts 源码检查。这类问题的特征很典型:CPU 和内存初始化正常,但外设驱动起不来,报错往往集中在 "no usable irq" 或者 "failed to request IRQ" 之类的地方。
4.3 PMP 物理内存保护与多 hart 启动的注意事项
启动流程里还有一个容易被忽视但破坏力极大的角色:PMP(Physical Memory Protection)。PMP 是一组 CSR,用于限制 S 模式和 U 模式对物理内存的访问权限。OpenSBI 启动时会配置 PMP,把自己的代码和数据区域设置为 M 模式独占,然后向 S 模式开放它应该访问的区域。
问题就在"应该访问"这几个字上。如果 PMP 配置过严,S 模式访问某块内存地址时直接触发 permission fault,内核崩溃;如果过松,M 模式固件的安全边界就被架空了。用 OpenSBI 默认配置时,特别要注意你的自定义内存区域是否被 PMP 覆盖到。比如 U-Boot 把内核镜像加载到了某个 OpenSBI 没有开放的地址,跳转内核后你会看到非常奇怪的 fault 信息,排了半天发现是 PMP 在作祟,这种经历我身边不少人都有过。
另外,多核启动是另一个高频翻车点。上电后通常只有一个(或少量)hart 执行 BootROM 后续代码,其他 hart 停在 WFI 等待中断。当 U-Boot 或者内核准备好之后,通过 SBI IPI(核间中断)唤醒其他 hart。每个 hart 被唤醒后,各自的a0寄存器装的是自己的 hartid。写汇编启动代码时务必注意:不能简单地让所有 hart 共用同一个初始化流程里的全局变量,否则就是数据竞争和不可预期的崩溃。正确做法是每个 hart 根据自身 hartid 决定执行路径,或者通过原子操作保证只有一个 hart 执行全局初始化,其他 hart 等待。
5. 踩坑实录:RISC-V 启动调试里的三个典型问题
5.1 问题一:OpenSBI 正常打印,U-Boot 一个字都没有
这个现象我见过太多次了。OpenSBI 的日志顺利输出,然后板子就像断电了一样,U-Boot 完全没有输出。绝大多数情况,根因不是"U-Boot 代码有 bug",而是地址没对齐。
排查链路分两步。第一步,确认 OpenSBI 下一级跳转地址和 U-Boot 实际加载地址一致。OpenSBI 如果用的是 FW_JUMP 模式,检查FW_JUMP_ADDR;U-Boot 的加载地址由CONFIG_TEXT_BASE决定。两个地址如果差了一个字节,PC 都会飞到没有代码的地方,表现就是死寂。第二步,确认 U-Boot 的串口驱动和调试串口配置。RISC-V 平台上 U-Boot 默认可能没有打开早期调试输出,你需要打开CONFIG_DEBUG_UART,并且在板级配置里正确设置 UART 的寄存器地址和时钟频率,否则最早的启动信息根本打不出来,你看到的"无输出"可能是假象。
这类问题排查时,先看地址,再看串口,最后才看代码逻辑。顺序反了,很容易在错误的方向上浪费一整天。
5.2 问题二:跳转内核后立刻死机,报错诡异且无规律
还有一种更隐蔽的情况:OpenSBI 正常、U-Boot 正常、bootm也正常,但内核打印了几行日志之后突然死掉,死机的位置还不固定,看起来像"内存不稳定"。
遇到这种情况,我建议优先怀疑 PMP 和缓存一致性。RISC-V 的 S 模式访问内存要经过 PMP 检查,如果 OpenSBI 开放的 PMP 区域没有覆盖内核镜像所在的内存,第一次访问未必会崩,可能是某次页面分配或 DMA 操作偶然触碰到禁区才崩,所以表现毫无规律。另一个常见原因是缓存一致性问题——RISC-V 本身默认不保证多核之间缓存一致,不同 hart 共享内存时要非常小心地处理内存屏障和缓存刷新。如果 Bootloader 加载内核时没有正确刷新缓存,内核执行到某条指令时发现读到的还是旧数据,死机位置自然飘忽不定。
我在调试这类问题时,会先在 OpenSBI 里临时把 PMP 配置放宽,用最宽松的方式跑一遍,如果能起来,再一项项收紧配置。这个"先宽后严"的思路,在排查启动类问题时几乎永远适用。另外,启动参数里加earlycon能让你看到内核最早的执行点,对缩小死机范围帮助很大。
5.3 问题三:多核启动只跑了一个核心,性能完全不对
如果你用的是多核 RISC-V 芯片,启动后却发现所有检核手段都显示只有一个核在线,通常原因不外乎两个:设备树里 CPU 节点写少了,或者有核心在启动过程中没有被正确唤醒。
设备树这块,cpus节点下每个 hart 都要有一个cpu子节点,reg字段对应 hartid,别图省事只写一个。内核完全依赖设备树来枚举在线 CPU,少写一个节点,内核就不知道这颗核心存在,自然不会给它分配任务。唤醒这块,OpenSBI 默认会在初始化后通过 SBI IPI 唤醒所有在线 hart,但如果平台配置里没有正确描述核间中断,或者前级 BootROM 已经把某些 hart 置于异常状态,那部分核心就可能永远停在 WFI 里。
遇到多核问题,先确认硬件层面能看到几颗核心:在 U-Boot 里执行 CPU 相关命令,或者看 OpenSBI 平台初始化日志里的 hart 数量。再对比设备树里定义了几颗核心,以及内核启动日志里smp: Bringing up secondary CPUs之后出现了几个核。一层层对照下来,问题基本就能定位。
RISC-V 启动调试,我自己的体会是:链路层级多、地址交接多,但每一层都有清晰的日志边界和 ABI 约定。你不必把每一行汇编都背下来,只要掌握"复位后 M 模式起步、多级 Bootloader 逐级交权、最后按寄存器约定把控制权交给内核"这条主线,遇到问题顺着链路逐层排查,效率会高出不少。如果你正卡在某个启动环节,不妨先从 a0、a1 这两个寄存器和 Hartid 开始数起,很多时候答案就藏在这些最基础的地方。