从零开始构建 MIPS/RISC-V 的 ALU:一个工程师的实践笔记
最近带学生做计算机体系结构实验,发现很多人对“ALU 到底是怎么工作的”这件事还停留在概念层面。课本讲得清楚但不够直观,仿真波形又跳得太快,初学者常常一头雾水:为什么一条add指令会触发加法?beq是怎么靠 ALU 来判断是否相等的?控制信号到底是怎么一层层下来的?
于是我想,不如写一篇真正“手把手”的教程——不堆术语、不列大纲,就从最基础的问题出发,像搭积木一样,把一个能跑通 MIPS 和 RISC-V 基础指令的 ALU 给它完整地实现出来。
一、先搞明白:ALU 究竟是干什么的?
我们常说 CPU 是大脑,那 ALU 就是它的“计算器”。它不负责记忆、也不管流程控制,只专注一件事:拿到两个数,做一次运算,返回结果。
比如你写了一条汇编:
add t0, t1, t2背后发生的事就是:
- 寄存器文件把t1和t2的值读出来;
- 这两个值送到 ALU;
- ALU 控制单元说:“这是个 ADD 操作!”;
- ALU 把两数相加,输出结果;
- 结果再写回t0。
听起来简单吧?可难点在于:同一只硬件电路,要支持几十种不同的操作——加、减、与、或、异或、比较、移位……这就需要一套精密的“调度系统”,也就是我们常说的控制信号机制。
二、MIPS 和 RISC-V 的 ALU 长什么样?
虽然架构不同,但早期的 MIPS 和 RISC-V(RV32I)在 ALU 设计上高度相似:都是 32 位宽,支持基本算术逻辑操作,采用组合逻辑 + 多路选择的方式实现多功能复用。
我们可以提炼出这个 ALU 必须具备的核心能力:
| 功能类型 | 支持操作 |
|---|---|
| 算术 | ADD, SUB, SLT(有符号小于) |
| 逻辑 | AND, OR, XOR, NOR |
| 移位 | SLL(左移)、SRL(逻辑右移)、SRA(算术右移) |
| 状态标志 | Zero(结果为零)、Carry/Borrow(进位/借位)、Overflow(溢出) |
别小看这些功能,它们已经足以支撑像add,sub,and,or,slt,beq,bne,lw,sw这类常用指令的执行了。
三、关键设计思路:控制信号是如何一步步下来的?
这是我当年学的时候最大的困惑点:Opcode 怎么变成 ALU 要做的具体动作?
其实整个过程是分层的,就像快递分拣中心——先按区域分大类,再按街道细分。
第一层:主控单元给出操作大类(ALUOp)
CPU 解码指令时,首先看opcode字段。根据它是 R 型、I 型还是分支指令,主控模块会输出一个叫ALUOp[1:0]的信号:
| ALUOp | 含义 |
|---|---|
| 2’b00 | 地址计算(如 lw/sw)→ 执行 ADD |
| 2’b01 | 分支比较(如 beq/bne)→ 执行 SUB |
| 2’b10 | R 型指令 → 需要看 funct 字段进一步判断 |
你看,这里还没决定到底做什么,只是划了个范围。
第二层:alu_control 模块精细译码
接下来才是重头戏。对于 R 型指令(比如add,sub,and),我们需要去看funct字段。这就由一个独立的alu_control模块来完成。
它的任务很明确:输入ALUOp和funct,输出一个 4 位的ALUControl信号,告诉 ALU 主体该走哪条路径。
比如当
ALUOp == 2'b10且funct == 6'b100000时,说明是add指令,那就让ALUControl = 4'b0010。
这种“两级译码”结构的好处是:主控单元不用知道所有 funct 编码细节,职责清晰,易于扩展和维护。
下面是这个模块的经典 Verilog 实现:
// alu_control.v module alu_control ( input [1:0] ALUOp, input [5:0] Funct, output reg [3:0] ALUControl ); always @(*) begin case (ALUOp) 2'b00: // lw/sw 地址计算 ALUControl = 4'b0010; // ADD 2'b01: // beq/bne 比较 ALUControl = 4'b0110; // SUB 2'b10: // R-type,查 funct case (Funct) 6'b100000: ALUControl = 4'b0010; // ADD 6'b100010: ALUControl = 4'b0110; // SUB 6'b100100: ALUControl = 4'b0000; // AND 6'b100101: ALUControl = 4'b0001; // OR 6'b100110: ALUControl = 4'b0011; // XOR 6'b100111: ALUControl = 4'b1111; // NOR 6'b101010: ALUControl = 4'b0111; // SLT default: ALUControl = 4'bxxxx; endcase default: ALUControl = 4'bxxxx; endcase end endmodule注意这里用了always @(*)和阻塞赋值=,因为这是一个纯组合逻辑模块,不能产生锁存器。这也是新手最容易犯错的地方之一:漏掉某个分支导致综合工具推断出意外的锁存器。
四、ALU 主体实现:真正的“运算工厂”
现在我们有了“指挥官”(alu_control),下面就要建好“工厂”本身了。
ALU 主体接收两个 32 位输入A和B,以及来自alu_control的ALUControl[3:0],然后根据控制码选择对应的运算结果输出。
如何高效实现多个功能?
常见做法是“并行计算 + 多路选择”:先把所有可能的结果都算出来,最后用一个多路选择器挑一个作为最终输出。
虽然这样会多消耗一些功耗(毕竟其他运算白算了),但在教学级设计中非常实用——逻辑清晰、延迟可控、调试方便。
关键问题处理
1. 加法与减法怎么做?
直接用加法器即可。减法可以转化为加负数:
assign AddFull = {1'b0, A} + {1'b0, B}; // 33位防止溢出 assign SubFull = {1'b0, A} - {1'b0, B};为什么要扩一位?为了能检测到最高位的进位/借位。
2. 溢出(Overflow)怎么判断?
只适用于有符号数运算。判断规则是:两个正数相加得负数,或两个负数相加得正数,就是溢出了。
对应代码:
assign Overflow = (A[31] == B[31]) && (A[31] != Result[31]);注意这里是在SUB操作下判断的,所以Result = A - B,如果符号突变就说明溢出。
3. SLT 怎么实现?
Set on Less Than,即 A < B 时结果为 1,否则为 0。必须使用有符号比较:
Result = (signed'(A) < signed'(B)) ? 32'h1 : 32'h0;Verilog 中signed'()表示强制按有符号数解释,这点非常重要,否则会当成无符号比较。
4. 移位操作注意边界
RISC-V 规定移位位数取低 5 位(即最多移 31 位),所以我们用B[4:0]作为移位量,并做安全检查:
Result = B[4:0] < 32 ? A >> B[4:0] : 32'd0; // SRL Result = B[4:0] < 32 ? $signed(A) >>> B[4:0] : 32'd0; // SRA>>>是算术右移,高位补符号位,符合有符号数语义。
下面是完整的 ALU 主体代码:
// alu.v - 32-bit ALU module alu ( input [31:0] A, B, input [3:0] ALUControl, output reg [31:0] Result, output reg Zero, output CarryOut, output Overflow ); wire [32:0] AddFull = {1'b0, A} + {1'b0, B}; wire [32:0] SubFull = {1'b0, A} - {1'b0, B}; wire [31:0] AddOut = AddFull[31:0]; wire [31:0] SubOut = SubFull[31:0]; // 借位:减法中若 SubFull[32]==1 表示没借位,反之为借位 assign CarryOut = !SubFull[32]; assign Overflow = (A[31] == B[31]) && (A[31] != SubOut[31]); always @(*) begin case (ALUControl) 4'b0010: Result = AddOut; // ADD 4'b0110: Result = SubOut; // SUB 4'b0000: Result = A & B; // AND 4'b0001: Result = A | B; // OR 4'b0011: Result = A ^ B; // XOR 4'b1111: Result = ~(A | B); // NOR 4'b0111: // SLT Result = (signed'(A) < signed'(B)) ? 32'h1 : 32'h0; 4'b0101: // SRL Result = (B[4:0] < 32) ? (A >> B[4:0]) : 32'd0; 4'b1101: // SRA Result = (B[4:0] < 32) ? $signed(A) >>> B[4:0] : 32'd0; default: Result = 32'bx; endcase end // Zero 标志:结果全为0则置1 assign Zero = (Result == 32'd0); endmodule五、把它放进处理器:看看它是怎么工作的
在一个单周期处理器里,ALU 处于绝对的C位:
+------------------+ | Control Unit | +--------+---------+ | ALUOp v +------------------+ | ALU Control | +--------+---------+ | ALUControl v A <-----+ +------------------+ +----------+ | | ALU |<-- B | Register | +---------->| (Arithmetic & |-----------| File | | | Logic Unit) | Result +----------+ | +--------+---------+ ^ | | Zero | | v | +------------< Branch Logic <-----------------+以执行beq r1, r2, label为例:
1. 控制单元识别出是分支指令,设置ALUOp = 2'b01;
2.alu_control模块据此输出ALUControl = 4'b0110(SUB);
3. ALU 计算r1 - r2;
4. 若结果为 0,则Zero = 1,配合分支逻辑跳转。
你看,连“是否相等”这种逻辑判断,本质上也是靠 ALU 做减法实现的。这就是硬件设计的巧妙之处。
六、新手常踩的坑与避坑指南
我在指导过程中总结了几条高频“翻车点”,供大家参考:
❌ 陷阱1:忘了补全 case 分支,导致锁存器被推断
always @(*) begin if (sel) out = a; // 没有 else! end👉 合成工具会认为“else 情况保持原值”,从而插入锁存器。应改为:
always @(*) begin if (sel) out = a; else out = b; end✅ 建议:组合逻辑一律用always @(*)+ 阻塞赋值=
非时序逻辑不要用<=,那是给寄存器用的。
❌ 陷阱2:移位位数未限制,导致综合失败
A >> B // 如果 B > 31,在某些工具中会被视为非法👉 正确做法是只取低5位:A >> B[4:0]
❌ 陷阱3:SLT 用了无符号比较
A < B ? 1 : 0 // 错!默认是无符号👉 必须加signed转换:
(signed'(A) < signed'(B)) ? 1 : 0✅ 建议:写完一定要做 Testbench!
哪怕只是几个简单测试用例:
initial begin A = 32'd5; B = 32'd3; ALUControl = 4'b0010; #10; // ADD -> 8 ALUControl = 4'b0110; #10; // SUB -> 2 ALUControl = 4'b0111; #10; // SLT -> 1 (5<3? no!) -> 0 ... end通过波形观察Result和Zero是否符合预期,是最直接有效的验证方式。
七、下一步往哪走?
一个能跑通基础指令的 ALU 只是起点。接下来你可以尝试:
- 加入乘除法单元(MDU):用 Wallace 树或移位加法实现;
- 升级为流水线结构:把取指、译码、执行、访存、写回拆开;
- 引入状态机控制器:支持多周期操作;
- 移植到 FPGA 开发板:接上 LED 或串口打印结果;
- 参与开源项目:比如 PicoRV32 或 VexRiscv ,看看工业级 ALU 是怎么写的。
从一个简单的加法器开始,到理解整个 CPU 的运作脉络,这正是学习计算机体系结构的魅力所在。
当你第一次看着自己写的 ALU 在 ModelSim 里正确输出5 + 3 = 8,而Zero = 0、Overflow = 0的那一刻,那种“我懂了”的感觉,真的很爽。
如果你也在做类似的实验,欢迎留言交流。遇到什么问题,咱们一起 debug。