恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Android USB Host 从零实现 U 盘读取:Bulk-Only Transport 与 SCSI 协议实战
首页
资讯中心
/
Android USB Host 从零实现 U 盘读取:Bulk-Only Transport 与 SCSI 协议实战
Android USB Host 从零实现 U 盘读取:Bulk-Only Transport 与 SCSI 协议实战
发布时间:2026/10/3 16:42:36
1. 为什么我要绕开 libaums 自己撸 USB 协议1.1 一个真实的需求场景去年接手一个工业平板项目客户要求设备能直接读取巡检人员插上的 U 盘把里面的 CSV 日志导进本地数据库。第一反应当然是找现成库libaums 是 Android 上读写 FAT32 U 盘最成熟的开源方案GitHub 上 star 数也不少。但实际接进去之后问题一个接一个客户那批 U 盘里有几个是 exFAT 格式的libaums 对 exFAT 的支持一直不太完整还有些 U 盘带多个分区库里的分区扫描逻辑直接卡死更麻烦的是这个库已经很久没更新了Android 13 之后的一些权限变更它没跟上。被逼到墙角之后我做了一个决定干脆自己用 Android 的 USB Host API 加上 Bulk-Only Transport 协议和 SCSI 命令集从零把 U 盘读出来。这篇文章就是那次折腾的完整记录包括协议原理、代码实现、踩过的坑以及为什么某些地方必须那么写。如果你也在做 Android 外接存储、USB 设备通信、或者单纯想搞明白 U 盘插上去之后系统到底发生了什么这篇内容应该能帮你省下不少查资料的时间。核心关键词就几个Android USB Host、Bulk-Only Transport、SCSI、Mass Storage。搞懂这四个东西之间的关系U 盘读取这件事就通了一大半。1.2 整体思路三层结构各管各的自己实现 U 盘读取本质上是在应用层重新走一遍操作系统内核本来帮你做的事。整个链路可以拆成三层最底层是 USB 通信层Android 通过UsbManager拿到设备句柄打开UsbDeviceConnection找到 Mass Storage 接口对应的两个 Bulk 端点一个 IN 一个 OUT然后在这两个端点上收发原始字节。中间是传输协议层U 盘不是随便发字节就能读的它遵循Bulk-Only TransportBOT协议。每个操作都要打包成一个CBWCommand Block Wrapper发出去U 盘回一个CSWCommand Status Wrapper中间可能夹着数据。最上层是命令层CBW 里装的是SCSI 命令比如READ(10)、WRITE(10)、INQUIRY、READ CAPACITY。真正决定读哪个扇区的是 SCSI 命令里的参数。这三层的关系打个比方USB 通信层是快递员负责把包裹送到BOT 协议是包裹的封装规范规定了箱子怎么贴单、怎么签收SCSI 命令是箱子里的货物清单告诉对方你要取什么。三层缺一不可但每一层都可以独立调试。我选择自己实现而不是用库核心原因就是可控。出了问题我能精确知道是 USB 层没通、还是 CBW 封装错了、还是 SCSI 命令参数算错了。用库的话黑盒一旦出问题排查成本反而更高。2. 动手前必须搞懂的协议细节2.1 Bulk-Only Transport 到底怎么工作BOT 协议是 USB Mass Storage 类规范里最简单的一种传输方式它规定了主机和设备之间用三个阶段的交互完成一次命令CBW 阶段主机发 31 字节的 Command Block Wrapper里面包含命令长度、数据传输方向、传输长度、LUN逻辑单元号和 SCSI 命令本身。数据阶段根据 CBW 里声明的方向主机从 IN 端点读数据或者往 OUT 端点写数据。这一步可能没有取决于命令。CSW 阶段设备回 13 字节的 Command Status Wrapper告诉主机这次命令成功还是失败实际传输了多少字节。CBW 的结构必须背下来因为一个字节错位整个通信就废了偏移长度字段说明04dCBWSignature固定 0x43425355即 USBC44dCBWTag主机生成的标签CSW 会原样返回84dCBWDataTransferLength数据阶段要传的字节数121bmCBWFlags0x80 表示 IN设备到主机0x00 表示 OUT131bCBWLUN逻辑单元号U 盘一般是 0141bCBWCBLengthSCSI 命令的长度6 到 161516CBWCBSCSI 命令本体CSW 的结构同样要记牢偏移长度字段说明04dCSWSignature固定 0x53425355即 USBS44dCSWTag必须和 CBW 的 tag 一致84dCSWDataResidue没传完的字节数121bCSWStatus0 成功1 失败2 阶段错误注意dCBWTag一定要每次递增或者随机生成不能重复。我一开始图省事固定写 1结果在某些 U 盘上连续发命令时 CSW 对不上号排查了半天才发现是 tag 冲突。2.2 SCSI 命令集里真正要用的就那几条SCSI 命令集庞大得吓人但读 U 盘只需要掌握几条核心命令INQUIRY (0x12)查询设备信息返回厂商、型号、版本。用来确认设备确实是 U 盘。TEST UNIT READY (0x00)测试设备是否就绪。刚插上时设备可能需要时间初始化要轮询这条命令。READ CAPACITY (10) (0x25)读取容量返回最后一个逻辑块地址和块大小。这是算总容量的关键。READ (10) (0x28)读数据指定起始 LBA 和要读的块数。WRITE (10) (0x2A)写数据参数结构类似。REQUEST SENSE (0x03)当命令失败时用这条命令获取详细错误信息。每条 SCSI 命令的 CDBCommand Descriptor Block结构不同READ(10)的 CDB 是 10 字节字节值说明00x28操作码10x00保留位和 LUN2-5LBA大端序的起始逻辑块地址60x00保留7-8传输块数大端序90x00控制字节这里有个新手最容易栽的坑所有多字节字段都是大端序Big-Endian。Java 默认是大端但如果你用ByteBuffer忘了设order或者手动移位时搞反了读出来的 LBA 就是错的数据自然也是错的。2.3 Android USB Host API 的关键约束Android 的 USB Host API 从 3.1 开始支持但有几个硬性约束必须提前知道权限必须动态申请UsbManager.requestPermission()会弹系统对话框用户点了同意才能打开设备。这个对话框没法绕过也没法自定义样式。端点通信是阻塞的bulkTransfer()是同步阻塞调用必须放到子线程否则直接 ANR。单次传输有大小限制虽然理论上可以传很大但实际单次bulkTransfer建议不超过 16KB超过要分片。我实测超过 64KB 在某些设备上会直接返回 -1。设备拔出要处理必须注册USB_DEVICE_DETACHED广播否则连接对象会变成野指针。提示UsbDeviceConnection的bulkTransfer返回值是实际传输的字节数负数表示失败。不要只看是否等于请求长度要处理部分传输的情况。3. 从零实现代码逐层拆解3.1 第一步找到设备并拿到通信通道先定义一个工具类把 USB 设备的发现、权限申请、连接打开封装起来。核心是遍历UsbManager.getDeviceList()找到UsbConstants.USB_CLASS_MASS_STORAGE的接口然后定位两个 Bulk 端点。public class UsbMassStorage { private UsbDeviceConnection connection; private UsbEndpoint endpointIn; private UsbEndpoint endpointOut; private int lastTag 0; public boolean open(UsbManager manager, UsbDevice device) { // 遍历接口找 Mass Storage 类 for (int i 0; i device.getInterfaceCount(); i) { UsbInterface intf device.getInterface(i); if (intf.getInterfaceClass() UsbConstants.USB_CLASS_MASS_STORAGE) { // 在这个接口里找 Bulk 端点 for (int j 0; j intf.getEndpointCount(); j) { UsbEndpoint ep intf.getEndpoint(j); if (ep.getType() UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() UsbConstants.USB_DIR_IN) { endpointIn ep; } else { endpointOut ep; } } } // 打开连接并声明接口 connection manager.openDevice(device); if (connection null) return false; connection.claimInterface(intf, true); return endpointIn ! null endpointOut ! null; } } return false; } }这段代码里claimInterface(intf, true)的第二个参数force设为 true 很关键。有些设备被系统自带的 mass storage 驱动占着不强制声明会失败。但要注意强制声明可能导致系统驱动被踢掉如果这个 U 盘同时被系统挂载着可能会引起冲突。3.2 第二步封装 CBW 和 CSW 的收发这是整个实现的核心。每次发 SCSI 命令都要走一遍发 CBW → 传数据 → 收 CSW的流程。我把它封装成一个executeCommand方法private byte[] executeCommand(byte[] cdb, int dataLength, boolean directionIn) { // 1. 构造 CBW byte[] cbw new byte[31]; ByteBuffer cbwBuf ByteBuffer.wrap(cbw); cbwBuf.order(ByteOrder.LITTLE_ENDIAN); // CBW 本身是小端 cbwBuf.putInt(0x43425355); // signature cbwBuf.putInt(lastTag); // tag cbwBuf.putInt(dataLength); // 数据长度 cbwBuf.put((byte) (directionIn ? 0x80 : 0x00)); // flags cbwBuf.put((byte) 0); // LUN cbwBuf.put((byte) cdb.length); // CDB 长度 cbwBuf.put(cdb); // SCSI 命令 // 剩余字节补零到 31 字节 while (cbwBuf.position() 31) cbwBuf.put((byte) 0); // 2. 发送 CBW int sent connection.bulkTransfer(endpointOut, cbw, cbw.length, 5000); if (sent ! cbw.length) return null; // 3. 数据阶段 byte[] data null; if (dataLength 0) { data new byte[dataLength]; if (directionIn) { int received connection.bulkTransfer(endpointIn, data, dataLength, 5000); if (received 0) return null; } else { connection.bulkTransfer(endpointOut, data, dataLength, 5000); } } // 4. 接收 CSW byte[] csw new byte[13]; int cswLen connection.bulkTransfer(endpointIn, csw, 13, 5000); if (cswLen ! 13) return null; // 5. 校验 CSW ByteBuffer cswBuf ByteBuffer.wrap(csw); cswBuf.order(ByteOrder.LITTLE_ENDIAN); int signature cswBuf.getInt(); int tag cswBuf.getInt(); int residue cswBuf.getInt(); byte status cswBuf.get(); if (signature ! 0x53425355 || tag ! lastTag || status ! 0) { return null; // 命令失败 } return data; }这里有个细节值得单独说CBW 和 CSW 的字节序是小端但 SCSI 命令里的多字节字段是大端。这个混搭是 USB Mass Storage 规范的历史遗留我第一次写的时候统一按小端处理结果 LBA 全错。记住这个口诀包裹CBW/CSW小端货物SCSI CDB大端。3.3 第三步用 SCSI 命令读出容量和数据有了executeCommand这个基础设施读 U 盘就变成了拼 SCSI 命令。先读容量public long[] readCapacity() { byte[] cdb new byte[10]; cdb[0] 0x25; // READ CAPACITY(10) byte[] data executeCommand(cdb, 8, true); if (data null) return null; ByteBuffer buf ByteBuffer.wrap(data); buf.order(ByteOrder.BIG_ENDIAN); // SCSI 数据是大端 long lastLba buf.getInt() 0xFFFFFFFFL; long blockSize buf.getInt() 0xFFFFFFFFL; return new long[]{lastLba 1, blockSize}; // 总块数, 块大小 }注意lastLba 1因为返回的是最后一个块的地址从 0 开始总块数要加一。块大小一般是 512 或 4096。然后读数据比如读第一个扇区通常是 MBR 或引导扇区public byte[] readBlocks(long lba, int count) { byte[] cdb new byte[10]; cdb[0] 0x28; // READ(10) // LBA 大端写入字节 2-5 cdb[2] (byte) (lba 24); cdb[3] (byte) (lba 16); cdb[4] (byte) (lba 8); cdb[5] (byte) lba; // 块数大端写入字节 7-8 cdb[7] (byte) (count 8); cdb[8] (byte) count; return executeCommand(cdb, count * 512, true); }读出来的就是原始扇区数据。要解析成文件系统还得自己实现 FAT32 或者 exFAT 的解析那是另一个大话题。但如果只是导出特定文件可以先用INQUIRY确认设备再按已知的文件系统结构去定位。3.4 第四步初始化流程不能省U 盘刚插上不能直接读必须走一遍初始化发INQUIRY确认设备响应正常。轮询TEST UNIT READY直到返回成功。有些 U 盘要等几百毫秒。发READ CAPACITY拿容量。如果中间任何一步失败发REQUEST SENSE拿错误码。public boolean init() { // INQUIRY byte[] inquiryCdb new byte[6]; inquiryCdb[0] 0x12; inquiryCdb[4] 36; // 分配长度 byte[] inquiryData executeCommand(inquiryCdb, 36, true); if (inquiryData null) return false; // 轮询 TEST UNIT READY byte[] turCdb new byte[6]; turCdb[0] 0x00; for (int i 0; i 10; i) { if (executeCommand(turCdb, 0, true) ! null) break; try { Thread.sleep(100); } catch (InterruptedException e) {} } return true; }注意TEST UNIT READY的 dataLength 是 0此时executeCommand会跳过数据阶段直接收 CSW。这个逻辑在封装时一定要处理好否则会卡在等数据上。4. 踩过的坑和排查经验4.1 常见问题速查表现象可能原因排查方法bulkTransfer 返回 -1端点方向搞反 / 超时太短打印端点地址确认方向超时加到 5000msCSW signature 不对CBW 封装字节错位逐字节打印 CBW 十六进制对比规范读出的数据全是 0LBA 字节序错误确认 SCSI CDB 用大端写入命令一直失败设备未就绪加 TEST UNIT READY 轮询权限对话框不弹设备已被系统占用检查是否已 claimInterface拔出后崩溃没处理 detached 广播注册广播并在回调里关闭连接4.2 几个只有实际做过才知道的细节第一个坑超时时间不能太短。我一开始设 1000ms读大块数据时经常超时。后来统一改成 5000ms稳定很多。但也不能无限长否则设备真挂了会一直卡着。第二个坑分片传输。读 1MB 数据时我试过一次性bulkTransfer传 1MB结果返回 -1。后来改成每次最多 16KB 循环读问题解决。这个限制和具体设备有关保守起见按 16KB 分片。第三个坑多分区 U 盘。有些 U 盘有多个 LUNbCBWLUN字段要遍历。大部分消费级 U 盘只有一个 LUN但工业级设备可能有多個。如果读不到数据试试把 LUN 从 0 遍历到 3。第四个坑exFAT 和 NTFS。自己实现 SCSI 层只能读到原始扇区文件系统解析是另一回事。FAT32 相对简单exFAT 复杂一些NTFS 基本别想自己撸。如果只是导出文件建议先确认 U 盘格式或者引导用户格式化成 FAT32。第五个坑Android 版本差异。Android 10 之后对 USB 权限管得更严requestPermission的对话框在某些定制 ROM 上行为不一致。测试时一定要覆盖目标机型别只在模拟器上跑。4.3 调试手段抓包和日志USB 层出问题时光看代码很难定位。我的做法是在每次bulkTransfer前后打印十六进制数据尤其是 CBW 和 CSW。用UsbManager的getDeviceList打印设备描述符确认 VID/PID 和接口类。有条件的话用硬件 USB 抓包工具能看到总线上真实的字节流对比自己的封装。日志建议分级CBW/CSW 用 verbose数据阶段用 debug错误用 error。这样正常运行时不会刷屏出问题能快速定位。5. 这套方案适合谁以及后续能怎么扩展自己实现 USB Mass Storage 读取代码量其实不大核心逻辑加起来不到 500 行。但它带来的可控性是现成库比不了的。如果你遇到 libaums 搞不定的格式、多分区、或者特殊设备自己撸一遍是值得的。这套基础打好之后扩展方向很多往上可以接 FAT32 解析实现完整的文件浏览往旁边可以支持 WRITE 命令实现写入往下可以研究 UASP 协议USB Attached SCSI Protocol获得更高吞吐。我目前只做到了读扇区和简单的 FAT32 目录解析写入还在测试阶段主要担心的是断电时的数据一致性。最后分享一个实用技巧调试阶段先用一个已知内容的 U 盘比如手动写入一个全是 0xAA 的扇区读出来对比能快速验证整条链路是否正确。比对着真实文件系统猜要高效得多。