Ethernet · Module 16
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.
Chapter 16.2 §7 computes an offset. Something has to act on it, and Chapter 16.1 §11 established what that something is not allowed to do.
It must not step the clock — Chapter 16.1 §3's ADJ_OFFSET breaks monotonicity and invalidates every measurement spanning it. It must trim the rate instead, which converges slowly and keeps time moving forwards.
And how fast to trim is not a preference. Chapter 16.1 §11 gave the shape of the answer and stopped short of the controller:
Correct aggressively and the loop admits measurement noise. Correct slowly and the drift accumulates between corrections. The two terms move in opposite directions, so there is an optimum, and Section 4 derives it.
| Measurement noise | Oscillator | Optimal interval | Best achievable |
|---|---|---|---|
| 25 µs — software timestamps | 100 ppm | 0.099 s | 29 764 ns |
| 25 µs | 0.1 ppm | 9.92 s | 2976 ns |
| 100 ns | 100 ppm | 0.003 s | 750 ns |
| 10 ns — hardware timestamps | 100 ppm | 0.0005 s | 162 ns |
| 10 ns | 0.1 ppm | 0.054 s | 16.2 ns |
The first row is Chapter 16.1 §11's 30 µs, derived properly. The last row is what Chapter 16.3's timestamp unit and a decent oscillator make possible — 16.2 ns, from a loop with a 3 Hz bandwidth.
And the chapter's second half is about the path rather than the endpoint. Chapter 16.2 §20's Run D was a fully conformant exchange 6.07 µs wrong, because one store-and-forward switch held a Sync for a frame time and a Delay_Req for nothing. A switch that measures how long it held each message and writes the value into correctionField reduces that to 1.3 ns — a factor of 4646 — using exactly the in-flight rewrite Chapter 16.3 §10 built.
1. Scope — What This Chapter Owns
This chapter owns the loop and the transparent clock: the offset filter, the PI controller and its gains, the rate-trim actuator, lock detection, holdover, the residence-time measurement, and what correctionField should contain.
It does not own the offset. Chapter 16.2 §7 computes it from four timestamps and Section 8 of that chapter established its bias. This chapter consumes the number and does not question it.
It does not own the timestamps. Chapter 16.3 placed the capture at the MAC/PHY boundary and built the in-flight rewrite. Section 12 here uses that rewrite and supplies the value it writes.
It does not own the clock. Chapter 16.1 §3 built the free-running counter with its two adjustment paths. This chapter uses ADJ_RATE and never ADJ_OFFSET, and Section 8 is why.
And it does not own the residual. What survives a correctly tuned loop and a path of transparent clocks is Chapter 16.5: the asymmetry, the granularity, and the jitter. Section 13 prices the improvement and names what is left.
2. What a Servo Is Correcting
Two quantities, one actuator, and the distinction between them decides the whole design.
| Quantity | Symbol | What it is | Corrected by |
|---|---|---|---|
| offset | θ | the clock is θ ahead of the master | integrating a rate change |
| drift | ω | the clock runs ω fast | a rate change directly |
A clock is a pure integrator: a rate error accumulates into a phase error. So correcting the phase means applying a rate change for a while and then removing it — which is why the loop needs two terms and not one.
The proportional term handles the offset. See a phase error, apply a proportional rate correction, and the phase converges. On its own it leaves a steady-state error whenever the drift is non-zero, because holding a constant rate correction requires a constant phase error to generate it.
The integral term handles the drift. It accumulates the phase error over time and produces a standing rate correction, so the steady-state phase error goes to zero even with a constant drift.
Which is the textbook reason a phase-locked loop is a PI controller, and here it has a specific physical meaning:
| Term | Physically | Removes |
|---|---|---|
Kp | "the clock is ahead, so slow it down" | the offset |
Ki | "the clock keeps getting ahead, so slow it permanently" | the drift |
And the integral term's accumulated value is the oscillator's measured frequency error — the same quantity Chapter 16.1 §5's drift_ppb estimated directly. Which is what makes Section 16's holdover work: when the reference goes away, the integrator's value is the last known drift, and continuing to apply it is the best available extrapolation.
One more quantity has to be named because the loop cannot touch it.
The measured offset is θ_true + (d_sm − d_ms)/2 — Chapter 16.2 §6. The servo drives the measured value to zero, so it drives θ_true to −(d_sm − d_ms)/2. The loop works perfectly and the clock ends up wrong by half the path's asymmetry. No gain, no bandwidth and no filter changes that, and Section 19's rejected property is a different consequence of the same fact.
3. RTL 1 — The Offset Filter
Before the controller, a filter. Its job is to reject the outliers a real network produces and to do so without adding lag the loop cannot afford.
// -----------------------------------------------------------------------
// servo_pkg -- shared types for the PTP servo and transparent clock.
// -----------------------------------------------------------------------
package servo_pkg;
localparam int NS_W = 32;
localparam int FRAC_W = 16; // fractional nanoseconds
// The offset and the path delay, signed nanoseconds.
typedef logic signed [47:0] off_t;
// A rate correction, in parts per billion, signed. 16.1 section 3's
// increment is trimmed by this.
typedef logic signed [31:0] ppb_t;
// correctionField's format: signed 16.16 fixed point nanoseconds.
typedef logic signed [63:0] correction_t;
typedef enum logic [2:0] {
S_FREERUN, // no reference ever seen
S_ACQUIRE, // a reference is present, converging
S_LOCKED, // inside the lock window for long enough
S_HOLDOVER, // the reference went away; apply the last drift
S_FAULT // the reference is present and unusable
} servo_state_e;
endpackage// -----------------------------------------------------------------------
// offset_filter -- rejects outliers and produces the error signal the
// controller acts on.
//
// A median-of-N is used rather than a mean, because the outliers a
// network produces are ONE-SIDED: 14.1's queueing only ever DELAYS a
// message, so a contaminated sample is always too large. A mean is
// dragged by them; a median is not.
// -----------------------------------------------------------------------
module offset_filter
import servo_pkg::*;
#(
parameter int N = 5, // odd, so the median exists
parameter int REJECT_NS = 100_000 // 100 us: obviously wrong
)(
input logic clk,
input logic rst_n,
input logic sample_valid,
input off_t offset_ns,
input off_t path_ns,
input logic sample_trustworthy, // 16.2 section 15
output logic err_valid,
output off_t err_ns, // the controller's input
output off_t spread_ns,
output logic [31:0] c_accepted,
output logic [31:0] c_rejected_gross,
output logic [31:0] c_rejected_untrusted
);
off_t win [N];
logic [$clog2(N+1)-1:0] fill;
logic [$clog2(N)-1:0] wp;
// A selection network for the median. For N = 5 this is a fixed
// sorting network -- 9 compare-exchanges -- and it is combinational.
function automatic off_t median5(input off_t a0, a1, a2, a3, a4);
off_t b [5];
int i, j;
off_t t;
begin
b[0]=a0; b[1]=a1; b[2]=a2; b[3]=a3; b[4]=a4;
for (i = 0; i < 5; i++)
for (j = i+1; j < 5; j++)
if (b[j] < b[i]) begin t = b[i]; b[i] = b[j]; b[j] = t; end
median5 = b[2];
end
endfunction
always_ff @(posedge clk or negedge rst_n) begin
int i;
if (!rst_n) begin
for (i = 0; i < N; i++) win[i] <= '0;
fill <= '0; wp <= '0;
err_valid <= 1'b0; err_ns <= '0; spread_ns <= '0;
c_accepted <= '0; c_rejected_gross <= '0;
c_rejected_untrusted <= '0;
end else begin
err_valid <= 1'b0;
if (sample_valid) begin
// Gate 1 -- 16.2 section 15's trustworthiness. A sample from an
// exchange with a negative round trip or a cross-master pairing
// is not an outlier, it is garbage.
if (!sample_trustworthy) begin
c_rejected_untrusted <= c_rejected_untrusted + 1;
end
// Gate 2 -- grossly wrong. A 100 us offset on a locked loop is
// a lost message or a master change, not a measurement.
else if ((offset_ns > off_t'(REJECT_NS)) ||
(offset_ns < -off_t'(REJECT_NS))) begin
c_rejected_gross <= c_rejected_gross + 1;
end
else begin
automatic off_t mx, mn;
win[wp] <= offset_ns;
wp <= (wp == N-1) ? '0 : (wp + 1'b1);
if (fill != N) fill <= fill + 1'b1;
c_accepted <= c_accepted + 1;
if (fill == N) begin
err_valid <= 1'b1;
err_ns <= median5(win[0], win[1], win[2], win[3], win[4]);
mx = win[0]; mn = win[0];
for (i = 1; i < N; i++) begin
if (win[i] > mx) mx = win[i];
if (win[i] < mn) mn = win[i];
end
spread_ns <= mx - mn;
end
end
end
end
end
endmoduleClassification: a sliding-window median with two rejection gates. Combinational selection network, registered output.
What it teaches: that a median is right here and a mean is not, and the reason is that the contamination is one-sided. Chapter 14.1's queueing only ever delays a message — there is no mechanism that makes a frame arrive early — so a contaminated t2 is always too large and a contaminated sample's offset is always displaced in one direction. A mean is dragged by every outlier in proportion to its size; a median of five is unmoved by up to two of them at any magnitude.
And it teaches that the two rejection gates catch different things and must be separate. sample_trustworthy comes from Chapter 16.2 §15 and means the exchange was sound — a sample from a cross-master pairing is not a large measurement, it is not a measurement. The gross gate catches a sample that was soundly obtained and is implausible, which on a locked loop means a lost message or a master change.
Deliberately simplified: the median uses an O(N²) bubble sort written for clarity. A production design uses a fixed sorting network — nine compare-exchanges for N = 5 — which is what synthesis will produce anyway, and the depth is four comparators rather than ten.
Production implication: the filter adds (N−1)/2 samples of group delay — two samples at N = 5, which at 16 Sync/s is 125 ms. That is lag inside the loop and it eats directly into Section 4's bandwidth budget: a 3 Hz loop has a 53 ms time constant, so 125 ms of filter delay makes it unstable. The filter's length and the loop's bandwidth are one decision, and a design that chooses them separately gets a loop that oscillates for a reason the controller's gains cannot explain.
4. Noise Against Drift, as a Loop Bandwidth
Chapter 16.1 §11 stated the trade and stopped. This is the derivation.
Two error terms as a function of the correction interval T:
The measurement noise, filtered. Averaging N = rate × T samples reduces a noise of standard deviation σ to σ / √(rate × T). It falls as T grows.
The drift accumulated between corrections. At ω parts per million, T seconds of free running accrues ω × 10⁻⁶ × T seconds of phase error. It rises linearly in T.
total(T) = σ / √(rate × T) + ω × 10⁻⁶ × TDifferentiate and set to zero:
T_opt = ( σ / (2 × ω × 10⁻⁶ × √rate) ) ^ (2/3)And the numbers, at 16 samples per second:
σ | ω | T_opt | Best total | Loop bandwidth 1/(2πT) |
|---|---|---|---|---|
| 25 µs | 100 ppm | 0.099 s | 29 764 ns | 1.60 Hz |
| 25 µs | 0.1 ppm | 9.92 s | 2976 ns | 0.016 Hz |
| 100 ns | 100 ppm | 0.003 s | 750 ns | 53 Hz |
| 10 ns | 100 ppm | 0.0005 s | 162 ns | 295 Hz |
| 10 ns | 0.1 ppm | 0.054 s | 16.2 ns | 2.95 Hz |
Row one is Chapter 16.1 §11's "about 30 µs" recovered exactly, and it confirms that chapter's conclusion: software timestamping's optimum is 29.8 µs, thirty times a 1 µs target, and the optimum is the best case.
Row four is the one that surprises. With good timestamps and a bad oscillator, the optimal correction interval is half a millisecond and the loop bandwidth is 295 Hz — which at 16 Sync/s is impossible, because a loop cannot have a bandwidth higher than a fraction of its sample rate. The optimum is unreachable and the design is sample-rate limited: raising the Sync rate would improve it, and that is the argument for 128/s.
Row five is the achievable one and it is the module's headline. Good timestamps and a good oscillator give 16.2 ns from a 3 Hz loop — a bandwidth that is comfortable at 16 Sync/s, tolerant of the filter's group delay, and slow enough to reject the outliers Section 3 does not catch.
Which gives the design rule and it runs opposite to instinct: a better oscillator makes the loop slower, not faster. At 0.1 ppm the drift term is a thousand times smaller, so the optimum moves to a longer interval where more noise is filtered out — and the loop becomes both more accurate and less twitchy. A design that specifies an OCXO and keeps a fast loop has bought the oscillator and not used it.
==
5. RTL 2 — The PI Controller
Two terms, two accumulators, and one of them is the answer to a question the servo was not asked.
// -----------------------------------------------------------------------
// pi_controller -- proportional-integral control of a clock's rate
// from a phase error.
//
// The plant is a pure integrator (rate in, phase out), so the closed
// loop is second order: s^2 + Kp*s + Ki = 0, giving
// wn = sqrt(Ki) natural frequency
// zeta = Kp / (2*sqrt(Ki)) damping
// Section 6 picks them.
// -----------------------------------------------------------------------
module pi_controller
import servo_pkg::*;
#(
// Gains in Q16 fixed point. Section 6's f_c = 3 Hz, zeta = 0.707:
// wn = 18.85 rad/s, Kp = 26.66, Ki = 355.3 -- scaled per sample.
parameter int KP_Q16 = 32'sd1747, // 0.02666 in Q16
parameter int KI_Q16 = 32'sd23, // 0.000355 in Q16
parameter int INT_LIMIT_PPB = 200_000 // +/- 200 ppm of authority
)(
input logic clk,
input logic rst_n,
input logic err_valid,
input off_t err_ns,
input logic freeze_integral, // holdover: stop integrating
input logic reset_loop,
output logic trim_valid,
output ppb_t trim_ppb,
output ppb_t integral_ppb, // the measured drift -- section 16
output logic integral_saturated,
output logic [31:0] c_updates,
output logic [31:0] c_saturations
);
logic signed [63:0] integ;
logic signed [63:0] prop;
logic signed [63:0] sum;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n || reset_loop) begin
integ <= '0; trim_valid <= 1'b0; trim_ppb <= '0;
integral_ppb <= '0; integral_saturated <= 1'b0;
c_updates <= '0; c_saturations <= '0;
end else begin
trim_valid <= 1'b0;
integral_saturated <= 1'b0;
if (err_valid) begin
// The clock is AHEAD by err_ns, so it must be SLOWED: the
// correction is negative. Getting this sign wrong produces a
// loop that diverges rather than one that converges, which is
// at least unmistakable.
prop = -(64'sd0 + 64'(err_ns)) * 64'sd1_000_000 * KP_Q16 >>> 16;
if (!freeze_integral) begin
integ <= integ - ((64'(err_ns) * 64'sd1_000_000 * KI_Q16) >>> 16);
end
sum = prop + integ;
// Integral windup must be bounded, and the bound is PHYSICAL:
// no oscillator this design supports is more than 200 ppm off,
// so a larger integral means the loop is chasing something
// that is not a frequency error.
if (integ > 64'sd(INT_LIMIT_PPB)) begin
integ <= 64'sd(INT_LIMIT_PPB);
integral_saturated <= 1'b1;
c_saturations <= c_saturations + 1;
end else if (integ < -64'sd(INT_LIMIT_PPB)) begin
integ <= -64'sd(INT_LIMIT_PPB);
integral_saturated <= 1'b1;
c_saturations <= c_saturations + 1;
end
trim_valid <= 1'b1;
trim_ppb <= ppb_t'(sum);
integral_ppb <= ppb_t'(integ);
c_updates <= c_updates + 1;
end
end
end
endmoduleClassification: a two-term controller with a physically-bounded integrator. One multiply-accumulate per sample.
What it teaches: that integral_ppb is the oscillator's measured frequency error and is worth more than the loop's output. The integrator converges to whatever standing rate correction cancels the drift — which is exactly Chapter 16.1 §5's drift_ppb, obtained for free as a by-product of controlling. Section 16's holdover is built on it, and so is a genuinely useful diagnostic: a device whose integral reads −137 ppb has a crystal 0.137 ppm fast, measured continuously, at no extra cost.
And it teaches that the integral limit must come from physics rather than from a fraction of the range. No oscillator in Chapter 16.1 §4's table is more than ±100 ppm off, so a limit at ±200 ppm has ample margin and an integral pressed against it means the loop is chasing something that is not a frequency error — a persistent offset bias, an asymmetric path, or a measurement that is wrong in a fixed direction. integral_saturated is therefore a finding rather than a clamp.
Deliberately simplified: the gains are constants, so the loop has one bandwidth for acquisition and for tracking. Production servos use a fast bandwidth while acquiring and switch to Section 4's optimum once locked — a three-times improvement in settling time at no steady-state cost, and the switch point is Section 10's state machine.
Production implication: the sign convention is the single most commonly inverted thing in a servo and it fails loudly, which is fortunate. A positive err_ns means the slave is ahead, so the trim must be negative. Getting it backwards produces a loop that diverges — the clock runs further away at an accelerating rate until the integral saturates — which is unmistakable in a way that most sign errors are not. The dangerous inversion is in Chapter 16.2 §7's correction field, where the error is a slow degradation instead.
==
6. Choosing Kp and Ki
Section 4 gave a bandwidth. This turns it into two numbers, and the algebra is short because the plant is as simple as plants get.
A clock integrates rate into phase, so its transfer function is 1/s — a pure integrator with no other dynamics. With a PI controller C(s) = Kp + Ki/s, the open loop is:
L(s) = (Kp + Ki/s) / sand the closed-loop characteristic equation is:
s² + Kp·s + Ki = 0which is the standard second-order form with:
ωn = √Ki the natural frequency
ζ = Kp / (2√Ki) the damping ratioSo the two design choices are a bandwidth and a damping, and the gains follow:
f_c | ζ | ωn rad/s | Kp | Ki | 2% settling | Overshoot |
|---|---|---|---|---|---|---|
| 0.10 Hz | 0.707 | 0.628 | 0.888 | 0.395 | 9.0 s | 4.3% |
| 0.05 Hz | 0.707 | 0.314 | 0.444 | 0.0987 | 18.0 s | 4.3% |
| 0.01 Hz | 0.707 | 0.0628 | 0.0888 | 0.00395 | 90.1 s | 4.3% |
| 0.10 Hz | 1.00 | 0.628 | 1.257 | 0.395 | — | 0% |
ζ = 0.707 is the usual choice and it is a genuine trade rather than a convention. It gives 4.3% overshoot and the fastest settling for a given bandwidth; ζ = 1 gives no overshoot and settles more slowly. For a clock, a 4.3% overshoot on a 40 µs correction is 1.7 µs of transient error in the wrong direction — which matters if anything downstream is sampling during acquisition, and does not otherwise.
And the settling times are the part that shapes expectations. A 0.01 Hz loop takes 90 seconds to settle to 2% of a step, so a device that has just been given a new master is out of specification for a minute and a half — which is why Section 10's state machine distinguishes acquiring from locked, and why production designs use a faster bandwidth while acquiring.
Two constraints bound the choice from outside, and both are frequently forgotten.
The sample rate. A discrete loop is stable only well below its sample rate; a rule of thumb is f_c < rate / 10. At 16 Sync/s that caps f_c at 1.6 Hz, so Section 4's 295 Hz optimum for good timestamps on a bad oscillator is not merely impractical — it is unreachable at any gain.
Section 3's filter delay. A median-of-5 at 16 Sync/s adds 125 ms of group delay, and a 3 Hz loop has a 53 ms time constant. Lag inside a loop erodes phase margin: 125 ms of delay in a 3 Hz loop is more than two time constants and the loop will oscillate. The filter and the bandwidth must be chosen together — either a shorter filter or a slower loop.
7. RTL 3 — Applying the Rate Trim
The actuator. Its only subtlety is that it must be bounded by what the clock's consumers tolerate rather than by what the loop wants.
// -----------------------------------------------------------------------
// rate_trim_applier -- converts a ppb correction into 16.1 section 3's
// fixed-point increment, with a rate limit.
//
// The limit is not about the loop. It is about every consumer that
// measures an INTERVAL using this clock: a trim of X ppb makes every
// measured duration wrong by X parts per billion for as long as it is
// applied. Section 8.
// -----------------------------------------------------------------------
module rate_trim_applier
import servo_pkg::*;
#(
parameter int CLK_MHZ = 156,
parameter int INC_FRAC_W = 16,
// What consumers tolerate as a frequency error. 1 ppm is generous
// for most; a test instrument may demand far less.
parameter int MAX_TRIM_PPB = 1000,
// How fast the trim may change, so a step in the loop's output does
// not become a step in the clock's rate.
parameter int MAX_SLEW_PPB_PER_UPDATE = 50
)(
input logic clk,
input logic rst_n,
input logic trim_valid,
input ppb_t trim_ppb,
output logic adj_valid,
output logic [23:0] increment, // 8.16 fixed point, 16.1 section 3
output ppb_t applied_ppb,
output logic trim_clipped,
output logic trim_slewed,
output logic [31:0] c_clipped,
output logic [31:0] c_slewed
);
// Nominal increment: 1e9 / (CLK_MHZ * 1e6) ns per tick, in Q16.
localparam int NOMINAL =
(1_000_000_000 << INC_FRAC_W) / (CLK_MHZ * 1_000_000);
ppb_t cur;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cur <= '0; adj_valid <= 1'b0; increment <= 24'(NOMINAL);
applied_ppb <= '0; trim_clipped <= 1'b0; trim_slewed <= 1'b0;
c_clipped <= '0; c_slewed <= '0;
end else begin
adj_valid <= 1'b0;
trim_clipped <= 1'b0;
trim_slewed <= 1'b0;
if (trim_valid) begin
automatic ppb_t want, next;
// Clip to what consumers tolerate.
want = trim_ppb;
if (want > ppb_t'(MAX_TRIM_PPB)) begin
want = ppb_t'(MAX_TRIM_PPB);
trim_clipped <= 1'b1;
c_clipped <= c_clipped + 1;
end else if (want < -ppb_t'(MAX_TRIM_PPB)) begin
want = -ppb_t'(MAX_TRIM_PPB);
trim_clipped <= 1'b1;
c_clipped <= c_clipped + 1;
end
// Slew-limit the change, so a loop transient does not become a
// rate discontinuity that downstream interval measurements see.
next = want;
if ((want - cur) > ppb_t'(MAX_SLEW_PPB_PER_UPDATE)) begin
next = cur + ppb_t'(MAX_SLEW_PPB_PER_UPDATE);
trim_slewed <= 1'b1;
c_slewed <= c_slewed + 1;
end else if ((cur - want) > ppb_t'(MAX_SLEW_PPB_PER_UPDATE)) begin
next = cur - ppb_t'(MAX_SLEW_PPB_PER_UPDATE);
trim_slewed <= 1'b1;
c_slewed <= c_slewed + 1;
end
cur <= next;
applied_ppb <= next;
// increment = NOMINAL * (1 + ppb/1e9), in Q16.
increment <= 24'(NOMINAL +
((64'(NOMINAL) * 64'(next)) / 64'sd1_000_000_000));
adj_valid <= 1'b1;
end
end
end
endmoduleClassification: a clip, a slew limiter and a fixed-point scale. No control, purely an actuator with two safety bounds.
What it teaches: that MAX_TRIM_PPB is set by the clock's consumers and not by the servo, which is the reverse of the usual direction. A trim of 1000 ppb makes every interval measured with this clock wrong by one part in a million for as long as it is applied — so a device that timestamps external events, or measures a duration, is degraded by the act of synchronising. Chapter 16.1 §2's distinction between frequency and phase requirements appears here as a conflict: correcting phase costs frequency accuracy, transiently.
And it teaches that the slew limit turns a step in the loop's output into a ramp in the clock's rate. The controller can produce a large correction after a lost message or a master change; applying it instantly is a rate discontinuity, which for an interval measurement is indistinguishable from a step in time. Fifty parts per billion per update at 16 updates per second is 800 ppb/s, so a 1000 ppb correction ramps in 1.25 seconds.
Deliberately simplified: the increment is recomputed with a 64-bit divide per update. At 16 updates per second that is irrelevant, and a production design would use a multiply by a precomputed reciprocal anyway — the divide is here because it makes the relationship to Chapter 16.1 §3's NOMINAL_INC readable.
Production implication: c_clipped rising on a locked loop means the controller is asking for more authority than the design permits, and the cause is almost never the oscillator. Chapter 16.1 §4's worst crystal is 100 ppm = 100 000 ppb, so a design whose MAX_TRIM_PPB is 1000 cannot correct a bad crystal at all — and the honest resolution is that a 1 ppm-tolerant consumer and a 100 ppm oscillator are incompatible requirements. The counter makes the incompatibility visible instead of leaving the loop permanently clipped.
8. Why Never a Step
Chapter 16.1 §3 built two adjustment paths and §22's sixth misconception said a step is almost never right. This is the arithmetic behind "almost".
Correcting a 40 µs offset by trimming takes time, and how much depends on the trim's magnitude:
| Trim applied | Time to close 40 µs | Frequency error while correcting |
|---|---|---|
| 1 ppm | 40 s | 1 ppm — negligible |
| 10 ppm | 4 s | measurable |
| 100 ppm | 0.4 s | as wrong as a bad crystal |
| 1000 ppm | 40 ms | larger than the offset it fixes |
So the trim's bound is not arbitrary: it is whatever the consumers tolerate as a frequency error, and the offset closes at whatever speed that allows. A device that must hold 1 ppm and has 40 µs to correct takes forty seconds.
And that is the case for a step, in exactly one situation: at initial time-of-day set, when the offset is arbitrarily large.
| Situation | Offset | Step or trim? |
|---|---|---|
| cold start, clock at zero | decades | step — no trim closes it |
| after a master change | microseconds to milliseconds | trim |
| after a lost message | one interval's drift — 6.25 µs at 16/s | trim |
| steady state | nanoseconds | trim |
Which gives the operational rule Chapter 16.1 §3's c_steps exists to check: more than one step in a device's lifetime means the resync interval was too long or the reference was lost. c_steps greater than one is itself a finding, and it points at Chapter 16.1 §6's arithmetic rather than at the servo.
And the cost of getting it wrong is worth restating because it is not recoverable. A backwards step makes time non-monotonic. A consumer that recorded an event at T and then reads T − 40 µs has an ordering violation it cannot detect, and no downstream analysis recovers it — which is why Chapter 16.1 §3's stepped_backwards is sticky and why it must be reported with its magnitude and its instant.
9. RTL 4 — Lock Detection and State
A servo has to say whether it is working, and the naive definition of "locked" is the one Section 19 refuses.
// -----------------------------------------------------------------------
// servo_lock_detector -- the servo's state machine.
//
// Lock is not "the error is small". An error can be small while the
// loop is diverging through zero, and small for one sample while the
// loop has not responded at all. Lock requires the error to STAY small
// for longer than the loop's own time constant -- section 19's
// rejected property is the version that forgets this.
// -----------------------------------------------------------------------
module servo_lock_detector
import servo_pkg::*;
#(
parameter int LOCK_WINDOW_NS = 1000, // 1 us
// Several loop time constants. At f_c = 3 Hz the time constant is
// 53 ms, so 500 ms is about nine of them.
parameter int LOCK_HOLD_CYCLES = 250_000_000, // 500 ms at 500 MHz
parameter int LOSS_CYCLES = 5_000_000_000/2 // 5 s at 500 MHz
)(
input logic clk,
input logic rst_n,
input logic err_valid,
input off_t err_ns,
input logic reference_present,
input logic integral_saturated,
input logic have_drift_estimate,
output servo_state_e state,
output logic freeze_integral,
output logic locked,
output logic [31:0] time_in_state_ms,
output logic [31:0] c_locks,
output logic [31:0] c_lock_losses
);
logic [63:0] in_window;
logic [63:0] no_ref;
logic [63:0] state_cycles;
servo_state_e st;
function automatic off_t absv(input off_t v);
absv = v[47] ? (~v + 1'b1) : v;
endfunction
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
st <= S_FREERUN; in_window <= '0; no_ref <= '0;
state_cycles <= '0; freeze_integral <= 1'b1;
locked <= 1'b0; time_in_state_ms <= '0;
c_locks <= '0; c_lock_losses <= '0;
end else begin
state_cycles <= state_cycles + 1;
time_in_state_ms <= state_cycles / 500_000;
no_ref <= reference_present ? '0 : (no_ref + 1);
if (err_valid) begin
// The window counter measures how long the error has STAYED
// inside the band, which is the property that matters. A
// single sample inside it proves nothing.
if (absv(err_ns) <= off_t'(LOCK_WINDOW_NS)) in_window <= in_window + 1;
else in_window <= '0;
end
unique case (st)
S_FREERUN: begin
freeze_integral <= 1'b1;
locked <= 1'b0;
if (reference_present) begin
st <= S_ACQUIRE; state_cycles <= '0; in_window <= '0;
end
end
S_ACQUIRE: begin
freeze_integral <= 1'b0;
locked <= 1'b0;
if (!reference_present && (no_ref > 64'(LOSS_CYCLES)))
st <= have_drift_estimate ? S_HOLDOVER : S_FREERUN;
else if (integral_saturated && (state_cycles > 64'(LOCK_HOLD_CYCLES)))
// The loop has had time and is still asking for more
// authority than exists. That is not acquisition.
st <= S_FAULT;
else if (in_window > 64'(LOCK_HOLD_CYCLES)) begin
st <= S_LOCKED;
state_cycles <= '0;
c_locks <= c_locks + 1;
end
end
S_LOCKED: begin
freeze_integral <= 1'b0;
locked <= 1'b1;
if (!reference_present && (no_ref > 64'(LOSS_CYCLES))) begin
st <= S_HOLDOVER;
state_cycles <= '0;
c_lock_losses <= c_lock_losses + 1;
end else if (in_window == '0) begin
st <= S_ACQUIRE;
state_cycles <= '0;
c_lock_losses <= c_lock_losses + 1;
end
end
S_HOLDOVER: begin
// Keep applying the last known drift. The integral holds its
// value and the proportional term has no input -- section 16.
freeze_integral <= 1'b1;
locked <= 1'b0;
if (reference_present) begin
st <= S_ACQUIRE; state_cycles <= '0; in_window <= '0;
end
end
S_FAULT: begin
freeze_integral <= 1'b1;
locked <= 1'b0;
if (!reference_present) st <= S_FREERUN;
end
endcase
end
end
assign state = st;
endmoduleClassification: a five-state machine with a dwell requirement. No arithmetic, and it is where the servo's honesty lives.
What it teaches: that lock requires the error to stay inside the window for longer than the loop's own time constant, and the dwell is the whole definition. At a 3 Hz bandwidth the time constant is 53 ms; LOCK_HOLD_CYCLES at 500 ms is nine time constants, which is enough for a second-order loop to have settled. A design that declares lock on a single in-window sample declares lock while the error is passing through zero on its way to an overshoot.
And it teaches that S_FAULT is distinct from S_ACQUIRE and the distinction is integral_saturated plus time. A loop that has been given nine time constants and is still demanding more authority than the design has is not acquiring slowly; it is chasing something a rate trim cannot fix — a persistent bias, an asymmetric path, or a reference that is itself unstable. Leaving it in S_ACQUIRE for ever is the common implementation and it reports "converging" indefinitely.
Deliberately simplified: LOCK_WINDOW_NS is a constant, so the same threshold applies to a deployment targeting 1 µs and one targeting 100 ns. Production designs derive it from the requirement — and, more usefully, compare it against Section 4's achievable floor: a lock window tighter than total(T_opt) can never be satisfied, and a design with one is a design that never reports lock.
Production implication: c_lock_losses is the counter that separates a network problem from a servo problem. Losses correlated with reference_present going low are the network — Chapter 16.2 §10's message loss, or a master change. Losses with the reference continuously present are the loop: too much bandwidth, too much filter delay, or Section 6's last two constraints violated. Two causes, one symptom, and one counter's correlation separates them.
10. The Servo's States
Five states, and the two that are usually collapsed are the two that matter most.
| State | Reference | Integral | Output | What it means |
|---|---|---|---|---|
FREERUN | never seen | frozen at zero | nominal | the clock is on its own and always was |
ACQUIRE | present | active | converging | the loop is working and has not settled |
LOCKED | present | active | tracking | in-window for nine time constants |
HOLDOVER | lost | frozen at its last value | the last known drift | extrapolating |
FAULT | present | frozen | last value | the loop cannot converge on this input |
FREERUN and HOLDOVER are the pair that gets collapsed, and they are physically different situations.
A device in FREERUN has never measured its own drift, so its increment is nominal and its error grows at the oscillator's full tolerance — 100 µs per second on Chapter 16.1 §4's commodity crystal.
A device in HOLDOVER has measured its drift and is still applying it. Its residual error grows at whatever the drift estimate is wrong by, plus the oscillator's ageing and temperature coefficient — which for a device that was locked a moment ago is orders of magnitude smaller.
FREERUN on a 100 ppm crystal | HOLDOVER with a good estimate | |
|---|---|---|
| error growth | 100 µs/s | the estimate's residual — often < 1 µs/s |
| holds 1 µs for | 10 ms | seconds |
| what it advertises — Chapter 16.2 §14 | clockClass 248 | clockClass 7 or 187 |
And the third row is where the two states reach the protocol. Chapter 16.2 §14's BMCA moves mastership based on clockClass, so a device that collapses the two states advertises the same class in both — either claiming holdover it cannot deliver, or discarding a holdover it could have offered.
ACQUIRE and LOCKED are the other pair worth keeping apart, for a reason that reaches outside the device. Section 6's settling times: a 0.01 Hz loop takes 90 seconds to settle. A device that reports "synchronised" the moment it starts converging is telling every downstream consumer that its time is usable for a minute and a half before it is.
11. RTL 5 — Residence Time Measurement
The transparent clock. It measures how long a frame was inside this switch, and the measurement's shape is the same self-referential one Chapter 16.3 §19 dissolved.
// -----------------------------------------------------------------------
// residence_timer -- measures the interval between a PTP event
// message's arrival SFD and its departure SFD.
//
// Both captures come from 16.3 section 3's unit. The value is not
// known until the frame departs -- and the departure SFD precedes
// correctionField by 22 octets, so it exists when the field needs it.
// -----------------------------------------------------------------------
module residence_timer
import servo_pkg::*;
#(
parameter int PENDING = 16 // frames in flight through the switch
)(
input logic clk,
input logic rst_n,
// Ingress: an event message arrived.
input logic in_valid,
input logic [15:0] in_tag,
input logic [47:0] in_sec,
input logic [31:0] in_ns,
// Egress: the same message is leaving.
input logic out_valid,
input logic [15:0] out_tag,
input logic [47:0] out_sec,
input logic [31:0] out_ns,
output logic residence_valid,
output logic [15:0] residence_tag,
output correction_t residence_16_16,
output logic negative_residence,
output logic [31:0] c_measured,
output logic [31:0] c_no_ingress,
output logic [31:0] c_abandoned,
output logic [31:0] longest_ns
);
typedef struct packed {
logic busy;
logic [15:0] tag;
logic [47:0] sec;
logic [31:0] ns;
logic [31:0] age;
} pend_t;
pend_t p [PENDING];
// A frame that entered and never left must free its slot: 14.1's
// allocator can discard a frame at the egress, and the transparent
// clock has no other way to learn that it did.
localparam int MAX_AGE = 32'h0400_0000; // ~134 ms at 500 MHz
always_ff @(posedge clk or negedge rst_n) begin
int i;
automatic bit placed, found;
if (!rst_n) begin
for (i = 0; i < PENDING; i++) p[i] <= '0;
residence_valid <= 1'b0; residence_tag <= '0;
residence_16_16 <= '0; negative_residence <= 1'b0;
c_measured <= '0; c_no_ingress <= '0;
c_abandoned <= '0; longest_ns <= '0;
end else begin
residence_valid <= 1'b0;
negative_residence <= 1'b0;
for (i = 0; i < PENDING; i++) begin
if (p[i].busy) begin
p[i].age <= p[i].age + 1;
if (p[i].age == MAX_AGE) begin
p[i].busy <= 1'b0;
c_abandoned <= c_abandoned + 1;
end
end
end
if (in_valid) begin
placed = 1'b0;
for (i = 0; i < PENDING; i++) begin
if (!p[i].busy && !placed) begin
placed = 1'b1;
p[i].busy <= 1'b1;
p[i].tag <= in_tag;
p[i].sec <= in_sec;
p[i].ns <= in_ns;
p[i].age <= '0;
end
end
end
if (out_valid) begin
found = 1'b0;
for (i = 0; i < PENDING; i++) begin
if (p[i].busy && (p[i].tag == out_tag)) begin
automatic logic signed [63:0] d;
found = 1'b1;
d = ($signed(64'(out_sec)) - $signed(64'(p[i].sec)))
* 64'sd1_000_000_000
+ ($signed(64'(out_ns)) - $signed(64'(p[i].ns)));
p[i].busy <= 1'b0;
// A negative residence is physically impossible and is
// therefore evidence -- a tag collision, or a clock
// stepped while the frame was inside us.
if (d < 0) begin
negative_residence <= 1'b1;
end else begin
residence_valid <= 1'b1;
residence_tag <= out_tag;
// Shift into 16.16 fixed point. The fractional bits stay
// zero here; 16.5 is where sub-nanosecond residence
// measurement becomes worth having.
residence_16_16 <= d <<< 16;
c_measured <= c_measured + 1;
if (d[31:0] > longest_ns) longest_ns <= d[31:0];
end
end
end
if (!found) c_no_ingress <= c_no_ingress + 1;
end
end
end
endmoduleClassification: an in-flight frame tracker with a subtraction. Sixteen entries, tag-addressed, one match per departure.
What it teaches: that a transparent clock needs the same frame tag Chapter 16.3 §13 argued for, and here the need is even sharper. A switch has many frames inside it at once — Chapter 14.1's queues hold thousands — so pairing an egress SFD to an ingress SFD by order is not merely fragile, it is wrong on the first reordering, which Chapter 13.4 §11's scheduler produces continuously.
And it teaches that c_abandoned exists because a frame can enter a switch and never leave. Chapter 14.1's allocator discards at the egress when the pool is exhausted, and the transparent clock has no notification — it only ever sees departures. Without the age-out, sixteen discarded PTP frames fill the tracker permanently and every subsequent residence measurement fails with c_no_ingress, silently.
Deliberately simplified: the fractional bits of residence_16_16 are left zero, so the measurement is quantised to whole nanoseconds on top of the capture granularity. At a 156.25 MHz capture the granularity is already 1.85 ns per capture — Section 13 — so the fractional field is not yet earning its keep; it does at 322 MHz and above, which is why the format has it.
Production implication: longest_ns is the measurement that says whether this switch belongs in a synchronised path at all. A store-and-forward switch at 1 Gb/s holds a maximum frame for 12.14 µs and a queued one for far longer — Chapter 14.1's 4096-cell queue drains in 4.19 ms. The residence correction handles any of it exactly; what longest_ns reveals is how much variable delay the path has, which is Chapter 16.2 §15's path_spread_ns seen from inside the switch that causes it.
12. RTL 6 — The Correction Field Update
The value from Section 11, written by Chapter 16.3 §10's mechanism, with the two decisions that chapter left open.
// -----------------------------------------------------------------------
// correction_accumulator -- decides WHAT a transparent clock adds to
// correctionField, and on which messages.
//
// 16.3 section 10 built the in-flight rewrite. This module supplies
// its add_value and its enable, and the two decisions here are the
// ones that make a path of transparent clocks compose correctly.
// -----------------------------------------------------------------------
module correction_accumulator
import servo_pkg::*;
(
input logic clk,
input logic rst_n,
input logic residence_valid,
input logic [15:0] residence_tag,
input correction_t residence_16_16,
// What kind of message is leaving, and on which port.
input logic msg_is_event,
input logic msg_is_pdelay, // peer-delay messages are special
input logic [15:0] egress_tag,
// The link's own asymmetry correction, if one has been calibrated.
// 16.5 section 11 produces it; this module applies it.
input correction_t cfg_egress_asymmetry,
input logic cfg_asymmetry_valid,
output logic add_valid,
output correction_t add_value,
output logic add_enable,
output logic [31:0] c_applied,
output logic [31:0] c_suppressed_general,
output logic [31:0] c_asymmetry_applied
);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
add_valid <= 1'b0; add_value <= '0; add_enable <= 1'b0;
c_applied <= '0; c_suppressed_general <= '0;
c_asymmetry_applied <= '0;
end else begin
add_valid <= 1'b0;
add_enable <= 1'b0;
if (residence_valid && (residence_tag == egress_tag)) begin
// Decision 1: EVENT messages only. A Follow_Up's correction
// already describes the Sync it accompanies; adding this
// switch's residence time to it corrupts a correct value --
// 16.3 section 10's production note, as a policy.
if (!msg_is_event) begin
c_suppressed_general <= c_suppressed_general + 1;
end else begin
automatic correction_t v;
v = residence_16_16;
// Decision 2: a calibrated link asymmetry is folded in here,
// because correctionField is the only place in the protocol
// where a per-hop constant can be carried.
if (cfg_asymmetry_valid) begin
v = v + cfg_egress_asymmetry;
c_asymmetry_applied <= c_asymmetry_applied + 1;
end
add_valid <= 1'b1;
add_value <= v;
add_enable <= 1'b1;
c_applied <= c_applied + 1;
end
end
end
end
endmoduleClassification: a policy block. It computes nothing and decides two things that make a path compose.
What it teaches: that a transparent clock adds to event messages only, and the reason is that correctionField means different things in the two kinds. In a Sync, it accumulates the delay that frame experienced. In a Follow_Up, it carries a correction that already describes the Sync — so adding the Follow_Up's own residence time corrupts a value that was correct, by exactly one switch's residence time per hop. Chapter 16.3 §10 noted it; here it is the enable.
And it teaches that correctionField is where a per-hop calibration belongs, which is not obvious and is the protocol's most useful quiet feature. Chapter 16.2 §8 established that the path's asymmetry cannot be measured from inside the protocol and must be calibrated externally. A calibrated value stored at a link is applied here — folded into the correction as the frame leaves — so the slave's arithmetic removes it without knowing it existed. Which is why Chapter 16.2 §12's peer delay localises asymmetry to a link: a per-link constant is maintainable and a per-path one is not.
Deliberately simplified: cfg_egress_asymmetry is a single constant per port, applied unconditionally. A real calibration is per link and per direction — half the imbalance goes on one side — and applying the whole imbalance on one side is a factor-of-two error that looks exactly like a correct calibration of a different cable.
Production implication: c_suppressed_general should be non-zero on any real link, because Follow_Ups exist and they pass through. A switch reporting zero suppressions while Follow_Ups are known to be crossing it is a switch adding residence time to general messages — and the resulting error is one residence time per hop, accumulating linearly across the path and indistinguishable from a path that is simply longer.
13. Chapter 16.1 §15 Recomputed With Transparent Clocks
Chapter 16.1 §15 ended on a flat statement: one ordinary switch puts the system back in the tens of microseconds. Chapter 16.2 §6 sharpened it to 6.07 µs. This is that number with Sections 11 and 12 in place.
Without a transparent clock, one store-and-forward switch at 1 Gb/s holds a Sync for up to a frame time and a Delay_Req for almost none — an asymmetry of 12.14 µs, and Chapter 16.2 §6's arithmetic halves it: 6070 ns of offset error.
With one, the switch measures its residence time and writes it into correctionField, and Chapter 16.2 §7 subtracts it. What remains is the error in that measurement.
The residence time is the difference of two captures, each quantised by Chapter 16.1 §14's period/√12:
| Capture clock | σ per capture | σ of the difference |
|---|---|---|
| 125 MHz | 2.31 ns | 3.27 ns |
| 156.25 MHz | 1.85 ns | 2.61 ns |
| 322.27 MHz | 0.90 ns | 1.27 ns |
And Chapter 16.2 §6's factor of two applies to the residual exactly as it applies to the asymmetry:
| Without | With, at 156.25 MHz | |
|---|---|---|
| asymmetry introduced | 12 140 ns | ≈0 |
| offset error | 6070 ns | 1.31 ns |
| improvement | — | 4646× |
Four and a half thousand times, for a sixteen-entry tracker, a subtraction, and the rewrite hardware Chapter 16.3 already built for one-step Sync.
And the scaling across several switches is the part that decides a fabric's design, because the two residual terms scale differently.
| Hops | Random — granularity, in quadrature | Systematic — 5 ns/switch of PHY error, linear | Total |
|---|---|---|---|
| 1 | 1.31 ns | 2.5 ns | 3.8 ns |
| 2 | 1.85 ns | 5.0 ns | 6.8 ns |
| 3 | 2.26 ns | 7.5 ns | 9.8 ns |
| 5 | 2.92 ns | 12.5 ns | 15.4 ns |
| 10 | 4.13 ns | 25.0 ns | 29.1 ns |
The random term grows as √N and the systematic term grows as N, so by three hops the systematic term dominates and by ten it is six times the random one.
Which produces the fabric design rule: the depth of a synchronised path is limited by each switch's characterisation error, not by its measurement granularity. A faster capture clock helps the first column and does nothing for the second; a better-characterised PHY delay — Chapter 16.3 §4's table, stale-checked — helps the second, which is the one that matters at depth.
And the comparison with an uncorrected path is not close:
| Hops | Uncorrected, 1 Gb/s | Transparent |
|---|---|---|
| 1 | 6070 ns | 3.8 ns |
| 3 | 18 210 ns | 9.8 ns |
| 5 | 30 350 ns | 15.4 ns |
A five-hop uncorrected path is 30 µs wrong. The same path with transparent clocks is 15 ns wrong. Which is Chapter 16.1 §18's last row delivered: the endpoints were already good to ~10 ns and the path was the limit, and this is the mechanism that removes it.
==
14. RTL 7 — Servo Telemetry
Six numbers, and the useful ones compare the loop against what its inputs permit rather than against a target.
// -----------------------------------------------------------------------
// servo_telemetry -- reports what the loop is achieving alongside what
// its inputs make achievable, so a servo doing its best on a bad path
// is distinguishable from one that is mistuned.
// -----------------------------------------------------------------------
module servo_telemetry
import servo_pkg::*;
#(
parameter int SAMPLE_RATE_HZ = 16
)(
input logic clk,
input logic rst_n,
input logic err_valid,
input off_t err_ns,
input off_t spread_ns, // section 3's measurement noise
input ppb_t integral_ppb,
input servo_state_e state,
input logic [31:0] correction_interval_ms,
input logic window_tick,
output off_t achievable_ns, // section 4's total(T)
output off_t achieved_ns,
output logic performing_to_inputs,
output logic bandwidth_is_sane,
output ppb_t measured_drift_ppb,
output logic [31:0] holdover_ns_per_s,
output logic [31:0] c_windows
);
off_t worst;
function automatic off_t absv(input off_t v);
absv = v[47] ? (~v + 1'b1) : v;
endfunction
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
worst <= '0; achievable_ns <= '0; achieved_ns <= '0;
performing_to_inputs <= 1'b0; bandwidth_is_sane <= 1'b0;
measured_drift_ppb <= '0; holdover_ns_per_s <= '0;
c_windows <= '0;
end else begin
if (err_valid && (absv(err_ns) > worst)) worst <= absv(err_ns);
if (window_tick) begin
automatic off_t noise_term, drift_term;
// Section 4's two terms, evaluated at the interval in use.
// sqrt is approximated by a shift-based estimate in hardware;
// the shape is what matters, not the third digit.
noise_term = spread_ns / off_t'(isqrt(SAMPLE_RATE_HZ *
(correction_interval_ms / 1000 + 1)));
drift_term = (off_t'(measured_drift_ppb) *
off_t'(correction_interval_ms)) / 1_000_000;
achievable_ns <= noise_term + drift_term;
achieved_ns <= worst;
worst <= '0;
// Within 2x of the floor is as good as it gets.
performing_to_inputs <= (worst <= (2 * (noise_term + drift_term)));
// Section 6's two outside constraints, as a single bit.
bandwidth_is_sane <= (correction_interval_ms >=
(32'd10_000 / SAMPLE_RATE_HZ));
// The integrator IS the drift estimate -- section 5.
measured_drift_ppb <= integral_ppb;
holdover_ns_per_s <= (integral_ppb < 0) ? 32'(-integral_ppb)
: 32'(integral_ppb);
c_windows <= c_windows + 1;
end
end
end
endmoduleClassification: an estimator that evaluates Section 4's formula at run time and compares the loop against it.
What it teaches: that achievable_ns can be computed in silicon because both of its inputs are already measured. spread_ns comes from Section 3's filter and is the measurement noise; integral_ppb comes from Section 5 and is the oscillator's drift. So the floor Section 4 derived on paper is evaluable continuously, and performing_to_inputs answers the question a threshold on the error cannot: is this loop doing as well as its inputs permit.
And it teaches that bandwidth_is_sane catches Section 6's first outside constraint — a correction interval shorter than ten sample periods is a discrete loop operating above the rule-of-thumb stability limit, and its symptom is c_lock_losses rising with the reference continuously present. One comparison catches a class of mistuning that otherwise takes a scope.
Deliberately simplified: the integer square root is called out and not built; a production design uses a small lookup or a couple of Newton iterations, and the precision needed is one significant figure — the point is to distinguish 30 µs from 16 ns, not to resolve 16.2 from 16.4.
Production implication: measured_drift_ppb is free and it is the number a device's oscillator specification should be audited against. A board built with a ±20 ppm TCXO whose integral settles at −87 000 ppb has a ±100 ppm crystal fitted, which is a procurement fact nobody would otherwise discover — and which, by Chapter 16.1 §6's holdover arithmetic, changes what that device may honestly advertise in Chapter 16.2 §14's clockClass.
15. RTL 8 — Conformance for a Closed Loop
The monitor checks the loop's discipline: the actuator it used, the bounds it respected, and the states it moved between.
// -----------------------------------------------------------------------
// servo_conformance_monitor -- one bit.
//
// It asserts that the servo behaved as a servo must: rate trims only,
// bounded authority, a dwell before declaring lock, and a transparent
// clock that adds to the right messages. It does NOT assert that the
// clock is correct -- 16.2 section 8's bias is still there.
// -----------------------------------------------------------------------
module servo_conformance_monitor
import servo_pkg::*;
(
input logic clk,
input logic rst_n,
input logic stepped_after_first, // 16.1 section 3's ADJ_OFFSET
input logic locked_without_dwell,
input logic integral_wound_unbounded,
input logic trim_exceeded_consumer_limit,
input logic corrected_a_general_message,
input logic negative_residence,
input logic residence_added_twice,
input logic cfg_bandwidth_above_rate, // section 6's first constraint
input logic cfg_filter_delay_excessive,// section 6's second
input logic cfg_lock_window_unreachable,
output logic conformant,
output logic [15:0] fault_vector,
output logic [31:0] c_violations
);
logic v_step, v_lock, v_wind, v_trim, v_gen, v_neg, v_twice;
logic v_bw, v_filt, v_win;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
v_step <= 1'b0; v_lock <= 1'b0; v_wind <= 1'b0; v_trim <= 1'b0;
v_gen <= 1'b0; v_neg <= 1'b0; v_twice <= 1'b0;
v_bw <= 1'b0; v_filt <= 1'b0; v_win <= 1'b0;
c_violations <= '0;
end else begin
if (stepped_after_first) begin v_step <= 1'b1; c_violations <= c_violations + 1; end
if (locked_without_dwell) begin v_lock <= 1'b1; c_violations <= c_violations + 1; end
if (integral_wound_unbounded) begin v_wind <= 1'b1; c_violations <= c_violations + 1; end
if (trim_exceeded_consumer_limit) begin v_trim <= 1'b1; c_violations <= c_violations + 1; end
if (corrected_a_general_message) begin v_gen <= 1'b1; c_violations <= c_violations + 1; end
if (negative_residence) begin v_neg <= 1'b1; c_violations <= c_violations + 1; end
if (residence_added_twice) begin v_twice <= 1'b1; c_violations <= c_violations + 1; end
// Standing configuration properties -- section 6's two outside
// constraints and a lock window the floor cannot reach.
v_bw <= cfg_bandwidth_above_rate;
v_filt <= cfg_filter_delay_excessive;
v_win <= cfg_lock_window_unreachable;
end
end
assign conformant = !(v_step || v_lock || v_wind || v_trim || v_gen ||
v_neg || v_twice || v_bw || v_filt || v_win);
assign fault_vector = {6'b0, v_win, v_filt, v_bw, v_twice, v_neg,
v_gen, v_trim, v_wind, v_lock, v_step};
endmoduleClassification: a sticky aggregator with seven runtime violations and three standing configuration terms.
What it teaches: that cfg_lock_window_unreachable is a configuration fault nothing else catches and it produces a device that never reports lock. Section 4's total(T_opt) is the floor; a lock window tighter than it can never be satisfied at any gain, so the servo converges correctly and sits in S_ACQUIRE for ever. The check is a comparison of two numbers the design already computes — Section 14's achievable_ns against LOCK_WINDOW_NS — and without it the symptom is a servo that looks broken and is not.
And it teaches that residence_added_twice is a real failure in a switch with both an ingress and an egress timestamp unit. The correction must be added once per hop, and a design that instantiates Chapter 16.3 §10's updater on both directions of the same port adds it twice. The error is one residence time per hop, accumulating linearly — Section 13's systematic column, doubled — and it looks exactly like a path that is longer than it is.
Deliberately simplified: cfg_filter_delay_excessive is an input, and computing it means comparing Section 3's (N−1)/2 sample group delay against Section 6's loop time constant. Both are known at elaboration, so the check is a compile-time comparison that a generate block could make an error — and it catches the single most common servo instability, which is a filter and a bandwidth chosen by different people.
Production implication: conformant here means the loop is a well-behaved control system and says nothing about the time being right. Chapter 16.2 §8's asymmetry bias is still present, the servo drives the measured offset to zero, and a fully conformant loop on a 10 m-imbalanced fibre pair holds the clock 25 ns wrong, stably, for ever. Section 14's performing_to_inputs says the loop is doing its job; nothing in this chapter says the job was the right one.
16. Holdover
When the reference goes away, the integrator is the only thing that helps — and Section 5 produced it as a by-product rather than as a goal.
A device in S_HOLDOVER freezes its integral and keeps applying it. The integral's value is the standing rate correction that was cancelling the oscillator's drift, so continuing to apply it means the clock keeps running at the corrected rate rather than reverting to nominal.
FREERUN — no estimate | HOLDOVER — the integral held | |
|---|---|---|
| applied rate | nominal | nominal + the measured drift |
| residual error rate | the oscillator's full tolerance | the estimate's error, plus ageing and temperature |
| 100 ppm crystal | 100 µs/s | often < 1 µs/s |
| holds 1 µs for | 10 ms | seconds |
And the residual has three parts, in increasing order of how hard they are to do anything about.
The estimate's own error. The integral converged under measurement noise, so it is the true drift plus a residual whose size is Section 4's noise term divided by the loop's time constant. A well-locked loop's estimate is good to a few parts per billion.
Temperature. A crystal's frequency moves with temperature, typically tens of ppb per degree, and a device that loses its reference because a rack lost power is a device whose temperature is also changing. This is usually the dominant term and it is why holdover specifications are quoted with a temperature stability condition.
Ageing. Parts per billion per day, which matters only for holdover measured in days and is why Chapter 16.1 §6's four-hour requirement needed "an oscillator plus an ageing model."
Which gives the honest holdover statement and it is what Chapter 16.2 §14's clockClass is supposed to encode:
| Oscillator | Drift | Integral's residual | Holds 1 µs for, in holdover |
|---|---|---|---|
| ±100 ppm crystal | 100 µs/s | a few ppb + temperature | seconds to a minute |
| ±20 ppm TCXO | 20 µs/s | a few ppb + less temperature | minutes |
| ±0.1 ppm OCXO | 100 ns/s | sub-ppb, oven-stabilised | hours |
The first row is the one that matters for honesty. A commodity crystal in FREERUN holds 1 µs for 10 ms; the same crystal in HOLDOVER with a good estimate holds it for seconds — a factor of a hundred or more, from an integrator the loop was running anyway.
And that is exactly the difference between advertising clockClass 248 and 187. A device that collapses FREERUN and HOLDOVER into one state either throws away a real capability or claims one it does not have, and Chapter 16.2 §14's BMCA will act on whichever it is told.
17. What the Servo Can and Cannot Promise
| Claim | Status |
|---|---|
| time stays monotonic | guaranteed — rate trims only, after the first set |
| the measured offset converges to zero | guaranteed, given Section 6's constraints |
| the loop rejects noise at its bandwidth | guaranteed |
| the integral is the oscillator's drift | guaranteed — and free |
| holdover extends beyond free-running | guaranteed, by a factor of a hundred or more |
| the residence correction is exact | guaranteed to the capture granularity — 1.31 ns |
| the clock equals the master's clock | no — Chapter 16.2 §8's bias survives |
| the achieved accuracy | a property of the inputs, not the loop |
Row seven is the boundary and it is the same one Chapter 16.2 §17 drew. The servo drives the measured offset to zero. The measurement is biased by half the path's asymmetry, so the loop drives the clock to be wrong by exactly that amount — perfectly, stably, with every lock indicator green. A bias is not noise, and a filter's whole purpose is to reject noise.
Row six is this chapter's contribution and it is large. Chapter 16.1 §15 ended with the path as the limit; Section 13 removed 4646× of it per hop. What remains is the systematic term — each switch's PHY characterisation error, adding linearly at 5 ns per hop — and that belongs to Chapter 16.3 §4's table rather than to the servo.
And row eight is why Section 14 reports achievable_ns alongside achieved_ns. A servo on a 25 µs-noise path is doing its job at 30 µs of error; one on a 10 ns path with a good oscillator is doing the same job at 16.2 ns. The loop is identical and the inputs differ by three orders of magnitude — so a threshold on the error alone says nothing about the servo.
==
18. The Cost, Accounted
| Component | Cost | Note |
|---|---|---|
| Section 3's filter — 5 × 48 bits + a sort network | 30 octets + 9 compare-exchanges | — |
| Section 5's PI controller | two 64-bit accumulators, one multiply | per sample — 16/s |
| Section 7's trim applier | a clip, a slew limiter, one divide | per sample |
| Section 9's state machine | 5 states, 3 counters | — |
| Section 11's residence tracker | 16 × (16 + 80 + 32) bits | 256 octets |
| Section 12's accumulator | a 64-bit add | — |
| telemetry and conformance | ≈60 flops | — |
| total, one port | ≈300 octets + a small datapath | against Chapter 16.3's 2500 XOR2 |
| what it buys | 4646× per hop | and a loop that reaches 16.2 ns |
Three hundred octets and one multiply per sample, and the residence tracker is most of it. The controller itself is two accumulators evaluated sixteen times a second — which is a rounding error even on a small microcontroller, and is why servos are frequently implemented in firmware while the transparent clock cannot be.
The module's four chapters, in proportion:
| Chapter | Cost | Where it lands |
|---|---|---|
| Chapter 16.2's exchange | ≈220 octets | arithmetic |
| Chapter 16.3's timestamp unit | ≈2500 XOR2 + 400 flops | the transmit datapath's timing |
| this chapter's servo | ≈300 octets + one multiply per sample | firmware, or trivial logic |
| this chapter's transparent clock | 256 octets + Chapter 16.3's rewrite | every switch in the path |
The last row is the deployment cost and it is the one a datasheet does not carry. The tracker is 256 octets and it requires Chapter 16.3 §10's in-flight rewrite hardware in every intermediate switch — which is why Chapter 16.2 §20's Run D exists as a scenario at all: a network of ordinary switches cannot be upgraded to transparent clocks in firmware.
19. Properties Worth Asserting, and One Worth Refusing
The properties divide by block: the filter, the controller, the actuator, the state machine, the residence tracker, and the configuration.
Group 1 — the filter.
// P1. An untrustworthy sample never enters the window. It is not an
// outlier, it is not a measurement -- 16.2 section 15.
property p_untrusted_rejected;
@(posedge clk) disable iff (!rst_n)
(sample_valid && !sample_trustworthy) |=> $stable(win);
endproperty
// P2. A grossly wrong sample never enters either.
property p_gross_rejected;
@(posedge clk) disable iff (!rst_n)
(sample_valid && (offset_ns > off_t'(REJECT_NS))) |=> $stable(win);
endproperty
// P3. The output is a member of the window -- a median always is,
// which a mean is not. This catches an arithmetic slip directly.
property p_median_is_a_sample;
@(posedge clk) disable iff (!rst_n)
err_valid |-> (err_ns inside {win[0], win[1], win[2], win[3], win[4]});
endproperty
// P4. Up to two outliers of any magnitude do not move the output.
property p_median_rejects_two;
@(posedge clk) disable iff (!rst_n)
(two_samples_replaced_by_outliers) |=> $stable(err_ns);
endproperty
// P5. Nothing is published until the window is full.
property p_needs_full_window;
@(posedge clk) disable iff (!rst_n)
err_valid |-> (fill == N);
endpropertyGroup 2 — the controller.
// P6. The correction opposes the error. Getting this backwards makes
// the loop diverge, which is at least unmistakable.
property p_correction_opposes_error;
@(posedge clk) disable iff (!rst_n)
(err_valid && (err_ns > 0)) |=> (trim_ppb < $past(trim_ppb));
endproperty
// P7. The integral is bounded by PHYSICS -- no supported oscillator
// is more than 200 ppm off.
property p_integral_is_bounded;
@(posedge clk) disable iff (!rst_n)
(integ <= 64'sd(INT_LIMIT_PPB)) && (integ >= -64'sd(INT_LIMIT_PPB));
endproperty
// P8. Saturation is reported, not silently clamped.
property p_saturation_reported;
@(posedge clk) disable iff (!rst_n)
(integ == 64'sd(INT_LIMIT_PPB)) |-> integral_saturated;
endproperty
// P9. A frozen integral does not move -- holdover depends on it.
property p_freeze_holds;
@(posedge clk) disable iff (!rst_n)
(err_valid && freeze_integral) |=> $stable(integ);
endproperty
// P10. With zero error the proportional term is zero and only the
// integral remains -- which is the steady state.
property p_steady_state_is_integral;
@(posedge clk) disable iff (!rst_n)
(err_valid && (err_ns == 0)) |=> (trim_ppb == ppb_t'(integ));
endproperty
// P11. The integral IS the drift estimate that section 16 needs.
property p_integral_is_the_drift;
@(posedge clk) disable iff (!rst_n)
trim_valid |-> (integral_ppb == ppb_t'(integ));
endpropertyGroup 3 — the actuator.
// P12. Never a step. 16.1 section 3's ADJ_OFFSET is not used after
// the initial time-of-day set.
property p_never_steps;
@(posedge clk) disable iff (!rst_n)
(c_steps > 32'd1) |-> 1'b0;
endproperty
// P13. The applied trim never exceeds what consumers tolerate.
property p_trim_within_consumer_limit;
@(posedge clk) disable iff (!rst_n)
adj_valid |-> ((applied_ppb <= ppb_t'(MAX_TRIM_PPB)) &&
(applied_ppb >= -ppb_t'(MAX_TRIM_PPB)));
endproperty
// P14. And never changes faster than the slew limit, so a loop
// transient does not become a rate discontinuity.
property p_slew_limited;
@(posedge clk) disable iff (!rst_n)
adj_valid |-> (absv(applied_ppb - $past(applied_ppb)) <=
ppb_t'(MAX_SLEW_PPB_PER_UPDATE));
endproperty
// P15. A zero trim produces exactly the nominal increment.
property p_zero_trim_is_nominal;
@(posedge clk) disable iff (!rst_n)
(adj_valid && (applied_ppb == 0)) |-> (increment == 24'(NOMINAL));
endproperty
// P16. Clipping is reported.
property p_clip_reported;
@(posedge clk) disable iff (!rst_n)
(trim_valid && (trim_ppb > ppb_t'(MAX_TRIM_PPB))) |=> trim_clipped;
endpropertyGroup 4 — the state machine.
// P17. Lock requires the error to have STAYED in the window for
// longer than the loop's own time constant. Section 19's rejected
// property is the version that omits the dwell.
property p_lock_requires_dwell;
@(posedge clk) disable iff (!rst_n)
$rose(locked) |-> ($past(in_window) > 64'(LOCK_HOLD_CYCLES));
endproperty
// P18. Losing the reference goes to HOLDOVER if a drift estimate
// exists, and to FREERUN if not. The two are different states.
property p_holdover_needs_an_estimate;
@(posedge clk) disable iff (!rst_n)
((state == S_HOLDOVER) && $past(state != S_HOLDOVER)) |-> $past(have_drift_estimate);
endproperty
// P19. HOLDOVER freezes the integral -- that is what holdover IS.
property p_holdover_freezes;
@(posedge clk) disable iff (!rst_n)
(state == S_HOLDOVER) |-> freeze_integral;
endproperty
// P20. A saturated integral after a full dwell is a FAULT, not
// acquisition. Leaving it in ACQUIRE reports "converging" for ever.
property p_persistent_saturation_is_a_fault;
@(posedge clk) disable iff (!rst_n)
((state == S_ACQUIRE) && integral_saturated &&
(state_cycles > 64'(LOCK_HOLD_CYCLES))) |=> (state == S_FAULT);
endproperty
// P21. locked is true only in S_LOCKED.
property p_locked_iff_state;
@(posedge clk) disable iff (!rst_n)
locked <-> (state == S_LOCKED);
endpropertyGroup 5 — the transparent clock.
// P22. A residence time is non-negative. It is a physical duration
// and a negative value is evidence about the inputs.
property p_residence_is_physical;
@(posedge clk) disable iff (!rst_n)
residence_valid |-> (residence_16_16 >= 0);
endproperty
// P23. A residence measurement pairs an egress to the ingress with
// the SAME tag -- never by order.
property p_residence_pairs_by_tag;
@(posedge clk) disable iff (!rst_n)
residence_valid |-> (residence_tag == $past(out_tag));
endproperty
// P24. Every tracked frame eventually leaves the tracker -- measured
// or aged out. 14.1's allocator can discard one at the egress.
property p_no_permanent_pending;
@(posedge clk) disable iff (!rst_n)
$rose(p[i].busy) |-> ##[1:MAX_AGE+1] !p[i].busy;
endproperty
// P25. The correction is added to EVENT messages only.
property p_correction_events_only;
@(posedge clk) disable iff (!rst_n)
add_valid |-> msg_is_event;
endproperty
// P26. And added exactly once per hop.
property p_added_once_per_hop;
@(posedge clk) disable iff (!rst_n)
add_valid |=> !add_valid until_with next_frame;
endproperty
// P27. The correction accumulates: the emitted value is the received
// value plus this switch's contribution.
property p_correction_accumulates;
@(posedge clk) disable iff (!rst_n)
add_valid |-> (emitted_correction == (received_correction + add_value));
endpropertyGroup 6 — configuration.
// P28. Section 6's first outside constraint: the loop bandwidth is
// well below the sample rate.
property p_bandwidth_below_sample_rate;
@(posedge clk) disable iff (!rst_n)
!cfg_bandwidth_above_rate;
endproperty
// P29. And the second: the filter's group delay is small against the
// loop's time constant.
property p_filter_delay_is_small;
@(posedge clk) disable iff (!rst_n)
!cfg_filter_delay_excessive;
endproperty
// P30. The lock window is reachable -- wider than section 4's floor.
// Otherwise the servo converges correctly and never reports lock.
property p_lock_window_is_reachable;
@(posedge clk) disable iff (!rst_n)
(off_t'(LOCK_WINDOW_NS) > achievable_ns);
endproperty
// P31. conformant is exactly the conjunction it claims.
property p_conformant_definition;
@(posedge clk) disable iff (!rst_n)
conformant <-> (fault_vector == 16'h0000);
endpropertyP17 is the one this chapter's refusal is about, and it is worth noticing what it contains that the obvious version does not: a dwell measured against the loop's own dynamics.
20. Verification Scenarios
Seventy-three scenarios. Several have expected outcomes in which the loop converges perfectly and the clock is 25 nanoseconds wrong.
The filter
| # | Scenario | Expected |
|---|---|---|
| 1 | Five clean samples | median published |
| 2 | Two samples replaced by 1 ms outliers | output unmoved |
| 3 | Same, with a mean instead | dragged by 400 µs |
| 4 | Outliers one-sided, as queueing produces | a mean is biased; a median is not |
| 5 | Untrustworthy sample — Chapter 16.2 §15 | c_rejected_untrusted, window unchanged |
| 6 | 500 µs offset on a locked loop | c_rejected_gross |
| 7 | Output checked against the window | always a member |
| 8 | Window not yet full | nothing published |
| 9 | Median-of-5 at 16 Sync/s | 125 ms of group delay |
| 10 | Same, in a 3 Hz loop (τ = 53 ms) | more than two time constants — oscillates |
| 11 | Median-of-3 at 16 Sync/s | 62.5 ms — still marginal |
The controller
| # | Scenario | Expected |
|---|---|---|
| 12 | Positive error | negative trim |
| 13 | Sign inverted | the loop diverges — unmistakable |
| 14 | Constant drift, Kp only | a standing steady-state error |
| 15 | Same, Kp and Ki | error → 0 |
| 16 | Steady state | trim equals the integral |
| 17 | Integral after lock, 100 ppm crystal | ≈ −100 000 ppb |
| 18 | Integral limit 200 ppm | ample for every oscillator in Chapter 16.1 §4 |
| 19 | Integral pressed against the limit | not a frequency error — a bias |
| 20 | Same | integral_saturated, a finding |
| 21 | freeze_integral asserted | integral stable |
Bandwidth and gains
| # | Scenario | Expected |
|---|---|---|
| 22 | σ = 25 µs, 100 ppm, 16/s | T_opt 0.099 s, total 29 764 ns |
| 23 | Same | matches Chapter 16.1 §11's "about 30 µs" |
| 24 | σ = 25 µs, 0.1 ppm | T_opt 9.92 s, total 2976 ns |
| 25 | σ = 100 ns, 100 ppm | T_opt 0.003 s, total 750 ns |
| 26 | σ = 10 ns, 100 ppm | T_opt 0.0005 s → f_c 295 Hz |
| 27 | Same, at 16 Sync/s | unreachable — sample-rate limited |
| 28 | Same, at 128 Sync/s | closer, and the argument for the rate |
| 29 | σ = 10 ns, 0.1 ppm | T_opt 0.054 s, total 16.2 ns, f_c 2.95 Hz |
| 30 | f_c 0.1 Hz, ζ = 0.707 | Kp 0.888, Ki 0.395, settling 9.0 s, overshoot 4.3% |
| 31 | f_c 0.01 Hz | settling 90 s |
| 32 | ζ = 1.0 | no overshoot, slower |
| 33 | f_c above rate/10 at 16/s | above 1.6 Hz — unstable |
| 34 | Better oscillator, same loop | the optimum moves slower — the loop should too |
The actuator
| # | Scenario | Expected |
|---|---|---|
| 35 | Trim 1 ppm, 40 µs offset | 40 s to converge |
| 36 | Trim 100 ppm | 0.4 s — and the clock's rate is as wrong as a bad crystal |
| 37 | Trim 1000 ppm | frequency error larger than the offset it fixes |
| 38 | MAX_TRIM_PPB 1000, 100 ppm crystal | permanently clipped — incompatible requirements |
| 39 | Same | c_clipped makes it visible |
| 40 | Loop step after a master change | slew-limited to a 1.25 s ramp |
| 41 | Zero trim | exactly the nominal increment |
| 42 | Step at cold start | the one legitimate step |
| 43 | Step afterwards | c_steps > 1 — a finding |
| 44 | Backwards step | ordering violation nothing downstream detects |
Lock and holdover
| # | Scenario | Expected |
|---|---|---|
| 45 | One in-window sample | not locked |
| 46 | In-window for 500 ms (9 τ) | locked |
| 47 | Diverging, error passing through zero | not locked — the dwell is not met |
| 48 | Locked, one noisy sample | stays locked — the filter absorbed it |
| 49 | Out of window for a full dwell | lock dropped |
| 50 | Integral saturated after a full dwell | S_FAULT, not S_ACQUIRE |
| 51 | Same, without the fault state | reports "converging" for ever |
| 52 | Reference lost, drift estimate held | S_HOLDOVER |
| 53 | Reference lost, no estimate | S_FREERUN |
| 54 | FREERUN, 100 ppm crystal | 100 µs/s — holds 1 µs for 10 ms |
| 55 | HOLDOVER, good estimate | often < 1 µs/s — holds for seconds |
| 56 | The two states collapsed | advertises the wrong clockClass |
| 57 | Lock window tighter than achievable_ns | never reports lock |
| 58 | Lock losses with the reference present | the loop — bandwidth or filter delay |
| 59 | Lock losses correlated with the reference | the network |
The transparent clock
| # | Scenario | Expected |
|---|---|---|
| 60 | One store-and-forward switch, uncorrected | 6070 ns of offset error |
| 61 | Same, transparent, 156.25 MHz captures | 1.31 ns — 4646× |
| 62 | Captures at 125 MHz | σ of the difference 3.27 ns |
| 63 | Captures at 322 MHz | 1.27 ns |
| 64 | Three hops, random term | 2.26 ns — √3 |
| 65 | Three hops, systematic at 5 ns/switch | 7.5 ns — linear |
| 66 | Ten hops | random 4.13, systematic 25.0 — the systematic dominates |
| 67 | Five hops uncorrected | 30 350 ns |
| 68 | Five hops transparent | 15.4 ns |
| 69 | Residence added to a Follow_Up | one residence time of error per hop |
| 70 | Updater on both directions of one port | residence_added_twice |
| 71 | Frame discarded at the egress | tracker entry ages out, c_abandoned |
| 72 | Same, without the age-out | 16 discards and the tracker stops for ever |
| 73 | Negative residence | a tag collision, or a clock stepped mid-transit |
The directed test random stimulus will not produce
A servo's behaviour is defined over many time constants, and random stimulus produces samples rather than trajectories. The finding here is that a loop's correctness and a clock's correctness are different things — a perfectly converging loop on a biased measurement holds the clock stably wrong — and demonstrating it requires driving the identical loop with two measurement models and comparing the converged state. No coverage metric asks for a comparison between two runs' steady states.
Setup: one servo, f_c = 3 Hz, ζ = 0.707, median-of-3 filter (62.5 ms of delay against a 53 ms time constant — deliberately at the edge), 16 samples per second, a 100 ppm oscillator model, and a testbench that holds the true offset. MAX_TRIM_PPB = 200 000 so the actuator is not the limit.
Stimulus, four runs of 600 seconds each.
Run A — clean: true offset +40 µs at t = 0, measurement noise σ = 10 ns, symmetric path.
Run B — noisy: the same, σ = 25 µs.
Run C — biased: σ = 10 ns, and a fixed +50 ns measurement bias — 10 m of fibre imbalance, Chapter 16.2 §6's 25 ns doubled at the sensor.
Run D — biased and corrected: Run C with the imbalance stored and applied by Section 12's accumulator.
Oracle:
| # | Observable | A — clean | B — noisy | C — biased | D — corrected |
|---|---|---|---|---|---|
| 1 | converges | yes | yes | yes | yes |
| 2 | time to S_LOCKED | ≈9 s | ≈9 s | ≈9 s | ≈9 s |
| 3 | measured offset, steady state | ≈0 | ≈0 | ≈0 | ≈0 |
| 4 | TRUE offset, steady state | ≈0 | ≈0 | −50 ns | ≈0 |
| 5 | achieved_ns | ≈16 ns | ≈30 µs | ≈16 ns | ≈16 ns |
| 6 | achievable_ns | ≈16 ns | ≈30 µs | ≈16 ns | ≈16 ns |
| 7 | performing_to_inputs | high | high | high | high |
| 8 | locked | high | high | high | high |
| 9 | integral_ppb | ≈ −100 000 | ≈ −100 000 | ≈ −100 000 | ≈ −100 000 |
| 10 | integral_saturated | low | low | low | low |
| 11 | conformant | high | high | high | high |
| 12 | c_violations | 0 | 0 | 0 | 0 |
| 13 | c_steps | 1 | 1 | 1 | 1 |
| 14 | trim_clipped | low | low | low | low |
| 15 | any indicator distinguishing C from A | — | — | none | — |
| 16 | reference removed at t = 300 s | HOLDOVER | HOLDOVER | HOLDOVER | HOLDOVER |
| 17 | error growth in holdover | < 1 µs/s | < 1 µs/s | < 1 µs/s | < 1 µs/s |
| 18 | same with FREERUN instead | 100 µs/s | — | — | — |
| 19 | rerun A with the median-of-5 filter | oscillates — 125 ms in a 53 ms loop | — | — | — |
| 20 | rerun A with f_c = 5 Hz at 16/s | unstable — above rate/10 | — | — | — |
Rows 3, 4 and 15 together are the finding. Run C's measured offset converges to zero exactly as Run A's does; its true offset converges to −50 ns and stays there. Every indicator the device produces — locked, performing_to_inputs, conformant, achieved_ns against achievable_ns — is identical between the two runs. The servo is not merely unable to fix the bias; it cannot tell that there is one.
Row 4's Run D is the remedy and it is not in this chapter's control loop at all: the imbalance is applied by Section 12's accumulator from a value Chapter 16.5 §11's calibration produced. A constant, stored, subtracted.
And rows 19 and 20 are Section 6's two outside constraints as experiments: the same gains that are stable with a median-of-3 oscillate with a median-of-5, and a bandwidth above a tenth of the sample rate is unstable at any damping.
21. Debugging a Servo
Five questions, in order. The first three are about the loop and the last two are about what the loop cannot see.
Step 1 — is the loop stable? c_lock_losses with reference_present continuously high, and bandwidth_is_sane. Losses with the reference present are the loop, not the network — and the two causes are Section 6's outside constraints: a bandwidth above a tenth of the sample rate, or a filter whose group delay approaches the loop's time constant. Both are compile-time comparisons the design can make about itself.
Step 2 — is the loop converging? state and time_in_state_ms. A device in S_ACQUIRE for minutes is either settling at a very low bandwidth — 90 seconds at 0.01 Hz — or it is in the state S_FAULT exists to distinguish: an integral saturated after a full dwell, chasing something a rate trim cannot fix.
Step 3 — is it performing to its inputs? achieved_ns against achievable_ns. A 30 µs error is a failure on a path whose floor is 16 ns and is the best available on one whose floor is 30 µs — and Section 4's formula, evaluated in silicon from spread_ns and integral_ppb, tells the two apart. performing_to_inputs high with the requirement unmet means the inputs are the problem.
Step 4 — is the path corrected? c_applied and c_suppressed_general at every switch, and Chapter 16.2 §15's path_spread_ns. A switch with c_applied at zero is not acting as a transparent clock — and Section 13's table says that costs 6070 ns per uncorrected hop at 1 Gb/s. c_suppressed_general at zero while Follow_Ups are crossing means the switch is corrupting them.
Step 5 — is there a bias? This is the step the servo cannot take and it is where the remaining error is. Section 20's Run C: a converged loop, every indicator green, and a clock 50 ns wrong. The only evidence available is external — Chapter 16.2 §15's mean_path_ns compared against the link's known physical length, and the difference is queueing, from which the asymmetry follows.
And the finding that ends an investigation here: stable, locked, performing to inputs, every hop correcting, and a measured path delay that matches the cable. That is a servo doing everything available to it — and Chapter 16.5 is about what is left when it is.
22. Common Misconceptions
1 — "A faster servo is a better servo."
The wrong model: correcting more aggressively converges sooner and tracks better.
What it costs: a loop that admits every bit of measurement noise it could have filtered. Section 4's optimum for 25 µs of jitter on a 100 ppm oscillator is a 0.099 s interval — 1.6 Hz — and going faster raises the total error because the noise term grows as the interval shrinks.
The corrected model: the bandwidth is the crossover between two terms the loop cannot reduce. Noise falls as 1/√(rate × T) and drift rises linearly in T, so there is a genuine optimum — and a better oscillator moves it slower, not faster. A design with an OCXO and a fast loop has bought the oscillator and not used it.
2 — "Step the clock to correct a large offset."
The wrong model: a step is a fast correction and is over quickly.
What it costs: every measurement spanning the step. A backwards step makes time non-monotonic, and a consumer that recorded an event at T and then reads T − 40 µs has an ordering violation it cannot detect — no downstream analysis recovers it.
The corrected model: a step is legitimate exactly once, at initial time-of-day set, where the offset is arbitrarily large. Everything after is a rate trim, bounded by what the clock's consumers tolerate as a frequency error — 1 ppm closes 40 µs in 40 seconds, and 100 ppm closes it in 0.4 s while making the clock as wrong in frequency as a bad crystal. c_steps greater than one is itself a finding.
3 — "The loop converged, so the clock is right."
The wrong model: a servo that drives its error to zero has made the clock correct.
What it costs: Section 20's Run C — a converged loop, locked high, conformant high, performing_to_inputs high, and the clock 50 ns wrong. The servo drives the measured offset to zero, and Chapter 16.2 §6's measurement is biased by half the path's asymmetry.
The corrected model: a filter rejects noise and a bias is not noise. The loop drives the true offset to −(bias) perfectly and stably, and no indicator the device produces distinguishes that from a clean path. The remedy is a stored calibration applied by Section 12's accumulator — outside the loop entirely.
4 — "Lock means the error is inside the window."
The wrong model: lock is a threshold on the current error.
What it costs: a lock indicator that fires on health and is silent on failure. A 3 Hz loop's time constant is 53 ms — 0.85 samples at 16/s — so at any instant the loop has not acted on the previous sample. One noisy sample fails the test while the servo is fine; one lucky sample passes it while the servo is diverging through zero.
The corrected model: lock is a dwell: the error stays inside the window for several loop time constants — 500 ms at 3 Hz is nine of them. And assert on the filtered error, not a raw sample, because Section 3's median is what the loop actually sees.
5 — "Transparent clocks are a nice-to-have."
The wrong model: good endpoints deliver good synchronisation.
What it costs: 6070 ns per uncorrected store-and-forward hop at 1 Gb/s, against endpoints already good to ten nanoseconds. Five hops is 30 350 ns. Chapter 16.1 §15 and Chapter 16.2 §20's Run D both end here.
The corrected model: a transparent clock measures its own residence time and writes it into correctionField, and the residual is the measurement's granularity: 1.31 ns at a 156.25 MHz capture — a factor of 4646. And it cannot be retrofitted in firmware: it needs Chapter 16.3 §10's in-flight rewrite hardware in every intermediate switch.
6 — "A deeper path just needs faster capture clocks."
The wrong model: the residual scales with timestamp granularity.
What it costs: money on capture clocks that fix the wrong term. Section 13: the random term grows as √N and the systematic term — each switch's PHY characterisation error — grows as N. At three hops the systematic term is already three times the random one; at ten hops it is six times.
The corrected model: the depth of a synchronised path is limited by characterisation, not granularity. A faster capture clock helps the √N term and does nothing for the N term; a better-characterised and stale-checked PHY delay — Chapter 16.3 §4's table — is what buys depth.
23. Interview Reasoning
Q1 — How do you choose a PTP servo's loop bandwidth?
From the crossover of two terms the loop cannot reduce. Filtered measurement noise falls as σ/√(rate × T); drift accumulated between corrections rises as ω × T. Differentiating gives T_opt = (σ / (2ω√rate))^(2/3). At 16 samples per second: 25 µs of jitter on a 100 ppm crystal gives 0.099 s and 29.8 µs of total error — Chapter 16.1 §11's figure, derived; 10 ns of jitter on a 0.1 ppm OCXO gives 0.054 s, a 3 Hz loop, and 16.2 ns. And a better oscillator moves the optimum slower.
Q2 — Derive the PI gains.
A clock is a pure integrator — rate in, phase out — so the plant is 1/s. With C(s) = Kp + Ki/s the closed-loop characteristic equation is s² + Kp·s + Ki = 0, giving ωn = √Ki and ζ = Kp/(2√Ki). At f_c = 0.1 Hz and ζ = 0.707: Kp = 0.888, Ki = 0.395, 2% settling in 9.0 s, 4.3% overshoot. Two constraints bound it from outside: the bandwidth must be well below the sample rate — a tenth is the rule of thumb — and the filter's group delay must be small against the loop's time constant.
Q3 — Why does the integral term matter beyond control?
Because it is the oscillator's measured drift. The integrator converges to the standing rate correction that cancels the clock's frequency error, which is exactly Chapter 16.1 §5's drift_ppb, obtained free. It is what makes holdover work — freeze it and keep applying it, and a 100 ppm crystal's error growth drops from 100 µs/s to often under 1 µs/s, which is the difference between holding 1 µs for 10 ms and holding it for seconds. And it is what Chapter 16.2 §14's clockClass should be derived from, rather than asserted.
Q4 — Why must a servo never step the clock?
Because a step breaks monotonicity and invalidates every measurement spanning it. A consumer that recorded an event at T and then reads T − 40 µs has an ordering violation nothing downstream detects. The alternative is a rate trim, which is bounded not by the loop but by what the clock's consumers tolerate as a frequency error — 1 ppm closes 40 µs in 40 seconds. A step is right exactly once, at initial time-of-day set, where the offset is arbitrarily large and no trim closes it — so c_steps greater than one points at Chapter 16.1 §6's resync arithmetic.
Q5 — What does a transparent clock buy, and what is left afterwards?
A factor of 4646 per hop. One uncorrected store-and-forward switch at 1 Gb/s introduces 12.14 µs of asymmetry and therefore 6070 ns of offset error; a transparent clock measures its residence time exactly and the residual is the measurement's granularity — two captures at 156.25 MHz differ with σ = 2.61 ns, halved by Chapter 16.2 §6's factor of two: 1.31 ns. What is left scales in two ways: granularity as √N and each switch's PHY characterisation error as N — so at ten hops the systematic term is 25 ns against 4.1 ns of random, and path depth is limited by characterisation rather than by clocks.
Q6 — Why can't you assert that a locked servo's error is small?
Because the evaluation window is shorter than the loop's time constant. A 3 Hz loop's τ is 53 ms — 0.85 samples at 16/s — so at the instant a sample arrives the loop has not acted on the previous one, and the property reads the measurement rather than the loop's output. It therefore fires on health (one noisy sample, servo fine) and is silent on failure (a diverging loop passing through zero). The fix is to change the window, not the bound: assert a dwell for lock, assert on the filtered error, and assert convergence within a settling time — a claim about behaviour over several time constants, which is the only timescale on which a loop's behaviour is defined.
24. Understanding Check
25. What's Next
This chapter closed the loop and corrected the path, and both results were large.
The loop: a PI controller whose bandwidth falls out of Chapter 16.1 §11's trade rather than being chosen — 2.95 Hz for good timestamps on a good oscillator, reaching 16.2 ns. The path: a transparent clock that turns 6070 ns per hop into 1.31 ns, using the in-flight rewrite Chapter 16.3 §10 built.
What is left is what neither could touch, and Section 20's Run C is the demonstration.
Three terms survive everything Module 16 has built.
Path asymmetry, entering at exactly half the imbalance — Chapter 16.2 §6 — and invisible to every statistic, because it is a bias rather than noise.
Timestamp granularity, at period/√12 per capture — Chapter 16.1 §14 — now propagated through four timestamps and a chain of transparent clocks, which changes its size.
And jitter, which the servo filters and does not remove, and which compounds across a chain of clocks each disciplining the next.
Chapter 16.5 — What Limits Accuracy prices all three with the protocol in place, which is a different exercise from Chapter 16.1 §15's pricing of them without it. It derives the asymmetry error's exact form and shows why no statistic reveals it — a bias is consistently displaced and every variance-based test passes. And it gives the calibration procedure a commissioned deployment runs and an uncommissioned one does not, producing the constant Section 12's accumulator has been waiting for.
One thread carries directly across. This chapter's servo was a filter, and a filter's entire purpose is to reject noise. The next chapter is about the three error sources that are not noise — and therefore about the limits of everything Module 16 has built.
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
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.
- 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
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.
