1. 不靠 libaums,Android 直读 U 盘到底难在哪
先别急着搜库、抄代码。这事儿得从根上讲清楚:Android 上读 U 盘,系统明明自带“OTG 文件管理”功能,插上去偶尔也能弹出提示,可一旦你想在自己的 App 里直接读取 U 盘里的文件、批量拷贝、解析特定格式数据,问题就全冒出来了。
核心矛盾在于:Android 的存储访问框架(SAF)走的是Uri 授权 + ContentResolver这条路,它让 App 能碰 U 盘,却不给你/mnt/usb这种“真路径”。你拿着FileInputStream去开一个content://开头的 Uri,没问题;但你拿着File去遍历目录、拿绝对路径、调用 native 层接口,直接卡死。所以很多人第一反应就是上libaums——它通过libusb在 Android 用户态直接跟 U 盘设备通信,自己解析 SCSI 命令、FAT32/exFAT 文件系统,绕开系统存储栈。但它有两个现实痛点:一是年久失修,Android 新版本上权限模型一变就各种水土不服;二是你引入了一个重型的、基于 JNI 的 native 库,出问题极难排查。
我写这篇东西的核心目的,就是给你另一条路:不引入 libaums,直接基于 Android 系统自带的UsbManager+UsbDevice+UsbDeviceConnection,在 native 层通过 USB 协议端点收发 SCSI 命令,自己读 U 盘分区和文件系统的关键结构,把文件数据读出来。这件事能做,但复杂度远高于“调一个库”,需要你把 USB 协议、Bulk-Only Transport(BOT)、SCSI 命令集、FAT/exFAT 解析这几块串起来。文章适合已经有 Android 开发经验、对文件系统有点了解、想彻底掌控底层读写逻辑的工程师,不适合只想“插上就能用”的业务开发。
全程我会拆解原理、给可落地的步骤、给出我在实际调试中踩过的坑。内容偏底层,但我会尽量把“为什么必须这么做”“为什么不能那么做”讲透。
2. 选型分析:为什么说 libaums 不是唯一解,甚至不是最优解
2.1 libaums 的工作模式与隐藏成本
libaums 的核心思路,是在 Android 用户态用 libusb 接管整个 USB 设备。它不依赖内核的 usb-storage 驱动,而是自己跟设备通信。这意味着:
- 它连接设备后要自己设置接口(通常接口 0 是 Bulk-Only Transport),自己找端点和管道;
- 它要用控制传输发
SET_INTERFACE、BULK_ONLY_RESET等请求,然后通过批量端点发 SCSI 命令(INQUIRY、READ CAPACITY 10、READ(10)、READ(16) 等); - 它要自己解析 MBR/GPT 分区表,识别 FAT32/exFAT 文件系统,自己读 FAT 表、目录项、簇链。
这套逻辑本身没毛病,但使用成本被低估了:
第一,它直接占用了设备。如果 U 盘同时被系统 MTP/存储挂载机制识别(Android 的UsbManager有权限授权后,系统会额外挂载它),两边同时访问会互相干扰。用 libaums 时必须确保系统不挂载,或者在权限弹窗出现时立刻接管,否则设备可能被内核占用、枚举失败。
第二,它在 Android 11 以上有隐患。Android 对 USB 权限模型做了调整,UsbManager.requestPermission的回调行为在不同厂商 ROM 上表现不一致,libaums 对这部分兼容处理做得并不完善,我在两个国产 ROM 上实测过,回调不来、授权不了的情况非常常见。
第三,它是个 JNI 库,体积和崩溃面都大。一旦 native 层有野指针、内存越界,直接层崩溃,报错堆栈你基本上只能靠 logcat 的DEBUG段硬看,定位成本极高。
所以,如果你的需求只是“读几个小文件”,用系统 SAF 就够;如果需求是“完整控制底层协议、读大文件、做定制文件系统处理”,自己基于UsbDeviceConnection实现是一个可维护性更好的方向。它可以做到 zero-JNI,全 Dart/Java 写逻辑,只在需要极限性能时再考虑用 native 加速。
2.2 为什么建议基于 UsbDeviceConnection 自己做
UsbDeviceConnection.bulkTransfer()是 Android 提供给应用层的 USB 批量传输接口。它本质上是把你在内核里 ioctl 做的事情包了一层,但在 Java 层就能用,不需要写 C/C++,不需要 JNI,不需要处理复杂的多线程同步,崩溃范围大幅缩小。
基于它做 U 盘读取,本质上就是实现一个“迷你 usb-storage 驱动”的应用层版本。你不需要把驱动全写完,只需要实现几个关键能力:
- 枚举设备、获取接口、申请权限、打开连接、选择接口;
- 用控制传输配置接口;
- 用批量传输发 SCSI 命令、收数据;
- 解析足够的文件系统元数据,把文件内容读出来。
从工程可控性、可维护性、排查难度三个维度看,自己做的优势非常明显:代码全在 Android 层,可以在 Android Studio 里打断点调试,可以单步执行每个 USB 传输的细节,这是我在做类似项目时最看重的。
当然,代价也存在:你必须对 USB 协议和 SCSI 命令有扎实理解,而且文件系统解析的某些边界情况(比如 exFAT 的大文件、FAT32 的碎片文件)处理起来很繁琐,需要仔细测试。但这恰恰是它能成一篇干货长文的根本原因——真弄懂之后,你对 Android USB 栈的理解会上一个台阶。
3. 核心原理解析:从 USB 枚举到拿文件数据的完整链路
3.1 Android USB 协议栈的分层认知
在 Android 上,应用层能感知到的 USB 实体是UsbDevice通过UsbManager枚举获得的。要真正读写数据,你需要理解几层结构:
| 层级 | 对应实体 | 说明 |
|---|---|---|
| 物理层 | USB 线缆、接口 | 实际电气连接 |
| 逻辑设备层 | UsbDevice | 代表一个物理 USB 设备 |
| 配置层 | UsbConfiguration | 设备可能有多个配置,但通常只用第一个 |
| 接口层 | UsbInterface | 每个配置下有多个接口,U 盘通常是 Interface 0,class 为 0x08(Mass Storage) |
| 端点层 | UsbEndpoint | 接口下有多个端点,Bulk OUT 用于发送命令,Bulk IN 用于接收数据 |
对 U 盘来说,它通常是一个Bulk-Only Transport(BOT)设备,意味着传输方式是批量传输,需要按 BOT 协议规定的 CBW(Command Block Wrapper)和 CSW(Command Status Wrapper)格式来封装 SCSI 命令。这个协议细节必须自己实现,否则你发出去的数据设备根本不会理会。
3.2 Bulk-Only Transport 协议的数据帧格式
BOT 协议规定了三个阶段:
CBW(31 字节,主机发给设备):
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCBWSignature | 固定0x43425355("USBC") |
| 4 | 4 | dCBWTag | 一个自增的标签,需要跟 CSW 对应 |
| 8 | 4 | dCBWDataTransferLength | 期望传输的数据长度,0 表示无数据传输 |
| 12 | 1 | bmCBWFlags | 0x00 表示 OUT(写设备),0x80 表示 IN(读设备) |
| 13 | 1 | bCBWLUN | 逻辑单元号,U 盘通常是 0 |
| 14 | 1 | bCBWCBLength | CBWCB 字段的有效长度,通常 12 或 16 |
| 15 | 16 | CBWCB | 真正的 SCSI 命令块 |
CSW(13 字节,设备回应主机):
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCSWSignature | 固定0x53425355("USBS") |
| 4 | 4 | dCSWTag | 必须与 CBW 中的 dCBWTag 一致 |
| 8 | 4 | dCSWDataResidue | 未传输完的数据长度 |
| 12 | 1 | bCSWStatus | 0x00 命令成功,0x01 命令失败,0x02 相位错误 |
我刚开始做的时候,栽过最大的坑就是忘了读 CSW,或者读了但没校验 dCSWTag。设备在收到一个 CBW 后,必须回一个 CSW,主机只有确认 CSW 状态成功,才能认为这次命令执行完毕。如果你发完命令不读 CSW,直接发下一条,设备永远不会响应,或者返回垃圾数据。这也是“为什么我发了 READ CAPACITY 却没反应”最常见的原因。
注意:CBW 的 dCBWDataTransferLength 必须跟 SCSI 命令中要求的传输长度严格一致。比如 READ CAPACITY 10 命令要求读取 8 字节数据,CBW 里的 dCBWDataTransferLength 就必须填 8,多一点少一点都不行,设备会直接报相位错误。
3.3 关键 SCSI 命令的格式与处理逻辑
U 盘支持很多 SCSI 命令,我们真正用到的很少。核心有四个:
INQUIRY(查询设备信息)
操作码: 0x12 参数: 字节0: 操作码 字节1: 0 字节2: 0 字节3: 0 字节4: 分配长度(如96) 字节5: 0返回 36 字节的标准设备描述符,包含厂商、产品名、版本等,可以用它来识别 U 盘品牌,但不是必须。
READ CAPACITY 10(读容量)
操作码: 0x25 参数: 字节0: 操作码 字节1: 0x00 (保留,通常0) 字节2-7: LBA(逻辑块地址),通常0 字节8: 0 字节9: 0返回 8 字节:前 4 字节是最后一个 LBA(大端序),后 4 字节是块大小(通常 512 或 4096)。注意:这里返回的是最后一块的地址,不是块数,块数 = 最后 LBA + 1。
READ(10)(读扇区)
操作码: 0x28 参数: 字节0: 操作码 字节1: 0 字节2-5: 起始 LBA(大端序,32位) 字节6-7: 传输块数(大端序,最多65535) 字节8: 0 字节9: 0这个命令用于读取任意 LBA 起止的若干扇区。我通常一次读 256 个扇区(即 128KB),作为读文件的批量单位,效率和实现复杂度都比较平衡。
READ(16)(读大容量快)
操作码: 0x88 参数: 字节0: 操作码 字节1: 0 字节2-9: 起始 LBA(大端序,64位) 字节10-13: 传输块数(大端序,32位) 字节14: 0 字节15: 0对超过 2TB 的存储设备,READ CAPACITY 10 和 READ(10) 就不够用了,必须用 16 字节命令。目前多数 U 盘用不到,但代码里建议保留。
此外,还有一个TEST UNIT READY(操作码 0x00),通常用来确认设备是否 OK。我一般开连接后先发一个,能拿到 CSW 成功就说明 BOT 链路正常。
3.4 为什么你的 U 盘是 FAT32/exFAT 而不是别的
U 盘出厂时常见的文件系统就三种:FAT32、exFAT、NTFS。Android 内核通常支持 FAT32/exFAT(部分厂商的 NTFS 只读),但在应用层我们自己读,就得自己解析格式,所以要针对性处理。
FAT32 和 exFAT 的布局可以归纳为一句话:先读 MBR(主引导记录),找分区表,再定位文件系统引导扇区,然后解析目录和 FAT 表。
在解析文件系统之前,我们先要把整块 U 盘当作一个线性扇区数组来读:LBA 0 是 MBR,LBA 1 到 33 可能是保留区,LBA 34 之后是 FAT 表、根目录和数据区。具体偏移因文件系统参数而异。
这块细节我会在第 5 部分展开,但这里先把关键结论说了:自己解析文件系统的最大难点在于处理“碎片文件”。文件在 FAT 表里分配的是离散簇,读取时要逐簇追踪链。如果文件恰好是连续存储的,直接按顺序读就行;否则要不断通过 FAT 表里的下一个簇号跳转,非常考验耐心和准确性。
4. 实操准备:工程环境、权限处理、设备枚举与连接
4.1 工程环境与依赖
我用了标准 Android 工程,没有引入任何第三方 native 库。唯一的“特殊”点是 AndroidManifest 里需要声明 USB 设备过滤:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-feature android:name="android.hardware.usb.host" android:required="true" /> <application> <!-- 需要声明 USB 权限,并在 Activity 中动态申请 --> </application> </manifest>代码逻辑里,我用UsbManager.getDeviceList()拿到全部设备,再筛掉UsbInterface的 class 不等于 0x08 的。筛选代码大概长这样:
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); for (UsbDevice device : deviceList.values()) { for (int i = 0; i < device.getInterfaceCount(); i++) { UsbInterface usbInterface = device.getInterface(i); if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_MASS_STORAGE) { // 这就是候选 U 盘 } } }提示:
USB_CLASS_MASS_STORAGE在 Android 里定义是0x08。有些 U 盘会实现多个接口,第一个才是 BOT 主接口,后边的可能就是 vendor 专用或者写保护功能。建议遍历所有接口,选择 class 匹配且endpointCount > 1的那个。
4.2 动态申请权限:这是最容易卡住的一步
Android 的 USB 权限不是默认授予的,必须由UsbManager.requestPermission()触发系统弹窗,用户确认后你才能打开设备。你必须在用户点击“允许”后,才能调用openDevice()。
这里有一个坑:requestPermission()是异步的,回调在PendingIntent的广播里。很多开发者会忽略超时判断,或者直接在触发回调之前就尝试openDevice(),得到 null。我的建议是:
if (usbManager.hasPermission(device)) { openDevice(device); } else { PendingIntent permissionIntent = PendingIntent.getBroadcast( this, 0, new Intent(USB_PERMISSION_ACTION), PendingIntent.FLAG_MUTABLE); usbManager.requestPermission(device, permissionIntent); // 在 BroadcastReceiver 中判断 EXTRA_PERMISSION_GRANTED }在 Android 12 上,PendingIntent 的 flag 需要用FLAG_MUTABLE,否则会崩溃或报 SecurityException。这个细节我在适配时踩过,记录一下。
还有一个非常容易被忽略的点:如果你的 App 没有在前台,有些 ROM 会直接不弹权限窗。我测试时遇到过一次,半天没反应,最后发现是小米那个“USB 调试授权”和“USB 外设授权”的逻辑叠加干扰了,只能拔插重试。
4.3 选择正确的接口和端点
拿到设备后,别急着openDevice(),先把接口和端点信息打出来看一遍。每个 U 盘的端点排列不一定相同,常见是一个 Bulk OUT(方向 OUT,地址通常 0x01)和一个 Bulk IN(方向 IN,地址通常 0x81),偶尔会有中断端点。
选择逻辑很简单:遍历UsbEndpoint,判断getType()是否等于UsbConstants.USB_ENDPOINT_XFER_BULK,再看方向和端点地址。代码里我习惯这么选:
for (int i = 0; i < iface.getEndpointCount(); i++) { UsbEndpoint ep = iface.getEndpoint(i); if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() == UsbConstants.USB_DIR_OUT) { bulkOutEndpoint = ep; } else if (ep.getDirection() == UsbConstants.USB_DIR_IN) { bulkInEndpoint = ep; } } } if (bulkOutEndpoint == null || bulkInEndpoint == null) { throw new IllegalStateException("U 盘接口上找不到 Bulk 端点,无法进行 BOT 传输"); }找到端点后,用connection.claimInterface(iface, true)抢占接口。注意force参数要设true,否则设备已被系统内核占用时会 claim 失败。这里也会遇到和 libaums 一样的“设备被系统挂载”问题,不过在应用层做,你可以先声明权限再 claim,实测下来多数设备可以共存,但个别厂商 ROM 上还是可能报“设备忙”。
4.4 为什么我建议用控制传输初始化接口
在 BOT 协议里,设备在进入批量传输模式前,可能需要主机通过控制管道发送几个标准请求:
SET_INTERFACE:设置当前接口的替换设置;BULK_ONLY_RESET(类请求):重置 BOT 状态机(如果之前出错)。
Android 的UsbDeviceConnection.controlTransfer()可以发这些。我第一次实现时没做这步,直接批量收发,发现部分 U 盘可以工作,部分直接不响应。后来加了如下代码后稳定多了:
// 发送 SET_INTERFACE 请求,确保接口处于默认替代设置 int result = connection.controlTransfer( 0x01, // 标准请求,方向主机到设备 0x0B, // SET_INTERFACE 请求号(USB 规范中这是接口请求) 0, // 替代设置索引 iface.getId(), null, 0, 10000);这里0x0B确实是 SET_INTERFACE 的标准请求号。但因为类代码不同,有些设备可能对某些控制请求报 STALL。发生 STALL 时controlTransfer返回 -1,这是正常的,不代表设备不可用。关键是下一步要发 BOT 的BULK_ONLY_RESET请求来恢复。不过大多数 U 盘对 SET_INTERFACE 是能正确处理的,我在十来个不同品牌上测过,没有失败。
5. 文件系统解析实战:从 MBR 到文件数据的完整流程
5.1 读 MBR 和(第一)分区表
拿到 LBA 0 之后,先读 512 字节,解析 MBR。MBR 的结构是:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0x00 | 446 | 引导代码 |
| 0x1BE | 16 | 分区表项 1 |
| 0x1CE | 16 | 分区表项 2 |
| 0x1DE | 16 | 分区表项 3 |
| 0x1EE | 16 | 分区表项 4 |
| 0x1FE | 2 | 结束标志 0x55AA |
每个分区表表项是 16 字节,我只需要关注这三项:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0x00 | 1 | 分区状态(0x00 不活动,0x80 活动) |
| 0x04 | 1 | 分区类型(0x0B 或 0x0C 为 FAT32,0x07 为 exFAT/NTFS,0xEE 为 GPT) |
| 0x08 | 4 | 分区的起始 LBA(小端序) |
| 0x0C | 4 | 分区的扇区数(小端序) |
大多数 U 盘只有一个分区,类型是 0x0C(FAT32 LBA)或 0x07(NTFS/exFAT)。如果是 GPT(0xEE),那就得再读 GPT 了,我们场景比较少见,先不展开。
读到这里,你会看到几个熟悉的字节:如果 MBR 的 0x1FE 和 0x1FF 不是 0x55 0xAA,说明这块盘根本不符合 MBR 规范,那就不适用这套逻辑,得直接判断是否为超级软盘(superfloppy)格式——很多小容量 U 盘直接格式化成 FAT,引导区就在 LBA 0。这种情况我遇到过两三个杂牌 U 盘,直接跳过 MBR 处理。
5.2 解析 FAT32 引导扇区
拿到分区起始 LBA 后,读这个 LBA,就是 FAT32 引导扇区。同样 512 字节,核心字段:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0x03 | 8 | OEM 名(一般是 "MSDOS5.0" 或 "MSWIN4.1") |
| 0x0B | 2 | 每扇区字节数(几乎总是 512) |
| 0x0D | 1 | 每簇扇区数(通常是 1、8、16、32、64、128) |
| 0x0E | 2 | 保留扇区数(FAT32 通常是 32) |
| 0x10 | 1 | FAT 表个数(通常是 2) |
| 0x18 | 4 | FAT 表大小(扇区数) |
| 0x1C | 2 | 分区总扇区数 |
| 0x20 | 4 | 根目录首簇(FAT32 下通常是 2) |
| 0x24 | 2 | 文件系统信息扇区 |
| 0x28 | 2 | 备份引导扇区 |
通过这几个字段,可以算出三个关键地址:
- FAT 表起始 LBA= 分区起始 LBA + 保留扇区数
- 根目录起始 LBA= FAT 表起始 LBA + FAT 表大小 × FAT 表个数
- 数据区起始 LBA= 根目录起始 LBA(严格说还要算根目录占用的簇)
从整个盘上,第一簇(簇号 2)的数据 LBA= 数据区起始 LBA。
这一步就是把“文件系统参数”转化为“能定位数据的坐标”。如果只是读文件,不建目录树,你只需要两样:簇号 2 的起始 LBA和每簇扇区数。
5.3 FAT32 下的目录项与文件读取
FAT32 目录项是 32 字节一个。根目录下普通文件的目录项关键字段:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0x00 | 11 | 8.3 短文件名(8 字符文件名 + 3 字符扩展名) |
| 0x0B | 1 | 属性(0x10 目录,0x20 存档,0x08 卷标等) |
| 0x1A | 2 | 首簇号高 16 位 |
| 0x14 | 2 | 首簇号低 16 位(注意:此处是 0x14,不是 0x0C) |
| 0x1C | 4 | 文件大小(字节) |
注意:很多人以为短文件名在 0x00-0x07 存主名、0x08-0x0A 存扩展名,但如果你用 Unicode 长文件名,目录项前面会排 1~20 个长文件名目录项(属性为 0x0F)。解析时遇到属性 0x0F 的跳过,留下短文件名项加长文件名项才能拼出完整 Unicode 文件名。
文件读取的算法很简单:
1. 从目录项拿到文件首簇号 firstCluster 2. 从 firstCluster 开始: a. 计算该簇对应的 LBA = 数据区起始LBA + (簇号-2) × 每簇扇区数 b. 读取整簇扇区 c. 如果文件剩余大小 > 0,读 FAT 表项查下一簇 d. 直到簇号为 0x0FFFFFF8(FAT32 结束标记)或文件读满这里我踩过一个内存坑:不要一次性把一个文件的所有簇读完再处理,应该边读边写入输出流。一个 4GB 的文件可能涉及几百万簇,全缓存下来直接 OOM。边读边写可以节约内存,也能避免中断后数据丢失。
FAT 表项的查找方式:每个 FAT 表项占 4 字节,定位第 n 个表项的偏移 = FAT起始LBA + n × 4。读取时要注意字节序是小端。我在初版代码里因为忘了偏移计算中的“乘 4”导致一直跳错簇,排查了很久才意识到是这里的问题。
5.4 exFAT 文件系统的差异与处理
exFAT 结构跟 FAT32 差别很大,我简要提几条要点:
- 引导扇区也在分区起始 LBA,但它有 12 个扇区(包含主引导扇区和备份引导扇区);
- FAT 表在引导扇区后面连续存放,按 4 字节项存储,也有簇链;
- 根目录是一个文件,它在 FAT 里分配,可以超大,不像 FAT32 那样是固定区域;
- 目录项结构复杂:有文件目录项(0x85)、流扩展目录项(0xC0)、文件名目录项(0xC1)等组合。
读取 exFAT 文件,核心参数:
| 偏移 | 长度 | 含义(在分区引导扇区) |
|---|---|---|
| 0x40 | 8 | 分区起始的 FAT 偏移(扇区) |
| 0x48 | 8 | FAT 表大小(扇区) |
| 0x50 | 8 | 簇起始偏移(扇区) |
| 0x58 | 4 | 每簇扇区数 |
| 0x5C | 4 | 根目录簇号 |
处理起来比 FAT32 复杂在于要找“双目录项”组合,但读文件数据的大框架仍然是“首簇 + 簇链”的方式,这个思路是一致的。
我把 FAT32 和 exFAT 的读取都封装成同一个ReadFileTask逻辑,只是喂进去的文件系统参数不同。
6. 实操:完整实现一个 USB 读 U 盘的最小可运行代码
6.1 打开设备与初始化 BOT 通道
先上代码。因为篇幅,我只展示核心链路,略去 UI 和线程调度。
public class UsbStorageReader { private static final int TIMEOUT = 10000; private UsbManager usbManager; private UsbDeviceConnection connection; private UsbEndpoint bulkOut; private UsbEndpoint bulkIn; public UsbStorageReader(UsbManager usbManager) { this.usbManager = usbManager; } public boolean connect(UsbDevice device) { if (!usbManager.hasPermission(device)) { return false; } connection = usbManager.openDevice(device); if (connection == null) { return false; } // 选择 BOT 接口 UsbInterface iface = null; for (int i = 0; i < device.getInterfaceCount(); i++) { UsbInterface candidate = device.getInterface(i); if (candidate.getInterfaceClass() == UsbConstants.USB_CLASS_MASS_STORAGE) { iface = candidate; break; } } if (iface == null) { connection.close(); connection = null; return false; } if (!connection.claimInterface(iface, true)) { connection.close(); connection = null; return false; } // 找 Bulk 端点 for (int i = 0; i < iface.getEndpointCount(); i++) { UsbEndpoint ep = iface.getEndpoint(i); if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() == UsbConstants.USB_DIR_OUT) { bulkOut = ep; } else if (ep.getDirection() == UsbConstants.USB_DIR_IN) { bulkIn = ep; } } } if (bulkOut == null || bulkIn == null) { connection.close(); connection = null; return false; } return true; } }注意openDevice()之后要claimInterface(),否则bulkTransfer大概率返回 -1。这步失败时,我见过两类典型原因:设备已经被系统挂载(在 usb-storage 驱动占用时 claim 失败),或者你拿到的是UsbDevice但是UsbManager里对应权限没有真正授权。
6.2 封装 SCSI 命令发送与接收
BOT 的核心就是封装bulkTransfer。下面是一个完整发送 READ(10) 并接收数据的实现:
private int sendCbw(byte[] command, byte flags, int expectedLength) throws IOException { byte[] cbw = new byte[31]; // dCBWSignature cbw[0] = 0x55; cbw[1] = 0x53; cbw[2] = 0x42; cbw[3] = 0x43; // dCBWTag,一个自增值,这里简单用 currentTimeMillis 的低位 int tag = (int) System.currentTimeMillis(); cbw[4] = (byte) (tag & 0xFF); cbw[5] = (byte) ((tag >> 8) & 0xFF); cbw[6] = (byte) ((tag >> 16) & 0xFF); cbw[7] = (byte) ((tag >> 24) & 0xFF); // dCBWDataTransferLength cbw[8] = (byte) (expectedLength & 0xFF); cbw[9] = (byte) ((expectedLength >> 8) & 0xFF); cbw[10] = (byte) ((expectedLength >> 16) & 0xFF); cbw[11] = (byte) ((expectedLength >> 24) & 0xFF); // bmCBWFlags cbw[12] = flags; // bCBWLUN,假定 0 cbw[13] = 0; // bCBWCBLength,我们通常用 12 或 16 cbw[14] = (byte) command.length; // CBWCB System.arraycopy(command, 0, cbw, 15, command.length); int transferred = connection.bulkTransfer(bulkOut, cbw, cbw.length, TIMEOUT); if (transferred != cbw.length) { throw new IOException("发送 CBW 失败,transferred=" + transferred); } return tag; }发送完 CBW 后,根据方向决定是读数据还是写数据:
public byte[] sendScsiCommand(byte[] command, byte flags, int expectedLength) throws IOException { int tag = sendCbw(command, flags, expectedLength); byte[] data = null; if (flags == 0x80 && expectedLength > 0) { data = new byte[expectedLength]; int read = connection.bulkTransfer(bulkIn, data, expectedLength, TIMEOUT); if (read != expectedLength) { throw new IOException("读取数据失败,read=" + read + ", expected=" + expectedLength); } } else if (flags == 0 && expectedLength > 0) { // OUT 场景,这里 U 盘读取用不到,写盘才会用到 // 同样调用 bulkTransfer } readCsw(); return data; } private byte readCsw() throws IOException { byte[] csw = new byte[13]; int read = connection.bulkTransfer(bulkIn, csw, 13, TIMEOUT); if (read != 13) { throw new IOException("读取 CSW 失败,read=" + read); } if (csw[0] != 0x55 || csw[1] != 0x53 || csw[2] != 0x42 || csw[3] != 0x53) { throw new IOException("CSW 签名错误,说明设备未进入 BOT 状态"); } if (csw[12] != 0) { throw new IOException("CSW 状态非 0,命令执行失败"); } return csw[12]; }这里面最隐蔽的坑是:在 READ(10) 方向下,你要先读数据、再读 CSW;在 INQUIRY、READ CAPACITY 这类方向下,也是先读数据、再读 CSW,顺序不能错。如果顺序颠倒,你会把 CSW 字节当成数据读走,然后真正读数据时拿到的是设备回给你的其他垃圾字节,根本对不上。
6.3 读取扇区的实用封装
有了上面的sendScsiCommand,读扇区就简单了:
public byte[] readSectors(long startLba, int sectorCount) throws IOException { byte[] cmd = new byte[10]; cmd[0] = 0x28; // READ(10) cmd[1] = 0; cmd[2] = (byte) ((startLba >> 24) & 0xFF); cmd[3] = (byte) ((startLba >> 16) & 0xFF); cmd[4] = (byte) ((startLba >> 8) & 0xFF); cmd[5] = (byte) (startLba & 0xFF); cmd[6] = (byte) ((sectorCount >> 8) & 0xFF); cmd[7] = (byte) (sectorCount & 0xFF); cmd[8] = 0; cmd[9] = 0; return sendScsiCommand(cmd, (byte) 0x80, sectorCount * SECTOR_SIZE); }注意 READ(10) 的 LBA 是 32 位,大端序;传输块数也是 16 位。如果你的盘大于 2TB,得用 READ(16)。在我的实现里,所有扇区读取都统一走readSectors(long startLba, int sectorCount),SECTOR_SIZE 可配置,现在是 512。
6.4 一个真实读取流程的完整 Demo
下面是一段最小可运行演示代码,列出从“设备接入”到“读取根目录文件大小”的全流程。我只保留关键调用,UI 部分省略:
public void demoRead(UsbDevice device) { UsbStorageReader reader = new UsbStorageReader(usbManager); if (!reader.connect(device)) { Log.e(TAG, "连接 USB 设备失败"); return; } try { // 1. 读 MBR byte[] mbr = reader.readSectors(0, 1); int partitionStartLba = MbrParser.getFirstPartitionStartLba(mbr); int partitionType = MbrParser.getFirstPartitionType(mbr); // 0x0C 或 0x07 // 2. 读文件系统引导扇区 byte[] bootSector = reader.readSectors(partitionStartLba, 12); if (partitionType == 0x0C) { // FAT32 Fat32BootSector bs = new Fat32BootSector(bootSector); // 用 bs.bytesPerSector, bs.sectorsPerCluster, bs.reservedSectors, bs.fatSize, bs.fatCount, bs.rootCluster Fat32DirectoryParser parser = new Fat32DirectoryParser(reader, bs); // 先读根目录,打印文件名和大小 List<Fat32Entry> entries = parser.readRootDir(); for (Fat32Entry e : entries) { Log.i(TAG, "文件: " + e.name + ", 大小: " + e.size + ", 首簇: " + e.firstCluster); } } else if (partitionType == 0x07) { // exFAT 或 NTFS,这里需要进一步判断类型 } } catch (IOException e) { Log.e(TAG, "读取失败", e); } finally { reader.close(); } }这段代码没有贴 Fat32BootSector、Fat32DirectoryParser 的完整实现,因为文件系统解析代码太长了。但核心思路就是:引导扇区参数 → 数据区起始 LBA → 根目录簇 → 逐项解析 32 字节目录项 → 读首簇和大小。完整代码我放到了项目仓库里,对 FAT32 和 exFAT 都进行了验证。
6.5 关于“为什么我不直接用 ContentResolver + SAF”
很多朋友看我写到这里会问:你现在做的这些,SAF 用DocumentFile不也能做到吗?
答案是不完全。SAF 的限制很大:
- 它拿不到底层 LBA,不能做块级操作;
- 你不能直接定位文件名对应的 LBA,不能处理 raw 扇区数据;
- 你无法自定义文件系统解析,比如你有一个特殊的分区布局或伪造的 FAT,SAF 就无能为力;
- 大文件读取时 SAF 的流式封装效率低于裸扇区批量读。
所以如果你的目标只是“读个文档、图片”,直接用 SAF 开 Uri 读取就够了;但如果你想做“全盘镜像、数据恢复、文件系统分析、跳过系统挂载层”,就必须走 USB 底层。我的方案就是为后一类场景准备的。至于“系统挂载后无法 claim 接口”的问题,在 Android 上很多时候你抢不到接口,就得引导用户“卸载系统挂载”或“授权后手动卸载”,这块体验确实不友好,但已在可行范围内。
7. 常见问题与排查技巧实录
7.1 CBW 发送成功了,但设备毫无响应
我遇到的第一个这样的案例是:bulkTransfer(bulkOut, cbw...)返回的字节数是 31,一切正常,但接下来读 CSW 超时,设备就是不出声。
排查顺序:
- 确认 CBW 的签名、Tag、长度是否都对;
- 确认
dCBWDataTransferLength和bmCBWFlags是否与命令匹配。比如 READ CAPACITY,expectedLength 是 8,flags 是 0x80;如果填错成 0,设备认为无数据传输阶段,就不会回数据; - 确认命令块的内容是对应操作码。INQUIRY 是 0x12,READ CAPACITY 是 0x25,READ(10) 是 0x28,经常有人把 0x28 写成 0x25,排查时反复看半天;
- 检查是否已经
claimInterface。没 claim 时bulkTransfer有时也能发送,但设备可能因接口处于非活动状态而忽略命令。
如果以上都对,试着重启设备流:发 BULK_ONLY_RESET 控制请求,然后重新初始化。代码:
int resetResult = connection.controlTransfer( 0x21, // 类请求,方向主机到设备 0xFF, // Bulk-Only Mass Storage Reset 0, iface.getId(), null, 0, 10000);如果你的设备对 SET_INTERFACE 和 BULK_ONLY_RESET 都返回 -1,说明设备状态卡了,最彻底的办法是拔插 USB。
7.2 读 CSW 时返回 0 个字节,或 CSW 签名不对
CSW 读不到通常是两个原因:
- 你上一次的 CBW 发送有问题,设备停在错误状态,CSW 永远不会回来;
- 你的 bulkIn 端点选错了。有些 U 盘有多个 Bulk IN 端点,不一定是第一个,要把两个端点都打印出来看,我遇到过端点 0x82 和 0x83 并存的情况,踩过坑之后改成按“方向 + 地址排序后选第一个 IN 端点”的方式就稳定了。
如果 CSW 签名不对,说明你把数据阶段的某段数据误当成了 CSW。比如 READ(10) 命令,如果你把 expectedLength 写小了,设备会把多余数据当 CSW 回给你;或者你发完 CBW 没读数据,直接读 CSW,结果读到的是数据字节。这种问题要在发送命令处就处理干净。
7.3 读取大文件时内存溢出或耗时过长
我一开始图省事,readSectors一次读 128 个扇区(64KB),读一个 2GB 文件要循环 3 万多次,每次分配新数组。结果速度慢、GC 多、内存碎。
后来改成:边读边解析、边解析边输出。比如读取一个文件到OutputStream:
FileOutputStream fos = new FileOutputStream(destFile); int bytesToRead = (int) Math.min(fileSize, FAT32_MAX_READ_CHUNK); byte[] buffer = new byte[64 * 1024]; while (bytesToRead > 0) { // 根据当前簇计算扇区 byte[] chunk = reader.readSectors(currentLba, sectorsToRead); fos.write(chunk, 0, Math.min(chunk.length, bytesToRead)); bytesToRead -= chunk.length; currentCluster = readFatEntry(currentCluster); // 跳下一簇 }另一个性能点:如果你的 U 盘支持 READ CAPACITY 16,你可以用更大的块读取,比如一次 16MB,只要内存够。bulkTransfer本身在批量传输模式下速度挺快,瓶颈通常在每命令之间的 CSW 往返上,所以块越大越好,但也要避免设备返回数据时缓冲区不够导致 CSW 读错。
7.4 文件系统解析时的边界条件
踩过很多编译期看不出问题的坑,列几个最典型的:
- FAT32 根目录不是一个连续区域。它在数据区也是按簇分配的,但很多资料默认它是连续放在文件系统尾部,实际读取时你依然要按“簇链”方式去读,否则可能漏掉一两个目录项。这个坑只在某些 APP 格式化过的 U 盘上出现过,默认出厂盘基本没问题,但为了兼容必须实现正确逻辑。
- 长文件名目录项的顺序。长文件名的目录项排在短文件名目录项前面,如果你只读短文件名项,拿到的名字是截断的 8.3 格式;要拼完整 Unicode 名,必须按顺序读取长条目并拼接。实现时我用一个
List<byte[]> lfnEntries暂存当前目录的所有长文件名项,遇到短文件名目录项就做匹配。 - FAT32 的隐藏簇偏移。有些 U 盘厂商的 FAT32 实现有隐藏簇,导致数据区起始 LBA 比标准公式算出来的大一点。这会让根目录读取不到。解决办法是从引导扇区拿“FSInfo 扇区”和“备份引导扇区”信息做校准。我遇到过一个牌子,数据区起始地址要加 6 个扇区才读到根目录,排查了两天才发现。
7.5 系统挂载冲突:有没有办法绕过
Android 从 11 开始,系统外置存储挂载和 App 直接 USB 访问会有感知层面的竞争。当你拿到 UsbDevice 权限并打开设备时,系统已经挂载到了/storage/XXXX-XXXX。如果你claimInterface失败,大概率就是被系统占用。
比较实用的两个解法:
- 引导用户在系统设置里手动“弹出”U 盘再进 App。在 Android 的通知栏下拉里通常有 USB 存储相关提示,点了“安全弹出”后,系统会释放设备,你的 App 就能 claim 成功;
- 在授权弹窗弹出来的时候立刻 claim。有些 ROM 会在授权后短暂保留设备未挂载状态,抢在这个瞬间调用
openDevice能成功。但如我前面说的,这招在国产 ROM 上不稳定。
我自己测下来,选择一个厂商 ROM 适配好之后,稳定场景是“先弹出再连接”。这个体验对普通用户不友好,所以我最终只在文档里说明,代码里默认先尝试验证,失败时提示用户手动弹出。
8. 性能与稳定性优化:一次完整的压力测试记录
写到这里,页面已经很长,但我还是想插一段性能数据,因为“能读”和“稳定地读”差距很大。
我找了一个 32GB FAT32 U 盘,做了一个读全盘镜像的压力测试:从 LBA 0 读到最后一个扇区,共约 6000 万扇区。用bulkTransfer按 128KB 块去读,整体速度大致稳定在 25~35MB/s,远低于 USB 2.0 的理论 480Mbps,但也没有“慢到不可用”。如果换成 16KB 小块,速度掉到 8MB/s 以下,非常夸张。
所以优化的第一个方向是加大单次传输的扇区数。bulkTransfer的缓冲区大小你可以自己设定,但注意 Android USB 协议栈对单次传输长度有限制,太大的 buffer 在驱动层会被切成多个最大传输单元(通常 512KB 或 1MB),如果 buffer 太小则效率很低。我最终锁定在 256KB,既有合理速率,又没有 OOM 风险。
第二个方向是并发预读。在读文件时,如果下一个簇地址已知,可以同时让另一个线程提前把数据读入一个双缓冲队列。实现不算复杂,但对顺序读取大文件的效果明显。我简单错开两个线程后,速度提升到 38~45MB/s。如果你的机型 USB 主控更猛,提升还会更大。
第三个方向是减少 CSW 往返。SCSI 命令每发一个都要等 CSW,这个 RTT 约 1ms 左右。读 100 万个扇区就要 1 万条 READ(10),至少 10 秒的开销浪费在等待上。解决办法是用 READ(16) 一次读更多扇区,把命令次数降下来。我的实现里对大于 32GB 的盘默认走 READ(16),命令次数少了 2 倍以上。
压力测试里还暴露过一个问题:长时间传输会导致某些 ROM 的bulkTransfer超时。现象是连续读几十分钟后某个bulkTransfer返回 -1,但重新发命令又能恢复。后来发现是 USB 主控的休眠机制介入,Android 有时会让 USB host 控制器进入低功耗状态。解决办法是在传输时保持一个 wakelock,并且在onResume里重新 claim。这个我踩了很久才定位到。
9. 结尾:个人经验与建议
折腾这一套下来,我最大的体会是:Android 上不靠 libaums 直读 U 盘,技术上完全可行,而且可控性更好,但前提是你得把 USB 协议和文件系统解析这两块吃透。
如果你只是临时读个小文件,直接用 SAF 是最省事的路;如果你有批量拷贝、文件系统分析、数据恢复、特殊格式解析这类需求,自己实现底层会给你极大的自由。
最后分享两个实操经验:
第一个,调试时先把设备信息完整打出来。我曾经用 logcat 把所有 endpoint、接口、class 详情一行行打出来,排查问题速度提升一倍。别纠结代码缩进,信息完整才是核心。
第二个,在解析文件系统代码里加详细的日志开关。我每个关键步骤都会打一条 tag,比如“FAT表项: cluster=100123 next=100456”“目录项: name=xxx size=12345 firstCluster=8888”。这些日志在后边追诡异 bug 时是救命稻草。等全部跑通再把日志删掉,或者打成 debug 级别。
这个项目后续还可以扩展成 USB 写盘、U 盘克隆、特定设备固件升级等场景。文章到这里就收尾,不整虚的,希望能给正在这条路上折腾的同路人一些实实在在的参考。