搞 RISC-V 的人不管是在写 CPU 还是写固件迟早都要撞上 bare-metal 启动这个坎。而“启动流程”这四个字往往是新手和老手中间的天然分界线会的人觉得不过就是设置栈、清 bss、调 main不会的人看一晚上启动代码也不知道第一行为什么要从某个地址开始执行。RISC-V 因为自身规范灵活不同芯片的复位入口、内存排布、引导方式差别很大bare-metal 启动流程没有一套通吃所有芯片的模板可抄。所以这篇专栏我把启动流程从设计思路到实操代码、再到排查技巧完整拆开适合正在写 RISC-V CPU、做板级移植或者刚入坑嵌入式裸机开发的工程师拿去对照。保证你看完能自己搭一个最小可跑的 RISC-V bare-metal 工程并且知道每一步到底在干嘛、为什么要这么干。1. 启动流程的整体设计与思路拆解1.1 bare-metal 到底是什么为什么启动是硬骨头先把我理解的 bare-metal 说清楚。所谓 bare-metal就是你的程序直接跑在硬件上没有 Linux、没有 RTOS甚至没有 BIOS 帮忙初始化。CPU 一上电执行的第一条指令就是你自己写的代码。这意味着所有硬件初始化的脏活累活都得自己干包括关中断、配时钟、设串口、初始化内存最后才轮到 main 函数登场。很多人是从 STM32 那套 Keil 工程转过来的用现成的启动文件点一下编译下载就跑起来了对启动流程没太多感觉。但到了 RISC-V 这边情况完全不同没有厂商统一帮你封装启动代码也没人保证复位向量就在某个固定地址。RISC-V 规范只规定“复位后进入机器模式、从实现定义好的复位地址开始取指”具体这个地址是多少、怎么把代码放上去完全由芯片或者模拟器说了算。比如 QEMU 的 virt 平台直接把 0x80000000 当成内存镜像起点而一些低成本 SoC 可能把内部 ROM 映射在 0x00000000再慢慢跳去 DDR。所以在 RISC-V 上搞 bare-metal启动流程不能背模板而是要按“一条指令为什么出现在这里”去理解。你需要把四件事在心里默默安排好程序从哪里开始执行复位向量、执行第一条指令时寄存器是什么状态尤其 sp、全局变量和栈放在哪里链接脚本、C 语言运行环境怎么建立起来数据段拷贝、bss 清零。这四件事没理顺后面所有程序都是空中楼阁随时可能崩得毫无征兆。1.2 从复位到 main 的经典路径我习惯把 bare-metal 启动路径画成一条接力棒传接过程。第一棒是复位向量它在芯片上电后带走 CPU 去取第一指令第二棒是汇编启动函数_start负责把 C 语言运行环境搭好第三棒才是main从此进入正常的业务逻辑。为什么第一棒必须是汇编因为在最早期C 的全局变量还没就位栈指针 sp 可能是一个不确定值sp寄存器也没有任何保证。除非你的主函数完全不依赖栈、不引用全局变量否则直接跳进 C 里就是灾难。汇编只做最小必要的动作设置 sp、清 bss、拷贝数据段、设置 trap 入口然后call main。整个过程干净利落绝不掺和硬件外设初始化那是main里板级代码该干的事。还有一个容易忽略的点RISC-V 没有一条“设置当前模式为机器模式然后跳转”的通用指令而是依赖复位默认值和mret。复位后默认进入 Machine Mode所以裸机代码大多不切模式直接一路 M 模式跑到底。如果哪天你的程序要切到 S 模式上跑一个简易内核就得在启动汇编里用mret做“伪返回”通过修改mepc和mstatus来切换特权级。这个机制区别于 x86 的实模式到保护模式切换也区别于 ARM 的异常向量跳转初学者最容易把模式切换想复杂其实核心就是改两个 CSR。1.3 现代 SoC 的多级启动对我们的启发逛社区经常会看到有人讨论高通芯片 SDX85 这类蜂窝模组方案的启动流程也常有人把 RISC-V CPU 设计里的复位逻辑单独拉出来研究。观察这些真实 SoC 的架构会发现它们普遍采用“多级启动”的方式芯片内部一段只读 Boot ROM 先在 SRAM 里跑然后从 SPI NAND/eMMC 里加载一个相对小的次级引导再由次级引导初始化 DDR最后跳转到主程序。这样做的原因很简单DDR 在上电时还不能用必须靠一段极小的代码先把控制器初始化好。这个思路对 RISC-V bare-metal 同样成立只是大部分人写裸机程序时把多级启动简化成单级了。比如 QEMU 下你只需要一个 ELF 丢进去就能跑因为内存模型已经帮你准备好了。但如果做真实板卡尤其是要支持从外置 Flash 启动的时候就得考虑把自己的启动分成两段一段放在 Boot ROM/SRAM 里的 loader负责最基本的串口调试和 DDR 初始化另一段才是放在 DDR 里的主程序。这种设计把“初始化内存”这个脏活从业务程序里剥离出来也让后续固件升级更容易。哪怕你暂时只在模拟器上玩明白这条多级路径会让后面接触 SDK、ATF 或者自定义 bootloader 时脑子清楚很多。2. 核心细节解析与实操要点2.1 理解复位向量、指令内存映射和 CSR 初值很多人第一步就栽在地址映射上。RISC-V 规范只给出一个“实现定义的复位向量”不同平台五花八门有的从 0x00000000 启动比如一些教学用的 RISC-V 核有的把 Boot ROM 映射到 0x00001000还有的直接让复位 PC 指向 0x80000000比如 QEMU virt。真正重要的是你的链接脚本中_start必须链接到复位后 CPU 第一眼看到的位置否则就算程序下载进去了CPU 跳过去取的是别的数据第一段就会打出 Illegal Instruction。说到 CSR 初值这里有几个默认状态构成了启动前提。复位后模式是 Machine Modemstatus.MPP字段往往不是 0所以后续如果用mret切模式一定要先把mepc和目标模式设好mtvec默认地址由实现决定可能是 0 也可能是个固定值如果你打算启用中断第一个动作就得把 trap handler 地址写进mtvec。不少真实板卡的一线问题都是“我明明写了中断程序怎么一打开全局中断就跳飞”后面一查是mtvec根本没设置CPU 跳去了一个没准备的地址。底线是启动代码的前几条指令要么显式消除这些初值的不确定性要么确保自己依赖的默认值确实是你需要的值。现代 RISC-V 内核还会带硬件浮点单元 FPU。机器模式复位时mstatus.FS默认值是 0也就是 FPU 未开启此时任何浮点指令都可能触发非法指令异常。这又是一个典型的“为什么我的 float 代码裸机跑不起来”的坑。启动时要在mstatus里把FS字段设为 2 或 3或者干脆用工具链的软浮点编译选项。假如你手头的是带 satp 的 RV64 内核还要注意复位后satp处于关闭 MMU 的状态开机直接是物理地址映射关系必须由你自己一步步建立起来。这一串 CSR 初值就是 bare-metal 启动的“出厂约定”只有把它们都摸清你的启动代码才叫有根有据。2.2 链接脚本为什么必须是启动流程的一部分我一直把链接脚本当成启动代码的“另一半大脑”。启动汇编负责执行但代码段放哪、栈放在哪、bss 段怎么算地址全靠链接脚本定义。常见新手误解是写启动代码却不写链接脚本拿 GCC 默认的排布直接编译结果_start被放到一个莫名奇妙的地址链接器也可能报“cannot find entry symbol”。Bare-metal 工程里链接脚本是你和硬件内存地图之间的唯一约定必须自己动手写。链接脚本最核心的是描述 LMA 和 VMA。VMA 是程序运行时 CPU 看到的地址LMA 是程序下载存储时存放的地址。所有从 Flash 启动的嵌入式系统都有这个经典问题.data段内容在编译时属于 FlashLMA 在 Flash运行时却必须被搬到 SRAM/DDRVMA 在 RAM。链接脚本在.data段上提供__data_la和__data_start两种符号启动代码再做一个简单循环把数据从存储位置搬到运行位置。如果在 QEMU 或直接把程序加载进 RAM 的开发环境LMA 和 VMA 相等不用搬但一旦上真实 Flash 板卡这块逻辑必不可少。把 LMA/VMA 概念搞清楚比背十遍启动汇编都有用。另一个链接脚本相关的细节是 gp 全局指针。RISC-V 默认小数据模型会生成大量 gp 相对寻址的访存指令访问速度快且代码小。但 gp 的值不是硬件自动给的而是由启动代码加载通常在链接脚本里放一个__global_pointer$符号。很多移植过来的用户汇编工程没用这个符号链接时却因为-mrelax开启了 gp 重定位优化导致运行时 GPIO 寄存器访问乱掉拿的地址全是错的。要么在链接脚本里显式定义__global_pointer$并让_start把它加载到 gp要么编译启动文件时用-mno-relax关掉对小需模型的放松优化。别小看这一行配置它能让你的调试时间从一个星期缩短到十分钟。2.3 中断向量表、异常委托和 trap 入口到此为止启动流程基本可以把 CPU 带到 main但真正的嵌入式系统没有中断寸步难行所以启动阶段必须把 trap 入口铺好。RISC-V 的异常入口设计比较特别它不像 Cortex-M 那样查一张矢量表而是把入口地址统一放到mtvec寄存器然后由软件根据mcause去判断是中断还是异常、是哪种来源。也就是说你需要的其实是一个 C 函数 dispatch 器加一段极短的汇编转发代码。在 M 模式裸机中设置mtvec最常用的两种模式是 Direct 全部指向同一个入口或者 Vectored 按异常类型间隔排列。Direct 模式简单、灵活适合绝大多数裸机场景进入 trap 后先把必要寄存器压栈然后读mcause判断来源处理完再mret返回。注意mret会恢复mepc和mstatus所以你的 trap 函数必须保证mepc指向真正应该返回的下一条指令。RISC-V 在 trap 时不会自动保存通用寄存器这点和 ARM 的硬件压栈完全不一样必须在软件里做保存/恢复这也解释了为什么每个 RISC-V trap handler 开头都有一堆 push 指令。如果你在写自己的 RISC-V CPU 核这就是你最容易测试 CPU 逻辑是否正确的地方用一条异常指令触发 trap看mtvec、mcause、mepc是不是按规范变化。至于裸机要不要做异常委托大多数情况根本不用。把 M 模式的所有异常自己吃掉就好。只有当你想跑一个 S 模式小内核把部分中断委托给 S 模式处理时才需要动medeleg和mideleg寄存器。这部分是链接到启动流程里的最后一个知识点如果后续想做系统态切换启动汇编里就要先完成委托设置再用mret降级到 S 模式。否则内核代码还没跑到 main就会因为“M 模式接管了所有异常”打乱整套逻辑。3. 实操过程与核心环节实现3.1 最小工程骨架长什么样纸上谈兵再多不如搭一个真实工程跑起来。我建议你们按下面这个目录结构准备 RISC-V bare-metal 最小工程我这边就用它做演示baremetal-min/ ├── start.S ├── main.c ├── linker.ld ├── Makefile ├── common.c └── common.h我的习惯是让start.S只干启动那一锤子买卖main.c里放 main 和板级初始化common.c则放调试打印、延时这类基础函数。这是刻意模仿真实 SDK 的分层启动、板级、公共库各自独立。很多初学者喜欢把所有代码塞进一个.c文件编译确实简单但后面接 RTOS、加外设驱动时就会重构到怀疑人生。趁工程还小保持这种分层能让后面每个阶段的替换成本都降到最低。工具链我推荐直接用官方 riscv-gnu-toolchain 编译好的二进制或者用系统包管理器装的riscv64-unknown-elf-系列工具。如果你手头主要跑 RV32 内核记得选准 multilib否则链接时会遇到“找不到 libgloss 的对应实现”这类问题。当然纯裸机其实可以不依赖标准库完全用-nostartfiles -nostdlib编译把所有基础函数自己手写。这样不光可以精确控制启动流程还能避免标准库初始化代码给你的启动流程添乱。这一套做下来你的裸机工程体积往往能压到几 KB 级别。3.2 start.S 里的关键指令逐行拆解现在看核心启动文件。我以 RV64 QEMU virt 为例把每一段都贴出来并解释.section .text.init,ax .globl _start _start: li t0, 0 csrw mstatus, t0 csrw mie, t0 la sp, _stack_top la gp, __global_pointer$ la a0, __bss_start la a1, __bss_end bgeu a0, a1, 2f 1: sd zero, 0(a0) addi a0, a0, 8 bltu a0, a1, 1b 2: la t0, trap_entry csrw mtvec, t0 call main 3: wfi j 3b这段代码有几个关键动作。前两条csrw先把mstatus和mie全部清零目的是让 CPU 带着一个完全关闭中断的干净状态开始运行。很多人会省略这一步但等你上真实板卡遇到莫名其妙的中断嵌套时就会回来找这两行的。第 4 行la sp, _stack_top是把全局指针 gp 和栈指针 sp 建立起来注意这里 sp 必须是 16 字节对齐RISC-V 的 ABI 明确规定栈指针在调用约定下要保持对齐。接着是 bss 清零循环用__bss_start和__bss_end这两个由链接脚本生成的内存边界符号把未初始化数据段全部填 0。千万别省 bss 清零否则 C 语义里“未显式初始化的全局变量等于 0”这一条就全崩了。然后我把mtvec指向一个trap_entry注意我这里用了 Direct 模式所以 trap_entry 可以是任意一个 4 字节对齐地址。由于 RV64 指令长度可能不固定RISC-V 规范只要求mtvec的最低两位表示模式Direct 模式要求低两位为 0因此汇编里我确保trap_entry地址对齐到 4 字节这对 linker 排布也是一个潜在约束。最后call main进入 C 世界。万一 main 意外返回我放了一个无限wfi死循环兜底。再看trap_entry的完整实现trap_entry: addi sp, sp, -32 sd t0, 0(sp) sd t1, 8(sp) sd ra, 16(sp) csrr t0, mcause csrr t1, mepc /* 在这里调用 C 函数处理异常 */ ld t0, 0(sp) ld t1, 8(sp) ld ra, 16(sp) addi sp, sp, 32 mret这里的核心是 RISC-V 的 trap 不自动保存寄存器一切都要软件来做。不过注意这个简化版只保存了 t0、t1 和 ra真正生产级代码需要按上下文把更多寄存器压栈尤其是如果你在 trap 里调用 C 函数ra必保。还有一个容易忽略的点mepc默认指向触发异常的指令本身举例而言如果你在 C 函数里修改了t1来调整返回地址记得把它写回mepc如果只是处理可恢复中断则保持原样即可。我没有在启动代码里启用全局中断。这是一种保守策略裸机里大多数情况下外围设备的中断不是开机就要的等main里完成外设初始化再显式打开全局中断更安全。过早开中断的后果就是一段初始化还没跑完外设就开始打断主流程祖传的坑。3.3 链接脚本里的地址符号到底怎么排下面这个链接脚本是配合上面 start.S 的标准写法OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwxa) : ORIGIN 0x80000000, LENGTH 128M } SECTIONS { . 0x80000000; .text : ALIGN(4) { *(.text.init) *(.text*) } RAM .rodata : ALIGN(4) { *(.rodata .rodata.*) } RAM .data : ALIGN(4) { __data_la LOADADDR(.data); __data_start .; *(.sdata .sdata.*) *(.data .data.*) __data_end .; } RAM .bss (NOLOAD) : ALIGN(4) { __bss_start .; *(.sbss .sbss.*) *(.bss .bss.*) *(COMMON) __bss_end .; } RAM . ALIGN(16); . 0x4000; __stack_top .; __global_pointer$ . 0x800; }先说ENTRY(_start)它告诉链接器入口符号是什么对生成 ELF 很关键。OUTPUT_ARCH(riscv)则指定了架构。内存区域的起始地址必须和复位向量一致这里 QEMU virt 的 0x80000000 就是我上面的_start要链接去的地方。 RAM的写法把所有段都排进 RAM大家在下载到内存运行的环境里都会这么干。链接脚本的最后三行是点睛之笔我先把当前位置用ALIGN(16)对齐再向后拨0x4000即 16KB 的空间作为堆栈区然后__stack_top指向栈顶。RISC-V 栈是向下生长的所以 sp 要指向这段被预留区域的最顶端。之后又定义了__global_pointer$ . 0x800原因是 gp 相对寻址允许偏移量在 -2048 到 2047 字节范围内把 gp 设到数据段中间位置可以让更多小数据访问命中短偏移从而减少重定位。这个设计虽然琐碎但在大工程里对性能提升和代码体积都是肉眼可见的。如果哪次你编译后发现启动代码访问变量卡在异常里第一件事就该检查 gp 到底设到了哪。数据段拷贝这个环节我直接说清场合。上面这个链接脚本里__data_la和__data_start指向同一个地址因为 QEMU 加载 ELF 后数据已经躺在运行地址里拷贝等于白拷贝。但真实 Flash 启动时LOADADDR(.data)一般会落在 ROM 或 SPI Flash 映射区与运行时 RAM 地址不同这时 start.S 就需要增加一个拷贝循环代码类似 bss 清零只是改为从__data_la读、写到__data_start。这是链接脚本和汇编配合的经典例子建议在真实板子上开发前先把这个能力焊死在启动代码里。3.4 用 QEMU 五分钟把工程跑起来我一般用下面的 Makefile 来做编译和运行CROSS ? riscv64-unknown-elf- CC $(CROSS)gcc OBJDUMP $(CROSS)objdump CFLAGS -marchrv64gc -mabilp64 -mcmodelmedany -O2 -g ASFLAGS -marchrv64gc -mabilp64 LDFLAGS -nostdlib -static -T linker.ld -mcmodelmedany all: kernel.elf kernel.elf: start.o main.o common.o $(CC) $(LDFLAGS) -o $ $^ %.o: %.S $(CC) $(ASFLAGS) -c -o $ $ %.o: %.c $(CC) $(CFLAGS) -c -o $ $ run: kernel.elf qemu-system-riscv64 -machine virt -cpu rv64 -m 128M -nographic -bios none -kernel kernel.elf dump: $(OBJDUMP) -D kernel.elf .PHONY: all run dump几个参数值得多说一句。-mcmodelmedany是 RISC-V 大地址代码模型生成绝对可达代码避免auipcaddi处理不了超长跳转的问题。-nostdlib则彻底移除标准启动库防止工具链偷偷塞进一套默认启动代码影响我们的_start。QEMU 命令行里-bios none是让 QEMU 不要加载默认的 OpenSBI 固件-kernel kernel.elf把我们的 ELF 直接加载到 0x80000000 并让主 CPU 从入口开始执行。跑起来之后应该能在串口看到你的main里打印的信息如果没有第一排查点永远是-bios none和.text地址是否配套。这种 QEMU 环境非常适合验证纯裸机的最小启动流程。唯一的坑就是 QEMU 是模拟器它不会暴露时序和真实外设的问题。但用来确认启动代码本身的逻辑比如 bss 清零、trap 入口、C 环境建立已经绰绰有余。而且 QEMU 支持-S -s参数挂 GDB可以逐行 inspect 启动过程的寄存器变化调试体验比真实板卡还要好。4. 常见问题与排查技巧实录4.1 启动就卡死或跳飞的 4 类典型原因我把自己这几年带过的裸机项目以及社区里高频提问的问题归纳成一张速查表放在这里启动流程出问题时先对照它症状常见原因排查手段PC 跑到 0 或非法地址复位向量与链接地址不匹配检查链接脚本MEMORY起始地址和 CPU 复位地址main 里全局变量的值不对bss 没清零或者 gp 指针没配对GDB 查看__bss_start/__bss_end地址范围确认清零循环执行次数程序莫名进入 Illegal Instructionmstatus 的 FS 字段没开启就用了浮点指令启动代码里配置mstatus.FS或改用软浮点编译选项开了中断后卡死mtvec 地址未设置或 trap 里没保存寄存器单步确认 mtvec 写的是 trap 入口trap 里至少保存 ra/sp从 Flash 启动时变量全是垃圾值.data 段 LMA 与 VMA 不同未做拷贝链接脚本里输出符号start.S 里对比__data_la和__data_start循环搬运我把第一行再讲透一点如果你拿到一块开发板上电直接短路先别急着怀疑硬件很多时候就是复位地址和链接脚本不一致。比如芯片复位向量是 0x00000000你却把代码链接到 0x80000000那么 CPU 从 Flash/SRAM 的 0 地址取到一段不认识的数据第一指令就是非法指令。反过来QEMU 要求 entry 在 0x80000000你却把_start放在 0x00000000就算编译成功QEMU 也只是把 ELF 加载到该地址然后从入口开始执行逻辑完全错位。解决方式很笨但很有效打印 ELF 的entry地址例如用readelf -h kernel.elf再和开发板手册里的复位地址对一遍。4.2 用 GDB 和 OpenOCD 一步步盯启动现场启动代码的问题之所以难查是因为它发生在程序最开始printf 都还没就位你根本没地方打日志。最可靠的排查手段是让处理器停在复位后第一条指令处然后单步观察每一条指令的执行。QEMU 模式下很简单把 Makefile 里的 run 命令改成qemu-system-riscv64 -machine virt -cpu rv64 -m 128M -nographic -bios none -kernel kernel.elf -S -s然后另开终端执行riscv64-unknown-elf-gdb kernel.elf (gdb) target remote :1234 (gdb) break _start (gdb) continue (gdb) info registers此时能看到启动第一条指令前的寄存器全貌。多数情况下你会一眼看到 sp 还是 0mstatus是一个未知的复位值这种状态就是你启动失败的第一现场。我在实战中还有一个很顺手的三步法先在 bss 清零结束处设断点检查全局变量是否全 0再在call main处设断点检查 sp 是否在栈区范围最后在 main 的第一条 C 语句设断点确认进入 C 世界时寄存器没有被破坏。这三步走完启动代码的质量基本就有底了。真实硬件上用 OpenOCD 配一个 JTAG 调试器也同理区别只是把target remote换成 OpenOCD 暴露的端口比如 3333。OpenOCD 启动时需要先连接 target再reset halt让 CPU 停在复位状态然后加载 ELF。这样在真实板卡上也能实现和 QEMU 一样的单步体验。不过我要提醒有些便宜的调试器仿真速度极慢单步蹦跳启动汇编没问题一旦进入带循环的 C 代码就等到怀疑人生这时可以改成在关键断点之间用continue跳过。4.3 从裸机启动迈向 RTOS 和自研 CPU 验证的小建议启动流程搭好了紧接着大多数人会想能不能往上跑个 RTOS或者如果我自己设计了一颗 RISC-V CPU该怎么验证启动逻辑这两件事我都踩过坑给点个人建议。先讲 RTOS 方向。FreeRTOS 或者 RT-Thread 这类系统并不替换你的启动代码它们通常只要求你提供一个main和几个print接口真正的线程切换底层依赖mstatus和定时器中断。所以把这篇文章的启动流程搞懂之后你会更容易读懂 RTOS 移植文档里要求的“提供栈空间、提供 tick 定时器、提供上下文切换函数”。尤其在 RISC-V 上RTOS 的上下文切换本质上就是一套精心设计的 trap handler你对trap_entry和寄存器保存理解得越深越不容易踩到“任务切换后优先级反转”“中断里栈空间不足”这类暗坑。再讲自研 CPU 验证方向。很多人认为验证 CPU 逻辑必须跑到 Linux那是大错特错。Linux 启动链路太复杂一旦失败你分不清是 CPU bug、外设 bug 还是 kernel 配置问题。裸机启动流程反而是最硬的验证起点你拿这篇文章里的最小工程能在自己写的 RISC-V 核上跑通 bss 清零、C 函数调用、中断触发就说明指令集核心的功能、PC 跳转、CSR 读写、异常入口都已经大体正确。在此基础上再逐渐加上 UART 外设、MMU最后才去考虑 Linux。从 CPU 设计者的角度看“能跑 bare-metal 启动流程”是比任何仿真 dump 都直观的信号你的设计离“可用的核”又近了一大步。我个人在实际操作里最深的体会是启动过程看似生成的代码只有几十行但它把所有软硬件约定焊死在同一个坐标系里。你在这一步多花一天把复位地址、CSR 初值、链接脚本、栈规划搞清楚后面写驱动、调中断的每一分钟都会给你回报。如果非要说还有什么独家技巧那就是永远在编译后先看一眼objdump的入口段确认第一条指令就是你的_start这一眼足够规避掉绝大多数所谓“玄学问题”。