macOS的音频栈从上到下大致分为应用层框架(AVFoundation、Core Audio的高层API)、中间的服务层以及最贴近驱动的Hardware Abstraction Layer,也就是我们常说的HAL。做音频工具、驱动调试助手或者专业声卡控制软件时,经常需要绕开上层封装,直接跟硬件驱动的属性打交道,比如枚举真实设备、切换采样率、监听耳机插拔。这篇文章就围绕这些底层需求,把Core Audio HAL的关键API和事件监听机制完整梳理一遍。

一、Core Audio HAL的对象模型与设备枚举
HAL层的一切操作都建立在AudioObject之上,系统里的每一个音频设备、每一个子流(Stream)乃至整个系统本身,都是一个AudioObject,用AudioObjectID标识。其中有一个特殊的ID叫kAudioObjectSystemObject,值为1,代表整个音频系统,它是所有枚举操作的起点。
要拿到当前系统里所有的音频设备,需要读取系统对象上的kAudioHardwarePropertyDevices属性。这个属性返回的是一个AudioObjectID数组。核心代码如下:
#include <CoreAudio/CoreAudio.h>
#include <stdio.h>
void ListAllAudioDevices(void) {
AudioObjectPropertyAddress addr = {
kAudioHardwarePropertyDevices,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
UInt32 size = 0;
// 第一次查询拿到需要的数据大小
OSStatus status = AudioObjectGetPropertyDataSize(
kAudioObjectSystemObject, &addr, 0, NULL, &size);
if (status != noErr) {
printf("GetPropertyDataSize failed: %d\n", (int)status);
return;
}
UInt32 count = size / sizeof(AudioObjectID);
AudioObjectID devices[count];
status = AudioObjectGetPropertyData(
kAudioObjectSystemObject, &addr, 0, NULL, size, &size, devices);
if (status != noErr) {
printf("GetPropertyData failed: %d\n", (int)status);
return;
}
for (UInt32 i = 0; i < count; i++) {
CFStringRef name = NULL;
AudioObjectPropertyAddress nameAddr = {
kAudioDevicePropertyDeviceNameCFString,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
UInt32 nameSize = sizeof(name);
AudioObjectGetPropertyData(devices[i], &nameAddr,
0, NULL, &nameSize, &name);
if (name) {
char buf[256];
CFStringGetCString(name, buf, sizeof(buf), kCFStringEncodingUTF8);
printf("Device %u: %s\n", (unsigned)devices[i], buf);
CFRelease(name);
}
}
}
这段代码体现了HAL API的标准调用模式:先构造AudioObjectPropertyAddress结构体描述要访问的属性,再通过AudioObjectGetPropertyDataSize查询数据长度,最后用AudioObjectGetPropertyData取数据。注意mElement字段在新版SDK里推荐用kAudioObjectPropertyElementMain,旧的kAudioObjectPropertyElementMaster虽然还能编译通过,但已经被标记为过时。
拿到设备ID之后,还可以进一步区分输入输出方向。读取kAudioDevicePropertyStreams配合kAudioDevicePropertyScopeInput或kAudioDevicePropertyScopeOutput,就能知道这个设备支持哪些方向的子流。聚合设备(Aggregate Device)在HAL层也是一个普通ID,只是它由多个真实设备组合而成,这也是不少专业软件做多设备同步录制的基础。
二、读取与设置硬件属性:采样率、时钟源与数据格式
与高层API不同,HAL层读写属性是直接作用到硬件驱动上的。以采样率为例,读取时查询kAudioDevicePropertyNominalSampleRate,设置时用AudioObjectSetPropertyData写入。设置之前,通常要先读取kAudioDevicePropertyAvailableNominalSampleRates确认硬件支持哪些档位,直接写一个设备不支持的采样率会返回错误码。
OSStatus SetDeviceSampleRate(AudioObjectID device, Float64 rate) {
AudioObjectPropertyAddress addr = {
kAudioDevicePropertyNominalSampleRate,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
return AudioObjectSetPropertyData(
device, &addr, 0, NULL, sizeof(rate), &rate);
}
Float64 GetDeviceSampleRate(AudioObjectID device) {
Float64 rate = 0;
UInt32 size = sizeof(rate);
AudioObjectPropertyAddress addr = {
kAudioDevicePropertyNominalSampleRate,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
AudioObjectGetPropertyData(device, &addr, 0, NULL, &size, &rate);
return rate;
}
采样率变更不是瞬时完成的,硬件需要重新锁定时钟,所以在设置之后马上读回来,可能还是旧值。正确的做法是先注册采样率变更监听器,设置属性,然后等回调通知确认新采样率生效后再继续后续操作。这是很多初学者容易踩的坑,逻辑上看起来没问题,实际上读到了过渡期的脏数据。
除了采样率,kAudioDevicePropertyClockSource也很常用,尤其是多接口的专业声卡。它决定设备内部的时钟基准,做多设备同步时通常要把主设备的时钟源设为内部时钟,从设备设为外部时钟,否则会出现周期性的爆音。读写方式和采样率完全一致,只是数据类型换成了UInt32的索引值,具体含义需要配合kAudioDevicePropertyClockSources列表来解析。
三、监听设备插拔与默认设备切换
事件监听是HAL开发的核心能力。原理是给某个AudioObject的某个属性注册AudioObjectPropertyListener回调函数,当该属性变化时,系统会在自己的内部线程里调用这个回调。设备插拔对应的就是系统对象上的kAudioHardwarePropertyDevices属性变化——设备列表变了,自然意味着有设备加入或移除。
static OSStatus DevicesChangedCallback(
AudioObjectID inObjectID,
UInt32 inNumberAddresses,
const AudioObjectPropertyAddress *inAddresses,
void *__nullable inClientData) {
// 注意:此回调运行在Core Audio的内部线程,
// 不要在这里做耗时操作或同步UI更新
for (UInt32 i = 0; i < inNumberAddresses; i++) {
if (inAddresses[i].mSelector == kAudioHardwarePropertyDevices) {
printf("设备列表发生变化,可能是设备插入或拔出\n");
// 这里应该重新枚举设备并做diff,
// 找出新增或移除的具体设备
}
}
return noErr;
}
void StartWatchingDevices(void) {
AudioObjectPropertyAddress addr = {
kAudioHardwarePropertyDevices,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
AudioObjectAddPropertyListener(
kAudioObjectSystemObject, &addr,
DevicesChangedCallback, NULL);
}
回调本身只告诉你列表变了,想知道是哪个设备插入、哪个设备拔出,需要在回调里重新枚举一遍设备列表,与之前缓存的列表做差集比较。通过对比新增的ID,再判断它是不是耳机(可以查kAudioDevicePropertyTransportType,看传输类型是USB还是内置),就能实现类似系统那种自动切换输出设备的行为。
默认输出设备切换对应的是kAudioHardwarePropertyDefaultOutputDevice。注意这个属性挂在系统对象上而不是具体设备上,注册监听时传的ID必须是kAudioObjectSystemObject,挂错了对象注册会直接失败,返回kAudioHardwareBadObjectError。回调触发后重新读取这个属性就能拿到新的默认设备ID。
还有一个非常实用的技巧:对每个具体设备监听kAudioDevicePropertyStreamFormat,可以捕获单个设备的格式变更,比如用户在音频MIDI设置里手动调整了采样率,或者某些USB DAC自己报告格式变化。结合前面提到的采样率写入确认逻辑,就能构建一个完整的设备状态同步机制。
四、回调线程的限制与工程实践
属性监听回调运行在Core Audio自己的实时性较高的线程上,这一点必须牢记。回调里绝对不能做这些事情:阻塞等待(锁、信号量)、分配大量内存、调用AppKit做UI更新、执行耗时的字符串格式化。标准做法是回调里只做最小化的信息提取,然后通过GCD把事件派发到串行队列,在队列里再做设备重枚举、diff和UI通知。
static OSStatus SafeDevicesChangedCallback(
AudioObjectID inObjectID,
UInt32 inNumberAddresses,
const AudioObjectPropertyAddress *inAddresses,
void *inClientData) {
dispatch_queue_t queue = (dispatch_queue_t)inClientData;
// 只做事件派发,不做实际处理
dispatch_async(queue, ^{
ReenumerateAndDiffDevices();
UpdateUserInterface();
});
return noErr;
}
另一个常见问题是进程生命周期的清理。程序退出前记得调用AudioObjectRemovePropertyListener注销回调,否则某些场景下系统可能在对象已销毁后仍尝试调用你的函数指针,造成偶发崩溃。同样,如果监听器和设备对象之间有强引用关系,注意避免循环引用导致的泄漏。
最后提醒一点,从较新的macOS版本开始,如果应用只是想正常录音播放,优先考虑AVAudioEngine这类高层接口,它们内部同样是走HAL,但省去了大量属性管理的细节。直接操作HAL的价值在于需要精细控制硬件行为、做诊断工具或者与自研驱动联调的场景。选对层次,才能让代码既强大又好维护。
Core AudioAudioObjectGetPropertyData音频驱动开发修改时间:2026-09-13 11:12:40