用ESP-IDF将SD卡图片显示到液晶屏:从硬件到代码全解析
2026/9/11 18:56:41 网站建设 项目流程

简介:面向物联网嵌入式开发者的ESP32实战例程,演示读取SD卡中的图片并显示到液晶屏,基于ESP-IDF与VSCode环境,使用C语言编写。代码已在ESP32-S3上验证运行,各模块接线在源码中有明晰定义,并添加了详细注释,方便替换硬件或移植到其他型号。资源包共47个文件,压缩后约192KB,以C源码和头文件为主体(各18个),另有VS Code工程配置、txt说明、README文档、sdkconfig和CSV分区表。分区表针对16MiB Flash规划,VSCode的launch/tasks等配置均已备好,打开即可编译、烧录与调试,便于理解项目工程组织。目前已有373人学习浏览,内容围绕SD卡文件读取、图片解码与LCD显示链路展开,适合物联网/嵌入式初学者快速上手,也能为进阶开发者提供改造参考。

1. 用 ESP-IDF 把 SD 卡里的图片显示到液晶屏,不是加个库就完事

SD 卡里存图片、开机后显示到液晶屏,这个需求在物联网终端、离线广告机和手持设备里非常常见。很多第一次做的人会直接找 Arduino 库,把 SD 卡库和 TFT 库拼在一起,图片确实能出来,但分辨率一旦到 320×240 以上、或者图片连续切换,帧率和 CPU 占用就失控了。用 ESP-IDF 从底层把这件事拆开做,读取文件、解码、送显每一步都握在自己手里,后续做 OTA、做菜单切换、加 LVGL 都有余地。这篇文章按我实际会用的方案讲:用 ESP32-S3 作为目标芯片,VSCode 加 ESP-IDF 扩展建工程,SPI 接口接 SD 卡,SPI 接口接液晶屏,先跑通 BMP 再处理 JPEG,最后把性能瓶颈和排错思路一起收掉。

2. SD 卡与液晶屏的硬件分工:总线选型、引脚规划和 VSCode 工程骨架

2.1 先决定 SD 卡走 SDMMC 还是 SPI:两者差别不只是速度

SD 卡在 ESP32 上有两种物理接口:一是专用的 SDMMC 主机控制器,支持 1 线或 4 线模式;二是把卡当作 SPI 从设备挂在 SPI 总线上。标题里这个场景同时要接液晶屏,所以总线怎么分要先定,不然后面写代码全是坑。

我自己做这个项目时,优先把 SDMMC 留给 SD 卡、SPI 留给液晶屏,因为 SDMMC 4 线模式的读取带宽远高于 SPI 模式,而且 SDMMC 控制器跑它的专用时钟,不占用 SPI 总线。如果你的开发板已经把 SD 卡槽接成了 SPI 模式,那就要再规划一个 SPI 主机给液晶屏。ESP32-S3 有 3 个 SPI 主机接口,SPI2 和 SPI3 都可分配给外设使用,SPI0/SPI1 被 Flash 占用不要碰,通常我会把 SPI2 给液晶屏、SPI3 给 SD 卡。

对比项SDMMC 4 线模式SPI 模式
数据带宽时钟 20~40 MHz 下最高约 160 Mbps单 bit 传输,约 10~20 Mbps 实际可用
引脚占用CLK、CMD、D0~D3 共 6 个,有映射约束MOSI、MISO、SCLK、CS 共 4 个,可接任意 GPIO
ESP-IDF 接入sdmmc_host_t+esp_vfs_fat_sdmmc_mountsdspi_host_t+esp_vfs_fat_sdspi_mount
卡兼容性对卡的时序要求略高,劣质卡容易初始化失败兼容性更好,工作频率可降得很低
与液晶屏共存总线独立,互不抢带宽两个外设各占一条 SPI 主机,或分时复用同一根 SPI

如果手头这块板子硬件已经固定,卡槽只能接 SPI,那就别折腾 SDMMC,直接按 SPI 模式写。低速不是问题,下面会看到瓶颈通常不在卡上。

2.2 液晶屏接口和驱动芯片要先对齐

液晶屏这边,SPI 接口最常见的是 ST7789、ILI9341、GC9A01 这类驱动芯片。它们基本都是 8 位命令、8 位参数的 SPI 控制方式,ESP-IDF 自带的esp_lcd组件里已经内置了 ST7789 等驱动的初始化序列,不需要自己抄数据手册去写寄存器,这是个省事点。买屏幕时确认两个信息:驱动芯片型号和接口是几线 SPI,通常 4 线 SPI 需要 SCK、MOSI、DC、CS、RST 这几个引脚。LCD 用 16 bit 像素格式 RGB565 时,一帧 320×240 全屏的数据量约 150 KB,这个数字后面算性能会用到。

2.3 在 VSCode 里新建 ESP-IDF 工程:路径和扩展配置决定排查成本

这一步很多人卡在环境配置上。常见做法是先安装 ESP-IDF 离线安装包或通过 VSCode 扩展的 ESP-IDF 安装器,装完得到一个 IDF 路径,比如 Windows 上常见的C:/esp_544/.espressif/v5.4.4/esp-idf,然后再安装 Espressif IDF 扩展。第一次用的时候,扩展会让填idf.py的路径,注意这里要填到esp-idf目录本身,而不是用户常误填的C:/esp_544/.espressif/v5.4.4/esp-idf/tools/idf.py

在 VSCode 中按下Ctrl+Shift+P执行ESP-IDF: Create Project from Template,选择hello_world模板,目标芯片选esp32s3。工程建立后,.vscode/settings.json里至少要确认这几项:

{ "idf.adapterTargetName": "esp32s3", "idf.port": "/dev/ttyACM0", "idf.flashType": "UART", "idf.espIdfPath": "C:/esp_544/.espressif/v5.4.4/esp-idf", "idf.openOcdConfigs": ["board/esp32s3-builtin.cfg"] }

这里idf.flashType用 UART 还是 JTAG,取决于你的 USB 转串口芯片;idf.openOcdConfigs只在调试时用,只烧录的话可以留空。Windows 下 ESP-IDF 路径最常见的问题是盘符大小写和反斜杠,VSCode 的 JSON 配置里统一用正斜杠,且路径不能带空格,否则编译时会出现类似The path for ESP-IDF is not valid的报错。

2.4 三个提前改好的 sdkconfig 参数

ESP-IDF 的配置全部收敛在sdkconfig文件里,VSCode 里点齿轮图标可以打开menuconfig,但工程初始化时直接写一个sdkconfig.defaults更省事。最小需要确认的参数是这组:

CONFIG_FATFS_LFN_HEAP=y CONFIG_FATFS_MAX_LFN=255 CONFIG_SPI_MASTER_FREQ_10M=y

第一个参数开启 FATFS 长文件名支持,否则 SD 卡里超过 8.3 格式的文件名会显示成截断状态,比如landscape_image变成LANDSC~1,后续按文件名找图片会失败。第二个参数把长文件名长度上限提到 255 字节。第三个参数是 SPI 主机默认时钟源配置,它只影响默认时钟选择,实际工作频率在代码里可以再单独指定。这三个参数不涉及硬件,任何时候改都安全。

3. 挂载 SD 卡并初始化液晶屏:先把两条链路分别跑通

3.1 SD 卡通过 SPI 挂载的完整代码与每一步的错误分支

我把 SD 卡放在 SPI3 主机上。挂载动作不是简单地调用f_open,第一步是初始化 SPI 总线,第二步是往这条总线上注册一个 SD 卡设备,第三步才是把 FAT 文件系统挂到/sdcard路径上。看代码:

#include <stdio.h> #include "freertos/FreeRTOS.h" #include "esp_log.h" #include "driver/spi_master.h" #include "driver/sdspi_host.h" #include "sdmmc_cmd.h" #include "esp_vfs_fat.h" #define PIN_SD_CLK 12 #define PIN_SD_MOSI 11 #define PIN_SD_MISO 13 #define PIN_SD_CS 10 static const char *TAG = "sd_card"; void sd_card_mount(void) { spi_bus_config_t bus_cfg = { .mosi_io_num = PIN_SD_MOSI, .miso_io_num = PIN_SD_MISO, .sclk_io_num = PIN_SD_CLK, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 16 * 1024, }; ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &bus_cfg, SDSPI_DEFAULT_DMA)); sdmmc_host_t host = SDSPI_HOST_DEFAULT(); host.slot = SPI3_HOST; host.max_freq_khz = 20000; sdspi_device_config_t slot_config = SDSPI_DEVICE_CONFIG_DEFAULT(); slot_config.gpio_cs = PIN_SD_CS; slot_config.host_id = SPI3_HOST; esp_vfs_fat_sdmmc_mount_config_t mount_config = { .format_if_mount_failed = false, .max_files = 5, .allocation_unit_size = 16 * 1024, }; sdmmc_card_t *card = NULL; esp_err_t ret = esp_vfs_fat_sdspi_mount("/sdcard", &host, &slot_config, &mount_config, &card); if (ret != ESP_OK) { ESP_LOGE(TAG, "SD mount failed: %s", esp_err_to_name(ret)); return; } sdmmc_card_print_info(stdout, card); }

逻辑说明:spi_bus_initialize把 SPI3 主机初始化为一个共享总线,max_transfer_sz设成 16 KB,这保证后续单次 DMA 传输至少能覆盖一块 FAT 扇区。SDSPI_HOST_DEFAULT()返回一个默认的 Host 结构,里面包含slotmax_freq_khz两个关键字段,我将max_freq_khz设为 20000,即 20 MHz,这是一个兼容性与速度折中的值;如果卡初始化时报0x103之类的 CRC 错误,优先把它降到 10000 甚至 4000 再验证硬件连线。

esp_vfs_fat_sdspi_mount是核心函数,它内部完成三件事:注册sdspi驱动、识别 SD 卡并读取 CID/CSD 信息、调用 FATFS 把文件系统挂载到/sdcard。参数format_if_mount_failed务必设成false,如果设成true,一张包含重要数据的卡在挂载失败时会被直接格式化。max_files控制 FATFS 同时打开的文件句柄数,图片轮播场景开 5 个足够。挂载成功后,/sdcard这个路径就可以当作普通 POSIX 路径直接用fopenfread

如果你的板子把 SD 卡接到了 SDMMC 控制器,把上面代码里的SDSPI_HOST_DEFAULT()换成SDMMC_HOST_DEFAULT()esp_vfs_fat_sdspi_mount换成esp_vfs_fat_sdmmc_mount,同时把slot_config换成sdmmc_slot_config_t并手动指定 CLK、CMD、D0~D3 引脚,其余逻辑完全相同。

3.2 用 esp_lcd 初始化 ST7789:把像素送显的通道打开

液晶屏这边,我建议走 ESP-IDF 自带的esp_lcd驱动框架,ST7789 的初始化时序由组件内部完成,不用自己对着数据手册敲命令。以下代码把 SPI2 配给 LCD:

#include "esp_lcd_panel_io.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_vendor.h" #define PIN_LCD_CLK 5 #define PIN_LCD_MOSI 6 #define PIN_LCD_DC 7 #define PIN_LCD_CS 8 #define PIN_LCD_RST 9 static esp_lcd_panel_handle_t panel = NULL; void lcd_init(void) { spi_bus_config_t bus_cfg = { .mosi_io_num = PIN_LCD_MOSI, .miso_io_num = -1, .sclk_io_num = PIN_LCD_CLK, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 32 * 1024, }; ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO)); esp_lcd_panel_io_handle_t io_handle = NULL; esp_lcd_panel_io_spi_config_t io_config = { .dc_gpio_num = PIN_LCD_DC, .cs_gpio_num = PIN_LCD_CS, .pclk_hz = 40 * 1000 * 1000, .lcd_cmd_bits = 8, .lcd_param_bits = 8, .spi_mode = 0, .trans_queue_depth = 10, }; ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &io_handle)); esp_lcd_panel_dev_config_t panel_config = { .reset_gpio_num = PIN_LCD_RST, .color_space = ESP_LCD_COLOR_SPACE_RGB, .bits_per_pixel = 16, }; ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel)); ESP_ERROR_CHECK(esp_lcd_panel_reset(panel)); ESP_ERROR_CHECK(esp_lcd_panel_init(panel)); esp_lcd_panel_invert_color(panel, true); }

这段代码里值得说的参数有三个。pclk_hz是 LCD 的像素时钟,40 MHz 对于 ST7789 和短排线来说是合理的值,如果你的屏幕出现雪花点或者错位,调低到 20 MHz 是最快的判断手段。trans_queue_depth是 SPI 传输队列深度,设 10 表示底层同时可以排队 10 个传输事务,配合 DMA 能在连续刷图时减少 CPU 等待。esp_lcd_panel_invert_color(panel, true)不是所有屏幕都需要,ST7789 的默认极性在不同厂商模组上有差异,颜色反相时把这行改为false即可。

到这里,SD 卡和 LCD 两条底层通路已经各自能工作,下一步就是把图片文件读出来画上去。

4. 读取图片文件并渲染到屏幕:BMP 与 JPEG 两条路线

4.1 优先用 BMP 跑通全链路:直接操作像素最可控

图片格式越简单,越容易验证整个链路是否正常。BMP 是其中最直接的一种,没有压缩算法,文件头记录尺寸、位深和像素偏移。我建议第一版代码只支持 24 位 BMP,因为它的每个像素是固定的 BGR 三字节,做颜色转换最直观。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include "esp_log.h" #define IMG_PATH "/sdcard/test.bmp" #define LCD_WIDTH 320 #define LCD_HEIGHT 240 static void show_bmp(const char *path) { FILE *f = fopen(path, "rb"); if (f == NULL) { ESP_LOGE("bmp", "open failed"); return; } uint8_t header[54]; if (fread(header, 1, 54, f) != 54) { fclose(f); return; } if (header[0] != 'B' || header[1] != 'M') { ESP_LOGE("bmp", "not a bmp file"); fclose(f); return; } uint32_t pixel_offset = header[10] | (header[11] << 8) | (header[12] << 16) | (header[13] << 24); uint32_t width = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); uint32_t height = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24); uint32_t bpp = header[28] | (header[29] << 8); if (width != LCD_WIDTH || height != LCD_HEIGHT || bpp != 24) { ESP_LOGE("bmp", "unsupported: %lux%lu %lubpp", width, height, bpp); fclose(f); return; } uint32_t row_size = ((24 * width + 31) / 32) * 4; uint8_t *row = malloc(row_size); uint16_t *buf = malloc(width * 2); if (row == NULL || buf == NULL) { fclose(f); return; } fseek(f, pixel_offset, SEEK_SET); for (uint32_t y = 0; y < height; y++) { if (fread(row, 1, row_size, f) != row_size) { break; } for (uint32_t x = 0; x < width; x++) { uint16_t b = row[x * 3 + 0]; uint16_t g = row[x * 3 + 1]; uint16_t r = row[x * 3 + 2]; buf[x] = (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3); } esp_lcd_panel_draw_bitmap(panel, 0, height - 1 - y, width, height - y, buf); } free(row); free(buf); fclose(f); }

逻辑说明:BMP 文件头固定 54 字节,pixel_offset在第 10 到 13 字节,24 位 BMP 通常是 54,但有些文件带着调色板信息,所以不能写死。BMP 像素数据按行存放,每一行需要按 4 字节对齐,所以行字节数不是简单的width * 3,而是要先用(24 * width + 31) / 32 * 4计算。BMP 的存储顺序是左下角为第一行,所以绘制时屏幕的y坐标要做翻转,否则图片上下颠倒。

这段代码一次只分配一行缓冲和一个 RGB565 行缓冲,内存占用不到 700 字节,即使后续跑多张图片轮播也不会因为堆不足而出问题。esp_lcd_panel_draw_bitmap的参数是起始和结束坐标,结束坐标是开区间,因此绘制一行时传入height - y作为结束行,符合驱动对坐标区间的约定。

4.2 JPEG 是实际项目躲不开的格式:引入 esp_jpeg 组件

BMP 文件体积太大,一张 320×240 的图要占 225 KB,实际 SD 卡里存量大的还是 JPEG。ESP-IDF 官方组件仓库里espressif/esp_jpeg是 IDF 配套的 JPEG 解码方案,封装了基于 libjpeg 的解码接口。在工程根目录执行这条指令把它加进依赖:

idf.py add-dependency "espressif/esp_jpeg^1.0.5"

这条命令会改写main/idf_component.yml,编译时会自动下载组件。JPEG 的解码流程比 BMP 多了两个环节:先解析文件头拿到尺寸,再解码整帧得到 YCbCr 数据,最后转成 RGB565。esp_jpeg 的解码函数会一次性输出完整帧,所以需要一块宽乘高乘 2 字节的输出缓冲,比如 320×240 全屏就是 150 KB。对于 ESP32-S3 的 512 KB SRAM 来说,这个缓冲可以承受,但如果你用经典 ESP32 且开了 WiFi,堆空间会比较紧张,这时要么降低图片分辨率,要么换成 LVGL 内置的lv_pnglv_jpg解码器,它们支持分块解码。

有一个需要注意的点:esp_jpeg 内部转换默认输出的是 RGB888,如果直接拿 RGB888 交给esp_lcd_panel_draw_bitmap,必须先把panel_config.color_space改成ESP_LCD_COLOR_SPACE_RGB并且bits_per_pixel设成 24,否则颜色完全错乱。更常见的做法是解码后自己做一次 RGB888 到 RGB565 的降位转换,代码和上面 BMP 里的转换循环完全一样。JPEG 解码耗时一般在几十毫秒到几百毫秒之间,取决于图片内容和质量参数,因此实际产品里很少在刷屏任务里同步做解码,而是先解码到一块缓冲,再异步送显。

4.3 把解码和刷屏拆成两个任务,避免卡顿

SD 卡读取、JPEG 解码、SPI 送显,这三件事共用 CPU 时间片。如果在一个函数里同步执行,整张图片显示完之前,系统无法响应其他事件。FreeRTOS 的典型做法是创建两个任务:解码任务负责打开文件、解码、把 RGB565 数据放进一个环形缓冲区;显示任务从环形缓冲区取数据,调用esp_lcd_panel_draw_bitmap分块送显。用一个xSemaphoreCreateBinary做同步,显示任务刷完一行就xSemaphoreGive通知解码任务继续。

对于 BMP 图,由于读取和解码几乎不消耗额外时间,同步方案也可以接受;但对于 JPEG,强烈建议按这个方式拆分,否则后续接按键切换图片时,按键扫描会被阻塞。

5. 花屏、卡顿与写保护:显示链路的性能优化与排错

5.1 先算清一次全屏刷新要花多少时间

以 320×240 为例,一帧 RGB565 数据是 153,600 字节。SPI 在 40 MHz 时钟下单 bit 传输的理论时间是 153600 * 8 / 40MHz,约 30.7 毫秒,但 SPI 协议有命令和地址开销,DMA 描述符也要占周期,实际跑到 50 到 80 毫秒很正常。再叠加 SD 卡读取 150 KB 的时间和 JPEG 解码时间,一帧总耗时可能超过 300 毫秒,帧率只有 3 FPS 左右。

这就是分辨率和总线架构带来制约的地方。提高性能有三个优先级:把 LCD 的pclk_hz提到 60 或 80 MHz,看屏幕是否还能稳定显示;把 SD 卡的max_freq_khz从默认值提升到 40000;如果卡支持 SDMMC 4 线模式,把 SD 整体迁到 SDMMC 主机,释放 SPI 带宽。最后一个改动效果最明显,因为 SD 的 SPI 模式读取一个扇区需要反复发送命令,协议开销比 SDMMC 大得多。

5.2 花屏和颜色错误的定位顺序

花屏先判断是数据错位还是颜色错位。数据错位表现为图像内容有规律地错行或出现斜条纹,优先查esp_lcd_panel_draw_bitmap传入的起始和结束坐标,开区间和闭区间混用是最常见的错因。颜色错位表现为整体偏蓝或偏红,优先检查color_spacebits_per_pixel是否匹配,再检查 BMP 转换循环里 BGR 和 RGB 的顺序。屏幕上下颠倒时,把坐标里的height - 1 - y改成y即可,不需要改驱动。

SPI 速率过高导致的花屏和上面两类不同,它的特征是低速率下图正常、提高pclk_hz后才出现,这种情况直接降回原来的频率,再通过调整杜邦线长度或改用屏蔽线来改善信号质量。

5.3 一组常见错误对照
现象常见原因处理手段
挂载失败,日志出现0x103SD 卡 SPI 时钟过高或连线过长max_freq_khz降到 10000,检查 CS/CLK 是否接反
挂载失败,ESP_ERR_NOT_FOUND卡没有正确上电或 CS 引脚未初始化确认卡的 VCC 供电电压,SD 卡必须 3.3V,不能直接接 5V
明明没锁却提示写保护卡槽的写保护检测引脚悬空或电平错误检查 GPIO 上下拉,SDMMC 模式需要使能SDMMC_SLOT_FLAG_INTERNAL_PULLUP
能读目录但打开文件失败文件名编码或长文件名未使能确保CONFIG_FATFS_LFN_HEAP=y,文件名不带中文
图片显示一半后停住max_files耗尽或文件被反复打开未关闭检查每次fopen后是否fclose,把max_files适当调大
刷屏时系统卡死SPI 传输队列满且未等 DMA 完成调大trans_queue_depth,或在送显前调用esp_lcd_panel_draw_bitmap的前后加上互斥锁

你如果遇到的是写保护误报,还有一个常见坑是卡槽机械开关本身损坏,修不了就直接把卡的写保护检测引脚对应的 GPIO 强制拉高,在软件层面绕过。

6. 进阶玩法:用 SD 卡图片做开机动画和轮播菜单

跑通单张图片后,下一步通常是把这块屏做成能展示多张图的终端。先做一个目录扫描,把/sdcard/images下的.bmp.jpg文件名读进一个字符串数组,再用一个 FreeRTOS 任务驱动状态机切换。状态机的好处是解码耗时不会再阻塞主逻辑,按键可以随时响应。

opendirreaddir枚举目录时,注意 FATFS 的长文件名读取需要缓冲,建议给每个文件分配 256 字节的缓冲。轮播逻辑里我会用一个简单的结构体记录当前索引和目标图片数量,切换时先释放上一张的显示缓冲,再加载下一张。如果每张图不需要全屏,可以给绘制函数加一个dst_xdst_y偏移参数,把图片贴在屏幕的任意位置。

再进一步,显示层可以接后端的 OTA 流程:把固件放置在 SD 卡某个分区,设备启动时检查版本号,如果 SD 卡里有新固件就通过esp_ota_ops执行升级。这套逻辑和图片显示共用 SD 卡驱动,只需要在初始化时多挂载一个esp_ota_select的检查分支。

最后给一个具体建议:如果项目里照片数量超过几十张,不要把文件名写死在代码里,而是把图片清单生成一个manifest.txt放在 SD 卡根目录,设备启动先读清单再逐个显示,后续更换图片只需要替换 SD 卡内容,不用重新编译固件。这样把硬件资源的改动限制在存储介质上,整个系统的可维护性会高很多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询