☰
RISC-V裸机启动全流程:链接脚本、启动汇编与多核唤醒
2026/10/7 12:13:55 网站建设 项目流程

1. 从复位向量到main函数:RISC-V裸机启动到底在做什么

很多人第一次接触RISC-V裸机开发,脑子里冒出来的第一个问题往往是:板子上电之后,第一条指令到底从哪儿来?我明明写了个main函数,谁调用的它?栈指针什么时候设置的?全局变量谁帮我初始化的?这些问题在Linux应用开发里几乎不用操心,因为操作系统和C运行时库把这一切都包圆了。但一旦进入bare-metal的世界,这些活儿全得自己干。

RISC-V的启动流程,说白了就是一段“从零开始搭台子”的过程。芯片复位后,CPU从某个固定的复位向量地址取第一条指令,此时没有栈、没有堆、没有初始化好的.data段,甚至连中断向量表都还没影子。你要做的,是在进入C语言的main之前,用汇编和链接脚本把运行环境一点点搭起来:设置栈指针、初始化数据段、清零BSS段、配置中断向量、必要时唤醒其他核心。这套流程在ARM Cortex-M上可能被启动文件封装得比较好,但在RISC-V生态里,因为指令集本身是模块化的,不同厂商的SoC启动细节差异很大,所以更需要你真正理解每一步在干什么。

这篇文章适合谁看?如果你已经写过一些RISC-V的裸机点灯程序,但对启动文件里那一堆汇编似懂非懂;或者你从STM32转过来,发现RISC-V的启动流程和ARM完全不是一回事;又或者你在做多核RISC-V芯片的bring-up,被主核从核的启动顺序搞得头大——那这篇内容应该能帮你把整条链路捋清楚。我会从链接脚本、启动汇编、C运行时初始化、多核启动几个维度,把RISC-V bare-metal启动流程拆开讲,尽量做到你照着就能复现。

2. 链接脚本:给代码和数据安排一个合理的“住址”

2.1 为什么链接脚本是启动流程的第一步

链接脚本这东西,在应用开发里几乎感知不到,因为编译器默认给你安排好了。但在裸机环境里,链接脚本决定了你的代码被放到哪个地址、数据段在RAM里的布局、栈顶在哪里、堆往哪个方向长。可以说,链接脚本是启动流程的“地基”,地基没打好,后面启动汇编写得再漂亮也跑不起来。

RISC-V芯片的地址空间布局通常是这样:复位向量在一个固定的低地址区域(比如0x80000000或者0x00000000,取决于具体SoC),这段地址映射到Flash或者ROM;RAM则在高地址区域,比如0x80000000往上。链接脚本要做的就是告诉链接器:.text段放到Flash区域,.data段虽然加载在Flash里,但运行时地址在RAM,.bss段直接在RAM里占位。

我见过不少新手在这里踩坑:链接脚本里把.data段的LMA(加载地址)和VMA(运行地址)搞混了,结果程序烧进去之后,全局变量的初始值全是乱的。原因很简单,.data段的初始值存在Flash里,启动时需要从Flash拷贝到RAM,如果你链接脚本没写对,拷贝的源地址和目标地址就错了。

2.2 一个可复用的链接脚本模板

下面这个链接脚本模板,是我在多个RISC-V裸机项目里反复用过的,你可以根据自己芯片的地址空间改一下MEMORY区域的起始地址和长度:

OUTPUT_ARCH("riscv") ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN = 0x80000000, LENGTH = 4M RAM (rwx) : ORIGIN = 0x80400000, LENGTH = 8M } SECTIONS { .text : { *(.text.init) *(.text .text.*) *(.rodata .rodata.*) } > FLASH .data : { _data_start = .; *(.data .data.*) _data_end = .; } > RAM AT> FLASH _data_load = LOADADDR(.data); .bss : { _bss_start = .; *(.bss .bss.*) *(COMMON) _bss_end = .; } > RAM .stack : { . = ALIGN(16); _stack_bottom = .; . += 0x4000; _stack_top = .; } > RAM _heap_start = .; }

这里有几个关键点值得展开说。ENTRY(_start)指定了入口符号,链接器会把ELF入口点设为_start,但注意,这只是告诉链接器入口在哪里,真正上电后CPU从哪个地址取指令,还是由硬件复位向量决定的。通常复位向量会跳转到_start,所以两者要对上。

.data段的> RAM AT> FLASH这个写法,意思是运行地址在RAM,加载地址在FLASH。链接器会自动生成_data_load符号,指向Flash里的加载地址。启动汇编里就用_data_load到_data_start的拷贝,把数据从Flash搬到RAM。

.stack段我习惯单独放一个段,定义_stack_top符号,启动汇编里直接把栈指针设成这个值。栈大小0x4000是16KB,对于大多数裸机程序够用了,如果你跑RTOS或者递归比较深,可以适当加大。

注意:不同厂商的RISC-V芯片复位向量地址可能不同,比如有些芯片复位后从0x00000000开始执行,有些从0x80000000开始。改链接脚本之前,一定先查芯片手册的Memory Map章节,确认Flash和RAM的起始地址。

2.3 链接脚本里容易忽略的细节

还有一个细节容易被忽略:.text.init段。我习惯把启动汇编单独放到.text.init段里,并且在链接脚本里把它放在.text段的最前面。这样做的好处是,启动代码一定在Flash的最开头,复位向量跳转过来就能直接执行,不用担心中间插了别的代码。

另外,如果你用的是支持压缩指令的RISC-V核心,链接脚本里可能还需要处理.riscv.attributes段,不过这个一般不影响运行,只是给调试器看的。真正影响运行的是对齐问题:栈指针必须16字节对齐,RISC-V的ABI要求栈对齐到16字节,不对齐的话在某些核心上会触发异常。

3. 启动汇编:上电后的第一段代码

3.1 _start符号里必须做的事

启动汇编是整个流程里最“硬核”的部分,因为它直接和硬件打交道。_start符号通常放在一个.S文件里,上电后CPU执行的第一条指令就在这里。这段代码要做的事情按顺序大致是:关中断、设置栈指针、拷贝.data段、清零.bss段、设置gp和tp寄存器、跳转到main。

为什么第一步是关中断?因为此时中断向量表还没配置,栈也没设置好,如果这时候来了个中断,CPU会跳到默认的中断入口,而那里可能什么都没有,直接跑飞。所以上电后第一件事就是写mstatus寄存器,把全局中断使能位清掉。

设置栈指针很简单,把链接脚本里定义的_stack_top加载到sp寄存器就行。但要注意,RISC-V的栈是向下增长的,所以_stack_top是栈的最高地址,sp设成这个值,压栈时sp减小。

拷贝.data段和清零.bss段是C运行时初始化的核心。.data段里的变量有初始值,这些初始值存在Flash里,必须搬到RAM里对应的位置。.bss段里的变量初始值为零,但Flash里不存储这些零,所以需要在RAM里手动清零。

3.2 一段完整的启动汇编示例

下面这段启动汇编,是我在一个RV32IMAC核心上实际用过的,你可以直接参考:

.section .text.init .globl _start _start: # 1. 关全局中断 csrw mie, zero csrw mip, zero # 2. 设置栈指针 la sp, _stack_top # 3. 拷贝 .data 段从 FLASH 到 RAM la a0, _data_load la a1, _data_start la a2, _data_end copy_data: bgeu a1, a2, clear_bss lw t0, 0(a0) sw t0, 0(a1) addi a0, a0, 4 addi a1, a1, 4 j copy_data clear_bss: # 4. 清零 .bss 段 la a1, _bss_start la a2, _bss_end clear_bss_loop: bgeu a1, a2, set_gp sw zero, 0(a1) addi a1, a1, 4 j clear_bss_loop set_gp: # 5. 设置全局指针 gp .option push .option norelax la gp, __global_pointer$ .option pop # 6. 跳转到 main call main # 7. main 返回后进入死循环 loop: j loop

这段代码里,la是伪指令,实际会展开成auipc加addi,用来加载符号地址。拷贝.data段用的是字拷贝,每次4字节,如果你的数据段不是4字节对齐的,可能需要改成字节拷贝。不过链接脚本里一般会保证对齐,所以字拷贝没问题。

gp寄存器的设置有个坑:RISC-V的ABI规定gp指向.sdata段的中间位置,用于小数据区的快速访问。如果你不设置gp,链接器生成的一些依赖gp的代码就会出错。设置gp的时候要用.option norelax,防止汇编器把la展开成依赖gp的指令,那就死循环了。

提示:call main之后理论上不应该返回,但万一main返回了,一定要有个死循环兜底,否则CPU会继续往下执行未知内存,行为不可预测。

3.3 中断向量表的早期配置

虽然启动汇编里第一步就关了中断,但有些场景下你可能希望在main之前就把中断向量表配好,比如你需要用定时器中断来做早期延时。RISC-V的中断向量表通过mtvec寄存器配置,支持两种模式:直接模式和向量模式。

直接模式下,所有中断都跳到同一个入口,由软件判断中断源。向量模式下,每个中断源有独立的入口地址,入口地址按中断号排列。裸机开发里我一般用直接模式,简单可控。配置代码大概是这样:

la t0, trap_entry csrw mtvec, t0

trap_entry是你自己写的中断服务入口,里面保存上下文、判断中断源、调用对应的处理函数。这部分内容展开就太长了,这里先提一句,后面讲多核启动的时候还会涉及。

4. C运行时初始化:从汇编到main的最后一公里

4.1 全局变量和静态变量的初始化时机

很多人以为全局变量的初始化是C语言自动完成的,其实在裸机环境里,这件事是启动汇编帮你做的。.data段的拷贝和.bss段的清零,就是全局变量和静态变量初始化的全部内容。如果你在启动汇编里漏了这两步,main函数里访问全局变量时,读到的就是随机值。

我踩过一次坑:链接脚本里.bss段的结束符号写成了_bss_end,但启动汇编里引用的是_bss_end_,多了一个下划线,结果链接器报未定义符号。这种低级错误在调试的时候特别浪费时间,因为编译能过,链接才报错,而且报错信息不一定直观。后来我养成了一个习惯:链接脚本里定义的所有符号,都在一个头文件里用extern声明一遍,汇编和C都引用这个头文件,避免拼写错误。

4.2 构造函数和析构函数的处理

C++项目里,全局对象的构造函数需要在main之前调用。裸机环境没有操作系统帮你做这件事,需要自己遍历.init_array段。链接脚本里要加上:

.init_array : { _init_array_start = .; KEEP(*(.init_array)) _init_array_end = .; } > RAM AT> FLASH

启动汇编里在跳转到main之前,遍历这个数组,把每个函数指针取出来调用一遍。析构函数在裸机里一般用不到,因为程序永远不会正常退出,但如果你要写,逻辑类似。

纯C项目不需要这一步,但如果你用C++写裸机,这个环节不能省。我见过有人用C++写RISC-V裸机,全局对象的构造函数没被调用,结果对象里的成员变量全是未初始化的,调试了半天才发现是.init_array没处理。

4.3 栈溢出检测的简易实现

裸机环境没有MMU,栈溢出不会触发异常,而是会悄悄覆盖相邻的内存区域,导致各种诡异问题。一个简单的栈溢出检测方法是:在栈的底部和顶部填充特定的魔数,在main循环里定期检查这些魔数是否被改写。

具体做法是在链接脚本里把栈段单独拿出来,启动汇编里把_stack_bottom到_stack_top之间的内存填充成0xDEADBEEF,然后在main里定期检查_stack_bottom附近的几个字是否还是0xDEADBEEF。如果被改了,说明栈曾经增长到这个位置,可能已经溢出。

这个方法不能精确检测溢出,但能给你一个预警。真正要精确检测,需要硬件支持或者用MPU,但大多数低端RISC-V核心没有MPU,所以软件填充法是最实用的。

5. 多核启动:主核与从核的配合

5.1 RISC-V多核启动的基本模型

多核RISC-V芯片的启动流程比单核复杂得多。上电后,通常只有一个核心(称为主核或者hart 0)从复位向量开始执行,其他核心(从核)处于暂停状态,等待主核发送启动信号。主核完成自身的初始化后,通过写特定的寄存器或者发送IPI(处理器间中断)来唤醒从核,从核从指定的入口地址开始执行。

这个模型和ARM的多核启动不太一样。ARM Cortex-A系列通常所有核心同时从复位向量启动,然后通过软件判断自己是主核还是从核。RISC-V则更多是主核先跑,从核待命,主核准备好之后再逐个唤醒从核。两种方式各有优劣,RISC-V的方式更可控,但主核的启动时间会稍长。

5.2 从核唤醒的两种常见方式

从核唤醒方式取决于具体SoC。常见的有两种:一种是通过写控制寄存器,主核把从核的入口地址写到某个寄存器,然后置位启动位,从核硬件自动跳转到入口地址;另一种是通过软件中断,主核向从核发送IPI,从核在中断处理里读取入口地址并跳转。

以我做过的一个四核RISC-V芯片为例,从核唤醒寄存器布局是这样的:

寄存器偏移名称功能
0x00CORE0_ENTRY核心0入口地址
0x04CORE1_ENTRY核心1入口地址
0x08CORE2_ENTRY核心2入口地址
0x0CCORE3_ENTRY核心3入口地址
0x10CORE_CTRL启动控制位,bit0对应核心0

主核初始化完内存和中断控制器后,把从核的入口地址写到对应的ENTRY寄存器,然后写CORE_CTRL的对应位,从核就开始执行。从核的入口代码通常是一段简短的汇编,设置自己的栈指针,然后跳转到C语言的从核主函数。

5.3 多核启动的同步与互斥

多核启动最大的难点不是唤醒本身,而是同步。主核在初始化内存控制器的时候,从核不能访问内存;主核在初始化串口的时候,从核不能打印日志。所以从核唤醒的时机很关键,必须在主核完成所有共享资源初始化之后。

我通常的做法是:主核完成初始化后,设置一个全局变量g_smp_ready,然后才唤醒从核。从核启动后,先自旋等待g_smp_ready变成1,然后再继续执行。这个变量要放在.data段里,并且初始值为0,确保从核读到的是主核写过的值。

互斥方面,裸机环境没有操作系统提供的锁,需要用原子指令自己实现。RISC-V的A扩展提供了lr.w和sc.w指令,可以实现自旋锁。一个最简单的自旋锁实现:

typedef struct { volatile int lock; } spinlock_t; void spin_lock(spinlock_t *l) { while (1) { int old; do { old = __atomic_exchange_n(&l->lock, 1, __ATOMIC_ACQUIRE); } while (old != 0); return; } } void spin_unlock(spinlock_t *l) { __atomic_store_n(&l->lock, 0, __ATOMIC_RELEASE); }

这里用了GCC的原子内置函数,编译的时候需要加-march=rv32imac或者rv64imac,确保A扩展被启用。如果你的核心没有A扩展,那就只能用关中断来模拟临界区,但那样在多核环境下是不安全的。

注意:多核启动时,从核的栈必须和主核的栈分开,不能共用。我一般给每个核心分配独立的栈空间,在链接脚本里定义_stack_top_core0、_stack_top_core1等符号,从核入口汇编里加载对应的栈顶地址。

6. 常见问题与排查技巧实录

6.1 程序烧进去不跑,怎么定位

这是最常见的问题,原因可能有很多。我的排查顺序是这样的:先用调试器连上,看PC停在哪个地址。如果PC停在复位向量附近,说明CPU根本没跳转到_start,可能是复位向量配置错了,或者Flash的启动模式没选对。如果PC停在_start里某条指令上,说明卡在那条指令了,常见的是la指令加载的地址不对,或者访问了未映射的内存。

如果调试器连不上,那就用最笨的办法:在_start的第一条指令处翻转一个GPIO,用示波器或者LED看有没有反应。如果有反应,说明CPU至少执行到了这里;如果没有,说明连第一条指令都没执行,问题出在硬件层面,比如时钟没配置、复位没释放、Flash没使能。

6.2 .data段拷贝后全局变量值还是不对

这个问题我遇到过两次,一次是链接脚本里.data段的LMA和VMA写反了,另一次是启动汇编里拷贝的源地址用了_data_start而不是_data_load。排查方法很简单:在main里打印_data_load、_data_start、_data_end三个符号的地址,看看是否符合预期。_data_load应该在Flash区域,_data_start和_data_end应该在RAM区域,而且_data_end - _data_start应该等于.data段的大小。

还有一个隐蔽的坑:如果.data段的大小不是4字节的整数倍,字拷贝会多拷贝几个字节,可能覆盖.bss段开头的变量。解决办法是在链接脚本里把.data段按4字节对齐,或者在启动汇编里用字节拷贝。

6.3 多核启动时从核跑飞

从核跑飞的原因通常有三个:入口地址写错了、栈指针没设置、从核被唤醒时主核还没初始化完共享资源。排查的时候,先在从核入口处翻转一个独立的GPIO,确认从核有没有跳转到入口。如果没有,检查主核写入口地址的寄存器偏移是否正确。如果有,检查从核的栈指针是否指向了合法的RAM区域。

还有一个容易忽略的点:从核的缓存一致性。如果从核和主核共享内存,但缓存没有维护一致性,从核可能读到旧的指令或数据。解决办法是在唤醒从核之前,主核执行一次缓存刷新操作,确保从核看到的内存内容是最新的。

6.4 常见问题速查表

现象可能原因排查方法
上电后无反应复位向量错误、时钟未配置检查复位向量地址、测量时钟输出
PC停在_start栈指针未设置、访问非法地址单步调试,检查sp值和内存映射
全局变量值随机.data段未拷贝、链接脚本错误打印_data_load/_data_start地址
从核不启动入口地址错误、控制寄存器未写检查从核入口GPIO、读控制寄存器
从核跑飞栈指针未设置、缓存不一致检查从核sp值、执行缓存刷新
中断不触发mtvec未配置、mstatus未使能读mtvec和mstatus寄存器

6.5 几个实用的调试技巧

第一个技巧:在启动汇编里尽早初始化串口,这样你可以在main之前就打印调试信息。串口初始化代码很短,通常就是配置波特率分频器和使能收发,放在.data段拷贝之前也没问题,因为串口寄存器是直接映射的,不依赖内存。

第二个技巧:用ebreak指令做断点。RISC-V的ebreak指令会触发断点异常,如果你接了调试器,就会停在那里;如果没有调试器,可以在异常处理里打印信息。我经常在关键路径上插ebreak,配合调试器快速定位问题。

第三个技巧:把启动汇编里的关键步骤用GPIO翻转标记出来。比如拷贝.data段之前翻转GPIO0,拷贝之后翻转GPIO1,清零.bss段之前翻转GPIO2,之后翻转GPIO3。用逻辑分析仪抓波形,就能精确知道每个步骤花了多长时间,有没有卡住。

7. 从启动流程延伸出去的几个方向

启动流程跑通之后,下一步通常就是移植RTOS或者跑Linux。如果你打算移植FreeRTOS,启动汇编里需要额外配置mtime和mtimecmp寄存器,用于系统时钟节拍。FreeRTOS的RISC-V移植层会接管trap_entry,你需要把中断向量表的配置从启动汇编里移出来,交给RTOS初始化。

如果你打算跑Linux,那启动流程就更复杂了,需要实现SBI(Supervisor Binary Interface)层,处理机器模式下的 trap,然后跳转到Supervisor模式。不过那是另一个话题了,裸机启动流程是这一切的基础,把这部分搞扎实,后面的路会好走很多。

我个人在实际操作中的体会是:RISC-V裸机启动流程看起来繁琐,但每一步都有明确的理由。链接脚本管布局,启动汇编管环境,C运行时管初始化,多核启动管协同。把这四块拆开理解,再合起来调试,基本上没有搞不定的启动问题。最怕的是不求甚解,从别处抄一段启动代码,跑通了就完事,一旦换个芯片或者换个编译器,又得从头再来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询