ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RISC-V裸机启动流程详解:从复位向量到main函数

RISC-V裸机启动流程详解:从复位向量到main函数 1. 从复位向量到main函数RISC-V裸机启动到底在做什么很多人第一次接触RISC-V裸机开发脑子里冒出来的第一个问题往往是一块芯片上电之后第一条指令到底从哪儿来我明明写的是C语言的main函数可CPU怎么知道要去执行它这个问题看起来基础但真正把它讲清楚需要把从复位到进入C环境这一整条链路拆开来看。RISC-V的裸机启动流程本质上就是一段“在操作系统接管之前把硬件从混沌状态整理成C语言可以正常运行的环境”的过程。它不像在Linux上写程序那样有内核帮你把栈指针设好、把BSS段清零、把中断向量表安排好。裸机环境下这些全部要你自己动手。关键词里的启动流程、链接脚本、多核启动其实指向的是同一件事的三个侧面代码放在哪里、运行时环境怎么建立、多个核心怎么协调。这篇文章适合两类人看。一类是刚学完RISC-V指令集、想上手真实芯片但不知道从哪下手的嵌入式新手另一类是从ARM Cortex-M转过来、发现RISC-V的启动逻辑和ARM那套Reset_Handler不太一样、需要重新建立认知的工程师。我会尽量把每一步背后的“为什么”讲透而不是只丢一段汇编让你抄。先说一个反直觉的结论RISC-V的裸机启动代码绝大部分工作不是“启动”而是“准备”。真正让CPU跑起来的那一下硬件已经帮你做完了——上电后PC自动指向复位向量。你要做的是在那条复位向量指向的代码里把栈、数据段、时钟、内存控制器这些东西一个个摆到位最后才跳到main。所以理解启动流程核心是理解“C语言运行需要哪些前提条件”然后倒推每一步该做什么。2. 链接脚本决定代码住在哪间屋子里的那张图纸2.1 为什么裸机一定要手写链接脚本在Linux上写程序链接脚本是工具链默认提供的你基本不用管。但裸机不一样因为你的代码要放到哪个物理地址完全取决于芯片的内存映射。比如一颗典型的RISC-V MCUFlash可能从0x20000000开始SRAM从0x10000000开始而另一颗SoC可能启动ROM在0x00000000DDR从0x80000000开始。链接脚本就是告诉链接器把.text放哪、把.data放哪、把.bss放哪、栈顶设在哪。我见过不少新手直接拿工具链默认脚本去链接结果生成的elf文件地址全是乱的烧进去之后CPU取指就飞了。所以第一件事先确认你手上这颗芯片的内存映射表通常在数据手册的Memory Map章节。没有这张表链接脚本无从谈起。2.2 一个最小可用的链接脚本长什么样下面这段是我在实际项目中反复用过的一个精简模板针对的是“代码在Flash、数据在SRAM”这种最常见的裸机场景OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x20000000, LENGTH 512K SRAM (rwx) : ORIGIN 0x10000000, LENGTH 64K } SECTIONS { .text : { *(.text.init) *(.text*) *(.rodata*) } FLASH .data : { *(.data*) } SRAM AT FLASH .bss : { *(.bss*) *(COMMON) } SRAM _stack_top ORIGIN(SRAM) LENGTH(SRAM); }这里有几个点值得展开说。ENTRY(_start)指定了入口符号链接器会把这个符号的地址写进elf的入口字段但注意——裸机环境下这个字段不一定被硬件使用真正决定第一条指令地址的是复位向量后面会讲。.text.init单独拎出来是为了把启动汇编放在最前面保证它落在Flash的起始地址。.data段用了AT FLASH意思是“运行时地址在SRAM但加载地址在Flash”这就是为什么启动代码里必须有一段“把data从Flash搬到SRAM”的逻辑。_stack_top定义在SRAM末尾因为栈是向下生长的。2.3 链接脚本里最容易踩的三个坑第一个坑是对齐。RISC-V的某些段有对齐要求比如中断向量表通常要求4字节或更大对齐。如果你忘了加ALIGN(4)链接出来的地址可能是奇数取指直接异常。第二个坑是栈的大小没留够。我吃过一次亏栈顶设在SRAM末尾但SRAM总共才64K.bss又占了一大块结果栈一深就踩到数据区现象是“程序跑着跑着变量莫名其妙变了”。后来我在链接脚本里显式留了一段_stack_size并在启动代码里做检查。第三个坑是符号命名冲突。_start、_stack_top这些符号如果和库里的符号重名链接器会报重复定义建议加项目前缀比如_myproj_start。提示链接脚本改完之后一定要用riscv64-unknown-elf-objdump -h看一下各段的地址和大小确认和你预期的一致再烧录。这一步能省掉大量“烧进去没反应”的调试时间。3. 启动汇编的逐行拆解从复位向量到C环境的最后一公里3.1 复位向量和_start符号的关系芯片上电后硬件会把PC设到一个固定地址这个地址叫复位向量。对于大多数RISC-V MCU复位向量就是Flash的起始地址也就是链接脚本里.text.init放的位置。所以只要保证_start是.text.init里的第一个符号CPU第一条指令就会执行它。这里有个细节有些SoC的复位向量不是Flash起始而是内部BootROMBootROM会先做一些时钟初始化再跳转到Flash。这种情况你就要查手册确认BootROM的跳转地址并在链接脚本里把代码放到那个地址。_start的第一件事通常是关中断。虽然复位后中断默认是关的但保险起见先把mstatus的MIE位清零。然后设置栈指针把_stack_top加载到sp寄存器。这两步做完你才有资格调用C函数——因为C函数需要栈。3.2 搬运.data和清零.bssC语言运行的前提接下来是启动流程里最经典的两段循环搬.data、清.bss。为什么必须做因为.data段的变量有初值这些初值存在Flash里但变量本身要在SRAM里运行所以得从Flash复制到SRAM。.bss段的变量初值全是0但SRAM上电后内容是随机的所以得手动清零。这两段逻辑用汇编写大概是这样la t0, _data_lma # Flash中data的加载地址 la t1, _data_start # SRAM中data的起始地址 la t2, _data_end # SRAM中data的结束地址 1: bgeu t1, t2, 2f lw t3, 0(t0) sw t3, 0(t1) addi t0, t0, 4 addi t1, t1, 4 j 1b 2: la t1, _bss_start la t2, _bss_end 3: bgeu t1, t2, 4f sw zero, 0(t1) addi t1, t1, 4 j 3b 4: call main注意_data_lma、_data_start这些符号需要在链接脚本里定义。很多人只定义了_data_start和_data_end忘了_data_lma结果复制的时候源地址错了变量初值全是乱的。这是我在带新人时见过最高频的错误之一。3.3 跳到main之前还要做什么搬完data、清完bss理论上就可以call main了。但实际项目里我通常还会做几件事。第一初始化时钟。很多MCU复位后跑的是内部低速时钟比如8MHz直接跑main会慢得离谱而且外设时序也不对。第二初始化内存控制器。如果代码要跑在DDR上而DDR还没初始化那取指就会失败——这种情况启动代码得先跑在SRAM里初始化完DDR再跳过去。第三设置gp和tp寄存器。RISC-V的gp用于全局指针优化tp用于线程指针如果编译器开了相关优化不设置这两个寄存器会导致访问全局变量出错。第四配置中断向量表。把mtvec指向你的异常处理入口。这些步骤的顺序不能乱时钟必须在内存控制器之前因为内存控制器配置依赖时钟内存控制器必须在跳转到DDR代码之前gp/tp设置必须在任何C代码之前。4. 多核启动一个核干活其他核先等着4.1 为什么多核启动比单核麻烦单核启动你只需要关心一个核从复位跑到main。多核启动的麻烦在于所有核上电后都会从同一个复位向量开始执行。如果你不做处理两个核会同时搬data、同时清bss、同时初始化外设轻则数据竞争重则外设配置错乱。所以多核启动的核心思路是选一个主核通常叫hart 0负责所有初始化工作其他核hart 1~N在初始化完成之前要么停在原地等待要么进入低功耗状态。4.2 用mhartid区分主从核RISC-V提供了一个CSR寄存器mhartid里面存着当前核的ID。启动汇编里第一件事就可以读它csrr t0, mhartid bnez t0, secondary_wait如果mhartid不为0就跳到secondary_wait标签让从核在一个循环里空转或者用WFI指令进入等待。主核则继续走完整的初始化流程。等主核把外设、内存、中断都初始化好之后再通过一个共享内存变量或者IPI核间中断通知从核“可以起来了”。从核收到通知后再设置自己的栈指针跳到各自的入口函数。这里有个关键点每个核都需要独立的栈。如果你只定义了一个_stack_top两个核共用同一个栈那必然踩踏。正确做法是在链接脚本里为每个核预留独立的栈空间比如_stack0_top、_stack1_top启动时根据mhartid选择对应的栈顶。4.3 多核启动的同步与内存屏障多核环境下内存顺序是个绕不开的话题。主核写了一个变量告诉从核“初始化完成”但从核可能因为乱序执行或缓存一致性看不到这个写操作。所以主核在写完之后要执行fence指令从核在读之前也要执行fence。如果核间有缓存不一致的问题比如某些核有私有Cache还需要做Cache flush或者用uncached地址访问共享变量。我在一个双核项目里就遇到过主核明明把标志位置1了从核读出来还是0查了半天发现是从核的Cache没刷新。后来把共享变量放到uncached区域问题消失。注意多核启动的调试比单核难得多因为现象往往不确定——有时候能跑有时候跑飞。建议先用单核跑通全部初始化再逐步加入从核每加一步都验证。5. 从复位到main的完整时序与常见故障对照5.1 一张表看清每个阶段的状态阶段PC位置栈状态中断关键动作上电复位复位向量未设置关闭硬件跳转到复位向量_start入口Flash未设置关闭关中断、读mhartid设置栈Flash已设置关闭sp _stack_top搬dataFlash已设置关闭Flash→SRAM复制清bssFlash已设置关闭SRAM清零时钟/内存初始化Flash或SRAM已设置关闭配置PLL、DDR跳转main运行时地址已设置按需开启call main这张表我在排查启动问题时经常拿出来对照。比如“程序卡在main之前”你就按表逐个阶段确认栈设了吗data搬了吗时钟起来了吗大部分问题都能定位到具体阶段。5.2 几个典型故障和排查思路故障一烧录后完全没反应。先确认复位向量地址对不对再看链接脚本里.text.init是不是在Flash起始。用objdump看入口地址和手册对比。故障二能进main但全局变量初值不对。九成是data搬运的源地址或长度错了检查_data_lma和_data_end的定义。故障三跑一会儿就飞。大概率是栈溢出检查栈大小和.bss占用必要时在栈顶和栈底放哨兵值运行一段时间后检查哨兵是否被改写。故障四多核时好时坏。检查每个核的栈是否独立、共享变量是否有内存屏障、从核是否在初始化完成前就访问了外设。5.3 用QEMU快速验证启动流程在真实硬件上调试启动代码每次改完都要烧录效率很低。我的习惯是先用QEMU跑一遍。QEMU的virt机器支持RISC-V可以加载你的elf文件用-bios none -kernel your.elf启动然后接GDB单步调试。这样你可以在指令级别看PC怎么走、栈怎么变、data怎么搬确认逻辑无误后再上真机。QEMU的好处是启动快、可复现而且能看到真实硬件上看不到的CSR状态。qemu-system-riscv64 -machine virt -nographic -bios none -kernel baremetal.elf -s -S-s -S会让QEMU等待GDB连接然后你用riscv64-unknown-elf-gdb连上去target remote :1234就可以开始单步了。我一般会在_start、搬data循环、call main这三处下断点确认每一步的状态符合预期。6. 我在实际项目里积累的几条启动代码经验第一条经验启动汇编尽量短能放到C里做的就放到C里做。汇编难读难调把时钟初始化、外设配置这些放到C函数里启动汇编只保留“设栈、搬data、清bss、跳C”这四件事可维护性会好很多。我现在的模板里_start只有二十来行剩下的全在system_init()里。第二条经验给启动流程加串口打印。在搬data之前就初始化一个最简单的串口哪怕只是轮询发送然后在每个阶段打印一个字符。这样一旦卡住你立刻知道卡在哪一步。这个技巧在没有任何调试器的裸机环境里简直是救命稻草。我通常用A表示进_startB表示栈设好C表示data搬完D表示进main。第三条经验链接脚本和启动汇编要一起改。很多人改了链接脚本的段布局忘了同步改启动汇编里的符号引用结果链接报错或者运行异常。我的做法是把两者放在同一个目录改完一个立刻检查另一个并且在Makefile里加一条规则用nm导出关键符号确认地址符合预期。第四条经验多核启动时从核的入口函数要单独写。不要指望从核和主核共用同一个main从核通常只需要跑一个特定的任务循环。给每个从核写独立的入口栈也独立分配这样职责清晰调试也方便。最后说一个容易被忽略的点启动代码的版本管理。不同芯片的启动流程差异很大建议每个芯片型号维护一份独立的启动代码和链接脚本不要试图用一套代码适配所有芯片。我在一个项目里试图用宏切换两套链接脚本结果宏没定义对链接到了错误的地址查了一整天才发现。后来改成每个芯片一个目录各自独立再也没出过这类问题。
返回列表