ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux USB协议栈深度解析:主机侧与Gadget框架及调试实战

Linux USB协议栈深度解析:主机侧与Gadget框架及调试实战 1. 从插上U盘那一刻说起USB协议栈到底在忙什么你插上一个U盘系统桌面弹出图标文件管理器里多了一个盘符——整个过程不到两秒。但就在这两秒里Linux内核里有一整套分层协作的代码在高速运转主机控制器驱动识别到端口电平变化核心层枚举设备、读取描述符、匹配驱动存储类驱动把SCSI命令翻译成USB传输块设备层再把它挂成文件系统。这一整套协作机制就是我们说的USB协议栈框架。很多人学Linux驱动卡在USB这一块不是因为概念多难而是因为它的分层太多、数据结构太绕、调用链太长。看代码的时候一个usb_submit_urb下去经过HCD层、核心层、设备驱动层最后落到具体控制器的寄存器操作中间穿插着端点、管道、接口、配置、描述符、URB、Gadget等一堆术语很容易迷路。这篇内容就是想把这条链路彻底捋清楚从主机侧Host和设备侧Gadget两条线分别拆解讲明白每一层负责什么、数据结构怎么组织、一次完整的传输是怎么走完的。适合谁看如果你正在写USB设备驱动、调试USB通信问题、做嵌入式Linux开发或者准备面试被问到USB枚举流程这类问题这篇内容能给你一条清晰的脉络。我不会只贴代码而是把为什么这么设计讲透再补上我自己在实际调试中踩过的坑和验证过的排查方法。读完你至少能做到拿到一份USB相关的内核日志知道该从哪一层开始查看到一个USB驱动框架知道它的probe函数大概在什么时机被调用。先给一个全局认知Linux USB子系统大致分为主机侧和设备侧两大块。主机侧从上到下是USB设备驱动、USB核心usbcore、主机控制器驱动HCD设备侧从上到下是Gadget驱动、Gadget核心、UDC驱动。两边共享的是一套描述符和协议规范但代码路径完全不同。下面我们逐层拆。2. 主机侧的四层结构谁在什么时候被调用2.1 分层模型与数据流向主机侧的USB协议栈从用户空间往下看大致是这样一条链路用户空间应用程序通过文件系统、字符设备或libusb访问设备设备驱动层如usb-storage、usbhid、usbserial负责具体功能USB核心层usbcore管理设备、配置、接口、端点提供URB提交接口主机控制器驱动HCD如xhci-hcd、ehci-hcd、ohci-hcd操作硬件寄存器硬件xHCI/EHCI/OHCI/UHCI控制器数据从上往下走的时候核心动作是构造URB并提交从下往上走的时候核心动作是完成回调completion。理解这一点整个主机侧就通了一半。我习惯把URB理解成一次USB传输的快递单你填好收件地址端点、货物数据缓冲区、运输方式控制/批量/中断/等时交给核心层核心层转交给HCDHCD负责实际派送送完给你回执回调函数。这个类比虽然不严谨但对理解URB的生命周期非常有用。2.2 USB核心层干了哪些看不见的活很多人以为核心层只是转发其实它做了大量关键工作设备枚举。当HCD检测到新设备会通知核心层核心层负责分配struct usb_device读取设备描述符、配置描述符、接口描述符、端点描述符然后为设备分配一个唯一的地址1~127。这个过程叫枚举enumeration是USB最核心的机制之一。驱动匹配。核心层根据接口的class/subclass/protocol或者VID/PID去匹配已注册的struct usb_driver。匹配成功后调用驱动的probe函数。这里有个容易混淆的点USB驱动匹配的是接口interface不是设备device。一个复合设备可能有多个接口分别匹配不同驱动。URB管理。核心层提供usb_submit_urb、usb_kill_urb、usb_unlink_urb等接口管理URB的生命周期包括引用计数、超时、取消等。电源管理。autosuspend、remote wakeup这些机制也在核心层实现。我实测下来调试USB问题时dmesg里核心层打印的信息是最有价值的。比如usb 1-1: new high-speed USB device number 2 using xhci_hcd这条就包含了总线号、端口号、设备号、速度和所用HCD是排查的起点。2.3 HCD层和硬件打交道的那一层HCD是真正操作寄存器的层。以xHCI为例它要负责初始化控制器分配命令环、事件环、传输环处理端口状态变化中断把URB转换成TRBTransfer Request Block处理完成事件回调URB的completion函数不同HCD的差异很大。UHCI/OHCI是USB 1.1时代的EHCI是USB 2.0的xHCI是USB 3.x的。现在新机器基本都是xHCI它同时兼容USB 2.0和3.x设备。如果你在调试老设备可能会遇到EHCI和 companion controller 的配合问题这个后面踩坑部分会讲。HCD层的一个关键概念是端点endpoint和管道pipe。端点是设备侧的硬件概念管道是主机侧对端点方向类型的抽象。核心层用usb_sndbulkpipe、usb_rcvbulkpipe这类宏来构造管道号HCD根据管道号找到对应的端点信息。2.4 设备驱动层probe之后才是你的战场设备驱动层是大多数开发者真正要写代码的地方。一个典型的USB设备驱动结构是这样的static int my_probe(struct usb_interface *intf, const struct usb_device_id *id) { struct usb_device *dev interface_to_usbdev(intf); // 获取端点、分配缓冲区、注册字符设备等 return 0; } static void my_disconnect(struct usb_interface *intf) { // 清理资源 } static struct usb_driver my_driver { .name my_usb_driver, .id_table my_id_table, .probe my_probe, .disconnect my_disconnect, }; module_usb_driver(my_driver);probe被调用的时机是核心层完成枚举、匹配到id_table之后。在probe里你可以通过intf-cur_altsetting拿到当前接口的端点信息通过usb_rcvbulkpipe等构造管道然后提交URB开始通信。这里有个经验probe里不要做耗时操作。因为probe是在核心层的上下文里同步调用的你如果在这里睡眠太久或者提交大量URB等待完成会拖慢整个枚举流程甚至导致其他设备枚举超时。我见过有人在probe里直接读几MB数据结果系统启动时USB设备各种异常就是这个原因。正确做法是在probe里只做资源分配和初始化实际数据传输放到工作队列或字符设备被打开时再做。3. 设备侧Gadget框架把Linux变成USB设备3.1 Gadget三层结构与UDC的角色主机侧是我控制别人设备侧是我被别人控制。Linux的Gadget框架让一块开发板可以模拟成U盘、串口、网卡、HID设备等。它的分层是Gadget驱动层如mass_storage、serial、ether、hid实现具体功能Gadget核心层提供统一的API管理配置和端点UDC驱动层USB Device Controller操作具体硬件控制器UDC是设备侧的HCD它负责响应主机的枚举请求、处理端点中断、完成数据传输。常见的UDC驱动有dwc2、dwc3、musb、chipidea等不同SoC用的控制器不一样。3.2 从零配置一个Gadgetconfigfs实操现代内核推荐用configfs来配置Gadget比老的g_serial模块方式灵活得多。下面是我常用的一套配置流程以模拟一个串口设备为例# 挂载configfs通常已自动挂载 mount -t configfs none /sys/kernel/config # 创建gadget目录 cd /sys/kernel/config/usb_gadget mkdir g1 cd g1 # 设置VID/PID echo 0x1d6b idVendor echo 0x0104 idProduct # 设置字符串描述符 mkdir strings/0x409 echo 1234567890 strings/0x409/serialnumber echo My Vendor strings/0x409/manufacturer echo My Gadget strings/0x409/product # 创建配置 mkdir configs/c.1 mkdir configs/c.1/strings/0x409 echo CDC ACM configs/c.1/strings/0x409/configuration # 创建功能实例 mkdir functions/acm.usb0 ln -s functions/acm.usb0 configs/c.1/ # 绑定UDC ls /sys/class/udc echo fe980000.usb UDC绑定UDC之后用一根USB线把开发板连到主机主机就会枚举出一个CDC ACM设备在Linux主机上表现为/dev/ttyACM0在Windows上表现为一个串口。这套流程我反复用过很多次关键在于UDC的名字要对不同板子不一样ls /sys/class/udc一看便知。3.3 Gadget驱动怎么写以f_acm为参考如果你想自己写一个Gadget功能驱动最值得参考的是drivers/usb/gadget/function/f_acm.c。它的核心结构是定义struct usb_function实现bind、set_alt、disable、setup等回调在bind里申请端点、设置描述符在setup里处理主机发来的控制请求通过usb_ep_queue提交请求处理完成回调Gadget侧的URB叫struct usb_request概念上和URB对应但API不同。usb_ep_queue提交请求完成回调里再决定是否继续提交下一个形成循环。这个提交-回调-再提交的模式是Gadget数据传输的基本节奏。有个坑我踩过端点的FIFO深度和最大包长要匹配。如果请求长度超过端点能力或者对齐不对会出现数据丢失或传输卡死。调试时可以用cat /sys/kernel/debug/usb/udc/下的信息看端点状态非常有用。4. 一次控制传输的完整链路从get_descriptor说起4.1 控制传输的三个阶段USB枚举的核心是控制传输而控制传输最典型的就是GET_DESCRIPTOR。一次控制传输分三个阶段Setup阶段主机发送8字节的setup包包含请求类型、请求、值、索引、长度Data阶段根据方向传输数据可能是IN设备到主机或OUT主机到设备也可能没有Status阶段方向与Data阶段相反用于确认以读设备描述符为例主机发GET_DESCRIPTOR设备返回18字节的设备描述符。这18字节里包含了bLength、bDescriptorType、bcdUSB、bDeviceClass、idVendor、idProduct、bNumConfigurations等关键信息。4.2 内核里的调用链在主机侧读描述符的调用链大致是usb_get_device_descriptor() - usb_get_descriptor() - usb_control_msg() - usb_submit_urb() // 构造控制URB - hcd-driver-urb_enqueue() // HCD入队 - 硬件传输 - 完成中断 - urb-complete() 回调 - 返回数据这条链路里usb_control_msg是同步接口它会提交URB并等待完成所以不能在中断上下文调用。如果你在写驱动时需要发控制请求记得用工作队列或者确保在进程上下文。4.3 用usbmon抓包验证理论讲再多不如抓一次包。Linux内核自带usbmon可以抓USB总线上的所有传输。用法# 加载usbmon模块 modprobe usbmon # 查看总线 ls /sys/kernel/debug/usb/usbmon/ # 抓1号总线的包 cat /sys/kernel/debug/usb/usbmon/1u /tmp/usb.log抓到的日志里S开头是提交C开头是完成Ci是控制传输的setup阶段。通过对比setup包里的请求和完成包里的数据可以精确验证枚举流程。我调试一个自定义设备时就是靠usbmon发现设备返回的配置描述符长度不对导致核心层解析失败最终定位到固件里的描述符表写错了。5. 调试USB问题的排查链路与常见坑5.1 从dmesg开始读懂内核的病历USB出问题第一件事永远是dmesg | grep -i usb。常见的日志和含义日志片段含义排查方向new high-speed USB device检测到高速设备正常继续看后续device descriptor read/64, error -71读描述符失败协议错误硬件连接、信号完整性device not accepting address设备不接受地址设备固件、供电unable to enumerate USB device枚举失败综合看前面日志reset high-speed USB device设备被复位可能是供电或固件问题error -71是EPROTO最常见的原因是信号质量差、线缆太长、设备供电不足。我遇到过一根劣质USB延长线导致枚举随机失败换线就好这种问题查代码是查不出来的。5.2 枚举失败的典型原因枚举失败的原因可以归为几类供电不足设备需要的电流超过端口能提供的尤其是没有外部供电的硬盘盒信号完整性线缆质量、走线、阻抗不匹配固件问题描述符格式错误、响应超时、端点配置不对驱动问题HCD bug、companion controller配合问题排查顺序建议先换线换端口再看dmesg再用usbmon抓包最后才怀疑代码。这个顺序能帮你快速排除大部分低级问题。5.3 一个真实的排查案例之前有个项目一块自定义USB设备在部分机器上枚举失败在另一些机器上正常。dmesg显示device descriptor read/64, error -71。我按顺序排查换线换端口——无效问题依旧usbmon抓包——发现setup包发出后设备没有响应示波器看D/D-信号——发现信号上升沿有明显振铃检查硬件——发现设备端没有加合适的匹配电阻最终在D线上加了一个串联电阻问题解决。这个案例说明USB问题不一定是软件问题信号完整性在高速模式下非常关键。全速12Mbps对信号要求低一些高速480Mbps就敏感得多。5.4 Gadget侧的常见坑Gadget侧我踩过的坑主要有UDC绑定失败通常是UDC已被占用或者gadget配置不完整。echo到UDC时报Device or resource busy先检查是不是已经有gadget绑定了端点数量不够一个gadget用了太多端点UDC支持不了。看ep_autoconfig的日志描述符不匹配configfs里配置的功能和实际描述符对不上主机枚举出来的设备不对传输卡死请求提交后没有回调通常是端点没使能或者请求长度不对调试Gadget/sys/kernel/debug/usb/udc_name/下的文件是宝藏能看到端点状态、请求队列等。另外主机侧的usbmon同样适用于观察Gadget和主机的交互。6. 数据结构与关键API速查6.1 主机侧核心数据结构理解USB协议栈绕不开几个核心结构体struct usb_device代表一个USB设备包含设备号、速度、描述符、配置列表等struct usb_interface代表一个接口驱动匹配的单位struct usb_host_endpoint代表一个端点包含端点描述符和URB队列struct urbUSB请求块一次传输的载体struct usb_driverUSB驱动包含id_table和probe/disconnect这些结构体的关系是一个device有多个config一个config有多个interface一个interface有多个endpoint除端点0外。驱动绑定在interface上通过interface找到device和endpoint。6.2 URB的提交与完成URB的使用有一套固定流程struct urb *urb usb_alloc_urb(0, GFP_KERNEL); usb_fill_bulk_urb(urb, dev, pipe, buf, len, complete_fn, context); usb_submit_urb(urb, GFP_KERNEL); // 在complete_fn里处理结果然后usb_free_urb关键点usb_alloc_urb的第二个参数是ISO包数量非等时传输传0usb_fill_bulk_urb是宏填充URB的各个字段usb_submit_urb在原子上下文用GFP_ATOMIC进程上下文用GFP_KERNEL完成回调在中断上下文执行不能睡眠我见过有人在完成回调里调用usb_control_msg结果系统崩溃就是因为控制传输会睡眠。正确做法是用工作队列把后续处理推迟到进程上下文。6.3 Gadget侧核心APIGadget侧的API和主机侧对应但不同usb_ep_alloc_request/usb_ep_free_request申请/释放请求usb_ep_queue提交请求usb_ep_dequeue取消请求usb_ep_enable/usb_ep_disable使能/禁用端点请求的完成回调里通常要判断req-status然后决定是否重新提交。这个循环是Gadget数据传输的核心。7. 从框架到实战几个值得深挖的方向7.1 USB复合设备与接口关联复合设备Composite Device是USB里比较复杂的场景。一个物理设备有多个接口每个接口匹配不同驱动但设备只有一个地址。核心层用usb_interface_association_descriptorIAD来描述接口的关联关系Windows下尤其依赖IAD来正确加载驱动。如果你做的是复合设备比如一个设备同时是串口存储就要注意每个接口的class/subclass/protocol要正确需要IAD的话要加上总电流不能超过配置描述符里声明的7.2 USB电源管理与autosuspendUSB设备的电源管理是个大话题。autosuspend允许设备在空闲时进入低功耗状态但很多设备不支持或者支持得不好会导致唤醒失败。调试时可以用# 查看设备的电源状态 cat /sys/bus/usb/devices/1-1/power/control # 禁用autosuspend echo on /sys/bus/usb/devices/1-1/power/control如果设备在autosuspend后无法唤醒先禁用autosuspend验证再考虑在驱动里实现remote wakeup。7.3 性能优化批量传输的吞吐量批量传输的吞吐量受多个因素影响端点最大包长、URB大小、提交频率、HCD调度。优化思路增大URB缓冲区减少提交次数使用多个URB流水线提交让HCD有活干对于高速设备端点最大包长512字节一次传输可以带多个包我实测过同样的硬件单URB提交和双URB流水线提交吞吐量能差30%以上。这个优化在数据采集类设备上很有价值。7.4 用tracepoint和ftrace追踪USB除了usbmon内核还提供了USB相关的tracepoint可以用ftrace追踪cd /sys/kernel/debug/tracing echo 1 events/usb/enable cat trace_pipe能看到usb_urb_submit、usb_urb_complete等事件比usbmon更轻量适合长时间追踪。8. 我在这块踩过的坑和几条实用建议先说几个印象深刻的坑。第一个是端点0的最大包长。控制传输用的端点0它的最大包长在设备描述符的bMaxPacketSize0字段里。如果这个值写错了枚举就会失败。我见过固件里把它写成64但设备实际只支持8结果主机按64发数据设备收不下直接卡死。这个字段一定要和硬件能力匹配。第二个是配置描述符的总长度。wTotalLength必须等于所有配置、接口、端点、类特定描述符的长度之和。少算一个字节核心层解析就会出错。我调试时用usbmon抓包对比主机请求的长度和设备返回的长度一眼就能看出问题。第三个是Gadget的UDC绑定顺序。必须先创建好所有function并链接到config最后才echo UDC。顺序错了会报错。而且一个UDC同时只能绑定一个gadget切换时要先解绑。几条实用建议调试USB先抓包再改代码。usbmon和dmesg能告诉你90%的问题写驱动时probe里只做初始化数据传输放到后面完成回调里不要睡眠需要睡眠就用工作队列Gadget配置用configfs比老式模块灵活遇到枚举问题先怀疑硬件和线缆再怀疑代码最后分享一个我常用的调试组合dmesg -w实时看内核日志同时cat /sys/kernel/debug/usb/usbmon/1u抓包两边对照基本能定位大部分USB问题。这套方法我在多个项目里验证过比盲目看代码高效得多。USB协议栈看着复杂但把分层理清楚、把数据流搞明白剩下的就是熟练度问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进