Chapter 107: GPS / GNSS + PPS¶
What: GNSS receivers (GPS + GLONASS + BeiDou + Galileo) and the PPS (Pulse-Per-Second) time-discipline signal. We compare u-blox NEO-6M (legacy, GPS-only), NEO-8M (multi-constellation), NEO-9M (concurrent multi-band, lower power, GNSS RAW data), and the cheap ATGM336H (BeiDou+GPS+GLONASS). On Linux, we parse NMEA-0183, decode u-blox’s binary UBX protocol, bring up gpsd as the central daemon, and wire the PPS GPIO to chrony for sub-microsecond NTP, turning a $5 receiver into a stratum-1 time server. MCU bridge: Think of Linux GPIO like the same pin set/reset block you used on STM32, but accessed through a kernel subsystem that owns numbering, direction, interrupts, and user-space exposure. GPIO: General-Purpose Input/Output, a pin controlled as a digital input, output, or interrupt source.
Why: GPS receivers do two things, both critical for embedded products:
Position for asset tracking, geo-fencing, fleet management, anti-theft.
Time, GPS atomic-clock-derived time, sub-µs precise, traceable to UTC. Telco basestations, financial exchanges, distributed databases (Spanner, CockroachDB), and any time-sensitive logging system uses GPS-disciplined clocks. A $5 chip + $20 antenna = stratum-1 NTP, no internet required.
The same PPS technique works for any time-domain measurement on Linux: synchronised audio between boards, distributed instruments, IP-connected oscilloscopes.
Focus: NMEA reports the wall-clock second, but it arrives 50–500 ms after the actual second. PPS is the nanosecond-accurate edge. A naïve “set the clock from
$GPRMC” gets you to ±100 ms. With PPS, the kernel timestamps each GPIO edge using the hardware clock. Chrony combines two streams: NMEA, which is slow but tells you which second this is. PPS, which is fast but does not name the second. Together they reach ±100 ns. The PPS path is the key: GPS pin → kernelpps_gpiodriver →/dev/pps0→ chrony refclock. Get this right and you have sub-microsecond GPS time. Skip the PPS and you have NMEA-only ±100 ms.Tooling. This chapter uses
gpsd+gpsd-clients(gpspipe,cgps,gpsmon),chrony,pps-tools(ppstest).
Ubuntu-base (target):
apt install gpsd gpsd-clients chrony pps-toolsBuildroot:
BR2_PACKAGE_GPSD=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_PPS_TOOLS=yBuildroot: a configuration-driven build system that produces a complete root filesystem and related images.Full per-tool reference: Userspace tooling appendix. MCU bridge: Think of the rootfs as the firmware image’s file-backed runtime environment. On an MCU you link everything into flash. On Linux, programs and config live in this mounted tree. rootfs: root filesystem, the directory tree mounted at / that contains /bin, /etc, /dev, and libraries.
107.1 GNSS module comparison¶
u-blox NEO-6M |
NEO-8M |
NEO-9M |
ATGM336H |
|
|---|---|---|---|---|
Year |
2011 |
2014 |
2019 |
2017 |
Constellations |
GPS |
GPS + GLONASS + Galileo + BeiDou + QZSS |
concurrent dual-band L1/L5 |
BeiDou + GPS + GLONASS |
Channels |
50 |
72 |
184 |
33 |
Position accuracy |
~2.5 m CEP |
~2.0 m |
~1.5 m (multi-band 0.5 m) |
~2.5 m |
Cold-start time |
~27 s |
~26 s |
~24 s |
~32 s |
Hot-start time |
< 1 s |
< 1 s |
< 2 s |
~1 s |
Sensitivity (acq) |
–148 dBm |
–148 dBm |
–148 dBm |
–148 dBm |
Current (acq) |
67 mA |
67 mA |
~35 mA |
30 mA |
Current (tracking) |
50 mA |
50 mA |
25 mA |
25 mA |
PPS output |
yes |
yes |
yes |
yes |
PPS jitter |
~30 ns |
~30 ns |
~20 ns |
~50 ns |
UART default |
9600 8N1 |
9600 8N1 |
38400 8N1 |
9600 8N1 |
Native protocol |
NMEA + UBX |
NMEA + UBX |
NMEA + UBX + RAW measurements |
NMEA only |
Cost (module + antenna) |
$8–15 |
$10–18 |
$40–60 |
$5–8 |
Pick guide:
NEO-8M: the common choice for most projects. Multi-constellation = better urban coverage, faster TTFF (time to first fix). Well-documented + UBX binary.
NEO-9M: when you need < 1 m accuracy, RAW pseudorange data (RTK-able with a base station), or lowest power.
ATGM336H: when BOM matters more than UBX support. Cheap, NMEA-only.
107.2 NMEA-0183, the universal text protocol¶
NMEA is a 1980s-era ASCII protocol designed for marine instruments. Every GNSS receiver speaks it. Sentences look like:
$GPRMC,123519.00,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
↑ ↑↑ ↑ ↑ ↑ ↑ ↑
UTC time lat (DDMM.mmm)lon spd hdg date magvar
fix valid knots deg ddmmyy
Sentence |
Content |
|---|---|
|
recommended minimum: time, lat/lon, speed, heading, date |
|
fix data with altitude, # satellites, HDOP |
|
active satellites + DOPs (PDOP, HDOP, VDOP) |
|
satellites in view (multiple sentences if > 4) |
|
velocity over ground |
|
lat/lon only |
|
|
The checksum is XOR of all bytes between $ and *, in hex. Parsers should always verify it. Corrupt UART bytes (no flow control on most modules) flip bits silently.
NMEA’s three weaknesses for time sync:
Latent: sentence is generated some time after the second. Transmission at 9600 baud takes ~70 ms.
Inconsistent: different modules emit GPRMC at different points in the second.
Verbose: parsing ASCII costs CPU you don’t need.
Hence PPS for sub-second timing.
107.3 UBX, u-blox binary protocol¶
Privilege boundary: $ means normal user. # or sudo means root and can change host or target state. After a privileged command, verify the expected device, service, or file appears before continuing. Roll back by undoing the config change or stopping the service you just enabled.
u-blox’s native protocol is binary and labels each message with the exact GPS time the position is valid for. Frame:
[µB] [class] [id] [len_lo] [len_hi] [payload...] [ck_a] [ck_b]
0xB5 0x62
Classes:
0x01NAV (navigation results, POSLLH, VELNED, PVT, STATUS)0x02RXM (raw measurements, RAWX for RTK)0x05ACK (ack/nack from CFG operations)0x06CFG (configuration, port baud, message rates, GNSS selection)0x0AMON (monitor, HW status, jamming, RF antenna)
The single most useful message: UBX-NAV-PVT (Position, Velocity, Time, class 0x01, id 0x07, 92 bytes). One frame per fix, contains the iTOW (integer time of week, ms), year/month/day/hour/min/sec, lat/lon/h, velocity, accuracy estimates, fix type, # satellites. Replaces 5+ NMEA sentences.
The setup command to enable NAV-PVT at 1 Hz only:
B5 62 06 01 08 00 01 07 00 01 00 00 00 00 18 E1
↑ CFG-MSG ↑ NAV-PVT, rate=1 on UART
Switching to UBX-only at startup reduces UART traffic 5× and gives you nanosecond-precise per-message timing. Use u-center on Windows or ubxtool from gpsd-clients to build these CFG messages.
107.4 PPS, the sub-microsecond signal¶
The PPS pin pulses high for ~100 ms exactly on the UTC second boundary. The receiver synchronizes its 1 kHz timepulse generator to its GNSS-derived clock. Jitter is ~20–50 ns.
GNSS receiver
│ TX (NMEA / UBX) ────────► /dev/ttymxc3
│ PPS ────────► GPIO5_3 → kernel pps_gpio driver → /dev/pps0
└─────────────────────────► chrony combines them
Linux kernel side: drivers/pps/. The pps_gpio driver registers an IRQ on the GPIO, and on each edge records a hardware timestamp (ktime_get_ts()) plus the GPIO event time. User-space (chrony) reads /dev/pps0 ioctl-style to get the latest edge timestamp and computes the offset between GPIO-edge-time and system-clock-time.
MCU bridge: Think of an IRQ like an EXTI/NVIC interrupt path, except Linux splits the hard interrupt from deferred work and must share lines across drivers. IRQ: interrupt request, the signal path that tells the CPU or interrupt controller that hardware needs service.
DT binding:
pps {
compatible = "pps-gpio";
gpios = <&gpio5 3 GPIO_ACTIVE_HIGH>;
assert-falling-edge; /* or omit for rising-edge */
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_pps>;
};
After dmesg | grep pps:
pps_core: LinuxPPS API ver. 1 registered
pps_core: Software ver. 5.3.6 - Copyright 2005-2007 Rodolfo Giometti
pps pps0: new PPS source pps.-1
pps pps0: Registered IRQ 67 as PPS source
/dev/pps0 exists. The tool to dump edges:
ppstest /dev/pps0
# trying PPS source "/dev/pps0"
# source 0 - assert 1709236745.000001234, sequence: 1234
# source 0 - assert 1709236746.000001456, sequence: 1235
Each pulse’s timestamp is captured with getnstimeofday() precision, typically sub-microsecond on a Cortex-A7 with hardware IRQ.
107.5 gpsd, the central daemon¶
gpsd opens the GPS receiver UART, parses NMEA/UBX, and exposes the parsed fix via a JSON socket protocol on localhost:2947. Applications (your code, chrony, navit, foxtrotgps, qmapshack) talk to gpsd, not the receiver directly.
apt install gpsd gpsd-clients
# /etc/default/gpsd
DEVICES="/dev/ttymxc3 /dev/pps0"
GPSD_OPTIONS="-n" # don't wait for client; always read
systemctl restart gpsd
# Inspect live fix
gpsmon
# Or in JSON
gpspipe -w
# {"class":"TPV","time":"2026-05-31T12:34:56.000Z","lat":52.3,"lon":13.4,...}
# Status check
cgps -s
For Python:
import gps
session = gps.gps(mode=gps.WATCH_ENABLE)
for report in session:
if report['class'] == 'TPV':
print(report.lat, report.lon, report.time)
107.6 Chrony + PPS, the stratum-1 NTP server¶
Once gpsd is up and /dev/pps0 is alive, chrony combines NMEA (gives the second number) + PPS (gives the precise edge) to discipline the system clock.
apt install chrony
# /etc/chrony/chrony.conf — append:
refclock SHM 0 refid GPS poll 4 noselect # gpsd's SHM segment (low precision, just for the second)
refclock PPS /dev/pps0 refid PPS lock GPS prefer trust # PPS (high precision)
systemctl restart chrony
# Verify
chronyc sources -v
# MS Name/IP address Stratum Poll Reach LastRx Last sample
# #? GPS 0 4 377 13 -45ms[ -45ms] +/- 100ms
# #* PPS 0 4 377 14 -100ns[ -100ns] +/- 200ns
The * next to PPS means it’s the chosen reference. The offset is < 200 ns. The system clock is now disciplined to GPS time at sub-microsecond accuracy. date +%N.%9N shows nanosecond-precise time.
To serve NTP to your LAN:
# chrony.conf
allow 192.168.0.0/24
# Now other Linux/Windows boxes can ntpd-sync from this i.MX6ULL
# On a client:
chronyc sources
# ^* mygpsbox.local 1 6 377 25 +12µs[ +15µs] +/- 410µs
Total parts cost is about $20. The result is a stratum-1 NTP server.
107.7 From scratch, UBX parser in C¶
Use NMEA for debugging, UBX for production. The skeleton parser:
/* ubx_parse.c — fragment */
struct ubx_nav_pvt {
uint32_t iTOW;
uint16_t year;
uint8_t month, day, hour, min, sec;
uint8_t valid;
uint32_t tAcc;
int32_t nano;
uint8_t fixType;
/* ... lat, lon, height, vel, etc ... */
};
static int read_ubx_frame(int fd, uint8_t *cls, uint8_t *id,
uint8_t *payload, int *plen) {
uint8_t buf;
/* Wait for 0xB5 0x62 */
do { if (read(fd, &buf, 1) != 1) return -1; } while (buf != 0xB5);
read(fd, &buf, 1);
if (buf != 0x62) return -2;
read(fd, cls, 1);
read(fd, id, 1);
uint8_t len_lo, len_hi;
read(fd, &len_lo, 1); read(fd, &len_hi, 1);
int len = len_lo | (len_hi << 8);
if (len > 200) return -3;
for (int i = 0; i < len; ) {
int n = read(fd, &payload[i], len - i);
if (n <= 0) return -4;
i += n;
}
/* Read + verify Fletcher checksum */
uint8_t ck_a = 0, ck_b = 0;
ck_a += *cls; ck_b += ck_a;
ck_a += *id; ck_b += ck_a;
ck_a += len_lo; ck_b += ck_a;
ck_a += len_hi; ck_b += ck_a;
for (int i = 0; i < len; i++) { ck_a += payload[i]; ck_b += ck_a; }
uint8_t rx_a, rx_b;
read(fd, &rx_a, 1); read(fd, &rx_b, 1);
if (ck_a != rx_a || ck_b != rx_b) return -5;
*plen = len;
return 0;
}
int main(void) {
int fd = open("/dev/ttymxc3", O_RDWR | O_NOCTTY);
/* configure 38400 8N1 ... */
for (;;) {
uint8_t cls, id, payload[200]; int len;
if (read_ubx_frame(fd, &cls, &id, payload, &len) == 0
&& cls == 0x01 && id == 0x07) {
struct ubx_nav_pvt *p = (void *)payload;
printf("%04d-%02d-%02dT%02d:%02d:%02d.%09d fix=%d\n",
p->year, p->month, p->day, p->hour, p->min, p->sec,
p->nano, p->fixType);
}
}
}
Run this. You’ll see each fix printed with the exact GPS-derived UTC time it was valid for. Compare with the PPS-disciplined system clock to verify they agree.
107.8 Lab¶
Antenna + first fix. Wire the module’s UART. Place the antenna with sky view. Launch
cat /dev/ttymxc3. Watch NMEA stream. Wait for$GPGGAwith non-zero “fix quality”, TTFF should be < 60 s outdoors.gpsd up. Configure gpsd. Run
cgps -sto see live position. Indoors near a window may work for u-blox. ATGM336H usually won’t.PPS wired. Add the DT pps-gpio node. Reboot. Verify
/dev/pps0. Runppstest /dev/pps0. Each pulse should print one second later.chrony stratum-1. Configure refclock SHM+PPS. Restart chrony.
chronyc sourcesshould show PPS selected.date +%N.%9Nshould show stable sub-µs precision.UBX binary mode. Use
ubxtool -p MON-VERto verify u-blox. Thenubxtool -e UBX -d NMEAto disable NMEA + enable UBX. Verify withubxtool -p NAV-PVT.NTP client benchmark. From another Linux box,
chronyc -h <gpsbox> sourcesshould show your box at stratum 1, offset < 1 µs.Cold-start time. Power-cycle the module. Measure TTFF outdoors vs indoors-by-window. Multi-constellation modules should win.
PPS jitter measurement. Capture 1000 PPS edges. Histogram the timestamp delta from 1.000000000 s. Should show ±20–50 ns.
Geofencing. Write a script that alerts when the lat/lon leaves a circle (haversine distance > 100 m). Useful for asset-theft alerts.
TPS6594 + GPS for outage survival. If your product is a stratum-1 server, hooking a UPS so the clock survives mains outages buys you 24+ hours of holdover (the OCXO inside drifts, but GPS resyncs as soon as power is back).
107.9 Pitfalls¶
Antenna missing or shielded. GPS needs sky view. An indoor desk position usually fails. Use a roof or window-mount antenna with a coax extension.
No PPS GPIO. Some “GPS modules” omit the PPS pin or it’s not bonded out. Verify before buying.
PPS GPIO not configured. Without the DT
pps-gpionode,/dev/pps0doesn’t appear.modprobe pps_gpioonly works if the platform driver instantiated it from DT.PPS polarity wrong. Rising-edge vs falling-edge, check the module datasheet. Wrong polarity → chrony sees no edges.
NMEA-only refclock = poor accuracy. Without PPS, system clock accuracy is ~30 ms (NMEA latency). Insist on PPS for sub-µs.
Baud rate too low for UBX-NAV-PVT at 10 Hz. At 9600, the 92-byte NAV-PVT plus other UBX leaves no headroom for 10 Hz updates. Switch to 38400 or 115200.
NMEA checksum bytes flipped. No flow control + heavy bus traffic = bit flips. Always verify the checksum and discard bad sentences.
Multi-constellation overrides single-constellation in NMEA. GNRMC, GNGGA replace GPRMC, GPGGA. Parsers must accept both prefixes.
u-blox jamming detection. The MON-RF message reports interference. If jamming is detected (drone show, military jammer, RF leak), the module may report no fix. Don’t blame the antenna without checking MON-RF.
GNSS time vs UTC leap seconds. GPS time has no leap seconds. UTC does. Old or unprogrammed modules may emit times off by 18 s after a leap second. Use the
LeapSecondsfield if exposed.TPS regulator + GPS together = noise. Switching regulators inject noise on the GPS antenna’s RF input. Use an LDO close to the antenna, or shield the regulator.
107.10 Going deeper¶
u-blox NEO-8M / NEO-9M Protocol Specification: the canonical UBX reference (1500+ pages).
gpsddocumentation: covers many receivers, JSON protocol, refclock SHM mechanism.chronydocumentation: refclock PPS, GPS, SHM integration.drivers/pps/+Documentation/pps/pps.rst, kernel side.NMEA-0183 standard (proprietary. Many free summaries online).
NTPv4 + IEEE 1588 PTP: for sub-µs over Ethernet (after you have a local stratum-1).
RTKLIB: for RTK centimetre-accurate positioning using u-blox RAW data.
Ch 51B: for using PPS to wake a sleeping device every second.
Next chapter: Chapter 108: RS-485 + Modbus RTU, beginning Group Q (Industrial buses).