1. 从复位向量到main函数:RISC-V裸机启动到底经历了什么
很多人第一次接触RISC-V裸机开发,脑子里冒出来的第一个问题就是:芯片一上电,第一条指令到底从哪儿取?我当初从ARM Cortex-M转过来的时候,也习惯性地去找类似0x00000000的复位向量表,结果发现RISC-V这套玩法虽然形似,细节上却有不少自己的脾气。这篇文章就把RISC-V从复位到进入main()的完整链路拆开讲清楚,包括链接脚本怎么写、启动代码怎么安排、多核场景下从核怎么唤醒,以及那些只有真正烧录调试过才会踩到的坑。
RISC-V裸机启动流程这件事,说穿了就是回答三个问题:第一条指令在哪里、栈指针什么时候设置、C语言环境什么时候准备好。看起来简单,但每一步都跟芯片具体的地址映射、链接脚本布局、启动模式引脚配置强相关。适合正在做RISC-V芯片bring-up的固件工程师、想从STM32转RISC-V的嵌入式开发者,以及需要理解多核启动流程的SoC验证人员。下面我按实际项目里的推进顺序,把每个环节的操作和背后的逻辑都摊开讲。
2. 启动流程整体设计与核心思路拆解
2.1 为什么RISC-V的启动不能照搬ARM那套
ARM Cortex-M的启动流程高度标准化:固定从0x00000000取MSP初值,再从0x00000004取Reset_Handler地址,向量表结构由内核规定死。RISC-V不一样,RISC-V特权架构只规定了复位后进入M模式、中断关闭,但复位向量地址是由具体SoC厂商决定的。有的芯片复位后PC指向0x80000000,有的指向0x00001000,还有的通过启动模式引脚选择从Flash还是从片内ROM启动。
这就意味着,做RISC-V裸机开发,第一件事不是写代码,而是翻芯片手册的Memory Map章节,确认复位向量地址。我见过有同事拿着某款RISC-V MCU的例程直接改,结果板子跑不起来,查了半天发现例程对应的芯片复位地址是0x20000000,而他手上这块是0x08000000,链接脚本压根没对上。
另一个关键差异是栈指针的设置时机。ARM在取复位向量时硬件自动加载MSP,RISC-V没有这个机制,SP的初始化必须由启动代码自己完成。所以RISC-V的启动汇编里,你一定会看到类似la sp, _stack_top这样的指令,而且这条指令必须放在调用任何C函数之前。
2.2 启动流程的分层设计思路
一个干净的RISC-V裸机启动流程,我通常分成四层来设计:
- 硬件层:复位向量、时钟初始化、PLL锁定、内存控制器配置。这部分跟芯片强绑定,通常用汇编或者带
__attribute__((section(".init")))的C函数实现。 - 运行环境层:设置SP、设置GP(全局指针)、初始化
.data段、清零.bss段。这部分是C语言能跑起来的前提。 - 系统层:中断向量表重定位、异常处理入口注册、多核从核唤醒。
- 应用层:跳转到
main(),开始业务逻辑。
这么分层的好处是,换芯片的时候只需要改硬件层和链接脚本,运行环境层和系统层的代码基本可以复用。我在几个不同厂商的RISC-V芯片之间移植代码时,这套结构帮我省了大量重复劳动。
2.3 链接脚本在启动流程中的核心地位
很多人低估了链接脚本的重要性,觉得那是链接器的事,跟启动流程没关系。实际上,链接脚本决定了启动代码能不能正确找到各个段的地址。.text段放哪里、.data段的加载地址和运行地址怎么分离、栈顶符号_stack_top定义在哪个位置,这些全部由链接脚本控制。
举个实际例子:如果你的.data段运行地址在RAM里,但加载地址在Flash里,启动代码就必须把数据从Flash拷贝到RAM。这个拷贝的源地址、目的地址、长度,全都要从链接脚本导出的符号里取。链接脚本写错一个符号,启动阶段就是硬件异常,而且这种异常往往没有明显报错,只能靠调试器单步跟。
3. 链接脚本与启动代码的核心细节解析
3.1 链接脚本的关键段落怎么写
先看一个我实际项目中用的链接脚本骨架,针对的是复位向量在0x80000000、RAM起始在0x80010000的一款RISC-V SoC:
OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN = 0x80000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x80010000, LENGTH = 128K } SECTIONS { .text : { *(.text.init) *(.text .text.*) } > FLASH .rodata : { *(.rodata .rodata.*) } > FLASH .data : { _data_start = .; *(.data .data.*) _data_end = .; } > RAM AT > FLASH _data_load = LOADADDR(.data); .bss : { _bss_start = .; *(.bss .bss.*) *(COMMON) _bss_end = .; } > RAM .stack : { . = ALIGN(16); _stack_bottom = .; . = . + 0x4000; _stack_top = .; } > RAM }这里有几个细节值得展开说。ENTRY(_start)告诉链接器入口符号是_start,但注意这只是给调试器和ELF文件用的,真正上电后PC去哪儿还是硬件决定的。*(.text.init)放在最前面,是为了确保启动代码被链接到复位向量地址处。.data段的AT > FLASH表示运行地址在RAM,加载地址在FLASH,链接器会自动生成_data_load符号供启动代码使用。
注意:
.stack段我习惯显式定义并导出_stack_top符号,而不是依赖链接器自动分配。显式定义的好处是栈溢出时容易排查,你可以把_stack_bottom附近填上魔数,运行一段时间后检查魔数是否被改写。
3.2 启动汇编代码逐行拆解
启动汇编是整个流程里最需要精确控制的部分,每一行都有明确目的。下面这段代码对应上面的链接脚本:
.section .text.init .globl _start _start: /* 1. 关闭全局中断 */ csrw mie, zero csrw mip, zero /* 2. 设置栈指针 */ la sp, _stack_top /* 3. 设置全局指针GP */ .option push .option norelax la gp, __global_pointer$ .option pop /* 4. 拷贝.data段从FLASH到RAM */ la a0, _data_load la a1, _data_start la a2, _data_end copy_data: bgeu a1, a2, clear_bss lw t0, 0(a0) sw t0, 0(a1) addi a0, a0, 4 addi a1, a1, 4 j copy_data /* 5. 清零.bss段 */ clear_bss: la a0, _bss_start la a1, _bss_end bgeu a0, a1, jump_main sw zero, 0(a0) addi a0, a0, 4 j clear_bss /* 6. 跳转到main */ jump_main: call main j .第1步关中断是必须的,复位后虽然M模式中断默认关闭,但显式清零mie和mip能避免某些芯片复位状态不确定带来的意外。第2步设置SP,注意_stack_top是链接脚本导出的符号,la伪指令会把它解析成正确的地址。
第3步设置GP这个操作,很多教程会忽略。RISC-V的ABI规定GP用于访问全局变量,链接器会把小全局变量优化成基于GP的访问。如果不设置GP,访问全局变量时可能跳到错误地址。.option norelax是防止汇编器把la优化成基于GP的加载,因为此时GP还没设置好。
第4步和第5步是标准的C运行时初始化。这里我用的是按字(4字节)拷贝,实际项目中如果数据量大,可以改成按双字或者用DMA加速。第6步call main之后跟一个死循环j .,防止main返回后跑飞。
3.3 中断向量表的安排方式
RISC-V的中断处理有两种模式:直接模式和向量模式,由mtvec寄存器的低两位控制。直接模式下所有异常和中断都跳到同一个入口,向量模式下不同中断跳到不同偏移。裸机开发中我一般先用直接模式,简单可靠:
void trap_handler(void) __attribute__((interrupt("machine"), aligned(4))); void trap_handler(void) { uint32_t mcause; asm volatile("csrr %0, mcause" : "=r"(mcause)); /* 根据mcause分发处理 */ }初始化时把trap_handler的地址写入mtvec即可。注意aligned(4)是必须的,因为mtvec低两位要放模式位,基地址必须4字节对齐。如果要用向量模式,则需要建一个跳转表,每个表项4字节,且整个表要256字节对齐。
4. 多核启动与完整实操流程
4.1 多核场景下从核怎么唤醒
单核启动流程走通之后,多核就是下一个必须面对的坎。RISC-V多核SoC的典型架构是:所有核共享复位向量,上电后所有核都从同一个地址开始执行。这时候就需要在启动代码里区分主核和从核。
区分的方法通常有两种:读mhartid寄存器,或者读芯片特定的核ID寄存器。mhartid是RISC-V标准寄存器,每个核的值不同,主核一般是0。我常用的做法是在_start最前面加一段判断:
_start: csrr t0, mhartid bnez t0, secondary_core_loop /* 主核继续走完整启动流程 */ ... secondary_core_loop: /* 从核等待主核发来的唤醒信号 */ la t1, _secondary_entry wait_loop: lw t2, 0(t1) beqz t2, wait_loop jr t2从核在主核完成基本初始化之前,一直在一个循环里等待。主核初始化完时钟、内存、外设之后,把从核要跳转的入口地址写到约定的内存位置,从核检测到非零值就跳过去。这个约定的内存位置通常放在共享RAM里,且要保证缓存一致性——如果从核有独立缓存,主核写完数据后需要执行fence操作。
提示:从核唤醒的同步点选择很关键。如果从核在内存控制器还没初始化好之前就去访问RAM,会直接挂死。所以从核的等待循环本身必须放在不需要RAM的地方,比如片内ROM或者紧耦合内存。
4.2 完整启动流程的实操步骤
把上面的内容串起来,一个完整的RISC-V裸机启动实操流程是这样的:
- 确认硬件信息:查芯片手册,确认复位向量地址、RAM起始地址和大小、启动模式引脚配置。这一步偷懒后面会加倍还回来。
- 编写链接脚本:根据硬件信息定义MEMORY区域,安排
.text、.data、.bss、.stack段的位置,导出_data_load、_data_start、_data_end、_bss_start、_bss_end、_stack_top等符号。 - 编写启动汇编:实现关中断、设SP、设GP、拷贝
.data、清零.bss、跳转main的完整流程,多核场景下加入主从核判断。 - 编写陷阱处理入口:实现
trap_handler,初始化mtvec,至少能打印mcause和mepc用于调试。 - 编写最小main函数:先让一个LED闪烁或者串口打印一个字符,验证启动流程走通。
- 烧录调试:用调试器连接,在
_start处下断点,单步跟踪每一步,确认SP、GP、各段地址都正确。 - 多核验证:如果是从核不启动的问题,先在从核等待循环里加一个GPIO翻转,确认从核确实跑到了等待点。
4.3 编译与链接命令的实际配置
工具链方面,我用的是riscv64-unknown-elf-gcc。编译选项里几个关键参数:
riscv64-unknown-elf-gcc -march=rv32imac -mabi=ilp32 -nostartfiles -T link.ld \ -O2 -ffunction-sections -fdata-sections \ -Wl,--gc-sections -o firmware.elf start.S main.c-nostartfiles是必须的,否则工具链会链接它自带的启动代码,跟我们的_start冲突。-ffunction-sections和-fdata-sections配合--gc-sections可以去掉未使用的代码,减小固件体积。-march和-mabi要根据芯片实际支持的指令集和ABI来选,选错了链接阶段就会报错。
生成ELF之后,还需要用objcopy转成二进制或者HEX用于烧录:
riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin烧录前建议用objdump反汇编确认一下入口地址处的指令确实是_start:
riscv64-unknown-elf-objdump -d firmware.elf | head -305. 常见问题与排查技巧实录
5.1 启动阶段典型问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 上电后PC跑飞 | 复位向量地址配错 | 查手册确认复位地址,反汇编确认该地址有指令 |
| 进main后全局变量值不对 | .data段未拷贝或GP未设置 | 在main入口检查全局变量地址和值,确认拷贝循环执行 |
| 栈操作后挂死 | SP未设置或栈空间不足 | 检查_stack_top符号地址,确认栈大小够用 |
| 从核不启动 | 从核等待地址未正确写入 | 在从核等待循环加GPIO翻转,确认从核运行状态 |
| 中断触发后跑飞 | mtvec未初始化或对齐错误 | 检查mtvec值低两位和对齐,确认陷阱入口地址正确 |
| 链接报错region overflow | 段大小超过MEMORY定义 | 用size命令查看各段大小,调整链接脚本或优化代码 |
5.2 几个只有踩过才知道的坑
第一个坑:.data段拷贝方向搞反。拷贝循环里源地址和目的地址写反,编译链接都不报错,但运行起来全局变量全是乱码。我当时的排查方法是在拷贝循环前后各加一个断点,对比源地址和目的地址的内存内容,一眼就看出来了。
第二个坑:GP设置时机不对。有次我把GP设置放在了.data拷贝之后,结果拷贝循环里用到的全局变量访问出错。原因是拷贝循环本身可能用到基于GP的寻址。正确做法是在任何C代码或者可能生成GP相对寻址的代码之前就设置好GP。
第三个坑:多核缓存一致性问题。主核写完从核入口地址后,从核读到的还是旧值。这是因为主核的写操作还在缓存里没刷到内存。解决方法是在主核写完数据后执行fence指令,必要时还要执行缓存刷新操作。这个问题在单核调试时完全不会出现,一到多核就暴露。
第四个坑:栈对齐。RISC-V的ABI要求栈指针16字节对齐,如果启动代码里设置的_stack_top没有16字节对齐,调用某些库函数时会触发异常。链接脚本里. = ALIGN(16)这一句不能省。
5.3 调试启动流程的实用技巧
启动阶段的调试,我总结下来最有效的三个手段:
- GPIO翻转法:在启动流程的每个关键节点翻转一个GPIO,用示波器或者逻辑分析仪看波形,能快速定位卡在哪一步。这个方法不需要调试器,特别适合调试器连接不稳定的场景。
- 串口打印法:在设置好UART之后,尽早加入打印语句。注意打印函数本身不能依赖太多运行时环境,最好用直接操作寄存器的方式实现。
- 调试器单步法:在
_start处下断点,单步执行,每执行几步就检查一次SP、GP、PC的值。这个方法最直接,但要求调试器能正确识别RISC-V的CSR寄存器。
注意:用调试器单步跟踪启动代码时,某些芯片的时钟初始化代码会导致调试连接断开。遇到这种情况,先把时钟配置跳过,等启动流程验证完再逐步加入。
6. 从启动流程延伸出的几个实际考量
6.1 启动代码的复用与移植策略
做过几款RISC-V芯片之后,我发现启动代码的复用关键在于把芯片相关的部分隔离出来。我的做法是建一个startup/目录,里面放一个通用的start.S,然后每款芯片一个chip_xxx.h,里面定义复位向量地址、内存布局、时钟配置等宏。链接脚本也用预处理,通过-DCHIP_XXX来切换不同的MEMORY定义。
这样移植到新芯片时,只需要新增一个头文件,改一下编译参数,启动汇编和链接脚本模板基本不用动。我在三个不同厂商的RISC-V芯片之间移植,每次花在启动流程上的时间从最初的两三天缩短到了半天。
6.2 bare-metal与RTOS启动的衔接
裸机启动流程走通之后,如果要上RTOS,启动代码需要做一点调整。RTOS通常需要接管mtvec,所以裸机阶段的陷阱处理入口要在RTOS初始化时被替换掉。另外RTOS的任务栈和启动阶段的栈是分开的,启动阶段的栈在跳转到RTOS第一个任务之后就可以回收。
以FreeRTOS为例,main()里调用xTaskCreate创建任务,然后调用vTaskStartScheduler()启动调度器。调度器启动时会配置mtvec指向自己的陷阱处理入口,并切换到第一个任务的栈。所以裸机启动代码里设置的SP,在调度器启动后就不再使用了。
6.3 启动时间优化的一点经验
有些应用对启动时间敏感,比如需要快速响应的工业控制场景。启动时间优化主要从几个方面入手:减少.data段拷贝量(把不需要修改的全局变量声明为const放到.rodata)、加快时钟初始化(PLL锁定时间通常是固定的,但可以优化配置流程)、并行初始化(多核场景下让从核分担部分外设初始化)。
我实测过的一个优化案例:把.data段从按字拷贝改成按双字拷贝,拷贝时间减少了约40%。另一个案例是把串口初始化从启动阶段移到main之后,启动时间缩短了约15毫秒。这些优化看起来不起眼,但在启动时间要求严格的场景下很关键。
启动流程这个东西,看再多文档不如实际烧录调试一遍。我最初做RISC-V裸机的时候,光是一个SP设置的问题就折腾了一整天,后来发现是链接脚本里栈段被其他段覆盖了。这种问题没有捷径,只能靠对每个环节的深入理解和耐心排查。把上面这套流程走通一遍,后面再遇到新的RISC-V芯片,基本都能在一天内完成bring-up。