1. 为什么我要绕开 libaums 自己啃 USB 协议
Android 上读 U 盘这件事,绝大多数人的第一反应是引入 libaums。这个库确实成熟,封装了 Bulk-Only Transport(BOT)和 SCSI 命令集,几行代码就能枚举文件。但我在实际项目里遇到过几次它不够用的情况:一是需要对接一些非标准块设备,libaums 的 SCSI 命令封装太死,改起来要动源码;二是库体积和依赖链在某些定制 ROM 上会引入额外问题;三是我自己想把整条链路吃透,知道每一个 USB 包到底长什么样。
所以这篇东西的核心,就是不依赖任何第三方 U 盘库,直接用 Android 的UsbManager、UsbDeviceConnection、UsbEndpoint这套原生 API,配合自己实现的 BOT 协议和 SCSI 命令,把 U 盘里的数据读出来。关键词里提到的 Android、USB、Bulk-Only Transport、SCSI 就是这条链路的四个支柱。
适合谁看?如果你已经会用UsbManager做基本的设备枚举,但对"枚举完之后怎么发命令、怎么读扇区、怎么解析 FAT"没概念,这篇就是给你写的。如果你只是想快速读个文件,那 libaums 更省事;但如果你想理解 U 盘在 Android 上到底是怎么被"说话"的,往下看。
先说清楚整体链路,避免后面迷路:
- USB 层:Android 通过
UsbManager拿到设备,申请权限,打开UsbDeviceConnection,找到 Bulk-In 和 Bulk-Out 两个端点。 - 传输层:U 盘属于 USB Mass Storage 类,走的是 Bulk-Only Transport 协议。所有命令都封装成 CBW(Command Block Wrapper),数据走 Bulk 端点,最后用 CSW(Command Status Wrapper)收尾。
- 命令层:CBW 里装的是 SCSI 命令,读扇区用
READ(10),查容量用READ CAPACITY(10),查设备信息用INQUIRY。 - 文件系统层:拿到扇区数据后,按 FAT32 的 BPB、FAT 表、目录项结构去解析,才能看到文件名和内容。
这四层每一层都有坑,下面逐层拆。
2. USB 层的设备枚举与权限申请细节
2.1 从 UsbManager 拿到设备列表开始
Android 的 USB 主机模式(USB Host)从 API 12 开始支持,但真正稳定用起来是 API 21 以后。第一步是拿到UsbManager:
UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList();getDeviceList()返回的是当前连接的所有 USB 设备。U 盘的识别不能只看名字,要看接口类。U 盘的标准接口类是USB_CLASS_MASS_STORAGE,值是 8,子类是USB_SUBCLASS_SCSI(6),协议是USB_PROTOCOL_BULK_ONLY(0x50)。判断逻辑:
for (UsbDevice device : deviceList.values()) { for (int i = 0; i < device.getInterfaceCount(); i++) { UsbInterface intf = device.getInterface(i); if (intf.getInterfaceClass() == 8 && intf.getInterfaceSubclass() == 6 && intf.getInterfaceProtocol() == 0x50) { // 这就是一个 Bulk-Only 的 U 盘 } } }这里有个容易忽略的点:有些 U 盘会暴露多个接口,比如一个 Mass Storage 接口加一个 HID 接口(某些带加密功能的盘)。你要遍历所有接口,找到那个 class=8 的,而不是拿getInterface(0)就开干。
2.2 权限申请不是一次性的
Android 的 USB 权限是按设备授权的,而且默认不持久。你第一次插上 U 盘,hasPermission()返回 false,必须发一个PendingIntent让系统弹框:
if (!usbManager.hasPermission(device)) { PendingIntent pi = PendingIntent.getBroadcast(context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(device, pi); }实测下来有几个坑:
- FLAG_IMMUTABLE 必须加,Android 12 以后不加会直接崩。
- 权限回调是异步的,别在
requestPermission之后立刻openDevice,要等广播回来。 - 如果用户拔了再插,权限可能失效,要重新申请。我一般会在
onResume里重新检查一遍hasPermission。 - 有些 ROM 对同一 VID/PID 的设备会记住授权,但这不是标准行为,不能依赖。
2.3 打开连接与端点定位
拿到权限后:
UsbDeviceConnection connection = usbManager.openDevice(device); connection.claimInterface(intf, true); // force=true 强制抢占claimInterface的第二个参数force建议传 true。因为有些系统服务(比如媒体扫描)可能已经占了这个接口,不强制抢会返回 false。
然后找端点。Bulk-Only 的 U 盘一定有一对 Bulk 端点:一个 IN(设备到主机,读数据),一个 OUT(主机到设备,发命令)。方向判断:
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; } }注意:端点地址(
getAddress())的低 4 位是端点号,最高位是方向。有些开发者直接用地址判断方向,容易搞混,用getDirection()更稳。
到这一步,USB 层的准备工作就完成了。接下来才是真正的硬骨头——BOT 协议。
3. Bulk-Only Transport 协议的手工实现
3.1 CBW 的 31 字节结构拆解
Bulk-Only Transport 的核心是三个结构:CBW、数据阶段、CSW。CBW 固定 31 字节,字段如下:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCBWSignature | 固定 0x43425355("USBC"小端) |
| 4 | 4 | dCBWTag | 命令标签,CSW 会原样返回 |
| 8 | 4 | dCBWDataTransferLength | 本次传输的数据字节数 |
| 12 | 1 | bmCBWFlags | 0x80 表示数据从设备到主机(IN) |
| 13 | 1 | bCBWLUN | 逻辑单元号,U 盘一般是 0 |
| 14 | 1 | bCBWCBLength | 后面 SCSI 命令的长度,6/10/12/16 |
| 15 | 16 | CBWCB | SCSI 命令本体 |
用 Java 拼这个结构,注意全部小端序:
byte[] cbw = new byte[31]; writeInt(cbw, 0, 0x43425355); // signature writeInt(cbw, 4, tag); // tag writeInt(cbw, 8, dataLength); // 数据长度 cbw[12] = (byte) (isRead ? 0x80 : 0x00); // 方向 cbw[13] = 0; // LUN cbw[14] = (byte) scsiCmd.length; // SCSI 命令长度 System.arraycopy(scsiCmd, 0, cbw, 15, scsiCmd.length);writeInt要自己写小端序,Java 的ByteBuffer默认是大端,用ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)也行,但我习惯手写,可控。
3.2 一次完整的命令往返流程
发一条 SCSI 命令,标准流程是:
- 通过 Bulk-Out 端点发 31 字节 CBW。
- 如果有数据阶段,通过 Bulk-In(读)或 Bulk-Out(写)传输数据。
- 通过 Bulk-In 端点读 13 字节 CSW。
CSW 结构:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCSWSignature | 固定 0x53425355("USBS"小端) |
| 4 | 4 | dCSWTag | 必须和 CBW 的 tag 一致 |
| 8 | 4 | dCSWDataResidue | 剩余未传输的数据量 |
| 12 | 1 | bCSWStatus | 0 成功,1 失败,2 阶段错误 |
代码骨架:
// 1. 发 CBW int sent = connection.bulkTransfer(epOut, cbw, 31, TIMEOUT); if (sent != 31) throw new IOException("CBW send failed"); // 2. 数据阶段 if (dataLength > 0) { if (isRead) { int read = connection.bulkTransfer(epIn, dataBuf, dataLength, TIMEOUT); // 注意:可能一次读不完,要循环 } else { connection.bulkTransfer(epOut, dataBuf, dataLength, TIMEOUT); } } // 3. 读 CSW byte[] csw = new byte[13]; int got = connection.bulkTransfer(epIn, csw, 13, TIMEOUT); if (got != 13) throw new IOException("CSW read failed");3.3 bulkTransfer 一次读不完怎么办
这是新手最容易翻车的地方。bulkTransfer的返回值是实际传输的字节数,不保证等于你请求的长度。尤其是读大块数据(比如 64KB 扇区)时,一次调用可能只返回几 KB。
正确做法是循环读,直到读满或超时:
int offset = 0; while (offset < dataLength) { int chunk = connection.bulkTransfer(epIn, Arrays.copyOfRange(dataBuf, offset, dataLength), dataLength - offset, TIMEOUT); if (chunk <= 0) throw new IOException("bulk read stalled at " + offset); offset += chunk; }实测心得:
bulkTransfer的 timeout 别设太短,我一般用 5000ms。有些廉价 U 盘响应慢,1000ms 会频繁超时。另外,如果 CSW 的dCSWDataResidue不为 0,说明数据没传完,要么重试要么报错,别当成功处理。
3.4 tag 的生成与校验
dCBWTag是个 4 字节的随机或递增值,作用是匹配 CBW 和 CSW。我一般用一个静态计数器递增,简单可靠。收到 CSW 后必须校验dCSWTag == 发出的 tag,不匹配说明命令和响应错位了,通常是上一次命令没清理干净。这时候要发一个REQUEST SENSE或者直接复位。
4. SCSI 命令集:读 U 盘真正用到的几条
4.1 INQUIRY:先确认设备身份
INQUIRY(0x12)是最安全的探测命令,返回 36 字节设备信息,包括厂商、型号、版本。CBW 的 CB 部分:
0x12, 0x00, 0x00, 0x00, 0x24, 0x00- 第 0 字节:操作码 0x12
- 第 4 字节:分配长度 0x24(36 字节)
- 第 5 字节:控制字节,一般 0
数据方向是 IN,dCBWDataTransferLength = 36。返回的数据里,偏移 8 开始是厂商(8 字节),16 开始是型号(16 字节),32 开始是版本(4 字节)。这一步能验证你的 BOT 实现是否正确,如果 INQUIRY 都读不出来,后面别想了。
4.2 READ CAPACITY(10):拿到扇区数和扇区大小
READ CAPACITY(10)(0x25)返回 8 字节:
- 前 4 字节:最后一个逻辑块地址(LBA),即总扇区数减 1
- 后 4 字节:逻辑块大小,通常是 512 或 4096
CB 部分:
0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00数据方向 IN,长度 8。解析:
long lastLba = readIntBE(data, 0); int blockSize = readIntBE(data, 4); long totalSectors = lastLba + 1; long capacityBytes = totalSectors * blockSize;注意这里是大端序,SCSI 命令的数据都是大端,和 USB 结构的小端正好相反,别搞混。
4.3 READ(10):按扇区读数据
READ(10)(0x28)是读扇区的核心命令,CB 是 10 字节:
| 字节 | 值 | 说明 |
|---|---|---|
| 0 | 0x28 | 操作码 |
| 1 | 0x00 | 保留 + LUN |
| 2-5 | LBA | 起始逻辑块地址(大端) |
| 6 | 0x00 | 保留 |
| 7-8 | 传输长度 | 读多少个扇区(大端) |
| 9 | 0x00 | 控制 |
拼命令:
byte[] cb = new byte[10]; cb[0] = 0x28; cb[2] = (byte) (lba >> 24); cb[3] = (byte) (lba >> 16); cb[4] = (byte) (lba >> 8); cb[5] = (byte) lba; cb[7] = (byte) (count >> 8); cb[8] = (byte) count;数据长度 =count * blockSize,方向 IN。比如读 1 个扇区(512 字节),dCBWDataTransferLength = 512。
踩坑记录:传输长度字段是扇区数不是字节数,我一开始填了字节数,结果读出来全是乱的。另外,一次读太多扇区(比如超过 64KB)有些 U 盘会直接报 CSW 失败,建议单次不超过 128 个扇区(64KB)。
4.4 REQUEST SENSE:出错时的救命稻草
当 CSW 的bCSWStatus是 1(失败)时,必须发REQUEST SENSE(0x03)拿具体错误码,否则下一次命令会一直失败。CB:
0x03, 0x00, 0x00, 0x00, 0x12, 0x00返回 18 字节,偏移 2 是 sense key,偏移 12 是 additional sense code。常见的:
- sense key 0x02:Not Ready,U 盘没准备好
- sense key 0x03:Medium Error,介质错误
- sense key 0x05:Illegal Request,命令参数不对
每次 CSW 失败后都要清 sense,这是 BOT 协议的硬性要求,不清的话后续命令全部失败。
5. 从扇区数据到文件:FAT32 解析的实操路径
5.1 读引导扇区,定位 BPB 关键字段
拿到扇区读写能力后,第一个扇区(LBA 0)就是引导扇区,里面是 BPB(BIOS Parameter Block)。关键字段:
| 偏移 | 长度 | 字段 | 用途 |
|---|---|---|---|
| 11 | 2 | 每扇区字节数 | 通常 512 |
| 13 | 1 | 每簇扇区数 | 计算簇大小 |
| 14 | 2 | 保留扇区数 | FAT 表起始位置 |
| 16 | 1 | FAT 表数量 | 通常 2 |
| 17 | 2 | 根目录项数 | FAT16 用,FAT32 为 0 |
| 19 | 2 | 总扇区数(小) | |
| 32 | 4 | 总扇区数(大) | |
| 36 | 4 | 每 FAT 扇区数 | |
| 44 | 4 | 根目录起始簇 | FAT32 通常是 2 |
解析代码:
byte[] boot = readSectors(0, 1); int bytesPerSector = readShortLE(boot, 11); int sectorsPerCluster = boot[13] & 0xFF; int reservedSectors = readShortLE(boot, 14); int numFats = boot[16] & 0xFF; long fatSizeSectors = readIntLE(boot, 36); long rootCluster = readIntLE(boot, 44);计算关键地址:
long fatStartSector = reservedSectors; long dataStartSector = reservedSectors + numFats * fatSizeSectors; long clusterSize = bytesPerSector * sectorsPerCluster;5.2 遍历根目录,找到目标文件
FAT32 的根目录是一个普通的簇链,起始簇号在 BPB 偏移 44。每个目录项 32 字节:
| 偏移 | 长度 | 说明 |
|---|---|---|
| 0 | 8 | 短文件名(8.3 格式) |
| 8 | 3 | 扩展名 |
| 11 | 1 | 属性(0x10 目录,0x20 归档,0x0F 长文件名) |
| 20 | 2 | 起始簇号高 16 位 |
| 26 | 2 | 起始簇号低 16 位 |
| 28 | 4 | 文件大小 |
短文件名的第一个字节如果是 0x00,表示后面没有更多目录项了;如果是 0xE5,表示这个项被删除。长文件名(LFN)用属性 0x0F 标记,需要把多个 LFN 项拼起来才是完整名字。
读目录的流程:
long cluster = rootCluster; while (cluster < 0x0FFFFFF8) { long sector = dataStartSector + (cluster - 2) * sectorsPerCluster; byte[] dirData = readSectors(sector, sectorsPerCluster); for (int i = 0; i < dirData.length; i += 32) { if ((dirData[i] & 0xFF) == 0x00) break; if ((dirData[i] & 0xFF) == 0xE5) continue; int attr = dirData[i + 11] & 0xFF; if (attr == 0x0F) { /* 收集 LFN */ continue; } // 解析短名和起始簇 } cluster = nextCluster(cluster); // 查 FAT 表 }5.3 顺着 FAT 表读文件内容
FAT 表就是一张簇号的链表。nextCluster的实现:
long fatOffset = cluster * 4; // FAT32 每项 4 字节 long fatSector = fatStartSector + fatOffset / bytesPerSector; int entryOffset = (int) (fatOffset % bytesPerSector); byte[] fatData = readSectors(fatSector, 1); long next = readIntLE(fatData, entryOffset) & 0x0FFFFFFF;读文件时,从起始簇开始,按簇读数据,然后查 FAT 表找下一个簇,直到遇到0x0FFFFFF8以上的结束标记。文件大小字段告诉你什么时候停,避免多读。
实操心得:FAT 表建议缓存到内存。一个 32GB 的 U 盘,FAT 表可能几 MB,每次查簇都读扇区会慢到无法忍受。我一般启动时把整个 FAT 表读进一个
int[],后续查簇直接数组访问,速度提升几十倍。
6. 实测中那些文档不会告诉你的坑
6.1 廉价 U 盘的 CSW 延迟与超时
我手上有几个杂牌 U 盘,READ(10)之后 CSW 要等 2-3 秒才回来。如果 timeout 设 1000ms,直接失败。但 timeout 设太大,界面又卡。我的做法是把 USB 操作放到独立线程,timeout 设 5000ms,主线程用回调更新进度。别在主线程做任何bulkTransfer,Android 会直接抛NetworkOnMainThreadException类似的限制。
6.2 扇区大小不一定是 512
大部分 U 盘是 512 字节扇区,但有些大容量盘(尤其是 4K 原生盘)是 4096。如果你硬编码 512,读出来的 BPB 全是错的。必须先用 READ CAPACITY 拿到 blockSize,后面所有计算都用这个值。
6.3 拔盘时的异常处理
用户随时可能拔盘,这时候bulkTransfer会返回 -1 或抛异常。如果不处理,应用直接崩。我的做法是:
- 每次
bulkTransfer检查返回值,小于 0 就抛自定义的UsbDisconnectedException。 - 在
BroadcastReceiver里监听ACTION_USB_DEVICE_DETACHED,收到后立即停止所有 IO 线程。 finally块里releaseInterface和close,别泄漏连接。
6.4 权限弹框的时机问题
requestPermission弹框是系统级的,如果用户在弹框时切到后台,回调可能丢失。我一般会在onResume里重新检查权限状态,而不是只依赖广播。另外,同一个设备短时间内反复申请权限,有些 ROM 会直接拒绝,要加个节流。
6.5 大端小端的反复横跳
这是最容易出错的地方,我列个对照表:
| 数据结构 | 字节序 |
|---|---|
| CBW/CSW 所有字段 | 小端 |
| SCSI 命令的 LBA、长度 | 大端 |
| SCSI 返回的数据 | 大端 |
| FAT BPB 字段 | 小端 |
| FAT 目录项簇号 | 小端 |
我踩过的坑:把 READ(10) 的 LBA 写成小端,结果读出来的扇区永远是错的,但 CSW 还返回成功,排查了半天。建议每个读写函数命名时带上 BE/LE 后缀,比如readIntBE、readShortLE,强迫自己每次都想一下。
7. 性能优化与代码组织建议
7.1 批量读扇区减少命令往返
一次READ(10)读 1 个扇区和读 128 个扇区,命令开销是一样的。解析 FAT 和目录时,尽量一次多读几个扇区。我实测读 64KB(128 扇区)在大多数 U 盘上是稳定的,再大就有盘会报错。可以做成自适应:先试 128,失败就减半重试。
7.2 用缓冲区池避免频繁分配
USB 传输的字节数组如果每次都new,GC 压力很大。我一般预分配几个固定大小的缓冲区(比如 64KB 一个),用ArrayBlockingQueue管理,用完归还。对于读 U 盘这种 IO 密集场景,能明显减少卡顿。
7.3 分层设计,别写成一坨
我的代码结构是这样的:
UsbMassStorageDevice:负责 USB 枚举、权限、端点、连接管理。BulkOnlyTransport:负责 CBW/CSW 拼装、命令往返、错误恢复。ScsiCommands:封装 INQUIRY、READ CAPACITY、READ(10)、REQUEST SENSE。Fat32FileSystem:负责 BPB 解析、FAT 表缓存、目录遍历、文件读取。
每层只依赖下一层,测试时可以单独 mock。比如BulkOnlyTransport可以注入一个假的UsbDeviceConnection,不用真插 U 盘就能测协议逻辑。
7.4 进度反馈与取消机制
读大文件时,用户需要知道进度。我在Fat32FileSystem里加了个回调接口,每读一个簇回调一次已读字节数。取消的话用一个volatile boolean标志,在簇循环里检查,收到取消就抛异常退出。别用Thread.interrupt,因为bulkTransfer是阻塞的,中断不一定能立刻生效。
8. 关于这套方案适用边界的个人体会
自己实现 USB U 盘读取,最大的价值不是"不用库",而是你完全掌控了每一个字节的流向。遇到非标准设备、需要定制 SCSI 命令、或者要对接特殊文件系统时,这套底层能力就是你的底气。
但它也有明确的边界。如果你只是要读个 FAT32 的普通 U 盘,libaums 确实更省事,它的错误恢复和边界处理经过大量设备验证。自己写的话,光是 CSW 失败后的 sense 清理、拔盘异常、不同扇区大小的兼容,就够调一阵子。
我的建议是:先用这套方案把 INQUIRY 和 READ CAPACITY 跑通,能正确打印出 U 盘的厂商、型号、容量,说明你的 BOT 和 SCSI 实现是对的。然后再往上叠 FAT 解析。别一上来就想着读文件,底层没通,上层全是玄学 bug。
另外,exFAT 和 NTFS 的解析复杂度比 FAT32 高一个量级,exFAT 的目录项是变长的,NTFS 有 MFT 和属性列表。如果你的场景需要支持这些,要么扩展解析层,要么老老实实回到成熟库。我目前只实现了 FAT32,exFAT 还在啃规范,等跑通了再单独写一篇。
最后分享一个调试技巧:用 USB 抓包工具看真实的 CBW/CSW 字节流。自己拼的结构对不对,抓一次包对比就知道了,比对着规范一行行数偏移快得多。尤其是 tag 不匹配、数据残留不为 0 这类问题,抓包一眼就能定位。