拿到一块全新的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。SPLSecondary Program LoaderSPL负责初始化DDR并把真正的U-Boot主体拷贝到DDR中。为什么需要这一步因为BootROM太小而DDR的初始化代码又很长不可能全塞进BootROM。SPL就是U-Boot裁减出来的“最小可运行版”通常只有几十KB。OpenSBI如果系统要跑LinuxM模式不能直接跳进S模式需要一个固件在M模式下“兜底”。OpenSBI在M模式完成PMP、定时器、中断控制等资源初始化然后跳转到S模式把权交给U-Boot或内核。U-BootU-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-Boot2.1 复位向量与BootROM芯片的第一口饭复位向量也就是Reset Vector是CPU上电后取第一条指令的地址它由芯片设计者定义RISC-V架构本身没有规定这个地址必须是多少。这跟x86那种固定地址不一样RISC-V世界里的Bootloader必须“承认”一个事实不同芯片的复位地址千差万别。拿几个我实际接触过的平台举例平台复位向量启动介质QEMU virt0x1000无直接由QEMU加载镜像平头哥系列MCU0x00000000片内Flash全志D10x00040000SD/Nand/QSPI等嘉楠K2100x00000000片内FlashBootROM从Flash搬运BootROM里做的事情从软件角度看大致是这样先设置好栈指针SP因为接下来可能要调用C函数然后初始化启动源对应的控制器比如从SD卡启动就要初始化SD控制器从QSPI Flash启动就要初始化QSPI控制器接着把二级镜像按固定偏移读入SRAM做一次校验最后跳转。校验方式各个芯片不一样有的是简单的CRC32有的用签名验签。生产环境里建议不要跳过这一步因为启动介质里的镜像损坏是很常见的故障。关于BootROM还有一个很坑的点它用的加载地址和二级镜像的链接地址必须一致。SPL编译时链接脚本会把代码段的起始地址设成一个固定的SRAM地址这个地址必须和BootROM搬运的目的地址相同否则跳过去就是跑死。我在K210上就踩过这个坑SPL链接到0x80000000BootROM实际把它搬到了另一个地址结果一上电就HardFault。2.2 SPL与OpenSBIM模式与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。先编译OpenSBIgit clone https://github.com/riscv-software-src/opensbi.git cd opensbi make PLATFORMgeneric CROSS_COMPILEriscv64-linux-gnu-编译完成后产物在build/platform/generic/firmware/下常见的有fw_jump.bin和fw_payload.bin。再编译U-Bootgit clone https://github.com/u-boot/u-boot.git cd u-boot make qemu-riscv64_smode_defconfig make CROSS_COMPILEriscv64-linux-gnu-然后用下面的命令同时加载OpenSBI和U-Bootqemu-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开发板上内核放在0x80200000DTB放在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 consolettyS0,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-BootBootROM / 自研Loader / 直接Flash权限模式M模式 - S模式通常M模式硬件初始化U-Boot负责rt_hw_board_init设备描述设备树DTBBSP内代码直接描述多任务准备start_kernel / schedulertthread_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这条完整链路跑通反复体验修改地址、观察日志的过程然后再上真实硬件效率会高得多。