FPGA跨时钟域处理实战:单bit信号同步与脉冲同步Verilog实现 1. 跨时钟域处理到底在解决什么问题做FPGA或者数字IC设计的朋友迟早会撞上跨时钟域这个坎。你写了一个模块这边时钟域跑得好好的那边时钟域也跑得好好的结果一联调数据全是错的或者偶尔错一次抓波形抓半天也抓不到。这种情况十有八九就是跨时钟域信号没处理好。所谓跨时钟域英文叫Clock Domain Crossing简称CDC说白了就是一个信号从A时钟域传到B时钟域。两个时钟频率不同、相位不同甚至频率完全一样但相位关系不确定都会导致B时钟域采样A时钟域信号时出现亚稳态。亚稳态这个东西很讨厌它不会每次都出问题可能跑几个小时才出一次仿真也仿不出来但一旦出问题就是致命的。我刚开始学Verilog那会儿根本没把CDC当回事觉得不就是把信号接过去嘛能有什么问题。后来做了一个项目两个模块分别用50MHz和100MHz的时钟中间传了一个使能信号结果上板之后数据偶尔跳变查了整整两天才发现是跨时钟域的问题。从那以后我就老老实实研究CDC了。这篇文章主要面向刚接触FPGA或者数字IC设计的朋友也适合那些做过一些项目但对CDC还是一知半解的人。我会从最基础的概念讲起把单bit信号跨时钟域处理的几种方法掰开揉碎讲清楚包括两级同步器、边沿检测同步、脉冲同步等每种方法都会给出完整的Verilog代码和Testbench并且解释为什么这么写、参数怎么算、实际项目中怎么选。2. 跨时钟域问题的本质与分类2.1 亚稳态是怎么产生的要理解CDC首先得理解亚稳态。触发器在时钟上升沿采样数据时要求数据在时钟沿前后一段时间内保持稳定这段时间分别叫做建立时间和保持时间。如果数据在这段时间内发生变化触发器的输出就可能进入一个既不是0也不是1的中间状态这就是亚稳态。亚稳态的可怕之处在于它不会立刻稳定下来而是需要一段时间才能随机地稳定到0或者1。如果这个亚稳态信号直接传给下游电路下游电路可能采到不同的值导致功能错误。更严重的是如果亚稳态信号扇出到多个路径不同路径可能采到不同的值造成状态机跑飞。在同一个时钟域内只要时序约束正确工具会保证建立保持时间满足要求不会出现亚稳态。但跨时钟域就不一样了两个时钟没有固定的相位关系工具也没法做时序分析所以必须由设计者自己处理。2.2 跨时钟域信号的分类跨时钟域信号大致可以分成三类处理方法完全不同第一类是单bit控制信号比如使能、复位、标志位等。这类信号的特点是变化不频繁每次变化都代表一个确定的事件。处理的核心思路是同步让信号在目标时钟域稳定后再使用。第二类是多bit数据信号比如数据总线、地址总线等。这类信号不能简单地用两级同步器处理因为多个bit可能在不同时刻稳定导致目标时钟域采到错误的数据组合。处理方法包括握手协议、异步FIFO、格雷码编码等。第三类是脉冲信号比如中断、触发等。这类信号的特点是宽度可能只有一个源时钟周期如果直接用两级同步器可能因为脉冲太窄而被漏掉。需要用脉冲展宽或者脉冲同步器来处理。这篇文章重点讲第一类和第三类也就是单bit信号和脉冲信号的跨时钟域处理。多bit信号的处理方法比较复杂涉及异步FIFO和握手协议我会在后续的文章里单独展开。2.3 为什么不能直接用两级触发器有人可能会问既然亚稳态需要时间稳定那我用两级触发器串联第一级可能进入亚稳态但第二级采样的时候第一级已经稳定了不就行了吗这个思路是对的两级同步器确实是最常用的CDC处理方法。但有几个关键点需要注意第一两级同步器的第一级触发器仍然可能进入亚稳态只是它进入亚稳态的概率很低而且即使进入了第二级触发器采样时大概率已经稳定了。但这并不意味着绝对不会出问题只是把MTBF平均无故障时间提高到了一个可以接受的水平。第二两级同步器只适用于单bit信号。如果是多bit信号每个bit都用自己的两级同步器由于亚稳态恢复时间不同不同bit可能在不同周期稳定导致目标时钟域采到错误的组合值。第三两级同步器会引入延迟。信号从源时钟域传到目标时钟域至少需要两个目标时钟周期。如果信号变化很快可能还没同步完就又变了导致丢失。所以两级同步器不是万能的得根据信号类型选择合适的方法。3. 单bit信号跨时钟域处理的几种方法3.1 两级同步器最基础也最常用两级同步器是CDC处理的基本功几乎所有单bit慢速信号都可以用它。原理很简单在目标时钟域用两个触发器串联源信号先经过第一级触发器再经过第二级触发器第二级触发器的输出就是同步后的信号。为什么是两级而不是一级或者三级一级不够因为第一级可能进入亚稳态直接输出会给下游电路带来风险。三级当然更安全但会增加一个周期的延迟而且MTBF的提升已经不那么明显了。两级是一个性价比很高的选择MTBF通常能达到几百年甚至几千年对于绝大多数应用足够了。下面是一个标准的两级同步器Verilog代码module sync_2ff ( input wire clk_dst, // 目标时钟域时钟 input wire rst_n_dst, // 目标时钟域复位低有效 input wire sig_src, // 源时钟域信号 output wire sig_dst // 同步到目标时钟域的信号 ); reg sync_ff1; reg sync_ff2; always (posedge clk_dst or negedge rst_n_dst) begin if (!rst_n_dst) begin sync_ff1 1b0; sync_ff2 1b0; end else begin sync_ff1 sig_src; sync_ff2 sync_ff1; end end assign sig_dst sync_ff2; endmodule这段代码看起来简单但有几个细节需要注意第一两级触发器必须用同一个时钟也就是目标时钟域的时钟。不能第一级用源时钟第二级用目标时钟那样起不到同步的作用。第二复位信号也要注意。如果复位信号是异步的最好用目标时钟域同步后的复位否则复位释放时可能产生亚稳态。上面的代码用了目标时钟域的复位这是比较稳妥的做法。第三综合工具可能会优化掉第一级触发器。有些综合工具看到两级触发器串联觉得第一级没用就给优化掉了。这时候需要加综合属性比如ASYNC_REG或者DONT_TOUCH告诉工具不要动这两级触发器。Xilinx的Vivado里可以用(* ASYNC_REG TRUE *)Intel的Quartus里可以用(* altera_attribute -name SYNCHRONIZER_IDENTIFICATION FORCED_IF_ASYNCHRONOUS *)。第四两级同步器只适用于慢速信号。如果源信号变化频率接近目标时钟频率两级同步器可能来不及同步导致丢失。一般来说源信号的变化间隔至少要大于目标时钟周期的两倍最好大于三倍。3.2 边沿检测同步把电平变成脉冲两级同步器输出的是电平信号如果源信号是一个持续的电平同步到目标时钟域后也是一个持续的电平。但有时候我们需要的是边沿也就是信号从0变1或者从1变0的那个瞬间。比如源时钟域有一个使能信号高电平有效持续了10个源时钟周期。目标时钟域需要在这个使能信号有效时做一次操作而不是持续做。这时候就需要边沿检测同步。边沿检测同步的思路是先把源信号用两级同步器同步到目标时钟域然后在目标时钟域对同步后的信号打一拍通过比较当前值和前一拍的值来检测上升沿或下降沿。module edge_detect_sync ( input wire clk_dst, input wire rst_n_dst, input wire sig_src, output wire pos_edge_dst, // 上升沿脉冲 output wire neg_edge_dst // 下降沿脉冲 ); reg sync_ff1; reg sync_ff2; reg sig_dst_d1; always (posedge clk_dst or negedge rst_n_dst) begin if (!rst_n_dst) begin sync_ff1 1b0; sync_ff2 1b0; sig_dst_d1 1b0; end else begin sync_ff1 sig_src; sync_ff2 sync_ff1; sig_dst_d1 sync_ff2; end end assign pos_edge_dst sync_ff2 ~sig_dst_d1; assign neg_edge_dst ~sync_ff2 sig_dst_d1; endmodule这段代码在两级同步器的基础上加了一级触发器sig_dst_d1用来存储同步后信号的前一拍值。pos_edge_dst在同步后信号从0变1的那个目标时钟周期为高neg_edge_dst在从1变0的那个周期为高。这里有个坑需要注意边沿检测输出的脉冲宽度只有一个目标时钟周期。如果下游电路需要更宽的脉冲得自己再展宽。另外如果源信号变化太快两次变化间隔小于目标时钟周期边沿检测可能会漏掉一次变化。3.3 脉冲同步器把窄脉冲搬到另一个时钟域脉冲同步器要解决的问题是源时钟域有一个窄脉冲可能只有一个源时钟周期宽怎么把它同步到目标时钟域并且在目标时钟域产生一个脉冲直接用两级同步器是不行的因为脉冲太窄目标时钟可能采不到。这时候需要用脉冲展宽加边沿检测的方法。思路是这样的在源时钟域用一个触发器把脉冲转换成电平翻转也就是每来一个脉冲输出电平翻转一次。这个翻转后的电平变化频率很低可以用两级同步器同步到目标时钟域。然后在目标时钟域检测这个电平的边沿每检测到一个边沿就产生一个脉冲。module pulse_sync ( input wire clk_src, input wire rst_n_src, input wire pulse_src, // 源时钟域脉冲 input wire clk_dst, input wire rst_n_dst, output wire pulse_dst // 目标时钟域脉冲 ); // 源时钟域脉冲转电平翻转 reg toggle_src; always (posedge clk_src or negedge rst_n_src) begin if (!rst_n_src) toggle_src 1b0; else if (pulse_src) toggle_src ~toggle_src; end // 目标时钟域两级同步 reg sync_ff1, sync_ff2, sync_ff3; always (posedge clk_dst or negedge rst_n_dst) begin if (!rst_n_dst) begin sync_ff1 1b0; sync_ff2 1b0; sync_ff3 1b0; end else begin sync_ff1 toggle_src; sync_ff2 sync_ff1; sync_ff3 sync_ff2; end end // 边沿检测 assign pulse_dst sync_ff2 ^ sync_ff3; endmodule这段代码的核心是toggle_src每来一个源脉冲就翻转一次。翻转后的信号变化频率是源脉冲频率的一半如果源脉冲不是特别密集翻转信号的变化间隔就足够大可以用两级同步器同步。目标时钟域用sync_ff2和sync_ff3做边沿检测每检测到一次翻转就输出一个脉冲。这里有几个关键点第一源脉冲之间的间隔必须大于目标时钟周期的两倍。如果两个源脉冲靠得太近toggle_src翻转太快目标时钟域可能来不及同步导致丢失脉冲。具体来说如果源脉冲间隔小于目标时钟周期的两倍就可能出问题。第二目标时钟频率最好高于源时钟频率。如果目标时钟比源时钟慢很多同步延迟会很大脉冲响应会变慢。一般来说目标时钟频率至少要是源脉冲频率的两倍以上。第三复位要处理好。源时钟域和目标时钟域的复位是独立的如果复位不同步可能导致toggle_src和同步后的信号不一致。实际项目中通常会用全局复位或者复位同步器来保证复位的一致性。3.4 握手协议更可靠但更复杂的方案前面讲的三种方法都是单向的信号从源时钟域传到目标时钟域不需要反馈。但如果信号变化比较快或者需要确保每次变化都被目标时钟域正确接收就需要用握手协议。握手协议的基本思路是源时钟域发出请求信号目标时钟域收到后发出应答信号源时钟域收到应答后才发下一个请求。这样就能保证每次请求都被正确处理。握手协议的Verilog实现比较复杂涉及多个同步器和状态机这里先不展开。对于大多数单bit信号两级同步器加边沿检测已经够用了。握手协议更多用在多bit数据传输或者需要确认的场景。4. 实操从零搭建一个CDC验证环境4.1 环境准备与工具选型要验证CDC电路你需要一个仿真工具。常用的有ModelSim、VCS、Xcelium等如果是学生或者个人学习ModelSim SE或者QuestaSim是比较常见的选择。如果不想装软件也可以用EDA Playground这种在线仿真平台支持多种仿真器适合快速验证小模块。我个人的习惯是用ModelSim做功能仿真用Vivado或者Quartus做综合和时序分析。功能仿真主要验证逻辑对不对综合和时序分析主要看工具会不会报CDC警告以及资源消耗情况。Testbench的写法很关键。CDC的Testbench和普通模块的Testbench不太一样因为涉及两个时钟域需要分别产生两个时钟并且要模拟源信号在目标时钟域的不确定性。最好的方法是让两个时钟的频率比不是整数倍比如源时钟50MHz目标时钟100MHz但相位随机偏移这样能更好地模拟真实情况。4.2 两级同步器的完整验证下面是一个两级同步器的Testbench我加了详细的注释timescale 1ns/1ps module tb_sync_2ff; // 时钟和复位 reg clk_src; reg clk_dst; reg rst_n_src; reg rst_n_dst; // 源信号 reg sig_src; // 同步后信号 wire sig_dst; // 实例化被测模块 sync_2ff u_sync_2ff ( .clk_dst (clk_dst), .rst_n_dst (rst_n_dst), .sig_src (sig_src), .sig_dst (sig_dst) ); // 源时钟50MHz周期20ns initial begin clk_src 1b0; forever #10 clk_src ~clk_src; end // 目标时钟100MHz周期10ns相位偏移3ns initial begin clk_dst 1b0; #3; forever #5 clk_dst ~clk_dst; end // 复位 initial begin rst_n_src 1b0; rst_n_dst 1b0; #100; rst_n_src 1b1; rst_n_dst 1b1; end // 源信号激励 initial begin sig_src 1b0; #200; // 产生一个脉冲 sig_src 1b1; #20; sig_src 1b0; #100; // 再产生一个脉冲 sig_src 1b1; #40; sig_src 1b0; #200; $stop; end // 波形记录 initial begin $dumpfile(tb_sync_2ff.vcd); $dumpvars(0, tb_sync_2ff); end endmodule这个Testbench的关键点是两个时钟的频率比是1:2但相位偏移了3ns这样能模拟两个时钟域之间的不确定性。源信号产生了两个脉冲宽度分别是20ns和40ns对应源时钟的1个周期和2个周期。你可以观察sig_dst的输出看看同步后的信号是什么样的。实测下来sig_dst会比sig_src延迟大约两个目标时钟周期也就是20ns左右。如果源脉冲宽度小于目标时钟周期sig_dst可能会漏掉这个脉冲。这就是为什么两级同步器只适用于慢速信号。4.3 脉冲同步器的验证与参数计算脉冲同步器的Testbench和两级同步器类似但需要特别注意源脉冲的间隔。下面是一个脉冲同步器的Testbenchtimescale 1ns/1ps module tb_pulse_sync; reg clk_src, clk_dst; reg rst_n_src, rst_n_dst; reg pulse_src; wire pulse_dst; pulse_sync u_pulse_sync ( .clk_src (clk_src), .rst_n_src (rst_n_src), .pulse_src (pulse_src), .clk_dst (clk_dst), .rst_n_dst (rst_n_dst), .pulse_dst (pulse_dst) ); // 源时钟50MHz initial begin clk_src 1b0; forever #10 clk_src ~clk_src; end // 目标时钟100MHz initial begin clk_dst 1b0; #3; forever #5 clk_dst ~clk_dst; end // 复位 initial begin rst_n_src 1b0; rst_n_dst 1b0; #100; rst_n_src 1b1; rst_n_dst 1b1; end // 源脉冲激励 initial begin pulse_src 1b0; #200; // 第一个脉冲 pulse_src 1b1; #20; pulse_src 1b0; #200; // 第二个脉冲 pulse_src 1b1; #20; pulse_src 1b0; #200; // 第三个脉冲间隔较短 pulse_src 1b1; #20; pulse_src 1b0; #100; pulse_src 1b1; #20; pulse_src 1b0; #300; $stop; end initial begin $dumpfile(tb_pulse_sync.vcd); $dumpvars(0, tb_pulse_sync); end endmodule这个Testbench里源脉冲的间隔分别是200ns、200ns、100ns。目标时钟周期是10ns所以200ns的间隔对应20个目标时钟周期100ns对应10个目标时钟周期都远大于2个目标时钟周期所以脉冲同步器应该能正确同步。如果你把间隔改成15ns也就是1.5个目标时钟周期就会发现脉冲同步器可能会漏掉脉冲。这就是为什么源脉冲间隔必须大于目标时钟周期的两倍。参数计算方面假设源脉冲频率是f_src目标时钟频率是f_dst那么源脉冲间隔T_src 1/f_src目标时钟周期T_dst 1/f_dst。要保证脉冲同步器正常工作需要T_src 2 * T_dst也就是f_src f_dst / 2。如果目标时钟是100MHz那么源脉冲频率不能超过50MHz。实际项目中为了留余量通常会让f_src f_dst / 3甚至更低。4.4 综合与实现中的注意事项写完代码仿真通过接下来就是综合和实现。这一步有很多坑我踩过不少这里分享几个关键的第一综合工具会报CDC警告。Vivado和Quartus都有CDC检查功能会检测跨时钟域的信号并给出警告。这些警告不一定是错误但需要你确认每个跨时钟域信号都正确处理了。如果某个信号没有处理工具会报严重警告这时候一定要回去检查。第二时序约束要写对。跨时钟域的信号不能用普通的时序约束需要用set_false_path或者set_clock_groups来告诉工具不要分析这些路径。如果约束写错了工具可能会报时序违例或者更糟糕的是工具会尝试优化这些路径导致CDC电路被破坏。第三综合属性要加对。前面提到的ASYNC_REG属性在Vivado里要加在两级同步器的触发器上。如果不加工具可能会把两级触发器合并成一级或者做其他优化导致同步器失效。我遇到过好几次仿真通过但上板失败的情况最后发现都是因为没加ASYNC_REG。第四复位要同步。如果复位信号是异步的最好在目标时钟域用两级触发器同步后再使用。否则复位释放时可能产生亚稳态导致电路行为不确定。5. 常见问题与排查技巧实录5.1 仿真通过但上板失败这是最让人头疼的问题。仿真的时候一切正常上板之后偶尔出错抓波形也抓不到。这种情况十有八九是CDC没处理好。排查思路是这样的首先检查所有跨时钟域的信号确认每个信号都用了合适的同步方法。然后检查综合属性确认ASYNC_REG加在了正确的位置。接着检查时序约束确认跨时钟域路径被正确约束了。最后用示波器或者逻辑分析仪抓实际波形看看出错的时候信号是什么状态。我遇到过一次两级同步器的代码写对了但综合的时候工具把第一级触发器优化掉了导致同步器变成了一级MTBF大幅降低。后来加了ASYNC_REG属性才解决。5.2 脉冲同步器漏脉冲脉冲同步器漏脉冲的原因通常是源脉冲间隔太短。前面说过源脉冲间隔必须大于目标时钟周期的两倍。如果源脉冲间隔接近这个极限可能会偶尔漏掉。解决方法有两个一是降低源脉冲频率二是提高目标时钟频率。如果两者都不能改那就得用更复杂的方案比如握手协议或者异步FIFO。还有一种可能是复位不同步导致的。如果源时钟域和目标时钟域的复位释放时间不同toggle_src和同步后的信号可能不一致导致漏脉冲。解决方法是用复位同步器确保两个时钟域的复位同步释放。5.3 边沿检测输出毛刺边沿检测输出的脉冲宽度只有一个目标时钟周期如果下游电路对毛刺敏感可能会误触发。解决方法是在边沿检测输出加一级触发器或者用脉冲展宽电路把脉冲展宽到需要的宽度。另外如果源信号有毛刺同步到目标时钟域后也可能产生毛刺。解决方法是在源时钟域对信号做滤波或者用施密特触发器整形。5.4 常见问题速查表问题现象可能原因排查方法解决方案仿真通过但上板偶尔出错CDC未处理或处理不当检查所有跨时钟域信号加两级同步器加ASYNC_REG属性脉冲同步器漏脉冲源脉冲间隔太短测量源脉冲间隔和目标时钟周期降低源脉冲频率或提高目标时钟频率边沿检测输出毛刺源信号有毛刺或下游电路敏感抓波形看毛刺来源源端滤波或输出加触发器综合工具报CDC警告跨时钟域信号未约束查看警告信息加set_false_path或set_clock_groups复位后电路行为不确定复位未同步检查复位路径用复位同步器5.5 独家避坑技巧第一个技巧仿真的时候一定要用非整数倍的时钟频率比。如果两个时钟频率是整数倍关系比如50MHz和100MHz仿真的时候可能碰巧不出问题但实际电路中相位关系是不确定的可能会出问题。用非整数倍频率比比如50MHz和77MHz能更好地模拟真实情况。第二个技巧在Testbench里加随机相位偏移。两个时钟的相位关系是随机的Testbench里可以用$random来生成随机相位偏移这样能覆盖更多的情况。第三个技巧用形式验证工具检查CDC。有些形式验证工具可以自动检查CDC电路的正确性比如Questa CDC、SpyGlass CDC等。这些工具能发现仿真发现不了的问题适合在项目后期做全面检查。第四个技巧CDC电路要单独做模块不要混在逻辑里。把CDC电路封装成独立的模块比如sync_2ff、pulse_sync等这样代码清晰也方便复用和检查。我见过有人把两级同步器直接写在逻辑里结果综合的时候被优化掉了查了半天才发现。第五个技巧上板调试的时候用ILA或者SignalTap抓波形。CDC的问题往往很难复现用ILA或者SignalTap抓实时波形能看到仿真看不到的问题。抓波形的时候要注意触发条件比如在出错的时候触发或者用状态机状态作为触发条件。6. 从单bit到多bit的进阶思路单bit信号的CDC处理相对简单两级同步器加边沿检测基本能覆盖大部分场景。但多bit信号的CDC处理就复杂多了不能用简单的两级同步器因为多个bit可能在不同时刻稳定导致目标时钟域采到错误的组合值。多bit信号的CDC处理方法主要有三种第一种是格雷码编码。格雷码的特点是相邻两个码字只有一位变化这样即使多个bit在不同时刻稳定也只会有一位出错不会导致整个数据错误。格雷码适合用于计数器等连续变化的信号但不适合用于任意数据。第二种是握手协议。源时钟域把数据准备好后发出请求信号目标时钟域收到请求后采样数据并发出应答信号源时钟域收到应答后才发下一个数据。握手协议能保证数据正确传输但速度较慢适合低速数据传输。第三种是异步FIFO。异步FIFO是处理多bit数据CDC最常用的方法读写分别用两个时钟域内部用格雷码指针和双口RAM实现。异步FIFO能保证数据正确传输而且速度较快适合高速数据传输。这三种方法每一种都值得单独写一篇文章我会在后续的文章里逐一展开。如果你现在就要用建议先搞清楚异步FIFO因为它是实际项目中最常用的。我个人在实际操作中的体会是CDC处理没有银弹每种方法都有适用场景和局限性。关键是要理解每种方法的原理和限制根据实际需求选择最合适的方案。另外CDC电路一定要做充分的仿真和验证不能只靠仿真通过就上板最好用形式验证工具做全面检查。上板之后如果发现问题不要慌用ILA或者SignalTap抓波形结合代码和约束仔细分析总能找到原因。