
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 TransportBOT协议这是 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:nameandroid.hardware.usb.host android:requiredtrue /然后注册一个动态广播接收器注意 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 结构体逐字段拆解CBWCommand Block Wrapper固定 31 字节是所有操作的起点。字段定义如下偏移长度字段说明04dCBWSignature固定 0x43425355即 USBC44dCBWTag命令标签CSW 会原样返回用于配对84dCBWDataTransferLength本次数据阶段要传的字节数121bmCBWFlagsbit71 表示数据从设备到主机In131bCBWLUN逻辑单元号U 盘一般是 0141bCBWCBLength后面 SCSI 命令块的有效长度1516CBWCBSCSI 命令块本体用 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 结构体与状态码解读CSWCommand Status Wrapper固定 13 字节是每次操作的收尾偏移长度字段说明04dCSWSignature固定 0x53425355即 USBS44dCSWTag必须和 CBW 的 tag 一致84dCSWDataResidue没传完的字节数121bCSWStatus0成功1失败2阶段错误解析 CSW 的时候bCSWStatus是最重要的判断依据。0 表示命令成功执行1 表示命令本身有问题比如 SCSI 命令参数不对2 表示传输阶段出了错通常意味着数据长度对不上或者端点方向搞反了。dCSWDataResidue也很有用。比如你请求读 512 字节结果 residue 是 512说明一个字节都没读到多半是设备没准备好或者命令被拒绝了。3.3 一次完整传输的时序把三段串起来一次读扇区的完整流程是这样的主机通过 Bulk-Out 端点发送 31 字节 CBW里面装着READ(10)命令dCBWDataTransferLength设为要读的字节数bmCBWFlags设为 0x80In。设备收到 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 字节是厂商 ID16 到 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 文件系统得自己按 BPBBIOS 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或先 detachCBW 发送成功但收不到 CSW数据阶段长度不对检查 dCBWDataTransferLength 是否匹配实际数据CSW status 1SCSI 命令参数错误检查命令块字节序、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读出来直接比对比解析文件系统快得多。等底层传输稳定了再往上叠文件系统解析一层一层验证出问题的时候范围就很小。这个分层验证的习惯是我做嵌入式这几年觉得最值钱的经验。