AT32裸机下USBx Device移植:HID+CDC ACM复合设备实战
2026/8/29 19:18:00 网站建设 项目流程

最近接了块带USB功能的板子,芯片型号是LAT1486,跑的是雅特力AT32平台的USBx Device协议栈。需求很典型:一个USB口要同时承担两件事,一个是模拟成HID设备做交互控制,另一个是模拟成CDC ACM虚拟串口做数据通信,也就是组成一个HID+CDC ACM的复合设备。麻烦在于,官方例程基本都挂在RT-Thread或者完整的BSP工程里,而我这边是裸机环境,只能把USBx Device库单独抽出来做standalone移植。这篇文章就把整个移植过程、描述符设计、踩坑点和调试方法完整记录下来,给后面要搞复合USB设备的同行一个参考。

如果你手头正好要做USB复合设备,或者在雅特力AT32这类M4 MCU上做USB虚拟串口+自定义HID,又不想为了一个USB功能把整个RTOS工程都搬进来,这篇内容应该能帮你省不少时间。我尽量把“为什么这样做”讲清楚,而不是只扔代码。

1. 项目背景与方案选型心得

1.1 为什么做 standalone 移植

先说说为什么会走到“standalone移植”这一步。官方SDK里其实已经提供了不少USB Device例程,但大部分是建立在RT-Thread、FreeRTOS或者完整BSP工程之上的。比如RT-Thread版本的例程,usbd的初始化、类驱动的注册、端点的回调,都跟内核的IPC、内存管理纠缠在一起。如果你的项目只是需要一个小巧的USB设备功能,却又被拖进整个RTOS体系里,性价比其实很低。

我之前在一个产测工具项目里就遇到过这种情况:功能本身很简单,固件里跑一个状态机就够,不需要任务调度,也不需要信号量。但官方例程依赖RTOS,强行移植意味着要么把RTOS引进来,要么把RTOS相关的调用全部改掉。前者会让代码体积和复杂度暴涨,后者则要求把USBx库内部的调度点都摸清楚。最后我选了后者,也就是把USBx Device从官方大工程里拆出来,加上一薄层standalone适配,直接跑在裸机上。

USBx Device这层协议栈本身其实对RTOS没什么强依赖。它做的事情就是处理USB标准请求、维护端点状态、调用类驱动的回调。真正需要“锁”的地方,在裸机环境里完全可以靠关中断来保证临界区。也就是说,USBx在设计上就有standalone运行的潜力,只是官方没有单独给一份裸机工程而已。

1.2 为什么选 HID + CDC ACM 复合方案

复合设备,说白了就是一个USB物理口上同时挂多个功能。这次选HID+CDC ACM,是从实际需求出发的。设备端有一个简单的按键/状态输入,需要让主机感知,同时设备还要通过串口跟主机交换大量数据。如果只做CDC,HID那部分交互就得通过串口协议来模拟,主机端写上位机要自己解析协议;如果只做HID,大数据量的传输带宽又不够灵活。两个功能合并到一个USB口上,主机端插一根线,既能看到一个虚拟串口,又能识别到一个HID设备,交互和通信分开,干净利落。

为什么不选两个独立USB设备?很多板子只有一个USB控制器,做不到。为什么不做成一个CDC虚拟串口加一个厂商自定义类?也可以,但Windows和Linux对HID的支持是最底层的,免驱、稳定,状态上报还能被BIOS/UEFI识别。CDC ACM在操作系统里也有原生驱动,所以组合起来就是“双免驱”。

另外HID+CDC ACM这个组合还有一个隐藏优势:HID的中断传输适合小数据量、低延迟的控制通道,CDC的批量传输适合大数据量、高吞吐的数据通道。两者配合,就是典型的“控制+数据”双通道架构。实际项目里很多设备都适用这种模式。

1.3 LAT1486 与 USBx Device 的基本资源

LAT1486具体型号对应的内部资源,我这边没有把数据手册完整贴出来的习惯,但有一点是确定的:它使用的是AT32平台上的USB设备控制器,配合雅特力官方的USBx Device软件库使用。这个库的分层很清楚,从上到下大致是应用层(描述符、类回调)、类驱动层(HID、CDC等)、核心层(usbd_core、usbd_std)、以及最底层的硬件适配(端点的寄存器操作、中断处理)。

在做移植之前,建议先把芯片的USB SRAM容量、可用端点数量和每端点FIFO大小查清楚。HID+CDC ACM复合设备至少需要3个IN端点(HID中断IN、CDC通知中断IN、CDC批量IN)和1个OUT端点(CDC批量OUT),部分场景还会额外加HID OUT端点。端点数量不够的方案就得做端点复用,复杂度会明显上升。LAT1486这类的USBD控制器基本都带4个以上端点,做这个复合设备是够用的,但如果你的目标芯片是某款低端点数的片子,选型阶段就得先把端点规划想清楚。

2. 描述符设计:复合设备最难啃的部分

2.1 从设备描述符说起:声明复合设备身份

很多人在复合设备上栽跟头,第一刀就死在设备描述符上。复合设备不是简单地把两个接口放在同一个配置里就行,你需要在设备描述符里声明“我是一个复合设备”,否则操作系统在解析配置描述符的时候,认不出后面的IAD(接口关联描述符),会按普通多接口设备去处理,导致功能错乱或者直接枚举失败。

设备描述符里有三个字节很关键:bDeviceClass、bDeviceSubClass、bDeviceProtocol。对于带IAD的复合设备,一般建议设置成:

  • bDeviceClass = 0xEF
  • bDeviceSubClass = 0x02
  • bDeviceProtocol = 0x01

这个组合就是USB规范里定义的“Miscellaneous Device Class”,专门用来标识使用IAD的复合设备。如果你的设备描述符里写的是0x00,操作系统可能会认为每个接口是独立功能,而不是一个复合功能,HID和CDC有可能分开识别,也可能识别出各种奇怪的问题。

顺带说一句,bcdUSB我一般写0x0200。如果你的HID报表里用了比较新的特性,可以考虑0x0210,但全速设备写2.0就好,兼容性最好。bMaxPacketSize0全速设备固定写64,这个不需要犹豫。

2.2 配置描述符里的 IAD 与 CDC 结构

配置描述符是整个复合设备最繁琐的部分。你需要把IAD、CDC控制接口、CDC数据接口、HID接口依次组织好,所有描述符的长度都要算对,一个字节的偏差都会导致枚举失败。

以我的实际布局为例:

  1. IAD(接口关联描述符):bFirstInterface=0,bInterfaceCount=2,bFunctionClass=0x02(CDC),bFunctionSubClass=0x02(ACM),bFunctionProtocol=0x01,表示接口0和接口1合并成一个CDC ACM功能;

  2. CDC控制接口(接口0):接口类0x02、子类0x02、协议0x00,里面要带CDC特有的功能描述符,包括Header Functional Descriptor、Call Management Functional Descriptor、ACM Functional Descriptor和Union Functional Descriptor。很多CDC无法被识别成串口的问题,就是漏了Union描述符,或者bControlInterface和bSubordinateInterface的编号对不上;

  3. CDC数据接口(接口1):接口类0x0A、子类0x00、协议0x00,带两个批量端点,Bulk IN和Bulk OUT,最大包长全速设备写64;

  4. HID接口(接口2):接口类0x03、子类0x00、协议0x00,带HID描述符和一个中断IN端点。

这里最典型的坑是接口顺序问题。IAD的bFirstInterface和bInterfaceCount必须与实际接口编号完全一致。如果你把HID接口放在CDC前面,IAD的bFirstInterface就要跟着改。而且CDC Control和CDC Data必须是相邻接口编号,中间不能插入HID接口,否则IAD就续不上了。

有个实用的排查方法:枚举失败时用USBlyzer或者Wireshark的USB抓包功能,直接看主机请求配置描述符时设备返回的原始字节流,再跟自己定义的描述符数组逐字节比对。绝大部分描述符问题都能这样查出来。

2.3 HID 描述符与 Report Descriptor 设计

HID接口的描述符相对CDC要简单,但Report Descriptor才是真正决定HID设备“长什么样”的部分。你的设备是键盘、鼠标、还是自定义HID,都由Report Descriptor决定。

这次我做的HID端是一个简单的自定义HID设备,用来上报按键和状态。报表描述符里用了一个输入报表,包含几个字节的开关量和状态位。对于HID报表描述符,需要注意几点:

第一,报表描述符的长度要跟HID描述符里的wDescriptorLength字段完全一致,多一个字节少一个字节都不行。我遇到过报表长度算错,主机直接报设备描述符请求失败的情况。建议在代码里用sizeof自动计算,不要手填。

第二,中断IN端点的最大包长一定要能容纳你的报表长度。比如报表长度为8字节,端点最大包长如果只写4,数据会被截断。反过来,端点包长改大了,轮询间隔也要对应调整。

第三,如果不确定自己的Report Descriptor有没有问题,可以在Windows的设备管理器里看HID设备的“功能说明”或者用HID调试助手读报告描述符,比对着规范逐字检查直观得多。

HID类协议在系统层面的支持很成熟,基本上描述符对了就能免驱工作,但正因为描述符出错时的报错五花八门,反而更容易让人摸不着头脑。

2.4 端点规划与带宽预算

复合设备的端点规划,直接关系到USB传输的实时性和可靠性。我先把我这次的端点分配列出来:

  • 端点0:控制端点,所有枚举流程必备;
  • 端点1 IN:CDC通知端点,中断传输,最大包长8字节或者16字节,轮询间隔按手册配置;
  • 端点2 IN:CDC数据Bulk IN,最大包长64字节;
  • 端点3 OUT:CDC数据Bulk OUT,最大包长64字节;
  • 端点4 IN:HID中断IN,最大包长根据报表长度定,轮询间隔1~10ms。

全速USB的总带宽是1ms一帧,每帧约1500字节左右可用带宽。HID中断IN如果轮询间隔是1ms,每帧占用8字节;CDC通知中断IN如果轮询间隔设16ms,平均每帧占用不到1字节;CDC的两个Bulk端点属于大块传输,抢占剩余带宽。整体算下来,HID+CDC ACM的复合设备在全速模式下带宽压力不大,但要注意批量端点不要长时间占满总线,否则会影响HID中断传输的实时性。

端点FIFO分配也是容易被忽略的点。USB控制器的每个端点拥有独立的FIFO空间,但总容量有限。CDC Bulk IN/OUT包长64字节,每个端点至少分配64字节,HID中断IN按包长分配,控制端点64字节。像AT32的USB设备控制器,FIFO是按端点静态或动态配置的,建议给Bulk端点留双缓冲空间,比如128字节,这样连续传输的时候效率更高。如果FIFO分配不足,会出现端点状态卡死、数据错乱的现象。

3. standalone 移植实操:从官方例程里“抠代码”

3.1 源码筛选与工程搭建

从官方SDK开始移植,第一步是搞清楚哪些文件是必须的,哪些是RTOS相关的垃圾。我的做法是新建一个空工程,然后只拷贝USBx Device库的核心文件,不碰官方例程的Application目录。

需要拷贝的核心文件大致包括:

  • USB Device核心层源码:usbd_core相关的c/h文件;
  • 标准请求处理:usbd_std相关的请求处理逻辑;
  • 类驱动源码:HID类驱动和CDC类驱动文件;
  • 硬件适配层:针对LAT1486 USB控制器的底层驱动文件,包括端点操作、控制器初始化和中断处理;
  • 配置头文件:包含端点号宏定义、最大包长、缓冲区地址等配置。

RTOS相关的东西,比如rtos_mutex、rtos_semaphore这类文件,一个都不要。官方类驱动里可能有条件编译,通过宏开关把RTOS相关代码关掉。

工程搭好后先编译一次,肯定会有不少报错。把报错逐个解决的过程,其实就是把库对外部依赖找出来的过程。大部分依赖集中在内存分配和临界区保护上。内存分配可以用简单的静态缓冲区替代,临界区保护在裸机上直接__disable_irq()/__enable_irq()就行。

3.2 裸机环境适配:时钟、中断、延时

USB控制器对时钟要求很严格。全速USB要求48MHz的USB时钟,AT32系列一般通过PLL分频得到。如果USB时钟不对,设备上电后枚举时会频繁复位,表现为主机不断重复“设备描述符请求失败”。这个坑我第二次移植时又踩了一次,排错排了大半天,最后发现是初始化顺序的问题——先把系统时钟切到PLL,再配置USB时钟源,顺序反了就会出现诡异问题。

中断处理也不复杂。USB控制器产生中断后,会进入对应的中断服务函数。裸机环境下,这个ISR直接调用USBx Device库的中断处理入口即可。注意在ISR里不要做耗时操作,把数据搬运和回调都留在库内部处理,应用层如果需要处理数据,通过标志位或者环形缓冲区延后到主循环执行。

延时函数也比较关键。USB复位、端点使能这些操作有时序要求。裸机上如果没有现成的毫秒延时,我直接用SysTick实现了一个简单的延时函数。SDK里RTOS版本用的都是OSAL层的延时,standalone移植后直接换成自己的实现就行。

3.3 初始化流程与主循环调度

USB设备初始化的流程,我整理成了一套固定顺序,每次新板子都是这么跑:

  1. 配置系统时钟,确保USB时钟为48MHz;
  2. 使能USB控制器GPIO引脚,配置D+/D-为复用功能;
  3. 复位USB控制器,等待就绪;
  4. 调用USBx Device的初始化函数,注册描述符和类驱动回调;
  5. 连接USB(有些控制器是软件拉上拉电阻,有的控制内部上拉);
  6. 等待主机枚举。

初始化完成之后,应用逻辑就是裸机主循环。HID的上报、CDC的收发,都通过主循环轮询来实现。这里我强烈建议用环形缓冲区来收发数据。USB中断把数据搬进缓冲区,主循环再从缓冲区取走,或者主循环把待发送数据放进缓冲区,USB中断有IN令牌时从缓冲区取数据发送。这样中断和主循环不会互相踩内存,处理不过来时最多丢数据,不会导致系统崩溃。

3.4 数据收发接口封装

把USBx库跑起来之后,还得给应用层封装一套顺手的接口。我这边封装了几个接口:

  • HID发送:把当前按键状态和状态字打包,通过HID中断IN端点上报;
  • CDC发送:把需要发往主机的数据通过CDC Bulk IN端点发送;
  • CDC接收回调:主机下发数据时,把数据搬运到接收缓冲区,并置一个数据就绪标志;
  • CDC线路编码处理:主机修改波特率时,系统会调用这个回调,至少要做个空实现,不然Set Line Coding请求失败,虚拟串口可能打不开。

接口封装有个原则:应用层不要直接操作端点号,也不要直接调用usbd core的底层函数。中间加一层,一是方便后续换芯片,二是让逻辑层好维护。比如HID报表内容将来要从上报按键改成上报摇杆数据,只需要改封装层,不用动主逻辑。

4. 踩坑实录与排查技巧

4.1 枚举失败 / 设备描述符请求超时

这是USB开发最常见的问题,没有之一。现象是USB插上后主机提示“无法识别的USB设备”,或者枚举设备时设备反复复位。

我的排查顺序是固定的:

  1. 先量USB时钟,频率不对先解决时钟;
  2. 看D+上拉是否正常,全速设备是靠D+上拉告诉主机“这里有全速设备”的;
  3. 用USBlyzer或者Linux下的dmesg抓枚举过程,看主机发出的第一个请求是什么,设备有没有响应;
  4. 对比抓包得到的描述符与代码里定义的描述符,逐字节检查。

其中第三步最有价值。我遇到过一例很隐蔽的问题:描述符数组里“配置描述符总长度”只差了1个字节,枚举到配置描述符那一步就卡死,前面设备描述符一切正常。这种问题不用抓包工具,盯着设备管理器看一辈子也看不出原因。

4.2 设备管理器报错、HID 感叹号

Windows设备管理器里如果HID设备出现感叹号,常见原因有两类:一是描述符返回的数据跟主机期望的不一致,二是Report Descriptor校验失败。

描述符里的HID描述符有两个字段很容易写错:bcdHID版本号,一般写0x0111;wDescriptorLength,必须和实际Report Descriptor长度一致。之前见过有人在HID描述符里把bNumDescriptors写成2,说自己想放两个Report Descriptor,结果主机只认第一个,后面全乱套。

Report Descriptor本身也要注意。如果你声明了输入报表,但中断IN端点一直不上报数据,部分系统会在设备管理器里给HID设备打个黄色感叹号,提示“无法启动”。所以在验证阶段,建议先让HID定时上报数据,确认主机能读到,再做按键触发上报的逻辑。

4.3 CDC ACM 识别成未知设备或串口无数据

CDC ACM识别异常,九成问题出在描述符,剩下的一成出在回调没有注册。CDC识别成未知设备,优先检查两个地方:

第一,Union Functional Descriptor里bControlInterface和bSubordinateInterface0是否填对了接口编号。很多人把Union描述符里的接口编号写错,导致操作系统无法把控制接口和数据接口关联成一个串口功能。

第二,ACM Functional Descriptor的bmCapabilities字段。如果你希望主机支持Set Line Coding、Get Line Coding这些标准串口控制请求,这个字段要按规范设置。有些极简实现把bmCapabilities全写0,Windows下也能识别成串口,但打开串口时可能会异常。

还有个常见问题是识别成串口后,打开串口发数据没反应。这时候要看CDC的两个Bulk端点方向有没有搞反。USB的IN方向是设备到主机,OUT方向是主机到设备,如果你把主机发来的数据放到了IN端点,数据自然就发不出去。

4.4 数据乱码、丢包与实时性瓶颈

运行起来之后,最闹心的是数据乱码和丢包。我遇到过的原因有三种:

第一种,端点FIFO配置不足,Bulk端点数据量稍大就丢包。解决办法是给Bulk端点分配双缓冲的FIFO空间,AT32系列可以在配置里直接指定端点缓冲区大小,尽量给Bulk端点留够。

第二种,中断处理里数据拷贝耗时太长,导致下一包数据到来时上一包还没处理完。解决办法是中断里只做指针搬运和标志置位,实际数据解析放到主循环。

第三种,没有处理ZLP(零长度包)。当你发送的数据长度刚好是端点最大包长的整数倍时,USB协议要求再发送一个零长度包表示传输结束。很多批量传输丢最后一个包,就是ZLP没处理。在USBx库的CDC类驱动里一般会处理,但如果你用的是自己封装的接口,记得把这点加上。

实时性方面,HID的上报间隔主要看中断端点的bInterval。全速设备bInterval单位是ms,键盘类通常用10ms,自定义HID需要低延迟就设1ms,但端点占用带宽也会增加。CDC的数据吞吐主要靠Bulk端点,本身没有实时保证,所以不要把关键控制指令走CDC通道,控制逻辑走HID才是最稳的。

4.5 好用的调试工具和方法

最后分享几个我调试USB设备时常用的工具组合。

Windows下最推荐的是USBlyzer,可以抓USB枚举过程和后续的传输数据。Bus Hound也行,但界面古老,数据解析不如USBlyzer直观。设备管理器里的“查看→显示隐藏的设备”也能看到一些问题设备,但信息量太少。

Linux下就方便了,dmesg直接看内核日志,插上设备后什么错误一目了然;lsusb -v可以查看设备的完整描述符;lsusb -t可以看设备树结构;USB口的实际数据抓包可以用Wireshark配合usbmon。

HID调试方面,Windows下用HID调试助手或者官方自带的“游戏控制器”面板查看HID输入报表。CDC调试就用串口助手,先确认枚举出的COM口号,再打开测试收发。

调试阶段还有个隐藏技巧:在HID或者CDC的数据里加一个递增计数器。如果计数器连续,说明数据通路没问题;如果跳变,说明有丢包;如果乱序,说明缓冲区管理有并发问题。这个小技巧帮我解决了很多看起来莫名其妙的“偶尔丢数据”问题。

5. 最后的一点体会与建议

整套移植做完之后,最大的体会是:USB复合设备本身不难,难的是把“描述符”这关过了之后,还要在一个精简工程里把所有依赖都理顺。USBx Device库拆出来之后,代码量增加不多,但可控性比之前用官方大工程强太多,出问题能直接看到底层逻辑。

有几个习惯我建议保持:一是描述符相关代码全部用宏和sizeof自动计算长度,不要手算,手算必出错;二是保留一份USB抓包工具,枚举失败时候先抓包再改代码;三是每次改动只动一个变量,测完再动下一个,USB问题很多时候是“多个小问题叠加”造成的,一次性改太多反而定位不了问题。

如果后续还想扩展,可以考虑在这套框架上增加第二个HID接口,比如把厂商自定义HID和键盘HID同时做进去,或者把CDC从ACM改成RNDIS做虚拟网卡。USBx Device库的类驱动设计基本都支持多实例注册,核心逻辑不变,只是描述符和端点规划要重新排。这次lat1486 usb hid这个项目做到这里告一段落,后续有新的坑我再来补充。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询