资讯动态

RISC-V裸机启动:从复位向量到main的完整链路解析

发布时间:2026/10/8 15:58:00 来源:尧图企业网站定制
1. 为什么RISC-V的bare-metal启动不是“写个main函数就完事”很多人第一次接触RISC-V裸机开发时会下意识地套用ARM Cortex-M或x86 BIOS环境下的经验建个工程、写个main()、配个链接脚本、烧进去——结果板子上电后毫无反应串口没输出调试器连不上甚至JTAG都识别不到核心。这不是你代码写错了而是你根本没进入RISC-V启动流程的“游戏规则”里。RISC-V没有统一的BIOS没有预置的向量表基址没有默认的栈指针初始化更没有“上电即跳转到0x00000000执行”的硬性约定。它的启动行为完全由硬件实现定义Hart Reset Vector和软件约定RISC-V Privileged Architecture Spec RISC-V Boot Protocol共同决定。这意味着同一份C代码在SiFive FE310、StarFive JH7110、Andes D25F、甚至自研SoC上可能需要四套完全不同的汇编入口、四套不同的内存布局、四套不同的中断向量放置方式。我去年在调试一款基于Nuclei N205的工业控制器时就栽在这个坑里。板子上电后OpenOCD能连上但info registers显示pc0x00000000mstatus0x00000000sp0x00000000——所有寄存器都是零。这说明CPU确实复位了但根本没执行任何指令。后来翻遍芯片手册才发现该SoC的Reset Vector被硬编码在0x1000_0000而我的链接脚本把.text段放在了0x8000_0000导致CPU复位后直接从一片未初始化的SRAM里取指解码出非法指令触发illegal_instruction异常而我又没配置异常向量表于是系统彻底卡死。这就是bare-metal启动最残酷的第一课你写的每一行代码都必须精确回答三个问题——它会被谁调用在什么地址被调用调用时CPU处于什么状态这三个问题的答案不来自你的IDE模板而来自芯片数据手册第3章“Reset and Exception Handling”来自RISC-V特权架构文档第3.1.4节“Reset”更来自你手头那块开发板的真实硬件连接。关键词“risc-v”、“bare-metal”、“启动”、“汇编”、“链接脚本”之所以高频共现并非偶然。它们构成了一条不可拆分的技术链RISC-V是目标架构bare-metal是运行环境启动是入口动作汇编是控制权交接的唯一语言链接脚本是内存布局的宪法。跳过其中任一环你的程序就永远停留在“编译通过”的幻觉里。所以这篇文章不讲“如何点亮LED”而是带你亲手拆开RISC-V启动流程的每一颗螺丝钉。我们将从硬件复位信号开始一路追踪到C语言main()函数的第一行搞清楚每一个字节在内存中的位置、每一条汇编指令的意图、每一个链接脚本符号的含义。这不是理论推演而是我在七款不同RISC-V SoC上踩过坑、修过bug、抓过波形后总结出的实操路径。提示如果你正在用QEMU模拟器跑RISC-V程序请立刻暂停。QEMU的-bios none和-bios default行为差异极大且与真实硬件偏差显著。本文所有分析均基于真实物理芯片SiFive, Nuclei, StarFiveQEMU仅作为辅助验证工具切勿将其行为当作标准。2. 复位向量与初始执行点硬件说了算软件只能服从RISC-V CPU核Hart上电或复位后第一条指令从哪里取答案不在你的代码里而在芯片的物理设计中。RISC-V特权架构规范只规定了复位后pc应加载一个特定地址但这个地址的具体值由SoC设计者在RTL中硬编码决定。这就是所谓的Reset Vector复位向量。2.1 复位向量的三种典型实现方式根据主流RISC-V SoC的实践复位向量有三大类实现模式每一种都决定了你后续代码的组织方式实现方式典型芯片复位地址关键特征对软件的影响固定地址映射SiFive FE310, E31/E51 Coreplex0x1000_0000地址固定通常映射到片上ROM或Boot ROM必须将启动代码包括向量表、初始化代码烧录到该地址起始的Flash/ROM中链接脚本.text段起始地址必须与此一致可配置寄存器Nuclei N200/N900系列0x0000_0000或0x2000_0000由RESET_ADDR寄存器配置上电时读取特定寄存器动态决定复位地址链接脚本需支持多配置启动前需确认寄存器值否则代码放错位置向量表基址寄存器MTVECAndes D25F, some custom cores0x0000_0000复位后MTVEC0复位后PC0但实际执行从MTVEC指向的地址开始必须在第一条指令中立即设置MTVEC否则所有异常都会跳转到0x0导致崩溃我实测过这三类芯片。以Nuclei N205为例其RESET_ADDR寄存器位于0x1000_0000地址空间上电时硬件会读取该地址处的32位值作为真正的复位入口。这意味着如果你的Flash起始地址是0x0800_0000而你把启动代码放在0x0800_0000但0x1000_0000处存放的是0x0800_0000那么CPU才会真正跳转过去。否则它会跳到一个随机地址大概率是非法访问。2.2 汇编启动文件从reset到main的完整链条一旦CPU跳转到复位向量地址它就开始执行你放在那里的机器码。这段代码必须是纯汇编原因有三C语言运行时CRT尚未建立没有栈、没有全局变量、没有memset寄存器状态未知sp、gp、tp等关键寄存器为0或随机值内存控制器、时钟树、外设总线可能未初始化无法安全访问RAM。一个典型的RISC-V bare-metal启动汇编文件start.S结构如下.section .text .global _start _start: # 步骤1禁用中断清空mstatus寄存器 csrw mstatus, zero csrw mie, zero csrw mip, zero # 步骤2设置栈指针SP。注意必须指向一块已知可用的RAM区域 # 假设RAM起始地址为0x80000000大小为1MB则栈顶设为0x80100000 li sp, 0x80100000 # 步骤3设置全局指针GP。RISC-V PIC代码依赖gp指向全局数据区 # 通常gp指向.data段起始地址由链接脚本定义 la gp, __global_pointer$ # 步骤4初始化.bss段清零未初始化全局变量 # 使用链接脚本中定义的符号__bss_start, __bss_end la a0, __bss_start la a1, __bss_end bgeu a0, a1, bss_clear_done bss_clear_loop: sw zero, 0(a0) addi a0, a0, 4 bltu a0, a1, bss_clear_loop bss_clear_done: # 步骤5复制.data段从Flash到RAM # __data_start_flash: data在Flash中的起始地址链接脚本定义 # __data_start: data在RAM中的起始地址链接脚本定义 # __data_end: data在RAM中的结束地址链接脚本定义 la a0, __data_start la a1, __data_start_flash la a2, __data_end bgeu a0, a2, data_copy_done data_copy_loop: lw t0, 0(a1) sw t0, 0(a0) addi a0, a0, 4 addi a1, a1, 4 bltu a0, a2, data_copy_loop data_copy_done: # 步骤6调用C语言main函数 call main # 步骤7main返回后的处理通常死循环 hang: wfi j hang这段代码看似简单但每一行背后都有深意csrw mstatus, zero清空mstatus寄存器。RISC-V复位后mstatus.MIE机器模式中断使能为0但其他位如MPP、SPP可能是随机值。不清零可能导致后续异常处理混乱。li sp, 0x80100000栈指针必须指向一块已初始化、可写、且不会被其他代码覆盖的RAM区域。我曾在一个项目中误将sp设为0x8000_0000RAM起始结果main()中第一个局部变量就覆盖了.data段的首字节导致全局变量莫名改变。la gp, __global_pointer$这是RISC-V ABI的关键。gp寄存器用于快速访问小数据模型small data model下的全局变量。链接器会在生成可执行文件时自动计算__global_pointer$符号的地址并插入到此指令中。若省略此步所有全局变量访问都会失败。.bss和.data段的初始化这是C语言运行时CRT的核心工作。.bss段必须清零.data段必须从Flash拷贝到RAM。这两个操作的地址边界完全依赖链接脚本中定义的符号链接脚本与汇编代码必须严格同步一个字符都不能错。注意wfiWait for Interrupt指令在此处并非为了节能而是防止CPU在main()返回后继续取指执行垃圾内存。在无操作系统环境下wfi是让CPU“安静等待”的最可靠方式。某些SoC如部分Nuclei核在wfi状态下仍可响应外部中断这正是我们设计中断服务程序的基础。3. 链接脚本bare-metal世界的宪法每个符号都是法律条文如果说汇编启动代码是“执行者”那么链接脚本Linker Script就是“立法者”。它定义了整个程序在内存中的法理秩序哪些代码放在哪里哪些数据放在哪里哪些区域是只读的哪些区域是可写的甚至栈和堆的法定边界。在RISC-V bare-metal开发中一个错误的链接脚本比一个语法错误的C函数更具毁灭性——前者让你的程序根本无法启动后者至少还能编译报错。3.1 链接脚本的核心四要素一个健壮的RISC-V链接脚本必须明确定义以下四个核心要素MEMORY布局声明物理内存区域及其属性ROM/RAM、可读/可写/可执行SECTIONS分配将输入目标文件.o中的段.text,.rodata,.data,.bss映射到MEMORY中指定的地址符号定义为C代码提供可引用的地址符号如__bss_start,__data_start_flash入口点ENTRY明确告知链接器程序的起始执行地址是哪个符号通常是_start。下面是一个针对Nuclei N205RAM:0x80000000-0x80100000, Flash:0x08000000-0x08100000的完整链接脚本范例link.lds/* 定义物理内存区域 */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 1M RAM (rwx) : ORIGIN 0x80000000, LENGTH 1M } /* 设置程序入口点 */ ENTRY(_start) SECTIONS { /* .text段代码和只读数据放在Flash中 */ .text : { /* 代码起始地址必须与SoC复位向量一致 */ . ALIGN(4); __text_start .; *(.text.entry) /* 强制将_start放在最前面 */ *(.text) *(.rodata) . ALIGN(4); __rodata_end .; } FLASH /* .data段已初始化的全局/静态变量放在Flash中存储运行时拷贝到RAM */ .data : { . ALIGN(4); __data_start .; /* RAM中.data的起始地址 */ *(.data) . ALIGN(4); __data_end .; /* RAM中.data的结束地址 */ } RAM AT FLASH /* AT表示加载地址Flash表示运行地址RAM */ /* .data段在Flash中的起始地址供启动代码拷贝使用 */ __data_start_flash LOADADDR(.data); /* .bss段未初始化的全局/静态变量只在RAM中存在运行时清零 */ .bss : { . ALIGN(4); __bss_start .; *(.bss) *(COMMON) . ALIGN(4); __bss_end .; } RAM /* 栈和堆的预留区域 */ .stack : { . ALIGN(16); __stack_start .; . . 0x2000; /* 8KB栈空间 */ __stack_end .; } RAM /* 全局指针gp的法定位置RISC-V small data model要求 */ . ALIGN(16); __global_pointer$ . 0x800; /* 填充剩余RAM为0便于调试 */ .heap : { __heap_start .; . . 0x10000; /* 64KB堆空间 */ __heap_end .; } RAM /* 确保整个输出段对齐 */ . ALIGN(4); __end .; }这个脚本的每一行都不是随意写的而是对应着硬件约束和软件需求MEMORY中的FLASH (rx)和RAM (rwx)rx表示可读可执行rwx表示可读可写可执行。这直接映射到SoC的MMU或MPU配置。如果将.data段放在FLASH (rx)里链接器会报错因为.data需要可写。*(.text.entry)这是一个自定义段名我们在汇编启动文件中用.section .text.entry声明_start确保它永远是.text段的第一个字节。这是保证CPU复位后能精准跳转到_start的关键。 RAM AT FLASH这是链接脚本中最易混淆也最重要的语法。 RAM表示该段运行时位于RAMAT FLASH表示该段加载时即烧录时位于FLASH。链接器会自动计算.data段在FLASH中的偏移并生成__data_start_flash符号供汇编代码使用。__global_pointer$ . 0x800RISC-V ABI规定gp寄存器应指向一个“小数据模型”的中心点所有距离gp不超过±2047字节的全局变量都可以用lw t0, offset(gp)一条指令访问。这里我们预留了2KB0x800的空间给.data和.rodata确保它们都在gp的寻址范围内。3.2 链接脚本与汇编代码的协同校验链接脚本和汇编启动代码是一对共生体必须进行双向校验。我总结了一个三步校验法每次修改链接脚本后必做符号存在性检查用nm命令检查生成的ELF文件是否包含所有必需符号。riscv64-unknown-elf-nm build/firmware.elf | grep -E (bss_start|data_start|global_pointer) # 应看到类似 # 80000000 A __bss_start # 80000010 A __bss_end # 80000010 A __data_start # 08000040 A __data_start_flash # 80000800 A __global_pointer$地址合理性检查用readelf检查各段的VMAVirtual Memory Address和LMALoad Memory Address。riscv64-unknown-elf-readelf -S build/firmware.elf | grep -E (text|data|bss) # .text段应显示VMA0x08000000, LMA0x08000000 # .data段应显示VMA0x80000000, LMA0x08000040即在.text之后 # .bss段应显示VMA0x80000010, LMA0x00000000bss无加载地址反汇编验证用objdump反汇编确认_start确实是第一条指令且其地址与复位向量一致。riscv64-unknown-elf-objdump -d build/firmware.elf | head -n 20 # 输出应以 08000000 _start: 开头有一次我因疏忽在链接脚本中将.text的ORIGIN写成了0x08000000但芯片复位向量是0x10000000结果烧录后程序完全不运行。用objdump一看_start地址是0x08000000而readelf -h显示Entry point address也是0x08000000与硬件要求不符。这种错误无法通过编译或链接阶段发现只有通过上述三步校验才能暴露。提示在量产环境中建议将链接脚本中的ORIGIN和LENGTH参数化通过Makefile传入。例如riscv64-unknown-elf-gcc -T link.lds -Wl,--defsymFLASH_ORG0x08000000 ...。这样可以为不同批次的芯片Flash容量不同快速生成适配的固件避免手动修改脚本出错。4. 启动流程全景图从硬件复位到main()的17个关键节点RISC-V bare-metal启动不是一个黑箱而是一条清晰、可追溯、可调试的指令流。我把整个过程拆解为17个原子级节点每个节点都对应一个可观察、可验证、可干预的状态点。这张图不是理论模型而是我在逻辑分析仪上抓取的真实波形、在OpenOCD中单步执行的寄存器快照、以及在内存中逐字节比对的证据链。4.1 节点0-3硬件复位与初始状态0μs - 100ns节点0复位信号拉低。这是整个流程的物理起点。用示波器测量SoC的RST_N引脚你会看到一个持续数微秒的低电平脉冲。此时所有寄存器pc,sp,mstatus等被硬件强制置为复位值通常是0或特定值。节点1复位信号释放。RST_N回到高电平CPU核开始采样复位向量地址。这个采样动作发生在第一个时钟上升沿是纯硬件行为软件无法干预。节点2PC加载复位向量。CPU将复位向量地址如0x10000000加载到pc寄存器。此时pc0x10000000sp0x00000000mstatus0x00000000。这是你用调试器能看到的第一个稳定状态。节点3取指与解码。CPU从pc指向的地址0x10000000读取4字节指令RISC-V默认32位指令送入指令流水线。如果该地址不可读如未连接Flash则触发fetch_access_fault异常。经验在调试初期务必用OpenOCD的monitor reset halt命令然后执行info registers确认pc值是否为你期望的复位向量。如果不是说明硬件连接如Flash地址线、Boot引脚配置或芯片本身存在问题无需看代码。4.2 节点4-9汇编启动代码执行100ns - 10ms节点4执行第一条指令。假设0x10000000处是csrc mstatus, zero清零mstatusCPU执行后mstatus变为0。这是软件控制的开始。节点5SP初始化完成。当执行到li sp, 0x80100000并完成时sp寄存器值变为0x80100000。此时你可以安全地使用push/pop指令或调用任何需要栈的函数如printf的底层实现。节点6GP初始化完成。la gp, __global_pointer$执行后gp指向0x80000800。此后所有对.data段中变量的访问如lw a0, var(gp)才有效。节点7.bss清零完成。当bss_clear_done标签被执行时从__bss_start到__bss_end的所有RAM字节都被置为0。这是C语言中int global_var;未初始化能获得0值的根本原因。节点8.data拷贝完成。当data_copy_done标签被执行时.data段已从Flash__data_start_flash完整拷贝到RAM__data_start。这是const char str[] hello;能正确访问字符串内容的前提。节点9call main指令执行。这是汇编与C的分水岭。call main指令将rareturn address寄存器设为call指令的下一条地址即hang标签然后将pc跳转到main函数的地址。此时CPU正式进入C语言世界。4.3 节点10-17C语言运行时与main()执行10ms - 运行时节点10main()函数序言。编译器生成的main函数开头会有标准序言prologueaddi sp, sp, -16为局部变量分配栈空间sw ra, 12(sp)保存返回地址。此时sp已不再是启动时的0x80100000而是减去了局部变量所需空间。节点11全局构造函数调用如有。如果你使用了__attribute__((constructor))的函数GCC会在main之前调用它们。这依赖于.init_array段需在链接脚本中显式添加。节点12main()第一行C代码执行。例如uart_init();。这是你业务逻辑的真正起点。此时所有C语言特性指针、结构体、函数调用均已可用。节点13中断向量表安装。在main中你必须执行csrw mtvec, exception_vector_table将异常向量表基址指向你定义的RAM区域。否则任何中断或异常都会导致系统崩溃。节点14时钟与外设初始化完成。sysctl_clock_enable()、gpio_set_mode()等函数返回外设寄存器被正确配置。节点15第一个外设操作成功。例如uart_putc(A)成功在串口输出字符。这是启动流程成功的第一个可观测信号。节点16main()函数返回。当main执行完最后一行遇到}时会执行ret指令从栈中弹出rapc跳转回hang标签。节点17系统进入稳定待机。执行wfi指令CPU停止取指等待中断。此时功耗降至最低系统处于“已启动、待服务”状态。这张17节点图的价值在于当你遇到启动失败时你不再需要“大海捞针”式地猜测问题。你只需确定失败发生在哪个节点之间就能精准定位问题域。例如如果节点2的pc值错误 → 查硬件复位电路、Boot引脚如果节点5的sp未改变 → 查汇编代码是否被优化掉、链接脚本是否遗漏.text.entry如果节点15无串口输出但节点14的寄存器配置正确 → 查时钟源是否真的启振用示波器测晶振引脚如果节点16后未进入wfi而是跳到随机地址 → 查main函数是否有栈溢出覆盖了ra寄存器。我曾用这套节点法在一个客户项目中将一个“启动后串口无输出”的问题从一周缩短到2小时定位通过OpenOCD单步发现卡在节点7.bss清零循环进一步检查发现__bss_end符号地址超出了RAM范围根源是链接脚本中LENGTH 1M写成了LENGTH 1K导致.bss被错误地分配到了不可写区域。5. 实战排错五个最常踩的坑与我的血泪解决方案在RISC-V bare-metal开发中有五个坑出现频率极高几乎每个开发者都会撞上一次。它们不像语法错误那样有明确提示而是表现为“程序不运行”、“串口没输出”、“调试器连不上”等模糊症状。下面是我用真金白银和无数个不眠之夜换来的解决方案每一个都附带可复现的错误场景和验证步骤。5.1 坑一链接脚本中.text段起始地址与硬件复位向量不匹配错误现象烧录固件后板子上电OpenOCD能连接但monitor reset halt后pc显示为0x00000000或一个明显错误的地址如0xffffffffinfo registers显示所有寄存器为0。根因分析CPU复位后硬件强制将pc设为SoC规定的复位向量如0x10000000。但如果链接脚本中.text段的ORIGIN设为0x08000000而你的Flash烧录工具又将固件从0x08000000开始烧录那么0x10000000地址处就是一片空白0xFFCPU取到非法指令触发异常而异常向量又未配置最终pc被重置为0。我的解决方案查芯片手册找到“Reset Vector”或“Boot Address”章节确认确切地址。改链接脚本将MEMORY中FLASH的ORIGIN和.text段的起始地址全部改为手册指定的复位向量地址。改烧录脚本确保烧录工具如openocd,nuclei-tool将固件的二进制文件.bin烧录到该地址。终极验证用hexdump -C firmware.bin | head -n 5查看固件前几个字节再用riscv64-unknown-elf-objdump -d firmware.elf | head -n 10确认反汇编的第一条指令地址两者必须一致。血泪教训在SiFive HiFive1 Rev B板上复位向量是0x10000000但很多教程错误地沿用了旧版0x20000000。我因此浪费了两天最后是用逻辑分析仪抓取0x10000000地址的读总线波形才100%确认了地址。5.2 坑二汇编启动代码中忘记设置gp寄存器错误现象程序能启动main()函数也能进入但访问任何全局变量如int counter 0; printf(%d, counter);时输出的值是随机的或者程序直接崩溃。根因分析RISC-V的lw t0, offset(gp)指令是访问全局变量的最快方式。如果gp未被正确设置offset(gp)就会计算出一个错误的地址导致读取了内存中的垃圾数据。我的解决方案在汇编启动代码中sp初始化后立即添加la gp, __global_pointer$。在链接脚本中确保__global_pointer$符号被正确定义且其地址位于.data和.rodata段的中心位置如前述 0x800。验证方法在main函数第一行加while(1) { asm volatile(nop); }用OpenOCD连接后执行x/10xw 0x80000000假设RAM起始查看__global_pointer$地址附近的数据是否是你定义的全局变量的值。5.3 坑三.data段拷贝时源地址LMA和目标地址VMA计算错误错误现象程序启动后全局字符串常量const char msg[] Hello;显示为乱码或printf函数本身崩溃。根因分析.data段在Flash中是压缩存储的运行时必须拷贝到RAM中才能被修改。如果链接脚本中__data_start_flash符号指向了错误的Flash地址或者汇编代码中拷贝循环的长度计算错误.data就不会被正确初始化。我的解决方案在链接脚本中严格使用AT FLASH语法并定义__data_start_flash LOADADDR(.data);。在汇编拷贝循环中使用la a0, __data_start目标RAM地址、la a1, __data_start_flash源Flash地址、la a2, __data_end目标结束地址。不要用sub指令计算长度因为.data段内可能有填充。验证方法烧录后用OpenOCD执行mdw 0x08000000 10查看Flash中.data原始数据和mdw 0x80000000 10查看RAM中拷贝后的数据两者必须完全一致。5.4 坑四中断向量表未安装或地址错误错误现象程序能正常运行但按下按键、接收串口中断时系统立即死机pc跳转到一个奇怪的地址如0x00000004。根因分析RISC-V中所有异常包括中断都跳转到mtvec寄存器指向的地址。复位后mtvec默认为0如果main中没有显式设置mtvec那么任何中断都会尝试从地址0开始执行那里通常是.text段的开始也就是_start代码这显然不是中断处理程序。我的解决方案在main函数最开头立即设置mtvecvoid exception_vector_table(void) __attribute__((section(.vector_table))); void exception_vector_table(void) { // 这里放你的异常处理代码或跳转到具体handler while(1) asm volatile(wfi); } // 在main中 int main(void) { // 必须是第一行 __asm__ volatile

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

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

免费获取报价 →
↑