Skip to content
Published at:

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 完整编码表

指令opcodefunct3funct7操作描述
add01100110000000000rd = rs1 + rs2
sub01100110000100000rd = rs1 - rs2
sll01100110010000000rd = rs1 << rs2(逻辑左移)
slt01100110100000000rd = (rs1 < rs2) ? 1 : 0(有符号)
sltu01100110110000000rd = (rs1 < rs2) ? 1 : 0(无符号)
xor01100111000000000rd = rs1 ^ rs2
srl01100111010000000rd = rs1 >> rs2(逻辑右移,补 0)
sra01100111010100000rd = rs1 >> rs2(算术右移,补符号位)
or01100111100000000rd = rs1 | rs2
and01100111110000000rd = 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 的基本形式

asm
add rd, rs1, rs2    # rd = rs1 + rs2,结果截断为 64 位(溢出位丢弃)
sub rd, rs1, rs2    # rd = rs1 - rs2

RISC-V 不加区分有/无符号的加法和减法——只有 addsub,没有 addu/subu。这是补码的威力:同一套位模式、同一套加法器电路,无论你把它当作无符号数还是有符号数,结果位模式完全一致。溢出的检测交给分支指令在软件层面完成。

asm
# 有符号和无符号,用同一条 add
li  t0, 0xFFFFFFFFFFFFFFFF    # 无符号 = 2^64 - 1,有符号 = -1
li  t1, 1
add t2, t0, t1                # t2 = 0(进位 1 丢弃),无论怎么解释都对

x0 参与算术

asm
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(无符号比较)捕获进位:

asm
# 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, a0sltu t0, a4, a2 结果相同——只要溢出,和就小于任一加数。

扩展到 256 位甚至 1024 位只需重复这个模式——每一轮的进位传给下一轮。这就是为什么 RV64I 不需要硬件 128 位加法器:软件组合足以覆盖任意精度需求,只是体现实速度差异。

逻辑运算

RISC-V 提供完整的按位逻辑指令:andorxor。每条都是 R-type,funct7=0000000。

asm
and  t0, t1, t2    # t0 = t1 & t2(按位与)
or   t0, t1, t2    # t0 = t1 | t2(按位或)
xor  t0, t1, t2    # t0 = t1 ^ t2(按位异或)

实用模式:提取低 8 位(掩码)

asm
li   t0, 0x12345678DEADBEEF
li   t1, 0xFF                 # 掩码:低 8 位全 1
and  t2, t0, t1               # t2 = 0xEF(提取低字节)

掩码技术是汇编中最常见的操作模式之一。任何"只看某些位"的需求都转化为 and + 掩码。

实用模式:判断奇偶

asm
andi t0, t1, 1        # t0 = t1 & 1。结果为 1 → 奇数,0 → 偶数
beqz t0, is_even       # 跳转到偶数分支

编译器对 x % 2 的优化就是 andi + beqz——除法的代价远高于位运算。

实用模式:xor 清零自身

asm
xor t0, t0, t0    # t0 = t0 ^ t0 = 0

相比于 add t0, x0, x0xor 自异或清零更直接表达了"清零"意图——汇编器通常将 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)。利用这一特性,大小写转换只需一条位运算:

asm
# 小写 → 大写:清除 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.htolower/toupper 实现中,编译器生成的就类似上述序列。

实用模式:位运算实现条件赋值

asm
# 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 的比较指令只有两条:sltsltu。它们不设置标志位、不写入条件码——而是将比较结果写入目标寄存器(1 或 0)。这是 RISC-V 与 x86/ARM 最根本的设计分歧之一。

slt / sltu 的基本语义

asm
slt  t0, t1, t2    # t0 = (t1 < t2) ? 1 : 0  —— 有符号比较(补码视角)
sltu t0, t1, t2    # t0 = (t1 < t2) ? 1 : 0  —— 无符号比较

注意 RISC-V 只有"小于"的比较指令,没有"大于"、"大于等于"、"小于等于"。要得到其他比较,只需交换操作数:

asm
# "大于": 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 就能拼出所有六种比较关系。不存在"缺失"——只是硬件不设冗余指令,软件自己组合。

比较 + 分支 = 条件判断

sltsltu 单独存在时只是置 0/1,但它们和分支指令(beq/bne)的组合构成 RISC-V 的条件判断体系:

asm
# 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 无符号的关键差异。同一个位模式,sltsltu 可能给出截然相反的答案。这正是补码的微妙之处:

asm
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 语言中 intsltunsigned intsltu。选错指令是静默的 bug——编译器和汇编器都不会警告你。

比较结果的后续利用

slt 的结果 0/1 不只是给分支用。它可以作为乘法的布尔因子、数组索引、或进一步的算术输入:

asm
# 计算两个数的最大值: 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

零源操作数

asm
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)

asm
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 写入是表达"执行此操作但丢弃结果"的方式。

无条件跳转

asm
beq x0, x0, label    # x0 == x0 永远成立 → 无条件跳转到 label

这条指令从根本上消灭了"无条件跳转需要专用指令"的需求。虽然 jal x0, label 在编码上更常见(J-type 提供 ±1MB 范围,而 B-type 只有 ±4KB),但 beq x0, x0 在原理上同样是无条件跳转。

零值比较

asm
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)、无条件跳转、零比较——三条指令的活一个寄存器搞定