ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用ESP-IDF将SD卡图片显示到液晶屏:从硬件到代码全解析

2026/9/11 18:56:41 拓冰建站 浏览量
用ESP-IDF将SD卡图片显示到液晶屏:从硬件到代码全解析 简介面向物联网嵌入式开发者的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 个可接任意 GPIOESP-IDF 接入sdmmc_host_tesp_vfs_fat_sdmmc_mountsdspi_host_tesp_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 中按下CtrlShiftP执行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_HEAPy CONFIG_FATFS_MAX_LFN255 CONFIG_SPI_MASTER_FREQ_10My第一个参数开启 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 结构里面包含slot和max_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 路径直接用fopen、fread。如果你的板子把 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_png、lv_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_space和bits_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_HEAPy文件名不带中文图片显示一半后停住max_files耗尽或文件被反复打开未关闭检查每次fopen后是否fclose把max_files适当调大刷屏时系统卡死SPI 传输队列满且未等 DMA 完成调大trans_queue_depth或在送显前调用esp_lcd_panel_draw_bitmap的前后加上互斥锁你如果遇到的是写保护误报还有一个常见坑是卡槽机械开关本身损坏修不了就直接把卡的写保护检测引脚对应的 GPIO 强制拉高在软件层面绕过。6. 进阶玩法用 SD 卡图片做开机动画和轮播菜单跑通单张图片后下一步通常是把这块屏做成能展示多张图的终端。先做一个目录扫描把/sdcard/images下的.bmp和.jpg文件名读进一个字符串数组再用一个 FreeRTOS 任务驱动状态机切换。状态机的好处是解码耗时不会再阻塞主逻辑按键可以随时响应。用opendir和readdir枚举目录时注意 FATFS 的长文件名读取需要缓冲建议给每个文件分配 256 字节的缓冲。轮播逻辑里我会用一个简单的结构体记录当前索引和目标图片数量切换时先释放上一张的显示缓冲再加载下一张。如果每张图不需要全屏可以给绘制函数加一个dst_x、dst_y偏移参数把图片贴在屏幕的任意位置。再进一步显示层可以接后端的 OTA 流程把固件放置在 SD 卡某个分区设备启动时检查版本号如果 SD 卡里有新固件就通过esp_ota_ops执行升级。这套逻辑和图片显示共用 SD 卡驱动只需要在初始化时多挂载一个esp_ota_select的检查分支。最后给一个具体建议如果项目里照片数量超过几十张不要把文件名写死在代码里而是把图片清单生成一个manifest.txt放在 SD 卡根目录设备启动先读清单再逐个显示后续更换图片只需要替换 SD 卡内容不用重新编译固件。这样把硬件资源的改动限制在存储介质上整个系统的可维护性会高很多。本文还有配套的精品资源点击获取