4. 单周期CPU设计(上):指令译码单元设计、寄存器堆设计、ALU设计
好,咱们开始动手了。
前面几章我们把RISC-V的基础指令集和整体架构聊了一遍。现在,是时候真正打开Vivado,敲下第一行Verilog代码了。
这一章,我们聚焦单周期CPU的三个核心模块:指令译码单元、寄存器堆和ALU。这三个模块,说白了就是CPU的“大脑翻译官”、“临时记事本”和“计算器”。
我个人习惯,设计任何模块之前,先画一张结构图,把数据流理清楚。不然代码写到一半,很容易把自己绕进去。
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 三个模块的协同工作
好了,三个模块都设计完了。它们怎么配合?
- 取指:PC指向指令存储器,读出32位指令
- 译码:指令送到译码器,拆出rs1、rs2、rd、立即数、控制信号
- 读寄存器:rs1和rs2地址送到寄存器堆,读出操作数A和B
- 运算:操作数A、B和立即数(通过多路选择器)送到ALU,ALU根据控制信号计算结果
- 写回:ALU结果写回寄存器堆的rd地址
这个流程,就是单周期CPU的核心数据通路。你想想看,一个时钟周期内,所有事情都干完了。简单粗暴,但有效。
核心要点:单周期CPU的“单周期”含义,是指一条指令从取指到写回,所有操作在一个时钟周期内完成。这意味着时钟周期必须足够长,以保证最慢的指令也能完成。这也是单周期CPU性能受限的根本原因——我们后面讲流水线时会再聊这个。
嗯,这一章的内容就到这里。三个模块的代码,我建议你亲手敲一遍,不要复制粘贴。敲代码的过程中,你会自然发现一些细节问题,比如位宽匹配、符号扩展、组合逻辑和时序逻辑的区别。这些问题,看十遍书不如自己踩一次坑来得深刻。
记住:设计CPU,不是写软件,是在设计硬件。每一行Verilog代码,最终都会变成实实在在的门电路。想清楚再写,写清楚再仿真。
公众号:蓝海资料掘金营,微信deep3321