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默认走的是同步SPI,CPU发起传输后必须等所有像素数据发完才能继续执行。128x160的屏幕,一个全屏刷新要传40960个像素,按16位色算就是81920字节,在40MHz SPI下光传输时间就要16毫秒左右,这还没算上TFT_eSPI内部的颜色转换和坐标计算开销。如果每帧都全屏刷新,理论最高帧率也就60帧出头,实际因为CPU被占用,能跑到20帧就算不错了。
解决思路其实很明确:让DMA来搬像素数据,把CPU解放出来。DMA(Direct Memory Access,直接存储器访问)是微控制器里的一个独立硬件单元,它可以在不占用CPU的情况下,把数据从一个内存地址搬到另一个地址。用在TFT刷新场景里,就是让DMA把帧缓冲区里的像素数据自动搬运到SPI数据寄存器,CPU只需要配置好DMA描述符,然后就可以去处理其他任务了。
但光有DMA还不够。如果只用单缓冲DMA,CPU在DMA传输期间不能修改帧缓冲区,否则屏幕上会出现撕裂——上半屏是旧画面,下半屏是新画面。所以还需要双缓冲:准备两块帧缓冲区,DMA在搬运缓冲区A的时候,CPU往缓冲区B里画下一帧;DMA搬完A,切换去搬B,CPU再回来画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画B,DMA搬完A切换去搬B,CPU再回来画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 STM32
ESP32和STM32在DMA支持上有明显差异,这直接影响了双缓冲的实现方式。
| 特性 | ESP32 | STM32F103 | STM32F407 | STM32H7 |
|---|---|---|---|---|
| SPI DMA支持 | 支持,链表模式 | 支持,单缓冲 | 支持,双缓冲 | 支持,双缓冲+DMAMUX |
| DMA通道数 | SPI2/SPI3有专用DMA | DMA1通道3/5 | DMA1/DMA2多通道 | DMA1/DMA2+BDMA |
| 最大传输长度 | 链表可拼接 | 65535 | 65535 | 65535 |
| 双缓冲硬件支持 | 需软件模拟 | 不支持 | 支持 | 支持 |
| 典型SRAM大小 | 520KB | 20KB | 192KB | 1MB |
ESP32的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,分辨率128x160,SPI接口。连线如下:
| TFT引脚 | ESP32引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 电源 |
| GND | GND | 地 |
| CS | GPIO5 | 片选 |
| RESET | GPIO4 | 复位 |
| DC | GPIO2 | 数据/命令切换 |
| MOSI | GPIO23 | SPI数据 |
| SCK | GPIO18 | SPI时钟 |
| BLK | GPIO15 | 背光控制 |
注意:ESP32的SPI引脚是固定的,GPIO23是VSPI的MOSI,GPIO18是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还是内部SRAM
ESP32有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上做了一组对比测试,屏幕128x160,SPI时钟40MHz,测试场景是每帧画一个移动的矩形和一段实时曲线。结果如下:
| 指标 | 单缓冲同步SPI | 单缓冲DMA | 双缓冲DMA |
|---|---|---|---|
| 平均帧率 | 18 fps | 32 fps | 45 fps |
| CPU占用率 | 92% | 55% | 28% |
| 撕裂现象 | 无(但卡顿) | 有 | 无 |
| 内存开销 | 0KB | 40KB | 80KB |
| 最大连续传输 | 无 | 40KB | 40KB |
从数据可以看出,双缓冲DMA在帧率和CPU占用上都有明显优势。单缓冲DMA虽然帧率提升了,但撕裂问题很严重,实际体验还不如单缓冲同步SPI。双缓冲DMA既解决了撕裂,又解放了CPU,是动态图像刷新的最优解。
4.2 SPI时钟频率与DMA传输块大小的调优
SPI时钟频率和DMA传输块大小是两个关键调优参数。SPI时钟越高,传输越快,但信号完整性越差。DMA传输块越大,中断频率越低,但内存占用越大。
我测试了不同SPI时钟下的最大稳定帧率:
| SPI时钟 | 最大帧率 | 屏幕状态 |
|---|---|---|
| 20MHz | 28 fps | 稳定 |
| 27MHz | 35 fps | 稳定 |
| 40MHz | 45 fps | 稳定 |
| 60MHz | 52 fps | 偶发噪点 |
| 80MHz | 58 fps | 明显噪点 |
40MHz是一个比较好的平衡点,再高的话屏幕边缘会出现细小的噪点,虽然不影响功能,但看起来不舒服。如果你的连线很短(小于5厘米),可以试试60MHz。
DMA传输块大小方面,我试过4KB、8KB、16KB、40KB四种。4KB时中断频率太高,CPU有10%的时间在处理中断;40KB时中断频率最低,但内存占用最大。最终我选了20KB一块,中断频率约200Hz,CPU中断开销约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绑到核心0,dmaTask绑到核心1,两个任务真正并行运行。实测下来,双核调度比单核轮询的帧率又提升了15%左右。
4.4 内存碎片与长期运行稳定性测试
双缓冲方案需要两块大内存,如果频繁分配释放,容易产生内存碎片。我的做法是在setup()里一次性分配好两块缓冲区,运行期间不再释放。这样虽然占用了80KB内存,但避免了碎片问题。
长期运行稳定性方面,我连续跑了72小时,帧率没有明显下降,内存剩余量稳定在150KB左右。唯一需要注意的是DMA描述符链表,如果每次传输都重新创建描述符,会产生碎片。我的做法是预先创建好两个描述符,传输时只修改描述符里的数据地址,不重新分配。
提示:ESP32的DMA描述符必须放在内部SRAM里,不能放在PSRAM里。如果描述符放在PSRAM,DMA会报错。
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,双缓冲占80KB,WiFi协议栈占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 |
注意:如果用了PSRAM,DMA描述符必须放在内部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缓冲区是40KB,DMA传输块也是40KB,那一次传输就能搞定。如果LVGL缓冲区是20KB,DMA传输块是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从“搬运工”变成了“指挥官”,整个系统的流畅度提升了一个档次。