Back to Home

Stratum 1 NTP on Raspberry Pi with chrony and GPS

The article describes creating an autonomous Stratum 1 NTP server on Raspberry Pi using chrony, libgpiod v2, DCF77 and GPS NEO-M8T. Accuracy of 42 ns is achieved. Migration issues from ntpd and GPIO API are addressed.

Precise NTP Stratum 1 on RPi: 42 ns with GPS PPS
Advertisement 728x90

Standalone Stratum 1 NTP Server on Raspberry Pi: chrony with DCF77 and GPS PPS in 2026

On Raspberry Pi OS Bookworm/Trixie, old NTP server tutorials fail due to libgpiod v2, the switch to chrony, and changes in GPIO chips. A C++20 implementation uses a DCF77 receiver RC8000 on GPIO 17 and a ublox NEO-M8T GPS module with PPS on GPIO 18. The server achieves <1 µs accuracy without internet, with independent time source verification.

Issues with Outdated Implementations

libgpiod v2 Breaks GPIO Code

libgpiod v2 removed the v1 API without compatibility. Old code with gpiod_chip_open_by_name and gpiod_chip_get_line does not compile:

// libgpiod v1 — DOES NOT COMPILE with v2
struct gpiod_chip *chip = gpiod_chip_open_by_name("gpiochip0");
struct gpiod_line *line = gpiod_chip_get_line(chip, 17);
gpiod_line_request_both_edges_events(line, "dcf77");

The new API requires gpiod_line_settings_new() and gpiod_line_config_new():

Google AdInline article slot
// libgpiod v2 — correct approach
const SettingsPtr settings(gpiod_line_settings_new());
gpiod_line_settings_set_edge_detection(settings.get(), GPIOD_LINE_EDGE_BOTH);
gpiod_line_settings_set_event_clock(settings.get(), GPIOD_LINE_CLOCK_REALTIME);

const LineCfgPtr line_cfg(gpiod_line_config_new());
gpiod_line_config_add_line_settings(line_cfg.get(), &offset, 1, settings.get());

const RequestPtr request(gpiod_chip_request_lines(chip.get(), req_cfg.get(), line_cfg.get()));

A key advantage is GPIOD_LINE_CLOCK_REALTIME: events receive timestamps from the kernel at interrupt time, reducing jitter from 40 ms to <5 ms.

Transition from ntpd to chrony

ntpd has been replaced by chrony in Bookworm. Comparison:

| Parameter | ntpd | chrony |

Google AdInline article slot

|-----------|------|--------|

| Convergence | minutes | seconds (makestep 0.5 3) |

| Signal loss | degradation | holdover (local stratum 10) |

Google AdInline article slot

| PPS | patches | native refclock PPS |

| PPS accuracy | ~100 µs | <1 µs |

Both support NTP SHM for data exchange via shared memory.

GPIO Chip on RPi 5

gpiochip0 is outdated. On RPi 5/Trixie, use gpiochip4 (check with gpiodetect).

System Architecture

Two time sources:

  • GPS NEO-M8T: PPS (/dev/pps0) + NMEA via gpsd (NTP SHM #0)
  • DCF77 RC8000: GPIO 17 → decoder → NTP SHM #2
  • chrony: stratum 1 with prefer PPS
  • Web monitor: chronyc + journald on :8080

chrony config:

refclock PPS /dev/pps0 refid PPS precision 1e-9 poll 0 dpoll -2 lock NMEA prefer
refclock SHM 0 refid NMEA precision 1e-3 poll 0 dpoll -2 offset 0.0 delay 0.2 noselect
refclock SHM 2 refid DCF7 precision 1e-2 offset 0.0 delay 0.008 poll 6 dpoll 5
local stratum 10

DCF77 serves as a fallback and GPS spoofing detector (discrepancy >several seconds).

DCF77 Decoder

Kernel Timestamp

GPIOD_LINE_CLOCK_REALTIME provides an interrupt timestamp, not userspace. StdDev in SHM: <5 ms.

NTP SHM for 64-bit

Structure with padding:

struct NtpShmTime {
    int32_t mode;
    int32_t count;
    int64_t clockTimeStampSec;
    int32_t clockTimeStampUSec;
    int32_t _pad;
    int64_t receiveTimeStampSec;
    int32_t receiveTimeStampUSec;
    int32_t leap;
    int32_t precision;
    int32_t nsamples;
    int32_t valid;
};
volatile NtpShmTime *shm_ = nullptr;

DCF77 Protocol

59 bits/minute in BCD with parity:

  • 100 ms HIGH → bit 0
  • 200 ms HIGH → bit 1
  • Pause >1.5 s → minute marker

Validation: 3 parity groups + timegm(). Bootstrap: 2 frames with a 60 s difference.

NEO-M8T Configuration

  • TIM firmware: RAIM, low PPS jitter
  • dynModel=Stationary: jitter 5–10 ns
  • antCableDelay=50 ns: cable compensation
  • 4 constellations: 20–30 satellites
  • lpMode=Continuous: no power saving

Web Monitor and Deployment

dcf77_web: HTTP :8080, chronyc in JSON, UI in binary. API: /api/status, /api/prefer (localhost only).

Build with vcpkg + CMake:

cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)

Ansible deployment with systemd hardening.

Results

chronyc tracking:

  • Stratum: 1
  • System time: +42 ns
  • RMS offset: 51 ns
  • Root dispersion: 15 ns

DCF77: ~3 ms.

Key Takeaways

  • libgpiod v2 requires a new object model with GPIOD_LINE_CLOCK_REALTIME
  • chrony outperforms ntpd in PPS accuracy (<1 µs) and holdover
  • NTP SHM for 64-bit needs explicit padding for int64_t
  • NEO-M8T TIM + Stationary: PPS jitter 5–10 ns
  • DCF77 validates GPS against spoofing

— Editorial Team

Advertisement 728x90

Read Next