Ethernet · Module 18
The Transmit DMA Path
On receive the MAC decides when a frame is complete; on transmit the driver does — and the difference puts a store-ordering requirement hardware cannot police onto the critical path.
Chapter 18.3 traced a frame from the wire into memory. This chapter runs it backwards, and the two directions are not mirror images.
On receive, the MAC decides when a frame is complete. It has the frame, it knows the length, it knows whether Chapter 6.3's residue check passed — and it publishes a descriptor when all of that is settled.
On transmit, the driver decides. The MAC is handed a descriptor and must believe it: this buffer pointer is valid, this length is right, this is the last fragment. And Chapter 18.2 §12 established that hardware cannot verify the store ordering that makes those fields trustworthy — a stale buffer pointer is a legal address, and reading it twice sees the same stable stale value.
So a requirement that was a corner case on receive is the critical path on transmit.
| Receive | Transmit | |
|---|---|---|
| decides a frame is complete | the MAC | the driver |
| the ordering requirement | hardware's — Section 10 of Chapter 18.3 | software's — unpoliceable |
| a violation produces | a stale tail in a buffer | a frame built from freed memory, sent on the wire |
| recoverable? | the frame is wrong in memory | NO — it has left the building |
Row four is the chapter's second theme. A received frame written wrongly is wrong in memory, where it can in principle be discarded. A transmitted frame is on a cable, and Chapter 7.3 established what happens when the MAC runs out of data mid-frame: the frame already on the wire is corrupted, and there is no way to take it back.
Which produces the number this chapter turns on.
| Line rate | Memory stall the frame can absorb | Cut-through transmit possible? |
|---|---|---|
| 1 Gb/s | a 100 ns stall needs 12.5 octets of lead | comfortably |
| 10 Gb/s | 125 octets | yes |
| 25 Gb/s | 312.5 octets | yes, with care |
| 100 Gb/s | 1250 octets for 100 ns; 3750 for 300 ns | NO — a maximum frame is 1518 |
At 100 Gb/s with a 300 ns memory stall, the lead required exceeds the largest frame there is. Cut-through transmit does not become difficult; it becomes arithmetically unavailable, and the transmit path is forced into store-and-forward whether it wants to be or not.
1. Scope, and the Asymmetry
This chapter builds the transmit side of everything Chapter 18.3 built for receive, and spends its length on the three places where the mirror breaks.
| # | The mirror | Where it breaks |
|---|---|---|
| 1 | the ring walk | the MAC trusts fields the driver wrote — Section 4 |
| 2 | the data movement | an underrun corrupts a frame in flight — Section 6 |
| 3 | the notification | it runs the other way: a doorbell, not an interrupt — Section 11 |
Row three is the one people forget. On receive, the MAC tells software a frame arrived — an interrupt. On transmit, software tells the MAC a frame is ready — a doorbell, which is a register write in the opposite direction, and it has the mirror image of Chapter 18.1 §10's cost problem.
What this chapter establishes:
| Section | Establishes |
|---|---|
| 2 | who decides a frame is complete, and what follows |
| 4 | the driver's barrier, on the critical path |
| 6 | the underrun, priced at four line rates |
| 9 | the rate at which cut-through transmit stops existing |
| 11 | doorbell against polling, derived |
| 13 | completion, and when a buffer may be freed |
| 16 | the fetch deadline for back-to-back transmission |
What it does not build: burst shaping is Chapter 18.5, adaptive coalescing of transmit completions is Chapter 18.6, and segmentation offload — which changes what a transmit descriptor even means — is Chapter 18.7. Those forward references are bold and unlinked because those chapters are not yet published.
2. Who Decides a Frame Is Complete
The receive path's descriptor is a report. The transmit path's is an instruction. That single difference propagates through every block in this chapter.
A receive descriptor, when the MAC writes it, describes something that has already happened. The frame arrived, it was this long, its FCS was good or bad. Every field is a fact the MAC observed, and the driver's only job is to read them.
A transmit descriptor, when the MAC reads it, describes something that has not happened yet. Send the octets at this address, this many of them, and this is or is not the last fragment. Every field is a claim the driver is making, and the MAC's job is to act on it.
| Field | On receive | On transmit |
|---|---|---|
| buffer pointer | the driver's, read by the MAC | the driver's, read by the MAC |
| length | the MAC's — observed | the driver's — asserted |
| status | the MAC's — observed | the MAC's, written after |
| last-fragment flag | the MAC's — observed | the driver's — asserted |
| ownership | the handoff | the handoff |
Rows two and four are the ones that move, and both move from observed to asserted.
Which has three consequences, and they are the chapter's structure.
Consequence 1 — the MAC cannot validate the claim. A length of 1518 is legal, a length of 1519 is not, and a length of 64 is legal but may be wrong. Chapter 18.2 §12's argument applies unchanged: a stale field from a missing store barrier is a plausible value, and there is nothing to see.
Consequence 2 — acting on the claim is irreversible. The MAC begins transmitting. Chapter 12.6 §8 established that a frame in flight cannot be aborted — and Chapter 17.3 made that partly untrue, but preemption suspends a frame to resume it later, not to abandon it. There is no way to un-send octets.
Consequence 3 — the failure is outside the chip. A receive-path bug corrupts memory. A transmit-path bug puts corrupt octets on a cable, where Chapter 6.3's FCS at the far end will discard them — which is the one piece of good news in this section, and it is the reason a transmit underrun is a lost frame rather than a security incident.
And the good news has a limit worth stating. The far end discards the frame because the MAC deliberately corrupts the FCS on an underrun — Section 8's job. A design that does not do that sends a truncated frame with a valid FCS over its truncated contents, which the far end accepts.
3. RTL 1 — The Transmit Ring Walker
The mirror of Chapter 18.3 §5's fetcher, with one structural difference: it must assemble a chain before it can start, not consume one as it goes.
// -----------------------------------------------------------------------
// txdma_pkg -- the transmit DMA path.
// -----------------------------------------------------------------------
package txdma_pkg;
localparam int ADDR_W = 64;
localparam int BUS_BYTES = 64; // 512-bit
localparam int MAX_BEATS = 16; // 18.5 revisits this choice
localparam int MAX_SG = 8;
localparam int IN_FLIGHT = 32;
localparam int DESC_B = 16;
// A transmit descriptor. Compare 18.2's receive descriptor: the
// length and the last-fragment flag have moved from the MAC's
// column to the driver's, which is section 2's whole subject.
typedef struct packed {
logic [ADDR_W-1:0] buf_ptr; // the driver asserts this
logic [15:0] buf_len; // the driver asserts this
logic first; // the driver asserts this
logic last; // the driver asserts this
logic insert_fcs; // let the MAC append 6.2's FCS
logic insert_vlan; // 13.2's tag
logic [3:0] flags;
logic [7:0] status; // the MAC writes this, after
logic own; // 1 = MAC, 0 = driver
} tx_desc_t;
// Status bits the MAC writes back.
localparam int TS_SENT = 0;
localparam int TS_UNDERRUN = 1; // 7.3 -- the frame was corrupted
localparam int TS_BUS_ERROR = 2;
localparam int TS_TOO_LONG = 3; // the chain exceeded the MTU
localparam int TS_TOO_SHORT = 4; // padded to 60 -- 5.2
localparam int TS_TIMESTAMP = 5; // 16.3 captured one
// A chain, as the walker assembles it.
typedef struct packed {
logic [$clog2(MAX_SG+1)-1:0] n;
logic [15:0] total_bytes;
logic [15:0] first_index;
logic complete;
} chain_t;
endpackage// -----------------------------------------------------------------------
// tx_ring_walker -- finds a complete chain and refuses a partial one.
//
// The asymmetry with 18.3 section 5: a RECEIVE walker takes
// descriptors as it needs them, because the frame's length is not
// known in advance. A TRANSMIT walker must find the LAST descriptor
// before it may start, because starting a frame it cannot finish is
// section 6's underrun by construction.
// -----------------------------------------------------------------------
module tx_ring_walker
import txdma_pkg::*;
#(
parameter int BATCH = 8
)(
input logic clk,
input logic rst_n,
input logic [ADDR_W-1:0] ring_base,
input logic [15:0] ring_len,
input logic doorbell, // section 10
input logic cfg_poll_enable,
input logic [15:0] cfg_poll_interval,
input logic tick,
// Batched descriptor read.
output logic rd_valid,
input logic rd_ready,
output logic [ADDR_W-1:0] rd_addr,
output logic [15:0] rd_bytes,
input logic rd_done,
input logic rd_error,
input tx_desc_t rd_desc [BATCH],
input logic [$clog2(BATCH+1)-1:0] rd_count,
// A COMPLETE chain out. Nothing is emitted until `last` is seen.
output logic chain_valid,
input logic chain_ready,
output chain_t chain,
output tx_desc_t chain_desc [MAX_SG],
output logic [31:0] c_polls,
output logic [31:0] c_doorbells,
output logic [31:0] c_chains,
output logic [31:0] c_partial_stalls,
output logic [31:0] c_chain_too_long,
output logic [15:0] next_index,
output logic waiting_for_last
);
tx_desc_t acc [MAX_SG];
logic [$clog2(MAX_SG+1)-1:0] acc_n;
logic [15:0] acc_bytes;
logic [15:0] acc_first_index;
logic acc_active;
logic [15:0] idx;
logic [15:0] poll_age;
wire [15:0] mask = ring_len - 16'd1;
// A partial chain is a chain whose last descriptor the driver has
// not yet published. The walker HOLDS -- it does not start. This
// counter separates "the driver is slow" from "the driver is wrong".
assign waiting_for_last = acc_active && (acc_n != '0);
// Fetch when a doorbell rang, or when polling is enabled and the
// interval expired. Section 11 is about which of those to use.
wire poll_due = cfg_poll_enable && (poll_age >= cfg_poll_interval);
assign rd_valid = (doorbell || poll_due) && !chain_valid;
assign rd_addr = ring_base +
({{(ADDR_W-16){1'b0}}, (idx & mask)} << $clog2(DESC_B));
assign rd_bytes = 16'(BATCH) * 16'(DESC_B);
assign chain_valid = acc_active && (acc_n != '0) &&
acc[acc_n - 1].last;
assign chain = '{ n: acc_n,
total_bytes: acc_bytes,
first_index: acc_first_index,
complete: 1'b1 };
assign next_index = idx;
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
for (i = 0; i < MAX_SG; i++) acc[i] <= '0;
acc_n <= '0; acc_bytes <= '0; acc_first_index <= '0;
acc_active <= 1'b0; idx <= '0; poll_age <= '0;
c_polls <= '0; c_doorbells <= '0; c_chains <= '0;
c_partial_stalls <= '0; c_chain_too_long <= '0;
end else begin
if (tick) poll_age <= poll_age + 16'd1;
if (doorbell) c_doorbells <= c_doorbells + 1;
if (rd_valid && rd_ready) begin
if (poll_due) begin
c_polls <= c_polls + 1;
poll_age <= '0;
end
end
// Accumulate descriptors the MAC owns, stopping at `last`.
if (rd_done && !rd_error) begin
for (i = 0; i < BATCH; i++) begin
if ((i < int'(rd_count)) && rd_desc[i].own &&
(acc_n < MAX_SG[$bits(acc_n)-1:0]) && !chain_valid) begin
if (acc_n == '0) acc_first_index <= idx;
acc[acc_n] <= rd_desc[i];
acc_n <= acc_n + 1'b1;
acc_bytes <= acc_bytes + rd_desc[i].buf_len;
acc_active <= 1'b1;
idx <= idx + 16'd1;
end
end
if (acc_n >= MAX_SG[$bits(acc_n)-1:0] && !chain_valid)
c_chain_too_long <= c_chain_too_long + 1;
end
if (waiting_for_last && !chain_valid)
c_partial_stalls <= c_partial_stalls + 1;
if (chain_valid && chain_ready) begin
acc_n <= '0;
acc_bytes <= '0;
acc_active <= 1'b0;
c_chains <= c_chains + 1;
end
end
end
endmoduleClassification: a chain accumulator with a completion gate, driven by either a doorbell or a poll timer.
What it teaches: that the transmit walker must see the whole chain before it starts and the receive walker must not. Chapter 18.3 §7's receive walker takes descriptors as it needs them, because the frame's length is unknown while it is arriving. This one cannot: starting a frame whose last fragment the driver has not yet published is Section 6's underrun by construction — the MAC would transmit the fragments it has and then wait, mid-frame, for a descriptor that may be milliseconds away.
And it teaches that c_partial_stalls separates two software behaviours that look identical. A driver that publishes a chain's descriptors in order, last one last, produces brief partial stalls while the batch fetch catches up. A driver that publishes them out of order, or that rings the doorbell before publishing the last one, produces long ones — and both appear as "the transmit path is slow." The counter says which, and the second is the driver bug Chapter 18.2 §12 warned about, seen from the only place hardware can see it.
Deliberately simplified: the accumulation loop writes several acc entries in one cycle, which no synthesisable design does — a real walker consumes one descriptor per cycle from a buffered batch. There is no timeout on a partial chain, so a driver that publishes a first and never a last stalls the transmit path for ever; a production walker abandons the chain after a bounded wait and reports it. And chain_desc is declared and never driven in this listing, for length.
Production implication: c_chain_too_long catches the transmit mirror of Chapter 18.3 §7's overflow, and it has a sharper cause. A chain exceeding MAX_SG on transmit is usually a scatter list the stack built from a page-fragmented user buffer — not a jumbo-frame configuration — so it correlates with application behaviour rather than with an MTU setting. A port that transmits small writes fine and fails on large writev calls has this, and the fix is either a larger MAX_SG or a driver that coalesces fragments before queueing.
4. The Driver's Barrier, Now on the Critical Path
Chapter 18.2 §12 introduced the store-ordering requirement as one of two directions and called the driver-to-MAC direction the worse one. This section is why it is also the more frequent one.
The driver's transmit sequence:
| Step | The driver does |
|---|---|
| 1 | build the frame, or take a buffer from the stack |
| 2 | write buf_ptr |
| 3 | write buf_len, first, last |
| 4 | a store barrier |
| 5 | write own = MAC |
| 6 | a store barrier |
| 7 | ring the doorbell |
Steps 4 and 6 are both required and they are required for different reasons, which is why drivers that get one right frequently get the other wrong.
| Barrier | Orders | Omitting it means |
|---|---|---|
| step 4 | the fields before the ownership store | the MAC reads a descriptor it owns with a stale pointer |
| step 6 | the ownership store before the doorbell | the MAC is told to look and finds a descriptor it does not own |
Step 6's omission is the milder failure and it is the one that hides. The MAC fetches, finds own = DRIVER, and does nothing — so the frame sits until the next doorbell or poll. On a busy link the next doorbell is microseconds away and nothing is noticed; on a quiet link with polling disabled, the frame never goes out at all, and the symptom is a connection that hangs on its last packet.
Step 4's omission is Chapter 18.2 §8's direction-2 failure: the MAC reads a stale buf_ptr and transmits whatever is at the previous buffer's address.
And on transmit that failure has a property the receive direction does not have.
| Receive, direction 2 | Transmit | |
|---|---|---|
| the stale pointer causes | a write into freed memory | a read from freed memory |
| the damage | corrupts an unrelated subsystem | transmits an unrelated subsystem's contents |
| contained? | within the machine | NO — it is on the wire |
Row three is the reason this is the direction to care about. A stale receive pointer corrupts memory; a stale transmit pointer puts up to 1518 octets of whatever now lives at that address onto a cable — which may be another process's data, a page of a file, or a freed key buffer. The frame is well formed and its FCS is valid, so nothing downstream discards it.
This is a disclosure path, not merely a bug, and it is worth naming as such because it changes how seriously the missing barrier is treated. The fix is one instruction in the driver.
And the frequency argument, which is the section's title. On receive, the driver refills descriptors in batches, on its own schedule, usually with the port quiescent between bursts. On transmit, the driver publishes a descriptor on every single packet the application sends, at whatever rate the application sends them — so the ordering requirement is exercised once per transmitted frame rather than once per refill batch. At 1 Gb/s with minimum-size frames that is 1.488 million descriptors per second, so a race with a one-in-a-million window is hit about 1.5 times a second — and at 100 Gb/s, 149 times a second.
5. RTL 2 — The Gather Engine
The mirror of Chapter 18.3 §8's write engine, reading instead of writing, and with one extra job: it must not fall behind the wire.
// -----------------------------------------------------------------------
// tx_gather_engine -- reads a chain's buffers into the transmit FIFO.
//
// The three-way minimum is 18.3 section 7's, unchanged: a buffer
// boundary, a 4 KiB page boundary and the burst limit. What is new is
// the DEADLINE -- the octets must arrive before the wire needs them,
// and section 6 is what happens when they do not.
// -----------------------------------------------------------------------
module tx_gather_engine
import txdma_pkg::*;
(
input logic clk,
input logic rst_n,
input logic chain_valid,
output logic chain_ready,
input chain_t chain,
input tx_desc_t chain_desc [MAX_SG],
// Read requests out.
output logic ar_valid,
input logic ar_ready,
output logic [ADDR_W-1:0] ar_addr,
output logic [7:0] ar_len,
output logic [$clog2(IN_FLIGHT)-1:0] ar_tag,
// Read data in. NOTE: data may return out of order across tags,
// which is why the reorder buffer below exists at all.
input logic r_valid,
input logic [BUS_BYTES*8-1:0] r_data,
input logic r_last,
input logic [$clog2(IN_FLIGHT)-1:0] r_tag,
input logic r_error,
// Into the transmit FIFO, strictly in frame order.
output logic fifo_wr,
output logic [BUS_BYTES*8-1:0] fifo_data,
output logic [$clog2(BUS_BYTES+1)-1:0] fifo_bytes,
output logic fifo_sof,
output logic fifo_eof,
input logic fifo_full,
output logic [15:0] bytes_fetched,
output logic [15:0] bytes_total,
output logic [31:0] c_bursts,
output logic [31:0] c_page_splits,
output logic [31:0] c_buffer_splits,
output logic [31:0] c_read_errors,
output logic [31:0] c_reorder_stalls,
output logic gather_error
);
logic [$clog2(MAX_SG+1)-1:0] sg_i;
logic [ADDR_W-1:0] cur_addr;
logic [15:0] cur_remaining;
logic active;
wire [12:0] to_page = 13'h1000 - {1'b0, cur_addr[11:0]};
wire [15:0] by_burst = 16'(MAX_BEATS * BUS_BYTES);
wire [15:0] by_page = {3'b0, to_page};
logic [15:0] this_burst;
always_comb begin
this_burst = cur_remaining;
if (this_burst > by_burst) this_burst = by_burst;
if (this_burst > by_page) this_burst = by_page;
end
// A read burst may be issued whenever the FIFO has room. Unlike
// 18.3's write engine, this one may run AHEAD -- and it must, by
// section 16's deadline.
assign ar_valid = active && (cur_remaining != '0) && !fifo_full;
assign ar_addr = cur_addr;
assign ar_len = 8'(((this_burst + BUS_BYTES - 1) / BUS_BYTES) - 1);
assign ar_tag = {{($clog2(IN_FLIGHT)-$clog2(MAX_SG)){1'b0}}, sg_i};
assign chain_ready = !active;
assign gather_error = c_read_errors != '0;
// Read data must enter the FIFO in FRAME order, not in response
// order. On a bus that returns different tags out of order, a
// reorder buffer is required -- 18.5 designs the ID policy that
// makes it small.
logic [$clog2(IN_FLIGHT)-1:0] expect_tag;
wire in_order = r_valid && (r_tag == expect_tag);
assign fifo_wr = in_order && !fifo_full;
assign fifo_data = r_data;
assign fifo_bytes = BUS_BYTES[$bits(fifo_bytes)-1:0];
assign fifo_sof = in_order && (bytes_fetched == '0);
assign fifo_eof = in_order && r_last &&
(sg_i == (chain.n - 1'b1));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
active <= 1'b0; sg_i <= '0; cur_addr <= '0; cur_remaining <= '0;
bytes_fetched <= '0; bytes_total <= '0; expect_tag <= '0;
c_bursts <= '0; c_page_splits <= '0; c_buffer_splits <= '0;
c_read_errors <= '0; c_reorder_stalls <= '0;
end else begin
if (chain_valid && chain_ready) begin
active <= 1'b1;
sg_i <= '0;
cur_addr <= chain_desc[0].buf_ptr;
cur_remaining <= chain_desc[0].buf_len;
bytes_total <= chain.total_bytes;
bytes_fetched <= '0;
expect_tag <= '0;
end
if (ar_valid && ar_ready) begin
c_bursts <= c_bursts + 1;
if (this_burst == by_page && this_burst != cur_remaining)
c_page_splits <= c_page_splits + 1;
if (this_burst == cur_remaining)
c_buffer_splits <= c_buffer_splits + 1;
cur_addr <= cur_addr + {{(ADDR_W-16){1'b0}}, this_burst};
cur_remaining <= cur_remaining - this_burst;
if (this_burst == cur_remaining) begin
if (sg_i < (chain.n - 1'b1)) begin
sg_i <= sg_i + 1'b1;
cur_addr <= chain_desc[sg_i + 1].buf_ptr;
cur_remaining <= chain_desc[sg_i + 1].buf_len;
end
end
end
if (r_valid && !in_order) c_reorder_stalls <= c_reorder_stalls + 1;
if (r_valid && r_error) c_read_errors <= c_read_errors + 1;
if (fifo_wr) begin
bytes_fetched <= bytes_fetched + 16'(BUS_BYTES);
if (r_last) expect_tag <= expect_tag + 1'b1;
end
if (fifo_eof) active <= 1'b0;
end
end
endmoduleClassification: a three-way-minimum burst issuer with an in-order delivery constraint on its responses.
What it teaches: that the transmit path has an ordering requirement the receive path does not, and it is on the read side. Chapter 18.3 §8's write engine did not care what order its responses arrived in — it counted them. This engine must deliver octets into the FIFO in frame order, because that is the order they will go onto the wire, so a response that arrives early for a later burst must be held.
And it teaches that c_reorder_stalls is a direct measurement of what the bus's ID policy costs. A design that issues every burst under one ID gets in-order responses by protocol and never stalls — and pays Chapter 18.2 §10's serialisation. A design that issues under many IDs gets concurrency and must reorder. Chapter 18.5 is the chapter that chooses between them, and this counter is its input.
Deliberately simplified: there is no reorder buffer, only a stall — so an out-of-order response is dropped rather than held, which would lose data. A real engine holds up to IN_FLIGHT bursts' worth. chain_desc[sg_i + 1] is read combinationally from an array index computed in the same cycle, which will not meet timing. And fifo_bytes is always the full bus width, so the final partial beat of a buffer is over-reported; the real design carries a byte count per beat as Chapter 18.3 §8's strobes did.
Production implication: c_page_splits and c_buffer_splits mean something different here than on receive, and the difference is who allocated the buffer. On receive the buffers are the driver's pool, aligned or not by the driver's own allocator. On transmit they are frequently the application's — a write() from a user buffer at an arbitrary offset — so the alignment is not the driver's to fix. A transmit path showing high page splits and a receive path showing none is not a bug; it is the application, and the only remedy is a copy, which costs more than the splits do.
6. The Underrun, and Why It Cannot Be Recovered
Chapter 7.3 established that a transmit underrun corrupts a frame already on the wire. This section prices it and shows why the design must be built around preventing it rather than handling it.
The mechanism. The MAC has begun transmitting. Octets are leaving at the line rate and the interframe gap has already been consumed. The FIFO empties. There is no way to pause — Chapter 12.6 §8's rule applies to the transmitter as much as to anyone — so the MAC must emit something for every symbol time.
What it emits decides how bad the failure is:
| The MAC emits | The far end sees | Severity |
|---|---|---|
| a deliberately wrong FCS | a CRC error; the frame is discarded | a lost frame |
| idle or an error symbol | a truncated frame, discarded | a lost frame |
| whatever was in the FIFO | a frame with a VALID FCS over wrong data | corruption delivered |
Row three is the failure mode a design must make impossible, and it is not hypothetical: it is what happens if the FCS is computed over whatever the FIFO produced rather than being deliberately inverted. Chapter 6.2's generator does not know the data was wrong, so it produces a correct FCS for incorrect contents.
Now the window. How long a stall can a frame in flight absorb?
On store-and-forward transmit, the answer is that there is no window at all — the whole frame is in the FIFO before the first octet goes out, so a memory stall during transmission is impossible by construction. That is the entire reason store-and-forward transmit exists.
On cut-through transmit the window is the lead: how many octets are buffered ahead of the wire.
| Line rate | 100 ns stall needs | 300 ns | 800 ns |
|---|---|---|---|
| 1 Gb/s | 12.5 octets | 37.5 | 100 |
| 10 Gb/s | 125 | 375 | 1 000 |
| 25 Gb/s | 312.5 | 937.5 | 2 500 |
| 100 Gb/s | 1 250 | 3 750 | 10 000 |
And the frame is at most 1518 octets, which bounds what can possibly be buffered ahead.
| Stall to survive | Cut-through survives up to |
|---|---|
| 100 ns | 121.44 Gb/s |
| 200 ns | 60.72 Gb/s |
| 300 ns | 40.48 Gb/s |
| 500 ns | 24.29 Gb/s |
| 800 ns | 15.18 Gb/s |
Read the 300 ns row. A system whose worst-case memory read latency is 300 ns — entirely ordinary — cannot do cut-through transmit above 40.48 Gb/s on a maximum-size frame, because the lead required exceeds the frame. Section 9 develops what follows.
And the arithmetic is worse for small frames, which is the part that surprises. A 64-octet frame has 64 octets to buffer ahead at most:
| Stall | 64-octet frame: cut-through survives up to |
|---|---|
| 100 ns | 5.120 Gb/s |
| 300 ns | 1.707 Gb/s |
So at 10 Gb/s, cut-through transmit of minimum-size frames cannot survive a 100 ns stall. Which is fine, and the reason it is fine is worth stating: a 64-octet frame takes 51.2 ns to transmit at 10 Gb/s, so store-and-forward's added latency is 51.2 ns — negligible, and it removes the problem entirely. Cut-through is only worth having where the frame is long and the rate is low, which is exactly where it is also safe.
7. RTL 3 — The Transmit FIFO Writer
The block that decides when transmission may begin, which is the whole of Section 6's argument expressed as one threshold.
// -----------------------------------------------------------------------
// tx_fifo_writer -- fill policy and the start-of-transmission decision.
//
// Two policies: store-and-forward (start when the frame is complete)
// and cut-through (start when `cfg_start_threshold` octets are
// buffered). Section 9 derives the rate at which the second stops
// being available, and this block is where that is enforced rather
// than assumed.
// -----------------------------------------------------------------------
module tx_fifo_writer
import txdma_pkg::*;
#(
parameter int DEPTH_B = 16384
)(
input logic clk,
input logic rst_n,
input logic wr,
input logic [BUS_BYTES*8-1:0] wr_data,
input logic [$clog2(BUS_BYTES+1)-1:0] wr_bytes,
input logic wr_sof,
input logic wr_eof,
input logic cfg_cut_through,
input logic [15:0] cfg_start_threshold,
input logic [15:0] cfg_line_rate_mbps,
input logic [15:0] cfg_worst_stall_ns,
output logic full,
output logic [15:0] occupancy_b,
output logic may_start,
output logic frame_complete,
// The refusal. If the configured threshold cannot cover the
// configured stall at the configured rate, cut-through is unsafe
// and the block falls back rather than transmitting a frame it may
// not be able to finish.
output logic cut_through_unsafe,
output logic [15:0] required_lead_b,
output logic [31:0] c_frames_started,
output logic [31:0] c_cut_through_used,
output logic [31:0] c_fallbacks,
output logic [15:0] c_peak_occupancy
);
logic [15:0] occ;
logic have_eof;
// Required lead = stall x rate / 8, in octets.
// Mbps x ns / 8000 = octets. 100000 Mbps x 300 ns / 8000 = 3750.
wire [31:0] lead32 = (32'(cfg_line_rate_mbps) * 32'(cfg_worst_stall_ns))
/ 32'd8000;
assign required_lead_b = lead32[15:0];
// Section 6's table, as a runtime check. A threshold below the
// required lead is a design that will underrun -- not often, and
// not reproducibly, which is the worst frequency for a fault that
// corrupts a frame on the wire.
assign cut_through_unsafe = cfg_cut_through &&
(cfg_start_threshold < required_lead_b);
// Start when the frame is complete, or -- only if cut-through is
// both enabled AND safe -- when the threshold is reached.
assign may_start = have_eof ||
(cfg_cut_through && !cut_through_unsafe &&
(occ >= cfg_start_threshold));
assign frame_complete = have_eof;
assign occupancy_b = occ;
assign full = (occ >= (DEPTH_B[15:0] - 16'(BUS_BYTES)));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
occ <= '0; have_eof <= 1'b0;
c_frames_started <= '0; c_cut_through_used <= '0;
c_fallbacks <= '0; c_peak_occupancy <= '0;
end else begin
if (wr && !full) begin
occ <= occ + {{(16-$bits(wr_bytes)){1'b0}}, wr_bytes};
if (wr_sof) have_eof <= 1'b0;
if (wr_eof) have_eof <= 1'b1;
end
if (may_start && !$past(may_start)) begin
c_frames_started <= c_frames_started + 1;
if (!have_eof) c_cut_through_used <= c_cut_through_used + 1;
end
if (cfg_cut_through && cut_through_unsafe)
c_fallbacks <= c_fallbacks + 1;
if (occ > c_peak_occupancy) c_peak_occupancy <= occ;
end
end
endmoduleClassification: a fill-level gate with a runtime safety check on its own configuration.
What it teaches: that the cut-through threshold is not a tuning knob, it is a quantity derived from two numbers the MAC does not own — the line rate and the system's worst-case memory read latency. required_lead_b computes Section 6's table in hardware, and cut_through_unsafe refuses the mode when the configured threshold cannot cover it. A design that accepts any threshold silently transmits frames it may not be able to finish.
And it teaches why the refusal falls back rather than erroring. Cut-through is a latency optimisation. Falling back to store-and-forward costs latency and loses nothing; erroring costs the link. This is Chapter 17.3 §13's fail-closed argument in a different mechanism — when a capability's failure corrupts data and its absence only costs performance, the default must be off and the evidence must be positive.
Deliberately simplified: the FIFO is modelled as an occupancy counter with no storage, because the storage is Chapter 18.1 §9's asynchronous FIFO — the transmit side crosses into the transmit clock domain and this block sits on the host side of it. $past in an always_ff is testbench style, not synthesisable as written. And the division computing lead32 is a real divide, which a production design replaces with a lookup indexed by line rate, since there are only a handful of rates.
Production implication: c_cut_through_used against c_frames_started is the fraction of frames that actually started early, and on most ports it is far lower than expected. A frame whose gather completes before the threshold is reached starts as store-and-forward regardless of the mode — which at 100 Gb/s is almost every frame, because Chapter 18.3's bursts deliver 1024 octets at a time and a 1518-octet frame is two of them. The counter is how a team discovers that a latency optimisation they believe is running has never once engaged.
8. RTL 4 — The Underrun Guard
The block that exists so Section 6's third row cannot happen.
// -----------------------------------------------------------------------
// underrun_guard -- detects an empty FIFO during transmission and
// guarantees the frame is DISCARDED by the far end.
//
// The guarantee is the point. An underrun always loses the frame; the
// only question is whether the far end knows. Corrupting the FCS
// deliberately is what converts an undetectable corruption into an
// ordinary CRC error -- 6.1's mechanism, used on purpose.
// -----------------------------------------------------------------------
module underrun_guard
import txdma_pkg::*;
(
input logic clk_tx, // the TRANSMIT clock domain
input logic rst_tx_n,
input logic transmitting,
input logic fifo_empty,
input logic frame_last_octet,
// To 6.2's FCS generator.
output logic force_bad_fcs,
output logic abort_now,
// To the completion reporter, via a handshake -- 18.1 section 14.
output logic underrun_event,
output logic [31:0] c_underruns,
output logic [31:0] c_underrun_octets,
output logic [15:0] worst_shortfall_b,
output logic guard_disabled_unsafely
);
logic in_underrun;
logic [15:0] shortfall;
// An empty FIFO while transmitting and not at the frame's end is an
// underrun. There is no other condition, and there is no recovery:
// the octets already sent cannot be recalled.
wire underrun_now = transmitting && fifo_empty && !frame_last_octet;
// Once entered, the guard stays asserted for the rest of the frame.
// A FIFO that refills mid-underrun does NOT rescue the frame -- the
// gap has already been transmitted as whatever the PHY emitted.
assign force_bad_fcs = in_underrun;
// Aborting immediately is an alternative to running to the end with
// a bad FCS. Both lose the frame; aborting frees the wire sooner.
assign abort_now = in_underrun;
assign underrun_event = underrun_now && !in_underrun;
// A design that can disable the guard can transmit a frame whose
// FCS is valid over wrong data -- section 6's row three. There is
// no legitimate reason to allow it, so it is flagged.
assign guard_disabled_unsafely = 1'b0; // tied off: the guard is not optional
always_ff @(posedge clk_tx or negedge rst_tx_n) begin
if (!rst_tx_n) begin
in_underrun <= 1'b0; shortfall <= '0;
c_underruns <= '0; c_underrun_octets <= '0; worst_shortfall_b <= '0;
end else begin
if (underrun_now && !in_underrun) begin
in_underrun <= 1'b1;
shortfall <= '0;
c_underruns <= c_underruns + 1;
end
if (in_underrun) begin
shortfall <= shortfall + 16'd1;
c_underrun_octets <= c_underrun_octets + 1;
if ((shortfall + 16'd1) > worst_shortfall_b)
worst_shortfall_b <= shortfall + 16'd1;
end
if (frame_last_octet) in_underrun <= 1'b0;
end
end
endmoduleClassification: a sticky fault detector whose output is a deliberate protocol violation.
What it teaches: that the correct response to an unrecoverable corruption is to make it detectable, and Chapter 6.1's mechanism is the tool. The frame is lost either way. Corrupting the FCS on purpose converts "a frame with wrong contents and a valid checksum" into "a frame with a CRC error" — which every Ethernet device in the world already handles, counts and discards. The guard does not fix anything; it moves the failure into a category the rest of the network understands.
And it teaches that the guard must be sticky for the remainder of the frame. A FIFO that refills two beats after the gap does not rescue the frame: the octets transmitted during the gap were whatever the PHY emitted, and they are already on the cable. A guard that deasserts when the FIFO refills produces a frame with a valid FCS over data containing a hole — Section 6's row three, created by the block that exists to prevent it.
Deliberately simplified: the block lives entirely in the transmit clock domain and c_underruns must therefore cross to the host domain through Chapter 18.1 §14's handshake, which is noted and not shown. shortfall counts clock cycles rather than octets — they are equal only at a one-octet-per-cycle interface such as GMII, and at 100 Gb/s a cycle is 64 octets. And guard_disabled_unsafely is tied to zero, which is the listing making a point rather than implementing one: the signal exists so that a future revision adding a disable has somewhere obvious to fail.
Production implication: c_underruns should be wired to an interrupt that is never coalesced — Chapter 18.3 §14's evt_coalescable mask exists for exactly this. An underrun means the memory system failed to meet a hard deadline, which is the same class of event as Chapter 18.1 §9's receive overflow and has the same cause. A port reporting underruns and receive overflows together is a port whose memory system is the problem in both directions, and the pair is much stronger evidence than either alone.
9. Cut-Through Transmit, and the Rate at Which It Stops Existing
Section 6 produced a table. This section reads it as a design decision, because the conclusion is stronger than "cut-through is harder at high rates."
The claim: above a rate determined by the memory system, cut-through transmit does not exist as an option.
The arithmetic is one line. Cut-through starts transmitting with L octets buffered. To survive a stall of S, L must be at least S × r / 8. And L cannot exceed the frame, because there is nothing else to buffer. So cut-through requires S × r / 8 < F, which rearranges to r < 8F / S.
| Worst-case stall | Maximum rate, 1518-octet frame | Maximum rate, 64-octet frame |
|---|---|---|
| 100 ns | 121.44 Gb/s | 5.120 Gb/s |
| 200 ns | 60.72 Gb/s | 2.560 Gb/s |
| 300 ns | 40.48 Gb/s | 1.707 Gb/s |
| 500 ns | 24.29 Gb/s | 1.024 Gb/s |
| 800 ns | 15.18 Gb/s | 0.640 Gb/s |
And the right-hand column is the one that decides it, because a port must handle minimum-size frames. At 1 Gb/s with a 500 ns stall, cut-through is already marginal for a 64-octet frame.
Which produces a clean rule and it is not the rule people expect.
Cut-through transmit is available where the frame is long relative to the rate — that is, where store-and-forward's latency cost is largest. And it is unavailable where store-and-forward's cost is smallest.
The two clauses are the same fact and their coincidence is fortunate rather than designed.
| Line rate | S&F latency cost, 1518-octet frame | Cut-through available? |
|---|---|---|
| 1 Gb/s | 12 144 ns — large | yes |
| 10 Gb/s | 1 214 ns | yes, to ~40 Gb/s |
| 25 Gb/s | 486 ns | marginal |
| 100 Gb/s | 121 ns — small | no |
At 100 Gb/s store-and-forward adds 121 ns to a maximum-size frame and 5.12 ns to a minimum-size one, which against Chapter 17.1's per-hop budget is nothing. The optimisation that is unavailable is also the one that was not worth much.
Three consequences for a design.
First, the mode is a function of the deployment, not of the MAC. The same silicon at 1 Gb/s wants cut-through and at 100 Gb/s must not have it — so Section 7's cut_through_unsafe is a runtime check rather than a synthesis parameter.
Second, the store-and-forward transmit FIFO must hold a whole maximum frame, which at a 9000-octet MTU is 9 KiB per port and is not negotiable: the frame cannot start until all of it is present. This is a different sizing argument from Chapter 18.1 §8's receive FIFO, which was sized against a latency; this one is sized against the MTU.
Third — and this is the one that catches designs out — store-and-forward transmit does not remove the deadline, it moves it. A frame cannot start until it is complete, so the next frame must be fetched while the current one is transmitting or the wire goes idle between frames. Section 16 derives that deadline, and at 100 Gb/s it is 5.12 ns for a minimum-size frame, which no memory system meets — so the fetch must be pipelined several frames deep.
10. RTL 5 — The Doorbell
Software's way of telling the MAC there is work, and the mirror of Chapter 18.1 §11's interrupt.
// -----------------------------------------------------------------------
// doorbell_unit -- the transmit-side notification, and its fallback.
//
// A doorbell is a register write from the CPU. It costs the CPU an
// MMIO write and costs the MAC nothing. Polling costs the MAC a
// descriptor read per interval and costs the CPU nothing. Section 11
// derives which is cheaper where, and this block supports both
// because the answer depends on the traffic.
// -----------------------------------------------------------------------
module doorbell_unit
import txdma_pkg::*;
(
input logic clk,
input logic rst_n,
// The register write. `db_count` is the number of descriptors the
// driver has published -- a COUNT, not a flag, so that a doorbell
// arriving during a fetch is not lost.
input logic db_write,
input logic [15:0] db_count,
input logic cfg_poll_enable,
input logic [15:0] cfg_poll_interval,
input logic tick,
// Work acknowledged by the walker.
input logic chain_taken,
output logic work_available,
output logic poll_due,
output logic [15:0] outstanding_work,
output logic [31:0] c_doorbells,
output logic [31:0] c_polls,
output logic [31:0] c_empty_polls,
output logic [31:0] c_doorbells_coalesced,
output logic doorbell_lost // must never assert
);
logic [15:0] pending;
logic [15:0] poll_age;
logic [15:0] last_db_count;
// The doorbell is CUMULATIVE. A driver that publishes 8 descriptors
// and writes 8 has told the MAC everything even if the MAC was
// mid-fetch when the write landed. A doorbell that is a pulse can
// be missed; one that is a count cannot.
wire [15:0] delta = db_count - last_db_count;
assign work_available = (pending != 16'd0);
assign outstanding_work = pending;
assign poll_due = cfg_poll_enable &&
(poll_age >= cfg_poll_interval);
// A doorbell whose delta is zero means the driver rang without
// publishing, or the count wrapped. Either is a driver fault.
assign doorbell_lost = db_write && (delta == 16'd0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
pending <= '0; poll_age <= '0; last_db_count <= '0;
c_doorbells <= '0; c_polls <= '0; c_empty_polls <= '0;
c_doorbells_coalesced <= '0;
end else begin
if (tick) poll_age <= poll_age + 16'd1;
if (db_write) begin
c_doorbells <= c_doorbells + 1;
last_db_count <= db_count;
pending <= pending + delta;
// A doorbell that added more than one descriptor is the
// driver batching -- section 11's lever.
if (delta > 16'd1)
c_doorbells_coalesced <= c_doorbells_coalesced + 1;
end
if (poll_due) begin
poll_age <= '0;
c_polls <= c_polls + 1;
if (pending == 16'd0) c_empty_polls <= c_empty_polls + 1;
end
if (chain_taken && (pending != 16'd0))
pending <= pending - 16'd1;
end
end
endmoduleClassification: a cumulative-count notification register with an independent polling timer.
What it teaches: that a doorbell must be a count and not a pulse, for exactly the reason Chapter 18.1 §14's request crossing had to be a toggle. A pulse arriving while the MAC is mid-fetch is lost, and the frame sits until the next doorbell — which on a connection sending one final packet is never. A cumulative count is idempotent: the MAC can read it at any time and compute what it has not yet seen, and a doorbell arriving during any operation whatsoever is safe.
And it teaches that c_empty_polls is the direct cost of polling. Every poll that finds nothing is a descriptor read that moved no frame — pure overhead on the memory system, charged to Chapter 18.1 §12's transaction budget. On a link at 1% utilisation, 99% of polls are empty, which is Section 11's whole argument.
Deliberately simplified: delta is computed by subtraction with no wrap handling, so a 16-bit count wrapping while the MAC is descheduled loses descriptors; a real design uses a wider counter or an explicit wrap protocol. There is one doorbell for the whole port, where Chapter 18.7's multi-queue needs one per queue — and that is not a small change, because the register's address becomes part of the queue's identity. poll_age also continues counting while a fetch is in flight, so a slow fetch produces a poll immediately after it completes.
Production implication: c_doorbells_coalesced against c_doorbells says how well the driver is batching, and it is the single most effective transmit-path tuning number. A driver that rings once per packet produces a ratio near zero and 1.488 million MMIO writes per second at 1 Gb/s; one that rings once per batch of 32 produces a ratio near one and 46 500 per second. The hardware is identical; the difference is a if (last_packet_in_batch) in the driver, and this counter is how anyone finds out it is missing.
11. Doorbell Against Polling, Derived
Both mechanisms tell the MAC there is work. They charge different parties, and the right choice depends on a number neither party owns: the link's utilisation.
What each costs.
| Doorbell | Polling | |
|---|---|---|
| charged to | the CPU | the memory system |
| the cost | one MMIO write per ring | one descriptor read per interval |
| scales with | the frame rate ÷ the batch size | 1 ÷ the poll interval |
| when idle | nothing | the full poll rate |
| latency | immediate | up to one interval |
Row four is the asymmetry that decides most cases. A doorbell costs nothing when there is nothing to send. Polling costs the same whether the link is saturated or dark.
Polling's cost, in Chapter 18.1 §12's units:
| Poll interval | Descriptor reads/s | Transactions per cycle at 250 MHz |
|---|---|---|
| 100 ns | 10.000 M | 0.0400 |
| 500 ns | 2.000 M | 0.0080 |
| 1 µs | 1.000 M | 0.0040 |
| 10 µs | 0.100 M | 0.0004 |
| 100 µs | 0.010 M | 0.00004 |
And the doorbell's cost, in MMIO writes per second:
| Line rate | Ring per frame | Ring per 8 | Ring per 32 |
|---|---|---|---|
| 1 Gb/s | 1.488 M/s | 0.186 M/s | 0.047 M/s |
| 10 Gb/s | 14.881 M/s | 1.860 M/s | 0.465 M/s |
| 25 Gb/s | 37.202 M/s | 4.650 M/s | 1.163 M/s |
| 100 Gb/s | 148.810 M/s | 18.601 M/s | 4.650 M/s |
The top-right cell of that table is the answer for almost every real design: ring once per batch of 32, and the cost is 4.65 million MMIO writes per second even at 100 Gb/s — which is a few per cent of a core, against 148.8 million if the driver rings per packet.
Now the comparison that matters, and it is not "which is cheaper" but "which is cheaper at this utilisation".
| Link utilisation | Doorbell cost | Polling at 1 µs | Cheaper |
|---|---|---|---|
| 100%, 64-octet frames, 1 Gb/s | 0.047 M MMIO/s (batch 32) | 1.000 M reads/s | doorbell |
| 10% | 0.005 M/s | 1.000 M/s | doorbell |
| 0% — idle | 0 | 1.000 M/s | doorbell, overwhelmingly |
| 100%, 100 Gb/s, per packet | 148.8 M MMIO/s | 1.000 M/s | polling |
The last row is the only one where polling wins, and it wins because the doorbell has been used badly. A driver ringing per packet at 100 Gb/s is the pathological case, and batching fixes it more cheaply than switching mechanism does.
Which yields the rule most designs land on and it is a hybrid:
Use a doorbell as the primary mechanism, batched by the driver. Add a slow poll — tens of microseconds — as a backstop, so that a lost or mis-ordered doorbell costs latency rather than a hung transmit path.
The backstop's cost at a 50 µs interval is 20 000 reads per second — 0.00008 transactions per cycle — which is free, and it converts Chapter 18.2 §12's missing step-6 barrier from "the connection hangs on its last packet" into "the last packet is 50 µs late."
And that conversion is the strongest argument for the hybrid. The missing barrier is a driver bug hardware cannot detect; a slow poll does not detect it either, but it removes its worst symptom — and a 50 µs latency spike on the final packet of a transfer is survivable in a way that a hang is not.
12. RTL 6 — The Completion Reporter
The mirror of Chapter 18.3 §12's writeback, and the place where "the frame has been sent" has to be defined precisely.
// -----------------------------------------------------------------------
// tx_completion_reporter -- writes status and ownership back, in
// chain order, and only when the frame is genuinely gone.
//
// "Gone" is the question. A frame is not complete when its last
// descriptor was read, nor when its last octet entered the FIFO --
// it is complete when its last octet has left the PHY interface and
// its underrun status is known.
// -----------------------------------------------------------------------
module tx_completion_reporter
import txdma_pkg::*;
#(
parameter int MAX_BATCH = 8
)(
input logic clk,
input logic rst_n,
// From the transmit domain, already synchronised -- 18.1 sec 14.
input logic frame_done,
input logic frame_underrun,
input logic [15:0] frame_first_index,
input logic [$clog2(MAX_SG+1)-1:0] frame_desc_count,
input logic frame_timestamped, // 16.3
input logic [ADDR_W-1:0] ring_base,
input logic [15:0] ring_len,
input logic cfg_batch_enable,
input logic [15:0] cfg_batch_timeout,
input logic tick,
output logic wr_valid,
input logic wr_ready,
output logic [ADDR_W-1:0] wr_addr,
output logic [15:0] wr_bytes,
output logic [7:0] wr_status,
output logic [31:0] c_completions,
output logic [31:0] c_underrun_completions,
output logic [31:0] c_batched_entries,
output logic [31:0] c_timeout_flushes,
output logic [15:0] mean_batch_x100,
output logic completed_before_done // must never assert
);
logic [15:0] batch_first, batch_next;
logic [$clog2(MAX_BATCH+1)-1:0] batch_n;
logic [15:0] age;
logic have;
logic [7:0] status_acc;
wire [15:0] mask = ring_len - 16'd1;
// A chain's descriptors are contiguous by construction -- section 3
// accumulated them in index order -- so a chain always batches. The
// contiguity break 18.3 section 12 had to handle cannot occur here
// WITHIN a chain, only BETWEEN chains.
wire is_next = have && (frame_first_index == batch_next);
wire no_wrap = ((frame_first_index & mask) != 16'd0);
wire can_join = cfg_batch_enable && is_next && no_wrap &&
((batch_n + frame_desc_count) <= MAX_BATCH[$bits(batch_n)-1:0]);
wire timed_out = have && (age >= cfg_batch_timeout) &&
(cfg_batch_timeout != 16'd0);
assign wr_valid = have && (timed_out || (frame_done && !can_join) ||
(batch_n == MAX_BATCH[$bits(batch_n)-1:0]));
assign wr_addr = ring_base +
({{(ADDR_W-16){1'b0}}, (batch_first & mask)} << $clog2(DESC_B));
assign wr_bytes = 16'(batch_n) * 16'(DESC_B);
assign wr_status = status_acc;
// The invariant: nothing is reported that the transmit domain has
// not declared done. `frame_done` is the ONLY permitted trigger.
assign completed_before_done = wr_valid && !have;
always_comb begin
if (c_completions == '0) mean_batch_x100 = 16'd0;
else mean_batch_x100 = 16'((c_batched_entries * 32'd100) / c_completions);
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
have <= 1'b0; batch_first <= '0; batch_next <= '0;
batch_n <= '0; age <= '0; status_acc <= '0;
c_completions <= '0; c_underrun_completions <= '0;
c_batched_entries <= '0; c_timeout_flushes <= '0;
end else begin
if (tick && have) age <= age + 16'd1;
if (wr_valid && wr_ready) begin
c_completions <= c_completions + 1;
c_batched_entries <= c_batched_entries + {29'b0, batch_n};
if (timed_out) c_timeout_flushes <= c_timeout_flushes + 1;
have <= 1'b0; batch_n <= '0; age <= '0; status_acc <= '0;
end
if (frame_done) begin
if (frame_underrun)
c_underrun_completions <= c_underrun_completions + 1;
if (can_join) begin
batch_next <= frame_first_index + 16'(frame_desc_count);
batch_n <= batch_n + frame_desc_count;
end else begin
batch_first <= frame_first_index;
batch_next <= frame_first_index + 16'(frame_desc_count);
batch_n <= frame_desc_count;
have <= 1'b1;
age <= '0;
end
status_acc <= status_acc |
(8'(1) << TS_SENT) |
(frame_underrun ? (8'(1) << TS_UNDERRUN) : 8'd0) |
(frame_timestamped ? (8'(1) << TS_TIMESTAMP) : 8'd0);
end
end
end
endmoduleClassification: an in-order completion batcher gated on a transmit-domain done event.
What it teaches: that a chain always batches and the batching problem Chapter 18.3 §12 had is therefore half as bad here. A receive path completes frames in whatever order their data lands; a transmit path completes them in the order it sent them, because the wire is serial. So c_broken_by_order has no transmit equivalent — the only contiguity break is the ring's wrap, and a five-descriptor chain is five contiguous entries by construction from Section 3.
And it teaches that status_acc being OR-accumulated across a batch is a deliberate loss of precision. A batch of eight frames, one of which underran, reports the underrun bit on all eight in this listing. A real design carries per-descriptor status, which is why a real writeback is one descriptor-sized write per entry rather than one status byte — and it is the reason the transmit writeback batches by address range but not by content.
Deliberately simplified: the per-descriptor status just noted; frame_done and its companions are assumed already synchronised from the transmit clock domain, which needs Chapter 18.1 §14's handshake and is not shown; mean_batch_x100 is a combinational divide; and there is no handling of a completion arriving while a writeback is in flight, which a real design queues.
Production implication: c_underrun_completions against c_completions is the transmit path's most important ratio and it should be zero. Any non-zero value means frames have been corrupted on the wire — Section 8 made them detectable, but they are still lost — and the cause is always the memory system missing a deadline, never the MAC. A port with a non-zero value and cut-through enabled should have cut-through disabled first, which Section 7's cut_through_unsafe would have done automatically if the configured stall figure had been honest.
13. Completion, and When a Buffer May Be Freed
The completion's meaning is a contract with software, and getting it slightly wrong produces a bug that is indistinguishable from a memory corruption anywhere else in the system.
The question: when may the driver free the buffer a transmit descriptor pointed at?
The answer must be: not until the MAC has finished reading it. Which is later than most of the plausible candidates:
| Candidate event | Safe to free? | Why not |
|---|---|---|
| the descriptor was fetched | no | the data has not been read |
| the read burst was issued | no | Chapter 18.3 §19's class 76 — issuance is not completion |
| the read response returned | not quite | the frame may still underrun and be retried |
| the last octet entered the FIFO | yes, for the data | the MAC no longer needs the buffer |
| the last octet left the PHY | yes | and this is what the status reports |
Rows four and five are both safe and they are not the same instant, which matters at high rates: at 100 Gb/s a 1518-octet frame sits in the FIFO for 121.44 ns after its last octet arrives there. Reporting at row four frees the buffer 121 ns sooner and is correct for the data; reporting at row five is required for the status, because the underrun is not known until the frame has been transmitted.
Most designs report once, at row five, and accept the 121 ns. A design that reports twice — a data-complete and a transmission-complete — doubles the writeback transactions, which Chapter 18.1 §12's budget does not have room for.
And row three deserves its own note because it introduces something the receive path has no equivalent of: a retry.
A frame that underruns is lost. Some MACs will retransmit it — the data is still in memory, the descriptor is still owned by the MAC, and nothing prevents reading it again. That is a real feature and it has a sharp precondition: the buffer must not have been freed, which is why the completion cannot be reported before the transmission is known to have succeeded.
| Report at FIFO-complete | Report at PHY-complete | |
|---|---|---|
| buffer freed | 121 ns earlier | — |
| retry possible | NO — the buffer may be gone | yes |
| status accurate | no — underrun unknown | yes |
So the retry feature and the early completion are incompatible, and a design must choose. Almost all choose accurate status, which is the right call: an underrun is a symptom of a memory system that will underrun again, and retransmitting into the same condition mostly produces a second underrun.
One more contract detail, and it is the one drivers get wrong. The completion reports on a chain, and the chain's descriptors are released as a group. A driver that frees a buffer when its descriptor's ownership returns, rather than when the chain's does, frees the first fragment of a frame whose later fragments are still being read — which is the transmit mirror of Chapter 18.3 §11's backwards publication, and it has the same fix: the chain is the unit, not the descriptor.
14. RTL 7 — Transmit Telemetry
Seven counters, and each one separates a pair of causes that produce the same complaint.
// -----------------------------------------------------------------------
// txdma_telemetry -- the transmit path's observable state.
// -----------------------------------------------------------------------
module txdma_telemetry
import txdma_pkg::*;
(
input logic clk,
input logic rst_n,
input logic chain_started,
input logic chain_completed,
input logic waiting_for_last, // section 3
input logic fifo_starved, // gather behind the wire
input logic underrun, // section 8
input logic doorbell,
input logic poll,
input logic empty_poll,
input logic cut_through_start,
input logic [15:0] fifo_occupancy_b,
input logic [15:0] chain_bytes,
output logic [31:0] c_chains,
output logic [31:0] c_completions,
output logic [31:0] c_partial_wait_cycles,
output logic [31:0] c_starve_cycles,
output logic [31:0] c_underruns,
output logic [31:0] c_doorbells,
output logic [31:0] c_polls,
output logic [31:0] c_empty_polls,
output logic [31:0] c_cut_through,
output logic [15:0] c_min_occupancy, // the underrun margin
output logic [31:0] c_octets,
output logic [15:0] in_flight_chains
);
logic [15:0] open_chains;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
c_chains <= '0; c_completions <= '0; c_partial_wait_cycles <= '0;
c_starve_cycles <= '0; c_underruns <= '0; c_doorbells <= '0;
c_polls <= '0; c_empty_polls <= '0; c_cut_through <= '0;
c_min_occupancy <= 16'hFFFF; c_octets <= '0; open_chains <= '0;
end else begin
if (chain_started) begin
c_chains <= c_chains + 1;
open_chains <= open_chains + 16'd1;
c_octets <= c_octets + {16'b0, chain_bytes};
end
if (chain_completed) begin
c_completions <= c_completions + 1;
if (open_chains != 16'd0) open_chains <= open_chains - 16'd1;
end
if (waiting_for_last) c_partial_wait_cycles <= c_partial_wait_cycles + 1;
if (fifo_starved) c_starve_cycles <= c_starve_cycles + 1;
if (underrun) c_underruns <= c_underruns + 1;
if (doorbell) c_doorbells <= c_doorbells + 1;
if (poll) c_polls <= c_polls + 1;
if (empty_poll) c_empty_polls <= c_empty_polls + 1;
if (cut_through_start) c_cut_through <= c_cut_through + 1;
// The MINIMUM occupancy during transmission is the underrun
// margin, measured. A port that has never underrun but whose
// minimum reached 64 octets is one stall away.
if (fifo_occupancy_b < c_min_occupancy)
c_min_occupancy <= fifo_occupancy_b;
end
end
assign in_flight_chains = open_chains;
endmoduleClassification: a counter set built around minima and cycle-accumulations rather than event counts.
What it teaches: that c_min_occupancy is the only counter in this chapter that predicts a failure instead of reporting one. Every other counter here rises after something went wrong. This one records how close the transmit FIFO ever came to empty while transmitting — so a port with zero underruns and a minimum of 64 octets has been one memory stall away all along, and nobody would know. It is the transmit mirror of Chapter 18.1 §13's c_rx_high_water, and it is the same argument: a margin that is never measured is a margin nobody can defend.
And it teaches that c_partial_wait_cycles and c_starve_cycles are different starvations. The first is waiting for the driver to publish a chain's last descriptor — a software problem. The second is the gather engine falling behind the wire — a memory problem. Both look like "transmit is slow" and they are fixed by different teams, which is the selection rule every telemetry block in Module 18 has used.
Deliberately simplified: c_min_occupancy is sampled every cycle regardless of whether the port is transmitting, so an idle port drives it to zero immediately — a real design qualifies it with transmitting. The counters are 32-bit and wrap. And c_octets accumulates the chain's claimed length rather than the octets actually transmitted, which differ precisely when there was an underrun — the one case where the difference matters.
Production implication: the ratio c_empty_polls / c_polls is the number that decides whether polling should be on at all. Near one means the poll is finding nothing almost every time — Section 11's idle case — and the interval should be lengthened or the poll disabled in favour of the doorbell. Near zero means the link is busy enough that the poll is doing useful work, which on a well-driven port means the doorbell is not arriving and there is a driver bug to find.
15. RTL 8 — The Transmit Conformance Monitor
The last block, and its verdicts divide by who fixes them.
// -----------------------------------------------------------------------
// txdma_conformance_monitor -- what the transmit counters mean.
// -----------------------------------------------------------------------
module txdma_conformance_monitor
import txdma_pkg::*;
(
input logic clk,
input logic rst_n,
input logic [31:0] c_chains,
input logic [31:0] c_completions,
input logic [31:0] c_underruns,
input logic [31:0] c_partial_wait_cycles,
input logic [31:0] c_starve_cycles,
input logic [31:0] c_doorbells,
input logic [31:0] c_doorbells_coalesced,
input logic [31:0] c_polls,
input logic [31:0] c_empty_polls,
input logic [31:0] c_page_splits,
input logic [31:0] c_bursts,
input logic [31:0] c_chain_too_long,
input logic [15:0] c_min_occupancy,
input logic [15:0] required_lead_b,
input logic cut_through_unsafe,
input logic doorbell_lost,
input logic completed_before_done,
output logic tx_path_ok,
output logic memory_missed_deadline,
output logic underrun_margin_thin,
output logic driver_not_batching,
output logic driver_publishing_late,
output logic polling_wasteful,
output logic chain_limit_reached,
output logic cfg_fault,
output logic none_of_the_above
);
// Fatal and non-negotiable: a frame was corrupted on the wire.
assign memory_missed_deadline = (c_underruns != '0);
// Predictive: the margin was thin even though nothing failed.
assign underrun_margin_thin = (c_chains > 32'd1000) &&
(c_min_occupancy < required_lead_b);
// The driver rings per packet rather than per batch -- section 10.
assign driver_not_batching = (c_doorbells > 32'd10000) &&
(c_doorbells_coalesced < (c_doorbells >> 3));
// The driver publishes a chain's last descriptor late -- section 3.
assign driver_publishing_late = (c_chains > 32'd1000) &&
(c_partial_wait_cycles >
(c_chains << 6)); // > 64 cycles/chain
// Polling finds nothing almost every time -- section 11.
assign polling_wasteful = (c_polls > 32'd10000) &&
(c_empty_polls > ((c_polls >> 1) + (c_polls >> 2)));
assign chain_limit_reached = (c_chain_too_long != '0);
assign cfg_fault = cut_through_unsafe | doorbell_lost |
completed_before_done;
assign tx_path_ok = !memory_missed_deadline && !cfg_fault;
assign none_of_the_above = tx_path_ok && !underrun_margin_thin &&
!driver_not_batching && !driver_publishing_late &&
!polling_wasteful && !chain_limit_reached;
// ---- properties -------------------------------------------------
p_underrun_is_fatal:
assert property (@(posedge clk) disable iff (!rst_n)
(c_underruns != '0) |-> !tx_path_ok)
else $error("tx_path_ok asserted with underruns recorded");
p_completions_le_chains:
assert property (@(posedge clk) disable iff (!rst_n)
c_completions <= c_chains)
else $error("more chains completed than were started");
p_never_complete_before_done:
assert property (@(posedge clk) disable iff (!rst_n)
!completed_before_done)
else $error("a completion was reported before the transmit domain declared done");
p_cut_through_refused_when_unsafe:
assert property (@(posedge clk) disable iff (!rst_n)
cut_through_unsafe |-> cfg_fault)
else $error("an unsafe cut-through configuration was not flagged");
p_verdicts_exclusive_with_clear:
assert property (@(posedge clk) disable iff (!rst_n)
none_of_the_above |-> (!memory_missed_deadline && !cfg_fault &&
!driver_not_batching && !polling_wasteful))
else $error("none_of_the_above asserted alongside a finding");
endmoduleClassification: a verdict generator with one predictive output among six reactive ones.
What it teaches: that underrun_margin_thin is worth more than memory_missed_deadline even though the second is the actual failure. By the time underruns are counted, frames have been corrupted on a cable. The margin verdict fires while everything still works — c_min_occupancy below the lead Section 7 computed — and it fires on a port that has never failed and is about to.
And it teaches that driver_not_batching and driver_publishing_late are both "the driver" and need different fixes. The first is a batching change — ring once per group instead of once per packet — and costs nothing. The second is a publication-order change, and it is Chapter 18.2 §12's barrier in its step-6 form: the doorbell arriving before the last descriptor is visible. One is a performance tuning; the other is a correctness bug with a performance symptom.
Deliberately simplified: the thresholds are literals — 64 cycles per chain, an eighth, three quarters — where production takes them from registers. The counters are absolute rather than windowed, so a long-running port drifts into several verdicts. And underrun_margin_thin compares against required_lead_b, which is only meaningful when cut-through is enabled; on a store-and-forward port the margin is the whole frame and the comparison is vacuous.
Production implication: none_of_the_above for the fourth time in Module 18. Chapter 18.1 §15, Chapter 18.2 §16, Chapter 18.3 §16 and this block now cover the MAC's interfaces, its ring protocol, and both DMA directions. A port on which all four assert is a port whose entire hardware-to-driver contract is clean, which removes about twenty hypotheses — and is the only evidence that will move an investigation off the network stack.
16. Back-to-Back Transmission and the Fetch Deadline
Section 9 said store-and-forward moves the deadline rather than removing it. This section is the deadline.
To keep the wire busy, frame n+1 must be completely fetched before frame n finishes transmitting. So the fetch's whole round trip — descriptor read, data reads, responses — must fit inside one frame's transmission time.
| Line rate | 64-octet frame | 1518-octet frame |
|---|---|---|
| 1 Gb/s | 512.00 ns | 12 144.00 ns |
| 10 Gb/s | 51.20 ns | 1 214.40 ns |
| 25 Gb/s | 20.48 ns | 485.76 ns |
| 100 Gb/s | 5.12 ns | 121.44 ns |
Against a 300 ns memory round trip, exactly one cell in that table is comfortable: 1 Gb/s with maximum-size frames.
| Line rate | Frame | Fits in 300 ns? |
|---|---|---|
| 1 Gb/s | 64 | yes — 512 ns |
| 1 Gb/s | 1518 | yes |
| 10 Gb/s | 64 | NO — 51.2 ns |
| 10 Gb/s | 1518 | yes — 1214 ns |
| 25 Gb/s | 64 | NO — 20.5 ns |
| 100 Gb/s | 64 | NO — 5.12 ns |
| 100 Gb/s | 1518 | NO — 121.4 ns |
Which gives the transmit path's depth requirement, and it is the same shape as Chapter 18.2 §9's: round trip divided by frame interval. Note the divisor is the interval, not the transmission time above — the next frame may start after Chapter 8.3's 20 octets of preamble and interframe gap, so the budget is 672 ns at 1 Gb/s rather than 512.
| Line rate | Frame | Frame interval | Frames in flight at 300 ns |
|---|---|---|---|
| 1 Gb/s | 64 | 672.00 ns | 1 |
| 10 Gb/s | 64 | 67.20 ns | 5 |
| 25 Gb/s | 64 | 26.88 ns | 12 |
| 100 Gb/s | 64 | 6.72 ns | 45 |
| 100 Gb/s | 1518 | 123.04 ns | 3 |
Forty-five frames in flight at 100 Gb/s with minimum-size frames, which is a deep pipeline of chains, each at a different stage — descriptor fetched, data requested, data arriving, in the FIFO, transmitting.
And that is the transmit path's real structure, stated once: it is not a loop that fetches a frame and sends it. It is a pipeline whose depth is set by the memory system's latency, exactly as Chapter 18.3 §10's receive barrier was, and a design that treats it as a loop runs at frame_time / round_trip of line rate — 2.24% at 100 Gb/s with minimum-size frames.
One asymmetry with the receive side is worth noting, because it makes the transmit case easier. On receive, frames arrive when they arrive and the design has no choice about the rate. On transmit, the design controls when frames go out — so falling behind costs throughput and never data, provided Section 8's guard is present. The transmit pipeline may be shallower than the arithmetic demands and the only consequence is a slower link.
17. What the Transmit Path Assumes
Chapter 18.1 §17 listed eight assumptions the MAC makes about its system. The transmit path adds four and they are all about software.
| # | The assumption | If it is false | Established in |
|---|---|---|---|
| 1 | a store barrier precedes the ownership store | freed memory is transmitted onto the wire | Section 4 |
| 2 | a store barrier precedes the doorbell | the last packet of a transfer hangs | Section 4 |
| 3 | a chain's last is published no later than its first | the transmit path stalls mid-chain | Section 3 |
| 4 | the buffer is not freed before the completion | the frame is built from reused memory | Section 13 |
| 5 | memory read latency fits the fetch deadline | the link runs below line rate | Section 16 |
| 6 | the worst-case stall is smaller than the cut-through lead | an underrun corrupts a frame on the wire | Sections 6, 7 |
Rows 1 to 4 are software's and hardware can detect none of them. Row 5 is the system's and shows up as throughput. Row 6 is a configuration claim — the integrator tells the MAC what the worst-case stall is, and Section 7's check is only as good as that number.
Which produces an uncomfortable summary: the transmit path's four most serious failure modes are all in the driver, and all four are one instruction or one ordering decision.
And hardware's total contribution to diagnosing them is three counters:
| Counter | Catches | How completely |
|---|---|---|
c_partial_wait_cycles | assumption 3 | well |
c_empty_polls with c_doorbells low | assumption 2, indirectly | poorly |
| nothing | assumptions 1 and 4 | not at all |
Row three is the honest entry. A stale buffer pointer and a prematurely freed buffer both produce a well-formed frame containing the wrong octets, and the MAC has no way to know. Chapter 18.2 §15's audit register — recording what the MAC actually fetched — is the only hardware contribution available, and it works by letting a driver author compare against what they believe they wrote.
Which is why the reference driver matters more here than anywhere else in the module. Chapter 18.2 §12's callout argued that a cross-component requirement is best embodied in code somebody will copy. On the transmit path there are four such requirements, they are exercised on every packet, and their failures reach the wire.
18. The Cost, Accounted
Eight blocks, and the transmit path is cheaper than the receive path in logic and more expensive in memory.
| Block | Approximate cost | Dominated by |
|---|---|---|
tx_ring_walker | ~700 flops | the chain accumulator |
tx_gather_engine | ~600 flops + a reorder buffer | the reorder buffer, if IDs are many |
tx_fifo_writer | ~150 flops + a divide | the lead computation |
underrun_guard | ~80 flops | trivial, and load-bearing |
doorbell_unit | ~120 flops | counters |
tx_completion_reporter | ~250 flops | the batch state |
txdma_telemetry | ~400 flops | counters |
txdma_conformance_monitor | ~130 flops | comparators |
About 2 430 flops — close to Chapter 18.3's 2 350 — and the smallest block in the table is the one without which the path is unsafe. underrun_guard is eighty flops and it is the difference between a lost frame and a frame delivered with a valid checksum over wrong data.
The memory is where transmit costs more:
| Line rate | Store-and-forward TX FIFO | Why |
|---|---|---|
| any | one maximum frame — 1.5 KiB at 1518 MTU | the frame cannot start until complete |
| any | 9 KiB at a 9000-octet MTU | the same |
| 100 Gb/s, pipelined | 45 frames in flight | Section 16's deadline |
Row three is the one that decides the number. Forty-five minimum-size frames is 45 × 64 = 2.81 KiB of frame data; forty-five maximum-size frames would be 66.7 KiB, but Section 16's table shows only 3 maximum-size frames need to be in flight — so the worst case is not the product of the two worsts.
| Line rate | Frames in flight | × frame size | TX FIFO |
|---|---|---|---|
| 100 Gb/s, 64-octet | 45 | 2.81 KiB | 2.81 KiB |
| 100 Gb/s, 1518-octet | 3 | 4.45 KiB | 4.45 KiB |
| 100 Gb/s, 9000-octet | 1 | 8.79 KiB | 8.79 KiB |
The MTU sets the floor and the pipeline depth never exceeds it, which is a pleasant result: a transmit FIFO sized at one maximum frame plus a little is sufficient at every rate, unlike Chapter 18.1 §18's receive FIFO, whose 32 KiB at 100 Gb/s is dominated by a stall buffer that scales with the line rate.
Module 18's memory, with both directions now counted, for a 100 Gb/s port:
| Size | Of a 4 MiB on-chip budget | |
|---|---|---|
| receive FIFO, lossless to 100 m | 32 KiB | 0.78% |
| transmit FIFO | ~9 KiB | 0.22% |
| total on-chip | ~41 KiB | 1.0% |
| rings, both directions, padded | 512 KiB DRAM | — |
| receive buffers, 4096 × 2 KiB | 8 MiB DRAM | — |
The transmit FIFO is 28% of the receive FIFO, which is the asymmetry this chapter opened with, arriving from a different direction: the receive path cannot refuse work and must buffer against the system's worst behaviour; the transmit path can simply go slower.
19. Properties Worth Asserting, and One Worth Refusing
The transmit path's properties divide by block, and the rejected one is about the only irreversible action in Module 18.
Ring walker.
// A chain is only emitted when its last descriptor has been seen.
p_chain_requires_last:
assert property (@(posedge clk) disable iff (!rst_n)
(chain_valid && chain_ready) |-> acc[acc_n - 1].last)
else $error("a chain was emitted without its last descriptor");
// Only MAC-owned descriptors are accumulated.
p_accumulate_only_owned:
assert property (@(posedge clk) disable iff (!rst_n)
(rd_done && !rd_error && rd_desc[0].own == 1'b0) |=>
(acc_n == $past(acc_n)))
else $error("a driver-owned descriptor was accumulated");
// The first descriptor of a chain carries `first`.
p_first_is_first:
assert property (@(posedge clk) disable iff (!rst_n)
(chain_valid && (acc_n != '0)) |-> acc[0].first)
else $error("a chain's first descriptor did not carry the first flag");
// A chain never exceeds MAX_SG without being flagged.
p_chain_bounded:
assert property (@(posedge clk) disable iff (!rst_n)
(acc_n >= MAX_SG) |=> (c_chain_too_long > $past(c_chain_too_long)))
else $error("an over-long chain was not counted");
// The ring index only advances.
p_index_monotonic:
assert property (@(posedge clk) disable iff (!rst_n)
##1 (next_index >= $past(next_index)))
else $error("the transmit ring index went backwards");
// A partial chain is held, never started.
p_partial_never_starts:
assert property (@(posedge clk) disable iff (!rst_n)
waiting_for_last |-> !chain_valid)
else $error("a partial chain was offered as complete");Gather engine.
// No read burst crosses a 4 KiB boundary.
p_read_within_page:
assert property (@(posedge clk) disable iff (!rst_n)
(ar_valid && ar_ready) |->
((ar_addr[11:0] + this_burst) <= 13'h1000))
else $error("a transmit read crossed a page boundary");
// No burst exceeds the beat limit.
p_read_within_burst:
assert property (@(posedge clk) disable iff (!rst_n)
(ar_valid && ar_ready) |-> (ar_len < MAX_BEATS))
else $error("a read burst exceeded MAX_BEATS");
// No burst reads past the end of its buffer.
p_read_within_buffer:
assert property (@(posedge clk) disable iff (!rst_n)
(ar_valid && ar_ready) |-> (this_burst <= cur_remaining))
else $error("a read ran past the end of its buffer");
// Data enters the FIFO in frame order only.
p_fifo_order:
assert property (@(posedge clk) disable iff (!rst_n)
fifo_wr |-> (r_tag == expect_tag))
else $error("out-of-order data entered the transmit FIFO");
// Exactly one start-of-frame per chain.
p_one_sof:
assert property (@(posedge clk) disable iff (!rst_n)
fifo_sof |=> (!fifo_sof throughout fifo_eof[->1]))
else $error("two starts of frame without an end");
// The octets fetched never exceed the chain's declared total.
p_fetch_within_total:
assert property (@(posedge clk) disable iff (!rst_n)
active |-> (bytes_fetched <= bytes_total))
else $error("more octets were fetched than the chain declared");
// A read error is counted and does not silently pass.
p_read_error_counted:
assert property (@(posedge clk) disable iff (!rst_n)
(r_valid && r_error) |=> (c_read_errors > $past(c_read_errors)))
else $error("a read error was not counted");FIFO writer and the underrun guard.
// Transmission never starts on an unsafe cut-through configuration.
p_no_unsafe_cut_through:
assert property (@(posedge clk) disable iff (!rst_n)
(may_start && !frame_complete) |-> !cut_through_unsafe)
else $error("cut-through started with an insufficient lead");
// Store-and-forward starts only on a complete frame.
p_sf_starts_complete:
assert property (@(posedge clk) disable iff (!rst_n)
(may_start && !cfg_cut_through) |-> frame_complete)
else $error("store-and-forward started on an incomplete frame");
// The occupancy never exceeds the FIFO.
p_occupancy_bounded:
assert property (@(posedge clk) disable iff (!rst_n)
occupancy_b <= DEPTH_B)
else $error("the transmit FIFO occupancy exceeded its depth");
// An underrun forces a bad FCS, always.
p_underrun_forces_bad_fcs:
assert property (@(posedge clk_tx) disable iff (!rst_tx_n)
in_underrun |-> force_bad_fcs)
else $error("an underrun did not corrupt the FCS");
// The guard is sticky for the rest of the frame.
p_guard_is_sticky:
assert property (@(posedge clk_tx) disable iff (!rst_tx_n)
(in_underrun && !frame_last_octet) |=> in_underrun)
else $error("the underrun guard deasserted mid-frame");
// The guard clears at the frame boundary and only there.
p_guard_clears_at_frame_end:
assert property (@(posedge clk_tx) disable iff (!rst_tx_n)
(in_underrun && frame_last_octet) |=> !in_underrun)
else $error("the underrun guard survived the frame");
// A store-and-forward frame can never underrun.
p_sf_cannot_underrun:
assert property (@(posedge clk_tx) disable iff (!rst_tx_n)
(transmitting && $past(frame_complete_at_start)) |-> !underrun_now)
else $error("a store-and-forward frame underran -- impossible by construction");Doorbell.
// A doorbell whose delta is zero is a driver fault.
p_doorbell_advances:
assert property (@(posedge clk) disable iff (!rst_n)
db_write |-> (db_count != last_db_count))
else $error("a doorbell was rung without publishing a descriptor");
// Pending work never goes negative.
p_pending_nonnegative:
assert property (@(posedge clk) disable iff (!rst_n)
chain_taken |-> (pending != 16'd0))
else $error("a chain was taken with no pending work");
// The poll timer resets when a poll is issued.
p_poll_resets:
assert property (@(posedge clk) disable iff (!rst_n)
poll_due |=> (poll_age == '0))
else $error("the poll timer did not reset");
// Work available implies pending non-zero, and conversely.
p_work_matches_pending:
assert property (@(posedge clk) disable iff (!rst_n)
work_available == (pending != 16'd0))
else $error("work_available disagreed with the pending count");Completion.
// Nothing is written back before the transmit domain declares done.
p_writeback_after_done:
assert property (@(posedge clk) disable iff (!rst_n)
(wr_valid && wr_ready) |-> have)
else $error("a completion was written before a frame was done");
// A batch covers contiguous descriptors.
p_completion_batch_contiguous:
assert property (@(posedge clk) disable iff (!rst_n)
(frame_done && can_join) |-> (frame_first_index == batch_next))
else $error("a non-contiguous chain joined a completion batch");
// An underrun sets the underrun status bit.
p_underrun_reported:
assert property (@(posedge clk) disable iff (!rst_n)
(frame_done && frame_underrun) |=> status_acc[TS_UNDERRUN])
else $error("an underrun was not reported in the status");
// A held batch is flushed if a timeout is configured.
p_completion_batch_flushed:
assert property (@(posedge clk) disable iff (!rst_n)
(have && (cfg_batch_timeout != '0)) |-> ##[1:$] (wr_valid && wr_ready))
else $error("a completion batch was held with a timeout configured");Telemetry.
// Counters are monotonic.
p_tx_counters_monotonic:
assert property (@(posedge clk) disable iff (!rst_n)
##1 ((c_chains >= $past(c_chains)) &&
(c_underruns >= $past(c_underruns))))
else $error("a transmit counter decreased");
// Completions never exceed chains started.
p_tx_completions_le_chains:
assert property (@(posedge clk) disable iff (!rst_n)
c_completions <= c_chains)
else $error("more chains completed than started");
// The minimum occupancy only decreases.
p_min_occupancy_monotonic:
assert property (@(posedge clk) disable iff (!rst_n)
##1 (c_min_occupancy <= $past(c_min_occupancy)))
else $error("the minimum occupancy increased");
// Empty polls never exceed polls.
p_empty_polls_le_polls:
assert property (@(posedge clk) disable iff (!rst_n)
c_empty_polls <= c_polls)
else $error("more empty polls than polls");20. Verification Scenarios
Fifty-seven scenarios, plus a six-run directed test that requires a memory model with a variable read latency.
Ring walk and chain assembly — 10 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 1 | a single-descriptor chain, first and last set | emitted immediately |
| 2 | a 5-descriptor chain, published in order | emitted when last arrives |
| 3 | first published, last withheld | held; waiting_for_last; no transmission |
| 4 | last published 100 µs later | emitted then; c_partial_wait_cycles large |
| 5 | a descriptor owned by the driver | not accumulated |
| 6 | a 9-descriptor chain with MAX_SG 8 | c_chain_too_long |
| 7 | a batch fetch spanning the ring's wrap | shortened, not split |
| 8 | a doorbell during a fetch | not lost — the count is cumulative |
| 9 | a fetch returning a bus error | counted; retried on the next trigger |
| 10 | two chains back to back | both emitted in index order |
Gather and ordering — 9 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 11 | 1518 octets, one 2 KiB buffer, page-aligned | 2 bursts |
| 12 | the same, 3 KiB into a page | 3 bursts, c_page_splits |
| 13 | 5 buffers of 2 KiB | c_buffer_splits = 5 |
| 14 | responses returning out of order | held; c_reorder_stalls; FIFO order preserved |
| 15 | responses in order | no stalls |
| 16 | a read error mid-chain | counted; the frame is abandoned |
| 17 | the FIFO full mid-gather | ar_valid low; no data lost |
| 18 | a buffer of zero length | skipped; the next is taken |
| 19 | a chain whose total exceeds the MTU | TS_TOO_LONG, not transmitted |
Start policy and underrun — 12 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 20 | store-and-forward, complete frame | starts; cannot underrun |
| 21 | cut-through, threshold 512, occupancy 512 | starts early; c_cut_through |
| 22 | cut-through, threshold 512, required lead 3750 | cut_through_unsafe; falls back |
| 23 | cut-through at 1 Gb/s, 300 ns stall | safe — 37.5 octets needed |
| 24 | cut-through at 100 Gb/s, 300 ns stall | unsafe — 3750 needed, frame is 1518 |
| 25 | FIFO empties mid-frame | force_bad_fcs; c_underruns |
| 26 | the FIFO refills two beats later | guard STAYS asserted — the hole is on the wire |
| 27 | the underrun is at the last octet | frame_last_octet — not an underrun |
| 28 | an underrun on a store-and-forward frame | impossible; property fires if seen |
| 29 | minimum occupancy during a clean frame | recorded in c_min_occupancy |
| 30 | occupancy reaches 64 octets, no underrun | underrun_margin_thin |
| 31 | a 64-octet frame at 10 Gb/s, cut-through | unsafe above 5.12 Gb/s — refused |
Doorbell and polling — 9 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 32 | one doorbell, delta 1 | one chain fetched |
| 33 | one doorbell, delta 32 | c_doorbells_coalesced rises |
| 34 | a doorbell with delta 0 | doorbell_lost |
| 35 | polling enabled, ring empty | c_empty_polls rises |
| 36 | polling at 1 µs, link idle | 1 M reads/s, all empty |
| 37 | polling disabled, doorbell missing | the frame never goes — the hang |
| 38 | polling at 50 µs as a backstop | the frame goes, 50 µs late |
| 39 | a doorbell arriving during a fetch | counted; work pending increases |
| 40 | the doorbell count wrapping | descriptors lost — the known limitation |
Completion — 8 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 41 | a clean frame | TS_SENT; ownership returned |
| 42 | an underrun frame | TS_SENT and TS_UNDERRUN |
| 43 | 8 contiguous chains | one writeback |
| 44 | a chain crossing the ring's wrap | the batch flushes |
| 45 | 3 chains then silence, timer set | flushed by timeout |
| 46 | 3 chains then silence, timer 0 | held — the failure the timer prevents |
| 47 | a 5-descriptor chain | all five released together |
| 48 | a completion before frame_done | completed_before_done — must never happen |
Verdicts — 9 scenarios.
| # | Scenario | Expected |
|---|---|---|
| 49 | one underrun | memory_missed_deadline, not tx_path_ok |
| 50 | min occupancy below the lead, no underrun | underrun_margin_thin |
| 51 | 10 000 doorbells, 100 coalesced | driver_not_batching |
| 52 | 10 000 doorbells, 9 000 coalesced | not flagged |
| 53 | partial waits above 64 cycles per chain | driver_publishing_late |
| 54 | 80% of polls empty | polling_wasteful |
| 55 | an over-long chain | chain_limit_reached |
| 56 | an unsafe cut-through configuration | cfg_fault |
| 57 | everything clean | none_of_the_above |
The directed test — six runs random stimulus will not produce.
What this test needs is a memory model whose read latency varies, and most do not have one. A constant-latency model either always meets the deadline or never does; the underrun happens when the latency is usually fine and occasionally is not, which is a distribution, not a value.
And the second requirement is that the variation must land at a specific moment: during transmission, not before it. A long latency before the frame starts delays the frame and harms nothing. The same latency 40% of the way through a cut-through frame is an underrun — and the probability of a random generator producing a long read at that point, on a frame that started cut-through, is small enough that a long regression will not.
Construct it. Six runs, one variable: where the stall lands.
| Run | Mode | Read latency | Stall lands | Expected |
|---|---|---|---|---|
| A | store-and-forward | 300 ns constant | anywhere | no underrun — impossible by construction |
| B | store-and-forward | 2 µs spike | anywhere | no underrun; the frame is late |
| C | cut-through, lead 512, 1 Gb/s | 300 ns spike | mid-frame | no underrun — 37.5 octets needed |
| D | cut-through, lead 512, 10 Gb/s | 300 ns spike | mid-frame | no underrun — 375 needed, 512 held |
| E | cut-through, lead 512, 10 Gb/s | 600 ns spike | mid-frame | UNDERRUN — 750 needed, 512 held |
| F | cut-through, lead 512, 10 Gb/s | 600 ns spike | BEFORE the start | no underrun — the frame is merely late |
Runs E and F are the pair and they are the point. The identical stall, the identical configuration, the identical frame — and the only difference is whether it landed before or after the first octet went out. Before, it is a latency. After, it is a corrupted frame on a cable.
Which is the whole of Section 19's rejected class demonstrated: the threshold check passed in both runs, because the FIFO held 512 octets when transmission began. may_start was true, the property held, and run E lost a frame.
The oracle, in four parts:
| Check | Runs A–D, F | Run E |
|---|---|---|
c_underruns | zero | one |
force_bad_fcs | never asserted | asserted, sticky to frame end |
| the far end's FCS check | passes | fails — the frame is discarded |
c_min_occupancy | above zero | zero |
Row three is the check that proves the guard works and it requires a far-end model. A testbench that stops at the MAC's PHY interface sees the bad FCS and can verify it is bad, which is sufficient; one that checks only c_underruns has not verified that the frame is actually discarded downstream, which is the guard's entire purpose.
And row four is the one to watch across all six runs, because it is the margin. Run D's minimum occupancy is 512 − 375 = 137 octets — it survived, with 137 octets to spare — and a regression that records only pass or fail cannot tell run D from run C, which had 474 to spare. The margin is the result; the verdict is only its sign.
21. Debugging the Transmit Path
Four complaints, and three of them are the driver.
Complaint 1 — "the far end reports CRC errors on frames we send."
| Check | If yes | Meaning |
|---|---|---|
c_underruns non-zero? | the memory system missed a deadline | Sections 6, 8 — and the guard worked |
| cut-through enabled? | Section 9's arithmetic | disable it, or raise the lead |
c_min_occupancy near zero? | confirmed | the same |
| errors on our receive side too? | a physical fault as well | two problems |
Row one is the diagnosis and it is a good outcome, which is unusual in this module: the CRC errors mean Section 8's guard is doing its job. The frames are lost and the far end knows. A design without the guard would show no errors at the far end and corrupted data in the application, which is far worse and far harder to find.
Complaint 2 — "a connection hangs on its last packet."
| Check | If yes | Meaning |
|---|---|---|
c_doorbells flat while a descriptor is MAC-owned? | the doorbell never rang | the driver's step-7 |
waiting_for_last asserted? | the chain's last was never published | the driver's chain construction |
| polling disabled? | nothing will rescue it | Section 11's backstop |
| enabling a 50 µs poll fixes it? | confirms one of the above | and gives a workaround |
Row four is the diagnostic to reach for first because it is a configuration change rather than a code change: if a slow poll makes the hang disappear, the doorbell path is broken and the remaining question is only which of the two causes it is.
Complaint 3 — "transmit throughput is below line rate and nothing is wrong."
| Check | If yes | Meaning |
|---|---|---|
c_partial_wait_cycles large? | waiting for the driver | driver_publishing_late |
c_starve_cycles large? | waiting for memory | Section 16's pipeline depth |
c_reorder_stalls large? | the bus returns data out of order | Chapter 18.5's ID policy |
c_page_splits high? | the application's buffers are unaligned | often unfixable — Section 5 |
| all flat? | the offered load is what it is | none_of_the_above |
Row four is the one to be careful with, because unlike the receive path it is frequently not a bug: transmit buffers are often the application's, at whatever offset write() supplied, and the only fix is a copy that costs more than the splits.
Complaint 4 — "large sends fail, small ones work."
| Check | If yes | Meaning |
|---|---|---|
c_chain_too_long non-zero? | MAX_SG exceeded | Section 3 |
| fails above a specific size? | compute MAX_SG × the fragment size | the exact limit |
writev or sendfile involved? | a page-fragmented scatter list | the usual cause |
TS_TOO_LONG set? | the chain exceeded the MTU | a different bug |
Row three is the tell. A chain limit is hit by scattered sends, not by large ones — a 64 KiB contiguous buffer is one descriptor; a 16 KiB writev of eight fragments is eight — so the correlation is with the shape of the application's I/O rather than its size.
And the two symptoms this chapter's failures are systematically mistaken for:
| Symptom | Instinct | This chapter's cause |
|---|---|---|
| CRC errors reported by the far end | our cable or our PHY | a transmit underrun — the memory system |
| a connection that hangs at the end | the application or the peer | a missing doorbell, or a missing barrier before it |
22. Misconceptions
Misconception 1 — "transmit is receive with the arrows reversed."
The wrong model: the same ring, the same ownership bit, the same DMA — just reading instead of writing.
What it costs: a design that validates nothing, because on receive there was nothing to validate. The MAC acts on a length, a pointer and a last-fragment flag that the driver asserted, and Chapter 18.2 §12 established hardware cannot check the store ordering that makes them trustworthy.
The corrected model: a receive descriptor is a report of something that happened; a transmit descriptor is an instruction for something that has not. The length and the last-fragment flag move from observed to asserted, and acting on them is irreversible. Section 2.
Misconception 2 — "the start threshold prevents underruns."
The wrong model: start transmitting when enough octets are buffered and the FIFO cannot run dry.
What it costs: a design with no underrun guard, which on the underrun that happens anyway transmits a frame with a valid FCS over data containing a hole — corruption the far end accepts.
The corrected model: the threshold is a necessary condition evaluated at the moment of commitment; the requirement is a sufficient one about the next 12 µs, which is not knowable then. The threshold reduces the probability; Section 8's guard handles the case it does not prevent; and c_min_occupancy measures how close it came. Section 19.
Misconception 3 — "cut-through transmit is a risk to be traded off."
The wrong model: cut-through saves latency and risks underruns, so it is a judgement call.
What it costs: a 100 Gb/s port configured for cut-through on a system with a 300 ns worst-case read latency — which needs 3750 octets of lead against a 1518-octet maximum frame. It is not a risk that was accepted; it is an impossibility that was not computed.
The corrected model: cut-through requires S × r / 8 < F. At a 300 ns stall it is available to 40.48 Gb/s on maximum-size frames and 1.707 Gb/s on minimum-size ones. And the benefit and the feasibility condition are the same quantity — cut-through is available exactly where its saving exceeds the stall it must survive. Section 9.
Misconception 4 — "a doorbell is a flag."
The wrong model: the driver sets a bit; the MAC sees it and goes to look.
What it costs: a doorbell rung while the MAC is mid-fetch is lost, and the frame waits for the next one — which on a connection sending its final packet never comes. The symptom is a hang, and it reproduces once a week.
The corrected model: the doorbell is a cumulative count, so the MAC can read it at any moment and compute what it has not yet seen. This is Chapter 18.1 §14's toggle argument in a different costume: a level that carries state cannot be missed, a pulse can. Section 10.
Misconception 5 — "polling is simpler and therefore safer."
The wrong model: avoid the doorbell's ordering requirement by having the MAC poll the ring.
What it costs: on an idle link, a poll every 1 µs is a million descriptor reads per second moving nothing — and across sixteen ports, sixteen million. The cost is paid precisely when there is no work.
The corrected model: a doorbell costs nothing when idle and scales with the work; polling costs the same always. The right structure is a doorbell with a slow poll as a backstop — 50 µs, 20 000 reads per second, 0.00008 transactions per cycle — which turns a lost doorbell from a hang into a latency spike. Section 11.
Misconception 6 — "store-and-forward removes the timing problem."
The wrong model: buffer the whole frame and the memory system's latency no longer matters.
What it costs: a port that cannot keep the wire busy. At 100 Gb/s the next frame must be completely fetched within 6.72 ns of the previous one starting, and a design that fetches one frame at a time runs at 2.24% of line rate.
The corrected model: store-and-forward removes the underrun and moves the deadline to the gap between frames. The transmit path is a pipeline whose depth is the memory round trip divided by the frame interval — 45 frames at 100 Gb/s with minimum-size frames. The one mercy is that falling behind costs throughput and never data. Section 16.
23. Interview Questions
Q1 — "How is the transmit DMA path different from the receive path? Give the structural difference, not a list."
Who decides a frame is complete. On receive the MAC decides — it has the frame, it measured the length, it checked the FCS — so every descriptor field it writes is a fact it observed. On transmit the driver decides, so the length, the buffer pointer and the last-fragment flag are claims the MAC must act on without being able to verify them. Chapter 18.2 §12's store-ordering requirement moves from a corner case to the critical path, because it is exercised on every transmitted packet rather than on every refill batch — 1.488 million times a second at 1 Gb/s.
Q2 — "A transmit underrun happens. What does the MAC do, and why?"
It deliberately corrupts the FCS and keeps doing so for the rest of the frame. The frame is lost either way — octets have already gone out and cannot be recalled — so the only question is whether the far end knows. A bad FCS makes it an ordinary CRC error that every Ethernet device already discards and counts. The guard must be sticky: a FIFO that refills two beats later does not rescue the frame, and a guard that deasserts produces a frame with a valid FCS over data containing a hole, which the far end accepts.
Q3 — "Should we use cut-through transmit on our 100 Gb/s port?"
Not if the worst-case memory read latency is above about 121 ns, and it will be. Cut-through needs S × r / 8 octets of lead, and that cannot exceed the frame: at a 300 ns stall the lead required is 3750 octets against a 1518-octet maximum frame. It is not a risk to be weighed; it is arithmetically unavailable. And the thing that makes it acceptable is that store-and-forward at 100 Gb/s costs only 121 ns on a maximum-size frame — the optimisation disappears exactly where it stops being worth having, because its benefit and its feasibility condition are the same quantity.
Q4 — "Doorbell or polling?"
Doorbell, batched by the driver, with a slow poll as a backstop. A doorbell costs the CPU one MMIO write and costs nothing when idle; polling costs a descriptor read per interval whether or not there is work — a million per second at 1 µs, on a link that may be dark. Batching is what makes the doorbell cheap: ringing per packet at 100 Gb/s is 148.8 million MMIO writes per second, ringing per 32 is 4.65 million. The backstop at 50 µs costs 20 000 reads per second and converts a lost doorbell from a hang into a 50 µs latency spike.
Q5 — "When may the driver free a transmit buffer?"
Not before the completion, and the completion must not be reported before the frame has left the PHY. Not when the descriptor was fetched, not when the read burst was issued — Chapter 18.3 §19's class 76 — and not when the read response returned, because the frame may still underrun and a MAC that retries needs the buffer intact. And the unit is the chain, not the descriptor: a driver freeing a fragment when its own descriptor returns frees the head of a frame whose tail is still being read.
Q6 — "Your 100 Gb/s transmit path runs at 2% of line rate with no errors. What is wrong?"
It is a loop where it should be a pipeline. At 100 Gb/s the frame interval for minimum-size frames is 6.72 ns, and a memory round trip is 300 ns — so a design that fetches a frame, sends it, and then fetches the next runs at 6.72/300 = 2.24%. Store-and-forward removes the underrun and moves the deadline into the gap between frames; 45 chains must be in flight at once, each at a different stage. The counter to look at is c_starve_cycles, and the fix is depth, not speed.
24. Understanding Check
25. What's Next
Module 18 has three chapters left and each takes an unfinished thread from this one.
| Thread | Left open here | Chapter |
|---|---|---|
| burst shaping and IDs | Section 5's c_reorder_stalls has no policy behind it | Chapter 18.5 |
| completion coalescing | Section 12 batches by address, not by rate | Chapter 18.6 |
| what a descriptor means | Section 2's "one descriptor, one fragment" is about to change | Chapter 18.7 |
Chapter 18.5 — Mastering Memory over AXI takes Section 5's gather engine and Chapter 18.3 §8's write engine and shapes their traffic properly. Two questions from this chapter go straight into it.
The first is the reorder buffer. This chapter's gather engine must deliver octets into the FIFO in frame order, so a response arriving early for a later burst must be held. One AXI ID makes that free and serialises everything; many IDs give concurrency and need a buffer. Chapter 18.2 §10's mode 0 already depended on same-ID ordering for the descriptor writes, so the ID budget is already partly spent — and Chapter 18.5 has to allocate what remains.
The second is the burst length. This chapter used 16 beats without justifying it. Chapter 18.3 §6 established that 37.04% of maximum-size frames cross a 4 KiB page boundary at random alignment, and a longer burst is more likely to be split by one — so the longest legal burst is not obviously the best one, and the trade has a number attached that Chapter 18.5 derives.
And one result from this chapter carries directly into it. Section 16's pipeline depth — 45 frames in flight at 100 Gb/s — is a statement about outstanding transactions, which is exactly the resource Chapter 18.5 is about allocating. The transmit path needs 45 chains open; the receive path needed 45 frames open; and the bus offers a fixed number of outstanding transactions to be divided between them.
Continue learning
Related tutorials
- Related topic
The Transmit Path
Framing, padding, check append and interframe gap in one datapath — and their order is forced rather than chosen, because the pad is inside the covered range and the gap is outside the frame. Plus the one event the order cannot help with.
- Related topic
The Ethernet MAC as an SoC IP Block
A MAC integrated into an SoC demands 1.143 times its line rate in memory bandwidth, crosses four clock domains, and at 100 Gb/s asks for 1.79 bus transactions per cycle.
- Related topic
Descriptor Rings and the Ownership Model
A ring hands buffers between a DMA engine and a driver with one bit and no lock — provided the descriptor's other fields are visible before that bit is, which no memory system promises.
- Related topic
The Receive DMA Path
A received frame crosses seven boundaries between the MAC's FIFO and a host buffer, and the last two contribute 700 ns and 100 microseconds of jitter to a 24.2 ns clock.
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.
