帧缓冲设计:双缓冲、局部刷新与脏矩形追踪

帧缓冲,说白了就是一块内存区域,专门用来存放一帧完整的像素数据。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命令,可以指定一个矩形区域,后续的数据只写入这个区域。这就是局部刷新的硬件基础。

局部刷新的流程是这样的:

  1. 应用程序修改后台缓冲中的某个区域。
  2. 记录这个区域的位置和大小(这就是脏矩形)。
  3. 在刷新周期,只把这个脏矩形对应的数据发送到OLED。
  4. 发送窗口设置命令,然后发送数据。

经验之谈:局部刷新不是万能的。如果整个屏幕都在变化(比如视频播放),局部刷新反而会增加开销——因为每次都要计算脏矩形。我一般会在脏矩形面积超过屏幕面积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 整体架构:双缓冲 + 脏矩形追踪

把上面三个东西串起来,就是一套完整的帧缓冲管理体系。我画了一张图,帮你理清关系:

帧缓冲管理体系架构图 帧缓冲管理体系架构 应用层(绘制操作) 绘制文本、图形、图像 → 写入后台缓冲 脏矩形追踪层 记录变化区域 → 合并脏矩形 → 生成刷新列表 双缓冲层 前台缓冲(显示) ↔ 后台缓冲(绘制) OLED驱动层 设置窗口 → DMA传输 → 刷新GRAM 核心流程:绘制 → 标记脏矩形 → 合并 → 交换缓冲 → 局部刷新OLED

整个流程是这样的:

  1. 应用程序调用绘制函数(比如画一个矩形),写入后台缓冲。
  2. 同时,脏矩形追踪器记录这个矩形的位置。
  3. 在VSync中断或定时刷新周期,脏矩形追踪器合并所有脏矩形。
  4. 双缓冲层执行交换操作(指针交换)。
  5. 驱动层根据脏矩形列表,设置OLED窗口,只发送变化区域的数据。
  6. 刷新完成后,清空脏矩形列表。

我的建议:不要把脏矩形合并和刷新放在中断里做。中断里只做标志位设置,合并和刷新放在主循环或任务中。我曾经在定时器中断里做脏矩形合并,结果合并时间太长,导致下一次中断被错过——屏幕直接卡死。

2.5 内存与性能权衡

最后聊一下实际项目中怎么选型。不同的应用场景,对帧缓冲的要求完全不同:

场景 缓冲区大小 刷新策略 脏矩形数量
静态文本显示 单缓冲即可 全屏刷新(变化极少) 0~1
菜单/UI交互 双缓冲(推荐) 局部刷新 1~8
动画/视频播放 三缓冲(可选) 全屏刷新 1(全屏)
低功耗传感器显示 双缓冲 + 压缩 局部刷新 + 休眠 1~4

对于大多数嵌入式UI应用,双缓冲 + 脏矩形追踪是最佳组合。内存开销大约是两帧的像素数据,对于128×64单色屏只有2KB,对于240×240 RGB565屏需要115KB——嗯,后者在STM32F103上可能有点吃力,这时候可以考虑用外部SRAM或者压缩缓冲区。

注意:如果MCU的RAM实在不够,可以退而求其次用单缓冲 + 局部刷新。但一定要保证绘制操作和刷新操作互斥——要么在绘制时禁止刷新,要么在刷新时禁止绘制。否则,你会在屏幕上看到各种奇怪的残影。