All writing

~/writing/gpsd-i2c-chrony-shm

Precision timing
4 min read

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, but there's also an I2C interface (u-blox calls it DDC), and I2C is appealing when the serial port is already spoken for: ts2phc driving a PTP clock, say, or a board with no UART to spare. You give up the pulse-per-second precision a serial-plus-PPS setup gets you, 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 just stays idle between sentences. I2C is master-driven: you ask for bytes, you get bytes, always. So when the module has no NMEA queued, it has to answer with something. What it answers is 0xFF, over and over, for as many bytes as you request. Reading the GPS is an exercise in 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. Drop it. A $ is the start of an NMEA sentence, and that's the moment to take a timestamp: at the front of the sentence, not the end, so the time recorded is as close as possible to when the fix was emitted rather than after the whole sentence trickled across the bus.

reader.go (the I2C byte stream)
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
    }
}

There's a subtlety in that 0xFF check: 0xFF is also a perfectly legal byte in the middle of 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 really 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, and it says so. 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 claiming the nanosecond accuracy a PPS source would have. A clock that overstates its precision is worse than one that admits its limits, because chrony will weight it accordingly.

A refclock that does not own its shared memory

The other interesting call 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:

attach-only, never create
// 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 silently skips publishing until that segment exists. It's a guest in shared memory, not the host.

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, and skipping a few samples while you wait for the owner to show up is a feature, not a failure.

It's a small program, a couple hundred lines. Reads a GPS the indirect way, ignores the filler, writes timestamps into shared memory it was careful not to own.