第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的节奏走。

好,今天的课就到这里。记得动手仿真一下,看看波形对不对。有问题随时交流。