资讯动态

RISC-V裸机开发必读:从复位向量到链接脚本的启动流程全解析

发布时间:2026/10/20 13:55:38 来源:尧图企业网站定制
1. 从复位向量开始RISC-V 启动的第一条指令很多人刚开始接触 RISC-V 裸机开发时第一个困惑往往是芯片上电之后CPU 到底从哪里开始执行第一条指令这个问题不像看起来那么简单因为它直接决定了你在链接脚本里写起始地址、在启动文件里放复位向量时脑子里得有一个清晰的全局图。对于大多数 RISC-V 核来说复位后的默认地址是0x80000000。为什么是这里因为这是 RISC-V 规范里推荐的“最低可用内存地址”很多 SoC 和开发板把 DDR 或 SRAM 映射在这附近。比如常见的某些国产 RISC-V 开发板片上 SRAM 就在0x80000000附近而0x00000000往往留给 boot ROM 或外设映射。这跟 ARM 的0x00000000启动方式不同ARM 的异常向量表通常要求放在低地址而 RISC-V 更灵活——mtvecMachine Trap Vector寄存器可以指向任意地址。实际开发时我们面对的情况大致有三种启动方式复位 PC 指向适用场景直接运行在 SRAM0x80000000小规模裸机程序、教学实验、快速验证Boot ROM 加载到 DDR由 Boot ROM 跳转量产产品、Linux 启动前置阶段JTAG 调试器加载由调试器写入 PC开发调试阶段、断点单步调试我自己的习惯是前期开发和调试阶段尽量用 JTAG 直接把程序加载到 SRAM 里跑省去烧写 flash 的等待时间迭代快。等程序稳定了再考虑怎么通过 boot ROM 或引导程序把它搬到最终位置。在写 startup 代码之前你还需要理解一个概念RISC-V 没有像 ARM 那样的“向量表”概念但异常入口是存在的。CPU 遇到中断或异常时硬件会跳转到mtvec指向的地址。这就是为什么启动代码里通常先把mtvec设好——即使你暂时不需要处理中断挂一个默认 handler 也是好习惯否则一旦发生异常PC 会飞到随机位置难排查。提示复位后建议立刻确认当前是 Machine Mode 还是其他模式。RISC-V 支持 M/S/U 三种特权级裸机程序一般固定跑在 Machine Mode因为只有 M Mode 才能访问所有 CSR 寄存器。2. bare-metal 启动流程的四个阶段2.1 阶段一设置栈指针和全局指针上电后CPU 处于一个“原始”的状态栈指针sp可能是零全局指针gp也未初始化。在调用任何 C 函数之前这两件事必须做否则编译器生成的压栈、弹栈指令会直接把数据写到地址 0 上立刻触发异常。先看一段最基础的 startup 代码RISC-V 32位无 MMUM Mode 裸机.section .text.startup .globl _start _start: /* 设置栈指针栈顶放在链接脚本里定义的 __stack_top */ la sp, __stack_top /* 设置全局指针用于访问小数据段提升寻址效率 */ la gp, __global_pointer$ /* 清空 bss 段C 语言里未初始化全局变量默认应为 0 */ la t0, __bss_start la t1, __bss_end bss_clear: bge t0, t1, bss_clear_done sw zero, 0(t0) addi t0, t0, 4 j bss_clear bss_clear_done: /* 拷贝 data 段从 LMA加载地址拷贝到 VMA运行地址 */ la t0, __data_load_start la t1, __data_start la t2, __data_end data_copy: bge t1, t2, data_copy_done lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j data_copy data_copy_done: /* 调用 C 语言的 main 函数 */ call main /* main 返回后死循环嵌入式设备不应退出 */ 1: j 1b这段代码里有几个细节值得展开说。为什么需要 gp 全局指针RISC-V 的寻址指令luiaddi组合最多覆盖 32 位地址空间但两条指令才能访问一个 32 位地址代码密度不理想。gp 寄存器存在的意义是提供一个“基准点”访问 gp 附近正负 2KB 范围的全局变量时只需要一条指令。链接器会根据__global_pointer$符号自动优化。代价是如果你的程序用了很大的全局数组或者链接脚本没定义好这个符号编译器会报错或者生成低效代码。bss 清零为什么要自己做在 Linux 上加载器loader会替你清零 bss。但裸机环境里没有人帮你上电后 SRAM 里的内容是随机值。如果你不把 bss 清零C 语言中int flag;这种“默认是 0”的语义就完全不成立你的程序可能出现完全随机的 bug——而且是那种编译、烧录都正常一运行就时好时坏的诡异问题。2.2 阶段二配置机器模式下的关键 CSR栈和 bss 搞定之后接下来是根据你的外设需求配置 CSR。下面这几个是 bare-metal 开发里最常用的/* 关闭所有中断避免启动过程被打断 */ csrw mie, zero /* 设置异常入口地址指向我们自己的 trap handler */ la t0, trap_handler csrw mtvec, t0 /* 开启浮点单元如果使用硬件浮点 */ li t0, (1 13) | (1 14) /* FS01 初始状态 */ csrs mstatus, t0这里特别提醒一个坑如果你用到了浮点运算却没有正确初始化mstatus.FS字段CPU 会触发非法指令异常。我第一次遇到这个问题时排查了半天最后用调试器单步才发现是fadd.s指令直接导致程序跳进了异常处理函数。RISC-V 规范规定浮点单元默认是关闭的要显式打开。另外如果你计划用定时器中断或外部中断还需要配置mie寄存器对应位以及mstatus.MIE全局中断使能位。这部分的顺序是先设好mtvec再配置外设中断源最后才开全局中断。顺序反了的话会出现中断来了但向量表还没就位的竞态条件。2.3 阶段三初始化系统时钟与外设这一步严格来说不属于“CPU 启动”而是“板级初始化”。不同开发板差异很大。以典型的 RISC-V SoC 为例开机后 CPU 可能跑在一个默认的慢时钟上比如 24MHz 内部 RC 振荡器你需要通过寄存器配置 PLL把 CPU 主频提到标称值比如 400MHz 或更高。然后初始化 DDR 控制器、串口、GPIO 等。这一部分通常由板级支持包BSP提供我不建议在起步阶段自己从头写。最好直接把厂商 SDK 里的system_init()或类似函数拿过来用。等整个启动流程跑通了再逐步替换成自己的精简版本——这样做的好处是如果系统起不来你能快速区分是 CPU 启动代码的问题还是板级初始化的问题。注意如果跳过了时钟和外设初始化直接跑 main最常见的现象就是串口打印乱码波特率不对或者程序不定时死机外设时钟源不对。排查这类问题先确认时钟树再谈其他。2.4 阶段四跳转 main 之前的最后检查在调用 main 之前还有几件小事值得养成习惯第一是关掉看门狗。很多 SoC 默认上电后看门狗是跑的如果你的初始化时间超过了看门狗超时时间芯片会被强制复位表现为程序“跑飞”或反复重启。第二是权限检查。如果你的程序要操作某些受保护的外设而当前运行在 U Mode需要先切换到 M Mode 或配置 PMPPhysical Memory Protection条目。对于纯 M Mode 裸机这一步通常可以跳过但如果你将来要跑 RTOS 或简单内核PMP 是绕不开的。第三是给 C 运行时准备环境。包括设置ra返回地址寄存器为零以及确保a0/a1寄存器里存放的启动参数是正确的——某些 bootloader 跳转时会通过a0传设备树地址或核心编号你的 main 函数签名可以对应接收这些参数。3. 链接脚本bare-metal 的地基工程说句实在话绝大多数新手写启动代码真正卡住他们 80% 时间的地方不是汇编而是链接脚本。链接脚本决定了你的代码、数据、栈、堆分别放在哪里如果链接脚本写错启动代码写得再漂亮也跑不起来。下面给一份适用于上述 startup 代码的链接脚本关键片段OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN 0x80000000, LENGTH 128K } SECTIONS { .text : { KEEP(*(.text.startup)) *(.text*) } RAM .rodata : { *(.rodata*) } RAM .data : { __data_start .; *(.data*) __data_end .; } RAM AT RAM .bss : { . ALIGN(4); __bss_start .; *(.bss*) *(COMMON) __bss_end .; } RAM . ALIGN(4); __heap_start .; __heap_end ORIGIN(RAM) LENGTH(RAM); . ALIGN(16); __stack_top ORIGIN(RAM) LENGTH(RAM); }3.1 为什么使用 ALIGN(4) 和 ALIGN(16)RISC-V 要求某些指令和数据按边界对齐。比如lwload word要求地址是 4 的倍数如果地址不对齐会触发 Load Address Misaligned 异常。栈指针建议按 16 字节对齐这是为了兼容可能的 SIMD 指令和 ABI 规范。3.2 LMA 与 VMAdata 段的隐藏陷阱很多人在链接脚本里看到AT语法就懵了。简单解释一下VMAVirtual Memory Address是程序运行时数据所在的地址。LMALoad Memory Address是数据初始拷贝来源所在的地址。如果你的程序直接烧写在 SRAM 里运行LMA VMA就是通常的情况。但如果你的程序在 flash 里运行、需要把 data 段拷贝到 RAM 里那么 LMA 在 flashVMA 在 RAM这就需要用AT显式指定 LMA 的位置。在上面我给的 startup 代码里__data_load_start就是 LMA__data_start是 VMA。如果你的场景是直接在 SRAM 中调试LMA 和 VMA 相同data_copy 那一段会变成自我拷贝效果上没有破坏性——但效率上浪费了一点启动时间。严格的项目里我会在链接脚本里通过条件判断区分这两种场景。3.3 栈和堆的摆放策略栈和堆到底谁在上谁在下行业里没有绝对统一的标准但有一个通用原则栈向下增长堆向上增长两者相向而行最终在中间碰撞。因此合理的布局是栈顶放在 RAM 的最高地址堆从 bss 之后开始向上长。这样理论上堆栈可以共享最大限度的空间。不过要注意如果你的程序里既用了动态内存分配malloc 之类又用了深度递归堆和栈的碰撞几乎是必然的。裸机环境里我通常会通盘考虑需要确定性时直接禁用 malloc改用静态分配或内存池。这是很多工程实践中总结出来的经验——在资源受限的 MCU 上动态内存分配往往带来的麻烦比好处多。提示链接脚本里的符号__stack_top、__bss_start等默认是局部的。在 C 文件里引用它们时需要先extern声明例如extern char __stack_top[]。注意链接器符号不是变量不能直接读取它的值正确做法是取地址。4. 从编译到运行构建流程与常见错误排查4.1 构建命令与 Makefile 示例以裸机环境为例典型的构建流程是riscv-none-elf-gcc -marchrv32imac -mabiilp32 -nostdlib -Tlink.ld \ -c startup.S -o startup.o riscv-none-elf-gcc -marchrv32imac -mabiilp32 -nostdlib -Tlink.ld \ -c main.c -o main.o riscv-none-elf-gcc -nostdlib -Tlink.ld startup.o main.o -o firmware.elf riscv-none-elf-objcopy -O binary firmware.elf firmware.bin riscv-none-elf-objdump -D firmware.elf firmware.dis几个关键参数解释一下-marchrv32imac表示支持整数乘法除法、原子指令、压缩指令。根据你的芯片是否支持选择对应的扩展集。-mabiilp32表示整数、指针、长整型都是 32 位。如果march和mabi不匹配gcc 会报警告甚至生成错误的调用约定。-nostdlib告诉编译器不要链接标准库——裸机环境没有标准库可用如果不加这个参数链接时会出现一堆找不到的函数错误。-Tlink.ld指定链接脚本。注意链接脚本必须在命令行中位于源文件之后否则不会被正确读取。链接成功的产物是一个 ELF 文件里面有符号表和段信息方便调试器使用。最终烧录用objcopy生成的纯二进制firmware.bin因为 bootloader 或调试器烧录时一般不认识 ELF 格式。4.2 地址错乱最常见的三类链接错误错误一undefined reference to __stack_top这个错误几乎人人都遇到过。原因无非两种要么链接脚本里根本没定义这个符号要么链接脚本和启动代码引用的名字不一致。检查办法很简单用riscv-none-elf-nm firmware.elf | grep stack看符号是否存在。不要靠肉眼猜。错误二relocation truncated to fit: R_RISCV_HI20出现这个错误通常意味着你的代码段或数据段超出了单条lui/auipc指令能寻址的范围编译器无法把它优化为一条指令。解决办法是检查内存布局你的 flash 和 RAM 地址差太远了吗你的代码超过了 2GB 的范围吗对于小内存 MCU最可能是链接脚本里 RAM 的 LENGTH 写得太大或者段放错了位置。错误三程序跳转地址不对单步走几步就飞了这种情况排查顺序是先看反汇编文件firmware.dis里_start的地址是否等于0x80000000再看启动汇编的la sp, __stack_top是否被优化成了错误指令最后用调试器读 PC 寄存器和 sp 寄存器确认硬件 PC 是否真的落到了_start。如果 PC 跳到别处先查链接脚本的ENTRY是否设置正确再查是否 JTAG 工具加载 ELF 时选错了 base address。4.3 中断和异常入口trap_handler 怎么写前面提到要设置mtvec这里给一个最小实现void trap_handler(void) { /* 读出异常原因mcause 寄存器 */ unsigned long cause read_csr(mcause); unsigned long tval read_csr(mtval); /* 触发异常的指令地址或数据 */ /* 如果是外部中断中断最高位为1调用对应处理 */ if (cause (1UL 31)) { unsigned long irq cause 0xFF; /* 根据 irq 号分发到不同外设中断处理函数 */ external_irq_handler(irq); } else { /* 异常打印原因和现场信息 */ exception_handler(cause, tval); } }读mcause判断是中断还是异常最高位为 1 是中断否则是异常。mtval会记录触发异常的指令地址或非法访问的地址是排查非法指令和数据访问违例的关键信息。一个好的 trap handler 至少应该做到死循环之前把mcause、mtval、mepc异常返回地址、sp等现场信息保存到内存中方便调试器事后读取。4.4 让启动过程可见串口打印的最小实现很多人调试裸机时最痛苦的是“看不见摸不着”。我强烈建议在 startup 阶段尽早初始化串口并且打印一行Hello from RISC-V。这一步能帮你快速确认CPU 跑起来了、时钟没问题、GPIO 配置正确、串口驱动基本可用。一个极简的串口发送函数MMIO 方式长这样#define UART_BASE 0x10013000 /* 某个 UART 外设地址按实际芯片修改 */ void uart_putc(char c) { volatile unsigned int *txdata (unsigned int *)(UART_BASE 0x00); *txdata c; /* 等待发送完成SIFive UART 风格检查 TXFULL 标志 */ while (*(volatile unsigned int *)(UART_BASE 0x1C) (1 31)); } void uart_print(const char *s) { while (*s) uart_putc(*s); }这里的volatile关键字极其重要。如果去掉编译器可能会把连续多次写同一地址的操作优化掉或者把重复读操作缓存导致程序逻辑对但行为完全不对。这个坑在优化等级-O2以上时尤其容易爆发——我自己就曾经被-O2优化掉一次串口轮询卡了半天才发现。5. 在 qemu 上跑通第一个 bare-metal 程序没有开发板也能学 RISC-V 裸机这一点可能是这个生态最友好的地方了。qemu 提供了virt虚拟板卡模拟了一颗 RISC-V CPU 和若干外设可以在完全无硬件的条件下验证启动流程和裸机代码。启动 qemu 的命令qemu-system-riscv32 -machine virt -nographic -bios none -kernel firmware.bin这里有几个参数需要解释-machine virt选择 virt 虚拟板卡它把 RAM 放在0x80000000RAM 大小默认 128MB但这只是虚拟内存不占你电脑实际 128MB。-bios none告诉 qemu 不要加载任何 bootloader直接运行我们的裸机程序。-kernel firmware.bin指定我们的二进制镜像。注意qemu 会把这文件加载到0x80000000正好是我们链接脚本设定的起始地址。-nographic把串口输出重定向到当前终端方便调试。由于 qemu virt 板卡的串口地址是0x10000000而不是某些开发板的0x10013000你需要根据目标环境调整 UART 基地址。这个细节很小但经常让人卡住——抽象经验是凡是涉及到地址的地方都要以目标硬件的 datasheet 为准不要凭感觉套用别的板子的值。在 qemu 上开发还有一个额外好处可以用-gdb参数来开启调试配合 gdb 远程调试单步追踪启动流程比真机方便太多。命令大概是qemu-system-riscv32 -machine virt -nographic -bios none -kernel firmware.elf \ -S -gdb tcp::1234然后另开一个终端riscv-none-elf-gdb firmware.elf (gdb) target remote :1234 (gdb) break _start (gdb) continue这样你就能全程单步看到_start执行时寄存器的变化了。我第一次在 qemu 上单步看完整的启动过程时对裸机流程的理解一下子清晰了很多——强烈建议你也试一次。6. 启动流程的扩展话题PMP 与多核场景6.1 当你需要启用 PMP 时PMP 是 RISC-V 提供的物理内存保护机制可以限制 S/U Mode 下可访问的内存范围。在单核裸机里如果你全程跑 M ModePMP 基本是个可有可无的东西。但一旦你想跑一个简单的 RTOS或者想验证用户态代码PMP 就变成必需品了。启用 PMP 的示例/* 允许 S/U Mode 访问全部内存这是一个非常宽松的配置 */ write_csr(pmpaddr0, 0xFFFFFFFF 2); write_csr(pmpcfg0, 0x0F); /* TOR R/W/X A */PMP 配置有个让人困惑的点pmpaddr寄存器里的值不是直接地址而是地址右移两位的结果并且是否需要移位取决于PMP_GRANULARITY。遇到问题时先查你所用内核的手册确认粒度。6.2 多核启动primary 和 secondary 的协作现代 RISC-V SoC 往往不止一个核心。上电后几个核都会从复位向量开始执行但通常只有一个核通常是 hart 0负责初始化系统全局资源其他核需要等待。最简单的做法是检查当前 hart 的编号int hart_id read_csr(mhartid); if (hart_id 0) { /* primary hart初始化 DDR、时钟、外设等 */ system_init(); main(); } else { /* secondary hart等待 primary 发出信号 */ while (1) { wfi(); /* 等待中断 */ } }wfiwait for interrupt指令让非主核进入低功耗等待状态。要让 secondary hart 真正跑起来primary hart 需要通过一块共享内存准备好每个核的启动入口地址然后发送 IPI处理器间中断唤醒它们。这个过程在 Linux SMP 启动中很常见裸机项目若涉及多核也基本是同样的套路。这里特别提醒多核环境下启动代码里对全局资源的初始化必须放在一个明确的临界区里否则不同核同时去初始化同一个外设轻则功能异常重则总线总线冲突导致死锁。最简单可靠的方案就是上面这样除 hart 0 外的核心一律等待不让它们碰共享资源。7. 几个亲测有效的调试技巧说几个实操中反复用到、但书本上很少讲的小技巧。第一个技巧是用链接脚本符号打印内存布局。你可以在 C 代码里把__stack_top、__heap_start、__bss_start等符号的地址打出来这样上电后第一件事就是确认内存布局跟预期一致。很多时候排查启动异常第一件事不是看代码逻辑而是先确认这些地址是否落在合理区间。第二个技巧是在启动汇编里人为制造一个异常。比如故意执行一条ebreak指令或者在mtvec里挂一个会打印mcause的 handler。这样做能快速验证 trap 通路是否正常工作。如果你连异常入口都没配好后续遇到任何硬件错误都会表现得极其诡异——与其被动等 bug 出现不如在启动阶段主动测试。第三个技巧是用objdump检查第一条指令riscv-none-elf-objdump -d firmware.elf | head -30如果_start处的机器码不是预期的指令比如本应是la sp, __stack_top却变成了一堆 0说明链接或者编译出了问题而不是运行环境的问题。把“代码是否真的烧录进去了”和“逻辑是否正确”分开排查能省掉大量瞎猜的时间。8. 裸机启动流程的后续延伸走完上面这一整套流程之后你的程序能跑起来了但这离“生产力”还有一段距离。顺着这条线往下通常会触及下面几个方向。第一个方向是补齐运行时库的缺失。-nostdlib意味着memcpy、memset、printf这些全都没有。你需要要么自己实现要么移植一份精简的 C 库比如 picolibc 或 newlib-nano。其中printf是最大的一块工程很多人最开始只用串口打印整数和字符串就避免引入完整格式化输出——这个取舍在资源受限的 MCU 上是完全合理的。第二个方向是设备树驱动的引入。如果你的裸机程序要跑在复杂的 SoC 上外设地址、时钟配置、中断号等信息可能通过设备树FDT传递给程序。这实际上就是 Linux 启动早期阶段做的事也是从“裸机”到“带操作系统的程序”之间的过渡。第三个方向是 RTOS 的移植。FreeRTOS 或 RT-Thread 的移植本质上就是实现几个与架构相关的函数vPortStartFirstTask启动第一个任务、xPortSysTickHandler节拍中断处理、上下文切换和栈初始化。而这些恰好就是你在本文里看到的启动代码和 trap handler 的变种。换句话说你把裸机启动流程彻底搞明白了移植 RTOS 就有了七八成基础。我自己在带新人时有个习惯要求他们必须把启动流程从复位到 main 的完整链路跑一遍并手动画出每一步的寄存器变化才能开始写业务逻辑。这个流程看起来很基础但它决定了你对整个系统的信任度——如果你连程序是怎么跑起来的都不清楚后面遇到任何疑难杂症排查起点都会是错的。从实际项目角度看RISC-V 裸机开发的启动流程比 ARM 要简单清晰但正因为简单很多人容易忽略细节。把本文涉及的几个核心概念——复位地址、链接脚本、启动汇编、trap handler、PMP——吃透再配上 qemu 和一块真实开发板交叉验证你就能很自然地建立起对 RISC-V 体系结构的完整认知。

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

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

免费获取报价 →
↑