Android原生USB协议实现U盘读取:绕过libaums的BOT与SCSI实战

发布时间:2026/11/4 11:03:09
Android原生USB协议实现U盘读取:绕过libaums的BOT与SCSI实战 1. 为什么我要绕开 libaums 自己啃 USB 协议Android 上读 U 盘这件事绝大多数人的第一反应是引入 libaums。这个库确实成熟封装了 Bulk-Only TransportBOT和 SCSI 命令集几行代码就能枚举文件。但我在实际项目里遇到过几次它不够用的情况一是需要对接一些非标准块设备libaums 的 SCSI 命令封装太死改起来要动源码二是库体积和依赖链在某些定制 ROM 上会引入额外问题三是我自己想把整条链路吃透知道每一个 USB 包到底长什么样。所以这篇东西的核心就是不依赖任何第三方 U 盘库直接用 Android 的UsbManager、UsbDeviceConnection、UsbEndpoint这套原生 API配合自己实现的 BOT 协议和 SCSI 命令把 U 盘里的数据读出来。关键词里提到的 Android、USB、Bulk-Only Transport、SCSI 就是这条链路的四个支柱。适合谁看如果你已经会用UsbManager做基本的设备枚举但对枚举完之后怎么发命令、怎么读扇区、怎么解析 FAT没概念这篇就是给你写的。如果你只是想快速读个文件那 libaums 更省事但如果你想理解 U 盘在 Android 上到底是怎么被说话的往下看。先说清楚整体链路避免后面迷路USB 层Android 通过UsbManager拿到设备申请权限打开UsbDeviceConnection找到 Bulk-In 和 Bulk-Out 两个端点。传输层U 盘属于 USB Mass Storage 类走的是 Bulk-Only Transport 协议。所有命令都封装成 CBWCommand Block Wrapper数据走 Bulk 端点最后用 CSWCommand Status Wrapper收尾。命令层CBW 里装的是 SCSI 命令读扇区用READ(10)查容量用READ CAPACITY(10)查设备信息用INQUIRY。文件系统层拿到扇区数据后按 FAT32 的 BPB、FAT 表、目录项结构去解析才能看到文件名和内容。这四层每一层都有坑下面逐层拆。2. USB 层的设备枚举与权限申请细节2.1 从 UsbManager 拿到设备列表开始Android 的 USB 主机模式USB Host从 API 12 开始支持但真正稳定用起来是 API 21 以后。第一步是拿到UsbManagerUsbManager usbManager (UsbManager) context.getSystemService(Context.USB_SERVICE); HashMapString, UsbDevice deviceList usbManager.getDeviceList();getDeviceList()返回的是当前连接的所有 USB 设备。U 盘的识别不能只看名字要看接口类。U 盘的标准接口类是USB_CLASS_MASS_STORAGE值是 8子类是USB_SUBCLASS_SCSI6协议是USB_PROTOCOL_BULK_ONLY0x50。判断逻辑for (UsbDevice device : deviceList.values()) { for (int i 0; i device.getInterfaceCount(); i) { UsbInterface intf device.getInterface(i); if (intf.getInterfaceClass() 8 intf.getInterfaceSubclass() 6 intf.getInterfaceProtocol() 0x50) { // 这就是一个 Bulk-Only 的 U 盘 } } }这里有个容易忽略的点有些 U 盘会暴露多个接口比如一个 Mass Storage 接口加一个 HID 接口某些带加密功能的盘。你要遍历所有接口找到那个 class8 的而不是拿getInterface(0)就开干。2.2 权限申请不是一次性的Android 的 USB 权限是按设备授权的而且默认不持久。你第一次插上 U 盘hasPermission()返回 false必须发一个PendingIntent让系统弹框if (!usbManager.hasPermission(device)) { PendingIntent pi PendingIntent.getBroadcast(context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(device, pi); }实测下来有几个坑FLAG_IMMUTABLE 必须加Android 12 以后不加会直接崩。权限回调是异步的别在requestPermission之后立刻openDevice要等广播回来。如果用户拔了再插权限可能失效要重新申请。我一般会在onResume里重新检查一遍hasPermission。有些 ROM 对同一 VID/PID 的设备会记住授权但这不是标准行为不能依赖。2.3 打开连接与端点定位拿到权限后UsbDeviceConnection connection usbManager.openDevice(device); connection.claimInterface(intf, true); // forcetrue 强制抢占claimInterface的第二个参数force建议传 true。因为有些系统服务比如媒体扫描可能已经占了这个接口不强制抢会返回 false。然后找端点。Bulk-Only 的 U 盘一定有一对 Bulk 端点一个 IN设备到主机读数据一个 OUT主机到设备发命令。方向判断UsbEndpoint epIn null, epOut null; for (int i 0; i intf.getEndpointCount(); i) { UsbEndpoint ep intf.getEndpoint(i); if (ep.getType() UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() UsbConstants.USB_DIR_IN) epIn ep; else epOut ep; } }注意端点地址getAddress()的低 4 位是端点号最高位是方向。有些开发者直接用地址判断方向容易搞混用getDirection()更稳。到这一步USB 层的准备工作就完成了。接下来才是真正的硬骨头——BOT 协议。3. Bulk-Only Transport 协议的手工实现3.1 CBW 的 31 字节结构拆解Bulk-Only Transport 的核心是三个结构CBW、数据阶段、CSW。CBW 固定 31 字节字段如下偏移长度字段说明04dCBWSignature固定 0x43425355USBC小端44dCBWTag命令标签CSW 会原样返回84dCBWDataTransferLength本次传输的数据字节数121bmCBWFlags0x80 表示数据从设备到主机IN131bCBWLUN逻辑单元号U 盘一般是 0141bCBWCBLength后面 SCSI 命令的长度6/10/12/161516CBWCBSCSI 命令本体用 Java 拼这个结构注意全部小端序byte[] cbw new byte[31]; writeInt(cbw, 0, 0x43425355); // signature writeInt(cbw, 4, tag); // tag writeInt(cbw, 8, dataLength); // 数据长度 cbw[12] (byte) (isRead ? 0x80 : 0x00); // 方向 cbw[13] 0; // LUN cbw[14] (byte) scsiCmd.length; // SCSI 命令长度 System.arraycopy(scsiCmd, 0, cbw, 15, scsiCmd.length);writeInt要自己写小端序Java 的ByteBuffer默认是大端用ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)也行但我习惯手写可控。3.2 一次完整的命令往返流程发一条 SCSI 命令标准流程是通过 Bulk-Out 端点发 31 字节 CBW。如果有数据阶段通过 Bulk-In读或 Bulk-Out写传输数据。通过 Bulk-In 端点读 13 字节 CSW。CSW 结构偏移长度字段说明04dCSWSignature固定 0x53425355USBS小端44dCSWTag必须和 CBW 的 tag 一致84dCSWDataResidue剩余未传输的数据量121bCSWStatus0 成功1 失败2 阶段错误代码骨架// 1. 发 CBW int sent connection.bulkTransfer(epOut, cbw, 31, TIMEOUT); if (sent ! 31) throw new IOException(CBW send failed); // 2. 数据阶段 if (dataLength 0) { if (isRead) { int read connection.bulkTransfer(epIn, dataBuf, dataLength, TIMEOUT); // 注意可能一次读不完要循环 } else { connection.bulkTransfer(epOut, dataBuf, dataLength, TIMEOUT); } } // 3. 读 CSW byte[] csw new byte[13]; int got connection.bulkTransfer(epIn, csw, 13, TIMEOUT); if (got ! 13) throw new IOException(CSW read failed);3.3 bulkTransfer 一次读不完怎么办这是新手最容易翻车的地方。bulkTransfer的返回值是实际传输的字节数不保证等于你请求的长度。尤其是读大块数据比如 64KB 扇区时一次调用可能只返回几 KB。正确做法是循环读直到读满或超时int offset 0; while (offset dataLength) { int chunk connection.bulkTransfer(epIn, Arrays.copyOfRange(dataBuf, offset, dataLength), dataLength - offset, TIMEOUT); if (chunk 0) throw new IOException(bulk read stalled at offset); offset chunk; }实测心得bulkTransfer的 timeout 别设太短我一般用 5000ms。有些廉价 U 盘响应慢1000ms 会频繁超时。另外如果 CSW 的dCSWDataResidue不为 0说明数据没传完要么重试要么报错别当成功处理。3.4 tag 的生成与校验dCBWTag是个 4 字节的随机或递增值作用是匹配 CBW 和 CSW。我一般用一个静态计数器递增简单可靠。收到 CSW 后必须校验dCSWTag 发出的 tag不匹配说明命令和响应错位了通常是上一次命令没清理干净。这时候要发一个REQUEST SENSE或者直接复位。4. SCSI 命令集读 U 盘真正用到的几条4.1 INQUIRY先确认设备身份INQUIRY0x12是最安全的探测命令返回 36 字节设备信息包括厂商、型号、版本。CBW 的 CB 部分0x12, 0x00, 0x00, 0x00, 0x24, 0x00第 0 字节操作码 0x12第 4 字节分配长度 0x2436 字节第 5 字节控制字节一般 0数据方向是 INdCBWDataTransferLength 36。返回的数据里偏移 8 开始是厂商8 字节16 开始是型号16 字节32 开始是版本4 字节。这一步能验证你的 BOT 实现是否正确如果 INQUIRY 都读不出来后面别想了。4.2 READ CAPACITY(10)拿到扇区数和扇区大小READ CAPACITY(10)0x25返回 8 字节前 4 字节最后一个逻辑块地址LBA即总扇区数减 1后 4 字节逻辑块大小通常是 512 或 4096CB 部分0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00数据方向 IN长度 8。解析long lastLba readIntBE(data, 0); int blockSize readIntBE(data, 4); long totalSectors lastLba 1; long capacityBytes totalSectors * blockSize;注意这里是大端序SCSI 命令的数据都是大端和 USB 结构的小端正好相反别搞混。4.3 READ(10)按扇区读数据READ(10)0x28是读扇区的核心命令CB 是 10 字节字节值说明00x28操作码10x00保留 LUN2-5LBA起始逻辑块地址大端60x00保留7-8传输长度读多少个扇区大端90x00控制拼命令byte[] cb new byte[10]; cb[0] 0x28; cb[2] (byte) (lba 24); cb[3] (byte) (lba 16); cb[4] (byte) (lba 8); cb[5] (byte) lba; cb[7] (byte) (count 8); cb[8] (byte) count;数据长度 count * blockSize方向 IN。比如读 1 个扇区512 字节dCBWDataTransferLength 512。踩坑记录传输长度字段是扇区数不是字节数我一开始填了字节数结果读出来全是乱的。另外一次读太多扇区比如超过 64KB有些 U 盘会直接报 CSW 失败建议单次不超过 128 个扇区64KB。4.4 REQUEST SENSE出错时的救命稻草当 CSW 的bCSWStatus是 1失败时必须发REQUEST SENSE0x03拿具体错误码否则下一次命令会一直失败。CB0x03, 0x00, 0x00, 0x00, 0x12, 0x00返回 18 字节偏移 2 是 sense key偏移 12 是 additional sense code。常见的sense key 0x02Not ReadyU 盘没准备好sense key 0x03Medium Error介质错误sense key 0x05Illegal Request命令参数不对每次 CSW 失败后都要清 sense这是 BOT 协议的硬性要求不清的话后续命令全部失败。5. 从扇区数据到文件FAT32 解析的实操路径5.1 读引导扇区定位 BPB 关键字段拿到扇区读写能力后第一个扇区LBA 0就是引导扇区里面是 BPBBIOS Parameter Block。关键字段偏移长度字段用途112每扇区字节数通常 512131每簇扇区数计算簇大小142保留扇区数FAT 表起始位置161FAT 表数量通常 2172根目录项数FAT16 用FAT32 为 0192总扇区数小324总扇区数大364每 FAT 扇区数444根目录起始簇FAT32 通常是 2解析代码byte[] boot readSectors(0, 1); int bytesPerSector readShortLE(boot, 11); int sectorsPerCluster boot[13] 0xFF; int reservedSectors readShortLE(boot, 14); int numFats boot[16] 0xFF; long fatSizeSectors readIntLE(boot, 36); long rootCluster readIntLE(boot, 44);计算关键地址long fatStartSector reservedSectors; long dataStartSector reservedSectors numFats * fatSizeSectors; long clusterSize bytesPerSector * sectorsPerCluster;5.2 遍历根目录找到目标文件FAT32 的根目录是一个普通的簇链起始簇号在 BPB 偏移 44。每个目录项 32 字节偏移长度说明08短文件名8.3 格式83扩展名111属性0x10 目录0x20 归档0x0F 长文件名202起始簇号高 16 位262起始簇号低 16 位284文件大小短文件名的第一个字节如果是 0x00表示后面没有更多目录项了如果是 0xE5表示这个项被删除。长文件名LFN用属性 0x0F 标记需要把多个 LFN 项拼起来才是完整名字。读目录的流程long cluster rootCluster; while (cluster 0x0FFFFFF8) { long sector dataStartSector (cluster - 2) * sectorsPerCluster; byte[] dirData readSectors(sector, sectorsPerCluster); for (int i 0; i dirData.length; i 32) { if ((dirData[i] 0xFF) 0x00) break; if ((dirData[i] 0xFF) 0xE5) continue; int attr dirData[i 11] 0xFF; if (attr 0x0F) { /* 收集 LFN */ continue; } // 解析短名和起始簇 } cluster nextCluster(cluster); // 查 FAT 表 }5.3 顺着 FAT 表读文件内容FAT 表就是一张簇号的链表。nextCluster的实现long fatOffset cluster * 4; // FAT32 每项 4 字节 long fatSector fatStartSector fatOffset / bytesPerSector; int entryOffset (int) (fatOffset % bytesPerSector); byte[] fatData readSectors(fatSector, 1); long next readIntLE(fatData, entryOffset) 0x0FFFFFFF;读文件时从起始簇开始按簇读数据然后查 FAT 表找下一个簇直到遇到0x0FFFFFF8以上的结束标记。文件大小字段告诉你什么时候停避免多读。实操心得FAT 表建议缓存到内存。一个 32GB 的 U 盘FAT 表可能几 MB每次查簇都读扇区会慢到无法忍受。我一般启动时把整个 FAT 表读进一个int[]后续查簇直接数组访问速度提升几十倍。6. 实测中那些文档不会告诉你的坑6.1 廉价 U 盘的 CSW 延迟与超时我手上有几个杂牌 U 盘READ(10)之后 CSW 要等 2-3 秒才回来。如果 timeout 设 1000ms直接失败。但 timeout 设太大界面又卡。我的做法是把 USB 操作放到独立线程timeout 设 5000ms主线程用回调更新进度。别在主线程做任何bulkTransferAndroid 会直接抛NetworkOnMainThreadException类似的限制。6.2 扇区大小不一定是 512大部分 U 盘是 512 字节扇区但有些大容量盘尤其是 4K 原生盘是 4096。如果你硬编码 512读出来的 BPB 全是错的。必须先用 READ CAPACITY 拿到 blockSize后面所有计算都用这个值。6.3 拔盘时的异常处理用户随时可能拔盘这时候bulkTransfer会返回 -1 或抛异常。如果不处理应用直接崩。我的做法是每次bulkTransfer检查返回值小于 0 就抛自定义的UsbDisconnectedException。在BroadcastReceiver里监听ACTION_USB_DEVICE_DETACHED收到后立即停止所有 IO 线程。finally块里releaseInterface和close别泄漏连接。6.4 权限弹框的时机问题requestPermission弹框是系统级的如果用户在弹框时切到后台回调可能丢失。我一般会在onResume里重新检查权限状态而不是只依赖广播。另外同一个设备短时间内反复申请权限有些 ROM 会直接拒绝要加个节流。6.5 大端小端的反复横跳这是最容易出错的地方我列个对照表数据结构字节序CBW/CSW 所有字段小端SCSI 命令的 LBA、长度大端SCSI 返回的数据大端FAT BPB 字段小端FAT 目录项簇号小端我踩过的坑把 READ(10) 的 LBA 写成小端结果读出来的扇区永远是错的但 CSW 还返回成功排查了半天。建议每个读写函数命名时带上 BE/LE 后缀比如readIntBE、readShortLE强迫自己每次都想一下。7. 性能优化与代码组织建议7.1 批量读扇区减少命令往返一次READ(10)读 1 个扇区和读 128 个扇区命令开销是一样的。解析 FAT 和目录时尽量一次多读几个扇区。我实测读 64KB128 扇区在大多数 U 盘上是稳定的再大就有盘会报错。可以做成自适应先试 128失败就减半重试。7.2 用缓冲区池避免频繁分配USB 传输的字节数组如果每次都newGC 压力很大。我一般预分配几个固定大小的缓冲区比如 64KB 一个用ArrayBlockingQueue管理用完归还。对于读 U 盘这种 IO 密集场景能明显减少卡顿。7.3 分层设计别写成一坨我的代码结构是这样的UsbMassStorageDevice负责 USB 枚举、权限、端点、连接管理。BulkOnlyTransport负责 CBW/CSW 拼装、命令往返、错误恢复。ScsiCommands封装 INQUIRY、READ CAPACITY、READ(10)、REQUEST SENSE。Fat32FileSystem负责 BPB 解析、FAT 表缓存、目录遍历、文件读取。每层只依赖下一层测试时可以单独 mock。比如BulkOnlyTransport可以注入一个假的UsbDeviceConnection不用真插 U 盘就能测协议逻辑。7.4 进度反馈与取消机制读大文件时用户需要知道进度。我在Fat32FileSystem里加了个回调接口每读一个簇回调一次已读字节数。取消的话用一个volatile boolean标志在簇循环里检查收到取消就抛异常退出。别用Thread.interrupt因为bulkTransfer是阻塞的中断不一定能立刻生效。8. 关于这套方案适用边界的个人体会自己实现 USB U 盘读取最大的价值不是不用库而是你完全掌控了每一个字节的流向。遇到非标准设备、需要定制 SCSI 命令、或者要对接特殊文件系统时这套底层能力就是你的底气。但它也有明确的边界。如果你只是要读个 FAT32 的普通 U 盘libaums 确实更省事它的错误恢复和边界处理经过大量设备验证。自己写的话光是 CSW 失败后的 sense 清理、拔盘异常、不同扇区大小的兼容就够调一阵子。我的建议是先用这套方案把 INQUIRY 和 READ CAPACITY 跑通能正确打印出 U 盘的厂商、型号、容量说明你的 BOT 和 SCSI 实现是对的。然后再往上叠 FAT 解析。别一上来就想着读文件底层没通上层全是玄学 bug。另外exFAT 和 NTFS 的解析复杂度比 FAT32 高一个量级exFAT 的目录项是变长的NTFS 有 MFT 和属性列表。如果你的场景需要支持这些要么扩展解析层要么老老实实回到成熟库。我目前只实现了 FAT32exFAT 还在啃规范等跑通了再单独写一篇。最后分享一个调试技巧用 USB 抓包工具看真实的 CBW/CSW 字节流。自己拼的结构对不对抓一次包对比就知道了比对着规范一行行数偏移快得多。尤其是 tag 不匹配、数据残留不为 0 这类问题抓包一眼就能定位。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询