ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32+ST7789屏幕驱动实战:从SPI配置到动态刷新

ESP32+ST7789屏幕驱动实战:从SPI配置到动态刷新 做了这么多年嵌入式我一直觉得点屏这件事特别能反映一个开发者的基本功。ESP32搭配ST7789驱动的小屏幕几乎是DIY圈和产品原型阶段最常见的组合但很多人在SPI配置这一步就卡住了要么白屏要么花屏要么刷新速度上不去。这篇文章我就用ESP-IDF从零开始手把手捋一遍把SPI总线参数、初始化序列、动态内容显示这几块真正讲透你照着走一遍就能跑起来。1. 为什么是ST7789 ESP-IDF屏幕选型与开发框架的匹配逻辑1.1 ST7789这颗驱动IC到底强在哪ST7789是矽创电子推出的一款TFT-LCD驱动控制器最大支持240x320分辨率常见市售模组是1.3寸、1.54寸、1.69寸、2.0寸这些规格。它用SPI接口通信最少只需要4根线SCLK、MOSI、CS、DC如果忽略复位引脚还能更省。相比同门师兄ST7735ST7789的优势在于分辨率更高、支持RGB 18bit颜色深度也能配置成16bit、边框更窄所以这两年四线SPI小屏市场基本被ST7789吃掉了。它最有意思的一点是支持多种分辨率配置同样是ST7789芯片可以驱动不同的面板——有的模组是240x240有的是240x320还有的是135x240。这个特性在后面初始化配置时是一个大坑后面我会专门讲。1.2 为什么不用Arduino库或标准库而是ESP-IDFArduino生态下有TFT_eSPI、Adafruit_GFX这类库几行代码就能点亮屏幕但Arduino框架对底层SPI的封装太“温柔”了——它帮你把DMA、时序、片选管理都隐藏掉了一旦遇到性能瓶颈或者奇怪的兼容性问题你根本不知道去哪一层排查。标准库的思路又太“裸”没有ESP32这种多核SoC上的中断、DMA、任务调度这些高级资源的统一管理接口写起来累。ESP-IDF是Espressif官方维护的物联网开发框架它介于两者之间一方面提供了完整的外设驱动API比如spi_master、spi_slave、driver/dma另一方面保留了足够多的底层控制权你可以精确控制SPI时钟频率、数据位长、DMA通道乃至使用双缓冲来避免画面撕裂。用ESP-IDF驱动ST7789不是简单画个点而是把MCU核心外设怎么协同工作这件事搞清楚。这也是为什么我强烈建议“想进阶的人”走ESP-IDF这条路。1.3 硬件连接与物料准备我这次用的是一块ESP32-DevKitC V4开发板加一块1.54寸240x240分辨率的ST7789模组。接线如下ST7789引脚ESP32引脚说明VCC3V3电源部分模组是5V兼容注意看丝印GNDGND共地SCL / SCLKGPIO18SPI时钟SDA / MOSIGPIO23SPI主机输出CSGPIO5片选可软件控制也可硬件控制DCGPIO4数据/命令选择RSTGPIO2复位可接GPIO也可接3.3V上拉BLK / BLGPIO22背光控制可接PWM这里有个小细节很多模组把DC叫做A0把CS叫做SDA丝印各不相同买回来第一件事是拿万用表对着说明书把引脚名对应清楚别上来就默认某种命名。ESP32的SPI外设FSPI通常映射到GPIO18/19/23/25和HSPI都可以用我习惯用VSPI也就是GPIO18作为SCLK、GPIO23作为MOSI避免和Flash等系统外设冲突。2. SPI总线参数是第一道门槛从规格书到ESP-IDF配置2.1 规格书里那些容易忽略的时序参数ST7789数据手册的时序章节写得又密又枯燥但无非就那几件事SPI Mode、时钟频率上限、数据位序。ST7789支持SPI Mode 0CPOL0, CPHA0和SPI Mode 3CPOL1, CPHA1我们一般用Mode 0。关键点是SPI Mode决定了时钟空闲电平和数据采样沿配置错了以后屏幕不是完全不亮而是显示内容错位、颜色异常这种“看起来有反应但完全不对”的故障排查起来特别花时间远不如直接白屏好定位。时钟频率方面ST7789手册上写的是支持最高约62.5MHz的SPI时钟但那是芯片极限实际要看模组走线、电源质量和ESP32引脚驱动能力。我实测ESP32在40MHz下稳定60MHz偶发乱码80MHz花屏概率明显增大。原因在PCB走线寄生电容和信号完整性板载排线越长越明显。所以我的建议是功能验证阶段先用20MHz跑通后再慢慢往上提等确认稳定了再定最终频率。位序一定是MSB First这是绝大多数SPI器件的默认值。ESP-IDF里spi_transfer_t结构体没有专门配置位序的选项因为它默认就是MSB First所以这块反而不容易出错。2.2 ESP-IDF的SPI总线和设备配置ESP-IDF从v4.x到v5.xSPI驱动接口有个明显变化v5开始以spi_master为前缀重命名了大部分函数和类型我用的是ESP-IDF v5.2。初始化分两层首先初始化spi_bus然后在这个总线上挂一个spi_device。#include driver/spi_master.h #include esp_lcd_panel_io.h // 后面会用到 #define ST7789_SCLK_PIN 18 #define ST7789_MOSI_PIN 23 #define ST7789_CS_PIN 5 #define ST7789_DC_PIN 4 #define ST7789_RST_PIN 2 spi_bus_config_t buscfg { .sclk_io_num ST7789_SCLK_PIN, .mosi_io_num ST7789_MOSI_PIN, .miso_io_num -1, .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz 240 * 240 * 2 8, // 确保一次能传一帧16bit颜色数据 }; spi_device_interface_config_t devcfg { .clock_speed_hz 40 * 1000 * 1000, // 40MHz .mode 0, .spics_io_num ST7789_CS_PIN, .queue_size 8, .flags SPI_DEVICE_HALFDUPLEX, };这里最容易被忽略的是.flags。ST7789是半双工设备——主机发送命令和数据从不读数据回传除非你读寄存器。.flags SPI_DEVICE_HALFDUPLEX告诉驱动层这个设备不用分配MISO相关资源同时还会走更快的数据传输路径。我见过有人抄代码把flags漏了虽然多数情况能工作但在某些ESP32封装版本上会降低DMA效率。调用初始化函数spi_bus_initialize(SPI2_HOST, buscfg, SPI_DMA_CH_AUTO); spi_bus_add_device(SPI2_HOST, devcfg, spi_handle);第二个参数是DMA通道ESP-IDF v5以后支持SPI_DMA_CH_AUTO让驱动自动选通道省心。第三个参数返回device句柄后面所有收发都以它为操作对象。2.3 硬件片选与软件片选什么时候用哪个ST7789的CS引脚可以接ESP32的GPIO由SPI外设自动拉低/拉高也可以接普通GPIO手动控制。两种方式在ESP-IDF里差别很明显硬件片选在spi_device_interface_config_t里设置.spics_io_num 5然后传输时驱动自动处理CS时序。软件片选.spics_io_num -1自己在发送命令前gpio_set_level(5, 0)发送完再拉高。我的经验是能硬件片选就硬件片选。软件片选在单次传输时没问题但在连续传输或者配合DMA时会引入片选时序毛刺——CS信号拉低后到时钟第一个沿之间需要一定建立时间软件控制容易把这个时间做太短模组逻辑就会漏收字节表现为主控发送一切正常但屏幕纹丝不动或者画面残缺。顺带提一嘴DC引脚。DC不是SPI信号它决定当前字节是命令还是数据。编程上发送命令时拉低DC发送数据时拉高DC。ESP-IDF的esp_lcd_panel_io接口也是这么干的命令和数据的区分靠一个类型变量传给传输函数。2.4 常见SPI配置错误导致的现象对照配置错误故障现象原因分析SPI Mode配置成Mode 1或Mode 2花屏、颜色错位数据和时钟采样沿不匹配时钟频率过高偶发乱码、刷新中断、横条纹信号完整性不足未配HALFDUPLEX部分场景刷新慢甚至卡死驱动尝试读MISO等不到数据软件片选时序太短屏幕无反应但SCLK有波形CS建立时间不够写代码之前把这几项先确认了能省掉后面一大半调屏时间。3. 初始化序列剖析ST7789从睡醒到正常显示的关键步骤3.1 厂家初始化代码的常见结构ST7789上电后并不是直接就能显示它内部有个状态机需要主机按顺序发送一组初始化命令。网上能搜到各种“初始化数组”什么0x36、0x3A、0xB2、0xE8看起来像天书。其实去读ST7789数据手册就会发现它们都可以归为几类通信和复位类SWRESET0x01、SLPOUT0x11显示控制基础类MADCTL0x36、COLMOD0x3A面板时序类PORCTRL0xB2、GCTRL0xB7、VCOMS0xBB、Lcmctrl0xC0显示开关类INVON0x21、DISPON0x29伽马校正类PVGAMCTRL0xE0、NVGAMCTRL0xE1大多数厂家代码都是先把电源和时序稳定下来B2、B7、BB再设置显示方向和颜色格式36、3A最后开显示11、29。顺序不能乱因为后续命令依赖前面的寄存器状态。3.2 关键寄存器逐个解读MADCTL0x36是最实用的寄存器它通过bit7MY、bit6MX、bit5MV控制扫描方向也就是决定你的画面是从左上角开始还是从右下角开始。屏幕物理方向不同行地址列地址映射就不同同样的代码在竖屏模组上正常换成横屏模组就会左右或上下镜像。我一般通过这个寄存器配合set_window函数来适配不同分辨率模组。COLMOD0x3A设置像素格式我们常用的值是0x05RGB444、0x06RGB666、0x07RGB565。RGB565是16bit一个像素占2字节在MCU上处理最方便颜色表现也不差。如果你设置成RGB666但代码里按RGB565传数据屏幕颜色会整体偏紫偏绿这也是常见疑难杂症。PORCTRL0xB2、GCTRL0xB7、VCOMS0xBB这些是面板特性寄存器它们和具体玻璃模组的驱动电压、功耗设置有关。不同厂家的模组即使都是ST7789芯片这几组值往往也不能通用。你在网上抄的初始化数组如果屏是同一型号芯片但不同批次画面可能偏淡、偏暗甚至出现残影就得考虑是不是这些模拟参数不匹配。3.3 ESP-IDF下发送初始化命令的标准姿势用ESP-IDF发送初始化序列最直接的方式就是通过spi_device句柄逐条发送。命令字节和数据字节要区分DC引脚电平。我封装了两个函数void st7789_send_cmd(uint8_t cmd, const uint8_t *data, size_t len) { spi_transaction_t trans {0}; trans.length 8; // 命令字节 trans.tx_buffer cmd; trans.user (void *)0; // 表示这是命令 spi_device_polling_transmit(spi_handle, trans); if (data len 0) { trans.length len * 8; trans.tx_buffer data; trans.user (void *)1; // 表示这是数据 spi_device_polling_transmit(spi_handle, trans); } }这里的trans.user字段后面要用来决定DC电平见后续esp_lcd_panel_io的实现的说明。核心初始化序列如下uint8_t swreset 0x00; st7789_send_cmd(0x01, NULL, 0); // SWRESET vTaskDelay(pdMS_TO_TICKS(150)); st7789_send_cmd(0x11, NULL, 0); // SLPOUT vTaskDelay(pdMS_TO_TICKS(200)); uint8_t madctl 0x00; st7789_send_cmd(0x36, madctl, 1); uint8_t colmod 0x05; st7789_send_cmd(0x3A, colmod, 1); st7789_send_cmd(0x21, NULL, 0); // INVON uint8_t porctrl[] {0x0C, 0x0C, 0x00, 0x33, 0x33}; st7789_send_cmd(0xB2, porctrl, sizeof(porctrl)); uint8_t gctrl 0x35; st7789_send_cmd(0xB7, gctrl, 1); uint8_t vcoms 0x19; st7789_send_cmd(0xBB, vcoms, 1); uint8_t lcmctrl 0x2C; st7789_send_cmd(0xC0, lcmctrl, 1); uint8_t vdv 0x12; st7789_send_cmd(0xC1, vdv, 1); uint8_t vrh 0x0B; st7789_send_cmd(0xC5, vrh, 1); st7789_send_cmd(0x29, NULL, 0); // DISPON vTaskDelay(pdMS_TO_TICKS(100));这个序列不一定适合所有模组但它是ST7789芯片级通用的基础序列能保证屏幕点亮。如果画面偏色或亮度不对再针对自己的模组调PORCTRL和伽马寄存器的值。3.4 为什么初始化顺序不能乱改有人图省事想去掉SLPOUT直接开DISPON或者把SWRESET省略掉结果屏幕就是一直黑。原因是ST7789内部有上电时序要求SLPOUT命令会让芯片从sleep模式切换到normal模式而DISPON必须等SLPOUT完成且内部时钟稳定后才能生效。中间那些PORCTRL之类的设置如果放在SLPOUT之前一部分寄存器会被Sleep模式的内部复位逻辑覆盖掉。我有一次为了省时间把SLPOUT之后的延时从120ms改成了30ms结果屏幕间歇性闪烁还以为是电磁干扰。后来示波器看SLPOUT命令和后续命令之间的间隔发现内部DC-DC根本没稳定加回100ms以上就好了。屏幕驱动这件事时序上的“等一等”往往比算法上的“优化”更重要。4. 动态内容显示从静态图片到帧率流畅的实战4.1 全屏刷新 vs 局部刷新什么时候用哪种屏幕点亮只是第一步动态显示才是真正的分水岭。ST7789内部有一整块GRAMGraphic RAMMCU往GRAM里写什么屏幕就显示什么。刷新分两种思路全屏刷新每次把整帧数据比如240x240x2115200字节通过SPI写入GRAM。局部刷新只更新屏幕上变化的那一小块区域比如一个进度条、一段滚动数字、一个心率波形。全屏刷新思路简单但SPI带宽是硬约束。40MHz时钟下理论最快传输速度是5MB/s传一帧115200字节最快也要23ms实际加上命令开销、DMA启动开销能跑到20fps就不错了。局部刷新则是只调用CASET0x2A和RASET0x2B设置窗口然后往里写那一小块的数据。比如画一个60x20像素的进度条一次只写2400字节10ms以内能搞定刷新率自然翻倍。4.2 DMA刷新SPI传输不阻塞CPU的原理与实现动态内容一个比SPI带宽更棘手的问题是CPU占用。如果每一帧都靠CPU死等SPI逐字节发送完那CPU就什么事情都干不了了这在跑复杂业务逻辑的产品里是致命的。ESP-IDF的解决方案是DMA把准备好的帧数据放到内存缓冲区告诉DMA通道“从缓冲区把这些字节搬给SPI外设”然后CPU立刻返回去算下一帧或者处理其他任务传输完成通过中断或回调通知你。DMA配置的核心是把帧缓冲区地址设成DMA可用。ESP32的DMA对内存有要求必须物理连续且地址对齐到4字节。所以最稳妥的做法是用heap_caps_malloc分配DMA能力的内存uint8_t *frame_buf heap_caps_malloc(240 * 240 * 2, MALLOC_CAP_DMA);注意不能用普通的malloc否则底层DMA传输出错或数据错乱。如果用了esp_lcd_panel_io_tx_color底层会直接走DMA并把你的缓冲区地址传入但前提是这个缓冲区满足DMA要求。DMA发送的代码有两种风格。一种是用spi_device_queue_trans把传输扔进队列然后在另一处调用spi_device_get_trans_result等待完成另一种是注册传输完成回调在回调里置标志位。我习惯用后者因为动态界面往往需要“这一帧发完了我再准备下一帧”的同步逻辑回调正好卡准这个时间点static bool spi_trans_done false; void spi_send_done_cb(spi_transaction_t *t) { spi_trans_done true; } void st7789_write_frame(uint8_t *buf, size_t len) { spi_transaction_t trans {0}; trans.length len * 8; trans.tx_buffer buf; trans.cb spi_send_done_cb; spi_trans_done false; spi_device_queue_trans(spi_handle, trans, portMAX_DELAY); // 这里可以做其他事情等下一帧需要发送时再检查 }这个模式的好处是发送和计算重叠一边在DMA搬运上一帧数据一边CPU在计算下一帧的像素值。240x240的屏幕用这个方式在40MHz总线频率下刷新率从纯阻塞式的15fps能提升到22fps以上而且CPU空闲时间大幅增加能去处理触摸、通信等其他任务了。4.3 动态内容的绘制策略进度条、波形、动画、文字滚动有了DMA动态显示的具体策略就有讲究了。我总结了几个高频场景的做法进度条是最简单的动态更新场景。做法是先整块清除进度条背景区域再按当前百分比往窗口里填充前景色。void draw_progress_bar(int x, int y, int w, int h, int percent, uint16_t color) { uint16_t fill_w (w * percent) / 100; // 设置窗口为进度条整个区域 st7789_set_window(x, y, x w - 1, y h - 1); // 背景色填充整个区域 for (int i 0; i w * h; i) { st7789_push_data(bg_color, 2); // 极简示意实际用DMA批量填充 } // 再设置窗口为填充进度部分 st7789_set_window(x, y, x fill_w - 1, y h - 1); for (int i 0; i fill_w * h; i) { st7789_push_data(color, 2); } }波形显示稍微复杂一点核心是移位每次来一个新采样点把整个波形区域左移一列然后最右列画新点。这里如果整个区域重绘会很浪费技巧是只重绘“旧点擦除 新点绘制”这两列利用窗口裁剪开销降低一个数量级。文字滚动最常用的是8x16点阵字库。先开一个精灵区域把文字点阵按列扫描变成16bit颜色缓冲再把这个缓冲通过DMA写进指定窗口。滚动时每次偏移一列注意在移出屏幕的列重复绘制背景色。动画比如小飞机移动则要把绘图范围限制在运动物体周围的一个边框内确保画面变化区域最小化而不重绘整个背景。4.4 撕裂与闪烁的规避思路动态显示最常见的画质问题有两类闪烁和撕裂。闪烁flicker是局部刷新时频繁把背景整块擦除又重绘导致的。解决思路是“只画该画的像素”背景不变就不重绘背景只更新变化区域。进度条更新时先只画新增长的部分填充的矩形区域比上一次的大而不是整条重新画。撕裂tearing是MCU写入GRAM的速度和ST7789扫描屏幕刷新的速度不同步导致的。如果你在写入过程中屏幕刚好从GRAM顶部开始扫描用户就会看到画面上半部是旧帧、下半部是新帧中间有一条分界线。ST7789提供TETearing Effect输出引脚来标示屏幕当前是否处于消隐区但四线SPI模组很少引出这个引脚。软件上减轻撕裂的办法是让写入尽量快——DMA 40MHz下写入一帧约23ms而屏幕刷新率通常60Hz16.6ms一帧无TE同步时撕裂几乎无法完全避免但可以靠局部刷新、减少无谓的全屏更新来降低发生概率。如果确实需要高画质刷新终极方案是双缓冲一个缓冲区给DMA发送当前帧另一个缓冲区给CPU绘制下一帧两者交替使用发送完成后交换角色。这个思路在ESP-IDF里实现很简单两个缓冲区各分配DMA内存绘制完成后把要显示的缓冲区指针交给st7789_write_frame发送回调里再交换。5. 实测性能数据与踩坑记录5.1 不同SPI时钟、DMA与否的实测帧率我自己用ESP32 240MHz240x240 RGB565全屏刷新测了这么一组数据SPI时钟是否DMA实测帧率CPU空闲情况10MHz否约4fps几乎全忙10MHz是约8fps大部分时间空闲20MHz是约16fps大部分时间空闲40MHz是约22fps大部分时间空闲80MHz是间歇性乱码不稳定40MHz配合DMA是稳定性和性能最平衡的点。再往上要靠PCB信号走线优化和给屏幕单独贴电容不是简单改软件参数能解决的。5.2 踩坑一白屏SPI波形明明有输出但屏幕无反应有次客户反馈屏幕白屏我拿到板子示波器量SPI波形SCLK和MOSI都有正常脉冲CS也被拉低但屏幕就是不显示。排查了好久发现是复位引脚出了鬼——RST接在了一个依赖上电时序的外设扩展芯片后面扩展芯片比ESP32慢屏幕先看到了复位有效电平然后ESP32开始发初始化命令时屏幕其实还在复位状态下后面命令全部被忽略。这个问题定位方法很朴素在初始化命令前额外用普通GPIO手动拉一下RST低电平至少10ms再拉高等120ms后再发SWRESET白屏立刻消失。从那以后我所有项目都让RST接独立GPIO由代码控制复位时序不再依赖上电默认复位。如果你也白屏先确认上电时序再确认RST引脚没有悬空。5.3 踩坑二花屏画面像被搅碎了一样花屏一般有三类。一种是整个画面显示错乱但颜色“有内容”最先怀疑SPI Mode不对检查是不是Mode 0和Mode 3配反了。第二种是画面有规律地偏色、竖条纹检查COLMOD的像素格式是否和你实际发送的数据长度一致RGB565数据按RGB666发就会出现这个现象。第三种是图像边缘有残留或者位置不对检查MADCTL以及你读取屏幕分辨率时的列/行地址方向是否和模组物理方向一致。有一次最难排查的花屏来源是ESP32的Flash和PSRAM占用了同一组SPI引脚冲突我在menuconfig里开启了PSRAM但代码里却用了它的保留引脚作为屏幕的SCLK。ESp32某些封装和模组的引脚复用规则很微妙遇到花屏先看是不是引脚复用冲突再往下查。5.4 踩坑三动态刷新出现明显闪烁这个问题出现在我用全屏DMA刷新做动画时CPU绘制下一帧的速度只能达到23ms/帧而屏幕刷新率是60Hz绘制中屏幕不断读取GRAM导致明暗交替的“呼吸感”。解决方案是切到局部刷新只更新画面变化区域立刻消除闪烁。如果你做的是全屏视频流或复杂的粒子效果已经没法靠局部刷新解决那就得上双缓冲甚至考虑用ESP32的JPEG硬件解码配合帧缓冲压缩方案来减负。我做天气预报卡片时把不需要频繁变化的部分背景、图标做成了静态基底只让温度数字区域局部刷新CPU占用从86%降到了12%。5.5 给新手的调试建议逻辑分析仪是点屏必备工具便宜的8通道24MHz采样率就够用能看到每条命令的字节、CS时序、DC电平很多“软件没问题但屏幕不动”的疑难杂症用逻辑分析仪一量就露馅。先点纯色点亮后第一件事不是显示图片而是先显示纯红、纯绿、纯蓝三色可以快速验证MADCTL方向和COLMOD格式是否正确。跑通一个像素点在纯色基础上画一个亮点然后做行扫描动画确认坐标方向和范围设置没问题后再上复杂界面。加打印日志在关键初始化步骤后加ESP_LOG打印确认每条命令都执行了。大部分初始化失败是命令执行了但时序不对日志能帮你看清“执行到哪一步之后屏幕才没反应”。最后分享一点我自己的心得屏幕这玩意看着简单实际上从SPI配置到动态刷新每一层都有坑。管好时钟频率管好片选管好窗口地址管好DMA缓冲区屏幕自然就服服帖帖。不要一上来就追求80MHz、60fps先把20MHz下的稳定刷新跑通再去逐项优化你会发现很多所谓“性能瓶颈”其实只是配置边界没摸清。拿一块240x240的ST7789屏先把第一帧跑出来再慢慢玩出花来这条路走通一次后面换ILI9341、ILI9488、GC9A01都只是换初始化序列的事。
返回列表