1. 为什么我要绕开 libaums 自己啃 USB 协议
1.1 一个真实的需求场景
去年接手一个工业平板项目,客户要求外接 U 盘导入配置文件,设备跑的是定制 Android 9,系统裁剪得比较狠,没有 GMS,也没有任何现成的文件管理器。第一反应当然是上 libaums,毕竟这是 Android 上读写 FAT32 U 盘最省事的库。但实际接进去之后问题就来了:这个库对某些国产主控的 U 盘兼容性一般,枚举阶段偶尔卡死,而且它内部封装太厚,出了错只能看到一句IOException,根本不知道是 CBW 阶段挂了还是 CSW 状态不对。更麻烦的是客户要求支持热插拔后自动重连,libaums 的UsbMassStorageDevice在拔插几次之后会残留状态,得重启应用才能恢复。
折腾了两天之后我决定放弃这层封装,直接用 Android 的UsbManager加上自己实现的 Bulk-Only Transport 协议栈来做。听起来吓人,其实核心逻辑并不复杂:USB 大容量存储设备本质上就是一套"发命令-传数据-收状态"的三段式流程,把 CBW、数据包、CSW 这三个结构体拼对,再配合 SCSI 的几条基础指令,就能把 U 盘读出来。这篇文章就把我踩过的坑和最终跑通的方案完整写出来,适合有一定 Android 基础、想搞清楚 USB 通信底层原理、或者被现成库坑过的朋友参考。
1.2 整体思路:三层结构拆开看
自己实现这套东西,我把它拆成三层来理解,这样调试的时候定位问题会快很多。
最底层是Android USB Host API,也就是UsbManager、UsbDevice、UsbInterface、UsbEndpoint、UsbDeviceConnection这几个类。这一层负责跟系统打交道:申请权限、打开设备、声明接口、在端点上传数据。它不关心你传的是什么协议,只负责把字节搬进搬出。
中间层是Bulk-Only Transport(BOT)协议,这是 USB 大容量存储设备类规范里定义的一套传输框架。它规定了每一次操作都要走"命令块包装(CBW)→ 数据阶段(可选)→ 命令状态包装(CSW)"这个固定节奏。CBW 里装着我们要发的 SCSI 命令,CSW 里装着设备执行完之后的返回状态。这一层是承上启下的关键,也是最容易出错的地方。
最上层是SCSI 命令集,U 盘真正"听懂"的语言。读扇区用READ(10),写扇区用WRITE(10),查容量用READ CAPACITY(10),查设备信息用INQUIRY。我们平时说的"读 U 盘",落到这一层就是不停地发READ(10)把扇区数据搬出来。
三层各司其职,调试的时候哪一层出问题就盯哪一层:权限报错看第一层,传输超时看第二层,数据不对看第三层。下面我按这个顺序展开。
2. 底层准备:Android USB Host 权限与端点识别
2.1 权限申请与 Intent 过滤
Android 上访问 USB 设备,第一步永远是权限。跟普通运行时权限不一样,USB 权限是通过UsbManager.requestPermission()弹系统对话框申请的,用户点同意之后会收到一个带EXTRA_PERMISSION_GRANTED的广播。
先在AndroidManifest.xml里声明 USB 特性,这一步很多人会漏:
<uses-feature android:name="android.hardware.usb.host" android:required="true" />然后注册一个动态广播接收器,注意 Android 12 之后必须显式指定包名,否则收不到:
val filter = IntentFilter(ACTION_USB_PERMISSION) filter.addCategory(Intent.CATEGORY_DEFAULT) ContextCompat.registerReceiver( context, usbReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED )申请权限的代码很直接:
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager val deviceList = usbManager.deviceList val target = deviceList.values.firstOrNull { it.deviceClass == 8 } // 8 = Mass Storage if (target != null && !usbManager.hasPermission(target)) { val intent = PendingIntent.getBroadcast( this, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE ) usbManager.requestPermission(target, intent) }注意:
deviceClass == 8只是粗筛。有些 U 盘在设备描述符层面把 class 设成 0(表示由接口描述符定义),这时候得遍历getInterfaceCount(),看接口的getInterfaceClass()是不是 8。我遇到过一款闪迪的盘就是这种情况,只按设备 class 过滤会直接漏掉。
2.2 找到 Bulk 端点:In 和 Out 要分清
拿到权限之后,打开设备、找到大容量存储接口、定位两个 Bulk 端点。BOT 协议规定,一个 U 盘接口下必然有一个 Bulk-In 端点(设备到主机,用来收数据)和一个 Bulk-Out 端点(主机到设备,用来发命令)。
val usbInterface = target.getInterface(0) var endpointIn: UsbEndpoint? = null var endpointOut: UsbEndpoint? = null for (i in 0 until usbInterface.endpointCount) { val ep = usbInterface.getEndpoint(i) if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.direction == UsbConstants.USB_DIR_IN) endpointIn = ep else endpointOut = ep } }这里有个坑我踩过:端点地址不能写死。网上有些示例代码直接假设 In 端点是0x81、Out 是0x02,大部分情况确实如此,但少数设备会不一样。老老实实按direction判断,别偷懒。
拿到端点之后打开连接并声明接口:
val connection = usbManager.openDevice(target) connection.claimInterface(usbInterface, true) // force = trueforce参数设成 true 是为了强制从内核驱动手里抢过接口。Android 默认的usb-storage驱动会占用大容量存储设备,不强制声明的话claimInterface会返回 false。这一点很关键,很多人卡在这里,明明权限有了却打不开接口。
2.3 超时设置与缓冲区大小
bulkTransfer的签名是bulkTransfer(endpoint, buffer, offset, length, timeout),超时单位是毫秒。我一般设 5000ms,太短了在低速 U 盘上容易误判超时,太长了卡住的时候体验差。
缓冲区大小建议按端点最大包长(endpoint.maxPacketSize)的整数倍来分配,通常是 512 字节。读扇区的时候一次读多个扇区,缓冲区就是512 * 扇区数。我实测下来一次读 64 个扇区(32KB)比较平衡,再大对内存和延迟都不划算。
3. Bulk-Only Transport 协议:CBW、数据、CSW 三段式
3.1 CBW 结构体逐字段拆解
CBW(Command Block Wrapper)固定 31 字节,是所有操作的起点。字段定义如下:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCBWSignature | 固定 0x43425355,即 "USBC" |
| 4 | 4 | dCBWTag | 命令标签,CSW 会原样返回,用于配对 |
| 8 | 4 | dCBWDataTransferLength | 本次数据阶段要传的字节数 |
| 12 | 1 | bmCBWFlags | bit7=1 表示数据从设备到主机(In) |
| 13 | 1 | bCBWLUN | 逻辑单元号,U 盘一般是 0 |
| 14 | 1 | bCBWCBLength | 后面 SCSI 命令块的有效长度 |
| 15 | 16 | CBWCB | SCSI 命令块本体 |
用 Kotlin 拼这个结构体,我习惯用ByteBuffer配小端序,因为 USB 协议里多字节字段都是小端:
fun buildCbw(tag: Int, dataLen: Int, isIn: Boolean, lun: Int, cb: ByteArray): ByteArray { val buf = ByteBuffer.allocate(31).order(ByteOrder.LITTLE_ENDIAN) buf.putInt(0x55534243) // "USBC" buf.putInt(tag) buf.putInt(dataLen) buf.put(if (isIn) 0x80.toByte() else 0x00.toByte()) buf.put(lun.toByte()) buf.put(cb.size.toByte()) buf.put(cb) buf.put(ByteArray(16 - cb.size)) // 补齐到 16 字节 return buf.array() }注意:
dCBWSignature写的时候要小心字节序。0x43425355在小端序下写出来正好是55 53 42 43,对应 ASCII 的 "USBC"。如果你用大端序写,设备会直接不认,返回的 CSW 状态是失败。我第一次就是这里写反了,抓包看半天才发现。
3.2 CSW 结构体与状态码解读
CSW(Command Status Wrapper)固定 13 字节,是每次操作的收尾:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCSWSignature | 固定 0x53425355,即 "USBS" |
| 4 | 4 | dCSWTag | 必须和 CBW 的 tag 一致 |
| 8 | 4 | dCSWDataResidue | 没传完的字节数 |
| 12 | 1 | bCSWStatus | 0=成功,1=失败,2=阶段错误 |
解析 CSW 的时候,bCSWStatus是最重要的判断依据。0 表示命令成功执行;1 表示命令本身有问题(比如 SCSI 命令参数不对);2 表示传输阶段出了错,通常意味着数据长度对不上或者端点方向搞反了。
dCSWDataResidue也很有用。比如你请求读 512 字节,结果 residue 是 512,说明一个字节都没读到,多半是设备没准备好或者命令被拒绝了。
3.3 一次完整传输的时序
把三段串起来,一次读扇区的完整流程是这样的:
- 主机通过 Bulk-Out 端点发送 31 字节 CBW,里面装着
READ(10)命令,dCBWDataTransferLength设为要读的字节数,bmCBWFlags设为 0x80(In)。 - 设备收到 CBW 后,通过 Bulk-In 端点把扇区数据发回来,长度等于 CBW 里声明的长度。
- 主机通过 Bulk-In 端点接收 13 字节 CSW,检查 tag 和 status。
这里有个细节:数据阶段的方向由 CBW 的 flags 决定,而不是由端点决定。虽然数据是从 Bulk-In 端点来的,但整个操作的"数据方向"是主机读取,所以 flags 是 0x80。写操作则相反,flags 是 0x00,数据通过 Bulk-Out 端点发出去。
实操心得:调试阶段强烈建议把每次 CBW 和 CSW 的十六进制都打出来。我一开始读容量总是失败,把 CBW 打出来一看,
dCBWDataTransferLength写成了 0,而READ CAPACITY(10)是需要返回 8 字节数据的,长度写 0 设备自然不返回。这种错误光看代码很难发现,打日志一眼就看出来了。
4. SCSI 命令集:让 U 盘真正干活
4.1 INQUIRY:先确认设备身份
INQUIRY是最基础的命令,用来查询设备厂商、型号、版本。虽然读文件不一定需要它,但调试阶段先发一条INQUIRY能确认整条链路是否通了。
命令块 6 字节:0x12, 0x00, 0x00, 0x00, 0xFF, 0x00。其中第 4 字节是分配长度,设 0xFF 表示让设备尽量多返回。数据阶段是 In 方向,返回 36 字节的标准 INQUIRY 数据,前 8 字节是外设信息,第 8 到 16 字节是厂商 ID,16 到 32 字节是产品 ID。
val cb = byteArrayOf(0x12, 0x00, 0x00, 0x00, 0xFF.toByte(), 0x00) val result = executeCommand(cb, 36, isIn = true) val vendor = String(result, 8, 8).trim() val product = String(result, 16, 16).trim()4.2 READ CAPACITY(10):算出 U 盘有多大
这条命令返回 8 字节:前 4 字节是最后一个可寻址的逻辑块地址(LBA),后 4 字节是块大小(通常 512)。
val cb = byteArrayOf(0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) val result = executeCommand(cb, 8, isIn = true) val buf = ByteBuffer.wrap(result).order(ByteOrder.BIG_ENDIAN) val lastLba = buf.int.toLong() and 0xFFFFFFFFL val blockSize = buf.int val totalBytes = (lastLba + 1) * blockSize注意这里 SCSI 数据是大端序,跟 CBW 的小端序正好相反,别搞混了。我见过有人把 CBW 和 SCSI 的字节序搞成一样的,结果容量算出来是个天文数字。
4.3 READ(10):把扇区数据读出来
READ(10)命令块 10 字节,格式是:0x28, flags, LBA(4字节大端), group, length(2字节大端), control。
fun buildRead10(lba: Long, blockCount: Int): ByteArray { val cb = ByteArray(10) cb[0] = 0x28 cb[1] = 0x00 cb[2] = ((lba shr 24) and 0xFF).toByte() cb[3] = ((lba shr 16) and 0xFF).toByte() cb[4] = ((lba shr 8) and 0xFF).toByte() cb[5] = (lba and 0xFF).toByte() cb[6] = 0x00 cb[7] = ((blockCount shr 8) and 0xFF).toByte() cb[8] = (blockCount and 0xFF).toByte() cb[9] = 0x00 return cb }读出来的数据就是原始扇区内容。要解析 FAT32 文件系统,得自己按 BPB(BIOS Parameter Block)去解析引导扇区、FAT 表、目录项。这部分内容很多,本文聚焦在"把数据读出来"这一层,文件系统解析可以另开一篇。
4.4 封装一个通用的命令执行函数
把 CBW 组装、数据传输、CSW 校验串成一个函数,后面所有命令都复用它:
private var currentTag = 0 fun executeCommand(cb: ByteArray, dataLen: Int, isIn: Boolean): ByteArray? { val tag = ++currentTag val cbw = buildCbw(tag, dataLen, isIn, 0, cb) val cbwSent = connection.bulkTransfer(endpointOut, cbw, cbw.size, TIMEOUT) if (cbwSent != cbw.size) return null val data = if (dataLen > 0) { val buffer = ByteArray(dataLen) val ep = if (isIn) endpointIn!! else endpointOut!! val received = connection.bulkTransfer(ep, buffer, dataLen, TIMEOUT) if (received < 0) return null buffer } else null val csw = ByteArray(13) val cswReceived = connection.bulkTransfer(endpointIn!!, csw, 13, TIMEOUT) if (cswReceived != 13) return null val cswBuf = ByteBuffer.wrap(csw).order(ByteOrder.LITTLE_ENDIAN) val signature = cswBuf.int val cswTag = cswBuf.int val residue = cswBuf.int val status = cswBuf.get() if (signature != 0x53425355.toInt() || cswTag != tag || status != 0.toByte()) { return null } return data }这个函数是整个方案的心脏。所有 SCSI 命令都通过它走一遍,出错就返回 null,调用方根据 null 决定重试还是报错。
5. 实操踩坑与问题排查实录
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| claimInterface 返回 false | 内核驱动占用 | force 参数设 true,或先 detach |
| CBW 发送成功但收不到 CSW | 数据阶段长度不对 | 检查 dCBWDataTransferLength 是否匹配实际数据 |
| CSW status = 1 | SCSI 命令参数错误 | 检查命令块字节序、LBA 范围 |
| CSW status = 2 | 传输阶段错误 | 检查端点方向、flags 设置 |
| 读容量返回全 0 | 字节序搞反 | SCSI 数据用大端,CBW 用小端 |
| 拔插后无法重连 | 连接未释放 | 拔插时 close 连接、释放接口 |
| 大文件读取卡顿 | 单次读扇区太多 | 降到 32 或 64 扇区一批 |
5.2 热插拔的处理
U 盘拔掉的时候,正在进行的bulkTransfer会返回 -1 或者抛异常。这时候必须做清理:releaseInterface、close连接、把端点引用置空。否则下次插上来的设备虽然能拿到权限,但claimInterface会失败,因为旧连接还占着。
我一般用一个UsbDeviceManager单例来管理连接状态,监听UsbManager.ACTION_USB_DEVICE_DETACHED广播,收到之后走一遍清理流程。重连的时候重新走权限申请和端点识别,不要复用旧的UsbDeviceConnection。
5.3 关于超时和重试
工业现场电磁环境复杂,偶尔一次bulkTransfer超时是正常的。我的做法是每个命令最多重试 3 次,每次间隔 50ms。如果 3 次都失败,才向上层报错。实测下来这个策略能把偶发干扰导致的失败率降到几乎为零。
但要注意,重试之前必须确保上一次传输已经彻底结束。如果 CSW 没收到就重试,设备那边可能还停在旧命令的状态里,新命令发过去会直接乱套。稳妥的做法是重试前先发一个REQUEST SENSE或者干脆复位设备(UsbDeviceConnection.controlTransfer发一个 Bulk-Only Mass Storage Reset 类请求)。
5.4 一个容易被忽略的细节:LUN
大部分 U 盘只有一个逻辑单元,LUN 填 0 就行。但有些多合一读卡器会暴露多个 LUN,比如插了 SD 卡和 CF 卡。这时候bCBWLUN要分别填 0 和 1 去枚举。判断方法是在INQUIRY之后看返回数据,或者直接尝试不同 LUN 发TEST UNIT READY(命令 0x00),能返回成功的就是有效 LUN。
6. 性能优化与进阶方向
6.1 批量读取的扇区数怎么定
前面提到我一般一次读 64 个扇区。这个数字不是拍脑袋来的。USB 2.0 全速模式下,Bulk 端点每帧(1ms)最多传 19 个 64 字节的包,理论带宽约 1.2MB/s。一次读 64 扇区是 32KB,大概需要 27ms 传完。如果一次读 512 扇区(256KB),传输时间超过 200ms,期间如果用户拔盘,损失的数据量就大。而且很多 U 盘的主控缓冲区没那么大,一次要太多反而会触发内部错误。
实际调优的时候可以做个简单的基准测试:从 16 扇区开始,逐步翻倍到 256,测每种大小的平均耗时,选一个耗时增长开始变缓的拐点。我测过的几款盘,拐点基本都在 64 到 128 之间。
6.2 用双缓冲隐藏传输延迟
如果要做文件拷贝这种连续读取,单线程"发命令-等数据-收状态"的节奏会让 USB 总线在命令间隙空转。可以用两个线程做双缓冲:一个线程负责发命令和收 CSW,另一个线程负责处理已经读到的数据。这样总线利用率能提升 20% 到 30%。
不过双缓冲会引入复杂度,尤其是错误处理。我的建议是先把单线程版本跑稳,确认协议层没问题了,再考虑加缓冲优化。
6.3 文件系统解析的衔接
数据读出来之后,要真正"看到文件",还得解析 FAT32 或 exFAT。FAT32 的引导扇区在 LBA 0,偏移 0x0B 是每扇区字节数,0x0D 是每簇扇区数,0x0E 是保留扇区数,0x10 是 FAT 表数量,0x20 是总扇区数,0x2C 是根目录起始簇。把这些字段读出来,就能算出 FAT 表位置和数据区位置,然后顺着目录项链找到文件。
这部分我建议单独封装一个Fat32Parser类,输入是一个"按 LBA 读扇区"的函数,输出是文件树。这样解析逻辑跟 USB 传输逻辑解耦,测试的时候可以用内存里的镜像文件模拟,不用真插 U 盘。
7. 我个人的几点实操体会
整套方案从零跑通大概花了我一周时间,其中前三天基本都在跟字节序和端点方向较劲。回头看,如果一开始就把 CBW 和 CSW 的十六进制日志打全,能省掉至少一半的调试时间。USB 协议不像网络协议有现成的抓包工具那么方便,很多时候只能靠打日志一点点对。
另外一点体会是,不要迷信现成的库。libaums 确实省事,但它把太多细节藏起来了,一旦出问题就很难定位。自己实现一遍之后,我对 USB 大容量存储的理解完全不一样了,后面再遇到 USB 转串口、USB 网卡这类设备,排查思路都是相通的。
最后分享一个小技巧:调试阶段可以准备一个已知内容的 U 盘,比如第一个扇区写满 0xAA,读出来直接比对,比解析文件系统快得多。等底层传输稳定了,再往上叠文件系统解析,一层一层验证,出问题的时候范围就很小。这个"分层验证"的习惯,是我做嵌入式这几年觉得最值钱的经验。