☰
FPGA跨时钟域突发流双缓冲:异步FIFO乒乓设计与避坑
2026/10/26 12:47:06 网站建设 项目流程

简介:面向FPGA初学者与需要夯实跨时钟域设计的中级开发者,这份工程以Vivado异步FIFO IP为核心,演示读写分处不同时钟域时如何借助乒乓结构不间断搬运数据:ping、pong两路缓冲交替承担写入与读出,切换由状态机依据空满标志决策,避免丢数或错序。压缩包共205个文件、约17.82MB,主体为23个Verilog源文件与5至7个VHDL文件,另有xdc约束、xci IP配置、xpr工程文件、do与tcl脚本、仿真日志及综合报告,覆盖从源码到实现报告的完整链路。已有975人学习下载。读者可据此对照IP Catalog配置参数,理解读写指针同步、握手信号与双端口RAM的取舍,并借仿真波形与综合结果验证边界条件,形成可复用的异步FIFO乒乓操作模板,用于高速采集、图像流水与通信接口等连续数据流场景。工程保留了综合完成标记与仿真运行脚本,便于直接复现实验并逐层排查时序问题。

1. 一块大 FIFO 救不了的场景:跨时钟域突发流的双缓冲方案

采集前端在 100 MHz 下连续出数,后级处理链跑在 200 MHz,但因为要攒批打包,读的时候有一阵没一阵。很多人的第一反应是把 FIFO 深度翻倍,觉得容量够了就不丢数。深度只买延迟,不买吞吐——平均读速率低于平均写速率时,多大的 FIFO 都会被填满。乒乓操作换了个思路:两块缓冲轮流用,写一块的同时读另一块,把“容量”换成“时间上的并行”。这里要泼一盆冷水:乒乓吸收的是突发,不是持续的速率失配,这一点搞反了上板必翻车。这套方案适合要交 FPGA 数据通路课设或毕设、需要一份能仿真能上板的 Vivado 工程的人,也适合工作里第一次做跨时钟域缓冲的同行。

2. 总体架构设计:双缓冲分区、三个时钟域与握手边界

先把架构定下来,再谈代码。这一章要回答三个问题:为什么是两块而不是一块、模块怎么切、切换点定在哪里。定不下来这三件事,后面写再多的 verilog 也是在给 bug 铺路。

2.1 单块大 FIFO 与双块乒乓的容量账

单块 FIFO 的深度需求可以写成D ≥ (R_wr - R_rd) × T_gap,其中R_wr是写入速率,R_rd是读出速率,T_gap是读侧最长的一次停顿。采集链 100 MHz × 16 bit 就是 200 MB/s,后级做一个 512 点批处理,中间可能停 10 μs 不读,这一停就要吃掉 1000 个 16 bit 数据,深度至少得 1024 起步。这还只是一次停顿,如果停顿是周期性的、平均速率刚好持平,FIFO 会越垫越满,因为没有机制让它回到半空状态,深度还得往上加。

乒乓把这件事拆开了。单块深度取 2048,写满一块耗 20.48 μs,读侧 200 MHz 只需 10.24 μs 就能清空一块,留出整整一倍的时间去做别的。两块交替之后,系统的约束从“FIFO 不能满”变成了“每一块的时间内必须把上一块读完”,这是两条完全不同的设计线:前者拼容量,后者拼时序预算。代价是控制逻辑变复杂,而且写侧必须能被背压——如果上游是绝对不能停的连续流,两块缓冲在极端情况下还是会顶穿,那就得考虑三块或者加大单块深度。

提示:判断标准很简单,算一下写侧填满一块需要多久,再算读侧清空一块需要多久。后者必须比前者小,而且要留 30% 以上的余量,否则一旦后级某次多停了几个周期,积压就会滚雪球。

2.2 模块划分与信号流向

工程切成五块,按时钟域分家,跨域的只留同步器这一条通道。这样划分的好处是每一块都能单独仿真,出问题的时候能快速定位到是写侧切早了还是读侧读慢了。

模块时钟域职责关键信号
fifo_wr_ctrlwr_clk选写入块、判满切块、产生块计数wr_bank、wr_blk_cnt
fifo_rd_ctrlrd_clk选读出块、判空、产生读完成计数rd_bank、rd_blk_cnt
cdc_sync双侧单比特/多比特准静态信号同步两级触发器
fifo_a / fifo_b独立时钟FIFO Generator 例化,各 2048×16almost_full、empty
pingpong_top—连线、写使能门控、输出选择fifo_wr_en、fifo_rd_en

信号流向也定死:外部写数据只进两个 FIFO 的 din,写侧控制只负责把wr_en分发到当前选中的那一块;读侧同理,从当前选中的那一块取dout。两块 FIFO 的读写使能永远不会同时有效,这是切块逻辑要保证的第一条不变量。

2.3 切换判据:almost_full 阈值与时序预算

用 almost_full 触发切块,不用 full。原因是 full 是个“已经来不及”的信号,从 full 拉高到写侧真正把wr_en拉低,中间隔着组合逻辑、输出寄存器,实际还会多写进去两三拍。almost_full 提前一段距离报警,把这几拍的账算进去。

阈值怎么定?把同步延迟和响应延迟加起来。写侧切块后,读侧要经过两级同步才能看到新的块号,200 MHz 下是 10 ns;读侧判断读空、再同步回写侧,又两级,再 10 ns。写时钟 100 MHz 一拍 10 ns,整个过程不超过 3 拍,也就是 30 ns。阈值留 128 个深度的余量,时间上是 1.28 μs,比 30 ns 大了两个数量级,稳。

如果反过来,写时钟比读时钟快很多,比如写 200 MHz、读 50 MHz,这个余量就得按最慢的那个域重新算,经验上留 1/16 的深度比较安全,也就是 2048 深度留 128,4096 留 256。

2.4 深度与时钟比的计算

把参数摆出来算一遍,比拍脑袋靠谱。设计的硬指标是:写侧必须能连续灌,读侧允许有 gap,但每块的清空时间要小于写入时间。

参数取值说明
wr_clk100 MHz采集侧,连续写
rd_clk200 MHz处理侧,突发读,占空比约 70%
位宽16 bit读写同宽,不做位宽转换
单块深度2048两块共 4096×16,约 2 个 BRAM36
almost_full 阈值1920留 128 深度余量
写满一块20.48 μs2048 / 100 MHz
清空一块14.63 μs2048 / (200 MHz × 0.7)

清空时间 14.63 μs 小于写入时间 20.48 μs,余量 28%,刚好过线。如果后级处理更重,读占空比掉到 50%,清空时间变成 20.48 μs,正好打平,这时候要么提高读时钟,要么把单块深度降到 1024 换取更快的周转——深度小周转快,深度大抗突发强,这是一对要权衡的量。

3. 异步FIFO IP 配置:读写独立时钟下的必看参数

IP 的配置页看着选项不多,选错一个就得重来。这一章按页面顺序把几个真正影响行为的选项过一遍,顺带说清楚为什么这么选。

3.1 基础页:接口类型、读模式与位宽

Interface Type 选 Native,不要选 AXI。AXI 那套握手机制在这里是纯负担,乒乓自身的切块逻辑已经承担了流控角色,再叠一层 AXI 只会让时序更难收敛。Clock Type 选 Independent Clocks Block RAM,这是异步 FIFO 的标准配置,读写各一个时钟,内部用格雷码指针跨域。

Read Mode 这一项要掰扯一下。Standard FIFO 的读延迟是 1 拍,rd_en拉高的下一个周期dout才有效;First Word Fall Through 则是数据提前摆出来,rd_en更像一个“确认”信号。乒乓方案里读侧要精确判断“这一块读空了没有”,Standard 模式的语义更干净:empty拉高就是真的没数了,一拍不多一拍不少。FWFT 省下的那一拍延迟在这个场景里意义不大,反而让空判断的边界变模糊。

位宽这一项,如果不做位宽转换,读写都填 16,深度填 2048。如果要转换,比如写 16 读 32,可以让 IP 自己做不对称位宽,代价是深度的换算方式变了——写侧深度 2048 对应读侧 1024,almost_full 的阈值要按写侧口径设,别设反了。

3.2 标志位页:阈值、计数与不要碰的信号

Almost Full 和 Almost Empty 都要开,前者用于切块,后者用于读侧提前准备。Almost Full Assert Value 填 1920,Almost Empty 填 8,这两个数不是随手写的,1920 来自上一章的时序预算,8 是给读侧留一点收尾余量。

Data Count 默认会打开,位宽是log2(2048)+1 = 12。这个信号在异步 FIFO 里是跨域计算出来的,别拿去和另一个时钟域的计数器直接比大小。它的用途仅限于本域观察,比如写侧看看当前块还剩多少空间,读侧看看积压了多少。想跨域判断“读侧读到哪了”,得自己做过握手,这是后面要讲的。

注意:Simulation 页的 Reset Pin 建议勾上,虽然多一个引脚,但调试期能手动复位 FIFO 内部指针,比重新烧比特流快得多。上板验证完再考虑要不要关掉。

3.3 复位页:同步复位与 rst_busy 的等待

FIFO Generator 的复位是同步复位,wr_rst必须同步于wr_clk,rd_rst同步于rd_clk,两个复位不需要对齐。这一步不难,难的是很多人复位之后立刻就开始写,结果前几个数莫名其妙丢了。

原因在wr_rst_busy。IP 复位之后内部指针要重新对齐,这个信号会保持高电平若干拍,期间写使能无效。正确的做法是把wr_rst_busy拉低当作“可以开始工作”的许可:

// 复位同步 + busy 等待:两件事分开做,别混在一起 reg [1:0] wr_rst_sync; always @(posedge wr_clk or negedge rst_n) begin if (!rst_n) wr_rst_sync <= 2'b11; else wr_rst_sync <= {wr_rst_sync[0], 1'b0}; end wire wr_rst = wr_rst_sync[1]; // 同步后的复位,送进 IP 的 wr_rst // 工作许可:只有 busy 拉低之后,写侧状态机才允许动 wire wr_ready = !wr_rst_busy;

这段代码做了两件事:把外部异步复位打成两级同步,保证wr_rst的释放沿落在wr_clk的建立保持窗口之外;把wr_rst_busy单独拎出来当门控,而不是拿它当复位用。这两件事混在一起是常见错误,用 busy 去复位状态机会导致状态机在 busy 期间反复复位,永远起不来。

3.4 例化与参数传递

两块 FIFO 参数完全一样,用 generate 展开比复制粘贴两份更不容易出错,改深度的时候只改一处。

// 双块 FIFO 例化:参数集中在一处,改深度只改这里 localparam integer FIFO_DW = 16; localparam integer FIFO_DEP = 2048; localparam integer AF_TH = 1920; genvar gi; generate for (gi = 0; gi < 2; gi = gi + 1) begin : gen_fifo fifo_generator_0 u_fifo ( .wr_clk (wr_clk), .rd_clk (rd_clk), .din (fifo_din[gi]), .wr_en (fifo_wr_en[gi]), .rd_en (fifo_rd_en[gi]), .dout (fifo_dout[gi]), .full (fifo_full[gi]), .almost_full (fifo_afull[gi]), // 阈值在 IP 里配置,此处只接出 .empty (fifo_empty[gi]), .almost_empty (fifo_aempty[gi]), .wr_rst (wr_rst), .rd_rst (rd_rst), .wr_rst_busy (wr_rst_busy), .rd_rst_busy (rd_rst_busy) ); end endgenerate

din用向量接法,fifo_din[0]和fifo_din[1]都是 16 位,写侧根据wr_bank把数据分发到对应的一位上。wr_en和rd_en也是同样的向量化处理,只有被选中的那一块才会收到有效的使能。这样两份 IP 的连线完全对称,看代码的时候不用在 A 块和 B 块之间来回跳。

4. 乒乓切换控制:写侧选块、读侧同步与背压逻辑

架构和 IP 都定了,剩下的是切块本身。这一章的两段代码是整套工程的核心,一段在写域,一段在读域,中间靠同步器连接。写的时候心里要有一条线:任何跨域信号都不能直接参与组合判断,都得先打两拍。

4.1 写侧:块计数、切块条件与写使能门控

写侧维护两个东西:当前写哪一块wr_bank,以及已经写满了多少块wr_blk_cnt。切块的触发条件是当前块 almost_full,且目标块已经被读空。读空的证据来自读侧同步回来的rd_blk_cnt,两者一比就知道目标块安不安全。

// 写侧切块:almost_full 且目标块已清空,才允许翻转 bank reg wr_bank; reg [3:0] wr_blk_cnt; // 写完的块数,4 位回绕,差值不超过 15 都安全 wire cur_afull = wr_bank ? fifo_afull[1] : fifo_afull[0]; wire can_switch = (rd_blk_cnt_sync <= wr_blk_cnt); always @(posedge wr_clk) begin if (!wr_ready) begin wr_bank <= 1'b0; // 复位后默认先写 A 块 wr_blk_cnt <= 4'd0; end else if (wr_en_in && cur_afull && can_switch) begin wr_bank <= ~wr_bank; // 切到另一块继续写,不停流 wr_blk_cnt <= wr_blk_cnt + 4'd1; end end // 写使能门控:当前块几乎满之后立刻关闭,等切块完成 assign fifo_wr_en[0] = wr_en_in && (wr_bank == 1'b0) && !cur_afull; assign fifo_wr_en[1] = wr_en_in && (wr_bank == 1'b1) && !cur_afull;

can_switch这个条件看着别扭,实际上是在防止覆盖。wr_blk_cnt是写完的块数,rd_blk_cnt_sync是读完的块数,后者追上来了说明目标块已经被清空,可以安全覆盖。如果读侧还没读完,can_switch为假,写侧就停在 almost_full 上不动,fifo_wr_en全为 0,上游必须能停。这就是前面反复强调的背压点,它不是设计缺陷,是乒乓结构的固有约束。

wr_blk_cnt用 4 位是够的,块与块之间差 20 μs,4 位能记 16 块,回绕周期 300 多微秒,远大于同步延迟。真要用到 8 位也没问题,只是没必要。

4.2 读侧:写块同步、读空判定与完成计数

读侧的活儿简单一些:把写侧的wr_bank同步过来,取反就是自己要读的块;读完一块就把rd_blk_cnt加一,再同步回写域。

// 读侧选块:写侧块号两级同步后取反,读的永远是"不在写"的那一块 reg [1:0] wr_bank_sync; always @(posedge rd_clk) begin if (!rd_ready) wr_bank_sync <= 2'b00; else wr_bank_sync <= {wr_bank_sync[0], wr_bank}; end wire rd_bank = ~wr_bank_sync[1]; wire cur_empty = rd_bank ? fifo_empty[1] : fifo_empty[0]; always @(posedge rd_clk) begin if (!rd_ready) begin rd_blk_cnt <= 4'd0; end else if (rd_en_in && cur_empty) begin rd_blk_cnt <= rd_blk_cnt + 4'd1; // 一块读完,计数加一 end end assign fifo_rd_en[0] = rd_en_in && (rd_bank == 1'b0) && !cur_empty; assign fifo_rd_en[1] = rd_en_in && (rd_bank == 1'b1) && !cur_empty;

关键在于rd_bank = ~wr_bank_sync[1]。写侧正在写 A,读侧读 B;写侧切到 B,读侧改读 A。切换发生在写侧 almost_full 的那一刻,此时 A 已经填了 1920 个,读侧切过去有足够的数可读。同步延迟两拍,在 200 MHz 下是 10 ns,对应写侧 1 个拍,A 块在这期间最多多进 1 个数,不影响。

rd_blk_cnt在cur_empty为真时加一,这个时机要卡准。cur_empty是组合信号,读完最后一个数的那一拍empty才会拉高,此时加一正好对应“这一块彻底读完了”。如果提前一拍加,写侧会以为目标块已经清空,实际还差一个数,覆盖就发生了。

4.3 顶层连线与直通信号

顶层只做连线和一点分发,不塞逻辑。wr_en_in来自上游,rd_en_in来自下游,两个 FIFO 的dout用rd_bank选出来送给下游。

// 顶层:读数据选择,两块 dout 二选一 assign dout = rd_bank ? fifo_dout[1] : fifo_dout[0]; assign valid = rd_en_in && !cur_empty;

valid这个信号建议一定要有。下游拿数据的时候,光看rd_en不够,还得知道这一拍的数据是不是真的。cur_empty参与进来之后,valid就等价于“这一拍确实读出了一个有效数”,后级状态机直接拿它当握手请求,比自己去算 FIFO 空标志省事得多。

同步器建议单独放一个小模块,两个方向各一套,参数化位宽。写域到读域的wr_bank是 1 位,读域到写域的rd_blk_cnt是 4 位,都是准静态信号,两级触发器足够,配一条set_max_delay -datapath_only约束就行。

4.4 仿真激励怎么写才能跑到切换点

仿真跑不到切块的那一刻,等于没仿。激励要覆盖三个场景:正常交替、读侧停摆、复位后立即开写。

正常交替最简单,写侧连续灌数,读侧占空比 70%,跑 100 μs 至少能看到三次切块。读侧停摆更关键,让读使能停 50 μs,看看写侧是不是老老实实停在 almost_full 上等can_switch,而不是把数据覆盖掉。复位场景容易被忽略,复位释放后立刻给写使能,观察wr_rst_busy期间有没有数据被吞。

// 激励片段:读侧中途停 50 us,验证背压是否生效 initial begin wr_en_in = 1'b0; rd_en_in = 1'b0; #200 rst_n = 1'b1; #100 wr_en_in = 1'b1; // 写侧立刻开灌 #1000 rd_en_in = 1'b1; // 读侧晚点启动 #5000 rd_en_in = 1'b0; // 中途停摆 50 us(按 100 MHz 计时) #5000 rd_en_in = 1'b1; #20000 $finish; end

写数据的时候用递增计数器,读出来再比一遍,能直接抓出覆盖和错位。比只看波形高效得多。

5. 避坑与排查:五类上板才暴露的问题

仿真过了不代表上板能跑,时序、复位、跨域这几件事在仿真里都是理想化的。这一章的五条都是我在类似工程里真见过的,每条按现象、原因、解决三段写。

5.1 切换瞬间读写撞同一块

现象:长时间跑不会错,但偶发一包数据里出现一段重复或跳变,位置不固定。

原因:写侧切块和读侧同步之间有延迟,如果读侧还在读旧块,而写侧已经切回旧块开始写,读写指针就撞上了。这种情况只在读侧几乎贴着边界读空时才出现,仿真时间短很难撞到。

解决:把can_switch的条件收紧,要求rd_blk_cnt_sync严格等于或大于wr_blk_cnt,而不是只判断不等。再保险一点,在 almost_full 阈值上多留 64 个深度,让写侧的等待窗口更宽。上板后用 ILA 抓wr_bank和rd_bank,看两者是否存在同一时刻指向同一块的情况,抓一次长时间运行就够了。

5.2 almost_full 阈值没算同步延迟

现象:切块看似正常,但每块的最后十几个数对不上,读侧读到的和写侧写进去的不一致。

原因:阈值设得太靠近满,比如 2040,只留 8 个深度。写侧从 almost_full 到真正停写要几拍,读侧同步过来又要几拍,8 个深度在 100 MHz 下只有 80 ns,不够。

解决:阈值按“同步延迟 + 输出寄存器 + 组合逻辑延迟”的总和来定,经验公式是至少留 64 个深度,时钟比超过 4 倍时翻倍。改完之后跑一次长仿真,用data_count记录每块切换时的最大值,应该稳定停在阈值附近,不会顶到 full。

5.3 data_count 直接跨域比较

现象:综合报跨时钟域警告,或者上板后读侧判断“读到哪了”的结果和实际差很多。

原因:data_count是 FIFO 内部用格雷码指针算出来的,它本身在各自时钟域内是准的,但拿它和另一个域的计数器比大小,等于让组合逻辑去处理一个跨域的多比特值,中间态必然出错。

解决:跨域只传单比特或者准静态的计数器,比如本文里的rd_blk_cnt,每 20 μs 才变一次,两级同步完全够用。data_count只在本地域用,写侧看看剩余空间、读侧看看积压深度,别拿去做控制决策。

5.4 复位释放不同步导致指针错位

现象:上电后第一包数据永远错,后面恢复正常。

原因:wr_rst和rd_rst释放的时刻没有对齐,一个域已经开始工作,另一个域的指针还没对齐好,前几个数的读写地址对不上。

解决:两个域的复位各自用两级同步器,wr_rst_busy和rd_rst_busy都要等。如果系统里有全局复位,建议在顶层统一产生一个rst_n,再分发到两个域各自同步,不要在中间插入任何组合逻辑。

5.5 时序约束缺失或冲突

现象:时序报告里出现跨时钟域路径的 setup 违例,或者综合提示约束冲突。

原因:FIFO IP 自带的 XDC 已经处理了内部指针的跨域约束,如果外面又用set_false_path把整条路径切掉,或者用set_clock_groups覆盖了 IP 的约束,就会出问题。

解决:IP 内部的约束不要动,自己写的同步器路径单独加:

# 只约束自己写的跨域路径,别碰 IP 自带的 set_max_delay -datapath_only -from [get_cells {u_sync/wr_bank_reg[*]}] \ -to [get_cells {u_sync/wr_bank_sync_reg[*]}] 4.000

-datapath_only的作用是只约束数据路径延迟,不做时钟沿检查,这正是准静态信号需要的。时钟组的设置用set_clock_groups -asynchronous,但要把 IP 已经声明过的时钟排除掉,避免重复声明。

6. 验收与答辩:用 ILA 和时序报告把方案讲圆

工程能跑起来只是及格线,验收要拿出证据。我一般会准备一张表,每一项指标对应一个观测手段,答辩的时候按表讲,比空口说“功能正常”有说服力得多。

验收项观测手段合格判据
数据完整性写递增、读比对连续跑 10 分钟,零误码
切换行为ILA 抓 wr_bank / rd_bank每次切换前 almost_full 已拉高
背压有效性ILA 抓 can_switch 与 fifo_wr_en读侧停摆时写使能确实关闭
时序收敛实现后的时序报告WNS ≥ 0,跨域路径无违例
资源占用综合报告两块 FIFO 占 2 个 BRAM36 以内

ILA 的触发条件要设得刁钻一点,别只抓“almost_full 拉高”,那样看到的都是正常时序,看不出问题。把触发条件设成can_switch == 0 && cur_afull == 1,也就是写侧停在等待状态的那一刻,抓下来的波形最能说明背压逻辑是活的。抓的时候把wr_blk_cnt、rd_blk_cnt_sync、wr_bank、rd_bank一起放进去,四个信号摆在一起,读侧到底追没追上写侧,一眼就能看出来。

时序报告重点看两处。一处是WNS,只要不为负就过关,但如果某个跨域路径的 slack 只剩几十皮秒,得回头看看是不是约束写漏了。另一处是 CDC 报告,重点看有没有“unsafe”的路径,自己写的同步器路径应该显示为“Timed”或者“Safe”,如果是“Unsafe”,说明set_max_delay没生效。

答辩的时候,讲解顺序建议从“为什么不用一块大 FIFO”开始,把 2.1 节那个容量账讲出来,评委立刻就知道你算过。然后讲切换判据,讲 almost_full 阈值怎么来的,把同步延迟那几拍说清楚。最后讲背压,主动承认“读侧太慢时写侧会停”,这反而是加分项——说明你清楚方案的边界在哪。被问到“如果上游不能停怎么办”,答案就是加深单块深度或者加第三块缓冲,这个思路要能当场说出来。

提示:答辩前把 ILA 波形存成截图,标注好触发条件和关键时间点。现场重新抓波形有风险,一次抓不到就冷场了。

这套工程我前后改过几版,第一版就是因为 almost_full 阈值设成 2040 翻了车,仿真跑了十分钟全是好的,上板十分钟错三包,定位了整整两天。从那以后我每次配 FIFO IP,阈值这一栏都是先算再填,算完还要在仿真里打一条打印,把每次切块时的data_count打出来看峰值,确认没有顶到 full 才敢上板。希望这套结构能帮到你,少走那两天弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询