从零开始设计一个 RISC-V 兼容的32位ALU:Verilog实战指南
你有没有想过,一条简单的add x5, x6, x7指令背后,CPU到底是怎么把两个数加起来的?
这背后的核心功臣,就是算术逻辑单元(ALU)。作为处理器数据通路的“运算大脑”,ALU 负责执行所有基础计算——加减乘除、与或非、移位比较……几乎每条指令都绕不开它。
随着 RISC-V 架构的崛起,越来越多工程师和学生开始动手实现自己的处理器核心。而 ALU,正是这条旅程的第一站。本文将带你手把手用 Verilog 实现一个兼容 RISC-V RV32I 指令集的 32 位 ALU,并对比经典 MIPS 架构的设计差异,帮助你真正理解:指令如何变成电路行为。
我们不堆术语,不讲空话,只聚焦一件事:写出能跑、能综合、能进 FPGA 的真实代码。
为什么是 RISC-V?又为什么要提 MIPS?
RISC-V 不是凭空火起来的。它的成功在于简洁、模块化、完全开源。特别是基础整数指令集RV32I,只有不到 50 条指令,却足以构建一个完整 CPU。这种极简主义让它成为教学与原型开发的理想选择。
而 MIPS,虽然逐渐淡出工业界,但依然是无数高校计算机体系结构课的“教科书级”案例。它的规整格式、清晰编码,非常适合初学者理解 CPU 工作原理。
更重要的是:RISC-V 和 MIPS 的 ALU 高度相似。它们都是典型的 RISC 架构,采用寄存器-寄存器型运算,控制信号来自 funct 字段,功能集合也基本重合。
所以,我们今天做的这个 ALU,不仅能跑 RISC-V 指令,也能轻松兼容 MIPS 的大部分操作。一鱼两吃,何乐不为?
ALU 要做什么?先看指令需求
在写代码之前,我们必须搞清楚:RV32I 到底需要 ALU 支持哪些操作?
翻阅《The RISC-V Instruction Set Manual》可以发现,RV32I 中涉及 ALU 的 R-type 和 I-type 指令主要包括:
| 操作类型 | 指令 | 功能 |
|---|---|---|
| 算术 | ADD, SUB | 加法 / 减法 |
| 逻辑 | AND, OR, XOR | 按位与 / 或 / 异或 |
| 移位 | SLL, SRL, SRA | 左移 / 逻辑右移 / 算术右移 |
| 比较 | SLT, SLTU | 小于则置1(有符号 / 无符号) |
也就是说,我们的 ALU 至少要支持7 种核心功能。注意,SUB 和 SLT 都依赖减法结果,我们可以复用同一个加法器来实现,节省硬件资源。
再来看控制信号怎么来。RISC-V 的 opcode 决定指令大类(如 R-type),funct3 和 funct7 进一步细化具体操作。例如:
ADD: funct3=000, funct7=0000000SUB: funct3=000, funct7=0100000
这意味着,顶层控制器需要把这些字段组合起来,生成一个内部的alu_ctrl信号,告诉 ALU:“现在该做哪个运算”。
为了简化接口,我们定义一个3 位的 alu_ctrl 编码方案,如下表所示:
| alu_ctrl[2:0] | 对应操作 |
|---|---|
| 3’b000 | AND |
| 3’b001 | OR |
| 3’b010 | ADD / SLT(通过额外标志区分) |
| 3’b011 | XOR |
| 3’b100 | SLL |
| 3’b101 | SRL |
| 3’b110 | SRA |
| 3’b111 | SUB |
注:ADD 和 SLT 共享同一组加法器输出,仅在写回阶段处理方式不同。我们在本设计中将 SLT 视为一种特殊输出模式。
核心设计:Verilog 实现一个可综合的 32 位 ALU
下面就是重头戏了——完整的、可综合的 Verilog 代码。你可以直接复制到你的项目中使用。
// File: alu.v // 32-bit ALU for RISC-V RV32I (compatible with common MIPS operations) module alu ( input [31:0] a, input [31:0] b, input [2:0] alu_ctrl, output reg [31:0] result, output wire zero ); // 内部信号:加法器输出 wire [31:0] adder_out; // 【关键技巧】统一加法器实现 ADD/SUB // 减法 A - B 等价于 A + (~B) + 1 assign adder_out = (alu_ctrl == 3'b111) ? (a - b) : (a + b); // 移位操作:注意移位量只能取低5位(0~31) wire [4:0] shift_amt; assign shift_amt = b[4:0]; // RISC-V 规定移位量来自 rs2[4:0] // 各类移位结果 wire [31:0] shifted_left = a << shift_amt; wire [31:0] shifted_right_log = a >> shift_amt; wire [31:0] shifted_right_arith = {{32{a[31]}} >> shift_amt}; // 复制符号位 // 组合逻辑主流程 always @(*) begin case (alu_ctrl) 3'b000: result = a & b; // AND 3'b001: result = a | b; // OR 3'b010: result = adder_out; // ADD 3'b011: result = a ^ b; // XOR 3'b100: result = shifted_left; // SLL 3'b101: result = shifted_right_log; // SRL 3'b110: result = shifted_right_arith; // SRA 3'b111: result = adder_out; // SUB default: result = 32'h0; endcase end // 零标志位:用于 BEQ/BNE 等条件跳转 assign zero = (result == 32'd0); endmodule关键点解析
✅ 加法器复用
我们用同一组加法器实现了 ADD 和 SUB。Verilog 综合工具会识别a - b并映射为带进位的加法结构(即a + ~b + 1),无需手动拆解。
✅ 移位边界安全
RISC-V 明确规定移位量来自rs2[4:0],即最多 31 位。但我们仍建议在更复杂的 ALU 中加入判断:
assign shifted_left = (shift_amt >= 5'd32) ? 32'd0 : (a << shift_amt);防止仿真时出现未定义行为。
✅ SLT 怎么办?
当前版本暂未实现 SLT。如果你需要支持slt指令,可以在alu_ctrl==3'b010时改为:
result = ($signed(a) < $signed(b)) ? 32'h1 : 32'h0;或者由控制逻辑单独拉高一个set_less_than信号,在写回阶段处理。
✅ zero 标志的重要性
虽然 RISC-V 没有传统 FLAGS 寄存器,但zero信号必须反馈给控制单元,用于判断是否跳转。比如beq x1, x2, label实际上就是判断(x1 == x2)即(x1 - x2 == 0)。
和 MIPS 的 ALU 有什么不同?
尽管两者非常相似,但仍有几个值得注意的区别:
| 对比项 | RISC-V | MIPS |
|---|---|---|
| 控制信号来源 | opcode + funct3 + funct7 | funct[5:0] 直接译码 |
| NOR 指令 | 不支持 | 支持nor rd, rs, rt |
| 移位指令编码 | funct3 区分 SLL/SRL/SRA,funct7 区分 ADD/SUB | funct[5:0] 直接指定 |
| 是否有伪指令 | 有(如mv,neg) | 较少 |
举个例子,MIPS 的NOR可以直接用硬件实现:
3'b100: result = ~(a | b); // 如果分配了该编码而在 RISC-V 中,nor是伪指令,会被汇编器转成not+or,最终走 AND/OR 路径即可。
这也体现了 RISC-V 的哲学:硬件尽量简单,复杂性交给软件层解决。
如何测试?给你一个极简 Testbench
光写模块不够,还得验证它能不能跑。下面是配套的测试激励代码:
// File: tb_alu.v module tb_alu; reg [31:0] a, b; reg [2:0] alu_ctrl; wire [31:0] result; wire zero; // 实例化被测模块 alu u_alu ( .a(a), .b(b), .alu_ctrl(alu_ctrl), .result(result), .zero(zero) ); initial begin $monitor("T=%0t | op=%b | A=%8h B=%8h | Result=%8h | Zero=%b", $time, alu_ctrl, a, b, result, zero); // 测试向量 a = 32'h0000_0001; b = 32'h0000_0002; alu_ctrl = 3'b000; #10; // AND alu_ctrl = 3'b001; #10; // OR alu_ctrl = 3'b010; #10; // ADD alu_ctrl = 3'b111; #10; // SUB alu_ctrl = 3'b100; #10; // SLL: 1 << 2 = 4 #10; alu_ctrl = 3'b101; // SRL: 4 >> 1 = 2 b = 32'd1; #10; $finish; end endmodule运行这个 testbench,你应该能看到类似输出:
T=0 | op=000 | A=00000001 B=00000002 | Result=00000000 | Zero=1 T=10 | op=001 | A=00000001 B=00000002 | Result=00000003 | Zero=0 T=20 | op=010 | A=00000001 B=00000002 | Result=00000003 | Zero=0 T=30 | op=111 | A=00000001 B=00000002 | Result=ffffffff | Zero=0 ...看到ADD得到3,SUB得到-1(补码表示为0xffffffff),说明一切正常!
在系统中怎么用?ALU 的上下游连接
ALU 不是孤立存在的。在一个典型的单周期 CPU 中,它的位置如下:
+-------------+ | Register | | File | | RD1 → A | | RD2 → B | +-----+-------+ | v +---+---+ | ALU | | | +---+---+ | v +---------+---------+ | MUX for WriteBack | +---------+---------+ | v Write Data to RegFile同时,zero信号会连回控制单元,参与分支决策:
// 控制单元片段示意 wire branch_taken; assign branch_taken = (opcode == OP_BEQ) ? zero : (opcode == OP_BNE) ? ~zero : 1'b0;这才是完整的闭环。
常见坑点与调试秘籍
新手常踩的坑,我们都替你趟过了:
❌ 问题1:移位超过31位导致仿真异常
现象:某些仿真器中a << 32输出不确定值。
解决:显式限制移位量:
assign shifted_left = (shift_amt >= 5'd32) ? 32'd0 : (a << shift_amt);❌ 问题2:SLT 判断错误
现象:slt x1, x2, x3对负数比较出错。
原因:用了普通<而不是$signed()。
修正:
result = ($signed(a) < $signed(b)) ? 1 : 0;❌ 问题3:always 块阻塞赋值导致锁存器推断
错误写法:
always @(alu_ctrl) begin if (alu_ctrl == 3'b000) result = a & b; // 其他情况没覆盖 → 综合出锁存器! end正确做法:使用always @(*)并确保case覆盖所有分支,或加default。
下一步可以怎么做?
你现在有了一个可用的 ALU,接下来可以:
- ✅ 添加溢出检测(Overflow)信号,用于陷阱处理;
- ✅ 扩展支持MUL/DIV(需引入 M 扩展,可能用状态机实现);
- ✅ 使用超前进位加法器(CLA)替换默认加法器,提升性能;
- ✅ 把
alu_ctrl解码逻辑独立成一个control unit 模块,实现真正的指令驱动; - ✅ 接入 Wishbone 或 AXI 总线,做成可复用 IP 核。
掌握 ALU 设计,是你迈向自研 CPU 的第一步。它看似简单,却是整个计算机系统的缩影:输入、控制、运算、输出、反馈,五脏俱全。
而当你亲手让add指令在 FPGA 上跑起来时,那种“我懂了”的顿悟感,是任何理论课都无法替代的。
现在,打开你的 Vivado 或 Quartus,新建一个工程,把这段代码贴进去——让第一个比特流动起来吧!
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。