ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android不依赖libaums自研OTG U盘读取:USB协议全链路实现

Android不依赖libaums自研OTG U盘读取:USB协议全链路实现 这阵子接了一个Android外设相关的活儿需求很朴素把插在OTG口上的U盘读出来拿到里面的文件列表和内容。常规做法是直接引libaums这个开源库三五行代码就能把U盘识别、挂载、读文件全搞定。但这次项目有个特殊要求不能用libaums而且底层的数据读取链路必须能一行一行讲清楚方便后续定制命令和做兼容性排查。我索性就把Android官方USB Host API、USB Mass Storage协议、BOT传输、SCSI命令、FAT32/exFAT文件系统解析整个链路自己走了一遍最终做到了不依赖libaums在Android上通过USB协议把U盘完整读出来。这篇文章就记录这条完整的自研链路包括设备探测、权限申请、接口识别、批量端点通信、CBW/CSW封装、SCSI读写、块缓冲、文件系统解析以及真实调试中踩过的坑。适合有Android开发基础、想深入了解USB协议的工程师也适合做嵌入式或硬件开发的同事参考移植思路。1. 项目目标与整体设计思路1.1 先把问题拆明白很多人以为“Android读U盘”就是把文件路径拿到用File类去遍历。实际上Android系统层面已经帮我们做了很多U盘插入OTG口系统通过vold守护进程自动挂载把它变成一个文件系统目录应用再用Storage Access FrameworkSAF去访问。但这次的需求不是走系统挂载而是要求在USB协议层面自己去读取相当于绕过了系统的文件系统抽象直接跟U盘这个USB设备对话。把问题拆开其实是四层通过USB Host API发现设备、申请USB权限、拿到UsbDevice对象。在UsbDevice里找到大容量存储接口获取一对批量传输端点。基于批量传输端点实现Bulk-Only TransportBOT协议通过SCSI命令读写U盘的裸扇区。把读到的块数据解析成FAT32/exFAT文件系统呈现出目录和文件内容。这四层每一层都是独立的可以单独调试、单独写测试用例。整个项目的核心难点在第三层BOT协议和SCSI命令是很多Android开发者没接触过的东西也是libaums这类库隐藏得最深的部分。1.2 技术选型背后的考量既然要“不靠libaums”自然要想清楚替代方案。我当时列了三条路直接用Android官方USB Host API配合Java层的协议实现。这是最稳的路Android 3.1之后系统自带UsbManager、UsbDeviceConnection这些类不需要root不依赖JNI跨机型兼容性最好。用libusb的Android移植版本通过JNI调C层库。能力强能操作任意USB端点但要处理NDK交叉编译和JNI内存管理工作量明显大。在内核层挂一个自定义USB驱动。这条路在普通商品手机上根本走不通没有root权限改不了内核模块。对比下来官方USB Host API是最合适的。它虽然屏蔽了底层内核细节但保留了完整设备描述符、接口、端点的访问能力足以让我们自己实现Mass Storage协议。另一个关键点USB Host API的bulkTransfer方法是同步阻塞的数据缓冲、超时控制都暴露在外面这正好方便我们按自己的节奏控制读取流程。选这条路的另一个好处是项目代码可以完全跑在Java层做单元测试和日志打印都非常方便。1.3 整体架构与模块划分具体实现时我按层级拆了四个模块模块职责对应代码UsbManagerWrapper设备发现、权限申请、热插拔监听UsbDiscovery.ktBulkTransfer封装底层bulkTransfer处理分包和超时BulkTransfer.ktBotProtocol构造CBW、解析CSW、管理命令生命周期BotClient.ktScsiCommand生成SCSI CDB命令、解析响应ScsiCommands.ktBlockDevice扇区读写的逻辑封装BlockDevice.ktFileSystemFAT32/exFAT文件系统解析Fat32Parser.kt这种分层的好处是每层只依赖下一层比如ScsiCommand不必关心CBW怎么构造BotProtocol也不必关心文件系统结构。调试的时候可以从底层开始逐层验证先验证能收发数据再验证SCSI能读到块大小最后再上文件系统解析。2. 设备探测与连接在Android上找到并打开U盘2.1 USB Host API的基本姿势Android的USB Host模式核心在UsbManager这个类。第一步要做的是在Manifest里声明设备特性uses-feature android:nameandroid.hardware.usb.host /获取设备列表很简单UsbManager manager (UsbManager) getSystemService(Context.USB_SERVICE); HashMapString, UsbDevice deviceList manager.getDeviceList(); for (UsbDevice device : deviceList.values()) { // 处理device }关键点getDeviceList返回的HashMap里key是设备名value才是真正的UsbDevice对象。遍历时别被HashMap的无序性坑了不要依赖顺序去识别是哪个U盘。2.2 识别大容量存储设备的关键判断U盘插入之后系统会把整个USB设备枚举成一个UsbDevice对象但这个对象可能对应多种类型的设备。比如一个U盘可能是纯Mass Storage也可能里面同时包含一个读卡器芯片、一个蓝牙模块不同的接口对应不同功能。要识别是不是大容量存储设备不能只判断UsbDevice的类因为很多U盘的设备类填的是0x00真正的类型信息在接口描述符里。正确做法是遍历所有接口UsbInterface findMassStorageInterface(UsbDevice device) { for (int i 0; i device.getInterfaceCount(); i) { UsbInterface intf device.getInterface(i); if (intf.getInterfaceClass() UsbConstants.USB_CLASS_MASS_STORAGE intf.getInterfaceProtocol() 0x50) { return intf; } } return null; }USB_CLASS_MASS_STORAGE的常量值实际是0x08表示这个接口是存储设备protocol 0x50表示走Bulk-Only Transport协议。子类通常不用太较真大部分U盘是0x06SCSI transparent command set个别软驱类老设备是0x04但协议判断到0x50基本就能把绝大多数U盘框进来。这段判断代码是整个项目的敲门砖很多奇怪的“识别不到U盘”问题就出在这里。比如某些U盘厂家会在接口子类上填不标准的值如果只判断子类就会翻车所以我最终只判接口类和协议宁可多匹配进来几个设备也要保证不漏判。2.3 权限申请与热插拔监听Android对USB设备有一个单独的运行时权限体系即使Manifest里声明了USB_HOST应用在真正访问设备前也必须获得用户授权。常规做法是注册一个广播接收器private static final String ACTION_USB_PERMISSION com.example.USB_PERMISSION; PendingIntent pendingIntent PendingIntent.getBroadcast( this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); manager.requestPermission(device, pendingIntent);然后注册广播接收器处理授权结果。这里有一个很隐蔽的坑PendingIntent必须设置FLAG_IMMUTABLE从Android 12开始这是强制要求否则在部分机型上会抛异常。另外广播接收器的IntentFilter里要同时监听USB设备插拔事件receiver android:name.UsbReceiver intent-filter action android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED / action android:nameandroid.hardware.usb.action.USB_DEVICE_DETACHED / /intent-filter /receiver插拔是最容易忽略的环节。实际使用中用户不会永远插着一个U盘插入时要能自动检测到拔出时要能立刻释放资源否则下一次插入可能因为上一次的UsbDeviceConnection没关闭而打不开设备。我的经验是拔出广播里除了关连接还要主动把内存里的BufferReference清空避免内存泄漏。2.4 拿到真正干活的端点识别出Mass Storage接口后下一步就是从这个接口里取批量传输端点。USB的每个接口Interface下面有一个或多个端点Endpoint端点分控制、批量、中断、等时四种类型U盘读写用的是批量传输端点包最大尺寸一般是512字节。UsbEndpoint bulkIn null, bulkOut null; for (int i 0; i intf.getEndpointCount(); i) { UsbEndpoint ep intf.getEndpoint(i); if (ep.getType() UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() UsbConstants.USB_DIR_IN) { bulkIn ep; } else { bulkOut ep; } } } if (bulkIn null || bulkOut null) { throw new IllegalStateException(U盘接口缺少批量端点); }拿到端点后用UsbManager的openDevice方法打开设备然后claimInterface声明对这个接口的独占访问权UsbDeviceConnection connection manager.openDevice(device); boolean claimed connection.claimInterface(intf, true); if (!claimed) { // 可能被系统或者其他应用占用 }claimInterface返回false是个很常见的问题。出现这个情况大概率是系统已经把这个USB设备当作存储设备挂载并独占了接口或者有其他应用先打开了同一个设备。处理办法是先等几百毫秒重试一次如果还不行提示用户拔掉U盘重新插入或者到系统设置里关闭“USB存储”的自动挂载。Android系统的行为差异很大媒体存储服务偶尔会抢在应用之前打开U盘这个只能靠重试机制兜底。3. 核心协议层BOT传输与SCSI命令3.1 用“下单、收货、回执”理解BOT协议USB Mass Storage的Bulk-Only Transport协议是整个读取动作的中枢。它的工作逻辑可以类比成寄快递发件人填一张快递单CBW快递员把它塞进包裹快递发出后收件人签收CSW回传一张回执单。整个流程是主机先发送一条31字节的CBW命令包接着按命令要求传输数据可能无数据、设备到主机、主机到设备最后设备回传一条13字节的CSW状态包。BOT协议的硬性要求是主机发送CBW之后必须完成数据阶段再接收CSW。不能跳过CSW直接发下一条CBW否则设备端的命令状态机会错乱回传的数据会是垃圾。3.2 CBW与CSW的字节级拆解CBW固定31字节结构如下字段偏移长度说明dCBWSignature04固定字符串“USBC”十六进制0x43425355dCBWTag44命令标记主机生成CSW会原样返回dCBWDataTransferLength84要传输的数据字节数本次数据传输的长度bmCBWFlags1210x80表示设备到主机读0x00表示主机到设备写bCBWLUN131逻辑单元号通常填0bCBWCBLength141CDB的有效字节数最大16CBWCB1516真正的SCSI命令块CSW固定13字节字段偏移长度说明dCSWSignature04固定字符串“USBS”十六进制0x53425355dCSWTag44原样返回CBW里的tag用于匹配dCSWDataResidue84未传输完成的数据字节数0表示恰好传完bCSWStatus1210成功1失败2相位错误实现的时候有个细节要注意读CSW不能用bulkTransfer直接读13字节有些U盘要求读取长度必须是端点最大包长的整数倍。稳妥做法是申请一个64字节的缓冲区读满后再截取前13字节。我最初直接传13字节结果某块老U盘一直返回超时换成分包读取后立刻正常。构造CBW的代码byte[] buildCbw(int tag, int transferLength, int direction, byte[] cdb) { byte[] cbw new byte[31]; putUint32(cbw, 0, 0x43425355); // USBC putUint32(cbw, 4, tag); // tag putUint32(cbw, 8, transferLength); // 数据长度 cbw[12] (byte) (direction DIR_IN ? 0x80 : 0x00); cbw[13] 0; // LUN cbw[14] (byte) cdb.length; // CDB长度 System.arraycopy(cdb, 0, cbw, 15, cdb.length); return cbw; }3.3 封装一个批量传输层Android的UsbDeviceConnection提供了bulkTransfer方法用来同步收发数据调用方式很简单int transferred connection.bulkTransfer(endpoint, buffer, length, timeout);返回负数表示传输失败返回0表示超时或没有数据。实际项目中我封装了一个BulkTransfer类主要处理三类问题单次bulkTransfer不一定能传完所有数据尤其是大批量读取时需要用循环直到读完或用完超时时间。接收方向的传输要区分“确实没有数据”和“传输失败”两者差一个超时时间的判断。所有USB操作必须放在子线程Android禁止在主线程执行socket和USB读写。超时时间也有讲究。SCSI命令刚发起时U盘可能需要较长时间准备数据尤其是刚通电启动的U盘首次INQUIRY命令经常超过200ms。我用的是分段超时策略发送命令阶段用1000ms数据接收阶段用3000ms读目录等操作放宽到5000ms。太短容易误判超时太长遇到坏盘会卡死整个流程。3.4 SCSI命令和U盘对话的语言BOT协议传输的CBWCB字段里放的是标准SCSI命令块CDB。U盘把自己模拟成一块SCSI磁盘我们必须用SCSI命令去问它“你是谁”“容量多大”“某个扇区是什么”。最基础的四条命令INQUIRYopcode 0x12查询设备基本信息返回97字节左右的设备信息包括厂商名、产品名、设备类型等。返回数据第0字节的低5位表示设备类型0x00表示直接访问块设备读到这个值说明它确实是一块磁盘。TEST UNIT READYopcode 0x00没有任何数据阶段直接看CSW的状态字段0表示设备就绪。这个命令通常要轮询几次U盘刚上电时可能返回失败需要等待。READ CAPACITY(10)opcode 0x25返回8字节数据前4字节是最后一个逻辑块的地址LBA后4字节是每块大小字节。由此得知U盘的总容量和块大小。READ(10)opcode 0x28读取指定LBA地址的若干个块。这是读取文件数据的核心命令。构造READ(10)的CDBbyte[] cdb new byte[10]; cdb[0] 0x28; // READ(10) cdb[2] (byte) (lba 24); cdb[3] (byte) (lba 16); cdb[4] (byte) (lba 8); cdb[5] (byte) lba; cdb[7] (byte) (blockCount 8); cdb[8] (byte) blockCount;这里有个容易搞混的地方CDB里的LBA和块数都是大端序big-endian也就是高位在前。我用代码时吃过亏把低字节塞到了高位移位位置导致读出来的扇区全是错乱的。建议写一个通用的大端字节序写入函数后续所有SCSI命令都用它。3.5 超时、复位与错误恢复SCSI命令偶尔会失败比如U盘读到的扇区有问题、设备短暂无响应。这时候不能盲目重试要先发REQUEST SENSE命令opcode 0x03去取错误码。它返回18字节的Sense数据里面包含错误键和附加信息能判断是介质错误还是设备内部错误。我实现了一套容错策略TEST UNIT READY失败时最多轮询5次每次间隔200ms仍失败就发REQUEST SENSE取详细原因。READ/WRITE命令返回CSW状态为1时先记录现场信息再发REQUEST SENSE错误码直接打到日志里方便排查。设备反复超时时发一个USB设备复位命令重新初始化通道。Android里可以通过connection.controlTransfer往设备发送标准USB重置指令方法参考USB 2.0规范的第9章。这套策略让我在一次测试中成功救回了一块有坏扇区的U盘没有它整个读取流程会在半路崩溃。4. 块读写与缓冲策略4.1 扇区寻址与块对齐U盘内部通过LBA逻辑块地址访问数据每一块大小通常是512字节或4096字节从READ CAPACITY命令里能读出来。文件系统解析时所有偏移都要换算成LBA比如引导扇区在0号块FAT表在某个绝对扇区号。块读写接口设计得很简单public byte[] readSectors(long startLba, int count) { byte[] buffer new byte[(int) sectorSize * count]; // 构造READ(10) CDB发送CBW接收数据读取CSW return buffer; }这里最容易被忽视的是对齐问题。绝大多数U盘支持任意LBA读取但有些嵌入式U盘芯片要求读取长度必须是扇区大小的整数倍且起始地址必须对齐到扇区边界。如果不小心构造了一个非对齐的读取请求轻则返回垃圾数据重则设备报错。写代码时强制做一次对齐检查if (startLba % sectorSize ! 0) { throw new IllegalArgumentException(LBA未对齐); }4.2 大块读取与性能调优一开始我逐扇区读取也就是一次读512字节读取一个10MB的文件要发两万次USB请求慢得离谱。后来改成一次读取32个扇区16KB速度提升很明显实测从几百KB/s涨到15MB/s左右。性能优化要考虑两个限制Android的bulkTransfer单次长度被限制在65535字节以内这是由USB请求长度的底层限制决定的即使是USB 3.0设备也绕不开。U盘本身存在最小读取粒度超过这个粒度后再加大请求长度速度并不会继续提升反而因为单次传输超时风险上升而变慢。我最终的策略是读取文件数据时按32KB为单位目录读取时按8KB为单位因为目录扇区往往比较离散一次读太大反而浪费。这样在绝大多数U盘上都能达到稳定速度同时规避了超时风险。5. 文件系统解析从扇区到文件名5.1 FAT32的关键结构裸扇区读出来之后U盘上存储的数据是连续的块但用户看到的是文件和目录这层映射关系就是文件系统。现在常见的U盘是FAT32和exFAT两种老的FAT16基本见不着了NTFS则因为兼容性问题很少用在移动U盘上。FAT32的引导扇区DBR位于0号扇区里面记录了文件系统的关键参数。解析时最需要关注的字段偏移长度字段说明0x0B2每扇区字节数通常5120x0D1每簇扇区数簇是文件系统分配的最小单位0x0E2保留扇区数引导扇区后面的保留区长度0x101FAT表数量通常2有备份FAT0x244FAT表长度每个FAT表占多少扇区0x2C4根目录起始簇号FAT32的根目录不是一个固定区域簇是FAT32最重要的概念它是文件系统分配存储空间的最小单位。一个簇可以是1到128个扇区具体看U盘容量。FAT表本质上是一个数组数组下标对应簇号数组里存的是下一个簇的簇号。文件内容就是按照这个“链表”依次读取每个簇的数据。5.2 目录项与长文件名FAT32的目录是一组32字节的记录存放在目录簇里。每个32字节记录要么是普通文件项要么是长文件名项要么是卷标项短文件名记录偏移0到7是文件名主体8到10是扩展名11是属性字节属性0x10表示子目录0x20表示普通文件0x0F表示长文件名项。长文件名项一个文件会在真正的短文件名记录之前连续排列若干个属性为0x0F的记录每条记录保存13个UTF-16字符。目录结束标志如果记录的第一个字节是0x00说明这个目录的内容到这里就结束了。解析长文件名时要逆序读取从最后一个长文件名项往前拼。这个顺序问题我一开始搞反了导致所有中文文件名都变成了乱码。调试后发现长文件名的序号是递减的序号0x40表示最后一个长文件名项实际字符是倒序存放的。5.3 读取文件内容的完整流程整个文件读取流程是一条完整的链路从引导扇区解析出每簇扇区数、FAT表位置、根目录簇号。从根目录簇开始依次读取根目录下所有目录项构建文件和目录的树形结构。对于目标文件读取它的起始簇号和文件大小。用FAT表依次找出所有后续簇号按簇读取数据拼成完整文件内容。FAT表的读取也有小技巧FAT表可能很大不需要一次性全读进内存按需读取对应簇号的FAT项所在扇区即可。比如要查簇号0x1000用FAT表起始扇区加上0x1000 * 4 / 每扇区字节数就能定位到应该读哪个扇区读到后偏移取余数就能拿到4字节的FAT项。代码里最麻烦的是处理FAT表的跨扇区情况一个FAT项可能刚好跨在两个扇区边界上需要自己拆两半拼一下。这种边界问题写了专门的单元测试用例覆盖实际测试中确实有几块U盘的FAT表布局比较特殊触发了这个边界分支。5.4 exFAT的适配方向现在市面上一半以上新U盘出厂就是exFAT格式因为它支持超大文件和超多文件数。exFAT的文件系统结构跟FAT32完全不一样主要区别没有FAT表改用簇链直接记录文件数据所在的连续扇区区间Extent。BPB结构不一样关键参数位置全变了。目录项使用哈希索引做快速查找。如果项目只需要小众场景的自用读取可以先用FAT32做主力支持遇到exFAT分区时走另一个分支。exFAT解析的复杂度比FAT32略低一点因为它不需要遍历FAT表直接从File Entry里的Extent信息找到文件数据的起始偏移和长度复制出来即可。真正烦人的是exFAT对校验和的严格要求写数据时如果校验和算错整个目录项都会被系统判为无效。6. 实测现场与排坑实录6.1 端到端验证流程实现完成后我准备了一批测试介质和场景测试项介质预期结果FAT32读列表2GB TF卡转USB能列出目录中文文件名正常FAT32读大文件8GB U盘写入1GB文件完整拷贝哈希一致exFAT读大文件64GB USB 3.0 U盘能读取超过4GB的大文件热插拔多个U盘轮流插拔拔出不影响下一次插入山寨U盘扩容盘标称32GB实际4GB能识别容量异常并提示实测流程基本一次性跑通但还是有几个问题翻车。最典型的问题是32GB扩容U盘READ CAPACITY命令返回了32GB的容量但真实数据实际只有4GB导致读文件时到4GB之后SCSI命令返回错误。这个不是协议层问题而是U盘本身虚标但工程上要能做出来并且优雅处理。6.2 常见问题速查表整理了一张调研和排错过程中最值得收藏的问题表问题现象可能原因解决方案应用收不到设备权限弹窗设备没有声明USB_HOST特性或广播接收器未注册检查Manifest和Activity生命周期claimInterface返回false系统媒体服务抢先占用了设备做重试或提示用户关闭自动挂载USB读取总是超时单次读取长度过大或设备处于坏状态分块读取加设备复位逻辑读取目录文件名为乱码长文件名项顺序处理反了按序号逆序合并字符串FAT32分区解析不出来扇区大小不是512字节从BPB读取真实扇区大小不能用常量文件内容前后颠倒LBA或块数大端序写错检查字节序函数打印调试日志6.3 USB抓包调试思路代码写到一半时遇到一个极难排查的兼容性问题某品牌U盘第一次插能正常读第二次插就死锁。常规日志根本看不出问题后来想到用USB协议分析工具抓包直接对比成功和失败时的USB包序列才发现是前一次USB连接没有完全关闭导致第二次打开时设备状态机还停留在上一次的命令。后来我养成了一个习惯调试USB协议问题时先用USB协议分析器抓取CBW/CSW包的完整序列确认收发方向和数据长度是否匹配。很多U盘的“玄学问题”在抓包后都变成了具体的字段错误比如长度字段填错、CSW没有读完、tag不匹配等。这种调试思路比盯着日志猜要高效得多。6.4 不依赖libaums的收益与边界完成这套自研实现后最大的感受是“原理清楚了很多”。libaums把底层的USBC通信固定成了一套逻辑遇到特殊设备时黑盒状态下根本不知道去哪看。现在自己实现了每一层遇到任何异常都能从字节级别定位问题。自研方案的优缺点也要理性看待优点零第三方依赖底层逻辑完全可控支持深度定制比如自定义SCSI命令、批量传输策略、文件系统扩展。缺点开发周期比调库长兼容性维护成本高需要持续覆盖不同U盘芯片和不同手机型号。如果只是常规业务需求直接调libaums或系统SAF更省事。但如果是深度定制、自定义协议扩展、或者需要把USB读取能力嵌入到特定框架里自研这条路是完全走得通的而且长期维护下来反而更可控。一些实际操作的体会最后分享一点实际操作中的经验。整条链路里最容易让人半途而废的环节是SCSI命令阶段因为Android端拿到的错误信息往往很模糊ESET几十个字段加起来稍不留神就忽略了关键信号。我的建议是先拿一块确定好用的U盘把INQUIRY和READ CAPACITY跑通把返回的每一个字节都逐行打日志再往上层走。之后每加一层都先验证这一层的输入输出是否和预期一致避免出了错不知道是哪层的锅。还有一个小技巧把FAT32的引导扇区、FAT表、目录项的关键字段解析打印出来跟计算机上的十六进制编辑器结果对比能快速定位解析错误。我很多次都是靠这个对比找出代码里的字节位置偏差。如果后面想让这套代码服务更多场景可以考虑继续支持NTFS、增加写文件能力、或者接入FUSE做虚拟文件系统。有这套底层在扩展新格式主要是文件系统层的工作协议栈不用再动了。
返回列表