UCIe · Module 5
The Adapter Layer
The microarchitecture of the UCIe Die-to-Die Adapter — buffering and occupancy, flow control as resource accounting, integrity-pipeline alignment, retry retention, link-management and negotiation state machines, and arbitration between protocols.
Engineers meeting the UCIe stack for the first time usually classify the Adapter as glue — the thing between the interesting protocol logic and the interesting physical layer. That instinct is wrong, and this chapter is written to replace it with a microarchitectural picture.
The Adapter holds more state than either of its neighbours. It runs the link's control plane, it is the reliability boundary in the modes that use one, it accounts for storage that lives on another die, and it decides which protocol gets to use the link this cycle. Every one of those is a hardware structure with an ownership rule, a failure mode, and an assertion that catches it.
1. The Link's Control Plane
The Adapter's documented responsibilities are the ones Module 4 introduced: coordinating the higher-level link state machine and bring-up, negotiating parameters with the remote partner, providing CRC and link-level retry where configured, arbitrating and multiplexing when protocols share the link, and coordinating power states with the partner.
Listing them is not the lesson. The lesson is that each requires state, and asking what state? is what turns a specification bullet into a design:
| Responsibility | State it requires |
|---|---|
| Accept traffic from above | Buffer storage and occupancy |
| Deliver to the PHY | Egress state, ordering position |
| Flow control | An account of capacity — local and remote |
| Integrity | A pipeline whose stages stay aligned with the data |
| Retry | Retained copies of unacknowledged transport units |
| Link management | A state machine with prerequisites |
| Negotiation | Stored remote capability and the chosen operating point |
| Arbitration | Selection state, and enough history for fairness |
| Power coordination | Agreed state with the partner |
The Adapter is the link's control plane and — in the modes that use one — its reliability boundary. It separates semantic behaviour from physical behaviour by owning all the state that sits between them.
2. The Ingress Buffer
Module 4 used one-entry stages to teach ownership. The Adapter needs a real queue, because it must absorb bursts from above while the link below is busy.
Illustrative architecture RTL — not UCIe normative signal naming.
// Adapter ingress buffer: a small synchronous FIFO with explicit occupancy.
parameter int unsigned DEPTH = 4;
parameter int unsigned DATA_W = 256;
localparam int unsigned PTR_W = $clog2(DEPTH);
localparam int unsigned CNT_W = $clog2(DEPTH + 1);
logic [DATA_W-1:0] fifo_mem [DEPTH];
logic [PTR_W-1:0] rd_ptr_q, wr_ptr_q;
logic [CNT_W-1:0] count_q;
logic full, empty, do_enq, do_deq;
assign full = (count_q == CNT_W'(DEPTH));
assign empty = (count_q == '0);
assign up_ready = !full;
assign do_enq = up_valid && up_ready;
assign dn_valid = !empty;
assign do_deq = dn_valid && dn_ready;
assign dn_data = fifo_mem[rd_ptr_q];
always_ff @(posedge clk) begin
if (!rst_n) begin
rd_ptr_q <= '0;
wr_ptr_q <= '0;
count_q <= '0;
end else begin
if (do_enq) begin
fifo_mem[wr_ptr_q] <= up_data;
wr_ptr_q <= (wr_ptr_q == PTR_W'(DEPTH-1)) ? '0 : wr_ptr_q + 1'b1;
end
if (do_deq) begin
rd_ptr_q <= (rd_ptr_q == PTR_W'(DEPTH-1)) ? '0 : rd_ptr_q + 1'b1;
end
unique case ({do_enq, do_deq})
2'b10: count_q <= count_q + 1'b1;
2'b01: count_q <= count_q - 1'b1;
default: count_q <= count_q; // both, or neither
endcase
end
endArchitecture. Storage that decouples the rate the Protocol Layer produces from the rate the PHY consumes, so a short stall below does not immediately stall above.
State. fifo_mem holds payloads; wr_ptr_q/rd_ptr_q are head and tail; count_q is occupancy. Occupancy is deliberately a separate counter rather than derived from pointer difference — it makes full and empty unambiguous without a wrap bit, and it is the single most useful signal in debug.
Cycle behaviour. Cycle N: up_valid and !full, so do_enq — the payload is written and wr_ptr_q advances. Cycle N+1: the PHY is busy, dn_ready low, so no dequeue and count_q holds. Cycle N+2: dn_ready rises while up_valid is still high — both fire, count_q is unchanged, and the buffer sustains full throughput rather than alternating.
Contract. Upstream: if up_ready was high, the payload is retained. Downstream: dn_data is the oldest entry and is stable while dn_valid is high.
Failure/DV. The default arm matters more than it looks — treating simultaneous enqueue and dequeue as either an increment or a decrement corrupts occupancy immediately, which then corrupts full and empty. That is a silent structural bug, and it is exactly what the next section asserts against.
3. Occupancy Assertions
// Illustrative buffer invariants — conceptual, not UCIe normative properties.
// SAFETY: occupancy can never exceed capacity.
property p_no_overflow;
@(posedge clk) disable iff (!rst_n) count_q <= CNT_W'(DEPTH);
endproperty
assert property (p_no_overflow) else $error("occupancy exceeded DEPTH");
// SAFETY: never dequeue from an empty buffer.
property p_no_underflow;
@(posedge clk) disable iff (!rst_n) do_deq |-> (count_q != '0);
endproperty
assert property (p_no_underflow) else $error("dequeued while empty");
// SAFETY: the full/empty flags must agree with occupancy.
property p_full_iff_depth;
@(posedge clk) disable iff (!rst_n) full == (count_q == CNT_W'(DEPTH));
endproperty
assert property (p_full_iff_depth) else $error("full flag disagrees with occupancy");
property p_empty_iff_zero;
@(posedge clk) disable iff (!rst_n) empty == (count_q == '0);
endproperty
assert property (p_empty_iff_zero) else $error("empty flag disagrees with occupancy");Each catches a distinct bug. Overflow means a payload was written over an unconsumed one — silent loss. Underflow means a payload was fabricated from stale memory — silent corruption. The flag-consistency pair catches the class where occupancy is right but the derived control is wrong, which produces either lost throughput (never asserting ready) or overflow (asserting ready when full) depending on direction.
4. Flow Control Is Resource Accounting
A FIFO handles local capacity. But the Adapter also has to avoid overrunning storage on the other die, which it cannot observe.
Flow control is accounting for storage ownership across a boundary you cannot see.
The general mechanism is a counter representing how much remote capacity this transmitter is entitled to consume: decrement when consuming it, increment when the remote side signals capacity has been freed. The exact mechanism UCIe defines belongs to the flow-control chapter later in the curriculum; the accounting shape is what matters now.
// Conceptual transmit-side capacity accounting -- generic, not UCIe's
// specific flow-control mechanism.
parameter int unsigned CREDIT_W = 6;
logic [CREDIT_W-1:0] tx_credit_q;
logic credit_return; // remote signalled capacity freed
logic tx_consume; // we are launching a transport unit
assign may_transmit = (tx_credit_q != '0);
assign tx_consume = dn_valid && dn_ready && may_transmit;
always_ff @(posedge clk) begin
if (!rst_n) begin
tx_credit_q <= INITIAL_CREDITS;
end else begin
unique case ({tx_consume, credit_return})
2'b10: tx_credit_q <= tx_credit_q - 1'b1;
2'b01: tx_credit_q <= tx_credit_q + 1'b1;
default: tx_credit_q <= tx_credit_q;
endcase
end
endArchitecture. A local model of remote storage. The transmitter is permitted to send only while it holds entitlement.
State. tx_credit_q — initialised during bring-up to whatever the partner advertised, then maintained for the life of the link.
Cycle behaviour. Consuming and returning on the same cycle leaves the count unchanged — the same simultaneity handling as the FIFO, and wrong in the same way if collapsed.
Contract. The receiver relies absolutely on this: it sized its buffers assuming the transmitter respects the accounting. If the transmitter sends without entitlement, the receiver has no defence — it has nowhere to put the arriving unit.
Failure/DV. Transmit at zero credit and the remote buffer overflows, dropping or corrupting a transport unit that the local side believes was delivered. Worse, an underflowing counter that wraps looks like a large positive credit, so the transmitter floods.
// SAFETY: never launch a transport unit without entitlement.
property p_no_send_without_credit;
@(posedge clk) disable iff (!rst_n)
tx_consume |-> (tx_credit_q != '0);
endproperty
assert property (p_no_send_without_credit)
else $error("transmitted with zero remote capacity");
// SAFETY: entitlement must never exceed what was advertised.
property p_credit_bounded;
@(posedge clk) disable iff (!rst_n) tx_credit_q <= MAX_CREDITS;
endproperty
assert property (p_credit_bounded)
else $error("credit count exceeded the advertised maximum");The second property catches duplicate returns — a return counted twice inflates entitlement, and the transmitter later overruns a receiver that never had that capacity.
5. Integrity, and the Alignment Bug
In the modes where the Adapter provides reliability, transmit computes integrity information and receive verifies it. The full mechanism is a later chapter; what belongs here is a single line and the trap hiding inside it.
// The receive-side decision, in one line.
assign deliver_up = rx_valid && crc_ok;That looks trivial and conceals a genuine pipeline problem: crc_ok and the payload it describes must refer to the same transport unit. Checking is not instantaneous — it takes at least one pipeline stage — so if the data path and the check result are not delayed identically, they drift apart.
Here is the bug, which is common enough to be worth showing:
// INCORRECT: the check result is registered but the payload is not, so
// crc_ok lags the data it describes by one cycle.
always_ff @(posedge clk) begin
crc_ok_q <= crc_check(rx_data); // result for THIS cycle's data...
end
assign deliver_up = rx_valid && crc_ok_q; // ...applied to NEXT cycle's data
assign up_data = rx_data; // undelayedWhy it fails. Cycle N: unit A arrives; crc_check(A) is computed and registered. Cycle N+1: crc_ok_q now holds A's result, but rx_data is unit B. A good A followed by a corrupt B is accepted, because B is gated by A's verdict. A corrupt A followed by a good B is rejected, discarding a valid unit.
The signature is diagnostically distinctive: errors appear to be off by one transport unit, and rejection and acceptance are both wrong in a correlated way. It also looks intermittent — it only shows when corruption actually occurs — which sends people hunting for a marginal channel.
// CORRECTED: delay the payload alongside its check result so both stages
// describe the same transport unit.
always_ff @(posedge clk) begin
rx_data_q <= rx_data;
rx_valid_q <= rx_valid;
crc_ok_q <= crc_check(rx_data);
end
assign deliver_up = rx_valid_q && crc_ok_q;
assign up_data = rx_data_q;DV. The catch is a scoreboard property rather than a single-cycle assertion: for each transport unit injected with known-bad integrity, exactly that unit must be rejected and no other. An error-injection test that corrupts alternating units exposes the misalignment immediately, whereas corrupting a single unit in isolation may not.
6. Retry Requires Retention
If the link can replay a transport unit, then transmitting it is not the end of the transmitter's responsibility.
// Conceptual replay retention -- illustrative, not UCIe's retry protocol.
logic retry_valid_q;
logic [DATA_W-1:0] retry_data_q;
logic [SEQ_W-1:0] retry_seq_q;
always_ff @(posedge clk) begin
if (!rst_n) begin
retry_valid_q <= 1'b0;
end else if (tx_fire) begin
retry_valid_q <= 1'b1; // launched: keep a copy
retry_data_q <= tx_data;
retry_seq_q <= tx_seq;
end else if (ack_fire && (ack_seq == retry_seq_q)) begin
retry_valid_q <= 1'b0; // confirmed: now safe to discard
end
endArchitecture. A local copy retained until the remote side confirms receipt. This is the longest-lived state in the Adapter — it outlives the transmission by a full round trip.
Cycle behaviour. Set on launch; cleared only on a matching confirmation. Between those two events the Adapter holds the only recoverable copy in the system.
Failure/DV. The classic bug:
// WRONG: discarding the replay copy at transmission.
if (tx_fire)
retry_valid_q <= 1'b0;Why it fails. Transmission is exactly when the copy becomes necessary. If the unit is corrupted in the channel, the receiver rejects it and requests replay — and there is nothing to replay. The failure only manifests when an error actually occurs, so it survives clean-link testing completely and appears in the field as an unrecoverable link error under marginal conditions.
// SAFETY: a launched unit must remain retained until confirmed.
property p_retry_retained_until_ack;
@(posedge clk) disable iff (!rst_n)
tx_fire |=> retry_valid_q throughout (!(ack_fire && ack_seq == retry_seq_q))[->1];
endpropertyThis one deserves a caveat: as written it assumes a confirmation eventually arrives, which makes it a bounded-liveness property rather than pure safety, and in a formal flow it needs an accompanying assumption that the environment does acknowledge. The simpler safety companion — tx_fire |=> retry_valid_q — is weaker but unconditional, and is the one to start with.
7. Link Management as a State Machine
The Adapter owns bring-up, and bring-up is a sequence with prerequisites.
Illustrative architecture states — not UCIe normative state names.
typedef enum logic [2:0] {
ADP_RESET,
ADP_WAIT_PHY, // waiting for the physical link to report usable
ADP_NEGOTIATE, // exchanging and selecting parameters with the partner
ADP_READY, // datapath enabled
ADP_RECOVERY, // transient fault; re-establishing
ADP_ERROR // unrecoverable (e.g. no compatible operating point)
} adapter_state_t;
adapter_state_t adp_state_q;
always_ff @(posedge clk) begin
if (!rst_n) begin
adp_state_q <= ADP_RESET;
end else begin
unique case (adp_state_q)
ADP_RESET : adp_state_q <= ADP_WAIT_PHY;
ADP_WAIT_PHY : if (phy_link_up) adp_state_q <= ADP_NEGOTIATE;
ADP_NEGOTIATE : if (negotiate_done) adp_state_q <= (common_ok ? ADP_READY : ADP_ERROR);
ADP_READY : if (link_fault) adp_state_q <= ADP_RECOVERY;
ADP_RECOVERY : if (recovered) adp_state_q <= ADP_WAIT_PHY;
ADP_ERROR : /* held until software or reset intervenes */ ;
default : adp_state_q <= ADP_ERROR;
endcase
end
end
// The single abstraction consumed by the Protocol Layer.
assign link_operational = (adp_state_q == ADP_READY);Architecture. The control plane that gates everything else. Note ADP_RECOVERY returns to ADP_WAIT_PHY, not directly to ADP_READY — after a fault the physical link and the negotiated parameters must both be re-established, and shortcutting that is a real bug.
Contract. The Protocol Layer consumes link_operational and nothing finer, per Chapter 5.1 §8.
Failure/DV. Reaching ADP_READY without completing negotiation means running with an operating point the partner never agreed to.
// SAFETY: ready requires a successfully negotiated operating point.
property p_ready_requires_negotiated;
@(posedge clk) disable iff (!rst_n)
(adp_state_q == ADP_READY) |-> negotiated_valid_q;
endproperty
assert property (p_ready_requires_negotiated)
else $error("entered READY without a negotiated operating point");
// SAFETY: no protocol traffic accepted outside READY.
property p_no_traffic_outside_ready;
@(posedge clk) disable iff (!rst_n)
(adp_state_q != ADP_READY) |-> !(up_valid && up_ready);
endproperty
assert property (p_no_traffic_outside_ready)
else $error("accepted protocol traffic while not READY");8. Negotiation Needs Stored Remote State
Chapter 3.2 introduced capability intersection as a concept. Here it is a hardware structure owned by this layer.
// Conceptual parameter negotiation state -- not UCIe's encoding.
logic [CAP_W-1:0] local_caps; // fixed by this design
logic [CAP_W-1:0] remote_caps_q; // captured during NEGOTIATE
logic [CAP_W-1:0] common_caps;
logic [CAP_W-1:0] selected_q; // the chosen operating point
logic negotiated_valid_q;
assign common_caps = local_caps & remote_caps_q;
assign common_ok = (common_caps != '0);
always_ff @(posedge clk) begin
if (!rst_n) begin
negotiated_valid_q <= 1'b0;
end else if (adp_state_q == ADP_NEGOTIATE && negotiate_done && common_ok) begin
selected_q <= select_preferred(common_caps);
negotiated_valid_q <= 1'b1;
end else if (adp_state_q == ADP_RECOVERY) begin
negotiated_valid_q <= 1'b0; // must be re-established after a fault
end
endArchitecture. Negotiation is inherently sequential — it requires exchanging information with a partner across a link, which takes many cycles. It cannot be combinational, and treating it as such is a design error rather than an optimisation.
State. remote_caps_q retains what the partner advertised, for the life of the link. selected_q is the agreed operating point everything else depends on.
Cycle behaviour. Captured once during ADP_NEGOTIATE; invalidated on entering recovery, which is what forces re-negotiation rather than resuming on stale parameters.
Failure/DV. Failing to invalidate on recovery means resuming with an operating point that may no longer hold — for instance after the physical link retrains at a different width.
9. Arbitration and Starvation
Where several protocols share one link, something must choose. Fixed priority is the obvious implementation and has a well-known flaw:
// INCORRECT for shared links: strict priority starves the lower stream.
always_comb begin
if (a_valid) grant = GRANT_A;
else if (b_valid) grant = GRANT_B;
else grant = GRANT_NONE;
endWhy it fails. If stream A has work every cycle, B is never granted. Cycle N through N+1000: a_valid high, b_valid high, grant = A every cycle. B makes no progress at all. This is not a slowdown — it is indefinite starvation, and if B carries responses that A's progress ultimately depends on, it becomes a deadlock.
// CORRECTED: round-robin -- the last granted source has lowest priority next.
logic last_grant_a_q;
always_comb begin
unique case (1'b1)
(a_valid && b_valid): grant = last_grant_a_q ? GRANT_B : GRANT_A;
a_valid: grant = GRANT_A;
b_valid: grant = GRANT_B;
default: grant = GRANT_NONE;
endcase
end
always_ff @(posedge clk) begin
if (!rst_n) last_grant_a_q <= 1'b0;
else if (grant == GRANT_A) last_grant_a_q <= 1'b1;
else if (grant == GRANT_B) last_grant_a_q <= 1'b0;
endState. last_grant_a_q is the entire fairness memory — one bit, and without it fairness is impossible.
DV. Fairness is a liveness property, and writing it carelessly produces an assertion that is either vacuous or unprovable. A bounded formulation is far more useful:
// BOUNDED LIVENESS: a continuously requesting source must be granted within
// a bounded window while the arbiter is making progress. The bound is a
// design parameter -- an unbounded eventually() is usually not checkable
// in simulation and needs fairness assumptions in formal.
property p_b_not_starved;
@(posedge clk) disable iff (!rst_n)
(b_valid && arb_progressing)[*STARVE_LIMIT] |-> ##[0:STARVE_LIMIT] (grant == GRANT_B);
endpropertyPair it with coverage that both sources are granted while both are continuously requesting — coverage often finds starvation faster than assertions do.
10. Turning Lower-Layer Conditions Into Status
The Adapter is where physical conditions become something the Protocol Layer can act on. It must distinguish conditions that look similar and mean different things:
- A detected bad payload — integrity check failed. Recoverable in the modes with retry; the transport unit is rejected and not delivered upward.
- A resource condition — buffers full, no credit. Not an error at all; normal backpressure.
- Link unavailable — the PHY is not reporting the link usable. Traffic must stop; not a data error.
- Negotiation mismatch — no compatible operating point exists. Terminal for this configuration; not retryable.
- Persistent physical fault — repeated failures that recovery does not clear.
These require different responses, and collapsing them is a common design error. Backpressure treated as an error causes spurious recovery; a persistent fault treated as backpressure causes a hang with no diagnostic.
What goes upward is an abstraction: operational or not, and whatever degradation status the architecture defines. Raw physical detail must not leak, per Chapter 4.2.
11. Debugging a Stalled Adapter
When traffic stops, walk this in order. The first no is the answer:
- Is the link state
ADP_READY? If not, the control plane is the problem — go to §7's prerequisites. - Is the Protocol side actually presenting
up_valid? - Does the buffer have space — is
count_q < DEPTH? - Did an enqueue fire, and did
count_qincrement? - Is arbitration granting this source, or is another stream monopolising it?
- Is retry or recovery active and suppressing new traffic?
- Is the PHY side accepting — is
dn_readyever high? - Is there transmit entitlement — is
tx_credit_qnon-zero? - Is an error state suppressing the datapath?
- Is
link_operationalcorrectly reflecting state upward?
Two signals answer most cases. count_q distinguishes "nothing is arriving" (stays zero) from "nothing is leaving" (pegged at DEPTH). And tx_credit_q at zero with a full buffer is the signature of the far side not returning capacity — which points at the remote Adapter or the return path, not this one.
12. Common Misconceptions
13. Understanding Check
14. Summary and What Comes Next
The Adapter is the link's control plane and, in the modes that use one, its reliability boundary — and every responsibility it holds is backed by state. Ingress buffering with explicit occupancy decouples production from consumption, and its simultaneous enqueue-and-dequeue case is where naive implementations corrupt the count. Flow control is resource accounting for storage on a die you cannot observe, with underflow and duplicated returns as the two dangerous directions. Integrity requires pipeline alignment, whose failure looks like errors shifted by one unit and masquerades as a marginal channel. Retry requires retention until delivery is confirmed, and freeing the copy at transmission is invisible to clean-link testing and fatal on a marginal one.
Above the datapath sits a link-management state machine whose transitions have prerequisites — PHY up before negotiate, agreed operating point before ready, recovery back to waiting rather than straight to ready — and negotiation that is inherently sequential and must be invalidated after a fault. Where protocols share a link, arbitration needs fairness state, because strict priority starves indefinitely and can become deadlock.
For debug, two counters do most of the work: occupancy distinguishes "nothing arriving" from "nothing leaving", and zero credit with a full buffer means this Adapter is correctly waiting on the far side.
The Adapter can only manage a link if something beneath it makes that link real — discovering usable lanes, establishing timing, and reporting physical readiness honestly:
- 5.3 — The Physical Layer — the digital state inside a PHY: bring-up state machines, lane health versus lane enable, clock-domain crossing done safely, timeouts, and why a link can train perfectly and still corrupt every transfer.
Browse the full path on the UCIe tutorials index.