4. RISC-V 汇编基础:从寄存器到第一个程序

好,咱们进入正题。RISC-V 的汇编,说实话,是我见过最清爽的指令集之一。你想想看,一个指令集能精简到一页纸就能写完,这在当今的处理器世界里简直是一股清流。我当年从 ARM 转到 RISC-V 时,第一反应是:就这?但用久了你会发现,这种精简恰恰是它的力量所在。

4.1 RISC-V 寄存器组:你的 32 个临时工

RISC-V 有 32 个通用寄存器,编号从 x0 到 x31。每个寄存器都是 32 位宽(RV32)或 64 位宽(RV64)。咱们课程主要用 RV64,但寄存器数量是一样的。

这里有个特殊角色——x0。它永远返回 0,你写啥进去都没用。我在项目中见过新手往 x0 里写数据,然后 debug 半天找不到问题……嗯,x0 就是硬件的“零线”,别想着用它存东西。

寄存器 ABI 名称 用途 谁负责保存
x0 zero 硬连线为 0 —
x1 ra 返回地址 调用者
x2 sp 栈指针 被调用者
x3 gp 全局指针 —
x4 tp 线程指针 —
x5–x7 t0–t2 临时寄存器 调用者
x8 s0/fp 保存寄存器 / 帧指针 被调用者
x9 s1 保存寄存器 被调用者
x10–x17 a0–a7 函数参数 / 返回值 调用者
x18–x27 s2–s11 保存寄存器 被调用者
x28–x31 t3–t6 临时寄存器 调用者
我的习惯:写汇编时,我一般把 a0–a7 当“快递员”,用来传参;s0–s11 当“保险柜”,用来存需要保留的变量;t0–t6 当“草稿纸”,用完就扔,不用操心保存。

4.2 指令格式:RISC-V 的四种“骨架”

RISC-V 指令长度固定为 32 位(基础指令集)。所有指令分成四种格式:R 型、I 型、S 型、U 型。还有分支用的 B 型和跳转用的 J 型,但本质上是 S 型和 U 型的变体。

说白了,你只要记住:

  • R 型:两个源寄存器 + 一个目标寄存器。比如 add x1, x2, x3。
  • I 型:一个源寄存器 + 立即数 + 目标寄存器。比如 addi x1, x2, 100。
  • S 型:两个源寄存器 + 立即数(用于存储)。比如 sw x1, 0(x2)。
  • U 型:目标寄存器 + 20 位立即数。比如 lui x1, 0x12345。

核心要点:RISC-V 没有 ARM 那种“条件执行”后缀,也没有 x86 那种变长指令。每条指令就是 32 位,解码简单,硬件实现也简单。这也是为什么 RISC-V 能在低功耗场景下表现优异。

4.3 常用汇编指令:你只需要记住这 10 条

说实话,RISC-V 基础指令集只有 40 多条,但日常写固件,常用的不超过 15 条。我列个清单,你照着练就行。

4.3.1 Load / Store:搬数据

  • ld rd, offset(rs1) — 从内存加载 64 位数据到 rd
  • lw rd, offset(rs1) — 加载 32 位(符号扩展)
  • lbu rd, offset(rs1) — 加载 8 位(无符号扩展)
  • sd rs2, offset(rs1) — 将 rs2 的 64 位数据存到内存
  • sw rs2, offset(rs1) — 存 32 位
  • sb rs2, offset(rs1) — 存 8 位
我曾经踩过的坑:在 QEMU 里调试时,用 lw 加载一个地址,结果发现高 32 位被符号扩展了。如果你只想取低 32 位,记得用 lwu(无符号加载)。这个 bug 我查了整整一个下午。

4.3.2 算术运算:算数

  • add rd, rs1, rs2 — rd = rs1 + rs2
  • addi rd, rs1, imm — rd = rs1 + 立即数
  • sub rd, rs1, rs2 — rd = rs1 - rs2
  • mul rd, rs1, rs2 — rd = rs1 * rs2(低 64 位)
  • div rd, rs1, rs2 — rd = rs1 / rs2
  • rem rd, rs1, rs2 — rd = rs1 % rs2

4.3.3 分支与跳转:控制流

  • beq rs1, rs2, label — 相等则跳转
  • bne rs1, rs2, label — 不等则跳转
  • blt rs1, rs2, label — 有符号小于则跳转
  • bge rs1, rs2, label — 有符号大于等于则跳转
  • jal rd, label — 跳转并链接(存返回地址到 rd)
  • jalr rd, rs1, offset — 间接跳转并链接

小技巧:写循环时,我习惯用 ble 或 bge 做边界检查,用 beq 做相等判断。别用 bne 做循环退出条件——容易写出死循环。你想想看,条件写反了,CPU 就永远跳不出去了。

4.4 第一个汇编程序:点亮 QEMU 的“LED”

咱们在 QEMU 上跑一个最简单的程序:把 0x12345678 写入内存地址 0x10000000(QEMU virt 平台的 UART 基址),然后死循环。说白了,就是往串口写一个数,虽然你看不到输出,但 QEMU 的日志里能抓到。

# first.S — 我的第一个 RISC-V 汇编程序
.section .text
.globl _start

_start:
    # 把立即数 0x12345678 加载到 t0
    lui   t0, 0x12345       # t0 = 0x12345000
    addi  t0, t0, 0x678     # t0 = 0x12345678

    # 把地址 0x10000000 加载到 t1
    lui   t1, 0x10000       # t1 = 0x10000000

    # 将 t0 的值写入 t1 指向的内存
    sw    t0, 0(t1)

    # 死循环
loop:
    j     loop

怎么编译和运行?

  1. 用 riscv64-linux-gnu-as first.S -o first.o 汇编
  2. 用 riscv64-linux-gnu-ld first.o -o first.elf 链接
  3. 用 qemu-system-riscv64 -machine virt -kernel first.elf -nographic 运行

注意:QEMU 的 -kernel 选项会直接把 ELF 文件加载到内存起始地址 0x80000000。所以你的 _start 必须放在 0x80000000 处。链接脚本里要指定 . = 0x80000000;,否则 QEMU 找不到入口。

4.5 知识结构图:RISC-V 汇编全景

下面这张图把本章的核心知识点串起来了。你一看就明白:寄存器是“手”,指令格式是“骨架”,指令是“动作”,程序是“剧本”。

RISC-V 汇编知识结构图 RISC-V 汇编知识结构 寄存器组 x0–x31, 32 个通用寄存器 指令格式 R / I / S / U 四种骨架 常用指令 Load/Store/算术/分支 x0 (zero) 硬连线 0 x1–x31 通用用途 R 型 reg + reg I 型 reg + imm Load/Store ld, sd, lw, sw 算术 add, sub, mul 第一个汇编程序 _start → 加载数据 → 写入内存 → 死循环 💡 学习路径建议 先记住 32 个寄存器的 ABI 名称 → 理解四种指令格式 → 掌握 10 条常用指令 → 动手写第一个汇编程序并在 QEMU 上运行

4.6 避坑指南:我替你踩过的雷

  • 寄存器 x0 不能写:往 x0 写数据不会报错,但数据会消失。我见过有人用 addi x0, x0, 1 想实现 NOP,结果发现 x0 永远是 0。正确的 NOP 是 addi x0, x0, 0。
  • 立即数范围:I 型指令的立即数只有 12 位(有符号),范围 -2048 到 2047。想加载大常数?用 lui + addi 组合。
  • 对齐问题:ld 要求地址 8 字节对齐,lw 要求 4 字节对齐。不对齐会触发异常。QEMU 默认会报错,别问我怎么知道的。
  • 链接地址:QEMU virt 平台从 0x80000000 启动。你的代码必须链接到这个地址,否则 PC 会跑飞。

我的调试习惯:在 QEMU 里加 -d in_asm,cpu_reset 参数,能看到每条指令的执行和寄存器变化。第一次跑汇编程序时,我建议你打开这个开关,亲眼看看 CPU 是怎么一步步走的。