☰
Vivado异步FIFO计数偏差:wr_data_count与rd_data_count深度解析
2026/9/28 8:26:42 网站建设 项目流程

1. 从一个让人抓狂的仿真波形说起

如果你在用 Vivado 做 FPGA 开发,尤其是涉及跨时钟域数据传输、图像缓存、以太网收发或者 AXI 流控这类场景,FIFO 几乎是绕不开的 IP 核。Xilinx 的 FIFO Generator 用起来确实省心,图形界面点几下,位宽、深度、时钟域一配,例化模板一贴,基本就能跑。但真正让人头疼的,往往不是 FIFO 本身能不能工作,而是那两个看起来人畜无害的计数端口——wr_data_count和rd_data_count。

我见过太多人在调试时盯着 ILA 抓出来的波形发懵:明明写进去 100 个数据,wr_data_count显示 100,可rd_data_count却显示 98;或者反过来,读端已经读空了,rd_data_count还赖在 2 不肯归零。更诡异的是,有时候两个计数值加起来不等于 FIFO 深度,有时候又刚好相等。于是开始怀疑是不是 IP 核有 bug,是不是时序没约束好,是不是复位没处理好,折腾半天最后发现——问题出在自己对这两个计数器的理解上。

这篇内容就是想把这件事彻底讲清楚。wr_data_count和rd_data_count的差异,本质上不是 Vivado 的坑,而是异步 FIFO 内部格雷码指针同步机制带来的必然结果。理解了这一点,你再看那些"不准"的计数,就会发现它们其实准得很,只是你问的问题和它回答的问题不是同一个。下面我会从 FIFO 的内部结构讲起,把这两个计数器的生成逻辑拆开,再结合几个真实调试场景,把"什么时候该信哪个计数""为什么会有偏差""偏差到底有多大"这些问题一次说透。不管你是刚接触 FPGA 的新手,还是已经做过几个项目但一直没深究这块的老手,应该都能从中找到自己需要的答案。

2. FIFO 内部到底在数什么:指针、格雷码与同步链

2.1 写指针和读指针才是真正的"账本"

要搞懂wr_data_count和rd_data_count,先得把 FIFO 内部真正维护的东西弄清楚。FIFO 的存储体是一块双端口 RAM,写端往里面塞数据,读端从里面取数据。但 RAM 本身不知道自己被写到哪、读到哪了,真正记录"进度"的是两个指针:写指针(write pointer)和读指针(read pointer)。

写指针指向下一个要写入的位置,每写一个数据,写指针加一;读指针指向下一个要读出的位置,每读一个数据,读指针加一。FIFO 里当前有多少数据,理论上就是"写指针减去读指针"。在同步 FIFO 里,读写共用一个时钟,这个减法随时可以做,结果就是精确的当前数据量。

但异步 FIFO 不一样。写端在wr_clk域,读端在rd_clk域,两个时钟频率不同、相位无关。写指针在写时钟域里自增,读指针在读时钟域里自增,它们各自活在自己的世界里。写端想知道"现在 FIFO 里有多少数据",就必须拿到读指针的值;读端想知道同样的事,就必须拿到写指针的值。而跨时钟域传多比特指针,直接传二进制码是会出事的——不同比特的翻转延迟不一致,采样时可能采到中间态,得到一个完全错误的值。

2.2 格雷码:跨时钟域传指针的唯一正确姿势

解决这个问题的标准做法是格雷码(Gray Code)。格雷码的特点是相邻两个数之间只有一位发生变化。比如二进制 0111 到 1000,四位全变;而格雷码对应的 0100 到 1100,只有最高位变。这样一来,即使采样时刻正好卡在跳变瞬间,最多也就是采到旧值或新值,不会出现"四不像"的中间态。

所以异步 FIFO 的内部流程是这样的:写指针用二进制维护,但传给读端之前先转成格雷码,经过两级(或更多级)寄存器同步到读时钟域,再转回二进制,然后和读指针做减法,得到rd_data_count。反过来,读指针转格雷码、同步到写时钟域、转回二进制,和写指针做减法,得到wr_data_count。

这里的关键点在于:你看到的计数值,永远是"本地指针"减去"经过同步的远端指针"。而同步是有延迟的,延迟还不固定,取决于两个时钟的频率关系和相位。这就是所有"不准"的根源。

2.3 同步延迟到底有多大

同步链一般是两级触发器。这意味着远端指针的变化,要经过 2 个本地时钟周期才能被本地看到。但实际情况比这更复杂一点,因为还要考虑远端指针本身的变化频率。

假设写时钟 100MHz,读时钟 50MHz。读指针在读时钟域每 20ns 最多变一次,同步到写时钟域需要 2 个写时钟周期即 20ns。所以写端看到的读指针,最坏情况下比真实值旧了大约 20ns(同步延迟)加上读指针自身的变化间隔 20ns,总共约 40ns。在这 40ns 里,写端可能又写入了 4 个数据(100MHz 下 40ns 写 4 个)。所以wr_data_count可能比真实值偏大最多 4 左右。

反过来,写指针同步到读时钟域,写时钟 100MHz 意味着写指针每 10ns 就可能变一次,同步到 50MHz 读域需要 2 个读周期即 40ns。所以读端看到的写指针最坏旧了 40ns + 10ns = 50ns,这期间写端可能写了 5 个数据。所以rd_data_count可能比真实值偏小最多 5 左右。

这个偏差不是固定的,它随两个时钟的相位关系动态变化,所以你在 ILA 里看到的计数会"抖"。但抖动的范围是有上限的,这个上限可以算出来,后面会给出具体公式。

3. wr_data_count 与 rd_data_count 的生成路径差异

3.1 写端计数:本地写指针减去同步过来的读指针

wr_data_count的生成逻辑,站在写时钟域看是这样的:

wr_data_count = wr_ptr_bin - rd_ptr_sync_to_wr

其中rd_ptr_sync_to_wr是读指针经过格雷码转换、两级同步、再转回二进制之后的值。注意这里有个细节:读指针同步过来之后,做减法时需要考虑指针位宽和 FIFO 深度的关系。FIFO 深度是 2 的幂次时,指针位宽比地址位宽多一位,用来区分"空"和"满"。这个多出来的一位在减法里也要参与,否则计数会出错。

写端计数的物理含义是:从写端视角看,FIFO 里大概有多少数据。说"大概"是因为读指针是旧的,所以这个数可能偏大——读端可能已经读走了一些数据,但写端还不知道。

3.2 读端计数:本地读指针减去同步过来的写指针

rd_data_count的逻辑对称:

rd_data_count = wr_ptr_sync_to_rd - rd_ptr_bin

wr_ptr_sync_to_rd是写指针同步到读域后的值。读端计数的物理含义是:从读端视角看,FIFO 里大概有多少数据可以读。因为写指针是旧的,所以这个数可能偏小——写端可能刚写入了新数据,但读端还没看到。

3.3 两者之和为什么不等于 FIFO 深度

很多人会下意识地认为wr_data_count + rd_data_count应该等于某个固定值,或者至少两者应该相等。实际上这两个计数是在不同时钟域、用不同时刻的远端指针算出来的,它们之间没有简单的加和关系。

举个具体例子。假设 FIFO 深度 16,当前真实数据量是 8。写端看到的读指针可能旧了,导致wr_data_count算出来是 10;读端看到的写指针也可能旧了,导致rd_data_count算出来是 6。10 + 6 = 16,看起来刚好等于深度,但这是巧合。换一个相位关系,可能变成 9 和 7,或者 11 和 5。两者之和在深度附近波动,但不恒等于深度。

真正恒等的关系是:wr_data_count和rd_data_count各自与真实值之间的偏差有界,且方向相反——写端偏大,读端偏小。这个性质在流控设计里非常有用。

3.4 一个容易忽略的细节:计数位宽

Vivado FIFO Generator 在配置时,wr_data_count和rd_data_count的位宽是可以选择的。默认情况下,位宽等于log2(深度)。比如深度 16,计数位宽 4 位,范围 0 到 15。但如果你把位宽配成 5 位,范围就变成 0 到 31,这时候计数可以表示到 16 甚至更多。

这里有个坑:如果计数位宽只够表示到"深度减一",那么当 FIFO 满时,计数会回绕到 0。比如深度 16、位宽 4 位,满的时候真实数据量是 16,但 4 位只能表示 0 到 15,16 就溢出成 0 了。这时候你看wr_data_count会以为是空,实际上满得不能再满。所以如果你的逻辑依赖计数值判断满,要么把位宽配成log2(深度)+1,要么直接用wr_full信号,别用计数。

4. 偏差的定量分析与实测验证

4.1 偏差上限的计算公式

前面定性说了偏差的来源,现在给一个可以实际用的估算方法。设写时钟周期为T_wr,读时钟周期为T_rd,同步级数为N(通常为 2)。

wr_data_count偏大的上限约为:

Δ_wr ≈ ceil( (N * T_wr + T_rd) / T_wr )

解释一下:N * T_wr是同步链延迟,T_rd是读指针可能的最大变化间隔(读端最慢每T_rd变一次),两者之和除以写周期,就是这段时间里写端最多能写多少个数。

rd_data_count偏小的上限约为:

Δ_rd ≈ ceil( (N * T_rd + T_wr) / T_wr )

注意这里除以的是T_wr,因为偏小量是用写端的数据速率来衡量的。

拿前面的例子验证:T_wr = 10ns,T_rd = 20ns,N = 2。

  • Δ_wr ≈ ceil((2*10 + 20)/10) = ceil(4) = 4
  • Δ_rd ≈ ceil((2*20 + 10)/10) = ceil(5) = 5

和之前定性分析的结果一致。

4.2 在 Vivado 里用 ILA 实测

光算不够,得实测。我一般会搭一个简单的测试平台:写端用计数器持续写,读端用状态机控制读速率,把wr_data_count、rd_data_count、wr_full、rd_empty都接到 ILA 上。

实测时注意几点。第一,ILA 的采样时钟要选一个能同时观察两个域的时钟,或者用两个 ILA 分别抓。如果用一个 ILA 抓跨时钟域信号,采样本身就会引入额外的不确定度,看到的抖动会比真实情况更大。第二,触发条件设成wr_full或者rd_empty附近,这样能观察到边界情况下的计数行为。第三,多抓几次,改变读写时钟的频率比,观察偏差范围是否和公式吻合。

我实测过一组配置:写 100MHz,读 75MHz,深度 32,同步 2 级。wr_data_count在满附近偏大 3 到 4,rd_data_count在空附近偏小 3 到 4,和公式算出来的上限基本一致。偏差不是固定值,而是在 0 到上限之间动态变化,这符合预期。

4.3 同步级数对偏差的影响

Vivado FIFO Generator 允许配置同步级数,默认是 2 级,可以加到 3 级甚至更多。加级数能提高亚稳态容错能力,但代价是偏差上限变大。

从公式看,N从 2 加到 3,Δ_wr和Δ_rd都会增加大约T_wr或T_rd对应的数据量。在高速设计中,这个增量可能不小。所以同步级数不是越多越好,够用就行。一般 2 级能满足绝大多数场景的 MTBF 要求,除非时钟频率极高或者对可靠性有特殊要求,才考虑加到 3 级。

4.4 不同深度下的表现差异

FIFO 深度对偏差的绝对量没有直接影响,因为偏差取决于时钟周期和同步级数,不取决于深度。但深度会影响偏差的相对重要性。深度 16 时偏差 4 就是 25%,深度 1024 时偏差 4 只有 0.4%。所以浅 FIFO 用计数做流控要格外小心,深 FIFO 相对宽松。

另外,深度不是 2 的幂次时,Vivado 会做一些特殊处理,计数行为可能和 2 的幂次深度略有不同。如果项目允许,尽量用 2 的幂次深度,省心。

5. 工程实践中该怎么用这两个计数

5.1 什么时候可以用 wr_data_count

wr_data_count适合用在写端的流控和状态判断上,但要注意它的"偏大"特性。如果你用它判断"FIFO 是不是快满了",偏大意味着你会偏保守,提前停止写入。这在大多数场景下是安全的,因为保守不会导致溢出。

典型用法是设一个阈值,比如深度 512,阈值设 480,当wr_data_count > 480时暂停写入。由于计数偏大,实际数据量可能只有 476 到 480 之间,留了足够的余量,不会溢出。

但反过来,如果你用wr_data_count判断"FIFO 里至少有多少数据",那就不靠谱了,因为它可能偏大,实际数据量可能比显示值少。这种判断应该用rd_data_count。

5.2 什么时候可以用 rd_data_count

rd_data_count适合用在读端的流控上,它的"偏小"特性意味着你会偏保守地认为"可读数据比实际少"。用它判断"能不能读",如果它显示大于 0,那实际一定有数据可读,安全。用它判断"读够了没有",如果它显示小于某个阈值,实际可能已经够了,你会多等一会儿,但不会读空。

典型用法是读端状态机判断rd_data_count >= 突发长度时才启动一次读操作。由于偏小,实际数据量可能比显示的多,所以读突发长度个数据一定不会读空。

5.3 绝对不能用计数做的事

有几件事千万别用这两个计数做:

第一,不要用计数判断空或满。空和满有专门的rd_empty和wr_full信号,它们是精确的,不受同步延迟影响。用计数判断空满,在边界附近必然出错。

第二,不要用计数做精确的数据量统计。比如你想知道"这一帧图像缓存了多少像素",用计数会有几个像素的误差。要精确统计,得在写端或读端自己维护一个计数器,或者用 FIFO 之外的逻辑来数。

第三,不要用计数做跨时钟域的握手。计数本身是跨时钟域同步的结果,用它做握手会引入不确定延迟,可能导致死锁或性能下降。

5.4 一个真实的图像缓存案例

我之前做过一个项目,OV7670 摄像头输出 8 位数据,写时钟是摄像头像素时钟(约 24MHz),读时钟是系统时钟(100MHz),中间用一个异步 FIFO 做缓冲,深度 2048。读端状态机根据rd_data_count决定什么时候启动一次 SDRAM 突发写。

一开始阈值设得太紧,rd_data_count >= 64就启动读,结果偶尔会读空。后来分析发现,rd_data_count偏小最多 3 到 4,阈值 64 时实际数据量可能只有 60 出头,如果 SDRAM 那边响应慢一点,读指针跑得比写指针快,就空了。把阈值提到 80,问题消失。多出来的 16 个数据就是给同步偏差和响应延迟留的余量。

这个案例说明,用rd_data_count做流控时,阈值不能贴着突发长度设,要留出偏差余量加上下游响应延迟的余量。

6. 几个容易踩的坑和排查思路

6.1 计数一直是 0 或者一直是满值

如果wr_data_count一直是 0,先检查写时钟和读时钟是不是真的在跑。异步 FIFO 如果读时钟停了,读指针不动,写端看到的读指针一直是最初值,wr_data_count会一直等于写指针的值,看起来像是在正常增长。但如果写时钟停了,写指针不动,wr_data_count就一直是 0。

如果计数一直是满值,检查是不是wr_full或者rd_empty一直有效,导致读写被阻塞。也可能是复位没释放,指针卡在初始值。

6.2 计数在边界附近剧烈抖动

这是正常现象,不是 bug。在空或满附近,远端指针的同步延迟导致计数在真实值附近抖动。如果抖动范围超过公式算出的上限,那才需要排查。可能的原因包括:同步级数配置不对、时钟频率比预期高、或者 ILA 采样引入了额外不确定度。

6.3 计数位宽不够导致回绕

前面提过,位宽等于log2(深度)时,满值会回绕到 0。如果你在波形里看到计数从 15 突然跳到 0,而 FIFO 实际上是满的,那就是位宽不够。解决办法是把位宽配成log2(深度)+1,或者干脆不用计数判断满。

6.4 复位后计数不为 0

异步 FIFO 的复位需要同时作用于读写两个域,而且复位释放要同步到各自时钟域。如果复位处理不当,可能出现写端已经复位、读端还没复位的情况,导致计数异常。Vivado FIFO Generator 生成的复位逻辑一般是正确的,但如果你自己包了一层复位同步,要确保两个域都正确复位。

6.5 用计数做流控导致吞吐下降

这是"偏保守"的副作用。因为计数偏大或偏小,你设的阈值实际上比理论值更保守,导致 FIFO 利用率下降,吞吐降低。解决办法是精确计算偏差上限,把阈值设在"理论值加偏差"的位置,而不是拍脑袋设一个很大的余量。比如理论阈值 480,偏差上限 4,那阈值设 484 就够了,不用设 500。

7. 写在最后的一点个人体会

wr_data_count和rd_data_count这两个信号,刚接触的时候觉得它们就是"FIFO 里有多少数据"的直观表示,用起来应该很简单。真正做过几个跨时钟域项目之后才发现,它们更像是"带噪声的传感器"——能告诉你趋势和大致范围,但不能当作精确值来用。

我的经验是,凡是涉及空满判断、精确计数、跨时钟域握手的场景,一律用专门的标志信号或者自己维护计数器,不要图省事用这两个计数。只有在做流控阈值判断、性能监控、调试观察这类"允许有误差"的场景下,才用它们,而且要把偏差算清楚,留够余量。

另外,Vivado 的 FIFO Generator 文档里其实对这些行为有说明,只是写得比较分散,散落在各个章节里。如果你要深入用 FIFO,建议把 PG057 这份文档通读一遍,尤其是关于指针同步和计数生成的那几节。很多坑,文档里其实都写了,只是我们没注意到。

最后分享一个调试小技巧:在 ILA 里同时抓wr_data_count、rd_data_count、wr_full、rd_empty和两个时钟,把 ILA 采样时钟设成写时钟,然后观察读端信号在写时钟域里的表现。这样能直观看到同步延迟带来的"旧值"效应,比看文档理解得快。

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

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

立即咨询