4、低功耗刷新策略实战:基于帧缓冲区的局部刷新策略、基于脏矩形算法的增量刷新

各位好,欢迎来到第四章。前面我们聊了DMA怎么搬运数据,帧率怎么算。但有个实际问题一直没解决——屏幕上的内容,真的每次都要全部重画吗?

我早年做一款智能手表,电池撑不过一天。后来一查,发现大部分功耗都耗在了“画那些根本没变化的地方”上。说白了,你盯着时间看,秒针在动,但背景的天气图标、心率曲线根本没变,你却每帧都把它们重新刷一遍。这不就是浪费电吗?

所以这一章,我们就来实战两种低功耗刷新策略:局部刷新和脏矩形增量刷新。它们的目标只有一个——只画该画的部分,省下不该花的电。

4.1 局部刷新策略:只更新“变了”的那块

局部刷新的思路很直接:既然帧缓冲区里大部分内容没变,那我就不动它。我只把变化的那一小块区域,重新写入显存,然后告诉屏幕驱动:“嘿,你只需要刷新这块矩形区域。”

这听起来简单,但实现起来有几个关键点。

4.1.1 帧缓冲区的区域划分

首先,你得知道你的帧缓冲区是怎么组织的。假设我们用的是RGB565格式,分辨率320×240。那么一帧的数据量是:

320 × 240 × 2 = 153,600 字节

如果每次全刷,DMA就要搬153KB。但如果只刷一个100×100的弹窗呢?

100 × 100 × 2 = 20,000 字节

数据量直接降到原来的13%。功耗自然就下来了。

我个人习惯在驱动层维护一个脏区域结构体,记录当前需要刷新的矩形范围:

typedef struct {
    uint16_t x;      // 起始X坐标
    uint16_t y;      // 起始Y坐标
    uint16_t width;  // 矩形宽度
    uint16_t height; // 矩形高度
} dirty_rect_t;

每次应用层调用刷新接口时,先更新这个结构体,再由底层驱动决定怎么刷。

4.1.2 局部刷新的硬件支持

这里有个坑。很多便宜的LCD控制器,并不支持“只刷一部分”。你往显存里写数据,它不管你写的是哪块,反正整屏都扫一遍。

所以,你得确认你的屏幕驱动IC是否支持窗口模式。比如常见的ILI9341、ST7789,它们都有设置窗口的命令:

// 设置列地址范围
LCD_WriteCmd(0x2A);
LCD_WriteData(x_start >> 8);
LCD_WriteData(x_start & 0xFF);
LCD_WriteData(x_end >> 8);
LCD_WriteData(x_end & 0xFF);

// 设置行地址范围
LCD_WriteCmd(0x2B);
LCD_WriteData(y_start >> 8);
LCD_WriteData(y_start & 0xFF);
LCD_WriteData(y_end >> 8);
LCD_WriteData(y_end & 0xFF);

// 开始写显存
LCD_WriteCmd(0x2C);

设置完窗口后,你往数据线送像素,驱动IC就会自动把像素填到窗口区域内,窗口外的内容保持不变。嗯,这里要注意:窗口模式下,DMA传输的地址和长度必须和窗口大小严格匹配,否则画面会错位。

我曾经在一个项目里,窗口设置对了,但DMA传输的像素数比窗口面积多了几个字节。结果屏幕右下角出现了一条彩色的“尾巴”,排查了半天才发现是DMA计数器没对齐。

4.2 脏矩形算法:增量刷新的核心

局部刷新解决了“刷哪里”的问题。但还有一个问题没解决:我怎么知道哪些区域变了?

这就是脏矩形算法登场的时候了。

4.2.1 什么是脏矩形?

脏矩形,说白了就是“画面中发生变化的最小矩形区域”。比如你按下一个按钮,按钮本身变了,但按钮周围的背景没变。那么脏矩形就是按钮的边界框。

算法流程大致是这样的:

  1. 应用层修改某个UI元素(比如更新文本、移动图标)。
  2. UI框架计算出该元素的新旧边界,合并成一个脏矩形。
  3. 将脏矩形提交给刷新管理器。
  4. 刷新管理器合并多个脏矩形(如果有多个变化)。
  5. 最终将合并后的脏矩形区域,通过DMA传输到显存。
  6. 驱动层设置窗口,只刷新该区域。

你看,整个过程只处理变化的部分,没变的地方纹丝不动。

4.2.2 脏矩形合并策略

实际项目中,一帧内可能有多个UI元素同时变化。比如一个进度条在动,同时右上角的电量图标也在更新。这时候你会得到两个脏矩形。

合并策略一般有两种:

策略 做法 优点 缺点
直接合并 取所有脏矩形的最小外接矩形 实现简单,一次DMA传输搞定 可能包含大量未变化区域,浪费带宽
分块合并 将屏幕分成固定大小的块(如16×16),标记哪些块脏了 减少无效刷新区域 需要维护块位图,实现稍复杂
链表合并 维护一个脏矩形链表,相邻或重叠的合并 精确控制刷新区域 链表操作有开销,适合变化不频繁的场景

我个人比较推荐分块合并。它在实现复杂度和刷新效率之间取得了不错的平衡。你想想看,把屏幕切成16×16的小块,每个块用一个bit标记是否脏。一帧下来,你只需要遍历这些bit,找到所有脏块,然后逐个刷新。

小技巧:分块的大小要和DMA传输的粒度匹配。如果DMA每次传输的最小单位是4字节,那你的块宽度最好是4的倍数。否则会有对齐问题,导致性能下降。

4.2.3 脏矩形算法的代码骨架

下面是一个简化的脏矩形管理器实现:

#define SCREEN_WIDTH  320
#define SCREEN_HEIGHT 240
#define BLOCK_SIZE    16

// 脏块位图,每个bit代表一个块
uint8_t dirty_map[(SCREEN_WIDTH / BLOCK_SIZE) * (SCREEN_HEIGHT / BLOCK_SIZE) / 8];

// 标记某个区域为脏
void mark_dirty(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
    uint16_t start_block_x = x / BLOCK_SIZE;
    uint16_t start_block_y = y / BLOCK_SIZE;
    uint16_t end_block_x = (x + w - 1) / BLOCK_SIZE;
    uint16_t end_block_y = (y + h - 1) / BLOCK_SIZE;

    for (uint16_t by = start_block_y; by <= end_block_y; by++) {
        for (uint16_t bx = start_block_x; bx <= end_block_x; bx++) {
            uint16_t block_index = by * (SCREEN_WIDTH / BLOCK_SIZE) + bx;
            dirty_map[block_index / 8] |= (1 << (block_index % 8));
        }
    }
}

// 刷新所有脏块
void flush_dirty_blocks(void) {
    uint16_t total_blocks = (SCREEN_WIDTH / BLOCK_SIZE) * (SCREEN_HEIGHT / BLOCK_SIZE);
    for (uint16_t i = 0; i < total_blocks; i++) {
        if (dirty_map[i / 8] & (1 << (i % 8))) {
            uint16_t bx = (i % (SCREEN_WIDTH / BLOCK_SIZE)) * BLOCK_SIZE;
            uint16_t by = (i / (SCREEN_WIDTH / BLOCK_SIZE)) * BLOCK_SIZE;
            // 设置窗口,只刷新这个块
            lcd_set_window(bx, by, bx + BLOCK_SIZE - 1, by + BLOCK_SIZE - 1);
            // 通过DMA传输该块对应的帧缓冲区数据
            dma_transfer(frame_buffer + (by * SCREEN_WIDTH + bx) * 2, BLOCK_SIZE * BLOCK_SIZE * 2);
            // 清除脏标记
            dirty_map[i / 8] &= ~(1 << (i % 8));
        }
    }
}

这段代码看起来简单,但实际项目中要考虑的东西不少。比如,如果脏块太多,一帧内刷不完怎么办?那就需要做优先级排序,先刷用户最关注的区域(比如正在点击的按钮),剩下的留到下一帧。

4.3 实战中的避坑指南

讲到这里,我想分享几个我在项目中踩过的坑。

坑一:局部刷新导致画面撕裂

局部刷新时,如果屏幕的扫描线正好扫到你正在刷新的区域,就会出现上半截是新内容、下半截是旧内容的撕裂现象。解决办法是:在垂直消隐期(VBlank)进行刷新,或者使用双缓冲。

坑二:脏矩形合并后反而更耗电

有一次,我遇到一个动画效果,整个屏幕的元素都在缓慢移动。结果脏矩形合并后,几乎覆盖了整个屏幕。这时候局部刷新反而没有意义了。我的做法是:当脏矩形面积超过屏幕面积的70%时,直接切回全屏刷新。因为全屏刷新的DMA传输效率更高,单次传输的开销反而比多次小窗口传输要低。

坑三:DMA传输和脏矩形更新不同步

如果DMA正在传输某个脏块的数据,而应用层又标记了新的脏块,就会导致数据错乱。我习惯用双缓冲脏矩形表:一个表给应用层写,一个表给DMA读。DMA传输完成后,交换两个表的指针。这样读写就不会冲突了。

4.4 性能对比:局部刷新 vs 全屏刷新

最后,我们来看一组实测数据。这是我用一块320×240的TFT屏,在48MHz SPI总线下测的:

刷新方式 传输数据量 刷新耗时 相对功耗
全屏刷新 153.6 KB ~32 ms 100%
局部刷新(100×100弹窗) 20 KB ~4.2 ms ~13%
脏矩形增量刷新(典型UI操作) 2~15 KB 0.5~3.2 ms ~5%~10%

你看,脏矩形增量刷新在典型UI操作下,功耗只有全屏刷新的十分之一。这就是为什么现在的智能手表、手环,能用那么小的电池撑好几天。

好了,这一章的内容就到这里。局部刷新和脏矩形算法,是低功耗显示的核心技术。下一章我们会继续深入,看看怎么把DMA和这些刷新策略结合起来,实现真正的“零拷贝”传输。

脏矩形增量刷新流程图 应用层修改UI元素 计算脏矩形区域 合并脏矩形(分块/链表) DMA传输脏块数据 设置窗口,局部刷新屏幕 关键点 • 只传输变化区域 • 脏矩形面积 < 70% 时有效 • 需在VBlank期间刷新 • 双缓冲脏矩形表防冲突