Ethernet · Module 6
A Parallel CRC-32 Engine in RTL
The wide next-state function is linear, so it is generated rather than derived — and the cost per bit falls as the datapath widens. What is designed is the final partial word, where eight sub-networks exist and real traffic reaches two of them.
Chapter 6.2 and Chapter 6.3 both worked one bit per clock, and both said so. That constraint has to go.
At 10 Gb/s, one bit per clock is a 10 GHz register. At 100 Gb/s it is 100 GHz. Neither exists. A real engine consumes eight, thirty-two or sixty-four bits per cycle, and the transformation from the serial definition to a wide one is the subject of this chapter.
The good news is that the transformation is exact and mechanical. The n-bit next-state function is the serial step composed with itself n times, the composition is linear, and linear means it can be generated — as a matrix, by a script, from the polynomial — rather than derived by hand or copied from somewhere.
The bad news is where the effort actually goes. Nobody's parallel engine is wrong in the matrix; generated code does not have typos. Designs are wrong in two other places, both at the end of the frame:
- the final partial word, when a frame's length is not a multiple of the datapath width;
- the byte-enable path that carries which lanes of that last transfer are valid.
And there is a third failure, which is about verification rather than design. The only correct way to sign off a generated engine is equivalence against the bit-serial reference — and the obvious way to build that reference is to generate it from the same matrix, which makes the comparison worthless.
1. Scope — What This Chapter Owns
Chapter 6.2 owns the arithmetic and the four conventions. Nothing here changes either: a parallel engine computes the same function as the serial one, or it is wrong.
Chapter 6.3 owns checking and the residue. The residue is a property of the code, so it is identical at every datapath width — a 64-bit checker tests for the same 0xDEBB20E3.
This chapter owns the implementation: deriving the wide next-state function, the choice between a generated matrix and a lookup table, what the width costs in logic and buys in timing, the final partial word, the byte-enable path, and the equivalence check that signs the result off.
It does not own the datapath around the engine — the xMII widths of Chapter 4.5's interface family, or the transmit and receive paths that Chapter 7.1 and Chapter 7.2 assemble. The engine is a block with a data port, and this chapter stops at its edges.
The question this chapter answers that its neighbours do not: given a definition that consumes one bit per clock, how do you build one that consumes sixty-four — and how do you know it is the same function?
2. Why the Composition Is Linear, and Why That Settles Everything
The serial step from Chapter 6.2 §3 is:
next = (reg << 1) XOR (poly if (reg[31] XOR bit) else 0)That is a linear function of reg and bit over the field with two elements. Shifting is linear. Exclusive-or is addition. The conditional exclusive-or is a product of a linear function of the inputs with a constant, which is still linear.
And a composition of linear functions is linear, so applying the step n times is one linear function of the 32-bit state and the n-bit input together.
Any linear function over this field is a matrix, which gives the decomposition in the callout above:
next_state = F · current_state XOR H · input_dataEach column of F is obtained by running the serial step n times on a state with a single bit set and no input. Each column of H is obtained by running it on a zero state with a single input bit set. Thirty-two runs plus n runs, and the matrices are complete.
Which is the point that makes this chapter short: the wide engine is not designed, it is computed. And it is verifiable independently — the linear decomposition can be checked against direct serial evaluation on random inputs before a line of RTL is written.
3. RTL 1 — Generating the Matrix
// SYNTHESIZABLE. The matrices are computed at elaboration.
//
// F[i] is the state after running the serial step W times, starting from
// a state with only bit i set and feeding zeros.
// H[i] is the state after running it W times from a zero state, feeding
// a data word with only bit i set.
//
// Both are obtained by RUNNING THE SERIAL STEP. Nothing is transcribed,
// nothing is derived by hand, and the polynomial appears exactly once.
package crc_parallel_pkg;
import crc32_pkg::*;
// The serial step of Chapter 6.2 §3, verbatim. This function is the
// single source of truth for the entire chapter.
function automatic logic [31:0] serial_step(input logic [31:0] r,
input logic b);
serial_step = {r[30:0], 1'b0} ^ ((r[31] ^ b) ? POLY_MSB : 32'h0);
endfunction
// W steps, consuming `d` most-significant-bit first.
function automatic logic [31:0] serial_steps(input logic [31:0] r,
input logic [63:0] d,
input int unsigned W);
logic [31:0] s;
s = r;
for (int unsigned i = 0; i < W; i++) s = serial_step(s, d[W-1-i]);
serial_steps = s;
endfunction
// Column i of F: the response to state bit i alone.
function automatic logic [31:0] f_col(input int unsigned i,
input int unsigned W);
f_col = serial_steps(32'h1 << i, 64'h0, W);
endfunction
// Column i of H: the response to data bit i alone.
function automatic logic [31:0] h_col(input int unsigned i,
input int unsigned W);
h_col = serial_steps(32'h0, 64'h1 << i, W);
endfunction
// The wide next state, assembled by superposition. This IS the matrix
// multiply -- each set input bit contributes its column.
function automatic logic [31:0] next_state(input logic [31:0] r,
input logic [63:0] d,
input int unsigned W);
logic [31:0] acc;
acc = 32'h0;
for (int unsigned i = 0; i < 32; i++) if (r[i]) acc ^= f_col(i, W);
for (int unsigned i = 0; i < W; i++) if (d[i]) acc ^= h_col(i, W);
next_state = acc;
endfunction
endpackage
// SYNTHESIZABLE. The generated network, as a block.
//
// Wrapping the function in a module is not ceremony: it gives the
// network a name in the netlist, a place to constrain timing, and a
// single instance that every width in the design shares. A design that
// calls next_state() inline in three places has three networks that
// synthesis may optimise differently and that no timing report names.
module crc32_next_state
import crc32_pkg::*;
import crc_parallel_pkg::*;
#(
parameter int unsigned W = 8
) (
input logic [31:0] state_in,
input logic [W-1:0] data_in,
output logic [31:0] state_out
);
// Constant-folded at elaboration into a fixed XOR network: every output
// bit is the parity of a subset of the 32 + W inputs, and nothing else.
assign state_out = next_state(state_in, 64'(data_in), W);
endmoduleClassification: synthesizable; the matrices are elaboration-time constants.
What it teaches: that serial_step appears exactly once and everything else is built from it. The polynomial is mentioned in one place, the step is written in one place, and every width the design ever needs is a different value of W applied to the same function. There is no second implementation to keep in agreement, which is the property that makes this approach safer than a checked-in generated file.
Deliberately simplified: serial_steps takes a 64-bit data word and a runtime width. A production version parameterises the width so the loops unroll to constants, which is what turns next_state into a fixed exclusive-or network rather than a loop.
Production implication: the temptation is to generate the matrices with a script and check the result into the repository as Verilog. That works and it introduces a second artefact that can drift — from a polynomial change, a width change, or a merge. Computing them in an elaboration-time function removes the artefact entirely, and the cost is nothing: the loops are constant-folded, and what reaches synthesis is the same exclusive-or network either way.
4. RTL 2 — A Byte-Wide Engine
// SYNTHESIZABLE.
//
// Eight bits per clock, which is the width an xMII-class interface
// delivers and the smallest width worth parallelising.
//
// The datapath is one line. Everything else in this module exists for
// the frame's edges, which is the honest ratio for a CRC engine: the
// arithmetic is free and the boundaries are the work.
module crc32_byte_engine
import crc32_pkg::*;
import crc_parallel_pkg::*;
(
input logic clk,
input logic rst_n,
input logic frame_start,
input logic oct_valid,
input logic [7:0] oct_data,
input logic oct_last,
output logic fcs_valid,
output logic [31:0] fcs,
output logic [31:0] crc_reg
);
logic [31:0] reg_q;
assign crc_reg = reg_q;
// The input reflection of Chapter 6.2 §5, applied per octet before the
// octet enters the network.
wire [7:0] oct_applied = REFLECT_IN ? reflect8(oct_data) : oct_data;
// THE DATAPATH. One combinational function, depth 4, 252 XOR terms
// across the 32 outputs -- and no sequential dependence inside the
// cycle, which is why it meets timing where a serial engine cannot.
wire [31:0] next = next_state(reg_q, {56'h0, oct_applied}, 8);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
reg_q <= INIT_VALUE;
fcs_valid <= 1'b0;
fcs <= '0;
end else begin
fcs_valid <= 1'b0;
if (frame_start) begin
reg_q <= INIT_VALUE;
end else if (oct_valid) begin
reg_q <= next;
if (oct_last) begin
// Output conventions applied to the value the last octet
// produced -- `next`, not `reg_q`, because reg_q has not been
// updated yet. Reading the wrong one is a one-octet-early
// result and is the single most common bug in this module.
fcs <= (REFLECT_OUT ? reflect32(next) : next) ^ XOR_OUT;
fcs_valid <= 1'b1;
end
end
end
end
endmoduleClassification: synthesizable.
What it teaches: that the output must be taken from next and not from reg_q. On the cycle oct_last is asserted, reg_q still holds the state before the last octet — the register updates at the same clock edge that produces the result. Reading reg_q yields the check value for a frame one octet shorter, which is a perfectly stable, perfectly repeatable wrong answer, and the golden vector of Chapter 6.2 §7 catches it immediately while a self-consistent loopback does not.
Deliberately simplified: no back-pressure. A real engine needs a ready signal, and the interaction between a stall and oct_last is another place the boundary logic can be wrong.
Production implication: next_state is called with the width as an argument and a zero-padded 64-bit data word, which reads as wasteful and is not. Both arguments are elaboration-time constants, so the loops unroll, the padding bits contribute nothing, and what synthesis sees is exactly the 252-term network. Writing it this way means the byte engine and the 64-bit engine of Section 6 share one function rather than having two hand-maintained networks that must agree.
5. Cost and Timing Against Width
Computed by generating the matrices and counting exclusive-or input terms across the 32 output bits:
| Width | Total XOR terms | Worst output bit | Balanced tree depth | Terms per input bit |
|---|---|---|---|---|
| 8 | 252 | 14 | 4 | 31.5 |
| 16 | 446 | 20 | 5 | 27.9 |
| 32 | 904 | 34 | 6 | 28.3 |
| 64 | 1 422 | 52 | 6 | 22.2 |
Read the last two columns of the table, because they carry the engineering argument.
Depth grows logarithmically and then stops. A 64-bit engine has the same tree depth as a 32-bit one — six levels of exclusive-or — because the worst output bit went from 34 terms to 52 and both round up to the same power of two. Doubling the width doubled the throughput and cost nothing in logic depth.
And terms per input bit falls as the width rises. The 8-bit engine spends 31.5 XOR terms per bit of data consumed; the 64-bit engine spends 22.2. The wide engine is not merely faster, it is more efficient per bit — which is the opposite of the usual intuition that parallelising costs area in proportion to speed.
The reason is that F is amortised. Every width pays 32 columns of state-response terms; only the H columns scale with the data. At width 8 the state matrix dominates; at width 64 the data matrix does, and the fixed cost has been spread over eight times as much data.
6. RTL 3 — Sixty-Four Bits, With Byte Enables
// SYNTHESIZABLE.
//
// Sixty-four bits per clock with byte enables, which is what a frame
// whose length is not a multiple of eight requires.
//
// The core idea: a transfer carrying k valid octets must advance the
// state by EXACTLY 8k bits. So the module instantiates the generated
// network at eight widths and selects among them.
//
// It does NOT mask the invalid octets to zero and run the full 64-bit
// network. Zero octets are not "no octets" -- feeding eight zero bits
// advances the state, and a zero octet is a perfectly ordinary input
// that changes the remainder. That mistake produces a frame whose CRC is
// computed over a longer message than was sent, and it is invisible on
// every frame whose length happens to be a multiple of eight.
module crc32_wide_engine
import crc32_pkg::*;
import crc_parallel_pkg::*;
#(
parameter int unsigned LANES = 8 // octets per transfer
) (
input logic clk,
input logic rst_n,
input logic frame_start,
input logic xfer_valid,
input logic [8*LANES-1:0] xfer_data, // lane 0 is the FIRST octet
// Number of valid octets in THIS transfer, 1..LANES. A count rather
// than a mask, deliberately: valid octets are always contiguous from
// lane 0, and a mask permits states that cannot occur and must then be
// handled or excluded.
input logic [3:0] xfer_octets,
input logic xfer_last,
output logic fcs_valid,
output logic [31:0] fcs,
output logic [31:0] crc_reg,
// Asserted if a transfer arrives with an octet count outside 1..LANES.
// Not an error the engine can absorb -- it is an upstream fault, and
// silently clamping it would produce a stable wrong check value.
output logic bad_octet_count
);
logic [31:0] reg_q;
assign crc_reg = reg_q;
// Reflect each octet independently, then present them most-significant
// octet first, because the generated network consumes MSB-first.
logic [8*LANES-1:0] applied;
always_comb begin
applied = '0;
for (int unsigned i = 0; i < LANES; i++) begin
automatic logic [7:0] o = xfer_data[8*i +: 8];
applied[8*(LANES-1-i) +: 8] = REFLECT_IN ? reflect8(o) : o;
end
end
// Eight candidate next states, one per possible valid-octet count.
// Each is the generated network at a different width, and each consumes
// the TOP 8k bits of `applied` -- which is why the octets were packed
// most-significant first above.
logic [31:0] cand [LANES+1];
always_comb begin
cand[0] = reg_q; // no octets: state unchanged
for (int unsigned k = 1; k <= LANES; k++)
cand[k] = next_state(reg_q,
64'(applied >> (8*(LANES-k))),
8*k);
end
wire count_ok = (xfer_octets >= 4'd1) && (xfer_octets <= 4'(LANES));
wire [31:0] next = cand[count_ok ? xfer_octets : 4'd0];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
reg_q <= INIT_VALUE;
fcs_valid <= 1'b0;
fcs <= '0;
bad_octet_count <= 1'b0;
end else begin
fcs_valid <= 1'b0;
if (frame_start) begin
reg_q <= INIT_VALUE;
bad_octet_count <= 1'b0;
end else if (xfer_valid) begin
if (!count_ok) begin
// Report and do not advance. A clamped count computes a valid
// check value over the wrong number of octets, which is the
// worst available outcome: stable, repeatable and wrong.
bad_octet_count <= 1'b1;
end else begin
reg_q <= next;
if (xfer_last) begin
fcs <= (REFLECT_OUT ? reflect32(next) : next) ^ XOR_OUT;
fcs_valid <= 1'b1;
end
end
end
end
end
endmoduleClassification: synthesizable.
What it teaches: that masking invalid octets to zero is not the same as not consuming them. A zero octet is an ordinary input that advances the state by eight bits and changes the remainder. Masking and running the full 64-bit network therefore computes the check over a longer message than was transmitted — and the resulting frame is internally consistent, so a self-checking loopback accepts it. It is wrong only against a peer, and only on frames whose length is not a multiple of eight.
Deliberately simplified: xfer_octets is a count rather than a byte-enable mask. On a MAC receive datapath valid octets are contiguous from the first lane, so a count is complete — and it is strictly better than a mask, because a mask can express states that cannot occur and every one of them then needs a decision.
Production implication: bad_octet_count reports and refuses to advance rather than clamping. Clamping is the instinct, it costs nothing, and it converts an upstream fault into a valid check value over the wrong number of octets — which passes every structural test, arrives at the far end as an ordinary check-sequence mismatch, and is attributed to the link. A fault that is reported at its origin costs one signal; the same fault clamped costs a cross-device investigation, which is Chapter 5.5 §11's argument arriving at the other end of the module.
7. The Last Transfer Is Where the Bugs Are
Every frame has exactly one final transfer, and there are eight possible shapes for it. A frame whose length modulo eight is 1 exercises the 8-bit sub-network; modulo 8 is 5 exercises the 40-bit one; and a frame that divides evenly exercises the full-width network twice in a row and none of the partial ones.
So the edge cases are not rare, they are thin. They occur once per frame, against 189 exercises of the main path in a maximum-size frame — and each individual case occurs on only one eighth of frames if lengths are uniform, which they are not.
Real traffic is worse than uniform. Frame lengths cluster: minimum-size frames at 64 octets, maximum-size at 1518, and control frames at fixed sizes. 64 and 1518 are both ≡ 0 and ≡ 6 modulo 8 respectively, so a workload of minimum and maximum frames exercises exactly two of the eight cases and never touches the other six.
Illustrative, for the covered range of Chapter 5.8: a minimum frame's covered range is 60 octets, which is 7 full transfers plus 4 octets; a maximum frame's is 1514, which is 189 plus 2. Two residues, on the two most common frame sizes on any link.
Which is the argument for the equivalence check rather than for more frames. A directed sweep over lengths is worth building — Section 10 does — but the sweep must be constructed to cover the residues, because a traffic generator will not produce them in useful proportion.
8. RTL 4 — Producing the Last Transfer Correctly
// SYNTHESIZABLE.
//
// Packs an octet stream into LANES-wide transfers and produces the valid
// octet count for each -- including the final, partial one.
//
// Three failures live here and all three are invisible on frames whose
// length is a multiple of LANES:
//
// 1. the final partial word is never emitted, so the frame's last
// octets are silently dropped from the computation
// 2. the final partial word is emitted with a count of LANES, so
// stale octets from the previous frame are consumed
// 3. the final word is emitted twice -- once on the last octet and
// again on the flush -- doubling the last octets
//
// Each produces a stable, repeatable, wrong check value.
module frame_word_packer
#(
parameter int unsigned LANES = 8
) (
input logic clk,
input logic rst_n,
input logic frame_start,
input logic oct_valid,
input logic [7:0] oct_data,
input logic oct_last,
output logic xfer_valid,
output logic [8*LANES-1:0] xfer_data,
output logic [3:0] xfer_octets,
output logic xfer_last
);
logic [8*LANES-1:0] acc_q;
logic [3:0] fill_q;
// The accumulator is cleared at frame start, not merely the counter.
// Clearing only the counter leaves the previous frame's octets in the
// lanes -- harmless while the count is honoured and catastrophic the
// moment a count is wrong, because the stale data is plausible.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
acc_q <= '0;
fill_q <= '0;
xfer_valid <= 1'b0;
xfer_data <= '0;
xfer_octets <= '0;
xfer_last <= 1'b0;
end else begin
xfer_valid <= 1'b0;
xfer_last <= 1'b0;
if (frame_start) begin
acc_q <= '0;
fill_q <= '0;
end else if (oct_valid) begin
automatic logic [8*LANES-1:0] acc_next = acc_q;
automatic logic [3:0] fill_next = fill_q + 4'd1;
acc_next[8*fill_q +: 8] = oct_data;
if (oct_last) begin
// The final transfer. Emitted with the ACTUAL count, which may
// be anything from 1 to LANES -- and note it is emitted here,
// on the last octet, rather than by a separate flush. A flush
// is where failure 3 comes from: two paths that can both emit
// the same word.
xfer_data <= acc_next;
xfer_octets <= fill_next;
xfer_valid <= 1'b1;
xfer_last <= 1'b1;
acc_q <= '0;
fill_q <= '0;
end else if (fill_next == 4'(LANES)) begin
// A full transfer in the ordinary course.
xfer_data <= acc_next;
xfer_octets <= 4'(LANES);
xfer_valid <= 1'b1;
acc_q <= '0;
fill_q <= '0;
end else begin
acc_q <= acc_next;
fill_q <= fill_next;
end
end
end
end
// The frame must never end with octets still in the accumulator. This
// is failure 1, stated as a property of the module's own state rather
// than left to the engine to notice -- because the engine cannot: a
// shorter message produces a perfectly valid check value.
// synopsys translate_off
always_ff @(posedge clk) begin
if (rst_n && frame_start && (fill_q != 4'd0))
$error("frame started with %0d octets still buffered from the previous frame", fill_q);
end
// synopsys translate_on
endmoduleClassification: synthesizable.
What it teaches: that the final transfer must be emitted by the same path that handles ordinary transfers, not by a separate flush. A flush is a second emitter for the same word, and any condition under which both fire produces a frame whose last octets are consumed twice. The check value is then stable, repeatable and wrong — and it is wrong only for frames that end mid-word, which is most of them and not the ones a quick test uses.
Deliberately simplified: no back-pressure and no gaps within a frame. Both add states in which fill_q is non-zero for extended periods, which is where the interaction with frame_start becomes interesting.
Production implication: the accumulator is cleared at frame_start and not merely the fill counter. Clearing only the counter is harmless while every count is honoured — the stale lanes are never consumed — and it converts any count error from a visible one into an invisible one, because the invalid lanes contain plausible octets from the previous frame rather than zeros. A design should make its unused state obviously wrong, so that a bug reaching it produces a symptom rather than a subtly incorrect result.
9. RTL 5 — The Only Correct Sign-Off
// VERIFICATION COMPONENT. NOT FOR SYNTHESIS.
//
// Drives the same octets through the parallel engine and through the
// bit-serial engine, and requires identical results.
//
// The INDEPENDENCE of the two sides is the entire value:
//
// the serial engine -- written from the polynomial, one bit per step
// the parallel engine -- a generated matrix, LANES octets per step
//
// They share the polynomial constant and nothing else. In particular the
// serial engine does NOT use crc_parallel_pkg's serial_step, because a
// shared step function would make a bug in that function invisible to
// this check -- which is precisely the failure Section 10 rejects.
module crc_equivalence_checker
import crc32_pkg::*;
#(
parameter int unsigned LANES = 8,
parameter int unsigned MAX_OCTETS = 1522
) (
input logic clk,
input logic rst_n,
input logic start,
input logic [13:0] octet_count, // swept by the testbench
// To the serial reference.
output logic ref_start,
output logic ref_valid,
output logic [7:0] ref_data,
output logic ref_last,
input logic ref_fcs_valid,
input logic [31:0] ref_fcs,
// To the parallel design under test, through the packer of Section 8.
output logic dut_start,
output logic dut_valid,
output logic [7:0] dut_data,
output logic dut_last,
input logic dut_fcs_valid,
input logic [31:0] dut_fcs,
output logic compare_done,
output logic equivalent,
output logic [31:0] ref_observed,
output logic [31:0] dut_observed,
output logic [13:0] failing_length,
output logic failure_seen,
// Which residues modulo LANES have been exercised. A sweep that never
// reaches all of them has not tested the partial-word paths.
output logic [7:0] residues_covered
);
logic [13:0] idx_q;
logic ref_got_q, dut_got_q;
typedef enum logic [1:0] { E_IDLE, E_DRIVE, E_WAIT, E_CMP } e_e;
e_e state_q;
// The same pseudo-random octet stream to both, so any difference is
// the engines and not the stimulus.
function automatic logic [7:0] pattern(input logic [13:0] i);
pattern = i[7:0] ^ {i[13:8], 2'b10};
endfunction
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state_q <= E_IDLE; idx_q <= '0;
ref_start <= 1'b0; ref_valid <= 1'b0; ref_last <= 1'b0; ref_data <= '0;
dut_start <= 1'b0; dut_valid <= 1'b0; dut_last <= 1'b0; dut_data <= '0;
ref_got_q <= 1'b0; dut_got_q <= 1'b0;
compare_done <= 1'b0; equivalent <= 1'b0;
ref_observed <= '0; dut_observed <= '0;
failing_length <= '0; failure_seen <= 1'b0;
residues_covered <= '0;
end else begin
ref_start <= 1'b0; ref_valid <= 1'b0; ref_last <= 1'b0;
dut_start <= 1'b0; dut_valid <= 1'b0; dut_last <= 1'b0;
compare_done <= 1'b0;
case (state_q)
E_IDLE: if (start) begin
ref_start <= 1'b1; dut_start <= 1'b1;
idx_q <= '0; ref_got_q <= 1'b0; dut_got_q <= 1'b0;
state_q <= E_DRIVE;
end
E_DRIVE: begin
ref_valid <= 1'b1; ref_data <= pattern(idx_q);
dut_valid <= 1'b1; dut_data <= pattern(idx_q);
ref_last <= (idx_q == octet_count - 14'd1);
dut_last <= (idx_q == octet_count - 14'd1);
if (idx_q == octet_count - 14'd1) state_q <= E_WAIT;
else idx_q <= idx_q + 14'd1;
end
E_WAIT: begin
if (ref_fcs_valid) begin ref_observed <= ref_fcs; ref_got_q <= 1'b1; end
if (dut_fcs_valid) begin dut_observed <= dut_fcs; dut_got_q <= 1'b1; end
if (ref_got_q && dut_got_q) state_q <= E_CMP;
end
E_CMP: begin
equivalent <= (ref_observed == dut_observed);
compare_done <= 1'b1;
// Record which length-modulo-LANES class this run covered, so
// the sweep's completeness is a reported fact rather than an
// assumption about how the testbench was configured.
residues_covered[octet_count % LANES] <= 1'b1;
if ((ref_observed != dut_observed) && !failure_seen) begin
failure_seen <= 1'b1;
failing_length <= octet_count;
end
state_q <= E_IDLE;
end
default: state_q <= E_IDLE;
endcase
end
end
endmoduleClassification: verification component, not for synthesis.
What it teaches: that residues_covered turns the sweep's completeness from an assumption into a reported fact. A run that reports equivalence on ten thousand frames and has covered three of the eight residue classes has not tested the partial-word paths, and nothing else in the environment says so. Coverage of the edge cases is a property of the length distribution, and the length distribution is a testbench parameter that gets changed without anybody rechecking what it now covers.
Deliberately simplified: one frame per invocation. A production environment runs this as a formal equivalence proof over the two next-state functions, which covers all lengths at once and is the correct instrument where the tool is available.
Production implication: failing_length is captured on the first failure and it is nearly always the whole diagnosis. A parallel engine that fails equivalence fails at a specific set of lengths, and the set names the bug: failures only at lengths ≡ 0 modulo LANES point at the full-width path or the packer's flush; failures at every length except those point at the partial-word selection; and a failure at exactly one residue points at one sub-network. The length is more informative than the values, which is the opposite of Chapter 6.2 §8's convention diagnosis, where the values were everything.
10. Table-Driven Against Combinational
Software CRC implementations almost always use a 256-entry lookup table. Hardware almost never does, and the reason is worth stating because the two look like the same optimisation.
The table identity is exact and easy to verify:
next = (reg << 8) XOR T[(reg >> 24) XOR byte]where T[i] is the result of running eight serial steps on a state of i shifted into the top octet. Checked over two thousand random state-and-octet pairs, it agrees with the serial engine every time — it is the same function, expressed as a memory lookup instead of an exclusive-or network.
In software the table wins decisively. A 1 KB table is a rounding error, it sits in cache, and it replaces eight iterations of a bit loop with one indexed load and two exclusive-ors.
In hardware it usually loses, and for three separate reasons:
| Table | Combinational network | |
|---|---|---|
| storage | 256 × 32 bits = 1 KB of ROM or LUTs | none |
| latency | a memory access on the critical path | 4 levels of exclusive-or |
| widening past 8 bits | a 65 536-entry table for 16 bits | 446 XOR terms |
The third row is the one that settles it. A table's size is exponential in the width and a network's is roughly linear — 8 bits needs 256 entries and 252 XOR terms; 16 bits needs 65 536 entries and 446 XOR terms. Beyond one octet the table is not an option, and every modern MAC is wider than one octet.
Which leaves the table with one genuine hardware use: as an independent reference in verification, because it is a different expression of the same function and shares no structure with the generated network. Chapter 6.2's bit-serial engine is better still, being independent of both.
11. Assertions — Equivalence, Boundaries, and Coverage of the Residues
// ---------------------------------------------------------------------
// P1 -- THE SIGN-OFF PROPERTY. The parallel engine and the INDEPENDENT
// bit-serial reference produce identical values, at every length.
// ---------------------------------------------------------------------
property p_parallel_equals_serial;
@(posedge clk) disable iff (!rst_n)
compare_done |-> (dut_observed == ref_observed);
endproperty
a_parallel_equals_serial: assert property (p_parallel_equals_serial)
else $error("parallel engine differs from the serial reference at length %0d", octet_count);
// ---------------------------------------------------------------------
// P2 -- The generated matrices reproduce direct serial evaluation. An
// elaboration-time check on the generator itself, independent of any
// frame ever being driven.
// ---------------------------------------------------------------------
// synopsys translate_off
a_matrix_matches_serial: assert final
(next_state(32'h1234_5678, 64'h00AB, 8) ==
serial_steps(32'h1234_5678, 64'h00AB, 8))
else $fatal(1, "generated matrix disagrees with the serial step it was built from");
// synopsys translate_on
// ---------------------------------------------------------------------
// P3 -- The full-width network is the composition of narrower ones.
// Consuming eight octets in one step equals consuming four then four.
// This catches a width parameter that did not propagate.
// ---------------------------------------------------------------------
// synopsys translate_off
a_widths_compose: assert final
(next_state(32'hDEAD_BEEF, 64'h0011_2233_4455_6677, 64) ==
next_state(next_state(32'hDEAD_BEEF, 64'h0011_2233, 32), 64'h4455_6677, 32));
// synopsys translate_on
// ---------------------------------------------------------------------
// P4 -- The result is taken from `next`, not from the register. Written
// as a value relation, because the failure is a one-octet-early result
// that is otherwise perfectly stable.
// ---------------------------------------------------------------------
property p_result_from_next_not_reg;
@(posedge clk) disable iff (!rst_n)
fcs_valid |-> (fcs == ((REFLECT_OUT ? reflect32($past(next)) : $past(next)) ^ XOR_OUT));
endproperty
a_result_from_next_not_reg: assert property (p_result_from_next_not_reg);
// ---------------------------------------------------------------------
// P5 -- The octet count on a transfer is always in range. Out of range
// must be REPORTED, not clamped.
// ---------------------------------------------------------------------
property p_octet_count_in_range;
@(posedge clk) disable iff (!rst_n)
(xfer_valid && !bad_octet_count) |-> ((xfer_octets >= 4'd1) && (xfer_octets <= 4'(LANES)));
endproperty
a_octet_count_in_range: assert property (p_octet_count_in_range);
// ---------------------------------------------------------------------
// P6 -- A bad count does not advance the state. Clamping produces a
// valid check value over the wrong number of octets.
// ---------------------------------------------------------------------
property p_bad_count_does_not_advance;
@(posedge clk) disable iff (!rst_n)
(xfer_valid && !count_ok) |=> $stable(crc_reg);
endproperty
a_bad_count_does_not_advance: assert property (p_bad_count_does_not_advance);
// ---------------------------------------------------------------------
// P7 -- Only the FINAL transfer of a frame may be partial. A partial
// transfer mid-frame means the packer emitted early.
// ---------------------------------------------------------------------
property p_only_last_is_partial;
@(posedge clk) disable iff (!rst_n)
(xfer_valid && (xfer_octets != 4'(LANES))) |-> xfer_last;
endproperty
a_only_last_is_partial: assert property (p_only_last_is_partial);
// ---------------------------------------------------------------------
// P8 -- Exactly one last transfer per frame. Two is the double-emit
// failure of Section 8; zero is the dropped-tail failure.
// ---------------------------------------------------------------------
property p_one_last_per_frame;
@(posedge clk) disable iff (!rst_n)
(xfer_valid && xfer_last) |-> ##1 (!xfer_last throughout (!frame_start [*1:$]));
endproperty
a_one_last_per_frame: assert property (p_one_last_per_frame);
// ---------------------------------------------------------------------
// P9 -- The packer's accumulator is empty at frame start. Octets left
// over from the previous frame are plausible data, which makes any
// downstream count error invisible.
// ---------------------------------------------------------------------
property p_accumulator_empty_at_start;
@(posedge clk) disable iff (!rst_n)
frame_start |-> (fill_q == 4'd0);
endproperty
a_accumulator_empty_at_start: assert property (p_accumulator_empty_at_start);
// ---------------------------------------------------------------------
// P10 -- The octets driven into the engine over a frame equal the octets
// the client supplied. The end-to-end conservation property, and the one
// that catches a dropped or duplicated tail regardless of mechanism.
// ---------------------------------------------------------------------
property p_octet_conservation;
@(posedge clk) disable iff (!rst_n)
(xfer_valid && xfer_last) |-> (octets_consumed_this_frame == client_octets_this_frame);
endproperty
a_octet_conservation: assert property (p_octet_conservation);
// ---------------------------------------------------------------------
// P11 -- The register is preset at every frame start (Chapter 6.2 P2,
// restated here because a wide engine has a second place to get it
// wrong: the packer's own state).
// ---------------------------------------------------------------------
property p_engine_preset_every_frame;
@(posedge clk) disable iff (!rst_n)
frame_start |=> (crc_reg == INIT_VALUE);
endproperty
a_engine_preset_every_frame: assert property (p_engine_preset_every_frame);
// ---------------------------------------------------------------------
// P12 -- The byte-wide and wide engines agree on the same octet stream.
// A second equivalence, between two DIFFERENT widths of the same
// generator -- weaker than P1, and cheap.
// ---------------------------------------------------------------------
property p_widths_agree;
@(posedge clk) disable iff (!rst_n)
(byte_fcs_valid && wide_fcs_valid) |-> (byte_fcs == wide_fcs);
endproperty
a_widths_agree: assert property (p_widths_agree);
// ---------------------------------------------------------------------
// P13 -- The golden vector still holds at every width. Chapter 6.2's
// self-test is width-independent and is the cheapest conformance check
// available to a parallel engine.
// ---------------------------------------------------------------------
property p_golden_at_any_width;
@(posedge clk) disable iff (!rst_n)
selftest_done |-> (observed == CHECK_123456789);
endproperty
a_golden_at_any_width: assert property (p_golden_at_any_width);
// ---------------------------------------------------------------------
// P14 -- The residue is width-independent too: a wide checker tests for
// the same constant a serial one does.
// ---------------------------------------------------------------------
property p_residue_width_independent;
@(posedge clk) disable iff (!rst_n)
(check_valid && frame_was_valid) |-> (residue_observed == RESIDUE);
endproperty
a_residue_width_independent: assert property (p_residue_width_independent);
// ---------------------------------------------------------------------
// P15 -- Equivalence has been demonstrated at EVERY residue class before
// the sign-off is meaningful. Coverage as a property, because a sweep
// that missed a class has not tested that sub-network at all.
// ---------------------------------------------------------------------
property p_all_residues_covered_before_signoff;
@(posedge clk) disable iff (!rst_n)
signoff_asserted |-> (residues_covered == 8'hFF);
endproperty
a_all_residues_covered_before_signoff:
assert property (p_all_residues_covered_before_signoff);
// ---------------------------------------------------------------------
// P16 -- The first failing length is captured once and kept. It is more
// informative than the values (Section 9).
// ---------------------------------------------------------------------
property p_failing_length_stable;
@(posedge clk) disable iff (!rst_n)
failure_seen |=> $stable(failing_length);
endproperty
a_failing_length_stable: assert property (p_failing_length_stable);
// ---------------------------------------------------------------------
// P17 -- The generated network is purely combinational. No output bit
// depends on anything but the current state and the current data, which
// is what makes the block retimeable and what a registered intermediate
// would silently break.
// ---------------------------------------------------------------------
property p_network_is_combinational;
@(posedge clk) disable iff (!rst_n)
(state_out == next_state(state_in, 64'(data_in), W));
endproperty
a_network_is_combinational: assert property (p_network_is_combinational);
// ---------------------------------------------------------------------
// P18 -- Consuming zero octets leaves the state unchanged. The identity
// case, which distinguishes "no octets" from "a zero octet" -- the
// distinction Section 6 is built on.
// ---------------------------------------------------------------------
property p_zero_octets_is_identity;
@(posedge clk) disable iff (!rst_n)
(xfer_valid && (xfer_octets == 4'd0) && count_ok_relaxed) |=> $stable(crc_reg);
endproperty
a_zero_octets_is_identity: assert property (p_zero_octets_is_identity);
// ---------------------------------------------------------------------
// P19 -- COVERAGE. Every partial-word width exercised, individually.
// ---------------------------------------------------------------------
c_partial_1: cover property (@(posedge clk) disable iff (!rst_n) (xfer_valid && xfer_last && xfer_octets == 4'd1));
c_partial_7: cover property (@(posedge clk) disable iff (!rst_n) (xfer_valid && xfer_last && xfer_octets == 4'd7));
c_partial_8: cover property (@(posedge clk) disable iff (!rst_n) (xfer_valid && xfer_last && xfer_octets == 4'(LANES)));
// ---------------------------------------------------------------------
// P20 -- COVERAGE. A frame shorter than one full transfer, which
// exercises the packer's start and end in the same cycle.
// ---------------------------------------------------------------------
c_frame_shorter_than_word: cover property (
@(posedge clk) disable iff (!rst_n)
(xfer_valid && xfer_last && (xfer_octets < 4'(LANES)) && first_xfer_of_frame)
);12. Verification — Twenty-Four Scenarios and a Length Sweep Nothing Else Produces
| # | Scenario | Stimulus | What must be observed |
|---|---|---|---|
| 1 | Golden vector, byte engine | 123456789 | 0xCBF43926 (P13) |
| 2 | Golden vector, 64-bit engine | the same nine octets | the same value — width-independent |
| 3 | Matrix against serial step | elaboration | they agree (P2) |
| 4 | Width composition | elaboration | 64-bit step equals two 32-bit steps (P3) |
| 5 | One-octet frame | 1 octet | one partial transfer of 1; equivalence holds |
| 6 | Seven-octet frame | 7 octets | one partial transfer of 7 (P17) |
| 7 | Eight-octet frame | 8 octets | one full transfer, no partial (P7) |
| 8 | Nine-octet frame | 9 octets | one full plus a partial of 1 |
| 9 | Residue sweep | lengths 1 to 64 | equivalence at every length; residues_covered = 0xFF (P15) |
| 10 | Minimum-frame covered range | 60 octets | residue 4; equivalence holds |
| 11 | Maximum-frame covered range | 1514 octets | residue 2; equivalence holds |
| 12 | Result taken from next | any frame | fcs matches the last transfer's output (P4) |
| 13 | Result taken from reg_q | inject the mutation | golden vector fails immediately |
| 14 | Partial mid-frame | force a short transfer before the end | P7 fires |
| 15 | Two last transfers | force a flush alongside the last octet | P8 fires |
| 16 | Dropped tail | suppress the final partial transfer | equivalence fails at every non-multiple length |
| 17 | Stale accumulator | skip the accumulator clear at frame_start | P9 fires; the value is otherwise plausible |
| 18 | Octet count zero | drive xfer_octets = 0 | bad_octet_count; state unchanged (P5, P6) |
| 19 | Octet count above LANES | drive xfer_octets = 9 | bad_octet_count; state unchanged |
| 20 | Clamped count | modify to clamp instead of report | equivalence fails; no error flag — the point of P6 |
| 21 | Byte engine against wide engine | 1000 frames, mixed lengths | identical results (P12) |
| 22 | Residue check at width 64 | valid frames | 0xDEBB20E3 (P14) |
| 23 | Back-to-back frames | no gap between frames | each preset correctly (P11) |
| 24 | Sign-off with partial coverage | assert sign-off after residues 2 and 4 only | P15 fires |
13. Debugging — The Failing Length Names the Bug
Symptom — equivalence fails at every length that is not a multiple of the datapath width.
The partial-word path is not being taken at all: either the packer never emits the final partial transfer, or the engine's width selection is stuck at full width. Check xfer_octets on the last transfer of a short frame — if it reads LANES when fewer octets were supplied, the count is wrong; if no last transfer appears, the packer is dropping the tail.
Symptom — equivalence fails only at lengths that are multiples of the width.
The opposite fault. A frame that fills its last word exactly has no partial transfer, and a packer that emits one anyway — a flush firing alongside the ordinary path — consumes the last word twice. Section 8's double-emit failure, and P8 catches it directly.
Symptom — equivalence fails at exactly one residue.
One generated sub-network is wrong, which in a properly generated design means a width parameter did not propagate to that instance. Check that every candidate is built from the same next_state function with only W differing; a hand-written special case for one width is the usual cause, and it is usually the width somebody optimised.
Symptom — the golden vector fails at 64 bits and passes at 8.
Not a boundary problem: the golden message is nine octets, which is one full transfer and a partial of one, so both paths are exercised. A width-dependent golden-vector failure points at the octet packing order — the generated network consumes most-significant-bit first, so the octets must be presented most-significant-octet first, and a design that packs lane 0 into the top produces a stable wrong answer.
Symptom — the check value is right for a frame one octet shorter than the one sent.
The result is being taken from reg_q instead of next on the last transfer. Perfectly stable and perfectly repeatable, invisible to any self-consistent loopback, and caught in nine octets by the golden vector — which is the argument for having it.
Symptom — everything passes and a peer rejects frames whose length is not a multiple of eight.
Suspect masking rather than counting. An engine that zero-masks the invalid lanes and runs the full-width network computes over a longer message than was sent. The frame is internally consistent, so the design's own receiver accepts it; only a peer disagrees, and only on frames that end mid-word.
Symptom — a frame arrives with octets from the previous frame in it.
The packer clears its fill counter at frame_start but not its accumulator, and something downstream honoured a count it should not have. The stale lanes contain plausible data, which is why this presents as a data-corruption bug rather than a control bug. P9 catches the condition directly, and Section 8's argument is that unused state should be made obviously wrong rather than merely unused.
14. Common Misconceptions
"Parallelising a CRC is hard."
The wrong model: the wide next-state function has to be derived, and deriving it is error-prone algebra.
What it costs: people copy generated Verilog from somewhere, check it in, and acquire a second artefact that drifts from the polynomial it was built for — or they hand-derive and get it subtly wrong.
The corrected model: the function is linear, so it is fully determined by its action on unit vectors: 32 + n runs of the serial step produce the whole matrix. It is computed, not derived, and it can be computed in an elaboration-time function so there is no artefact at all.
"A wider engine costs proportionally more logic."
The wrong model: eight times the data means eight times the gates.
What it costs: designs stay narrower than they should, and run at clock rates they do not need to.
The corrected model: eight times the data costs about five and a half times the exclusive-or terms — 252 at width 8, 1 422 at width 64 — and the tree depth is the same at 32 and 64 bits. Terms per input bit falls from 31.5 to 22.2, because the state matrix is a fixed cost amortised over more data. The wide engine is more efficient per bit, not less.
"Mask the invalid bytes to zero and run the full-width network."
The wrong model: an invalid lane contributes nothing if it is zero.
What it costs: the check is computed over a longer message than was transmitted. The frame is internally consistent, so the design's own receiver accepts it; only a peer disagrees, and only on frames that end mid-word.
The corrected model: a zero octet is an ordinary input that advances the state by eight bits. Not consuming an octet and consuming a zero octet are different operations. The engine must advance by exactly 8k bits for k valid octets, which is why Section 6 selects among eight sub-networks.
"Use a lookup table, like software does."
The wrong model: the 256-entry table is the standard optimisation.
What it costs: a kilobyte of ROM on the critical path, and no route beyond one octet per clock — because a 16-bit table needs 65 536 entries.
The corrected model: the table is exact and it is the wrong trade in hardware. Its size is exponential in the width and a network's is roughly linear. Its one genuine hardware use is as an independent reference in verification, and even there the bit-serial engine is better because it shares less.
"We verified the engine against a golden model."
The wrong model: any reference makes an equivalence check meaningful.
What it costs: Section 11's rejected property. A reference generated from the same matrix shares every bug in the generator — including a corrupted polynomial — so the check passes in exactly the situation it exists to detect.
The corrected model: the reference must be independently derived. Chapter 6.2's bit-serial engine shares only the polynomial constant; the golden vector shares nothing at all, because its value comes from outside the design. The question is not whether you have a reference but what you could corrupt that it would not notice.
15. Interview Reasoning
"How do you compute a CRC 64 bits at a time?"
The weak answer gestures at unrolling. The answer that ends the topic names the property first: the serial step is linear, so n steps are linear, so the wide next state is F · state XOR H · data with both matrices obtained by running the serial step on unit vectors — 32 runs plus n. The payoff is that it is generated rather than derived, and can be generated in an elaboration-time function so there is no checked-in artefact to drift.
"What does going from 8 bits to 64 cost?"
The expected answer is "eight times". The correct one is about five and a half times the exclusive-or terms — 252 to 1 422 — with the tree depth unchanged between 32 and 64 bits, and terms per input bit falling from 31.5 to 22.2. The reason is that the 32 state-response columns are a fixed cost amortised over more data. Wide engines are more efficient per bit, which inverts the usual intuition and is the actual argument for going wide.
"Where do parallel CRC engines actually go wrong?"
Never in the matrix — generated code has no typos. In the last transfer: a final partial word that is dropped, emitted twice, or emitted with a count of the full width; and in the byte-enable path that carries which lanes are valid. The strong close is why these survive testing: each residue class is a different sub-network, real traffic clusters at two frame sizes, and a link's two commonest frames exercise two of the eight cases and never touch the other six.
"How would you sign off a generated CRC engine?"
Equivalence against an independently written bit-serial reference, swept over every length modulo the datapath width, with the residue coverage asserted rather than assumed. Then the part that shows the trap is understood: a reference generated from the same matrix as the design proves nothing — corrupt the polynomial and both sides change together and the check still passes. The test to apply to any equivalence check is what you could corrupt that it would not notice.
16. Understanding Check
Because the serial step is linear over the field with two elements, and so is any composition of it.
The step is next = (reg << 1) XOR (poly if (reg[31] XOR bit) else 0). Shifting is linear, exclusive-or is addition, and the conditional exclusive-or is a linear function of the inputs multiplied by a constant. Applying it n times is therefore one linear function of the 32-bit state and the n-bit input.
Any linear function over this field is a matrix, which gives next = F · state XOR H · data.
And a linear function is completely determined by its action on unit vectors, so 32 runs of the serial step (one per state bit, with zero input) and n runs (one per data bit, from a zero state) produce the entire matrix. Nothing is derived by hand and nothing is copied.
Which is also why the technique does not transfer to functions that look similar. A non-linear function of the same size would need exhaustive evaluation — 2⁹⁶ cases for a 64-bit step — so "this is a linear state machine" is the load-bearing observation, not "this is a CRC".
17. What's Next
The claim this chapter defended: the wide engine is generated and the boundaries are designed, and all the failures are in the boundaries.
The serial step is linear, so the n-bit next state is F · state XOR H · data with both matrices obtained by running that step on unit vectors — no algebra, no checked-in artefact, one place where the polynomial appears. And the cost scales better than the data rate: eight times the width for 5.6 times the exclusive-or terms, with the tree depth unchanged from 32 bits to 64 and the cost per input bit falling.
Which moves the whole difficulty to the last transfer, where eight different sub-networks exist, each exercised once per frame, and where a link's two commonest frame sizes reach two of them and never the other six. A dropped tail, a doubled tail, a count of the full width, a mask instead of a count — every one produces a check value that is stable, repeatable and wrong, and every one is invisible to a self-consistent loopback.
Which is why the sign-off is an equivalence against the bit-serial reference, and why the reference's independence is the whole of its value. A model generated from the same matrix passes when the polynomial itself is corrupted.
Module 6 ends here. Four chapters took the frame check sequence from a guarantee (Chapter 6.1) through an arithmetic and its conventions (Chapter 6.2), a receiver that tests for a constant (Chapter 6.3), and an engine that runs at line rate.
Chapter 7.1 — The Transmit Path assembles everything Modules 5 and 6 built into one datapath: framing, the padding of Chapter 5.6, the check append at Chapter 5.8's append point, and the interframe gap of Chapter 5.9.
And its argument is that the order is forced. Padding must precede the check, because the pad is inside the covered range. The check must precede the gap, because the gap is outside the frame. Each wrong ordering breaks something specific and identifiable — and 7.1 works through what, in each case, and what the resulting frames look like to a peer.
The full path is on the Ethernet curriculum index.
Continue learning
Related tutorials
- Related topic
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.
- Related topic
Frame Check Sequence
The check sequence protects a range, and the range is shorter than the frame's journey — appended at one point in a transmitter, verified at one point in the next receiver, and recomputed at every hop, so a device's own memory is covered by nothing the frame carries.
- Related topic
What Error Detection Must Guarantee
A detector's specification is a set of bounded guarantees plus a probability, and neither can be stated without an error model — including the guarantee everybody cites and this polynomial does not provide.
- Related topic
CRC-32 Generation
The arithmetic is a shift register performing polynomial division. The four conventions wrapped around it — initial value, input reflection, output reflection, final complement — change no guarantee and every value, which is where interoperability fails.
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.
