搞 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 Mode;mstatus.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段内容在编译时属于 Flash(LMA 在 Flash),运行时却必须被搬到 SRAM/DDR(VMA 在 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 = -march=rv64gc -mabi=lp64 -mcmodel=medany -O2 -g ASFLAGS = -march=rv64gc -mabi=lp64 LDFLAGS = -nostdlib -static -T linker.ld -mcmodel=medany 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几个参数值得多说一句。-mcmodel=medany是 RISC-V 大地址代码模型,生成绝对可达代码,避免auipc+addi处理不了超长跳转的问题。-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 Instruction | mstatus 的 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 还是 0,mstatus是一个未知的复位值,这种状态就是你启动失败的第一现场。我在实战中还有一个很顺手的三步法:先在 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,这一眼足够规避掉绝大多数所谓“玄学问题”。