
前一阵帮朋友调一个虚拟摄像头方案板子用的 i.MX8M PlusLinux 5.10UVC Gadget 枚举正常、驱动也能装上但 Windows 默认相机一打开就黑屏。我一边抓 USB 抓包一边翻内核源码最后定位到是配置描述符里 VS 接口的 bInterfaceNumber 和实际分配到的接口号错位。借着这次排查我把 usb gadget uvc driver 的 code 从头到尾过了一遍这篇就把驱动的骨架、控制请求路径、视频数据搬运以及常见的描述符坑都摊开来讲。适合正在用 configfs 搭 UVC Gadget、或者读 drivers/usb/gadget/function/uvc.c 读到一半卡住的朋友参考。1. 先搞清楚 USB Gadget 与 UVC 在内核里的角色分配UVC Gadget 最容易被混淆的一点就是它跟主机端的uvcvideo驱动是两回事。uvcvideo是 host 侧驱动程序用来接管插入的 USB 摄像头而uvc gadget是 device 侧驱动让开发板冒充一个标准的 UVC 摄像头把板端的视频数据通过 USB 总线送给主机。也就是说同一个 USB 摄像头的协议栈host 端和 device 端各有各的实现Gadget 这边是设备端。Linux USB gadget 框架本身分三层最底层是 UDC Controller 驱动比如 dwc3、ci_hdrc中间是 composite gadget framework上层是具体的 function 驱动。UVC 就是 function 层的一个成员和f_mass_storage、f_serial这类 function 平级。它的核心价值是让一个 SOC 板子在主机眼里变成一个不需要额外驱动的摄像头。从 UVC 协议角度来看一个 UVC function 一定包含两个逻辑接口。一个是 VideoControlVC接口负责摄像头终端控制、参数协商通常带一个中断 IN 端点另一个是 VideoStreamingVS接口负责视频数据传输包含 Format/Frame 描述符以及 Bulk 或 Isoc 类型的视频 IN 端点。主机枚举设备时会通过 VC 接口发送 UVC 类请求协商分辨率、帧率、格式协商完成后再通过 SET_INTERFACE 把 VS 接口切到非零 alternate setting设备端才开始往主机邮箱里倒视频数据。所以读代码之前先在脑子里立起这个模型uvc.c管的不是如何在屏幕上显示图像而是如何把 USB 协议层的请求和 V4L2 视频设备桥接起来。数据方向是反的不是从 USB 收到图像给应用而是应用往 V4L2 设备填一帧帧图像驱动把它们打包成 USB request 发出去。1.1 从一张文件清单看懂驱动边界内核里 UVC Gadget 的代码分布在drivers/usb/gadget/function/下面主力文件就这几个先看这张表会省很多事文件职责uvc.cfunction 生命周期、bind、setup 控制请求入口、事件设备uvc_v4l2.cV4L2 视频设备、ioctl 处理uvc_queue.cvb2 buffer 队列管理uvc_video.cUSB request 池、payload 组装、端点发送uvc_configfs.cconfigfs 属性与描述符树、UVC 参数之间的映射uvc.h共用结构体、事件类型、追踪宏定义初次阅读时我建议先把 uvc.c 里uvc_bind和uvc_setup这两个函数看懂再跳到 uvc_video.c 看uvc_video_pump然后回头补 configfs 的内容。因为 configfs 的部分大量代码是在解析字符串属性如果不先理解最终的描述符结构很容易被这些 attr 读写逻辑带偏。1.2 UVC 类协议在 gadget 侧的分层USB 协议里面传输类型分成 Control、Interrupt、Bulk、Isoc 四种UVC 规范几乎全用上了。端点 0 上的 Control 传输用于枚举和 UVC 类控制请求VC 接口的中断 IN 端点用于上报摄像头事件VS 接口的视频端点负责传图像数据。普通开发者可能不关心这么多但一旦遇到主机端能识别但打不开流这种问题你就得知道这些层级之间是怎么配合的。UVC 类请求集中在SET_CUR、GET_CUR、GET_MAX、GET_MIN、GET_RES、GET_INFO这一组它们都是端点 0 上的 Control Transfer方向由bmRequestType里的最高位决定。Gadget 侧不会把这些请求全部实现掉而是转发给用户态由用户态应用根据具体硬件去应答。这个设计有点类似 HID 驱动里的 report 处理但比 HID 更松散内核只提供通道不解释语义。理解到这一步后面看uvc_setup的分支逻辑就非常顺了。2. 从 uvc_bind 开始设备描述符与 configfs 的联动关系Gadget function 没有传统 platform driver 的probe入口一个 function 被添加进 configuration 之后由 composite framework 调用它的 bind 回调。在 configfs 方式下uvc_bind才是真正把 function 变成 USB 设备的入口。uvc_bind的核心工作可以归纳成四件事申请接口编号、复制描述符、申请端点、注册 V4L2 和事件设备。以 5.10 内核为例流程可以简化成下面这段伪代码static int uvc_bind(struct usb_configuration *c, struct usb_function *f) { struct uvc_device *uvc to_uvc(f); struct uvc_streaming *stream uvc-stream; /* 1. 分配 VC 和 VS 两个接口号 */ stream-control_intf usb_interface_id(c, f); stream-streaming_intf usb_interface_id(c, f); /* 2. 把 configfs 里配好的描述符树复制到 function */ ret uvc_copy_descriptors(uvc, c-cdev-gadget); /* 3. 自动分配中断端点和视频端点 */ ret usb_ep_autoconfig(c-cdev-gadget, stream-int_ep); ret usb_ep_autoconfig(c-cdev-gadget, stream-video_ep); /* 4. 注册 /dev/videoX 和 /dev/uvc-gadget */ ret uvc_register(uvc); }第二步的uvc_copy_descriptors是最容易出问题的因为它要做的事情不只是memcpy而是把所有 configfs 里配置的格式、帧、帧间隔按照 USB Video Class 规范生成一串完整的描述符二进制数据。只要有一个字段的格式不对主机端 UVC 驱动就可能直接拒绝加载。2.1 bind 回调里接口编号和端点参数是怎么定下来的接口编号必须动态分配因为一个 composite 设备可能同时挂 UVC、ACM、Mass Storage 多个 function所有接口编号是按顺序排的。usb_interface_id()会返回当前 configuration 里下一个可用的接口号然后把函数描述符里的bInterfaceNumber打上补丁。这也是为什么很多人在 configfs 里没法手工指定 VS 接口号的原因内核不允许你写死因为写死一定会和别的 function 冲突。端点地址也不是 UVC 规范定死的而是由 UDC 控制器的硬件布局决定。usb_ep_autoconfig会在gadget-ep_list里找一个类型、方向、带宽都合适的端点然后把它分配给 UVC 使用。所以你在主机端用lsusb -v抓到的 bEndpointAddress从来不是你在 configfs 里设置出来的而是驱动 bind 之后动态补进去的。理解了这一点再看抓包文件里的描述符就不会被 endpoint 地址带偏。这类自动分配的代价是如果 UDC 控制器没有足够的 IN 端点或者端点被其他 function 占用了bind 就会失败。实际项目里比较常见的一个问题是UVC 使用 Isoc 传输时要求 UDC 必须支持对应高速下的带宽参数。你配置的bInterval和wMaxPacketSize如果超出控制器能力usb_ep_autoconfig可能仍然返回一个端点但真正启动流时主机端会报告带宽不足。这个不是代码逻辑问题而是配置参数问题。2.2 configfs 描述符树是如何变成 USB 描述符二进制流的configfs 方式下UVC function 在/sys/kernel/config/usb_gadget/.../functions/uvc.usb0/下会有一个目录树常见结构是uvc.usb0/ ├── control/ └── streaming/ ├── mjpeg/ │ └── 720p/ │ ├── wWidth │ ├── wHeight │ ├── dwFrameInterval └── uncompressed/ └── 360p/当你向这些文件写入数值configfs 的属性回调会更新内核里的struct uvc_function_config。但这时候还没有生成任何 USB 描述符只有等到 UDC 真正 bind function 时uvc_copy_descriptors才会遍历这棵配置树把 mjpeg/uncompressed、每个 format、每个 frame 以及对应的帧间隔按 UVC 规范的二进制格式组织成标准接口描述符和 class-specific 描述符链。有个非常常见的误解是改了 configfs 里的 wWidth抓包马上就能看到变化。实际不一定因为如果 board 端 UDC 处于已绑定状态描述符已经在 bind 时被复制到 function 里改配置不会立刻生效。必须把 UDC 解绑、清掉 configfs 配置、重新创建 function再重新 bind描述符才会真正更新。调试的时候我总是习惯在改完 configfs 后先echo UDC再重新设置避免抓到旧缓存。3. 控制管道上的 UVC 类请求如何走到用户态控制请求是 UVC Gadget 驱动里最绕但最值得读的部分因为它的架构和常见 USB function 不太一样。大部分 function 驱动在自己内部就把 class 请求处理掉了但 UVC Gadget 把控制参数的解释权丢给了用户态。直接结果是内核里的uvc_setup代码量不大但它撑起了整个 UVC 控制链路。每次主机的控制传输都对应一个 setup packet里面包含了bmRequestType、bRequest、wValue、wIndex、wLength这些字段。UDC 控制器收到 setup packet 后composite framework 会根据bRequestType USB_TYPE_MASK分类。标准请求比如GET_DESCRIPTOR、SET_CONFIGURATION由 framework 和 UDC 驱动自己处理class 请求SET_CUR、GET_CUR这类才会转发到uvc_setup。uvc_setup做的事情非常直接先判断这个 class 请求是不是发给 UVC VC 接口的然后把整个struct usb_ctrlrequest打包成一个事件放入事件队列唤醒在/dev/uvc-gadget上等待的用户态程序。用户态程序读出事件后根据请求内容构造应答数据写回驱动驱动再把应答放到端点 0 的 data stage 或者直接完成 status stage。3.1 setup 包的分流标准请求交给框架类请求留给用户态为什么不让内核直接处理 UVC 类请求因为在设备端UVC 控制参数亮度、对比度、曝光、增益等跟具体摄像头传感器强相关。内核驱动不想也不可能为每个传感器都维护一套寄存器映射。把控制请求转发给用户态意味着用户态程序可以很自由地根据自己的硬件去应答。比如主机发一个GET_CUR请求查询当前亮度用户态程序可以读 I2C 获取 sensor 寄存器然后把 16 bit 的结果通过 EP0 返回给主机。这个设计非常实用但也带来一个隐患如果用户态程序没有及时处理事件主机端的控制请求就会超时。Windows 下表现为打开相机后卡在设置界面Linux 下表现为uvcvideo: Failed to query (GET_CUR) UVC control。所以读到这里你就会明白uvc_setup本身不是性能瓶颈用户态事件循环才是。另一个容易踩的细节是 status stage 的处理。控制请求分 setup、data、status 三个阶段完成 status stage 才是整个控制传输的结束。由于 UVC 类请求的数据阶段可能需要等待用户态准备uvc_setup不能立刻给 UDC 一个完成的回复而是返回一个类似USB_GADGET_DELAYED_STATUS的状态让 framework 知道要等用户态写回之后再完成。如果你在移植驱动时把这个延迟状态漏掉了主机端就会出现 EP0 状态错误枚举没有问题但后续相机应用一启动就报错。3.2 /dev/uvc-gadget 与 video_device 如何配合UVC Gadget 驱动在 bind 时会注册两个设备节点。一个是video_device通常对应/dev/videoX用来做视频数据流另一个是 misc 设备/dev/uvc-gadget专门用来传递控制事件。这两个节点的创建都发生在uvc_register里所以如果你的脚本只创建了 function 而没看到/dev/videoX大概率是 bind 还没成功或者被 systemd 的 udev 规则改了名字。板端用户态程序要同时打开这两个节点一个读控制事件一个往 video 设备里写视频帧。内核自带的示例工具uvc-gadget就是这个工作模式它会根据/dev/uvc-gadget读到的事件响应主机的 SET_CUR/GET_CUR同时不断把 V4L2 buffer 排队到视频端点发送出去。调试时特别要注意一点在事件处理回调里不要做长时间阻塞操作。因为主机端控制请求是有超时的你在这里读个 I2C 可能没问题但如果去等一个互斥锁或者 sleep 几十毫秒就很容易触发主机端 UVC 驱动超时。我见过有项目在用户态控制事件里做了网络请求主机端每次打开摄像头都卡十几秒最后不得不把网络请求改成异步。4. 视频数据搬运的核心uvc_queue 与 uvc_video_pump 的配合如果说控制管道决定的是开不开得了流那视频数据管道决定的就是画面流不流畅。视频侧的代码在uvc_v4l2.c、uvc_queue.c和uvc_video.c三个文件里核心是一个 V4L2 output 设备加一个 workqueue 驱动的 USB request 流水线。先明确一点这个 V4L2 设备是 output 类型不是 capture 类型。普通摄像头的/dev/video0是应用程序往里面读图像而 UVC Gadget 的/dev/video0是应用程序往里面写图像。方向上千万别搞反。板端用户态程序生成一帧帧 RGB/YUV/MJPEG 数据通过VIDIOC_QBUF把这些帧挂到 vb2 队列驱动再从队列里取 buffer拆成 USB 请求发送到主机。uvc_video_pump就是这个流水线的发动机。它是一个 workqueue 函数只要有新的 buffer 入队或者某个 USB request 发送完成空了出来它就会被重新调度。它的核心逻辑是维护两个链表一个是 QBUF 进来的待传 buffer 队列一个是空闲usb_request池。只要有待传 buffer 同时又有空闲 request它就把数据从 buffer 拷贝进 request填好 payload 头然后usb_ep_queue扔给 UDC 控制器。4.1 从 V4L2 到 USB缓冲区两次搬运与指针管理视频数据经过的路径大概是这样的用户态应用 mmap 一个 vb2 buffer往里面填充一帧图像然后调用VIDIOC_QBUF。驱动把这个 buffer 挂进uvc_queue的链表uvc_video_pump会被唤醒。它从链表中取出 buffer 头部再取一个空闲usb_request把 buffer 里的数据切一块出去填充到 request 的 buf 里然后usb_ep_queue提交。request 发送完成后uvc_video_complete回调负责把 request 归还到空闲池并再次调度 pump。这里有两个关键点值得琢磨。一是 request 池的数量默认一般是 5 个不是越多越好。request 池太小USB 链路稍微一卡就无 request 可用吞吐掉下去request 池太大内存占用会增加而且 UDC 控制器在同一时刻能排队的 request 数量也有限。我在某些平台上试过调到 8发现帧率没有明显改善反而内存占用更难看后来就保持默认了。二是 buffer 到 request 之间的拷贝。理想情况是 DMA 直通但很多 UDC 驱动的 DMA 映射和 vb2 内存之间还有一层软件搬运实际上就是memcpy。所以你在优化帧率的时候最先看的应该是uvc_video_pump里的 memcpy 时间而不是疯狂调 USB 带宽。如果发现 CPU 占用过高可以考虑把图像尺寸降下来或者把传输格式从 uncompressed 换成 MJPEG让数据量先降一个数量级。4.2 payload 头与 FID 翻转给 host 的帧边界信号USB 视频数据不是直接裸传图像的每一个 UVC payload 前面都要加一个 payload header。header 的第一个字节是bHeaderLength第二个字节是bmHeaderInfo。bmHeaderInfo里的 bit0 表示这一包是否是一帧的结尾EOFbit1 表示 FID 翻转bit2 表示带了 PTSbit3 表示带了 SCR。主机端的uvcvideo驱动就是靠 FID 和 EOF 来判断一帧从哪里开始、在哪里结束的。FID 的逻辑需要特别注意每一帧开始的时候 FID 要翻转一次同一帧内部的所有 payload 都保持相同的 FID到了下一帧再翻转。驱动里一般会用一个frame_id成员来记录当前 FID在 pump 开启新帧时把它取反。如果 FID 没有正确翻转主机端会把连续两帧当成同一帧处理画面会出现撕裂或者花屏。如果 EOF 没有在一帧最后一个 payload 里置位主机端可能一直等待下一帧的 EOF画面延迟会明显增加。我在调一个 1080p MJPEG 方案时曾经因为 EOF 放错了位置Windows 的相机能打开但画面每隔几秒就停顿一下。后来抓 USB 包发现每一帧的最后一个 payload 头里 EOF 位没有被置上而是被挤到了下一个帧的第一个 payload。这个错误不抓包根本看不出来靠代码 review 也是看了半天才找到。5. 描述符与端点配置我踩过的那些 USB 抓包才能定位的坑光看源码不开抓包工具UVC Gadget 排错效率极低。因为很多问题不是代码逻辑崩溃而是描述符字段不符合 host 预期。usb抓包是这里最值的工具usb描述符和usb协议是理解抓包内容的底层背景。每次遇到 UVC 问题我先不猜先把枚举阶段的描述符抓下来看一遍。一个完整的 UVC 配置描述符在 USB 协议层面长这样先是 configuration descriptor接着是接口关联描述符 IAD然后是一个 VC 接口及其 class-specific 描述符、一个中断 IN 端点描述符再然后是 VS 接口的 alternate setting 0 和 alternate setting 1以及 VS 的 class-specific 描述符和视频 IN 端点描述符。主机端 UVC 驱动会遍历这一段描述符任何一个字段对不上都可能让设备驱动加载失败。5.1 一个典型的接口号错位案例之前我遇到的那个 Windows 黑屏问题就是典型的接口号错位。当时我在一个 composite 配置里同时挂了 UVC 和 ECMUVC 的 VC 接口分配到了 interface 0VS 接口分配到了 interface 2但 IAD 里的bInterfaceCount仍然写着 2。Windows 的 UVC 驱动认为接口 0 到接口 1 是一个完整的 UVC 双接口设备结果发现接口 1 根本不是 VS接口 2 莫名其妙多出来了相机应用就打不开视频流。用usb抓包抓枚举阶段的数据能看到GET_DESCRIPTOR返回的配置描述符里IAD 与 VS 接口号明显不一致。Linux 主机虽然容忍了这种描述符但 Windows 的类驱动对 UVC 描述符要求严格得多。解决办法也很直接调整 composite 配置里 function 的添加顺序或者干脆给 UVC function 单独一个 configuration保证 VC 和 VS 接口号连续。这之后我才意识到UVC 描述符的接口号连续性不是规范建议而是 Windows 驱动的硬要求。5.2 USB 抓包需要重点看的三个时间点抓 UVC Gadget 的包不需要从头到尾看每一包重点看三个时间点。第一是枚举阶段的GET_DESCRIPTOR。重点检查 configuration descriptor 里的 IAD、bInterfaceNumber 是否连续、视频端点描述符的 bEndpointAddress 是否和实际端点一致、bInterval 和 wMaxPacketSize 是否在控制器能力范围内。Wireshark 会解析 UVC class-specific 描述符你可以直接看到 VC 和 VS 的结构。第二是主机发起SET_INTERFACE之前。如果主机没有发起 SET_INTERFACE或者一直停在 VS interface alt 0说明之前的SET_CURVS_PROBE_CONTROL 或 VS_COMMIT_CONTROL协商没通过。这时候抓包会看到设备端返回 STALL。需要留意的是驱动返回 STALL 不一定代表内核代码错误也有可能是用户态事件循环没及时响应导致 EP0 超时后 UDC 自动 STALL。第三是视频传输开始后的数据包。对于 MJPEG每一帧通常以ff d8开头以ff d9结尾。你可以在抓包里直接搜 payload 里的ff d8看是不是在每个 FID 翻转后出现再看帧末尾有没有 EOF。如果搜不到ff d8说明板端根本没有用户态程序往 V4L2 设备里喂数据后面解决的就是应用问题不是驱动问题。6. 调试 UVC Gadget 驱动的实用工具与参考路径最后聊点实操中能直接用的工具和阅读路径。UVC Gadget 的调试不像普通字符设备驱动那样靠printk就能搞定因为问题经常出在 USB 协议层你需要在协议层看到设备到底回了什么。我自己的固定组合是usbmon/Wireshark 抓包加上ftrace看驱动函数调用再用板端uvc-gadget工具做功能验证。usbmon 是 Linux 内核自带的 USB 抓包接口加载usbmon模块后在/sys/kernel/debug/usb/usbmon/下面按总线号开放数据。Wireshark 能直接解析这些文件把 Control Transfer、描述符、UVC class 请求都列出来。对于经验不够的新手我会建议先学会在 Wireshark 里过滤usb.setup.bRequestType 0x21这个过滤条件能找到所有发给 UVC 接口的 class 请求。内核源码里的阅读路径我推荐从drivers/usb/gadget/legacy/webcam.c开始而不是一上来就啃uvc_configfs.c。因为 webcam.c 是老的 legacy 注册方式调用链非常短可以用一句话概括usb_composite_probe-usb_add_config-uvc_bind_config-usb_add_function-uvc_bind。把这条链走通之后再去看 configfs 就是在想办法动态生成这串注册动作而已。6.1 从 webcam.c 快速理解整体调用链webcam.c 的问题在于它是 legacy 模式不推荐在新项目里使用但作为学习资料非常合适。它把整个 UVC function 作为一个完整的 composite gadget 注册掉没有 configfs 那层中间抽象代码能少看一半。对照webcam_driver里的bind回调你能清楚看到 configuration 是怎么被创建的UVC function 是怎么被挂上去的。等你理解了usb_add_function之后再回头看 configfs 就轻松了configfs 在用户态以目录和文件的形式描述了同样信息内核在uvc_copy_descriptors里把目录树翻译成描述符在uvc_bind里申请端点和注册设备。两者的底层完全一样只是入口不同。我经常跟同事说UVC Gadget 的 configfs 就是一个可以热插拔的 webcam gadget这句话基本准确。6.2 日志和 ftrace 的组合用法如果问题出在驱动内部比如 pump 没有被正确调度或者 request 一直排队但没有完成用 ftrace 看函数调用比打printk高效得多。可以这样开echo 0 /sys/kernel/debug/tracing/tracing_on echo function_graph /sys/kernel/debug/tracing/current_tracer echo uvc_* /sys/kernel/debug/tracing/set_ftrace_filter echo 1 /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace这样能看到uvc_video_pump是否被反复调度uvc_setup有没有进入usb_ep_queue之后 request 的 complete 回调是否及时。比在代码里到处加dev_dbg再重新编译省事很多。内核还支持 dynamic debug也可以针对单个文件打开调试日志echo file drivers/usb/gadget/function/uvc.c p /sys/kernel/debug/dynamic_debug/control我自己的经验是UVC Gadget 出问题百分之六十是 configfs 描述符配置不对百分之二十是用户态没往 /dev/videoX 里喂数据剩下百分之二十才是驱动本身的 bug。所以调试前先抓个包确认主机拿到的描述符是什么样再决定要不要去拔驱动代码。你花五分钟抓包省下来的时间可能比你瞎改一晚配置都值。