【risc-v专栏 07】启动与 bare-metal 流程

本篇定位:RISC-V 专题第 7 篇。本篇把"上电到 main 运行"的完整链路讲透:启动汇编必做 5 件事、多核启动、ROM 固化规则、链接脚本与启动的配合。读完后你能从零写一个 bare-metal 启动流程,能调任何启动死机。


一、启动链路总览

RISC-V MCU 的典型启动链路:

复制代码
上电
 │
 ▼
BootROM(出厂固化,只读)
 │  - 硬件复位向量跳到这里
 │  - 初始化最小环境(栈/时钟)
 │  - 加载下一级(Flash/bootloader)到 RAM
 │  - 跳转
 ▼
Bootloader(可选,多级)
 │  - 初始化更多外设(DDR/外设时钟)
 │  - 加载应用固件
 │  - 跳转
 ▼
Application / RTOS
 │  - _start 启动汇编
 │  - 初始化运行时(.data/.bss/gp/mtvec)
 │  - 调 main()
 ▼
main() 运行

各级职责分工

级 谁做 干什么
BootROM 芯片厂商 最小初始化 + 加载下一级
Bootloader 你 外设初始化 + 加载 app
App _start 启动汇编 + runtime 运行时初始化 + main

二、启动汇编必做 5 件事(App _start)

这是本篇核心。App 启动汇编(crt0.s 或 startup.s)必须做 5 件事,缺一不可:

2.1 第 1 件:初始化 sp(栈指针)

asm 复制代码
_start:
    la   sp, _stack_top        # sp 指向栈顶(链接脚本定义)
  • 栈用于函数调用、局部变量、中断保存
  • sp 没初始化,任何 C 函数调用都崩(压栈写到野地址)
  • 栈大小要够(中断嵌套深 + 任务多的场景),链接脚本里预留

2.2 第 2 件:初始化 gp(全局指针)⭐ RISC-V 特有

asm 复制代码
    .option push
    .option norelax          # 防 linker relaxation 把这步优化掉
    la   gp, __global_pointer$
    .option pop
  • gp 指向 .sdata 段中点,用于 gp 相对寻址(②专题讲过)
  • 忘了设 gp,gp 相对寻址全崩------访问小全局变量读到野地址
  • .option norelax 必须加:linker relaxation 会把 la gp 优化成 mv gp,gp(无意义),必须禁掉这步 relaxation

gp 忘设是 RISC-V 启动常见崩点

ARM 没有 gp 概念,你 ARM 经验带过来容易漏这步。症状:启动早期随机崩溃,访问全局变量拿到错值。调试启动死机,先确认 gp 有没有设------这是 RISC-V 启动汇编最容易被漏的一步。

2.3 第 3 件:拷贝 .data 段(Flash → RAM)

asm 复制代码
    la   t0, _sidata          # .data 在 Flash 的加载地址(LMA)
    la   t1, _sdata           # .data 在 RAM 的运行地址(VMA)
    la   t2, _edata
1:  bgeu t1, t2, 2f
    lw   t3, 0(t0)
    sw   t3, 0(t1)
    addi t0, t0, 4
    addi t1, t1, 4
    j    1b
2:
  • .data 是已初始化全局变量,存储在 Flash(掉电保留),运行时要在 RAM(可读写)
  • 启动时从 Flash 拷到 RAM
  • 不拷贝:.data 变量读到 Flash 的值,写它就崩(Flash 只读)

2.4 第 4 件:清零 .bss 段

asm 复制代码
    la   t0, _sbss
    la   t1, _ebss
    li   t2, 0
1:  bgeu t0, t1, 2f
    sw   t2, 0(t0)
    addi t0, t0, 4
    j    1b
2:
  • .bss 是未初始化/初始化为 0 的全局变量,链接脚本不占 Flash(只记大小),运行时在 RAM
  • C 标准要求 .bss 默认 0
  • 不清零:全局变量是随机值,所有 if (flag==0) 类逻辑全错------常见诡异 bug 源

2.5 第 5 件:设置 mtvec(trap 入口)

asm 复制代码
    la   t0, trap_vector
    csrw mtvec, t0            # 配 trap 入口地址
  • 没配 mtvec,任何异常/中断跳到默认地址(0 或保留),立刻死机套娃
  • 必须在开中断前配好(开 MIE 后才有中断,但 trap 随时可能来,如 ecall/异常)

完整启动汇编骨架

asm 复制代码
.section .text.init
.global _start
_start:
    # 1. sp
    la   sp, _stack_top

    # 2. gp(防 relaxation)
    .option push
    .option norelax
    la   gp, __global_pointer$
    .option pop

    # 3. 拷 .data
    la   t0, _sidata
    la   t1, _sdata
    la   t2, _edata
1:  bgeu t1, t2, 2f
    lw   t3, 0(t0)
    sw   t3, 0(t1)
    addi t0, t0, 4
    addi t1, t1, 4
    j    1b
2:

    # 4. 清 .bss
    la   t0, _sbss
    la   t1, _ebss
    li   t2, 0
3:  bgeu t0, t1, 4f
    sw   t2, 0(t0)
    addi t0, t0, 4
    j    3b
4:

    # 5. mtvec
    la   t0, trap_vector
    csrw mtvec, t0

    # 6. (可选)初始化 FPU、cache、中断控制器
    # 7. 调 main
    call main

    # main 返回(不应该),死循环
5:  wfi
    j    5b

三、链接脚本与启动的配合

启动汇编用的 _stack_top _sidata _sdata _edata _sbss _ebss __global_pointer$ 全是链接脚本定义的符号。

典型链接脚本结构

ld 复制代码
ENTRY(_start)

MEMORY {
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS {
    .text : {
        KEEP(*(.text.init))     /* 启动代码在前 */
        *(.text .text.*)
        *(.rodata .rodata.*)
    } > FLASH

    .data : {
        _sdata = .;             /* VMA: RAM 里运行地址 */
        *(.data .data.*)
        _edata = .;
    } > RAM AT > FLASH          /* LMA 在 Flash */
    _sidata = LOADADDR(.data);  /* .data 的 Flash 加载地址 */

    .bss : {
        _sbss = .;
        *(.bss .bss.*) *(COMMON)
        _ebss = .;
    } > RAM

    .sdata : {                  /* 小数据段,gp 寻址 */
        *(.sdata .sdata.*)
    } > RAM
    .sbss : {
        *(.sbss .sbss.*)
    } > RAM

    /* gp 指向 .sdata 中点 */
    __global_pointer$ = . + 0x800;   /* ±2KB 范围 */

    /* 栈 */
    _stack_top = ORIGIN(RAM) + LENGTH(RAM);
}

.data 的 LMA vs VMA

.data > RAM AT > FLASH:

  • VMA(Virtual Memory Address)= RAM 地址,运行时在这里
  • LMA(Load Memory Address)= Flash 地址,启动前在这里
    _sidata = LOADADDR(.data) 给启动汇编用,从这拷贝到 _sdata。LMA/VMA 概念不懂,启动汇编拷贝段就写不对。

四、多核启动

RISC-V 多核(SMP)启动,所有核上电都进 BootROM,但通常只有一个核(核0)跑启动流程,其他核"park"等待。

4.1 主核(核0)流程

asm 复制代码
_start:
    csrr t0, mhartid
    bnez t0, park         # 非核0,去 park

    # 核0:正常启动流程
    la sp, _stack_top
    # ... 5 件事 ...
    call main

park:
    # 非主核:等待唤醒
    wfi                  # 低功耗等待中断
    j park               # 醒来再检查(或被主核引导)

4.2 主核唤醒从核

主核跑起来后,通过 MSI(写从核的 MSIP)或设置共享内存的启动地址,唤醒从核:

c 复制代码
// 主核唤醒核1
*(uint32_t*)(CLINT + MSIP_OFFSET(1)) = 1;  // 触发核1的 MSI

从核的 MSI handler 里跳转到应用代码。


五、ROM 固化规则

5.1 ROM 固化的三个硬条件

  1. 位置无关或固定地址:ROM 烧死后地址不变
  2. 不写自身所在段:ROM 只读
  3. 可写状态在 SRAM:函数的可写全局状态必须在 SRAM,不能跟着代码在 ROM

5.2 ROM 固化与启动的关系

  • BootROM 本身就是最大的"固化代码"
  • 你固化的库函数(__divsi3/memcpy 等)在 ROM,主程序链接时 PROVIDE 指向 ROM 地址
  • 启动汇编调用的早期函数(如 memcpy 拷 .data)若在 ROM,要先确认 ROM 可用

5.3 启动早期不能用的函数

启动汇编执行时,有些前提没建立,不能用:

  • 不能调 printf(stdio 没初始化)
  • 不能调 malloc(堆没初始化)
  • 不能调依赖 .data 的函数(.data 还没拷)
  • 不能调依赖全局对象的函数(C++ 构造没跑)

早期只能用:纯计算函数、ROM 固化的无状态函数、直接寄存器操作。


六、启动死机调试

启动阶段死机最难调(没有 printf)。方法:

6.1 串口/JTAG 早期输出

  • 用 UART 寄存器直接输出字符(不用 printf,绕过 stdio)
  • 在启动汇编每步后输出标记,定位卡在哪步
asm 复制代码
_start:
    la sp, _stack_top
    uart_putc('1')        # sp 设完
    la gp, __global_pointer$
    uart_putc('2')        # gp 设完
    # ...

6.2 JTAG 单步

  • JTAG 调试器能在启动早期挂上(若芯片支持)
  • 单步执行启动汇编,看哪步崩

6.3 常见启动死机

症状 可能原因
启动即崩,无输出 sp 没设 / 复位向量错
访问全局变量崩 gp 没设
全局变量值错 .bss 没清零
写全局变量崩 .data 没拷贝(写 Flash)
开中断后崩 mtvec 没配
随机崩 链接脚本段地址错 / 栈溢出

七、FreeRTOS 移植的启动钩子

FreeRTOS RISC-V port 在启动流程里挂钩:

7.1 FreeRTOS 需要的启动前提

  • sp/gp/mtvec 已配(标准启动汇编)
  • 定时器(CLINT mtimecmp/外设timer)可配
  • 至少一个中断(MTI)使能

7.2 FreeRTOS 启动序列

c 复制代码
// main 里
int main(void) {
    hardware_init();          // 你的外设初始化
    vPortDefineHeapRegions(); // FreeRTOS 堆
    xTaskCreate(...);         // 建任务
    vTaskStartScheduler();    // 启动调度器
    while(1);                 // 不应到这
}

vTaskStartScheduler() 内部:

  1. 配定时器中断(vPortSetupTimerInterrupt 配 mtimecmp)
  2. 开全局中断(MIE)
  3. 切到第一个任务(用 mret 或类似机制切栈)

7.3 启动汇编里别开中断

  • 启动汇编不开 MIE,等 FreeRTOS 调度器启动时再开
  • 提前开中断,若 mtvec/handler 没就绪会崩

port 层就是连接"RISC-V 启动/trap/中断"和"FreeRTOS 调度"的胶水:① 配 mtvec 指向 FreeRTOS 的 trap entry;② trap entry 用 mscratch 切栈(④专题);③ tick 用 MTI;④ 任务切换在 trap 里完成。本专题 ③④⑤⑦ 合起来,就是 FreeRTOS port 的全部基础。


八、ARM vs RISC-V 启动对照

ARM Cortex-M RISC-V
复位向量 向量表第1项(初始 SP)+ 第2项(reset) BootROM 跳 _start(或向量表)
初始 SP 硬件自动从向量表加载 软件在 _start 里 la sp
gp 无 必须设(la gp, __global_pointer$)
.data 拷贝 启动代码做 启动代码做(一样)
.bss 清零 启动代码做 启动代码做(一样)
trap 入口 VTOR 配 mtvec 配
多核 Cortex-M 多核少 mhartid 判断主从

九、本篇小结

  • 启动链路:BootROM → Bootloader → App _start → main
  • 启动汇编必做 5 件事:sp / gp / .data拷贝 / .bss清零 / mtvec
  • gp 必须设且防 relaxation(.option norelax),RISC-V 特有易漏点
  • 链接脚本的 LMA/VMA 概念:.data 在 Flash(LMA)运行在 RAM(VMA)
  • 多核启动:mhartid 判断主从,从核 park,主核唤醒
  • ROM 固化三硬条件:固定地址/不写自身段/状态在 SRAM
  • 启动早期不能用 printf/malloc/依赖 .data 的函数
  • FreeRTOS port 挂在启动流程上:mtvec/tick/MIE

速查表

启动步骤 代码
设 sp la sp, _stack_top
设 gp .option norelax; la gp, __global_pointer$
拷 .data 从 _sidata 拷到 _sdata~_edata
清 .bss _sbss~_ebss 清 0
配 trap csrw mtvec, &trap_vector
主核判断 csrr t0, mhartid; bnez t0, park
跳 main call main
.data LMA _sidata = LOADADDR(.data)
gp 范围 __global_pointer$ = .sdata中点,±2KB

💡技术之路漫漫,分享是为了更好地交流。如果本文的内容对你有启发,希望能得到你的 点赞 👍 和 收藏 ⭐。

如果你在调试过程中遇到了其他问题,欢迎在 评论区 💬 留言,我们一起探讨。也欢迎 关注 👀 我,一起交流底层开发的那些事儿。


相关推荐
硅基手札18 小时前
【risc-v专栏 08】扩展生态与 -march/-mabi 匹配
risc-v
硅基手札1 天前
【risc-v专栏 06b】内存架构深挖:PMA / ePMP / Cache / CMO / MMU 细节 / RVWMO 形式化
架构·risc-v
硅基手札1 天前
【risc-v专栏03】CSR 速查与特权架构 M/S/U
单片机·架构·risc-v
IvorySQL1 天前
去 IOE 的最后一公里:IvorySQL 5.4 × RISC-V 实测
数据库·人工智能·ai·postgresql·risc-v
RISCV_Explorer1 天前
技术指南:从总线协议到软件可见行为——RISC-V 多核缓存一致性的 Linux 实践
linux·缓存·risc-v
RISCV_Explorer8 天前
RISC-V内存属性配置深度专题——Svpbmt页表位、PMA优先级与设备访问策略
risc-v
RISCV_Explorer9 天前
RISC-V处理器性能优化:从指令集到微架构的协同设计
后端·risc-v
RISCV_Explorer12 天前
RISC-V启动与运行时规范机制解析——BRS约定、HSM多核启动与设备树要求
后端·risc-v
@嵌入式扫地僧18 天前
国产 RISC-V 机器人关节 MCU 量产落地解析:硬件 EtherCAT 支持与进口替代完整指南
机器人·risc-v·ethercat·国产化替代·机器人关节mcu