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.cen est directement issu ;le passage des dynamixels aux moteurs CC ne touchera que
drivers/:ros_basepublieraodomsans 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.
Tâche |
Priorité |
Cadence |
Pile |
Ce qu’elle fait, et ce qu’elle possède |
|---|---|---|---|---|
|
AboveNormal |
100 Hz |
4 Kio |
Possède : UART4 et UART8 (dynamixel), SPI1 (flash) au démarrage. |
|
Normal |
100 Hz |
2 Kio |
Possède : I2C3, ADC2. |
|
Normal |
15 Hz |
2 Kio |
|
|
Normal |
50 Hz |
2 Kio |
|
|
BelowNormal |
libre |
12 Kio |
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.
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 |
|
8 Mio |
Normal, write-through, cacheable, exécutable |
1 |
|
512 Kio |
Normal, write-back write-allocate |
2 |
|
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 |
|
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.
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 |
|
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 |
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 |
|---|---|
|
le domaine a perdu son alimentation : la pile ne fait rien |
|
alimenté, mais le quartz a été déclaré en panne ; heure perdue |
|
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
dateest 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 |
|---|---|
|
monotone et locale : sert à cadencer (« une seconde s’est-elle écoulée ? »). Insensible à une resynchronisation. |
|
heure de l’agent, obtenue par |
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 |
|---|---|---|---|---|
|
|
⬆ |
50 Hz |
|
|
⬆ |
10 Hz |
même instantané que |
|
|
⬇ |
à la demande |
→ |
|
|
service |
→ |
||
|
|
⬆ |
25 Hz |
|
|
⬆ |
1 Hz |
|
|
|
⬆ |
1 Hz |
|
|
|
|
⬆ |
15 Hz |
|
|
|
⬆ |
sur transition |
|
|
service |
→ |
||
|
|
⬆ |
événement |
|
|
⬇ |
→ voyants et buzzer |
||
|
|
⬆ |
événement |
boutons de navigation de l’écran |
|
⬇ |
→ SSD1309, tâche micro-ROS |
||
|
|
⬆ |
1 Hz |
les dix-huit blocs, 2 par message |
|
serveur de paramètres |
|
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.
Ensuite seulement, le ST-LINK
Il n’y a pas d’UART de debug sur cette carte — PB3 sert de MISO à la
flash de données, donc pas de SWO ni de printf par ITM. Chaque bloc expose
donc aussi sa structure en RAM, relisible au ST-LINK.
Danger
Cette méthode n’est plus fiable depuis l’activation du cache de données. Le débogueur atteint la RAM par la matrice de bus, sans traverser le cache : une valeur que le processeur vient d’écrire peut encore être dans le cache et pas en mémoire. On lit alors du périmé, voire des octets jamais écrits.
Elle demande en outre un débogueur branché, de connaître les adresses et de décoder les octets à la main — et les adresses bougent à chaque recompilation. C’était la seule option pendant le bring-up, quand il n’y avait ni lien série ni agent.
Le seul état encore lu par cette voie est g_fault : après un débordement
de pile il n’y a plus de /diagnostics pour le raconter. Les deux crochets
de faute FreeRTOS appellent donc SCB_CleanDCache() avant de figer le
cœur, ce qui rend la structure visible au débogueur.
arm-none-eabi-nm build/uROSProjet.elf | grep -E "g_uros|g_sensors|g_ui"
STM32_Programmer_CLI -c port=SWD mode=HOTPLUG -r32 <adresse> 0x18
Structure |
Contenu |
|---|---|
|
|
|
compteurs de publication, d’erreur de lecture, dernières valeurs brutes |
|
publications |
Les compteurs de réception valent d’être lus même quand l’effet n’est pas encore implémenté : ils prouvent que le chemin ROS → carte fonctionne, sans attendre le matériel.
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.
Comment l’état traverse les tâches
Chaque module matériel publie un instantané protégé par un seqlock (
App/rt_state.h) :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.
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/odomva plus loin : il est calé sur le compteur de pas de l’asservissement, un pas sur deux, et rattrape son retard (+= 2plutô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
osDelayUntilsur 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é.