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

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
Créez un projet
tp05_uartcomme 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 :
const char msg[] = "Bonjour depuis la carte\r\n"; HAL_UART_Transmit(&huart5, (const uint8_t *)msg, sizeof(msg) - 1, 100);
Questions
Pourquoi
\r\nen fin de ligne, et pas seulement\n? Essayez sans le\r.Combien de temps faut-il pour envoyer cette ligne à 115 200 bit/s ? (Une trame 8N1 fait 10 bits.)
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
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.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 relancezHAL_UART_Receive_ITpour 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.
Questions
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 ?
Pourquoi faut-il relancer
HAL_UART_Receive_ITdans 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.
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
bataffiche la tension de la batterie (TP 6 — Le niveau de batterie),templa température (TP 7 — Le bus I²C : température, accéléromètre et gyroscope).Couleur au clavier :
rgb 255 128 0règle le voyant RGB (TP 2 — PWM et couleurs).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 Remettre la carte en état RatBot. Sur un robot, relancez
aussi sa pile : sudo systemctl start ratbot.service.