帧缓冲设计:双缓冲、局部刷新与脏矩形追踪
帧缓冲,说白了就是一块内存区域,专门用来存放一帧完整的像素数据。OLED驱动里,这块内存怎么管、怎么刷,直接决定了显示效果和功耗。我做了这么多年嵌入式,见过太多人在帧缓冲上栽跟头——要么内存爆了,要么刷新闪成鬼影。
这一章,咱们就聊聊帧缓冲设计的三个核心问题:双缓冲怎么搭、局部刷新怎么做、脏矩形怎么追踪。嗯,这三个东西绑在一起,就是一套完整的帧缓冲管理体系。
2.1 双缓冲机制:告别撕裂与闪烁
先问一个问题:你直接往OLED的GRAM(显存)里写数据,边写边显示,会发生什么?
答案是撕裂。上一帧还没写完,下一帧已经开始显示了,屏幕中间会出现一条明显的分界线。我早期做的一个项目,128×64的单色OLED,就因为没做双缓冲,客户一看就说“你这屏幕在抖”。
双缓冲的思路很简单:准备两个缓冲区,一个叫前台缓冲(Front Buffer),一个叫后台缓冲(Back Buffer)。
- 前台缓冲:当前正在显示的内容,只读不写。
- 后台缓冲:应用程序正在绘制的内容,只写不读。
绘制完成后,通过一次交换操作(Swap),把后台缓冲的内容推到前台。OLED这边,只需要把后台缓冲的数据一次性刷到GRAM里就行。
关键点:交换操作必须是原子的。要么不换,要么瞬间换完。在MCU上,通常用指针交换来实现——两个缓冲区的地址指针互换,然后触发DMA传输。
我个人的习惯是,在STM32上做双缓冲时,会分配两块大小完全相同的内存,然后用一个volatile变量做状态标志:
// 双缓冲结构体
typedef struct {
uint8_t buffer[2][OLED_WIDTH * OLED_HEIGHT / 8]; // 两个缓冲区
volatile uint8_t active_idx; // 当前前台索引
volatile uint8_t dirty_flag; // 是否需要刷新
} FrameBuffer_t;
// 交换操作
void fb_swap(FrameBuffer_t *fb) {
fb->active_idx ^= 1; // 原子切换
fb->dirty_flag = 1; // 标记需要刷新
}
这里有个坑:active_idx ^= 1 在C语言里是原子操作吗?在大多数MCU上,对8位变量的异或操作确实是原子的,但如果你用32位变量,或者编译器优化开了-O2,就可能被拆成多条指令。我曾经在GCC -O2优化下遇到过指针交换被拆成三条指令,导致前台和后台同时被写——嗯,那画面简直了。
避坑指南:双缓冲的交换操作,一定要用volatile修饰,并且检查编译后的汇编代码。如果MCU支持,可以用关中断或原子操作指令来保护交换过程。
2.2 局部刷新算法:只刷需要变的部分
双缓冲解决了撕裂问题,但带来了一个新问题:每次交换都要把整个缓冲区刷到OLED上。对于128×64的单色屏,一帧是1024字节,用SPI 10MHz刷一次大概1ms。但如果换成240×240的彩色屏,一帧是115200字节,刷一次就要100ms以上——帧率直接掉到10fps。
怎么办?局部刷新。
核心思想很简单:只把变化的那部分像素刷到OLED上。没变的部分,保持不动。
OLED驱动芯片通常支持设置窗口(Set Window)命令,比如SSD1306的0x21和0x22命令,可以指定一个矩形区域,后续的数据只写入这个区域。这就是局部刷新的硬件基础。
局部刷新的流程是这样的:
- 应用程序修改后台缓冲中的某个区域。
- 记录这个区域的位置和大小(这就是脏矩形)。
- 在刷新周期,只把这个脏矩形对应的数据发送到OLED。
- 发送窗口设置命令,然后发送数据。
经验之谈:局部刷新不是万能的。如果整个屏幕都在变化(比如视频播放),局部刷新反而会增加开销——因为每次都要计算脏矩形。我一般会在脏矩形面积超过屏幕面积50%时,直接做全屏刷新。
来看一个简单的局部刷新实现:
void oled_update_rect(FrameBuffer_t *fb, uint8_t x, uint8_t y,
uint8_t w, uint8_t h) {
uint8_t *back = fb->buffer[fb->active_idx ^ 1];
// 设置OLED窗口
oled_set_window(x, y, x + w - 1, y + h - 1);
// 只发送脏矩形区域的数据
for (uint8_t row = y; row < y + h; row++) {
uint16_t offset = row * OLED_WIDTH / 8 + x / 8;
oled_send_data(&back[offset], w / 8);
}
}
注意,这里假设OLED是单色页寻址模式,每页8个像素。如果是RGB屏,计算方式会复杂一些,但原理一样。
2.3 脏矩形追踪技术:高效管理变化区域
局部刷新有了,但怎么知道哪些区域变了?这就需要脏矩形追踪。
脏矩形(Dirty Rect)的概念来自图形学,简单说就是:用一个矩形列表来记录所有发生变化的区域。每次绘制操作后,把受影响的范围加入脏矩形列表。在刷新时,合并这些矩形,然后逐个刷新。
脏矩形追踪的核心数据结构:
typedef struct {
uint8_t x, y; // 左上角坐标
uint8_t w, h; // 宽度和高度
} DirtyRect_t;
typedef struct {
DirtyRect_t rects[MAX_DIRTY_RECTS]; // 脏矩形列表
uint8_t count; // 当前脏矩形数量
} DirtyTracker_t;
脏矩形追踪有几个关键操作:
| 操作 | 描述 | 复杂度 |
|---|---|---|
| 添加 | 绘制操作后,将受影响区域加入列表 | O(1) |
| 合并 | 将相邻或重叠的矩形合并,减少刷新次数 | O(n²) |
| 裁剪 | 将脏矩形限制在屏幕范围内 | O(n) |
| 清空 | 刷新完成后,清空列表 | O(1) |
合并算法是脏矩形追踪的核心。我常用的策略是:
- 如果两个矩形有重叠,或者间距小于某个阈值(比如4像素),就合并成一个更大的矩形。
- 如果脏矩形数量超过上限(比如16个),就合并所有矩形为一个全屏矩形。
- 如果脏矩形总面积超过屏幕面积的50%,直接标记全屏刷新。
性能关键:脏矩形合并的复杂度是O(n²),n是脏矩形数量。n一般不会超过32,所以可以接受。但如果你的应用频繁产生大量小区域变化(比如鼠标指针移动),可以考虑用位图法——用一个bitmap标记每个像素块是否脏,合并时直接扫描bitmap。
下面是一个简单的脏矩形合并实现:
void dirty_merge(DirtyTracker_t *tracker) {
if (tracker->count <= 1) return;
// 如果脏矩形太多,直接合并为全屏
if (tracker->count > MAX_DIRTY_RECTS) {
tracker->rects[0] = (DirtyRect_t){0, 0, SCREEN_W, SCREEN_H};
tracker->count = 1;
return;
}
// 两两合并
for (int i = 0; i < tracker->count; i++) {
for (int j = i + 1; j < tracker->count; j++) {
if (rects_overlap(&tracker->rects[i], &tracker->rects[j])) {
// 合并到第一个矩形
rect_union(&tracker->rects[i], &tracker->rects[j]);
// 移除被合并的矩形
tracker->rects[j] = tracker->rects[--tracker->count];
j--; // 重新检查当前位置
}
}
}
}
这里rects_overlap判断两个矩形是否重叠或相邻,rect_union计算两个矩形的最小包围盒。这两个函数实现起来不难,但要注意边界条件——我曾经因为矩形坐标计算少加了1,导致屏幕边缘有一条像素始终刷不到。
2.4 整体架构:双缓冲 + 脏矩形追踪
把上面三个东西串起来,就是一套完整的帧缓冲管理体系。我画了一张图,帮你理清关系:
整个流程是这样的:
- 应用程序调用绘制函数(比如画一个矩形),写入后台缓冲。
- 同时,脏矩形追踪器记录这个矩形的位置。
- 在VSync中断或定时刷新周期,脏矩形追踪器合并所有脏矩形。
- 双缓冲层执行交换操作(指针交换)。
- 驱动层根据脏矩形列表,设置OLED窗口,只发送变化区域的数据。
- 刷新完成后,清空脏矩形列表。
我的建议:不要把脏矩形合并和刷新放在中断里做。中断里只做标志位设置,合并和刷新放在主循环或任务中。我曾经在定时器中断里做脏矩形合并,结果合并时间太长,导致下一次中断被错过——屏幕直接卡死。
2.5 内存与性能权衡
最后聊一下实际项目中怎么选型。不同的应用场景,对帧缓冲的要求完全不同:
| 场景 | 缓冲区大小 | 刷新策略 | 脏矩形数量 |
|---|---|---|---|
| 静态文本显示 | 单缓冲即可 | 全屏刷新(变化极少) | 0~1 |
| 菜单/UI交互 | 双缓冲(推荐) | 局部刷新 | 1~8 |
| 动画/视频播放 | 三缓冲(可选) | 全屏刷新 | 1(全屏) |
| 低功耗传感器显示 | 双缓冲 + 压缩 | 局部刷新 + 休眠 | 1~4 |
对于大多数嵌入式UI应用,双缓冲 + 脏矩形追踪是最佳组合。内存开销大约是两帧的像素数据,对于128×64单色屏只有2KB,对于240×240 RGB565屏需要115KB——嗯,后者在STM32F103上可能有点吃力,这时候可以考虑用外部SRAM或者压缩缓冲区。
注意:如果MCU的RAM实在不够,可以退而求其次用单缓冲 + 局部刷新。但一定要保证绘制操作和刷新操作互斥——要么在绘制时禁止刷新,要么在刷新时禁止绘制。否则,你会在屏幕上看到各种奇怪的残影。