简介:工程文件围绕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 Type | Native | AXI 版多一层握手,除非直接接 DMA,否则纯属加复杂度 |
| FIFO Implementation | Independent Clocks Block RAM | 异步 FIFO 必须选独立时钟,选 Common Clocks 出不来两个时钟口 |
| Read Mode | Standard FIFO | 与 FWFT 差一拍,读侧计数逻辑完全不同 |
| Data Width | 32 | 与上游数据位宽一致,改位宽要同步改顶层参数 |
| Depth | 1024 | 取两倍帧长,512 帧长留一半余量给切换抖动 |
| Almost Full Threshold | 32 | 提前 32 拍给出水位提示,给切换逻辑留反应时间 |
| Programmable Almost Empty | 4 | 调试时看水位用,逻辑上不依赖它 |
| 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 endmodulefifo_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 endmodulefifo_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 endmodulevalid_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>