Ethernet · Module 16
Hardware Timestamping at the MAC/PHY Boundary
A Sync must carry the time of its own transmission. One-step rewrites the field in flight and patches the CRC with a linear map; two-step sends a second message.
Chapter 16.2 §2 named the problem and Section 5 of that chapter worked around it. This chapter solves it.
A Sync message must carry t1 — the moment it left the wire. That moment is not known until the message is already leaving, by which time its destination address, its source address, its EtherType and thirty-four octets of PTP header are physically gone.
There are exactly two answers.
Two-step sends the value afterwards, in a Follow_Up. Simple, costs a message, and Chapter 16.2 §5 built the slave side of it.
One-step rewrites the field as the frame streams past — and then must fix the FCS, because Chapter 6.3's residue check will fail on every frame whose content changed after its CRC was computed.
And fixing the FCS is where the chapter's engineering lives. The naive answer is to buffer the frame, patch it, and recompute — which adds a full store-and-forward delay: 512 ns at 1 Gb/s, on a mechanism whose whole purpose is nanosecond accuracy. The right answer costs about 1248 XOR gates and no latency at all, and it works because Chapter 6.2's CRC is linear over GF(2).
1. Scope — What This Chapter Owns
This chapter owns the timestamp unit: where it sits, what it captures, the two-step report path, the one-step in-flight rewrite, the FCS patch that makes one-step possible, the transmit-timestamp FIFO, and the pairing of a timestamp to the frame it belongs to.
It does not own why the boundary is the right place. Chapter 16.1 §13 derived it: capture at the earliest point whose remaining delay to the wire is characterisable, which is the MAC/PHY boundary at Chapter 5.2's Start Frame Delimiter. This chapter takes that as settled.
It does not own the arithmetic. Chapter 16.2 §6 derived the offset and path-delay equations from t1 through t4. This chapter produces t1 and t3 and consumes neither.
It does not own the correction field's content. The unit built in Section 10 can add to correctionField in flight; what value a transparent clock puts there, and why, is Chapter 16.4 §12. Section 10 builds the mechanism and states the requirement.
And it does not own the residual error. What a characterised PHY delay leaves behind, and what Chapter 4.4's elastic buffer contributes, is Chapter 16.5. Section 4 here names the terms and does not price them.
2. The Problem Chapter 16.1 Left Open
State it precisely, because the imprecise version has an easy and wrong solution.
The imprecise version: "a Sync must contain the time it was sent." The easy wrong answer is to read the clock, write it into the frame, and transmit — which records the time the software decided to send, not the time the frame left. Chapter 16.1 §9 priced that path at 9 µs typical and 10.5 ms of jitter.
The precise version: a Sync must contain the value of a counter sampled at the instant its own Start Frame Delimiter crosses the MAC/PHY boundary.
And that instant is after the frame's first fifteen octets are already transmitted.
| Octet | What it is | Transmitted at 1 Gb/s |
|---|---|---|
| −8 … −1 | preamble | 0 – 64 ns |
| −1 | the SFD — the capture instant | 56 – 64 ns |
| 0 … 5 | destination address | 64 – 112 ns |
| 6 … 11 | source address | 112 – 160 ns |
| 12 … 13 | EtherType 0x88F7 | 160 – 176 ns |
| 14 … 47 | the PTP common header | 176 – 448 ns |
| 48 … 57 | originTimestamp — where t1 must go | 448 – 528 ns |
| 58 … 59 | pad | 528 – 544 ns |
| 60 … 63 | the FCS | 544 – 576 ns |
The capture happens at 64 ns and the field it must fill begins at 448 ns. So there are 384 ns of slack at 1 Gb/s — plenty — and the field ends 16 ns before the FCS begins.
That 16 ns is the constraint, and it shrinks with line rate:
| Line rate | Capture to field | Field end to FCS start |
|---|---|---|
| 1 Gb/s | 384 ns | 16.0 ns |
| 10 Gb/s | 38.4 ns | 1.60 ns |
| 25 Gb/s | 15.4 ns | 0.64 ns |
| 100 Gb/s | 3.84 ns | 0.16 ns |
==
At 100 Gb/s there are 160 picoseconds between the last octet of the timestamp and the first octet of the FCS, which is less than one gate delay in any process this design would be built in. Read serially, one-step is impossible at high rate.
It is not impossible, and Section 8's callout explains why: the datapath is not serial. At 100 Gb/s a MAC moves 64 octets per cycle at 195 MHz, so the entire 64-octet Sync frame — timestamp field, pad and FCS — arrives in one cycle, and the question is not "how much time between two octets" but "can the patch be computed combinationally in 5.12 ns". Which it can.
3. RTL 1 — The Timestamp Unit at the Boundary
The capture itself, on both directions, with the classification that decides whether to capture at all.
// -----------------------------------------------------------------------
// tsu_pkg -- shared types for the timestamp unit.
// -----------------------------------------------------------------------
package tsu_pkg;
localparam int SEC_W = 48;
localparam int NS_W = 32;
typedef struct packed {
logic [SEC_W-1:0] sec;
logic [NS_W-1:0] ns;
} ts_t;
// The PTP originTimestamp wire format: 6 octets of seconds then 4
// of nanoseconds. 80 bits, and the width of every patch in this
// chapter.
localparam int OTS_BITS = 80;
typedef logic [OTS_BITS-1:0] ots_t;
function automatic ots_t to_wire(input ts_t t);
to_wire = {t.sec, t.ns};
endfunction
// Where the interesting fields sit, as offsets from the first octet
// of the PTP header. 16.2 section 3's layout.
localparam int OFF_CORRECTION = 8; // 8 octets
localparam int OFF_ORIGIN_TS = 34; // 10 octets
// A transmit timestamp is meaningless without knowing which frame it
// belongs to. 16.1 section 12's simplification, fixed here.
typedef struct packed {
logic valid;
logic [15:0] frame_tag; // assigned by the transmit scheduler
ts_t ts;
} tx_ts_entry_t;
endpackage// -----------------------------------------------------------------------
// tsu_capture_point -- captures the free-running clock at the SFD, on
// receive and on transmit, for EVENT messages only.
//
// The classification is one bit -- 16.2 section 4: message types 0x0
// to 0x3 are event messages and the top bit of messageType is clear.
// Everything else needs no timestamp and must not consume a FIFO slot.
// -----------------------------------------------------------------------
module tsu_capture_point
import tsu_pkg::*;
(
input logic clk, // the MAC clock for this direction
input logic rst_n,
input logic sfd, // 4.2's reconciliation sublayer
input logic [3:0] sfd_lane, // which lane of the word -- 16.1 section 12
input ts_t now,
// Late classification: the message type is not known at the SFD.
// The timestamp is captured UNCONDITIONALLY and discarded if the
// frame turns out not to need it.
input logic class_valid,
input logic class_is_event,
input logic [15:0] class_frame_tag,
// Fixed, characterised corrections for everything below this point.
input logic [15:0] cfg_phy_delay_ns,
input logic [15:0] cfg_lane_ps,
output logic ts_valid,
output ts_t ts_out,
output logic [15:0] ts_frame_tag,
output logic [31:0] c_captured,
output logic [31:0] c_discarded,
output logic overrun
);
ts_t held;
logic held_valid;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
held <= '0; held_valid <= 1'b0;
ts_valid <= 1'b0; ts_out <= '0; ts_frame_tag <= '0;
c_captured <= '0; c_discarded <= '0; overrun <= 1'b0;
end else begin
ts_valid <= 1'b0;
// The capture is unconditional and one cycle. 16.1 section 19's
// p_capture_latency_is_fixed: a VARIABLE capture latency
// reintroduces exactly the jitter this whole module removes.
if (sfd) begin
if (held_valid) overrun <= 1'b1;
held <= '{ sec: now.sec,
ns: now.ns
- NS_W'(cfg_phy_delay_ns)
- NS_W'((32'(cfg_lane_ps) * 32'(sfd_lane)) / 1000) };
held_valid <= 1'b1;
end
// Classification arrives later, once the header has been parsed.
// An event message keeps its timestamp; anything else frees it.
if (class_valid && held_valid) begin
held_valid <= 1'b0;
if (class_is_event) begin
ts_valid <= 1'b1;
ts_out <= held;
ts_frame_tag <= class_frame_tag;
c_captured <= c_captured + 1;
end else begin
c_discarded <= c_discarded + 1;
end
end
end
end
endmoduleClassification: a capture register with deferred classification. One flop's worth of decision and the chapter's foundation.
What it teaches: that the capture must precede the classification, and the ordering is forced. The SFD arrives at octet −1; the messageType field arrives at octet 14 — fifteen octets and 120 ns later at 1 Gb/s. A design that waits to know whether a frame needs a timestamp has already lost the instant it would have recorded. So every frame's SFD is captured and most captures are thrown away, and c_discarded will be far larger than c_captured on any link carrying ordinary traffic.
And it teaches that the lane term is in picoseconds rather than nanoseconds for a reason. Chapter 10.6's XGMII presents four or eight octets per clock, so one lane position is 0.8 ns at 10 Gb/s and 0.08 ns at 100 — and a correction expressed in integer nanoseconds truncates the whole term to zero. Chapter 16.1 §12's simplification was exactly this, and the fix is one unit change.
Deliberately simplified: one held capture with no queue, so two SFDs before a classification lose one. Section 12 builds the FIFO this needs, and the reason it needs one is not back-to-back PTP frames — those are rare — but the classification latency: at 100 Gb/s with a 64-octet datapath, the SFD and the messageType are in the same cycle and the pipeline behind them is several cycles deep.
Production implication: overrun is the signal that a timestamp was silently overwritten, and its consequence is worse than a lost timestamp. With frame tags, an overrun loses one measurement and is detected; without them, the next classification claims a timestamp belonging to a different frame — and the resulting t1 is wrong by the interval between two frames, which on a busy link is nanoseconds and therefore invisible, and on a quiet one is milliseconds and therefore catastrophic. The tag is 16 bits and it converts a silent corruption into a counter.
4. Where Exactly, and What Is Below It
Section 3 captured at "the boundary". Two questions remain: which side of the boundary, and what the characterised correction actually covers.
The capture is on the MAC side and the correction accounts for everything on the PHY side.
| Below the capture point | Magnitude | Constant? |
|---|---|---|
| the MII/XGMII interface itself | a few ns | yes |
| the PCS — Chapter 3.4 | tens of ns | yes, per mode |
| 64B/66B encode or decode — Chapter 3.5 | tens of ns | yes |
| FEC, when enabled — Chapter 3.7 | hundreds of ns | yes, and mode-dependent |
| the PMA serialiser | ns | yes |
| Chapter 4.4's elastic buffer | 5–20 ns | NO — it varies |
| the cable | 5 ns/m | yes |
Six of the seven rows are constant and removable — Chapter 16.1 §9's callout: a constant delay of any size is an offset. The fourth row is the largest and it is still removable: FEC adds hundreds of nanoseconds and adds the same hundreds every frame, so a correctly characterised design subtracts it and loses nothing.
The sixth row is the one that is not, and it is the floor.
And cfg_phy_delay_ns must therefore be a table rather than a constant, because four of the rows depend on the operating mode:
| Mode | What changes |
|---|---|
| 1000BASE-T against 1000BASE-X | different PCS, different delay |
| 10GBASE-R with and without FEC | hundreds of ns of difference |
| 25G with RS-FEC | the largest correction in the table |
| a link that renegotiated — Chapter 11.2 | the mode changed under the correction |
The last row is the operational trap. A link that auto-negotiates down from 10 Gb/s to 1 Gb/s has changed its PHY delay by tens of nanoseconds, and a design whose correction was set at commissioning is now wrong by that amount — silently, monotonically, with every check passing. Chapter 16.1 §17's cfg_phy_delay_zero catches an unset correction; nothing catches a stale one, which is why the correction must be indexed by the live link mode rather than configured once.
And the values come from the PHY vendor rather than from measurement, for the reason Chapter 16.1 §19 established: measuring them needs an instrument better than the device. A datasheet figure with a stated uncertainty is the honest input; a number obtained by tuning until the offset looked right has absorbed the path's asymmetry into the PHY correction, and the two are then inseparable.
5. RTL 2 — Two-Step: Capture and Report
The simpler answer. The frame goes out untouched and a second message carries the value.
// -----------------------------------------------------------------------
// twostep_reporter -- takes a transmit timestamp from the FIFO and
// builds the Follow_Up that carries it.
//
// The Sync's own originTimestamp field is transmitted as ZERO and the
// twoStepFlag is set, so a conformant slave knows to wait. 16.2
// section 5's parked t2 is the other end of this.
// -----------------------------------------------------------------------
module twostep_reporter
import tsu_pkg::*;
(
input logic clk,
input logic rst_n,
// A Sync has been transmitted and its timestamp has come back.
input logic tx_ts_valid,
input logic [15:0] tx_ts_tag,
input ts_t tx_ts,
// The scheduler's record of which tag was which Sync.
input logic [15:0] pending_tag,
input logic [15:0] pending_seq,
input logic pending_valid,
input logic tx_ready,
output logic fu_request,
output logic [15:0] fu_seq,
output ots_t fu_precise_origin,
output logic [31:0] c_followups,
output logic [31:0] c_orphan_ts, // a timestamp with no pending Sync
output logic [31:0] latency_cycles // capture to Follow_Up transmitted
);
logic [31:0] age;
logic armed;
logic [15:0] armed_seq;
ots_t armed_ts;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
armed <= 1'b0; age <= '0;
fu_request <= 1'b0; fu_seq <= '0; fu_precise_origin <= '0;
c_followups <= '0; c_orphan_ts <= '0; latency_cycles <= '0;
armed_seq <= '0; armed_ts <= '0;
end else begin
fu_request <= 1'b0;
if (tx_ts_valid) begin
if (pending_valid && (pending_tag == tx_ts_tag)) begin
armed <= 1'b1;
armed_seq <= pending_seq;
armed_ts <= to_wire(tx_ts);
age <= '0;
end else begin
// A transmit timestamp whose frame nobody is waiting for.
// Section 13: without tags this case is undetectable and
// the timestamp is attributed to the wrong frame.
c_orphan_ts <= c_orphan_ts + 1;
end
end
if (armed) begin
age <= age + 1;
if (tx_ready) begin
fu_request <= 1'b1;
fu_seq <= armed_seq;
fu_precise_origin <= armed_ts;
armed <= 1'b0;
c_followups <= c_followups + 1;
latency_cycles <= age;
end
end
end
end
endmoduleClassification: a one-deep report queue. No datapath, no timing pressure, and that is its entire advantage.
What it teaches: that two-step's latency does not matter and this is worth saying explicitly. latency_cycles can be thousands — a Follow_Up delayed by a millisecond loses nothing, because Chapter 16.2 §4 established that a general message carries a number and numbers do not decay. So the module has no timing requirement at all, which is why two-step is the answer for any device whose transmit path is already tight.
And it teaches that c_orphan_ts is the counter that catches the missing frame tag. A transmit timestamp arriving with no matching pending Sync means the scheduler's record and the timestamp unit's record have diverged — and without tags there is nothing to compare, so the timestamp is silently attributed to whatever Sync is pending. Section 13 is that problem in full.
Deliberately simplified: one armed slot, so a second transmit timestamp arriving before the first's Follow_Up is sent is lost. At 128 Sync/s and a transmit path that is ready within microseconds this never happens; on a device whose transmit queue is congested — Chapter 14.1 — it does, and the remedy is the same small FIFO Section 12 builds for the other direction.
Production implication: the Follow_Up must carry the same sequence ID as the Sync it describes, and pending_seq is where that comes from. A design that generates a fresh sequence ID for the Follow_Up produces a message the slave cannot match — Chapter 16.2 §5's c_orphan_fu at the far end — and the slave's c_stale_sync climbs while its c_followup also climbs, which is a confusing pair of symptoms for a one-line bug.
6. What Two-Step Costs in Messages
The cost is exactly one extra frame per Sync, and its size is where the accounting starts.
| Sync alone | Sync + Follow_Up | |
|---|---|---|
| frames | 1 | 2 |
| octets on the wire | 84 | 168 |
| at 1 Sync/s | 672 bit/s | 1344 bit/s |
| at 16 Sync/s | 10 752 bit/s | 21 504 bit/s |
| at 128 Sync/s | 86 016 bit/s | 172 032 bit/s |
| transmit timestamps needed | 1 per Sync | 1 per Sync — unchanged |
| transmit frames the master must schedule | 1 | 2 |
Row six is the one people get wrong. Two-step does not need more timestamps — the Sync's transmit timestamp is captured either way. What it needs is a second frame to carry it, which is a scheduling and bandwidth cost rather than a timestamping one.
And the delta at the deployment scale that matters:
| Deployment | One-step | Two-step | Extra |
|---|---|---|---|
| 1 slave, 16 Sync/s | 33 382 bit/s | 44 134 bit/s | 10 752 bit/s |
| 1 slave, 128 Sync/s | 266 342 bit/s | 352 358 bit/s | 86 016 bit/s |
| 1024 slaves, 128 Sync/s | 131 072 msg/s | 131 200 msg/s | 128 msg/s |
The last row is the surprise and it is a consequence of multicast. Sync and Follow_Up are both sent to the PTP multicast group, so one Follow_Up serves every slave — the extra cost of two-step at a thousand slaves is 128 messages per second, not 131 072. Chapter 16.2 §18's master load is dominated by unicast Delay_Resps, and two-step barely touches it.
Which reverses the intuition about where one-step pays. It does not pay at scale on the master's message count. It pays on a link whose bandwidth is genuinely tight — a 10 Mb/s industrial segment where 21 504 bit/s is 0.2% of the link — and on a slave-side transmit path where every frame scheduled is a frame not carrying data.
And it pays in one more place that the table cannot show: a two-step exchange has a window in which the slave holds t2 and does not yet have t1 — Chapter 16.2 §5's parked slot, 48 octets of state and a c_stale_sync counter. One-step publishes on the Sync itself and has no pending state at all.
7. RTL 3 — One-Step: Rewriting a Field in Flight
The harder answer, and the module that makes the rest of the chapter necessary.
// -----------------------------------------------------------------------
// onestep_field_rewriter -- substitutes the captured transmit timestamp
// into the outgoing frame's originTimestamp field as it streams past.
//
// The datapath is W octets wide. The field is 10 octets at a known
// offset, so it may span one or two beats. Both cases must be handled
// and the two-beat case is the one that is usually wrong.
// -----------------------------------------------------------------------
module onestep_field_rewriter
import tsu_pkg::*;
#(
parameter int W = 8 // octets per beat
)(
input logic clk,
input logic rst_n,
// The outgoing stream, before substitution.
input logic in_valid,
input logic [W*8-1:0] in_data,
input logic in_sop,
input logic in_eop,
input logic [$clog2(W+1)-1:0] in_bytes, // valid octets on eop
// Which frame this is and where its field sits.
input logic frame_is_onestep_sync,
input logic [15:0] field_offset, // octets from frame start
input ots_t new_ts, // to_wire(captured t1)
output logic out_valid,
output logic [W*8-1:0] out_data,
output logic out_sop,
output logic out_eop,
output logic [$clog2(W+1)-1:0] out_bytes,
// The delta the FCS patch needs: old XOR new, positioned.
output logic delta_valid,
output ots_t delta_bits,
output logic [15:0] delta_offset,
output logic [31:0] c_rewritten,
output logic [31:0] c_split_across_beats
);
logic [15:0] octet_ctr;
ots_t old_captured;
logic [$clog2(OTS_BITS+1)-1:0] captured_bits;
// For each octet lane in this beat, is it inside the field?
logic [W-1:0] lane_in_field;
logic [W-1:0][7:0] lane_new;
logic [W-1:0][7:0] lane_old;
always_comb begin
int L, pos;
for (L = 0; L < W; L++) begin
pos = int'(octet_ctr) + L;
lane_in_field[L] = frame_is_onestep_sync &&
(pos >= int'(field_offset)) &&
(pos < int'(field_offset) + 10);
lane_old[L] = in_data[8*L +: 8];
// Octet (pos - field_offset) of the 10-octet field, MSB first.
lane_new[L] = lane_in_field[L]
? new_ts[OTS_BITS - 8*(pos - int'(field_offset)) - 1 -: 8]
: in_data[8*L +: 8];
end
end
always_ff @(posedge clk or negedge rst_n) begin
int L;
if (!rst_n) begin
octet_ctr <= '0; out_valid <= 1'b0; out_data <= '0;
out_sop <= 1'b0; out_eop <= 1'b0; out_bytes <= '0;
delta_valid <= 1'b0; delta_bits <= '0; delta_offset <= '0;
old_captured <= '0; captured_bits <= '0;
c_rewritten <= '0; c_split_across_beats <= '0;
end else begin
out_valid <= in_valid;
out_sop <= in_sop;
out_eop <= in_eop;
out_bytes <= in_bytes;
delta_valid <= 1'b0;
if (in_valid) begin
// Substitute.
for (L = 0; L < W; L++) out_data[8*L +: 8] <= lane_new[L];
// Accumulate the OLD field content, which the FCS patch needs.
for (L = 0; L < W; L++) begin
if (lane_in_field[L]) begin
automatic int k;
k = int'(octet_ctr) + L - int'(field_offset);
old_captured[OTS_BITS - 8*k - 1 -: 8] <= lane_old[L];
captured_bits <= captured_bits + 8;
end
end
octet_ctr <= in_sop ? 16'(W) : (octet_ctr + 16'(W));
// Once the whole field has passed, the delta is complete and
// the patch can be computed. On a wide datapath this is the
// SAME beat the FCS is in -- section 8's callout.
if (frame_is_onestep_sync &&
((int'(octet_ctr) + W) >= (int'(field_offset) + 10)) &&
((int'(octet_ctr)) < (int'(field_offset) + 10))) begin
delta_valid <= 1'b1;
delta_offset <= field_offset;
c_rewritten <= c_rewritten + 1;
if (int'(octet_ctr) <= int'(field_offset))
; // the field fitted in one beat
else
c_split_across_beats <= c_split_across_beats + 1;
end
if (in_eop) begin
octet_ctr <= '0;
captured_bits <= '0;
end
end
end
end
// delta = old XOR new, which is zero outside the field by
// construction. This is the only nonzero input the patch needs.
always_comb delta_bits = old_captured ^ new_ts;
endmoduleClassification: a per-lane multiplexer with an accumulator for the displaced content. Combinational substitution, registered output.
What it teaches: that the old field content must be captured, not discarded, and that requirement is what makes the FCS patch cheap. The delta is old XOR new, and a CRC's linearity means the patch depends only on the delta and its position — Section 8. A design that overwrites the field without keeping what was there must recompute the CRC from the whole frame, which needs the frame buffered and costs 512 ns at 1 Gb/s.
And it teaches that a 10-octet field on a W-octet datapath spans one beat or two, and the two-beat case is where implementations break. With W = 8, the field at offset 48 occupies octets 48–57, which is beats 6 and 7 — so the substitution, the old-content capture and the patch's readiness all straddle a beat boundary. c_split_across_beats exists to make the case visible in simulation, because a design tested only with a field that happens to align is a design tested on half its logic.
Deliberately simplified: field_offset is an input, computed elsewhere from the frame's type and whether it carries a Chapter 13.2 tag. That computation is the same tag-dependent offset arithmetic Chapter 16.2 §3's parser needed, and getting it wrong here is worse: the rewriter substitutes ten octets of timestamp into the middle of the PTP header, producing a frame that is well-formed, correctly checksummed after the patch, and semantically destroyed.
Production implication: frame_is_onestep_sync must be false for every frame that is not a one-step Sync, and the classification arrives from a parser several cycles upstream. A design that gates on "is this a Sync" without checking the one-step flag will rewrite a two-step Sync's originTimestamp — which a conformant slave ignores, so the damage is invisible until the deployment changes to one-step and the field is now written twice.
8. RTL 4 — Fixing the FCS Mid-Frame
The patch. It is the chapter's centre and it is six lines of arithmetic resting on one property of Chapter 6.2's CRC.
// -----------------------------------------------------------------------
// fcs_patcher -- corrects the frame check sequence after octets were
// substituted, WITHOUT recomputing it from the frame.
//
// CRC-32 is linear over GF(2): CRC(A xor B) = CRC(A) xor CRC(B).
// So if the frame changed by `delta` at a known position, the new FCS
// is the old FCS xor CRC(delta placed at that position) -- and that is
// a FIXED linear map from 80 delta bits to 32 FCS bits.
//
// No frame buffer. No full CRC engine. One matrix multiply.
// -----------------------------------------------------------------------
module fcs_patcher
import tsu_pkg::*;
#(
// Distance in octets from the END of the substituted field to the
// start of the FCS. For a 64-octet Sync with the field at 48..57
// and the FCS at 60..63, this is 2.
parameter int TRAILING_OCTETS = 2
)(
input logic clk,
input logic rst_n,
input logic delta_valid,
input ots_t delta_bits, // old XOR new, 80 bits
input logic fcs_valid,
input logic [31:0] fcs_in,
output logic fcs_out_valid,
output logic [31:0] fcs_out,
output logic [31:0] c_patched,
output logic patch_without_delta // an FCS arrived unpatched
);
// The GF(2) matrix. Row j of PATCH_M is the set of delta bits whose
// parity forms bit j of the CRC contribution. It depends only on the
// polynomial and on TRAILING_OCTETS, so it is a compile-time
// constant -- generated the same way 6.4's parallel CRC tables are.
//
// Shown symbolically: a real design emits the 32 x 80 bit pattern
// from a script, exactly as chapter 6.4 emits its per-width tables.
function automatic logic [31:0] crc32_of_positioned_delta(input ots_t d);
logic [31:0] r;
int i;
begin
// Feed the delta through the CRC shift register, then advance
// TRAILING_OCTETS zero octets to position it. Written serially
// for clarity; synthesis unrolls it into the matrix above.
r = 32'h0000_0000; // a DELTA has no init value
for (i = OTS_BITS-1; i >= 0; i--) begin
if (r[31] ^ d[i]) r = {r[30:0], 1'b0} ^ 32'h04C1_1DB7;
else r = {r[30:0], 1'b0};
end
for (i = 0; i < TRAILING_OCTETS*8; i++) begin
if (r[31]) r = {r[30:0], 1'b0} ^ 32'h04C1_1DB7;
else r = {r[30:0], 1'b0};
end
crc32_of_positioned_delta = r;
end
endfunction
logic [31:0] held_patch;
logic have_patch;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
held_patch <= '0; have_patch <= 1'b0;
fcs_out_valid <= 1'b0; fcs_out <= '0;
c_patched <= '0; patch_without_delta <= 1'b0;
end else begin
fcs_out_valid <= 1'b0;
patch_without_delta <= 1'b0;
if (delta_valid) begin
// A zero delta means the field already held the right value --
// possible, and the patch is then the identity.
held_patch <= crc32_of_positioned_delta(delta_bits);
have_patch <= 1'b1;
end
if (fcs_valid) begin
fcs_out_valid <= 1'b1;
if (have_patch) begin
fcs_out <= fcs_in ^ held_patch;
have_patch <= 1'b0;
c_patched <= c_patched + 1;
end else begin
// No substitution happened: pass the FCS through unchanged.
fcs_out <= fcs_in;
end
end
// A one-step Sync whose FCS arrived with no delta computed is a
// frame that will be discarded by every receiver -- 6.3's
// residue check. It must be counted, loudly.
if (fcs_valid && !have_patch && delta_expected)
patch_without_delta <= 1'b1;
end
end
endmoduleClassification: a GF(2) matrix multiply and an XOR. Zero added latency, no frame buffer, and it replaces a full CRC engine.
What it teaches: that linearity is the whole trick and it is worth stating as an identity. CRC(A ⊕ B) = CRC(A) ⊕ CRC(B) — so a frame that changed from F to F ⊕ Δ has a CRC that changed from CRC(F) to CRC(F) ⊕ CRC(Δ). The patch is CRC(Δ), and Δ is zero everywhere except the ten octets that changed. So the map from 80 delta bits to 32 patch bits is a fixed 32 × 80 GF(2) matrix, computable at elaboration exactly as Chapter 6.4 computes its per-width tables.
And it teaches why r starts at zero rather than at 0xFFFFFFFF. Chapter 6.2's CRC-32 initialises its register to all ones and complements its output, and both of those are constants that cancel in a difference. The patch is a CRC of a delta, not of a frame — so the initial value and the final complement must be omitted, and including them produces a patch that is wrong by a fixed 32-bit value on every frame. The symptom is that every patched frame fails its receiver's residue check, which at least fails loudly.
Deliberately simplified: the CRC loop is written bit-serially for readability and is intended to be fully unrolled. The real implementation emits the 32 × 80 bit matrix from a generator script — the same tooling Chapter 6.4 uses — and the resulting logic is 32 XOR trees over an average of 40 terms each: roughly 1248 XOR2 gates and a depth of 6.
Production implication: TRAILING_OCTETS is a parameter and it must match the actual distance from the field's end to the FCS. For a 64-octet untagged Sync it is 2; for a tagged one the frame is 68 octets and it is still 2; for a Delay_Resp with the field elsewhere it differs — and a wrong value produces a patch that is a valid CRC of a differently-positioned delta, so the frame is consistently and wrongly checksummed. Every receiver discards it, patch_without_delta does not fire, and the symptom is a Sync stream that vanishes.
==
9. The FCS Recomputation, Priced
Two ways to make the FCS right after a substitution, and they differ by a factor that decides whether one-step is viable at all.
| A — buffer and recompute | B — patch the FCS | |
|---|---|---|
| what it needs | the whole frame buffered | the delta and its position |
| logic | a full CRC-32 engine — Chapter 6.4 | a 32 × 80 GF(2) matrix |
| gates | thousands | ≈1248 XOR2 |
| storage | 64 octets — 512 flops | 80 flops for the old content |
| added latency at 1 Gb/s | 512 ns | 0 |
| added latency at 100 Gb/s | 5.12 ns | 0 |
| against Chapter 12.1 §12's 28 ns budget | 18× over at 1 Gb/s | fits trivially |
Row five is the one that settles it. A store-and-forward buffer for the patch adds 512 ns at 1 Gb/s to a mechanism whose target is 10 ns — and it adds it after the timestamp was captured, so it does not corrupt the value, it delays the frame. Which matters because Chapter 16.2 §6's asymmetry is exactly a difference in delay between two directions, and a device that buffers Syncs and not Delay_Reqs has created 512 ns of asymmetry and 256 ns of offset error by trying to be careful.
Approach B has no such effect because it adds nothing to the frame's transit at all.
And the timing question is the interesting one, because the serial view in Section 2 said there were 0.16 ns at 100 Gb/s.
| Line rate | Datapath | Clock | Period | A 64-octet Sync spans |
|---|---|---|---|---|
| 1 Gb/s | 8-bit | 125 MHz | 8.00 ns | 64 beats |
| 10 Gb/s | 64-bit | 156.25 MHz | 6.40 ns | 8 beats |
| 25 Gb/s | 64-bit | 390.6 MHz | 2.56 ns | 8 beats |
| 100 Gb/s | 512-bit | 195.3 MHz | 5.12 ns | 1 beat |
At 100 Gb/s the whole frame is one beat, so the field, the pad and the FCS are all in the same cycle — and the 0.16 ns serial gap does not exist as a timing constraint. What exists instead is a requirement to compute the patch combinationally within one 5.12 ns cycle, from a delta formed in the same cycle.
A depth-6 XOR tree in a modern process is a few hundred picoseconds. The substitution's multiplexers are one level. The whole path — extract old, XOR with new, matrix multiply, XOR into the FCS — is comfortably inside 5.12 ns, and it is comfortably inside 2.56 ns at 25 Gb/s too.
Which is the resolution of Section 2's apparent impossibility, and the general lesson is worth extracting: a serial timing analysis of a wide datapath produces constraints that do not exist. The 0.16 ns figure is real as a statement about the wire and meaningless as a statement about the logic, because the logic never sees those two octets separately.
10. RTL 5 — The Correction Field and Its Arithmetic
The same in-flight rewrite, applied to a different field, by a device in the middle rather than at the end.
// -----------------------------------------------------------------------
// correction_field_updater -- adds a value to the 8-octet
// correctionField as a frame streams past, and patches the FCS.
//
// This is section 7's rewriter and section 8's patcher applied to a
// different offset and a different width. The VALUE added is 16.4
// section 12's residence time; this module is the mechanism.
//
// correctionField is signed 16.16 fixed point: nanoseconds in the
// upper 48 bits, fractional nanoseconds in the lower 16.
// -----------------------------------------------------------------------
module correction_field_updater
import tsu_pkg::*;
#(
parameter int W = 8
)(
input logic clk,
input logic rst_n,
input logic in_valid,
input logic [W*8-1:0] in_data,
input logic in_sop,
input logic in_eop,
input logic frame_is_ptp_event,
input logic [15:0] correction_offset, // octets from frame start
input logic signed [63:0] add_value, // 16.16 fixed point
output logic out_valid,
output logic [W*8-1:0] out_data,
output logic out_sop,
output logic out_eop,
output logic delta_valid,
output logic [63:0] delta_bits,
output logic overflow, // the field saturated
output logic [31:0] c_updated,
output logic [31:0] c_overflow
);
logic [15:0] octet_ctr;
logic [63:0] old_field, new_field;
logic [W-1:0] lane_in_field;
always_comb begin
int L, pos;
for (L = 0; L < W; L++) begin
pos = int'(octet_ctr) + L;
lane_in_field[L] = frame_is_ptp_event &&
(pos >= int'(correction_offset)) &&
(pos < int'(correction_offset) + 8);
end
end
// The added value is signed and the field is signed, so the sum can
// overflow in either direction. Saturating is wrong -- it produces a
// plausible number -- so the design must SAY it overflowed.
logic signed [64:0] sum_ext;
always_comb begin
sum_ext = $signed({old_field[63], old_field}) + $signed({add_value[63], add_value});
new_field = sum_ext[63:0];
end
always_ff @(posedge clk or negedge rst_n) begin
int L;
if (!rst_n) begin
octet_ctr <= '0; old_field <= '0;
out_valid <= 1'b0; out_data <= '0;
out_sop <= 1'b0; out_eop <= 1'b0;
delta_valid <= 1'b0; delta_bits <= '0;
overflow <= 1'b0; c_updated <= '0; c_overflow <= '0;
end else begin
out_valid <= in_valid;
out_sop <= in_sop;
out_eop <= in_eop;
delta_valid <= 1'b0;
overflow <= 1'b0;
if (in_valid) begin
for (L = 0; L < W; L++) begin
automatic int pos, k;
pos = int'(octet_ctr) + L;
k = pos - int'(correction_offset);
if (lane_in_field[L]) begin
old_field[63 - 8*k -: 8] <= in_data[8*L +: 8];
out_data[8*L +: 8] <= new_field[63 - 8*k -: 8];
end else begin
out_data[8*L +: 8] <= in_data[8*L +: 8];
end
end
octet_ctr <= in_sop ? 16'(W) : (octet_ctr + 16'(W));
if (frame_is_ptp_event &&
((int'(octet_ctr) + W) >= (int'(correction_offset) + 8)) &&
((int'(octet_ctr)) < (int'(correction_offset) + 8))) begin
delta_valid <= 1'b1;
delta_bits <= old_field ^ new_field;
c_updated <= c_updated + 1;
// Sign-extension mismatch between the 65-bit sum and the
// 64-bit field means the correction no longer fits.
if (sum_ext[64] != sum_ext[63]) begin
overflow <= 1'b1;
c_overflow <= c_overflow + 1;
end
end
if (in_eop) octet_ctr <= '0;
end
end
end
endmoduleClassification: a read-modify-write on a frame in flight, with an overflow flag. The same shape as Section 7 with an adder in the middle.
What it teaches: that the correction field is accumulated rather than written, and that is what lets an arbitrary number of transparent clocks compose. Each switch adds its own residence time to whatever is already there, so a message crossing five transparent clocks arrives with the sum of five residence times — and Chapter 16.2 §7's calculation subtracts the total. No switch needs to know how many others there are, or which position it occupies in the path.
And it teaches that saturating on overflow is the wrong behaviour, which is unusual. Most datapath arithmetic saturates because a clipped value is closer to the truth than a wrapped one. Here a clipped correction produces a plausible path delay that is silently too small, and Chapter 16.2 §7's arithmetic will accept it. An overflow must be reported, because the correct response is to drop the message rather than to deliver a wrong one — a 64-bit signed field in 16.16 format covers ±140 years, so an overflow means something is badly wrong rather than that the path is long.
Deliberately simplified: the field is read and written in the same pass, which requires the whole 8-octet field to be visible before the substitution is emitted. With W = 8 and an aligned offset that is one beat and works; with a misaligned offset it spans two beats and the first beat's output must be emitted before the second beat's input is seen. Production designs delay the stream by one beat — which costs 6.4 ns at 10 Gb/s and is the honest price of a read-modify-write on a streaming interface.
Production implication: frame_is_ptp_event gates this on event messages only, and applying it to a general message is a real bug with a subtle signature. A Follow_Up's correctionField is meaningful — it corrects the Sync it describes — but its residence time is not part of the path measurement, because the Follow_Up is not the frame that was timed. A transparent clock that adds its residence time to a Follow_Up corrupts a correction that was already correct, and the error is exactly one switch's residence time per hop.
11. One-Step Against Two-Step, Accounted
Both are conformant, both are deployed, and the choice is decided by which resource a device is short of.
| Two-step | One-step | |
|---|---|---|
| extra frames per Sync | 1 | 0 |
| bandwidth at 16 Sync/s | +10 752 bit/s | — |
| bandwidth at 128 Sync/s | +86 016 bit/s | — |
| extra messages, 1024 slaves at 128/s | +128/s — multicast serves all | — |
| transmit timestamps captured | 1 per Sync | 1 per Sync — same |
| slave pending state | 48 octets + c_stale_sync | none |
| transmit datapath logic | none | ≈1248 XOR2, 80 muxes, 80 flops |
| transmit timing path | untouched | depth-6 XOR in the output cycle |
| frame buffering | none | none, with the patch |
| tolerance to a late report | unlimited | not applicable |
| a lost report | c_stale_sync, recover next Sync | impossible |
| risk of a wrong field offset | low | a corrupted header — Section 7 |
Rows seven and eight are one-step's entire cost and they are both in the transmit path, which is the resource a small device is short of. Row six is two-step's, and it is in the slave.
Which produces a clean allocation and it is the one real deployments use:
A grandmaster or a switch — a device with a wide datapath, a hardware transmit scheduler and plenty of area — takes one-step. The gates are cheap at that size, the timing fits, and removing the Follow_Up removes a message the master must schedule per Sync per domain.
A small embedded slave takes two-step, because it has no transmit-side PTP hardware at all and 48 octets of pending state is nothing. And it does not matter: a slave sends Delay_Reqs, whose t3 never goes on the wire — Chapter 16.2 §11 — so a slave needs no in-flight rewrite in either mode.
And the third case is the one that makes the flag necessary. A network mixes them. A master may be one-step and a slave must handle both — which is why Chapter 16.2 §5's twoStepFlag check is not optional and why both of its wrong assumptions produce a slave that never locks.
One more asymmetry is worth naming. The correctionField rewrite in Section 10 is not optional for a transparent clock — a switch cannot report its residence time in a separate message, because there is no message to send it in. So a transparent clock is always a one-step device for that field, even if the grandmaster it forwards for is two-step. The mechanism this chapter built for one-step Sync is required regardless.
12. RTL 6 — The Transmit Timestamp FIFO
A transmit timestamp arrives after the frame that caused it. Something must hold it, and something must know which frame it belongs to.
// -----------------------------------------------------------------------
// tx_timestamp_fifo -- holds transmit timestamps with their frame tags
// until the protocol layer collects them.
//
// 16.1 section 12's simplification was a single capture register with
// no tag. This module fixes both halves: a queue, so a burst does not
// lose entries, and a tag, so an entry cannot be attributed to the
// wrong frame.
// -----------------------------------------------------------------------
module tx_timestamp_fifo
import tsu_pkg::*;
#(
parameter int DEPTH = 8
)(
input logic clk,
input logic rst_n,
// From the capture point.
input logic push,
input logic [15:0] push_tag,
input ts_t push_ts,
// The protocol layer asks for a specific tag rather than taking the
// head. A burst can complete out of order and order is not the
// property that matters -- identity is.
input logic lookup_valid,
input logic [15:0] lookup_tag,
output logic hit,
output ts_t hit_ts,
output logic [$clog2(DEPTH+1)-1:0] occupancy,
output logic full,
output logic [31:0] c_pushed,
output logic [31:0] c_dropped, // pushed while full
output logic [31:0] c_evicted, // aged out unclaimed
output logic [31:0] c_miss // looked up and not present
);
tx_ts_entry_t e [DEPTH];
logic [15:0] age [DEPTH];
logic [$clog2(DEPTH+1)-1:0] occ;
// Any entry nobody claims must eventually leave, or a single
// abandoned frame permanently consumes a slot.
localparam int MAX_AGE = 16'hF000;
always_ff @(posedge clk or negedge rst_n) begin
int i;
automatic bit placed;
if (!rst_n) begin
for (i = 0; i < DEPTH; i++) begin e[i] <= '0; age[i] <= '0; end
occ <= '0; hit <= 1'b0; hit_ts <= '0;
c_pushed <= '0; c_dropped <= '0; c_evicted <= '0; c_miss <= '0;
end else begin
hit <= 1'b0;
for (i = 0; i < DEPTH; i++) begin
if (e[i].valid) begin
age[i] <= age[i] + 1'b1;
if (age[i] == 16'(MAX_AGE)) begin
e[i].valid <= 1'b0;
occ <= occ - 1'b1;
c_evicted <= c_evicted + 1;
end
end
end
if (push) begin
placed = 1'b0;
for (i = 0; i < DEPTH; i++) begin
if (!e[i].valid && !placed) begin
placed = 1'b1;
e[i].valid <= 1'b1;
e[i].frame_tag <= push_tag;
e[i].ts <= push_ts;
age[i] <= '0;
occ <= occ + 1'b1;
c_pushed <= c_pushed + 1;
end
end
// Dropping the NEWEST is the right policy: an old entry may
// still be claimed, and a dropped new one is detectable at
// the protocol layer as a missing timestamp for a known frame.
if (!placed) c_dropped <= c_dropped + 1;
end
if (lookup_valid) begin
automatic bit found;
found = 1'b0;
for (i = 0; i < DEPTH; i++) begin
if (e[i].valid && (e[i].frame_tag == lookup_tag)) begin
found = 1'b1;
hit <= 1'b1;
hit_ts <= e[i].ts;
e[i].valid <= 1'b0;
occ <= occ - 1'b1;
end
end
if (!found) c_miss <= c_miss + 1;
end
end
end
assign occupancy = occ;
assign full = (occ == DEPTH);
endmoduleClassification: a small tag-addressed store with an age-out. Eight entries, associative lookup, not a FIFO despite the name.
What it teaches: that order is not the property that matters and a true FIFO is the wrong structure. Transmit completions can arrive out of order — a transmit scheduler with several queues, Chapter 13.4 §11, completes a high-priority frame before a low-priority one that was queued earlier — and the protocol layer asks for a specific frame's timestamp rather than for the oldest. Lookup by tag is correct; pop is not.
And it teaches that dropping the newest on overflow is deliberate and counter-intuitive. The usual policy is to drop the oldest and keep the fresh data. Here an old entry may still be claimed by a protocol layer that is a few microseconds behind, and a dropped new entry surfaces immediately at the protocol layer as c_miss for a frame it knows it sent. Dropping the oldest converts a detectable miss into a silent substitution.
Deliberately simplified: the lookup is a linear scan over eight entries evaluated in one cycle, which is fine at eight and not at sixty-four. A grandmaster serving a thousand slaves needs depth for a Delay_Resp burst — Chapter 16.2 §11's callout — and at that depth the structure becomes Chapter 12.5's set-associative table indexed by the tag's low bits, with the same interesting property Section 9 of that chapter described: a collision here is a correctness failure rather than a capacity one.
Production implication: c_evicted and c_miss together diagnose a protocol layer that is too slow. c_evicted rising means timestamps are being captured and never collected — the software or firmware that should read them is not keeping up, and the Follow_Ups it should have generated are simply absent. At the far end that is Chapter 16.2 §5's c_stale_sync climbing with c_sync, which looks like a network problem and is a local scheduling one.
13. Pairing a Timestamp to a Frame
Section 12 solved this with a 16-bit tag. This section is the argument for why the tag is not optional, because a great many designs omit it and appear to work.
Without a tag, a timestamp and a frame are associated by order: the nth timestamp belongs to the nth frame.
That is correct until any one of five things happens.
| Event | What order-based pairing does |
|---|---|
| a capture overrun — Chapter 16.1 §12 | every later pairing is off by one |
| a FIFO drop | the same |
| out-of-order transmit completion | two pairings swapped |
| a frame aborted after its SFD | one extra timestamp, all later ones shifted |
| the protocol layer restarting | its count and the hardware's diverge |
Rows one, two and four are the ones that occur, and their consequence is the same: a permanent off-by-one that no check detects.
And the resulting error's size is the part worth internalising, because it is not small and it is not constant.
| Interval between the two frames | t1 error |
|---|---|
| back-to-back at 1 Gb/s | 672 ns |
| back-to-back at 100 Gb/s | 6.72 ns |
| one Sync interval at 128/s | 7.81 ms |
| one Sync interval at 1/s | 1 second |
The last two rows are the common case, because PTP messages are not back-to-back. A Sync stream at 1/s with an off-by-one pairing gives every Sync the previous Sync's transmit timestamp — so t1 is one second stale and the computed offset is off by one second. Which, unusually, is loud: the slave's servo sees a one-second error and refuses to lock.
The dangerous case is the middle rows. On a busy link where a Sync happens to be adjacent to other frames, an off-by-one gives t1 a value from a frame microseconds or nanoseconds away — and the resulting offset error is a few hundred nanoseconds, which looks exactly like a path asymmetry and is indistinguishable from one. Chapter 16.2 §8's calibration procedure will faithfully absorb it into a stored constant, and the constant will be wrong the moment the traffic pattern changes.
So the tag's 16 bits buy the difference between an error that is diagnosable and an error that is absorbed into a calibration.
And the tag has to come from the right place: the transmit scheduler, not the protocol layer. The scheduler is what decides the order frames actually leave in — Chapter 13.4 §11 — so it is the only component that can label a frame in a way the capture point and the protocol layer will both agree on. A tag assigned by the protocol layer and carried through a reordering scheduler is a tag that identifies intent rather than reality.
==
14. RTL 7 — Timestamp Unit Telemetry
Six numbers, and the useful ones describe the unit's discipline rather than its output.
// -----------------------------------------------------------------------
// tsu_telemetry -- what an operator or an integrator needs to decide
// whether a timestamp unit is behaving, without a reference clock.
//
// Every quantity here is derived from the unit's own behaviour.
// 16.1 section 19 established that accuracy cannot be among them.
// -----------------------------------------------------------------------
module tsu_telemetry
import tsu_pkg::*;
(
input logic clk,
input logic rst_n,
input logic capture,
input logic classify,
input logic ts_published,
input logic overrun,
input logic [31:0] c_patched,
input logic [31:0] c_rewritten,
input logic [31:0] c_dropped,
input logic [31:0] c_miss,
input logic [31:0] c_evicted,
input logic [$clog2(9)-1:0] fifo_occupancy,
input logic window_tick,
output logic [15:0] capture_to_classify_cycles,
output logic [15:0] worst_capture_to_classify,
output logic [15:0] peak_occupancy,
output logic [15:0] patch_ratio_x100, // patched / rewritten
output logic pairing_is_sound,
output logic unit_is_keeping_up,
output logic [31:0] c_windows
);
logic [15:0] lat;
logic timing;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
lat <= '0; timing <= 1'b0;
capture_to_classify_cycles <= '0;
worst_capture_to_classify <= '0;
peak_occupancy <= '0; patch_ratio_x100 <= '0;
pairing_is_sound <= 1'b0; unit_is_keeping_up <= 1'b0;
c_windows <= '0;
end else begin
// The interval between capturing and classifying sets the FIFO
// depth the unit needs. It is a pipeline property and nobody
// measures it, so the depth is usually a guess.
if (capture) begin timing <= 1'b1; lat <= '0; end
else if (timing) begin
lat <= lat + 1'b1;
if (classify) begin
timing <= 1'b0;
capture_to_classify_cycles <= lat;
if (lat > worst_capture_to_classify)
worst_capture_to_classify <= lat;
end
end
if (16'(fifo_occupancy) > peak_occupancy)
peak_occupancy <= 16'(fifo_occupancy);
if (window_tick) begin
// Every rewritten frame must have been patched. A ratio other
// than 100 means frames left with a stale FCS and every
// receiver discarded them -- 6.3's residue check.
patch_ratio_x100 <= (c_rewritten == 0) ? 16'd100
: 16'((c_patched * 100) / c_rewritten);
// Pairing is sound if nothing was dropped, evicted or missed.
// Any of the three means a timestamp and a frame may have
// been mismatched -- section 13.
pairing_is_sound <= (c_dropped == '0) && (c_evicted == '0) &&
(c_miss == '0);
// The unit keeps up if the queue never approached full and
// no capture was overwritten.
unit_is_keeping_up <= (peak_occupancy < 16'd7) && !overrun;
c_windows <= c_windows + 1;
end
end
end
endmoduleClassification: a latency and occupancy monitor with two derived judgements. It measures the unit, not the time.
What it teaches: that worst_capture_to_classify is the measurement that sizes the FIFO, and it is almost never taken. Section 12's depth of eight is a guess; the correct depth is the worst capture-to-classify latency times the peak event-message rate, and the first factor is a property of the parser pipeline that only the design knows. A design that measures it can size the FIFO from evidence — and can discover that at 100 Gb/s with a deep parse pipeline the answer is larger than eight.
And it teaches that patch_ratio_x100 must be exactly 100 and any other value is a silent catastrophe. A rewritten frame whose FCS was not patched fails Chapter 6.3's residue check at every receiver and is discarded — so the symptom is a Sync stream that vanishes, with the transmitting device's counters all healthy. The ratio is two counters and a divide, and it converts an invisible failure into a number.
Deliberately simplified: unit_is_keeping_up compares the peak occupancy against a hard-coded 7 out of 8. A production design parameterises the margin and, more usefully, reports the peak as a fraction — a unit running at 90% of its queue depth is one traffic change away from dropping, and "did not drop" is not the same finding as "has headroom".
Production implication: pairing_is_sound is a single bit that gates whether any offset computed downstream is worth believing, and it is the bit Section 13's argument makes necessary. A device reporting pairing_is_sound low has possibly mismatched a timestamp to a frame, and the resulting error looks like a path asymmetry — which Chapter 16.2 §8's calibration would absorb into a stored constant. So the bit's real job is to prevent a calibration from being taken while the pairing is unreliable.
15. RTL 8 — Conformance for a Timestamp Unit in Flight
The monitor checks the discipline that makes accuracy possible, and deliberately not accuracy.
// -----------------------------------------------------------------------
// tsu_conformance_monitor -- one bit.
//
// It asserts: fixed capture latency, correct classification, complete
// patching, sound pairing, and a characterised configuration. 16.1
// section 19 established why accuracy is not on the list and section
// 19 here refuses a different property for a different reason.
// -----------------------------------------------------------------------
module tsu_conformance_monitor
import tsu_pkg::*;
(
input logic clk,
input logic rst_n,
input logic variable_capture_latency,
input logic rewrote_a_general_message,
input logic rewrote_a_twostep_sync,
input logic patch_without_delta,
input logic correction_overflow,
input logic pairing_unsound,
input logic cfg_phy_delay_unset,
input logic cfg_phy_delay_stale, // the link mode changed
input logic cfg_lane_in_ns, // truncates to zero
output logic conformant,
output logic [15:0] fault_vector,
output logic [31:0] c_violations
);
logic v_lat, v_gen, v_two, v_patch, v_ovf, v_pair;
logic v_unset, v_stale, v_lane;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
v_lat <= 1'b0; v_gen <= 1'b0; v_two <= 1'b0;
v_patch <= 1'b0; v_ovf <= 1'b0; v_pair <= 1'b0;
v_unset <= 1'b0; v_stale <= 1'b0; v_lane <= 1'b0;
c_violations <= '0;
end else begin
// A variable capture latency is the one violation that destroys
// the mechanism rather than degrading it -- it reintroduces
// exactly the jitter 16.1 removed.
if (variable_capture_latency) begin v_lat <= 1'b1; c_violations <= c_violations + 1; end
if (rewrote_a_general_message) begin v_gen <= 1'b1; c_violations <= c_violations + 1; end
if (rewrote_a_twostep_sync) begin v_two <= 1'b1; c_violations <= c_violations + 1; end
if (patch_without_delta) begin v_patch <= 1'b1; c_violations <= c_violations + 1; end
if (correction_overflow) begin v_ovf <= 1'b1; c_violations <= c_violations + 1; end
if (pairing_unsound) begin v_pair <= 1'b1; c_violations <= c_violations + 1; end
// Standing configuration properties. 14.1 section 17's argument:
// these are wrong from elaboration, not from the first frame.
v_unset <= cfg_phy_delay_unset;
v_stale <= cfg_phy_delay_stale;
v_lane <= cfg_lane_in_ns;
end
end
assign conformant = !(v_lat || v_gen || v_two || v_patch || v_ovf ||
v_pair || v_unset || v_stale || v_lane);
assign fault_vector = {7'b0, v_lane, v_stale, v_unset, v_pair,
v_ovf, v_patch, v_two, v_gen, v_lat};
endmoduleClassification: a sticky aggregator with six runtime violations and three standing configuration terms.
What it teaches: that cfg_phy_delay_stale is a fault class Chapter 16.1 §17 did not have, and Section 4 is why it exists. That chapter's cfg_phy_delay_zero catches a correction nobody set. This one catches a correction that was right and is no longer — a link that renegotiated from 10 Gb/s to 1 Gb/s, or that enabled FEC, has changed its PHY delay by tens or hundreds of nanoseconds under a constant that was correct at commissioning. The check is a comparison of the live link mode against the mode the correction was indexed for.
And it teaches that rewrote_a_twostep_sync is separate from rewrote_a_general_message because the two have different blast radii. Rewriting a general message corrupts a field that mattered — Section 10's Follow_Up case. Rewriting a two-step Sync's originTimestamp corrupts a field every conformant slave ignores, so it is harmless today and becomes a double-write the day the deployment switches to one-step. A latent fault deserves its own bit.
Deliberately simplified: variable_capture_latency is presented as an input, and producing it means asserting that the interval from sfd to the capture register's update is always exactly one cycle. That is Chapter 16.1 §19's p_capture_latency_is_fixed as a runtime monitor, which is cheap — one comparator on a two-cycle history — and is worth having in silicon because a synthesis or placement change can introduce a retiming stage that no functional test detects.
Production implication: conformant here means the unit's discipline is sound: the capture is fixed-latency, the classification is right, every rewrite was patched, and the pairing is trustworthy. It says nothing about the accuracy achieved — Chapter 16.2 §8's asymmetry is still there, Chapter 16.5's terms are still there, and a fully conformant unit behind an uncorrected store-and-forward switch still delivers 6.07 µs of error. Which is the same separation Chapter 15.3 §15 drew between a fault and a finding, arriving here as a separation between discipline and outcome.
16. Where the Unit Sits in the Datapath
Three blocks have been built and they do not all sit in the same place. Putting them on the datapath makes the timing argument concrete.
| Block | Direction | Where | Why there |
|---|---|---|---|
| Section 3's capture | both | at the MAC/PHY boundary | the earliest characterisable point |
| Section 7's rewriter | transmit | after the MAC, before the PHY | it must see the frame after the FCS was computed |
| Section 8's patcher | transmit | on the FCS octets specifically | it patches, it does not recompute |
| Section 10's correction updater | transmit | the same place, a different field | a transparent clock's only mechanism |
| Section 12's FIFO | transmit | beside the datapath, not in it | it holds values, not frames |
Row two is the one that decides the architecture and it is counter-intuitive. The rewriter must sit downstream of the MAC's CRC generator, because its whole premise is that a frame with a valid FCS arrives and leaves with a different valid FCS. A rewriter placed upstream of the CRC generator needs no patcher at all — the MAC would compute the CRC over the already-substituted content.
And that placement is available and is sometimes the right answer. Its cost is that the substitution now happens before the frame's transmit timestamp exists:
| Rewriter placement | Needs a patcher? | Can substitute the real t1? |
|---|---|---|
| upstream of the CRC generator | no | no — t1 is not known yet |
| downstream, before the PHY | yes | yes |
Which is the whole reason the patcher exists. The timestamp is only available once the frame is physically leaving, and by then the CRC has been computed — so either the substitution happens too early to be correct, or it happens late and the CRC must be repaired. There is no third placement.
And the correction updater has the same constraint for a different reason. A transparent clock's residence time is the difference between a frame's arrival and its departure, so it is not known until the frame departs — the same self-referential shape, resolved the same way.
One consequence for verification is worth stating. The rewriter and the patcher are downstream of the MAC, so a testbench that drives the MAC and checks its output never exercises them. They must be verified on the PHY-side interface, with a model that recomputes the FCS from the emitted frame and compares — which is Section 20's directed test.
==
17. What the Unit Can and Cannot Promise
| Claim | Status |
|---|---|
| the capture is at the SFD | guaranteed |
| the capture latency is exactly one cycle | guaranteed, and it is the load-bearing one |
| the characterised PHY delay is subtracted | guaranteed, if the constant is right |
| a rewritten frame's FCS is valid | guaranteed — Section 8, and patch_ratio_x100 proves it |
| a timestamp belongs to the frame claimed | guaranteed with tags; not without |
| an arbitrary number of transparent clocks compose | guaranteed — the correction accumulates |
| the timestamp equals the true instant | no — a constant offset and the elastic buffer remain |
| the achieved accuracy | not a property of this unit |
Row two is the one everything else rests on, and it is worth saying why it is the structural promise rather than an implementation detail. Chapter 16.1 §9 established that a constant delay is removable and a variable one is not. A fixed capture latency is precisely the guarantee that the remaining delay is constant — which is what makes Section 4's characterisation meaningful. A unit with a variable capture latency has no characterisable delay and therefore no accuracy at any calibration.
And row five is the one designs omit. Section 13's arithmetic: without a tag, an off-by-one pairing produces an error of a few hundred nanoseconds on a busy link — indistinguishable from a path asymmetry, and absorbed by a calibration that will then be wrong.
Row seven is the honest limit and it has two parts. The constant part is the PHY delay, removable if characterised and stale-checked. The variable part is Chapter 4.4's elastic buffer at 5 to 20 ns, and nothing in this chapter touches it — Chapter 16.5 prices it with the protocol in place.
Which gives the chapter's closing position: this unit makes accuracy possible and does not deliver it. It removes the jitter Chapter 16.1 spent nine sections rejecting, and what it leaves behind is a constant to be characterised, an elastic buffer that cannot be, and Chapter 16.2 §8's asymmetry that this chapter never touched.
18. The Cost, Accounted
| Component | Cost | Against what |
|---|---|---|
| capture register, per direction | 80 flops + a subtract | — |
| transmit FIFO — 8 × (16 + 80) bits | 96 octets | — |
| Section 7's rewriter — 10-octet mux | 80 2:1 muxes + 80 flops | — |
| Section 8's patcher — 32 × 80 GF(2) | ≈1248 XOR2, depth 6 | replaces a full CRC-32 engine |
| Section 10's correction updater | 64-bit adder + 64 muxes + a 32 × 64 matrix | ≈1000 XOR2 |
| telemetry and conformance | ≈40 flops | — |
| total, one port, both directions | ≈2500 XOR2 + ≈400 flops | tiny against a MAC |
| added frame latency | zero | against 512 ns for buffer-and-recompute |
| added timing depth | 6 XOR levels | inside 5.12 ns at 100 Gb/s; tight at 2.56 ns |
Row eight is the number that makes one-step viable and it is the chapter's central engineering result. The naive implementation adds 512 ns at 1 Gb/s — and does so asymmetrically, which by Chapter 16.2 §6's arithmetic creates 256 ns of offset error in the act of trying to be precise. The patch adds nothing.
And the comparison across the module puts it in proportion:
| Mechanism | Cost | Where it lands |
|---|---|---|
| Chapter 16.2's exchange | ≈220 octets | arithmetic — nowhere |
| this unit | ≈2500 XOR2 + 400 flops | the transmit datapath's timing |
| Chapter 16.4's transparent clock | a residence timer per port | every switch in the path |
| Chapter 16.5's calibration | a constant per link | a commissioning procedure |
Two and a half thousand gates is a rounding error in a MAC — Chapter 19.1's block is orders of magnitude larger — and the cost that actually constrains a design is the timing depth, which is why the row above quotes it against two clock periods rather than against an area budget.
19. Properties Worth Asserting, and One Worth Refusing
The properties divide by block: the capture, the classification, the rewrite, the patch, the correction, the FIFO, and the configuration.
Group 1 — the capture.
// P1. THE load-bearing property: the capture latency is exactly one
// cycle, always. A variable latency reintroduces the jitter the whole
// of chapter 16.1 was about removing.
property p_capture_latency_is_one;
@(posedge clk) disable iff (!rst_n)
sfd |=> held_valid;
endproperty
// P2. And nothing else ever writes the capture register.
property p_no_spurious_capture;
@(posedge clk) disable iff (!rst_n)
$changed(held) |-> $past(sfd);
endproperty
// P3. The lane correction is applied in picoseconds, so it does not
// truncate to zero -- 16.1 section 12's simplification, fixed.
property p_lane_correction_nonzero;
@(posedge clk) disable iff (!rst_n)
(sfd && (sfd_lane != '0) && (cfg_lane_ps != '0))
|=> (held.ns != (now.ns - NS_W'(cfg_phy_delay_ns)));
endproperty
// P4. A second SFD before a classification sets overrun.
property p_overrun_flagged;
@(posedge clk) disable iff (!rst_n)
(sfd && held_valid) |=> overrun;
endproperty
// P5. Captures are monotonic -- checkable with no reference.
property p_captures_monotonic;
@(posedge clk) disable iff (!rst_n)
(ts_valid && $past(ts_valid)) |-> ((ts_out.ns >= $past(ts_out.ns)) ||
(ts_out.sec > $past(ts_out.sec)));
endpropertyGroup 2 — the classification.
// P6. Only event messages publish a timestamp. 16.2 section 4's split.
property p_only_events_publish;
@(posedge clk) disable iff (!rst_n)
ts_valid |-> $past(class_is_event);
endproperty
// P7. A general message's capture is discarded, not leaked.
property p_general_discards;
@(posedge clk) disable iff (!rst_n)
(class_valid && !class_is_event && held_valid) |=> $changed(c_discarded);
endproperty
// P8. Every capture is eventually resolved -- published or discarded.
property p_every_capture_resolved;
@(posedge clk) disable iff (!rst_n)
$rose(held_valid) |-> ##[1:$] !held_valid;
endproperty
// P9. Classification always follows capture, never precedes it.
property p_capture_precedes_classify;
@(posedge clk) disable iff (!rst_n)
class_valid |-> $past(held_valid) || overrun;
endpropertyGroup 3 — the rewrite.
// P10. Only a one-step Sync's originTimestamp is rewritten.
property p_rewrite_only_onestep_sync;
@(posedge clk) disable iff (!rst_n)
delta_valid |-> frame_is_onestep_sync;
endproperty
// P11. Octets outside the field pass through unchanged.
property p_outside_field_untouched;
@(posedge clk) disable iff (!rst_n)
(in_valid && !lane_in_field[L]) |=> (out_data[8*L +: 8] == $past(in_data[8*L +: 8]));
endproperty
// P12. The substituted content is exactly the captured timestamp in
// wire order -- MSB of seconds first.
property p_substitution_is_the_timestamp;
@(posedge clk) disable iff (!rst_n)
(in_valid && lane_in_field[L]) |=>
(out_data[8*L +: 8] == new_ts[OTS_BITS - 8*k - 1 -: 8]);
endproperty
// P13. The delta is zero outside the field, by construction.
property p_delta_is_confined;
@(posedge clk) disable iff (!rst_n)
delta_valid |-> ((delta_bits & ~field_mask) == '0);
endproperty
// P14. A field spanning two beats is handled and counted.
property p_split_field_counted;
@(posedge clk) disable iff (!rst_n)
(delta_valid && (octet_ctr > field_offset)) |=> $changed(c_split_across_beats);
endpropertyGroup 4 — the patch.
// P15. Every rewritten frame's FCS is patched. A ratio other than
// 100% means frames left with a stale FCS and every receiver
// discarded them -- 6.3's residue check.
property p_every_rewrite_is_patched;
@(posedge clk) disable iff (!rst_n)
delta_valid |-> ##[1:$] (fcs_out_valid && $past(have_patch));
endproperty
// P16. The patch is linear: patching with a zero delta is the
// identity.
property p_zero_delta_is_identity;
@(posedge clk) disable iff (!rst_n)
(fcs_valid && have_patch && (held_patch == '0)) |=> (fcs_out == $past(fcs_in));
endproperty
// P17. Linearity, stated directly: the patch for A xor B is the xor
// of the patches. This is the property the whole module rests on.
property p_patch_is_linear;
@(posedge clk) disable iff (!rst_n)
(crc32_of_positioned_delta(a ^ b) ==
(crc32_of_positioned_delta(a) ^ crc32_of_positioned_delta(b)));
endproperty
// P18. The patch uses no initial value and no final complement --
// both cancel in a difference, and including them makes every
// patched frame fail its receiver's check.
property p_no_init_no_complement;
@(posedge clk) disable iff (!rst_n)
(crc32_of_positioned_delta('0) == 32'h0000_0000);
endproperty
// P19. The emitted frame's FCS is the correct CRC of the emitted
// frame -- the end-to-end check, on the PHY-side interface.
property p_emitted_frame_is_valid;
@(posedge clk) disable iff (!rst_n)
out_eop |-> (emitted_fcs == crc32_of(emitted_frame_body));
endproperty
// P20. An FCS arriving with no patch when one was expected is
// counted, loudly.
property p_missing_patch_flagged;
@(posedge clk) disable iff (!rst_n)
(fcs_valid && !have_patch && delta_expected) |=> patch_without_delta;
endpropertyGroup 5 — the correction field.
// P21. The correction ACCUMULATES -- it is added, never replaced, so
// an arbitrary number of transparent clocks compose.
property p_correction_accumulates;
@(posedge clk) disable iff (!rst_n)
delta_valid |-> (new_field == (old_field + add_value));
endproperty
// P22. Only event messages are updated. A Follow_Up's correction is
// already correct and must not gain this switch's residence time.
property p_correction_only_events;
@(posedge clk) disable iff (!rst_n)
delta_valid |-> frame_is_ptp_event;
endproperty
// P23. Overflow is reported, never saturated. A clipped correction is
// a plausible wrong number that section 7 of chapter 16.2 accepts.
property p_overflow_is_reported;
@(posedge clk) disable iff (!rst_n)
(sum_ext[64] != sum_ext[63]) |-> overflow;
endpropertyGroup 6 — the FIFO and pairing.
// P24. A lookup returns the entry with that tag, or a miss. It never
// returns a different frame's timestamp.
property p_lookup_is_by_tag;
@(posedge clk) disable iff (!rst_n)
hit |-> ($past(e[i].frame_tag) == $past(lookup_tag));
endproperty
// P25. A hit consumes its entry exactly once.
property p_hit_consumes_once;
@(posedge clk) disable iff (!rst_n)
hit |=> !e[i].valid;
endproperty
// P26. Overflow drops the NEWEST, so an old entry can still be
// claimed and the loss surfaces as a miss at the protocol layer.
property p_overflow_drops_newest;
@(posedge clk) disable iff (!rst_n)
(push && full) |=> ($stable(e) && $changed(c_dropped));
endproperty
// P27. Every entry eventually leaves -- claimed or aged out.
property p_no_permanent_entries;
@(posedge clk) disable iff (!rst_n)
$rose(e[i].valid) |-> ##[1:MAX_AGE+1] !e[i].valid;
endproperty
// P28. Occupancy equals the number of valid entries.
property p_occupancy_is_exact;
@(posedge clk) disable iff (!rst_n)
occupancy == $countones(entry_valid_vector);
endpropertyGroup 7 — configuration.
// P29. Standing property: the PHY delay is characterised.
property p_phy_delay_characterised;
@(posedge clk) disable iff (!rst_n)
!cfg_phy_delay_unset;
endproperty
// P30. And is not stale -- the live link mode matches the mode the
// correction was indexed for. Section 4's operational trap.
property p_phy_delay_is_current;
@(posedge clk) disable iff (!rst_n)
!cfg_phy_delay_stale;
endproperty
// P31. The lane term is in picoseconds, not nanoseconds.
property p_lane_units_are_ps;
@(posedge clk) disable iff (!rst_n)
!cfg_lane_in_ns;
endproperty
// P32. conformant is exactly the conjunction it claims.
property p_conformant_definition;
@(posedge clk) disable iff (!rst_n)
conformant <-> (fault_vector == 16'h0000);
endpropertyP1 is the property the mechanism rests on, P17 is the identity that makes the patch possible, and P19 is the end-to-end check that catches every way the rewrite-and-patch pair can go wrong together. None of them says the timestamp arrives in time to be written — which is the property this chapter refuses, and its impossibility is structural in a way none of the earlier refusals were.
20. Verification Scenarios
Seventy-four scenarios. The ones that matter are on the PHY-side interface, because a testbench that stops at the MAC never sees the rewrite or the patch at all.
The capture
| # | Scenario | Expected |
|---|---|---|
| 1 | SFD detected | timestamp in exactly one cycle |
| 2 | A retiming stage added by synthesis | two cycles — variable_capture_latency |
| 3 | Same, functional test only | passes — the jitter is back and invisible |
| 4 | SFD at lane 0 | no lane correction |
| 5 | SFD at lane 5, XGMII, cfg_lane_ps = 800 | 4.0 ns subtracted |
| 6 | Same, lane term in integer ns | truncates to zero |
| 7 | Two SFDs before a classification | overrun |
| 8 | 1000 ordinary frames, no PTP | c_captured = 0, c_discarded = 1000 |
| 9 | Captures compared pairwise | monotonic |
| 10 | cfg_phy_delay_ns = 0 | timestamps 200 ns early, all checks pass |
| 11 | Link renegotiates 10 G → 1 G | cfg_phy_delay_stale |
| 12 | FEC enabled after commissioning | hundreds of ns of stale correction |
Classification
| # | Scenario | Expected |
|---|---|---|
| 13 | Sync, type 0x0 | published |
| 14 | Follow_Up, type 0x8 | discarded |
| 15 | Announce, type 0xB | discarded |
| 16 | Pdelay_Req, type 0x2 | published — it is an event |
| 17 | Classification 15 octets after the SFD | 384 ns at 1 Gb/s, 3.84 ns at 100 |
| 18 | Deep parse pipeline at 100 Gb/s | more than 8 captures outstanding |
| 19 | Same, depth-8 FIFO | c_dropped |
The rewrite
| # | Scenario | Expected |
|---|---|---|
| 20 | One-step Sync, field at octet 48, W = 8 | spans beats 6 and 7 |
| 21 | Same | c_split_across_beats |
| 22 | Field aligned to a beat boundary | one beat — and half the logic untested |
| 23 | Octets outside the field | unchanged |
| 24 | Substituted octets | the captured timestamp, MSB of seconds first |
| 25 | Two-step Sync | not rewritten |
| 26 | Same, rewritten anyway | harmless today; a double write after switching to one-step |
| 27 | A general message rewritten | ten octets into the PTP header |
| 28 | Same | well-formed, correctly checksummed, semantically destroyed |
| 29 | Tagged Sync, untagged field_offset | the header overwritten |
| 30 | Old field content not captured | the patch is impossible |
The patch
| # | Scenario | Expected |
|---|---|---|
| 31 | Delta of zero | patch is the identity |
| 32 | CRC(a ⊕ b) against CRC(a) ⊕ CRC(b) | equal — linearity |
| 33 | Patch computed with 0xFFFFFFFF init | wrong by a fixed 32-bit value, every frame |
| 34 | Same | every receiver discards — Chapter 6.3's residue |
| 35 | Patch with the final complement applied | the same |
| 36 | TRAILING_OCTETS = 2, untagged Sync | correct |
| 37 | TRAILING_OCTETS = 3 | a valid CRC of a mis-positioned delta |
| 38 | Same | frame consistently and wrongly checksummed; patch_without_delta silent |
| 39 | Emitted frame checked on the PHY interface | FCS valid |
| 40 | Testbench stopping at the MAC | the patch never exercised |
| 41 | FCS arrives, no delta, none expected | passed through unchanged |
| 42 | FCS arrives, no delta, one expected | patch_without_delta |
| 43 | patch_ratio_x100 | exactly 100, or frames are vanishing |
| 44 | Patch matrix, 10-octet field | 32 × 80, depth 6, ≈1248 XOR2 |
Latency and timing
| # | Scenario | Expected |
|---|---|---|
| 45 | Buffer-and-recompute at 1 Gb/s | +512 ns |
| 46 | Same, applied to Syncs only | 256 ns of offset error — Chapter 16.2 §6 |
| 47 | Patch approach | +0 ns |
| 48 | 1 Gb/s, 8-bit datapath | 64 beats per Sync, field-to-FCS = 2 beats |
| 49 | 10 Gb/s, 64-bit | 8 beats, adjacent cycles, 6.40 ns |
| 50 | 25 Gb/s, 64-bit | 8 beats, 2.56 ns — tight |
| 51 | 100 Gb/s, 512-bit | 1 beat — field and FCS in the same cycle, 5.12 ns |
| 52 | Serial reading at 100 Gb/s | 0.16 ns — a constraint that does not exist |
The correction field
| # | Scenario | Expected |
|---|---|---|
| 53 | Five transparent clocks in the path | the five residence times sum |
| 54 | Same, no switch knowing the count | correct — it accumulates |
| 55 | Correction replaced instead of added | only the last switch counted |
| 56 | Correction overflow, 64-bit signed 16.16 | reported, not saturated |
| 57 | Same, saturated | a plausible path delay that is too small |
| 58 | Applied to a Follow_Up | corrupts a correction that was correct |
| 59 | Same, per hop | one switch's residence time of error per hop |
| 60 | Misaligned 8-octet field, W = 8 | spans two beats — needs a one-beat delay |
Pairing
| # | Scenario | Expected |
|---|---|---|
| 61 | Tagged pairing, overrun occurs | one measurement lost, detected |
| 62 | Order-based pairing, overrun occurs | every later pairing off by one |
| 63 | Same, back-to-back at 1 Gb/s | 672 ns of t1 error |
| 64 | Same, one Sync interval at 1/s | 1 second of error — loud, servo refuses to lock |
| 65 | Same, adjacent to bulk traffic | a few hundred ns — looks like asymmetry |
| 66 | Same, calibration taken | the asymmetry constant absorbs it |
| 67 | Traffic pattern changes | the calibration is now wrong |
| 68 | Out-of-order transmit completion | two pairings swapped — a FIFO pops the wrong one |
| 69 | Tag from the protocol layer, reordering scheduler | identifies intent, not reality |
| 70 | Tag from the transmit scheduler | correct |
| 71 | Lookup for an absent tag | c_miss |
| 72 | Entry never claimed | ages out, c_evicted |
| 73 | Protocol layer too slow | c_evicted rising, Follow_Ups absent |
| 74 | Same, seen at the far end | c_stale_sync climbing with c_sync |
The directed test random stimulus will not produce
Random stimulus will not exercise the rewrite-and-patch pair correctly, for a structural reason: the blocks sit downstream of the MAC, so a testbench that drives the MAC and checks its output never reaches them. Section 16's last paragraph. And the check that matters is not "did the field change" but "is the emitted frame's FCS the correct CRC of the emitted frame's body" — a recomputation from the wire, which is a checker rather than a stimulus and which no coverage metric requests.
Setup: one transmit path, W = 8 octets per beat at 156.25 MHz — 10 Gb/s. A PHY-side monitor that captures every emitted frame, recomputes CRC-32 over octets 0 through n−5, and compares against the emitted FCS. The capture point is driven with a known SFD instant. cfg_phy_delay_ns = 200, cfg_lane_ps = 800.
Stimulus, five phases. Phase 1: 1000 ordinary frames of random length, no PTP. Phase 2: 100 two-step Syncs. Phase 3: 100 one-step Syncs, field at octet 48 — spanning beats 6 and 7. Phase 4: 100 one-step Syncs on a tagged VLAN, field at octet 52. Phase 5: 100 one-step Syncs interleaved with ordinary frames such that the transmit scheduler completes them out of order.
Oracle:
| # | Observable | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 |
|---|---|---|---|---|---|---|
| 1 | frames emitted with a valid FCS | 1000 | 100 | 100 | 100 | 100 |
| 2 | any frame with an invalid FCS | 0 | 0 | 0 | 0 | 0 |
| 3 | c_captured | 0 | 100 | 100 | 100 | 100 |
| 4 | c_discarded | 1000 | 0 | 0 | 0 | ~100 |
| 5 | c_rewritten | 0 | 0 | 100 | 100 | 100 |
| 6 | c_patched | 0 | 0 | 100 | 100 | 100 |
| 7 | patch_ratio_x100 | 100 | 100 | 100 | 100 | 100 |
| 8 | c_split_across_beats | 0 | 0 | 100 | 100 | 100 |
| 9 | emitted originTimestamp | n/a | all zero | the captured value | the captured value | the captured value |
| 10 | captured value against the driven SFD | n/a | SFD − 200 ns − lane | the same | the same | the same |
| 11 | twoStepFlag in the emitted frame | n/a | set | clear | clear | clear |
| 12 | c_dropped, c_miss, c_evicted | 0 | 0 | 0 | 0 | 0 |
| 13 | timestamps paired to the right frame | n/a | yes | yes | yes | yes — by tag |
| 14 | rerun phase 5 with order-based pairing | — | — | — | — | pairings swapped |
| 15 | rerun phase 3 with TRAILING_OCTETS = 3 | — | — | every FCS invalid | — | — |
| 16 | rerun phase 3 with 0xFFFFFFFF init | — | — | every FCS invalid | — | — |
| 17 | rerun phase 4 with untagged field_offset | — | — | — | PTP header corrupted, FCS valid | — |
| 18 | added latency, all phases | 0 | 0 | 0 | 0 | 0 |
Row 2 is the whole test and rows 15 to 17 are why it has to exist. All three of those misconfigurations produce frames that are internally consistent and wrong: rows 15 and 16 emit frames every receiver discards, and row 17 emits a frame with a perfectly valid FCS and a destroyed PTP header — which no CRC check anywhere will catch.
Row 8 is the second finding. Phases 3 and 4 both split the field across two beats, which is the case a naive implementation gets wrong and which an aligned-offset test never reaches. Row 17's tagged variant is the same logic at a different offset, and it is the one a deployment actually hits.
And row 14 is Section 13's argument reduced to an experiment: the same stimulus, the same hardware, one design decision different, and the timestamps end up on the wrong frames.
21. Debugging a Timestamp Unit
Five questions, in order. The first two need no reference clock and eliminate most cases.
Step 1 — is the capture disciplined? variable_capture_latency and worst_capture_to_classify. A capture latency that is not exactly one cycle has reintroduced the jitter the whole of Chapter 16.1 was about removing, and it is a synthesis or retiming artefact that no functional test detects. The worst capture-to-classify latency is what sizes the FIFO, and a design that has not measured it has guessed.
Step 2 — are frames surviving? patch_ratio_x100 and the receiving end's frame-error counters. A ratio other than 100 means rewritten frames left with a stale FCS and every receiver discarded them — so the symptom is a Sync stream that vanishes with the transmitter's counters all healthy. The far end's c_bad_fcs, not ours, is where it shows.
Step 3 — is the pairing sound? pairing_is_sound, c_dropped, c_miss, c_evicted. Any of the three non-zero means a timestamp may have been attributed to the wrong frame, and Section 13's arithmetic says the resulting error looks exactly like a path asymmetry. This step must precede any calibration, because a calibration taken while the pairing is unreliable stores the pairing error as a constant.
Step 4 — is the configuration current? cfg_phy_delay_unset, cfg_phy_delay_stale, cfg_lane_in_ns. The stale case is the operational one: a link that renegotiated or enabled FEC has changed its PHY delay under a constant that was right at commissioning, and nothing but this check notices. The lane check catches a correction that truncated to zero.
Step 5 — what is left? cfg_phy_delay_ns against the vendor's figure, and the elastic buffer. Once steps 1 to 4 are clean, the unit's residual is a characterised constant plus Chapter 4.4's 5 to 20 ns, and any error larger than that is not in this unit — it is Chapter 16.2 §8's asymmetry or a switch in the path.
And the finding that ends an investigation here: fixed capture latency, patch_ratio_x100 at 100, pairing_is_sound high, no stale configuration, peak_occupancy well below full. That is a timestamp unit doing everything available to it — and Section 17's last row says the accuracy achieved is then somebody else's problem.
22. Common Misconceptions
1 — "One-step is impossible at high line rates."
The wrong model: there are only 0.16 ns between the timestamp field and the FCS at 100 Gb/s.
What it costs: two-step chosen on a device where one-step was the better answer, on the strength of a timing analysis that does not apply. The 0.16 ns figure is a property of the wire; the logic runs on a 512-bit datapath at 195 MHz, where the field, the pad and the FCS are all in the same 5.12 ns cycle.
The corrected model: convert wire-serial figures into cycle counts before reasoning about them. A wider datapath has more time, not less — 5.12 ns against 8-bit-at-1-Gb/s's 16 ns is comparable, and both are far more than 0.16 ns. The real constraint is a depth-6 XOR tree inside one cycle, which is tight at 25 Gb/s and comfortable at 100.
2 — "Rewriting the timestamp means recomputing the CRC."
The wrong model: a changed frame needs a fresh checksum.
What it costs: a store-and-forward buffer — 512 ns at 1 Gb/s — on a mechanism targeting 10 ns. And worse: applied to Syncs and not to Delay_Reqs, it creates 512 ns of path asymmetry and, by Chapter 16.2 §6's arithmetic, 256 ns of offset error in the act of trying to be precise.
The corrected model: Chapter 6.2's CRC is linear over GF(2), so CRC(F ⊕ Δ) = CRC(F) ⊕ CRC(Δ). The patch is a fixed 32 × 80 GF(2) matrix on the delta — about 1248 XOR2 gates, depth 6, and zero added latency. No buffer, no CRC engine.
3 — "Two-step costs a lot at scale."
The wrong model: an extra message per Sync per slave.
What it costs: one-step implemented on a device whose transmit path could not comfortably take it, to save a cost that is not there. Sync and Follow_Up are both multicast, so one Follow_Up serves every slave — the extra cost at a thousand slaves and 128 Sync/s is 128 messages per second, against a total of 131 072.
The corrected model: two-step's cost is bandwidth on a tight link — 21 504 bit/s at 16 Sync/s — and 48 octets of pending state at the slave. One-step's cost is gates and a timing path. Choose by which resource is short, and note that a master's message load is dominated by unicast Delay_Resps either way.
4 — "The timestamp goes in and the frame is done."
The wrong model: a timestamp is a value written into a field.
What it costs: Section 13's off-by-one, and it is the most damaging error in the chapter because it hides. A timestamp attributed to the wrong frame produces a t1 error equal to the interval between the two frames — on a busy link that is a few hundred nanoseconds, which looks exactly like a path asymmetry. Chapter 16.2 §8's calibration then absorbs it into a stored constant, and the constant is wrong the moment the traffic changes.
The corrected model: a timestamp is a (value, frame) pair, and the frame half needs a 16-bit tag assigned by the transmit scheduler — the only component that knows the order frames actually leave in. Order-based pairing is correct until the first overrun, drop, abort or out-of-order completion, and permanently wrong afterwards.
5 — "The PHY delay is a constant you set once."
The wrong model: characterise at commissioning and store.
What it costs: tens or hundreds of nanoseconds of silent, monotonic error. Four of the delay's components depend on the operating mode — the PCS, the block coding, FEC, and the negotiated speed — so a link that renegotiated from 10 Gb/s to 1 Gb/s, or that enabled FEC, has invalidated the constant. Chapter 16.1 §17's check catches an unset correction; nothing catches a stale one.
The corrected model: the correction is a table indexed by the live link mode, and cfg_phy_delay_stale compares the two. And the values come from the vendor's characterisation, not from tuning — a number obtained by adjusting until the offset looked right has absorbed the path's asymmetry into the PHY constant, and the two are then inseparable.
6 — "We verified the MAC, so the timestamping is verified."
The wrong model: the timestamp unit is inside the MAC.
What it costs: the rewriter and the patcher untested. Both sit downstream of the MAC's CRC generator — they must, because their premise is that a frame with a valid FCS arrives and leaves with a different valid FCS — so a testbench that drives the MAC and checks its output never reaches them.
The corrected model: verify on the PHY-side interface, with a monitor that recomputes CRC-32 from the emitted octets and compares. Section 20's row 2 is the whole test, and rows 15 to 17 show three misconfigurations that produce internally consistent, entirely wrong frames — including one with a valid FCS and a destroyed PTP header, which no checksum anywhere catches.
23. Interview Reasoning
Q1 — A Sync must carry the time it was transmitted. What is the problem and what are the two answers?
The value does not exist when the field needs it. The capture instant is the SFD, at octet −1; the originTimestamp field is at octets 48–57 — so there is 384 ns of slack at 1 Gb/s and the field is satisfiable. What is not available is the time the frame finished, which is the naive reading. Two-step sends the captured value afterwards in a Follow_Up — one extra multicast frame, unlimited tolerance to lateness, 48 octets of pending state at the slave. One-step substitutes it into the field as the frame streams and must then repair the FCS, because the CRC was computed over the old content.
Q2 — How do you fix the FCS without buffering the frame?
Exploit linearity. Chapter 6.2's CRC-32 is linear over GF(2), so CRC(F ⊕ Δ) = CRC(F) ⊕ CRC(Δ). The frame changed by Δ = old ⊕ new, which is zero outside the ten substituted octets, so the new FCS is old_fcs ⊕ CRC(Δ positioned) — a fixed 32 × 80 GF(2) matrix, about 1248 XOR2 gates at depth 6, computable at elaboration exactly like Chapter 6.4's parallel CRC tables. Zero added latency, no frame buffer, no CRC engine. And the patch uses no initial value and no final complement — both cancel in a difference.
Q3 — At 100 Gb/s there are 0.16 ns between the field and the FCS. How is one-step possible?
Because the datapath is 512 bits wide. At 100 Gb/s a MAC moves 64 octets per cycle at 195 MHz, so the entire 64-octet Sync — field, pad and FCS — is one beat, and the question is not "how long between two octets" but "can the patch be computed combinationally in 5.12 ns". A depth-6 XOR tree plus two levels of muxing comfortably fits. The 0.16 ns is a statement about the wire and is meaningless about the logic — and the trap runs the other way too: at 25 Gb/s with a 64-bit datapath the budget is 2.56 ns, which is tight.
Q4 — Why does a transmit timestamp need a frame tag?
Because without one it is paired by order, and order breaks permanently on the first anomaly. A capture overrun, a FIFO drop, an aborted frame or an out-of-order transmit completion shifts every subsequent pairing by one, and nothing detects it. The resulting t1 error is the interval between the two frames: one second at 1 Sync/s — loud, the servo refuses to lock — but a few hundred nanoseconds on a busy link, which is indistinguishable from a path asymmetry and will be absorbed into a calibration that is then wrong. And the tag must come from the transmit scheduler, which is the only component that knows the order frames actually left in.
Q5 — Where does the rewriter sit, and why can't it go earlier?
Downstream of the MAC's CRC generator, before the PHY. Placing it upstream removes the need for a patcher entirely — the MAC would compute the CRC over the substituted content — but at that point the transmit timestamp does not exist yet. There are exactly two placements and they trade the patcher against the value's availability, so the patcher exists because the timestamp is only available once the frame is physically leaving. A consequence for verification: a testbench that stops at the MAC output never exercises the rewrite or the patch.
Q6 — Why can't you assert that the field holds the time the frame was sent?
Because the property is self-referential in time: the value it asserts about measures the event the assertion is part of. A field cannot contain a measurement of anything later than its own transmission, and "the time the frame was sent", read as the last octet leaving, is later. There is a second folded impossibility too: substituting the timestamp invalidates the FCS and patching the FCS depends on the substituted value, so the pre-patch stream is a frame no receiver accepts. The fix is to change the temporal reference, not the tightness: assert that the field holds the captured value, that the captured value is the SFD instant — which strictly precedes the field — and that the emitted frame's FCS is valid, plus a composition check that the patch belonged to this frame.
24. Understanding Check
25. What's Next
This chapter produced t1 and t3 correctly and built the mechanism a transparent clock needs without saying what to put in it.
Chapter 16.2's exchange now has trustworthy inputs. The capture is fixed-latency at the MAC/PHY boundary, the PHY delay is characterised and stale-checked, the pairing is tagged, and a one-step Sync leaves with a valid FCS and the right value in it. What remains is the two things this chapter deliberately did not do.
Chapter 16.4 — The Servo takes the offset Chapter 16.2 §7 computes and closes the loop on it, using Chapter 16.1 §3's rate trim and never its step — and derives the loop bandwidth from §11's noise-against-drift trade, which gave the shape and stopped short of the controller. Then it fills in Section 10's add_value: the residence time a switch measures between a frame's arrival and its departure, which is what turns Chapter 16.2 §20's 6.07 µs into something much smaller.
And Chapter 16.5 — What Limits Accuracy takes what is left. Three terms survive everything both chapters do: Chapter 16.2 §8's path asymmetry, entering at half the imbalance; Chapter 16.1 §14's quantisation at period/√12; and Chapter 4.4's elastic buffer, which this chapter named as its own floor and did not price.
One thread runs from here into both. This chapter's central trick was that a value which does not exist yet can still be delivered — by sending it later, or by rewriting the frame in flight and repairing what the rewrite broke. Chapter 16.4's residence time is exactly the same shape: a switch cannot know how long it held a frame until the frame leaves, and the mechanism it uses to report it is the one built in Section 10.
Continue learning
Related tutorials
- Related topic
Why Sub-Microsecond Sync Is a Hardware Problem
A 100 ppm crystal drifts 8.64 seconds a day, a software timestamp jitters by more than it measures, and the fix is a register 10 nanoseconds from the wire.
- Related topic
Frame Check Sequence
The check sequence protects a range, and the range is shorter than the frame's journey — appended at one point in a transmitter, verified at one point in the next receiver, and recomputed at every hop, so a device's own memory is covered by nothing the frame carries.
- Related topic
What Error Detection Must Guarantee
A detector's specification is a set of bounded guarantees plus a probability, and neither can be stated without an error model — including the guarantee everybody cites and this polynomial does not provide.
- Related topic
CRC-32 Generation
The arithmetic is a shift register performing polynomial division. The four conventions wrapped around it — initial value, input reflection, output reflection, final complement — change no guarantee and every value, which is where interoperability fails.
Standards & specifications
- Governing standard
- IEEE Std 802.3 (Ethernet)(opens IEEE in a new tab)
Defines the Ethernet MAC, the media-independent interfaces and the physical-layer sublayers, including framing, access control, auto-negotiation and per-rate PHY specifications. VLAN tagging, priority and time-sensitive shaping are defined by IEEE 802.1, not by 802.3.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the Ethernet curriculum.
