第3章:RISC-V基础指令集:RV32I基础整数指令、寄存器与调用约定、汇编语言基础

好,咱们直接进入正题。RV32I是RISC-V最基础的指令集,说白了就是所有RISC-V芯片的“地基”。不管你以后做AI加速还是嵌入式控制,这章的内容你都得吃透。我个人习惯把RV32I看作一套精简到极致的乐高积木——块数不多,但组合起来能搭出整个世界。

3.1 RV32I的寄存器体系

RISC-V的寄存器设计,我当年第一次看的时候就觉得“嗯,这很清爽”。一共32个通用寄存器,每个都是32位宽。没有那些花里胡哨的专用寄存器,干净利落。

寄存器名 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 临时寄存器

关键点:x0永远是0。这个设计太巧妙了——你想想看,很多指令需要“丢弃结果”或者“生成0”,直接用x0做目标寄存器就行,省了一条伪指令。

3.2 调用约定(Calling Convention)

调用约定这东西,说白了就是大家商量好的“规矩”。你写函数,我调函数,参数放哪、返回值放哪、谁负责保存寄存器,都得有个说法。RISC-V的调用约定我个人觉得比ARM的要直观不少。

3.2.1 参数传递规则

  • 前8个整数参数用a0-a7传递
  • 多余的参数压栈传递
  • 返回值放在a0(32位)或a0+a1(64位)

3.2.2 寄存器保存规则

这里有个容易踩坑的地方。临时寄存器(t0-t6)是调用者保存的,保存寄存器(s0-s11)是被调用者保存的。我在项目中遇到过好几次,新手写汇编时忘了保存s寄存器,结果函数返回后主调函数的数据全乱了。

避坑指南:我曾经在移植一个AI推理库时,发现某个矩阵乘法函数跑完,主循环的计数器莫名其妙变了。查了两天才发现——被调函数里用了s2但没保存恢复。从那以后,我写汇编第一件事就是检查寄存器保存。

3.3 RV32I核心指令集

RV32I一共就40多条指令,但覆盖了所有基本操作。我按功能分类给你捋一遍。

3.3.1 算术运算指令

# 加法
add rd, rs1, rs2      # rd = rs1 + rs2
addi rd, rs1, imm12   # rd = rs1 + 立即数

# 减法
sub rd, rs1, rs2      # rd = rs1 - rs2

# 逻辑运算
and rd, rs1, rs2      # 按位与
or  rd, rs1, rs2      # 按位或
xor rd, rs1, rs2      # 按位异或

# 移位
sll rd, rs1, rs2      # 逻辑左移
srl rd, rs1, rs2      # 逻辑右移
sra rd, rs1, rs2      # 算术右移

小技巧:addi的立即数是12位有符号数,范围-2048到2047。如果你要加载更大的常数,得用lui+addi组合。我习惯把常用常数预先算好,省得每次手算偏移量。

3.3.2 内存访问指令

RISC-V的内存访问只有load和store两种,没有ARM那种复杂的寻址模式。简洁是简洁了,但写起来得多写几行。

# 加载
lw rd, offset(rs1)    # 加载字(32位)
lh rd, offset(rs1)    # 加载半字(16位)
lb rd, offset(rs1)    # 加载字节(8位)

# 存储
sw rs2, offset(rs1)   # 存储字
sh rs2, offset(rs1)   # 存储半字
sb rs2, offset(rs1)   # 存储字节

你想想看,为什么没有“store immediate”指令?因为立即数可以直接放到寄存器里再存嘛。RISC-V的设计哲学就是“少即是多”。

3.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  # 寄存器间接跳转

注意:分支指令的偏移量是12位有符号数,以2字节为单位。这意味着分支范围大约±4KB。如果你的代码段很大,可能需要用jal+跳板的方式。我在做AI模型部署时就遇到过这种情况——模型权重初始化代码太长,分支跳不出去了。

3.4 汇编语言基础

写RISC-V汇编,我个人觉得比x86舒服多了。指令格式统一,伪指令丰富,可读性强。

3.4.1 基本语法结构

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

main:
    addi sp, sp, -16    # 分配栈空间
    sw   ra, 12(sp)     # 保存返回地址
    
    li   a0, 42         # 加载立即数
    jal  print_int      # 调用函数
    
    lw   ra, 12(sp)     # 恢复返回地址
    addi sp, sp, 16     # 释放栈空间
    ret                 # 返回

3.4.2 常用伪指令

伪指令是汇编器提供的“语法糖”,实际会被翻译成一条或多条真实指令。嗯,这里要注意,伪指令虽然方便,但也要知道它背后干了什么。

伪指令 等价指令 说明
li rd, imm lui + addi 加载任意32位立即数
mv rd, rs addi rd, rs, 0 寄存器复制
not rd, rs xori rd, rs, -1 按位取反
neg rd, rs sub rd, x0, rs 取负数
nop addi x0, x0, 0 空操作
ret jalr x0, ra, 0 函数返回

3.4.3 一个完整的例子

咱们写个简单的数组求和函数,把前面学的都用上。

# int sum_array(int *arr, int n)
# a0 = arr, a1 = n
# 返回值在 a0

.section .text
.globl sum_array

sum_array:
    li   t0, 0          # sum = 0
    li   t1, 0          # i = 0
    
.loop:
    bge  t1, a1, .done  # if i >= n, done
    slli t2, t1, 2      # t2 = i * 4 (字节偏移)
    add  t2, a0, t2     # t2 = &arr[i]
    lw   t3, 0(t2)      # t3 = arr[i]
    add  t0, t0, t3     # sum += arr[i]
    addi t1, t1, 1      # i++
    j    .loop
    
.done:
    mv   a0, t0         # 返回值 = sum
    ret

经验之谈:写这种循环时,我习惯先把循环不变量(比如数组基址)放到临时寄存器里,避免每次循环都重新计算。在AI模型部署中,这种优化能省下不少指令周期。

3.5 常见陷阱与调试技巧

最后分享几个我实际踩过的坑。

  1. 栈对齐问题:RISC-V要求栈指针16字节对齐。我刚开始移植代码时,忘了对齐,结果某些浮点指令直接崩溃。
  2. 符号扩展陷阱:lb和lh指令是符号扩展的,如果你要无符号加载,得用lbu和lhu。这个在处理像素数据时特别容易出错。
  3. 分支延迟槽:RISC-V没有分支延迟槽!如果你是从MIPS转过来的,这个习惯一定要改。

终极避坑:我曾经在调试一个AI推理引擎时,发现某个函数在-O2优化下跑飞了。最后定位到是汇编代码里用了t0-t2,但编译器生成的代码也在用这些寄存器。记住:如果你在C代码中内联汇编,一定要用volatile和clobber list声明所有被修改的寄存器。

好了,RV32I的基础就这些。下一章咱们会讲RISC-V的乘除法扩展和原子操作,这些在AI模型部署中会频繁用到。先把基础打牢,后面才能飞起来。