Skip to content
VLSI Mentor

Ethernet · Module 2

Layering as an Engineering Contract

A layer boundary costs a register stage, a translation and a forgone optimisation, continuously. It buys a re-verification count of one instead of many — and because the cost is visible and the benefit is not, boundaries erode one reasonable local decision at a time.

Chapter 2.1 listed what each layer must not know, and Chapter 2.2 traced a frame across all of them. Both treated the boundaries as given.

They are not given. Every boundary costs something concrete — a register stage, a width conversion, a translation function somebody had to write and verify — and a designer who can see the cost and not the benefit will remove it, for a locally excellent reason, in a way that is not obviously wrong until three years later.

What does a layer boundary actually cost, what does it buy, and how would you tell which side of that trade a particular boundary is on?

The answer has to be measurable or it is an aesthetic preference. This chapter makes it measurable in the only currency that matters for hardware: what a change forces you to re-verify.

1. What a Boundary Costs

Take the cost seriously first. The arguments against layering are real and an engineer proposing to violate one usually has a good reason.

Latency. A boundary is usually a register stage, sometimes several. Chapter 2.2's pipeline paid one cycle per stage, and a real stack pays more — a clock-domain crossing at a layer boundary costs a synchroniser's worth of uncertainty on top.

Area and power. The registers holding data at a boundary exist only because the boundary exists. So does the translation logic — Chapter 2.1's reconciliation sublayer is an entire block whose sole job is to convert between two representations that could have been one.

Lost optimisation. This is the sharpest one. If the PCS knew where a frame's addresses were, it could make routing decisions earlier. If the MAC knew the line rate in seconds, some arithmetic would be simpler. Every prohibition in Chapter 2.1 §11 forbids an optimisation that would genuinely work.

Debugging opacity. A layered design hides information behind interfaces. When something is wrong, the interface that prevented coupling also prevents observation — which is why the management path exists as a separate mechanism reaching past the datapath boundaries.

None of these is imaginary. A design that ignores them ships slower, larger silicon than one that does not. The question is what the other column contains.

2. What a Boundary Buys, Stated as a Cost Avoided

The benefit is invisible in the way costs are not: it is work that does not happen.

Verification locality. A block verified against a contract is verified against a fixed thing. A block verified against its neighbour's behaviour is verified against a moving thing, and moves when the neighbour does.

Independent replacement. Chapter 1.6 showed one MAC source elaborating against three physical layers thirty years apart. That is the benefit's clearest form: the expensive, well-verified block outlived every cheap one attached below it.

Parallel development. Two teams can build two layers simultaneously if the contract is written first. Without one, the second team waits for the first, or both guess and integrate late.

Bounded blame. When a contract is violated, the violation is at a named interface. Without contracts, a failure's cause can be anywhere in the coupled set — which is Chapter 1.6's interoperability argument applied inside one chip.

3. RTL 1 — One MAC, Two Lower Layers, Unmodified

The contract's value is a compile-time property before it is anything else.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SYNTHESIZABLE. A block whose interface is a CONTRACT: offer octets, be
// told when to wait, be told when the path is unusable. Nothing about the
// layer below appears in its ports or its parameters.
//
// NOT a real MAC. Chapter 2.5 owns the responsibilities; this owns the
// interface discipline.
module contract_mac #(
  parameter int unsigned WIDTH = 8
) (
  input  logic clk,
  input  logic rst_n,
 
  input  logic             cli_valid,
  input  logic [WIDTH-1:0] cli_data,
  input  logic             cli_last,
  output logic             cli_ready,
 
  // THE CONTRACT, in three signals. Not a width, not a rate, not a medium,
  // not a coding scheme. "Take this octet when you can, and tell me when
  // you cannot."
  output logic             low_valid,
  output logic [WIDTH-1:0] low_data,
  output logic             low_last,
  input  logic             low_ready,
  input  logic             low_usable      // the path below is available
);
 
  logic [WIDTH-1:0] hold_q;
  logic             last_q, v_q;
 
  assign cli_ready = low_usable && (!v_q || low_ready);
  assign low_valid = v_q;
  assign low_data  = hold_q;
  assign low_last  = last_q;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      v_q <= 1'b0; hold_q <= '0; last_q <= 1'b0;
    end else begin
      if (v_q && low_ready) v_q <= 1'b0;
      if (cli_valid && cli_ready) begin
        hold_q <= cli_data; last_q <= cli_last; v_q <= 1'b1;
      end
    end
  end
endmodule
 
 
// LOWER LAYER A: a narrow parallel interface. Converts width, runs slowly,
// signals unusable while it is holding.
module lower_narrow #(
  parameter int unsigned WIDTH = 8,
  parameter int unsigned OUT_W = 4
) (
  input  logic clk, rst_n,
  input  logic             up_valid,
  input  logic [WIDTH-1:0] up_data,
  input  logic             up_last,
  output logic             up_ready,
  output logic             up_usable,
  output logic             pin_en,
  output logic [OUT_W-1:0] pin_data
);
  localparam int unsigned RATIO = WIDTH / OUT_W;
  logic [WIDTH-1:0] d_q; logic busy_q; logic [1:0] sel_q;
  wire last_slice = (sel_q == 2'(RATIO - 1));
  assign up_ready  = !busy_q || last_slice;
  assign up_usable = 1'b1;
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin busy_q <= 1'b0; sel_q <= '0; d_q <= '0; end
    else if (up_valid && up_ready) begin d_q <= up_data; sel_q <= '0; busy_q <= 1'b1; end
    else if (busy_q) begin
      if (last_slice) busy_q <= 1'b0; else sel_q <= sel_q + 1'b1;
    end
  end
  assign pin_en   = busy_q;
  assign pin_data = d_q[sel_q*OUT_W +: OUT_W];
endmodule
 
 
// LOWER LAYER B: a serial interface. Completely different physics, a
// different rate, a different notion of "busy", and an availability signal
// that actually goes low. SAME THREE CONTRACT SIGNALS.
module lower_serial #(
  parameter int unsigned WIDTH = 8
) (
  input  logic clk, rst_n,
  input  logic             up_valid,
  input  logic [WIDTH-1:0] up_data,
  input  logic             up_last,
  output logic             up_ready,
  output logic             up_usable,
  input  logic             link_locked,   // this layer's own concern
  output logic             serial_out,
  output logic             serial_valid
);
  logic [WIDTH-1:0] sr_q; logic [3:0] cnt_q; logic busy_q;
  assign up_ready  = link_locked && !busy_q;
  // The MAC above learns only that the path is unusable. It is never told
  // WHY, and it has no port through which it could be told.
  assign up_usable = link_locked;
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin busy_q <= 1'b0; cnt_q <= '0; sr_q <= '0; end
    else if (up_valid && up_ready) begin sr_q <= up_data; cnt_q <= 4'(WIDTH); busy_q <= 1'b1; end
    else if (busy_q) begin
      sr_q <= {sr_q[WIDTH-2:0], 1'b0};
      cnt_q <= cnt_q - 1'b1;
      if (cnt_q == 4'd1) busy_q <= 1'b0;
    end
  end
  assign serial_out   = sr_q[WIDTH-1];
  assign serial_valid = busy_q;
endmodule
 
 
// THE DEMONSTRATION. Same MAC source, twice, against layers that share
// nothing but three signal names.
module mac_against_two_lowers (
  input logic clk, rst_n, link_locked,
  input logic cli_valid_a, cli_last_a, cli_valid_b, cli_last_b,
  input logic [7:0] cli_data_a, cli_data_b
);
  logic va, ra, la, ua; logic [7:0] da;
  logic vb, rb, lb, ub; logic [7:0] db;
 
  contract_mac u_mac_a (.clk, .rst_n,
    .cli_valid(cli_valid_a), .cli_data(cli_data_a), .cli_last(cli_last_a), .cli_ready(),
    .low_valid(va), .low_data(da), .low_last(la), .low_ready(ra), .low_usable(ua));
  lower_narrow u_low_a (.clk, .rst_n,
    .up_valid(va), .up_data(da), .up_last(la), .up_ready(ra), .up_usable(ua),
    .pin_en(), .pin_data());
 
  contract_mac u_mac_b (.clk, .rst_n,
    .cli_valid(cli_valid_b), .cli_data(cli_data_b), .cli_last(cli_last_b), .cli_ready(),
    .low_valid(vb), .low_data(db), .low_last(lb), .low_ready(rb), .low_usable(ub));
  lower_serial u_low_b (.clk, .rst_n,
    .up_valid(vb), .up_data(db), .up_last(lb), .up_ready(rb), .up_usable(ub),
    .link_locked(link_locked), .serial_out(), .serial_valid());
endmodule

Classification: synthesizable.

What it teaches: that a contract is three signals and a set of absences. contract_mac has no port and no parameter naming a width, a rate, a medium or a coding scheme, and the two lower layers share nothing but those three signals — one converts width in parallel, the other serialises bit by bit, and one of them can become unusable while the other never does.

low_usable is the port worth defending. It tells the MAC that the path is unavailable and never why. A design that added a link_locked input to the MAC would work perfectly and would have coupled the MAC to a physical-layer concept, so the next lower layer — one with no such concept — would need a MAC change. The contract is stronger for carrying less.

Deliberately simplified: one beat of holding; no receive direction; lower_serial shifts a fixed width with no coding; no clock-domain crossing, which a real boundary usually has.

Production implication: a real boundary crosses clock domains, so the contract must also specify what happens to in-flight data across a reset in either domain, and the availability signal must itself be synchronised — a detail that is exactly the kind of thing a contract must state and an informal boundary silently gets wrong.

4. RTL 2 — The Same Design, With the Contract Violated

Now the version an engineer would actually write when optimising, and the cost is not visible in it.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ILLUSTRATIVE ANTI-PATTERN. A MAC that reaches across the boundary for a
// locally excellent reason. It works, it is smaller, and it is faster.
//
// DO NOT copy this shape. Read it against Section 3 and Section 5.
module coupled_mac #(
  parameter int unsigned WIDTH = 8,
  // VIOLATION 1: the MAC now knows the layer below is a width converter,
  // and how wide. A serial lower layer has no meaningful value for this.
  parameter int unsigned LOWER_RATIO = 2,
  // VIOLATION 2: the MAC now knows the line rate in real time units.
  // Chapter 1.6 showed the whole point of bit times was to avoid this.
  parameter int unsigned LINE_RATE_MBPS = 100
) (
  input  logic clk,
  input  logic rst_n,
 
  input  logic             cli_valid,
  input  logic [WIDTH-1:0] cli_data,
  input  logic             cli_last,
  output logic             cli_ready,
 
  output logic             low_valid,
  output logic [WIDTH-1:0] low_data,
  output logic             low_last,
 
  // VIOLATION 3: instead of a ready, the MAC reads the lower layer's
  // INTERNAL state and predicts when it will be free. This is the
  // optimisation — it removes a cycle of handshake latency, and it is why
  // someone does it.
  input  logic [1:0]       lower_slice_index,
  input  logic             lower_busy,
 
  // VIOLATION 4: the MAC consumes a physical-layer status directly, so it
  // now contains the concept of a locked link.
  input  logic             lower_link_locked
);
 
  logic [WIDTH-1:0] hold_q; logic last_q, v_q;
 
  // The optimisation. By reading the lower layer's slice counter, the MAC
  // knows one cycle EARLY that the layer will be free, and can accept the
  // next octet without waiting for a ready. Genuinely faster.
  wire lower_free_next = !lower_busy ||
                         (lower_slice_index == 2'(LOWER_RATIO - 1));
 
  assign cli_ready = lower_link_locked && (!v_q || lower_free_next);
  assign low_valid = v_q;
  assign low_data  = hold_q;
  assign low_last  = last_q;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin v_q <= 1'b0; hold_q <= '0; last_q <= 1'b0; end
    else begin
      if (v_q && lower_free_next) v_q <= 1'b0;
      if (cli_valid && cli_ready) begin
        hold_q <= cli_data; last_q <= cli_last; v_q <= 1'b1;
      end
    end
  end
 
  // VIOLATION 5, the quiet one: a timing constant derived from the rate.
  // It is correct today and it is wrong at any other rate, and nothing in
  // the design will say so — it will simply behave differently.
  localparam int unsigned NS_PER_OCTET = 8000 / LINE_RATE_MBPS;
endmodule

Classification: illustrative anti-pattern; synthesizable and functional.

What it teaches: that a contract violation does not look like a mistake. This module is smaller and faster than Section 3's — it removes a cycle of handshake latency by predicting the lower layer's availability instead of waiting to be told. Every reviewer would accept that trade in isolation.

Count what it now knows. The lower layer's width ratio, its internal slice counter, its busy signal, its link-lock concept, and the line rate in nanoseconds. Five facts, none of which appears in Section 3's version, and each of which is a dependency nobody wrote down.

The NS_PER_OCTET localparam is the most insidious. It is unused in this fragment, computed silently, and correct at exactly one rate. At any other rate it is wrong and nothing fails — the design simply behaves differently, and the discrepancy surfaces as a timing anomaly nobody can source.

Deliberately simplified: the violations are concentrated for legibility; in practice they accumulate one per release, each in a different file, and no single commit looks like a violation.

Production implication: every one of the five couplings above is a real pattern from real designs. The defence is not vigilance — it is Section 5's question, asked at review time: which blocks must be re-verified if the layer below changes?

5. RTL 3 — Measuring What a Change Costs

The trade is only decidable if the benefit is countable. It is, and this is how.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ELABORATION-TIME AUDIT. Counts cross-layer dependencies declared by each
// block and fails the build when a block reaches across more boundaries than
// its contract allows.
//
// Contains no logic. The parameters are DECLARATIONS a designer writes, and
// the value of the check is that the declaration must be kept honest.
module layer_contract_audit #(
  // How many facts about the layer BELOW does this block's interface name?
  // A contract-respecting block declares zero.
  parameter int unsigned MAC_LOWER_DEPS   = 0,
  parameter int unsigned PCS_LOWER_DEPS   = 0,
  parameter int unsigned PMA_LOWER_DEPS   = 0,
  // The budget. Zero means a strict contract; a positive number is a
  // deliberate, reviewed exception with a reason recorded next to it.
  parameter int unsigned DEP_BUDGET       = 0,
  // How many blocks sit ABOVE each layer — the transitive re-verification
  // cost when that layer changes.
  parameter int unsigned BLOCKS_ABOVE_PMA = 3,
  parameter int unsigned BLOCKS_ABOVE_PCS = 2,
  parameter int unsigned BLOCKS_ABOVE_MAC = 1
) ();
 
  // THE NUMBER THE CHAPTER IS ABOUT. With every contract intact, replacing
  // a layer forces re-verification of that layer alone: one block. Every
  // cross-layer dependency adds the block that declared it, plus everything
  // above that block, because the coupling is transitive.
  localparam int unsigned REVERIFY_ON_PMA_CHANGE =
    1 + (PMA_LOWER_DEPS > 0 ? BLOCKS_ABOVE_PMA : 0)
      + (PCS_LOWER_DEPS > 0 ? BLOCKS_ABOVE_PCS : 0)
      + (MAC_LOWER_DEPS > 0 ? BLOCKS_ABOVE_MAC : 0);
 
  localparam int unsigned TOTAL_DEPS =
    MAC_LOWER_DEPS + PCS_LOWER_DEPS + PMA_LOWER_DEPS;
 
  if (TOTAL_DEPS > DEP_BUDGET) begin : g_over_budget
    $error("cross-layer dependencies %0d exceed budget %0d. Replacing the PMA now forces re-verification of %0d blocks instead of 1.",
           TOTAL_DEPS, DEP_BUDGET, REVERIFY_ON_PMA_CHANGE);
  end
 
  // Reported even when the build passes, because the number is the point.
  initial $display("[layer audit] cross-layer deps=%0d; re-verify on PMA change=%0d blocks",
                   TOTAL_DEPS, REVERIFY_ON_PMA_CHANGE);
 
endmodule

Classification: elaboration-time audit; produces no hardware.

What it teaches: that "layering is good architecture" becomes a decidable question the moment it is expressed as how many blocks must be re-verified when one layer changes. With contracts intact the answer is one. Section 4's coupled_mac declares four dependencies, so replacing the PMA re-verifies the PMA, the PCS, the MAC and the client — four blocks and their entire test suites.

The honest limitation, and it is a real one. This audit believes what the designer declares. A block that reaches across a boundary and does not update its declaration passes. So it is a review artefact, not a proof: its value is that it forces the number into a place where a reviewer must look at it, and makes an increase a visible, arguable change rather than an invisible one.

Why report the number even on success. A count that only appears on failure is a gate. A count that always appears is a trend, and a project whose dependency count is climbing by one per release is eroding its architecture in exactly the way Section 2 describes — one reasonable local optimisation at a time.

Deliberately simplified: hand-declared counts rather than extracted from the netlist; a uniform budget where a real project would set it per boundary; no distinction between a read-only dependency and a control one.

Production implication: extract the dependencies mechanically where the tooling allows — an interface that names a signal from a non-adjacent layer is detectable in a netlist — and make the budget a reviewed number in the design specification rather than a parameter default, so raising it requires a conversation.

Two rows showing the re-verification surface of a PMA change. In the contract-intact row only the PMA must be re-verified. In the violated row the PMA, PCS, MAC and client must all be re-verified because each named something belonging to the layer below it.PMA changesre-verify: this blockPCSuntouched: contract heldMACuntouched: contract heldclientuntouched: contract heldPMA changesre-verify: this blockPCSre-verify: it read PMA stateMACre-verify: it read PCS stateclientre-verify: transitivelycoupledcontractcontractcontractcoupledcoupledcoupled12
Figure 1 — the same PMA change, with the contract intact and with it violated.

The upper row's three green blocks are the entire benefit of layering, and they are the reason it is hard to argue for: nothing happened to them, so there is nothing to point at.

6. RTL 4 — Verifying a Layer Against Its Contract

If the contract is real, a block can be verified without its neighbours existing.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// NON-SYNTHESIZABLE. Verifies contract_mac against its CONTRACT, with no
// model of any layer below it.
//
// The harness generates adversarial-but-legal lower-layer behaviour from
// the contract's rules alone. If it needed to know what the lower layer
// actually does, the contract would be incomplete.
module contract_harness #(
  parameter int unsigned WIDTH = 8
) (
  input  logic clk,
  input  logic rst_n,
 
  input  logic             low_valid,
  input  logic [WIDTH-1:0] low_data,
  input  logic             low_last,
  output logic             low_ready,
  output logic             low_usable,
 
  output logic             saw_violation
);
 
  // A CONTRACT-LEGAL adversary. It may withhold ready arbitrarily and may
  // withdraw usable arbitrarily, because the contract permits both — it
  // says nothing about how often either happens. Anything the MAC breaks
  // under this stimulus is a MAC bug, because everything here is legal.
  logic [7:0] lfsr_q;
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) lfsr_q <= 8'hA5;
    else lfsr_q <= {lfsr_q[6:0], lfsr_q[7] ^ lfsr_q[5] ^ lfsr_q[4] ^ lfsr_q[3]};
  end
 
  assign low_ready  = lfsr_q[0];
  assign low_usable = |lfsr_q[3:1];   // low roughly one cycle in eight
 
  // The three contract clauses the MAC must obey, checked here rather than
  // in the MAC — because they are properties of the INTERFACE, and belong
  // to whoever owns the interface.
  logic [WIDTH-1:0] held_q;
  logic             was_valid_q, held_last_q;
  logic             v1, v2, v3;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      held_q <= '0; was_valid_q <= 1'b0; held_last_q <= 1'b0;
    end else begin
      was_valid_q <= low_valid;
      if (low_valid) begin held_q <= low_data; held_last_q <= low_last; end
    end
  end
 
  // CLAUSE 1: payload is stable while valid is held and ready is low.
  assign v1 = was_valid_q && low_valid && !$past(low_ready)
              && (low_data != held_q);
  // CLAUSE 2: valid does not withdraw without a transfer.
  assign v2 = was_valid_q && !low_valid && !$past(low_ready);
  // CLAUSE 3: nothing is offered while the path is unusable.
  assign v3 = low_valid && !low_usable;
 
  assign saw_violation = v1 || v2 || v3;
 
endmodule

Classification: non-synthesizable verification component.

What it teaches: that a good contract lets you generate stimulus from the contract itself. The adversary here withholds low_ready and withdraws low_usable at random, because the contract permits both without saying how often. That is stronger stimulus than any real lower layer would produce — a real one has patterns, and patterns hide bugs.

The harness has no model of a lower layer, and that is the test of the contract. If verifying the MAC required knowing what the PCS does, the interface would not be a contract; it would be a description of one particular neighbour. Section 5's dependency count would then be non-zero by construction.

Why the clauses live in the harness rather than the MAC. They are properties of the interface, not of either block, so they belong to whoever owns the interface — and the same harness can then be pointed at any block claiming to implement it. That is what makes a contract reusable rather than a one-off agreement between two teams.

Deliberately simplified: three clauses where a real interface specification has more; a uniform random adversary where a real one should also produce long stalls and long usable-low intervals; transmit only.

Production implication: package the harness with the interface definition, not with either block, so both sides are verified against the same artefact. A contract with two independently-written checkers has two interpretations, and the difference between them is where the integration bug lives.

7. RTL 5 — A Conformance Checker Either Side Can Apply

The strongest form of a contract is one that both parties can check without knowing anything about each other.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SYNTHESIZABLE (as a monitor). Watches a valid/ready/usable interface and
// reports contract violations, knowing nothing about either neighbour.
//
// The same instance works at the client boundary, the MAC boundary and the
// PCS boundary, because it checks the CONTRACT, not the layers.
module interface_conformance #(
  parameter int unsigned WIDTH = 8,
  parameter int unsigned STALL_LIMIT = 1024   // ILLUSTRATIVE liveness bound
) (
  input  logic clk,
  input  logic rst_n,
 
  input  logic             valid,
  input  logic [WIDTH-1:0] data,
  input  logic             last,
  input  logic             ready,
  input  logic             usable,
 
  output logic err_unstable,     // payload changed while stalled
  output logic err_withdrawn,    // valid dropped without a transfer
  output logic err_when_unusable,// offered while the path was unusable
  output logic err_starved,      // ready withheld beyond the liveness bound
  output logic [15:0] err_count
);
 
  logic [WIDTH-1:0] d_q;
  logic             v_q, l_q, r_q;
  logic [15:0]      stall_q, cnt_q;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      v_q <= 1'b0; d_q <= '0; l_q <= 1'b0; r_q <= 1'b0; stall_q <= '0;
    end else begin
      v_q <= valid; d_q <= data; l_q <= last; r_q <= ready;
      // A stall is valid-without-ready. Counting it is what turns a safety
      // monitor into one that can also catch a liveness failure — a
      // downstream block that never asserts ready violates nothing in a
      // cycle-by-cycle sense and starves the link completely.
      if (valid && !ready) begin
        if (stall_q != 16'hFFFF) stall_q <= stall_q + 1'b1;
      end else stall_q <= '0;
    end
  end
 
  assign err_unstable      = v_q && valid && !r_q && (data != d_q || last != l_q);
  assign err_withdrawn     = v_q && !valid && !r_q;
  assign err_when_unusable = valid && !usable;
  assign err_starved       = (stall_q >= 16'(STALL_LIMIT));
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) cnt_q <= '0;
    else if ((err_unstable || err_withdrawn || err_when_unusable || err_starved)
             && cnt_q != 16'hFFFF) cnt_q <= cnt_q + 1'b1;
  end
  assign err_count = cnt_q;
 
endmodule

Classification: synthesizable monitor; typically synthesised only in debug builds.

What it teaches: that a contract expressed as an independent checker is transferable. The same module watches the client boundary, the MAC boundary and the PCS boundary, because it checks the interface's rules and has no opinion about the layers. A checker that needed per-boundary customisation would prove the boundaries had different contracts — which is a finding worth having.

err_starved is the clause that is easiest to omit and the most valuable. The three safety clauses are cycle-by-cycle and easy to state. Liveness — that ready must eventually assert — is not, and a downstream block that simply never asserts it violates no safety property while stopping the link completely. The bound is illustrative, and the fact that a bound is needed is not.

Why counting matters more than flagging. A one-cycle error pulse on an interface nobody is watching is lost. A saturating count survives to be read later, which is what makes this useful in silicon rather than only in simulation.

Deliberately simplified: one direction; four clauses; a fixed liveness bound where a real one is derived from the system's timing; no distinction between a first violation and a recurring one.

Production implication: synthesise these at every internal boundary in debug builds and read the counts after a failure — an interface with a non-zero count localises the fault to one boundary immediately, which is the internal equivalent of Chapter 2.2's loopback bisection.

8. Where the Contract Is Deliberately Broken

An honest chapter names the exceptions, because they exist and they are not failures.

The management path. Chapter 2.1 §7 established that software reaches past the MAC and the RS directly into the PHY's registers. That is a deliberate violation: the management path's purpose is to configure things the datapath must not know about, and it works precisely because it is separate from the datapath.

Checksum and segmentation offload. A MAC that computes a higher-layer checksum is doing work that belongs above it. It is done anyway, because the performance gain is large and the coupling is bounded — and Chapter 2.4 develops exactly what it costs.

Cut-through switching. A switch that begins forwarding after reading only the destination address is making a decision before the frame is complete, which couples the forwarding decision to the frame's internal layout. Chapter 12.6 shows the trade.

A three-step test for a cross-layer dependency: is it written down in the interface specification, is it bounded to a stated scope, and is its cost recorded. A dependency passing all three is a deliberate exception; one failing any of them is erosion.Exception, or erosion?1Written downin the specification, not only in the RTL2Boundeda stated scope it may not grow past3Cost recordedso the next extension argues against a number
Figure 2 — three tests a legitimate exception passes and erosion fails.

9. Where to Put a Boundary

If boundaries cost something, the interesting question is not whether to have them but where, and the answer follows from Section 2's benefit: a boundary pays out when the thing below it is replaced.

Put a boundary where change is predicted. Chapter 2.1's reconciliation sublayer is the worked example. Somebody predicted that interface widths and clocking schemes would keep changing while framing would not, and put an adapter exactly on that line. Interface widths went from 4 bits to 8 to 32; clocking went from single-edge to double-edge to source-synchronous to serialised. Every one of those changes landed on the RS and none reached the MAC. The prediction was correct and the boundary has been paying out for thirty years.

Do not put a boundary where nothing varies. A boundary between two blocks that have always changed together, and always will, is pure cost — a register stage and a translation buying a replaceability nobody will use. This is the honest answer to why not every module in a design gets an interface specification.

Three signals that a proposed boundary is in the right place:

The two sides change on different schedules. If one side is revised every generation and the other is not, a boundary converts that into independent work instead of coupled work.

The two sides are built by different people. A boundary that separates two teams is doing organisational work as well as technical work, and the contract becomes the thing they negotiate instead of the code.

A plausible alternative implementation exists. If you can name a second thing that could sit below this boundary — a different PHY, a different medium, a simulation model — the boundary has a concrete payout. If you cannot, it is speculative.

10. What a Complete Contract States

Section 13 says that when two conforming blocks disagree, the specification is the defect. That is only useful if there is a standard for what a specification must contain. There is, and it is short.

ClauseWhat it must stateFailure when omitted
Transferwhen data is considered handed overboth sides count transfers differently
Stabilitywhat must not change while a transfer is pendingthe sink samples a different item than it was offered
Withdrawalwhether an offer may be retracted, and whenitems silently dropped, or a sink that waits forever
Availabilitywhat "unusable" means and what each side must doSection 13's scenario 4 — three defensible readings
Orderingwhether items may be reordereda receiver with no reassembly meets one that reorders
Livenesswhat each side must eventually doa starved interface that violates nothing per cycle
Resetwhat happens to in-flight data, and in which order sides reseta stale item delivered after reset, or a lockup on release
Errorhow a fault is signalled and what the peer must doan error that one side reports and the other ignores

The last two are the ones most often missing, and both produce integration-only failures.

Reset ordering matters because two blocks in different reset domains will be released in some order, and a contract that does not say which produces a design that works on one board and hangs on another. Chapter 2.1 §7's bottom-up bring-up is exactly this clause, stated for the whole stack.

Error semantics matter because an error is the case both sides are least likely to have tested against each other. Section 3's low_usable is one clause of it; a complete contract also says whether an item in flight when the error occurred is delivered, discarded, or delivered with a marking.

11. Waveform — The Same Stimulus, Two Boundaries

One cycle of latency, and what it was traded for

10 cycles
Ten clock cycles. The lower layer is busy from cycle 2 to cycle 4 and asserts ready at cycle 4. The contract MAC waits for ready and accepts at cycle 5. The coupled MAC reads the lower layer's slice index, predicts availability, and accepts at cycle 4 — one cycle earlier.coupled MAC predicts, accepts earlycoupled MAC predicts,accepts earlycontract MAC waits to be toldcontract MAC waits to betoldclklow_busyslice_idx0001200010low_readyctr_acceptcpl_acceptctr_deps0000000000cpl_deps4444444444t0t1t2t3t4t5t6t7t8t9
Figure 3 — the contract MAC waits to be told; the coupled MAC predicts and is faster.

cpl_accept at cycle 4 and ctr_accept at cycle 5. The coupled MAC is genuinely one cycle faster, every time. That is not a trick; it is the real benefit of reading the lower layer's internal state.

cpl_deps is 4 for the whole trace and ctr_deps is 0. That is the price, and it is invisible in a timing diagram because it is not a timing property. The two rows are the chapter: a benefit you can see in a waveform against a cost you can only see in a dependency count.

And note that nothing in the trace is wrong. Both designs work. The comparison is not correctness versus incorrectness, it is a cycle now against a re-verification later — which is why the argument cannot be settled by simulation and has to be settled by Section 5's number.

12. Assertions

Invariants of these models. None is an IEEE requirement.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SVA over contract_mac, coupled_mac and interface_conformance.
 
// SAFETY — P1: payload is stable while stalled. The core handshake clause;
// violating it means the sink can sample a different item than it was
// offered.
property p_stable_under_stall;
  @(posedge clk) disable iff (!rst_n)
  (low_valid && !low_ready) |=> (low_valid && $stable(low_data) && $stable(low_last));
endproperty
a_stable_under_stall : assert property (p_stable_under_stall);
 
// SAFETY — P2: valid never withdraws without a transfer. A withdrawn valid
// is a silently dropped item.
property p_no_withdraw;
  @(posedge clk) disable iff (!rst_n)
  (low_valid ##1 !low_valid) |-> $past(low_ready);
endproperty
a_no_withdraw : assert property (p_no_withdraw);
 
// SAFETY — P3: nothing is offered while the path is unusable. Offering
// takes ownership of an item with no way to deliver it.
property p_nothing_when_unusable;
  @(posedge clk) disable iff (!rst_n)
  !low_usable |-> !(cli_valid && cli_ready);
endproperty
a_nothing_unusable : assert property (p_nothing_when_unusable);
 
// SAFETY — P4: cli_ready does not depend combinationally on cli_valid.
// Catches the loop that makes two contract-respecting blocks uncomposable.
property p_no_ready_valid_loop;
  @(posedge clk) disable iff (!rst_n)
  $changed(cli_ready) |-> !$changed(cli_valid) || $changed(low_ready) || $changed(low_usable);
endproperty
a_no_rv_loop : assert property (p_no_ready_valid_loop);
 
// CONTRACT — P5: the contract MAC's behaviour depends only on contract
// signals. The property that makes it replaceable; a failure means
// something else leaked into its decision.
property p_decision_from_contract_only;
  @(posedge clk) disable iff (!rst_n)
  $changed(u_mac_a.cli_ready) |->
    ($changed(u_mac_a.low_ready) || $changed(u_mac_a.low_usable)
     || $changed(u_mac_a.v_q));
endproperty
a_contract_only : assert property (p_decision_from_contract_only);
 
// CONTRACT — P6: the same MAC source produces the same state sequence
// against both lower layers, given equivalent contract-level stimulus.
// This is Section 3's claim, checkable.
property p_same_mac_same_behaviour;
  @(posedge clk) disable iff (!rst_n)
  (u_low_a.up_ready == u_low_b.up_ready && u_low_a.up_usable == u_low_b.up_usable
   && cli_valid_a == cli_valid_b)
    |=> (u_mac_a.v_q == u_mac_b.v_q);
endproperty
a_same_behaviour : assert property (p_same_mac_same_behaviour);
 
// SAFETY — P7: the conformance monitor counts every violation it detects.
// A monitor that flags without counting loses the evidence.
property p_violations_counted;
  @(posedge clk) disable iff (!rst_n)
  (err_unstable || err_withdrawn || err_when_unusable || err_starved)
    |=> (err_count > $past(err_count) || err_count == 16'hFFFF);
endproperty
a_violations_counted : assert property (p_violations_counted);
 
// LIVENESS — P8: an accepted item is eventually handed on. ASSUMPTION,
// stated: the layer below eventually asserts ready and stays usable.
assume property (@(posedge clk) s_eventually (low_ready && low_usable));
property p_item_progresses;
  @(posedge clk) disable iff (!rst_n)
  (cli_valid && cli_ready) |-> s_eventually (low_valid && low_ready);
endproperty
a_item_progresses : assert property (p_item_progresses);

The property that must not be written

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// FALSE for a correct design. Included as a warning, not as a check.
// property p_layers_never_share_signals;
//   @(posedge clk) disable iff (!rst_n)
//   !$isunknown(mac.any_phy_signal) |-> 1'b0;
// endproperty

It reads like the layering rule stated absolutely, and it forbids things the architecture requires.

The management path deliberately reaches past the datapath boundaries — that is its purpose, and Chapter 2.1 §7 says so. Reset and clock cross every boundary by necessity. Status aggregation, link_up, collects terms from three layers into one signal on purpose.

A rule with no exceptions is not the rule that is actually in force, and asserting one produces a check that fires on correct behaviour and is immediately disabled — taking P5 with it, which is the property that genuinely catches a datapath coupling.

The correct form is P5: the MAC's datapath decisions depend only on contract signals. That permits management, reset and status while forbidding the thing Section 4 does. Section 8's three tests — written down, bounded, cost stated — are how a human decides which side of that line a proposed signal falls on.

13. Verification

Monitors observe: every interface signal at every boundary via interface_conformance; the contract MAC's internal state against the coupled MAC's under equivalent stimulus; and the audit's dependency count at elaboration.

The scoreboard independently predicts the octet sequence at each boundary from the sequence at the one above, using the contract's rules alone. It must not model either block's internals — a scoreboard that knows how the MAC decides agrees with the MAC about every decision bug.

Scenarios

  1. Contract MAC, no backpressure. Baseline: verify full-rate transfer and no violations.
  2. Contract MAC, random backpressure. The harness adversary. Verify stability (P1) and no withdrawal (P2).
  3. Contract MAC, path unusable. Withdraw low_usable while idle. Verify nothing is offered (P3) and clean recovery.
  4. Contract MAC, path unusable while holding. Withdraw low_usable with an item held. Verify the held item is not corrupted and not silently dropped — the case most likely to be implemented inconsistently.
  5. Contract MAC against lower layer A, then B. Same contract-level stimulus, both lower layers. Verify identical MAC state sequences (P6). Section 3's claim, tested.
  6. Long stall. Withhold low_ready beyond the liveness bound. Verify err_starved and that no safety clause fires — a starved interface is legal cycle by cycle and broken overall.
  7. Coupled MAC, lower layer replaced. Instantiate coupled_mac against lower_serial, which has no slice index. Verify it fails — the demonstration that coupling is not free.
  8. Audit within budget. Elaborate with zero declared dependencies. Verify the build passes and the count is reported.
  9. Audit over budget. Elaborate with dependencies declared. Verify the error fires and names the re-verification count.
  10. Conformance monitor at each boundary. Instantiate at the client, MAC and PCS boundaries with the same parameters. Verify each detects an injected violation at its own boundary and none reports another's.
  11. Injected violation of each clause. Four scenarios: unstable payload, withdrawn valid, offer while unusable, starvation. Verify exactly the corresponding output and that the count increments (P7).
  12. Reset at each boundary. Verify no stale held item, no spurious violation on release, and a clean first transfer.

Coverage

Cross backpressure duration against holding state at both boundaries. Cover low_usable withdrawn while idle, while holding, and in the same cycle as a transfer. Cover each conformance clause firing alone and in combination. Cover the audit at zero, at budget, and above budget.

A directed stimulus for the usable-withdrawn-while-holding case

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// NON-SYNTHESIZABLE — directed stimulus. Withdraws path availability with an
// item held, which is the contract clause most often implemented two
// different ways by two teams.
task automatic withdraw_usable_while_holding();
  // Get one item held in the MAC, not yet accepted below.
  force low_ready = 1'b0;
  cli_valid <= 1'b1; cli_data <= 8'h5A; cli_last <= 1'b0;
  @(posedge clk iff cli_ready);
  cli_valid <= 1'b0;
  @(posedge clk);
  assert (dut.v_q) else $error("precondition: an item should be held");
 
  // Withdraw availability. THE QUESTION: does the MAC keep presenting the
  // held item, or withdraw it?
  force low_usable = 1'b0;
  repeat (4) @(posedge clk);
 
  // The contract says valid may not withdraw without a transfer (P2), and
  // the item must not be corrupted. Keeping it presented is the ONLY
  // behaviour consistent with both.
  assert (dut.low_valid)
    else $error("MAC withdrew a held item when the path became unusable — the client believes it was taken");
  assert (dut.low_data == 8'h5A)
    else $error("held item was corrupted while the path was unusable");
 
  // And it must not accept anything NEW.
  cli_valid <= 1'b1; cli_data <= 8'h99;
  @(posedge clk);
  assert (!cli_ready)
    else $error("MAC accepted a new item with no path to deliver it");
 
  release low_usable; release low_ready;
endtask

The three assertions are three different teams' intuitions, which is why this case needs a directed test rather than randomisation. One reading says withdraw the item because the path is gone; another says hold it because the client believes it was taken; a third forgets to stop accepting. Only one is consistent with the contract, and the contract has to be read to know which — which is what makes this a contract defect when two blocks disagree, not a bug in either.

14. Debugging — Locating a Contract Violation

Contract failures have a signature that block failures do not: they appear at integration and reproduce in neither block's own test suite.

SymptomWhat it meansFirst check
Both blocks pass alone, fail togetherThe contract is underspecified — each read it differentlyWhich clause do the two interpretations differ on
A block breaks when its neighbour is replacedAn undeclared dependencyThe dependency count, and what the new neighbour lacks
A failure moves when an unrelated block changesTransitive couplingWalk the dependency chain from the changed block upward
An interface monitor reports a violationLocalised already — one boundaryWhich clause, and which side asserted it
Everything passes and the design will not portCoupling with no functional symptomThe audit count, not the simulation

The last row is the one with no test that fails. A design can be entirely correct, fully verified, and still coupled — the symptom appears only when somebody tries to reuse a block and discovers what it needs. That is why Section 5's count is an elaboration check rather than a simulation one: the failure mode has no runtime signature at all.

And the first row's fix is not in either block. When two conforming blocks disagree, the interface specification is wrong. Patching one side makes the pair work and leaves the ambiguity for the next implementer — which is how a contract becomes a description of two particular blocks.

15. Common Misconceptions

"Layering is a design philosophy."

The wrong model: an aesthetic preference, a matter of taste, defensible but not decidable.

What it costs: it loses every argument against a concrete optimisation, because a taste cannot outweigh a measured cycle. Boundaries then erode one reasonable local decision at a time.

The corrected model: layering is a measurable trade. The cost is a register stage and a translation, in cycles and gates. The benefit is a re-verification count: with contracts intact, replacing a layer re-verifies one block. Section 5 makes that number a build-time output, and a number can be argued against a cycle.

"A boundary that costs a cycle should be removed."

The wrong model: latency is measurable and the benefit is not, so the cycle wins.

What it costs: Section 4's coupled_mac — genuinely faster, genuinely smaller, and unattachable to any lower layer that lacks the internals it reads. The cost lands on whoever replaces that layer, which is not the person who removed the boundary.

The corrected model: the two sides of the trade are denominated differently. A cycle is paid continuously; a re-verification is paid once and can be enormous. The decision needs both numbers, which is why the dependency count is reported on success as well as failure — a trend is more informative than a gate.

"Any cross-layer signal is a violation."

The wrong model: strict layering means no signal ever crosses more than one boundary.

What it costs: the rule is unenforceable, because the management path, reset, clock and status aggregation all cross boundaries by necessity. An unenforceable rule gets ignored entirely, taking the enforceable part with it.

The corrected model: the rule constrains datapath decisions. A block's forwarding, framing or handshaking behaviour must depend only on its contract; management, reset and status are separate, declared paths. Section 8's three tests — written down, bounded, cost stated — separate a deliberate exception from erosion, and Section 12's rejected property is what happens when the absolute version is asserted instead.

"If a block passes its own tests, it conforms."

The wrong model: a verified block is a conforming block.

What it costs: two blocks that each pass everything and fail together, with each team's evidence proving the other must be at fault. It is the most expensive integration argument there is, and neither side is wrong about their own block.

The corrected model: a block's own tests check it against its author's reading of the contract. Conformance requires testing against the contract itself — Section 6's harness, which has no model of any neighbour, and Section 7's monitor, which is owned by the interface rather than by either block. When two conforming blocks disagree, the specification is the defect.

16. Interview Reasoning

Because it converts a system-wide verification problem into several local ones, and verification is where the cost of change actually lands.

The chain a strong answer walks:

  • The cost is real and continuous: a boundary is a register stage, sometimes a clock-domain crossing, plus translation logic that exists only because the boundary does. It also forbids optimisations that would genuinely work.
  • The benefit is that a block verified against a contract is verified against a fixed thing. A block verified against its neighbour's behaviour must be re-verified whenever that neighbour changes, and so must everything above it.
  • So the measurable question is: how many blocks must be re-verified when one layer changes? With contracts intact, one. With a coupled design, every block that reached across, transitively.
  • Ethernet's evidence is that one MAC design outlived every physical layer from coax to 800 Gb/s optics, because nothing in the MAC ever named a medium, a rate in seconds, a coding scheme or a lane count.

What separates a good answer from a complete one: naming why boundaries erode. The cost is continuous and visible; the benefit is lumpy and invisible — nobody logs the re-verification that did not happen. So erosion never looks like abandoning layering, it looks like a series of individually reasonable optimisations, each defensible alone.

The follow-up to be ready for: are there legitimate violations? Yes — the management path, checksum offload, cut-through switching. What makes them exceptions rather than erosion is that all three are written down, bounded to a specific scope, and have their cost recorded next to them.

17. Understanding Check

18. What's Next

A layer boundary costs a register stage, a translation and a forgone optimisation, continuously and visibly. It buys a re-verification count of one instead of many, occasionally and invisibly. That mismatch is why boundaries erode, and the defence is to make the benefit a number that appears on every build.

The prohibitions themselves are Chapter 2.1's table; this chapter argued why they are worth enforcing and where they are deliberately broken.

Chapter 2.4 — Where Ethernet Stops takes the outermost of those boundaries: the one between Ethernet and everything above it. What a MAC deliberately does not do, why the payload is opaque to it, and what checksum offload actually costs when it reaches across that line.

Chapter 2.5 and Chapter 2.6 then take the two halves of the stack in turn — the work that exists because the medium is shared, and the work that exists because it is analog.

The full path is on the Ethernet curriculum index.

Continue learning

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.