帧缓冲设计:单缓冲与双缓冲机制

帧缓冲,说白了就是一块内存区域。LCD要显示一帧画面,就得从这块内存里读数据。我刚开始做手环项目时,觉得这玩意儿不就是个数组嘛,有啥好设计的?后来踩了坑才明白——缓冲区的设计直接决定了你的显示效果和系统性能。

单缓冲模式

单缓冲最简单。整个LCD显示就靠一块缓冲区。MCU往里面写数据,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控制器读完一帧、准备读下一帧的时刻。这时候交换,画面不会撕裂。

经验之谈:我曾经在一个项目里忘了加swap_pending标志,结果MCU画到一半,VSync中断来了,强行交换缓冲区——画面直接花掉。加个标志位,等MCU画完再允许交换,这坑就算填上了。

内存分配策略

手环的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,这才是黄金搭档。流程是这样的:

  1. MCU在后台缓冲区绘制新一帧
  2. 绘制完成后,设置交换标志
  3. VSync中断到来,检查交换标志,执行缓冲区交换
  4. 启动DMA,将新的前台缓冲区数据传输到LCD
  5. DMA传输完成后,通知MCU可以开始绘制下一帧

这个流程里,DMA传输和MCU绘制是并行的。MCU画后台缓冲区的时候,DMA正在传前台缓冲区。帧率自然就上去了。

优化技巧:如果LCD支持,可以配置DMA为循环模式。这样DMA会持续不断地从缓冲区读取数据发送到LCD,MCU只需要在后台更新缓冲区内容即可。帧率可以稳定在60fps以上。

DMA传输的坑

嗯,这里要注意几个常见问题:

  • 总线仲裁:DMA和CPU共用总线,如果DMA占用了太多带宽,CPU响应会变慢。我建议给DMA分配较低的优先级,或者使用专用的DMA总线(如果有的话)。
  • 缓存一致性:如果MCU有Cache,DMA传输的数据可能和Cache里的数据不一致。解决办法是在DMA传输前执行Cache Clean操作,传输后执行Cache Invalidate操作。
  • 传输粒度:DMA支持字节、半字、字传输。对于16位色的LCD,用半字传输最合适。用字节传输效率低,用字传输可能浪费带宽。
避坑指南:我曾经在一个项目里,DMA传输帧缓冲时用了字节模式,结果传输速度只有理论值的一半。改成半字模式后,帧率直接从30fps提升到了55fps。传输粒度的选择,直接影响性能。

总结

帧缓冲设计,说白了就是三个问题:用单缓冲还是双缓冲?内存怎么分配?DMA怎么传?

我的建议是:

  • 追求简单、RAM紧张、对画面撕裂不敏感 → 单缓冲
  • 追求画面流畅、无撕裂 → 双缓冲+VSync同步
  • 内存分配 → 静态分配,地址对齐到8字节
  • 数据传输 → DMA半字传输,配合双缓冲实现并行处理

这些经验都是我在实际项目中一点点试出来的。你照着做,至少能少走一半弯路。