一颗 RISC-V 芯片从上电(Power-On Reset)到执行 main() 的完整过程。
这是芯片底层软件工程师的必备知识,也是 RISC-V 芯片公司(如时擎)面试高频考点。
参考《嵌入式自我修养》的"从电源到 main"框架,结合 RISC-V 特权架构与真实启动代码。


一、整体流程图

上电 / 复位
Power-On Reset

硬件复位
PC = 复位向量地址

进入 Machine Mode
最高特权级

执行启动汇编 _start
(crt0.S)

关中断 + 关看门狗

初始化栈指针 sp
(每个 hart)

初始化全局指针 gp
(RISC-V 特有)

重定位 .data 段
(Flash → RAM)

清零 .bss 段

初始化时钟 / PLL
(可提前到 C)

设置异常向量 mtvec

切换特权级(可选)
M → S/U

跳转 main()

C 运行环境建立
main 之后


二、第一阶段:硬件复位(上电 → 第一条指令)

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_00000x0001_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,用 mstatus CSR 表达状态。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)结构一致:

  1. .section .vectors 向量表,.org 0x0 放第一条指令 tj __reset
  2. __start 是链接脚本指定入口
  3. __LOAD_RAM/__LOAD_FLASH 宏决定是否重定位到 RAM
  4. 设置栈、关看门狗、初始化时钟,最后跳 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 复位向量 0x000000000xFFFF0000(由 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 0x00xFFFF0000 实现定义(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。”


Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐