1. USB协议栈到底解决了什么问题
Linux内核里USB子系统是我见过最"分层清晰"但又最容易让人迷路的模块之一。很多做了两三年驱动的兄弟,能写个字符设备、能调个I2C传感器,一碰到USB就发怵——设备插上去没反应,dmesg里一堆urb、endpoint、descriptor的字眼,完全不知道从哪下手。这篇就把Linux USB协议栈从硬件插拔到应用读写这条链路完整拆一遍,讲清楚每一层在干什么、数据怎么流动、出问题该去哪个目录翻代码。
先说清楚这个内容适合谁看:一是做嵌入式Linux驱动开发、需要对接USB外设的工程师;二是做系统运维、想搞明白lsusb背后到底发生了什么的同学;三是准备内核相关面试、需要把USB子系统讲明白的人。看完你应该能做到:拿到一个USB设备,知道内核从哪几个阶段识别它,知道写一个USB驱动需要填哪些结构体,知道传输卡住时该看哪些计数器和日志。
USB协议栈的核心价值在于统一。在USB出现之前,键盘用PS/2、打印机用并口、扫描仪用SCSI卡,每种外设一套接口。USB把这堆东西收敛成一套"主机控制器 + 集线器 + 设备"的树形拓扑,用四种传输类型覆盖所有场景。Linux内核要做的事情,就是把这套硬件规范翻译成内核里统一的设备模型,让上层驱动不用关心底下是UHCI、OHCI还是XHCI。
我个人的经验是,理解USB协议栈不要一上来就啃spec,那本USB 2.0规范六百多页,看完前面忘后面。正确的路径是:先建立"分层 + 数据结构 + 数据流"三个维度的框架认知,再顺着一次真实的枚举过程去读代码,最后才是抠某个具体传输类型的细节。下面我就按这个思路展开。
2. 整体架构分层与设计思路拆解
2.1 从硬件到应用的五层结构
Linux USB协议栈从上到下大致可以切成五层,每一层的职责边界非常清楚,这也是它设计上最值得学习的地方。
最上面是USB设备驱动层(USB Device Drivers),比如drivers/usb/storage里的U盘驱动、drivers/usb/serial里的串口驱动、drivers/hid/usbhid里的键鼠驱动。这一层直接和具体的设备打交道,实现probe、disconnect,注册file_operations给用户空间用。
往下是USB核心层(USB Core),代码在drivers/usb/core。这一层是整个协议栈的中枢,负责设备枚举、配置管理、驱动匹配、urb(USB Request Block)的提交和回调分发。它向上给设备驱动提供usb_register、usb_submit_urb这些API,向下调用主机控制器驱动。
再往下是主机控制器驱动层(HCD, Host Controller Driver),代码在drivers/usb/host。这一层对应具体的硬件控制器:uhci-hcd.c、ohci-hcd.c、ehci-hcd.c、xhci-hcd.c。它负责把USB Core发来的urb翻译成控制器能识别的描述符(TD、QH等),并处理硬件中断。
然后是硬件控制器本身,UHCI/OHCI对应USB 1.1,EHCI对应USB 2.0,XHCI对应USB 3.x。再往下就是物理层的USB线缆、集线器和设备了。
用户空间这一侧,通过usbfs(现在叫usbdevfs)挂载在/dev/bus/usb/下,lsusb、libusb这些工具就是通过它直接和USB Core通信的,绕过设备驱动。
2.2 为什么这样分层
这个分层不是拍脑袋定的,背后有三个硬约束。
第一是硬件多样性。主机控制器有四种规格,如果USB Core直接和硬件打交道,那每支持一种控制器就要改一遍核心代码。分层之后,HCD层把硬件差异封装成统一的hcd_driver接口,USB Core只认接口不认硬件。
第二是设备多样性。USB设备类型成千上万,如果都塞进核心层,代码会爆炸。分层之后,设备驱动可以独立编译成模块,按需加载,insmod一个驱动就能支持一类设备。
第三是传输语义的抽象。USB有控制、中断、批量、等时四种传输,它们的时序要求、错误处理、带宽保证完全不同。urb这个数据结构把这四种传输统一成一个对象,设备驱动只需要填urb的字段然后提交,不用管底下怎么调度。
提示:很多人搞混urb和endpoint的关系。endpoint是设备侧的硬件概念,一个设备最多16个IN + 16个OUT端点;urb是主机侧软件概念,一次传输请求对应一个urb,urb里指定目标endpoint。一个endpoint上可以排队多个urb。
2.3 关键数据结构总览
理解USB协议栈,绕不开几个核心结构体,我把它们的关系整理成一张表,方便对照记忆。
| 结构体 | 所在文件 | 作用 |
|---|---|---|
struct usb_device | include/linux/usb.h | 描述一个USB设备,包含设备地址、描述符、配置列表 |
struct usb_interface | include/linux/usb.h | 描述设备的一个接口,驱动实际绑定的是接口 |
struct usb_host_endpoint | include/linux/usb.h | 描述一个端点,含端点描述符和urb队列 |
struct urb | include/linux/usb.h | USB请求块,一次传输的载体 |
struct usb_driver | include/linux/usb.h | 设备驱动注册的结构体 |
struct hc_driver | include/linux/usb/hcd.h | 主机控制器驱动接口 |
struct usb_hcd | include/linux/usb/hcd.h | 主机控制器实例 |
这里有个容易踩的坑:驱动绑定的是interface不是device。一个USB设备可以有多个配置,每个配置有多个接口,每个接口有多个端点。比如一个USB音箱,可能有一个音频控制接口和一个音频流接口,对应两个驱动。所以写驱动时probe函数的参数是struct usb_interface *,不是struct usb_device *。
3. 核心细节解析与实操要点
3.1 设备枚举:从插上到被识别
设备插上那一刻发生的事情,是整个协议栈最精彩的部分。我按时间顺序拆一遍。
第一步,端口检测。主机控制器(以XHCI为例)检测到端口状态变化,产生中断。HCD层读取端口寄存器,发现是连接事件,调用hub_port_connect_change。这里注意,根集线器(root hub)是虚拟的,由HCD模拟出来,代码在hub.c里。
第二步,复位与地址分配。新设备默认地址是0,主机先给它复位,然后通过控制传输发送SET_ADDRESS请求,分配一个1到127之间的唯一地址。这一步之后,设备才真正"上线"。
第三步,读设备描述符。主机先读8字节的设备描述符(因为bMaxPacketSize0字段在第8字节,需要先知道端点0的最大包长),然后再完整读18字节。设备描述符里有厂商ID、产品ID、设备类、配置数量等。
第四步,读配置描述符。配置描述符长度不固定,因为后面跟着接口描述符和端点描述符。主机先读9字节拿到wTotalLength,再按这个长度读完整块。这一整块数据在代码里叫config descriptor,实际包含配置、接口、端点、类特定描述符。
第五步,选择配置。主机发送SET_CONFIGURATION请求,设备进入配置状态。此时设备的所有接口都激活,等待驱动绑定。
第六步,驱动匹配。USB Core遍历已注册的usb_driver,用id_table里的VID/PID/设备类去匹配接口。匹配上就调用驱动的probe函数。
整个枚举过程在hub.c的hub_port_init和usb_new_device里,配合generic.c里的usb_get_device_descriptor、usb_get_configuration。想调试枚举问题,把CONFIG_USB_DEBUG打开,dmesg里会打印每一步的细节。
3.2 四种传输类型的选型逻辑
USB定义了四种传输类型,选错了会导致性能问题甚至功能异常。我把它们的特性和适用场景整理如下。
| 传输类型 | 带宽保证 | 可靠性 | 典型用途 | 端点方向 |
|---|---|---|---|---|
| 控制传输 | 无 | 高(有重传) | 枚举、配置、类请求 | 双向(端点0) |
| 中断传输 | 有(周期保证) | 高(有重传) | 键盘、鼠标、游戏手柄 | IN为主 |
| 批量传输 | 无 | 高(有重传) | U盘、打印机、网卡 | 双向 |
| 等时传输 | 有(带宽预留) | 低(不重传) | 音频、视频 | 双向 |
选型逻辑其实很直接:要保证延迟且数据量小,用中断;要保证带宽且能容忍丢包,用等时;要保证数据正确性且不在乎延迟,用批量;设备管理相关,用控制。
这里有个实操经验:中断传输的"中断"不是硬件中断的意思,而是主机周期性轮询设备。轮询间隔由端点描述符里的bInterval决定,全速设备是1到255毫秒,高速设备是1到16个微帧(每个微帧125微秒)。如果你写一个自定义设备用中断传输,bInterval设太小会占满总线带宽,设太大响应又慢,需要根据实际数据量算。
3.3 urb的生命周期管理
urb是USB传输的核心,它的生命周期分五个阶段:分配、填充、提交、回调、释放。
分配用usb_alloc_urb,参数是等时传输的包数和内存分配标志(GFP_KERNEL或GFP_ATOMIC)。填充阶段根据传输类型调用不同的初始化函数:usb_fill_control_urb、usb_fill_int_urb、usb_fill_bulk_urb、usb_fill_iso_urb。提交用usb_submit_urb,这个函数可以在原子上下文调用(如果urb是GFP_ATOMIC分配的)。回调函数在传输完成时由HCD层调用,可能在中断上下文,所以回调里不能睡眠。释放用usb_free_urb。
注意:urb提交后不能立即释放,必须等回调执行完。如果驱动在回调里重新提交urb(比如中断传输的轮询),要保证释放时urb已经不再被提交。常见做法是在
disconnect里先usb_kill_urb再usb_free_urb。
我踩过的一个坑:在回调函数里调用usb_submit_urb重新提交,结果设备拔掉时urb还在队列里,disconnect里没杀干净,导致内核oops。正确做法是用usb_kill_urb(会等待urb完成)而不是usb_unlink_urb(异步取消),并且在disconnect里先停掉所有重新提交的逻辑。
3.4 端点与管道的对应关系
端点描述符里有几个关键字段:bEndpointAddress(bit7是方向,0是OUT,1是IN;低4位是端点号)、bmAttributes(bit1-0是传输类型)、wMaxPacketSize(最大包长)、bInterval(轮询间隔)。
管道(pipe)是USB Core内部用的概念,把端点地址、设备地址、传输类型打包成一个整数。usb_sndctrlpipe、usb_rcvctrlpipe这些宏就是构造管道的。设备驱动一般不直接操作管道,但看代码时会遇到,知道它是"设备地址 + 端点信息"的编码就行。
wMaxPacketSize这个字段要特别注意,它决定了单次传输的最大包长。高速设备的批量端点最大512字节,中断端点最大1024字节,控制端点最大64字节。如果你提交的urb长度超过端点能力,USB Core会自动拆分成多个包,但驱动层最好自己控制单次传输长度,避免一次提交太大导致延迟。
4. 实操过程与核心环节实现
4.1 环境准备与调试工具
要研究USB协议栈,先得有个能观察的环境。我一般用两种方式:一是物理机装Linux,插真实USB设备;二是QEMU虚拟机,用-device usb-xxx参数模拟设备。QEMU的好处是可以随时改设备参数,坏处是某些控制器行为不完全一致。
调试工具必备这几个:
lsusb -v:看设备描述符、配置描述符、接口、端点的完整信息,比看spec直观。dmesg -w:实时看内核日志,枚举过程、驱动绑定、错误信息都在这里。usbmon:抓USB总线上的实际数据包,配合Wireshark分析。用法是modprobe usbmon,然后cat /sys/kernel/debug/usb/usbmon/1u。ls /sys/bus/usb/devices/:看USB设备树,每个目录对应一个设备或接口,里面的文件能读到描述符和状态。
内核配置方面,调试阶段建议打开CONFIG_USB_DEBUG、CONFIG_USB_ANNOUNCE_NEW_DEVICES、CONFIG_USB_MON。生产环境关掉,否则日志太多。
4.2 写一个最小USB驱动
我以一个自定义的USB设备为例,走一遍驱动编写流程。假设设备VID是0x1234,PID是0x5678,有一个批量IN端点和一个批量OUT端点。
第一步,定义id_table:
static struct usb_device_id my_usb_ids[] = { { USB_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(usb, my_usb_ids);第二步,实现probe函数。在probe里要做几件事:获取接口的端点信息、分配urb、注册字符设备或其它上层接口。
static int my_usb_probe(struct usb_interface *intf, const struct usb_device_id *id) { struct usb_device *dev = interface_to_usbdev(intf); struct usb_host_interface *iface_desc; struct usb_endpoint_descriptor *ep; int i; iface_desc = intf->cur_altsetting; for (i = 0; i < iface_desc->desc.bNumEndpoints; i++) { ep = &iface_desc->endpoint[i].desc; if (usb_endpoint_is_bulk_in(ep)) { /* 记录IN端点地址和最大包长 */ } else if (usb_endpoint_is_bulk_out(ep)) { /* 记录OUT端点地址 */ } } /* 分配urb、注册设备等 */ return 0; }第三步,实现disconnect,在里面杀掉所有urb、释放资源、注销设备。
第四步,注册驱动:
static struct usb_driver my_usb_driver = { .name = "my_usb", .id_table = my_usb_ids, .probe = my_usb_probe, .disconnect = my_usb_disconnect, }; module_usb_driver(my_usb_driver);module_usb_driver这个宏会自动处理module_init和module_exit,比手写省事。
4.3 一次批量传输的完整代码
批量传输是最常用的,我写一个完整的读写函数。
static int my_bulk_read(struct my_dev *dev, void *buf, int len) { struct urb *urb; int ret; urb = usb_alloc_urb(0, GFP_KERNEL); if (!urb) return -ENOMEM; usb_fill_bulk_urb(urb, dev->udev, usb_rcvbulkpipe(dev->udev, dev->bulk_in_addr), buf, len, my_read_callback, dev); ret = usb_submit_urb(urb, GFP_KERNEL); if (ret) { usb_free_urb(urb); return ret; } /* urb会在回调里被释放,这里不能free */ return 0; }回调函数里要检查urb->status,0表示成功,负数表示错误码。常见错误码有-EPIPE(端点stall,需要usb_clear_halt清除)、-ETIMEDOUT(超时)、-ENOENT(urb被kill)、-ESHUTDOWN(设备断开)。
提示:
usb_submit_urb在GFP_KERNEL下可能睡眠,不能在中断上下文调用。如果要在中断里提交,urb必须用GFP_ATOMIC分配,提交也用GFP_ATOMIC。
4.4 参数计算实例:中断传输的带宽估算
假设你要做一个自定义的USB数据采集设备,全速(12Mbps),用中断传输,每次上报64字节,要求延迟不超过10毫秒。怎么定bInterval?
全速总线的帧长是1毫秒,每帧最多传19个64字节的中断包(受总线带宽限制)。中断端点的bInterval单位是毫秒,范围1到255。如果设bInterval=1,每毫秒轮询一次,延迟满足要求,但占用带宽是64字节/毫秒 = 512kbps,占全速总线约4.3%。如果设bInterval=10,每10毫秒轮询一次,延迟刚好卡在10毫秒边界,带宽占用降到0.43%。
实际选型要留余量,我一般设bInterval=5,延迟5毫秒,带宽占用0.86%,兼顾响应和总线负载。高速设备的话,bInterval单位是微帧(125微秒),计算方式不同,bInterval值对应2的幂次微帧数,需要查表。
5. 常见问题与排查技巧实录
5.1 设备插上没反应怎么查
这是最高频的问题。排查顺序我总结成一张表。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| dmesg无任何输出 | 硬件未识别、供电不足 | 换端口、换线、测电压 |
| 有"new full-speed USB device"但无驱动绑定 | 驱动未加载或id_table不匹配 | lsmod看驱动、lsusb看VID/PID |
| 枚举到一半失败 | 描述符读取错误、端点0问题 | 开CONFIG_USB_DEBUG看详细日志 |
| 驱动probe返回错误 | 端点配置不符、资源分配失败 | 看probe里的返回值、加printk |
| 设备反复断开重连 | 供电不稳、线缆接触不良 | 换带供电的hub、换线 |
我遇到过一个典型案例:设备在Windows上正常,在Linux上枚举失败。最后发现是设备描述符里bMaxPacketSize0报的是64,实际端点0只能处理8字节,Windows容错处理了,Linux严格按描述符来就挂了。这种问题只能抓usbmon看实际数据包,对比描述符。
5.2 urb提交失败的错误码速查
usb_submit_urb返回的错误码和回调里urb->status的错误码含义不同,别搞混。
| 错误码 | 含义 | 处理方式 |
|---|---|---|
-ENODEV | 设备已断开 | 停止提交,清理资源 |
-ENOMEM | 内存不足 | 降低提交频率或减小urb大小 |
-EINVAL | 参数错误 | 检查端点地址、传输类型、包长 |
-EPIPE | 端点stall | usb_clear_halt清除后重试 |
-EXDEV | 等时传输部分完成 | 检查已完成帧数,重传剩余 |
-ETIMEDOUT | 超时 | 检查设备是否响应、增大timeout |
-ESHUTDOWN | 控制器已关闭 | 设备正在移除,停止操作 |
-ENOENT | urb被kill | 正常现象,disconnect时会出现 |
5.3 内存与并发避坑
USB驱动里最容易出内存问题的地方是urb的分配释放和回调的并发。
第一个坑:在回调里释放urb。回调执行时urb还在HCD的完成队列里,此时usb_free_urb会导致use-after-free。正确做法是在回调里标记完成,在其它上下文释放,或者用usb_free_urb的引用计数机制(usb_get_urb增加引用)。
第二个坑:disconnect和回调的竞态。设备拔掉时,disconnect被调用,但可能还有urb在飞行中。如果disconnect直接释放了驱动私有数据,回调访问这些数据就崩了。标准做法是在disconnect里先usb_kill_urb(它会等待所有urb完成),再释放数据。
第三个坑:urb重复提交。同一个urb在回调里重新提交前,必须确保它已经完成。如果驱动逻辑有bug导致重复提交,内核会打印"urb submitted while active"警告。
注意:
usb_kill_urb会睡眠,不能在中断上下文调用。如果要在原子上下文取消urb,用usb_unlink_urb,但它是异步的,返回不代表urb已经完成。
5.4 性能调优的几个抓手
USB传输性能上不去,通常卡在三个地方。
一是urb大小。批量传输单次urb太小,会导致中断和调度开销占比过高。我一般把urb设成端点最大包长的整数倍,比如512字节端点的urb设成16KB或32KB。但也不能太大,太大单次传输延迟高,且内存占用大。
二是提交深度。单个urb串行提交,等一个完成再提交下一个,吞吐量上不去。正确做法是维护一个urb池,同时提交多个urb,用完成回调补充。U盘驱动usb-storage就是这么干的。
三是中断合并。XHCI支持中断合并(Interrupt Moderation),减少中断频率。可以通过/sys/bus/usb/devices/.../power/下的参数调整,但要注意延迟和吞吐的权衡。
5.5 用usbmon抓包分析实例
usbmon是排查USB问题的终极武器。用法分三步。
第一步,加载模块:modprobe usbmon。然后ls /sys/kernel/debug/usb/usbmon/会看到0u到N u的文件,每个对应一条总线。
第二步,确定设备在哪条总线:lsusb输出里Bus 001 Device 003,就抓1u。
第三步,抓包:cat /sys/kernel/debug/usb/usbmon/1u > /tmp/usb.log,然后操作设备,停止抓包后用Wireshark打开(Wireshark支持usbmon格式)。
日志里每一行是一个USB事务,包含时间戳、urb标签、传输类型、端点、数据长度、状态。看枚举过程时,重点看控制传输的SETUP阶段,里面是标准请求(GET_DESCRIPTOR、SET_ADDRESS等)。看数据传输时,重点看批量或中断传输的完成状态。
我排查过一个"设备偶尔丢数据"的问题,usbmon显示批量IN传输偶尔返回-EPIPE,说明设备端点stall了。查设备固件发现是缓冲区溢出导致stall,加大设备侧缓冲区后问题消失。这种问题不看usbmon根本定位不到。
6. 从协议栈看内核设计思想
写到这里,我想跳出USB本身,聊聊这套协议栈体现的内核设计思路,这对理解其它子系统也有帮助。
第一是接口隔离。hc_driver把硬件差异挡在HCD层,usb_driver把设备差异挡在驱动层,USB Core只做协调。这种设计让每一层可以独立演进,XHCI出来时不用改设备驱动,新设备出来时不用改控制器驱动。
第二是异步优先。urb机制本质是把同步的"读写"操作异步化,提交和完成分离。这符合内核"不在中断上下文做重活"的原则,也让驱动可以灵活控制并发度。
第三是引用计数管理生命周期。usb_device、usb_interface、urb都有引用计数,确保对象在被使用时不会被释放。这套机制在kobject层面统一实现,USB只是使用者。
第四是sysfs暴露内部状态。/sys/bus/usb/devices/下每个设备、接口、端点都有目录,里面的文件能读描述符、状态、统计信息。这让用户空间工具(lsusb、usbview)不用ioctl就能获取信息,也方便脚本化监控。
我个人在实际操作中的体会是,学USB协议栈最大的收益不是会写USB驱动,而是理解了"分层 + 异步 + 引用计数"这套组合拳。后来我看PCI、看网络子系统、看块设备层,发现都是类似的套路。所以如果你正在啃USB,别只盯着urb和endpoint,多想想它为什么这么设计,收获会大得多。
最后分享一个调试小技巧:当你怀疑是驱动问题时,先把设备驱动rmmod,用libusb写个用户空间程序直接操作设备。如果用户空间能正常工作,说明硬件和协议栈没问题,问题在驱动;如果用户空间也不行,那就是设备或控制器的问题。这个二分法能帮你快速缩小排查范围,省下大量时间。