Ethernet · Module 2
The PHY Layer
Coding, serialisation, clock recovery and line drive all exist because a real channel attenuates, disperses and carries no clock. The PCS, PMA and PMD split follows the same logic — each owns one consequence of physics, and each changes on its own schedule.
Chapter 2.5 organised the MAC's six responsibilities under one principle: each exists because the medium is shared, unreliable, or both. The PHY has its own principle and it is different.
The MAC's problem is that bits have no structure. The PHY's problem is that bits do not exist.
A conductor carries a voltage. A fibre carries light. Neither carries a one or a zero, and neither carries a clock. Everything the PHY does is a consequence of converting between a digital abstraction the MAC believes in and a continuous physical quantity that is all the medium actually offers.
What does the PHY do, why is it internally divided into three sublayers, and what does each sublayer own?
1. The Gap the PHY Closes
The same construction Chapter 2.5 §1 used, from the other side.
What the medium offers: a continuous physical quantity that changes over time, degrades with distance, and arrives with no annotation. There is no clock, no framing, no notion of a symbol boundary, and no guarantee the received waveform resembles the transmitted one.
What the MAC needs: a stream of bits, in order, at a known rate, with an indication of whether the stream is trustworthy.
| The gap | The responsibility | Because the channel |
|---|---|---|
| a receiver has no clock | line coding guarantees transitions | carries no clock |
| a channel with DC bias distorts | line coding balances the signal | is AC-coupled or optical |
| the medium carries one signal, the datapath is parallel | serialisation | is one channel |
| symbol timing must be extracted from the data | clock recovery | carries no clock |
| a signal degrades with distance | line drive and equalisation | attenuates and disperses |
| a receiver must know where blocks begin | block synchronisation | delivers no boundaries |
| two ends run on independent clocks | elastic buffering | connects two independent oscillators |
Two entries name line coding, and that is the observation worth carrying. Coding is not one mechanism serving one purpose — it simultaneously guarantees the transition density clock recovery needs and controls the DC content the channel requires, and those are independent physical constraints that happen to be solvable by the same choice of code.
2. Why the PHY Is Divided Into Three
The MAC is one block. The PHY is three sublayers, and the division is not arbitrary.
| Sublayer | Owns | Changes when |
|---|---|---|
| PCS | line coding, block boundaries, lane striping and alignment, idle insertion and deletion | the coding changes — a new generation, a new rate |
| PMA | serialisation, deserialisation, clock recovery, per-lane timing | the rate or lane count changes |
| PMD | drivers, receivers, electrical or optical levels, the connector | the medium changes — copper to fibre, one reach to another |
The right-hand column is the reason for the split, and it is Chapter 2.3's test applied inside the PHY: put a boundary where change is predicted, and these three change independently.
The clearest evidence is that they are mixed and matched in practice. The same PCS serves several PMDs — one coding scheme, several media and reaches. A PMD change for a longer reach leaves the PCS untouched. Collapsing all three into "the PHY" hides which one a given change affects, which is why Chapter 2.1 §6 insisted on naming them separately.
3. Line Coding — Two Problems, One Mechanism
Coding is the PCS's central job and it solves two independent problems at once.
Transition density, for clock recovery. A receiver extracts timing from the signal's transitions. A long run of identical bits produces no transitions, the receiver's timing drifts, and it eventually samples in the wrong place. Coding guarantees a transition within a bounded number of symbols.
DC balance, for the channel. Many channels are AC-coupled — transformer or capacitor isolated — and cannot pass a sustained DC offset. A code that emitted long runs of one polarity would charge the coupling and shift the receiver's decision threshold. Coding keeps the running average near zero.
The cost is overhead, and it is the direct trade. A code that maps 8 bits to 10 symbols spends 20% of the channel guaranteeing those two properties. A code that maps 64 bits to 66 spends about 3% and works harder to achieve the same guarantees. Chapter 3.5 covers the specific codes; the structural point is that overhead buys transition density and DC balance, and nothing else.
A crucial distinction, and it is the one most often blurred. Line coding maps bits to symbols. Modulation maps symbols to physical levels — how many distinguishable voltage levels a symbol occupies. They are separate choices at separate sublayers, and Chapter 3.6 owns modulation. A code is not a modulation and PAM4 is not a block code.
4. RTL 1 — A Block Coder
// SYNTHESIZABLE. A small block coder illustrating the two things every line
// code does: guarantee transitions, and control running disparity.
//
// NOT 4B/5B, 8B/10B or 64B/66B — the table is illustrative. Chapter 3.5
// owns the real codes and their normative mappings.
module block_coder #(
parameter int unsigned IN_W = 4,
parameter int unsigned OUT_W = 5
) (
input logic clk,
input logic rst_n,
input logic in_valid,
input logic [IN_W-1:0] in_data,
input logic in_control, // a control code, not data
output logic in_ready,
output logic out_valid,
output logic [OUT_W-1:0] out_code,
input logic out_ready,
// Running disparity: the cumulative excess of ones over zeros. The
// channel cannot pass a sustained offset, so this must stay bounded.
output logic signed [5:0] disparity,
output logic disparity_alarm
);
// ILLUSTRATIVE table. Every entry has at least two transitions, which is
// the transition-density guarantee — a receiver's timing recovery has
// something to lock onto no matter what the data is.
function automatic logic [OUT_W-1:0] encode(input logic [IN_W-1:0] d);
case (d)
4'h0: encode = 5'b11110; 4'h1: encode = 5'b01001;
4'h2: encode = 5'b10100; 4'h3: encode = 5'b10101;
4'h4: encode = 5'b01010; 4'h5: encode = 5'b01011;
4'h6: encode = 5'b01110; 4'h7: encode = 5'b01111;
4'h8: encode = 5'b10010; 4'h9: encode = 5'b10011;
4'hA: encode = 5'b10110; 4'hB: encode = 5'b10111;
4'hC: encode = 5'b11010; 4'hD: encode = 5'b11011;
4'hE: encode = 5'b11100; default: encode = 5'b11101;
endcase
endfunction
// A control code is a symbol that CANNOT appear in the data mapping. That
// is how a receiver distinguishes "this is an idle marker" from "this is
// the data value that happens to look like one" — and it is why a code
// must have spare symbols. A code with no spare capacity cannot signal
// anything out of band.
localparam logic [OUT_W-1:0] CTRL_IDLE = 5'b00111;
logic signed [5:0] disp_q;
logic [OUT_W-1:0] code_q;
logic v_q;
assign in_ready = !v_q || out_ready;
assign out_valid = v_q;
assign out_code = code_q;
wire [OUT_W-1:0] chosen = in_control ? CTRL_IDLE : encode(in_data);
// Ones minus zeros for this symbol. Summed over time this is the DC
// content the channel sees.
wire signed [5:0] delta = 6'sd0 + 6'($countones(chosen)) - 6'(OUT_W - $countones(chosen));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
v_q <= 1'b0; code_q <= '0; disp_q <= '0;
end else begin
if (v_q && out_ready) v_q <= 1'b0;
if (in_valid && in_ready) begin
code_q <= chosen;
v_q <= 1'b1;
disp_q <= disp_q + delta;
end
end
end
assign disparity = disp_q;
// A real code SELECTS between two representations of each value to hold
// disparity bounded. This model only MEASURES it, and alarms when the
// bound is exceeded — which makes the mechanism's absence visible rather
// than pretending the simplification does not matter.
assign disparity_alarm = (disp_q > 6'sd6) || (disp_q < -6'sd6);
endmoduleClassification: synthesizable.
What it teaches: that a line code does three jobs, not one. It guarantees transitions so timing can be recovered, it bounds running disparity so an AC-coupled channel is not charged, and — the one most often forgotten — it provides spare symbols that cannot occur in data, which is the only way to signal anything out of band.
The control-code point is the subtle one. Idle, start and error indications have to be distinguishable from every possible data value. A code with no spare capacity cannot express them, so the coding overhead is not purely a tax: part of it buys an out-of-band signalling alphabet the layers above depend on.
disparity_alarm is honest about the simplification. A real code selects
between two representations of each value to actively hold disparity near zero.
This model only measures it and reports when the bound is exceeded, which makes
the missing mechanism visible instead of leaving a reader to assume it is there.
Deliberately simplified: an illustrative table rather than a normative one; no disparity-driven selection; one symbol at a time where a real coder is wide; no decoder.
Production implication: a real coder implements the selection rule that keeps disparity bounded, decodes with an invalid-symbol indication for codes that cannot occur, and reports invalid symbols as a distinct error — because an invalid code group means the channel corrupted a symbol, which is different from a frame that failed its check value.
5. Serialisation — Because the Medium Is One Channel
A datapath is parallel because parallel logic is cheap. A medium is serial because conductors and fibres are expensive and because parallel media suffer skew between their lanes.
So the PMA converts, and the conversion has consequences worth naming.
The serial rate is the parallel rate times the width. A 32-bit datapath at some clock rate becomes a serial stream at 32 times that rate, and that multiplication is why the serial side is the hard part of a high-rate design.
The bit order is a specification decision. Which end of the parallel word goes first must be agreed by both sides, and getting it wrong produces a link that comes up, reports health, and garbles every frame — Chapter 2.1 §14's failure, at a different boundary.
And multi-lane links reintroduce the problem serialisation solved. When one serial stream is not fast enough, the PCS stripes across several lanes and the lanes arrive skewed, so alignment markers are inserted and the receiver deskews them. Chapter 9.5 develops this; the structural point is that lane striping is the PCS's job and per-lane timing is the PMA's, which is why they are separate sublayers.
6. RTL 2 — Serialiser and Deserialiser
// SYNTHESIZABLE. Parallel to serial and back, in one clock domain.
//
// NOT a real PMA: a real serial side runs at WIDTH times the parallel rate
// and uses circuit techniques outside RTL. This is the digital shape.
module pma_serdes #(
parameter int unsigned WIDTH = 10,
// Which end goes first. A SPECIFICATION decision that both ends must
// share — and the classic bring-up failure when they do not, because the
// link comes up and every frame is garbled.
parameter bit MSB_FIRST = 1'b1
) (
input logic clk,
input logic rst_n,
// Transmit: parallel in, serial out.
input logic tx_valid,
input logic [WIDTH-1:0] tx_word,
output logic tx_ready,
output logic serial_out,
output logic serial_active,
// Receive: serial in, parallel out. Alignment is NOT solved here — this
// block emits a word every WIDTH bits from wherever it started counting,
// which is almost certainly the wrong boundary. Section 8 fixes that.
input logic serial_in,
input logic serial_valid,
output logic rx_valid,
output logic [WIDTH-1:0] rx_word
);
localparam int unsigned CNT_W = $clog2(WIDTH);
// ── Transmit ───────────────────────────────────────────────────────────
logic [WIDTH-1:0] tx_sr_q;
logic [CNT_W-1:0] tx_cnt_q;
logic tx_busy_q;
assign tx_ready = !tx_busy_q || (tx_cnt_q == CNT_W'(WIDTH - 1));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
tx_sr_q <= '0; tx_cnt_q <= '0; tx_busy_q <= 1'b0;
end else if (tx_valid && tx_ready) begin
tx_sr_q <= tx_word;
tx_cnt_q <= '0;
tx_busy_q <= 1'b1;
end else if (tx_busy_q) begin
// Shift toward whichever end goes first. The two cases are mirror
// images and choosing wrongly is undetectable locally — both ends
// must agree, and neither can tell alone.
tx_sr_q <= MSB_FIRST ? {tx_sr_q[WIDTH-2:0], 1'b0}
: {1'b0, tx_sr_q[WIDTH-1:1]};
if (tx_cnt_q == CNT_W'(WIDTH - 1)) tx_busy_q <= 1'b0;
else tx_cnt_q <= tx_cnt_q + 1'b1;
end
end
assign serial_out = MSB_FIRST ? tx_sr_q[WIDTH-1] : tx_sr_q[0];
assign serial_active = tx_busy_q;
// ── Receive ────────────────────────────────────────────────────────────
logic [WIDTH-1:0] rx_sr_q;
logic [CNT_W-1:0] rx_cnt_q;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rx_sr_q <= '0; rx_cnt_q <= '0;
end else if (serial_valid) begin
rx_sr_q <= MSB_FIRST ? {rx_sr_q[WIDTH-2:0], serial_in}
: {serial_in, rx_sr_q[WIDTH-1:1]};
rx_cnt_q <= (rx_cnt_q == CNT_W'(WIDTH - 1)) ? '0 : rx_cnt_q + 1'b1;
end
end
// THE HONEST LIMITATION. A word is emitted every WIDTH bits from an
// arbitrary starting point. Nothing here knows where a code group
// actually begins — that is block synchronisation, and it is a SEARCH
// (Chapter 2.2's discovery problem) rather than a conversion.
assign rx_valid = serial_valid && (rx_cnt_q == CNT_W'(WIDTH - 1));
assign rx_word = rx_sr_q;
endmoduleClassification: synthesizable.
What it teaches: that deserialisation and alignment are different problems, and that a deserialiser alone produces words from an arbitrary boundary. It converts; it does not discover. Section 8's block synchroniser is what finds the real boundary, and separating the two is why the PCS and PMA are different sublayers.
MSB_FIRST is a parameter and not a preference. Both ends must agree, and
neither can determine the other's choice from its own observations. A mismatch
produces a link that trains, locks, reports every status bit healthy, and
delivers garbage — the same shape as
Chapter 2.1 §14's width mismatch, and equally
invisible to every per-layer status term.
Deliberately simplified: one clock domain, where a real SerDes has at least
two; no analog behaviour; serial_valid stands in for a recovered bit clock; no
loopback.
Production implication: a real serialiser's output rate is a multiple of its input clock and is generated by circuitry outside the RTL abstraction; its verification is partly measurement rather than simulation; and the bit-order choice is fixed by the standard rather than parameterised, precisely because a parameter invites two implementations to differ.
7. RTL 3 — Clock Recovery, With Its Abstraction Stated
The PHY responsibility that RTL models least honestly, so the model states its boundary explicitly.
// BEHAVIOURAL MODEL — not synthesisable as a real recovery loop.
//
// ABSTRACTION BOUNDARY, stated: a real CDR is a phase detector, a loop
// filter and a controlled oscillator, operating in continuous time. This
// model captures only the digital consequences a MAC-side designer must
// account for: acquisition takes time, lock is lost when transitions stop,
// and re-acquisition takes time again.
module cdr_behavioural #(
// ILLUSTRATIVE. Real acquisition time depends on loop bandwidth and the
// incoming signal, and is specified in the PHY's datasheet.
parameter int unsigned LOCK_SYMBOLS = 64,
// The bounded run length a code guarantees. Exceeding it starves the
// recovery loop — which is WHY line codes guarantee transition density.
parameter int unsigned MAX_RUN_LENGTH = 5
) (
input logic clk,
input logic rst_n,
input logic signal_present, // from the PMD: something is arriving
input logic rx_bit,
input logic rx_bit_valid,
output logic clk_locked,
output logic [7:0] lock_acquisitions,
output logic [7:0] lock_losses,
output logic [3:0] longest_run
);
logic [7:0] lock_cnt_q;
logic [3:0] run_q, longest_q;
logic last_bit_q, locked_q;
logic [7:0] acq_q, loss_q;
// Run length: consecutive identical bits. This is the quantity a line
// code exists to bound, and tracking it here is what connects Section 3's
// coding argument to a measurable consequence.
wire same_as_last = (rx_bit == last_bit_q);
wire run_too_long = (run_q >= 4'(MAX_RUN_LENGTH));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
lock_cnt_q <= '0; run_q <= '0; longest_q <= '0;
last_bit_q <= 1'b0; locked_q <= 1'b0; acq_q <= '0; loss_q <= '0;
end else begin
if (rx_bit_valid) begin
last_bit_q <= rx_bit;
run_q <= same_as_last ? run_q + 1'b1 : '0;
if (run_q > longest_q) longest_q <= run_q;
end
if (!signal_present) begin
// No signal, no lock. Immediate, because there is nothing to track.
if (locked_q && loss_q != 8'hFF) loss_q <= loss_q + 1'b1;
locked_q <= 1'b0;
lock_cnt_q <= '0;
end else if (run_too_long && locked_q) begin
// Transitions stopped for longer than the code should ever allow.
// A real loop drifts and eventually samples wrongly; this model
// declares the loss, which is the digital consequence.
if (loss_q != 8'hFF) loss_q <= loss_q + 1'b1;
locked_q <= 1'b0;
lock_cnt_q <= '0;
end else if (!locked_q && rx_bit_valid) begin
// Acquisition takes time. That delay is why a link does not come up
// instantly and why Chapter 2.1's link_up has a term for it.
if (lock_cnt_q == 8'(LOCK_SYMBOLS - 1)) begin
locked_q <= 1'b1;
if (acq_q != 8'hFF) acq_q <= acq_q + 1'b1;
end else lock_cnt_q <= lock_cnt_q + 1'b1;
end
end
end
assign clk_locked = locked_q;
assign lock_acquisitions = acq_q;
assign lock_losses = loss_q;
assign longest_run = longest_q;
endmoduleClassification: behavioural model; not synthesisable as a real recovery loop, and the header says so.
What it teaches: three digital consequences a designer above the PHY must
account for. Acquisition takes time, so a link does not come up instantly and
link_up needs a term for it. Lock can be lost, so the term is dynamic
rather than a one-time event. Transition density determines whether lock
holds, which is the direct connection between Section 3's coding argument and
something measurable.
longest_run is the diagnostic that connects the layers. If the observed run
length approaches what the code should guarantee, either the coder is wrong or
the received symbols are corrupted — and in both cases the symptom appears as
lock loss, two sublayers away from the cause.
Stating the abstraction boundary is the point of the module. A model that pretended to be a real CDR would teach a wrong mental picture, and an engineer who believed it would be surprised by every measurement. Naming what is abstracted is what makes the model useful rather than misleading.
Deliberately simplified: everything continuous-time; no jitter, no loop bandwidth, no frequency offset — Chapter 4.4's elastic buffer handles the offset's consequences and Section 9 models it; lock declared by a counter rather than by a phase relationship.
Production implication: real acquisition time comes from the PHY's datasheet and feeds the system's link-up budget; lock loss is counted and is a leading indicator of a marginal channel; and the recovery loop's behaviour is characterised by measurement, which is where RTL verification stops being sufficient.
8. RTL 4 — Block Synchronisation, Which Is a Search
Section 6's deserialiser emits words from an arbitrary boundary. Finding the real one is a separate problem and a different kind of problem.
// SYNTHESIZABLE. Finds the code-group boundary by trying an offset,
// measuring how often it yields valid symbols, and shifting when it does
// not.
//
// NOT a standard's mechanism — Chapter 3.5 owns those. The structure is the
// content: hypothesis, evidence, commit, and re-verify.
module block_sync #(
parameter int unsigned WIDTH = 10,
// How many consecutive valid symbols before declaring lock. Larger is
// slower to lock and less likely to lock falsely.
parameter int unsigned GOOD_LIMIT = 16,
// How many invalid symbols before giving up the hypothesis.
parameter int unsigned BAD_LIMIT = 4
) (
input logic clk,
input logic rst_n,
input logic sym_valid,
input logic [WIDTH-1:0] sym_data,
input logic sym_legal, // from the decoder: is this a
// symbol the code can produce?
output logic shift_request, // try the next offset
output logic block_lock,
output logic [$clog2(WIDTH)-1:0] offset,
output logic [7:0] lock_losses
);
typedef enum logic [1:0] { S_HUNT, S_TEST, S_LOCKED } s_state_e;
s_state_e state_q, state_d;
logic [$clog2(GOOD_LIMIT+1)-1:0] good_q;
logic [$clog2(BAD_LIMIT+1)-1:0] bad_q;
logic [$clog2(WIDTH)-1:0] off_q;
logic [7:0] loss_q;
// THE EVIDENCE. A wrong offset slices code groups across their
// boundaries, producing bit patterns the code cannot generate. Illegal
// symbols are therefore the signal that the hypothesis is wrong — and
// this is why a code needs INVALID symbols as well as spare valid ones.
// A code in which every bit pattern were legal could not be synchronised
// this way at all.
always_comb begin
state_d = state_q;
case (state_q)
S_HUNT: if (sym_valid) state_d = S_TEST;
S_TEST: if (sym_valid && !sym_legal) state_d = S_HUNT;
else if (good_q == GOOD_LIMIT-1) state_d = S_LOCKED;
// Lock is not permanent. A locked receiver keeps checking, because a
// channel that degrades will start producing illegal symbols and the
// offset may need re-establishing.
S_LOCKED: if (bad_q == BAD_LIMIT-1) state_d = S_HUNT;
default: state_d = S_HUNT;
endcase
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state_q <= S_HUNT; good_q <= '0; bad_q <= '0; off_q <= '0; loss_q <= '0;
end else begin
state_q <= state_d;
if (state_q == S_HUNT && state_d == S_TEST) good_q <= '0;
else if (state_q == S_TEST && sym_valid)
good_q <= sym_legal ? good_q + 1'b1 : '0;
if (state_q != S_LOCKED) bad_q <= '0;
else if (sym_valid) bad_q <= sym_legal ? '0 : bad_q + 1'b1;
// Each failed hypothesis advances to the next offset. WIDTH offsets
// exist and exactly one is right, so this terminates — a search that
// could not exhaust its space would be a receiver that hangs.
if (state_q == S_TEST && state_d == S_HUNT)
off_q <= (off_q == $clog2(WIDTH)'(WIDTH-1)) ? '0 : off_q + 1'b1;
if (state_q == S_LOCKED && state_d == S_HUNT && loss_q != 8'hFF)
loss_q <= loss_q + 1'b1;
end
end
assign shift_request = (state_q == S_TEST) && (state_d == S_HUNT);
assign block_lock = (state_q == S_LOCKED);
assign offset = off_q;
assign lock_losses = loss_q;
endmoduleClassification: synthesizable.
What it teaches: that block synchronisation is Chapter 2.2's discovery problem in its purest form — a hypothesis, evidence gathered over time, a commitment, and continuous re-verification. It is not a conversion and it cannot be made one.
Illegal symbols are the evidence, and that is a design consequence. A wrong offset slices code groups across their boundaries and produces patterns the code cannot generate. A code in which every bit pattern were legal could not be synchronised this way at all — so the coding overhead in Section 4 buys a third thing beyond transitions and DC balance: a set of invalid patterns that makes alignment detectable.
GOOD_LIMIT and BAD_LIMIT are the two halves of a confidence trade. A
large GOOD_LIMIT locks slowly and rarely locks falsely; a small one locks fast
and can commit to a wrong offset that happens to produce a run of legal-looking
symbols. A large BAD_LIMIT tolerates a noisy channel; a small one abandons a
good lock on a single corrupted symbol.
Deliberately simplified: an abstract sym_legal input rather than a real
decoder; one offset tried at a time, where a real design may test several in
parallel; no distinction between a lock lost to noise and one lost to a genuine
realignment.
Production implication: a real synchroniser's thresholds come from a target false-lock probability and a target lock time, both derived from the channel's expected error rate; and lock losses are counted because a rising count is a leading indicator of channel degradation, visible before frames start failing their check value.
9. RTL 5 — The Elastic Buffer
Two ends of a link run on independent oscillators that are nominally the same frequency and are not exactly equal. The receiver takes bits at the far end's rate and must forward them at its own.
// SYNTHESIZABLE. Absorbs a bounded frequency difference between the
// recovered receive clock and the local clock by deleting idle when the
// buffer fills and inserting it when the buffer empties.
//
// Chapter 4.4 owns this mechanism in depth; this shows its place in the PHY.
module elastic_buffer #(
parameter int unsigned WIDTH = 10,
parameter int unsigned DEPTH = 16,
localparam int unsigned PTR_W = $clog2(DEPTH),
localparam int unsigned CNT_W = $clog2(DEPTH + 1)
) (
input logic clk,
input logic rst_n,
input logic wr_valid,
input logic [WIDTH-1:0] wr_data,
input logic wr_is_idle, // this symbol is deletable
input logic rd_ready,
output logic rd_valid,
output logic [WIDTH-1:0] rd_data,
output logic [CNT_W-1:0] occupancy,
output logic [15:0] idles_deleted,
output logic [15:0] idles_inserted,
output logic overflow,
output logic underflow
);
localparam logic [CNT_W-1:0] HIGH_MARK = CNT_W'((DEPTH * 3) / 4);
localparam logic [CNT_W-1:0] LOW_MARK = CNT_W'(DEPTH / 4);
localparam logic [WIDTH-1:0] IDLE_SYM = {WIDTH{1'b0}}; // ILLUSTRATIVE
logic [WIDTH-1:0] mem_q [DEPTH];
logic [PTR_W-1:0] wr_q, rd_q;
logic [CNT_W-1:0] cnt_q;
logic [15:0] del_q, ins_q;
wire full = (cnt_q == CNT_W'(DEPTH));
wire empty = (cnt_q == '0);
// THE ADJUSTMENT, and both directions are needed because the offset can
// have either sign. Idle is the ONLY symbol that may be added or removed,
// because it carries no information — which is why the interframe gap is
// a MINIMUM rather than a fixed value (Chapter 2.5 section 8).
wire delete_now = wr_valid && wr_is_idle && (cnt_q >= HIGH_MARK);
wire insert_now = rd_ready && empty;
wire do_wr = wr_valid && !full && !delete_now;
wire do_rd = rd_ready && !empty;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_q <= '0; rd_q <= '0; cnt_q <= '0; del_q <= '0; ins_q <= '0;
end else begin
if (do_wr) begin
mem_q[wr_q] <= wr_data;
wr_q <= (wr_q == PTR_W'(DEPTH-1)) ? '0 : wr_q + 1'b1;
end
if (do_rd) rd_q <= (rd_q == PTR_W'(DEPTH-1)) ? '0 : rd_q + 1'b1;
case ({do_wr, do_rd})
2'b10: cnt_q <= cnt_q + 1'b1;
2'b01: cnt_q <= cnt_q - 1'b1;
default: cnt_q <= cnt_q;
endcase
if (delete_now && del_q != 16'hFFFF) del_q <= del_q + 1'b1;
if (insert_now && ins_q != 16'hFFFF) ins_q <= ins_q + 1'b1;
end
end
assign rd_valid = !empty || insert_now;
assign rd_data = empty ? IDLE_SYM : mem_q[rd_q];
assign occupancy = cnt_q;
// These two must NEVER assert. If they do, the frequency offset exceeded
// what the buffer can absorb, or the idle supply was insufficient — and
// the consequence is corrupted data rather than merely degraded timing.
assign overflow = wr_valid && full && !wr_is_idle;
assign underflow = rd_ready && empty && !insert_now;
assign idles_deleted = del_q;
assign idles_inserted = ins_q;
endmoduleClassification: synthesizable.
What it teaches: that idle is the only adjustable quantity in the stream, because it is the only part that carries no information. That is why the interframe gap is a minimum rather than a fixed value — the PCS may consume some of it absorbing a clock offset, and a receiver that demanded exactly the minimum would fail on any link with a real frequency difference.
Both directions are needed and the reason is that the offset has a sign. If the far end is faster the buffer fills and idle must be deleted; if slower it empties and idle must be inserted. A design implementing only one direction works against half the population of peers.
overflow and underflow must never assert, and counting them is what turns
a silent corruption into a diagnosis. If either fires, the offset exceeded the
buffer's capacity or the idle supply was insufficient — and the consequence is
not degraded timing but lost or duplicated symbols, which appear far upstream as
frames failing their check value with no apparent cause.
Deliberately simplified: one clock domain, where a real elastic buffer spans two and needs proper CDC; a single idle symbol rather than a sequence; fixed marks rather than ones derived from a specified frequency tolerance and maximum frame length.
Production implication: the depth is derived from the worst-case frequency offset multiplied by the longest interval without an idle opportunity — which is the maximum frame size — so a design supporting jumbo frames needs a deeper elastic buffer than one that does not. That is a direct dependency between Chapter 5.7's frame size and this block's area, and it is easy to miss.
10. RTL 6 — Signal Quality, Feeding the Link Status
Chapter 2.1 defined link_up as a conjunction
of four terms from three layers. Three of them come from here.
// SYNTHESIZABLE. Aggregates the PHY's own conditions into the terms
// Chapter 2.1's link_up consumes, and qualifies them so a MARGINAL link is
// distinguishable from a healthy one.
module phy_status #(
// How long every condition must hold before the link is declared up.
// Prevents a link that is oscillating from appearing usable.
parameter int unsigned STABLE_SYMBOLS = 256,
// Lock losses within the observation window that make a link marginal
// even though it is currently up.
parameter int unsigned MARGINAL_LOSSES = 4
) (
input logic clk,
input logic rst_n,
input logic signal_detect, // PMD
input logic clk_locked, // PMA
input logic block_lock, // PCS
input logic lane_aligned, // PCS
input logic [7:0] cdr_losses,
input logic [7:0] block_losses,
output logic phy_ready,
output logic phy_marginal, // up, and should not be trusted
output logic [1:0] lowest_failing // which term fails first
);
logic [$clog2(STABLE_SYMBOLS+1)-1:0] stable_q;
logic ready_q;
// ALL FOUR, and the order matters for diagnosis rather than for logic:
// each depends on the ones below it, so the LOWEST failing term is the
// cause and everything above it is a consequence. Chapter 2.1 section 15
// builds the descent method on exactly this.
wire all_ok = signal_detect && clk_locked && block_lock && lane_aligned;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
stable_q <= '0; ready_q <= 1'b0;
end else if (!all_ok) begin
// Any term dropping clears the timer immediately. A link that
// oscillates never accumulates enough stability to be declared up,
// which is the intended behaviour — an intermittent link is worse
// than a down one, because software will try to use it.
stable_q <= '0;
ready_q <= 1'b0;
end else if (stable_q != $clog2(STABLE_SYMBOLS+1)'(STABLE_SYMBOLS)) begin
stable_q <= stable_q + 1'b1;
end else begin
ready_q <= 1'b1;
end
end
assign phy_ready = ready_q;
// MARGINAL is the output that earns its area. A link can satisfy every
// term right now and have lost lock repeatedly, which predicts failure
// under load. Without this, "up" and "reliably up" are the same reading.
assign phy_marginal = ready_q
&& ((cdr_losses >= 8'(MARGINAL_LOSSES))
|| (block_losses >= 8'(MARGINAL_LOSSES)));
// The lowest failing term, for the descent in Chapter 2.1 section 15.
always_comb begin
if (!signal_detect) lowest_failing = 2'd0; // PMD
else if (!clk_locked) lowest_failing = 2'd1; // PMA
else if (!block_lock) lowest_failing = 2'd2; // PCS
else if (!lane_aligned) lowest_failing = 2'd3; // PCS
else lowest_failing = 2'd3;
end
endmoduleClassification: synthesizable.
What it teaches: that a link status built from a conjunction alone is insufficient. Four terms all true is "up now"; four terms true and stable for a declared interval is "usable"; and four terms true with a history of losses is marginal — a link that will fail under load and currently reports healthy.
phy_marginal is the output most often missing from real designs, and its
absence is why a degrading link presents as an intermittent application problem
rather than as a physical one. The information exists — the losses were counted —
and without this output nobody looks at it until the link fails entirely.
lowest_failing encodes the dependency order that
Chapter 2.1 §15's debugging method walks. The
terms are not independent: block lock cannot hold without clock lock, which
cannot hold without signal. Reporting the lowest failing term reports the
cause; reporting all failing terms reports the cause and its consequences
together, which is more data and less information.
Deliberately simplified: symbol-counted stability rather than a real timer; no hysteresis on the marginal threshold; losses as free-running counters rather than within a sliding window, which is what a real marginality measure needs.
Production implication: a real design measures losses within a sliding window so an old fault does not mark a link marginal forever, exposes every term individually through the management interface — Chapter 2.1 §7 — and defines a de-assertion threshold separate from the assertion one, so a marginal link does not oscillate between states.
11. Waveform — A Link Coming Up
Signal, clock, block, lanes, then ready
10 cyclesThe terms assert strictly bottom-up and never out of order. Clock lock cannot precede signal detect, because there is nothing to lock to; block lock cannot precede clock lock, because symbol boundaries cannot be found without symbol timing. That dependency is why the debugging method descends, and P4 asserts it.
shift_req at cycle 5 is a rejected hypothesis. The synchroniser tried an
offset, saw illegal symbols, and advanced. That is the search from Section 8
visible as a single pulse — and a link that never locks shows this pulse
repeating forever, which is a far more useful symptom than "no block lock".
phy_ready lags lane_align by the stability interval. Everything is true at
cycle 6 and the link is not declared until cycle 8, deliberately. A link that
oscillates never accumulates the interval and is never declared up — which is
correct, because an intermittent link is worse than a down one.
12. Assertions
Invariants of these models. None is an IEEE requirement, and the behavioural CDR's properties describe the model's declared abstraction rather than real recovery.
// SVA over the modules in this chapter.
// SAFETY — P1: running disparity stays bounded. The DC-balance guarantee;
// exceeding it means an AC-coupled channel accumulates an offset and the
// receiver's threshold shifts.
property p_disparity_bounded;
@(posedge clk) disable iff (!rst_n)
!disparity_alarm;
endproperty
a_disparity_bounded : assert property (p_disparity_bounded);
// SAFETY — P2: a control code is never a data code. If they overlapped, a
// receiver could not distinguish an idle marker from a data value, and the
// out-of-band alphabet would not exist.
property p_control_distinct;
@(posedge clk) disable iff (!rst_n)
(out_valid && in_control) |-> (out_code == CTRL_IDLE);
endproperty
a_control_distinct : assert property (p_control_distinct);
// CONSERVATION — P3: every parallel word produces exactly WIDTH serial
// bits. Catches a shift counter that skips or repeats, which shifts every
// subsequent word and garbles the link from that point on.
property p_serialiser_conserves;
@(posedge clk) disable iff (!rst_n)
(tx_valid && tx_ready) |=> (serial_active [*WIDTH]);
endproperty
a_serialiser_conserves : assert property (p_serialiser_conserves);
// SAFETY — P4: status terms assert bottom-up. Block lock without clock lock
// is impossible physically, so asserting it catches a status path that
// latches or a term computed from the wrong source.
property p_status_ordered;
@(posedge clk) disable iff (!rst_n)
block_lock |-> (clk_locked && signal_detect);
endproperty
a_status_ordered : assert property (p_status_ordered);
// SAFETY — P5: the block synchroniser's search terminates. WIDTH offsets
// exist and exactly one is correct; a search that could not exhaust its
// space is a receiver that hangs with no error.
property p_search_terminates;
@(posedge clk) disable iff (!rst_n)
(state_q == S_HUNT) |-> s_eventually (offset != $past(offset) || block_lock);
endproperty
a_search_terminates : assert property (p_search_terminates);
// SAFETY — P6: lock is re-verified while held. A locked receiver that
// stopped checking would hold a stale lock through a channel degradation
// and deliver garbage with every status bit green.
property p_lock_reverified;
@(posedge clk) disable iff (!rst_n)
(block_lock && sym_valid && !sym_legal) |=> (bad_q > 0 || !block_lock);
endproperty
a_lock_reverified : assert property (p_lock_reverified);
// SAFETY — P7: the elastic buffer never overflows or underflows. Both mean
// the frequency offset exceeded what the buffer absorbs, and the result is
// corrupted data rather than degraded timing.
property p_elastic_never_spills;
@(posedge clk) disable iff (!rst_n)
!overflow && !underflow;
endproperty
a_elastic_safe : assert property (p_elastic_never_spills);
// SAFETY — P8: only idle symbols are deleted. Deleting a data symbol
// silently corrupts a frame, and the check value two layers up is the only
// thing that would ever notice.
property p_only_idle_deleted;
@(posedge clk) disable iff (!rst_n)
delete_now |-> wr_is_idle;
endproperty
a_only_idle_deleted : assert property (p_only_idle_deleted);
// SAFETY — P9: the link is not declared ready until every term has been
// stable for the declared interval. Catches a design that reports up on the
// instant all four are true, which makes an oscillating link look usable.
property p_ready_needs_stability;
@(posedge clk) disable iff (!rst_n)
$rose(phy_ready) |-> (stable_q == STABLE_SYMBOLS);
endproperty
a_ready_stable : assert property (p_ready_needs_stability);
// LIVENESS — P10: a link with a good signal eventually becomes ready.
// ASSUMPTIONS, stated: the signal persists, and the symbols are legal at
// some offset. Without both this is false for a correct design — which is
// the whole point of a receiver that can fail to lock.
assume property (@(posedge clk) s_eventually (signal_detect));
assume property (@(posedge clk) s_eventually (sym_valid && sym_legal));
property p_link_eventually_ready;
@(posedge clk) disable iff (!rst_n)
signal_detect |-> s_eventually (phy_ready);
endproperty
a_link_ready : assert property (p_link_eventually_ready);The property that must not be written
// FALSE for a correct design. Included as a warning, not as a check.
// property p_lock_once_acquired_is_held;
// @(posedge clk) disable iff (!rst_n)
// block_lock |=> block_lock;
// endpropertyIt reads like a stability requirement on a working receiver, and it forbids the mechanism that makes a receiver safe.
Lock must be losable. A channel that degrades starts producing illegal symbols,
and a receiver that could not abandon a stale lock would keep delivering garbage
with every status bit reporting healthy — which is strictly worse than reporting
the link down. Section 8's BAD_LIMIT exists precisely to lose lock, and P6
asserts that it does.
The correct properties are P4 and P6: lock depends on the terms below it, and lock is continuously re-verified while held. Both are true of a correct design, and together they catch the two real bugs — a status term computed from the wrong source, and a lock that is never rechecked.
Writing the wrong version fires on the first channel-degradation test, gets waived as "the test is too aggressive", and removes attention from P6 — the property that catches a receiver holding a stale lock, which is the failure mode that produces corrupted data with a green link.
13. Verification
Monitors observe: the coder's disparity and control codes; the serialiser's bit count per word; the synchroniser's state, offset and both counters; the elastic buffer's occupancy, insert and delete counts; and every status term against the aggregate.
The scoreboard independently predicts the encoded symbol for each input from its own copy of the table, the expected serial bit sequence from the parallel word and the declared bit order, and the expected occupancy from the write and read events. It must not read the design's disparity accumulator — a checker sharing the design's accumulator agrees with it about every accumulation bug.
Scenarios
- Every data value encoded. Verify each maps to a legal symbol and that every symbol has at least the guaranteed transition count.
- A long run of one value. Verify disparity stays bounded (P1) — the case that would charge an AC-coupled channel.
- Control codes interleaved with data. Verify a control code is never producible from data (P2).
- Serialisation, both bit orders. Verify the serial sequence matches the
declared order and that exactly
WIDTHbits leave per word (P3). - Bit-order mismatch. Serialise MSB-first and deserialise LSB-first. Verify the words differ — the failure that reports every status bit healthy.
- Clock recovery, acquisition. Verify lock after the declared interval and not before.
- Clock recovery, run-length violation. Feed a run longer than the code should permit. Verify lock is lost and counted.
- Block sync, correct offset first. Verify lock without any shift request.
- Block sync, every wrong offset. Start at each of the
WIDTHoffsets in turn and verify the search terminates at the right one (P5). - Block sync, degradation while locked. Inject illegal symbols. Verify lock
is lost after
BAD_LIMIT(P6) and re-acquired when the channel recovers. - Elastic buffer, far end faster. Verify idle deletion, that only idle is deleted (P8), and that occupancy stays centred.
- Elastic buffer, far end slower. Verify idle insertion and no underflow.
- Elastic buffer, offset beyond capacity. Verify overflow is detected — this scenario must fail P7 deliberately, to prove the detection works.
- Status, each term dropping alone. Four scenarios. Verify
phy_readyfalls andlowest_failingnames the right term. - Status, oscillating link. Toggle a term repeatedly. Verify
phy_readynever asserts (P9) — the case that makes an intermittent link look usable. - Reset in each state of each module. Verify no stale lock, no stale offset, no stale occupancy.
Coverage
Cross every data value against running disparity sign. Cover run lengths from one
to beyond the code's guarantee. Cover all WIDTH starting offsets in the
synchroniser. Cover elastic occupancy at empty, low mark, centre, high mark and
full, with the offset in both directions. Cover every combination of the four
status terms.
A directed stimulus for the bit-order mismatch
// NON-SYNTHESIZABLE — directed stimulus. Instantiates a serialiser and a
// deserialiser with OPPOSITE bit orders and shows that every status bit
// reports healthy while every word is wrong.
task automatic bit_order_mismatch_reports_healthy();
logic [WIDTH-1:0] sent, got;
sent = 10'b1100100011;
send_word(sent);
wait (rx_valid);
got = rx_word;
// Everything that could report a problem does not.
assert (dut_phy.phy_ready)
else $error("precondition: the link should report ready");
assert (dut_sync.block_lock)
else $error("precondition: block lock should hold");
assert (dut_eb.overflow == 1'b0 && dut_eb.underflow == 1'b0)
else $error("precondition: the elastic buffer should be quiet");
// AND THE DATA IS REVERSED. This is the only evidence, and it requires
// comparing across the boundary — no single block can detect it.
assert (got == {<<{sent}})
else $error("expected a bit-reversed word; the mismatch model is wrong");
assert (got != sent)
else $error("bit orders did not actually differ — the test proves nothing");
$display("bit-order mismatch: sent %b, received %b, every status term healthy",
sent, got);
endtaskThe final $display is the finding, not debug output. It records that a link
can report entirely healthy and deliver reversed data, which is the argument for
an end-to-end identity check —
Chapter 2.2 §7 — existing at all.
14. Debugging — Descending the PHY
Chapter 2.1 §15 gave the descent; this fills in what each PHY term actually means when it fails.
| Failing term | Sublayer | Likely cause |
|---|---|---|
| No signal detect | PMD | Cable, connector, transceiver, far end unpowered |
| Signal, no clock lock | PMA | Rate mismatch, or a channel too degraded to recover timing |
| Clock locked, no block lock | PCS | Coding mismatch, or a rate close enough to lock but wrong |
| Block lock, no lane alignment | PCS | Skew beyond the deskew range, a swapped lane, a missing lane |
| All terms up, frames garbled | boundary | Bit order or width mismatch — no term reports it |
| All terms up, marginal flag set | PMA or PCS | Repeated lock loss; a degrading channel before it fails |
The fifth row has no status term and needs Section 13's scenario 5. Every per-layer condition is satisfied and the data is wrong, because the fault is in a relationship between two blocks and no single block can observe a relationship.
The sixth row is the one worth building for. phy_marginal reports a link
that is currently up and has lost lock repeatedly — which predicts failure under
load and is visible before anything breaks. Most designs have the information and
do not expose it, so the first symptom is an outage rather than a warning.
15. Common Misconceptions
"The PHY is one block."
The wrong model: everything below the MAC is a single unit called the PHY.
What it costs: it becomes impossible to say which part a change affects, so a PMD change for a longer reach is treated as a whole-PHY revision, and a coding change is assumed to require new analog work. It also hides where verification stops being sufficient.
The corrected model: three sublayers that change independently. The PCS changes with the coding, the PMA with the rate or lane count, the PMD with the medium. The same PCS serves several PMDs in practice, which is only expressible if the three are named separately.
"Line coding is just overhead."
The wrong model: a tax on the channel, imposed by the standard, that a cleverer design would avoid.
What it costs: proposals to reduce or remove it that would break clock recovery, DC balance and block synchronisation simultaneously — and the failures would appear at three different places with no obvious common cause.
The corrected model: coding buys three things. Transition density, so timing can be recovered from the data. DC balance, so an AC-coupled channel is not charged. And invalid symbols, without which block synchronisation has no evidence to work from and alignment cannot be found at all. The overhead is the price of all three, which is why it fell from 25% to about 3% rather than to zero.
"PAM4 is a line code."
The wrong model: coding and modulation are the same kind of choice.
What it costs: the two are made at different sublayers for different reasons, and conflating them makes the generation history incoherent — it becomes unclear why a rate increase sometimes changed the coding and sometimes the modulation.
The corrected model: line coding maps bits to symbols; modulation maps symbols to physical levels. A block code and a modulation scheme are independent choices, and a link has both. Chapter 3.5 owns codes, Chapter 3.6 owns modulation, and Section 3 keeps them apart deliberately.
"If the link reports up, the physical layer is working."
The wrong model: link_up is a health indicator for everything below the MAC.
What it costs: the bit-order and width mismatches in Section 13's scenario 5 are undiagnosable, and a marginal link that has lost lock repeatedly is treated as healthy until it fails outright.
The corrected model: link_up is a conjunction of per-layer conditions, and
every term is about a layer meeting its own requirement. None is about two
layers agreeing with each other, and none is about the link's stability. A
correct status design adds two things the conjunction lacks: a stability interval
before declaring up, and a marginality indication from the loss counters.
16. Interview Reasoning
Everything that exists because a real channel is analog — it attenuates, disperses, and carries no clock.
The responsibilities:
- Line coding guarantees transitions so the receiver can recover timing from the data itself, keeps the signal DC-balanced for an AC-coupled channel, and provides invalid symbols that make block synchronisation possible.
- Serialisation converts a parallel datapath onto a medium that carries one signal.
- Clock recovery extracts symbol timing, because no clock was transmitted alongside the data.
- Line drive and equalisation put a signal on the medium that survives the channel.
- Block synchronisation and elastic buffering find code-group boundaries and absorb the frequency difference between two independent oscillators.
Why three sublayers: they change independently. The PCS changes with the coding, the PMA with the rate or lane count, the PMD with the medium. One PCS serves several PMDs in practice, and a PMD change for a longer reach leaves the PCS untouched — which is only expressible if they are named separately.
What separates a good answer from a complete one: naming where the evidence changes. The PCS is fully digital and provable in simulation. The PMD is characterised by measurement — eye diagrams, jitter, return loss — and the PMA straddles the two. A project that plans only RTL verification for "the PHY" has budgeted for a fraction of the work.
The follow-up to be ready for: why is line coding not just overhead? Because it buys three independent things at once, and the third is the one people forget — a set of invalid bit patterns. Without invalid symbols, a receiver has no evidence for which offset is the right code-group boundary, and block synchronisation is impossible.
17. Understanding Check
18. What's Next
The PHY exists because a channel is analog: it carries no clock, it attenuates, it disperses, and it offers a continuous quantity rather than bits. Coding recovers timing and balance and supplies the invalid symbols alignment needs. Serialisation matches a parallel datapath to a single channel. Clock recovery extracts timing that was never sent. Elastic buffering absorbs the difference between two independent oscillators.
The three sublayers exist because those responsibilities change on different schedules — and because the evidence changes too, from simulation at the PCS to measurement at the PMD.
Chapter 2.7 — End Systems, Switches and Routers closes Module 2 by placing these layers into the devices built from them, and by fixing the forwarding boundary between layer two and layer three that Chapter 2.4 identified.
Module 3 then takes the PHY properly: copper and fibre channels, the PCS, PMA and PMD in depth, block coding against line modulation, forward error correction, and how a link physically comes up.
The full path is on the Ethernet curriculum index.
Continue learning
Related tutorials
- Related topic
Why the PHY Is Internally Layered — PCS, PMA, PMD
Each PHY sublayer boundary sits where the unit of information changes: octets become blocks, blocks become bits on lanes, bits become symbols on a medium. That placement turns N codings times M media into N plus M designs, and it marks where simulation stops being sufficient evidence.
- Related topic
Ethernet System Architecture
Client, MAC, reconciliation sublayer, PCS, PMA, PMD, medium — six blocks whose port lists are the real content. Each contract has two halves: what a layer delivers, and what it is forbidden to know about its neighbours, which is why one MAC outlived every physical layer.
- Related topic
Copper Ethernet
From 1000BASE-T onward every twisted pair carries both directions at once, so each receiver hears its own transmitter louder than the far end. Cancelling a known local signal is why BASE-T PHYs are adaptive signal-processing engines, and why pair count and signalling changed at every generation.
- Related topic
Fibre Ethernet
Separate strands per direction delete the echo problem that shapes copper PHYs, and introduce two others: a conversion boundary inside a pluggable module you do not own, and a link that can break in one direction while the far end still reports perfect health.
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.
