从 ARM 裸机跳过来写 RISC-V 的 bare-metal 启动流程最大的感受是ARM 的启动文件往往是现成的你只需要改改向量表基地址、堆栈大小链接脚本调一调就能跑而 RISC-V 生态里很多环境连一份像样的 startup 汇编都要自己动手复位后 PC 落在哪里、栈指针什么时候立起来、数据段怎么从 Flash 搬到 RAM每一步都要自己确认。这篇是 RISC-V 系列专栏的第七期我就把启动与 bare-metal 流程从头捋一遍内容包括 reset vector、链接脚本的布局约定、启动汇编的逐条逻辑、trap 向量的处理以及多核启动时 hart 同步那点事。对于刚接触 RISC-V 又不想被汇编劝退的朋友这篇文章可以当一份启动阶段的避坑手册来用。1. 复位向量RISC-V 进入 C 语言世界前的第一站1.1 reset vector 到底是什么先说结论RISC-V 手册并没有规定复位后 PC 必须从哪个地址取指它把复位向量留给了具体实现来决定。这是和 ARM 系明显不同的地方——ARM Cortex-M 的复位向量是 0x00000000里面一开始放的是 MSP 初始值和 Reset_Handler 地址而 RISC-V 处理器厂商可以根据 SoC 的存储架构自由选择。常见的复位地址有这么几类0x80000000很多把 DDR 或大块 SRAM 排在最高地址的 SoC会直接把复位地址指到这里比如 QEMU virt 机器里 RAM 起点就是 0x80000000。0x00001000 / 0x00000000低地址放 BootROM 的芯片像有些 MCU 直接把 Flash 映射到 0 地址复位就从 0 去取。0x20000000 这种某些 SiFive 系列的处理器会把掩膜 ROM / Flash 放在这个段。所以拿到一块新板子第一件事就是查 datasheet 里Reset Vector那一节确认这个地址指向哪里。如果复位地址指向 BootROM那裸机程序通常不是直接从 BootROM 开始写的而是 BootROM 负责把用户代码从 Flash 拷到 RAM再跳转到用户代码入口如果复位地址直接就落在 Flash 上那第一条指令就是你烧进去的固件开头。1.2 复位瞬间硬件给了你什么状态RISC-V 复位之后有几个出厂默认状态是写启动代码必须依赖的特权级固定在 Machine Mode也就是最高特权模式所有 CSR 都能访问MISA、MSTATUS 等寄存器可以随便配置。PC 被拉到 reset vector每个 hart硬件线程都有自己的 PC如果有多核每个核都在跑同一条启动代码路径。mhartid 这个 CSR 可以读出当前 hart 的编号主核一般是 0或者由芯片自定义哪个核是启动核。这里就引出一个关键的启动设计约束因为每个 hart 都从 _start 开始执行启动代码必须通过 mhartid 来区分主核和从核。主核负责做全局初始化清 BSS、搬数据、配时钟、设中断入口从核则通常是自旋等待等主核把环境准备好之后再被放行。这套逻辑在汇编里就是很简单的几行判断但它决定了多核系统能否稳定起来。复位向量的取指还有一点要注意如果 PC 指向的地址上没有有效指令硬件会触发取指错误。裸机调试时最常见的现象是 GDB 单步第一步就跳飞到 0 或者一个奇奇怪怪的地址然后陷入异常循环。所以上来先确认第一条指令落在哪比什么都重要。2. 链接脚本启动代码的棋盘与边界2.1 为什么必须在启动阶段搞数据搬家嵌入式程序往往存在一个很别扭的处境代码放在只读存储里Flash运行起来要用的变量却必须放在 RAM 里。编译完成后.text代码段和 .rodata只读常量可以直接烧在 Flash 里原地执行但 .data初始化了的全局变量不行。比如你在 C 里写了int g_counter 5;这个 5 在烧录文件里是放在 Flash 的可程序一跑g_counter这个变量的地址在 RAM 里。RAM 断电不保存数据所以硬件没法直接把 5 留在 RAM 里必须在启动时把 Flash 里存的 5 搬到 RAM 中那个变量的地址上。这一搬就是启动汇编最主要的工作之一。. However 至于 .bss未初始化静态变量它在烧录文件里根本不占空间但运行时需要一块清零的内存区域。C 语言规定未初始化的全局变量和静态变量默认值是 0所以启动时要把 .bss 段清零。如果跳过这一步你会在后面看到局部变量莫名有初值的诡异 bug实际上那是上一次程序运行残留的数据。2.2 启动代码怎么知道从哪里搬、搬多少这就要靠链接脚本里预定义的那几个符号了。原因很简单汇编里没办法写死Flash 里 .data 的起始地址是多少因为链接之后地址才会确定。链接脚本通过给段贴标签的方式把地址信息暴露给启动代码。一份典型的 RISC-V 链接脚本片段长这样ENTRY(_start) MEMORY { /* 假设 ROM 从 0x80000000 开始RAM 由 SoC 定义 */ rom (rx) : ORIGIN 0x80000000, LENGTH 256K ram (rwx) : ORIGIN 0x80040000, LENGTH 192K } SECTIONS { .text : { KEEP(*(.text.entry)) *(.text*) } rom .rodata : { *(.rodata*) } rom _sidata LOADADDR(.data); .data : ALIGN(4) { _sdata .; *(.sdata .sdata.* .data .data.*) _edata .; } ram AT rom .bss : ALIGN(4) { _sbss .; *(.sbss .sbss.* .bss .bss.*) _ebss .; } ram }这里最关键的是.data段的 ram AT rom语法。它的意思是VMA运行时虚拟地址也就是程序运行时变量的访问地址在 RAM 里但 LMA加载地址也就是烧录文件里数据存放的位置在 ROM 里。_sidata LOADADDR(.data)就是把 LMA 地址传给_sidata这个符号。而.data段内部用.当前地址计数器记录了运行时起始_sdata和结束_edata.bss段记录了_sbss和_ebss。启动代码要做的事情就变成了把_sidata处的一段数据拷贝到_sdata到_edata的内存区域再把_sbss到_ebss的区域全部填 0。如果你习惯看 ARM 的分散加载文件就会觉得这套思路很亲切。区别在于 ARM 的 C 运行时库可能帮你把数据搬运做掉了而 RISC-V 裸机环境里这件事默认就是 startup 汇编自己写的。2.3 链接脚本出错的几种典型表现链接时报错undefined symbol: _sdata说明脚本里没定义或在被汇编引用的翻译单元里不可见。要么补上标签要么检查是否为GLOBAL。启动后全局变量值不对多半是.data段的 LMA/VMA 关系写错了导致拷贝源地址不对。程序一进 main 就异常有时是.bss和.data重叠了或者栈区域被某个段覆盖。链接脚本里的ALIGN(4)不能省略RISC-V 规范里某些 CSR 和原子操作对地址对齐要求很严。提示写启动代码的头几次建议把链接脚本里的 ORIGIN 和 LENGTH 对着存储器映射表核对三遍。地址错了不一定会链接失败但一定会运行失败而且失败方式非常隐蔽。3. 启动汇编逐条拆解栈、数据与 BSS 的接力3.1 _start 入口处的第一件正事立栈现在终于进入动手环节。启动汇编通常放在独立的.text.entry段里理由是要保证链接脚本可以把这段固定在复位入口处。第一段典型的启动汇编长这样.section .text.entry .globl _start .type _start, function _start: /* 从 CSR 读取当前 hart id */ csrr t0, mhartid /* 从核跳去自旋等待主核继续往下走 */ bnez t0, secondary_halt /* 主核初始化栈指针 */ la sp, _stack_top /* 可选初始化全局指针 gp用于缩短全局变量寻址 */ .option norelax la gp, __global_pointer$ .option relax /* 初始化 trap 向量先指向我们自己的 trap_entry */ la t0, trap_entry csrw mtvec, t0为什么先立栈因为接下来调用 C 函数、保存现场、处理异常全都要靠栈。RISC-V 的栈是向下增长的栈指针应该指向栈区的最高地址。链接脚本里要预留一块栈内存然后用符号_stack_top暴露出来例如在 SECTIONS 外面加一句PROVIDE(_stack_top ORIGIN(ram) LENGTH(ram));。这叫栈顶注意不是栈底。la sp, _stack_top之后栈就可用了吗严格来说还缺一步如果 SoC 要求栈地址按 16 字节对齐RISC-V ABI 要求sp在调用边界保持 16 字节对齐链接脚本要做好对齐或者干脆在汇编里andi sp, sp, -16强制对齐。我之前忽略这一步结果调用一个带 double 参数的函数时load 指令直接触发 misaligned 异常。3.2 数据搬运与 BSS 清零的汇编细节一个性能尚可的拷贝循环是 4 字节一搬如果你知道段对齐到 16 字节也可以用lw/sw处理甚至一次搬 8 字节RV64 用ld/sd。下面这段是 RV32 的写法/* 把 .data 从 ROM 拷贝到 RAM_sidata - _sdata .. _edata */ la t0, _sidata la t1, _sdata la t2, _edata copy_data: bgeu t1, t2, bss_init /* 如果 t1 t2 说明拷贝完了 */ lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data bss_init: /* BSS 清零_sbss .. _ebss */ la t0, _sbss la t1, _ebss clear_bss: bgeu t0, t1, call_main /* 清零完成 */ sw zero, 0(t0) addi t0, t0, 4 j clear_bss call_main: /* 进入 C 运行时初始化 */ call __libc_init_array call main /* main 如果意外返回挂在这里 */ loop: j loop写这段汇编时有几个坑我要强调一下判断结束用bgeu无符号比较而不用beq因为地址差可能不是 4 的整数倍直接比较地址大小更稳妥。如果.data段为空_sdata和_edata可能指向同一个位置循环一次都不会执行属于正常现象。__libc_init_array在 C 工程里负责调用全局构造函数在纯 C 工程里可以不加但加上也不会有副作用。有一个细节容易被忽略这段代码本身在 .text 段里如果启动代码从 Flash 原地执行没有任何问题但如果 SoC 要求系统启动阶段先把代码镜像从 Flash 拷到 RAM 再运行比如某些包含 BootROM 的芯片那你就得在这段数据搬运之前先做一次自我搬移把 .text 段也搬进 RAM。这种场景里启动汇编会被分成两段前面那段跑在 Flash/BootROM 中后面那段跑在 RAM 中。搬移用的还是同一套 LMA/VMA 机制只是把.text段也搞成 ram AT rom。搞不清楚这一点代码会在某个时刻突然开始乱跳这就是 PC 跟着 Flash 里的旧代码走、但数据已经跑到 RAM 里去的结果。3.3 从汇编世界跨入C 世界的交接准备主核在跳进 main 之前除了立栈和数据搬运一般还会做这些事配置 PMP物理内存保护条目确保 M 模式能访问全部内存和 IO 区域。RISC-V 默认复位后 PMP 可能不开放全部地址某些实现里越权访问会直接触发异常。设置mstatus.MPRV或清掉一些默认位保证后续访问行为符合预期。如需使用浮点启用mstatus.FS位否则浮点指令会被视为非法指令。内存保护这个东西最容易被人忽略。你在 QEMU 上跑没问题因为 QEMU 的默认 PMP 很宽容但真实芯片上PMP 没配好可能连访问外部 SPI Flash 都触发异常。启动代码里在该配置 PMP 的位置写上csrw pmpaddr0, ...和csrw pmpcfg0, ...是我的习惯动作宁可每次都用不到也不能等踩到了再来补。跳转到 main 之后启动流程就算是把接力棒交给了 C 世界。不过在真正的嵌入式工程里main 之前往往还要初始化一个最小串口、给调试留个口子这在后续的开发中会是救命稻草。我的习惯是启动代码里尽早配好 UART哪怕只输出一个字符也能确认 PC 确实跑到了预期位置。4. trap 入口别让中断砸穿裸机系统4.1 mtvec你的异常和中断都会先到这里C 启动完成并不意味着万事大吉因为裸机程序活在真实硬件里任何时间都可能出现异常和中断。RISC-V 把所有异常和外部中断统称为 trapM 模式的 trap 入口由mtvec寄存器决定。mtvec不是简单一个地址它还包含模式位。最低两位表示模式00Direct 模式所有 trap 都跳转到mtvec指向的同一个地址。01Vectored 模式中断按 cause 跳转到不同偏移的入口异常还是走基地址。裸机启动阶段我强烈建议先用 Direct 模式也就是mtvec最低两位写成 00。因为 Vectored 模式要求有一个完整的中断向量表每个中断入口都放着跳转指令工程还小的时候没必要给自己增加这个复杂度。把mtvec trap_entry就足够了。mtvec的地址对齐要求是 4 字节对齐。Direct 模式下这个限制很容易满足但如果后面对接了 RTOS或者要支持多个中断源再切 Vectored 模式也不迟——切的时候记得把对齐改成4 * 中断向量条数的最小对齐。4.2 一次 trap 保存现场的完整动作trap 进入后硬件自动做的只有几件事PC 存入mepc。触发原因存入mcause负数表示中断正数表示异常。mstatus.MIE被硬件清除中断被暂时屏蔽同时老的 MIE 被保存到mstatus.MPIE。特权级相关的mstatus.MPP记录之前的模式。可能附带一些信息存入mtval例如非法地址、非法指令编码。但硬件不会帮你保存通用寄存器。如果不在 trap 入口处保存所有 GPR那这个 trap 一返回现场就全丢了。所以 trap_entry 的第一件事就是分配栈帧并压栈。下面的 trap 入口是简化版但结构完整.section .text .globl trap_entry .align 2 trap_entry: /* 先腾出栈空间一次性保存现场 */ addi sp, sp, -32 * XLEN_REG_SIZE /* 按惯例保存 ra 和临时/参数寄存器实际工程可扩展到全部 32 个 GPR */ SAVE_GPRS /* 这里用宏展开逐个 sw 到 sp 偏移处 */ /* 读 cause判断该走哪个分支 */ csrr t0, mcause bltz t0, is_interrupt is_exception: /* 异常处理例如打印 mepc、mcause、mtval 后停机 */ call exception_handler j trap_return is_interrupt: /* 中断处理根据 mcause 低位置判断来源 */ call irq_handler trap_return: /* 恢复 GPR */ LOAD_GPRS /* 返回前清掉本帧 */ addi sp, sp, 32 * XLEN_REG_SIZE mret这里最值得说的就是最后那个mret它和普通ret有本质区别。mret会把mepc写回 PC同时用mstatus.MPP恢复之前的特权级、用MPIE恢复 MIE。如果你图省事用ret那就是跳去 ra 寄存器指向的地址完全偏离了被中断打断的现场而且特权级也不会恢复一场 Live 调试立刻变成灾难现场。保存 GPR 的宏也是一个容易出错的地方。RISC-V 有 32 个整数寄存器异常入口我一般全保存因为中断处理里可能跑 C 函数而 C 函数会按 ABI 随意使用临时寄存器和参数寄存器。只保存部分寄存器虽然省一点栈空间但排查起来太痛苦了不值得。4.3 中断使能顺序最后的开关要最后合上能在 trap 入口处理中断不等于中断就会自己进来。RISC-V 的中断开启是Do 级联的需要配置mie寄存器把对应中断源打开比如 MTIE 定时器中断、MSIE 软件中断、MEIE 外部中断。需要把mstatus.MIE置 1这是全局总开关。我遇到不少新手把mie配好了却忘了开mstatus.MIE然后在主循环里一直轮询等待中断就是不触发。反过来如果先把mstatus.MIE打开了再写mtvec中途一个非法访问异常就会跳到还没配好的入口结果是直接卡死。所以标准顺序是先配mtvec再开mie各个中断源最后才置mstatus.MIE。还有个细节mstatus.MIE可以用csrw写整个 mstatus但那样会把其他字段也一并改掉容易误伤 FPU 配置和 MPRV。更安全的做法是用csrwi或csrw只操作对应位比如csrsi mstatus, 0x8把第 3 位置 1。这类只改一个位的操作在启动代码配置中能避免很多莫名其妙的副作用。5. 多核启动每个 hart 都从 _start 跑谁当主核谁当从核5.1 主从核的约定与共享标志的原子操作多核系统里芯片上电后每一个 hart 都会从同一个入口执行_start。如果每个核都做一遍全局初始化数据搬运和 BSS 清零都会重复执行而后启动的核完全可能把前面核已经初始化好的数据结构再踩一遍。所以启动编码的第一条分支就是csrr t0, mhartid bnez t0, secondary_wait主核通常是 hart 0走正常初始化路径从核则进入一个等待循环等待主核发出的可以开始你自己的工作的信号。这个信号怎么传最简单也最安全的做法是在一块公共内存里放一个标志变量secondary_wait: /* 死等标志位变成 1 */ la t0, boot_flag 1: lw t1, 0(t0) beqz t1, 1b /* 被释放后给从核立自己的栈 */ csrr t0, mhartid slli t0, t0, 12 /* 每个核栈大小按 4KB 计算 */ la sp, _stack_top sub sp, sp, t0 /* 从核之后可以直接进入各自的应用入口 */ call secondary_main主核初始化完成后在某个合适的时机把boot_flag写成 1。这里有个东西必须考虑内存可见性。从核的等待循环在另一条硬件线程上如果主核写标志之前还有内存写的操作从核不一定立刻能看到那些写的结果。RISC-V 内存模型里普通读写是弱有序的跨 hart 共享数据时需要合适的同步原语。在标志写入前后加fence指令或者用原子指令直接操作标志位是常见做法。使用原子指令的例子是amoswapli t1, 1 amoswap.w t2, t1, (t0) /* 把 1 换入 boot_flag并返回旧值 */这段主核代码的好处是能顺便拿到旧值判断是不是已经有核把这个标志设置过了避免重复发布。从核那边也可以用lr/sc或fence实现更精细的同步。注意多个 hart 共享同一个栈区是启动崩溃的头号元凶。每个核必须按 hartid 计算独立栈区否则一个核的嵌套调用会把另一个核的栈给踩烂。最简单的方式就是上面那样栈顶减去hartid * 栈大小。5.2 复杂 SoC 里的多阶段启动从 BootROM 到主镜像如果去看一些大型 SoC比如我做过的 4G/5G 调制解调器像高通 SDX85 这类芯片你会发现它们在完整系统层面上的启动也是接力形式芯片内部 BootROM 先跑极短的固化代码初始化最基本的外设然后把下一级引导程序搬进 SRAM引导程序再接替初始化 DDR、时钟最后才把主系统镜像加载到内存并跳转。这个链条上每一级都承担了上一级铺路下一级跑长跑的角色。把这个思路放回 RISC-V 裸机工程里你完全可以把自己的启动代码当成一个小型 BootROM 看待硬件复位跑_start做最基本的栈和数据初始化尤其当你的最终应用不止一个镜像而是包含 SPL、主固件、可更新的 App 分区时这个多阶段分层会非常有用。裸机启动流程的Part 1 负责为自己跑起来铺路Part 2 负责为应用层铺路本质和大型 SoC 的启动思想是一致的。在多核正式进入应用之前还有一个隐藏任务如果要使能 I-Cache/D-Cache 和 MMU通常是在这一步做。对复杂 SoC从 bootloader 阶段开启 MMU 时还要确保页表所在内存对每个 hart 可见不然某个核一开启虚拟地址就立刻捉瞎。我见过的案例里有核因为没等共享内存同步就使能了 MMU最后 PC 直接飞到物理地址上怎么追都追不回来。所以我的经验是多核启动阶段宁可多等不可抢跑所有共享内存的写入顺序比几个周期的性能更要紧。6. 启动调试的几个高频坑与排查思路6.1 现象一完全没有输出GDB 卡在莫名地址第一个典型失败现场烧录后串口什么都没打印调试器一 attach 发现 PC 停在某个不是_start的地址。排查思路按顺序来确认复位向量地址和链接脚本里入口是否一致。用 GDB 在_start下断点直接continue如果断不下来说明代码根本没被放在复位向量指向的位置查链接脚本和烧录地址。确认栈是否立好。复位后在 trap 入口或 C 函数调用时若 sp 还是 0 或者指向非法内存任何call指令都会把返回地址写到神秘区域程序直接失控。确认能否取指成功。有的 Flash 需要在启动时等待控制器就绪否则第一条指令执行就触发异常。看mcause的值如果是取指错误优先检查 Flash 控制器和时钟初始化。我还习惯在_start的第一行放一个可读的指纹往一个已知 GPIO 寄存器写一个特征值或者直接往 UART TX 写一个固定字节。硬件单步虽然慢但能最快定位问题是出在取指、还是出在第一条指令本身。6.2 现象二变量值对不上程序越跑越诡异如果全局变量在 main 里读出来不是初始值十有八九是.data拷贝没做对或者.bss没清零。排查手法如下用调试器看_sidata处的内存是什么再看_sdata地址处的内存是什么两者相等则说明搬运动作做了。如果拷贝循环一次都没有进入检查_sdata和_edata值是否相等。相等表示.data段是空的这种情况下全局变量应该没有初始值才对。如果 BSS 没清零最常见的结果是某个全局标志碰巧是 0x5A程序判断错误走进了诡异分支。这种 bug 比变量值不对更隐蔽。有一个很经典的问题链接脚本里用了 ram AT rom但_sidata是用LOADADDR(.data)取的如果你在.data段内部手动定义了_sidata .那就取成了 VMA 而不是 LMA源地址和目的地址变成同一个搬运等于自我赋值全局变量自然全错。这个我在给一个 RISC-V 开发板做移植时踩过后来看到程序里打印出来的正确初始值全都是零才回头查链接脚本。6.3 调试工具链的一点点实战建议当前调试 RISC-V 裸机启动我用得比较多的是 OpenOCD GDB 的组合。下面几条操作很实用在 trap_entry 处也下一个断点这样任何异常都能在第一时间停下来然后读mepc、mcause、mtval三个 CSR就能定位是哪条指令触发的。如果你的调试器支持直接读写 CSR 是最快的方式。GDB 里通常可以用monitor reg或者带扩展的 gdb 命令查看mtvec、mepc等。QEMU 模拟器作为起步工具极其合适-machine virt -kernel your.elf -s -S拉起 GDB复位后单步汇编观察栈和内存变化比在真实芯片上抓瞎快得多。不过要记住QEMU 默认对很多 CSR 行为很宽松真实芯片上该配的 PMP、时钟一个都不能少。有条件的话给启动代码加 semihosting在汇编阶段通过调试器向宿主机打印字符。这样在串口驱动都还没初始化的时候也能看到启动走到了哪一步。这个方法我强烈推荐尤其适合多核启动的场景因为每个核都能打出自己的 hartid立刻能看到哪个核卡在哪个等待循环。还有一个小习惯值得分享每次改板子或者换工具链版本我建议从头跑一遍启动流程验证不直接跑业务代码。宁愿花十分钟在_start单步走一遍也别等应用崩溃之后再来怀疑启动代码有没有问题。这个投入回报率非常高。这次启动流程的笔记就到这里。裸机开发的乐趣和折磨都在这些底层细节里一次正确的地图勾勒、一次低压的汇编循环、一次苛刻的 CSR 配置都决定着上层 C 代码能否有尊严地运行。后面专栏里我会继续写 RISC-V 的中断驱动框架、外设寄存器封装以及在真实 SoC 上移植 RTOS 的经验如果你也在折腾 RISC-V 的 bare-metal 启动欢迎交流各自的踩坑记录。