GNSS/PPS acquisition: udev, gpsd#

Why#

The physical wiring (Hardware and wiring chapter) and the isolated kernel (previous chapters) are useless until the operating system properly exposes both streams from the GNSS receiver: the PPS signal via a dedicated kernel module, and NMEA frames via gpsd. This chapter connects hardware to software.

How#

Kernel modules#

cat > /etc/modules-load.d/pps.conf <<'EOF'
pps-gpio
pps-ldisc
EOF

modprobe pps-gpio
modprobe pps-ldisc

udev rules: stable names#

cat > /etc/udev/rules.d/10-pps.rules <<'EOF'
KERNEL=="ttyAMA0", SYMLINK+="gps0"
KERNEL=="pps0", OWNER="root", GROUP="tty", MODE="0660", SYMLINK+="gpspps0"
EOF

udevadm control --reload-rules
udevadm trigger

The stable symlink (SYMLINK+=) removes any dependency on device enumeration order at boot (ttyAMA0/pps0 could in theory get renamed). On the pps0 line, OWNER="root", GROUP="tty", MODE="0660" applies the principle of least privilege to the device node: read/write restricted to the owner and the tty group (the historical group for serial devices on Linux), no access for the rest of the system.

Low latency on the serial port#

cat > /etc/systemd/system/setserial.service <<'EOF'
[Unit]
Description=Low-latency UART for GNSS NMEA
After=systemd-modules-load.service

[Service]
Type=oneshot
ExecStart=/bin/setserial /dev/ttyAMA0 low_latency
RemainAfterExit=true

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now setserial.service

Without this setting, the kernel’s serial driver batches several received characters before waking up waiting processes (a delay on the order of a few milliseconds, meant for efficiency on a typical terminal workload). The low_latency flag disables this timed batching and delivers each received character immediately, which stabilizes NMEA frame reception latency. The offset 0.125 compensation applied on the chrony side (Chrony chapter) assumes a constant delay, and this setting helps guarantee that.

Configuring gpsd#

cat > /etc/default/gpsd <<'EOF'
DEVICES="/dev/gps0"
GPSD_OPTIONS="-n"
USBAUTO="false"
EOF

systemctl enable --now gpsd

-n starts collection immediately, without waiting for a client to connect, needed since chronyd must have data available right from boot.

Confining gpsd to Core 0#

gpsd only handles a low-throughput serial stream (NMEA): it needs neither real-time isolation nor a dedicated core, only protection from being preempted by lower-priority general-purpose tasks.

mkdir -p /etc/systemd/system/gpsd.service.d/
cat > /etc/systemd/system/gpsd.service.d/override.conf <<'EOF'
[Service]
# Confined to Core 0 (general pool, see the Firmware chapter)
CPUAffinity=0
# High priority: preempts noisier services (Nice >= 0) without touching RT
Nice=-10
EOF

systemctl daemon-reload
systemctl restart gpsd

Pitfalls to avoid#

The Raspberry Pi 5 exposes several /dev/ppsN devices, only one is the right one. On this hardware, one typically finds pps0 (the GNSS receiver’s GPIO), pps1 (the network controller’s hardware clock, used by phc2sys, see the PHC chapter), and pps2 (automatically created by gpsd on the serial port’s DCD line, generally inactive if the receiver doesn’t emit PPS on that pin). Targeting the wrong device in chrony’s configuration produces no visible error, just a total absence of precise synchronization. Always check before locking in the configuration:

for p in /sys/class/pps/pps*; do
    printf "%-20s name=%s\n" "$p" "$(cat $p/name)"
done
  • Without an antenna connected, the receiver still emits data, invalid. NMEA frames keep arriving, with empty fields and a V (Void) status, sourced from the receiver’s internal oscillator, not UTC-traceable. Receiving frames should not be confused with having a valid fix: that’s exactly the role of chrony’s lock GPS directive (Chrony chapter), which makes that distinction automatically.
  • Forgetting USBAUTO="false". If gpsd attempts automatic USB device detection alongside the explicit serial device, it can introduce a startup delay or conflicts with other USB devices that might be plugged in.

Verify#

# Raw NMEA frames (no fix: empty fields, V status, normal in the first
# few seconds/minutes after boot)
timeout 5 cat /dev/gps0

# PPS pulses, should show 1 line per second
ppstest /dev/pps0

# Interactive gpsd summary (quit with 'q')
cgps -s

# Effective confinement to Core 0
ps -eLo psr,ni,comm | grep gpsd

Expected ppstest output once a fix is acquired:

trying PPS source "/dev/pps0"
found PPS source "/dev/pps0"
ok, found 1 source(s), now start fetching data...
source 0 - assert 1778011166.000219296, sequence: 186 - clear  0.000000000
source 0 - assert 1778011167.000218233, sequence: 187 - clear  0.000000000

The assert field is the UTC timestamp (Unix epoch + nanoseconds) of each pulse’s rising edge, the gap between consecutive lines should be extremely close to exactly 1.000000000 second.

Acquiring the first GNSS fix after a cold start typically takes anywhere from tens of seconds to a few minutes, the time needed for the receiver to download ephemeris data for visible satellites.


📚 Going further

The pps-gpio kernel module attaches a handler to a GPIO pin and exposes the result to userspace via /dev/ppsN. On every rising edge, the interrupt is handled and a timestamp is recorded in an internal circular buffer, readable via the PPS_FETCH ioctl system call (Linux kernel documentation, PPS subsystem).

The latency between the GPIO’s physical edge and the kernel-side timestamp recording is fundamentally bounded by kernel interrupt latency: on the order of 30-150 µs on a standard kernel, versus 5-30 µs on a properly configured PREEMPT_RT kernel (see the Real-time kernel chapter), this 5-10x factor is what justifies this entire guide’s real-time configuration effort.