4、DMA与音频数据传输:DMA控制器原理、Zephyr DMA API、音频环形缓冲区设计
音频处理,说白了就是跟数据搬运较劲。你想想看,一个 48kHz、16 位立体声的音频流,每秒要搬将近 200KB 的数据。如果全靠 CPU 一个一个字节去搬,那别的活就别干了。这时候,DMA 就是你的救星。
我个人习惯把 DMA 叫做「数据搬运工」。它不需要 CPU 插手,自己就能把数据从内存搬到外设,或者反过来。我刚开始做音频驱动时,总觉得 DMA 配置很玄乎,后来踩了几个坑才明白——其实核心就三件事:谁搬、搬多少、搬完通知谁。
4.1 DMA控制器原理
DMA 控制器,说白了就是一个专门干数据拷贝的硬件模块。它有自己的寄存器,能独立访问内存和外设。你只要告诉它「源地址、目的地址、数据长度」,它就能自己跑完整个传输。
为什么会这样?因为 DMA 控制器和 CPU 是并行的。CPU 在跑你的应用逻辑,DMA 在后台默默搬数据。等搬完了,DMA 发个中断通知 CPU:「活干完了,你来处理吧。」
嗯,这里要注意:DMA 不是万能的。它只能做简单的内存拷贝,不能做数据格式转换、不能做算法处理。所以音频数据的重采样、混音这些活,还是得 CPU 来干。
核心概念:DMA 传输模式通常有三种:
- 内存到外设:比如把音频缓冲区数据送到 I2S 发送 FIFO
- 外设到内存:比如从 I2S 接收 FIFO 读取音频数据到缓冲区
- 内存到内存:比如在两个音频缓冲区之间拷贝数据
我在项目中遇到过一个问题:DMA 传输完成后,数据还没真正到达目的地。为什么?因为 DMA 和 CPU 之间有缓存一致性问题。尤其是在带 Cache 的 MCU 上,DMA 写内存时绕过了 Cache,CPU 读到的可能是旧数据。解决办法?要么用非缓存内存区域,要么手动做 Cache 刷新。
避坑指南:我曾经在 STM32 上调试一个音频播放器,声音总是断断续续。查了两天才发现,DMA 配置的源地址是 I2S 的数据寄存器,但地址没有按 4 字节对齐。DMA 在传输时做了对齐处理,导致数据错位。记住:DMA 的源地址和目的地址,一定要按传输宽度对齐。
4.2 Zephyr DMA API
Zephyr 的 DMA API 设计得挺清爽。它把不同厂家的 DMA 控制器抽象成统一的接口。你写驱动时,不用关心底层是 STM32 的 DMA 还是 NXP 的 eDMA,API 都一样。
核心 API 就这几个:
| API 函数 | 功能说明 |
|---|---|
dma_config() |
配置 DMA 通道,包括源地址、目的地址、传输宽度、突发大小等 |
dma_start() |
启动 DMA 传输 |
dma_stop() |
停止 DMA 传输 |
dma_get_status() |
获取 DMA 传输状态,比如剩余字节数 |
我建议你重点看 dma_config() 的参数结构体 dma_config。里面有个 dma_block_config,定义了每个数据块的传输参数。音频应用中,我们通常用「循环模式」——DMA 传输完一块数据后,自动从头开始,不用 CPU 重新启动。
// 典型的音频 DMA 配置示例
struct dma_config dma_cfg = {
.channel_direction = MEMORY_TO_PERIPHERAL,
.source_data_size = 2, // 16 位音频数据
.dest_data_size = 2,
.source_burst_length = 4,
.dest_burst_length = 4,
.block_count = 1,
.head_block = &block_cfg,
};
struct dma_block_config block_cfg = {
.source_address = (uint32_t)audio_buffer,
.dest_address = (uint32_t)&I2S->DR,
.block_size = AUDIO_BUF_SIZE,
.source_gather_en = 0,
.dest_scatter_en = 0,
.source_gather = NULL,
.dest_scatter = NULL,
.dest_scatter_en = 0,
.source_gather_en = 0,
.fifo_mode_control = 0,
.source_addr_adj = DMA_ADDR_ADJ_INCREMENT,
.dest_addr_adj = DMA_ADDR_ADJ_NO_CHANGE,
};
嗯,这里有个细节:source_addr_adj 和 dest_addr_adj 决定了地址是否递增。音频播放时,源地址(内存缓冲区)要递增,目的地址(I2S 数据寄存器)不变。反过来录音时,源地址不变,目的地址递增。
个人经验:Zephyr 的 DMA API 在 2.6 版本之后做了重构,增加了 dma_reload() 函数。如果你用较新版本,建议用这个函数来更新 DMA 传输参数,比先 stop 再 start 更高效。我在做低功耗音频播放器时,用 dma_reload() 减少了约 30% 的中断处理时间。
4.3 音频环形缓冲区设计
环形缓冲区,说白了就是一个固定大小的数组,加上两个指针:写指针和读指针。写指针追着读指针跑,读指针追着写指针跑。永远不会溢出,也不会浪费空间。
为什么音频要用环形缓冲区?因为 DMA 和 CPU 是异步工作的。DMA 在后台往缓冲区里写数据,CPU 在前台处理数据。如果没有环形缓冲区,你就要频繁地同步两者,效率很低。
我设计环形缓冲区时,习惯用「幂次方大小」。比如 1024、2048、4096 字节。为什么?因为可以用位运算代替取模运算,速度快很多。
// 音频环形缓冲区核心结构
struct audio_ringbuf {
uint8_t *buffer;
uint32_t size; // 必须是 2 的幂
uint32_t write_idx; // 写指针
uint32_t read_idx; // 读指针
uint32_t mask; // size - 1,用于位运算
};
// 写入数据
int audio_ringbuf_write(struct audio_ringbuf *rb,
const uint8_t *data, uint32_t len) {
uint32_t space = audio_ringbuf_space(rb);
if (len > space) {
return -ENOSPC;
}
for (uint32_t i = 0; i < len; i++) {
rb->buffer[(rb->write_idx + i) & rb->mask] = data[i];
}
rb->write_idx = (rb->write_idx + len) & rb->mask;
return len;
}
// 读取数据
int audio_ringbuf_read(struct audio_ringbuf *rb,
uint8_t *data, uint32_t len) {
uint32_t avail = audio_ringbuf_avail(rb);
if (len > avail) {
len = avail;
}
for (uint32_t i = 0; i < len; i++) {
data[i] = rb->buffer[(rb->read_idx + i) & rb->mask];
}
rb->read_idx = (rb->read_idx + len) & rb->mask;
return len;
}
关键设计点:环形缓冲区的大小选择直接影响音频延迟。缓冲区越大,延迟越高,但 CPU 负载越低。我一般这样选:
- 低延迟场景(如通话):缓冲区大小 = 2 × DMA 块大小
- 高保真场景(如播放器):缓冲区大小 = 4 × DMA 块大小
- 极端低功耗场景:缓冲区大小 = 8 × DMA 块大小,允许 CPU 长时间休眠
我曾经在一个项目中,把环形缓冲区大小从 4096 改成 1024,延迟从 20ms 降到了 5ms。但代价是 CPU 中断频率从 50Hz 升到了 200Hz,功耗增加了 15%。所以,没有完美的配置,只有适合场景的配置。
避坑指南:我曾经在环形缓冲区里遇到过一个诡异的 bug——数据偶尔会丢失。查了很久才发现,DMA 中断和 CPU 读取操作没有做原子保护。当 DMA 写完一块数据触发中断时,CPU 正在读取缓冲区,两者同时操作同一个位置,数据就乱了。解决办法:在读写操作前后加 irq_lock() 和 irq_unlock(),或者用双缓冲方案。
说到双缓冲,其实它是环形缓冲区的一种特例。两个缓冲区轮流使用:DMA 写一个,CPU 读另一个。等 DMA 写完了,交换角色。这种方案实现简单,但缓冲区利用率只有 50%。环形缓冲区利用率高,但实现复杂一些。我个人习惯:如果音频数据是固定大小的块,用双缓冲;如果是可变长度的流,用环形缓冲区。
最后,我想强调一点:DMA 和环形缓冲区是音频驱动的基石。你把这两个东西搞透了,后面写 I2S 驱动、Codec 驱动都会轻松很多。嗯,下一章我们就来实战——用 Zephyr 写一个完整的音频播放驱动。