
1. 从复位向量到main函数RISC-V裸机启动到底经历了什么很多人第一次接触RISC-V裸机开发脑子里冒出来的第一个问题就是芯片上电之后第一条指令到底从哪里取我当初从ARM Cortex-M转过来的时候习惯性地去找中断向量表结果发现RISC-V这套玩法完全不一样。ARM那边复位之后硬件自动从向量表里取MSP和Reset_HandlerRISC-V这边则是硬编码了一个复位地址通常是0x80000000或者0x00000000具体取决于芯片设计。CPU从那个地址开始取指、译码、执行而那个地址上放的往往就是一段汇编写的启动代码。所谓bare-metal说白了就是不带操作系统程序直接跑在硬件上。你写的C代码要能跑起来中间必须有人帮你把栈指针设好、把.data段从Flash搬到RAM、把.bss段清零、把中断向量安排好这些事情操作系统平时帮你做了裸机环境下就得自己来。启动流程就是干这个的它是你的C语言main函数能正常执行的前置条件。这篇文章我打算把RISC-V裸机启动的整个链路拆开讲清楚从复位向量开始到链接脚本怎么配合再到多核启动时那些容易踩的坑。适合已经写过一些RISC-V裸机代码但总觉得启动部分云里雾里的朋友也适合从其他架构转过来想快速上手RISC-V的同行。我会尽量把每个步骤背后的“为什么”讲透而不是只丢一段汇编让你自己悟。2. 启动流程的整体设计与核心思路拆解2.1 为什么RISC-V的启动和ARM差别这么大RISC-V的设计哲学是极简和灵活。ARM Cortex-M把很多启动相关的事情做成了硬件行为比如自动加载向量表首地址到栈指针、自动跳转到Reset_Handler。RISC-V没这么干它只规定了一个复位地址剩下的全部交给软件。这种设计的好处是灵活你可以根据具体场景定制启动流程坏处是啥都得自己写对新手不太友好。具体来说RISC-V上电后的行为可以概括为几步PC被设置为复位向量地址CPU从该地址取第一条指令此时MMU、Cache、中断控制器等模块的状态取决于具体实现。有些芯片会在这之前做一些内部初始化但软件能感知到的起点就是复位向量。所以启动代码的第一条指令必须放在链接脚本指定的位置通常是.text段的起始处。2.2 启动代码要解决的几个核心问题我把启动阶段需要做的事情归纳为四类。第一类是环境准备包括设置栈指针、初始化全局指针gp如果用了 relaxation 优化、配置时钟和PLL。第二类是内存布局把.data段从只读存储器搬到RAM、把.bss段清零、必要时初始化堆区。第三类是中断和异常处理设置mtvec寄存器指向陷阱处理入口配置PLIC或CLINT。第四类是跳转到C环境调用main函数或者RTOS的入口。这四类事情的顺序不能乱。栈指针必须在任何函数调用之前设好否则压栈会写到非法地址。.data段的搬运必须在C代码访问全局变量之前完成否则读到的就是Flash里的初始值而不是RAM里的正确值。中断向量表的设置要在开中断之前做不然一个中断过来就飞了。2.3 链接脚本在启动中的角色链接脚本是启动流程的“地图”。它告诉链接器把哪些段放在哪个地址入口点在哪里栈顶地址是多少。RISC-V的链接脚本通常以ENTRY(_start)开头指定入口符号。然后定义MEMORY区域比如FLASH从0x80000000开始RAM从0x80010000开始。接着在SECTIONS里安排.text、.rodata、.data、.bss的位置。一个常见的做法是把启动汇编放在一个独立的段里比如.init或者.start然后在链接脚本里把这个段放在最前面。这样即使链接器做了优化启动代码也一定在复位向量处。我见过有人把启动代码和普通代码混在一起结果链接器把别的函数排到了前面芯片一上电就跑飞了。2.4 多核启动的基本模型多核RISC-V芯片上电后通常只有一个核称为hart 0或者主核从复位向量开始执行其他核处于暂停状态或者在一个等待循环里。主核负责完成所有的初始化工作然后通过写CLINT的msip寄存器或者类似的机制来唤醒其他核。其他核被唤醒后通常跳到一个统一的入口然后各自设置自己的栈指针和gp再进入各自的业务逻辑。这里有个关键点每个核的栈必须是独立的。如果两个核用同一个栈指针压栈的时候会互相覆盖程序行为完全不可预测。我一般会在链接脚本里为每个核分配独立的栈空间然后在启动代码里根据hart id来设置对应的栈指针。3. 核心细节解析与实操要点3.1 复位向量处的第一条指令复位向量处的代码通常是一段汇编第一件事是跳转到真正的启动代码。为什么要多这一跳因为复位向量处可能空间有限或者需要先做一些芯片特定的处理。比如有些芯片在复位后需要先配置一下时钟才能访问某些外设。这一跳通常用j指令或者auipcjalr组合。.section .init .globl _start _start: j reset_handler这段代码放在.init段链接脚本把它安排在复位地址。j指令是PC相对的不依赖任何寄存器所以即使此时栈指针还没设好也能正常执行。3.2 栈指针的设置与栈空间规划栈指针的设置是启动代码里最基础也最重要的一步。RISC-V的栈是向下增长的所以栈指针初始值应该指向栈空间的最高地址。栈空间的大小取决于你的应用裸机环境下一般几KB到几十KB不等。如果用了RTOS或者跑了一些递归比较深的算法栈要相应加大。la sp, _stack_top这行代码把_stack_top符号的地址加载到sp寄存器。_stack_top在链接脚本里定义通常放在RAM的末尾或者专门划出的栈区域末尾。注意la是伪指令实际会展开成auipcaddi所以它不依赖gp寄存器可以在gp初始化之前使用。注意栈空间一定要在链接脚本里明确预留不能和.data、.bss段重叠。我见过有人忘了预留栈空间结果栈增长时覆盖了全局变量查了两天才发现问题。3.3 .data段搬运与.bss段清零.data段存放的是有初始值的全局变量和静态变量。这些变量的初始值在编译时被放在只读存储器里比如Flash但运行时它们必须在RAM里。所以启动代码需要把.data段的初始值从Flash拷贝到RAM。.bss段存放的是没有初始值或者初始值为零的变量启动代码需要把这段内存清零。la a0, _data_lma 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 clear_bss: la a1, _bss_start la a2, _bss_end clear_loop: bgeu a1, a2, done sw zero, 0(a1) addi a1, a1, 4 j clear_loop这段代码里_data_lma是.data段在Flash里的加载地址_data_start和_data_end是.data段在RAM里的起始和结束地址。这些符号都需要在链接脚本里定义。搬运的时候按字4字节操作效率比较高但如果.data段大小不是4的倍数最后几个字节需要特殊处理。3.4 中断向量与陷阱处理入口RISC-V的陷阱处理入口由mtvec寄存器指定。mtvec的低两位表示模式0表示直接模式所有陷阱都跳到同一个地址1表示向量模式不同中断跳到不同的偏移。裸机环境下通常用直接模式然后在陷阱处理函数里根据mcause寄存器来判断陷阱类型。la t0, trap_entry csrw mtvec, t0trap_entry是一段汇编负责保存上下文、调用C语言的陷阱处理函数、恢复上下文、执行mret返回。保存上下文的时候要注意RISC-V的寄存器很多全部保存会消耗大量栈空间。实际项目中通常只保存 caller-saved 寄存器callee-saved 寄存器由C函数自己负责。3.5 多核启动的hart id获取与分支多核启动时每个核都需要知道自己的hart id然后根据hart id决定自己的行为。hart id可以通过csrr指令读取mhartid寄存器获得。csrr a0, mhartid bnez a0, secondary_core_entry j primary_core_entry主核走primary_core_entry完成所有初始化后唤醒其他核。从核走secondary_core_entry通常先等待一个标志位然后设置自己的栈指针和gp再进入业务逻辑。这里有个细节从核在等待期间不能访问任何需要初始化的外设因为主核可能还没初始化完。4. 实操过程与核心环节实现4.1 链接脚本的完整编写链接脚本是启动流程的骨架我以一个典型的RISC-V裸机项目为例展示链接脚本的关键部分。OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 4M RAM (rwx) : ORIGIN 0x80010000, LENGTH 64K } SECTIONS { .text : { *(.init) *(.text) *(.rodata) } FLASH .data : { _data_start .; *(.data) _data_end .; } RAM AT FLASH _data_lma LOADADDR(.data); .bss : { _bss_start .; *(.bss) *(COMMON) _bss_end .; } RAM .stack : { _stack_top . 0x1000; } RAM }这个脚本里.text段放在FLASH里.data段虽然运行时在RAM但加载地址在FLASH所以用AT FLASH指定加载地址。_data_lma通过LOADADDR获取.data段在FLASH里的实际地址。.stack段预留了4KB空间_stack_top指向栈顶。4.2 启动汇编的完整实现把前面讲的各个步骤串起来一个完整的启动汇编大概长这样.section .init .globl _start _start: j reset_handler reset_handler: csrr a0, mhartid bnez a0, secondary_entry primary_entry: la sp, _stack_top la gp, __global_pointer$ la t0, trap_entry csrw mtvec, t0 la a0, _data_lma la a1, _data_start la a2, _data_end call copy_data la a1, _bss_start la a2, _bss_end call clear_bss call main j . secondary_entry: la sp, _stack_top_secondary la gp, __global_pointer$ la t0, trap_entry csrw mtvec, t0 call secondary_main j .主核完成所有初始化后调用main从核设置自己的栈和gp后调用secondary_main。注意从核的栈指针用的是_stack_top_secondary这个符号需要在链接脚本里单独定义。4.3 多核唤醒的具体实现主核在完成初始化后需要唤醒其他核。RISC-V通常通过CLINTCore Local Interruptor的msip寄存器来实现。每个核在CLINT里有一个对应的msip寄存器写1会向该核发送软件中断。#define CLINT_BASE 0x02000000 #define CLINT_MSIP(hartid) (CLINT_BASE 4 * (hartid)) void wake_secondary_cores(int num_cores) { for (int i 1; i num_cores; i) { *(volatile uint32_t *)CLINT_MSIP(i) 1; } }从核在启动后需要先使能软件中断然后进入等待循环。当收到软件中断后清除msip寄存器然后继续执行。这里有个容易忽略的点从核在等待期间最好执行WFI指令这样可以降低功耗。4.4 参数计算与内存布局验证链接完成后一定要检查生成的map文件确认各个段的地址和大小符合预期。我一般会重点看几个东西_stack_top是否在RAM范围内、_data_lma是否在FLASH范围内、.bss段大小是否合理。如果栈空间不够可以调整链接脚本里的栈大小如果.data段太大放不下可能需要优化代码或者换更大的RAM。提示可以用riscv64-unknown-elf-objdump -h查看各个段的大小和地址用riscv64-unknown-elf-nm查看符号地址。这两个工具在调试启动问题时非常有用。5. 常见问题与排查技巧实录5.1 启动后跑飞或者进入异常这是最常见的问题表现是程序没有输出或者输出乱码。排查思路是先用调试器连上看PC停在哪里。如果PC停在trap_entry读mcause和mepc寄存器mcause告诉你异常类型mepc告诉你异常发生的位置。常见的异常包括指令访问错误取指地址不对、加载访问错误访问了非法地址、非法指令汇编写错了或者工具链配置不对。如果PC停在某个死循环检查栈指针是否设置正确。栈指针不对的话函数调用时压栈会写到非法地址触发异常。可以在启动代码里加一句死循环用调试器读sp寄存器的值确认它在RAM范围内。5.2 .data段数据不对如果全局变量的初始值不对大概率是.data段搬运出了问题。检查_data_lma、_data_start、_data_end这三个符号的地址是否正确。可以用objdump查看.data段在Flash里的实际内容和RAM里的内容对比。如果Flash里的内容是对的但RAM里不对说明搬运代码有问题如果Flash里的内容就不对说明链接脚本或者编译选项有问题。还有一种情况是.data段搬运了但搬运长度不对。比如.data段大小是100字节但搬运代码按字搬运只搬了96字节最后4字节没搬。这种问题比较隐蔽建议在搬运代码里加边界检查。5.3 多核启动时从核不执行从核不执行的原因可能有很多。首先确认从核是否真的被唤醒了可以在从核的入口处加一个GPIO翻转或者写一个内存标志。如果从核根本没执行检查msip寄存器的地址是否正确有些芯片的CLINT基地址不是0x02000000。如果从核执行了但卡住了检查从核的栈指针是否设置正确从核的栈空间是否和主核重叠。还有一个容易忽略的点从核在访问全局变量之前必须确保主核已经完成了.data段搬运和.bss段清零。如果从核启动太快可能在主核完成初始化之前就访问了未初始化的全局变量。解决办法是在从核入口处加一个等待循环等主核设置一个标志后再继续。5.4 常见问题速查表问题现象可能原因排查方法上电后无输出栈指针未设置或设置错误调试器读sp寄存器确认在RAM范围全局变量初始值不对.data段搬运失败对比Flash和RAM中.data段内容进入trap_entry非法指令或访问非法地址读mcause和mepc定位问题从核不执行msip地址错误或从核未使能中断检查CLINT基地址和mie寄存器多核运行时数据错乱栈空间重叠或缺少同步检查各核栈指针加内存屏障5.5 几个我踩过的坑第一个坑是gp寄存器的初始化。RISC-V的链接器 relaxation 优化会用gp寄存器来访问全局变量如果gp没设好访问全局变量就会出错。gp通常指向.sdata段中间的一个位置链接脚本里用__global_pointer$符号表示。启动代码里一定要在调用任何C函数之前设置gp。第二个坑是栈对齐。RISC-V的ABI要求栈指针16字节对齐如果栈指针没对齐某些指令比如浮点加载会触发异常。设置栈指针的时候确保_stack_top是16字节对齐的链接脚本里可以用ALIGN(16)来保证。第三个坑是中断使能的时机。有些代码在初始化中断控制器之前就开了全局中断结果一个中断过来mtvec还没设置直接跑飞。正确的顺序是先设置mtvec再初始化中断控制器最后开全局中断。第四个坑是多核启动时的内存屏障。主核写完标志位后从核可能因为乱序执行或者缓存一致性问题看不到最新的值。在写标志位后加一个fence指令在读标志位前也加一个fence指令可以避免这类问题。6. 启动流程的优化与进阶话题6.1 启动时间的优化裸机环境下启动时间往往很关键尤其是需要快速响应的场景。启动时间主要消耗在.data段搬运和.bss段清零上。如果.data段很大可以考虑把一些不常修改的全局变量放到.rodata段这样就不需要搬运了。.bss段清零可以用DMA来做比CPU逐字清零快很多。另一个优化点是时钟配置。有些芯片默认使用内部低速时钟启动启动代码里需要尽快切换到外部高速时钟。切换时钟的顺序要注意先配置PLL等PLL锁定后再切换时钟源切换过程中要确保CPU有稳定的时钟。6.2 从裸机到RTOS的过渡很多项目一开始是裸机后来业务复杂了需要上RTOS。从裸机过渡到RTOS启动流程需要做一些调整。RTOS通常需要自己的启动代码来初始化任务栈、调度器等。可以在裸机启动代码完成基本初始化后调用RTOS的启动函数而不是直接调用main。RTOS的启动函数会创建初始任务然后启动调度器。调度器启动后第一个任务开始执行此时栈指针会切换到任务的栈。所以RTOS启动之前系统栈只需要够RTOS启动函数使用即可不需要太大。6.3 调试启动代码的几个技巧调试启动代码最有效的手段是GPIO翻转和串口输出。在启动代码的关键节点翻转一个GPIO用示波器或者逻辑分析仪看波形可以直观地看到启动流程走到了哪一步。串口输出稍微麻烦一点因为串口初始化本身就需要一些代码但一旦串口能用了打印调试信息就方便很多。如果芯片支持JTAG调试直接用调试器单步执行启动代码是最直接的。可以在复位向量处设置断点然后单步跟踪看每一步执行后寄存器和内存的变化。这种方法适合排查复杂的启动问题但需要调试器支持RISC-V。我个人在实际操作中的体会是启动代码虽然看起来只是一小段汇编但涉及的知识面很广包括芯片架构、链接器、ABI、中断控制器等。每次调试启动问题都是对这些知识的一次重新梳理。建议大家在写启动代码的时候不要照抄别人的模板而是理解每一行代码的作用这样出了问题才能快速定位。另外启动代码写好后一定要在真实硬件上验证仿真器有时候和真实芯片的行为有差异尤其是时钟和中断相关的部分。