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解决的是传输效率的问题,压缩解决的是内存容量的问题。这三者不是互斥的,你可以组合使用。比如:双缓冲 + DMA传输 + RLE压缩,就是一个很实用的方案。
我建议你在实际项目中,先评估内存够不够,再决定要不要上双缓冲或三缓冲。然后看看CPU负载,决定要不要用DMA。最后,如果内存还是紧张,再考虑压缩。一步一步来,别一上来就全上,容易把自己搞晕。