1. 为什么我要绕开 libaums 自己撸 USB 协议
Android 上读 U 盘这件事,绝大多数人第一反应是找现成库,libaums 确实是最常被提到的那个。它封装了 Bulk-Only Transport(BOT)和 SCSI 命令集,用起来几行代码就能枚举设备、挂载文件系统。但我在实际项目里踩过几次坑之后,逐渐转向了自己直接走 USB 协议栈。原因不复杂:libaums 对某些主控芯片的兼容性并不理想,尤其是那些 SCSI 命令响应不规范的山寨 U 盘,或者需要自定义超时、自定义重试策略的场景,库的抽象层反而成了阻碍。更关键的是,当你需要精确控制每一个 USB 包、每一次 SCSI 命令的交互时序时,黑盒库会让你在排查问题时无从下手。
这篇文章面向的是有一定 Android 开发基础、了解 USB 通信基本概念、但可能没深入接触过 USB 存储协议栈的开发者。我会从 Android USB Host API 的最底层开始,一步步拆解如何用 Bulk-Only Transport 协议和 SCSI 命令集,自己实现一个能读取 U 盘扇区数据、解析 FAT32 文件系统的完整方案。整个过程不依赖 libaums,也不依赖任何第三方 USB 库,只用 Android 系统提供的UsbManager、UsbDeviceConnection、UsbEndpoint这些原生 API。读完之后,你应该能理解 U 盘从插入到读出文件这中间到底发生了什么,并且能自己动手实现一套可控、可调试、可定制的 USB 存储读取逻辑。
2. 整体方案设计与核心思路拆解
2.1 为什么选择 Bulk-Only Transport 而不是其他传输方式
USB 存储设备在 USB 协议层面主要支持两种传输规范:Bulk-Only Transport(BOT)和 USB Attached SCSI(UAS)。BOT 是 USB 2.0 时代就定下来的标准,几乎所有 U 盘都支持,它的核心思想是把 SCSI 命令封装在 USB 的 Bulk 传输端点里,通过 CBW(Command Block Wrapper)和 CSW(Command Status Wrapper)两个结构体来完成命令的发送和状态的回收。UAS 则是 USB 3.0 之后引入的,支持多命令队列,性能更好,但兼容性不如 BOT,很多老设备和小厂主控根本不支持。
我选择 BOT 的理由很直接:兼容性优先。你永远不知道用户会插进来一个什么年代的 U 盘,BOT 是最大公约数。而且 BOT 的协议结构非常清晰,CBW 和 CSW 都是固定 31 字节和 13 字节的结构,解析起来没有歧义。UAS 虽然快,但它的命令队列管理和流式传输机制复杂得多,对于“读出 U 盘文件”这个需求来说,BOT 的性能完全够用。
2.2 Android USB Host API 的能力边界
Android 从 3.1 开始支持 USB Host 模式,提供了一套android.hardware.usb包下的 API。这套 API 能做的事情包括:枚举已连接的 USB 设备、请求权限、打开设备连接、获取接口和端点、通过 Bulk 端点收发数据。但它不提供任何文件系统层面的抽象,也不会自动帮你挂载 U 盘。你拿到的是一个原始的字节流通道,剩下的协议解析、命令交互、文件系统解析,全得自己来。
这里有个关键点:Android 的 USB Host API 只支持同步的bulkTransfer调用,没有异步版本。这意味着你必须在子线程里做所有 USB 通信,否则会阻塞主线程导致 ANR。而且bulkTransfer的超时参数需要谨慎设置,设太短容易在 U 盘响应慢时误判失败,设太长又会让整个读取流程卡住。
2.3 分层架构设计
我把整个方案分成四层,从下往上依次是:
- USB 传输层:负责与
UsbDeviceConnection交互,通过 Bulk 端点发送 CBW、接收 CSW 和数据。这一层要处理端点选择、超时控制、错误重试。 - BOT 协议层:负责构造 CBW、解析 CSW,管理命令的执行流程。每个 SCSI 命令都要经过“发送 CBW -> 传输数据 -> 接收 CSW”这个完整周期。
- SCSI 命令层:实现具体的 SCSI 命令,比如 INQUIRY、READ CAPACITY、READ(10)、WRITE(10) 等。这一层要处理命令描述块(CDB)的构造和响应数据的解析。
- 文件系统层:在能读写扇区的基础上,解析 FAT32 的引导扇区、FAT 表、目录项,最终实现按文件名读取文件内容。
这种分层的好处是每一层都可以独立测试和替换。比如你只想验证 USB 通信是否正常,可以只做到 BOT 层,发一个 INQUIRY 命令看看 U 盘返回的厂商信息。如果你想支持 exFAT 或 NTFS,只需要替换文件系统层,下面的三层完全不用动。
3. 核心细节解析与实操要点
3.1 USB 权限申请与设备枚举的坑
Android 上访问 USB 设备的第一步是枚举和权限申请。UsbManager.getDeviceList()会返回一个HashMap<String, UsbDevice>,key 是设备名,value 是UsbDevice对象。但这里有个容易被忽略的点:你拿到的UsbDevice列表可能包含很多非存储设备,比如 USB 鼠标、键盘、串口设备。你需要通过UsbDevice.getDeviceClass()、getDeviceSubclass()、getDeviceProtocol()来判断,或者更可靠的方式是遍历接口,看是否有USB_CLASS_MASS_STORAGE(值为 8)的接口。
权限申请用UsbManager.requestPermission(),它会弹出一个系统对话框让用户确认。这个对话框是系统级的,你无法自定义样式。而且如果用户在对话框弹出时直接拔了 U 盘,回调可能会收到一个异常状态,需要做好防护。我一般会在BroadcastReceiver里同时监听ACTION_USB_PERMISSION和UsbManager.ACTION_USB_DEVICE_DETACHED,确保设备拔出时能及时清理资源。
注意:
requestPermission的回调是在主线程的 BroadcastReceiver 里执行的,如果你在回调里直接做 USB 通信,会阻塞主线程。正确的做法是在回调里启动一个工作线程,把UsbDeviceConnection的打开和后续操作都放到子线程。
3.2 端点选择与接口配置
一个 USB 存储设备通常有一个配置(Configuration),里面包含一个接口(Interface),接口下有两个 Bulk 端点:一个 IN 端点用于读取数据,一个 OUT 端点用于发送数据。你需要通过UsbInterface.getEndpointCount()遍历端点,根据UsbEndpoint.getDirection()和getType()来区分。USB_ENDPOINT_XFER_BULK是 Bulk 类型,USB_DIR_IN和USB_DIR_OUT区分方向。
在打开设备之前,必须先调用UsbDeviceConnection.claimInterface()来独占这个接口。如果不 claim 就直接传输,会返回 -1 错误。claim 之后,在完成所有操作后要记得releaseInterface(),否则下次插入同一个 U 盘时可能会因为接口被占用而无法打开。
还有一个细节:有些 U 盘的接口有多个备用设置(Alternate Setting),默认的 setting 0 可能不是 Bulk 传输模式。你需要检查UsbInterface.getAlternateSetting(),必要时用setInterface()切换到正确的设置。不过大多数 U 盘默认就是对的,这个坑主要出现在一些复合设备上。
3.3 CBW 和 CSW 的结构与字节序
CBW 是 31 字节的固定结构,字段如下:
| 偏移 | 长度 | 字段名 | 说明 |
|---|---|---|---|
| 0 | 4 | dCBWSignature | 固定为 0x43425355,即 "USBC" 的小端表示 |
| 4 | 4 | dCBWTag | 命令标签,由主机生成,CSW 会原样返回 |
| 8 | 4 | dCBWDataTransferLength | 本次命令期望传输的数据长度 |
| 12 | 1 | bmCBWFlags | bit7 为 1 表示数据从设备到主机(IN),为 0 表示主机到设备(OUT) |
| 13 | 1 | bCBWLUN | 逻辑单元号,U 盘通常为 0 |
| 14 | 1 | bCBWCBLength | CDB 的长度,有效值 1-16 |
| 15 | 16 | CBWCB | 实际的 SCSI 命令描述块 |
CSW 是 13 字节:
| 偏移 | 长度 | 字段名 | 说明 |
|---|---|---|---|
| 0 | 4 | dCSWSignature | 固定为 0x53425355,即 "USBS" 的小端表示 |
| 4 | 4 | dCSWTag | 必须与 CBW 的 dCBWTag 一致 |
| 8 | 4 | dCSWDataResidue | 剩余未传输的数据长度 |
| 12 | 1 | bCSWStatus | 0 表示成功,1 表示失败,2 表示阶段错误 |
所有多字节字段都是小端字节序(Little-Endian)。在 Java 里,ByteBuffer默认就是大端序,所以你需要显式调用order(ByteOrder.LITTLE_ENDIAN)。这个细节如果搞错,U 盘会直接不响应或者返回错误状态。
3.4 SCSI 命令集的核心命令
要实现读 U 盘,至少需要以下几个 SCSI 命令:
- INQUIRY (0x12):查询设备信息,返回厂商、产品型号、版本等。这是最基础的命令,用来确认设备是否正常响应。
- TEST UNIT READY (0x00):检查设备是否就绪。在 U 盘刚插入时,可能需要反复发送这个命令直到返回成功。
- READ CAPACITY (0x25):读取设备容量,返回最后一个逻辑块地址(LBA)和块大小。U 盘通常块大小为 512 字节。
- READ (10) (0x28):从指定 LBA 读取指定数量的块。这是读取扇区数据的核心命令。
- WRITE (10) (0x2A):向指定 LBA 写入数据。如果需要格式化或写文件,这个命令必不可少。
- REQUEST SENSE (0x03):当命令失败时,获取详细的错误信息。
每个命令的 CDB 结构不同,但都是固定长度的字节数组。比如 READ(10) 的 CDB 是 10 字节:操作码 0x28,后面跟 4 字节的 LBA(大端序)、1 字节的保留位、2 字节的传输块数(大端序)、1 字节的控制位。注意 SCSI 命令内部的字节序是大端,而 CBW 和 CSW 的字段是小端,这个混用很容易搞错。
4. 实操过程与核心环节实现
4.1 环境准备与权限声明
在 AndroidManifest.xml 里需要声明 USB Host 特性:
<uses-feature android:name="android.hardware.usb.host" /> <uses-permission android:name="android.permission.USB_PERMISSION" />如果你的应用需要在前台服务里持续读取 U 盘,还需要FOREGROUND_SERVICE权限。对于 Android 10 及以上,如果只是读取 U 盘而不涉及存储权限,不需要申请READ_EXTERNAL_STORAGE,因为你是直接通过 USB 协议访问块设备,绕过了 Android 的存储访问框架。
4.2 打开设备与初始化连接
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); UsbDevice device = ...; // 从设备列表中找到的存储设备 UsbDeviceConnection connection = usbManager.openDevice(device); if (connection == null) { // 权限未授予或设备已被占用 return; } UsbInterface usbInterface = device.getInterface(0); connection.claimInterface(usbInterface, true); UsbEndpoint inEndpoint = null; UsbEndpoint outEndpoint = null; for (int i = 0; i < usbInterface.getEndpointCount(); i++) { UsbEndpoint ep = usbInterface.getEndpoint(i); if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() == UsbConstants.USB_DIR_IN) { inEndpoint = ep; } else { outEndpoint = ep; } } }claimInterface的第二个参数force设为 true 表示强制占用,即使接口已被其他驱动占用也会尝试抢占。对于 U 盘来说,Android 系统本身没有自动挂载,所以通常不会有冲突。
4.3 构造并发送 CBW
private byte[] buildCBW(int tag, int dataTransferLength, boolean directionIn, byte[] cdb) { ByteBuffer buffer = ByteBuffer.allocate(31); buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.putInt(0x43425355); // signature buffer.putInt(tag); buffer.putInt(dataTransferLength); buffer.put((byte) (directionIn ? 0x80 : 0x00)); buffer.put((byte) 0); // LUN buffer.put((byte) cdb.length); buffer.put(cdb); // 补齐到 16 字节 while (buffer.position() < 31) { buffer.put((byte) 0); } return buffer.array(); }发送 CBW 用connection.bulkTransfer(outEndpoint, cbw, cbw.length, timeout)。返回值是实际发送的字节数,正常应该是 31。如果返回 -1,说明发送失败,可能是端点方向搞反了或者设备已经断开。
4.4 接收 CSW 与状态判断
private CswResult receiveCSW(int expectedTag) { byte[] cswBuffer = new byte[13]; int received = connection.bulkTransfer(inEndpoint, cswBuffer, 13, 5000); if (received != 13) { return null; // 接收失败 } ByteBuffer buffer = ByteBuffer.wrap(cswBuffer); buffer.order(ByteOrder.LITTLE_ENDIAN); int signature = buffer.getInt(); int tag = buffer.getInt(); int residue = buffer.getInt(); byte status = buffer.get(); if (signature != 0x53425355 || tag != expectedTag) { return null; // 签名或标签不匹配 } return new CswResult(residue, status); }bCSWStatus为 0 表示命令成功执行。如果为 1,表示命令失败,需要发送 REQUEST SENSE 获取具体错误码。如果为 2,表示阶段错误,通常意味着数据传输阶段出了问题,比如主机期望传输的数据长度和实际不符。
4.5 读取扇区数据的完整流程
以读取 LBA 0 处的引导扇区为例:
public byte[] readSector(int lba, int sectorCount) { int tag = generateTag(); int dataLength = sectorCount * 512; // 构造 READ(10) CDB byte[] cdb = new byte[10]; cdb[0] = 0x28; // READ(10) cdb[1] = 0; cdb[2] = (byte) ((lba >> 24) & 0xFF); cdb[3] = (byte) ((lba >> 16) & 0xFF); cdb[4] = (byte) ((lba >> 8) & 0xFF); cdb[5] = (byte) (lba & 0xFF); cdb[6] = 0; cdb[7] = (byte) ((sectorCount >> 8) & 0xFF); cdb[8] = (byte) (sectorCount & 0xFF); cdb[9] = 0; // 发送 CBW byte[] cbw = buildCBW(tag, dataLength, true, cdb); int sent = connection.bulkTransfer(outEndpoint, cbw, cbw.length, 5000); if (sent != 31) return null; // 接收数据 byte[] data = new byte[dataLength]; int received = connection.bulkTransfer(inEndpoint, data, dataLength, 10000); if (received != dataLength) return null; // 接收 CSW CswResult csw = receiveCSW(tag); if (csw == null || csw.status != 0) return null; return data; }注意 LBA 和 sectorCount 在 CDB 里是大端序,所以移位操作是从高位到低位。而 CBW 里的 dataTransferLength 是小端序,由ByteBuffer的order设置来处理。
4.6 解析 FAT32 引导扇区
拿到 LBA 0 的 512 字节数据后,解析 FAT32 引导扇区:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 11 | 2 | 每扇区字节数(通常 512) |
| 13 | 1 | 每簇扇区数 |
| 14 | 2 | 保留扇区数 |
| 16 | 1 | FAT 表数量 |
| 32 | 4 | 总扇区数(FAT32 用偏移 32) |
| 44 | 4 | 根目录起始簇号(通常为 2) |
| 48 | 2 | FSInfo 扇区号 |
| 82 | 8 | 文件系统类型字符串 "FAT32 " |
关键计算:FAT 表起始扇区 = 保留扇区数;数据区起始扇区 = 保留扇区数 + FAT 表数量 × 每 FAT 扇区数;每 FAT 扇区数 = 总扇区数 × 4 / 每扇区字节数(FAT32 每个 FAT 项占 4 字节)。
4.7 读取目录项与文件内容
根目录的起始簇号通常是 2,对应的扇区号 = 数据区起始扇区 + (簇号 - 2) × 每簇扇区数。读取该扇区后,每 32 字节是一个目录项。目录项的第一个字节为 0x00 表示后面没有更多条目,为 0xE5 表示该条目已删除。属性字节偏移 11,0x0F 表示长文件名条目,0x20 表示普通文件,0x10 表示目录。
文件名在偏移 0-7(短名)和 8-10(扩展名),采用 OEM 编码。长文件名需要通过长文件名条目重组,每个长文件名条目包含 13 个 UTF-16 字符。文件起始簇号在偏移 20-21(高 16 位)和 26-27(低 16 位),文件大小在偏移 28-31。
读取文件内容时,从起始簇号开始,沿着 FAT 表链找到下一个簇,直到遇到 0x0FFFFFFF 结束标记。每个簇的数据通过 READ(10) 命令从对应的扇区读取。
5. 常见问题与排查技巧实录
5.1 bulkTransfer 返回 -1 的几种原因
这是最常见的问题,可能的原因包括:端点方向搞反了(IN 端点用于接收,OUT 端点用于发送)、接口没有正确 claim、超时时间太短、设备已经断开、数据长度超过了端点最大包大小。排查时先用 INQUIRY 命令测试最基本的通信,如果 INQUIRY 都失败,说明底层连接有问题。
5.2 CSW 状态为 1 时的错误定位
CSW 状态为 1 表示命令失败,但具体原因需要发送 REQUEST SENSE 命令获取。REQUEST SENSE 的 CDB 是 6 字节,返回的 sense data 里包含 sense key 和 additional sense code。常见的 sense key 有:0x02 表示未就绪,0x03 表示介质错误,0x05 表示非法请求。比如 READ CAPACITY 返回 sense key 0x02,通常是因为 U 盘还在初始化,需要等待几秒后重试。
5.3 大数据量传输时的分块策略
一次bulkTransfer能传输的最大数据量受端点最大包大小限制。虽然理论上可以一次请求很大的数据,但实际中如果数据量超过几 MB,很容易因为超时或缓冲区问题失败。我的做法是每次最多读取 64 个扇区(32KB),循环读取直到完成。这样既能保证成功率,又不会因为单次传输太大而卡住。
5.4 设备热插拔的处理
U 盘在读取过程中被拔出,bulkTransfer会返回 -1 或抛出异常。你需要在每个操作后检查连接状态,并在BroadcastReceiver里监听ACTION_USB_DEVICE_DETACHED。一旦检测到拔出,立即停止所有读取线程,释放接口,关闭连接。否则下次插入时可能会因为资源未释放而无法打开。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| openDevice 返回 null | 权限未授予 | 检查 requestPermission 回调 |
| claimInterface 失败 | 接口被占用 | 尝试 force=true 或重启设备 |
| bulkTransfer 返回 -1 | 端点方向错误 | 确认 IN/OUT 端点选择正确 |
| CSW 签名不匹配 | 字节序错误 | 检查 ByteBuffer 的 order 设置 |
| READ(10) 返回 sense key 0x02 | 设备未就绪 | 延迟重试,先发 TEST UNIT READY |
| 读取大文件时卡死 | 单次传输过大 | 分块读取,每块不超过 64 扇区 |
| 文件名乱码 | 编码问题 | 短名用 OEM 编码,长名用 UTF-16 |
5.6 实操心得:超时参数的设置
bulkTransfer的超时参数我一般这样设置:CBW 发送用 5000ms,数据接收用 10000ms,CSW 接收用 5000ms。对于 READ(10) 这种可能传输大量数据的命令,数据接收的超时时间要适当放宽。但也不能无限大,否则设备异常时线程会一直卡住。我通常会在外层再加一个总的超时控制,比如整个读取操作超过 30 秒就主动中断。
5.7 实操心得:tag 的生成与管理
CBW 的 tag 是一个 32 位整数,由主机生成,CSW 会原样返回。我习惯用一个 AtomicInteger 从 1 开始递增,每次发送命令前getAndIncrement()。这样可以确保每个命令的 tag 唯一,在接收 CSW 时能准确匹配。如果 tag 不匹配,说明 CSW 可能是上一个命令的残留,需要丢弃并重新接收。
5.8 实操心得:REQUEST SENSE 的正确用法
很多人在 CSW 状态为 1 时直接重试命令,这是不对的。正确的做法是先发送 REQUEST SENSE 清除设备的错误状态,然后再重试。如果不发 REQUEST SENSE,设备可能会一直返回错误状态,导致后续所有命令都失败。REQUEST SENSE 的返回数据里,sense key 在第一个字节的低 4 位,additional sense code 在第二个字节,additional sense code qualifier 在第三个字节。根据这些信息可以精确定位问题。
6. 性能优化与扩展思路
6.1 减少命令交互次数
每次 READ(10) 命令都需要发送 CBW、接收数据、接收 CSW 三个步骤,如果每次只读一个扇区,效率很低。我的做法是一次读取多个扇区,比如 64 个,这样一次命令就能拿到 32KB 数据。对于 FAT32 文件系统,目录项和 FAT 表通常连续存放,批量读取能显著减少命令次数。
6.2 缓存 FAT 表和目录项
FAT 表在读取文件时需要频繁访问,如果每次都从 U 盘读,性能会很差。我通常会在内存里缓存整个 FAT 表(对于 32GB 的 U 盘,FAT 表大约 4MB,完全可以接受),目录项也做一层缓存。这样读取文件时只需要从 U 盘读实际的数据簇,FAT 表的查询在内存里完成。
6.3 支持多分区与多种文件系统
有些 U 盘有多个分区,引导扇区里的分区表(MBR)在 LBA 0 的偏移 446 处,每个分区项 16 字节。解析分区表后,可以分别读取每个分区的引导扇区。文件系统方面,除了 FAT32,还可以扩展支持 exFAT 和 NTFS。exFAT 的引导扇区结构和 FAT32 不同,但 SCSI 命令层完全复用。NTFS 更复杂,需要解析 MFT,但原理是一样的。
6.4 写入功能的实现要点
写入和读取的流程类似,只是方向相反。WRITE(10) 命令的 CDB 操作码是 0x2A,数据方向是 OUT。需要注意的是,写入前要确保 U 盘没有写保护,并且要正确处理 CSW 的 residue 字段。如果 residue 不为 0,说明实际写入的数据长度和请求的不一致,需要根据 residue 调整后续操作。写入完成后,最好发送一个 SYNCHRONIZE CACHE (0x35) 命令,确保数据真正落盘。
6.5 调试技巧:用日志还原完整命令流
在开发阶段,我习惯把每次 CBW 的 tag、CDB 内容、数据长度、CSW 的状态和 residue 都打印出来。这样当出现问题时,可以对照 SCSI 规范逐字段检查。比如如果 CSW 的 residue 等于 dataTransferLength,说明数据完全没有传输,通常是端点方向搞反了。如果 residue 是一个非零但小于 dataTransferLength 的值,说明部分数据传输成功,可能是设备缓冲区满了或者超时了。
6.6 兼容性测试的注意事项
不同厂商的 U 盘在 SCSI 命令的响应上可能有细微差异。比如有些 U 盘对 INQUIRY 命令的响应数据长度不是标准的 36 字节,有些 U 盘在 READ CAPACITY 之前需要先发 TEST UNIT READY。我在测试时至少会覆盖三种不同主控的 U 盘:群联、慧荣、安国。如果条件允许,还会测试 USB 2.0 和 USB 3.0 两种接口的设备。测试的重点是命令的容错性,比如超时重试、错误恢复、热插拔处理。
6.7 从 U 盘读取到文件展示的完整链路
最后把整个链路串起来:用户插入 U 盘 -> 系统广播触发 -> 申请权限 -> 打开设备 -> claim 接口 -> 发送 INQUIRY 确认设备 -> 发送 READ CAPACITY 获取容量 -> 读取 LBA 0 解析分区表 -> 读取分区引导扇区解析 FAT32 参数 -> 读取根目录簇解析目录项 -> 根据文件名找到起始簇 -> 沿 FAT 链读取数据簇 -> 拼接成完整文件。每一步都有对应的 SCSI 命令和数据结构解析,任何一步出错都会导致最终读不到文件。我在实际项目中把这整个链路封装成了一个UsbStorageReader类,对外只暴露listFiles()和readFile()两个方法,内部处理所有协议细节和错误重试。
这个方案我已经在多个项目中实际使用过,包括一个需要在无网络环境下从 U 盘导入配置文件的工业平板应用,以及一个需要读取 U 盘日志文件的车载设备应用。实测下来,对市面上主流的 U 盘兼容性很好,读取速度也能满足需求。如果你也在做类似的事情,建议先从 INQUIRY 命令开始,一步步验证每一层的正确性,不要一上来就试图读文件,那样出了问题很难定位。