从零构建MIPS乘法器:手把手教你实现ALU中的定点乘法
你有没有想过,一条简单的mult $t0, $t1指令背后,CPU到底做了什么?
在现代处理器中,乘法早已被硬件加速到一个时钟周期内完成。但如果你正在用Verilog写一个教学级MIPS CPU,或者想深入理解RISC-V的M扩展模块,那么——这个乘法功能,得你自己做出来。
本文不讲空话,不堆术语,带你从数学原理出发,一步步搭建出可综合、可集成的32位定点乘法器,并完整融入MIPS五级流水线架构。无论你是做课程设计、FPGA原型验证,还是为自研RISC-V软核打基础,这篇都能让你真正“看得见、摸得着”底层运算的本质。
为什么不能让主ALU直接算乘法?
我们先来打破一个常见误解:很多人以为ALU应该像计算器一样,支持加减乘除全功能。但实际上,在标准MIPS和RISC-V架构中,乘法并不在主ALU路径中执行。
为什么?
因为乘法太慢了。
主ALU处理的是单周期操作,比如add、sub、and,它们的关键路径就是“寄存器→多路选择→加法器→结果回写”,延迟通常控制在几纳秒内。而32位整数乘法如果用纯组合逻辑实现,会涉及32个部分积生成+压缩树(如Wallace树),面积大、延迟高,轻松突破几十纳秒——这会直接拖垮整个流水线的时序。
所以现实做法是:
把乘法做成独立的协处理单元,多周期运行,主流水线暂停等待。
这也正是MIPS中HI和LO寄存器存在的意义:它们不是通用寄存器,而是专门为长耗时运算(乘、除)准备的结果暂存区。
定点乘法的本质:移位 + 条件相加
别被“乘法”两个字吓到。二进制下的乘法其实非常直观:
A × B = A × (b₀×2⁰ + b₁×2¹ + ... + b₃₁×2³¹) = Σ (if bᵢ == 1, then A << i)换句话说:逐位检查乘数B的每一位,如果是1,就把被乘数A左移i位后加到累加器里。
举个例子:
1011 (11) × 1101 (13) ------- 1011 ← 1011 × 1 × 2⁰ 0000 ← 1011 × 0 × 2¹ 1011 ← 1011 × 1 × 2² 1011 ← 1011 × 1 × 2³ ------- 10001111 (143)每一行都是“条件移位”,最后全部加起来。这就是所谓的“部分积累加法”。
但问题是:你要么一次性生成所有部分积(组合逻辑阵列乘法器),代价是面积爆炸;要么一次只处理一位,循环32次——这就是我们要实现的迭代式移位相加乘法器。
构建你的第一台32位乘法机
我们现在要做的,是一个典型的基于状态机的时序乘法器。它不需要DSP Slice,完全由LUT和寄存器构成,适合在Artix-7这类中低端FPGA上部署。
核心结构设计
我们需要以下几个关键组件:
| 模块 | 功能 |
|---|---|
multiplicand | 被乘数(A) |
multiplier_in | 乘数(B) |
A_reg | 累加器,保存当前和 |
Q_reg | 存放乘数,每次右移一位 |
B_shifted | 被乘数不断左移(A<<i) |
count | 循环计数器(0~31) |
state | 控制状态机(空闲/运行/完成) |
工作流程如下:
- 初始化:A=0,B_shifted = A << 0,Q = multiplier
- 循环32次:
- 如果 Q[0] == 1 → A += B_shifted[63:32] (高32位)
- Q >> 1,B_shifted << 1
- count++ - 结束:输出
{A, Q}作为64位结果
注意:这里我们只保留A的低32位,高位通过最终拼接恢复。这是节省资源的小技巧。
Verilog实现:带状态机的可综合代码
module multiplier_32bit ( input clk, input rst_n, input start, input [31:0] multiplicand, input [31:0] multiplier_in, output reg [63:0] product, output reg done ); reg [31:0] A; // 累加器(低32位) reg [63:0] B_shifted; // 被乘数左移版本(64位) reg [31:0] Q; // 乘数寄存器 reg [5:0] count; // 计数器(最多32) reg busy; localparam S_IDLE = 2'b00; localparam S_RUN = 2'b01; localparam S_DONE = 2'b10; reg [1:0] state; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; A <= 0; B_shifted <= 0; Q <= 0; count <= 0; product <= 0; done <= 0; busy <= 0; end else begin case (state) S_IDLE: begin done <= 0; if (start && !busy) begin A <= 0; B_shifted <= {multiplicand, 32'b0}; // 初始左移0位 Q <= multiplier_in; count <= 0; busy <= 1; state <= S_RUN; end end S_RUN: begin if (Q[0]) begin A <= A + B_shifted[63:32]; // 加入高32位 end Q <= Q >> 1; // 乘数右移 B_shifted <= B_shifted << 1; // 被乘数左移 count <= count + 1; if (count == 31) begin state <= S_DONE; end end S_DONE: begin product <= {A, Q}; // 拼接得到64位结果 done <= 1; busy <= 0; state <= S_IDLE; end endcase end end endmodule关键点解析:
为何用64位中间变量?
因为每次左移都要保持完整数据宽度,防止溢出。结果怎么拼出来的?
最终A是低32位累加值,Q已经变成全0(被右移干净了),但它的原始低位参与了最后一次加法判断。最终{A, Q}实际上等于完整的64位乘积。done信号的作用?
这是你和主控单元通信的“握手信号”。只有当done == 1,才能允许后续mfhi/mflo指令读取结果。
如何把它塞进MIPS流水线?
光有乘法器还不够,你还得让它和CPU其他部件协同工作。
控制信号扩展
在原有的MIPS控制器基础上,增加以下信号:
| 信号名 | 方向 | 说明 |
|---|---|---|
ex_mult_start | 输出 | EX阶段触发乘法开始 |
mult_done | 输入 | 反馈乘法是否完成 |
wb_write_hi | 输出 | 写使能HI寄存器 |
wb_write_lo | 输出 | 写使能LO寄存器 |
stall_pipe | 输出 | 插入气泡,暂停流水线 |
控制逻辑修改示例
// 在控制单元中添加对 mult/multu 的识别 always @(*) begin alu_op = `ALU_ADD; // 默认操作 mem_read = 0; mem_write = 0; wb_reg_write = 0; ex_mult_start = 0; stall_pipe = 0; case (opcode) `OP_MULT, `OP_MULTU: begin alu_op = `ALU_NOP; // 主ALU不动作 ex_mult_start = 1; // 启动乘法器 wb_reg_write = 0; // 不写通用寄存器 stall_pipe = !mult_done; // 直到完成前一直停顿 end `OP_MFHI: begin // 从HI寄存器取数据写入rd src_a_sel = `SRC_REG; src_b_sel = `SRC_IMM; alu_op = `ALU_MOV; wb_reg_write = 1; end // ... 其他指令 endcase end流水线行为模拟
以mult $t0, $t1→mflo $s0为例:
| 周期 | IF | ID | EX | MEM | WB |
|---|---|---|---|---|---|
| T1 | mult | mult | |||
| T2 | addi | addi | mult (start) | ||
| T3 | lw | lw | mult (run) | mult (wait) | |
| … | … | … | … | … | … |
| T34 | beq | beq | mflo | mult (done) | write $s0 |
可以看到,从EX阶段开始,连续32个周期都在等乘法结束,期间插入多个“气泡”阻止新指令推进。
实际部署中的坑与秘籍
你以为写完代码就完了?No。真实世界的问题才刚开始。
⚠️ 坑点1:计数器边界错误
很多初学者写成count == 32才跳转S_DONE,但别忘了:
- 第一次循环是第0位(LSB)
- 最后一次是第31位
- 所以只需要32次循环(0~31)
如果你用了==32,就会多跑一圈,导致结果错乱。
✅ 正确做法:if (count == 31)就进入S_DONE。
⚠️ 坑点2:符号扩展问题
上面的代码只适用于无符号乘法(对应multu)。如果你想支持有符号数(mult),必须先处理补码。
常见做法:
1. 提前取出两个操作数的符号位(最高位)
2. 分别取绝对值(负数则按位取反+1)
3. 使用上述无符号乘法器计算
4. 根据符号位异或决定是否对结果求补
也可以改用Booth编码算法一步到位,但复杂度更高,适合进阶优化。
✅ 秘籍1:资源共享优化FF数量
注意到Q和A都是32位寄存器吗?其实你可以复用移位逻辑!
观察发现:
-Q每次右移
-B_shifted每次左移
- 二者合起来正好是64位宽
有些设计会合并为一个64位寄存器,利用不同切片实现双向移位,节省约10%寄存器资源。
✅ 秘籍2:提前终止优化性能
如果乘数低位有很多0,其实可以跳过无效循环。
加入判断:
if (Q == 0) begin state <= S_DONE; end虽然增加了比较逻辑,但在某些场景下能显著减少平均周期数。
✅ 秘籍3:异步通知避免死锁
强烈建议将done信号通过脉冲形式发送给控制单元,并清零自身状态。否则一旦错过时钟边沿,可能造成永久阻塞。
适用场景与未来升级方向
这套方案特别适合:
- 🎓 大学计算机组成实验(如华科、清华的CPU课设)
- 🔬 RISC-V M扩展原型验证(RV32IM)
- 💡 教学FPGA平台(Nexys A7、DE10-Lite)
- 🔐 抗侧信道攻击定制乘法器(可控时序)
下一步你可以尝试:
- Booth编码乘法器:将平均迭代次数从32降到16
- 流水线化设计:每个周期处理多位,提升吞吐率
- 双周期乘法器:用少量DSP slice加速
- 饱和模式支持:用于音频/DSP处理
- 与RISC-V兼容封装:定义CSR接口替代HI/LO
写在最后:掌握底层,才有真正的自由
当你亲手写出第一个能在FPGA上跑通的乘法器,你会突然明白:
原来那些看似神秘的指令,不过是移位、加法和状态切换的组合。
这种“原来如此”的顿悟感,是调用IP核永远无法带来的。
更重要的是,一旦你理解了乘法是怎么一步步算出来的,你就有了重新定义它的能力——你可以为AI推理定制低精度乘法,为加密算法设计抗功耗分析的恒定时间乘法,甚至为量子经典混合计算构建专用协处理器。
而这,正是开源硬件的魅力所在。
如果你也在构建自己的MIPS或RISC-V核心,欢迎在评论区分享你的乘法器设计方案。我们一起把这块“硬骨头”啃透。