
做嵌入式开发这些年我从ARM平台一路折腾到RISC-V最大的感受是RISC-V启动链路如果不从头到尾捋一遍后面写驱动、调内核、做OTA都是盲人摸象。尤其很多刚转过来的朋友习惯性用Cortex-M那套思路去看RISC-V的启动结果往往卡在“程序到底从哪开始跑”“为什么改了链接脚本还是飞”“厂家给的bootloader和开源U-Boot到底什么关系”这类基础问题上。这篇文章就从按下电源键那一刻说起把RISC-V从上电、BootROM接管、一级引导加载、特权级切换一直到内核入口的完整路径拆开讲清楚。强实操向每个环节都会给出我能直接用的判断方法、调试手段和避坑点适合正在做RISC-V板级移植、想搞懂bootloader原理、或者准备自己写引导程序的嵌入式工程师。1. 上电之后RISC-V复位向量与默认运行模式1.1 复位向量不是固定0x00000000别被ARM经验带偏做过单片机的人都知道ARM Cortex-M上电后从0x00000000取栈顶指针从0x00000004取复位向量这个地址一般对应Flash起始区域。RISC-V完全不同它的复位向量没有统一的绝对地址标准完全由芯片设计者决定。有的芯片放在0x00000000有的放在0x10000000有的放在0x00200000甚至有些MCU允许通过eFuse、OTP或boot pin去覆盖默认值。比如QEMU的riscv64 virt平台复位后CPU实际上从0x1000附近的BootROM代码开始执行执行一小段跳转指令后进入0x80000000的DDR区域。而K210芯片固定从0x00000000开始片内有一块不可改写的BootROM把Flash里的固件搬运到SRAM。全志D1那块芯片则更复杂内置BootROM会去检测启动介质从SPI NOR、SD卡或者eMMC里读取下一级引导程序。所以拿到一块新板子第一步是去查芯片手册里的“Boot Process”或者“Boot ROM”章节确认三个关键信息复位地址是多少、启动介质怎么选择、BootROM会把一级loader加载到哪块内存。把这三个地址搞清楚后面看代码就不迷糊了。# 一个典型的RISC-V启动入口片段 .section .text._start .globl _start _start: csrr a0, mhartid # 读出当前Hart ID bnez a0, secondary_halt # 非0号核先停下来等待 la sp, _stack_top # 设置C语言运行环境栈指针 call main_entry secondary_halt: wfi j secondary_halt这段汇编几乎是所有RISC-V bootloader、RTOS、内核启动代码的“标准开头”先读mhartid再设置栈指针。读mhartid是因为RISC-V芯片经常是多核异构0号核执行主引导逻辑其它核要么WFI等待要么通过IPI中断唤醒避免多个核同时去初始化同一个外设。1.2 为什么启动代码必须跑在M态RISC-V定义了三种特权级机器模式M、监管者模式S、用户模式U。M态权限最高能够访问所有CSR寄存器能够配置物理内存保护PMP、中断委托、时钟、电源管理这些关键资源。S态是给操作系统内核用的U态跑普通应用程序。芯片上电复位后默认进入M态PC指向复位向量。为什么要这样设计因为启动阶段要做的事情都属于“底层硬件管理”关闭看门狗、配置系统时钟、初始化DDR控制器、设置PMP条目、关闭或者重映射中断入口。这些操作如果放在S态或者U态权限不够要么触发非法指令异常要么根本写不进关键寄存器。举一个典型场景Linux内核在S态运行但内核不能直接访问PMP也不能随便改M态的中断委托。当Linux想要做电源管理、设置定时器或者通过SBI调用请求固件服务时就会触发ecall指令陷入M态让OpenSBI去处理。这种“S态请求、M态服务”的模型也是RISC-V平台级固件的核心价值。启动阶段同样如此M态的bootloader负责把所有硬件资源初始化好再通过mret指令带着合适的寄存器状态跳转到S态入口操作系统内核一进来就能看到一个干净稳定的环境。关于特权级切换有两条常用指令要记住ecall用于主动陷入低特权级向高特权级请求服务mret用于从M态返回到S态或U态。mret会从mepc寄存器恢复PC从mstatus的MPP字段恢复目标特权级。OpenSBI最终跳转Linux内核用的就是设置好mepc和mstatus后执行mret的方式。1.3 启动介质与内存布局代码不搬内核就没法跑启动问题和代码搬运问题总是绑在一起。RISC-V SoC的内部SRAM通常只有几百KB到几MB而Linux内核、文件系统、甚至一个带日志系统的bootloader都可能远远超过这个容量。所以绝大多数RISC-V芯片的BootROM只负责两件事初始化一小块内存把一级loader从启动介质搬到SRAM然后跳过去执行。这里有个很容易踩的坑链接脚本里的加载地址LMA和运行地址VMA如果搞反了代码在调试器里可能看着是加载成功了一运行就飞到奇怪的位置。比如U-Boot SPL链接到SRAM的0x0A000000U-Boot主体链接到DDR的0x80000000如果搬运逻辑里按错地址计算拷贝长度大概率起不来。判断方法也很简单编译完看readelf -l u-boot.elf对比LOAD段的LMA和VMA搬运时必须保证最终运行时所有段都落在VMA对应的物理地址上。Cortex-M和RISC-V在这一点上有很大差异。Cortex-M允许直接从Flash取指执行支持XIP代码不一定要搬到SRAM。RISC-V虽然也可以做XIP但很多SoC的Flash访问速度慢、带宽低而且为了执行效率和多核一致性主流做法还是“BootROM拷贝到SRAMSPL初始化DDR后再拷贝到DDR”。所以RISC-V的启动链路天然比Cortex-M多出至少一级搬运这个不是多余设计而是性能和灵活性的折中。2. Bootloader的分工逻辑先理解再选型2.1 三级启动链BootROM、一级Loader、OS Loader各管什么一个完整的RISC-V Linux启动链路行业里一般分成三级角色划分得非常清楚。第一级是芯片内部的BootROM出厂固化用户改不了。它的任务很纯粹根据启动引脚或eFuse配置选定启动介质把第二级loader通常是SPL或者一个极小的引导块从Flash、SD卡或eMMC里拷到SRAM然后跳转。BootROM的天职是“用最少的硬件资源把下一棒递出去”它自身不负责DDR初始化这种耗时工作因为DDR颗粒型号千差万别出厂固件没法覆盖所有场景。第二级是指SPL、U-Boot SPL、或者自研的一级引导程序。这一级要完成的事情就比较重了初始化DDR控制器的时序参数、初始化UART用于调试打印、读取启动参数、从启动介质加载真正的OS Loader比如U-Boot主体到DDR最后把设备树地址、启动介质编号等关键信息传给下一级。这一级的难点在于“它必须在DDR还没初始化的情况下做很多决定”而且自身运行在SRAM里空间受限所以业界普遍把U-Boot拆成SPL和U-Boot主体两个阶段。第三级是OS Loader典型代表是U-Boot主体、或OpenSBI配合U-Boot的组合。它的任务包括加载内核镜像到指定内存地址、验证签名、把设备树放到约定位置、设置好启动参数最后跳转到内核入口。如果做安全启动这一级还要负责校验镜像的哈希和签名如果做AB分区它还要读取boot_metadata判断该启动哪个slot。典型三级启动链 BootROM - SPL(或一级Loader) - U-Boot主体 - Linux内核每一级都比前一级更“庞大”但也更“智能”。BootROM体积只有几十KB做得越简单越不容易出bugU-Boot主体则动辄几百KB甚至几MB因为要支持网络启动、FAT文件系统、EFI启动等一堆功能。2.2 开源方案横向对比OpenSBI、U-Boot、RT-Thread、自研轮子调研工程项目该用哪个bootloader时我通常会从三个维度判断是否要跑Linux、芯片体系是否要求M态固件、团队对软件的修改能力。OpenSBI是目前RISC-V平台级固件的标准选择它实现在M态向S态提供SBI接口处理定时器、IPI、远程内存屏障等底层操作。Linux内核和U-Boot在RISC-V上底层都依赖SBI所以只要跑LinuxOpenSBI基本绕不开。U-Boot则是OS Loader层的事实标准支持大量RISC-V SoC社区活跃既能当SPL用也能当主loader用。缺点是代码体积大、配置项多对于只跑RTOS的小MCU来说显得杀鸡用牛刀。RT-Thread官方提供的bootloader主要面向MCU场景体积小、配置简单支持FAL分区管理、OTA、AB模式在国产RISC-V MCU生态里很受欢迎。它适合不需要Linux只需要可靠地引导RTOS、做升级回滚的中小型设备。自研bootloader则适合那些对启动延迟、安全要求、定制化程度有极致要求的场景但要付出巨大的测试成本。我的经验是除非确认开源方案确实不满足需求否则不要轻易自研。bootloader一旦启动失败整个设备就是砖头这个风险的优先级高于一切。方案运行模式适用场景优点明显短板OpenSBIM态Linux启动前的平台固件规范标准、社区活跃、与Linux适配好只解决M态服务不负责加载内核U-BootS态/M态嵌入式Linux启动功能全、支持多SoC、生态成熟配置复杂、镜像体积大RT-Thread bootloaderM态RTOS设备、OTA升级轻量、易定制、AB分区友好不支持直接引导Linux自研bootloaderM态特殊安全/延迟需求完全掌控逻辑风险高、测试成本大2.3 AB分区不只是OTA它也是启动可靠性设计很多研发人员第一次接触AB分区是在OTA需求里但实际上AB分区更是启动可靠性设计的一部分。典型AB分区方案里系统固件存放在slot A和slot B两个独立的存储分区中bootloader通过读取boot_metadata包含当前活跃slot、重试次数、升级成功标志来决定启动哪个slot。举个例子设备正在运行slot AOTA下载完成写入slot B写入成功后bootloader把metadata中的active slot切换为B下次开机尝试从B启动。如果B启动失败或者连续重启超过重试次数bootloader会自动回退到slot A。这个过程不需要主机介入完全由启动链自行判断所以能显著降低“升级变砖”的概率。在RISC-V设备上做AB分区有一件事要提前考虑RISC-V的启动介质可能是SPI NOR、eMMC、SD卡不同介质对随机读、磨损均衡、原子写的支持差异很大。metadata的写入必须是原子性的否则设备可能在重启时同时看到“A激活”和“B激活”两个矛盾状态。实际项目中建议把metadata放在单独的小分区并且在写入时做双备份 CRC校验每次启动优先读取校验通过的副本。3. 实战拆解从QEMU到真板内核加载的完整路径3.1 先搭一套能跑的启动环境镜像与内存布局讲原理不落地等于白讲。我个人习惯先在QEMU的virt平台上把整条启动链路跑通再去碰真实芯片因为QEMU配置简单、出错能反复调试还不烧板子。在QEMU virt平台上内存布局大致如下0x1000附近是BootROM0x80000000开始是DDR。OpenSBI通常装到0x80000000U-Boot主体或Linux内核可以紧接着排在后面。常用的启动组合是“OpenSBI U-Boot Linux内核”U-Boot作为OpenSBI的payload也可以直接用fw_payload把U-Boot和内核包进去。准备三个东西编译好的RISC-V 64位OpenSBI固件fw_payload.elf、U-Boot镜像、Linux内核Image。启动命令大致为qemu-system-riscv64 \ -machine virt \ -nographic \ -bios fw_payload.elf \ -kernel Image \ -append root/dev/vda rw consolettyS0-bios参数指定OpenSBI固件-kernel指定内核镜像。QEMU会先把fw_payload加载到0x80000000然后复位CPU从0x1000开始执行BootROM跳转到OpenSBI入口。这样一个最小链路就建起来了后面所有的原理都能在这套环境上验证。3.2 BootROM这一棒如何把控制权交给OpenSBIQEMU虚拟平台的BootROM只有一小段代码但逻辑和真实芯片的BootROM一致读mhartid设置栈指针跳到DDR中的下一级入口。真实SoC的BootROM还要多做一些事比如从启动介质把镜像拷到SRAM、校验镜像签名、初始化简易的UART用于错误打印但本质都是一样的——把PC从复位向量引导到下一级bootloader的入口地址。这里我要特别提醒在做真实板子时BootROM阶段如果失败通常没有任何串口输出因为这时候UART可能还没初始化。判断BootROM是否成功的常规手段是看芯片手册里有没有“BootROM打印特定字节”的机制或者用JTAG读PC寄存器。我在调试一块国产RISC-V开发板时就遇到过BootROM一直尝试从SPI Flash读取固件但因为PMIC上电时序不对导致Flash供电异常最终BootROM在3秒后超时重试板子表现为主控不启动、没有任何日志。这种问题如果不是靠示波器量Flash电源管脚真的一筹莫展。3.3 OpenSBI这一棒M态铺路S态接棒OpenSBI的入口代码通常叫fw_start它做的事情非常有代表性读mhartid、初始化栈指针、清零BSS段、初始化平台相关的控制寄存器、配置PMP条目、设置异常向量表最后通过mret切到S态并跳转下一个阶段的入口。OpenSBI有几种固件加载模式最常用的是FW_JUMP和FW_PAYLOAD。FW_JUMP模式下OpenSBI编译时指定一个固定地址启动完成后把PC跳到这个地址由外部负责把U-Boot或其它loader放到那里FW_PAYLOAD模式则直接把下一级镜像打包进OpenSBI固件里。工程里我更推荐FW_JUMP 外部加载方式因为升级下一级镜像时不需要重新编译OpenSBI部署更灵活。OpenSBI跳转到S态时对寄存器状态有严格要求a0必须是当前Hart IDa1必须是设备树二进制DTB的物理地址。这条约定也是RISC-V硬件平台标准的一部分下游的U-Boot和Linux内核都依赖它。我调试时经常用JTAG在OpenSBImret指令前打断点直接检查a0和a1寄存器这是判断OpenSBI是否正常交棒的最快方式。OpenSBI还解决了一个实际问题不同芯片的定时器、中断控制器、IPI机制差异很大如果没有SBI接口Linux内核要为每一款芯片写一套平台代码。有了SBILinux只要调用SBI的set_timer、send_ipi等标准接口具体实现交给M态的OpenSBI。这也是为什么RISC-V能把SoC做得很定制化同时内核适配成本反而更低。3.4 U-Boot与内核交接设备树地址、hart id、入口约定U-Boot在RISC-V上启动Linux的最后一个环节本质上就是“完成约定”设置好a0为hart ida1为设备树物理地址a2通常为0然后跳转到内核入口地址_start。Linux RISC-V的入口地址通常是物理地址0x80200000但这个地址不是绝对的它实际上是编译内核时由text_offset和加载地址共同决定的。U-Boot会把内核加载到约定地址然后执行跳转指令。设备树DTB的传递是RISC-V启动里最容易被忽略的坑。U-Boot加载内核时会把DTB放到内存中的某个地址比如通过fdt_addr_r变量指定的0x88000000。如果这个地址恰好被内核镜像覆盖或者OpenSBI传递下来的DTB地址没有保留内核就会在启动早期因为找不到正确设备树而直接panic。排查这类问题的手段是看U-Boot启动日志里的“Kernel image 0x... DTB 0x...”输出手工确认内存区域不重叠。U-Boot这段还支持从网络、SD卡、USB等介质加载内核但不管来源怎么变最终都要走同一套跳转逻辑。在真实项目中我会建议把U-Boot的bootcmd收敛成一套固定命令setenv bootargs root/dev/mmcblk0p2 rw consolettyS0,115200; load mmc 0:1 0x80200000 /boot/Image; load mmc 0:1 0x88000000 /boot/dtb/board.dtb; booti 0x80200000 - 0x88000000;booti是RISC-V架构U-Boot专用的内核启动命令第二个参数-表示没有initrd。执行后U-Boot会检查内核镜像的头部魔数、设置好寄存器参数再跳进去。这里每一步都有明确日志一旦异常看log就能定位到是加载失败、镜像损坏还是DTB地址冲突。3.5 轻量内核路径参考RT-Thread在RISC-V上怎么启动如果项目目标不是Linux而是RT-Thread这样的RTOS启动链路会轻很多。RT-Thread既可以直接在M态裸跑也可以通过OpenSBI在S态运行。对于大部分RISC-V MCU直接M态跑就够了不需要OpenSBI也不需要U-Boot一个极简的二级bootloader负责把RT-Thread固件从Flash搬到RAM即可。RT-Thread在RISC-V上的启动入口是startup_gcc.S流程和前面提到的启动汇编几乎一样读mhartid、设置栈顶、清零BSS、调用entry函数。随后entry会执行rt_hw_board_init完成时钟、串口、内存堆的初始化再通过rt_application_init创建主线程。与Linux启动路径相比RT-Thread少掉了特权级切换、设备树解析、内核镜像解压这些大开销步骤所以从复位到线程调度快的话只需要几十毫秒。在RISC-V MCU上做RT-Thread接入时要注意链接脚本的RAM划分。RT-Thread的向量表、栈、堆、BSS段都要有明确的位置尤其栈顶地址必须在链接脚本里定义正确否则首次函数调用就会踩飞。调试时如果发现程序停在hardfault第一反应应该是用调试器读栈指针和PC寄存器看是不是栈没设好、BSS没清零就访问了全局变量。4. 启动卡住别慌调试三板斧与常见问题速查4.1 串口日志排查法启动类问题排查第一板斧永远是串口日志。看日志的心法是“定位当前执行到哪个阶段”每一级loader都应该在最开始和最结尾打印明确标记。比如OpenSBI如果正常启动会打印一串包含版本号、平台名、内存布局的banner信息其中能看到Domain0 Name、Boot HART这些关键内容。U-Boot启动则会打印从版本信息、DDR大小到加载命令执行结果的完整流程。两级loader的日志里前一级的日志最后几行和后一级的日志最开始几行是天然的分界线一旦发现断点就知道问题出在交接处。实操时有个小技巧在自己写的bootloader里每一步关键操作前用UART输出一个简短的十六进制阶段码比如0xA0表示时钟初始化完成、0xA1表示DDR训练完成、0xB0表示开始搬运镜像。固化这种“阶段码协议”后哪怕客户现场板子坏了只要让现场人员拍一张串口打印截图远程就能判断死在哪一步效率极高。4.2 JTAG/SWD调试工具实操日志能定位问题但很多时候不够。比如DDR训练失败串口可能完全没输出这时就要上JTAG调试器。RISC-V的调试接口和ARM不同普遍使用JTAG支持RISC-V调试规范的调试器有OpenOCD配合J-Link、以及常见的CH32V系列使用的WCH-Link。WCH-Link价格便宜在RISC-V MCU和部分SoC上非常好用拿来做自制板的烧录与调试足够。我帮朋友调试一块基于RISC-V MCU的自制板时就是用WCH-Link配合OpenOCD给板子烧bootloader。连接过程有一个非常容易出的问题在启动阶段MCU默认时钟可能来自内部RC频率很低调试器的速度也要跟着调低。OpenOCD的adapter speed如果设置太高连接时经常报TDO/IDCODE mismatch。先把速度降到1000kHz确认能稳定连接后再慢慢调高。调试命令方面最常用的是下面几条openocd -f interface/wch-link.cfg -f target/wch_riscv.cfg在telnet或gdb侧halt reg pc load_image /path/to/bootloader.elf reg pc 0x00000000 resumehalt后读PC寄存器是最常见的判断方式如果PC还在0x00000000附近徘徊说明BootROM或一级loader没跳出去如果PC飞到0xffffffff或0xdeadbeef这种怪地址大概率是栈溢出或者函数指针被破坏。见过不少工程师一上来就调大adapter speed追求下载速度结果始终连不上目标浪费时间。4.3 常见问题速查表把我在多个RISC-V项目里遇到概率最高的启动问题整理成一张表方便直接对照。现象可能原因排查手段上电无任何串口打印JTAG能连BootROM没找到有效启动介质或镜像头错误量Flash供电、检查启动引脚电平、看手册确认BootROM搜索顺序OpenSBI未打印banner固件加载地址错误或DTB传入地址非法用JTAG设断点在OpenSBI入口打印a0/a1和PCOpenSBI打印后U-Boot无输出FW_JUMP跳转地址和U-Boot实际加载地址不匹配核对链接地址与加载地址检查跳转前后LMA/VMAU-Boot打印正常内核无启动日志DTB地址被覆盖或内核入口地址不对打印U-Boot的loaded images信息确认DTB和Image不重叠内核启动后重复重启设备树里DDR大小超出实际物理内存检查内核命令行mem参数和DTB内存节点RT-Thread启动后莫名hardfault栈指针未正确设置或BSS段未清零调试器读取SP回溯调用栈检查链接脚本AB分区切到B后回退失败metadata写入非原子或CRC校验缺陷检查分区写入流程增加双备份与CRC校验这张表是我个人调试经验的浓缩不一定覆盖所有情况但能覆盖绝大多数RISC-V启动交付初期的问题。真遇到了表里没有的现象“二分法切断启动链路”永远是通用的把总线逻辑分析仪挂到关键信号上或者在一级loader里临时加一个点灯动作确认前一级活着再排查后一级。5. 我踩过几次坑之后真正记住的几件事启动链路这种系统性工程真正让人记忆深刻的不是原理而是一次次让人头疼的实际问题。最后分享几条我这些年攒下来的体会希望能帮后来的人少走一点弯路。第一永远不要在拿到新板子的第一天就去改bootloader。先把出厂固件跑通用官方工具把原厂镜像备份好确认串口、JTAG、烧录流程全部可控再开始做二次开发。没有这条底线一旦刷了个带bug的自编译bootloader救砖的成本远比省下的那半天高。第二bootloader里的链接脚本值得反复看十遍。RISC-V启动期没有MMU代码里出现的地址既是链接地址也是物理地址一旦链接脚本里的VMA配置错误调试器看起来程序“加载成功”一运行就飞。我每次写新的启动代码第一件事就是在链接脚本里把地址段和芯片内存映射表一行行对照。第三给bootloader增加功能要极度克制。能点灯就不要做UI能打印hex就不要做log系统能固定路径就不要扫文件系统。启动代码越简单越容易从逻辑上证明它是正确的。很多生产环境的启动失败不是某个功能特别难而是bootloader塞了太多不该有的功能导致边缘情况失控。最后启动问题排查要有耐心。用JTAG读一次PC、加一行打印、查一次数据手册反复迭代大多数问题都能定位得清清楚楚。RISC-V的启动链路很透明每一级loader都是“接力棒”只要确认终点方向和每一步的交接约定剩下的就是一级一级往下查而已。