1. 从复位向量到main函数RISC-V裸机启动到底经历了什么很多人第一次接触RISC-V裸机开发时都会有一个疑问我写的那个main函数到底是怎么被跑起来的编译器生成的汇编里明明没有调用main的指令芯片上电之后又是怎么找到我的代码的这个问题看起来简单但真正把整个链路讲清楚的人并不多。大部分教程要么只给你一段启动汇编让你抄要么直接跳到链接脚本这样写就行了中间最关键的那段逻辑——从硬件复位到C环境就绪——往往被一笔带过。这篇文章就是要把这段路走一遍。我会从复位向量开始经过启动汇编、链接脚本、段布局、栈指针初始化、BSS清零、数据段搬运一直到跳进main函数把每一步为什么这么做、不这么做会出什么问题都讲清楚。适合已经写过一点RISC-V汇编、但对自己写的启动代码知其然不知其所以然的开发者也适合从ARM Cortex-M转过来、想搞清楚RISC-V启动差异的朋友。整篇内容基于常见的裸机开发实践展开涉及具体芯片时会用通用的RISC-V核行为来描述不绑定某一家厂商。先给一个全局视角。RISC-V的启动流程和ARM有相似之处但有一个根本区别RISC-V的复位行为没有ARM那么标准化。ARM Cortex-M有明确定义的向量表结构复位后硬件自动从向量表取MSP和Reset_Handler。RISC-V这边复位后PC去哪里、哪些CSR被初始化成什么值很大程度上取决于具体实现。所以你在RISC-V上写启动代码第一件事不是写汇编而是搞清楚你手上这颗核的复位约定。2. 复位之后PC到底去了哪里RISC-V的复位约定与入口地址2.1 复位向量不是一个地址而是一个约定在ARM世界里复位向量是向量表的第一个字硬件固定从0x00000000或某个可重映射的基址取。RISC-V没有这种硬性规定。RISC-V特权规范里只说复位后hart进入M模式Machine Modemstatus.MIE为0其他CSR的值是实现定义的。至于PC规范建议复位向量地址由实现决定通常是一个固定的、可以通过硬件引脚或熔丝配置的地址。实际芯片里常见的做法是复位向量指向一段ROM里的boot ROM代码boot ROM再根据启动模式比如从Flash启动、从SRAM启动跳转到用户代码。但也有很多简单的FPGA软核或者教学用核复位向量直接指向你烧录代码的起始地址比如0x80000000或者0x00000000。所以你在写启动代码之前必须确认两件事复位后PC的初始值是多少以及你的代码被链接到了哪个地址。这两件事如果对不上现象就是芯片上电后毫无反应或者跑飞到一个莫名其妙的地方。我见过不少人在这上面卡了一整天最后发现是链接脚本里的起始地址和实际复位向量差了一个偏移。2.2 链接脚本决定了你的代码住在哪链接脚本linker script在裸机开发里的地位相当于给链接器画了一张地图哪个段放在哪个地址、哪些符号定义在什么位置、栈顶在哪里。RISC-V裸机常用的链接脚本结构大致是这样OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 512K RAM (rwx) : ORIGIN 0x80080000, LENGTH 128K } SECTIONS { .text : { *(.text.init) *(.text*) } FLASH .rodata : { *(.rodata*) } FLASH .data : { *(.data*) } RAM AT FLASH .bss : { *(.bss*) *(COMMON) } RAM _stack_top ORIGIN(RAM) LENGTH(RAM); }这里有几个关键点值得展开。第一.text.init放在最前面这是启动汇编所在的段必须保证它位于入口地址。第二.data段用了AT FLASH意思是运行地址在RAM但加载地址在FLASH这就是后面要做数据段搬运的原因。第三_stack_top定义在RAM的最高地址栈是向下生长的所以栈顶取最高地址。ENTRY(_start)告诉链接器入口符号是_start但这只是给ELF文件头用的真正决定PC去哪里的还是硬件复位向量。如果硬件复位向量指向0x80000000而你的.text.init恰好被链接到0x80000000那就对上了。2.3 一个容易忽略的细节入口地址的对齐RISC-V的指令可以是16位压缩指令RVC或32位标准指令。复位向量地址通常要求至少4字节对齐有些实现要求更严格的对齐。如果你的启动代码第一条指令是压缩指令而复位向量只保证2字节对齐可能没问题但如果硬件要求4字节对齐你就要确保入口地址是4的倍数。这个细节在链接脚本里通过调整ORIGIN或者加ALIGN来保证。我在一个项目里遇到过这样的情况链接脚本里FLASH的ORIGIN设成了0x80000001想着反正代码从那里开始结果上电直接跑飞。后来改成0x80000000一切正常。原因就是复位向量要求4字节对齐奇数地址被硬件忽略了低位。这种坑不踩一次很难记住。3. 启动汇编里每一行都在干什么从_start到C环境的完整链路3.1 关中断、设栈指针最先要做的两件事启动汇编的第一件事通常是关全局中断然后设置栈指针。关中断是因为此时中断向量表还没准备好任何中断都会导致不可预期的行为。RISC-V里关中断通过清除mstatus的MIE位实现_start: csrw mie, zero csrw mip, zero csrr t0, mstatus li t1, ~0x8 and t0, t0, t1 csrw mstatus, t0这里先把mie和mip清掉防止之前残留的中断使能和挂起位捣乱然后清mstatus.MIE。注意mstatus.MIE是第3位所以掩码是~0x8。接下来设栈指针。栈指针sp也就是x2寄存器必须指向一块可写的RAM区域。通常从链接脚本导出的_stack_top符号取值la sp, _stack_topla是伪指令实际会展开成auipc加addi或者lui加addi取决于地址范围。这一步做完你才有栈可以用后面调用函数才不会覆盖掉别的数据。注意有些开发者在设置栈指针之前就调用了函数结果函数压栈时写到了未初始化或者只读的区域现象是莫名其妙的HardFault或者数据被改写。栈指针的设置必须是启动汇编里最早完成的事情之一。3.2 BSS段清零为什么不能省C语言里未初始化的全局变量和静态变量按照标准应该初始化为0。这些变量放在.bss段而.bss段在ELF文件里不占实际存储空间只记录起始和结束地址。所以启动代码必须手动把这块区域清零否则这些变量的初值是RAM里的随机值。清零的逻辑很直接从_bss_start到_bss_end逐字节或逐字写0。la t0, _bss_start la t1, _bss_end bgeu t0, t1, bss_done bss_loop: sd zero, 0(t0) addi t0, t0, 8 bltu t0, t1, bss_loop bss_done:这里用sd按8字节清零前提是_bss_start和_bss_end都是8字节对齐的。如果不是就要改用sb逐字节清零或者先对齐再按字清零。用bgeu而不是bge是因为地址是无符号数用无符号比较更稳妥。我见过有人为了省事直接在链接脚本里把.bss段去掉想着我不用未初始化变量就行。结果用了标准库的某些函数内部有静态缓冲区直接跑飞。.bss清零这一步除非你完全不用任何依赖零初始化数据的代码否则不能省。3.3 数据段搬运从FLASH到RAM.data段存放的是有初值的全局变量和静态变量。这些变量的初值在编译时被放在FLASH里加载地址但运行时它们应该在RAM里运行地址。所以启动代码要把.data段从FLASH拷贝到RAM。链接脚本里通过AT指定了加载地址启动代码里需要拿到三个符号加载地址_data_load、运行起始地址_data_start、运行结束地址_data_end。la t0, _data_load la t1, _data_start la t2, _data_end bgeu t1, t2, data_done data_loop: ld t3, 0(t0) sd t3, 0(t1) addi t0, t0, 8 addi t1, t1, 8 bltu t1, t2, data_loop data_done:这段代码和BSS清零结构几乎一样区别在于源地址和目标地址不同。同样要注意对齐问题。如果.data段不是8字节对齐的就要用更小的搬运单位。这里有一个常见的坑链接脚本里.data段的加载地址和运行地址如果计算错了搬运就会把错误的数据写到错误的地方。现象是全局变量的初值不对或者程序跑着跑着就崩了。排查方法是看map文件确认_data_load、_data_start、_data_end三个符号的地址是否符合预期。3.4 跳进main之前还有什么要做BSS清零和DATA搬运做完C运行环境基本就绪了。但还有几件事根据情况可能需要处理。第一是浮点单元初始化。如果你的RISC-V核有FPU且编译时用了硬浮点需要确保mstatus.FS字段不是0否则浮点指令会触发非法指令异常。通常设置mstatus.FS为Initial或Dirty。第二是中断控制器初始化。虽然全局中断还是关着的但中断控制器比如PLIC的优先级、阈值等可能需要提前配置。这个通常放在main里做不放在启动汇编里。第三是缓存初始化。如果核有指令缓存或数据缓存可能需要使能。这个因核而异有些核复位后缓存就是使能的有些需要手动开。第四是跳转到main。RISC-V里用call main或者jal ra, main。如果main返回了通常进入一个死循环因为裸机环境没有操作系统来接管返回值。call main loop: j loop到这里整个启动链路就走完了。从复位向量到main函数中间经历了关中断、设栈、清BSS、搬DATA、跳转五步。每一步都有它存在的理由少一步都可能出问题。4. 链接脚本与启动代码的配合符号、段布局与常见错位4.1 那些以下划线开头的符号是怎么来的启动汇编里用到的_bss_start、_bss_end、_data_load这些符号都不是C语言里定义的而是在链接脚本里通过符号赋值产生的。链接脚本里的写法通常是.bss : { _bss_start .; *(.bss*) *(COMMON) _bss_end .; } RAM .data : { _data_start .; *(.data*) _data_end .; } RAM AT FLASH _data_load LOADADDR(.data);.是位置计数器表示当前地址。_bss_start .就是把当前位置赋给这个符号。LOADADDR(.data)取的是.data段的加载地址也就是它在FLASH里的位置。这些符号在汇编里通过la伪指令取地址在C语言里通过extern声明后取地址。注意是取地址不是取值。比如extern char _bss_start;然后_bss_start才是地址。4.2 段布局错位的典型症状与排查段布局错位是裸机开发里最常见的问题之一。典型症状包括程序上电后不运行、运行到某处卡死、全局变量值不对、函数调用返回后跑飞。排查的第一步是看map文件。链接器生成的map文件里会列出每个段、每个符号的地址。确认.text.init是否在入口地址、.data的加载地址和运行地址是否合理、_stack_top是否在RAM范围内。第二步是反汇编。用objdump把ELF反汇编看_start处的指令是否和你的启动汇编一致看main的地址是否在预期范围内。第三步是检查对齐。RISC-V对指令地址有对齐要求如果.text段起始地址不是4字节对齐取指可能出错。数据访问也有对齐要求虽然RISC-V支持非对齐访问取决于实现但性能会下降某些核直接触发异常。我遇到过一个案例链接脚本里RAM的ORIGIN设成了0x80000000LENGTH设成了0x20000但实际RAM只有0x10000。链接器把栈顶算到了0x80020000超出了物理RAM范围。程序运行时栈一压就写到了不存在的地址现象是随机崩溃。后来把LENGTH改对问题消失。这种错误链接器不会报因为从链接器的角度看地址是合法的。4.3 启动代码放在哪个段.text.init的意义启动汇编通常放在一个独立的段里比如.text.init或者.init然后在链接脚本里把这个段放在最前面。这样做的好处是不管其他代码怎么排列启动代码永远在入口地址。如果启动代码和普通代码混在一起放在.text段链接器可能会因为优化或者段合并把别的函数排到前面导致入口地址处不是启动代码。虽然可以通过ENTRY指定入口符号但硬件复位向量不认这个它只认地址。所以一个稳妥的做法是启动汇编单独一个文件用.section .text.init声明段链接脚本里.text段的第一个输入就是*(.text.init)。这样无论怎么链接启动代码都在最前面。5. 从模拟器到真实芯片验证启动流程的几种手段5.1 用QEMU验证基本流程QEMU的RISC-V系统模式可以模拟一颗完整的SoC包括CLINT、PLIC、UART等外设。对于验证启动流程来说QEMU的好处是你可以直接指定内核ELF文件它会从你指定的入口地址开始执行。qemu-system-riscv64 -machine virt -nographic -bios none -kernel firmware.elf-bios none表示不使用QEMU自带的boot ROM直接从-kernel指定的ELF入口开始。这样你就能验证自己的启动代码是否能正确跑到main。在QEMU里验证的好处是如果启动代码有问题通常会有比较明确的报错比如invalid instruction或者trap at address。而且可以用GDB连上去单步调试看每一步寄存器的变化。5.2 用OpenOCD加GDB调试真实硬件真实芯片上验证启动流程通常需要JTAG调试器加OpenOCD。OpenOCD负责和芯片的调试模块通信GDB负责发指令。连接上之后第一件事是看复位后的PC值。这能直接告诉你硬件复位向量在哪里。然后单步执行看每一步是否符合预期。重点检查几个点栈指针是否被正确设置、BSS是否被清零、DATA是否被搬运、main是否被正确调用。如果芯片支持还可以在启动代码里翻转一个GPIO用示波器或者逻辑分析仪看波形。这种方法不需要调试器适合现场排查。5.3 一个实用的启动自检思路在启动代码里加一段自检逻辑把关键步骤的结果通过UART打印出来或者写到某个固定的内存位置。比如在BSS清零之后检查_bss_start到_bss_end是否全为0在DATA搬运之后检查几个已知初值的变量是否正确。这种自检在开发阶段很有用能快速定位问题出在哪一步。量产固件里可以去掉或者保留一个精简版本用于故障诊断。6. 踩过的坑与实战经验那些文档里不会写的东西6.1 栈溢出不会报错但会悄悄改写你的数据裸机环境没有内存保护栈溢出不会触发异常而是直接覆盖相邻的内存区域。如果栈向下生长溢出会覆盖栈下面的数据如果栈向上生长RISC-V通常是向下溢出会覆盖栈上面的数据。我遇到过一次栈顶设在了RAM的最高地址但RAM最高地址附近正好是某个外设的寄存器映射区。栈一溢出就写到了外设寄存器导致外设行为异常。排查了很久才发现是栈的问题。避免这个问题的办法是在栈顶和栈底各放一个魔数canary定期检查魔数是否被改写。或者用链接脚本把栈区域单独隔离开两边留出保护带。6.2 中断向量表没对齐中断一来就跑飞RISC-V的中断向量表基址由mtvec寄存器控制低两位表示模式。如果mtvec的低两位不是0表示向量模式每个中断入口地址是基址加上中断号乘以4。如果基址没对齐计算出来的入口地址就是错的。设置mtvec的时候要确保基址按4字节对齐向量模式下要求更严格。而且向量表里的每个入口通常是一条跳转指令跳转到实际的中断处理函数。6.3 编译器优化把启动代码优化没了启动汇编里的某些操作从编译器的角度看可能是无用的比如连续写同一个寄存器。如果启动代码是用C写的编译器可能会把这些操作优化掉。所以启动代码的关键部分通常用汇编写或者用volatile和内存屏障来阻止优化。即使是汇编如果用了宏或者内联汇编也要注意编译器的行为。最稳妥的方式是启动代码单独一个汇编文件不经过C编译器。6.4 不同核的复位行为差异RISC-V的复位行为因核而异。有的核复位后所有CSR都是0有的核某些CSR有非零初值。有的核复位后缓存是关的有的核是开的。有的核复位后PC指向ROM有的核直接指向Flash。所以换一颗核启动代码可能需要调整。不能把一颗核上的启动代码直接搬到另一颗核上除非你确认两者的复位约定一致。7. 把启动流程抽象成可复用的模板7.1 启动代码的模块化拆分一个可复用的启动模板通常拆成几个部分复位入口、栈初始化、BSS清零、DATA搬运、FPU初始化、跳转main。每个部分用独立的标签方便根据具体核裁剪。比如有些核不需要FPU初始化就把那段去掉有些核的BSS清零可以用硬件加速就替换成硬件操作。模块化的好处是换核的时候只需要改对应的部分不用重写整个启动文件。7.2 链接脚本的参数化链接脚本里的地址、长度这些参数最好提取成变量或者用宏定义方便不同配置下复用。比如FLASH_ORIGIN 0x80000000; FLASH_LENGTH 512K; RAM_ORIGIN 0x80080000; RAM_LENGTH 128K;这样换芯片的时候只改这几个参数就行段布局的逻辑不用动。7.3 启动流程的文档化启动代码里每一步在做什么最好用注释写清楚。特别是那些看起来多余的操作比如为什么要先清mie再清mstatus为什么要用bgeu而不是bge。这些细节过几个月自己都会忘更别说接手的人。我在团队里推的做法是启动汇编里每个标签上面写一段注释说明这一步的目的和注意事项。链接脚本里每个段上面写清楚这个段放什么、为什么放在这个地址。这样新人接手的时候看注释就能理解不用反复问。8. 从裸机启动延伸出去还能怎么玩启动流程跑通之后其实还有很多可以延伸的方向。比如把启动代码做成支持多核的每个核有自己的栈和启动路径主核负责初始化外设从核等待主核的信号再开始跑。这在多核RISC-V芯片上很常见。再比如把启动代码和bootloader结合起来启动代码负责最基础的初始化然后跳转到bootloaderbootloader再根据条件加载不同的应用固件。这种结构在需要固件升级的场景下很有用。还有一个方向是启动时间的优化。裸机启动通常很快但如果外设初始化很耗时或者DATA段很大启动时间就会上去。可以通过延迟初始化、按需搬运等方式优化。我做过一个项目把DATA搬运改成按页搬运启动时间从几百毫秒降到了几十毫秒。启动流程是裸机开发的地基地基打牢了上面盖什么楼都稳。反过来地基有问题后面调什么都别扭。所以花时间把这段搞清楚是值得的。