☰
RISC-V裸机启动流程详解:从复位向量到C语言入口
2026/10/20 14:01:03 网站建设 项目流程

1. 从零理解 RISC-V 的启动流程到底在做什么

很多人第一次接触 RISC-V 的 bare-metal 开发时,脑子里其实是一团浆糊。平时写单片机也好,写嵌入式 Linux 驱动也好,好歹有个 IDE 帮你把一切安排好了,点一下下载按钮程序就跑起来了。但一旦进入 RISC-V 的裸机世界,你会发现连“程序从哪里开始执行”这个问题都得自己回答。这就是启动流程要解决的核心问题。

所谓 bare-metal,说白了就是“没有操作系统帮你兜底”。你写的代码就是这片芯片上唯一在跑的东西,没有调度器、没有内存管理、没有标准库的完整支持。芯片一上电,PC 指针指向某个固定地址,然后一切都要靠你自己一步步搭起来。这个过程就像你被扔到一片空地上,得先自己打地基、拉水电、盖房子,最后才能住人。

RISC-V 的启动流程之所以值得单独拿出来讲,是因为它和 ARM、x86 的启动方式有本质区别。RISC-V 的设计哲学是极简和模块化,它没有规定一个统一的启动地址,也没有强制要求某种特定的启动模式。这些全部交给具体的芯片厂商和 SoC 设计者去决定。所以你拿到一块 RISC-V 的开发板,第一件事不是急着写代码,而是翻手册确认:复位向量在哪里?有哪些复位原因?时钟默认怎么配置?这些信息决定了你后面所有工作的基础。

这篇文章适合谁看?如果你已经写过一些 C 语言,对计算机体系结构有基本概念,知道什么是寄存器、什么是栈,但还没真正从零启动过一颗 RISC-V 芯片,那这篇内容就是为你准备的。我会从最底层的复位行为讲起,一步步拆解到 C 语言入口,中间涉及的链接脚本、启动汇编、内存布局、栈初始化这些关键环节都会给出可直接参考的代码和配置。整个过程我会尽量用“为什么这么做”来解释每一步,而不是只丢一堆代码让你抄。

2. 启动流程的整体设计与核心思路拆解

2.1 为什么 RISC-V 的启动流程需要自己搭建

RISC-V 规范只定义了指令集和特权架构,它刻意没有规定芯片上电后必须从哪里取第一条指令。这个设计决定带来了极大的灵活性,但也意味着软件开发者必须自己处理启动细节。对比一下 ARM Cortex-M 系列,ARM 规定了中断向量表必须放在地址 0x00000000(或者通过 VTOR 寄存器重定位),复位后硬件自动从向量表取初始 SP 和复位处理函数地址。RISC-V 没有这种硬性规定,SoC 厂商可以自由决定复位向量地址,比如放在 0x80000000、0x20000000 或者别的什么地方。

这种灵活性带来的后果就是:你写的启动代码和具体芯片强相关。换一颗芯片,启动流程可能就得重新调整。但好消息是,一旦你理解了启动流程的本质逻辑,适配新芯片只是改几个地址和配置的问题。

启动流程的本质目标其实就几个:把 CPU 从一个已知但受限的复位状态,引导到一个可以运行 C 代码的环境。这个环境需要满足几个条件:栈指针有效、关键外设时钟使能、内存控制器初始化完毕(如果用的是外部 DDR)、中断系统处于可控状态。这些条件缺一不可,少一个你的 C 代码就可能跑飞。

2.2 启动阶段划分与各阶段职责

从复位到 main 函数,整个启动过程可以划分为几个清晰的阶段。每个阶段有明确的输入和输出,理解这些阶段划分有助于你在出问题时快速定位是哪一步出了岔子。

第一个阶段是复位入口阶段。芯片刚上电,CPU 从复位向量取第一条指令。这个阶段通常用汇编编写,因为此时栈还没建立,C 代码根本没法跑。汇编代码要做的事情很有限但很关键:关中断、设置栈指针、可能还要初始化一些最基本的 CSR 寄存器。

第二个阶段是底层硬件初始化。这个阶段可以开始用 C 语言写了,但要注意不能使用需要栈空间较大的操作。主要工作是配置时钟树、初始化内存控制器、设置 GPIO 复用功能等。很多芯片在这个阶段还需要处理复位原因判断,比如是上电复位还是看门狗复位,不同原因可能需要走不同的初始化路径。

第三个阶段是数据段准备。C 语言程序有已初始化数据段、未初始化数据段和只读数据段。已初始化数据段的初值存在 Flash 里,运行时需要拷贝到 RAM;未初始化数据段需要在 RAM 里清零。这些工作必须在进入 main 之前完成,否则全局变量的值就是随机的。

第四个阶段是跳转到 C 入口。完成上述所有准备后,调用 main 函数或者调用更底层的系统初始化函数。到这里,启动流程就算结束了,后续就是应用程序的逻辑。

2.3 链接脚本在启动流程中的核心地位

很多人低估了链接脚本的重要性。链接脚本决定了你的代码和数据在内存中如何布局,而启动代码又高度依赖这个布局。比如栈顶地址从哪里来?已初始化数据段的加载地址和运行地址分别在哪里?这些信息都是链接脚本定义的符号提供的。

一个典型的 RISC-V 裸机链接脚本需要定义几个关键段:.text 段存放代码,.rodata 段存放只读数据,.data 段存放已初始化全局变量,.bss 段存放未初始化全局变量。此外还需要定义栈空间,通常放在 RAM 的高地址端,因为栈是向下增长的。

链接脚本里还会导出一些符号供启动代码使用,比如 _start 入口地址、_data_load_start、_data_start、_data_end、_bss_start、_bss_end、_stack_top 等。启动汇编通过这些符号来计算拷贝范围和栈顶位置。如果链接脚本写错了,启动代码就会访问错误的内存区域,轻则数据错乱,重则直接硬件异常。

3. 核心细节解析与实操要点

3.1 复位向量与入口汇编的编写要点

复位向量是芯片上电后 PC 指向的第一个地址。在 RISC-V 中,这个地址由 SoC 厂商决定,通常会在芯片手册的“Boot ROM”或者“Reset Vector”章节说明。比如某些芯片复位向量在 0x80000000,有些在 0x20000000,还有的芯片支持通过引脚选择启动介质,复位向量会根据引脚状态映射到不同区域。

入口汇编的第一条指令通常是一条跳转指令,跳到真正的启动代码。为什么要多这一跳?因为复位向量位置可能空间有限,放不下完整的启动代码,所以先放一条跳转指令,跳到更大的代码区域。这条跳转指令通常用j或者jal实现。

接下来要做的第一件事是关中断。RISC-V 的中断使能由 mstatus 寄存器的 MIE 位控制,同时 mie 寄存器控制具体哪些中断源使能。在启动初期,这两者都应该清零,避免在环境还没准备好时被中断打断。关中断的汇编很简单:

csrw mie, zero csrw mip, zero csrci mstatus, 8

第三件事是设置栈指针。栈指针的值通常来自链接脚本定义的 _stack_top 符号。在汇编里可以这样写:

la sp, _stack_top

注意这里用的是la伪指令,它会根据地址范围自动选择auipc+addi或者lui+addi来加载地址。如果栈顶地址超过 32 位范围,还需要用li配合移位操作。

3.2 数据段拷贝与 BSS 段清零的实现细节

已初始化数据段在 Flash 中有一份初始值,但运行时必须位于 RAM 中。启动代码需要把这份数据从 Flash 拷贝到 RAM。拷贝的源地址、目的地址和结束地址分别由链接脚本导出的 _data_load_start、_data_start、_data_end 三个符号确定。

拷贝过程通常用循环实现,每次拷贝一个字(4 字节)或者一个字节。用字拷贝效率更高,但要注意地址对齐。如果 _data_start 和 _data_load_start 不是 4 字节对齐的,就得用字节拷贝或者先处理不对齐的部分。实际项目中,链接脚本通常会把数据段对齐到 4 字节或 8 字节,所以直接用字拷贝是安全的。

BSS 段清零相对简单,只需要把 _bss_start 到 _bss_end 之间的内存全部写零。同样可以用字操作来加速。需要注意的是,BSS 段清零必须在数据段拷贝之后进行,因为两者在内存中可能是相邻的,顺序搞反了会把刚拷贝的数据又清掉。

这里有一个容易踩的坑:如果链接脚本里 .data 段和 .bss 段之间有间隙,清零操作可能会意外覆盖其他数据。所以链接脚本里通常会把 .data 和 .bss 紧挨着放,或者用明确的符号界定范围。

3.3 时钟与内存控制器的初始化顺序

时钟初始化是启动流程中最容易出问题的环节之一。RISC-V 芯片通常有多个时钟源:内部 RC 振荡器、外部晶振、PLL 倍频输出等。复位后默认使用内部 RC,频率通常很低(比如 8MHz 或 16MHz),因为此时外部晶振还没稳定,PLL 也没锁定。

启动代码需要按顺序做这几件事:首先使能外部晶振,等待稳定;然后配置 PLL 倍频参数,等待锁定;最后切换系统时钟源到 PLL 输出。每一步都需要检查状态寄存器确认操作完成,不能盲目往下走。

内存控制器初始化同样关键。如果程序运行在外部 DDR 上,而 DDR 控制器还没初始化,那访问 DDR 地址就会直接挂掉。DDR 初始化涉及一系列时序参数:行地址选通延迟、列地址选通延迟、预充电时间等。这些参数通常由芯片厂商提供的初始化表给出,直接照搬即可,但要注意不同型号的 DDR 颗粒可能需要微调。

注意:时钟切换和 DDR 初始化期间,绝对不能访问尚未就绪的内存区域。启动代码本身应该运行在芯片内部的 SRAM 中,等 DDR 初始化完成后再跳转到 DDR 执行。

4. 实操过程与核心环节实现

4.1 链接脚本的完整编写与符号定义

下面是一个典型的 RISC-V 裸机链接脚本,我加了详细注释说明每个部分的意图:

OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN = 0x80000000, LENGTH = 4M RAM (rwx) : ORIGIN = 0x80200000, LENGTH = 8M } SECTIONS { .text : { *(.text.entry) *(.text .text.*) *(.rodata .rodata.*) } > FLASH .data : { _data_start = .; *(.data .data.*) _data_end = .; } > RAM AT > FLASH _data_load_start = LOADADDR(.data); .bss : { _bss_start = .; *(.bss .bss.*) *(COMMON) _bss_end = .; } > RAM .stack : { . = ALIGN(16); _stack_bottom = .; . = . + 0x4000; _stack_top = .; } > RAM }

这个脚本里几个关键点值得展开说。ENTRY(_start)告诉链接器程序入口是 _start 符号,这个符号在启动汇编里定义。.text.entry段被放在最前面,确保复位向量指向的地址就是启动代码的起始位置。.data段的AT > FLASH表示加载地址在 Flash,运行地址在 RAM,链接器会自动计算 LOADADDR。栈空间分配了 16KB,对于大多数裸机程序够用了,如果中断嵌套深或者局部变量大,可以适当加大。

4.2 启动汇编的逐行实现与注释

启动汇编文件通常命名为 start.S 或者 boot.S,下面是一个完整的实现:

.section .text.entry .globl _start _start: /* 关中断 */ csrw mie, zero csrw mip, zero csrci mstatus, 8 /* 设置栈指针 */ la sp, _stack_top /* 拷贝 .data 段 */ la a0, _data_load_start la a1, _data_start la a2, _data_end 1: bgeu a1, a2, 2f lw t0, 0(a0) sw t0, 0(a1) addi a0, a0, 4 addi a1, a1, 4 j 1b 2: /* 清零 .bss 段 */ la a0, _bss_start la a1, _bss_end 3: bgeu a0, a1, 4f sw zero, 0(a0) addi a0, a0, 4 j 3b 4: /* 跳转到 C 入口 */ call main /* main 返回后死循环 */ 5: j 5b

这段代码里,csrci mstatus, 8是清除 mstatus 的 MIE 位(第 3 位),立即数 8 就是 1<<3。数据段拷贝循环用bgeu做无符号比较,因为地址是无符号数。call main会保存返回地址到 ra 寄存器,如果 main 返回了,后面的死循环可以防止程序跑飞。

4.3 从汇编到 C 的过渡与 main 函数准备

汇编跳转到 main 之前,其实还可以插入一个更底层的 C 函数,通常叫system_init或者board_init。这个函数负责时钟配置、GPIO 初始化、串口初始化等。这样做的好处是这些初始化可以用 C 写,比汇编方便得多,而且此时栈已经建立,C 代码可以正常运行。

void system_init(void) { /* 使能外部晶振 */ *((volatile unsigned int *)0x40000000) |= (1 << 0); while (!(*((volatile unsigned int *)0x40000004) & (1 << 0))); /* 配置 PLL */ *((volatile unsigned int *)0x40000010) = 0x00000001; while (!(*((volatile unsigned int *)0x40000014) & (1 << 0))); /* 切换系统时钟到 PLL */ *((volatile unsigned int *)0x40000008) = 0x00000002; /* 初始化串口 */ *((volatile unsigned int *)0x40001000) = 0x00000001; *((volatile unsigned int *)0x40001004) = 115200; }

这些寄存器地址都是示例,实际使用时必须查芯片手册确认。串口初始化后就可以打印调试信息了,这对后续排查问题非常有帮助。

4.4 启动流程的验证与调试手段

启动代码写完后,怎么确认它真的在工作?最直接的办法是在关键节点翻转一个 GPIO,用示波器或者逻辑分析仪看波形。比如在复位入口翻转一次,在数据段拷贝完成后翻转一次,在进入 main 后翻转一次。如果某个节点没有翻转,就说明卡在前面了。

另一个手段是通过串口打印。在 system_init 里初始化串口后,打印一行字符串,确认串口能正常工作。然后在 main 函数开头再打印一行,确认程序确实进入了 main。如果第一行能打印但第二行不能,说明问题出在 system_init 到 main 之间。

如果连串口都出不来,那就得用调试器了。RISC-V 通常支持 JTAG 调试,可以通过 OpenOCD 连接,设置断点在 _start 地址,单步执行看 PC 是否按预期跳转。调试器还能查看 CSR 寄存器的值,确认 mstatus、mepc 等寄存器状态是否符合预期。

5. 常见问题与排查技巧实录

5.1 启动阶段典型问题速查表

现象可能原因排查方法
程序完全不跑复位向量地址错误查手册确认复位向量,用调试器看 PC
跑飞或硬件异常栈指针未初始化或指向非法区域检查 _stack_top 符号地址是否在 RAM 范围
全局变量值随机.data 段未拷贝或 .bss 未清零在 main 里打印全局变量地址和值
串口无输出时钟配置错误或波特率不对测量时钟输出引脚,核对波特率分频计算
中断触发后死机中断向量表未设置或栈溢出检查 mtvec 寄存器,加大栈空间
DDR 访问挂死DDR 控制器未初始化或时序错误确认初始化代码在 SRAM 中运行,检查时序参数

5.2 栈溢出与内存越界的排查经验

栈溢出是裸机开发中最隐蔽的问题之一。因为栈向下增长,如果栈空间不够,就会覆盖栈底下面的数据。而栈底下面通常是 .bss 段或者堆空间,覆盖后可能不会立即崩溃,而是表现为某个全局变量莫名其妙变了值。

我一般的做法是在栈底放一个魔术数字,比如 0xDEADBEEF,然后在主循环里定期检查这个值是否还在。如果被改了,说明栈曾经溢出过。这个方法简单有效,几乎不占什么开销。

另一个经验是给栈空间留足余量。很多人觉得 4KB 栈够了,但实际上中断嵌套加上 printf 这种函数,很容易就超过 4KB。我通常至少给 8KB,如果用到浮点运算或者递归,直接给 16KB 以上。

5.3 时钟配置失败的调试思路

时钟配置失败的表现通常是串口波特率不对,或者程序运行速度明显偏慢。排查时首先要确认外部晶振是否真的起振了。用示波器测晶振引脚,应该能看到正弦波。如果没有,检查晶振负载电容是否匹配,或者尝试降低驱动电流。

PLL 锁定失败也很常见。PLL 配置寄存器里通常有倍频系数和分频系数,这些值需要根据目标频率和参考频率计算。比如参考频率 12MHz,目标频率 144MHz,倍频系数就是 12。但有些 PLL 还有预分频和后分频,计算时要一并考虑。配置完后要轮询锁定标志位,不能直接切换时钟源。

提示:切换系统时钟源之前,一定要确认目标时钟已经稳定。切换过程中 CPU 可能会停顿几个周期,这是正常的,但如果停顿后程序跑飞,说明目标时钟根本没起来。

5.4 中断向量表配置的注意事项

RISC-V 的中断向量表由 mtvec 寄存器控制。mtvec 的低两位决定模式:0 表示直接模式,所有中断都跳到同一个地址;1 表示向量模式,不同中断跳到不同偏移。裸机开发中通常用直接模式,在统一的中断处理函数里再根据 mcause 寄存器判断中断源。

设置 mtvec 时要注意地址对齐。直接模式下 mtvec 的低两位必须为 0,也就是地址必须 4 字节对齐。向量模式下对齐要求更严格,通常是 64 字节或 128 字节对齐,具体看芯片实现。

中断处理函数要用__attribute__((interrupt))修饰,这样编译器会自动保存和恢复必要的寄存器。如果忘了加这个属性,中断返回时可能破坏现场,导致程序行为异常。

6. 启动流程的优化与进阶方向

6.1 启动速度优化与按需初始化

默认的启动流程会把所有初始化都做完再进 main,但实际项目中很多外设可能根本用不到。比如一个纯控制任务可能不需要串口,一个电池供电的设备可能希望尽快进入低功耗模式。这时候可以把初始化拆分成多个阶段,按需调用。

具体做法是把 system_init 拆成 clock_init、uart_init、gpio_init 等独立函数,在 main 里根据需要调用。启动汇编只做最必要的栈设置和数据段准备,其他全部延后。这样启动时间可以从几百毫秒缩短到几毫秒。

另一个优化点是数据段拷贝。如果 .data 段很小,拷贝耗时可以忽略;但如果 .data 段有几十 KB,拷贝就会明显拖慢启动。这时候可以考虑把一些不常改的全局变量放到 .rodata 段,直接从 Flash 读取,省去拷贝步骤。

6.2 多核启动与从核唤醒流程

多核 RISC-V 芯片的启动流程更复杂一些。通常只有一个核(主核)从复位向量开始执行,其他核处于休眠状态。主核完成基本初始化后,通过写特定的控制寄存器或者发送 IPI 来唤醒从核。从核被唤醒后,从某个约定地址开始执行,这个地址通常由主核提前写入。

从核的启动代码通常更简单,只需要设置自己的栈指针,然后跳转到指定的任务函数。但要注意每个核的栈空间必须独立,不能共用,否则会互相踩踏。链接脚本里可以为每个核分配独立的栈段,用不同的符号标记栈顶。

核间同步是多核启动的难点。主核在唤醒从核之前,必须确保从核要访问的内存和共享资源已经初始化完毕。通常用一个共享内存中的标志变量来同步,主核写标志,从核轮询等待标志变为有效后再继续。

6.3 启动代码的可移植性设计

如果你希望启动代码能在不同 RISC-V 芯片之间复用,就需要把芯片相关的部分抽象出来。常见的做法是定义一个硬件抽象层,把复位向量地址、时钟寄存器地址、内存布局等放在独立的头文件里,启动汇编和 C 代码通过宏来引用这些定义。

比如可以定义RESET_VECTOR、CLOCK_BASE、UART_BASE等宏,换芯片时只需要改这个头文件。启动汇编里用.equ或者直接引用 C 头文件中的宏(通过预处理器)。这样核心启动逻辑不用动,适配新芯片的工作量大大减少。

不过要注意,不同芯片的启动流程差异可能很大,强行统一反而会增加复杂度。我的经验是:同一系列或者同一厂商的芯片可以共用启动框架,跨厂商的芯片还是各写各的比较好,硬套只会给自己找麻烦。

7. 我个人在 RISC-V 裸机启动上踩过的坑

第一次搞 RISC-V 裸机的时候,我犯了一个很低级的错误:链接脚本里把栈放在了 Flash 区域。结果程序一运行就挂,因为栈是读写操作,而 Flash 是只读的。当时查了半天没找到原因,后来用调试器看 sp 寄存器的值,发现指向了 0x80000000 附近,才意识到栈放错地方了。这个教训告诉我,链接脚本写完后一定要检查每个段的地址范围是否和它的属性匹配。

还有一次是数据段拷贝的循环写错了,用了blt而不是bgeu。地址比较必须用无符号比较,因为地址是 32 位无符号数,用有符号比较在地址超过 0x7FFFFFFF 时会出错。这个 bug 很隐蔽,因为大多数情况下地址不会那么大,但一旦内存布局变了就可能触发。

时钟配置那块也踩过坑。有一次外部晶振的负载电容焊错了,晶振起振很慢,启动代码里等待稳定的循环没有超时机制,结果程序就卡在那里了。后来我养成了习惯,所有等待循环都加超时计数,超时后要么报错要么用默认时钟继续跑,至少不会死等。

串口调试也是血泪史。早期没有串口输出,全靠 GPIO 翻转和示波器,效率极低。后来不管什么项目,第一步都是把串口调通,哪怕只是打印一个字符,对后续调试的帮助都是巨大的。所以我现在强烈建议:启动代码里优先初始化串口,越早越好。

最后再分享一个小技巧:在启动汇编里,跳转到 main 之前可以把 mcause、mepc、mtval 这几个 CSR 的值保存到内存里。这样如果程序在 main 里出了异常,可以通过查看这些保存的值来定位异常原因。这个操作只需要几行汇编,但排查问题时能省下大量时间。

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

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

立即咨询