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``. .. code-block:: text 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 : :doc:`traction` pour l'estimateur de roue, l'asservissement et l'odométrie, et :doc:`banc_de_test` 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 :doc:`../ros2/topics`, 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. .. list-table:: Les tâches, de la plus prioritaire à la moins :header-rows: 1 :widths: 14 14 10 10 52 * - 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 : .. code-block:: text 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``) : .. code-block:: c 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. .. code-block:: c 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 :doc:`bootloader`. **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** ``__init()`` **sur un message statique.** .. code-block:: c /* 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 : .. list-table:: :header-rows: 1 :widths: 8 22 20 50 * - 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) : .. code-block:: text 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é ~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 * - 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. .. list-table:: Mesuré sur ratbot-1 le 2026-10-14, ``ratbot-profile 60``, image ToF d'essai publiée dans les deux cas :header-rows: 1 * - 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 : .. list-table:: :header-rows: 1 :widths: 30 70 * - é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 : .. list-table:: :header-rows: 1 :widths: 30 70 * - 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 ~~~~~~~~~~~~~~~~~~~~ .. code-block:: text 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 -------------------------- .. list-table:: :header-rows: 1 :widths: 34 66 * - 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é : .. code-block:: c 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 :doc:`../ros2/topics`, 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. .. list-table:: :header-rows: 1 :widths: 18 26 8 14 34 * - 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 : .. code-block:: text 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`` ~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: bash ros2 topic echo /diagnostics La carte y publie **dix-huit blocs à 1 Hz**, chacun avec un niveau et ses compteurs : .. code-block:: text 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. .. code-block:: bash # 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 : .. code-block:: yaml - 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. .. code-block:: bash arm-none-eabi-nm build/uROSProjet.elf | grep -E "g_uros|g_sensors|g_ui" STM32_Programmer_CLI -c port=SWD mode=HOTPLUG -r32 0x18 .. list-table:: :header-rows: 1 :widths: 18 82 * - Structure - Contenu * - ``g_uros`` - ``state`` (1 transport, 2 agent trouvé, 3 prêt), ``ping_fails``, ``spins``, ``time_synced``, et un drapeau par bloc initialisé * - ``g_sensors`` - compteurs de publication, d'erreur de lecture, dernières valeurs brutes * - ``g_ui`` - publications ``Joy``, réceptions par abonnement, état des boutons 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 % : .. code-block:: bash ./scripts/ratbot-profile 60 # durée en secondes .. code-block:: text 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** : .. code-block:: text 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.