~/writing/gpsd-i2c-chrony-shm
The GPS that answers 0xFF when it has nothing to say
Reading a u-blox GPS over I2C frees the UART, but the I2C interface has no concept of idle. When the module has nothing to send, every read comes back as 0xFF filler. This is the small bridge that turns that into a chrony refclock.
A u-blox GPS has more than one way to talk. There’s the UART everyone uses, and there’s also an I2C interface (u-blox calls it DDC). I2C is useful when the serial port is already taken: ts2phc driving a PTP clock, or a board with no UART to spare. You give up the pulse-per-second precision of a serial-plus-PPS setup, but you get a coarse NMEA time source over two wires you probably already have.
The problem is that I2C has no concept of “nothing to send right now.” A UART stays idle between sentences. I2C is master-driven: you ask for bytes, you get bytes. So when the module has no NMEA queued, it answers 0xFF, over and over, for as many bytes as you request. Reading the GPS means wading through filler to find the actual sentences.
Skip the filler, stamp the dollar sign
The read loop pulls a chunk off the bus and walks it byte by byte. A 0xFF means the module has nothing, so drop it. A $ starts an NMEA sentence, so timestamp there, at the front of the sentence, not the end. That keeps the recorded time close to when the fix was emitted rather than after the whole sentence trickled across the bus.
buf := make([]byte, r.chunk) // default 128 bytes per read
for {
r.dev.Read(buf)
for _, b := range buf {
if b == 0xFF { // module idle filler; not data
continue
}
if b == '$' {
recvAt = time.Now() // timestamp at the START of the sentence
haveStart = true
line = line[:0]
line = append(line, '$')
continue
}
if b == '\n' {
if haveStart && validateNMEA(line) {
r.onSentence(line, recvAt)
}
haveStart = false
continue
}
// accumulate, with an overflow guard that drops a runaway line
}
}That 0xFF check has a catch: 0xFF is also a legal byte inside binary UBX frames. It works here because the module is configured to emit NMEA on the I2C port, not UBX, so a 0xFF on this stream is filler and never payload. Don’t filter bytes you don’t understand unless you’ve arranged to understand all the ones that matter.
The time this produces is coarse. NMEA over I2C, timestamped in software at the $, is good to about a millisecond. So the refclock advertises a precision of -10 (roughly 1 ms) rather than the nanosecond accuracy a PPS source would have. Chrony weights a refclock by its stated precision, so overstating it makes the clock worse than admitting its limits.
A refclock that does not own its shared memory
The other interesting part is how it hands time to chrony. The standard mechanism is an NTP shared-memory segment: the refclock writes timestamps into a SysV shared memory region keyed by a unit number, and chrony reads them. The usual approach creates that segment with IPC_CREAT.
This bridge doesn’t. It attaches to a segment that already exists and refuses to create one:
// No IPC_CREAT. If the segment is not already there, we do not make it.
shmid, _, err := unix.Syscall(unix.SYS_SHMGET, uintptr(key), structSize, 0666)
if err != 0 {
// Not attached yet; skip this sample rather than create the segment.
return
}The reason is cooperation. Something else owns the layout of that shared memory: gpsd, or chrony itself, or another refclock feeder. Whoever set it up decided the segment’s size and permissions. If this bridge created the segment first with the wrong shape, it’d win a race it had no business entering and hand everyone else a malformed region. By attaching only, it slots into an arrangement someone else established and skips publishing until that segment exists.
Creating shared state is a claim, not a convenience
IPC_CREAT looks like a harmless “make it if it isn’t there.” On a key that another process defines, it’s a land grab: the first one to create the segment dictates its size and permissions, and everyone else has to live with it. When you’re joining an arrangement rather than defining it, attach-only is the right default. Skipping a few samples while the owner shows up is fine.
It’s a small program, a couple hundred lines. It reads a GPS the indirect way, ignores the filler, and writes timestamps into shared memory it doesn’t own.