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 : :doc:`tp01_gpio`. 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. .. tikz:: L'envoi d'un octet en SPI : CS sélectionne l'écran, SCK cadence, MOSI porte les bits. \tikzset{lb/.style={font=\scriptsize}, sg/.style={thick, blue!60!black}} \node[lb, anchor=east] at (-0.2,3.3) {CS}; \draw[sg] (-0.1,3.6) -- (0.4,3.6) -- (0.4,3.0) -- (8.6,3.0) -- (8.6,3.6) -- (9.2,3.6); \node[lb, anchor=east] at (-0.2,2.2) {DC}; \draw[sg] (-0.1,1.9) -- (9.2,1.9); \node[lb, anchor=west] at (9.3,2.2) {0 : commande, 1 : donnée}; \node[lb, anchor=east] at (-0.2,1.1) {SCK}; \draw[sg] (-0.1,0.8) -- (0.8,0.8) \foreach \i in {0,...,7} { -- (0.8+\i,0.8) -- (0.8+\i,1.4) -- (1.3+\i,1.4) -- (1.3+\i,0.8) } -- (9.2,0.8); \node[lb, anchor=east] at (-0.2,0) {MOSI}; \foreach \i/\b in {0/b7,1/b6,2/b5,3/b4,4/b3,5/b2,6/b1,7/b0} { \draw[sg] (0.2+\i,-0.3) -- (0.3+\i,0) -- (0.2+\i,0.3); \draw[sg] (0.3+\i,0.3) -- (1.2+\i,0.3) (0.3+\i,-0.3) -- (1.2+\i,-0.3); \node[lb] at (0.75+\i,0) {\b}; } \draw[sg] (8.2,-0.3) -- (8.3,0) -- (8.2,0.3); \foreach \i in {0,...,7} \draw[black!30, dashed] (0.8+\i,-0.5) -- (0.8+\i,1.5); \node[lb, align=left, anchor=north west] at (0,-0.7) {un octet, poids fort d'abord : l'écran lit MOSI à chaque front montant de SCK, tant que CS est à 0.\\ À 4 MHz, un bit dure 0,25 µs, un octet 2 µs.}; L'écran ~~~~~~~ Un module OLED monochrome de **128 × 64 points**, piloté par un contrôleur **SSD1309**, relié au connecteur ``SCREEN_SPI1`` : .. list-table:: :header-rows: 1 :widths: 20 20 60 * - 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 .. warning:: 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**. .. tikz:: La mémoire de l'écran : 8 pages de 128 octets, chaque octet est une colonne de 8 points. \tikzset{lb/.style={font=\scriptsize}} \foreach \p in {0,...,7} { \node[lb, anchor=east] at (-0.1,-0.45*\p-0.22) {page \p}; \foreach \x in {0, 0.5, 1, 1.5, 3, 3.5, 4, 4.5} \draw[black!40, fill=blue!4] (\x,-0.45*\p) rectangle ++(0.5,-0.45); \node[lb] at (2.5,-0.45*\p-0.22) {…}; } \foreach \c/\x in {0/0, 1/0.5, 2/1, 3/1.5, 124/3, 125/3.5, 126/4, 127/4.5} \node[lb, rotate=90, anchor=west] at (\x+0.25,0.05) {\c}; \node[lb] at (2.5,0.95) {colonnes}; \draw[thick, red!70!black, fill=red!20] (1.5,-0.45) rectangle ++(0.5,-0.45); \begin{scope}[xshift=7cm, yshift=0.4cm] \foreach \b in {0,...,7} { \draw (0,-0.4*\b) rectangle ++(0.6,-0.4); \node[lb, anchor=west] at (0.7,-0.4*\b-0.2) {bit \b}; } \draw[fill=red!40] (0,-1.2) rectangle ++(0.6,-0.4); \node[lb, anchor=west] at (1.4,-0.2) {(ligne 8, en haut)}; \node[lb, anchor=west] at (1.4,-3.0) {(ligne 15, en bas)}; \node[lb] at (0.3,0.35) {octet 131}; \end{scope} \draw[red!70!black, dashed] (2,-0.45) -- (7,0.4); \draw[red!70!black, dashed] (2,-0.9) -- (7,-2.8); \node[lb, align=left, anchor=north west] at (0,-3.9) {un octet = une colonne de 8 points d'une page ; le point (x, y) est le bit (y \% 8)\\ de l'octet x + (y / 8) $\times$ 128. Exemple : le point (3, 11) est le bit 3 de l'octet 131.}; 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_ecran`` comme 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 : .. Capture d'écran à insérer : CubeMX, SPI3 en Transmit Only Master, Data Size 8 Bits, Prescaler 16. Déposez _static/captures/sans_os/tp08-spi3.png puis décommentez le bloc ci-dessous (retirez le préfixe « .. » devant chaque ligne) : .. .. image:: ../_static/captures/sans_os/tp08-spi3.png .. :align: center .. :width: 90% - **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_DC`` et ``SCREEN_RST``. Pour ``SCREEN_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 : .. code-block:: c 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. .. admonition:: Question 1. Remplacez ``0xA6`` par ``0xA7``. Que voit-on ? Et avec ``0xA0`` à la place de ``0xA1`` ? .. dropdown:: Solution de l'étape 2 :icon: code :color: secondary .. code-block:: c /* 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. .. dropdown:: Réponse à la question 1 :icon: light-bulb :color: info **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) : .. code-block:: c 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. .. admonition:: Questions 2. Quel motif d'octets donne des bandes **horizontales** de 8 points ? Et des bandes **verticales** de 8 points ? 3. Combien de temps dure l'envoi de l'image entière à 4 MHz ? Combien d'images par seconde peut-on envoyer au plus ? .. dropdown:: Solution de l'étape 3 :icon: code :color: secondary .. code-block:: c 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 : .. code-block:: c 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. .. dropdown:: Réponses aux questions 2 et 3 :icon: light-bulb :color: info **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.** .. code-block:: text 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. .. code-block:: c 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. .. Capture d'écran à insérer : photo de l'écran : le cadre, la diagonale et le compteur. Déposez _static/captures/sans_os/tp08-ecran-compteur.png puis décommentez le bloc ci-dessous (retirez le préfixe « .. » devant chaque ligne) : .. .. image:: ../_static/captures/sans_os/tp08-ecran-compteur.png .. :align: center .. :width: 90% .. dropdown:: Solution de l'étape 4 :icon: code :color: secondary .. code-block:: c /* 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 : .. code-block:: c 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. .. dropdown:: Tout le code de la correction :icon: download :color: secondary :download:`main_user_code.c <../../corrections/sans_os/tp08/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 (:doc:`tp07_i2c`), en degrés avec une décimale. - **La batterie à l'écran** : la tension et une jauge dessinée (:doc:`tp06_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 :ref:`etat-ratbot`.