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 的核心逻辑:
- 写指针在写时钟域递增,指向下一个写入地址
- 读指针在读时钟域递增,指向下一个读出地址
- 写指针同步到读时钟域,判断空标志
- 读指针同步到写时钟域,判断满标志
空满判断是关键。我见过很多新手在这里翻车。
- 空标志:读指针追上写指针时,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 的整体结构:
总结一下:
- 单比特:用两级同步器,简单粗暴
- 多比特、低吞吐:用握手协议,可靠但慢
- 多比特、高吞吐:用异步 FIFO,复杂但快
最后提醒一句: 不管用哪种方式,CDC 路径一定要在综合时设置 false path 或 set_clock_groups。不然工具会报一堆时序违例,全是假的。我见过有人没设 false path,结果花了三天去修那些根本不存在的时序问题……
CDC 检查,说白了就是「小心驶得万年船」。多想想亚稳态会不会传播,多看看同步器够不够用,多测测 FIFO 会不会溢出。嗯,做到这些,基本就稳了。