3、音频DMA引擎:DMA控制器配置、音频DMA传输模式、环形缓冲区管理

音频数据流处理,说白了就是一场与时间的赛跑。CPU 如果亲自去搬运每一个音频采样点,那基本上就别干别的了。所以,DMA 引擎就是那个帮你干苦力活的「搬运工」。我刚开始接触 Zephyr 音频子系统时,最头疼的就是 DMA 配置。配置不对,声音要么断断续续,要么直接没声。

这一节,我们就来聊聊 DMA 控制器怎么配、音频传输有哪些模式、以及环形缓冲区这个经典数据结构怎么玩。嗯,这些都是实战中绕不开的硬骨头。

3.1 DMA 控制器配置

在 Zephyr 里,DMA 控制器是通过设备树(Device Tree)来描述的。我个人习惯是先看芯片手册,再对着设备树改。你想想看,如果连硬件支持几个 DMA 通道都不清楚,代码写得再漂亮也是白搭。

先看一个典型的 DMA 配置示例:

// 在设备树中定义 DMA 控制器
dma0: dma@40020000 {
    compatible = "st,stm32-dma";
    reg = <0x40020000 0x400>;
    interrupts = <11 0>, <12 0>, <13 0>, <14 0>,
                 <15 0>, <16 0>, <17 0>, <18 0>;
    dma-channels = <8>;
    #dma-cells = <2>;
    status = "okay";
};

// 音频外设引用 DMA
&i2s1 {
    dmas = <&dma0 3 0x2040>,  // 发送通道
           <&dma0 4 0x2040>;  // 接收通道
    dma-names = "tx", "rx";
    status = "okay";
};

这里有个坑,我踩过好几次。dma-cells 里的第二个参数,不同芯片厂商定义完全不同。ST 的芯片用 16 位编码表示优先级、方向、数据宽度等。NXP 的 i.MX 系列又是另一套规则。所以,一定要看对应 SoC 的 binding 文档

注意: 配置 DMA 通道时,务必确认该通道没有被其他外设占用。我曾经在调试 I2S 录音时,发现 DMA 传输一直超时,查了半天才发现是 UART 偷偷占用了同一个 DMA 通道。

在代码层面,初始化 DMA 的流程大致如下:

#include <zephyr/drivers/dma.h>

const struct device *dma_dev;
struct dma_config dma_cfg = {0};

void audio_dma_init(void)
{
    dma_dev = DEVICE_DT_GET(DT_NODELABEL(dma0));
    if (!device_is_ready(dma_dev)) {
        printk("DMA device not ready\n");
        return;
    }

    // 配置 DMA 传输参数
    dma_cfg.channel_direction = MEMORY_TO_PERIPHERAL;
    dma_cfg.source_data_size = 2;  // 16-bit 音频数据
    dma_cfg.dest_data_size = 2;
    dma_cfg.source_burst_length = 4;  // 每次突发传输 4 个采样点
    dma_cfg.dest_burst_length = 4;
    dma_cfg.block_count = 1;
    dma_cfg.user_data = NULL;

    // 配置完成后,启动 DMA
    dma_config(dma_dev, 3, &dma_cfg);  // 通道 3 用于 I2S TX
}

我个人习惯把 burst_length 设成 4 或 8。设得太小,DMA 频繁中断,CPU 负担重;设得太大,延迟会变高。对于 48kHz 的音频,4 个采样点也就 83 微秒的延迟,完全可以接受。

3.2 音频 DMA 传输模式

音频 DMA 传输模式,说白了就两种:单次传输和循环传输。但在实际项目中,我们几乎只用循环传输。为什么?因为音频流是连续的,你不可能每播完一段就停下来重新配置一次。

Zephyr 的 DMA API 支持两种模式:

模式 描述 适用场景
单次传输 传输完指定数据量后停止 短音频提示音、按键音
循环传输 传输完成后自动重新开始 音乐播放、实时语音

配置循环传输时,关键是要设置好回调函数。DMA 每完成一个 block 的传输,就会触发一次中断,回调函数里你就可以填充新的数据或者处理已接收的数据。

static void dma_callback(const struct device *dma_dev,
                         void *user_data,
                         uint32_t channel,
                         int status)
{
    if (status < 0) {
        printk("DMA transfer error: %d\n", status);
        return;
    }

    // 这里处理音频数据
    // 比如:从环形缓冲区读取新数据填充到 DMA 源地址
    audio_buffer_refill();
}

void audio_dma_start_cyclic(void)
{
    struct dma_block_config block_cfg = {0};

    // 配置循环传输
    dma_cfg.complete_callback_en = 1;
    dma_cfg.error_callback_en = 1;
    dma_cfg.user_data = NULL;

    // 设置 block 参数
    block_cfg.source_address = (uint32_t)audio_tx_buffer;
    block_cfg.dest_address = (uint32_t)&I2S->DR;  // 外设地址
    block_cfg.block_size = AUDIO_BLOCK_SIZE;      // 每次传输的字节数

    dma_cfg.head_block = &block_cfg;

    // 启动循环传输
    dma_config(dma_dev, 3, &dma_cfg);
    dma_start(dma_dev, 3);
}
小技巧: 在回调函数里不要做耗时操作。我曾经在回调里直接调用 printk 打印调试信息,结果导致 DMA 传输卡顿,声音出现爆音。正确的做法是:回调里只设置一个标志位,然后在主循环或另一个线程里处理数据。

3.3 环形缓冲区管理

环形缓冲区,也叫循环缓冲区,是音频处理中最基础的数据结构。它的核心思想是:用一块固定大小的内存,通过两个指针(读指针和写指针)来管理数据,写满了就从头开始覆盖。

我刚开始做音频项目时,自己手写了一个环形缓冲区,结果各种边界条件没处理好,不是读到了脏数据,就是写指针追上了读指针。后来我直接用 Zephyr 提供的 ring_buffer 模块,省心多了。

Zephyr 的环形缓冲区 API 使用起来很简单:

#include <zephyr/sys/ring_buffer.h>

#define AUDIO_RING_BUF_SIZE  4096  // 4KB 缓冲区

uint8_t ring_buf_data[AUDIO_RING_BUF_SIZE];
struct ring_buf audio_ring;

void audio_ring_init(void)
{
    ring_buf_init(&audio_ring, sizeof(ring_buf_data), ring_buf_data);
}

// 生产者(比如 DMA 回调)往缓冲区写数据
int audio_ring_put(uint8_t *data, size_t len)
{
    return ring_buf_put(&audio_ring, data, len);
}

// 消费者(比如音频处理线程)从缓冲区读数据
int audio_ring_get(uint8_t *data, size_t len)
{
    return ring_buf_get(&audio_ring, data, len);
}

这里有个关键点:环形缓冲区的大小必须是 2 的幂次方。Zephyr 的 ring_buffer 实现内部用了位运算来取模,如果不是 2 的幂次方,性能会下降,而且可能出现 bug。我建议设成 4096 或 8192,对于 48kHz 16-bit 双声道音频,4096 字节可以存约 64 毫秒的数据,足够应付大部分场景。

核心要点: 环形缓冲区的大小要能覆盖 DMA 传输的延迟。如果 DMA 每 1 毫秒传输一次数据,缓冲区至少要能存 2-3 次传输的数据量,防止出现「欠载」(underflow)或「溢出」(overflow)。

实际项目中,我通常会用双缓冲区的思路来配合环形缓冲区。DMA 在传输一块数据时,CPU 在处理另一块数据。这样流水线作业,效率最高。

// 双缓冲区 + 环形缓冲区的配合使用
#define NUM_DMA_BUFFERS  2
uint8_t dma_buffers[NUM_DMA_BUFFERS][AUDIO_BLOCK_SIZE];
int current_buffer = 0;

void dma_callback(...)
{
    // DMA 传输完成,将当前缓冲区数据放入环形缓冲区
    ring_buf_put(&audio_ring, dma_buffers[current_buffer], AUDIO_BLOCK_SIZE);

    // 切换到下一个缓冲区
    current_buffer = (current_buffer + 1) % NUM_DMA_BUFFERS;

    // 更新 DMA 源地址
    dma_block_cfg.source_address = (uint32_t)dma_buffers[current_buffer];
    dma_config(dma_dev, 3, &dma_cfg);
}

嗯,这里要注意:DMA 正在传输的缓冲区,绝对不能去修改里面的数据。否则你会听到各种奇怪的噪音。我当初调试时,就是因为没处理好这个同步问题,折腾了两天才找到原因。

最后总结一下:DMA 配置要仔细看芯片手册,循环传输是音频的主流模式,环形缓冲区是数据流的「蓄水池」。这三者配合好了,音频系统就稳了一大半。下一节我们会聊音频编解码器的驱动开发,到时候这些知识都会用上。