这阵子接了一个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 Framework(SAF)去访问。
但这次的需求不是走系统挂载,而是要求在USB协议层面自己去读取,相当于绕过了系统的文件系统抽象,直接跟U盘这个USB设备对话。把问题拆开,其实是四层:
- 通过USB Host API发现设备、申请USB权限、拿到UsbDevice对象。
- 在UsbDevice里找到大容量存储接口,获取一对批量传输端点。
- 基于批量传输端点实现Bulk-Only Transport(BOT)协议,通过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.kt |
| BulkTransfer | 封装底层bulkTransfer,处理分包和超时 | BulkTransfer.kt |
| BotProtocol | 构造CBW、解析CSW、管理命令生命周期 | BotClient.kt |
| ScsiCommand | 生成SCSI CDB命令、解析响应 | ScsiCommands.kt |
| BlockDevice | 扇区读写的逻辑封装 | BlockDevice.kt |
| FileSystem | FAT32/exFAT文件系统解析 | Fat32Parser.kt |
这种分层的好处是每层只依赖下一层,比如ScsiCommand不必关心CBW怎么构造,BotProtocol也不必关心文件系统结构。调试的时候可以从底层开始逐层验证:先验证能收发数据,再验证SCSI能读到块大小,最后再上文件系统解析。
2. 设备探测与连接:在Android上找到并打开U盘
2.1 USB Host API的基本姿势
Android的USB Host模式核心在UsbManager这个类。第一步要做的是在Manifest里声明设备特性:
<uses-feature android:name="android.hardware.usb.host" />获取设备列表很简单:
UsbManager manager = (UsbManager) getSystemService(Context.USB_SERVICE); HashMap<String, 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盘是0x06(SCSI 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:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> <action android:name="android.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字节,结构如下:
| 字段 | 偏移 | 长度 | 说明 |
|---|---|---|---|
| dCBWSignature | 0 | 4 | 固定字符串“USBC”,十六进制0x43425355 |
| dCBWTag | 4 | 4 | 命令标记,主机生成,CSW会原样返回 |
| dCBWDataTransferLength | 8 | 4 | 要传输的数据字节数,本次数据传输的长度 |
| bmCBWFlags | 12 | 1 | 0x80表示设备到主机(读),0x00表示主机到设备(写) |
| bCBWLUN | 13 | 1 | 逻辑单元号,通常填0 |
| bCBWCBLength | 14 | 1 | CDB的有效字节数,最大16 |
| CBWCB | 15 | 16 | 真正的SCSI命令块 |
CSW固定13字节:
| 字段 | 偏移 | 长度 | 说明 |
|---|---|---|---|
| dCSWSignature | 0 | 4 | 固定字符串“USBS”,十六进制0x53425355 |
| dCSWTag | 4 | 4 | 原样返回CBW里的tag,用于匹配 |
| dCSWDataResidue | 8 | 4 | 未传输完成的数据字节数,0表示恰好传完 |
| bCSWStatus | 12 | 1 | 0成功,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命令去问它“你是谁”“容量多大”“某个扇区是什么”。最基础的四条命令:
INQUIRY(opcode 0x12):查询设备基本信息,返回97字节左右的设备信息,包括厂商名、产品名、设备类型等。返回数据第0字节的低5位表示设备类型,0x00表示直接访问块设备,读到这个值说明它确实是一块磁盘。
TEST UNIT READY(opcode 0x00):没有任何数据阶段,直接看CSW的状态字段,0表示设备就绪。这个命令通常要轮询几次,U盘刚上电时可能返回失败,需要等待。
READ CAPACITY(10)(opcode 0x25):返回8字节数据,前4字节是最后一个逻辑块的地址(LBA),后4字节是每块大小(字节)。由此得知U盘的总容量和块大小。
READ(10)(opcode 0x28):读取指定LBA地址的若干个块。这是读取文件数据的核心命令。
构造READ(10)的CDB:
byte[] 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号扇区,里面记录了文件系统的关键参数。解析时最需要关注的字段:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0x0B | 2 | 每扇区字节数 | 通常512 |
| 0x0D | 1 | 每簇扇区数 | 簇是文件系统分配的最小单位 |
| 0x0E | 2 | 保留扇区数 | 引导扇区后面的保留区长度 |
| 0x10 | 1 | FAT表数量 | 通常2,有备份FAT |
| 0x24 | 4 | FAT表长度 | 每个FAT表占多少扇区 |
| 0x2C | 4 | 根目录起始簇号 | 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做虚拟文件系统。有这套底层在,扩展新格式主要是文件系统层的工作,协议栈不用再动了。