晋城市网站建设_网站建设公司_悬停效果_seo优化
2025/12/28 1:43:53 网站建设 项目流程

如何让ST7789显示不再“闪瞎眼”?双缓冲实战全解析

你有没有遇到过这种情况:在STM32或ESP32上驱动一块小小的240×320的TFT屏幕,画个渐变色条或者刷新波形图时,画面像老电视一样“撕裂”,甚至每动一下就白光一闪?别急——这并不是你的代码写得差,而是你还在用单缓冲绘图的老办法

我曾经也踩过这个坑。直到有一天,我把项目从“能看”升级到“好看”的过程中,终于下定决心搞清楚:为什么别人家的界面丝滑流畅,而我的却像幻灯片翻页?

答案就是——双缓冲(Double Buffering)

今天,我就带你一步步拆解ST7789上实现双缓冲的核心逻辑,不讲空话,只讲你能立刻上手的实战技巧。


一、先说痛点:为什么ST7789容易“闪”?

我们先来直面问题根源。

ST7789是一款非常流行的TFT驱动芯片,支持SPI通信、RGB565格式,最大分辨率240×320。它便宜、好买、资料多,是嵌入式开发者的首选之一。但它有个致命弱点:没有独立显存,所有像素数据都靠MCU提供

这意味着什么?

当你调用draw_pixel(x, y, color)的时候,CPU其实是通过SPI一条条把数据发给ST7789的GRAM(图形内存)。而与此同时,屏幕本身也在不停地扫描显示当前内容。

于是就出现了经典的“画面撕裂”场景:

你想画一个移动的小方块。
刚开始清屏 → 开始画 → 屏幕刚好扫到中间 → 显示的是“上半空白 + 下半有方块”。
用户看到的就是——撕裂!

更糟的是,如果你采用“先清屏再重绘”的策略,整个屏幕会先变白,再慢慢画出来,视觉上就是明显的“闪烁”。

这不是硬件缺陷,这是绘制与显示未分离导致的必然结果。


二、解决之道:双缓冲是怎么“骗过眼睛”的?

想象一下电影院是怎么放电影的:胶片是一帧一帧切换的,但我们看到的是连续动作。关键就在于——画面切换发生在观众看不见的时候

双缓冲正是用了同样的原理。

它的核心思想只有三句话:

  1. 我有两个画布:一个正在展示(前缓冲),另一个你在背后偷偷画(后缓冲)。
  2. 你画完了告诉我一声,我立刻换画布。
  3. 换的过程要快,最好在用户没察觉时完成。

这样一来,用户永远只看到完整的画面,不会看到“正在画”的中间状态。

那么具体怎么实现?

✅ 第一步:分配两块内存
#define LCD_WIDTH 240 #define LCD_HEIGHT 320 #define FRAME_SIZE (LCD_WIDTH * LCD_HEIGHT) // 分配两个帧缓冲区(建议放在DMA可访问区域) uint16_t frame_buffer[2][FRAME_SIZE] __attribute__((aligned(32)));

每个像素用RGB565格式存储,占2字节。一帧就是240 × 320 × 2 = 153,600 字节 ≈ 150KB。双缓冲就是300KB——对STM32F4这类小容量MCU确实吃紧,但像STM32H7、ESP32这种带PSRAM的平台完全扛得住。

✅ 第二步:定义前后缓冲指针
uint16_t *buf_front = frame_buffer[0]; // 当前显示用 uint16_t *buf_back = frame_buffer[1]; // 当前绘制用

注意:这里只是指针交换,不是 memcpy!效率极高。

✅ 第三步:绘制全部在后台进行

比如你要画个彩色渐变:

void draw_gradient(void) { for (int y = 0; y < LCD_HEIGHT; y++) { for (int x = 0; x < LCD_WIDTH; x++) { uint16_t r = (x >> 3) & 0x1F; uint16_t g = (y >> 2) & 0x3F; uint16_t b = (x ^ y) >> 3 & 0x1F; buf_back[y * LCD_WIDTH + x] = (r << 11) | (g << 5) | b; } } }

所有的操作都在buf_back上完成,屏幕仍然显示的是buf_front的内容,毫无干扰。

✅ 第四步:原子化切换

等你画完了,调用一个swap_buffers()函数:

void swap_buffers(void) { spi_wait_idle(); // 确保上一帧传输已完成 // 交换指针 uint16_t *temp = buf_front; buf_front = buf_back; buf_back = temp; // 启动DMA推送新帧到ST7789 st7789_update_screen(buf_front); }

就这么简单?没错。没有复制、没有延迟、没有复杂协议,只是一个指针切换 + 一次全屏更新。


三、关键优化:如何让刷新更快更稳?

虽然双缓冲解决了“撕裂”和“闪烁”,但如果处理不当,还是会卡顿、掉帧、发热严重。下面这几个实战技巧,都是我在调试中一点点试出来的。

🔧 技巧1:一定要用DMA传数据!

别再用裸SPI发送像素了!每一帧15万多个像素点,如果靠CPU轮询发送,不仅耗时长(可能几十毫秒),还会阻塞其他任务。

正确的做法是:

  • 初始化SPI为DMA模式;
  • 调用HAL_SPI_Transmit_DMA()或 ESP-IDF 中的spi_device_transmit()
  • 让硬件自动搬运数据,CPU腾出手干别的事。

示例(基于HAL库):

void st7789_update_screen(uint16_t *frame) { st7789_set_address_window(0, 0, LCD_WIDTH - 1, LCD_HEIGHT - 1); st7789_write_command(ST7789_RAMWR); // 写GRAM命令 HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)frame, FRAME_SIZE * 2); }

这样一次刷新可以压到5~10ms以内(取决于SPI速率),轻松做到30fps动画。


🔧 技巧2:局部刷新比全刷香多了

你真的需要每次刷新整块屏幕吗?

比如你只是改了个按钮颜色,或者弹了个提示框,没必要把整个背景重新传一遍。

引入“脏矩形”机制(Dirty Rectangle):

typedef struct { uint16_t x, y, w, h; } rect_t; rect_t dirty_rect = {0, 0, LCD_WIDTH, LCD_HEIGHT}; // 默认全刷 void mark_dirty(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { // 合并区域(简化版) uint16_t x1 = MIN(dirty_rect.x, x); uint16_t y1 = MIN(dirty_rect.y, y); uint16_t x2 = MAX(dirty_rect.x + dirty_rect.w, x + w); uint16_t y2 = MAX(dirty_rect.y + dirty_rect.h, y + h); dirty_rect.x = x1; dirty_rect.y = y1; dirty_rect.w = x2 - x1; dirty_rect.h = y2 - y1; } void flush_dirty(void) { if (dirty_rect.w == 0 || dirty_rect.h == 0) return; st7789_set_address_window( dirty_rect.x, dirty_rect.y, dirty_rect.x + dirty_rect.w - 1, dirty_rect.y + dirty_rect.h - 1 ); uint32_t offset = (dirty_rect.y * LCD_WIDTH + dirty_rect.x); uint32_t size = dirty_rect.w * dirty_rect.h; st7789_write_command(ST7789_RAMWR); HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)&buf_front[offset], size * 2); // 清除标记 dirty_rect.w = 0; }

配合双缓冲使用,既能保证完整性,又能大幅降低带宽压力。


🔧 技巧3:模拟VSync,避免“错峰切换”

ST7789本身不输出垂直同步信号(VSync),但你可以通过定时器模拟。

假设你的屏幕刷新率大约是60Hz(每帧约16.6ms),可以用一个定时器中断来标记“新的一帧开始了”:

static volatile bool vsync_flag = true; void TIM6_IRQHandler(void) { if (TIM6->SR & TIM_SR_UIF) { TIM6->SR = ~TIM_SR_UIF; vsync_flag = true; } } void swap_buffers_vsync(void) { while (!vsync_flag); // 等待VSync开始 spi_wait_idle(); vsync_flag = false; // 切换并刷新 uint16_t *temp = buf_front; buf_front = buf_back; buf_back = temp; st7789_update_screen(buf_front); }

虽然仍是软件模拟,但在高帧率下能显著减少因“中途切换”带来的轻微抖动感。


四、真实场景中的取舍:内存 vs 流畅性

双缓冲最大的代价是什么?内存翻倍

对于某些资源紧张的项目(如STM32F1/F4系列),300KB SRAM直接没了,怎么办?

这里有几种折中方案:

方案说明适用场景
使用外部PSRAMESP32/QSPI PSRAM可达4MB推荐首选
降分辨率改为128×160或160×120对清晰度要求不高
单缓冲+局部绘制不撕裂但仍有闪烁风险极端资源限制
三缓冲+优先级调度更平滑,适合复杂UI高端应用

我个人建议:宁可牺牲一点功能,也要保证显示质量。因为用户体验是从第一眼开始建立的。


五、常见坑点与避坑指南

❌ 坑1:指针没对齐,DMA报错

尤其是Cortex-M7平台,DMA要求缓冲区地址按特定字节对齐(如32字节)。记得加:

__attribute__((aligned(32)))

否则可能出现传输异常或HardFault。


❌ 坑2:忘记等待SPI空闲就切换

// 错误示范: buf_front = buf_back; return; // 此时DMA还在传!下一帧可能覆盖旧数据!

正确做法是在swap_buffers()前确保上一帧DMA已完成:

while (spi_dma_busy()); // 或注册DMA完成回调

❌ 坑3:频繁刷新导致功耗飙升

如果你做的是电池供电设备(比如智能手表),一直跑60fps等于持续烧电。

解决方案:

  • 静态画面降到5fps;
  • 动画期间恢复高刷;
  • 利用ST7789的SLEEP_IN/SLEEP_OUT命令休眠屏幕。

六、结语:从“能用”到“好用”,只差一个双缓冲

回到开头的问题:怎么让ST7789不再闪?

答案已经很清晰了:

用双缓冲隔离绘制与显示,用DMA加速传输,用局部刷新节省资源。

这套组合拳下来,哪怕是最基础的MCU,也能做出媲美消费电子产品的视觉体验。

更重要的是,一旦你掌握了这套方法,后续接入LVGL、LittlevGL、TouchGFX Lite等GUI框架时就会发现——它们底层其实也是这么干的。

你现在做的,不只是修一个“闪屏”的Bug,而是在搭建一套现代嵌入式图形系统的地基。

所以,别再让你的用户盯着“撕裂的画面”皱眉了。
现在就去改代码,加上双缓冲,让他们眼前一亮吧!

如果你在实现过程中遇到了SPI DMA配置问题、内存不足崩溃、或是刷新不同步的情况,欢迎在评论区留言,我们一起排查解决。

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

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

立即咨询