HomeCDC GuideDay 10
DAY 10 · ADVANCED CDC PATTERNS

Multi-Clock Domain Hierarchies

By EcrioniX · Updated Jun 23, 2026

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.

Typical Mobile SoC — Clock Domain Topology CPU Cluster 1–3 GHz GPU 600–1000 MHz DDR PHY 400–800 MHz PCIe 250 MHz System Fabric / NoC (200–400 MHz) all domains cross here via CDC bridges USB 60 / 480 MHz Always-On 32 kHz RTC
Figure — Six of the typical 8–12 clock domains in a mobile SoC. All domains converge at a shared fabric where CDC bridges handle each crossing.

1. Real SoC Clock Topology

A modern mobile SoC does not have two clock domains — it has many. Here is a representative inventory for a 2024-era application processor:

DomainTypical FrequencySourceNotes
CPU big cores1.5–3.2 GHzPLL0DVFS — frequency changes at runtime
CPU LITTLE cores800 MHz–1.8 GHzPLL1Separate PLL for independent scaling
GPU600–1000 MHzPLL2Can gate entirely during idle
NPU / AI engine500–900 MHzPLL3Power-gated between ML inferences
DDR PHY400–800 MHzPLL4LPDDR5 uses strobe clocks per byte lane
System fabric / NoC200–400 MHzPLL5 ÷ NCentral hub — all domains cross here
PCIe100 or 250 MHzSpread-spectrum PLLEMI mitigation via SSC
USB 3.x125 or 250 MHzUSB PLLSeparate for HS/SS modes
Display148–594 MHzDisplay PLLPixel clock — panel dependent
Camera ISP400–800 MHzISP PLLChanges with frame rate / resolution
Audio12.288 / 24.576 MHzAudio PLLMust be jitter-clean; I2S alignment critical
Always-on (AON)32.768 kHzCrystalKeeps 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.

2. The Clock Domain Graph

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.

Rule of thumb

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.

3. Domain Partitioning Principles

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.

4. Crossing Type Inventory

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 TypeWhen to UseLatencyArea CostKey Risk
Single-bit 2-FF syncSingle control signals: enable, reset, interrupt2–3 cycles dstLowOnly safe for 1 bit; never for multi-bit data
Pulse synchronizerShort pulses that must not be lost3–5 cyclesLowSource pulse must be wider than dest clock period
Gray-code counter syncFIFO read/write pointer crossing2 cyclesLowOnly valid for counters; not arbitrary data
Async FIFOHigh-bandwidth data streams between domains4–6 cyclesMediumPointer crossing must use gray code
Handshake (req/ack)Infrequent multi-bit control words4–6 cycles each dirLowThroughput: one transfer per 4–8 cycles
MCP (multi-cycle path)Quasi-static config registersCombinationalNoneData must be stable for multiple dest cycles; SDC constraint is mandatory
CDC bridge / wrapperIP integration at AXI boundary6–12 cyclesHighProtocol 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.

5. CDC Budget — Complexity vs. Number of Crossings

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:

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:

Example CDC budget rules (Tier 1 SoC project):

6. Hierarchical Synchronization — Bridge Domains

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.

7. SDC Multi-Clock Constraints

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.

constraints.sdc — multi-clock declarations
## 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

SDC Pitfall: Incomplete Clock Definitions

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.

8. CDC Lint vs. CDC Formal Verification

Two complementary analysis methods are required for CDC sign-off at commercial tapeout. They find different classes of bugs and neither replaces the other.

MethodWhat It ChecksSpeedCoverageTool Examples
CDC Lint (static)Structural: missing synchronizers, reconvergence, fan-out from async signals, combo logic between domainsMinutesStructural bugs onlySynopsys SpyGlass CDC, Mentor Questa CDC
CDC FormalFunctional: proves synchronizer correctness, checks data stability for MCP paths, verifies FIFO pointer mathHours–daysMathematically complete for bounded proof depthCadence 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.

9. CDC at IP Integration Boundaries

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.

10. Verilog: 3-Domain System with FIFO and Handshake Crossings

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 — top with 3 async clock domains
// =============================================================
// 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
async_fifo.v — gray-code pointer async FIFO (parameterized)
// 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
tb_three_domain.sv — SystemVerilog testbench (3 async clocks)
// 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

11. CDC Sign-Off Checklist

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:

Day 10 takeaways

FAQ

How many clock domains does a real SoC have?

A 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.

What is a CDC budget and why does it matter?

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.

What is the difference between CDC lint and CDC formal verification?

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.

Previous
← Day 9: Clock Gating

← Full course roadmap