FPGA中的时序问题

一、跨时钟域设计(CDC)

1.1 什么是CDC

跨时钟域(CDC) = 一个信号,从“时钟 A”控制的逻辑,进入“时钟 B”控制的逻辑。

也就是说里面有四个主角:

原始信号A 原始时钟A 目标信号B 目标时钟B
eg:[15:0] Data0_A eg:AFE给的时钟 40MHz@0° eg:[15:0] Data0_B eg:FPGA内部时钟 120MHz@90°

1.2 为什么要做CDC

CDC核心问题是:一个时钟域,永远无法“可靠地判断”另一个时钟域的跳变时刻。因为两个时钟:不同频率、不同相位、不同步。

所以当 B 域去采样 A 域的信号时:① 可能采早了 → 采不到;② 可能采晚了 → 采错边沿;③ 可能正好压在跳变点 → 触发器亚稳态。

1.2.1 物理层原因:触发器有建立保持时间

任何一个触发器,都要求:

  • 建立时间(setup time)
  • 保持时间(hold time)

但是 A 域信号的跳变时刻 和 B 域触发器的采样边沿是完全无关的,这会导致亚稳态

因此需要一个中间商来协调这一切,也就是CDC。

1.2.2 电路层后果:亚稳态会导致什么?

亚稳态意味着:

  • 输出在 0 和 1 之间抖动
  • 延迟不确定
  • 可能传播给下一级

结果就是:

  • 状态机乱跳
  • FIFO 指针错乱
  • 计数器加错
  • 数据对不齐
  • 成像系统直接出伪影(工程层)

这就是为什么:

CDC 错误是 FPGA 里“最隐蔽、最难查、最容易复现失败”的问题。

1.3 怎么来做CDC

1.3.1 先判断这是 CDC 吗?

只要源时钟 ≠ 目标时钟,就必须当 CDC 处理。

铁律 1:先判断是不是 CDC

只要源时钟 ≠ 目标时钟,就是 CDC,哪怕频率一样相位不一样也算。


铁律 2:控制信号一定走同步器

电平 → 原语:xpm_cdc_single

脉冲 → 原语:xpm_cdc_pulse 或 toggle

多 bit → 异步 FIFO(如 xpm_fifo_async


铁律 3:CDC 后不要直接进核心逻辑

要至少经过:

  • 1~2 拍 pipeline
  • 再做边沿检测或状态判断

1.3.2 用什么 CDC 结构?

1.3.2.1 xpm_cdc_single 原语
是什么

xpm_cdc_single 是 Xilinx 提供的 单 bit 电平型 CDC 同步器
内部结构可以理解为:

  • 源时钟域打一拍(可选)
  • 目的时钟域串联 N 级触发器(通常 2–4 级)

通过增加同步级数,把亚稳态概率压到极低,从而满足系统平均无故障工作时间(MTBF)要求。

xpm_cdc_single 官方参数:

参数 取值 作用
DEST_SYNC_FF 2~10 目标域同步级数,数值越高,稳定性越好
INIT_SYNC_FF 0/1 是否在仿真时给同步触发器一个已知初值,0:初值为X,1:初值为0
SIM_ASSERT_CHK 0/1 在仿真阶段是否自动检测 CDC 违规,并打印警告,0:不检查,1:检查
SRC_INPUT_REG 0/1 是否在源时钟域先打一拍,0:不打拍,1:打拍(推荐)
把“不干净的组合输入信号”变成“源时钟域内的干净同步信号”,再送去做 CDC。
就是如果是个垃圾波形,那就写1
适用场景
  • 布尔信号0/1:enablemodeflag
  • 寄存器配置完成标志、软复位信号
  • !!!不要求“精确一拍脉冲”,只要求电平(使能信号、复位信号、标志位信号、模式选择信号、锁定信号)最终正确传递

※注意:不适用于数据传输,传过来的数据经过时钟线采回来的是错的!!!仅限于标志位、使能en信号(长期信号)!!

代码模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// cdc_single.v
module cdc_single #
(
parameter integer DEST_SYNC_FF = 4 // 目的域同步级数
)
(
input wire src_clk, //原始时钟A
input wire src_in, //原始信号A
input wire dest_clk, //目标时钟B

output wire dest_out //目标信号B
);
// Xilinx XPM 单比特电平 CDC
xpm_cdc_single #(
.DEST_SYNC_FF(DEST_SYNC_FF),
.INIT_SYNC_FF(0),
.SIM_ASSERT_CHK(0),
.SRC_INPUT_REG(1)
) u_xpm_cdc_single (
.src_clk (src_clk),
.src_in (src_in),
.dest_clk(dest_clk),
.dest_out(dest_out)
);
endmodule

不加 CDC 的“错误写法”(对比用)

1
2
3
4
5
6
7
8
9
10
11
12
13
// cdc_single_wrong.v
module cdc_single_naive(
input wire src_clk,
input wire src_in,
input wire dest_clk,

output reg dest_out
);
// 典型错误写法:直接在目标时钟域采样异步信号
always @(posedge dest_clk) begin
dest_out <= src_in;
end
endmodule
仿真对比

纯 RTL 仿真不能真实体现亚稳态问题,因为仿真模型不会把触发器采到“半高电平”或随机延迟。

但仍然可以通过仿真看到几件事:

src_insrc_clk 域产生慢速电平变化时,

  • cdc_single 输出会稳定、无毛刺;
  • cdc_single_naive 在切换瞬间可能出现一拍的毛刺(特别是你在 tb 里刻意安排“刚好撞在边沿附近”时)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// tb_cdc_single.sv
`timescale 1ns/1ps

module tb_cdc_single;
reg src_clk = 0;
reg dest_clk = 0;
reg src_in = 0;

wire dest_safe;
wire dest_naive;

// 80 MHz 源时钟(12.5 ns)
always #6.25 src_clk = ~src_clk;
// 120 MHz 目的时钟(8.33 ns)
always #4.166 dest_clk = ~dest_clk;

// 安全 CDC
cdc_single u_safe (
.src_clk (src_clk),
.src_in (src_in),
.dest_clk(dest_clk),
.dest_out(dest_safe)
);

// 错误写法
cdc_single_naive u_naive (
.src_clk (src_clk),
.src_in (src_in),
.dest_clk(dest_clk),
.dest_out(dest_naive)
);

initial begin
$dumpfile("tb_cdc_single.vcd");
$dumpvars(0, tb_cdc_single);

// 一开始 src_in = 0
#100;

// 将 src_in 拉高一个较长电平,且刻意与 dest_clk 边沿“错位”
repeat (5) begin
#(13.7); // 非整数倍,制造边沿对齐问题
src_in <= ~src_in;
end

#200;
$finish;
end
endmodule

Run Behavioral Simulation仿真就行

后面可以在 Vivado 里打开:CDC Checker(工具报错)

1
report_cdc
结果

image-20251210141720779

可以看到经过原时钟80MHz→120MHz,电平信息成功传输,但是数据信息无法传递信息,这就对应了上面说的**※注意**的内容,这是 xpm_cdc_single 原语所决定的,它只传输标志位、使能en信号这些,不负责传输数据,我在80MHz时钟下src_in中有一个高电平,120MHz时钟下的dest_safe能被采到一个高电平,功能就达到了。

其次可以看到dest_naive也成功传输了这个使能信号(src_in),这是因为,我们仿真无法仿真亚稳态状态,所以对于电平来说,无法在仿真中感受出来CDC的作用,但是它确实在真实的情况下起到不可替代的作用,接下来的 xpm_cdc_pulse 原语仿真可以感受到如果不用CDC,信号的缺失。

1.3.2.2 xpm_cdc_pulse 原语
是什么

xpm_cdc_pulse 是 Xilinx 提供的 单 bit 脉冲型 CDC 同步器

它针对的是“一个源域的窄脉冲,要确保在目标域至少产生一拍脉冲”的场景。

它和上面的xpm_cdc_single的区别在于,要传输的信号不同,pulse是要传输一个窄脉冲,时间短,而single是要传输一个时间较长的信号。

xpm_cdc_single:用于同步“电平状态”
xpm_cdc_pulse:用于同步“一次性事件(脉冲)”

实现:

  • 源域:检测脉冲,置位一个内部 toggle/持久状态
  • 目的域:同步这个状态并检测变化,从而输出一个目标时钟域的脉冲
参数 取值 作用
DEST_SYNC_FF 2~10 抗亚稳态能力,数值越高,抗性越强
INIT_SYNC_FF 0/1 是否在仿真时给同步触发器一个已知初值,0:初值为X,1:初值为0
REG_OUTPUT 0/1 是否在目标域 再用一个寄存器 把脉冲打一拍,
决定 dest_pulse 是“组合脉冲”还是“寄存器脉冲”,
0:组合逻辑,1:寄存器输出 时序逻辑(推荐)
RST_USED 0/1 是否启用复位,0:不启用复位(推荐),1:外接复位信号
SIM_ASSERT_CHK 0/1 仿真 CDC 违规检查,0:不检查,1:打开检查
适用场景
cdc跨的是什么 用哪个 为什么
使能 enable xpm_cdc_single 这是“状态”
复位 reset xpm_cdc_single 这是“状态”
模式选择 mode xpm_cdc_single 这是“状态”
锁定标志 locked xpm_cdc_single 这是“状态”
触发一次 start xpm_cdc_pulse 这是“事件”
来了一次中断 irq xpm_cdc_pulse 这是“事件”
写寄存器一次 xpm_cdc_pulse 这是“事件”

现在是 1 还是 0 → ✅ xpm_cdc_single

有没有发生过一次 → ✅ xpm_cdc_pulse

代码模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// cdc_pulse.v
module cdc_pulse #
(
parameter integer DEST_SYNC_FF = 4
)
(
input wire src_clk,
input wire src_pulse, // 源域的单周期脉冲
input wire dest_clk,

output wire dest_pulse // 目域的单周期脉冲
);
xpm_cdc_pulse #(
.DEST_SYNC_FF (DEST_SYNC_FF),
.INIT_SYNC_FF (0),
.REG_OUTPUT (1),
.RST_USED (0),
.SIM_ASSERT_CHK(0)
) u_xpm_cdc_pulse (
.src_clk (src_clk),
.src_pulse (src_pulse),
.dest_clk (dest_clk),
.dest_pulse (dest_pulse),
.src_rst (1'b0),
.dest_rst (1'b0)
);
endmodule

不加 CDC 的错误写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// cdc_pulse_naive.v
module cdc_pulse_naive(
input wire src_clk,
input wire src_pulse, // 只有 1 个源时钟周期
input wire dest_clk,

output reg dest_pulse
);
always @(posedge dest_clk) begin
// 直接在目标域采样窄脉冲:非常容易完全采不到
dest_pulse <= src_pulse;
end
endmodule
仿真对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// tb_cdc_pulse.sv
`timescale 1ns/1ps

module tb_cdc_pulse;
reg src_clk = 0;
reg dest_clk = 0;
reg src_pulse = 0;

wire dest_pulse_safe;
wire dest_pulse_naive;

// 源时钟 100 MHz(10 ns)
always #5 src_clk = ~src_clk;
// 目标时钟 60 MHz(约 16.666 ns)
always #8.333 dest_clk = ~dest_clk;

// 安全 CDC
cdc_pulse u_safe (
.src_clk (src_clk),
.src_pulse (src_pulse),
.dest_clk (dest_clk),
.dest_pulse (dest_pulse_safe)
);

// 错误写法
cdc_pulse_naive u_naive (
.src_clk (src_clk),
.src_pulse (src_pulse),
.dest_clk (dest_clk),
.dest_pulse (dest_pulse_naive)
);

// 计数器
integer cnt_src = 0; // 源域脉冲总数
integer cnt_safe = 0; // safe 在目标域看到的事件次数
integer cnt_naive = 0; // naive 在目标域看到的事件次数

// 源域:统计 src_pulse 的上升沿个数(真实事件数)
reg src_pulse_d1;
always @(posedge src_clk) begin
src_pulse_d1 <= src_pulse;
if (src_pulse & ~src_pulse_d1)
cnt_src <= cnt_src + 1;
end

// 目标域:对 safe / naive 都再打一拍做边沿检测
reg dest_pulse_safe_d1;
reg dest_pulse_naive_d1;

always @(posedge dest_clk) begin
dest_pulse_safe_d1 <= dest_pulse_safe;
dest_pulse_naive_d1 <= dest_pulse_naive;
end

wire dest_event_safe = dest_pulse_safe & ~dest_pulse_safe_d1;
wire dest_event_naive = dest_pulse_naive & ~dest_pulse_naive_d1;

// 目标域:统计真正“收到的事件次数”
always @(posedge dest_clk) begin
if (dest_event_safe)
cnt_safe <= cnt_safe + 1;
if (dest_event_naive)
cnt_naive <= cnt_naive + 1;
end

integer i;

initial begin
// 可选:导出波形文件(如果你用的是 gtkwave 等)
// $dumpfile("tb_cdc_pulse.vcd");
// $dumpvars(0, tb_cdc_pulse);

#50;

// 在源域产生 20 个单周期脉冲
for (i = 0; i < 20; i = i + 1) begin
// 故意错位,打乱与 dest_clk 的相位关系
#(17 + i*3);
src_pulse <= 1'b1;
@(posedge src_clk); // 只保持 1 个 src_clk 周期
src_pulse <= 1'b0;
end

#200;

$finish;
end

endmodule
结果

image-20251210162725977

可以看到使用xpm_cdc_pulse原语,实现了对于src_pulse脉冲信号的检测,并且通过对脉冲信号→脉冲事件,可以看到输出的已经变成非常规整的脉冲信号了。对比实验可以看到dest_pulse_naive信号就无法如实的采集脉冲信号,会产生信号丢失。

但是还是要注意:xpm_cdc_pulse也不是用来传输数据的,只是传输有几个脉冲过来了,这个信息不会因为cdc的原因丢失。

除此之外,可以看到dest_pulse_safe信号,在中间部分产生了一个很长(两个dest_clk时钟周期的脉冲信号),这是为什么呢?

是因为:这是 xpm_cdc_pulse设计特性之一,不是 bug,不是仿真错误,也不是写错了

而是由这三个因素共同决定的:

  1. 源脉冲与目标时钟完全异步
  2. xpm_cdc_pulse 内部是“toggle + 同步 + 边沿检测”结构
  3. 配置了:.REG_OUTPUT(1)

理想对齐(会看到 1 拍宽),如果 toggle 的翻转 正好非常靠近 dest_clk 上升沿,再加上:REG_OUTPUT = 1` 再打一拍

结果看到的就是: 目标域连续两个时钟周期为高电平

但是不管是1拍还是2拍,在目标域都是被当做计数一次,不会当成两个独立事件

1.3.2.3 xpm_fifo_async 异步FIFO

目前没研究明白,按理说应该是从01开始,但是现在从1e开始,等下次遇到再研究研究吧

image-20251210204740804