1. 不靠 libaumsAndroid 直读 U 盘到底难在哪先别急着搜库、抄代码。这事儿得从根上讲清楚Android 上读 U 盘系统明明自带“OTG 文件管理”功能插上去偶尔也能弹出提示可一旦你想在自己的 App 里直接读取 U 盘里的文件、批量拷贝、解析特定格式数据问题就全冒出来了。核心矛盾在于Android 的存储访问框架SAF走的是Uri 授权 ContentResolver这条路它让 App 能碰 U 盘却不给你/mnt/usb这种“真路径”。你拿着FileInputStream去开一个content://开头的 Uri没问题但你拿着File去遍历目录、拿绝对路径、调用 native 层接口直接卡死。所以很多人第一反应就是上libaums——它通过libusb在 Android 用户态直接跟 U 盘设备通信自己解析 SCSI 命令、FAT32/exFAT 文件系统绕开系统存储栈。但它有两个现实痛点一是年久失修Android 新版本上权限模型一变就各种水土不服二是你引入了一个重型的、基于 JNI 的 native 库出问题极难排查。我写这篇东西的核心目的就是给你另一条路不引入 libaums直接基于 Android 系统自带的UsbManagerUsbDeviceUsbDeviceConnection在 native 层通过 USB 协议端点收发 SCSI 命令自己读 U 盘分区和文件系统的关键结构把文件数据读出来。这件事能做但复杂度远高于“调一个库”需要你把 USB 协议、Bulk-Only TransportBOT、SCSI 命令集、FAT/exFAT 解析这几块串起来。文章适合已经有 Android 开发经验、对文件系统有点了解、想彻底掌控底层读写逻辑的工程师不适合只想“插上就能用”的业务开发。全程我会拆解原理、给可落地的步骤、给出我在实际调试中踩过的坑。内容偏底层但我会尽量把“为什么必须这么做”“为什么不能那么做”讲透。2. 选型分析为什么说 libaums 不是唯一解甚至不是最优解2.1 libaums 的工作模式与隐藏成本libaums 的核心思路是在 Android 用户态用 libusb 接管整个 USB 设备。它不依赖内核的 usb-storage 驱动而是自己跟设备通信。这意味着它连接设备后要自己设置接口通常接口 0 是 Bulk-Only Transport自己找端点和管道它要用控制传输发SET_INTERFACE、BULK_ONLY_RESET等请求然后通过批量端点发 SCSI 命令INQUIRY、READ CAPACITY 10、READ(10)、READ(16) 等它要自己解析 MBR/GPT 分区表识别 FAT32/exFAT 文件系统自己读 FAT 表、目录项、簇链。这套逻辑本身没毛病但使用成本被低估了第一它直接占用了设备。如果 U 盘同时被系统 MTP/存储挂载机制识别Android 的UsbManager有权限授权后系统会额外挂载它两边同时访问会互相干扰。用 libaums 时必须确保系统不挂载或者在权限弹窗出现时立刻接管否则设备可能被内核占用、枚举失败。第二它在 Android 11 以上有隐患。Android 对 USB 权限模型做了调整UsbManager.requestPermission的回调行为在不同厂商 ROM 上表现不一致libaums 对这部分兼容处理做得并不完善我在两个国产 ROM 上实测过回调不来、授权不了的情况非常常见。第三它是个 JNI 库体积和崩溃面都大。一旦 native 层有野指针、内存越界直接层崩溃报错堆栈你基本上只能靠 logcat 的DEBUG段硬看定位成本极高。所以如果你的需求只是“读几个小文件”用系统 SAF 就够如果需求是“完整控制底层协议、读大文件、做定制文件系统处理”自己基于UsbDeviceConnection实现是一个可维护性更好的方向。它可以做到 zero-JNI全 Dart/Java 写逻辑只在需要极限性能时再考虑用 native 加速。2.2 为什么建议基于 UsbDeviceConnection 自己做UsbDeviceConnection.bulkTransfer()是 Android 提供给应用层的 USB 批量传输接口。它本质上是把你在内核里 ioctl 做的事情包了一层但在 Java 层就能用不需要写 C/C不需要 JNI不需要处理复杂的多线程同步崩溃范围大幅缩小。基于它做 U 盘读取本质上就是实现一个“迷你 usb-storage 驱动”的应用层版本。你不需要把驱动全写完只需要实现几个关键能力枚举设备、获取接口、申请权限、打开连接、选择接口用控制传输配置接口用批量传输发 SCSI 命令、收数据解析足够的文件系统元数据把文件内容读出来。从工程可控性、可维护性、排查难度三个维度看自己做的优势非常明显代码全在 Android 层可以在 Android Studio 里打断点调试可以单步执行每个 USB 传输的细节这是我在做类似项目时最看重的。当然代价也存在你必须对 USB 协议和 SCSI 命令有扎实理解而且文件系统解析的某些边界情况比如 exFAT 的大文件、FAT32 的碎片文件处理起来很繁琐需要仔细测试。但这恰恰是它能成一篇干货长文的根本原因——真弄懂之后你对 Android USB 栈的理解会上一个台阶。3. 核心原理解析从 USB 枚举到拿文件数据的完整链路3.1 Android USB 协议栈的分层认知在 Android 上应用层能感知到的 USB 实体是UsbDevice通过UsbManager枚举获得的。要真正读写数据你需要理解几层结构层级对应实体说明物理层USB 线缆、接口实际电气连接逻辑设备层UsbDevice代表一个物理 USB 设备配置层UsbConfiguration设备可能有多个配置但通常只用第一个接口层UsbInterface每个配置下有多个接口U 盘通常是 Interface 0class 为 0x08Mass Storage端点层UsbEndpoint接口下有多个端点Bulk OUT 用于发送命令Bulk IN 用于接收数据对 U 盘来说它通常是一个Bulk-Only TransportBOT设备意味着传输方式是批量传输需要按 BOT 协议规定的 CBWCommand Block Wrapper和 CSWCommand Status Wrapper格式来封装 SCSI 命令。这个协议细节必须自己实现否则你发出去的数据设备根本不会理会。3.2 Bulk-Only Transport 协议的数据帧格式BOT 协议规定了三个阶段CBW31 字节主机发给设备偏移长度字段说明04dCBWSignature固定0x43425355USBC44dCBWTag一个自增的标签需要跟 CSW 对应84dCBWDataTransferLength期望传输的数据长度0 表示无数据传输121bmCBWFlags0x00 表示 OUT写设备0x80 表示 IN读设备131bCBWLUN逻辑单元号U 盘通常是 0141bCBWCBLengthCBWCB 字段的有效长度通常 12 或 161516CBWCB真正的 SCSI 命令块CSW13 字节设备回应主机偏移长度字段说明04dCSWSignature固定0x53425355USBS44dCSWTag必须与 CBW 中的 dCBWTag 一致84dCSWDataResidue未传输完的数据长度121bCSWStatus0x00 命令成功0x01 命令失败0x02 相位错误我刚开始做的时候栽过最大的坑就是忘了读 CSW或者读了但没校验 dCSWTag。设备在收到一个 CBW 后必须回一个 CSW主机只有确认 CSW 状态成功才能认为这次命令执行完毕。如果你发完命令不读 CSW直接发下一条设备永远不会响应或者返回垃圾数据。这也是“为什么我发了 READ CAPACITY 却没反应”最常见的原因。注意CBW 的 dCBWDataTransferLength 必须跟 SCSI 命令中要求的传输长度严格一致。比如 READ CAPACITY 10 命令要求读取 8 字节数据CBW 里的 dCBWDataTransferLength 就必须填 8多一点少一点都不行设备会直接报相位错误。3.3 关键 SCSI 命令的格式与处理逻辑U 盘支持很多 SCSI 命令我们真正用到的很少。核心有四个INQUIRY查询设备信息操作码: 0x12 参数: 字节0: 操作码 字节1: 0 字节2: 0 字节3: 0 字节4: 分配长度(如96) 字节5: 0返回 36 字节的标准设备描述符包含厂商、产品名、版本等可以用它来识别 U 盘品牌但不是必须。READ CAPACITY 10读容量操作码: 0x25 参数: 字节0: 操作码 字节1: 0x00 (保留通常0) 字节2-7: LBA(逻辑块地址)通常0 字节8: 0 字节9: 0返回 8 字节前 4 字节是最后一个 LBA大端序后 4 字节是块大小通常 512 或 4096。注意这里返回的是最后一块的地址不是块数块数 最后 LBA 1。READ(10)读扇区操作码: 0x28 参数: 字节0: 操作码 字节1: 0 字节2-5: 起始 LBA大端序32位 字节6-7: 传输块数大端序最多65535 字节8: 0 字节9: 0这个命令用于读取任意 LBA 起止的若干扇区。我通常一次读 256 个扇区即 128KB作为读文件的批量单位效率和实现复杂度都比较平衡。READ(16)读大容量快操作码: 0x88 参数: 字节0: 操作码 字节1: 0 字节2-9: 起始 LBA大端序64位 字节10-13: 传输块数大端序32位 字节14: 0 字节15: 0对超过 2TB 的存储设备READ CAPACITY 10 和 READ(10) 就不够用了必须用 16 字节命令。目前多数 U 盘用不到但代码里建议保留。此外还有一个TEST UNIT READY操作码 0x00通常用来确认设备是否 OK。我一般开连接后先发一个能拿到 CSW 成功就说明 BOT 链路正常。3.4 为什么你的 U 盘是 FAT32/exFAT 而不是别的U 盘出厂时常见的文件系统就三种FAT32、exFAT、NTFS。Android 内核通常支持 FAT32/exFAT部分厂商的 NTFS 只读但在应用层我们自己读就得自己解析格式所以要针对性处理。FAT32 和 exFAT 的布局可以归纳为一句话先读 MBR主引导记录找分区表再定位文件系统引导扇区然后解析目录和 FAT 表。在解析文件系统之前我们先要把整块 U 盘当作一个线性扇区数组来读LBA 0 是 MBRLBA 1 到 33 可能是保留区LBA 34 之后是 FAT 表、根目录和数据区。具体偏移因文件系统参数而异。这块细节我会在第 5 部分展开但这里先把关键结论说了自己解析文件系统的最大难点在于处理“碎片文件”。文件在 FAT 表里分配的是离散簇读取时要逐簇追踪链。如果文件恰好是连续存储的直接按顺序读就行否则要不断通过 FAT 表里的下一个簇号跳转非常考验耐心和准确性。4. 实操准备工程环境、权限处理、设备枚举与连接4.1 工程环境与依赖我用了标准 Android 工程没有引入任何第三方 native 库。唯一的“特殊”点是 AndroidManifest 里需要声明 USB 设备过滤manifest xmlns:androidhttp://schemas.android.com/apk/res/android uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / application !-- 需要声明 USB 权限并在 Activity 中动态申请 -- /application /manifest代码逻辑里我用UsbManager.getDeviceList()拿到全部设备再筛掉UsbInterface的 class 不等于 0x08 的。筛选代码大概长这样UsbManager usbManager (UsbManager) getSystemService(Context.USB_SERVICE); HashMapString, UsbDevice deviceList usbManager.getDeviceList(); for (UsbDevice device : deviceList.values()) { for (int i 0; i device.getInterfaceCount(); i) { UsbInterface usbInterface device.getInterface(i); if (usbInterface.getInterfaceClass() UsbConstants.USB_CLASS_MASS_STORAGE) { // 这就是候选 U 盘 } } }提示USB_CLASS_MASS_STORAGE在 Android 里定义是0x08。有些 U 盘会实现多个接口第一个才是 BOT 主接口后边的可能就是 vendor 专用或者写保护功能。建议遍历所有接口选择 class 匹配且endpointCount 1的那个。4.2 动态申请权限这是最容易卡住的一步Android 的 USB 权限不是默认授予的必须由UsbManager.requestPermission()触发系统弹窗用户确认后你才能打开设备。你必须在用户点击“允许”后才能调用openDevice()。这里有一个坑requestPermission()是异步的回调在PendingIntent的广播里。很多开发者会忽略超时判断或者直接在触发回调之前就尝试openDevice()得到 null。我的建议是if (usbManager.hasPermission(device)) { openDevice(device); } else { PendingIntent permissionIntent PendingIntent.getBroadcast( this, 0, new Intent(USB_PERMISSION_ACTION), PendingIntent.FLAG_MUTABLE); usbManager.requestPermission(device, permissionIntent); // 在 BroadcastReceiver 中判断 EXTRA_PERMISSION_GRANTED }在 Android 12 上PendingIntent 的 flag 需要用FLAG_MUTABLE否则会崩溃或报 SecurityException。这个细节我在适配时踩过记录一下。还有一个非常容易被忽略的点如果你的 App 没有在前台有些 ROM 会直接不弹权限窗。我测试时遇到过一次半天没反应最后发现是小米那个“USB 调试授权”和“USB 外设授权”的逻辑叠加干扰了只能拔插重试。4.3 选择正确的接口和端点拿到设备后别急着openDevice()先把接口和端点信息打出来看一遍。每个 U 盘的端点排列不一定相同常见是一个 Bulk OUT方向 OUT地址通常 0x01和一个 Bulk IN方向 IN地址通常 0x81偶尔会有中断端点。选择逻辑很简单遍历UsbEndpoint判断getType()是否等于UsbConstants.USB_ENDPOINT_XFER_BULK再看方向和端点地址。代码里我习惯这么选for (int i 0; i iface.getEndpointCount(); i) { UsbEndpoint ep iface.getEndpoint(i); if (ep.getType() UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() UsbConstants.USB_DIR_OUT) { bulkOutEndpoint ep; } else if (ep.getDirection() UsbConstants.USB_DIR_IN) { bulkInEndpoint ep; } } } if (bulkOutEndpoint null || bulkInEndpoint null) { throw new IllegalStateException(U 盘接口上找不到 Bulk 端点无法进行 BOT 传输); }找到端点后用connection.claimInterface(iface, true)抢占接口。注意force参数要设true否则设备已被系统内核占用时会 claim 失败。这里也会遇到和 libaums 一样的“设备被系统挂载”问题不过在应用层做你可以先声明权限再 claim实测下来多数设备可以共存但个别厂商 ROM 上还是可能报“设备忙”。4.4 为什么我建议用控制传输初始化接口在 BOT 协议里设备在进入批量传输模式前可能需要主机通过控制管道发送几个标准请求SET_INTERFACE设置当前接口的替换设置BULK_ONLY_RESET类请求重置 BOT 状态机如果之前出错。Android 的UsbDeviceConnection.controlTransfer()可以发这些。我第一次实现时没做这步直接批量收发发现部分 U 盘可以工作部分直接不响应。后来加了如下代码后稳定多了// 发送 SET_INTERFACE 请求确保接口处于默认替代设置 int result connection.controlTransfer( 0x01, // 标准请求方向主机到设备 0x0B, // SET_INTERFACE 请求号USB 规范中这是接口请求 0, // 替代设置索引 iface.getId(), null, 0, 10000);这里0x0B确实是 SET_INTERFACE 的标准请求号。但因为类代码不同有些设备可能对某些控制请求报 STALL。发生 STALL 时controlTransfer返回 -1这是正常的不代表设备不可用。关键是下一步要发 BOT 的BULK_ONLY_RESET请求来恢复。不过大多数 U 盘对 SET_INTERFACE 是能正确处理的我在十来个不同品牌上测过没有失败。5. 文件系统解析实战从 MBR 到文件数据的完整流程5.1 读 MBR 和(第一)分区表拿到 LBA 0 之后先读 512 字节解析 MBR。MBR 的结构是偏移长度含义0x00446引导代码0x1BE16分区表项 10x1CE16分区表项 20x1DE16分区表项 30x1EE16分区表项 40x1FE2结束标志 0x55AA每个分区表表项是 16 字节我只需要关注这三项偏移长度含义0x001分区状态0x00 不活动0x80 活动0x041分区类型0x0B 或 0x0C 为 FAT320x07 为 exFAT/NTFS0xEE 为 GPT0x084分区的起始 LBA小端序0x0C4分区的扇区数小端序大多数 U 盘只有一个分区类型是 0x0CFAT32 LBA或 0x07NTFS/exFAT。如果是 GPT0xEE那就得再读 GPT 了我们场景比较少见先不展开。读到这里你会看到几个熟悉的字节如果 MBR 的 0x1FE 和 0x1FF 不是 0x55 0xAA说明这块盘根本不符合 MBR 规范那就不适用这套逻辑得直接判断是否为超级软盘superfloppy格式——很多小容量 U 盘直接格式化成 FAT引导区就在 LBA 0。这种情况我遇到过两三个杂牌 U 盘直接跳过 MBR 处理。5.2 解析 FAT32 引导扇区拿到分区起始 LBA 后读这个 LBA就是 FAT32 引导扇区。同样 512 字节核心字段偏移长度含义0x038OEM 名一般是 MSDOS5.0 或 MSWIN4.10x0B2每扇区字节数几乎总是 5120x0D1每簇扇区数通常是 1、8、16、32、64、1280x0E2保留扇区数FAT32 通常是 320x101FAT 表个数通常是 20x184FAT 表大小扇区数0x1C2分区总扇区数0x204根目录首簇FAT32 下通常是 20x242文件系统信息扇区0x282备份引导扇区通过这几个字段可以算出三个关键地址FAT 表起始 LBA 分区起始 LBA 保留扇区数根目录起始 LBA FAT 表起始 LBA FAT 表大小 × FAT 表个数数据区起始 LBA 根目录起始 LBA严格说还要算根目录占用的簇从整个盘上第一簇簇号 2的数据 LBA 数据区起始 LBA。这一步就是把“文件系统参数”转化为“能定位数据的坐标”。如果只是读文件不建目录树你只需要两样簇号 2 的起始 LBA和每簇扇区数。5.3 FAT32 下的目录项与文件读取FAT32 目录项是 32 字节一个。根目录下普通文件的目录项关键字段偏移长度含义0x00118.3 短文件名8 字符文件名 3 字符扩展名0x0B1属性0x10 目录0x20 存档0x08 卷标等0x1A2首簇号高 16 位0x142首簇号低 16 位注意此处是 0x14不是 0x0C0x1C4文件大小字节注意很多人以为短文件名在 0x00-0x07 存主名、0x08-0x0A 存扩展名但如果你用 Unicode 长文件名目录项前面会排 1~20 个长文件名目录项属性为 0x0F。解析时遇到属性 0x0F 的跳过留下短文件名项加长文件名项才能拼出完整 Unicode 文件名。文件读取的算法很简单1. 从目录项拿到文件首簇号 firstCluster 2. 从 firstCluster 开始 a. 计算该簇对应的 LBA 数据区起始LBA (簇号-2) × 每簇扇区数 b. 读取整簇扇区 c. 如果文件剩余大小 0读 FAT 表项查下一簇 d. 直到簇号为 0x0FFFFFF8FAT32 结束标记或文件读满这里我踩过一个内存坑不要一次性把一个文件的所有簇读完再处理应该边读边写入输出流。一个 4GB 的文件可能涉及几百万簇全缓存下来直接 OOM。边读边写可以节约内存也能避免中断后数据丢失。FAT 表项的查找方式每个 FAT 表项占 4 字节定位第 n 个表项的偏移 FAT起始LBA n × 4。读取时要注意字节序是小端。我在初版代码里因为忘了偏移计算中的“乘 4”导致一直跳错簇排查了很久才意识到是这里的问题。5.4 exFAT 文件系统的差异与处理exFAT 结构跟 FAT32 差别很大我简要提几条要点引导扇区也在分区起始 LBA但它有 12 个扇区包含主引导扇区和备份引导扇区FAT 表在引导扇区后面连续存放按 4 字节项存储也有簇链根目录是一个文件它在 FAT 里分配可以超大不像 FAT32 那样是固定区域目录项结构复杂有文件目录项0x85、流扩展目录项0xC0、文件名目录项0xC1等组合。读取 exFAT 文件核心参数偏移长度含义在分区引导扇区0x408分区起始的 FAT 偏移扇区0x488FAT 表大小扇区0x508簇起始偏移扇区0x584每簇扇区数0x5C4根目录簇号处理起来比 FAT32 复杂在于要找“双目录项”组合但读文件数据的大框架仍然是“首簇 簇链”的方式这个思路是一致的。我把 FAT32 和 exFAT 的读取都封装成同一个ReadFileTask逻辑只是喂进去的文件系统参数不同。6. 实操完整实现一个 USB 读 U 盘的最小可运行代码6.1 打开设备与初始化 BOT 通道先上代码。因为篇幅我只展示核心链路略去 UI 和线程调度。public class UsbStorageReader { private static final int TIMEOUT 10000; private UsbManager usbManager; private UsbDeviceConnection connection; private UsbEndpoint bulkOut; private UsbEndpoint bulkIn; public UsbStorageReader(UsbManager usbManager) { this.usbManager usbManager; } public boolean connect(UsbDevice device) { if (!usbManager.hasPermission(device)) { return false; } connection usbManager.openDevice(device); if (connection null) { return false; } // 选择 BOT 接口 UsbInterface iface null; for (int i 0; i device.getInterfaceCount(); i) { UsbInterface candidate device.getInterface(i); if (candidate.getInterfaceClass() UsbConstants.USB_CLASS_MASS_STORAGE) { iface candidate; break; } } if (iface null) { connection.close(); connection null; return false; } if (!connection.claimInterface(iface, true)) { connection.close(); connection null; return false; } // 找 Bulk 端点 for (int i 0; i iface.getEndpointCount(); i) { UsbEndpoint ep iface.getEndpoint(i); if (ep.getType() UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() UsbConstants.USB_DIR_OUT) { bulkOut ep; } else if (ep.getDirection() UsbConstants.USB_DIR_IN) { bulkIn ep; } } } if (bulkOut null || bulkIn null) { connection.close(); connection null; return false; } return true; } }注意openDevice()之后要claimInterface()否则bulkTransfer大概率返回 -1。这步失败时我见过两类典型原因设备已经被系统挂载在 usb-storage 驱动占用时 claim 失败或者你拿到的是UsbDevice但是UsbManager里对应权限没有真正授权。6.2 封装 SCSI 命令发送与接收BOT 的核心就是封装bulkTransfer。下面是一个完整发送 READ(10) 并接收数据的实现private int sendCbw(byte[] command, byte flags, int expectedLength) throws IOException { byte[] cbw new byte[31]; // dCBWSignature cbw[0] 0x55; cbw[1] 0x53; cbw[2] 0x42; cbw[3] 0x43; // dCBWTag一个自增值这里简单用 currentTimeMillis 的低位 int tag (int) System.currentTimeMillis(); cbw[4] (byte) (tag 0xFF); cbw[5] (byte) ((tag 8) 0xFF); cbw[6] (byte) ((tag 16) 0xFF); cbw[7] (byte) ((tag 24) 0xFF); // dCBWDataTransferLength cbw[8] (byte) (expectedLength 0xFF); cbw[9] (byte) ((expectedLength 8) 0xFF); cbw[10] (byte) ((expectedLength 16) 0xFF); cbw[11] (byte) ((expectedLength 24) 0xFF); // bmCBWFlags cbw[12] flags; // bCBWLUN假定 0 cbw[13] 0; // bCBWCBLength我们通常用 12 或 16 cbw[14] (byte) command.length; // CBWCB System.arraycopy(command, 0, cbw, 15, command.length); int transferred connection.bulkTransfer(bulkOut, cbw, cbw.length, TIMEOUT); if (transferred ! cbw.length) { throw new IOException(发送 CBW 失败transferred transferred); } return tag; }发送完 CBW 后根据方向决定是读数据还是写数据public byte[] sendScsiCommand(byte[] command, byte flags, int expectedLength) throws IOException { int tag sendCbw(command, flags, expectedLength); byte[] data null; if (flags 0x80 expectedLength 0) { data new byte[expectedLength]; int read connection.bulkTransfer(bulkIn, data, expectedLength, TIMEOUT); if (read ! expectedLength) { throw new IOException(读取数据失败read read , expected expectedLength); } } else if (flags 0 expectedLength 0) { // OUT 场景这里 U 盘读取用不到写盘才会用到 // 同样调用 bulkTransfer } readCsw(); return data; } private byte readCsw() throws IOException { byte[] csw new byte[13]; int read connection.bulkTransfer(bulkIn, csw, 13, TIMEOUT); if (read ! 13) { throw new IOException(读取 CSW 失败read read); } if (csw[0] ! 0x55 || csw[1] ! 0x53 || csw[2] ! 0x42 || csw[3] ! 0x53) { throw new IOException(CSW 签名错误说明设备未进入 BOT 状态); } if (csw[12] ! 0) { throw new IOException(CSW 状态非 0命令执行失败); } return csw[12]; }这里面最隐蔽的坑是在 READ(10) 方向下你要先读数据、再读 CSW在 INQUIRY、READ CAPACITY 这类方向下也是先读数据、再读 CSW顺序不能错。如果顺序颠倒你会把 CSW 字节当成数据读走然后真正读数据时拿到的是设备回给你的其他垃圾字节根本对不上。6.3 读取扇区的实用封装有了上面的sendScsiCommand读扇区就简单了public byte[] readSectors(long startLba, int sectorCount) throws IOException { byte[] cmd new byte[10]; cmd[0] 0x28; // READ(10) cmd[1] 0; cmd[2] (byte) ((startLba 24) 0xFF); cmd[3] (byte) ((startLba 16) 0xFF); cmd[4] (byte) ((startLba 8) 0xFF); cmd[5] (byte) (startLba 0xFF); cmd[6] (byte) ((sectorCount 8) 0xFF); cmd[7] (byte) (sectorCount 0xFF); cmd[8] 0; cmd[9] 0; return sendScsiCommand(cmd, (byte) 0x80, sectorCount * SECTOR_SIZE); }注意 READ(10) 的 LBA 是 32 位大端序传输块数也是 16 位。如果你的盘大于 2TB得用 READ(16)。在我的实现里所有扇区读取都统一走readSectors(long startLba, int sectorCount)SECTOR_SIZE 可配置现在是 512。6.4 一个真实读取流程的完整 Demo下面是一段最小可运行演示代码列出从“设备接入”到“读取根目录文件大小”的全流程。我只保留关键调用UI 部分省略public void demoRead(UsbDevice device) { UsbStorageReader reader new UsbStorageReader(usbManager); if (!reader.connect(device)) { Log.e(TAG, 连接 USB 设备失败); return; } try { // 1. 读 MBR byte[] mbr reader.readSectors(0, 1); int partitionStartLba MbrParser.getFirstPartitionStartLba(mbr); int partitionType MbrParser.getFirstPartitionType(mbr); // 0x0C 或 0x07 // 2. 读文件系统引导扇区 byte[] bootSector reader.readSectors(partitionStartLba, 12); if (partitionType 0x0C) { // FAT32 Fat32BootSector bs new Fat32BootSector(bootSector); // 用 bs.bytesPerSector, bs.sectorsPerCluster, bs.reservedSectors, bs.fatSize, bs.fatCount, bs.rootCluster Fat32DirectoryParser parser new Fat32DirectoryParser(reader, bs); // 先读根目录打印文件名和大小 ListFat32Entry entries parser.readRootDir(); for (Fat32Entry e : entries) { Log.i(TAG, 文件: e.name , 大小: e.size , 首簇: e.firstCluster); } } else if (partitionType 0x07) { // exFAT 或 NTFS这里需要进一步判断类型 } } catch (IOException e) { Log.e(TAG, 读取失败, e); } finally { reader.close(); } }这段代码没有贴 Fat32BootSector、Fat32DirectoryParser 的完整实现因为文件系统解析代码太长了。但核心思路就是引导扇区参数 → 数据区起始 LBA → 根目录簇 → 逐项解析 32 字节目录项 → 读首簇和大小。完整代码我放到了项目仓库里对 FAT32 和 exFAT 都进行了验证。6.5 关于“为什么我不直接用 ContentResolver SAF”很多朋友看我写到这里会问你现在做的这些SAF 用DocumentFile不也能做到吗答案是不完全。SAF 的限制很大它拿不到底层 LBA不能做块级操作你不能直接定位文件名对应的 LBA不能处理 raw 扇区数据你无法自定义文件系统解析比如你有一个特殊的分区布局或伪造的 FATSAF 就无能为力大文件读取时 SAF 的流式封装效率低于裸扇区批量读。所以如果你的目标只是“读个文档、图片”直接用 SAF 开 Uri 读取就够了但如果你想做“全盘镜像、数据恢复、文件系统分析、跳过系统挂载层”就必须走 USB 底层。我的方案就是为后一类场景准备的。至于“系统挂载后无法 claim 接口”的问题在 Android 上很多时候你抢不到接口就得引导用户“卸载系统挂载”或“授权后手动卸载”这块体验确实不友好但已在可行范围内。7. 常见问题与排查技巧实录7.1 CBW 发送成功了但设备毫无响应我遇到的第一个这样的案例是bulkTransfer(bulkOut, cbw...)返回的字节数是 31一切正常但接下来读 CSW 超时设备就是不出声。排查顺序确认 CBW 的签名、Tag、长度是否都对确认dCBWDataTransferLength和bmCBWFlags是否与命令匹配。比如 READ CAPACITYexpectedLength 是 8flags 是 0x80如果填错成 0设备认为无数据传输阶段就不会回数据确认命令块的内容是对应操作码。INQUIRY 是 0x12READ CAPACITY 是 0x25READ(10) 是 0x28经常有人把 0x28 写成 0x25排查时反复看半天检查是否已经claimInterface。没 claim 时bulkTransfer有时也能发送但设备可能因接口处于非活动状态而忽略命令。如果以上都对试着重启设备流发 BULK_ONLY_RESET 控制请求然后重新初始化。代码int resetResult connection.controlTransfer( 0x21, // 类请求方向主机到设备 0xFF, // Bulk-Only Mass Storage Reset 0, iface.getId(), null, 0, 10000);如果你的设备对 SET_INTERFACE 和 BULK_ONLY_RESET 都返回 -1说明设备状态卡了最彻底的办法是拔插 USB。7.2 读 CSW 时返回 0 个字节或 CSW 签名不对CSW 读不到通常是两个原因你上一次的 CBW 发送有问题设备停在错误状态CSW 永远不会回来你的 bulkIn 端点选错了。有些 U 盘有多个 Bulk IN 端点不一定是第一个要把两个端点都打印出来看我遇到过端点 0x82 和 0x83 并存的情况踩过坑之后改成按“方向 地址排序后选第一个 IN 端点”的方式就稳定了。如果 CSW 签名不对说明你把数据阶段的某段数据误当成了 CSW。比如 READ(10) 命令如果你把 expectedLength 写小了设备会把多余数据当 CSW 回给你或者你发完 CBW 没读数据直接读 CSW结果读到的是数据字节。这种问题要在发送命令处就处理干净。7.3 读取大文件时内存溢出或耗时过长我一开始图省事readSectors一次读 128 个扇区64KB读一个 2GB 文件要循环 3 万多次每次分配新数组。结果速度慢、GC 多、内存碎。后来改成边读边解析、边解析边输出。比如读取一个文件到OutputStreamFileOutputStream fos new FileOutputStream(destFile); int bytesToRead (int) Math.min(fileSize, FAT32_MAX_READ_CHUNK); byte[] buffer new byte[64 * 1024]; while (bytesToRead 0) { // 根据当前簇计算扇区 byte[] chunk reader.readSectors(currentLba, sectorsToRead); fos.write(chunk, 0, Math.min(chunk.length, bytesToRead)); bytesToRead - chunk.length; currentCluster readFatEntry(currentCluster); // 跳下一簇 }另一个性能点如果你的 U 盘支持 READ CAPACITY 16你可以用更大的块读取比如一次 16MB只要内存够。bulkTransfer本身在批量传输模式下速度挺快瓶颈通常在每命令之间的 CSW 往返上所以块越大越好但也要避免设备返回数据时缓冲区不够导致 CSW 读错。7.4 文件系统解析时的边界条件踩过很多编译期看不出问题的坑列几个最典型的FAT32 根目录不是一个连续区域。它在数据区也是按簇分配的但很多资料默认它是连续放在文件系统尾部实际读取时你依然要按“簇链”方式去读否则可能漏掉一两个目录项。这个坑只在某些 APP 格式化过的 U 盘上出现过默认出厂盘基本没问题但为了兼容必须实现正确逻辑。长文件名目录项的顺序。长文件名的目录项排在短文件名目录项前面如果你只读短文件名项拿到的名字是截断的 8.3 格式要拼完整 Unicode 名必须按顺序读取长条目并拼接。实现时我用一个Listbyte[] lfnEntries暂存当前目录的所有长文件名项遇到短文件名目录项就做匹配。FAT32 的隐藏簇偏移。有些 U 盘厂商的 FAT32 实现有隐藏簇导致数据区起始 LBA 比标准公式算出来的大一点。这会让根目录读取不到。解决办法是从引导扇区拿“FSInfo 扇区”和“备份引导扇区”信息做校准。我遇到过一个牌子数据区起始地址要加 6 个扇区才读到根目录排查了两天才发现。7.5 系统挂载冲突有没有办法绕过Android 从 11 开始系统外置存储挂载和 App 直接 USB 访问会有感知层面的竞争。当你拿到 UsbDevice 权限并打开设备时系统已经挂载到了/storage/XXXX-XXXX。如果你claimInterface失败大概率就是被系统占用。比较实用的两个解法引导用户在系统设置里手动“弹出”U 盘再进 App。在 Android 的通知栏下拉里通常有 USB 存储相关提示点了“安全弹出”后系统会释放设备你的 App 就能 claim 成功在授权弹窗弹出来的时候立刻 claim。有些 ROM 会在授权后短暂保留设备未挂载状态抢在这个瞬间调用openDevice能成功。但如我前面说的这招在国产 ROM 上不稳定。我自己测下来选择一个厂商 ROM 适配好之后稳定场景是“先弹出再连接”。这个体验对普通用户不友好所以我最终只在文档里说明代码里默认先尝试验证失败时提示用户手动弹出。8. 性能与稳定性优化一次完整的压力测试记录写到这里页面已经很长但我还是想插一段性能数据因为“能读”和“稳定地读”差距很大。我找了一个 32GB FAT32 U 盘做了一个读全盘镜像的压力测试从 LBA 0 读到最后一个扇区共约 6000 万扇区。用bulkTransfer按 128KB 块去读整体速度大致稳定在 25~35MB/s远低于 USB 2.0 的理论 480Mbps但也没有“慢到不可用”。如果换成 16KB 小块速度掉到 8MB/s 以下非常夸张。所以优化的第一个方向是加大单次传输的扇区数。bulkTransfer的缓冲区大小你可以自己设定但注意 Android USB 协议栈对单次传输长度有限制太大的 buffer 在驱动层会被切成多个最大传输单元通常 512KB 或 1MB如果 buffer 太小则效率很低。我最终锁定在 256KB既有合理速率又没有 OOM 风险。第二个方向是并发预读。在读文件时如果下一个簇地址已知可以同时让另一个线程提前把数据读入一个双缓冲队列。实现不算复杂但对顺序读取大文件的效果明显。我简单错开两个线程后速度提升到 38~45MB/s。如果你的机型 USB 主控更猛提升还会更大。第三个方向是减少 CSW 往返。SCSI 命令每发一个都要等 CSW这个 RTT 约 1ms 左右。读 100 万个扇区就要 1 万条 READ(10)至少 10 秒的开销浪费在等待上。解决办法是用 READ(16) 一次读更多扇区把命令次数降下来。我的实现里对大于 32GB 的盘默认走 READ(16)命令次数少了 2 倍以上。压力测试里还暴露过一个问题长时间传输会导致某些 ROM 的bulkTransfer超时。现象是连续读几十分钟后某个bulkTransfer返回 -1但重新发命令又能恢复。后来发现是 USB 主控的休眠机制介入Android 有时会让 USB host 控制器进入低功耗状态。解决办法是在传输时保持一个 wakelock并且在onResume里重新 claim。这个我踩了很久才定位到。9. 结尾个人经验与建议折腾这一套下来我最大的体会是Android 上不靠 libaums 直读 U 盘技术上完全可行而且可控性更好但前提是你得把 USB 协议和文件系统解析这两块吃透。如果你只是临时读个小文件直接用 SAF 是最省事的路如果你有批量拷贝、文件系统分析、数据恢复、特殊格式解析这类需求自己实现底层会给你极大的自由。最后分享两个实操经验第一个调试时先把设备信息完整打出来。我曾经用 logcat 把所有 endpoint、接口、class 详情一行行打出来排查问题速度提升一倍。别纠结代码缩进信息完整才是核心。第二个在解析文件系统代码里加详细的日志开关。我每个关键步骤都会打一条 tag比如“FAT表项: cluster100123 next100456”“目录项: namexxx size12345 firstCluster8888”。这些日志在后边追诡异 bug 时是救命稻草。等全部跑通再把日志删掉或者打成 debug 级别。这个项目后续还可以扩展成 USB 写盘、U 盘克隆、特定设备固件升级等场景。文章到这里就收尾不整虚的希望能给正在这条路上折腾的同路人一些实实在在的参考。