
拿到一块 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却忘了芯片复位后先进的是片内 BootROMBootROM 因为找不到合法的启动镜像一直死循环在那个检查镜像签名的流程里。这就是典型的连起点都搞错了的排障现场。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/串口加载 SPLSPLSecondary Program LoaderSPI 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。最常见的启动链条长这样上电复位 - BootROMM 模式 - OpenSBIM 模式 - U-BootS 模式 - Linux 内核S 模式 - 应用程序U 模式每次交接都需要完成两件敏感的事情地址对准特权级切换。BootROM 要跳到 OpenSBI 的入口地址同时保证 CPU 还在 M 模式OpenSBI 初始化完硬件和 SBI 服务之后通过mret降权跳到 U-BootU-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 打印完一片死寂却去翻内核代码找原因完全跑偏。这两层之间的分工用一张表说清楚能力OpenSBIU-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属性对应 hartidriscv,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 启动的注意事项启动流程里还有一个容易被忽视但破坏力极大的角色PMPPhysical 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_ADDRU-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 开始数起很多时候答案就藏在这些最基础的地方。