资讯动态

RISC-V裸机启动全解析:链接脚本、多核竞争与启动汇编

发布时间:2026/10/8 18:30:21 来源:尧图企业网站定制
1. 为什么 bare-metal 启动值得单独拎出来讲很多人学 RISC-V 是从写汇编点灯开始的写完gpio_set(1)看到灯亮了就觉得“启动不过如此”。但真正到了自己画板子、自己写链接脚本、自己处理多核竞争的时候才发现启动流程是整个系统里最容易翻车、也最考验基本功的一环。裸机bare-metal环境下没有操作系统帮你兜底没有 MMU 帮你做地址映射甚至连栈指针都得你自己设——CPU 上电那一刻它不知道你的代码在哪、数据在哪、栈在哪全靠你通过链接脚本和启动代码告诉它。这篇内容围绕 RISC-V 的启动与 bare-metal 流程展开核心会覆盖链接脚本的编写逻辑、启动汇编的执行顺序、多核启动的竞争处理、以及从复位向量到 C 语言main之间的完整链路。适合已经能跑通简单裸机程序、但对自己写的启动代码“知其然不知其所以然”的开发者也适合从 ARM 平台迁移过来、想搞清楚 RISC-V 启动差异的工程师。我会尽量把每个设计决策背后的“为什么”讲透而不是甩一段汇编让你自己悟。RISC-V 的启动和 ARM 有个本质区别ARM 的复位向量地址通常是固定的比如 Cortex-M 从 0x00000000 取栈顶和复位向量而 RISC-V 把复位地址做成了可配置的。这个设计给了 SoC 厂商很大的自由度但也意味着你拿到的每一块 RISC-V 板子启动地址可能都不一样。这个差异直接影响了链接脚本怎么写、启动代码怎么定位后面会详细展开。2. 启动流程的整体设计与地址空间规划2.1 从复位向量到第一条指令RISC-V 规范里复位后的行为定义得比较“克制”。CPU 从某个实现定义的地址开始取指这个地址叫复位向量reset vector。常见的取值有几类0x00000000、0x80000000、0x1000 等具体取决于 SoC 设计。比如很多基于 SiFive 核心的芯片复位地址在 0x1004 或者 0x80000000而一些 FPGA 上的软核可能把复位向量放在 0x00000000。这里有个容易踩的坑复位向量地址不一定是 0。我见过有人照着某块开发板的链接脚本抄把.text段起始地址写成 0x80000000结果换了一块复位地址在 0x0 的板子程序根本跑不起来因为 CPU 上电后从 0x0 取指那里是空的。所以第一步永远是查你手上这颗芯片的数据手册确认复位向量地址。复位后 CPU 处于M 模式Machine Mode这是 RISC-V 权限等级里最高的。此时中断全局关闭MMU如果实现了关闭缓存状态取决于具体实现。你需要在启动代码里完成一系列初始化才能安全地跳到 C 语言环境。2.2 链接脚本的核心作用链接脚本linker script在 bare-metal 里的地位相当于操作系统的“内存规划局”。它决定了代码段、数据段、BSS 段、栈、堆分别放在哪个地址以及各个段的排列顺序。对于裸机程序链接脚本通常要处理这几件事入口点定义通过ENTRY()指定入口符号通常是_start。段布局.text代码、.rodata只读数据、.data已初始化全局变量、.bss未初始化全局变量的地址分配。栈和堆的预留通过符号标记栈顶、堆起始位置。对齐要求RISC-V 对指令对齐有要求尤其是压缩指令集C 扩展下指令可以是 2 字节对齐但跳转目标通常要求 4 字节对齐。一个典型的链接脚本骨架长这样OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN 0x80000000, LENGTH 128M } SECTIONS { .text : { *(.text.init) *(.text .text.*) } RAM .rodata : { *(.rodata .rodata.*) } RAM .data : { *(.data .data.*) } RAM .bss : { *(.bss .bss.*) *(COMMON) } RAM . ALIGN(16); _stack_top .; . . 0x10000; _stack_bottom .; }这里有几个细节值得说。*(.text.init)单独拎出来放在最前面是为了确保启动代码一定在段的起始位置这样复位向量跳过来就能直接执行。_stack_top和_stack_bottom这两个符号会被启动汇编引用用来设置sp寄存器。栈的大小 0x1000064KB是个经验值实际项目里要根据中断嵌套深度和函数调用层级调整太小吃中断会直接踩内存。2.3 为什么启动代码要用汇编写C 语言函数调用依赖栈而栈指针sp在复位时是未定义的。所以在设置好sp之前你不能安全地执行任何 C 代码。这就是启动代码必须用汇编写的原因——它是“鸡生蛋”问题C 需要栈栈需要汇编来设。启动汇编通常放在一个.S文件里大写 S 表示需要预处理器核心任务按顺序是关闭全局中断复位时本来就是关的但显式再关一次更稳妥。设置栈指针sp。清除.bss段。把.data段从 Flash 拷贝到 RAM如果代码存在 Flash 里。设置gp全局指针和tp线程指针如果链接器 relaxation 需要的话。跳到main。这个顺序不能乱。比如清 BSS 必须在跳 main 之前否则 C 代码里那些“默认值为 0”的全局变量就是随机值。而设置gp必须在任何可能用到全局变量的代码之前否则链接器优化后的代码会访问错误的地址。3. 核心细节解析与实操要点3.1 栈指针设置的正确姿势设置sp看起来简单但有个对齐的坑。RISC-V 的 ABI 要求栈指针 16 字节对齐。如果你设的栈顶地址不是 16 字节对齐的某些编译器生成的向量化指令或者long double操作可能会触发异常。所以链接脚本里_stack_top之前最好加一句. ALIGN(16);。另一个问题是栈的生长方向。RISC-V 的栈是向下生长的也就是压栈时sp减小。所以_stack_top应该是栈区域的高地址sp初始化为_stack_top然后随着函数调用逐渐向低地址移动。我见过有人把sp设成栈底结果第一次压栈就踩到了 BSS 段这种 bug 用调试器看半天都未必能找到。la sp, _stack_top用laload address伪指令而不是li是因为_stack_top是个链接期才确定的符号la会被汇编器展开成auipcaddi的组合能正确处理任意 32 位地址。3.2 BSS 段清除的边界处理清 BSS 的标准写法是用_bss_start和_bss_end两个符号做循环la t0, _bss_start la t1, _bss_end bgeu t0, t1, bss_done bss_loop: sd zero, (t0) addi t0, t0, 8 bltu t0, t1, bss_loop bss_done:这里用bgeu和bltu无符号比较而不是有符号版本是因为地址是无符号数。如果 BSS 段跨越了 0x80000000 这种高位地址有符号比较会出错。按 8 字节sd清零比按字节清零快但前提是 BSS 起始和结束地址都是 8 字节对齐的。链接脚本里给.bss段加ALIGN(8)就能保证这一点。注意如果 BSS 段很大比如几百 KB这个循环会消耗不少启动时间。在对启动速度敏感的场景比如需要快速响应的工业控制可以考虑用 DMA 或者硬件清零模块来加速但大多数场景下软件循环就够了。3.3 DATA 段拷贝的两种场景.data段的处理取决于你的存储介质。如果代码直接跑在 RAM 里比如通过调试器加载或者从 BootROM 拷贝到 RAM那.data已经在正确的位置了不需要拷贝。但如果代码存在 Flash 里.data段的初始值也在 Flash 里运行时需要拷贝到 RAM。链接脚本里通常这样处理.data : AT(ADDR(.text) SIZEOF(.text)) { _data_start .; *(.data .data.*) _data_end .; } RAM _data_load LOADADDR(.data);AT()指定了加载地址在 Flash 里LOADADDR取出这个地址。启动汇编里用_data_load作为源_data_start作为目的_data_end作为结束做一次内存拷贝。这个拷贝逻辑和清 BSS 类似但源和目的不同。3.4 多核启动的竞争与仲裁多核 RISC-V 芯片的启动是个经典难题。上电后所有核心同时从复位向量取指如果每个核心都去清 BSS、初始化外设就会产生竞争——两个核心同时写同一个外设寄存器结果不可预测。常见的处理策略有两种策略一主从模式Master-Slave。用mhartid寄存器读取当前核心的 ID只有 ID 为 0 的核心执行完整初始化其他核心进入等待循环WFI等主核初始化完成后通过 IPI核间中断或者共享内存标志唤醒。csrr t0, mhartid bnez t0, secondary_hart_wait # 主核继续初始化 ... secondary_hart_wait: wfi j secondary_hart_wait策略二各自初始化私有资源。每个核心只初始化自己私有的东西比如自己的栈、自己的中断控制器共享资源由主核统一初始化。这种模式适合核心间耦合度低的场景。mhartid是 M 模式下的只读 CSR每个核心读到的值不同。但要注意mhartid的编号不一定是连续的也不一定从 0 开始具体取决于硬件实现。所以判断“是不是主核”最好用mhartid 0而不是“第一个执行到的核心”。实操心得多核启动时从核的等待循环里最好加一点延迟或者用 WFI 而不是忙等busy-wait。忙等会让从核持续占用总线带宽影响主核的初始化速度。WFI 让核心进入低功耗状态等中断唤醒既省电又减少总线竞争。4. 实操过程与核心环节实现4.1 完整启动汇编的逐行拆解下面是一个可以直接参考的启动汇编模板我按执行顺序逐段说明.section .text.init .globl _start _start: # 1. 关闭全局中断 csrw mie, zero csrw mip, zero # 2. 读取核心 ID非 0 核心进入等待 csrr t0, mhartid bnez t0, secondary_wait # 3. 设置栈指针 la sp, _stack_top # 4. 清除 BSS la t0, _bss_start la t1, _bss_end bgeu t0, t1, bss_done bss_clear: sd zero, (t0) addi t0, t0, 8 bltu t0, t1, bss_clear bss_done: # 5. 拷贝 DATA 段 la t0, _data_load la t1, _data_start la t2, _data_end bgeu t1, t2, data_done data_copy: ld t3, (t0) sd t3, (t1) addi t0, t0, 8 addi t1, t1, 8 bltu t1, t2, data_copy data_done: # 6. 设置 gp全局指针 .option push .option norelax la gp, __global_pointer$ .option pop # 7. 跳转到 main call main # 8. main 返回后进入死循环 halt: wfi j halt secondary_wait: wfi j secondary_wait第 6 步的.option norelax是个关键细节。链接器 relaxation 会把la gp, __global_pointer$优化成addi gp, gp, 0之类的短指令但此时gp还是未初始化的值优化后反而出错。用.option push/pop临时关闭 relaxation确保la被正确展开。4.2 链接脚本的完整配置与参数计算结合上面的启动汇编链接脚本需要提供这些符号_stack_top、_bss_start、_bss_end、_data_load、_data_start、_data_end、__global_pointer$。OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x20000000, LENGTH 4M RAM (rwx) : ORIGIN 0x80000000, LENGTH 128M } SECTIONS { .text : { *(.text.init) *(.text .text.*) . ALIGN(8); } FLASH .rodata : { *(.rodata .rodata.*) . ALIGN(8); } FLASH .data : { _data_start .; *(.data .data.*) . ALIGN(8); _data_end .; } RAM AT FLASH _data_load LOADADDR(.data); .bss : { _bss_start .; *(.bss .bss.*) *(COMMON) . ALIGN(8); _bss_end .; } RAM . ALIGN(16); _stack_top .; . . 0x10000; _stack_bottom .; __global_pointer$ _data_start 0x800; }__global_pointer$的偏移 0x800 是个经验值。RISC-V 的gp相对寻址范围是 ±2KB把gp设在.data段起始 0x800 的位置可以让前后各 2KB 范围内的全局变量都能用gp相对寻址访问减少指令数。如果你的全局变量特别多超过了 4KB 范围超出的部分链接器会自动生成绝对地址访问不影响正确性只是代码稍微大一点。4.3 多核启动的完整实现多核场景下从核的唤醒通常有两种方式。一种是主核初始化完成后通过写msip寄存器机器模式软件中断挂起给从核发 IPI。从核在 WFI 状态下收到中断后醒来跳转到指定的入口。secondary_wait: # 从核设置自己的栈 la sp, _secondary_stack_top # 等待主核的 IPI wfi # 醒来后跳转到从核入口 call secondary_main j secondary_wait主核这边void wake_secondary_harts(void) { for (int i 1; i NUM_HARTS; i) { // 向从核发送 IPI *(volatile uint32_t *)(CLINT_MSIP_BASE i * 4) 1; } }CLINTCore Local Interruptor的msip寄存器每个核心一个写 1 触发软件中断。从核收到中断后mip.MSIP置位如果mie.MSIE使能且全局中断打开就会跳转到mtvec指向的异常处理入口。注意从核的栈必须和主核分开否则两个核心同时压栈会互相踩。链接脚本里要给每个从核预留独立的栈空间或者用动态分配的方式在运行时给从核分配栈。4.4 从复位到 main 的时序验证写完启动代码后怎么验证它真的按预期执行了最直接的方法是用调试器单步跟踪但更高效的方式是在关键节点翻转 GPIO 或者写调试寄存器用逻辑分析仪或者示波器看时序。我通常会在启动代码里插几个“标记点”# 标记点 1复位后立即翻转 GPIO li t0, GPIO_BASE li t1, 0x1 sw t1, 0(t0) # ... 初始化代码 ... # 标记点 2BSS 清除完成后翻转另一个 GPIO li t1, 0x2 sw t1, 0(t0)用示波器看这两个 GPIO 的翻转时间差就能知道 BSS 清除花了多久。如果时间异常长可能是 BSS 段太大或者循环效率有问题。这种方法比在调试器里数指令周期直观得多也更接近真实运行环境。5. 常见问题与排查技巧实录5.1 启动失败的症状与对应原因症状可能原因排查方法上电后无任何反应复位向量地址错误查数据手册确认复位地址检查链接脚本.text起始地址程序跑飞PC 跳到非法地址栈指针未设置或对齐错误在启动汇编第一条指令后设断点检查sp值全局变量初值不对DATA 段未拷贝或拷贝范围错误检查_data_load、_data_start、_data_end符号地址多核场景下随机死机多核竞争共享资源确认从核是否在等待循环中检查共享外设的访问是否加锁中断触发后死机mtvec未设置或中断向量表未对齐检查mtvec的 MODE 字段和 BASE 字段对齐要求5.2 链接脚本符号地址的验证方法链接脚本里的符号地址对不对不用等到运行时才发现。编译完成后用riscv64-unknown-elf-nm或者objdump看符号表riscv64-unknown-elf-nm build/project.elf | grep -E _bss_start|_bss_end|_data_start|_stack_top输出会显示每个符号的地址。检查_bss_start是否小于_bss_end_stack_top是否在 RAM 范围内且 16 字节对齐。如果_bss_start和_bss_end相等说明 BSS 段是空的这本身没问题但如果你明明定义了未初始化全局变量那就要检查链接脚本里.bss段的收集规则是不是写错了。5.3 多核启动的调试技巧多核调试最头疼的是“不知道哪个核心在跑”。一个实用的技巧是给每个核心分配不同的 GPIO 引脚在启动代码里各自翻转csrr t0, mhartid # 根据 hartid 选择不同的 GPIO slli t0, t0, 2 li t1, GPIO_BASE add t1, t1, t0 li t2, 0x1 sw t2, 0(t1)这样用示波器或者逻辑分析仪一看就知道哪些核心起来了、起来的顺序是什么。如果某个核心的 GPIO 一直没翻转说明它卡在了前面的某一步。另一个技巧是在从核的等待循环里加一个计数器主核可以通过共享内存读取这个计数器判断从核是否真的在等待。如果计数器不增长说明从核可能已经跑飞了。5.4 启动代码的优化经验启动代码虽然短但优化空间不小。几个我实际用过的技巧BSS 清零用sd而不是sb在 64 位平台上按 8 字节清零比按字节清零快 8 倍。前提是地址对齐。DATA 拷贝用批量加载如果支持向量扩展可以用向量指令一次拷贝多个字节。但要注意向量扩展在启动早期的可用性有些芯片需要先配置才能用。栈的大小按需分配不要无脑给 64KB根据最坏情况下的中断嵌套深度和函数调用链计算。省下来的 RAM 可以给堆或者数据段用。从核等待用 WFI 而不是忙等忙等会让从核持续取指占用指令缓存和总线带宽。WFI 让核心进入低功耗状态等中断唤醒。踩过的坑有一次在从核等待循环里用了j secondary_wait而不是wfi; j secondary_wait结果从核疯狂取指把指令缓存全占了主核的初始化代码频繁缓存缺失启动时间多了将近一倍。改成 WFI 后恢复正常。6. 启动流程的扩展与进阶方向6.1 从 bare-metal 到 RTOS 的过渡bare-metal 启动流程跑通之后下一步通常是引入 RTOS。RTOS 的启动和 bare-metal 有个关键区别RTOS 需要为每个任务维护独立的栈和上下文所以启动代码里除了设置主栈还要初始化任务栈和调度器。以 FreeRTOS 为例main函数里会调用xTaskCreate创建任务然后调用vTaskStartScheduler启动调度器。调度器启动时会配置第一个定时器中断通常是机器模式定时器mtime然后触发第一次上下文切换。启动代码需要确保mtvec已经指向正确的异常处理入口并且mie.MTIE机器定时器中断使能已经打开。6.2 安全启动的考量在一些对安全性有要求的场景启动流程还需要考虑固件签名验证、安全启动链等。RISC-V 的 M 模式提供了物理内存保护PMP机制可以在启动早期配置 PMP 区域限制后续代码的访问权限。比如把 BootROM 区域设为只读可执行把数据区域设为不可执行防止代码注入攻击。PMP 的配置需要在 M 模式下完成而且一旦锁定pmpcfg的L位置 1直到下次复位前都不能修改。所以配置顺序很重要先配置所有需要的区域最后再锁定。6.3 启动时间的测量与优化对启动时间敏感的场景比如汽车电子要求 100ms 内启动完成需要精确测量每个阶段的耗时。RISC-V 的mcycleCSR 提供了一个周期计数器可以在启动代码的关键节点读取csrr t0, mcycle # ... 某个初始化阶段 ... csrr t1, mcycle sub t2, t1, t0 # t2 就是这段代码消耗的周期数把各阶段的周期数打印出来就能定位启动时间的瓶颈。常见的瓶颈包括BSS 段过大导致清零时间长、DATA 段拷贝量大、外设初始化等待超时等。针对性地优化这些环节通常能把启动时间压缩 30% 以上。6.4 不同 RISC-V 核心的启动差异虽然 RISC-V 的启动流程在概念上是一致的但不同厂商的核心在细节上有差异。比如SiFive 系列复位向量通常在 0x1004BootROM 会做一些基础初始化后再跳转到用户代码。平头哥系列复位向量和启动流程有自己的规范需要参考具体芯片的手册。开源核心如 Rocket、CVA6复位向量可以通过参数配置灵活性更高但也意味着没有统一的默认值。迁移代码时最需要关注的是复位向量地址、CSR 的可用性有些核心可能没实现某些 CSR、以及多核的启动方式是同时启动还是主核先启动。这些差异在数据手册里通常都有说明但容易被忽略。我个人在实际操作中的体会是启动代码这东西写一次能管很久但每次换芯片都得重新核对一遍地址和 CSR。最省事的做法是把启动代码做成可配置的模板把复位向量地址、栈大小、核心数量这些参数抽出来换芯片时只改配置不改逻辑。这样既减少了重复劳动也降低了出错概率。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑