WWDC Quick Look 💓 By SwiftGGTeam
Create audio drivers with DriverKit

Create audio drivers with DriverKit

观看原视频

Highlight

macOS Monterey 引入 AudioDriverKit,硬件音频驱动可以用一个运行在用户空间的 DriverKit extension 直接连接 CoreAudio HAL,省掉 audio server plug-in、独立安装包和重启流程。

核心内容

做 macOS 音频硬件驱动,以前要维护两层代码。

一层是 audio server plug-in。它负责跟 CoreAudio HAL(Hardware Abstraction Layer,硬件抽象层)对接。另一层是 kernel extension(内核扩展,kext)或 DriverKit extension(驱动扩展,dext)。它负责跟硬件通信。

这条链路能工作,但开发者要处理 plug-in 和 dext 之间的通信。组件变多,资源占用变多,开销和延迟也可能增加。

macOS Big Sur 已经把驱动开发从内核搬到用户空间。安全性变好了,但音频驱动仍然需要 audio server plug-in 加 dext 两个组件。

macOS Monterey 给了新的选择:AudioDriverKit。它是一个 DriverKit 框架,音频驱动只需要一个 dext。框架负责处理 dext 和 CoreAudio HAL 之间的进程间通信。

安装方式也变了。AudioDriverKit extension 打包在 Mac App 里。用户安装 App 后允许驱动,系统就能动态加载 dext,不需要独立 installer package,也不需要重启。

这场 session 的重点很明确:如果你在做 USB 或 PCI 音频硬件驱动,可以把驱动模型收敛到一个用户空间 dext;如果你只做虚拟音频设备,Apple 仍然建议继续使用 audio server plug-in 模型。

详细内容

AudioDriverKit 的基本架构

02:30)CoreAudio HAL 通过 AudioDriverKit 和 driver extension 通信。AudioDriverKit 会创建一个私有 user client,供 HAL 使用。这个 user client 不暴露给你的 dext。

如果 App 需要直接和 dext 通信,可以额外打开自定义 user client。这个路径适合做厂商控制面板,例如切换设备模式或读取硬件状态。

// SimpleAudioDriver example override of IOService::NewUserClient
kern_return_t SimpleAudioDriver::NewUserClient_Impl(uint32_t in_type,
													 IOUserClient** out_user_client)
{
	kern_return_t error = kIOReturnSuccess;
	//	Have the super class create the IOUserAudioDriverUserClient object if the type is
	//	kIOUserAudioDriverUserClientType
	if (in_type == kIOUserAudioDriverUserClientType)
	{
		error = super::NewUserClient(in_type, out_user_client, SUPERDISPATCH);
	}
	else
	{
		IOService* user_client_service = nullptr;
		error = Create(this, "SimpleAudioDriverUserClientProperties", &user_client_service);
		FailIfError(error, , Failure, "failed to create the SimpleAudioDriver user-client");
		*out_user_client = OSDynamicCast(IOUserClient, user_client_service);
	}
	return error;
}

关键点:

  • NewUserClient_Impl 在客户端进程连接 dext 时被调用。
  • kIOUserAudioDriverUserClientType 是 HAL 需要的类型,交给 IOUserAudioDriver 基类创建。
  • super::NewUserClient(...) 创建 IOUserAudioDriverUserClient,AudioDriverKit 用它处理 HAL 通信。
  • 其他类型走 IOService::Create,从 dext 的 info.plist 条目创建自定义 user client。
  • OSDynamicCast(IOUserClient, user_client_service) 把创建出来的服务对象转成 IOUserClient 返回。

驱动入口:继承 IOUserAudioDriver

05:58)音频 dext 的入口是 IOUserAudioDriver 子类。它本身继承自 IOService,所以需要处理 StartStopNewUserClient 这些服务生命周期方法。

// SimpleAudioDriver example, subclass of IOUserAudioDriver

class SimpleAudioDriver: public IOUserAudioDriver
{
public:
	virtual bool init() override;
	virtual void free() override;

	virtual kern_return_t Start(IOService* provider) override;
	virtual kern_return_t Stop(IOService* provider) override;
	virtual kern_return_t NewUserClient(uint32_t in_type,
										 IOUserClient** out_user_client) override;

	virtual kern_return_t StartDevice(IOUserAudioObjectID in_object_id,
									   IOUserAudioStartStopFlags in_flags) override;

	virtual kern_return_t StopDevice(IOUserAudioObjectID in_object_id,
									  IOUserAudioStartStopFlags in_flags) override;

关键点:

  • init()free() 管理驱动对象自身的初始化与释放。
  • Start(IOService* provider) 在 dext 启动时进入,session 中用它创建 SimpleAudioDevice,再用 AddObject 加到 driver。
  • Stop(IOService* provider) 在 dext 停止时进入,用于释放设备相关资源。
  • NewUserClient(...) 负责 HAL user client 和自定义 user client 的连接入口。
  • StartDevice(...)StopDevice(...) 会在 HAL 启停某个音频设备的 IO 时被调用。

创建设备、流和音频格式

07:04IOUserAudioDevice 表示一个音频设备。设备下面挂 IOUserAudioStream。stream 使用 IOMemoryDescriptor 做音频 IO,这块内存会映射给 CoreAudio HAL。

session 的示例创建的是输入流。真实硬件驱动中,Apple 建议这块内存尽量就是硬件 DMA 使用的同一块 IO memory。

// SimpleAudioDevice::init, set device sample rates and create IOUserAudioStream object
...
    SetAvailableSampleRates(sample_rates, 2);
    SetSampleRate(kSampleRate_1);

    //	Create the IOBufferMemoryDescriptor ring buffer for the input stream
    OSSharedPtr<IOBufferMemoryDescriptor> io_ring_buffer;
    const auto buffer_size_bytes = static_cast<uint32_t>(in_zero_timestamp_period *
        sizeof(uint16_t) * input_channels_per_frame);
    IOBufferMemoryDescriptor::Create(kIOMemoryDirectionInOut, buffer_size_bytes, 0,
       io_ring_buffer.attach());

    //	Create input stream object and pass in the IO ring buffer memory descriptor
    ivars->m_input_stream = IOUserAudioStream::Create(in_driver,
                                                      IOUserAudioStreamDirection::Input,
                                                      io_ring_buffer.get());
...

关键点:

  • SetAvailableSampleRates(sample_rates, 2) 声明设备支持的采样率列表。
  • SetSampleRate(kSampleRate_1) 设置当前采样率。
  • IOBufferMemoryDescriptor::Create(...) 创建输入流使用的 ring buffer。
  • buffer_size_bytes 按 zero timestamp period、16 位采样大小、输入声道数计算。
  • IOUserAudioStream::Create(...) 创建输入方向的音频流,并把内存描述符交给 stream。

08:19)stream 创建后还不能直接工作。你还要声明它支持的格式列表,设置当前格式,再把 stream 加到设备上。

// SimpleAudioDevice::init continued
    IOUserAudioStreamBasicDescription input_stream_formats[2] = {
            kSampleRate_1, IOUserAudioFormatID::LinearPCM,
            static_cast<IOUserAudioFormatFlags>(
                    IOUserAudioFormatFlags::FormatFlagIsSignedInteger |
                    IOUserAudioFormatFlags::FormatFlagsNativeEndian),
            static_cast<uint32_t>(sizeof(int16_t)*input_channels_per_frame),
            1,
            static_cast<uint32_t>(sizeof(int16_t)*input_channels_per_frame),
            static_cast<uint32_t>(input_channels_per_frame),
            16
		    },
        ...
    }

    ivars->m_input_stream->SetAvailableStreamFormats(input_stream_formats, 2);
    ivars->m_input_stream_format = input_stream_formats[0];
    ivars->m_input_stream->SetCurrentStreamFormat(&ivars->m_input_stream_format);

    error = AddStream(ivars->m_input_stream.get());

关键点:

  • IOUserAudioStreamBasicDescription 描述 stream 的音频格式。
  • IOUserAudioFormatID::LinearPCM 表示线性 PCM。
  • FormatFlagIsSignedInteger 表示采样值是有符号整数。
  • FormatFlagsNativeEndian 表示使用平台原生字节序。
  • SetAvailableStreamFormats(...) 注册 stream 支持的格式列表。
  • SetCurrentStreamFormat(...) 设置当前格式。
  • AddStream(...) 把 stream 加入设备,HAL 才能看到它。

音量控制和自定义属性

08:50)AudioDriverKit 把音量控制也建模成音频对象。示例用 IOUserAudioLevelControl 创建一个输入音量控制,初始值是 -6 dB,范围是 -96 dB 到 0 dB。

//	Create volume control object for the input stream.
    ivars->m_input_volume_control = IOUserAudioLevelControl::Create(in_driver,
        true, -6.0, {-96.0, 0.0},
        IOUserAudioObjectPropertyElementMain,
        IOUserAudioObjectPropertyScope::Input,
        IOUserAudioClassID::VolumeControl);

    //	Add volume control to device
    error = AddControl(ivars->m_input_volume_control.get());

关键点:

  • IOUserAudioLevelControl::Create(...) 创建音量控制对象。
  • 第二个参数 true 表示这个控制可设置。
  • -6.0 是初始音量值。
  • {-96.0, 0.0} 是 dB 范围。
  • IOUserAudioObjectPropertyScope::Input 表示这个控制作用于输入 scope。
  • AddControl(...) 把控制加入设备。

09:22)每个 IOUserAudioObject 都可以带自定义属性。自定义属性需要一个 property address,里面包含 selector、scope 和 element。

// SimpleAudioDevice::init, Create custom property

IOUserAudioObjectPropertyAddress prop_addr = {
    kSimpleAudioDriverCustomPropertySelector,
    IOUserAudioObjectPropertyScope::Global,
    IOUserAudioObjectPropertyElementMain
};

custom_property = IOUserAudioCustomProperty::Create(in_driver, prop_addr, true,
    IOUserAudioCustomPropertyDataType::String,
    IOUserAudioCustomPropertyDataType::String);

qualifier = OSSharedPtr(
    OSString::withCString(kSimpleAudioDriverCustomPropertyQualifier0), OSNoRetain);
data = OSSharedPtr(
    OSString::withCString(kSimpleAudioDriverCustomPropertyDataValue0), OSNoRetain);
custom_property->SetQualifierAndDataValue(qualifier.get(), data.get());

AddCustomProperty(custom_property.get());

关键点:

  • IOUserAudioObjectPropertyAddress 定义属性地址。
  • kSimpleAudioDriverCustomPropertySelector 是自定义 selector。
  • IOUserAudioObjectPropertyScope::Global 表示全局 scope。
  • IOUserAudioCustomProperty::Create(...) 创建自定义属性对象。
  • 两个 IOUserAudioCustomPropertyDataType::String 分别指定 qualifier 和 data value 的类型。
  • SetQualifierAndDataValue(...) 写入 qualifier 和 data value。
  • AddCustomProperty(...) 把属性挂到设备上。

启动 IO,映射 stream 内存

11:18)当 HAL 要运行设备 IO 时,driver 会调用设备的 StartIO。真实硬件驱动应该在这里启动硬件 IO。示例没有硬件,所以用 timer 和 action 模拟中断与 DMA。

// StartIO
kern_return_t SimpleAudioDevice::StartIO(IOUserAudioStartStopFlags in_flags)
{
    __block kern_return_t error = kIOReturnSuccess;
    __block OSSharedPtr<IOMemoryDescriptor> input_iomd;
    ivars->m_work_queue->DispatchSync(^(){
		//	Tell IOUserAudioObject base class to start IO for the device
        error = super::StartIO(in_flags);
        if (error == kIOReturnSuccess)
        {
            // Get stream IOMemoryDescriptor, create mapping and store to ivars
            input_iomd = ivars->m_input_stream->GetIOMemoryDescriptor();
            input_iomd->CreateMapping(0, 0, 0, 0, 0, ivars->m_input_memory_map.attach());

            // Start timers to send timestamps and generate sine tone on the stream buffer
            StartTimers();
        }
    });
    return error;
}

关键点:

  • DispatchSync(...) 把启动流程放到 work queue 上同步执行。
  • super::StartIO(in_flags) 通知基类设备 IO 已启动。
  • GetIOMemoryDescriptor() 取出输入 stream 的内存描述符。
  • CreateMapping(...) 创建 IOMemoryMap,后续可以拿到 buffer 地址、长度和 offset。
  • StartTimers() 启动示例中的时间源,用来生成 zero timestamp 和测试音频数据。

更新 zero timestamp

11:57)HAL 需要 sample time 和 host time 的配对来同步 IO。IOUserAudioClockDevice 提供 UpdateCurrentZeroTimestampGetCurrentZeroTimestamp,用于原子更新和读取这个配对。

kern_return_t SimpleAudioDevice::StartTimers()
{
...
	//	clear the device's timestamps
	UpdateCurrentZeroTimestamp(0, 0);
	auto current_time = mach_absolute_time();
	auto wake_time = current_time + ivars->m_zts_host_ticks_per_buffer;

	//	start the timer, the first time stamp will be taken when it goes off
	ivars->m_zts_timer_event_source->WakeAtTime(kIOTimerClockMachAbsoluteTime,
												 wake_time,
												 0);
	ivars->m_zts_timer_event_source->SetEnable(true);
...
}

关键点:

  • UpdateCurrentZeroTimestamp(0, 0) 先清空设备当前 timestamp。
  • mach_absolute_time() 读取当前 host time。
  • m_zts_host_ticks_per_buffer 表示一个 buffer 对应的 host ticks。
  • WakeAtTime(...) 安排 zero timestamp timer 的下一次触发。
  • SetEnable(true) 启用 timer event source。

12:27)timer 触发后,示例读取当前 timestamp,按一个 buffer 的步长推进 sample time 和 host time,再写回设备。

void SimpleAudioDevice::ZtsTimerOccurred_Impl(OSAction* action, uint64_t time)
{
...
	GetCurrentZeroTimestamp(&current_sample_time, &current_host_time);
	auto host_ticks_per_buffer = ivars->m_zts_host_ticks_per_buffer;
	if (current_host_time != 0) {
		current_sample_time += GetZeroTimestampPeriod();
		current_host_time += host_ticks_per_buffer;
	}
	else {
		current_sample_time = 0;
		current_host_time = time;
	}
	// Update the device with the current timestamp
	UpdateCurrentZeroTimestamp(current_sample_time, current_host_time);

	//	set the timer to go off in one buffer
	ivars->m_zts_timer_event_source->WakeAtTime(kIOTimerClockMachAbsoluteTime,
												current_host_time + host_ticks_per_buffer, 0);
}

关键点:

  • GetCurrentZeroTimestamp(...) 读取上一次 sample time 和 host time。
  • current_host_time != 0 表示已经有锚点 timestamp。
  • GetZeroTimestampPeriod() 返回每次推进的 sample frame 数。
  • 第一次触发时,time 被用作 host time 锚点。
  • UpdateCurrentZeroTimestamp(...) 把新配对写回设备,HAL 用它运行和同步 IO。
  • 最后一行把 timer 安排到下一个 buffer 的时间点。

写入输入 buffer

13:03)示例用正弦波模拟输入音频。真实硬件驱动中,这一步通常对应 DMA 写入的音频数据。

void SimpleAudioDevice::GenerateToneForInput(size_t in_frame_size)
{
	// Fill out the input buffer with a sine tone
	if (ivars->m_input_memory_map)
	{
		//	Get the pointer to the IO buffer and use stream format information
		//	to get buffer length
		const auto& format = ivars->m_input_stream_format;
		auto buffer_length = ivars->m_input_memory_map->GetLength() /
            (format.mBytesPerFrame / format.mChannelsPerFrame);
		auto num_samples = in_frame_size;
		auto buffer = reinterpret_cast<int16_t*>(ivars->m_input_memory_map->GetAddress() +
            ivars->m_input_memory_map->GetOffset());
...
}

关键点:

  • ivars->m_input_memory_map 存在时,说明 StartIO 已经创建了内存映射。
  • m_input_stream_format 提供当前 stream 格式。
  • GetLength() 返回映射内存长度。
  • format.mBytesPerFrame / format.mChannelsPerFrame 得到单个采样的字节数。
  • GetAddress() + GetOffset() 得到可写的 buffer 起始地址。
  • reinterpret_cast<int16_t*> 对应示例中的 signed 16-bit PCM 格式。

13:30)写 buffer 时,示例读取音量控制的 scalar value,把它乘到正弦波上,再按声道数写入 ring buffer。

void SimpleAudioDevice::GenerateToneForInput(size_t in_frame_size)
{
...
	auto input_volume_level = ivars->m_input_volume_control->GetScalarValue();

	for (size_t i = 0; i < num_samples; i++)
	{
		float float_value = input_volume_level *
    sin(2.0 * M_PI * frequency *
       static_cast<double>(ivars->m_tone_sample_index) / format.mSampleRate);

		int16_t integer_value = FloatToInt16(float_value);
		for (auto ch_index = 0; ch_index < format.mChannelsPerFrame; ch_index++)
		{
			auto buffer_index =
                (format.mChannelsPerFrame * ivars->m_tone_sample_index + ch_index) %
                buffer_length;
			buffer[buffer_index] = integer_value;
		}
		ivars->m_tone_sample_index += 1;
	}
}

关键点:

  • GetScalarValue() 读取输入音量控制的增益。
  • sin(...) 生成当前 sample index 对应的正弦波采样。
  • FloatToInt16(float_value) 把浮点采样转为 16 位整数采样。
  • 内层循环按 mChannelsPerFrame 给每个声道写同一个采样值。
  • % buffer_length 处理 ring buffer 回绕。
  • m_tone_sample_index += 1 推进下一次生成的位置。

配置变更:让 HAL 先停 IO

14:02)如果要改采样率或 stream 格式,driver 不能直接改设备状态。正确流程是调用 RequestDeviceConfigurationChange

HAL 收到请求后,会通知监听者配置变更将开始。如果设备正在跑 IO,HAL 会先停掉 IO,捕获旧状态,然后调用 driver 的 PerformDeviceConfigurationChange。变更完成后,HAL 更新 IO 相关状态,通知客户端,再按需重启 IO。

// IOUserAudioClockDevice.h and IOUserAudioDevice.h

kern_return_t RequestDeviceConfigurationChange(uint64_t in_change_action,
											    OSObject* in_change_info);

virtual kern_return_t PerformDeviceConfigurationChange(uint64_t in_change_action,
											            OSObject* in_change_info);

virtual kern_return_t AbortDeviceConfigurationChange(uint64_t change_action,
													  OSObject* in_change_info);

关键点:

  • RequestDeviceConfigurationChange(...) 发起配置变更请求。
  • in_change_action 标识本次变更类型。
  • in_change_info 可以是任意 OSObject,用于传递变更上下文。
  • PerformDeviceConfigurationChange(...) 是真正允许修改设备状态的位置。
  • AbortDeviceConfigurationChange(...) 用于中止配置变更。

15:32)示例用自定义 user client 命令模拟硬件自下而上的配置变更,请求切换采样率。

kern_return_t SimpleAudioDriver::HandleTestConfigChange()
{
	auto change_info = OSSharedPtr(OSString::withCString("Toggle Sample Rate"), OSNoRetain);
	return ivars->m_simple_audio_device->RequestDeviceConfigurationChange(
        k_custom_config_change_action, change_info.get());
}

class SimpleAudioDevice: public IOUserAudioDevice
{
...
	virtual kern_return_t PerformDeviceConfigurationChange(uint64_t change_action,
												    OSObject* in_change_info) final LOCALONLY;
}

关键点:

  • OSString::withCString("Toggle Sample Rate") 创建一段变更说明。
  • OSSharedPtr(..., OSNoRetain) 管理这个 OSString
  • RequestDeviceConfigurationChange(...) 把自定义 action 和 change info 交给设备。
  • PerformDeviceConfigurationChange(...) 需要在设备子类中覆盖。

16:05)执行变更时,示例读取当前采样率,在两个采样率之间切换,然后通知 stream 设备采样率已变化。

// In SimpleAudioDevice::PerformDeviceConfigurationChange
	kern_return_t ret = kIOReturnSuccess;
	switch (change_action) {
		case k_custom_config_change_action: {
			if (in_change_info)	{
				auto change_info_string = OSDynamicCast(OSString, in_change_info);
				DebugMsg("%s", change_info_string->getCStringNoCopy());
			}

			double rate_to_set = static_cast<uint64_t>(GetSampleRate()) !=
                static_cast<uint64_t>(kSampleRate_1) ? kSampleRate_1 : kSampleRate_2;
			ret = SetSampleRate(rate_to_set);
			if (ret == kIOReturnSuccess) {
				// Update stream formats with the new rate
				ret = ivars->m_input_stream->DeviceSampleRateChanged(rate_to_set);
			}
		}
			break;

		default:
			ret = super::PerformDeviceConfigurationChange(change_action, in_change_info);
			break;
	}

	// Update the cached format:
	ivars->m_input_stream_format = ivars->m_input_stream->GetCurrentStreamFormat();

	return ret;
}

关键点:

  • switch (change_action) 按变更类型分发。
  • OSDynamicCast(OSString, in_change_info) 取回请求阶段传入的说明字符串。
  • GetSampleRate() 读取当前设备采样率。
  • SetSampleRate(rate_to_set) 修改设备采样率。
  • DeviceSampleRateChanged(rate_to_set) 让 stream 更新当前 stream format。
  • 未处理的 action 交给 super::PerformDeviceConfigurationChange(...)
  • 最后用 GetCurrentStreamFormat() 更新 driver 缓存的格式。

核心启发

  1. 做什么:为 USB 音频接口做一个伴随控制 App。 为什么值得做:AudioDriverKit extension 可以打包在 Mac App 里,用户安装 App 后加载驱动,不需要独立安装器。 怎么开始:用 IOUserAudioDriver 作为 dext 入口,把设备控制命令放在自定义 user client 中,必要时用 RequestDeviceConfigurationChange 更新采样率或 stream 格式。

  2. 做什么:给录音设备增加硬件增益和输入电平控制。 为什么值得做:session 展示了 IOUserAudioLevelControl,控制值可以在 IO path 中用于处理输入 buffer。 怎么开始:在 IOUserAudioDevice 初始化时创建 IOUserAudioLevelControl,用 AddControl 加到设备;在写入或读取 buffer 时调用 GetScalarValue() 应用增益。

  3. 做什么:为多采样率硬件做稳定的采样率切换。 为什么值得做:采样率变化会影响 IO 状态,AudioDriverKit 提供了 HAL 协调的配置变更流程。 怎么开始:先调用 RequestDeviceConfigurationChange,在 PerformDeviceConfigurationChange 中执行 SetSampleRate,再调用 stream 的 DeviceSampleRateChanged

  4. 做什么:把硬件厂商的诊断信息暴露给控制面板。 为什么值得做:AudioDriverKit 支持自定义 user client,也支持 IOUserAudioCustomProperty怎么开始:用 IOUserAudioCustomProperty::Create 定义设备属性;App 侧通过自定义 user client 和 dext 通信,读取硬件状态或触发测试命令。

  5. 做什么:构建低延迟输入设备的时间戳验证工具。 为什么值得做:HAL 依赖 sample time 与 host time 配对同步 IO,timestamp 贴近硬件时钟会影响录音稳定性。 怎么开始:在设备中维护 UpdateCurrentZeroTimestamp,记录 mach_absolute_time 与 sample time 的关系,并在控制 App 中显示 jitter 或 buffer 周期变化。

关联 Session

评论

GitHub Issues · utterances