Une visionneuse de photos tactile sur ESP32
Comment j’ai construit une petite visionneuse de photos avec un ESP32, un écran tactile de 3,2" et une carte SD, avec un menu, des miniatures et le balayage, sur une puce sans mémoire supplémentaire.

Caractéristiques
| Carte | ESP32 DOIT DevKit V1 (ESP32-WROOM-32, sans PSRAM) |
|---|---|
| Écran | TFT 3,2", ILI9341, 320 × 240, SPI |
| Tactile | XPT2046 résistif, sur le même bus SPI que l’écran |
| Stockage | Module de carte microSD, FAT32, sur son propre bus SPI |
| Bibliothèques | LovyanGFX, JPEGDEC, Arduino SD |
| Compilation | Arduino core pour ESP32 3.3, arduino-cli et un Makefile |
| Vitesse d’écran | 34 images par seconde, plein écran, SPI à 40 MHz |
| Vitesse SD | 1,7 Mo/s en écriture, 1,37 Mo/s en lecture, SPI à 20 MHz |
Schéma fonctionnel
Présentation
Je voulais voir jusqu’où je pouvais pousser ensemble un ESP32, un petit écran tactile de 3,2” et une carte SD. Le résultat est une petite visionneuse de photos. Elle a un menu d’accueil, une page de miniatures que je fais défiler du doigt et une vue plein écran. Deux boutons font les mêmes choses que l’écran tactile.
L’ESP32 que j’utilise n’a pas de mémoire supplémentaire (PSRAM). Il a environ 520 Ko de RAM au total, et beaucoup moins d’un seul bloc. Cette limite a guidé presque tous mes choix.
Le code est écrit en C++ avec l’Arduino core pour ESP32. Pas besoin de tout lire pour suivre la page. Chaque extrait ci-dessous est court et montre une seule idée.
Les composants et le câblage
J’utilise trois puces. L’ILI9341 pilote l’écran, le XPT2046 lit le tactile, et la carte SD contient les photos. Les trois parlent SPI, un bus simple avec un fil d’horloge, un fil de données sortant et un fil de données entrant, plus un fil de sélection (« chip select ») par puce.
L’ESP32 a deux bus SPI libres, alors j’ai donné un bus à chaque tâche.
| Composant | Bus | Broches (GPIO) | Vitesse |
|---|---|---|---|
| Écran | VSPI | SCK 18, MOSI 23, MISO 19, CS 5, DC 16, RST 17 | 40 MHz |
| Tactile | VSPI (partagé) | CS 33, IRQ 34 | 1 MHz |
| Carte SD | HSPI | SCK 14, MOSI 13, MISO 27, CS 15 | 20 MHz |
| Boutons | - | 25 et 26, vers GND | - |
L’écran et la puce tactile partagent un bus. La carte SD est seule sur l’autre. Ainsi, lire un fichier n’attend jamais l’écran, et l’écran n’attend jamais la carte.
Toutes les broches sont dans un seul fichier, pins.h, pour ne les changer qu’à un endroit.
// LCD ILI9341 (VSPI)
#define PIN_LCD_SCLK 18
#define PIN_LCD_MOSI 23
#define PIN_LCD_MISO 19
#define PIN_LCD_CS 5
// SD card (HSPI)
#define PIN_SD_SCLK 14
#define PIN_SD_MOSI 13
#define PIN_SD_MISO 27 // not GPIO12, the board won't boot
#define PIN_SD_CS 15
Le commentaire à côté de MISO compte. GPIO12 est la broche MISO habituelle du HSPI, mais c’est aussi une broche de démarrage (« strapping »). L’ESP32 la lit à la mise sous tension pour choisir la tension de la mémoire flash. Un module de carte SD tire cette ligne vers le haut, donc la puce choisirait 1,8 V et ne démarrerait pas. C’est pour ça que j’ai mis MISO sur GPIO27 dès le départ. Je l’explique plus en détail dans un article du blog.
Compiler et flasher
Je compile depuis le terminal avec arduino-cli et un petit Makefile, sans ouvrir l’IDE Arduino.
$ make build
$ make flash PORT=/dev/ttyUSB0
Dessiner l’écran par bandes
Un écran complet fait 320 × 240 pixels, avec 2 octets par pixel. Cela fait 150 Ko, plus que ce que l’ESP32 peut me donner d’un seul bloc. Je ne garde donc jamais une image entière en mémoire.
Je dessine l’écran par bandes de 24 lignes, avec deux petits tampons de 15 Ko. Pendant que le processeur remplit une bande, le matériel SPI envoie l’autre à l’écran tout seul (c’est le DMA). Le processeur et le bus travaillent en même temps.
int buf = 0;
for (int y = 0; y < height; y += STRIP_H) {
fillStrip(strip[buf], y); // the CPU draws 24 rows
// waits for the other strip to finish, then sends this one with DMA
lcd.pushImageDMA(0, y, 320, STRIP_H, strip[buf]);
buf ^= 1; // switch buffers
}
Je l’ai testé avec une animation « plasma » qui change chaque pixel à chaque image. J’ai mesuré chaque partie.
- Avec le SPI à 40 MHz, j’obtiens 34 images par seconde. Le processeur a besoin de 9,4 ms par image, puis attend le bus 17 ms. La limite, c’est le bus, pas le processeur.
- À 80 MHz, j’atteignais 66 images par seconde, mais des lignes coupées apparaissaient sur l’écran. Mon module et mes fils ne suivent pas, alors je suis resté à 40 MHz.
Afficher une photo plus grande que l’écran
Les photos d’un appareil font souvent 4000 pixels de large, et l’écran en fait 320. La bibliothèque JPEGDEC peut décoder un JPEG en taille réelle, ½, ¼ ou ⅛, ce qui est bien plus rapide que de tout décoder pour réduire ensuite. Je prends la plus petite taille qui reste assez grande.
// largest shrink that still gives at least the size I need
int div = 8;
if (w / 8 < fitW || h / 8 < fitH) div = 4;
if (w / 4 < fitW || h / 4 < fitH) div = 2;
if (w / 2 < fitW || h / 2 < fitH) div = 1;
Ensuite je la mets à la taille exacte ligne par ligne, en gardant les proportions. Ce qui reste autour devient des bandes noires. Les fichiers PNG et BMP passent par LovyanGFX, qui sait les ajuster tout seul.
Des miniatures qui chargent vite
La page de miniatures montre 6 images. Décoder six gros JPEG à chaque fois est lent. La première fois que je crée une miniature (96 × 72), je l’enregistre donc sur la carte SD dans un dossier caché /.thumbs. La fois suivante, je lis juste 14 Ko de pixels prêts.
Chaque miniature enregistrée commence par un petit en-tête. Il retient la taille et la date de la photo d’origine. Quand je remplace une photo, sa miniature est donc refaite.
struct ThumbHeader {
char magic[4]; // "THB1"
uint16_t w, h;
uint32_t srcSize; // size and date of the photo
uint32_t srcTime; // if one changes, rebuild
};
La carte SD
La carte démarre à 20 MHz. Si ça échoue, par exemple avec de longs fils, elle réessaie à 4 MHz.
const uint32_t freqs[] = { 20000000, 4000000 };
for (uint32_t f : freqs) {
if (SD.begin(PIN_SD_CS, sdSpi, f)) return true;
SD.end();
}
return false;
Demander 25 MHz donne quand même 20 MHz. L’ESP32 ne peut diviser son horloge de 80 MHz qu’en 40, 26,7 ou 20 MHz, et la bibliothèque SD ne dépasse pas 25. Avec cette bibliothèque, 20 MHz est donc le vrai maximum.
J’ai écrit un test de vitesse qui écrit 512 Ko, les relit et vérifie chaque octet.
| Taille de bloc | Écriture | Lecture |
|---|---|---|
| 4 Ko | 1,3 Mo/s | 1,37 Mo/s |
| 16 Ko | 1,7 Mo/s | 1,37 Mo/s |
| 32 Ko | 1,7 Mo/s | 1,37 Mo/s |
Écrire par blocs de 16 Ko ou plus va plus vite. La lecture ne change pas, parce que la bibliothèque lit la carte 512 octets à la fois, sans DMA.
Encore un piège. La carte a besoin d’une résistance de tirage (pull-up) sur MISO, et beaucoup de modules n’en ont pas. Avec la version 3.x du core ESP32, pinMode() sur une broche déjà utilisée par le bus SPI la débranche du bus. J’active donc le pull-up autrement.
gpio_pullup_en((gpio_num_t)PIN_SD_MISO); // not pinMode()
Les boutons
Chaque bouton va d’une broche à GND, avec le pull-up interne de l’ESP32. Un appui fait lire LOW à la broche. Un vrai bouton « rebondit » pendant quelques millisecondes, alors je n’accepte un nouvel état que s’il reste stable 20 ms. Un appui court est un clic, et 0,8 s ou plus est un appui long.
bool raw = digitalRead(pin) == LOW;
if (raw != lastRaw) { lastRaw = raw; changedAt = millis(); }
if (raw != stable && millis() - changedAt >= 20) {
stable = raw; // the level held for 20 ms, it's real
...
}
BTN1 passe à l’élément suivant et BTN2 l’ouvre. Un appui long sur BTN2 revient en arrière.
Les problèmes rencontrés
- Écran blanc, pas d’image. Le rétroéclairage marchait, mais l’écran ne recevait aucune commande. C’était un fil mal enfoncé. Lire l’identifiant de l’ILI9341 par MISO (il doit répondre
00 93 41) est maintenant ma première vérification. - Le tactile ne réagissait pas. Deux fils de données du tactile étaient inversés. Je l’ai trouvé sans même toucher l’écran. Le XPT2046 a un capteur de température intégré, et une puce qui marche renvoie une valeur stable d’environ 830 à température ambiante. Des valeurs au hasard voulaient dire qu’elle ne répondait pas.
- Les JPEG progressifs ne s’ouvrent pas. JPEGDEC ne lit que les JPEG « baseline », il faut donc enregistrer les photos ainsi avant de les mettre sur la carte.
- Les grandes cartes SD. Les cartes de plus de 32 Go sont souvent en exFAT, qui n’est pas pris en charge ici. Il faut les formater en FAT32.
Ce que j’ai appris
- La mémoire décide du design. Sans PSRAM, une image entière ne tient pas. Les bandes, les petits tampons et la mémoire réservée seulement quand un écran est ouvert ont tout rendu possible.
- Mesurer avant de deviner. Chronométrer chaque partie a montré que la limite était le bus SPI, pas le processeur. Le processeur seul pourrait dessiner environ 100 images par seconde.
- Deux bus valent mieux qu’un. Séparer l’écran et la carte SD a supprimé tout un type d’attente.
- Se renseigner sur les broches d’abord. Les broches de démarrage, de la flash et d’entrée seule de l’ESP32 peuvent coûter des heures si on les choisit au hasard.
- Le câblage, c’est la moitié du débogage. Mes deux problèmes les plus durs venaient des fils, pas du code. Un petit test qui lit un identifiant ou un capteur sur chaque puce fait gagner beaucoup de temps.
La suite
J’ai essayé de lire de courtes vidéos (MJPEG) depuis la carte. J’atteignais environ 25 images par seconde en 256 × 192, et la vitesse de lecture de la carte était la limite. J’ai aussi des broches prêtes pour des entrées analogiques, des capteurs I2C et un autre appareil SPI.




