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 更灵活——mtvec(Machine 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位,无 MMU,M 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 的寻址指令lui+addi组合最多覆盖 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) /* FS=01 初始状态 */ 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 或配置 PMP(Physical 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 要求某些指令和数据按边界对齐。比如lw(load word)要求地址是 4 的倍数,如果地址不对齐,会触发 Load Address Misaligned 异常。栈指针建议按 16 字节对齐,这是为了兼容可能的 SIMD 指令和 ABI 规范。3.2 LMA 与 VMA:data 段的隐藏陷阱很多人在链接脚本里看到AT>语法就懵了。简单解释一下:VMA(Virtual Memory Address)是程序运行时数据所在的地址。LMA(Load Memory Address)是数据初始拷贝来源所在的地址。如果你的程序直接烧写在 SRAM 里运行,LMA = VMA,就是通常的情况。但如果你的程序在 flash 里运行、需要把 data 段拷贝到 RAM 里,那么 LMA 在 flash,VMA 在 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 -march=rv32imac -mabi=ilp32 -nostdlib -Tlink.ld \ -c startup.S -o startup.o riscv-none-elf-gcc -march=rv32imac -mabi=ilp32 -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几个关键参数解释一下:-march=rv32imac表示支持整数乘法除法、原子指令、压缩指令。根据你的芯片是否支持,选择对应的扩展集。-mabi=ilp32表示整数、指针、长整型都是 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 放在0x80000000,RAM 大小默认 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 Mode,PMP 基本是个可有可无的东西。但一旦你想跑一个简单的 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(); /* 等待中断 */ } }wfi(wait 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 体系结构的完整认知。
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 更灵活——mtvec(Machine 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位,无 MMU,M 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 的寻址指令lui+addi组合最多覆盖 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) /* FS=01 初始状态 */ 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 或配置 PMP(Physical 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 要求某些指令和数据按边界对齐。比如lw(load word)要求地址是 4 的倍数,如果地址不对齐,会触发 Load Address Misaligned 异常。栈指针建议按 16 字节对齐,这是为了兼容可能的 SIMD 指令和 ABI 规范。3.2 LMA 与 VMA:data 段的隐藏陷阱很多人在链接脚本里看到AT>语法就懵了。简单解释一下:VMA(Virtual Memory Address)是程序运行时数据所在的地址。LMA(Load Memory Address)是数据初始拷贝来源所在的地址。如果你的程序直接烧写在 SRAM 里运行,LMA = VMA,就是通常的情况。但如果你的程序在 flash 里运行、需要把 data 段拷贝到 RAM 里,那么 LMA 在 flash,VMA 在 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 -march=rv32imac -mabi=ilp32 -nostdlib -Tlink.ld \ -c startup.S -o startup.o riscv-none-elf-gcc -march=rv32imac -mabi=ilp32 -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几个关键参数解释一下:-march=rv32imac表示支持整数乘法除法、原子指令、压缩指令。根据你的芯片是否支持,选择对应的扩展集。-mabi=ilp32表示整数、指针、长整型都是 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 放在0x80000000,RAM 大小默认 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 Mode,PMP 基本是个可有可无的东西。但一旦你想跑一个简单的 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(); /* 等待中断 */ } }wfi(wait 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 体系结构的完整认知。