USB Gadget FunctionFS初始化流程详解:f_fs.c内核实现剖析
2026/9/16 2:01:44 网站建设 项目流程

做USB gadget开发或者搞过Android系统移植的,大概率都跟FunctionFS打过照面。最典型的场景是:内核里有一个usb gadget控制器,你想让用户态程序直接控制这个设备对电脑/手机呈现出来的USB功能,比如把某个用户态协议栈包装成一个串口、一个MTP设备,或者一个自定义vendor设备。这时候FunctionFS就是那条把用户态和内核USB协议栈缝在一起的线,而drivers/usb/gadget/function/f_fs.c就是这条线在内核侧的全部实现。

这个文件里最值得先吃透的,就是初始化流程。从模块加载、文件系统挂载、ep0节点创建,到用户态往ep0写描述符触发状态机迁移,再到gadget bind流程配合,这条链路牵涉VFS、USB gadget、configfs、用户态ABI好几个层面,很容易让新手看懵。这篇东西就围绕f_fs.c的初始化流程来拆,把每一步是在干什么、为什么这么干、出问题怎么排查,一次说清楚。

1. 先定位:f_fs.c 在整个USB gadget体系里到底承担什么

1.1 用一句话说清FunctionFS的设计价值

普通USB gadget开发,开发者要么在drivers/usb/gadget/function/里写一个内核态function(比如f_serial.c、f_mass_storage.c),要么用configfs把现成function组合成复合设备。但很多场景下,真正想实现的USB逻辑不是在“内核态”里做的,而是在用户态程序里做,比如用户态要实现一套私有协议交互,这逻辑写在用户态显然开发效率更高、调试更方便。FunctionFS就是为这个场景设计的:它在内核里提供一个文件系统接口,用户态程序通过mount -t functionfs拿到可以在用户态文件读写操作访问的端点文件,往这些文件里写数据,数据就会从USB总线上发出去;USB主机发过来的数据,你能从文件里读出来。相当于内核只负责把USB协议栈翻译成文件读写语义,业务逻辑全留给你在用户态自己玩。

这也是f_fs.c整个初始化的总目标:把“一个可mount的文件系统”、“一组USB端点的文件抽象”、“一个和gadget生命周期挂钩的function实例”这三样东西统一管理起来。

1.2 初始化流程不是一条线,而是三条线在并发

f_fs.c的初始化,我初看的时候最容易被绕晕的地方在于:它不是“一个函数从头执行到尾”的单一流程,而是三条互相耦合的初始化线

第一条是文件系统侧:模块加载时注册functionfs文件系统,mount时创建ffs_data对象,在VFS层准备好ep0节点。第二条是gadget侧:通过configfs或者传统的usb_add_functionf_fs实例绑定到UDC控制器上,执行ffs_func_bind,此时才算真正拿到端点、分配请求。第三条是用户态侧:用户程序打开ep0,按FunctionFS ABI的要求先写设备/配置描述符,再写字符串描述符,一步步把状态从“描述符未就绪”推到“设备可用”。

三条线之间有严格的等待关系,但又不是简单的同步。比如Gadget侧可以先bind、用户态后写描述符;也可以用户态先把描述符全部写完,gadget侧再绑上来,两种时序代码都接受。这就是为什么f_fs.c里大量使用状态标志、互斥锁和等待队列来协调三方进度。后面我拆解的时候,会反复提到struct ffs_data里的状态标志,它就是这三条线的“共享内存”。

1.3 先记住这几个关键结构和函数,后面不会迷路

  • struct ffs_data:整个FunctionFS实例的“总账本”,状态机、设备描述符指针、端点文件数组、gadget引用全在这里。
  • struct ffs_function:USB gadget function的封装,里面包含struct usb_function、指向ffs_data的指针、以及bind之后申请到的一组struct ffs_ep
  • ffs_mod_init:模块入口,注册文件系统和USB function。
  • ffs_sb_fill:挂载时填充超级块,初始化ffs_data并创建ep0节点。
  • ffs_ep0_write/ffs_ep0_read:用户态与内核态交互的核心入口,写描述符、读事件都在这里。
  • ffs_func_bind:gadget绑定阶段的初始化入口,把function实例和实际端点接起来。

后续所有内容,基本都是围绕这几个函数和结构展开的。

2. 模块加载阶段:内核在 ffs_mod_init 里悄悄做的三件事

2.1 注册文件系统:让 mount 有法可依

内核模块加载时,会走到module_init(ffs_mod_init)。这个函数本身不长,但它干的活一个比一个重要。第一件事就是register_filesystem(&ffs_fs_type),把名叫functionfs的文件系统注册进内核VFS层。

我们平时在板子上执行mount -t functionfs myfs /sys/kernel/config/usb_gadget/g1/functions/ffs.myfs/(Android常见用法),内核正是在mod_init阶段注册的ffs_fs_type才能识别这个文件系统类型。ffs_fs_type里的关键字段包括文件系统名、mount回调和umount回调等。mount回调在较新内核里指向ffs_fs_mount,这个函数会负责构造一个struct file_system_type级别的挂载,然后实际初始化工作交给ffs_sb_fill

这里有一个很容易忽略但很关键的细节:FunctionFS的mount方式与普通文件系统不同,它天然设计成需要以-t functionfs的方式挂载到一个由configfs创建的function目录上。为什么要这么设计?因为一个gadget设备可以有多个USB function,也就意味着可能有多个FunctionFS实例。这时每个实例需要有独立的ffs_data,而mount就是创建独立实例的自然入口。同一个functionfs类型可以被mount到不同目录,内核各自维护一份ffs_data

2.2 注册USB function:让上层配置框架能找到它

mod_init的第二件事,是usb_function_register(&ffs_function)

ffs_function在f_fs.c里是一个struct usb_function_type(不同版本内核里类型名可能略有差异),它的作用是描述“FunctionFS这一类USB function的能力”。注册进去之后,configfs或者传统的legacy gadget框架才知道内核里有这么一类function,才能在配置阶段为它分配实例、调用bind。

打个比方,如果你把USB gadget配置比作“组装一台电脑”,usb_function_register就相当于向市场登记了“PowerFS这个品牌的内存条可以用”,后面configfs像内存插槽一样构造function时,就能从这个品牌里选一个已经登记过的型号。如果没有这一步,后面无论configfs里怎么创建ffs.*节点,内核都找不到对应的function实现。

2.3 创建debugfs目录:给排错留一个观察窗口

mod_init最后还会创建debugfs相关目录,具体路径一般是/sys/kernel/debug/usb_ffs/,下面再按FunctionFS实例名分目录。这个目录在正常运行时内容很简单,但它存在的意义是让开发者不用开ftrace就能快速确认某个FunctionFS实例是否注册成功

我在实际调试时,经常先用ls /sys/kernel/debug/usb_ffs/看目录下有没有对应实例名,来判断module加载是否正常、configfs的function节点是否创建成功。在早期内核版本里,如果没开CONFIG_DEBUG_FS,这段逻辑会被编译器剔除,目录自然看不到,这不代表functionfs不可用,别把这个当故障误判了。

mon_init阶段还有不少锁和缓存初始化,但宏观上记住这三件事就够了:注册文件系统、注册USB function、创建debugfs。这三样分别服务于后面挂载、bind、调试三条线。

3. 挂载瞬间:ffs_sb_fill 与 ffs_data 的完整出生过程

3.1 从 mount 到 sb_fill 的调用链

在Linux里,mount -t functionfs ...最终会走到ffs_sb_fill。这个函数是初始化流程里第一个“大动作”,它要完成超级块、根目录、ep0节点、ffs_data初始化的全套准备工作。

标准的调用链是:

ffs_fs_mount -> mount_nodev(ffs_sb_fill) -> ffs_sb_fill(sb, desc, size)

ffs_sb_fill会先取出struct ffs_data *ffs,这个ffs要么是mount参数里带过来的已有实例(在configfs绑定场景下往往从function配置处传入),要么是刚通过ffs_data_new新建的。注意这里的“要么”,背后就是那两种挂载时序:一种是先mount再bind,另一种是configfs创建function后,function实例里已经持有了一个ffs_data,mount时直接复用。

这个函数的核心工作可以拆成三步:

  1. 初始化/补充ffs_data里的基础结构(互斥锁、自旋锁、等待队列、引用计数等)。
  2. 设置超级块的操作函数集以及根目录inode。
  3. 创建ep0节点,并把ep0的dentry存在ffs->ep0里,inode的私有数据指向ffs

3.2 ffs_data:这个初始化流程里的“总账本”

struct ffs_data是这个文件里最重要的结构,初始化流程里几乎所有状态都沉淀在这里。理解它的字段,基本就等于理解了初始化流程的一半。

几个关键字段的关系是这样的:

  • state:当前状态机位置,决定了用户态现在可以干什么、内核期待什么。
  • flags:一些辅助标志位,比如是否已经有人打开了ep0、是否有I/O在途等。
  • ep0/epfiles:ep0文件dentry和端点文件数组。ep0在mount时创建,而epfiles里的其它端点文件要等描述符就绪之后才会创建,这是ABI特意设计的。
  • ffs->descs/ffs->raw_descs/ffs->string_tab:解析后的设备/配置描述符、原始描述符数据、字符串表。用户态写入的原始二进制就存在这里,供bind时检索端点。
  • ffs->gadget/ffs->func:绑定的gadget和function实例,bind时填入。

实操时值得留意的是,ffs_data里几乎所有字段都是随着初始化流程推进逐步填起来的,没有任何一个函数能一口气把它全初始化完。我在代码里找初始化相关逻辑时,如果只看ffs_data_new会漏掉一大半:ffs_data_new只负责零初始化和一部分基础设施,真正的关键字段都在ffs_sb_fillffs_ep0_writeffs_func_bind这几个不同阶段填入。读代码时别指望“找到初始化函数就万岁”,要顺着状态机一条链追下去。

3.3 ep0 与 epfiles 的准备:用户态的入口从哪来

FunctionFS的用户态入口分两种文件:ep0ep

ep0在mount时由ffs_sb_fill创建,它是用户态和内核态交互的控制通道。用户程序通过open(ep0)取得文件描述符,然后write设备描述符、配置描述符、字符串描述符,read事件,对一个正常function来说,ep0就是控制信息的必经之路。对应的file_operationsffs_ep0_operations,其中write接口ffs_ep0_write是状态机的主要驱动力。

ep<x>这类端点文件,在mount阶段并不会创建。它们的创建被推迟到了描述符解析完成、状态进入ACTIVE之后,由ffs_epfiles_create批量创建。为什么这么晚?因为只有读到用户态写下来的端点描述符,内核才知道这个function到底配置了几个端点、每个端点是IN还是OUT、用了什么传输类型,才能决定创建几个文件、每个文件的属性是什么。所以你去翻代码会发现:ffs_epfiles_create的调用位置不在mount路径,而在状态机推进到“描述符就绪”的路径上。

对使用者来说,这就意味着:挂载成功不代表端点文件齐全。你要是脚本里写完mount立刻去lsep1,大概率是空的,必须等用户态程序完成描述符写入之后文件才会出现。很多初学FunctionFS的人在这个地方被绊倒过。

4. 状态机驱动:用户态写入才是真正让初始化“往下走”的引擎

4.1 状态定义的完整图景

f_fs.c里的初始化不是由内核单方面推进的,而是用户态每往ep0写一段数据,状态才向前跳一格。这些状态定义在include/uapi/linux/usb/functionfs.h中,它们不仅是内核里的枚举,也是用户态ABI的一部分。宏观上可以分成三组:

  • 描述符准备阶段FFS_READ_DESCRIPTORS(等待用户态写入设备/配置描述符)、FFS_READ_STRINGS(等待写入字符串描述符)。
  • 设备可用阶段FFS_ACTIVE,表示描述符和字符串都解析完成,设备可以被bind和枚举。
  • 生命周期收尾阶段FFS_BOUNDFFS_CLOSINGFFS_DEACTIVATED等,处理设备解绑、ep0关闭、错误中断等情况。

对于初始化流程来说,最核心的路径就是FFS_READ_DESCRIPTORS -> FFS_READ_STRINGS -> FFS_ACTIVE。这一条链全部由ffs_ep0_write驱动。

4.2 第一步:写 descriptors,触发设备描述符解析

用户态程序打开ep0后,首先要做的不是读写业务数据,而是按照FunctionFS ABI把一个包含魔数的数据块和一个完整的“设备描述符+配置描述符集合”写进去。

这里有个容易忽略的点:FunctionFS设计了一套魔术字协议,用来区分用户态写的是描述符段还是字符串段。每次write的前4字节是魔数,内核会根据魔数确定当前数据段的类型,再调用不同的解析函数。这个细节完美规避了接口歧义:同样是往ep0写数据,内核不用靠状态猜你写的是什么,魔数本身就带着语义。

描述符解析阶段的内核处理,大致流程是:

  1. 用户态write(ep0, desc, len),进入ffs_ep0_write
  2. 解析魔数,确定这是描述符数据,调用__ffs_data_got_descs
  3. 逐个校验描述符:设备描述符里类型、长度对不对;配置描述符集合里的配置头、接口描述符、端点描述符是否齐全。
  4. 解析完成后,把原始描述符数据暂存到ffs->raw_descs,解析出端点信息供后续创建ep文件时使用。
  5. 状态切换到FFS_READ_STRINGS,等待下一轮写入。

如果中途任何一个描述符校验失败,内核会返回错误,状态机原地不动。这个阶段我踩过最多的坑是设备描述符里的bMaxPacketSize0写得和控制器能力不匹配,或者配置描述符的wTotalLength统计少了端点描述符长度,都会导致校验不过。

4.3 第二步:写 strings,完成状态推进到 ACTIVE

描述符写完后,用户态还需要把字符串描述符写进去,即使不需要字符串,也必须写一个符合ABI的最小数据段,把状态从FFS_READ_STRINGS推过去。这一步对应的内核函数是__ffs_data_got_strings,它会解析字符串索引、语言ID和UTF-16编码的字符串内容,存到ffs->string_tab里。

字符串解析完成后,状态迁移到FFS_ACTIVE,紧接着内核会做几件收尾工作:

  • 调用ffs_epfiles_create,根据之前解析出的端点描述符批量创建ep1ep2等设备文件。
  • 调用ffs_data_opened/ready相关的回调,唤醒那些在等待设备就绪的用户态程序。
  • ffs_data标记为“可以对外提供端点服务”。

到这一步,用户态视角的初始化基本完成:你可以去ls看到端点文件,可以open它们收发数据。但注意,这并不代表USB主机侧已经枚举成功,枚举还依赖gadget侧的bind和UDC的状态,也就是下一章要讲的内容。

4.4 初始化完成后,状态机还会继续变吗

初始化完成后,状态机并不就此静止。gadget bind之后,状态可能进入FFS_BOUND;主机和设备断开时可能进入FFS_DEACTIVATED;用户态关闭ep0时进入FFS_CLOSING。理解初始化流程的价值就在于,后面这些“非初始化”状态的处理逻辑,全部依赖初始化阶段建立好的数据结构和引用关系。

比如FFS_BOUND状态,表示ffs_data已经和具体的USB function绑定在一起。这时候如果用户态想重新写描述符,内核会严格拒绝,因为设备已经运行起来了。反过来,如果主机枚举所需的数据还没准备好,bind可能成功而枚举却不成功,握手会卡在FFS_ACTIVEFFS_BOUND的转换边沿。这个状态机时序,是排查“f_fs设备插上没反应”这类问题的核心地图。

5. gadget 侧的 bind:初始化流程的另一半拼图

5.1 bind 的触发时机与前提

前面说的都是用户态驱动的那条初始化线。真正让FunctionFS“接入USB总线”的,是ffs_func_bind。它由gadget框架在device配置被激活时调用,不管你是用configfs配好gadget后执行UDC绑定,还是legacy USB gadget直接在驱动里usb_add_function,最终都会走到这里的bind回调。

bind的必要前提是,configfs里已经有一个ffs.<name>类型的function节点,并且mount的时候把对应的ffs_data关联到了这个function实例上。我在项目里看到的做法通常是:

  1. 在configfs创建gadget目录、配置目录。
  2. functions/下创建ffs.myfunc,得到function目录。
  3. mount -t functionfs myfunc /sys/kernel/config/usb_gadget/g1/functions/ffs.myfunc/
  4. 用户态程序打开这个目录下的ep0,开始初始化交互。
  5. 把所有function配置好之后,把gadget关联到UDC控制器,触发bind。

第3步mount的作用,本质上就是把ffs_data挂到第2步创建的function实例上,让后续bind时能通过function实例找到ffs_data。顺序可以变,但这个关联关系必须建立。

5.2 bind 内部做了哪些初始化动作

ffs_func_bind内部做的事情,比名字看起来要多得多,主要集中在“把逻辑端点描述符映射到真实端点”上。

流程大致是:

  1. 通过function实例拿到ffs_data,确认引用关系和状态合法性。
  2. 遍历之前解析好的描述符里的端点信息,对每一个逻辑端点调用usb_ep_autoconfig之类的接口,从UDC控制器上申请一个物理端点。
  3. 为每个申请到的端点分配struct ffs_ep,初始化对应的请求缓冲区。
  4. 分配并初始化用于USB控制传输的ep0请求(setup请求),这个请求用于处理主机的控制命令,比如GET_DESCRIPTOR
  5. 把整理好的描述符集合、端点数组挂在struct usb_function的对应字段上,方便后面的set_altdisable等回调使用。

如果你的FunctionFS设备里有多个接口(比如一个CDC ECM再加一个自定义Bulk接口),bind阶段会反复遍历配置描述符,确保每个接口里的每个端点都被正确映射。这个阶段最常见的错误是端点数量超过UDC控制器的实际能力,导致usb_ep_autoconfig失败返回NULL,整个bind直接失败。

5.3 bind 与用户态状态机的先后关系

我前面反复强调,这两条初始化线是解耦的。bind时ffs_data的状态可能是最初始的FFS_READ_DESCRIPTORS,也可能是已经就绪的FFS_ACTIVE。两种情况内核都能处理:

  • 如果bind时描述符还没写好,ffs_func_bind会先完成所有物理端点映射,但由于描述符还没解析完整,一些依赖最终状态的操作会延迟到用户态写完之后执行,驱动会通过等待队列和状态回调通知彼此。
  • 如果bind时描述符已经就绪,ffs_func_bind会直接读取已经解析好的端点信息,一步到位完成绑定。

这个设计对实际部署很有意义:开机阶段可以先把gadget全部配置好,用户态程序晚点起来也不影响,只要最终所有description都写好,设备就能正常枚举。反过来,用户态程序先跑起来写了描述符,gadget再bind,同样成立。理解了这种双向时序,调试时起码不会一看到“先有鸡还是先有蛋”的顺序差异就慌。

6. 初始化相关常见失败场景与排查实录

6.1 ep0 打开失败或者 read 不到数据

现象:用户态程序openep0失败,或者成功打开后read一直返回空/阻塞。先说open失败,第一步不是看FunctionFS的代码,而是确认挂载目录环境对不对。很多新手把mount的源目录和open目录搞混,或者在gadget的function目录没有正确关联ffs_data的情况下强行open,自然拿不到ep0。

read不到数据则要看设备当前状态。ep0的read事件依赖设备和主机之间有控制传输交互,如果gadget还没bind到UDC,或者说UDC没有被真正关联,主机侧根本看不到这个设备,自然也就不会有任何控制请求过来。这种时候先查/sys/kernel/config/usb_gadget/g1/UDC是否有值。

6.2 descriptor 校验一直过不去

写描述符阶段返回-EINVAL,十有八九是f_fs.c里内置的校验没过。这类问题排查,我建议先写一个最小可用的描述符集合,跑通后再加功能。最小集合里设备描述符和配置描述符都要字段完整,wTotalLength必须和实际长度一致。我在实际项目里吃过一次亏:配置描述符集合包含一个接口关联描述符(IAD),但我在构造时只算了接口描述符的部分,没把IAD的长度算进去,结果FunctionFS解析后多检查了好几层才发现长度不匹配。

另一个常见问题是字符串段写法不对。FunctionFS对字符串段的数据格式很严格,语言ID、字符串数量、字符串块长度都要按ABI来。建议直接参考Documentation/usb/functionfs.txt里附带的用户态示例去对照写,别自己拍脑袋造格式。

6.3 状态机卡在 READ_DESCRIPTORS 不动

如果你在代码里调试或者打印日志,发现状态一直停在FFS_READ_DESCRIPTORS,大概率是ep0的write调用压根没走到“完整写一段描述符”的路径。常见原因有两个:一是用户态程序只写了部分描述符就停了;二是写入的数据缺少必要的魔数头,被内核当成非法数据拒绝了,而用户态又没检查write返回值,错误被吞掉。

这种问题,最直接的定位方法是在ffs_ep0_write入口和__ffs_data_got_descs入口加打印,对比一下write是否到达、解析是否成功。不要一上来就猜是状态机逻辑bug,绝大多数情况是用户态协议栈实现不完整。

6.4 排查初始化问题我惯用的三板斧

第一板斧是看dmesg。f_fs.c里很多关键路径都有pr_debugdev_dbg级别的日志,如果内核开启了对应调试宏,能非常直观地看到mount、bind、状态切换的时序。我在调试时一般会开dyndbg,给f_fs.c单独开调试:

echo 'file drivers/usb/gadget/function/f_fs.c +p' > /sys/kernel/debug/dynamic_debug/control

第二板斧是用tracepoint看状态机。如果用的内核版本比较新,USB gadget层已经有tracepoint,重点关注usb_gadgetconfigfs和function相关的trace点,能还原整套初始化时序图。没有tracepoint也可以用ftrace的function_graph,直接把ffs_ep0_writeffs_sb_fillffs_func_bind这几个函数挂上,观察调用栈。

第三板斧是写一个最小用户态复现程序。不要一上来就跑完整业务,只做mount、open ep0、写一份最小描述符、写字符串,然后看内核日志和文件节点变化。这样能把“FunctionFS初始化问题”和“业务逻辑问题”快速切分。我做USB自定义function时,这种最小复现程序几乎就是调试期的标配,成本很低但效率极高。

最后再分享一个习惯:调试FunctionFS初始化时,尽量把用户态程序的write返回值、errno、以及每次write的字节数都打印出来。因为FunctionFS对用户态写入的时机和数据完整性非常敏感,很多初始化失败不是内核bug,而是用户态某个write少了4字节,或者write被信号打断后没有重新提交完整数据。这些细节一旦在日志里暴露出来,定位基本就是分钟级的事。

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

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

立即咨询