ARTICLE DETAIL

资讯详情

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

快时钟域到慢时钟域CDC处理:脉冲同步器、异步FIFO与Verilog实战

快时钟域到慢时钟域CDC处理:脉冲同步器、异步FIFO与Verilog实战 做FPGA和IC数字设计的朋友多半都在CDC跨时钟域Clock Domain Crossing上吃过亏。我最近在写一个SPI从机接口时就撞上了典型场景SPI时钟由外部主机给大概10MHz而内部逻辑跑在100MHz反过来内部100MHz时钟域产生的一个单周期控制脉冲要送到10MHz的SPI时钟域里去置一个标志位。这个“快时钟域到慢时钟域”的跨时钟域处理和慢时钟域到快时钟域完全是两种难度。快到慢的核心问题很简单快时钟域的信号变化太快慢时钟域可能根本采不到。你可能觉得“我打两拍同步不就行了”但真实情况远没那么简单——如果快域信号是一个只有1个快时钟周期宽的脉冲慢域两个周期才采一次这个脉冲大概率被直接忽略。这篇内容我会把快时钟域到慢时钟域的CDC问题掰开揉碎讲清楚单比特、多比特、数据流分别该怎么处理附上可直接用的Verilog代码、仿真验证方法和工程排错经验适合正在做FPGA原型验证、数字IC前端设计以及刚接触异步设计的同学参考。1. 跨时钟域的本质快时钟域到慢时钟域为什么会翻车1.1 亚稳态不是玄学而是触发器的物理属性很多初学者把亚稳态当成“偶尔出现的bug”其实它是触发器的物理属性一定会发生只是概率问题。每个触发器都有建立时间和保持时间要求也就是数据在时钟沿前后必须稳定一段时间。当数据变化恰好落在这个窗口内触发器的输出就可能停留在中间电平既不是0也不是1然后再花随机时间恢复到有效状态。这个恢复过程就叫亚稳态。亚稳态的可怕之处在于它可能传播。如果亚稳态输出直接接到下一个触发器的数据端而它在下一个时钟沿到来时还没稳定下游触发器会再次进入亚稳态。所以经典做法是“打两拍”也就是用两级触发器串联。第一级可能进入亚稳态但经过整整一个时钟周期后它的输出大概率已经稳定第二级采到稳定数据的概率极高。这里有一个常用指标叫MTBFMean Time Between Failures平均无故障时间它和时钟频率、数据变化频率、触发器本身的亚稳态窗口参数强相关。频率越高MTBF越短。你可以简单理解为只在高速时钟域里打两拍可能还不够需要三级甚至更多级同步。1.2 快到慢特有的两类故障脉冲漏采与数据撕裂慢时钟域采快时钟域的窄脉冲最直观的问题是漏采。假设快域是100MHz时钟周期10ns一个单周期脉冲宽度就是10ns。慢域是25MHz周期40ns慢域触发器每个40ns才采样一次。如果这个10ns脉冲恰好落在两次采样之间慢域根本看不见它信号直接丢失。第二类问题是数据撕裂主要出现在多比特信号上。比如一个8bit计数器从0xFF跳到0x00理论上所有位同时翻转但实际上每位的走线延迟、触发器翻转时间都有细微差别。如果快域的多比特数据直接被慢域采样慢域可能采到0xFE、0x01这种中间态。单比特亚稳态只是某一位不确定多比特同时出错的组合就完全不可预测了。所以跨时钟域处理有一条铁律单比特信号用同步器多比特数据用异步FIFO或握手。任何“先把多比特数据组合成逻辑再跨时钟域”的做法都是在玩火因为组合逻辑会放大亚稳态的传播范围。2. 单比特信号的三种Verilog处理方案2.1 电平信号两级触发器同步器是最低配置先看最简单的情况快域信号是一个电平会持续很多个时钟周期只需把它同步到慢域。比如一个状态机的空闲标志位拉高后至少保持几百个周期慢域晚几个周期采到完全没关系。这时用两级触发器同步器就够了。module sync_2ff #( parameter WIDTH 1 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] din, output wire [WIDTH-1:0] dout ); reg [WIDTH-1:0] sync_r0, sync_r1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_r0 {WIDTH{1b0}}; sync_r1 {WIDTH{1b0}}; end else begin sync_r0 din; sync_r1 sync_r0; end end assign dout sync_r1; endmodule这个模块是所有CDC处理的地基。注意第二级输出才是真正可以用的信号第一级输出绝对不能直接参与逻辑。综合时通常还要给这两个触发器加ASYNC_REG约束后面我会专门讲。电平信号用两级同步器有一个前提信号在慢域两次采样之间不要变化太快。如果电平虽然持续好几个周期但快域翻转频率和慢域采样频率接近依然存在采到亚稳态的风险。稳妥的做法是确认信号宽度至少大于慢域时钟周期的1.5倍。2.2 脉冲信号脉冲同步器快传慢实现细节如果快域信号是单周期脉冲直接打两拍几乎必然丢失。我的处理方式是把脉冲先变成电平等慢域“看到”后再把电平清掉。这个结构其实就是脉冲同步器也能看成简化的握手。逻辑分四步快域脉冲到来时置位请求电平req慢域对这个req打两拍慢域检测同步后电平的上升沿输出一个慢域脉冲同时把req的同步结果作为反馈送回快域快域确认后清零req。module pulse_sync_fast2slow ( input wire clk_fast, input wire rst_n_fast, input wire pulse_in, input wire clk_slow, input wire rst_n_slow, output wire pulse_out ); // ------- 快时钟域脉冲转电平 ------- reg req; reg ack_sync1, ack_sync2; always (posedge clk_fast or negedge rst_n_fast) begin if (!rst_n_fast) req 1b0; else if (ack_sync2) req 1b0; // 慢域已收到请求清除电平 else if (pulse_in) req 1b1; // 快域脉冲置位请求 end always (posedge clk_fast or negedge rst_n_fast) begin if (!rst_n_fast) begin ack_sync1 1b0; ack_sync2 1b0; end else begin ack_sync1 req_s2; // 慢域反馈回来的电平 ack_sync2 ack_sync1; end end // ------- 慢时钟域同步 边沿检测 ------- reg req_s1, req_s2, req_s3; always (posedge clk_slow or negedge rst_n_slow) begin if (!rst_n_slow) begin req_s1 1b0; req_s2 1b0; req_s3 1b0; end else begin req_s1 req; req_s2 req_s1; req_s3 req_s2; end end assign pulse_out req_s2 ~req_s3; // 慢域上升沿检测输出单周期脉冲 endmodule使用这个电路有一个关键限制快域输入脉冲的最小间隔必须大于一次完整握手的时长也就是“req置位 慢域两拍同步 反馈回快域两拍同步”的时间。如果两个脉冲靠得太近第一个脉冲的req还没被清除第二个脉冲的置位会被忽略脉冲就丢了。你可以把req置位逻辑改成“即使req为高新脉冲到达也重新置位一次”但它无法解决慢域把两个脉冲合并成一个输出的问题。如果慢域频率实在太低或者快域脉冲间隔不确定我建议先别用脉冲同步器直接加一个小FIFO把脉冲事件缓存起来本质上是从“事件同步”变成“数据流同步”可靠性会高很多。2.3 握手方式什么时候比脉冲同步器更合适脉冲同步器的本质已经带了握手的味道但它是“单向通知”。真正意义上的握手适合处理多比特控制信号比如一个8bit命令字要从快域传给慢域。这时你不能直接同步8根线需要先让数据稳定再用请求应答机制告诉慢域“可以采了”。// 快域发送端 reg [7:0] data_q; reg req; always (posedge clk_fast or negedge rst_n_fast) begin if (!rst_n_fast) begin req 1b0; end else if (valid_in !busy) begin data_q data_in; req 1b1; // 数据准备好拉高请求 end else if (ack_sync2) begin req 1b0; // 慢域已应答撤销请求 end end assign busy req; // 慢域接收端 reg req_s1, req_s2; reg ack; always (posedge clk_slow or negedge rst_n_slow) begin if (!rst_n_slow) begin req_s1 1b0; req_s2 1b0; ack 1b0; end else begin req_s1 req; req_s2 req_s1; ack req_s2; // 应答信号用电平反馈 if (req_s2 !req_s3) // 上升沿表示新数据到达 data_captured data_q; // 此时data_q必然稳定 end end握手的好处是安全性极高只要req为高data_q一直保持不变慢域任意时刻采样都采到稳定数据。代价是延迟变大一次传输要经过“req拉高 - 慢域采样 - ack拉高 - 快域采样ack - req拉低”多个周期。在快到慢场景下这个延迟往往可以接受因为慢域本身处理速度就慢。握手还有一个容易踩的坑ack直接用req_s2这个电平快域看到ack后撤掉req但req_s2不会立刻变低它要等req变低后再经过两拍才低下去。也就是说ack会持续一段时间为高。这没问题但不允许在快域用“ack下降沿”触发任何动作只能用ack高电平清除req并把req的清除逻辑做得干净一点。3. 多比特信号的处理不能直接同步要换个思路3.1 数据流场景用异步FIFO多比特数据如果是连续的数据流比如ADC采样数据、图像像素流、串口接收的字节流最合理的方案就是异步FIFO。异步FIFO本质上把“跨时钟域传输”变成了“两个时钟域分别访问存储阵列”中间靠读写指针同步来维持顺序。异步FIFO的核心难点在于空满判断。写时钟域要知道FIFO是否满读时钟域要知道是否空但对方指针属于另一个时钟域必须同步。如果直接用二进制指针同步多比特同时翻转时可能出现完全错误的指针值。解决办法是使用格雷码指针。格雷码的精髓在于相邻两个值只有1位不同。以4bit格雷码为例序列是0000、0001、0011、0010、0110、0111、0101、0100、1100……指针加1时只有1位翻转同步器即使发生亚稳态输出也只可能错误1位而且错误后的值依然是相邻合法值。当亚稳态恢复后下一拍采样得到的值就会恢复正确对空满判断的破坏被限制在极小范围内。我在实际项目里一般直接用成熟方案比如Xilinx的xpm_fifo_async原语或者Clifford Cummings论文里的经典异步FIFO代码。如果用原语只需要配置读写数据宽度、深度、读写时钟不用关心内部格雷码细节。// Xilinx 7系列等FPGA上的异步FIFO原语示例 xpm_fifo_async #( .FIFO_MEMORY_TYPE (auto), .FIFO_READ_LATENCY (0), .FIFO_WRITE_DEPTH (256), .READ_DATA_WIDTH (8), .WRITE_DATA_WIDTH (8), .READ_MODE (fwft) ) u_fifo_async ( .rst (rst_n), .wr_clk (clk_fast), .wr_rst_busy (wr_rst_busy), .wr_en (wr_en), .din (din), .full (full), .rd_clk (clk_slow), .rd_rst_busy (rd_rst_busy), .rd_en (rd_en), .dout (dout), .empty (empty) );如果你要在ASIC里自己写需要额外注意读写指针都扩展一位用于区分“写满”和“读空”这两种看似相同的指针相等状态。当最高位不同且其余位相同时表示满完全相同时表示空。3.2 控制类多比特场景用握手加寄存器锁存不是所有多比特数据都是数据流。有些场景只是偶尔传一次配置字、命令字频率很低。这种情况没必要上FIFO用前面提到的握手方案加一个数据锁存就够。关键逻辑在慢域必须在确认req有效后再去采样data_q不能在req还没同步过来时采。因为快域在req拉高之前data_q可能还在变化。握手保证的是“req拉高之后data_q保持不变”所以慢域只要检测到req_s2上升沿此刻采data_q就是安全的。有的工程师图省事把data_q直接接到慢域触发器上然后想“反正req同步后我再采”。这个思路没问题但要注意采样的具体时序位置。如果data_q在快域是寄存器输出它的变化沿和req的拉高沿在同一个快域时钟沿慢域在req_s2变高后的时钟沿采样data_q两个时钟沿之间已经隔了至少两个慢域周期数据必然稳定。这也是握手方案在多比特控制信号上的正确姿势。4. 完整工程实践与验证从RTL到仿真4.1 一个典型的快到慢场景设计实例我以一个实际项目为背景快域100MHz慢域25MHz快域每产生一个单周期脉冲慢域就要对应输出一个单周期脉冲用于触发后端逻辑。这个需求看似简单但如果直接把快域脉冲接到慢域仿真时肉眼看到的是“脉冲时有时无”上板后就是偶发的功能错误。我把快域设计成一个简单脉冲发生器每隔一定周期产生脉冲然后用前面写的pulse_sync_fast2slow模块做跨域处理。module top_fast2slow ( input wire clk_fast, input wire rst_n_fast, input wire clk_slow, input wire rst_n_slow, output wire pulse_out ); // 快域脉冲发生器每16个快时钟周期产生一个单周期脉冲 reg [3:0] cnt; reg pulse_in; always (posedge clk_fast or negedge rst_n_fast) begin if (!rst_n_fast) begin cnt 4d0; pulse_in 1b0; end else begin cnt cnt 1b1; pulse_in (cnt 4d15); end end pulse_sync_fast2slow u_pulse_sync ( .clk_fast (clk_fast), .rst_n_fast (rst_n_fast), .pulse_in (pulse_in), .clk_slow (clk_slow), .rst_n_slow (rst_n_slow), .pulse_out (pulse_out) ); endmodule这里的时间间隔要算清楚100MHz下cnt计数16拍是160ns25MHz慢域周期40ns脉冲间隔相当于4个慢域周期满足脉冲同步器的使用条件。如果你把间隔改成只有5个快域周期也就是50ns慢域刚刚能采到第一个req但req还没走完反馈清除流程下一个脉冲可能就被丢掉了。4.2 Testbench怎么写才能验证同步器真的可靠验证CDC模块最关键的是要让快域脉冲落在慢域采样点的不同相位。如果固定相位仿真可能某些极端情况永远测不到。我的习惯是让testbench里的脉冲间隔带一点随机性并且把快慢时钟相对相位设成非整数倍关系让事件在长时间仿真里自然滑动。timescale 1ns/1ps module tb_pulse_sync(); reg clk_fast, rst_n_fast; reg clk_slow, rst_n_slow; wire pulse_out; // 快时钟域100MHz 10ns周期 initial begin clk_fast 0; forever #5 clk_fast ~clk_fast; end // 慢时钟域25MHz 40ns周期 initial begin clk_slow 0; forever #20 clk_slow ~clk_slow; end reg pulse_in; integer error_cnt; // 产生随机间隔的脉冲 initial begin rst_n_fast 0; rst_n_slow 0; pulse_in 0; error_cnt 0; #100; rst_n_fast 1; rst_n_slow 1; #200; repeat (100) begin (posedge clk_fast); pulse_in 1b1; (posedge clk_fast); pulse_in 1b0; // 随机等待30到80个快时钟周期 repeat ($urandom_range(30, 80)) (posedge clk_fast); end #1000; if (error_cnt 0) $display(TEST PASSED); else $display(TEST FAILED, error_cnt%0d, error_cnt); $finish; end // 监测每次快域给出脉冲慢域最终都应给出一个脉冲 always (posedge clk_fast) begin if (pulse_in) begin // 直接用fork...join_none配合延时检测 fork begin (posedge pulse_out); end begin #1000; $display(ERROR: pulse lost at time %0t, $time); error_cnt error_cnt 1; end join_any disable fork; end end top_fast2slow u_top ( .clk_fast (clk_fast), .rst_n_fast (rst_n_fast), .clk_slow (clk_slow), .rst_n_slow (rst_n_slow), .pulse_out (pulse_out) ); endmodule这个仿真里我用fork...join_any做了超时判断脉冲发出后1000ns内没有等到慢域输出就报错。实际项目里可以封成更规范的scoreboard但核心思路一样——事件级的跨域验证必须做“发出的每一个事件都要在足够长的时间内被收到一次”的检查而不是人眼盯波形。仿真时要特别注意波形窗口里快慢时钟的对齐关系。跑足够多的随机周期之后你会看到pulse_out相对pulse_in的延迟在变化这是正常的因为同步器本来就会引入2到4个慢时钟周期的随机延迟。只要每个脉冲都被收到并且只收到一次就算通过。5. 这些坑我基本都踩过约束、复位的实战经验5.1 FPGA上光有RTL还不够约束不到位照样翻车很多同学在仿真里跑得好好的上板后偶尔出错第一反应是代码逻辑问题其实很可能是约束没做。综合工具默认会把所有触发器当成同步逻辑去优化时序它不知道这两个触发器是跨时钟域的同步器可能会为了满足某些错误的时序路径而重排逻辑甚至把同步器级数优化掉了。我在实际工程里至少会做两件事。第一是给同步器寄存器加ASYNC_REG属性告诉工具这些寄存器是异步同步链的一部分不能被打散。第二是用set_clock_groups把异步时钟域隔离开让时序引擎不对跨时钟域路径做约束分析避免报出大量假路径。# 在Xilinx Vivado里约束异步时钟 set_clock_groups -asynchronous \ -group [get_clocks -include_generated_clocks clk_fast] \ -group [get_clocks -include_generated_clocks clk_slow] # 给同步器链路加属性 set_property ASYNC_REG TRUE [get_cells -hier -filter {NAME ~ *sync_r0*}] set_property ASYNC_REG TRUE [get_cells -hier -filter {NAME ~ *sync_r1*}]不隔离异步时钟域还会有另一个问题时序工具可能为了“满足”一条根本不存在的时序路径去增加逻辑延迟或者改变布局反而把真正需要时序收敛的路径搞乱。所以约束不是走过场是CDC工程里和RTL同等重要的一环。5.2 排查亚稳态故障的方法亚稳态故障在板子上的表现很有迷惑性偶发性错误复现难温度电压一变就不出现。如果你遇到“同一个工程十块板子里有一块偶尔错一次”的情况优先怀疑跨时钟域。排查思路有几个方向。第一用片上逻辑分析仪抓跨域信号但要注意探针本身也会引入新的采样问题最好把信号先同步到采样时钟域再抓。第二检查综合报告里有没有把异步时钟域当成同步路径分析如果发现大量关于跨时钟域的时序违例说明约束没加对。第三做不同频率、不同延迟的硬件实测比如用PLL把快域频率从100MHz调到120MHz看故障概率是否明显上升——频率越高亚稳态发生概率越大这是一个很有效的定位手段。真正定位到某一对跨时钟域信号后修复方向通常是扩大同步器级数、改用脉冲同步器或FIFO、增加信号稳定时间。不要指望在代码后面加一堆(* keep true *)能根治问题那只是让工具别乱优化根本问题还是设计方案。5.3 常见问题速查表我整理了一份自己在项目里反复对照的速查表希望能帮你少走弯路。问题现象可能原因推荐解法慢域偶尔收不到快域脉冲快域脉冲宽度太窄慢域漏采改用脉冲同步器先展宽成电平再同步多比特数据跨域后出现乱值各比特不同时稳定出现数据撕裂用握手或异步FIFO禁止直接同步多比特快域连续快速发事件慢域只收到部分脉冲间隔小于握手的反馈清除时间增大脉冲间隔或改用事件FIFO缓存仿真正常上板偶发错误约束缺失工具没有正确处理异步路径加ASYNC_REG和set_clock_groups检查时序报告同步器输出仍有亚稳态传播只打了一拍或同步链被打散至少两级同步确认综合结果没有重排异步FIFO空满标志异常指针同步方式不对二进制指针直接跨域用格雷码指针或直接用厂商FIFO原语还有一点很多人忽略复位信号本身也是跨时钟域的。异步复位、同步释放这个经典要求先做到否则复位释放时刻落在不同时钟沿附近也会把亚稳态带到整个设计里。我的习惯是每个时钟域内都做一个独立的复位同步释放模块复位信号不进同步器而是先本地同步后再释放。最后说一个我实际调试过的案例。之前有个项目快域200MHz产生一个“数据有效”脉冲慢域33MHz采集我在仿真里验证了5000个随机脉冲都没问题结果上板后跑两小时偶发一次丢脉冲。后来查了半天发现是快域脉冲间隔被后端时序修到只剩150ns而慢域加反馈清除的总延迟需要约200ns第二个脉冲到达时req还没来得及清零。解决办法是改用一个小FIFO缓存事件号读端每个慢域周期处理一个事件。这个问题本质上不是同步器设计错而是没有在系统层面评估事件的到达速率。CDC问题做多了你会发现大部分故障都不是某一个模块写错而是设计者默认“时钟频率差一点没关系”“脉冲偶尔丢一次应该问题不大”。在做FPGA和ASIC设计时只要沾上跨时钟域就要把最坏情况想清楚信号最小宽度是多少事件最大速率是多少慢域能否在规定时间内完成一次握手。把这些数值算清楚再选择合适的同步方案比事后抓波形高效得多。
返回列表