ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RISC-V裸机启动全解析:从复位向量到多核工程实践

RISC-V裸机启动全解析:从复位向量到多核工程实践 1. 从复位向量到第一条指令RISC-V 上电后到底发生了什么很多人第一次接触 RISC-V 的 bare-metal 开发都会有一个错觉以为写完main函数、编译烧录进去芯片就会乖乖跑起来。结果上电之后串口一片安静调试器连上去发现 PC 指针停在一个莫名其妙的地址或者干脆卡在某个死循环里出不来。这种代码没错但就是跑不起来的情况十有八九是启动流程没搞明白。RISC-V 和 ARM 在启动这件事上有一个本质区别ARM 有相对固定的异常向量表布局和厂商约定俗成的启动约定而 RISC-V 把上电后第一条指令从哪来这件事交给了具体实现。规范里只定义了复位时 hart硬件线程应该跳转到某个实现相关的复位向量地址至于这个地址是 0x0、0x80000000 还是别的什么完全取决于芯片设计。这就意味着做 RISC-V bare-metal 开发第一件事不是写业务代码而是搞清楚你手上这颗芯片的复位行为。1.1 复位向量的两种典型布局实际项目里最常见的复位向量布局有两种。第一种是固定地址型比如很多 MCU 级别的 RISC-V 核如 GD32VF103、CH32V 系列复位后 PC 直接指向 0x00000000 或 0x08000000这个地址通常映射到 Flash 或 Boot ROM。第二种是可配置型比如 SiFive 的很多核、平头哥的 C906/C910复位向量地址由硬件引脚或一次性可编程区域决定常见的是 0x80000000 这类 DDR 或 SRAM 起始地址。为什么这个区别很重要因为它直接决定了你的链接脚本里.text段的起始地址怎么写。如果你把代码链接到 0x80000000但芯片复位后实际跳到 0x0那 CPU 取到的就是一片空白或者随机数据表现为程序根本没跑。我见过太多人在这里卡一整天反复检查编译选项最后发现是链接地址和复位向量对不上。提示拿到一块新板子先别急着写代码。用调试器连上去复位后单步执行一条指令看 PC 落在哪个地址这个地址就是你链接脚本的起点。1.2 上电到 C 语言环境就绪的完整链路从复位到能安全执行 C 代码中间要跨过好几道坎。复位瞬间CPU 处于机器模式M-mode此时通用寄存器全是未定义值sp栈指针没有指向任何合法内存.data段还在 Flash 里没有搬到 RAM.bss段对应的 RAM 区域是随机值不是 0中断控制器、时钟、内存控制器可能都还没初始化所以启动代码通常叫startup.S或crt0.S要按顺序做这几件事设置栈指针、搬移.data、清零.bss、设置mtvec异常向量基址、初始化必要的 CSR、最后跳转到main。这个顺序不能乱尤其是栈指针必须在任何函数调用之前设好否则第一次压栈就会写到非法地址。# 典型的 RISC-V 启动汇编片段示意 .section .text.start .globl _start _start: # 1. 设置栈指针_stack_top 由链接脚本定义 la sp, _stack_top # 2. 搬移 .data 段从 Flash 的 _data_lma 到 RAM 的 _data_start la a0, _data_start la a1, _data_end la a2, _data_lma copy_data: bgeu a0, a1, clear_bss lw t0, 0(a2) sw t0, 0(a0) addi a0, a0, 4 addi a2, a2, 4 j copy_data # 3. 清零 .bss 段 clear_bss: la a0, _bss_start la a1, _bss_end li t0, 0 zero_bss: bgeu a0, a1, jump_main sw t0, 0(a0) addi a0, a0, 4 j zero_bss jump_main: call main # main 返回后进入死循环防止跑飞 1: j 1b这段代码看着简单但每一行背后都有讲究。比如搬移.data用的是字4 字节为单位如果你的段起始地址不是 4 字节对齐lw/sw就会触发地址非对齐异常。再比如_stack_top的定义它必须指向一块真实存在的 RAM而且栈是向下增长的所以_stack_top应该是 RAM 的高地址端。1.3 为什么 bare-metal 不能直接照搬 Linux 的启动思路有人会想Linux 启动不也是从汇编到 C 吗能不能参考答案是思路可以借鉴但细节完全不能照搬。Linux 的启动代码假设自己是被 bootloader 加载的很多环境已经就绪而且它运行在 S-mode 或 M-mode 下有完整的设备树描述硬件。bare-metal 什么都没有硬件信息全靠你自己在代码里写死或者通过链接脚本约定。更关键的是Linux 的启动流程里有大量和虚拟内存、页表、多核调度相关的东西这些在 bare-metal 场景下要么不需要要么需要你自己从零实现。所以我的建议是bare-metal 的启动代码就老老实实按设栈、搬数据、清 bss、跳 main这个最小闭环来写不要引入任何不必要的抽象。等这个闭环跑通了再往上加功能。2. 链接脚本bare-metal 项目里最容易被忽视却最致命的一环如果说启动汇编是 bare-metal 的骨架那链接脚本就是决定这副骨架长什么样的图纸。我见过太多项目代码逻辑写得漂漂亮亮结果因为链接脚本里一个段的对齐没写对或者 LMA 和 VMA 搞混了导致程序在特定条件下才崩溃排查起来极其痛苦。2.1 LMA 与 VMA理解这两个概念就理解了一半链接脚本里最容易让人懵的就是 LMALoad Memory Address和 VMAVirtual Memory Address。用大白话解释VMA 是程序运行时以为自己所在的地址LMA 是程序实际被存放的地址。在 bare-metal 场景下.data段的 VMA 是 RAM 地址因为变量要在 RAM 里读写但它的 LMA 是 Flash 地址因为烧录时数据存在 Flash 里。启动代码要做的搬移 .data本质就是把数据从 LMA 复制到 VMA。/* 典型的 bare-metal 链接脚本结构 */ MEMORY { FLASH (rx) : ORIGIN 0x80000000, LENGTH 512K RAM (rwx) : ORIGIN 0x80080000, LENGTH 128K } SECTIONS { .text : { *(.text.start) /* 启动代码放最前面 */ *(.text*) *(.rodata*) } FLASH .data : { _data_start .; *(.data*) _data_end .; } RAM AT FLASH /* VMA 在 RAMLMA 在 FLASH */ _data_lma LOADADDR(.data); .bss : { _bss_start .; *(.bss*) *(COMMON) _bss_end .; } RAM _stack_top ORIGIN(RAM) LENGTH(RAM); }这里有几个细节值得展开。第一.text.start必须放在最前面因为复位向量指向的就是 Flash 起始地址如果启动代码不在那儿CPU 取到的就是别的函数的指令。第二_data_lma LOADADDR(.data)这行是给启动汇编用的它告诉汇编代码数据实际存在哪。第三_stack_top直接定义在 RAM 末尾简单粗暴但有效。2.2 段对齐与访问宽度一个字节引发的血案RISC-V 对内存访问的对齐要求比 x86 严格得多。x86 允许非对齐访问性能略降但能跑RISC-V 默认会触发异常。这意味着如果你的.data段起始地址不是 4 字节对齐启动代码里的lw指令就会直接抛异常。我在一个项目里就踩过这个坑链接脚本里.data段前面有个 1 字节的标记段导致.data起始地址变成了奇数。程序在搬移数据时直接卡死调试器显示异常代码是load address misaligned。解决办法很简单在段定义里加ALIGN(4).data : ALIGN(4) { _data_start .; *(.data*) _data_end .; } RAM AT FLASH注意不只是.data任何会被lw/sw/lh/sh访问的段都要保证对齐。栈指针也要 16 字节对齐RISC-V ABI 要求否则调用某些库函数时会出问题。2.3 用objdump验证链接结果链接脚本写完不能靠猜一定要用工具验证。riscv64-unknown-elf-objdump -h可以列出所有段的 VMA、LMA、大小和对齐。重点看三件事.text的起始地址是否等于复位向量、.data的 LMA 是否在 Flash 范围内、各段是否有意外的重叠。$ riscv64-unknown-elf-objdump -h firmware.elf Sections: Idx Name Size VMA LMA Algn 0 .text 00001234 80000000 80000000 2**2 1 .data 00000200 80080000 80001234 2**2 2 .bss 00000400 80080200 80080200 2**2从这份输出能读出很多信息.text从 0x80000000 开始说明复位向量设对了.data的 VMA 是 0x80080000RAMLMA 是 0x80001234Flash说明搬移逻辑有据可依对齐都是 2 的幂次符合要求。养成每次改完链接脚本就跑一遍objdump的习惯能省下大量调试时间。3. 多核启动RISC-V bare-metal 里最考验架构设计的部分单核启动跑通之后下一步几乎必然会遇到多核。RISC-V 的多核启动和 x86 那种所有核同时抢总线的模型不一样它有一个明确的约定上电后所有 hart 都会从复位向量开始执行但通常只有一个 hart 被指定为主核boot hart其余 hart 需要被主核唤醒或主动进入等待状态。3.1 主从核的识别与分工问题来了代码是同一份所有 hart 都从_start开始跑怎么区分谁是主核常见做法有两种。第一种是读mhartidCSR约定 hart 0 是主核_start: csrr t0, mhartid bnez t0, secondary_hart_wait # 非 0 号核跳到等待分支 # 主核继续执行初始化 la sp, _stack_top ...第二种是通过硬件寄存器或设备树传递主核信息这种方式更灵活但依赖具体平台。对于大多数 bare-metal 项目用mhartid判断就够了。从核的处理策略也有讲究。最简单的做法是让从核进入 WFIWait For Interrupt循环等主核初始化完硬件后通过软件中断如 CLINT 的 MSIP 寄存器唤醒它们。这种主核先跑从核后跟的模式能避免多个核同时初始化同一份外设导致的竞争。3.2 每个 hart 独立的栈空间分配多核场景下最容易出 bug 的地方是栈。如果所有 hart 共用同一个_stack_top两个核同时压栈就会互相覆盖数据表现为随机崩溃而且极难复现。正确做法是给每个 hart 分配独立的栈区域/* 假设最多 4 个 hart每个 4KB 栈 */ _stack_base ORIGIN(RAM) LENGTH(RAM) - 0x4000;secondary_hart_wait: # 每个 hart 根据自己的 mhartid 计算栈顶 csrr t0, mhartid li t1, 0x1000 # 每个栈 4KB mul t0, t0, t1 la sp, _stack_base add sp, sp, t0 # 进入等待循环 wait_loop: wfi j wait_loop这里mul指令在有些精简核上可能没有比如 RV32I 不含 M 扩展那就得用移位或循环加法代替。这也是 bare-metal 开发的一个特点你得清楚目标核支持哪些指令扩展不能想当然地用。3.3 核间同步与内存屏障主核初始化完共享资源后要通知从核可以开始工作。这个通知动作涉及核间同步必须考虑内存序问题。RISC-V 是弱内存模型主核写的数据从核不一定能立刻看到所以需要插入fence指令// 主核初始化共享数据后 shared_flag 1; __asm__ volatile(fence rw, rw ::: memory); // 然后触发软件中断唤醒从核从核被唤醒后也要先执行fence再读共享数据确保看到的是最新值。我见过一个项目从核偶尔读到旧的配置数据查了两天才发现是缺了内存屏障。这种问题在单核上永远不会出现一到多核就变成偶发故障非常折磨人。提示多核调试时如果条件允许先用单核模式把逻辑跑通再逐步开启从核。这样能把逻辑错误和并发错误分开定位。4. 从零搭建一个可复现的 bare-metal 工程骨架前面讲的是原理这一节把整个流程串起来给出一套可以直接抄的工程结构。这套结构我在好几个 RISC-V 平台上用过改改地址就能移植。4.1 目录结构与构建流程project/ ├── linker/ │ └── firmware.ld ├── src/ │ ├── startup.S │ ├── main.c │ └── uart.c ├── include/ │ └── regs.h └── Makefile构建流程用 Makefile 管理核心是编译、链接、生成反汇编和二进制三个步骤CROSS riscv64-unknown-elf- CFLAGS -marchrv32imac -mabiilp32 -nostdlib -ffreestanding -O2 LDFLAGS -T linker/firmware.ld -nostartfiles all: firmware.elf firmware.bin firmware.dump firmware.elf: $(OBJS) $(CROSS)gcc $(LDFLAGS) -o $ $^ firmware.bin: firmware.elf $(CROSS)objcopy -O binary $ $ firmware.dump: firmware.elf $(CROSS)objdump -d $ $-nostdlib和-ffreestanding这两个选项必须加否则编译器会尝试链接标准库而 bare-metal 环境根本没有。-march和-mabi要和目标核严格匹配写错了要么编译不过要么生成的指令核不支持。4.2 最小可运行示例点亮一个 GPIO理论说再多不如跑一个实际例子。下面是一个最小化的 main 函数假设目标平台有一个内存映射的 GPIO 寄存器#include stdint.h #define GPIO_BASE 0x10012000UL #define GPIO_OUTPUT (*(volatile uint32_t *)(GPIO_BASE 0x00)) #define GPIO_DIR (*(volatile uint32_t *)(GPIO_BASE 0x04)) int main(void) { // 配置为输出 GPIO_DIR | (1 0); while (1) { GPIO_OUTPUT ^ (1 0); // 翻转 for (volatile int i 0; i 100000; i); } return 0; }这段代码里volatile是关键。没有它编译器会认为循环里的空操作没意义直接优化掉或者把 GPIO 读写合并成一次。bare-metal 里所有寄存器访问都必须加volatile这是铁律。4.3 验证启动流程是否正确的检查清单烧录之前按这个清单过一遍能挡掉大部分低级错误检查项正确做法常见错误复位向量与链接脚本.text起始地址一致链接到 0x80000000 但芯片从 0x0 启动栈指针指向 RAM 高地址且对齐未设置或指向 Flash.data搬移LMA 到 VMA 完整复制只搬了部分或方向搞反.bss清零全部清零漏掉COMMON段段对齐4 字节对齐奇数地址导致非对齐异常多核栈每核独立共用栈导致随机崩溃这张表里的每一项我都至少踩过一次。尤其是.bss漏掉COMMON段这个因为COMMON段存放的是未初始化的全局变量如果没清零这些变量的初值就是 RAM 里的随机值程序行为完全不可预测。5. 调试 bare-metal 启动问题的实战思路bare-metal 最难受的地方在于没有操作系统兜底出了问题没有日志、没有异常处理只能靠调试器和 LED。但正因为如此掌握一套系统的排查方法就特别重要。5.1 用调试器单步跟踪复位后的前几条指令程序跑不起来时第一步永远是连调试器复位然后单步。看 PC 是否落在预期的复位向量地址看第一条指令是不是你的_start。如果不是说明链接地址或复位向量配置有问题这时候改代码没用得改链接脚本或启动配置。如果 PC 对了但执行几条就飞了重点看栈指针。在la sp, _stack_top执行后读一下sp的值确认它指向 RAM 范围内且是 16 字节对齐。我遇到过一次_stack_top定义成了 RAM 起始地址结果栈向下增长直接越界到 Flash 区域一压栈就异常。5.2 没有串口时如何定位问题很多早期板子串口还没调通这时候可以用GPIO 打点法在启动流程的每个关键节点翻转一个 GPIO用示波器或逻辑分析仪看波形。比如设栈后翻转一次、搬完.data后翻转一次、清完.bss后翻转一次。如果波形停在某个点就知道问题出在哪一步。// 用内存映射地址直接操作不依赖任何驱动 #define DEBUG_PIN (*(volatile uint32_t *)0x10012000) DEBUG_PIN 1; // 打点这个方法看着原始但在没有其他调试手段时极其有效。而且它不依赖任何库和驱动只要 CPU 能执行指令、总线能访问到 GPIO就能用。5.3 异常处理的兜底机制bare-metal 程序跑飞后往往直接卡死什么信息都没有。一个实用的技巧是在启动代码里设置mtvec指向一个异常处理函数把mcause、mepc等 CSR 保存到一块固定的内存区域然后让 LED 闪烁特定模式。这样即使程序崩溃也能通过 LED 闪烁次数反推异常类型。# 设置异常向量 la t0, trap_handler csrw mtvec, t0 trap_handler: csrr t0, mcause csrr t1, mepc # 保存到固定地址供调试器查看 la t2, 0x8007FF00 sw t0, 0(t2) sw t1, 4(t2) # 死循环等待调试器 1: j 1b这个兜底机制在项目早期特别有用能把你从完全不知道发生了什么变成至少知道异常类型和出错地址。6. 几个容易忽略但影响很大的细节写到这里核心流程基本讲完了。最后补充几个我在实际项目中反复遇到的问题都是那种不知道就永远想不到知道了就一句话的事的类型。第一个是编译优化等级和启动代码的关系。启动汇编里如果用了 C 语言风格的标签或者依赖编译器优化在-O0和-O2下行为可能不同。我的建议是启动汇编尽量用纯汇编写不依赖任何编译器行为这样无论优化等级怎么变都稳定。第二个是中断使能的时机。很多人在启动代码里很早就开了全局中断结果中断向量还没设置好一有中断就跳到非法地址。正确顺序是先设mtvec再初始化中断控制器最后才开全局中断。第三个是内存属性的配置。有些 RISC-V 核支持 PMP物理内存保护如果不配置 PMP某些地址区域可能默认不可访问。这在带 MMU 或 PMP 的核上尤其要注意启动代码里要先把 PMP 配好否则访问 RAM 就会触发异常。第四个是时钟初始化。很多 MCU 复位后跑在内部低速时钟上如果直接按高速时钟的频率去算串口波特率串口输出就是乱码。启动流程里要确认时钟树配置或者至少在初始化外设前把时钟切到预期频率。这些细节单看都不复杂但组合在一起就是 bare-metal 开发的门槛所在。我的经验是每换一个平台就把启动流程从头到尾重新验证一遍不要假设上次能跑这次也能跑。RISC-V 生态的碎片化程度比 ARM 高得多同一份代码在不同核上的行为差异可能超出你的想象。把启动和 bare-metal 流程吃透之后后面做中断、做驱动、做多核调度都会顺很多因为所有这些功能都建立在程序能正确跑起来这个基础之上。这个基础打不牢上层写得再花哨也是空中楼阁。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进