☰
RISC-V裸机启动全流程:从复位向量到多核main的工程实践
2026/10/7 11:45:19 网站建设 项目流程

1. 为什么裸机启动值得单独拎出来讲

很多人学 RISC-V 的时候,注意力都放在指令集、流水线、特权级这些"看起来更硬核"的地方,结果一到真正上手,第一道坎反而是:板子上电之后,代码到底是怎么跑起来的?我见过太多人卡在这一步——编译出来的二进制烧进去没反应,串口一片安静,连个字符都打不出来,然后就开始怀疑工具链、怀疑芯片、怀疑人生。

裸机(bare-metal)启动流程这件事,说白了就是回答一个问题:从上电复位那一刻,到你的main函数被调用之前,中间到底发生了什么。这个问题在 Linux 上你基本不用操心,因为有 bootloader 和内核帮你兜底;但在裸机环境下,没有操作系统、没有运行时、没有现成的栈指针、没有清零的 BSS 段,所有东西都得你自己安排。RISC-V 在这方面尤其典型,因为它是一个开放指令集,不同厂商的 SoC 复位行为、内存布局、多核启动约定都不一样,没有一个"放之四海而皆准"的启动模板。

这篇内容适合三类人:一是刚接触 RISC-V 裸机开发、想搞清楚启动链路的新手;二是从 ARM 裸机转过来、发现 RISC-V 的启动约定不太一样的老手;三是需要自己写链接脚本、自己搭多核启动框架的嵌入式工程师。我会把启动流程拆成几个关键环节——复位入口、链接脚本、段初始化、栈设置、多核启动——每一块都讲清楚"为什么这么做"以及"不这么做会出什么问题"。这些内容不是照本宣科,而是我在实际调试中反复踩坑之后总结出来的,很多细节在官方文档里根本不会写。

先给一个整体认知:RISC-V 裸机启动的核心链路大致是复位向量 → 启动代码(汇编)→ 段初始化 → 栈与运行时准备 → 跳转到 C 的 main。多核场景下还要额外处理 hartid 分发和从核唤醒。下面逐层展开。

2. 复位入口与启动代码的第一条指令

2.1 复位向量到底指向哪里

RISC-V 规范里,复位后的第一条指令地址由具体实现决定。特权级规范定义了一个reset vector的概念,但具体地址是平台相关的。常见的做法有两种:一种是固定地址,比如很多 SoC 把复位向量放在0x80000000或者0x0;另一种是通过硬件引脚或者配置寄存器决定启动模式,比如从 Flash、从 SRAM、从 SD 卡启动,不同模式映射到不同的地址。

这里有个非常容易踩的坑:你链接脚本里指定的入口地址,必须和芯片实际复位后取指的地址一致。我见过有人链接脚本写0x80000000,结果芯片复位后从0x0取指,那自然跑飞。排查这种问题最直接的办法是看芯片手册的"Boot Mode"章节,或者用调试器连上去看复位后的 PC 值。

在汇编启动代码里,第一条指令通常不是直接跳main,而是先做一些"不能等"的事情。为什么不能等?因为此时栈还没建立,C 代码根本没法跑——任何函数调用都需要栈,任何局部变量都需要栈。所以启动汇编的第一要务是先把栈指针(sp)设好。

# 典型的 RISC-V 启动汇编片段 .section .text.start .globl _start _start: # 1. 设置栈指针,指向栈顶(栈向下增长) la sp, _stack_top # 2. 清零 BSS 段 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: # 3. 跳转到 C 入口 call main # 4. main 返回后死循环 hang: j hang

这段代码看着简单,但每一行都有讲究。la sp, _stack_top里的_stack_top是链接脚本里定义的符号,它必须指向一块真实存在的、可读写的 RAM。如果你把栈顶设到了 ROM 区域,第一次压栈就会触发异常或者静默失败。

2.2 为什么 BSS 清零必须用汇编做

BSS 段存放的是未初始化或初始化为零的全局变量和静态变量。C 语言标准要求这些变量在程序开始前必须是零值。但问题是,BSS 段在可执行文件里并不占实际空间(只记录大小),加载到内存后那块区域的内容是"不确定"的——可能是上次运行残留的数据,也可能是随机值。

所以启动代码必须手动把 BSS 段清零。为什么用汇编而不是 C?因为此时 C 运行时环境还没建立,你没法保证编译器生成的 C 代码不会用到栈或者其它未初始化的状态。用汇编做这件事最干净、最可控。

这里有个细节:清零的粒度。上面代码用的是sd(存储双字,8 字节),前提是 BSS 段的起止地址都是 8 字节对齐的。如果不对齐,用sd会写越界。稳妥的做法是在链接脚本里用ALIGN(8)保证对齐,或者在汇编里先处理不对齐的头部。我一般倾向于在链接脚本里对齐,省得汇编里写一堆边界判断。

2.3 栈的设置不是随便指一个地址就行

栈指针设在哪里,直接决定了你后面能跑多大的程序。栈向下增长,所以_stack_top应该指向栈区域的高地址端。栈的大小要预估:如果用了递归、大局部数组、或者中断嵌套,栈消耗会比你想象的大得多。

我的经验是,裸机项目里栈至少留 4KB,稍微复杂一点的(带中断、带 printf)留 8KB 到 16KB。如果 RAM 紧张,可以在链接脚本里把栈放在 RAM 末尾,然后通过_stack_top = ORIGIN(RAM) + LENGTH(RAM)这种方式自动计算。但要注意,如果多核共享同一块 RAM,每个核的栈必须分开,否则两个核同时压栈会互相踩踏。

提示:调试栈溢出最笨但最有效的办法,是在栈区域填充一个魔数(比如0xDEADBEEF),跑一段时间后检查栈底附近的魔数有没有被覆盖。被覆盖了就说明栈溢出了。

3. 链接脚本:启动流程的隐形骨架

3.1 链接脚本决定了内存布局的一切

很多人写裸机代码时对链接脚本(linker script)不够重视,觉得那是"链接器的事",结果启动失败一大半原因都出在这里。链接脚本本质上是在描述:代码放哪、数据放哪、栈放哪、各个段的起止符号是什么。启动汇编里用到的_stack_top、_bss_start、_bss_end这些符号,全都是在链接脚本里定义的。

一个典型的 RISC-V 裸机链接脚本长这样:

OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M } SECTIONS { .text : { *(.text.start) /* 启动代码放最前面 */ *(.text .text.*) } > RAM .rodata : { *(.rodata .rodata.*) } > RAM .data : { *(.data .data.*) } > RAM .bss : { _bss_start = .; *(.bss .bss.*) *(COMMON) _bss_end = .; } > RAM .stack : { . = ALIGN(16); _stack_bottom = .; . += 16K; _stack_top = .; } > RAM }

3.2 为什么启动代码要放在最前面

*(.text.start)这一行的作用是保证启动汇编被放在.text段的最前面。为什么这么重要?因为复位向量通常指向内存的起始地址,如果你的启动代码不在最前面,复位后取到的第一条指令就是别的函数的中间,直接跑飞。

这里有个常见的误解:有人觉得ENTRY(_start)就保证了_start在最前面。其实不是,ENTRY只是告诉链接器程序的入口符号是_start,用于生成 ELF 的入口地址字段,但它不保证_start的代码被放在最前面。真正保证顺序的是*(.text.start)这种显式的段排序。

3.3 段初始化:data 段从哪来

BSS 段清零好理解,但.data段(已初始化的全局变量)有个更微妙的问题:它的初始值存在可执行文件里,但运行时必须在 RAM 里。如果程序是从 Flash 启动的,.data段的初始值一开始在 Flash 里,启动代码必须把它从 Flash 拷贝到 RAM。

# data 段拷贝(LMA -> VMA) la t0, _data_lma # 加载地址(Flash 中) la t1, _data_start # 运行地址(RAM 中) la t2, _data_end copy_loop: bgeu t1, t2, copy_done ld t3, (t0) sd t3, (t1) addi t0, t0, 8 addi t1, t1, 8 j copy_loop copy_done:

链接脚本里要相应定义 LMA 和 VMA:

.data : AT(_data_lma) { _data_start = .; *(.data .data.*) _data_end = .; } > RAM _data_lma = LOADADDR(.data);

如果你的程序是直接从 RAM 运行的(比如通过调试器加载),那 data 段已经在正确位置了,不需要拷贝。但为了代码通用性,我建议还是保留拷贝逻辑,用条件编译或者运行时判断来跳过。

注意:拷贝 data 段时,源地址和目标地址的对齐要一致。如果源是 4 字节对齐而目标要求 8 字节对齐,用ld/sd会出问题。稳妥做法是统一按 8 字节对齐,或者在链接脚本里用ALIGN强制对齐。

4. 多核启动:hartid 分发与从核唤醒

4.1 多核启动的核心问题

RISC-V 多核启动和单核最大的区别在于:上电后所有核可能同时从复位向量开始执行。如果你不做区分,所有核都会去初始化 BSS、设置栈、跑 main,结果就是互相踩踏,程序行为完全不可预测。

解决思路很直接:在启动汇编的最开始读取当前核的hartid(硬件线程 ID),然后根据 hartid 决定这个核该干什么。通常约定 hartid 为 0 的核作为主核(boot hart),负责完整的初始化和跑主逻辑;其它核作为从核,要么进入等待状态,要么被主核唤醒后执行特定任务。

_start: # 读取 hartid csrr a0, mhartid # 只有 hartid == 0 的核继续初始化 bnez a0, secondary_hang # 主核:设置栈、清 BSS、拷贝 data、跳 main la sp, _stack_top # ... 初始化代码 ... call main secondary_hang: # 从核:先自旋等待 wfi j secondary_hang

4.2 从核唤醒的几种方式

从核不能一直傻等,主核初始化完成后需要把它们叫起来干活。常见的唤醒方式有几种:

第一种是自旋锁 + 共享标志位。主核初始化完成后,往一个共享内存地址写一个标志,从核在循环里不断读这个标志,读到就跳出循环。这种方式简单,但从核在等待期间会一直占着总线,功耗也高。

第二种是WFI + 中断。从核执行wfi(Wait For Interrupt)进入低功耗状态,主核通过软件中断(比如 CLINT 的 MSIP 寄存器)或者 IPI(核间中断)把从核唤醒。这种方式更优雅,功耗低,但需要配置中断控制器。

第三种是硬件特定的唤醒机制。有些 SoC 提供了专门的核间通信寄存器或者 mailbox,主核写寄存器,从核轮询或者接收中断。这种方式依赖具体硬件,可移植性差。

我在实际项目里最常用的是第二种,因为 RISC-V 的 CLINT(Core Local Interruptor)基本是标配,写 MSIP 寄存器就能触发软件中断,实现起来不复杂。下面是一个简化的从核唤醒示例:

// 主核:唤醒从核 #define CLINT_MSIP(hart) (*(volatile uint32_t *)(CLINT_BASE + 4 * (hart))) void wake_secondary_harts(int num_harts) { for (int i = 1; i < num_harts; i++) { CLINT_MSIP(i) = 1; // 触发从核软件中断 } } // 从核:等待唤醒 void secondary_main(void) { // 配置中断使能 // ... while (!start_flag) { asm volatile("wfi"); } // 执行从核任务 }

4.3 多核场景下的栈和共享资源

多核启动最容易出问题的地方是栈的分配。每个核必须有自己独立的栈区域,不能共用。如果两个核同时压栈到同一块内存,数据会互相覆盖,表现为随机的、难以复现的崩溃。

我的做法是在链接脚本里为每个核预留独立的栈空间:

.stack : { . = ALIGN(16); _stack0_top = . + 16K; . += 16K; _stack1_top = . + 16K; . += 16K; _stack2_top = . + 16K; . += 16K; _stack3_top = . + 16K; . += 16K; } > RAM

然后在启动汇编里根据 hartid 选择对应的栈顶:

csrr a0, mhartid la t0, _stack0_top li t1, 16K mul t2, a0, t1 sub sp, t0, t2

除了栈,共享的全局变量、外设寄存器、DMA 缓冲区都需要考虑多核并发访问的问题。裸机环境下没有操作系统帮你做同步,你得自己用原子指令(比如amoadd.w、lr/sc)或者自旋锁来保护临界区。

提示:多核调试时,如果现象是"单核跑没问题,多核跑就挂",优先怀疑栈冲突和共享变量竞争。可以先把从核全部停掉,只跑主核,确认单核逻辑正确后再逐步放开从核。

5. 从启动代码到 main:那些容易忽略的细节

5.1 浮点单元和向量单元的初始化

如果你的代码用到了浮点运算,启动时需要确保浮点单元(FPU)已经使能。RISC-V 的 FPU 状态由mstatus寄存器的 FS 字段控制。复位后 FS 通常是 0(Off),此时执行浮点指令会触发非法指令异常。

# 使能 FPU li t0, (1 << 13) # FS = Initial csrs mstatus, t0 # 或者 FS = Clean/Dirty li t0, (3 << 13) csrs mstatus, t0

向量单元(如果支持 RVV)也类似,需要设置mstatus.VS字段。这些细节在纯整数程序里无所谓,但一旦用到浮点或向量指令,忘了初始化就是直接异常。

5.2 中断和异常的早期配置

裸机程序通常需要在启动早期就把中断向量表设好。RISC-V 的中断向量基址由mtvec寄存器控制。如果你打算用中断,必须在开中断之前设置mtvec,否则中断来了找不到处理函数,直接跑飞。

la t0, trap_entry csrw mtvec, t0

trap_entry是一个汇编入口,负责保存上下文、调用 C 的中断处理函数、恢复上下文。这部分内容展开又是一大篇,这里只强调一点:trap_entry 必须放在能被 mtvec 正确寻址的地方,通常要求 4 字节对齐,如果用了向量模式还要求 128 字节对齐。

5.3 缓存和 MMU 的取舍

裸机环境下,缓存和 MMU 是可选的。不用缓存的话,所有内存访问都直接打到物理内存,速度慢但行为简单可预测。用缓存的话,需要考虑缓存一致性、cache line 对齐、DMA 和缓存的数据同步等问题。

我的建议是:新手先用无缓存模式跑通启动流程,确认代码逻辑正确后再逐步开启缓存。一上来就开缓存,出了问题很难判断是逻辑错误还是缓存一致性问题。MMU 同理,裸机下大多数场景不需要地址翻译,直接物理地址访问最省心。

6. 启动失败的排查链路

6.1 串口没输出,怎么一步步定位

启动失败最典型的表现就是串口没输出。这时候不要瞎猜,按链路一步步排查:

第一步,确认程序真的烧进去了。用调试器连上去,读一下复位向量地址处的内存,看看是不是你编译出来的指令。如果不是,说明烧录环节有问题。

第二步,确认复位后 PC 指向正确。单步执行几条指令,看 PC 是不是落在_start附近。如果 PC 跑到了别的地方,检查链接脚本的入口地址和芯片复位向量是否匹配。

第三步,确认栈指针设置正确。在设置 sp 的那条指令之后,读一下 sp 的值,看它是不是指向一块有效的 RAM 区域。

第四步,确认 BSS 清零和 data 拷贝没有越界。如果_bss_end定义错了,清零循环可能把别的数据覆盖掉,或者陷入死循环。

第五步,确认 main 函数真的被调用了。可以在 main 的第一行加一个 GPIO 翻转或者直接写串口寄存器的操作,绕过所有库函数,看有没有反应。

6.2 常见启动失败原因对照表

现象可能原因排查方法
串口完全无输出复位向量地址不匹配调试器读复位后 PC
输出乱码时钟频率配置错误检查 UART 分频系数
跑到一半挂死栈溢出或栈指向无效内存检查 sp 值和栈大小
全局变量值不对BSS 未清零或 data 未拷贝检查段初始化代码
多核跑飞从核未正确停住或栈冲突单核模式验证
浮点指令异常FPU 未使能检查 mstatus.FS

6.3 一个真实的排查案例

我之前遇到过一个情况:单核跑得好好的程序,加上从核唤醒之后,主核偶尔会挂。排查了很久,最后发现是从核的栈和主核的 BSS 段重叠了。链接脚本里栈区域紧挨着 BSS 段,从核栈向下增长时踩到了主核的全局变量。这种问题的隐蔽性在于,它取决于从核什么时候开始压栈、压多深,所以现象是"偶尔"挂,而不是"必然"挂。

解决办法是在链接脚本里给栈区域和 BSS 段之间留足够的间隙,或者用ASSERT检查段之间有没有重叠:

ASSERT(_bss_end <= _stack_bottom, "BSS overlaps stack!")

链接时如果重叠,直接报错,比运行时随机崩溃好排查得多。

7. 我个人的一些实操体会

启动流程这个东西,看文档觉得都懂,真上手写又是另一回事。我最大的体会是:不要试图一次写对,要分阶段验证。先把最小可运行的程序跑起来——设置栈、跳 main、main 里翻转一个 GPIO——确认这条链路通了,再往上加 BSS 清零、data 拷贝、多核启动。每加一个功能就验证一次,出问题的时候范围小,好定位。

另一个体会是关于链接脚本的。很多人把链接脚本当成"一次写好就不动"的东西,其实它应该跟着你的内存布局需求不断调整。我习惯在链接脚本里加大量ASSERT,把各种约束(段不重叠、地址对齐、栈大小足够)都写成断言,链接时就能发现问题,比运行时调试省事得多。

还有一点,多核启动的复杂度主要不在"启动"本身,而在"启动之后各核怎么协作"。启动阶段把每个核的栈、状态、职责分清楚,后面写并发逻辑会轻松很多。如果启动阶段就糊弄,后面会一直还债。

最后分享一个小技巧:如果你不确定某个符号(比如_stack_top)在链接后的实际地址,可以用riscv64-unknown-elf-nm或者objdump看一下符号表,比在代码里打印地址方便。启动阶段串口可能还没初始化,打印不了,看符号表是最快的确认方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询