7、音频数据通路:数据从APP到AudioFlinger的旅程、BufferQueue与环形缓冲区、数据拷贝与共享内存。
各位同学,今天我们来聊聊音频数据是怎么从你的APP一路跑到AudioFlinger的。这条路,说白了就是音频系统的“高速公路”。我当年刚接触这部分时,总觉得不就是传个数据嘛,能有多复杂?结果一深入才发现,这里面的门道可真不少。
你想想看,APP在用户空间,AudioFlinger在系统服务层,中间还隔着进程边界。数据怎么过去?直接传指针?不行,不同进程的虚拟地址空间是隔离的。那就拷贝?拷贝多了性能又扛不住。嗯,Android的工程师们想了一套组合拳:BufferQueue + 共享内存 + 环形缓冲区。今天我们就把它拆开揉碎了讲清楚。
7.1 数据通路的整体架构
先看一张整体流程图,帮你建立全局观。
这张图展示了核心流程。APP通过AudioTrack.write()把数据送出来,经过BufferQueue的调度,数据被写入共享内存。AudioFlinger从共享内存中读取,再塞进内部的环形缓冲区,最后交给HAL层输出。每一步都有它的设计考量,我们逐个来看。
7.2 BufferQueue:生产者-消费者的调度器
BufferQueue这个名字,你如果做过图形系统(SurfaceFlinger)应该不陌生。音频系统也借鉴了同样的思想。它本质上是一个生产者-消费者队列,负责协调APP(生产者)和AudioFlinger(消费者)之间的数据流动。
我记得有一次排查一个音频卡顿问题,发现APP写数据的速度时快时慢,而AudioFlinger读取的节奏是固定的(比如每10ms读一次)。如果没有BufferQueue做缓冲,数据要么溢出,要么饥饿。BufferQueue在这里起到了“蓄水池”的作用。
- dequeueBuffer():APP申请一个空闲的buffer,准备写入数据
- queueBuffer():APP写完数据后,把buffer归还给队列,通知AudioFlinger
- acquireBuffer():AudioFlinger从队列中取出一个已填满的buffer
- releaseBuffer():AudioFlinger处理完后,把buffer释放回队列
这里有个细节:BufferQueue内部维护了多个buffer slot(通常是2~4个)。为什么是多个?你想想看,如果只有一个buffer,那APP和AudioFlinger就得串行工作,一个写的时候另一个必须等,延迟就上去了。多个buffer可以实现“流水线”作业——APP在写slot 1,AudioFlinger在读slot 2,互不干扰。
7.3 共享内存:跨进程的“直通车”
BufferQueue传递的是buffer的“元数据”(比如索引、状态),真正的音频数据存在哪里?答案是共享内存。Android提供了两种机制:ashmem(Android Shared Memory)和MemFd(基于memfd_create)。
说白了,共享内存就是一块物理内存,被映射到多个进程的虚拟地址空间。APP往里面写数据,AudioFlinger直接就能读到,不需要拷贝。嗯,这里要注意:虽然数据本身不需要跨进程拷贝,但BufferQueue的操作(dequeue/queue)还是需要Binder通信的。不过Binder只传递几个整数,开销很小。
AudioTrack的getBufferSizeInFrames()返回的值是共享内存的总大小,如果你写入的数据超过这个值,write()会阻塞等待。所以一定要根据采样率、位深、通道数合理计算buffer大小。
7.4 环形缓冲区:AudioFlinger内部的“传送带”
数据到了AudioFlinger这边,并不是直接丢给HAL的。AudioFlinger内部维护了一个环形缓冲区(Ring Buffer),用于解耦“从共享内存读取”和“向HAL写入”这两个操作。
环形缓冲区的工作原理很简单:一块连续的内存,用两个指针(写指针和读指针)来管理。写指针指向下一个可写入的位置,读指针指向下一个可读取的位置。当指针到达末尾时,自动回绕到开头。
// 伪代码:环形缓冲区核心逻辑
class RingBuffer {
uint8_t* buffer;
int size;
int writeIndex; // 写指针
int readIndex; // 读指针
void write(const uint8_t* data, int len) {
// 检查剩余空间
int available = (readIndex - writeIndex - 1 + size) % size;
if (len > available) {
// 阻塞等待或丢弃数据
return;
}
// 分两段写入(处理回绕)
int firstPart = min(len, size - writeIndex);
memcpy(buffer + writeIndex, data, firstPart);
memcpy(buffer, data + firstPart, len - firstPart);
writeIndex = (writeIndex + len) % size;
}
void read(uint8_t* out, int len) {
// 类似write的逻辑,从readIndex开始读取
// 更新readIndex
}
};
为什么不用链表而用环形缓冲区?性能。环形缓冲区是连续内存,CPU缓存友好,而且不需要动态分配和释放。音频数据是实时流,每一帧都不能掉,环形缓冲区的确定性行为非常适合这种场景。
mAvailable变量记录可读数据量。
7.5 数据拷贝:一次还是两次?
这是很多同学容易混淆的地方。我直接说结论:
| 场景 | 拷贝次数 | 说明 |
|---|---|---|
| 传统AudioTrack(Java层) | 2次 | APP → 共享内存(1次),AudioFlinger → 环形缓冲区(1次) |
| AAudio(C++层) | 0~1次 | APP直接写入共享内存,AudioFlinger直接引用,无拷贝 |
| OpenSL ES | 1~2次 | 取决于实现,部分场景有额外拷贝 |
你可能会问:为什么AudioFlinger不直接从共享内存读数据送给HAL,还要多一次拷贝到环形缓冲区?原因有两个:
- 时序解耦:共享内存的读写由APP控制,APP可能随时写入新数据。AudioFlinger需要自己的缓冲区来保证稳定的输出节奏。
- 混音需求:多个音频流(比如音乐+通知)需要在AudioFlinger中混音,混音结果写入环形缓冲区,再送给HAL。如果直接从共享内存读,混音逻辑会变得非常复杂。
- 对于低延迟场景(游戏、实时通信),优先使用AAudio。它绕过了Java层的拷贝,并且支持
MMAP模式,实现真正的零拷贝。 - 如果必须用Java层的AudioTrack,尽量使用
WRITE_BLOCKING模式,避免频繁的线程切换。 - 我个人的习惯:在
onCreate中预分配好buffer,不要在音频回调中做任何内存分配。
7.6 实战:一次数据通路的故障排查
最后分享一个我实际遇到的案例。有一次,某个音乐APP在播放高采样率音频(192kHz)时出现断断续续的杂音。我一开始以为是解码器的问题,后来用systrace抓了一下,发现AudioFlinger的环形缓冲区经常处于“空”状态。
进一步分析发现,APP使用AudioTrack.write()写入的数据块太小(每次只写64帧),而AudioFlinger每10ms才读取一次。对于192kHz,10ms就是1920帧。APP写64帧,AudioFlinger读1920帧,这中间差了30倍!环形缓冲区很快就空了,导致输出静音。
解决方案:让APP每次写入至少一个“周期”的数据(比如1920帧),或者在AudioTrack配置时增大bufferSizeInFrames。嗯,从那以后我养成了一个习惯:凡是高采样率的音频流,一定先算好buffer大小再动手。
好了,这一章的内容就到这里。数据通路是音频系统的骨架,理解了它,你就能明白为什么有些APP延迟低、有些卡顿。下一章我们会深入AudioFlinger的线程模型,看看数据到了之后是怎么被处理和混音的。
公众号:蓝海资料掘金营,微信deep3321