1. 项目概述一次拨开启动迷雾的实战复盘先说个很多人踩过的坑。我第一次接触 RISC-V 开发板时拿着一个编译好的裸机程序烧进去板子没反应串口一片空白。当时我以为是 Flash 烧录地址错了对着地址查了半天后来才意识到问题根本不在烧录而是我对 RISC-V 的启动路径缺乏整体认知——复位之后 PC 到底从哪个地址取值Bootloader 和主程序是什么关系内核或者 RTOS 是怎么被拉起来的这些问题不搞清楚写再多代码也是盲人摸象。这篇文章就是围绕一条完整路径展开的上电 → 复位 → 启动ROM → Bootloader 一级/二级 → 硬件初始化 → 镜像搬运 → 内核/RTOS 入口 → 应用程序运行。我会结合 RISC-V 的机器态M Mode和监督态S Mode机制把 Linux 内核、U-Boot、RT-Thread 这类系统的启动过程串起来讲同时给出一个最小化的 Bootloader 实现思路方便你直接对着改。适合谁来读三类人最合适刚入手 RISC-V 开发板想搞明白“上电后到底发生了什么”的嵌入式工程师在 ARM 平台写过 Bootloader想快速迁移到 RISC-V 上的老手正在学习 RTOS特别是 RT-Thread或 Linux 内核移植需要理解底层启动链路的学生和爱好者。如果你已经熟练使用 STM32 这类 ARM 平台会发现 RISC-V 的启动流程“形似但神不似”——同样是向量表、同样是初始化时钟和内存但权限模型、CSR 寄存器、中断入口的处理方式完全不同。这篇文章不会只给你结论我会把每个环节的“为什么”也讲清楚。2. 上电与复位第一行代码从哪里来2.1 RISC-V 的复位向量设计约定与自由并存RISC-V 一个非常“劝退”新手的地方是指令集规范里并没有强制规定复位向量Reset Vector的具体地址。ARM Cortex-M 系列一般从0x00000000取向量表而 RISC-V 完全看 SoC 厂商心情。有的芯片从0x00001000开始比如某些国产 RISC-V MCU有的从0x80000000开始比如常见的 SiFive 系列还有一些把复位地址固定到片内 BootROM 的高地址区域。这意味着了解你手上芯片的内存映射Memory Map是启动流程的第一门必修课。曾经我拿到一块新板子手册里主 RAM 地址是0x80000000但调试器一连接 PC 却停在0x00001000我当时就懵了。查了芯片手册才发现0x00001000是芯片内部 BootROM厂商在那里固化了一段出厂引导程序负责加载用户分区、校验签名、甚至支持串口下载。所以不要假设一定先查三样东西复位后默认的 PC 值是多少启动介质是 SPI Flash、并行 NOR Flash 还是内部 ROM第一条可执行指令是放在 XIP片上执行区域还是需要拷贝到 RAM。从工程实践来看大多数 RISC-V SoC 的复位向量都落在 BootROM 或者一个固定映射的 Flash 别名区因为 RISC-V 标准把“复位向量在哪里”留给了平台实现这给了芯片厂商极大的灵活性但对应用工程师来说意味着调试时每块板子都可能有自己的“脾气”。2.2 从 PC 取值到第一条指令启动 ROM 做了什么以比较常见的设计来说CPU 复位后PC 被硬件置为复位向量地址随后开始取指。这时候 DDR 控制器还没初始化外部 DRAM 不可用所以第一条指令必须在 ROM、SRAM 或者 XIP Flash 中。芯片厂商在 BootROM 里通常做这些事情设置关键的栈指针SP为 C 语言环境铺路配置基础时钟至少让 CPU 不再跑内部慢速 RC初始化 SPI Flash 控制器或者 SDIO 控制器尝试从预设介质读取下一级程序跳转到加载地址。这里有个细节值得注意RISC-V 上电后全局中断默认是关闭的mstatus.MIE位和mie寄存器默认清零。所以 BootROM 里一般不会看到“关中断”这一步不像 ARM 平台要先cpsr里清中断使能位。如果你在写自己的启动汇编时看到别人代码里“上来就关中断”那是兼容性习惯不是 RISC-V 必须的。我们用一个最简的复位启动汇编代码段来说明.section .text.init .globl _start _start: /* 设置全局栈指针指向栈顶 */ la sp, _stack_top /* 清零 BSS 段 */ la t0, _bss_start la t1, _bss_end bss_clear: bge t0, t1, bss_done sw zero, 0(t0) addi t0, t0, 4 j bss_clear bss_done: /* 跳转到 C 入口 */ call main loop: wfi j loop注意上面la sp, _stack_top之前理论上还要设置 CSR 寄存器比如mtvec指向中断向量表、mstatus设置浮点扩展等但作为最小启动路径第一步永远是 SP。栈都没建立谈什么 C 函数调用。这个顺序和 ARM 一样但是 ARM 是从向量表里取__initial_spRISC-V 是直接一条指令写 SP——表现不同目标一致。再说一个冷知识RISC-V 里PC 相对跳转是默认行为所有跳转指令jal、jalr天然支持位置无关代码PIC。所以很多 Bootloader 可以直接从 Flash 上执行不需要先搬运到 RAM 就能完成基础初始化。这也是为什么 RISC-V 的启动 ROM 往往比 ARM 的简单——因为不需要复杂的地址重定位。2.3 为什么 BootROM 不是万能的两级启动的产生如果 BootROM 直接把 Linux 内核运行起来当然最省事。但现实是BootROM 容量极小通常几十 KB没有复杂的驱动框架也缺少灵活的配置它只适合加载一个体积有限、用途明确的程序。于是两级加载模式几乎成了 RISC-V Linux 设备的标配第一级BootROM片上固件→ 加载 SPL / 一级 Bootloader第二级一级 Bootloader → 初始化 DDR、设备树、文件系统 → 加载 U-Boot 或 OpenSBI第三级U-Boot / OpenSBI → 加载内核、ramdisk、进入 S Mode。如果你玩过 ARM 平台这个套路和 AM335x 的 ROM → MLO → U-Boot 如出一辙。RISC-V 的另外一层特殊性在于标准定义了SBISupervisor Binary InterfaceOpenSBI 作为 M Mode 的固件负责和 Linux 内核打交道。所以路径变成了BootROM - U-Boot SPL - OpenSBI (M Mode) - U-Boot (S Mode) - Linux Kernel注意 U-Boot 本身在 RISC-V 上可以跑在 M Mode也可以跑在 S Mode。跑在 S Mode 时OpenSBI 先接管 M Mode提供 ecall 服务。这个层级关系很多人一开始搞不清我建议直接把它记成“三个世界”机器态掌握一切硬件监督态运行操作系统用户态运行应用程序。Bootloader 的跳转过程本质上是从机器态向监督态的权力移交。3. Bootloader 到底在干什么核心职责与关键机制3.1 除了搬运镜像Bootloader 更是一台硬件初始化机器很多教程把 Bootloader 简单描述成“拷贝代码、跳转”这是巨大的误解。拿 U-Boot 来说它在跳转内核之前做的事情包括初始化 PLL 和时钟树让 CPU 跑到标称频率初始化 DDR 控制器做人机界面和内存巡检初始化存储控制器支持从 eMMC、SD、SPI Flash 读镜像配置串口、网卡、USB方便调试和网络启动解析设备树把硬件资源信息传递给内核设置内核启动参数bootargs例如 console、root 分区等。这些工作里面DDR 初始化是最容易让人抓狂的一环。RISC-V 的 SoC 大多把 DDR 控制器的初始化参数藏在厂商私有寄存器里U-Boot 里通常会有对应的dram_init函数但时序参数不对轻则启动崩溃重则内存校验失败。我的做法是先用厂商 SDK 里验证过的参数跑通一次再考虑优化。千万不要一上来就对着手册自己调时序除非你能用逻辑分析仪抓 DDR 总线否则排查一周都可能找不到问题。从职责划分上看一级 Bootloader 里最核心的动作是初始化最小硬件环境 - 从存储介质加载二级镜像 - 跳转并移交控制权而到了二级 Bootloader比如完整版 U-Boot它的功能就扩展成了“一个小型操作系统”有命令解析、文件系统驱动、网络协议栈、环境变量管理。你会发现它已经不是“启动专用”了更像一个固件层面的开发调试平台。3.2 RISC-V 的异常模型ecall 的妙用在 RISC-V Linux 启动链路中OpenSBI 是绕不开的角色。它提供的功能本质上是通过ecall指令从 S Mode“陷入”到 M Mode然后由 M Mode 的固件代完成一些特权操作比如中断控制、定时器、远程 IPI、系统重置等。为什么需要这一层Linux 内核其实希望自己不要关心板级细节它只要知道“我这个系统有 4 个核定时器频率是多少怎么发 IPI”然后通过 SBI 调用统一接口就行了。这就好比你住酒店不需要知道热水器是什么牌子只需要按一个统一的“热水”按钮酒店运维团队OpenSBI在后台搞定一切。写 Bootloader 时ecall也经常被用于“测试模式切换”。我见过不少新手直接在 S Mode 下裸写csrw mie然后异常产生却不进中断查了半天发现是mstatus.MPRV或者权限模式的问题。这里给你们一个建议只要你的 Bootloader 跑在 M Mode访问 CSR 之前先看权限只要你的内核跑在 S Mode涉及特权操作一律走 SBI 代理不要试图绕过。3.3 设备树的作用镜像之外的“硬件说明书”RISC-V 平台现在普遍采用扁平设备树Device Tree Blob, DTB用于描述 CPU 数量、内存布局、中断控制器类型、外设地址范围等。Bootloader 的一项重要任务就是在跳转内核之前加载 DTB 到内存储器的某个约定地址用实际探测到的内存大小更新 DTB 中的memory节点把 Bootloader 收集的 MAC 地址、月台信息等写入 DTB 或通过 ATAG 传递确认 DTB 地址与内核镜像地址没有重叠。我有一次踩过坑内核启动到一半报Unable to handle kernel paging request at virtual address查了半天才发现 U-Boot 把 DTB 加载到了内核实模式映射的地址区间内核解压时把 DTB 覆盖了。从那以后我养成了习惯跳转前先用 U-Boot 命令行打印 DTB 加载地址并对比内核镜像加载范围手动错开至少几 MB 空间。4. 实操解析手写一个最小化 RISC-V Bootloader4.1 工程结构设计与工具链准备动手之前先准备好基础工具工具用途说明riscv64-unknown-elf-gcc编译裸机程序也可以用 riscv64-linux-gnu-gccriscv64-unknown-elf-objcopy生成 bin 镜像用于烧录/加载OpenOCD调试与烧录配合 JTAG 使用GDBriscv 版本断点调试看起来熟悉但寄存器完全不一样我的工程目录一般长这样bootloader/ ├── link.ld ├── start.S ├── main.c ├── ddr_init.c └── Makefile链接脚本是启动流程的灵魂。下面给一个简化版重点在于定义内存布局和入口点OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { /* 假设 BootROM 加载外部 Flash 到 SRAM 0x80000000 */ RAM (rwx) : ORIGIN 0x80000000, LENGTH 128K DDR (rwx) : ORIGIN 0x90000000, LENGTH 512M } SECTIONS { .text : { KEEP(*(.text.init)) *(.text*) } RAM .rodata : { *(.rodata*) } RAM .bss : { _bss_start .; *(.bss*) _bss_end .; } RAM .stack : { _stack_top . 4K; } RAM }注意ENTRY(_start)必须和汇编里的全局标号对应。很多移植问题一半出在链接脚本的段顺序一半出在入口符号不一致。你用riscv64-unknown-elf-objdump -d bootloader.elf看一下入口是否落在_start一目了然。4.2 汇编启动步骤SP、BSS、CSR 一个都不能少继续贴上完整的start.S关键片段这次把 CSR 初始化也加上.section .text.init .globl _start _start: /* 关闭全局中断确保启动过程不被干扰 */ csrrci zero, mstatus, 0x8 /* 清除 MIE 位 */ /* 设置栈指针 */ la sp, _stack_top /* 设置所有 hart 的中断向量表默认 _trap_entry */ la t0, _trap_entry csrw mtvec, t0 /* 设置 BSS 段 */ la t0, _bss_start la t1, _bss_end 1: bge t0, t1, 2f sw zero, 0(t0) addi t0, t0, 4 j 1b 2: /* 调用 C 代码 */ call main /* main 返回后不可能成功死循环 */ 3: wfi j 3b这里有两个细节csrw mtvec这步很容易被漏掉。RISC-V 复位后mtvec的默认值是实现定义的有些芯片默认为 0此时一旦中断发生CPU 会跳到地址 0 去取指大概率直接 Hardfault 或者异常循环。所以我在启动汇编里第一段就把mtvec指向本地标签。BSS 清零不能省。跳进 C 世界之前不把全局变量清零你会在后面调试时遇到“某些变量初始值随机”的诡异问题。我碰到过一次一个全局标志位莫名其妙是0xDEADBEEF浪费了我一下午最后发现 BSS 清零的循环因为链接脚本地址写错只清了前 16 字节。4.3 主循环里的关键操作时钟频率与跳转地址main.c里做的事情按顺序大概是#include stdint.h extern void jump_to_kernel(uint32_t addr, uint32_t dtb_addr); void delay(uint32_t count) { while (count--) { __asm__ volatile(nop); } } int main(void) { /* 1. 设置系统时钟例如 PLL 倍频到 1GHz */ pll_init(1000000000); /* 2. 初始化 DDR如果是从 Flash 启动需要搬运镜像 */ ddr_init(); /* 3. 从某种介质读取内核镜像示意实际可能是 SD/eMMC/Flash */ uint32_t kernel_entry 0x90200000; uint32_t dtb_addr 0x90100000; /* 4. 跳转之前关中断 */ __asm__ volatile(csrci mstatus, 0x8); /* 5. 跳转参数 a0 hart id, a1 dtb 地址 */ jump_to_kernel(kernel_entry, dtb_addr); return 0; }jump_to_kernel的汇编通常长这样.globl jump_to_kernel jump_to_kernel: /* a0 kernel_addr, a1 dtb_addr */ csrw mstatus, zero /* 关闭中断 */ li t0, 0x80000000 /* 清掉 MPP 位或者保持 M Mode取决于目标 */ /* 把 target 地址写入 mepc */ csrw mepc, a0 /* 参数设定 */ mv a0, zero /* hartid */ mv a1, a1 /* dtb 地址 */ mret重点说这里的mret。如果你在 M Mode 下执行mret它会从mepc恢复 PC并根据mstatus.MPP决定跳转后进入哪个特权级。如果你希望跳转之后让内核跑在 S Mode就必须提前把mstatus.MPP设置为01Supervisor同时把mepc设置成内核入口地址。很多移植失败的现场就是内核启动后打印几行信息然后卡死一查发现是特权级没切换干净Linux 在 S Mode 下执行了 M Mode 才允许的指令直接触发非法指令异常。我这边的建议是裸机 Bootloader 只需要在 M Mode 下跳转裸机程序保持 M Mode 不变是最省事的。真要引导 Linux建议直接用现成的 OpenSBI U-Boot 流程而不是自己从零写 M Mode 到 S Mode 的状态切换这里面的坑足够消耗你两个礼拜。4.4 参考 RT-Thread 在 RISC-V 上的启动初始化流程如果你开发的是 MCU 级别的小系统大概率会用 RT-Thread 这类 RTOS。RT-Thread 在 RISC-V 上的启动流程和上面讲的 Linux 路径不完全一样但骨架类似复位后进入_start设置栈指针清零 BSS设置mtvec指向rt_hw_trap_handler调用entry函数执行基础的时钟、串口、中断控制器初始化进入rt_hw_board_init完成堆内存初始化调用rt_system_scheduler_init和rt_system_timer_init创建main线程启动调度器。RT-Thread 的rt_hw_trap_handler是中断与异常的最终汇聚点对应汇编里的_trap_entry。如果你遇到过“RT-Thread 跑起来后中断无法响应”第一个要查的就是mtvec是否被正确设置第二个要查的是mstatus.MIE是否在整个初始化完成后才打开。顺序反了中断会来得比异常处理准备更早然后直接卡死。5. 实战中的常见问题与排查技巧5.1 镜像文件加载了但程序就是跑不起来这个问题我见得太多了。通常先做三件事确认编译器产生的入口地址和链接脚本匹配用riscv64-unknown-elf-nm bootloader.elf | grep _start看符号地址再用objdump反汇编确认第一条指令不是 0。确认栈指针是否在可写内存范围SP 指向的地址必须是物理存在且已经初始化的内存。很多板子的内部 SRAM 只有几十KB栈顶地址如果写在 128KB 之外代码一调用函数就写飞了。确认复位向量是否正确烧录用 OpenOCD 读取一下复位向量处的 4 字节指令如果内容是0x00000000或者0xffffffff大概率烧录地址错了。我的排查顺序是GDB 连接目标板info registers看 PC 是否停在复位向量stepi单步看能不能执行到跳转指令再x/4i $pc看反汇编。如果 PC 停在意外地址就去查mepc、mtvec和链接脚本的映射。RISC-V 的 GDB 调试体验和 ARM 差别很大因为寄存器名完全不同但基本思路一样。先看汇编再查内存最后翻手册。5.2 跳转到内核后无输出卡在了哪里这个症状有两种常见原因。第一种串口初始化前就被跳过去了。如果 Bootloader 跳转前把串口时钟、引脚复用、波特率寄存器都改了而内核启动早期依赖的 Debug Console 没能在新时钟域下工作就会表现为“什么输出都没有”。解决方法是跳转前不要动调试串口或者把内核参数里的 console 串口设置成 Bootloader 使用的同一组配置。第二种设备树地址不对。Linux 内核启动早期会通过a1寄存器拿到 DTB 物理地址如果传了一个空地址或者被覆盖的地址内核会 panic。你可以在 OpenOCD 下打断点检查a0、a1寄存器确认传参无误。我在调试自己的板子时习惯在jump_to_kernel里加一条打印printf(jump to %x, dtb %x\n, kernel_entry, dtb_addr);很多莫名其妙的“跳转失败”其实在打印里就能看出端倪比如 dtb_addr 变量在因为某种优化被覆盖成了 0。5.3 缓存与内存一致性启动阶段的隐形杀手RISC-V 架构允许实现不自动维护缓存一致性特别是在启动阶段MMU 还没有开启指令缓存和数据缓存的行为千奇百怪。我曾经遇到一个问题Bootloader 从 Flash 读取镜像后写入 DDR然后跳转执行结果前几条指令正常后面随机崩溃。最后发现是 D-Cache 没有 clean 到内存写到 DDR 的数据还停留在缓存里跳转后 I-Cache 取到的是陈旧数据。解决思路是在从外部介质读取镜像写入 DDR 之后执行fence.i保证指令缓存失效如果代码里用了 DMA确保 DMA 操作完成后再跳转必要时加内存屏障开启 MMU 和缓存之前先把启动阶段的缓存设置简单化能不开就不开等稳定了再优化。对 RISC-V 来说fence.i是一条专门用于同步指令流的指令很多从 ARM 转过来的朋友会忽略它。ARM 那边有类似操作但没有这么赤裸裸地要求你“每次修改代码后都要执行”。你可以在启动汇编里放一个宏每次加载镜像之后统一调用一次fence.i省心。6. 基于经验的方法论与调试建议6.1 最小可行启动路径优先我的经验是无论你最终目标是 Linux 还是 RT-Thread第一步先实现一个最小裸机程序点灯、串口打印、延时循环。这一步跑通后再往上叠加 Bootloader、虚拟内存、调度器。很多人喜欢直接编译 Linux、U-Boot、OpenSBI 一套流程然后烧进去黑屏面对海量的启动日志无从下手。最小启动路径里面的配置项极少只有_start、SP、BSS、main。如果你在最小路径上的每个环节都清楚后面那些复杂 Boot 流程无非是在这个路径上增加更多初始化步骤而已。这里也解释一个经常被混淆的概念MCU 和 SoC 的启动流程到底有什么不同MCU 通常从内部 Flash 直接执行Bootloader 是选配的甚至可以直接烧写应用程序到 0x0 地址上电就跑SoC 则普遍有 BootROM且必须经过多级加载。你手上的 RISC-V 芯片属于哪一类要去看芯片手册的“Boot Process”章节而不是猜。6.2 学会读启动日志比学会写代码更重要RISC-V 平台启动日志的常见格式OpenSBI 启动日志开始一行是OpenSBI v1.x它会打印平台名称、M Mode 频率、可用硬件特性。U-Boot 启动日志从U-Boot 2024.xx开始里面有 DRAM 大小、启动介质、环境变量加载位置。Linux 内核启动日志通常以[ 0.000000] Linux version ...开始后面是Machine model、Memory、Kernel command line。错误定位时我的策略是分层排查阶段看什么常见错误BootROM 阶段串口是否有早期打印无打印 BootROM 没跑起来或介质选择错误SPL 阶段DTB 是否加载打印报 CRC 错误 镜像损坏U-Boot 阶段DRAM 大小、环境变量DRAM 为 0 内存控制器初始化失败OpenSBI 阶段SBI 版本、平台卡住无输出 M Mode 陷阱异常Kernel 阶段内核启动 banner无 banner 传参不对或镜像损坏这套表格几乎能覆盖 90% 的启动失败问题。每当你拿到一块新板子先跑通 BootROM 到 U-Boot 阶段再跑通 OpenSBI最后才是内核。反过来调试你会被多层初始化代码淹没。6.3 工具链版本选择与常见坑RISC-V 的工具链迭代速度很快旧工具链编出来的 OpenSBI 或 U-Boot放到新内核上可能出现结构体对齐或 BUG 指令不兼容。我的建议是使用与内核匹配的交叉编译工具链版本至少 GCC 12 以上除非你必须调试 OpenSBI否则不要自己编译 OpenSBI直接用官方 release 版本U-Boot 尽量用主线版本厂商定制版往往带了一堆私有补丁出问题排查困难如果你的板子带 DDR 初始化私有固件保持厂商 SDK 里的初始化代码版本不变否则内存参数一变后续所有环节都无法定位。我见过最惨的一次一个团队花了三天排查 Linux 启动崩溃最后发现是编译器开到-O3把一段 Bootloader 汇编里的内存屏障指令给优化掉了。自此我在写启动相关代码时统一用-O2并且对关键汇编文件设置-fno-pic -fno-builtin -fno-stack-protector。7. 完整路径复盘从上电到内核加载你该记住哪些节点走到这里我们再回头看完整路径几个关键节点值得牢牢记住复位向量第一个 PC 地址不是厂商随便定的就是 BootROM 入口SP 与 BSSC 语言环境的基础缺了它们任何高级代码都无法运行CSR 初始化mtvec、mstatus、mie是中断与特权管理的核心三件套时钟与内存控制器没有它们DDR 就是一块不能访问的荒地镜像搬运不管你从 Flash、SD 卡还是网络加载都必须做完整性和缓存同步处理跳转前参数传递RISC-V 使用a0/a1传递 hartid 和 DTB这和 ARM 用r0/r1类似但大家传的内容不同特权级切换用mret切换到目标权限模式mepc指到入口地址fence.i改完代码后别忘了指令同步。如果你把这些节点都梳理清楚就会发现从 STM32 到 RISC-V、从 RT-Thread 到 Linux其实底层逻辑都是相通的启动是“硬件初始化 代码搬运 控制权移交”的组合变化的是寄存器和权限模型。最后顺便说一句现在很多 RISC-V 芯片的调试器都已经支持通过 JTAG 直接读写内存、设置硬件断点不用反复烧 Flash。调试 Bootloader 时我建议直接让 CPU 停在复位向量准备好复位地址处的镜像用 GDB 的load命令把程序加载进 RAM再手动设置 PC 到_start。这样做的好处是重启开发板后不需要每次烧写 Flash迭代速度快很多。等你把所有流程调通之后再固化到 Flash 也不迟。我自己调 RISC-V 板载 DDR 初始化的时候就是靠 OpenOCD 配合 TCL 脚本自动加载镜像几十次迭代下来比传统烧写效率高太多了。