ARTICLE DETAIL

资讯详情

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

深度解析ALSA音频驱动PCM Write数据传递链路:从writei到DMA

深度解析ALSA音频驱动PCM Write数据传递链路:从writei到DMA 排查Linux下音频播放卡顿的问题十个里有八个最终都会绕到ALSA的PCM数据通道上来。加上这几年做嵌入式音频产品我几乎每周都要翻一遍sound/core/pcm_lib.c的代码。如果你也在接触ALSA音频驱动看完了官方文档和内核代码之后对snd_pcm_writei的底层流程仍然似懂非懂——明明就是一次普通的write为什么中间要经过这么多次状态切换和指针更新搞懂这条链路对排查underrun、爆音、采样率不匹配这类问题帮助非常大。这一篇就把ALSA音频驱动里的PCM Write数据传递过程完整拆开从应用层调用一路讲到DMA把数据搬进声卡。1. PCM数据从应用走到声卡的完整路径1.1 一条write调用背后的五层接力在Windows上大家搞ASIO、搞WASAPI到了Linux下不管上面跑的是PulseAudio、PipeWire还是JACK最终落到硬件这一层基本都是ALSA在扛。应用想要播放一段音频最直接的方式就是调用libasound也就是alsa-lib提供的snd_pcm_writei把PCM帧数据交出去。但从这个函数到声卡真正发出声音数据会经历一条非常明确、职责分层的传递链。我平时画给新人的路径是这样的应用层调用 snd_pcm_writei() - alsa-lib 内部封装通过 ioctl(SNDRV_PCM_IOCTL_WRITEI_FRAMES) 陷入内核 - 内核 ALSA 核心的 snd_pcm_common_ioctl() 分发处理 - snd_pcm_lib_write() / snd_pcm_lib_writev() 进入数据搬运逻辑 - snd_pcm_lib_write1() 主循环计算水位、拷贝数据、更新指针 - 驱动实现 ops-trigger()启动 DMA 控制器这条链路上每一层只干一件事。alsa-lib负责把用户空间乱七八糟的音频参数格式、声道、采样率、访问方式转换成内核能识别的结构体内核ALSA核心负责状态机管理、内存分配、指针跟踪和策略控制而具体到“数据怎么从内存搬运到DMA控制器”则完全靠声卡驱动自己实现的那几个回调。很多人在看代码的时候会困惑数据到底是在哪个环节进入DMA缓冲区的答案是transfer那一步。ALSA核心把数据从用户空间的临时buffer往内核态的环形缓冲区ring buffer里面拷贝DMA控制器再从这个环形缓冲区里把数据拉走。两边是独立工作的应用写应用的速度DMA搬DMA的速度中间靠环形缓冲区解耦。1.2 为什么一定要有环形缓冲区环形缓冲区这个设计初看觉得绕但实际想过一遍就明白它是ALSA里最精妙的一块。音频数据是连续流的声卡DMA必须是连续不断地按照采样率去消费数据而应用层写入数据却是突发的、块状的。如果让两边直接对接要么硬件等数据的时候出现空隙underrun要么应用一次写太快把硬件冲垮。环形缓冲区解决的就是这个问题。它从物理上是一个固定大小的内存区域但在逻辑上首尾相接像一个轮子。声卡DMA一边转应用一边往里写。DMA消费数据的位置叫hw_ptr硬件指针应用写入数据的位置叫appl_ptr应用指针两个指针把缓冲区切成了“可写区”和“待播放区”两部分。举个例子采样率44100Hz、16bit、双声道的PCM流每秒钟要消费176400字节。如果硬件中断每处理一个period比如4096帧就通知一次应用应用有足够的时间把后续数据填进去音频就不会断。这个中间缓冲的时间窗口就是buffer_size和period_size这两个参数共同决定的。buffer给得越大抗突发能力越强但延迟也越高period给得越大中断次数越少CPU占用率越低但延迟同样会变大。我一直和硬件同事说调音频延迟就是调这两个数的博弈任何方案都不可能同时做到极低延迟和极高容错。1.3 两个指针与水位计算理解了环形缓冲区就理解了一半ALSA。剩下的核心就是看ALSA怎么根据两个指针决定自己能不能继续写数据、能写多少。播放方向ALSA计算可写空间的公式是avail buffer_size - (hw_ptr - appl_ptr) // 对 buffer_size 取模其中hw_ptr是DMA已经消费到的帧位置appl_ptr是应用已经写入的帧位置。hw_ptr往前转可写空间变大appl_ptr往前转可写空间变小。当avail接近0说明缓冲区快被写满了应用必须等DMA消费一批数据之后才能继续写。这句看起来简单实际代码里要处理的边界情况很多。最典型的就是指针回绕hw_ptr和appl_ptr都在0到buffer_size之间循环直接相减会有负数问题。内核里snd_pcm_playback_avail这个函数的处理方式是先算差值再按帧对齐核心代码如下static snd_pcm_uframes_t snd_pcm_playback_avail(struct snd_pcm_runtime *runtime) { snd_pcm_uframes_t hw_ptr READ_ONCE(runtime-status-hw_ptr); snd_pcm_uframes_t appl_ptr READ_ONCE(runtime-control-appl_ptr); if (appl_ptr hw_ptr) return runtime-buffer_size - appl_ptr hw_ptr; else return hw_ptr - appl_ptr; }我第一次认真读这段代码的时候还在想为什么不直接用减法然后取模后来发现如果直接用snd_pcm_uframes_t的无符号减法然后对buffer_size取模其实也能得到同样的结果。但内核里特意分情况讨论主要是为了可读性和避免一些编译器优化带来的未定义行为。这个函数在用户空间每次writei时都会被调用到如果你的驱动或者应用层频繁跑这个路径它的性能影响不可忽视。2. 内核态write路径的核心机制2.1 snd_pcm_lib_write1入口与状态检查数据真正进入内核的写路径是从snd_pcm_lib_write和snd_pcm_lib_writev这两个函数开始的。前者对应线性缓冲interleaved后者对应分散缓冲non-interleaved。两者最后都会收敛到snd_pcm_lib_write1只是传入的transfer回调不同。snd_pcm_lib_write1干了三件大事状态合法性检查、等待可写空间、调用transfer搬运数据。状态机的检查是第一步。ALSA的PCM有很严格的状态转移规则如果substream当前不处于PREPARED或RUNNING状态写操作会被直接拒绝。这个严格是有道理的——试想一下如果硬件已经进入停止状态应用还在死命往里写数据这些数据永远不会被播放只会越积越多最后导致内存浪费和逻辑混乱。代码里的检查逻辑类似这样if (substream-stream ! SNDRV_PCM_STREAM_PLAYBACK) return -EINVAL; if (!(runtime-state SNDRV_PCM_STATE_PREPARED || runtime-state SNDRV_PCM_STATE_RUNNING)) return -EBADFD;这里有一个比较容易忽略的点snd_pcm_lib_write1允许在PREPARED状态下直接写数据这其实是一种优化。应用在调用snd_pcm_prepare之后、snd_pcm_start之前可以先写入一部分数据内核把数据写进缓冲区之后再根据start_threshold的设定决定什么时候真正触发硬件启动。这种做法在低延迟场景里很常见因为可以保证硬件一开始跑环形缓冲区里已经有一定量的数据垫底减少起播瞬间的underrun风险。2.2 transfer函数如何把数据拷贝进DMA区transfer是整个数据通道的手指。snd_pcm_lib_write1内部会循环调用transfer回调直到把用户期望的frames全部写入或者出错为止。默认的transfer实现是snd_pcm_lib_write_transfer。它的核心任务可以总结为一句话根据appl_ptr当前的位置找到环形缓冲区里对应的内存偏移然后把用户空间的数据拷贝进去。代码逻辑简化后是这样的static int snd_pcm_lib_write_transfer(struct snd_pcm_substream *substream, unsigned long hwoff, void *buf, unsigned long frames) { struct snd_pcm_runtime *runtime substream-runtime; int err; char __user *data (char __user *)buf; // 分两段处理处理环形缓冲区回绕 while (frames 0) { unsigned long frames1 frames; unsigned long off frames_to_bytes(runtime, hwoff); if (hwoff frames1 runtime-buffer_size) { frames1 runtime-buffer_size - hwoff; } if (substream-ops-copy_user) err substream-ops-copy_user(substream, 0, off, data, frames_to_bytes(runtime, frames1)); else err copy_from_user(runtime-dma_area off, data, frames_to_bytes(runtime, frames1)); if (err 0) return err; data frames_to_bytes(runtime, frames1); hwoff (hwoff frames1) % runtime-buffer_size; frames - frames1; } return 0; }注意这里的分段处理当hwoff加上本次要写的frames超过了buffer_size就必须把一次拷贝拆成两段先写缓冲区末尾的内存再写缓冲区开头的内存。这个回绕处理是ring buffer最容易写错的地方。我在Review过不少声卡驱动的patch有相当一部分新人会在这里漏掉回绕导致缓冲区末尾写不进去或者覆盖头部数据。另外一个重点是copy_user和copy_from_user的分流。驱动如果实现了copy_user回调框架就会走驱动自己定义的路径这给了一些特殊硬件很大的灵活性。比如有些DSP声卡不希望数据落在普通内存里而是希望直接写进DSP的mbox寄存器有些低延迟驱动希望自己做内存屏障管理。这些需求都能通过自定义copy_user回调来实现。如果没有特殊需求默认的copy_from_user直接拷贝效率其实也不差因为dma_area是内核线性地址CPU cache一致性由DMA API来保证。2.3 什么时候真正触发硬件启动写数据写得再快硬件不动声音也出不来。snd_pcm_lib_write1在写完数据之后会检查是否需要触发硬件启动这个检查基于start_threshold。start_threshold的含义是当环形缓冲区中的数据量达到多少帧时自动调用snd_pcm_start启动硬件。默认情况下很多驱动把start_threshold设为1这意味着应用只要写了一个周期的数据硬件就会立刻开始DMA搬运。这样的好处是起播快延迟低坏处是如果应用每次写入的数据量不均匀很容易在启动瞬间出现短暂的buffer水位不足。代码里的逻辑是if (substream-runtime-state SNDRV_PCM_STATE_PREPARED snd_pcm_playback_hw_avail(runtime) runtime-start_threshold) { err snd_pcm_start(substream); if (err 0) return err; }这里有个很实用的经验如果你在产品里遇到起播爆音但播起来之后一切正常可以尝试把start_threshold调大比如设为period_size或者buffer_size的一半让硬件等缓冲区积攒了足够的数据再启动。代价是起播延迟会增加几十毫秒但对很多嵌入式语音提示、游戏音效这类场景完全可接受。3. 硬件循环中的DMA搬运与中断反馈3.1 DMA环形缓冲区的内存布局上面一直在讲数据往环形缓冲区里填那这个环形缓冲区到底是怎么分配的内存在哪里这涉及到ALSA中非常核心的内存管理机制。ALSA提供了一套pcm内存分配接口驱动通常在hw_params回调里通过snd_pcm_lib_malloc_pages或者更高级的snd_pcm_set_managed_buffer来分配DMA缓冲区。底层实际上走的是dma_alloc_coherent或者dma_alloc_noncoherent要求分配出来的内存物理连续传统PCI声卡或者支持SG模式现代多数SoC声卡。这块内存就是环形缓冲区的物理载体它的虚拟地址会映射到runtime-dma_area同时物理地址会传给DMA控制器作为搬运源地址。这里最需要注意的是内存对齐和size约束。DMA缓冲区大小通常需要是period_size的整数倍而且字节数不能超过硬件DMA控制器的寻址限制。很多SoC内置的音频控制器有最大突发粒度限制比如I2S控制器一次DMA传输最大只支持16KB那你的period_size换算成字节数就不能超过这个值。否则DMA配置直接失败驱动报错的时候还不好查。我遇到过不止一次问题不在驱动逻辑而在内存参数没对上硬件规格。3.2 中断来了以后snd_pcm_period_elapsed做了什么当DMA搬完一个period的数据硬件会产生中断。驱动在中断处理函数里要做的事情很短促调用snd_pcm_period_elapsed告诉ALSA核心“我又消费了一个period的数据”。snd_pcm_period_elapsed做的事情可以拆成三步第一步更新hw_ptr第二步检查是否发生XRUN第三步唤醒等待在poll/wait队列上的进程。这三步中最关键的是第一步因为hw_ptr的更新直接影响应用下一次write时计算avail的结果。我在调试一个PCIe音频卡时遇到过非常诡异的现象音频播放几分钟后声音出现周期性卡顿间隔非常固定。后来通过ftrace跟踪发现硬件的DMA中断每次都正常产生但是snd_pcm_period_elapsed里更新的hw_ptr偶尔会比预期少一个period。原因是硬件在特定条件下会跳过最后一次DMA传输的中断导致驱动少调用一次period_elapsed。这种问题很难从软件层面修复最后是通过把DMA中断设置为每个period的末尾强制触发一次来解决。另外要提一个常见误区snd_pcm_period_elapsed不能和驱动自旋锁一起使用。这个函数内部会调用唤醒操作可能触发调度如果你在调用它的时候还持有spinlock很容易造成死锁或者内核崩溃。正确的做法是把中断处理程序里获取硬件状态、读DMA指针这些操作放在锁内完成释锁之后再调用snd_pcm_period_elapsed。3.3 指针回绕与边界处理细节环形缓冲区指针回绕在正常运行时处处可见。DMA把缓冲区头部的数据消费完之后hw_ptr会从buffer_size - 1跳到0继续消费缓冲区开头的数据。这个逻辑本身很简单但麻烦在于如果在回绕瞬间同时发生应用写入和硬件消费两个指针的更新顺序很容易引发竞态。ALSA核心处理这个问题的手段是hw_ptr和appl_ptr分别存储在不同的内存区域一个在status结构体里由内核/中断更新一个在control结构体里由应用/内核进程上下文更新两者都使用原子读写。在snd_pcm_lib_write1里计算avail的时候会先读hw_ptr再读appl_ptr而在中断处理里更新hw_ptr的时候会先写hw_ptr再唤醒。这个顺序保证了任何时刻读到的指针要么是旧值要么是新值不会出现中间状态。不过在使用DMA的驱动里还有一层硬件层面的同步问题。有些DMA控制器写内存缓冲区时不是按严格顺序的CPU读取dma_area里的数据时可能读到未完成的数据。标准的解决方案是在DMA传输完成中断里调用dma_sync_single_for_cpu或者dma_map_single时设置DMA_FROM_DEVICE方向。在AC97、HDA这类经典控制器上这些问题处理得比较多而新的SoC集成的音频DMA通常已经保证了缓存一致性但作为驱动开发者做一次显式同步总归更稳妥。4. 实战排查Write路径的常见坑4.1 underrun问题怎么定位所有音频驱动问题里underrun也叫XRUN是出现频率最高的。表现形式就是播放声音出现咔哒啪嗒的爆音严重的时候直接静音。underrun的根源很简单DMA消费数据的速度快于应用填充数据的速度环形缓冲区被搬空了硬件无数据可播。定位underrun的第一手信息在ALSA的proc节点里。每个PCM substream都有对应的状态文件路径是/proc/asound/card0/pcm0p0/sub0/status正常播放时内容类似state: RUNNING hw_ptr : 123456 appl_ptr: 124000如果发生了underrunstate会变成XRUNhw_ptr和appl_ptr会停在某个位置不再前进。看到这个基本可以确认问题出在数据供给跟不上DMA消费。但更常遇到的情况是状态看起来一直是RUNNING不定时爆音proc节点很难抓到现场。这时候最好的办法是打开内核的PCM调试开关。在编译内核时开启CONFIG_SND_PCM_XRUN_DEBUG然后在运行时通过debugfs或者proc接口打开xrun跟踪内核会在发生xrun时打印详细的指针信息和调用栈比盲猜高效得多。实际项目中underrun的根因往往不在驱动而在用户空间。比如应用的写入buffer设置太小、调度延迟过高、PulseAudio的TCP传输阻塞等等。我会先用上一节提到的方法确认数据确实没跟上再去查应用层不要一上来就怀疑驱动。4.2 snd_pcm_period_elapsed调用时机为什么是个雷区前面提过snd_pcm_period_elapsed不能在持锁状态下调用这里还想再展开一个更隐蔽的问题它不能被调用两次。DMA中断里最典型的错误是中断标志还没有被清除驱动就调用了一次snd_pcm_period_elapsed然后中断处理结束硬件又立即触发一次新的中断同一个period被上报了两次。这样hw_ptr会被过度推进ALSA核心会把appl_ptr到hw_ptr之间的距离算成负值导致avail计算异常应用随之陷入写数据失败的循环。判断一个period是否已经上报过标准做法是在中断里读取DMA控制器的当前传输位置然后和驱动自己保存的last_period位置进行比较确认确实跨过了period边界才调用。有些硬件有中断状态寄存器可以精确读取每个period的完成状态没有的话就要用提交给DMA的buffer描述符状态来判断。这块逻辑是驱动最容易出bug的地方我在code review的时候会非常仔细地查。4.3 观察指针的调试手段调试ALSA的PCM数据通道最直观的方式是观察hw_ptr和appl_ptr的演进。除了proc节点Linux内核还提供了一系列tracepoint可以用来记录这两个指针的变化。常用的tracepoint事件包括snd_pcm_hw_ptr snd_pcm_appl_ptr snd_pcm_xrun snd_pcm_period_elapsed配合trace-cmd或者perf可以这样抓trace-cmd record -e snd_pcm_hw_ptr -e snd_pcm_appl_ptr aplay -D hw:0,0 test.wav trace-cmd report抓出来的trace可以清晰看到hw_ptr每经过一个period之后跳变一次appl_ptr则随着应用写入步进。如果发现hw_ptr长时间不动说明DMA没有搬运如果appl_ptr长时间不动且hw_ptr一直在动说明数据卡死在应用层。我在一个HDMI音频项目里用这个办法定位过一个问题声卡输出的声音每隔几秒就断一下从trace里看appl_ptr每跨过一个buffer_size就会停一下等了将近200毫秒才继续前进。查到最后是用户空间的一个多线程音频服务加了不必要的同步等待跟驱动毫无关系。指针观察法在这种跨层问题排查里非常好用。5. 性能优化与进阶扩展5.1 write和mmap怎么选ALSA除了writei这套接口还提供mmap模式。mmap模式下应用直接把环形缓冲区映射到用户空间写数据不经过系统调用而是直接写内存写完通过snd_pcm_avail_update和snd_pcm_forward这类函数通知内核更新appl_ptr。这样省掉了每次write的ioctl和copy_from_user开销延迟更低CPU占用也更小。mmap模式适合低延迟音频应用场景比如专业DAW、实时音频效果器、游戏引擎音频。JACK默认就是用mmap模式操作ALSA设备。但mmap模式也有代价应用自己管理数据写入的时机内核不再帮应用做state检查写错了就很容易产生脏数据或者XRUN。另外不是所有驱动都支持mmap需要实现ops-mmap回调并且DMA缓冲区必须是可映射的。对于简单产品或者驱动开发入门先用好writei把整条链路摸透了再考虑上mmap。5.2 参数配置对延迟的影响音频延迟的构成是很多人容易忽略的。从应用产生一个音频块到声音从喇叭出来延迟大致等于传输延迟 环形缓冲区积累延迟 DSP/控制器处理延迟 DA转换延迟其中环形缓冲区积累延迟和参数配置直接相关。假设buffer_size设为4096帧采样率44100Hz那么缓冲区理论上最多积压4096 / 44100 92.9毫秒的数据。如果应用在缓冲区快满的时候才写入一批数据实际延迟可能逼近这个上限。所以低延迟配置的思路是尽量减小buffer_size同时让start_threshold保持较小值让硬件及早启动。但buffer_size减小会使得中断频率升高CPU负担增大系统的调度压力也成倍上升。如果系统里还有其他高负载任务反而容易造成周期性underrun。折中下来我一般建议先以period_size为256或512帧起步实测后再往小调。5.3 从ALSA到ASoC/DPCM文章到这里核心讲的还是标准ALSA框架下的PCM Write路径。但在现代嵌入式平台实际驱动开发大部分都在ASoC框架下进行ASoC在最下面多了一层component、dai、platform、codec的抽象。数据传递本质上仍然是ALSA的PCM核心在管只不过DMA的申请、启动、指针读取等具体操作被封装成了platform driver里的dai ops和pcm ops。DPCMDynamic PCM则更进一步允许PCM流在不同后端之间动态路由比如一个前端PCM可以动态连接到不同的codec后台上。在这种复杂拓扑下PCM Write的数据路径没有变但多了一层BE/FE之间的数据流转和控制逻辑。如果你做的是手机、平板、智能音箱这类产品大概率会接触到ASoC和DPCM。那时候再回来看这篇说的PCM Write基本路径会发现底层原理通用性很强只是在上面叠了更多业务逻辑层。我个人在实际调试中最大的体会是驱动程序百分之七八十的时间不是在写新功能而是在用trace和proc观察指针、分析状态机、排查跨层交互。把PCM Write这条主链路彻底吃透等于拿到了分析所有音频问题的钥匙。这次的第六篇先把数据通路梳理清楚后续篇章我准备接着讲ALSA的中断处理和ASoC框架下的DPCM路由到时候又会有不少实战案例可以聊。
返回列表