4、跨时钟域(CDC)检查:同步器设计、握手协议、异步FIFO

跨时钟域,简称 CDC。这玩意儿,是数字芯片设计里最容易出鬼的地方。

我刚开始做设计那会儿,觉得时钟域之间传个信号嘛,不就是打两拍?后来被现实狠狠教育了一顿。有一次,一个异步接口的数据老是不对,查了三天,最后发现是握手协议没处理好,亚稳态传播出去了。从那以后,我对 CDC 的敬畏心就拉满了。

说白了,CDC 检查就是确保:从一个时钟域到另一个时钟域的信号,不会因为时序问题导致逻辑错误。核心问题只有一个——亚稳态。

4.1 亚稳态与同步器

什么是亚稳态?你想想看,一个寄存器在时钟沿采数据时,如果数据刚好在变化,那它可能既不是 0 也不是 1,而是卡在中间。这个状态叫亚稳态。

亚稳态会传播。如果后面还跟着其他寄存器,那整个逻辑链都可能被污染。

怎么解决?最经典的办法就是两级同步器,也就是常说的「打两拍」。

两级同步器原理:

第一级寄存器可能会进入亚稳态,但第二级寄存器给它一个完整时钟周期去稳定。等第二级采样时,信号基本已经稳定了。

代码长这样:

// 两级同步器,用于单比特信号跨时钟域
module sync_2ff (
    input  wire clk_dst,
    input  wire rst_n,
    input  wire data_in,
    output reg  data_out
);

reg sync_ff1, sync_ff2;

always @(posedge clk_dst or negedge rst_n) begin
    if (!rst_n) begin
        sync_ff1 <= 1'b0;
        sync_ff2 <= 1'b0;
    end else begin
        sync_ff1 <= data_in;
        sync_ff2 <= sync_ff1;
    end
end

assign data_out = sync_ff2;

endmodule

嗯,这里要注意:两级同步器只适用于单比特信号。多比特信号不能用,因为每个比特到达的时间可能不一样,组合起来就是错误数据。

我的经验: 如果目标时钟频率很高,比如超过 500MHz,我建议用三级同步器。多一级,亚稳态的 MTBF(平均无故障时间)能提升好几个数量级。

4.2 握手协议

多比特数据怎么跨时钟域?打两拍不行了。这时候就得用握手协议。

握手协议的核心思想是:发送方说「我有数据了」,接收方说「我收到了」,然后发送方再发下一个。

标准做法是 req/ack 协议:

  • 发送域:把数据准备好,然后拉高 req 信号
  • 接收域:看到 req 后,采样数据,然后拉高 ack 信号
  • 发送域:看到 ack 后,拉低 req,准备下一笔

这里有个坑——req 和 ack 本身也是跨时钟域的!所以它们各自也需要两级同步器。

我曾经踩过的坑: 有一次做握手协议,发送域拉高 req 后,接收域还没采样到,发送域就把数据改了。结果接收域采到了错误数据。后来我加了一个约束:req 拉高期间,数据必须保持稳定。

握手协议的时序图大概是这样:

发送时钟域:
  data:   X  D0  X  D1  X
  req:    ___/‾‾‾\_________

接收时钟域:
  ack:    ___________/‾‾‾\_
  data_out:  X  D0  X  D1

握手协议的优点是简单可靠,缺点是吞吐量低。每传一笔数据,至少需要两个跨时钟域往返。如果时钟频率差异大,延迟会很高。

4.3 异步 FIFO

当需要批量传输数据时,握手协议就太慢了。这时候就该异步 FIFO 上场了。

异步 FIFO 的本质是:一个双端口 RAM,加上读写指针,再加上格雷码同步。

为什么用格雷码?因为格雷码相邻两个值只有 1 比特变化。这样跨时钟域同步时,即使出现亚稳态,最多错一个比特,不会出现多个比特同时错乱的情况。

异步 FIFO 的核心逻辑:

  1. 写指针在写时钟域递增,指向下一个写入地址
  2. 读指针在读时钟域递增,指向下一个读出地址
  3. 写指针同步到读时钟域,判断空标志
  4. 读指针同步到写时钟域,判断满标志

空满判断是关键。我见过很多新手在这里翻车。

  • 空标志:读指针追上写指针时,FIFO 为空
  • 满标志:写指针追上读指针,且多绕了一圈时,FIFO 为满

判断多绕了一圈,通常用指针最高位来区分。比如深度为 8 的 FIFO,指针用 4 位(3 位地址 + 1 位绕圈标志)。

代码示例(核心部分):

// 写指针转格雷码
wire [WIDTH:0] wptr_gray;
assign wptr_gray = wptr ^ (wptr >> 1);

// 同步到读时钟域
reg [WIDTH:0] wptr_gray_sync1, wptr_gray_sync2;
always @(posedge rclk or negedge rst_n) begin
    if (!rst_n) begin
        wptr_gray_sync1 <= 0;
        wptr_gray_sync2 <= 0;
    end else begin
        wptr_gray_sync1 <= wptr_gray;
        wptr_gray_sync2 <= wptr_gray_sync1;
    end
end

// 空标志判断:读指针追上同步后的写指针
assign empty = (rptr_gray == wptr_gray_sync2);

我的习惯: 异步 FIFO 的深度不要选 2 的幂次减 1,直接选 2 的幂次。比如深度 8、16、32。这样指针绕圈判断更简单,不容易出错。

异步 FIFO 的优缺点很明显:

优点 缺点
吞吐量高,适合批量传输 面积大,需要双端口 RAM
延迟可控,与数据量无关 空满判断有延迟,可能误判
支持任意时钟频率比 设计复杂度高,容易出 bug

下面这张图展示了异步 FIFO 的整体结构:

写时钟域 (wclk) 读时钟域 (rclk) 写指针 (wptr) 读指针 (rptr) 双端口 RAM 深度: 2^N, 宽度: M 写数据 读数据 格雷码转换 格雷码转换 同步器 (2FF) 同步器 (2FF) 空/满标志生成

总结一下:

  • 单比特:用两级同步器,简单粗暴
  • 多比特、低吞吐:用握手协议,可靠但慢
  • 多比特、高吞吐:用异步 FIFO,复杂但快

最后提醒一句: 不管用哪种方式,CDC 路径一定要在综合时设置 false path 或 set_clock_groups。不然工具会报一堆时序违例,全是假的。我见过有人没设 false path,结果花了三天去修那些根本不存在的时序问题……

CDC 检查,说白了就是「小心驶得万年船」。多想想亚稳态会不会传播,多看看同步器够不够用,多测测 FIFO 会不会溢出。嗯,做到这些,基本就稳了。

可可精品资料库