星夜离尘56头像
FPGA 同步 FIFO 不必是 2 的幂:深度 1、满时同拍读写与自检验证封面图

FPGA 同步 FIFO 不必是 2 的幂:深度 1、满时同拍读写与自检验证

工程里有时只需要缓存 3 个或 5 个数据,并不需要把逻辑容量扩成 4 或 8。真正容易出错的地方,是把“指针加一自然溢出”误当成任意深度的回绕规则,以及忽略满队列同拍读写、深度为 1 时的边界。

本文实现一个小容量、单时钟的 ready/valid FIFO。重点是把接口契约写清楚,再用独立于环形指针的参考队列验证。它不是异步 FIFO,也不承诺映射成 Block RAM。

**验证范围:**本文 RTL 与下方完整测试平台已在 Windows 上的 Vivado XSim 2022.1 原生运行。主测试覆盖 8 组参数、86,777 个周期步骤、603,760 次检查,其中随机激励为 80,000 个周期。另有独立编写的测试与技术复核。没有进行综合、布局布线、静态时序分析或上板测试,不能据此给出资源占用或最高频率。

一、先规定四个边界

所有传输都在 clk 上升沿采样;复位期间不接受传输。

  • 输入接受条件:s_valid && s_ready。
  • 输出消费条件:m_valid && m_ready。
  • 空队列不做输入到输出的组合直通。空时即使双方都准备好,这个上升沿也只接收新数据;新数据随后成为有效队头,最早在下一个上升沿被消费。
  • 满队列允许同拍先消费旧队头、再接收新数据,容量保持不变。

m_data 是已存储队头的组合读出,只在 m_valid=1 时有传输意义。这里的“空时不直通”指没有输入旁路,不等于必须先发一个读请求才能看到队头。无效期间本文实现把 m_data 置零。

复位也要说准确:指针和 level 使用同步高有效复位,只有上升沿才清零;但 rst 同时组合屏蔽 s_ready、m_valid 和 m_data。因此 rst 拉高时接口立即无效,level 到复位上升沿才归零。集成时应把复位作为本时钟域的受约束信号,不能随意异步切换。

二、用占用量区分满与空

读写指针相等既可能表示空,也可能表示满。这里不浪费一个存储位置,而是单独维护 level,范围为 0..DEPTH,逻辑容量正好等于 DEPTH。

事件下一拍占用量
只写入level + 1
只读出level - 1
同时读写level
都不发生level
复位上升沿0

非 2 次幂深度必须显式回绕。例如 DEPTH=3 的合法地址只有 0,1,2。两位指针的自然溢出是 0,1,2,3,0,其中地址 3 已越界。正确规则是:等于 DEPTH-1 时回到 0,否则加一。

还要分别计算两种位宽:

  • 指针位宽:至少 1 位,否则 DEPTH=1 时 $clog2(1)=0 会造成非法或意外的端口范围。
  • 计数位宽:使用 $clog2(DEPTH+1),因为计数需要表示 DEPTH 本身。例如深度 8 的指针是 3 位,计数却需要 4 位。

三、完整 RTL

保存为 sync_fifo.sv。这是 SystemVerilog 文件,需要使用 xvlog -sv 编译。

`timescale 1ns/1ps
module sync_fifo #(
    parameter integer DATA_W = 8,
    parameter integer DEPTH = 3,
    localparam integer PTR_W = (DEPTH < 2) ? 1 : $clog2(DEPTH),
    localparam integer COUNT_W = (DEPTH < 2) ? 1 : $clog2(DEPTH+1)
)(
    input  wire                   clk,
    input  wire                   rst,
    input  wire                   s_valid,
    output wire                   s_ready,
    input  wire [DATA_W-1:0]      s_data,
    output wire                   m_valid,
    input  wire                   m_ready,
    output wire [DATA_W-1:0]      m_data,
    output reg  [COUNT_W-1:0]     level
);
    reg [DATA_W-1:0] mem [0:DEPTH-1];
    reg [PTR_W-1:0] rd_ptr, wr_ptr;
    wire push = s_valid && s_ready;
    wire pop  = m_valid && m_ready;

    // Small-FIFO asynchronous read; this does not promise BRAM inference.
    assign m_valid = !rst && (level != 0);
    assign m_data  = m_valid ? mem[rd_ptr] : {DATA_W{1'b0}};
    // When full, a same-edge pop frees a slot for a replacement word.
    assign s_ready = !rst && ((level < DEPTH) || m_ready);

    function automatic [PTR_W-1:0] advance(input [PTR_W-1:0] p);
        if (p == DEPTH-1) advance = '0;
        else              advance = p + 1'b1;
    endfunction

    always @(posedge clk) begin
        if (rst) begin
            rd_ptr <= '0;
            wr_ptr <= '0;
            level  <= '0;
        end else begin
            if (push) begin
                mem[wr_ptr] <= s_data;
                wr_ptr <= advance(wr_ptr);
            end
            if (pop) rd_ptr <= advance(rd_ptr);
            case ({push, pop})
                2'b10: level <= level + 1'b1;
                2'b01: level <= level - 1'b1;
                default: level <= level;
            endcase
        end
    end

    // Simulation guard. Integrators must also enforce positive parameters.
    // synthesis translate_off
    initial begin
        if (DATA_W < 1 || DEPTH < 1)
            $fatal(1, "DATA_W and DEPTH must both be positive");
    end
    // synthesis translate_on
endmodule

DATA_W 与 DEPTH 都必须是正整数。文件中的检查用于仿真诊断,不是硬件保护。非法参数可能在展开端口、数组或复制表达式时就被工具拒绝,未必能运行到 $fatal。本次实测中,DEPTH=0 在仿真时间 0 被检查捕获,DATA_W=0 则在静态展开阶段因零次复制表达式被拒绝。

为什么满时可以继续 ready

level < DEPTH 时一定还有空间;当队列满且 m_ready=1 时,m_valid 也为 1,旧队头会在该上升沿离开,所以新输入可以占用释放的位置。这个优化建立在本文单时钟、同沿握手的契约上。

它带来一条 m_ready → s_ready 的组合路径。不要把该模块直接当成切断 ready 时序路径的寄存器切片,也不能不经检查就称它符合某一完整 AXI 接口规范。多级级联时需要检查组合路径长度和潜在组合环;本文没有做时序收敛验证。

DEPTH=1 为什么也成立

读写指针始终为 0。队列满且双方握手时,下游在上升沿采样旧的 mem[0],非阻塞赋值随后将新输入写入 mem[0],占用量仍为 1。这里依赖的是所给 RTL 的事件语义和组合读结构,不能直接类推为任何同步 RAM 原语的同地址读写模式。

复位为什么不清空整个数组

有效数据由 level 和指针界定,复位只需要把队列标记为空。旧存储内容不会在无效时作为有效输出。这样避免给每个存储单元都加复位逻辑,但这不是安全擦除,存储内容本身没有被清除。

四、用深度 3 手算一次满队列替换

下面表格是在 rst=0 下说明握手语义的手算例子,不是另一份仿真日志。输入数值用十进制表示;表中队列按“队头在左”排列。

上升沿前队列输入 valid/data输出 ready本沿输出上升沿后队列
空1 / 111无[11]
[11]1 / 220无[11,22]
[11,22]1 / 330无[11,22,33]
[11,22,33]1 / 440无,输入也未被接受[11,22,33]
[11,22,33]1 / 44111[22,33,44]
[22,33,44]0 / 无关122[33,44]

第 4 行被背压时,生产者要保持 s_valid 与数据 44,直到第 5 行真正握手。接收者可以改变 m_ready,但 FIFO 在 m_valid=1 && m_ready=0 期间必须保持同一队头。

五、完整自检测试平台

保存为 tb_sync_fifo.sv。参考模型使用顺序数组和移动元素维护队列,不复制 DUT 的环形指针实现;每一步先比较接口,再按期望握手更新参考队列,最后在非阻塞赋值生效后复查状态。

测试不是看一张波形“似乎正确”,而是逐步检查容量、输出顺序、队头、ready/valid 和复位行为。随机段使用固定种子的 xorshift32,源端遇到背压会保留未接受的数据,因此可重复运行。

`timescale 1ns/1ps
module fifo_case #(
    parameter integer W = 8,
    parameter integer D = 3,
    parameter integer ID = 0
)(output logic done);
    localparam integer CW = (D < 2) ? 1 : $clog2(D+1);
    logic clk = 0, rst = 1, sv = 0, mr = 0;
    logic [W-1:0] sd = '0;
    wire sr, mv;
    wire [W-1:0] md;
    wire [CW-1:0] level;
    sync_fifo #(.DATA_W(W), .DEPTH(D)) dut
      (.clk(clk), .rst(rst), .s_valid(sv), .s_ready(sr), .s_data(sd),
       .m_valid(mv), .m_ready(mr), .m_data(md), .level(level));
    always #5 clk = ~clk;
    logic [W-1:0] q [0:D];
    integer n = 0, cycles = 0, checks = 0;
    integer pushes = 0, pops = 0, full_replace = 0, stalls = 0;
    integer empty_both = 0, resets_nonempty = 0;
    integer j, k, i;
    bit er, ev, push, pop, old_accept = 1;
    bit gen_v;
    logic [W-1:0] gen_data;
    int unsigned rng = 32'h49a2d73b + ID;
    function automatic int unsigned rand32();
        rng = rng ^ (rng << 13);
        rng = rng ^ (rng >> 17);
        rng = rng ^ (rng << 5);
        return rng;
    endfunction
    task automatic ck(input bit ok, input string what);
        checks++;
        if (!ok) $fatal(1,"FAIL case=%0d W=%0d D=%0d cycle=%0d n=%0d %s", ID,W,D,cycles,n,what);
    endtask
    task automatic step(input bit r, input bit v,
                        input logic [W-1:0] data, input bit ready);
        @(negedge clk);
        rst = r; sv = v; sd = data; mr = ready;
        #1;
        er = !r && ((n < D) || ready);
        ev = !r && (n != 0);
        ck(sr === er, "s_ready");
        ck(mv === ev, "m_valid");
        ck(level === n, "pre-edge level");
        if (ev) ck(md === q[0], "head/order/stall data");
        else ck(md === {W{1'b0}}, "invalid data must be zero");
        push = v && er;
        pop = ready && ev;
        old_accept = er;
        if (r && n != 0) resets_nonempty++;
        if (!r && n==D && push && pop) full_replace++;
        if (!r && ev && !ready) stalls++;
        if (!r && n==0 && v && ready) empty_both++;
        @(posedge clk);
        if (r) n = 0;
        else begin
            if (pop) begin
                for (j=0; j<n-1; j++) q[j] = q[j+1];
                n--; pops++;
            end
            if (push) begin q[n] = data; n++; pushes++; end
        end
        #1;
        ck(level === n, "post-edge level");
        ck(mv === (!r && n!=0), "post-edge valid");
        if (!r && n!=0) ck(md === q[0], "post-edge head");
        cycles++;
    endtask
    initial begin
        done=0;
        // First edge establishes the synchronous reset before assertions.
        @(posedge clk); #1;
        step(1,1,'1,1);
        step(0,1,'h21,1); // Empty: enqueue only, no fall-through dequeue.
        step(0,0,'0,1);
        repeat (30) begin
            for (i=0;i<D;i++) step(0,1,i+17,0);
            repeat (9) step(0,1,'hA5,0); // Full, held producer, stalled consumer.
            step(0,1,'hA5,1); // Accept the held word first.
            repeat (D+3) step(0,1,rand32(),1); // Full replacement.
            for (i=0;i<D+2;i++) step(0,0,'0,1);
        end
        for (i=0;i<D;i++) step(0,1,i+73,0);
        step(1,1,'1,1); // Flush nonempty queue and reject both handshakes.
        gen_v=0; gen_data='0; old_accept=1;
        for(k=0;k<10000;k++) begin
            if (!gen_v || old_accept) begin
                gen_v = (rand32()%4 != 0);
                gen_data = {rand32(),rand32()};
            end
            step(0,gen_v,gen_data,(rand32()%5 < 3));
        end
        // Stop source only after its pending transfer has been accepted.
        if(gen_v && !old_accept) step(0,gen_v,gen_data,1);
        for(i=0;i<D+2;i++) step(0,0,'0,1);
        ck(n==0,"final drain");
        ck(full_replace>0 && stalls>0 && empty_both>0 && resets_nonempty>0,"coverage");
        $display("PASS case=%0d W=%0d D=%0d cycles=%0d checks=%0d pushes=%0d pops=%0d full_replace=%0d stalls=%0d empty_both=%0d resets_nonempty=%0d", ID,W,D,cycles,checks,pushes,pops,full_replace,stalls,empty_both,resets_nonempty);
        done=1;
    end
endmodule
module tb_sync_fifo;
    wire [7:0] done;
    fifo_case #(.W(1), .D(1), .ID(0)) c0(done[0]);
    fifo_case #(.W(8), .D(3), .ID(1)) c1(done[1]);
    fifo_case #(.W(8), .D(5), .ID(2)) c2(done[2]);
    fifo_case #(.W(16),.D(8), .ID(3)) c3(done[3]);
    fifo_case #(.W(33),.D(1),.ID(4)) c4(done[4]);
    fifo_case #(.W(1), .D(3), .ID(5)) c5(done[5]);
    fifo_case #(.W(16),.D(5),.ID(6)) c6(done[6]);
    fifo_case #(.W(33),.D(8),.ID(7)) c7(done[7]);
    initial begin wait (&done); $display("ALL_PASS"); $finish; end
    initial begin #2000000; $fatal(1,"TIMEOUT"); end
endmodule

在已经配置好 Vivado 命令行环境的 PowerShell 中运行:

xvlog -sv sync_fifo.sv tb_sync_fifo.sv
if ($LASTEXITCODE -ne 0) { throw "xvlog failed" }
xelab tb_sync_fifo -s fifo_test -mt off
if ($LASTEXITCODE -ne 0) { throw "xelab failed" }
xsim fifo_test -runall | Tee-Object simulation.txt
if ($LASTEXITCODE -ne 0) { throw "xsim failed" }
$log = Get-Content simulation.txt -Raw
if ($log -notmatch '(?m)^ALL_PASS\s*$' -or $log -match 'Fatal:|ERROR:') {
    throw "Functional test did not pass; inspect simulation.txt"
}

不要只检查进程退出码。本次 XSim 的部分 $fatal 失败运行也返回了 0,因此还必须检查明确的成功标记,并排除失败信息。正常结束应有 8 条 PASS case=... 和最后的 ALL_PASS。

本次主测试结果

下表直接由本次原生运行日志提取。周期是各配置执行的 step 次数;检查数包含一个周期内的多项断言,不等于独立测试用例数。

DATA_WDEPTH周期步骤检查次数满时同拍读写次数
1110,54972,5674,604
8310,73274,7733,316
8510,91676,2733,255
16811,19278,2413,164
33110,54872,5504,517
1310,73274,8323,493
16510,91676,2813,404
33811,19278,2433,407

合计 86,777 个周期步骤、603,760 次检查。每配置有 10,000 个随机周期,其余为定向填满、连续背压、满时替换、排空和非空复位等步骤。写入次数与读出次数不必完全相等,因为复位会按契约丢弃队列中尚未消费的数据。

另外注入了 3 类错误,确认测试会失败,而不是只会输出 PASS:

  1. 把回绕条件改成指针全 1:在深度 1 触发失败;单独运行深度 3 时也在第 16 号步骤发现队头错误。
  2. 满时即使下游 ready 也禁止输入:深度 1 测试立即发现 s_ready 与契约不符。
  3. 只要输出 valid 就推进读指针:深度 3 的背压测试发现队头改变。

这些故障版本都先完成编译和展开,再被功能检查捕获。它们属于负向验证,不是最终 RTL 的失败。最终代码使用上文版本。另一个独立编写的测试平台也对深度 1、3、5、8 完成原生仿真;独立复核没有发现阻断性 RTL 问题。

六、集成前再检查三件事

  1. **时钟域。**该模块只能连接同一时钟域,不具备亚稳态处理和跨时钟域安全性。
  2. **存储结构。**这里是组合读队头的 RTL,适合小 FIFO 的教学与原型。换成同步读 Block RAM,需要重新设计读延迟、输出预取和满时同地址读写语义,不能只替换数组声明。
  3. **实现证据。**仿真验证的是给定激励下的逻辑行为。实际资源、时钟频率、器件读写模式和复位时序,仍需在目标工程完成综合、实现与硬件验证。

任意深度 FIFO 的关键不是多写一个参数,而是让容量、指针回绕、计数位宽和同拍读写在同一份接口契约下成立。先把深度 1、3、5 跑通,往往比只验证深度 16 更容易暴露边界错误。

参考:AMD《Vivado Design Suite User Guide: Logic Simulation (UG900)》2022.1,Vivado Simulator Quick Reference Guide。本文的 FIFO 实现与测试平台为围绕上述契约编写的示例,官方文档引用仅用于仿真工具用法。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/hxy_programmer/article/details/170230434

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--