Ethernet · Module 16
The PTP Message Exchange
Why synchronisation needs four messages rather than one, what each equation assumes, and the half-the-imbalance error that survives every measurement.
Chapter 16.1 §5 established the asymmetry that shapes this whole chapter: drift survives an unknown path and offset does not.
A device can estimate its rate error across a path it knows nothing about, because drift is a difference of differences and a constant path delay cancels in the subtraction. It cannot estimate its phase error at all without knowing that path's delay — and the path's delay is exactly the thing a single message cannot reveal.
So the exchange has four messages, and the four exist to produce two equations in two unknowns.
One equation measures the round trip and gives the path delay. The other measures the imbalance and gives the offset. Neither works alone, and both rest on one assumption that the arithmetic cannot avoid: that the path is the same length in both directions.
It is not, and the residual is exactly half the imbalance. Ten metres of fibre length difference is 25 ns of offset error — two and a half times Chapter 16.1 §18's endpoint residual — and no amount of averaging, filtering or faster hardware touches it, because the samples are not noisy. They are consistently displaced.
1. Scope — What This Chapter Owns
This chapter owns the exchange: the message set, the common header, the four timestamps and the two equations derived from them, sequence pairing, the symmetry assumption and its cost, the peer-delay alternative, and the announce/BMCA selection of a master.
It does not own where a timestamp comes from. Chapter 16.1 §13 derived that it must be captured at the MAC/PHY boundary at Chapter 5.2's Start Frame Delimiter, and §12 sketched the capture register. This chapter assumes those timestamps exist and uses them.
It does not own how a Sync message carries its own transmit timestamp. That value is not known until the message is already leaving, and the two answers — one-step and two-step — are Chapter 16.3. Section 5 here uses two-step because it is the simpler one to present and says so.
It does not own the correction. How a slave's clock is disciplined from the offset this chapter computes, and how a switch reports the time it held a message, is Chapter 16.4. Section 7 produces the numbers that chapter consumes.
And it does not own the error budget. Section 8 establishes the symmetry assumption and prices the obvious sources; Chapter 16.5 takes the full accounting and the calibration that removes what can be removed.
2. Why Four Messages and Not One
The obvious protocol is one message. It fails, and understanding exactly how it fails is the whole design rationale.
Suppose the master sends one message saying "it is now T". The slave receives it and sets its clock to T.
The slave's clock is now wrong by the transit time, which for a 100 m link plus two PHYs plus a switch is anywhere from 700 ns to 12 µs. And the slave has no way to know what that transit time was, because the message contains no information about its own journey.
Adding a second message does not help either, if it goes the same way. Two Syncs give two (t1, t2) pairs, and their difference gives the drift — Chapter 16.1 §5's finding, that a constant delay cancels in the subtraction. The offset does not cancel; it is present, identically, in both samples.
So the missing information is the transit time, and the only way to measure a transit time with two clocks that disagree is to send something back.
| Messages | What is known | What is not |
|---|---|---|
| 1 — Sync | t2 − t1 = d_ms + offset | one equation, two unknowns |
| 2 — two Syncs | the drift | the offset, and the path |
| 2 — Sync + Delay_Req | t2−t1 and t4−t3 | two equations, two unknowns — solvable |
| 4 — with Follow_Up and Delay_Resp | the same, with the timestamps actually delivered | — |
The third row is the protocol. The fourth row is the third row implemented, and the extra two messages exist purely to carry timestamps that could not be carried in the messages they describe.
t1 is the moment the Sync left. The Sync cannot contain it — the value is not known until transmission is under way. So either the hardware rewrites the field mid-flight (one-step) or a second message carries it afterwards (Follow_Up, two-step).
t4 is the moment the master received the Delay_Req. The Delay_Req is already gone, so the master sends it back in a Delay_Resp. There is no alternative; the slave has no other way to learn it.
Which is why the count is four and not two, and why the two extra messages are asymmetric in kind: Follow_Up is optional and one-step removes it, while Delay_Resp is structural and nothing removes it.
3. RTL 1 — The PTP Common Header
Every PTP message begins with the same 34 octets, and two of its fields do work the rest of the protocol depends on.
// -----------------------------------------------------------------------
// ptp_pkg -- shared types for the IEEE 1588 message exchange.
// -----------------------------------------------------------------------
package ptp_pkg;
localparam logic [15:0] PTP_ETHERTYPE = 16'h88F7;
// Two reserved multicast groups: one for messages a transparent clock
// must NOT forward unchanged, one for the rest.
localparam logic [47:0] PTP_PRIMARY_DA = 48'h01_1B_19_00_00_00;
localparam logic [47:0] PTP_PDELAY_DA = 48'h01_80_C2_00_00_0E;
typedef enum logic [3:0] {
MSG_SYNC = 4'h0,
MSG_DELAY_REQ = 4'h1,
MSG_PDELAY_REQ = 4'h2,
MSG_PDELAY_RESP = 4'h3,
MSG_FOLLOW_UP = 4'h8,
MSG_DELAY_RESP = 4'h9,
MSG_PDELAY_RESP_FU= 4'hA,
MSG_ANNOUNCE = 4'hB,
MSG_SIGNALING = 4'hC,
MSG_MANAGEMENT = 4'hD
} msg_type_e;
// Messages 0x0..0x3 are EVENT messages: they are timestamped on
// transmit and on receive. Everything else is a GENERAL message and
// carries no timestamp of its own. The distinction decides which
// messages 16.3's timestamp unit must act on.
function automatic bit is_event(input msg_type_e t);
is_event = (t[3] == 1'b0);
endfunction
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;
// A port's identity: an 8-octet clock identity plus a 2-octet port
// number. Every message says who sent it, and the pairing in
// section 9 depends on it.
typedef struct packed {
logic [63:0] clock_id;
logic [15:0] port_num;
} port_id_t;
// correctionField is signed nanoseconds in a 16.16 fixed-point form:
// the low 16 bits are fractional nanoseconds. 16.4's transparent
// clock accumulates residence time here.
typedef logic signed [63:0] correction_t;
endpackage// -----------------------------------------------------------------------
// ptp_header_parser -- recognises a PTP message and extracts the common
// header. It is a filter first and a parser second.
// -----------------------------------------------------------------------
module ptp_header_parser
import ptp_pkg::*;
(
input logic clk,
input logic rst_n,
input logic rx_valid,
input logic [7:0] rx_octet,
input logic rx_sop,
input logic rx_eop,
input logic rx_fcs_ok,
input logic rx_tagged, // 13.2's tag shifts every offset
output logic hdr_valid,
output msg_type_e msg_type,
output logic is_event_msg,
output logic [15:0] msg_length,
output logic [7:0] domain,
output logic [15:0] flags,
output correction_t correction,
output port_id_t src_port,
output logic [15:0] seq_id,
output logic [31:0] c_by_type [16],
output logic [31:0] c_wrong_domain,
output logic [31:0] c_bad_fcs
);
// 13.2 section 7: a tag moves every offset after octet 12 by four.
// A parser with hard-coded offsets silently mis-parses a tagged PTP
// frame, which is legal and common on a trunk.
logic [7:0] base;
assign base = rx_tagged ? 8'd18 : 8'd14;
logic [7:0] idx;
logic in_frame, et_ok;
logic [15:0] et_q;
msg_type_e mt_q;
logic [15:0] len_q, flg_q, seq_q;
logic [7:0] dom_q;
correction_t corr_q;
port_id_t sp_q;
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
idx <= '0; in_frame <= 1'b0; et_ok <= 1'b0; et_q <= '0;
hdr_valid <= 1'b0; c_wrong_domain <= '0; c_bad_fcs <= '0;
for (i = 0; i < 16; i++) c_by_type[i] <= '0;
end else begin
hdr_valid <= 1'b0;
if (rx_valid && rx_sop) begin
idx <= 8'd1; in_frame <= 1'b1; et_ok <= 1'b0;
end else if (rx_valid && in_frame) begin
idx <= idx + 1'b1;
if (idx == (base - 2)) et_q[15:8] <= rx_octet;
if (idx == (base - 1)) et_ok <= ({et_q[15:8], rx_octet} == PTP_ETHERTYPE);
if (idx == base + 8'd0) mt_q <= msg_type_e'(rx_octet[3:0]);
if (idx == base + 8'd2) len_q[15:8] <= rx_octet;
if (idx == base + 8'd3) len_q[7:0] <= rx_octet;
if (idx == base + 8'd4) dom_q <= rx_octet;
if (idx == base + 8'd6) flg_q[15:8] <= rx_octet;
if (idx == base + 8'd7) flg_q[7:0] <= rx_octet;
// correctionField, octets 8..15 of the header.
if ((idx >= base + 8'd8) && (idx <= base + 8'd15))
corr_q[63 - 8*(idx - base - 8)-: 8] <= rx_octet;
// sourcePortIdentity, octets 20..29.
if ((idx >= base + 8'd20) && (idx <= base + 8'd27))
sp_q.clock_id[63 - 8*(idx - base - 20) -: 8] <= rx_octet;
if (idx == base + 8'd28) sp_q.port_num[15:8] <= rx_octet;
if (idx == base + 8'd29) sp_q.port_num[7:0] <= rx_octet;
if (idx == base + 8'd30) seq_q[15:8] <= rx_octet;
if (idx == base + 8'd31) seq_q[7:0] <= rx_octet;
end
if (rx_valid && rx_eop) begin
in_frame <= 1'b0;
if (et_ok) begin
if (!rx_fcs_ok) c_bad_fcs <= c_bad_fcs + 1;
else begin
c_by_type[mt_q] <= c_by_type[mt_q] + 1;
hdr_valid <= 1'b1;
msg_type <= mt_q;
is_event_msg <= is_event(mt_q);
msg_length <= len_q;
domain <= dom_q;
flags <= flg_q;
correction <= corr_q;
src_port <= sp_q;
seq_id <= seq_q;
end
end
end
end
end
endmoduleClassification: a receive-side header decoder with a tag-aware offset base. One pass, no state beyond the current frame.
What it teaches: that the event/general split is a property of the message type's top bit and it decides the entire hardware interface. Message types 0x0 to 0x3 are event messages — they are timestamped on transmit and receive — and everything else is general and carries no timestamp of its own. A timestamp unit does not need to parse PTP; it needs one bit, and is_event(t) = !t[3] is that bit. Chapter 16.3's unit sits in the datapath and must decide in a handful of cycles whether to capture, and this encoding is why it can.
And it teaches that correctionField is in the common header rather than in any message body, which is the structural decision that makes transparent clocks possible. A switch that wants to report how long it held a message does not need to know what kind of message it is — it adds to a field at a fixed offset, present in all of them. Chapter 16.4 §12 is the consequence.
Deliberately simplified: rx_tagged is an input, and a real parser must determine it from the frame. Chapter 13.2 §7 established that a tag moves every offset after octet 12 by four, and a PTP parser with hard-coded untagged offsets silently mis-parses every tagged PTP frame — reading the message type from the middle of the header, matching nothing, and counting nothing. On a trunk port that is every PTP frame.
Production implication: c_wrong_domain exists because PTP domains are the mechanism by which two independent synchronisation systems share a network, and a device listening on the wrong domain hears everything and uses none of it. The symptom is a slave that never locks with a healthy link and rising c_by_type counters — which looks like a protocol fault and is a single-octet configuration difference.
4. The Message Set, Field by Field
Ten message types, of which five carry the exchange and the rest are infrastructure.
| Message | Type | Event? | Body | Frame | Wire | Purpose |
|---|---|---|---|---|---|---|
| Sync | 0x0 | yes | 10 | 64 | 84 | carries t1, or announces that a Follow_Up will |
| Delay_Req | 0x1 | yes | 10 | 64 | 84 | its transmission is t3; its arrival is t4 |
| Pdelay_Req | 0x2 | yes | 20 | 72 | 92 | the peer-delay alternative — Section 12 |
| Pdelay_Resp | 0x3 | yes | 20 | 72 | 92 | — |
| Follow_Up | 0x8 | no | 10 | 64 | 84 | carries t1 when the hardware could not |
| Delay_Resp | 0x9 | no | 20 | 72 | 92 | carries t4 back to the slave |
| Pdelay_Resp_Follow_Up | 0xA | no | 20 | 72 | 92 | — |
| Announce | 0xB | no | 30 | 82 | 102 | the master's credentials — Section 14 |
| Signaling | 0xC | no | varies | — | — | negotiation of rates |
| Management | 0xD | no | varies | — | — | out of scope |
Three of these are 64-octet frames and the reason is Chapter 5.6's minimum payload: a 34-octet header plus a 10-octet body is 44 octets, two short of the 46 minimum, so two octets of pad make the frame 64. The protocol's smallest messages are at Ethernet's floor.
And the event/general column is the one that shapes the hardware.
An event message is timestamped both on transmit and on receive. Its arrival on the wire is the thing being measured, so Chapter 16.1 §13's capture point applies to it and to nothing else.
A general message carries data about a previous event and is not itself timed. A Follow_Up may be delayed by a millisecond and lose nothing — it is carrying a number, and the number does not decay. Which is exactly Chapter 16.1's distinction between a fact about the transport and a fact carried over it, appearing here as a two-way split in the message set.
==
So the load on the timestamp unit is smaller than the message count suggests:
| At 16 Sync/s and 16 Delay_Req/s | Messages/s | Timestamped |
|---|---|---|
| slave, two-step | 49 | 32 — Sync RX and Delay_Req TX |
| slave, one-step | 33 | 32 — the same |
| master, one slave | 49 | 32 |
| master, 1024 slaves at 128/s | 131 328 | >16 000 |
The last row is the number Chapter 16.1 §6's callout predicted, and it is why a large deployment's oscillator choice becomes the master's processing requirement. A fabric of a thousand slaves on 100 ppm crystals needs a master generating and timestamping over a hundred thousand messages a second; the same fabric on OCXOs needs a few thousand.
5. RTL 2 — Sync and Follow_Up
The slave's receive path for the master-to-slave half of the exchange. It holds two values that arrive in different messages and must be matched.
// -----------------------------------------------------------------------
// sync_followup_tracker -- pairs a Sync's RECEIVE timestamp with the
// TRANSMIT timestamp that arrives afterwards.
//
// Two-step is used here because it is the simpler shape to present:
// the Sync's own t1 field is ignored and a Follow_Up carries it. One-
// step removes the second message and is chapter 16.3's subject.
// -----------------------------------------------------------------------
module sync_followup_tracker
import ptp_pkg::*;
#(
parameter int PENDING = 4 // outstanding Syncs awaiting a Follow_Up
)(
input logic clk,
input logic rst_n,
input logic hdr_valid,
input msg_type_e msg_type,
input logic [15:0] flags,
input logic [15:0] seq_id,
input port_id_t src_port,
input correction_t correction,
input ts_t body_ts, // originTimestamp or preciseOrigin
input ts_t rx_hw_ts, // 16.1 section 12's capture
input port_id_t cfg_master, // whose Syncs we honour
output logic pair_valid,
output logic [15:0] pair_seq,
output ts_t t1,
output ts_t t2,
output correction_t t1_correction,
output logic [31:0] c_sync,
output logic [31:0] c_followup,
output logic [31:0] c_orphan_fu, // a Follow_Up with no Sync
output logic [31:0] c_stale_sync // a Sync evicted unmatched
);
typedef struct packed {
logic busy;
logic [15:0] seq;
ts_t t2;
logic twostep;
} slot_t;
slot_t slots [PENDING];
logic [$clog2(PENDING)-1:0] wp;
// flagField bit 9 (twoStepFlag) says a Follow_Up is coming. A design
// that ignores it and always waits will stall on a one-step master;
// one that ignores it and never waits will use an empty t1 field.
logic two_step;
assign two_step = flags[9];
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
for (i = 0; i < PENDING; i++) slots[i] <= '0;
wp <= '0; pair_valid <= 1'b0;
c_sync <= '0; c_followup <= '0;
c_orphan_fu <= '0; c_stale_sync <= '0;
end else begin
pair_valid <= 1'b0;
if (hdr_valid && (src_port.clock_id == cfg_master.clock_id)) begin
unique case (msg_type)
MSG_SYNC: begin
c_sync <= c_sync + 1;
if (!two_step) begin
// One-step: t1 is in this frame's own body, already
// rewritten by the master's hardware. Publish at once.
pair_valid <= 1'b1;
pair_seq <= seq_id;
t1 <= body_ts;
t2 <= rx_hw_ts;
t1_correction <= correction;
end else begin
// Two-step: park t2 and wait.
if (slots[wp].busy) c_stale_sync <= c_stale_sync + 1;
slots[wp].busy <= 1'b1;
slots[wp].seq <= seq_id;
slots[wp].t2 <= rx_hw_ts;
slots[wp].twostep <= 1'b1;
wp <= wp + 1'b1;
end
end
MSG_FOLLOW_UP: begin
automatic bit hit;
c_followup <= c_followup + 1;
hit = 1'b0;
for (i = 0; i < PENDING; i++) begin
if (slots[i].busy && (slots[i].seq == seq_id)) begin
hit = 1'b1;
pair_valid <= 1'b1;
pair_seq <= seq_id;
t1 <= body_ts;
t2 <= slots[i].t2;
t1_correction <= correction;
slots[i].busy <= 1'b0;
end
end
if (!hit) c_orphan_fu <= c_orphan_fu + 1;
end
default: ;
endcase
end
end
end
endmoduleClassification: a small associative buffer keyed on sequence ID, with a one-step bypass. Four slots, one match per message.
What it teaches: that the two-step flag must be honoured rather than assumed, and both wrong assumptions produce a slave that never locks. A design that always waits for a Follow_Up stalls for ever against a one-step master — c_sync rises, c_followup stays at zero, and no pair is ever published. A design that never waits reads the Sync's originTimestamp field, which a two-step master transmits as zero, and computes an offset of roughly the current epoch.
And it teaches why the pending buffer needs more than one slot. A Sync and its Follow_Up are two frames, and a second Sync can be transmitted before the first's Follow_Up arrives — legally, at any Sync rate, and inevitably when the two messages take different paths through a switch's queues. A single-slot design silently drops the older pair, and c_stale_sync is how that becomes visible rather than mysterious.
Deliberately simplified: the slot search is a linear scan over four entries, evaluated combinationally in one cycle. At 128 Sync/s this is comfortable; a boundary clock serving a thousand slaves needs a proper CAM or a hash, and the sequence ID is 16 bits, so the natural structure is Chapter 12.5's set-associative table indexed by the low bits.
Production implication: c_orphan_fu is the counter that separates the master is not sending Follow_Ups from we are dropping Syncs. A Follow_Up arriving with no matching Sync means the Sync was lost or was never parsed — a tagged frame against an untagged parser, Section 3's simplification — and the two have completely different remedies. Without the counter both present as a slave that does not lock.
6. The Four Timestamps, Derived
Two equations fall out of four measurements, and it is worth doing the algebra rather than quoting the result, because the assumption enters at a specific line.
Define:
offset = the slave's clock minus the master's clock. Positive means the slave is ahead.
d_ms = the one-way delay from master to slave. d_sm = the one-way delay from slave to master.
The Sync leaves the master at t1 on the master's clock and arrives at the slave at t2 on the slave's clock:
t2 = t1 + d_ms + offset
so t2 - t1 = d_ms + offset ... (1)The Delay_Req leaves the slave at t3 on the slave's clock and arrives at the master at t4 on the master's:
t4 = t3 - offset + d_sm
so t4 - t3 = d_sm - offset ... (2)Add them, and the offset cancels:
(t2 - t1) + (t4 - t3) = d_ms + d_sm = the round trip ... (3)Subtract them, and the round trip does not:
(t2 - t1) - (t4 - t3) = d_ms - d_sm + 2 * offset ... (4)Equation (3) is a genuine measurement. It is made entirely of four numbers two clocks observed, and it is correct regardless of what the offset is or how asymmetric the path is. The round-trip delay is known.
Equation (4) has two unknowns in it — the offset, and the difference between the two one-way delays — and four timestamps cannot separate them.
So the protocol assumes d_ms = d_sm, at which point:
mean_path = ((t2 - t1) + (t4 - t3)) / 2 ... from (3)
offset = ((t2 - t1) - (t4 - t3)) / 2 ... from (4)And the error in the second is exactly:
offset_error = (d_sm - d_ms) / 2==
Half the imbalance. Not a fraction of it that depends on the traffic, not something that averages down, not something a better clock improves — exactly half, deterministically, for as long as the imbalance exists.
| Imbalance | Offset error |
|---|---|
| 1 m of fibre — 5 ns | 2.5 ns |
| 10 m of fibre — 50 ns | 25 ns |
| 100 m of fibre — 500 ns | 250 ns |
| asymmetric PHY TX vs RX — 40 ns | 20 ns |
| a switch that queued 12.14 µs one way | 6.07 µs |
The last row is why Chapter 16.1 §15 ended where it did. A store-and-forward switch that holds a Sync for a full frame time and a Delay_Req for almost none is an asymmetry of 12.14 µs at 1 Gb/s, and the offset is wrong by half of it — six microseconds, from one switch, on a system whose endpoints are good to ten nanoseconds.
7. RTL 3 — Computing Offset and Path Delay
Section 6's algebra in hardware, with the correction fields folded in and the sign conventions made explicit.
// -----------------------------------------------------------------------
// offset_delay_calc -- turns four timestamps into an offset and a mean
// path delay.
//
// The correctionField from each message is SUBTRACTED from the
// corresponding interval: it holds the residence time a transparent
// clock accumulated, which is delay that did not happen on the wire.
// 16.4 section 12 is where those values come from.
// -----------------------------------------------------------------------
module offset_delay_calc
import ptp_pkg::*;
(
input logic clk,
input logic rst_n,
input logic ms_valid, // a (t1,t2) pair from section 5
input ts_t t1,
input ts_t t2,
input correction_t corr_ms,
input logic sm_valid, // a (t3,t4) pair from section 11
input ts_t t3,
input ts_t t4,
input correction_t corr_sm,
output logic result_valid,
output logic signed [63:0] offset_ns,
output logic signed [63:0] mean_path_ns,
output logic signed [63:0] round_trip_ns,
output logic negative_path, // the round trip came out < 0
output logic [31:0] c_results,
output logic [31:0] c_negative
);
// Both halves must be present before either equation is evaluated.
// A design that computes an offset from a fresh (t1,t2) and a stale
// (t3,t4) has mixed two path measurements -- section 10.
logic ms_have, sm_have;
logic signed [63:0] d_ms, d_sm;
function automatic logic signed [63:0] ts_diff(input ts_t a, input ts_t b);
ts_diff = ($signed(64'(a.sec)) - $signed(64'(b.sec))) * 64'sd1_000_000_000
+ ($signed(64'(a.ns)) - $signed(64'(b.ns)));
endfunction
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
ms_have <= 1'b0; sm_have <= 1'b0;
d_ms <= '0; d_sm <= '0;
result_valid <= 1'b0; offset_ns <= '0;
mean_path_ns <= '0; round_trip_ns <= '0;
negative_path <= 1'b0; c_results <= '0; c_negative <= '0;
end else begin
result_valid <= 1'b0;
negative_path <= 1'b0;
// correctionField is 16.16 fixed point; take its integer part.
if (ms_valid) begin
d_ms <= ts_diff(t2, t1) - (corr_ms >>> 16);
ms_have <= 1'b1;
end
if (sm_valid) begin
d_sm <= ts_diff(t4, t3) - (corr_sm >>> 16);
sm_have <= 1'b1;
end
if (ms_have && sm_have && (ms_valid || sm_valid)) begin
automatic logic signed [63:0] a, b, rt;
a = ms_valid ? (ts_diff(t2, t1) - (corr_ms >>> 16)) : d_ms;
b = sm_valid ? (ts_diff(t4, t3) - (corr_sm >>> 16)) : d_sm;
rt = a + b;
round_trip_ns <= rt;
// Equation (3): the round trip, with no assumption in it.
mean_path_ns <= rt >>> 1;
// Equation (4): the offset, ASSUMING d_ms == d_sm.
offset_ns <= (a - b) >>> 1;
result_valid <= 1'b1;
c_results <= c_results + 1;
// A negative round trip is arithmetically impossible and
// therefore evidence about the INPUTS -- section 10.
if (rt < 0) begin
negative_path <= 1'b1;
c_negative <= c_negative + 1;
end
end
end
end
endmoduleClassification: two subtractions, an add, a subtract and two arithmetic shifts. The cheapest module in the chapter and the one that carries the assumption.
What it teaches: that the correction field is subtracted, not added, and the sign convention is the most commonly inverted thing in a PTP implementation. correctionField accumulates the time a message spent inside switches — residence time, which is delay that did not happen on the wire and must not be attributed to the path. Subtracting it gives the true wire delay; adding it doubles the error the transparent clocks were correcting. The symptom of the inversion is an offset that gets worse when transparent clocks are enabled.
And it teaches that negative_path is a diagnostic rather than an error case. (t2−t1) + (t4−t3) is a physical round trip and cannot be negative, so a negative result means one of the four timestamps is wrong — a stale pair matched to a fresh one, a Follow_Up matched to the wrong Sync, or a clock that was stepped between t2 and t3. The arithmetic detects an input fault it cannot otherwise see.
Deliberately simplified: ts_diff multiplies a seconds difference by 10⁹, which for a 48-bit seconds field is a 64-bit multiply. In practice the two timestamps of a pair are within a second of each other almost always, so a production design compares the seconds fields first and takes a fast path. The slow path still has to exist, because the first pair after a cold start spans an arbitrary interval.
Production implication: the two shifts are arithmetic right shifts on signed values, so they round toward negative infinity rather than toward zero. On a single sample that is half a nanosecond and irrelevant; on a servo integrating millions of samples it is a systematic bias of −0.5 ns — Chapter 16.1 §11's point that a one-sided error has a removable mean, appearing here as an artefact of the arithmetic rather than of the physics. Round-to-nearest costs one adder.
8. The Assumption the Arithmetic Cannot Avoid
Section 6 introduced d_ms = d_sm in one line and everything after it depends on that line. It is worth being precise about what kind of statement it is.
It is not a measurement. It is not an approximation with an error bar. It is the only way to get two numbers out of one equation, and the protocol adopts it because there is no alternative available to four timestamps.
And it is worth showing that no fifth or sixth message helps.
| Additional measurement | What it adds | Does it separate d_ms from d_sm? |
|---|---|---|
| more Syncs | more (t1,t2) pairs | no — every one has d_ms + offset |
| more Delay_Reqs | more (t3,t4) pairs | no — every one has d_sm − offset |
| averaging both | less noise | no — the bias is in every sample |
| a faster clock | finer resolution | no |
| a third device | two more paths | no — three more unknowns with them |
| measuring the cable | the physical length | yes — and it is not a network measurement |
Row three is the one that matters operationally, and Section 22's fourth misconception is exactly it. Averaging reduces the variance of the offset estimate and leaves its mean displaced by (d_sm − d_ms)/2, because every sample is displaced by the same amount. A thousand samples give a beautifully tight estimate of the wrong number.
Row six is the honest answer and it is a commissioning procedure rather than a protocol feature. Measure the asymmetry once, with a known-good reference or with a physical cable measurement, store it as a constant, and subtract it. That is what a calibrated deployment does and an uncalibrated one does not — Chapter 16.5 §11.
Which gives the chapter's central claim in its strongest form:
The PTP exchange measures the round trip exactly and the offset only up to half the path's asymmetry. The first is a measurement; the second is a measurement plus a premise. And the premise is about cables and queues, which the protocol has no access to.
9. RTL 4 — Sequence IDs and Pairing
Four timestamps are useless unless they belong to the same exchange, and nothing about their arrival order guarantees that.
// -----------------------------------------------------------------------
// seqid_pairing_tracker -- matches a Delay_Resp back to the Delay_Req
// it answers, and detects the pairings that silently go wrong.
//
// The sequenceId is 16 bits and wraps. The requestingPortIdentity in a
// Delay_Resp names WHICH slave asked, which matters because the
// Delay_Resp is multicast on most deployments and every slave sees it.
// -----------------------------------------------------------------------
module seqid_pairing_tracker
import ptp_pkg::*;
#(
parameter int OUTSTANDING = 8,
parameter int TIMEOUT_CYCLES = 2_500_000_000/1000 // 5 ms at 500 MHz
)(
input logic clk,
input logic rst_n,
// Our own Delay_Req, as it leaves.
input logic dreq_sent,
input logic [15:0] dreq_seq,
input ts_t dreq_t3,
// An arriving Delay_Resp.
input logic dresp_valid,
input logic [15:0] dresp_seq,
input port_id_t dresp_requesting,
input ts_t dresp_t4,
input correction_t dresp_correction,
input port_id_t cfg_self,
output logic sm_valid,
output ts_t t3,
output ts_t t4,
output correction_t corr_sm,
output logic [31:0] c_matched,
output logic [31:0] c_not_for_us,
output logic [31:0] c_unmatched,
output logic [31:0] c_timed_out
);
typedef struct packed {
logic busy;
logic [15:0] seq;
ts_t t3;
logic [31:0] age;
} entry_t;
entry_t q [OUTSTANDING];
logic [$clog2(OUTSTANDING)-1:0] wp;
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
for (i = 0; i < OUTSTANDING; i++) q[i] <= '0;
wp <= '0; sm_valid <= 1'b0;
c_matched <= '0; c_not_for_us <= '0;
c_unmatched <= '0; c_timed_out <= '0;
end else begin
sm_valid <= 1'b0;
// Age every outstanding request; a Delay_Resp that never comes
// must free its slot or the tracker fills and stops matching.
for (i = 0; i < OUTSTANDING; i++) begin
if (q[i].busy) begin
q[i].age <= q[i].age + 1;
if (q[i].age == TIMEOUT_CYCLES-1) begin
q[i].busy <= 1'b0;
c_timed_out <= c_timed_out + 1;
end
end
end
if (dreq_sent) begin
q[wp].busy <= 1'b1;
q[wp].seq <= dreq_seq;
q[wp].t3 <= dreq_t3;
q[wp].age <= '0;
wp <= wp + 1'b1;
end
if (dresp_valid) begin
// Gate 1 -- is this answering US? A multicast Delay_Resp is
// seen by every slave on the segment and answers one of them.
if (dresp_requesting.clock_id != cfg_self.clock_id) begin
c_not_for_us <= c_not_for_us + 1;
end else begin
automatic bit hit;
hit = 1'b0;
for (i = 0; i < OUTSTANDING; i++) begin
if (q[i].busy && (q[i].seq == dresp_seq)) begin
hit = 1'b1;
sm_valid <= 1'b1;
t3 <= q[i].t3;
t4 <= dresp_t4;
corr_sm <= dresp_correction;
q[i].busy <= 1'b0;
c_matched <= c_matched + 1;
end
end
if (!hit) c_unmatched <= c_unmatched + 1;
end
end
end
end
endmoduleClassification: an outstanding-transaction tracker with an age-out. Eight entries, one match per response.
What it teaches: that requestingPortIdentity is a gate and not a field to record. On most deployments the Delay_Resp is sent to the same multicast group as everything else, so every slave on the segment receives every other slave's answer — and a design that matches on sequence ID alone will happily pair its own t3 with another slave's t4 whenever the two sequence numbers coincide. With 16-bit sequence IDs and a dozen slaves that is not rare; it is routine.
And it teaches that the age-out is what keeps the tracker alive under loss. A Delay_Resp that never arrives holds a slot for ever, and eight lost responses stop the mechanism permanently with no counter moving — the slave keeps sending Delay_Reqs, keeps receiving nothing it can match, and reports a stale path delay indefinitely. c_timed_out turns a silent stall into a rate.
Deliberately simplified: the slot scan is linear over eight entries and the age counter is per entry. A boundary clock tracking a thousand slaves needs one tracker per port, not one per device, and the natural structure is again Chapter 12.5's set-associative table — with the interesting difference that a collision here is a correctness failure rather than a capacity one, so associativity must cover the worst case rather than the typical one.
Production implication: c_not_for_us rising fast is normal on a multicast deployment and is itself a useful measurement: it counts the other slaves on this segment. A device seeing zero is either alone or is on a unicast deployment; one seeing thousands per second is sharing a segment with a large population, which is the first thing to know when the master's load is in question — Section 4's last row.
10. What a Lost Message Costs
Every message in the exchange can be lost, and the four losses have four different consequences.
| Lost | Immediate effect | Recovery | Counter |
|---|---|---|---|
| Sync | no fresh (t1,t2) | the next Sync | c_sync gap |
| Follow_Up | t2 is parked and never used | the next Sync/Follow_Up pair | c_stale_sync |
| Delay_Req | the master never answers | the next Delay_Req | c_timed_out |
| Delay_Resp | t3 is held with no t4 | the next exchange | c_timed_out |
Every row recovers on the next exchange, which is the protocol's most important structural property and it is the same one Chapter 14.4 §2 and Chapter 15.3 §24 both arrived at: periodic complete state rather than transactional exchange.
A lost PTP message leaves nothing incomplete. There is no retransmission, no acknowledgement and no state machine waiting for something that will not come — the pairing ages out, the slot frees, and the next Sync starts over.
What the loss costs is not correctness. It is drift, accumulated for one extra interval:
| Sync rate | Interval | Drift at 100 ppm | at 20 ppm | at 0.1 ppm |
|---|---|---|---|---|
| 1/s | 1000 ms | 100 µs | 20 µs | 0.1 µs |
| 16/s | 62.5 ms | 6.25 µs | 1.25 µs | 0.006 µs |
| 128/s | 7.81 ms | 0.78 µs | 0.16 µs | 0.001 µs |
Read the first column against a 1 µs requirement and the message rate's real job appears. It is not to keep the offset estimate fresh for its own sake — it is to bound the damage a single lost message does. At 1/s on a commodity crystal, one lost Sync puts the slave 100 µs out and the requirement is violated until the next one lands.
Which is a different argument for a fast rate than Chapter 16.1 §6's. That one said the rate must cover the drift between messages. This one says it must cover the drift between messages that arrive, and on a lossy path those are not the same interval. A design sizing its Sync rate from the nominal interval has assumed a loss rate of zero.
And the loss rate is not zero, for a specific reason. PTP messages are small, low-rate and — on a network without Chapter 13.4 §9's priority mapping configured for them — in the same queue as bulk traffic. Chapter 14.1's drop is indifferent to what a frame carries. A Sync discarded by a congested egress is a Sync lost, and the congestion that caused it is exactly when accurate time matters.
11. RTL 5 — Delay_Req and Delay_Resp
The slave-to-master half. Its shape is dictated by one fact: the slave must transmit a message whose only purpose is to be timestamped by somebody else.
// -----------------------------------------------------------------------
// delay_req_resp_engine -- issues Delay_Req messages at a randomised
// interval and consumes the Delay_Resp that answers them.
//
// The randomisation is not cosmetic. Every slave on a segment that
// issues Delay_Req on a fixed period synchronised to the Sync it just
// received will transmit at the SAME instant, and the master receives
// N requests in one burst -- section 12's callout.
// -----------------------------------------------------------------------
module delay_req_resp_engine
import ptp_pkg::*;
#(
parameter int CLK_HZ = 500_000_000,
parameter int LOG_INTERVAL = 0 // 2^0 = 1 s between requests
)(
input logic clk,
input logic rst_n,
input logic sync_received, // a fresh Sync landed
input logic [15:0] rand_bits, // from an LFSR
input logic tx_ready,
output logic dreq_tx,
output logic [15:0] dreq_seq,
input ts_t dreq_hw_ts, // 16.3's TX capture
input logic dreq_hw_ts_valid,
output logic dreq_sent,
output logic [15:0] dreq_sent_seq,
output ts_t dreq_t3,
output logic [31:0] c_issued,
output logic [31:0] c_no_tx_ts,
output logic [31:0] interval_ms
);
localparam int NOMINAL = CLK_HZ; // 2^LOG_INTERVAL sec
localparam int PERIOD = NOMINAL <<< LOG_INTERVAL;
logic [31:0] cnt, target;
logic [15:0] seq_q;
logic awaiting_ts;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt <= '0; seq_q <= '0; dreq_tx <= 1'b0;
awaiting_ts <= 1'b0; dreq_sent <= 1'b0;
c_issued <= '0; c_no_tx_ts <= '0;
// The first interval is randomised too, so a rack of devices
// powering up together does not stay in lockstep.
target <= PERIOD;
interval_ms <= '0;
end else begin
dreq_tx <= 1'b0;
dreq_sent <= 1'b0;
cnt <= cnt + 1;
if (cnt >= target) begin
if (tx_ready && !awaiting_ts) begin
dreq_tx <= 1'b1;
dreq_seq <= seq_q;
seq_q <= seq_q + 1'b1;
awaiting_ts <= 1'b1;
c_issued <= c_issued + 1;
cnt <= '0;
// Uniform over [0.75, 1.25] of the nominal period. The
// standard requires the mean to be the configured interval
// and the individual intervals to be spread.
target <= PERIOD - (PERIOD >> 2)
+ ((PERIOD >> 1) * 32'(rand_bits)) / 32'd65536;
interval_ms <= target / (CLK_HZ / 1000);
end
end
// The TRANSMIT timestamp arrives from the hardware after the
// frame has left. Until it does, t3 does not exist -- which is
// the same structural problem 16.3 solves for Sync.
if (awaiting_ts) begin
if (dreq_hw_ts_valid) begin
awaiting_ts <= 1'b0;
dreq_sent <= 1'b1;
dreq_sent_seq <= seq_q - 1'b1;
dreq_t3 <= dreq_hw_ts;
end else if (cnt == target) begin
// A whole interval with no transmit timestamp: the capture
// was lost. The request is unusable and must be abandoned.
awaiting_ts <= 1'b0;
c_no_tx_ts <= c_no_tx_ts + 1;
end
end
end
end
endmoduleClassification: a randomised periodic transmitter with an outstanding-timestamp interlock.
What it teaches: that the randomised interval prevents a synchronised burst, and the failure it prevents is a self-inflicted denial of service. Every slave on a segment receives the same multicast Sync at the same instant. A slave that issues its Delay_Req a fixed delay afterwards will collide with every other slave doing the same — so the master receives N Delay_Reqs within microseconds, timestamps them all, and must generate N Delay_Resps in a burst. At Section 4's thousand slaves that is a thousand messages in one window, on a device sized for an average.
And it teaches that t3 does not exist when the Delay_Req is sent. The engine transmits, then waits for its own hardware to report when the frame actually left — Chapter 16.1 §12's capture, on the transmit side. This is the same structural problem a Sync has and the reason Section 2 said the count is four: a message cannot contain the time of its own transmission. Delay_Req solves it by not trying — t3 never goes on the wire, it stays at the slave.
Deliberately simplified: rand_bits is an input and a real design needs an LFSR seeded differently per device. Seeding every device identically re-creates the burst the randomisation was added to prevent, and the usual seed source is the low bits of the clock identity, which is the device's MAC address and is guaranteed distinct.
Production implication: c_no_tx_ts catches a transmit-timestamp capture that was lost — Chapter 16.1 §12's overrun, seen from the protocol side. Without the interlock the engine would issue a second Delay_Req while the first's timestamp was still outstanding, and the arriving Delay_Resp would be matched to a t3 belonging to the wrong frame. The resulting path delay is wrong by the interval between the two requests — up to a second — and every check in Section 7 passes.
12. Peer Delay Against End-to-End Delay
The exchange in Sections 6 and 11 measures the delay from the slave to the master, across however many switches lie between. There is a second mechanism that measures each link instead, and the difference is architectural rather than cosmetic.
| end-to-end — Delay_Req/Resp | peer delay — Pdelay_Req/Resp | |
|---|---|---|
| what is measured | slave to master, whole path | one link, between neighbours |
| who participates | the two endpoints | every device, with its neighbour |
| messages per measurement | 2 | 2 or 3 |
| load at the master | N slaves × rate | none — it is peer to peer |
| a switch in the path | must be a transparent clock | must be a peer-delay-capable clock |
| asymmetry | the whole path's | each link's, separately |
| what a topology change costs | every slave re-measures the path | only the changed link re-measures |
Row four is why large deployments use peer delay. End-to-end puts N × rate Delay_Reqs on one master; peer delay distributes the measurement across every link and the master sees none of it. Section 4's 131 328 messages per second becomes, under peer delay, two messages per second per link regardless of how many slaves are downstream.
And row six is the more interesting difference. End-to-end asymmetry is the whole path's asymmetry and it accumulates across every hop; peer delay measures each link's asymmetry separately, so a calibration can be applied per link — which is the only form in which a calibration is maintainable, because a link's asymmetry is a property of a cable and a path's is a property of a topology that changes.
==
The cost is that every device must implement it. An end-to-end deployment works with ordinary switches in the path, badly — Section 6's 6.07 µs from one store-and-forward hop. A peer-delay deployment requires every switch to speak the protocol, and one that does not breaks the chain entirely rather than degrading it.
Which is the choice, stated plainly: end-to-end degrades gracefully on equipment that does not participate, and peer delay does not work at all without it. The second is better wherever the whole path is under one organisation's control — the same condition Chapter 14.4 §17 found for lossless Ethernet, arriving from a completely different direction.
13. RTL 6 — The Announce Message and the Dataset
Everything so far assumed a master. Choosing one is a separate mechanism with no election, no voting and no message that carries a result.
// -----------------------------------------------------------------------
// announce_dataset -- holds the local clock's advertised quality and
// the best remote dataset seen, and decides which is better.
//
// There is no election. Every clock advertises what it is; every clock
// independently applies the same comparison; and the one that finds
// nothing better than itself becomes master. Section 14.
// -----------------------------------------------------------------------
module announce_dataset
import ptp_pkg::*;
#(
parameter int ANNOUNCE_TIMEOUT = 3 // 3 announce intervals
)(
input logic clk,
input logic rst_n,
// What we are.
input logic [7:0] local_priority1,
input logic [7:0] local_class,
input logic [7:0] local_accuracy,
input logic [15:0] local_variance,
input logic [7:0] local_priority2,
input logic [63:0] local_clock_id,
// An arriving Announce.
input logic ann_valid,
input logic [7:0] rx_priority1,
input logic [7:0] rx_class,
input logic [7:0] rx_accuracy,
input logic [15:0] rx_variance,
input logic [7:0] rx_priority2,
input logic [63:0] rx_clock_id,
input logic [7:0] rx_steps_removed,
input logic announce_tick, // one per announce interval
output logic we_are_master,
output logic [63:0] best_master_id,
output logic [7:0] best_steps_removed,
output logic [31:0] c_announces,
output logic [31:0] c_master_changes,
output logic [31:0] c_timeouts
);
// The best master clock algorithm's comparison, in order. Each field
// is only consulted if every earlier one ties. The LAST tiebreak is
// the clock identity, which is unique by construction -- so the
// comparison is TOTAL and two clocks can never both decide they win.
function automatic bit remote_is_better(
input logic [7:0] rp1, input logic [7:0] rc,
input logic [7:0] ra, input logic [15:0] rv,
input logic [7:0] rp2, input logic [63:0] rid,
input logic [7:0] lp1, input logic [7:0] lc,
input logic [7:0] la, input logic [15:0] lv,
input logic [7:0] lp2, input logic [63:0] lid);
begin
if (rp1 != lp1) return (rp1 < lp1);
if (rc != lc) return (rc < lc);
if (ra != la) return (ra < la);
if (rv != lv) return (rv < lv);
if (rp2 != lp2) return (rp2 < lp2);
return (rid < lid); // total order, always decides
end
endfunction
logic [7:0] b_p1, b_cls, b_acc, b_p2;
logic [15:0] b_var;
logic [63:0] b_id;
logic have_best;
logic [7:0] age;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
have_best <= 1'b0; age <= '0;
b_p1 <= '0; b_cls <= '0; b_acc <= '0; b_var <= '0;
b_p2 <= '0; b_id <= '0;
we_are_master <= 1'b1; // alone until told otherwise
best_master_id <= '0; best_steps_removed <= '0;
c_announces <= '0; c_master_changes <= '0; c_timeouts <= '0;
end else begin
if (ann_valid) begin
c_announces <= c_announces + 1;
age <= '0;
// Is this Announce better than the best we hold?
if (!have_best ||
remote_is_better(rx_priority1, rx_class, rx_accuracy,
rx_variance, rx_priority2, rx_clock_id,
b_p1, b_cls, b_acc, b_var, b_p2, b_id)) begin
have_best <= 1'b1;
b_p1 <= rx_priority1; b_cls <= rx_class;
b_acc <= rx_accuracy; b_var <= rx_variance;
b_p2 <= rx_priority2; b_id <= rx_clock_id;
if (b_id != rx_clock_id) c_master_changes <= c_master_changes + 1;
best_master_id <= rx_clock_id;
best_steps_removed <= rx_steps_removed;
end
end
if (announce_tick) begin
if (have_best) begin
age <= age + 1'b1;
if (age == ANNOUNCE_TIMEOUT-1) begin
// The master went quiet. Fall back to ourselves and let
// the comparison run again from scratch.
have_best <= 1'b0;
c_timeouts <= c_timeouts + 1;
end
end
end
// We are master exactly when nothing better has been heard.
we_are_master <= !have_best ||
!remote_is_better(b_p1, b_cls, b_acc, b_var, b_p2, b_id,
local_priority1, local_class, local_accuracy,
local_variance, local_priority2, local_clock_id);
end
end
endmoduleClassification: a comparison against a held best, with a timeout. No state machine, no negotiation, one total order.
What it teaches: that the comparison must be a total order and the clock identity is what makes it one. Every earlier field can tie — two devices with the same priority, class, accuracy and variance are entirely plausible, and are the common case in a rack of identical switches. The final tiebreak is the clock identity, which is derived from a MAC address and is unique by construction, so the comparison always decides. A design that stops at priority2 has a comparison that can return "equal", and two devices that each find nothing better than themselves both become master.
And it teaches that this is Chapter 15.3 §9's structure again, exactly. LACP agreed on who decides rather than on the selected set, and derived the set from that. BMCA agrees on a comparison function and each device applies it independently — no message carries the outcome, and correctness depends on every device evaluating the same function from the same inputs. Two implementations that order the fields differently produce two masters on one domain.
Deliberately simplified: only one best dataset is held, so a device with several ports sees them as one population. A boundary clock must run the comparison per port and then decide which port is its slave port — the one facing the best master — with every other port becoming a master port. That is the whole of a boundary clock's control plane and it is one comparison per port plus a selection across them.
Production implication: c_master_changes is the counter that turns a mysterious synchronisation outage into a topology event. A master change discards the servo's accumulated state — a new master means a new offset and possibly a new path delay — so every change costs a re-lock, which at Chapter 16.1 §4's holding times is seconds of degraded accuracy. A network where c_master_changes climbs steadily has two clocks alternating, usually because their datasets tie on every field an implementation compares and differ on one it does not.
14. BMCA — Choosing a Master Nobody Elected
The selection has five ordered fields and a tiebreak, and each field exists to express a different kind of claim.
| Field | Octets | What it claims | Set by |
|---|---|---|---|
| priority1 | 1 | "use me" or "do not" | the operator |
| clockClass | 1 | what I am traceable to | the clock's own state |
| clockAccuracy | 1 | how good I think I am | the implementation |
| offsetScaledLogVariance | 2 | how stable I am | measured |
| priority2 | 1 | the operator's tiebreak | the operator |
| clockIdentity | 8 | which device I am | unique — the final tiebreak |
priority1 is first because it is the override. An operator who wants a specific device to be master sets its priority1 low and every other device's high, and no amount of clock quality overrides it. Which is deliberate: the best clock in the room is frequently not the one you want as master, because mastership is also a topology decision.
clockClass is the one that changes at run time and is the most informative. It says what the clock is currently traceable to:
| clockClass | Meaning |
|---|---|
| 6 | a primary reference — GPS, locked |
| 7 | a primary reference in holdover |
| 13 | an application-specific source, locked |
| 14 | the same, in holdover |
| 187 | was a primary reference, now free-running |
| 248 | the default — no traceability at all |
A GPS-locked grandmaster advertises 6. The same device with its antenna unplugged advertises 7, then 187, and the BMCA moves mastership away from it automatically — which is the mechanism by which a network notices that its time source has gone blind. Without clockClass a failed GPS receiver keeps its mastership and keeps serving increasingly wrong time.
And the subtlety worth knowing: clockClass degrades on a schedule the device chooses. A device in holdover is still a better master than a free-running peer, for as long as its oscillator's drift keeps it inside the requirement — which is Chapter 16.1 §6's holdover arithmetic, used here as a protocol input. An OCXO can honestly hold class 7 for hours; a crystal cannot hold it for a minute, and a device that advertises the same holdover class regardless of its oscillator is lying to the algorithm.
==
15. RTL 7 — Exchange Telemetry
Six numbers, and the useful ones say whether the exchange's premise is holding rather than whether its messages are arriving.
// -----------------------------------------------------------------------
// exchange_telemetry -- what an operator needs to decide whether a PTP
// exchange is healthy, and — separately — whether its results can be
// trusted.
//
// The second question is the one Section 8 makes necessary: an
// exchange can be perfectly healthy and produce an offset that is
// wrong by half the path's asymmetry.
// -----------------------------------------------------------------------
module exchange_telemetry
import ptp_pkg::*;
#(
parameter int HISTORY = 16
)(
input logic clk,
input logic rst_n,
input logic result_valid,
input logic signed [63:0] offset_ns,
input logic signed [63:0] mean_path_ns,
input logic signed [63:0] round_trip_ns,
input logic [31:0] c_sync,
input logic [31:0] c_stale_sync,
input logic [31:0] c_timed_out,
input logic [31:0] c_negative,
input logic window_tick,
output logic signed [63:0] path_spread_ns, // max - min of mean_path
output logic signed [63:0] path_median_ns,
output logic [15:0] loss_pct_x100,
output logic path_is_stable,
output logic results_trustworthy,
output logic [31:0] c_windows
);
logic signed [63:0] hist [HISTORY];
logic [$clog2(HISTORY)-1:0] wp;
logic [$clog2(HISTORY):0] fill;
logic [31:0] sync_base, lost_base;
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
for (i = 0; i < HISTORY; i++) hist[i] <= '0;
wp <= '0; fill <= '0;
path_spread_ns <= '0; path_median_ns <= '0;
loss_pct_x100 <= '0; path_is_stable <= 1'b0;
results_trustworthy <= 1'b0; c_windows <= '0;
sync_base <= '0; lost_base <= '0;
end else begin
if (result_valid) begin
hist[wp] <= mean_path_ns;
wp <= wp + 1'b1;
if (fill != HISTORY) fill <= fill + 1'b1;
end
if (window_tick) begin
automatic logic signed [63:0] mx, mn;
automatic logic [31:0] syncs, lost;
mx = hist[0]; mn = hist[0];
for (i = 1; i < HISTORY; i++) begin
if (hist[i] > mx) mx = hist[i];
if (hist[i] < mn) mn = hist[i];
end
path_spread_ns <= mx - mn;
// A crude median: the midpoint of the observed range is good
// enough to spot a path that has changed, which is the use.
path_median_ns <= (mx + mn) >>> 1;
syncs = c_sync - sync_base;
lost = (c_stale_sync + c_timed_out) - lost_base;
loss_pct_x100 <= (syncs == 0) ? 16'd0
: 16'((lost * 10000) / syncs);
sync_base <= c_sync;
lost_base <= c_stale_sync + c_timed_out;
// A stable path is one whose measured delay is not moving.
// A moving path delay means the asymmetry is also moving, so
// no stored calibration applies.
path_is_stable <= ((mx - mn) < 64'sd1000) && (fill == HISTORY);
// Trustworthy means: messages are arriving, the arithmetic is
// sane, and the path is stable enough for a calibration to
// mean anything. It does NOT mean the offset is correct --
// section 8, and section 19's rejected property.
results_trustworthy <= (fill == HISTORY) &&
(c_negative == '0) &&
((mx - mn) < 64'sd1000);
c_windows <= c_windows + 1;
end
end
end
endmoduleClassification: a windowed spread estimator over the measured path delay, plus a loss rate and two judgements.
What it teaches: that path_spread_ns is the measurement that decides whether anything else in the chapter is usable, for the same reason Chapter 16.1 §5's offset_spread_ns was. A path whose measured delay varies by 20 µs between samples has queueing in it — Chapter 12.6's store-and-forward plus Chapter 14.1's backlog — and its asymmetry varies with it, so no stored calibration applies and Section 8's residual is not even constant.
And it teaches why results_trustworthy deliberately does not include the offset. The offset is the output; its correctness depends on a premise this device cannot evaluate. What the bit says is that the inputs are sound and the path is stable enough for the premise to be stably wrong — which is the condition under which a calibration is worth storing. A device reporting results_trustworthy high and a 6 µs error has a constant asymmetry waiting to be calibrated out.
Deliberately simplified: the median is the midpoint of the range, which is not a median and is badly affected by a single outlier. A production design sorts or uses a running quantile estimator; the midpoint is used here because the use is detecting a path that moved, and a path that moved moves both ends of the range.
Production implication: loss_pct_x100 is the counter that connects Section 10's arithmetic to reality. At 1 Sync/s on a 100 ppm crystal, a 1% loss rate means the slave spends 1% of its time more than 100 µs out of specification — which for a requirement of 1 µs is a violation one second in a hundred, invisible in any average. The remedy is not a better servo; it is Chapter 13.4 §9's priority mapping configured to put PTP in a queue that does not drop.
16. RTL 8 — Conformance for an Exchange
The monitor checks that the exchange was conducted correctly. It deliberately does not check that its answer is right.
// -----------------------------------------------------------------------
// exchange_conformance_monitor -- one bit.
//
// Every check is evaluable from messages this device sent or received.
// Section 19's rejected property is the one check that is not, and it
// is the one an operator most wants this bit to mean.
// -----------------------------------------------------------------------
module exchange_conformance_monitor
import ptp_pkg::*;
(
input logic clk,
input logic rst_n,
input logic negative_round_trip,
input logic paired_across_masters, // t1,t2 and t3,t4 from different masters
input logic twostep_flag_ignored,
input logic resp_not_for_us_used, // matched on seq alone
input logic correction_sign_wrong, // added instead of subtracted
input logic cfg_domain_mismatch,
input logic cfg_no_master_selected,
input logic dreq_interval_not_random,
output logic conformant,
output logic [7:0] fault_vector,
output logic [31:0] c_violations
);
logic v_neg, v_cross, v_flag, v_wrong_resp, v_sign, v_dom, v_nomaster, v_burst;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
v_neg <= 1'b0; v_cross <= 1'b0; v_flag <= 1'b0;
v_wrong_resp <= 1'b0; v_sign <= 1'b0; v_burst <= 1'b0;
v_dom <= 1'b0; v_nomaster <= 1'b0; c_violations <= '0;
end else begin
// Runtime violations, sticky: each is a real defect and one
// occurrence is the finding.
if (negative_round_trip) begin v_neg <= 1'b1; c_violations <= c_violations + 1; end
if (paired_across_masters) begin v_cross <= 1'b1; c_violations <= c_violations + 1; end
if (twostep_flag_ignored) begin v_flag <= 1'b1; c_violations <= c_violations + 1; end
if (resp_not_for_us_used) begin v_wrong_resp <= 1'b1; c_violations <= c_violations + 1; end
if (correction_sign_wrong) begin v_sign <= 1'b1; c_violations <= c_violations + 1; end
// Standing configuration properties -- wrong from power-on,
// not from the first message. 14.1 section 17's argument.
v_dom <= cfg_domain_mismatch;
v_nomaster <= cfg_no_master_selected;
v_burst <= dreq_interval_not_random;
end
end
assign conformant = !(v_neg || v_cross || v_flag || v_wrong_resp ||
v_sign || v_dom || v_nomaster || v_burst);
assign fault_vector = {v_burst, v_nomaster, v_dom, v_sign,
v_wrong_resp, v_flag, v_cross, v_neg};
endmoduleClassification: a sticky fault aggregator with five runtime violations and three standing configuration terms.
What it teaches: that paired_across_masters is a check nothing else would catch and it matters at exactly the wrong moment. A BMCA master change between a Sync and a Delay_Resp leaves the slave holding (t1, t2) from one master and (t3, t4) from another — two different clocks, two different paths, and Section 7's arithmetic combines them without complaint. The resulting offset is nonsense and the round trip may well be positive, so negative_round_trip does not fire. The check is a comparison of two sourcePortIdentity fields the design already parsed.
And it teaches that correction_sign_wrong is detectable locally, which is not obvious. Adding the correction field instead of subtracting it makes the computed path delay grow when transparent clocks are enabled, and the sign of the change is the evidence: enabling residence-time correction must reduce the measured wire delay or leave it alone. A design that measures mean_path_ns before and after can catch its own inverted sign without a reference clock.
Deliberately simplified: dreq_interval_not_random is presented as a configuration input. Detecting it properly means measuring the variance of the device's own Delay_Req intervals and comparing against the standard's requirement, which is a few counters — and worth having, because a burst-generating slave is invisible to itself and visible only as load at the master.
Production implication: conformant here means the exchange was conducted to specification and says nothing about the accuracy achieved. A slave behind one store-and-forward switch is fully conformant and 6.07 µs wrong, which is correct and is the thing operators most want the bit to mean. Section 15's results_trustworthy and path_spread_ns are where the accuracy question lives, and the two outputs are separate because one can be true while the other is false in both directions.
17. What the Exchange Can and Cannot Promise
Put the guarantees and the non-guarantees side by side, because the boundary is where every argument about PTP accuracy sits.
| Claim | Status |
|---|---|
| the round-trip delay is measured exactly | guaranteed — equation (3), no premise |
| the drift is measurable across an unknown path | guaranteed — Chapter 16.1 §5 |
| a lost message costs one interval's drift and nothing else | guaranteed — Section 10 |
| the offset is measured exactly | only if d_ms = d_sm |
| the error is exactly half the imbalance | guaranteed — and that is the useful form |
| both ends pick the same master | only if both implement the same comparison |
| the master is the best clock | no — priority1 overrides quality |
| averaging improves the offset | its variance, not its mean |
Rows four and five are the same fact stated pessimistically and constructively, and the constructive form is the one to carry.
The offset may be wrong is unactionable. "The offset is wrong by exactly (d_sm − d_ms)/2" is a specification: it says the error is constant while the path is, which means it is calibratable, which is why Chapter 16.5 §11 has a procedure and not an apology.
And row eight is the one that wastes the most engineering time. Section 8's table: averaging reduces the variance of the offset estimate and leaves its mean displaced by the same amount every sample was displaced by. A thousand samples give a tight estimate of the wrong number, and the tightness is frequently reported as accuracy.
Which gives the honest summary of the exchange:
Four timestamps measure a round trip and split it by assumption. The measurement is exact and the split is not, and everything the protocol achieves beyond half-the-asymmetry comes from somewhere else — Chapter 16.4's residence-time correction removing the switches' contribution, and Chapter 16.5's calibration removing the cables'.
18. The Cost of the Exchange, Accounted
The protocol is cheap on the wire and the cost lands somewhere else entirely.
| Component | At 16/s per slave | Against what |
|---|---|---|
| Sync + Follow_Up | 168 octets/s | — |
| Delay_Req + Delay_Resp | 176 octets/s | — |
| Announce at 1/8 s | 12.75 octets/s | — |
| two-step total | 5517 octets/s — 44 134 bit/s | 4.4 × 10⁻³% of 1 Gb/s |
| one-step total | 4173 octets/s — 33 382 bit/s | saves 10 752 bit/s |
| at 1/s, two-step | 2854 bit/s | 2.9 × 10⁻⁴% |
| at 128/s, two-step | 352 358 bit/s | 3.5 × 10⁻²% |
| state at the slave | 4 Sync slots + 8 Delay slots | ≈220 octets |
| the master's load, 1024 slaves at 128/s | 131 328 msg/s | the real cost |
The last row is the cost and it is not bandwidth. 131 328 messages per second is 107 Mb/s of control traffic on a 100 Gb/s fabric — negligible — and it is a device that must generate, timestamp and transmit a message every 7.6 microseconds. At that rate the master's timestamp unit, not its link, is the constraint.
And Section 12's peer delay changes that row and only that row:
| end-to-end | peer delay | |
|---|---|---|
| master's messages/s, 1024 slaves at 128/s | 131 328 | 256 |
| messages per link | 2 | 2 or 3 |
| requires every switch to participate | no | yes |
Which is the trade in one line: end-to-end concentrates the load at the master and works on ordinary switches badly; peer delay distributes it and does not work on ordinary switches at all.
Compared against the batch's other mechanisms, the exchange is the cheapest thing in Module 16:
| Mechanism | State | What it buys |
|---|---|---|
| the exchange — this chapter | ≈220 octets | an offset, up to half the asymmetry |
| LACP — Chapter 15.3 §17 | ≈320 octets | the mis-cable, the one-way failure |
| distribution — Chapter 15.2 §18 | 3.8 KiB | 25% instead of 74.9% at a failover |
| PFC — Chapter 14.4 §18 | 1.45 MiB | collateral 88% → 11% |
Two hundred and twenty octets, and the thing it produces is wrong by half a cable's asymmetry. Which is the right shape: the protocol is the cheap part and the accuracy is bought by the timestamp unit, the transparent clocks and the calibration — the three chapters that follow.
19. Properties Worth Asserting, and One Worth Refusing
The properties divide by what they protect: the header, the pairing, the arithmetic, the sequence tracking, the BMCA, and the configuration.
Group 1 — the header.
// P1. A PTP message is recognised only on the PTP EtherType.
property p_ethertype_gate;
@(posedge clk) disable iff (!rst_n)
hdr_valid |-> $past(et_ok);
endproperty
// P2. Nothing is published on a bad FCS.
property p_no_commit_bad_fcs;
@(posedge clk) disable iff (!rst_n)
(rx_eop && !rx_fcs_ok) |-> !hdr_valid;
endproperty
// P3. The event/general split is exactly the top bit of the message
// type. 16.3's timestamp unit depends on this being cheap.
property p_event_is_top_bit;
@(posedge clk) disable iff (!rst_n)
hdr_valid |-> (is_event_msg == !msg_type[3]);
endproperty
// P4. A tagged frame is parsed at the shifted offsets. 13.2 section 7.
property p_tag_shifts_offsets;
@(posedge clk) disable iff (!rst_n)
(hdr_valid && rx_tagged) |-> (base == 8'd18);
endproperty
// P5. Exactly one type counter moves per accepted message.
property p_type_counter_partition;
@(posedge clk) disable iff (!rst_n)
hdr_valid |=> ($countones(type_counter_changed) == 1);
endpropertyGroup 2 — Sync and Follow_Up pairing.
// P6. A one-step Sync publishes immediately and never parks.
property p_onestep_is_immediate;
@(posedge clk) disable iff (!rst_n)
(hdr_valid && (msg_type == MSG_SYNC) && !flags[9]) |=> pair_valid;
endproperty
// P7. A two-step Sync never publishes on its own.
property p_twostep_waits;
@(posedge clk) disable iff (!rst_n)
(hdr_valid && (msg_type == MSG_SYNC) && flags[9]) |=> !pair_valid;
endproperty
// P8. A published pair's t2 came from the Sync with the SAME
// sequence id as the Follow_Up that completed it.
property p_pair_is_same_sequence;
@(posedge clk) disable iff (!rst_n)
pair_valid |-> (pair_seq == $past(seq_id));
endproperty
// P9. Only the configured master's Syncs are honoured.
property p_only_our_master;
@(posedge clk) disable iff (!rst_n)
pair_valid |-> ($past(src_port.clock_id) == cfg_master.clock_id);
endproperty
// P10. An evicted unmatched Sync is always counted.
property p_eviction_counted;
@(posedge clk) disable iff (!rst_n)
(hdr_valid && (msg_type == MSG_SYNC) && flags[9] && slots[wp].busy)
|=> $changed(c_stale_sync);
endpropertyGroup 3 — the arithmetic.
// P11. The round trip is non-negative. It is a physical duration and
// a negative value is evidence about the inputs.
property p_round_trip_is_physical;
@(posedge clk) disable iff (!rst_n)
(result_valid && !negative_path) |-> (round_trip_ns >= 0);
endproperty
// P12. mean_path is exactly half the round trip -- equation (3),
// which carries no premise.
property p_mean_path_is_half_round_trip;
@(posedge clk) disable iff (!rst_n)
result_valid |-> (mean_path_ns == (round_trip_ns >>> 1));
endproperty
// P13. The correction field is SUBTRACTED. Adding it doubles the
// error transparent clocks were correcting.
property p_correction_is_subtracted;
@(posedge clk) disable iff (!rst_n)
(ms_valid && (corr_ms > 0)) |=> (d_ms < ts_diff(t2, t1));
endproperty
// P14. No result is published until BOTH halves have been seen.
property p_needs_both_halves;
@(posedge clk) disable iff (!rst_n)
result_valid |-> (ms_have && sm_have);
endproperty
// P15. A negative round trip is always flagged and counted.
property p_negative_flagged;
@(posedge clk) disable iff (!rst_n)
(result_valid && (round_trip_ns < 0)) |-> negative_path;
endproperty
// P16. Under the premise, the offset is exact. The premise is an
// explicit antecedent -- section 19's rejected property is what
// happens when it is left implicit.
property p_offset_correct_when_symmetric;
@(posedge clk) disable iff (!rst_n)
(result_valid && (tb_d_ms == tb_d_sm)) |->
(offset_ns == (tb_slave_ns - tb_master_ns));
endproperty
// P17. And when the premise fails, the error is EXACTLY half the
// imbalance. A stronger and more useful claim than the one refused.
property p_error_is_half_the_imbalance;
@(posedge clk) disable iff (!rst_n)
result_valid |-> ((offset_ns - (tb_slave_ns - tb_master_ns)) ==
((tb_d_sm - tb_d_ms) >>> 1));
endpropertyGroup 4 — sequence tracking.
// P18. A Delay_Resp addressed to another slave is never used.
property p_resp_must_be_for_us;
@(posedge clk) disable iff (!rst_n)
sm_valid |-> ($past(dresp_requesting.clock_id) == cfg_self.clock_id);
endproperty
// P19. A matched pair's t3 came from the request with that sequence id.
property p_t3_matches_sequence;
@(posedge clk) disable iff (!rst_n)
sm_valid |-> ($past(dresp_seq) inside {outstanding_seqs});
endproperty
// P20. Every outstanding request eventually frees its slot -- matched
// or aged out. Without this the tracker fills and stops silently.
property p_slots_always_free;
@(posedge clk) disable iff (!rst_n)
$rose(q[i].busy) |-> ##[1:TIMEOUT_CYCLES+1] !q[i].busy;
endproperty
// P21. A Delay_Req is never issued while its predecessor's transmit
// timestamp is outstanding.
property p_one_outstanding_tx_ts;
@(posedge clk) disable iff (!rst_n)
(dreq_tx) |-> !$past(awaiting_ts);
endproperty
// P22. The Delay_Req interval is randomised: consecutive intervals
// are not equal. A fixed interval produces section 11's burst.
property p_interval_is_randomised;
@(posedge clk) disable iff (!rst_n)
dreq_tx |=> (target != $past(target));
endpropertyGroup 5 — BMCA.
// P23. The comparison is a TOTAL order: it never returns "equal",
// because the clock identity always decides.
property p_comparison_is_total;
@(posedge clk) disable iff (!rst_n)
(a_id != b_id) |-> (remote_is_better(a) != remote_is_better(b));
endproperty
// P24. priority1 dominates everything. An operator override is not
// overridden by clock quality.
property p_priority1_dominates;
@(posedge clk) disable iff (!rst_n)
(rx_priority1 < local_priority1) |=> !we_are_master;
endproperty
// P25. We are master exactly when nothing better has been heard.
property p_master_iff_best;
@(posedge clk) disable iff (!rst_n)
we_are_master <-> (!have_best || !remote_is_better(best, local));
endproperty
// P26. An announce timeout falls back to ourselves rather than
// holding a master that has gone quiet.
property p_timeout_releases_master;
@(posedge clk) disable iff (!rst_n)
(announce_tick && (age == ANNOUNCE_TIMEOUT-1)) |=> !have_best;
endproperty
// P27. Every master change is counted -- each one costs a re-lock.
property p_master_change_counted;
@(posedge clk) disable iff (!rst_n)
$changed(best_master_id) |=> $changed(c_master_changes);
endpropertyGroup 6 — configuration and conformance.
// P28. Standing property: the domain matches. A device on the wrong
// domain hears everything and uses none of it.
property p_domain_matches;
@(posedge clk) disable iff (!rst_n)
!cfg_domain_mismatch;
endproperty
// P29. A pair is never assembled across a master change.
property p_no_cross_master_pairing;
@(posedge clk) disable iff (!rst_n)
result_valid |-> !paired_across_masters;
endproperty
// P30. results_trustworthy requires a stable path -- it is about the
// premise's stability, not about the offset's correctness.
property p_trustworthy_needs_stable_path;
@(posedge clk) disable iff (!rst_n)
results_trustworthy |-> (path_spread_ns < 64'sd1000);
endproperty
// P31. conformant is exactly the conjunction it claims.
property p_conformant_definition;
@(posedge clk) disable iff (!rst_n)
conformant <-> (fault_vector == 8'h00);
endpropertyP11 through P15 are about the measurement, P16 and P17 about the premise, and P23 through P27 about a comparison every participant must evaluate identically. None of them says the computed offset is the true offset — which is the property this chapter refuses, and the reason is a kind of unavailability the series has not met before.
20. Verification Scenarios
Seventy-two scenarios. Several have expected outcomes in which every message is correct, every check passes, and the offset is wrong by six microseconds.
The header
| # | Scenario | Expected |
|---|---|---|
| 1 | Untagged PTP frame | parsed at base 14 |
| 2 | 802.1Q-tagged PTP frame | parsed at base 18 |
| 3 | Same, hard-coded untagged parser | message type read from mid-header — matches nothing |
| 4 | Same, on a trunk port | every PTP frame mis-parsed |
| 5 | Sync, type 0x0 | is_event_msg high |
| 6 | Follow_Up, type 0x8 | is_event_msg low |
| 7 | Announce, type 0xB | general |
| 8 | Bad FCS | nothing published, c_bad_fcs + 1 |
| 9 | Wrong domain | c_wrong_domain; slave never locks on a healthy link |
| 10 | correctionField at header octets 8–15 | present in every message type |
| 11 | Sync frame size | 64 octets, 84 on the wire |
| 12 | Delay_Resp frame size | 72 octets, 92 on the wire |
| 13 | Announce frame size | 82 octets, 102 on the wire |
Sync and Follow_Up
| # | Scenario | Expected |
|---|---|---|
| 14 | Two-step master, Sync then Follow_Up | pair published on the Follow_Up |
| 15 | One-step master, Sync alone | pair published immediately |
| 16 | Design always waits, one-step master | c_sync rises, c_followup = 0, no pair ever |
| 17 | Design never waits, two-step master | reads originTimestamp = 0 |
| 18 | Same | offset ≈ the current epoch |
| 19 | Two Syncs before the first Follow_Up | both parked — needs 2+ slots |
| 20 | Same, single-slot design | older pair silently dropped |
| 21 | Same, with the counter | c_stale_sync |
| 22 | Follow_Up with no matching Sync | c_orphan_fu |
| 23 | Sync from a non-selected master | ignored |
The four timestamps
| # | Scenario | Expected |
|---|---|---|
| 24 | Symmetric path, 500 ns each way | offset exact, mean_path = 500 ns |
| 25 | Same, offset = +40 µs | recovered exactly |
| 26 | Asymmetric: d_ms = 500, d_sm = 550 | mean_path = 525 — exact |
| 27 | Same | offset error = +25 ns — half the imbalance |
| 28 | 10 m of fibre imbalance | 25 ns |
| 29 | 100 m of fibre imbalance | 250 ns |
| 30 | Asymmetric PHY, 40 ns | 20 ns |
| 31 | One store-and-forward switch, 12.14 µs one way | 6.07 µs |
| 32 | 1000 samples averaged, asymmetric path | variance falls, mean unchanged |
| 33 | Faster clock, asymmetric path | no change |
| 34 | A third device added | no help — three more unknowns |
| 35 | Cable measured physically | the asymmetry, and it is not a network measurement |
| 36 | correctionField subtracted | mean_path falls with transparent clocks |
| 37 | correctionField added | mean_path rises — the inverted sign |
| 38 | Clock stepped between t2 and t3 | negative_path |
| 39 | Fresh (t1,t2) with stale (t3,t4) | two path measurements mixed |
| 40 | Arithmetic shift on a signed value | −0.5 ns systematic bias |
Pairing and loss
| # | Scenario | Expected |
|---|---|---|
| 41 | Delay_Resp for another slave, same seq | c_not_for_us, not used |
| 42 | Same, matched on seq alone | our t3 with their t4 |
| 43 | Delay_Resp never arrives | slot ages out, c_timed_out |
| 44 | Eight lost responses, no age-out | tracker full, mechanism stops silently |
| 45 | Lost Sync at 1/s, 100 ppm | 100 µs of drift accrued |
| 46 | Lost Sync at 16/s, 100 ppm | 6.25 µs |
| 47 | Lost Sync at 128/s, 100 ppm | 0.78 µs |
| 48 | Lost Sync at 16/s, 0.1 ppm | 0.006 µs |
| 49 | 1% loss at 1/s, 100 ppm | 1% of the time, >100 µs out |
| 50 | Same, in an average | invisible |
| 51 | PTP in the bulk queue, congested egress | Syncs dropped by Chapter 14.1 |
| 52 | PTP in a priority queue | not dropped |
| 53 | Delay_Req at a fixed offset after Sync | all slaves transmit together |
| 54 | 1024 slaves, same seed | a 1024-message burst at the master |
| 55 | Randomised interval | spread over 0.75–1.25 of nominal |
| 56 | TX timestamp lost | c_no_tx_ts, request abandoned |
| 57 | Same, no interlock | t3 from the wrong frame |
BMCA
| # | Scenario | Expected |
|---|---|---|
| 58 | Two identical switches, no identity tiebreak | both become master |
| 59 | Same, with the identity tiebreak | exactly one |
| 60 | priority1 lower on a worse clock | the worse clock wins |
| 61 | GPS locked | clockClass 6 |
| 62 | Antenna unplugged | 7, then 187 |
| 63 | Same | mastership moves away automatically |
| 64 | Same, no clockClass degradation | a blind master keeps serving |
| 65 | Crystal advertising holdover class 7 | lying to the algorithm |
| 66 | Master goes quiet, 3 announce intervals | c_timeouts, fall back to self |
| 67 | Two implementations ordering fields differently | two masters, both conformant |
| 68 | Master change between Sync and Delay_Resp | paired_across_masters |
| 69 | Same | round trip may be positive — negative_path does not fire |
| 70 | Master change | c_master_changes, servo re-locks |
| 71 | Boundary clock, one dataset for all ports | cannot choose a slave port |
| 72 | Same, per-port comparison | correct |
The directed test random stimulus will not produce
A path asymmetry is not a traffic condition, a fault, or a distribution — it is a property of the testbench's own delay model, and a random stimulus generator varies frames rather than cable lengths. And the finding is the chapter's central claim: that the round trip is measured exactly while the offset is not, which requires driving the identical exchange through two different delay models and comparing. No coverage metric asks for that.
Setup: one master, one slave, a delay model with independently settable d_ms and d_sm. The slave's true offset is fixed at exactly +40 000 ns — known to the testbench, which Chapter 16.1 §19 established is the only place it can exist. Two-step master, 16 Sync/s, 16 Delay_Req/s, 1000 exchanges per run.
Stimulus, four runs.
Run A — symmetric: d_ms = d_sm = 500 ns.
Run B — symmetric, large: d_ms = d_sm = 12 640 ns, a store-and-forward hop each way.
Run C — asymmetric, small: d_ms = 500, d_sm = 550 ns — 10 m of fibre.
Run D — asymmetric, large: d_ms = 12 640, d_sm = 500 ns — a switch that queued one direction and not the other.
Oracle:
| # | Observable | A — sym 500 | B — sym 12 640 | C — asym 50 | D — asym 12 140 |
|---|---|---|---|---|---|
| 1 | round_trip_ns | 1000 | 25 280 | 1050 | 13 140 |
| 2 | round_trip_ns error | 0 | 0 | 0 | 0 |
| 3 | mean_path_ns | 500 | 12 640 | 525 | 6570 |
| 4 | true d_ms | 500 | 12 640 | 500 | 12 640 |
| 5 | mean_path error | 0 | 0 | +25 | −6070 |
| 6 | offset_ns | 40 000 | 40 000 | 40 025 | 33 930 |
| 7 | offset error | 0 | 0 | +25 | −6070 |
| 8 | error against (d_sm−d_ms)/2 | exact | exact | exact | exact |
| 9 | 1000 samples averaged, offset | 40 000 | 40 000 | 40 025 | 33 930 |
| 10 | offset variance after averaging | → 0 | → 0 | → 0 | → 0 |
| 11 | negative_path | low | low | low | low |
| 12 | path_spread_ns | ≈0 | ≈0 | ≈0 | ≈0 |
| 13 | results_trustworthy | high | high | high | high |
| 14 | conformant | high | high | high | high |
| 15 | c_violations | 0 | 0 | 0 | 0 |
| 16 | rerun C with the imbalance stored and subtracted | — | — | error 0 | — |
Rows 1 and 2 are the first finding: the round trip is exact in all four runs, including both asymmetric ones. Equation (3) carries no premise and the measurement proves it.
Rows 6 to 8 are the second: the offset error is exactly (d_sm − d_ms)/2, every time, to the nanosecond. Run B shows that a large symmetric delay costs nothing — 12.64 µs each way, offset exact — and Run D shows that a large asymmetry costs half of itself. The two runs have almost the same total path and opposite outcomes.
Rows 9 to 15 are the third and the most important operationally. A thousand samples drive the variance to zero while the mean stays displaced; path_spread_ns reads near zero because the path is stable; results_trustworthy and conformant are both high; no violation counter moves. Run D is a fully conformant exchange producing an offset 6.07 µs wrong, and every instrument in Sections 15 and 16 says it is healthy.
Row 16 is the remedy and the reason results_trustworthy is defined the way it is: a stable path means a constant error, which is exactly the condition under which a stored calibration works.
21. Debugging a PTP Exchange
Five questions, in order. The first three are about messages and the last two are about the premise.
Step 1 — are the messages arriving and being parsed? c_by_type across all sixteen types. All zeros on a link carrying PTP means the parser is not recognising them — a tagged frame against an untagged parser is the common cause, and Section 3's simplification is exactly that bug. A domain mismatch presents identically from the protocol's side and is separated by c_wrong_domain.
Step 2 — are pairs being assembled? c_sync, c_followup, c_stale_sync, c_orphan_fu. c_sync rising with c_followup at zero is a one-step master against a design that waits. c_stale_sync rising means Syncs are arriving faster than Follow_Ups are being matched. c_orphan_fu rising means Syncs are being lost or not parsed.
Step 3 — is the slave-to-master half working? c_matched, c_timed_out, c_not_for_us. c_timed_out rising means Delay_Resps are not coming back — a master that is overloaded, Section 4's last row, or a unicast/multicast configuration mismatch. c_not_for_us is normal and counts the other slaves on the segment.
Step 4 — is the arithmetic sound? negative_path and c_negative. A negative round trip is physically impossible and therefore evidence about the inputs: a cross-master pairing, a wrong Follow_Up match, or a clock stepped mid-exchange. paired_across_masters separates the first from the others.
Step 5 — is the premise holding? path_spread_ns and mean_path_ns. This is the step that is almost never taken and it is where the accuracy lives. A mean_path_ns of 6.5 µs on a link whose cable is 100 m — 500 ns — says 6 µs of the measured path is queueing, which means the asymmetry is that large too and the offset is wrong by half of it. A path_spread_ns of 20 µs says the asymmetry is not even constant, so no calibration applies.
And the finding that ends an investigation without a fault: messages flowing, pairs assembling, negative_path low, conformant high, path_spread_ns near zero — and an offset error that matches path_asymmetry / 2. That is the protocol working exactly as specified, and the remaining error belongs to Chapter 16.4's transparent clocks and Chapter 16.5's calibration rather than to anything in this chapter.
22. Common Misconceptions
1 — "PTP measures the one-way delay."
The wrong model: the exchange determines how long a message takes to travel.
What it costs: confidence in a number that is half measurement and half assumption. Equation (3) measures the round trip exactly and carries no premise. The one-way delay is half of it, and halving it is the assumption — the only way to get a one-way number out of a two-way measurement.
The corrected model: mean_path is a measurement of (d_ms + d_sm)/2 and is exact. It equals d_ms only if the path is symmetric, and the difference propagates straight into the offset at half the imbalance.
2 — "More messages would let PTP measure the asymmetry."
The wrong model: the limitation is a shortage of data.
What it costs: designs that raise the message rate expecting accuracy and get only freshness. Section 8's table: more Syncs, more Delay_Reqs, averaging, a faster clock and even a third device all fail, because every (t1,t2) sample contains d_ms + offset and every (t3,t4) sample contains d_sm − offset. Four unknowns remain three equations short whatever the sample count.
The corrected model: the asymmetry is measurable only outside the protocol — by measuring the cable, or against a reference known to be symmetric. That is a commissioning procedure, and a deployment that did not perform it carries the error for ever.
3 — "A big path delay is a big error."
The wrong model: accuracy degrades with distance.
What it costs: effort shortening paths that were never the problem. Run B of Section 20's directed test: 12.64 µs each way, symmetric, and the offset is exact to the nanosecond. Run D has almost the same total path, asymmetrically distributed, and is 6.07 µs wrong.
The corrected model: only the imbalance matters, and it enters at exactly half. A 10 km symmetric fibre pair is better than a 10 m asymmetric one, which is the opposite of the intuition and is why a deliberately matched pair of fibres is worth specifying.
4 — "We averaged a thousand samples, so we are accurate to nanoseconds."
The wrong model: a tight estimate is an accurate one.
What it costs: a reported accuracy that is a measure of the estimator rather than of the clock. Averaging drives the variance toward zero and leaves the mean displaced by (d_sm − d_ms)/2 — because every sample is displaced by the same amount. Section 20's rows 9 and 10: variance → 0, error unchanged at 6.07 µs.
The corrected model: report the spread and name the bias separately. A tight spread with a constant path is good news — it means the error is constant and therefore calibratable — but it is not accuracy, and results_trustworthy is deliberately defined to say the former and not the latter.
5 — "Both ends run 1588, so they will agree on a master."
The wrong model: conformance implies identical behaviour.
What it costs: two masters on one domain, each serving a different time, with every device individually conformant. BMCA has no election and no message carrying the result — each clock applies the comparison independently, so correctness requires every implementation to order the same fields the same way and to reach the identity tiebreak.
The corrected model: the comparison function is part of the interface. A design that stops before the clockIdentity tiebreak has a comparison that can return "equal", and two identical switches — the common case in a rack — both find nothing better than themselves.
6 — "PTP will work over the existing network."
The wrong model: it is an application protocol.
What it costs: Section 20's Run D. A single store-and-forward switch that queues one direction and not the other introduces 12.14 µs of asymmetry at 1 Gb/s, and the offset is wrong by 6.07 µs — three orders of magnitude worse than the endpoints' own residual. And the Syncs themselves are in whatever queue Chapter 13.4 §9's mapping put them in, so they are dropped by Chapter 14.1's allocator exactly when time matters most.
The corrected model: sub-microsecond PTP requires every device in the path to participate — as a transparent clock reporting its residence time, or as a boundary clock terminating and re-originating the exchange. Chapter 16.1 §15 said it as an endpoint result; this chapter says it as an arithmetic one.
23. Interview Reasoning
Q1 — Why does PTP need four messages?
Because one message gives one equation in two unknowns. t2 − t1 = d_ms + offset — and a second Sync does not help, because the offset appears identically in both samples while a constant delay cancels, which is Chapter 16.1 §5's drift-survives-offset-does-not. Sending something back gives a second, independent equation — t4 − t3 = d_sm − offset — and two equations in two unknowns are solvable. The extra two messages exist to carry timestamps that could not ride in the messages they describe: t1 is not known when the Sync is transmitted, and t4 is known only at the master.
Q2 — Derive the offset and the path delay.
Add and subtract. (t2−t1) + (t4−t3) = d_ms + d_sm — the round trip, with the offset cancelled, and this is a genuine measurement. (t2−t1) − (t4−t3) = d_ms − d_sm + 2·offset — two unknowns, and four timestamps cannot separate them. So the protocol assumes d_ms = d_sm, giving mean_path = sum/2 and offset = diff/2, and the offset's residual error is exactly (d_sm − d_ms)/2 — half the imbalance.
Q3 — How much does asymmetry cost, concretely?
Exactly half of itself. 10 m of fibre length difference is 50 ns of imbalance and 25 ns of offset error; 100 m is 250 ns; an asymmetric PHY at 40 ns is 20 ns. And one store-and-forward switch that queues a Sync for a full frame time and a Delay_Req for nothing is 12.14 µs at 1 Gb/s — 6.07 µs of error, three orders of magnitude worse than a good endpoint's own residual. It does not average out, because the samples are not noisy; they are consistently displaced.
Q4 — Can any amount of measurement find the asymmetry?
No, not from inside the protocol. More Syncs give more samples of d_ms + offset; more Delay_Reqs give more of d_sm − offset; averaging reduces variance and leaves the mean where it was; a faster clock refines a number that is already wrong; and a third device adds two more paths with three more unknowns. The asymmetry is measurable only outside — a physical cable measurement, or a comparison against a reference known to be symmetric. That is a commissioning step, and it produces a constant to store and subtract.
Q5 — How does PTP choose a master, and what breaks it?
There is no election. Each clock advertises a dataset in Announce messages — priority1, clockClass, clockAccuracy, offsetScaledLogVariance, priority2, clockIdentity — and every clock independently applies the same ordered comparison. The one that finds nothing better than itself becomes master. It works because the comparison is a total order: the clock identity, unique by construction, always decides. What breaks it is two implementations comparing the fields in a different order, or stopping before the identity tiebreak — at which point two identical switches each find nothing better than themselves and there are two masters.
Q6 — Why can't you assert that the computed offset is correct?
Because the formula is a theorem conditional on a premise about cables and queues. offset = ((t2−t1) − (t4−t3))/2 is exactly right when d_ms = d_sm and wrong by half the difference otherwise, and that difference is outside the design, outside the protocol, and outside anything four timestamps can observe. So the assertion passes vacuously on a symmetric testbench, fails by a fixed amount on an asymmetric one, and is absent in silicon. The properties that hold are: the round trip is exact; the formula is evaluated correctly; the result is right when the path is symmetric; and — the strongest — the error is exactly (d_sm − d_ms)/2, which catches sign and shift bugs at any asymmetry.
24. Understanding Check
25. What's Next
This chapter produced two numbers and deliberately left three problems open.
The numbers: a round-trip delay that is exact, and an offset that is exact up to half the path's asymmetry.
The first problem is the one Section 2 named and Section 5 worked around. A Sync must carry the time of its own transmission, and that time is not known until the message is already leaving. Two-step sends it afterwards in a Follow_Up; one-step rewrites the field in flight and must then fix the FCS mid-frame. Chapter 16.3 — Hardware Timestamping at the MAC/PHY Boundary builds both, prices the FCS recomputation against Chapter 12.1 §12's frame budget, and places the unit at the boundary Chapter 16.1 §13 derived.
The second is the correction this chapter subtracted without producing. correctionField arrives holding residence time, and something has to put it there. Chapter 16.4 — The Servo closes the loop on the offset using Chapter 16.1 §3's rate trim, derives the loop bandwidth from §11's noise-against-drift trade, and builds the transparent clock that measures how long a switch held each message — which is what turns Section 20's Run D from 6.07 µs into something much smaller.
And the third is the residual nothing so far removes. Chapter 16.5 — What Limits Accuracy takes the asymmetry this chapter derived, the granularity Chapter 16.1 §14 bounded, and the jitter neither of them could filter, and prices all three with the protocol in place — then gives the calibration procedure that removes the part of the asymmetry that is constant.
One thread carries into all three. This chapter's arithmetic was exact and its premise was not. Every chapter that follows is an attempt to make the premise more nearly true — by measuring the switches, by calibrating the cables, and by admitting what is left.
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
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.
- Related topic
The Servo — Offset, Delay and Correction
A PI loop whose bandwidth falls out of the noise-against-drift trade, and a residence-time measurement that turns 6.07 microseconds of error into 1.3 nanoseconds.
- Related topic
What Limits Accuracy
Path asymmetry enters at exactly half the imbalance and leaves every variance unchanged; granularity survives four timestamps; and jitter compounds down a chain of clocks.
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.
