第4章:单周期数据通路设计:取指、译码、执行、访存、写回五阶段模型
各位同学,今天我们来聊聊单周期数据通路。说白了,就是一条指令在一个时钟周期内走完取指、译码、执行、访存、写回这五个阶段。听起来很简单对吧?但这里面的门道,我当年可是踩过不少坑的。
4.1 五阶段模型概览
先给大家画个轮廓。这五个阶段,每个都有自己明确的任务:
- 取指(IF):从指令存储器中取出指令
- 译码(ID):解析指令,读取寄存器
- 执行(EX):ALU干活,算地址或做运算
- 访存(MEM):读写数据存储器
- 写回(WB):把结果写回寄存器堆
你想想看,单周期意味着什么?意味着所有阶段必须在同一个时钟边沿之前完成。我刚开始做RISC-V处理器时,就因为这个理解不到位,导致时序收敛出了问题。
核心要点:单周期数据通路中,组合逻辑路径长度决定了最大时钟频率。路径越短,频率越高。
4.2 取指阶段(IF)设计
取指阶段,说白了就是PC(程序计数器)干活。PC指向当前指令地址,然后从指令存储器里读出指令。
我个人习惯把PC设计成带使能的寄存器。为什么?因为遇到流水线停顿或异常时,我们需要冻结PC。来看个简单的Verilog代码:
module if_stage (
input clk,
input rst_n,
input pc_en, // PC使能
input [31:0] pc_next,
output [31:0] pc,
output [31:0] instr
);
// PC寄存器
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n)
pc <= 32'h0;
else if (pc_en)
pc <= pc_next;
end
// 指令存储器(简单ROM模型)
instr_mem u_instr_mem (
.addr(pc),
.dout(instr)
);
endmodule
嗯,这里要注意:指令存储器的地址必须是字对齐的。我记得有次调试,发现取出来的指令全是乱的,查了半天才发现是地址没对齐。
4.3 译码阶段(ID)设计
译码阶段要做两件事:解析指令操作码,读取寄存器堆。这里有个关键点——寄存器堆的读端口是组合逻辑还是时序逻辑?
我建议用组合逻辑读端口。为什么?因为单周期内要完成读操作,时序逻辑会多一个周期延迟。我曾经在一个项目里用了时序读端口,结果整个数据通路时序全乱了。
module id_stage (
input [31:0] instr,
input [31:0] wb_data, // 写回数据
input [4:0] wb_addr, // 写回地址
input wb_en, // 写回使能
output [31:0] rs1_data,
output [31:0] rs2_data,
output [31:0] imm // 立即数
);
// 寄存器堆
regfile u_regfile (
.clk(clk),
.raddr1(instr[19:15]),
.raddr2(instr[24:20]),
.rdata1(rs1_data),
.rdata2(rs2_data),
.waddr(wb_addr),
.wdata(wb_data),
.wen(wb_en)
);
// 立即数生成
imm_gen u_imm_gen (
.instr(instr),
.imm(imm)
);
endmodule
避坑指南:我曾经在译码阶段忘记处理x0寄存器。x0是硬连线的0,读它必须返回0,写它必须被忽略。这个坑,很多新手都会踩。
4.4 执行阶段(EX)设计
执行阶段是ALU的主场。加减乘除、与或非、移位,全在这里完成。但单周期里,ALU的运算时间直接决定了时钟周期。
举个例子,乘法器如果用组合逻辑实现,延迟会很大。我建议对于复杂运算,要么拆成多周期,要么用流水线乘法器。单周期里,老老实实用加法器和移位器就够了。
module ex_stage (
input [31:0] rs1_data,
input [31:0] rs2_data,
input [31:0] imm,
input [3:0] alu_ctrl, // ALU控制信号
output [31:0] alu_result,
output zero_flag
);
// ALU核心
always_comb begin
case (alu_ctrl)
4'b0000: alu_result = rs1_data + rs2_data; // ADD
4'b0001: alu_result = rs1_data - rs2_data; // SUB
4'b0010: alu_result = rs1_data & rs2_data; // AND
4'b0011: alu_result = rs1_data | rs2_data; // OR
4'b0100: alu_result = rs1_data ^ rs2_data; // XOR
4'b0101: alu_result = rs1_data << rs2_data[4:0]; // SLL
default: alu_result = 32'h0;
endcase
end
assign zero_flag = (alu_result == 32'h0);
endmodule
你想想看,如果ALU里放一个64位乘法器,那这个周期得多长?所以,单周期设计里,ALU的运算类型要精挑细选。
4.5 访存阶段(MEM)设计
访存阶段只对load/store指令有意义。其他指令,比如加法、逻辑运算,访存阶段就是透明的——数据直接旁路过去。
数据存储器的设计,我建议用同步写、异步读。为什么?因为单周期内,写操作必须在时钟上升沿完成,而读操作可以随时进行。
module mem_stage (
input clk,
input mem_write, // 写使能
input mem_read, // 读使能
input [31:0] mem_addr,
input [31:0] mem_wdata,
output [31:0] mem_rdata
);
// 数据存储器
data_mem u_data_mem (
.clk(clk),
.we(mem_write),
.addr(mem_addr),
.wdata(mem_wdata),
.rdata(mem_rdata)
);
endmodule
注意:数据存储器的地址必须对齐。我曾经遇到一个bug,因为地址没对齐,导致load指令读出来的数据全是错的。调试了整整两天才发现问题。
4.6 写回阶段(WB)设计
写回阶段,就是把结果写回寄存器堆。结果可能来自ALU,也可能来自数据存储器。所以需要一个多路选择器。
我个人习惯把写回逻辑放在译码阶段一起处理。为什么?因为寄存器堆的写端口在译码阶段,这样数据流更紧凑。但要注意,写回的数据必须在这个时钟周期内稳定。
module wb_stage (
input [31:0] alu_result,
input [31:0] mem_rdata,
input reg_wb_sel, // 0: ALU结果, 1: 存储器数据
output [31:0] wb_data
);
// 写回数据选择
assign wb_data = reg_wb_sel ? mem_rdata : alu_result;
endmodule
4.7 五阶段整合与关键路径分析
好了,五个阶段都讲完了。现在把它们串起来,看看关键路径在哪里。
单周期数据通路的关键路径,通常是从PC出发,经过指令存储器、寄存器堆读端口、ALU、数据存储器,最后到寄存器堆写端口。这条路径有多长?
| 阶段 | 典型延迟(ns) | 说明 |
|---|---|---|
| 取指 | 2.0 | 指令存储器读延迟 |
| 译码 | 1.5 | 寄存器堆读延迟 + 立即数生成 |
| 执行 | 3.0 | ALU运算延迟(含加法器) |
| 访存 | 2.0 | 数据存储器读延迟 |
| 写回 | 0.5 | 多路选择器延迟 |
| 总计 | 9.0 | 加上寄存器建立时间约10ns |
看到没?总延迟约10ns,意味着最大时钟频率只有100MHz。这就是单周期的瓶颈。我当年做的一个项目,目标频率是200MHz,单周期根本跑不到,最后只能改成流水线。
总结:单周期数据通路设计简单直观,但性能受限。适合教学演示或低频率应用。实际项目中,我们通常用流水线来提升频率。
4.8 课后思考
最后留几个问题,大家想想:
- 如果ALU里放一个32位乘法器,关键路径会变成多少?
- 单周期里,分支指令怎么处理?PC的更新逻辑要改吗?
- 为什么寄存器堆的写操作必须用时钟同步?
这些问题,我在下一章讲流水线时会用到。大家先琢磨琢磨。
个人经验:单周期设计虽然简单,但它是理解流水线的基础。我建议每个做数字前端设计的同学,都亲手搭一遍单周期数据通路。踩过坑,才能真正理解流水线的价值。