3. 帧缓冲(FrameBuffer)管理:单缓冲与双缓冲机制、显存分配策略、DMA加速刷屏、局部刷新与脏矩形算法

帧缓冲管理,说白了就是解决「画布怎么铺、怎么画、怎么刷到屏幕上」的问题。我早年做第一个GUI移植时,觉得这玩意儿不就是个内存块嘛,结果被撕裂、闪烁、刷屏慢折磨得够呛。今天咱们把这几个核心机制掰开揉碎讲清楚。

3.1 单缓冲与双缓冲机制

先看最基础的概念——缓冲模式。你想想看,MCU往显存里写像素数据,LCD控制器同时从显存读数据刷新屏幕,这俩操作如果共用一块内存,冲突就来了。

3.1.1 单缓冲(Single Buffer)

系统只有一块显存。CPU写数据时,LCD控制器可能正在读同一块内存。结果就是:屏幕上出现「撕裂」——上半部分是新画面,下半部分还是旧的。

适用场景:低分辨率、静态界面、对显示质量要求不高的产品。

典型问题:画面撕裂、闪烁(尤其全屏更新时)。

我在一个温控器项目里用过单缓冲。屏幕只有128×64,刷新率低,撕裂不明显。但换到320×240的彩屏后,肉眼可见的撕裂线,客户直接退货。嗯,从那以后我再也不敢在彩屏上用单缓冲了。

3.1.2 双缓冲(Double Buffer)

分配两块显存:前端缓冲(Front Buffer)和后端缓冲(Back Buffer)。LCD控制器只读前端缓冲,CPU只写后端缓冲。画完后,通过一个「交换」操作(Flip/Swap)把前后端角色互换。

特性 单缓冲 双缓冲
内存占用 1×显存大小 2×显存大小
画面撕裂 有 无(垂直同步配合)
实现复杂度 低 中
适用场景 低端、静态 动画、视频、交互频繁

我的习惯:只要RAM够用,一律上双缓冲。多花几十KB内存,换来的是用户肉眼可见的流畅体验,这笔账怎么算都划算。

3.2 显存分配策略

显存怎么分?不是简单malloc一块内存就完事。嵌入式系统里,显存位置、对齐方式、连续性问题,直接影响刷屏性能。

3.2.1 分配位置

  • 内部SRAM:速度快,但容量小。适合小尺寸屏幕(< 320×240)。
  • 外部SDRAM:容量大,但速度慢。适合大尺寸屏幕,但要注意DMA传输效率。
  • LTDC/DSI专用内存:部分MCU有专用显存接口,直接连到LCD控制器,效率最高。

3.2.2 对齐要求

很多LCD控制器要求显存地址按4字节、8字节甚至16字节对齐。我踩过一个坑:用malloc分配显存,地址没对齐,结果刷屏时颜色错位,查了两天才发现是地址问题。

// 正确做法:使用对齐分配
// 以STM32为例,使用DMA2D时要求32字节对齐
#define FRAMEBUFFER_ALIGN  32

static uint8_t fb_back[FRAMEBUFFER_SIZE] __attribute__((aligned(FRAMEBUFFER_ALIGN)));
static uint8_t fb_front[FRAMEBUFFER_SIZE] __attribute__((aligned(FRAMEBUFFER_ALIGN)));

注意:不要用动态分配(malloc)管理显存。显存需要长期占用、地址固定、对齐可控。静态数组或内存池才是正解。

3.3 DMA加速刷屏

CPU逐像素拷贝显存?太慢了。DMA(直接存储器访问)可以解放CPU,让数据搬运在后台完成。

3.3.1 为什么需要DMA

假设屏幕分辨率800×480,颜色深度16位(RGB565)。一帧数据量:800×480×2 = 768,000字节。CPU主频200MHz,逐字节拷贝大约需要几毫秒。如果加上颜色转换、alpha混合,CPU占用率直接拉满。

DMA可以做到:CPU发个启动命令,然后去干别的,DMA自己把数据搬完再触发中断。

3.3.2 常用DMA引擎

DMA类型 特点 典型MCU
普通DMA 内存→内存,逐字节/字拷贝 STM32F4/F7 DMA2
DMA2D(Chrom-ART) 支持颜色格式转换、alpha混合、图像旋转 STM32F7/H7
PXP(Pixel Pipeline) 类似DMA2D,支持缩放、旋转、颜色空间转换 NXP i.MX RT系列

我的经验:DMA2D是STM32上刷屏的神器。一次DMA2D传输可以同时完成「从后端缓冲到前端缓冲的拷贝」+「RGB565转RGB888」+「alpha混合」。CPU只需要设置寄存器,然后等中断。

// DMA2D 刷屏示例(伪代码)
void dma2d_copy_fb(void *src, void *dst, uint32_t size) {
    // 等待DMA2D空闲
    while (DMA2D->CR & DMA2D_CR_START);
    
    // 配置源地址、目标地址、传输大小
    DMA2D->FGMAR = (uint32_t)src;    // 源地址
    DMA2D->OMAR  = (uint32_t)dst;    // 目标地址
    DMA2D->NLR   = (height << 16) | width;  // 行列数
    DMA2D->FGPFCCR = DMA2D_FGPFCCR_CM_RGB565; // 源格式
    DMA2D->OPFCCR  = DMA2D_OPFCCR_CM_RGB565;  // 目标格式
    
    // 启动传输
    DMA2D->CR |= DMA2D_CR_START;
    
    // 可以去做其他事了,传输完成中断会通知
}

3.4 局部刷新与脏矩形算法

全屏刷新?那是杀鸡用牛刀。大多数GUI更新只涉及屏幕的一小部分。比如按钮按下、滑块拖动、文本更新。局部刷新可以大幅降低刷屏带宽和CPU占用。

3.4.1 脏矩形(Dirty Rectangle)原理

核心思想:只更新「发生变化」的区域。GUI框架维护一个或多个矩形区域(脏矩形),每次绘制操作后,把受影响区域标记为「脏」。刷屏时,只把这些脏矩形对应的显存区域拷贝到前端缓冲。

脏矩形算法流程 屏幕(800×480) 脏矩形1 按钮按下 脏矩形2 滑块拖动 脏矩形3 文本更新 只更新这些脏矩形对应的显存区域,其余部分不动

3.4.2 脏矩形合并策略

多个脏矩形如果靠得太近,合并成一个大的矩形反而更高效。为什么?因为DMA传输有启动开销,传输一个大块比传输多个小块更快。

  • 不合并:每个控件独立刷新,适合控件分散、更新频率低的情况。
  • 合并:将相邻或重叠的脏矩形合并成最小外接矩形,适合密集更新场景。
  • 全屏回退:如果脏矩形面积超过屏幕的70%,干脆全屏刷新,省去合并计算开销。

我建议:在低端MCU上,优先用「不合并+单脏矩形」策略。每次只更新一个矩形区域,实现简单,内存开销小。等性能有富余了再上合并算法。

3.4.3 脏矩形实现要点

// 脏矩形结构体
typedef struct {
    int16_t x, y;      // 左上角坐标
    uint16_t w, h;     // 宽度和高度
} dirty_rect_t;

// 脏矩形管理器
typedef struct {
    dirty_rect_t rects[MAX_DIRTY_RECTS];
    uint8_t count;
} dirty_manager_t;

// 标记脏区域
void mark_dirty(dirty_manager_t *mgr, int16_t x, int16_t y, uint16_t w, uint16_t h) {
    if (mgr->count >= MAX_DIRTY_RECTS) {
        // 溢出:标记全屏脏
        mgr->rects[0] = (dirty_rect_t){0, 0, SCREEN_WIDTH, SCREEN_HEIGHT};
        mgr->count = 1;
        return;
    }
    mgr->rects[mgr->count++] = (dirty_rect_t){x, y, w, h};
}

// 执行局部刷新
void flush_dirty(dirty_manager_t *mgr, uint8_t *fb_back, uint8_t *fb_front) {
    for (int i = 0; i < mgr->count; i++) {
        dirty_rect_t *r = &mgr->rects[i];
        // 计算显存偏移
        uint32_t offset = (r->y * SCREEN_WIDTH + r->x) * BYTES_PER_PIXEL;
        uint32_t line_bytes = r->w * BYTES_PER_PIXEL;
        
        // 逐行拷贝(或用DMA2D)
        for (int row = 0; row < r->h; row++) {
            memcpy(fb_front + offset + row * SCREEN_WIDTH * BYTES_PER_PIXEL,
                   fb_back  + offset + row * SCREEN_WIDTH * BYTES_PER_PIXEL,
                   line_bytes);
        }
    }
    mgr->count = 0;  // 清空脏矩形列表
}

我曾经踩过的坑:脏矩形坐标计算时,忘了考虑控件边框和阴影的扩展区域。结果按钮按下后,阴影部分没刷新,屏幕上留下一块「鬼影」。后来我统一在脏矩形周围加2-4像素的padding,问题解决。

3.5 综合策略建议

不同硬件配置,帧缓冲管理策略完全不同。我整理了一张决策表,供你参考:

硬件条件 缓冲模式 刷屏方式 刷新策略
RAM < 64KB,屏幕 < 320×240 单缓冲 CPU逐行拷贝 全屏刷新
RAM 64KB~256KB,屏幕 320×240 双缓冲 DMA内存拷贝 脏矩形(不合并)
RAM > 256KB,屏幕 800×480 双缓冲 DMA2D/PXP硬件加速 脏矩形(合并优化)
带专用显存控制器 三缓冲(可选) 硬件自动刷新 脏矩形+垂直同步

帧缓冲管理没有银弹。你得根据硬件资源、屏幕尺寸、交互复杂度来权衡。我个人习惯是:先上双缓冲+脏矩形,这是性价比最高的组合。如果内存紧张,再考虑降级到单缓冲,但一定要做好撕裂补偿(比如只在垂直消隐期更新)。