第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 常见陷阱与调试技巧
最后分享几个我实际踩过的坑。
- 栈对齐问题:RISC-V要求栈指针16字节对齐。我刚开始移植代码时,忘了对齐,结果某些浮点指令直接崩溃。
- 符号扩展陷阱:lb和lh指令是符号扩展的,如果你要无符号加载,得用lbu和lhu。这个在处理像素数据时特别容易出错。
- 分支延迟槽:RISC-V没有分支延迟槽!如果你是从MIPS转过来的,这个习惯一定要改。
终极避坑:我曾经在调试一个AI推理引擎时,发现某个函数在-O2优化下跑飞了。最后定位到是汇编代码里用了t0-t2,但编译器生成的代码也在用这些寄存器。记住:如果你在C代码中内联汇编,一定要用volatile和clobber list声明所有被修改的寄存器。
好了,RV32I的基础就这些。下一章咱们会讲RISC-V的乘除法扩展和原子操作,这些在AI模型部署中会频繁用到。先把基础打牢,后面才能飞起来。