简介:这份资源面向 Linux 与 Android 嵌入式驱动开发者,聚焦 USB 复合设备场景,解决单一 USB 接口同时枚举出音频与串口功能的问题。其核心是基于 legacy 方式实现 UAC1 与 CDC ACM 的复合驱动,开机即可自动枚举为 USB Audio 与 UART,无需在 Linux 下额外配置 usb_gadget 脚本,也无需在 Android 的 init.rc 中做功能声明;若偏好脚本方式,只需不编译 legacy 驱动,并在 USB 配置脚本中声明 acm、uac1 功能即可。驱动基于 RK 平台实现,理论上全平台通用,适合具备一定内核与 USB Gadget 基础的开发者参考移植。压缩包共 12 个文件,约 41KB,包含 4 个 Kconfig、4 个 Makefile、2 个 C 源文件及 2 份 rk3308_linux_defconfig,覆盖编译配置、驱动实现与内核配置模板,便于直接集成到工程中。目前已有 1010 人学习下载,可作为 UAC 与 CDC 复合设备开发的实用参考。
1. USB Audio + CDC 复合设备驱动:一个接口干两件事,到底怎么落地
做过 USB 音频产品的工程师大概率遇到过这种需求:设备既要当声卡用,又要通过串口和上位机通信。比如一个带 DSP 处理的麦克风阵列,音频流走 USB Audio 类,同时还要用 CDC 虚拟串口上报增益、滤波器参数、固件版本这些控制信息。如果分成两个 USB 设备,用户得插两根线,体验直接翻车。这时候就需要 USB Audio + CDC 复合设备驱动——在一颗 MCU 上同时枚举出音频接口和通信接口,主机侧看起来是一个设备,实际上跑着两套完全不同的 USB 类协议。
这个方案适合谁?做 USB 音频外设的嵌入式工程师、需要音频+控制双通道的产品开发者、以及正在从单功能 USB 设备往复合设备迁移的团队。核心难点不在音频流本身,也不在 CDC 收发,而在于复合设备描述符的组织、端点资源的分配、以及主机侧驱动加载顺序带来的玄学问题。下面按实际落地路径拆开讲。
2. 复合设备描述符怎么组织:配置描述符、接口关联与端点分配
2.1 为什么复合设备不能简单拼接两个描述符
单功能 USB 设备的结构很清晰:一个设备描述符、一个配置描述符、一个接口描述符、若干端点描述符。但复合设备不行——主机枚举时如果看到两个独立的接口,它不知道这两个接口属于同一个功能单元还是两个独立功能。USB-IF 定义了一个叫 Interface Association Descriptor(IAD)的东西,用来告诉主机“这几个接口是一组的”。
具体到 USB Audio + CDC 的场景,典型结构是这样的:
- 配置描述符里声明两个接口
- 接口 0 是 AudioControl,接口 1 是 AudioStreaming(音频数据端点在这)
- 接口 2 是 CDC Control,接口 3 是 CDC Data
- 用 IAD 把接口 0 和接口 1 绑成一组,接口 2 和接口 3 绑成另一组
如果不加 IAD,Windows 下大概率会把音频接口识别成独立设备,CDC 接口识别成另一个 COM 口,设备管理器里出现两个条目,用户体验和“复合设备”的初衷就背离了。
2.2 描述符结构的具体写法
下面是一个可参考的描述符组织方式,用 C 结构体表示(以常见 MCU USB 栈为例):
// 配置描述符:总长度需要动态计算 typedef struct { USB_CONFIGURATION_DESCRIPTOR config; // 9 bytes USB_INTERFACE_ASSOCIATION_DESCRIPTOR iad_audio; // 8 bytes USB_INTERFACE_DESCRIPTOR audio_control; // 9 bytes // ... AudioControl 类特定描述符 USB_INTERFACE_DESCRIPTOR audio_streaming; // 9 bytes // ... AudioStreaming 类特定描述符 + 端点描述符 USB_INTERFACE_ASSOCIATION_DESCRIPTOR iad_cdc; // 8 bytes USB_INTERFACE_DESCRIPTOR cdc_control; // 9 bytes // ... CDC 功能描述符 USB_INTERFACE_DESCRIPTOR cdc_data; // 9 bytes USB_ENDPOINT_DESCRIPTOR cdc_ep_in; // 7 bytes USB_ENDPOINT_DESCRIPTOR cdc_ep_out; // 7 bytes } composite_config_t;关键参数说明:
wTotalLength必须精确等于所有描述符字节数之和,多一个字节少一个字节都会导致枚举失败- IAD 的
bFirstInterface和bInterfaceCount要覆盖正确的接口范围 - AudioStreaming 接口的端点用 Isochronous 类型,CDC Data 接口的端点用 Bulk 类型
- 端点地址不能冲突,比如音频用 EP1 IN,CDC 就用 EP2 IN 和 EP2 OUT
2.3 端点资源分配的约束
很多 MCU 的 USB 外设端点数量有限,比如只有 4 个双向端点。音频流通常需要 1 个同步端点,CDC 需要 1 个 Bulk IN 和 1 个 Bulk OUT,加起来就 3 个了。如果音频还需要反馈端点(用于异步同步),端点就不够用。
常见做法是:音频用同步端点,CDC 用 Bulk 端点,反馈端点能省则省。如果 MCU 支持端点复用或者有更多端点,优先保证音频流的带宽和实时性,CDC 的 Bulk 传输可以容忍一定延迟。
注意:端点 FIFO 大小也要匹配。音频同步端点每帧传输的数据量取决于采样率和位深,比如 48kHz/16bit/2ch 每毫秒需要 192 字节,FIFO 至少要有这个容量加上一点余量。
3. 从零搭建:USB Audio + CDC 复合设备的代码实现路径
3.1 音频接口的配置:采样率、通道数与端点
音频接口的核心是描述音频流的格式。以 48kHz、16bit、双声道为例,AudioStreaming 接口的格式类型描述符里要写清楚:
// 音频流格式类型描述符(简化版) typedef struct { uint8_t bLength; uint8_t bDescriptorType; uint8_t bDescriptorSubtype; uint8_t bFormatType; // 0x01 = FORMAT_TYPE_I uint8_t bNrChannels; // 2 uint8_t bSubframeSize; // 2 bytes per sample uint8_t bBitResolution; // 16 uint8_t bSamFreqType; // 1 = 单采样率 uint8_t tSamFreq[3]; // 48000 编码为 0x80BB00 } usb_audio_format_t;参数怎么改:
- 改采样率就改
tSamFreq,如果是多采样率支持,bSamFreqType设为对应数量,后面跟多个三字节频率值 - 改通道数改
bNrChannels,同时端点描述符里的wMaxPacketSize要跟着变 - 位深改
bBitResolution和bSubframeSize,16bit 对应 2 字节,24bit 对应 3 字节
音频数据端点的wMaxPacketSize计算方式:采样率 × 通道数 × 子帧大小 ÷ 1000(因为 USB 全速是每毫秒一帧)。48kHz × 2ch × 2bytes ÷ 1000 = 192 字节。
3.2 CDC 接口的配置:虚拟串口与数据端点
CDC 部分相对标准化,需要以下描述符:
- CDC Control 接口:包含 Header Functional Descriptor、Call Management Functional Descriptor、ACM Functional Descriptor、Union Functional Descriptor
- CDC Data 接口:包含两个 Bulk 端点
// CDC 功能描述符组(简化) uint8_t cdc_functional_desc[] = { // Header Functional Descriptor 0x05, 0x24, 0x00, 0x10, 0x01, // Call Management Functional Descriptor 0x05, 0x24, 0x01, 0x00, 0x01, // ACM Functional Descriptor 0x04, 0x24, 0x02, 0x02, // Union Functional Descriptor 0x05, 0x24, 0x06, 0x00, 0x02 // 接口0和接口2关联 };Union Functional Descriptor 里的bMasterInterface和bSlaveInterface要指向正确的接口号。如果接口编号变了,这里必须同步改,否则主机会把 CDC 数据接口当成独立接口处理。
3.3 复合设备的枚举流程与主机侧行为
设备插入后,主机依次做这些事:
- 读取设备描述符,发现
bDeviceClass是 0xEF(Miscellaneous)、bDeviceSubClass是 0x02(Common Class)、bDeviceProtocol是 0x01(Interface Association Descriptor),就知道这是复合设备 - 读取配置描述符,解析出 IAD 和各个接口
- 根据接口的类代码加载对应驱动:音频接口加载
usbaudio.sys,CDC 接口加载usbser.sys - 在设备管理器里,音频接口出现在“声音、视频和游戏控制器”下,CDC 接口出现在“端口”下
这里有个血泪经验:Windows 10 之前,CDC 驱动不会自动加载,需要提供一个 INF 文件。Windows 10 之后大部分情况能自动识别,但如果设备描述符里 CDC 的类代码写错了,就会变成未知设备。
# Linux 下查看复合设备枚举结果 lsusb -v -d 0x1234:0x5678 2>/dev/null | grep -E "bInterfaceClass|bInterfaceNumber|IAD" # 查看音频设备 arecord -l # 查看 CDC 串口 dmesg | grep cdc_acm在 Linux 下,CDC-ACM 驱动会自动创建/dev/ttyACM0,音频接口会出现在arecord -l的输出里。如果只看到一个,说明描述符有问题。
4. 避坑与排查:复合设备驱动最常见的 5 个翻车现场
4.1 枚举失败,设备管理器显示“未知 USB 设备(设备描述符请求失败)”
现象:插上设备后主机反复报错,设备管理器里出现黄色感叹号,提示设备描述符请求失败。
原因:配置描述符的wTotalLength字段和实际字节数不一致,或者描述符数组里有某个bLength写错了。主机读取描述符时按wTotalLength请求数据,如果设备返回的数据长度对不上,就会直接放弃枚举。
解决:用 USB 协议分析仪抓包,或者用软件工具(如 Wireshark + USBPcap)看主机请求的描述符长度和设备实际返回的长度。逐字节核对描述符数组,特别是 IAD 和类特定描述符的bLength。
4.2 音频接口正常但 CDC 串口不出现
现象:设备管理器里能看到音频设备,但“端口”分类下没有 COM 口。
原因:CDC 的 Union Functional Descriptor 里接口号写错了,或者 CDC Data 接口的端点描述符缺失。另一个常见原因是bDeviceClass没有设为 0xEF,主机把设备当成单一功能设备处理,只加载了音频驱动。
解决:检查设备描述符的bDeviceClass、bDeviceSubClass、bDeviceProtocol三个字段。复合设备的标准值是 0xEF/0x02/0x01。然后检查 Union 描述符里的接口号是否和实际接口编号一致。
4.3 音频播放有杂音或断流
现象:音频能播放,但每隔几秒出现咔嗒声或短暂断流。
原因:同步端点的 FIFO 溢出或欠载。复合设备里 CDC 的 Bulk 传输和音频的同步传输共享 USB 带宽,如果 CDC 传输量太大,音频端点可能拿不到足够的带宽。
解决:降低 CDC 的传输频率或单次传输量,给音频流留出足够的带宽。全速 USB 下,同步端点最多占 90% 的帧带宽,CDC Bulk 传输要控制在这个范围内。另外检查音频端点的wMaxPacketSize是否和实际数据量匹配,过大或过小都会导致同步问题。
4.4 Windows 下 CDC 驱动加载失败,提示“该设备的驱动程序未被安装”
现象:设备管理器里 CDC 接口显示为未知设备,属性里提示代码 28。
原因:Windows 7 和部分 Windows 10 版本不会自动为 CDC 设备加载usbser.sys,需要 INF 文件。如果 INF 文件里的 VID/PID 和实际设备不匹配,或者 INF 没有签名,都会导致加载失败。
解决:提供一个包含正确 VID/PID 的 INF 文件,并在测试时禁用驱动签名强制。生产环境需要做 WHQL 签名或者使用微软自带的 CDC 驱动(要求设备描述符完全符合 CDC 规范)。
4.5 设备拔插后主机蓝屏或死机
现象:设备正常工作时拔掉 USB 线,主机蓝屏或音频服务崩溃。
原因:音频驱动在设备突然断开时没有正确处理端点停止,导致访问了已释放的内存。这在复合设备里更常见,因为音频和 CDC 两个驱动可能同时收到断开通知,处理顺序不确定。
解决:在固件里确保设备断开时先停止音频流,再关闭 CDC 接口。主机侧如果自己写驱动,要在 IRP_MN_SURPRISE_REMOVAL 处理里做好资源清理。用现成的 usbaudio.sys 和 usbser.sys 一般不会有这个问题,但如果自己写过滤驱动就要特别注意。
5. 进阶技巧:用复合设备实现音频+控制的双向低延迟通道
5.1 把 CDC 当成音频控制的带外通道
CDC 虚拟串口的 Bulk 传输虽然不如同步端点实时,但用来传控制参数完全够用。一个实用技巧是:音频数据走同步端点,DSP 参数、增益、滤波器系数走 CDC。上位机通过串口发送参数,设备收到后立即生效,不需要停止音频流。
# 上位机通过 CDC 串口发送音频参数 import serial import struct ser = serial.Serial('/dev/ttyACM0', 115200, timeout=1) # 发送增益参数:命令字 0x01 + 4字节浮点数 gain = 1.5 cmd = struct.pack('<Bf', 0x01, gain) ser.write(cmd) # 读取设备返回的当前参数 response = ser.read(5) if response[0] == 0x81: current_gain = struct.unpack('<f', response[1:])[0] print(f"当前增益: {current_gain}")这个模式的好处是控制通道和音频通道完全独立,改参数不会导致音频断流。参数说明:命令字自己定义,建议用单字节区分读写和不同参数类型;浮点数用小端序,和 MCU 的字节序保持一致。
5.2 验证复合设备稳定性的方法
长时间跑音频流的同时,用脚本持续通过 CDC 发送控制命令,观察是否出现断流或丢包。Linux 下可以用arecord录一段音频,同时用 Python 脚本每 100ms 发一次参数查询,跑 24 小时看结果。
# 录制 10 分钟音频,同时后台跑 CDC 压力测试 arecord -D hw:1,0 -f S16_LE -r 48000 -c 2 -d 600 test.wav & python3 cdc_stress_test.py --port /dev/ttyACM0 --interval 0.1 --duration 600如果 10 分钟录音里没有杂音,CDC 也没有丢包,基本可以认为复合设备稳定。我一般会跑至少 8 小时,因为有些同步问题在短时间内不一定暴露。
5.3 一个容易被忽略的细节:字符串描述符的索引
复合设备里字符串描述符的索引号容易搞混。音频接口的iInterface和 CDC 接口的iInterface可以指向不同的字符串,但如果索引号超出字符串描述符数组的范围,主机读取时会失败。常见做法是给每个接口分配独立的字符串索引,并在GET_DESCRIPTOR请求里正确返回。
提示:如果不需要用户可读的接口名称,把
iInterface设为 0 可以省掉字符串描述符,减少枚举时间。
做复合设备这几年,最大的教训是:描述符的每一个字节都要自己算清楚,不能靠猜。我习惯在代码里用sizeof和偏移量宏来生成描述符数组,而不是手写十六进制。这样改接口数量或端点参数时,wTotalLength会自动更新,少了很多低级错误。希望帮到你。
本文还有配套的精品资源,点击获取