TP 1 — Premier nœud micro-ROS

Objectifs

  • comprendre ce que sont un nœud, un topic et un message ROS 2 ;

  • intégrer micro-ROS à un projet CubeIDE avec FreeRTOS ;

  • faire publier par la carte l’état d’un bouton et un compteur, et les lire sur un ordinateur avec les outils de ROS 2.

Durée indicative : deux séances de 3 h. Prérequis : la partie 2, en particulier TP 6 — Le DMA et TP 7 — Un programme en flash externe.

Matériel : la carte, son câble de programmation, un adaptateur USB-série 3,3 V branché sur le connecteur J1 (comme au TP 5 de la partie 1), et un ordinateur sous Linux avec ROS 2 Humble et Docker.

Ce qu’il faut savoir

ROS 2 en quelques mots

Un programme ROS 2 est fait de nœuds (nodes) : des programmes qui s’échangent des messages sur des topics, des canaux nommés comme /ratbot_1/base/cmd_vel. Un nœud publie sur un topic, d’autres s’y abonnent ; aucun ne connaît les autres. Chaque topic a un type de message, par exemple std_msgs/msg/Bool (un booléen) ou sensor_msgs/msg/Temperature.

Les nœuds se découvrent seuls sur le réseau, grâce à DDS, la couche de communication de ROS 2. Tous ceux qui partagent le même numéro de domaine (ROS_DOMAIN_ID) se voient.

micro-ROS et l’agent

Un microcontrôleur n’a ni la mémoire ni le réseau qu’il faudrait pour parler DDS. micro-ROS lui fournit une version allégée de ROS 2 : la carte parle un protocole compact, XRCE-DDS, sur une simple liaison série, à un programme qui tourne sur l’ordinateur, l”agent. L’agent crée de l’autre côté les vrais nœuds et topics DDS, au nom de la carte :

Figure made with TikZ

La carte parle XRCE-DDS à l’agent, qui la représente dans le monde ROS 2.

C’est exactement ce que fait le robot : sa carte est reliée au Raspberry Pi par UART5, à 3 Mbit/s, et l’agent tourne sur le Raspberry Pi.

Un numéro de domaine par poste

Dans une salle de TP, toutes les cartes publieraient le même topic, et chaque poste verrait celles des voisins. Chaque poste utilise donc son propre domaine : 10 + le numéro du poste (11 pour le poste 1, etc.).

Avertissement

N’utilisez jamais le domaine 77 : c’est celui des robots. Une carte de TP qui y publierait apparaîtrait au milieu de la flotte.

Le domaine se règle des deux côtés : dans le programme de la carte, et sur l’ordinateur, avec export ROS_DOMAIN_ID=11.

Étape 1 — Le projet CubeMX

  1. Créez un projet ros_tp01_noeud : FREERTOS en CMSIS_V2, Timebase Source sur TIM6, LED_1 à LED_4 en sortie, SW1 en entrée sans tirage (partie 2, TP 1).

  2. UART5, sur PB12 (RX) et PB13 (TX), en Asynchronous, 115 200 bit/s, 8N1.

  3. Onglet DMA Settings de UART5 : ajoutez UART5_RX en mode Circular et UART5_TX en mode Normal, toutes deux en priorité Very High. Onglet NVIC Settings : activez UART5 global interrupt.

  4. FREERTOS → Config parameters : TOTAL_HEAP_SIZE à 65536. Tasks and Queues : la tâche defaultTask reçoit une pile de 3000 mots (12 Kio : micro-ROS en demande plus de 10).

  5. CORTEX_M7 : MPU NOT USED, I-cache activé, D-cache désactivé (partie 2, TP 6 et 7).

  6. Générez le code.

Étape 2 — Ajouter micro-ROS au projet

micro-ROS est fourni sous forme d’une bibliothèque statique, compilée pour le Cortex-M7 et pour votre projet, dans un conteneur Docker.

  1. Dans le dossier du projet :

    git clone -b humble https://github.com/micro-ROS/micro_ros_stm32cubemx_utils.git
    
  2. Project → Properties → C/C++ Build → Settings → Build Steps → Pre-build steps, sur une seule ligne :

    docker pull microros/micro_ros_static_library_builder:humble && docker run --rm -v ${workspace_loc:/${ProjName}}:/project --env MICROROS_LIBRARY_FOLDER=micro_ros_stm32cubemx_utils/microros_static_library_ide microros/micro_ros_static_library_builder:humble
    
  3. MCU GCC Compiler → Include paths : ajoutez micro_ros_stm32cubemx_utils/microros_static_library_ide/libmicroros/include.

  4. MCU GCC Linker → Libraries : dans Library search path, le chemin absolu de micro_ros_stm32cubemx_utils/microros_static_library_ide/libmicroros ; dans Libraries, microros.

  5. Copiez dans Core/Src ces quatre fichiers du dossier micro_ros_stm32cubemx_utils/extra_sources : microros_time.c, microros_allocators.c, custom_memory_manager.c, et microros_transports/dma_transport.c.

  6. Compilez. Le premier build est long (une dizaine de minutes) : la bibliothèque est construite. Les suivants la réutilisent.

Étape 3 — Faire tenir le programme

La compilation échoue sans doute à l’édition de liens, avec deux messages :

region `FLASH' overflowed by ... bytes
region `DTCMRAM' overflowed by ... bytes
  1. La flash. Le programme dépasse les 128 Kio de la flash interne : il doit vivre en flash externe, exactement comme au TP 7 de la partie 2 (région FLASH à 0x90000000, __enable_irq() au début de main, programmation avec le greffon QSPI, -rst).

  2. La RAM. Dans le script d’édition de liens, faites passer .bss (et ._user_heap_stack si besoin) de DTCMRAM à la grande RAM AXI (RAM_D1 ou RAM), en remplaçant >DTCMRAM par >RAM_D1 à la fin de la section.

Questions

  1. Quelle taille fait le programme (arm-none-eabi-size, ou la console de CubeIDE) ? Combien de fois la flash interne ?

  2. Le transport dma_transport.c déclare ses tampons de réception et d’émission en variables globales, donc dans .bss. Hormis la place, pourquoi est-il indispensable que .bss quitte la DTCM ? (Partie 2, TP 6.)

Réponses aux questions 1 et 2

1. La taille du programme. Plusieurs centaines de Kio (de l’ordre de 200 à 300 Kio avec deux types de messages), soit deux à trois fois la flash interne. arm-none-eabi-size affiche trois nombres : text (le code et les constantes, en flash), data (les variables initialisées, en flash et en RAM), bss (les variables à zéro, en RAM seulement). C’est text + data qui doit tenir en flash.

D’où vient tout ce code ? La bibliothèque micro-ROS embarque une partie de la pile ROS 2 (rcl, rclc), le client XRCE-DDS, la sérialisation des messages (CDR), et le code de chaque type de message utilisé. Le robot, avec une vingtaine de types, dépasse 400 Kio.

2. Les tampons du transport hors de la DTCM. Le transport dma_transport.c reçoit par DMA (en mode circulaire) et émet par DMA : ses tampons doivent être accessibles aux contrôleurs DMA1 et DMA2, qui n’ont pas accès à la DTCM (partie 2, TP 6). Si .bss reste en DTCM, la carte n’envoie ni ne reçoit rien : le DMA s’arrête sur une erreur de bus, et la carte attend l’agent indéfiniment, LED_1 clignotante, sans autre message. Déplacer .bss en AXI SRAM résout à la fois le problème de place et celui d’accès.

Étape 4 — Le nœud et ses deux topics

Dans StartDefaultTask, et seulement là (micro-ROS n’est pas prévu pour être appelé depuis plusieurs tâches) :

  1. Le transport et la mémoire. Indiquez à micro-ROS d’utiliser UART5, et son propre allocateur :

    rmw_uros_set_custom_transport(true, (void *)&huart5,
                                  cubemx_transport_open, cubemx_transport_close,
                                  cubemx_transport_write, cubemx_transport_read);
    
    rcl_allocator_t a = rcutils_get_zero_initialized_allocator();
    a.allocate      = microros_allocate;
    a.deallocate    = microros_deallocate;
    a.reallocate    = microros_reallocate;
    a.zero_allocate = microros_zero_allocate;
    rcutils_set_default_allocator(&a);
    

    Ces fonctions sont définies dans les fichiers copiés à l’étape 2 : déclarez-les extern (voir sample_main.c dans micro_ros_stm32cubemx_utils).

  2. Attendre l’agent. Tant que rmw_uros_ping_agent(200, 1) ne renvoie pas RMW_RET_OK, faites clignoter LED_1. Allumez-la fixe ensuite.

  3. Le nœud. Créez-le dans votre domaine, avec le nom ratbot_lab et l’espace de noms tp :

    rcl_allocator_t    allocator = rcl_get_default_allocator();
    rcl_init_options_t opts      = rcl_get_zero_initialized_init_options();
    rcl_init_options_init(&opts, allocator);
    rcl_init_options_set_domain_id(&opts, 11);       /* 10 + numéro du poste */
    rclc_support_init_with_options(&support, 0, NULL, &opts, &allocator);
    rclc_node_init_default(&node, "ratbot_lab", "tp", &support);
    
  4. Deux éditeurs (publishers) : button de type std_msgs/msg/Bool, et counter de type std_msgs/msg/Int32 :

    rclc_publisher_init_default(&pub_button, &node,
        ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Bool), "button");
    
  5. La boucle : toutes les 100 ms (osDelayUntil), publiez l’état de SW1 ; toutes les secondes, le compteur, augmenté de 1.

Vérifiez chaque code de retour (RCL_RET_OK) : en cas d’échec, allumez LED_4 et arrêtez-vous là. C’est votre seul moyen de savoir quelle étape a échoué.

Étape 5 — Côté ordinateur

  1. Branchez l’adaptateur USB-série sur J1, et repérez son nom (/dev/ttyUSB0 en général : ls /dev/ttyUSB*).

  2. Lancez l’agent, dans un terminal :

    docker run -it --rm -v /dev:/dev --privileged --net=host --ipc=host \
        microros/micro-ros-agent:humble serial --dev /dev/ttyUSB0 -b 115200
    
  3. Faites redémarrer la carte (bouton reset) : LED_1 cesse de clignoter, et l’agent affiche la création de la session, du nœud et des topics.

  4. Dans un autre terminal :

    source /opt/ros/humble/setup.bash
    export ROS_DOMAIN_ID=11
    ros2 node list
    ros2 topic list
    ros2 topic echo /tp/button
    ros2 topic hz /tp/counter
    

Questions

  1. Quel est le nom complet du nœud ? Celui des deux topics ?

  2. Appuyez sur SW1 : que montre ros2 topic echo ? Mesurez la cadence de /tp/button avec ros2 topic hz : est-ce celle que vous avez programmée ?

  3. Arrêtez l’agent (Ctrl+C), puis relancez-le. La carte reprend-elle ses publications ? Pourquoi ? (On y reviendra au TP 2.)

  4. Changez ROS_DOMAIN_ID dans le terminal, et relancez ros2 topic list. Que voyez-vous ? Que se serait-il passé avec le domaine 77 ?

Solution de l’étape 4

Les inclusions (USER CODE BEGIN Includes) :

#include <rcl/rcl.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <uxr/client/transport.h>
#include <rmw_microxrcedds_c/config.h>
#include <rmw_microros/rmw_microros.h>
#include <std_msgs/msg/bool.h>
#include <std_msgs/msg/int32.h>

Les fonctions définies dans les fichiers copiés à l’étape 2, déclarées extern (USER CODE BEGIN 0) :

bool   cubemx_transport_open(struct uxrCustomTransport *transport);
bool   cubemx_transport_close(struct uxrCustomTransport *transport);
size_t cubemx_transport_write(struct uxrCustomTransport *transport,
                              const uint8_t *buf, size_t len, uint8_t *err);
size_t cubemx_transport_read(struct uxrCustomTransport *transport,
                             uint8_t *buf, size_t len, int timeout, uint8_t *err);
void *microros_allocate(size_t size, void *state);
void  microros_deallocate(void *pointer, void *state);
void *microros_reallocate(void *pointer, size_t size, void *state);
void *microros_zero_allocate(size_t number_of_elements, size_t size_of_element,
                             void *state);

La tâche :

#define POSTE_DOMAIN_ID 11u              /* 10 + numéro du poste ; JAMAIS 77 */

void StartDefaultTask(void *argument)
{
    rcl_allocator_t      allocator;
    rcl_init_options_t   opts;
    rclc_support_t       support;
    rcl_node_t           node;
    rcl_publisher_t      pub_button, pub_counter;
    std_msgs__msg__Bool  msg_button;
    std_msgs__msg__Int32 msg_counter;
    uint32_t             next, tick = 0u;

    (void)argument;

    /* 1. le transport et la mémoire */
    rmw_uros_set_custom_transport(true, (void *)&huart5,
                                  cubemx_transport_open, cubemx_transport_close,
                                  cubemx_transport_write, cubemx_transport_read);
    allocator = rcutils_get_zero_initialized_allocator();
    allocator.allocate      = microros_allocate;
    allocator.deallocate    = microros_deallocate;
    allocator.reallocate    = microros_reallocate;
    allocator.zero_allocate = microros_zero_allocate;
    if (!rcutils_set_default_allocator(&allocator)) { fail(1u); }

    /* 2. attendre l'agent */
    while (rmw_uros_ping_agent(200, 1) != RMW_RET_OK)
    {
        HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin);
        osDelay(200);
    }
    HAL_GPIO_WritePin(LED_1_GPIO_Port, LED_1_Pin, GPIO_PIN_SET);

    /* 3. le nœud, dans le domaine du poste */
    allocator = rcl_get_default_allocator();
    opts      = rcl_get_zero_initialized_init_options();
    if (rcl_init_options_init(&opts, allocator) != RCL_RET_OK)                 { fail(2u); }
    if (rcl_init_options_set_domain_id(&opts, POSTE_DOMAIN_ID) != RCL_RET_OK)  { fail(2u); }
    if (rclc_support_init_with_options(&support, 0, NULL, &opts, &allocator)
        != RCL_RET_OK)                                                         { fail(3u); }
    if (rclc_node_init_default(&node, "ratbot_lab", "tp", &support) != RCL_RET_OK) { fail(4u); }

    /* 4. deux éditeurs */
    if (rclc_publisher_init_default(&pub_button, &node,
            ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Bool), "button") != RCL_RET_OK)    { fail(5u); }
    if (rclc_publisher_init_default(&pub_counter, &node,
            ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "counter") != RCL_RET_OK)  { fail(5u); }

    /* 5. la boucle : bouton à 10 Hz, compteur à 1 Hz */
    msg_counter.data = 0;
    next = osKernelGetTickCount();
    for (;;)
    {
        msg_button.data = (HAL_GPIO_ReadPin(SW1_GPIO_Port, SW1_Pin) == GPIO_PIN_SET);
        if (rcl_publish(&pub_button, &msg_button, NULL) != RCL_RET_OK)
        {
            HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);    /* échec ponctuel */
        }
        if (++tick % 10u == 0u)
        {
            if (rcl_publish(&pub_counter, &msg_counter, NULL) != RCL_RET_OK)
            {
                HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);
            }
            msg_counter.data++;
        }
        next += 100u;
        osDelayUntil(next);
    }
}

fail(n) allume LED_4 et fait clignoter LED_1 n fois en boucle (voir le fichier complet) : sans terminal ni débogueur, c’est le seul moyen de savoir quelle étape a échoué.

Les deux allocateurs. Le premier (microros_allocate & co) est celui de la pile micro-ROS : il puise dans un tas qui lui est réservé (custom_memory_manager.c), séparé du tas de FreeRTOS. Le second (rcl_get_default_allocator) est l’interface par laquelle rcl demande de la mémoire : après rcutils_set_default_allocator, il renvoie le premier.

Le ping avant tout. Sans agent, rclc_support_init échoue, et on ne saurait pas distinguer « l’agent n’est pas lancé » de « la pile est cassée ». Le ping lève l’ambiguïté : LED_1 qui clignote veut dire « j’attends l’agent ».

Ce qui part sur le fil. rcl_publish sérialise le message (format CDR : un booléen tient en un octet), l’encapsule dans un paquet XRCE, que le transport encadre (framing : début, longueur, somme de contrôle) avant de l’envoyer par DMA. L’agent fait le chemin inverse, et publie le message en DDS au nom de la carte.

ROSIDL_GET_MSG_TYPE_SUPPORT donne la description du type de message (comment le sérialiser), générée à la compilation de la bibliothèque : on ne peut publier que les types compilés dans libmicroros.

Réponses aux questions 3 à 6

3. Les noms. Le nœud est /tp/ratbot_lab : son nom, précédé de son espace de noms. Les topics ont des noms relatifs (sans / au début) : ils sont placés dans l’espace de noms du nœud, d’où /tp/button et /tp/counter. Un nom commençant par / serait absolu, et ignorerait l’espace de noms. C’est ce mécanisme qui permet au même firmware de servir tous les robots : seul l’espace de noms change (/ratbot_1, /ratbot_2…).

4. Le bouton. data: true tant que SW1 est enfoncé, data: false sinon, à 10 Hz, la cadence de la boucle. ros2 topic hz donne une moyenne très proche de 10,000 Hz grâce à osDelayUntil, avec une petite gigue due à la liaison série et au réseau. Publier un état toutes les 100 ms, qu’il change ou non, est simple et robuste ; publier seulement les changements économise la liaison (exercice).

5. L’agent relancé. Non, la carte ne reprend pas ses publications. La carte et l’agent partagent une session XRCE : l’agent garde, pour elle, la liste de ses entités (nœud, éditeurs) et les crée en DDS. Un agent relancé ne connaît plus rien de cette session ; les messages de la carte font référence à des entités qui n’existent plus, et sont ignorés. Ce programme ne le détecte pas (rcl_publish échoue, LED_3 clignote). Il faut redémarrer la carte… ou surveiller l’agent et tout recréer : c’est l’étape 3 du TP 2.

6. Le domaine. Dans un autre domaine, ros2 topic list ne montre plus les topics de la carte : DDS ne fait se rencontrer que les participants d’un même domaine (le numéro de domaine choisit les ports réseau utilisés pour la découverte). Dans le domaine 77, la carte apparaîtrait parmi les vrais robots du laboratoire : ses topics se mêleraient aux leurs, et un nom mal choisi pourrait même entrer en conflit avec ceux d’un robot. Le domaine se fixe dans le firmware : l’agent crée le participant DDS de la carte dans le domaine qu’elle demande, quel que soit le ROS_DOMAIN_ID de la machine où il tourne.

Tout le code de la correction

taches.c — compilé sans avertissement contre les en-têtes de micro-ROS Humble, la HAL et FreeRTOS du firmware RatBot (sans édition de liens ni essai sur carte).

Le fichier contient les inclusions, les déclarations et la fonction de la tâche micro-ROS, qui remplace le squelette StartDefaultTask généré par CubeMX. La configuration attendue est rappelée en tête du fichier, et les réponses aux questions à la fin.

Exercices supplémentaires

Ces exercices n’ont pas de correction.

  • Les quatre boutons : un seul topic, de type std_msgs/msg/UInt8, dont chaque bit donne l’état d’un bouton ; publié seulement quand il change.

  • La batterie : publiez la tension (TP 6 de la partie 1) en std_msgs/msg/Float32, une fois par seconde, et tracez-la avec rqt_plot ou PlotJuggler.

  • Le débit : passez la liaison à 921 600 bit/s (dans CubeMX et pour l’agent), publiez le compteur aussi vite que possible, et mesurez la cadence maximale avec ros2 topic hz.

Après le TP

La flash externe contient votre programme : réinstallez celui du robot, voir Remettre la carte en état RatBot.