做Linux开发这些年,我接触过不少新人,几乎每个人第一次面对/sys/bus/usb/devices/下面那一长串以数字命名的目录时,都会陷入同一个困惑:内核到底是怎么把这棵树搭起来的?USB设备从插入到能被应用程序访问,中间经历了什么?"Linux USB协议栈框架"这个词听起来很唬人,但拆开看,它其实就是一个管理硬件资源、匹配驱动、传输数据的框架,只是这个框架的抽象做得比较干净,值得花时间彻底搞懂。这篇文章我会从整体框架到核心数据结构,再到枚举流程、驱动编写和问题排查,把USB协议栈这条线完整串一遍,适合正在学Linux驱动开发、或者被USB设备调试折腾得头疼的人。
1. 先建立全局观:USB协议栈的本质是"三纵四横"
1.1 为什么很多人看USB源码会迷路
第一次看内核drivers/usb/目录的人,多半会被里面密密麻麻的子目录劝退。core/、host/、gadget/、dwc3/、xhci/、serial/、storage/……感觉每个目录都自成体系,彼此之间似乎有关系又似乎没关系。其实迷路的根本原因,是把"协议栈"理解成了"一套线性代码",但USB在内核里实际是一个立体结构。
从纵向看,USB协议栈可以分成三层:USB设备驱动层(比如U盘驱动、USB串口驱动)、USB核心层(USBCore,负责枚举、带宽管理、设备生命周期)、主机控制器驱动层(HCD,负责跟具体硬件寄存器打交道)。如果涉及设备侧开发,还要加上UDC层(USB Device Controller),也就是gadget框架。
但从横向看,每个层级之间传递的并不是简单的一个"数据包",而是被抽象成一个个对象:usb_device、usb_interface、usb_driver、urb。理解这个横向的对象流转,比背目录结构有用得多。
1.2 四个抽象层各自的职责边界
我用一个日常类比来说明。USB协议栈就像一家快递公司:
- USB设备驱动层相当于"发件人/收件人",它只关心我要寄什么、收到什么,不关心快递走哪条路。
- USB核心层相当于"快递分拣中心",负责规划路线、处理异常、通知上下级。
- 主机控制器驱动层相当于"运输车队",负责把包裹实际送到路上,对应OHCI/EHCI/XHCI这些控制器。
- USB硬件相当于"高速公路网",而总线带宽管理就是"交通调度"。
这个类比能解释很多源码设计。比如为什么usb_submit_urb()返回-ENOMEM时,设备驱动往往不知道是DMA内存不足还是带宽不够——因为分拣中心和运输车队之间的细节被刻意屏蔽了。这种屏蔽是刻意的,不是为了让你调试时抓狂,而是为了让驱动开发者在绝大多数场景下只需要面对usb_device和urb这两个对象,不用关心底层控制器是XHCI还是老掉牙的OHCI。
1.3 核心对象关系速查表
在继续往下之前,先把四个经常混淆的结构体关系理清:
| 结构体 | 作用 | 生命周期 |
|---|---|---|
usb_device | 代表一根USB总线上的一台物理设备 | 从设备插入到拔出 |
usb_interface | 代表设备里的一个功能接口(注意是"功能"不是"物理端口") | 随设备创建,但可动态绑定/解绑驱动 |
usb_driver | 代表一个能驱动某个接口的驱动程序 | 模块加载到卸载 |
urb | USB Request Block,一次数据传输的请求块 | 一次传输从创建到回调完成 |
这里最容易被忽略的是usb_interface。一个物理USB设备可能有多个接口,比如USB耳机通常有音频接口和HID控制接口。usb_driver通过id_table匹配到的是usb_interface,而不是usb_device。也就是说,同一个物理设备,可能同时被两个不同的驱动绑定,每个驱动只管自己那个接口。这个设计是USB协议栈最精髓的抽象之一,也解释了为什么lsusb -t里面同一个设备会出现多行记录。
2. USB枚举全过程:从插入到设备节点生成,内核内部发生了什么
2.1 物理层动作:hub检测、复位与速度协商
当USB设备插入端口时,第一个感知到的是hub(根集线器或外部hub)。hub检测到端口电平变化后,会向主机控制器报告,主机控制器再把这个事件上报给USB核心层的hub驱动。
hub_port_connect_change()是关键的入口函数。内核在这里做了几件事:先对端口做复位(hub_port_reset()),让设备进入默认状态;复位过程中通过chirp序列协商高速/全速/低速模式;然后给设备分配一个默认地址0,为后续控制传输做准备。
这一步经常出问题的点在于:如果设备是低质量USB 3.0设备,可能在复位阶段就因为信号质量差导致协商失败。你会看到dmesg里反复出现reset SuperSpeed USB device number X using xhci_hcd的日志,然后设备被断开重连。这时候如果只盯着协议栈代码看,很难定位,因为问题出在物理层的信号完整性上。
2.2 逻辑层动作:GET_DESCRIPTOR与地址分配
复位成功后,内核开始通过控制传输(Control Transfer)读取设备描述符。枚举初期,设备还处于默认地址0,因此所有的GET_DESCRIPTOR请求都发给地址0端点0。
获取设备描述符的前18个字节后,内核会做一次校验:usb_get_device_descriptor()分配一个usb_device对象,然后通过usb_set_address()给设备分配一个唯一的地址。这个分配不是简单的自增,而是从1开始往上找,复用已拔出设备的地址,但保证当前总线上的设备地址唯一。
拿到完整设备描述符后,内核继续读取配置描述符(Configuration Descriptor)。配置描述符里嵌套了接口描述符、端点描述符、以及各种类特殊描述符。这一步在内核里的体现是usb_get_configuration(),它会遍历所有配置,解析出一棵以usb_host_config为根的树。
2.3 配置选择与接口驱动匹配
设备可以有多个配置,但同一时刻只能激活一个。默认情况下,内核使用配置描述符里的bConfigurationValue作为初始配置(通常是第一个配置)。驱动可以通过usb_set_configuration()主动切换配置。
选定配置后,内核为配置里的每个接口创建一个usb_interface对象,并尝试给每个接口匹配驱动。这个匹配动作并不是一次性完成的——它在每次设备插入、驱动注册、驱动注销时都会重新触发。也就是说,驱动的加载顺序会影响设备插入瞬间的绑定结果,这也是为什么有些人会遇到"先插设备再加载驱动不生效,先加载驱动再插设备就正常"的现象。
匹配成功后,内核调用驱动的probe()函数。probe()里通常做这几件事:保存usb_interface指针、解析端点信息、分配缓冲区、注册字符设备或子系统接口(比如tty、input)。USB协议栈框架在这一步给了驱动开发者极大的自由度——除了必须调用usb_register_driver()注册自己的usb_driver外,probe里做什么几乎不受限制。
2.4 用户态视角:udev事件与应用感知
内核内部的枚举完成不代表"用户能看到设备"。现代Linux系统依赖udev守护进程监听内核的uevent,进而创建/dev节点、加载固件、设置权限。
一次成功的枚举会触发多次uevent:设备添加时、接口添加时、驱动绑定时都会产生事件。udevadm monitor可以看到这些事件流。如果你写了一个USB驱动,发现/dev节点不出现,除了检查内核日志,还应该用udevadm info /sys/bus/usb/devices/xxx看看设备的DEVNAME、MAJOR、MINOR属性是否存在。
实测中有一个常见坑:驱动已经通过device_create()创建了设备类,但udev规则里的SYMLINK+="mydevice"死活不生效。排查后发现是udev规则的ATTR匹配条件里用了idVendor,而idVendor在usb子系统的事件里是ID_VENDOR_ID,大小写和下划线格式对不上,匹配自然失败。
3. 驱动与设备的"相亲"机制:usb_driver匹配详解
3.1 id_table匹配的优先级与自定义匹配
每个usb_driver结构体里都有一个id_table,这是一组struct usb_device_id数组。匹配时USB核心按顺序遍历这个数组,只要有一个表项匹配,就认定该驱动可以绑定该接口。
usb_device_id的匹配字段包括idVendor、idProduct、bInterfaceClass、bInterfaceSubClass、bInterfaceProtocol等。这里有个优先级问题:如果一个驱动的id_table里同时有按idVendor+idProduct精确匹配的条目,也有按bInterfaceClass匹配的通用条目,那么精确匹配优先。内核的匹配函数会先做专用匹配,再做通用匹配,保证尽可能把设备绑定给最"了解"它的驱动。
如果id_table里的静态匹配不够用,还可以实现驱动的usb_driver里的match回调。这个回调返回非0就表示匹配成功。我见过一个有意思的用法:用match回调读取接口的额外描述符字符串,然后根据字符串内容决定是否绑定。这样就可以在不改usb_device_id的情况下,实现同厂商同型号但不同固件版本的差异化驱动绑定。
3.2 probe函数的执行上下文与典型动作
probe()在什么上下文里执行?答案是在内核线程的进程上下文中,具体来说是USB核心的usb_probe_interface()流程,其上下文允许睡眠,可以调用usb_control_msg()这类可能阻塞的函数。
但要注意,probe()的执行路径受driver_override的影响。如果在/sys/bus/usb/devices/.../driver_override里指定了驱动名,内核会强制绑定该驱动,此时id_table匹配会被跳过。这是调试阶段绑定错驱动后的"后悔药",也是产品化阶段屏蔽第三方驱动加载的手段。
probe()里最常见的问题是把很多耗时操作直接做了。比如从设备读取固件版本用了5秒,这5秒里USB核心层在等你的probe返回,整个设备的枚举流程被堵住。如果设备有多个接口需要多个驱动,后面的接口都会被延误。更合理的做法是把耗时操作放到工作队列或者晚到调用(比如第一次打开设备时)再做。
3.3 一对多模型背后的设计思考
为什么USB驱动框架是"一个驱动绑定多个接口",而不是"一个驱动绑定一个设备"?原因在于复合设备(Composite Device)的普及。
以USB摄像头为例:一个物理设备可能包含一个视频采集接口(UVC类)和一个麦克风接口(UAC类)。如果绑定模型是一个驱动对应一个设备,那么这个摄像头必须由一个驱动同时处理视频和音频,代码耦合度极高。而现在的模型让uvcvideo驱动只管视频接口,snd-usb-audio只管音频接口,两者互不干扰。即使只有其中一个子系统在运行,另一个也不受影响。
这个模型也带来一个调试上的启示:当你lsusb能看到设备,但/dev/video0不出现时,先看lsusb -t确认视频接口是否真的存在,再看/sys/bus/usb/drivers/uvcvideo下有没有绑定到相应的接口。大部分"驱动不生效"的案例,其实都是接口还没绑定,而不是驱动代码本身跑飞了。
4. 与设备通信的钥匙:urb请求块全解析
4.1 为什么不能直接用read/write访问USB设备
Linux的设备访问通常走read()/write(),但USB设备驱动层基本不用这两个接口直接跟硬件交互(上层抽象如usb_fs和usbfs除外),而是通过urb。
原因很简单:USB传输是主机主动发起的,且传输方式不止bulk一种。比如中断传输需要按固定间隔反复提交,等时传输需要精确的帧同步,控制传输有固定的setup阶段。这些特征无法用统一的read/write语义表达,所以内核设计了urb作为"传输请求"的载体。
一个struct urb里包含了:目标设备与端点(dev、pipe)、传输方向(usb_rcvbulkpipe/usb_sndbulkpipe)、缓冲区(transfer_buffer)、长度(transfer_buffer_length)、传输完成回调(complete)、上下文指针(context)等字段。所有这些字段组合在一起,就是一次数据传输的完整契约。
4.2 urb的完整生命周期:从创建到回调
一个urb的生命周期可以概括为以下步骤:
- 创建:
usb_alloc_urb()分配并初始化,参数iso_packets在非等时传输时为0。 - 填充:设置
dev、pipe、buffer等字段;控制传输还需要填充setup_packet。 - 提交:
usb_submit_urb()把请求交给USB核心层。注意提交后不能再修改urb字段,除非在complete回调里重新填充。 - 执行:USB核心层调度传输,底层控制器把数据搬到/搬离设备。
- 完成回调:传输完成后调用
complete回调,参数struct urb *urb里的status字段表示传输结果。回调运行在中断上下文或软中断上下文,不能睡眠。 - 释放:回调后如果不再需要该urb,调用
usb_free_urb()。
这里的陷阱集中在第2到第5步。徒手写完一个urb驱动的开发者,十个里有九个被complete回调里的msleep()坑过——一睡就是"BUG: scheduling while atomic"的大红字。
4.3 四种传输类型的适用场景与端点选择
USB定义了四种传输类型,驱动里通过端点描述符里的bmAttributes识别:
| 传输类型 | 特点 | 典型场景 | 适用端点 |
|---|---|---|---|
| 控制传输 | 双向,可靠,每次传输有协议开销 | 枚举、设备配置、命令下发 | 端点0 |
| 批量传输 | 可靠性高,带宽动态分配 | U盘、USB网卡数据面 | 非周期性端点 |
| 中断传输 | 低延迟,固定轮询间隔 | 鼠标键盘、HID | 非周期性端点,有bInterval |
| 等时传输 | 带宽有保证,不可靠 | 音频、视频流 | 非周期性端点 |
选择传输类型时,一个常见的理解误区是"串口数据量小,所以用中断传输"。实际上很多USB转串口芯片的数据面用的是批量传输,而不是中断传输。批量传输虽然"优先级低",但在裸数据搬运场景下吞吐量更高,而且对延迟的敏感度也没有想象中那么高。真正对延迟敏感的是HID设备这类需要固定轮询的场景。
usb_find_bulk_in_endpoint()、usb_find_int_in_endpoint()这类辅助函数就是用来在probe里找到匹配的端点地址的。它们返回的ep_addr配合usb_rcvbulkpipe()/usb_sndbulkpipe()生成pipe,后续提交urb全靠这个pipe。
5. 手写一个bulk传输驱动:从skeleton到可跑的代码
5.1 用module_usb_driver宏省掉模板代码
写一个完整的USB驱动其实不用从零敲架子。内核提供module_usb_driver()宏,一行代码注册:
static struct usb_driver my_usb_driver = { .name = "my_usb_drv", .probe = my_usb_probe, .disconnect = my_usb_disconnect, .id_table = my_usb_id_table, }; module_usb_driver(my_usb_driver);这个宏展开后就是标准的module_init/module_exit,自动调用usb_register()和usb_deregister()。对于大多数不需要额外初始化参数的驱动,这一行宏就足够。如果你的驱动初始化比较复杂,需要加载固件或者初始化全局状态,那就老老实实自己写module_init函数。
id_table的常见写法是按vendor/product匹配:
static const struct usb_device_id my_usb_id_table[] = { { USB_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(usb, my_usb_id_table);MODULE_DEVICE_TABLE这行不是装饰,它让modprobe在安装驱动前就能解析设备ID,实现热插拔时自动加载驱动模块。没有这行,即使设备插入,系统也不会自动modprobe你的驱动。
5.2 probe里的标准动作顺序
一个bulk传输设备的probe函数,标准动作顺序如下:
- 保存
struct usb_interface *interface指针到私有结构体。 - 调用
usb_set_intfdata(interface, dev_priv)保存私有数据。 - 遍历
interface->cur_altsetting->endpoint,找到in和out端点。 - 用
usb_alloc_urb()分配urb,usb_alloc_coherent()分配DMA缓冲区。 - 注册字符设备或子系统接口。
- 提交第一个urb,开始接收数据。
一个精简但完整的probe骨架:
static int my_usb_probe(struct usb_interface *interface, const struct usb_device_id *id) { struct usb_host_interface *iface_desc; struct usb_endpoint_descriptor *endpoint; struct my_dev_priv *dev; int ret; dev = kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev->udev = usb_get_dev(interface_to_usbdev(interface)); dev->interface = interface; usb_set_intfdata(interface, dev); iface_desc = interface->cur_altsetting; /* 遍历端点,找到第一个bulk in和bulk out */ for (int i = 0; i < iface_desc->desc.bNumEndpoints; i++) { endpoint = &iface_desc->endpoint[i].desc; if (usb_endpoint_is_bulk_in(endpoint)) { dev->bulk_in_ep = endpoint->bEndpointAddress; dev->bulk_in_size = usb_endpoint_maxp(endpoint); } if (usb_endpoint_is_bulk_out(endpoint)) { dev->bulk_out_ep = endpoint->bEndpointAddress; } } if (dev->bulk_in_ep == 0 || dev->bulk_out_ep == 0) { dev_err(&interface->dev, "bulk endpoints not found\n"); ret = -ENODEV; goto err_free; } /* 分配urb和DMA缓冲区 */ dev->urb = usb_alloc_urb(0, GFP_KERNEL); if (!dev->urb) { ret = -ENOMEM; goto err_free; } dev->buffer = usb_alloc_coherent(dev->udev, dev->bulk_in_size, GFP_KERNEL, &dev->buffer_dma); /* ... 初始化urb,填充pipe、buffer、complete回调等 ... */ /* 最后提交接收urb */ ret = usb_submit_urb(dev->urb, GFP_KERNEL); if (ret) { dev_err(&interface->dev, "submit urb failed %d\n", ret); goto err_free_urb; } return 0; err_free_urb: usb_free_urb(dev->urb); err_free: usb_put_dev(dev->udev); usb_set_intfdata(interface, NULL); kfree(dev); return ret; }5.3 回调函数里的原子上下文:常见的落坑点
complete回调运行在中断上下文,意味着几乎什么睡眠操作都不能做。kmalloc要用GFP_ATOMIC,不能加锁如果锁可能睡眠,不能调用usb_control_msg()这类睡眠函数。一个实用的做法是把需要复杂处理的数据放入工作队列,再在工作队列里慢慢处理:
static void my_urb_complete(struct urb *urb) { struct my_dev_priv *dev = urb->context; switch (urb->status) { case 0: /* 传输成功,排队处理数据 */ queue_work(dev->workqueue, &dev->work); break; case -ENOENT: case -ECONNRESET: /* 被kill或复位,直接返回 */ return; default: dev_err(&dev->interface->dev, "urb status: %d\n", urb->status); break; } /* 重新提交urb,让数据流持续 */ ret = usb_submit_urb(urb, GFP_ATOMIC); }注意第5节代码里的GFP_ATOMIC——在中断上下文提交urb只能用这个,用GFP_KERNEL会直接触发调度器报错。
5.4 传输方向的细节:get_stall的错觉
当bulk传输出错时,USB核心会返回-EPIPE表示端点被STALL了。新手看到这个错误,第一反应是"设备坏了"。实际上在很多协议里(特别是mass storage的CBW/CSW流程),端点STALL是正常的协议流程。比如Bulk-Only传输的命令块不支持时,设备会STALL端点来拒绝。
处理方法是调用usb_clear_halt()(内核里对应usb_reset_endpoint())清除STALL状态,而不是直接把设备断开。这里有个小技巧:清除STALL后需要重新提交urb,但有些设备的固件在STALL之后需要先读走端点FIFO里的残留数据,否则清除STALL也不会生效。等时传输和中断传输出现-EPIPE时的处理策略也各不相同,不能一概而论。
6. 排查问题的三板斧:usbmon、dmesg与sysfs
6.1 usbmon抓包:像tcpdump一样看USB流量
如果只有一句话推荐调试USB协议栈的工具,那就是usbmon。它把USB总线上传输的URB记录下来,通过debugfs导出,配合抓包工具可以像tcpdump一样看USB流量。
启用方式:
# 挂载debugfs mount -t debugfs none /sys/kernel/debug # 加载usbmon模块 modprobe usbmon # 用usbmon抓总线0的数据 cat /sys/kernel/debug/usb/usbmon/0u输出里每一行代表一个URB事件,格式大致是:
ffff9a1234567890 1234567890 C I:0:001:1 1:2048 0 1 2048 ffff9a1234567890 1234567890 S I:0:001:1 1:2048 0 1 2048逐字段解析:C表示完成(Complete),I表示bulk in,0:001是总线号:设备地址,1是端点号,2048是传输长度,最后的0是状态码。这才是USB抓包的正确打开方式,比在驱动里打印printk高效得多。
6.2 URB状态码速查表
拿到usbmon输出后,最关键的判断依据是最后一个字段——status。usbmon里显示的数字是负数错误码的绝对值。常见的几个:
| 状态码 | 含义 | 典型场景 |
|---|---|---|
| 0 | 传输成功 | 正常 |
| 2 | -ENOENT,URB被usb_kill_urb取消 | 设备拔出或驱动注销时 |
| 104 | -ECONNRESET,URB被要求取消 | 设备复位或配置切换 |
| 70 | -EPIPE,端点STALL | 设备拒绝请求,传输卡死 |
| 84 | -EOVERFLOW,数据量超出端点最大包长 | 对端点最大包长理解错误 |
| 75 | -ETIMEDOUT,控制传输超时 | 设备无响应 |
| 115 | -EINPROGRESS,传输还没完成 | 正常中间状态 |
当status频繁出现非零值时,先别急着改驱动,先用usbmon确认是哪个设备、哪个端点、哪个方向在报错,然后对照设备数据手册看这个端点的协议约定。很多情况下,不是驱动没发数据,而是协议交互本身就应该出现STALL。
6.3 实测案例:一个UVC摄像头枚举失败的排查链路
有一次我调试一个UVC摄像头,插上后dmesg反复出现device not accepting address X,设备一直无法完成枚举。当时第一反应是修改id_table或者调整驱动probe逻辑,但仔细看usbmon后发现,问题发生在地址分配后第一次读取设备描述符时:
S C:0:001:0 0:0 0 18 C C:0:001:0 0:0 0 18设备对控制传输req没有响应,状态码是超时。这说明设备根本没有进入地址状态。换了一根线、换了一个端口都一样。再用lsusb -v看hub状态,发现端口反复执行复位。最后排查到是供电不足——摄像头的峰值电流超过了USB口的供电能力,在复位阶段设备电压跌落,导致主控重启。
这个案例提醒我:USB协议栈调试要分层次。先确认物理层(供电、信号、线缆),再做传输层判断(usbmon看URB状态),最后才轮到驱动代码。反过来排查,大概率会白白浪费时间。USB协议栈框架里的错误码和日志只是告诉你"哪个环节出错了",不告诉你"为什么出错",找到根因还得靠分层定位。
写在最后
我见过太多人一上来就想把drivers/usb/core/下几千行代码读完,结果越看越迷糊。我自己的经验是:先不碰核心实现,抓一个真实设备,用usbmon看一遍它的完整枚举和数据交互,再对照include/uapi/linux/usb/ch9.h里的描述符结构体逐字段解析,最后回到drivers/usb/里找一个同类驱动的probe/callback看它怎么用这些字段。走完这三步,USB协议栈的框架就基本焊死在脑子里了。
留个后话:这篇文章主要讲了主机侧(Host)的视角,如果你做的是U盘、键盘、网卡这类设备的驱动,上面的内容已经足够。但如果你的产品本身是一个USB设备(比如基于STM32做USB转串口),那你需要的是gadget框架,那套东西的视角正好相反——角色从主机变成了设备,枚举流程里的"主动方"变成了"被动方"。那又是一套完全不同的框架了。