~/textes/bbb-pru-pps-timestamping
Les cœurs de réserve d'un BeagleBone gardent l'heure mieux que son noyau
Le pilote PPS standard de Linux horodate l'impulsion GPS dans un gestionnaire d'interruption et paye pour ça ~20 µs de gigue. Le BeagleBone a deux cœurs temps réel à 200 MHz que Linux ne touche jamais. J'ai déplacé l'horodatage dans un de ces cœurs-là, et le décalage de l'horloge est tombé dans les bas nanosecondes.
Une horloge disciplinée au GPS vit ou meurt sur un seul instant : le moment où tu enregistres l'arrivée du front de l'impulsion par seconde. Tout ce qui suit, l'asservissement, la correction de fréquence, le décalage que chrony rapporte, est bâti sur cet horodatage-là. S'il gigue, l'horloge au complet gigue.
Sur Linux le chemin habituel c'est le pilote pps-gpio. Tu câbles le PPS à une broche GPIO, et le noyau horodate le front montant dans un gestionnaire d'IRQ. Ça marche, et pour ben des usages c'est correct. Mais l'horodatage tombe après que l'interruption a été distribuée et le gestionnaire ordonnancé, et ce chemin-là est à la merci de l'ordonnanceur. Sous charge normale tu vois ~20 µs de dispersion, avec des valeurs aberrantes qui dépassent 10 µs par-dessus. C'est le plancher de bruit de l'horodatage d'un front à travers le sous-système d'interruption de Linux. Tu peux pas moyenner ton chemin hors d'une gigue aussi grosse.
Le BeagleBone Black a une sortie de secours, assise inutilisée sur la même puce.
Deux CPU sans système d'exploitation
L'AM335x sur un BeagleBone a une PRU-ICSS : deux Programmable Real-Time Units, des cœurs à 200 MHz qui roulent indépendamment de l'ARM et du noyau Linux. Pas d'OS, pas d'IRQ au sens normal, une instruction par cycle de 5 ns, de façon déterministe. Tu charges le micrologiciel depuis l'espace utilisateur via le cadriciel remoteproc et ils roulent, c'est tout.
C'est exactement ce dont l'horodatage de front a besoin. Une PRU qui scrute une broche dans une boucle serrée voit le front sans rien d'ordonnancé entre la transition et l'horodatage. L'AM335x lui donne aussi une horloge pour estampiller : l'IEP (Industrial Ethernet Peripheral) a un compteur libre à 200 MHz, un tic à chaque 5 ns.
Donc le plan : arrêter de demander à Linux de mesurer l'heure du front. PRU0 surveille la broche PPS, fige le compteur IEP à l'instant où elle passe à l'état haut, et dépose la valeur dans la mémoire partagée pour qu'un démon en espace utilisateur la ramasse.
uint32_t prev = __R31 & PPS_BIT; // P8_16 = pr1_pru0_pru_r31_14
for (;;) {
uint32_t cur = __R31 & PPS_BIT;
if (cur && !prev) {
// Rising edge. Latch the 200 MHz IEP counter right here,
// in the PRU, with nothing scheduled between edge and read.
pps_data.iep_lo = IEP_COUNT_LO;
pps_data.seq++;
}
prev = cur;
// ... service rpmsg, etc.
}La PRU écrit une petite structure, { seq, iep_lo }, dans sa RAM de données. seq s'incrémente à chaque impulsion pour que le lecteur puisse distinguer un échantillon frais d'un échantillon périmé. iep_lo c'est le compte de tics IEP brut au front. Note ce que c'est pas : c'est pas l'heure murale. La PRU n'a aucune idée de l'heure qu'il est. Elle connaît seulement son propre compteur libre. Combler cet écart-là, c'est là qu'est le vrai travail.
Le bout difficile c'est le passage d'un domaine d'horloge à l'autre
La PRU produit des horodatages en tics IEP. Chrony veut des nanosecondes de CLOCK_REALTIME. Deux horloges différentes, à des rythmes légèrement différents et qui dérivent, et la traduction ne peut pas étaler la précision à la nanoseconde que tu viens juste de te donner tout ce trouble à obtenir.
Un démon en espace utilisateur, pru_pps_shm, fait le passage. Il roule en SCHED_FIFO pour que l'ordonnanceur ne puisse pas s'asseoir dessus, lit la structure de la PRU dans la DRAM via un mmap de /dev/mem, et doit ensuite répondre à une question : au moment où le front PPS s'est produit (un tic IEP connu), c'était quoi CLOCK_REALTIME ?
Pour corréler les deux horloges il encadre une lecture du compteur IEP entre deux lectures d'horloge murale, dix fois de suite, en gardant l'encadrement le plus serré :
long long best_spread = 999999999LL;
for (int i = 0; i < 10; i++) {
struct timespec t1, t2;
clock_gettime(CLOCK_REALTIME, &t1);
uint32_t c = read_iep_counter(); // sample IEP between two wall reads
clock_gettime(CLOCK_REALTIME, &t2);
long long spread = ns2 - ns1; // how tight is this bracket?
if (spread < best_spread) {
best_spread = spread;
best_cal_iep = c;
best_cal_wall = ns1 + spread / 2; // midpoint of the tightest bracket
}
}L'encadrement épingle une valeur IEP à une valeur d'horloge murale, et spread c'est l'incertitude de cet épinglage : l'horloge murale aurait pu être n'importe où dans cette fenêtre quand l'IEP a été échantillonné, donc le point milieu est la meilleure estimation et la largeur de la fenêtre est l'erreur. Le meilleur de dix garde l'encadrement où une interruption ou un défaut de cache n'a pas étiré la fenêtre. Sur le noyau RT ce spread reste sous 2 µs, typiquement autour de 1,3.
La deuxième pièce c'est le rythme des tics. Nominalement l'IEP roule à 200 MHz, donc 5,0 ns par tic. Pas tout à fait, et ça dérive avec la température. Donc le démon mesure la période réelle et la lisse avec un filtre IIR, qui suit la dérive thermique sans courir après le bruit :
// Filtered IEP tick period in ns. Nominal 5.0; reality is a hair under,
// and it moves with temperature.
ns_per_tick = ns_per_tick * 0.9 + measured * 0.1;Avec un épinglage (iep, wall) calibré et un ns_per_tick filtré, projeter le front PPS dans l'heure murale c'est de l'arithmétique : prends le point de calibration et recule du nombre de tics IEP entre le front et l'échantillon de calibration, fois les ns par tic. Cet instant CLOCK_REALTIME projeté entre dans la refclock à mémoire partagée NTP de chrony, unité 2, qui discipline l'horloge du système.
Capturer pas cher, corréler avec soin
La capture par la PRU c'est la moitié facile et clinquante : horodatage matériel à une résolution de 5 ns. Ça vaut rien sans la moitié plate. Passe des tics IEP à l'heure murale avec un seul clock_gettime négligé, ou présume que le compteur roule exactement à 200 MHz, et t'as réintroduit des µs d'erreur. Le front précis au silicium aura servi à rien. La précision est fixée par le maillon le plus faible, et le maillon le plus faible c'est le passage d'un domaine d'horloge à l'autre, pas la capture.
Ce que le démon te dit
À chaque impulsion, le démon journalise une ligne qui est toute la santé de la chaîne d'un coup d'œil :
seq=202 delta=200011758 offset=+634 ns gap=38925 (194.6 us) spread=1291 ns ns/tick=4.999707 [good=201]delta c'est les tics IEP entre deux impulsions consécutives ; ça devrait se tenir proche de 200 millions (une seconde à 200 MHz), et 200011758 dit que l'IEP roule un brin vite, ce qui est exactement pourquoi ns/tick s'établit à 4,999707 plutôt qu'à 5,0. offset c'est le résidu sous la seconde du front contre la seconde UTC. gap c'est combien de temps après le front la calibration a roulé, gardé sous 250 µs. spread c'est la largeur de l'encadrement de calibration, ici 1291 ns. good compte les impulsions acceptées ; le compteur bad correspondant devrait rester à zéro.
Le résultat
Le gain apparaît dans chaque métrique.
Une fois que l'asservissement de chrony a convergé, chronyc tracking met le décalage de l'heure système dans les bas nanosecondes, souvent à l'intérieur de ±10 ns. La vue sourcestats montre l'écart-type estimé qui s'établit à 1.0e-09, une nanoseconde, contre les 5 à 20 µs que pps-gpio montre couramment. Un extrait de journal de tracking une fois que c'est établi :
Date (UTC) Time IP Address St Freq ppm Offset Offset sd
2026-03-02 15:59:48 PPS 1 58.491 1.172e-14 1.716e-17
2026-03-02 15:59:50 PPS 1 58.491 2.141e-15 8.552e-18
2026-03-02 15:59:52 PPS 1 58.491 4.945e-16 8.621e-18Une note de bas de page qui vaut la peine d'être connue : l'IEP peut aussi être exposé comme une horloge matérielle PTP à travers le pilote noyau pru_iep, apparaissant comme /dev/ptpN, ce qui permettrait à linuxptp de l'utiliser directement. Ce projet ne prend pas ce chemin-là. Il lit le compteur IEP directement de /dev/mem et fait la corrélation lui-même, ce qui garde le tout lisible : une boucle serrée sur un cœur sans OS, un encadrement soigné pour passer dans l'heure murale, et un noyau tenu à l'écart de la seule étape qui doit se passer à temps.
Le code, micrologiciel et démon et superpositions d'arbre de périphériques, est sur GitHub.