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项目中用过这种策略。当时的需求是:写寄存器后立即读,必须读到新值。如果不做旁路,读到的还是旧值,整个流水线就乱了。嗯,那次调试花了我整整一个下午。

注意:读后写策略下,如果写地址和读地址相同,但写使能无效(we=0),那读到的还是mem里的旧值。别把旁路逻辑写死了,一定要判断we有效。

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
关键点:仿真时一定要用$monitor或$display把每个时钟周期的读写地址、数据打印出来。我曾经因为没打印,漏掉了一个旁路逻辑的bug,仿真跑了半天才发现。血的教训。

4.4 知识体系结构图

下面这张图帮你理清双端口寄存器堆的核心逻辑:

双端口寄存器堆知识体系 双端口寄存器堆 RTL实现 读写冲突处理 仿真验证 组合读 vs 寄存器读 双读端口 + 单写端口 写后读(读优先) 读后写(写优先 + 旁路) 正常读写测试 冲突场景覆盖 核心:根据应用场景选择冲突策略 读优先 → 简单可靠 | 写优先 → 需要旁路逻辑

4.5 避坑指南

最后,分享几个我踩过的坑:

  • 复位问题:寄存器堆要不要复位?我建议部分复位——只复位控制寄存器,数据寄存器不复位。因为数据寄存器上电默认值无所谓,复位反而浪费面积和功耗。
  • 地址越界:如果raddr或waddr超出了DEPTH范围,读出来的是X态。一定要在仿真时加assertion检查。
  • 多周期写:有些设计里写操作需要两个周期(先写地址,再写数据)。这种情况下,读写冲突的判断逻辑会更复杂,需要额外加一个写pending标志。

好了,双端口寄存器堆的核心内容就这些。下一章我们会聊多端口寄存器堆的设计,那才是真正考验架构能力的地方。

可可精品资料库