
1. 从复位向量到main函数RISC-V裸机启动到底在做什么很多人第一次接触RISC-V裸机开发脑子里冒出来的第一个问题往往是板子上电之后第一条指令到底从哪儿来我明明写了个main函数谁调用的它栈指针什么时候设置的全局变量谁帮我初始化的这些问题在Linux应用开发里几乎不用操心因为操作系统和C运行时库把这一切都包圆了。但一旦进入bare-metal的世界这些活儿全得自己干。RISC-V的启动流程说白了就是一段“从零开始搭台子”的过程。芯片复位后CPU从某个固定的复位向量地址取第一条指令此时没有栈、没有堆、没有初始化好的.data段甚至连中断向量表都还没影子。你要做的是在进入C语言的main之前用汇编和链接脚本把运行环境一点点搭起来设置栈指针、初始化数据段、清零BSS段、配置中断向量、必要时唤醒其他核心。这套流程在ARM Cortex-M上可能被启动文件封装得比较好但在RISC-V生态里因为指令集本身是模块化的不同厂商的SoC启动细节差异很大所以更需要你真正理解每一步在干什么。这篇文章适合谁看如果你已经写过一些RISC-V的裸机点灯程序但对启动文件里那一堆汇编似懂非懂或者你从STM32转过来发现RISC-V的启动流程和ARM完全不是一回事又或者你在做多核RISC-V芯片的bring-up被主核从核的启动顺序搞得头大——那这篇内容应该能帮你把整条链路捋清楚。我会从链接脚本、启动汇编、C运行时初始化、多核启动几个维度把RISC-V bare-metal启动流程拆开讲尽量做到你照着就能复现。2. 链接脚本给代码和数据安排一个合理的“住址”2.1 为什么链接脚本是启动流程的第一步链接脚本这东西在应用开发里几乎感知不到因为编译器默认给你安排好了。但在裸机环境里链接脚本决定了你的代码被放到哪个地址、数据段在RAM里的布局、栈顶在哪里、堆往哪个方向长。可以说链接脚本是启动流程的“地基”地基没打好后面启动汇编写得再漂亮也跑不起来。RISC-V芯片的地址空间布局通常是这样复位向量在一个固定的低地址区域比如0x80000000或者0x00000000取决于具体SoC这段地址映射到Flash或者ROMRAM则在高地址区域比如0x80000000往上。链接脚本要做的就是告诉链接器.text段放到Flash区域.data段虽然加载在Flash里但运行时地址在RAM.bss段直接在RAM里占位。我见过不少新手在这里踩坑链接脚本里把.data段的LMA加载地址和VMA运行地址搞混了结果程序烧进去之后全局变量的初始值全是乱的。原因很简单.data段的初始值存在Flash里启动时需要从Flash拷贝到RAM如果你链接脚本没写对拷贝的源地址和目标地址就错了。2.2 一个可复用的链接脚本模板下面这个链接脚本模板是我在多个RISC-V裸机项目里反复用过的你可以根据自己芯片的地址空间改一下MEMORY区域的起始地址和长度OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 4M RAM (rwx) : ORIGIN 0x80400000, LENGTH 8M } SECTIONS { .text : { *(.text.init) *(.text .text.*) *(.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 _heap_start .; }这里有几个关键点值得展开说。ENTRY(_start)指定了入口符号链接器会把ELF入口点设为_start但注意这只是告诉链接器入口在哪里真正上电后CPU从哪个地址取指令还是由硬件复位向量决定的。通常复位向量会跳转到_start所以两者要对上。.data段的 RAM AT FLASH这个写法意思是运行地址在RAM加载地址在FLASH。链接器会自动生成_data_load符号指向Flash里的加载地址。启动汇编里就用_data_load到_data_start的拷贝把数据从Flash搬到RAM。.stack段我习惯单独放一个段定义_stack_top符号启动汇编里直接把栈指针设成这个值。栈大小0x4000是16KB对于大多数裸机程序够用了如果你跑RTOS或者递归比较深可以适当加大。注意不同厂商的RISC-V芯片复位向量地址可能不同比如有些芯片复位后从0x00000000开始执行有些从0x80000000开始。改链接脚本之前一定先查芯片手册的Memory Map章节确认Flash和RAM的起始地址。2.3 链接脚本里容易忽略的细节还有一个细节容易被忽略.text.init段。我习惯把启动汇编单独放到.text.init段里并且在链接脚本里把它放在.text段的最前面。这样做的好处是启动代码一定在Flash的最开头复位向量跳转过来就能直接执行不用担心中间插了别的代码。另外如果你用的是支持压缩指令的RISC-V核心链接脚本里可能还需要处理.riscv.attributes段不过这个一般不影响运行只是给调试器看的。真正影响运行的是对齐问题栈指针必须16字节对齐RISC-V的ABI要求栈对齐到16字节不对齐的话在某些核心上会触发异常。3. 启动汇编上电后的第一段代码3.1 _start符号里必须做的事启动汇编是整个流程里最“硬核”的部分因为它直接和硬件打交道。_start符号通常放在一个.S文件里上电后CPU执行的第一条指令就在这里。这段代码要做的事情按顺序大致是关中断、设置栈指针、拷贝.data段、清零.bss段、设置gp和tp寄存器、跳转到main。为什么第一步是关中断因为此时中断向量表还没配置栈也没设置好如果这时候来了个中断CPU会跳到默认的中断入口而那里可能什么都没有直接跑飞。所以上电后第一件事就是写mstatus寄存器把全局中断使能位清掉。设置栈指针很简单把链接脚本里定义的_stack_top加载到sp寄存器就行。但要注意RISC-V的栈是向下增长的所以_stack_top是栈的最高地址sp设成这个值压栈时sp减小。拷贝.data段和清零.bss段是C运行时初始化的核心。.data段里的变量有初始值这些初始值存在Flash里必须搬到RAM里对应的位置。.bss段里的变量初始值为零但Flash里不存储这些零所以需要在RAM里手动清零。3.2 一段完整的启动汇编示例下面这段启动汇编是我在一个RV32IMAC核心上实际用过的你可以直接参考.section .text.init .globl _start _start: # 1. 关全局中断 csrw mie, zero csrw mip, zero # 2. 设置栈指针 la sp, _stack_top # 3. 拷贝 .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 clear_bss: # 4. 清零 .bss 段 la a1, _bss_start la a2, _bss_end clear_bss_loop: bgeu a1, a2, set_gp sw zero, 0(a1) addi a1, a1, 4 j clear_bss_loop set_gp: # 5. 设置全局指针 gp .option push .option norelax la gp, __global_pointer$ .option pop # 6. 跳转到 main call main # 7. main 返回后进入死循环 loop: j loop这段代码里la是伪指令实际会展开成auipc加addi用来加载符号地址。拷贝.data段用的是字拷贝每次4字节如果你的数据段不是4字节对齐的可能需要改成字节拷贝。不过链接脚本里一般会保证对齐所以字拷贝没问题。gp寄存器的设置有个坑RISC-V的ABI规定gp指向.sdata段的中间位置用于小数据区的快速访问。如果你不设置gp链接器生成的一些依赖gp的代码就会出错。设置gp的时候要用.option norelax防止汇编器把la展开成依赖gp的指令那就死循环了。提示call main之后理论上不应该返回但万一main返回了一定要有个死循环兜底否则CPU会继续往下执行未知内存行为不可预测。3.3 中断向量表的早期配置虽然启动汇编里第一步就关了中断但有些场景下你可能希望在main之前就把中断向量表配好比如你需要用定时器中断来做早期延时。RISC-V的中断向量表通过mtvec寄存器配置支持两种模式直接模式和向量模式。直接模式下所有中断都跳到同一个入口由软件判断中断源。向量模式下每个中断源有独立的入口地址入口地址按中断号排列。裸机开发里我一般用直接模式简单可控。配置代码大概是这样la t0, trap_entry csrw mtvec, t0trap_entry是你自己写的中断服务入口里面保存上下文、判断中断源、调用对应的处理函数。这部分内容展开就太长了这里先提一句后面讲多核启动的时候还会涉及。4. C运行时初始化从汇编到main的最后一公里4.1 全局变量和静态变量的初始化时机很多人以为全局变量的初始化是C语言自动完成的其实在裸机环境里这件事是启动汇编帮你做的。.data段的拷贝和.bss段的清零就是全局变量和静态变量初始化的全部内容。如果你在启动汇编里漏了这两步main函数里访问全局变量时读到的就是随机值。我踩过一次坑链接脚本里.bss段的结束符号写成了_bss_end但启动汇编里引用的是_bss_end_多了一个下划线结果链接器报未定义符号。这种低级错误在调试的时候特别浪费时间因为编译能过链接才报错而且报错信息不一定直观。后来我养成了一个习惯链接脚本里定义的所有符号都在一个头文件里用extern声明一遍汇编和C都引用这个头文件避免拼写错误。4.2 构造函数和析构函数的处理C项目里全局对象的构造函数需要在main之前调用。裸机环境没有操作系统帮你做这件事需要自己遍历.init_array段。链接脚本里要加上.init_array : { _init_array_start .; KEEP(*(.init_array)) _init_array_end .; } RAM AT FLASH启动汇编里在跳转到main之前遍历这个数组把每个函数指针取出来调用一遍。析构函数在裸机里一般用不到因为程序永远不会正常退出但如果你要写逻辑类似。纯C项目不需要这一步但如果你用C写裸机这个环节不能省。我见过有人用C写RISC-V裸机全局对象的构造函数没被调用结果对象里的成员变量全是未初始化的调试了半天才发现是.init_array没处理。4.3 栈溢出检测的简易实现裸机环境没有MMU栈溢出不会触发异常而是会悄悄覆盖相邻的内存区域导致各种诡异问题。一个简单的栈溢出检测方法是在栈的底部和顶部填充特定的魔数在main循环里定期检查这些魔数是否被改写。具体做法是在链接脚本里把栈段单独拿出来启动汇编里把_stack_bottom到_stack_top之间的内存填充成0xDEADBEEF然后在main里定期检查_stack_bottom附近的几个字是否还是0xDEADBEEF。如果被改了说明栈曾经增长到这个位置可能已经溢出。这个方法不能精确检测溢出但能给你一个预警。真正要精确检测需要硬件支持或者用MPU但大多数低端RISC-V核心没有MPU所以软件填充法是最实用的。5. 多核启动主核与从核的配合5.1 RISC-V多核启动的基本模型多核RISC-V芯片的启动流程比单核复杂得多。上电后通常只有一个核心称为主核或者hart 0从复位向量开始执行其他核心从核处于暂停状态等待主核发送启动信号。主核完成自身的初始化后通过写特定的寄存器或者发送IPI处理器间中断来唤醒从核从核从指定的入口地址开始执行。这个模型和ARM的多核启动不太一样。ARM Cortex-A系列通常所有核心同时从复位向量启动然后通过软件判断自己是主核还是从核。RISC-V则更多是主核先跑从核待命主核准备好之后再逐个唤醒从核。两种方式各有优劣RISC-V的方式更可控但主核的启动时间会稍长。5.2 从核唤醒的两种常见方式从核唤醒方式取决于具体SoC。常见的有两种一种是通过写控制寄存器主核把从核的入口地址写到某个寄存器然后置位启动位从核硬件自动跳转到入口地址另一种是通过软件中断主核向从核发送IPI从核在中断处理里读取入口地址并跳转。以我做过的一个四核RISC-V芯片为例从核唤醒寄存器布局是这样的寄存器偏移名称功能0x00CORE0_ENTRY核心0入口地址0x04CORE1_ENTRY核心1入口地址0x08CORE2_ENTRY核心2入口地址0x0CCORE3_ENTRY核心3入口地址0x10CORE_CTRL启动控制位bit0对应核心0主核初始化完内存和中断控制器后把从核的入口地址写到对应的ENTRY寄存器然后写CORE_CTRL的对应位从核就开始执行。从核的入口代码通常是一段简短的汇编设置自己的栈指针然后跳转到C语言的从核主函数。5.3 多核启动的同步与互斥多核启动最大的难点不是唤醒本身而是同步。主核在初始化内存控制器的时候从核不能访问内存主核在初始化串口的时候从核不能打印日志。所以从核唤醒的时机很关键必须在主核完成所有共享资源初始化之后。我通常的做法是主核完成初始化后设置一个全局变量g_smp_ready然后才唤醒从核。从核启动后先自旋等待g_smp_ready变成1然后再继续执行。这个变量要放在.data段里并且初始值为0确保从核读到的是主核写过的值。互斥方面裸机环境没有操作系统提供的锁需要用原子指令自己实现。RISC-V的A扩展提供了lr.w和sc.w指令可以实现自旋锁。一个最简单的自旋锁实现typedef struct { volatile int lock; } spinlock_t; void spin_lock(spinlock_t *l) { while (1) { int old; do { old __atomic_exchange_n(l-lock, 1, __ATOMIC_ACQUIRE); } while (old ! 0); return; } } void spin_unlock(spinlock_t *l) { __atomic_store_n(l-lock, 0, __ATOMIC_RELEASE); }这里用了GCC的原子内置函数编译的时候需要加-marchrv32imac或者rv64imac确保A扩展被启用。如果你的核心没有A扩展那就只能用关中断来模拟临界区但那样在多核环境下是不安全的。注意多核启动时从核的栈必须和主核的栈分开不能共用。我一般给每个核心分配独立的栈空间在链接脚本里定义_stack_top_core0、_stack_top_core1等符号从核入口汇编里加载对应的栈顶地址。6. 常见问题与排查技巧实录6.1 程序烧进去不跑怎么定位这是最常见的问题原因可能有很多。我的排查顺序是这样的先用调试器连上看PC停在哪个地址。如果PC停在复位向量附近说明CPU根本没跳转到_start可能是复位向量配置错了或者Flash的启动模式没选对。如果PC停在_start里某条指令上说明卡在那条指令了常见的是la指令加载的地址不对或者访问了未映射的内存。如果调试器连不上那就用最笨的办法在_start的第一条指令处翻转一个GPIO用示波器或者LED看有没有反应。如果有反应说明CPU至少执行到了这里如果没有说明连第一条指令都没执行问题出在硬件层面比如时钟没配置、复位没释放、Flash没使能。6.2 .data段拷贝后全局变量值还是不对这个问题我遇到过两次一次是链接脚本里.data段的LMA和VMA写反了另一次是启动汇编里拷贝的源地址用了_data_start而不是_data_load。排查方法很简单在main里打印_data_load、_data_start、_data_end三个符号的地址看看是否符合预期。_data_load应该在Flash区域_data_start和_data_end应该在RAM区域而且_data_end - _data_start应该等于.data段的大小。还有一个隐蔽的坑如果.data段的大小不是4字节的整数倍字拷贝会多拷贝几个字节可能覆盖.bss段开头的变量。解决办法是在链接脚本里把.data段按4字节对齐或者在启动汇编里用字节拷贝。6.3 多核启动时从核跑飞从核跑飞的原因通常有三个入口地址写错了、栈指针没设置、从核被唤醒时主核还没初始化完共享资源。排查的时候先在从核入口处翻转一个独立的GPIO确认从核有没有跳转到入口。如果没有检查主核写入口地址的寄存器偏移是否正确。如果有检查从核的栈指针是否指向了合法的RAM区域。还有一个容易忽略的点从核的缓存一致性。如果从核和主核共享内存但缓存没有维护一致性从核可能读到旧的指令或数据。解决办法是在唤醒从核之前主核执行一次缓存刷新操作确保从核看到的内存内容是最新的。6.4 常见问题速查表现象可能原因排查方法上电后无反应复位向量错误、时钟未配置检查复位向量地址、测量时钟输出PC停在_start栈指针未设置、访问非法地址单步调试检查sp值和内存映射全局变量值随机.data段未拷贝、链接脚本错误打印_data_load/_data_start地址从核不启动入口地址错误、控制寄存器未写检查从核入口GPIO、读控制寄存器从核跑飞栈指针未设置、缓存不一致检查从核sp值、执行缓存刷新中断不触发mtvec未配置、mstatus未使能读mtvec和mstatus寄存器6.5 几个实用的调试技巧第一个技巧在启动汇编里尽早初始化串口这样你可以在main之前就打印调试信息。串口初始化代码很短通常就是配置波特率分频器和使能收发放在.data段拷贝之前也没问题因为串口寄存器是直接映射的不依赖内存。第二个技巧用ebreak指令做断点。RISC-V的ebreak指令会触发断点异常如果你接了调试器就会停在那里如果没有调试器可以在异常处理里打印信息。我经常在关键路径上插ebreak配合调试器快速定位问题。第三个技巧把启动汇编里的关键步骤用GPIO翻转标记出来。比如拷贝.data段之前翻转GPIO0拷贝之后翻转GPIO1清零.bss段之前翻转GPIO2之后翻转GPIO3。用逻辑分析仪抓波形就能精确知道每个步骤花了多长时间有没有卡住。7. 从启动流程延伸出去的几个方向启动流程跑通之后下一步通常就是移植RTOS或者跑Linux。如果你打算移植FreeRTOS启动汇编里需要额外配置mtime和mtimecmp寄存器用于系统时钟节拍。FreeRTOS的RISC-V移植层会接管trap_entry你需要把中断向量表的配置从启动汇编里移出来交给RTOS初始化。如果你打算跑Linux那启动流程就更复杂了需要实现SBISupervisor Binary Interface层处理机器模式下的 trap然后跳转到Supervisor模式。不过那是另一个话题了裸机启动流程是这一切的基础把这部分搞扎实后面的路会好走很多。我个人在实际操作中的体会是RISC-V裸机启动流程看起来繁琐但每一步都有明确的理由。链接脚本管布局启动汇编管环境C运行时管初始化多核启动管协同。把这四块拆开理解再合起来调试基本上没有搞不定的启动问题。最怕的是不求甚解从别处抄一段启动代码跑通了就完事一旦换个芯片或者换个编译器又得从头再来。