2. 嵌入式GUI基础

帧缓冲,说白了就是显存里的一块区域,专门用来存放你要显示的画面数据。MCU往这块区域里写像素,显示控制器从这块区域里读像素,然后刷到屏幕上。这个读写过程如果配合不好,画面就会出现撕裂、闪烁,甚至卡顿。我最早做GUI优化时,就被这个问题折腾得不轻。

2.1 单缓冲的痛点:撕裂与闪烁

先聊聊最简单的单缓冲。MCU直接往显存里写数据,同时显示控制器也在读同一块显存去刷新屏幕。你想想看,如果MCU写到一半,控制器开始读,那屏幕上就会出现上半部分是旧画面、下半部分是新画面的情况——这就是撕裂。

还有一种情况更常见:MCU先清空整个缓冲,再一像素一像素地画。清空的那一瞬间,屏幕是黑的;画到一半时,屏幕是半成品。用户看到的,就是画面一闪一闪的。嗯,这就是闪烁。

核心结论:单缓冲模式下,撕裂和闪烁是结构性问题,不是靠优化绘制代码能解决的。必须从缓冲架构上动手。

2.2 双缓冲机制:空间换时间

双缓冲的思路很简单:准备两块缓冲,一块叫前台缓冲(Front Buffer),一块叫后台缓冲(Back Buffer)。显示控制器只读前台缓冲,MCU只写后台缓冲。画完了,通过一个交换操作,把后台变成前台,前台变成后台。

这个交换操作,在硬件支持的情况下,其实就是改一个指针地址,瞬间完成。用户永远看不到绘制中的半成品,也看不到撕裂。

我在一个STM32H7的项目里用过双缓冲,效果立竿见影。之前单缓冲时,刷新率只能跑到30fps,而且画面一直闪。改成双缓冲后,直接稳定在60fps,闪烁完全消失。

双缓冲的实现要点

  • 缓冲大小:每块缓冲 = 屏幕宽度 × 高度 × 每像素字节数。比如800×480的RGB565屏,每块缓冲 = 800 × 480 × 2 = 768KB,两块就是1.5MB。这个内存开销你得提前算好。
  • 交换时机:必须在垂直消隐期(VBlank)内交换。如果在屏幕正在刷新时交换,还是会出现撕裂。大多数LTDC控制器都支持VBlank中断,我就是在这个中断里做缓冲交换的。
  • 指针交换 vs 数据拷贝:硬件支持指针交换时,直接改LTDC的帧缓冲地址寄存器就行。如果不支持,就只能做memcpy把后台数据拷到前台——这很慢,不推荐。

我的习惯:在初始化时就把两块缓冲的地址固定下来,运行时只交换指针。这样既快又稳,不会出现内存碎片问题。

2.3 三缓冲机制:进一步平滑

双缓冲虽然解决了撕裂和闪烁,但有一个隐藏问题:如果MCU绘制一帧的时间超过了屏幕刷新一帧的时间,那MCU就得等。等前台缓冲释放了,才能开始画下一帧。这个等待时间,会导致帧率下降。

三缓冲就是再加一块缓冲。MCU可以连续绘制,不用等。显示控制器从一块缓冲读,MCU往另一块写,还有一块作为备用。说白了,就是让绘制和显示完全解耦。

我曾经在一个需要跑60fps动画的项目里试过三缓冲。双缓冲时,偶尔会因为绘制超时掉到45fps,画面能感觉到卡顿。换成三缓冲后,帧率稳稳地锁在60fps,再也没有掉过。

特性 单缓冲 双缓冲 三缓冲
内存开销 1× 2× 3×
撕裂 有 无(VBlank交换) 无
闪烁 有 无 无
绘制等待 无 可能有 极少
适用场景 静态画面 一般动画 高帧率动画

注意:三缓冲不是万能的。如果你的MCU绘制速度本身就慢,三缓冲只是让绘制不等待,但帧率上限还是由绘制速度决定的。我曾经见过有人上了三缓冲,帧率没变,内存倒是多了1.5MB——白费功夫。

2.4 DMA加速传输:让CPU歇着

不管是双缓冲还是三缓冲,数据总得从MCU内存搬到显存里。如果每次都用CPU做memcpy,那CPU就被占死了,没法干其他事。而且,memcpy的效率其实不高,尤其是大块数据拷贝。

DMA(直接存储器访问)就是干这个的。你告诉DMA控制器:源地址在哪、目标地址在哪、要拷多少字节。然后DMA自己干活,CPU可以继续绘制下一帧,或者处理触摸事件。

我记得在一个项目里,用CPU做memcpy拷贝一帧800×480的RGB565数据,耗时约8ms。换成DMA后,耗时降到3ms,而且CPU完全空闲。这5ms的差距,在60fps的周期里(16.7ms),就是30%的性能提升。

DMA传输的关键配置

  • 源地址和目标地址:必须确保地址对齐。很多DMA控制器要求32位对齐,否则会触发异常或降低效率。
  • 传输完成中断:DMA传完后会触发中断。你在这个中断里做缓冲交换,或者通知GUI引擎开始下一帧绘制。
  • 循环模式:如果画面是静态的,可以用DMA的循环模式,让DMA一直从缓冲往显存传数据,CPU完全不用管。
// 以STM32的DMA2D为例(专门用于图形加速的DMA)
// 配置从后台缓冲到前台缓冲的传输
DMA2D->CR = 0x00030000;  // 存储器到存储器模式
DMA2D->FGMAR = (uint32_t)backBuffer;  // 源地址(后台缓冲)
DMA2D->FGOR = 0;  // 源行偏移
DMA2D->BGMAR = (uint32_t)frontBuffer; // 目标地址(前台缓冲)
DMA2D->BGOR = 0;  // 目标行偏移
DMA2D->FGPFCCR = LTDC_PIXEL_FORMAT_RGB565; // 像素格式
DMA2D->NLR = (width << 16) | height; // 宽度和高度
DMA2D->CR |= DMA2D_CR_START; // 启动传输
// 等待传输完成标志位
while(!(DMA2D->ISR & DMA2D_ISR_TCIF));

避坑指南:我曾经在DMA传输还没完成时就去修改缓冲内容,结果屏幕上出现了花屏。后来我加了一个信号量,DMA传输完成前,后台缓冲禁止写入。这个习惯我一直保留到现在。

2.5 帧缓冲压缩技术:省内存就是省钱

嵌入式系统的内存是金贵的。一块800×480的RGB888屏幕,一帧就需要800×480×3 ≈ 1.1MB。双缓冲就是2.2MB,三缓冲就是3.3MB。很多MCU总共才2MB内存,光缓冲就占满了,其他代码和数据往哪放?

帧缓冲压缩,就是在不显著影响画质的前提下,把缓冲数据压缩存储。显示时再解压。常见的压缩方法有几种:

  • RLE(游程编码):适合有大面积纯色块的GUI界面。比如按钮、背景、文字。连续相同的像素值,只存一次值和重复次数。压缩率通常在2:1到5:1之间。
  • LZ4:一种快速的LZ77变体。解压速度极快,适合实时解压。压缩率比RLE高,但实现复杂一些。
  • 调色板压缩:把RGB888转成索引色,比如256色调色板。每个像素从3字节变成1字节,压缩率3:1。适合色彩不丰富的界面。

我在一个资源紧张的Cortex-M4项目里用过调色板压缩。屏幕是480×272的RGB565,原本一帧需要261KB。转成256色调色板后,一帧只需要130KB,内存直接省了一半。画质损失?说实话,在那种小屏幕上,肉眼几乎看不出来。

压缩方法 压缩率 解压速度 实现复杂度 适用场景
RLE 2:1 ~ 5:1 极快 低 UI界面、文字
LZ4 2:1 ~ 3:1 快 中 通用场景
调色板 3:1 极快 低 色彩少的界面

注意:压缩和解压本身需要CPU时间。如果压缩率带来的内存节省,不足以抵消解压带来的性能开销,那就不值得用。我建议你先做性能评估:解压一帧需要多少时间?这个时间是否在VBlank窗口内?如果解压时间超过VBlank,那画面就会撕裂。

2.6 知识体系总览

下面这张图把帧缓冲优化的核心逻辑串起来了。你可以看到,从单缓冲到三缓冲,从CPU拷贝到DMA传输,再到压缩技术,每一步都是在解决特定的问题。

帧缓冲优化知识体系 帧缓冲优化 缓冲机制 DMA加速传输 帧缓冲压缩 单缓冲 双缓冲 三缓冲 DMA2D / 通用DMA VBlank同步传输 RLE LZ4 调色板 核心思路:减少等待 + 减少拷贝 + 减少内存 双/三缓冲解决等待问题 → DMA解决拷贝效率问题 → 压缩解决内存容量问题

嗯,到这里帧缓冲优化的核心内容就讲完了。双缓冲和三缓冲解决的是画面质量的问题,DMA解决的是传输效率的问题,压缩解决的是内存容量的问题。这三者不是互斥的,你可以组合使用。比如:双缓冲 + DMA传输 + RLE压缩,就是一个很实用的方案。

我建议你在实际项目中,先评估内存够不够,再决定要不要上双缓冲或三缓冲。然后看看CPU负载,决定要不要用DMA。最后,如果内存还是紧张,再考虑压缩。一步一步来,别一上来就全上,容易把自己搞晕。