RISC-V裸机启动深度解析:从复位向量到多核同步的避坑指南

发布时间:2026/10/30 1:14:08
RISC-V裸机启动深度解析:从复位向量到多核同步的避坑指南

从 ARM 裸机跳过来写 RISC-V 的 bare-metal 启动流程,最大的感受是:ARM 的启动文件往往是现成的,你只需要改改向量表基地址、堆栈大小,链接脚本调一调就能跑;而 RISC-V 生态里很多环境连一份像样的 startup 汇编都要自己动手,复位后 PC 落在哪里、栈指针什么时候立起来、数据段怎么从 Flash 搬到 RAM,每一步都要自己确认。这篇是 RISC-V 系列专栏的第七期,我就把启动与 bare-metal 流程从头捋一遍,内容包括 reset vector、链接脚本的布局约定、启动汇编的逐条逻辑、trap 向量的处理,以及多核启动时 hart 同步那点事。对于刚接触 RISC-V 又不想被汇编劝退的朋友,这篇文章可以当一份"启动阶段的避坑手册"来用。

1. 复位向量:RISC-V 进入 C 语言世界前的第一站

1.1 reset vector 到底是什么

先说结论:RISC-V 手册并没有规定复位后 PC 必须从哪个地址取指,它把"复位向量"留给了具体实现来决定。这是和 ARM 系明显不同的地方——ARM Cortex-M 的复位向量是 0x00000000,里面一开始放的是 MSP 初始值和 Reset_Handler 地址,而 RISC-V 处理器厂商可以根据 SoC 的存储架构自由选择。

常见的复位地址有这么几类:

  • 0x80000000:很多把 DDR 或大块 SRAM 排在最高地址的 SoC,会直接把复位地址指到这里,比如 QEMU virt 机器里 RAM 起点就是 0x80000000。
  • 0x00001000 / 0x00000000:低地址放 BootROM 的芯片,像有些 MCU 直接把 Flash 映射到 0 地址,复位就从 0 去取。
  • 0x20000000 这种:某些 SiFive 系列的处理器会把掩膜 ROM / Flash 放在这个段。

所以拿到一块新板子,第一件事就是查 datasheet 里"Reset Vector"那一节,确认这个地址指向哪里。如果复位地址指向 BootROM,那裸机程序通常不是直接从 BootROM 开始写的,而是 BootROM 负责把用户代码从 Flash 拷到 RAM,再跳转到用户代码入口;如果复位地址直接就落在 Flash 上,那第一条指令就是你烧进去的固件开头。

1.2 复位瞬间,硬件给了你什么状态

RISC-V 复位之后,有几个"出厂默认状态"是写启动代码必须依赖的:

  • 特权级固定在 Machine Mode,也就是最高特权模式,所有 CSR 都能访问,MISA、MSTATUS 等寄存器可以随便配置。
  • PC 被拉到 reset vector,每个 hart(硬件线程)都有自己的 PC,如果有多核,每个核都在跑同一条启动代码路径。
  • mhartid 这个 CSR 可以读出当前 hart 的编号,主核一般是 0,或者由芯片自定义哪个核是启动核。

这里就引出一个关键的启动设计约束:因为每个 hart 都从 _start 开始执行,启动代码必须通过 mhartid 来区分主核和从核。主核负责做全局初始化(清 BSS、搬数据、配时钟、设中断入口),从核则通常是自旋等待,等主核把环境准备好之后再被"放行"。这套逻辑在汇编里就是很简单的几行判断,但它决定了多核系统能否稳定起来。

复位向量的取指还有一点要注意:如果 PC 指向的地址上没有有效指令,硬件会触发取指错误。裸机调试时最常见的现象是 GDB 单步第一步就跳飞到 0 或者一个奇奇怪怪的地址,然后陷入异常循环。所以上来先确认"第一条指令落在哪",比什么都重要。

2. 链接脚本:启动代码的棋盘与边界

2.1 为什么必须在启动阶段搞"数据搬家"

嵌入式程序往往存在一个很别扭的处境:代码放在只读存储里(Flash),运行起来要用的变量却必须放在 RAM 里。

编译完成后,.text(代码段)和 .rodata(只读常量)可以直接烧在 Flash 里原地执行;但 .data(初始化了的全局变量)不行。比如你在 C 里写了int g_counter = 5;,这个 5 在烧录文件里是放在 Flash 的,可程序一跑,g_counter这个变量的地址在 RAM 里。RAM 断电不保存数据,所以硬件没法直接把 5 留在 RAM 里,必须在启动时把 Flash 里存的 5 搬到 RAM 中那个变量的地址上。这一搬,就是启动汇编最主要的工作之一。

. However 至于 .bss(未初始化静态变量),它在烧录文件里根本不占空间,但运行时需要一块清零的内存区域。C 语言规定未初始化的全局变量和静态变量默认值是 0,所以启动时要把 .bss 段清零。如果跳过这一步,你会在后面看到"局部变量莫名有初值"的诡异 bug,实际上那是上一次程序运行残留的数据。

2.2 启动代码怎么知道从哪里搬、搬多少

这就要靠链接脚本里预定义的那几个符号了。原因很简单:汇编里没办法写死"Flash 里 .data 的起始地址是多少",因为链接之后地址才会确定。链接脚本通过给段贴标签的方式,把地址信息暴露给启动代码。

一份典型的 RISC-V 链接脚本片段长这样:

ENTRY(_start) MEMORY { /* 假设 ROM 从 0x80000000 开始,RAM 由 SoC 定义 */ rom (rx) : ORIGIN = 0x80000000, LENGTH = 256K ram (rwx) : ORIGIN = 0x80040000, LENGTH = 192K } SECTIONS { .text : { KEEP(*(.text.entry)) *(.text*) } > rom .rodata : { *(.rodata*) } > rom _sidata = LOADADDR(.data); .data : ALIGN(4) { _sdata = .; *(.sdata .sdata.* .data .data.*) _edata = .; } > ram AT> rom .bss : ALIGN(4) { _sbss = .; *(.sbss .sbss.* .bss .bss.*) _ebss = .; } > ram }

这里最关键的是.data段的> ram AT> rom语法。它的意思是:VMA(运行时虚拟地址,也就是程序运行时变量的访问地址)在 RAM 里,但 LMA(加载地址,也就是烧录文件里数据存放的位置)在 ROM 里。_sidata = LOADADDR(.data)就是把 LMA 地址传给_sidata这个符号。

而.data段内部用.(当前地址计数器)记录了运行时起始_sdata和结束_edata,.bss段记录了_sbss和_ebss。启动代码要做的事情就变成了:把_sidata处的一段数据,拷贝到_sdata到_edata的内存区域;再把_sbss到_ebss的区域全部填 0。

如果你习惯看 ARM 的分散加载文件,就会觉得这套思路很亲切。区别在于 ARM 的 C 运行时库可能帮你把数据搬运做掉了,而 RISC-V 裸机环境里,这件事默认就是 startup 汇编自己写的。

2.3 链接脚本出错的几种典型表现

  • 链接时报错undefined symbol: _sdata:说明脚本里没定义或在被汇编引用的翻译单元里不可见。要么补上标签,要么检查是否为GLOBAL。
  • 启动后全局变量值不对:多半是.data段的 LMA/VMA 关系写错了,导致拷贝源地址不对。
  • 程序一进 main 就异常:有时是.bss和.data重叠了,或者栈区域被某个段覆盖。链接脚本里的ALIGN(4)不能省略,RISC-V 规范里某些 CSR 和原子操作对地址对齐要求很严。

提示:写启动代码的头几次,建议把链接脚本里的 ORIGIN 和 LENGTH 对着存储器映射表核对三遍。地址错了不一定会链接失败,但一定会运行失败,而且失败方式非常隐蔽。

3. 启动汇编逐条拆解:栈、数据与 BSS 的接力

3.1 _start 入口处的第一件正事:立栈

现在终于进入动手环节。启动汇编通常放在独立的.text.entry段里,理由是要保证链接脚本可以把这段固定在复位入口处。

第一段典型的启动汇编长这样:

.section .text.entry .globl _start .type _start, @function _start: /* 从 CSR 读取当前 hart id */ csrr t0, mhartid /* 从核跳去自旋等待,主核继续往下走 */ bnez t0, secondary_halt /* 主核:初始化栈指针 */ la sp, _stack_top /* 可选:初始化全局指针 gp,用于缩短全局变量寻址 */ .option norelax la gp, __global_pointer$ .option relax /* 初始化 trap 向量,先指向我们自己的 trap_entry */ la t0, trap_entry csrw mtvec, t0

为什么先立栈?因为接下来调用 C 函数、保存现场、处理异常全都要靠栈。RISC-V 的栈是向下增长的,栈指针应该指向栈区的最高地址。链接脚本里要预留一块栈内存,然后用符号_stack_top暴露出来,例如在 SECTIONS 外面加一句PROVIDE(_stack_top = ORIGIN(ram) + LENGTH(ram));。这叫"栈顶",注意不是栈底。

la sp, _stack_top之后,栈就可用了吗?严格来说还缺一步:如果 SoC 要求栈地址按 16 字节对齐(RISC-V ABI 要求sp在调用边界保持 16 字节对齐),链接脚本要做好对齐,或者干脆在汇编里andi sp, sp, -16强制对齐。我之前忽略这一步,结果调用一个带 double 参数的函数时,load 指令直接触发 misaligned 异常。

3.2 数据搬运与 BSS 清零的汇编细节

一个性能尚可的拷贝循环是 4 字节一搬,如果你知道段对齐到 16 字节,也可以用lw/sw处理,甚至一次搬 8 字节(RV64 用ld/sd)。下面这段是 RV32 的写法:

/* 把 .data 从 ROM 拷贝到 RAM:_sidata -> _sdata .. _edata */ la t0, _sidata la t1, _sdata la t2, _edata copy_data: bgeu t1, t2, bss_init /* 如果 t1 >= t2 说明拷贝完了 */ lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j copy_data bss_init: /* BSS 清零:_sbss .. _ebss */ la t0, _sbss la t1, _ebss clear_bss: bgeu t0, t1, call_main /* 清零完成 */ sw zero, 0(t0) addi t0, t0, 4 j clear_bss call_main: /* 进入 C 运行时初始化 */ call __libc_init_array call main /* main 如果意外返回,挂在这里 */ loop: j loop

写这段汇编时有几个坑我要强调一下:

  • 判断结束用bgeu(无符号比较)而不用beq,因为地址差可能不是 4 的整数倍,直接比较地址大小更稳妥。
  • 如果.data段为空,_sdata和_edata可能指向同一个位置,循环一次都不会执行,属于正常现象。
  • __libc_init_array在 C++ 工程里负责调用全局构造函数,在纯 C 工程里可以不加,但加上也不会有副作用。

有一个细节容易被忽略:这段代码本身在 .text 段里,如果启动代码从 Flash 原地执行,没有任何问题;但如果 SoC 要求系统启动阶段先把代码镜像从 Flash 拷到 RAM 再运行(比如某些包含 BootROM 的芯片),那你就得在这段数据搬运之前,先做一次"自我搬移",把 .text 段也搬进 RAM。这种场景里启动汇编会被分成两段,前面那段跑在 Flash/BootROM 中,后面那段跑在 RAM 中。搬移用的还是同一套 LMA/VMA 机制,只是把.text段也搞成> ram AT> rom。搞不清楚这一点,代码会在某个时刻突然开始乱跳,这就是 PC 跟着 Flash 里的旧代码走、但数据已经跑到 RAM 里去的结果。

3.3 从"汇编世界"跨入"C 世界"的交接准备

主核在跳进 main 之前,除了立栈和数据搬运,一般还会做这些事:

  • 配置 PMP(物理内存保护)条目,确保 M 模式能访问全部内存和 IO 区域。RISC-V 默认复位后 PMP 可能不开放全部地址,某些实现里越权访问会直接触发异常。
  • 设置mstatus.MPRV或清掉一些默认位,保证后续访问行为符合预期。
  • 如需使用浮点,启用mstatus.FS位,否则浮点指令会被视为非法指令。

内存保护这个东西最容易被人忽略。你在 QEMU 上跑没问题,因为 QEMU 的默认 PMP 很宽容;但真实芯片上,PMP 没配好,可能连访问外部 SPI Flash 都触发异常。启动代码里在该配置 PMP 的位置写上csrw pmpaddr0, ...和csrw pmpcfg0, ...是我的习惯动作,宁可每次都用不到,也不能等踩到了再来补。

跳转到 main 之后,启动流程就算是"把接力棒交给了 C 世界"。不过在真正的嵌入式工程里,main 之前往往还要初始化一个最小串口、给调试留个口子,这在后续的开发中会是救命稻草。我的习惯是启动代码里尽早配好 UART,哪怕只输出一个字符,也能确认 PC 确实跑到了预期位置。

4. trap 入口:别让中断砸穿裸机系统

4.1 mtvec:你的异常和中断都会先到这里

C 启动完成并不意味着万事大吉,因为裸机程序活在真实硬件里,任何时间都可能出现异常和中断。RISC-V 把所有异常和外部中断统称为 trap,M 模式的 trap 入口由mtvec寄存器决定。

mtvec不是简单一个地址,它还包含模式位。最低两位表示模式:

  • 00:Direct 模式,所有 trap 都跳转到mtvec指向的同一个地址。
  • 01:Vectored 模式,中断按 cause 跳转到不同偏移的入口,异常还是走基地址。

裸机启动阶段,我强烈建议先用 Direct 模式,也就是mtvec最低两位写成 00。因为 Vectored 模式要求有一个完整的中断向量表,每个中断入口都放着跳转指令,工程还小的时候没必要给自己增加这个复杂度。把mtvec = &trap_entry就足够了。

mtvec的地址对齐要求是 4 字节对齐。Direct 模式下这个限制很容易满足,但如果后面对接了 RTOS,或者要支持多个中断源,再切 Vectored 模式也不迟——切的时候记得把对齐改成4 * 中断向量条数的最小对齐。

4.2 一次 trap 保存现场的完整动作

trap 进入后,硬件自动做的只有几件事:

  • PC 存入mepc。
  • 触发原因存入mcause(负数表示中断,正数表示异常)。
  • mstatus.MIE被硬件清除,中断被暂时屏蔽,同时老的 MIE 被保存到mstatus.MPIE。
  • 特权级相关的mstatus.MPP记录之前的模式。
  • 可能附带一些信息存入mtval,例如非法地址、非法指令编码。

但硬件不会帮你保存通用寄存器。如果不在 trap 入口处保存所有 GPR,那这个 trap 一返回,现场就全丢了。所以 trap_entry 的第一件事,就是分配栈帧并压栈。

下面的 trap 入口是简化版,但结构完整:

.section .text .globl trap_entry .align 2 trap_entry: /* 先腾出栈空间,一次性保存现场 */ addi sp, sp, -32 * XLEN_REG_SIZE /* 按惯例保存 ra 和临时/参数寄存器,实际工程可扩展到全部 32 个 GPR */ SAVE_GPRS /* 这里用宏展开,逐个 sw 到 sp 偏移处 */ /* 读 cause,判断该走哪个分支 */ csrr t0, mcause bltz t0, is_interrupt is_exception: /* 异常处理:例如打印 mepc、mcause、mtval 后停机 */ call exception_handler j trap_return is_interrupt: /* 中断处理:根据 mcause 低位置判断来源 */ call irq_handler trap_return: /* 恢复 GPR */ LOAD_GPRS /* 返回前清掉本帧 */ addi sp, sp, 32 * XLEN_REG_SIZE mret

这里最值得说的就是最后那个mret,它和普通ret有本质区别。mret会把mepc写回 PC,同时用mstatus.MPP恢复之前的特权级、用MPIE恢复 MIE。如果你图省事用ret,那就是跳去 ra 寄存器指向的地址,完全偏离了被中断打断的现场,而且特权级也不会恢复,一场 Live 调试立刻变成灾难现场。

保存 GPR 的宏也是一个容易出错的地方。RISC-V 有 32 个整数寄存器,异常入口我一般全保存,因为中断处理里可能跑 C 函数,而 C 函数会按 ABI 随意使用临时寄存器和参数寄存器。只保存部分寄存器虽然省一点栈空间,但排查起来太痛苦了,不值得。

4.3 中断使能顺序:最后的开关要最后合上

能在 trap 入口处理中断,不等于中断就会自己进来。RISC-V 的中断开启是"Do 级联"的:

  1. 需要配置mie寄存器,把对应中断源打开(比如 MTIE 定时器中断、MSIE 软件中断、MEIE 外部中断)。
  2. 需要把mstatus.MIE置 1,这是全局总开关。

我遇到不少新手,把mie配好了却忘了开mstatus.MIE,然后在主循环里一直轮询等待,中断就是不触发。反过来,如果先把mstatus.MIE打开了,再写mtvec,中途一个非法访问异常就会跳到还没配好的入口,结果是直接卡死。所以标准顺序是:先配mtvec,再开mie各个中断源,最后才置mstatus.MIE。

还有个细节:mstatus.MIE可以用csrw写整个 mstatus,但那样会把其他字段也一并改掉,容易误伤 FPU 配置和 MPRV。更安全的做法是用csrwi或csrw只操作对应位,比如csrsi mstatus, 0x8把第 3 位置 1。这类"只改一个位"的操作,在启动代码配置中能避免很多莫名其妙的副作用。

5. 多核启动:每个 hart 都从 _start 跑,谁当主核谁当从核

5.1 主从核的约定与共享标志的原子操作

多核系统里,芯片上电后每一个 hart 都会从同一个入口执行_start。如果每个核都做一遍全局初始化,数据搬运和 BSS 清零都会重复执行,而后启动的核完全可能把前面核已经初始化好的数据结构再踩一遍。所以启动编码的第一条分支就是:

csrr t0, mhartid bnez t0, secondary_wait

主核(通常是 hart 0)走正常初始化路径;从核则进入一个等待循环,等待主核发出的"可以开始你自己的工作"的信号。

这个信号怎么传?最简单也最安全的做法,是在一块公共内存里放一个标志变量:

secondary_wait: /* 死等标志位变成 1 */ la t0, boot_flag 1: lw t1, 0(t0) beqz t1, 1b /* 被释放后,给从核立自己的栈 */ csrr t0, mhartid slli t0, t0, 12 /* 每个核栈大小按 4KB 计算 */ la sp, _stack_top sub sp, sp, t0 /* 从核之后可以直接进入各自的应用入口 */ call secondary_main

主核初始化完成后,在某个合适的时机把boot_flag写成 1。这里有个东西必须考虑:内存可见性。从核的等待循环在另一条硬件线程上,如果主核写标志之前还有内存写的操作,从核不一定立刻能看到那些写的结果。RISC-V 内存模型里,普通读写是弱有序的,跨 hart 共享数据时需要合适的同步原语。在标志写入前后加fence指令,或者用原子指令直接操作标志位,是常见做法。

使用原子指令的例子是amoswap:

li t1, 1 amoswap.w t2, t1, (t0) /* 把 1 换入 boot_flag,并返回旧值 */

这段主核代码的好处是:能顺便拿到旧值,判断是不是已经有核把这个标志设置过了,避免重复发布。从核那边也可以用lr/sc或fence实现更精细的同步。

注意:多个 hart 共享同一个栈区是启动崩溃的头号元凶。每个核必须按 hartid 计算独立栈区,否则一个核的嵌套调用会把另一个核的栈给踩烂。最简单的方式就是上面那样,栈顶减去hartid * 栈大小。

5.2 复杂 SoC 里的多阶段启动:从 BootROM 到主镜像

如果去看一些大型 SoC(比如我做过的 4G/5G 调制解调器,像高通 SDX85 这类芯片),你会发现它们在完整系统层面上的启动也是"接力"形式:芯片内部 BootROM 先跑极短的固化代码,初始化最基本的外设,然后把下一级引导程序搬进 SRAM;引导程序再接替初始化 DDR、时钟,最后才把主系统镜像加载到内存并跳转。这个链条上每一级都承担了"上一级铺路,下一级跑长跑"的角色。

把这个思路放回 RISC-V 裸机工程里,你完全可以把自己的启动代码当成一个小型 BootROM 看待:硬件复位跑_start,做最基本的栈和数据初始化;尤其当你的最终应用不止一个镜像,而是包含 SPL、主固件、可更新的 App 分区时,这个多阶段分层会非常有用。裸机启动流程的"Part 1 负责为自己跑起来铺路,Part 2 负责为应用层铺路",本质和大型 SoC 的启动思想是一致的。

在多核正式进入应用之前,还有一个隐藏任务:如果要使能 I-Cache/D-Cache 和 MMU,通常是在这一步做。对复杂 SoC,从 bootloader 阶段开启 MMU 时,还要确保页表所在内存对每个 hart 可见,不然某个核一开启虚拟地址就立刻捉瞎。我见过的案例里,有核因为没等共享内存同步就使能了 MMU,最后 PC 直接飞到物理地址上,怎么追都追不回来。所以我的经验是:多核启动阶段宁可多等,不可抢跑;所有共享内存的写入顺序比几个周期的性能更要紧。

6. 启动调试的几个高频坑与排查思路

6.1 现象一:完全没有输出,GDB 卡在莫名地址

第一个典型失败现场:烧录后串口什么都没打印,调试器一 attach 发现 PC 停在某个不是_start的地址。

排查思路按顺序来:

  • 确认复位向量地址和链接脚本里入口是否一致。用 GDB 在_start下断点,直接continue,如果断不下来,说明代码根本没被放在复位向量指向的位置,查链接脚本和烧录地址。
  • 确认栈是否立好。复位后在 trap 入口或 C 函数调用时,若 sp 还是 0 或者指向非法内存,任何call指令都会把返回地址写到神秘区域,程序直接失控。
  • 确认能否取指成功。有的 Flash 需要在启动时等待控制器就绪,否则第一条指令执行就触发异常。看mcause的值,如果是取指错误,优先检查 Flash 控制器和时钟初始化。

我还习惯在_start的第一行放一个可读的"指纹":往一个已知 GPIO 寄存器写一个特征值,或者直接往 UART TX 写一个固定字节。硬件单步虽然慢,但能最快定位问题是出在取指、还是出在第一条指令本身。

6.2 现象二:变量值对不上,程序越跑越诡异

如果全局变量在 main 里读出来不是初始值,十有八九是.data拷贝没做对,或者.bss没清零。排查手法如下:

  • 用调试器看_sidata处的内存是什么,再看_sdata地址处的内存是什么,两者相等则说明搬运动作做了。
  • 如果拷贝循环一次都没有进入,检查_sdata和_edata值是否相等。相等表示.data段是空的,这种情况下全局变量应该没有初始值才对。
  • 如果 BSS 没清零,最常见的结果是某个全局标志"碰巧"是 0x5A,程序判断错误走进了诡异分支。这种 bug 比变量值不对更隐蔽。

有一个很经典的问题:链接脚本里用了> ram AT> rom,但_sidata是用LOADADDR(.data)取的;如果你在.data段内部手动定义了_sidata = .,那就取成了 VMA 而不是 LMA,源地址和目的地址变成同一个,搬运等于自我赋值,全局变量自然全错。这个我在给一个 RISC-V 开发板做移植时踩过,后来看到程序里打印出来的"正确初始值"全都是零,才回头查链接脚本。

6.3 调试工具链的一点点实战建议

当前调试 RISC-V 裸机启动,我用得比较多的是 OpenOCD + GDB 的组合。下面几条操作很实用:

  • 在 trap_entry 处也下一个断点,这样任何异常都能在第一时间停下来,然后读mepc、mcause、mtval三个 CSR,就能定位是哪条指令触发的。
  • 如果你的调试器支持,直接读写 CSR 是最快的方式。GDB 里通常可以用monitor reg或者带扩展的 gdb 命令查看mtvec、mepc等。
  • QEMU 模拟器作为起步工具极其合适,-machine virt -kernel your.elf -s -S拉起 GDB,复位后单步汇编,观察栈和内存变化,比在真实芯片上抓瞎快得多。不过要记住:QEMU 默认对很多 CSR 行为很宽松,真实芯片上该配的 PMP、时钟一个都不能少。
  • 有条件的话给启动代码加 semihosting,在汇编阶段通过调试器向宿主机打印字符。这样在串口驱动都还没初始化的时候,也能看到启动走到了哪一步。这个方法我强烈推荐,尤其适合多核启动的场景,因为每个核都能打出自己的 hartid,立刻能看到哪个核卡在哪个等待循环。

还有一个小习惯值得分享:每次改板子或者换工具链版本,我建议从头跑一遍启动流程验证,不直接跑业务代码。宁愿花十分钟在_start单步走一遍,也别等应用崩溃之后再来怀疑启动代码有没有问题。这个投入回报率非常高。

这次启动流程的笔记就到这里。裸机开发的乐趣和折磨都在这些底层细节里:一次正确的地图勾勒、一次低压的汇编循环、一次苛刻的 CSR 配置,都决定着上层 C 代码能否有尊严地运行。后面专栏里我会继续写 RISC-V 的中断驱动框架、外设寄存器封装,以及在真实 SoC 上移植 RTOS 的经验,如果你也在折腾 RISC-V 的 bare-metal 启动,欢迎交流各自的踩坑记录。