做嵌入式这行越久,越发现 RISC-V 的启动流程是个绕不开的坎。很多朋友拿到开发板,串口刚连上,一按复位,看到 Bootloader 正常打印、内核顺顺当当加载起来,就觉得一切理所当然;可真到出问题时,从上电到内核入口之间到底发生了什么,脑子里往往是一片空白。我写这篇内容,就是想把这套路径——从上电复位、Bootloader 接手,到内核加载与跳转——从头到尾拆开讲一遍,配合我自己实际调过的板子和代码,告诉你每一步到底发生了什么、为什么这么设计、出问题时该从哪里下手。无论你是刚接触 RISC-V 的嵌入式新人,还是从 ARM 转过来的老工程师,这篇都能帮你建立一套完整的启动流程心智模型。
1. 启动路径全景:从复位向量到内核入口
1.1 为什么很多人绕不开启动流程这个坎
我在 ARM 平台上调过不少启动问题,当时觉得 ARM 的启动链路已经够绕了,直到切到 RISC-V 才发现,架构上看起来简洁,真正落到芯片上,每家 SoC 厂商的理解都不一样。RISC-V 指令集本身只规定了复位后从 M-mode 开始执行,却没规定从哪个地址执行——这一点是很多新手踩坑的第一个地方。
更麻烦的是,RISC-V 生态里既有跑 Linux 的应用处理器,又有跑 RTOS 的 MCU,还有介于两者之间的异构芯片,这些场景的启动路径差别非常大。但不管怎么变,核心链路是一致的:上电后从某段固定的代码开始,完成时钟、内存、外设的初始化,再把控制权交给下一级程序。把这个骨架吃透,遇到任何具体芯片都能快速对上号。
1.2 三个阶段的划分:硬件自举、Bootloader 引导、系统加载
我习惯把整个启动路径拆成三个阶段。硬件自举阶段从电源稳定开始,芯片内部的启动 ROM(BootROM)会先接管,完成最基础的初始化:关闭看门狗、设定时钟源、判断启动介质、把第一段代码搬到 SRAM,或者直接映射 Flash。这部分代码固化在芯片内部,一般不可修改,但它的行为会通过寄存器和引脚暴露出来。
Bootloader 引导阶段是真正可以发挥的地方。无论是 U-Boot、OpenSBI 还是自研的 mini loader,要做的事情都差不多:初始化 DDR、配置串口、加载外部镜像、解析设备树、跳转。这一阶段占了整个启动时间的大头,也是性能优化和调试的重点。
系统加载阶段则从 Bootloader 把执行权交给内核或 RTOS 开始,一直到系统 banner 打印出来、调度器跑起来。对 Linux 来说,入口在 arch/riscv/kernel/head.S;对 RT-Thread 来说,入口是启动汇编之后调用的 rtthread_startup。理解了这三个阶段,后面所有细节都能往这个框架里塞。
2. 上电复位:RISC-V 最先执行的代码在哪
2.1 复位向量与硬件启动地址
RISC-V 规范里,复位后处理器默认处于 M-mode,PC 的值由平台决定。这里有个常见的误区:很多教程说 RISC-V 复位后从 0x80000000 执行,这其实是 QEMU 的 virt 平台和部分开发板约定俗成的地址,并不能代表所有 RISC-V 芯片。
实际产品里,有的芯片把复位地址设在片内 ROM 的起始位置,比如 0x00000000;有的芯片有一段 BootROM,复位后先跑出厂固件再跳到用户程序;还有些 MCU 可以直接配置复位后从哪个 bank 的 Flash 启动。拿到一块新板子,第一件事永远是查芯片手册里的 Reset Vector、Boot Address、Boot ROM 这几个关键字,而不是凭经验猜地址。
我调过的一块 RISC-V MCU,复位后从 0x20000000 的 SRAM 映射区开始执行,因为它的 BootROM 已经提前把用户代码从 SPI Flash 搬到了 SRAM,然后再把 PC 指向搬运后的地址。如果我不看手册,直接按 0x80000000 去截取启动日志,根本对不上。
2.2 启动模式如何影响执行路径
启动模式选择是硬件自举阶段的关键环节。ARM 平台常见 BOOT 引脚拨码开关,RISC-V 芯片也一样,只不过实现方式更灵活。有的芯片用一组 GPIO 在复位释放时锁存电平,有的用 OTP/eFuse 烧录默认启动介质,还有的既支持引脚配置,也可以通过 BootROM 里的命令交互切换。
以我常用的那款芯片为例,它有两根 BOOT 引脚,四种组合分别对应从主 Flash 启动、从 SD 卡启动、从 UART 下载模式、从调试口下载模式。启动介质一旦选错,表现出来就是串口完全无输出,或者打印一句“Boot mode invalid”就停住。
这里给你一个排查建议:复位前先用万用表量一下 BOOT 引脚的电位,确认硬件拨到了预期档位。很多时候启动失败不是软件问题,而是拨码开关被人动过,或者上拉电阻虚焊,这类问题浪费的时间远比写代码多。
2.3 BootROM 做了什么,为什么它要先跑
BootROM 是芯片出厂固化的第一段代码,它存在的意义是解决“CPU 还没初始化外部存储控制器,怎么执行用户程序”的鸡生蛋问题。芯片上电时时钟不稳定、DDR 控制器不可用、外部 Flash 也无法访问,CPU 唯一能确保无脑执行的就是片内 ROM。
BootROM 的典型行为是:配置基础时钟、关闭看门狗、根据启动引脚选择介质、把引导程序(通常叫 SPL 或者二级 loader)从 Flash/SD 卡读入 SRAM,然后跳转。以 SPI NOR Flash 启动为例,BootROM 会按固定协议发起读命令(比如 0x03 读命令),把 Flash 前若干个扇区的内容读进 SRAM。所以烧录地址、烧录内容都必须和 BootROM 的约定严格对应。
我遇到过一位朋友,自己写的 bootloader 明明烧在 Flash 的 0x0 地址,烧录也成功,但就是起不来。后来查手册才发现,这颗芯片的 BootROM 约定引导程序从 0x2000 偏移开始读,他烧的偏移不对,自然进不了用户代码。这类“地址约定不一致”的问题,在 RISC-V 平台尤其常见,因为各家 BootROM 的布局差异太大了。
3. Bootloader 实战:从最小引导到 U-Boot 移植
3.1 Bootloader 的核心职责与为什么分两级引导
Bootloader 的核心职责可以浓缩成四件事:初始化硬件、加载镜像、配置运行环境、跳转执行。听起来简单,但每一件事展开都有很多细节。
为什么很多 RISC-V 平台要做两级引导?因为 BootROM 能用的 SRAM 通常只有几十 KB,放不下完整的 U-Boot。所以先用一级引导(SPL)做最小硬件初始化,把 DDR 初始化好,再把完整的 U-Boot 加载到 DDR 里跑。这种设计在 ARM 平台也有,RISC-V 这边直接沿用并强化了。
我在实际项目中常用的组合是 BootROM -> OpenSBI -> U-Boot -> Linux。OpenSBI 在这里充当 M-mode 固件,U-Boot 跑在 S-mode,它通过 ecall 调用 OpenSBI 的运行时服务。这样内核启动时就能利用 SBI 提供的接口完成一些特权操作,而不需要让内核自己处理 M-mode 的细节。
3.2 手写一个最小 Bootloader 的关键步骤
如果你不想一上来就啃 U-Boot,可以先写一个几十行的最小 bootloader,把它跑在 QEMU 的 virt 平台上,把“跳转”和“链接脚本”这两个概念彻底吃透,后面再接触复杂方案会轻松很多。
以 QEMU virt 平台为例,目标是把一段程序放到内存起始处,初始化栈指针,关中断,然后跳转到一个 C 函数。启动汇编大致长这样:
.section .text.init .globl _start _start: csrr t0, mhartid la sp, _stack_top csrw mstatus, zero call main loop: wfi j loop这段代码做的事情很直白:读取当前 hart 的编号,设置栈顶指针,清零机器状态寄存器(避免意外中断),进入 C 语言的 main 函数。注意 wfi(等待中断)配合死循环,是为了防止 main 返回后 PC 跑飞。
链接脚本里,_stack_top的地址必须放在内存布局的末尾,因为栈是向下增长的。如果栈顶设错,函数调用一深,现场就被破坏,表现是随机死机、返回值错乱,这类问题用串口打印都不一定能抓到,最好用 GDB 看栈回溯。
编译命令大致是:
riscv64-linux-gnu-gcc -nostdlib -march=rv64gc -mabi=lp64d -T link.ld start.S main.c -o boot.elf riscv64-linux-gnu-objcopy -O binary boot.elf boot.bin这里-nostdlib表示不链接标准库,因为裸机环境下没有操作系统帮忙,标准库的启动代码反而会引入一堆无关初始化。-march指定指令集,-mabi指定 ABI,这两者必须匹配,否则链接阶段会报错。
3.3 U-Boot 在 RISC-V 平台上的配置与编译
最小 bootloader 能帮助理解原理,但真正量产还是要用 U-Boot 这种成熟方案。U-Boot 对 RISC-V 的支持已经很完善,官方仓库里有针对 QEMU、SiFive、StarFive、全志等平台的 defconfig。
以 QEMU virt 平台为例,配置和编译过程如下:
make CROSS_COMPILE=riscv64-linux-gnu- qemu-riscv64_smode_defconfig make CROSS_COMPILE=riscv64-linux-gnu- -j$(nproc)编译产物里有两个关键文件:u-boot和u-boot.bin。如果你的平台需要 SPL,还会有spl/u-boot-spl.bin。烧录时通常把 SPL 放在 BootROM 能读到的位置,U-Boot 主体放在 DDR 加载地址对应的 Flash 偏移处。
移植一块新开发板时,不要从零写板级文件,最好找一个功能相近的参考板,复制其目录,然后逐个修改内存映射、串口地址、时钟配置、Flash 驱动。我踩过的坑是,很多芯片的 UART 地址在手册里给的是外设基地址加偏移,但 U-Boot 的 dts 文件里用的是完整物理地址,两者一旦对不上,串口永远不出字,而且不报错。
设备树在 U-Boot 里也至关重要。U-Boot 编译时会把 dts 编进镜像,启动时通过它知道硬件长什么样。如果你改了板子上的外设,却忘了同步 dts,可能出现“U-Boot 起来了,但内核认不到设备”的诡异问题——其实设备树里压根没这个节点。
3.4 设备树在启动链路中的角色
很多刚开始接触 RISC-V 的人都问,为什么内核不直接在代码里写死外设寄存器地址,非要引入设备树。原因很简单:同一个内核要在不同配置的板子上跑,用设备树描述硬件,内核代码就能保持通用。
设备树在启动链路里像一张“硬件清单”。Bootloader(U-Boot)负责把清单从存储介质读出来,可以裁剪、修改,然后放在内存某个约定好的位置,跳转内核时把地址通过 a1 寄存器传过去。
设备树写错的现象很典型:内核能启动,但某个驱动的 probe 失败,dmesg 里有类似“-19”的错误码。我调试过一个网口问题,查了半天驱动,最后发现是 dts 里把中断号写错了一位,导致中断申请失败,网口永远 link 不上。
3.5 OpenSBI 与 M-mode 固件的必要知识
如果你的目标平台要跑 Linux,OpenSBI 基本绕不开。它运行在 M-mode,向上给 S-mode 的内核提供 ecall 服务接口,向下做硬件初始化。OpenSBI 有三种常见构建方式:FW_JUMP、FW_PAYLOAD、FW_DYNAMIC。其中 FW_PAYLOAD 会把 U-Boot 或内核镜像直接打包进 OpenSBI,跳转时直接进入被打包的程序,简单省事;FW_JUMP 则只是在 OpenSBI 初始化完成后跳到固定地址,需要你提前把下一级程序放在那个地址。
选择哪种模式,取决于部署流程。我自己的偏好是 FW_JUMP 加外部加载,好处是 OpenSBI 镜像体积小、更新灵活,U-Boot 和内核独立存放。调试早期则喜欢 FW_PAYLOAD,一条镜像搞定,少一层跳转关系。不管用哪种,都要确保 OpenSBI 的链接地址和你的内存布局一致,否则它会把自己覆盖掉,表现是莫名其妙的死机。
4. 内核加载与跳转:寄存器约定和现场交接
4.1 a0、a1 寄存器与 Hart ID、DTB
跳转内核是整个启动流程中最精细的一步。RISC-V Linux 内核约定,跳转时 a0 寄存器存放当前 hart 的 ID,a1 寄存器存放设备树二进制(DTB)的物理地址。这套约定写在内核文档里,U-Boot 也遵循它。
为什么强调物理地址?因为跳转时内核还没开 MMU,所有地址都按物理地址访问。如果你把 a1 传了一个虚拟地址,内核早期访问设备树时会直接触发异常,表现通常是启动卡在很靠前的地方,没有任何打印。
还有一点容易被忽略:跳转前必须关闭中断,尤其是外部中断和定时器中断。如果带着中断进入内核,内核在初始化中断控制器之前就收到一组 pending 的中断请求,行为完全不可预知。U-Boot 在跳转前通常会清理并屏蔽中断,但如果你自己写 bootloader,很容易漏掉这一步。
4.2 跳转前的现场准备清单
我整理过一份跳转内核前的检查清单,每次出问题都会对照检查:
- 当前特权级是否是 S-mode。如果要从 M-mode 直接跳,需要先完成 mret 到 S-mode。
- a0 是否为当前运行 hart 的编号。
- a1 是否指向有效的 DTB 物理地址。
- 页表是否关闭,即 satp 寄存器是否清零。
- 中断是否全部关闭,sstatus 的 SIE 位是否清零。
- 栈指针是否有效,并且设置在内核可用的内存区域。
- 是否需要设置 mret/trap 上下文,保证内核能从异常返回。
其中页表关闭这一项,很多从 ARM 转过来的朋友容易忽略。ARM 内核启动时会自己重建页表,但 RISC-V 的规范要求 Bootloader 跳转时 satp 为零,也就是处于直接地址映射状态。如果 satp 里残留了某个旧的页表基地址,内核第一个取指就可能取到错的位置。
4.3 内核入口的第一段代码
Linux RISC-V 内核的入口在 arch/riscv/kernel/head.S,U-Boot 跳转进去之后,它要做的事情依次是:读取 a0、a1 保存到全局变量,建立早期页表,开启 MMU 缓存,设置 C 运行环境,然后跳到 start_kernel。
早期页表阶段有个有趣的细节:内核先用一段固定映射把内核镜像所在区域映射好,保证指令可以继续取指。这段映射的页表是静态定义的,链接脚本里给它留了空间。如果你改过内核的链接地址,却没有同步修改这部分的页表定义,启动时会触发指令页错误,现象就是第一次访问内存直接跳回异常向量。
对于 RT-Thread 这类 RTOS,启动路径简单很多。它没有设备树和 MMU 的负担,复位后通常是汇编清 BSS、设栈、调 rtthread_startup,然后进入调度器。RT-Thread 在 RISC-V 上的移植关键也在入口汇编和链接脚本,特别是向量表的放置和栈空间的分配。
5. 常见启动故障与调试技巧实录
5.1 卡在复位、无打印的排查思路
启动调试最让人抓狂的现象就是:串口全无输出,程序好像根本就没跑。我一般按下面顺序排查。
第一步确认电源和时钟。先量电压,再用示波器或逻辑分析仪看主时钟引脚有没有振荡。外部晶振不起振是常见的隐藏问题,尤其是起振电容配错的时候。第二步确认复位引脚释放了没有。有的板子复位 IC 异常,电压正常但一直按住复位,CPU 永远停在复位状态。第三步确认 BootROM 是否执行。如果 BootROM 支持某种指示,比如点亮 LED 或者拉高某个 GPIO,可以借助它判断路径。第四步确认加载地址和烧录地址一致。
在这个排查过程里,串口代码尽量放早。我的习惯是,Bootloader 第一条指令后立刻初始化串口,输出一个字符。这样只要 CPU 跑起来,至少能看到第一个打印,能大幅缩小问题范围。
5.2 设备树传递失败的典型症状
设备树传递失败通常有几种表现:内核启动时卡在 Machine model 相关输出之前,或者报错说 DTB 不在内存范围内,又或者启动后部分驱动不可用。排查时,先用打印确认 U-Boot 传给内核的 a1 值,再与 DTB 实际加载地址对比。
一个实用技巧是在 U-Boot 环境里用fdt addr命令设置设备树地址,然后用fdt print验证设备树内容是否完整。如果fdt print报错,说明 DTB 数据损坏,可能是加载时长度算错,也可能是 Flash 里的 DTB 本身就不完整。我调试过程中还遇到过 DTB 对齐问题,某些内核要求 DTB 地址满足 8 字节对齐,不满足会直接报错。
5.3 RT-Thread 等 RTOS 的启动初始化路径对照
很多 MCU 开发者用的不是 Linux,而是 RT-Thread。RT-Thread 在 RISC-V 平台的启动初始化流程相对轻量,一般从 entry 汇编开始,到 rtthread_startup,再到调度器运行。具体路径是:硬件初始化、时钟和中断控制器配置、BSS 清零、系统堆初始化、内存堆初始化、定时器初始化、调度器启动。
RT-Thread 移植时有一个容易被忽略的点:中断向量表。RISC-V 的中断入口由 mtvec/stvec 寄存器控制,必须指向正确的异常处理函数。如果你发现中断一触发就死机,先检查向量表地址是否设置正确,以及处理函数是否用正确的汇编保护现场。另外一个常见坑是栈大小分配不足,任务一多、调用一深就栈溢出,表现同样是无规律死机。
5.4 调试工具链组合:OpenOCD、串口、逻辑分析仪
我日常调试 RISC-V 启动问题常用的工具组合是 OpenOCD 加 GDB、串口、逻辑分析仪三件套。OpenOCD 负责通过 JTAG 访问 CPU 内部状态,GDB 用来设断点、单步、查看寄存器;串口用来输出 bootloader 日志;逻辑分析仪用来查时序问题,比如 Flash 读时序异常、时钟信号丢失。
使用 OpenOCD 时,常见问题是 target 配置文件里的参数不对,导致 GDB 无法连接 hart。解决方法是先确认 target 的名称,使用 RISC-V 通用 target,并检查 JTAG 信号线是否连接完好。调试早期建议用 GDB 在跳转内核的地址下断点,确认 a0、a1 寄存器的值是否符合预期,这一步非常有效。
5.5 性能优化:从启动时间到 Flash 读取
如果你的产品对启动时间有要求,可以从几个方向优化。第一,BootROM 到 SPL 阶段,Flash 读速度往往是瓶颈,可以尝试提高 SPI 时钟频率,或者启用 Dual/Quad 读模式,但要注意 Flash 是否支持以及 BootROM 是否允许。第二,DDR 初始化参数需要仔细调,有些芯片手册会给参考值,但不一定最优。第三,U-Boot 里可以裁剪不需要的驱动和命令,减少镜像体积和解压时间。
我测试过的一个方案里,通过把 SPI Flash 的读模式从标准 0x03 改成 Quad 0x6B,并把时钟从 50MHz 提升到 80MHz,启动阶段读镜像的时间缩短了将近一半。不过这种优化必须严格验证 Flash 的时序余量,我遇到过高温环境下 Quad 模式读写出错的情况,最后只能降回 Dual 模式保证量产稳定性。
最后再分享一个小技巧——每次调试启动问题,我都会在 Bootloader 和内核之间留一个“双保险”打印点,一个在跳转前,一个在跳转后的第一行汇编代码里。这样,如果跳转后无输出,我能立刻判断是跳转本身失败,还是内核早期初始化出了问题。这一个小习惯,帮我省下了大量排查时间。RISC-V 启动流程说复杂也复杂,但只要你把复位向量、BootROM、Bootloader、设备树、寄存器约定这条主线捋顺,再配合一套趁手的调试工具,绝大多数问题都能在两三分钟内定位到具体环节。