CXL · Module 4
Data Link Layer
How a CXL link makes an unreliable channel look reliable: detection versus correction and why the order matters, the replay buffer that bounds how far a sender may run ahead, credit-based flow control, and what recovery costs in wire time.
Chapter 4.1 got units onto the wire and labelled them. It named the 2-byte CRC in the 68-byte flit and then deliberately set it aside.
This chapter picks it up. The question here is narrow and consequential: the channel corrupts things — what does the link do about it, and what does that cost?
1. The Engineering Problem — The Wire Is Not Reliable
At 64 GT/s with PAM-4 signalling, bit errors are not exceptional events to be designed around. They are a designed-for rate. The CXL Consortium says so directly, describing CXL 3.0 as using the PCIe 6.0 PHY at 64 GT/s with "PAM-4 and high BER mitigated by PCIe 6.0 FEC and CRC".
Read that phrasing carefully. The bit error rate is high — high enough to need mitigating — and two different mechanisms mitigate it. That is the whole chapter in one sentence, because the two mechanisms do different jobs at different costs, and every interesting decision at this layer follows from telling them apart.
Above this layer, Chapter 3.4's coherence protocol assumes messages arrive. Chapter 3.1's home agent waits for acknowledgements that it assumes will come. Neither has any notion of a corrupted flit, and neither should. Something has to turn a channel with a high error rate into a delivery service those layers can build on — and it has to do it without telling them, because the moment retry becomes visible upstream, Chapter 3.5's layering falls apart.
2. The One-Sentence Model
The data link layer is an insurance policy with a premium: it corrects what it can in place, detects what it cannot and asks for it again, holds every transmitted unit until the far side confirms receipt — which is what bounds how far ahead a sender may run — and pays for all of it in wire time that carries no new payload.
Call it the insurance model. Correction is the cheap policy that covers small claims silently. Detection plus replay is the expensive policy that covers everything else. The replay buffer is the reserve you must hold to be able to pay a claim at all, and credits are the promise from the other side that they can accept the payout.
3. What This Chapter Owns
| Question | Owned by |
|---|---|
| Getting units onto a shared wire | 4.1 |
| Which layer owns retry, and why | 3.5 |
| Integrity, replay, credits, recovery cost | this chapter |
| Per-protocol transaction behaviour | 4.3 |
| The three protocols side by side | 4.4 |
| Stack-level PCIe comparison | 4.5 |
Chapter 3.5 established that retry belongs here and that its duplicates must not escape. This chapter builds the mechanism and measures the premium.
4. Two Mechanisms, Two Jobs
The distinction that organises everything below.
| Correct | Detect | |
|---|---|---|
| Does | fixes it in place | notices it |
| Then | delivery proceeds | ask again |
| Latency | own decode, always | a round trip, when it fires |
| Bits | extra on every unit | a resend on failure |
| Covers | errors it can fix | all of them, up to aliasing |
Correction is paid on every unit whether or not it is needed. Detection is nearly free until it fires, and then it costs an entire round trip. That asymmetry is why both exist: a channel with a high error rate would spend all its time retrying if detection were the only mechanism, and a code strong enough to correct every possible error would spend an unacceptable fraction of every unit on redundancy.
5. Why the Order Matters
Correction runs first. Detection runs on the result.
That ordering is not a convention — it is the only order that makes sense, and reversing it produces a specific, measurable waste:
correct first: error arrives → correction fixes it → check passes → deliver
cost: correction decode. No round trip.
check first: error arrives → check fails → request replay → ...
cost: a full round trip, for an error that was fixable.Section 11 measures both. The wrong order turns a correctable error — the common case on a high-BER channel — into a retransmission.
The deeper point is Chapter 3.5's responsibility-placement rule applied within a layer: handle a problem at the lowest level that can actually solve it. Escalating from correction to replay is the same mistake as escalating from replay to the transaction layer, one step down.
6. Quantitative Reasoning — What Recovery Costs
Three calculations that a link-layer design cannot avoid.
Retry costs wire time twice
A replayed unit occupies the link exactly as much as an original. So if a fraction r of units are replayed, the effective delivery rate is:
effective_rate = nominal_rate / (1 + r)At a 5% replay rate the link delivers about 95.2% of what it otherwise would — and it does so while every counter that measures transmissions looks perfectly healthy. Section 12 measures the distinction, and Chapter 3.5 §13 showed the consequence upstream: a boundary sized against nominal drain rate will backpressure on a link that is merely working harder.
Replay-buffer depth bounds how far you may run ahead
A sender may not transmit a unit it could not replay. So the buffer depth directly caps in-flight units, and by Little's Law:
required_depth ≈ send_rate × round_trip_to_acknowledgementA link sending one unit per cycle with a 40-cycle acknowledgement round trip needs 40 entries to avoid stalling. With 8, the achievable rate is capped at 8/40 = 0.2 units per cycle — a 5× shortfall that no amount of signalling rate fixes, because the limit is the sender's own ability to recover.
This is structurally the same calculation as Chapter 3.2's outstanding-table sizing, and it recurs for the same reason: any resource held across a round trip is bounded by rate times latency.
Credits are the same arithmetic from the receiver's side
Credits let a sender commit before checking, which is what makes a long round trip workable:
max_in_flight = credits × bytes_per_creditA ready/valid handshake tells you about this cycle after you have offered. Credits tell you about the future before you commit. On a link whose round trip is long, only the second is usable — which is why credits exist at this layer and not inside a chip.
7. Teaching-model boundary
8. RTL 1 — Detect, and Decide
Purpose
The decision this layer exists to make: deliver, or ask again.
// Integrity checking, and the distinction that decides what happens next.
//
// ARCHITECTURAL TEACHING MODEL. The CXL 68-byte flit carries a 2-byte CRC
// protecting its 64-byte payload, and CXL 3.0 uses PCIe 6.0 FEC and CRC with a
// different CRC for latency-optimized flits -- but NO CXL or PCIe CRC
// polynomial, FEC code, syndrome or check sequence is modelled or claimed here.
// The 8-bit parity-style check below is a teaching stand-in.
module crc_guard #(
parameter bit ACCEPT_ON_MISMATCH = 1'b0 // 1 = the bug shape
) (
input logic clk,
input logic rst_n,
input logic flit_valid,
input logic [31:0] payload,
input logic [7:0] rx_check, // the check value that arrived with it
input logic fec_corrected, // a lower-level code already fixed it
output logic [7:0] computed_check,
output logic integrity_ok,
output logic deliver,
output logic request_retry,
output logic [15:0] n_corrected_q, // fixed below, no retry needed
output logic [15:0] n_detected_q, // not fixable, retry required
output logic [15:0] n_delivered_q,
output logic bad_delivered_err
);
logic match;
// A deliberately weak teaching check: byte-wise XOR of the payload.
assign computed_check = payload[31:24] ^ payload[23:16]
^ payload[15:8] ^ payload[7:0];
assign match = (computed_check == rx_check);
assign integrity_ok = flit_valid && match;
// The decision the whole layer exists to make.
assign deliver = flit_valid && (ACCEPT_ON_MISMATCH ? 1'b1 : match);
assign request_retry = flit_valid && !match && !ACCEPT_ON_MISMATCH;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
if (flit_valid && fec_corrected) n_corrected_q <= n_corrected_q + 16'd1;
if (flit_valid && !match) n_detected_q <= n_detected_q + 16'd1;
if (deliver) n_delivered_q <= n_delivered_q + 16'd1;
// Something failed its integrity check and went up the stack anyway.
if (deliver && !match) bad_delivered_err <= 1'b1;
end
end
endmoduleCorrected and detected are counted separately, and that separation is the diagnostic. A rising corrected count with a flat detected count is a channel degrading within the correcting code's power — an early warning. A rising detected count means errors are escaping correction and each one now costs a round trip. Collapsing them into one "error count" discards exactly the information that distinguishes a link to watch from a link to fix.
Synthesis. The check itself is a small XOR tree here; a real CRC is a wider XOR network, still combinational, still in the receive path. The important structural point is that this is combinational and in the critical path of every received unit — which is why the code's complexity is a real design constraint and not just a mathematical choice.
Aliasing is the limit nobody escapes. Any finite check maps many corrupted payloads onto the same value, so some corruptions pass. This is where the published FIT figures come from, and it is why "the CRC passed" means "no detected error" rather than "correct".
Simulation evidence
=== EXP1: integrity check -- what gets delivered ===
intact flit : computed=44 received=44 ok=1 deliver=1 retry=0
corrupted flit : computed=44 received=ff ok=0 deliver=0 retry=1
accept-on-mismatch variant: deliver=1
accept-on-mismatch bad_delivered_err=1Row one is identical between the two designs. Every intact flit — which is almost all of them — behaves the same way, so a receiver that ignores its own check is indistinguishable from a correct one on a healthy channel. The divergence appears exactly when the mechanism was supposed to earn its keep.
9. RTL 2 — The Replay Buffer
Purpose
You cannot resend what you did not keep.
// The replay buffer: hold every transmitted unit until the far side confirms
// it arrived, so a failed transfer can be sent again.
//
// The buffer's DEPTH is what bounds how far ahead the transmitter may run. A
// sender that transmits beyond its ability to replay has no way to recover,
// which is why `can_send` is a hard gate rather than advice.
//
// ARCHITECTURAL TEACHING MODEL. CXL 68-byte flit mode includes link
// reliability mechanisms such as Ack/Nak; the sequence numbering, buffer
// management and replay policy below are TEACHING VALUES and are not the
// CXL-defined retry mechanism.
module retry_buffer #(
parameter int unsigned DEPTH = 8,
parameter int unsigned MAX_RETRY = 3,
parameter bit FREE_ON_SEND = 1'b0 // 1 = the bug shape
) (
input logic clk,
input logic rst_n,
input logic send,
input logic [7:0] send_data,
input logic rx_ack, // far side got everything up to ack_seq
input logic [4:0] ack_seq,
input logic rx_nak, // far side rejected from nak_seq onward
input logic [4:0] nak_seq,
output logic can_send,
output logic [4:0] send_seq,
output logic replaying,
output logic [4:0] replay_seq,
output logic [7:0] replay_data,
output logic [7:0] occupancy_q,
output logic [7:0] max_occupancy_q,
output logic give_up,
output logic lost_unit_err, // acknowledged more than was outstanding
output logic overflow_err
);
// A sender may only transmit what it could still replay.
assign can_send = (occupancy_q < DEPTH[7:0]) && !replaying;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
if (send && can_send) begin
buf_q[seq_q % DEPTH] <= send_data;
seq_q <= seq_q + 5'd1;
// FREE_ON_SEND=1 never grows the buffer: the unit is considered done
// the moment it leaves, so nothing can be replayed.
if (!FREE_ON_SEND) occupancy_q <= occupancy_q + 8'd1;
end
if (rx_ack) begin
// Everything up to and including ack_seq is confirmed and may go.
// Acknowledging more than was outstanding means the two ends disagree
// about what was sent -- reportable, not silently clamped.
if (occupancy_q >= (ack_seq - oldest_q + 5'd1))
occupancy_q <= occupancy_q - 8'(ack_seq - oldest_q + 5'd1);
else begin occupancy_q <= '0; lost_unit_err <= 1'b1; end
oldest_q <= ack_seq + 5'd1;
retry_count_q <= 8'd0;
end
if (rx_nak && !replaying) begin
replaying <= 1'b1; replay_ptr_q <= nak_seq;
retry_count_q <= retry_count_q + 8'd1;
end else if (replaying) begin
if (replay_ptr_q + 5'd1 == seq_q) replaying <= 1'b0; // caught up
else replay_ptr_q <= replay_ptr_q + 5'd1;
end
end
end
endmoduleAn acknowledgement is cumulative and that is a deliberate efficiency. Confirming "everything up to sequence N" retires many entries with one message, rather than one acknowledgement per unit. The cost is that a single lost acknowledgement delays retiring everything behind it — which is fine, because the next one covers it.
Replay restarts from the failure point and continues forward, not just the single failed unit. The receiver rejected from that sequence onward, so everything after it must be resent in order — which is why replaying blocks new sends until it catches up, and why a replay costs proportional to how far ahead the sender had run.
give_up bounds it. A link that retries forever converts a permanently failed transfer into a permanently stalled transaction — Chapter 3.5's point, and the reason escalation to the layer above must eventually happen.
Simulation evidence
=== EXP2: replay -- what a sender must keep ===
5 sent, none acknowledged:
keep-until-acked : occupancy=5 can_send=1
free-on-send : occupancy=0 can_send=1 <-- nothing to replay
nak from seq 2:
keep-until-acked : replaying=1 seq=2 data=a2
after replay: keep-until-acked replaying=0 replayed=3
ack up to seq 4: occupancy=0 retry_count=0The correct buffer held five, replayed three (sequences 2, 3 and 4), and retired everything on a single cumulative acknowledgement. The retry counter reset on the acknowledgement, which matters — retries must count consecutive failures, not lifetime failures, or a long-lived healthy link eventually exceeds any threshold.
=== EXP3: buffer depth bounds how far ahead you may run ===
12 sends into a depth-8 buffer:
keep-until-acked : can_send=0 occupancy=8 max=8 <-- STALLED at 8
free-on-send : can_send=1 occupancy=0 <-- ran ahead unbounded
-> the correct buffer refused to transmit a 9th unacknowledged unit.
The free-on-send buffer transmitted all 12 and retained none,
so it has run 12 units ahead of anything it could replay.The correct sender stalled at 8, which is the entire mechanism working. The free-on-send variant transmitted all twelve and kept none — so a rejection of any of them is unrecoverable, and the failure surfaces not as a link error but as a transaction that never completes.
10. RTL 3 — Credits
Purpose
Ask permission before committing, because on a long link asking afterwards is too late.
// Credit-based flow control: a transmitter may only send what the receiver has
// already promised it has room for.
//
// The difference from a ready/valid handshake matters. Ready/valid tells you
// about THIS cycle after you have offered; credits tell you about the future
// before you commit, which is what makes them workable across a link whose
// round trip is long.
//
// ARCHITECTURAL TEACHING MODEL. CXL 68-byte flit mode includes credits; the
// credit unit, initialisation and return mechanism below are TEACHING VALUES
// and are not the CXL-defined credit scheme.
module credit_flow #(
parameter int unsigned INIT_CREDITS = 8,
parameter bit IGNORE_CREDITS = 1'b0 // 1 = the bug shape
) (
input logic clk,
input logic rst_n,
input logic want_send,
input logic credit_return, // receiver freed one buffer
input logic rx_buffer_free, // receiver's actual free space > 0
output logic may_send,
output logic did_send,
output logic [7:0] credits_q,
output logic [15:0] blocked_cycles_q,
output logic [15:0] n_sent_q,
output logic overrun_err, // sent with no room at the receiver
output logic credit_leak_err // credits exceeded what was issued
);
assign may_send = IGNORE_CREDITS ? 1'b1 : (credits_q != 8'd0);
assign did_send = want_send && may_send;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
if (want_send && !may_send) blocked_cycles_q <= blocked_cycles_q + 16'd1;
// Spend on send, replenish on return -- in ONE assignment, because both
// can happen in the same cycle and two writes would lose one.
credits_q <= credits_q
- ((did_send && credits_q != 8'd0) ? 8'd1 : 8'd0)
+ (credit_return ? 8'd1 : 8'd0);
// The invariant the whole scheme exists to guarantee.
if (did_send && !rx_buffer_free) overrun_err <= 1'b1;
// More credits than were ever issued means a return was double-counted.
if (credits_q > INIT_CREDITS[7:0]) credit_leak_err <= 1'b1;
end
end
endmoduleTwo error outputs guard the two directions. overrun_err catches spending credit that was not there; credit_leak_err catches gaining credit that was never issued. The second is easy to overlook and is the more insidious — a leak inflates the sender's permission over time, so the design works perfectly and then overruns the receiver after hours of operation.
rx_buffer_free is a verification input, not a design input. A real transmitter cannot see the receiver's occupancy — that is precisely why credits exist. It is present here so the testbench can check that the credit scheme actually protected what it claims to protect, which is the difference between checking the mechanism and checking its purpose.
Simulation evidence
=== EXP4: credits -- permission before commitment ===
12 send attempts, 8 initial credits, no returns:
honouring credits : sent=8 credits=0 blocked=4
ignoring credits : sent=12 overrun_err=0
receiver now genuinely full:
honouring credits : did_send=0 overrun_err=0
ignoring credits : did_send=1 overrun_err=1 <-- data lost
4 credits returned: credits=4The credit-ignoring sender showed overrun_err=0 for the first twelve sends, and that is the important subtlety: it sent four units it had no permission for, and nothing went wrong because the receiver happened to have room. The scheme was violated and the consequence did not arrive until the receiver was genuinely full.
That gap — between violating a protocol and suffering for it — is why this defect survives testing. A bench where the receiver is fast never punishes it.
11. RTL 4 — Correct First, Then Check
Purpose
Order the two mechanisms so the cheap one runs first.
// Two mechanisms, in order, doing different jobs.
//
// A correcting code runs FIRST and fixes what it can in place -- no retry, no
// latency cost beyond its own. A detecting check runs SECOND on the result and
// catches what correction could not, which is what triggers a replay.
//
// ARCHITECTURAL TEACHING MODEL. CXL 3.0 uses PCIe 6.0 FEC and CRC at 64 GT/s,
// with a different CRC for latency-optimized flits. NO FEC code, CRC
// polynomial, correction capability or syndrome is modelled or claimed.
module fec_then_crc #(
parameter bit CRC_FIRST = 1'b0 // 1 = the wrong order
) (
input logic clk,
input logic rst_n,
input logic flit_valid,
input logic err_correctable, // within the correcting code's power
input logic err_uncorrectable, // beyond it
output logic fec_fixed,
output logic crc_failed,
output logic deliver,
output logic trigger_retry,
output logic [15:0] n_fixed_q,
output logic [15:0] n_retried_q,
output logic [15:0] n_clean_q,
output logic wasteful_retry_err // retried something FEC could fix
);
// Correct first: a correctable error is gone by the time integrity is judged.
assign fec_fixed = flit_valid && err_correctable && !CRC_FIRST;
assign post_fec_bad = CRC_FIRST ? (err_correctable || err_uncorrectable)
: err_uncorrectable;
assign crc_failed = flit_valid && post_fec_bad;
assign deliver = flit_valid && !crc_failed;
assign trigger_retry = crc_failed;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
// A replay was requested for an error the correcting code could have
// handled: full round-trip cost paid for nothing.
if (trigger_retry && err_correctable && !err_uncorrectable)
wasteful_retry_err <= 1'b1;
end
end
endmoduleSimulation evidence
=== EXP5: correct first, then check ===
clean : correct-order deliver=1 retry=0 | crc-first deliver=1 retry=0
CORRECTABLE : correct-order fixed=1 deliver=1 retry=0 | crc-first retry=1
UNCORRECTABLE : correct-order deliver=0 retry=1 | crc-first retry=1
crc-first wasteful_retry_err=1 (retried an error FEC could fix)
correct order: fixed=1 retried=2 clean=3Rows one and three are identical between the orderings. Clean units and unrecoverable units behave the same either way — the entire difference is row two, the correctable error, which the wrong order converts into a full round trip.
That matters more than one row suggests, because on a high-BER channel correctable errors are the common case. That is the whole reason a correcting code is there. So an implementation with the order reversed pays a round trip on the errors that occur most often, and its symptom is not incorrectness — it is a link that mysteriously spends a large fraction of its capacity on retransmission.
12. RTL 5 — What Recovery Cost
Purpose
Separate "how much did we send" from "how much of the wire did we use".
// Link-layer health accounting: how much of the wire went to recovery, and
// how often integrity actually failed.
//
// ARCHITECTURAL TEACHING MODEL. Real link layers expose considerably richer
// error registers and telemetry than this.
module link_health (
input logic clk,
input logic rst_n,
input logic unit_sent,
input logic unit_replayed,
input logic crc_fail,
input logic fec_fix,
input logic blocked,
output logic [15:0] n_sent_q,
output logic [15:0] n_replayed_q,
output logic [15:0] n_crcfail_q,
output logic [15:0] n_fecfix_q,
output logic [15:0] blocked_cycles_q,
output logic [15:0] max_block_run_q,
output logic [15:0] wire_units_q // sent + replayed = wire occupancy
);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
// Wire occupancy counts BOTH first transmissions and replays. A replay
// consumes the link exactly as much as an original does.
wire_units_q <= wire_units_q + {15'b0, unit_sent} + {15'b0, unit_replayed};
// ... per-event counts, and a maximum blocked run alongside the total ...
end
end
endmodulewire_units_q exists as a separate quantity from n_sent_q because they answer different questions. Sent units measure work delivered; wire units measure capacity consumed. Their ratio is the retry overhead, and a link that reports only the first will look healthy while spending a third of its capacity resending.
Simulation evidence
=== link health ===
sent=13 replayed=3 wire_units=16 crc_fail=5 fec_fix=1 blocked=5 max_block=5Sixteen units of wire time delivered thirteen units of work — an 18.75% recovery overhead in this run, entirely invisible in the sent count. Applying Section 6's formula in reverse: 13/16 = 0.8125, so the effective rate was about 81% of nominal, and every transmission counter reported success.
13. Waveform — A Rejection and Its Replay
Reject, replay, and a cumulative acknowledgement
10 cyclesFour readings.
Occupancy rises and does not fall until the acknowledgement. Every one of those five units is being held, and that holding is what makes recovery possible. A design that freed on transmission would show occupancy flat at zero and would have nothing to resend at cycle 6.
can_send drops for the entire replay. New work is blocked while old work is resent — the link is fully occupied and delivering nothing new. This is where the 1/(1+r) rate penalty physically happens, and the trace makes it concrete: three cycles of wire time producing zero new payload.
Replay is a range, not a unit. Sequences 2, 3 and 4 all go again, because the receiver rejected from sequence 2 onward. So the cost of a rejection scales with how far ahead the sender had run — which is the argument for a buffer sized to the round trip and no larger.
One acknowledgement retires five units. Cumulative acknowledgement is why the return path does not need a message per unit, and it is why a single lost acknowledgement is harmless: the next one covers everything behind it.
14. Assertions
Concurrent SVA execution: NOT SUPPORTED BY Icarus Verilog. Not executed; each property maps to its procedural stand-in and to the mutation that proves the check fires.
// SAFETY -------------------------------------------------------------------
// D1 — nothing that failed its integrity check is delivered upward.
a_no_bad_delivery: assert property (@(posedge clk) disable iff (!rst_n)
deliver |-> integrity_ok);
// D2 — the replay buffer never exceeds its depth, and never accepts when full.
a_buffer_bounded: assert property (@(posedge clk) disable iff (!rst_n)
(occupancy_q <= DEPTH) && (send && can_send |-> occupancy_q < DEPTH));
// D3 — a sender never transmits a unit it could not replay.
a_replayable: assert property (@(posedge clk) disable iff (!rst_n)
(send && can_send) |-> (occupancy_q < DEPTH));
// D4 — credits are never spent below zero, and never exceed what was issued.
a_credit_bounded: assert property (@(posedge clk) disable iff (!rst_n)
(credits_q <= INIT_CREDITS) && (did_send |-> (credits_q != 0)));
// D5 — the purpose, not the mechanism: a credited send always had room.
a_no_overrun: assert property (@(posedge clk) disable iff (!rst_n)
did_send |-> rx_buffer_free);
// D6 — CONSERVATION: wire occupancy equals originals plus replays. This is
// the relation that makes recovery cost visible, and M/N5 proves it fires.
a_wire_conserved: assert property (@(posedge clk) disable iff (!rst_n)
wire_units_q == n_sent_q + n_replayed_q);
// D7 — a correctable error never triggers a replay.
a_no_wasteful_retry: assert property (@(posedge clk) disable iff (!rst_n)
(err_correctable && !err_uncorrectable) |-> !trigger_retry);
// LIVENESS -----------------------------------------------------------------
// D8 — a replay always terminates: it catches up or the sender gives up.
// ASSUMPTION: the far side eventually acknowledges or rejects. That is a
// constraint on the environment, not on this module.
a_replay_terminates: assert property (@(posedge clk) disable iff (!rst_n)
$rose(replaying) |-> ##[1:DEPTH+1] (!replaying || give_up));
// D9 — a blocked sender eventually proceeds once credits return.
a_credits_unblock: assert property (@(posedge clk) disable iff (!rst_n)
(want_send && !may_send && credit_return) |-> ##[1:2] may_send);
// GOAL / PERFORMANCE -------------------------------------------------------
// D10 — recovery overhead stays within the design's budget.
a_retry_overhead: assert property (@(posedge clk) disable iff (!rst_n)
(n_sent_q > 100) |-> (n_replayed_q * 10 < n_sent_q));| SVA | Class | Result |
|---|---|---|
| D1 | safety | held; accepting variant flagged |
| D2, D3 | safety | stalled at 8; no overflow |
| D4 | safety | spent to 0, never below |
| D5 | safety | held; ignoring variant flagged |
| D6 | safety | conserved: 13 + 3 = 16 |
| D7 | safety | held; wrong order flagged |
| D8 | liveness | replay caught up in 3 cycles |
| D9 | liveness | unblocked |
| D10 | goal | 18.75% — exceeds a 10% budget |
D10 is a goal property and it failed the budget in this run — deliberately, because the stimulus injected a rejection into a short run. That is the right behaviour for a performance property: it is not a correctness violation, it is the design telling you it is outside its intended operating envelope. A safety property has no way to express that, and a link at 18.75% recovery overhead is a link worth investigating even though every unit was eventually delivered correctly.
15. Mutation Testing
Six deliberate mutations injected into the exact tutorial RTL, compiled and simulated; the good RTL restored and re-verified.
| # | Mutation | Detected by |
|---|---|---|
| N1 | deliver a flit that failed its check | refusing guard delivered a bad flit |
| N2 | send with no credits | credit-honouring sender overran the receiver |
| N3 | credit returned without being spent | credit-honouring sender overran the receiver |
| N4 | check integrity before correction | correct order retried a fixable error |
| N5 | replays not counted as wire occupancy | wire units not conserved |
| N6 | accept while the replay buffer is full | retry buffer overflow |
N3 is the interesting one. A credit returned without a corresponding spend is a slow leak — the sender gains permission it was never granted, a little at a time. It was caught by the overrun check rather than by a credit check, because the leak's consequence is eventually sending when there is no room. That is worth noting as a general pattern: a resource-accounting bug is often easier to catch at its consequence than at its cause, which is an argument for asserting the purpose (did_send |-> rx_buffer_free) alongside the mechanism.
16. Debug Lab
Corrupted data reaches the protocol layer and nothing reports an error
CHECK-COMPUTED-NOT-ENFORCEDassign integrity_ok = (computed_check == rx_check); // computed
assign deliver = flit_valid; // ... and not usedNothing at this layer. Corrupted payload arrives at the transaction layer and produces a wrong address, a wrong tag, or a wrong value — and the investigation starts several layers above the fault. Both receivers on identical stimulus:
intact flit : ok=1 deliver=1 retry=0
corrupted flit : ok=0 deliver=0 retry=1 | accept-on-mismatch deliver=1
accept-on-mismatch bad_delivered_err=1The check was computed and then not placed in the decision path — the same shape as Chapter 3.3's permission that was calculated and ignored. It survives review because the code visibly has an integrity check.
It survives testing for a different reason: every intact flit behaves identically between the two designs, and on a bench with a clean channel that is every flit.
Put the check in the decision, and assert the decision against the check:
assign deliver = flit_valid && integrity_ok;
a_no_bad_delivery: assert property (deliver |-> integrity_ok);Prevention. Inject corruption deliberately — a channel model that flips bits at a controlled rate, not an assumption that the channel is clean. And note the check must name integrity_ok independently; deliver |-> deliver passes on the bug.
A rejected unit cannot be resent because it was never kept
FREED-ON-TRANSMISSION// The unit is on the wire; the buffer entry is done.
if (send && can_send) begin
buf_q[seq_q % DEPTH] <= send_data;
seq_q <= seq_q + 1;
// occupancy never incremented
endThe link never stalls and never recovers. A rejection arrives and there is nothing to replay, so a transaction simply never completes — which surfaces as Chapter 3.6's host timeout, several layers away.
5 sent, none acknowledged:
keep-until-acked : occupancy=5 can_send=1
free-on-send : occupancy=0 can_send=1 <-- nothing to replay
12 sends into a depth-8 buffer:
keep-until-acked : can_send=0 occupancy=8 <-- STALLED at 8
free-on-send : can_send=1 occupancy=0 <-- ran ahead unboundedA unit's lifetime was tied to leaving the transmitter rather than to being acknowledged. This is Chapter 3.1's tag-lifetime defect in its link-layer form: the resource was released at the wrong event.
The second consequence is subtler and worse. Because occupancy never rises, can_send never falls, so the sender runs arbitrarily far ahead of what it could recover — the buffer's depth stops bounding anything, and the design has silently removed its own flow control.
Hold until acknowledged, and let occupancy gate transmission:
if (send && can_send) occupancy_q <= occupancy_q + 1;
if (rx_ack) occupancy_q <= occupancy_q - (ack_seq - oldest_q + 1);
assign can_send = (occupancy_q < DEPTH) && !replaying;Prevention. Assert (send && can_send) |-> (occupancy_q < DEPTH) and drive more sends than the buffer depth with no acknowledgements — the correct design must stall, and a design that does not stall has no replay capability regardless of what its buffer contains.
A sender overruns a receiver after hours of correct operation
CREDIT-LEAK// Return credits as the receiver frees buffers.
credits_q <= credits_q + (credit_return ? 1 : 0); // spend path lostPerfect for a long time, then data loss at the receiver. The credit count drifts upward until it exceeds anything that was ever issued, and the sender's apparent permission grows without bound.
receiver now genuinely full:
honouring credits : did_send=0 overrun_err=0
ignoring credits : did_send=1 overrun_err=1 <-- data lostTwo non-blocking writes to credits_q in one cycle — the spend and the return — with the second overwriting the first. On any cycle where a send and a return coincide, the spend is lost and a credit is created.
The failure is cumulative and rate-dependent: the more traffic, the more coincidences, the faster the leak. So it is invisible at low load and appears under exactly the conditions where the protection matters. And this is the third appearance of the same NBA hazard in this course, after Chapter 2.5's latency accumulator and Chapter 4.1's wire counter.
One assignment, both terms:
credits_q <= credits_q
- ((did_send && credits_q != 0) ? 1 : 0)
+ (credit_return ? 1 : 0);Prevention. Two assertions in opposite directions — credits_q <= INIT_CREDITS catches the leak, did_send |-> (credits_q != 0) catches the overspend. Then assert the purpose alongside the mechanism: did_send |-> rx_buffer_free. In the mutation run it was that purpose check, not the credit check, that caught the leak.
The link spends a third of its capacity retrying errors it could have fixed
INTEGRITY-CHECKED-BEFORE-CORRECTION// Check integrity on what arrived from the wire.
assign crc_failed = flit_valid && (err_correctable || err_uncorrectable);Correct data, poor throughput, and a retry rate far above what the channel's uncorrectable error rate would predict. Both orderings on identical stimulus:
clean : correct-order retry=0 | crc-first retry=0
CORRECTABLE : correct-order fixed=1 retry=0 | crc-first retry=1
UNCORRECTABLE : correct-order retry=1 | crc-first retry=1
crc-first wasteful_retry_err=1Integrity was judged before correction had run, so every correctable error was treated as a delivery failure. Clean and unrecoverable units behave identically between the two orderings — the entire difference is the correctable case.
And the correctable case is the common one, which is precisely why a correcting code exists on a high-BER channel. So the wrong order converts the most frequent error class into a full round trip, and the symptom is a link that spends a large and unexplained fraction of its capacity on retransmission while delivering correct data.
Correct first, judge the result:
assign post_fec_bad = err_uncorrectable; // correctable errors are gone
assign crc_failed = flit_valid && post_fec_bad;Prevention. Assert (err_correctable && !err_uncorrectable) |-> !trigger_retry, and drive the three error classes separately — clean, correctable, uncorrectable — rather than a single "error" stimulus that cannot distinguish them. Watch the ratio of corrected to detected counts in silicon: a link whose detected count tracks its corrected count has this bug.
Throughput counters look healthy on a link spending 19% of its wire on replay
SENT-COUNTED-NOT-WIRE-TIME// Count what we transmitted.
if (unit_sent) wire_units_q <= wire_units_q + 1; // replays not countedEvery transmission counter reports success, aggregate throughput is below expectation, and no error is logged anywhere. The conserved model shows where the capacity went:
sent=13 replayed=3 wire_units=16 crc_fail=5 fec_fix=1Wire occupancy was equated with delivered work. A replay consumes the link exactly as much as an original transmission and delivers nothing new, so a counter that ignores replays measures work and reports it as capacity.
Measured, sixteen units of wire time delivered thirteen units of work — 18.75% recovery overhead, entirely invisible in the sent count. Applying the rate formula, the effective delivery rate was about 81% of nominal on a link reporting no errors.
Count both, and assert the conservation:
wire_units_q <= wire_units_q + {15'b0, unit_sent} + {15'b0, unit_replayed};
a_wire_conserved: assert property (wire_units_q == n_sent_q + n_replayed_q);Prevention. Add a goal property with a budget — (n_sent_q > 100) |-> (n_replayed_q * 10 < n_sent_q) — so a link outside its intended recovery envelope reports itself rather than waiting to be noticed as unexplained slowness. It is not a correctness property and should not be treated as one; it is the design stating its operating assumption.
17. Design Review
On integrity. Is the check in the decision path or merely computed? Does the property name the check independently, or does it assert deliver against deliver? Are corrected and detected counted separately — because their ratio is the early warning that a channel is degrading?
On replay. When exactly is a buffer entry freed — on transmission, on acknowledgement, or on the data being consumed? Does occupancy actually gate can_send, or is the depth decorative? Is replay a range from the failure point, and does it block new sends while it catches up? Is there a retry limit, and does exceeding it escalate rather than spin? Does the retry counter reset on a successful acknowledgement, or does it accumulate over the link's lifetime?
On credits. Are spend and return in one assignment? Are both directions asserted — never below zero, never above what was issued? Is the purpose asserted alongside the mechanism, so a credited send is checked to have actually had room? Who issues the initial credits, and what happens on reset with credits outstanding?
On ordering. Does correction run before detection? Is there a check that a correctable error never triggers a replay?
On instrumentation. Is wire occupancy counted separately from delivered work? Is there a recovery-overhead budget expressed as a property? Are maxima recorded alongside totals?
And the structural question. Which upstream behaviour would change if this layer's retry became visible? If the answer is "none", the layering holds. If any upstream logic branches on a retry count, Chapter 3.5's boundary has already been breached.
18. Verification Plan
Reference model. A transmitter-side model tracking every sequence number's state — sent, acknowledged, replaying — and a receiver-side model of expected delivery order. Two conservation relations fall out: wire units equal originals plus replays, and delivered units equal sent units minus those still outstanding. The scoreboard is keyed on sequence number, because link-layer defects are relationships between a unit and its acknowledgement.
Directed tests. Intact and corrupted units; each of the three error classes separately; more sends than buffer depth with no acknowledgements; a rejection at the oldest, middle and newest outstanding sequence; a cumulative acknowledgement covering several units; a lost acknowledgement followed by a later one; credit exhaustion; a credit return coinciding with a send; retry limit exceeded; reset with units outstanding.
Constrained-random dimensions. Error injection rate and class mix, acknowledgement latency, rejection position within the outstanding window, credit-return timing relative to sends, and send duty cycle.
Functional coverage:
| Dimension | Bins |
|---|---|
| error class | clean, correctable, uncorrectable |
| buffer occupancy at send | empty, 1..D-1, full |
| rejection position | oldest, middle, newest outstanding |
| acknowledgement span | 1 unit, several, all outstanding |
| credit level at send attempt | 0, 1, 2..N-1, N |
| retry count reached | 0, 1..MAX-1, MAX |
| coincidence | send with return, send with ack, nak during replay |
The cross worth taking is error class × buffer occupancy, because a rejection with a full buffer is where replay and flow control interact. The coincidence row is the one that finds the NBA-hazard class and it is routinely omitted.
Error injection. Bit corruption at a controlled rate; a rejection during an in-progress replay; an acknowledgement for a sequence never sent; duplicate acknowledgements; and a far side that stops responding entirely.
19. How This Appears in Real Engineering
CXL / protocol architect
The Latency-Optimized flit decision lives here, and the Consortium quantified it: 2–5 ns against FIT from 5×10⁻⁸ to 0.026 and efficiency from 0.94 to 0.92. That is a deliberate weakening of integrity for latency, and it should be a decision with a stated rationale rather than a default.
RTL engineer
Five disciplines from the measured runs: put the check in the decision path; free a buffer entry on acknowledgement, never on transmission; keep spend and return in one assignment; run correction before detection; and count replays as wire time.
DV engineer
The channel model is the test. A bench with a clean channel cannot distinguish a receiver that enforces its check from one that ignores it — every intact unit behaves identically. Drive the three error classes separately, and include the coincidence cases where a send and a return land in the same cycle, because that is where the credit leak lives.
Performance engineer
Two ratios. Corrected-to-detected tells you whether the channel is degrading within or beyond the correcting code's power. Sent-to-wire tells you the recovery overhead — measured at 18.75% here, on a link whose transmission counters all reported success. Then apply effective = nominal / (1 + r) to convert it into the rate the layers above will actually see.
Firmware and system software
The link-layer error registers are the earliest warning a platform gets that a channel is degrading. A corrected count climbing with a flat detected count is a link to schedule maintenance on; a climbing detected count is a link already costing throughput.
Silicon debug / validation
The counters worth their area are corrected, detected, replayed, and blocked-cycle maximum. The first two localise the channel's condition, the third quantifies its cost, and the fourth distinguishes steady backpressure from a single long stall.
20. Common Misconceptions
21. Interview Reasoning
22. Exercises
-
Explain. Correction runs before detection. State the general principle that ordering follows, and identify one other place in this course where the same principle decided a design.
-
Calculate. A link sends one unit per cycle with a 60-cycle acknowledgement round trip. What replay-buffer depth avoids stalling? If the design ships with 16, what is the achievable rate as a fraction of nominal?
-
Calculate. A link reports a 12% replay fraction. What is the effective delivery rate? If the layer above is a boundary queue sized against nominal drain rate, what does Chapter 3.5's analysis predict it will do?
-
RTL modification. Change
retry_bufferso a rejection replays only the named sequence rather than the range from it forward. Construct the stimulus that shows this is wrong, and say which of Section 14's properties detects it. -
DV task. Write the SVA for the credit-leak property, then construct a mutation that satisfies it while still overrunning the receiver. What third property closes the gap?
-
Debug task. A link reports corrected=4000, detected=3, replayed=3, over a million units. A second link reports corrected=40, detected=800, replayed=800. Both deliver correct data. Which one would you investigate first, and what is each one telling you?
23. Summary
The data link layer is an insurance policy with a premium. It makes an unreliable channel look reliable to everything above it, and it charges wire time for the service.
Two mechanisms, in order. Correction fixes what it can with no round trip; detection catches what it cannot and asks again. Measured, reversing the order turned every correctable error into a retransmission while clean and uncorrectable units behaved identically — and correctable errors are the common case on a channel the Consortium describes as having a high BER mitigated by FEC and CRC.
The replay buffer is what makes recovery possible, and its depth is a promise. A sender may transmit only what it could resend. Measured, twelve sends into a depth-8 buffer stalled at eight; the free-on-transmission variant kept nothing, and lost its flow control as a side effect because occupancy never gated transmission. Depth must track rate × acknowledgement round trip, which is the same calculation as Chapter 3.2's outstanding table.
Credits are permission before commitment. Measured, a credit-ignoring sender transmitted four units it had no right to and nothing went wrong — until the receiver was genuinely full. The gap between violating the protocol and suffering for it is exactly why the defect survives testing.
Recovery is not free. Sixteen units of wire time delivered thirteen units of work — 18.75% overhead with every transmission counter reporting success. The effective rate is nominal / (1 + r), and only a wire-occupancy counter separate from a delivered-work counter makes it visible.
Two results carry beyond this layer. Assert the purpose, not only the mechanism — a credit leak was caught by "a credited send had room" rather than by any credit bound. And the same non-blocking-write hazard has now appeared three times in this course, in a latency accumulator, a wire counter and a credit register: any register written from two conditions in one cycle needs one assignment, and the failure is always silent and always in the flattering direction.
24. What Comes Next
This chapter delivered units intact. It said nothing about what those units mean.
Chapter 4.3 is the layer that knows: what a request is, what ordering it requires, how the three protocol classes differ in what they need from the layers below, and why the Consortium's stated target of near-CPU-cache latency for .cache and .mem shapes every decision at that level.
For adjacent material: Physical Layer has the flit structure whose CRC this chapter used, CXL Layered Architecture has the contract that keeps retry invisible upstream, and The CXL System View has the end-to-end path a replay silently lengthens. The path is on the CXL tutorials index.
Standards & specifications
- Governing standard
- CXL Specification (CXL Consortium)(opens CXL Consortium in a new tab)
Defines CXL.io, CXL.cache and CXL.mem, and the coherence and memory-pooling behaviour built on them. System design and deployment topology are not mandated.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the CXL curriculum.
