4. 寄存器堆的Verilog建模(二):双端口寄存器堆的RTL实现,解决读写冲突(写后读、读后写)

好,咱们接着聊双端口寄存器堆。

上一章我们搞定了单端口,说白了就是一根数据总线,读写不能同时进行。但实际项目中,尤其是CPU的寄存器堆,你想想看,指令流水线里经常要同时读两个操作数、写一个结果。单端口根本忙不过来。这时候就得请出双端口寄存器堆了。

双端口,顾名思义,有两个独立的访问通道。每个通道都有自己的地址、数据和读写控制信号。它们可以同时操作,互不干扰。但问题来了——如果两个端口同时访问同一个地址呢?一个要写,一个要读,数据就乱套了。这就是我们常说的读写冲突。

我当年第一次做CPU项目时,就栽在这个坑里。仿真跑得好好的,上板子就出随机错误。查了三天,最后发现是写后读冲突没处理好。嗯,从那以后,我对双端口的设计就格外小心。

4.1 双端口寄存器堆的结构

先画个图,帮大家理清思路。

寄存器堆 32 x 32-bit 端口A(读/写) 端口B(读/写) 冲突检测 写后读 / 读后写 地址比较 + 优先级 地址A、数据A、写使能A 地址B、数据B、写使能B 仲裁结果控制写数据通路

从图上能看出来,两个端口都能读也能写。关键就在于中间那个冲突检测模块。它负责判断两个端口是不是在同时操作同一个地址,然后决定谁先谁后。

4.2 读写冲突的两种场景

双端口寄存器堆的冲突,其实就两种:

  • 写后读(RAW):端口A写地址X,同时端口B读地址X。读到的数据是旧的还是新的?
  • 读后写(WAR):端口A读地址X,同时端口B写地址X。读操作会不会被写操作干扰?

你可能会问:那写后写(WAW)呢?两个端口同时写同一个地址?嗯,这种情况在设计中通常被禁止,或者由软件保证不会发生。我们硬件只处理RAW和WAR。

核心原则:写后读,必须读到新数据;读后写,必须读到旧数据。

说白了,就是保证数据的一致性。不能因为两个端口同时操作,就把数据搞乱了。

4.3 RTL实现:带冲突解决的双端口寄存器堆

好,上代码。我习惯用写优先的策略来解决RAW冲突。什么意思呢?就是当写和读同时发生时,让写操作先完成,读操作直接拿到新写进去的数据。

module dp_rf #(
    parameter ADDR_WIDTH = 5,
    parameter DATA_WIDTH = 32,
    parameter DEPTH = 32
)(
    input  wire                     clk,
    input  wire                     rst_n,
    
    // 端口A
    input  wire                     we_a,
    input  wire [ADDR_WIDTH-1:0]   addr_a,
    input  wire [DATA_WIDTH-1:0]   wdata_a,
    output reg  [DATA_WIDTH-1:0]   rdata_a,
    
    // 端口B
    input  wire                     we_b,
    input  wire [ADDR_WIDTH-1:0]   addr_b,
    input  wire [DATA_WIDTH-1:0]   wdata_b,
    output reg  [DATA_WIDTH-1:0]   rdata_b
);

    // 寄存器阵列
    reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];
    
    // 写操作(同步写)
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            // 初始化所有寄存器为0
            integer i;
            for (i = 0; i < DEPTH; i = i + 1)
                mem[i] <= {DATA_WIDTH{1'b0}};
        end else begin
            if (we_a) mem[addr_a] <= wdata_a;
            if (we_b) mem[addr_b] <= wdata_b;
        end
    end
    
    // 读操作(组合逻辑读 + 写优先冲突处理)
    always @(*) begin
        // 端口A读数据
        if (we_a && (addr_a == addr_b)) begin
            // 写后读冲突:端口A写,端口B读同一地址
            // 写优先:读数据直接取写数据
            rdata_a = wdata_a;
        end else begin
            rdata_a = mem[addr_a];
        end
        
        // 端口B读数据
        if (we_b && (addr_b == addr_a)) begin
            // 写后读冲突:端口B写,端口A读同一地址
            rdata_b = wdata_b;
        end else begin
            rdata_b = mem[addr_b];
        end
    end

endmodule

个人经验:这里读操作用了组合逻辑,好处是读数据没有延迟,适合高速流水线。但要注意,组合逻辑读会消耗更多面积。如果对时序要求不高,也可以改成同步读,用寄存器打一拍。

4.4 冲突检测的逻辑细节

咱们仔细看看冲突检测那两行代码:

if (we_a && (addr_a == addr_b)) begin
    rdata_a = wdata_a;
end

这个条件的意思是:端口A在写,而且两个端口访问的是同一个地址。这时候,端口A读到的数据就直接从写数据里拿,而不是从寄存器阵列里读。

为什么会这样?因为写操作是同步的,在时钟上升沿才写入。而读操作是组合逻辑,随时都在读。如果读操作去读寄存器阵列,读到的是旧数据。但我们要的是新数据,所以直接绕过寄存器阵列,把写数据送给读端口。

嗯,这里有个细节要注意:两个端口同时写同一个地址怎么办?上面的代码里,两个写操作都会执行,但最终结果取决于最后一个赋值。这在硬件上是不确定的。我建议在设计中明确禁止这种情况,或者加一个优先级仲裁。

我曾经踩过的坑:有一次我忘了处理两个端口同时写同一个地址的情况。仿真时因为赋值顺序固定,看起来没问题。但综合后,硬件行为就不确定了。后来我加了一个优先级逻辑:端口A的写优先级高于端口B。这样虽然损失了一点灵活性,但保证了确定性。

4.5 读后写(WAR)冲突的处理

读后写冲突,说白了就是读操作先发生,写操作后发生。因为读是组合逻辑,写是同步逻辑,所以读操作在时钟上升沿之前就已经完成了。写操作在时钟上升沿才写入。所以读后写天然不会冲突——读到的永远是旧数据。

但有一种情况例外:如果读操作也是同步的(用寄存器打了一拍),那读和写就都在时钟上升沿触发。这时候读后写就会有问题:读操作可能读到新数据,也可能读到旧数据,取决于时序。

我个人建议:读操作尽量用组合逻辑,这样WAR冲突自动解决。如果非要用同步读,那就得在冲突检测里加上WAR的处理逻辑。

4.6 性能与面积的权衡

双端口寄存器堆,说白了就是用面积换性能。两个端口意味着两套地址译码、两套数据通路。面积大约是单端口的两倍。

但好处也很明显:

  • 一个时钟周期内可以完成两次读、一次写(如果两个端口都读,一个端口写)
  • 流水线CPU可以同时取指令和读寄存器
  • 多发射处理器可以同时读取多个操作数

我做过一个项目,为了省面积,用了伪双端口(一个读端口、一个写端口)。结果性能瓶颈就卡在寄存器堆上了。后来改成真双端口,性能提升了30%。所以该花的面积还是得花。

4.7 验证要点

写完了RTL,怎么验证?我列几个关键点:

  1. 基本读写功能:两个端口各自独立读写,互不干扰
  2. 写后读冲突:两个端口同时操作同一地址,检查读到的数据是否正确
  3. 读后写冲突:验证读操作不受写操作影响
  4. 边界地址:地址0和地址最大值(31)的读写
  5. 复位后状态:所有寄存器是否初始化为0

我记得有一次验证,发现写后读冲突时数据不对。查了半天,原来是地址比较的逻辑写反了。addr_a == addr_b 写成了 addr_a != addr_b。这种低级错误,仿真一跑就能发现。所以验证一定要覆盖冲突场景。

4.8 小结

双端口寄存器堆,核心就是解决读写冲突。写后读用写优先策略,读后写靠组合逻辑读天然解决。代码实现上,注意冲突检测的条件要写对,两个端口同时写同一个地址要避免或加优先级。

嗯,这一章就到这里。代码虽然不长,但里面的细节值得好好琢磨。下一章我们聊聊多端口寄存器堆的设计思路,那才是真正考验功力的时候。


可可精品资料库