从零构建 RISC 核心:深入剖析 MIPS/RISC-V ALU 的设计与验证实战

你有没有想过,一条简单的 add x5, x3, x4 指令背后,到底发生了什么?

在现代处理器的世界里,每一条指令的执行都依赖于一个看似低调却至关重要的模块—— 算术逻辑单元(ALU) 。它是 CPU 的“计算心脏”,无论是加减乘除、逻辑判断,还是移位操作,最终都要由它来完成。

尤其是在 MIPS 和 RISC-V 这类精简指令集架构中,ALU 的作用更加突出。由于指令简单、格式统一,大多数操作都能在一个周期内通过 ALU 完成。因此,一个高效、稳定、可复用的 ALU 模块,是构建完整 CPU 流水线的第一步。

本文将带你从零开始,亲手实现一个兼容 RV32I 子集 和 MIPS I 架构 的 32 位通用 ALU,并搭建完整的 SystemVerilog 测试平台,覆盖典型运算场景和边界条件。我们不只写代码,更要讲清楚每一行背后的工程考量与调试经验。


ALU 是什么?为什么它如此关键?

在嵌入式系统、FPGA 开发乃至国产芯片研发中,ALU 并不是一个陌生概念。但真正动手实现时,很多人会发现:手册上的“组合逻辑电路”四个字,藏着不少坑。

它不只是“做加法”的黑盒子

ALU 接收两个操作数 A 和 B ,以及一个控制信号 alu_ctrl ,然后根据这个控制码决定执行哪种操作:

  • 算术运算: ADD , SUB
  • 逻辑运算: AND , OR , XOR
  • 移位操作: SLL , SRL , SRA
  • 比较指令: SLT (Set Less Than)

输出不仅仅是结果 result ,还包括多个状态标志:
- zero :结果是否为零 → 决定分支跳转
- overflow :是否有符号溢出 → 影响异常处理
- carry_out :无符号进位/借位 → 支持多精度运算

这些标志虽然只占几个比特,却是程序流控制的关键依据。比如 beq (相等则跳转)就依赖 zero 标志;而大数加法可能需要利用 carry_out 实现链式进位。

控制信号怎么来?

在实际 CPU 中, alu_ctrl 通常由指令译码器生成。例如:

架构 来源字段 示例说明
MIPS opcode + funct add 和 sub 共享 opcode,靠 funct 区分
RISC-V funct3 + funct7 add vs sub 通过 funct7[5] 判断

这意味着你的 ALU 模块必须能准确响应这些控制编码。接口设计稍有偏差,整个流水线就会“跑飞”。


我们要造一个什么样的 ALU?

目标很明确:做一个 参数化、高可靠性、易集成 的 32 位 ALU 模块,支持主流整数操作,适用于教学级单周期或五级流水线 CPU 设计。

功能需求清单

特性 说明
数据宽度 32 位(可扩展)
操作类型 ADD/SUB, AND/OR/XOR/NOR, SLL/SRL/SRA, SLT(SLTU)
输出标志 zero, overflow, carry_out
组合逻辑实现 无状态,纯组合路径
可综合 支持 FPGA 与 ASIC 综合工具

✅ 提示:我们在 RISC-V RV32I 和 MIPS I 的交集指令上对齐功能,提升模块通用性。


RTL 实现:SystemVerilog 编写的通用 ALU 模块

下面是核心代码实现。别急着复制粘贴,我们先拆解关键设计点。

// 文件名:alu.sv
// 功能:通用 32-bit ALU for MIPS/RISC-V (RV32I subset)
module alu #(
    parameter WIDTH = 32,
    parameter ALU_CTRL_WIDTH = 3
)(
    input  logic [WIDTH-1:0] operand_a,
    input  logic [WIDTH-1:0] operand_b,
    input  logic [ALU_CTRL_WIDTH-1:0] alu_ctrl,
    output logic [WIDTH-1:0] result,
    output logic              zero,
    output logic              overflow,
    output logic              carry_out
);

    logic [WIDTH:0] signed_add_result;     // 扩展一位用于溢出检测
    logic [WIDTH:0] unsigned_sub_result;

    always_comb begin
        unique case (alu_ctrl)
            3'b000: result = operand_a & operand_b;                    // AND

            3'b001: result = operand_a | operand_b;                    // OR

            3'b010: begin
                        result = operand_a + operand_b;
                        signed_add_result = {operand_a[WIDTH-1], operand_a} +
                                          {operand_b[WIDTH-1], operand_b};
                        overflow = (signed_add_result[WIDTH] != signed_add_result[WIDTH-1]);
                        carry_out = signed_add_result[WIDTH];
                     end

            3'b011: begin
                        result = operand_a - operand_b;
                        unsigned_sub_result = {1'b0, operand_a} - {1'b0, operand_b};
                        carry_out = ~unsigned_sub_result[WIDTH];  // 借位取反即为进位
                        overflow = (operand_a[WIDTH-1] && ~operand_b[WIDTH-1] && ~result[WIDTH-1]) ||
                                   (~operand_a[WIDTH-1] && operand_b[WIDTH-1] && result[WIDTH-1]);
                     end

            3'b100: result = operand_a ^ operand_b;                    // XOR

            3'b101: result = operand_a << operand_b[4:0];               // SLL

            3'b110: if (operand_b[5]) 
                        result = $signed(operand_a) >>> operand_b[4:0]; // SRA
                     else
                        result = operand_a >> operand_b[4:0];            // SRL

            default: result = 'x;
        endcase
    end

    assign zero = (result == 32'd0);

endmodule

关键技术点解析

1. 溢出检测:为什么不能直接用 result[31] ?

很多初学者误以为:“只要结果变负就是溢出了”。错!真正的 有符号溢出 是指运算结果超出了表示范围(如 32 位补码只能表示 -2³¹ ~ 2³¹-1)。

正确做法是检查 符号位变化是否合理 。我们通过扩展一位进行带符号加法:

signed_add_result = {a_sign, a} + {b_sign, b};
overflow = (result_ext[32] != result_ext[31]); // 进位与符号不同 → 溢出

这是 IEEE 标准推荐的方法,比查表更可靠。

2. 减法中的 carry_out 到底是什么?

注意:减法本质上是加法的逆运算。硬件中常用补码实现 A - B = A + (~B) + 1 。

此时,“进位输出”其实是“ 借位标志的反 ”。也就是说:
- 如果 A >= B ,没有借位 → carry_out = 1
- 如果 A < B ,发生借位 → carry_out = 0

这正好符合 ARM 等架构中 C 标志的定义,便于后续实现 CMP 和条件执行。

3. 算术右移 SRA 怎么保证符号扩展?

使用 $signed() 强制解释为有符号数,再配合 >>> 运算符,Verilog 会自动填充符号位。

$signed(32'h80000000) >>> 1 → 32'hC0000000

如果不加 $signed ,默认按无符号处理,会导致错误。

4. 移位数量为何取 operand_b[4:0] ?

因为 32 位数据最多左移 31 位。用低 5 位作为移位量,既满足规范(RISC-V 要求),又避免非法操作。


如何验证?别让 bug 藏在角落里

写完 RTL 只完成了一半工作。 功能验证才是确保 ALU 可靠性的关键 。

我见过太多学生写了 ALU,测试只跑了 1+1=2 就宣布成功,结果遇到 -1 + 1 或最大值溢出直接翻车。

验证策略三板斧

方法 适用场景 工程价值
定向测试(Directed Test) 覆盖典型指令和边界值 快速发现问题
随机测试(Randomized Test) 大量输入组合压力测试 提升覆盖率
断言监控(Assertion) 实时捕捉非法状态 加速调试

今天我们先聚焦 定向测试 ,打好基础。


测试平台(Testbench)实战:不只是“打个印”

// 文件名:tb_alu.sv
module tb_alu;

    parameter WIDTH = 32;
    logic [WIDTH-1:0] a, b;
    logic [2:0]       ctrl;
    logic [WIDTH-1:0] res;
    logic             zero, of, co;

    alu #(.WIDTH(WIDTH)) u_alu (
        .operand_a(a),
        .operand_b(b),
        .alu_ctrl(ctrl),
        .result(res),
        .zero(zero),
        .overflow(of),
        .carry_out(co)
    );

    initial begin
        $dumpfile("alu.vcd");
        $dumpvars(0, tb_alu);

        // 测试 1: ADD 正常情况
        {a, b, ctrl} = {32'h00000001, 32'h00000002, 3'b010}; #10;
        check_result("ADD", res, 32'd3);

        // 测试 2: SUB 基本减法
        {a, b, ctrl} = {32'h00000005, 32'h00000003, 3'b011}; #10;
        check_result("SUB", res, 32'd2);

        // 测试 3: AND 全零
        {a, b, ctrl} = {32'hFFFF0000, 32'h0000FFFF, 3'b000}; #10;
        check_result("AND", res, 32'h00000000);

        // 测试 4: OR 合并掩码
        {a, b, ctrl} = {32'h000000FF, 32'h0000FF00, 3'b001}; #10;
        check_result("OR", res, 32'h0000FFFF);

        // 测试 5: SLL 左移一位
        {a, b, ctrl} = {32'hFFFFFFFF, 32'h00000001, 3'b101}; #10;
        check_result("SLL", res, 32'hFFFFFFFE);

        // 测试 6: SRA 算术右移(符号扩展)
        {a, b, ctrl} = {32'h80000000, 32'h00000001, 3'b110}; 
        #10;
        check_result("SRA", res, 32'hC0000000); 

        // 测试 7: 溢出检测(INT_MAX + 1)
        {a, b, ctrl} = {32'sd2147483647, 32'd1, 3'b010}; #10;
        if (of !== 1) $error("[OVERFLOW] FAIL: Should set overflow!");
        else $display("[OVERFLOW] PASS");

        // 测试 8: Zero 标志触发
        {a, b, ctrl} = {32'd10, 32'd10, 3'b011}; #10; // 10 - 10 = 0
        if (zero !== 1) $error("[ZERO] FAIL: 10-10 should set zero flag");
        else $display("[ZERO] PASS");

        $display("✅ All tests completed.");
        $finish;
    end

    task check_result(string op_name, logic [31:0] actual, logic [31:0] expected);
        if (actual === expected) begin
            $display("[%s] PASS: Result = %h", op_name, actual);
        end else begin
            $error("[%s] FAIL: Expected %h, Got %h", op_name, expected, actual);
        end
    endtask

endmodule

测试设计思路

  1. 覆盖主流操作 :AND/OR/XOR/ADD/SUB/SLL/SRA
  2. 包含边界值 :
    - 最大正数加 1 → 触发溢出
    - 相同数相减 → 触发 zero
    - 负数右移 → 验证符号扩展
  3. 标志位专项测试 :单独验证 overflow 和 zero 行为
  4. 波形输出支持 :生成 .vcd 文件,可用 GTKWave 查看信号跳变

运行命令示例(使用 EDA Playground 或本地仿真器):

vcs -sverilog alu.sv tb_alu.sv && ./simv
# 或 ModelSim
vsim -c tb_alu -do "run -all"

实际应用中的那些“坑”与应对技巧

纸上得来终觉浅。以下是我在 FPGA 开发和课程指导中总结的真实问题清单。

❌ 坑点 1: case 没有用 unique ,综合后出现锁存器

如果你写的是普通 case 而非 unique case ,综合工具可能因未覆盖所有情况插入锁存器(latch),导致时序混乱。

✅ 解决方案 :显式声明 unique case ,并确保 default 分支存在。

❌ 坑点 2:移位量超过位宽,行为未定义

Verilog 中,若移位量大于等于数据宽度,结果是未定义的!

例如: a << 32 在某些工具中可能是 0,也可能是原值。

✅ 解决方案 :在前端加入限制,或使用 % WIDTH 对移位量取模。

❌ 坑点 3:误把 carry_out 当作减法的“借位”

记住: carry_out = 1 表示无借位(够减), = 0 表示有借位。这点和直觉相反!

建议在文档中标注清楚,避免后续模块误解。

✅ 秘籍:如何快速定位问题?

  1. 打开 VCD 波形,观察 operand_a , operand_b , alu_ctrl , result 四者关系
  2. 查看 overflow 和 zero 是否在预期时刻翻转
  3. 使用 $monitor 打印每一拍的变化:
    systemverilog initial $monitor("Time=%0t | A=%h B=%h Ctrl=%b | Res=%h Z=%b Ov=%b Co=%b", $time, a, b, ctrl, res, zero, of, co);

更进一步:你可以这样升级你的 ALU

现在你已经有了一个可靠的整数 ALU,下一步可以考虑以下方向:

  • 支持 SLT / SLTU :增加比较功能,只需在 3'b010 分支添加条件赋值
  • 引入 FPU 接口 :为未来浮点单元预留扩展空间
  • 加入低功耗优化 :在驱动端添加 clock gating(虽 ALU 自身为组合逻辑)
  • 形式验证辅助 :使用 JasperGold 证明其行为等价于数学模型
  • 移植到 UVM 框架 :构建随机测试环境,冲击 98%+ 功能覆盖率

掌握了 ALU 的设计与验证,你就迈出了构建自主 CPU 的第一步。它或许不像分支预测或缓存那样炫酷,但它是最坚实的基础。

当你看到 1 + (-1) = 0 并且 zero=1 被正确置起时,那种“我真正理解了计算机”的感觉,值得每一个工程师去体验。

如果你正在学习计算机组成原理、准备 FPGA 项目,或者参与开源 RISC-V 芯片开发,不妨动手实现一遍这个 ALU 模块。调试过程中的每一次报错,都是通往深度理解的阶梯。

💬 你在实现 ALU 时踩过哪些坑?欢迎在评论区分享你的故事。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐