Tous les textes

~/textes/gpsd-i2c-chrony-shm

Horloges de précision
4 min de lecture

Le GPS qui répond 0xFF quand il n'a rien à dire

Lire un GPS u-blox par I2C libère l'UART, mais l'interface I2C n'a aucune notion de repos. Quand le module n'a rien à envoyer, chaque lecture revient en remplissage 0xFF. Voici le petit pont qui transforme ça en refclock pour chrony.

Un GPS u-blox a plus d'une façon de parler. Il y a l'UART que tout le monde utilise, mais il y a aussi une interface I2C (u-blox l'appelle DDC), et l'I2C devient intéressante quand le port série est déjà pris: ts2phc qui pilote une horloge PTP, par exemple, ou une carte qui n'a pas d'UART à donner. On abandonne la précision de la pulse-par-seconde qu'un montage série-plus-PPS procure, mais on récupère une source de temps NMEA grossière sur deux fils qu'on a probablement déjà.

Le problème, c'est que l'I2C n'a aucune notion de "rien à envoyer pour l'instant." Un UART reste simplement au repos entre les phrases. L'I2C est piloté par le maître: tu demandes des octets, tu reçois des octets, toujours. Donc quand le module n'a aucune phrase NMEA en file, il doit répondre quelque chose. Ce qu'il répond, c'est 0xFF, encore et encore, pour autant d'octets que tu en demandes. Lire le GPS, c'est patauger dans le remplissage pour trouver les vraies phrases.

Sauter le remplissage, horodater le signe de dollar

La boucle de lecture tire un bloc du bus et le parcourt octet par octet. Un 0xFF veut dire que le module n'a rien. On le jette. Un $ est le début d'une phrase NMEA, et c'est le moment de prendre un horodatage: au début de la phrase, pas à la fin, pour que le temps enregistré soit aussi proche que possible du moment où le point GPS a été émis plutôt qu'après que toute la phrase ait fini de couler sur le 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
    }
}

Il y a une subtilité dans cette vérification du 0xFF: 0xFF est aussi un octet parfaitement légal au milieu de trames binaires UBX. Ça marche ici parce que le module est configuré pour émettre du NMEA sur le port I2C, pas de l'UBX, donc un 0xFF sur ce flux est vraiment du remplissage et jamais une charge utile. Ne filtre pas des octets que tu ne comprends pas à moins de t'être arrangé pour comprendre tous ceux qui comptent.

Le temps que ça produit est grossier, et il le dit. Du NMEA par I2C, horodaté en logiciel au $, est bon à environ une milliseconde. Donc le refclock annonce une précision de -10 (à peu près 1 ms) au lieu de prétendre à l'exactitude nanoseconde qu'une source PPS aurait. Une horloge qui surestime sa précision est pire qu'une qui admet ses limites, parce que chrony la pondère en conséquence.

Un refclock qui ne possède pas sa mémoire partagée

L'autre appel intéressant, c'est la façon dont il remet le temps à chrony. Le mécanisme standard est un segment de mémoire partagée NTP: le refclock écrit des horodatages dans une région de mémoire partagée SysV identifiée par un numéro d'unité, et chrony les lit. L'approche habituelle crée ce segment avec IPC_CREAT.

Ce pont-là ne le fait pas. Il s'attache à un segment qui existe déjà et refuse d'en créer un:

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
}

La raison, c'est la coopération. Quelque chose d'autre possède la disposition de cette mémoire partagée: gpsd, ou chrony lui-même, ou un autre alimentateur de refclock. Celui qui l'a mise en place a décidé de la taille et des permissions du segment. Si ce pont créait le segment en premier avec la mauvaise forme, il gagnerait une course dans laquelle il n'avait rien à faire et remettrait à tous les autres une région mal formée. En s'attachant seulement, il se glisse dans un arrangement que quelqu'un d'autre a établi et saute discrètement la publication tant que ce segment n'existe pas. C'est un invité dans la mémoire partagée, pas l'hôte.

Créer un état partagé est une revendication, pas une commodité

IPC_CREAT a l'air d'un inoffensif "crée-le s'il n'est pas là." Sur une clé qu'un autre processus définit, c'est une saisie de terrain: le premier à créer le segment dicte sa taille et ses permissions, et tous les autres doivent vivre avec. Quand tu te joins à un arrangement plutôt que de le définir, t'attacher seulement est le bon défaut, et sauter quelques échantillons en attendant que le propriétaire se montre est une fonctionnalité, pas un échec.

C'est un petit programme, quelques centaines de lignes. Il lit un GPS par le chemin indirect, ignore le remplissage, écrit des horodatages dans une mémoire partagée qu'il a pris soin de ne pas posséder.