☰
Android原生USB协议实现U盘读取:绕过libaums的BOT与SCSI实战
2026/11/4 11:03:06 网站建设 项目流程

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 字节,字段如下:

偏移长度字段说明
04dCBWSignature固定 0x43425355("USBC"小端)
44dCBWTag命令标签,CSW 会原样返回
84dCBWDataTransferLength本次传输的数据字节数
121bmCBWFlags0x80 表示数据从设备到主机(IN)
131bCBWLUN逻辑单元号,U 盘一般是 0
141bCBWCBLength后面 SCSI 命令的长度,6/10/12/16
1516CBWCBSCSI 命令本体

用 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 命令,标准流程是:

  1. 通过 Bulk-Out 端点发 31 字节 CBW。
  2. 如果有数据阶段,通过 Bulk-In(读)或 Bulk-Out(写)传输数据。
  3. 通过 Bulk-In 端点读 13 字节 CSW。

CSW 结构:

偏移长度字段说明
04dCSWSignature固定 0x53425355("USBS"小端)
44dCSWTag必须和 CBW 的 tag 一致
84dCSWDataResidue剩余未传输的数据量
121bCSWStatus0 成功,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 字节:

字节值说明
00x28操作码
10x00保留 + LUN
2-5LBA起始逻辑块地址(大端)
60x00保留
7-8传输长度读多少个扇区(大端)
90x00控制

拼命令:

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)。关键字段:

偏移长度字段用途
112每扇区字节数通常 512
131每簇扇区数计算簇大小
142保留扇区数FAT 表起始位置
161FAT 表数量通常 2
172根目录项数FAT16 用,FAT32 为 0
192总扇区数(小)
324总扇区数(大)
364每 FAT 扇区数
444根目录起始簇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 字节:

偏移长度说明
08短文件名(8.3 格式)
83扩展名
111属性(0x10 目录,0x20 归档,0x0F 长文件名)
202起始簇号高 16 位
262起始簇号低 16 位
284文件大小

短文件名的第一个字节如果是 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 这类问题,抓包一眼就能定位。

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

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

立即咨询