十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux USB协议栈框架剖析:从枚举到驱动开发与调试

Linux USB协议栈框架剖析:从枚举到驱动开发与调试 做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代表一个能驱动某个接口的驱动程序模块加载到卸载urbUSB 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规则里的SYMLINKmydevice死活不生效。排查后发现是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里同时有按idVendoridProduct精确匹配的条目也有按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()分配urbusb_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与sysfs6.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表示完成CompleteI表示bulk in0:001是总线号:设备地址1是端点号2048是传输长度最后的0是状态码。这才是USB抓包的正确打开方式比在驱动里打印printk高效得多。6.2 URB状态码速查表拿到usbmon输出后最关键的判断依据是最后一个字段——status。usbmon里显示的数字是负数错误码的绝对值。常见的几个状态码含义典型场景0传输成功正常2-ENOENTURB被usb_kill_urb取消设备拔出或驱动注销时104-ECONNRESETURB被要求取消设备复位或配置切换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框架那套东西的视角正好相反——角色从主机变成了设备枚举流程里的主动方变成了被动方。那又是一套完全不同的框架了。
返回列表