ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA异步FIFO乒乓缓冲:跨时钟域数据搬运实战

FPGA异步FIFO乒乓缓冲:跨时钟域数据搬运实战

简介:工程文件围绕Vivado异步FIFO IP的乒乓操作展开,面向已具备Verilog基础、希望理解跨时钟域数据缓冲与双FIFO交替切换的FPGA学习者。内容涉及读写指针同步、双端口RAM存储、乒乓状态机切换与判满判空、IP Catalog配置及仿真验证等环节,可用于搭建可综合、可仿真的完整实验环境,帮助梳理从代码到约束、综合实现的工程组织方式。压缩包共205个文件,约17.82MB,以Verilog与VHDL源码、XDC时序约束、XCI IP配置、TCL/BAT/DO流程脚本为主,并含综合实现日志、仿真数据库与工程文件,便于对照检查配置与复现结果。已有975人学习下载,适合作为异步FIFO乒乓操作的入门范例和课程设计参考,也可据此拆解FIFO深度、切换时序和边界条件验证思路。

1. 两个时钟域之间最容易出问题的地方,一块 FIFO 兜不住

采集侧跑 100 MHz,处理侧跑 75 MHz,突发来的时候一口气写 512 拍,中间还夹着几十微秒的空闲——这种数据流如果用一块异步 FIFO 硬扛,仿真大概率没问题,上板跑几分钟就开始丢帧。原因并不复杂:异步 FIFO 只解决“跨时钟域安全搬运”,不解决“读写同时进行”。真正让吞吐稳住的,是 FPGA + Verilog 乒乓操作这套结构:两块 FIFO 轮转,写 A 的时候读 B,切换的瞬间两端都不停。这份工程文件就是围绕 Vivado 异步 FIFO IP 搭的一套可复现乒乓缓冲,包含 IP 配置、双 bank 切换逻辑、跨域同步器和约束文件,适合正在做跨时钟域数据搬运、又不想从零搭框架的 Verilog 开发者。它并不复杂:如果你指望它顺手解决仲裁和反压协议,那就想多了。

2. 工程骨架与异步 FIFO IP 的参数明细

拿到一份 Vivado 工程文件,先别急着综合。工程能不能一次跑通,八成取决于 IP 的状态和参数是否和当前工具版本对得上。这章先把目录结构、IP 配置和几个容易选错的开关说清楚,后面写切换逻辑才有地基。

2.1 目录结构与版本兼容处理

工程按 Vivado 常规布局组织,主要文件如下:

路径内容
pingpong_fifo.xpr工程入口,含综合/实现策略
srcs/hdl/top_pingpong.v顶层,例化双 FIFO 与读写控制
srcs/hdl/pp_wr_ctrl.v写侧帧计数与 bank 切换
srcs/hdl/pp_rd_ctrl.v读侧帧消费与 bank 轮转
srcs/hdl/cdc_sync.v两级同步器,带 ASYNC_REG 属性
ip/fifo_bank/fifo_bank.xci异步 FIFO IP 配置,两块 bank 共用同一份配置
constrs/pin.xdc时钟定义、时钟组、CDC 例外
sim/tb_pingpong.v自比对测试平台,随机间隔写入

.xci 是版本敏感文件。低版本工具打开高版本生成的 IP,会在 Sources 面板显示为 Locked,综合直接报错说找不到对应的 IP 输出产物。常见做法是先看状态再升级:

# 在工程目录下启动 Vivado,Tcl Console 里执行 open_project ./pingpong_fifo.xpr report_ip_status # Status 为 Locked 的 IP 需要升级,升级会重新生成输出产物 upgrade_ip [get_ips fifo_bank] # 升级完成后重新生成,确保 .dcp / 仿真模型都是当前版本 generate_target all [get_files ./ip/fifo_bank/fifo_bank.xci]

report_ip_status的输出重点看两列:Status 和 Change Log。Status 为Locked说明版本不匹配;为Up to date但 Change Log 非空,说明配置被改过但没重新生成。upgrade_ip只改 IP 的版本标记,不自动重新生成,所以后面那句generate_target不能省,否则综合时用的还是旧产物。升级前建议先把整个工程复制一份,IP 升级是不可逆的,改坏了回不去。

2.2 六个必须逐项核对的 IP 参数

双击 IP 打开定制界面,下面这些参数建议照着填,填错任何一个后面都会以奇怪的方式表现出来:

参数建议值为什么这么选
Interface TypeNativeAXI 版多一层握手,除非直接接 DMA,否则纯属加复杂度
FIFO ImplementationIndependent Clocks Block RAM异步 FIFO 必须选独立时钟,选 Common Clocks 出不来两个时钟口
Read ModeStandard FIFO与 FWFT 差一拍,读侧计数逻辑完全不同
Data Width32与上游数据位宽一致,改位宽要同步改顶层参数
Depth1024取两倍帧长,512 帧长留一半余量给切换抖动
Almost Full Threshold32提前 32 拍给出水位提示,给切换逻辑留反应时间
Programmable Almost Empty4调试时看水位用,逻辑上不依赖它
Data Count勾选出问题时能直接看 FIFO 里还剩多少,比猜强
Reset Pin勾选不勾就没有wr_rst/rd_rst端口,例化必报错
Safety Circuit勾选溢出/下溢时冻结指针,避免错误扩散到整帧

Depth 和 BRAM 的关系值得单独算一笔。32 bit × 1024 = 32 Kb,一个 36 Kb 的块 RAM 装得下,剩下 4 bit 是校验位,浪费就浪费了。但如果把位宽改成 40 bit,1024 深度就需要 40 Kb,一个 BRAM 装不下,工具会拼两块,时序和面积都会变。所以位宽和深度是一起看的,别只改一个。

Almost Full 的 32 不是随便填的。它决定了写侧在多早的时候知道“这一帧快写完了”。如果填 0,等于关闭 almost_full,写侧只能靠 full 判断,而 full 拉高的那一拍wr_en往往已经被组合逻辑挡住了,最后一拍数据就丢了——这是乒乓结构里最经典的一类丢帧原因。

2.3 Read Mode、复位与 Safety Circuit 的取舍

Read Mode 选 Standard 还是 FWFT,是这份工程里第二个容易犹豫的地方。Standard FIFO 的时序是:rd_en拉高后下一个时钟沿,dout才有效。FWFT(First Word Fall Through)则是数据提前出现在dout上,rd_en更像一个 ack。前者读侧计数和rd_en一一对应,写状态机最直观;后者少一拍延迟,但读侧必须用rd_en && !empty作为“消费了一拍”的判据,否则会多读。工程里用的是 Standard,读侧指针每发一次rd_en加一,帧边界和指针完全对齐,调试时对波形很友好。

复位引脚勾上之后,IP 会生成wr_rst和rd_rst两个高有效复位。这两个复位内部会同步到各自的时钟域,但仍然要求两侧同时有效。运行中只复位一侧,是异步 FIFO 最隐蔽的问题:写指针清零了、读指针没清,读侧会认为 FIFO 里有大量数据,实际读出来全是无效值,而且这个错误会一直传播下去,直到下一次全局复位。所以顶层只留一个外部复位,进两个时钟域各做一次同步释放,再分别送给 IP 和控制逻辑。

Safety Circuit 建议打开。它干的事是:一旦检测到写满还写、读空还读,就冻结内部指针并拉高错误标志,防止错误状态蔓延。代价是多一点组合逻辑和几个触发器的面积。做原型阶段这点面积不算什么,等上了量再评估要不要关。我一般会在调试版本里打开,量产版本再根据资源报告决定。

3. 乒乓切换的握手:toggle 比 ready 电平可靠在哪

结构定下来之后,真正的难点不在 FIFO 本身,而在“什么时候切换 bank”这件事上。写侧认为一帧写完了,读侧怎么知道?读侧读完了,写侧又怎么知道这块 bank 可以复用了?这两个问题都是跨时钟域的,处理不好就会看到“仿真全对、上板偶发错帧”这种令人头疼的现象。

3.1 单缓冲、双缓冲与乒乓的吞吐差别

先把账算清楚,才知道为什么要用两块 FIFO。假设写域 100 MHz、位宽 32 bit,峰值写带宽 400 MB/s;读域 75 MHz、位宽 32 bit,峰值读带宽 300 MB/s。一帧 512 拍,写满需要 5.12 μs,读空需要 6.83 μs。

单块 FIFO 的模型是“写满—读空—再写”。写侧写完 512 拍后必须等读侧把这 512 拍全部读走才能继续写,等待时间 6.83 μs,有效吞吐被读侧限死。更麻烦的是,如果上游数据不能停,这段等待期间的数据只能丢或者缓存在别处。

乒乓的模型是“写 A—写 B(同时读 A)—写 A(同时读 B)”。写 B 的 5.12 μs 里,读侧有 6.83 μs 的时间窗去读 A,读得完;等写侧要写第三帧时,A 已经空了。稳态下写侧只需要在“读侧比写侧慢”的差值上等一小段:两帧写入 10.24 μs,两帧读出 13.66 μs,差 3.42 μs,摊到每帧 1.71 μs 的背压。这个数字直接决定了上游需不需要加缓冲。

如果这个差值不可接受,只有三条路:提高读时钟、加宽读侧位宽、或者把 bank 数从 2 加到 3 以上。前两条是改物理条件,第三条是改结构,各有代价。

3.2 写侧切换条件与背压

写侧的状态机只有两个状态:选 bank、写帧。选 bank 的条件是“这块 bank 里没有未被消费的帧”。判断方法不是看 FIFO 的 empty,而是看一个跨域握手信号——这一点很关键,因为 FIFO 的 empty 在读时钟域,写侧看不到。

握手用 toggle 最稳。每块 bank 配一对信号:写侧提交一帧后翻转wr_tgl[k],读侧消费完一帧后翻转rd_tgl[k]。两个信号各自跨到对方时钟域做两级同步,然后比较:

  • 写侧看wr_tgl[k] == rd_tgl_sync[k],相等说明这块 bank 的上一帧已经被读走,可以写。
  • 读侧看wr_tgl_sync[k] != rd_tgl[k],不等说明有一帧新数据待读。

toggle 相比电平 ready 的优势在于它是事件语义。电平 ready 在高电平期间持续有效,如果读时钟比较慢,可能在两个采样沿之间错过一次拉高又拉低;toggle 每帧只翻转一次,只要一帧的持续时间大于两三个读时钟周期,就一定能被采到。反过来说,如果一帧只有一个读时钟周期那么短,toggle 也会漏,这属于设计前提不成立,得回去加深度。

背压就落在“选不到空闲 bank”这个分支上。此时写侧拉低in_ready,上游必须停。上游停不下来的话,正确做法是在更前面再加一级小 FIFO 做弹性缓冲,而不是把 bank 深度加大到无限——深度加大只是把溢出时间往后推,不改变读写速率的差值。

3.3 读侧消费完成与跨域同步

读侧的切换逻辑比写侧多一层考虑:两块 bank 都有数据时先读哪块。答案是按写入顺序读,否则输出数据会乱序。实现上维护一个last_sel记录上次读的 bank,两块都有数据时选另一块,保证轮转。

读帧过程中要处理 empty。正常情况下不会读到空,因为写侧提交一帧意味着 512 个数据已经全部写进 FIFO,而 FIFO 深度是 1024,读侧读 512 个一定读得到。但如果 FIFO 深度被改小到小于帧长,读侧就会在帧中间撞上 empty,此时如果继续发rd_en,读出来的是无效数据,而且rd_ptr已经加过了,整帧错位。所以读侧必须有!empty保护,撞上就停住等,等不到就说明参数配错了。

帧读完的判定是rd_ptr == FRAME_LEN-1,在这个时钟沿翻转rd_tgl[k]、清reading、更新last_sel。翻转动作必须和最后一次rd_en在同一个时钟沿完成,早一拍会让写侧提前认为 bank 空闲,晚一拍会让写侧多等一个周期。这种“差一拍”的问题在波形上极难发现,靠的是代码里对时序的明确注释。

3.4 复位释放与第一帧的初始状态

上电之后、第一帧到来之前,所有 toggle 必须初值一致,否则读侧一上电就认为“有数据待读”,冲进去读一块空 FIFO。工程里的做法是:外部复位低有效,进每个时钟域后做同步释放,同步后的复位同时清 FIFO 的wr_rst/rd_rst和控制逻辑里的所有 toggle、指针、状态位。释放之后的头几个周期,FIFO 内部还需要几个时钟做指针初始化,这段时间empty可能短暂为低,所以读侧状态机要等到复位释放后第一个稳定周期才允许启动。

还有一个容易忽略的点:两块 FIFO 的复位必须同时释放。如果 bank0 的复位早释放了几个周期、bank1 晚释放,写侧可能已经往 bank0 写了一帧并翻转了 toggle,而 bank1 的指针还没初始化完。虽然不会立刻出错,但第一轮轮转时 bank1 的行为是不确定的。稳妥的做法是把两个 IP 的复位接同一根线,保证同拍释放。

4. 代码实现:两个 bank 的例化、切换与约束

原理讲完,接下来是能直接抄的部分。代码分成四块:顶层连线、写侧控制、读侧控制、约束。每块都给完整可综合的写法,注释标出关键拍的位置。

4.1 顶层:双 FIFO 例化与数据通路选择

顶层只做三件事:例化两块 FIFO、把读写控制接上去、按当前 bank 选择输出数据。

module top_pingpong #( parameter integer DW = 32, parameter integer FRAME_LEN = 512 )( input wire wr_clk, input wire rd_clk, input wire rst_n, // 低有效,两个域各自同步释放 input wire din_valid, input wire [DW-1:0] din, output wire din_ready, output reg dout_valid, output reg [DW-1:0] dout ); wire [1:0] fifo_wr_en; wire [1:0] fifo_rd_en; wire [1:0] fifo_full; wire [1:0] fifo_empty; wire [2*DW-1:0] fifo_dout; // 两块 bank 的 dout 拼在一起 wire [1:0] wr_tgl, rd_tgl; wire [1:0] wr_tgl_sync, rd_tgl_sync; wire out_bank; // 两块 FIFO 用同一份 IP 配置,generate 展开省得写两遍 genvar gi; generate for (gi = 0; gi < 2; gi = gi + 1) begin : g_fifo fifo_bank u_fifo ( .wr_clk (wr_clk), .rd_clk (rd_clk), .wr_rst (wr_rst_sync), .rd_rst (rd_rst_sync), .din (wr_din_r), .wr_en (fifo_wr_en[gi]), .rd_en (fifo_rd_en[gi]), .dout (fifo_dout[gi*DW +: DW]), .full (fifo_full[gi]), .empty (fifo_empty[gi]) ); end endgenerate // 输出按读侧当前 bank 选择,注意 out_bank 已与 FIFO 输出对齐一拍 always @(posedge rd_clk) begin if (!rst_n) dout <= {DW{1'b0}}; else if (fifo_rd_en != 2'b00) dout <= out_bank ? fifo_dout[2*DW-1:DW] : fifo_dout[DW-1:0]; end endmodule

fifo_bank就是 IP 定制时填的 Component Name,两块 bank 共用一份 .xci,靠 generate 展开两次例化,改配置只需要改一处。fifo_dout用位选拼接,避免在 generate 里对同一根线做三元赋值——那种写法综合工具能过,但仿真器行为不一定一致。

dout的寄存条件写的是fifo_rd_en != 2'b00,而不是dout_valid。原因是 Standard FIFO 下rd_en有效后的下一拍dout才更新,如果等dout_valid再寄存,会晚一拍,数据就和 valid 错开了。这个细节在波形上表现为“valid 比 data 晚一个周期”,接下游时会被当成丢拍。

4.2 写侧:帧计数、bank 选择与数据同拍寄存

写侧最容易被写错的地方不是状态机,而是wr_en和din的时序关系。FIFO 在时钟沿采样这两个信号,必须同拍有效。如果wr_en是寄存器输出、din直接接上游组合数据,那么wr_en有效的那个沿,上游数据可能已经撤销了,写进去的就是错值。

module pp_wr_ctrl #( parameter integer DW = 32, parameter integer FRAME_LEN = 512 )( input wire wr_clk, input wire wr_rst, // 高有效,同步释放后 input wire in_valid, input wire [DW-1:0] in_data, output wire in_ready, input wire [1:0] full, output reg [1:0] fifo_wr_en, output reg [DW-1:0] fifo_din, // 接 FIFO 的 din output reg wr_tgl0, output reg wr_tgl1, input wire rd_tgl0_sync, input wire rd_tgl1_sync ); localparam integer PW = $clog2(FRAME_LEN); reg [PW-1:0] wr_ptr; reg writing; reg bank_sel; reg wen_r; wire bank0_free = (wr_tgl0 == rd_tgl0_sync); wire bank1_free = (wr_tgl1 == rd_tgl1_sync); // 只有正在写、且当前 bank 没满时才接数据 assign in_ready = writing && !full[bank_sel]; always @(posedge wr_clk) begin if (wr_rst) begin wr_ptr <= {PW{1'b0}}; writing <= 1'b0; bank_sel <= 1'b0; wen_r <= 1'b0; wr_tgl0 <= 1'b0; wr_tgl1 <= 1'b0; fifo_wr_en <= 2'b00; end else begin fifo_wr_en <= 2'b00; if (!writing) begin // 优先续用当前 bank;被占用则换另一块;都占用就等 if (bank_sel == 1'b0 && bank0_free) begin writing <= 1'b1; wr_ptr <= {PW{1'b0}}; end else if (bank_sel == 1'b1 && bank1_free) begin writing <= 1'b1; wr_ptr <= {PW{1'b0}}; end else if (bank0_free) begin bank_sel <= 1'b0; writing <= 1'b1; wr_ptr <= {PW{1'b0}}; end else if (bank1_free) begin bank_sel <= 1'b1; writing <= 1'b1; wr_ptr <= {PW{1'b0}}; end end else if (in_valid && in_ready) begin // 数据与使能同拍寄存,下一拍一起进 FIFO fifo_din <= in_data; fifo_wr_en[bank_sel] <= 1'b1; wr_ptr <= wr_ptr + 1'b1; if (wr_ptr == FRAME_LEN-1) begin writing <= 1'b0; if (bank_sel == 1'b0) begin wr_tgl0 <= ~wr_tgl0; // 提交 bank0,读侧可消费 bank_sel <= 1'b1; end else begin wr_tgl1 <= ~wr_tgl1; // 提交 bank1 bank_sel <= 1'b0; end end end end end endmodule

fifo_din和fifo_wr_en在同一个时钟沿寄存,下一拍同时到达 FIFO 端口,采样关系是确定的。wr_ptr的加一发生在提交那一拍,和fifo_wr_en的寄存同拍,所以指针数的是“已提交拍数”,不是“已写入 FIFO 拍数”——两者差一拍,但帧边界的判断用的是指针,只要一致就没问题。

in_ready是组合输出,依赖writing和full。full来自 FIFO 的写时钟域,同域组合没问题。如果时序紧张,可以把in_ready也打一拍,代价是握手多一个周期延迟,上游要能接受。

4.3 读侧:轮转、empty 保护与输出对齐

读侧比写侧多两件事:两块都有数据时按顺序读,以及dout_valid要和 Standard FIFO 的输出延迟对齐。

module pp_rd_ctrl #( parameter integer DW = 32, parameter integer FRAME_LEN = 512 )( input wire rd_clk, input wire rd_rst, input wire [1:0] empty, output reg [1:0] fifo_rd_en, output wire dout_valid, input wire wr_tgl0_sync, input wire wr_tgl1_sync, output reg rd_tgl0, output reg rd_tgl1, output reg out_bank, output reg [DW-1:0] dout ); localparam integer PW = $clog2(FRAME_LEN); reg [PW-1:0] rd_ptr; reg reading; reg rd_sel; reg last_sel; reg valid_r; wire bank0_has = (wr_tgl0_sync != rd_tgl0); wire bank1_has = (wr_tgl1_sync != rd_tgl1); assign dout_valid = valid_r; always @(posedge rd_clk) begin if (rd_rst) begin rd_ptr <= {PW{1'b0}}; reading <= 1'b0; rd_sel <= 1'b0; last_sel <= 1'b1; valid_r <= 1'b0; fifo_rd_en <= 2'b00; rd_tgl0 <= 1'b0; rd_tgl1 <= 1'b0; end else begin fifo_rd_en <= 2'b00; valid_r <= 1'b0; if (!reading) begin if (bank0_has && !bank1_has) begin reading <= 1'b1; rd_sel <= 1'b0; rd_ptr <= {PW{1'b0}}; end else if (bank1_has && !bank0_has) begin reading <= 1'b1; rd_sel <= 1'b1; rd_ptr <= {PW{1'b0}}; end else if (bank0_has && bank1_has) begin // 两块都有数据,从上次没读的那块开始,保证先写先读 reading <= 1'b1; rd_sel <= ~last_sel; rd_ptr <= {PW{1'b0}}; end end else if (!empty[rd_sel]) begin fifo_rd_en[rd_sel] <= 1'b1; // 本拍发读,下拍 dout 有效 rd_ptr <= rd_ptr + 1'b1; valid_r <= 1'b1; // 与 FIFO 输出延迟对齐 out_bank <= rd_sel; if (rd_ptr == FRAME_LEN-1) begin reading <= 1'b0; last_sel <= rd_sel; if (rd_sel == 1'b0) rd_tgl0 <= ~rd_tgl0; // 告诉写侧 bank0 空 else rd_tgl1 <= ~rd_tgl1; end end end end endmodule

valid_r在发rd_en的同一拍拉高,下一拍和 FIFO 的dout一起有效。out_bank也同一拍更新,用来在顶层选择数据。如果这里用reading && !empty直接当 valid,就会早一拍,下游收到的第一个数据和 valid 对不上。

!empty[rd_sel]这个保护不能省。它存在的意义是防止参数配错时读空 FIFO,正常情况下永远为真。真撞上了,读侧会卡住等写侧补数据,此时如果写侧也在等读侧释放 bank,就会死锁——但死锁至少能看到,比读到一堆假数据强。

4.4 约束:时钟组与同步器例外

两个时钟来自不同源时,必须告诉工具它们是异步的,否则会去分析本来就不该分析的路径,要么报大量违例,要么工具自己乱优化。

# 写域 100 MHz,读域 75 MHz,两个时钟无相位关系 create_clock -period 10.000 -name wr_clk [get_ports wr_clk] create_clock -period 13.333 -name rd_clk [get_ports rd_clk] # 声明异步,工具不再分析两组时钟之间的建立/保持 set_clock_groups -asynchronous -group [get_clocks wr_clk] -group [get_clocks rd_clk] # 同步器首级不参与时序分析,避免工具把它当普通路径优化 set_false_path -to [get_cells -h <p> <a href="https://download.csdn.net/download/wczzmmp2/85695758" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进