1. 为什么 bare-metal 启动值得单独拎出来讲
很多人学 RISC-V 是从写汇编点灯开始的,写完gpio_set(1)看到灯亮了就觉得“启动不过如此”。但真正到了自己画板子、自己写链接脚本、自己处理多核竞争的时候,才发现启动流程是整个系统里最容易翻车、也最考验基本功的一环。裸机(bare-metal)环境下没有操作系统帮你兜底,没有 MMU 帮你做地址映射,甚至连栈指针都得你自己设——CPU 上电那一刻,它不知道你的代码在哪、数据在哪、栈在哪,全靠你通过链接脚本和启动代码告诉它。
这篇内容围绕 RISC-V 的启动与 bare-metal 流程展开,核心会覆盖链接脚本的编写逻辑、启动汇编的执行顺序、多核启动的竞争处理、以及从复位向量到 C 语言main之间的完整链路。适合已经能跑通简单裸机程序、但对自己写的启动代码“知其然不知其所以然”的开发者,也适合从 ARM 平台迁移过来、想搞清楚 RISC-V 启动差异的工程师。我会尽量把每个设计决策背后的“为什么”讲透,而不是甩一段汇编让你自己悟。
RISC-V 的启动和 ARM 有个本质区别:ARM 的复位向量地址通常是固定的(比如 Cortex-M 从 0x00000000 取栈顶和复位向量),而 RISC-V 把复位地址做成了可配置的。这个设计给了 SoC 厂商很大的自由度,但也意味着你拿到的每一块 RISC-V 板子,启动地址可能都不一样。这个差异直接影响了链接脚本怎么写、启动代码怎么定位,后面会详细展开。
2. 启动流程的整体设计与地址空间规划
2.1 从复位向量到第一条指令
RISC-V 规范里,复位后的行为定义得比较“克制”。CPU 从某个实现定义的地址开始取指,这个地址叫复位向量(reset vector)。常见的取值有几类:0x00000000、0x80000000、0x1000 等,具体取决于 SoC 设计。比如很多基于 SiFive 核心的芯片复位地址在 0x1004 或者 0x80000000,而一些 FPGA 上的软核可能把复位向量放在 0x00000000。
这里有个容易踩的坑:复位向量地址不一定是 0。我见过有人照着某块开发板的链接脚本抄,把.text段起始地址写成 0x80000000,结果换了一块复位地址在 0x0 的板子,程序根本跑不起来,因为 CPU 上电后从 0x0 取指,那里是空的。所以第一步永远是查你手上这颗芯片的数据手册,确认复位向量地址。
复位后 CPU 处于M 模式(Machine Mode),这是 RISC-V 权限等级里最高的。此时中断全局关闭,MMU(如果实现了)关闭,缓存状态取决于具体实现。你需要在启动代码里完成一系列初始化,才能安全地跳到 C 语言环境。
2.2 链接脚本的核心作用
链接脚本(linker script)在 bare-metal 里的地位,相当于操作系统的“内存规划局”。它决定了代码段、数据段、BSS 段、栈、堆分别放在哪个地址,以及各个段的排列顺序。对于裸机程序,链接脚本通常要处理这几件事:
- 入口点定义:通过
ENTRY()指定入口符号,通常是_start。 - 段布局:
.text(代码)、.rodata(只读数据)、.data(已初始化全局变量)、.bss(未初始化全局变量)的地址分配。 - 栈和堆的预留:通过符号标记栈顶、堆起始位置。
- 对齐要求:RISC-V 对指令对齐有要求,尤其是压缩指令集(C 扩展)下,指令可以是 2 字节对齐,但跳转目标通常要求 4 字节对齐。
一个典型的链接脚本骨架长这样:
OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M } SECTIONS { .text : { *(.text.init) *(.text .text.*) } > RAM .rodata : { *(.rodata .rodata.*) } > RAM .data : { *(.data .data.*) } > RAM .bss : { *(.bss .bss.*) *(COMMON) } > RAM . = ALIGN(16); _stack_top = .; . = . + 0x10000; _stack_bottom = .; }这里有几个细节值得说。*(.text.init)单独拎出来放在最前面,是为了确保启动代码一定在段的起始位置,这样复位向量跳过来就能直接执行。_stack_top和_stack_bottom这两个符号会被启动汇编引用,用来设置sp寄存器。栈的大小 0x10000(64KB)是个经验值,实际项目里要根据中断嵌套深度和函数调用层级调整,太小吃中断会直接踩内存。
2.3 为什么启动代码要用汇编写
C 语言函数调用依赖栈,而栈指针sp在复位时是未定义的。所以在设置好sp之前,你不能安全地执行任何 C 代码。这就是启动代码必须用汇编写的原因——它是“鸡生蛋”问题:C 需要栈,栈需要汇编来设。
启动汇编通常放在一个.S文件里(大写 S 表示需要预处理器),核心任务按顺序是:
- 关闭全局中断(复位时本来就是关的,但显式再关一次更稳妥)。
- 设置栈指针
sp。 - 清除
.bss段。 - 把
.data段从 Flash 拷贝到 RAM(如果代码存在 Flash 里)。 - 设置
gp(全局指针)和tp(线程指针),如果链接器 relaxation 需要的话。 - 跳到
main。
这个顺序不能乱。比如清 BSS 必须在跳 main 之前,否则 C 代码里那些“默认值为 0”的全局变量就是随机值。而设置gp必须在任何可能用到全局变量的代码之前,否则链接器优化后的代码会访问错误的地址。
3. 核心细节解析与实操要点
3.1 栈指针设置的正确姿势
设置sp看起来简单,但有个对齐的坑。RISC-V 的 ABI 要求栈指针 16 字节对齐。如果你设的栈顶地址不是 16 字节对齐的,某些编译器生成的向量化指令或者long double操作可能会触发异常。所以链接脚本里_stack_top之前最好加一句. = ALIGN(16);。
另一个问题是栈的生长方向。RISC-V 的栈是向下生长的,也就是压栈时sp减小。所以_stack_top应该是栈区域的高地址,sp初始化为_stack_top,然后随着函数调用逐渐向低地址移动。我见过有人把sp设成栈底,结果第一次压栈就踩到了 BSS 段,这种 bug 用调试器看半天都未必能找到。
la sp, _stack_top用la(load address)伪指令而不是li,是因为_stack_top是个链接期才确定的符号,la会被汇编器展开成auipc+addi的组合,能正确处理任意 32 位地址。
3.2 BSS 段清除的边界处理
清 BSS 的标准写法是用_bss_start和_bss_end两个符号做循环:
la t0, _bss_start la t1, _bss_end bgeu t0, t1, bss_done bss_loop: sd zero, (t0) addi t0, t0, 8 bltu t0, t1, bss_loop bss_done:这里用bgeu和bltu(无符号比较)而不是有符号版本,是因为地址是无符号数。如果 BSS 段跨越了 0x80000000 这种高位地址,有符号比较会出错。按 8 字节(sd)清零比按字节清零快,但前提是 BSS 起始和结束地址都是 8 字节对齐的。链接脚本里给.bss段加ALIGN(8)就能保证这一点。
注意:如果 BSS 段很大(比如几百 KB),这个循环会消耗不少启动时间。在对启动速度敏感的场景(比如需要快速响应的工业控制),可以考虑用 DMA 或者硬件清零模块来加速,但大多数场景下软件循环就够了。
3.3 DATA 段拷贝的两种场景
.data段的处理取决于你的存储介质。如果代码直接跑在 RAM 里(比如通过调试器加载或者从 BootROM 拷贝到 RAM),那.data已经在正确的位置了,不需要拷贝。但如果代码存在 Flash 里,.data段的初始值也在 Flash 里,运行时需要拷贝到 RAM。
链接脚本里通常这样处理:
.data : AT(ADDR(.text) + SIZEOF(.text)) { _data_start = .; *(.data .data.*) _data_end = .; } > RAM _data_load = LOADADDR(.data);AT()指定了加载地址(在 Flash 里),LOADADDR取出这个地址。启动汇编里用_data_load作为源,_data_start作为目的,_data_end作为结束,做一次内存拷贝。这个拷贝逻辑和清 BSS 类似,但源和目的不同。
3.4 多核启动的竞争与仲裁
多核 RISC-V 芯片的启动是个经典难题。上电后所有核心同时从复位向量取指,如果每个核心都去清 BSS、初始化外设,就会产生竞争——两个核心同时写同一个外设寄存器,结果不可预测。
常见的处理策略有两种:
策略一:主从模式(Master-Slave)。用mhartid寄存器读取当前核心的 ID,只有 ID 为 0 的核心执行完整初始化,其他核心进入等待循环(WFI),等主核初始化完成后通过 IPI(核间中断)或者共享内存标志唤醒。
csrr t0, mhartid bnez t0, secondary_hart_wait # 主核继续初始化 ... secondary_hart_wait: wfi j secondary_hart_wait策略二:各自初始化私有资源。每个核心只初始化自己私有的东西(比如自己的栈、自己的中断控制器),共享资源由主核统一初始化。这种模式适合核心间耦合度低的场景。
mhartid是 M 模式下的只读 CSR,每个核心读到的值不同。但要注意,mhartid的编号不一定是连续的,也不一定从 0 开始,具体取决于硬件实现。所以判断“是不是主核”最好用mhartid == 0而不是“第一个执行到的核心”。
实操心得:多核启动时,从核的等待循环里最好加一点延迟或者用 WFI 而不是忙等(busy-wait)。忙等会让从核持续占用总线带宽,影响主核的初始化速度。WFI 让核心进入低功耗状态,等中断唤醒,既省电又减少总线竞争。
4. 实操过程与核心环节实现
4.1 完整启动汇编的逐行拆解
下面是一个可以直接参考的启动汇编模板,我按执行顺序逐段说明:
.section .text.init .globl _start _start: # 1. 关闭全局中断 csrw mie, zero csrw mip, zero # 2. 读取核心 ID,非 0 核心进入等待 csrr t0, mhartid bnez t0, secondary_wait # 3. 设置栈指针 la sp, _stack_top # 4. 清除 BSS la t0, _bss_start la t1, _bss_end bgeu t0, t1, bss_done bss_clear: sd zero, (t0) addi t0, t0, 8 bltu t0, t1, bss_clear bss_done: # 5. 拷贝 DATA 段 la t0, _data_load la t1, _data_start la t2, _data_end bgeu t1, t2, data_done data_copy: ld t3, (t0) sd t3, (t1) addi t0, t0, 8 addi t1, t1, 8 bltu t1, t2, data_copy data_done: # 6. 设置 gp(全局指针) .option push .option norelax la gp, __global_pointer$ .option pop # 7. 跳转到 main call main # 8. main 返回后进入死循环 halt: wfi j halt secondary_wait: wfi j secondary_wait第 6 步的.option norelax是个关键细节。链接器 relaxation 会把la gp, __global_pointer$优化成addi gp, gp, 0之类的短指令,但此时gp还是未初始化的值,优化后反而出错。用.option push/pop临时关闭 relaxation,确保la被正确展开。
4.2 链接脚本的完整配置与参数计算
结合上面的启动汇编,链接脚本需要提供这些符号:_stack_top、_bss_start、_bss_end、_data_load、_data_start、_data_end、__global_pointer$。
OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN = 0x20000000, LENGTH = 4M RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M } SECTIONS { .text : { *(.text.init) *(.text .text.*) . = ALIGN(8); } > FLASH .rodata : { *(.rodata .rodata.*) . = ALIGN(8); } > FLASH .data : { _data_start = .; *(.data .data.*) . = ALIGN(8); _data_end = .; } > RAM AT> FLASH _data_load = LOADADDR(.data); .bss : { _bss_start = .; *(.bss .bss.*) *(COMMON) . = ALIGN(8); _bss_end = .; } > RAM . = ALIGN(16); _stack_top = .; . = . + 0x10000; _stack_bottom = .; __global_pointer$ = _data_start + 0x800; }__global_pointer$的偏移 0x800 是个经验值。RISC-V 的gp相对寻址范围是 ±2KB,把gp设在.data段起始 + 0x800 的位置,可以让前后各 2KB 范围内的全局变量都能用gp相对寻址访问,减少指令数。如果你的全局变量特别多,超过了 4KB 范围,超出的部分链接器会自动生成绝对地址访问,不影响正确性,只是代码稍微大一点。
4.3 多核启动的完整实现
多核场景下,从核的唤醒通常有两种方式。一种是主核初始化完成后,通过写msip寄存器(机器模式软件中断挂起)给从核发 IPI。从核在 WFI 状态下收到中断后醒来,跳转到指定的入口。
secondary_wait: # 从核设置自己的栈 la sp, _secondary_stack_top # 等待主核的 IPI wfi # 醒来后跳转到从核入口 call secondary_main j secondary_wait主核这边:
void wake_secondary_harts(void) { for (int i = 1; i < NUM_HARTS; i++) { // 向从核发送 IPI *(volatile uint32_t *)(CLINT_MSIP_BASE + i * 4) = 1; } }CLINT(Core Local Interruptor)的msip寄存器每个核心一个,写 1 触发软件中断。从核收到中断后,mip.MSIP置位,如果mie.MSIE使能且全局中断打开,就会跳转到mtvec指向的异常处理入口。
注意:从核的栈必须和主核分开,否则两个核心同时压栈会互相踩。链接脚本里要给每个从核预留独立的栈空间,或者用动态分配的方式在运行时给从核分配栈。
4.4 从复位到 main 的时序验证
写完启动代码后,怎么验证它真的按预期执行了?最直接的方法是用调试器单步跟踪,但更高效的方式是在关键节点翻转 GPIO 或者写调试寄存器,用逻辑分析仪或者示波器看时序。
我通常会在启动代码里插几个“标记点”:
# 标记点 1:复位后立即翻转 GPIO li t0, GPIO_BASE li t1, 0x1 sw t1, 0(t0) # ... 初始化代码 ... # 标记点 2:BSS 清除完成后翻转另一个 GPIO li t1, 0x2 sw t1, 0(t0)用示波器看这两个 GPIO 的翻转时间差,就能知道 BSS 清除花了多久。如果时间异常长,可能是 BSS 段太大或者循环效率有问题。这种方法比在调试器里数指令周期直观得多,也更接近真实运行环境。
5. 常见问题与排查技巧实录
5.1 启动失败的症状与对应原因
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 上电后无任何反应 | 复位向量地址错误 | 查数据手册确认复位地址,检查链接脚本.text起始地址 |
| 程序跑飞,PC 跳到非法地址 | 栈指针未设置或对齐错误 | 在启动汇编第一条指令后设断点,检查sp值 |
| 全局变量初值不对 | DATA 段未拷贝或拷贝范围错误 | 检查_data_load、_data_start、_data_end符号地址 |
| 多核场景下随机死机 | 多核竞争共享资源 | 确认从核是否在等待循环中,检查共享外设的访问是否加锁 |
| 中断触发后死机 | mtvec未设置或中断向量表未对齐 | 检查mtvec的 MODE 字段和 BASE 字段对齐要求 |
5.2 链接脚本符号地址的验证方法
链接脚本里的符号地址对不对,不用等到运行时才发现。编译完成后用riscv64-unknown-elf-nm或者objdump看符号表:
riscv64-unknown-elf-nm build/project.elf | grep -E "_bss_start|_bss_end|_data_start|_stack_top"输出会显示每个符号的地址。检查_bss_start是否小于_bss_end,_stack_top是否在 RAM 范围内且 16 字节对齐。如果_bss_start和_bss_end相等,说明 BSS 段是空的,这本身没问题,但如果你明明定义了未初始化全局变量,那就要检查链接脚本里.bss段的收集规则是不是写错了。
5.3 多核启动的调试技巧
多核调试最头疼的是“不知道哪个核心在跑”。一个实用的技巧是给每个核心分配不同的 GPIO 引脚,在启动代码里各自翻转:
csrr t0, mhartid # 根据 hartid 选择不同的 GPIO slli t0, t0, 2 li t1, GPIO_BASE add t1, t1, t0 li t2, 0x1 sw t2, 0(t1)这样用示波器或者逻辑分析仪一看,就知道哪些核心起来了、起来的顺序是什么。如果某个核心的 GPIO 一直没翻转,说明它卡在了前面的某一步。
另一个技巧是在从核的等待循环里加一个计数器,主核可以通过共享内存读取这个计数器,判断从核是否真的在等待。如果计数器不增长,说明从核可能已经跑飞了。
5.4 启动代码的优化经验
启动代码虽然短,但优化空间不小。几个我实际用过的技巧:
- BSS 清零用
sd而不是sb:在 64 位平台上,按 8 字节清零比按字节清零快 8 倍。前提是地址对齐。 - DATA 拷贝用批量加载:如果支持向量扩展,可以用向量指令一次拷贝多个字节。但要注意向量扩展在启动早期的可用性,有些芯片需要先配置才能用。
- 栈的大小按需分配:不要无脑给 64KB,根据最坏情况下的中断嵌套深度和函数调用链计算。省下来的 RAM 可以给堆或者数据段用。
- 从核等待用 WFI 而不是忙等:忙等会让从核持续取指,占用指令缓存和总线带宽。WFI 让核心进入低功耗状态,等中断唤醒。
踩过的坑:有一次在从核等待循环里用了
j secondary_wait而不是wfi; j secondary_wait,结果从核疯狂取指,把指令缓存全占了,主核的初始化代码频繁缓存缺失,启动时间多了将近一倍。改成 WFI 后恢复正常。
6. 启动流程的扩展与进阶方向
6.1 从 bare-metal 到 RTOS 的过渡
bare-metal 启动流程跑通之后,下一步通常是引入 RTOS。RTOS 的启动和 bare-metal 有个关键区别:RTOS 需要为每个任务维护独立的栈和上下文,所以启动代码里除了设置主栈,还要初始化任务栈和调度器。
以 FreeRTOS 为例,main函数里会调用xTaskCreate创建任务,然后调用vTaskStartScheduler启动调度器。调度器启动时会配置第一个定时器中断(通常是机器模式定时器mtime),然后触发第一次上下文切换。启动代码需要确保mtvec已经指向正确的异常处理入口,并且mie.MTIE(机器定时器中断使能)已经打开。
6.2 安全启动的考量
在一些对安全性有要求的场景,启动流程还需要考虑固件签名验证、安全启动链等。RISC-V 的 M 模式提供了物理内存保护(PMP)机制,可以在启动早期配置 PMP 区域,限制后续代码的访问权限。比如把 BootROM 区域设为只读可执行,把数据区域设为不可执行,防止代码注入攻击。
PMP 的配置需要在 M 模式下完成,而且一旦锁定(pmpcfg的L位置 1),直到下次复位前都不能修改。所以配置顺序很重要:先配置所有需要的区域,最后再锁定。
6.3 启动时间的测量与优化
对启动时间敏感的场景(比如汽车电子要求 100ms 内启动完成),需要精确测量每个阶段的耗时。RISC-V 的mcycleCSR 提供了一个周期计数器,可以在启动代码的关键节点读取:
csrr t0, mcycle # ... 某个初始化阶段 ... csrr t1, mcycle sub t2, t1, t0 # t2 就是这段代码消耗的周期数把各阶段的周期数打印出来,就能定位启动时间的瓶颈。常见的瓶颈包括:BSS 段过大导致清零时间长、DATA 段拷贝量大、外设初始化等待超时等。针对性地优化这些环节,通常能把启动时间压缩 30% 以上。
6.4 不同 RISC-V 核心的启动差异
虽然 RISC-V 的启动流程在概念上是一致的,但不同厂商的核心在细节上有差异。比如:
- SiFive 系列:复位向量通常在 0x1004,BootROM 会做一些基础初始化后再跳转到用户代码。
- 平头哥系列:复位向量和启动流程有自己的规范,需要参考具体芯片的手册。
- 开源核心(如 Rocket、CVA6):复位向量可以通过参数配置,灵活性更高,但也意味着没有统一的默认值。
迁移代码时,最需要关注的是复位向量地址、CSR 的可用性(有些核心可能没实现某些 CSR)、以及多核的启动方式(是同时启动还是主核先启动)。这些差异在数据手册里通常都有说明,但容易被忽略。
我个人在实际操作中的体会是,启动代码这东西,写一次能管很久,但每次换芯片都得重新核对一遍地址和 CSR。最省事的做法是把启动代码做成可配置的模板,把复位向量地址、栈大小、核心数量这些参数抽出来,换芯片时只改配置不改逻辑。这样既减少了重复劳动,也降低了出错概率。