Introduction
Clock Domain Crossing (CDC) is one of the most common sources of subtle, hard-to-debug bugs in FPGA design. Unlike most digital design problems, CDC issues don’t always show up in simulation — they can pass all your testbenches and still cause intermittent failures in hardware.
In this post we’ll look at two real CDC problems that appeared during the development of a digital FM receiver on a Xilinx FPGA. Both bugs caused the design to malfunction in hardware despite appearing correct in simulation. We’ll walk through what the problem was, why it happened, how to identify it, and how to fix it properly.
If you know VHDL and understand basic clocking concepts but haven’t dealt with CDC before, this post is for you.
Background: What is Clock Domain Crossing?
In an FPGA design with multiple clocks, each clock defines a clock domain — a group of registers that are all clocked by the same signal. When data travels from a register in one clock domain to a register in a different clock domain, that is a Clock Domain Crossing.
The problem with CDC is metastability. When a register’s setup or hold time is violated — which can happen when data changes close to a clock edge from a different domain — the output of the register can become undefined for an unpredictable period of time. This can propagate through your design and cause incorrect behaviour.
There are also timing analysis problems. Vivado checks setup and hold timing between all registers. When the source and destination registers are in different clock domains, the tool checks timing between the nearest clock edges — which may be very close together even if in practice the clocks are far apart. This can cause false timing violations.
The Design Context
The FM receiver used the following clock structure, all generated from a single MMCM in a Xilinx Clock Wizard:
| Output | Frequency | Signal name | Used for |
|---|---|---|---|
| clk_out1 | 100 MHz | clk100MHz | General logic / ILA |
| clk_out2 | 200 MHz | clk200MHz | DSP / FM demodulator |
| clk_out3 | 10 MHz | clk10MHz | ADC clock / DAC state machine |
| clk_out4 | 4.8 MHz | clk4M8MHz | Unused in final design |
| clk_out5 | 12.24 MHz | clkI2SMHz | I2S audio interface |
Even though all clocks come from the same MMCM, Vivado treats paths between different clock outputs as potential CDC crossings and will flag timing violations on them.
Case Study 1: The Missing DAC Pulse
The Problem
The design included a DAC (AD5445) driven by a state machine running on clk10MHz>. The state machine needed to be triggered at a 50 kHz rate to output audio samples. A pulse signal DAC_in_pulse was used as the trigger. The original code generated this pulse in the clk200MHz domain:
PROBLEM — DAC pulse generated in clk200MHz, consumed in clk10MHz
VHDL
-- PROBLEMATIC CODE
-- DAC pulse generated in clk200MHz domain
process(clk200MHz, reset)
begin
if reset = '1' then
DACcounter <= 0;
DAC_in_pulse <= '0';
elsif rising_edge(clk200MHz) then
if DACcounter = 3999 then
DACcounter <= 0;
DAC_in_pulse <= '1'; -- ONE clk200MHz cycle = 5 ns wide!
else
DACcounter <= DACcounter + 1;
DAC_in_pulse <= '0';
end if;
end if;
end process;
-- DAC state machine running on clk10MHz
process(clk10MHz, reset)
begin
if reset = '1' then
state <= 0;
elsif rising_edge(clk10MHz) then
case state is
when 0 =>
if DAC_in_pulse = '1' then -- trying to catch a 5ns pulse!
BasebandSigScaled <= FM_audio_out(45 downto 34);
state <= 1;
end if;
end case;
end if;
end process;
Why It Failed
The symptom was that BasebandSigScaled was always zero and the DAC produced no output. A test toggle signal placed inside the state 0 trigger confirmed that the condition was never being entered — the pulse was never seen.
The root cause becomes clear when you look at the clock periods:
clk200MHz period: 5 ns
clk10MHz period: 100 ns
DAC_in_pulse HIGH for: ONE clk200MHz cycle = 5 ns
clk10MHz samples every: 100 ns
The pulse is 20× shorter than the sampling clock period!
Here is what happens on the waveforms:
clk200MHz: __|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_
DAC_pulse: __________________________|‾|_________________________
clk10MHz: ____|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|________
↑
clk10MHz samples here
— pulse is already gone!
The clk10MHz rising edge arrives long after the 5 ns pulse has gone low. The pulse is completely invisible to the slow clock.
The Fix
The simplest and cleanest fix is to move the pulse generator into the same clock domain as the state machine. Since the state machine runs on clk10MHz, generate the pulse there too:
FIXED — Generate DAC pulse in clk10MHz domain
-- FIXED CODE
-- 10 MHz / 50 kHz = 200 cycles
process(clk10MHz, reset)
begin
if reset = '1' then
DACcounter <= 0;
DAC_in_pulse <= '0';
elsif rising_edge(clk10MHz) then
if DACcounter = 199 then
DACcounter <= 0;
DAC_in_pulse <= '1'; -- 100 ns wide — clk10MHz sees it every time
else
DACcounter <= DACcounter + 1;
DAC_in_pulse <= '0';
end if;
end if;
end process;
Now DAC_in_pulse is generated and consumed in the same clock domain. No CDC. No missed pulse. The state machine reliably triggers at 50 kHz.
Key lesson: A pulse generated in a fast clock domain can be completely invisible to a slow clock domain if the pulse width is shorter than the slow clock period. The fix is to either generate the pulse in the destination clock domain, or use a toggle-based handshake to safely transfer the event across domains.
Case Study 2: The I2S Audio Timing Violation
The Problem
The second CDC issue appeared as a Vivado timing violation after implementation. The timing report showed:
Slack (VIOLATED): -0.891ns
Source: FM_Demod/fir_compiler/.../m_axis_data_tdata_int_reg[43]/C
(clocked by clk_out2_clk_wiz_0, period = 5.000ns)
Destination: I2S_Audio/Audio_in_reg_reg[31]/D
(clocked by clk_out5_clk_wiz_0, period = 81.667ns)
Requirement: 1.667ns
Data delay: 1.896ns → VIOLATION of -0.891ns
The path was from the FM demodulator output register (clocked by clk200MHz) directly into the I2S audio input register (clocked by clkI2SMHz at 12.24 MHz). The offending code in the I2S module was:
PROBLEM — Direct CDC crossing from clk200MHz to clkI2SMHz
-- PROBLEMATIC CODE
-- Audio_data_in comes from clk200MHz domain
-- Audio_in_reg is in clkI2SMHz domain
process(pll_clk, reset) -- pll_clk = clkI2SMHz = 12.24 MHz
begin
if reset = '1' then
Audio_in_reg <= (others => '0');
elsif rising_edge(pll_clk) then
Audio_in_reg <= Audio_data_in; -- direct CDC crossing!
end if;
end process;
Why Vivado Flagged It
Even though both clocks come from the same MMCM, Vivado performs timing analysis between all pairs of clock domains unless explicitly told not to. It looks for the worst-case pair of clock edges:
clk200MHz edge at: 80.000 ns
clkI2SMHz edge at: 81.667 ns
Gap between edges: 1.667 ns ← this is the timing requirement
Data path delay: 1.896 ns ← longer than requirement!
Slack: -0.891 ns ← VIOLATION
The tool asks: “if data launches on the 200 MHz edge at 80 ns, can it arrive at the 12.24 MHz register before its edge at 81.667 ns?” The answer is no — routing takes 1.896 ns but only 1.667 ns is available.
In practice this crossing is safe — the FM demodulator outputs new audio data at 50 kHz, so data is stable for 20,000 ns between updates. But Vivado doesn’t know this without being told.
Fix Part 1 — Tell Vivado the Path is Safe
Add a set_false_path constraint in your XDC file:
FIXED — XDC constraint — suppress false timing violation
# False path between 200MHz and 12.24MHz clock domains
set_false_path -from [get_clocks clk_out2*] -to [get_clocks clk_out5*]
set_false_path -from [get_clocks clk_out5*] -to [get_clocks clk_out2*]
The wildcard * covers multiple clock name variants Vivado generates internally such as clk_out5_clk_wiz_0 and clk_out5_clk_wiz_0_1.
Fix Part 2 — Make the RTL Actually Safe
The set_false_path constraint removes the timing violation but does not address metastability. For a robust design, add a two-flop synchronizer:
FIXED — Two-flop synchronizer in RTL
-- Add to architecture signals
signal Audio_sync1 : std_logic_vector(31 downto 0) := (others => '0');
signal Audio_sync2 : std_logic_vector(31 downto 0) := (others => '0');
process(pll_clk, reset)
begin
if reset = '1' then
Audio_sync1 <= (others => '0');
Audio_sync2 <= (others => '0');
Audio_in_reg <= (others => '0');
elsif rising_edge(pll_clk) then
Audio_sync1 <= Audio_data_in; -- first flop: may go metastable
Audio_sync2 <= Audio_sync1; -- second flop: metastability resolved
-- latch only on LRCK rising edge
if lrck_r1 = '1' and lrck_r2 = '0' then
Audio_in_reg <= Audio_sync2; -- safe to use
end if;
end if;
end process;
The two-flop synchronizer works in three steps:
- Audio_sync1 captures
Audio_data_in— this flop may go metastable if data changed near the clock edge. - One full
clkI2SMHzcycle passes (81.6 ns) — metastability resolves with very high probability. - Audio_sync2 captures the now-stable value — safe, clean data delivered to the I2S shift register.
Also add a targeted false path for the synchronizer’s first flop input:
set_false_path -from [get_clocks clk_out2*] -to [get_pins *Audio_sync1_reg[*]/D]
Do You Always Need Both?
In this specific design — no. Since the audio data updates at only 50 kHz it is stable for 20,000 ns between changes. The set_false_path constraint alone is sufficient. However, for control signals, fast-changing data, or safety-critical designs, always use both the two-flop synchronizer in RTL and the false path constraint in XDC.
Key lesson: Vivado checks timing between all clock domains using worst-case edge pairs. Even when your data is known to be stable, you must explicitly tell the tool which paths are safe using
set_false_pathorset_clock_groups. For true metastability protection, combine this with a two-flop synchronizer in RTL.
Comparison of the Two Cases
| Case Study 1 | Case Study 2 | |
|---|---|---|
| Problem | Pulse missed by slow clock | Timing violation in Vivado |
| Source clock | clk200MHz (fast) | clk200MHz (fast) |
| Destination clock | clk10MHz (slow) | clkI2SMHz (slow) |
| Symptom | DAC output always zero | Implementation timing failure |
| Root cause | Pulse too narrow for slow clock | Nearest clock edges only 1.667 ns apart |
| Fix | Move pulse to destination domain | set_false_path + two-flop sync |
| Visible in simulation? | Sometimes | No |
General CDC Guidelines
Based on these two case studies, here are five practical rules to apply in your own designs:
|
01 Never send a single-cycle pulse across clock domains. A pulse that is one cycle wide in the fast domain may be completely invisible in the slow domain. Use a toggle signal and detect the edge in the destination domain instead, or generate the pulse directly in the destination domain. |
|
02 Always add set_false_path or set_clock_groups for known-safe crossings. Even when your RTL handles CDC correctly, Vivado will still flag timing violations unless you tell it the path is intentionally unconstrained. |
|
03 Use a two-flop synchronizer for control signals and slowly-changing data. Two flops give the metastable output of the first flop one full clock cycle to resolve before being captured by the second. This reduces the probability of failure to negligible levels. |
|
04 For wide data buses, consider a handshake or FIFO. A two-flop synchronizer works well for single bits or slowly-changing data. For wide buses that update frequently, use a proper handshake protocol or a dual-clock FIFO. |
|
05 Check your CDC paths early — run report_cdc after synthesis. Vivado’s report_cdc command flags potential CDC crossings before you reach implementation, saving significant debug time later.
|
Summary
CDC bugs are among the hardest to find in FPGA design because they are timing-dependent, often intermittent, and frequently invisible in simulation. The two case studies in this post show two very different manifestations of the same underlying problem — data crossing between clock domains without proper synchronization.
→ Keep signals in the same clock domain wherever possible
→ Use toggle-based event transfer for cross-domain triggers
→ Use two-flop synchronizers for data crossing domains
→ Always add XDC constraints to match your RTL intent
→ Run report_cdc early — don’t wait until implementation
Understanding CDC is an essential step in moving from functional VHDL designs to robust, production-quality FPGA implementations.
This post is based on real debugging experience from developing a digital FM receiver on a Xilinx 7-series FPGA using Vivado 2020.2 and VHDL.