1. 课程导入
当你按下开发板的复位键,CPU 并不会立刻执行 main()。在 C 语言世界之前,还有一段由汇编书写的“开场白”:它设置栈指针、初始化数据段、配置中断向量、把控制权交给运行时环境。这段代码就是启动代码(startup code),而理解它的钥匙,正是 RISC-V 汇编。本课程的目标,是让你具备从复位向量一路读到 main() 的完整能力,并能独立编写、调试、裁剪一个可用的启动流程。
一、核心概念:三条主线与一个交汇点
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 裸机工程,通常按下面的顺序推进。理解这个顺序,比死记指令更重要。
- 确定内存映射:查阅芯片手册,明确 Flash 起始地址、RAM 起始地址与大小、外设寄存器区间。
- 编写链接脚本:用
MEMORY与SECTIONS描述段放置,导出_stack_top、__bss_start、_end等符号。 - 编写启动汇编:在
.text.init段定义_start,完成栈设置、数据搬运、BSS 清零、向量配置。 - 编写 C 入口:提供
main()与必要的运行时桩函数,避免依赖完整 libc。 - 编译链接:使用
-nostdlib -nostartfiles -T link.ld生成 ELF,再用objcopy转成 bin/hex。 - 调试验证:在 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 中清零,否则全局变量初值不确定。很多“程序跑飞”的问题,根源就在这两步没做对。
四、误区与复盘:新手最容易踩的坑
复盘方法很简单:用 objdump -h 查看各段地址与大小,用 nm 查看符号地址,再在调试器里单步执行 _start,逐条核对 sp、a0、a1 的值。只要这三样对得上,启动代码基本就是可靠的。
| 阶段 | 关键动作 | 观察对象 | 常见故障 |
|---|---|---|---|
| 复位 | PC 指向复位向量 | PC 初值 | 向量地址配错 |
| 建栈 | sp ← _stack_top | sp 对齐与范围 | 栈越界覆盖数据 |
| 搬数据 | .data 从 LMA 到 VMA | a0/a1/a2 循环 | 初值丢失 |
| 清 BSS | .bss 写零 | __bss_start/_end | 全局变量随机值 |
| 进 C | call main | ra 与栈帧 | 未对齐导致异常 |
_start 的每条指令;再修改链接脚本,把 RAM 起始地址换一个值,观察程序是否仍然正常;最后加入 trap 处理,让非法指令触发一个可见的打印。这三步做完,你对启动代码的理解就从“能背”变成“能改”。