3、Linux ALSA框架:架构分层与核心组件

ALSA 框架,说白了就是 Linux 下音频驱动的「总调度室」。我刚接触时也觉得它很复杂,分层多、概念杂。但摸熟了之后会发现,它的设计其实很优雅——每一层各司其职,出了问题也容易定位。

3.1 ALSA 架构分层

ALSA 在用户空间和硬件之间,一共拆了三层。我习惯把它画成下面这张图:

ALSA 架构分层 用户空间 应用程序(aplay / arecord / 播放器) 内核空间 - ALSA 核心层 snd_card / PCM设备 / Control接口 / 路由管理 内核空间 - ASoC 层 Machine驱动 / Platform驱动 / Codec驱动 硬件层 I2S总线 / DMA控制器 / WM8960 Codec 数据流:用户空间 → ALSA核心 → ASoC → 硬件

从上往下看,数据流是这样的:

  • 用户空间:应用程序调用 ALSA lib API,比如 aplay、arecord,或者你写的播放器。
  • ALSA 核心层:管理声卡设备(snd_card)、PCM 设备、Control 接口。它不关心底层硬件细节。
  • ASoC 层:这是嵌入式音频驱动的核心。它把驱动拆成三块——Machine、Platform、Codec。
  • 硬件层:I2S 总线、DMA 控制器、Codec 芯片(比如 WM8960)。
我的习惯:调试时先确认问题出在哪一层。用户空间报错,先查 ALSA lib 配置;内核报 DMA 超时,直奔 Platform 驱动。别一上来就怀疑 Codec。

3.2 snd_soc_dai_link 与 snd_soc_card

这两个结构体,是 ASoC 层里最重要的「接线图」。我调试过的板子,有一半的音频问题最后都出在这里——要么 link 没配对,要么 card 注册顺序错了。

3.2.1 snd_soc_card

它代表一块「声卡」。一个 card 可以包含多个 DAI link,每个 link 连接一个 CPU DAI 和一个 Codec DAI。

struct snd_soc_card {
    const char *name;
    const char *long_name;
    struct snd_soc_dai_link *dai_link;
    int num_links;
    // ... 其他字段
};

注册时,驱动里会这样写:

static struct snd_soc_card my_card = {
    .name = "WM8960-Audio",
    .dai_link = my_dai_links,
    .num_links = ARRAY_SIZE(my_dai_links),
};

static int my_probe(struct platform_device *pdev) {
    return devm_snd_soc_register_card(&pdev->dev, &my_card);
}

3.2.2 snd_soc_dai_link

它描述一条音频数据通路。我见过新手把 codec_dai_name 写错,结果驱动加载成功但播放没声音——查了两天才发现是字符串拼写问题。

static struct snd_soc_dai_link my_dai_links[] = {
    {
        .name = "WM8960 Playback",
        .stream_name = "Playback",
        .cpu_dai_name = "2020000.i2s",
        .codec_dai_name = "wm8960-hifi",
        .platform_name = "2020000.i2s",
        .codec_name = "wm8960.0-001a",
        .ops = &my_ops,
        .dai_fmt = SND_SOC_DAIFMT_I2S |
                   SND_SOC_DAIFMT_NB_NF |
                   SND_SOC_DAIFMT_CBS_CFS,
    },
};
字段 含义 常见坑
cpu_dai_name CPU 侧 I2S 接口的设备名 设备树中 compatible 不匹配
codec_dai_name Codec 侧的 DAI 名称 拼写错误,大小写敏感
platform_name DMA 引擎对应的平台设备 与 cpu_dai 共用时容易漏配
dai_fmt I2S 格式、时钟极性和主从模式 主从模式配反导致无数据
我曾经踩过的坑:dai_fmt 里的时钟极性配反了。I2S 标准是 BCLK 下降沿采样,但有些 Codec 要求上升沿。查了两天波形才发现。所以,拿到新板子第一件事——用示波器看 I2S 波形。

3.3 DMA 引擎与环形缓冲区

音频数据量大,用 CPU 逐字节搬运不现实。DMA 引擎就是干这个的——它负责把内存里的音频数据搬到 I2S 发送 FIFO,或者从 I2S 接收 FIFO 搬到内存。

3.3.1 环形缓冲区(Ring Buffer)

ALSA 的 PCM 缓冲区本质是一个环形缓冲区。应用程序写数据到缓冲区尾部,DMA 从缓冲区头部读取发送。两者通过指针协调:

  • appl_ptr:应用程序当前写入位置。
  • hw_ptr:DMA 当前读取位置。
  • 当 appl_ptr 追上 hw_ptr,缓冲区满,应用程序阻塞等待。
  • 当 hw_ptr 追上 appl_ptr,缓冲区空,DMA 静音输出。

为什么会发生 XRUN(欠载/溢出)?说白了就是 appl_ptr 和 hw_ptr 的追赶游戏没玩好。应用程序写慢了,DMA 读空了——这就是 underrun。反过来就是 overrun。

关键指标:缓冲区大小和周期大小(period_size)直接影响延迟。周期越小,延迟越低,但 CPU 中断频率越高。我一般从 1024 帧开始调,再根据实际效果微调。

3.3.2 DMA 引擎配置

在 Platform 驱动里,DMA 的配置通常长这样:

static const struct snd_dmaengine_pcm_config my_dma_config = {
    .prepare_slave_config = snd_dmaengine_pcm_prepare_slave_config,
    .compat_filter_fn = NULL,
    .pcm_hardware = &my_pcm_hardware,
    .prealloc_buffer_size = 64 * 1024,
};

static int my_platform_probe(struct platform_device *pdev) {
    return devm_snd_dmaengine_pcm_register(&pdev->dev, &my_dma_config, 0);
}

这里要注意 prealloc_buffer_size。设太小,高采样率下容易 XRUN;设太大,浪费内存。我一般设 64KB,对于 48kHz/16bit/双声道,大约能缓冲 340ms 的数据,够用。

3.3.3 DMA 传输流程

  1. 应用程序调用 snd_pcm_writei(),数据写入环形缓冲区。
  2. ALSA 核心层更新 appl_ptr,触发 DMA 传输请求。
  3. Platform 驱动的 trigger() 回调启动 DMA 通道。
  4. DMA 控制器从缓冲区搬运数据到 I2S TX FIFO。
  5. I2S 控制器按 BCLK 节奏将数据串行发送到 Codec。
  6. DMA 传输完成,产生中断,更新 hw_ptr。
  7. 如果 hw_ptr 超过 appl_ptr 一个 period,ALSA 核心唤醒应用程序继续写入。
调试技巧:在 trigger() 回调里加打印,确认 DMA 是否被正确启动。如果 trigger 被调用了但没声音,多半是 DMA 通道配置错了——检查 dma_request_slave_channel 的参数。

3.4 本章小结

ALSA 框架的分层设计,让音频驱动开发变得模块化。你不需要同时理解所有细节——先搞清楚你的问题在哪一层,再深入那一层去查。snd_soc_dai_link 是连接 CPU 和 Codec 的桥梁,配错了就没声音。DMA 引擎和环形缓冲区决定了延迟和稳定性,调参是个细致活。