4. RISC-V 汇编基础:寄存器组、指令格式与常用指令

好,咱们今天聊聊 RISC-V 汇编。说实话,很多同学一听到“汇编”两个字就头疼,觉得那是上古时代的东西。但我得说,搞嵌入式系统,尤其是 RISC-V 内核,汇编是绕不开的坎。你想想看,C 代码编译完是什么?就是汇编。调试器里单步执行看的是什么?还是汇编。所以,咱们把这层窗户纸捅破,后面就顺了。

4.1 寄存器组:你的“手边工具”

RISC-V 的寄存器,说白了就是 CPU 内部最快的那几个存储单元。我习惯把它们想象成工程师桌面上的工具——常用的螺丝刀、钳子、万用表,随手就能拿到。

4.1.1 通用寄存器(GPR)

RISC-V 有 32 个通用寄存器,编号从 x0 到 x31。每个都是 32 位宽(RV32)或 64 位宽(RV64)。这里有个特别的设计:x0 永远返回 0。你往 x0 写数据?没用,它还是 0。我在项目中就见过新手往 x0 里存中间结果,结果 debug 了半天——嗯,这个坑我踩过。

寄存器ABI 名称用途说明
x0zero硬连线为 0,常用于清零或占位
x1ra返回地址,调用子程序时自动保存
x2sp栈指针,指向当前栈顶
x3gp全局指针,用于访问全局变量
x4tp线程指针,多线程环境用
x5-x7t0-t2临时寄存器,调用者保存
x8s0/fp保存寄存器,也可作帧指针
x9s1保存寄存器
x10-x17a0-a7函数参数/返回值寄存器
x18-x27s2-s11保存寄存器,被调用者保存
x28-x31t3-t6临时寄存器
我的习惯:写汇编时,先把 a0-a7 当作参数传递通道,s 系列寄存器存需要保留的变量,t 系列随便用。这样代码可读性高,不容易乱。

4.1.2 控制状态寄存器(CSR)

CSR 是 RISC-V 的“控制面板”。它们不参与普通运算,但控制着 CPU 的工作模式、中断、异常等核心行为。常用的有:

  • mstatus:机器状态寄存器,控制全局中断使能、特权级等
  • mtvec:机器异常入口基址,告诉 CPU 出问题时跳到哪里
  • mepc:异常返回地址,异常处理完后回到哪条指令
  • mcause:异常原因,告诉你为什么进了异常

访问 CSR 用 csrrcsrw 指令。我曾经在调试一个中断响应慢的问题时,发现是 mtvec 没对齐——嗯,RISC-V 要求 mtvec 按 4 字节对齐,否则硬件会忽略低两位。这种细节,手册上写得很清楚,但你不踩一次坑,真记不住。

4.2 指令格式:六种“骨架”

RISC-V 的指令格式非常规整。所有指令都是 32 位长,分成 6 种类型。我画了个图,你一看就明白:

RISC-V 六种指令格式结构图 R型 funct7 rs2 rs1 funct3 rd opcode I型 imm[11:0] rs1 funct3 rd opcode S型 imm[11:5] rs2 rs1 funct3 imm[4:0] opcode B型 imm[12|10:5] rs2 rs1 funct3 imm[4:1|11] opcode U型 imm[31:12] rd opcode J型 imm[20|10:1|11|19:12] rd opcode 图例: 立即数高位 源寄存器 源寄存器1 功能码 目标寄存器 操作码 注:所有指令均为32位固定长度,opcode位于最低7位

每种格式的字段位置是固定的,这给硬件解码带来了极大便利。我当年第一次看到这个设计时,心里就一个想法:这才叫优雅。

4.3 常用指令:干活用的“家伙什”

4.3.1 算术与逻辑指令

这些指令在 R 型和 I 型中都有。R 型操作两个寄存器,I 型操作一个寄存器加立即数。

# 算术运算
add x5, x6, x7      # x5 = x6 + x7
sub x5, x6, x7      # x5 = x6 - x7
addi x5, x6, 100    # x5 = x6 + 100

# 逻辑运算
and x5, x6, x7      # x5 = x6 & x7
or  x5, x6, x7      # x5 = x6 | x7
xor x5, x6, x7      # x5 = x6 ^ x7
andi x5, x6, 0xFF   # x5 = x6 & 0xFF

# 移位
slli x5, x6, 3      # x5 = x6 << 3
srli x5, x6, 2      # x5 = x6 >> 2 (逻辑右移)
srai x5, x6, 2      # x5 = x6 >> 2 (算术右移)
避坑指南:我曾经在写一个位操作算法时,用了 srai 做逻辑右移,结果符号位被扩展了,整个计算结果全错。后来养成了习惯:无符号数用 srli,有符号数用 srai。

4.3.2 访存指令

RISC-V 的访存指令只有两种:加载(Load)和存储(Store)。Load 是 I 型,Store 是 S 型。

# 加载指令
lb  x5, 0(x6)      # 加载字节(有符号扩展)
lbu x5, 0(x6)      # 加载字节(无符号扩展)
lh  x5, 0(x6)      # 加载半字
lhu x5, 0(x6)      # 加载半字(无符号)
lw  x5, 0(x6)      # 加载字

# 存储指令
sb  x5, 0(x6)      # 存储字节
sh  x5, 0(x6)      # 存储半字
sw  x5, 0(x6)      # 存储字

注意:RISC-V 不支持“一次加载多个寄存器”的指令(比如 ARM 的 LDM)。这是为了简化硬件设计。我刚开始从 ARM 转过来时很不习惯,但用久了发现,少就是多。

4.3.3 分支与跳转指令

分支指令是 B 型,跳转指令是 J 型。分支是条件跳转,跳转是无条件跳转。

# 条件分支(B型)
beq  x5, x6, label   # if x5 == x6, 跳转到 label
bne  x5, x6, label   # if x5 != x6, 跳转
blt  x5, x6, label   # if x5 < x6 (有符号), 跳转
bge  x5, x6, label   # if x5 >= x6 (有符号), 跳转
bltu x5, x6, label   # if x5 < x6 (无符号), 跳转
bgeu x5, x6, label   # if x5 >= x6 (无符号), 跳转

# 无条件跳转(J型)
jal  x1, func        # 跳转到 func, 返回地址存 x1
jalr x0, x1, 0       # 从函数返回(跳转到 x1 指向的地址)
注意:分支指令的偏移量是相对于当前 PC 的,而且是按字节计算的。但 RISC-V 要求指令地址必须是 2 字节对齐(压缩指令)或 4 字节对齐(标准指令)。所以分支偏移量最低位总是 0,编码时被省略了。你写汇编时不用管这个,汇编器会帮你算。

4.4 汇编程序结构:从“裸奔”到“穿衣”

一个完整的 RISC-V 汇编程序,通常包含以下几个部分:

.section .text          # 代码段
.globl main             # 声明 main 为全局符号

main:
    # 函数序言:保存返回地址和栈帧
    addi sp, sp, -16    # 分配栈空间
    sw   ra, 12(sp)     # 保存返回地址
    
    # 你的代码写在这里
    li   a0, 42         # 加载立即数 42 到 a0
    jal  print_number   # 调用子函数
    
    # 函数尾声:恢复现场并返回
    lw   ra, 12(sp)     # 恢复返回地址
    addi sp, sp, 16     # 回收栈空间
    ret                 # 返回

.section .data          # 数据段
msg: .asciz "Hello"     # 定义一个字符串

.section .bss           # 未初始化数据段
.lcomm buffer, 256      # 分配 256 字节缓冲区

这个结构,说白了就是“三段式”:代码段放指令,数据段放初始化的全局变量,bss 段放未初始化的全局变量。我在项目中见过有人把所有东西都塞到 .text 段里,结果链接时报了一堆地址冲突——嗯,该分还是得分。

我的建议:刚开始写汇编时,先搭好这个骨架。函数入口先保存 ra,出口恢复 ra。这个习惯养成了,写再复杂的程序都不会跑飞。

好了,关于 RISC-V 汇编的基础,咱们就聊到这儿。寄存器、指令格式、常用指令、程序结构,这四个点串起来,就是汇编编程的“四梁八柱”。你把这些搞明白了,后面看启动代码、看中断处理,就会觉得——哦,原来就这么回事。