☰
Linux USB协议栈架构详解:从URB传输到设备驱动调试
2026/9/26 7:01:09 网站建设 项目流程

做Linux下USB驱动调试的这些年,我最大的感受就是:USB协议栈就像一棵盘根错节的树,表面上看到的是/dev/ttyUSB0、U盘挂载这些结果,底下却藏着从硬件控制器、内核核心层、主机控制器驱动到设备驱动的完整链条。很多人被USB驱动问题卡住,往往不是不会写代码,而是对整个协议栈框架没有一个清晰的坐标系——不知道一个URB从哪儿来、到哪儿去,不知道设备枚举时内核到底做了多少件事,更不知道usbmon、/sys/kernel/debug里那些节点意味着什么。

这篇文章我想从一个长期搞嵌入式Linux底层开发的人角度,把Linux USB协议栈的框架掰开揉碎讲一遍。内容会覆盖分层架构、核心数据结构、URB传输模型、设备枚举路径,再到设备驱动怎么和协议栈打交道,最后给出一套调试手段和常见问题排查表。适合刚接触USB驱动开发的人,也适合那些已经会用libusb但想补一补内核功课的工程师。看完之后,你再面对“设备识别不了”“urb completing with status -EPIPE”这类问题,心里会更有底。

1. 先从整体看:Linux USB协议栈到底分几层

1.1 USB协议栈的四个层次,一次说清楚

我之前在内核社区看到过一句很精辟的话:USB协议栈是内核里少有的“从总线物理层一路长到文件系统层”的完整子系统。拆开看,它大体是四层结构。

第一层是USB物理总线与主机控制器。往细了说包括USB线缆、连接器、差分信号线上的电平翻转、NRZI编码,以及根集线器根端口上的时钟恢复。这一层不是软件思维能完全覆盖的,但你要知道,主机控制器(Host Controller)其实是一个硬件块,它负责把系统内存里的URB描述转换成总线上的token包、SOF帧、数据包和握手包。常用的主机控制器有EHCI(USB 2.0)、xHCI(USB 3.x/2.0),它们就是协议栈的“物理执行单元”。

第二层是主机控制器驱动(HCD)。内核里的ehci-hcd、xhci-hcd这类的模块,专门负责操作主机控制器寄存器,把URB提交到硬件队列,在中断里回收传输结果。这一层隐藏了不同主控硬件之间的差异,向上层提供一个统一的接口。HCD的核心工作是维护周期性调度表,管理带宽,处理硬件中断。很多传输层面的诡异错误,比如“babble”“CRC错误”都会从这里以status code的形式暴露出来。

第三层是USB Core(usbcore)。这是Linux协议栈真正的大管家,也常被叫做“USB核心层”。它负责三件大事:设备枚举、设备模型抽象、URB管理。设备插入后,usbcore通过hub驱动触发枚举流程,读取描述符,建立usb_device、usb_interface这些对象,然后和已经注册的usb_driver做匹配。URB的生命周期也是usbcore在协调,它把设备驱动的URB请求交给HCD,再把完成状态回传给驱动。所有USB设备在sysfs里的拓扑,都来自这一层创建的设备模型。

第四层是USB设备驱动(客户端驱动)。包括内核自带的usb-storage、usbhid、usb-serial等,也包括你为特定设备写的驱动。它们不需要关心USB协议里“包怎么发”的细节,只需要用URB或更上层的接口(比如urb、class driver API)表达“我要传输什么数据”。从这里能看到设备和“功能”对应的实质:一个复合设备可以有几个interface,每个interface都能挂一个不同的驱动。

这四层对应到一条数据通道上,就是你从驱动里submit一个URB,它进入usbcore,经HCD变成硬件可以执行的描述符,最终在总线上变成电气信号;返回时信号通过主机控制器回到HCD,再由usbcore调用的回调函数,把数据送到你的驱动代码里。理解了这个路径,你就知道“协议栈框架”不是纸面分层,而是实实在在的数据流。

1.2 核心数据结构:usb_device、usb_interface、usb_driver和usb_host_endpoint

分层是骨架,数据结构才是血和肉。你在任何USB驱动probe函数里都会碰到这些结构体。

首先是struct usb_device,它代表物理USB设备。这个结构体在设备枚举成功后由usbcore分配,里面包含设备描述符、配置描述符、速度、地址、端口号等信息。驱动里经常用interface_to_usbdev(interface)从接口指针拿到设备指针。它对应sysfs里的/sys/bus/usb/devices/。

其次是struct usb_interface。这才是设备驱动真正绑定的东西,一个物理USB设备可以包含多个interface,比如一个摄像头设备可以同时有Video Control接口和Video Streaming接口,它们分别由uvcvideo驱动里的两个子驱动来处理。usb_interface里最常用的有cur_altsetting、num_altsetting,还有通过usb_ifnum_to_if()等辅助函数拿到的接口描述符和端点信息。你要写一个驱动,probe函数的第一个参数就是这个结构体指针。

struct usb_driver是驱动的注册入口,它和PCI/platform驱动类似,核心就是.probe、.disconnect、.id_table。.id_table里声明这个驱动能匹配的设备(vendor ID、product ID、bInterfaceClass、bInterfaceSubClass这些),usbcore在设备枚举或驱动注册时都会做匹配。匹配成功后probe被调用,失败则驱动对应的功能可能无法工作。

还有一个容易被忽略但很重要的结构体是struct usb_host_endpoint。每个USB接口下会有若干端点(endpoint),但驱动用的不是描述符结构体,而是系统分配好的usb_host_endpoint。它和usb_endpoint_descriptor是不同层面的东西:描述符是设备声称的静态信息,host_endpoint是usbcore在枚举后构建的运行时信息,里面还存放着该端点当前被占用的URB队列、带宽估算、streams信息(USB 3.0以后支持bulk stream)。驱动在写URB时常常通过usb_pipeendpoint(pipe)来定位端点,或者使用usb_rcvbulkpipe()这类宏直接由端点和方向合成pipe。

理解这些结构体后,你再看协议栈的代码才不会迷路。比如调试时遇到“can’t find interface”这类错误,本质上是设备模型里没有生成对应的usb_interface,或生成后被usbcore丢弃了,而不是你的读取函数有问题。

2. 设备枚举与URB传输:协议栈里最重要的两条路

2.1 设备枚举流程:插入一个U盘后内核做了什么

很多做应用层USB开发的人对“枚举”的印象就是Windows右下角弹提示。但在内核里,枚举是一个非常精细的状态机流程。

当一个设备插入Hub端口,Hub检测到底端口的D+或D-线上电平变化(对应不同速度),上报一次连接事件。usb_hub事件线程会去读Hub端口状态,然后对设备进行复位。复位后再等内容:

第一步,分配地址。设备刚上电时处于默认地址0,usbcore向设备发送一个SET_ADDRESS控制请求,把新地址告诉设备,之后所有通信都走这个地址。第二步,读取设备描述符。严格来说是先读前8个字节确认端点0的最大包长度,然后再读取完整描述符。这里有个经典坑:如果你看到device descriptor read/64, error -71这类日志,往往是端点0最大包长度不一致或电气信号不稳定。

第三步,读取配置描述符。因为配置描述符里允许有多个interface、多个端点,内核需要先读配置描述符的前9个字节,根据它的wTotalLength字段再读完整配置。composite设备还会读取设备描述符里的bNumConfigurations,逐个处理。第四步,根据配置创建interface、端点等内核对象。USB Core会为每个interface创建一个usb_interface,并挂在usb总线设备模型下。第五步,发出广播,让所有已注册usb_driver的id_table去匹配。匹配成功,driver的probe会被调用;失败,接口可能一直处于“unbound”状态,甚至由usb_generic驱动接管。

dmesg里常见的usb 1-1: New USB device found, idVendor=...就是枚举成功的标志。如果枚举失败,控制请求会返回对应的错误码,比如-ENOTCONN表示设备断开,-EPROTO表示PID/CRC错误。搞清楚枚举顺序,排查“为什么内核识别不到设备”会更快。

2.2 URB到底是什么:从submit到completion的一生

URB全称是USB Request Block,是Linux USB协议栈里传输的“一封信”。你想发数据给设备,不是直接写寄存器,而是构造一个URB,填好方向、端点、数据缓冲区、回调函数,然后提交给usbcore。

一个URB的基本生命周期是这样的:

  • 驱动调用usb_alloc_urb()分配urb结构体;
  • 填充struct urb的各字段,包括pipe、transfer_buffer、transfer_buffer_length、complete回调、context;
  • 调用usb_submit_urb()提交,usbcore会把URB加入相应端点的队列,并通知HCD;
  • HCD通过硬件调度执行传输,可能分多笔事务来处理大数据块;
  • 传输完成或有错误,HCD在中断上下文里向usbcore报告,usbcore最终调用你的complete回调,并设置urb->status;
  • 完成后通常由complete回调里调用usb_free_urb()释放(也可以放事后清理函数)。

了解这个生命周期,你就知道为什么USB驱动的complete回调不能在睡眠函数里随意调用,因为它可能是中断上下文中被调用的。虽然较新内核的complete回调可能在tasklet或软中断中,但依然不能像进程上下文那样随便kmalloc(..., GFP_KERNEL)。这里建议使用GFP_ATOMIC,或者把更多工作延迟到workqueue去处理。

USB协议里传输类型分成四类,URB也对应四种:

传输类型典型用途带宽/延迟特点常用宏或函数
控制传输枚举、命令设置双向、受最大包长限制、延迟不保证usb_control_msg()
批量传输U盘、串口数据高吞吐、延迟不确定usb_bulk_msg()
中断传输鼠标、键盘、中断端点设备周期固定、低吞吐延迟可预测usb_interrupt_msg()
等时传输音频、视频实时流带宽固定、不允许出错重发手动填充iso_packets

批量传输最大带宽能到理论值,但延迟大;中断传输适合小数据高频查询;等时传输适合音视频流但丢了就丢了。驱动选错传输类型,性能会很难看。比如有些廉价的USB转串口芯片,如果驱动错误地使用中断传输读数据,你看到的就是CPU占用高、数据吞吐低。

3. 动手写一个USB驱动:和协议栈正确打交道

3.1 编一个最简probe框架,先跑通再说

写过几个USB驱动后,我越来越觉得“先跑通再优化”在USB开发里特别重要。一个最小驱动通常长这样:

#include <linux/module.h> #include <linux/usb.h> static int my_usb_probe(struct usb_interface *intf, const struct usb_device_id *id) { struct usb_device *dev = interface_to_usbdev(intf); dev_info(&intf->dev, "probe: vendor=0x%04x product=0x%04x\n", id->idVendor, id->idProduct); dev_info(&intf->dev, "speed=%s\n", usb_speed_string(dev->speed)); return 0; } static void my_usb_disconnect(struct usb_interface *intf) { dev_info(&intf->dev, "disconnect\n"); } static const struct usb_device_id my_usb_id_table[] = { { USB_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(usb, my_usb_id_table); static struct usb_driver my_usb_driver = { .name = "my_usb_driver", .probe = my_usb_probe, .disconnect = my_usb_disconnect, .id_table = my_usb_id_table, }; module_usb_driver(my_usb_driver); MODULE_LICENSE("GPL");

这个框架里最关键的是id_table。你可以根据bInterfaceClass匹配一类设备,例如{ USB_INTERFACE_INFO(USB_CLASS_HID, 0, 0) },也可以精确匹配vendor/product。module_usb_driver宏把module_init和module_exit都隐掉了,省事。

但真正开发时,probe里通常还要拿到端点参数。方法是usb_find_interface? 不对,通常先获取interface->cur_altsetting->endpoint,然后根据endpoint->desc.bmAttributes判断传输类型,再用usb_endpoint_dir_in()等宏判断方向:

struct usb_host_interface *alt = intf->cur_altsetting; struct usb_endpoint_descriptor *ep; for (i = 0; i < alt->desc.bNumEndpoints; i++) { ep = &alt->endpoint[i].desc; if (usb_endpoint_is_bulk_in(ep)) { bulk_in_pipe = usb_rcvbulkpipe(dev, ep->bEndpointAddress); bulk_in_buf = kmalloc(1024, GFP_KERNEL); } }

有时候设备有多个alt setting,比如UVC摄像头在开始传输前要先通过usb_set_interface()切换alternate,这个动作绕不开协议栈。在probe阶段不要随便切alt setting,等流传输准备好后再设。

3.2 提交URB、处理complete回调的注意点

一个发送URB的常见写法是:

struct urb *urb = usb_alloc_urb(0, GFP_KERNEL); if (!urb) return -ENOMEM; usb_fill_bulk_urb(urb, dev, usb_sndbulkpipe(dev, 1), buf, len, my_write_callback, ctx); ret = usb_submit_urb(urb, GFP_KERNEL); if (ret) { usb_free_urb(urb); return ret; }

这里usb_fill_bulk_urb只是一个封口函数,它不太检查参数合法,你填的端点必须真的存在,方向要和设备的端点方向一致。如果方向反了,HCD会返回-EINVAL,然后在日志里看到urb submitted with no completion handler——这个警告是usbcore提醒你URB没有complete回调,意味着即使失败也没人通知。

complete回调里最经典的一个错误是:驱动收到了urb->status == -ESHUTDOWN或-ENOENT,却以为设备出错了直接释放URB。-ESHUTDOWN通常是你主动调用了usb_kill_urb()或断开连接,属于预期行为。断开流程里不要再用一个已经kill的URB。建议在disconnect里做这几件事:

usb_kill_urb(rx_urb); usb_kill_urb(tx_urb); usb_free_urb(rx_urb); usb_free_urb(tx_urb); kfree(bufs);

usb_kill_urb会等待正在执行的回调结束,因此不要在complete回调里调用它,会出现死锁。我吃过这个亏,后来习惯用usb_poison_urb在设备异常时强制丢弃待处理URB,但这种强制方式会让URB的complete回调立即以-ENOENT结束,需要你的回调代码妥善处理。

另外,批量URB的传输缓冲区最好用usb_alloc_coherent()分配DMA一致内存,尤其当传输频繁且数据量大的时候。如果你的数据缓冲区是普通的kmalloc内存,协议栈在底层往往还要做一次dma_map或bounce,虽然不至于出错,但效率会低。用usb_alloc_coherent可以避免某些平台上的cache一致性问题。

3.3 不想写内核驱动?usbfs和libusb怎么借力协议栈

不是所有场景都需要写内核驱动。调试新硬件时我经常先用libusb验证设备,再用内核驱动做产品。libusb用户态直接调用usbfs(也就是/dev/bus/usb/),绕过了内核usb_driver匹配流程,但走的依然是usbcore和HCD。它的好处是你可以在用户态发控制、中断、批量传输,不用每次改代码都重编内核模块。

不过要记住,如果设备已经绑定了一个内核驱动,libusb需要先detach kernel driver,否则会返回LIBUSB_ERROR_BUSY。这相当于把设备从内核驱动手里抢过来,代价是那些内核驱动的功能(比如串口)就暂时不可用了。生产环境如果USB设备是固定的自定义设备,写内核驱动更可靠;做工具链和原型验证,libusb更快。

lsusb这个命令大家都会用,它能从sysfs读设备描述符。但更深的信息得看/sys/bus/usb/devices/里每个子目录的结构:

  • idVendor,idProduct,bcdDevice
  • bConfigurationValue
  • bNumInterfaces
  • speed
  • power/目录里的autosuspend_delay_ms,control
  • 每个ep_*符号链接指向端点对应的设备结构体

如果你发现设备在lsusb里有,但某个interface没有内核驱动绑定,可以在/sys/bus/usb/drivers/usb下看到它的状态。有时候注册了驱动却依然显示unbound,大概率是id_table里的匹配条件不对,或者probe返回了非0值。

4. 协议栈调试与性能观测:把USB流量“看清楚”

4.1 usbmon:Linux原生的USB抓包大杀器

调试USB协议栈,我不会只用dmesg。dmesg只能看到内核主动打印的错误,看不到具体传输的数据内容。usbmon是内核自带的USB监控设施,利用它在USB总线层面“旁路”所有URB,能记录方向、端点、URB状态和数据长度。使用方式非常直接:

# 挂载debugfs mount -t debugfs none /sys/kernel/debug # 查看usbmon总线号列表 cat /sys/kernel/debug/usb/usbmon/0u

数字0代表所有总线,u读模式表示“raw data”。想抓特定总线,比如总线1,用1u。如果想在wireshark里分析,还可以用wireshark -i /sys/kernel/debug/usb/usbmon/0u,新版wireshark直接把它当抓包接口。这个组合我几乎每周都在用,尤其是调试自定义HID设备或者USB串口数据不完整时,一抓就清楚是谁的问题。

usbmon输出的关键字段包括:

  • 事件类型U表示URB提交,C表示URB完成,S表示URB中断? 这里再具体说:usbmon事件里字母有U(URB submitted)、C(URB complete)、Z(ISO start)。每个事件带URB地址、方向、端点、状态、长度等,
  • 状态码,比如-EINPROGRESS表示正在进行,0表示成功,-EPIPE表示端点被stall。

使用usbmon有一个前提:必须在编译内核时开启CONFIG_USB_MON,很多商业发行版是默认开启的,但嵌入式板卡上未必。如果你发现/sys/kernel/debug/usb/usbmon目录不存在,先检查内核配置,再确认main controller是不是xHCI。xHCI由于部分传输由硬件管理,usbmon对等时数据的记录可能不如EHCI完整,但常规控制/批量/中断足够用。

4.2 借助动态调试与ftrace观察协议栈内部调用

dmesg偶尔会漏打印,因为很多usbcore和HCD里的dev_dbg日志默认是关掉的。比如想观察usb_submit_urb到usb_hcd_submit_urb的调用路径,可以打开内核动态调试:

echo 'func usb_submit_urb +p' > /sys/kernel/debug/dynamic_debug/control echo 'func usb_hcd_submit_urb +p' > /sys/kernel/debug/dynamic_debug/control

打开后dmesg会输出带文件名行号的调用日志。这个手段在排查“URB卡住不complete”时特别有用。你可以看到URB有没有真正提交到HCD,有没有启动定时器。如果提交后完全没有后续中断,那问题可能出在硬件层或者中断丢失,而不是协议栈。

另一个工具是ftrace的function_graph。非常适合观察一个URB从submit到completion的完整函数调用链。使用:

echo function_graph > /sys/kernel/debug/tracing/current_tracer echo usb_submit_urb > /sys/kernel/debug/tracing/set_ftrace_filter echo 1 > /sys/kernel/debug/tracing/tracing_on # 执行你的USB操作后 cat /sys/kernel/debug/tracing/trace

你会看到usb_submit_urb、usb_hcd_submit_urb、xhci_queue_bulk_tx之类的帧,再往下就是硬件驱动相关函数。这套方法比盲目翻代码高效得多,尤其网络是黑盒的情况下。

4.3 常见问题排查速查表,建议收藏

写USB驱动的人,基本绕不过下面这些报错。我把这些年调试时的经验和排查顺口溜整理成一张表:

现象 / 日志可能原因排查方向
device not accepting address地址设置失败 / 电气兼容性差检查线缆长度、供电电流;换主控口
-EPIPE端点STALL读端点的halt状态;用usbmon确认哪个阶段STALL
-ETIMEDOUTURB超时未完成考虑URB大小、供电;用usb_clear_halt复位端点
-ESHUTDOWN设备断开、总线复位、驱动kill检查系统是否进入suspend、线缆被拔
-ENOENTURB被kill或poison排查disconnect流程是否有竞态
-ENODEV设备结构已释放/断开防止驱动使用已经释放的usb_device指针
probe返回-ENOMEM内存分配失败检查DMA一致内存池、传输缓冲区大小
cacheline size mismatch某些嵌入式平台DMA对齐问题用usb_alloc_coherent()分配缓冲

这里必须再提一句:不要一看到-EPIPE就认为设备坏了。普通USB设备在内部状态不对时,端点会主动返回STALL,用来告诉主机“此请求不支持或条件不满足”。很多情况下,你只需要在驱动里对控制请求重试或复位设备,而不需要重新插拔。

5. 不止是主机侧:USB Gadget框架与协议栈的对称之美

5.1 把Linux设备变成USB从设备,理解协议栈另一半

前面讲的主要是Linux做主机(Host),但Linux同样可以作为USB设备,也就是Gadget。很多嵌入式产品比如手机上用USB连接电脑、工业设备虚拟串口、U盘模式,背后的框架就是USB Gadget。

Gadget协议栈的核心组件是UDC(USB Device Controller)驱动,它对应硬件里的device controller IP。向上有usb_gadget结构表示硬件控制器,再往上是usb_gadget_driver,也就是function驱动。最早的实现里,你可以直接编译g_serial.ko、g_mass_storage.ko这类gadget驱动,功能单一。现代内核更推荐用ConfigFS来动态配置复合设备,就像拼乐高一样组合出多功能的USB设备。

看到这里你应该理解,协议栈并不是只有主机侧一根筋。主机侧的usb_device和从设备侧的usb_gadget不是一回事,但USB协议描述的device/interface/endpoint模型两侧都在用。因此读懂主机侧的usbcore,再看Gadget侧的function实现,会发现很多命名上的对称,比如usb_function、usb_configuration。

5.2 用ConfigFS快速虚拟一个串口Gadget

这里演示一个我最常用来测试的配置:把板卡虚拟成一个USB串口设备,接上电脑后看到/dev/ttyACM0。在支持ConfigFS的硬件上,操作流程是:

modprobe libcomposite modprobe usb_f_acm mkdir -p /sys/kernel/config/usb_gadget/g1 cd /sys/kernel/config/usb_gadget/g1 echo 0x1234 > idVendor echo 0x5678 > idProduct mkdir -p configs/c.1/strings/0x409 echo "Config" > configs/c.1/strings/0x409/configuration mkdir -p functions/acm.usb0 ln -s functions/acm.usb0 configs/c.1/ echo "ci_hdrc.0" > UDC # 这里换成你自己的UDC名称

这个过程中,libcomposite模块把配置从ConfigFS映射到Gadget框架;functions/acm.usb0创建了一个USB ACM(抽象控制模型)接口;最后把function链接到配置,再绑定UDC控制器,硬件才真正作为USB设备开始工作。注意UDC文件里的字符串必须和/sys/class/udc/下的实际目录名一致,否则会报-ENODEV。

如果你的产品要做自定义USB设备,比如给传感器做一个厂商自定义接口,用ConfigFS配起来依然很顺手。区别只是需要写一个function驱动,实现usb_function里的bind/unbind/start/stop回调。这部分开始深入Gadget协议栈内部时,建议先拿g_serial源码读一遍,再谈别的。Gadget侧对URB没有主机侧那么概念统一,因为你的function驱动直接面对UDC的usb_ep和usb_request,结构上更接近“接口级操作”。

我个人在实际调试中的体会是,遇到USB问题,先别急着翻数据手册。先把usbmon抓包放在第二位,dmesg放在第三位,真正第一位应该确认lsusb -t看到的拓扑和sysfs里的设备模型是否正常。模型不对,问题一定出在协议栈的枚举或驱动绑定侧;模型正常但数据传输错误,那才轮到URB和硬件寄存器。这套“先看模型、再看流量、最后才看代码”的顺序,帮我省了无数冤枉时间。最后再分享一个小技巧:调试新设备时,在probe里加一句dump_stack()看调用栈,往往比猜测id_table匹配路径要实在得多,但记得调试完一定要删掉,否则生产环境日志会被刷爆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询