3、帧缓冲(FrameBuffer):帧缓冲的概念、多缓冲机制、缓冲切换策略

说到图形加速,帧缓冲是个绕不开的基础概念。我刚开始接触嵌入式图形时,总觉得帧缓冲就是个“存像素的地方”,后来踩过几次坑才明白——它远不止这么简单。

说白了,帧缓冲就是一块专门的内存区域,用来存放一帧完整的图像数据。MCU 的显示控制器会以固定频率(比如 60Hz)从这块内存里读取数据,然后通过 LTDC 或 RGB 接口推送到屏幕。你看到的每一帧画面,本质上都是帧缓冲里那堆像素点的“快照”。

3.1 单缓冲的问题

最简单的方案就是单缓冲——整个系统只有一块帧缓冲。MCU 往里面画图,显示控制器同时从里面读数据往外送。

听起来挺合理?但实际跑起来问题就来了。

撕裂(Tearing):当 MCU 正在更新帧缓冲的某个区域,而显示控制器刚好读到那个区域时,屏幕上就会出现一条明显的“撕裂线”——上半部分是旧帧,下半部分是新帧。这在快速滚动的 UI 中尤其明显,用户体验极差。

我在一个车载仪表盘项目里就吃过这个亏。当时用单缓冲跑一个转速表指针动画,指针每转一圈,屏幕中间就闪一道横线。客户看了直接摇头。嗯,从那以后我再也不敢在动画场景里用单缓冲了。

3.2 双缓冲机制

解决撕裂的标准方案就是双缓冲。系统维护两块帧缓冲:

  • 前端缓冲(Front Buffer):显示控制器正在读取并送往屏幕的那一帧。只读,不写。
  • 后端缓冲(Back Buffer):MCU 正在绘制新内容的那一帧。只写,不读。

当后端缓冲绘制完成后,通过一次“交换”操作,让前端缓冲变成后端,后端变成前端。这样显示控制器永远只读完整的一帧,撕裂自然就消失了。

关键点:双缓冲的核心不是“有两块内存”,而是“读写分离”——任何时候都只有一方在访问某块缓冲,不会出现同时读写的情况。

3.3 缓冲切换策略

双缓冲有了,怎么切换?这里就有讲究了。我见过不少新手直接改指针地址,结果画面闪得更厉害。

3.3.1 指针交换法

这是 TouchGFX 默认的策略。系统维护两个指针:pFront 和 pBack。切换时只需要交换指针值,不需要拷贝任何像素数据。

// 伪代码示意
void swap_buffers() {
    uint16_t* temp = pFront;
    pFront = pBack;
    pBack  = temp;
    
    // 告诉显示控制器新的前端缓冲地址
    LTDC->CFBAR = (uint32_t)pFront;
}

这个操作是 O(1) 的,几乎不花时间。但有个前提——显示控制器必须在 VBlank(垂直消隐) 期间更新地址,否则还是会出现撕裂。

3.3.2 拷贝切换法

有些硬件不支持动态修改帧缓冲地址,那就只能用拷贝法:后端绘制完成后,把整帧数据拷贝到前端缓冲。

你想想看,如果分辨率是 480x272,颜色深度 16bit,一帧就是 480×272×2 ≈ 261KB。用 DMA 拷贝大概要花几毫秒。对于 60fps 来说,16ms 一帧,这几毫秒还能接受。但如果是 1024x600 甚至更高分辨率,拷贝开销就会明显拖累帧率。

我的建议:只要硬件支持指针交换,就别用拷贝法。我在一个 800x480 的项目里对比过,指针交换比拷贝快了将近 20 倍,而且没有额外的内存带宽消耗。

3.3.3 等待 VBlank

无论用哪种切换策略,都必须和 VBlank 同步。VBlank 是显示控制器扫完一帧后、开始扫下一帧前的短暂间隙。在这个间隙里切换缓冲,用户是感知不到的。

TouchGFX 的 OSWrappers::waitForVSync() 就是干这个的。它会阻塞当前任务,直到 VBlank 信号到来。

我曾经踩过的坑:在一个 RTOS 项目里,我把 VBlank 等待放在了高优先级中断里,结果导致低优先级的绘制任务迟迟得不到调度,帧率直接掉到 30fps。后来改成在绘制任务里主动等待 VBlank 信号量,才恢复正常。记住:VBlank 等待应该在绘制线程里做,而不是在中断里。

3.4 三缓冲机制

双缓冲解决了撕裂,但引入了一个新问题:帧率受限。如果绘制一帧的时间刚好超过 16ms(60fps 的周期),那系统就不得不等到下一个 VBlank 才能开始绘制新帧,帧率直接腰斩到 30fps。

三缓冲就是为这个场景准备的。系统维护三块帧缓冲:

缓冲 角色 谁在访问
Buffer 0 前端缓冲(正在显示) 显示控制器(只读)
Buffer 1 就绪缓冲(已完成绘制) 等待切换(无人访问)
Buffer 2 后端缓冲(正在绘制) MCU(只写)

三缓冲的好处是:即使绘制时间偶尔超过 16ms,系统仍然可以在下一个 VBlank 切换到“就绪缓冲”,保持 60fps 输出。绘制任务可以“超前”绘制,把空闲帧存起来。

代价也很明显——多占一块帧缓冲的内存。对于资源紧张的 MCU 来说,这可能是奢侈的。我个人习惯是:只有 UI 中有复杂动画或频繁重绘的场景才用三缓冲,静态页面双缓冲完全够用。

3.5 TouchGFX 中的缓冲配置

在 TouchGFX 中,缓冲配置是在 TouchGFXConfiguration.cpp 里完成的。关键参数就两个:

  • frame_buffer_count:缓冲数量(1、2 或 3)
  • frame_buffer_stride:每行像素的字节数(通常等于 宽度 × 每像素字节数)
// TouchGFX 4.x 典型配置
static TouchGFXHAL hal(display, dma, touch, 480, 272, 
                       reinterpret_cast<uint16_t*>(0xC0000000),  // 帧缓冲基地址
                       frame_buffer_stride,                     // 步长
                       frame_buffer_count);                     // 缓冲数量

这里有个细节:frame_buffer_stride 不一定等于 width * bytes_per_pixel。有些硬件要求每行对齐到 4 字节或 8 字节,如果宽度不对齐,就需要填充。我遇到过一块 STM32F769 的板子,LTDC 要求每行 32 字节对齐,480×2=960 刚好不对齐,必须补到 992 字节。当时查了好几天才找到原因。

3.6 缓冲策略的选择建议

说了这么多,到底怎么选?我整理了一个简单的对照表:

场景 推荐策略 理由
静态页面、设置菜单 单缓冲 没有动画,不会撕裂,省内存
简单动画、列表滚动 双缓冲 + 指针交换 消除撕裂,开销极小
复杂动画、视频播放 三缓冲 防止绘制超时导致掉帧
硬件不支持指针交换 双缓冲 + DMA 拷贝 兼容性优先,性能次之
一句话总结:帧缓冲的核心是“读写分离”,多缓冲的核心是“用空间换时间”,切换策略的核心是“与 VBlank 同步”。把这三点吃透了,TouchGFX 的图形加速原理你就掌握了三分之一。
帧缓冲工作机制 显示控制器 前端缓冲 (正在显示,只读) 后端缓冲 (正在绘制,只写) 就绪缓冲 (已完成,等待切换) MCU / GPU 读取像素 绘制新帧 VBlank 时切换 备用切换路径 关键原则 • 读写分离 • 与 VBlank 同步 • 指针交换优于拷贝 内存占用(480×272) 单缓冲:261 KB 双缓冲:522 KB 三缓冲:783 KB

这张图展示了帧缓冲的核心工作流。注意看箭头方向——显示控制器只读前端缓冲,MCU 只写后端缓冲,两者互不干扰。三缓冲时多了一个“就绪缓冲”作为缓冲池,让绘制任务可以提前准备下一帧。