[转载] 乒乓缓冲区(Ping-Pong Buffers)


原文出处: https://embedded.fm/blog/2017/3/21/ping-pong-buffers
作者: Embedded.fm 博客
原文发布日期: 2017 年 3 月 21 日
许可协议: 原文未声明显式开源协议,本文为其中文翻译,所有权利归原文作者所有。原文配图已本地化托管。


当 Andrei 在讲解 DMA 的原理和两个实现示例时,我一直在想:他什么时候会讲到我常用的那种用法——一个慢速外设慢慢「滴」进数据,直到攒够数量再对它跑某个算法。(公允地说,Andrei 也一直在想我什么时候会写出那篇我答应过却又忘了的、关于乒乓缓冲区的文章。)

使用乒乓缓冲区并不是一个很难的概念,它只是一种把内存来回挪动的方式,让系统的不同部分能够各自使用它而互不冲突。见下面的系统架构图。

所讨论系统的架构草图:外设通过 SPI 连到处理器,DMA 负责搬运数据,缓冲区供算法读取。
所讨论系统的架构草图。

假设你有一颗外接的高精度 ADC 挂在 SPI 总线上,以 96kHz 采样——也许是为了做一个音频小设备。ADC 需要它的缓冲区被及时读走以免丢数据,但这个过程很简单:

  1. 处理器收到 DataReady 中断。
  2. 通过 DMA,SPI 发出 ADC 的读命令,并附上足够多的 0xFF 哑字节(dummy bytes)来传输一个样本。

由于是 DataReady 这个 GPIO 中断触发了一次基于 DMA 的 SPI 传输,处理器的主循环对此可以完全无感知。事实上,这个过程可以重复几百次,直到你攒够了足以真正做点事情的样本量:跑一遍信号处理、做压缩、或者存到某种存储器里(比如 SD 卡)。

按这种思路,数据采集的流程就变成了:

  1. 处理器收到 DataReady 中断。
  2. 通过 DMA,SPI 发出 ADC 的读命令,并附上足够多的 0xFF 哑字节来传输一个样本。
  3. 重复步骤 1、2,直到装满 N 个样本的缓冲区,从而触发 DMAComplete 中断。
  4. DMAComplete 中断:通知算法开始运行。
  5. (非中断上下文中)算法在数据上运行。

前四步发生得非常快,且大部分在硬件里完成,所以几乎不占用处理器周期。这给我们留出了更多时间去做最后一步——把原始数据变成可执行的智能信息,这才是有趣的部分。下面来看看这些中断在时间轴上是如何发生的。

时间轴:周期性的 ADC 读取,在攒满 N 个样本(图中 N=8)后触发一次 DMA 完成中断。
时间轴:周期性的 ADC 读取,在攒满 N 个样本(图中 N=8)后触发一次 DMA 完成中断。

想象一个正在被 ADC 样本填满的缓冲区。当 DMAComplete 中断发生、而距离下一个 ADC 样本到来之间只差一个样本时,问题就出现了。一旦下一个 ADC 样本就绪,它就会写进你的缓冲区——也就是你正在上面跑算法的那个缓冲区。你可以把整块数据拷贝出来,但那是在浪费周期。

换个办法,我们可以用乒乓缓冲区(ping-pong buffer)。分配两个大小为 N(算法所需的样本数)的缓冲区。我们姑且把其中一个叫 Ping,另一个叫 Pong。

我们会修改 SPI/DMA 的初始化代码,让它把 SPI 收到的数据存进 Ping 缓冲区。然后,在 DMAComplete 中断里,你要做的事情大致是:

若当前正在使用的缓冲区是 Ping:
    把 DMA 当前使用的缓冲区切换为 Pong
    通知运行时去处理 Ping
否则(当前正在使用的缓冲区是 Pong):
    把 DMA 当前使用的缓冲区切换为 Ping
    通知运行时去处理 Pong

系统就在两个缓冲区之间来回翻转,让你先在一个缓冲区上跑算法,再在另一个上跑,如此往复循环下去。

时间轴:周期性的 ADC 中断,以及两个乒乓缓冲区各自的当前状态(一个正被 DMA 写入,另一个正被处理)。
时间轴:周期性的 ADC 中断,以及两个乒乓缓冲区各自的当前状态。

如果你觉得这很像一个只有 2 个元素的循环缓冲区,那你完全正确。因此,如果你的处理时间比数据采集时间还要长,可以改用(由多个缓冲区组成的)循环缓冲区。这在脑子里稍微有点绕,但画一张你想要的示意图会大有帮助。

注:乒乓缓冲区并不是什么新概念。我已经在音频和其他数据处理中用过好几次。你也常常见到它被用于往显示器送数据,以及处理视频输入——在视频领域它通常被称为双缓冲(double buffering)。