
1. 从一个真实场景说起为什么我要啃USB协议栈做嵌入式Linux开发的朋友大概率都遇到过这样的场景板子插上USB设备dmesg里刷出一堆日志设备节点也出来了但数据就是不通或者更诡异的是同一个U盘在PC上好好的插到自研板子上枚举都过不去。这时候你去问别人得到的回答往往是看看USB驱动——废话我当然知道看驱动问题是看哪一层USB协议栈在Linux内核里算是一个看起来简单、用起来复杂、调起来要命的子系统。它不像字符设备那样一条线走到底而是分了主机侧Host和设备侧Gadget两大阵营中间还夹着USB核心层USB Core、HCDHost Controller Driver、UDCUSB Device Controller、各类Class驱动、Gadget Function等等。层次一多出问题时定位就变成了猜谜游戏。这篇内容我打算把Linux USB协议栈的框架从头到尾捋一遍不是照本宣科翻译内核文档而是按照一个实际开发者理解它的路径来组织先搞清楚整体分层和数据流向再逐层拆解关键结构体和注册流程然后落到实操——怎么加打印、怎么看枚举过程、怎么排查最常见的几类问题。适合正在做USB驱动开发、调试USB外设、或者准备面试被问到USB子系统的朋友。看完之后你至少能做到拿到一份USB相关的内核日志知道每一行大概来自哪一层出了问题该往哪个方向查。2. Linux USB协议栈的整体分层与设计思路2.1 主机侧与设备侧两条独立的栈很多人初学USB协议栈时最大的困惑就是为什么内核里drivers/usb/目录下既有host/又有gadget/还有core/它们之间是什么关系答案其实很直接Linux USB子系统实际上是两套并行的栈一套负责我作为主机去控制别的USB设备Host侧另一套负责我作为一个USB设备被别的主机控制Gadget侧。这两套栈共享的只有最底层的USB协议规范定义和部分核心数据结构代码路径基本是分开的。Host侧的典型场景你的开发板作为主机插U盘、插键盘、插4G模块。Gadget侧的典型场景你的开发板作为设备被PC识别成一个串口、一个网卡、一个U盘。现在很多SoC的USB控制器比如DWC3、MUSB是双角色的通过OTG或Type-C的CC引脚来切换角色但软件栈依然是两套。这个设计的好处是职责清晰Host侧关心的是如何调度多个设备的带宽、如何管理枚举、如何给上层Class驱动提供统一接口Gadget侧关心的是如何响应主机的标准请求、如何把数据从Function层搬到UDC层。两者混在一起反而会让代码难以维护。2.2 主机侧的四层结构Host侧的层次从下往上依次是USB Host Controller Hardware硬件控制器比如xHCIUSB 3.x、EHCIUSB 2.0高速、OHCI/UHCIUSB 1.1。HCDHost Controller Driver对应drivers/usb/host/下的xhci-hcd.c、ehci-hcd.c等负责把USB Core下发的请求翻译成控制器能理解的TDTransfer Descriptor或TRBTransfer Request Block。USB Coredrivers/usb/core/这是整个Host侧的中枢负责设备枚举、配置管理、URBUSB Request Block的分配与提交、驱动与设备的匹配。USB Class Driversdrivers/usb/class/、drivers/hid/usbhid/、drivers/usb/storage/等具体处理某一类设备比如mass storage、HID、CDC-ACM。数据流向是这样的上层Class驱动构造一个URB提交给USB CoreUSB Core把URB挂到对应设备的端点队列上交给HCDHCD把URB拆成硬件能执行的传输描述符写寄存器触发传输传输完成后硬件产生中断HCD在中断处理里回收URB回调上层完成函数。2.3 设备侧的层次结构Gadget侧从下往上UDCUSB Device Controller硬件控制器驱动比如dwc3-gadget.c、musb_gadget.c。Gadget Coredrivers/usb/gadget/udc/下的udc-core.c管理UDC的注册、Gadget Driver的绑定。Composite Layerdrivers/usb/gadget/composite.c负责把多个Function组合成一个复合设备处理配置描述符的拼装。Function Driversf_acm.c、f_mass_storage.c、f_ecm.c等实现具体的功能。设备侧的核心是描述符和端点。主机发来标准请求GET_DESCRIPTOR、SET_CONFIGURATION等Gadget Core和Composite层负责解析并回应Function层负责具体端点的数据收发。2.4 为什么这样分层这个分层不是拍脑袋定的背后有几个硬约束第一硬件差异巨大。xHCI和EHCI的寄存器模型完全不同但上层驱动不应该关心这些差异所以必须有HCD这一层做抽象。第二设备类型繁多。USB-IF定义了上百种Class如果每加一种设备就改Core那Core会爆炸。所以Class驱动独立出来通过usb_driver结构体注册由Core负责匹配。第三Gadget侧需要动态组合。同一个硬件可能今天做串口明天做网卡后天做U盘所以Composite层要能把Function像积木一样拼起来。理解了这三条再看代码就不会迷路。3. 核心数据结构与注册流程拆解3.1 usb_device、usb_interface与usb_driverHost侧最核心的三个结构体是usb_device、usb_interface和usb_driver。usb_device代表一个物理USB设备对应内核里的一个struct device。它包含了设备描述符、配置描述符数组、当前配置、端点信息等。每个usb_device下面挂着若干个usb_interface一个interface对应设备的一个功能。比如一个USB耳机可能有两个interface一个Audio Control一个Audio Streaming。usb_driver是驱动开发者最常打交道的结构体关键字段包括static struct usb_driver my_driver { .name my_usb_driver, .id_table my_id_table, .probe my_probe, .disconnect my_disconnect, };id_table是匹配表里面用USB_DEVICE(vendor, product)或USB_INTERFACE_CLASS(class)这样的宏来声明本驱动支持哪些设备。内核在枚举到一个新设备时会遍历所有已注册的usb_driver用id_table去匹配匹配成功就调用probe。这里有个容易踩的坑匹配的粒度是interface而不是device。也就是说如果你的设备有多个interfaceprobe会被调用多次每次传入一个usb_interface。很多新手在probe里做全局初始化结果被调用多次导致资源重复申请。正确做法是把每个interface当作独立实例来处理。3.2 URBUSB传输的载体URBUSB Request Block是Host侧数据传输的基本单位。你可以把它理解成一个USB传输请求的快递单上面写着发给哪个端点、什么传输类型、数据缓冲区在哪、多长、完成后回调哪个函数。一个典型的URB提交过程struct urb *urb usb_alloc_urb(0, GFP_KERNEL); usb_fill_bulk_urb(urb, udev, usb_sndbulkpipe(udev, ep), buf, len, my_complete, context); usb_submit_urb(urb, GFP_KERNEL);usb_submit_urb之后URB就交给了USB CoreCore再交给HCD。传输完成后HCD在中断上下文里调用my_complete回调。注意完成回调运行在中断上下文里面不能睡眠不能调用可能引起调度的函数。如果需要做耗时操作用工作队列或tasklet转一下。URB的生命周期管理是USB驱动最容易出bug的地方。常见问题包括URB提交后设备被拔掉回调里访问已释放的内存URB还没完成就usb_free_urb在disconnect里忘记usb_kill_urb导致回调在设备结构体释放后触发。这些后面会专门讲。3.3 Gadget侧的usb_gadget与usb_epGadget侧的核心结构体是usb_gadget和usb_ep。usb_gadget代表一个UDC实例关键字段有ep_list端点链表、speed当前速度、ops操作函数集。usb_ep代表一个端点关键字段有name、maxpacket、ops。Gadget Driver通过usb_gadget_probe_driver注册自己UDC Core在合适的时机调用Gadget Driver的bind函数。在bind里Gadget Driver要遍历gadget-ep_list找到自己需要的端点并保存下来。端点的命名规则是ep1in、ep1out这样的形式in表示设备到主机out表示主机到设备。注意这是从主机视角命名的初学者容易搞反。3.4 描述符的组织方式USB设备的描述符是分层的设备描述符 - 配置描述符 - 接口描述符 - 端点描述符。在Gadget侧这些描述符通常用宏来定义static struct usb_device_descriptor device_desc { .bLength USB_DT_DEVICE_SIZE, .bDescriptorType USB_DT_DEVICE, .bcdUSB cpu_to_le16(0x0200), .bDeviceClass USB_CLASS_PER_INTERFACE, .idVendor cpu_to_le16(0x1d6b), .idProduct cpu_to_le16(0x0104), .bNumConfigurations 1, };Composite层会把这些描述符拼成一个完整的配置描述符块在主机发GET_DESCRIPTOR时一次性返回。这里的关键是描述符的长度和偏移必须严格正确一个字节错了主机就可能枚举失败。调试时可以用lsusb -v在PC侧看实际枚举出来的描述符和代码里定义的对比。4. 实操从枚举日志到驱动加载的完整追踪4.1 打开USB Core的调试日志内核里USB Core有比较完善的调试日志通过CONFIG_USB_DEBUG老内核或动态调试新内核打开。新内核推荐用dynamic debug# 挂载debugfs mount -t debugfs none /sys/kernel/debug # 打开usb core的所有调试信息 echo file drivers/usb/core/* p /sys/kernel/debug/dynamic_debug/control # 打开hcd的调试信息 echo file drivers/usb/host/* p /sys/kernel/debug/dynamic_debug/control打开后dmesg里会看到非常详细的枚举过程包括每一次控制传输的请求类型、请求码、数据长度、返回状态。这是排查枚举问题的第一手资料。4.2 一次完整枚举的日志解读插上一个USB设备典型的日志序列是这样的usb 1-1: new high-speed USB device number 2 using xhci_hcd usb 1-1: New USB device found, idVendor0781, idProduct5567 usb 1-1: New USB device strings: Mfr1, Product2, SerialNumber3 usb 1-1: Product: Cruzer Blade usb 1-1: Manufacturer: SanDisk usb 1-1: SerialNumber: 4C530001120523116195 usb-storage 1-1:1.0: USB Mass Storage device detected scsi host0: usb-storage逐行解读第一行HCD检测到端口状态变化复位后设备以high-speed接入分配了设备号2。第二行设备描述符读取成功打印VID/PID。第三到六行读取字符串描述符打印厂商、产品、序列号。第七行usb-storage驱动匹配到了interface 0开始probe。第八行SCSI子系统注册了一个host。如果枚举卡在某一步比如只打印了第一行就没有后续那问题通常出在控制传输阶段可能是硬件信号完整性问题、可能是描述符返回长度不对、也可能是HCD的TD调度有问题。4.3 用usbmon抓USB总线数据usbmon是内核提供的USB抓包工具能看到总线上实际传输的每一个包。用法# 加载usbmon模块 modprobe usbmon # 查看有哪些总线 ls /sys/kernel/debug/usb/usbmon/ # 抓1号总线的数据 cat /sys/kernel/debug/usb/usbmon/1u usb.log抓到的数据可以用Wireshark打开Wireshark支持usbmon格式能看到SETUP包、DATA包、ACK包的完整内容。排查主机发了请求但设备没响应这类问题时usbmon是终极武器——它能明确告诉你问题出在主机侧还是设备侧。4.4 在probe里加打印定位驱动问题如果设备枚举成功了但功能不正常下一步是在驱动的probe里加打印static int my_probe(struct usb_interface *intf, const struct usb_device_id *id) { struct usb_device *udev interface_to_usbdev(intf); int ep_in, ep_out; dev_info(intf-dev, probe: VID%04x PID%04x\n, le16_to_cpu(udev-descriptor.idVendor), le16_to_cpu(udev-descriptor.idProduct)); ep_in intf-cur_altsetting-endpoint[0].desc.bEndpointAddress; ep_out intf-cur_altsetting-endpoint[1].desc.bEndpointAddress; dev_info(intf-dev, ep_in0x%02x ep_out0x%02x\n, ep_in, ep_out); return 0; }注意endpoint[0]不一定是IN端点顺序取决于描述符里的定义。稳妥的做法是遍历intf-cur_altsetting-endpoint数组根据bEndpointAddress的最高位判断方向。4.5 Gadget侧的功能验证Gadget侧验证相对麻烦因为需要另一台机器做主机。常用方法是配置一个g_serial或g_mass_storage然后在PC上看是否识别# 加载g_serial模块 modprobe g_serial # 在PC上应该能看到一个新的串口设备 # Linux下是/dev/ttyACM0Windows下是COM口如果PC没反应先在开发板上dmesg看UDC是否成功注册、Gadget Driver是否bind成功。常见问题是UDC驱动没加载、或者/sys/class/udc/下没有控制器实例。5. 常见问题与排查技巧实录5.1 枚举失败类问题速查现象可能原因排查方向完全无日志硬件未检测到检查VBUS、D/D-接线、时钟有new device但无后续控制传输失败usbmon抓包看SETUP阶段读到描述符但长度错误描述符定义有误对比lsusb -v输出枚举成功但驱动不probeid_table不匹配检查VID/PID/Classprobe成功但功能异常端点配置或URB问题检查端点地址、传输类型5.2 URB相关的经典坑坑一在disconnect里忘记kill URB。设备拔掉后已提交的URB会以-ESHUTDOWN完成回调仍会被调用。如果回调里访问了已经释放的私有数据结构就是use-after-free。正确做法static void my_disconnect(struct usb_interface *intf) { struct my_dev *dev usb_get_intfdata(intf); usb_set_intfdata(intf, NULL); if (dev) { usb_kill_urb(dev-urb); // 确保所有URB完成 usb_free_urb(dev-urb); kfree(dev); } }usb_kill_urb会等待URB完成或取消返回后就不会再有回调了。坑二在完成回调里睡眠。完成回调在中断上下文kmalloc(GFP_KERNEL)、mutex_lock、msleep都会出问题。需要睡眠的操作丢到工作队列。坑三URB重复提交。同一个URB在未完成前不能再次提交否则内核会打印警告并拒绝。如果需要连续传输在完成回调里重新提交。5.3 Gadget侧的常见问题Gadget侧最常见的问题是端点资源不足。一个UDC的端点数量有限如果Composite里配置了太多Function每个Function又要多个端点就会bind失败。排查方法是看/sys/kernel/debug/usb/udc/下的端点使用情况或者直接在bind失败时打印gadget-ep_list里还剩哪些端点。另一个常见问题是描述符不匹配。Composite层会根据Function的fs_descriptors、hs_descriptors、ss_descriptors来拼装不同速度下的配置描述符。如果只定义了高速描述符全速主机来枚举时就会失败。稳妥做法是三种速度都定义或者用usb_copy_descriptors做转换。5.4 性能调优的几个点如果USB传输吞吐上不去可以从这几个方向查URB大小批量传输的URB缓冲区太小会导致频繁中断建议至少4KB以上。URB数量单个URB串行提交会有等待间隙可以用多个URB做流水线。中断合并xHCI支持中断合并IMOD调整/sys/bus/pci/devices/.../imod_interval可以减少中断频率。内存对齐DMA缓冲区最好按cache line对齐避免cache一致性问题导致的性能损失。6. 我踩过的几个真实坑与经验总结第一个坑是把interface当成device。早期写一个USB转串口驱动时我在probe里申请了一个全局的接收缓冲区结果设备有两个interfaceprobe被调用两次第二次把第一次的缓冲区覆盖了数据就乱了。后来改成每个interface一份私有数据用usb_set_intfdata挂上去问题解决。这个教训是USB驱动的实例粒度是interface不是device。第二个坑是在完成回调里直接处理业务逻辑。有个项目需要在收到数据后做协议解析解析函数里用了mutex结果在中断上下文里直接崩了。后来改成回调里只做数据拷贝和唤醒工作队列解析放到工作队列里做。虽然多了一次上下文切换但稳定性完全不一样。第三个坑是Gadget描述符的wTotalLength算错。Composite层拼装描述符时wTotalLength是所有描述符长度之和。我手动改了一个Function的描述符忘了更新wTotalLength结果主机枚举时读到的配置描述符不完整直接枚举失败。这个问题的隐蔽性在于代码编译没问题运行时也不报错就是主机不认。后来养成了习惯改完描述符一定用lsusb -v在PC侧核对一遍。第四个坑是忽略了USB 3.0的SS描述符。一个项目从USB 2.0升级到USB 3.0硬件换了xHCI控制器但Gadget侧只定义了hs_descriptors没有ss_descriptors。结果设备插到USB 3.0口上时主机尝试以SuperSpeed枚举失败回退到HighSpeed才成功。虽然功能能用但性能没上去。后来补了SS描述符才真正跑在USB 3.0速率上。最后分享一个调试习惯每次改USB相关代码先在PC侧用lsusb -v和dmesg确认枚举正常再测功能。枚举是所有USB功能的基础枚举不过后面都是白搭。把枚举日志看熟能省掉大量瞎猜的时间。