
U 盘插上 Android 设备之后系统到底做了什么这个问题我在做车载文件管理项目时被反复追问过。当时的需求很明确设备上插一个 U 盘App 要能直接读取里面的媒体文件不能依赖第三方库也不能要求用户去系统设置里手动挂载。团队第一反应是找现成方案libaums 几乎是所有人的默认答案。但当我们真正把它拆开看了一遍之后发现它做的事情并没有想象中那么神秘——本质上就是把 USB 协议栈里最基础的那几层用 Java 重新实现了一遍。既然这样那不如自己走一遍搞清楚每一步到底在干什么。这篇文章适合两类人一类是正在做 Android USB 外设通信、被 libaums 的封装挡住了视线想弄明白底层到底怎么跑的开发者另一类是对 USB 协议本身感兴趣想找一个具体场景把 Bulk-Only Transport、SCSI 命令集这些概念落到实处的工程师。我会从 Android USB Host API 的边界讲起一步步拆到 U 盘枚举、Bulk-Only 传输、SCSI 读扇区最后给出一个能跑通的最小实现思路。全程不依赖 libaums只用 Android 自带的android.hardware.usb包。1. 先搞清楚 Android USB Host API 到底给了我们什么很多人一上来就想着怎么读文件结果卡在第一步设备根本拿不到 U 盘的访问权。Android 的 USB Host 模式并不是插上就能用的它有一套自己的权限和枚举流程。如果你不理解这套流程的边界后面写再多 SCSI 命令都是白搭。1.1 UsbManager 与设备枚举的真实行为Android 通过UsbManager来管理所有 USB 设备。调用getDeviceList()会返回一个HashMapString, UsbDevicekey 是设备名value 是设备对象。这里有一个很容易被忽略的点这个列表返回的是当前已经连接到系统并且被内核识别到的 USB 设备但不代表你的 App 有权限访问它们。U 盘插入后内核的 USB 存储驱动usb-storage通常会先抢着去识别它把它当成一个块设备挂载到/mnt/media_rw/或者类似路径下。这时候你的 App 通过UsbManager看到的设备是存在的但如果你直接去openDevice()很可能会抛异常因为设备已经被内核驱动占用了。注意Android 从 6.0 开始对 USB 设备的访问控制越来越严格普通 App 想要拿到一个已经被系统挂载的 U 盘的原始 USB 接口往往需要先让系统释放这个接口或者你的 App 具备系统级权限。那怎么办两条路。第一条是走 Storage Access Framework让用户通过系统文件选择器授权访问 U 盘里的文件这条路简单但受限于 SAF 的能力很多底层操作做不了。第二条就是我们要讲的在 USB 层面直接和 U 盘通信绕过文件系统层。这条路要求你的 App 能够拿到UsbDeviceConnection和对应的UsbInterface。1.2 权限申请与 Intent 过滤的坑要让 App 拿到 USB 设备的访问权标准做法是注册一个USB_DEVICE_ATTACHED的广播接收器或者在AndroidManifest.xml里声明android.hardware.usb.action.USB_DEVICE_ATTACHED的 intent filter并附带一个device_filter.xml来指定你关心的设备类型。activity ... intent-filter action android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED / /intent-filter meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/device_filter / /activitydevice_filter.xml里可以按 vendor-id、product-id、class、subclass、protocol 来过滤。U 盘通常的 class 是 0x08Mass Storagesubclass 是 0x06SCSI Transparent Command Setprotocol 是 0x50Bulk-Only Transport。你可以这样写resources usb-device class8 subclass6 protocol80 / /resources但这里有个现实问题很多设备在插入时已经被系统挂载了你的 Activity 不一定会被拉起。这时候你需要主动调用UsbManager.requestPermission()传入一个 PendingIntent等用户授权后再去openDevice()。PendingIntent permissionIntent PendingIntent.getBroadcast( context, 0, new Intent(ACTION_USB_PERMISSION), 0); usbManager.requestPermission(device, permissionIntent);授权回调里拿到EXTRA_PERMISSION_GRANTED为 true 之后才能继续。这个流程看起来简单但在实际项目里用户点击“允许”之后openDevice()仍然可能返回 null原因通常是内核驱动还占着接口。这时候你需要考虑用UsbManager的setInterface()或者更底层的claimInterface()来强制获取接口控制权——但后者在非 root 设备上不一定成功。1.3 为什么不能直接走文件系统有人会问既然系统已经挂载了 U 盘我直接读/mnt/media_rw/XXXX-XXXX/不就行了吗理论上可以但有几个致命问题。第一这个路径的访问权限通常只给系统应用或者media_rw组普通 App 没有读权限。第二不同厂商的挂载路径和权限策略差异很大你没法保证代码在所有设备上都能跑。第三如果你要做的是格式化、分区、底层扇区读写这类操作文件系统层根本满足不了。所以走 USB 协议直接和 U 盘对话虽然复杂但换来的是最大的可控性和跨设备一致性。这也是 libaums 存在的意义——它帮你把这层复杂性封装掉了。但封装意味着黑盒出问题的时候你很难定位。自己实现一遍至少你知道每一步在干什么。2. Bulk-Only TransportU 盘通信的骨架U 盘属于 USB Mass Storage 设备它使用的传输协议叫 Bulk-Only Transport简称 BOT。这个名字听起来很学术但拆开看其实很直白它只用了 USB 的 Bulk 端点来传数据没有中断端点也没有等时端点。整个通信过程就是“发命令、传数据、收状态”三步循环。2.1 BOT 的三阶段模型BOT 的每一次完整事务由三个阶段组成Command Stage主机通过 Bulk OUT 端点发送一个 31 字节的命令块包装Command Block WrapperCBW。Data Stage根据命令的方向通过 Bulk IN 或 Bulk OUT 端点传输数据。这个阶段是可选的有些命令没有数据阶段。Status Stage主机通过 Bulk IN 端点读取一个 13 字节的命令状态包装Command Status WrapperCSW。这三个阶段必须严格按顺序执行中间不能插入其他事务。CBW 和 CSW 的结构是固定的CBW 里包含了 SCSI 命令本身CSW 里包含了命令执行的结果。2.2 CBW 的字段拆解与构造CBW 一共 31 字节结构如下偏移长度字段说明04dCBWSignature固定为 0x43425355即 USBC44dCBWTag主机生成的唯一标识CSW 会原样返回84dCBWDataTransferLength本次事务期望传输的数据长度121bmCBWFlagsbit7 为 1 表示数据从设备到主机IN为 0 表示主机到设备OUT131bCBWLUN逻辑单元号U 盘通常是 0141bCBWCBLength后面 SCSI 命令块的有效长度通常是 6、10 或 161516CBWCB实际的 SCSI 命令块构造 CBW 的时候dCBWTag可以用一个自增的整数只要保证每次事务的 tag 不同就行。dCBWDataTransferLength要和后面实际传输的数据量一致否则设备可能会报错。bmCBWFlags的方向位要和 SCSI 命令的预期方向匹配比如读命令READ(10)是 IN写命令WRITE(10)是 OUT。byte[] cbw new byte[31]; ByteBuffer buffer ByteBuffer.wrap(cbw); buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.putInt(0x43425355); // signature buffer.putInt(tag); // tag buffer.putInt(dataTransferLength); // data length buffer.put((byte) (isRead ? 0x80 : 0x00)); // flags buffer.put((byte) 0); // LUN buffer.put((byte) scsiCommand.length); // CB length buffer.put(scsiCommand); // SCSI command // 剩余字节补 0这里有一个细节CBW 的所有多字节字段都是小端序。如果你用 Java 的 ByteBuffer 默认大端序去写设备会直接不认。我第一次写的时候就在这里卡了半天发出去的 CBW 设备毫无反应后来用 USB 抓包工具一看signature 字节序反了。2.3 CSW 的校验与错误处理CSW 也是固定 13 字节偏移长度字段说明04dCSWSignature固定为 0x53425355即 USBS44dCSWTag必须和对应的 CBW tag 一致84dCSWDataResidue实际传输的数据长度与期望长度的差值121bCSWStatus0 表示成功1 表示失败2 表示阶段错误收到 CSW 之后第一件事是校验 signature 和 tag。如果 tag 对不上说明事务错位了整个通信状态需要重置。bCSWStatus为 0 只代表 USB 层面的传输成功不代表 SCSI 命令执行成功。SCSI 命令的执行结果需要通过REQUEST SENSE命令去查询。提示很多初学者看到 CSW status 为 0 就以为万事大吉结果读出来的数据全是错的。实际上 SCSI 命令失败时CSW status 可能是 0但 sense data 里会有具体的错误码。养成每次命令后检查 sense 的习惯能省掉大量调试时间。2.4 端点选择与超时设置U 盘的 Bulk-Only 接口通常有两个 Bulk 端点一个 IN一个 OUT。端点的地址在UsbInterface的getEndpoint()里可以拿到通过getDirection()区分方向。UsbEndpoint的getMaxPacketSize()返回的是单个包的最大字节数通常是 512 字节。传输大块数据时底层会自动拆包你不需要手动分包。超时设置是一个容易被忽视的点。bulkTransfer()的超时参数单位是毫秒设得太短会导致大容量传输频繁超时设得太长又会让错误恢复变得迟钝。我的经验是命令阶段和状态阶段用 1000ms 左右数据阶段根据数据量动态调整一般每 64KB 给 500ms最少 2000ms。当然这只是一个起点具体还要看 U 盘的实际性能。3. SCSI 命令集真正和 U 盘对话的语言BOT 只是运输层真正决定 U 盘行为的是里面装的 SCSI 命令。U 盘使用的 SCSI 命令集是精简版的主要涉及 INQUIRY、READ CAPACITY、READ(10)、WRITE(10)、REQUEST SENSE 这几条。把这几条搞明白读 U 盘的基本能力就有了。3.1 INQUIRY先问清楚对方是谁INQUIRY 命令的作用是获取设备的基本信息包括厂商、产品型号、版本号等。命令块是 6 字节字节值说明00x12操作码10x00EVPD 位0 表示返回标准数据20x00页代码EVPD 为 0 时忽略30x00保留40x24分配长度通常设 36 字节50x00控制字节数据阶段是 IN 方向返回 36 字节的标准 INQUIRY 数据。前 8 字节是外设信息第 8 到 15 字节是厂商 ID16 到 31 字节是产品 ID32 到 35 字节是版本。这些信息在调试的时候很有用能确认你面对的是什么设备。byte[] inquiryCmd new byte[6]; inquiryCmd[0] 0x12; inquiryCmd[4] 0x24; byte[] inquiryData new byte[36]; // 构造 CBW发送命令读取数据读取 CSW3.2 READ CAPACITY(10)拿到容量和扇区大小知道设备是谁之后下一步是问它有多大。READ CAPACITY(10) 命令返回两个关键信息最后一个逻辑块地址LBA和逻辑块大小。命令块是 10 字节字节值说明00x25操作码10x00保留2-50x00000000逻辑块地址通常设 06-70x0000保留80x00PMI 位0 表示返回最后一个 LBA90x00控制字节返回数据是 8 字节前 4 字节是最后一个 LBA后 4 字节是块大小。总容量 (lastLBA 1) * blockSize。比如返回 lastLBA 为 0x001D9FFFblockSize 为 0x00000200512那容量就是 (0x001D9FFF 1) * 512 ≈ 15.6GB。注意有些大容量 U 盘会返回 0xFFFFFFFF 作为 lastLBA这时候需要用 READ CAPACITY(16) 命令来获取真实容量。判断依据是 lastLBA 是否为 0xFFFFFFFF如果是就换 16 字节版本。3.3 READ(10)从扇区读数据READ(10) 是读数据的核心命令命令块 10 字节字节值说明00x28操作码10x00保留2-5LBA起始逻辑块地址大端序60x00保留7-8传输长度要读的块数大端序90x00控制字节数据阶段是 IN 方向返回的数据长度 块数 * 块大小。比如读 1 个块块大小 512那dCBWDataTransferLength就是 512。byte[] readCmd new byte[10]; readCmd[0] 0x28; readCmd[2] (byte) ((lba 24) 0xFF); readCmd[3] (byte) ((lba 16) 0xFF); readCmd[4] (byte) ((lba 8) 0xFF); readCmd[5] (byte) (lba 0xFF); readCmd[7] (byte) ((count 8) 0xFF); readCmd[8] (byte) (count 0xFF);这里 LBA 和传输长度都是大端序和 CBW 的小端序正好相反。这个混用是 SCSI 协议的历史遗留问题写代码的时候一定要小心别搞混了。3.4 REQUEST SENSE命令失败后的诊断工具当 CSW 的 status 不为 0或者你怀疑命令执行有问题时需要发 REQUEST SENSE 来获取详细的错误信息。命令块 6 字节字节值说明00x03操作码10x00保留2-30x0000保留40x12分配长度通常 18 字节50x00控制字节返回的 sense data 里第 2 字节是 sense key第 12 字节是 additional sense code第 13 字节是 additional sense code qualifier。常见的 sense key 有0x00 无错误0x02 未就绪0x03 介质错误0x05 非法请求。根据这些码可以快速定位问题。4. 从扇区数据到文件系统最后一步怎么走拿到扇区数据之后你面对的是一堆原始字节。如果只是想读文件还需要解析文件系统。U 盘最常见的文件系统是 FAT32 和 exFAT少数用 NTFS。这一步的工作量不小但也不是不能做。4.1 判断文件系统类型第一个扇区LBA 0是主引导记录MBR偏移 0x1BE 开始是四个分区表项每个 16 字节。分区表项的第 4 字节是分区类型0x0B 或 0x0C 表示 FAT320x07 表示 exFAT 或 NTFS0x83 表示 Linux 分区。找到第一个有效分区后读取它的起始 LBA那就是文件系统的引导扇区。FAT32 的引导扇区里偏移 0x0B 是每扇区字节数0x0D 是每簇扇区数0x0E 是保留扇区数0x10 是 FAT 表数量0x24 是每 FAT 扇区数0x2C 是根目录起始簇号。把这些字段读出来就能计算出数据区的起始位置和簇的大小。4.2 FAT32 目录项解析FAT32 的目录项是 32 字节短文件名目录项的结构如下偏移长度说明08文件名8.3 格式83扩展名111属性0x10 表示目录0x20 表示归档121保留131创建时间十分之一秒14-152创建时间16-172创建日期18-192最后访问日期20-212起始簇号高 16 位22-232最后修改时间24-252最后修改日期26-272起始簇号低 16 位28-314文件大小长文件名用多个目录项拼接属性字节为 0x0F按顺序排列在短文件名目录项之前。解析长文件名需要按序读取每个目录项的 13 个 Unicode 字符然后拼接起来。4.3 簇链遍历与文件读取FAT32 的 FAT 表是一个整数数组每个表项 4 字节记录下一个簇的簇号。文件的数据就分布在簇链上。读取文件时从目录项里的起始簇号开始查 FAT 表找到下一个簇直到遇到 0x0FFFFFFF 表示结束。int currentCluster startCluster; while (currentCluster 0x0FFFFFF8) { long clusterOffset dataStartSector (currentCluster - 2) * sectorsPerCluster; // 读取该簇的数据 currentCluster readFatEntry(currentCluster); }这个过程需要频繁读取 FAT 表如果每次都发 READ(10) 命令会很慢。优化方法是把整个 FAT 表缓存到内存里FAT 表的大小 fatSectors * sectorSize通常几 MB 以内完全可以缓存。提示如果你只是想在 App 里展示 U 盘里的文件列表自己解析 FAT32 的工作量可能不太划算。这时候可以考虑用 Android 的 Storage Access Framework让系统帮你处理文件系统层。但如果你要做的是底层数据恢复、扇区级备份这类操作那自己解析文件系统就是绕不开的路。5. 实测中遇到的几个典型问题与排查思路理论讲完了实际跑起来还是会遇到各种意外。我把自己踩过的几个坑整理出来希望能帮你少走弯路。5.1 bulkTransfer 返回 -1 但没有任何异常这是最常见的问题。bulkTransfer()返回 -1 表示传输失败但它不会告诉你为什么失败。可能的原因有端点方向搞反了、CBW 构造错误、设备处于错误状态、超时时间太短。排查的时候第一步是确认端点方向IN 端点只能读OUT 端点只能写。第二步是用抓包工具看实际发出的数据确认 CBW 的 signature 和 tag 是否正确。第三步是发一个简单的 INQUIRY 命令测试基本通信是否正常。如果 INQUIRY 都失败那问题大概率在权限或者接口占用上。检查openDevice()是否返回了非 nullclaimInterface()是否返回了 true。如果claimInterface()失败说明接口被内核驱动占用了这时候需要先让系统释放接口或者换一个没有被占用的设备。5.2 读出来的数据全是 0 或者乱码数据能读出来但内容不对通常有几个原因。第一是 LBA 算错了比如把字节偏移直接当成 LBA 用忘了除以扇区大小。第二是字节序搞反了SCSI 命令里的 LBA 和长度是大端序CBW 里的字段是小端序混用就会出错。第三是数据阶段的方向位设错了读命令设成了 OUT写命令设成了 IN。排查的时候可以先读 LBA 0 的 512 字节用十六进制查看器看最后两个字节是不是 0x55 0xAA。这是 MBR 的结束标志如果对不上说明读的位置或者解析方式有问题。5.3 大容量传输频繁超时读大文件的时候如果一次读太多扇区bulkTransfer()可能会超时。原因是单次bulkTransfer()调用能传输的数据量受限于 USB 请求缓冲区的大小传输量太大时底层会拆成多次传输耗时增加。解决办法是分块读取每次读 32KB 到 64KB循环读取直到读完。这样每次传输的时间可控也方便做进度反馈。int totalBytes fileSize; int offset 0; int chunkSize 64 * 1024; while (offset totalBytes) { int toRead Math.min(chunkSize, totalBytes - offset); int sectors toRead / sectorSize; // 发 READ(10) 命令读 sectors 个扇区 offset sectors * sectorSize; }5.4 设备拔出后的状态恢复U 盘在传输过程中被拔出bulkTransfer()会抛异常或者返回 -1。这时候需要捕获异常关闭连接释放接口然后等待设备重新插入。如果不做清理下次插入时可能会因为接口还被占用而无法打开。建议在onPause()或者广播接收器里监听USB_DEVICE_DETACHED及时释放资源。6. 自己实现和用 libaums 的取舍写到这里你应该对整个过程有了完整的认识。那到底该自己实现还是用 libaums我的看法是看你的需求层次。如果你只是想在 App 里浏览 U 盘文件、复制几个文件出来libaums 完全够用它帮你省掉了协议层和文件系统层的所有麻烦。但如果你需要做底层扇区读写、自定义 SCSI 命令、或者对性能有极致要求那自己实现更可控。而且自己实现一遍之后再用 libaums 的时候你就能看懂它每一步在干什么出问题也知道去哪里找。从代码量来看一个能读扇区的最小实现大概 500 到 800 行 Java 代码主要包括 CBW/CSW 构造、SCSI 命令封装、Bulk 传输循环、错误处理这几块。文件系统解析的部分如果只做 FAT32 的目录遍历再加 300 行左右。整体工作量不算小但也不是遥不可及。我在实际项目里的做法是协议层自己实现文件系统层用现成的解析库。这样既保证了对 USB 通信的完全控制又不用重复造文件系统的轮子。协议层的代码一旦调通后续换不同的 U 盘、不同的 Android 设备基本不需要改动稳定性比想象中好。最后分享一个小技巧调试 USB 通信的时候如果手头没有专业的 USB 抓包工具可以在代码里把每次发送的 CBW 和收到的 CSW 用十六进制打印出来对照协议文档逐字节检查。虽然笨但非常有效。我最初调通 INQUIRY 命令就是靠这种方式一个字节一个字节对出来的。