A two-domain SoC is a textbook exercise. Real chips have 6 to 20 independent clock domains, each crossing every other in ways that compound exponentially. Managing that complexity requires more than synchronizers — it requires a disciplined architectural approach: domain partitioning, crossing inventories, CDC budgets, and hierarchical synchronization strategies. This lesson covers all of it.
A modern mobile SoC does not have two clock domains — it has many. Here is a representative inventory for a 2024-era application processor:
| Domain | Typical Frequency | Source | Notes |
|---|---|---|---|
| CPU big cores | 1.5–3.2 GHz | PLL0 | DVFS — frequency changes at runtime |
| CPU LITTLE cores | 800 MHz–1.8 GHz | PLL1 | Separate PLL for independent scaling |
| GPU | 600–1000 MHz | PLL2 | Can gate entirely during idle |
| NPU / AI engine | 500–900 MHz | PLL3 | Power-gated between ML inferences |
| DDR PHY | 400–800 MHz | PLL4 | LPDDR5 uses strobe clocks per byte lane |
| System fabric / NoC | 200–400 MHz | PLL5 ÷ N | Central hub — all domains cross here |
| PCIe | 100 or 250 MHz | Spread-spectrum PLL | EMI mitigation via SSC |
| USB 3.x | 125 or 250 MHz | USB PLL | Separate for HS/SS modes |
| Display | 148–594 MHz | Display PLL | Pixel clock — panel dependent |
| Camera ISP | 400–800 MHz | ISP PLL | Changes with frame rate / resolution |
| Audio | 12.288 / 24.576 MHz | Audio PLL | Must be jitter-clean; I2S alignment critical |
| Always-on (AON) | 32.768 kHz | Crystal | Keeps RTC and PMU alive during sleep |
With 12 domains there are 66 unique domain pairs (12 choose 2). Each pair that communicates needs a CDC strategy. That is not trivial — it is an architectural problem that must be solved before a single line of RTL is written.
The most useful mental model for multi-clock design is a directed graph where:
Drawing this graph at the start of a project forces explicit decisions about every crossing. A crossing without an edge in the graph has no synchronizer — that omission is immediately visible. Teams at companies like Apple, Qualcomm, and MediaTek maintain this graph in their design specification documents and use it as the CDC verification checklist.
If your clock domain graph has edges you cannot explain the synchronization strategy for, you have a CDC violation waiting to manifest in silicon. Every edge must have a labeled strategy before RTL is written.
Partitioning is the art of deciding which logic lives in which clock domain. Poor partitioning multiplies CDC complexity. Good partitioning minimizes edges in the domain graph.
Principle 1 — Group by clock source. All logic driven by PLL2 lives in the GPU domain. Do not split a functional block across two clocks. If a block genuinely needs data from two domains, designate one as its primary domain and bring external data in through a synchronizer at the block boundary.
Principle 2 — Minimize crossing fan-out. A single signal crossing from domain A to domain B that fans out to 50 register inputs inside domain B is fine — the synchronizer output drives a signal already in domain B. A signal that fans out to 50 registers in 5 different domains is a structural nightmare. Keep crossing fan-out to 1 synchronizer per domain crossing.
Principle 3 — Avoid CDC islands. A small block sitting between two large domains — receiving data from both and sending to both — creates O(N) crossing complexity internally. Where possible, assign island blocks to the higher-frequency neighboring domain and synchronize at a single boundary.
Principle 4 — Use a fabric domain as a hub. Most SoCs route inter-domain traffic through a shared interconnect (AXI fabric, ring bus, or mesh NoC) running at its own frequency. Rather than every domain crossing every other domain directly (N² crossings), all domains cross into the fabric (N crossings from each domain, total 2N). CDC bridges at the fabric boundary become the standard synchronization point and the standard verification unit.
Not all crossings are equal. Choosing the wrong technique wastes area, introduces latency, or — worst — misses bugs. Here is the complete inventory of CDC techniques and when to use each:
| Crossing Type | When to Use | Latency | Area Cost | Key Risk |
|---|---|---|---|---|
| Single-bit 2-FF sync | Single control signals: enable, reset, interrupt | 2–3 cycles dst | Low | Only safe for 1 bit; never for multi-bit data |
| Pulse synchronizer | Short pulses that must not be lost | 3–5 cycles | Low | Source pulse must be wider than dest clock period |
| Gray-code counter sync | FIFO read/write pointer crossing | 2 cycles | Low | Only valid for counters; not arbitrary data |
| Async FIFO | High-bandwidth data streams between domains | 4–6 cycles | Medium | Pointer crossing must use gray code |
| Handshake (req/ack) | Infrequent multi-bit control words | 4–6 cycles each dir | Low | Throughput: one transfer per 4–8 cycles |
| MCP (multi-cycle path) | Quasi-static config registers | Combinational | None | Data must be stable for multiple dest cycles; SDC constraint is mandatory |
| CDC bridge / wrapper | IP integration at AXI boundary | 6–12 cycles | High | Protocol translation complexity; full handshake must be verified |
Build a crossing inventory table for your design — one row per edge in the domain graph. Include source domain, destination domain, signal names, crossing type, synchronizer module name, and verification method. This document becomes the CDC sign-off checklist at tapeout.
CDC complexity grows with the number of domains and crossing edges. For N domains with full mesh connectivity there are N×(N−1) directed crossings to manage. With 12 domains that is 132 directed edges. Each edge needs:
false_path or set_max_delay -datapath_only)Teams that skip the budget step end up with hundreds of undocumented crossing points discovered late in the project — after RTL freeze, when changes are expensive. The standard approach is to set CDC budgets in the microarchitecture specification:
When two large domains communicate frequently and at high bandwidth, a point-to-point async FIFO is often insufficient. Teams use dedicated CDC bridge blocks — small RTL modules whose only job is synchronizing between two specific domains. The bridge runs at the higher of the two frequencies (or at a third bridge clock) and contains:
The AXI Interconnect in ARM-based SoCs is the canonical example. Each AXI slave port has a built-in CDC bridge. The CPU domain writes to one side; the fabric domain reads from the other. The crossing is contained, documented, and verifiable in isolation.
Hierarchical verification follows the same structure: verify each bridge block independently, then verify the system with the bridges as black boxes with known-correct models. This divide-and-conquer approach makes CDC formal tractable on large SoCs that would otherwise have thousands of reachable states.
Every pair of asynchronous clocks must be declared in SDC (Synopsys Design Constraints). If you miss a pair, the tool will attempt to time across the crossing and either report false violations or — worse — silently apply incorrect optimization that corrupts the synchronizer.
## Define all clocks explicitly create_clock -period 0.667 -name clk_cpu [get_ports CLK_CPU] ; ## 1.5 GHz create_clock -period 1.000 -name clk_gpu [get_ports CLK_GPU] ; ## 1.0 GHz create_clock -period 2.500 -name clk_noc [get_ports CLK_NOC] ; ## 400 MHz create_clock -period 2.500 -name clk_ddr [get_ports CLK_DDR] ; ## 400 MHz create_clock -period 4.000 -name clk_pcie [get_ports CLK_PCIE] ; ## 250 MHz create_clock -period 81.38 -name clk_audio [get_ports CLK_AUDIO] ; ## 12.288 MHz create_clock -period 30517 -name clk_aon [get_ports CLK_AON] ; ## 32.768 kHz ## Declare ALL unrelated clock pairs asynchronous. ## This prevents timing analysis across CDC crossings entirely. ## Missing a clock here = false timing errors or silent misoptimization. set_clock_groups -asynchronous \ -group [get_clocks clk_cpu] \ -group [get_clocks clk_gpu] \ -group [get_clocks clk_noc] \ -group [get_clocks clk_ddr] \ -group [get_clocks clk_pcie] \ -group [get_clocks clk_audio] \ -group [get_clocks clk_aon] ## For synchronizer flip-flop inputs: constrain the capture FF ## set_max_delay -datapath_only: optimize combinational path but ## do NOT treat it as a cross-domain setup check. ## This preserves hold-time checking while relaxing setup. set_max_delay -datapath_only \ -from [get_clocks clk_cpu] \ -to [get_pins u_cpu_to_noc_sync/ff1_reg/D] \ 1.200 ## WRONG: never use set_false_path for synchronizer DATA inputs. ## false_path removes ALL timing checks including hold, which can ## cause hold violations on the synchronizer input in PVT corners. ## set_false_path -from clk_cpu -to [get_pins u_sync/ff1_reg/D] <-- BAD ## Report clocks to verify completeness -- run this after sourcing SDC ## report_clocks -nosplit
If a generated clock (e.g., a clock divider output) is not declared with create_generated_clock, it will not appear in set_clock_groups. The tool infers an incorrect relationship and may miss CDC violations or report false ones. Always run report_clocks and compare inferred vs. explicitly defined clocks before timing sign-off. Missing one generated clock is one of the most common CDC constraint bugs in production designs.
Two complementary analysis methods are required for CDC sign-off at commercial tapeout. They find different classes of bugs and neither replaces the other.
| Method | What It Checks | Speed | Coverage | Tool Examples |
|---|---|---|---|---|
| CDC Lint (static) | Structural: missing synchronizers, reconvergence, fan-out from async signals, combo logic between domains | Minutes | Structural bugs only | Synopsys SpyGlass CDC, Mentor Questa CDC |
| CDC Formal | Functional: proves synchronizer correctness, checks data stability for MCP paths, verifies FIFO pointer math | Hours–days | Mathematically complete for bounded proof depth | Cadence JasperGold CDC, Synopsys VC Formal CDC |
The industry flow: run CDC lint early (at block level, before integration) to catch structural violations fast. Run CDC formal at chip level after integration to prove synchronizers are correct and data integrity is maintained end-to-end.
One class of bugs that CDC lint cannot catch but formal can: reconvergence. This is when two bits of a multi-bit bus take different CDC paths — one through a synchronizer, one direct — and reconverge in combinational logic in the destination domain. Each bit individually has a synchronizer so the structural check passes. The formal check fails because the two bits are seen at different "ages": one settled, one still metastable, creating a corrupted combined value. Finding reconvergence is one of the highest-value results of CDC formal closure.
Third-party IP is the biggest CDC risk in a modern SoC. Every IP block — memory controller, USB PHY, PCIe controller, image signal processor — has its own clock interface, and the integration team must document and verify every boundary crossing.
A disciplined IP integration CDC process requires:
When IP documentation is missing or incomplete — common with older or lower-cost IP — the integration team must reverse-engineer the CDC interface by reading the RTL and running CDC lint on the IP independently before wiring it into the SoC.
The following example implements a system with three asynchronous clock domains: clk_fast (CPU at 200 MHz), clk_slow (peripheral bus at 50 MHz), and clk_audio (audio engine at 12.288 MHz). Data flows CPU→peripheral via async FIFO, and volume control flows CPU→audio via a handshake for a quasi-static register that changes infrequently.
// =============================================================
// three_domain_system.v
// clk_fast = 200 MHz (CPU / data producer)
// clk_slow = 50 MHz (peripheral / data consumer)
// clk_audio = 12.288 MHz (audio engine)
//
// Crossing 1: clk_fast -> clk_slow via async FIFO (data stream)
// Crossing 2: clk_fast -> clk_audio via req/ack handshake
// (quasi-static volume control register)
// =============================================================
module three_domain_system (
input wire clk_fast,
input wire clk_slow,
input wire clk_audio,
input wire rst_n,
// CPU side (clk_fast)
input wire [7:0] cpu_data_in,
input wire cpu_wr_en,
input wire [7:0] cpu_vol_ctrl,
input wire cpu_vol_wr,
// Peripheral output (clk_slow)
output wire [7:0] periph_data_out,
output wire periph_data_valid,
// Audio output (clk_audio)
output wire [7:0] audio_vol_sync,
output wire fifo_full,
output wire fifo_empty
);
// -------------------------------------------------------
// CROSSING 1: clk_fast -> clk_slow via 8-entry async FIFO
// -------------------------------------------------------
wire [7:0] fifo_rd_data;
wire fifo_rd_valid;
async_fifo #(
.DATA_W (8),
.DEPTH (8)
) u_fast_to_slow_fifo (
.wr_clk (clk_fast),
.rd_clk (clk_slow),
.rst_n (rst_n),
.wr_data (cpu_data_in),
.wr_en (cpu_wr_en),
.rd_en (1'b1), // peripheral always consumes
.rd_data (fifo_rd_data),
.rd_valid (fifo_rd_valid),
.full (fifo_full),
.empty (fifo_empty)
);
assign periph_data_out = fifo_rd_data;
assign periph_data_valid = fifo_rd_valid;
// -------------------------------------------------------
// CROSSING 2: clk_fast -> clk_audio via req/ack handshake
// Volume is 8 bits wide -- too wide for single-bit sync,
// too infrequent for a FIFO. Handshake is correct here.
// Source must hold data stable from req assertion until ack.
// -------------------------------------------------------
reg [7:0] fast_vol_latch;
reg fast_req;
wire fast_ack;
// Source side (clk_fast) -- assert req once, wait for ack
always @(posedge clk_fast or negedge rst_n) begin
if (!rst_n) begin
fast_vol_latch <= 8'h40;
fast_req <= 1'b0;
end else if (cpu_vol_wr && !fast_req) begin
fast_vol_latch <= cpu_vol_ctrl; // latch before asserting req
fast_req <= 1'b1;
end else if (fast_ack) begin
fast_req <= 1'b0;
end
end
// Synchronize fast_req into clk_audio domain (2-FF)
reg [1:0] req_sync_audio;
always @(posedge clk_audio or negedge rst_n) begin
if (!rst_n) req_sync_audio <= 2'b00;
else req_sync_audio <= {req_sync_audio[0], fast_req};
end
// Destination side (clk_audio) -- capture on req rising edge
reg [7:0] audio_vol_reg;
reg audio_ack_r;
always @(posedge clk_audio or negedge rst_n) begin
if (!rst_n) begin
audio_vol_reg <= 8'h40;
audio_ack_r <= 1'b0;
end else begin
audio_ack_r <= req_sync_audio[1];
// Rising edge of synchronized req: latch data
// fast_vol_latch has been stable since fast_req was asserted
if (req_sync_audio[1] && !audio_ack_r)
audio_vol_reg <= fast_vol_latch;
end
end
assign audio_vol_sync = audio_vol_reg;
// Ack path: clk_audio -> clk_fast (also needs 2-FF sync)
reg [1:0] ack_sync_fast;
always @(posedge clk_fast or negedge rst_n) begin
if (!rst_n) ack_sync_fast <= 2'b00;
else ack_sync_fast <= {ack_sync_fast[0], audio_ack_r};
end
assign fast_ack = ack_sync_fast[1];
endmodule
// Parameterized async FIFO with gray-code pointer synchronization.
// DEPTH must be a power of 2.
// PTR_W has one extra bit so full/empty can be distinguished.
module async_fifo #(
parameter DATA_W = 8,
parameter DEPTH = 8,
parameter PTR_W = $clog2(DEPTH) + 1
) (
input wire wr_clk, rd_clk, rst_n,
input wire [DATA_W-1:0] wr_data,
input wire wr_en,
input wire rd_en,
output reg [DATA_W-1:0] rd_data,
output wire rd_valid,
output wire full,
output wire empty
);
// Dual-port storage
reg [DATA_W-1:0] mem [0:DEPTH-1];
// Binary pointers (local to each domain)
reg [PTR_W-1:0] wr_ptr_bin;
reg [PTR_W-1:0] rd_ptr_bin;
// Gray code conversions
wire [PTR_W-1:0] wr_ptr_gray = wr_ptr_bin ^ (wr_ptr_bin >> 1);
wire [PTR_W-1:0] rd_ptr_gray = rd_ptr_bin ^ (rd_ptr_bin >> 1);
// 2-FF synchronizers: rd_ptr_gray into wr_clk; wr_ptr_gray into rd_clk
reg [PTR_W-1:0] rd_gray_s1, rd_gray_s2; // in wr_clk domain
reg [PTR_W-1:0] wr_gray_s1, wr_gray_s2; // in rd_clk domain
// ---- Write domain (wr_clk) ----
always @(posedge wr_clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr_bin <= '0;
rd_gray_s1 <= '0;
rd_gray_s2 <= '0;
end else begin
rd_gray_s1 <= rd_ptr_gray; // sync rd pointer in
rd_gray_s2 <= rd_gray_s1;
if (wr_en && !full)
wr_ptr_bin <= wr_ptr_bin + 1'b1;
end
end
always @(posedge wr_clk)
if (wr_en && !full) mem[wr_ptr_bin[PTR_W-2:0]] <= wr_data;
// Full: MSB differs, lower bits equal (gray code property)
assign full = (wr_ptr_gray ==
{~rd_gray_s2[PTR_W-1:PTR_W-2], rd_gray_s2[PTR_W-3:0]});
// ---- Read domain (rd_clk) ----
always @(posedge rd_clk or negedge rst_n) begin
if (!rst_n) begin
rd_ptr_bin <= '0;
wr_gray_s1 <= '0;
wr_gray_s2 <= '0;
end else begin
wr_gray_s1 <= wr_ptr_gray; // sync wr pointer in
wr_gray_s2 <= wr_gray_s1;
if (rd_en && !empty)
rd_ptr_bin <= rd_ptr_bin + 1'b1;
end
end
always @(posedge rd_clk)
if (rd_en && !empty) rd_data <= mem[rd_ptr_bin[PTR_W-2:0]];
assign empty = (rd_ptr_gray == wr_gray_s2);
assign rd_valid = !empty;
endmodule
// SystemVerilog testbench for three_domain_system.
// Three truly asynchronous clocks with intentional phase offsets.
// Exercises: FIFO streaming, handshake volume update, FIFO full flag.
`timescale 1ns/1ps
module tb_three_domain;
localparam FAST_P = 5.0; // 200 MHz
localparam SLOW_P = 20.0; // 50 MHz
localparam AUDIO_P = 81.38; // 12.288 MHz
reg clk_fast, clk_slow, clk_audio, rst_n;
reg [7:0] cpu_data_in, cpu_vol_ctrl;
reg cpu_wr_en, cpu_vol_wr;
wire [7:0] periph_data_out, audio_vol_sync;
wire periph_data_valid, fifo_full, fifo_empty;
// Independent clocks -- intentional phase offsets to stress CDC
initial clk_fast = 0; always #(FAST_P/2) clk_fast = ~clk_fast;
initial #1.3 clk_slow = 0; always #(SLOW_P/2) clk_slow = ~clk_slow;
initial #7.7 clk_audio = 0; always #(AUDIO_P/2) clk_audio = ~clk_audio;
three_domain_system dut (.*);
// Reset sequence
initial begin
rst_n = 0; cpu_wr_en = 0; cpu_vol_wr = 0;
cpu_data_in = 8'h00; cpu_vol_ctrl = 8'h40;
repeat(5) @(posedge clk_fast);
rst_n = 1;
end
// Task: write one byte into the fast->slow FIFO
task automatic write_byte(input [7:0] d);
@(posedge clk_fast);
cpu_data_in <= d; cpu_wr_en <= 1;
@(posedge clk_fast);
cpu_wr_en <= 0;
endtask
// Task: send volume update via handshake and wait for ack
task automatic update_volume(input [7:0] v);
@(posedge clk_fast);
cpu_vol_ctrl <= v; cpu_vol_wr <= 1;
@(posedge clk_fast);
cpu_vol_wr <= 0;
repeat(30) @(posedge clk_fast); // allow ack round-trip
endtask
initial begin
wait(rst_n);
repeat(3) @(posedge clk_fast);
// Test 1: stream 4 bytes CPU -> peripheral
write_byte(8'hAA); write_byte(8'hBB);
write_byte(8'hCC); write_byte(8'hDD);
$display("T=%0t: Sent 4 bytes to async FIFO", $time);
repeat(50) @(posedge clk_slow); // wait for drain
// Test 2: update audio volume control
update_volume(8'h60);
$display("T=%0t: Volume updated -> audio_vol_sync=0x%02x",
$time, audio_vol_sync);
// Test 3: fill FIFO to capacity (8 entries)
repeat(8) write_byte($urandom_range(0, 255));
@(posedge clk_fast);
if (fifo_full)
$display("T=%0t: FIFO full flag correct", $time);
else
$display("T=%0t: WARN -- FIFO not full after 8 writes", $time);
repeat(200) @(posedge clk_fast);
$display("T=%0t: All tests complete", $time);
$finish;
end
// Monitor: log all data received in clk_slow domain
always @(posedge clk_slow)
if (periph_data_valid)
$display("T=%0t [clk_slow] periph_data_out = 0x%02x",
$time, periph_data_out);
endmodule
Before tapeout, every crossing in the inventory must be checked against all of the following. This list mirrors the closure criteria used at Tier-1 semiconductor companies:
set_clock_groups -asynchronous; synchronizer inputs have set_max_delay -datapath_onlyset_clock_groups -asynchronous — every unrelated clock pair must be declared; missing declarations cause incorrect optimization or silent timing errorsA typical mobile SoC has 6 to 12 distinct clock domains including CPU, GPU, DDR PHY, PCIe, USB, display, camera ISP, audio, and always-on. High-end AI accelerator SoCs can exceed 20 independent clock domains, creating hundreds of potential crossing pairs that must each be documented and verified.
A CDC budget is a design-phase limit on the number and types of clock domain crossings in a block or subsystem. Because verification complexity grows roughly as O(N²) with domain count, unconstrained crossings make formal CDC closure impractical. Budgets are set in the microarchitecture spec and enforced at design review before RTL freeze, when changes are inexpensive.
CDC lint (static analysis) checks the structural netlist for crossing violations — missing synchronizers, reconvergence, async fan-out — and runs in minutes. CDC formal uses model checking to mathematically prove synchronizer correctness and data integrity. Both are required: lint finds structural problems quickly, formal closes proof gaps that structural analysis cannot reason about, particularly reconvergence.