1. USB协议栈到底解决了什么问题
做嵌入式或者内核开发的朋友,迟早会碰到USB。你插一个U盘到Linux机器上,系统自动识别、挂载、弹出文件管理器,这一套动作行云流水,背后其实是一条相当长的链路在协同工作。这条链路就是Linux USB协议栈。
我刚接触USB子系统的时候,最大的困惑是:为什么一个简单的“插U盘”动作,内核里要牵扯出十几层结构体、好几个子系统、一堆回调函数?后来啃了挺长时间代码,才慢慢理解——USB协议栈本质上解决的是**“主机如何用一套统一的框架,去管理形态千差万别的外设”**这个问题。USB设备种类太多了,键盘、鼠标、摄像头、网卡、串口转换器、存储设备,每种设备的通信方式、数据格式、速率要求都不一样。如果每来一种设备就写一套驱动,内核早就爆炸了。所以USB协议栈的设计目标就是:把共性抽出来做成框架,把差异留给驱动去填。
这篇文章我打算从整体架构讲到具体实现,把Linux USB协议栈的骨架拆开来看。适合谁读?如果你正在写USB设备驱动、调试USB设备枚举问题、或者单纯想搞明白lsusb背后发生了什么,那这篇内容应该能帮到你。我会尽量用大白话把关键机制讲清楚,同时给出可以直接参考的代码路径和调试方法。
2. 整体架构:从硬件到用户空间的分层设计
2.1 五层结构一览
Linux USB协议栈从下到上大致可以分成这么几层:
| 层级 | 名称 | 核心职责 | 关键代码位置 |
|---|---|---|---|
| 1 | USB主机控制器驱动 | 操作硬件寄存器,收发数据包 | drivers/usb/host/ |
| 2 | USB核心层 | 设备管理、驱动匹配、URB调度 | drivers/usb/core/ |
| 3 | USB设备驱动 | 针对具体设备类型的功能实现 | drivers/usb/class/、drivers/usb/storage/等 |
| 4 | 类驱动/子系统 | 将USB设备接入标准子系统 | drivers/usb/class/、drivers/usb/net/等 |
| 5 | 用户空间接口 | sysfs、usbfs、字符设备 | /sys/bus/usb/、/dev/bus/usb/ |
这个分层不是随便切的。最下面的主机控制器驱动(HCD)负责跟硬件打交道,比如xHCI、EHCI、OHCI、UHCI这些。它们把不同硬件平台的差异屏蔽掉,向上提供统一的接口。USB核心层是整座大厦的承重墙,它管理所有USB设备、总线、配置、接口、端点,同时负责驱动的注册和匹配。设备驱动层就是具体干活的,比如usb-storage负责U盘,usbhid负责键盘鼠标。
2.2 为什么这样分层
我一开始觉得这个分层有点过度设计,后来自己写了一个USB转串口驱动才明白:分层是为了让每一层只关心自己的事。主机控制器驱动不需要知道上面插的是U盘还是摄像头,它只管把URB(USB Request Block)发出去、收回来。USB核心层不需要知道具体设备怎么工作,它只管枚举、匹配驱动、管理生命周期。设备驱动不需要关心底层是xHCI还是EHCI,它只管调用usb_submit_urb()提交请求。
这种设计带来的直接好处是:换一个主机控制器,上面的驱动完全不用改。比如从EHCI换到xHCI,usb-storage驱动一行代码都不用动。同样,加一种新USB设备,也只需要写一个新的设备驱动,核心层和HCD都不用碰。
2.3 关键数据结构的关系
理解USB协议栈,有几个结构体必须搞清楚它们之间的关系:
struct usb_device:代表一个USB设备,是核心层管理设备的基本单位。struct usb_interface:代表设备的一个接口。一个USB设备可以有多个接口,比如一个复合设备同时有音频接口和HID接口。struct usb_host_config:代表设备的配置。一个设备可以有多个配置,但同一时间只能激活一个。struct usb_host_endpoint:代表端点,是数据传输的通道。每个接口可以有多个端点。struct urb:USB Request Block,是USB数据传输的载体。所有数据收发都通过URB完成。
它们的关系可以这样理解:一个usb_device包含一个或多个usb_host_config,每个配置包含一个或多个usb_interface,每个接口包含一个或多个usb_host_endpoint。驱动绑定的是usb_interface,而不是整个设备。这一点很关键,很多初学者会搞混。
3. 设备枚举:插上USB设备后内核做了什么
3.1 枚举的完整流程
插上USB设备的那一刻,内核会经历一系列标准动作,这个过程叫枚举。我把它拆成几个关键步骤:
- 端口检测:主机控制器检测到端口电平变化,知道有设备插入。
- 端口复位:HCD对端口进行复位,设备进入Default状态。
- 分配地址:主机通过控制传输给设备分配一个唯一地址(1~127)。
- 读取设备描述符:先读8字节获取端点0的最大包长,再读完整的18字节设备描述符。
- 读取配置描述符:获取设备的配置信息,包括接口数、端点信息等。
- 选择配置:主机发送SET_CONFIGURATION请求,激活一个配置。
- 驱动匹配:核心层根据设备/接口的VID、PID、类代码等信息,匹配已注册的驱动。
整个过程走的是控制传输,端点0是默认的控制端点,所有设备都必须支持。
3.2 描述符体系详解
USB描述符是枚举过程中最核心的数据。设备通过描述符告诉主机“我是什么、我能干什么”。主要描述符类型有:
- 设备描述符:包含VID、PID、设备类、协议、端点0最大包长、配置数量等。
- 配置描述符:包含配置值、接口数量、供电方式、最大功耗等。
- 接口描述符:包含接口号、备用设置、端点数量、接口类、子类、协议等。
- 端点描述符:包含端点地址、属性(控制/中断/批量/等时)、最大包长、轮询间隔等。
- 字符串描述符:提供厂商名、产品名、序列号等可读信息。
这些描述符在代码里对应include/uapi/linux/usb/ch9.h中的结构体定义。我调试的时候经常用lsusb -v把描述符全部打印出来,对照代码看,理解会快很多。
3.3 实操:用lsusb和sysfs观察枚举结果
# 查看所有USB设备的基本信息 lsusb # 查看某个设备的完整描述符 lsusb -v -d 0781:5567 # 通过sysfs查看设备树 ls /sys/bus/usb/devices/ # 输出类似:1-1 1-1:1.0 usb1 usb2 ... # 查看某个设备的详细信息 cat /sys/bus/usb/devices/1-1/idVendor cat /sys/bus/usb/devices/1-1/idProduct cat /sys/bus/usb/devices/1-1/manufacturer cat /sys/bus/usb/devices/1-1/product1-1这种命名规则表示总线1上的第1个端口。1-1:1.0表示该设备的第1个接口的第0号备用设置。这个命名规则在调试多设备系统时特别有用,能快速定位物理连接关系。
注意:
lsusb -v需要root权限才能看到完整的描述符信息,普通用户只能看到设备列表。
4. URB机制:USB数据传输的核心载体
4.1 URB是什么
URB全称USB Request Block,是USB协议栈里数据传输的基本单位。你可以把它理解成一张“快递单”:驱动填好收件地址(端点)、货物内容(数据缓冲区)、运输方式(传输类型),然后交给核心层去派送。核心层把URB交给HCD,HCD再通过硬件把数据发出去。
URB的结构体定义在include/linux/usb.h里,字段非常多,但常用的就那么几个:
struct urb { struct usb_device *dev; // 目标设备 unsigned int pipe; // 端点管道,包含端点地址和传输类型 int status; // 传输完成后的状态 unsigned int transfer_flags; // 传输标志 void *transfer_buffer; // 数据缓冲区 u32 transfer_buffer_length; // 数据长度 u32 actual_length; // 实际传输长度 void *context; // 完成回调的上下文 usb_complete_t complete; // 完成回调函数 // ... 其他字段 };4.2 四种传输类型
USB定义了四种传输类型,每种对应不同的应用场景:
| 传输类型 | 特点 | 典型用途 | 端点方向 |
|---|---|---|---|
| 控制传输 | 可靠、有保证、用于配置 | 枚举、类请求 | 双向 |
| 中断传输 | 周期性、低延迟、数据量小 | 键盘、鼠标 | 单向 |
| 批量传输 | 可靠、无带宽保证、数据量大 | U盘、打印机 | 单向 |
| 等时传输 | 实时、有带宽保证、不重传 | 音频、视频 | 单向 |
选择哪种传输类型,取决于设备的功能需求。比如U盘用批量传输,因为要保证数据正确性,但可以容忍延迟。摄像头用等时传输,因为视频流不能等,丢几帧没关系。
4.3 URB的完整生命周期
一个URB从创建到销毁,经历这几个阶段:
- 分配:
usb_alloc_urb()分配URB结构体。 - 填充:根据传输类型,用
usb_fill_bulk_urb()、usb_fill_int_urb()等函数填充字段。 - 提交:
usb_submit_urb()把URB提交给核心层。 - 传输:HCD从队列里取出URB,通过硬件完成数据传输。
- 完成回调:传输结束后,HCD调用URB的
complete回调函数。 - 释放:
usb_free_urb()释放URB。
这里有个容易踩的坑:完成回调是在中断上下文里执行的,所以回调函数里不能做耗时操作,不能睡眠。如果需要处理大量数据,应该在回调里唤醒一个工作队列或者tasklet。
4.4 实操:写一个简单的URB提交示例
#include <linux/module.h> #include <linux/usb.h> #include <linux/slab.h> static void my_complete(struct urb *urb) { if (urb->status == 0) { pr_info("transfer completed, actual length: %u\n", urb->actual_length); } else { pr_err("transfer failed, status: %d\n", urb->status); } } static int my_send_data(struct usb_device *udev, unsigned int ep) { struct urb *urb; char *buf; int ret; urb = usb_alloc_urb(0, GFP_KERNEL); if (!urb) return -ENOMEM; buf = kmalloc(64, GFP_KERNEL); if (!buf) { usb_free_urb(urb); return -ENOMEM; } memcpy(buf, "hello usb", 10); usb_fill_bulk_urb(urb, udev, usb_sndbulkpipe(udev, ep), buf, 10, my_complete, NULL); ret = usb_submit_urb(urb, GFP_KERNEL); if (ret) { pr_err("submit urb failed: %d\n", ret); kfree(buf); usb_free_urb(urb); return ret; } return 0; }这段代码展示了URB的基本用法。实际驱动里,通常会在probe函数里分配URB,在disconnect里释放,中间根据需要反复提交。
提示:
usb_submit_urb的第二个参数是GFP标志。如果在原子上下文(比如中断处理里)提交URB,必须用GFP_ATOMIC。
5. 驱动匹配机制:核心层如何找到对的驱动
5.1 匹配的三种方式
USB核心层匹配驱动,主要看三个东西:
- 设备ID表:驱动注册时提供一个
usb_device_id数组,里面列出支持的VID/PID组合。 - 类代码匹配:如果设备ID表里有
USB_DEVICE_INFO宏,核心层会根据设备类、子类、协议来匹配。 - 接口类匹配:对于标准类设备(如HID、存储、音频),核心层会根据接口描述符里的类代码匹配对应的类驱动。
匹配的入口在drivers/usb/core/driver.c的usb_device_match()函数。它先尝试匹配接口驱动,如果没找到,再尝试匹配设备驱动。
5.2 编写设备ID表
static const struct usb_device_id my_usb_ids[] = { { USB_DEVICE(0x1234, 0x5678) }, // 匹配特定VID/PID { USB_DEVICE_VER(0x1234, 0x5678, 0x0100, 0x0100) }, // 匹配版本 { USB_INTERFACE_INFO(USB_CLASS_HID, 0, 0) }, // 匹配HID类 { } // 结束标记 }; MODULE_DEVICE_TABLE(usb, my_usb_ids);MODULE_DEVICE_TABLE这个宏很重要,它会把设备ID表导出到模块信息里,这样用户空间工具(如udev)就能知道这个模块支持哪些设备,实现自动加载。
5.3 probe和disconnect的职责
驱动的probe函数在匹配成功后调用,主要做这几件事:
- 检查设备是否真的可用(有些设备ID表匹配了,但实际功能可能不支持)。
- 分配驱动私有数据结构。
- 初始化端点、URB、工作队列等资源。
- 向子系统注册(比如注册为input设备、网络设备、块设备)。
- 保存
usb_interface的私有数据指针。
disconnect函数在设备拔出或驱动卸载时调用,负责反向清理:
- 向子系统注销。
- 取消所有已提交的URB。
- 释放所有分配的资源。
- 等待所有回调完成。
注意:
disconnect里必须先usb_kill_urb()或usb_poison_urb()取消URB,再释放资源。否则回调可能在资源释放后执行,导致use-after-free。
6. 常见问题与排查技巧实录
6.1 设备识别不了怎么办
这是最常见的问题。排查思路从下往上走:
| 排查步骤 | 命令/方法 | 判断依据 |
|---|---|---|
| 1. 硬件连接 | `dmesg | tail` |
| 2. 端口状态 | cat /sys/bus/usb/devices/usb1/1-1/port_status | 看端口是否检测到连接 |
| 3. 枚举日志 | `dmesg | grep usb` |
| 4. 描述符读取 | lsusb -v | 看描述符是否完整 |
| 5. 驱动匹配 | ls /sys/bus/usb/drivers/ | 看设备是否绑定了驱动 |
如果dmesg里连“new device”都没有,那大概率是硬件问题或者HCD驱动没加载。如果有“new device”但枚举失败,通常是描述符有问题或者供电不足。
6.2 传输超时怎么排查
URB传输超时,urb->status会是-ETIMEDOUT。常见原因:
- 端点地址错了:检查
usb_sndbulkpipe()或usb_rcvbulkpipe()的参数。 - 设备没准备好:有些设备需要先发送特定命令才能开始传输。
- 缓冲区问题:DMA缓冲区没有正确映射,或者用了栈上的缓冲区。
- 带宽不足:等时传输特别容易遇到,需要调整轮询间隔。
我遇到过一次批量传输超时,查了半天发现是端点方向搞反了。usb_sndbulkpipe用于发送(主机到设备),usb_rcvbulkpipe用于接收(设备到主机)。这个错误很隐蔽,因为提交URB不会报错,只有传输完成时才返回错误。
6.3 驱动加载了但probe没调用
这种情况通常是设备ID表没匹配上。检查方法:
# 查看设备实际使用的驱动 ls -l /sys/bus/usb/devices/1-1:1.0/driver # 查看驱动支持的设备列表 cat /sys/bus/usb/drivers/my_driver/new_id如果driver链接指向的不是你的驱动,说明匹配失败。可以用echo往new_id里写VID/PID手动触发匹配:
echo "1234 5678" > /sys/bus/usb/drivers/my_driver/new_id6.4 独家避坑经验
坑一:在probe里做耗时操作。probe是在USB核心层的上下文里调用的,如果probe里做太多耗时操作(比如等待设备响应),会阻塞整个枚举流程。正确的做法是把耗时操作放到工作队列里异步执行。
坑二:忘记处理-EPROTO错误。USB传输过程中,设备可能会返回-EPROTO(协议错误)。有些驱动只处理-ETIMEDOUT,遇到-EPROTO就崩溃了。实际上-EPROTO通常意味着设备端有问题,驱动应该优雅地处理而不是直接报错退出。
坑三:URB重复提交。同一个URB在完成之前不能再次提交。如果驱动逻辑里没有检查URB状态就重复提交,会导致内核警告甚至崩溃。正确的做法是在完成回调里重新提交,或者用标志位控制。
坑四:忽略电源管理。USB设备支持运行时电源管理(runtime PM)。如果驱动没有正确处理autosuspend,设备可能会在传输过程中被挂起,导致传输失败。建议在probe里调用usb_enable_autosuspend(),并在传输前调用usb_autopm_get_interface()。
7. 从零写一个USB驱动:完整实操流程
7.1 驱动骨架搭建
一个最小的USB驱动包含这几部分:
#include <linux/module.h> #include <linux/usb.h> #include <linux/slab.h> #define DRIVER_NAME "my_usb_driver" struct my_dev { struct usb_device *udev; struct usb_interface *intf; struct urb *rx_urb; char *rx_buf; }; static int my_probe(struct usb_interface *intf, const struct usb_device_id *id) { struct my_dev *dev; struct usb_endpoint_descriptor *ep; int ret; dev = kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev->udev = interface_to_usbdev(intf); dev->intf = intf; /* 查找批量输入端点 */ ret = usb_find_common_endpoints(intf->cur_altsetting, NULL, NULL, &ep, NULL); if (ret) { dev_err(&intf->dev, "no bulk endpoint found\n"); goto err_free; } dev->rx_buf = kmalloc(512, GFP_KERNEL); if (!dev->rx_buf) { ret = -ENOMEM; goto err_free; } dev->rx_urb = usb_alloc_urb(0, GFP_KERNEL); if (!dev->rx_urb) { ret = -ENOMEM; goto err_buf; } usb_fill_bulk_urb(dev->rx_urb, dev->udev, usb_rcvbulkpipe(dev->udev, ep->bEndpointAddress), dev->rx_buf, 512, my_rx_complete, dev); usb_set_intfdata(intf, dev); ret = usb_submit_urb(dev->rx_urb, GFP_KERNEL); if (ret) { dev_err(&intf->dev, "submit rx urb failed: %d\n", ret); goto err_urb; } dev_info(&intf->dev, "my_usb_driver probed\n"); return 0; err_urb: usb_free_urb(dev->rx_urb); err_buf: kfree(dev->rx_buf); err_free: kfree(dev); return ret; } static void my_disconnect(struct usb_interface *intf) { struct my_dev *dev = usb_get_intfdata(intf); usb_kill_urb(dev->rx_urb); usb_free_urb(dev->rx_urb); kfree(dev->rx_buf); kfree(dev); usb_set_intfdata(intf, NULL); dev_info(&intf->dev, "my_usb_driver disconnected\n"); } static const struct usb_device_id my_ids[] = { { USB_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(usb, my_ids); static struct usb_driver my_driver = { .name = DRIVER_NAME, .id_table = my_ids, .probe = my_probe, .disconnect = my_disconnect, }; module_usb_driver(my_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A minimal USB driver example");7.2 编译和加载
obj-m += my_usb_driver.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean编译后加载:
make sudo insmod my_usb_driver.ko dmesg | tail -20如果设备ID匹配上了,应该能看到probe的日志。如果没看到,检查lsusb确认VID/PID是否正确。
7.3 调试技巧
- 动态调试:在
drivers/usb/core/里打开CONFIG_USB_DEBUG,可以看到详细的枚举日志。 - usbmon:抓USB数据包的神器。加载
usbmon模块后,用tcpdump -i usbmon1就能抓到总线上的数据。 - ftrace:跟踪
usb_submit_urb和usb_complete_urb的调用,分析URB的生命周期。
# 使用usbmon抓包 sudo modprobe usbmon sudo tcpdump -i usbmon1 -w usb.pcap # 然后用wireshark打开usb.pcap分析提示:usbmon抓到的数据包格式和网络包类似,但协议解析需要专门的USB解析器。Wireshark内置了USB解析功能,可以直接看到描述符、控制传输、批量传输的内容。
8. 类驱动与子系统集成
8.1 USB存储驱动的工作方式
usb-storage是最典型的类驱动。它的工作流程是:
- 匹配到USB存储设备(接口类代码为
USB_CLASS_MASS_STORAGE)。 - 创建一个SCSI主机适配器(
scsi_host)。 - 把USB批量传输封装成SCSI命令。
- 向SCSI子系统注册,让上层看到的是一个标准的SCSI磁盘。
这样设计的好处是:USB存储设备对上层来说就是一个SCSI磁盘,不需要为USB存储单独写文件系统或块设备驱动。SCSI子系统已经有一套完整的块设备管理机制,直接复用就行。
8.2 HID驱动与输入子系统
usbhid驱动负责键盘、鼠标等HID设备。它把USB中断传输收到的数据解析成输入事件,然后通过input_report_key()、input_report_rel()等函数上报给输入子系统。输入子系统再分发给用户空间的evdev接口。
这种集成方式让USB键盘和PS/2键盘在用户空间看起来完全一样,应用程序不需要关心底层是USB还是PS/2。
8.3 网络设备驱动
USB网卡驱动(如asix、r8152)把USB批量传输封装成网络数据包,通过netdev接口注册到网络子系统。上层看到的就是一个标准的以太网接口,可以用ifconfig、ip等命令管理。
9. 性能优化与调试进阶
9.1 提高批量传输吞吐量
批量传输的吞吐量受几个因素影响:
- URB大小:URB的
transfer_buffer_length越大,单次传输的数据越多,但延迟也越大。通常设置成端点最大包长的整数倍。 - URB数量:同时提交多个URB(URB流水线),可以掩盖传输延迟。比如提交4个URB,一个在传输时,其他在排队。
- DMA映射:使用
usb_alloc_coherent()分配DMA一致性缓冲区,避免每次传输都做DMA映射。
我实测下来,把URB大小从512字节提高到16KB,同时提交4个URB,U盘读写速度能提升30%以上。
9.2 减少中断传输延迟
中断传输的延迟主要取决于轮询间隔(bInterval)。对于HID设备,通常设置成1~10ms。如果对延迟敏感,可以适当减小轮询间隔,但会增加总线负载。
9.3 用perf分析USB性能
# 跟踪usb_submit_urb的调用次数和耗时 sudo perf probe --add usb_submit_urb sudo perf record -e probe:usb_submit_urb -a sleep 10 sudo perf report这个命令可以统计一段时间内URB提交的频率和分布,帮助定位性能瓶颈。
10. 我踩过的那些坑
写USB驱动这几年,踩过的坑真不少。有一次调试一个USB转串口设备,枚举正常,驱动也probe成功了,但就是收不到数据。查了两天,最后发现是端点描述符里的wMaxPacketSize字段被设备厂商填错了,实际能收64字节,描述符里写的是8字节。这种硬件层面的坑,只能靠抓包和对比分析来定位。
还有一次,驱动在x86上跑得好好的,换到ARM平台上就各种超时。后来发现是DMA缓存一致性问题,ARM平台需要显式调用dma_sync_single_for_cpu()来同步缓存。这个坑让我深刻理解了为什么USB驱动里到处是DMA相关的API。
最坑的一次是电源管理。设备在空闲时被自动挂起,然后应用层发数据时,驱动直接提交URB,结果因为设备还在挂起状态,传输一直失败。正确的做法是在提交URB前调用usb_autopm_get_interface(),传输完成后调用usb_autopm_put_interface()。这个细节在文档里写得很隐蔽,但实际项目中非常关键。
如果你也在写USB驱动,我的建议是:先把usbmon用熟,再把ftrace用熟,最后把电源管理搞清楚。这三样东西掌握了,大部分USB问题都能自己解决。