跨时钟域数据通路如何保证不丢包
在 SoC 里,几乎不存在整个芯片只有一个时钟频率的设计:CPU 有动态调频(DFS),DDR 控制器有自己的时钟,NoC、外设各自一个域。数据在这些不同频率的时钟域之间流动时,丢包是最高频的一类 bug。
这篇文章回答两个问题:
跨时钟域的数据通路,靠什么机制保证一个包都不丢;
当其中一侧的时钟在持续做 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 拍——突发期间的瞬时写入速率就可能超过读出速率。所以跨时钟域数据通路的设计,核心就是两条:
用同步器/异步 FIFO 解决亚稳态(保证采到的数据是对的);
用足够的深度 + 流控解决速率不匹配(保证数据有地方放、没地方放时上游停下来)。
异步 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 丢包:
时钟切换 glitch-free(或 relock 期间有流控兜底,见下文);
FIFO 深度按所有频率组合中的最坏情况计算(见下节);
长期平均速率仍然满足"写入 ≤ 读出"(见下节)。
深度按最坏频率组合计算
这是 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"来解决,必须用流控:
切换前反压:拉低读侧的
ready/credit,让写侧在 relock 开始前就停下来;切换前排空:先把 FIFO 读到接近空,给停止窗口留出缓冲;
读侧域内的数据通路由时钟门控整体冻结:停止的只是时钟,寄存器状态保持,恢复后继续——只要写侧在窗口内被流控挡住,一个包都不会丢。
换句话说: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 清零。
总结
跨时钟域不丢包,本质是三件事:
采得对:同步器解决亚稳态,格雷码传递指针,数据本体走 FIFO;
装得下:深度按最坏频率组合(
fw_max/fr_min)和最大突发计算;读得完:平均速率校验 + 流控反压,让"慢半拍"的同步信息只损失性能、不造成溢出。
DFS 不改变异步 FIFO 的正确性,它改变的是最坏情况:频率组合变成一个区间,深度和平均速率都必须按区间的端点算;切换瞬态的假空、relock 的停止窗口,则需要余量和流控来兜底。把"标称频率"换成"频率区间"来重新推导一遍,DFS 场景下的丢包问题就回到了同一套方法论的范畴内。