Architecture de l’application firmware

Le code applicatif vit dans firmware/ratbot_firmware_card/App/, à côté du code généré par CubeMX qu’il ne faut jamais modifier hors des blocs USER CODE.

App/
├── ratbot.h              types partagés, RATBOT_ID, domaine DDS
├── rt_state.h            seqlock et compteur de cycles
├── config.[ch]           configuration persistante (flash U7)
├── traction.[ch]         roues, asservissement, odométrie — SANS ROS
├── sensors.[ch]          acquisition température · batterie · IMU — SANS ROS
├── tof.[ch]              capteur de distance avant (ToF) — SANS ROS
├── tof_frame.[ch]        son image : statuts, orientation — calcul pur
├── safety.[ch]           verrou d'arrêt d'urgence — SANS ROS
├── drivers/              parle au MATÉRIEL — n'inclut jamais rcl.h
│   ├── tmp102 · battery · board_io · buzzer
│   ├── lsm6dsox · dynamixel · encoder · motor_dc
│   └── w25q64 · ssd1309
└── ros/                  parle à micro-ROS — ne touche jamais un registre
    ├── ros_node          transport, domaine, exécuteur, profil de boucle
    ├── ros_base          cmd_vel_safe · odom · joint_states
    ├── ros_sensors       imu · temperature · battery
    ├── ros_tof           tof/depth
    ├── ros_safety        emergency_stop · service rearm
    ├── ros_params        serveur de paramètres
    ├── ros_diag          /diagnostics
    ├── ros_ui            buttons · leds · led · sound
    └── ros_screen        expression · heartbeat · button

Note

Deux sujets ont leur propre page, parce qu’ils débordaient largement de celle-ci : Traction : estimateur de roue et odométrie pour l’estimateur de roue, l’asservissement et l’odométrie, et Le banc de test sur PC pour les vérifications qui les protègent.

Les trois modules de premier niveau — traction, sensors, safety — sont l’étage matériel : ils tournent dans leurs propres tâches, à cadence fixe, et n’appellent jamais rcl. C’est le cœur de l’architecture décrite plus bas.

La règle des deux couches

Un fichier de drivers/ n’inclut jamais rcl.h. Un fichier de ros/ ne touche jamais un registre.

Ce n’est pas de la cosmétique, et ça paie trois fois :

  • les drivers se testent sans micro-ROS — c’est exactement ainsi qu’a été mené le bring-up de la carte, et le code de tmp102.c en est directement issu ;

  • le passage des dynamixels aux moteurs CC ne touchera que drivers/ : ros_base publiera odom sans savoir d’où viennent les ticks ;

  • chaque fichier de ros/ correspond à un sous-système du Les topics du robot, ce qui rend le contrat traçable dans le code.

Chaque en-tête de drivers/ porte le brochage et les pièges relevés au bring-up — état de repos des signaux actifs bas, défauts CubeMX corrigés, limites matérielles. C’est le premier endroit à lire avant de toucher un périphérique.

Les tâches, et une hiérarchie de priorités

Le matériel tourne dans ses propres tâches, à cadence fixe. Elles remplissent des instantanés en mémoire. La tâche micro-ROS les lit et publie — elle ne mesure rien et ne pilote rien.

Les tâches, de la plus prioritaire à la moins

Tâche

Priorité

Cadence

Pile

Ce qu’elle fait, et ce qu’elle possède

traction

AboveNormal

100 Hz

4 Kio

safety_step() lit le contact d’arrêt d’urgence. traction_step() traite une roue par pas, donc 50 Hz par roue : asservissement PI et intégration de l’odométrie.

Possède : UART4 et UART8 (dynamixel), SPI1 (flash) au démarrage.

capteurs

Normal

100 Hz

2 Kio

sensors_step() : centrale inertielle à chaque pas, moyenne de 4 ; batterie à 1 Hz (la température est lue par la tâche tof, qui possède I2C2). Décalée de 5 ms par rapport à la traction.

Possède : I2C3, ADC2.

tof

Normal

15 Hz

2 Kio

tof_step() toutes les 10 ms : le capteur a-t-il une image ? Si oui, la lire (~400 octets, ~10 ms de bus), la convertir, la publier. Seule propriétaire d’I2C2 depuis le 2026-10-20 : le capteur et le TMP102, lu ici une fois par seconde et rendu à la tâche capteurs par sensors_temperature_update() (son propre seqlock, un écrivain). La lecture du capteur ne retarde donc jamais la centrale inertielle, sur I2C3. Pile de 4 Kio : le pilote ST garde quelques centaines d’octets en locales au démarrage (~2 s, envoi de son firmware)

ihm

Normal

50 Hz

2 Kio

board_io_poll() : anti-rebond des boutons, deux lectures concordantes. buzzer_spin() fait avancer les mélodies. fan_step() applique la consigne des ventilateurs.

microros

BelowNormal

libre

12 Kio

ros_node_spin() — la seule à appeler rcl.

Possède : UART5 (lien vers l’agent), SPI3 (écran).

La hiérarchie n’est pas un détail. Les tâches matérielles sont courtes et périodiques : elles reprennent la main quand elles veulent, ROS consomme ce qui reste. Sans cette hiérarchie, la tâche micro-ROS — qui tourne au maximum — affamerait l’asservissement. C’est exactement ce qui se passait quand tout vivait dans une seule tâche : la boucle de vitesse réglée pour 100 Hz tournait à 31, parce que publier lui volait son temps.

Le coût est mesuré et modeste :

processeur traction     10,6 %
processeur capteurs      8,3 %
reste à micro-ROS       81,1 %

Warning

rcl et rclc ne sont pas thread-safe. Publier ou spinner depuis plusieurs tâches FreeRTOS corrompt la session XRCE, et les symptômes sont erratiques — messages tronqués, session qui tombe, plantages sans rapport apparent. Une seule tâche appelle rcl, sans exception.

Un bus, un propriétaire

Chaque périphérique partagé appartient à une tâche. Ce n’est pas un principe abstrait : la tâche micro-ROS lisait la flash de configuration au démarrage pendant que la tâche traction la lisait aussi, sur le même SPI1 — et w25q64_init() ne se contente pas de sonder, il réinitialise le périphérique. Comme un chargement raté retombe sur les valeurs par défaut, la seconde lecture écrasait la première : le robot repartait avec l’identifiant de servo 18 alors que la flash disait 14, pendant que le serveur de paramètres, lui, affichait 14.

La tâche micro-ROS attend désormais que la traction ait fini (config_ready()), et ne touche plus jamais la flash au démarrage.

Attendre sans bloquer les autres

HAL_Delay() de ST est une attente active : elle tourne dans une boucle jusqu’au tick suivant. Sous un ordonnanceur, c’est une faute — la tâche garde le processeur sans rien faire et tout ce qui est moins prioritaire est gelé. La calibration du biais du gyroscope fait 200 lectures espacées de 10 ms : deux secondes pendant lesquelles micro-ROS n’existait plus.

HAL_Delay étant déclarée __weak, elle est redéfinie une fois pour toutes dans freertos.c : sous l’ordonnanceur elle appelle osDelay, avant lui et en interruption elle garde l’attente active d’origine.

Même logique pour les délais d’attente des bus : ils doivent être une fraction de la période d’appel, pas une valeur confortable. Une lecture I2C de 0,35 ms appelée toutes les 10 ms avec 50 ms de délai d’attente, c’est un capteur muet qui prend 100 % du processeur disponible — une nappe desserrée coûtait la liaison ROS. Elle est à 5 ms, et au bout de dix échecs consécutifs le capteur est décroché puis retenté toutes les 5 secondes.

Comment l’état traverse les tâches

Chaque module matériel publie un instantané protégé par un seqlock (App/rt_state.h) :

rt_write_begin(&seq);   /* compteur impair : écriture en cours */
/* ... remplir la structure ... */
rt_write_end(&seq);     /* compteur pair : donnée cohérente    */

Le lecteur relit le compteur avant et après sa copie ; s’il a bougé, ou s’il est impair, il recommence.

Note

Pourquoi pas un mutex. Un mutex ferait attendre l’écrivain temps réel pendant que le lecteur ROS tient le verrou — exactement l’inversion de priorité qu’on cherche à éviter. Ici l’écrivain n’attend jamais. Valable pour un seul écrivain par structure, ce qui est le cas : chaque tâche matérielle possède la sienne.

Si la contention persiste au-delà de quatre tentatives — ce qui n’est pas censé arriver — le lecteur rend la dernière copie saine, jamais une copie mélangée, et l’incrémente dans lectures_dechirees. Une pose et des vitesses prises à deux instants différents donneraient une odométrie fausse plutôt qu’en retard.

La cadence voyage avec la donnée

Chaque mesure porte un compteur, incrémenté à chaque nouvelle valeur. La couche ROS publie quand le compteur a changé, et rien d’autre. Elle ne contient plus une seule constante de période.

if (s.imu_seq != imu_seen) { imu_seen = s.imu_seq; publier(); }

Trois conséquences, et c’est le but :

  • la cadence d’un topic a une seule définition, dans le module qui fait la mesure ;

  • une mesure ne peut être ni republiée deux fois, ni sautée sans qu’on le voie (imu_non_publiees, odom_pas_sautes) ;

  • une lecture ratée n’incrémente rien, donc aucune valeur périmée ne repart avec un horodatage frais.

base/odom va plus loin : il est calé sur le compteur de pas de l’asservissement, un pas sur deux, et rattrape son retard (+= 2 plutôt que = courant). La cadence moyenne est donc exactement la moitié des 100 Hz de la traction — 49,6 Hz mesurés, zéro pas sauté — et ne peut pas dériver comme le ferait une échéance d’horloge.

Une échéance ratée ne se rattrape pas en rafale

osDelayUntil sur une échéance déjà passée ne dort pas : elle rend une erreur et la boucle repart aussitôt. Une tâche qui a débordé d’un pas se met alors à tourner en rafale pour rattraper — au moment précis où le système est le plus chargé. periodic_wait() repart de maintenant : des pas sont perdus, aucun n’est comprimé.

L’exécuteur

ros_node.c crée un rclc_executor_t dimensionné à 16 handles — une par abonnement, minuterie ou service. Quatre abonnements, le service rearm et les cinq handles du serveur de paramètres en consomment dix ; le reste est de la marge.

Il coûtait 2,5 ms à chaque tour de boucle, soit environ un tiers du processeur passé à guetter des commandes qui arrivent à 20 Hz au plus ; 2,1 ms mesurés le 2026-10-14, une fois le chemin de publication en ITCM. C’est aujourd’hui sans conséquence — personne d’autre ne réclame ce temps — mais c’est le premier levier à actionner le jour où le lidar ou la caméra en auront besoin.

Important

Vérifier le retour de chaque rclc_executor_add_subscription(). Un dépassement du nombre de handles échoue silencieusement si on l’ignore : l’abonnement n’est jamais servi, et rien ne le signale.

Trois règles d’écriture

Elles découlent des contraintes de la carte, détaillées dans Bootloader et exécution depuis la flash externe.

1. Un gros tampon se déclare static, jamais en local — une globale trop grosse fait échouer l’édition de liens, un tableau local trop gros déborde la pile à l’exécution, sans un mot.

2. Tout tampon touché par le DMA va dans la section .dma_buffer, et rien d’autre. Deux raisons, dont une qui n’existait pas au départ : le DMA du H7 n’atteint pas la DTCMRAM, et cette section est désormais déclarée non cacheable par le MPU. Ce qu’on y range en trop est du calcul condamné à tourner sans cache — le tas de micro-ROS y a vécu par erreur et en occupait 94 %.

3. Ne jamais appeler <msg>__init() sur un message statique.

/* NON : __init() alloue frame_id sur le tas, on l'ecrase juste apres.
 * Fuite immediate, et un futur __fini() liberera une adresse STATIQUE. */
sensor_msgs__msg__Temperature__init(&msg);
msg.header.frame_id.data = mon_tampon;

/* OUI : le message est statique donc deja a zero, on renseigne les champs. */
msg.header.frame_id.data     = mon_tampon;
msg.header.frame_id.size     = strlen(mon_tampon);
msg.header.frame_id.capacity = sizeof(mon_tampon);

C’est le piège le plus vicieux des trois : la corruption du tas se manifeste bien plus tard, sans rapport visible avec sa cause.

MPU et caches

Le firmware s’exécute depuis la flash QSPI externe, à 0x90000000. Sans cache d’instructions, chaque lecture d’instruction traverse la liaison série de la flash : mesure faite, plus aucun topic ne sort. Il est donc obligatoire.

Le cache de données est resté éteint longtemps, faute d’une configuration MPU utilisable — celle que génère CubeMX pose une région de 4 Go en NO_ACCESS qui couvre justement 0x90000000. ratbot_mpu_config() (main.c, bloc USER CODE 0) la remplace par trois régions :

n°

zone

taille

attributs

0

0x90000000 flash QSPI

8 Mio

Normal, write-through, cacheable, exécutable

1

0x24000000 SRAM AXI

512 Kio

Normal, write-back write-allocate

2

0x30000000 tampons DMA

8 Kio

Normal, non cacheable, non exécutable

Toutes non partageables, et PRIVDEFENA garde la carte mémoire par défaut partout ailleurs.

Warning

Sur Cortex-M7, la mémoire déclarée partageable est traitée comme non cacheable : le cœur n’a pas d’unité de cohérence. Activer le MPU en laissant la carte par défaut s’appliquer à la SRAM risque donc de laisser le cache inerte — et de faire conclure « le cache n’apporte rien » alors que c’est la configuration qui est fausse. D’où les régions explicites.

Le témoin memcpy_us de /diagnostics lève cette ambiguïté : trois recopies de 4 Kio en SRAM, chronométrées. Cache éteint 1073 µs, cache actif 152 µs. Si ce chiffre ne bouge pas, c’est le MPU qui est faux.

Pourquoi la région DMA doit être non cacheable

Le DMA ne passe pas par le cache du processeur. À l’émission, le cœur prépare une trame qui reste dans le cache pendant que la RAM contient encore l’ancienne ; le DMA lit la RAM et envoie du périmé. À la réception, c’est l’inverse. Et le pire est que ça marche souvent : la panne est intermittente et silencieuse, selon que la ligne de cache a déjà été évacuée ou non.

Une région non cacheable dédiée règle le problème une fois pour toutes, sans semer des SCB_CleanDCache_by_Addr dans dix pilotes. La surface DMA du firmware est petite et connue — six flux, quatre UART et deux pour I2C2 (capteur ToF, depuis le 2026-10-20) :

uart5_rx   micro-ROS    dma_buffer       2048 o
uart5_tx   micro-ROS    dma_tx_buffer    2048 o
uart4_rx   dynamixel    dxl_dma_buf[0]     64 o
uart8_rx   dynamixel    dxl_dma_buf[1]     64 o
i2c2_rx    ToF          bounce           2048 o   DMA1 flux 4
i2c2_tx    ToF          bounce           2048 o   DMA1 flux 5 (même tampon)

La section fait 6 272 octets, pour une région de 8 Kio : ne pas dépasser 8 192 (objdump -h build/uROSProjet.elf). Le pilote du capteur passe ses propres tampons, en SRAM AXI cacheable : la couche plateforme les recopie par le tampon bounce plutôt que de les confier au DMA. Seule exception, en lecture : le firmware du capteur (3 blocs de 20 à 32 Kio), envoyé directement depuis la flash QSPI — des constantes, que le cache ne peut pas rendre incohérentes. Tout refus du DMA retombe sur l’attente active (dma_fallbacks du bloc tof)

Vérification contre la corruption silencieuse : 45 000 échanges dynamixel, zéro somme de contrôle fausse.

Ce que ça a rapporté

grandeur

avant

après

processeur réel par tour de boucle

42,03 ms

6,13 ms

cadence de boucle micro-ROS

18,3 Hz

132 Hz

base/odom

18 Hz

49,6 Hz

Le chemin de publication en ITCM

Le cache d’instructions ne suffisait pas à long terme. En un mois, la boucle est retombée de 105 Hz (12 septembre) à 27 Hz (14 octobre), par petites touches, sans qu’une seule modification en soit responsable : chaque fonctionnalité ajoutée aux tâches à 100 Hz — sécurité inertielle, zéro du gyroscope, journal — chassait un peu plus le code de micro-ROS des 16 Kio de cache, qui était alors relu depuis la QSPI.

Le bloc loop profile (voir plus bas) l’a montré : chaque publication coûtait 7 à 10 ms, quelle que soit sa taille, et 70 % de ce temps était du calcul — sérialisation, mise en trame octet par octet, CRC —, le reste l’attente de fin de chaque envoi DMA.

Le script de l’éditeur de liens (STM32H750XX_FLASH.ld) place donc en ITCM — 64 Kio couplés au cœur, zéro état d’attente, hors du bus AXI — le chemin de publication entier : sérialisation (microcdr), client XRCE et sa mise en trame, couche rmw, sérialiseurs des messages publiés, et le noyau FreeRTOS, qui tourne à chaque changement de tâche. 59 Kio sur 64. Le code est recopié de la flash au tout début de main(), avant HAL_Init et bien avant le premier appel au noyau. L’éditeur de liens insère seul les relais (veneers) des appels entre ITCM et QSPI.

Mesuré sur ratbot-1 le 2026-10-14, ratbot-profile 60, image ToF d’essai publiée dans les deux cas

grandeur

code en QSPI

chemin de publication en ITCM

cadence de boucle micro-ROS

23,2 Hz

175,4 Hz

processeur réel par tour

31,82 ms

4,33 ms

base/odom

23,2 Hz, 54 % de pas sautés

50,2 Hz, 0 pas sauté

IMU non publiées, sur 60 s

215

0

Warning

La liste existe deux fois dans le script : dans l’EXCLUDE_FILE de .text et dans .itcm. Un fichier ajouté à .itcm seulement reste en flash, car .text, écrit plus haut, le prend en premier. Et l’ITCM est presque pleine : ce qui y entre désormais doit avoir été mesuré.

Le Makefile refait l’édition de liens quand le script change (firmware-fixes le vérifie) : sans cela, un changement de placement ne prenait pas effet, sans un mot.

Note

Depuis l’activation du cache, la relecture de RAM au ST-LINK n’est plus fiable : le débogueur atteint la mémoire par la matrice de bus, sans traverser le cache, et peut lire des valeurs périmées. Seul g_fault est encore lu par cette voie, et les crochets de faute FreeRTOS appellent SCB_CleanDCache() avant de figer le cœur.

Perte de l’agent : le watchdog

La session micro-ROS est une poignée de main faite une seule fois : la carte déclare son nœud et ses entités, l’agent les enregistre. Si l’agent redémarre — Pi rebootée, service relancé, câble touché — il oublie tout. La carte, elle, ne le savait pas : elle continuait de publier dans le vide, vivante et muette, jusqu’à ce que quelqu’un appuie sur reset.

La reprise se fait par redémarrage, déclenché par le watchdog indépendant (IWDG1, 8 s). Un démontage et une reconstruction de toutes les entités ont été essayés — trois cents lignes sur sept fichiers — et ne tenaient pas. Le redémarrage garantit un état propre, et le watchdog rattrape en prime tout ce à quoi on n’a pas pensé : tâche coincée, interblocage.

Tout tient dans le critère de vie. La tâche traction ne caresse le chien de garde que si la tâche capteurs avance — son compteur d’acquisitions, qui tourne même si aucun capteur ne répond — et si ros_node_alive() le permet :

état de la session

vivant ?

on attend l’agent

oui — attendre la Pi est légitime, même des heures

établie

seulement si rmw_uros_sync_session() a réussi dans les 6 s

initialisation incomplète

non — le redémarrage est la reprise propre

Warning

Surveiller des tâches, jamais des composants. Le critère a d’abord porté sur le compteur d’acquisitions de la centrale inertielle. Débrancher celle-ci suffisait alors à faire redémarrer la carte en boucle — quatorze sessions micro-ROS en trois minutes, mesuré le 2026-09-12 — parce qu’un capteur absent et une tâche bloquée donnent exactement le même symptôme. Un capteur absent est une panne, qu’on signale en orange et en bips ; ce n’est pas une raison de relancer un robot qui roule très bien sans lui.

Warning

Ne pas fonder le critère sur les publications. Une publication best-effort réussit localement même quand la session est morte : elle écrit dans le tampon de sortie, et personne ne se plaint. Un premier chien de garde fondé sur le compteur d’odométrie n’a jamais mordu — IWDG1RSTF jamais levé. rmw_uros_sync_session est un aller-retour dans la session : un agent redémarré ne connaît plus notre clé de client et ne répond pas.

Le même appel, toutes les 2 s, recale l’horloge de la carte sur celle de l’agent — donc sur le serveur NTP du labo.

Mesuré, agent redémarré : odométrie perdue à +4 s, revenue à +26 s, cause_reset: 4 dans le bloc horloge. La RTC traverse ce redémarrage sans perdre l’heure.

Configuration de l’IWDG, d’après RM0433 §45.3.3 — et l’ordre est contre-intuitif : on démarre d’abord (0xCCCC, qui force aussi le LSI), puis 0x5555, PR, RLR, on attend SR = 0, et 0xAAAA. Dans l’ordre inverse, le LSI ne tourne pas encore, SR reste à 3 indéfiniment, et l’attente gelait la tâche traction. Le compteur est gelé pendant le débogage (DBGMCU_APB4FZ1), sinon halter le cœur au ST-LINK redémarre la carte. Option bytes : IWDG1_SW = 1, il ne démarre donc pas tout seul.

Horloge sur pile : RTC et quartz LSE

La RTC tourne sur le quartz 32,768 kHz (LSE) et, carte éteinte, sur la pile du VBAT. Elle ne tenait pourtant pas l’heure à travers une coupure — et ce n’était ni la pile ni le quartz.

Danger

Ne jamais armer le CSS du LSE (HAL_RCCEx_EnableLSECSS). ES0396 §2.2.19, présent sur notre révision V : quand ce CSS est actif et que VBAT et VDD sont séparés, une coupure de VDD rend le LSI instable, bien au-delà de 32 kHz. Or c’est le LSI qui cadence le CSS : il déclare mort un quartz en parfaite santé, le rattrapage de démarrage efface le domaine, et l’heure est perdue à chaque coupure.

Mesuré sur une coupure de 30 s : CSS actif → boot_lse_defaut=1, RTC repartie de zéro ; CSS éteint → boot_lse_pret=1, l’heure a continué. Contournement officiel de ST : « Clock the RTC with LSE […] without using the CSS on LSE. » scripts/firmware-fixes le vérifie, dans main.c et dans le .ioc.

Avec la pile, le domaine de sauvegarde persiste — y compris à travers un reflash. Un drapeau de panne verrouillé, ou un LSECSSON armé par un ancien firmware, survit indéfiniment. Le démarrage éteint donc un CSS hérité par un reset du domaine : une migration qui coûte l’heure une fois, sur les cartes concernées.

Le bloc horloge de /diagnostics dit ce qui s’est passé, grâce à une photographie du domaine prise en toute première instruction de main(), avant que le rattrapage ne l’efface :

au démarrage

diagnostic

boot_domaine_vierge=1

le domaine a perdu son alimentation : la pile ne fait rien

boot_lse_defaut=1

alimenté, mais le quartz a été déclaré en panne ; heure perdue

boot_lse_pret=1, sans défaut

l’heure a tenu

cause_reset distingue mise sous tension (1), bouton (2), reset logiciel (3) et watchdog (4) : le registre RCC_RSR est photographié puis effacé à chaque démarrage, sans quoi ses drapeaux s’accumulent et ne disent plus rien.

La chaîne de l’heure

serveur de temps du labo (192.168.1.190, RTC DS3232)
       │ NTP, seule source de la Pi (pools Internet désactivés)
       ▼
Raspberry Pi ── chrony
       │ rmw_uros_sync_session, toutes les 2 s
       ▼
ratbot_controller ── horodatage des messages
       │ écriture si l'écart dépasse 1 s
       ▼
RTC sur pile ── status/rtc ──► la Pi, quand elle démarre sans NTP

Qui fait foi. À l’ouverture de session, la plus avancée des deux heures gagne : si l’agent est à plus de 60 s derrière la RTC, la Pi a démarré sans heure — la carte garde sa RTC et le signale (pi_en_retard). Sinon la Pi fait foi et la carte écrit sa RTC. Ensuite on suit la Pi dans les deux sens — appliquer « la plus avancée gagne » en permanence ferait un cliquet, chaque robot suivant son quartz le plus rapide. Sauf un cas : une Pi reconnue fiable qui recule soudain de plus d’une minute n’est pas corrigée par NTP, elle est en panne ; la carte lui retire sa confiance plutôt que d’écrire une heure fausse dans la seule horloge qui tient hors tension.

Garde-fous : jamais d’écriture avant 2025 (RTC remise à zéro, ou Pi en 1970) ; écriture à la seconde près, donc un résidu d’une demi-seconde au plus — la RTC sert à donner une heure plausible, la précision vient de NTP.

Côté Pi, ratbot-time.service (ratbot time) démarre avec l’agent. Si chrony n’est pas synchronisé, il lit status/rtc et avance l’horloge si la RTC a plus d’une minute d’avance — jamais il ne la recule. Il compense l’âge du message grâce à header.stamp (594 ms d’écart sans cette correction, 19 ms avec), puis relance l’agent.

Warning

Trois pièges rencontrés en validant la chaîne, tous corrigés :

  • Cycle systemd. Avec WantedBy=multi-user.target, on obtenait multi-user → heure → agent → multi-user ; systemd a supprimé le démarrage du service. Il est désormais voulu par l’agent.

  • Lecture en root impossible. Fast DDS passe les données par mémoire partagée ; un lecteur root ne reçoit rien d’un agent qui tourne sous un autre utilisateur. La lecture se fait sous l’utilisateur de l’agent, seul date est en root.

  • Saut d’horloge sous un agent vivant. Après un saut de 44 jours, la liaison série restait pleine de trafic mais plus aucun nœud n’apparaissait dans le graphe : les entités DDS de l’agent, créées à l’ancienne date, étaient devenues invisibles. D’où la relance de l’agent après le réglage ; la carte se reconnecte seule par son watchdog.

Validé sur le robot le 2026-09-11 : Pi démarrée avec 44 jours de retard et sans serveur NTP, horloge reprise de la RTC puis agent relancé automatiquement ; au retour du serveur, premier échantillon de chrony à +0,27 ms.

Au passage, un défaut de la liaison série a été trouvé : une carte relancée pendant le redémarrage de la Pi reçoit du bruit, et la HAL abandonne la réception DMA sur la première erreur de trame sans jamais la relancer. La boucle d’attente de l’agent remet désormais la liaison à zéro toutes les cinq tentatives (cubemx_transport_reset).

Deux autres errata vérifiés et écartés : §2.2.14 (bits LSEDRV inversés) ne concerne que la révision Y ; §2.1.1 (corruption du cache D en write-through) exige des écritures en mémoire write-through, et notre seule région WT est la flash QSPI, en lecture seule.

Deux horloges, deux usages

Fonction

Usage

HAL_GetTick()

monotone et locale : sert à cadencer (« une seconde s’est-elle écoulée ? »). Insensible à une resynchronisation.

rmw_uros_epoch_nanos()

heure de l’agent, obtenue par rmw_uros_sync_session() : sert à dater les messages.

Les confondre casse quelque chose dans les deux sens : dater avec le tick local rendrait les timestamps incomparables avec ceux des autres machines du labo ; cadencer sur l’epoch ferait sauter la période à chaque resynchronisation.

Sans appel à rmw_uros_sync_session(), rmw_uros_epoch_nanos() renvoie 0 et tous les messages sortent horodatés à zéro.

Dater la mesure, pas la publication

Il ne suffit pas d’utiliser la bonne horloge : encore faut-il l’appliquer au bon instant. La tâche micro-ROS publie un instantané que la traction a rempli plus tôt, avec un retard variable — jusqu’à une quinzaine de millisecondes. Y écrire l’heure courante revient à affirmer que la donnée vient d’être mesurée.

L’instantané transporte donc le tick du pas qui l’a produit (traction_state_t::step_tick), et le message est daté :

stamp = rmw_uros_epoch_nanos() - (HAL_GetTick() - st->step_tick) * 1e6

L’âge vient de HAL_GetTick, monotone et locale, donc insensible à une resynchronisation ; seule l’origine vient de l’agent. Vérification sur 1000 messages : les écarts d’horodatage sont des multiples de 10 ms, la période de l’asservissement — preuve que les dates sont calées sur les pas de commande et non sur les instants de publication, qui tombent au rythme de la boucle.

Pour un filtre qui fusionne odométrie et centrale inertielle, quinze millisecondes d’erreur d’horodatage font plus de dégâts que quelques hertz de cadence en moins.

Qui publie quoi

Chaque fichier de ros/ correspond à un sous-système du Les topics du robot, ce qui rend le contrat traçable dans le code. La cadence n’est jamais écrite ici : elle est portée par le compteur du module qui fait la mesure.

Fichier

Topic / service

Sens

Cadence

D’où vient la donnée

ros_base

base/odom

⬆

50 Hz

traction.c, un pas d’asservissement sur deux

base/joint_states

⬆

10 Hz

même instantané que odom

base/cmd_vel_safe

⬇

à la demande

→ traction_set_twist(), watchdog 500 ms

reset_odometry

service

→ traction_reset_odometry()

ros_sensors

sensors/imu/data_raw

⬆

25 Hz

sensors.c, moyenne de 4 échantillons à 100 Hz

sensors/temperature

⬆

1 Hz

sensors.c, TMP102

sensors/battery

⬆

1 Hz

sensors.c, ADC2

ros_tof

sensors/tof/depth

⬆

15 Hz

tof.c ; datée à la mesure, rien sans capteur

ros_safety

status/emergency_stop

⬆

sur transition

safety.c, verrou lu à 100 Hz

rearm

service

→ safety_rearm(), refusé contact fermé ; même réarmement par 5 appuis sur SW1, dans safety_step()

ros_ui

ui/buttons

⬆

événement

board_io.c, tâche IHM

ui/leds · status/led · status/sound

⬇

→ voyants et buzzer

ros_screen

screen/button

⬆

événement

boutons de navigation de l’écran

screen/expression · screen/heartbeat

⬇

→ SSD1309, tâche micro-ROS

ros_diag

/diagnostics

⬆

1 Hz

les dix-huit blocs, 2 par message

ros_params

serveur de paramètres

config.c, persisté en flash U7

Le chemin d’une mesure, de bout en bout :

LSM6DSOX  ──I2C 400 kHz──►  sensors_step()      tâche capteurs, 100 Hz
                                 │  moyenne de 4
                                 ▼
                           instantané + imu_seq          seqlock
                                 │
                                 ▼
                           ros_sensors_spin()   tâche micro-ROS
                                 │  publie si imu_seq a changé
                                 ▼
                           sensors/imu/data_raw          25 Hz

Diagnostiquer

D’abord /diagnostics

ros2 topic echo /diagnostics

La carte y publie dix-huit blocs à 1 Hz, chacun avec un niveau et ses compteurs :

micro-ROS link      serial link         dynamixel bus
traction            wheel estimator     sensors
imu                 clock               emergency stop
flash and settings  screen              buzzer
black box           logbook             log dump
motion guard        tof                 loop profile

Le topic est absolu, comme le veut la convention ROS : un agrégateur unique voit toute la flotte, et rqt_runtime_monitor fonctionne sans configuration.

Note

Les dix-huit blocs partent en neuf messages de deux, étalés dans la seconde (DIAG_BLOCKS et DIAG_PER_MSG dans ros_diag.c). Un DiagnosticArray complet dépassait la MTU de 2048 octets du transport, et la panne était vicieuse : la taille dépend du nombre de chiffres des compteurs. Le message passait au démarrage puis échouait une fois ceux-ci à quatre chiffres — 12 publications réussies, puis 166 échecs d’affilée, sans qu’aucun code ne change.

Chaque chaîne est donc bornée et un _Static_assert vérifie à la compilation que le pire cas tient. Et si un module déclare plus de paires que RATBOT_DIAG_KV_MAX, le bloc passe en WARN et le dit : une API de diagnostic ne perd jamais une donnée en silence.

Le robot est identifié deux fois, et ce n’est pas une redondance inutile : hardware_id est ce que lit un agrégateur, mais le nom du bloc est préfixé lui aussi (ratbot_1: traction) pour qu’un topic echo brut reste lisible quand quatre robots publient sur le même topic.

# tout ce qui ne va pas, sur toute la flotte
ros2 topic echo /diagnostics | grep -B1 -A1 "message:" | grep -v "nominal\|etablie"

# un seul robot
ros2 topic echo /diagnostics | grep -A3 "ratbot_2:"

Un extrait qui vaut mieux qu’une description :

- level: "\x01"
  name: traction
  message: une seule roue repond
  hardware_id: ratbot_1
  values:
  - {key: wheels_online,       value: '1'}
  - {key: blind_runs,          value: '12'}

Warning

wheels_online est un masque de bits, pas un compte : bit 0 la roue gauche, bit 1 la droite. Les deux roues en ligne valent donc 3, et la valeur 1 ci-dessus veut dire « seule la gauche répond ». Un outil de supervision qui comparerait à 2 se tromperait.

Note

Un arrêt d’urgence actif est un WARN, pas une ERROR. Ce n’est pas une panne : c’est un état voulu. Le classer en erreur noierait les vraies pannes chaque fois que quelqu’un appuie sur le champignon.

Mesurer les performances

Warning

Ne pas juger la carte avec ros2 topic hz. Il échantillonne des arrivées sur la Raspberry : ordonnanceur Linux, DDS, lecture du lien série par paquets. Sur des versions identiques du firmware il a rendu 18,0 / 18,9 / 19,0 / 20,3 Hz — ±10 %, de quoi valider n’importe quelle conclusion.

scripts/ratbot-profile lit les compteurs de la carte dans /diagnostics et donne la même chose à ±0,03 % :

./scripts/ratbot-profile 60          # durée en secondes
cadence de boucle           132,33 Hz   (+/- 0,02 %)
processeur traction          10,6 %
processeur capteurs           8,3 %
reste à micro-ROS            81,1 %
>> CPU RÉEL par tour          6,13 ms <<
memcpy 4 Kio                   152 µs   (témoin du cache D)
pas d'asservissement         99,09 Hz
>> odom publié              49,55 Hz <<
pas sautés                        0    (0,0 %)

Où part le tour. Le bloc loop profile donne, pour chaque étape de la boucle (executor, sensors, tof, base, diag…), le temps qu’elle a pris dans la dernière seconde, en microsecondes : une étape qui publie rarement y compte à son juste poids. Le bloc serial link cumule depuis le démarrage les envois (tx_calls), les kilo-octets envoyés (tx_kib) et l’attente de fin de DMA (tx_wait_ms) : la différence entre deux relevés dit si une publication coûte du calcul ou du fil. Avec l’ITCM, l’attente pèse encore ~20 % de la boucle : le levier suivant serait un envoi qui n’attend pas la fin du précédent. Le 2026-10-14, avant l’ITCM : 868 envois par seconde de 40 octets — la mise en trame XRCE vide son tampon de 42 octets à chaque remplissage —, 0,35 ms d’attente chacun, 30 % de la boucle.

Le nombre à suivre est le CPU réel par tour :

CPU réel par tour = (1 s − traction − capteurs) / tours par seconde

boucle_us mesure du temps au mur. Le compteur de cycles du DWT continue de tourner pendant les préemptions, donc ce chiffre inclut le temps volé par les tâches matérielles : il bouge quand celles-ci changent, même si micro-ROS n’a pas bougé d’un iota. C’est ce qui a fait conclure trop vite que rcl_publish coûtait 24 ms — il en coûtait 18.

Les deux tâches matérielles publient en revanche un chiffre honnête (cpu_us_par_s) : la traction est la plus prioritaire, donc jamais préemptée, et les capteurs ne le sont que par elle.