4. 单周期CPU设计(上):指令译码单元设计、寄存器堆设计、ALU设计

好,咱们开始动手了。

前面几章我们把RISC-V的基础指令集和整体架构聊了一遍。现在,是时候真正打开Vivado,敲下第一行Verilog代码了。

这一章,我们聚焦单周期CPU的三个核心模块:指令译码单元寄存器堆ALU。这三个模块,说白了就是CPU的“大脑翻译官”、“临时记事本”和“计算器”。

我个人习惯,设计任何模块之前,先画一张结构图,把数据流理清楚。不然代码写到一半,很容易把自己绕进去。

指令存储器 (ROM) 指令译码 (Decoder) 寄存器堆 (RegFile) ALU (运算器) 数据存储器 (RAM) 指令[31:0] rs1, rs2, rd 操作数A, B 结果 控制信号 存储单元 译码/控制 寄存器 运算单元

4.1 指令译码单元设计

指令译码,就是把32位的二进制指令,拆解成CPU能理解的各个字段。

RISC-V的指令格式很规整,这是它优雅的地方。你想想看,x86那堆变长指令,译码器复杂得要命。RISC-V就清爽多了,总共就那么几种格式:R型、I型、S型、B型、U型、J型。

译码单元要干的事:

  • 提取操作码(opcode),判断指令类型
  • 提取寄存器地址:rs1、rs2、rd
  • 提取功能码(funct3、funct7),确定具体操作
  • 生成立即数(不同指令格式,立即数拼接方式不同)
  • 输出控制信号,告诉后续模块该做什么

核心要点:译码单元不负责计算,它只负责“翻译”和“发号施令”。控制信号的正确性,直接决定了CPU能不能跑对。

来看一段我常用的译码器代码框架:

module decoder (
    input  [31:0] instr,
    output [6:0]  opcode,
    output [4:0]  rs1_addr,
    output [4:0]  rs2_addr,
    output [4:0]  rd_addr,
    output [2:0]  funct3,
    output [6:0]  funct7,
    output [31:0] imm
);

    assign opcode   = instr[6:0];
    assign rs1_addr = instr[19:15];
    assign rs2_addr = instr[24:20];
    assign rd_addr  = instr[11:7];
    assign funct3   = instr[14:12];
    assign funct7   = instr[31:25];

    // 立即数生成 - 这里只展示I型
    assign imm = { {21{instr[31]}}, instr[30:20] };

endmodule

嗯,这里要注意。立即数的生成,不同指令格式差别很大。I型指令的立即数是12位符号扩展,S型指令的立即数要拆成两段拼接。我刚开始写的时候,就因为在S型立即数拼接上搞错了位宽,仿真死活不对。后来我养成了一个习惯:每写一种立即数生成逻辑,立刻写一个testcase验证

个人经验:译码器最容易出bug的地方,就是立即数生成。建议你把每种指令格式的立即数位宽和拼接方式,画一张表格贴在显示器旁边。

4.2 寄存器堆设计

寄存器堆,就是CPU内部的那32个通用寄存器。x0到x31。

为什么叫“堆”?因为它是一堆寄存器组成的存储阵列。每个寄存器32位宽,一共32个,总共1024位。

寄存器堆的接口:

  • 两个读端口:读地址rs1、rs2,输出数据rd1、rd2
  • 一个写端口:写地址rd,写数据wd,写使能we
  • 时钟clk和复位rst

你可能会问:为什么是两个读端口?因为很多指令(比如加法)需要同时读取两个源操作数。单端口的话,就得花两个时钟周期去读,那就不是单周期CPU了。

来看代码:

module regfile (
    input         clk,
    input         rst,
    input  [4:0]  rs1,
    input  [4:0]  rs2,
    input  [4:0]  rd,
    input  [31:0] wd,
    input         we,
    output [31:0] rd1,
    output [31:0] rd2
);

    reg [31:0] regs [0:31];
    integer i;

    // 写操作
    always @(posedge clk or posedge rst) begin
        if (rst) begin
            for (i = 0; i < 32; i = i + 1)
                regs[i] <= 32'b0;
        end else if (we && (rd != 5'b0)) begin
            regs[rd] <= wd;
        end
    end

    // 读操作(组合逻辑)
    assign rd1 = (rs1 == 5'b0) ? 32'b0 : regs[rs1];
    assign rd2 = (rs2 == 5'b0) ? 32'b0 : regs[rs2];

endmodule

避坑指南:我曾经在项目中遇到过一个bug——读操作用了时序逻辑,导致读数据晚了一个周期。记住:寄存器堆的读操作必须是组合逻辑,写操作才是时序逻辑。另外,x0寄存器永远为0,写操作要忽略它。

这里有个细节:读端口为什么用组合逻辑?因为单周期CPU要求在一个时钟周期内完成“读寄存器→运算→写回”的全过程。如果读操作也占用一个周期,那整个流水就要多一拍,设计就复杂了。

4.3 ALU设计

ALU,算术逻辑单元。它就是CPU里的计算核心。

RISC-V的ALU需要支持哪些操作?

操作类型 指令示例 功能描述
加法 ADD, ADDI rs1 + rs2 / rs1 + imm
减法 SUB rs1 - rs2
按位与 AND, ANDI rs1 & rs2
按位或 OR, ORI rs1 | rs2
按位异或 XOR, XORI rs1 ^ rs2
左移 SLL, SLLI rs1 << rs2[4:0]
逻辑右移 SRL, SRLI rs1 >> rs2[4:0]
算术右移 SRA, SRAI rs1 >>> rs2[4:0]
小于比较 SLT, SLTI rs1 < rs2 ? 1 : 0

说白了,ALU就是一个多路选择器,根据控制信号选择执行哪种运算。

module alu (
    input  [31:0] a,
    input  [31:0] b,
    input  [3:0]  alu_ctrl,
    output reg [31:0] result,
    output zero
);

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

    always @(*) begin
        case (alu_ctrl)
            4'b0000: result = a + b;           // ADD
            4'b0001: result = a - b;           // SUB
            4'b0010: result = a & b;           // AND
            4'b0011: result = a | b;           // OR
            4'b0100: result = a ^ b;           // XOR
            4'b0101: result = a << b[4:0];    // SLL
            4'b0110: result = a >> b[4:0];    // SRL
            4'b0111: result = $signed(a) >>> b[4:0]; // SRA
            4'b1000: result = (a < b) ? 1 : 0; // SLT
            default: result = 32'b0;
        endcase
    end

endmodule

个人经验:写ALU时,减法可以用加法器加补码实现,但直接用减法器更清晰。另外,算术右移要用$signed()做类型转换,不然Verilog默认按逻辑右移处理。我当年就在这里吃过亏,仿真结果怎么都不对,查了半天才发现是符号位没扩展。

你可能会注意到,ALU里有个zero信号。这个信号在条件分支指令(BEQ、BNE等)中非常关键。分支指令需要判断两个操作数是否相等,zero信号直接告诉控制单元:结果为零,跳转吧。

4.4 三个模块的协同工作

好了,三个模块都设计完了。它们怎么配合?

  1. 取指:PC指向指令存储器,读出32位指令
  2. 译码:指令送到译码器,拆出rs1、rs2、rd、立即数、控制信号
  3. 读寄存器:rs1和rs2地址送到寄存器堆,读出操作数A和B
  4. 运算:操作数A、B和立即数(通过多路选择器)送到ALU,ALU根据控制信号计算结果
  5. 写回:ALU结果写回寄存器堆的rd地址

这个流程,就是单周期CPU的核心数据通路。你想想看,一个时钟周期内,所有事情都干完了。简单粗暴,但有效。

核心要点:单周期CPU的“单周期”含义,是指一条指令从取指到写回,所有操作在一个时钟周期内完成。这意味着时钟周期必须足够长,以保证最慢的指令也能完成。这也是单周期CPU性能受限的根本原因——我们后面讲流水线时会再聊这个。

嗯,这一章的内容就到这里。三个模块的代码,我建议你亲手敲一遍,不要复制粘贴。敲代码的过程中,你会自然发现一些细节问题,比如位宽匹配、符号扩展、组合逻辑和时序逻辑的区别。这些问题,看十遍书不如自己踩一次坑来得深刻。

记住:设计CPU,不是写软件,是在设计硬件。每一行Verilog代码,最终都会变成实实在在的门电路。想清楚再写,写清楚再仿真。


公众号:蓝海资料掘金营,微信deep3321