Ethernet · Module 4
The MAC/PHY Boundary in RTL
What five chapters described as one boundary is three in silicon: a data boundary at the port list, a clock boundary inside the elastic buffer, and a reset boundary that is an order rather than a place. Confusing any two produces a specific, recognisable integration failure.
Five chapters have described this boundary. Chapter 4.1 gave the ownership rule and the assumption contract. Chapter 4.2 gave the vocabulary and its generations. Chapter 4.3 gave the cycle-by-cycle sequence. Chapter 4.4 gave the clock arithmetic. Chapter 4.5 gave the second, management boundary.
All five deferred the same thing, and each said so. 4.2 built generation adapters with both sides on one clock. 4.3 put transmit and receive in a single domain so the sequence would be visible. 4.4 maintained a fill count in one domain, stated that a real dual-clock FIFO cannot, and made the reason its rejected property. 4.5 clocked a slave directly from MDC.
Every one of those was a simplification with the same shape: pretend there is one clock.
There is not, and when the simplification is removed something becomes visible that none of the five chapters could show. The boundary they described is not one boundary. It is three, and they are drawn in different places.
Where does each of the three actually sit, and what goes wrong when they are confused for one another?
1. Scope — What This Chapter Owns
This chapter owns: the three boundaries and where each sits; the clock-domain crossing that Chapters 4.2 through 4.5 all deferred, including pointer encoding and synchroniser depth; the reset boundary and its ordered release; elaboration-time configuration checking; and the integration wrapper that makes a boundary reusable rather than re-derived per project.
This chapter does not own and does not restate: the ownership rule (4.1), the vocabulary and generations (4.2), the data-flow sequence (4.3), the compensation arithmetic (4.4), or the management protocol (4.5). It assembles them. Where an earlier chapter's model made a single-clock simplification, this chapter names it and replaces it — that is the whole of its contribution.
The debts it repays, each stated by the chapter that incurred it:
| Chapter | What it deferred |
|---|---|
| 4.2 §6 | asynchronous width conversion |
| 4.3 §5 | the crossing between MAC and PHY clocks |
| 4.4 §6 | pointer synchronisation, and why its fill count was a simplification |
| 4.5 §5 | synchronising MDC into a system domain |
2. Three Boundaries, Three Locations
Read the top row against the middle one. The contract of Chapter 4.1 applies at the port list. Metastability lives one block lower, inside the elastic buffer. A conformance checker placed at the port list — which is exactly where Chapter 4.1 §7 put one, correctly — cannot see a crossing failure, because at the port list there is no crossing.
That is not a flaw in either chapter. It is the consequence of the two boundaries being in different places, and it means two different kinds of check are needed in two different locations.
3. RTL 1 — The Complete Port List
Chapter 4.1 §5 gave a port list annotated with ownership and assumptions. This is the same boundary as a buildable module: parameterised, with the clock and reset structure explicit.
// SYNTHESIZABLE. The boundary as a buildable module.
//
// Every data signal here was justified by an earlier chapter. What this adds
// is what all of them omitted: THREE CLOCKS, TWO RESET DOMAINS, and the
// parameters that let one module serve several interface generations.
//
// The clock structure is the part worth studying, because the naming
// carries the architecture:
//
// clk_mac -- the local oscillator. The MAC and the port list live here.
// clk_rx -- RECOVERED from the incoming signal. Runs at the FAR END's
// rate, which differs from clk_mac permanently (Chapter 4.4).
// clk_mdc -- the management clock, asynchronous to both and 50x slower
// (Chapter 4.5).
//
// Three domains, and the crossings between them are Sections 4 and 5.
package boundary_pkg;
typedef enum logic [1:0] {
GEN_MII = 2'd0, // 4 bits, SDR
GEN_GMII = 2'd1, // 8 bits, SDR
GEN_RGMII = 2'd2, // 4 bits, DDR
GEN_XGMII = 2'd3 // 32 bits, DDR
} generation_e;
// Data width per generation. Chapter 4.2 §6 showed a width change is a
// RATE change, so this parameter has consequences well beyond a bus size.
function automatic int unsigned gen_width(input generation_e g);
case (g)
GEN_MII: gen_width = 4;
GEN_GMII: gen_width = 8;
GEN_RGMII: gen_width = 4;
default: gen_width = 32;
endcase
endfunction
function automatic bit gen_is_ddr(input generation_e g);
gen_is_ddr = (g == GEN_RGMII) || (g == GEN_XGMII);
endfunction
endpackage
module mac_phy_boundary_rtl
import boundary_pkg::*;
#(
parameter generation_e GENERATION = GEN_GMII,
parameter int unsigned IF_W = gen_width(GENERATION),
parameter bit IS_DDR = gen_is_ddr(GENERATION),
// Elastic buffer depth. Chapter 4.4 §5 derives it; passing it in keeps
// that derivation in one place rather than duplicated here.
parameter int unsigned ELASTIC_DEPTH = 16,
// Synchroniser stages. Two is the usual minimum; more for higher clock
// ratios or tighter reliability targets. Section 5 develops the trade.
parameter int unsigned SYNC_STAGES = 2
) (
// ── Clock domain 1: the local oscillator ────────────────────────────────
input logic clk_mac,
input logic rst_mac_n, // released synchronously to clk_mac
// ── Clock domain 2: recovered from the incoming signal ──────────────────
input logic clk_rx,
input logic rst_rx_n, // released synchronously to clk_rx
// ── Clock domain 3: management, asynchronous to both ────────────────────
input logic clk_mdc,
input logic rst_mdc_n,
// ── Data boundary, entirely in clk_mac. Chapters 4.1 through 4.3. ───────
output logic [IF_W-1:0] txd,
output logic tx_en,
output logic tx_er,
input logic tx_accept,
input logic [IF_W-1:0] rxd,
input logic rx_dv,
input logic rx_er,
// ── Status, in clk_mac after synchronisation. Chapter 3.4 §13. ──────────
output logic link_up,
output logic [3:0] link_status_vector,
// ── Management boundary, in clk_mdc. Chapter 4.5. ───────────────────────
inout wire mdio,
output logic mdc,
// ── Integration observability ───────────────────────────────────────────
// Exposed at the top level because an integration fault is invisible from
// inside either block -- Chapter 4.1 §13's argument, applied to the
// structural boundaries rather than the contractual one.
output logic cdc_fault,
output logic reset_order_fault,
output logic [15:0] c_elastic_slip
);
// The body is Sections 4 through 7. What matters here is that the three
// clocks and two data resets appear in the PORT LIST rather than being
// derived internally -- an integrator must be able to see, from the
// interface alone, how many domains this module contains.
//
// A boundary module that hides its clock structure forces every
// integrator to read its source to find out what they are connecting.
endmoduleClassification: synthesizable, and deliberately a declaration.
What it teaches: that a boundary module must expose its clock and reset structure in its port list. Three clocks and three resets appear as ports rather than being derived internally, because an integrator has to know how many domains they are connecting from the interface alone. A module that hides this forces everyone who instantiates it to read its source.
Deliberately simplified: the body is Sections 4 through 7 and the DDR handling is a parameter rather than an implementation — a real DDR interface needs explicit double-edge capture, which is a physical-design matter.
Production implication: deriving IF_W and IS_DDR from GENERATION rather than passing them independently removes an entire class of misconfiguration. A design where width and signalling mode are separate parameters can be given a combination that does not exist — 8 bits with RGMII's DDR, say — and it will elaborate, simulate, and fail in hardware. Derive what can be derived, and Section 6 checks what cannot.
4. RTL 2 — The Clock-Domain Crossing
The thing four chapters deferred. Chapter 4.4 §6 maintained a fill count in one domain and said plainly that a real dual-clock FIFO cannot — this is what it has to do instead.
// SYNTHESIZABLE. The dual-clock elastic buffer, properly.
//
// WHY GRAY CODING. A binary counter crossing a domain can be sampled
// mid-transition, and a binary counter changes SEVERAL bits at once:
// 0111 -> 1000 changes four. Sample during that and you can read any of
// sixteen values, including ones the counter never held.
//
// A Gray code changes exactly ONE bit per increment. Sampling mid-
// transition therefore yields either the old value or the new one -- never
// a value that never existed. That is the entire trick, and it is why
// pointers cross Gray-coded and are converted back after synchronisation.
//
// WHY TWO FILL COUNTS. Chapter 4.4 §11 rejected the property asserting a
// single shared fill, because there is no instant at which both pointers
// are simultaneously valid. The correct construction is a fill count PER
// DOMAIN, each conservative in the direction that matters there:
//
// the WRITE side must never overflow, so it must not UNDERESTIMATE fill.
// Its view of the read pointer is delayed, so it sees fewer reads than
// have happened, so its fill estimate is HIGH. Conservative. Correct.
//
// the READ side must never underflow, so it must not OVERESTIMATE fill.
// Its view of the write pointer is delayed, so it sees fewer writes,
// so its estimate is LOW. Conservative. Correct.
//
// The two numbers DIFFER, both are right, and neither is "the" fill.
module async_elastic_fifo #(
parameter int unsigned W = 8,
parameter int unsigned DEPTH = 16,
parameter int unsigned PTR_W = $clog2(DEPTH),
parameter int unsigned SYNC_STAGES = 2
) (
// ── Write domain: the recovered clock ───────────────────────────────────
input logic clk_wr,
input logic rst_wr_n,
input logic wr_en,
input logic [W-1:0] wr_data,
output logic wr_full,
output logic [PTR_W:0] wr_side_fill, // conservative HIGH
// ── Read domain: the local clock ────────────────────────────────────────
input logic clk_rd,
input logic rst_rd_n,
input logic rd_en,
output logic [W-1:0] rd_data,
output logic rd_empty,
output logic [PTR_W:0] rd_side_fill, // conservative LOW
// Overflow and underflow, each detected in the domain that can see it.
output logic overflow,
output logic underflow
);
logic [W-1:0] mem [DEPTH];
// Binary and Gray pointers. Binary for indexing; Gray for crossing.
logic [PTR_W:0] wbin_q, wgray_q;
logic [PTR_W:0] rbin_q, rgray_q;
// Synchronised copies. Note each is a copy of the OTHER domain's pointer,
// delayed by SYNC_STAGES cycles of the receiving clock.
logic [PTR_W:0] rgray_in_wr [SYNC_STAGES];
logic [PTR_W:0] wgray_in_rd [SYNC_STAGES];
function automatic logic [PTR_W:0] bin2gray(input logic [PTR_W:0] b);
bin2gray = b ^ (b >> 1);
endfunction
function automatic logic [PTR_W:0] gray2bin(input logic [PTR_W:0] g);
gray2bin = g;
for (int i = PTR_W; i > 0; i--) gray2bin[i-1] = gray2bin[i] ^ g[i-1];
endfunction
// ── Write domain ────────────────────────────────────────────────────────
logic [PTR_W:0] rbin_in_wr_c;
assign rbin_in_wr_c = gray2bin(rgray_in_wr[SYNC_STAGES-1]);
always_ff @(posedge clk_wr or negedge rst_wr_n) begin
if (!rst_wr_n) begin
wbin_q <= '0;
wgray_q <= '0;
for (int i = 0; i < SYNC_STAGES; i++) rgray_in_wr[i] <= '0;
overflow <= 1'b0;
end else begin
// The synchroniser chain. Its depth is the reliability knob: each
// stage gives a metastable event another full clock period to
// resolve, and the mean time between failures rises exponentially
// with stages.
rgray_in_wr[0] <= rgray_q;
for (int i = 1; i < SYNC_STAGES; i++) rgray_in_wr[i] <= rgray_in_wr[i-1];
if (wr_en && !wr_full) begin
mem[wbin_q[PTR_W-1:0]] <= wr_data;
wbin_q <= wbin_q + 1'b1;
wgray_q <= bin2gray(wbin_q + 1'b1);
end
overflow <= wr_en && wr_full;
end
end
// Full when the write pointer has wrapped onto the (delayed) read
// pointer. The delay makes this EARLY, which is the safe direction.
assign wr_side_fill = wbin_q - rbin_in_wr_c;
assign wr_full = (wr_side_fill >= (PTR_W+1)'(DEPTH));
// ── Read domain ─────────────────────────────────────────────────────────
logic [PTR_W:0] wbin_in_rd_c;
assign wbin_in_rd_c = gray2bin(wgray_in_rd[SYNC_STAGES-1]);
always_ff @(posedge clk_rd or negedge rst_rd_n) begin
if (!rst_rd_n) begin
rbin_q <= '0;
rgray_q <= '0;
for (int i = 0; i < SYNC_STAGES; i++) wgray_in_rd[i] <= '0;
underflow <= 1'b0;
end else begin
wgray_in_rd[0] <= wgray_q;
for (int i = 1; i < SYNC_STAGES; i++) wgray_in_rd[i] <= wgray_in_rd[i-1];
if (rd_en && !rd_empty) begin
rbin_q <= rbin_q + 1'b1;
rgray_q <= bin2gray(rbin_q + 1'b1);
end
underflow <= rd_en && rd_empty;
end
end
assign rd_data = mem[rbin_q[PTR_W-1:0]];
assign rd_side_fill = wbin_in_rd_c - rbin_q;
assign rd_empty = (rd_side_fill == '0);
endmoduleConceptual — why the two fill views differ
8 cyclesClassification: synthesizable.
What it teaches: that there is no single fill count, and the two that exist are both correct. The write side's estimate is high because its view of the read pointer is stale; the read side's is low for the mirror reason. Each is conservative in the direction that matters where it lives — the write side must not overflow, the read side must not underflow — and asking which is "the real fill" is the question Chapter 4.4 §11 showed has no answer.
It also teaches why Gray coding is not an optimisation. A binary counter changes several bits at once — 0111 to 1000 changes four — and sampling mid-transition can yield a value the counter never held. A Gray code changes one bit per increment, so a mid-transition sample yields either the old value or the new one. That is the entire trick.
Deliberately simplified: no reset synchronisation between domains, which Section 5 owns, and no almost-full or almost-empty thresholds, which real designs need because the delayed pointer views make exact thresholds meaningless.
Production implication: SYNC_STAGES is a reliability parameter, not a latency one. Each stage gives a metastable event another full clock period to resolve, and the mean time between failures rises exponentially with stages. Two is the usual minimum; higher clock ratios and tighter targets want three. The cost is latency in the pointer view, which makes both fill estimates more conservative — so more stages means a slightly less efficient buffer, and that is the trade rather than area.
And the consequence for Chapter 4.4's compensation: rd_side_fill is what the idle insert and delete logic must use, because it lives in the read domain. Using the write side's number there would be reading a value from the wrong clock — the very error 4.4's rejected property was about.
5. RTL 3 — The Reset Boundary Is an Order, Not a Place
// SYNTHESIZABLE. Reset for three domains, with an ordered release.
//
// TWO RULES, and the second is the one that gets missed.
//
// RULE 1 -- per domain: assert ASYNCHRONOUSLY, release SYNCHRONOUSLY.
// Asynchronous assertion works with no clock, which matters because a
// clock may not be running at power-on. Synchronous release avoids a
// release that violates recovery time on some flops and not others,
// leaving a domain half in reset.
//
// RULE 2 -- across domains: release in a defined ORDER.
// A CONSUMER must be out of reset before its PRODUCER, or the producer
// emits into a block that is not listening and the data is silently lost.
// Here the read side of the elastic buffer must be ready before the write
// side starts filling it.
//
// Rule 1 is well known and usually implemented. Rule 2 is where designs
// fail, because each domain's reset is individually correct.
module boundary_reset_ctrl #(
parameter int unsigned SYNC_STAGES = 3,
// Cycles to hold each domain after the one before it is released.
parameter int unsigned STAGGER = 16,
parameter int unsigned STAG_W = $clog2(STAGGER + 1)
) (
input logic clk_mac,
input logic clk_rx,
input logic clk_mdc,
// One asynchronous reset in, from a pin or a power-on circuit.
input logic rst_async_n,
// Per-domain resets out, each released synchronously to its own clock.
output logic rst_mac_n,
output logic rst_rx_n,
output logic rst_mdc_n,
// A domain observed active while a domain it depends on was still in
// reset. This is rule 2 broken, and it is otherwise invisible -- the
// symptom is data lost at start-up, which looks like a link that comes
// up slowly.
output logic reset_order_fault
);
// ── Rule 1, three times. Asynchronous assert, synchronous release. ──────
logic [SYNC_STAGES-1:0] mac_sync_q, rx_sync_q, mdc_sync_q;
// The READ side of the elastic buffer lives in clk_mac, so it is released
// FIRST -- rule 2. Its reset takes the raw asynchronous input directly.
always_ff @(posedge clk_mac or negedge rst_async_n) begin
if (!rst_async_n) mac_sync_q <= '0;
else mac_sync_q <= {mac_sync_q[SYNC_STAGES-2:0], 1'b1};
end
assign rst_mac_n = mac_sync_q[SYNC_STAGES-1];
// The WRITE side is held additionally until the read side has been out of
// reset for STAGGER cycles. A write into a buffer whose read side is
// still resetting is silently lost.
logic [STAG_W-1:0] stagger_q;
logic mac_ready_q;
always_ff @(posedge clk_mac or negedge rst_async_n) begin
if (!rst_async_n) begin
stagger_q <= '0;
mac_ready_q <= 1'b0;
end else if (rst_mac_n) begin
if (stagger_q == STAG_W'(STAGGER)) mac_ready_q <= 1'b1;
else stagger_q <= stagger_q + 1'b1;
end
end
// Cross mac_ready_q into the rx domain before releasing it. The
// synchroniser here is doing double duty: it is both the release
// synchroniser of rule 1 and the ordering mechanism of rule 2.
always_ff @(posedge clk_rx or negedge rst_async_n) begin
if (!rst_async_n) rx_sync_q <= '0;
else rx_sync_q <= {rx_sync_q[SYNC_STAGES-2:0], mac_ready_q};
end
assign rst_rx_n = rx_sync_q[SYNC_STAGES-1];
// Management is independent of both and may be released whenever. It is
// deliberately NOT ordered against the datapath -- Chapter 4.5 §2 showed
// the two boundaries have different contracts, and coupling their resets
// would make a management access wait on a link that may never come up.
always_ff @(posedge clk_mdc or negedge rst_async_n) begin
if (!rst_async_n) mdc_sync_q <= '0;
else mdc_sync_q <= {mdc_sync_q[SYNC_STAGES-2:0], 1'b1};
end
assign rst_mdc_n = mdc_sync_q[SYNC_STAGES-1];
// ── Ordering violation detection ────────────────────────────────────────
// The rx domain running while the mac domain is still in reset means the
// write side is filling a buffer nobody is draining.
always_ff @(posedge clk_rx or negedge rst_async_n) begin
if (!rst_async_n) reset_order_fault <= 1'b0;
else if (rst_rx_n && !rx_sync_q[0]) reset_order_fault <= 1'b1;
end
endmoduleClassification: synthesizable.
What it teaches: that reset ordering is a dependency graph, not a broadcast. A consumer must be out of reset before its producer, or the producer emits into a block that is not listening. Here the elastic buffer's read side — in the MAC domain — must be ready before the write side begins filling it.
And that the management domain is deliberately not ordered against the datapath. Chapter 4.5 §2 showed the two boundaries have different contracts, and coupling their resets would make a management access wait on a link that may never come up — which is exactly when you most need management access.
Deliberately simplified: a fixed stagger rather than a handshake. A production design usually waits for an explicit ready from the consumer rather than counting cycles, because a cycle count is a guess that a clock-ratio change invalidates.
Production implication: the failure from getting rule 2 wrong is data lost at start-up, which presents as a link that comes up slowly or drops its first frames. It is not reported as a reset fault by anything, because each domain's reset was individually correct — which is why reset_order_fault has to be built deliberately, and why it is exposed at the top level of Section 3's port list.
6. RTL 4 — Elaboration Is a Verification Opportunity
A parameter mismatch caught at elaboration costs seconds. The same mismatch found in simulation costs hours. Found in silicon, months. Most designs check nothing at elaboration.
// SYNTHESIZABLE -- and it generates NO LOGIC.
//
// Every check is evaluated at elaboration. A failure stops the build with a
// message naming the two parameters that disagree.
//
// THE ECONOMICS, which is the whole argument:
// caught at elaboration -- seconds, and the message names the cause
// caught in simulation -- hours, and the symptom is corrupted data
// caught in silicon -- months, and a respin
//
// A configuration error is STATIC. It cannot be caught by a runtime
// assertion in any useful sense, because a design with mismatched
// parameters usually will not function well enough to reach the assertion.
// Section 9's rejected property is exactly that mistake.
module boundary_config_check
import boundary_pkg::*;
#(
parameter generation_e GENERATION = GEN_GMII,
parameter int unsigned IF_W = gen_width(GENERATION),
parameter bit IS_DDR = gen_is_ddr(GENERATION),
parameter int unsigned MAC_W = 8,
parameter int unsigned ELASTIC_DEPTH = 16,
parameter int unsigned SYNC_STAGES = 2,
parameter int unsigned CLK_MAC_MHZ = 125,
parameter int unsigned CLK_RX_MHZ = 125,
parameter int unsigned MDC_KHZ = 2500,
// From Chapter 4.4's derivation.
parameter int unsigned PPM_TOTAL = 200,
parameter int unsigned MAX_FRAME = 1518
) ();
// ── Check 1: the width is the generation's width ────────────────────────
// Catches an independently-passed IF_W disagreeing with GENERATION, which
// elaborates, simulates on a matched testbench, and fails in hardware.
if (IF_W != gen_width(GENERATION))
$error("IF_W=%0d does not match GENERATION width %0d",
IF_W, gen_width(GENERATION));
if (IS_DDR != gen_is_ddr(GENERATION))
$error("IS_DDR=%0b does not match GENERATION signalling", IS_DDR);
// ── Check 2: the MAC width is a whole multiple of the interface ─────────
// Chapter 4.2 §6 showed a width change is a RATE change. A non-integer
// ratio means an octet spans a fractional number of interface cycles,
// which no adapter can do.
if ((MAC_W % IF_W) != 0)
$error("MAC_W=%0d is not a whole multiple of IF_W=%0d -- no adapter ratio exists",
MAC_W, IF_W);
// ── Check 3: the elastic buffer is deep enough ─────────────────────────
// Chapter 4.4 §5's arithmetic, enforced. Catches a depth chosen by habit
// rather than derived, which works until someone enables jumbo frames.
localparam int unsigned SLIP_OCTETS =
((MAX_FRAME * PPM_TOTAL) + 999_999) / 1_000_000;
localparam int unsigned MIN_DEPTH = (2 * SLIP_OCTETS) + 2;
if (ELASTIC_DEPTH < MIN_DEPTH)
$error("ELASTIC_DEPTH=%0d below the %0d required for MAX_FRAME=%0d at %0d ppm",
ELASTIC_DEPTH, MIN_DEPTH, MAX_FRAME, PPM_TOTAL);
// ── Check 4: the buffer depth is a power of two ─────────────────────────
// The Gray pointer arithmetic of Section 4 requires it. A non-power-of-two
// depth makes the wrap comparison wrong in a way that only shows at the
// wrap point -- so it passes short simulations and fails under load.
if ((ELASTIC_DEPTH & (ELASTIC_DEPTH - 1)) != 0)
$error("ELASTIC_DEPTH=%0d must be a power of two for Gray pointer wrap",
ELASTIC_DEPTH);
// ── Check 5: synchroniser depth is at least two ────────────────────────
// One stage is not a synchroniser. Catches a well-meaning latency
// optimisation that removes the reliability the structure exists for.
if (SYNC_STAGES < 2)
$error("SYNC_STAGES=%0d -- a single stage is not a synchroniser", SYNC_STAGES);
// ── Check 6: MDC is within its specified maximum ───────────────────────
// Chapter 4.5 §2: MDC is specified up to 2.5 MHz. Catches a divider
// computed for the wrong system clock, which produces a bus no device
// answers -- and which looks exactly like an absent device.
if (MDC_KHZ > 2500)
$error("MDC_KHZ=%0d exceeds the specified 2500 kHz maximum", MDC_KHZ);
// ── Check 7: the clock ratio is plausible for the generation ───────────
// A DDR generation carries two symbols per clock, so its clock is half
// what an SDR generation of the same throughput needs. Catches an SDR
// clock left in place after a switch to a DDR generation.
if (IS_DDR && (CLK_MAC_MHZ > (2 * CLK_RX_MHZ)))
$error("DDR generation with CLK_MAC_MHZ=%0d against CLK_RX_MHZ=%0d -- ratio implausible",
CLK_MAC_MHZ, CLK_RX_MHZ);
endmoduleClassification: synthesizable, generating no logic.
What it teaches: that configuration errors are static and belong to elaboration. A parameter mismatch is knowable before a single cycle is simulated, and catching it there costs a build message rather than a debugging session.
Deliberately simplified: $error in an elaboration context, which is the portable form. Some tools prefer $fatal or an assertion in a generate block, and the choice is tool-dependent rather than architectural.
Production implication: check 4 is the one that catches the most painful bug. A non-power-of-two buffer depth makes the Gray pointer wrap comparison wrong — and only at the wrap point, which a short simulation never reaches. The design passes every directed test, passes regression, and fails under sustained load in hardware. Six lines at elaboration prevent it entirely.
And check 6 is the one that produces the most confusing symptom. An MDC divider computed for the wrong system clock produces a bus running too fast for any device to answer — which Chapter 4.5 §8 showed is indistinguishable from every device being absent. An integrator sees thirty-two silent addresses and checks the device map.
7. RTL 5 — The Integration Wrapper
// SYNTHESIZABLE. The assembly.
//
// No new function. It wires the pieces together and hoists every fault
// indication to one place, because an INTEGRATION fault is visible from
// neither block:
//
// the MAC sees data arriving corrupted and blames the PHY
// the PHY sees data leaving correctly and blames the MAC
// the crossing between them is in neither, and reports to neither
//
// That is Chapter 4.1 §13's attribution argument, applied to the structural
// boundaries rather than the contractual one -- and it needs the same
// answer: an observer that spans both.
module mac_phy_integration
import boundary_pkg::*;
#(
parameter generation_e GENERATION = GEN_GMII,
parameter int unsigned ELASTIC_DEPTH = 16,
parameter int unsigned SYNC_STAGES = 2,
parameter int unsigned CNT_W = 20
) (
input logic clk_mac,
input logic clk_rx,
input logic clk_mdc,
input logic rst_async_n,
// ── One place to read every structural fault ────────────────────────────
output logic cdc_overflow,
output logic cdc_underflow,
output logic reset_order_fault,
output logic [CNT_W-1:0] c_cdc_overflow,
output logic [CNT_W-1:0] c_cdc_underflow,
// The two fill views, both exposed. A difference much larger than
// SYNC_STAGES means the synchronisers are not behaving -- which is the
// only externally visible symptom of a marginal crossing.
output logic [7:0] wr_side_fill,
output logic [7:0] rd_side_fill,
output logic [7:0] fill_view_skew,
// First structural fault since reset, held. Ordering is the information
// that survives when everything else has recovered.
output logic [1:0] first_fault,
output logic first_fault_valid
);
logic rst_mac_n, rst_rx_n, rst_mdc_n;
boundary_reset_ctrl #(.SYNC_STAGES(3)) u_rst (
.clk_mac, .clk_rx, .clk_mdc, .rst_async_n,
.rst_mac_n, .rst_rx_n, .rst_mdc_n,
.reset_order_fault
);
boundary_config_check #(
.GENERATION(GENERATION),
.ELASTIC_DEPTH(ELASTIC_DEPTH),
.SYNC_STAGES(SYNC_STAGES)
) u_cfg ();
// The elastic buffer instance would go here, driving cdc_overflow and
// cdc_underflow and the two fill views.
// The skew between the two fill views is bounded by the synchroniser
// latency plus whatever crossed during it. Much larger means the
// synchronisers are not resolving, which is a marginal crossing -- and it
// is the only symptom visible from outside.
assign fill_view_skew = (wr_side_fill > rd_side_fill)
? (wr_side_fill - rd_side_fill)
: (rd_side_fill - wr_side_fill);
always_ff @(posedge clk_mac or negedge rst_mac_n) begin
if (!rst_mac_n) begin
c_cdc_overflow <= '0;
c_cdc_underflow <= '0;
first_fault <= '0;
first_fault_valid <= 1'b0;
end else begin
if (cdc_overflow && !(&c_cdc_overflow)) c_cdc_overflow <= c_cdc_overflow + 1'b1;
if (cdc_underflow && !(&c_cdc_underflow)) c_cdc_underflow <= c_cdc_underflow + 1'b1;
if (!first_fault_valid) begin
if (reset_order_fault) begin first_fault <= 2'd0; first_fault_valid <= 1'b1; end
else if (cdc_overflow) begin first_fault <= 2'd1; first_fault_valid <= 1'b1; end
else if (cdc_underflow) begin first_fault <= 2'd2; first_fault_valid <= 1'b1; end
end
end
end
endmoduleClassification: synthesizable.
What it teaches: that an integration fault is visible from neither block. The MAC sees corrupted data and blames the PHY; the PHY sees correct data leaving and blames the MAC; the crossing is in neither and reports to neither. That needs an observer spanning both, which is exactly the argument Chapter 4.1 §13 made for contract attribution — reappearing here for structure.
Deliberately simplified: the elastic buffer instantiation is elided to keep the wiring visible.
Production implication: fill_view_skew is the only externally visible symptom of a marginal crossing. The two fill views should differ by roughly the synchroniser latency plus whatever crossed during it — a small, bounded number. A skew much larger than that means the synchronisers are not resolving cleanly, which is metastability, and it is otherwise undetectable from outside: the data corruption it causes looks random and has no other signature.
And first_fault is ordered deliberately with reset_order_fault first. A reset-ordering fault at start-up causes overflows and underflows afterwards, so reporting the overflow as the first fault would name a consequence. Ordering is the information that survives when everything else has recovered.
8. RTL 6 — The Management Domain, and Why It Is the Easy One
Chapter 4.5 §5 clocked its slave directly from MDC and deferred synchronising it here. Section 1's table lists that debt, and this pays it — but the interesting part is why this crossing is far easier than Section 4's, because the reason generalises.
// SYNTHESIZABLE. Bringing MDC into the system clock domain.
//
// WHY THIS CROSSING IS EASY AND SECTION 4'S IS NOT.
//
// Section 4 crosses a MULTI-BIT pointer between two clocks of SIMILAR rate.
// Multi-bit means several signals must be sampled as a coherent group, and
// similar rates mean a transition can land anywhere relative to the
// sampling edge. Hence Gray coding, hence matched synchronisers.
//
// This crosses a SINGLE-BIT clock signal at a ratio of FIFTY OR MORE. Two
// consequences follow, and both remove a hazard rather than mitigating it:
//
// SINGLE BIT -- there is no group to keep coherent, so there is nothing
// for Gray coding to solve.
// HUGE RATIO -- every MDC level persists for fifty-plus system cycles,
// so a synchroniser has many cycles to resolve and the
// sampled edge is unambiguous.
//
// The general rule worth carrying: a crossing's difficulty is set by the
// WIDTH of what crosses and the RATIO of the clocks -- not by the fact that
// there is a crossing. Applying Section 4's machinery here would be
// correct, expensive and pointless.
module mdc_domain_sync #(
parameter int unsigned SYNC_STAGES = 2,
// Minimum system cycles an MDC level must hold to be believed. Rejects a
// glitch on a line that is often long and unterminated.
parameter int unsigned DEBOUNCE = 4,
parameter int unsigned DEB_W = $clog2(DEBOUNCE + 1),
parameter int unsigned CNT_W = 16
) (
input logic clk_sys,
input logic rst_sys_n,
// Raw pins, asynchronous to clk_sys.
input logic mdc_pin,
input logic mdio_pin,
// In the system domain: an edge pulse rather than a clock. The management
// logic then runs entirely on clk_sys and treats MDC as data, which is
// what Chapter 4.5 §5's slave should have done.
output logic mdc_rising,
output logic mdc_falling,
output logic mdio_sampled,
// An MDC period shorter than the specified minimum. Chapter 4.5 §2 gives
// 2.5 MHz as the maximum, so a system clock of 125 MHz should see at
// least fifty cycles per half period. Far fewer means the master's
// divider is wrong -- and Chapter 4.5 §8 showed that looks exactly like
// every device being absent.
output logic mdc_too_fast,
output logic [CNT_W-1:0] c_mdc_too_fast,
// Glitches rejected by the debounce. A long unterminated management line
// picks these up, and a design that clocks directly from the pin will
// shift a frame by a bit.
output logic [CNT_W-1:0] c_glitches_rejected
);
logic [SYNC_STAGES-1:0] mdc_sync_q, mdio_sync_q;
logic mdc_stable_q;
logic [DEB_W-1:0] deb_q;
logic [CNT_W-1:0] half_period_q;
// Minimum system cycles per MDC half period, from the 2.5 MHz maximum.
localparam int unsigned MIN_HALF = 20;
always_ff @(posedge clk_sys or negedge rst_sys_n) begin
if (!rst_sys_n) begin
mdc_sync_q <= '0;
mdio_sync_q <= '0;
mdc_stable_q <= 1'b0;
deb_q <= '0;
half_period_q <= '0;
mdc_rising <= 1'b0;
mdc_falling <= 1'b0;
mdc_too_fast <= 1'b0;
c_mdc_too_fast <= '0;
c_glitches_rejected <= '0;
end else begin
mdc_rising <= 1'b0;
mdc_falling <= 1'b0;
mdc_too_fast <= 1'b0;
// Plain synchronisers. Single-bit, so no coherence problem exists.
mdc_sync_q <= {mdc_sync_q[SYNC_STAGES-2:0], mdc_pin};
mdio_sync_q <= {mdio_sync_q[SYNC_STAGES-2:0], mdio_pin};
// Debounce: a level must hold for DEBOUNCE cycles to be believed.
// Cheap here precisely because the ratio is so large -- four cycles
// out of fifty costs nothing.
if (mdc_sync_q[SYNC_STAGES-1] != mdc_stable_q) begin
if (deb_q == DEB_W'(DEBOUNCE)) begin
mdc_stable_q <= mdc_sync_q[SYNC_STAGES-1];
deb_q <= '0;
if (mdc_sync_q[SYNC_STAGES-1]) mdc_rising <= 1'b1;
else mdc_falling <= 1'b1;
// A half period shorter than the specification allows.
if (half_period_q < CNT_W'(MIN_HALF)) begin
mdc_too_fast <= 1'b1;
if (!(&c_mdc_too_fast)) c_mdc_too_fast <= c_mdc_too_fast + 1'b1;
end
half_period_q <= '0;
end else begin
deb_q <= deb_q + 1'b1;
end
end else begin
// The level went back before the debounce completed: a glitch.
if ((deb_q != '0) && !(&c_glitches_rejected))
c_glitches_rejected <= c_glitches_rejected + 1'b1;
deb_q <= '0;
if (!(&half_period_q)) half_period_q <= half_period_q + 1'b1;
end
// MDIO is sampled on the rising edge, per Chapter 4.5's frame.
if (mdc_rising) mdio_sampled <= mdio_sync_q[SYNC_STAGES-1];
end
end
endmoduleClassification: synthesizable.
What it teaches: that a crossing's difficulty is set by the width of what crosses and the ratio of the clocks, not by the existence of a crossing. Section 4 needed Gray coding because a multi-bit pointer must stay coherent, and matched synchronisers because the two clocks are of similar rate. Here a single bit crosses at a ratio of fifty or more, so neither hazard exists — and applying Section 4's machinery would be correct, expensive and pointless.
And it converts MDC from a clock into data. Chapter 4.5 §5's slave clocked directly from the pin, which is common and fragile: a glitch on a long unterminated line shifts the entire frame by one bit. Running the management logic on the system clock and treating MDC as an edge pulse removes that class of fault entirely.
Deliberately simplified: a fixed debounce and a single minimum-period check. Production designs derive both from the actual system clock frequency rather than hard-coding cycle counts.
Production implication: c_mdc_too_fast catches an integration fault whose symptom is badly misleading. A master divider computed for the wrong system clock produces an MDC above 2.5 MHz that no device answers — and Chapter 4.5 §8 showed that is indistinguishable from every device being absent. An integrator sees thirty-two silent addresses and audits the device map. This counter names the divider instead, and Section 6's check 6 catches the same fault at elaboration, which is better still.
9. Assertions
Every property below is a property of these teaching models and of standard clock-domain-crossing practice. IEEE 802.3 specifies the interface signals and timing per clause; synchroniser depth, reset ordering and elaboration checks are implementation choices — and Section 6 argued that the last of those should not be assertions at all.
// ─── Safety: pointers cross Gray-coded ─────────────────────────────────────
// Catches a binary pointer crossing directly, which can be sampled
// mid-transition and yield a value the counter never held. The corruption
// that follows is rare, random-looking, and has no other signature.
property p_pointer_gray_coded;
@(posedge clk_wr) disable iff (!rst_wr_n)
$changed(wgray_q) |-> ($countones(wgray_q ^ $past(wgray_q)) == 1);
endproperty
// ─── Safety: the synchroniser chain is not bypassed ────────────────────────
// Catches a "latency optimisation" that reads the first stage instead of the
// last, removing the very reliability the chain exists for.
property p_uses_final_sync_stage;
@(posedge clk_wr) disable iff (!rst_wr_n)
(rbin_in_wr_c == gray2bin(rgray_in_wr[SYNC_STAGES-1]));
endproperty
// ─── Safety: the write side never overflows ────────────────────────────────
// Evaluated in the WRITE domain, using the write side's conservative fill.
// Catches a full flag computed from the wrong domain's view.
property p_no_write_overflow;
@(posedge clk_wr) disable iff (!rst_wr_n)
(wr_en && wr_full) |-> overflow;
endproperty
// ─── Safety: the read side never underflows ────────────────────────────────
// The mirror, in the READ domain with the read side's conservative fill.
property p_no_read_underflow;
@(posedge clk_rd) disable iff (!rst_rd_n)
(rd_en && rd_empty) |-> underflow;
endproperty
// ─── Conservation: each fill view is conservative in its own direction ─────
// The correct replacement for Chapter 4.4 §11's rejected shared-fill
// property. Catches the two views being computed from the same pointers,
// which reintroduces the cross-domain comparison that has no meaning.
property p_write_view_is_conservative_high;
@(posedge clk_wr) disable iff (!rst_wr_n)
(wr_side_fill <= DEPTH);
endproperty
property p_read_view_is_conservative_low;
@(posedge clk_rd) disable iff (!rst_rd_n)
(rd_side_fill <= DEPTH);
endproperty
// ─── Safety: reset asserts asynchronously ──────────────────────────────────
// Catches a synchronous-assert reset, which does nothing if the clock is not
// running -- which at power-on it may not be.
property p_reset_asserts_async;
@(negedge rst_async_n) 1'b1 |-> (!rst_mac_n && !rst_rx_n && !rst_mdc_n);
endproperty
// ─── Ordering: the consumer leaves reset before the producer ───────────────
// Rule 2 of Section 5. Catches the write side filling a buffer whose read
// side is still resetting -- data silently lost at start-up, which presents
// as a link that comes up slowly.
property p_read_side_released_first;
@(posedge clk_rx) disable iff (!rst_async_n)
$rose(rst_rx_n) |-> rst_mac_n;
endproperty
// ─── Causation: an ordering violation is reported ──────────────────────────
// Catches the fault being detectable and not detected, which leaves a
// start-up data loss with no indication anywhere.
property p_order_fault_reported;
@(posedge clk_rx) disable iff (!rst_async_n)
(rst_rx_n && !rst_mac_n) |=> reset_order_fault;
endproperty
// ─── Independence: management reset is not gated on the datapath ───────────
// Chapter 4.5 §2's argument. Catches management access being made to wait on
// a link that may never come up -- exactly when it is most needed.
property p_mgmt_reset_independent;
@(posedge clk_mdc) disable iff (!rst_async_n)
$rose(rst_mdc_n) |-> 1'b1; // deliberately unconditional
endproperty
// ─── Stability: the first fault is held ────────────────────────────────────
// Catches a first-fault register overwritten by the consequences of the
// first fault, which names an effect as the cause.
property p_first_fault_stable;
@(posedge clk_mac) disable iff (!rst_mac_n)
first_fault_valid |=> $stable(first_fault);
endproperty
// ─── Bounded: the two fill views do not diverge without bound ──────────────
// The only externally visible symptom of a marginal crossing. Catches
// synchronisers that are not resolving, whose data corruption otherwise
// looks random and has no signature.
property p_fill_views_bounded;
@(posedge clk_mac) disable iff (!rst_mac_n)
(fill_view_skew <= (SYNC_STAGES + 2));
endproperty
// ─── Safety: an MDC edge is debounced before it is believed ────────────────
// Catches logic clocked directly from the pin, where a glitch on a long
// unterminated line shifts the whole management frame by one bit.
property p_mdc_edge_debounced;
@(posedge clk_sys) disable iff (!rst_sys_n)
mdc_rising |-> $past(deb_q == DEBOUNCE);
endproperty
// ─── Causation: a short MDC period is reported ─────────────────────────────
// Catches a divider computed for the wrong system clock, whose symptom is
// otherwise indistinguishable from every device being absent.
property p_mdc_period_checked;
@(posedge clk_sys) disable iff (!rst_sys_n)
mdc_too_fast |-> (mdc_rising || mdc_falling);
endproperty10. Verification
Scenarios
- Gray pointer increment. Verify exactly one bit changes per increment, across a full wrap. A design that fails this can be sampled mid-transition into a value the counter never held.
- Pointer wrap at a power-of-two depth. Verify full and empty are correct across the wrap point in both domains. This is where a non-power-of-two depth fails, and only at the wrap.
- Write faster than read. Verify
wr_fullasserts before the buffer actually overflows — the write side's conservative view is early, which is the safe direction. - Read faster than write. Verify
rd_emptyasserts before it actually empties, for the mirror reason. - The two fill views compared. Verify they differ by at most the synchroniser latency plus what crossed during it, and that neither is treated as the fill.
- Synchroniser depth swept. Elaborate with two and three stages and verify the fill views become more conservative with more stages — that is the trade, and a suite that runs one depth never sees it.
- Clock ratio swept. Run with the write clock faster, slower, and equal. Equal is the trap case — a testbench using one clock for both hides every crossing bug, which is how they reach silicon.
- Asynchronous reset with no clocks running. Assert
rst_async_nwith both clocks stopped and verify all three domains enter reset. A synchronous-assert design does nothing here. - Reset release order, correct. Verify the MAC domain releases first, the stagger elapses, then the RX domain — and that
reset_order_faultstays low. - Reset release order, forced wrong. Release the RX domain first and verify
reset_order_faultasserts and sticks. Without this the failure is silent data loss at start-up. - Management reset independence. Hold the datapath in reset indefinitely and verify management access still works. Coupling them would deny access exactly when it is most needed.
- Each elaboration check, individually. Eleven runs, one per check in Section 6, each with the parameter deliberately wrong. Verify the build fails with a message naming the two parameters. A check that has never been made to fire has not been verified.
- A non-power-of-two elastic depth. Verify elaboration stops. Then bypass the check and verify the design does fail at the wrap point — which demonstrates why the check exists.
- A single synchroniser stage. Verify elaboration stops rather than producing a design that works in simulation and fails in silicon.
- An MDC divider for the wrong system clock. Verify elaboration stops rather than producing a bus that looks like thirty-two absent devices.
- First-fault ordering. Force a reset-ordering fault, then many overflows. Verify
first_faultstill reports the ordering fault — the overflows are its consequences. - Fill-view skew under a marginal crossing. Inject synchroniser failures and verify
fill_view_skewexceeds its bound. This is the only externally visible symptom of metastability. - An MDC glitch. Inject a pulse shorter than the debounce and verify it is rejected and counted, and that the frame is not shifted by a bit.
- An MDC period above the specification. Drive it faster than 2.5 MHz and verify
c_mdc_too_fastadvances — the fault that otherwise looks like every device being absent.
What the checker must own
- Two genuinely independent clock generators, with configurable ratio including non-integer ones. A testbench driving both domains from one clock cannot find a single bug in Section 4, and running only at a 1:1 ratio is the most common reason crossing bugs reach silicon.
- Elaboration-failure tests. Scenario 12 requires a build harness that expects a failed compile and checks the message. Most environments have no way to express that, which is why elaboration checks are so often written and never verified.
- A reset controller that can be forced into the wrong order, because Scenario 20 cannot otherwise be constructed.
- Coverage crosses of clock ratio against fill band against synchroniser depth. The bin
(ratio 1:1)must be explicitly excluded from sign-off coverage — passing there proves nothing about a crossing, and counting it inflates confidence.
11. Debugging — Which Boundary
The symptom: an integration that behaves correctly most of the time and corrupts data occasionally, with each block verified independently.
Step 1 — read first_fault, not the counters. By the time anyone looks, several fault types will have counts. Ordering is the information that survives, and Section 7 puts reset_order_fault first deliberately because everything downstream is its consequence.
| First fault | Which boundary | Where to go |
|---|---|---|
reset_order_fault | reset | the release order — and the loss was at start-up, not now |
cdc_overflow / cdc_underflow | clock | the buffer, its depth, and Chapter 4.4's discharge |
| neither, but data corrupted | data | Chapter 4.1's contract checkers at the port list |
Step 2 — if it is the clock boundary, read fill_view_skew before the depth. The two views should differ by roughly the synchroniser latency. A much larger skew means the synchronisers are not resolving — metastability, not a sizing problem — and adding depth will not help.
Step 3 — if frames are lost only at start-up, stop looking at the running system. That is Scenario 20's signature: correct behaviour for the entire run except the first few frames. It is a reset-ordering fault, it happened once, and reset_order_fault is the only surviving evidence.
Step 4 — if the failure appeared after a configuration change, re-run elaboration with checks enabled. Section 6's checks cost seconds and name the mismatched parameters directly. A design whose elaboration checks were removed to speed up builds has traded seconds for the entire class of static faults, and this is where that trade comes due.
Step 5 — if everything above is clean and data is still corrupted, the fault is at the data boundary, and Chapter 4.1 §13's attribution takes over. Note the ordering: structural faults are ruled out first because they are cheaper to check and because they make the contract checkers fire spuriously. A crossing fault corrupts the very signals the contract checkers examine, and they will name the wrong thing with conviction.
The method stated once: first fault before counters, skew before depth, start-up before steady state, and elaboration before simulation — because each of those is cheaper than the one after it and each rules out a whole boundary.
12. Common Misconceptions
"A port list is documentation."
The wrong model: the interface is where you write down what connects to what.
What it costs: clocks and resets get derived internally, so an integrator cannot tell from the interface how many domains they are connecting. Widths and signalling modes become independent parameters that can be given impossible combinations, and the design elaborates, simulates on a matched testbench, and fails in hardware.
The corrected model: a port list is a declaration of structure. Three clocks and three resets appear as ports because an integrator must see the domain count from the interface alone, and IF_W is derived from GENERATION so an impossible pair cannot be expressed.
"The clock domain changes at the interface."
The wrong model: two blocks with different clocks meet at the port list, so that is where the crossing is.
What it costs: you put synchronisers at the port list, where there is nothing to synchronise, and none where the crossing actually is. Or you place a contract checker at the crossing, where the contract does not apply.
The corrected model: the crossing is inside the elastic buffer, below the port list. Above the buffer everything is in the MAC's domain, so the port list is a clean synchronous interface — which is why Chapter 4.3 could describe its sequence cycle by cycle without ever mentioning a crossing. Where the crossing sits is an integration fact, and a hard-IP PHY presenting its own clock at the port list changes it.
"There is one fill count, and the two views are an approximation of it."
The wrong model: the buffer has an occupancy; each domain sees a slightly stale version.
What it costs: you pick one view as "the real one" and use it in both domains — and in one of them it is optimistic, which is the direction that overflows or underflows.
The corrected model: there is no single fill. The write side's view is conservative high because its read-pointer copy is stale; the read side's is conservative low for the mirror reason. Each is correct in the direction that matters where it lives, and asking which is real is the question Chapter 4.4 §11 showed has no answer.
"Reset is a signal."
The wrong model: assert it, wait, deassert it, everything starts.
What it costs: all domains are released together, and the producer fills a buffer whose consumer is still resetting. Data is lost at start-up, silently — no overflow, because the buffer did not overflow; it simply had no reader. The symptom is a link that drops its first frames.
The corrected model: reset is a sequence, not a place. Assert asynchronously so it works with no clock running; release synchronously per domain, in a dependency order — consumer before producer. And the management domain is deliberately outside that order, because coupling it would deny access exactly when a link that will not come up makes it most necessary.
"Runtime assertions cover configuration."
The wrong model: assert the parameters are consistent and the check is done.
What it costs: Section 9's rejected property. The assertion compares constants, so it either fires on every cycle and buries the log, or — more often — the mismatch prevents the design reaching a state where the property is even enabled. The assertion checking the configuration is disabled by the consequences of the configuration being wrong. And it displaces the elaboration check that would have caught it in seconds.
The corrected model: a property comparing only elaboration-time constants belongs in an elaboration check. Runtime assertions are for things that vary — pointer relationships, fill bounds, reset ordering, fault reporting.
13. Interview Reasoning
"Where is the clock-domain crossing in a MAC/PHY interface?"
The instinctive answer is the port list, and on this boundary it usually is not. The crossing is inside the elastic buffer, below the interface — everything above it is in the MAC's domain, which is why the port list is a clean synchronous interface. A strong answer adds that this is an integration fact rather than a property of Ethernet: a hard-IP PHY presenting its own clock at the port list puts the crossing there instead, which is why a well-built boundary module takes it as a parameter.
"Why do the two sides of a dual-clock FIFO disagree about how full it is?"
Because each sees a delayed copy of the other's pointer. The write side sees fewer reads than have happened, so its fill estimate is high; the read side sees fewer writes, so its estimate is low. Both are correct, and each is conservative in the direction that matters where it lives — the write side must not overflow, the read side must not underflow. The follow-up worth anticipating is what "the fill" is, and the answer is that there is no such quantity, which is exactly why a shared fill count cannot be asserted.
"A design loses a few frames after every link-up and is otherwise perfect. What is it?"
Almost certainly reset release order. The producer's domain came out of reset before the consumer's, so it filled a buffer nobody was draining — and nothing reports an overflow, because there was none. The design is flawless from a few cycles later onward, which is why the loss gets dismissed as a warm-up artefact. Naming that the evidence must be sticky to survive to when anyone looks is what distinguishes a complete answer.
14. Understanding Check
Because the contract, the clock change and the reset release are in three different places, and each has its own failure mode.
- The data boundary is the port list — a place in the netlist. Chapter 4.1's contract applies here, Chapter 4.2's vocabulary lives here, and Chapter 4.3's sequence is observable here.
- The clock boundary is not at the port list. It is inside the elastic buffer, below it — a place in the timing graph, and the two do not coincide.
- The reset boundary is not a place at all. Reset asserts asynchronously and releases synchronously per domain, in an order — it is a sequence.
Each confusion has a signature:
| Confusion | Failure |
|---|---|
| clock boundary assumed at the port list | metastability — rare corruption with no cause |
| reset boundary assumed at the port list | one domain runs before another is ready |
| data boundary assumed at the crossing | a contract checker in the wrong domain, green forever |
The follow-up to be ready for: is the geometry always this way? No. A hard-IP PHY presenting its own clock at the port list puts the crossing right there. Which arrangement you have is an integration fact, which is why Section 3's module takes it as a parameter.
15. What's Next
The claim this chapter defended: what five chapters described as one boundary is, in silicon, three — a data boundary at the port list, a clock boundary inside the elastic buffer, and a reset boundary that is an order rather than a place. They do not coincide, and confusing any two produces a specific, recognisable failure.
Each of the five earlier chapters made the same simplification — pretend there is one clock — and each said so. Removing it is what this chapter did: Gray-coded pointers with per-domain synchronisers, two deliberately unequal fill views each conservative where it lives, an ordered reset release with the consumer before the producer, and elaboration checks that catch a static fault in seconds rather than a simulation in hours.
Module 4 is complete. The boundary that defines Ethernet IP now has its rule, its vocabulary, its sequence, its arithmetic, its management path and its structure.
Chapter 5.1 — Frame Format Overview opens Module 5, and the shift is deliberate. Every chapter from 4.1 to here has treated a frame as octets with a boundary — the boundary was the subject and the contents were opaque, which is exactly Chapter 2.4's payload opacity seen from the inside.
Module 5 opens the frame. Every field, grouped by the decision it drives: alignment, addressing, type resolution, sizing, error detection, and the idle that separates one frame from the next. It is the first module since Module 2 in which the frame's contents are the subject rather than something carried past.
The full path is on the Ethernet curriculum index.
Continue learning
Related tutorials
- Related topic
Where the MAC Ends and the PHY Begins
The MAC/PHY boundary is generated by one rule: a responsibility belongs to the side that can detect its own failure. That rule decides every case — and it explains why each side is blind to the other's failures, which is what makes a contract violation invisible from both sides.
- Related topic
The Reconciliation Sublayer and the xMII Contract
The xMII generations are a record of what each had to give up — width, pins, timing margin, even parallelism — to keep carrying the same vocabulary as rates rose. That one vocabulary survived six unrelated physical forms is what media-independence actually means.
- Related topic
Data Flow Across the Boundary
Transmit is scheduled and receive is not. That one temporal fact is why the transmit path can be back-pressured and the receive path cannot — so one needs a handshake and the other a buffer, and being unready costs latency in one direction and a whole frame in the other.
- Related topic
Elastic Buffering and Clock Compensation
Two independent oscillators differ by a bounded amount forever, and a bounded rate difference still accumulates without limit unless something discharges it. The interframe gap is that opportunity — which is why it is not negotiable and why the buffer is far smaller than intuition suggests.
Standards & specifications
- Governing standard
- IEEE Std 802.3 (Ethernet)(opens IEEE in a new tab)
Defines the Ethernet MAC, the media-independent interfaces and the physical-layer sublayers, including framing, access control, auto-negotiation and per-rate PHY specifications. VLAN tagging, priority and time-sensitive shaping are defined by IEEE 802.1, not by 802.3.
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 Ethernet curriculum.
