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:
commit
6881489bf6
56 changed files with 10297 additions and 0 deletions
51
docs-site/src/content/docs/explanation/cpu0-is-sacred.md
Normal file
51
docs-site/src/content/docs/explanation/cpu0-is-sacred.md
Normal 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 cpu1–3.
|
||||
|
||||
```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.
|
||||
:::
|
||||
52
docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md
Normal file
52
docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md
Normal 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/).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
50
docs-site/src/content/docs/findings.md
Normal file
50
docs-site/src/content/docs/findings.md
Normal 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/)
|
||||
82
docs-site/src/content/docs/how-to/benchmark-pps-jitter.md
Normal file
82
docs-site/src/content/docs/how-to/benchmark-pps-jitter.md
Normal 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/).
|
||||
111
docs-site/src/content/docs/how-to/cross-compile-rt-kernel.md
Normal file
111
docs-site/src/content/docs/how-to/cross-compile-rt-kernel.md
Normal 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
|
||||
```
|
||||
76
docs-site/src/content/docs/how-to/patch-pps-gpio.md
Normal file
76
docs-site/src/content/docs/how-to/patch-pps-gpio.md
Normal 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.
|
||||
84
docs-site/src/content/docs/how-to/survive-a-power-cut.md
Normal file
84
docs-site/src/content/docs/how-to/survive-a-power-cut.md
Normal 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.
|
||||
94
docs-site/src/content/docs/index.mdx
Normal file
94
docs-site/src/content/docs/index.mdx
Normal 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.
|
||||
88
docs-site/src/content/docs/reference/configuration.md
Normal file
88
docs-site/src/content/docs/reference/configuration.md
Normal 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
|
||||
```
|
||||
72
docs-site/src/content/docs/reference/downloads.md
Normal file
72
docs-site/src/content/docs/reference/downloads.md
Normal 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.
|
||||
:::
|
||||
78
docs-site/src/content/docs/reference/hardware.md
Normal file
78
docs-site/src/content/docs/reference/hardware.md
Normal 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
|
||||
```
|
||||
75
docs-site/src/content/docs/reference/measurements.md
Normal file
75
docs-site/src/content/docs/reference/measurements.md
Normal 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.
|
||||
65
docs-site/src/content/docs/reference/the-patch.md
Normal file
65
docs-site/src/content/docs/reference/the-patch.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue