1. USB协议栈的整体架构与设计哲学
1.1 为什么Linux的USB子系统值得单独拿出来讲
USB可能是日常接触最多、但被理解得最浅的子系统之一。插个U盘、接个键鼠、连个串口模块,系统"自动就认了",这背后其实是一整套分层清晰、职责明确的协议栈在运转。Linux的USB子系统从2.4内核开始成型,到如今已经是一套相当成熟的框架,代码主要分布在drivers/usb/目录下,核心层在drivers/usb/core/,主机控制器驱动在drivers/usb/host/,各种设备类驱动(存储、HID、串口、网卡)则分散在drivers/usb/class/、drivers/usb/storage/、drivers/usb/serial/等目录。
我之所以想把这套框架拆开讲,是因为很多做嵌入式、做驱动、做系统运维的朋友,遇到USB设备不识别、枚举失败、带宽不够、热插拔异常这类问题时,往往只会在dmesg里翻日志,却不知道日志是从协议栈的哪一层打出来的,也就无从下手定位。把框架理清楚,等于拿到了一张地图,出问题时能快速判断是主机控制器层、核心层、还是设备类驱动层的问题。
这篇文章面向三类人:一是刚接触Linux驱动开发、想搞懂USB子系统的初学者;二是做嵌入式产品、需要调试USB外设的工程师;三是运维或系统集成人员,需要理解USB设备在系统中的呈现方式。我会从整体架构讲到关键数据结构,再落到枚举流程、数据传输、常见故障排查,尽量做到看完能上手、能对照代码、能自己定位问题。
1.2 四层结构:从硬件到用户空间的完整链路
Linux USB协议栈在逻辑上可以分成四层,从上到下依次是:
- 用户空间层:应用程序通过
/dev/bus/usb/下的设备节点、sysfs、usbfs(现在叫 usbfs,挂载在/dev/bus/usb)或者各类字符设备(如/dev/ttyUSB0、/dev/input/eventX)来访问USB设备。 - 设备类驱动层:也就是常说的"USB驱动",比如
usb-storage、usbhid、usbserial、cdc_ether等。它们负责把USB设备抽象成块设备、输入设备、TTY设备或网络设备。 - USB核心层(USB Core):这是整个协议栈的中枢,负责设备枚举、配置管理、驱动匹配、URB(USB Request Block)的提交与回调、电源管理等。核心层不关心具体设备是什么,只负责把设备"接进来"并交给合适的驱动。
- 主机控制器驱动层(HCD):直接和硬件打交道,比如
xhci-hcd(USB 3.x)、ehci-hcd(USB 2.0)、ohci-hcd/uhci-hcd(USB 1.1)。它负责把核心层下发的URB翻译成硬件能理解的传输描述符,并处理中断、DMA、端口状态变化。
这四层之间通过统一的接口通信,核心层对上提供usb_driver注册接口,对下提供hcd注册接口。这种分层的好处是:换一个主机控制器,上层驱动不用改;加一种新设备,核心层不用改。这也是Linux USB子系统能支持成千上万种设备的原因。
提示:很多人把"USB驱动"和"USB主机控制器驱动"混为一谈。前者是设备类驱动,后者是HCD,两者在代码里是完全不同的注册路径,调试时一定要先分清是哪一层出的问题。
1.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 usb_driver | include/linux/usb.h | 设备类驱动的注册结构,包含probe、disconnect、id_table |
struct urb | include/linux/usb.h | USB请求块,是数据传输的基本单位 |
struct usb_hcd | include/linux/usb/hcd.h | 主机控制器抽象,包含HCD操作函数集 |
struct usb_device_descriptor | include/uapi/linux/usb/ch9.h | 设备描述符,枚举时最先读取 |
其中urb是最值得花时间理解的结构。它就像一张"快递单":里面写明了要发给哪个端点、数据缓冲区在哪、数据长度多少、是控制传输还是批量传输、完成后回调哪个函数。核心层把URB提交给HCD,HCD完成后通过回调通知提交者。整个USB数据传输就是围绕URB的提交、排队、完成、回收展开的。
usb_device和usb_interface的关系也容易搞混。一个物理设备(比如一个USB音箱)可能包含多个接口:一个音频控制接口、一个音频流接口、一个HID接口用于音量按键。驱动是绑定到接口上的,不是绑定到设备上的。所以usb_driver的probe函数收到的是usb_interface *,而不是usb_device *。这一点在写驱动时非常关键。
2. 设备枚举流程与核心层工作机制
2.1 从插入到识别:枚举的完整步骤
设备插入USB端口后,主机控制器首先检测到端口状态变化(D+或D-被拉高),产生一个端口变化中断。HCD处理这个中断,通知核心层有新设备接入。接下来就是标准的枚举流程:
- 端口复位:HCD对端口进行复位,设备进入默认状态,使用地址0。
- 读取设备描述符:核心层通过控制传输(端点0)读取设备描述符的前8个字节,主要是为了拿到
bMaxPacketSize0,即端点0的最大包大小。 - 分配设备地址:核心层通过
SET_ADDRESS请求给设备分配一个唯一地址(1~127)。 - 再次读取完整设备描述符:这次用新地址读取完整的18字节设备描述符。
- 读取配置描述符:先读9字节配置描述符,拿到
wTotalLength,再读取完整的配置描述符集合(包含接口描述符、端点描述符、类特定描述符)。 - 选择配置:通过
SET_CONFIGURATION请求激活一个配置。 - 注册接口并匹配驱动:核心层为每个接口创建
usb_interface,然后在usb_driver链表中查找匹配的驱动,调用其probe函数。
整个过程在hub.c的hub_port_connect和generic.c的usb_new_device中实现。如果你在dmesg里看到new high-speed USB device number X using xhci_hcd,那就是枚举开始的标志;看到New USB device found, idVendor=XXXX, idProduct=XXXX,说明设备描述符读取成功;看到Product: XXX、Manufacturer: XXX,说明字符串描述符也读到了。
2.2 驱动匹配机制:id_table与probe的触发条件
USB驱动的匹配不像平台设备那样靠设备树,而是靠id_table。每个usb_driver结构里有一个struct usb_device_id数组,核心层在枚举完成后,会拿设备的idVendor、idProduct、bDeviceClass等字段去和每个驱动的id_table比对。匹配成功就调用该驱动的probe。
usb_device_id支持多种匹配方式:
USB_DEVICE(vendor, product):按厂商ID和产品ID精确匹配。USB_DEVICE_VER(vendor, product, lo, hi):再加上版本号范围。USB_DEVICE_INTERFACE_CLASS(class):按接口类匹配,比如所有HID类设备。USB_DEVICE_INTERFACE_PROTOCOL(class, protocol):按接口类和协议匹配。USB_DEVICE_INFO(class, subclass, protocol):按设备类信息匹配。
写驱动时,id_table的最后一个条目必须是全零,作为结束标志。probe函数里通常要做几件事:检查接口端点是否符合预期、申请URB、注册字符设备或输入设备、初始化硬件。如果probe返回非零,核心层会认为驱动不认这个设备,继续尝试下一个驱动。
注意:
probe函数里不要做耗时太长的操作,因为它在枚举上下文中执行,会阻塞后续设备的枚举。如果确实需要延迟初始化,用工作队列或定时器延后处理。
2.3 URB生命周期:数据传输的核心载体
URB是USB数据传输的基本单位,理解它的生命周期就理解了USB数据流。一个URB从创建到销毁通常经历以下阶段:
- 分配:用
usb_alloc_urb(iso_packets, gfp_flags)分配。对于等时传输,需要指定等时包数量。 - 填充:根据传输类型调用不同的填充函数:
- 控制传输:
usb_fill_control_urb() - 批量传输:
usb_fill_bulk_urb() - 中断传输:
usb_fill_int_urb() - 等时传输:手动填充
urb->iso_frame_desc[]
- 控制传输:
- 提交:调用
usb_submit_urb(urb, gfp_flags),核心层把URB交给HCD,HCD把它加入对应端点的队列。 - 完成:传输完成后,HCD调用URB的
complete回调函数。回调在中断上下文中执行,所以不能睡眠。 - 回收:回调里通常用
usb_free_urb()释放URB,或者重新提交以实现持续传输。
这里有个容易踩的坑:complete回调运行在原子上下文,不能调用可能睡眠的函数(如kmalloc(GFP_KERNEL)、mutex_lock)。如果需要在回调里做复杂处理,应该用工作队列把任务推到进程上下文。另外,URB提交后不能随意修改其内容,必须等回调返回后才能复用或释放。
对于批量传输,核心层会把URB拆分成多个USB事务(transaction),每个事务最大包长由端点描述符的wMaxPacketSize决定。比如高速批量端点最大包长512字节,要传4KB数据,HCD会拆成8个事务。这些细节HCD会处理,驱动层不用关心,但理解这一点有助于分析带宽和延迟问题。
3. 主机控制器驱动与硬件交互细节
3.1 HCD的职责与注册流程
主机控制器驱动是协议栈里最贴近硬件的一层。以xhci为例,它负责:
- 初始化控制器硬件,分配命令环、事件环、设备上下文等。
- 处理端口状态变化,通知核心层设备插入或拔出。
- 接收核心层下发的URB,转换成TRB(Transfer Request Block)写入命令环。
- 处理传输完成事件,调用URB回调。
- 管理DMA映射,确保数据缓冲区对硬件可见。
HCD通过usb_create_hcd()创建usb_hcd结构,填充hcd->driver指向的hc_driver操作函数集,然后调用usb_add_hcd()注册到核心层。hc_driver里最关键的是urb_enqueue、urb_dequeue、endpoint_disable这几个回调。核心层提交URB时,最终就是调用hcd->driver->urb_enqueue。
3.2 不同版本控制器的差异与选型
Linux支持多种USB主机控制器,对应不同的硬件规范:
| 控制器 | 规范 | 速度支持 | 典型驱动 |
|---|---|---|---|
| UHCI | USB 1.1 | 低速、全速 | uhci-hcd |
| OHCI | USB 1.1 | 低速、全速 | ohci-hcd |
| EHCI | USB 2.0 | 高速(兼容1.1) | ehci-hcd |
| XHCI | USB 3.x | 超速、高速、全速、低速 | xhci-hcd |
UHCI和OHCI的区别在于硬件分担的工作量:UHCI把更多调度工作交给软件,OHCI则更多由硬件完成。EHCI只负责高速传输,低速和全速设备交给伴随的UHCI/OHCI控制器。XHCI是统一控制器,一个驱动搞定所有速度,也是现在的主流。
在嵌入式平台上选型时,如果SoC只支持USB 2.0,通常用EHCI加一个OHCI或UHCI。如果支持USB 3.0,基本就是XHCI。调试时可以通过lspci(PCI接口)或lsusb -t查看当前系统用的是哪个HCD,lsusb -t会以树状图显示控制器、根集线器、设备和接口的层级关系,非常直观。
3.3 带宽分配与调度策略
USB是主从架构,主机控制器负责调度所有传输。不同传输类型的调度策略不同:
- 控制传输:用于枚举和配置,优先级较高,但数据量小。
- 中断传输:周期性轮询,保证延迟,适合键鼠、HID设备。高速下轮询间隔1~16个微帧。
- 批量传输:利用剩余带宽,不保证延迟,适合U盘、打印机。高速下最大包长512字节。
- 等时传输:预留固定带宽,保证时序,适合音频、视频。每帧最多占用80%的微帧时间。
带宽分配在usb_hcd的bandwidth字段中管理。等时和中断传输在提交URB时会检查是否有足够带宽,不够就返回-ENOSPC。这也是为什么同时接多个USB摄像头时,第二个可能无法启动——带宽被第一个占满了。排查这类问题,可以看dmesg里有没有no bandwidth或-ENOSPC相关报错。
4. 常见故障排查与实战经验
4.1 设备不识别:从日志到定位的完整思路
设备插上没反应,是最常见的问题。我的排查顺序通常是:
- 看物理层:换线、换端口、换主机,排除线缆和端口故障。USB线缆质量对高速设备影响很大,劣质线会导致枚举失败或降速。
- 看
dmesg:插入瞬间执行dmesg -w,观察有没有new USB device相关输出。如果完全没有,说明HCD没检测到端口变化,可能是硬件或HCD驱动问题。 - 看
lsusb:如果dmesg有枚举日志但lsusb看不到设备,说明枚举中途失败。常见原因是描述符读取错误、供电不足、设备固件问题。 - 看
lsusb -t:确认设备挂在哪个控制器下,速度是多少。如果显示480M但设备是USB 3.0,说明协商降速了。 - 看驱动绑定:
ls /sys/bus/usb/devices/找到设备目录,查看driver符号链接是否指向某个驱动。如果没有,说明没有驱动匹配。
一个典型场景:某USB转串口模块插上后/dev/ttyUSB0不出现。dmesg显示设备枚举成功,lsusb能看到,但driver为空。这说明usbserial驱动没有匹配。原因可能是模块的idVendor/idProduct不在usbserial的id_table里。解决办法是用echo "vendor product" > /sys/bus/usb-serial/drivers/generic/new_id动态添加,或者写一个简单的驱动。
4.2 枚举失败与供电问题
枚举失败在dmesg里通常表现为:
device descriptor read/64, error -71:协议错误,可能是信号完整性问题。device not accepting address X, error -71:设备不接受分配的地址。unable to enumerate USB device:枚举最终失败。over-current change:过流,通常是供电不足或短路。
供电问题是嵌入式平台上的高发问题。USB 2.0端口标准供电500mA,USB 3.0是900mA。如果设备实际电流超过这个值,或者主机端口供电能力不足,就会枚举失败或反复重连。解决办法:
- 用带外部供电的USB Hub。
- 检查设备是否有大电容,上电瞬间浪涌可能导致过流保护。
- 在设备树或平台代码里调整端口的供电限制(如果有相关配置)。
- 测量VBUS电压,确认在4.75V~5.25V范围内。
提示:有些开发板的USB Host端口默认限流值设得比较低,接大功率设备时会触发保护。可以查一下HCD驱动或电源管理相关的配置,适当放宽限流阈值,但要注意硬件承受能力。
4.3 传输超时与带宽不足
传输超时通常表现为urb回调返回-ETIMEDOUT或-EPROTO。可能原因:
- 设备固件没有正确响应,比如控制传输的
SET_CONFIGURATION没回ACK。 - 端点类型不匹配,比如把批量端点当控制端点用。
- 带宽不足,等时或中断传输提交时返回
-ENOSPC。 - HCD调度延迟,尤其在系统负载高时。
排查带宽问题,可以看/sys/kernel/debug/usb/devices,里面会列出每个设备的带宽占用情况。如果等时传输频繁失败,考虑降低采样率、分辨率,或者把设备分到不同的控制器上。另外,USB 2.0的等时传输每帧最多占用80%的微帧时间,这个限制是硬性的,不能通过软件绕过。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 设备完全不识别 | 线缆/端口/HCD故障 | dmesg -w无输出 | 换线换端口,检查HCD驱动加载 |
| 枚举中途失败 | 供电不足/信号完整性 | dmesg有error -71 | 外供电Hub,缩短线缆 |
| 设备识别但无驱动 | id_table不匹配 | lsusb有设备,driver为空 | 动态添加id或写驱动 |
| 传输超时 | 设备固件/端点类型 | URB回调返回-ETIMEDOUT | 检查固件,确认端点描述符 |
| 带宽不足 | 等时/中断传输过多 | -ENOSPC,debugfs查看带宽 | 降低负载,分控制器 |
| 热插拔后设备丢失 | 电源管理/驱动bug | 反复插拔测试 | 关闭autosuspend,更新驱动 |
4.5 几个我踩过的坑
第一个坑是usb_submit_urb在中断上下文里用GFP_KERNEL。这个会直接触发内核警告甚至崩溃,因为中断上下文不能睡眠。正确做法是用GFP_ATOMIC,或者在进程上下文提交。
第二个坑是忘记处理probe失败时的资源释放。probe里申请了URB、注册了设备,如果中间某步失败直接返回,之前申请的资源就泄漏了。正确做法是用goto错误处理链,逐级释放。
第三个坑是等时传输的iso_frame_desc没有正确初始化。等时传输每个包的状态和实际长度都在iso_frame_desc里,如果没清零,HCD可能读到垃圾值导致传输异常。分配URB后一定要用memset清零相关结构。
第四个坑是热插拔时驱动没有正确处理disconnect。disconnect回调里要确保所有URB都已回收,否则设备拔出后URB回调可能访问已释放的内存。标准做法是在disconnect里调用usb_kill_urb或usb_poison_urb,确保没有未完成的URB。
5. 从框架到实践:写一个最小USB驱动
5.1 驱动骨架与注册流程
写一个最小USB驱动,核心就是实现probe、disconnect和id_table。下面是一个骨架示例:
#include <linux/module.h> #include <linux/usb.h> static int my_probe(struct usb_interface *intf, const struct usb_device_id *id) { struct usb_device *dev = interface_to_usbdev(intf); dev_info(&intf->dev, "my device probed: VID=%04x PID=%04x\n", le16_to_cpu(dev->descriptor.idVendor), le16_to_cpu(dev->descriptor.idProduct)); return 0; } static void my_disconnect(struct usb_interface *intf) { dev_info(&intf->dev, "my device disconnected\n"); } static const struct usb_device_id my_id_table[] = { { USB_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(usb, my_id_table); 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); MODULE_LICENSE("GPL");这个驱动编译加载后,插入VID/PID匹配的设备,dmesg就会打印probe信息。module_usb_driver宏封装了usb_register和usb_deregister,省去了模块初始化和退出函数的样板代码。
5.2 端点探测与URB提交示例
实际驱动里,probe通常要遍历接口的端点,找到需要的输入输出端点,然后提交URB。下面是一个批量传输的示例:
static int my_probe(struct usb_interface *intf, const struct usb_device_id *id) { 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)) { dev_info(&intf->dev, "found bulk in ep 0x%02x\n", ep->bEndpointAddress); } if (usb_endpoint_is_bulk_out(ep)) { dev_info(&intf->dev, "found bulk out ep 0x%02x\n", ep->bEndpointAddress); } } return 0; }提交URB时,先分配,再填充,最后提交:
struct urb *urb = usb_alloc_urb(0, GFP_KERNEL); usb_fill_bulk_urb(urb, dev, usb_sndbulkpipe(dev, ep_out), buf, len, my_complete, context); usb_submit_urb(urb, GFP_KERNEL);my_complete回调里检查urb->status,0表示成功,负数表示错误。回调结束后,如果不再复用,调用usb_free_urb释放。
5.3 调试手段与工具链
调试USB驱动,常用的手段有:
dmesg:最直接,看枚举、probe、传输错误。lsusb -v:查看设备描述符、配置描述符、端点描述符的详细内容。lsusb -t:查看USB拓扑和速度。/sys/kernel/debug/usb/devices:查看设备带宽、端点使用情况。usbmon:抓USB传输包,类似网络抓包。需要加载usbmon模块,然后用tcpdump或wireshark分析。ftrace:跟踪内核函数调用,比如跟踪usb_submit_urb、usb_hcd_submit_urb的调用路径。
usbmon是排查传输问题的利器。加载模块后,/sys/kernel/debug/usb/usbmon/下会出现0u、1u等文件,对应不同的总线。用cat /sys/kernel/debug/usb/usbmon/1u就能看到总线1上的所有USB传输,包括控制传输的setup包、数据包、握手包。分析这些包,能精确判断是哪个环节出了问题。
提示:
usbmon输出量很大,建议配合grep或重定向到文件后分析。另外,抓包本身会影响性能,生产环境慎用。
5.4 电源管理与autosuspend
USB电源管理是另一个容易出问题的地方。Linux默认对很多设备启用autosuspend,空闲一段时间后自动挂起。如果设备固件不支持挂起/恢复,或者驱动没有正确处理suspend/resume回调,就会出现设备"假死"。
关闭autosuspend的方法:
echo -1 > /sys/bus/usb/devices/usbX/power/autosuspend_delay_ms echo on > /sys/bus/usb/devices/usbX/power/control或者在驱动里调用usb_enable_autosuspend/usb_disable_autosuspend控制。对于不支持远程唤醒的设备,建议在驱动里禁用autosuspend,避免意外挂起。
6. 框架演进与扩展方向
6.1 从USB 2.0到USB 3.x的架构变化
USB 3.x相比2.0,不只是速度提升,架构上也有明显变化。USB 3.x增加了独立的超速总线和端点,控制传输、批量传输、中断传输、等时传输在超速下都有独立的端点类型。XHCI驱动里,设备上下文、端点上下文、TRB环这些概念都是USB 3.x特有的。
对于驱动开发者来说,大部分设备类驱动不需要关心底层是2.0还是3.0,因为核心层屏蔽了差异。但如果要充分利用超速带宽,比如做高速数据采集,就需要理解XHCI的流式传输(streams)和批量端点增强(bulk endpoint companion descriptor)。这些特性在include/uapi/linux/usb/ch9.h里有定义。
6.2 USB Type-C与PD的软件栈
Type-C接口和USB PD(Power Delivery)协议是近年的热点。Linux内核里有drivers/usb/typec/子系统,负责Type-C端口管理、角色切换、PD协商。tcpm(Type-C Port Manager)框架把端口控制器驱动和策略引擎分开,策略引擎决定什么时候切换数据角色、什么时候协商电压。
如果你在做Type-C相关的产品,需要关注typec子系统的struct typec_port、struct typec_switch、struct typec_mux这些结构。PD协商则涉及drivers/usb/typec/pd/下的协议解析。这部分代码相对较新,不同内核版本差异较大,移植时要特别注意。
6.3 gadget框架:设备端的另一套协议栈
前面讲的都是主机侧(Host)的协议栈。Linux还有一套gadget框架,用于设备侧(Device),也就是让嵌入式板子模拟成U盘、串口、网卡等USB设备。gadget框架在drivers/usb/gadget/下,核心是udc(USB Device Controller)驱动和composite层。
gadget框架的分层和主机侧类似:UDC驱动对接硬件,gadget function实现具体功能(如f_mass_storage、f_serial、f_ecm),composite层把多个function组合成一个复合设备。配置方式有传统的gadgetfs、configfs,以及现在的configfs动态配置。用configfs可以在用户空间动态创建gadget,不需要重新编译内核,非常灵活。
6.4 后续可以深入的方向
如果你已经把主机侧协议栈理清楚了,可以往这几个方向深入:
- USB音频类(UAC):理解等时传输在音频流中的应用,以及
snd-usb-audio驱动的实现。 - USB视频类(UVC):理解
uvcvideo驱动如何通过等时或批量传输传输视频帧。 - USB网卡:理解
cdc_ether、r8152等驱动如何把USB传输封装成网络包。 - USB调试:深入
usbmon和ftrace,学会用数据包和函数调用链定位复杂问题。 - gadget开发:用configfs创建自定义USB设备,理解UDC和composite的交互。
这套框架的代码量很大,但结构清晰,分层合理。我的经验是,不要试图一次读完所有代码,而是带着问题去读:设备不识别,就看枚举流程;传输超时,就看URB提交和回调;带宽不够,就看HCD调度。问题驱动的方式,比从头啃代码效率高得多。
最后分享一个我常用的技巧:在drivers/usb/core/里加pr_debug或dev_dbg,配合dynamic_debug动态开启,可以精确跟踪核心层的执行路径,比单纯看dmesg信息量大得多。具体做法是编译内核时开启CONFIG_DYNAMIC_DEBUG,然后通过/sys/kernel/debug/dynamic_debug/control打开指定文件的调试输出。这个手段在排查枚举和驱动匹配问题时特别有用。