干了几年数字IC,面试过别人也被别人面试过,跨时钟域(CDC)永远是绕不开的坎。不管是前端设计、验证还是后端,只要是做数字芯片的,CDC基础知识就是吃饭的家伙。很多同学拿着一堆八股文背得滚瓜烂熟,真到项目里遇到一个异步接口还是两眼一抹黑。这篇文章就把同步/异步时钟、跨时钟域处理这套东西从头捋一遍,尽量用实际项目的视角讲,不讲虚的。
这块内容我打算持续更新,所以标题带了个"待更"。先写基础篇,把概念和单bit同步讲透,后面再补异步FIFO深度解析、CDC验证方法学、以及真实项目的CDC逃逸案例。这篇适合正在准备数字IC校招面试的同学、刚入行的前端/验证工程师,以及想系统补一下CDC基础的后端工程师。
1. 跨时钟域到底在解决什么问题
先别急着背同步器结构,先搞清楚我们为什么要做跨时钟域处理,以及问题的根源是什么。只有理解了本质,面试时不管怎么追问你都能接得住。
数字芯片里,不同模块往往工作在不同的时钟频率下。芯片外部进来的接口时钟、内部PLL分频出来的总线时钟、高速接口的serdes时钟,这些时钟之间频率可能不同,相位关系也可能完全随机。当信号从一个时钟域进入另一个时钟域时,就产生了跨时钟域的问题,简称CDC。
1.1 先分清:同步时钟和异步时钟
很多人把"同步/异步"挂在嘴边,但问深一层就开始含糊。判断两个时钟是同步还是异步,不是看频率是否相同,而是看它们之间有没有固定的相位关系。
如果两个时钟来自同一个时钟源,比如同一个PLL输出的不同分频,或者同一个时钟经过去抖缓冲树之后的分支,那么它们之间的相位关系是确定的、可预测的,这就是同步时钟。即使频率不同(比如一个100MHz,一个50MHz),只要上升沿的对齐方式是固定的,仍然算同步时钟。
异步时钟则完全相反,两个时钟来自不同的振荡源,频率可能有微小偏差,相位关系完全随机,没有任何办法预测某个时刻两个时钟沿的相对位置。比如芯片外部输入的参考时钟和内部PLL产生的时钟就是异步的。还有一种特殊情况,即使是同一个晶振产生的时钟,经过了不同的PLL、不同的分频路径,到达寄存器时钟端的相位已经失去了确定性,工程上也要按异步来处理。
这里有个面试常考的坑:两个时钟频率相同但相位不固定,算同步还是异步?答案是异步。相位不固定就意味着每次采样的时序裕量都不一样,无法保证满足建立保持时间,必须按异步处理。
1.2 亚稳态:CDC一切问题的根源
跨时钟域最怕的就是亚稳态。什么叫亚稳态?简单说,当触发器的数据输入在时钟沿附近发生变化,违反了建立时间或保持时间的要求时,触发器的输出就会进入一个不确定的状态——既不是稳定的高电平也不是稳定的低电平,而是在两者之间的振荡或徘徊,这个状态就叫做亚稳态。
用生活化的例子理解:你把一个球放在山顶上,它可能滚向左边,也可能滚向右边,但在某个瞬间它停在山顶上,这个"停在山上"的状态就是亚稳态。触发器处于亚稳态时,输出电压可能徘徊在阈值附近,导致后级逻辑误判为0或1,甚至继续传递下去,让多个模块都出错。
亚稳态最终会"决断"(resolution),变成稳定的0或1,但决断需要时间(用tmet表示)。如果决断时间太长,超过了后级寄存器的建立时间要求,亚稳态就会传播到下一级,造成整个逻辑链的混乱。
亚稳态有几个关键特性必须记住:
- 亚稳态无法完全消除,只能通过增加MTBF(平均无故障时间)来降低发生概率
- 亚稳态的输出值不可预测,可能是0,可能是1,还可能振荡
- 亚稳态可能传播,所以需要同步器来"隔离"它
1.3 单bit和multi-bit:处理难度完全不同
跨时钟域的信号按位宽可以分成两类:单bit信号和多bit信号(总线)。这两类的处理方式截然不同。
单bit信号,比如一个中断标志、一个握手请求、一个写使能脉冲,处理方式相对简单,核心就是"打拍同步"——用目标时钟域的双触发器(或多触发器)链来采样源时钟域的信号。
多bit信号,比如一组数据总线、FIFO指针、配置寄存器值,就不能简单用打拍来处理了。因为多bit信号在源时钟沿同时变化,到达目标时钟域时由于布线延迟差异,各bit的到达时间和被采样时刻可能不同步,导致采样到组合错误的值。比如计数器从011变成100,如果采样时刻恰好卡在中间,可能采到000、111等乱七八糟的值。
多bit信号的标准解法是异步FIFO、握手协议、或者格雷码编码(针对连续变化的指针类信号)。这些后面都会详细讲。
2. 单bit跨时钟域的经典方案
单bit跨时钟域是CDC的基础,也是最常考的面试题。很多人知道"打两拍",但为什么打两拍、打三拍行不行、什么场景不能只打两拍,这些才是拉开差距的地方。
2.1 两级触发器同步器:最基础也最常用
两级触发器同步器(也就是常说的"打两拍")是最基础的CDC结构。源时钟域的信号先打一拍进入目标时钟域的第一级触发器,再打一拍到第二级触发器,第二级的输出作为同步后的信号使用。
为什么非要两级?第一级触发器采样异步信号时,大概率会进入亚稳态,但经过一个时钟周期后,亚稳态会"决断"成稳定值,第二级触发器再采样时采到的就是稳定值了。所以第二级触发器的输出是干净的、不会出现亚稳态传播的信号。
用实际代码说明,Verilog写法非常简单:
module sync_2ff #( parameter WIDTH = 1 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] async_in, output wire [WIDTH-1:0] sync_out ); reg [WIDTH-1:0] sync_ff1; reg [WIDTH-1:0] sync_ff2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff1 <= {WIDTH{1'b0}}; sync_ff2 <= {WIDTH{1'b0}}; end else begin sync_ff1 <= async_in; sync_ff2 <= sync_ff1; end end assign sync_out = sync_ff2; endmodule这里有个关键点:第一级触发器的输出sync_ff1仍然可能是亚稳态的(刚采完还没来得及决断),但它不需要接到任何组合逻辑,直接接第二级触发器的D端,所以亚稳态不会传播到功能逻辑里。这就是两级同步器的核心原理——把亚稳态"关"在两级触发器之间。
实际项目中,综合工具会给同步器标记特殊的时序约束(比如set_false_path或set_max_delay),让工具不检查第一级触发器的时序,或者用更严格的约束保证同步器有足够的决断时间。这个后面在工具约束部分细说。
2.2 打两拍够不够:关于MTBF的数学账
面试官最喜欢问的一个问题:打两拍能100%消除亚稳态吗?答案是不能,只能把亚稳态发生的概率降到极低。
这里要引入MTBF(Mean Time Between Failures,平均故障间隔时间)的概念。MTBF衡量的是同步器平均多久发生一次失效(即亚稳态传播到后级逻辑)。对于一个双触发器同步器,MTBF的近似公式是:
MTBF = e^(tmet/τ) / (T0 × fclk × fdata)
其中:
- tmet是允许的决断时间,即第二级触发器的建立时间减去第一级触发器的CK-to-Q延迟,近似等于一个时钟周期减去建立时间
- τ是决断时间常数,由工艺决定,通常在几皮秒到几十皮秒
- T0是触发器的亚稳态窗口宽度,由工艺决定
- fclk是目标时钟频率
- fdata是异步信号的翻转频率
从这个公式能看出几个重要结论:
第一,tmet在指数项上,所以增加决断时间对MTBF的提升是立竿见影的。比如时钟频率从100MHz降到50MHz,tmet翻倍,MTBF会指数级提升。这也是为什么跨时钟域信号最好用较慢的时钟去采样。
第二,fclk和fdata在分母上,频率越高,越容易发生亚稳态。所以高速设计里CDC的风险更大。
第三,工艺节点越先进,τ越小,同样条件下MTBF越大,但先进工艺的时钟频率也更高,所以实际风险并不一定更小。
有一组典型数据可以感受一下:对于一个100MHz时钟、10MHz翻转率的信号,两级同步器的MTBF大约是几十年到几百年,工程上完全够用。但如果时钟频率翻倍到200MHz且不加任何处理,MTBF可能降到几小时甚至几分钟,产品根本没法用。
2.3 什么场景不能只打两拍:电平型vs脉冲型信号
打两拍适用于电平型信号——即源时钟域的信号是持续稳定的电平(保持多个目标时钟周期不变)。比如模块A采到一个外部事件,拉高一个标志位并保持不变,模块B用双触发器同步这个标志位,这样一定能采到,因为电平一直在。
但如果源时钟域产生的是一拍宽的脉冲信号,而这个脉冲宽度比目标时钟周期还窄,那打两拍就失效了——很可能脉冲在两个时钟沿之间悄悄过去,第一级触发器根本没采到。
快时钟域到慢时钟域:脉冲同步器
跨时钟域最经典的应用场景:快时钟域产生一个单周期脉冲,需要传到慢时钟域。比如一个100MHz模块每发生一次事件就产生一个脉冲,需要把这个脉冲同步到50MHz模块。
脉冲信号在快时钟域看起来就是一个时钟周期的高电平,在慢时钟域看来可能只是一个很窄的毛刺,采样时大概率漏掉。解决办法是先把脉冲转成电平(翻转信号),同步过去之后再检测边沿恢复成脉冲。
这就是脉冲同步器的原理,核心是把"事件"变成"状态":每次脉冲来到,同步器的状态翻转一次;目标时钟域检测到状态变化(边沿),就产生一个脉冲。具体代码逻辑:
module pulse_sync ( input wire fast_clk, input wire slow_clk, input wire rst_n, input wire pulse_in, // 源时钟域脉冲 output wire pulse_out // 目标时钟域脉冲 ); // 脉冲转电平翻转 reg toggle; always @(posedge fast_clk or negedge rst_n) begin if (!rst_n) toggle <= 1'b0; else if (pulse_in) toggle <= ~toggle; end // 电平信号跨时钟域(打两拍) reg sync_ff1; reg sync_ff2; always @(posedge slow_clk or negedge rst_n) begin if (!rst_n) begin sync_ff1 <= 1'b0; sync_ff2 <= 1'b0; end else begin sync_ff1 <= toggle; sync_ff2 <= sync_ff1; end end // 边沿检测恢复脉冲 reg sync_ff2_d; always @(posedge slow_clk or negedge rst_n) begin if (!rst_n) sync_ff2_d <= 1'b0; else sync_ff2_d <= sync_ff2; end assign pulse_out = sync_ff2 & ~sync_ff2_d; // 上升沿检测 endmodule这个结构完美解决了"窄脉冲跨慢时钟域丢失"的问题,代价是脉冲的延迟变大了——从源脉冲发生到目标时钟域检测到边沿,需要2~3个目标时钟周期。某些实时性要求高的场景,这个延迟时间需要提前评估好。
慢时钟域到快时钟域:可以直接打两拍吗
慢时钟域的信号(无论是电平还是脉冲)进入快时钟域,情况要简单一些。因为快时钟的采样率更高,慢信号的变化通常能被采到。
慢时钟域的单周期脉冲进入快时钟域,只要慢时钟频率和快时钟频率不是差得太离谱(比如快时钟是慢时钟的2倍以上),脉冲宽度本身就足够宽,快时钟域的打两拍同步器基本都能采到。但严谨地讲,如果慢时钟域的脉冲宽度在快时钟域里只是"恰好满足建立时间"边缘,仍然存在采不到的风险,所以更稳妥的做法还是用脉冲同步器或者握手。
工程上有一个快速判断经验:如果源时钟频率和目标时钟频率相差4倍以上,单bit信号一律用脉冲同步器或握手处理,不要赌打两拍一定能采到。
2.4 结绳法:脉冲信号的另一种思路
脉冲同步器的思路是"翻转电平",还有另一种思路叫"结绳法"。结绳法的做法是:源时钟域检测到脉冲后,把信号拉高;目标时钟域同步到高电平后,发出应答,源时钟域收到应答后拉低;下一次脉冲再重复这个过程。
结绳法本质上是把CDC变成了异步握手,每一个事件都要完成"请求-应答"的完整循环。它的好处是更可靠,适用于极端情况(比如目标时钟域可能暂停、频率极低);坏处是吞吐量低,一个事件从发出到处理完可能需要多个时钟周期,不适合高频事件流的传输。
实际项目中,结绳法用得不算多,更多是在写异步接口协议时作为一种兜底方案。但如果问你"怎么确保一个脉冲一定能被对方采到",结绳法就是比脉冲同步器更稳妥的答案。
3. 多bit信号跨时钟域的工程解法
多bit跨时钟域要比单bit麻烦得多,也是面试重点中的重点。面试官一般会从"多bit能不能直接打两拍"开始问,然后看你能不能讲清楚异步FIFO的格雷码原理。
3.1 为什么多bit不能直接打两拍
关键在于多bit信号是并行变化的。假设一组8bit总线从01111111变成10000000,理论上所有bit同时翻转。但实际物理实现中,每根线的布线延迟不同,到达目标时钟域触发器D端的时间有微小差异。如果这些差异恰好跨过了目标时钟的采样沿,就会出现某些bit采到了新值、某些bit还停留在旧值的情况,组合出来的结果既不是旧值也不是新值。
举个例子,一个计数器从7(0111)变成8(1000),如果最高位先翻转、低三位还没翻完的时候采样,可能采到1111(15)这种完全错误的值。这个错误值一旦被下游逻辑使用,可能造成状态机跳错、地址访问错误等严重问题。
所以结论是:多bit数据不能用双触发器同步,这是CDC的铁律。要解决多bit跨时钟域,要么用异步FIFO把数据缓存起来,要么用握手协议做"数据稳定后再通知对方来取"的流程,要么对特殊信号(如FIFO指针)用格雷码编码。
3.2 异步FIFO:多bit跨时钟域的标准答案
异步FIFO是跨时钟域最常用的方案,没有之一。它解决的是"数据流"跨时钟域的问题——源时钟域持续或突发地写入数据,目标时钟域以不同的时钟频率读走数据,中间用FIFO做缓冲。
异步FIFO的核心难点在于空满判断。如果FIFO为空时读,读到的是无效数据;如果FIFO为满时写,数据会丢失(或者覆盖还没读走的数据)。而空满判断需要比较读指针和写指针,这两个指针分别处在不同的时钟域里,比较本身就面临CDC问题。
异步FIFO的设计思路:
- 用读写两个指针分别记录写入位置和读出位置
- 判断空:读指针和写指针相等
- 判断满:写指针追上读指针,即写指针比读指针多走了一圈
如果直接用二进制指针跨时钟域比较,就会遇到多bit同时翻转的采样问题。所以经典解法是把指针转换成格雷码再跨时钟域——格雷码的特点是相邻两个值只有1bit变化,即使采样时刻卡在中间,最多也就是1bit的错误,而1bit的错误只会让指针"看起来没变化",不会出现完全离谱的值。
格雷码跨时钟域还有一个细节要理解:把写指针的格雷码同步到读时钟域后,和读指针(也在读时钟域)比较判断空;把读指针的格雷码同步到写时钟域后,和写指针比较判断满。跨时钟域比较的永远是"异步域的当前值"和"本地域的值",而同步过程有两拍延迟,所以空满判断是保守的——可能会误判为满(但其实没满),但绝不会误判为空或满导致数据出错。
具体空满条件怎么判断,假设FIFO深度是2的N次方,指针位宽是N+1(最高位表示折返):
- 空:读写指针完全相等(包括最高位)
- 满:写指针最高位与读指针最高位相反,其余位相等
这个判断逻辑在代码里是这样实现的:
// 写时钟域判断满 wire full = (wptr_gray == {~rptr_gray_sync[MSB], rptr_gray_sync[MSB-1:0]}); // 读时钟域判断空 wire empty = (rptr_gray == wptr_gray_sync);其中rptr_gray_sync是读指针格雷码同步到写时钟域后的值,wptr_gray_sync是写指针格雷码同步到读时钟域后的值。
二进制转格雷码的公式是:gray = (bin >> 1) ^ bin。这个公式必须熟记,面试和写代码都会用。
3.3 握手协议:适合低速控制信号的方案
异步FIFO适合高吞吐的数据流,但如果是偶尔传输一次配置数据,用FIFO就有点杀鸡用牛刀了。这时候握手协议更合适。
握手协议的标准流程是四相握手(4-phase handshake):
- 源时钟域把数据放到总线上,拉高req信号
- 目标时钟域同步req(打两拍),检测到req后采样数据总线,拉高ack
- 源时钟域同步ack(打两拍),检测到ack后撤销req(数据可以撤掉)
- 目标时钟域检测到req撤销,拉低ack;源时钟域检测到ack撤销,完成一次传输
这个流程的关键在于:目标时钟域只有在req有效之后才去采样数据,保证了采到的数据一定是稳定的——因为源时钟域在req拉高之前就把数据放好了,而且数据会保持到收到ack之后。握手协议本质上是用"时序上的前后关系"替代了"同一时刻的采样可靠性"。
握手协议的缺点是每次传输至少需要4次跨时钟域握手,延迟很大。但优点是简单可靠、通用性强,几乎适用于所有慢速控制信号跨时钟域的场景。
3.4 MUX同步:多bit的另一种思路
除了FIFO和握手,还有一个常见方案叫MUX同步,也叫"使能同步"。思路是:多bit数据不直接跨时钟域,而是把多bit信号源时钟域保持不变,只把"数据有效"这个单bit信号用双触发器同步过去;目标时钟域收到有效信号后,再去采样数据总线。
这里有个前提条件:数据信号必须保持足够长的时间,确保目标时钟域同步到有效信号后再去采数据时,数据仍然是稳定的。所以MUX同步适用于"数据保持多个时钟周期不变"的场景,比如配置寄存器、状态机的状态值。
MUX同步比握手协议简单,不需要回ack,延迟更小。但如果源时钟域的数据变化太频繁,目标时钟域可能等不到采样时机,就需要改成FIFO方案。
个人经验:MUX同步本质上是一种"慢到快"的思想——只要数据在慢时钟域能保持足够久,快时钟域总能采到。所以面试时遇到"多bit慢信号到快时钟域怎么办",MUX同步是一个很加分的回答方向。
3.5 格雷码不只用于FIFO
面试里还有个高频追问:格雷码除了FIFO,还能用在什么场景?答案是:只要是多bit连续变化、且需要跨时钟域比较的信号,格雷码都是解决办法。
典型例子是异步FIFO的指针,这是最经典的。另外还有:乒乓RAM的地址切换标志、异步比较器的输入编码、陀螺仪角度编码器输出(纯数字逻辑场景)。核心规律就是:如果一组信号在逻辑上按顺序变化,每次只改变1bit,那它跨时钟域同步的问题就退化成了单bit问题,直接打两拍即可。
理解了这个本质,回答"为什么FIFO指针用格雷码""格雷码有什么优缺点"这类问题就游刃有余了。格雷码的缺点也要能说出来:不能用于任意数据的跨时钟域,只能处理"有序、单bit变化"的信号;而且格雷码做算术运算很麻烦,所以FIFO里实际的读写地址计算还是用二进制,只在跨时钟域时才转格雷码。
4. 面试八股与项目避坑指南
这一章回到大家最关心的:面试怎么答,项目里怎么用。我整理了面试最高频的CDC题目和几个真实项目中踩过的坑。
4.1 面试高频问题速查表
下面这些问题都是我被问过或看别人被问过的,整理成表格方便对照复习:
| 问题 | 回答要点 |
|---|---|
| 什么是亚稳态 | 触发器建立/保持时间不满足时,输出处于不确定状态,随时间推移可能恢复为0或1 |
| 打两拍为什么能消除亚稳态影响 | 第一级把亚稳态隔离,第二级采样时第一级的输出已稳定 |
| 打两拍能完全消除亚稳态吗 | 不能,只能把概率降到极低,用MTBF衡量 |
| 快时钟到慢时钟传脉冲怎么办 | 不能用双触发器,需要用脉冲同步器(翻转电平法)或握手 |
| 多bit数据能不能打两拍 | 不能,多bit各bit到达时间不同,采样可能得到错误组合值 |
| 异步FIFO空满怎么判断 | 写指针格雷码同步到读时钟域判空,读指针格雷码同步到写时钟域判满 |
| 格雷码为什么适合跨时钟域 | 相邻值只有1bit变化,采样最多1bit错误,不会产生大的偏差 |
| 异步复位信号需要同步吗 | 异步复位释放时需要同步,即异步复位同步释放电路 |
| 异步FIFO深度怎么定 | 根据突发长度、读写频率差、延迟计算:深度 ≥ 突发写入数 - 在途读出数 |
| 什么时候用握手什么时候用FIFO | 低吞吐控制信号用握手,高吞吐数据流用FIFO |
4.2 异步复位同步释放:容易被忽略的CDC
跨时钟域不只是数据信号,复位信号同样有CDC问题。一个常见的错误是直接使用异步复位,复位撤销时如果恰好落在时钟沿附近,就会产生亚稳态,可能导致部分触发器复位了、部分没复位,状态机进入非法状态。
标准解法是异步复位同步释放,代码结构:
reg rst_n_r1, rst_n_r2; always @(posedge clk or negedge async_rst_n) begin if (!async_rst_n) begin rst_n_r1 <= 1'b0; rst_n_r2 <= 1'b0; end else begin rst_n_r1 <= 1'b1; rst_n_r2 <= rst_n_r1; end end assign sync_rst_n = rst_n_r2;这个是异步置位、同步释放:复位信号有效时立即复位(异步),复位撤销时经过两级触发器同步后,所有触发器同一个时钟沿释放(同步释放)。这样既保留了异步复位响应快的优点,又避免了复位释放时的亚稳态问题。
面试时如果被问到复位,至少要能画出这个电路、写出这段代码,并说明为什么最后一级的输出可以直接用作整个模块的异步复位信号。
4.3 工具约束与验证手段:不只是写代码
项目里做CDC,写完代码只是第一步。业界常用的CDC工具(比如Cadence的Conformal CDC、Synopsys的SpyGlass CDC)会自动检查代码里的跨时钟域路径,标记缺失同步器的信号、检查同步器结构、分析时钟域划分。
综合时需要给同步器的第一级触发器设置合理的时序例外。常见做法是把第一级触发器的时钟路径设为false path(因为它采样的异步信号本来就没有时序关系),同时对同步器内部路径设置max_delay约束,确保第一级有足够的决断时间。不同工具的命令不完全一样,但思路一致:让工具知道这条路径的时序要求特殊。
验证端的重点则是CDC验证。除了功能仿真里做跨时钟域用例,还要关注X传播(X-Propagation)问题——异步信号采不到时在仿真里会显示为X,可能导致验证环境误报。常见的处理方式是用cdc_ignore之类的声明或仿真选项来规避。
真实项目中还有一个容易被坑的点:异步FIFO的格雷码信号在仿真波形里可能出现"看起来像毛刺"的中间态。这不一定是bug,因为格雷码变化时只有1bit跳变,跳变过程中可能有些bit先到、有些后到,仿真器可能显示成短暂毛刺,但实际上功能是安全的。遇到这种情况,要相信设计逻辑而不是直接改代码。
4.4 我在项目中总结的几条经验
最后分享几条自己踩过的坑总结的经验,都是文档里不太会写的:
第一,同步器的放置位置有讲究。同步器一定要放在信号的"入口处",也就是说,异步信号进入目标时钟域后,第一件事就是打两拍,之后才能进组合逻辑。如果把打两拍放在一堆逻辑后面,前面组合逻辑的毛刺会直接进同步器,第一级触发器采到毛刺后亚稳态风险大增。一定要让同步器紧贴时钟域边界。
第二,同样的CDC问题在验证环境里也可能出现。testbench里如果给DUT的输入信号没有做同步处理,仿真时就会看到莫名其妙的X态或数据错误,以为是DUT的bug,排查半天发现是激励的问题。所以在testbench里也要按CDC的规范来,该打拍打拍,该握手握手。
第三,写RTL时就要规划好时钟域。很多同学写代码时不标注时钟域,一个module里混着多个时钟,等到CDC检查时发现一堆红色报警。我现在写代码的习惯是:每个module只允许一个时钟(或者明确分成几个时钟域的子模块),跨时钟域信号统一封装在专门的CDC模块里,用命名后缀(比如_sync、_cdc)标注。这样代码可读性高,工具检查也省心。
第四,慢时钟域同步快时钟域信号时,优先确认信号的最小脉宽和翻转率。如果源信号翻转太频繁,目标时钟域根本跟不上,这时候不是加同步器能解决的,得从架构上改——比如改用FIFO缓存数据,而不是直接同步信号。
这些经验看着简单,但都是花了不少时间换来的。跨时钟域这个主题确实值得反复钻研,后续我会接着把异步FIFO的完整设计、CDC验证方法、以及真实调试案例整理出来,到时候见。