ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

跨时钟域与亚稳态:异步FIFO设计原理与工程实践

跨时钟域与亚稳态:异步FIFO设计原理与工程实践 1. 跨时钟域问题到底出在哪做FPGA开发只要不是单时钟的玩具项目早晚都会被CDCClock Domain Crossing跨时钟域问题教做人。我在Part.16里刚聊完串口和通信时序评论区就有朋友追问“两个时钟域之间传数据直接打两拍不就行了吗”说句实在话能问出这个问题说明你已经摸到门槛了但距离真正的CDC设计还有一段路要走。这一篇Part.17我把跨时钟域、亚稳态、异步FIFO这三件事放在一起讲是因为它们本质上是同一个问题在不同规模下的表现单bit信号跨时钟域用两级同步器多bit数据跨时钟域用异步FIFO而这一切的底层源头都是亚稳态。本篇文章适合学过Verilog基础、写过几个小模块、正准备接触多时钟项目的朋友也适合做了几年开发但没系统梳理过CDC设计方法的工程师当作复习提纲。先说个我自己的经历。第一次做多时钟项目是在一块Xilinx Spartan-6上同时跑UART115200波特率和一个USB芯片的并行接口时钟分别是25MHz和48MHz。刚开始图省事直接把跨时钟的信号接到了内部逻辑上。结果仿真怎么跑都正常上板之后每隔几分钟就死一次机。后来查了整整一个下午问题就出在一个简单到不行的握手信号上——它从48MHz时钟域过来在25MHz时钟域里采到了正在翻转的中间状态。那是我第一次意识到仿真环境默认屏蔽了现实世界的物理效应而真实芯片上的触发器对建立时间和保持时间的约束是极其严格的。2. 亚稳态的物理本质与两级同步器的边界条件2.1 触发器的建立时间和保持时间到底意味着什么要理解亚稳态先得明白D触发器在工作时其实有一个“窗口期”。信号输入D端必须比时钟有效沿提前Tsu建立时间到达并且比时钟有效沿晚Th保持时间才离开触发器才能稳定地采样到0或者1。这个窗口是芯片的物理属性不会因为你写的RTL代码多漂亮而改变。如果数据在这个窗口内发生了翻转就会出大事——触发器内部那两个交叉耦合的反相器会进入一个既不判0也不判1的中间状态输出电压悬在阈值附近而且这个状态可能持续很长时间才恢复到某个逻辑电平。更糟的是不同触发器对这个中间状态的恢复时间不一样同一种工艺下也可能因为温度、电压波动出现几十皮秒到几纳秒的差异。我在Part.4讲时序约束的时候提过建立时间和保持时间不满足是综合工具直接报错的但跨时钟域场景更隐蔽因为时序分析工具默认只分析同一个时钟域内的路径。跨时钟域的路径如果不做约束标注工具会当它不存在综合出来的电路很可能是建立时间不满足的。上板之后问题就变成偶发性、间歇性的极难复现也极难定位。2.2 两级同步器的定量分析两级同步器也叫双触发器同步器Two-Flip-Flop Synchronizer是处理单bit跨时钟域信号的标准结构。它做的事情在时间域上很容易理解第一级触发器在目标时钟域采样源信号如果采样到了亚稳态那么它输出的不确定值还有整整一个目标时钟周期的时间去稳定第二级触发器在下一个周期再采样一次此时第一级的输出大概率已经稳定成了确定的0或1。那“大概率”到底有多大这需要量化。亚稳态最终稳定所需的平均时间用MTBFMean Time Between Failures平均故障间隔来衡量简化公式是MTBF e^(tresolve/τ) / (fclk × fdata × C1)其中tresolve是允许的稳定时间等于目标时钟周期减去触发器本身的tco等参数τ是亚稳态衰减时间常数fclk和fdata分别是目标时钟频率和数据变化频率。举一个典型值如果目标时钟100MHz、数据变化频率1MHz、τ取50ps一级触发器允许的稳定时间只有大约8ns10ns周期减去大约2ns的tco和线延迟算出来的MTBF可能只有几分钟。这就是为什么一级触发器跨时钟同步是不可接受的。加上第二级之后允许的稳定时间变成18ns以上MTBF能提升到几百年甚至上万年。所以“为什么要打两拍”的答案不是拍脑袋定的而是用MTBF算出来的。当然实际项目里不会有人拿着公式逐级算但你必须明白这个公式的存在意义才能理解为什么有的场景需要三级同步。2.3 两级同步器的使用边界这里必须把话说明白两级同步器不是万能的它只适用于单bit信号而且这个单bit信号源时钟域要保持足够长的时间确保目标时钟域能采到至少一拍。为什么多bit信号不能用两级同步器举一个最直观的例子目标时钟域用两级同步器分别同步4bit信号“1001→0110”理想情况下目标时钟域采样结果仍然是“0110”。但实际情况是每个bit经过第一级触发器亚稳态的恢复时间不一样A位可能在第一个时钟沿就稳定成1B位可能要到第二个时钟沿才稳定成1C位在第一个沿稳定成0但它在源端已经开始变了D位干脆直接采到了中间态。结果目标时钟域看到的可能是“1110”或者“0111”甚至“1010”——一个完全没在源端出现过的错误值。这个现象说明一个重要结论多bit总线跨时钟域两级同步器解决不了数据一致性问题。解决手段要么是握手协议加数据缓存要么是异步FIFO本质上是把多位数据交给一个专门的存储阵列通过读写指针的格雷码编码来保证采样的有效性要么是多bit合并成单bit再到目标域里解码这是高级技巧后面再展开。初学者最容易犯的错就是把两级同步器推广到所有跨时钟域场景结果数据偶尔出错查上半天也找不到原因。3. 异步FIFO的核心设计原理与完整实现3.1 为什么是格雷码而不是二进制码异步FIFO解决两件事一是多位数据的可靠传输二是读写时钟域之间的速率匹配写端快读端慢或反之。它的基本结构是一个双端口RAM加读写指针写指针由写时钟域控制读指针由读时钟域控制。判断空满的时候需要把对方的指针同步过来。问题来了FIFO的指针是一个N位二进制数这玩意儿本身就是多bit总线直接同步必然出现数据不一致。解决思路是让指针每次只变化一位——这正是格雷码的物理特性。把N位二进制指针先转换成N位格雷码再用两级同步器把格雷码同步到对端因为格雷码相邻两值只有1bit变化所以无论同步器采到的是变化前还是变化后的值都是合法值最多只是让空满判断“慢半拍”绝不会产生错误的数据翻转组合。二进制转格雷码的公式是 gray bin ^ (bin 1)。举个例子二进制8b0000到8b0001格雷码从8b0000变到8b0001只有最低位变化二进制8b0111到8b1000十进制7到8格雷码从8b0100变到8b1100仍然只有一位变化。这就是格雷码最核心的美妙之处。回到FIFO的场景读写指针的递进恰好每次只改变一个计数值天然贴合格雷码的应用条件。3.2 空满判断的具体实现空满判断是所有异步FIFO设计里最容易出错的地方市面上很多教程和开源代码在这里都有坑。先把结论给出来空判断读时钟域拿着读指针二进制跟同步过来的写指针先格雷码同步再转回二进制比较相等即空。满判断写时钟域拿着写指针二进制跟同步过来的读指针先格雷码同步再转回二进制比较当最高两位不同、其余位相同时判定为满。为什么深度为2^N的FIFO要用N1位指针以深度16的FIFO为例地址是4位但指针要用5位。原因在于满判断需要一个额外的“回转位”来区分“真满”和“指针绕了一圈又回到同一地址”这两种情况。当写指针比读指针多跑整整一圈16个地址时低4位相同但第5位最高位肯定不同这就可以判定为满。这里给出一个可直接用于仿真的16深度异步FIFO代码骨架module async_fifo_16x8 #( parameter DATA_WIDTH 8, parameter ADDR_WIDTH 4 )( input wire rst_n, // 写侧 input wire w_clk, input wire w_en, input wire [DATA_WIDTH-1:0] w_data, output wire full, // 读侧 input wire r_clk, input wire r_en, output wire [DATA_WIDTH-1:0] r_data, output wire empty ); localparam PTR_WIDTH ADDR_WIDTH 1; // 5位指针 reg [DATA_WIDTH-1:0] mem [0:(1ADDR_WIDTH)-1]; reg [PTR_WIDTH-1:0] w_ptr_bin; reg [PTR_WIDTH-1:0] r_ptr_bin; wire [PTR_WIDTH-1:0] w_ptr_gray; wire [PTR_WIDTH-1:0] r_ptr_gray; // 写数据 always (posedge w_clk or negedge rst_n) begin if (!rst_n) w_ptr_bin 0; else if (w_en !full) begin mem[w_ptr_bin[ADDR_WIDTH-1:0]] w_data; w_ptr_bin w_ptr_bin 1b1; end end // 读数据异步读这里为简化使用同步读 reg [DATA_WIDTH-1:0] r_data_reg; always (posedge r_clk or negedge rst_n) begin if (!rst_n) begin r_ptr_bin 0; r_data_reg 0; end else if (r_en !empty) begin r_data_reg mem[r_ptr_bin[ADDR_WIDTH-1:0]]; r_ptr_bin r_ptr_bin 1b1; end end assign r_data r_data_reg; // 二进制转格雷码 assign w_ptr_gray w_ptr_bin ^ (w_ptr_bin 1); assign r_ptr_gray r_ptr_bin ^ (r_ptr_bin 1); // 同步到对端 reg [PTR_WIDTH-1:0] w_ptr_gray_sync1, w_ptr_gray_sync2; reg [PTR_WIDTH-1:0] r_ptr_gray_sync1, r_ptr_gray_sync2; always (posedge r_clk or negedge rst_n) begin if (!rst_n) begin w_ptr_gray_sync1 0; w_ptr_gray_sync2 0; end else begin w_ptr_gray_sync1 w_ptr_gray; w_ptr_gray_sync2 w_ptr_gray_sync1; end end always (posedge w_clk or negedge rst_n) begin if (!rst_n) begin r_ptr_gray_sync1 0; r_ptr_gray_sync2 0; end else begin r_ptr_gray_sync1 r_ptr_gray; r_ptr_gray_sync2 r_ptr_gray_sync1; end end // 格雷码转二进制用于空满比较 function [PTR_WIDTH-1:0] gray2bin; input [PTR_WIDTH-1:0] gray; integer i; begin gray2bin[PTR_WIDTH-1] gray[PTR_WIDTH-1]; for (i PTR_WIDTH-2; i 0; i i - 1) gray2bin[i] gray2bin[i1] ^ gray[i]; end endfunction wire [PTR_WIDTH-1:0] w_ptr_gray_sync2_bin gray2bin(w_ptr_gray_sync2); wire [PTR_WIDTH-1:0] r_ptr_gray_sync2_bin gray2bin(r_ptr_gray_sync2); // 空满判断 assign empty (r_ptr_bin w_ptr_gray_sync2_bin); assign full (w_ptr_bin[PTR_WIDTH-1] ! r_ptr_gray_sync2_bin[PTR_WIDTH-1]) (w_ptr_bin[PTR_WIDTH-2:0] r_ptr_gray_sync2_bin[PTR_WIDTH-2:0]); endmodule这段代码默认了FPGA内部RAM的写法实际工程中如果使用Block RAM IP读写端口的配置要对应修改。为了突出重点这里用的是同步读寄存器输出异步读FIFO的做法后续会单独写一篇。3.3 空满信号为什么有滞后性以及它的副作用这个环节很多教程一句话带过但实际工程中它特别坑。因为空满判断需要依赖“对端同步过来的指针”而两级同步器本身有2个目标时钟周期的延迟加上同步器工作在目标时钟域所以满信号在写时钟域产生但它参考的读指针是“两个写时钟周期之前”的读指针状态。这意味着即使FIFO真的已经腾出空间满信号也不会立刻拉低它会多保持几个周期。空信号在读时钟域产生同理它参考的写指针是两个读时钟周期前的状态。这种滞后带来的直接后果是满信号拉高时FIFO可能还没真正写满还有一点余量空信号拉高时FIFO可能还没真正读空。这在方向上是“偏保守”的也就是宁可提前说满、提前说空也不会产生写覆盖未读数据和读空数据的严重错误。所以异步FIFO的深度一般不建议取得刚刚够用建议留出至少20%到30%的余量。假设应用场景读写吞吐率几乎相等、偶尔突发写理论最小深度是16你最好选深度32的FIFO。否则满信号频繁拉高写侧被迫降速整个流水线的有效吞吐率会缩水。3.4 异步复位的处理细节FIFO模块里的复位如果不小心同样会引入CDC问题。异步复位在释放的时候如果靠近目标时钟的有效沿会让触发器进入亚稳态。常规解法是“异步复位同步释放”也就是复位信号先打两拍再作为内部真正使用的复位。上面代码里用的是negedge rst_n触发复位逻辑看起来是异步复位但如果没有做同步释放处理在一个噪声干扰或者电源抖动的场景下复位释放瞬间就可能踩中建立时间窗口。这个问题在异步FIFO里尤其隐蔽因为复位同时影响了两个时钟域的指针和同步器寄存器任何一边的复位释放时机不对都会让同步器拿到错误初值。我在工程里习惯这样做// 写时钟域复位同步释放 reg [1:0] w_rst_sync; always (posedge w_clk or negedge rst_n) begin if (!rst_n) w_rst_sync 2b00; else w_rst_sync {w_rst_sync[0], 1b1}; end wire w_rstn w_rst_sync[1]; // 读时钟域复位同步释放 reg [1:0] r_rst_sync; always (posedge r_clk or negedge rst_n) begin if (!rst_n) r_rst_sync 2b00; else r_rst_sync {r_rst_sync[0], 1b1}; end wire r_rstn r_rst_sync[1];然后把所有写时钟域内的复位信号换成w_rstn读时钟域内的换成r_rstn。同步器部分因为既有写时钟域又有读时钟域的寄存器要分别接对应的同步复位。这个细节如果漏掉在单板调试时会出现“上电一会儿正常跑几分钟后偶发错乱”的诡异现象。4. 仿真验证与常见问题排查4.1 怎么设计一个可信的异步FIFO测试用例异步FIFO的仿真比普通模块更需要注意一个点你的testbench里写数据和读数据要真正使用两个不同的时钟并且要让这两个时钟的频率比值覆盖多种情况比如写快读慢、写慢读快、写读同频不同相。我在工程里写testbench时至少覆盖以下几类场景单次写、单次读验证基本读写和空满翻转。连续写满验证满信号在写完N个字之后拉高因为滞后实际拉高可能在没有真正写满时发生但这个行为要被捕捉到。连续读空验证空信号在读完N个字之后拉高并且读指针不再变化。先写满再读空验证指针的回卷情况。写入过程中同时读验证动态读写下空满信号不会卡死。给大家一个仿真小技巧异步FIFO最容易出现的隐性bug是“空满信号翻转异常”和“数据错位”。前者用波形对比就能看出来后者最好是让写入数据等于写入地址加上一个固定偏移比如每次写mem[i] i 8hA5读出之后检查数据是否等于读地址加偏移。这样一旦发生数据错位仿真报告立刻能抓到。4.2 上板调试的典型症状与排查方向仿真过了不代表上板就一定没问题。异步FIFO在上板阶段常见的故障有下面几类**故障一偶发读到一个错数据而且频率很低。**优先排查同步器的寄存器有没有被综合工具优化掉。有些优化选项会把两级同步器合并或挪位置导致实际物理延迟异常。解决方法是给同步器的两级寄存器加上综合约束(* keep true *)或(* ASYNC_REG TRUE *)明确告诉工具这两个寄存器必须保持在同一个SLICE里并且不能被打散。**故障二FIFO空满状态死锁读写都卡住。**先用逻辑分析仪看空满信号和读写使能信号最常见的原因是复位释放不同步两个时钟域的复位释放相差太远一方认为FIFO已经复位另一方还在复位的余波里导致同步器初值不一致。**故障三数据间歇性错位但不规律。**这时候要重点怀疑格雷码转换电路。确认一下综合工具有没有把gray2bin函数里的组合逻辑打平如果生成了比较高的扇出而且布线布局不理想就会出现毛刺。我的经验是把格雷码转二进制的功能单独抽出来用寄存器打一拍后再做空满比较虽然增加了一个周期延迟但可靠性和时序收敛性都明显提升。4.3 遗留的思考非2次幂深度与多bit控制信号这篇主要覆盖了深度为2的幂次的经典异步FIFO结构。如果你在项目里遇到需要非2次幂深度的FIFO比如深度12、深度20经典格雷码方案就失效了。实际工程里有两条路一是把深度向上取整到2的幂次只使用其中一部分地址这是最简单的做法二是采用“手握手”的传输机制每次只传输一个指针值并等待确认吞吐率下降但通用性很强。另一种常见场景是跨时钟域传递多bit控制信号比如一个64bit配置寄存器要从慢时钟域写入快时钟域。有人图省事直接加两级同步器结果就是前面说的数据错乱。正确的操作性做法是先把64bit数据写成快时钟域没法采到的方式——比如先发一个“数据准备好”脉冲信号目标域收到后回一个“请发送”信号形成握手闭环再由目标域去锁存数据。或者更简单粗暴地把64bit分拆成8个8bit用异步FIFO传过去目标域再拼接。我个人在实际调板过程中最大的体会是跨时钟域设计不怕复杂怕侥幸。每次看到“这里应该不会同时变化”这样的想法后面大概率就要出问题。异步FIFO里的格雷码、两级同步器、复位同步释放每一层都是为了覆盖一个真实存在的物理风险。把这些基础模块调稳了后面的高速通信、图像采集项目才能站得住脚。
返回列表