ARTICLE DETAIL

资讯详情

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

嵌入式Linux ASoC音频驱动开发:Codec驱动与控件注册详解

嵌入式Linux ASoC音频驱动开发:Codec驱动与控件注册详解 干了几年嵌入式Linux驱动音频这一块永远是最容易让人头皮发麻的。寄存器手册厚得像砖头数据手册里一句话能让你查一整天好不容易调通I2C读写接上喇叭又没声音。后来把ASoC框架捋顺了再看Codec驱动和音频控件其实就是一个套路组件、控件、DAPM路径、DAI配置四件事而已。这篇文章我结合自己做过的几颗Codec芯片把ASoC音频驱动里最核心的控件注册与Codec驱动开发从头到尾拆一遍。内容包括框架设计思路、kcontrol控件机制的完整说明、DAPM路径的接法以及实际调试中常见的坑。适合正在做嵌入式Linux音频驱动、或者刚接触ALSA想搞懂ASoC到底在干什么的朋友。1. ASoC音频子系统的设计思路拆解1.1 为什么要拆成Codec、Platform、Machine三块早期Linux音频驱动并不是现在这个样子。那时候每个板子的音频驱动都是一个大杂烩Codec芯片的初始化、I2S控制器的寄存器配置、DMA缓冲区的申请、板级音频路径的切换全部揉在同一个文件里。换一颗Codec改板子甚至换一个DMA通道都可能把整个驱动推翻重写。大量重复代码散落在各个平台维护起来非常痛苦。ASoCALSA System on Chip就是为了解决这个问题而诞生的。它把音频驱动按职责拆成三个独立角色Codec负责音频编解码芯片本身比如音量、EQ、ADC/DAC开关、采样率配置。你写的驱动主要就是这一块。Platform负责SoC内部的音频控制器比如I2S/TDM外设、DMA引擎。它不管Codec芯片上有什么寄存器只负责把数据从内存搬到总线。Machine负责把上面两者匹配起来。它定义一个dai_link说清楚“CPU侧DAI接到Codec侧DAI使用I2S格式主从关系谁是主”。板级的喇叭、耳机、MIC的物理连接也归它管。打个比方就像一套家庭音响系统。Platform是播放器负责读碟、输出数字流Codec是功放加解码器负责把数字信号变成模拟信号驱动喇叭Machine就是那根连接线加音频切换器决定播放器该接到哪个功放、功放该去推哪个音箱。这套拆分的好处是换一颗Codec只需要写新的Codec驱动Platform和Machine基本不动换平台则只需要重写Platform层Codec驱动甚至可以被多个平台复用。这是整个ASoC架构最核心的设计理念。1.2 从播放一首歌看音频数据的实际流向理解了三个角色再看一条完整的播放路径就非常清晰了。用户在应用层调用ALSA库播放一首MP3解码之后得到PCM数据这些数据进入内核后大致经历这么几个环节Platform层的DMA把内存里的PCM数据搬运到I2S控制器的FIFOI2S控制器按设定的帧格式比如16bit、双声道、44.1kHz把数据一位一位送到总线上的Codec芯片Codec芯片的DAIDigital Audio Interface接收数据经过内部通路比如DAC转换、模拟加法器、可编程增益放大器PGA最后从SPK_OUT或HP_OUT引脚输出到喇叭或耳机。这一整条链路在ASoC里就是一组dai_link加上一组DAPM路径route。dai_link负责描述“I2S总线两端是哪两个DAI在通信”DAPM路径负责描述“Codec内部模拟信号从哪个输入走到哪个输出”。调试音频驱动绝大部分时间就是在查这条链路断在哪。对应的驱动代码里hw_params回调是所有参数协商的核心。比如Codec驱动里的hw_params函数需要根据Linux ALSA传入的采样率、声道数、格式去设置Codec内部与时钟、数据格式相关的寄存器Platform层的hw_params则设置DMA的传输格式。两边都配置好了PCM流才能真正跑起来。2. 控件kcontrol系统用户看到的旋钮和开关2.1 内核控件与用户空间的映射关系在用户空间调整音量、切换输入源、打开或关闭某个功放你操作的其实是一系列“控件”control。在ASoC框架里这些控件在内核侧就叫kcontrol每个控件有一个名字、一组属性以及读/写寄存器的回调函数。用一条命令就能看到一个声卡上注册了哪些控件。在设备终端执行amixer contents输出会像这样numid1,ifaceMIXER,namePCM Volume ; typeINTEGER,accessrw------,values2,min0,max255,step0 : values128,128 numid2,ifaceMIXER,nameSpeaker Switch ; typeBOOLEAN,accessrw------,values1 : valueson其中PCM Volume就是内核里注册的一个kcontroltype是INTEGER范围0到255双声道所以values2。audio_hw层面用tinymix操作更直观tinymix PCM Volume 200 tinymix Speaker Switch 1如果把嵌入式Linux音频驱动比作一个调音台控件就是调音台上的推子和开关。用户不需要关心底层寄存器地址是0x1F还是0x3A只需要知道“PCM Volume”这个名字内核替他把对应寄存器的位域改好。2.2 常用控件宏与参数计算ASoC提供了一组便捷宏定义控件时不需要手动写完整结构体。最常见的几个是SOC_SINGLE单个寄存器单个位域比如一个通道的音量。SOC_DOUBLE两个寄存器或同一寄存器的两个位域通常用于左右声道音量。SOC_SWITCH单个开关位控制某个通路的开与关。SOC_ENUM枚举型控件比如输入源选择、数据格式选择。拿一个典型场景举例。假设Codec芯片手册写着“Volume Register地址0x1Fbit 8到bit 12共5位控制左声道音量0表示静音0x1F表示最大”那么用SOC_SINGLE就能非常简洁地描述static const struct snd_kcontrol_new vol_controls[] { SOC_SINGLE(PCM Volume, 0x1F, 8, 0x1F, 0), };这里四个关键参数分别是控件名、寄存器地址、位偏移、最大值。最后一个参数是是否反转0表示寄存器值越大音量越大。如果芯片设计反了音量越大寄存器值越小就把这里的0改成1。位域的计算逻辑内核会帮你搞定内部调用的是snd_soc_component_update_bits(component, reg, mask, val)。mask根据位偏移和位数自动生成比如上面这个5位字段mask就是0x1F 8。所以你在驱动里哪怕写裸代码也推荐用这个函数而不是直接regmap_write因为update_bits天然就是“读-改-写”不会破坏同一寄存器里其他位域的值。SOC_ENUM稍微特殊一点它的定义包含一个soc_enum结构体和字符串数组static const char * const input_texts[] {LINE_IN, MIC_IN, AUX_IN}; static SOC_ENUM_SINGLE_DECL(input_enum, 0x20, 0, input_texts); static const struct snd_kcontrol_new input_controls[] { SOC_ENUM(Input Source, input_enum), };用户空间读到的是字符串而不是0/1/2。这个设计对应用层非常友好也减少了驱动里到处做数字映射的麻烦。2.3 get/put回调的工作细节用宏定义控件时get和put回调是由框架自动生成的内部调用了snd_soc_component_read和snd_soc_component_update_bits。但有些控件的逻辑不是简单的读写例如需要跨多个寄存器算出一个值、开关控件与后续DAPM路径联动、或者寄存器含义和用户空间数字不一致时就必须自己实现get和put回调。手动实现时关键点有三个。第一get回调的任务是读寄存器并把值按ucontrol-value.integer.value[0]的格式填回去注意如果实际寄存器值和用户空间期望的线性值不一致要做换算。第二put回调的任务是校验用户传入的值是否越界然后写寄存器并且如果写失败了要返回错误码。第三返回值有讲究写了并且值变化返回1写了但值和原来一样返回0出错返回负数。很多初学者统一return 0或return 1结果某些ALSA上层工具只能得到错误反馈表现为“控件能读不能写”或“写后自动还原”。举个例子一个去重音效开关寄存器bit31表示开启0表示关闭。用户空间约定的是布尔值static int deemp_get(struct snd_kcontrol *kcontrol, struct snd_ctl_elem_value *ucontrol) { struct snd_soc_component *component snd_kcontrol_chip(kcontrol); unsigned int val; snd_soc_component_read(component, 0x21, val); ucontrol-value.integer.value[0] !!(val (1 3)); return 0; } static int deemp_put(struct snd_kcontrol *kcontrol, struct snd_ctl_elem_value *ucontrol) { struct snd_soc_component *component snd_kcontrol_chip(kcontrol); unsigned int val, old; snd_soc_component_read(component, 0x21, old); if (ucontrol-value.integer.value[0]) val old | (1 3); else val old ~(1 3); if (val ! old) return snd_soc_component_update_bits(component, 0x21, 1 3, val (1 3)) ? 1 : -EIO; return 0; }注意snd_kcontrol_chip(kcontrol)这个用法。老版本代码里常见snd_kcontrol_chip返回的是注册控件时传入的data指针通常就是component拿到它之后再做寄存器访问。3. Codec驱动开发的完整实操3.1 第一步定义regmap和I2C挂载绝大多数Codec芯片都挂在I2C或SPI总线上先让内核能找到芯片是第一件事。以I2C接口的Codec为例驱动文件的基本骨架static const struct regmap_config codec_regmap { .reg_bits 8, .val_bits 8, .max_register 0x7F, }; static int codec_i2c_probe(struct i2c_client *i2c) { struct regmap *regmap; regmap devm_regmap_init_i2c(i2c, codec_regmap); if (IS_ERR(regmap)) return PTR_ERR(regmap); return devm_snd_soc_register_component(i2c-dev, codec_component_driver, codec_dai, 1); } static const struct i2c_device_id codec_i2c_id[] { { mycodec, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, codec_i2c_id); static const struct of_device_id codec_of_match[] { { .compatible vendor,mycodec }, { } }; static struct i2c_driver codec_i2c_driver { .driver { .name mycodec, .of_match_table codec_of_match, }, .probe codec_i2c_probe, .id_table codec_i2c_id, }; module_i2c_driver(codec_i2c_driver);这里reg_bits 8, val_bits 8表示寄存器地址和值都是8位。绝大多数小Codec芯片都是这种结构如WM8960、ES8316这类芯片基本符合。遇到寄存器地址按16位甚至更大范围寻址的芯片修改reg_bits和max_register即可。devm_snd_soc_register_component是标准注册接口第三个参数表示这个Codec带几个DAI。大多数Codec至少提供两个DAI一个给Playback一个给Capture有的芯片还有辅助DAI用于连接蓝牙或语音通道。3.2 第二步注册控件和DAPM widget组件驱动结构体需要填充控件数组和DAPM widget数组。以我们上面定义的音量控件为例完整的注册逻辑如下static const struct snd_soc_component_driver codec_component_driver { .name mycodec, .controls vol_controls, .num_controls ARRAY_SIZE(vol_controls), .dapm_widgets codec_dapm_widgets, .num_dapm_widgets ARRAY_SIZE(codec_dapm_widgets), .dapm_routes codec_dapm_routes, .num_dapm_routes ARRAY_SIZE(codec_dapm_routes), .idle_bias_on 1, };控件数组和DAPM数组需要注册到组件驱动里ASoC框架会在probe过程中自动创建对应的ALSA control对象。控件数量不能为0哪怕芯片只有一个DAC静音开关也得注册至少一个控件否则声卡创建出来之后将会几乎没有可操作的东西。DAPM widget这里先不展开下一节详细讲。现在只需要知道芯片内部所有需要电源管理的模块比如DAC、ADC、PGA、功放、偏置电压都应该用DAPM widget来描述而不是用普通控件。3.3 第三步配置DAI和PCM回调Codec驱动的DAI部分是一个snd_soc_dai_driver结构体里面最关键的是ops和capture/playback成员的配置static const struct snd_soc_dai_ops codec_dai_ops { .hw_params codec_hw_params, .set_fmt codec_set_fmt, .set_sysclk codec_set_sysclk, .mute_stream codec_mute_stream, }; static struct snd_soc_dai_driver codec_dai { .name mycodec-hifi, .playback { .stream_name Playback, .channels_min 2, .channels_max 2, .rates SNDRV_PCM_RATE_8000_48000, .formats SNDRV_PCM_FMTBIT_S16_LE, }, .capture { .stream_name Capture, .channels_min 2, .channels_max 2, .rates SNDRV_PCM_RATE_8000_48000, .formats SNDRV_PCM_FMTBIT_S16_LE, }, .ops codec_dai_ops, };rates和formats字段的作用是告诉ASoC内核这套DAI支持什么采样率和数据格式。实际运行时应用程序请求的格式如果不在这里面内核会直接返回错误根本不会调用hw_params。所以这两行的过滤条件很重要宁可写全一点也不要漏掉你在驱动的hw_params里真正支持的格式。hw_params的典型实现是根据采样率设置Codec内部的时钟分频器static int codec_hw_params(struct snd_pcm_substream *substream, struct snd_pcm_hw_params *params, struct snd_soc_dai *dai) { struct snd_soc_component *component dai-component; int rate params_rate(params); switch (rate) { case 8000: snd_soc_component_update_bits(component, 0x22, 0x07, 0x00); break; case 44100: snd_soc_component_update_bits(component, 0x22, 0x07, 0x03); break; case 48000: snd_soc_component_update_bits(component, 0x22, 0x07, 0x04); break; default: return -EINVAL; } return 0; }这段代码写的只是示例真实芯片的分频寄存器要根据MCLK频率和fsync频率计算。比如系统MCLK是12.288MHz要得到48kHz采样率BCLK通常是采样率乘位数乘通道数比如16bit双声道就是48k乘32等于1.536MHz。Codec内部还需要从MCLK分频得到Bit Clock分频系数就是12.288MHz除1.536MHz等于8。计算清楚再填寄存器不怕音频无声或跑调。3.4 第四步设备树与Machine层关联Codec驱动本身可以被注册但要让系统真正使用这颗Codec需要设备树里描述硬件连接。以常见的I2C挂载方式为例设备树片段如下i2c1 { mycodec: codec1a { compatible vendor,mycodec; reg 0x1a; #sound-dai-cells 0; }; }; sound { compatible simple-audio-card; simple-audio-card,name myboard; simple-audio-card,format i2s; simple-audio-card,bitclock-master dai_cpu; simple-audio-card,frame-master dai_cpu; dai_cpu: simple-audio-card,cpu { sound-dai i2s1; }; dai_link: simple-audio-card,codec { sound-dai mycodec; }; };设备树里compatible要跟驱动中of_match_table对应reg要跟芯片硬件地址一致。Machine层的simple-audio-card帮我们省了写Machine驱动的功夫它通过sound-dai里引用的phandle自动寻找Codec的DAI和CPU的DAI并把两者绑定成dai_link。如果不想用simple-audio-card也可以手写Machine驱动核心是构造snd_soc_dai_link数组并指定cpu_dai_name、codec_dai_name、codec_of_node或codec指针等字段。这种方式更灵活但代码量也更多。4. DAPM与音频路径驱动“通”才是硬道理4.1 DAPM控件的本质DAPMDynamic Audio Power Management动态音频电源管理是ASoC里最巧妙也最容易劝退新人的部分。它的核心思想是根据音频信号的流向自动开关音频链路中的各个模块电源。比如你只播放音乐不上录音ADC和相关模拟电路就不需要供电你把耳机拔了功放就可以休眠。DAPM控件和我们在第2节讲的普通控件不一样。普通控件只是简单映射寄存器和用户空间DAPM控件除了映射寄存器之外还要参与“电源状态”的传播。每个DAPM widget控件都有一个power状态框架通过遍历音频路径判断某个widget是否需要开启。如果路径上游有信号源、下游有终端中间所有widget都会自动开启反之则自动关闭并在关闭时调用你注册的event回调去做所谓“延迟断电”处理。常见的DAPM widget类型有SND_SOC_DAPM_INPUT外部输入引脚比如MIC、LINE_IN。SND_SOC_DAPM_OUTPUT外部输出引脚比如HP_OUT、SPK_OUT。SND_SOC_DAPM_DAC/SND_SOC_DAPM_ADC数模/模数转换器。SND_SOC_DAPM_PGA可编程增益放大器往往和音量控件配合。SND_SOC_DAPM_MUX/SND_SOC_DAPM_MUX输入源选择。SND_SOC_DAPM_SWITCH通路的开关。SND_SOC_DAPM_SUPPLY供电项不作为音频信号路径只给其他widget供电。定义DAPM widget的宏类似static const struct snd_soc_dapm_widget codec_dapm_widgets[] { SND_SOC_DAPM_INPUT(MIC_IN), SND_SOC_DAPM_INPUT(LINE_IN), SND_SOC_DAPM_DAC(DAC, Playback, 0x23, 0, NULL, 0), SND_SOC_DAPM_ADC(ADC, Capture, 0x24, 0, NULL, 0), SND_SOC_DAPM_PGA(SPK PGA, NULL, 0x25, 0, NULL, 0), SND_SOC_DAPM_OUTPUT(SPK_OUT), };注意DAC那个widget后面的Playback它和DAI里的stream_name对应表示DAC放在播放链路上。如果名字不匹配DAPM的电源传播会断掉导致有声卡设备但实际没有声音输出。4.2 把播放路径串起来的routeswidget定义好之后要用route把它们连起来。route用snd_soc_dapm_route描述每个route由三个字段组成source、sink、control。比如static const struct snd_soc_dapm_route codec_dapm_routes[] { { Input Source, MIC_IN, MIC_IN }, { Input Source, LINE_IN, LINE_IN }, { ADC, NULL, Input Source }, { DAC, NULL, ADC }, /* 这是错的只是演示说明 */ { SPK PGA, NULL, DAC }, { SPK_OUT, NULL, SPK PGA }, };注意route的方向是从信号源到信号接收者。{ ADC, NULL, Input Source }表示信号从Input Source流向ADC。NULL代表这条路径不需要开关控制如果路径经过一个MUX或SWITCH第三个字段就是对应的枚举控件名DAPM会根据用户空间选择控件状态来自动决定是否连通这条路径。一个极常见的坑MCU新手会把route顺序写反写成{ MIC_IN, NULL, Input Source }于是信号永远无法从模拟输入传到ADC录音全是静音。调试时如果波形无论如何都进不来先检查route方向。音频路径的完整度直接决定芯片能不能出声。拿播放来说数据进入Codec后至少要走DAI输入 - DAC - 模拟混音器或PGA - SPK_OUT。其中任何一环没有route连接信号就会断开。检查时可以在内核调试目录里查看整条链路cat /sys/kernel/debug/asoc/soc-name/dapm_widgets cat /sys/kernel/debug/asoc/soc-name/dapm_paths这两个文件会列出所有widget及连接关系。如果播放时某一行显示DAC: off说明DAPM没有把你需要的DAC通电。这时候要顺着route往上查看看是不是某个input信号没有“联通”导致路径根本没建立起来。4.3 DAPM事件回调与电源时序DAPM不只是一个静态的路径图widget在上下电时还会触发事件。典型场景是功放功放启动时如果DAC已经输出信号会产生POP音功放关闭时如果DAC还在播放也会突然截断产生爆音。所以功放widget往往会加上event回调在供电前后设置一个延时。定义带事件的widgetstatic int spk_pga_event(struct snd_soc_dapm_widget *w, struct snd_kcontrol *kcontrol, int event) { switch (event) { case SND_SOC_DAPM_PRE_PMD: /* 先关功放 */ snd_soc_component_update_bits(w-dapm-component, 0x26, 0x01, 0x00); break; case SND_SOC_DAPM_POST_PMU: /* 等DAC稳定后开功放 */ msleep(50); snd_soc_component_update_bits(w-dapm-component, 0x26, 0x01, 0x01); break; } return 0; } SND_SOC_DAPM_PGA_E(SPK PGA, 0x25, 0, NULL, 0, spk_pga_event, SND_SOC_DAPM_PRE_PMD | SND_SOC_DAPM_POST_PMU),这里的时序逻辑是DAPM非常值钱的地方。事件类型很多常见有PRE_PMU、POST_PMU、PRE_PMD、POST_PMD。实际项目中在POST_PMU里做延时启动在PRE_PMD里先做静音能有效解决大部分POP音问题。事件回调里允许调用msleep因为DAPM初始化是在线程上下文中执行的不必担心原子环境。5. 实战问题排查与经验记录5.1 用户空间找不到控件这个问题的出现频率最高。驱动加载正常声卡也出现了但tinymix里看不到你注册的控件名。排查顺序建议这样确认控件有没有写进组件的控件数组以及num_controls是否用了ARRAY_SIZE。少写一个控件数组入口后面所有控件都不会被注册。确认控件名是否和用户空间期望一致。内核允许重名控件但如果同一个卡里出现两个同名控件tinymix操作时可能只会操作到第一个容易产生“改了没反应”的错觉。检查设备树里Codec节点有没有#sound-dai-cells。如果这个属性缺失Machine层可能无法正确绑定Codec控件注册流程没有走完。用cat /proc/asound/card0/codec#0或cat /sys/kernel/debug/asoc/cards查看当前ASoC识别到的组件和控件状态。还有一种很迷惑的现象新版内核用DAPM控件代替了一些普通控件你在tinymix里看到的名字可能是DAPM自动生成的比如DAC Playback Volume。这种名字往往带前缀或后缀。遇到这种情况直接在/proc/asound/里搜索关键字更靠谱。5.2 无声、爆音和POP音的排查顺序没有声音是最难定位的问题因为它既可能是软件配置问题也可能是硬件线路问题。我的调试习惯是按下述顺序逐一排查不要一上来先怀疑寄存器配置第一步确认硬件通路。用示波器看I2S总线的BCLK、FSYNC、DATA波形。如果DATA完全没有波形问题在DMA和Platform层如果波形正常再去查Codec内部。第二步确认控件状态。用tinymix把所有涉及到的控件都打开音量调到最大尤其注意PCM Playback Volume和Speaker Switch这两个最常见的卡控。第三步确认DAPM路径。查看/sys/kernel/debug/asoc/.../dapm_widgets看DAC、PGA、功放是否都处于on状态。路径断点往往在这里一目了然。第四步确认Codec寄存器。通过regmap debugfs或串口打印读取关键寄存器实际值。有些控件被用户空间改了但寄存器没有变化说明regmap配置或缓存有问题。POP音和爆音的逻辑稍微不同。POP音多数是模拟偏置建立时间不足功放上电太快。这时可以拉长PGA事件回调的启动延时或者把功放放在DAPM路径的更下游让DAC先稳定。爆音往往和电源突然断开有关优化的方向是断电前先切到静音状态。5.3 regmap、时钟与缓存问题用regmap做Codec寄存器管理是大势所趋但也带来几个隐藏点。第一个是regmap cache默认是用regmap_config.cache_type控制的。如果你在probe阶段写过寄存器初始化序列后来用户空间改控件再读取时寄存器缓存和硬件真实值可能不一致。排查这类问题最简单粗暴的方法是临时把cache_type设为REGCACHE_NONE看现象是否变化。第二个是时钟问题。很多Codec在上电时需要一个稳定的MCLK信号否则I2C上哪怕寄存器写对了芯片内部PLL也锁不住采样率会乱表现为“有声音但明显变调”或者“异常抖动”。可以先用固定频率的晶振或外部信号源测试排除MCLK波动因素。系统侧确认clk_enable是否正常以及set_sysclk里的频率参数是否传对了。第三个是信号格式问题。I2S、左对齐、右对齐、DSP格式Codec侧和CPU侧必须一致。在set_fmt回调里主机/从机角色CBS/CFS、CBM/CFM也必须匹配。如果simple-audio-card,bitclock-master定义错误Codec侧会自动切换主从角色。这种问题通常表现为BCLK没有输出或连续报帧同步错误内核日志里会有ASoC: failed to start之类的报错。写在最后我自己调试Codec驱动时最大的感悟有两点。第一是ASoC框架里每个抽象都不是白做的kcontrol管用户可见的操作DAPM管电源和路径dai_link管数据连接。遇到问题先归类比盲目翻寄存器手册效率高得多。第二是音频调试一定要会看波形没有示波器就去查逻辑分析仪光靠眼睛瞪内核日志排查无声问题的效率会非常低。如果手里的芯片是常见的WM、ES、TLV、AK系列先找同系列芯片的现有驱动看结构再对着自己手册改寄存器。照着框架搭一遍比看十篇文章都管用。希望这篇笔记能帮你少走几步弯路。
返回列表