Ethernet · Module 19
The Receive Frame Parser
A double-tagged frame's full header fits in one beat only when the frame starts within the first three octets of it — 4.7% of the time — and the parser has 1.31 cycles per frame.
Chapter 19.1 gave this chapter two constraints and they are the whole of it.
§4: at 100 Gb/s a MAC has 1.312 clock cycles per minimum-size frame. §3: above a 128-bit datapath a beat routinely carries the end of one frame and the start of the next, so a header may begin at any offset in a beat rather than at offset zero.
Put them together and the parser's problem is stated.
| What a naive parser assumes | What is true at 100 Gb/s | |
|---|---|---|
| where a header starts | octet 0 of a beat | any of 64 offsets |
| how long it has | the frame | 1.312 cycles |
| how many frames it sees at once | one | twelve — Chapter 19.1 §6 |
And the first row's consequence is sharper than it looks, because it decides how many beats a parse spans.
A frame's header runs from Chapter 5.1's destination address at offset 0 through the L4 header Chapter 18.7 §3's checksum engine needs. How far that is depends on Chapter 13.2's tags:
| Tagging | L3 starts at | L4 starts at | Header through TCP |
|---|---|---|---|
| untagged | 14 | 34 | 54 octets |
| one tag | 18 | 38 | 58 octets |
| two tags | 22 | 42 | 62 octets |
A 64-octet beat holds the whole of that only if the frame starts early enough in it.
| Tagging | Fits in one beat when the start offset is | Fraction of the 64 offsets |
|---|---|---|
| untagged | 0 to 10 | 17.2% |
| one tag | 0 to 6 | 10.9% |
| two tags | 0 to 2 | 4.7% |
So a double-tagged frame's full parse fits in one beat 4.7% of the time, and 95.3% of the time it spans two — which at 1.312 cycles per frame is a deficit, not a budget.
This chapter builds a parser that lives with that, and the answer is Chapter 19.1 §6's: the parse is pipelined across frames and tagged, because there is no way to spend two cycles on a frame that arrives every 1.312.
1. Scope, and the Two Constraints
Everything this chapter builds is a response to one of the two numbers above.
| Section | Establishes |
|---|---|
| 2 | where the fields are, and what moves them |
| 4 | the barrel shift, priced in muxes |
| 6 | how many beats a parse spans, by tag depth and offset |
| 9 | Chapter 18.7 §16's eight assumptions, costed |
| 11 | what can be decided in beat zero |
| 13 | the declines the parser cannot make |
| 16 | pipelining the parse across frames |
| 17 | what the parser owes its consumers |
What this chapter does not build: the transmit side is Chapter 19.3, the CRC integration is Chapter 19.4, and the FIFOs are Chapter 19.5. Those forward references are bold and unlinked because those chapters are not yet published.
And one scoping decision is worth stating because it is not obvious. The parser does not decide whether to keep a frame — Chapter 7.4's address filter does, using the destination address this block extracts. The parser's output is a set of facts about offsets, and every consumer decides for itself what to do with them.
| Consumer | Wants |
|---|---|
| Chapter 7.4's filter | the destination address |
| Chapter 13.4's VLAN logic | the VID and the PCP |
| Chapter 18.7's checksum engine | the L3 and L4 offsets, and a decline flag |
| Chapter 18.7's RSS hash | the 5-tuple |
| Chapter 16.3's capture | whether this is a PTP event message |
| Chapter 19.7's counters | the length and the frame class |
Six consumers, one parse, and the parse must produce every field any of them needs whether or not that consumer is enabled — because enabling it later must not change the parser's timing.
2. Where the Fields Are, and What Moves Them
Chapter 5.1 fixed the layout: destination at offset 0, source at 6, type at 12. Nothing after offset 12 has a fixed position.
The reason is Chapter 13.2's: four octets inserted at offset 12 move every field after them, and a frame may carry none, one or two tags — so the EtherType that actually says what the payload is sits at 12, 16 or 20.
| Field | Untagged | One tag | Two tags |
|---|---|---|---|
| destination | 0–5 | 0–5 | 0–5 |
| source | 6–11 | 6–11 | 6–11 |
| outer TPID | — | 12–13 | 12–13 |
| outer TCI | — | 14–15 | 14–15 |
| inner TPID | — | — | 16–17 |
| inner TCI | — | — | 18–19 |
| EtherType | 12–13 | 16–17 | 20–21 |
| IPv4 header | 14–33 | 18–37 | 22–41 |
| L4 header | 34–53 | 38–57 | 42–61 |
Rows one and two are the only rows that do not move, which is why Chapter 5.1 §3's argument — destination first so a receiver can abandon early — still holds at 100 Gb/s: the address filter's input is at a fixed offset and everything else is not.
And the EtherType's position is decided by its own content, which is the recursion a parser has to unwind:
The field at offset 12 is an EtherType unless it is a TPID, in which case the field at 16 is an EtherType unless it is a TPID.
Chapter 13.2 §5's TPID values are 0x8100 and 0x88A8, so the test is a comparison against a small set — and each level of the recursion is one comparison and one 4-octet shift.
Two more things move offsets and neither is a tag.
IPv4 options. The IPv4 header's length is in its own first octet — the IHL field, in the low nibble — and it is 5 words (20 octets) in the common case and up to 15 words (60 octets) otherwise. So the L4 header's offset depends on a field inside the L3 header, which is a second level of the same recursion.
And a tunnel. An encapsulated frame has a second L3 and L4 header after the first, and locating them requires the whole parse to run again at a new base offset. Chapter 18.7 §16's row five listed this as detectable "partly", and Section 13 is why.
| What moves the L4 offset | How far | Detectable in beat 0? |
|---|---|---|
| VLAN tags | +4 per tag | yes — the TPID chain |
| IPv4 options | +0 to +40 | yes — the IHL nibble |
| IPv6 extension headers | +8 per header, chained | NO — unbounded chain |
| a tunnel | a whole second parse | NO |
Rows three and four are the parser's real limits and Section 13 is about what a design does when it meets them.
3. RTL 1 — The Beat Aligner
The first block, and it exists because Chapter 19.1 §3 established that a frame may begin anywhere in a beat.
// -----------------------------------------------------------------------
// parse_pkg -- the receive frame parser.
// -----------------------------------------------------------------------
package parse_pkg;
localparam int DP_BYTES = 64; // 512-bit at 100 Gb/s
localparam int HDR_BYTES = 64; // the parse window
localparam int MAX_TAGS = 2; // 13.2's stack depth
// 13.2 section 5's TPID values. A third exists in some deployments
// and a design that hard-codes two will decline those frames --
// which is safe, and is section 13's subject.
localparam logic [15:0] TPID_8100 = 16'h8100;
localparam logic [15:0] TPID_88A8 = 16'h88A8;
localparam logic [15:0] ET_IPV4 = 16'h0800;
localparam logic [15:0] ET_IPV6 = 16'h86DD;
localparam logic [15:0] ET_PTP = 16'h88F7; // 16.2's layer-2 PTP
// What the parse produces. Every field is an OFFSET or a fact
// about one -- the parser locates, it does not decide.
typedef struct packed {
logic [7:0] frame_id; // 19.1 section 3's tag
logic valid;
logic [47:0] dst_addr;
logic [47:0] src_addr;
logic [15:0] ethertype;
logic [1:0] tag_count;
logic [11:0] outer_vid;
logic [2:0] outer_pcp;
logic [7:0] l3_offset; // from the frame's first octet
logic [7:0] l4_offset;
logic is_ipv4;
logic is_ipv6;
logic is_tcp;
logic is_udp;
logic is_fragment;
logic has_ip_options;
logic is_ptp;
// The three ways a parse can be incomplete. Each has a different
// consumer response and conflating them is section 13's error.
logic offsets_valid; // l3/l4 are trustworthy
logic declined_known; // a case we recognised and refused
logic declined_unknown; // a case we could not classify
} parse_t;
endpackage// -----------------------------------------------------------------------
// beat_aligner -- presents a 64-octet window whose octet 0 is the
// frame's first octet, whatever offset the frame began at.
//
// 19.1 section 3: a beat can hold the end of one frame and the start
// of the next, so the header begins at an arbitrary offset. Every
// downstream extraction would otherwise need that offset in its own
// addressing, which is 64 variants of every field mux.
// -----------------------------------------------------------------------
module beat_aligner
import parse_pkg::*;
(
input logic clk,
input logic rst_n,
input logic beat_valid,
input logic [DP_BYTES*8-1:0] beat_data,
input logic beat_sof,
input logic [$clog2(DP_BYTES)-1:0] beat_sof_offset,
input logic [7:0] beat_frame_id,
// A 2-beat window, aligned so that octet 0 is the frame's octet 0.
output logic win_valid,
output logic [HDR_BYTES*8-1:0] win_data,
output logic [7:0] win_frame_id,
output logic win_complete, // 64 octets present
output logic [6:0] win_bytes,
output logic [31:0] c_aligned,
output logic [31:0] c_offset_zero,
output logic [31:0] c_spans_two_beats,
output logic [6:0] worst_offset
);
// Two beats are held so that a header beginning late in beat n can
// be completed from beat n+1. Section 6: at a two-tag frame the
// header needs 62 octets, so an offset above 2 requires both.
logic [DP_BYTES*8-1:0] prev_data;
logic [$clog2(DP_BYTES)-1:0] sof_off_q;
logic [7:0] id_q;
logic armed;
wire [2*DP_BYTES*8-1:0] pair = {beat_data, prev_data};
// The barrel shift. Section 4 prices it: 64 positions x 64 octets.
// Written as a variable part-select, which synthesises to exactly
// that and is the honest way to express it.
assign win_data = pair[{1'b0, sof_off_q} * 8 +: HDR_BYTES*8];
assign win_valid = armed && beat_valid;
assign win_frame_id = id_q;
assign win_bytes = 7'(DP_BYTES) - {1'b0, sof_off_q} + 7'(DP_BYTES);
assign win_complete = (win_bytes >= 7'(HDR_BYTES));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
prev_data <= '0; sof_off_q <= '0; id_q <= '0; armed <= 1'b0;
c_aligned <= '0; c_offset_zero <= '0;
c_spans_two_beats <= '0; worst_offset <= '0;
end else if (beat_valid) begin
prev_data <= beat_data;
if (beat_sof) begin
sof_off_q <= beat_sof_offset;
id_q <= beat_frame_id;
armed <= 1'b1;
c_aligned <= c_aligned + 1;
if (beat_sof_offset == '0) c_offset_zero <= c_offset_zero + 1;
// Section 6: a two-tag header is 62 octets, so any offset
// above 2 needs the following beat as well.
if (beat_sof_offset > 8'd2)
c_spans_two_beats <= c_spans_two_beats + 1;
if ({1'b0, beat_sof_offset} > worst_offset)
worst_offset <= {1'b0, beat_sof_offset};
end
end
end
endmoduleClassification: a two-beat sliding window with a variable barrel shift, producing an offset-normalised header.
What it teaches: that normalising the offset once is far cheaper than carrying it into every extraction. A design that leaves the header where it lands needs every field mux to take the offset as an input — the destination address mux, the source mux, the EtherType mux, the IHL mux — and each becomes 64 variants of itself. One barrel shift at the front costs Section 4's figure once; not having it costs it per field.
And it teaches why two beats are held rather than one. Section 6: a double-tagged frame's header through the L4 is 62 octets, so a frame starting at offset 3 or beyond does not have its header in the beat it started in. The window is therefore 64 octets taken from a 128-octet pair, and win_complete says whether the second beat has arrived yet.
Deliberately simplified: the variable part-select across a 1024-bit vector is one enormous mux and is written as a part-select for clarity — a production design pipelines it into two stages of 8-way shift, which is Section 4's argument. win_bytes is computed as though both beats are always present, which is wrong on the first beat of a frame and is why win_complete exists rather than being derived. And there is one aligner, so a beat containing two frame starts — impossible at 512 bits, possible at 1024 — is not handled.
Production implication: c_offset_zero against c_aligned is the fraction of frames that began at a beat boundary, and it should be small. A port where it is near 1.0 has been tested with frames spaced far enough apart to realign — Chapter 19.1 §14's dual_frame_pct argument in the parser's own terms — and the barrel shift's 64 positions have been exercised at one of them.
4. The Barrel Shift, Priced
Section 3 asserted that normalising the offset is cheaper than carrying it. This section is the arithmetic, because the barrel shift is the parser's largest single structure and is worth seeing costed.
A 64-way byte-granular shift on a 64-octet output selects each output octet from 64 candidates.
| What is shifted | Octets | 64-way byte muxes | 2:1 bit-muxes |
|---|---|---|---|
| destination alone | 6 | 384 | 3 072 |
| destination and source | 12 | 768 | 6 144 |
| through the EtherType | 22 | 1 408 | 11 264 |
| the full 54-octet header | 54 | 3 456 | 27 648 |
| the whole 64-octet window | 64 | 4 096 | 32 768 |
Row five is what Section 3's aligner builds and 32 768 two-input muxes is a real structure — comparable to Chapter 19.1 §9's 512-bit CRC matrix at 16 384 XOR terms, and about twice it.
Now the alternative, and why it is worse.
A design that leaves the header in place gives each field extractor the offset and lets it address the pair directly. Each extractor is then its own 64-way shift over its own width:
| Extractor | Octets | 64-way muxes |
|---|---|---|
| destination | 6 | 384 |
| source | 6 | 384 |
| outer TPID | 2 | 128 |
| outer TCI | 2 | 128 |
| inner TPID | 2 | 128 |
| inner TCI | 2 | 128 |
| EtherType — three possible positions | 2 × 3 | 384 |
| IHL nibble — three possible positions | 1 × 3 | 192 |
| IP protocol — three positions | 1 × 3 | 192 |
| fragment flags — three positions | 2 × 3 | 384 |
| total | — | 2 432 |
Which is less than the aligner's 4 096, and it is still the wrong answer — because every one of those extractors now takes the offset as a timing-critical input, and the offset is produced by the same logic that detects the start of frame. The aligner's shift happens once, in its own pipeline stage, and everything downstream addresses a fixed layout.
| Align once | Offset per extractor | |
|---|---|---|
| muxes | 4 096 | 2 432 |
| stages the offset is on the critical path of | 1 | 10 |
| adding a field later costs | nothing | another 64-way shift |
| the downstream layout is | fixed | offset-dependent |
Row three is the one that decides it. Chapter 18.7 added the L3 and L4 offsets to the parser's outputs after the parser existed, and a design with a fixed post-alignment layout absorbed that as two more constant-offset reads. A design with per-extractor shifts absorbed it as two more 64-way shifts.
And the shift itself is pipelined rather than flat, which is how the 32 768 muxes close timing:
| Stage | Shift | Muxes |
|---|---|---|
| 1 | by 0, 8, 16, 24, 32, 40, 48 or 56 octets | 8-way, 64 octets — 512 |
| 2 | by 0 to 7 octets | 8-way, 64 octets — 512 |
Two stages of 8-way shift is 1 024 byte-muxes and two levels of logic, against one stage of 64-way at 4 096 muxes and six levels. The pipelined version is a quarter of the area and a third of the depth — and costs one cycle of latency, which Chapter 19.1 §6's cross-frame pipelining already pays for.
5. RTL 2 — The Field Extractor
With the window aligned, every fixed-offset field is a constant slice. The interesting part is the fields whose offset the content decides.
// -----------------------------------------------------------------------
// field_extractor -- constant slices from an aligned window, plus the
// three content-dependent reads.
//
// Section 2: destination and source never move. Everything from
// offset 12 onward moves by 0, 4 or 8 octets depending on the tag
// stack, so those reads are three-way muxes rather than slices.
// -----------------------------------------------------------------------
module field_extractor
import parse_pkg::*;
(
input logic clk,
input logic rst_n,
input logic win_valid,
input logic [HDR_BYTES*8-1:0] win_data,
input logic [1:0] tag_count, // from section 7
input logic tag_count_valid,
output logic [47:0] dst_addr,
output logic [47:0] src_addr,
output logic [15:0] ethertype,
output logic [11:0] outer_vid,
output logic [2:0] outer_pcp,
output logic [7:0] l3_offset,
output logic fields_valid,
output logic [31:0] c_extracted,
output logic [31:0] c_untagged,
output logic [31:0] c_one_tag,
output logic [31:0] c_two_tags
);
// Fixed slices. 5.1 fixed these and 13.2 cannot move them, which
// is why 5.1 section 3's early-abandonment argument survives at
// 100 Gb/s -- the address filter's input is a constant slice.
assign dst_addr = win_data[0 +: 48];
assign src_addr = win_data[48 +: 48];
// The outer tag, if any. Read unconditionally: reading it when it
// is not a tag costs nothing and removes it from the critical path
// of the tag decision.
wire [15:0] at12 = win_data[96 +: 16];
wire [15:0] at14 = win_data[112 +: 16];
wire [15:0] at16 = win_data[128 +: 16];
wire [15:0] at20 = win_data[160 +: 16];
assign outer_pcp = at14[15:13];
assign outer_vid = at14[11:0];
// The EtherType sits at 12, 16 or 20. A three-way mux, not a
// 64-way one -- which is what section 3's alignment bought.
always_comb begin
unique case (tag_count)
2'd0: ethertype = at12;
2'd1: ethertype = at16;
default: ethertype = at20;
endcase
l3_offset = 8'd14 + (8'd4 * {6'b0, tag_count});
end
assign fields_valid = win_valid && tag_count_valid;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
c_extracted <= '0; c_untagged <= '0;
c_one_tag <= '0; c_two_tags <= '0;
end else if (fields_valid) begin
c_extracted <= c_extracted + 1;
unique case (tag_count)
2'd0: c_untagged <= c_untagged + 1;
2'd1: c_one_tag <= c_one_tag + 1;
default: c_two_tags <= c_two_tags + 1;
endcase
end
end
endmoduleClassification: constant slices plus three-way muxes on the fields the tag stack moves.
What it teaches: that reading a field unconditionally and deciding later is faster than deciding first. at12, at14, at16 and at20 are all read every cycle, whether or not the frame is tagged — so the tag decision is not on the read's critical path, only on the mux that follows it. A design that gates the reads on the tag count serialises a comparison in front of a slice, which costs a level of logic on the parser's tightest path for no benefit: the reads are free.
And it teaches that Section 3's alignment turned a 64-way problem into a 3-way one. The EtherType sits at 12, 16 or 20 — three positions, one 3:1 mux of 16 bits. Without alignment it would sit at offset + 12, offset + 16 or offset + 20 with offset spanning 64 values: 192 positions, and Section 4's table shows what that costs.
Deliberately simplified: tag_count arrives from Section 7 as a combinational input, so the two blocks are one logic cone in this listing and a real design registers between them. Only the outer tag's VID and PCP are extracted, where a design supporting Chapter 13.2's double tagging needs the inner one too — and the inner tag's fields are at 18–19, which is another constant slice. The IPv6 case is absent entirely; its L3 header is 40 octets rather than 20, so the L4 offset arithmetic differs.
Production implication: c_untagged, c_one_tag and c_two_tags together tell an integrator what the parser is actually seeing, and the ratio is frequently not what the network diagram says. A port believed to be on an access link — untagged — that reports a substantial c_one_tag is receiving tagged frames, which either means the switch port is configured as a trunk or something upstream is not stripping. Neither is visible from the frames' contents once the parser has stripped them, so the counters are the only evidence.
6. How Many Beats a Parse Spans
Section 1 gave the fractions. This section derives them, because the derivation shows which of the three inputs a design controls.
A parse spans one beat if the frame's header fits inside the beat the frame started in. The header's length through the L4 is 14 + 4T + 20 + 20 for IPv4 with T tags — and the beat has 64 − k octets left after a frame starting at offset k.
One beat suffices when
k + 54 + 4T ≤ 64, that isk ≤ 10 − 4T.
| Tags | Header through L4 | k must be | Offsets that work | Fraction |
|---|---|---|---|---|
| 0 | 54 octets | ≤ 10 | 11 of 64 | 17.2% |
| 1 | 58 | ≤ 6 | 7 of 64 | 10.9% |
| 2 | 62 | ≤ 2 | 3 of 64 | 4.7% |
And k is not uniformly distributed, which is worth being careful about.
Chapter 5.9 establishes that the gap between frames is 9 to 15 octets with a bounded deficit, plus Chapter 5.2's 8 octets of preamble and SFD — so consecutive frames are 17 to 23 octets apart. The next frame's start offset is therefore the previous frame's end offset plus 17 to 23, modulo 64 — which over a long run of same-size frames cycles through a subset of the 64 offsets rather than covering them uniformly.
| Traffic | k behaves |
|---|---|
| identical frame sizes, back to back | cycles through a fixed orbit |
| mixed sizes | approximately uniform |
| frames spaced far apart | k is whatever the idle alignment gives |
Row one is the case a regression must construct deliberately, and Section 20's directed test does: a run of identical minimum-size frames visits the same handful of offsets for ever, so a barrel shift that is broken at offset 37 is never exercised.
Now the consequence for the cycle budget, which is the section's point.
| One-beat parse | Two-beat parse | |
|---|---|---|
| cycles needed | 1 | 2 |
| cycles available at 100 Gb/s, 64-octet frames | 1.312 | 1.312 |
| verdict | fits | DEFICIT of 0.688 cycles |
And 95.3% of double-tagged frames are in the right-hand column.
Which is not a problem to be solved by making the parse faster. The parse takes two beats because the header is in two beats; no amount of logic changes that. The answer is Chapter 19.1 §6's: the parse is pipelined across frames, so the parser works on frame n's second beat while frame n+1's first beat arrives, and its throughput is one frame per 1.312 cycles even though its latency is two.
| Latency | Throughput | |
|---|---|---|
| a two-beat parse | 2 cycles | one frame per cycle, pipelined |
| what the budget requires | anything | one frame per 1.312 cycles |
Throughput is what the budget constrains and latency is what the structure costs — and confusing them is how a design concludes the parse is impossible.
7. RTL 3 — The Tag Stack Detector
The recursion from Section 2, unrolled to a fixed depth — because a recursion cannot be unrolled at run time in 1.312 cycles.
// -----------------------------------------------------------------------
// tag_stack_detector -- how many 13.2 tags precede the EtherType.
//
// The recursion is "the field at 12 is an EtherType unless it is a
// TPID, in which case the field at 16 is...". It is unrolled to
// MAX_TAGS levels, in parallel, because a sequential unroll costs a
// cycle per level and section 6's budget has 1.312 in total.
// -----------------------------------------------------------------------
module tag_stack_detector
import parse_pkg::*;
(
input logic clk,
input logic rst_n,
input logic win_valid,
input logic [HDR_BYTES*8-1:0] win_data,
input logic cfg_accept_88a8,
output logic [1:0] tag_count,
output logic tag_count_valid,
output logic stack_too_deep, // more than MAX_TAGS
output logic [31:0] c_depth [3],
output logic [31:0] c_too_deep,
output logic [31:0] c_88a8_seen
);
wire [15:0] at12 = win_data[96 +: 16];
wire [15:0] at16 = win_data[128 +: 16];
wire [15:0] at20 = win_data[160 +: 16];
// One comparator per level, all three in parallel. The comparisons
// are independent; only the PRIORITY between them is sequential,
// and priority is a mux rather than a cycle.
function automatic logic is_tpid(input logic [15:0] v,
input logic accept_88a8);
return (v == TPID_8100) || (accept_88a8 && (v == TPID_88A8));
endfunction
wire t0 = is_tpid(at12, cfg_accept_88a8);
wire t1 = t0 && is_tpid(at16, cfg_accept_88a8);
wire t2 = t1 && is_tpid(at20, cfg_accept_88a8);
// MAX_TAGS is 2, so a third tag is a stack this parser refuses.
// Refusing is correct and is section 13's point: the parse is
// wrong beyond this depth, and saying so is better than guessing.
assign stack_too_deep = win_valid && t2;
always_comb begin
if (t1) tag_count = 2'd2;
else if (t0) tag_count = 2'd1;
else tag_count = 2'd0;
end
assign tag_count_valid = win_valid && !stack_too_deep;
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
for (i = 0; i < 3; i++) c_depth[i] <= '0;
c_too_deep <= '0; c_88a8_seen <= '0;
end else if (win_valid) begin
if (stack_too_deep) c_too_deep <= c_too_deep + 1;
else c_depth[tag_count] <= c_depth[tag_count] + 1;
if ((at12 == TPID_88A8) || (at16 == TPID_88A8))
c_88a8_seen <= c_88a8_seen + 1;
end
end
endmoduleClassification: a fixed-depth parallel unroll of a content-dependent recursion, with an explicit refusal beyond the depth.
What it teaches: that a recursion whose depth is unbounded in the protocol must be unrolled to a fixed depth in hardware, and the design must say what it does beyond that depth. Chapter 13.2 permits a tag stack; this parser handles two and refuses three. The refusal is a correct outcome — the offsets would be wrong, and a wrong offset is Section 13's undetectable corruption — so stack_too_deep is a feature and not a limitation apologised for.
And it teaches that the three comparisons are parallel and only the priority is ordered. is_tpid(at12), is_tpid(at16) and is_tpid(at20) do not depend on each other — each reads a different constant slice — so all three fire in one level of logic, and the t1 = t0 && ... chain is an AND tree rather than a sequence. A sequential unroll would cost a cycle per level, and Section 6's budget has 1.312 in total.
Deliberately simplified: cfg_accept_88a8 is a single bit where a real design has a configurable TPID set — some deployments use 0x9100 or a provider-chosen value, and a parser that hard-codes two will decline those frames. The counters are indexed by tag_count as an array subscript on a 2-bit value into a 3-entry array, which is legal and would be a case statement. And stack_too_deep is computed from t2 alone, so a frame with three tags and a fourth is reported identically — the parser cannot count beyond what it unrolled.
Production implication: c_too_deep should be zero on almost every network, and any non-zero value is worth investigating rather than tuning away. A frame carrying three tags is provider-bridged traffic reaching an access port, or a misconfigured trunk, or a device that is stacking tags it should be swapping. The parser declines it and Chapter 18.7's offload declines with it — so the frame is delivered, unoffloaded, and works. The counter is the only sign anything unusual happened.
8. RTL 4 — The L3/L4 Locator
Section 7 found where the EtherType is. This block is the second level of the same recursion: where the L4 header is, given an L3 header whose length is inside itself.
// -----------------------------------------------------------------------
// l3_l4_locator -- the L3 and L4 offsets 18.7 section 16 needs.
//
// The IPv4 header's length is the IHL nibble in its own first octet,
// so the L4 offset depends on a field whose position depends on the
// tag count. Two levels of content-dependence, both resolved in the
// same cycle because section 6's budget allows one.
// -----------------------------------------------------------------------
module l3_l4_locator
import parse_pkg::*;
(
input logic clk,
input logic rst_n,
input logic win_valid,
input logic [HDR_BYTES*8-1:0] win_data,
input logic [15:0] ethertype,
input logic [7:0] l3_offset,
input logic win_complete,
output logic [7:0] l4_offset,
output logic is_ipv4,
output logic is_ipv6,
output logic is_tcp,
output logic is_udp,
output logic is_fragment,
output logic has_ip_options,
output logic offsets_valid,
output logic [31:0] c_ipv4,
output logic [31:0] c_ipv6,
output logic [31:0] c_options,
output logic [31:0] c_fragments,
output logic [31:0] c_incomplete_window
);
assign is_ipv4 = (ethertype == ET_IPV4);
assign is_ipv6 = (ethertype == ET_IPV6);
// Read the IPv4 header's first octets at the tag-dependent offset.
// Three possible positions -- 14, 18 or 22 -- so a three-way mux,
// which is what section 3's alignment and section 7's count bought.
logic [7:0] ip_b0, ip_proto;
logic [15:0] ip_flags_frag;
always_comb begin
unique case (l3_offset)
8'd14: begin
ip_b0 = win_data[112 +: 8];
ip_flags_frag = win_data[160 +: 16];
ip_proto = win_data[184 +: 8];
end
8'd18: begin
ip_b0 = win_data[144 +: 8];
ip_flags_frag = win_data[192 +: 16];
ip_proto = win_data[216 +: 8];
end
default: begin
ip_b0 = win_data[176 +: 8];
ip_flags_frag = win_data[224 +: 16];
ip_proto = win_data[248 +: 8];
end
endcase
end
// IHL is in words. 5 means a 20-octet header; anything else means
// 18.7 section 16's row four -- options, and a moved L4 offset.
wire [3:0] ihl = ip_b0[3:0];
assign has_ip_options = is_ipv4 && (ihl != 4'd5);
// 18.7 section 16's row three. The fragment bit and a non-zero
// offset both mean there is no L4 header here at all.
assign is_fragment = is_ipv4 &&
(ip_flags_frag[13] || (ip_flags_frag[12:0] != 13'd0));
assign is_tcp = is_ipv4 && !is_fragment && (ip_proto == 8'd6);
assign is_udp = is_ipv4 && !is_fragment && (ip_proto == 8'd17);
assign l4_offset = l3_offset + ({4'b0, ihl} << 2);
// The offsets are only trustworthy if the whole header was in the
// window. Section 6: at a start offset above 2 with two tags, it
// was not -- and the second beat has to arrive first.
assign offsets_valid = win_valid && win_complete && is_ipv4 &&
!is_fragment;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
c_ipv4 <= '0; c_ipv6 <= '0; c_options <= '0;
c_fragments <= '0; c_incomplete_window <= '0;
end else if (win_valid) begin
if (is_ipv4) c_ipv4 <= c_ipv4 + 1;
if (is_ipv6) c_ipv6 <= c_ipv6 + 1;
if (has_ip_options) c_options <= c_options + 1;
if (is_fragment) c_fragments <= c_fragments + 1;
if (!win_complete) c_incomplete_window <= c_incomplete_window + 1;
end
end
endmoduleClassification: a second content-dependent read, muxed over the three positions the first level can produce.
What it teaches: that two levels of content-dependence compose into a 3-way mux rather than a 9-way one, because the second level's offset is fixed once the first is resolved. The IHL nibble is at l3_offset + 0 and l3_offset has three values — so three positions, not three times the sixty-four the un-aligned case would give. Section 3's alignment pays for itself twice, once at the EtherType and once here.
And it teaches that l4_offset is arithmetic on a field the frame supplied, which is the parser's most dangerous output. An IHL of 15 gives an L4 offset of l3_offset + 60 — 82 octets into a frame that may be 64 octets long. The offset is computed correctly from a value the frame contained, and Chapter 18.7 §16's row seven is the consumer-side version of the same exposure. Section 17 is what the parser owes about it.
Deliberately simplified: IPv6 is detected and not located — its L4 offset requires walking an extension-header chain, which Section 13 establishes is unbounded and is why offsets_valid requires is_ipv4. The fragment test reads the flags and fragment-offset field as one 16-bit word, which is correct for IPv4's layout and is written without naming the bits. And l4_offset is computed without checking it against the frame's length, which a production design must do and Section 12 does.
Production implication: c_incomplete_window against c_ipv4 is the measured version of Section 6's fraction, and comparing it against the prediction is how a design confirms its alignment logic is exercised. Section 6 predicts 95.3% for double-tagged traffic at uniformly distributed offsets; a measured 0% means frames are arriving at offset zero, which means they are spaced far enough apart to realign — and the two-beat path has not been tested.
9. Chapter 18.7's Eight Assumptions, Costed
Chapter 18.7 §16 listed eight assumptions offload makes about the traffic and said the parser must detect each one. This section is what each costs, in the two currencies the parser has: cycles and mux width.
| # | Assumption | What must be read | Mux width | Cycles |
|---|---|---|---|---|
| 1 | IPv4 or IPv6 | the EtherType, at 12/16/20 | 3-way × 16 bits | 0 — in beat 0 |
| 2 | TCP or UDP | the IP protocol, at L3+9 | 3-way × 8 bits | 0 |
| 3 | not a fragment | flags and offset, at L3+6 | 3-way × 16 bits | 0 |
| 4 | no IPv4 options | IHL, at L3+0 low nibble | 3-way × 4 bits | 0 |
| 5 | no tunnel | a SECOND L3/L4 parse | the whole parser again | 1+ — Section 13 |
| 6 | tag depth | the TPID chain | 3 × 16-bit comparators | 0 for depth ≤ 2 |
| 7 | the checksum offset | nothing — software supplied it | — | not the parser's |
| 8 | a sane MSS | nothing — transmit only | — | not the parser's |
Six of the eight are free, and that is the section's first finding.
Rows 1 to 4 and 6 are all reads from the aligned window at positions Section 7's tag count already resolved — three-way muxes of between 4 and 16 bits, all in parallel, all in beat 0. Their total cost:
| Bits muxed | |
|---|---|
| EtherType | 3 × 16 = 48 |
| protocol | 3 × 8 = 24 |
| fragment flags | 3 × 16 = 48 |
| IHL | 3 × 4 = 12 |
| TPID comparators | 3 × 16 = 48 |
| total | 180 bits of 3:1 mux |
One hundred and eighty bits of three-way mux buys five of Chapter 18.7's eight assumptions — against Section 4's 32 768 two-input muxes for the barrel shift. The assumptions are 0.5% of the parser.
Row five is the expensive one and it is expensive in a structural way rather than a numerical one.
A tunnel is a second L3 and L4 header after the first. Detecting it requires knowing the tunnel protocol — VXLAN, GRE, GENEVE, IP-in-IP — and then running the whole parse again at a new base offset.
| The outer parse | The inner parse | |
|---|---|---|
| base offset | 0 | the outer L4's payload |
| beats it needs | 1 or 2 | 1 or 2 more |
| cycles | 1 | 1 more |
| against a 1.312-cycle budget | fits | does not |
So a parser that handles one level of tunnelling costs roughly twice the logic and twice the latency — and the latency is affordable, because Section 6 established that latency is not what the budget constrains. The logic is the cost: a second aligner window, a second locator, a second set of muxes.
Which is why most MACs decline tunnelled traffic and Chapter 18.7 §16 marked row five "partly". The parser detects that the outer L4 is UDP on a known port and stops there, reporting the outer offsets and a decline for the inner.
Rows seven and eight are not the parser's at all, and they are in the table because Chapter 18.7 §16 listed them. Row seven — a software-supplied checksum offset — is unverifiable by anything, which that chapter established; row eight is a transmit parameter. Neither costs this block a gate.
And the section's second finding is the distribution. Five assumptions cost 0.5% of the parser; one costs 100% of it again; two cost nothing. A design deciding how much offload to support is really deciding about row five, and every other row comes along for free.
10. RTL 5 — The Parse Result Store
Chapter 19.1 §6's tagged side channel, instantiated for the parser.
// -----------------------------------------------------------------------
// parse_result_store -- holds parse results by frame_id until their
// consumers ask.
//
// 19.1 section 6: at 1.312 cycles per frame a 16-stage pipeline
// holds twelve frames, so a result produced in this stage belongs to
// a frame that left several stages ago. The tag is the only way to
// say which.
// -----------------------------------------------------------------------
module parse_result_store
import parse_pkg::*;
#(
parameter int SLOTS = 16
)(
input logic clk,
input logic rst_n,
input logic res_valid,
input parse_t res,
// Consumers read by tag, not by position.
input logic rd_valid,
input logic [7:0] rd_frame_id,
output logic rd_hit,
output parse_t rd_res,
input logic retire_valid,
input logic [7:0] retire_frame_id,
output logic [$clog2(SLOTS+1)-1:0] occupancy,
output logic [31:0] c_stored,
output logic [31:0] c_hits,
output logic [31:0] c_misses,
output logic store_full,
output logic tag_collision // must never assert
);
parse_t slot [SLOTS];
logic slot_busy[SLOTS];
// Find the slot holding this tag, and a free slot for a new one.
logic hit;
logic [$clog2(SLOTS)-1:0] hit_i, free_i;
logic have_free;
always_comb begin
int i;
hit = 1'b0; hit_i = '0; have_free = 1'b0; free_i = '0;
for (i = SLOTS-1; i >= 0; i--) begin
if (slot_busy[i] && (slot[i].frame_id == rd_frame_id)) begin
hit = 1'b1;
hit_i = i[$clog2(SLOTS)-1:0];
end
if (!slot_busy[i]) begin
have_free = 1'b1;
free_i = i[$clog2(SLOTS)-1:0];
end
end
end
assign rd_hit = rd_valid && hit;
assign rd_res = slot[hit_i];
assign store_full = !have_free;
// Two live results with the same tag means the tag space is
// smaller than the pipeline's occupancy -- 19.1 section 14's
// c_results_unmatched, caught at the store instead.
logic coll;
always_comb begin
int i;
coll = 1'b0;
for (i = 0; i < SLOTS; i++)
if (res_valid && slot_busy[i] && (slot[i].frame_id == res.frame_id))
coll = 1'b1;
end
assign tag_collision = coll;
always_comb begin
int i;
occupancy = '0;
for (i = 0; i < SLOTS; i++) if (slot_busy[i]) occupancy = occupancy + 1'b1;
end
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
for (i = 0; i < SLOTS; i++) begin
slot[i] <= '0; slot_busy[i] <= 1'b0;
end
c_stored <= '0; c_hits <= '0; c_misses <= '0;
end else begin
if (res_valid && have_free && !tag_collision) begin
slot[free_i] <= res;
slot_busy[free_i] <= 1'b1;
c_stored <= c_stored + 1;
end
if (rd_valid) begin
if (hit) c_hits <= c_hits + 1;
else c_misses <= c_misses + 1;
end
for (i = 0; i < SLOTS; i++)
if (retire_valid && slot_busy[i] &&
(slot[i].frame_id == retire_frame_id))
slot_busy[i] <= 1'b0;
end
end
endmoduleClassification: a small content-addressed store keyed on a pipeline tag, with an explicit collision check.
What it teaches: that the store's depth must exceed the pipeline's occupancy and the tag space must exceed the store's depth, and the two are different requirements. Chapter 19.1 §6: twelve frames in flight at 100 Gb/s with a 16-stage pipeline — so sixteen slots is adequate. The tag is eight bits, giving 256 values, which is far more than sixteen — and that surplus is what makes tag_collision a genuine invariant rather than an expected condition.
And it teaches that consumers read by tag rather than by position, which is what lets Chapter 7.4's filter, Chapter 13.4's VLAN logic and Chapter 18.7's checksum engine consume the same parse at different pipeline depths. A store indexed by position would require every consumer to be the same number of stages away, which they are not and cannot be made to be.
Deliberately simplified: the hit and free searches are combinational over sixteen entries every cycle, which is two 16-way comparisons of an 8-bit tag in the read path; a production store uses a small CAM. rd_res is driven from slot[hit_i] unconditionally, so a miss returns slot zero's contents with rd_hit low — which is correct and will trip up a consumer that ignores rd_hit. And a result arriving when the store is full is dropped silently; a real design stalls the parse, which it may do because the parse is not the datapath.
Production implication: c_misses is the counter that catches a consumer reading a result that has already been retired, which is a pipeline-depth mismatch rather than a parser bug. A consumer eighteen stages downstream of the parser reading a store that retires at sixteen misses every time — and the symptom is that consumer behaving as though every frame were unparseable, which for Chapter 18.7's checksum engine means offload silently doing nothing.
11. What Can Be Decided in Beat Zero
Section 9 said six of eight assumptions cost no cycles. This section is the more precise statement, because "in beat zero" depends on where in beat zero the frame started.
A decision can be made in the frame's first beat if the field it reads is inside that beat. With a frame starting at offset k, the beat holds octets 0 to 63 − k of the frame.
| Decision | Reads octet | Available when |
|---|---|---|
| is this mine? | 0–5 | k ≤ 58 |
| who sent it? | 6–11 | k ≤ 52 |
| how many tags? | 12–21 | k ≤ 42 |
| IPv4 or IPv6? | 12/16/20 | k ≤ 42 |
| IPv4 options? | L3+0 — up to 22 | k ≤ 41 |
| a fragment? | L3+6 — up to 28 | k ≤ 35 |
| TCP or UDP? | L3+9 — up to 31 | k ≤ 32 |
| where is L4? | computed from IHL | k ≤ 32 |
| the 5-tuple for RSS | through L4+4 — up to 46 | k ≤ 17 |
| the full L4 header | through 61 | k ≤ 2 |
Read the right-hand column down: the decisions degrade gracefully rather than failing together.
A frame starting at offset 50 has its destination and source addresses in beat zero and nothing else — so Chapter 7.4's address filter can run and Chapter 18.7's checksum engine must wait. That is exactly the behaviour Chapter 5.1 §3's layout was designed to produce: the earliest decision is available earliest, at every alignment.
Start offset k | Decisions available in beat zero |
|---|---|
| 0–2 | all of them |
| 3–17 | everything except the full L4 header |
| 18–32 | through the L4 offset; not the 5-tuple |
| 33–35 | through the fragment test |
| 36–42 | through the tag count and EtherType |
| 43–52 | destination and source only |
| 53–58 | destination only |
| 59–63 | none — the address is not complete |
And the distribution of k decides how often each row happens. Section 6 established that consecutive frames are 17 to 23 octets apart — Chapter 5.9's gap plus Chapter 5.2's preamble — so on back-to-back minimum-size frames k advances by (64 + 20) mod 64 = 20 each frame and cycles through a short orbit.
| Frame | k | Row reached |
|---|---|---|
| 1 | 0 | all |
| 2 | 20 | through the L4 offset |
| 3 | 40 | tag count and EtherType |
| 4 | 60 | none |
| 5 | 16 | all but the full L4 header |
| 6 | 36 | tag count and EtherType |
| 7 | 56 | destination only |
| 8 | 12 | all but the full L4 header |
A cycle of sixteen offsets — 0, 20, 40, 60, 16, 36, 56, 12, 32, 52, 8, 28, 48, 4, 24, 44 — and then it repeats, because 20 and 64 have a greatest common divisor of 4. So a run of identical minimum-size frames visits sixteen of the sixty-four offsets and never the other forty-eight.
Which is Section 20's directed test in one sentence, and it is why a regression of back-to-back minimum-size frames does not exercise the barrel shift.
And the practical consequence for a design is a rule about ordering the consumers. A parser that produces all its outputs together makes every consumer wait for the slowest decision — the full L4 header, available at k ≤ 2. A parser that produces them as they become available lets the address filter abandon the frame at offset 6 even when the L4 parse will not complete until the next beat, which is Chapter 5.1 §3's early-abandonment argument surviving intact at 100 Gb/s.
12. RTL 6 — The Decline Logic
The block that turns Section 11's degradation into a statement a consumer can act on.
// -----------------------------------------------------------------------
// parse_decline_logic -- three kinds of "I did not parse this", kept
// apart.
//
// 18.7 section 3 established that a checksum engine must be able to
// say "not computed". The parser's version has three cases and they
// have different consumer responses:
// declined_known -- we recognised the case and refused (a fragment)
// declined_unknown -- we could not classify it at all (a new EtherType)
// incomplete -- we ran out of window; ask again next beat
// -----------------------------------------------------------------------
module parse_decline_logic
import parse_pkg::*;
(
input logic clk,
input logic rst_n,
input logic win_valid,
input logic win_complete,
input logic [15:0] ethertype,
input logic is_ipv4,
input logic is_ipv6,
input logic is_tcp,
input logic is_udp,
input logic is_fragment,
input logic has_ip_options,
input logic stack_too_deep,
input logic [7:0] l4_offset,
input logic [15:0] frame_bytes,
output logic declined_known,
output logic declined_unknown,
output logic incomplete,
output logic offsets_trustworthy,
output logic [31:0] c_decline_fragment,
output logic [31:0] c_decline_options,
output logic [31:0] c_decline_tags,
output logic [31:0] c_decline_proto,
output logic [31:0] c_decline_l4_past_end,
output logic [31:0] c_incomplete,
output logic [31:0] c_clean
);
// Cases we RECOGNISE and refuse. Each is a real frame type and
// each has a counter, because 18.7 section 21's complaint 1 is
// diagnosed by which of them dominates.
wire d_fragment = is_ipv4 && is_fragment;
wire d_options = is_ipv4 && has_ip_options;
wire d_tags = stack_too_deep;
wire d_proto = (is_ipv4 || is_ipv6) && !(is_tcp || is_udp) && !is_fragment;
// The L4 offset is computed from a field the FRAME supplied --
// section 8. An IHL of 15 puts it 60 octets past the L3 header,
// which on a 64-octet frame is past the end. Computing it
// correctly from a hostile value is not the same as it being
// usable.
wire d_past_end = is_ipv4 && ({8'b0, l4_offset} >= frame_bytes);
assign declined_known = win_valid &&
(d_fragment | d_options | d_tags | d_proto |
d_past_end);
// A case we could not classify: not IPv4, not IPv6, not a tag we
// know. The frame is fine; we simply have nothing to say about it.
assign declined_unknown = win_valid && !is_ipv4 && !is_ipv6 &&
!stack_too_deep;
// Not a decline at all: the window did not hold the whole header
// and the answer is available next beat -- section 11.
assign incomplete = win_valid && !win_complete &&
!declined_known && !declined_unknown;
assign offsets_trustworthy = win_valid && win_complete &&
!declined_known && !declined_unknown;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
c_decline_fragment <= '0; c_decline_options <= '0;
c_decline_tags <= '0; c_decline_proto <= '0;
c_decline_l4_past_end <= '0; c_incomplete <= '0; c_clean <= '0;
end else if (win_valid) begin
if (d_fragment) c_decline_fragment <= c_decline_fragment + 1;
if (d_options) c_decline_options <= c_decline_options + 1;
if (d_tags) c_decline_tags <= c_decline_tags + 1;
if (d_proto) c_decline_proto <= c_decline_proto + 1;
if (d_past_end) c_decline_l4_past_end <= c_decline_l4_past_end + 1;
if (incomplete) c_incomplete <= c_incomplete + 1;
if (offsets_trustworthy) c_clean <= c_clean + 1;
end
end
endmoduleClassification: a three-way classification of parse failure, with per-cause accounting on one of the three.
What it teaches: that "incomplete" is not a decline and must not be reported as one. A frame whose header did not fit in beat zero will parse perfectly next beat — Section 11 — so a consumer told "declined" discards an offload opportunity that was available one cycle later. The three outcomes have three responses: a known decline means fall back to software; an unknown decline means the same; an incomplete means wait.
And it teaches that d_past_end is the parser's only defence against a hostile header. Section 8 computes l4_offset from the frame's own IHL nibble — correctly, from whatever value was there — and a value of 15 puts the L4 header 60 octets past the L3. On a 64-octet frame that is past the end, and a consumer that reads there reads the FCS or the next frame. The check is one comparison against the frame length and it is the difference between a computed offset and a usable one.
Deliberately simplified: frame_bytes arrives as an input and is not known when the parse runs on a frame still arriving — a real design either delays the check or uses a conservative bound. declined_unknown fires on every non-IP EtherType including ARP and Chapter 16.2's layer-2 PTP, which are perfectly parseable by a design that wants them; the listing treats "not IP" as "unclassified" for brevity. And IPv6 is neither declined nor located, which Section 13 addresses.
Production implication: the five decline counters are Chapter 18.7 §21's complaint-1 table with the causes separated at the source. c_decline_proto dominating means a protocol the parser does not know — on a modern network usually a tunnel. c_decline_fragment dominating means the path is fragmenting, an MTU problem elsewhere. c_decline_l4_past_end non-zero at all is worth investigating, because it means frames are arriving whose IHL and length disagree — malformed, or hostile.
13. The Declines the Parser Cannot Make
Section 12 classified three kinds of failure. This section is about a fourth, which the parser cannot report because it does not know it has happened.
Chapter 18.7 §16 marked two of its eight assumptions "NO" in the detectable column. Row six is the parser's.
The VLAN tagging matches the parser's expectation — if it does not, every offset is wrong, and there is no detection.
Section 7's detector compares against 0x8100 and optionally 0x88A8. A frame tagged with a third TPID — 0x9100, or a provider-chosen value — is read as untagged, and every offset after 12 is four octets early.
| What the parser concludes | What is actually there |
|---|---|
| EtherType at 12 | a TPID |
| IPv4 header at 14 | the TCI and the real EtherType |
| IHL at 14, low nibble | the TCI's low nibble |
| L4 offset = 14 + 4 × that | arithmetic on a priority code point |
And the failure is silent in the specific way that matters: the parse succeeds. A TCI whose low nibble happens to be 5 produces an IHL of 5, a plausible header length, and a plausible L4 offset — and offsets_trustworthy asserts.
The probability is computable and it is not small. The parser concludes IPv4 if the 16 bits at offset 12 equal 0x0800; with an unrecognised TPID at 12, those 16 bits are the TPID, which is not 0x0800 — so the frame is declined_unknown, which is safe.
The dangerous case is one tag deeper. A frame with one recognised outer tag and one unrecognised inner one: the parser sees the outer TPID, shifts by 4, reads the inner TPID at 16, does not recognise it, and treats it as the EtherType. Again not 0x0800, again a safe decline.
So the actual exposure is narrower than Chapter 18.7 §16 suggests, and worth stating precisely:
| Case | Outcome | Safe? |
|---|---|---|
| an unrecognised TPID where an EtherType is expected | not IPv4 — declined | YES |
| a parser expecting NO tags on a tagged frame | TPID read as EtherType — declined | YES |
| a parser expecting a tag on an untagged frame | reads 4 octets into the IP header | NO — may match |
Row three is the real hazard and it is a configuration error rather than a traffic one. A parser configured to strip a tag that is not present reads the IPv4 header's first four octets as a TCI and the next two as an EtherType — and the IPv4 header at offset 12 begins 45 00 followed by the total length. The "EtherType" it then reads is the total length, which for a 2 048-octet frame is 0x0800. Exactly 0x0800.
So a mis-configured parser on a 2 048-octet untagged IPv4 frame concludes it is looking at IPv4, at an offset four octets wrong, and every subsequent field is garbage that happens to parse.
| Value | |
|---|---|
frame sizes whose total-length field equals 0x0800 | exactly one: total length 2 048 |
| as a fraction of 1 500-octet-MTU traffic | zero — 2 048 exceeds the MTU |
| with jumbo frames enabled | one size in 9 000 |
Which is reassuring and is not a proof. The general form — a field read at the wrong offset landing on a value that passes the test — has no bound, and Chapter 18.7 §16's general rule stands: over-estimate a header's length rather than under-estimate it, because reading too far lands in a constrained field and reading too little lands in data.
And there is a second undetectable case that Section 9's row five introduced: the unbounded chain.
IPv6 extension headers form a linked list, each carrying the type of the next. A parser that walks them needs a loop whose depth the frame decides, which Section 6's budget does not have — so a design either unrolls to a fixed depth and declines beyond it, or declines IPv6's L4 entirely.
| Design | IPv6 L4 offset | Cost |
|---|---|---|
| decline all IPv6 | never | nothing; offload lost on IPv6 |
| unroll 2 headers | when the chain is short | two more levels of mux |
| walk the chain | always | unbounded cycles — impossible |
Row one is what Section 8's offsets_valid implements and it is the honest choice at 100 Gb/s: a parser that declines IPv6's L4 is correct, and one that guesses is Section 12's d_past_end waiting to happen.
14. RTL 7 — Parser Telemetry
The counters that make Sections 6, 9 and 11's arithmetic checkable.
// -----------------------------------------------------------------------
// rxparse_telemetry -- the parser's observable state.
// -----------------------------------------------------------------------
module rxparse_telemetry
import parse_pkg::*;
(
input logic clk,
input logic rst_n,
input logic frame_seen,
input logic [6:0] start_offset,
input logic spans_two_beats,
input logic [1:0] tag_count,
input logic offsets_trustworthy,
input logic declined_known,
input logic declined_unknown,
input logic incomplete,
input logic stack_too_deep,
input logic tag_collision,
input logic [7:0] l3_offset,
input logic [7:0] l4_offset,
output logic [31:0] c_frames,
output logic [31:0] c_two_beat,
output logic [31:0] c_offset_hist [8], // 8-octet buckets
output logic [31:0] c_trust,
output logic [31:0] c_decline_known,
output logic [31:0] c_decline_unknown,
output logic [31:0] c_incomplete,
output logic [31:0] c_collisions,
output logic [7:0] worst_l4_offset,
output logic [15:0] two_beat_pct,
output logic [15:0] trust_pct,
output logic [15:0] offsets_covered // how many buckets are non-zero
);
wire [2:0] bucket = start_offset[5:3];
always_comb begin
int i;
two_beat_pct = (c_frames == '0) ? 16'd0
: 16'((c_two_beat * 32'd100) / c_frames);
trust_pct = (c_frames == '0) ? 16'd0
: 16'((c_trust * 32'd100) / c_frames);
offsets_covered = '0;
for (i = 0; i < 8; i++)
if (c_offset_hist[i] != '0) offsets_covered = offsets_covered + 16'd1;
end
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
c_frames <= '0; c_two_beat <= '0; c_trust <= '0;
c_decline_known <= '0; c_decline_unknown <= '0;
c_incomplete <= '0; c_collisions <= '0; worst_l4_offset <= '0;
for (i = 0; i < 8; i++) c_offset_hist[i] <= '0;
end else if (frame_seen) begin
c_frames <= c_frames + 1;
c_offset_hist[bucket] <= c_offset_hist[bucket] + 1;
if (spans_two_beats) c_two_beat <= c_two_beat + 1;
if (offsets_trustworthy) c_trust <= c_trust + 1;
if (declined_known) c_decline_known <= c_decline_known + 1;
if (declined_unknown) c_decline_unknown <= c_decline_unknown + 1;
if (incomplete) c_incomplete <= c_incomplete + 1;
if (tag_collision) c_collisions <= c_collisions + 1;
if (l4_offset > worst_l4_offset) worst_l4_offset <= l4_offset;
end
end
endmoduleClassification: a parse-outcome accountant with a start-offset histogram.
What it teaches: that offsets_covered is a coverage measurement the design makes about its own testing, which is Chapter 19.1 §14's dual_frame_pct argument applied to the barrel shift. Section 11 showed that back-to-back identical frames visit sixteen of sixty-four offsets — a quarter — so eight buckets of eight offsets each should all be non-zero after any realistic run, and a value below eight is evidence the shift has been tested at a subset.
And it teaches that worst_l4_offset is the parser's hostile-input watermark. Section 12's d_past_end catches an offset past the frame's end; this records how far the computed offset ever got. A value above about 62 — the largest legitimate offset, two tags plus a 20-octet IPv4 header — means an IHL above 5 has been seen, which is Section 9's row four and is legitimate; a value near 82 means an IHL of 15, which is legal IPv4 and vanishingly rare on real traffic.
Deliberately simplified: three combinational divides and an 8-way scan. The histogram is 8 buckets of 8 offsets, which is enough to see a subset and not enough to see which sixteen of Section 11's orbit are being hit — a 64-bucket version is 64 counters and would be worth it during bring-up. And the counters never reset, so offsets_covered describes the port's lifetime rather than the last run.
Production implication: trust_pct is the number that decides whether Chapter 18.7's offload is worth having on this port, and it is the parser-side version of that chapter's offload_coverage_pct. The two should agree; where they do not, the difference is consumers declining for reasons the parser did not — a checksum engine refusing a frame the parser located perfectly, which means the decline is in the consumer's own configuration rather than in the traffic.
15. RTL 8 — The Parser Conformance Monitor
The verdicts, and they divide into a fault the parser can have and three properties of the traffic it cannot fix.
// -----------------------------------------------------------------------
// rxparse_conformance_monitor -- parse verdicts.
// -----------------------------------------------------------------------
module rxparse_conformance_monitor
import parse_pkg::*;
(
input logic clk,
input logic rst_n,
input logic [31:0] c_frames,
input logic [31:0] c_trust,
input logic [31:0] c_decline_known,
input logic [31:0] c_decline_unknown,
input logic [31:0] c_incomplete,
input logic [31:0] c_collisions,
input logic [31:0] c_decline_l4_past_end,
input logic [31:0] c_decline_tags,
input logic [15:0] two_beat_pct,
input logic [15:0] trust_pct,
input logic [15:0] offsets_covered,
input logic [7:0] worst_l4_offset,
input logic tag_collision,
input logic store_full,
output logic parser_ok,
output logic tag_space_fault,
output logic offsets_untested,
output logic malformed_traffic,
output logic tunnelled_traffic,
output logic provider_tags_seen,
output logic none_of_the_above
);
// The only fault the parser itself can have: a tag reused while a
// result is still live -- 19.1 section 14's class, at the store.
assign tag_space_fault = (c_collisions != 32'd0) || store_full;
// A coverage verdict, not a fault -- section 14. Fewer than all
// eight buckets means the barrel shift has been exercised at a
// subset of its 64 positions.
assign offsets_untested = (c_frames > 32'd1_000_000) &&
(offsets_covered < 16'd8);
// Frames whose IHL and length disagree. Legal IPv4 permits an IHL
// of 15; a frame that is too short to hold it is malformed.
assign malformed_traffic = (c_decline_l4_past_end != 32'd0);
// Section 9 row five: a protocol the parser does not classify. On
// a modern network this is usually an overlay.
assign tunnelled_traffic = (c_frames > 32'd100_000) &&
(c_decline_unknown > (c_frames >> 4));
// Section 13: a tag stack deeper than this parser unrolls.
assign provider_tags_seen = (c_decline_tags != 32'd0);
assign parser_ok = !tag_space_fault && !malformed_traffic;
assign none_of_the_above = parser_ok && !offsets_untested &&
!tunnelled_traffic && !provider_tags_seen;
// ---- properties -------------------------------------------------
p_no_tag_collision:
assert property (@(posedge clk) disable iff (!rst_n)
!tag_collision)
else $error("two live parse results shared a frame tag");
p_outcomes_partition:
assert property (@(posedge clk) disable iff (!rst_n)
(c_frames > 32'd0) |->
((c_trust + c_decline_known + c_decline_unknown + c_incomplete)
<= c_frames))
else $error("the parse outcomes do not partition the frames");
p_malformed_excludes_ok:
assert property (@(posedge clk) disable iff (!rst_n)
malformed_traffic |-> !parser_ok)
else $error("parser_ok asserted with malformed traffic recorded");
p_trust_bounded:
assert property (@(posedge clk) disable iff (!rst_n)
trust_pct <= 16'd100)
else $error("trust percentage exceeded 100");
p_l4_offset_sane:
assert property (@(posedge clk) disable iff (!rst_n)
worst_l4_offset <= 8'd82)
else $error("an L4 offset exceeded the largest legal value");
endmoduleClassification: a verdict generator with one design fault and three traffic observations.
What it teaches: that only one of the five verdicts is something the parser can be blamed for. tag_space_fault is a design error — the tag space is smaller than the pipeline's occupancy, Chapter 19.1 §14's class. The other four describe the traffic: malformed frames, tunnels, provider tags, and a testbench that has not covered the offsets. A monitor that reported them with equal urgency would have an operator chasing the network for a coverage gap.
And it teaches that p_l4_offset_sane's bound of 82 is derived rather than chosen. The largest legitimate L4 offset is two tags (8) plus the 14-octet Ethernet header plus a 60-octet IPv4 header with maximum options — 82. Anything above that is arithmetic on a field that was not an IHL, which is Section 13's mis-tagging case detected from its consequence.
Deliberately simplified: the thresholds are literals — a sixteenth for tunnelled traffic, a million frames for the coverage verdict — where production takes them from registers. p_outcomes_partition uses ≤ rather than = because a frame can be counted in two categories in this listing's accounting; a real monitor makes the four mutually exclusive at the source. And store_full is folded into the tag verdict, which conflates a depth problem with a tag-width one.
Production implication: none_of_the_above for the eighth time across Modules 18 and 19, and this is the first one that is partly about the testbench. A port asserting it has no parse fault, no malformed traffic, no unexpected tunnelling or tagging, and a barrel shift that has been exercised across its range — which is a different kind of claim from the seven before it, because the last clause is about how the design was verified rather than about how it is behaving.
16. Pipelining the Parse Across Frames
Section 6 established that a two-beat parse cannot fit in a 1.312-cycle budget and that the answer is throughput rather than latency. This section is the structure.
The parse is four stages and none of them can be removed.
| Stage | Does | Latency |
|---|---|---|
| 1 | align — Section 3's barrel shift, pipelined in two | 2 cycles |
| 2 | detect the tag stack — Section 7 | 1 cycle |
| 3 | extract fields and locate L3/L4 — Sections 5, 8 | 1 cycle |
| 4 | classify and decline — Section 12 | 1 cycle |
| total | — | 5 cycles |
Five cycles of latency against a 1.312-cycle frame interval means 3.8 frames are in the parser at once.
| Line rate | Cycles per frame | Frames in a 5-stage parser |
|---|---|---|
| 1 Gb/s | 84.000 | 0.06 |
| 10 Gb/s | 10.500 | 0.48 |
| 25 Gb/s | 10.500 | 0.48 |
| 100 Gb/s | 1.312 | 3.81 |
And the 1 Gb/s row is why a parser written for that rate has no tags: 0.06 frames in flight means the parse completes long before the next frame arrives, so "the current frame" is unambiguous and the structure works without a name for it.
At 100 Gb/s four frames are in the parser simultaneously, so every stage carries frame_id and Section 10's store is keyed on it. This is Chapter 19.1 §6's technique with a concrete depth.
Two details make it work and both are easy to get wrong.
First, the window spans two beats and therefore two frames' worth of data. Section 3's aligner holds prev_data and beat_data; at 100 Gb/s those two beats may contain parts of three frames — the end of one, the whole of a minimum-size second, and the start of a third. The aligner tracks one start-of-frame offset, so a beat with two starts is not representable — which at 512 bits cannot happen, because 64 octets holds at most one 84-octet frame slot, and is Section 17's note for wider datapaths.
Second, the stages have different latencies for different frames. A frame whose header fits in one beat completes stage 1 in two cycles; one that spans two completes in three, because the second beat has to arrive. So results emerge out of order relative to frame arrival, and Section 10's store — content-addressed by tag rather than a FIFO — is what makes that harmless.
| A FIFO of results | A tag-addressed store | |
|---|---|---|
| in-order results | works | works |
| out-of-order results | BREAKS | works |
| a consumer at a different depth | breaks | works |
Row two is the one that decides it, and the out-of-order case is not rare: Section 6's fractions say 95.3% of double-tagged frames take the longer path, so a run of mixed tagging produces results out of order constantly.
17. What the Parser Owes Its Consumers
Section 1 listed six consumers. This section is the contract, because several of its clauses are not obvious and one of them is the whole of Section 13.
| # | The parser guarantees | Or says it cannot |
|---|---|---|
| 1 | the destination address is the frame's octets 0–5 | always — a fixed slice |
| 2 | the tag count is the number of TPIDs it recognised | stack_too_deep if deeper |
| 3 | the EtherType is the field after the last tag | subject to clause 2 |
| 4 | l3_offset is where an IP header would start | subject to clause 3 |
| 5 | l4_offset is computed from the frame's own IHL | d_past_end if it is past the end |
| 6 | offsets_trustworthy means clauses 3 to 5 hold | explicitly false otherwise |
| 7 | nothing about IPv6's L4 | offsets_valid requires IPv4 |
| 8 | nothing about a tunnel's inner headers | declined_unknown |
Clause 5 is the one worth reading twice. The parser does not guarantee that l4_offset points at an L4 header. It guarantees that l4_offset is what the frame's IHL field says, which is a different and weaker claim — and it is the strongest claim available, because the IHL is the only information about the header's length that exists.
Which means a consumer reading at l4_offset is trusting the frame, and Chapter 18.7 §16's row seven is the same exposure on the transmit side. The parser's contribution is d_past_end, which bounds the damage to the frame's own extent, and that is all hardware can do.
And clause 6 is the contract's load-bearing clause. A consumer that ignores offsets_trustworthy and reads l4_offset anyway gets a number — always a number, never an error — and computes a checksum over the wrong span. Chapter 18.7 §3's checksum_computed bit exists because of this clause, and a consumer that does not check it is the reason that bit exists.
There is one more thing the parser owes and it is about timing rather than content.
Section 11 established that decisions become available at different offsets. A parser that emits all its outputs together makes Chapter 7.4's address filter wait for the L4 parse, which it does not need — and the filter's whole purpose, Chapter 5.1 §3, is to abandon a frame six octets in.
| Output group | Available at | Consumer |
|---|---|---|
| destination | k ≤ 58 | Chapter 7.4's filter |
| tag count, VID, PCP | k ≤ 42 | Chapter 13.4 |
| L3/L4 offsets | k ≤ 32 | Chapter 18.7's checksum |
| the 5-tuple | k ≤ 17 | Chapter 18.7's RSS |
Four output groups with four availability points, and emitting them as they become ready costs four valid bits and preserves the early-abandonment property Chapter 5.1 designed the frame layout for.
18. The Cost, Accounted
Eight blocks, and one of them is 80% of the chapter.
| Block | Approximate cost | Dominated by |
|---|---|---|
beat_aligner | ~1 200 flops + 1 024 byte-muxes | the two-stage barrel shift |
field_extractor | ~200 flops + 180 bits of 3:1 mux | the three-way reads |
tag_stack_detector | ~80 flops + 3 comparators | trivial |
l3_l4_locator | ~150 flops + a 4-bit shift-add | the IHL arithmetic |
parse_result_store | ~2 400 flops + a 16-way CAM | 16 slots × ~140 bits |
parse_decline_logic | ~200 flops | counters |
rxparse_telemetry | ~450 flops | the histogram |
rxparse_conformance_monitor | ~130 flops | comparators |
About 4 800 flops plus the mux structures, and the two large entries are the aligner and the store — neither of which does any parsing. The blocks that actually extract fields are 430 flops and 180 bits of mux.
Which is the chapter's cost summary in one sentence: the parse is almost free and the alignment and the bookkeeping are the design.
| Cost | Caused by | |
|---|---|---|
| alignment | ~1 200 flops + 1 024 byte-muxes | Chapter 19.1 §3's dual-frame beat |
| tagging and storage | ~2 400 flops | Chapter 19.1 §6's 1.312 cycles |
| the actual parse | ~430 flops + 180 mux bits | Chapter 5.1's layout |
Both causes are Chapter 19.1's two numbers, and neither would exist at 1 Gb/s: a GMII parser has 84 cycles per frame and a frame that always starts at offset zero, so it needs no aligner and no tags. The 3 600 flops of alignment and bookkeeping are what 100 Gb/s costs a parser that would otherwise be 430.
The mux structures, placed against Chapter 19.1 §18's:
| Structure | Size |
|---|---|
| the CRC matrix, 512 bits | 16 384 XOR terms |
| the barrel shift, two stages | 1 024 byte-muxes = 8 192 2:1 bit-muxes |
| the barrel shift, flat | 4 096 byte-muxes = 32 768 |
The pipelined shift is half the CRC matrix; the flat one is twice it — which is Section 4's argument placed against the MAC's other large structure.
And no memory at all. The store is flops; the window is flops; the histogram is flops. This chapter adds zero bytes of SRAM, which leaves Chapter 19.1 §18's total unchanged at about 48 KiB for a 100 Gb/s port.
19. Properties Worth Asserting, and One Worth Refusing
The parser's properties are about offsets, classification and tags. The rejected one is about classification, and it is the one a verification plan opens with.
Alignment.
// The window's octet 0 is the frame's octet 0.
p_window_is_aligned:
assert property (@(posedge clk) disable iff (!rst_n)
win_valid |-> (win_data[7:0] == frame_octet_0))
else $error("the aligned window did not begin at the frame's first octet");
// The shift amount is the recorded start offset.
p_shift_matches_offset:
assert property (@(posedge clk) disable iff (!rst_n)
(beat_valid && beat_sof) |=> (sof_off_q == $past(beat_sof_offset)))
else $error("the aligner used a different offset from the one it was given");
// A window is only complete when both beats have arrived.
p_complete_needs_two_beats:
assert property (@(posedge clk) disable iff (!rst_n)
win_complete |-> armed)
else $error("a window was declared complete before the aligner was armed");
// The offset never exceeds the datapath.
p_offset_within_beat:
assert property (@(posedge clk) disable iff (!rst_n)
beat_sof |-> (beat_sof_offset < DP_BYTES))
else $error("a start-of-frame offset fell outside the beat");
// A frame starting at offset 0 needs only one beat for any tagging.
p_offset_zero_is_complete:
assert property (@(posedge clk) disable iff (!rst_n)
(win_valid && (sof_off_q == '0)) |-> win_complete)
else $error("a frame at offset zero did not produce a complete window");Field extraction.
// The fixed slices are fixed. 5.1 says so and 13.2 cannot move them.
p_dst_is_octets_0_to_5:
assert property (@(posedge clk) disable iff (!rst_n)
fields_valid |-> (dst_addr == win_data[0 +: 48]))
else $error("the destination address was not the window's first six octets");
p_src_is_octets_6_to_11:
assert property (@(posedge clk) disable iff (!rst_n)
fields_valid |-> (src_addr == win_data[48 +: 48]))
else $error("the source address was not the window's octets 6 to 11");
// The EtherType's position follows the tag count, and only that.
p_ethertype_position:
assert property (@(posedge clk) disable iff (!rst_n)
(fields_valid && (tag_count == 2'd0)) |->
(ethertype == win_data[96 +: 16]))
else $error("an untagged frame's EtherType was not at octet 12");
// l3_offset is 14 plus four per tag, always.
p_l3_offset_arithmetic:
assert property (@(posedge clk) disable iff (!rst_n)
fields_valid |-> (l3_offset == (8'd14 + (8'd4 * {6'b0, tag_count}))))
else $error("the L3 offset did not follow the tag count");
// The tag count never exceeds what the detector unrolled.
p_tag_count_bounded:
assert property (@(posedge clk) disable iff (!rst_n)
tag_count_valid |-> (tag_count <= 2'(MAX_TAGS)))
else $error("a tag count exceeded the unrolled depth");Tag detection.
// A TPID at 12 means at least one tag.
p_tpid_implies_tag:
assert property (@(posedge clk) disable iff (!rst_n)
(win_valid && (win_data[96 +: 16] == TPID_8100)) |-> (tag_count != 2'd0))
else $error("a TPID at octet 12 did not produce a tag");
// No TPID at 12 means no tags.
p_no_tpid_no_tag:
assert property (@(posedge clk) disable iff (!rst_n)
(win_valid && (win_data[96 +: 16] != TPID_8100) &&
(win_data[96 +: 16] != TPID_88A8)) |-> (tag_count == 2'd0))
else $error("a frame with no TPID was reported as tagged");
// A third tag is refused rather than counted.
p_third_tag_refused:
assert property (@(posedge clk) disable iff (!rst_n)
stack_too_deep |-> !tag_count_valid)
else $error("a tag count was offered for a stack that is too deep");
// 0x88A8 is only a tag when configured.
p_88a8_gated:
assert property (@(posedge clk) disable iff (!rst_n)
(win_valid && !cfg_accept_88a8 &&
(win_data[96 +: 16] == TPID_88A8)) |-> (tag_count == 2'd0))
else $error("0x88A8 was treated as a tag with acceptance disabled");L3 and L4 location.
// The L4 offset is the L3 offset plus four times the IHL.
p_l4_offset_arithmetic:
assert property (@(posedge clk) disable iff (!rst_n)
(win_valid && is_ipv4) |->
(l4_offset == (l3_offset + ({4'b0, ihl} << 2))))
else $error("the L4 offset did not follow the IHL");
// An IHL of 5 means no options.
p_ihl5_no_options:
assert property (@(posedge clk) disable iff (!rst_n)
(win_valid && is_ipv4 && (ihl == 4'd5)) |-> !has_ip_options)
else $error("an IHL of 5 was reported as carrying options");
// A fragment is never reported as TCP or UDP.
p_fragment_has_no_l4:
assert property (@(posedge clk) disable iff (!rst_n)
is_fragment |-> (!is_tcp && !is_udp))
else $error("a fragment was classified as TCP or UDP");
// IPv4 and IPv6 are mutually exclusive.
p_l3_exclusive:
assert property (@(posedge clk) disable iff (!rst_n)
!(is_ipv4 && is_ipv6))
else $error("a frame was classified as both IPv4 and IPv6");
// Offsets are only trustworthy on a complete window.
p_offsets_need_complete:
assert property (@(posedge clk) disable iff (!rst_n)
offsets_valid |-> win_complete)
else $error("offsets were declared valid on an incomplete window");
// IPv6 never produces a trustworthy L4 offset -- section 13.
p_ipv6_l4_declined:
assert property (@(posedge clk) disable iff (!rst_n)
is_ipv6 |-> !offsets_valid)
else $error("an IPv6 frame produced a trustworthy L4 offset");Decline classification and the store.
// The three outcomes are mutually exclusive.
p_outcomes_exclusive:
assert property (@(posedge clk) disable iff (!rst_n)
$onehot0({declined_known, declined_unknown, incomplete}))
else $error("two parse outcomes asserted together");
// Trustworthy excludes all three.
p_trust_excludes_declines:
assert property (@(posedge clk) disable iff (!rst_n)
offsets_trustworthy |->
(!declined_known && !declined_unknown && !incomplete))
else $error("offsets were trustworthy alongside a decline");
// An incomplete window is not a decline.
p_incomplete_is_not_decline:
assert property (@(posedge clk) disable iff (!rst_n)
incomplete |-> (!declined_known && !declined_unknown))
else $error("an incomplete window was reported as a decline");
// An L4 offset past the frame's end is always refused.
p_past_end_declined:
assert property (@(posedge clk) disable iff (!rst_n)
({8'b0, l4_offset} >= frame_bytes) |-> !offsets_trustworthy)
else $error("an L4 offset past the frame's end was offered as trustworthy");
// A result is only stored under a tag not already live.
p_store_tag_unique:
assert property (@(posedge clk) disable iff (!rst_n)
!tag_collision)
else $error("two live results shared a tag");
// A read hit returns the result stored under that tag.
p_read_returns_its_tag:
assert property (@(posedge clk) disable iff (!rst_n)
rd_hit |-> (rd_res.frame_id == rd_frame_id))
else $error("a tagged read returned another frame's result");
// A retired tag is no longer readable.
p_retire_removes:
assert property (@(posedge clk) disable iff (!rst_n)
retire_valid |=> !(rd_valid && (rd_frame_id == $past(retire_frame_id))
&& rd_hit))
else $error("a retired result was still readable");20. Verification Scenarios
Fifty-eight scenarios, plus a six-run directed test that requires a frame generator which controls the start offset.
Alignment — 11 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 1 | a frame at offset 0 | window complete in one beat |
| 2 | a frame at offset 2, two tags | complete — the boundary case |
| 3 | a frame at offset 3, two tags | spans two beats |
| 4 | a frame at offset 10, untagged | complete — the boundary case |
| 5 | a frame at offset 11, untagged | spans two beats |
| 6 | a frame at offset 63 | one octet in the beat; nothing decidable |
| 7 | every offset 0 to 63 | the aligned window is identical in all 64 |
| 8 | back-to-back frames sharing a beat | two starts, one per beat — handled |
| 9 | the shift's two stages | 8-way then 8-way = the 64-way result |
| 10 | a frame preceded by a long idle | offset is whatever alignment gives |
| 11 | win_bytes on the first beat of a frame | win_complete low |
Field extraction — 10 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 12 | untagged IPv4 | EtherType at 12, L3 at 14 |
| 13 | one tag | EtherType at 16, L3 at 18 |
| 14 | two tags | EtherType at 20, L3 at 22 |
| 15 | 0x88A8 outer, acceptance on | counted as a tag |
| 16 | 0x88A8 outer, acceptance off | NOT a tag; EtherType is 0x88A8 |
| 17 | three tags | stack_too_deep, count invalid |
| 18 | the VID and PCP of a single tag | from octets 14–15 |
| 19 | a broadcast destination | extracted as a fixed slice |
| 20 | the address at offset 58 | the last offset where it is complete |
| 21 | ARP (0x0806) | declined_unknown |
L3 and L4 location — 10 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 22 | IPv4, IHL 5, TCP | L4 at L3+20; is_tcp |
| 23 | IPv4, IHL 6 | has_ip_options; L4 at L3+24; declined |
| 24 | IPv4, IHL 15 | L4 at L3+60; d_past_end on a short frame |
| 25 | IPv4, UDP | is_udp |
| 26 | IPv4, ICMP | d_proto, declined |
| 27 | IPv4 with the more-fragments bit | is_fragment, declined |
| 28 | IPv4 with a non-zero fragment offset | is_fragment, declined |
| 29 | IPv6 | is_ipv6; offsets_valid LOW |
| 30 | IPv6 with extension headers | declined — Section 13 |
| 31 | two tags plus IHL 15 on a 64-octet frame | d_past_end |
Decline classification — 9 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 32 | a clean untagged IPv4 TCP frame | offsets_trustworthy |
| 33 | an incomplete window | incomplete, NOT a decline |
| 34 | the same frame one beat later | now trustworthy |
| 35 | a fragment | declined_known, c_decline_fragment |
| 36 | an unknown EtherType | declined_unknown |
| 37 | three tags | declined_known, c_decline_tags |
| 38 | the three outcomes together | impossible — $onehot0 |
| 39 | trustworthy with a decline | impossible |
| 40 | an L4 offset equal to the frame length | d_past_end |
The result store — 9 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 41 | store one, read it back by tag | hit, same contents |
| 42 | read a tag never stored | miss; rd_hit low |
| 43 | four frames in flight, read out of order | all four hit |
| 44 | sixteen slots filled | store_full |
| 45 | a seventeenth result | dropped; the parse stalls |
| 46 | two live results with one tag | tag_collision |
| 47 | retire a tag, then read it | miss |
| 48 | a consumer 18 stages downstream, store of 16 | c_misses rises |
| 49 | occupancy against 19.1's 12 frames | peaks near 12 |
Telemetry and verdicts — 9 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 50 | back-to-back 64-octet frames | 16 of 64 offsets visited; offsets_covered = 8 |
| 51 | mixed frame sizes | all 8 buckets |
| 52 | frames spaced far apart | offset 0 every time; offsets_covered = 1 |
| 53 | a million frames, one bucket | offsets_untested |
| 54 | an L4 offset above 82 | property fires |
| 55 | a tag collision | tag_space_fault, not parser_ok |
| 56 | d_past_end non-zero | malformed_traffic, not parser_ok |
| 57 | more than a sixteenth unknown | tunnelled_traffic |
| 58 | everything clean and covered | none_of_the_above |
The directed test — six runs random stimulus will not produce.
Section 11 established the problem: a run of identical minimum-size frames advances the start offset by 20 each frame and visits sixteen of the sixty-four offsets — 0, 20, 40, 60, 16, 36, 56, 12, 32, 52, 8, 28, 48, 4, 24, 44 — and never the other forty-eight. A random frame-size generator does better and still does not cover: it visits offsets in proportion to the size distribution, and a barrel shift broken at one position is found only by chance.
So the test needs a generator that controls k directly, which means controlling the interframe gap frame by frame — and Chapter 5.9's deficit mechanism means the gap is 9 to 15 octets, so k can be steered by ±3 per frame and swept.
Construct it. Six runs, one variable.
| Run | Stimulus | Offsets visited | Expected |
|---|---|---|---|
| A | 64-octet frames, minimum gap, sustained | 16 of 64 — the orbit | passes; covers a quarter |
| B | mixed sizes, random gaps | ~all, unevenly | passes; no guarantee |
| C | gap steered to sweep k = 0…63 | all 64, once each | the coverage run |
| D | k swept, two tags, IPv4 TCP | all 64 | 95.3% span two beats |
| E | k swept, shift broken at 37 | all 64 | FAILS at k = 37 only |
| F | k = 0 only, shift broken at 37 | 1 of 64 | passes — the false pass |
Run F is the point. A barrel shift with one broken position passes every test that does not visit that position — and run A, the obvious "line rate, minimum frames" stress, visits sixteen. The probability that a single broken position is among them is 16 in 64, one in four; for a shift broken at two positions it is better, and for one broken position it is a coin flip weighted against the tester.
Run C is the fix and it is cheap: a generator that varies the gap within Chapter 5.9's legal 9-to-15 range sweeps k across all 64 positions in at most 64 frames.
And run D is the one that exercises both paths deliberately. Section 6: a two-tagged frame parses in one beat only at k ≤ 2, and the orbit contains exactly one such offset — k = 0. So run A exercises the one-beat path once in sixteen frames and the two-beat path fifteen times in sixteen. That ratio is the right way round and it is luck: a different frame size gives a different orbit, and an orbit containing several small offsets would exercise the two-beat path hardly at all.
The oracle, in four parts:
| Check | Runs A–D | Run E | Run F |
|---|---|---|---|
| aligned window against the generator's frame | matches at every k | differs at k = 37 | matches — k = 0 only |
offsets_covered | 8 in C and D, ≤ 4 in A | 8 | 1 |
p_classification_matches_injection | passes | FAILS | passes |
p_unparseable_declines | passes | PASSES | passes |
Row four is Section 19's rejected class demonstrated. At k = 37 the window is shifted wrongly, so the parser reads garbage and classifies it as whatever the garbage says — and if it says "not IPv4", the frame is declined_unknown and the rejected property is satisfied. Only row three, which compares against what the generator injected, fails.
21. Debugging a Parser
Three complaints, and the first is the one this chapter exists for.
Complaint 1 — "offload is disabled on most frames and the traffic looks ordinary."
| Check | If yes | Meaning |
|---|---|---|
c_incomplete large? | the window is not complete | Section 6 — a two-beat parse |
| is the consumer treating incomplete as a decline? | it must not | Section 12's three outcomes |
c_decline_unknown dominant? | a protocol the parser does not classify | usually a tunnel |
c_decline_options dominant? | IPv4 options | rare, and real |
trust_pct against 18.7's coverage? | they should agree | a gap means the consumer declines |
Row two is the bug this chapter predicts and it is a consumer-side error. A checksum engine told "incomplete" and treating it as "declined" throws away an offload that would have been available one cycle later — and on a port where 95.3% of double-tagged frames span two beats, that is almost all of them.
Complaint 2 — "some frames are parsed wrongly and there is no error."
| Check | If yes | Meaning |
|---|---|---|
| is the parser configured to strip a tag? | Section 13's row three | an untagged frame is read four octets early |
c_decline_l4_past_end non-zero? | an IHL that does not fit | malformed, or the above |
worst_l4_offset above 82? | arithmetic on a non-IHL | confirmed misparse |
| does disabling tag stripping fix it? | confirms | a configuration fault |
Row four is the test to run first because it is a register write rather than an investigation, and Section 13 establishes that this is the one mis-tagging case with no detection.
Complaint 3 — "it works in the lab and fails in the field."
| Check | If yes | Meaning |
|---|---|---|
offsets_covered below 8? | the barrel shift is under-tested | Section 20's run F |
| were frames spaced apart in the lab? | offset 0 every time | one of 64 positions |
| does the field traffic have mixed sizes? | it visits more offsets | including a broken one |
c_offset_hist concentrated? | a short orbit | Section 11 |
Row two is the lab's fault and it is the most common cause of this complaint in this chapter. A bench generator sending frames with generous gaps realigns before every frame, so k is zero always — and the whole barrel shift is exercised at one position.
And the two symptoms this chapter is systematically blamed for:
| Symptom | Blamed on | Usually is |
|---|---|---|
| offload disabled on most frames | the checksum engine | a consumer treating "incomplete" as "declined" |
| occasional misparsed frames in the field only | the traffic | a barrel-shift position the lab never visited |
22. Misconceptions
Misconception 1 — "a frame starts at the beginning of a beat."
The wrong model: the datapath delivers frames aligned; the header is at octet 0 of the first beat.
What it costs: a parser that reads the destination address from the wrong six octets on every frame that shares a beat with its predecessor — which at 100 Gb/s with minimum-size frames is essentially all of them, because Chapter 19.1 §3's beat is 64 octets and the gap is 17 to 23.
The corrected model: a header begins at any of 64 offsets, and normalising that once with a barrel shift is cheaper than carrying the offset into every extraction. 1 024 byte-muxes in two pipelined stages against ten extractors each needing their own 64-way shift. Sections 3, 4.
Misconception 2 — "the parse has 1.31 cycles, so it must fit in one."
The wrong model: the budget is 1.312 cycles per frame, so the parse must complete in one.
What it costs: a design that concludes the parse is impossible and either narrows the datapath — quadrupling the clock, which Chapter 19.1 §16 shows the CRC cannot meet — or drops the L4 offsets and with them Chapter 18.7's offload.
The corrected model: the budget constrains throughput, not latency. A five-stage parse with five cycles of latency produces one result per cycle once pipelined, and holds 3.81 frames at 100 Gb/s — each carrying Chapter 19.1 §3's frame_id. Sections 6, 16.
Misconception 3 — "incomplete means declined."
The wrong model: the parser could not produce the offsets, so the frame is not offloadable.
What it costs: offload thrown away on 95.3% of double-tagged frames, because their headers span two beats and the answer arrives one cycle later. The port then does its checksums in software — Chapter 18.7 §2's 102.81% of a core — for a reason that lasts one cycle.
The corrected model: there are three outcomes, not two: a known decline, an unknown decline, and incomplete. The first two mean fall back; the third means wait. Section 12.
Misconception 4 — "a parser that cannot classify a frame is a limitation."
The wrong model: declining frames is a shortfall to be engineered away.
What it costs: a design that guesses. A tag stack deeper than the unroll, an IPv6 extension chain, a tunnel — each has an offset the parser cannot determine, and computing one anyway produces Section 12's d_past_end at best and a silently wrong checksum at worst.
The corrected model: a decline is a correct outcome and the parser's most important output is the bit that says so. Chapter 18.7 §3 needed "not computed" as a third value for exactly this reason, and a parser that cannot decline forces its consumers to trust offsets that were guessed. Sections 12, 13.
Misconception 5 — "the tag detection is the expensive part."
The wrong model: unwinding Chapter 13.2's recursion is the parser's hard problem.
What it costs: attention on three 16-bit comparators — 80 flops — while the aligner and the result store, 3 600 flops between them, get none.
The corrected model: the recursion unrolls to a fixed depth in parallel and costs almost nothing. The parser's cost is 80% alignment and bookkeeping: 1 200 flops plus 1 024 byte-muxes for the barrel shift, 2 400 for the tagged result store, and 430 for every field extraction in the chapter. Section 18.
Misconception 6 — "assert that every unparseable frame is declined."
The wrong model: the property states the requirement, it passes, the requirement is covered.
What it costs: a vacuous pass on the exact frames that matter. The antecedent is computed from the parser's own classification, so a frame the parser misclassifies as IPv4 is not "unparseable" by the property's definition — and the property is satisfied while the offsets are wrong.
The corrected model: a property about a classifier must draw its cases from outside the classifier. The antecedent is what the generator built, not what the parser concluded, and the two differ exactly on the misclassifications. Section 19.
23. Interview Questions
Q1 — "Where does a frame's header start, on a 100 Gb/s MAC?"
Anywhere in the beat. Chapter 19.1 §3: a 512-bit beat is 64 octets and the gap between frames is 17 to 23 — Chapter 5.2's 8 octets of preamble plus Chapter 5.9's 9-to-15 gap — so a beat routinely carries the end of one frame and the start of the next. The parser therefore begins with a 64-way barrel shift, pipelined as two 8-way stages: 1 024 byte-muxes and two levels of logic, against 4 096 and six for a flat shift.
Q2 — "How many beats does a parse span?"
One or two, and which depends on the tagging and the start offset. A frame's header through the L4 is 54 octets untagged, 58 with one tag, 62 with two — so one beat suffices when the start offset k satisfies k ≤ 10 − 4T. That is 17.2%, 10.9% and 4.7% of the 64 offsets respectively — so 95.3% of double-tagged frames span two beats. At 1.312 cycles per frame that is a deficit in latency and not in throughput, which is the distinction that matters.
Q3 — "The budget is 1.31 cycles and your parse takes five. Explain."
The budget constrains throughput, not latency. A five-stage parse pipelined across frames produces one result per cycle and holds 3.81 frames at 100 Gb/s — each tagged with Chapter 19.1 §3's frame_id, because "the current frame" names four things. The results emerge out of order — a one-beat parse finishes before a two-beat one that started earlier — so the result store is content-addressed by tag rather than a FIFO.
Q4 — "What does Chapter 18.7's offload cost the parser?"
Six of its eight assumptions are free and one costs the parser again. The EtherType, the IP protocol, the fragment flags, the IHL and the TPID chain are all reads from the aligned window at positions the tag count already resolved — 180 bits of three-way mux, 0.5% of the parser. The eighth, a tunnel, needs a second L3 and L4 parse at a new base offset: a second aligner window, a second locator, roughly double the logic. Which is why most MACs decline tunnelled traffic.
Q5 — "A parser configured to strip a tag meets an untagged IPv4 frame. What happens?"
It reads four octets into the IP header and may conclude IPv4 anyway. The IPv4 header begins 45 00 followed by the total length, so the "EtherType" it reads is the total length — and for a 2 048-octet frame that is exactly 0x0800. The parse then succeeds at an offset four octets wrong, with offsets_trustworthy asserted. Chapter 18.7 §16 marked this undetectable and it is; the general defence is to over-estimate a header's length rather than under-estimate it, because reading too far lands in a constrained field and reading too little lands in data.
Q6 — "You assert that every unparseable frame is declined. It passes. Is the parser verified?"
No — the property cannot fail. Its antecedent is computed from the parser's own classification, so a frame the parser misclassifies as IPv4 is not "unparseable" by the property's definition and the antecedent never fires on it. The property is exercised thousands of times on genuinely unparseable frames and never on the case that matters. The fix is to move the universe outside the design: the antecedent must be what the generator built, not what the parser concluded — and the two differ exactly on the misclassifications.
24. Understanding Check
25. What's Next
Module 19 has five chapters left and this one has handed each of them something.
| Chapter | Builds | Takes from here |
|---|---|---|
| Chapter 19.3 — the transmit assembler | padding, CRC append, the gap | the mirror of the alignment problem |
| Chapter 19.4 — the CRC engine | the matrix, integrated | the dual-frame case, shared |
| Chapter 19.5 — the FIFOs | the clock crossing | the depths |
| Chapter 19.6 — the memory interface | request shaping | — |
| Chapter 19.7 — the counters | RMON, saturating | the frame classification |
Chapter 19.3 is the mirror and its alignment problem runs the other way. This chapter received frames at arbitrary offsets and normalised them. The assembler must place frames at offsets that satisfy Chapter 5.9's gap — which is 9 to 15 octets with a bounded deficit, so the transmitter chooses k rather than discovering it, and the choice is constrained by a deficit accumulator rather than by a barrel shift.
And it inherits a harder version of one constraint. This parser may take five cycles because its output is advisory — a consumer waits, and nothing has left the building. The assembler's output is on a cable, so Chapter 19.1 §5's rule applies: a transmit stage may stall before commit and not after, and the commit point is where the assembler's latency stops being free.
Chapter 19.4 takes the dual-frame case this chapter met in the aligner and meets it again in the accumulator. A beat holding the end of one frame and the start of another needs two CRC accumulators — one finalising, one initialising, in the same cycle — which is the same structural consequence as this chapter's two-beat window, arriving at a different block from the same 64-octet beat and 17-to-23-octet gap.
Continue learning
Related tutorials
- Related topic
Where Ethernet Stops
The payload is opaque to a MAC, and every capability that follows — one silicon design for every protocol above, including protocols invented after it shipped — depends on it staying opaque. Checksum offload is the deliberate exception, and it costs exactly what the boundary was buying.
- Related topic
Frame Format Overview
Every field exists to let a receiver make one decision at one moment, and the field order is the order those decisions must be made. The check value comes last because it covers everything before it — which makes every decision taken before it provisional.
- Related topic
Why VLANs Exist
Segmentation is the only lever on the broadcast-domain limit, and it turns every singleton in a switch into a vector. Supporting all 4094 VLANs costs 60 KiB — 0.94× the entire forwarding table.
- Related topic
Access Ports, Trunk Ports and Tag Handling
A frame's VLAN is assigned by the receiving port, not carried by the frame. Across an untagged link it is destroyed and re-invented — which is why a native-VLAN mismatch leaks both ways with every check clean.
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.
