TP 8 — Le bus SPI : l’écran OLED
Objectifs
comprendre le bus SPI et ses lignes de commande ;
initialiser un écran OLED par une suite de commandes ;
dessiner dans une image en mémoire (framebuffer), puis l’envoyer à l’écran d’un seul bloc ;
afficher des chiffres, par exemple une mesure d’un TP précédent.
Durée indicative : deux séances de 3 h. Prérequis : TP 1 — Allumer une LED, lire un bouton.
Ce qu’il faut savoir
Le bus SPI
Le SPI est un bus série synchrone, plus rapide et plus simple que l’I²C :
SCK, l’horloge, imposée par le maître ;
MOSI, les données du maître vers l’esclave ;
MISO, les données de l’esclave vers le maître (inutile ici) ;
CS (chip select), une broche par esclave, à 0 pendant l’échange : il n’y a pas d’adresse, c’est CS qui désigne le composant.

L’envoi d’un octet en SPI : CS sélectionne l’écran, SCK cadence, MOSI porte les bits.
L’écran
Un module OLED monochrome de 128 × 64 points, piloté par un contrôleur
SSD1309, relié au connecteur SCREEN_SPI1 :
Signal |
Broche |
Rôle |
|---|---|---|
MOSI |
PC12 (SPI3_MOSI) |
données vers l’écran |
SCK |
PC10 (SPI3_SCK) |
horloge |
CS |
PD14 |
sélection de l’écran, active à 0 |
DC |
PD15 |
0 : l’octet envoyé est une commande ; 1 : c’est une donnée (des pixels) |
RST |
PC8 |
remise à zéro de l’écran, active à 0 |
Avertissement
Le module porte un interrupteur SPI / I²C : il doit être sur SPI. L’écran ne renvoie rien (pas de MISO) : le microcontrôleur ne peut pas savoir s’il est bien branché. Quand rien ne s’affiche, vérifiez d’abord le câble et cet interrupteur.
La mémoire de l’écran
Les 128 × 64 points sont rangés en 8 pages de 8 lignes. Un octet représente une colonne de 8 points d’une page, bit 0 en haut. L’écran entier tient donc en 128 × 8 = 1 024 octets.

La mémoire de l’écran : 8 pages de 128 octets, chaque octet est une colonne de 8 points.
On dessine dans un tableau de 1 024 octets en mémoire, puis on l’envoie à l’écran en une fois.
Étape 1 — Configurer le SPI
Créez un projet
tp08_spi_ecrancomme au TP 1 (STM32H750VBTx, SYS → Debug : Serial Wire, horloge par défaut).Dans Connectivity → SPI3, choisissez Transmit Only Master. Vérifiez que SCK et MOSI sont sur PC10 et PC12.
Dans Parameter Settings du SPI3 :
Data Size : 8 Bits. CubeMX propose parfois 4 bits par défaut sur ce microcontrôleur : vérifiez-le ;
Prescaler : 16.
L’horloge du SPI. Dans Clock Configuration, le multiplexeur SPI123 Clock Mux propose plusieurs sources ; les PLL ne sont pas actives : choisissez PER_CK (la HSI, 64 MHz). Avec le prédiviseur 16, l’horloge du bus vaut 4 MHz, sous les quelque 10 MHz qu’accepte le SSD1309.
Configurez PD14, PD15 et PC8 en GPIO_Output, labels
SCREEN_CS,SCREEN_DCetSCREEN_RST. PourSCREEN_CS, réglez le niveau de départ (GPIO output level) sur High : l’écran n’est pas sélectionné au démarrage.Générez le code.
Étape 2 — Initialiser l’écran
Écrivez une fonction
send_commands(const uint8_t *cmds, uint16_t n): DC à 0 (commande), CS à 0,HAL_SPI_Transmit(&hspi3, …), CS à 1.Remise à zéro : RST à 1, 10 ms, RST à 0, 10 ms, RST à 1, 10 ms.
Envoyez la suite d’initialisation suivante, tirée de la fiche technique du SSD1309 :
static const uint8_t init_cmds[] = { 0xAE, /* affichage éteint pendant la configuration */ 0xD5, 0x80, /* diviseur d'horloge */ 0xA8, 0x3F, /* 64 lignes */ 0xD3, 0x00, /* pas de décalage vertical */ 0x40, /* ligne de départ 0 */ 0xA1, /* colonnes de droite à gauche */ 0xC8, /* lignes de bas en haut */ 0xDA, 0x12, /* câblage des lignes, 128 × 64 */ 0x81, 0x7F, /* contraste à mi-course */ 0xD9, 0x22, /* pré-charge */ 0xDB, 0x34, /* tension de désélection */ 0xA4, /* afficher la mémoire */ 0xA6, /* vidéo normale (A7 : inversée) */ 0x20, 0x00, /* adressage horizontal */ 0xAF, /* affichage allumé */ };
Note
Les exemples écrits pour l’écran SSD1306, très répandus, envoient aussi
0x8D, 0x14 pour allumer une pompe de charge interne. Le SSD1309 n’en a
pas : sa haute tension est fournie par le module. Ne l’envoyez pas.
Question
Remplacez
0xA6par0xA7. Que voit-on ? Et avec0xA0à la place de0xA1?
Solution de l’étape 2
/* Un envoi : DC choisit commande (0) ou donnée (1), CS encadre le tout. */
static void send(GPIO_PinState dc, const uint8_t *buf, uint16_t n)
{
HAL_GPIO_WritePin(SCREEN_DC_GPIO_Port, SCREEN_DC_Pin, dc);
HAL_GPIO_WritePin(SCREEN_CS_GPIO_Port, SCREEN_CS_Pin, GPIO_PIN_RESET);
HAL_SPI_Transmit(&hspi3, (uint8_t *)buf, n, 100u);
HAL_GPIO_WritePin(SCREEN_CS_GPIO_Port, SCREEN_CS_Pin, GPIO_PIN_SET);
}
static void send_commands(const uint8_t *cmds, uint16_t n)
{
send(GPIO_PIN_RESET, cmds, n);
}
static void screen_init(void)
{
HAL_GPIO_WritePin(SCREEN_RST_GPIO_Port, SCREEN_RST_Pin, GPIO_PIN_SET);
HAL_Delay(10);
HAL_GPIO_WritePin(SCREEN_RST_GPIO_Port, SCREEN_RST_Pin, GPIO_PIN_RESET);
HAL_Delay(10); /* remise à zéro */
HAL_GPIO_WritePin(SCREEN_RST_GPIO_Port, SCREEN_RST_Pin, GPIO_PIN_SET);
HAL_Delay(10);
send_commands(init_cmds, (uint16_t)sizeof(init_cmds));
}
appelée une fois dans USER CODE BEGIN 2.
Les trois fils de commande.
CS (chip select), actif à 0 : l’écran n’écoute le bus que pendant qu’il est à 0. Plusieurs composants peuvent ainsi partager SCK et MOSI, chacun avec son CS. On le remonte après chaque envoi : l’écran sait qu’une suite d’octets est finie.
DC (data/command) : à 0, les octets sont des commandes (régler, allumer) ; à 1, des données (les pixels). Il n’existe pas en SPI standard : c’est un fil propre à ce genre d’écran.
RST : une impulsion à 0 remet le contrôleur dans son état de départ. La fiche technique impose une durée minimale (quelques microsecondes) ; 10 ms laissent une large marge, et le temps à l’alimentation de se stabiliser.
Le niveau de départ de CS à High est important : si CS démarrait à 0, l’écran écouterait le bus pendant la configuration du SPI, et pourrait prendre les premières transitions de SCK pour des données.
Réponse à la question 1
1. 0xA7 et 0xA0.
0xA7 passe en vidéo inversée : chaque bit de la mémoire est affiché à l’envers, fond allumé et dessin éteint. La mémoire n’a pas changé : c’est l’écran qui l’interprète autrement.
0xA0 à la place de 0xA1 inverse le sens des colonnes : l’image apparaît en miroir gauche-droite. De même, 0xC0 à la place de 0xC8 la retourne de haut en bas.
Ces commandes existent parce que le contrôleur ne sait pas comment l’écran
est monté dans l’appareil : 0xA1 et 0xC8 ensemble font une rotation
de 180°. On choisit la combinaison qui donne une image à l’endroit pour
ce montage-là, sans rien changer au reste du programme.
Étape 3 — Envoyer une image
Déclarez l’image en mémoire :
static uint8_t fb[1024];Écrivez
flush()qui l’envoie à l’écran : d’abord les commandes qui fixent la fenêtre d’écriture à l’écran entier, puis les 1 024 octets en mode donnée (DC à 1) :static const uint8_t fenetre[] = { 0x21, 0x00, 0x7F, /* colonnes 0 à 127 */ 0x22, 0x00, 0x07, /* pages 0 à 7 */ };
Testez quatre motifs, deux secondes chacun : tout allumé (0xFF partout), tout éteint (0x00), des bandes verticales, des bandes horizontales.
Questions
Quel motif d’octets donne des bandes horizontales de 8 points ? Et des bandes verticales de 8 points ?
Combien de temps dure l’envoi de l’image entière à 4 MHz ? Combien d’images par seconde peut-on envoyer au plus ?
Solution de l’étape 3
static uint8_t fb[1024]; /* 128 colonnes × 8 pages d'un octet */
static const uint8_t fenetre[] = { 0x21, 0x00, 0x7F, 0x22, 0x00, 0x07 };
static void flush(void)
{
send_commands(fenetre, (uint16_t)sizeof(fenetre)); /* tout l'écran */
send(GPIO_PIN_SET, fb, (uint16_t)sizeof(fb)); /* 1 024 octets */
}
static void fill(uint8_t v)
{
for (uint32_t k = 0u; k < sizeof(fb); k++) { fb[k] = v; }
}
et les quatre motifs dans la boucle :
static uint32_t m = 0u;
switch (m)
{
case 0: fill(0xFFu); break; /* tout allumé */
case 1: fill(0x00u); break; /* tout éteint */
case 2: for (uint32_t k = 0u; k < 1024u; k++) /* verticales */
fb[k] = ((k % 128u) / 8u % 2u) ? 0xFFu : 0x00u;
break;
default: for (uint32_t k = 0u; k < 1024u; k++) /* horizontales */
fb[k] = ((k / 128u) % 2u) ? 0xFFu : 0x00u;
break;
}
flush();
m = (m + 1u) % 4u;
HAL_Delay(2000);
L’image en mémoire. On ne dessine jamais directement sur l’écran : on
dessine dans le tableau fb (frame buffer), puis on l’envoie en entier.
C’est plus simple (le SSD1309 ne permet pas de relire sa mémoire en SPI, et
modifier un point demande de connaître les 7 autres de son octet) et cela
évite qu’on voie l’image se construire.
La fenêtre. En adressage horizontal (commande 0x20, 0x00 de
l’initialisation), le contrôleur range chaque octet reçu à la colonne
suivante, et passe à la page suivante au bout de la ligne. Les commandes
0x21 (colonnes) et 0x22 (pages) fixent la fenêtre et remettent le
curseur au début : sans elles, l’image suivante commencerait là où la
précédente s’est arrêtée.
Réponses aux questions 2 et 3
2. Les motifs. Un octet de l’image est une colonne de 8 points, bit 0 en haut. Les 128 premiers octets forment la page 0 (lignes 0 à 7), les 128 suivants la page 1 (lignes 8 à 15), etc. :
bandes horizontales de 8 points : une page sur deux pleine. Octets 0 à 127 à 0xFF, 128 à 255 à 0x00, 256 à 383 à 0xFF… L’indice de page est
k / 128.bandes verticales de 8 points : dans chaque page, 8 colonnes à 0xFF, 8 à 0x00. L’indice de colonne est
k % 128, et le numéro de la bande(k % 128) / 8.
Comprendre cette organisation, qui n’est pas ligne par ligne comme on l’imagine, est la clé de tout le reste du TP.
3. Le temps d’envoi.
1 024 octets × 8 bits / 4 000 000 bit/s = 2,05 ms (+ 6 octets de commande)
soit au plus 488 images par seconde si l’on ne faisait que cela. En
pratique, HAL_SPI_Transmit ajoute un peu de temps entre les octets ; on
reste autour de 400 images par seconde. Un affichage lisible n’en demande
que 5 à 25 : l’écran n’est jamais le facteur limitant, mais ces 2 ms
bloquent le processeur à chaque image (la partie 2 montre comment les
confier au DMA).
Étape 4 — Dessiner
Écrivez
pixel(x, y, allume), qui allume ou éteint un point de l’image en mémoire, à l’aide de la formule ci-dessus.Dessinez un cadre autour de l’écran, puis une diagonale.
Pour afficher des chiffres, voici une police de 5 × 7 points : chaque caractère fait 5 colonnes, un octet par colonne, bit 0 en haut. Elle s’écrit directement dans une page de l’image.
static const uint8_t chiffres[10][5] = { { 0x3E, 0x51, 0x49, 0x45, 0x3E }, /* 0 */ { 0x00, 0x42, 0x7F, 0x40, 0x00 }, /* 1 */ { 0x42, 0x61, 0x51, 0x49, 0x46 }, /* 2 */ { 0x21, 0x41, 0x45, 0x4B, 0x31 }, /* 3 */ { 0x18, 0x14, 0x12, 0x7F, 0x10 }, /* 4 */ { 0x27, 0x45, 0x45, 0x45, 0x39 }, /* 5 */ { 0x3C, 0x4A, 0x49, 0x49, 0x30 }, /* 6 */ { 0x01, 0x71, 0x09, 0x05, 0x03 }, /* 7 */ { 0x36, 0x49, 0x49, 0x49, 0x36 }, /* 8 */ { 0x06, 0x49, 0x49, 0x29, 0x1E }, /* 9 */ };
Affichez un compteur qui augmente d’une unité par seconde.
Solution de l’étape 4
/* Allume (on = 1) ou éteint un point de l'image en mémoire. */
static void pixel(uint32_t x, uint32_t y, uint32_t on)
{
if ((x >= 128u) || (y >= 64u)) { return; } /* hors écran */
if (on) { fb[x + (y / 8u) * 128u] |= (uint8_t)(1u << (y % 8u)); }
else { fb[x + (y / 8u) * 128u] &= (uint8_t)~(1u << (y % 8u)); }
}
/* Un chiffre dans la page « page », à partir de la colonne x. */
static void digit(uint32_t x, uint32_t page, uint32_t d)
{
for (uint32_t c = 0u; c < 5u; c++)
{
if ((x + c) < 128u) { fb[x + c + page * 128u] = chiffres[d % 10u][c]; }
}
}
/* Un nombre de 0 à 99 999, cadré à gauche, 6 colonnes par chiffre. */
static void number(uint32_t x, uint32_t page, uint32_t v)
{
char s[6];
uint32_t n = 0u;
do { s[n++] = (char)(v % 10u); v /= 10u; } while ((v != 0u) && (n < 5u));
while (n > 0u) { digit(x, page, (uint32_t)s[--n]); x += 6u; }
}
et dans la boucle :
static uint32_t compteur = 0u;
fill(0x00u);
for (uint32_t x = 0u; x < 128u; x++) { pixel(x, 0u, 1u); pixel(x, 63u, 1u); } /* cadre */
for (uint32_t y = 0u; y < 64u; y++) { pixel(0u, y, 1u); pixel(127u, y, 1u); }
for (uint32_t x = 0u; x < 64u; x++) { pixel(x, x, 1u); } /* diagonale */
number(70u, 3u, compteur);
flush();
compteur++;
HAL_Delay(1000);
La formule du point. Le point (x, y) est dans la page y / 8, donc
dans l’octet x + (y / 8) × 128, au bit y % 8. Pour l’allumer sans
toucher aux 7 autres points de l’octet, on fait un OU avec un masque
(|=) ; pour l’éteindre, un ET avec le masque inversé (&= ~).
C’est la manipulation de bits la plus courante en informatique embarquée.
Le test des bornes. Sans lui, un point hors de l’écran écrirait en
dehors du tableau fb : dans une autre variable, sans aucun message
d’erreur. Une fonction de dessin doit toujours se protéger.
Les chiffres dans l’ordre. v % 10 donne le dernier chiffre :
on les obtient de droite à gauche. Le tableau s les garde, puis la
seconde boucle les dessine dans l’ordre de lecture. La police tient
exactement dans une page : bit 0 en haut, 7 points de haut, une ligne vide
en bas qui sépare les lignes de texte.
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.
La température à l’écran : affichez la mesure du TMP102 (TP 7 — Le bus I²C : température, accéléromètre et gyroscope), en degrés avec une décimale.
La batterie à l’écran : la tension et une jauge dessinée (TP 6 — Le niveau de batterie).
Grands chiffres : doublez la taille de la police (chaque point devient un carré de 2 × 2).
Balle qui rebondit : un carré de 4 × 4 points qui se déplace et rebondit sur les bords, 25 images par seconde.
Toutes les lettres : complétez la police avec les lettres majuscules.
Après le TP
Pour rendre la carte au robot, voir Remettre la carte en état RatBot.