TP 5 — La liaison série avec le PC ================================== Objectifs --------- - comprendre une liaison série asynchrone (**UART**) : débit, trame, niveaux ; - envoyer du texte au PC, puis rediriger ``printf`` vers la liaison série ; - recevoir des caractères, par scrutation puis par **interruption**, et piloter la carte au clavier. Durée indicative : une séance de 3 h. Prérequis : :doc:`tp01_gpio`. Ce qu'il faut savoir -------------------- La liaison série ~~~~~~~~~~~~~~~~ Un UART envoie les octets bit par bit sur un fil (**TX**) et les reçoit sur un autre (**RX**), sans horloge commune : les deux côtés doivent être réglés sur le **même débit**, ici **115 200 bits par seconde**. Chaque octet part dans une **trame** : un bit de départ à 0, les 8 bits de données, un bit d'arrêt à 1 (réglage noté **8N1**). Au repos, la ligne est à 1. Le TX de l'un va sur le RX de l'autre, et réciproquement : les fils se **croisent**. .. tikz:: Une trame série 8N1 : un bit de départ à 0, huit bits de données, un bit d'arrêt à 1. \tikzset{lb/.style={font=\scriptsize}} % niveaux : idle, start, b0..b7 (0x41 = 'A', poids faible d'abord), stop, idle \def\bits{1/0/start, 2/1/b0, 3/0/b1, 4/0/b2, 5/0/b3, 6/0/b4, 7/0/b5, 8/1/b6, 9/0/b7, 10/1/stop} \draw[thick, blue!60!black] (0,1) -- (1,1) \foreach \i/\v/\n in \bits { -- (\i,\v) -- (\i+1,\v) } -- (12,1); \foreach \i/\v/\n in \bits { \draw[black!25] (\i,-0.2) -- (\i,1.3); \node[lb] at (\i+0.5,1.55) {\n}; } \draw[black!25] (11,-0.2) -- (11,1.3); \node[lb, left] at (0,1) {1}; \node[lb, left] at (0,0) {0}; \node[lb] at (0.5,1.55) {repos}; \node[lb] at (11.5,1.55) {repos}; \draw[{Stealth}-{Stealth}] (2,-0.45) -- node[below, lb]{8 bits de données, poids faible d'abord : 0x41 = « A »} (10,-0.45); \draw[{Stealth}-{Stealth}] (1,-1.15) -- node[below, lb]{une trame 8N1 = 10 bits ; à 115 200 bit/s, un bit dure 8,7 µs, la trame 87 µs} (11,-1.15); Le connecteur ~~~~~~~~~~~~~ La carte n'a pas de port série réservé au débogage. On utilise **UART5**, sortie sur le connecteur **J1** (« SERIAL ROS », JST PH à 3 points) : c'est le port qui relie la carte à la Raspberry Pi dans le robot. .. list-table:: :header-rows: 1 :widths: 15 25 60 * - Broche de J1 - Signal - Côté PC * - 1 - RX de la carte (PB12) - le **TX** de l'adaptateur * - 2 - TX de la carte (PB13) - le **RX** de l'adaptateur * - 3 - GND - la **masse** de l'adaptateur Côté PC, il faut un **adaptateur USB-série en 3,3 V** (à base de FTDI, CH340, CP2102…). Il apparaît sous Linux comme ``/dev/ttyUSB0`` ; on l'ouvre avec un terminal série : .. code-block:: bash picocom -b 115200 /dev/ttyUSB0 # quitter : Ctrl-A puis Ctrl-X .. Capture d'écran à insérer : un terminal picocom qui affiche les lignes envoyées par la carte ; si possible, une photo de l'adaptateur USB-série branché sur J1. Déposez _static/captures/sans_os/tp05-picocom.png puis décommentez le bloc ci-dessous (retirez le préfixe « .. » devant chaque ligne) : .. .. image:: ../_static/captures/sans_os/tp05-picocom.png .. :align: center .. :width: 90% .. warning:: L'adaptateur doit être en **3,3 V**. Un adaptateur en 5 V peut abîmer la broche de réception de la carte. .. tip:: **Sur un robot**, la Raspberry Pi est déjà branchée sur J1 et peut servir de terminal, sans adaptateur : arrêtez d'abord la pile du robot (``sudo systemctl stop ratbot.service``), qui occupe le port, puis lancez ``picocom -b 115200 /dev/ttyAMA0`` sur la Pi. Étape 1 — Envoyer du texte -------------------------- #. Créez un projet ``tp05_uart`` comme au TP 1 (STM32H750VBTx, **SYS → Debug : Serial Wire**, horloge par défaut), avec **LED_1** à **LED_4** en sortie. #. Dans **Connectivity → UART5**, choisissez **Asynchronous**. Vérifiez que RX et TX sont sur **PB12** et **PB13**. Dans **Parameter Settings** : **Baud Rate** 115200, **Word Length** 8 bits, **Parity** None, **Stop Bits** 1. #. Générez le code. #. Envoyez une ligne de texte toutes les secondes : .. Capture d'écran à insérer : CubeMX, UART5 en Asynchronous sur PB12/PB13, 115200 bit/s, 8N1. Déposez _static/captures/sans_os/tp05-uart5.png puis décommentez le bloc ci-dessous (retirez le préfixe « .. » devant chaque ligne) : .. .. image:: ../_static/captures/sans_os/tp05-uart5.png .. :align: center .. :width: 90% .. code-block:: c const char msg[] = "Bonjour depuis la carte\r\n"; HAL_UART_Transmit(&huart5, (const uint8_t *)msg, sizeof(msg) - 1, 100); .. admonition:: Questions 1. Pourquoi ``\r\n`` en fin de ligne, et pas seulement ``\n`` ? Essayez sans le ``\r``. 2. Combien de temps faut-il pour envoyer cette ligne à 115 200 bit/s ? (Une trame 8N1 fait 10 bits.) 3. Que voit-on dans le terminal si on le règle à 9 600 bit/s au lieu de 115 200 ? .. dropdown:: Solution de l'étape 1 :icon: code :color: secondary .. code-block:: c static const char msg[] = "Bonjour depuis la carte\r\n"; HAL_UART_Transmit(&huart5, (const uint8_t *)msg, (uint16_t)(sizeof(msg) - 1u), 100u); HAL_Delay(1000); **sizeof(msg) − 1.** Une chaîne C se termine par un octet nul (``'\0'``), compté par ``sizeof`` mais qu'on n'envoie pas : le terminal l'afficherait comme un caractère parasite, ou pas du tout. **Le dernier argument (100)** est un délai maximal en millisecondes : ``HAL_UART_Transmit`` attend que chaque octet soit parti (drapeau ``TXE``, puis ``TC`` à la fin) et abandonne au-delà de ce délai, en renvoyant ``HAL_TIMEOUT``. C'est une fonction **bloquante** : le programme ne fait rien d'autre pendant l'envoi. **Ce que fait le matériel.** Pour chaque octet, la HAL l'écrit dans le registre ``TDR`` de l'UART ; l'UART le recopie dans son registre à décalage et l'envoie bit par bit, au rythme fixé par le registre ``BRR`` : 64 MHz / 115 200 ≈ 556 cycles par bit. .. dropdown:: Réponses aux questions 1 à 3 :icon: light-bulb :color: info **1. \\r\\n.** Ce sont deux commandes héritées des téléscripteurs : ``\r`` (*carriage return*, code 13) ramène le chariot en **début de ligne**, ``\n`` (*line feed*, code 10) fait **descendre d'une ligne**. Un terminal série les exécute à la lettre : avec ``\n`` seul, chaque ligne commence là où finissait la précédente, et le texte part **en escalier**. Linux, dans ses fichiers, n'utilise que ``\n`` ; Windows utilise ``\r\n`` ; une liaison série brute, où personne ne traduit, demande les deux. **2. La durée.** La ligne fait 25 caractères (``\r`` et ``\n`` compris). Chaque octet part dans une trame de 10 bits (départ, 8 données, arrêt) : .. code-block:: text 25 × 10 bits / 115 200 bit/s = 2,17 ms soit 87 µs par caractère. Le « débit utile » d'une liaison 8N1 est donc de 11 520 octets par seconde, pas 14 400 (115 200 / 8). **3. À 9 600 bit/s.** Des caractères **sans rapport** avec le texte (souvent des symboles bizarres, ou rien). L'UART du PC échantillonne la ligne au milieu de chacun de **ses** bits, de durée 104 µs, alors que la carte envoie des bits de 8,7 µs : chaque bit lu par le PC recouvre douze bits envoyés, et il reconstruit des octets faux. Il n'y a **aucune horloge commune** : c'est pour cela que les deux côtés doivent être réglés sur le même débit, à quelques pourcents près. Étape 2 — ``printf`` sur la liaison série ----------------------------------------- ``printf`` écrit ses caractères par une fonction de bas niveau que le projet peut fournir. Dans un projet STM32CubeIDE, le fichier ``syscalls.c`` appelle ``__io_putchar`` pour chaque caractère : il suffit de la définir, dans ``USER CODE BEGIN 0`` : .. code-block:: c int __io_putchar(int ch) { uint8_t c = (uint8_t)ch; HAL_UART_Transmit(&huart5, &c, 1, 10); return ch; } Ajoutez ``#include `` dans ``USER CODE BEGIN Includes``, puis affichez un compteur avec ``printf("compteur = %lu\r\n", n);``. .. note:: Par défaut, ``printf`` n'affiche pas les nombres à virgule (``%f``), pour gagner de la place en mémoire. Pour les activer : **Project → Properties → C/C++ Build → Settings → MCU Settings → Use float with printf**. Mieux : restez en entiers, par exemple en millivolts ou en millièmes de degré. .. dropdown:: Solution de l'étape 2 :icon: code :color: secondary Avec ``__io_putchar`` (donnée dans le sujet) et ``#include `` : .. code-block:: c static uint32_t n = 0u; printf("compteur = %lu\r\n", (unsigned long)n++); HAL_Delay(1000); **Le chemin d'un caractère.** ``printf`` formate le texte, puis appelle ``_write`` (dans ``Core/Src/syscalls.c``, fourni par CubeIDE), qui appelle ``__io_putchar`` pour chaque caractère. ``syscalls.c`` déclare cette fonction « faible » : en la définissant, on décide où partent les caractères. **%lu et le transtypage.** ``%lu`` attend un ``unsigned long``. Sur ce processeur, ``uint32_t`` est un ``unsigned long``… ou un ``unsigned int`` selon la bibliothèque : le transtypage ``(unsigned long)`` rend le code juste dans tous les cas, et évite un avertissement du compilateur. **La mise en tampon.** ``printf`` peut garder les caractères en mémoire jusqu'à la fin de la ligne. Si un texte sans ``\n`` n'apparaît pas, c'est pour cela : ``setvbuf(stdout, NULL, _IONBF, 0);`` au démarrage supprime le tampon. Étape 3 — Recevoir des caractères --------------------------------- #. **Par scrutation.** Dans la boucle, attendez un caractère avec un délai court : .. code-block:: c uint8_t c; if (HAL_UART_Receive(&huart5, &c, 1, 10) == HAL_OK) { /* un caractère est arrivé */ } Faites basculer LED_1 à LED_4 avec les touches ``1`` à ``4``, et renvoyez chaque caractère reçu au terminal (*écho*), pour voir ce que l'on tape. #. **Par interruption.** La scrutation oblige la boucle à revenir souvent attendre. Avec une interruption, l'UART prévient le programme quand un caractère arrive : - dans CubeMX, onglet **NVIC Settings** de l'UART5, cochez **UART5 global interrupt** ; régénérez le code ; - demandez la réception d'un caractère : ``HAL_UART_Receive_IT(&huart5, &rx, 1);`` ; - la HAL appelle ``HAL_UART_RxCpltCallback`` à l'arrivée du caractère : définissez-la, traitez le caractère, puis relancez ``HAL_UART_Receive_IT`` pour le suivant. #. Ajoutez un clignotement de LED_4 dans la boucle principale, avec ``HAL_GetTick``, et vérifiez qu'il ne s'arrête pas pendant qu'on tape. .. admonition:: Questions 4. Avec la scrutation et un délai de 10 ms, que se passe-t-il si l'on colle d'un coup une longue chaîne de caractères dans le terminal ? Et avec l'interruption ? 5. Pourquoi faut-il relancer ``HAL_UART_Receive_IT`` dans la fonction de rappel ? .. dropdown:: Solution de l'étape 3 :icon: code :color: secondary Une fonction commune traite un caractère reçu (``USER CODE BEGIN 0``) : .. code-block:: c static void handle_char(uint8_t c) { HAL_UART_Transmit(&huart5, &c, 1u, 10u); /* écho */ if ((c >= (uint8_t)'1') && (c <= (uint8_t)'4')) { uint32_t k = (uint32_t)(c - (uint8_t)'1'); /* '1' → 0 … '4' → 3 */ HAL_GPIO_TogglePin(leds[k].port, leds[k].pin); } } (``leds`` est le tableau du TP 1.) Le caractère ``'1'`` vaut 49 en ASCII : ``c - '1'`` le transforme en indice de 0 à 3. **Par scrutation**, dans la boucle : .. code-block:: c uint8_t c; if (HAL_UART_Receive(&huart5, &c, 1u, 10u) == HAL_OK) { handle_char(c); } **Par interruption** : deux variables globales, un rappel, et le traitement dans la boucle. .. code-block:: c static uint8_t rx; /* l'octet reçu */ static volatile uint8_t rx_ready; /* 1 : rx contient un caractère */ /* USER CODE BEGIN 4 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == UART5) { rx_ready = 1u; } } Dans ``USER CODE BEGIN 2`` : ``HAL_UART_Receive_IT(&huart5, &rx, 1u);``. Dans la boucle : .. code-block:: c static uint32_t last = 0u; if (rx_ready) { uint8_t c = rx; rx_ready = 0u; HAL_UART_Receive_IT(&huart5, &rx, 1u); /* prêt pour le suivant */ handle_char(c); } if ((HAL_GetTick() - last) >= 250u) /* le clignotement continue */ { last = HAL_GetTick(); HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin); } **Pourquoi traiter dans la boucle, pas dans le rappel.** Le rappel s'exécute **dans l'interruption**. L'écho, lui, appelle ``HAL_UART_Transmit``, qui attend 87 µs : une interruption doit rester courte, et ne doit pas attendre. Le rappel se contente de **lever un drapeau** ; la boucle fait le travail. C'est le schéma de base de toute la programmation par interruptions : l'interruption signale, la boucle traite. **On relance avant de traiter** : la réception du caractère suivant est armée le plus tôt possible, pendant que l'écho part. .. dropdown:: Réponses aux questions 4 et 5 :icon: light-bulb :color: info **4. Une longue chaîne collée d'un coup.** Les caractères arrivent alors collés les uns aux autres, un toutes les 87 µs. - **Par scrutation**, la boucle ne lit l'UART que lorsqu'elle est dans ``HAL_UART_Receive`` ; pendant l'écho (87 µs) et le reste de la boucle, les caractères s'accumulent dans l'UART, qui n'a qu'un registre de réception (``RDR``) d'un octet. Quand un octet arrive alors que le précédent n'a pas été lu, il n'a pas de place : il est **perdu**, et l'UART lève le drapeau de **débordement** (*overrun*, ``ORE``). On voit des caractères manquer. - **Par interruption**, chaque caractère est pris dès son arrivée, tant que la réception est réarmée à temps. C'est bien mieux, mais la boucle doit toujours traiter un caractère avant que le suivant n'arrive : avec un écho de 87 µs, la marge est nulle, et des caractères peuvent encore se perdre. La vraie solution est un **tampon circulaire** rempli par l'interruption (exercice supplémentaire), ou le **DMA** (partie 2, TP 6), qui range les octets en mémoire sans le processeur. Le STM32H750 a aussi une **FIFO** matérielle de 16 octets par UART, désactivée par défaut dans CubeMX (**FIFO Mode**) : l'activer donne de la marge, sans changer le principe. **5. Relancer HAL_UART_Receive_IT.** La fonction demande la réception d'un nombre **fixé** d'octets (ici 1). Elle active l'interruption de réception de l'UART ; quand le nombre est atteint, la HAL **désactive** l'interruption, puis appelle le rappel. Sans nouvel appel, l'UART continue de recevoir des octets, mais personne n'est prévenu : ils s'accumulent en débordement. C'est un choix de la HAL : chaque réception a un début et une fin, et c'est au programme de décider s'il en veut une autre. .. dropdown:: Tout le code de la correction :icon: download :color: secondary :download:`main_user_code.c <../../corrections/sans_os/tp05/main_user_code.c>` — compilé sans avertissement contre la HAL du STM32H7. Ce n'est **pas** un ``main.c`` complet : ce sont les extraits à placer dans les blocs ``USER CODE`` du ``main.c`` généré par CubeMX, chacun sous le commentaire qui indique son bloc. La boucle ``while (1)`` contient une variante par étape, encadrée par ``#if 0`` / ``#if 1`` : n'en activer **qu'une à la fois**. La configuration CubeMX 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. - **Petit interpréteur** : lisez des lignes entières (jusqu'à la touche Entrée) et reconnaissez des commandes comme ``led 2 on``, ``led all off``, ``help``. - **Mesures au terminal** : la commande ``bat`` affiche la tension de la batterie (:doc:`tp06_batterie`), ``temp`` la température (:doc:`tp07_i2c`). - **Couleur au clavier** : ``rgb 255 128 0`` règle le voyant RGB (:doc:`tp02_pwm`). - **Tampon circulaire** : rangez les caractères reçus par interruption dans un tampon circulaire de 64 octets, et traitez-les depuis la boucle principale. Après le TP ----------- Pour rendre la carte au robot, voir :ref:`etat-ratbot`. Sur un robot, relancez aussi sa pile : ``sudo systemctl start ratbot.service``.