4、寄存器堆设计(二):双端口寄存器堆的RTL实现、读写冲突处理(写后读、读后写)、仿真验证
好,咱们接着聊寄存器堆。上一章我们把单端口寄存器堆的底子打好了,这一章要上点硬菜——双端口寄存器堆。说白了,就是允许你在同一个时钟周期里,同时读两个地址、写一个地址。这在CPU的寄存器文件、DSP的数据通路里太常见了。
我个人习惯把双端口寄存器堆分成两类:真正双端口(两个读口+两个写口)和伪双端口(两个读口+一个写口)。咱们今天重点讲后者,因为它在实际项目里出现频率最高,而且读写冲突的问题也最典型。
4.1 双端口寄存器堆的RTL实现
先看一个最简单的双端口寄存器堆结构。它有两个读地址端口(raddr1、raddr2),一个写地址端口(waddr),以及对应的读数据输出和写数据输入。
module dp_regfile #(
parameter DEPTH = 16,
parameter DWIDTH = 32
)(
input logic clk,
input logic rst_n,
// 写端口
input logic we,
input logic [$clog2(DEPTH)-1:0] waddr,
input logic [DWIDTH-1:0] wdata,
// 读端口1
input logic [$clog2(DEPTH)-1:0] raddr1,
output logic [DWIDTH-1:0] rdata1,
// 读端口2
input logic [$clog2(DEPTH)-1:0] raddr2,
output logic [DWIDTH-1:0] rdata2
);
logic [DWIDTH-1:0] mem [DEPTH];
// 写操作
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < DEPTH; i++) mem[i] <= '0;
end else if (we) begin
mem[waddr] <= wdata;
end
end
// 读操作(组合逻辑读)
assign rdata1 = mem[raddr1];
assign rdata2 = mem[raddr2];
endmodule
嗯,这里要注意:读操作我用了组合逻辑直接读。这样做的好处是读延迟小,但坏处是——读数据会随地址变化立即变化,这在某些时序紧张的场合可能带来问题。我在项目中遇到过,组合读路径太长导致时序违例,后来改成了寄存器输出读。
4.2 读写冲突处理
双端口寄存器堆最头疼的问题来了——读写冲突。你想想看,如果同一个时钟周期里,你既想写地址A,又想读地址A,那读出来的数据是旧值还是新值?
这取决于你的设计策略。常见的处理方式有两种:
- 写后读(Write After Read):读操作先拿到旧值,写操作再写入新值。读到的数据是写之前的旧数据。
- 读后写(Read After Write):写操作先完成,读操作拿到新写入的数据。读到的数据是写之后的新数据。
说白了,这就是一个优先级问题。写后读相当于读优先,读后写相当于写优先。
4.2.1 写后读(读优先)
这种策略下,如果读写地址相同,读操作返回的是旧数据。实现起来很简单——读操作直接走组合逻辑,写操作在时钟上升沿更新。因为读是组合的,写是时序的,所以读永远看到的是写之前的值。
// 写后读:读优先
assign rdata1 = (we && raddr1 == waddr) ? mem[raddr1] : mem[raddr1];
// 注意:这里读到的就是旧值,因为mem还没更新
等等,上面这行代码其实有点多余。因为组合读本身看到的就是旧值,不需要额外处理。但为了代码可读性,我建议显式写出来:
assign rdata1 = (we && raddr1 == waddr) ? mem[raddr1] : mem[raddr1];
// 其实两种分支结果一样,但明确告诉读者:我们处理了冲突
4.2.2 读后写(写优先)
这种策略下,如果读写地址相同,读操作返回的是新写入的数据。实现时需要做写数据旁路(bypass)。
// 读后写:写优先
always_comb begin
if (we && raddr1 == waddr) begin
rdata1 = wdata; // 旁路:直接输出写数据
end else begin
rdata1 = mem[raddr1];
end
end
我曾经在一个DSP项目中用过这种策略。当时的需求是:写寄存器后立即读,必须读到新值。如果不做旁路,读到的还是旧值,整个流水线就乱了。嗯,那次调试花了我整整一个下午。
4.3 仿真验证
写完了RTL,不验证等于白写。双端口寄存器堆的验证重点就是读写冲突场景。我一般会写一个简单的testbench,覆盖以下几种情况:
- 正常读写(地址不同)
- 写后读冲突(地址相同,读优先)
- 读后写冲突(地址相同,写优先)
- 两个读端口同时读同一个地址
- 写端口连续写,读端口连续读
下面是一个简单的仿真代码片段:
initial begin
// 初始化
we = 0; waddr = 0; wdata = 0;
raddr1 = 0; raddr2 = 0;
// 写地址5,数据0xA5A5A5A5
@(posedge clk);
we = 1; waddr = 5; wdata = 32'hA5A5A5A5;
@(posedge clk);
we = 0;
// 读地址5(写后读模式,应读到旧值0)
raddr1 = 5;
@(posedge clk);
// 检查rdata1是否为0
// 读后写冲突测试
@(posedge clk);
we = 1; waddr = 3; wdata = 32'h12345678;
raddr2 = 3; // 同时读地址3
@(posedge clk);
// 如果是写优先,rdata2应为32'h12345678
// 如果是读优先,rdata2应为旧值
end
4.4 知识体系结构图
下面这张图帮你理清双端口寄存器堆的核心逻辑:
4.5 避坑指南
最后,分享几个我踩过的坑:
- 复位问题:寄存器堆要不要复位?我建议部分复位——只复位控制寄存器,数据寄存器不复位。因为数据寄存器上电默认值无所谓,复位反而浪费面积和功耗。
- 地址越界:如果raddr或waddr超出了DEPTH范围,读出来的是X态。一定要在仿真时加assertion检查。
- 多周期写:有些设计里写操作需要两个周期(先写地址,再写数据)。这种情况下,读写冲突的判断逻辑会更复杂,需要额外加一个写pending标志。
好了,双端口寄存器堆的核心内容就这些。下一章我们会聊多端口寄存器堆的设计,那才是真正考验架构能力的地方。