Communication avec la carte STM32

Principe

La carte STM32 exécute un client micro-ROS (voir le chapitre Monter un projet FreeRTOS + micro-ROS). Elle ne parle pas DDS directement : ses nœuds, publications et abonnements sont proxifiés par l’agent micro-ROS qui tourne sur la Raspberry Pi. Vu du graphe ROS 2, les topics du firmware sont des topics ordinaires.

digraph transport { rankdir=LR; bgcolor="transparent"; node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=10, margin="0.2,0.12"]; edge [fontname="sans-serif", fontsize=9]; stm32 [label="STM32\nclient micro-ROS", fillcolor="#ffe6cc", color="#d79b00"]; agent [label="Raspberry Pi 4\nagent micro-ROS", fillcolor="#dae8fc", color="#6c8ebf"]; graphe [label="graphe ROS 2", shape=ellipse, fillcolor="#d5e8d4", color="#82b366"]; stm32 -> agent [label=" XRCE-DDS sur série\n UART5, 3 Mbaud", dir=both]; agent -> graphe [label=" DDS"]; }

Le client embarqué, l’agent, et le graphe ROS 2.

Transport

Décidé et validé le 2026-09-06 : UART5 + DMA. Porté à 3 Mbaud le 2026-09-08. L’USB CDC est abandonné — aucun périphérique USB n’existe dans le .ioc, et la carte n’a pas de connecteur pour ça.

Côté carte

UART5, connecteur J1 « SERIAL ROS » (JST PH 3 points) : J1.1 = RX_ROS = PB12, J1.2 = TX_ROS = PB13, J1.3 = GND

Côté Pi

/dev/ttyAMA0 — le PL011, basculé sur GPIO 14/15 par dtoverlay=disable-bt

Transport

dma_transport.c, sélectionné dans le Makefile

Débit

3 Mbaud, exact des deux côtés : noyau UART5 = D2PCLK1 = 60 MHz, suréchantillonnage 16 → USARTDIV = 20 pile ; et c’est aussi le plafond exact du PL011 de la Raspberry (48 MHz ÷ 16 ÷ 3 MHz = 1). Il n’y a que 15 cm de fil entre la carte et la Pi.

Préparer la Raspberry

Par défaut l’UART GPIO est occupé par la console noyau et un getty, et c’est le mini-UART (ttyS0) qui y est câblé — son débit dérive de l’horloge du VPU, donc inutilisable à ce débit. Trois corrections :

# 1. liberer l'UART de la console noyau
sudo sed -i "s/console=serial0,115200 //" /boot/firmware/cmdline.txt
# 2. basculer le PL011 (ttyAMA0, debit stable) sur GPIO 14/15
echo "dtoverlay=disable-bt" | sudo tee -a /boot/firmware/config.txt
# 3. couper le getty serie
sudo systemctl disable --now serial-getty@ttyS0.service
sudo reboot

Après reboot, /dev/serial0 pointe sur ttyAMA0, en groupe dialout.

Warning

Le DMA du STM32H7 n’atteint pas la DTCMRAM. Les contrôleurs DMA1/DMA2 vivent dans le domaine D2 ; la TCM est câblée directement au cœur, hors de la matrice de bus qu’ils voient. Tout tampon qu’ils touchent doit être ailleurs — sinon le transfert reste figé, sans aucun drapeau d’erreur.

Trois tampons sont concernés, et les trois ont mordu successivement :

  • le tampon de réception de dma_transport.c ;

  • le tas de micro-ROS (custom_memory_manager.c), d’où viennent les messages sérialisés ;

  • le tampon d’émission, qui ne nous appartient pas : micro-ROS passe un pointeur sur son propre tas FreeRTOS. D’où un tampon de transit en RAM_D2, avec recopie avant émission.

Tous sont placés dans la section .dma_buffer (RAM_D2, 0x30000000) définie par le linker, et les horloges des SRAM D2 doivent être activées (RCC->AHB2ENR) — sans elles les écritures partent dans le vide.

Symptôme à reconnaître : DMAx_Streamy->NDTR figé et M0AR pointant dans 0x2000xxxx.

Note

it_transport.c (une interruption par octet) convient à la mise au point, mais pas au nominal : à 1 Mbaud il faut déjà ~100 000 interruptions par seconde — trois fois plus à 3 — et depuis la flash QSPI le gestionnaire n’est plus assez rapide. La réception part alors en ORE (overrun) et la HAL cesse définitivement de recevoir. Mesuré : UART5->ISR = 0x006210F8.

Le domaine DDS se fixe dans le firmware

Piège non évident : ROS_DOMAIN_ID côté Raspberry ne configure que les processus du Pi. Le participant DDS que l’agent crée pour le compte de la carte suit le domaine demandé par le client, qui vaut 0 par défaut. Sans l’appel ci-dessous, le robot publie hors de portée du reste de la flotte alors que tout semble fonctionner côté carte :

rcl_init_options_set_domain_id(&init_options, RATBOT_ROS_DOMAIN_ID);
rclc_support_init_with_options(&support, 0, NULL, &init_options, &allocator);

Pourquoi la cinématique est dans le firmware (et pas ros2_control)

Décision (2026-07) : c’est le firmware qui convertit cmd_vel en vitesses de roues (cinématique inverse) et la position des roues en odom (cinématique directe). L’alternative envisagée était le diff_drive_controller de ros2_control sur la Raspberry Pi, la carte ne recevant que des consignes de vitesses de roues. Arguments de la décision :

  • Sécurité au bon endroit : le watchdog (silence de cmd_vel > ~500 ms → arrêt) vit dans la carte, au plus près des moteurs, et fonctionne même si la Pi ou le WiFi est hors service.

  • Les dynamixels asservissent déjà la vitesse : il ne reste au firmware que les deux formules de la cinématique différentielle (~30 lignes).

  • Le passage à des moteurs CC ne change pas la donne (la carte le prévoit) : la boucle d’asservissement PID (~1 kHz) devrait tourner dans le STM32 dans les deux options — ros2_control envoie des consignes de roues et suppose que quelqu’un les réalise en dessous. Le travail firmware serait le même.

  • La mutualisation avec l’URDF est un faux avantage : diff_drive_controller lit wheel_separation/wheel_radius dans son YAML, pas dans l’URDF — il y aurait deux sources dans les deux cas. Le vrai avantage (régler sans reflasher) est conservé : les paramètres de roues du firmware seront configurables depuis la Pi (paramètres micro-ROS, TODO).

  • Moins de trafic et de fragilité : pas de boucle de commande à 50–100 Hz qui traverse la liaison série, pas de hardware interface micro-ROS à écrire ni de controller_manager à configurer.

Cette décision est réversible et invisible de l’extérieur : le contrat public reste base/cmd_vel/base/odom. Si un besoin réel de ros2_control apparaît (bras manipulateur, contrôleurs avancés…), le pont pourra être écrit sans rien changer pour les projets hôtes.

Messages échangés

Le firmware est organisé en threads FreeRTOS, chacun responsable d’un sous-système. Le tableau donne la correspondance entre les threads et les topics du contrat (Les topics du robot) :

Thread FreeRTOS

Topics

Remarques

Moteurs de traction

base/cmd_vel_safe (⬇), base/odom (⬆), base/joint_states (⬆)

Le firmware s’abonne à base/cmd_vel_safe, la sortie du garde-barrière cmd_vel_gate (voir Les topics du robot, arrêt global). Watchdog : arrêt si silence > ~500 ms.

IMU (I2C)

sensors/imu/data_raw (⬆)

Données brutes ; la fusion se fait sur la Pi.

Température carte (TMP102)

sensors/temperature (⬆)

Capteur entre les deux régulateurs de courant.

Écran + 6 boutons de navigation

(aucun)

Menu local du robot, interne au firmware.

4 boutons + 4 LEDs utilisateur

ui/buttons (⬆), ui/leds (⬇)

Buzzer actif

(aucun — reflet dans /diagnostics )

Alertes de sécurité autonomes (température, batterie, danger), indépendantes de ROS.

Buzzer passif

ui/buzzer/play (⬇)

Mélodies et alertes sonores commandées depuis ROS.

Capteurs additionnels

TODO

Prévu pour des extensions futures.

TODO : sensors/battery (quel thread ?), service reset_odometry, et la politique QoS de chaque topic (best effort pour les flux périodiques, reliable pour les événements).

Dimensionnement de la liaison

Une liaison série à 3 Mbaud (8N1) transporte au mieux ~300 ko/s. Charge réellement mesurée aujourd’hui :

  • base/odom à 50 Hz : ~38 ko/s (message d’environ 750 octets, dominé par les deux matrices de covariance) ;

  • sensors/imu/data_raw à 25 Hz : ~8 ko/s ;

  • base/joint_states à 10 Hz, /diagnostics à 1 Hz en 5 morceaux, status/* et événements : ~7 ko/s.

Soit ~53 ko/s, moins d’un cinquième du lien — et environ 92 messages par seconde.

Important

La vraie limite n’a jamais été le débit du fil, c’est le nombre de messages par seconde — et ce plafond a été levé deux fois depuis.

Essai de charge, un Int32 publié aussi vite que possible :

Débit du lien

Cadence obtenue

Occupation du fil

1 Mbaud

241,5 Hz

~12 %

3 Mbaud

240,6 Hz

~4 %

Tripler le débit ne changeait rien, parce que le plafond était ailleurs : cubemx_transport_write attendait la fin du DMA avec osDelay(1), soit un tick FreeRTOS entier (1 ms) pour un transfert de 170 µs. Chaque message coûtait plusieurs ticks, quel que soit le débit.

Premier correctif (2026-09-08) : attente serrée par taskYIELD() au lieu du sommeil arrondi au tick. On paie le temps réel du DMA, pas un multiple du tick.

Second correctif (2026-09-10) : activation du cache de données, qui a divisé par sept le temps processeur d’un tour de boucle micro-ROS (42,0 ms → 6,1 ms). Voir Architecture de l’application firmware.

Conséquence pour la conception : compter en messages par seconde, pas en octets. La charge actuelle est de ~92 messages/s pour une boucle qui tourne à 132 Hz — elle n’est donc plus saturée par la publication.

Note

Le plafond n’a pas été re-mesuré depuis ces deux correctifs. L’ancien chiffre de ~240 messages/s ne vaut plus, et il n’y a pas de raison de le remesurer tant que la marge est celle-là. À refaire le jour où le lidar viendra.

Le levier suivant, s’il en faut un : rclc_executor_spin_some coûte 2,5 ms à chaque tour de boucle, soit environ un tiers du processeur passé à guetter des commandes qui arrivent à 20 Hz au plus. Le limiter libérerait beaucoup, au prix d’un peu de latence sur cmd_vel.

Robustesse de la liaison

Quatre défauts trouvés le 2026-09-24, en cherchant pourquoi une carte parfaitement vivante redémarrait en plein essai. Ils sont liés, et les mesures qui suivent viennent du même enregistrement.

La consigne cmd_vel doit être en best effort

rclc_subscription_init_default crée une souscription fiable. Sur la voie descendante, une trame perdue bloque alors tout ce qui suit, le temps de la retransmission.

Ce que ça donnait sur un carré : à l’ordre d’arrêt, le robot continue de tourner à 28 °/s constants pendant que la consigne descend de 0,5 à 0 en 0,9 s. Il s’arrête net 0,35 s après le dernier zéro — c’est le délai de garde cmd_vel_timeout_ms qui tombe, pas la consigne qui arrive. Entre-temps, 12 degrés de trop à chaque virage.

Passée en rclc_subscription_init_best_effort : une consigne perdue est simplement remplacée par la suivante, 50 ms plus tard.

fiable

best effort

dépassement par virage

+12°

+2,7°

synchronisations d’horloge ratées par essai

32

0 à 3

Note

Le second chiffre a résolu un mystère qui traînait : rmw_uros_sync_session échouait systématiquement dès que le robot roulait. Même cause — la voie descendante était saturée par les retransmissions.

Un message reçu prouve que la session vit

Le chien de garde considérait la liaison morte si la synchronisation d’horloge échouait pendant SYNC_DEAD_MS (6 s). Comme elle échouait à chaque trajet, il relançait une carte en parfait état de marche, en plein essai.

La liveness a donc deux sources, et il suffit de l’une :

synchronisation réussie dans les 6 s      (SYNC_DEAD_MS)
OU un message reçu dans les 3 s           (RX_ALIVE_MS)

ros_node_note_rx() est appelé depuis chaque callback d’abonnement — cmd_vel, les LED, le buzzer, l’écran. Un agent qui parle est un agent vivant, quelle que soit l’opinion de l’horloge.

Une erreur de réception ne doit pas rendre la carte sourde

La HAL traite toute erreur de réception comme bloquante : bruit de ligne, débordement, erreur de trame — le DMA s’arrête et ne redémarre pas tout seul. La carte restait sourde jusqu’au chien de garde.

HAL_UART_ErrorCallback relance donc la réception, et compte. Le bloc serial link de /diagnostics expose :

framing  noise  overrun  parity     les erreurs, par nature
restarts                            relances de la réception DMA
restart_ko                          relances qui ont échoué

La relance se synchronise avec le chemin de lecture : les deux ne doivent pas réarmer le DMA en même temps.

Tip

Le PL011 du Raspberry produit 3 à 4 débordements par minute à 3 Mbaud (visibles dans /proc/tty/driver/ttyAMA, colonne oe). Ce n’est pas la cause des pertes de liaison, mais ça explique que le compteur ne reste pas à zéro.

Ne pas attendre 1 seconde sur une publication fiable

Sur un topic fiable, le client attend l’acquittement de l’agent jusqu’à RMW_UXRCE_PUBLISH_RELIABLE_TIMEOUT, 1000 ms par défaut. Une seule publication qui traîne fait chuter toutes les cadences.

ros_pub_sans_attente() ramène ce délai à ROS_RELIABLE_WAIT_MS (5 ms) — mais seulement après l’initialisation, et seulement sur les quatre publishers périodiques.

Danger

Appliqué pendant l’initialisation, ce réglage provoque une boucle de redémarrage : le chien de garde tombe toutes les 12 secondes. La création des entités a besoin du délai long. Le correctif a dû être annulé une première fois pour cette raison.

Limites du client micro-ROS

Le nombre d’entités micro-ROS est figé à la compilation de la bibliothèque statique (colcon.meta, section rmw_microxrcedds). Les changer impose de régénérer la lib, pas seulement de recompiler le firmware.

Valeurs retenues le 2026-09-06, MAX_SERVICES relevé le 2026-09-07 :

Paramètre

Avant

Après

Pourquoi

RMW_UXRCE_MAX_PUBLISHERS

10

24

marge confortable pour tout le contrat

RMW_UXRCE_MAX_SUBSCRIPTIONS

5

16

le contrat en demande déjà cinq (cmd_vel_safe, ui/leds, status/sound, screen/command, screen/heartbeat) : on était exactement à la limite

RMW_UXRCE_MAX_SERVICES

1

16

le serveur de paramètres en consomme 5 à lui seul (get, get_types, set, list, describe). Avec rearm on était à 6, et les trois services déjà prévus au contrat auraient débordé les 8 initiaux

RMW_UXRCE_MAX_NODES / MAX_CLIENTS

1 / 1

2 / 2

marge

RMW_UXRCE_MAX_HISTORY

4

4

délibérément inchangé — voir l’avertissement ci-dessous

UCLIENT_CUSTOM_TRANSPORT_MTU

512

2048

un nav_msgs/Odometry fait ~750 octets : il ne tenait pas dans un message

Warning

``MAX_HISTORY`` et le MTU se multiplient. La mémoire consommée est de l’ordre de entités × history × MTU : gonfler les deux en même temps est la façon classique de faire exploser la RAM. Ici, passer les entités de 16 à 48 au total a fait grimper .bss de 61 à 109 Ko, ne laissant que 21 Ko de DTCMRAM. D’où la bascule de .bss vers l’AXI SRAM (512 Ko), décrite dans Bootloader et exécution depuis la flash externe.

L’autre limite, celle qu’on rencontre en premier

Les valeurs ci-dessus ne sont pas les seules bornes. L’exécuteur rclc a la sienne, et elle vit dans le firmware — pas dans la bibliothèque :

/* App/ros/ros_node.c */
#define ROS_EXECUTOR_HANDLES   16u

Un handle est consommé par chaque abonnement, minuterie ou service servi par l’exécuteur. Le serveur de paramètres en prend 5, comme ses services.

Danger

Le dépassement est SILENCIEUX. rclc_executor_add_subscription() renvoie une erreur que rien n’oblige à lire : si on l’ignore, l’abonnement est créé, visible dans ros2 topic list, apparié côté DDS — et jamais servi. Aucun message n’arrive, aucune erreur n’est signalée.

Tous les rclc_executor_add_* du projet testent leur retour. Ne pas déroger : c’est le genre de panne qu’on cherche pendant des heures du mauvais côté du lien.

Cette limite-là ne demande pas de régénérer la bibliothèque : c’est un simple #define, une recompilation du firmware suffit.

Télécharger la bibliothèque précompilée

libmicroros.a et ses en-têtes microros_include/ ne sont pas dans le dépôt (47 Mio, régénérables). Pour compiler le firmware sans passer par Docker, la bibliothèque utilisée par les robots du parc est proposée en téléchargement : libmicroros_ratbot_humble.tar.gz (9,7 Mo), avec son empreinte SHA-256.

Depuis firmware/ratbot_firmware_card/ :

curl -fLO https://dev.ratbot.org/fichiers/libmicroros_ratbot_humble.tar.gz
curl -fLO https://dev.ratbot.org/fichiers/libmicroros_ratbot_humble.tar.gz.sha256
sha256sum -c libmicroros_ratbot_humble.tar.gz.sha256
tar -xzf libmicroros_ratbot_humble.tar.gz \
    -C micro_ros_stm32cubemx_utils/microros_static_library

L’archive contient libmicroros/ tel que le produit l’étape Docker ci-dessous (ROS 2 Humble, colcon.meta du dépôt), plus le colcon.meta utilisé et les licences de micro-ROS (Apache-2.0). Elle n’est à reprendre que si colcon.meta change : la bibliothèque est alors à régénérer, et l’archive à remplacer sur le serveur (dossier fichiers/ du site, que le déploiement de la doc ne touche pas).

Régénérer la bibliothèque

Important

colcon.meta existe en deux exemplaires, un par dossier de génération :

micro_ros_stm32cubemx_utils/microros_static_library/library_generation/colcon.meta
micro_ros_stm32cubemx_utils/microros_static_library_ide/library_generation/colcon.meta

Modifier les deux. N’en changer qu’un donne une bibliothèque dont les limites dépendent du dossier utilisé pour la générer — et l’écart ne se voit qu’à l’exécution, quand une entité de trop échoue sans bruit.

Depuis la racine du projet firmware, après avoir modifié colcon.meta :

docker run -it --rm -v $(pwd):/project \
  --env MICROROS_LIBRARY_FOLDER=micro_ros_stm32cubemx_utils/microros_static_library \
  microros/micro_ros_static_library_builder:humble

Note

libmicroros.a (36 Mio) et microros_include/ sont dans le .gitignore : un clone frais ne peut pas compiler le firmware sans cette étape Docker ou le téléchargement de l’archive précompilée (ci-dessus).

Si le conteneur répond Permission denied sur library_generation.sh, c’est que le bit exécutable manque (chmod +x). Il est désormais versionné.

Après régénération, tout reflasher et revalider : la bibliothèque change intégralement.