拿到一块全新的RISC-V开发板,上电之后串口什么反应都没有,这种问题我调试过太多次了。排查到最后,十次里有九次都出在启动链路的某个环节上:复位向量没对、BootROM没找到后级镜像、OpenSBI和U-Boot的跳转地址不匹配。RISC-V启动流程和Bootloader这摊事,说难不难,但它跟ARM、跟STM32这类MCU的启动路径确实有本质区别——M模式、S模式、Hart、SBI这些概念混在一起,很容易把新手绕晕。这篇东西我会从芯片上电那一刻开始,把BootROM、SPL、OpenSBI、U-Boot到内核加载的整条路径拆开讲,并结合QEMU和实际场景给出可直接复现的调试方法。无论你是做MCU想往Linux走,还是刚接触RISC-V想做Bring-up,这条启动链路都是绕不开的第一课。
1. 启动链路的全貌:一次上电后到底发生了什么
1.1 与ARM/传统MCU启动流程的本质差异
很多人是从STM32转到RISC-V的。STM32这类MCU的启动非常直观:复位向量直接指向Flash里的应用程序,芯片从地址0x08000000开始取指,跑起来就是main函数。整个过程几乎没有“Bootloader”的概念,除非你主动做一个用于IAP升级的引导程序。
RISC-V SoC则完全不是这个路子。以我现在常用的QEMU virt平台为例,芯片复位后PC并不是指向DDR,也不是指向Flash,而是指向片内固化的BootROM代码,这个地址由芯片设计者决定,比如QEMU virt是0x1000,某些国产SoC则是0x00000000或者0x00040000。BootROM的空间通常只有几十KB,它干不了复杂的事,只能完成一个目标:把下一级启动代码从外部存储介质搬运到SRAM或者DDR中,然后跳转。
这里就要说到和ARM Cortex-A系列的一个关键区别:RISC-V把权限模式分成了M模式(Machine)、S模式(Supervisor)、U模式(User),而ARM用的是EL3、EL2、EL1。BootROM运行在最高权限的M模式下,U-Boot这类Bootloader可以运行在M模式或者S模式,Linux内核则必须运行在S模式。这就多出来一个M模式到S模式切换的问题,OpenSBI就是为了解决这个问题而生的,这也是RISC-V启动流程里最有特色的部分。
1.2 一条完整的RISC-V启动链:BootROM → SPL → U-Boot → Kernel
我们把整条链路展开,从按下电源键到内核真正接管硬件,通常会经历这几个阶段:
- 复位与BootROM:处理器复位,PC跳转到BootROM,运行最底层的固化代码。BootROM初始化最小硬件环境,比如时钟、栈指针、启动介质对应的控制器,然后从当前选中的启动源读取下一级代码到SRAM。
- SPL(Secondary Program Loader):SPL负责初始化DDR,并把真正的U-Boot主体拷贝到DDR中。为什么需要这一步?因为BootROM太小,而DDR的初始化代码又很长,不可能全塞进BootROM。SPL就是U-Boot裁减出来的“最小可运行版”,通常只有几十KB。
- OpenSBI:如果系统要跑Linux,M模式不能直接跳进S模式,需要一个固件在M模式下“兜底”。OpenSBI在M模式完成PMP、定时器、中断控制等资源初始化,然后跳转到S模式,把权交给U-Boot或内核。
- U-Boot:U-Boot运行在S模式(也可以配置成M模式),它负责加载内核镜像和设备树到DDR,设置好启动参数,最后跳转到内核入口。
- Linux内核:内核在S模式运行,早期汇编代码建立页表、解压镜像,随后进入start_kernel,完成体系结构初始化和子系统初始化。
这个结构看起来复杂,但每一级的存在都有它的道理。就像接力赛,第一棒跑不了全程,但它必须把接力棒稳稳交到第二棒手里。
这里我补充一句:如果跑的是RT-Thread或者裸机程序,链路可以大幅简化,很多MCU级别的RISC-V芯片直接由BootROM加载应用程序到SRAM就完事了。这个差异我后面会单独拿一节来说。
1.3 阶段划分背后的“够用就好”哲学
为什么不能把U-Boot直接固化在芯片里?为什么BootROM不直接加载Linux镜像?答案就两个字:空间和灵活性。
BootROM固化在芯片内部,出货之后改不了。芯片厂商不知道你外面接的是Nor Flash还是SD卡,也不知道你的DDR颗粒是哪个厂家的,所以BootROM只能做一个非常通用的事情:按照固定的协议,从启动介质里读固定长度的代码到固定地址。至于这段代码是什么、干什么,都交给后面的SPL或者U-Boot去决定。
DDR初始化的坑也在这里。每一块板子的DDR布线、颗粒型号、频率都可能不同,参数需要逐个调整。U-Boot里针对不同开发板维护了一套完整的DDR初始化序列,这套代码如果全塞进BootROM,芯片面积和成本都会暴涨。所以实际工程中常见的做法就是层层分工,每一级只解决那一级该解决的问题。
2. 前三级启动实战:从Reset Vector到U-Boot
2.1 复位向量与BootROM:芯片的第一口饭
复位向量也就是Reset Vector,是CPU上电后取第一条指令的地址,它由芯片设计者定义,RISC-V架构本身没有规定这个地址必须是多少。这跟x86那种固定地址不一样,RISC-V世界里的Bootloader必须“承认”一个事实:不同芯片的复位地址千差万别。
拿几个我实际接触过的平台举例:
| 平台 | 复位向量 | 启动介质 |
|---|---|---|
| QEMU virt | 0x1000 | 无,直接由QEMU加载镜像 |
| 平头哥系列MCU | 0x00000000 | 片内Flash |
| 全志D1 | 0x00040000 | SD/Nand/QSPI等 |
| 嘉楠K210 | 0x00000000 | 片内Flash(BootROM从Flash搬运) |
BootROM里做的事情,从软件角度看大致是这样:先设置好栈指针(SP),因为接下来可能要调用C函数;然后初始化启动源对应的控制器,比如从SD卡启动就要初始化SD控制器,从QSPI Flash启动就要初始化QSPI控制器;接着把二级镜像按固定偏移读入SRAM,做一次校验,最后跳转。
校验方式各个芯片不一样,有的是简单的CRC32,有的用签名验签。生产环境里建议不要跳过这一步,因为启动介质里的镜像损坏是很常见的故障。
关于BootROM还有一个很坑的点:它用的加载地址和二级镜像的链接地址必须一致。SPL编译时,链接脚本会把代码段的起始地址设成一个固定的SRAM地址,这个地址必须和BootROM搬运的目的地址相同,否则跳过去就是跑死。我在K210上就踩过这个坑,SPL链接到0x80000000,BootROM实际把它搬到了另一个地址,结果一上电就HardFault。
2.2 SPL与OpenSBI:M模式与S模式的交接班
SPL这段代码堪称“夹缝中生存”。它既要足够小,小到能塞进SRAM,又要足够强大,强大到能初始化DDR。U-Boot的SPL机制通过CONFIG_SPL_BUILD这个编译选项,把主U-Boot里的大多数功能都裁剪掉,只保留串口、存储驱动、DDR驱动和基本的加载逻辑。
在RISC-V平台上,SPL运行在M模式。它干完DDR初始化之后,有两种选择:直接把主U-Boot加载到DDR然后跳过去,或者先跳到OpenSBI,再由OpenSBI跳进S模式的U-Boot。第二种方式在Linux系统里更常见,因为OpenSBI在M模式要把PMP、MIPI等资源准备好,后面Linux跑在S模式才不越界。
OpenSBI这个名字,全称是RISC-V Open Source Supervisor Binary Interface,它实际上干了两件事:第一,运行在M模式,充当一个“微型固件”,给S模式提供SBI调用,比如定时器、IPI、远程核启动、控制台输出;第二,接手启动流程,把控制权从M模式转到S模式。S模式的内核或U-Boot通过一条ecall指令就能触发SBI的M模式处理逻辑,这让内核代码不用直接碰硬件细节。
OpenSBI有三种运行模式:fw_payload、fw_jump和fw_dynamic。fw_payload把下一级镜像直接打进OpenSBI的bin文件里,适合快速原型验证;fw_jump则是OpenSBI运行完直接跳到一个固定地址;fw_dynamic由下一级加载器传入SBI信息。调试的时候我习惯用fw_jump,因为它可以在QEMU命令行里用-kernel单独指定U-Boot,改U-Boot不用重新打包OpenSBI。
2.3 多核(Hart)启动的默认路径:主Hart与小Hart的分工
RISC-V里一个CPU核心叫Hart,这名字是Hardware Thread的缩写。多核系统启动时,不可能所有核同时往下跑,那样指令流会全乱。SoC厂商在硬件上做了一个机制:上电后只有主Hart(通常是Hart 0)自动进入启动流程,其他Hart被WFI指令挂起,等待主Hart通过IPI中断唤醒。
OpenSBI在M模式启动时,它会把所有Hart的状态都登记好,然后只让主Hart继续跳转到S模式。后面运行Linux时,主Hart会通过SBI调用请求OpenSBI唤醒其他Hart,每个小Hart再各自初始化自己的MMU、页表,最后进入idle线程等待调度。
这个机制很容易被忽略,但在调试多核RISC-V板卡时非常重要。如果某个Hart没有被正确唤醒,系统表现为只有一个核在工作,或者调度器完全卡死。这时候不妨回到OpenSBI的日志,看看它初始化了几个Hart,再对照设备树里cpu节点的reg属性,确认hartid是否一致。
2.4 用QEMU快速验证前三级启动
理论说再多,不如实际跑一遍。QEMU是学习RISC-V启动流程最廉价的方式,没有之一。我强烈建议读者在最开始就搭好这个环境。
准备OpenSBI和U-Boot的源码,交叉编译工具链用riscv64-linux-gnu-gcc。先编译OpenSBI:
git clone https://github.com/riscv-software-src/opensbi.git cd opensbi make PLATFORM=generic CROSS_COMPILE=riscv64-linux-gnu-编译完成后,产物在build/platform/generic/firmware/下,常见的有fw_jump.bin和fw_payload.bin。
再编译U-Boot:
git clone https://github.com/u-boot/u-boot.git cd u-boot make qemu-riscv64_smode_defconfig make CROSS_COMPILE=riscv64-linux-gnu-然后用下面的命令同时加载OpenSBI和U-Boot:
qemu-system-riscv64 -M virt -bios fw_jump.bin -kernel u-boot.bin -nographic如果一切正常,你会先看到OpenSBI的带版本信息的banner,然后看到U-Boot的启动日志和命令行提示符。U-Boot会打印一行类似RISC-V #的提示符,这就说明前三级启动已经通了。
这里有个关键点要记住:qemu-riscv64_smode_defconfig这个配置编译出来的U-Boot默认运行在S模式,直接把它丢给QEMU的-kernel却不带-bios,启动大概率会失败。因为U-Boot在S模式之前需要OpenSBI帮它准备好环境。这也是为什么我坚持用fw_jump.bin配合-bios的原因。
3. U-Boot如何把内核“扶上马”:内核加载的完整细节
3.1 U-Boot的booti/bootm与内核镜像格式
U-Boot启动Linux的指令分两种:booti和bootm。booti用来启动原始镜像Image,也就是Linux内核编译出来的、未经压缩的ELF转存后的二进制文件;bootm用来启动带U-Boot头部的uImage,这个头是mkimage工具加的,里面包含了加载地址、入口地址、镜像大小和CRC校验值。
在RISC-V平台上,我最推荐用booti,原因很简单:省事。booti不需要给内核镜像加额外的文件头,直接从文件系统里把Image文件加载到内存地址,指定设备树地址,就能启动。用bootm的话,你每次更新内核都得先用mkimage重新打包,多一道流程就多一个出错的可能。
这里有一个实践中的小习惯:内核镜像和DTB的加载地址至少隔开16MB,甚至32MB,目的是防止内核解压时把自己覆盖掉。我在自己的一台D1开发板上,内核放在0x80200000,DTB放在0x82200000,两个区块完全不碰,这种保险并不多余。
3.2 RISC-V Linux启动约定的寄存器与参数传递
U-Boot跳转内核前,必须按照RISC-V Linux的启动约定设置寄存器。这套约定由Linux内核社区在Documentation/riscv/boot.rst里写成明文,简单说就是三条:
- a0寄存器传递Hart ID,也就是当前启动的是哪个核;
- a1寄存器传递DTB在内存中的物理地址;
- 若无DTS指定的UART等,内核早期用a2当作console参数的情况很少见,一般不需要。
如果你是从ARM64转过来的,这里要特别小心:ARM64是通过x0传DTB地址,x1传其他参数,寄存器正好是x系列。RISC-V用的是a系列,Hart ID还要单独给,千万不要搞混。
U-Boot的booti会替我们做好这些事,它内部会读取当前环境的boot-hartid、fdt_addr_r这些变量来填充寄存器和参数。但如果你在写自己的裸机源码,想直接从自定义Bootloader跳进Linux,这三个寄存器的要求就必须手动满足。尤其是a0传错Hart ID,内核会在启动早期根本不知道该从哪个核初始化SMP,表现就是其他核不工作,或者干脆直接死锁。
3.3 设备树(DTB)在启动路径中的角色
RISC-V架构的现代Linux内核已经完全依赖设备树来发现硬件,ACPI在RISC-V上的生态还不成熟,大部分平台压根不用。所以DTB的质量直接决定启动能不能到最后一步。
一份基本的RISC-V设备树,至少要有cpus节点,每个cpu子节点必须有riscv,isa属性描述指令集扩展,比如rv64imafdc,还要有reg属性对应Hart ID。memory节点的reg必须与硬件DDR地址一致,否则内核在启动时计算的物理内存映射全是错的。
我在调试中遇到过最典型的DTB问题是:U-Boot加载的是开发板厂商随BSP提供的旧DTB,但内核已经换成了新版本,设备树里有些节点属性不匹配,启动日志会打印一堆OF: fdt: Ignoring memory range之类的警告。所以一个朴素的原则是:内核和设备树尽量保持同一套源码编译出来,不要在关键时候手动拼凑。
3.4 实操示范:用U-Boot命令行手工启动Linux
下面是一段典型的RISC-V U-Boot命令行启动流程。假设你已经把Linux内核镜像和设备树放到了SD卡或虚拟磁盘的第一个分区:
setenv bootargs root=/dev/mmcblk0p2 rw console=ttyS0,115200 load mmc 0:1 ${kernel_addr_r} /boot/Image load mmc 0:1 ${fdt_addr_r} /boot/board.dtb booti ${kernel_addr_r} - ${fdt_addr_r}kernel_addr_r和fdt_addr_r是U-Boot预定义的环境变量,我强烈建议不要删掉或随意修改它们的值,因为U-Boot的链接地址、内存布局都跟它们有关。命令中间的-表示ramdisk为空,RISC-V Linux里可以直接用initrd特性,但多数场景没必要传。
如果使用uImage,命令就换成:
imxtract ${kernel_addr_r} 0 bootm ${kernel_addr_r}不过在RISC-V上,mkimage打包的地址和入口地址如果没设对,bootm会直接报Bad Magic Number。这也是我偏爱booti的原因。
跳转之后,如果一切正常,串口会先打印内核早期的Uncompressing Linux...,随后是一长串[ 0.000000]开头的日志,等到Freeing unused kernel memory出现,就说明你已经成功从Bootloader走到了内核加载。
4. 另一条路线:RT-Thread与MCU级RISC-V的启动初始化流程
4.1 MCU与SoC启动流程的差异
上面讲的启动链路,核心依赖是DDR和外部存储,这是SoC的玩法。但RISC-V现在也大量用在MCU领域,比如南洋理工、国产平头哥等系列芯片,它们的Flash和SRAM都很小,根本没有DDR,启动流程自然不一样。
MCU的RISC-V芯片通常把程序放在片内Flash里,复位后从Flash基地址执行,不需要DDR初始化这一步。有产品升级需求时,会在Flash开头放一个Bootloader,它把上位机传来的新固件写入Flash的APP区,然后跳过去。这个过程不涉及S模式,也没有OpenSBI,整条链路极其精简。
一个典型的例子是嘉楠K210。它内置6MB SRAM,芯片上电后BootROM从外部Flash读取固件到SRAM的0x80000000地址,然后直接跳转执行。整个启动过程只需要一次搬运,中间不需要SPL,更不需要OpenSBI。
4.2 RT-Thread在RISC-V上的启动初始化路径
RT-Thread在RISC-V上的启动路径,和Linux完全不同,但和Linux内核启动的“先汇编、再C”很相似。它的启动文件通常在BSP的entry.S里定义入口_start(或reset_handler),汇编代码负责设置栈指针、清零BSS段,然后调用C函数entry。
entry一路调用下去,最终进入rtthread_startup。这个函数是RT-Thread的启动核心,逻辑顺序大致如下:
- 关闭中断;
- 调用
rt_hw_board_init,完成时钟初始化、串口初始化、堆内存初始化; - 打印版本号;
- 初始化定时器和调度器;
- 创建主线程(main线程);
- 启动调度器,开始多线程运行。
从这段流程可以看到,RT-Thread把“硬件初始化和OS初始化”完全分离:rt_hw_board_init负责底层,用户的主程序只关心从main函数之后的逻辑。这种设计移植性很强,因为换一块板子只需要改board.c里的rt_hw_board_init,上层OS代码不用动。
如果把RT-Thread放到S模式下跑,比如在带OpenSBI的SoC上,就要额外处理SBI调用。RT-Thread社区有专用的移植分支,但一般初学者没必要一上来就搞这个,先用M模式裸机方式跑通,体验“上电即进main”的顺畅感,对理解RISC-V启动流程反而更有帮助。
4.3 RT-Thread与Linux启动路径对照表
| 启动阶段 | Linux路径 | RT-Thread路径 |
|---|---|---|
| 复位入口 | BootROM / OpenSBI / U-Boot | BootROM / 自研Loader / 直接Flash |
| 权限模式 | M模式 -> S模式 | 通常M模式 |
| 硬件初始化 | U-Boot负责 | rt_hw_board_init |
| 设备描述 | 设备树DTB | BSP内代码直接描述 |
| 多任务准备 | start_kernel / schedule | rtthread_startup / 调度器 |
| 加载方式 | U-Boot加载Image | 可直接固化在Flash |
看完这个表就很清楚:RT-Thread和Linux的启动路径差距,主要来自运行环境和硬件资源的不同。如果你熟悉的只有MCU开发,上手RT-Thread会非常快;如果将来要转Linux,那理解U-Boot和内核加载之间的关系,就是一个必经的台阶。
5. 常见问题与排查技巧实录
5.1 串口完全没输出的三类原因
我在群里看到最多的求助就是“上电串口没输出,是不是板子坏了”。综合来看,RISC-V板卡串口无日志,绝大多数逃不开这三类原因:
第一,串口控制器本身没初始化成功。BootROM阶段通常不打印日志,第一个打印点要么在OpenSBI,要么在U-Boot。如果U-Boot没跑起来,那串口自然没有输出。这种情况先检查BootROM是否真的找到了有效镜像,很多板卡可以通过LED状态或者示波器量引脚区分。
第二,波特率不匹配。RISC-V开发板的调试串口波特率千奇百怪,115200很常见,但1500000也时有出现,比如部分全志平台默认1500000。拿到板卡先看原理图或SDK文档,不要一口咬定是115200。
第三,串口引脚复用配置错误。有些SoC的UART引脚默认不是UART功能,需要Bootloader提前配置pinmux。U-Boot的dts里如果漏配了,日志就会卡在一个莫名其妙的早期位置。解决方法是对照原理图和芯片手册,手动检查pinmux寄存器。
我调试时有个习惯:先把波特率所有的常见档位都试一遍,再用万用表量TX引脚的电压,看有没有数据翻转。这能快速区分是硬件问题还是软件问题。
5.2 卡在OpenSBI或U-Boot阶段的处理
OpenSBI阶段就卡住,多半是跳转地址或内存问题。最常见的是fw_jump的跳转地址和U-Boot的实际链接地址不一致。U-Boot如果被编译到text_base为0x80200000,而fw_jump默认跳转到0x80000000,那两条指令后必然翻车。解决方法是拿到U-Boot的编译产物后,用下面的命令确认入口地址:
riscv64-linux-gnu-readelf -h u-boot | grep Entry再看OpenSBI的platform.c里配置的跳转地址,两个地址必须匹配。
U-Boot里卡住的情况,总体可以分三段看:卡在U-Boot SPL之前,那SPL还没跑起来;卡在BOOTDEV加载设备之后,说明存储驱动有问题;卡在Starting kernel ...,说明U-Boot后续环境没什么问题,问题在内核那一侧。
5.3 内核启动panic或hang的定位
内核能打印日志却提前panic,是最好定位的,因为日志会告诉你精确的崩溃位置。没有日志的hang则麻烦一些,需要逐个阶段排查。
一个比较容易忽略的点是:内核地址和DTB地址的间距不够,导致内核解压时把DTB覆盖。启动日志里如果出现Kernel panic - not syncing: Cannot decompress,九成是这个原因。代码里多留16MB以上的间隔是安全写法。
还有一类问题是设备树里chosen节点的bootargs没有正确传给内核。U-Boot的setenv bootargs只是改了环境变量,但如果booti之前没有真正把bootargs写入chosen节点,内核照样拿不到启动参数。U-Boot的booti命令在RISC-V实现里会处理这件事,但如果你用的是自定义Bootloader,就必须手动把chosen节点更新到DTB再传下去。
5.4 排查隐形问题的四个辅助工具
启动问题有时候看日志看不出所以然,尤其是BootROM阶段没有打印,这时候就要借助外部工具。
用QEMU启动全套系统时,配合GDB可以单步观察PC的变化。QEMU提供了-S -gdb tcp::1234参数,再用riscv64-linux-gnu-gdb连上去,设断点看寄存器,非常直观。
真实板卡上则推荐OpenOCD加J-Link或板载调试器。用调试器可以查看Hart ID、PMP配置、甚至直接改写内存。重点检查几个寄存器:mstatus、mepc、satp,这几个基本上承载了启动跳转的所有关键状态。
反汇编也是常用技能。遇到SPL或U-Boot“跑飞”,把卡住的PC地址和反汇编文件对照,往往一眼就能看出是跳转到了空地址还是错误的函数入口。
最后,如果你用的是U-Boot,务必学会md、mw、go这几个内存调试命令。它们能在U-Boot环境里直接读改写内存,甚至偷偷把PC跳到某个地址。很多启动问题的定位,就是这个看似原始的手段解决的。
我个人在实际操作中有一个很深的体会:RISC-V启动流程里的绝大多数坑,归结起来就是三个地址的问题——复位向量、下一级镜像的加载地址、内核和DTB的运行地址。把这三个地址的来龙去脉查清楚,再配上一台能看寄存器状态的调试器,整个启动链路就不会再有秘密。另外,强烈建议新手不要一上来就抱着真机折腾,先在QEMU里把OpenSBI、U-Boot、Linux这条完整链路跑通,反复体验修改地址、观察日志的过程,然后再上真实硬件,效率会高得多。