ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

FPGA跨时钟域处理:从亚稳态到异步FIFO设计全解析

FPGA跨时钟域处理:从亚稳态到异步FIFO设计全解析 做FPGA开发做到一定阶段凡是动手写过带外部接口模块的朋友早晚都会碰到这么一种现象板子跑起来功能偶尔错一下复位一下又好了仿真测不出问题逻辑分析仪抓到的波形又总是看不出个所以然。我最早在图像采集项目里就走过这个阶段当时把矛头指向了接口时序翻来覆去调了几天最后才意识到是异步信号直接被送进了状态机根子就在跨时钟域CDC。跨时钟域不是玄学它是数字电路里最基础的一层物理约束不处理等你的就是黑盒一样的随机故障。这篇Part.17我们完整走一遍亚稳态的成因、两级同步器的用法、多比特数据对CDC的补充方案然后一步步把异步FIFO设计出来RTL代码和验证细节都给全。适合所有刚要接触多时钟系统的FPGA开发者哪怕你之前从来没听过这几个词这章看完也应该能自己动手写了。1. 跨时钟域的物理本质亚稳态从哪里来又往哪里去1.1 D触发器的“时间窗口”到底有多窄数字电路里每个D触发器都要求数据在时钟沿附近保持稳定这个要求拆成两段时钟沿到来之前数据必须先稳定一段时间叫建立时间setup time时钟沿过去之后数据还要继续保持一段时间叫保持时间hold time。这两个参数不是芯片厂商随便写的而是由触发器的物理实现决定的比如CMOS工艺下的传输门、锁存级结构、内部电容充放电速度等。可以想象成公共汽车的关门动作建立时间就是乘客必须在门关之前站到门口保持时间就是门关之后不能再有人硬挤进来。如果数据在这个窗口内变化了触发器就会陷入一种尴尬状态——输出既不是明确的0也不是明确的1而是停留在某个中间电平附近甚至来回抖动。这个状态就叫亚稳态metastability。它只能随着时间逐渐收敛到某个合法电平但收敛到什么值完全随机收敛需要多长时间也完全随机。最关键的一点是数据跨时钟域到达目的时钟沿的时刻是随机的所以它一定有概率落在触发器的setup/hold窗口里。也就是说只要存在真实的不相关时钟域交叉亚稳态就是不可避免的物理现象不是你代码写得“干净”就能躲开的。1.2 亚稳态会传染后果往往滞后出现亚稳态最坑人的地方不是它本身而是它会传染给下一级逻辑。如果第一级触发器输出处于不稳定状态这个不稳定电平又被下一级触发器在同一个时钟周期内采样那下一级也会跟着进入亚稳态。更麻烦的是这个“感染链”一旦进入状态机、计数器、地址译码器就表现为状态错乱、跳变异常、控制信号毛刺而且往往得过若干个时钟周期才被下游逻辑发现等到定位时已经面目全非。我见过一个不算复杂的项目一个跨时钟域的8位计数器被直接打到另一侧的状态机里。仿真时因为时钟相位关系固定完全正常上板后偶尔出现状态机跳到非法分支按下复位重启就恢复。后来在示波器上抓数据总线才发现计数器从0x7F翻到0x80的那一拍目的时钟沿恰好撞上了多个比特同时翻转的窗口结果状态机采到了一个乱七八糟的中间组合。这就是典型的“多比特跨时钟域”问题和第1.1节说的单比特亚稳态还不完全一样它不是某一个触发器不稳定而是多个触发器在各自相位上被采到的结果拼成了一个错位快照。理解这个差异后面讲异步FIFO才有基础。2. 单比特信号的跨时钟域基础操作两级同步器2.1 为什么是两级不是一级处理单个比特跨时钟域最稳妥也最常见的办法是“打两拍”也就是用两个串在一起的D触发器同步。很多新手只知道这么写却说不清为什么两级就够。一级触发器的问题在于如果它恰好采到了变化的信号并进入亚稳态它输出的不稳定电平会在同一个时钟周期里直接参与逻辑运算此时电路行为完全不可预测。两级同步器的思路是第一级负责“承受”亚稳态第二级在第一级的输出稳定了一个完整时钟周期之后再去采样。即便第一级亚稳态收敛得慢它也有整整一个周期的时间去恢复哪怕极端情况下第一级没完全恢复第二级再次采到亚稳态的概率已经被压低到和应用场景几乎绝缘的程度。用行业里常说的MTBF平均无故障时间概念来说单级触发器跨时钟域的MTBF可能只有几秒到几分钟两级同步器通常能把MTBF推到几百年甚至几千年以上。当然极高温、极高辐照环境里有人会用到三级甚至更多级普通FPGA项目里两级已经是标准答案。我们还要纠正一个说法两级同步器并不是“消除”了亚稳态而是把亚稳态限制在同步器内部给它一个完整的时钟周期去收敛不让它跑出去污染别的逻辑。2.2 同步电平容易同步脉冲很难两级同步器最容易被忽略的限制是它同步的是电平不是脉冲。假设发送时钟域产生了一个持续一个周期的脉冲信号接收时钟域如果频率比发送域慢很多两个采样沿之间可能永远碰不到这个脉冲脉冲就丢了就算频率差不多也仍然存在脉冲刚好落在两个采样沿之间的概率。实际里我常看到有人把UART收到的“数据有效”脉冲打成两拍就往状态机里送结果偶发性丢帧查了很久。解决脉冲丢失问题有两个方向一是把脉冲展宽为电平例如用一个toggle触发器来一次脉冲就翻转一次接收域通过检测边沿来还原一个周期的脉冲二是确保脉冲宽度至少大于接收时钟一个周期但这在异步时钟下很难严格保证所以工程上更推荐toggle方案。经典电路长这样发送域每次发生事件就将一个flag取反接收域把这个flag打两拍后做边沿检测恢复出单周期脉冲。它本质上已经把“同步值”变成了“同步事件”在CDC里是非常实用的基础手法。后面讲异步FIFO时指针同步用的也是同一套思想——只不过把“事件翻转”换成了“指针变化”。2.3 复位信号也是跨时钟域的常客CDC不只是数据路径的事复位信号同样容易踩坑。如果复位信号直接接到两个异步时钟域的触发器复位端上外部复位撤除时不同时钟域的触发器各自在不同的时间点脱离复位而且复位释放沿和时钟沿的关系完全随机这就等于在系统启动阶段人为制造了一次跨时钟域采样。正确的做法是每个时钟域内部都为自己的同步器准备“同步复位”外部异步复位进来后先在本时钟域内打两拍再用做该域所有寄存器的复位。这样每个时钟域脱离复位的时刻都和本域时钟严格对齐不会再引入额外的CDC路径。我遇到过复位顺序不对导致FIFO上电后空满状态错误的故障后来给两个时钟域分别加了同步复位才正常。别小看这一步很多“初始化后第一次读写就出错”的案子破案的入口就在这里。3. 多比特数据跨时钟域从握手协议到格雷码思想3.1 每个比特各自打两拍结果依然错乱单比特可以靠两级同步器那多比特数据是不是每个比特都打两拍就行这是CDC问题里最经典的陷阱。每个比特在跨时钟域时到达目的时钟沿的相位轻微差异决定了它“这一拍采到的是旧值还是新值”多个比特之间完全可能有的采到旧值、有的采到新值结果拼出一个原本不存在的中间值。前面说的0x7F到0x80就是活生生的例子低7位从1变成0最高位从0变成1理想情况应该采到0x80或0x7F但实际操作中完全可能采到0x00、0xFF等乱七八糟的组合。如果下游逻辑把这些值当成有效地址或状态编码系统就会随机崩溃。也就是说两级同步器只能保证“单个比特最终稳定”不能保证“多个比特稳定成同一个版本”。多比特跨时钟域需要额外机制。3.2 握手协议低频数据的稳定传输方案最直接的额外机制是握手handshake。经典四相位握手是这样的发送方先把数据放到总线上然后拉高req接收方采样到req后锁存总线数据再拉高ack发送方看到ack后拉低req接收方看到req拉低后拉低ack完成一轮。数据有效窗口必须覆盖req从拉高到拉低的整个区间这样接收方随时采样都能拿到同一份稳定数据。这套方案逻辑上无懈可击但代价是每一次传输都要经历req到ack、ack到req的两次跨时钟往返吞吐量非常低。所以握手协议只适合寄存器配置、控制字下发这类低频场景不适合图像像素流、网络包数据流这种高带宽连续数据。数据流场景需要的是下一节要讲的FIFO方案。3.3 格雷码给多比特跨时钟域开了一扇窗在握手之外还有一种更巧妙的思路适合特殊的多比特数据——计数型数据。如果跨时钟域的“多比特”本质上是单调递增的计数器指针那就可以把它编码成格雷码Gray Code。格雷码的特点是两个相邻数值之间只有一位变化比如二进制0、1、2、3对应格雷码000、001、011、010每步只翻转1位。这意味着即使接收时钟刚好采到指针翻转的那一拍不幸撞上亚稳态窗口最多也只是在该位是否翻转上不确定。结果要么采到旧指针要么采到新指针要么理论上出现一个“介于两者之间”的非法格雷值但由于相邻格雷值只差1位非法格雷值对应的二进制地址最多偏出1个位置不会产生0x7F跳到0x80那种灾难性错位。而且配合后面要讲的指针比较策略这种小幅偏差不会影响FIFO空满判断的安全性。这正是异步FIFO能成立的理论地基。4. 异步FIFO的核心决策指针编码与空满判定4.1 为什么FIFO天然适合解决多比特CDC异步FIFO的本质是一个双口RAM加两组独立指针写时钟域控制写指针读时钟域控制读指针数据本身永远不跨时钟域搬运跨时钟域的只有写指针和读指针。这样“多比特数据”的问题就被化解了写入侧把数据按序写进RAM读出侧按序读走双方各干各的。剩下的问题只有一个读侧怎么知道FIFO空了写侧怎么知道FIFO满了。这必然需要把对方的指针同步过来但指针是多比特的直接打两拍会碰到第3.1节说的错位问题。于是格雷码登场了把指针编码成格雷码后再同步指针即使被采错也只会错一个格雷步不会大范围跳变。异步FIFO设计最核心的智慧就在“指针用格雷码编码”这个小动作上。4.2 格雷码与二进制的转换RTL写侧生成格雷指针标准写法是二进制指针右移一位再异或自己assign wgray (wbin_next 1) ^ wbin_next;读侧需要把同步过来的格雷指针还原成二进制吗不需要。空满判断可以直接在格雷码域比较。但有些设计里需要在某个时钟域把格雷码转回二进制做地址计算这时可以用逐位异或展开的循环integer i; always (*) begin bin gray; for (i ADDR_WIDTH; i 0; i i - 1) bin[i-1] bin[i] ^ gray[i-1]; end这里的核心思路是格雷码只在跨时钟域同步那段路上用RAM的实际地址仍然用二进制指针的低位即可因为二进制和格雷码在地址空间上是一一映射的用哪套编码访问RAM都合法重点只是跨过时钟边界时必须保持“相邻值只差1位”的特性。4.3 扩展一位指针空满判断的经典公式先讲为什么指针要多加一位。如果深度是16地址宽度4位那指针宽度至少5位其中最高位用来表示“是否多走了一圈”。当写指针比读指针多走一整圈时低4位完全相同单靠低4位会误判为“空”这显然不可接受。所以指针位宽必须是地址位宽加1靠高位区分轮次。在格雷码域里判断空的条件最简单assign empty (rgray_next wq2_rptr);这里的rgray_next是读侧下一个周期的格雷指针wq2_rptr是写指针同步到读时钟域后打两拍的值。读侧只拿写指针同步过来判断空写侧只拿读指针同步过来判断满绝不在本地同时判断另一个域的精确状态。判断满的公式稍微复杂一点需要把读指针格雷码的高两位取反后和写指针比较assign full (wgray_next {~rq2_wptr[ADDR_WIDTH], ~rq2_wptr[ADDR_WIDTH-1], rq2_wptr[ADDR_WIDTH-2:0]});这个公式的标准版本在业界异步FIFO设计里被反复使用。初学者不需要硬背只要理解它的几何意义当写指针和读指针的高两位互为反码时说明写指针已经领先读指针“一圈”而低位的指针对齐正好差一个FIFO深度。如果FIFO深度不是2的幂次格雷码的循环特性会被打破空满判断会出现安全隐患所以异步FIFO深度请老老实实设计成2的幂次。这是很多项目里深度为3、5、6的FIFO一仿真就爆出空满错误的根源。4.4 空满信号的“提前一拍”思想细心的读者会发现在空满判断里都用了next指针而不是当前指针。这是一种常见的工程取舍用下一个周期的格雷指针做比较相当于让空满信号提前一拍生效宁可让FIFO在边界处少写/少读一拍也不能让RAM发生写入覆盖或读出无效数据。对一个多时钟系统来说满信号慢半拍可能导致数据丢失快半拍最多损失一点性能安全性优先。对空信号来说如果不用rgray_next而是直接比较当前读指针那读最后一个数据的那一拍读指针还没来得及更新空信号要到下一拍才拉高下游可能在这个窗口里再发一次读请求读出无效数据。提前一拍判断能有效规避这种窗口。5. 手写一个可用的异步FIFO完整RTL与关键写法5.1 顶层结构先看全貌我直接给一个简洁但可直接使用的异步FIFO骨架深度参数化为2的幂次module async_fifo #( parameter DATA_WIDTH 8, parameter ADDR_WIDTH 4 // 实际深度 2^ADDR_WIDTH )( input wire wr_clk, input wire wr_rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] wr_data, output wire full, input wire rd_clk, input wire rd_rst_n, input wire rd_en, output wire [DATA_WIDTH-1:0] rd_data, output wire empty ); localparam PTR_WIDTH ADDR_WIDTH 1; reg [DATA_WIDTH-1:0] mem [0:(1ADDR_WIDTH)-1]; reg [PTR_WIDTH-1:0] wbin, rbin; wire [PTR_WIDTH-1:0] wgray (wbin 1) ^ wbin; wire [PTR_WIDTH-1:0] rgray (rbin 1) ^ rbin; reg [PTR_WIDTH-1:0] wq1, wq2; // 写指针同步到读时钟域 reg [PTR_WIDTH-1:0] rq1, rq2; // 读指针同步到写时钟域 ... endmodule两个时钟域各自的指针同步寄存器需要注意复位属于哪个域wq1/wq2在rd_clk域其复位必须是rd_rst_nrq1/rq2在wr_clk域复位是wr_rst_n。我曾经见过有人图省事把两个域的同步复位都接到同一个全局复位上结果在时钟频率差距大的系统里制造出了隐藏的CDC路径。5.2 写侧逻辑满信号与写指针更新写侧代码关键部分如下wire [PTR_WIDTH-1:0] wbin_next wbin 1b1; wire [PTR_WIDTH-1:0] wgray_next (wbin_next 1) ^ wbin_next; assign full (wgray_next {~rq2[PTR_WIDTH-1], ~rq2[PTR_WIDTH-2], rq2[PTR_WIDTH-3:0]}); always (posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) wbin {PTR_WIDTH{1b0}}; else if (wr_en !full) begin mem[wbin[ADDR_WIDTH-1:0]] wr_data; wbin wbin_next; end end注意full判断用的rq2是已经被同步到写时钟域的读指针格雷码它天然有延迟不代表此刻读侧的真实位置。因此full拉高并不精确等于RAM此刻真的满了而是“读侧大概率还没把位置腾出来”的保守估计。满信号稍微早一点比晚一点更安全至少不会真的把RAM写爆。如果你还需要更精确的容量信息可以考虑额外加almost_full、almost_empty之类的辅助信号但那些都属于工程定制标准异步FIFO只需要空满就够了。5.3 读侧逻辑空信号与同步读数据读侧逻辑和写侧对称wire [PTR_WIDTH-1:0] rbin_next rbin 1b1; wire [PTR_WIDTH-1:0] rgray_next (rbin_next 1) ^ rbin_next; assign empty (rgray_next wq2); reg [DATA_WIDTH-1:0] rd_data_r; always (posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) rd_data_r {DATA_WIDTH{1b0}}; else if (rd_en !empty) begin rd_data_r mem[rbin[ADDR_WIDTH-1:0]]; rbin rbin_next; end end assign rd_data rd_data_r;这里我把RAM读数据先放进寄存器再输出给下游这样读数据是同步读对时序收敛更友好也避免了组合读路径占用过多的FIFO输入延迟。代价是读数据会晚一个时钟周期出来但只要整个读侧逻辑都按延迟一拍来设计完全没问题。5.4 手写FIFO的三个隐患提醒第一RAM写条件里wr_en !full中full和wr_en之间还要满足Setup/Hold如果这两个信号来自同一时钟域保证时序收敛但如果wr_en来自外部异步源那就等于又制造了一个新的CDC问题。工程上写侧使能应该由本域的延迟逻辑产生绝不能让外部异步信号直接驱动。第二复位阶段的新杠指针跨度。复位后wbin和rbin都清零格雷码也全零空满状态自然正确。但如果两个域复位释放时间差太大会让同步寄存器采样到正在变化的复位信号这又回到了第2.3节的复位同步问题。所以我在5.1里特意强调同步复位这是异步FIFO能正常工作的前置条件。第三如果你用的是Xilinx或Intel器件工程上我更建议直接调用厂商提供的异步FIFO IP比如Xilinx的xpm_fifo_async。厂商IP经过了大量验证内部的格雷码、指针同步、复位逻辑都处理得很成熟还支持almost_full、prog_full等丰富选项。手写FIFO的意义在于理解原理和排查问题时心里有底真正做产品时请优先用IP不要为了炫技而重新发明轮子。6. 仿真验证与工程约束把异步FIFO真正用起来6.1 构造异步时钟的Testbench骨架验证异步FIFO最大的难点是让两个时钟“足够异步”同时又可以被仿真器精确控制。最直接的方式是用不同周期的时钟源initial begin wr_clk 0; forever #5 wr_clk ~wr_clk; // 100 MHz end initial begin rd_clk 0; forever #7 rd_clk ~rd_clk; // 约 71.4 MHz end如果你想让时钟之间带有固定相位偏移可以给其中一个时钟加#3的初始延迟。不过仿真里真正的异步验证不只看固定相位差还要跑随机读写压力让两个时钟各自独立地产生读写请求验证空满信号和数据的完整性。6.2 数据完整性与顺序性检查写满再读空是一种基础自测但远远不够。更可靠的测试是持续随机写入一批递增数据同时持续随机读出最后把读出的数据按顺序拼接起来检查是否和写入序列完全一致。这个自测能一次发现RAM覆盖、空满信号错误、读指针跳变等多类问题。我自己的测试方法通常是写侧维护一个计数器把连续写入的DATA_WIDTH比特数据做累加比对读侧每读一个数就检查它是不是期望的下一个数。如果是8位数据直接写0、1、2、3……到255循环读侧按同样序列校验即可。差别只在写作上实现方式。另外要专门做“满时继续写”“空时继续读”的异常测试。我的RTL里已经用wr_en !full和rd_en !empty做了保护但如果你在外围逻辑里没有额外判断就一定要在仿真里注入这种非法操作看看是不是真的不会破坏RAM状态。6.3 时序约束和CDC报告异步FIFO在综合工具里通常不需要传统意义上的“修时序”因为跨时钟域路径本来就是异步的STA静态时序分析无法按同步路径检查。但你必须告诉工具哪些时钟是异步的否则Vivado或Quartus会把你两个时钟当成相关时钟在CDC路径上报一堆红色违规约束文件里也会出现大片看着吓人的时序失败。在XDC/SDC里常用的是set_clock_groups -asynchronous -group {wr_clk} -group {rd_clk}这条命令会把两个时钟设为完全异步让工具不再去分析它们之间的路径。另一个对同步器有用的约束是把打拍寄存器标记为ASYNC_REG防止综合工具把两级同步器的寄存器挪到不合适的位置set_property ASYNC_REG true [get_cells {wq1_reg[*] wq2_reg[*]}]如果没有这一约束工具在优化时可能把两级同步器寄存器之间的路径当成普通路径插入额外逻辑或者调整布局反而破坏了同步器的“打两拍必须紧贴”的物理要求。厂商IP内部已经处理好了这些问题这再次说明产品化用IP更省心。6.4 我在实际项目中踩过的一些坑记录三个最常见的坑大家遇到类似现象可以直接对照排查。第一个坑是数据包通道的FIFO配合看门狗复位。调试时按下复位键后立刻继续收发结果第一次读到的数据总是错的。查到最后是复位释放不同步读时钟域比写时钟域先脱离复位读侧已经把指针归零写侧却还在上一轮的位置等到写侧也复位后指针才对上。解决方法是每个时钟域都加独立同步复位复位释放后等待至少一两个目标时钟周期再开始读写。第二个坑是仿真波形里看格雷指针同步。经常能看到同步后的指针已经在变化但本地指针还没更新这本身是正常的延迟不代表FIFO出错。新手一看波形里指针不同步就慌其实异步FIFO的指针天然就有延迟只要空满逻辑正确、数据完整就没问题。第三个坑是满信号引起的背压设计。如果上游逻辑看到full拉高后还要再等一个周期才停止发送那FIFO实际容量就必须预留这个“反应延时”的余量否则就会在满信号的延迟窗口内把数据写丢。工程上要么把full信号提前要么在上游写状态机里做提前量要么干脆用almost_full参数留出一两个深度的余量。道理想清楚之后这个坑其实是最好避开的。异步FIFO这块内容技术细节比较多但核心就是三件事理解亚稳态的物理来源、知道多比特跨时钟域为什么不能无脑打两拍、把格雷码指针同步和空满判定公式记牢。手写一遍之后再去看厂商IP的用户指南你会发现里面很多选项和参数其实都是围绕这些基础逻辑展开的。遇到调试问题手里的板子和仿真波形都会比记忆可靠把指针、空满、数据完整性这组线索按顺序查一遍问题基本都能收口。
返回列表