1. 为什么我要绕开 libaums 自己啃 USB 协议
1.1 一个真实的需求场景
去年接了个活儿,客户要求在 Android 平板上插 U 盘,把里面的照片批量导入到 App 的私有目录,还要支持 FAT32 和 exFAT 两种格式。第一反应当然是找现成库,libaums 是 GitHub 上 star 数最高的那个,Java 写的,封装了 Bulk-Only Transport 和 SCSI 命令集,看起来开箱即用。但实际接进去之后问题就来了:这个库最后一次实质性更新已经是很久以前的事,用的还是android.hardware.usb那套老 API,在 Android 13 以上的设备上申请权限时行为不一致,部分国产 ROM 直接拒绝授予UsbManager.requestPermission的持久化权限。更麻烦的是它内部对 SCSI 命令的封装是写死的,我想加一个自定义的 READ CAPACITY(16) 命令去读大容量 U 盘的容量,得改源码重新编译。
折腾了两天之后我决定放弃,自己从 USB 协议层往上写。这篇文章就是那次踩坑的完整记录,把 Bulk-Only Transport(BOT)协议、SCSI 命令集、Android USB Host API 这三块拼起来,实现一个能读 U 盘扇区、解析 FAT32 目录、最终拿到文件内容的完整链路。适合有 Android 开发基础、想搞清楚 U 盘读取底层原理的人,也适合那些被第三方库坑过、想自己掌控代码的同行。
1.2 自己实现 vs 用现成库的取舍
先说清楚,不是所有场景都值得自己写。如果你的需求只是"读个 U 盘里的文件",设备型号固定、Android 版本固定,libaums 或者系统自带的 Storage Access Framework 完全够用,没必要重复造轮子。但下面这几种情况,自己实现反而更省事:
- 需要支持非标准文件系统:比如客户要求读 exFAT 或者 ext4 格式的 U 盘,libaums 只支持 FAT32,你就得自己处理。
- 需要精细控制 SCSI 命令:比如要读 U 盘的 SMART 信息、要做低级格式化、要发自定义 vendor command,封装库不会给你这个口子。
- 目标设备 Android 版本跨度大:从 Android 7 到 Android 14 都要跑,第三方库的兼容性补丁跟不上。
- 包体积敏感:libaums 加上依赖大概 300KB 左右,对于一个工具类 App 来说不算小。
自己实现的核心工作量其实就三块:USB 通信层(BOT 协议)、命令层(SCSI)、文件系统层(FAT32/exFAT 解析)。其中 USB 通信层是最容易出错的,因为 Android 的 USB Host API 有一堆坑,下面我会逐个拆开讲。
1.3 整体架构长什么样
在动手写代码之前,先把整个数据流理清楚。Android 设备作为 USB Host,U 盘作为 USB Device,两者之间的通信遵循 USB 2.0 协议。U 盘属于 Mass Storage Class(大容量存储类),具体子类是 SCSI Transparent Command Set,协议是 Bulk-Only Transport。
一次完整的"读一个扇区"操作,数据流是这样的:
- App 通过
UsbManager拿到UsbDevice对象,申请权限。 - 打开设备拿到
UsbDeviceConnection。 - 找到 Mass Storage 接口下的两个 Bulk Endpoint(一个 IN 一个 OUT)。
- 构造一个 31 字节的 Command Block Wrapper(CBW),里面包含 SCSI 命令(比如 READ(10))。
- 通过 Bulk OUT Endpoint 把 CBW 发出去。
- 如果是读操作,通过 Bulk IN Endpoint 读数据。
- 读一个 13 字节的 Command Status Wrapper(CSW),确认命令执行成功。
这七步就是 BOT 协议的全部核心。SCSI 命令集是在第 4 步里塞进去的,FAT32 解析是在第 6 步拿到数据之后做的。理解了这条链路,后面所有代码都是它的展开。
2. USB 通信层:Bulk-Only Transport 协议拆解
2.1 CBW 结构体的每个字节都不能错
CBW 是 Host 发给 Device 的命令包,固定 31 字节,结构如下:
| 偏移 | 长度 | 字段名 | 说明 |
|---|---|---|---|
| 0 | 4 | dCBWSignature | 固定 0x43425355,ASCII "USBC" |
| 4 | 4 | dCBWTag | 命令标签,Host 自定义,CSW 会原样返回 |
| 8 | 4 | dCBWDataTransferLength | 本次传输的数据长度(字节) |
| 12 | 1 | bmCBWFlags | bit7=1 表示 Device→Host(读),0 表示 Host→Device(写) |
| 13 | 1 | bCBWLUN | 逻辑单元号,U 盘一般是 0 |
| 14 | 1 | bCBWCBLength | 后面 SCSI 命令的长度,合法值 1-16 |
| 15 | 16 | CBWCB | SCSI 命令本身 |
这里最容易踩的坑是字节序。USB 协议规定所有多字节字段都是小端序(Little Endian),而 Java 默认是大端序。我第一次写的时候忘了处理,dCBWSignature写成了0x55534243,结果 U 盘直接不响应,抓包看发现发出去的字节是反的。正确做法是用ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)来构造。
另一个坑是dCBWTag。这个字段看起来随便填,但如果你同时发了多个命令,必须保证每个命令的 tag 唯一,否则 CSW 回来的时候你分不清是哪个命令的响应。我的做法是用一个AtomicInteger自增,简单可靠。
private byte[] buildCBW(int tag, int dataLength, boolean isRead, byte[] scsiCmd) { ByteBuffer buf = ByteBuffer.allocate(31); buf.order(ByteOrder.LITTLE_ENDIAN); buf.putInt(0x43425355); // signature buf.putInt(tag); // tag buf.putInt(dataLength); // data transfer length buf.put((byte) (isRead ? 0x80 : 0x00)); // flags buf.put((byte) 0); // LUN buf.put((byte) scsiCmd.length); // CB length buf.put(scsiCmd); while (buf.position() < 31) buf.put((byte) 0); // padding return buf.array(); }2.2 CSW 只有 13 字节但信息量很大
CSW 是 Device 返回给 Host 的状态包,固定 13 字节:
| 偏移 | 长度 | 字段名 | 说明 |
|---|---|---|---|
| 0 | 4 | dCSWSignature | 固定 0x53425355,ASCII "USBS" |
| 4 | 4 | dCSWTag | 必须和对应 CBW 的 tag 一致 |
| 8 | 4 | dCSWDataResidue | 实际传输长度与期望长度的差值 |
| 12 | 1 | bCSWStatus | 0x00 成功,0x01 失败,0x02 Phase Error |
dCSWDataResidue这个字段很多人忽略,但它其实是排查问题的关键。比如你请求读 512 字节,结果 residue 是 512,说明一个字节都没读到,问题出在 SCSI 命令参数上;如果 residue 是 0,说明全部读到了。如果 residue 是个奇怪的值比如 256,那可能是 U 盘的实际扇区大小不是 512 而是 2048,你请求的长度不对齐。
bCSWStatus返回 0x01 的时候,说明 SCSI 命令执行失败,但具体失败原因要看 SCSI 的 Sense Data。这时候需要发一个 REQUEST SENSE 命令去拿详细错误码。我遇到过最常见的是 0x28(NOT READY TO READY CHANGE),意思是 U 盘刚插入还没准备好,等几百毫秒重试就行。
2.3 Android USB Host API 的初始化流程
Android 这边的初始化分四步,每一步都有坑:
第一步:声明权限和特性。在AndroidManifest.xml里加:
<uses-feature android:name="android.hardware.usb.host" android:required="true" />注意required="true"会让没有 USB Host 功能的设备无法安装,如果你的 App 还有其他功能,建议设成false然后在代码里判断。
第二步:枚举设备。通过UsbManager.getDeviceList()拿到所有已连接的 USB 设备,然后过滤出 Mass Storage 类的:
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); for (UsbDevice device : usbManager.getDeviceList().values()) { if (isMassStorage(device)) { // 找到 U 盘 } } private boolean isMassStorage(UsbDevice device) { for (int i = 0; i < device.getInterfaceCount(); i++) { UsbInterface intf = device.getInterface(i); if (intf.getInterfaceClass() == 0x08) { // Mass Storage Class return true; } } return false; }第三步:申请权限。这是最容易出问题的地方。Android 从 6.0 开始,USB 权限需要通过PendingIntent申请,用户会看到一个弹窗。但很多国产 ROM 会把这个弹窗拦截掉,或者默认拒绝。我的做法是申请之前先检查usbManager.hasPermission(device),如果有就直接用,没有才申请。另外,如果 App 是系统签名或者有MANAGE_USB权限,可以静默授予,但普通 App 拿不到这个权限。
private static final String ACTION_USB_PERMISSION = "com.example.USB_PERMISSION"; PendingIntent permissionIntent = PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(device, permissionIntent);注意FLAG_IMMUTABLE,Android 12 以上必须加,否则会崩。
第四步:打开连接并找到 Bulk Endpoint。
UsbDeviceConnection connection = usbManager.openDevice(device); UsbInterface intf = device.getInterface(0); connection.claimInterface(intf, true); UsbEndpoint epIn = null, epOut = 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) epIn = ep; else epOut = ep; } }claimInterface的第二个参数force建议传true,否则如果内核已经占用了这个接口(比如系统自动挂载了 U 盘),你会拿不到控制权。传true会强制从内核手里抢过来,代价是系统的文件管理器就看不到这个 U 盘了。
2.4 一次完整的 Bulk 传输怎么写
有了 CBW、CSW 和 Endpoint,一次完整的传输就是"发 CBW → 传数据 → 收 CSW"三步。这里有个细节:bulkTransfer的返回值是实际传输的字节数,可能小于请求的长度,需要循环调用直到传完。
private boolean executeCommand(UsbDeviceConnection conn, UsbEndpoint epIn, UsbEndpoint epOut, byte[] cbw, byte[] dataBuffer, boolean isRead) { // 1. 发 CBW int sent = conn.bulkTransfer(epOut, cbw, cbw.length, 5000); if (sent != cbw.length) return false; // 2. 传数据 if (dataBuffer != null && dataBuffer.length > 0) { if (isRead) { int offset = 0; while (offset < dataBuffer.length) { int len = conn.bulkTransfer(epIn, dataBuffer, offset, dataBuffer.length - offset, 5000); if (len <= 0) return false; offset += len; } } else { int offset = 0; while (offset < dataBuffer.length) { byte[] chunk = Arrays.copyOfRange(dataBuffer, offset, dataBuffer.length); int len = conn.bulkTransfer(epOut, chunk, chunk.length, 5000); if (len <= 0) return false; offset += len; } } } // 3. 收 CSW byte[] csw = new byte[13]; int received = conn.bulkTransfer(epIn, csw, 13, 5000); if (received != 13) return false; ByteBuffer buf = ByteBuffer.wrap(csw).order(ByteOrder.LITTLE_ENDIAN); int signature = buf.getInt(); int tag = buf.getInt(); int residue = buf.getInt(); byte status = buf.get(); return signature == 0x53425355 && status == 0x00; }超时时间我设的 5000ms,实测对于大多数 U 盘够用。如果遇到慢速 U 盘或者大块数据传输,可以适当加大。但注意bulkTransfer是阻塞调用,不要在 UI 线程里跑,必须放到子线程。
3. SCSI 命令层:让 U 盘听懂你的话
3.1 读扇区的 READ(10) 命令怎么构造
SCSI 命令集里读扇区用的是 READ(10),命令码 0x28,总共 10 字节:
| 字节 | 值 | 说明 |
|---|---|---|
| 0 | 0x28 | 操作码 |
| 1 | 0x00 | 保留位 + LUN |
| 2-5 | LBA | 逻辑块地址,大端序 |
| 6 | 0x00 | 保留 |
| 7-8 | 传输长度 | 要读的扇区数,大端序 |
| 9 | 0x00 | 控制字节 |
注意这里 LBA 和传输长度是大端序,和 CBW 的小端序正好相反。这是 SCSI 协议的规定,我第一次写的时候统一用了小端序,结果读出来的数据全是乱的。这个坑我踩了整整一个下午,最后抓包对比才发现的。
private byte[] buildRead10(long lba, int sectorCount) { ByteBuffer buf = ByteBuffer.allocate(10); buf.order(ByteOrder.BIG_ENDIAN); // 注意是大端 buf.put((byte) 0x28); buf.put((byte) 0x00); buf.putInt((int) lba); buf.put((byte) 0x00); buf.putShort((short) sectorCount); buf.put((byte) 0x00); return buf.array(); }扇区大小一般是 512 字节,但有些大容量 U 盘是 4096 字节。怎么知道?发一个 READ CAPACITY(10) 命令(0x25)就能拿到。返回的 8 字节里,前 4 字节是最后一个可寻址的 LBA,后 4 字节是扇区大小。
3.2 INQUIRY 命令:先问清楚对方是谁
在正式读写之前,建议先发一个 INQUIRY 命令(0x12),拿到 U 盘的厂商、型号、版本信息。这个命令返回 36 字节,前 8 字节是外设信息,8-15 字节是厂商 ID,16-31 字节是产品 ID,32-35 字节是版本号。
private byte[] buildInquiry() { byte[] cmd = new byte[6]; cmd[0] = 0x12; // INQUIRY cmd[1] = 0x00; cmd[2] = 0x00; cmd[3] = 0x00; cmd[4] = 36; // allocation length cmd[5] = 0x00; return cmd; }这个命令的价值在于调试。当你发现读写失败的时候,先发 INQUIRY 看看能不能拿到正确的响应,如果连这个都失败,说明 USB 通信层有问题;如果能拿到,说明通信层 OK,问题出在后面的命令参数上。这是我排查问题时最常用的分界线。
3.3 REQUEST SENSE:拿到失败的具体原因
当 CSW 的 status 返回 0x01 时,必须发 REQUEST SENSE(0x03)去拿详细错误信息。返回的数据里,第 2 字节是 Sense Key,第 12 字节是 ASC(Additional Sense Code),第 13 字节是 ASCQ。
常见的 Sense Key 和含义:
| Sense Key | 含义 | 常见原因 |
|---|---|---|
| 0x00 | No Sense | 没有错误 |
| 0x02 | Not Ready | U 盘未就绪,需要等待 |
| 0x03 | Medium Error | 介质错误,扇区损坏 |
| 0x05 | Illegal Request | 命令参数非法,检查 LBA 和长度 |
| 0x06 | Unit Attention | 设备状态变化,重试即可 |
| 0x07 | Data Protect | 写保护,U 盘被锁 |
我遇到最多的是 0x05,基本都是 LBA 超出范围或者传输长度不对齐。有一次客户拿了个 64GB 的 U 盘,我用 32 位 int 存 LBA,结果超过 2TB 的寻址范围就溢出了,后来改用 long 才解决。虽然 64GB 还没到 2TB,但 READ(10) 的 LBA 字段是 32 位,最大只能寻址 2TB,超过这个容量必须用 READ(16)(0x88),LBA 字段是 64 位。
3.4 命令执行的重试策略
USB 通信不是 100% 可靠的,尤其是劣质 U 盘或者接触不良的时候。我的重试策略是这样的:
- Unit Attention(0x06):直接重试,最多 3 次,每次间隔 100ms。
- Not Ready(0x02):等待 500ms 后重试,最多 5 次。U 盘刚插入时经常这样。
- Illegal Request(0x05):不重试,直接报错,因为参数错了重试也没用。
- Medium Error(0x03):重试 2 次,还失败就标记该扇区损坏,跳过。
- USB 传输本身失败(bulkTransfer 返回负数):重新 claimInterface 后重试。
这套策略是我在实际项目中慢慢调出来的,能覆盖 95% 以上的异常情况。剩下的 5% 基本是硬件问题,软件层面无解。
4. 文件系统层:从扇区数据到文件内容
4.1 FAT32 的引导扇区怎么解析
拿到扇区数据之后,下一步是解析 FAT32 文件系统。第一个扇区(LBA 0)是引导扇区,关键字段如下:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 11 | 2 | BytesPerSector | 每扇区字节数,一般是 512 |
| 13 | 1 | SectorsPerCluster | 每簇扇区数 |
| 14 | 2 | ReservedSectors | 保留扇区数 |
| 16 | 1 | NumFATs | FAT 表数量,一般是 2 |
| 32 | 4 | TotalSectors32 | 总扇区数 |
| 36 | 4 | FATSize32 | 每个 FAT 表的扇区数 |
| 44 | 4 | RootCluster | 根目录起始簇号,一般是 2 |
有了这些字段,就能算出几个关键地址:
- FAT 表起始扇区= ReservedSectors
- 数据区起始扇区= ReservedSectors + NumFATs × FATSize32
- 簇 N 的起始扇区= 数据区起始扇区 + (N - 2) × SectorsPerCluster
这里有个容易搞错的地方:簇号是从 2 开始的,不是从 0。所以计算扇区地址的时候要减 2。我第一次写的时候忘了减,读出来的目录项全是乱码。
4.2 目录项和长文件名的处理
FAT32 的目录项是 32 字节一条,短文件名(8.3 格式)直接存在目录项里,长文件名用多个目录项拼接。每个目录项的属性字节(偏移 11)如果是 0x0F,说明这是一个长文件名项,需要和后面的短文件名项组合。
长文件名的编码是 UTF-16,每个长文件名项存 13 个字符,分布在偏移 1-10、14-25、28-31 三个区域。多个长文件名项按倒序排列,最后跟一个短文件名项。解析的时候要从后往前读。
private String parseLongFileName(byte[] entry) { StringBuilder sb = new StringBuilder(); // 第一段:偏移 1-10,5 个字符 for (int i = 1; i <= 10; i += 2) { char c = (char) ((entry[i + 1] << 8) | (entry[i] & 0xFF)); if (c != 0 && c != 0xFFFF) sb.append(c); } // 第二段:偏移 14-25,6 个字符 for (int i = 14; i <= 25; i += 2) { char c = (char) ((entry[i + 1] << 8) | (entry[i] & 0xFF)); if (c != 0 && c != 0xFFFF) sb.append(c); } // 第三段:偏移 28-31,2 个字符 for (int i = 28; i <= 31; i += 2) { char c = (char) ((entry[i + 1] << 8) | (entry[i] & 0xFF)); if (c != 0 && c != 0xFFFF) sb.append(c); } return sb.toString(); }注意 UTF-16 是小端序,低字节在前。这个和 SCSI 命令的大端序又不一样,写的时候要特别小心。
4.3 簇链的遍历和文件读取
FAT32 的文件数据是分散在多个簇里的,通过 FAT 表串成链表。FAT 表的每一项是 4 字节,存的是下一个簇的簇号。如果值是 0x0FFFFFFF,说明这是最后一个簇;如果是 0x0FFFFFF7,说明这个簇是坏簇。
读一个文件的流程是:
- 从目录项里拿到起始簇号(偏移 26-27,高 16 位在偏移 20-21)。
- 从起始簇开始,读一个簇的数据。
- 查 FAT 表,拿到下一个簇号。
- 重复 2-3,直到遇到 0x0FFFFFFF。
- 把所有簇的数据拼起来,截取到文件实际大小(目录项偏移 28-31)。
这里有个性能优化点:FAT 表本身也是存在扇区里的,如果每次都去读 FAT 表的一个项,会产生大量 USB 传输。我的做法是一次性把整个 FAT 表读到内存里(对于 32GB 的 U 盘,FAT 表大概 4MB 左右),后续查表直接在内存里做。这样读一个大文件的速度能提升好几倍。
4.4 exFAT 的差异点
如果客户要求支持 exFAT,解析逻辑要改不少。exFAT 的引导扇区结构和 FAT32 完全不同,目录项也不是 32 字节固定长度,而是用 EntryType 区分。最关键的是 exFAT 没有 FAT 表链,而是用 Allocation Bitmap 来标记簇的占用情况,文件数据是连续存储的。
exFAT 的目录项里,0x85 是文件目录项,0xC0 是流扩展项(存起始簇和文件大小),0xC1 是文件名项。解析的时候要按顺序读,遇到 0x85 开始一个新文件,遇到 0xC0 拿数据位置,遇到 0xC1 拼文件名。
说实话,exFAT 的解析复杂度比 FAT32 高不少,如果项目时间紧,建议先用 FAT32 顶上,exFAT 作为二期需求。我当时是先花了两天把 FAT32 跑通,又花了一周才把 exFAT 调稳。
5. 实操中踩过的坑和排查技巧
5.1 权限申请失败的三种情况
情况一:用户点了拒绝。这个最简单,重新申请就行。但注意 Android 会记住用户的选择,如果用户勾选了"不再询问",后续申请会直接返回失败。这时候只能引导用户去系统设置里手动开启。
情况二:ROM 拦截了弹窗。部分国产 ROM 会拦截 USB 权限弹窗,导致onReceive永远收不到回调。我的做法是加一个超时机制,申请后 3 秒没收到回调就当作失败处理,提示用户检查系统设置。
情况三:设备被内核占用。如果 U 盘插入后系统自动挂载了,claimInterface会失败。这时候需要先connection.releaseInterface,再重新claimInterface(intf, true)。如果还是不行,可能需要在AndroidManifest.xml里加android:requestLegacyExternalStorage="true"(仅 Android 10 及以下有效)。
5.2 数据传输不完整的排查思路
bulkTransfer返回的字节数小于请求长度,这是最常见的问题。排查顺序:
- 检查 Endpoint 方向:IN 用于读,OUT 用于写,搞反了会直接返回 -1。
- 检查 CBW 的 dataTransferLength:这个值必须和实际要传的数据长度一致,不一致会导致 residue 非零。
- 检查 SCSI 命令的传输长度:READ(10) 的传输长度是扇区数,不是字节数。如果你要读 512 字节,传输长度应该填 1,不是 512。
- 检查超时时间:大块数据传输需要更长的超时,我一般设 10 秒。
- 检查 U 盘是否支持多扇区传输:有些老 U 盘一次只能读一个扇区,传输长度填大于 1 会失败。
5.3 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| bulkTransfer 返回 -1 | Endpoint 方向错误或设备未连接 | 检查 Endpoint 方向,重新打开设备 |
| CSW status = 0x01 | SCSI 命令执行失败 | 发 REQUEST SENSE 拿详细错误码 |
| CSW signature 不对 | 字节序错误或 CBW 构造错误 | 检查 ByteOrder 设置 |
| 读出的数据全是 0xFF | U 盘未就绪或 LBA 超出范围 | 等待 500ms 重试,检查 LBA |
| 读出的数据全是 0x00 | 扇区未格式化或读取位置错误 | 检查文件系统类型和起始扇区 |
| 长文件名乱码 | UTF-16 字节序错误 | 确认低字节在前 |
| 大文件读取到一半失败 | 簇链断裂或 FAT 表损坏 | 检查 FAT 表项,跳过坏簇 |
| 权限申请无响应 | ROM 拦截或用户拒绝 | 加超时机制,引导用户手动授权 |
5.4 性能优化的几个实用技巧
技巧一:批量读扇区。一次 READ(10) 最多可以读 65535 个扇区(约 32MB),但实际测试下来,一次读 128 个扇区(64KB)性能最好。读太多会导致超时,读太少会导致命令开销占比过高。
技巧二:缓存 FAT 表。前面提过,把整个 FAT 表读到内存里,后续查表不走 USB。对于 32GB 的 U 盘,FAT 表大概 4MB,内存完全放得下。
技巧三:预读目录。解析目录的时候,一次性把整个目录占用的簇都读出来,而不是一个目录项一个目录项地读。一个簇 4KB 能存 128 个目录项,一次读完比读 128 次快得多。
技巧四:用 Direct ByteBuffer。bulkTransfer内部会做一次内存拷贝,如果传入的是 Direct ByteBuffer,可以省掉这次拷贝。对于大块数据传输,性能提升大概 10%-20%。
ByteBuffer buffer = ByteBuffer.allocateDirect(64 * 1024); buffer.order(ByteOrder.LITTLE_ENDIAN); // 用 buffer.array() 传给 bulkTransfer5.5 调试工具和抓包方法
软件层面调不通的时候,抓包是最有效的。Android 上可以用usbmon抓 USB 流量,但需要 root 权限。没有 root 的话,可以用一台 Linux 电脑做 USB 代理,把 U 盘插在电脑上,用usbmon抓包,然后对比 Android 端的行为。
我常用的抓包命令:
# 加载 usbmon 模块 sudo modprobe usbmon # 查看有哪些 USB 总线 ls /sys/kernel/debug/usb/usbmon/ # 抓 1 号总线的流量 sudo cat /sys/kernel/debug/usb/usbmon/1u > usb.log抓到的数据用 Wireshark 打开,过滤usb.transfer_type == 0x03(Bulk 传输),就能看到 CBW 和 CSW 的原始字节。对比一下你发出去的 CBW 和 U 盘返回的 CSW,问题基本一目了然。
6. 代码组织和工程化建议
6.1 分层架构怎么设计
自己实现 USB 读取,代码量不小,建议分成四层:
- USB 通信层:封装
UsbDeviceConnection、bulkTransfer、CBW/CSW 的构造和解析。这一层不涉及任何 SCSI 和文件系统逻辑。 - SCSI 命令层:封装 READ(10)、INQUIRY、REQUEST SENSE 等命令的构造和响应解析。这一层依赖 USB 通信层。
- 文件系统层:封装 FAT32/exFAT 的解析逻辑,对外提供
listFiles、readFile等接口。这一层依赖 SCSI 命令层。 - 业务层:App 的具体逻辑,比如导入照片、显示进度条等。
这样分层的好处是每一层都可以单独测试。比如 USB 通信层可以用一个模拟的UsbDeviceConnection来测,不需要真的插 U 盘。
6.2 线程模型和进度回调
USB 读写必须在子线程执行,我一般用一个单独的HandlerThread或者ExecutorService。进度回调通过Handler发回主线程更新 UI。
public interface ReadProgressCallback { void onProgress(long current, long total); void onComplete(File result); void onError(String message); } private void readFileAsync(String path, ReadProgressCallback callback) { executor.execute(() -> { try { long totalSize = getFileSize(path); long read = 0; // ... 读取逻辑 while (read < totalSize) { // 读一个簇 read += clusterSize; long finalRead = read; mainHandler.post(() -> callback.onProgress(finalRead, totalSize)); } mainHandler.post(() -> callback.onComplete(resultFile)); } catch (Exception e) { mainHandler.post(() -> callback.onError(e.getMessage())); } }); }注意进度回调不要发得太频繁,否则主线程会被消息淹没。我的做法是每读 1% 或者每读 100ms 发一次,取两者中频率低的那个。
6.3 异常处理和资源释放
USB 资源必须严格释放,否则下次插入 U 盘会打不开。释放顺序是:
connection.releaseInterface(intf)connection.close()- 把
UsbDeviceConnection、UsbEndpoint的引用置空
建议用try-finally包起来:
UsbDeviceConnection connection = null; try { connection = usbManager.openDevice(device); // ... 操作 } finally { if (connection != null) { connection.releaseInterface(intf); connection.close(); } }另外,如果 App 被系统杀掉,USB 连接不会自动释放,需要重新插入 U 盘才能恢复。这个目前没有好的解决办法,只能提示用户重新插拔。
6.4 兼容性测试清单
不同品牌、不同容量的 U 盘行为差异很大,建议至少测试以下几种:
- 容量:4GB、16GB、64GB、128GB,覆盖 FAT32 和 exFAT。
- 品牌:金士顿、闪迪、三星、杂牌,杂牌最容易出问题。
- 接口:USB 2.0 和 USB 3.0,3.0 的 U 盘在 2.0 接口上可能降速。
- Android 版本:至少覆盖 Android 8、10、12、14。
- 设备类型:手机、平板、电视盒子,电视盒子的 USB 供电可能不足。
我实测下来,杂牌 U 盘的问题最多,主要是 CSW 返回的 residue 不对,或者 REQUEST SENSE 返回的 Sense Key 不规范。对于这类 U 盘,只能加更多的重试和容错逻辑。
7. 最后分享几个实战心得
写到这里,核心的技术点基本都覆盖了。最后分享几个我在实际项目中总结的小经验,都是文档里不会写的。
关于扇区大小:不要假设所有 U 盘都是 512 字节扇区。我遇到过 4096 字节扇区的 U 盘,READ(10) 的传输长度按 512 算会读不到数据。正确做法是先发 READ CAPACITY(10) 拿到实际扇区大小,再动态调整。
关于 LBA 溢出:READ(10) 的 LBA 是 32 位,最大寻址 2TB。如果你的 U 盘超过 2TB,必须用 READ(16)。虽然现在 2TB 的 U 盘还不多,但移动硬盘已经有很多超过 2TB 的了,代码里最好直接支持 READ(16)。
关于热插拔:U 盘拔出后,UsbDeviceConnection会失效,所有bulkTransfer都会返回 -1。建议注册一个BroadcastReceiver监听UsbManager.ACTION_USB_DEVICE_DETACHED,收到广播后立即停止所有读写操作,释放资源。
关于电量:USB Host 模式下,Android 设备要给 U 盘供电。如果 U 盘功耗大(比如机械移动硬盘),可能会导致设备电量快速下降,甚至触发过流保护。建议在读取大文件时插着充电器。
关于文件系统损坏:如果 U 盘在读写过程中被拔出,FAT 表可能会损坏。下次插入时,系统会提示"需要格式化"。这种情况只能让用户在电脑上修复,App 层面无解。所以读写过程中要提示用户不要拔出 U 盘。
这套方案我在三个项目里用过,最长的已经稳定运行了一年多,读取过上千个不同品牌的 U 盘。核心代码大概 2000 行左右,比引入 libaums 多写了不少,但换来的是完全可控的行为和更好的兼容性。如果你也在做类似的需求,希望这篇记录能帮你少踩几个坑。