1. 课程导入

当你按下开发板的复位键,CPU 并不会立刻执行 main()。在 C 语言世界之前,还有一段由汇编书写的“开场白”:它设置栈指针、初始化数据段、配置中断向量、把控制权交给运行时环境。这段代码就是启动代码(startup code),而理解它的钥匙,正是 RISC-V 汇编。本课程的目标,是让你具备从复位向量一路读到 main() 的完整能力,并能独立编写、调试、裁剪一个可用的启动流程。

学习本课程后,你应当能够:读懂 RV32I/RV64I 指令与伪指令;理解 ABI 调用约定与寄存器职责;编写链接脚本并解释段布局;实现复位入口、栈初始化、.data/.bss 搬运与清零;配置 trap 向量并处理异常;最终把启动代码移植到 QEMU 与真实开发板上运行。

一、核心概念:三条主线与一个交汇点

RISC-V 启动工程可以拆成三条主线。第一条是指令与汇编:RV32I 基础整数指令、乘除与原子扩展、压缩指令,以及 la、li、call 等伪指令如何展开为真实指令。第二条是ABI 与运行时:sp、ra、a0-a7、t0-t6、s0-s11 的职责划分,调用者保存与被调用者保存寄存器的区别,栈帧如何随函数调用生长。第三条是链接与内存布局:链接脚本如何把 .text、.rodata、.data、.bss 放到不同地址,符号 _start、__bss_start、_end 如何成为启动代码的“路标”。

三条主线的交汇点,就是复位入口 _start。它必须回答四个问题:栈在哪里?数据从哪里来?中断向量装在哪?跳转到 C 代码前还要准备什么?把这四个问题答清楚,启动代码就写对了大半。

RISC-V 启动代码工程知识框架 ① 指令与汇编 RV32I / RV64I 指令 伪指令展开 · 寻址模式 反汇编与调试观察 ② ABI 与运行时 sp / ra / a0-a7 职责 调用者/被调用者保存 栈帧生长与对齐 ③ 链接与内存布局 链接脚本 · 段放置 _start / __bss_start LMA 与 VMA 差异 交汇点:复位入口 _start 栈 / 数据 / 向量 / 跳转 C 上电复位 PC ← 复位向量 设置栈指针 sp ← _stack_top 搬运与清零 .data / .bss 跳转 main call main 实战路线:QEMU 验证 → 链接脚本裁剪 → 开发板烧录 → 异常处理扩展 工具链:riscv64-unknown-elf-gcc · objdump · gdb · OpenOCD

二、方法流程:从源码到第一条指令

一个最小可运行的 RISC-V 裸机工程,通常按下面的顺序推进。理解这个顺序,比死记指令更重要。

  1. 确定内存映射:查阅芯片手册,明确 Flash 起始地址、RAM 起始地址与大小、外设寄存器区间。
  2. 编写链接脚本:用 MEMORY 与 SECTIONS 描述段放置,导出 _stack_top、__bss_start、_end 等符号。
  3. 编写启动汇编:在 .text.init 段定义 _start,完成栈设置、数据搬运、BSS 清零、向量配置。
  4. 编写 C 入口:提供 main() 与必要的运行时桩函数,避免依赖完整 libc。
  5. 编译链接:使用 -nostdlib -nostartfiles -T link.ld 生成 ELF,再用 objcopy 转成 bin/hex。
  6. 调试验证:在 QEMU 或硬件上单步执行,观察 PC、sp 与内存内容是否符合预期。
# 典型编译与反汇编命令
riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 \
  -nostdlib -nostartfiles -T link.ld \
  start.S main.c -o firmware.elf

riscv64-unknown-elf-objdump -d firmware.elf | head -40
riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin

# QEMU 运行与调试
qemu-system-riscv32 -nographic -machine virt -bios firmware.elf
qemu-system-riscv32 -s -S -nographic -machine virt -bios firmware.elf

三、案例场景:一个 20 行启动代码的解剖

下面这段代码是许多 RISC-V 裸机工程的起点。它短,但每一行都对应一个必须理解的概念。

    .section .text.init
    .globl _start
_start:
    la      sp, _stack_top      # 建立栈,指向 RAM 高端
    la      a0, __data_load     # .data 在 Flash 中的加载地址
    la      a1, __data_start    # .data 在 RAM 中的运行地址
    la      a2, __data_end
1:  bgeu    a1, a2, 2f
    lw      t0, 0(a0)
    sw      t0, 0(a1)
    addi    a0, a0, 4
    addi    a1, a1, 4
    j       1b
2:  la      a0, __bss_start
    la      a1, __bss_end
3:  bgeu    a0, a1, 4f
    sw      zero, 0(a0)
    addi    a0, a0, 4
    j       3b
4:  call    main
    j       .                   # main 返回后原地循环

这段代码体现了三个关键点:第一,la 是伪指令,实际会展开为 auipc 加 addi,用于加载任意 32 位地址;第二,.data 段的变量在 Flash 中有初值副本,必须搬到 RAM 才能被读写;第三,.bss 段不占 Flash 空间,但必须在 RAM 中清零,否则全局变量初值不确定。很多“程序跑飞”的问题,根源就在这两步没做对。

四、误区与复盘:新手最容易踩的坑

常见误区一:认为复位后 sp 已经有效。实际上复位时寄存器值未定义,必须先设置栈,否则任何函数调用都会写坏内存。误区二:把 .data 和 .bss 混为一谈,只清零不搬运,导致带初值的全局变量变成 0。误区三:链接脚本里把栈放在 .bss 之后却忘记对齐,sp 未按 16 字节对齐,触发某些指令异常。误区四:直接调用 libc 的 printf 却不提供 _write 等桩函数,链接阶段报未定义符号。

复盘方法很简单:用 objdump -h 查看各段地址与大小,用 nm 查看符号地址,再在调试器里单步执行 _start,逐条核对 sp、a0、a1 的值。只要这三样对得上,启动代码基本就是可靠的。

阶段关键动作观察对象常见故障
复位PC 指向复位向量PC 初值向量地址配错
建栈sp ← _stack_topsp 对齐与范围栈越界覆盖数据
搬数据.data 从 LMA 到 VMAa0/a1/a2 循环初值丢失
清 BSS.bss 写零__bss_start/_end全局变量随机值
进 Ccall mainra 与栈帧未对齐导致异常
建议的学习节奏:先用 QEMU 跑通最小工程,确保能单步看到 _start 的每条指令;再修改链接脚本,把 RAM 起始地址换一个值,观察程序是否仍然正常;最后加入 trap 处理,让非法指令触发一个可见的打印。这三步做完,你对启动代码的理解就从“能背”变成“能改”。