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传输的地址和长度必须和窗口大小严格匹配,否则画面会错位。
4.2 脏矩形算法:增量刷新的核心
局部刷新解决了“刷哪里”的问题。但还有一个问题没解决:我怎么知道哪些区域变了?
这就是脏矩形算法登场的时候了。
4.2.1 什么是脏矩形?
脏矩形,说白了就是“画面中发生变化的最小矩形区域”。比如你按下一个按钮,按钮本身变了,但按钮周围的背景没变。那么脏矩形就是按钮的边界框。
算法流程大致是这样的:
- 应用层修改某个UI元素(比如更新文本、移动图标)。
- UI框架计算出该元素的新旧边界,合并成一个脏矩形。
- 将脏矩形提交给刷新管理器。
- 刷新管理器合并多个脏矩形(如果有多个变化)。
- 最终将合并后的脏矩形区域,通过DMA传输到显存。
- 驱动层设置窗口,只刷新该区域。
你看,整个过程只处理变化的部分,没变的地方纹丝不动。
4.2.2 脏矩形合并策略
实际项目中,一帧内可能有多个UI元素同时变化。比如一个进度条在动,同时右上角的电量图标也在更新。这时候你会得到两个脏矩形。
合并策略一般有两种:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 直接合并 | 取所有脏矩形的最小外接矩形 | 实现简单,一次DMA传输搞定 | 可能包含大量未变化区域,浪费带宽 |
| 分块合并 | 将屏幕分成固定大小的块(如16×16),标记哪些块脏了 | 减少无效刷新区域 | 需要维护块位图,实现稍复杂 |
| 链表合并 | 维护一个脏矩形链表,相邻或重叠的合并 | 精确控制刷新区域 | 链表操作有开销,适合变化不频繁的场景 |
我个人比较推荐分块合并。它在实现复杂度和刷新效率之间取得了不错的平衡。你想想看,把屏幕切成16×16的小块,每个块用一个bit标记是否脏。一帧下来,你只需要遍历这些bit,找到所有脏块,然后逐个刷新。
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传输完成后,交换两个表的指针。这样读写就不会冲突了。
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和这些刷新策略结合起来,实现真正的“零拷贝”传输。