ESP32-S3 驱动 SPI LCD 的双缓冲实战:如何让低速接口跑出丝滑动画?
你有没有遇到过这种情况——明明代码逻辑没问题,UI 设计也挺美观,可一到屏幕上,滑动菜单像卡顿的老式投影仪,按钮点击后画面“撕”成两半?
尤其是在用 ESP32-S3 带一个 320×240 的 ST7789 屏时,哪怕只是滚动一行文字,都能看出明显的闪烁和残影。
问题出在哪?不是芯片性能不够,也不是屏幕质量差,而是—— 你在用单缓冲模式“裸奔” 。
今天我们就来聊聊,在资源有限、带宽紧张的嵌入式系统中,怎么靠 双缓冲 + DMA + PSRAM 这套组合拳,把 SPI 这种“慢吞吞”的接口,玩出媲美并口屏的流畅感。
别急着翻 API 文档,先搞清楚一件事:为什么 SPI LCD 特别需要双缓冲?
为什么 SPI 接口更容易出现画面撕裂?
SPI(Serial Peripheral Interface)本质上是串行通信,数据是一位一位发出去的。假设你的屏幕分辨率是 320×240,色深为 RGB565(每像素 2 字节),那么完整一帧的数据量就是:
320 × 240 × 2 = 153,600 字节 ≈ 150KB
如果 SPI 时钟频率跑在 40MHz,理论传输速率约 5MB/s,刷一帧大约需要 30ms —— 换句话说,不到 33fps。
关键来了:在这 30ms 内,LCD 控制器正在持续从显存读取数据进行扫描显示。而如果你此时还在往同一个缓冲区里写新内容(比如移动了一个按钮),就可能出现这样的场景:
上半屏还是旧帧(按钮在左边),下半屏已经是新帧(按钮移到右边)→ 视觉上看起来像是画面被“撕开”了。
这就是典型的 屏幕撕裂(Screen Tearing) 。
而在 PC 或手机上,这个问题通常由垂直同步(VSync)和硬件双缓冲解决;但在 ESP32-S3 上,没有专用 GPU,也没有内置帧缓存管理单元,一切都要我们自己搭。
所以, 双缓冲不是“高级技巧”,而是使用 SPI LCD 时避免视觉灾难的底线操作 。
双缓冲的本质:绘制与显示分离
我们可以把双缓冲想象成两个画布轮换使用:
- 前台缓冲区(Front Buffer) :当前正在被 LCD 扫描显示的那一块内存;
- 后台缓冲区(Back Buffer) :CPU 正在悄悄绘制下一帧的地方。
整个流程就像舞台换景:
- 演员在幕后(Back Buffer)排练下一幕;
- 当前这幕演完(当前帧显示结束),灯光一暗,快速切换布景;
- 新的一幕开始,观众看到的是完整的场景,毫无破绽。
对应到代码里,就是:
// 总有一个是“绘制目标”
uint8_t *draw_buffer = back_buffer;
// 在这个 buffer 里画 UI 元素
draw_rectangle(draw_buffer, ...);
draw_text(draw_buffer, ...);
// 绘制完成后,通知驱动刷新
flush_to_lcd(draw_buffer, width, height);
// 刷新完成中断触发后,交换角色
swap_front_back();
注意!这里有个关键点: 不能一画完就立刻交换 。必须等 LCD 真正把这一帧数据全部读走之后,才能允许 CPU 去修改原来的 front buffer。否则又会引发竞争条件。
那怎么知道什么时候刷新完了?答案是: DMA 传输完成中断 。
ESP32-S3 的秘密武器:GDMA + SPI 外设联动
ESP32-S3 虽然没有 GPU,但它有一套非常强大的外设 DMA 子系统,尤其是 GDMA(General-DMA)模块 ,可以直接接管 SPI 数据传输任务。
这意味着什么?
👉 你可以告诉 GDMA:“接下来我要发 150KB 像素数据,地址在这里,你自己去推给 SPI,发完了叫我一声。”
👉 然后 CPU 就可以转身去做别的事:处理触摸事件、跑网络协议栈、甚至继续准备下一帧 UI。
整个过程完全异步,真正实现“绘制不阻塞显示,显示不影响逻辑”。
来看一段核心初始化代码:
// 配置 SPI 总线
spi_bus_config_t buscfg = {
.sclk_io_num = LCD_PIN_SCLK,
.mosi_io_num = LCD_PIN_MOSI,
.miso_io_num = -1,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = FRAME_SIZE, // 支持大包传输
};
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
这里的
max_transfer_sz
设置为整帧大小,确保 GDMA 可以一次性搬运全部数据,减少中断开销。
接着配置面板 IO:
esp_lcd_panel_io_spi_config_t io_config = {
.pclk_hz = 40 * 1000000, // SPI 时钟 40MHz
.lcd_cmd_bits = 8,
.lcd_param_bits = 8,
.dc_gpio_num = LCD_PIN_DC,
.spi_mode = 0,
.trans_queue_depth = 10,
.on_color_trans_done = lcd_flush_ready, // 传输完成回调
.user_ctx = &disp_drv // 回传 LVGL 驱动结构
};
重点看
.on_color_trans_done
—— 这个函数会在 GDMA 把一帧图像彻底发送完毕后自动调用。这时候你才能安全地执行缓冲区交换。
void lcd_flush_ready(esp_lcd_panel_io_handle_t panel_io, esp_err_t err, void *user_ctx)
{
lv_disp_drv_t *drv = (lv_disp_drv_t *)user_ctx;
lv_disp_flush_ready(drv); // 通知 LVGL:可以开始画下一帧了
}
看到了吗?DMA 不仅帮你省下了 CPU 时间,还提供了精确的同步信号,让你知道“现在可以换缓冲了”。
这才是双缓冲能稳定运行的技术基石。
内存分配的艺术:PSRAM 是你的朋友
前面说了,一个 320×240 的 RGB565 缓冲要占 150KB,双缓冲就是 300KB。
ESP32-S3 内部 SRAM 一般是 512KB,听起来不少,但刨去蓝牙协议栈、WiFi 缓冲、FreeRTOS 任务栈、LVGL 动态对象池……留给帧缓冲的空间其实很紧张。
更糟的是: DMA 对内存有严格要求——必须位于内部 SRAM 或支持 DMA 访问的外部 RAM 区域 。
好消息是,ESP32-S3 支持外接
PSRAM(Pseudo Static RAM)
,容量常见为 2MB 或 4MB,且通过
heap_caps_malloc()
可以直接分配支持 DMA 的内存块。
所以我们应该怎么做?
✅ 正确做法 :
#define MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM
s_back_buffer = heap_caps_malloc(FRAME_SIZE, MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM);
s_front_buffer = heap_caps_malloc(FRAME_SIZE, MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM);
⚠️ 千万不要用
malloc()
或
calloc()
,它们可能分配到不可用于 DMA 的内存区域,导致传输失败或崩溃。
📌 小贴士:可以通过
heap_caps_check_valid_size_with_flags(MALLOC_CAP_SPIRAM)
检查是否启用了 PSRAM 支持。
实际性能表现:你能跑到多少帧?
我们来做个真实测算。
| 参数 | 数值 |
|---|---|
| 分辨率 | 320×240 |
| 色深 | RGB565 |
| SPI 时钟 | 80 MHz |
| 每帧数据量 | ~150 KB |
理论最大传输速率:80Mbps ÷ 8 = 10MB/s
单帧传输时间:150KB ÷ 10MB/s =
15ms
也就是说,极限情况下可以达到 ~66fps 。但这只是理想值,实际受以下因素影响:
-
命令开销(每次刷新前需发送
0x2C写内存命令) - GDMA 启动延迟
- 总线争抢(WiFi 也可能占用 SPI 总线)
- CPU 调度开销
实测结果通常是:
| 条件 | 实际帧率 |
|---|---|
| 80MHz + 全屏刷新 | 45~50fps |
| 40MHz + 全屏刷新 | 30~35fps |
| 40MHz + 局部刷新(仅变动区域) | >60fps(平均) |
所以,如果你想追求更高帧率,除了提高 SPI 频率,还有一个杀手锏: 局部刷新(Partial Update) 。
局部刷新:只更新变化的部分
很多时候,整个屏幕并不会每一帧都变。比如:
- 仪表盘上的指针转动 → 只改中心区域;
- 数字时钟更新 → 只重绘数字部分;
- 滚动列表 → 只刷新增行。
LVGL 本身就支持脏区域标记机制。当你调用
lv_obj_invalidate()
时,它会记录哪些区域需要重绘。
我们可以改造
flush_cb
函数,只上传变更区域:
void my_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map)
{
int x1 = area->x1;
int y1 = area->y1;
int x2 = area->x2;
int y2 = area->y2;
// 限制边界
if (x1 < 0) x1 = 0;
if (y1 < 0) y1 = 0;
if (x2 >= LCD_WIDTH) x2 = LCD_WIDTH - 1;
if (y2 >= LCD_HEIGHT) y2 = LCD_HEIGHT - 1;
// 发送该矩形区域的数据
esp_lcd_panel_draw_bitmap(panel_handle, x1, y1, x2 + 1, y2 + 1, (uint8_t *)color_map);
lv_disp_flush_ready(drv);
}
这样,原本要传 150KB 的全屏刷新,可能变成只传 10KB 的小区域更新,效率提升非常明显。
🧠 经验法则:
- 动态元素占比 < 30% → 优先考虑局部刷新;
- 动画复杂、频繁全屏变化 → 接受 30fps,保证一致性。
和 LVGL 深度集成:让框架替你管理双缓冲
说实话,手动维护前后台缓冲、做指针切换,听着就不够优雅。好在像 LVGL(Light and Versatile Graphics Library) 这样的现代嵌入式 GUI 框架,早已原生支持双缓冲模型。
只需要在初始化显示器驱动时声明:
static lv_disp_draw_buf_t draw_buf;
lv_disp_draw_buf_init(&draw_buf, back_buffer, NULL, FRAME_SIZE / 2);
lv_disp_drv_init(&disp_drv);
disp_drv.draw_buf = &draw_buf;
disp_drv.flush_cb = my_flush_cb;
disp_drv.hor_res = LCD_WIDTH;
disp_drv.ver_res = LCD_HEIGHT;
lv_disp_t *disp = lv_disp_drv_register(&disp_drv);
注意到没?第二个参数传的是
NULL
—— 因为我们只用了
双缓冲中的逻辑双缓冲
,物理上仍是两个独立 buffer,但 LVGL 会自动调度绘制顺序。
而且,
FRAME_SIZE / 2
是因为
lv_color_t
默认是 16bit,单位匹配即可。
一旦注册成功,LVGL 会自动将所有绘图操作定向到当前的 back buffer,并在
flush_cb
返回后等待 DMA 完成信号,形成完美闭环。
多核协同:别让 GUI 拖垮 WiFi 性能
ESP32-S3 是双核 Xtensa LX7,主频高达 240MHz。这么强的算力,当然不能浪费。
建议做法:
- Core 0 :专用于 WiFi/BT 协议栈、TCP/IP 处理、OTA 升级等实时性高、中断密集的任务;
- Core 1 :负责 GUI 渲染、触摸输入、音频播放等人机交互任务。
如何绑定任务到指定核心?
xTaskCreatePinnedToCore(
display_refresh_task,
"display",
4096,
NULL,
20, // 优先级较高
NULL,
1 // 绑定到 Core 1
);
同时,LVGL 的主循环也应运行在同一核心:
while(1) {
lv_timer_handler(); // 处理动画、输入等
vTaskDelay(pdMS_TO_TICKS(5)); // 控制 tick 频率
}
这样做有什么好处?
- 避免跨核缓存污染;
- 减少任务切换开销;
- 提升 GUI 响应速度,尤其在 WiFi 流量较大时仍能保持流畅。
常见坑点与避雷指南 🛑
❌ 错误1:在中断中调用 LVGL API
LVGL 不是线程安全的,所有 API 必须在主线程上下文中调用。如果你在 GPIO 中断里直接
lv_label_set_text()
,轻则界面卡死,重则 crash。
✅ 正确做法:设置标志位或发送消息队列,由主任务轮询处理。
❌ 错误2:忘记清空缓冲区
第一次启动时不初始化缓冲区,屏幕上可能会残留上次关机前的“鬼影”。记得加一句:
memset(back_buffer, 0, FRAME_SIZE);
memset(front_buffer, 0, FRAME_SIZE);
最好填充为黑屏(RGB565: 0x0000)或背光颜色。
❌ 错误3:DMA 传输未对齐
某些 SPI 控制器要求传输长度为 4 字节对齐。若非对齐可能导致最后几个像素丢失。
✅ 解决方案:调整
max_transfer_sz
为 4 的倍数,或启用
spidma_bus_add_flash_device()
补偿机制。
❌ 错误4:盲目追求 60fps
如前所述,320×240 @ 60fps 需要近 10MB/s 带宽,接近 SPI 极限。强行推送会导致丢帧、卡顿、甚至总线锁死。
✅ 建议目标:日常应用锁定 30fps ,动画场景冲刺 45fps ,足够丝滑又不失稳定性。
如何调试双缓冲是否生效?
最简单的办法:在
draw_ui_frame()
里故意制造一个“撕裂诱饵”——画一条横跨屏幕中央的红色粗线。
// 在 y=120 处画一条红条
for (int x = 0; x < 320; x++) {
set_pixel(buffer, x, 120, RED);
set_pixel(buffer, x, 121, RED);
}
然后快速上下滑动页面。如果没有双缓冲,你会看到这条红线在某个位置突然错位或断裂;如果双缓冲工作正常,红线始终保持完整。
另一个方法是打印日志:
static uint32_t frame_cnt = 0;
printf("Frame %lu flushed at %lu ms\n", ++frame_cnt, esp_log_timestamp());
观察间隔是否稳定。抖动过大说明 DMA 或 CPU 调度有问题。
进阶思路:三缓冲可行吗?
有人问:“既然双缓冲有用,那三缓冲是不是更好?”
理论上,三缓冲可以进一步隐藏延迟,适合极高动态场景。但在 ESP32-S3 上要慎重:
- 多一倍内存占用(3 × 150KB = 450KB),吃掉大半 PSRAM;
- 管理复杂度上升,容易引入缓冲饥饿或溢出;
- LVGL 目前并未针对三缓冲优化调度策略。
✅ 更推荐的做法:
与其上三缓冲,不如做好
脏区域合并 + 异步预渲染
。例如提前计算下一帧布局,在空闲周期预加载纹理。
最后一点思考:技术的选择永远基于权衡
双缓冲确实带来了更流畅的体验,但也付出了代价:
- 多花了 150KB 内存;
- 增加了约 10ms 的输入延迟(因为要等一整帧刷新);
- 调试难度略升(你看到的画面其实是“上一帧”的状态)。
所以在一些极简场景中,比如只显示静态文本或传感器数值,完全可以关闭双缓冲,节省资源。
但只要涉及到动画、滑动、交互反馈, 双缓冲就是不可或缺的基础设施 。
它不是一个炫技的功能,而是一种对用户体验的尊重。
当你下一次看到那个平滑滚动的日历控件,或是丝滑展开的菜单动画,请记住背后默默工作的不只是 LVGL,还有那一组不起眼的
front_buffer
和
back_buffer
,以及准时响起的 DMA 传输完成中断。
正是这些底层细节的打磨,才让一块廉价的 SPI TFT 屏,也能焕发出高端设备般的光彩。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

被折叠的 条评论
为什么被折叠?



