Crossing a single bit across clock domains is hard enough. Now imagine two masters in different clock domains both trying to own a shared bus — who gets it? How does the bus transfer control? And how do you guarantee that when domain B receives a "bus granted" signal, the data from domain A is already fully stable? This is the arbiter-based CDC problem, and it is one of the hardest topics in SoC design.
Modern SoCs routinely have multiple bus masters: a CPU in one clock domain, a DMA engine in another, a hardware accelerator in a third. These masters share a memory-mapped bus — AHB, AXI, or a custom fabric. The moment you have two masters in different clock domains requesting the same bus, you face a compounded CDC problem:
None of these can be solved by simply synchronizing every signal individually. Independent synchronizers on different signals introduce different latencies, destroying the timing relationship between them. You need an arbitration mechanism that the entire system agrees on, with formal timing guarantees.
If you route data[31:0] and data_valid through separate 2-FF synchronizers, data_valid might arrive 1–2 cycles before or after the data bits settle. The receiver will either miss valid data or latch corrupt data. You cannot synchronize control and data independently — they must be coordinated through an arbiter protocol.
A bus arbiter receives request signals from multiple masters and asserts a single grant signal to exactly one of them at a time. The two classic schemes are:
| Scheme | How it works | Latency | Starvation risk |
|---|---|---|---|
| Fixed priority | Master 0 always wins over Master 1 if both request simultaneously | 0 cycles (combinational) | High — low-priority master may never get bus |
| Round-robin | After granting to Master N, next grant goes to Master N+1 (wraps around) | 1 cycle (register-based) | None — fair under sustained load |
| Weighted round-robin | Each master gets a burst of N grants before rotating | 1 cycle | Low — configurable minimum bandwidth |
The key insight: the arbiter's grant signal is a single bit. A single-bit signal crossing clock domains can be safely handled with a 2-FF synchronizer (Days 2–3). This is why arbitration is a powerful CDC strategy — it converts a wide multi-bit bus ownership problem into a narrow 1-bit control crossing.
A standard round-robin arbiter is a registered circuit — it samples REQ signals on its own clock. But when the arbiter itself operates asynchronously (no dedicated arbiter clock), or when two REQs arrive within picoseconds of each other, even the arbiter can enter a metastable state.
The solution is the MUTEX cell — a mutual exclusion element that guarantees metastability-free arbitration even when both requests arrive simultaneously. The MUTEX is built from two cross-coupled NAND gates (an SR latch variant) with output enables that guarantee exactly one output can be asserted at a time.
A MUTEX cell guarantees: (1) at most one output is ever HIGH; (2) if one request is pending when the other arrives, the earlier one wins immediately; (3) if both arrive simultaneously, the cell resolves to exactly one — waiting as long as necessary. Resolution time is unbounded but probabilistically short (nanoseconds in practice).
A standard SR latch from your cell library cannot serve as a MUTEX because the library cell is characterized and optimized for normal operation — its cross-coupled transistors may be perfectly balanced, creating a symmetric metastable state that lingers. A proper MUTEX requires:
In most SoC designs, the MUTEX cell is provided by the standard cell library vendor with a special characterization file. You instantiate it directly — you do not implement it from gates.
Conceptually, a MUTEX cell looks like this in Verilog — though in practice you always use the library-provided cell, not this RTL:
// CONCEPTUAL ONLY -- do NOT synthesize this. // Real MUTEX cells come from IP library with custom layout. // This illustrates the principle only. module mutex_cell ( input wire req_a, // Request from domain A input wire req_b, // Request from domain B output wire gnt_a, // Grant to domain A (never simultaneous with gnt_b) output wire gnt_b // Grant to domain B (never simultaneous with gnt_a) ); // Cross-coupled NAND gates -- SR latch structure. // In real implementation: custom transistor sizing ensures asymmetric // resolution so one side always wins before the other. wire q_a, q_b; // NAND-based SR latch (conceptual) assign q_a = ~(req_a & q_b); assign q_b = ~(req_b & q_a); // Output enables: only assert grant when latch is fully settled. // In real cell, this is a metastability filter (resolution detection) circuit. assign gnt_a = q_a & ~q_b; // settled: A won assign gnt_b = q_b & ~q_a; // settled: B won // KEY PROPERTY: gnt_a & gnt_b == 0 always (mutual exclusion) // KEY PROPERTY: gnt_a | gnt_b == 1 eventually (liveness) endmodule
The complete arbiter-based CDC protocol follows this sequence. Note that control must be confirmed stable before data is driven:
REQ_A to the arbiter; master places data stable on the bus simultaneouslyGNT_A (single bit); GNT_A is synchronized into Domain A via 2-FF syncREQ_A; arbiter withdraws grant; next master gets busFor systems with exactly two clock domains sharing one resource, a token-passing scheme is simpler and often more efficient than a full arbiter. Only the token holder is allowed to initiate a transfer. The token is a single-bit register that crosses from one domain to the other using a proper toggle synchronizer handshake.
The protocol works as follows:
token_owner = A)// Token-passing CDC for 2-domain shared resource.
// Domain A holds the token at reset; passes it when done.
module token_cdc (
input wire clk_a, rst_a,
input wire clk_b, rst_b,
// Domain A
input wire req_a, // A wants to use the resource
output reg gnt_a, // A currently holds the token
input wire done_a, // A signals transfer complete
// Domain B
input wire req_b,
output reg gnt_b,
input wire done_b
);
// Toggle registers: toggled to signal ownership transfer
reg toggle_ab; // A toggles this to pass token to B
reg toggle_ba; // B toggles this to pass token back to A
// 2-FF synchronizers
reg [1:0] sync_ab_b; // toggle_ab synced into clk_b
reg [1:0] sync_ba_a; // toggle_ba synced into clk_a
always @(posedge clk_b or posedge rst_b) begin
if (rst_b) sync_ab_b <= 2'b00;
else sync_ab_b <= {sync_ab_b[0], toggle_ab};
end
always @(posedge clk_a or posedge rst_a) begin
if (rst_a) sync_ba_a <= 2'b00;
else sync_ba_a <= {sync_ba_a[0], toggle_ba};
end
// Edge detectors: level change on toggle = ownership transferred
reg last_ab_b, last_ba_a;
wire got_token_b = sync_ab_b[1] ^ last_ab_b;
wire got_token_a = sync_ba_a[1] ^ last_ba_a;
// Domain A: holds token at reset
always @(posedge clk_a or posedge rst_a) begin
if (rst_a) begin
gnt_a <= 1'b1;
toggle_ab <= 1'b0;
last_ba_a <= 1'b0;
end else begin
last_ba_a <= sync_ba_a[1];
if (got_token_a) gnt_a <= 1'b1; // token returned from B
if (gnt_a && done_a) begin
gnt_a <= 1'b0;
toggle_ab <= ~toggle_ab; // pass token to B
end
end
end
// Domain B: waits for token
always @(posedge clk_b or posedge rst_b) begin
if (rst_b) begin
gnt_b <= 1'b0;
toggle_ba <= 1'b0;
last_ab_b <= 1'b0;
end else begin
last_ab_b <= sync_ab_b[1];
if (got_token_b) gnt_b <= 1'b1; // received token from A
if (gnt_b && done_b) begin
gnt_b <= 1'b0;
toggle_ba <= ~toggle_ba; // pass token back to A
end
end
end
endmodule
The single most common mistake in arbiter-based CDC is violating the control-before-data rule: the control signal (grant, enable, valid) must be seen by the destination domain before it samples data. The timing constraint has three components:
| Phase | Requirement | Mechanism |
|---|---|---|
| Data setup | Data must be stable in source domain BEFORE control is asserted | RTL ordering: drive data first, assert control only after data is stable |
| Control crossing | Control signal synchronized through 2-FF or 3-FF sync | 2-FF synchronizer, adds 2–3 cycle latency in destination domain |
| Data hold | Data must remain stable for 2+ cycles after control de-assertion | RTL protocol: source holds data until a synchronized ACK is received back |
The data itself does not need to be synchronized — it is driven by the source domain and held stable until the destination domain confirms (via a synchronized ACK handshake) that it has captured the data. This is why you synchronize narrow control signals and use the wide data bus freely — as long as data is stable when control is captured.
In CDC, data follows control. Control tells the receiver "data is ready." Control crosses through the synchronizer. Data is stable before control rises, and stays stable until an ACK confirms capture. Never assert control before data is stable on the source side.
When shared memory (dual-port RAM, shared register file) is accessed by two clock domains, a semaphore handshake prevents simultaneous read/write corruption. A semaphore is a single-bit flag indicating resource ownership, transferred with a 4-phase handshake: REQ → ACK → REL → ACK_REL.
The key properties of a CDC semaphore:
// CDC Semaphore: shared memory access from two clock domains.
// Uses 4-phase handshake: REQ -> ACK -> REL -> ACK_REL.
module cdc_semaphore (
input wire clk_a, rst_a,
input wire clk_b, rst_b,
// Domain A interface
input wire req_a, // A requests ownership
output wire ack_a, // A is granted ownership
input wire rel_a, // A releases ownership
// Domain B interface
input wire req_b,
output wire ack_b,
input wire rel_b
);
// Ownership register: 1 = domain A owns, 0 = domain B owns.
// Lives in clk_a domain; B's view is synchronized.
reg own_a;
// 2-FF synchronizers
reg [1:0] own_a_sync; // own_a synced into clk_a (reading in own domain, direct)
reg [1:0] own_b_sync; // ~own_a synced into clk_b
always @(posedge clk_a or posedge rst_a)
if (rst_a) own_a_sync <= 2'b11; // A owns at reset
else own_a_sync <= {own_a_sync[0], own_a};
always @(posedge clk_b or posedge rst_b)
if (rst_b) own_b_sync <= 2'b00; // B does not own at reset
else own_b_sync <= {own_b_sync[0], ~own_a};
assign ack_a = own_a_sync[1]; // A sees it owns resource
assign ack_b = own_b_sync[1]; // B sees it owns resource
// A releases: clear own_a in clk_a domain
always @(posedge clk_a or posedge rst_a) begin
if (rst_a) own_a <= 1'b1;
else if (rel_a) own_a <= 1'b0; // A releases, B gets it
end
// B releases: must use a pulse synchronizer to set own_a in clk_a.
// Shown here as a toggle-based pulse synchronizer:
reg toggle_b_rel;
reg [1:0] sync_rel_a;
reg last_rel_a;
wire rel_b_pulse_a;
always @(posedge clk_b or posedge rst_b)
if (rst_b) toggle_b_rel <= 1'b0;
else if (rel_b) toggle_b_rel <= ~toggle_b_rel;
always @(posedge clk_a or posedge rst_a)
if (rst_a) begin sync_rel_a <= 2'b00; last_rel_a <= 1'b0; end
else begin
sync_rel_a <= {sync_rel_a[0], toggle_b_rel};
last_rel_a <= sync_rel_a[1];
end
assign rel_b_pulse_a = sync_rel_a[1] ^ last_rel_a;
always @(posedge clk_a or posedge rst_a)
if (rst_a) ;
else if (rel_b_pulse_a) own_a <= 1'b1; // B released, A gets ownership back
endmodule
This is the practical implementation you will encounter in real SoC designs — a dual-port RAM where Port A is clocked by clk_a and Port B by clk_b, with a round-robin arbiter preventing simultaneous writes to the same address:
// Dual-port RAM with CDC arbiter for two clock domains.
// Reads are unrestricted (both ports read freely).
// Writes are arbitrated: only one port writes at a time.
// Arbiter runs in clk_a; clk_b write requests are synchronized in.
module dp_ram_cdc_arb #(
parameter DEPTH = 256,
parameter WIDTH = 32,
parameter ABITS = 8
)(
// Port A
input wire clk_a, rst_a,
input wire wr_req_a,
input wire [ABITS-1:0] addr_a,
input wire [WIDTH-1:0] wdata_a,
output reg wr_ack_a,
// Port B
input wire clk_b, rst_b,
input wire wr_req_b,
input wire [ABITS-1:0] addr_b, // held stable until wr_ack_b
input wire [WIDTH-1:0] wdata_b, // held stable until wr_ack_b
output reg wr_ack_b,
// Read ports (combinational, no arbitration needed)
input wire [ABITS-1:0] rd_addr_a,
output wire [WIDTH-1:0] rd_data_a,
input wire [ABITS-1:0] rd_addr_b,
output wire [WIDTH-1:0] rd_data_b
);
// ---- Memory array ----
reg [WIDTH-1:0] mem [0:DEPTH-1];
// ---- Sync wr_req_b into clk_a via toggle synchronizer ----
reg toggle_b;
reg [1:0] sync_tog_a;
reg last_tog_a;
always @(posedge clk_b or posedge rst_b)
if (rst_b) toggle_b <= 1'b0;
else if (wr_req_b && !wr_ack_b) toggle_b <= ~toggle_b;
always @(posedge clk_a or posedge rst_a)
if (rst_a) begin sync_tog_a <= 2'b00; last_tog_a <= 1'b0; end
else begin
sync_tog_a <= {sync_tog_a[0], toggle_b};
last_tog_a <= sync_tog_a[1];
end
wire wr_req_b_in_a = sync_tog_a[1] ^ last_tog_a; // pulse in clk_a
// ---- Round-robin arbiter in clk_a ----
reg last_grant; // 0 = last grant was to A, 1 = to B
reg [1:0] state;
reg wr_en_a, wr_en_b;
reg [ABITS-1:0] arb_addr;
reg [WIDTH-1:0] arb_data;
localparam IDLE = 2'd0,
GRANT_A = 2'd1,
GRANT_B = 2'd2;
always @(posedge clk_a or posedge rst_a) begin
if (rst_a) begin
state <= IDLE; last_grant <= 1'b0;
wr_ack_a <= 1'b0; wr_en_a <= 1'b0; wr_en_b <= 1'b0;
end else begin
wr_ack_a <= 1'b0; wr_en_a <= 1'b0; wr_en_b <= 1'b0;
case (state)
IDLE: begin
if (wr_req_a && wr_req_b_in_a) begin
if (!last_grant) begin
state <= GRANT_A; last_grant <= 1'b0;
end else begin
state <= GRANT_B; last_grant <= 1'b1;
end
end else if (wr_req_a) begin
state <= GRANT_A;
end else if (wr_req_b_in_a) begin
state <= GRANT_B;
end
end
GRANT_A: begin
arb_addr <= addr_a;
arb_data <= wdata_a;
wr_en_a <= 1'b1;
wr_ack_a <= 1'b1;
state <= IDLE;
end
GRANT_B: begin
// B holds addr_b/wdata_b stable until it receives wr_ack_b.
// Reading them here in clk_a is safe: B guarantees stability.
arb_addr <= addr_b;
arb_data <= wdata_b;
wr_en_b <= 1'b1;
state <= IDLE;
// wr_ack_b is issued via toggle sync back to clk_b (below)
end
default: state <= IDLE;
endcase
end
end
// ---- Sync wr_ack back to clk_b ----
reg toggle_ack_a;
reg [1:0] sync_ack_b;
reg last_ack_b;
always @(posedge clk_a or posedge rst_a)
if (rst_a) toggle_ack_a <= 1'b0;
else if (wr_en_b) toggle_ack_a <= ~toggle_ack_a;
always @(posedge clk_b or posedge rst_b)
if (rst_b) begin sync_ack_b <= 2'b00; last_ack_b <= 1'b0; end
else begin
sync_ack_b <= {sync_ack_b[0], toggle_ack_a};
last_ack_b <= sync_ack_b[1];
end
always @(posedge clk_b or posedge rst_b)
if (rst_b) wr_ack_b <= 1'b0;
else wr_ack_b <= sync_ack_b[1] ^ last_ack_b; // one-cycle pulse
// ---- RAM write (single write port, arbitrated) ----
always @(posedge clk_a)
if (wr_en_a || wr_en_b) mem[arb_addr] <= arb_data;
// ---- RAM reads (asynchronous) ----
assign rd_data_a = mem[rd_addr_a];
assign rd_data_b = mem[rd_addr_b];
endmodule
In ARM's AMBA AHB bus, the bus matrix handles multiple masters. When Master 0 is in the CPU clock domain (1.2 GHz) and Master 1 is a DMA controller in a 200 MHz low-power domain, the AHB arbiter must cross grant signals. The standard approach used in ARM Cortex-M and Cortex-A SoCs:
| Signal | Direction | CDC method | Notes |
|---|---|---|---|
HBUSREQ | DMA (clk_b) → Arbiter (clk_a) | 2-FF synchronizer | Single-bit request; safe to synchronize directly |
HGRANT | Arbiter (clk_a) → DMA (clk_b) | 2-FF synchronizer | Single-bit grant; 2–3 cycle latency is acceptable |
HADDR[31:0] | DMA → Shared bus | No synchronizer | DMA holds address stable until HGRANT is seen; data is static during grant window |
HWDATA[31:0] | DMA → Shared bus | No synchronizer | DMA holds write data stable until HREADY is returned from slave |
HRDATA[31:0] | Slave → DMA (clk_b) | Register in clk_b | HREADY is synchronized first; only then is HRDATA captured into clk_b register |
HREADY | Slave → DMA (clk_b) | 2-FF synchronizer | Single-bit ready; synchronized before HRDATA capture |
The key pattern: only single-bit control signals are synchronized. Wide data buses are kept stable by protocol — the source domain holds data until the destination acknowledges via a synchronized control pulse. This minimizes synchronizer hardware and maximizes throughput.
| Scenario | Recommended scheme | Why |
|---|---|---|
| 2 domains, infrequent transfers | Token-passing | Simplest; no arbiter logic; starvation-free by construction |
| 2 domains, burst transfers | Handshake + semaphore | Keeps bus for multiple beats before releasing ownership |
| 3+ domains, round-robin fairness | Registered round-robin arbiter + grant synchronizers | Proven in AHB/AXI bus matrices; scales to N masters |
| Async request timing unknown | MUTEX cell + arbiter | Metastability-resistant when request timing is truly asynchronous |
| High-performance NoC | Credit-based flow control | Avoids blocking; combines CDC with flow control; used in AXI4 |
When control (enable/valid) and data signals are synchronized independently through separate 2-FF synchronizers, they arrive at the destination domain at different times due to different synchronizer latencies and routing delays. The receiver may see a control pulse that doesn't correspond to stable data, causing an incorrect capture. You must ensure data is stable before control is asserted, and hold data stable until the destination acknowledges capture via a synchronized ACK.
A MUTEX (mutual exclusion) cell is a special circuit built from cross-coupled NAND gates that guarantees only one output is asserted even when both inputs go high simultaneously. Standard cells cannot fulfill this role because perfectly balanced transistors may enter a symmetric metastable state that never resolves. A proper MUTEX uses custom layout with deliberate asymmetry to ensure eventual resolution to exactly one grant, making it safe for arbitration across asynchronous clock domains.
In arbiter-based CDC, the grant signal (control) must cross to the receiving domain and be fully stable before data is allowed to transfer. The sequence is: source asserts REQ and holds data stable; arbiter asserts GRANT; GRANT is synchronized through 2 FFs (2-cycle latency); destination sees stable GRANT; only then does it sample data. If data is driven before the synchronized GRANT is seen, the receiver may sample invalid values.