ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式Linux ASoC音频驱动开发:Codec控件与DAPM排障实战

嵌入式Linux ASoC音频驱动开发:Codec控件与DAPM排障实战 我几乎每拿到一块新板子第一件事不是跑性能测试而是先去翻音频驱动代码。原因很简单音频链路在嵌入式 Linux 里是少数几个横跨“I2C 寄存器配置、DMA 数据搬运、DAPM 电源路径、用户空间 mixer 控制”的完整子系统任何一个环节断掉你听到的就是一片死寂。而现在的嵌入式 Linux 音频绝大部分都绕不开 ASoCALSA System on Chip这套框架。这篇文章不打算讲泛泛的理论主要围绕 ASoC 音频控件Kcontrol和 Codec 驱动开发来展开。我会把 ASoC 框架的抽象思想、Codec 驱动骨架、控件宏定义、DAPM 路由以及一套我实际用的无声排障链路都写清楚。适合正在调 I2S Codec 方案的驱动工程师也适合想弄明白 wm8960、es8316 这类 Codec 驱动如何接进内核的学习者。1. 为什么是 ASoC老 ALSA 驱动的问题与三层拆分1.1 老式 ALSA Codec 驱动的问题在 ASoC 还没大规模铺开之前Codec 驱动和板级控制、CPU 平台控制往往是搅在一起的。你可以想象一下当时的代码长什么样一个 Codec 驱动里既要处理 I2C 寄存器读写又要知道所用平台的 DMA 通道、时钟配置还要硬编码电路板上的 GPIO 用来切换功放、使能 MICBias。代码一多维护就成了灾难。这种设计的根本问题在于没有“分层”的意识。Codec 芯片可能是通用的但每块板子的音频路径不一样有的用内部 speaker有的用耳机有的用差分 Line Out有的需要 D 类功放有的需要外部 Class-AB。如果 Codec 驱动把板级差异也塞进去那换个平台就等于重写一遍。到了移动设备时代这个问题更明显。SoC 越来越普遍内置 I2S/PDM 接口越来越标准外挂 Codec 的玩法也越来越统一。内核社区选择做一套专门给嵌入式和移动设备用 ALSA 子框架这就是 ASoC。1.2 三层抽象Codec、Platform、MachineASoC 的核心是把整个音频系统拆成三个角色。Codec 层负责编解码芯片本身寄存器映射、音频通路、音量控制、DAPM 电源管理、数字接口DAI能力。简单说它只关心 Codec 自己怎么干活不关心它被接到了哪个 SoC 上。Platform 层负责 DMA 和 CPU 侧的 DAI 操作。比如你把 I2S 数据从内存搬运到 Codec或者从 Codec 收数据到内存这是 Platform 层的事情。它不关心 Codec 芯片是哪个厂商的只关心自己的 FIFO、DMA 通道和时钟。Machine 层是胶水层负责把 Codec 和 Platform 连接起来同时告诉系统板级信息哪根 I2S 接到了哪个 Codec、MCLK 频率是多少、sysclk 怎么配、codec 的 DAI 和 cpu 的 DAI 怎么组成一个 PCM 设备。这样拆的最大好处是 Codec 驱动可以做到“一次编写多处复用”。同一颗 Codec 用在 i.MX、RK、全志、高通上Codec 驱动基本不用改只需要改 Machine 配置。Platform 驱动也不依赖具体 Codec它只管把数据送到数字接口上。真正的板级差异被收敛进了 Machine 或者设备树。1.3 ASoC 在文件上怎么落如果你去看内核源码这种分层对应得很直观。sound/soc/codecs/ 目录下全是厂商的 Codec 驱动wm8960.c、es8316.c、tlv320aic3x.c。sound/soc/xxx/ 里是各家 SoC 的 Platform 和 Machine 驱动。比如 rockchip/ 目录下既有 rockchip_i2s.c 这种 Platform DAI 驱动也有 rk3399_* 这种 Machine 驱动。我自己在写 Machine 驱动时经常只关心三件事codec 的 DAI name、cpu 的 DAI name、初始化时刻需要设置的时钟频率。剩下音频通路的控制全部交给 Codec 驱动。这也直接决定了你在设备树里看到 simple-audio-card 时为什么能写得那么短sound { compatible simple-audio-card; simple-audio-card,format i2s; simple-audio-card,mclk-fs 256; simple-audio-card,cpu { sound-dai i2s0; }; simple-audio-card,codec { sound-dai wm8960; }; };这颗 wm8960 如果之前已经被别的板子驱动过那你在自己板子上基本不需要写多少代码只要 Machine 对、时钟对、I2C 地址对就能跑起来。这其实就是 ASoC 设计成功的地方。2. Codec 驱动骨架component 结构、配置和探测时序2.1 传统 codec_driver 与 component_driver 的差异早期 ASoC 驱动里Codec 驱动用snd_soc_codec_driver这个结构体来注册。后来内核对 ASoC 做了 component 化改造很多结构体合并进了snd_soc_component_driver。如果你看新内核里的 Codec 驱动会发现它们大多已经不再直接用snd_soc_codec_driver了。我在给别人的代码 review 时经常看到新老 API 混用这在内核版本升级时特别容易出问题。老版本里如果你看到这样的写法static struct snd_soc_codec_driver soc_codec_dev_mycodec { .probe mycodec_probe, .component_driver { .controls mycodec_snd_controls, .num_controls ARRAY_SIZE(mycodec_snd_controls), .dapm_widgets mycodec_dapm_widgets, .num_dapm_widgets ARRAY_SIZE(mycodec_dapm_widgets), .dapm_routes mycodec_intercon, .num_dapm_routes ARRAY_SIZE(mycodec_intercon), }, };新内核里已经推荐直接把snd_soc_component_driver定义成顶层变量然后通过devm_snd_soc_register_component注册static const struct snd_soc_component_driver mycodec_component { .probe mycodec_probe, .idle_bias_on 1, .use_pmdown_time 1, .endianness 1, .non_legacy_dai_naming 1, .controls mycodec_snd_controls, .num_controls ARRAY_SIZE(mycodec_snd_controls), .dapm_widgets mycodec_dapm_widgets, .num_dapm_widgets ARRAY_SIZE(mycodec_dapm_widgets), .dapm_routes mycodec_intercon, .num_dapm_routes ARRAY_SIZE(mycodec_intercon), }; static int mycodec_i2c_probe(struct i2c_client *i2c) { struct regmap *regmap; ... regmap devm_regmap_init_i2c(i2c, mycodec_regmap_cfg); ... return devm_snd_soc_register_component(i2c-dev, mycodec_component, NULL, 0); }idle_bias_on这个字段很多人不重视。它表示 Codec 在没有音频流时也保持偏置供电这样对降低爆音有帮助。use_pmdown_time则影响关音频流时延后关电源的时机用来避免关机时的 pop 声。我建议新写驱动尽量用新 API。即使你现在的内核还是老版本只要从snd_soc_codec_driver移植到snd_soc_component_driver改动成本也不高而且以后升级内核会少踩很多坑。2.2 regmap 与 I2C 通信的初始化大多数 Codec 都挂在 I2C 上少数用 SPI。开发第一步我永远先确认 I2C 通信能不能通。判断依据很简单用 i2cdetect 能看到地址并且读寄存器返回的不是 0xff 或者 0x00。通信层我强烈建议用 regmap而不是自己写 i2c_transfer。regmap 带来的好处是多方面的第一你不用自己处理缓存、多字节读写、clock 拉伸第二它天然对接 debugfs你在/sys/kernel/debug/regmap/xxx/registers里能直接看到当前寄存器值变化排查音量、开关问题极其方便。典型的 regmap 配置类似这样static const struct regmap_config mycodec_regmap { .reg_bits 8, .val_bits 8, .max_register 0x2f, .cache_type REGCACHE_RBTREE, };reg_bits和val_bits必须和芯片手册严格对上。有些 Codec 是 7 位寄存器地址加 9 位数据有的是 16 位地址 16 位数据一旦配置错读哪一个寄存器都错位然后你会在调试里浪费一整天。还有一个容易忽略的点是 Codec 的复位 GPIO 和主时钟。如果复位脚没拉高I2C 有时候能识别但寄存器读写不出来如果 MCLK 频率不对DAI 时钟配置会失败。所以 Codec probe 里通常要确保三件事reset GPIO 正确释放、CLK 能开、regmap 能访问。这三个检查做完才开始注册音频控件。2.3 Machine、dai_link 与 simple-audio-card 的配合Codec 驱动注册完了还要有 Machine 层把它和 CPU DAI 连起来。用设备树 simple-audio-card 时内核会自动创建一个 generic Machine 驱动。你用sound-dai来引用 CPU 和 Codec 的 DAI 节点。这里最容易出问题的就是 DAI name 匹配。Codec 驱动里 DAI 结构体可能叫mycodec_dai它的名字字段是mycodec-hifi而你在设备树或 Machine 驱动里写死成mycodec那就对不上。内核在启动时会打印找不到 DAI 或者在 bind 时报错。如果你写 Machine 驱动核心结构体是snd_soc_dai_link。一个很常见的错误是 codec 侧没有指定 DAI name导致 probe 后 PCM 设备创建不出来。我在调试时习惯先看启动日志里有没有“ASoC: no DMI platform”或“ASoC: failed to bind”这类信息。ASoC 的绑定日志一般比较清晰只要你别把日志等级设太低。3. 音频控件开发从 SOC_SINGLE 到自定义 TLV 音量曲线3.1 Kcontrol 到底代表什么用户在使用tinymix或amixer时看到的每一个控制项内核里都是一个snd_kcontrol_new。在 ASoC 里它通常被叫做 Kcontrol也就是我们说的音频控件。控件可以是一个音量滑杆、一个开关、一个枚举选择器也可以是一个带回调的虚拟节点。控件背后的核心几乎都是“对 Codec 寄存器里的某些 bit 进行读写”。所以写控件首先要对寄存器手册非常熟。我的习惯是建一个寄存器表把地址、bit 位、含义、默认值、读写属性全列出来然后再去写控件。不然纯靠脑子记必错。3.2 常用控件宏与对应场景ASoC 提供了一套超级方便的宏来定义控件。咱们最常用的有这些宏用途典型参数SOC_SINGLE单个 bit 段的音量或开关name, reg, shift, max, invertSOC_DOUBLE左右声道两个 bit 段name, reg, lshift, rshift, max, invertSOC_ENUM枚举选择如输入源切换name, reg, shift, max, enumSOC_SINGLE_TLV带音量 DB 表的音量控件name, reg, shift, max, invert, tlvSOC_DOUBLE_R_TLV左右声道寄存器不同的音量控件name, lreg, rreg, shift, max, invert, tlv举个例子某颗 Codec 的麦克风增益在寄存器 0x02 的 bit 6:4线性 0~15你不能写 15 代表最大增益吗可以但用户空间实际上更希望能看到了多少 dB。此时就要用 TLV 表把寄存器数值和真实 dB 值映射起来。static const DECLARE_TLV_DB_SCALE(mic_gain_tlv, 0, 300, 0); SOC_SINGLE_TLV(Capture MIC Volume, 0x02, 4, 15, 0, mic_gain_tlv),注意DECLARE_TLV_DB_SCALE的第三个参数是步进单位是 1/100 dB。上面这个例子就是每格 3.00 dB。第四个参数表示是否反转。很多老工程师有个习惯喜欢用SOC_SINGLE加一个“音量”名字但不给 TLV。结果用户空间看到的就不是 dB而是裸数值。虽然能用但体验很差。我的建议是只要是音量控件一律带上 TLV尤其是带 Stage 功放或喇叭的项目调音时没有 dB 参考会很痛苦。3.3 音量曲线和非线性映射有些 Codec 寄存器的音量并不等于线性增益而是分成多个区间每个区间步进不一样。这时你不能再靠DECLARE_TLV_DB_SCALE一把梭要设计自定义控件。我处理过一颗 Codec 的 PGA 增益低端每步 0.5 dB高端每步 2 dB。用户从 0 拉到 15 感觉前面变化特别慢后面又突然变大。如果寄存器本身支持的是这种非均匀曲线你不能直接在控件里做线性映射要写一个转换表。做法大致是先定义两个数组一个是寄存器值一个是对应 dB 值get 回调里从寄存器值查到 dBput 回调里从用户给的 dB 反查最接近的寄存器值。必要时候还要做插值。这样调音师在用户空间看到一个连续的 dB 滑杆体验就和专业音频设备一致了。自定义控件的结构比较固定的static int mycodec_vol_info(struct snd_kcontrol *kcontrol, struct snd_ctl_elem_info *uinfo); static int mycodec_vol_get(struct snd_kcontrol *kcontrol, struct snd_ctl_elem_value *ucontrol); static int mycodec_vol_put(struct snd_kcontrol *kcontrol, struct snd_ctl_elem_value *ucontrol); static struct snd_kcontrol_new mycodec_vol_ctl { .iface SNDRV_CTL_ELEM_IFACE_MIXER, .name Custom Volume, .info mycodec_vol_info, .get mycodec_vol_get, .put mycodec_vol_put, };要提醒的是不要只做 get/put 就完事。有些 Codec 存在左右声道联动或者硬件保护位必须在 put 里先判断当前寄存器状态再决定写什么值。否则你可能只是为了改一个音量却把另一个通道的静音位冲掉了。3.4 控件回调与 event 要注意的坑除了 get/putASoC 还支持在控件值改变时触发一个事件回调用SOC_SINGLE_EXT或SOC_ENUM_EXT配合snd_soc_dapm_*用得比较多。我经常见到的坑有两个。第一个回调里做 I2C 读写时不要加太重的锁。控件 put 本身可能在原子上下文附近被调用虽然大部分是进程上下文但有些路径下可能有问题。如果在回调里直接 sleep 很大时间行为难以预测。正确做法是标记需要延迟处理的用 schedule_work或者使用 DAPM 的 event。第二个不要在回调里直接改其他控件的数值。这样容易造成用户空间看到的值和内核实际值不一致。内核里控件的值是可以被用户空间缓存的你强行变更tinymix下次读取时未必能看到。如果确实需要联动可以在硬件寄存器层面处理。4. DAPM Widget、Route 和 Event把功耗管明白4.1 DAPM 官方机制的直觉理解DAPM 全称 Dynamic Audio Power Management名字看着高大上核心思想其实很简单根据音频通路当前是否被使用动态地开关音频子模块电源。比如你现在只从麦克风录音那扬声器功放和 DAC 根本不需要供电DAPM 就会自动把它关掉。DAPM 把 Codec 内部各个功能块称为 widget。来一个生活化类比Codec 里的电路就像家里各个房间的电器DAPM 就是智能电闸你的音频链路就像从插头到灯泡的一段电线。只有整根线都通了灯泡才亮哪一段不需要对应电闸就拉掉。在 ASoC 里widget 种类很多DAC、ADC、MIC、MIXER、PGA、AIF、SUPPLY、CLOCK 等。widget 之间通过 route 连接。路由的建立是从用户空间打开 PCM 播放时DAPM 自动搜索所有路径只要一条完整路径形成就开启路径上所有 widget。4.2 widget 和 route 的编写顺序写 DAPM 配置我建议顺序是先列功能块再列连接关系最后再写 event。功能块就是你要控制电源的对象。我见过很多人在这里喜欢少写几个 widget反正 route 也能配但少写 widget 以后功耗和爆音问题会接踵而来。wireless Example:static const struct snd_soc_dapm_widget mycodec_dapm_widgets[] { SND_SOC_DAPM_INPUT(MICIN), SND_SOC_DAPM_MICBIAS(MICBIAS, SND_SOC_NOPM, 0, 0), SND_SOC_DAPM_ADC(Left ADC, Left Capture, SND_SOC_NOPM, 0, 0), SND_SOC_DAPM_DAC(Left DAC, HiFi Playback, SND_SOC_NOPM, 0, 0), SND_SOC_DAPM_PGA(Speaker PGA, SND_SOC_NOPM, 0, 0, NULL, 0), SND_SOC_DAPM_OUTPUT(SPK_OUT), };route 要把这些 widget 连起来static const struct snd_soc_dapm_route mycodec_intercon[] { { MICBIAS, NULL, MICIN }, { Left ADC, NULL, MICIN }, { Left DAC, NULL, HiFi Playback }, { Speaker PGA, NULL, Left DAC }, { SPK_OUT, NULL, Speaker PGA }, };这里的每一个字符串必须和 widget name 完全一致包括左右声道的后缀。很多坑就是“Left DAC”和“DAC Left”这么来的ASoC 不给你模糊匹配。4.3 用 debugfs 和 procfs 审计 DAPM 状态DAPM 调试时我最常看的就是/sys/kernel/debug/asoc/下的 dapm 节点。你打开内核的 debugfs 之后进去能看到类似snd-soc-card的目录里面有 components、codec 等。dapm 节点会把当前每个 widget 的 power 状态、连接的 input/output 都列出来。比如widget Left DAC out:0 Speaker PGA state:on说明 Left DAC 的输出正连到 Speaker PGA并且 Speaker PGA 当前是开着的。如果某个 widget 的 state 和你预想的不一致比如录音输入还开着多半是 route 没配置成期望路径或者有软件引入的自动路径没走对。遇到 DAPM 没有自动开启某个 widget我一般先检查三件事这个 widget 是否被 route 引用这个 widget 有没有 SND_SOC_DAPM_PRE_PMU / POST_PMU 这类 eventevent 里是不是被无条件 return error是否用了SND_SOC_DAPM_SUPPLY但没有用SND_SOC_DAPM_REGULATOR或snd_soc_dapm_mark_endpoint建立路径依赖导致它没被拉起。DAPM 这个模块读代码可能觉得绕但调试起来反而有迹可循。virtual path 不走通时你只要顺着 debugfs 的 in/out 一条条看通常很快能找到断点。5. 实操一次无声问题从 tinymix 到寄存器的完整排查5.1 先把链路分层音频无声是所有音频相关问题里最难定位的一类因为问题可能出现在用户空间、ALSA core、DAI 配置、Codec 寄存器、物理电路五个层面你必须先把链路分层。我拿到无声问题第一步先确认底层 PCM 设备是否创建成功。cat /proc/asound/cards确认声卡存在再cat /proc/asound/pcm看有没有 PCM playback 节点。如果 PCM 节点都没了问题大概率在 Machine 或 DAI binding不必急着看寄存器。如果 PCM 节点正常用tinymix查看所有控制项。注意别直接相信名字很多 Codec 驱动的控件名不规范比如把 Capture 开关写成 “ADC Switch”把 Playback 开关写成 “DAC Playback”。这种情况会导致脚本设置不到正确位置。5.2 从用户空间逐层验证我会用tinymix -D 0列出当前值。然后先把 Playback 相关 Switch 全部打开把 Volume 调到一个中间值比如tinymix -D 0 PCM Playback Volume 100。再放一个已知的正弦波或者标准 wav。如果此时耳机/喇叭一点声音没有下一步拿示波器或者万用表量 Codec 输出引脚。任何阶段都不能跳过以判断“信号是根本没到 Codec还是到了 Codec 出不来”。我遇到过很多次问题其实在 I2S 数据线没接对或者功放 EN 脚没拉高结果调了一整天的 Codec 寄存器。如果示波器能看到波形但喇叭没有声音检查功放、喇叭连接和功放静音脚。如果示波器完全没有波形再去确认 I2S 时钟和 bit clock 是否在跑。MCLK 没起来、BCLK 频率配置错误都会导致 Codec 没有数据输出。5.3 寄存器级 debugfs 验证链路前面都没问题再进寄存器层排查。我用 regmap debugfs 比较多命令大概是cat /sys/kernel/debug/regmap/1-001a/registers这里1-001a是 I2C 总线和设备地址具体看你的设备。能看到寄存器 dump 之后你在用户空间设置某个控件比如把 “Speaker Playback Switch” 打开再回来 dump 一次对比对应寄存器 bit 是否变化。如果 bit 没变说明控件回调没生效问题在 Codec 驱动本身如果 bit 变了但喇叭没声就继续查寄存器值是否对应到实际模拟开关上。我踩过很深的一个坑是寄存器地址错位。某颗芯片手册写的是十六进制地址但 regmap 里reg_bits配置多了导致实际 I2C 写入地址整体左移。这时候你以为写的是 0x02实际写到了 0x10。Debugfs 里看到 0x10 变化了但 codec 的硬件功能完全不变。后来我把手册地址逐个和读到的寄存器对照才定位到是 regmap 的位宽问题。5.4 一个很容易被忽略的 pop 音问题很多次解决完无声你以为就结束了接着又开始被爆音问题折磨。Codec 的 speaker enable 和 DAC enable 之间有先后顺序尤其是带外部功放的板子如果先打开 speaker再开 DAC那电源瞬间冲击就会产生明显的“咔哒”声。用 DAPM event 可以解决。给功放 widget 配置一个 POST_PMU event在功放真正打开后延时几毫秒再放音。类似这样static int speaker_amp_event(struct snd_soc_dapm_widget *w, struct snd_kcontrol *kcontrol, int event) { if (SND_SOC_DAPM_EVENT_ON(event)) mdelay(50); return 0; } SND_SOC_DAPM_SUPPLY(Speaker Amp, SND_SOC_NOPM, 0, 0, speaker_amp_event, SND_SOC_DAPM_PRE_PMU | SND_SOC_DAPM_POST_PMD),不要小看这个 mdelay。实际硬件上有些功放的启动时间要 200ms这里给不够后面的音频数据就会在功放还没完全稳定时打进去出现前几个 ms 的杂音。这个坑靠看数据手册是能避开的但很多人不习惯仔细看 Timing Characteristics 章节。6. 我给新手的两个建议和收尾经验6.1 建议从哪些 Codec 驱动开始精读如果你想快速掌握 ASoC Codec 驱动开发我建议不要直接去看最复杂的高阶 Codec找个经典老芯片把基础吃透。wm8960 是个很好的入门选择它的 DAPM 路由不算太复杂控件结构也典型。TLV320AIC3x 系列适合理解左右声道独立寄存器和 TLV 音量。ES8316 则适合看怎么用简洁的 regmap 配置控制一颗现代低功耗 Codec。读的时候不建议从头到尾通读而是带着问题读。比如这个 Codec 的录音路径是 MIC - PGA - ADC 几步Playback 路径的 DAC 输出能不能直接到耳机还是要经过 MIXER每个开关对应的寄存器位是什么你自己能把链路图画出来才算读懂了。6.2 养成把音频链路画成表格的习惯Codec 内部通路多我强烈建议在开发初期就列一张表把每个控件名、寄存器地址、bit 偏移、所属 widget、连接关系全部列出来。列这种表不是为了发文档而是为了调音和排障时能快速建立“控件名到硬件电路”的映射关系。很多无声问题查到最后其实是左右声道接反或者某个 MIXER 没打开。6.3 最后分享一个小工具习惯我一直有一个习惯任何 Codec 驱动合入前我都会在板子上跑一遍完整的tinymix遍历和tinyplay/tinycap回环测试。不是只测一条默认链路而是把所有 MUX 组合、音量极值、开关切换都跑一遍。这个习惯帮我提前发现了不少寄存器配置错误和 DAPM route 漏配问题也让我在后续项目里少了一堆半夜被拉起来定位音频 Bug 的烦恼。音频驱动的难点从来不是单个 API 不会用而是链路太长、时序太多、你根本不知道它断在哪。ASoC 这套框架已经把能解耦的都解耦了剩下的工作实际上就是在 Codec 驱动里把控件和 DAPM 路由做扎实再加上一套可靠的验证流程。你按这个思路去做Audio 问题基本都能被时间和耐心磨出来。
返回列表