ARTICLE DETAIL

资讯详情

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

ESP32/STM32 TFT_eSPI DMA双缓冲实战:彻底解决TFT刷新卡顿与撕裂

ESP32/STM32 TFT_eSPI DMA双缓冲实战:彻底解决TFT刷新卡顿与撕裂 1. 为什么TFT刷新总是卡顿从一次智能小车项目说起去年帮朋友调一个基于Arduino的智能小车项目主控是ESP32屏幕上要实时显示超声波雷达扫描图、电池电压曲线和电机PWM占空比。屏幕用的是1.8寸TFT LCD分辨率128x160驱动芯片ST7735库选的是社区里口碑最好的TFT_eSPI。硬件连线检查了三遍SPI时钟拉到40MHz按理说刷个128x160的小屏幕应该毫无压力。结果实际跑起来雷达扫描的扇形区域每转一圈就明显撕裂一次电压曲线的刷新率连10帧都稳不住电机一加速屏幕就开始闪。这个问题困扰了我整整两天。一开始怀疑是SPI时钟不够高从20MHz一路加到80MHz撕裂感反而更严重了。后来用逻辑分析仪抓SPI总线波形才发现问题根本不在时钟频率上——CPU有大量时间在等SPI传输完成每次pushImage或者fillRect调用之后程序就卡在while循环里轮询SPI状态寄存器这段时间CPU完全没法处理传感器数据和电机控制。屏幕越大、刷新区域越多CPU被“绑架”的时间就越长。这就是典型的阻塞式SPI传输带来的问题。TFT_eSPI默认走的是同步SPICPU发起传输后必须等所有像素数据发完才能继续执行。128x160的屏幕一个全屏刷新要传40960个像素按16位色算就是81920字节在40MHz SPI下光传输时间就要16毫秒左右这还没算上TFT_eSPI内部的颜色转换和坐标计算开销。如果每帧都全屏刷新理论最高帧率也就60帧出头实际因为CPU被占用能跑到20帧就算不错了。解决思路其实很明确让DMA来搬像素数据把CPU解放出来。DMADirect Memory Access直接存储器访问是微控制器里的一个独立硬件单元它可以在不占用CPU的情况下把数据从一个内存地址搬到另一个地址。用在TFT刷新场景里就是让DMA把帧缓冲区里的像素数据自动搬运到SPI数据寄存器CPU只需要配置好DMA描述符然后就可以去处理其他任务了。但光有DMA还不够。如果只用单缓冲DMACPU在DMA传输期间不能修改帧缓冲区否则屏幕上会出现撕裂——上半屏是旧画面下半屏是新画面。所以还需要双缓冲准备两块帧缓冲区DMA在搬运缓冲区A的时候CPU往缓冲区B里画下一帧DMA搬完A切换去搬BCPU再回来画A。这样CPU和DMA并行工作刷新率和CPU利用率都能大幅提升。这篇文章就把我在ESP32和STM32平台上折腾TFT_eSPI DMA双缓冲的完整过程整理出来。从底层原理到具体配置从ESP32的SPI DMA到STM32的DMA双缓冲模式再到实际项目中的参数调优和踩坑记录都会详细展开。不管你是做智能小车、无人机地面站、还是便携式示波器只要涉及到TFT动态图像刷新这套方案都能直接参考。2. 搞懂TFT_eSPI的底层机制SPI传输、帧缓冲与DMA的三角关系2.1 TFT_eSPI默认的SPI传输流程与性能瓶颈TFT_eSPI这个库之所以在Arduino社区流行是因为它对不同驱动芯片做了很好的抽象ST7735、ILI9341、ST7789这些常见驱动都能直接支持而且提供了丰富的绘图API。但它的默认传输模式是同步阻塞的这一点从源码里就能看出来。以ESP32为例TFT_eSPI在TFT_eSPI.cpp里有一个pushPixels函数核心逻辑大致是这样的先把要发送的像素数据从颜色格式转换成SPI需要的字节序然后调用SPI.writeBytes或者直接操作SPI寄存器最后等待SPI传输完成标志位。这个等待过程就是CPU被阻塞的根源。我实测过一组数据在ESP32上SPI时钟设为40MHz用TFT_eSPI默认配置刷新128x160全屏单帧耗时约18毫秒其中纯SPI传输时间约16毫秒颜色转换和函数调用开销约2毫秒。这意味着CPU有将近90%的时间在等SPI根本没空干别的。如果项目里还有MPU6050读取、PID计算、串口通信这些任务帧率直接掉到个位数。更麻烦的是TFT_eSPI的绘图API是直接往SPI发送数据的没有中间帧缓冲。你调用drawPixel它就立刻发一个像素你调用fillRect它就连续发一片像素。这种“即画即发”的模式在静态界面下没问题但在动态刷新场景下每次绘图都会触发一次SPI传输函数调用开销和SPI启动开销叠加起来非常可观。2.2 帧缓冲区的引入用内存换流畅度要解决这个问题第一步是引入帧缓冲区Frame Buffer。帧缓冲区就是一块内存区域大小等于屏幕分辨率乘以每个像素的字节数。对于128x160的16位色屏幕帧缓冲区大小是128 * 160 * 2 40960字节也就是40KB。ESP32有520KB SRAM拿出40KB做帧缓冲完全没问题STM32F103C8T6只有20KB SRAM就有点紧张了可能需要降低分辨率或者用8位色。有了帧缓冲区之后绘图操作就不再直接往SPI发送数据了而是先写到帧缓冲区里。等一帧画完再统一把帧缓冲区的内容通过SPI发送到屏幕。这样做的好处是绘图操作变成了纯内存写入速度极快SPI传输变成了连续的大块数据传输效率更高。但单帧缓冲区还不够。如果CPU正在往帧缓冲区里画下一帧DMA同时在搬运当前帧就会出现“撕裂”——屏幕上显示的画面一部分是旧帧一部分是新帧。所以需要双缓冲两块帧缓冲区交替使用DMA搬A的时候CPU画BDMA搬完A切换去搬BCPU再回来画A。2.3 DMA双缓冲的工作原理让CPU和DMA并行干活DMA双缓冲的核心思想是乒乓操作。以ESP32的SPI DMA为例ESP32的SPI外设支持DMA链表模式可以配置多个DMA描述符每个描述符指向一块内存区域。当第一个描述符对应的数据传输完成后DMA自动切换到第二个描述符同时触发中断通知CPU。CPU在中断里把第二块缓冲区标记为“正在传输”然后把第一块缓冲区标记为“可写入”接着去画下一帧。STM32的DMA双缓冲模式更直接一些。STM32的DMA控制器支持双缓冲模式Double Buffer Mode需要配置两个内存地址寄存器M0AR和M1AR和一个目标地址寄存器PAR。DMA在M0AR和M1AR之间自动切换每次传输完成触发中断CPU在中断里处理缓冲区切换。这里有一个关键点DMA传输完成中断的频率不能太高。如果每传输一小块数据就触发一次中断CPU会被中断淹没反而降低效率。所以DMA传输的数据块要足够大比如一次传输半屏或者整屏的数据。对于128x160的屏幕一次传输40KB数据在40MHz SPI下耗时约8毫秒中断频率约125Hz这个频率对CPU来说完全可以接受。2.4 不同平台的DMA能力对比ESP32 vs STM32ESP32和STM32在DMA支持上有明显差异这直接影响了双缓冲的实现方式。特性ESP32STM32F103STM32F407STM32H7SPI DMA支持支持链表模式支持单缓冲支持双缓冲支持双缓冲DMAMUXDMA通道数SPI2/SPI3有专用DMADMA1通道3/5DMA1/DMA2多通道DMA1/DMA2BDMA最大传输长度链表可拼接655356553565535双缓冲硬件支持需软件模拟不支持支持支持典型SRAM大小520KB20KB192KB1MBESP32的SPI DMA用的是链表描述符每个描述符可以指向不同大小的内存块灵活性很高但需要手动管理描述符链表。STM32F103没有硬件双缓冲需要用两个DMA通道或者软件切换来模拟。STM32F407和H7有硬件双缓冲模式配置更简单但需要仔细设置DMA中断优先级。对于Arduino生态来说ESP32是首选平台因为TFT_eSPI对ESP32的DMA支持最好社区里也有现成的例程可以参考。STM32平台需要用STM32duino或者HAL库来配置工作量稍大一些但性能上限更高。3. 动手改造TFT_eSPI从单缓冲到双缓冲的完整配置3.1 硬件选型与连线检查清单在开始改代码之前先把硬件确认一遍。我用的配置是ESP32-WROOM-32开发板TFT屏幕是1.8寸ST7735S分辨率128x160SPI接口。连线如下TFT引脚ESP32引脚说明VCC3.3V电源GNDGND地CSGPIO5片选RESETGPIO4复位DCGPIO2数据/命令切换MOSIGPIO23SPI数据SCKGPIO18SPI时钟BLKGPIO15背光控制注意ESP32的SPI引脚是固定的GPIO23是VSPI的MOSIGPIO18是VSPI的SCK。如果你用HSPI引脚会不一样。另外背光引脚最好接一个PWM通道方便调亮度。连线检查清单确认TFT的VCC和GND没有接反接反会烧屏确认CS、DC、RESET引脚没有和其他外设冲突确认SPI时钟线尽量短超过10厘米容易受干扰确认背光限流电阻合适一般串一个100欧姆电阻3.2 TFT_eSPI库的User_Setup配置要点TFT_eSPI的配置都在User_Setup.h文件里这个文件在Arduino库目录下的TFT_eSPI文件夹里。关键配置项如下#define ST7735_DRIVER #define TFT_WIDTH 128 #define TFT_HEIGHT 160 #define TFT_CS 5 #define TFT_DC 2 #define TFT_RST 4 #define TFT_MOSI 23 #define TFT_SCLK 18 #define LOAD_GLCD #define LOAD_FONT2 #define LOAD_FONT4 #define SPI_FREQUENCY 40000000这里有几个坑要注意第一SPI_FREQUENCY不要设太高。ST7735S的 datasheet 标称最高SPI时钟是15MHz但实际测试40MHz也能跑只是屏幕边缘可能会有轻微噪点。如果发现花屏降到27MHz试试。第二TFT_RST如果接的是ESP32的GPIO4注意GPIO4在启动时会有短暂的高电平脉冲可能导致屏幕复位异常。解决办法是在setup()里手动拉低再拉高一次。第三如果要启用DMA需要在User_Setup.h里加上#define USE_DMA或者类似的宏。不同版本的TFT_eSPI宏定义不一样我用的2.5.0版本是#define ESP32_DMA。3.3 双缓冲内存分配PSRAM还是内部SRAMESP32有520KB内部SRAM但其中一部分被WiFi和蓝牙协议栈占用了。如果项目里用了WiFi可用SRAM可能只有200KB左右。双缓冲需要两块40KB的帧缓冲区总共80KB内部SRAM勉强够用但会比较紧张。如果ESP32模组带PSRAM比如ESP32-WROVER可以把帧缓冲区分配到PSRAM里。PSRAM有4MB或8MB放帧缓冲区绰绰有余。但PSRAM的访问速度比内部SRAM慢DMA从PSRAM搬运数据可能会成为瓶颈。我实测过从PSRAM搬运40KB数据到SPI比从内部SRAM慢约20%。所以我的建议是如果内部SRAM够用优先用内部SRAM如果不够再用PSRAM。分配代码如下// 内部SRAM分配 uint16_t* frameBuffer[2]; frameBuffer[0] (uint16_t*)heap_caps_malloc(128 * 160 * 2, MALLOC_CAP_DMA); frameBuffer[1] (uint16_t*)heap_caps_malloc(128 * 160 * 2, MALLOC_CAP_DMA); // PSRAM分配 frameBuffer[0] (uint16_t*)heap_caps_malloc(128 * 160 * 2, MALLOC_CAP_SPIRAM); frameBuffer[1] (uint16_t*)heap_caps_malloc(128 * 160 * 2, MALLOC_CAP_SPIRAM);注意MALLOC_CAP_DMA标志确保分配的内存可以被DMA访问。ESP32的DMA不能访问所有SRAM区域必须用这个标志。3.4 DMA描述符配置与SPI总线初始化ESP32的SPI DMA配置需要用到ESP-IDF的底层API。Arduino环境下可以通过esp32-hal-spi.h里的函数来操作。核心步骤如下第一步初始化SPI总线设置DMA通道spi_t* spi spiStartBus(VSPI_HOST, SPI_CLOCK_DIV2, SPI_MODE0, SPI_MSBFIRST); spi-dev-user.usr_mosi 1; spi-dev-user.usr_miso 0; spi-dev-user.doutdin 0;第二步配置DMA描述符。ESP32的DMA描述符是一个链表结构每个节点包含数据地址、数据长度和下一个节点的指针typedef struct { uint32_t blocksize : 12; uint32_t datalen : 12; uint32_t unused : 4; uint32_t eof : 1; uint32_t owner : 1; uint32_t sof : 1; uint32_t unused2 : 1; uint8_t* buf; struct dma_descriptor* next; } dma_descriptor_t;第三步把描述符链表挂到SPI的DMA通道上启动传输spi-dev-dma_out_link.addr (uint32_t)desc; spi-dev-dma_out_link.start 1; spi-dev-cmd.usr 1;这套配置比较底层容易出错。如果不想直接操作寄存器可以用TFT_eSPI的pushImageDMA函数它内部已经封装好了DMA配置。但pushImageDMA只支持单缓冲要做双缓冲还是得自己管理描述符。3.5 缓冲区切换逻辑与撕裂消除验证双缓冲的核心逻辑是DMA传输完成中断里切换缓冲区。伪代码如下volatile int dmaBufferIndex 0; volatile bool dmaBusy false; void IRAM_ATTR dmaTransferDone() { dmaBusy false; dmaBufferIndex 1 - dmaBufferIndex; // 切换缓冲区 } void loop() { if (!dmaBusy) { // 把当前帧缓冲区的内容通过DMA发送 dmaBusy true; startDMATransfer(frameBuffer[dmaBufferIndex]); } // CPU继续画下一帧到另一个缓冲区 int drawIndex 1 - dmaBufferIndex; drawFrame(frameBuffer[drawIndex]); }验证撕裂是否消除的方法很简单在屏幕上画一个快速移动的竖条如果竖条边缘清晰没有上下错位说明双缓冲生效了。如果竖条中间有断裂说明缓冲区切换时机不对可能是DMA传输完成中断触发太晚或者CPU画帧速度超过了DMA传输速度。我实测下来ESP32在40MHz SPI下128x160全屏刷新可以稳定在45帧左右CPU占用率从原来的90%降到30%以下。如果只刷新部分区域比如只刷新雷达扫描的扇形区域帧率可以轻松破百。4. 性能实测与调优帧率、CPU占用与内存开销的平衡4.1 实测数据对比单缓冲 vs 双缓冲我在ESP32上做了一组对比测试屏幕128x160SPI时钟40MHz测试场景是每帧画一个移动的矩形和一段实时曲线。结果如下指标单缓冲同步SPI单缓冲DMA双缓冲DMA平均帧率18 fps32 fps45 fpsCPU占用率92%55%28%撕裂现象无但卡顿有无内存开销0KB40KB80KB最大连续传输无40KB40KB从数据可以看出双缓冲DMA在帧率和CPU占用上都有明显优势。单缓冲DMA虽然帧率提升了但撕裂问题很严重实际体验还不如单缓冲同步SPI。双缓冲DMA既解决了撕裂又解放了CPU是动态图像刷新的最优解。4.2 SPI时钟频率与DMA传输块大小的调优SPI时钟频率和DMA传输块大小是两个关键调优参数。SPI时钟越高传输越快但信号完整性越差。DMA传输块越大中断频率越低但内存占用越大。我测试了不同SPI时钟下的最大稳定帧率SPI时钟最大帧率屏幕状态20MHz28 fps稳定27MHz35 fps稳定40MHz45 fps稳定60MHz52 fps偶发噪点80MHz58 fps明显噪点40MHz是一个比较好的平衡点再高的话屏幕边缘会出现细小的噪点虽然不影响功能但看起来不舒服。如果你的连线很短小于5厘米可以试试60MHz。DMA传输块大小方面我试过4KB、8KB、16KB、40KB四种。4KB时中断频率太高CPU有10%的时间在处理中断40KB时中断频率最低但内存占用最大。最终我选了20KB一块中断频率约200HzCPU中断开销约3%内存占用40KB两块缓冲区各20KB。4.3 CPU占用率测量与任务调度优化测量CPU占用率的方法是用一个空闲任务计数器。在loop()里每次循环给一个变量加一然后每秒统计一次。如果空闲计数器的值远低于理论最大值说明CPU被其他任务占用了。优化任务调度的关键是把绘图任务和DMA传输任务分开。在FreeRTOS里可以创建两个任务一个任务负责画帧另一个任务负责DMA传输。两个任务通过队列或者信号量同步。这样即使画帧任务偶尔卡顿DMA传输任务也能继续运行不会导致屏幕闪烁。xTaskCreatePinnedToCore(drawTask, Draw, 4096, NULL, 2, NULL, 0); xTaskCreatePinnedToCore(dmaTask, DMA, 4096, NULL, 3, NULL, 1);把drawTask绑到核心0dmaTask绑到核心1两个任务真正并行运行。实测下来双核调度比单核轮询的帧率又提升了15%左右。4.4 内存碎片与长期运行稳定性测试双缓冲方案需要两块大内存如果频繁分配释放容易产生内存碎片。我的做法是在setup()里一次性分配好两块缓冲区运行期间不再释放。这样虽然占用了80KB内存但避免了碎片问题。长期运行稳定性方面我连续跑了72小时帧率没有明显下降内存剩余量稳定在150KB左右。唯一需要注意的是DMA描述符链表如果每次传输都重新创建描述符会产生碎片。我的做法是预先创建好两个描述符传输时只修改描述符里的数据地址不重新分配。提示ESP32的DMA描述符必须放在内部SRAM里不能放在PSRAM里。如果描述符放在PSRAMDMA会报错。5. 踩坑实录DMA双缓冲常见问题与排查手册5.1 屏幕花屏、噪点与颜色错乱的排查路径花屏是DMA双缓冲最常见的故障。我遇到过三种花屏第一种是全屏随机噪点原因是SPI时钟太高或者连线太长。解决办法是降低SPI时钟到27MHz或者缩短SPI连线。第二种是颜色错乱红色变蓝色绿色变紫色。原因是颜色字节序搞反了。TFT_eSPI默认用大端序但有些屏幕驱动芯片用小端序。解决办法是在User_Setup.h里加上#define TFT_SWAP_BYTES。第三种是屏幕上半部分正常下半部分花屏。原因是DMA传输块大小和屏幕行宽不匹配。128像素宽16位色一行是256字节。如果DMA传输块大小不是256的整数倍最后一行数据会错位。解决办法是把DMA传输块大小设为256的整数倍比如20KB 20480字节 80行。5.2 DMA传输中断丢失与缓冲区切换异常DMA传输中断丢失的表现是屏幕卡住不动或者帧率突然掉到零。原因通常是中断优先级配置不当或者中断处理函数执行时间太长。ESP32的中断优先级从1到7数字越大优先级越高。SPI DMA中断建议设为3太高会阻塞WiFi和蓝牙中断太低会被其他中断抢占。中断处理函数里只做缓冲区切换和标志位设置不要做耗时操作。STM32平台上DMA中断优先级要低于SPI中断但高于系统滴答定时器中断。如果DMA中断优先级太高会导致SPI传输出错如果太低会导致缓冲区切换不及时。5.3 内存不足与PSRAM访问速度的取舍ESP32不带PSRAM的模组只有520KB SRAM双缓冲占80KBWiFi协议栈占100KB左右剩下340KB给应用程序。如果应用程序还要跑LVGL或者TensorFlow Lite内存就不够了。这时候有三个选择一是降低屏幕分辨率比如从128x160降到128x128帧缓冲区从40KB降到32KB二是用8位色帧缓冲区减半三是用PSRAM但接受20%的性能损失。我试过用PSRAM做帧缓冲帧率从45fps降到36fps但内存压力大大缓解。如果你的项目对帧率要求不高30fps够用PSRAM是更好的选择。5.4 常见问题速查表现象可能原因排查方法解决办法全屏噪点SPI时钟太高降低时钟测试降到27MHz颜色错乱字节序反了检查TFT_SWAP_BYTES加上宏定义下半屏花屏DMA块大小不对检查块大小是否为256倍数调整为256倍数屏幕卡死DMA中断丢失检查中断优先级调整优先级到3帧率突然下降内存碎片检查剩余内存预分配缓冲区撕裂缓冲区切换太晚检查中断触发时机提前切换缓冲区编译报错DMA宏未定义检查User_Setup.h加上ESP32_DMA注意如果用了PSRAMDMA描述符必须放在内部SRAM否则DMA会报错。这个坑我踩过排查了一整天才发现。6. 从能跑到好用进阶优化与项目落地建议6.1 局部刷新只更新变化区域全屏刷新虽然简单但浪费带宽。如果屏幕上只有一小块区域在变化比如雷达扫描的扇形区域可以只刷新那块区域。TFT_eSPI提供了setAddrWindow函数可以设置刷新窗口。局部刷新的关键是计算变化区域的边界。我的做法是维护一个“脏矩形”列表每次绘图时更新脏矩形边界帧结束时只把脏矩形区域通过DMA发送。实测下来雷达扫描场景下局部刷新比全屏刷新帧率提升了3倍。但局部刷新也有代价DMA传输块变小了中断频率变高了。所以脏矩形不能太小最好大于4KB。如果脏矩形小于4KB就合并到全屏刷新里。6.2 颜色格式转换的硬件加速TFT_eSPI默认用软件做颜色格式转换比如从RGB565转成屏幕需要的字节序。这个转换过程消耗CPU。ESP32的SPI外设有硬件字节交换功能可以自动做字节序转换。启用硬件字节交换的方法是在SPI配置里设置SPI_SWAP_BYTES。这样CPU就不需要做转换了直接往帧缓冲区里写RGB565数据SPI硬件自动交换字节序。实测下来CPU占用率又降低了5%左右。6.3 与LVGL等GUI库的集成思路如果项目里用了LVGL双缓冲DMA的集成方式会有所不同。LVGL有自己的帧缓冲管理机制需要把LVGL的帧缓冲和DMA双缓冲对接起来。我的做法是LVGL配置成双缓冲模式两个缓冲区分别对应DMA的两个缓冲区。LVGL在flush_cb回调里触发DMA传输DMA传输完成中断里通知LVGL切换缓冲区。这样LVGL的绘图和DMA的传输就能并行起来。需要注意的是LVGL的缓冲区大小要和DMA传输块大小匹配。如果LVGL缓冲区是40KBDMA传输块也是40KB那一次传输就能搞定。如果LVGL缓冲区是20KBDMA传输块是40KB就需要两次传输。6.4 不同屏幕驱动芯片的适配要点不同驱动芯片对DMA双缓冲的适配要求不一样。ST7735S和ILI9341比较常见适配起来也简单。ST7789和GC9A01稍微麻烦一点因为它们的初始化序列比较长DMA传输前需要先发命令。适配新驱动芯片的步骤确认驱动芯片的SPI模式模式0或模式3确认颜色格式RGB565还是RGB666确认是否需要字节交换确认初始化序列是否需要在DMA传输前发送我适配过ST7735S、ILI9341、ST7789三种芯片ST7735S最简单ST7789最麻烦。ST7789的初始化序列有20多条命令每条命令之间需要延时DMA传输前必须确保初始化完成。6.5 项目落地检查清单最后整理一份项目落地检查清单照着这个清单走基本不会出大问题[ ] 确认屏幕驱动芯片型号和SPI模式[ ] 确认ESP32模组是否带PSRAM[ ] 在User_Setup.h里配置正确的引脚和SPI时钟[ ] 分配两块帧缓冲区确保内存对齐[ ] 配置DMA描述符确保描述符在内部SRAM[ ] 设置DMA传输完成中断优先级设为3[ ] 实现缓冲区切换逻辑确保无撕裂[ ] 测试不同SPI时钟下的稳定性找到最佳值[ ] 测量CPU占用率确保有足够余量[ ] 连续运行24小时检查内存碎片和帧率稳定性这套方案我在三个项目里用过智能小车的雷达显示、便携式示波器的波形刷新、无人机地面站的姿态仪表。最复杂的场景是无人机地面站屏幕上同时显示姿态球、高度曲线、GPS地图帧率还能稳定在35fps以上。关键就是把DMA双缓冲用对了CPU从“搬运工”变成了“指挥官”整个系统的流畅度提升了一个档次。
返回列表