05. 算术与逻辑运算
算术与逻辑运算是 CPU 存在的根本目的——加法器和逻辑门组合出一切计算。RISC-V 的 R-type 指令集将寄存器-寄存器运算浓缩为 10 条核心指令(加上立即数变体共约 20 条),足够覆盖高级语言中所有整数运算。本章逐条剖析这些指令的语义、编码和实战模式。
R-type 指令格式回顾
R-type(Register type)是六种基本格式中最规整的一种,也是理解其他格式的起点。
位域布局(bit 31 到 bit 0):
┌─────────┬───────┬───────┬─────┬───────┬─────────┐
│ funct7 │ rs2 │ rs1 │funct3│ rd │ opcode │
└────7────┴───5───┴───5───┴──3───┴───5───┴────7────┘- opcode[6:0] =
0110011,标识"整数寄存器-寄存器操作"。译码器看到这个 opcode 立刻知道这是 R-type - rd[11:7]:目标寄存器编号(Destination Register)
- rs1[19:15]:第一源操作数寄存器
- rs2[24:20]:第二源操作数寄存器
- funct3[14:12]:3 位子操作码,决定运算类别(加/减、移位、比较、逻辑)
- funct7[31:25]:7 位子操作码,在同一 funct3 类别内进一步区分变体
funct3 + funct7 完整编码表:
| 指令 | opcode | funct3 | funct7 | 操作描述 |
|---|---|---|---|---|
add | 0110011 | 000 | 0000000 | rd = rs1 + rs2 |
sub | 0110011 | 000 | 0100000 | rd = rs1 - rs2 |
sll | 0110011 | 001 | 0000000 | rd = rs1 << rs2(逻辑左移) |
slt | 0110011 | 010 | 0000000 | rd = (rs1 < rs2) ? 1 : 0(有符号) |
sltu | 0110011 | 011 | 0000000 | rd = (rs1 < rs2) ? 1 : 0(无符号) |
xor | 0110011 | 100 | 0000000 | rd = rs1 ^ rs2 |
srl | 0110011 | 101 | 0000000 | rd = rs1 >> rs2(逻辑右移,补 0) |
sra | 0110011 | 101 | 0100000 | rd = rs1 >> rs2(算术右移,补符号位) |
or | 0110011 | 110 | 0000000 | rd = rs1 | rs2 |
and | 0110011 | 111 | 0000000 | rd = rs1 & rs2 |
注意 add/sub 共享 funct3=000,靠 funct7 的最高位(bit 30)区分:0 为 add,1 为 sub。同样 srl/sra 共享 funct3=101,funct7 bit 30 区分。这种设计保留了 funct3 编码空间——RISC-V 规范故意把同类操作放进同一 funct3,给未来扩展留余地。
为什么没有 mul/div? 乘除法在 RISC-V 中属于 M 扩展(可选),不在基础整数指令集 RV64I 中。RV64I 的设计哲学是"基础集极致精简",乘法电路占用大量硅面积(32 位乘法器需要约 2000 个逻辑门 vs 加法器的 100 个),很多嵌入式 MCU 不需要硬件乘除。
加法与减法
add / sub 的基本形式
add rd, rs1, rs2 # rd = rs1 + rs2,结果截断为 64 位(溢出位丢弃)
sub rd, rs1, rs2 # rd = rs1 - rs2RISC-V 不加区分有/无符号的加法和减法——只有 add 和 sub,没有 addu/subu。这是补码的威力:同一套位模式、同一套加法器电路,无论你把它当作无符号数还是有符号数,结果位模式完全一致。溢出的检测交给分支指令在软件层面完成。
# 有符号和无符号,用同一条 add
li t0, 0xFFFFFFFFFFFFFFFF # 无符号 = 2^64 - 1,有符号 = -1
li t1, 1
add t2, t0, t1 # t2 = 0(进位 1 丢弃),无论怎么解释都对x0 参与算术
add t0, t1, x0 # t0 = t1 + 0 = t1 —— 寄存器复制(本质上就是伪指令 mv)
add t0, x0, x0 # t0 = 0 + 0 = 0 —— 寄存器清零(addi t0, x0, 0 也可以)
sub t0, x0, t1 # t0 = 0 - t1 —— 取负(本质上就是伪指令 neg)RISC-V 用 x0 消灭了三条本来需要专用指令的操作:复制(mv)、清零、取负(neg)。第 9 章会展示,这些"伪指令"由汇编器透明展开为上述 add/sub 形式。
多精度算术:128 位加法
RV64I 的寄存器宽度是 64 位,但大整数(128 位、256 位乃至更大)可以通过软件组合实现。关键是用 sltu(无符号比较)捕获进位:
# 128 位加法:result[127:0] = a[127:0] + b[127:0]
# 约定:a 的低 64 位在 a0,高 64 位在 a1;b 在 a2/a3;结果低 64 位在 a4,高 64 位在 a5
# --- 低 64 位相加 ---
add a4, a0, a2 # a4 = a0 + a2(低64位直接加)
# --- 检测进位 ---
sltu t0, a4, a2 # t0 = 1 if a4 < a2(即发生进位),否则 0
# sltu 判断的逻辑:如果 a0 + a2 溢出 64 位,则结果必然小于任一加数
# 思路相同:也可以判断 a4 < a0
# --- 高 64 位相加(含进位)---
add a5, a1, a3 # a5 = a1 + a3
add a5, a5, t0 # a5 = a5 + carry进位检测的等价写法:sltu t0, a4, a0 和 sltu t0, a4, a2 结果相同——只要溢出,和就小于任一加数。
扩展到 256 位甚至 1024 位只需重复这个模式——每一轮的进位传给下一轮。这就是为什么 RV64I 不需要硬件 128 位加法器:软件组合足以覆盖任意精度需求,只是体现实速度差异。
逻辑运算
RISC-V 提供完整的按位逻辑指令:and、or、xor。每条都是 R-type,funct7=0000000。
and t0, t1, t2 # t0 = t1 & t2(按位与)
or t0, t1, t2 # t0 = t1 | t2(按位或)
xor t0, t1, t2 # t0 = t1 ^ t2(按位异或)实用模式:提取低 8 位(掩码)
li t0, 0x12345678DEADBEEF
li t1, 0xFF # 掩码:低 8 位全 1
and t2, t0, t1 # t2 = 0xEF(提取低字节)掩码技术是汇编中最常见的操作模式之一。任何"只看某些位"的需求都转化为 and + 掩码。
实用模式:判断奇偶
andi t0, t1, 1 # t0 = t1 & 1。结果为 1 → 奇数,0 → 偶数
beqz t0, is_even # 跳转到偶数分支编译器对 x % 2 的优化就是 andi + beqz——除法的代价远高于位运算。
实用模式:xor 清零自身
xor t0, t0, t0 # t0 = t0 ^ t0 = 0相比于 add t0, x0, x0,xor 自异或清零更直接表达了"清零"意图——汇编器通常将 li t0, 0 也翻译为 addi t0, x0, 0,但手工编写时 xor 自异或是经典惯用法。注意在 x86 中 xor eax, eax 是推荐清零方式(比 mov eax, 0 更短、且不产生部分寄存器停顿),RISC-V 中 addi t0, x0, 0 同样 4 字节,无性能差异——选择哪种风格是个人偏好。
实用模式:ASCII 大小写转换
ASCII 编码的一个经典设计:大写字母和小写字母的二进制仅 bit 5 不同('A'=0x41=0100_0001, 'a'=0x61=0110_0001)。利用这一特性,大小写转换只需一条位运算:
# 小写 → 大写:清除 bit 5
li t0, 'a' # t0 = 0x61
andi t0, t0, 0x5F # t0 = 0x41 = 'A'(0x5F = 0101_1111,bit 5=0)
# 大写 → 小写:设置 bit 5
li t0, 'A' # t0 = 0x41
ori t0, t0, 0x20 # t0 = 0x61 = 'a'(0x20 = 0010_0000,bit 5=1)
# 翻转大小写:翻转 bit 5
li t0, 'a' # t0 = 0x61
xori t0, t0, 0x20 # t0 = 0x41 = 'A'
xori t0, t0, 0x20 # t0 = 0x61 = 'a'(再翻回来)这是位运算的直接体现——一条指令完成看似"高级"的字符转换,没有分支、没有查表。在实际的 ctype.h 的 tolower/toupper 实现中,编译器生成的就类似上述序列。
实用模式:位运算实现条件赋值
# if (flag) x = a else x = b
# 设 flag 在 t0(0 或非 0),a 在 t1,b 在 t2
sltu t0, x0, t0 # 先将 flag 归约为布尔值(0 或 1)
neg t0, t0 # t0 = flag ? 全 1 : 0
and t3, t1, t0 # t3 = flag ? a : 0
not t0, t0 # t0 = flag ? 0 : 全 1
and t4, t2, t0 # t4 = flag ? 0 : b
or t0, t3, t4 # t0 = flag ? a : b这种无分支的条件传送序列避免了分支预测失败带来的惩罚,在关键路径上常用。
比较与置位
RISC-V 的比较指令只有两条:slt 和 sltu。它们不设置标志位、不写入条件码——而是将比较结果写入目标寄存器(1 或 0)。这是 RISC-V 与 x86/ARM 最根本的设计分歧之一。
slt / sltu 的基本语义
slt t0, t1, t2 # t0 = (t1 < t2) ? 1 : 0 —— 有符号比较(补码视角)
sltu t0, t1, t2 # t0 = (t1 < t2) ? 1 : 0 —— 无符号比较注意 RISC-V 只有"小于"的比较指令,没有"大于"、"大于等于"、"小于等于"。要得到其他比较,只需交换操作数:
# "大于": t1 > t2 等价于 t2 < t1
slt t0, t2, t1 # t0 = (t2 < t1) = (t1 > t2)
# "大于等于": t1 >= t2 等价于 !(t1 < t2)
slt t0, t1, t2 # t0 = (t1 < t2)
xori t0, t0, 1 # t0 = !t0 = (t1 >= t2)
# "小于等于": t1 <= t2 等价于 !(t2 < t1)
slt t0, t2, t1 # t0 = (t2 < t1) = (t1 > t2)
xori t0, t0, 1 # t0 = !t0 = (t1 <= t2)用一条 slt + 一条 xori 就能拼出所有六种比较关系。不存在"缺失"——只是硬件不设冗余指令,软件自己组合。
比较 + 分支 = 条件判断
slt 和 sltu 单独存在时只是置 0/1,但它们和分支指令(beq/bne)的组合构成 RISC-V 的条件判断体系:
# if (a < b) goto label → slt + bnez
slt t0, a0, a1
bnez t0, label
# if (a >= b) goto label → slt + beqz
slt t0, a0, a1
beqz t0, label
# if (a <= b) goto label → slt(交换) + beqz
slt t0, a1, a0 # t0 = (b < a)
beqz t0, label # 等于取反:!(b < a) = a <= b有符号 vs 无符号的关键差异。同一个位模式,slt 和 sltu 可能给出截然相反的答案。这正是补码的微妙之处:
li t0, -1 # t0 = 0xFFFFFFFFFFFFFFFF(有符号 -1,无符号 2^64-1)
li t1, 1 # t1 = 1
slt t2, t0, t1 # t2 = 1(有符号视角:-1 < 1,成立)
sltu t3, t0, t1 # t3 = 0(无符号视角:2^64-1 < 1,不成立)选择 slt 还是 sltu 取决于你要比较的是有符号整数还是无符号整数。C 语言中 int → slt,unsigned int → sltu。选错指令是静默的 bug——编译器和汇编器都不会警告你。
比较结果的后续利用
slt 的结果 0/1 不只是给分支用。它可以作为乘法的布尔因子、数组索引、或进一步的算术输入:
# 计算两个数的最大值: max = (a < b) ? b : a
slt t0, a0, a1 # t0 = (a0 < a1) 的有符号比较结果
neg t0, t0 # t0 = 0 或 全 1(掩码)
and t1, a1, t0 # t1 = (t0 ? a1 : 0)
not t0, t0 # 掩码取反
and t2, a0, t0 # t2 = (t0 ? 0 : a0)
or t0, t1, t2 # t0 = max(a0, a1)x0 的妙用
x0(zero)是 RISC-V 设计中最精巧的硬件决策之一。它不是简单的"一个值为 0 的寄存器",而是多项指令天然的零成本替代品。回顾 RISC-V 规范:向 x0 写入的数据会被丢弃,从 x0 读取永远返回 0。
零源操作数
add t0, t1, x0 # t0 = t1 + 0 = t1 → 等效于 mv t0, t1
sub t0, x0, t1 # t0 = 0 - t1 = -t1 → 等效于 neg t0, t1
sltu t0, x0, t1 # t0 = (0 < t1) ? 1 : 0 → 判断 t1 是否非零结果丢弃(NOP)
add x0, t1, t2 # 计算 t1 + t2,结果丢弃 → 等效于 NOP(实际上是"无用"的计算,非真正的 NOP)
addi x0, x0, 0 # 真正的 NOP: x0 = 0 + 0,无副作用 → 汇编器将 nop 展开为此addi x0, x0, 0 是 RISC-V 规范定义的唯一 NOP。有些程序用 R-type 的增减来故意诱发 ALU 延迟(极罕见的时序需求),向 x0 写入是表达"执行此操作但丢弃结果"的方式。
无条件跳转
beq x0, x0, label # x0 == x0 永远成立 → 无条件跳转到 label这条指令从根本上消灭了"无条件跳转需要专用指令"的需求。虽然 jal x0, label 在编码上更常见(J-type 提供 ±1MB 范围,而 B-type 只有 ±4KB),但 beq x0, x0 在原理上同样是无条件跳转。
零值比较
beq t0, x0, label # if t0 == 0 goto label
bne t0, x0, label # if t0 != 0 goto label(等价于 beqz/bnez 伪指令)从寄存器与零比较是最常见的操作之一(循环结束判断、空指针检查、标志位测试)——x0 使得所有这些操作不需要立即数,一条指令完成。
本章要点
- R-type 是所有寄存器-寄存器运算的基础格式:opcode=0110011,funct3+funct7 区分具体操作
- 加法与减法不区分有/无符号(
add/sub各一条)——补码使同一套硬件、同一条指令对两种语义都正确 - 逻辑运算(and/or/xor)覆盖按位操作的全部需求:掩码提取、奇偶判断、大小写转换、无分支条件赋值
- RISC-V 只有
slt/sltu两条比较置位指令,无专用的大于/大于等于/小于等于——交换操作数或取反即可 - x0 硬连线恒为零,天然提供零值源操作数、结果丢弃(NOP)、无条件跳转、零比较——三条指令的活一个寄存器搞定