The Cuckoo Escapement: field report, kernel patch, dashboard

What the Raspberry Pi time-server guides get wrong on a Pi 4, with the
measurements. The headline artifact is a four-line pps-gpio patch: PREEMPT_RT
force-threads IRQ handlers, and pps-gpio takes its timestamp inside its handler,
so the realtime kernel puts a scheduler between the electrical edge and the
clock. IRQF_NO_THREAD takes RMS offset from 2468 ns to 199 ns.

- kernel/     the patch
- dashboard/  live status page (position hidden by default)
- docs-site/  the write-up (Astro/Starlight, brass, no tutorial section)
This commit is contained in:
Ryan Malloy 2026-07-14 09:21:25 -06:00
commit 6881489bf6
56 changed files with 10297 additions and 0 deletions

View file

@ -0,0 +1,51 @@
---
title: cpu0 is sacred
description: The CPU map this box lives by — discovered by measurement, not designed.
sidebar:
order: 6
---
This is the layout the machine ended up with:
```
cpu0 ──── PPS interrupt. Nothing else. Ever.
cpu1 ──── web tier (dashboard, Caddy)
cpu2 ──── chronyd (isolated)
cpu3 ──── gpsd + UART IRQ thread (isolated)
```
Not one of those four lines came from a guide. Each came from a measurement that
contradicted an assumption.
- **cpu0 holds the PPS interrupt** because the Pi 4's GPIO mux
[physically refuses to move it](/explanation/the-interrupt-you-cannot-move/).
That isn't a preference; it's a constraint we cannot configure away.
- **cpu2 and cpu3 are isolated** (`isolcpus=2,3`) for the timing daemons, which
want determinism more than throughput.
- **cpu1 got the web tier** only after a benchmark caught it
[taxing the clock 36% from cpu0](/explanation/the-observer-effect/).
## The rule this implies
Because the PPS interrupt cannot be relocated, **cpu0 is load-bearing for
precision in a way no other core is**. Anything you schedule there is competing
directly with the timestamp.
So: every service on this box — an exporter, a log shipper, a backup job, a cron
entry, anything you add six months from now — belongs on cpu13.
```ini
[Service]
CPUAffinity=1
Nice=10
```
It's a one-line tax, and the alternative is a slow, invisible erosion of the one
number the machine exists to produce.
:::note[Isolation alone did nothing]
Worth saying plainly: `isolcpus` on its own did **not** improve PPS jitter, and
plausibly made it worse — by evacuating cpu2/3, we concentrated *everything else*
onto cpu0/1, which is where the PPS interrupt lives. Isolation only pays once you
also keep the evacuated work away from the PPS core.
:::

View file

@ -0,0 +1,52 @@
---
title: Why PTP is off the table on a Pi 4
description: PTP's entire value is hardware timestamping. The Pi 4's NIC has no PTP hardware clock. Software PTP is a worse NTP.
sidebar:
order: 3
---
The reference builds all reach for **PTP** (IEEE 1588), and they're right to: on
the right hardware it's dramatically better than NTP.
The Pi 4 is not the right hardware. One command settles it:
```console
$ ethtool -T eth0
Capabilities:
software-transmit
software-receive
software-system-clock
PTP Hardware Clock: none
Hardware Transmit Timestamp Modes: none
Hardware Receive Filter Modes: none
```
**`PTP Hardware Clock: none`.** There isn't one. There's no `/dev/ptp0` to open.
## Why that's fatal rather than inconvenient
PTP's whole advantage is **hardware timestamping**: the network card itself
stamps the packet as it crosses the wire, in silicon, outside the operating
system. That's what removes kernel scheduling, driver latency, and queueing from
the measurement, and it's why PTP reaches nanoseconds where NTP reaches
microseconds.
Take the hardware clock away and PTP is just... a protocol. Software-timestamped
PTP has the packets stamped by the *kernel*, on the *CPU*, subject to exactly the
scheduling jitter you were trying to escape. It is a more complicated NTP with
worse tooling.
## But the guides say the Pi 4's PHY supports PTP
They do, and the *chip* does — the BCM54213PE PHY has PTP capability on paper.
It doesn't matter. The Pi 4's `bcmgenet` MAC driver doesn't expose a PHC, so
Linux has nothing to give you. And the reference builds that make PTP work feed
the PPS into the NIC through a **SYNC pin that only the CM4/CM5 break out** — a
regular Pi 4 board doesn't route it anywhere you can reach.
## So don't chase it
We spent real time on this before running `ethtool -T`, which we should have run
first. If your board reports `PTP Hardware Clock: none`, close the tab. Put the
effort into the PPS path instead — that's where the nanoseconds actually are, and
[it needs the help](/explanation/preempt-rt-made-it-worse/).

View file

@ -0,0 +1,113 @@
---
title: Why PREEMPT_RT made it worse
description: The realtime kernel force-threads interrupt handlers. The PPS driver takes its timestamp inside its handler. Those two facts multiply badly.
sidebar:
order: 1
---
Installing a realtime kernel is the prestige move in every Pi time-server guide.
It is also, on its own, the single most damaging thing we did.
| | raw PPS jitter (σ) | peak-to-peak |
|---|---|---|
| Stock kernel | 2134 ns | 11 µs |
| **PREEMPT_RT** | **6947 ns** | **38 µs** |
Three times worse. Not marginally, not within noise — **three times.**
## Why
PREEMPT_RT achieves its determinism by **force-threading interrupt handlers**.
Instead of running in hard-IRQ context (immediately, uninterruptibly, nanoseconds
after the electrical edge), a handler becomes a schedulable kernel thread that
the scheduler runs *when it gets around to it*.
For most drivers this is a good trade: you lose a little latency, you gain the
ability to preempt long-running handlers, and the *worst case* improves. That's
the entire pitch of realtime Linux, and it's a good pitch.
But look at what `pps-gpio` actually does in its handler:
```c
static irqreturn_t pps_gpio_irq_handler(int irq, void *data)
{
...
pps_get_ts(&ts); /* ← THE TIMESTAMP IS TAKEN HERE */
pps_event(info->pps, &ts, ...);
...
}
```
**The handler is the measurement.** `pps_get_ts()` is the whole point of the
driver — it captures *when the pulse arrived*. Everything downstream, every
nanosecond of accuracy chrony reports, descends from that one call.
So when PREEMPT_RT threads this handler, it doesn't defer some *work*. It defers
**the act of looking at the clock**. The timestamp is no longer taken at the
electrical edge; it's taken after thread-wakeup latency — microseconds later, and
*variably* later, which is worse.
We didn't make the system more deterministic. We inserted a scheduler between the
pulse and the clock.
## You can see it happen
On a stock kernel, the PPS interrupt has no thread at all:
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
(nothing — it runs in hard-irq context)
```
Boot PREEMPT_RT and it materialises:
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
239 RR 50 3 irq/41-pps@12.-1
```
That thread is the problem. It is also — and this is the cruel part — *exactly
what the guides tell you to go and pin to an isolated core.* You can only
`taskset` a thread. The advice to isolate the PPS IRQ **requires** the very
threading that destroys the timestamp.
<div />
:::danger[The trap, stated plainly]
**You can pin the PPS interrupt, or you can timestamp it fast. You cannot do
both.** Threading is the price of pinning, and on our board that price was 12×
the accuracy. A hard-IRQ handler on a *busy* CPU 0 beat a threaded-and-pinned one
on a *quiet, isolated* CPU 3 — by a mile.
:::
## The fix
Tell the kernel this particular handler must not be threaded:
```c
flags |= IRQF_NO_THREAD;
```
That's it. The timestamp goes back to hard-IRQ context, at the electrical edge,
while the rest of the system keeps every benefit of PREEMPT_RT.
| | RMS offset | raw PPS jitter |
|---|---|---|
| Stock kernel | 440 ns | 2134 ns |
| PREEMPT_RT (unpatched) | 2468 ns | 6947 ns |
| **PREEMPT_RT + `IRQF_NO_THREAD`** | **199 ns** | 2568 ns |
The patch is four lines and it's [here](/reference/the-patch/). It is, as far as
we can tell, not applied anywhere — which means **anyone running GPIO-based PPS
on a realtime kernel today is silently eating microseconds of jitter** and has no
reason to suspect it, because everything *looks* fine. chrony still says Stratum 1.
The dashboard still says locked. The number is just quietly worse.
## The lesson underneath
The realtime kernel is not "the fast kernel." It is the *predictable* kernel, and
it buys predictability by making things schedulable. If the thing you care about
is **a measurement taken inside an interrupt handler**, making it schedulable is
precisely the wrong move.
Nothing about that is obvious from the outside. It is only obvious from a number.

View file

@ -0,0 +1,75 @@
---
title: The interrupt you cannot move
description: On a Pi 4, GPIO interrupts are demuxed through pinctrl-bcm2835 and refuse an smp_affinity. The IRQ-isolation advice is unachievable here.
sidebar:
order: 2
---
Every guide says the same thing: park the PPS interrupt on its own isolated CPU,
give it realtime priority, and keep the noisy world away from it.
On a Raspberry Pi 4, **you cannot.**
```console
$ echo 3 > /proc/irq/41/smp_affinity_list
tee: /proc/irq/41/smp_affinity_list: Operation not permitted
```
## Why
Your PPS arrives on a **GPIO pin**, and GPIO interrupts on the BCM2711 are not
first-class interrupts. They are **demultiplexed** through the GPIO controller:
```console
$ grep -E 'pps|uart' /proc/interrupts
40: 3532866 0 0 0 GICv2 153 Level uart-pl011
41: 104835 0 0 0 pinctrl-bcm2835 18 Edge pps@12.-1
```
Look at the difference. The UART is a **GICv2** interrupt — a real line into the
interrupt controller, and it takes an affinity happily. The PPS is a
**`pinctrl-bcm2835`** interrupt — one of dozens of GPIO lines multiplexed behind
a single parent IRQ. There is no per-line steering to give. Every GPIO interrupt
lands wherever the GPIO controller's parent lands, together.
So the PPS interrupt goes where it goes, and no amount of configuration moves it.
## The cruel bit
There *is* one way to gain control of it: **PREEMPT_RT force-threads interrupt
handlers**, and a thread can be `taskset` anywhere. Boot a realtime kernel and the
thing you couldn't pin becomes pinnable:
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
239 RR 50 3 irq/41-pps@12.-1 ← RT priority, isolated CPU 3. It worked!
```
The guides are vindicated. Except it's a trap, because
[threading the handler is what destroys the
timestamp](/explanation/preempt-rt-made-it-worse/) — `pps-gpio` takes its
measurement *inside* that handler, so putting it behind the scheduler costs more
than the isolation ever gives back.
:::danger[Pin it, or timestamp it fast. Not both.]
- **Threaded + pinned to a quiet isolated core:** RMS offset **2468 ns**
- **Hard-IRQ + unpinned on a busy CPU 0:** RMS offset **199 ns**
The fast handler on the *noisy* core beat the scheduled handler on the *quiet*
core by more than 12×. Interrupt latency dominates CPU contention, and it isn't
close.
:::
## What to do instead
Accept that the PPS interrupt lives on CPU 0, and then **treat CPU 0 as sacred**.
You can't move the interrupt, but you can move *everything else*:
```ini
# every other service gets an affinity that isn't 0
[Service]
CPUAffinity=1
```
That's the whole strategy. It's not the one in the guides, but it's the one the
hardware permits. [→ cpu0 is sacred](/explanation/cpu0-is-sacred/)

View file

@ -0,0 +1,82 @@
---
title: The observer effect
description: Our monitoring dashboard cost 36% more PPS jitter. The instrument was bending the measurement.
sidebar:
order: 5
---
We built a status dashboard for the time server. Then somebody asked the obvious
question nobody asks: **is the dashboard hurting the clock?**
It was. By 36%.
## The measurement
A/B/A, sixty-two seconds of raw `ppstest` per round, with the middle round as the
control and the third to prove it wasn't drift:
| Round | Dashboard | PPS jitter (σ) | peak-to-peak |
|---|---|---|---|
| 1 | **on** | 1912 ns | 10252 ns |
| 2 | **off** | **1304 ns** | **6914 ns** |
| 3 | **on** | 2179 ns | 11683 ns |
Round 3 reproduces round 1. It's real.
## Why
Two facts, individually harmless, catastrophic together:
1. The collector was **forking `chronyc` four times a second** — once each for
`tracking`, `sources`, `sourcestats`, `clients`. Process creation is one of the
most expensive things you can ask a scheduler to do.
2. That churn landed on **CPU 0** — the one core the PPS interrupt is welded to
and [cannot be moved off](/explanation/the-interrupt-you-cannot-move/).
The monitoring tool was standing on the neck of the thing it monitors. And it was
invisible: every metric looked fine, chrony still said Stratum 1, the dashboard
still said "locked". The number was just quietly worse.
## The fix (no timing code was touched)
**1. Batch chronyc into one process.** It reads commands from stdin, so one fork
serves all three:
```bash
printf 'tracking\nsources\nsourcestats\n' | chronyc -c
```
Tell the outputs apart by field count: 14 = tracking, 10 = sources, 8 = sourcestats.
:::caution
Passing multiple commands as **arguments** silently runs only the first.
`chronyc -c tracking sources sourcestats` returns tracking and nothing else, with
no error. Use stdin.
:::
**2. Get off CPU 0.**
```ini
[Service]
CPUAffinity=1
```
## The result
| | Dashboard off | Dashboard on | Penalty |
|---|---|---|---|
| Before | 1304 ns | 1912 / 2179 ns | **+36%** |
| After | 1437 ns | **1169 / 1450 ns** | **none — within noise** |
The box running its *entire* production stack is now quieter than it was sitting
**idle** before the fix.
## The general rule
On a machine where one core is load-bearing for precision, **every other service
you run is a tenant on the other cores**, whether it knows it or not. And process
creation is the loudest neighbour there is.
A monitoring tool must not perturb what it measures. If you have never checked
whether yours does, you do not know that it doesn't.

View file

@ -0,0 +1,62 @@
---
title: Where the precision actually lives
description: NMEA labels the second. PPS carries all the accuracy. Once you internalise that, half the tuning advice evaporates.
sidebar:
order: 4
---
A GPS receiver hands you time twice, in two completely different currencies, and
almost every tuning mistake comes from confusing them.
## NMEA tells you *which* second it is
The receiver computes the time precisely, and then it has to **shift a text
sentence out of a serial port**. At 9600 baud that takes hundreds of milliseconds,
and the delay wobbles from second to second depending on how many sentences are
enabled and what the CPU was doing.
Our NMEA-derived time sat **+160 ms** off, with hundreds of microseconds of noise.
That's not the receiver being bad. That's a UART being a UART.
## PPS tells you *exactly when* that second began
The same receiver also raises a **single electrical edge** at the top of every
second, accurate to nanoseconds. No protocol, no encoding, no serial port — just
a voltage going high at the instant the second starts.
That edge is where every nanosecond of your accuracy comes from. All of it.
## What that means in practice
chrony uses them together, and the division of labour is total:
```
refclock SHM 0 refid GPS ... noselect # NMEA: labels the second. Never the time source.
refclock PPS /dev/pps0 ... lock GPS # PPS: IS the time source.
```
The NMEA source is marked `noselect` — chrony is explicitly told *never to use it
to set the clock*. Its only job is to answer "which second is this pulse?", and
for that it merely has to be within half a second. It has an entire half-second
of slack.
:::tip[The consequence that saves you a day]
**Anything that improves NMEA and nothing else improves nothing.**
We raised the module's baud rate from 9600 → 115200, a 12× improvement to the
serial path, and measured the result:
| | PPS offset | root dispersion |
|---|---|---|
| 9600 baud | 1 ns | 7.6 µs |
| 115200 baud | 1 ns | 6.3 µs |
**Identical.** We'd improved the thing that doesn't carry the precision.
Worse: pinning gpsd to that baud later caused a
[total GPS outage after a power cut](/how-to/survive-a-power-cut/). We nearly
took the server down defending an optimisation worth nothing.
:::
Baud rate, sentence count, SBAS, update rate — all of it lives on the NMEA side of
the wall. Tune it if you enjoy tuning. Just don't expect the clock to notice.

View file

@ -0,0 +1,50 @@
---
title: The findings, in brief
description: Everything we discovered, with the numbers, on one page.
---
For people who want the whole thing in ninety seconds.
## What we built
A GPS-disciplined Stratum 1 NTP server: **Raspberry Pi 4** + **BerryGPS-IMU v4**
(u-blox CAM-M8C), PPS on GPIO18, `gpsd` + `chrony`. Final state: **RMS offset
199 ns**, root delay ~1 ns, survives a cold power cut unattended. About $130 of
parts, replacing an appliance that costs $1,500$10,000.
## What the guides get wrong on a Pi 4
| Claim | Reality |
|---|---|
| "Use PTP for real precision" | **Impossible.** `ethtool -T eth0``PTP Hardware Clock: none`. No hardware timestamping exists on this NIC. |
| "Isolate the PPS IRQ on a dedicated core" | **Not permitted.** GPIO IRQs demux through `pinctrl-bcm2835` and reject `smp_affinity`. |
| "Install PREEMPT_RT" | **Made jitter 3× worse** until patched — it threads the handler that takes the timestamp. |
| "Raise the GPS baud rate" | **Irrelevant.** PPS offset measured 1 ns at 9600 vs 115200. Identical. NMEA only *labels* the second. |
## What actually helped
| Change | RMS offset |
|---|---|
| Baseline | 823 ns |
| chrony `filter 10` + `prefer` on the PPS refclock | 440 ns |
| PREEMPT_RT + [`IRQF_NO_THREAD` patch](/reference/the-patch/) | **199 ns** |
## What we broke, and how we found it
Two failures that a reboot will never reveal. Only a **cold power cut** exposes
them, which is why you must actually pull the plug:
1. **gpsd was being started by the dashboard.** It's socket-activated; chrony
reads its *shared memory*, never its socket, so nothing else triggered it. The
monitoring page was load-bearing for the time server.
2. **Pinning gpsd's baud turned a module quirk into an outage.** The CAM-M8 keeps
config in supercap-backed RAM and reverts to 9600 on power loss. With gpsd
pinned to 115200 it came back talking to a module that wasn't listening: **no
GPS at all.** Use `GPSD_OPTIONS="-n"` and let it auto-probe.
## And the instrument was bending the measurement
Our own dashboard cost **36% more PPS jitter** by forking `chronyc` four times a
second onto CPU 0 — the one core the PPS interrupt is welded to and cannot be
moved from. Batching it to one process and pinning it off CPU 0 erased the
penalty entirely. [→ The observer effect](/explanation/the-observer-effect/)

View file

@ -0,0 +1,82 @@
---
title: Benchmark PPS jitter honestly
description: Measure the kernel's own pulse timestamps, run A/B/A, and don't let chrony's smoothing lie to you.
sidebar:
order: 3
---
Every claim on this site was produced this way. If you want to disagree with us,
disagree with these numbers.
## Don't use chrony's stats for this
`chronyc sourcestats` gives you a windowed, median-filtered, slowly-converging
estimate. That is *exactly what you want* for disciplining a clock and *exactly
what you don't want* for measuring a change you just made. It lags, it smooths,
and it will happily hide a regression for several minutes.
Measure the kernel's PPS timestamps directly instead.
## The measurement
Each PPS assert should land exactly 1.000000000 s after the last one. The
deviation from that is the jitter — nothing else.
```bash
sudo apt install pps-tools
sudo timeout 62 ppstest /dev/pps0 | awk '
/assert/ {
split($0, a, "assert "); split(a[2], b, ","); t = b[1] + 0;
if (prev > 0) {
d = (t - prev - 1.0) * 1e9;
n++; sum += d; sumsq += d*d;
if (n == 1 || d > max) max = d;
if (n == 1 || d < min) min = d;
}
prev = t
}
END {
mean = sum/n; sd = sqrt(sumsq/n - mean*mean);
printf "n=%d jitter_sd=%.0f ns p2p=%.0f ns\n", n, sd, max-min
}'
```
62 seconds gives you ~60 intervals. That's enough to see a 3× effect and not
enough to see a 5% one — size your window to the effect you're hunting.
## Always run A/B/A
This is the part people skip, and it's the part that makes the number mean
something.
A time server's behaviour drifts on the scale of minutes: the board warms up,
satellites rise and set, the DOP changes. If you measure **A then B** and B is
worse, you cannot distinguish "B is worse" from "the last five minutes were
worse."
So measure **A → B → A**:
```
round 1: feature OFF → 1304 ns
round 2: feature ON → 1912 ns
round 3: feature OFF → 1169 ns ← agrees with round 1. Now believe round 2.
```
If the two A rounds disagree with each other by more than the A-to-B difference,
**you have measured nothing** and you should go again with a longer window.
We caught our [dashboard's 36% tax](/explanation/the-observer-effect/) exactly
this way, and we *disbelieved* two other apparent wins when the bracketing rounds
refused to agree.
## Sanity check: is it even connected?
```console
$ sudo ppstest /dev/pps0
source 0 - assert 1783875773.176667184, sequence: 28
source 0 - assert 1783875774.176667944, sequence: 29
```
Sequence incrementing once a second = you have a pulse. No output = you have a
blinking LED and a wire that goes nowhere. See [Hardware](/reference/hardware/).

View file

@ -0,0 +1,111 @@
---
title: Cross-compile an RT kernel and deploy it to a headless Pi
description: Build an RPi-native aarch64 PREEMPT_RT kernel on an x86 workstation in ~40 minutes, and install it so that a failed boot doesn't cost you a trip to the SD card slot.
sidebar:
order: 2
---
Building natively on a Pi 4 takes hours. Cross-compiling on a normal workstation
takes about forty minutes. And if you've never done it, the scary part isn't the
build — it's that a bad kernel on a headless box means physically extracting the
SD card. This page addresses both.
:::tip[Or skip it]
We publish the built artifacts. See [Downloads](/reference/downloads/).
:::
## 1. Toolchain
```bash
sudo apt install crossbuild-essential-arm64 bc bison flex libssl-dev make \
libc6-dev libncurses5-dev
```
## 2. Source, matched to your running kernel
```bash
git clone --depth=1 --branch rpi-6.12.y \
https://github.com/raspberrypi/linux.git
cd linux
```
Use **Raspberry Pi's** tree, not vanilla. RPi's PREEMPT_RT is already merged in
6.12 and the board's DT/overlays live there.
## 3. Configure
```bash
export ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
make bcm2711_defconfig # Pi 4
scripts/config --enable PREEMPT_RT
scripts/config --set-str LOCALVERSION "-rt-cuckoo"
make olddefconfig
```
Now apply [the patch](/how-to/patch-pps-gpio/) — this is the whole reason you're
building a kernel rather than installing one.
:::note[Check that MMC and EXT4 are `=y`]
`bcm2711_defconfig` builds the SD card driver and filesystem *into* the image, not
as modules. That means **you don't need an initramfs** — which removes the single
most common way a hand-built Pi kernel fails to boot.
```bash
grep -E 'CONFIG_(MMC_BCM2835|EXT4_FS)=' .config # want =y, not =m
```
:::
## 4. Build
```bash
make -j$(nproc) Image modules dtbs
```
## 5. Stage the artifacts
```bash
# Note the ABSOLUTE path. `~` does not expand inside a make variable —
# INSTALL_MOD_PATH=~/out silently installs into a literal "~" directory
# and you end up shipping an 80 KB tarball that contains nothing.
make INSTALL_MOD_PATH=/home/you/out modules_install
tar -C /home/you/out -czf rt-modules.tar.gz lib/modules/
gzip -c arch/arm64/boot/Image > kernel-rt.img.gz
```
## 6. Deploy without a rescue trip
The rule: **never overwrite the kernel that currently boots.**
```bash
scp kernel-rt.img.gz rt-modules.tar.gz pi@host:/tmp/
ssh pi@host
sudo tar -C / -xzf /tmp/rt-modules.tar.gz
zcat /tmp/kernel-rt.img.gz | sudo tee /boot/firmware/kernel-rt.img > /dev/null
```
`kernel8.img` — the stock kernel — is untouched. Then add **one** line to
`/boot/firmware/config.txt`:
```ini
kernel=kernel-rt.img
```
```bash
sudo reboot
```
:::tip[The recovery path]
If it doesn't come back: pull the SD card, mount the FAT boot partition on any
machine, **delete that one line**, put it back. The stock kernel boots. That's the
whole rollback — no initramfs to regenerate, no bootloader to repair, and it works
from a Windows laptop if that's all you have.
Test the rollback *before* you need it.
:::
## 7. Confirm
```console
$ uname -a
Linux gps-ntp 6.12.x-rt-cuckoo #1 SMP PREEMPT_RT ... aarch64
```

View file

@ -0,0 +1,76 @@
---
title: Patch pps-gpio for PREEMPT_RT
description: Rebuild one kernel module in about a minute and get your PPS timestamp back into hard-IRQ context.
sidebar:
order: 1
---
**Do this if:** you run PPS from a GPIO pin on a PREEMPT_RT kernel. Which is to
say — do this if you followed any realtime-kernel time-server guide.
[Why](/explanation/preempt-rt-made-it-worse/).
You do **not** need to rebuild the whole kernel. `pps-gpio` is a module.
## 1. Confirm you have the problem
```console
$ uname -a | grep -o PREEMPT_RT
PREEMPT_RT
$ ps -eo pid,class,rtprio,psr,comm | grep irq/.*pps
239 RR 50 3 irq/41-pps@12.-1 ← the handler is a thread. That's the bug.
```
If that `ps` prints nothing, your handler is already in hard-IRQ context and you
have nothing to fix.
## 2. Patch
In your kernel source tree, `drivers/pps/clients/pps-gpio.c`, in
`get_irqf_trigger_flags()`, just before the `return`:
```c
/* The handler timestamps the pulse, so it has to run in hard-irq
* context. Under PREEMPT_RT it would otherwise be force-threaded and
* the timestamp taken after thread wakeup latency, adding microseconds
* of jitter to an edge that should be good to nanoseconds.
*/
flags |= IRQF_NO_THREAD;
return flags;
```
## 3. Build just the module
```bash
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- \
M=drivers/pps/clients modules
```
About a minute, versus ~40 for a full kernel.
## 4. Install and reboot
```bash
scp drivers/pps/clients/pps-gpio.ko pi@host:/tmp/
ssh pi@host 'sudo install -m644 /tmp/pps-gpio.ko \
/lib/modules/$(uname -r)/kernel/drivers/pps/clients/pps-gpio.ko && \
sudo depmod -a && sudo reboot'
```
:::caution[The module must match the running kernel exactly]
`vermagic` is checked at load. If you build against a different source tree than
the running kernel, `modprobe` fails and — because `pps-gpio` is what creates
`/dev/pps0` — chrony loses its refclock entirely. Build from the *same* tree that
produced the kernel you're running, or rebuild both.
:::
## 5. Verify
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/.*pps
(nothing — back in hard-irq context)
```
Then [measure it](/how-to/benchmark-pps-jitter/). You should see roughly a 3×
improvement in raw PPS jitter, and about a 10× improvement in chrony's RMS offset.

View file

@ -0,0 +1,84 @@
---
title: Survive a power cut
description: Two failures that only a cold power-cycle finds. Both of ours were silent, and one of them was our own monitoring dashboard.
sidebar:
order: 4
---
A time server's whole job is to be there. Ours had been up for days, Stratum 1,
199 ns — and it would **not have survived a power outage**. Two independent bugs,
neither visible from any amount of `systemctl status`.
Pull the plug. It's the only test that finds these.
## Failure 1: never pin the gpsd baud rate
Several guides tell you to reconfigure the GPS module to a higher baud rate and
then pin gpsd to match:
```ini
GPSD_OPTIONS="-n -s 115200" # ← don't
```
Here's what happens. Most u-blox modules hold their config in **volatile RAM**.
Cut the power and the module comes back at its factory **9600**. gpsd, pinned to
115200, opens the port, talks to a device that isn't listening, and reports
nothing. Not a degraded fix. **No GPS at all.** Your Stratum 1 server silently
becomes a Stratum 3 client of the internet — and stays that way until a human
notices.
Let gpsd auto-probe:
```ini
GPSD_OPTIONS="-n"
```
It sweeps the standard baud rates, finds the module wherever it landed, and comes
back on its own.
:::note[And the baud change bought nothing anyway]
We measured it: **1 ns** difference in PPS offset between 9600 and 115200. Which
makes sense — [the precision lives in the pulse, not the
sentences](/explanation/where-precision-lives/). NMEA at 9600 has plenty of time
to tell you *which* second it is. Faster serial changes nothing and costs you your
power-cut resilience.
:::
## Failure 2: your monitoring is load-bearing
`gpsd` ships with **socket activation**. It starts when something connects to port
2947. And chrony never connects to port 2947 — it reads gpsd's *shared memory*.
So on a fresh boot, nothing starts gpsd. No gpsd, no NMEA, no `refclock SHM 0`, no
second-numbering for the PPS pulses.
Ours *appeared* to work. It worked because **the dashboard** — our web status page
— polls gpsd on 2947, and the dashboard starts at boot. The monitoring was
socket-activating the thing it was monitoring. Stop the dashboard "to reduce load
on the clock" and you'd have stopped the clock.
```console
$ systemctl is-enabled gpsd.service
disabled ← this is the bug
```
```bash
sudo systemctl enable gpsd.service
```
Check `gpsd.service`, not `gpsd.socket`.
## What a healthy recovery looks like
With both fixed, and a few internet NTP servers left in `chrony.conf` as a
backstop, we cut the mains and watched:
| t+ | state |
|---|---|
| 0 s | power restored, boot |
| ~35 s | chrony up, **Stratum 3** — leaning on internet NTP. Serving time. |
| ~90 s | GPS fix acquired, NMEA flowing |
| **165 s** | PPS trusted, internet sources demoted, **Stratum 1** |
No human involved. That middle window — serving slightly-worse time instead of no
time — is why you keep the upstream servers configured even on a GPS clock.

View file

@ -0,0 +1,94 @@
---
title: What this is (and isn't)
description: A field report on GPS Stratum 1 timekeeping on a Raspberry Pi 4 — what the guides get wrong, and the measurements that prove it.
---
import { Aside, CardGrid, Card } from '@astrojs/starlight/components';
**This is not a build guide.**
Guides for building a GPS-disciplined Stratum 1 NTP server on a Raspberry Pi
already exist. [geerlingguy/time-pi](https://github.com/geerlingguy/time-pi) and
[josh-blake/pixie](https://github.com/josh-blake/pixie) are both good. Go read
them. Come back when your jitter is bad.
This site is what happened when we followed that advice on a **Raspberry Pi 4**
and measured everything: **most of it is wrong on this board**, one piece of it
is wrong on *every* board, and the single change that helped most was a one-line
kernel patch nobody has written down.
<Aside type="caution" title="n = 1">
Every number here comes from **one** Raspberry Pi 4 with **one** GPS module.
This is a field report, not a study. We're telling you what we measured, how we
measured it, and where we were wrong — so you can check it against your own
board rather than take our word for it. That's the whole point.
</Aside>
## The short version
<CardGrid>
<Card title="PREEMPT_RT made it 3× worse" icon="warning">
The realtime kernel — the marquee upgrade — **tripled our PPS jitter**
(2134 ns → 6947 ns). It force-threads interrupt handlers, and the PPS driver
takes its timestamp *inside* the handler. We put a scheduler between the
electrical edge and the clock.
[→ Why](/explanation/preempt-rt-made-it-worse/)
</Card>
<Card title="You cannot pin the PPS interrupt" icon="error">
On a Pi 4, GPIO interrupts are demuxed through `pinctrl-bcm2835` and refuse
an `smp_affinity`. The "isolate the PPS IRQ on its own core" advice is
**unachievable here** — and the only way to enable it is the very thing that
costs you the accuracy.
[→ Why](/explanation/the-interrupt-you-cannot-move/)
</Card>
<Card title="PTP is impossible on a Pi 4" icon="error">
`ethtool -T eth0` → `PTP Hardware Clock: none`. There is no hardware
timestamping. Software PTP is just a worse NTP. Don't chase it.
[→ Why](/explanation/no-ptp-on-a-pi-4/)
</Card>
<Card title="Your dashboard is taxing your clock" icon="rocket">
Ours cost **36% more PPS jitter** — by forking `chronyc` four times a second
onto the one core the PPS interrupt is welded to. The instrument was bending
the measurement.
[→ Why](/explanation/the-observer-effect/)
</Card>
</CardGrid>
## What actually moved the needle
Almost none of the things we expected.
| Change | RMS offset |
|---|---|
| Baseline | 823 ns |
| chrony: median `filter` + `prefer` on the PPS refclock | 440 ns |
| PREEMPT_RT (unpatched) | **2468 ns** ← *worse* |
| PREEMPT_RT + our `IRQF_NO_THREAD` patch | **199 ns** |
Baud rate, SBAS, CPU isolation, IRQ pinning: **noise, or actively harmful.**
The full numbers and methodology are in [the measurements](/reference/measurements/).
## The one artifact worth stealing
If you take nothing else from this site, take
[the kernel patch](/reference/the-patch/). Every person running GPIO-based PPS
on a PREEMPT_RT kernel is, right now, silently eating microseconds of jitter and
has no idea. It's four lines. It's upstreamable. It's the reason our RMS offset
is 199 ns instead of 2468 ns.
## Why "The Cuckoo Escapement"
The **escapement** is the part of a mechanical clock that takes continuous energy
and chops it into discrete, regular ticks. It's the single component that decides
whether a clock is precise or worthless. That is *exactly* what a PPS interrupt
handler does: it takes an electrical edge and turns it into one discrete
timestamp. Our entire finding is that all the precision in the system lives in
that one handler — and that the realtime kernel was putting a scheduler in front
of it.
We didn't fix a time server. We fixed the escapement.
The **cuckoo** is the other half. The bird's whole job is to pop out and announce
the hour; the PPS pulse's whole job is to pop out and announce the second. The
bird *is* the pulse. And, well — everything you were told about this turned out
to be a bit cuckoo.

View file

@ -0,0 +1,88 @@
---
title: Final configuration
description: The chrony, gpsd, kernel and systemd config this box actually runs.
sidebar:
order: 4
---
## chrony
```ini
# /etc/chrony/conf.d/gps.conf
# Coarse NMEA time from gpsd. Labels WHICH second it is. Never the time source.
refclock SHM 0 refid GPS precision 1e-1 offset 0.0 delay 0.2 poll 3 noselect
# Kernel PPS: the precise source. Median-filter 10 pulses, lock to GPS for
# second-numbering, prefer it as the reference.
refclock PPS /dev/pps0 refid PPS precision 1e-9 poll 2 lock GPS filter 10 prefer
allow 10.0.0.0/8
```
Plus, in `chrony.conf`:
```ini
user root # needed to read gpsd's SHM segments (mode 0600, root-owned)
```
:::note[Keep the internet servers]
Leave a few upstream NTP servers configured. While the GPS cold-acquires after a
power cut, chrony leans on them and serves *slightly less precise* time rather than
*no* time — then demotes them the instant PPS becomes trustworthy. We watched it
bridge a 165-second gap and promote itself back to Stratum 1 unattended. That
graceful degradation is worth the four lines.
:::
## gpsd
```ini
# /etc/default/gpsd
START_DAEMON="true"
USBAUTO="false"
DEVICES="/dev/ttyAMA0"
GPSD_OPTIONS="-n" # -n = poll immediately. NEVER pin the baud with -s.
```
```bash
systemctl enable gpsd.service # NOT just gpsd.socket — see below
```
:::danger[Two ways gpsd will betray you]
1. **Socket activation isn't enough.** chrony reads gpsd's *shared memory*, never
its socket — so nothing triggers the daemon to start. If it seems to work
anyway, something *else* is connecting to port 2947 and starting it for you.
For us that was the dashboard: our monitoring page was load-bearing for the
time server. Check with `systemctl is-enabled gpsd.service`.
2. **Never pin the baud.** [The module reverts to 9600 on power
loss](/how-to/survive-a-power-cut/), and a pinned gpsd then talks to a device
that isn't listening. Let it auto-probe.
:::
## Kernel cmdline
```
isolcpus=2,3 irqaffinity=0,1 nohz=off cpuidle.off=1 skew_tick=1
```
## systemd affinities
[cpu0 is sacred](/explanation/cpu0-is-sacred/) — it holds the PPS interrupt.
```ini
# chrony.service.d/affinity.conf
[Service]
CPUSchedulingPolicy=rr
CPUSchedulingPriority=20
CPUAffinity=2
# gpsd.service.d/affinity.conf
[Service]
CPUAffinity=3
# everything else (dashboard, Caddy, exporters, cron…)
[Service]
CPUAffinity=1
Nice=10
```

View file

@ -0,0 +1,72 @@
---
title: Downloads — prebuilt RT kernel
description: A patched PREEMPT_RT kernel for the Raspberry Pi 4, so you don't need a cross-compile toolchain.
sidebar:
order: 5
---
The only expensive part of [the patch](/reference/the-patch/) is the toolchain.
Building natively on a Pi 4 takes hours; cross-compiling needs an x86 box and a
setup session. So here's the artifact.
:::danger[Read this before you download]
- **Raspberry Pi 4 / arm64 only.** `bcm2711_defconfig`. It will not boot a Pi 5 or
a Pi 3.
- **Unsigned, community-built.** We built this on a workstation. There is no chain
of trust here beyond "we published the exact recipe and the checksums." If that
isn't good enough for your environment — and for some environments it correctly
isn't — [build it yourself](/how-to/cross-compile-rt-kernel/). It's forty
minutes.
- **Verify the checksums.** They're in `SHA256SUMS`.
:::
## Artifacts
Published on the [releases page](https://git.supported.systems/warehack.ing/cuckoo-escapement/releases):
| File | What |
|---|---|
| `kernel-rt-<ver>.img.gz` | The kernel image, gzipped (Pi OS's own format) |
| `rt-modules-<ver>.tar.gz` | Matching modules — **must** be installed with the image |
| `install-rt-kernel.sh` | Installer. Adds a *new* image, never replaces `kernel8.img` |
| `SHA256SUMS` | Checksums |
## Install
```bash
sha256sum -c SHA256SUMS
sudo ./install-rt-kernel.sh
sudo reboot
```
The installer:
1. Untars the modules into `/lib/modules/`
2. Writes the image as `/boot/firmware/kernel-rt.img`**`kernel8.img` is left
alone**
3. Appends one line, `kernel=kernel-rt.img`, to `config.txt`
**Rollback is deleting that one line.** Mount the SD card's FAT partition on any
machine, remove it, and the stock kernel boots. That's deliberate: you should never
have to make a physical trip to a headless box because of a kernel you got from a
website.
## What's in it
Raspberry Pi's `rpi-6.12.y` tree, `bcm2711_defconfig`, plus exactly two changes:
```bash
scripts/config --enable PREEMPT_RT
# + the IRQF_NO_THREAD patch in drivers/pps/clients/pps-gpio.c
```
Nothing else. The full recipe is in
[Cross-compile an RT kernel](/how-to/cross-compile-rt-kernel/), and you should be
able to reproduce this byte-for-byte modulo build timestamps.
:::note[Pinned to a tested version]
The published download always points at a kernel we have **actually booted and
benchmarked** on a Pi 4 — not simply the newest upstream. Shipping a stranger an
unvalidated kernel for a machine they may not be able to physically reach is not
something we're willing to do.
:::

View file

@ -0,0 +1,78 @@
---
title: Hardware
description: BerryGPS-IMU v4 / u-blox CAM-M8C, and the PPS pad that isn't on the header.
sidebar:
order: 3
---
## The board
**BerryGPS-IMU v4** (Ozzmaker) on a **Raspberry Pi 4**. The GPS is a **u-blox
CAM-M8C** — 72-channel M8 engine, concurrent GPS/GLONASS/Galileo/BeiDou, with an
onboard antenna and a uFL connector for an external one.
Reported by the module itself:
```console
$ ubxtool -p MON-VER
swVersion ROM CORE 3.01 (107888)
hwVersion 00080000 # M8 generation
extension FWVER=SPG 3.01
extension PROTVER=18.00
extension GPS;GLO;GAL;BDS
```
## The PPS pin is not on the header
This costs people hours, so: **the BerryGPS-IMU's PPS is not wired to any GPIO.**
The board's normal header connection carries power, the GPS UART (GPIO14/15), and
the IMU's I²C — but the timepulse comes out of a **separate `T_PULSE` pad**, and
you have to run a wire from it yourself.
The schematic confirms it: the CAM-M8C's TIMEPULSE pin goes through a 2N2222
buffer that drives both the on-board **PPS LED** and the `T_PULSE` pad. Nothing
routes it to the Pi.
:::caution[The blinking LED lies to you]
The PPS LED blinks once a second as soon as the module has a fix — **whether or
not the pulse is connected to anything**. It tells you the module is generating
PPS. It tells you nothing about whether your Pi can see it.
We swept every plausible GPIO with interrupt-driven edge detection and found
nothing, while the LED blinked away merrily. The signal existed; it just had
nowhere to go.
:::
We soldered a jumper from **`T_PULSE` → GPIO18** (physical pin 12), then:
```ini
# /boot/firmware/config.txt
dtoverlay=pps-gpio,gpiopin=18
```
Verify with a hardware-timestamped check, not a polling loop — a 100 ms pulse is
easy to miss by polling:
```console
$ sudo ppstest /dev/pps0
source 0 - assert 1783875773.176667184, sequence: 28
source 0 - assert 1783875774.176667944, sequence: 29 # 1.000000760 s later
```
## The UART needs freeing first
The Pi 4's *good* UART (PL011) is wired to **Bluetooth** by default; GPIO14/15 get
the flaky mini-UART whose baud drifts with the CPU clock. And a serial console may
be sitting on the port. Both must go:
```ini
# /boot/firmware/config.txt
enable_uart=1
dtoverlay=disable-bt
```
```ini
# /boot/firmware/cmdline.txt — remove this:
console=serial0,115200
```

View file

@ -0,0 +1,75 @@
---
title: The measurements
description: Every number on this site, with the methodology that produced it. Check our work.
sidebar:
order: 2
---
All numbers from **one** Raspberry Pi 4 + BerryGPS-IMU v4 (u-blox CAM-M8C), PPS on
GPIO18. n = 1. Check them against your own board.
## Headline progression
| Change | RMS offset | Root dispersion |
|---|---|---|
| Baseline (stock kernel, stock chrony) | 823 ns | 16.8 µs |
| chrony `filter 10` + `prefer` on PPS refclock | 440 ns | 5 µs |
| PREEMPT_RT, unpatched | **2468 ns***worse* | 11.6 µs |
| **PREEMPT_RT + [`IRQF_NO_THREAD`](/reference/the-patch/)** | **199 ns** | 6.3 µs |
## Raw PPS jitter (kernel-timestamped)
| Kernel | jitter (σ) | peak-to-peak |
|---|---|---|
| Stock | 2134 ns | 11 µs |
| PREEMPT_RT (threaded handler) | 6947 ns | 38 µs |
| PREEMPT_RT + patch (hard-irq handler) | 2568 ns | 18 µs |
## The dashboard's tax
A/B/A, 62 s per round. [Why this matters](/explanation/the-observer-effect/).
| | Dashboard off | Dashboard on |
|---|---|---|
| Before fix | 1304 ns | 1912 / 2179 ns (**+36%**) |
| After fix | 1437 ns | 1169 / 1450 ns (**no penalty**) |
## Things that did nothing
| Change | Result |
|---|---|
| Baud 9600 → 115200 | PPS offset **1 ns** either way. Identical. |
| SBAS disabled | No measurable change to PPS. |
| `isolcpus` alone | Inconclusive-to-harmful (concentrates load onto the PPS core). |
## Methodology
**Don't trust chrony's own stats for this.** `chronyc sourcestats` reports a
windowed, median-filtered figure that lags reality and hides what you're trying to
see. Measure the kernel's PPS timestamps directly:
```bash
sudo timeout 62 ppstest /dev/pps0 | awk '
/assert/ {
split($0, a, "assert "); split(a[2], b, ","); t = b[1] + 0;
if (prev > 0) {
d = (t - prev - 1.0) * 1e9; # deviation from exactly 1.000000000 s, in ns
n++; sum += d; sumsq += d*d;
if (d > max || n == 1) max = d;
if (d < min || n == 1) min = d;
}
prev = t
}
END {
mean = sum/n; sd = sqrt(sumsq/n - mean*mean);
printf "n=%d jitter_sd=%.0f ns p2p=%.0f ns\n", n, sd, max-min
}'
```
Each pulse should be exactly 1.000000000 s after the last. The deviation *is* the
jitter.
**Always run A/B/A**, never A/B. Clock behaviour drifts on the scale of minutes;
if you measure on-then-off you cannot tell a real effect from thermal drift or a
satellite geometry change. Go on → off → on, and require the two "on" rounds to
agree before you believe the middle one.

View file

@ -0,0 +1,65 @@
---
title: The kernel patch
description: Four lines that keep the PPS timestamp in hard-IRQ context under PREEMPT_RT. The single most valuable artifact here.
sidebar:
order: 1
---
If you take one thing from this site, take this.
```c
--- a/drivers/pps/clients/pps-gpio.c
+++ b/drivers/pps/clients/pps-gpio.c
@@ -156,6 +156,13 @@ get_irqf_trigger_flags(const struct pps_gpio_device_data *data)
IRQF_TRIGGER_FALLING : IRQF_TRIGGER_RISING);
}
+ /* The handler timestamps the pulse, so it has to run in hard-irq
+ * context. Under PREEMPT_RT it would otherwise be force-threaded and
+ * the timestamp taken after thread wakeup latency, adding microseconds
+ * of jitter to an edge that should be good to nanoseconds.
+ */
+ flags |= IRQF_NO_THREAD;
+
return flags;
}
```
## What it does
`pps-gpio` requests its interrupt with only the trigger flags — no
`IRQF_NO_THREAD`. On a stock kernel that's fine, because handlers run in hard-IRQ
context anyway. Under **PREEMPT_RT**, the kernel force-threads it, and since
[the handler is where the timestamp is taken](/explanation/preempt-rt-made-it-worse/),
the measurement moves behind the scheduler.
`IRQF_NO_THREAD` tells the kernel: *not this one*. The handler stays in hard-IRQ
context; everything else keeps RT's preemptibility.
## What it's worth
| | RMS offset | raw PPS jitter |
|---|---|---|
| PREEMPT_RT, unpatched | 2468 ns | 6947 ns |
| **PREEMPT_RT, patched** | **199 ns** | 2568 ns |
## Why you probably need it
As far as we can tell this is not applied anywhere. Which means **every person
running GPIO-based PPS on a PREEMPT_RT kernel is, right now, silently eating
microseconds of jitter** — and has no reason to suspect it, because nothing looks
broken. chrony still reports Stratum 1. The dashboard still says locked. The
number is just quietly, invisibly worse.
If that's you, this patch is free accuracy.
:::note[Upstreamable]
This belongs upstream, not in a blog post. It's a correctness fix for any
timestamping IRQ handler under RT, not a local hack. If you're a PPS maintainer
reading this: please take it.
:::
## How to apply it
See [Patch pps-gpio](/how-to/patch-pps-gpio/) — it's a module, so you can rebuild
just the one `.ko` in about a minute rather than the whole kernel.