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开销增加。
我的经验: 在做一个低功耗温控器项目时,我们用了行缓冲+局部刷新,双缓冲从原来的48KB降到了4KB。代价是刷新逻辑复杂了一些,但RAM省了90%以上。

4.1.2 乒乓缓冲的变体:三缓冲与环形缓冲

标准的双缓冲是两块缓冲区交替使用。但在某些场景下,两块不够用——比如DMA传输还没完成,CPU又想写下一帧了。这时候可以考虑:

  • 三缓冲:三块缓冲区轮转。一块给DMA传输,一块给CPU写入,一块空闲等待。适合高帧率显示。
  • 环形缓冲:多块小缓冲区组成环,生产者写、消费者读。适合流式数据(如音频、传感器数据流)。
策略 RAM开销 适用场景 缺点
标准双缓冲 2×帧大小 通用显示 RAM占用高
行缓冲 几行大小 文本/波形 刷新逻辑复杂
三缓冲 3×帧大小 高帧率显示 RAM更高
环形缓冲 N×块大小 流式数据 管理开销

4.1.3 共享内存与零拷贝

在资源受限的环境下,尽量减少数据拷贝次数。我见过一个项目,每次刷新都把数据从应用缓冲拷贝到驱动缓冲,再拷贝到DMA缓冲——三层拷贝,RAM和CPU都浪费了。

更好的做法是:

  • 指针交换:不拷贝数据,只交换缓冲区指针。前提是硬件支持直接访问缓冲区。
  • DMA直接访问应用缓冲:如果DMA控制器支持分散-聚集模式,可以直接从应用缓冲区读取数据。
注意: 指针交换需要保证数据一致性。如果CPU在写缓冲区的同时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的标准流程。

我建议的流程:

  1. CPU写入缓冲区A,同时DMA正在传输缓冲区B
  2. DMA传输完成,触发中断
  3. 中断中:交换A/B角色,设置DMA源地址为刚写完的缓冲区
  4. CPU继续写入新的缓冲区(之前被DMA读走的那个)
  5. 循环往复
关键点: DMA传输完成中断要尽量短。我一般只在中断里做两件事:清标志位、设置下一个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
避坑指南: 如果你用FreeRTOS,还要注意任务切换时Cache上下文是否保存。我曾经在任务切换后忘了刷Cache,结果DMA传输的数据全是乱的。加一个临界区保护,确保刷Cache和启动DMA是原子的。

4.3 嵌入式双缓冲的典型架构

下面这张图展示了我常用的嵌入式双缓冲+DMA架构。它把CPU、DMA、显示外设和缓冲区的关系画清楚了。

嵌入式双缓冲+DMA架构图 嵌入式双缓冲 + DMA 架构 CPU 写入/处理数据 缓冲区 A 缓冲区 B DMA 控制器 显示外设 写入 写入 DMA 读取 DMA 读取 传输 说明: CPU 写入空闲缓冲区,DMA 同时传输另一个缓冲区到显示外设。DMA 传输完成后触发中断,交换缓冲区角色。 典型时序: 1. CPU 写缓冲区 A,DMA 传输缓冲区 B → 显示 2. DMA 传输完成 → 中断 → 切换:DMA 读 A,CPU 写 B 3. 循环往复,实现无撕裂、高效率的显示刷新

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完全不用管切换逻辑。

我的建议: 如果你的MCU支持硬件自动切换,优先用。省心、省代码、还省中断开销。我后来做的项目基本都走这个路线,除非RAM实在不够。