USB gadget自定义函数驱动开发:从描述符到configfs
发布时间:2026/10/18 20:26:10 锦皓数字建站

接手这个系列的时候我原本计划第五篇写 UVC 或 UAC 这类复杂协议设备的完整解析。但后来跟几位读者聊了聊发现大部分人卡住的点不在“用现成函数”而在“想做一个协议很简单的自定义设备却不知道该从哪儿下手”。比如有人要做个加密狗的替代品有人要做工厂测试用的 HID 上报工具还有人想在两个板子之间做自定义的高速通道——现成的 gadget 函数都不对口改起来又觉得无从下手。这篇文章就把这块彻底讲透我把 usb_function 这套框架掰开揉碎从描述符到端点再到 configfs 接入手把手一起写一个最简单的自定义 vendor 类设备函数。它的协议部分完全虚构但框架部分的每一个函数、每一个回调都是内核里面真实存在的机制你完全可以照着这套骨架去换成自己的协议。1. 为什么你需要自己写一个 function 驱动先盘一下内核里现成的武器库。Linux gadget 框架在 drivers/usb/gadget/function/ 目录下已经提供了一大堆现成函数f_hid、f_uvc、f_mass_storage、f_serial、f_acm、f_ecm、f_rndis、f_midi还有 f_accessory、f_obex 这些冷门一些的。如果你要做的东西刚好落在这几个大类的射程里我强烈建议直接用现成的改配置参数远比写驱动省事。但有一类需求是这些函数都覆盖不了的你的协议是私有协议只有你的上位机软件认识不能用标准类描述符去套你需要的不只是数据传输还想在控制端点实现“带外命令”比如让上位机发一个vendor请求来切换设备的工作模式你要对 USB 描述符里的厂商信息、序列号、bcdDevice 有强迫症级别的控制你要做边沿场景的压力测试标准函数做不了这种精细操作。我当年第一次动这个念头是因为某公司内部有个测试工装要用 USB 跑私有协议他们拿来一个评估板板子上的方案只提供了现成的批量传输类驱动不支持私有 class code也没有控制端点的自定义处理。折腾了一个星期最后只能老老实实进内核写函数。那套代码我后来拆出来做成模板也就是这篇文章的 demo 前身。写自定义 function 驱动的一个隐藏收益是你开始真正理解 USB 设备的生命周期。它不再是 configfs 里面几行字符串而是 bind、set_alt、setup、disable 这些回调在特定时刻被 composite 框架依次调用的过程。理解了这套节奏以后再排查标准函数的诡异问题思路会清楚很多。不过也要泼盆冷水。有一种情况我不建议自己写如果你的协议是块网卡只是希望改一改 MAC 地址或者加一个 vendor 请求那应该去改 f_ecm 的源码而不是从零写。USB gadget 函数驱动虽然门槛不算高但踩坑成本很高调试一次枚举失败可能就要折腾半天能用配置解决的问题就不要用代码解决。2. 框架咬合关系从 configfs 到你的回调函数很多教程一上来就贴代码我觉得这样反而容易看懵。先搞明白你写的这个“函数”在内核里到底挂在哪儿后面读代码才会顺。2.1 三个数据结构之间的关系composite 框架里最核心的三个结构体从外到内分别是struct usb_composite_dev整个 USB device 的抽象包含所有配置configuration、全局字符串表、设备描述符等struct usb_configuration对应一个配置描述符下面挂着若干个 functionstruct usb_function对应你写的那个“接口”也就是我们要动手实现的核心对象。在 configfs 模式下你在/sys/kernel/config/usb_gadget/gadget/functions/下面创建一个目录比如vendor_cmd.usb0这个操作的实际效果就是实例化了一个struct usb_function并且往某个 function 链表里注册了配置逻辑。之后你把vendor_cmd.usb0这个符号链接到configs/c.1/下面composite 框架就会在绑定阶段调到你注册的 bind 回调。所以你要实现的本质上是一组挂在struct usb_function上的函数指针。composite 框架负责 USB 协议的通用部分枚举、配置切换、字符串描述符你负责回答“我这个接口长什么样、数据怎么收发”。2.2 function 驱动的生命周期回调一个标准的 usb_function 驱动至少要实现下面的回调才算是“能跑”回调调用时机你要做的事如果没实现会发生什么bind设备枚举前composite 绑定配置时分配端点、分配字符串ID、注册描述符直接失败设备枚举不出来unbind设备解绑或模块卸载时释放资源内存泄漏set_alt主机选择了某个配置的某个接口使能端点、提交请求数据不通disable配置被切换或设备reset回收未完成的请求请求悬空容易panicsetup收到控制传输请求时处理 vendor/class 请求返回 -EOPNOTSUPP 丢给框架处理这里面 interplay 最微妙的是 bind 和 set_alt 的关系。bind 在枚举阶段就执行了这个阶段你只是“预订”端点资源把描述符注册到框架里真正让端点“跑起来”是在 set_alt。如果你在 bind 阶段就把端点 enable 了某些 USB 控制器会报错因为这时主机还没真正 set configuration。2.3 端点规划Bulk 和 Interrupt 的取舍我自己的经验是自定义协议优先用 Bulk 端点原因很简单Bulk 有流控硬件层面就处理了背压你不会因为上位机没在读导致数据覆盖Bulk 的吞吐可以打到 USB 控制器极限配合 usb_request 的异步模型双向刷数据很舒服唯一要注意的是 Bulk 端点不保证延迟工控类实时性要求高的场景改走 Interrupt 端点。Interrupt 端点的优势是固定轮询间隔适合小数据、低延迟、有实时性要求的控制类消息。代价是带宽小——全速模式下 Interrupt 最大每帧 64 字节高速模式下也就 1024 字节而且轮询间隔会占用总线时间。在函数驱动里端点不需要你手动指定物理端点号。你调用usb_ep_autoconfig()框架会从当前 UDC 的空闲端点池里挑一个适配的端点给你。这就是为什么你的代码在 DWC2 上能跑换到 ChipIdea 上也能跑只要 UDC 驱动实现了usb_ep_ops就行。3. 从零写一个 demovendor_cmd 函数驱动下面进入正戏。我用一个虚构的私有协议来演示完整实现协议名就叫 vendor_cmd。它模拟一个非常典型的自定义 IPC 设备一个 OUT 端点接收上位机下发的指令帧一个 IN 端点回传 ACK 和状态数据控制端点响应 0xA5 类命令用来读取设备固件版本。所有字节含义都是我自己定义的你换成自己的协议即可。3.1 文件骨架和 Kconfig 接入先建目录和最小文件cd drivers/usb/gadget/function/ vi f_vendor_cmd.c vi Kconfig # 追加 f_vendor_cmd 配置项 vi Makefile # 追加 f_vendor_cmd.oKconfig 片段config USB_GADGET_VENDOR_CMD tristate Vendor command function depends on USB_GADGET help Say Y to compile this gadget function as a module. This is a sample vendor command device function.Makefile 片段obj-$(CONFIG_USB_GADGET_VENDOR_CMD) f_vendor_cmd.o头文件可以直接复用include/linux/usb/composite.h里面定义了struct usb_function和一堆辅助宏。3.2 核心数据结构每个 function 实例对应一个上下文结构体用于保存端点、请求、状态struct f_vendor_cmd { struct usb_function func; struct usb_ep *in_ep; struct usb_ep *out_ep; struct usb_request *in_req; struct usb_request *out_req; u8 version_major; u8 version_minor; }; static inline struct f_vendor_cmd *func_to_vendor(struct usb_function *f) { return container_of(f, struct f_vendor_cmd, func); }这里有一个关键习惯所有通过函数创建的实例数据最终都从container_of拿回上下文结构体。这不是可选项而是必须的——composite 框架只传struct usb_function *给你你的一切状态都靠自己从这个指针反推。3.3 bind预订端点并注册描述符bind 是第一个被调用的回调职责很明确把描述符挪进框架用usb_ep_autoconfig挑好端点注册字符串描述符。static int vendor_cmd_bind(struct usb_configuration *c, struct usb_function *f) { struct f_vendor_cmd *vc func_to_vendor(f); struct usb_gadget *gadget c-cdev-gadget; int id, ret; id usb_interface_id(c, f); if (id 0) return id; vc-in_ep usb_ep_autoconfig(gadget, vc_in_desc); if (!vc-in_ep) goto fail_in; vc-out_ep usb_ep_autoconfig(gadget, vc_out_desc); if (!vc-out_ep) goto fail_out; vc-func.descriptors usb_descriptor_alloc(vc_fs_descs, vc_hs_descs, vc_ss_descs, 0); if (!vc-func.descriptors) goto fail_descs; usb_free_all_descriptors(f); ... }注意usb_descriptor_alloc这个 API在内核 4.x 后期版本里才标配化它一次性帮你把全/高/超速三份描述符都分配好后续usb_assign_descriptors绑定就是一条语句的事。老版本内核如果你碰到usb_function结构里没有descriptors成员说明该升级代码写法了老的usb_assign_descriptors接口在 5.15 左右还在兼容但新代码一律走新的流。bind 阶段没做完的事情有一个非常隐蔽的坑usb_ep_autoconfig虽然帮你挑选了端点但它只保证物理端点号不冲突并不保证你在描述符里声明的bEndpointAddress和它挑出来的一致。所以你在定义端点描述符时地址只能用通配方式等 bind 拿到真实端点地址后回头改描述符字段。3.4 定义描述符全速/高速/超速三份这个函数有三个速度梯度要分别定义。为了省篇幅我写全速的示例高速和超速结构完全相同就是bMaxPacketSize和bmAttributes可能不同static struct usb_interface_descriptor vc_fs_intf_desc { .bLength sizeof(vc_fs_intf_desc), .bDescriptorType USB_DT_INTERFACE, .bInterfaceNumber 0, // 实际由 bind 阶段 usb_interface_id 分配 .bNumEndpoints 2, .bInterfaceClass 0xFF, // vendor specific .bInterfaceSubClass 0x00, .bInterfaceProtocol 0x00, }; static struct usb_endpoint_descriptor vc_fs_in_desc { .bLength USB_DT_ENDPOINT_SIZE, .bDescriptorType USB_DT_ENDPOINT, .bmAttributes USB_ENDPOINT_XFER_BULK, .wMaxPacketSize cpu_to_le16(64), }; static struct usb_endpoint_descriptor vc_fs_out_desc { ... // 同上方向改为 USB_DIR_OUT };关键点在于bInterfaceClass你必须填0xFF。这是 USB 规范给厂商自定义类留的标识主机端不按任何标准类去解析你的上位机直接用 WinUSB / libusb 就能裸收发。如果写成别的类比如0x08Mass StorageWindows 会尝试加载 usbstor 驱动你的私有数据包会被当成 SCSI 命令解析一脸懵。然后是那三份描述符数组的组织方式。内核里每个描述符集都用struct usb_descriptor_header *数组来串最后一个元素必须是 NULLstatic struct usb_descriptor_header *vc_fs_descs[] { (struct usb_descriptor_header *)vc_fs_intf_desc, (struct usb_descriptor_header *)vc_fs_in_desc, (struct usb_descriptor_header *)vc_fs_out_desc, NULL, };绑定时还有个看起来多余但必须做的动作把自动分配到的端点和字符串 ID 回填到描述符里。因为描述符在 bind 之前是静态常量里面全是 0不回填的话主机解析出来的端点地址只能是 0设备必然枚举失败。3.5 setup处理 vendor 请求这个接口类设备要支持一个厂商请求读取固件版本请求号定义为VC_REQ_GET_VERSION0xA5。static int vendor_cmd_setup(struct usb_function *f, const struct usb_ctrlrequest *ctrl) { struct f_vendor_cmd *vc func_to_vendor(f); u16 w_index le16_to_cpu(ctrl-wIndex); void *response; int ret -EOPNOTSUPP; if ((ctrl-bRequestType USB_TYPE_MASK) ! USB_TYPE_VENDOR) return -EOPNOTSUPP; switch (ctrl-bRequest) { case VC_REQ_GET_VERSION: response vc-version; ret usb_ep_alloc_request(vc-in_ep, GFP_ATOMIC); ... vc-in_req-buf response; vc-in_req-length sizeof(vc-version); ret usb_ep_queue(vc-in_ep, vc-in_req, GFP_ATOMIC); break; } return ret; }控制请求的bRequestType前五位就是类型。USB_TYPE_VENDOR和USB_TYPE_CLASS是两种最容易写混的。主机发的 setup 包要匹配你的bRequestType如果不匹配这个 switch 永远进不来。还有个小知识setup 的usb_ep_queue要走控制端点对应的那个 ep0不是你的批量端点。你在这里其实不能直接往 in_ep 上 queue 数据严谨写法是用struct usb_gadget *gadget里面的 ep0。这个细节我在 demo 里简化了但你一定要记住——在校验主机请求时如果你的响应数据比较长还需要先返回USB_GADGET_DELAYED_STATUS等你的实际响应准备好再真正回复。我见过不少人第一次写 setup 时踩这个坑表现为 Windows 一直报“设备无法识别”。3.6 数据通道usb_ep_queue 与请求回填批量数据通道的收发逻辑是函数驱动里最容易写乱的环节。核心方法论就一句话你在 set_alt 时提交一个或多个 usb_request 到 OUT 端点硬件每次收到数据就完成一个请求并通过 completion 回调还给你你在回调里处理数据后再重新提交一个新的请求形成持续的接收循环。IN 方向类似需要发送数据时往队列里塞请求。static void vendor_cmd_complete(struct usb_ep *ep, struct usb_request *req) { struct f_vendor_cmd *vc req-context; int status req-status; switch (status) { case 0: // 正常完成处理 req-actual / req-buf handle_incoming_packet(vc, req); // 重新提交 OUT 请求继续接收 req-length VC_BUF_SIZE; usb_ep_queue(vc-out_ep, req, GFP_ATOMIC); break; case -ECONNRESET: case -ESHUTDOWN: // 端点被重置或断开不要再提交请求 break; default: break; } }completion 回调里的req-status是重中之重。平时你没看它是因为一切正常时它是 0。一旦设备被 reset、断开、或者主机切换了配置已经提交的请求会被内核主动 terminate此时回调会被调用而 status 会是-ECONNRESET或-ESHUTDOWN。如果你在回调里无条件重新提交请求就会形成死循环甚至 UAF。写驱动时一定要为这个分支单独处理别偷懒。对于 IN 方向发送数据时要小心短包问题。Bulk 传输里主机认为一次传输的结束标志是“收到一个长度小于最大包长的包”。如果你发送的数据长度恰好是端点最大包长的整数倍硬件不会自动补一个零长度包。所以发完一次恰好对齐的数据后你需要主动发一个长度为零的请求告诉主机“这批数据结束了”。这是所有批量传输最容易踩的坑也是 usbmon 抓包时最常看到的“数据发过去了但上位机收不全”的原因。3.7 用 configfs 把这些组织起来光有一个函数驱动还不够还需要让 configfs 能实例化它。完整做法是定义一组 configfs group 操作但那种样板代码比较长。内核里有个捷径用usb_function_register和一个宏来声明一个 function type。新版内核推荐这么写DECLARE_USB_FUNCTION_INIT(vendor_cmd, vendor_cmd_alloc_inst, vendor_cmd_alloc);然后你要实现vendor_cmd_alloc_inst返回struct usb_function_instance并在里面挂上struct configfs_attribute **列表。这样当你写mkdir /sys/kernel/config/usb_gadget/g1/functions/vendor_cmd.usb0内核才会看懂vendor_cmd这个类型名。这一步的本质是你告诉复合设备“我叫 vendor_cmd你可以用我创建任意多的实例”。每个实例有独立的struct f_vendor_cmd互不干扰这也是为什么要用container_of的原因——框架不会为你保存状态。4. 加载、枚举与实证调试流程代码写完后真正的战斗才刚刚开始。我给一个我自己反复用的调试流程按顺序执行能省掉至少一半的排查时间。4.1 模块加载与目录创建假定你的 UDC 是musb-hdrc.0平台是某开发板rootfs 里能访问 configfsmodprobe libcomposite modprobe f_vendor_cmd mkdir -p /sys/kernel/config/usb_gadget/g1 cd /sys/kernel/config/usb_gadget/g1 echo 0x1234 idVendor echo 0x5678 idProduct mkdir functions/vendor_cmd.usb0 mkdir configs/c.1 ln -s functions/vendor_cmd.usb0 configs/c.1/ echo musb-hdrc.0 UDC如果你在这一步发现mkdir functions/vendor_cmd.usb0报No such file or directory原因几乎可以肯定f_vendor_cmd模块没有成功加载或者configfs子系统没挂载。lspci 那种排查方式在内核模块里就是cat /sys/kernel/config/usb_gadget但我更推荐直接看 dmesgdmesg | tail -50bind 失败时usb_ep_autoconfig失败会返回 NULL你的 dmesg 里会有no free ep之类的报错。这时候多数是 UDC 驱动还占着端点比如某个调试串口把端点占了或者上电时默认的 gadget 没卸载干净。4.2 枚举成功的判断标准在 Linux 主机侧或者 Windows 设备管理器枚举成功的表现Linux 下dmesg出现new full-speed USB device、Manufacturer: xxx等行lsusb -v能看到你的 vendor ID、product ID 以及那两个 bulk 端点你写一个简单的 libusb 上位机libusb_claim_interface能成功libusb_bulk_transfer收发都返回 0。如果枚举都失败了先别怀疑函数驱动优先怀疑描述符。最常见的枚举失败原因就是我前面说的端点地址没回填、接口号占位符没替换、描述符数组顺序错乱。4.3 用 usbmon 抓包验证协议枚举通了不代表数据路径是对的。强烈建议在主机侧用 usbmon 全程抓包。用法非常朴素内核开启CONFIG_USB_MON然后modprobe usbmon cat /sys/kernel/debug/usb/usbmon/1u /tmp/urbfile.txt配合 wireshark 打开/tmp/urbfile.txt。你会看到 setup 包的 bRequest0xA5能看到 bulk OUT 的数据帧内容也能看到短包结束符。我调试自定义 gadget 时几乎每轮都开着 usbmon它可以明确告诉你“设备到底有没有把包发出去”和“主机到底收到了什么”这两个问题如果在逻辑里互相猜效率极低。有一次我的设备出现偶发性重启usbmon 抓到的现象是主机侧发出 reset 信号。顺着这个信号查下去发现是我在 set_alt 里把一个未初始化的usb_request提交上去了导致控制器 DMA 访问非法内存直接挂了整个 UDC。这种问题如果不用抓包单靠业务层现象根本定位不到。5. 写自定义函数驱动最容易翻车的几个细节最后聊几个我肉眼见过最多人写歪的细节每个都是真实项目里踩出来的教训。5.1 全速/高速/超速描述符各管各的很多新人只写了全速描述符觉得“我的设备是全速的高速没用”。实际上你的 UDC 控制器可能支持高速主机枚举时首先请求设备描述符里的bcdUSB。如果你的设备描述符写的是0x0200USB 2.0主机第一时间就会尝试用高速握手。这时如果你没有提供device_qualifier描述符或者高速接口描述符枚举就会卡在 get_descriptor 阶段表现为“设备描述符请求失败”。所以哪怕你的物理链路只能跑全速也把三套描述符都填上最多是高速描述符里的bMaxPacketSize跟全速一样但结构不能缺。5.2 disable 与 set_alt 的乱序问题USB 协议允许主机随时切配置。也就是说disable回调可能在你还在 queue 请求的时候被调用。正确的做法是在 disable 里先把所有未完成请求usb_ep_dequeue掉并且把这些请求从你的上下文链表中摘除等到 set_alt 再重新初始化。如果你偷懒disable 只是清零标志那 set_alt 再次提交请求时用的很可能是已经被终止的usb_request轻则请求永远不回调重则直接引发 page fault。我自己的习惯是在上下文结构体里用一个 bit 标志表示端点是否 activeset_alt 里置位disable 里清位。所有数据收发入口先查这个标志。这个习惯帮我扛过了不止一次因为上位机反复开关接口导致的崩溃。5.3 控制传输的响应必须走 ep0前面提过一次setup 里的响应必须通过gadget-ep0回应不能往批量端点 queue。这不是 API 层面的强制但协议层这样才正确控制传输是主机发起、端点 0 完成的独立事务。你如果想在 setup 回调里做“先收主机数据再回状态”这种双向交互必须用USB_GADGET_DELAYED_STATUS配合usb_composite_setup_continue()才能正确实现。我第一次写的时候把响应往批量端点上一扔抓包看到 ep0 的状态永远 STALL折腾了两小时才发现是 ep 用错了。5.4 内存分配标志GFP_ATOMIC 不是可选项函数驱动的回调尤其是 setup、completion、disable几乎全是在中断上下文或软中断上下文里被调用的。你在这些回调里做任何内存分配都必须用GFP_ATOMIC。用GFP_KERNEL的话一旦分配时发生内存回收内核会尝试睡眠而睡眠发生在中断上下文里直接触发调度器警告严重点直接 panic。这个坑的隐蔽之处在于它平时不触发内存充足时GFP_KERNEL也能用看起来没问题。一旦系统内存吃紧第一个崩溃的就是你这种驱动而且打出来的堆栈完全跟 USB 无关全是调度器相关的调用栈排查起来非常痛苦。6. 把这套骨架换成你自己的协议到这里你已经有了一个完整的最小函数驱动。替换成你的协议只需要改三个地方描述符部分改 bInterfaceClass 的 vendor 值改端点数量和方向分配setup 处理把你的 vendor 请求和对应的行为填进去数据帧解析替换handle_incoming_packet函数里的协议解析逻辑把数据从req-buf里按你自己的帧格式拆包。我实测下来第一次从零写一个这样的驱动顺利的话一个下午能跑通收发。真正耗时间的不是写代码而是那些看不见的细节——描述符没回填、请求状态没判断、短包没处理。这篇文章把这些细节全部摊开了你照着踩过去会快很多。这个 demo 我放在我自己的实验树上最近在做的变体是给它加一个functionfs适配层这样可以把协议解析从内核态挪到用户态开发迭代速度会快非常多。后面有空我再把 functionfs 和这个自定义函数结合的写法整理出来。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。