资讯动态

RISC-V裸机启动全解析:复位向量、链接脚本与汇编实战

发布时间:2026/10/20 1:47:09 来源:尧图企业网站定制
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伪指令它会被展开成一组加载绝对地址并跳转的指令或者一对auipcjalr指令涉及位置无关代码和绝对跳转的区别。对于裸机程序地址固定在链接脚本里绝对跳转没有问题但如果要做 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 字节搬运但对齐要求更宽松速度更慢或者用向量指令 / 双字指令一次搬运更多字节前提是处理器支持相应扩展。对大多数裸机应用来说数据段通常只有几百字节到几 KB4 字节搬运已经足够快不需要过度优化。注意启动代码里最好不要调用 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 -marchrv64gc -mabilp64 -nostdlib -ffreestanding -o startup.o startup.S-nostdlib告诉编译器不要链接标准 C 库这是裸机程序的必要参数。-ffreestanding表示编译环境是独立环境没有标准库和操作系统服务。-march和-mabi则指定目标架构和应用程序二进制接口。架构字符串里包含扩展名组合比如rv64imac就是整数指令、乘法除法、原子指令、压缩指令的组合gc通常是通用组合的简写。对于 C 文件riscv64-unknown-elf-gcc -c -marchrv64gc -mabilp64 -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 或者 0xffffffffmie 外设中是否有意外置位的中断使能位。比如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 或者调试复杂异常心里都会更有底。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑