帧缓冲设计:单缓冲与双缓冲机制
帧缓冲,说白了就是一块内存区域。LCD要显示一帧画面,就得从这块内存里读数据。我刚开始做手环项目时,觉得这玩意儿不就是个数组嘛,有啥好设计的?后来踩了坑才明白——缓冲区的设计直接决定了你的显示效果和系统性能。
单缓冲模式
单缓冲最简单。整个LCD显示就靠一块缓冲区。MCU往里面写数据,LCD控制器从里面读数据。听起来挺合理,对吧?
但问题来了。你想想看,MCU在写数据的时候,LCD控制器也在读数据。如果MCU写到一半,LCD控制器正好读到这一块——嗯,画面就会出现撕裂。我在一个低端手环项目里就遇到过,滑动菜单时屏幕中间总有一条横线在闪,排查了半天才发现是单缓冲的读写冲突。
双缓冲机制
双缓冲就优雅多了。两个缓冲区:一个叫前台缓冲区(Front Buffer),专门给LCD控制器读;一个叫后台缓冲区(Back Buffer),给MCU写。MCU在后台画完一帧后,通过一个指针交换操作,把后台变成前台,前台变成后台。
我个人习惯用这种结构:
typedef struct {
uint16_t *front_buf; // 前台缓冲区指针
uint16_t *back_buf; // 后台缓冲区指针
volatile uint8_t swap_pending; // 交换标志
} framebuffer_t;
// 初始化双缓冲
void fb_init(framebuffer_t *fb) {
fb->front_buf = (uint16_t *)FB_ADDR_1;
fb->back_buf = (uint16_t *)FB_ADDR_2;
fb->swap_pending = 0;
}
// 交换缓冲区(在VSync中断中调用)
void fb_swap(framebuffer_t *fb) {
uint16_t *tmp = fb->front_buf;
fb->front_buf = fb->back_buf;
fb->back_buf = tmp;
fb->swap_pending = 0;
}
这里的关键是交换时机。我建议在VSync(垂直同步)中断里做交换。为什么?因为VSync正好是LCD控制器读完一帧、准备读下一帧的时刻。这时候交换,画面不会撕裂。
内存分配策略
手环的RAM通常很紧张。一个128x64像素、16位色的LCD,单缓冲就要16KB。双缓冲直接翻倍到32KB。对于只有64KB RAM的MCU来说,这可不是小数目。
静态分配 vs 动态分配
我强烈建议用静态分配。在编译时就确定缓冲区地址,运行时不做malloc/free。原因很简单:
- 动态分配会产生内存碎片,手环跑几天可能就分配不出连续大块内存了
- 静态分配地址固定,DMA传输时不用操心地址变化
- 调试方便,出问题直接看内存窗口就知道缓冲区在哪
静态分配示例:
// 在链接脚本中预留内存区域
// 假设LCD分辨率为128x64,16位色
#define LCD_WIDTH 128
#define LCD_HEIGHT 64
#define FB_SIZE (LCD_WIDTH * LCD_HEIGHT * 2) // 16KB
// 使用__attribute__指定段
__attribute__((section(".framebuffer")))
uint16_t fb_back[LCD_WIDTH * LCD_HEIGHT];
__attribute__((section(".framebuffer")))
uint16_t fb_front[LCD_WIDTH * LCD_HEIGHT];
内存对齐
DMA传输通常要求缓冲区地址对齐到4字节或8字节。我见过有人没注意对齐,DMA传着传着就卡死了。用__attribute__((aligned(8)))可以解决:
__attribute__((aligned(8)))
uint16_t fb_back[LCD_WIDTH * LCD_HEIGHT];
DMA传输
DMA是帧缓冲的加速器。没有DMA,MCU得一条一条像素地往LCD写数据,CPU占用率直接拉满。有了DMA,MCU只需要配置好传输参数,剩下的活DMA帮你干完。
DMA传输配置
以STM32为例,配置DMA传输帧缓冲数据到LCD:
void dma_fb_transfer(uint16_t *src, uint32_t size) {
// 禁用DMA通道
DMA_Cmd(DMA1_Channel5, DISABLE);
// 配置源地址(帧缓冲区)
DMA_SetCurrDataCounter(DMA1_Channel5, size);
DMA_MemoryTargetConfig(DMA1_Channel5, (uint32_t)src, DMA_MemoryTarget_0);
// 配置目标地址(LCD的GRAM地址)
DMA_MemoryTargetConfig(DMA1_Channel5, (uint32_t)LCD_GRAM_ADDR, DMA_MemoryTarget_1);
// 使能DMA
DMA_Cmd(DMA1_Channel5, ENABLE);
}
// DMA传输完成中断回调
void DMA1_Channel5_IRQHandler(void) {
if(DMA_GetITStatus(DMA1_IT_TC5)) {
DMA_ClearITPendingBit(DMA1_IT_TC5);
// 传输完成,可以准备下一帧了
fb_transfer_complete_flag = 1;
}
}
这里有个细节:DMA传输完成后,记得清中断标志。我刚开始做的时候忘了这步,结果DMA只传输了一次,后面再也不动了——中断标志没清,DMA以为还在传输中。
DMA与双缓冲的配合
双缓冲+DMA,这才是黄金搭档。流程是这样的:
- MCU在后台缓冲区绘制新一帧
- 绘制完成后,设置交换标志
- VSync中断到来,检查交换标志,执行缓冲区交换
- 启动DMA,将新的前台缓冲区数据传输到LCD
- DMA传输完成后,通知MCU可以开始绘制下一帧
这个流程里,DMA传输和MCU绘制是并行的。MCU画后台缓冲区的时候,DMA正在传前台缓冲区。帧率自然就上去了。
DMA传输的坑
嗯,这里要注意几个常见问题:
- 总线仲裁:DMA和CPU共用总线,如果DMA占用了太多带宽,CPU响应会变慢。我建议给DMA分配较低的优先级,或者使用专用的DMA总线(如果有的话)。
- 缓存一致性:如果MCU有Cache,DMA传输的数据可能和Cache里的数据不一致。解决办法是在DMA传输前执行Cache Clean操作,传输后执行Cache Invalidate操作。
- 传输粒度:DMA支持字节、半字、字传输。对于16位色的LCD,用半字传输最合适。用字节传输效率低,用字传输可能浪费带宽。
总结
帧缓冲设计,说白了就是三个问题:用单缓冲还是双缓冲?内存怎么分配?DMA怎么传?
我的建议是:
- 追求简单、RAM紧张、对画面撕裂不敏感 → 单缓冲
- 追求画面流畅、无撕裂 → 双缓冲+VSync同步
- 内存分配 → 静态分配,地址对齐到8字节
- 数据传输 → DMA半字传输,配合双缓冲实现并行处理
这些经验都是我在实际项目中一点点试出来的。你照着做,至少能少走一半弯路。