4、嵌入式环境适配:资源受限下的缓冲策略、DMA传输优化
双缓冲在PC上跑得挺欢,一搬到嵌入式平台,问题就来了。说白了,MCU不是Cortex-A处理器,没有MMU,没有大容量DDR,甚至可能连Cache都没有。我早年在一个STM32F103项目上硬搬PC上的双缓冲思路,结果RAM直接爆了——那会儿才20KB的SRAM,一个VGA分辨率帧缓冲都放不下。
所以这一章,咱们聊聊怎么在资源受限的环境下把双缓冲用起来。核心就两件事:怎么省内存,怎么让DMA帮你干活。
4.1 资源受限下的缓冲策略
嵌入式系统的RAM通常以KB计,而不是GB。双缓冲意味着至少两份缓冲区,这本身就是一种奢侈。怎么办?
4.1.1 缓冲区裁剪:按需分配
不是所有场景都需要全尺寸缓冲区。举个例子,一个128×64的单色OLED屏,全屏缓冲只需要1KB。但如果换成320×240的TFT彩屏,16位色深下,一个帧缓冲就要150KB。两个就是300KB——很多MCU总共才128KB RAM。
我个人习惯的做法是:
- 行缓冲模式:只缓存当前正在刷新的几行数据,而不是整帧。适合滚动文本或简单波形显示。
- 局部刷新策略:只更新变化区域。比如仪表盘上只有数字在变,背景不变,那就只缓冲变化区域。
- 压缩缓冲:用RLE(游程编码)或LZSS压缩缓冲区内容,解压后直接送显。代价是CPU开销增加。
4.1.2 乒乓缓冲的变体:三缓冲与环形缓冲
标准的双缓冲是两块缓冲区交替使用。但在某些场景下,两块不够用——比如DMA传输还没完成,CPU又想写下一帧了。这时候可以考虑:
- 三缓冲:三块缓冲区轮转。一块给DMA传输,一块给CPU写入,一块空闲等待。适合高帧率显示。
- 环形缓冲:多块小缓冲区组成环,生产者写、消费者读。适合流式数据(如音频、传感器数据流)。
| 策略 | RAM开销 | 适用场景 | 缺点 |
|---|---|---|---|
| 标准双缓冲 | 2×帧大小 | 通用显示 | RAM占用高 |
| 行缓冲 | 几行大小 | 文本/波形 | 刷新逻辑复杂 |
| 三缓冲 | 3×帧大小 | 高帧率显示 | RAM更高 |
| 环形缓冲 | N×块大小 | 流式数据 | 管理开销 |
4.1.3 共享内存与零拷贝
在资源受限的环境下,尽量减少数据拷贝次数。我见过一个项目,每次刷新都把数据从应用缓冲拷贝到驱动缓冲,再拷贝到DMA缓冲——三层拷贝,RAM和CPU都浪费了。
更好的做法是:
- 指针交换:不拷贝数据,只交换缓冲区指针。前提是硬件支持直接访问缓冲区。
- DMA直接访问应用缓冲:如果DMA控制器支持分散-聚集模式,可以直接从应用缓冲区读取数据。
4.2 DMA传输优化
DMA(直接存储器访问)是嵌入式双缓冲的救星。没有DMA,CPU得亲自搬数据,帧率上不去,CPU还被占得死死的。有了DMA,CPU可以干别的事,数据搬运交给硬件。
4.2.1 DMA的基本配置
以STM32为例,配置DMA传输帧缓冲通常需要设置:
- 源地址:缓冲区A或B的起始地址
- 目标地址:外设数据寄存器(如SPI_DR、LTDC_DR)
- 传输长度:一帧的字节数
- 传输模式:循环模式(自动重载)或单次模式
- 数据宽度:8位、16位或32位
// STM32 HAL库示例:配置DMA传输帧缓冲
DMA_HandleTypeDef hdma;
hdma.Instance = DMA2_Stream0;
hdma.Init.Channel = DMA_CHANNEL_0;
hdma.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变
hdma.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
hdma.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma.Init.Mode = DMA_NORMAL; // 单次传输,非循环
hdma.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma);
// 启动传输:从bufferA到SPI数据寄存器
HAL_DMA_Start(&hdma, (uint32_t)bufferA, (uint32_t)&SPI1->DR, 320*240);
4.2.2 双缓冲与DMA的联动
DMA传输完成时会产生中断。在中断服务函数中,切换缓冲区指针,然后重新启动DMA。这就是双缓冲+DMA的标准流程。
我建议的流程:
- CPU写入缓冲区A,同时DMA正在传输缓冲区B
- DMA传输完成,触发中断
- 中断中:交换A/B角色,设置DMA源地址为刚写完的缓冲区
- CPU继续写入新的缓冲区(之前被DMA读走的那个)
- 循环往复
4.2.3 链表DMA与多缓冲
有些DMA控制器支持链表模式(如STM32的DMA2D、NXP的eDMA)。你可以预先配置好多个DMA传输描述符,每个描述符指向不同的缓冲区。DMA自动按链表顺序执行传输,完成后触发一次中断。
这样做的好处:
- 减少中断次数:N个缓冲区只需要一次中断
- CPU几乎零干预:DMA自己管理传输序列
- 适合多图层叠加或分块刷新
// 链表DMA描述符示例(伪代码)
DMA_Descriptor desc[3];
desc[0].src = bufferA;
desc[0].dst = LCD_ADDR;
desc[0].len = 320*240*2;
desc[0].next = &desc[1];
desc[1].src = bufferB;
desc[1].dst = LCD_ADDR;
desc[1].len = 320*240*2;
desc[1].next = &desc[2];
desc[2].src = bufferC;
desc[2].dst = LCD_ADDR;
desc[2].len = 320*240*2;
desc[2].next = NULL; // 链表结束
// 启动链表DMA
DMA_StartLinkedList(&desc[0]);
4.2.4 缓存一致性:一个容易被忽略的坑
如果你的MCU带Cache(比如Cortex-M7),DMA和Cache之间会有一致性问题。DMA直接访问内存,但CPU可能还在Cache里读写数据。结果就是:DMA读到的数据是旧的,或者CPU写的数据没被DMA看到。
我曾经在一个STM32H743项目上调试了三天,才发现是Cache没刷。画面一直闪烁,因为DMA读的是Cache里的脏数据。
解决办法:
- 使用非Cacheable内存区域:将缓冲区放在MPU配置为Non-Cacheable的地址段
- 手动刷Cache:在DMA传输前调用SCB_CleanDCache(),传输后调用SCB_InvalidateDCache()
- 使用DMA专用的内存:有些MCU有DTCM或SRAM,默认不经过Cache
4.3 嵌入式双缓冲的典型架构
下面这张图展示了我常用的嵌入式双缓冲+DMA架构。它把CPU、DMA、显示外设和缓冲区的关系画清楚了。
4.4 实际项目中的优化技巧
最后分享几个我在实际项目中用过的优化技巧,希望能帮你少走弯路。
4.4.1 利用空闲时间预填充
DMA传输期间CPU是空闲的。别闲着,提前计算下一帧的数据。比如在GUI系统中,DMA在刷当前帧时,CPU已经在渲染下一帧了。这样帧率可以翻倍。
4.4.2 分块传输减少延迟
如果一帧数据太大,DMA传输时间会很长,导致CPU等待时间增加。可以把一帧分成多个小块,每传输完一块就触发一次中断,CPU可以提前开始处理下一块的数据。
我曾在480×272的屏幕上用分块传输,每块64行。这样DMA延迟从原来的16ms降到了2ms,CPU响应更及时。
4.4.3 使用硬件自动切换
有些高级MCU(如STM32F7/H7系列)的LTDC外设支持自动双缓冲切换。你只需要设置两个缓冲区地址,硬件会在VBlank期间自动切换。CPU完全不用管切换逻辑。