# The spare cores in a BeagleBone keep better time than its kernel

> The standard Linux PPS driver timestamps the GPS pulse in an interrupt handler and pays ~20 µs of jitter for it. The BeagleBone has two 200 MHz real-time cores that Linux never touches. I moved the timestamp into one of those, and the clock offset dropped into the low nanoseconds.

- Author: JR (@dniminenn)
- Published: 2026-03-02
- Category: Precision timing
- Tags: gps, pps, beaglebone, pru, timing
- Canonical: https://dnim.dev/blog/bbb-pru-pps-timestamping

---

A GPS-disciplined clock depends on one moment: the instant you record that the pulse-per-second edge arrived. The servo, the freq correction, the offset chrony reports all build on that timestamp. If it jitters, the clock jitters.

On Linux the usual path is the `pps-gpio` driver. Wire PPS to a GPIO pin, and the kernel timestamps the rising edge in an IRQ handler. It works fine for plenty of uses. But the timestamp lands after the interrupt is dispatched and the handler scheduled, so it's at the scheduler's mercy. Under normal load you see ~20 µs of dispersion, with outliers past 10 µs on top. That's the noise floor of timestamping an edge through the Linux interrupt subsystem, and you can't average your way out of jitter that large.

The BeagleBone Black has a way out, unused on the same chip.

> **Note: Update**
>
> This describes the design as it stood in March 2026. The daemon's clock-domain crossing here turned out to have a feedback-loop flaw that only shows up under load; the follow-up, [The clock that rang like a bell under load](/blog/bbb-pru-pps-feedback-loop), covers what replaced it. The capture and calibration technique below is still worth reading, it's just not what the box runs today.

## Two CPUs with no operating system

The AM335x on a BeagleBone has a PRU-ICSS: two Programmable Real-Time Units, 200 MHz cores that run independently of the ARM and the Linux kernel. No OS, no IRQs in the normal sense, one deterministic instruction per 5 ns cycle. You load firmware from userspace via the `remoteproc` framework and they run.

That's what edge timestamping needs. A PRU polling a pin in a tight loop sees the edge with nothing scheduled between the transition and the timestamp. The AM335x also gives it a clock to stamp with: the IEP (Industrial Ethernet Peripheral) has a free-running counter at 200 MHz, one tick every 5 ns.

The plan: stop asking Linux to time the edge. PRU0 watches the PPS pin, latches the IEP counter the instant it goes high, and drops the value in shared memory for a userspace daemon to collect.

```c title="firmware/main.c (the capture loop)"
uint32_t prev = __R31 & PPS_BIT;     // P8_16 = pr1_pru0_pru_r31_14
for (;;) {
    uint32_t cur = __R31 & PPS_BIT;
    if (cur && !prev) {
        // Rising edge. Latch the 200 MHz IEP counter right here,
        // in the PRU, with nothing scheduled between edge and read.
        pps_data.iep_lo = IEP_COUNT_LO;
        pps_data.seq++;
    }
    prev = cur;
    // ... service rpmsg, etc.
}
```

The PRU writes a tiny struct, `{ seq, iep_lo }`, into its data RAM. `seq` increments on every pulse so the reader can tell a fresh sample from a stale one. `iep_lo` is the raw IEP tick count at the edge. It isn't wall-clock time. The PRU only knows its own free-running counter. Bridging that gap is the actual work.

## The hard part is the clock-domain crossing

The PRU produces timestamps in IEP ticks. Chrony wants `CLOCK_REALTIME` nanoseconds. Two clocks at slightly different, drifting rates, and the translation can't smear the nanosecond precision you just earned.

A userspace daemon, `pru_pps_shm`, does the crossing. It runs `SCHED_FIFO` so the scheduler can't sit on it, reads the PRU's struct out of DRAM via an `mmap` of `/dev/mem`, and answers one question: at the moment the PPS edge happened (a known IEP tick), what was `CLOCK_REALTIME`?

To correlate the two clocks it brackets a reading of the IEP counter between two wall-clock reads, ten times over, keeping the tightest bracket:

```c title="daemon/pru_pps_shm.c (IEP to wall calibration)"
long long best_spread = 999999999LL;
for (int i = 0; i < 10; i++) {
    struct timespec t1, t2;
    clock_gettime(CLOCK_REALTIME, &t1);
    uint32_t c = read_iep_counter();          // sample IEP between two wall reads
    clock_gettime(CLOCK_REALTIME, &t2);
    long long spread = ns2 - ns1;             // how tight is this bracket?
    if (spread < best_spread) {
        best_spread = spread;
        best_cal_iep  = c;
        best_cal_wall = ns1 + spread / 2;     // midpoint of the tightest bracket
    }
}
```

The bracket pins one IEP value to one wall-clock value, and `spread` is the uncertainty of that pin: the wall clock could have been anywhere in that window when the IEP was sampled, so the midpoint is the best estimate and the window width is the error. Best-of-ten keeps the bracket where an interrupt or cache miss didn't stretch the window. On the RT kernel that spread stays under 2 µs, typically around 1.3.

The second piece is the tick rate. Nominally the IEP runs at 200 MHz, 5.0 ns per tick. It doesn't, quite, and it drifts with temperature. The daemon measures the real period and smooths it with an IIR filter, which tracks thermal drift without chasing noise:

```c
// Filtered IEP tick period in ns. Nominal 5.0; reality is a hair under,
// and it moves with temperature.
ns_per_tick = ns_per_tick * 0.9 + measured * 0.1;
```

With a calibrated `(iep, wall)` pin and a filtered `ns_per_tick`, projecting the PPS edge into wall time is arithmetic: take the calibration point and walk back by the number of IEP ticks between the edge and the calibration sample, times the ns per tick. That projected `CLOCK_REALTIME` instant goes into chrony's NTP shared-memory refclock, unit 2, which disciplines the system clock.

> **Insight: Capture cheap, correlate carefully**
>
> The PRU capture is the easy half: hardware timestamping at 5 ns resolution. It's worthless without the boring half. Cross from IEP ticks to wall time with a single sloppy `clock_gettime`, or assume the counter runs at exactly 200 MHz, and you've reintroduced µs of error. The weakest link sets the precision, and that's the clock-domain crossing, not the capture.

## What the daemon tells you

Every pulse, the daemon logs a line with the whole health of the chain:

```text
seq=202 delta=200011758 offset=+634 ns gap=38925 (194.6 us) spread=1291 ns ns/tick=4.999707 [good=201]
```

`delta` is the IEP ticks between consecutive pulses; it should sit near 200 million (one second at 200 MHz), and 200011758 says the IEP's running a touch fast, which is exactly why `ns/tick` settles at 4.999707 rather than 5.0. `offset` is the sub-second residual of the edge against the UTC second. `gap` is how long after the edge the calibration ran, kept under 250 µs. `spread` is the calibration bracket width, here 1291 ns. `good` counts accepted pulses; the matching `bad` counter should stay at zero.

## The result

| Metric | Value | Note |
| --- | --- | --- |
| Typical offset | 100-800 ns (was 5-20 us (pps-gpio)) | PRU capture vs GPIO IRQ |
| Edge resolution | 5 ns (was ~1 us) | IEP tick vs GPIO IRQ jitter |
| Chrony offset sd | 1.0e-9 | estimated, after the servo converges |

Once chrony's servo converges, `chronyc tracking` puts the system time offset in the low nanoseconds, often inside ±10 ns. The sourcestats view shows the estimated sd settling at `1.0e-09`, one nanosecond, against the 5 to 20 µs that `pps-gpio` routinely shows. A tracking log excerpt after it's settled:

```text
   Date (UTC) Time     IP Address   St   Freq ppm   Offset       Offset sd
2026-03-02 15:59:48 PPS              1     58.491   1.172e-14    1.716e-17
2026-03-02 15:59:50 PPS              1     58.491   2.141e-15    8.552e-18
2026-03-02 15:59:52 PPS              1     58.491   4.945e-16    8.621e-18
```

One footnote: the IEP can also be exposed as a PTP hardware clock, which would let `linuxptp` use it directly. This project doesn't take that path here. It reads the IEP counter straight out of `/dev/mem` and does the correlation itself: a tight loop on a core with no OS, a careful bracket to cross into wall time, and a kernel kept clear of the one step that has to happen on time.

> **Note: That footnote didn't age well**
>
> This project does take that path now. See the update at the top.

The code, firmware and daemon and device-tree overlays, is on [GitHub](https://github.com/dniminenn/bbb-pps-pru).
