4. 设备树编译与反编译
好,咱们进入正题。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 | 临时寄存器 | 调用者 |
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 位数据到 rdlw 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 位
lw 加载一个地址,结果发现高 32 位被符号扩展了。如果你只想取低 32 位,记得用 lwu(无符号加载)。这个 bug 我查了整整一个下午。
4.3.2 算术运算:算数
add rd, rs1, rs2— rd = rs1 + rs2addi rd, rs1, imm— rd = rs1 + 立即数sub rd, rs1, rs2— rd = rs1 - rs2mul rd, rs1, rs2— rd = rs1 * rs2(低 64 位)div rd, rs1, rs2— rd = rs1 / rs2rem 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
怎么编译和运行?
- 用
riscv64-linux-gnu-as first.S -o first.o汇编 - 用
riscv64-linux-gnu-ld first.o -o first.elf链接 - 用
qemu-system-riscv64 -machine virt -kernel first.elf -nographic运行
注意:QEMU 的 -kernel 选项会直接把 ELF 文件加载到内存起始地址 0x80000000。所以你的 _start 必须放在 0x80000000 处。链接脚本里要指定 . = 0x80000000;,否则 QEMU 找不到入口。
4.5 知识结构图:RISC-V 汇编全景
下面这张图把本章的核心知识点串起来了。你一看就明白:寄存器是“手”,指令格式是“骨架”,指令是“动作”,程序是“剧本”。
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 是怎么一步步走的。