跨时钟域数据通路如何保证不丢包

在 SoC 里,几乎不存在整个芯片只有一个时钟频率的设计:CPU 有动态调频(DFS),DDR 控制器有自己的时钟,NoC、外设各自一个域。数据在这些不同频率的时钟域之间流动时,丢包是最高频的一类 bug。

这篇文章回答两个问题:

  1. 跨时钟域的数据通路,靠什么机制保证一个包都不丢;

  2. 当其中一侧的时钟在持续做 DFS(Dynamic Frequency Scaling,动态调频)时,这些机制哪些依然成立、哪些会失效,以及如何设计才能保证不丢包。

丢包的两个根源

先说清楚丢包到底是怎么发生的。跨时钟域丢包只有两个根源,除此之外别无其他:

根源一:亚稳态导致的采样错误

当写侧在 wclk 时钟沿更新数据,读侧用 rclk 采样时,两个时钟沿的相对相位是完全随机的。如果采样沿恰好落在数据翻转的窗口内,采样到的寄存器会进入亚稳态,输出一个既不是 0 也不是 1 的电平,并在之后随机时间才稳定:

wclk:    _|¨|_|¨|_|¨|_|¨|_
data:    ____|¨¨¨¨¨¨|____
              ^
rclk:    _|¨|_|¨|_|¨|_|¨|_
              |-- 采样沿落在翻转窗口 --> 亚稳态

亚稳态稳定后的结果是不确定的:可能采到旧值,也可能采到新值,还可能稳定成一个中间电平被后续逻辑解释成错误的值。采样错误 = 数据损坏 = 丢包(或错包)。

标准解法是两级同步器:

              +-----+        +-----+
async_sig --->| D Q |------->| D Q |-------> sync_sig (clk域)
              |     |        |     |
              +-----+        +-----+
                |              |
              clk 域          clk 域

第一级寄存器的亚稳态留给整整一个时钟周期去稳定,第二级采样到的是稳定值。两级同步器的失效概率(MTBF,Mean Time Between Failures)由下面的关系决定:

MTBF = e^(t_meta/τ) / (T0 × f_clk × f_data)

其中 t_meta 是留给亚稳态稳定的时间(一个时钟周期),τ、T0 是工艺常数。工程上只要 t_meta 足够大,MTBF 可以做到几百年,亚稳态问题就被解决了。

警告

两级同步器只能用于单比特控制信号或格雷码。并行数据总线不能直接用多级同步器同步:总线上各比特的走线延迟不同,同步后各比特可能落在新旧两个不同的"字"上(skew 导致的 reconvergence 问题),拼出一个从未存在过的数据。所以数据通路必须用握手协议或异步 FIFO,而不能把同步器用在数据总线上。

根源二:速率不匹配

第二个根源与亚稳态无关,是纯数学问题:写进去的速度比读出来的快,缓冲区必然溢出。

写入速率(平均或瞬时) > 读出速率  ==>  FIFO 溢出  ==>  丢包

注意"瞬时"两个字。哪怕写侧平均速率远低于读侧,只要写入是突发(burst)式的——比如 DMA 一次连续写 64 拍——突发期间的瞬时写入速率就可能超过读出速率。所以跨时钟域数据通路的设计,核心就是两条:

  1. 用同步器/异步 FIFO 解决亚稳态(保证采到的数据是对的);

  2. 用足够的深度 + 流控解决速率不匹配(保证数据有地方放、没地方放时上游停下来)。

异步 FIFO:跨时钟域数据通路的基础

异步 FIFO 是跨时钟域数据传输的标准载体。它的结构:

+--------------------------------------------------------------------+
|                          异步 FIFO                                 |
|                                                                    |
|  写时钟域 (wclk)              存储阵列              读时钟域 (rclk)  |
|                                                                    |
|  +-----------+   wdata    +------------------+   rdata  +---------+ |
|  | 写侧逻辑   | ---------> |   双端口 RAM      | -------> | 读侧逻辑 | |
|  |  (push)   |            |   (深度 2^n)      |          |  (pop)  | |
|  +-----+-----+            +------------------+          +----+----+ |
|        |                                                    |      |
|  +-----v-------+                                    +-------v----+ |
|  | 写指针(格雷码)|                                    | 读指针(格雷码)| |
|  +-----+-------+                                    +-------+----+ |
|        |                                                    |      |
|        |        指针同步(格雷码 + 两级同步器)                 |      |
|        +<-----------------+      +------------------------>+      |
|                                                                    |
+--------------------------------------------------------------------+

指针用格雷码传递

写指针在写时钟域产生,读指针在读时钟域产生。对方要判断空/满,就必须把指针同步过去。前面说过,并行总线不能直接同步,但格雷码可以:相邻两个地址的格雷码只有 1 比特翻转,同步时最多采到"旧值"或"新值",永远采不到非法的中间值——采错的那一拍只是把空/满判断推迟或提前一拍,不会产生错误地址。

空满判断是"保守的"

标准异步 FIFO(以经典的 Cummings 结构为例)的空满判断:

  • 空:读指针与同步过来的写指针完全相等——没有任何数据可读;

  • 满:写指针与同步过来的读指针最高位相反、其余位相等——写一圈追上读了。

由于指针同步有 2 拍延迟,这两个标志天然是保守的:

  • 写侧看到的读指针是"旧"的,读侧实际已经读走了更多数据 → 写侧会提前判满;

  • 读侧看到的写指针是"旧"的,写侧实际已经写入了更多数据 → 读侧会提前判空。

重要

异步 FIFO 的空/满标志只会"假满、假空",永远不会滞后于真实状态。假满只是损失一点深度利用率,假空只是让读侧空转几拍;它们损害性能,但绝不会导致丢包。这正是异步 FIFO 在各种频率组合下都不丢包的底层保证。

深度为什么取 2 的幂

格雷码是循环码,只有深度为 2 的幂时,"绕一圈回到原点"才能用最高位翻转来表示。所以 FIFO 深度要向上取整到 2 的幂,例如算出来需要 43,就取 64。

保证不丢包的条件一:FIFO 深度算对

深度公式推导

设:

  • 写侧突发长度 B(连续写 B 拍,每拍 1 个数据),写时钟频率 fw;

  • 读侧连续读(每周期读走 1 个),读时钟频率 fr。

写入 B 拍耗时 B / fw,这段时间读侧读走了 fr × (B / fw) = B × fr/fw 个数据。突发结束时 FIFO 里剩下的数据量是:

深度需求 = B × (1 − fr / fw)

这就是异步 FIFO 深度计算的基本公式。注意它只与频率比和突发长度有关,与绝对频率无关。

计算示例

写侧突发 64 拍,fw = 800 MHz,读侧 fr = 500 MHz:

深度需求 = 64 × (1 − 500/800) = 64 × 0.375 = 24

考虑指针同步的保守判满会吃掉约 2~3 拍的有效深度,实际取 24 + 3 = 27,向上取 2 的幂 → 深度 32。

保证不丢包的条件二:流控(背压)

FIFO 深度只能解决"突发"问题,解决不了"长期速率不匹配"问题。如果写侧平均速率持续高于读侧,再深的 FIFO 也会满。此时必须让上游停下来——这就是流控(flow control / backpressure)。三种常见机制:

valid/ready 握手

最常见的形式(AXI、valid-ready 接口都属此类):

         valid         ready
写侧  ----------> 读侧  <----------
  • valid 为 1 表示数据有效;

  • ready 为 1 表示接收方当拍能收;

  • 当拍 valid && ready 才算传输成功,否则数据保持。

读侧把 ready 拉低,写侧必须把数据停在寄存器里等待。握手是逐拍的、无损的——代价是背压要一级一级往前传,传播链上的每一级都要能"停住"。

水位线(almost_full)提前反压

异步 FIFO 的满标志是保守的,但"满"到"上游真正停手"之间还有一段延迟:上游看到 almost_full 之后,还要经过若干级流水、若干拍仲裁,才能真正停止写入。如果等真正的满标志才反压,反应延迟内写入的数据就会溢出。

所以工程上设置 almost_full 水位,提前量等于反压传播延迟:

almost_full 阈值 = FIFO 深度 − 反压传播延迟(拍) − 安全裕量

例如 FIFO 深度 64,写侧从看到反压到停止需要 5 拍,则 almost_full 设在 57 附近(再留 1~2 拍裕量)。

credit 机制

NoC 中常用:接收方向发送方发放 credit,发送方每发一个数据消耗一个 credit,credit 耗尽即停。credit 的返回路径本身就是异步的,需要在链路两端的寄存器里都保证"credit 不会透支"——发送端以本地计数为准,接收端的释放信息晚到只影响性能,不影响正确性。这和 FIFO 指针保守同步是同一个思想:让"慢半拍"的信息只造成性能损失,不造成溢出。

DFS 场景:一边频率一直在变

前面的讨论都假设 fw、fr 是固定值。现在把写侧换成持续做 DFS 的时钟域,看看哪些结论依然成立,哪些地方会翻车。

DFS 是什么

DFS(Dynamic Frequency Scaling)指运行中动态调整时钟频率:负载高时升频,负载低时降频省电。SoC 里 DFS 的实现有两类,它们的时钟行为完全不同,必须分开讨论:

1. glitch-free 时钟切换(clock mux)

芯片里 PLL 同时产生多个频率,用选择器在它们之间切换:

PLL 输出 0 (800MHz) ---+
                        |    +--------+
PLL 输出 1 (400MHz) ---+--->|  MUX   |----> clk_out
                             +--------+
                                  ^
                                  选择信号(先同步到两个时钟,在两个时钟都为低的窗口切换)

切换过程中时钟从不停止,只是周期变了。选择信号必须先做同步处理,保证切换点两侧的时钟沿都是完整的——否则切换瞬间会产生一个窄脉冲(glitch),格雷码指针会被这个假沿采错,直接导致地址错误。

警告

DFS 场景下,glitch-free 是异步 FIFO 正确性的硬前提。毛刺时钟下任何同步器都失效——这不是亚稳态概率问题,是确定性采错。

2. PLL 重新锁定(relock)

另一种实现是重新配置 PLL 的分频系数,配置期间 PLL 输出停止,锁定完成后时钟恢复。停止窗口通常在微秒量级。此时该时钟域的寄存器全部冻结,数据"停在原地"。

备注

现实中的 SoC DFS 多数走 glitch-free mux(切频快、时钟不断),relock 常见于省电场景(时钟门控、动态降电压前的准备)。设计跨时钟域通路时必须明确自己面对的是哪一种。

异步 FIFO 在 DFS 下的行为:正确性不变

先说好消息:异步 FIFO 的逻辑正确性与时钟频率无关。指针同步、格雷码比较、保守判空满,这些机制在频率任意变化时依然成立,因为:

  • 两级同步器解决的亚稳态问题,在低频下只会更安全(MTBF 公式中 f_data 降低,MTBF 上升);

  • 空满判断永远保守,不依赖频率的具体数值。

所以只要满足三个前提,DFS 本身不会让异步 FIFO 丢包:

  1. 时钟切换 glitch-free(或 relock 期间有流控兜底,见下文);

  2. FIFO 深度按所有频率组合中的最坏情况计算(见下节);

  3. 长期平均速率仍然满足"写入 ≤ 读出"(见下节)。

深度按最坏频率组合计算

这是 DFS 场景下的第一要务。写侧频率在 [fw_min, fw_max] 之间跳,读侧频率在 [fr_min, fr_max] 之间跳,深度公式里的频率要取最坏组合:

深度需求 = B × (1 − fr_min / fw_max)

即:写侧跑到最高频、读侧掉到最低频的瞬间。取其他任何组合算出来的深度都是自欺欺人——DFS 的调频策略不由你控制,最坏组合一定会出现(哪怕概率低)。

重要

DFS 场景下深度公式里的 fw 是写侧频率上限,fr 是读侧频率下限,而不是标称频率。很多丢包 bug 就是这里用标称频率算深度,而 DFS 恰好在某个瞬间给出了最坏组合。

平均速率校验:最容易被忽视的一点

深度公式只保证"一次突发装得下"。DFS 下还有一个更隐蔽的坑:平均速率会漂移。

举例:写侧 DFS 在 400 MHz 和 800 MHz 之间跳,平均频率 650 MHz,突发占空比 50%(每 128 拍写 64 拍),则平均写入速率是:

650 MHz × 50% = 325 M 数据/秒

读侧 DFS 在 300 MHz 和 600 MHz 之间跳,最低 300 MHz 连续读,只能读走 300 M 数据/秒。325 > 300——无论 FIFO 多深,只要调频策略把写侧平均频率压在 650 MHz 上足够久,FIFO 一定溢出。

警告

DFS 下丢包最常见的根因不是深度算错,而是只校验了瞬时突发、没校验平均速率。突发分析告诉你"装得下",平均速率分析告诉你"读得完"。两者都要算。

平均速率校验式:

写侧平均频率 × 写入占空比  ≤  读侧最低频率 × 读出效率

切换瞬态:假空时间变长

DFS 切换瞬间还会产生一个纯性能效应:读侧看到的写指针同步延迟是 2~3 个读时钟周期。读时钟突然从 600 MHz 掉到 300 MHz 时,这 2~3 拍的绝对时间翻倍,读侧"假空"等待的时间变长,有效读出速率短暂下降。

这不会直接丢包,但它会进一步压低读侧的实际吞吐。做平均速率校验时,如果读侧的效率本身就已经算得很满(比如 95%),DFS 切换期的假空就可能成为压垮骆驼的最后一根稻草。给读侧吞吐留 5%~10% 的余量,或把指针同步级数再打一拍做折中,是常见的防御措施。

PLL relock:时钟停止的窗口

如果 DFS 用的是 relock 实现,读侧时钟会停 T_lock 时间(典型 10~100 µs)。这段时间读侧一个数据都读不走,而写侧(异步域)还在全速写:

停止窗口内写入的数据量 = fw × T_lock

代入数字:写侧 800 MHz 持续写、relock 50 µs,就是 40000 个数据。深度 64 的 FIFO 撑不住 40000——任何实际深度的 FIFO 都撑不住 relock 窗口,所以不能用"加深 FIFO"来解决,必须用流控:

  1. 切换前反压:拉低读侧的 ready/credit,让写侧在 relock 开始前就停下来;

  2. 切换前排空:先把 FIFO 读到接近空,给停止窗口留出缓冲;

  3. 读侧域内的数据通路由时钟门控整体冻结:停止的只是时钟,寄存器状态保持,恢复后继续——只要写侧在窗口内被流控挡住,一个包都不会丢。

换句话说:relock 场景下,"时钟停了也不丢包"靠的是切换前的流控握手,而不是 FIFO 深度。如果写侧不接受反压(没有 ready 接口),那就必须保证写侧平均速率足够低、且 FIFO 深度能容纳 fw × T_lock 的数据——这在工程上几乎等价于"不允许这么设计"。

同步域 DFS:读写同源的情况

如果写读两侧的时钟同源(同一个 PLL 分频出来,比如 800 MHz 和 400 MHz),DFS 切换时两个时钟一起变、频率比不变。这种情况下:

  • 用同步 FIFO 即可,不需要指针跨域同步,深度按固定频率比计算;

  • DFS 切换期间(无论 glitch-free 还是 relock)整个通路一起变慢或暂停,数据停在寄存器里,不丢包;

  • 真正的异步边界在这个 DFS 域与外部固定频率模块的接口处——那里必须用前面说的异步 FIFO + 流控,并且外部模块不知道你正在 DFS,深度和流控必须按 DFS 的最坏组合设计。

完整设计示例

把上面的方法串起来,给一个完整的计算。场景:视频通路,写侧 DMA 突发写 64 拍,写时钟 DFS 范围 400~800 MHz;读侧处理引擎持续读,读时钟 DFS 范围 300~600 MHz,glitch-free 切换;写侧支持 ready 反压,反压传播延迟 5 拍(写时钟域)。

第一步:最坏组合深度

深度需求 = B × (1 − fr_min / fw_max) = 64 × (1 − 300/800) = 40

第二步:同步/反应裕量

  • 保守判满吃掉约 3 拍有效深度;

  • 反压传播延迟 5 拍,用 almost_full 提前反压,不占深度;

  • 需求 40 + 3 = 43,向上取 2 的幂 → FIFO 深度 64。

第三步:平均速率校验

  • 写侧 DFS 调度保证平均频率不超过 550 MHz,突发占空比 64/128 = 50%;

  • 平均写入速率 = 550 × 50% = 275 M 数据/秒;

  • 读侧最低 300 MHz,留 10% 假空余量后有效 270 M 数据/秒;

275 > 270,不满足!调整方案二选一:

  • 写侧 DFS 调度把平均频率上限压到 540 MHz(平均写入 270);

  • 或读侧 DFS 调度保证平均频率不低于 340 MHz(有效吞吐 ≥ 306)。

第四步:水位设置

almost_full = 64 − 5(反压延迟) − 1(安全裕量) = 58

写侧看到水位 ≥ 58 就拉低上游 ready。最坏组合(fw 最高、fr 最低)下,FIFO 在 58 处被截停,突发内最多再进 5~6 拍数据,远不到 64。

备注

这个例子中深度 64 实际上留了约 20 拍的裕量(需求 43)。工程上这是合理的:DFS 调度器、频率切换点、电压变化带来的时钟抖动都会侵蚀理论裕量,除非有精确的调度保证,否则别把深度抠到刚好。

设计检查清单

一个跨时钟域数据通路交付前,逐条核对:

  • 并行数据总线走异步 FIFO,不直接过同步器;单比特控制信号才用两级同步器;

  • FIFO 深度按 B × (1 − fr_min / fw_max) 计算,取 2 的幂,并加同步/反应裕量;

  • 平均速率校验通过:写侧平均速率(含 DFS 平均频率)≤ 读侧最低频率吞吐(含假空损失);

  • almost_full 水位提前量 ≥ 反压传播延迟;

  • 写侧必须有可反压的接口(ready/credit);没有反压接口的通路必须证明任何时刻瞬时写入都不会超过 FIFO 剩余空间;

  • DFS 时钟切换 glitch-free;若用 relock,切换前必须完成流控握手或排空;

  • 仿真中用随机化 DFS 频率(在每个切换窗口随机选点)跑最坏突发模式,断言 FIFO 永不溢出;

  • CDC 静态检查(SpyGlass CDC / Meridian 等)通过:同步器识别、格雷码同步、reconvergence 清零。

总结

跨时钟域不丢包,本质是三件事:

  1. 采得对:同步器解决亚稳态,格雷码传递指针,数据本体走 FIFO;

  2. 装得下:深度按最坏频率组合(fw_max / fr_min)和最大突发计算;

  3. 读得完:平均速率校验 + 流控反压,让"慢半拍"的同步信息只损失性能、不造成溢出。

DFS 不改变异步 FIFO 的正确性,它改变的是最坏情况:频率组合变成一个区间,深度和平均速率都必须按区间的端点算;切换瞬态的假空、relock 的停止窗口,则需要余量和流控来兜底。把"标称频率"换成"频率区间"来重新推导一遍,DFS 场景下的丢包问题就回到了同一套方法论的范畴内。

参考资料