ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深入Linux USB协议栈:URB、枚举与驱动开发实战

深入Linux USB协议栈:URB、枚举与驱动开发实战 写Linux驱动的人大概都绕不开USB。不管是做嵌入式、搞内核开发还是日常接个U盘、鼠标、USB转串口调试单片机背后都有一套成熟但不太容易看懂的机制在干活——这就是Linux内核里的USB协议栈框架。很多人能调通一个USB设备驱动但对“设备是怎么被枚举出来的”“URB是什么”“为什么有的传输会超时”这些核心问题还是一团浆糊。这篇分享适合几类人想系统学习Linux USB驱动开发的遇到USB设备无法枚举或传输不稳定想排查的以及准备读内核源码但不知道从哪下手的。我会从整体框架讲起把usbcore、HCD、URB、端点和管道这些关键概念拆开聊再给出一套实际抓包和排障的方法。最后我把这些年踩过的坑一并整理出来希望能帮你少走弯路。1. 项目概述为什么要啃Linux USB协议栈1.1 一个看似简单的U盘背后发生了什么你往电脑上插一个U盘系统在一两秒内就能弹出文件管理器。从用户视角看这就是“插上就能用”但从内核视角看事情多到吓人hub驱动检测到端口状态变化给设备上电、复位主机控制器给设备分配地址Set Address主机读取设备描述符、配置描述符、接口描述符、端点描述符主机端根据描述符选择配置Set Configurationusb-storage驱动被加载SCSI层把U盘抽象成磁盘块设备文件系统挂载。这一整套流程全部由Linux USB协议栈框架完成。如果你不需要关心底层那没问题但一旦设备“无法枚举”或“驱动加载了但数据传输报错”你就必须理解这个流程的每一步。这就是我写这篇内容的第一个原因USB的问题不是靠试出来的是靠理清协议栈层次定位出来的。1.2 “协议栈”这个词到底指什么很多初学者把“USB协议栈”理解成一个很大的代码块其实不是。协议栈的本质是“一组按层次组织的协议实现”每一层只负责特定职责层与层之间通过标准接口交互。拿我们熟悉的网络来打比方TCP/IP协议栈分为应用层、传输层、网络层、链路层蓝牙协议栈也分HCI、L2CAP、GATT等层。Linux里的USB协议栈同样分层硬件控制器在最底层往上是主机控制器驱动再往上是USB Core和设备驱动。每一层都定义了清晰的数据结构和接口上层不需要关心下层的硬件寄存器细节。搞懂了这个思路你再去看内核代码就不会迷路你不是在读一段巨大的程序而是在看一层层“服务”之间如何协作。1.3 弄清框架能解决什么具体问题我的经验是框架理解到位后至少能直接解决三类实际痛点。第一枚举类问题。设备插上没反应sysfs里没有设备节点dmesg报“device not accepting address”。如果你知道枚举的每个阶段就能判断是硬件复位失败、地址分配失败还是描述符读取失败。第二驱动匹配问题。写好的驱动probe函数没被调用。这多半是usb_device_id表中的VID/PID没匹配上或者驱动挂在了错误的驱动模型节点上——因为驱动绑定的是usb_interface不是usb_device。第三数据传输问题。有些设备间歇性超时、数据丢包。这时候你至少要知道URB的提交方式、传输类型选得对不对、端点最大包长度是不是搞错了。很多莫名其妙的“不稳定”最后查下来都是批量传输用了中断URB或者wMaxPacketSize设置错误。2. Linux USB协议栈整体框架从硬件到驱动的四层结构2.1 内核里USB子系统到底长什么样Linux内核的USB代码主要在drivers/usb目录下几个关键路径你可以先记住drivers/usb/core这是USB Core也就是协议栈的核心代码包括设备管理、URB处理、端点分配、驱动模型匹配等。drivers/usb/host这是HCD也就是各类主机控制器驱动比如EHCI、xHCI。drivers/usb/gadget这是设备侧框架让硬件可以扮演USB设备角色而不是主机角色。drivers/usb/class、drivers/usb/serial、drivers/usb/storage这些是具体的设备驱动比如USB音频类、串口类、存储类。整体看从硬件往上可以分成四层物理控制器Host Controller、HCD层、USB Core层、设备驱动层。每一层都是和上下层直接对话的改动某一层不需要重写其他层。2.2 USB Core真正意义上的“协议栈核心”USB Core是内核USB子系统的中枢它承担了最核心的工作。第一设备模型管理。USB设备的枚举、配置、热插拔事件、设备树维护都在这一层完成。sysfs里看到的/sys/bus/usb/devices目录结构就是USB Core维护的设备模型。第二URB管理。上层驱动提交URBUSB Core负责创建请求、调度、跟踪完成状态、调用回调函数。它不关心你怎么解析数据只关心请求在总线上能不能顺利执行。第三电源管理。设备的挂起、唤醒、自动断电等逻辑也由USB Core统一处理。这也是为什么有些设备长时间不访问会自动休眠连HID键盘都有这个机制。如果你正在读内核源码我建议先看drivers/usb/core/hub.c、urb.c、driver.c这三个文件。hub.c里是枚举和热插拔的状态机urb.c里是URB的生命周期driver.c里是驱动与接口的匹配逻辑。2.3 HCD与Gadget主机侧和设备侧的分工主机的USB控制器硬件型号很多老一点的UHCI/OHCI后来的EHCI再到现在的xHCI。这几种控制器的寄存器模型差别很大但USB Core不希望上层驱动去关心这些。于是有了HCD这个“适配层”它的职责就是把USB Core的标准请求翻译成各控制器能懂的寄存器操作。举个例子USB Core要求给地址为3的设备发送一个控制传输这个过程在EHCI和xHCI上的实现方式完全不同但HCD层会把这个差异吸收掉。上层驱动只要构造URB、选择端点、提交剩下的事情交给HCD。设备侧也有对应的框架叫Gadget。Gadget框架让一台Linux设备可以扮演USB外设的角色比如模拟成串口、网卡、U盘。它和HCD是对称的HCD管理主机控制器Gadget管理设备控制器UDC。这两个框架各有独立的API初学者最容易把usb_host和usb_gadget搞混记住一条主机侧用usb_submit_urb设备侧用usb_ep_queue完全两套东西。2.4 设备驱动层和用户态工具主机侧的设备驱动种类非常多内核里已经有大量现成实现usb-storage负责U盘和移动硬盘usbhid负责鼠标键盘uvcvideo负责摄像头cdc_acm和ftdi_sio负责USB转串口usbnet负责USB网卡。这些驱动是真正和业务逻辑打交道的层比如usb-storage会把URB拿到的数据转成SCSI命令再交给块设备层。如果你不想写内核驱动也可以用用户态的方式操作USB设备最典型的就是libusb。它通过usbfs接口与内核沟通直接提交控制传输和批量传输不需要加载专用驱动。很多工装设备、下载器都是用libusb做的。两种路径的选择不难设备遵循标准类协议优先用内核class驱动如果设备是自定义协议又不想维护内核代码就用libusb如果对实时性和内核集成有要求那就老老实实写内核驱动。3. 核心机制拆解设备模型、URB、端点和管道3.1 从usb_device到usb_interface的模型链路Linux设备模型里USB设备的表示层次可以说是一目了然只要你会看sysfs。每个USB物理设备对应一个usb_device结构。设备插入后USB Core为它分配地址并创建这个结构在/sys/bus/usb/devices/下体现为形如1-1.2的目录含义是总线1、端口1下面的端口2。一个物理设备可以有多个功能接口Interface。比如一个USB摄像头可能同时带麦克风它在USB层会暴露成两个interface一个video类接口一个audio类接口。每个usb_interface对应一组端点而设备驱动实际上是绑定到usb_interface上的不是绑定到整个usb_device上的。这个点很多人会搞错以为驱动匹配的是设备其实是接口。再看端点。每个接口下有多个端点端点是数据传输的最终对象。usb_host_endpoint结构保存了端点描述符的关键信息包括端点地址、传输类型、最大包长度。你在内核驱动里看到的bEndpointAddress、bInterval这些字段就定义在端点描述符里。3.2 URBUSB数据传输的最小单元URB的全称是USB Request Block。如果你了解网络协议栈可以把URB理解成USB世界里的struct sk_buff它是一个请求的信封承载了要传输的数据、目标管道、传输方向、回调函数等所有信息。一次典型的URB使用流程是这样的用usb_alloc_urb分配URB用usb_fill_bulk_urb或usb_fill_int_urb等函数填充参数然后调用usb_submit_urb提交给USB Core。USB Core处理完成后会调用你注册的complete回调函数最后你再用usb_free_urb释放。这里有个初学者容易踩的坑complete回调是在中断上下文还是进程上下文取决于URB提交时的上下文以及主机控制器实现。回调整体要快不能睡眠。如果你想在回调里做较多处理最好用工作队列或tasklet推迟处理不要直接在回调里睡死。URB的生命周期管理很关键设备拔出或传输被取消时你的complete回调可能会收到-ENODEV或-ESHUTDOWN之类的错误码。很多驱动崩溃就是因为在URB还没完成时释放了缓冲区。正确做法是在disconnect函数里调用usb_kill_urb或usb_unlink_urb并且保证urb-context指向的数据在你杀掉URB之前不会被释放。3.3 端点和管道数据怎么找到目的地USB设备里的每个端点都像一扇门主机要传输数据必须知道走哪扇门。端点由端点地址和方向组成比如0x81表示端点1的IN方向0x02表示端点2的OUT方向。但光有端点地址不够USB协议栈还引入了管道pipe的概念。管道是在端点地址之上叠加了传输类型和方向形成的完整通道路径。在内核开发中你不会手动去拼pipe的值而是用一系列宏来构建usb_sndctrlpipe(dev, 0)控制输出usb_rcvbulkpipe(dev, 0x81)批量输入usb_rcvintpipe(dev, 0x81)中断输入usb_sndisocpipe(dev, 0x02)等时输出这些宏拿到pipe之后再传给URB或usb_control_msg。可以这么理解pipe是快递单上的地址和运输方式URB是包裹本身端点就是那个收货地址对应的小区门禁。控制端点0特殊每一个USB设备都有一条默认控制管道它用于枚举阶段的所有命令比如读描述符、设置配置。在设备驱动中发送自定义vendor命令也是走这条默认控制管道。3.4 四种传输类型的选择与取舍USB2.0定义了四种传输类型每种都有明确的使用场景选错类型是驱动不稳定的一大来源。我整理成一张表方便你对照传输类型特点典型应用可靠性机制带宽特点控制传输双向、小数据量、枚举和命令设备枚举、vendor命令协议层确认重试占用固定带宽短小批量传输大数据量、非实时U盘、打印机、UVC视频数据CRC校验和重传闲时抢带宽不保证延迟中断传输小数据量、有延迟保证鼠标、键盘、HID设备错误重传轮询间隔保证等时传输实时、流式数据USB音频、摄像头实时流无重传出错直接丢预留固定带宽用一句话概括要可靠不要实时用批量要低延迟不要大数据量用中断既要实时又能容忍丢数据用等时遇到枚举和命令交互只能走控制。实际开发中我最常看到的问题是把批量端点当成中断端点用。中断传输在不枚举设备的前提下确实“感觉”更实时但内核里中断URB的调度受轮询间隔限制如果设备本身端点是批量类型你硬用中断端点描述符去提交只会得到一下canned error。一定以描述符为准不要以“我猜是”为准。4. 从写驱动到抓包Linux USB协议栈的实操路径4.1 一个最简单的USB驱动骨架我直接给一个可编译的最小驱动模型逻辑是匹配指定VID/PID的USB设备在probe里拿到接口和设备指针打印端点信息disconnect里做清理。#include linux/module.h #include linux/kernel.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); struct usb_host_interface *alt intf-cur_altsetting; int i; dev_info(intf-dev, probe: vid0x%04x pid0x%04x\n, id-idVendor, id-idProduct); for (i 0; i alt-desc.bNumEndpoints; i) { struct usb_endpoint_descriptor *ep alt-endpoint[i].desc; dev_info(intf-dev, ep addr0x%02x type%d maxpacket%d\n, ep-bEndpointAddress, ep-bmAttributes USB_ENDPOINT_XFERTYPE_MASK, le16_to_cpu(ep-wMaxPacketSize)); } return 0; } static void my_disconnect(struct usb_interface *intf) { dev_info(intf-dev, disconnect\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 myusb, .probe my_probe, .disconnect my_disconnect, .id_table my_id_table, }; module_usb_driver(my_driver); MODULE_LICENSE(GPL);USB_DEVICE(0x1234, 0x5678)会展开成包含VID和PID的usb_device_id驱动加载后USB Core会拿这个表去和枚举出来的接口匹配。需要注意如果设备有多个interface每个接口都会触发一次probe。4.2 probe之后怎么发起一个批量传输拿到设备指针和端点地址后最常用的传输方式是提交URB。以批量传输为例步骤如下用usb_alloc_urb(0, GFP_KERNEL)分配URB。用usb_fill_bulk_urb填充urb传入pipe、缓冲区指针、缓冲长度、回调函数等。调用usb_submit_urb提交。在complete回调里检查urb-status和actual_length。如果你不想处理异步回调内核也提供了同步接口比如usb_bulk_msg和usb_control_msg。它们内部会包装一个URB并等待完成适合驱动初始化阶段或低频命令交互。注意同步接口不能用在原子上下文也不能在中断回调里调用。很多控制命令用usb_control_msg一步到位比如给端点发一个厂商自定义请求int ret; ret usb_control_msg(dev, usb_sndctrlpipe(dev, 0), 0xc0, 0x40, value, index, buf, len, 1000);这里的bRequestType、bRequest、wValue、wIndex分别对应协议里的字段。最后一个参数是超时时间别看它简单设太短会误报超时设太长会拖慢出错后的恢复流程。我一般给控制命令1000ms批量传输的同步调用给3000ms起。4.3 USB转串口FT231X/FT232R这类设备的实际经验USB转串口可能是大家接触最多的USB应用了。Linux内核自带ftdi_sio驱动支持FTDI的大部分芯片包括FT232R、FT231X、FT2232等常见型号。只要芯片用的是FTDI家的插上后正常会识别为/dev/ttyUSB0无需装额外驱动。但这里有三个常见误区。第一个误区是拿Windows下的安装包思维套Linux。FTDI芯片在Linux里根本不需要去官网下驱动内核的ftdi_sio已经覆盖。相反如果你强行加载一些第三方驱动反而会和内核自带驱动冲突。第二个误区是设备被识别成ttyACM0而不是ttyUSB0。这可能是因为设备采用的不是FTDI芯片而是CDC ACM类的芯片例如CH340、CP210x或者其他国产芯片。ttyACM0本身不是错误关键在于使用/dev/serial/by-id这种稳定链接而不是直接依赖ttyUSB编号否则每次插拔顺序变了你的脚本就会打开错误的串口。第三个误区是权限问题。非root用户打开ttyUSB0经常遇到Permission denied这不是驱动问题是udev规则没配好。最简单的临时办法是把用户加入dialout组正式做法是给设备写一条udev规则按VID/PID指定权限。下面这条规则可以放在/etc/udev/rules.d/99-usb-serial.rules里SUBSYSTEMtty, ATTRS{idVendor}0403, ATTRS{idProduct}6001, MODE0666改完规则要执行udevadm control --reload-rules再重新插拔设备。4.4 实战路径按接口描述符写驱动而不是按设备型号写有些开发者拿到一个USB设备第一件事是去搜“XX芯片 Linux驱动”这不够优雅。更靠谱的做法是先抓描述符根据接口类别决定使用框架。如果设备描述符里的bInterfaceClass是0xff说明是厂商自定义设备没有现成驱动可用需要自己写。如果是0x08Mass Storage、0x03HID、0x02CDC这类标准类内核中一般已经有对应框架你要做的是确认它是否被正确匹配。这也是为什么我一直推荐先看lsusb -v的输出再决定怎么写驱动。描述符就是USB设备的“自我介绍”协议栈根据自我介绍来为它安排合适的驱动。不看自我介绍瞎写代码跟不看菜谱硬炒菜一个道理。5. USB抓包与常见问题排查实录5.1 用usbmon抓包看协议栈到底在传什么遇到USB传输问题最有效的调试手段不是猜而是抓包。Linux上抓USB包首选usbmon它把USB总线上传输的URB事件暴露给用户态配合Wireshark可以像看网络包一样看USB包。启用usbmon的步骤很简单sudo modprobe usbmon sudo mount -t debugfs none /sys/kernel/debug然后你可以直接读/sys/kernel/debug/usb/usbmon目录下的文件比如0u是所有总线的汇总1u是总线1的URB事件。想快速看动态用cat加过滤sudo cat /sys/kernel/debug/usb/usbmon/1u输出里的每一行都包含URB编号、事件类型、方向、设备/端点、状态、传输长度和数据。配合Wireshark更好用安装Wireshark后选择usbmon1或usbmon0作为抓包接口就能看到“URB bulk out”“URB interrupt in”这类事件甚至能解析多种class协议的解码。抓包能帮你确认的东西很多设备是否在枚举阶段卡住控制传输返回了什么错误批量传输的数据长度是不是和描述符里的wMaxPacketSize不匹配这些信息平时靠dmesg只能看大概抓包能看到细节。我自己的习惯是“先抓包再猜”。有一回设备间歇性丢数据代码复查两遍没找到问题抓包一看等时端点每5个包就有一个CRC错误这才意识到是线材质量不行。没有抓包工具这种问题能排查一周。5.2 设备枚举失败的处理套路枚举失败是最常见的USB问题表现是插上设备后系统没有反应或者dmesg反复打印“unable to enumerate USB device”。排查时我按下面这套顺序来第一步看硬件层。换线、换口、换机器排除线材和端口供电问题。USB2.0的D/D-是差分对网上买的劣质线看着能用跑高速就可能出问题尤其是枚举阶段的高速握手。第二步看dmesg。dmesg | grep -i usb会输出hub事件、地址分配、描述符读取失败等关键信息。常见报错有“device not accepting address”和“cannot enable. Maybe the USB cable is bad?”。前者通常是设备地址分配后通信失败后者是复位后设备没有响应。第三步是看系统能不能识别VID/PID。如果dmesg里连设备描述符都读不到说明设备侧的上拉电阻、固件里USB初始化有问题如果能读到VID/PID但驱动加载失败那就是软件匹配的问题。我补充一个容易忽略的原因有些设备对上电时序敏感插入瞬间如果电源毛刺太大会直接导致设备固件跑飞表现就是枚举失败。给设备用独立供电的hub往往能缓解。5.3 传输错误码速查别让URB错误吓住你内核驱动里URB回调的status字段会返回负数错误码。我整理几个最常见的情况错误码含义常见原因建议-ETIMEDOUT传输超时设备没响应、URB提交后没有完成检查设备供电、线材、设备固件状态-EPIPE端点暂停stall设备返回STALL通常命令不支持确认命令协议必要时清halt-EPROTO协议错误总线信号异常CRC错误换线、降速、检查PCB-ENODEV设备不存在设备已被拔出或重启重新检测设备检查disconnect逻辑-ESHUTDOWN传输被关闭设备断开或控制器停止一般在disconnect中忽略该错误-EOVERFLOW数据溢出接收的数据超过端点maxpacket核对端点描述符和缓冲区长度看到status报错先别慌对照表排查成本最低。比如-EPIPE不一定代表“坏了”它可能只是你发送了一个设备未定义的vendor请求设备合法地回了STALL。5.4 协议栈横向对比USB栈和网络协议栈的相通之处很多人学过TCP/IP协议栈再接触USB协议栈会觉得很亲切因为很多概念是对得上的。我在带新人时经常用这些类比URB对应网络协议栈里的sk_buff都是承载数据和请求的结构。pipe对应socket的地址单元描述“从哪里到哪”。hub的枚举过程对应链路层设备发现比如ARP或邻居发现。usb_device_id对应驱动匹配的“协议号”确定由哪个驱动处理。控制传输对应带外控制类似ICMP或管理面流量。蓝牙协议栈其实也类似HCI层像HCDL2CAP像传输层的逻辑通道GATT则是属性协议的上层。把一种协议栈吃透再看其他协议栈会快很多。不过要注意类比归类比底层差异还是很大尤其是时序和带宽模型USB的等时传输这种东西在网络里没有直接对应物。6. 个人经验与进一步扩展建议6.1 读内核源码从哪里开始如果你想把Linux USB协议栈真正吃透我建议按这个顺序读先读drivers/usb/core/hub.c。这个文件很长但你不必全读重点看hub_events和hub_port_connect_change这两个函数的流程它们和你插拔设备时看到的行为一一对应。再读drivers/usb/core/urb.c。URB的分配、提交、取消、完成都在这里。结合一个实际驱动看它能理解得更深比如drivers/usb/serial/ftdi_sio.c里的读写URB怎么协调。最后读drivers/usb/gadget/configfs.c或一个UDC驱动比如dwc2或dwc3。这是另一套API能让你理解设备侧和主机侧的不对称。还有一点内核编译配置也值得留意。如果你的嵌入式平台需要USB功能务必检查CONFIG_USB、CONFIG_USB_EHCI_HCD或CONFIG_USB_XHCI_HCD、CONFIG_USB_MON这些选项。很多“Linux系统完全不识别USB设备”的问题实际上只是内核没开USB支持。6.2 调试工具组合我日常调USB设备用得最多的工具和命令如下lsusb列出USB设备加-t可以看拓扑加-v可以看完整描述符。dmesg看内核日志枚举信息、驱动加载信息都在。usbmon配合Wireshark看URB级抓包数据。usbview图形化看设备树和占用带宽。/sys/kernel/debug/usb/devices文本形式导出设备树、配置、端点信息。如果你在嵌入式板子上没有Wireshark最低成本的方案是busyboxcat直接读usbmon输出再拿回PC分析。USB抓包不需要在PC上板子自带内核支持usbmon就行。还有个小技巧写驱动时可以在probe里用dev_info打印描述符也可以在用户态用lsusb -v对比两边的字段信息应该完全一致。如果描述符解析出来和你预期不同基本是代码里的长度或偏移算错了而不是设备问题。6.3 几个最后想说的话折腾Linux USB这几年最大的感觉是“协议栈比想象中好懂但坑比想象中多”。好懂是因为分层清晰每一层职责固定坑多是因为很多问题不在代码逻辑上而在硬件信号质量、描述符细节、URB生命周期这些不显眼的地方。我个人的习惯是接手一个陌生USB设备第一件事永远是抓描述符第二件事是抓一次枚举包第三件事才看驱动代码。先弄清“设备说自己是什么”再去看“驱动打算怎么处理它”能省掉大量无意义的瞎试。如果你以后遇到USB设备搞不定不妨先静下心把这一套框架在脑子里过一遍硬件连接有没有问题枚举到哪一步卡的描述符对不对驱动有没有匹配URB提交之后返回了什么错误。一步一步排除绝大多数问题都能找到方向。USB协议栈这扇门推开以后你会发现Linux里很多设备驱动都是建立在这套框架之上的理解USB就等于理解了一大半外设系统的运行逻辑。
返回列表