4. 处理器微架构设计(一):单周期CPU数据通路设计、ALU设计、寄存器文件设计
各位同学,欢迎来到处理器微架构设计的第一站。
说实话,很多初学者一听到「微架构」三个字就头大。觉得那是大神才碰的东西。其实没那么玄乎。你想想看,处理器本质上就是个复杂的有限状态机,加上一堆数据通路。今天我们就从最简单的单周期CPU开始,把它的骨架搭起来。
4.1 单周期CPU:最朴素的实现方式
什么叫单周期?说白了,就是一条指令从取指、译码、执行、访存到写回,全部在一个时钟周期内完成。听起来很暴力对吧?
我在刚入行时,带我的老工程师跟我说:「单周期CPU虽然慢,但它是最清晰的教学模型。你把这个搞懂了,后面搞流水线就是水到渠成的事。」后来我发现,他说得真对。
核心要点:单周期CPU的CPI(每指令周期数)恒为1。但它的时钟频率受限于最慢的那条指令——通常是load指令,因为它要经历完整的五级操作。
单周期CPU的数据通路,我习惯把它拆成五个阶段来看:
- 取指(IF):从指令存储器中取出当前PC指向的指令
- 译码(ID):解析指令,生成控制信号,读取寄存器
- 执行(EX):ALU干活,计算地址或算术结果
- 访存(MEM):读写数据存储器(仅load/store需要)
- 写回(WB):将结果写回寄存器文件
嗯,这里要注意:单周期里这些阶段是串行完成的,所以组合逻辑路径特别长。这也是为什么现代处理器没人用纯单周期——太慢了。
4.2 数据通路整体结构
我们先画一张整体框架图,把各个模块之间的关系理清楚。
这张图我故意画得比较简洁。实际项目中,数据通路上会有大量的多路选择器(MUX),用来在不同指令间切换数据来源。比如写回寄存器时,你要决定是写ALU结果还是写存储器读出的数据。
我的经验:刚开始设计数据通路时,别急着写代码。先在纸上把数据流画清楚。我见过太多人一上来就写Verilog,结果写到一半发现通路不通,回头改架构,浪费时间。
4.3 ALU设计:处理器的计算核心
ALU(算术逻辑单元)是处理器里最忙的模块。每条指令几乎都要经过它——要么做算术运算,要么算地址。
对于RISC-V,我们至少需要支持以下操作:
| 操作码 | 功能 | 说明 |
|---|---|---|
| ADD | 加法 | rs1 + rs2 |
| SUB | 减法 | rs1 - rs2 |
| AND | 按位与 | rs1 & rs2 |
| OR | 按位或 | rs1 | rs2 |
| XOR | 按位异或 | rs1 ^ rs2 |
| SLT | 有符号小于 | rs1 < rs2 ? 1 : 0 |
| SLTU | 无符号小于 | 同上,无符号比较 |
| SLL | 逻辑左移 | rs1 << rs2[4:0] |
| SRL | 逻辑右移 | rs1 >> rs2[4:0] |
| SRA | 算术右移 | 高位补符号位 |
下面是我常用的ALU Verilog代码。注意,我习惯用组合逻辑实现,因为单周期里ALU必须在同一个周期内出结果。
module alu (
input [31:0] a, // 操作数1
input [31:0] b, // 操作数2
input [3:0] alu_ctrl, // 控制信号
output reg [31:0] result,
output reg zero // 结果为0时拉高
);
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 = ($signed(a) < $signed(b)) ? 32'd1 : 32'd0; // SLT
4'b0110: result = (a < b) ? 32'd1 : 32'd0; // SLTU
4'b0111: result = a << b[4:0]; // SLL
4'b1000: result = a >> b[4:0]; // SRL
4'b1001: result = $signed(a) >>> b[4:0]; // SRA
default: result = 32'd0;
endcase
zero = (result == 32'd0);
end
endmodule
避坑指南:我曾经在SRA(算术右移)上栽过跟头。Verilog的 >>> 操作符只有在操作数声明为signed时才会做符号扩展。如果你直接用 wire [31:0] a,那么 a >>> b 实际上还是逻辑右移。记得用 $signed() 转换,或者把a声明为 signed wire。
4.4 寄存器文件设计:数据的临时仓库
寄存器文件是处理器的本地存储。RISC-V架构有32个通用寄存器,每个32位宽(RV32)。
设计要点:
- 两个读端口,一个写端口:这是RISC-V指令集的要求。大多数指令需要读两个源寄存器(rs1, rs2),写一个目标寄存器(rd)。
- x0寄存器硬连线为0:这是RISC-V的一个巧妙设计。x0永远读出0,写入被忽略。省掉了一条「清零」指令。
- 写优先 vs 读优先:如果在同一个周期内读写同一地址,写优先模式会先写入再读出新值;读优先模式则读出旧值。RISC-V规范要求采用写优先。
来看代码:
module regfile (
input clk,
input rst_n,
input [4:0] rs1_addr, // 读端口1地址
input [4:0] rs2_addr, // 读端口2地址
input [4:0] rd_addr, // 写端口地址
input [31:0] rd_data, // 写数据
input we, // 写使能
output [31:0] rs1_data, // 读端口1数据
output [31:0] rs2_data // 读端口2数据
);
reg [31:0] regs [0:31]; // 32个32位寄存器
// 写操作(时序逻辑)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
integer i;
for (i = 0; i < 32; i = i + 1)
regs[i] <= 32'd0;
end else if (we && (rd_addr != 5'd0)) begin
regs[rd_addr] <= rd_data;
end
end
// 读操作(组合逻辑)
// 注意:x0永远返回0
assign rs1_data = (rs1_addr == 5'd0) ? 32'd0 : regs[rs1_addr];
assign rs2_data = (rs2_addr == 5'd0) ? 32'd0 : regs[rs2_addr];
endmodule
关键设计决策:读操作我用组合逻辑实现,这样在同一个周期内就能拿到数据。如果读操作也做成时序的(加一级寄存器),那单周期CPU的时序就绷不住了——你得多等一个周期才能读到寄存器值。
4.5 把它们连起来:数据通路的整合
有了ALU和寄存器文件,再加上PC、指令存储器、控制单元,我们就可以搭出一个能跑R型指令(如add, sub, and, or)的单周期CPU了。
控制单元负责根据指令的opcode和funct3/funct7字段,生成一系列控制信号:
- RegWrite:是否写寄存器
- ALUSrc:ALU的第二个操作数来自寄存器还是立即数
- ALUCtrl:选择ALU执行哪种运算
- MemWrite:是否写数据存储器
- MemToReg:写回寄存器的数据来自ALU还是存储器
我习惯把这些控制信号做成一个查找表。不同的指令类型对应不同的控制信号组合。这样代码结构清晰,也方便后期扩展。
我的习惯:写控制单元时,我会先列一个Excel表格,把每条指令对应的控制信号填进去。然后对着表格写case语句。这样不容易漏掉某个指令,也方便review。
4.6 单周期CPU的局限性
讲到这里,我得泼点冷水。单周期CPU虽然好理解,但实际项目中几乎没人用。原因很简单:
- 时钟频率上不去:最慢的指令(load)决定了整个系统的时钟周期。其他指令明明可以跑得更快,却被拖累了。
- 资源利用率低:每个模块在每个周期只工作一小段时间,大部分时间在空转。
- 不支持流水线:无法通过指令级并行提高吞吐量。
我记得有一次做FPGA原型验证,用单周期CPU跑一个简单的排序算法,结果时钟频率只能跑到25MHz。换成5级流水线后,直接飙到100MHz。差距就是这么大。
但话说回来,单周期CPU是理解处理器工作原理的最佳起点。你把它吃透了,后面学流水线、乱序执行、分支预测,都会轻松很多。