做了这么多年嵌入式,我发现很多人一到 USB 音频设备就头疼:UAC 协议栈又长又绕,描述符字段一错整个设备在电脑上就是“未知 USB 设备(设备描述符请求失败)”,查半天查不出所以然。正好最近用 CherryUSB 配合 STM32 从零做了一个 UAC 麦克风加扬声器的复合音频设备,Windows、Linux、手机插上就能认,不用装任何驱动。这篇文章就把整套从硬件选型、描述符配置到数据通路的思路和踩坑记录完整写出来,给准备做 USB 声卡、USB 麦克风或者音频回环设备的同学做个参考,尤其是那些 “ 枚举成功了但没声音 ” 的疑难杂症,基本都能在这里找到答案。
1. 整体设计思路与方案选型
1.1 为什么是 UAC,而不是 HID 音频或自定义协议
USB 音频设备在行业里默认走的就是 UAC(USB Audio Class)规范,也就是系统自带的音频类驱动。选 UAC 最直接的好处是跨平台免驱:Windows 10 之后的系统对 UAC1 和 UAC2 都有原生支持,macOS、Linux 也自带驱动,Android 从 5.0 开始也支持 UAC 设备。也就是说你插上就变成一块标准声卡,在系统声音设置里能直接看到麦克风和扬声器,不需要用户去装任何 exe 或者 .inf。
有人会问,能不能用 HID 做音频传输?HID 确实能传数据,但系统不会把它当作音频设备,主机端要专门写一个应用拿 HID 数据再播放,而且 HID 的轮询方式做实时音频会带来额外的延迟和调度抖动。还有的团队想干脆自定义 Bulk 端点协议,自己写上位机,这种方案做产品原型没问题,但凡是需要对接第三方软件、通话 App、会议系统,都不如 UAC 来得通用。我的经验是:只要目标是做 “ 插上就能用的声卡/麦克风 ”,UAC 就是唯一正解。
1.2 UAC1 还是 UAC2,怎么选
UAC 规范有两个大版本,对嵌入式来说最关键的差别是带宽和兼容性的权衡。
UAC1 是早期规范,设计跑在 Full Speed 12Mbps 下,描述符结构比较简单,兼容性最好,Windows XP 到 Win11 通吃,很多单片机老项目里也都是 UAC1。但它的端点是采样率自适应模式,音频数据以每毫秒一个事务的方式同步传输,实际有效带宽不高,通道数和采样率受限。比如你要做 32bit/96kHz 的立体声麦克风,UAC1 在 Full Speed 下就比较勉强,容易出现带宽不足的问题。
UAC2 是 USB-IF 在 USB 2.0 时代推出的规范,本身瞄准 High Speed 480Mbps,支持高采样率、多通道、低延迟,还引入了数据反馈(Feedback)机制让同步更精确。Win10 1703 之后对 UAC2 驱动是原生支持的,所以现在做新品我强烈建议直接上 UAC2。但注意:如果你的 MCU 只有 Full Speed 12Mbps 的 USB 控制器,硬上 UAC2 会很痛苦,因为 High Speed 的带宽模型和 Full Speed 微帧机制完全不同,FS 下 UAC2 能跑但只能跑低采样率的单声道。
所以我的方案选择很简单:主控带 High Speed 控制器(比如 STM32H750 配合外部 ULPI PHY),直接 UAC2;只有 Full Speed 控制器(比如 F4 内置 OTG FS),优先 UAC1,把采样率控制在 48kHz/16bit/双声道以内,稳定且兼容性最好。实际做下来,Jitter 和兼容性都足够好。
1.3 为什么用 CherryUSB,而不是 ST 官方 USB 库
ST 官方 USB 库其实也能做,但代码封装层太厚,描述符是散落在一个巨型结构体里,改一个字段要翻好几个文件。而且 ST 的库不是所有系列代码结构一致,F4、H7 之间切换还得重新适应。CherryUSB 的最大优势是架构干净:把 Device Controller Driver(DCD)、USB Device Core、Class Driver 分层剥离,你有问题可以直接看到协议层的处理逻辑,不用猜。
另外 CherryUSB 对所有支持的 MCU 抽象度比较高,class 层的代码是通用的,下面换不同的 DCD 就能换芯片。我用它把 F407 和 H750 两个平台的工程跑起来,上层应用基本没动。对一个产品来说,这种可移植性太重要了,板上改方案时不至于把软件推倒重来。
2. 硬件平台搭建与开发环境准备
2.1 主控选型建议
做 UAC 音频设备,主控核心看三方面:USB 外设类型、I2S/SAI 音频接口、RAM 带宽。
如果只是 Full Speed 的 UAC1 麦克风/扬声器,STM32F4 系列性价比很高。F4 内部有 USB OTG FS,支持 Device 模式,配合 SAI 或 I2S 接口做音频采集和播放。F103 虽然也能做,但 F1 的 USB 是老的 Device-only 控制器,没有 OTG,DMA 路径也差一些,做复杂音频链路会吃力。
如果想上 UAC2 高速模式,推荐 STM32H750 或者 H743 搭配 USB3300 / USB3320 ULPI PHY。H7 内置 USB OTG HS,可以工作在 High Speed,注意它要外接 PHY 才能跑 480Mbps,内置 PHY 只能跑 Full Speed,这个别搞混。H7 的 SAI 接口和 DMA 带宽都强很多,多路音频流同时跑也不会有压力。
2.2 USB 时钟树配置:48MHz 是红线
STM32 的 USB 模块时钟必须精确等于 48MHz,差一点都不行。F4 的 USB OTG FS 时钟从 PLL Q 输出取 48MHz,系统时钟 168MHz 时 PLL_Q 设 48 很好配;如果系统时钟跑 180MHz,也要保证 PLL_Q=48,不然 USB 枚举会失败或者 CRC 错误不断。
H7 系列要小心,因为 H7 的主 PLL1、PLL2、PLL3 分工不同,USB 时钟是从 PLL3_Q 的 48MHz 出来给 OTG FS 用的,HS 模式下还要给 ULPI PHY 提供 60MHz 参考时钟。很多人第一次跑 H7 的 USB 例子发现设备连不上,一查就是 PLL 配置里没给 USB 分出一路 48MHz,或者 PLL3 频率不对导致 PHY 时钟不稳。
建议直接进 CubeMX 的 Clock Configuration 页面,打开 USB 外设,确认 USB "48MHz" 那一栏是绿色 48,再进工程。
2.3 音频硬件方案:从模拟到数字的取舍
麦克风输入有三种常见做法。第一种是模拟驻极体麦克风加运放放大,再进 STM32 的 ADC 采样,这是最容易上手的方案,但注意 STM32 ADC 采样率做不到像音频 Codec 那样精确,也难处理 DC 偏置,底噪大。第二种是模拟麦克风接外部 I2S ADC 芯片,比如 CS5340、WM8960,再由 I2S 接口输入 STM32,音质有明显提升,适合对麦克风灵敏度要求高的应用。第三种是数字 MEMS 麦克风,比如 INMP441,直接通过 PDM/SAI 接口把 1bit PDM 数据送给 MCU,在 MCU 内部做抽取滤波转 PCM,这种方案抗干扰强、电路最简单,近年很多集成 USB 麦克风产品都在用。
扬声器输出更直接:I2S 到外部 DAC + 功放是最常规的,PCM5102A 这类 DAC 芯片便宜好用;如果只要基本提示音级别,用 STM32 内置 DAC 或者 PWM 滤波也行,但要预期好音质只是一般般,别指望它播高质量音乐。
2.4 开发环境与调试工具
软件部分用 Keil MDK 或者 GCC 都行,CherryUSB 提供 CMake 工程也可以用 GCC 编译。建议准备三样调试工具:USB 分析仪、逻辑分析仪、I2S 协议分析。USB 分析仪不是必须但强烈建议,枚举阶段的问题靠它一下就能定位到描述符字段;逻辑分析仪用来抓 I2S/SAI 的波形,确认音频数据流有没有正常搬动;如果你的 USB 分析仪支持解码 UAC,那基本能看清 host 发了哪些类请求。
3. USB 音频核心原理与描述符设计
3.1 USB 枚举流程和 UAC 的特殊之处
USB 设备插上后,Host 会对设备发一系列标准请求:Get Descriptor、Set Address、再 Get Descriptor、Set Configuration、Set Interface。这些是标准 USB 枚举流程,任何设备都必须响应。UAC 设备的特殊性在于配置描述符里嵌入了 Audio Control(AC)和 Audio Streaming(AS)两类接口,Host 枚举完标准描述符后,还会发送音频类请求,比如 Get/Set Current 来读采样率、音量等 Control 信息。
也就是说,你的设备不仅要响应标准请求,还得响应类请求。主机枚举时如果发现配置描述符里音频接口的结构不对,就会把这个设备判定为“不支持”或者干脆不刷新出音频设备。
3.2 UAC 描述符结构核心细节
以 UAC1 麦克风加扬声器复合设备为例,描述符大致布局如下:
- 设备描述符:bDeviceClass 设 0x00,表示从接口描述符再确定设备类。bcdUSB 设为 0x0200,告诉系统这是一个 USB 2.0 设备,即使只有 Full Speed 也算规范。
- 配置描述符:内部包含两个独立的接口集合,一个是麦克风部分(Audio Control + Audio Streaming),一个是扬声器部分(Audio Control + Audio Streaming),它们可以用同一个 Configuration,也可以用不同的 Interface Number 分区分好。
麦克风接口集合里,AC 接口主要放 Input Terminal(给主机一个输入端子 1)、Output Terminal(把音频流交给 Streaming 接口)、IT 和 OT 之间有 Feature Unit(音量控制)或者没有都行。AS 接口里有端点描述符,Mc(Microphone)方向,bEndpointAddress 的 bit7 要置 1 表示 IN 端点,也就是设备往主机发数据。描述符会声明 wMaxPacketSize(每帧传输字节数)、bInterval、采样频率、位宽、通道数。
扬声器方向刚好相反,AS 接口里的端点是 OUT 端点,设备从主机收数据。复合设备在配置描述符里要包含麦克风 Control/Streaming 和扬声器 Control/Streaming 四组接口,定义好接口号别冲突。说实话手写这些描述符很费神,容易漏字段,CherryUSB 的 audio class 样例里已经给了完整的 UAC 描述符,直接改采样率、通道数、端点号这些字段就能用。
3.3 等时传输与带宽预算
UAC 的音频数据基本走等时传输(Isochronous),特点是每毫秒都有固定的传输机会,但不保证重传,丢失一个 packet 就不会再补。对实时音频来说这是合理的,因为人耳无法接受几十毫秒的补包延迟,但代价是要求你的缓冲设计要平滑,下溢和溢出都很难听。
Full Speed 下每个毫秒帧可以传输多个事务,但所有等时传输的总字节数要低于带宽上限。48kHz/16bit/单声道麦克风,每秒 48000 个采样帧,每帧 2 字节,每秒要传 96000 字节。在 12Mbps 带宽里大约占用不到 10%,看起来余量很大,但实际上等时传输每一笔事务有协议开销(token、CRC、包间隔),而且描述符里 wMaxPacketSize 是按帧给的,48kHz/16bit 单声道每帧 96 字节,一个 USB 帧能塞下;如果是 8 通道 24bit/96kHz,Full Speed 绝对爆掉,所以那种应用必须上 High Speed。
3.4 采样率与同步方式
UAC1 里有两种同步模式:异步(Asynchronous)和同步(Synchronous)。同步模式下主机按固定的 SOF 节奏发送数据,设备端跟着总线节奏走,但设备本地时钟和主机的 1kHz SOF 之间可能有微小偏差,音频应用里会导致周期性 buffer 溢出或欠载。异步模式下设备端通过 Feedback 端点把本地采样时钟反馈给主机,主机按反馈值微调传输速率,这是专业声卡常用的方式,但反馈端点和 PID 控制写起来复杂。
CherryUSB 的自带例程对 UAC1 用的是同步模式,实际测试中只要晶振精度够(20ppm 以内),长时间播放不会有明显问题;如果要求更严格,可以把反馈端点开启,用标准的 SampleRate Feedback 机制。做产品时我会先用同步模式把功能跑通,再去调异步反馈,避免一上来复杂度太高。
4. CherryUSB 工程搭建与核心配置
4.1 源码获取与工程骨架
从 GitHub 拉 CherryUSB 源码,建议用 release 版本而不是 master 分支,master 有时候在重构期间函数名会变。目录里 bsp/ 下是一堆板卡样例,直接拷贝一个同系列的板级工程最简单。不是必须从零开始建工程,从我经验看先让官方 audio_mic_sample 或 audio_speaker_sample 在你的板子上跑起来,再往里面加自己应用逻辑,效率最高。
代码里核心文件关系大概是:dcd_xxx.c 是底层 USB 控制器驱动(和 HAL 强相关),usbh/ 是主机栈不需要管,usbd/ 是设备栈核心,class/ 下有 uac 相关的 class 驱动。你的应用只要把 usb_dc_init() 和 usbd_initialize() 调好,然后注册好 class 的 callback 就行。
4.2 描述符与宏配置修改
打开 usbd_conf.h 或者 SDK 的 config 头文件,能看到 USB 端点数、FIFO 大小、buffer 对齐方式等配置。做音频时注意调整好端点 buffer 大小,一般至少是 wMaxPacketSize 的 2 倍,因为内部要做 ping-pong buffer。缓冲区不够会出现枚举成功但传数据丢包的现象。
描述符默认给的是单声道还是双声道要看模板,我习惯在 uac_desc.h 里搜索 AUDIO_SAMPLING_FREQ 和 channels 相关宏,全局改成 48000 和 2(立体声),注意要同时改麦克风方向和扬声器方向的描述符,如果你做的复合设备,两个方向都要改到位。
4.3 双向音频的初始化流程
初始化流程大致是:时钟和 GPIO 先配好,I2S/SAI 外设初始化并启动 DMA;然后 usb_dc_init() 启动 USB 控制器,usbd_initialize() 把 device 层的 class driver 装上;接着设置描述符、注册控制请求回调;最后使能 USB 中断,进入主循环。
关键区别:普通控制类设备在 while 循环里不停检查标志位,但 UAC 设备在枚举完成后,音频数据是硬实时流,不能在 while 里丢数据。你的主循环可以完全空转,因为数据通路是靠中断驱动的。USB 等时传输的中断来了,就触发 class 的 send/receive 回调,你在回调里搬数据;I2S DMA 中断到了,把下一块 buffer 填给 I2S。这种中断驱动的模型是 USB 音频稳定运行的根本。
5. 麦克风和扬声器数据通路的落地实现
5.1 麦克风链路:DMA 采集到 USB 发送
麦克风方向的核心是什么?把 I2S/SAI 收到的连续 PCM 数据,切成固定大小的块,通过 USB IN 端点发送给主机。这里最忌讳的是在中断里做太多数据搬移,否则会引入延迟和抖动。
我用的是三块 buffer 轮转:I2S DMA 采集满一块 buffer 后产生中断,DMA 自动切到下一块,在中断里把刚采满的那块数据指针交给 USB class 层发送。USB 发送完成后再释放。第一块在采集、第二块在传输、第三块空闲,形成流水线,这样 CPU 很少参与数据搬移,延迟也能控制在几个毫秒内。
有一个很关键的细节:I2S 采样率和 USB 帧率不匹配会导致长时间运行后 buffer 漂移。比如 I2S 实际采样率是 48048Hz,而 USB 按 48000Hz 的节奏发数据,积少成多,最终要么 buffer 空掉要么溢出。解决思路是把环形缓冲做大(比如 512 个采样帧),每次漂移一点时通过插值或者重复/丢弃一个采样来校正。这个课题很经典,很多声卡驱动里叫 “ Adaptive resampling 或 rate matching ”。刚开始可以先不做,但要知道这一步是绕不开的坑。
5.2 扬声器链路:USB 接收数据到 DAC
扬声器方向反过来:USB OUT 端点收到主机的 PCM 数据,放进 buffer,然后 I2S DMA 按采样率从 buffer 读取发送给 DAC。如果 USB 数据来的速率比 I2S 消费快,buffer 会溢出;如果主机发送稍慢,I2S 会读到空 buffer 导致爆音。解决方法同样是环形缓冲 + 水位管理。
采到一个很实用的技巧:扬声器 buffer 初始化时先填一段静音数据再启动 I2S,不要一上来就空转。否则在主机启动流的瞬间,I2S 可能先读到一个空样值,产生“啪”的一声。软件上初始化环形缓冲全部填零,启动 I2S 后先从静音开始填充,等 USB 数据到达后再换成真实数据,就没有爆音了。
5.3 环形缓冲实现要点
多生产者消费者的场景,USB 中断和 DMA 中断都可能写/读同一个环形缓冲。单生产者单消费者的情况,我推荐用无锁环形缓冲:一个读索引一个写索引,每个索引只有一方修改,写入时先写数据再更新索引,读取时先读索引再读数据,配合内存屏障就行。如果加了锁,中断里取锁很容易死锁,别小看这个问题,我见过好几个项目在这里翻车。
实际工程里我会把读写索引都定义成 volatile 无符号整型,并保证索引自增的代码不做跨指令优化,简单可靠。如果产品要上 RTOS,多线程访问就必须用关中断或者 mutex,但中断里也拿 mutex 就麻烦了,所以我的经验是:UAC 数据通路尽量保持中断上下文,进程上下文只做控制和状态展示。
5.4 音质相关硬件细节
音质问题很多时候不是软件而是电路和布局。I2S 信号线尽量短,DAC 的模拟电源和数字电源分开,模拟地单点连接。PCM5102 这类 DAC 的 VCC 引脚滤波电容放得离引脚越近越好,我用 10uF + 0.1uF 组合效果好很多。PDM 麦克风的时钟和数据线也要和 USB 走线拉开距离,避免 USB 高速信号耦合到底噪里。
如果发现录下来的声音有节奏性的“滋滋”声,大概率是 USB 的帧同步信号干扰了模拟链路,或者电源纹波没滤干净。排查时用电池给模拟部分单独供电,如果噪音消失就说明数字电源串扰严重,优先改进 LDO 滤波和 PCB 布局。
5.5 复合设备容易踩的“描述符坑”
做麦克风+扬声器复合设备时,最典型的坑是 AC 接口重复。一个 Audio Control 接口只能控制一个功能拓扑,如果麦克风和扬声器共用一个 AC 接口,却在里面声明了两个 Input Terminal 和两个 Output Terminal,一些系统可以识别,但很容易出现只有麦克风或者只有扬声器工作的问题。规范做法是:两组接口各自有自己的 AC 和 AS,互相独立。
另外别忘记配置描述符的总长度会自动变化,如果你修改了接口数量,总长度要重新计算。CherryUSB 的 usbd_get_config_desc 会返回描述符长度,打印出来检查一下,如果长度不对,或 Host 枚举时读到截断的数据,设备会直接枚举失败。
6. 常见问题与排查技巧实录
6.1 枚举失败:设备完全识别不到
遇到插入 USB 后系统提示“未知 USB 设备”,优先检查:USB 时钟是否 48MHz;设备是否有 1.5k 上拉电阻到 D+(Full Speed 设备),F4 的内置上拉由 USB 控制器控制,如果使用 OTG 模式要配置 IDD 引脚,否则 VBUS 检测不对,控制器不使能上拉,Host 根本发现不了设备;再就是 D+ / D- 的走线或者焊接问题。
有逻辑分析仪的话,能看到 Host 发的 Set Address 前的第一次 Get Descriptor 有没有回复。如果设备连 Device Descriptor 都没回,基本是底层 DCD 驱动、时钟、上拉的问题;如果回了但收到的是错误长度,则要检查描述符数组有没有写错。
6.2 枚举成功但系统不显示音频设备
插上之后设备管理器能识别到 USB 输入设备,但声音设置里就是没有“麦克风”或“扬声器”,这种情况十有八九是配置描述符里的音频接口结构有问题,导致操作系统不认为这是 UAC 设备。注意确认设备描述符里的 bDeviceClass 是 0x00、配置描述符里接口描述符的 bInterfaceClass 是 0x01(Audio),而不是被误写成了 0xFF 厂商自定义。另一种情况是使用 UAC2 时主机系统驱动没加载,比如老版本 Win7 不支持 UAC2,而 Linux 某些发行版默认没开 snd-usb-audio 模块。
如果系统已经刷新出“USB 麦克风”图标,但录音电平完全没有信号,先看麦克风方向有没有数据包在发。用 USB 分析仪抓包,如果 Host 发了 Set Interface 后,设备没有收到音频数据发送的请求,可能 AS 接口的端点地址错了,比如设备应该用 IN 端点,但你描述符里写成了 OUT。
6.3 播放有声音但断断续续、爆音
最常见原因是采样率不匹配。主机用 48kHz,你的 I2S 实际工作在 48016Hz,长时间下来缓冲会周期性溢出。初期排查把主机播放软件的采样率固定为 48kHz,确认问题是否消失;如果稳定则是时钟偏差,建议做 rate matching 或者改用异步反馈。另一个常见问题是 USB 端点和 I2S DMA 的 buffer 大小不是整数倍关系,导致每次切换边界时会产生半个采样帧的错位,数据错位会表现为持续高频杂音,同步两边 block size 为最小公倍数即可缓解。
还有一个容易被忽略的:等时传输在 USB 总线繁忙时会丢包,USB 高速设备共享带宽时,等时传输的优先级并不绝对,总线拥堵时一样丢。所以产品上如果 USB 麦克风和鼠标、U 盘共存,保不齐有瞬间丢包,加入一定的丢失隐藏(把上一包数据再放一次,或者插值)可以显著改善听感。
6.4 回声、底噪和“嗡嗡”声
多设备同时使用时,麦克风采集到扬声器播出来的声音形成回声,这是声学问题,UAC 设备本身解决不了,但可以为上层提供全双工能力,由上位机做 AEC。底噪问题则要从硬件链路排查:模拟麦克风的偏置电压是否干净、电源纹波、I2S 走线是否被 USB 高频信号干扰、差分输出的 DA 是否有共地噪声。
如果是“嗡嗡”的 50Hz 工频声,很大概率是模拟部分接地环路问题,尝试把模拟地和数字地单点连接,不要大面积覆铜混接。PDM 数字麦克风出现异常高频噪音,则检查 PDM 时钟频率和抽取滤波参数是否匹配,MEMS 麦克风校准增益是否设置过高。
6.5 快速定位问题的排查顺序表
我每次调 UAC 问题都按这个顺序来,能省至少一半时间:先确认 USB 枚举是否成功(设备管理器/系统报告);再用 lsusb -v 或者 USB 分析仪看描述符是否完整、类请求是否有响应;再确认 Endpoint 配置是否和实际收发的方向一致;接着用 printf 串口日志在 USB class 回调里打点,看 USB 数据有没有进入应用层;最后再上 I2S 侧逻辑分析仪,确定音频源到底有没有采到数据。很多“没声音”问题最终不是 USB 侧,而是 I2S 完全没有配置好 MCLK,或者 DAC 芯片静音引脚没拉高,这种低级错误在软件和硬件的边界上最容易发生。
7. 后续扩展方向与个人经验总结
做完基础的 USB 麦克风加扬声器复合设备,其实整个 UAC 框架已经搭起来了,后面做功能扩展,比如 USB 声卡加按键静音、音量旋钮、RGB 灯效联动,都是在现有拓扑往里加 HID 接口的问题,HID 这部分也是 CherryUSB 里现成的。想往专业方向走,可以研究异步反馈、类复合设备的多采样率切换,甚至把麦克风采集到的音频经过 DSP 做噪声抑制再传给主机,这些在 Cortex-M7 上都有算力余量。
另外我强烈建议新手把官方的 audio demo 先烧进板子,不去改任何代码,确认跑通一遍枚举和录音播放。很多人上来就改描述符,结果基础能力都没验证,出了问题根本不知道是协议栈的问题还是自己改崩的。先跑通,再增量修改,每改一点都插电脑验证,这是做 USB 设备最稳妥的节奏。
就我自己的实操体验来说,UAC 音频设备 80% 的价值都在描述符配置和数据流稳定的细节上,而 CherryUSB 很好地解决了前者的复杂度,让我可以把精力集中在后者。如果你也在做相关方案,建议手头始终留一个 USB 分析仪,很多玄学问题最后都能在总线层面找到真实原因。做完这个项目再回头看,从零搭一个免驱的 USB 音频设备真的没有想象中那么神秘,关键在于按规范把每一层打通——总线协议交给协议栈,应用逻辑自己掌控,做一个好用的 USB 声卡就是水到渠成的事。