1. 启动流程到底在做什么从复位向量到main函数很多人第一次接触RISC-V的裸机开发脑子里会有一个巨大的问号我写了个main函数烧进去之后它怎么就跑起来了中间到底发生了什么这个问题如果搞不清楚后面调多核、调中断、调内存分配基本就是盲人摸象。RISC-V的启动流程本质上就是一条从复位向量到main函数的路径。CPU上电或者复位之后硬件层面会强制把PC指针设置到一个固定地址这个地址就是复位向量。对于大多数RISC-V核来说这个地址通常是0x80000000或者0x00000000具体取决于芯片设计。PC跳到这个地址之后开始执行第一条指令而这条指令所在的位置就是我们放的启动代码。启动代码要干的事情按顺序大致是这么几件设置栈指针C语言函数调用依赖栈没有栈就没法跑C代码。所以启动代码第一件事就是给每个核设置好sp寄存器。初始化关键CSR比如mtvec异常向量表基址、mstatus机器状态寄存器等这些寄存器决定了后续异常和中断怎么处理。清零BSS段C语言里未初始化的全局变量和静态变量默认应该是0但硬件上电后内存内容是随机的所以需要手动把BSS段清零。搬运DATA段已初始化的全局变量其初始值存在Flash里运行时需要拷贝到RAM中。跳转到main前面都准备好了最后调用main函数进入用户代码。这五步看起来简单但每一步都有坑。比如栈指针设在哪里BSS段怎么知道从哪到哪DATA段从Flash哪个地址搬到RAM哪个地址这些信息全部来自一个关键文件——链接脚本。我见过不少新手直接把main函数写好编译烧录结果程序跑飞串口啥也不输出。排查半天发现是栈指针没设或者BSS段没清零导致某个全局变量初值是个随机数逻辑直接跑偏。所以启动流程不是“可选项”而是裸机开发的必修课。注意不同RISC-V核的复位向量地址可能不同一定要查芯片手册或者核的文档确认。比如SiFive的E31核复位向量是0x1004而很多国产RISC-V MCU是0x80000000。搞错地址第一条指令就取不到直接HardFault。2. 链接脚本启动流程的“地图”链接脚本linker script是裸机开发里最容易被忽视、但最不能出错的东西。它决定了你的代码和数据在内存里怎么摆放也决定了启动代码从哪里取数据、往哪里放数据。2.1 链接脚本的核心结构一个典型的RISC-V裸机链接脚本长这样OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 4M RAM (rwx) : ORIGIN 0x80400000, LENGTH 8M } SECTIONS { .text : { *(.text.init) *(.text*) } FLASH .rodata : { *(.rodata*) } FLASH .data : { *(.data*) } RAM AT FLASH .bss : { *(.bss*) *(COMMON) } RAM }这里有几个关键点ENTRY(_start)指定了入口符号链接器会把_start的地址写到ELF头里但实际硬件复位后PC是跳到复位向量不是跳到_start。所以_start必须放在复位向量对应的位置。MEMORY定义了FLASH和RAM的起始地址和大小。这个必须和芯片手册一致写错了程序肯定跑不起来。.text段放在FLASH里因为FLASH是只读可执行的。.data段比较特殊它的加载地址LMA在FLASH运行地址VMA在RAM。所以启动代码需要把.data段从FLASH搬到RAM。.bss段只在RAM里占位置不需要加载内容但启动代码需要把它清零。2.2 启动代码如何配合链接脚本链接脚本里通常会定义一些符号供启动代码使用__bss_start .; .bss : { ... } RAM __bss_end .; __data_load_start LOADADDR(.data); __data_start ADDR(.data); __data_end ADDR(.data) SIZEOF(.data);启动代码里就可以这样用la t0, __bss_start la t1, __bss_end bge t0, t1, bss_done bss_loop: sd zero, 0(t0) addi t0, t0, 8 blt t0, t1, bss_loop bss_done: la t0, __data_load_start la t1, __data_start la t2, __data_end bge t1, t2, data_done data_loop: ld t3, 0(t0) sd t3, 0(t1) addi t0, t0, 8 addi t1, t1, 8 blt t1, t2, data_loop data_done:这段汇编就是启动代码里最核心的搬运和清零逻辑。看起来简单但有几个细节容易翻车对齐问题如果BSS段大小不是8的倍数按8字节清零可能会越界。稳妥的做法是按字节清零或者确保链接脚本里BSS段大小对齐到8字节。符号地址获取la伪指令在RISC-V里会展开成auipcaddi如果地址超过±2GB范围会出问题。不过一般裸机程序都在同一个地址空间问题不大。搬运顺序必须先搬运DATA段再清零BSS段因为BSS段可能紧挨着DATA段顺序反了会把搬运的数据冲掉。实操心得我习惯在链接脚本里把BSS段和DATA段之间留一点空隙比如加个. ALIGN(8);这样即使清零逻辑有点小偏差也不会影响到DATA段的数据。2.3 链接脚本的常见坑坑一FLASH和RAM地址搞反。有些芯片的FLASH地址是0x00000000RAM是0x20000000如果你照着别的芯片抄链接脚本把FLASH写成0x80000000那程序烧进去根本跑不了。坑二栈指针设在了BSS段里。栈指针一般设在RAM的高地址端向下增长。如果你把栈指针设在BSS段范围内函数调用时压栈会覆盖BSS段的数据导致全局变量莫名其妙被改。坑三没有保留栈空间。链接脚本里如果不显式保留栈空间链接器可能会把其他段分配到栈的位置。稳妥的做法是在RAM末尾预留一块区域_stack_top ORIGIN(RAM) LENGTH(RAM);然后在启动代码里把sp设成_stack_top。3. 多核启动谁先跑谁等着单核启动流程搞明白之后多核启动就是下一个必须跨过的坎。RISC-V的多核启动和ARM不太一样ARM有明确的primary core和secondary core概念RISC-V更多是靠软件约定。3.1 多核启动的基本模型RISC-V多核启动通常有两种模式所有核都从复位向量开始执行硬件复位后所有核的PC都指向同一个复位向量。这时候需要软件来判断自己是哪个核然后决定谁继续跑启动流程谁在原地等待。只有一个核从复位向量开始执行有些芯片设计里只有core 0会从复位向量启动其他核处于暂停状态需要core 0通过IPI核间中断或者其他机制唤醒。第一种模式更常见也更通用。启动代码里通常会这样处理csrr t0, mhartid bnez t0, secondary_wait # core 0 continues with full init ... secondary_wait: # other cores wait for a signal ...mhartid是RISC-V的一个CSR寄存器存放当前核的ID。core 0的ID是0其他核依次是1、2、3……3.2 多核启动的同步问题多核启动最麻烦的地方在于同步。core 0在初始化内存、外设的时候其他核不能乱跑否则会破坏初始化过程。常见的做法是core 0先完成所有关键初始化栈、BSS、DATA、时钟、内存控制器等。core 0设置一个全局变量或者写一个特定的内存地址作为“启动完成”的标志。其他核在secondary_wait里循环检查这个标志一旦发现标志被设置就跳转到各自的入口。这个标志变量必须放在所有核都能访问的内存里通常是RAM的某个固定地址。而且要注意缓存一致性问题——如果核之间有缓存写标志和读标志需要加内存屏障。secondary_wait: la t0, boot_flag wait_loop: lw t1, 0(t0) beqz t1, wait_loop fence r, r # jump to secondary entry la t0, secondary_main jr t03.3 多核启动的栈分配每个核都需要自己的栈空间。如果所有核共用一个栈函数调用会互相踩踏程序行为完全不可预测。栈分配通常有两种方式静态分配在链接脚本里为每个核预留固定大小的栈空间。比如core 0用__stack0_topcore 1用__stack1_top启动代码根据mhartid选择对应的栈指针。动态分配core 0初始化完内存后从堆里给每个核分配栈空间然后把栈指针写到某个共享内存里其他核读取后设置自己的sp。静态分配更简单适合核数固定的场景。动态分配更灵活但需要先初始化堆管理器。.stack : { . ALIGN(16); __stack0_top .; . 4K; __stack1_top .; . 4K; __stack2_top .; . 4K; __stack3_top .; . 4K; } RAM启动代码里csrr t0, mhartid la t1, __stack0_top slli t0, t0, 12 sub t1, t1, t0 mv sp, t1这段代码的意思是每个核的栈顶地址 __stack0_top-mhartid * 4K。这样core 0用最高的4Kcore 1用次高的4K依次类推。注意栈大小要根据实际需求调整。如果某个核跑的任务递归很深或者局部变量很大4K可能不够。我一般至少给8K复杂任务给16K。4. 从汇编到C启动代码的完整实现前面讲了原理这一节直接上完整的启动代码实现。你可以把这段代码作为模板根据自己的芯片调整地址和寄存器。4.1 启动汇编文件.section .text.init .global _start _start: # 1. 设置栈指针 csrr t0, mhartid la t1, __stack0_top slli t0, t0, 13 # 每个核8K栈 sub t1, t1, t0 mv sp, t1 # 2. 只有core 0执行完整初始化 csrr t0, mhartid bnez t0, secondary_wait # 3. 清零BSS段 la t0, __bss_start la t1, __bss_end bge t0, t1, bss_done bss_loop: sd zero, 0(t0) addi t0, t0, 8 blt t0, t1, bss_loop bss_done: # 4. 搬运DATA段 la t0, __data_load_start la t1, __data_start la t2, __data_end bge t1, t2, data_done data_loop: ld t3, 0(t0) sd t3, 0(t1) addi t0, t0, 8 addi t1, t1, 8 blt t1, t2, data_loop data_done: # 5. 设置异常向量表 la t0, trap_entry csrw mtvec, t0 # 6. 设置启动完成标志 la t0, boot_flag li t1, 1 sw t1, 0(t0) fence rw, rw # 7. 跳转到main call main # 8. main返回后死循环 halt: j halt secondary_wait: la t0, boot_flag wait_loop: lw t1, 0(t0) beqz t1, wait_loop fence r, r call secondary_main j halt4.2 链接脚本完整版OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 4M RAM (rwx) : ORIGIN 0x80400000, LENGTH 8M } SECTIONS { .text : { *(.text.init) *(.text*) } FLASH .rodata : { *(.rodata*) } FLASH .data : { . ALIGN(8); __data_start .; *(.data*) . ALIGN(8); __data_end .; } RAM AT FLASH __data_load_start LOADADDR(.data); .bss : { . ALIGN(8); __bss_start .; *(.bss*) *(COMMON) . ALIGN(8); __bss_end .; } RAM .stack : { . ALIGN(16); __stack0_top .; . 8K; __stack1_top .; . 8K; __stack2_top .; . 8K; __stack3_top .; . 8K; } RAM _end .; }4.3 编译和链接命令riscv64-unknown-elf-gcc -marchrv64imac -mabilp64 -nostdlib -nostartfiles \ -T link.ld -o firmware.elf start.S main.c riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin这里-nostdlib和-nostartfiles是关键告诉编译器不要链接标准库和标准启动文件因为我们要用自己的启动代码。实操心得编译完之后一定要用riscv64-unknown-elf-objdump -d firmware.elf反汇编看一下确认_start确实在0x80000000BSS段和DATA段的地址符合预期。我遇到过链接脚本写错导致_start跑到0x80001000的情况烧进去直接跑飞。5. 常见问题与排查技巧实录裸机启动这块问题往往出得很隐蔽串口没输出、程序跑飞、多核卡死都是家常便饭。这一节整理几个我实际踩过的坑和排查方法。5.1 串口无输出程序像没跑起来这是最常见的问题。排查顺序建议这样确认复位向量地址用调试器连上看PC复位后是不是跳到了你预期的地址。如果不是检查芯片手册里的复位向量配置。确认第一条指令反汇编看0x80000000处的指令是不是_start的第一条。如果不是链接脚本里.text.init的放置有问题。确认栈指针在_start第一条指令后设断点看sp是否被设成了一个合理的RAM地址。如果sp是0或者指向FLASH后面C代码必挂。确认时钟有些芯片复位后默认时钟极低串口波特率算出来不对输出的是乱码。先用示波器或者逻辑分析仪看串口TX引脚有没有波形。5.2 BSS段没清零导致全局变量初值随机这个问题很隐蔽因为程序可能大部分时候能跑但偶尔出奇怪bug。比如你定义了一个全局数组int buf[100]期望初值是0但实际初值是随机数后续逻辑就崩了。排查方法在main函数开头打印几个全局变量的值看是不是0。如果不是检查BSS清零代码。常见错误包括__bss_start和__bss_end符号没在链接脚本里定义。清零循环的步长和BSS段大小不匹配比如BSS段大小是100字节按8字节清零只清了96字节剩下4字节没清。清零循环的终止条件写错比如用了ble而不是blt导致多清或少清。5.3 多核启动卡死多核启动卡死通常是因为同步逻辑有问题。排查思路确认所有核都到了secondary_wait用调试器分别attach到每个核看PC是不是在wait_loop里。确认boot_flag的地址是所有核都能访问的如果boot_flag被链接到了某个核的私有内存区域其他核读不到就会一直等。确认内存屏障core 0写boot_flag后要加fence w, w其他核读之前要加fence r, r。没有屏障的话由于乱序执行和缓存其他核可能读到旧值。5.4 常见问题速查表现象可能原因排查方法串口无输出复位向量错误调试器看PC串口乱码时钟配置错误示波器看TX波形全局变量初值随机BSS未清零打印全局变量值程序跑飞栈指针未设置断点看sp寄存器多核卡死同步标志不可见检查boot_flag地址和屏障DATA段数据错误搬运地址错误反汇编看LOADADDR中断不触发mtvec未设置读mtvec寄存器5.5 独家避坑技巧技巧一用GPIO做启动标记。在启动代码的关键节点翻转一个GPIO用逻辑分析仪抓波形可以精确知道启动卡在哪一步。比串口打印更可靠因为串口本身也需要初始化。技巧二保留一个“安全栈”。在链接脚本最开头预留一小块RAM作为应急栈如果主栈出了问题可以切换到应急栈来打印错误信息。技巧三启动代码里加CRC校验。如果程序是从外部Flash加载的可以在启动时对代码段做CRC校验确保加载的代码没有损坏。这个在工业场景里很有用。技巧四多核启动时给每个核分配独立的调试串口。如果条件允许每个核用一个独立的串口输出调试信息这样能清楚看到每个核的执行状态排查多核问题效率翻倍。技巧五用mcycle计数器做性能分析。在启动代码里读mcycle寄存器记录各个阶段的耗时可以找出启动流程的瓶颈。比如DATA段搬运如果很大可以考虑压缩存储或者用DMA搬运。6. 启动流程的优化与扩展启动流程跑通之后下一步就是优化。裸机启动的优化空间其实很大尤其是对启动时间敏感的场景比如汽车电子、工业控制启动时间可能要求在一毫秒以内。6.1 启动时间优化启动时间主要花在几个地方DATA段搬运如果DATA段很大搬运耗时很长。优化方法包括把不常改的全局变量放到.rodata段只读不需要搬运或者用压缩算法压缩DATA段启动时解压。BSS段清零BSS段越大清零越慢。可以用DMA来清零释放CPU去干别的。时钟初始化有些芯片的PLL锁定需要时间这段时间CPU可以先去干别的初始化最后再等PLL锁定。我实测过一个项目通过把DATA段从Flash搬运改成XIP就地执行启动时间从8ms降到了2ms。具体做法是把.data段也放到FLASH里但这样就不能在运行时修改这些变量了。适合那些初始化后就不变的配置数据。6.2 启动流程的扩展从裸机到RTOS裸机启动流程跑通之后如果要上RTOS启动代码需要做一些扩展堆初始化RTOS需要动态内存分配启动代码要初始化堆管理器。Tick定时器初始化RTOS需要一个周期性中断作为系统Tick启动代码要配置好定时器。第一个任务启动RTOS启动后需要切换到第一个任务的上下文。这通常通过一个汇编函数实现手动构造任务栈帧然后执行mret或者sret切换到任务。这些扩展内容展开又是一大篇但核心思路是一样的启动代码负责把硬件和软件环境准备好然后交给上层系统。6.3 启动流程的调试手段最后再分享几个启动流程的调试手段QEMU模拟QEMU支持RISC-V可以在没有硬件的情况下调试启动流程。用-d in_asm,cpu参数可以打印每条执行的指令和寄存器状态非常适合排查启动初期的诡异问题。OpenOCDGDB这是硬件调试的标配。OpenOCD负责和JTAG调试器通信GDB负责下断点、看寄存器、单步执行。多核调试时GDB可以切换核分别查看每个核的状态。逻辑分析仪抓GPIO翻转、串口波形、SPI Flash的片选信号可以直观看到启动流程的时序。尤其是排查Flash加载问题时逻辑分析仪能看到Flash的读命令和返回数据确认加载是否正确。启动流程这块理论不难但细节极多。每一个地址、每一个寄存器、每一个对齐要求都可能成为程序跑不起来的元凶。我自己的经验是第一次跑通一个芯片的启动流程至少要花两三天但跑通之后后面所有开发都建立在这个基础上收益是巨大的。