RISC-V 启动流程——从上电到 main 函数
一颗 RISC-V 芯片从上电(Power-On Reset)到执行
main()的完整过程。
这是芯片底层软件工程师的必备知识,也是 RISC-V 芯片公司(如时擎)面试高频考点。
参考《嵌入式自我修养》的"从电源到 main"框架,结合 RISC-V 特权架构与真实启动代码。
一、整体流程图
二、第一阶段:硬件复位(上电 → 第一条指令)
2.1 上电发生了什么
| 步骤 | 内容 |
|---|---|
| 1 | 电源稳定,复位信号(reset)拉低再拉高 |
| 2 | CPU 内核被复位,所有寄存器清零 |
| 3 | PC(程序计数器)被加载为复位向量地址 |
| 4 | 进入 Machine Mode(RISC-V 最高特权级) |
2.2 RISC-V 复位向量在哪
RISC-V 规范没有规定固定的复位地址,由具体实现决定。常见情况:
| 芯片类型 | 复位向量地址 | 说明 |
|---|---|---|
| 裸机 MCU | 0x0000_0000 或 Flash 基地址 |
上电直接跑 Flash/ROM 代码 |
| Linux 级 SoC | 0x8000_0000 或 0x0001_0000 |
SiFive HiFive 等 |
| 多核启动 | 各 hart 从同一复位向量启动,用 mhartid 区分 |
判断主核/从核 |
关键点:复位后第一条指令从复位向量开始取指,这是硬件决定的,不是软件配置的。
2.3 复位后的寄存器状态
| 寄存器 | 复位后状态 | 软件要做什么 |
|---|---|---|
pc |
= 复位向量 | 无 |
| 通用寄存器 x0-x31 | 未定义(x0 恒为 0) | 软件初始化 |
sp(x2) |
未定义 | 必须软件设置栈 |
gp(x3) |
未定义 | 必须软件设置(RISC-V 特有) |
mstatus |
大部分清零,MIE=0(关中断) | 按需配置 |
mtvec |
实现定义 | 软件设置异常向量 |
mhartid |
= 当前 hart ID | 多核判断用 |
RISC-V 与 ARM 的关键区别:ARM 上电后有 CPSR/SPSR 寄存器定义模式;RISC-V 没有 CPSR,用
mstatusCSR 表达状态。ARM 有固定复位向量表(0x0000 或 0xFFFF0000),RISC-V 复位地址由实现定义。
三、第二阶段:启动汇编 _start(crt0.S)
_start 是链接脚本指定的入口,是上电后执行的第一段软件。它做的事是"把硬件环境准备好,交给 C 语言"。
3.1 关中断 + 关看门狗
# 复位后 mstatus.MIE 通常已是 0,但显式确保
csrc mstatus, MIE # 清中断使能位
# 关看门狗(防止启动过程中被复位)
# 具体操作依赖 SoC 的看门狗外设寄存器
为什么先关看门狗:启动过程可能耗时(拷贝 .data、清零 .bss),若看门狗超时会导致反复复位。这也是《嵌入式自我修养》强调的经典第一课。
3.2 初始化栈指针 sp(每个 hart 都要)
# 为当前 hart 设置栈
la sp, _stack_top # 加载栈顶地址
多核场景下,每个 hart 需要独立的栈,通常用 mhartid 区分:
csrr t0, mhartid # 读 hart ID
slli t0, t0, STACK_SHIFT # 乘以栈大小
la sp, _stack_top
sub sp, sp, t0 # 每个 hart 分配独立栈空间
关键点:没有栈,C 函数就无法调用(call 指令要把返回地址压栈)。所以设置 sp 是进入 C 之前的第一要务。
3.3 初始化全局指针 gp(RISC-V 特有,最易被问)
这是 RISC-V 区别于 ARM/x86 的独特环节:
.option push
.option norelax
la gp, __global_pointer$ # 链接脚本定义的符号
.option pop
为什么需要 gp:
- RISC-V 的
lw/sw指令用 gp 相对寻址访问全局变量(通过 linker relaxation 优化) - gp 约定指向
.sdata段 + 0x800(小数据段中间偏移,±2KB 范围) - 若 gp 未初始化,所有通过 gp 相对寻址的全局变量访问都会跳错地址
| 概念 | 说明 |
|---|---|
.sdata / .sbss |
小数据段(small data),放入 gp 可达 ±2KB 的小变量 |
__global_pointer$ |
链接脚本定义的符号,= .sdata + 0x800 |
| linker relaxation | 编译期把绝对寻址优化为 gp 相对寻址,省一条指令 |
面试答法:RISC-V 启动里必须初始化 gp 寄存器,它指向 .sdata+0x800,用于全局变量的小数据段相对寻址;不初始化会导致全局变量访问错误。这是 RISC-V 特有的,ARM 没有。
3.4 重定位 .data 段(Flash → RAM)
为什么需要重定位:.data(已初始化全局变量)的加载地址 LMA 在 Flash(断电不丢),但运行地址 VMA 在 RAM(可写)。上电后必须把初值从 Flash 拷贝到 RAM。
# 拷贝 .data 段
la t0, _data_lma # Flash 中的源地址(加载地址)
la t1, _data_vma # RAM 中的目标地址(运行地址)
la t2, _data_end
copy_loop:
lw t3, 0(t0)
sw t3, 0(t1)
addi t0, t0, 4
addi t1, t1, 4
bltu t1, t2, copy_loop
这些符号(_data_lma/_data_vma/_data_end)由链接脚本定义。
3.5 清零 .bss 段
.bss(未初始化全局变量)在运行时必须是 0,但 Flash 里不存它的内容,所以要在 RAM 里清零:
# 清零 .bss 段
la t0, _bss_start
la t1, _bss_end
zero_loop:
sw zero, 0(t0) # zero 是 x0,恒为 0
addi t0, t0, 4
bltu t0, t1, zero_loop
面试要点:
.data要"拷贝"(因为有初值),.bss要"清零"(因为没有初值,运行前必须是 0)。两者都发生在进入 main 之前,否则全局变量的初始值不对。
3.6 设置异常向量 mtvec
la t0, trap_entry # 异常处理入口
csrw mtvec, t0 # 写入 mtvec CSR
mtvec 的低 2 位定义模式:
00:直接模式,所有异常跳转到同一地址01:向量模式,按异常类型跳转到不同偏移
四、第三阶段:跳转 main 与特权级切换
4.1 直接跳转(裸机 M 模式)
最简单的裸机场景,全程在 Machine Mode,直接调用:
call main # 或 jal ra, main
4.2 特权级切换(需要 U/S 模式时)
若需要从 M 模式切换到 U/S 模式(如跑 OS),用 mret:
# 设置返回后要进入的特权级
csrw mepc, main # 返回地址 = main
# 设置 mstatus.MPP = 00 (U) 或 01 (S)
# 清除 mstatus.MPIE
mret # 触发特权级切换 + 跳转 mepc
要点:
mret会根据mstatus.MPP切换到目标特权级,并跳转到mepc。这是从固件进入 OS 内核的标准方式。
五、第四阶段:main 之后的 C 运行环境
进入 main() 不代表万事大吉,C 运行环境还要靠一些"隐式"机制:
5.1 main 之前的隐藏工作
很多工具链(如 GCC)会插入 __libc_init_array 之类的调用,在 main 之前执行:
| 机制 | 作用 |
|---|---|
| 构造函数(constructor) | __attribute__((constructor)) 函数,在 main 前执行 |
| C++ 静态对象构造 | 全局对象的构造函数 |
.init_array 段 |
构造函数数组,逐个调用 |
5.2 main 的返回值与退出
- 裸机:
main通常while(1)死循环 - 有 OS:
main返回后调用exit(),回到启动代码
六、链接脚本(Linker Script)——启动流程的"地图"
启动代码里所有符号(_stack_top、__global_pointer$、_data_lma 等)都来自链接脚本。它是理解启动流程的关键:
SECTIONS {
. = 0x00000000; /* 复位向量 / Flash 基地址 */
.text : { /* 代码段 */
*(.vectors) /* 向量表放最前面 */
*(.text)
}
.rodata : { *(.rodata) } /* 只读数据 */
.data : AT(LOADADDR(.data)) { /* .data 加载在 Flash,运行在 RAM */
__data_vma = .;
*(.data)
__data_end = .;
} > RAM
.sdata : { /* 小数据段,gp 寻址 */
__global_pointer$ = . + 0x800;
*(.sdata)
} > RAM
.bss : {
__bss_start = .;
*(.bss)
__bss_end = .;
} > RAM
}
段与 LMA/VMA 对照表
| 段 | 内容 | LMA(加载) | VMA(运行) | 启动处理 |
|---|---|---|---|---|
.text |
代码 | Flash | Flash(XIP)或 RAM | 无需处理(或重定位) |
.rodata |
只读数据 | Flash | Flash | 无需处理 |
.data |
已初始化全局变量 | Flash | RAM | 拷贝 |
.sdata |
小数据段 | Flash | RAM | 拷贝 + gp 指向 |
.bss |
未初始化全局变量 | 无 | RAM | 清零 |
| 栈 | 局部变量 | 无 | RAM | 设置 sp |
七、面试速记(时擎必考)
7.1 一句话回答
“RISC-V 上电后,PC 跳到复位向量,进入 Machine Mode,执行启动汇编 _start:先关中断和看门狗,然后设置栈指针 sp 和全局指针 gp(RISC-V 特有),接着把 .data 段从 Flash 拷贝到 RAM、清零 .bss,设置异常向量 mtvec,最后跳转到 main。如果要从 M 模式切到 S/U 模式,用 mret 完成切换。”
7.2 高频追问与答案
| 追问 | 答案 |
|---|---|
| 为什么 .data 要拷贝? | .data 是已初始化全局变量,初值存在 Flash(LMA),运行时要写到 RAM(VMA),所以启动时拷贝 |
| 为什么 .bss 要清零? | .bss 是未初始化全局变量,Flash 不存内容,但运行前必须是 0,所以启动时清零 |
| gp 寄存器干什么的? | RISC-V 特有,指向 .sdata+0x800,用于全局变量 gp 相对寻址(linker relaxation),不初始化会导致全局变量访问错误 |
| sp 为什么必须软件设置? | 复位后 sp 未定义,没有栈就无法调用 C 函数(call 要压返回地址) |
| mtvec 是什么? | 异常向量基址 CSR,低 2 位定义直接/向量模式 |
| 怎么从 M 切到 U/S? | 设 mepc=目标地址、mstatus.MPP=目标特权级,执行 mret |
| 多核怎么区分主从? | 读 mhartid CSR,主核(通常 0)做全局初始化,从核等信号 |
| RISC-V 复位地址是多少? | 规范不固定,实现定义(MCU 常 0x0,Linux SoC 常 0x8000_0000) |
7.3 与真实启动代码的印证
真实芯片启动代码(如 Telink 的 cstartup_flash.S)结构一致:
.section .vectors向量表,.org 0x0放第一条指令tj __reset__start是链接脚本指定入口- 按
__LOAD_RAM/__LOAD_FLASH宏决定是否重定位到 RAM - 设置栈、关看门狗、初始化时钟,最后跳 C 代码
八、ARM 启动流程对比(Cortex-M 与 Cortex-A)
把 ARM 的启动流程对照 RISC-V 梳理,便于面试时"对比着讲"。
分两条线:Cortex-M(嵌入式 MCU,对应 Sharpa/时擎的 RTOS 场景)与 Cortex-A(应用处理器,对应后摩/知存的 Linux SoC 场景)。
8.1 Cortex-M 启动流程(嵌入式 MCU)
Cortex-M 上电流程:
| 步骤 | 内容 |
|---|---|
| 1 | 复位后,硬件从向量表地址(通常 0x0000_0000,可由 VTOR 重映射)取前两项 |
| 2 | 第 0 项(0x0)= 主栈指针 MSP 初始值,硬件自动加载到 SP |
| 3 | 第 1 项(0x4)= Reset_Handler 复位向量,硬件自动加载到 PC |
| 4 | 进入 Reset_Handler(Thread 模式 + 特权级)运行 |
| 5 | Reset_Handler:关看门狗 → 拷贝 .data → 清零 .bss → SystemInit(时钟)→ __main → main |
核心特点(与 RISC-V 最大的区别):
| 特点 | 说明 |
|---|---|
| SP 硬件自动加载 | 向量表首项是 MSP 初值,硬件自动设 SP,无需汇编设栈(RISC-V 必须软件设 sp) |
| 无 gp 寄存器 | 全局变量用绝对地址或 PC 相对寻址(位置无关),无 RISC-V 的 gp 概念 |
| NVIC 硬件向量表 | 中断由 NVIC 硬件管理,向量表基址 VTOR 可重定位 |
| 模式简单 | Thread/Handler 模式 + 特权/非特权(CONTROL 寄存器控制) |
| 异常返回即切换 | 用 BX LR(特殊 EXC_RETURN 值)触发异常返回与模式切换 |
8.2 Cortex-A 启动流程(应用处理器 / Linux SoC)
Cortex-A 上电流程:
| 步骤 | 内容 |
|---|---|
| 1 | 复位向量 0x00000000 或 0xFFFF0000(由 SCTLR.V 或实现决定) |
| 2 | 进入最高异常等级 EL3(或 EL1),CPSR 决定复位后的初始模式(SVC) |
| 3 | 用 MPIDR 区分多核(主核做全局初始化,从核等信号) |
| 4 | 启动汇编:关中断/关 MMU/关 Cache → 设置各模式栈指针(SVC/IRQ/FIQ/ABT/UND)→ 设异常向量表 VBAR → 初始化 MMU/页表 → 跳 C |
| 5 | 特权级逐级下降:EL3(固件)→ EL2(hypervisor)→ EL1(OS)→ EL0(应用),用 eret 切换 |
核心特点(与 RISC-V 的对应关系):
| Cortex-A 概念 | RISC-V 对应 | 说明 |
|---|---|---|
| CPSR/SPSR | mstatus | 状态寄存器(模式位、中断使能) |
| VBAR | mtvec | 异常向量基址寄存器 |
| MPIDR | mhartid | 多核 ID 区分 |
| eret | mret | 特权级切换 + 返回指令 |
| EL0-EL3 | M/S/U | 特权级模型 |
| 每模式一个栈 | 每 hart 一个栈 | 栈组织差异 |
8.3 逐项对比总表
| 维度 | ARM Cortex-M | ARM Cortex-A | RISC-V |
|---|---|---|---|
| 复位向量 | 向量表:首项 SP + 次项 PC | 0x0 或 0xFFFF0000 |
实现定义(MCU 常 0x0,SoC 常 0x8000_0000) |
| SP 初始化 | 硬件自动加载 MSP | 软件设置各模式栈 | 软件设置 sp |
| 全局指针 | 无 | 无 | gp(RISC-V 特有) |
| 状态寄存器 | xPSR(CONTROL/PRIMASK) | CPSR/SPSR | mstatus CSR |
| 异常向量 | NVIC 向量表(VTOR) | VBAR | mtvec CSR |
| 特权级 | Thread/Handler + 特权/非特权 | EL0-EL3 | M/S/U |
| 特权切换 | 异常返回(EXC_RETURN) | eret | mret |
| 多核区分 | —(单核为主) | MPIDR | mhartid |
| 中断控制器 | NVIC | GIC | PLIC / CLINT |
| .data/.bss 处理 | 拷贝/清零(同) | 拷贝/清零(同) | 拷贝/清零(同) |
共性:三种架构都要"拷贝 .data、清零 .bss、初始化栈",这是跨架构通用的启动步骤。
差异核心:Cortex-M 硬件代劳最多(自动设 SP),Cortex-A 模式栈最复杂(多模式多栈),RISC-V 最精简(软件设 sp + 特有的 gp)。
8.4 一句话对比
“ARM Cortex-M 上电后硬件自动加载 SP 和 PC(向量表前两项),RISC-V 要软件设 sp;ARM 没有 gp 寄存器,RISC-V 有 gp(指向 .sdata+0x800 做全局变量寻址);ARM 用 CPSR/VBAR/eret,RISC-V 用 mstatus/mtvec/mret;Cortex-A 多核用 MPIDR 区分,RISC-V 用 mhartid。”
更多推荐



所有评论(0)