☰
Android USB Host协议栈自研U盘读写:从BOT到FAT32全解析
2026/10/3 12:18:49 网站建设 项目流程

做 Android USB 读写这块,可能很多人的第一反应都是接 libaums。我最初也一样,项目要遍历 U 盘文件,网上搜一圈,能用的开源方案基本就它一个。但到了依赖评审那一步发现,libaums 已经好几年没怎么更新了,对新机型的兼容性只能说看运气,而且我们的需求比较特殊——U 盘插入后不知道是什么文件系统、什么扇区大小、什么设备描述符,希望自己控制整个容错链路,用现成库反而不好扩展。于是我们决定不靠 libaums,直接用 Android 原生 USB Host 协议栈把 U 盘读出来。

这篇文章会把整套链路拆开讲:USB 枚举、权限申请、批量传输协议、SCSI 命令、文件系统解析,每一步都会给出可以落地的代码思路。适合两类人看:一类是和我一样因为依赖维护和合规原因不能直接用三方库的;另一类是纯粹想弄明白 U 盘的字节流到底是怎么组织的,而不是只知道调一个 readFile 就完事。文章代码是 Java 写的,思路完全可以平移到 Kotlin 或者你自己的实现里。

1. libaums替你做掉的协议栈,才是自研绕不过去的前提

1.1 libaums 的价值不是"读U盘",而是"分装协议栈"

先说一个很多人忽视的事实:libaums 不是一个简单的"读文件"库,它把从 USB 枚举到 FAT 文件系统解析的完整协议栈都封装好了。U 盘插上手机之后,系统只能告诉你"有一个 USB 设备连接了",仅此而已。真正要从 U 盘读出一个文本文件的内容,中间至少隔着四层:

第一层是 USB 枚举层,Android 的 UsbManager 会帮你做,但你需要自己找到正确的接口和端点;第二层是 USB 传输层,U 盘用的是批量传输协议里的 Bulk-Only Transport 规范,你要自己构造 CBW 命令块、收发数据、解析 CSW 状态块;第三层是 SCSI 命令层,U 盘本质上是一个 SCSI 存储设备,你要会发 READ(10)、READ CAPACITY、INQUIRY 这些命令;第四层是文件系统层,读到的都是原始扇区,要把分部引导记录、文件分配表、目录项这些东西自己解析出来,才能还原成文件名和文件内容。

libaums 把这四层全部封进了 API 后面,你用的时候只看到UsbMassStorageDevice和FileSystem,但这不代表协议不存在。想要不靠 libaums,其实就是要自己把这四层一层层搭起来。

1.2 自己动手的成本评估:不是写个类,是写一套协议栈

我见过不少人觉得"不就是一个 U 盘嘛,Android 肯定有现成 API",结果一搜才发现 Android 系统本身并没有公开的 U 盘文件读取接口。系统自带的文件管理器能读 OTG U 盘,是因为它走的是存储服务层,不是普通 App 能调用的。第三方应用想读 U 盘,只能走 USB Host API 自己实现,这就是为什么 libaums 能成为事实标准。

自己动手的成本主要在三块:一是 USB 协议细节,BOT 协议和 SCSI 命令的字节布局必须精确,错一个字节 U 盘就返回错误状态;二是文件系统解析,只读 FAT32 其实还好,如果要支持 exFAT、NTFS,工作量会成倍涨;三是兼容性测试,不同品牌 U 盘、不同主控的协议实现存在细微差异,必须在真机上反复验证。

我们当时定的策略是:第一步只支持 FAT32 读取,保证文件列表和常规文件读取;第二步才是写操作;第三步再看 exFAT。这个节奏控制很重要,因为协议栈是层层依赖的,先打通最小闭环,再往上加功能,排错会容易得多。

2. 从USB设备到U盘的逻辑链路:接口、端点与BOT

2.1 描述符层次:配置、接口、端点的关系

拿到一个 UsbDevice 对象之后,很多新手会直接懵掉,因为 Android 的设备模型是分层的。一个 USB 设备可以有一个或多个配置,每个配置里有多个接口,每个接口里又有多条端点。对 U 盘来说,配置通常是 1 个,接口也是 1 个,但这个接口里只有两条批量端点:一条输入、一条输出。用 Android 的 API 来理解就是:

UsbDevice └─ UsbConfiguration(配置) └─ UsbInterface(接口,U盘一般只有1个) ├─ UsbEndpoint(批量输入,Bulk IN) └─ UsbEndpoint(批量输出,Bulk OUT)

写代码时用device.getInterface(0)拿到接口,然后遍历interface.getEndpoints(),通过endpoint.getType()判断是不是UsbConstants.USB_ENDPOINT_XFER_BULK,再用endpoint.getDirection()区分输入输出。这里有个关键点:不要假设端点下标是 0 输入 1 输出,一定要遍历判断,因为有些 U 盘的端点顺序是反的。

这里还需要留意每个端点的getMaxPacketSize(),大多数 U 盘批量端点的最大包长是 512 字节,但也有少数是 64 或者 4096。后面做批量传输的时候,这个值会影响你拆分数据的逻辑,所以先存下来备用。

2.2 Bulk-Only Transport:U 盘的主通信方式

U 盘用的类是大容量存储类,具体协议是 Bulk-Only Transport,简称 BOT。这个协议的核心思想是:所有的命令和数据都通过批量端点传输,不再额外走控制端点。BOT 定义了三种数据包:

包类型名字方向长度
CBWCommand Block Wrapper主机→设备(OUT)31字节
数据Data Payload由命令决定方向不定
CSWCommand Status Wrapper设备→主机(IN)13字节

每次命令的完整流程是:主机先通过 Bulk OUT 端点发送一个 31 字节的 CBW,告诉设备"我要执行什么 SCSI 命令、要传多少数据、方向是读还是写";然后根据命令方向,通过对应端点传输数据;最后主机通过 Bulk IN 端点读回一个 13 字节的 CSW,确认命令执行状态。如果 CSW 的状态不是 0,说明命令失败了,需要做错误处理。

这个流程里最容易踩坑的是顺序。发送 CBW 之后,必须严格按照 CBW 里声明的传输方向和数据长度来收发数据。如果命令要求读 512 字节,而你只读了 256 字节就去读 CSW,设备会认为发生了 Phase Error,后续命令全都发不进去,必须重置设备。所以刚开始调试的时候,宁可多读也不要少读,并且要把每次传输的返回值打日志,直接对比实际返回字节数和期望字节数。

2.3 必须掌握的 SCSI 命令

BOT 传输的实质内容是 SCSI 命令。SCSI 命令块(CDB)放在 CBW 的 payload 里,不同的命令用不同的操作码区分。U 盘最常用的命令就这几个:

  • INQUIRY(0x12):查询设备基本信息,比如厂商、型号
  • READ CAPACITY(0x25):读取设备总容量和块大小
  • TEST UNIT READY(0x00):查询设备是否就绪
  • READ(10)(0x28):读取指定逻辑块地址的数据
  • WRITE(10)(0x2A):写入数据到指定逻辑块地址
  • MODE SENSE(6)(0x1A):查询设备模式参数

每个命令都有固定的 CDB 格式,字段是固定的。以READ CAPACITY为例,CDB 一共 10 字节,操作码是 0x25,其他字段全部填 0,命令执行后会返回 8 字节数据:前 4 字节是最后一个逻辑块地址(减 1 就是总块数),后 4 字节是块大小。这个命令是我们判断扇区大小的主要依据。

READ(10)的 CDB 稍微复杂一点。第 2 到第 5 字节是起始逻辑块地址(大端序),第 7 到第 8 字节是要读取的块数量。我们读 U 盘文件的时候,就是按文件所在的簇号换算成逻辑块地址,然后发这个命令。需要注意:逻辑块地址是 0 起始的,引导扇区就是 LBA 0。

3. 搭建Android宿主环境:权限申请与连接建立

3.1 注册广播与权限处理

在 USB 协议层之前,还挡着一道权限门槛。Android 要求第三方应用必须获得用户授权才能访问 USB 设备。权限获取有两种方式:

第一种是被动式,你在清单文件里声明 USB 设备过滤规则,然后注册一个广播接收器监听UsbManager.ACTION_USB_ATTACHED。U 盘一插入,系统会弹窗问用户"是否允许此应用访问该 USB 设备",用户点了允许之后,广播里会带上 UsbDevice 和 EXTRA_PERMISSION_GRANTED 标志。这种方式用户体验好,适合明确知道 U 盘会长期插着的场景。

第二种是主动式,先通过usbManager.getDeviceList()拿到当前已连接的设备列表,然后调用usbManager.requestPermission(device, pendingIntent)请求权限,请求结果同样通过广播返回。这种方式更适合在界面里手动刷新设备列表、点击之后才发起授权的交互模式。

我们用的是第二种,因为产品要求有一个"U 盘管理"入口,用户先进界面,再决定是否授权。权限请求的 PendingIntent 固定用一个 RequestCode,这样可以避免多个设备同时授权时回调错乱。

这里有一个实际经验:U 盘的授权是持久化的,用户点过一次"允许"之后,系统会记录下来。但是换个 USB 口、或者换了不同的 U 盘,授权状态会重新问。如果你做的是工厂测试类的 App,建议在调试阶段直接在设置里把"USB 设备权限"全部清掉,不然容易被旧权限干扰判断。

3.2 获取接口、端点并打开设备

权限拿到之后,连接设备的核心代码大致是:

UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); UsbDevice device = usbManager.getDeviceList().values().iterator().next(); if (usbManager.hasPermission(device)) { UsbDeviceConnection connection = usbManager.openDevice(device); if (connection == null) { return; // 打开失败,通常是设备被占用或权限不够 } UsbInterface usbInterface = device.getInterface(0); connection.claimInterface(usbInterface, true); UsbEndpoint bulkIn = null; UsbEndpoint bulkOut = null; for (int i = 0; i < usbInterface.getEndpointCount(); i++) { UsbEndpoint ep = usbInterface.getEndpoint(i); if (ep.getType() != UsbConstants.USB_ENDPOINT_XFER_BULK) { continue; } if (ep.getDirection() == UsbConstants.USB_ENDPOINT_DIR_IN) { bulkIn = ep; } else { bulkOut = ep; } } }

这段代码有三个关键点需要解释清楚。

第一,claimInterface必须调用,而且第二个参数要传 true,代表独占接口。不 claim 接口直接做 bulkTransfer,在部分设备上会直接返回 0 或者 -1,非常坑。

第二,打开连接之前最好先检查connection != null,因为 USB 设备可能同时被系统存储服务占用,尤其是一些开启了 MTP 模式的设备,OpenDevice 会返回 null。这种情况下可以考虑提示用户切换到"传输文件(MTP)"之外的模式,或者直接用 USB 集线器区分。

第三,拿到端点和接口后建议缓存起来,不要每次都现取。协议栈后续每次读写都要用到这些对象,频繁遍历没必要。UsbEndpoint对象本身是轻量的,可以直接作为类成员保存。

4. 手写BOT协议栈:CBW发送、数据收发与CSW校验

4.1 封装 CBW 命令块

CBW 是一个严格的 31 字节包,字节布局不能有分毫差错。它的结构是:

偏移长度字段说明
04dCBWSignature固定值 0x43425355(ASCII "USBC")
44dCBWTag命令标记,递增即可
84dCBWTransferLength数据传输阶段要传的字节数
121bmCBWFlags0x80 表示设备→主机(读取),0x00 表示主机→设备(写入)
131bCBWLUN逻辑单元号,U盘通常填 0
141bCBWCBLength命令描述符块长度,通常 16
1516CBWCB具体的 SCSI 命令

注意字节序:CBW 头部的多字节字段全部是小端序,跟 SCSI CDB 内部的大端序字段完全相反,这一点很容易写错。我见过有人把 dCBWTransferLength 写成了大端,结果命令直接失败,排查了半天才发现是这个原因。

构造 CBW 的 Java 代码:

private byte[] buildCbw(int tag, int transferLength, int directionFlag, byte[] cdb) { byte[] cbw = new byte[31]; cbw[0] = 0x55; // 'U' cbw[1] = (byte) 0x53; // 'S' cbw[2] = (byte) 0x42; // 'B' cbw[3] = (byte) 0x43; // 'C' cbw[4] = (byte) (tag & 0xFF); cbw[5] = (byte) ((tag >> 8) & 0xFF); cbw[6] = (byte) ((tag >> 16) & 0xFF); cbw[7] = (byte) ((tag >> 24) & 0xFF); cbw[8] = (byte) (transferLength & 0xFF); cbw[9] = (byte) ((transferLength >> 8) & 0xFF); cbw[10] = (byte) ((transferLength >> 16) & 0xFF); cbw[11] = (byte) ((transferLength >> 24) & 0xFF); cbw[12] = (byte) directionFlag; // 0x80 读,0x00 写 cbw[13] = 0; // LUN cbw[14] = (byte) cdb.length; System.arraycopy(cdb, 0, cbw, 15, cdb.length); return cbw; }

这个方法的几个参数是有讲究的。tag是每次命令的标记,U 盘会用它在 CSW 里回显,方便你配对命令和响应。transferLength指的是数据阶段的字节数,不是整个 CBW 的长度。比如要读一个扇区 512 字节,这个值就是 512,但要注意的是有些 U 盘主控会严格校验这个值和实际传输量的匹配。

4.2 处理数据阶段的字节序与方向

CBW 发送完之后,进入数据阶段。数据的收发方向由bmCBWFlags决定。在 Android 上直接用bulkTransfer收发即可:

private byte[] sendCommand(int tag, byte[] cdb, int transferLength, boolean isRead, byte[] dataBuffer) { byte[] cbw = buildCbw(tag, transferLength, isRead ? 0x80 : 0x00, cdb); int outLen = connection.bulkTransfer(bulkOut, cbw, cbw.length, COMMAND_TIMEOUT); if (outLen != cbw.length) { throw new IOException("CBW发送失败,返回长度 " + outLen); } int actualLen; if (isRead) { actualLen = connection.bulkTransfer(bulkIn, dataBuffer, transferLength, DATA_TIMEOUT); } else { actualLen = connection.bulkTransfer(bulkOut, dataBuffer, transferLength, DATA_TIMEOUT); } if (actualLen < 0) { throw new IOException("数据传输失败"); } byte[] csw = new byte[13]; int cswLen = connection.bulkTransfer(bulkIn, csw, csw.length, COMMAND_TIMEOUT); if (cswLen != 13) { throw new IOException("CSW读取失败"); } return csw; }

数据阶段有一个实际经验:bulkTransfer的第三个参数虽然是期望长度,但实际返回的字节数可能不等于期望值。有些 U 盘对短包特别敏感,如果你只写了 512 字节但端点最大包长是 512,设备可能一直等一个短包来确认传输结束。这个问题在写操作的时候更明显。

另外,Android 的bulkTransfer底层已经帮你处理了拆包组包的逻辑,不需要手动分包。但要注意数据缓冲区不要每次 new 一个大的,频繁的 GC 会直接影响 USB 传输的稳定性。我们后来做了 ByteBuffer 池,把常用的 512 字节缓冲区和 4096 字节缓冲区复用起来,传输错误率立刻下降了一些。

4.3 解析 CSW 并判断命令结果

CSW 也是固定 13 字节,结构同样有小端序的多字节字段。关键字段是偏移 8 的 4 字节 dCSWDataResidue 和偏移 12 的 1 字节 bCSWStatus。

bCSWStatus 的三种取值意义明确:

状态值含义处理建议
0命令成功正常继续
1命令失败检查 CDB 参数或设备状态
2Phase Error协议流错误,必须重置设备

Phase Error 是最麻烦的,因为它意味着设备认为主机搞乱了传输阶段。比如命令声明要读 4096 字节,你只读了 512 字节就去读 CSW,设备就会报 Phase Error。一旦出现 Phase Error,后续命令基本都会失败,最稳妥的做法是用控制传输发一个 Bulk-Only Mass Storage Reset 请求,把设备恢复到初始状态。

CSW 解析代码:

private void parseCsw(byte[] csw) throws IOException { if (csw[0] != 0x55 || csw[1] != 0x53 || csw[2] != 0x42 || csw[3] != 0x43) { throw new IOException("CSW签名错误"); } int status = csw[12] & 0xFF; if (status == 0) { return; // 成功 } else if (status == 2) { throw new IOException("Phase Error,需要重置设备"); } else { throw new IOException("命令失败,status=" + status); } }

4.4 完整读取流程串一遍

把这些拼到一起,读一个逻辑块地址的完整流程就是:

private byte[] readSector(int lba) throws IOException { byte[] cdb = new byte[16]; cdb[0] = 0x28; // READ(10) cdb[1] = 0x00; cdb[2] = (byte) ((lba >> 24) & 0xFF); cdb[3] = (byte) ((lba >> 16) & 0xFF); cdb[4] = (byte) ((lba >> 8) & 0xFF); cdb[5] = (byte) (lba & 0xFF); // 6、7、8、9 是分配长度 cdb[7] = (byte) ((1 >> 8) & 0xFF); cdb[8] = (byte) (1 & 0xFF); byte[] dataBuffer = new byte[sectorSize]; byte[] csw = sendCommand(tag++, cdb, sectorSize, true, dataBuffer); parseCsw(csw); return dataBuffer; }

这里sectorSize就是前面用 READ CAPACITY 拿到的块大小。实际项目里请不要一个个扇区读,效率太低了。批量读多个块的方法是修改 CDB 里的分配长度字段,将传输长度改成sectorSize * blockCount,一次 bulkTransfer 直接接收整块数据。块的大小建议控制在 64KB 到 128KB 之间,太大会触发某些 U 盘的边界问题,太小则吞吐量上不去。

还有一个我之前忽略的细节:CDB 里分配长度是个 16 位字段,最大只能表达 65535 个块,如果块大小是 512 字节,一次最多约 32MB。如果底层 U 盘容量很大,读取大文件时要注意别超过这个上限。

5. 读出原始扇区之后:FAT32文件解析的思路

5.1 MBR 与引导扇区

USB 协议层打通之后,你能拿到的是原始扇区序列,跟直接看一块磁盘的二进制没区别。要从扇区还原出文件,需要自己解析分区表和文件系统。

第一步是读 LBA 0,也就是主引导记录。MBR 的最后 64 字节是分区表,每 16 字节一个表项。对于大多数 U 盘来说,里面只有一个分区类型是 0x0C(FAT32)或 0x0B(FAT32 无 LBA 扩展)的分区。分区表项第 8 到第 11 字节是这个分区的起始 LBA,第 12 到第 15 字节是分区长度。拿到起始 LBA 之后,读这个位置的分区引导扇区。

这里有个非常常见的坑:并不是所有 U 盘都有 MBR 分区表。有些 U 盘出厂是"超级软盘"格式,整个设备直接就是一个 FAT32 文件系统,没有分区表,LBA 0 就是引导扇区。所以解析的时候要做一个检查:如果 LBA 0 的偏移 510、511 字节是 0x55 0xAA,而且偏移 0 是跳转指令,那它大概率是直接格式化的 FAT32;如果 LBA 0 的分区表项第一个字节是 0x80(启动标志)或 0x00,那就按 MBR 流程走。兼容性的差异就在这种细节上。

5.2 FAT 表与目录项

引导扇区里有 BPB 参数,记录了 FAT 表数量、每个簇的扇区数、保留扇区数、根目录等关键信息。对于 FAT32 来说,根目录不再是固定的,它通过 BPB 里的根目录簇号来定位。有了这些参数,就可以计算数据区的起始 LBA:

数据区起始LBA = 保留扇区数 + FAT表数量 × 每个FAT表的扇区数

FAT 表本质是一个数组,数组项的索引就是簇号,数组项的值指向下一簇。FAT32 每项 4 字节,值 0x0FFFFFFF 表示文件的最后一簇。解析目录的时候,目录项是 32 字节的固定结构,前 11 字节是文件名(8.3 格式),偏移 20 的高 2 字节和偏移 26 的低 2 字节拼起来是首簇号,偏移 28 的 4 字节是文件大小。文件名如果是长文件名,会以 0x0F 属性字节开头,需要把分散的 UTF-16 片段拼接起来。

这些解析规则本身不复杂,但细节多得很。光文件名解码就涉及大小写标志、长文件名的序号翻转,还有 0xE5 表示删除项的判断。我们的做法是先支持 8.3 短文件名和中英文长文件名,其他边界情况先抛异常记录日志。

5.3 一条文件的读取链路

假设你已经定位到文件的首簇号,读取文件的完整链路就是:

  1. 根据簇号计算 LBA:数据区起始LBA + (簇号 - 2) × 每簇扇区数
  2. 读取该簇对应的所有扇区,拼接成文件内容片段
  3. 用当前簇号查 FAT 表,拿到下一簇号
  4. 如果下一簇是 0x0FFFFFFF 就停止,否则回到第 1 步继续

就这个循环,配合前面的 readSector,你就能把一个文件完整读出来。为了性能,读取路径上要加缓存:目录项、FAT 表项、文件片段都要做 LRU 缓存。我们实现的第一个可用版本没有缓存,读一个 100MB 的视频文件要发几千次 USB 命令,速度惨不忍睹。后来加了一层 128KB 的扇区缓存,整体吞吐量提升了 5 倍以上。

这里有一个值得提醒的点:文件碎片问题。FAT32 的文件在磁盘上可能是离散存储的,所以一定要按簇号顺藤摸瓜,不能假设所有簇是连续的。有些鸿蒙和 Windows 格式化出来的 U 盘会主动整理碎片,但你不能依赖这个假设。

6. 实测、性能调优与容易踩的深坑

6.1 实测环境与结果

我们的实测环境是:一台 Pixel 3 和一台小米 11,U 盘选了闪迪 32GB FAT32、金士顿 64GB exFAT,还有一个老款朗科 8GB FAT16。用 libaums 作为基准对照,对比自研方案和 libaums 在读取大文件时的实时吞吐量。

结果显示:自研方案在 FAT32 的闪迪 U 盘上读大文件能达到 12-15MB/s,libaums 大概在 10MB/s 左右,差距主要来自我们用了 128KB 的读写块,libaums 默认是 64KB。金士顿的 exFAT 盘因为我们的代码只支持 FAT32,直接跳过;朗科 FAT16 的兼容性检查能过,但根目录容量受限,文件数多了之后解析稍慢。

这里需要说明的是,吞吐量受 U 盘本身主控的影响非常大。同一个 U 盘跑 USB 2.0 和 USB 3.0 口,速度可以差 3 倍以上。如果你想追求最大性能,建议读写块选 128KB 而不是 1MB,因为大部分 U 盘主控对 1MB 以上的单次请求反而会强制切小包,产生额外的排队延迟。

6.2 踩坑记录:超时、Nak、字节序和兼容性

第一个坑是超时参数。最开始我们把 bulkTransfer 的超时设成了 1000ms,结果某些慢速 U 盘在首次通电后要好几秒才能真正就绪,所有命令都超时。后来改成:TEST UNIT READY 命令走 5 秒超时,数据读取走 3 秒超时,写操作走 5 秒超时,设备枚举阶段额外做循环重试。实测下来,USB 2.0 U 盘的稳定传输间隔很少超过几百毫秒,超时设大一点不会拖慢整体速度。

第二个坑是批量端点收到连续 Nak 握手。USB 设备忙时会对批量传输返回 Nak,表示"我还没准备好"。Android 的 bulkTransfer 内部会重试,但重试次数和间隔因设备而异。我们在测试中发现某款老 U 盘在大量写入时会连续返回 Nak 超过 500ms,导致 bulkTransfer 直接超时。解决办法是对写操作增加重试机制,超时后重发同样的 CBW,同时确保 CSW 标记配对正确。不重试的话,一次偶发的 Nak 超时就会让整个文件写入中断。

第三个坑是字节序混淆。这个前面提过,CBW 头部多字节字段是小端序,SCSI 命令 CDB 内部字段是大端序。我们第一次实现的时候把 CBWTransferLength 的 4 个字节按大端塞进去了,结果所有命令都返回"命令失败",查了两天才发现。建议在调试阶段先把 CBW、CSW 的字节打出来逐字段对照,而不是直接看最终读写结果。字节序错误的典型表现是命令能发出去但状态不可能为 0,或者数据全是错的但不报错。

第四个坑是设备断电和热插拔。USB 设备随时可能被拔出,而 Android 的bulkTransfer在设备拔出后可能表现为返回 -1,也可能表现为挂起不返回。我们最后的方案是在主线程之外单开一条 USB 监控线程,监听UsbManager.ACTION_USB_DEVICE_DETACHED,收到广播就把协议栈的传输循环打断,避免线程在 USB 调用里无限等待。这个看似小的问题,实际调试线程卡死的时间比协议本身还多。

还有一个和 U 盘无关但必须提的坑:部分国产品牌手机会在 OTG 插入时弹一个系统文件管理的浮窗,这本身没问题,但这导致 U 盘的 USB 接口被系统存储服务抢占,你的 App 拿不到同一设备的独占接口。像这种情况,流程上要设计成"检测到设备被占用时提示用户去系统文件管理器里弹出 U 盘,再回到 App 刷新设备列表"。

另外,USB 通信这个东西,日志一定要打全。我们上线前在协议栈每个关键步骤都加了 tag 标记:CBW 发送、数据收发、CSW 状态、命令名、LBA、块数、耗时。后面出问题的时候,用这些日志能很快定位是卡在哪个阶段。特别提醒,二进制包不要直接打字节数组,太占空间,打关键字段和状态码就够了。

如果你只是想在产线上快速验证 U 盘能不能读,其实是可以拿这个思路做一个小工具的。USB 协议并不可怕,它就像一层一层的盒子,CBW 里面套着 SCSI 命令,SCSI 命令里面套着扇区数据,扇区数据里面套着文件系统。只要有一层盒子打开出错,回头把这一层的日志拿出来对着协议规范看,总能找到问题在哪。真正让我觉得这一路值得的,是当协议栈跑通、U 盘里的文件一个不差地被读出来的时候,那种对"数据是怎么从硬件到字节流再到文件名"有了完整掌控的踏实感。现在技术栈里多了一个可以随时调优的 U 盘读写模块,再遇到兼容性问题,第一反应不是去翻库的 issue,而是打开自己的日志看它的端点描述符到底给了什么。

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

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

立即咨询