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 : TP 1 — Allumer une LED, lire un bouton.

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.

Figure made with TikZ

Une trame série 8N1 : un bit de départ à 0, huit bits de données, un bit d’arrêt à 1.

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.

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 :

picocom -b 115200 /dev/ttyUSB0      # quitter : Ctrl-A puis Ctrl-X

Avertissement

L’adaptateur doit être en 3,3 V. Un adaptateur en 5 V peut abîmer la broche de réception de la carte.

Astuce

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

  1. 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.

  2. 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.

  3. Générez le code.

  4. Envoyez une ligne de texte toutes les secondes :

    const char msg[] = "Bonjour depuis la carte\r\n";
    HAL_UART_Transmit(&huart5, (const uint8_t *)msg, sizeof(msg) - 1, 100);
    

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 ?

Solution de l’étape 1
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.

Réponses aux questions 1 à 3

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) :

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 :

int __io_putchar(int ch)
{
    uint8_t c = (uint8_t)ch;
    HAL_UART_Transmit(&huart5, &c, 1, 10);
    return ch;
}

Ajoutez #include <stdio.h> 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é.

Solution de l’étape 2

Avec __io_putchar (donnée dans le sujet) et #include <stdio.h> :

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

  1. Par scrutation. Dans la boucle, attendez un caractère avec un délai court :

    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.

  2. 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.

  3. 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.

Questions

  1. 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 ?

  2. Pourquoi faut-il relancer HAL_UART_Receive_IT dans la fonction de rappel ?

Solution de l’étape 3

Une fonction commune traite un caractère reçu (USER CODE BEGIN 0) :

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 :

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.

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 :

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.

Réponses aux questions 4 et 5

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.

Tout le code de la correction

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.

Après le TP

Pour rendre la carte au robot, voir Remettre la carte en état RatBot. Sur un robot, relancez aussi sa pile : sudo systemctl start ratbot.service.