第4章:取指阶段(IF)——程序计数器与指令存储器的实现
各位同学,欢迎来到第四章。从这一章开始,我们正式进入流水线的第一级——取指阶段(Instruction Fetch,简称IF)。
取指阶段是处理器最基础、最核心的环节。说白了,CPU每时每刻都在做三件事:取指令、译指令、执行指令。而取指,就是这一切的起点。
我个人习惯把取指阶段拆成三个部分来看:
- 程序计数器(PC)——决定下一条指令的地址
- 指令存储器(IMEM)——存放指令的地方
- 取指逻辑——把PC和IMEM串起来的控制逻辑
好,我们一个一个来啃。
4.1 程序计数器(PC)的实现
程序计数器,就是那个永远指向下一条指令地址的寄存器。它每周期加4(RISC-V是32位,一条指令4字节),除非遇到跳转或分支。
我在项目中遇到过一个问题:PC的更新时机。如果你在时钟上升沿更新PC,那取指逻辑必须用更新前的值。这个时序关系一旦搞错,整个流水线就乱套了。
核心要点:PC在每个时钟上升沿更新,更新值为当前PC + 4(默认情况)。
来看看最简单的PC模块长什么样:
module pc_reg (
input wire clk,
input wire rst_n,
input wire [31:0] pc_next, // 下一个PC值
output reg [31:0] pc_current // 当前PC值
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
pc_current <= 32'h00000000; // 复位后从0开始
else
pc_current <= pc_next;
end
endmodule
嗯,这里要注意:复位后PC从0开始,这是RISC-V的约定。但有些设计会把复位向量放在0x80000000,那就要改复位值了。
4.2 指令存储器(IMEM)的建模
指令存储器,就是存放程序代码的ROM(只读存储器)。在仿真阶段,我们通常用$readmemh系统函数把机器码加载进去。
你想想看,真实的芯片里指令存储器是SRAM或ROM,但仿真时我们只需要一个行为模型。说白了,就是一块只读的寄存器数组。
个人经验:我建议把IMEM设计成字节可寻址的,虽然RISC-V指令是4字节对齐,但后续处理数据访问时,字节寻址更灵活。
来看一个典型的IMEM实现:
module imem (
input wire [31:0] addr, // 指令地址
output wire [31:0] inst // 取出的指令
);
// 定义存储器大小:4KB = 1024条指令
reg [31:0] mem [0:1023];
// 初始化:从文件加载机器码
initial begin
$readmemh("inst.hex", mem);
end
// 组合逻辑读取:地址右移2位(因为按字节寻址,指令4字节对齐)
assign inst = mem[addr[31:2]];
endmodule
这里有个细节:addr[31:2] 是什么意思?因为地址是字节地址,而我们的存储器是按字(4字节)组织的。所以地址要右移2位,相当于除以4。
避坑指南:我曾经犯过一个错误——忘记处理地址未对齐的情况。虽然RISC-V要求指令必须4字节对齐,但万一外部传入的地址不是4的倍数,取出的指令就是错的。建议在取指逻辑中加入地址对齐检查。
4.3 取指逻辑的Verilog实现
取指逻辑,就是把PC和IMEM串起来的控制逻辑。它要决定:下一周期PC是多少?当前周期取哪条指令?
在单周期处理器中,取指逻辑很简单:PC每周期加4。但在流水线中,还要考虑分支、跳转、异常等情况。
我们先从最简单的开始——无分支的取指逻辑:
module if_stage (
input wire clk,
input wire rst_n,
output wire [31:0] inst, // 取出的指令
output wire [31:0] pc_plus4 // PC+4,供后续阶段使用
);
// PC相关信号
wire [31:0] pc_current;
wire [31:0] pc_next;
// PC寄存器实例化
pc_reg u_pc_reg (
.clk (clk),
.rst_n (rst_n),
.pc_next (pc_next),
.pc_current(pc_current)
);
// 指令存储器实例化
imem u_imem (
.addr(pc_current),
.inst(inst)
);
// 计算下一个PC:默认加4
assign pc_next = pc_current + 32'd4;
assign pc_plus4 = pc_current + 32'd4;
endmodule
这个模块把PC和IMEM连起来了。每个时钟周期,PC更新,IMEM输出新指令。
但实际项目中,PC的更新没那么简单。比如遇到分支指令,PC要跳转到目标地址。这时候就需要一个多路选择器:
// 带分支支持的PC更新逻辑
assign pc_next = branch_taken ? branch_target :
jump_taken ? jump_target :
pc_current + 32'd4;
我个人习惯把分支判断放在执行阶段(EX),然后通过流水线反馈回来。这样取指阶段只需要一个简单的选择器。
4.4 仿真验证
写完了代码,不仿真等于白写。我们来写个简单的testbench:
module tb_if_stage;
reg clk;
reg rst_n;
wire [31:0] inst;
wire [31:0] pc_plus4;
// 时钟生成
initial begin
clk = 0;
forever #5 clk = ~clk; // 10ns周期
end
// 复位
initial begin
rst_n = 0;
#20;
rst_n = 1;
end
// 实例化
if_stage u_if_stage (
.clk (clk),
.rst_n (rst_n),
.inst (inst),
.pc_plus4(pc_plus4)
);
// 监控输出
initial begin
$monitor("Time=%0t, PC=%h, Inst=%h, PC+4=%h",
$time, u_if_stage.pc_current, inst, pc_plus4);
#200;
$finish;
end
endmodule
仿真结果应该是:PC从0开始,每10ns(一个时钟周期)加4,依次取出指令。
验证要点:
- 复位后PC是否为0?
- 每个时钟周期PC是否加4?
- 取出的指令是否与存储器中对应地址的内容一致?
4.5 本章小结
取指阶段是整个处理器的入口。我们实现了:
- PC寄存器:每周期更新,默认加4
- 指令存储器:用$readmemh加载程序,组合逻辑读取
- 取指逻辑:把PC和IMEM串联,输出指令和PC+4
下一章,我们会进入译码阶段(ID),看看取出来的指令到底是什么意思。到时候你会发现,取指阶段虽然简单,但它是整个流水线的节拍器——所有阶段都跟着PC的节奏走。
好,今天的课就到这里。记得动手仿真一下,看看波形对不对。有问题随时交流。