1. 从零理解 RISC-V 的启动流程:为什么 bare-metal 是绕不开的第一课
搞嵌入式的人迟早会碰到一个坎:芯片上电那一瞬间,PC 指针到底指向哪里,栈指针从哪里来,C 语言的main函数凭什么能被调用。在 ARM 平台上,这些事通常被启动文件、链接脚本和厂商 SDK 层层包裹,你写个main就能跑。但换到 RISC-V 上,尤其是自己搭工具链、跑 FPGA 或者写模拟器的时候,这套“黑盒”就彻底打开了——你得自己写启动代码,自己安排内存布局,自己把.bss段清零。这就是bare-metal 流程的核心含义:没有操作系统、没有运行时库兜底,一切从复位向量开始,由你亲手接管。
这篇内容适合三类人看。第一类是刚接触 RISC-V、想搞清楚“上电之后到底发生了什么”的嵌入式新手;第二类是从 ARM 或其他架构转过来、被 RISC-V 的启动机制搞得有点懵的工程师;第三类是想自己写一个最小可运行系统、用于教学或验证的爱好者。我会把启动流程拆成几个关键环节:复位向量与入口地址、链接脚本的内存布局、启动汇编代码的逐行解析、C 运行环境初始化,以及 bare-metal 下常见的调试手段。每个环节都会说清楚“为什么这么做”,而不只是“怎么做”。
需要提前说明的是,RISC-V 的启动流程并没有一个像 ARM 那样统一的“标准模板”。它高度依赖具体的芯片实现、SoC 厂商的地址映射,以及你使用的工具链。但万变不离其宗,核心逻辑是一致的:复位后从某个固定地址取第一条指令,建立栈空间,初始化数据段,跳转到 C 入口。把这套逻辑吃透,换任何 RISC-V 芯片都能快速上手。
2. 复位向量与入口地址:第一条指令从哪里来
2.1 复位向量的硬件约定
RISC-V 规范本身并没有强制规定复位向量的具体地址,它把这个权力交给了具体的硬件实现。不过在实际工程中,绝大多数 RISC-V 芯片或软核都会把复位向量放在一个固定的、由硬件决定的地址上。常见的有几种情况:一种是固定在地址空间的最高端或最低端,比如0x80000000或者0x00000000;另一种是通过硬件引脚或配置寄存器来设定基址,然后偏移量固定。
以常见的开源 RISC-V 核为例,很多实现会把复位向量设在0x80000000附近,这个地址通常对应着片上 ROM 或者外部 Flash 的映射区域。上电后,CPU 从这里的第一个字取指令,开始执行。这个地址就是整个系统的“起点”,你的启动代码必须放在这里,或者在这里放一条跳转指令,跳到真正的启动代码所在位置。
注意:如果你用的是 FPGA 上的软核,复位向量地址往往可以在生成核的时候配置。一定要先确认这个地址,否则你的启动代码放错地方,CPU 取到的就是无效指令,直接跑飞。
2.2 链接脚本如何配合入口地址
链接脚本是 bare-metal 开发里最容易被忽视、但又最关键的环节。它决定了你的代码段、数据段、栈、堆分别被放到哪个地址区间。对于启动流程来说,链接脚本至少要保证两件事:第一,复位向量对应的地址上确实有你的启动代码;第二,栈指针的初始值指向一块可用的 RAM 区域。
一个典型的链接脚本会这样安排:把.text段的起始地址设为复位向量地址,确保启动代码被链接到正确位置。然后在 RAM 区域定义一个栈顶符号,比如_stack_top,启动汇编代码会把这个符号的地址加载到sp寄存器里。数据段.data的加载地址(LMA)在 Flash 或 ROM 里,运行地址(VMA)在 RAM 里,启动代码需要把数据从 LMA 拷贝到 VMA。.bss段则只需要在 RAM 里预留空间,启动时清零即可。
这里有个容易踩的坑:很多人写链接脚本时只关注了.text和.data,忘了给栈留足够的空间,或者栈顶地址设到了未映射的区域。结果就是程序一调用函数、一压栈就异常。我的经验是,在链接脚本里显式定义一个_stack_size,然后用_stack_top = _stack_start + _stack_size的方式来计算,这样栈的大小一目了然,改起来也方便。
2.3 启动代码的第一条指令
启动代码通常是一段汇编,放在一个独立的.S文件里。它的第一条指令往往是一条跳转指令,跳到真正的初始化代码。为什么要多这一跳?因为复位向量地址上可能只有很小的空间,放不下完整的初始化代码,所以先跳到一个更宽敞的地方。
这条跳转指令一般用j或者jal实现。如果用的是jal,还可以顺便把返回地址存下来,但 bare-metal 启动阶段通常不需要返回,所以直接用j就够了。跳转的目标是一个标签,比如_start,这个标签后面才是真正的初始化逻辑。
实操心得:有些工具链会在链接时自动插入一些启动代码,比如
crt0.o。如果你自己写了启动汇编,记得在链接选项里加上-nostartfiles,否则会出现符号重复定义的错误。这个坑我踩过好几次,报错信息往往很隐晦,让人摸不着头脑。
3. 启动汇编逐行解析:从复位到 C 入口
3.1 关闭中断与初始化全局指针
复位后第一件正经事,通常是关闭全局中断。虽然 RISC-V 的mstatus寄存器里MIE位默认是 0,但为了保险起见,显式地清一下mstatus.MIE是个好习惯。可以用csrci mstatus, 8这样的指令,把第 3 位(MIE)清零。这样在初始化过程中,任何意外触发的中断都不会打乱节奏。
接下来是初始化全局指针gp。RISC-V 的gp寄存器通常用于访问全局变量,尤其是在使用-mcmodel=medlow或类似代码模型时,链接器会假设gp指向一个特定的基址,通过gp加偏移来访问全局数据。如果gp没有正确初始化,访问全局变量就会出错。启动代码里一般会这样写:先加载__global_pointer$符号的地址到gp。这个符号由链接器定义,指向.sdata段的中间位置,方便正负偏移都能覆盖到。
注意:如果你编译时用了
-mno-relax或者代码模型不同,gp的初始化方式可能不一样。最稳妥的办法是看链接器生成的 map 文件,确认__global_pointer$符号的地址,然后在启动代码里正确加载。
3.2 设置栈指针
栈指针sp的设置是启动流程里最直观的一步:把一块 RAM 区域的最高地址加载到sp里。因为 RISC-V 的栈是向下生长的,所以sp初始值应该是栈区域的顶部地址。链接脚本里通常会定义一个_stack_top符号,启动代码用la sp, _stack_top就能完成设置。
这里有个细节:栈的对齐。RISC-V 的 ABI 要求栈指针 16 字节对齐。如果_stack_top没有对齐,调用函数时可能会触发异常。所以链接脚本里定义栈顶时,最好用. = ALIGN(16);确保对齐。另外,栈的大小要估算一下:bare-metal 程序虽然简单,但中断处理、递归调用、局部大数组都会消耗栈空间。我一般至少留 4KB,复杂一点的场景留 8KB 到 16KB。
3.3 拷贝数据段与清零 BSS 段
C 语言程序假设全局变量和静态变量都有确定的初值。对于有初值的变量,它们的初始值存储在 Flash 或 ROM 里(LMA),但运行时必须位于 RAM 里(VMA)。启动代码需要把这段数据从 LMA 拷贝到 VMA。链接脚本里通常会定义_sidata(LMA 起始)、_sdata(VMA 起始)、_edata(VMA 结束)这几个符号,启动汇编用一个循环完成拷贝。
对于没有初值的变量,它们位于.bss段,C 标准要求这些变量在程序开始前被清零。启动代码需要把_sbss到_ebss之间的内存全部写 0。这个操作看起来简单,但要注意:如果.bss段很大,清零会消耗一些时间。在低功耗场景下,可以考虑用硬件加速或者按字清零来提速。
实操心得:拷贝
.data段和清零.bss段的循环,一定要用正确的符号地址。我曾经因为链接脚本里符号名写错了一个字母,导致.data段没拷贝,全局变量的初值全是随机数,调试了半天才发现。建议在启动代码里加一些断言或者调试输出,确认这些符号的地址范围是合理的。
3.4 跳转到 C 入口
完成上述初始化后,就可以跳转到 C 语言的入口函数了。这个函数通常叫main,但在 bare-metal 环境下,有时候会先跳到一个_init或者setup函数,做一些平台相关的初始化,再调用main。跳转用jal指令或者call伪指令都行。
跳转之前,还要确保a0和a1寄存器的值符合约定。在 RISC-V 的启动流程里,a0通常用来传递 hart ID(硬件线程 ID),a1用来传递设备树(DTB)的地址。对于单核 bare-metal 程序,这两个值可能用不上,但按照惯例设置一下没坏处。如果main函数不需要参数,直接跳过去也没问题。
4. C 运行环境初始化:bare-metal 下的最小运行时
4.1 为什么需要运行时初始化
很多人以为跳转到main就万事大吉了,其实不然。C 语言的运行环境需要一些前提条件:栈已经设置好、全局变量已经初始化、.bss段已经清零。这些在启动汇编里已经做了。但还有一些事情,比如浮点单元初始化、中断向量表设置、时钟初始化,这些通常放在 C 代码里做。
另外,如果你用了标准库函数,比如printf、malloc,它们可能依赖一些底层实现,比如_write、_sbrk。在 bare-metal 环境下,这些函数需要你自己提供。printf最终会调用_write来输出字符,你可以把它重定向到串口。malloc需要_sbrk来管理堆空间,你需要定义堆的起始和结束地址。
4.2 中断向量表与异常处理
RISC-V 的中断和异常处理机制和 ARM 不太一样。它有一个mtvec寄存器,用来存放中断和异常处理程序的入口地址。这个入口可以有两种模式:直接模式和向量模式。直接模式下,所有异常和中断都跳到同一个地址;向量模式下,不同中断会跳到不同的偏移地址。
在 bare-metal 启动阶段,通常先把mtvec设为一个默认的处理函数,比如default_trap_handler,这个函数可以简单地打印异常信息然后死循环。等系统初始化完成后,再根据需求设置更精细的中断处理。设置mtvec的时候要注意对齐:直接模式下,基址必须 4 字节对齐;向量模式下,基址必须 64 字节对齐。
注意:如果你在启动代码里就打开了中断,一定要确保
mtvec已经设置好,否则中断一来,CPU 跳到未初始化的地址,直接跑飞。我的习惯是,在main函数里完成所有外设初始化之后,再最后打开全局中断。
4.3 堆空间的安排
malloc和free在 bare-metal 环境下不是必须的,但有时候为了方便,还是会用。堆空间通常放在.bss段之后,栈之前。链接脚本里可以定义一个_heap_start和_heap_end,_sbrk函数根据这两个符号来分配内存。
堆的大小需要根据实际需求来定。如果只是偶尔分配一些小对象,几 KB 就够了。如果要处理大量动态数据,就得留大一些。但 bare-metal 环境下,动态内存分配容易产生碎片,而且没有操作系统来回收,所以能不用就尽量不用。我个人的做法是,在启动阶段就把所有需要的内存静态分配好,避免运行时动态申请。
5. 常见问题与排查技巧实录
5.1 程序跑飞的几种典型原因
bare-metal 开发最让人头疼的就是程序跑飞,而且往往没有任何输出,只能靠调试器。根据我的经验,跑飞的原因主要有这么几类:复位向量地址不对、栈指针没设置好、链接脚本内存布局错误、中断向量表没初始化。
排查的时候,第一步是确认复位向量地址。用调试器连上芯片,看 PC 指针复位后指向哪里,那个地址上是不是你的启动代码。如果不是,检查链接脚本和芯片手册,确认复位向量地址是否匹配。第二步是看栈指针,复位后sp的值是不是在有效的 RAM 范围内。如果sp是 0 或者一个奇怪的值,说明栈初始化没生效。
5.2 数据段拷贝失败的排查
数据段拷贝失败的表现是全局变量初值不对。排查方法很简单:在main函数开头打印几个全局变量的值,看看是不是和预期一致。如果不一致,检查链接脚本里_sidata、_sdata、_edata这几个符号的定义,确认它们覆盖了所有需要拷贝的数据。
还有一个隐蔽的问题:如果.data段在 Flash 里的 LMA 和 VMA 地址计算有误,拷贝的源地址或目标地址就会错。可以用objdump或者readelf查看段信息,确认 LMA 和 VMA 是否符合预期。我一般会在链接脚本里加一些断言,比如ASSERT(_edata >= _sdata, "data section error"),这样链接时就能发现明显的问题。
5.3 中断处理中的常见陷阱
中断处理在 bare-metal 环境下有几个容易踩的坑。第一个是中断嵌套:RISC-V 默认不支持中断嵌套,如果在中断处理程序里打开了全局中断,可能会导致栈溢出或者状态混乱。第二个是中断优先级:RISC-V 的PLIC(平台级中断控制器)负责管理外部中断的优先级,配置不当会导致低优先级中断永远得不到响应。
第三个坑是中断处理程序的返回。RISC-V 用mret指令从中断返回,这个指令会恢复mstatus和mepc等寄存器。如果在中断处理程序里修改了这些寄存器,返回后可能会出问题。我的建议是,中断处理程序尽量短小,只做最必要的操作,把复杂逻辑放到主循环里处理。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 程序无输出,PC 跑飞 | 复位向量地址错误 | 用调试器查看复位后 PC 值,核对芯片手册 |
| 全局变量初值不对 | .data段拷贝失败 | 检查链接脚本符号定义,用objdump查看段信息 |
| 函数调用后异常 | 栈指针未设置或未对齐 | 查看复位后sp值,确认 16 字节对齐 |
| 中断触发后死机 | mtvec未初始化 | 检查mtvec寄存器值,确认中断向量表地址 |
malloc返回 NULL | 堆空间不足或_sbrk实现错误 | 检查链接脚本堆区域定义,确认_sbrk逻辑 |
实操心得:调试 bare-metal 程序,最有效的工具是调试器和串口输出。调试器可以单步跟踪启动代码,看每一步寄存器的变化。串口输出则可以在
main函数之后提供运行时信息。我习惯在启动代码的关键节点加一些 GPIO 翻转操作,用示波器或者逻辑分析仪看波形,这样即使串口没初始化好,也能知道程序执行到了哪一步。
6. 工具链与调试环境的选择建议
6.1 工具链的取舍
RISC-V 的工具链选择比较多,常见的有官方 GNU 工具链、LLVM/Clang 工具链,以及一些厂商定制的版本。对于 bare-metal 开发,我一般推荐用官方 GNU 工具链,因为它的文档全、社区支持好,遇到问题容易找到答案。LLVM 工具链在编译速度和代码质量上有优势,但在一些嵌入式场景下,链接脚本和启动代码的兼容性可能不如 GNU 工具链。
编译选项方面,bare-metal 程序通常用-nostdlib、-nostartfiles、-ffreestanding这几个选项。-nostdlib告诉链接器不要链接标准库,-nostartfiles告诉它不要用默认的启动文件,-ffreestanding告诉编译器这是一个独立环境,不要假设有操作系统。这几个选项配合使用,可以避免很多链接时的奇怪错误。
6.2 调试器的配置
调试 RISC-V bare-metal 程序,通常用 OpenOCD 加上 GDB。OpenOCD 负责和硬件调试器通信,GDB 负责提供调试界面。配置 OpenOCD 的时候,需要指定调试器的类型、目标芯片的配置文件,以及复位后的行为。有些调试器支持在复位后暂停 CPU,这样你可以从第一条指令开始单步跟踪。
GDB 这边,连接目标后用load命令把程序烧录到 Flash 或 RAM 里,然后用monitor reset halt让 CPU 复位并暂停。接下来就可以用stepi单步执行汇编指令,用info registers查看寄存器值。启动阶段的调试,重点看pc、sp、gp这几个寄存器的变化,以及.data段拷贝循环是否正常执行。
注意:有些芯片在复位后会自动从 Flash 启动,如果你用调试器直接烧录到 RAM 并跳转,可能会和芯片的启动流程冲突。这种情况下,需要先擦除 Flash 或者修改启动模式引脚,确保 CPU 从你期望的地址开始执行。
6.3 模拟器作为辅助手段
如果手头没有硬件,或者想快速验证启动代码的逻辑,可以用模拟器,比如 QEMU 或者 Spike。QEMU 支持多种 RISC-V 机器类型,可以模拟从复位到main的完整流程。Spike 是 RISC-V 官方维护的模拟器,更贴近硬件行为,适合验证底层细节。
用模拟器调试的好处是,你可以随时查看内存和寄存器状态,不受硬件限制。而且模拟器可以生成执行轨迹,帮你分析启动代码的每一步。不过模拟器毕竟不是真实硬件,有些硬件相关的行为(比如时钟、外设)可能模拟得不够准确。所以我的建议是,先在模拟器上把启动流程跑通,再上真实硬件验证。
7. 从 bare-metal 到更复杂系统的扩展思路
bare-metal 流程跑通之后,下一步通常有两种方向:一种是继续深挖,加入更多外设驱动、中断处理、低功耗管理,把 bare-metal 程序做成一个完整的嵌入式应用;另一种是引入操作系统,比如 FreeRTOS 或者 Zephyr,让系统具备任务调度、内存管理、设备驱动框架等能力。
如果选择引入操作系统,启动流程会有所变化。操作系统的启动代码通常会在完成基本的硬件初始化后,建立自己的运行环境,然后创建第一个任务。你之前写的 bare-metal 启动代码,很多部分可以直接复用,比如栈设置、数据段拷贝、.bss清零。但中断处理、时钟配置、内存管理这些,就需要按照操作系统的要求来调整。
不管走哪条路,把 bare-metal 启动流程搞清楚都是值得的。它让你对系统的控制力更强,遇到问题时排查手段更多,也更容易理解上层软件的行为。我在实际项目中,即使最终用的是操作系统,也会保留一份 bare-metal 的启动代码作为参考,调试底层问题时特别有用。
最后分享一个小技巧:把启动代码里的关键符号和地址整理成一张表,放在项目文档里。每次换芯片或者换工具链,先对照这张表检查一遍,能省下不少调试时间。这个习惯我坚持了好几年,确实管用。