1. 从复位向量到 main 函数:RISC-V 裸机启动全景接触 RISC-V 裸机开发的人,第一道坎往往不是外设驱动,而是“程序到底怎么跑起来的”。在 Linux 等操作系统环境下,启动流程被引导程序、内核、文件系统层层封装,开发者通常不需要关心上电瞬间发生了什么。但裸机开发完全不同,从芯片复位到 main 函数执行,中间每一步都是开发者自己说了算,每一步出错也都只能自己排查。我之前做某款 RISC-V 开发板的裸机实验时,第一次烧录自己写的启动代码,程序死活不跑。用调试器挂上去看,PC(程序计数器)停在复位向量处反复跳不出来。折腾了大半天,最后发现是链接脚本里栈指针初始化的位置不对,栈顶放到了不存在的内存区域,程序一进启动汇编就跳飞。这种经历在裸机开发中太典型了:不是逻辑写错,而是启动流程里一个看似不起眼的细节没对上。RISC-V 的启动和 bare-metal 流程,本质上就是三件事:把处理器从复位状态拉起来、把运行环境准备好、把控制权交给 C 语言世界。具体来说,启动流程要完成下面这些工作:确定复位向量位置,让处理器上电后能找到第一条指令;根据启动介质(片内 Flash、SPI Flash、JTAG 调试器等)选择启动模式;执行启动汇编代码,完成栈指针(sp)初始化、全局指针(gp)设置;清零 BSS 段、搬运数据段,为 C 语言运行环境做准备;调用 C 库初始化(如需要)和 main 函数,进入业务逻辑。这个流程和 ARM 的裸机启动在思路上类似,但 RISC-V 因为是开放指令集,不同厂商、不同开发板的复位地址、内存映射、启动模式差异很大,没有任何一家能像 ARM 的 CMSIS 那样给出一个打包好的通用启动文件。所以理解 RISC-V 的启动原理,而不是照搬某个模板,是绕不开的基本功。这篇文章我打算从启动模式的选择讲起,然后重点拆解链接脚本怎么写、启动汇编每一条指令在干什么,最后结合实际调试经验,整理几个常见问题。整体内容适合正在从单片机开发转向 RISC-V、或者刚开始接触 RISC-V 裸机编程的人作为参考。2. 启动模式与内存映射:为什么要反复确认复位地址2.1 上电复位后处理器在执行什么RISC-V 处理器上电复位后,硬件会强制把 PC 设置为某个特定地址,这个地址就是复位向量。处理器从复位向量开始取指执行,而这条路径上放的是启动代码。问题在于,复位向量到底是多少,完全取决于具体实现,不是指令集规范统一规定的。最常见的几种情况:某些简单核固定从 0x00000000 启动,所以 Flash 或 ROM 必须映射到这个地址。有些核支持从 0x00001000 或 0x20000000 等地址启动,需要查看数据手册确认。部分芯片支持多启动模式,通过硬件引脚的电平选择从片内 Flash 还是外部 SPI Flash 启动。我用的某款开发板支持 BOOT 引脚选择启动源,默认从片内 Flash 启动。可当时没仔细看手册,想当然认为复位向量是 0x00000000,结果链接脚本里把启动代码放到了另一个地址,程序自然跑不起来。后来用调试器连上去,查看复位后的 PC 值,才发现实际复位地址是 0x20000000,不是我以为的地址。这件事给我的教训是:拿到任何一款新的 RISC-V 芯片,第一步就是查手册里的启动/复位章节,确认三个关键参数:复位地址、启动介质映射地址、外设基地址。这三个参数决定链接脚本怎么分区、启动代码怎么写、外设寄存器怎么访问。除此之外,还要看芯片是否支持 BOOT 引脚配置,以及不同配置下地址映射会不会变化。2.2 片内 Flash 启动与调试器启动的本质差异除了复位地址,启动模式还决定开发调试阶段的工作方式。片内 Flash 模式是最终产品的正常启动路径,程序烧录到 Flash,上电后处理器直接从 Flash 取指执行。而调试器启动模式通常是通过 JTAG 接口把程序加载到 RAM 中运行,处理器直接从 RAM 取指。两者的内存映射不同,代码的运行位置也可能完全不同。这就产生了一个问题:链接脚本里的链接地址,到底用哪个?如果用 Flash 启动的链接地址去跑调试器加载的 RAM 程序,两个地址对不上,程序跳转和绝对寻址都会出错。我之前调试时就遇到过这种情况,每次用调试器加载程序后运行,一跑到绝对跳转指令就崩。后来才发现,链接脚本用的是 Flash 地址,但调试器把程序加载到了 RAM 地址,地址不匹配当然出问题。解决方式也很直接:调试阶段专门维护一套 RAM 链接脚本,把运行地址改成 RAM 段,不用的 Flash 段注释掉;正式发布时再切回 Flash 链接脚本。麻烦是麻烦一点,但省掉来回排查地址错乱的精力,值得。项目片内 Flash 启动调试器加载到 RAM 启动复位后 PC指向 Flash 映射区起始地址指向调试器设置的加载地址代码存放位置片内 Flash 非易失存储片内 RAM 易失存储数据段加载需要启动代码从 Flash 拷贝到 RAM由调试器直接初始化,启动代码可跳过典型使用场景产品发布、脱机运行开发调试阶段常见坑Flash 擦写寿命、启动配置错误链接地址与加载地址不一致导致跳转错乱调试阶段用 RAM 启动还有个额外好处:省去 Flash 擦写时间。调试一次烧写一次 Flash 非常耽误事,RAM 加载只需要几百毫秒,频繁改代码、跑测试的效率完全不一样。3. 链接脚本:裸机程序的“地契”与“地址地图”3.1 为什么链接脚本在裸机开发中如此关键很多人从 C 语言课开始就没搞清楚编译和链接到底各干了什么。编译把 C 代码翻译成机器指令,但此时指令里用到的变量地址、函数地址都只是“符号”,没有确定的内存位置。链接阶段才把这些符号落实到具体地址上,而链接脚本就是告诉链接器“这段代码放在哪个地址范围、那段数据放在哪个地址范围”的规则文件。裸机程序没有操作系统的加载器帮忙安排地址,一切都必须靠链接脚本写清楚。Linux 下编译一个程序,链接器会使用系统默认的链接脚本,把程序放到标准的虚拟地址空间;裸机开发没有这种默认规则,每个芯片的内存布局都不一样,链接脚本必须手工编写。链接脚本里最核心的要素包括:内存区域定义(MEMORY)、段布局(SECTIONS)、入口点设置(ENTRY)。内存区域定义告诉链接器这个芯片有哪些可用的 RAM 和 Flash,各有多大、从什么地址开始。段布局则说明代码段、只读数据段、数据段、BSS 段分别放到哪个内存区域、按什么顺序排放。入口点则是链接器生成的可执行文件里标记的程序起始地址,裸机程序通常指向复位向量处的启动汇编代码。编写链接脚本时,有一个非常容易忽略的细节:栈和堆的预留。栈是向下增长的,通常放在 RAM 的高地址端,栈指针初始化为链接脚本里定义的栈顶地址;堆用于动态内存分配,一般紧接着数据段后面预留。如果不预留在链接脚本里单独声明,后面用调试器观察栈使用情况和内存分配时,会非常混乱。3.2 一个最小可用的 RISC-V 裸机链接脚本逐段拆解下面这份链接脚本是我在某个模拟项目 X 里实际用过的简化版本,适合单核、片内 Flash 启动、内存映射简单的场景。我逐段说明每行的作用。/* memory 区域定义 */ MEMORY { FLASH (rx) : ORIGIN = 0x20000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20080000, LENGTH = 128K } /* 入口点声明 */ ENTRY(_start) SECTIONS { /* 中断向量表和启动代码 */ .text.init : { *(.text.init) } > FLASH /* 代码段 */ .text : { _stext = .; *(.text*) _etext = .; } > FLASH /* 只读数据段 */ .rodata : { *(.rodata*) } > FLASH /* 数据段(初始值在 Flash,运行位置在 RAM) */ .data : { _sdata = .; *(.data*) _edata = .; } > RAM AT > FLASH /* BSS 段 */ .bss : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } > RAM /* 栈顶地址定义 */ .stack : { . = ALIGN(16); . = . + 0x4000; _stack_top = .; } > RAM /* 堆地址定义 */ . = ALIGN(16); _heap_start = .; _heap_end = ORIGIN(RAM) + LENGTH(RAM) - 4; }几个关键点需要展开说明。.data段的> RAM AT > FLASH是这份脚本里最微妙的地方。它表示:程序最终运行时,数据段要放在 RAM 里,但初始值存放在 Flash 里。系统启动时,启动汇编代码负责把初始值从 Flash 拷贝到 RAM 的指定位置。这个过程叫“数据段加载”,或者叫“data copy”。如果没有这一步,运行在 RAM 里的全局变量初始值全是随机的,程序逻辑必然出错。链接器为了支持这种从不同 LMA(加载地址)到 VMA(运行地址)的映射,会自动生成两个符号_sdata和_edata,分别指向数据段在 RAM 中的起始和结束位置,以及_sidata(有时叫_la_data)指向 Flash 中初始值的起始位置,启动汇编就用这三个符号完成拷贝。BSS 段只定义了_sbss和_ebss,没有 AT 映射,因为这些变量没有初始值,只需要在启动代码里清零,不需要从 Flash 拷贝任何数据。清 BSS 这个操作在 C 语言标准中隐含了“未初始化全局变量自动为 0”的语义,裸机环境下必须由启动代码手工完成。这里还涉及链接器符号在汇编和 C 语言中的可见性。链接脚本里定义的_sbss、_ebss、_sdata等符号,本质上只是地址标号,不是 C 语言里的变量。在汇编启动文件里直接用la指令加载它们的地址即可;如果要在 C 代码里访问,需要用extern char _sbss[];这样的声明,取数组名就是地址。这个两者之间的区别,很多人第一次接触时会犯迷糊。3.3 内存布局的估算与预留方法链接脚本写完第一版后,记得做一次内存占用估算。对裸机程序来说,内存超量的后果往往不是启动时立刻爆掉,而是后续程序运行到某个特定分支时访问了不存在的内存地址,触发异常,非常难排查。所以链接脚本写完,我习惯用下面的方法快速验证。先用riscv64-unknown-elf-size查看各个段的大小。例如输出显示:text data bss dec hex filename 1200 256 1024 2480 9b0 main.elf然后对照链接脚本里的 Flash 容量和 RAM 容量,手工计算剩余空间。例如 Flash 512KB、RAM 128KB,那么 Flash 空间还剩 5121024 - 1200 - 256 = 523KB 左右,RAM 还剩 1281024 - 256 - 1024 - 16384(栈的预留)= 112KB 左右。这个计算虽然粗放,但能迅速发现明显的问题。再用riscv64-unknown-elf-nm -n main.elf检查各符号的地址是否能对上内存区域的边界。比如看到某个函数地址落在 Flash 之外,或者_stack_top超出了 RAM 范围,就说明链接脚本里有错误。还有一类问题值得注意:栈大小的预留。栈预留过小,C 函数调用稍微深一点就会压栈到非法地址;栈预留过大,则直接浪费宝贵的 RAM。折中的做法是先预留一个经验值(比如 16KB),后续通过调试器观察程序最复杂状态下的栈使用峰值,再调整到合适的值。往栈区域填充0xAA或者0xCC,跑完典型业务场景后用调试器找0xAA字节上边界,可以粗略估算实际栈使用量,这个方法我一直在用。4. 手工编写启动汇编:每一行指令都有存在理由4.1 最小启动代码的完整实现与逐步解释启动汇编文件一般叫startup.S或者crt0.S,是整个裸机程序里第一个执行的代码段,也是出错概率比较高的一个环节。下面这份实现是基于常见实践整理的,适用于大多数 RISC-V 单核裸机环境。.section .text.init .globl _start .type _start, @function _start: /* 关闭中断 */ csrw mie, zero csrw mip, zero /* 设置栈指针 */ la sp, _stack_top /* 设置全局指针(如果使用重定位的 small 模型) */ la gp, __global_pointer$ /* 数据段拷贝 */ la t0, _sidata la t1, _sdata la t2, _edata 1: bgeu t1, t2, 2f lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j 1b 2: /* BSS 清零 */ la t0, _sbss la t1, _ebss 3: bgeu t0, t1, 4f sw zero, 0(t0) addi t0, t0, 4 j 3b 4: /* 进入 C 语言世界 */ call main /* main 返回后,进入死循环 */ 1: j 1b逐段分析一下这段代码的逻辑和背后需要考虑的因素。.section .text.init把这段代码放到链接脚本里划分给启动代码的独立段中,确保复位向量跳转过来时,这里放的就是第一条指令。_globl _start导出_start符号,让链接脚本的ENTRY(_start)能找到它。csrw mie, zero和csrw mip, zero用于复位中断使能和中断挂起寄存器。这一步看起来简单,但非常必要。芯片上电后,某些实现可能默认中断使能位置位,或者残留一些挂起状态,如果启动过程中突然进入中断,而此时中断向量表还没准备好,程序会跳转到未知位置。所以启动的第一件事就是把中断彻底关掉,保证执行环境可控。la sp, _stack_top加载栈顶地址到栈指针。这一步执行完,C 函数调用的压栈操作就有地方放了。很多初学者会忽略一个问题:栈指针在启动代码里必须设置,而且要在跳转 C 函数之前设置完毕。如果在设置栈指针之前就调用了call,压栈会写到一个未初始化的地址,轻则随机覆盖数据,重则直接触发异常。la gp, __global_pointer$这一行和 RISC-V 的寻址方式有关。RISC-V 的lui/addi指令组合只能覆盖 32 位的地址范围,数据段和代码段如果分布较散,直接用绝对地址访问全局变量需要多条指令。编译器采用了一种更高效的方式:用 gp 寄存器保存一个全局指针,之后访问全局变量时用gp加上一个偏移量。这个__global_pointer$符号一般是链接器自动生成的,链接脚本里通常会用一段代码计算它应该指向的位置。不同的 RISC-V 工具链对这个过程的处理细节会有差异,有些芯片厂商提供的链接脚本会显式定义,有些则依赖编译器约定,所以这一行要根据具体工具链做调整。不使用的场景可以注释掉,但用了就必须确保链接脚本里存在对应的符号定义。接下来的数据段拷贝和 BSS 清零,就是对应前面链接脚本里> RAM AT > FLASH和 BSS 段定义的逻辑。循环逻辑很简单:比较当前地址是否到达结束地址,没到就继续搬运或者清零,一次搬运 4 字节。之所以一次搬运 4 字节而不是 1 字节,是为了提高效率,同时要求数据段和 BSS 段的起始地址都是 4 字节对齐的。链接脚本里通常用ALIGN(4)或.balign 4来保证这一点。最后call main跳转到 C 语言的main函数。注意,这里用的是call伪指令,它会被展开成一组加载绝对地址并跳转的指令,或者一对auipc+jalr指令,涉及位置无关代码和绝对跳转的区别。对于裸机程序,地址固定在链接脚本里,绝对跳转没有问题;但如果要做 Bootloader 这类需要在不同地址间移动的程序,就要仔细考虑是否改成相对跳转。4.2 数据段搬运的边界条件与性能考虑数据段搬运循环里有一个很容易踩的坑:地址对齐。lw和sw指令要求地址必须是 4 字节对齐的。如果数据段的起始地址或结束地址不是 4 的倍数,循环就会触发非对齐访问异常。这个问题在 x86 处理器上不太明显,因为 x86 支持非对齐访问,只是性能略差;但在 RISC-V 上,非对齐访问通常直接触发异常,程序瞬间崩溃。预防方式是在链接脚本里对_sdata、_edata、_sbss、_ebss这些符号的当前位置做 4 字节对齐。常见的写法:.data : { . = ALIGN(4); _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > FLASHALIGN(4)把当前位置向上对齐到 4 字节边界,这样_sdata和_edata就保证是对齐的。BSS 段也同理。这种对齐不仅解决了搬运指令的对齐要求,也让后面的变量访问更高效。另一个性能相关的考量是搬运宽度。上面用的是lw/sw一次搬运 4 字节,一般够用。更快的做法是用lbu/lhu按 1 字节或 2 字节搬运,但对齐要求更宽松,速度更慢;或者用向量指令 / 双字指令一次搬运更多字节,前提是处理器支持相应扩展。对大多数裸机应用来说,数据段通常只有几百字节到几 KB,4 字节搬运已经足够快,不需要过度优化。注意:启动代码里最好不要调用 C 库函数进行数据拷贝或清零,也不要依赖 C 编译器自动生成的初始化代码。这些代码本身可能依赖栈、依赖全局变量、依赖 C 库,而裸机启动阶段这些条件还不成立。老老实实用汇编手写循环,是最可控、最容易排查的方式。4.3 main 返回值与最终收尾启动代码在call main执行完后,不能直接退出,因为裸机程序没有操作系统可以返回。一旦 main 返回,PC 会回到启动代码的call指令下一条,如果我提前安排一个死循环的标号,就会落在那段死循环里。上面代码里的1: j 1b就是这个用途。有些人的启动代码会在main返回后执行ebreak进入调试暂停状态。这在调试阶段确实有效,适合配合调试器观察程序退出行为。但发布版固件不要用ebreak,否则程序会在异常指令上卡死,让看门狗重置,然后重复启动、崩溃、重置,看起来像系统不稳定。产品版用死循环或直接触发软件复位更可靠。5. 从编译到烧录:完整构建流程与实战记录5.1 工具链选择与编译参数设置RISC-V 裸机开发中最常用的工具链是 GNU 工具链,具体前缀根据目标架构不同而有差异。常见的有riscv64-unknown-elf-和riscv32-unknown-elf-。选择 32 位还是 64 位,取决于目标处理器内核的位数,一般看开发板的数据手册即可确认。编译启动汇编文件和 C 文件时,有几个参数必须谨慎设置。对于汇编文件:riscv64-unknown-elf-gcc -c -march=rv64gc -mabi=lp64 -nostdlib -ffreestanding -o startup.o startup.S-nostdlib告诉编译器不要链接标准 C 库,这是裸机程序的必要参数。-ffreestanding表示编译环境是独立环境,没有标准库和操作系统服务。-march和-mabi则指定目标架构和应用程序二进制接口。架构字符串里包含扩展名组合,比如rv64imac就是整数指令、乘法除法、原子指令、压缩指令的组合,gc通常是通用组合的简写。对于 C 文件:riscv64-unknown-elf-gcc -c -march=rv64gc -mabi=lp64 -nostdlib -ffreestanding -fno-builtin -O2 -o main.o main.c-fno-builtin防止编译器把某些标准函数调用替换成内置版本,避免对 C 库的隐式依赖。-O2是常规优化等级,如果调试需求多,可以先降低到-O0或者-Og,牺牲性能换可调试性。链接阶段:riscv64-unknown-elf-gcc -nostdlib -T link.ld -o main.elf startup.o main.o-T指定链接脚本。注意链接命令仍然用 gcc 包装器,而不是直接用ld,这样工具链的默认库路径和启动文件有关的配置不会被遗漏,反而更容易避开工具链版本差异带来的问题。最后生成烧录文件:riscv64-unknown-elf-objcopy -O binary main.elf main.bin如果需要生成 Intel HEX 或者带地址信息的上位机烧录格式,可以用-O ihex。5.2 烧录中遇到的第一现场问题记录我用某个某公司出的开发板做实验时,把编译好的main.bin烧录进 Flash,上电后板子没有任何反应,LED 不亮、串口也没有输出。反复烧了几次都一样。这种“完全没反应”的问题,优先怀疑启动流程而不是业务逻辑。排查从四个方向入手:第一步,确认烧录地址对不对。翻出之前烧录成功过的固件信息,核对 Flash 基地址和偏移。发现出厂固件从 0x20000000 开始,而我编译时链接脚本里把 FLASH 区域起点写成了 0x20000000,这一项没问题。第二步,确认复位向量是否指向正确位置。用调试器连接开发板,停止程序,查看当前 PC 值。结果 PC 停在 0x20000000 处,说明复位向量是对的。第三步,单步执行启动代码,观察栈指针设置和数据段搬运。用调试器单步跑了几条指令,发现la sp, _stack_top执行完后,sp 的值是 0x20084000,这个地址在 RAM 范围内,看起来没问题。可是继续往下跑,到la gp, __global_pointer$时,gp 的值居然是 0,这条指令执行后程序没报错,但后面访问全局变量的代码全乱了。第四步,回到链接脚本查__global_pointer$的定义。发现链接脚本里用了类似于PROVIDE(__global_pointer$ = ...)的写法,可那个表达式计算出来的值超出了数据段范围,导致链接器没有正确生成 gp 符号。我在链接脚本里加了一段显式的 gp 定位代码,比如把__global_pointer$定义为数据段起始地址加上一个固定偏移,并且用.option伪指令控制编译器的地址生成模型,重新编译烧录后,LED 正常点亮,串口也输出了打印信息。这个问题排查耗时将近一个下午,最后结论就是一个链接脚本符号定义错了。裸机调试中很多“看似完全无法理解”的现象,最终都指向链接脚本和启动文件这些底层细节,这也是为什么这篇文章花了大量篇幅拆解链接脚本和启动汇编的原因。5.3 构建流程自动化小技巧启动流程稳定之后,频繁的手工敲命令编译、烧录非常低效。我习惯写一个简单的 Makefile 把这些步骤固化下来,减少人为出错的机会。CROSS = riscv64-unknown-elf- CC = $(CROSS)gcc OBJCOPY = $(CROSS)objcopy ARCH = rv64gc ABI = lp64 CFLAGS = -march=$(ARCH) -mabi=$(ABI) -nostdlib -ffreestanding -fno-builtin -O2 ASFLAGS = -march=$(ARCH) -mabi=$(ABI) -nostdlib LDSCRIPT = link.ld OBJS = startup.o main.o all: main.bin main.elf: $(OBJS) $(CC) -nostdlib -T $(LDSCRIPT) -o $@ $(OBJS) %.o: %.S $(CC) $(ASFLAGS) -c -o $@ $< %.o: %.c $(CC) $(CFLAGS) -c -o $@ $< main.bin: main.elf $(OBJCOPY) -O binary $< $@ clean: rm -f *.o *.elf *.bin这个 Makefile 没有用到隐晦的自动依赖生成,保留了很高的可读性。实际项目再往里加链接后检查脚本,比如生成size报告、校验内存占用率,都能在这里扩展。裸机项目构建流程的核心追求是“稳定可复现”,一次配置,后续只管改代码、跑 make。6. 常见问题排查与避坑心得6.1 七个高频启动异常的对照速查表实际开发中,启动阶段的异常现象虽然千奇百怪,但根因往往集中在少数几个地方。我整理了一份速查表,方便定位问题:现象最可能的根因排查方法复位后 PC 停在非法地址复位向量配置错误或链接脚本入口点错误调试器查看复位后 PC,比对内存映射单步执行时跳转到意外位置链接地址和加载地址不一致对比链接脚本的 VMA 和调试器加载地址跳转 main 后立即崩溃栈指针未初始化或栈顶地址非法检查启动汇编是否执行la sp, _stack_top全局变量初始值错误数据段拷贝缺失或拷贝地址错误检查数据段搬运循环和_sidata符号BSS 变量不为 0BSS 清零缺失或清零范围错误检查 BSS 循环是否覆盖全部 BSS 段使用 gp 寻址的程序跑飞__global_pointer$未正确定义检查链接脚本 gp 符号定义,观察gp寄存器值编译可以,烧录后完全不运行Flash 起始地址和链接脚本起始地址不匹配核对数据手册的 Flash 映射地址这张表的每一行我都实际踩过。尤其是前三行,出现的频率最高。6.2 调试器检查启动流程的方法裸机调试离不开 JTAG 调试器。很多初学者只会在调试器里打断点看变量,但在启动阶段,有一个更实用的调试技巧:利用复位后的默认状态观察。具体做法是先用调试器连接目标板,执行复位命令(不运行),然后读取以下关键寄存器:PC 是否停在实际复位地址上;sp 和 gp 是否还是复位默认值(通常是 0 或者 0xffffffff);mie 外设中是否有意外置位的中断使能位。比如pc_0 = 0x20000000且sp_0 = 0,说明复位状态符合预期。然后单步执行启动代码,每执行一两条指令就观察一次 sp、gp 的变化,确认栈指针和全局指针在正确的时间点被设置为正确的值。等数据段搬运循环执行完后,再检查某个已知初始值的全局变量的当前值,确认搬运结果正确。这个方法比直接全速运行然后看现象要可靠得多,因为全速运行一旦出错,整个系统可能已经处于崩溃状态,无法通过简单的断点观察定位问题。6.3 几个我花了很长时间才明白的小经验第一,链接脚本里. = ALIGN(4)不能省。不只是为了数据段搬运的对齐要求,还关系到后续 printf 或寄存器访问时的地址对齐。编译器在生成lw、sw指令时,默认目标地址是对齐的;如果链接器把变量放到了非对齐地址,生成的代码就会触发异常,而这种异常往往出现在程序运行很后面的时候,很难往启动流程上联想。第二,不要在启动汇编里调用任何 C 函数。启动阶段 C 运行环境还没建立起来,栈虽然已经设置了,但 C 库的初始化函数、全局构造函数、atexit 处理等都没准备好。一旦调用了依赖这些机制的 C 函数,程序可能在某个深层函数里突然崩溃,而且报错位置和实际原因往往对不上。启动阶段老老实实用汇编完成最基础的初始化,等main函数准备好了再进入 C 世界。第三,BSS 清零的循环要放在数据段拷贝之后。这个顺序在教科书式的启动流程里很常见,但有它的道理:某些程序会把一些只读数据放在 RAM 里运行,或者数据段和 BSS 段有重叠的风险。如果顺序反了,BSS 清完了,数据段拷贝又有可能把随机值覆盖到 BSS 区域。虽然大多数链接脚本会保证这两个段不重叠,但顺序保持拷贝在前、清 BSS 在后是更稳妥的通用写法。第四,合理的调试方式是准备好两套链接脚本。一套针对 Flash 启动,一套针对调试器 RAM 加载。这个经验前面提过,但值得重复强调。很多调试问题的根源就是“Flash 启动的链接地址”和“RAM 加载的地址”对不上,导致绝对跳转和绝对寻址全部错乱。专门准备一个 RAM 版本链接脚本,把 VMA 改成 RAM 区域,加载地址和 VMA 保持一致,调试器的下载和运行就顺理成章。7. 一些后续可扩展的方向启动流程跑通之后,可以尝试几个方向继续深化。首先是中断向量表的建立。RISC-V 的中断入口默认从异常入口地址开始,需要先写一个中断向量表,在启动代码阶段设置mtvec寄存器指向它,同时实现一套中断调度框架。进阶玩法是支持中断嵌套时需要额外保存上下文,涉及 mepc、mcause 等系统寄存器的保存和恢复。这个过程比裸机主流程更考验对 RISC-V 特权架构的理解。其次是看门狗、时钟配置的低层驱动。启动代码里可以加入时钟树初始化、PLL(锁相环)配置、外设时钟使能等操作。不同处理器的时钟树差异很大,配置顺序错了可能导致外设时钟频率不对,烧写后现象千奇百怪。把这些低层初始化纳入启动流程,是走向稳定产品的必修课。再者是从启动流程扩展到 bootloader。可以用这套启动原理实现一个最精简的双备份 Bootloader:从 Flash 固定地址加载应用程序,进行 CRC(循环冗余校验)校验,通过则跳转执行,失败则进入下载模式等待重新烧录。理解了启动流程,跳转到应用地址这个操作的实现就会很清晰,无非是修改mepc然后mret或者直接跳转到应用入口地址执行。最后是位置无关代码(PIC)的理解。如果想把同一份裸机程序加载到不同地址运行,就需要编译成位置无关代码,并在启动时动态计算自身基址、修正全局指针 gp 和跳转地址。这一步会用到auipc和jalr的配合,也能更深入地理解 RISC-V 指令集处理地址访问的机制。我个人在实际操作中的体会是:启动流程的每一个细节都有它的历史渊源和硬件依据,多花几个小时读链接器的输出映射文件和反汇编代码,比反复试错烧录更节约时间。启动流程一旦吃透了,后面写寄存器驱动、移植简单 RTOS 或者调试复杂异常,心里都会更有底。
1. 从复位向量到 main 函数:RISC-V 裸机启动全景接触 RISC-V 裸机开发的人,第一道坎往往不是外设驱动,而是“程序到底怎么跑起来的”。在 Linux 等操作系统环境下,启动流程被引导程序、内核、文件系统层层封装,开发者通常不需要关心上电瞬间发生了什么。但裸机开发完全不同,从芯片复位到 main 函数执行,中间每一步都是开发者自己说了算,每一步出错也都只能自己排查。我之前做某款 RISC-V 开发板的裸机实验时,第一次烧录自己写的启动代码,程序死活不跑。用调试器挂上去看,PC(程序计数器)停在复位向量处反复跳不出来。折腾了大半天,最后发现是链接脚本里栈指针初始化的位置不对,栈顶放到了不存在的内存区域,程序一进启动汇编就跳飞。这种经历在裸机开发中太典型了:不是逻辑写错,而是启动流程里一个看似不起眼的细节没对上。RISC-V 的启动和 bare-metal 流程,本质上就是三件事:把处理器从复位状态拉起来、把运行环境准备好、把控制权交给 C 语言世界。具体来说,启动流程要完成下面这些工作:确定复位向量位置,让处理器上电后能找到第一条指令;根据启动介质(片内 Flash、SPI Flash、JTAG 调试器等)选择启动模式;执行启动汇编代码,完成栈指针(sp)初始化、全局指针(gp)设置;清零 BSS 段、搬运数据段,为 C 语言运行环境做准备;调用 C 库初始化(如需要)和 main 函数,进入业务逻辑。这个流程和 ARM 的裸机启动在思路上类似,但 RISC-V 因为是开放指令集,不同厂商、不同开发板的复位地址、内存映射、启动模式差异很大,没有任何一家能像 ARM 的 CMSIS 那样给出一个打包好的通用启动文件。所以理解 RISC-V 的启动原理,而不是照搬某个模板,是绕不开的基本功。这篇文章我打算从启动模式的选择讲起,然后重点拆解链接脚本怎么写、启动汇编每一条指令在干什么,最后结合实际调试经验,整理几个常见问题。整体内容适合正在从单片机开发转向 RISC-V、或者刚开始接触 RISC-V 裸机编程的人作为参考。2. 启动模式与内存映射:为什么要反复确认复位地址2.1 上电复位后处理器在执行什么RISC-V 处理器上电复位后,硬件会强制把 PC 设置为某个特定地址,这个地址就是复位向量。处理器从复位向量开始取指执行,而这条路径上放的是启动代码。问题在于,复位向量到底是多少,完全取决于具体实现,不是指令集规范统一规定的。最常见的几种情况:某些简单核固定从 0x00000000 启动,所以 Flash 或 ROM 必须映射到这个地址。有些核支持从 0x00001000 或 0x20000000 等地址启动,需要查看数据手册确认。部分芯片支持多启动模式,通过硬件引脚的电平选择从片内 Flash 还是外部 SPI Flash 启动。我用的某款开发板支持 BOOT 引脚选择启动源,默认从片内 Flash 启动。可当时没仔细看手册,想当然认为复位向量是 0x00000000,结果链接脚本里把启动代码放到了另一个地址,程序自然跑不起来。后来用调试器连上去,查看复位后的 PC 值,才发现实际复位地址是 0x20000000,不是我以为的地址。这件事给我的教训是:拿到任何一款新的 RISC-V 芯片,第一步就是查手册里的启动/复位章节,确认三个关键参数:复位地址、启动介质映射地址、外设基地址。这三个参数决定链接脚本怎么分区、启动代码怎么写、外设寄存器怎么访问。除此之外,还要看芯片是否支持 BOOT 引脚配置,以及不同配置下地址映射会不会变化。2.2 片内 Flash 启动与调试器启动的本质差异除了复位地址,启动模式还决定开发调试阶段的工作方式。片内 Flash 模式是最终产品的正常启动路径,程序烧录到 Flash,上电后处理器直接从 Flash 取指执行。而调试器启动模式通常是通过 JTAG 接口把程序加载到 RAM 中运行,处理器直接从 RAM 取指。两者的内存映射不同,代码的运行位置也可能完全不同。这就产生了一个问题:链接脚本里的链接地址,到底用哪个?如果用 Flash 启动的链接地址去跑调试器加载的 RAM 程序,两个地址对不上,程序跳转和绝对寻址都会出错。我之前调试时就遇到过这种情况,每次用调试器加载程序后运行,一跑到绝对跳转指令就崩。后来才发现,链接脚本用的是 Flash 地址,但调试器把程序加载到了 RAM 地址,地址不匹配当然出问题。解决方式也很直接:调试阶段专门维护一套 RAM 链接脚本,把运行地址改成 RAM 段,不用的 Flash 段注释掉;正式发布时再切回 Flash 链接脚本。麻烦是麻烦一点,但省掉来回排查地址错乱的精力,值得。项目片内 Flash 启动调试器加载到 RAM 启动复位后 PC指向 Flash 映射区起始地址指向调试器设置的加载地址代码存放位置片内 Flash 非易失存储片内 RAM 易失存储数据段加载需要启动代码从 Flash 拷贝到 RAM由调试器直接初始化,启动代码可跳过典型使用场景产品发布、脱机运行开发调试阶段常见坑Flash 擦写寿命、启动配置错误链接地址与加载地址不一致导致跳转错乱调试阶段用 RAM 启动还有个额外好处:省去 Flash 擦写时间。调试一次烧写一次 Flash 非常耽误事,RAM 加载只需要几百毫秒,频繁改代码、跑测试的效率完全不一样。3. 链接脚本:裸机程序的“地契”与“地址地图”3.1 为什么链接脚本在裸机开发中如此关键很多人从 C 语言课开始就没搞清楚编译和链接到底各干了什么。编译把 C 代码翻译成机器指令,但此时指令里用到的变量地址、函数地址都只是“符号”,没有确定的内存位置。链接阶段才把这些符号落实到具体地址上,而链接脚本就是告诉链接器“这段代码放在哪个地址范围、那段数据放在哪个地址范围”的规则文件。裸机程序没有操作系统的加载器帮忙安排地址,一切都必须靠链接脚本写清楚。Linux 下编译一个程序,链接器会使用系统默认的链接脚本,把程序放到标准的虚拟地址空间;裸机开发没有这种默认规则,每个芯片的内存布局都不一样,链接脚本必须手工编写。链接脚本里最核心的要素包括:内存区域定义(MEMORY)、段布局(SECTIONS)、入口点设置(ENTRY)。内存区域定义告诉链接器这个芯片有哪些可用的 RAM 和 Flash,各有多大、从什么地址开始。段布局则说明代码段、只读数据段、数据段、BSS 段分别放到哪个内存区域、按什么顺序排放。入口点则是链接器生成的可执行文件里标记的程序起始地址,裸机程序通常指向复位向量处的启动汇编代码。编写链接脚本时,有一个非常容易忽略的细节:栈和堆的预留。栈是向下增长的,通常放在 RAM 的高地址端,栈指针初始化为链接脚本里定义的栈顶地址;堆用于动态内存分配,一般紧接着数据段后面预留。如果不预留在链接脚本里单独声明,后面用调试器观察栈使用情况和内存分配时,会非常混乱。3.2 一个最小可用的 RISC-V 裸机链接脚本逐段拆解下面这份链接脚本是我在某个模拟项目 X 里实际用过的简化版本,适合单核、片内 Flash 启动、内存映射简单的场景。我逐段说明每行的作用。/* memory 区域定义 */ MEMORY { FLASH (rx) : ORIGIN = 0x20000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20080000, LENGTH = 128K } /* 入口点声明 */ ENTRY(_start) SECTIONS { /* 中断向量表和启动代码 */ .text.init : { *(.text.init) } > FLASH /* 代码段 */ .text : { _stext = .; *(.text*) _etext = .; } > FLASH /* 只读数据段 */ .rodata : { *(.rodata*) } > FLASH /* 数据段(初始值在 Flash,运行位置在 RAM) */ .data : { _sdata = .; *(.data*) _edata = .; } > RAM AT > FLASH /* BSS 段 */ .bss : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } > RAM /* 栈顶地址定义 */ .stack : { . = ALIGN(16); . = . + 0x4000; _stack_top = .; } > RAM /* 堆地址定义 */ . = ALIGN(16); _heap_start = .; _heap_end = ORIGIN(RAM) + LENGTH(RAM) - 4; }几个关键点需要展开说明。.data段的> RAM AT > FLASH是这份脚本里最微妙的地方。它表示:程序最终运行时,数据段要放在 RAM 里,但初始值存放在 Flash 里。系统启动时,启动汇编代码负责把初始值从 Flash 拷贝到 RAM 的指定位置。这个过程叫“数据段加载”,或者叫“data copy”。如果没有这一步,运行在 RAM 里的全局变量初始值全是随机的,程序逻辑必然出错。链接器为了支持这种从不同 LMA(加载地址)到 VMA(运行地址)的映射,会自动生成两个符号_sdata和_edata,分别指向数据段在 RAM 中的起始和结束位置,以及_sidata(有时叫_la_data)指向 Flash 中初始值的起始位置,启动汇编就用这三个符号完成拷贝。BSS 段只定义了_sbss和_ebss,没有 AT 映射,因为这些变量没有初始值,只需要在启动代码里清零,不需要从 Flash 拷贝任何数据。清 BSS 这个操作在 C 语言标准中隐含了“未初始化全局变量自动为 0”的语义,裸机环境下必须由启动代码手工完成。这里还涉及链接器符号在汇编和 C 语言中的可见性。链接脚本里定义的_sbss、_ebss、_sdata等符号,本质上只是地址标号,不是 C 语言里的变量。在汇编启动文件里直接用la指令加载它们的地址即可;如果要在 C 代码里访问,需要用extern char _sbss[];这样的声明,取数组名就是地址。这个两者之间的区别,很多人第一次接触时会犯迷糊。3.3 内存布局的估算与预留方法链接脚本写完第一版后,记得做一次内存占用估算。对裸机程序来说,内存超量的后果往往不是启动时立刻爆掉,而是后续程序运行到某个特定分支时访问了不存在的内存地址,触发异常,非常难排查。所以链接脚本写完,我习惯用下面的方法快速验证。先用riscv64-unknown-elf-size查看各个段的大小。例如输出显示:text data bss dec hex filename 1200 256 1024 2480 9b0 main.elf然后对照链接脚本里的 Flash 容量和 RAM 容量,手工计算剩余空间。例如 Flash 512KB、RAM 128KB,那么 Flash 空间还剩 5121024 - 1200 - 256 = 523KB 左右,RAM 还剩 1281024 - 256 - 1024 - 16384(栈的预留)= 112KB 左右。这个计算虽然粗放,但能迅速发现明显的问题。再用riscv64-unknown-elf-nm -n main.elf检查各符号的地址是否能对上内存区域的边界。比如看到某个函数地址落在 Flash 之外,或者_stack_top超出了 RAM 范围,就说明链接脚本里有错误。还有一类问题值得注意:栈大小的预留。栈预留过小,C 函数调用稍微深一点就会压栈到非法地址;栈预留过大,则直接浪费宝贵的 RAM。折中的做法是先预留一个经验值(比如 16KB),后续通过调试器观察程序最复杂状态下的栈使用峰值,再调整到合适的值。往栈区域填充0xAA或者0xCC,跑完典型业务场景后用调试器找0xAA字节上边界,可以粗略估算实际栈使用量,这个方法我一直在用。4. 手工编写启动汇编:每一行指令都有存在理由4.1 最小启动代码的完整实现与逐步解释启动汇编文件一般叫startup.S或者crt0.S,是整个裸机程序里第一个执行的代码段,也是出错概率比较高的一个环节。下面这份实现是基于常见实践整理的,适用于大多数 RISC-V 单核裸机环境。.section .text.init .globl _start .type _start, @function _start: /* 关闭中断 */ csrw mie, zero csrw mip, zero /* 设置栈指针 */ la sp, _stack_top /* 设置全局指针(如果使用重定位的 small 模型) */ la gp, __global_pointer$ /* 数据段拷贝 */ la t0, _sidata la t1, _sdata la t2, _edata 1: bgeu t1, t2, 2f lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j 1b 2: /* BSS 清零 */ la t0, _sbss la t1, _ebss 3: bgeu t0, t1, 4f sw zero, 0(t0) addi t0, t0, 4 j 3b 4: /* 进入 C 语言世界 */ call main /* main 返回后,进入死循环 */ 1: j 1b逐段分析一下这段代码的逻辑和背后需要考虑的因素。.section .text.init把这段代码放到链接脚本里划分给启动代码的独立段中,确保复位向量跳转过来时,这里放的就是第一条指令。_globl _start导出_start符号,让链接脚本的ENTRY(_start)能找到它。csrw mie, zero和csrw mip, zero用于复位中断使能和中断挂起寄存器。这一步看起来简单,但非常必要。芯片上电后,某些实现可能默认中断使能位置位,或者残留一些挂起状态,如果启动过程中突然进入中断,而此时中断向量表还没准备好,程序会跳转到未知位置。所以启动的第一件事就是把中断彻底关掉,保证执行环境可控。la sp, _stack_top加载栈顶地址到栈指针。这一步执行完,C 函数调用的压栈操作就有地方放了。很多初学者会忽略一个问题:栈指针在启动代码里必须设置,而且要在跳转 C 函数之前设置完毕。如果在设置栈指针之前就调用了call,压栈会写到一个未初始化的地址,轻则随机覆盖数据,重则直接触发异常。la gp, __global_pointer$这一行和 RISC-V 的寻址方式有关。RISC-V 的lui/addi指令组合只能覆盖 32 位的地址范围,数据段和代码段如果分布较散,直接用绝对地址访问全局变量需要多条指令。编译器采用了一种更高效的方式:用 gp 寄存器保存一个全局指针,之后访问全局变量时用gp加上一个偏移量。这个__global_pointer$符号一般是链接器自动生成的,链接脚本里通常会用一段代码计算它应该指向的位置。不同的 RISC-V 工具链对这个过程的处理细节会有差异,有些芯片厂商提供的链接脚本会显式定义,有些则依赖编译器约定,所以这一行要根据具体工具链做调整。不使用的场景可以注释掉,但用了就必须确保链接脚本里存在对应的符号定义。接下来的数据段拷贝和 BSS 清零,就是对应前面链接脚本里> RAM AT > FLASH和 BSS 段定义的逻辑。循环逻辑很简单:比较当前地址是否到达结束地址,没到就继续搬运或者清零,一次搬运 4 字节。之所以一次搬运 4 字节而不是 1 字节,是为了提高效率,同时要求数据段和 BSS 段的起始地址都是 4 字节对齐的。链接脚本里通常用ALIGN(4)或.balign 4来保证这一点。最后call main跳转到 C 语言的main函数。注意,这里用的是call伪指令,它会被展开成一组加载绝对地址并跳转的指令,或者一对auipc+jalr指令,涉及位置无关代码和绝对跳转的区别。对于裸机程序,地址固定在链接脚本里,绝对跳转没有问题;但如果要做 Bootloader 这类需要在不同地址间移动的程序,就要仔细考虑是否改成相对跳转。4.2 数据段搬运的边界条件与性能考虑数据段搬运循环里有一个很容易踩的坑:地址对齐。lw和sw指令要求地址必须是 4 字节对齐的。如果数据段的起始地址或结束地址不是 4 的倍数,循环就会触发非对齐访问异常。这个问题在 x86 处理器上不太明显,因为 x86 支持非对齐访问,只是性能略差;但在 RISC-V 上,非对齐访问通常直接触发异常,程序瞬间崩溃。预防方式是在链接脚本里对_sdata、_edata、_sbss、_ebss这些符号的当前位置做 4 字节对齐。常见的写法:.data : { . = ALIGN(4); _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > FLASHALIGN(4)把当前位置向上对齐到 4 字节边界,这样_sdata和_edata就保证是对齐的。BSS 段也同理。这种对齐不仅解决了搬运指令的对齐要求,也让后面的变量访问更高效。另一个性能相关的考量是搬运宽度。上面用的是lw/sw一次搬运 4 字节,一般够用。更快的做法是用lbu/lhu按 1 字节或 2 字节搬运,但对齐要求更宽松,速度更慢;或者用向量指令 / 双字指令一次搬运更多字节,前提是处理器支持相应扩展。对大多数裸机应用来说,数据段通常只有几百字节到几 KB,4 字节搬运已经足够快,不需要过度优化。注意:启动代码里最好不要调用 C 库函数进行数据拷贝或清零,也不要依赖 C 编译器自动生成的初始化代码。这些代码本身可能依赖栈、依赖全局变量、依赖 C 库,而裸机启动阶段这些条件还不成立。老老实实用汇编手写循环,是最可控、最容易排查的方式。4.3 main 返回值与最终收尾启动代码在call main执行完后,不能直接退出,因为裸机程序没有操作系统可以返回。一旦 main 返回,PC 会回到启动代码的call指令下一条,如果我提前安排一个死循环的标号,就会落在那段死循环里。上面代码里的1: j 1b就是这个用途。有些人的启动代码会在main返回后执行ebreak进入调试暂停状态。这在调试阶段确实有效,适合配合调试器观察程序退出行为。但发布版固件不要用ebreak,否则程序会在异常指令上卡死,让看门狗重置,然后重复启动、崩溃、重置,看起来像系统不稳定。产品版用死循环或直接触发软件复位更可靠。5. 从编译到烧录:完整构建流程与实战记录5.1 工具链选择与编译参数设置RISC-V 裸机开发中最常用的工具链是 GNU 工具链,具体前缀根据目标架构不同而有差异。常见的有riscv64-unknown-elf-和riscv32-unknown-elf-。选择 32 位还是 64 位,取决于目标处理器内核的位数,一般看开发板的数据手册即可确认。编译启动汇编文件和 C 文件时,有几个参数必须谨慎设置。对于汇编文件:riscv64-unknown-elf-gcc -c -march=rv64gc -mabi=lp64 -nostdlib -ffreestanding -o startup.o startup.S-nostdlib告诉编译器不要链接标准 C 库,这是裸机程序的必要参数。-ffreestanding表示编译环境是独立环境,没有标准库和操作系统服务。-march和-mabi则指定目标架构和应用程序二进制接口。架构字符串里包含扩展名组合,比如rv64imac就是整数指令、乘法除法、原子指令、压缩指令的组合,gc通常是通用组合的简写。对于 C 文件:riscv64-unknown-elf-gcc -c -march=rv64gc -mabi=lp64 -nostdlib -ffreestanding -fno-builtin -O2 -o main.o main.c-fno-builtin防止编译器把某些标准函数调用替换成内置版本,避免对 C 库的隐式依赖。-O2是常规优化等级,如果调试需求多,可以先降低到-O0或者-Og,牺牲性能换可调试性。链接阶段:riscv64-unknown-elf-gcc -nostdlib -T link.ld -o main.elf startup.o main.o-T指定链接脚本。注意链接命令仍然用 gcc 包装器,而不是直接用ld,这样工具链的默认库路径和启动文件有关的配置不会被遗漏,反而更容易避开工具链版本差异带来的问题。最后生成烧录文件:riscv64-unknown-elf-objcopy -O binary main.elf main.bin如果需要生成 Intel HEX 或者带地址信息的上位机烧录格式,可以用-O ihex。5.2 烧录中遇到的第一现场问题记录我用某个某公司出的开发板做实验时,把编译好的main.bin烧录进 Flash,上电后板子没有任何反应,LED 不亮、串口也没有输出。反复烧了几次都一样。这种“完全没反应”的问题,优先怀疑启动流程而不是业务逻辑。排查从四个方向入手:第一步,确认烧录地址对不对。翻出之前烧录成功过的固件信息,核对 Flash 基地址和偏移。发现出厂固件从 0x20000000 开始,而我编译时链接脚本里把 FLASH 区域起点写成了 0x20000000,这一项没问题。第二步,确认复位向量是否指向正确位置。用调试器连接开发板,停止程序,查看当前 PC 值。结果 PC 停在 0x20000000 处,说明复位向量是对的。第三步,单步执行启动代码,观察栈指针设置和数据段搬运。用调试器单步跑了几条指令,发现la sp, _stack_top执行完后,sp 的值是 0x20084000,这个地址在 RAM 范围内,看起来没问题。可是继续往下跑,到la gp, __global_pointer$时,gp 的值居然是 0,这条指令执行后程序没报错,但后面访问全局变量的代码全乱了。第四步,回到链接脚本查__global_pointer$的定义。发现链接脚本里用了类似于PROVIDE(__global_pointer$ = ...)的写法,可那个表达式计算出来的值超出了数据段范围,导致链接器没有正确生成 gp 符号。我在链接脚本里加了一段显式的 gp 定位代码,比如把__global_pointer$定义为数据段起始地址加上一个固定偏移,并且用.option伪指令控制编译器的地址生成模型,重新编译烧录后,LED 正常点亮,串口也输出了打印信息。这个问题排查耗时将近一个下午,最后结论就是一个链接脚本符号定义错了。裸机调试中很多“看似完全无法理解”的现象,最终都指向链接脚本和启动文件这些底层细节,这也是为什么这篇文章花了大量篇幅拆解链接脚本和启动汇编的原因。5.3 构建流程自动化小技巧启动流程稳定之后,频繁的手工敲命令编译、烧录非常低效。我习惯写一个简单的 Makefile 把这些步骤固化下来,减少人为出错的机会。CROSS = riscv64-unknown-elf- CC = $(CROSS)gcc OBJCOPY = $(CROSS)objcopy ARCH = rv64gc ABI = lp64 CFLAGS = -march=$(ARCH) -mabi=$(ABI) -nostdlib -ffreestanding -fno-builtin -O2 ASFLAGS = -march=$(ARCH) -mabi=$(ABI) -nostdlib LDSCRIPT = link.ld OBJS = startup.o main.o all: main.bin main.elf: $(OBJS) $(CC) -nostdlib -T $(LDSCRIPT) -o $@ $(OBJS) %.o: %.S $(CC) $(ASFLAGS) -c -o $@ $< %.o: %.c $(CC) $(CFLAGS) -c -o $@ $< main.bin: main.elf $(OBJCOPY) -O binary $< $@ clean: rm -f *.o *.elf *.bin这个 Makefile 没有用到隐晦的自动依赖生成,保留了很高的可读性。实际项目再往里加链接后检查脚本,比如生成size报告、校验内存占用率,都能在这里扩展。裸机项目构建流程的核心追求是“稳定可复现”,一次配置,后续只管改代码、跑 make。6. 常见问题排查与避坑心得6.1 七个高频启动异常的对照速查表实际开发中,启动阶段的异常现象虽然千奇百怪,但根因往往集中在少数几个地方。我整理了一份速查表,方便定位问题:现象最可能的根因排查方法复位后 PC 停在非法地址复位向量配置错误或链接脚本入口点错误调试器查看复位后 PC,比对内存映射单步执行时跳转到意外位置链接地址和加载地址不一致对比链接脚本的 VMA 和调试器加载地址跳转 main 后立即崩溃栈指针未初始化或栈顶地址非法检查启动汇编是否执行la sp, _stack_top全局变量初始值错误数据段拷贝缺失或拷贝地址错误检查数据段搬运循环和_sidata符号BSS 变量不为 0BSS 清零缺失或清零范围错误检查 BSS 循环是否覆盖全部 BSS 段使用 gp 寻址的程序跑飞__global_pointer$未正确定义检查链接脚本 gp 符号定义,观察gp寄存器值编译可以,烧录后完全不运行Flash 起始地址和链接脚本起始地址不匹配核对数据手册的 Flash 映射地址这张表的每一行我都实际踩过。尤其是前三行,出现的频率最高。6.2 调试器检查启动流程的方法裸机调试离不开 JTAG 调试器。很多初学者只会在调试器里打断点看变量,但在启动阶段,有一个更实用的调试技巧:利用复位后的默认状态观察。具体做法是先用调试器连接目标板,执行复位命令(不运行),然后读取以下关键寄存器:PC 是否停在实际复位地址上;sp 和 gp 是否还是复位默认值(通常是 0 或者 0xffffffff);mie 外设中是否有意外置位的中断使能位。比如pc_0 = 0x20000000且sp_0 = 0,说明复位状态符合预期。然后单步执行启动代码,每执行一两条指令就观察一次 sp、gp 的变化,确认栈指针和全局指针在正确的时间点被设置为正确的值。等数据段搬运循环执行完后,再检查某个已知初始值的全局变量的当前值,确认搬运结果正确。这个方法比直接全速运行然后看现象要可靠得多,因为全速运行一旦出错,整个系统可能已经处于崩溃状态,无法通过简单的断点观察定位问题。6.3 几个我花了很长时间才明白的小经验第一,链接脚本里. = ALIGN(4)不能省。不只是为了数据段搬运的对齐要求,还关系到后续 printf 或寄存器访问时的地址对齐。编译器在生成lw、sw指令时,默认目标地址是对齐的;如果链接器把变量放到了非对齐地址,生成的代码就会触发异常,而这种异常往往出现在程序运行很后面的时候,很难往启动流程上联想。第二,不要在启动汇编里调用任何 C 函数。启动阶段 C 运行环境还没建立起来,栈虽然已经设置了,但 C 库的初始化函数、全局构造函数、atexit 处理等都没准备好。一旦调用了依赖这些机制的 C 函数,程序可能在某个深层函数里突然崩溃,而且报错位置和实际原因往往对不上。启动阶段老老实实用汇编完成最基础的初始化,等main函数准备好了再进入 C 世界。第三,BSS 清零的循环要放在数据段拷贝之后。这个顺序在教科书式的启动流程里很常见,但有它的道理:某些程序会把一些只读数据放在 RAM 里运行,或者数据段和 BSS 段有重叠的风险。如果顺序反了,BSS 清完了,数据段拷贝又有可能把随机值覆盖到 BSS 区域。虽然大多数链接脚本会保证这两个段不重叠,但顺序保持拷贝在前、清 BSS 在后是更稳妥的通用写法。第四,合理的调试方式是准备好两套链接脚本。一套针对 Flash 启动,一套针对调试器 RAM 加载。这个经验前面提过,但值得重复强调。很多调试问题的根源就是“Flash 启动的链接地址”和“RAM 加载的地址”对不上,导致绝对跳转和绝对寻址全部错乱。专门准备一个 RAM 版本链接脚本,把 VMA 改成 RAM 区域,加载地址和 VMA 保持一致,调试器的下载和运行就顺理成章。7. 一些后续可扩展的方向启动流程跑通之后,可以尝试几个方向继续深化。首先是中断向量表的建立。RISC-V 的中断入口默认从异常入口地址开始,需要先写一个中断向量表,在启动代码阶段设置mtvec寄存器指向它,同时实现一套中断调度框架。进阶玩法是支持中断嵌套时需要额外保存上下文,涉及 mepc、mcause 等系统寄存器的保存和恢复。这个过程比裸机主流程更考验对 RISC-V 特权架构的理解。其次是看门狗、时钟配置的低层驱动。启动代码里可以加入时钟树初始化、PLL(锁相环)配置、外设时钟使能等操作。不同处理器的时钟树差异很大,配置顺序错了可能导致外设时钟频率不对,烧写后现象千奇百怪。把这些低层初始化纳入启动流程,是走向稳定产品的必修课。再者是从启动流程扩展到 bootloader。可以用这套启动原理实现一个最精简的双备份 Bootloader:从 Flash 固定地址加载应用程序,进行 CRC(循环冗余校验)校验,通过则跳转执行,失败则进入下载模式等待重新烧录。理解了启动流程,跳转到应用地址这个操作的实现就会很清晰,无非是修改mepc然后mret或者直接跳转到应用入口地址执行。最后是位置无关代码(PIC)的理解。如果想把同一份裸机程序加载到不同地址运行,就需要编译成位置无关代码,并在启动时动态计算自身基址、修正全局指针 gp 和跳转地址。这一步会用到auipc和jalr的配合,也能更深入地理解 RISC-V 指令集处理地址访问的机制。我个人在实际操作中的体会是:启动流程的每一个细节都有它的历史渊源和硬件依据,多花几个小时读链接器的输出映射文件和反汇编代码,比反复试错烧录更节约时间。启动流程一旦吃透了,后面写寄存器驱动、移植简单 RTOS 或者调试复杂异常,心里都会更有底。