设计并实现一个8×8位寄存器文件(Register File),包含8个通用寄存器R0-R7。寄存器文件是CPU中暂存数据的核心部件,支持同时读取两个寄存器和写入一个寄存器。R7将作为堆栈指针使用。
寄存器文件是一组高速存储单元,直接嵌入CPU内部,是CPU执行指令时最快的数据存储方式。在我们的8位电脑中,8个8位寄存器构成了CPU的核心工作空间——几乎所有指令都需要读写寄存器。
| 寄存器 | 名称 | 用途 | 特殊说明 |
|---|---|---|---|
| R0 | 零寄存器 | 总是返回0 | 写入无效,硬连线为0 |
| R1 | 累加器A | 算术运算主操作数 | 指令隐含使用 |
| R2 | 累加器B | 辅助操作数 | 乘法/除法扩展 |
| R3 | 变址X | 变址寻址基址 | 类似6502的X |
| R4 | 变址Y | 变址寻址偏移 | 类似6502的Y |
| R5 | 通用 | 临时存储 | 自由使用 |
| R6 | 通用 | 临时存储 | 自由使用 |
| R7 | SP | 堆栈指针 | PUSH/POP自动更新 |
// ========================================
// 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
在写逻辑中,我们检查 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;
}
将寄存器文件扩展为16×8位(16个寄存器)。需要修改哪些信号位宽?R0的硬连线为0逻辑是否需要改变?思考为什么8个寄存器对8位CPU来说通常是足够的。
当读端口和写端口指向同一寄存器时,当前设计读到的是旧值。实现一个前写旁路:如果 rs1 == ws && we,则 rd1 直接输出 wd 而不是寄存器中的旧值。这在流水线CPU中非常重要。
添加一个调试端口,可以一次性输出所有8个寄存器的值。设计接口方案并实现。考虑:为什么实际CPU不提供这种端口?
达成条件:
奖励:你的CPU现在有了"记忆"——数据可以暂存、读取、修改。这是从组合逻辑到时序逻辑的关键跨越。
在真实的FPGA实现中,寄存器文件会被映射为不同的硬件资源,取决于综合工具的决策:
| 实现方式 | 资源类型 | 延迟 | 适用场景 |
|---|---|---|---|
| 分布式触发器 | D触发器 | 1周期 | 小寄存器组(<32项) |
| 分布式RAM | LUT RAM | 1周期 | 中等寄存器组 |
| 块RAM(BRAM) | Block RAM | 2周期 | 大寄存器组(>64项) |
我们的8×8=64位寄存器文件足够小,通常会被映射为D触发器。这意味着:
reg [7:0] regs [0:7] 视为一个8元素的数组,每个元素8位宽。综合工具在真正生成FPGA比特流时才决定资源映射。
虽然硬件层面R0-R6是通用寄存器,但软件需要约定寄存器的使用规则。我们的调用约定(类似ARM的ABI):
| 寄存器 | 名称 | 调用者保存 | 被调用者保存 | 说明 |
|---|---|---|---|---|
| R0 | 零 | — | — | 硬连线0,无需保存 |
| R1 | A | ✅ | 函数参数1/返回值 | |
| R2 | B | ✅ | 函数参数2 | |
| R3 | X | ✅ | 临时/参数3 | |
| R4 | Y | ✅ | 被调用者必须保存 | |
| R5 | GP1 | ✅ | 被调用者必须保存 | |
| R6 | GP2 | ✅ | 被调用者必须保存 | |
| R7 | SP | — | — | 栈指针,函数进出时维护 |
调用者保存:调用函数前,调用者负责保存这些寄存器(如果需要保留值)。
被调用者保存:被调用的函数如果使用这些寄存器,必须在使用前保存、返回前恢复。