Tous les textes

~/textes/esp32-stratum-1-ntp

Horloges de précision
13 min de lecture

Le stratum 1 à 20 piasses que chrony mettait sur le banc

J'ai bâti un serveur NTP discipliné au GPS sur un ESP32 à 20 piasses, avec un horodatage interne à la nanoseconde. Puis chrony a refusé de s'en servir. Le correctif n'était pas dans l'horloge. Il était dans tout ce que j'avais tenu pour acquis sur la façon de servir l'heure.

Voici une question qui a l'air d'avoir une réponse évidente : quelle qualité d'horloge peux-tu bâtir pour vingt piasses ?

La réponse évidente, c'est "pas grand-chose". Une vraie horloge de référence stratum 1, c'est un étalon au rubidium, ou une boîte à OCXO avec maintien de fréquence, ou un grandmaster PTP corrigé en dents de scie installé dans un rack avec une antenne sur le toit. Ça coûte de l'argent pour vrai. Un ESP32, un module GPS u-blox et une puce Ethernet WIZnet W5500 coûtent à peu près vingt piasses ensemble. La sagesse populaire dit qu'un microcontrôleur fait l'affaire pour une horloge de passe-temps "assez bonne", et qu'il ne faut pas s'attendre à ce qu'il tienne tête au matériel sérieux.

J'aimais pas cette réponse-là, ça fait que je suis allé la mesurer. Ce que j'ai trouvé était plus étrange que "l'horloge à bon marché est pire". L'horloge à bon marché était, à l'interne, une des meilleures horloges de mon réseau. Et elle était inutile pareil, parce que j'avais bâti une excellente horloge et un serveur NTP médiocre, et c'est pas la même affaire.

Ce que "stratum 1" doit vraiment vouloir dire

Un serveur stratum 1, c'est un serveur qui tire son heure directement d'une source de référence plutôt que d'un autre serveur NTP. En pratique, ça veut dire un récepteur GPS. Le module GPS crache deux choses : une phrase NMEA par UART qui dit, en termes humains, "il est présentement 14:02:53 UTC", et une ligne pulse-par-seconde (PPS) qui passe au niveau haut à l'instant exact où chaque seconde commence. Le NMEA te dit quelle seconde c'est. Le PPS te dit précisément quand cette seconde-là commence. T'as besoin des deux.

La façon naïve de lire le PPS, c'est de le câbler à une broche GPIO et de prendre une interruption sur le front montant. Le problème, c'est qu'une interruption GPIO sur un CPU généraliste n'est pas ponctuelle. L'interruption doit être distribuée, le gestionnaire doit être ordonnancé, d'autres interruptions peuvent être en vol devant elle. Sur un ESP32, ça te coûte entre une et dix µs de gigue, et c'est de la gigue, pas un décalage fixe, ça fait que tu peux pas l'étalonner pour l'enlever.

L'ESP32 a un moyen de contourner ça que presque personne utilise pour les horloges. Le périphérique MCPWM a une unité de capture matérielle. Tu la pointes vers un GPIO, et quand le front arrive, le matériel verrouille la valeur d'un compteur en roue libre dans un registre, dans le silicium, avec zéro logiciel dans le chemin. Le CPU l'apprend plus tard et lit la valeur verrouillée. Le front est horodaté avant même que l'interruption existe.

pps_capture.c
// MCPWM capture: the hardware latches the timer on the PPS edge.
// The ISR runs later and just reads what silicon already recorded.
static bool IRAM_ATTR pps_capture_cb(mcpwm_cap_channel_handle_t ch,
                                     const mcpwm_capture_event_data_t *ed,
                                     void *user) {
    BaseType_t hp_task_woken = pdFALSE;
    // ed->cap_value was latched in hardware at the instant of the edge.
    // No dispatch jitter, no scheduling jitter. This is the whole trick.
    g_last_pps_ticks = ed->cap_value;
    g_pps_event = true;
    return hp_task_woken == pdTRUE;
}

L'horloge APB tourne à 80 MHz, ça fait que chaque tic vaut 12,5 ns. C'est la résolution de la capture. La partie intéressante, c'est ce que l'asservissement a fait avec ça une fois que j'avais des fronts propres à discipliner.

Gigue du PPS
11 ns
sous un seul tic de 12,5 ns
Décalage interne
7 ns
discipliné au GPS
Pulsations rejetées
0
sur 94 650 PPS

11 ns de gigue RMS sur un tic de 12,5 ns, ça veut dire que l'asservissement moyenne en dessous de la quantification de sa propre horloge, ce qu'une PLL bien élevée devrait faire. Zéro pulsation rejetée sur 94 650 d'entre elles, ça veut dire que la ligne PPS était propre et que le rejet des valeurs aberrantes n'a jamais eu à se déclencher. Selon chaque mesure interne que j'avais, l'horloge était solide.

Puis j'ai demandé à chrony ce qu'il en pensait.

La mise au banc

J'avais trois boîtes disciplinées au GPS sur le banc, chacune avec son propre récepteur, délibérément pas synchronisées entre elles. L'idée n'était pas de les asservir ensemble. L'idée était de laisser une instance chrony indépendante surveiller les trois et me dire, honnêtement, à laquelle elle faisait confiance. Une était une carte Intel i210 faisant de l'horodatage PTP matériel à partir d'un u-blox NEO-M9N. Une était un BeagleBone avec une alimentation GPS PPS. La troisième était mon ESP32 à 20 piasses.

chrony marque chaque source avec un symbole. ^* est la source sélectionnée, celle vers laquelle il pilote réellement l'horloge. ^+ est un bon candidat de repli. ^- veut dire mise au banc : une source qu'il a mesurée, à laquelle il ne fait pas assez confiance pour s'en servir, et qu'il a mise de côté.

Ma belle horloge est sortie ^-. Au banc. Le grandmaster i210, celui qui coûte cher, était ^*.

C'est ici que la sagesse populaire dit que je devrais avoir plus de jugeote. Un microcontrôleur à vingt piasses n'est pas un grandmaster. Sauf que les chiffres n'appuyaient pas cette histoire-là. L'hôte de surveillance était sur le propre segment de l'ESP32, 192.168.69.0/24, tandis que le grandmaster et l'autre horloge de référence étaient à un saut routé de distance sur 192.168.0.0/24. Ça fait que d'où chrony surveillait, l'ESP32 était l'horloge la plus proche du réseau, et il aurait dû avoir l'aller-retour le plus court et le plus stable des trois. Au lieu de ça, chrony mesurait son aller-retour à 1,88 ms contre 0,19 ms pour les autres. Dix fois plus lent que des pairs un sous-réseau plus loin.

L'indice

Une source plus proche topologiquement devrait avoir un aller-retour plus court, pas plus long. Quand l'horloge la plus proche mesure le pire délai, c'est pas du bruit que tu moyennes pour le faire disparaître. C'est un défaut, et il est dans la partie du système que tu contrôles.

Le GPS n'a jamais été le problème. L'horloge n'a jamais été le problème. Le problème était le serveur NTP, et plus précisément les horodatages qu'il mettait dans les paquets.

Comment NTP calcule réellement qui a raison

Chaque échange NTP a quatre horodatages. Le client estampille quand il envoie la requête (t1). Le serveur estampille quand il reçoit cette requête (t2) et quand il envoie sa réponse (t3). Le client estampille quand la réponse arrive (t4). À partir de ces quatre chiffres, le client calcule deux choses :

The NTP timestamp model
offset = ((t2 - t1) + (t3 - t4)) / 2
delay  = (t4 - t1) - (t3 - t2)

Tout l'édifice repose sur le fait que t2 et t3 soient honnêtes sur le moment où le paquet a réellement traversé le fil. Si ton serveur estampille t2 quelques centaines de µs après que le paquet est vraiment arrivé, et estampille t3 bien avant qu'il parte vraiment, alors le calcul voit un délai fantôme qui n'a jamais été sur le réseau. chrony peut pas faire la différence entre une vraie latence réseau et un serveur qui ment sur ses propres horodatages. Il voit juste une source avec deux millisecondes de jeu et il la met au banc.

Ça fait que j'ai arrêté de faire confiance à mes propres horodatages et je suis allé mesurer d'où ils venaient.

Réception : estampille-la dans le matériel, encore une fois

Le même coup que le PPS appliqué à l'arrivée des paquets. Le code original estampillait t2 dans le chemin de réception UDP, en logiciel, après que la pile réseau avait déjà remonté le paquet. Le W5500 a une ligne INTn qui s'active au moment où un paquet atterrit dans son tampon. J'ai câblé ça à un GPIO, pris l'interruption, et estampillé t2 là, aussi tôt dans l'arrivée que je pouvais physiquement le faire.

w5500_rx.c
// INTn from the W5500 asserts when a packet hits the socket buffer.
// Stamp the receive instant here, not up in the UDP read path.
static void IRAM_ATTR w5500_int_isr(void *arg) {
    uint64_t now = now_ns_disciplined();
    g_rx_hw_ts = now;            // becomes t2
    g_rx_irq_count++;            // 1:1 with served requests, for auditing
    xSemaphoreGiveFromISR(g_rx_sem, NULL);
}

J'ai ajouté un compteur, ntp_rx_irq_total, qui s'incrémentait à chacune de ces interruptions, pour pouvoir prouver que l'interruption se déclenchait exactement une fois par requête servie plutôt que de le supposer. La gigue de réception est descendue d'un sigma de 26 µs à 2,6 µs. Ça a fait de l'horodatage de réception de l'ESP32 le plus serré des trois horloges depuis ce point de vue, y compris le grandmaster.

Les deux choses qui bloquent entre t2 et t3

La réception était maintenant honnête. Le temps d'aller-retour était encore mauvais, ce qui voulait dire que du temps se perdait entre la réception de la requête et l'envoi de la réponse. Ça fait que j'ai instrumenté l'écart, et j'ai trouvé deux blocages distincts.

Le premier était un amorçage ARP. Avant d'envoyer une réponse, le code appelait un utilitaire qui, sur un cache ARP froid, attendait activement entre cent et deux cents millisecondes pour résoudre l'adresse MAC du client. Le deuxième était plus subtil : la boucle principale appelait gps->loop() pour servir l'UART au début de chaque itération, et cet appel pouvait bloquer jusqu'à 100 ms en vidant le GPS, juste avant le sondage NTP. Une requête pouvait rester là à attendre après le GPS avant même que le serveur la regarde.

Ni l'un ni l'autre n'est un problème d'horloge. Les deux sont un problème de "t'as écrit un serveur bloquant puis tu l'as mesuré en train de servir l'heure sur laquelle il bloquait".

Transmission : tu peux pas estampiller t3 après l'envoi

Le dernier blocage était le plus intéressant parce qu'il est vraiment difficile à corriger comme il faut. Tu veux que t3 soit l'instant où la réponse touche le fil. Mais t3 s'écrit dans le paquet, ce qui veut dire que tu dois connaître le temps de transmission avant de transmettre. Tu estampilles un horodatage pour un événement qui n'a pas encore eu lieu.

J'ai mesuré combien de temps prenait l'envoi réel. w5k_sendto bloquait pendant ~636 µs à pousser le paquet vers le W5500 par SPI. J'avais prédit ~560 µs à partir de l'horloge SPI et de la taille du paquet ; mesurer 636 a confirmé que je comprenais le chemin. Ça fait que t3 s'écrivait 636 µs avant que le paquet parte, à chaque fois, et cette erreur allait directement dans le calcul du décalage de chaque client.

Le correctif est de prédire le temps d'envoi et de pré-corriger t3 par cette valeur, en utilisant une EWMA de la durée d'envoi mesurée pour qu'elle s'adapte aux conditions au lieu d'être une constante magique.

ntp_tx.c
// t3 is written INTO the packet, so it must be stamped before egress.
// Pre-correct by the predicted on-wire moment using an EWMA of measured
// SPI send time, then split the write so the loop never blocks on it.
uint64_t predicted_tx = now_ns_disciplined() + g_send_ewma_ns;
pkt->transmit_ts = ns_to_ntp64(predicted_tx);
 
uint64_t t_before = now_ns_disciplined();
w5500_sendto_nonblocking(sock, pkt, sizeof(*pkt), client);
uint64_t measured = now_ns_disciplined() - t_before;
 
// Adapt the prediction toward what actually happened. 1/8 gain.
g_send_ewma_ns += ((int64_t)measured - (int64_t)g_send_ewma_ns) >> 3;

Passer à une écriture fractionnée non bloquante a retranché à elle seule à peu près 1,4 milliseconde de chaque requête. La correction EWMA a sorti l'erreur de transmission résiduelle du décalage.

Le résultat

Après que la réception était estampillée dans le matériel, que les deux blocages étaient enlevés, et que la transmission était pré-corrigée et rendue non bloquante, je suis retourné demander à chrony encore une fois. Ça a pas pris de temps.

Délai des pairs
2.11 mssub-ms
l'horloge la plus proche, qui agit enfin comme telle
Statut chrony
benchedselected
le ^- est devenu ^*
Sigma du décalage
~1 us
le plus serré depuis ce point de vue

L'ESP32 à vingt piasses est passé du banc à la sélection. Depuis ce juge, il est devenu la source vers laquelle chrony pilotait : la plus faible distance racine des trois, une gigue sous la microseconde, un ^* propre.

Puis je me suis forcé à vérifier si c'était réel ou juste un tour de passe-passe lié à l'endroit où j'étais placé. Le juge était sur le propre segment de l'ESP32, et le grandmaster était à un saut routé de distance sur l'autre sous-réseau. Depuis ce juge, l'aller-retour de l'ESP32 mesure à peu près 2 µs et celui du grandmaster à peu près 260, purement parce que l'un est local et l'autre traverse un routeur. Cette asymétrie flatte l'ESP32, ça fait que "sélectionné devant le grandmaster" est en partie un énoncé sur la topologie, pas sur les horloges. Ça fait que j'ai mesuré encore depuis le propre segment du grandmaster, où les rôles s'inversent. Là, le grandmaster est l'horloge locale et l'ESP32 est celle à un saut routé de distance, et le verdict de chrony bascule avec ça : il sélectionne le grandmaster, et il laisse l'ESP32 complètement en dehors de sa combinaison comme ^-, avec une distance racine plus large et à peu près trois fois la gigue du grandmaster depuis ce siège. Aligne les deux horloges GPS directement, là où l'erreur d'horloge de l'hôte qui mesure s'annule, et elles s'entendent à environ 50 à 90 µs près, et même cet écart, c'est l'asymétrie de routage qui se déplace avec le point de vue, pas les horloges elles-mêmes qui ne s'entendent pas.

Ça fait que je garde l'affirmation étroite, parce que la précision, c'est tout le but. L'ESP32 n'était plus au banc. Pour un client assis sur son propre segment, la boîte à vingt piasses était la source la plus serrée et la plus digne de confiance disponible, devant un grandmaster qui coûtait plusieurs fois plus cher et qui était assis un sous-réseau plus loin. C'est pas la même chose qu'être plus précis qu'un grandmaster, et depuis le propre point de vue du grandmaster, il l'est pas. Il est vraiment dans cette classe-là depuis l'endroit où le client est assis, ce qui est tout ce que j'avais besoin qu'il soit.

C'est toujours pas la meilleure horloge au monde, et le palier honnête au-dessus est réel : des boîtes GPSDO et OCXO avec maintien de fréquence qui gardent l'heure à travers les pertes de GPS, et des grandmasters corrigés en dents de scie avec du matériel qu'un ESP32 n'a pas. Ce qu'il est, c'est le meilleur dans sa propre catégorie. Parmi les serveurs stratum 1 à base de microcontrôleur, la combinaison de la capture MCPWM pour le PPS, de l'horodatage de réception matériel sur l'INTn du W5500, et d'une correction de transmission qui s'auto-étalonne est, autant que je peux le mesurer, la meilleure de sa classe. Et la chose qui le rend digne de ce nom, l'horodatage matériel, ne coûte rien de plus. Elle était assise dans le silicium depuis le début.

La partie sur laquelle je ne ferai pas de compromis

Encore une chose, parce que c'est la différence entre une horloge et un menteur. Un serveur stratum 1 doit être honnête sur le moment où il ne connaît pas l'heure. Si le GPS perd le verrouillage, le pire comportement possible est de continuer à servir avec assurance du stratum 1 avec de l'heure périmée, parce que chaque client en aval va le croire. Ça fait que le serveur vérifie le verrouillage à chaque requête. Des pulsations PPS qui arrivent, et un point NMEA de moins de 1,5 seconde d'âge, sinon il ne prétend pas être synchronisé. Au moment où il n'est pas certain, il annonce stratum 16 avec l'indicateur de saut réglé à l'alarme, ce qui veut dire en NTP "ne te sers pas de moi". Une horloge qui ment une fois est pire que pas d'horloge du tout.

C'est toute la discipline en une seule règle. La partie coûteuse n'a jamais été le matériel. C'était de refuser de croire ma propre horloge tant que le moniteur indépendant, qui surveillait depuis l'autre bout du réseau, n'était pas d'accord avec elle.

Le code est sur GitHub.