💾 第03课:寄存器文件

📖 本课目标

设计并实现一个8×8位寄存器文件(Register File),包含8个通用寄存器R0-R7。寄存器文件是CPU中暂存数据的核心部件,支持同时读取两个寄存器和写入一个寄存器。R7将作为堆栈指针使用。

🧠 寄存器文件是什么?

寄存器文件是一组高速存储单元,直接嵌入CPU内部,是CPU执行指令时最快的数据存储方式。在我们的8位电脑中,8个8位寄存器构成了CPU的核心工作空间——几乎所有指令都需要读写寄存器。

寄存器文件接口 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ┌──────────────────────┐ rs1[2:0]────┤ ├──── rd1[7:0] 读端口1 │ │ rs2[2:0]────┤ 寄存器文件 ├──── rd2[7:0] 读端口2 │ │ ws[2:0]─────┤ R0=00000000 │ │ R1=00000000 │ wd[7:0]─────┤ R2=00000000 │ │ R3=00000000 │ we ─────────┤ R4=00000000 │ │ R5=00000000 │ clk ────────┤ R6=00000000 │ │ R7=7FFF(SP) │ rst_n ──────┤ │ └──────────────────────┘ 读写规则: · 读操作:组合逻辑,即时输出 · 写操作:时钟上升沿触发 · 写使能(we):为1时在时钟沿写入 · R7初始化为0x7F(堆栈指针)

📐 寄存器分配策略

寄存器名称用途特殊说明
R0零寄存器总是返回0写入无效,硬连线为0
R1累加器A算术运算主操作数指令隐含使用
R2累加器B辅助操作数乘法/除法扩展
R3变址X变址寻址基址类似6502的X
R4变址Y变址寻址偏移类似6502的Y
R5通用临时存储自由使用
R6通用临时存储自由使用
R7SP堆栈指针PUSH/POP自动更新
💡 R0硬连线为0是MIPS架构的经典设计。将R0固定为0有很多好处:不需要专门的清零指令(MOV R0, Rx等效),条件判断也简化了。这个设计选择在我们的8位CPU中同样有价值。

🔧 Verilog实现

// ========================================
// regfile.v - 8×8位寄存器文件
// Retro8 复古电脑 CPU 核心模块
// ✅Verilator验证通过
// ========================================

module regfile (
    input  wire          clk,      // 系统时钟
    input  wire          rst_n,    // 低有效复位
    input  wire [2:0]   rs1,      // 读端口1选择
    input  wire [2:0]   rs2,      // 读端口2选择
    input  wire [2:0]   ws,       // 写端口选择
    input  wire [7:0]   wd,       // 写数据
    input  wire          we,       // 写使能
    output wire [7:0]   rd1,      // 读端口1输出
    output wire [7:0]   rd2       // 读端口2输出
);

    // 8个8位寄存器
    reg [7:0] regs [0:7];

    // 复位初始化
    integer i;
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            for (i = 0; i < 8; i = i + 1)
                regs[i] <= 8'd0;
            // R7(SP)初始化为栈顶地址
            regs[7] <= 8'h7F;
        end
        else if (we && (ws != 3'd0)) begin
            // 写使能有效且目标非R0时写入
            regs[ws] <= wd;
        end
    end

    // 读端口1:R0硬连线为0
    assign rd1 = (rs1 == 3'd0) ? 8'd0 : regs[rs1];
    // 读端口2:R0硬连线为0
    assign rd2 = (rs2 == 3'd0) ? 8'd0 : regs[rs2];

endmodule

🔍 设计要点分析

R0硬连线为0的实现

在写逻辑中,我们检查 ws != 3'd0,阻止对R0的写入。在读逻辑中,当选择R0时直接输出0。这意味着R0始终为0,无论发生什么操作。

// 写保护:R0不可写
else if (we && (ws != 3'd0)) begin
    regs[ws] <= wd;
end

// 读保护:R0读出为0
assign rd1 = (rs1 == 3'd0) ? 8'd0 : regs[rs1];

读写同时进行时的冲突

当同一条指令需要读和写同一个寄存器时(如 ADD R1, R2,结果写回R1),先读后写是关键。由于我们使用的是时钟上升沿写入,而读是组合逻辑,所以在同一个时钟周期内:

这就是经典的"写后读"(RAW)冒险,但在寄存器文件层面,我们的设计自然地处理了它。

寄存器文件的硬件实现选择

在FPGA实现中,寄存器文件通常映射到分布式RAM或块RAM。但我们的设计用 reg 数组,综合工具会根据规模选择最优实现:

Verilator仿真不关心底层实现,只验证逻辑正确性。

🧪 仿真验证

// tb_regfile.cpp - 寄存器文件测试台
#include "Vregfile.h"
#include "verilated.h"
#include <cstdio>

int main(int argc, char** argv) {
    Verilated::commandArgs(argc, argv);
    Vregfile* rf = new Vregfile;
    int errors = 0;

    // 复位
    rf->rst_n = 0; rf->clk = 0; rf->we = 0;
    for (int i = 0; i < 10; i++) {
        rf->clk = !rf->clk; rf->eval();
    }
    rf->rst_n = 1;

    // 验证复位后R0=0
    rf->rs1 = 0; rf->eval();
    if (rf->rd1 != 0) { printf("FAIL: R0 after reset = %d
", rf->rd1); errors++; }
    else printf("PASS: R0 = 0 after reset
");

    // 验证R7(SP)初始化
    rf->rs1 = 7; rf->eval();
    if (rf->rd1 != 0x7F) { printf("FAIL: R7(SP) = %d
", rf->rd1); errors++; }
    else printf("PASS: R7(SP) = 0x7F
");

    // 写入R1=42
    rf->ws = 1; rf->wd = 42; rf->we = 1;
    rf->clk = 1; rf->eval(); rf->clk = 0; rf->eval();
    rf->we = 0;

    // 读回R1
    rf->rs1 = 1; rf->eval();
    if (rf->rd1 != 42) { printf("FAIL: R1 = %d (expected 42)
", rf->rd1); errors++; }
    else printf("PASS: R1 = 42
");

    // 尝试写入R0(应该被忽略)
    rf->ws = 0; rf->wd = 99; rf->we = 1;
    rf->clk = 1; rf->eval(); rf->clk = 0; rf->eval();
    rf->we = 0;
    rf->rs1 = 0; rf->eval();
    if (rf->rd1 != 0) { printf("FAIL: R0 writable! = %d
", rf->rd1); errors++; }
    else printf("PASS: R0 still 0 after write attempt
");

    // 双端口同时读
    rf->rs1 = 1; rf->rs2 = 7; rf->eval();
    printf("Dual read: R1=%d, R7=%d
", rf->rd1, rf->rd2);

    printf("
Errors: %d
", errors);
    if (errors == 0) printf("ALL TESTS PASSED! ✅
");

    rf->final(); delete rf;
    return errors ? 1 : 0;
}

📝 练习

练习1:扩展寄存器文件

将寄存器文件扩展为16×8位(16个寄存器)。需要修改哪些信号位宽?R0的硬连线为0逻辑是否需要改变?思考为什么8个寄存器对8位CPU来说通常是足够的。

练习2:前写旁路(Write-Forwarding)

当读端口和写端口指向同一寄存器时,当前设计读到的是旧值。实现一个前写旁路:如果 rs1 == ws && we,则 rd1 直接输出 wd 而不是寄存器中的旧值。这在流水线CPU中非常重要。

练习3:寄存器组视图

添加一个调试端口,可以一次性输出所有8个寄存器的值。设计接口方案并实现。考虑:为什么实际CPU不提供这种端口?

🏆 成就解锁

💾 寄存器守护者

达成条件:

奖励:你的CPU现在有了"记忆"——数据可以暂存、读取、修改。这是从组合逻辑到时序逻辑的关键跨越。

🔬 寄存器文件的硬件映射

在真实的FPGA实现中,寄存器文件会被映射为不同的硬件资源,取决于综合工具的决策:

FPGA资源映射

实现方式资源类型延迟适用场景
分布式触发器D触发器1周期小寄存器组(<32项)
分布式RAMLUT RAM1周期中等寄存器组
块RAM(BRAM)Block RAM2周期大寄存器组(>64项)

我们的8×8=64位寄存器文件足够小,通常会被映射为D触发器。这意味着:

💡 Verilator中的实现:Verilator仿真不关心底层硬件映射。它将 reg [7:0] regs [0:7] 视为一个8元素的数组,每个元素8位宽。综合工具在真正生成FPGA比特流时才决定资源映射。

📊 寄存器使用约定(调用约定)

虽然硬件层面R0-R6是通用寄存器,但软件需要约定寄存器的使用规则。我们的调用约定(类似ARM的ABI):

Retro8 调用约定

寄存器名称调用者保存被调用者保存说明
R0硬连线0,无需保存
R1A函数参数1/返回值
R2B函数参数2
R3X临时/参数3
R4Y被调用者必须保存
R5GP1被调用者必须保存
R6GP2被调用者必须保存
R7SP栈指针,函数进出时维护

调用者保存:调用函数前,调用者负责保存这些寄存器(如果需要保留值)。

被调用者保存:被调用的函数如果使用这些寄存器,必须在使用前保存、返回前恢复。