4、音频数据流管理:DMA引擎配置、音频缓冲区管理、数据流启动/停止流程

各位同学,今天我们来聊聊音频驱动的核心——数据流管理。这部分内容,说白了就是让音频数据在内存和硬件之间高效流转。我做了这么多年QNX音频驱动,发现很多问题都出在数据流管理上。你想想看,音频数据要是卡顿、断流,用户第一反应就是“这设备不行”。所以这块必须吃透。

4.1 DMA引擎配置——数据搬运的核心

DMA,全称Direct Memory Access,直接内存访问。它的作用就是让数据绕过CPU,直接在内存和外设之间搬运。我刚开始做驱动时,总觉得DMA配置很简单,不就是设置几个寄存器嘛。直到有一次,我遇到一个音频断断续续的问题,查了三天才发现是DMA传输粒度没配对。

在QNX下,DMA引擎配置主要涉及以下几个关键点:

  • DMA通道选择:每个音频设备通常有专用的DMA通道,不能乱用。我记得有个项目,硬件工程师把I2S的DMA通道和SPI的搞混了,结果音频数据全跑到SPI设备上去了。
  • 传输方向:播放是内存到外设(DMA_MEM_TO_DEV),录音是外设到内存(DMA_DEV_TO_MEM)。这个方向搞反了,数据就飞了。
  • 传输宽度:8位、16位、32位,必须和音频数据格式匹配。比如你的音频是16位立体声,那每次传输就是32位。
  • 突发传输大小:这个参数影响传输效率。设得太小,DMA频繁中断;设得太大,可能造成延迟。

下面是一个典型的DMA配置代码片段,我在项目中经常这样写:

// DMA通道配置结构体
typedef struct {
    uint32_t    channel;        // DMA通道号
    uint32_t    direction;      // 传输方向
    uint32_t    width;          // 传输宽度
    uint32_t    burst_size;     // 突发传输大小
    paddr_t     src_addr;       // 源物理地址
    paddr_t     dst_addr;       // 目标物理地址
    size_t      transfer_size;  // 总传输大小
} dma_config_t;

// 配置DMA引擎
int audio_dma_config(dma_config_t *cfg) {
    // 检查参数有效性
    if (!cfg || cfg->transfer_size == 0) {
        return -EINVAL;
    }

    // 设置DMA通道
    dma_channel_select(cfg->channel);

    // 配置传输方向
    dma_set_direction(cfg->direction);

    // 配置数据宽度
    dma_set_data_width(cfg->width);

    // 配置突发传输大小
    dma_set_burst_size(cfg->burst_size);

    // 设置源和目标地址
    dma_set_src_addr(cfg->src_addr);
    dma_set_dst_addr(cfg->dst_addr);

    // 设置传输大小
    dma_set_transfer_size(cfg->transfer_size);

    return EOK;
}
⚠️ 注意:DMA配置中的地址必须是物理地址,不是虚拟地址。我见过不少新手直接传虚拟地址进去,结果DMA搬运的数据全是错的。在QNX下,需要用mmap_device_memory()或者直接操作物理地址。

4.2 音频缓冲区管理——环形缓冲区与双缓冲

缓冲区管理是音频驱动的重头戏。为什么需要缓冲区?因为音频数据是连续的流,而硬件处理是离散的。缓冲区就像水库,起到调节作用。

4.2.1 环形缓冲区

环形缓冲区,也叫循环缓冲区,是我个人最喜欢用的方案。它的原理很简单:一块固定大小的内存,写指针和读指针在里边绕圈圈。写满了就从头开始覆盖,读完了也从头开始读。

为什么会选择环形缓冲区?因为它天然适合音频这种流式数据。你想想看,音频数据源源不断进来,硬件源源不断取走,环形缓冲区正好能处理这种“生产者-消费者”模型。

实现环形缓冲区时,有几个关键点要注意:

  • 缓冲区大小:必须是2的幂次方,这样可以用位运算快速计算索引。比如缓冲区大小是1024字节,那索引取模可以用 & 0x3FF 代替 % 1024。
  • 读写指针管理:写指针追上读指针表示缓冲区满,读指针追上写指针表示缓冲区空。这个判断逻辑要小心,我遇到过因为指针溢出导致判断错误的bug。
  • 原子操作:读写指针的更新必须是原子的,否则在多线程环境下会出问题。
// 环形缓冲区结构体
typedef struct {
    uint8_t     *buffer;        // 缓冲区基地址
    size_t      size;           // 缓冲区大小(必须是2的幂次方)
    volatile uint32_t write_idx; // 写索引
    volatile uint32_t read_idx;  // 读索引
} ring_buffer_t;

// 写入数据到环形缓冲区
int ring_buffer_write(ring_buffer_t *rb, uint8_t *data, size_t len) {
    uint32_t available;

    // 计算可用空间
    available = (rb->read_idx - rb->write_idx - 1) & (rb->size - 1);

    if (available < len) {
        return -ENOSPC;  // 空间不足
    }

    // 写入数据
    for (size_t i = 0; i < len; i++) {
        rb->buffer[rb->write_idx] = data[i];
        rb->write_idx = (rb->write_idx + 1) & (rb->size - 1);
    }

    return EOK;
}
💡 经验之谈:环形缓冲区的大小选择很有讲究。太小了容易溢出,太大了浪费内存。我一般建议设置为音频帧大小的整数倍,比如16帧或32帧。这样既能保证流畅性,又不会占用太多内存。

4.2.2 双缓冲

双缓冲是另一种常见方案。它把缓冲区分成两块,一块给硬件用,一块给软件用。硬件处理完一块后,自动切换到另一块,同时通知软件处理已完成的那块。

双缓冲的好处是简单直观,特别适合硬件支持自动切换的场景。很多音频控制器都内置了双缓冲机制,比如I2S控制器通常有两个数据寄存器。

双缓冲的实现要点:

  • 缓冲区切换:硬件完成一块后,会产生中断,驱动在中断处理中切换缓冲区。
  • 数据同步:软件在填充缓冲区时,要确保硬件不会同时读取。这通常通过信号量或互斥锁来实现。
  • 缓冲区对齐:两个缓冲区最好在内存中对齐,方便DMA传输。

环形缓冲区 vs 双缓冲:怎么选?

我个人习惯:如果硬件支持自动切换,用双缓冲更简单;如果硬件只支持单次传输,用环形缓冲区更灵活。曾经有个项目,硬件不支持自动切换,我硬是用环形缓冲区实现了连续播放,效果还不错。

4.3 数据流启动/停止流程

数据流的启动和停止,看似简单,实则暗藏玄机。我见过太多因为启动/停止顺序不对导致的音频问题。

4.3.1 启动流程

启动音频数据流,一般遵循以下步骤:

  1. 准备缓冲区:先填充一部分数据到缓冲区,避免启动后无数据可播。
  2. 配置DMA:设置好DMA通道、地址、传输大小等参数。
  3. 启动DMA:使能DMA传输,开始搬运数据。
  4. 启动音频外设:使能I2S或PCM控制器,开始产生时钟和帧同步信号。
  5. 等待中断:DMA传输完成或缓冲区半满时产生中断,驱动在中断中继续填充数据。

这里有个细节:先启动DMA还是先启动外设?我建议先启动DMA。为什么呢?因为DMA准备好后,外设一启动就能立刻拿到数据。如果先启动外设,DMA还没准备好,外设可能读到空数据,产生爆音。

// 音频数据流启动函数
int audio_stream_start(audio_device_t *dev) {
    int ret;

    // 1. 准备缓冲区,预填充数据
    ret = audio_buffer_prefill(dev);
    if (ret != EOK) {
        return ret;
    }

    // 2. 配置DMA
    ret = audio_dma_config(&dev->dma_cfg);
    if (ret != EOK) {
        return ret;
    }

    // 3. 启动DMA(先启动)
    ret = dma_start(dev->dma_channel);
    if (ret != EOK) {
        return ret;
    }

    // 4. 启动音频外设(后启动)
    ret = audio_peripheral_start(dev);
    if (ret != EOK) {
        dma_stop(dev->dma_channel);  // 启动失败,回滚
        return ret;
    }

    dev->state = AUDIO_STATE_RUNNING;
    return EOK;
}

4.3.2 停止流程

停止流程和启动流程相反,但同样有讲究:

  1. 停止音频外设:先关闭I2S或PCM控制器,停止时钟和帧同步。
  2. 停止DMA:停止DMA传输,释放DMA通道。
  3. 清空缓冲区:把缓冲区中剩余的数据清掉,避免下次启动时出现残留数据。
  4. 复位状态:将设备状态设为空闲。

为什么要先停外设再停DMA?因为外设停了之后,DMA就没有数据请求了,自然就停了。如果先停DMA,外设还在运行,可能会产生欠载(underrun)错误。

⚠️ 避坑指南:我曾经在一个项目中,停止流程搞反了顺序,先停了DMA。结果外设还在请求数据,DMA却没有响应,导致I2S控制器进入了错误状态。从那以后,我每次写停止函数都会反复确认顺序。

4.3.3 暂停与恢复

除了启动和停止,有时候还需要暂停和恢复。暂停时,DMA和外设都停止,但保留当前状态。恢复时,从暂停点继续播放。

暂停和恢复的实现相对简单:

  • 暂停:停止DMA传输,但保留DMA配置和缓冲区内容。
  • 恢复:重新启动DMA,从上次停止的位置继续传输。

这里要注意的是,暂停时间不能太长。如果暂停太久,音频数据在缓冲区中可能已经过时了。我一般建议暂停时间不超过100毫秒。

4.4 总结

好了,这一章的内容就到这里。我们讲了DMA引擎配置、环形缓冲区和双缓冲的实现、以及数据流的启动/停止流程。这些是音频驱动开发的基础,也是最容易出问题的地方。

最后送大家一句话:音频驱动调试,七分在数据流,三分在硬件。把数据流管理好了,音频驱动就成功了一大半。下一章我们会讲音频时钟管理和同步,到时候再聊。