14. M 扩展:乘除法指令
第 5 章提到 RISC-V 基础整数指令集 RV64I 不包含乘除法——add/sub 可以解决一切算术的错觉来自补码,但它解决不了"两个 64 位数相乘得 128 位结果"这类需求。M 扩展(Integer Multiplication and Division)填补这个空白,提供硬件整数乘除。本章详述 M 扩展的所有指令、编码、边界行为和编译器优化。
M 扩展概述
为什么乘除是"扩展"而非"基础"
RISC-V 基础 ISA 刻意将乘除法排除在 RV32I/RV64I 之外,原因有三:
- 硬件成本:32 位乘法器需要约 2000 个逻辑门,除法器更多且慢——在面积敏感的低端 MCU 上这笔开销不值得
- 使用频率:大量嵌入式控制逻辑(传感器读取、GPIO 操作、状态机转移)根本不需要乘除
- 软件可替代:乘法可用循环移位加模拟(Booth 算法精神),除法可用试商减法模拟——慢但可用
因此,RV64I + M 是常见组合;没有 M 扩展时,编译器调用 __muldi3/__divdi3 等软浮点库函数。
编码空间
M 扩展指令复用基础整数 R-type 的 opcode 空间:
| 属性 | 值 |
|---|---|
| opcode | 0110011(与 add/sub 相同) |
| funct7 | 0000001(bit 30 = 0,区别于 sub/sra 的 funct7=0100000) |
funct3 区分 8 种操作(乘法 4 种 + 除法 4 种),funct7 统一为 0000001。这条设计线索说明:M 扩展的指令"看起来"是 R-type 整数操作的一个子类——解码器只需多检查一层 funct7 的第 0 位是否为 1。
指令总览
| 指令 | funct3 | 操作描述 |
|---|---|---|
mul | 000 | rd = (rs1 * rs2)[63:0],低 64 位 |
mulh | 001 | 有符号乘高 64 位 |
mulhsu | 010 | 有符号(rs1) × 无符号(rs2) 高 64 位 |
mulhu | 011 | 无符号乘高 64 位 |
div | 100 | 有符号除,向零取整 |
divu | 101 | 无符号除 |
rem | 110 | 有符号余数 |
remu | 111 | 无符号余数 |
RV64 额外提供 mulw/divw/divuw/remw/remuw 五条 32 位变体(opcode=0111011,funct7=0000001),操作数截取低 32 位,结果符号扩展到 64 位。
乘法指令
基本形式
mul rd, rs1, rs2 # rd = (rs1 * rs2)[63:0] 低 64 位,有符号与无符号结果相同
mulh rd, rs1, rs2 # rd = (rs1 * rs2)[127:64] 有符号 × 有符号的高 64 位
mulhu rd, rs1, rs2 # rd = (rs1 * rs2)[127:64] 无符号 × 无符号的高 64 位
mulhsu rd, rs1, rs2 # rd = (rs1 * rs2)[127:64] rs1 有符号,rs2 无符号这四条指令覆盖了任意 64 位乘法语义的所有组合。RISC-V 没有单独的 "有符号 128 位结果" 或 "无符号 128 位结果" 指令——而是通过组合 mul(低 64 位)+ mulh/mulhu/mulhsu(高 64 位)来覆盖。低 64 位乘法结果与符号无关(补码的算术性质保证),只需一条 mul。
128 位完整乘法
# 计算 a0 * a1,128 位结果存入 t1:t0(t0=低64,t1=高64)
# ---- 有符号版本 ----
mul t0, a0, a1 # 低 64 位(有符号、无符号结果相同)
mulh t1, a0, a1 # 高 64 位(有符号)
# ---- 无符号版本 ----
mul t0, a0, a1 # 低 64 位
mulhu t1, a0, a1 # 高 64 位(无符号)汇编器建议先写 mulh 再写 mul(先后顺序),因为某些实现可以对相邻的 mulh+mul 对做宏融合——在一条流水线中同时产出高、低 64 位。
32 位乘法(RV64M)
mulw rd, rs1, rs2 # rd = sign_ext((rs1[31:0] * rs2[31:0])[31:0])mulw 将 rs1 和 rs2 的低 32 位相乘,取结果的低 32 位,符号扩展到 64 位后写入 rd。编译器对 C 语言的 int32_t * int32_t 产生 mulw——语义正确且节省对高 32 位的无关计算。
常数乘法优化
编译器极少对字面量常数直接生成 mul——移位+加法的组合体效率远高于通用乘法器。以下是常见常数乘法的等价变换:
# t1 = t0 * 3
slli t1, t0, 1 # t1 = t0 * 2
add t1, t1, t0 # t1 = t0 * 2 + t0 = t0 * 3
# t1 = t0 * 5
slli t1, t0, 2 # t1 = t0 * 4
add t1, t1, t0 # t1 = t0 * 4 + t0 = t0 * 5
# t1 = t0 * 10
slli t1, t0, 3 # t1 = t0 * 8
slli t2, t0, 1 # t2 = t0 * 2
add t1, t1, t2 # t1 = t0 * 8 + t0 * 2 = t0 * 10
# t1 = t0 * 12
slli t1, t0, 3 # t1 = t0 * 8
slli t2, t0, 2 # t2 = t0 * 4
add t1, t1, t2 # t1 = t0 * 8 + t0 * 4 = t0 * 12RISC-V 没有像 x86 的 lea 那样能在一个周期内完成"移位+加法"的地址生成指令——上述序列需 2-3 条指令,但每条都是单周期 ALU 操作。当乘数是 2 的幂方、或可分解为少量 2 的幂之和时,移位加法优于 mul(无 M 扩展时更是唯一选择)。
RV32 中的 64 位乘法
RV32M 上计算 64 位乘法的需求催生了 mulh/mulhu/mulhsu 的原始设计动机——只有 32 位寄存器,却需要 64 位结果。以下是 RV32M 上实现 64 位无符号乘法的完整序列:
# RV32M: result[63:0] = a[31:0] * b[31:0]
# a_lo: a0, a_hi: a1; b_lo: a2, b_hi: a3
# result_lo: t0, result_hi: t1
mul t0, a0, a2 # t0 = a_lo * b_lo (低 32 位)
mulhu t1, a0, a2 # t1 = a_lo * b_lo (高 32 位)
mul t2, a1, a2 # t2 = a_hi * b_lo
add t1, t1, t2 # t1 += a_hi * b_lo
mul t2, a0, a3 # t2 = a_lo * b_hi
add t1, t1, t2 # t1 += a_lo * b_hi这段代码精确反映了小学乘法的竖式展开——四个部分积相加。在 RV64 中同样可用 mulw 系列实现 128 位乘法,原理一致。
除法指令
基本形式
div rd, rs1, rs2 # rd = rs1 / rs2 有符号除,向零取整
divu rd, rs1, rs2 # rd = rs1 / rs2 无符号除
rem rd, rs1, rs2 # rd = rs1 % rs2 有符号余数
remu rd, rs1, rs2 # rd = rs1 % rs2 无符号余数RISC-V 的除法遵循 C99 语义:商向零取整,余数满足 dividend = quotient * divisor + remainder,且余数的符号与被除数相同。
# 示例:有符号除法的商与余数
li t0, 10
li t1, 3
div t2, t0, t1 # t2 = 10 / 3 = 3(向零取整,不是 3.33)
rem t3, t0, t1 # t3 = 10 % 3 = 1(10 = 3 * 3 + 1)
li t0, -10
li t1, 3
div t2, t0, t1 # t2 = -10 / 3 = -3(向零取整,不是 -4)
rem t3, t0, t1 # t3 = -10 % 3 = -1(-10 = 3 * -3 + (-1),余数与被除数同号)除零行为——RISC-V 的特殊选择
RISC-V 在除零问题上的设计与其他架构截然不同:不抛异常,返回特定值。
# 除零的返回值
li t0, 42
# 除法除零
div t1, t0, x0 # t1 = -1(全 1 位模式,即 0xFFFFFFFFFFFFFFFF)
divu t1, t0, x0 # t1 = 2^64 - 1(MAX_UINT,同样是全 1)
# 余数除零
rem t1, t0, x0 # t1 = t0(原样返回被除数)
remu t1, t0, x0 # t1 = t0(原样返回被除数)这个设计选择的核心原因:
- 无异常开销:OS 无需处理除零陷阱,控制流不被打断——在嵌入式实时场景中至关重要
- 可预测性:除法永远不会因输入值而导致程序崩溃
- 软件检测:代码可以在除法后立即检查结果是否全 1,自行决定后续策略:
div t1, t0, t1 # 执行除法
not t2, t1 # t2 = ~t1
beqz t2, handle_divzero # 如果 t1 全 1(~t1 == 0),说明发生了除零溢出:唯一的情况
RISC-V 除法有一个且仅有一个溢出场景:-2^63 / -1。
li t0, -9223372036854775808 # -2^63(0x8000000000000000)
li t1, -1
div t2, t0, t1 # 溢出!正确结果 2^63 超出了 64 位有符号范围规范规定此场景下 div 返回被除数(即 -2^63),rem 返回 0。实际代码中此场景极罕见,但编写通用库代码时应考虑。
除以常数优化——强度削减
编译器从不直接对常数做除法——以下面的 x / 10 为例,编译器会生成类似这样的序列:
# 等效于 t0 = rs1 / 10(无符号)
# 核心思路:除以 10 = 乘以 (2^N / 10),再右移 N 位
# 编译器预先计算 magic number 和 shift amount
li t1, 0xCCCCCCCCCCCCCCCD # 预计算的魔法数(2^67 / 10 的近似)
mulhu t1, t0, t1 # 高 64 位乘法
srli t1, t1, 3 # t1 = t1 >> 3这个技术称为"除法的强度削减"——将昂贵的除法替换为廉价的乘法+移位。mulhu 将 128 位乘积的高 64 位捕获为"除以 2^64"的近似,再配合右移完成精确除法。有符号除法的强度削减更复杂(需要处理负数向零取整),但原理相同:一次 mulh/mulhu + 若干移位和加法替换一次 div。
32 位除法(RV64M)
divw rd, rs1, rs2 # 64 位结果 = sign_ext(32 位 rs1 / 32 位 rs2)
divuw rd, rs1, rs2 # 无符号版
remw rd, rs1, rs2 # 余数版
remuw rd, rs1, rs2 # 无符号余数版这些指令对应 C 语言中 int32_t / int32_t 的精确语义。
性能模型
典型延迟
| 指令 | 典型周期数 | 依赖因素 |
|---|---|---|
mul | 3-5 | 乘法器宽度、流水线级数 |
mulh/mulhu | 3-5 | 与 mul 相同(共享乘法器阵列) |
div/divu | 20-80 | 实现算法(SRT / Newton 迭代)、操作数位宽 |
rem/remu | 20-80 | 与除法相同(除法器同时产出商和余数) |
RISC-V 规范不保证执行周期数——上述数据基于常见开源核(Rocket、BOOM、C906)的实测。关键是除法的延迟比乘法大一个数量级,这是所有 ISA 的共性,来自除法算法的内在串行性。
M 扩展与编译器最佳实践
- 循环中的除法是性能杀手:将
x / d提到循环外,或改用乘法倒数(如果 d 是常数) - 取模 2 的幂用
andi而非rem:x % 8=andi x, 7 - 除以 2 的幂用
srli/srai而非div:x / 8=srli x, 3
这些优化的效果不只体现在 M 扩展上——它们让代码在任何情况下都更快,在没有 M 扩展时更是从"不可能"变为"可能"。
本章要点
- M 扩展复用基础 R-type 的 opcode=
0110011,以 funct7=0000001区分;8 条指令覆盖乘法(funct3=000..011)和除法(funct3=100..111) - 128 位完整乘法用
mul(低 64 位)+mulh/mulhu(高 64 位)组合;常数乘除编译期优化为移位+加法 - RISC-V 除零不抛异常:
div返 -1、divu返 MAX、rem返被除数——这是实时系统友好的设计选择 - 除法延迟是乘法的 5-20 倍;取模/除 2 的幂用位运算替代是编写高效汇编的基本素养
- 没有 M 扩展时,编译器调用
__muldi3/__divdi3软浮点库——硬件 M 扩展的存在对性能有量级影响