不靠 libaums自己如何在 Android 上用 USB 协议把 U 盘读出来

文章来源声明: 原文作者:Yeyu; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688761613197099058; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

从协议底层讲清 Android USB 读盘链路,踩坑细节尤其有价值;适合想理解 BOT/SCSI 或排查 UAS 兼容问题的开发者,生产环境仍建议 libaums。

这篇记录我怎么从零把一块 U 盘的信息读出来的:不引任何第三方存储库,直接在 `UsbDeviceConnection.bulkTransfer` 上手写 Bulk-Only Transport(BOT)和 SCSI 命令。目标很小——先把 INQUIRY(厂商、产品名、版本号)读通——但小目标背后是一整套协议,读通了就等于把 USB 大容量存储的骨架摸清了。

中间踩了两个坑,一个是自己手抖写错一个字段,一个是设备本身不按常理出牌(移动硬盘走的是 UAS 不是 BOT)。这两个坑都写进来了,因为它们比"正确代码"更有价值——正确代码抄一遍就有,坑不亲手踩过下次还会栽。

代码全部来自能跑通的工程,不是伪代码。


先把地图画出来

读一个文件,数据要穿过好几层,每层管一件事:

你的 <span>App</span>
  └─ 文件系统层   <span>FAT32</span> / <span>exFAT:</span>目录项、簇链              ← 最上层,最啰嗦
      └─ 分区层    <span>MBR</span> / <span>GPT</span><span>:</span>第 <span>0</span> 扇区里找分区起始 <span>LBA</span>
          └─ 块设备层  <span>SCSI</span> 命令<span>:INQUIRY</span> / <span>READ</span>(<span>10</span>) 按 <span>LBA</span> 读扇区
              └─ 传输层  <span>BOT</span><span>:CBW</span> → 数据 → <span>CSW</span>,走两个 bulk 端点
                  └─ <span>USB</span>   <span>UsbManager</span> / bulkTransfer

这篇的重心在下面两层:传输层(BOT)和块设备层(SCSI)。把"按扇区读"跑通,上面的分区和文件系统就只是纯字节解析了,那部分我最后给入口,真要做完整文件系统还是上 libaums 划算。

有两个约定贯穿全文,先说死,不然后面处处踩:

第一,端点方向站在主机(手机)视角。bulk-OUT 是主机发给设备,bulk-IN 是设备发给主机。别站在 U 盘视角想,会绕晕。

第二,字节序有两套。BOT 的外壳(CBW/CSW)里的整数是小端;而外壳里装的 SCSI 命令(CDB)里的多字节字段是大端。同一个包里两种字节序,这是新手最爱翻车的地方。


第一步:枚举设备、要权限

这部分是标准动作。注册广播,监听插入/权限结果,拿到 UsbDevice 后如果没权限就 requestPermission,有权限直接用。

<span>class</span> <span>MainActivity</span> : <span>ComponentActivity</span>() {
    <span>companion</span> <span>object</span> {
        <span>const</span> <span>val</span> Action_USB_PERMISSION = <span>"com.example.usbproj.action.USB_PERMISSION"</span>
    }

    <span>private</span> <span>lateinit</span> <span>var</span> usb: UsbManager

    <span>private</span> <span>val</span> permissionIntent: PendingIntent <span>by</span> lazy {
        PendingIntent.getBroadcast(
            <span>this</span>, <span>0</span>,
            Intent(Action_USB_PERMISSION).setPackage(packageName),
            <span>if</span> (Build.VERSION.SDK_INT >= <span>31</span>)
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
            <span>else</span> PendingIntent.FLAG_UPDATE_CURRENT
        )
    }

    <span>private</span> <span>val</span> receiver = <span>object</span> : BroadcastReceiver() {
        <span>override</span> <span><span>fun</span> <span>onReceive</span><span>(context: <span>Context</span>?, intent: <span>Intent</span>?)</span></span> {
            <span>when</span> (intent?.action) {
                Action_USB_PERMISSION -> {
                    <span>val</span> device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: <span>return</span>
                    <span>if</span> (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, <span>false</span>)) {
                        onDeviceReady(device)
                    }
                }
                UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
                    <span>val</span> device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: <span>return</span>
                    <span>if</span> (usb.hasPermission(device)) onDeviceReady(device)
                    <span>else</span> usb.requestPermission(device, permissionIntent)
                }
            }
        }
    }

    <span>override</span> <span><span>fun</span> <span>onCreate</span><span>(savedInstanceState: <span>Bundle</span>?)</span></span> {
        <span>super</span>.onCreate(savedInstanceState)
        usb = getSystemService(UsbManager::<span>class</span>.java)

        <span>val</span> filter = IntentFilter().apply {
            addAction(Action_USB_PERMISSION)
            addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
            addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
        }
        <span>// Android 13+ 动态广播必须显式标注导出与否</span>
        registerReceiver(receiver, filter,
            <span>if</span> (Build.VERSION.SDK_INT >= <span>33</span>) Context.RECEIVER_NOT_EXPORTED <span>else</span> <span>0</span>)

        <span>// 冷启动时设备可能已经插着,先扫一遍</span>
        usb.deviceList.values.firstOrNull()?.let { device ->
            <span>if</span> (usb.hasPermission(device)) onDeviceReady(device)
            <span>else</span> usb.requestPermission(device, permissionIntent)
        }
    }
}

两个容易忘的点:Android 12 起 PendingIntent 必须显式给 FLAG_MUTABLE;Android 13 起动态注册广播必须给 RECEIVER_NOT_EXPORTED。少一个都直接崩。

拿到设备、有了权限,真正的活从 onDeviceReady 里开始——所有 USB 收发都是阻塞调用,别在主线程干:

<span>private</span> <span><span>fun</span> <span>onDeviceReady</span><span>(d: <span>UsbDevice</span>)</span></span> {
    Thread {
        <span>val</span> storage = UsbStorage(usb, d)
        <span>try</span> {
            storage.<span>open</span>()
            <span>val</span> info = storage.inquiry()
            Log.i(<span>"Main"</span>, <span>"读到了:<span>$info</span>"</span>)
        } <span>catch</span> (e: Exception) {
            Log.e(<span>"Main"</span>, <span>"读盘失败:<span>${e.message}</span>"</span>)
        } <span>finally</span> {
            storage.close()
        }
    }.start()
}

后面 UsbStorage 这个类,就是这篇的全部内容。


第二步:打开设备——这里藏着移动硬盘读不出的第一个大坑

直觉的写法是:找 class==8(Mass Storage)的接口,抓两个 bulk 端点,openDevice + claimInterface,完事。我一开始也是这么写的,U 盘没问题,插上一块 SanDisk 移动硬盘就全乱。

先看能跑通的最终版,再讲为什么要多那几步:

<span>class</span> <span>UsbStorage</span>(<span>private</span> <span>val</span> usb: UsbManager, <span>private</span> <span>val</span> device: UsbDevice) {
    <span>private</span> <span>lateinit</span> <span>var</span> conn: UsbDeviceConnection
    <span>private</span> <span>lateinit</span> <span>var</span> itf: UsbInterface
    <span>private</span> <span>lateinit</span> <span>var</span> epIn: UsbEndpoint     <span>// 设备 → 主机</span>
    <span>private</span> <span>lateinit</span> <span>var</span> epOut: UsbEndpoint    <span>// 主机 → 设备</span>

    <span><span>fun</span> <span>open</span><span>()</span></span> {
        dumpDescriptors()   <span>// 先把设备结构打出来,调试期的救命日志</span>

        <span>// 1) 找 Mass Storage 接口,但只认 BOT(protocol=0x50)</span>
        <span>val</span> massStorage = (<span>0</span> until device.interfaceCount).map { device.getInterface(it) }
            .filter { it.interfaceClass == UsbConstants.USB_CLASS_MASS_STORAGE }
        <span>val</span> botItfs = massStorage.filter { it.interfaceProtocol == <span>0x50</span> }  <span>// 0x50=BOT, 0x62=UAS</span>
        check(botItfs.isNotEmpty()) {
            <span>"该设备没有 BOT 接口,只有:"</span> +
                massStorage.joinToString { <span>"0x<span>${it.interfaceProtocol.toString(<span>16</span>)}</span>"</span> }
        }
        itf = botItfs.first()

        <span>// 2) 抓一对 bulk 端点(BOT 就用两根:一 IN 一 OUT)</span>
        <span>for</span> (i <span>in</span> <span>0</span> until itf.endpointCount) {
            <span>val</span> ep = itf.getEndpoint(i)
            <span>if</span> (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) {
                <span>if</span> (ep.direction == UsbConstants.USB_DIR_IN) epIn = ep <span>else</span> epOut = ep
            }
        }
        check(<span>this</span>::epIn.isInitialized && <span>this</span>::epOut.isInitialized) { <span>"未找到 BULK IN/OUT 端点"</span> }

        <span>// 3) 打开连接,强制独占接口(从内核 usb-storage / uas 驱动手里抢过来)</span>
        conn = usb.openDevice(device) ?: error(<span>"openDevice 失败(没权限?)"</span>)
        check(conn.claimInterface(itf, <span>true</span>)) { <span>"claimInterface 失败"</span> }

        <span>// 4) ★ 把设备切到 BOT 的 alt setting,再复位状态机</span>
        conn.setInterface(itf)
        resetRecovery()
    }

    <span><span>fun</span> <span>close</span><span>()</span></span> {
        <span>if</span> (<span>this</span>::conn.isInitialized) {
            conn.releaseInterface(itf)
            conn.close()
        }
    }
}

第 1 步和第 4 步是踩坑之后加的,值得单独说。

BOT 和 UAS:一块盘的两副面孔

U 盘(闪存盘)几乎都是纯 BOT;而移动硬盘、移动固态,几乎清一色 UAS(USB Attached SCSI)。两者的接口类都是 class=8、子类都是 subclass=6,肉眼看不出区别,区别藏在 interfaceProtocol:

protocol名称bulk 端点传输模型
`0x50`BOT2 个(IN/OUT)本文的 CBW → 数据 → CSW
`0x62`UAS4 个 + stream命令 IU / 状态 IU,和 BOT 完全两套

更麻烦的是很多盘是双模:同一个接口号挂两个 alt setting。我这块 SanDisk 打出来的描述符长这样:

interface<span>[0]</span>: <span>id</span>=<span>0</span> alt=<span>0</span> class=<span>8</span> subclass=<span>6</span> protocol=<span>0</span>x50(BOT) endpoints=<span>2</span>
    ep<span>[0]</span> <span>addr</span>=<span>0</span>x81 IN  BULK
    ep<span>[1]</span> <span>addr</span>=<span>0</span>x02 OUT BULK
interface<span>[1]</span>: <span>id</span>=<span>0</span> alt=<span>1</span> class=<span>8</span> subclass=<span>6</span> protocol=<span>0</span>x62(UAS) endpoints=<span>4</span>
    ep<span>[0]</span> <span>addr</span>=<span>0</span>x81 IN  BULK
    ep<span>[1]</span> <span>addr</span>=<span>0</span>x02 OUT BULK
    ep<span>[2]</span> <span>addr</span>=<span>0</span>x83 IN  BULK
    ep<span>[3]</span> <span>addr</span>=<span>0</span>x04 OUT BULK

同一个接口(id 都是 0),alt0 是 BOT,alt1 是 UAS。问题在于:插上时,内核的 UAS 驱动会把设备切到 UAS(alt1)。这时你就算在代码里选中了 alt0 那个 BOT 接口对象,设备物理上还停在 UAS 模式。你按 BOT 发命令,设备按 UAS 解析,鸡同鸭讲。

所以第 1 步不能只按 class==8 选,得明确挑 protocol==0x50;第 4 步 conn.setInterface(itf) 才是真正把设备从 UAS 切回 BOT 的那一下。切完再 resetRecovery() 把 BOT 状态机复位到干净起点。

这个坑的现象后面讲 CSW 时还会回来——它伪装成"CSW 签名错",极具迷惑性。

那段救命的描述符日志

调这类问题,光看报错没用,得先看清设备到底长什么样。这个 dumpDescriptors 我一直默认留着:

<span>private</span> <span><span>fun</span> <span>dumpDescriptors</span><span>()</span></span> {
    Log.i(TAG, <span>"device: name=<span>${device.deviceName}</span> VID=0x%04X PID=0x%04X 接口数=<span>${device.interfaceCount}</span>"</span>
        .format(device.vendorId, device.productId))
    <span>for</span> (i <span>in</span> <span>0</span> until device.interfaceCount) {
        <span>val</span> it = device.getInterface(i)
        <span>val</span> proto = <span>when</span> (it.interfaceProtocol) { <span>0x50</span> -> <span>"BOT"</span>; <span>0x62</span> -> <span>"UAS"</span>; <span>else</span> -> <span>"其他"</span> }
        Log.i(TAG, <span>"interface[<span>$i</span>]: id=<span>${it.id}</span> alt=<span>${it.alternateSetting}</span> class=<span>${it.interfaceClass}</span> "</span> +
                <span>"protocol=0x<span>${it.interfaceProtocol.toString(<span>16</span>)}</span>(<span>$proto</span>) endpoints=<span>${it.endpointCount}</span>"</span>)
        <span>for</span> (j <span>in</span> <span>0</span> until it.endpointCount) {
            <span>val</span> ep = it.getEndpoint(j)
            <span>val</span> dir = <span>if</span> (ep.direction == UsbConstants.USB_DIR_IN) <span>"IN"</span> <span>else</span> <span>"OUT"</span>
            <span>val</span> type = <span>if</span> (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) <span>"BULK"</span> <span>else</span> <span>"其他"</span>
            Log.i(TAG, <span>"    ep[<span>$j</span>] addr=0x%02X %s %s maxPkt=%d"</span>.format(ep.address, dir, type, ep.maxPacketSize))
        }
    }
}

后来但凡读不出来,我第一件事就是看这段——是不是只有 UAS、bulk 端点是不是 4 个,答案十有八九就在里面。


第三步:BOT 的心脏——CBW 和 CSW

BOT 全称 Bulk-Only Transport,"Bulk-Only"是说它只用两根 bulk 端点,没有专门的命令线。既然只有两根管子,那命令、数据、结果三样东西就得排队走。BOT 的办法是把一次操作切成雷打不动的三段:

① 命令阶段   bulk-OUT →  发 <span>CBW</span>(<span>31</span> 字节),里面包着一条 SCSI 命令
② 数据阶段   IN 或 OUT   传实际数据(读走 IN,写走 OUT;有的命令没这段)
③ 状态阶段   bulk-IN  ←  收 <span>CSW</span>(<span>13</span> 字节),告诉你成功还是失败

每一条 SCSI 命令,都是这套三段式走一遍——INQUIRY 是、READ 是、写也是。理解了这个节奏,后面所有命令就都是同一个模板换个 CDB 而已。

CBW:命令的信封(31 字节,小端)

偏移长度字段说明
04dCBWSignature固定 `0x43425355`("USBC")
44dCBWTag自定义序号,CSW 会原样返回,用来配对
84dCBWDataTransferLength数据阶段要传多少字节
121bmCBWFlagsbit7:1=IN(读),0=OUT(写)
131bCBWLUN逻辑单元号,U 盘一般 0
141bCBWCBLength**后面 CDB 的长度**(1~16)
1516CBWCB真正的 SCSI 命令(CDB),不足补 0

CSW:结果的回执(13 字节,小端)

偏移长度字段说明
04dCSWSignature固定 `0x53425355`("USBS")
44dCSWTag必须等于你发的 CBW 的 tag
84dCSWDataResidue期望 − 实际,还差多少没传
121bCSWStatus0=成功,1=失败,2=Phase Error

信封(CBW/CSW)是壳,壳里装的 CDB 是芯。这个"壳与芯"的关系理顺了,后面的字节序、长度字段就都对得上号。


第四步:一个函数跑通一条命令

这是全篇的心脏。发 CBW、传数据、收 CSW、校验,一条龙。它同时是我踩第二个坑的地方——先看完整代码,坑标在注释里:

<span>private</span> <span>var</span> tag = <span>0</span>
<span>private</span> <span>val</span> TIMEOUT = <span>5000</span>

<span>/**
 * 跑一条 SCSI 命令。
 * <span>@param</span> cdb    命令描述块(问什么)
 * <span>@param</span> data   数据缓冲(读:接收桶;写:待发数据),null=无数据阶段
 * <span>@param</span> dataIn true=读(设备→主机),false=写
 * <span>@return</span> 数据阶段实际传输的字节数
 */</span>
<span>private</span> <span><span>fun</span> <span>transfer</span><span>(cdb: <span>ByteArray</span>, <span>data</span>: <span>ByteArray</span>?, dataIn: <span>Boolean</span>)</span></span>: <span>Int</span> {
    <span>val</span> expected = <span>data</span>?.size ?: <span>0</span>
    <span>val</span> myTag = ++tag

    <span>// ---- ① 组 CBW(31 字节,小端外壳)----</span>
    <span>val</span> cbw = ByteArray(<span>31</span>)
    <span>val</span> bb = java.nio.ByteBuffer.wrap(cbw).order(java.nio.ByteOrder.LITTLE_ENDIAN)
    bb.putInt(<span>0x43425355</span>)                        <span>// dCBWSignature "USBC"</span>
    bb.putInt(myTag)                             <span>// dCBWTag</span>
    bb.putInt(expected)                          <span>// dCBWDataTransferLength</span>
    cbw[<span>12</span>] = <span>if</span> (dataIn) <span>0x80</span>.toByte() <span>else</span> <span>0</span>   <span>// bmCBWFlags,bit7:1=IN</span>
    cbw[<span>13</span>] = <span>0</span>                                  <span>// bCBWLUN</span>
    cbw[<span>14</span>] = cdb.size.toByte()                  <span>// ★ bCBWCBLength = CDB 长度!不是 cbw.size!</span>
    System.arraycopy(cdb, <span>0</span>, cbw, <span>15</span>, cdb.size)  <span>// CBWCB = SCSI 命令</span>

    <span>// ---- 发 CBW ----</span>
    <span>val</span> cbwSent = conn.bulkTransfer(epOut, cbw, cbw.size, TIMEOUT)
    <span>if</span> (cbwSent != cbw.size) { resetRecovery(); error(<span>"CBW 发送失败 sent=<span>$cbwSent</span>"</span>) }

    <span>// ---- ② 数据阶段 ----</span>
    <span>var</span> transferred = <span>0</span>
    <span>if</span> (<span>data</span> != <span>null</span> && expected > <span>0</span>) {
        <span>val</span> ep = <span>if</span> (dataIn) epIn <span>else</span> epOut
        <span>var</span> off = <span>0</span>
        <span>while</span> (off < expected) {
            <span>val</span> chunk = minOf(expected - off, <span>16</span> * <span>1024</span>)  <span>// 大缓冲分片,老设备对超大包敏感</span>
            <span>val</span> tmp = <span>if</span> (dataIn) ByteArray(chunk) <span>else</span> <span>data</span>.copyOfRange(off, off + chunk)
            <span>val</span> n = conn.bulkTransfer(ep, tmp, chunk, TIMEOUT)
            <span>if</span> (n < <span>0</span>) <span>break</span>                        <span>// 出错/超时,跳出去收 CSW</span>
            <span>if</span> (dataIn) System.arraycopy(tmp, <span>0</span>, <span>data</span>, off, n)  <span>// 读回的字节写进 data</span>
            off += n; transferred += n
            <span>if</span> (n == <span>0</span>) <span>break</span>
            <span>// ★ 别因「n < chunk」就 break:bulkTransfer 一次不保证读满,后面可能还有</span>
        }
        <span>// 短读:管道里还堵着字节,先排空,别污染 CSW</span>
        <span>if</span> (dataIn && transferred < expected) clearHalt(epIn)
    }

    <span>// ---- ③ 收 CSW(13 字节)----</span>
    <span>var</span> status = readCsw(myTag)
    <span>if</span> (status == CSW_BAD) {
        clearHalt(epIn)              <span>// 疑似残留污染,清端点再读一次</span>
        status = readCsw(myTag)
        <span>if</span> (status == CSW_BAD) { resetRecovery(); error(<span>"CSW 签名错(重试后仍失败)"</span>) }
    }
    <span>when</span> (status) {
        <span>0</span> -> {}                       <span>// Passed</span>
        <span>1</span> -> <span>throw</span> ScsiFailed()       <span>// Failed → 上层去 REQUEST SENSE 查原因</span>
        <span>else</span> -> { resetRecovery(); error(<span>"Phase Error"</span>) }
    }
    <span>return</span> transferred
}

<span>private</span> <span><span>fun</span> <span>readCsw</span><span>(myTag: <span>Int</span>)</span></span>: <span>Int</span> {
    <span>val</span> csw = ByteArray(<span>13</span>)
    <span>val</span> n = conn.bulkTransfer(epIn, csw, <span>13</span>, TIMEOUT)
    <span>if</span> (n != <span>13</span>) <span>return</span> CSW_BAD
    <span>val</span> cb = java.nio.ByteBuffer.wrap(csw).order(java.nio.ByteOrder.LITTLE_ENDIAN)
    <span>if</span> (cb.getInt(<span>0</span>) != <span>0x53425355</span>) <span>return</span> CSW_BAD   <span>// 签名不对,不是 CSW</span>
    <span>if</span> (cb.getInt(<span>4</span>) != myTag) <span>return</span> CSW_BAD         <span>// tag 不配对</span>
    <span>return</span> csw[<span>12</span>].toInt() and <span>0xFF</span>                   <span>// bCSWStatus</span>
}

<span>private</span> <span>companion</span> <span>object</span> { <span>const</span> <span>val</span> CSW_BAD = -<span>1</span>; <span>const</span> <span>val</span> TAG = <span>"UsbStorage"</span> }

坑二:bCBWCBLength 写成了整个 CBW 的长度

第 14 字节 bCBWCBLength 要填的是 CDB 的长度——INQUIRY 是 6。我当时手一滑写成了 cbw.size,也就是 31。协议规定这个字段最大只能是 16,填 31 直接是非法命令包。设备收到看不懂,行为未定义,后面数据和 CSW 全跟着乱。

一个字符的差别,cbw.size 改成 cdb.size,病根就除了。这种错不看每个字段的 hex 根本发现不了——所以我在 transfer 里全程打 log,把 CBW 整包 hex 打出来,一眼就能核对 bCBWCBLength 那位是不是 06。


第五步:数据阶段到底在传什么——cdb 和 buf 别搞混

这是我一开始没绕明白的地方,单独讲清楚。

一条 INQUIRY,我这样调:

<span>val</span> cdb = byteArrayOf(<span>0x12</span>, <span>0x00</span>, <span>0x00</span>, <span>0x00</span>, <span>0x24</span>, <span>0x00</span>)  <span>// 命令:我要 INQUIRY</span>
<span>val</span> buf = ByteArray(<span>36</span>)                                     <span>// 空桶:装答案用</span>
transfer(cdb, buf, dataIn = <span>true</span>)
<span>// 调用前 buf 全是 0;调用后 buf 被设备填满了查询结果</span>

cdb 和 buf 是两样完全不同的东西,对应两个不同的阶段:

cdb 是那 6 字节命令,内容是"执行 INQUIRY"。它在命令阶段被包进 CBW,从 bulk-OUT 发出去。

buf 是一个 36 字节的空数组。传进去的时候它全是 0,它不是"要发的数据",而是你预先备好的接收桶。设备在数据阶段通过 bulk-IN 把查询结果写进这个桶。

一句话讲清:批量传输的数据阶段,负责把 INQUIRY 的查询结果读回来,填进 buf 这个空字节数组里。 cdb 管"问",buf 管"接答案",一个走出、一个走进。

看 transfer 里数据阶段那两行就明白 buf 是被"填"的,不是被"发"的:

<span>val</span> n = conn.bulkTransfer(ep, tmp, chunk, TIMEOUT)   <span>// 从 bulk-IN 读回数据</span>
<span>if</span> (dataIn) System.arraycopy(tmp, <span>0</span>, <span>data</span>, off, n)   <span>// 读回来的,拷进 data(就是 buf)</span>

bulkTransfer(epIn, ...) 是读动作,读回来的字节再拷进 data。函数返回后,buf[8..15] 是厂商、buf[16..31] 是产品名——全是数据阶段从设备那儿接回来的。

同一个数据阶段、同一根 bulk-IN,既能返回 INQUIRY 的 36 字节,也能返回 READ(10) 的一整个扇区,区别只在命令阶段发的是哪条 CDB。buf 只是容器,方向由 dataIn 决定往里读还是往外写。


第六步:INQUIRY——把厂商、产品、版本读出来

有了 transfer,INQUIRY 就是拼一个 6 字节 CDB、给一个 36 字节的桶,然后从固定偏移把字段切出来。

标准 INQUIRY 数据里我要的三段,都是 ASCII,右侧用空格补齐:

偏移长度字段含义
01外设类型低 5 位:0x00=直接存取(磁盘/U盘)
88Vendor Identification厂商
1616Product Identification产品名
324Product Revision Level版本号

要读到第 36 字节(0~35),所以 CDB 里的分配长度给 36(0x24)。

<span>data</span> <span>class</span> <span>InquiryInfo</span>(
    <span>val</span> vendor: String, <span>val</span> product: String, <span>val</span> revision: String, <span>val</span> deviceType: <span>Int</span>,
) {
    <span>override</span> <span><span>fun</span> <span>toString</span><span>()</span></span> = <span>"厂商=<span>$vendor</span> 产品=<span>$product</span> 版本=<span>$revision</span> type=<span>$deviceType</span>"</span>
}

<span><span>fun</span> <span>inquiry</span><span>()</span></span>: InquiryInfo {
    <span>val</span> cdb = byteArrayOf(
        <span>0x12</span>,   <span>// byte0: OPCODE=0x12,INQUIRY</span>
        <span>0x00</span>,   <span>// byte1: EVPD=0,取标准数据(不是 VPD 页)</span>
        <span>0x00</span>,   <span>// byte2: PAGE CODE=0</span>
        <span>0x00</span>,   <span>// byte3: 分配长度高字节</span>
        <span>0x24</span>,   <span>// byte4: 分配长度低字节 = 36</span>
        <span>0x00</span>,   <span>// byte5: CONTROL</span>
    )
    <span>val</span> buf = ByteArray(<span>36</span>)
    transfer(cdb, buf, dataIn = <span>true</span>)
    Log.i(TAG, <span>"INQUIRY 原始36字节: <span>${buf.toHex()}</span>"</span>)   <span>// 正常形如 00 80 04 02 1f ...</span>

    <span><span>fun</span> <span>str</span><span>(off: <span>Int</span>, len: <span>Int</span>)</span></span> =
        String(buf, off, len, Charsets.US_ASCII).trim { it == <span>' '</span> || it == <span>'\u0000'</span> }

    <span>return</span> InquiryInfo(
        vendor     = str(<span>8</span>, <span>8</span>),      <span>// byte 8..15</span>
        product    = str(<span>16</span>, <span>16</span>),    <span>// byte 16..31</span>
        revision   = str(<span>32</span>, <span>4</span>),     <span>// byte 32..35</span>
        deviceType = buf[<span>0</span>].toInt() and <span>0x1F</span>,
    )
}

<span>private</span> <span><span>fun</span> ByteArray.<span>toHex</span><span>()</span></span> = joinToString(<span>" "</span>) { <span>"%02x"</span>.format(it) }

跑通那一刻,Logcat 里就是这样:

setInterface(<span>id</span>=<span>0</span>, alt=<span>0</span>) = <span>true</span>
→CBW <span>tag</span>=<span>1</span> dataIn=<span>true</span> expected=<span>36</span> cdbLen=<span>6</span> opcode=<span>0</span>x12 cbw=<span>55</span> <span>53</span> <span>42</span> <span>43</span> <span>01</span> <span>00</span> <span>00</span> <span>00</span> <span>24</span> <span>00</span> <span>00</span> <span>00</span> <span>80</span> <span>00</span> <span>06</span> <span>12</span> <span>00</span> <span>00</span> <span>00</span> <span>24</span> <span>00</span> ...
  CBW <span>sent</span>=<span>31</span>
  data IN <span>req</span>=<span>36</span> got=<span>36</span> <span>off</span>=<span>0</span>
  CSW read <span>n</span>=<span>13</span> hex=<span>55</span> <span>53</span> <span>42</span> <span>53</span> <span>01</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span>
←CSW <span>tag</span>=<span>1</span> status=<span>0</span>
INQUIRY: 厂商=SanDisk 产品=... 版本=... <span>type</span>=<span>0</span>

CSW 开头是 55 53 42 53("USBS")、status=0,就成了。


回到坑一:那串 03 00 00 01 到底是什么

前面说 UAS 会伪装成"CSW 签名错"。这里把现场还原一下,因为它太有代表性了。

bCBWCBLength 改对之后,我以为稳了,结果 SanDisk 移动硬盘还是报签名错。日志显示 CBW 发送成功、数据阶段也读到了 36 字节,一切正常,唯独收 CSW 时读回来这 13 字节:

CSW read <span>n</span>=<span>13</span> hex=<span>03</span> <span>00</span> <span>00</span> <span>01</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span> <span>00</span>
CSW 签名错 <span>sig</span>=<span>0</span>x01000003(期望 <span>0</span>x53425355)

一开始我以为是数据没排空、残留数据顶到了 CSW 位置。但这串 03 00 00 01 每次都一模一样,不像随机残留。拆开一看,它根本不是 CSW,是一个 UAS Sense IU:

<span>byte0</span> = <span>0</span>x03      → UAS IU 类型 = Sense IU(CSW 的签名头应该是 <span>55</span> <span>53</span> <span>42</span> <span>53</span>)
<span>byte1</span> = <span>0</span>x00      → 保留
byte2..<span>3</span> = <span>00</span> <span>01</span>  → IU Tag(大端)= <span>1</span>,正好等于我发的 CBW tag

也就是说,设备压根没在跑 BOT——它停在 UAS 模式,把我的 BOT CBW 当成一条 UAS 命令解析,然后按 UAS 的规矩回了一个 Sense IU。tag=1 对上了,更坐实了这一点:它确实"收到并回应"了我的命令,只是用的另一套语言。

解药就是前面 open() 里第 1 步和第 4 步:选接口只认 protocol==0x50,claim 之后 conn.setInterface(itf) 把设备切回 BOT(alt0),再 resetRecovery() 复位。切回来,CSW 开头立刻变成 55 53 42 53。

顺带一提,如果 setInterface 返回 false 或切换不生效(个别非 root 手机的 UAS 驱动咬死不放),那就是应用层切不动 BOT 了。这种时候别硬刚,换一块普通 U 盘验证代码最快——U 盘是纯 BOT,插上就通,能立刻证明"代码没问题,只是那块硬盘是 UAS"。真要读 UAS 硬盘,得另写一套 UAS 传输层(4 端点、命令/状态 IU、stream ID),那是另一篇的工作量了。


错误恢复:Reset Recovery

上面几处出错都调了 resetRecovery。BOT 规范定义了固定的复位动作:发一个 class 请求 Bulk-Only Mass Storage Reset,再对两个端点做 Clear-Feature(清 HALT)。卡死、Phase Error、切完 alt 之后,都用它回到干净起点。

<span>private</span> <span><span>fun</span> <span>resetRecovery</span><span>()</span></span> {
    <span>// Bulk-Only Mass Storage Reset:bmRequestType=0x21, bRequest=0xFF, wIndex=接口号</span>
    conn.controlTransfer(<span>0x21</span>, <span>0xFF</span>, <span>0</span>, itf.id, <span>null</span>, <span>0</span>, TIMEOUT)
    clearHalt(epIn); clearHalt(epOut)
}
<span>private</span> <span><span>fun</span> <span>clearHalt</span><span>(ep: <span>UsbEndpoint</span>)</span></span> {
    <span>// CLEAR_FEATURE(ENDPOINT_HALT):bmRequestType=0x02, bRequest=1, wValue=0, wIndex=端点地址</span>
    conn.controlTransfer(<span>0x02</span>, <span>1</span>, <span>0</span>, ep.address, <span>null</span>, <span>0</span>, TIMEOUT)
}


第七步:读容量、读扇区,以及"这次到底算不算读成功"

INQUIRY 通了,后面全是同一个模板换 CDB。趁热我把容量和第 0 扇区也读了,顺手定了个"这次读盘算不算成功"的判据——不然肉眼看一串数字,没法自动判断对错。

先 READ CAPACITY(10)(opcode 0x25)拿总块数和块大小。块大小这里我没敢写死成 512——移动硬盘常是 4K,写死会在数据阶段少读,残留再去连累 CSW。返回的两个字段都是大端 4 字节:

<span><span>fun</span> <span>readCapacity</span><span>()</span></span>: Pair<<span>Long</span>, <span>Int</span>> {   <span>// (最后一个 LBA, 块大小)</span>
    <span>val</span> cdb = ByteArray(<span>10</span>).also { it[<span>0</span>] = <span>0x25</span> }
    <span>val</span> buf = ByteArray(<span>8</span>)
    transfer(cdb, buf, dataIn = <span>true</span>)
    <span>val</span> bb = java.nio.ByteBuffer.wrap(buf).order(java.nio.ByteOrder.BIG_ENDIAN)
    <span>val</span> lastLba = bb.getInt(<span>0</span>).toLong() and <span>0xFFFFFFFFL</span>     <span>// byte 0..3</span>
    <span>val</span> blockSize = bb.getInt(<span>4</span>)                            <span>// byte 4..7</span>
    <span>return</span> lastLba to blockSize
}

它的成功判据很朴素:blockSize 是 512 或 4096、lastLba > 0,并且 (lastLba+1) × blockSize 换算出的总容量跟盘的标称容量对得上。三条都满足,才敢说这条命令读对了。

再 READ(10)(opcode 0x28)按 LBA 读扇区。CDB 里的起始 LBA 和块数都是大端——壳小端、芯大端,这里又一次:

<span><span>fun</span> <span>read10</span><span>(lba: <span>Long</span>, blockCount: <span>Int</span>, blockSize: <span>Int</span>)</span></span>: ByteArray {
    <span>val</span> cdb = ByteArray(<span>10</span>)
    cdb[<span>0</span>] = <span>0x28</span>
    cdb[<span>2</span>] = (lba ushr <span>24</span>).toByte(); cdb[<span>3</span>] = (lba ushr <span>16</span>).toByte()
    cdb[<span>4</span>] = (lba ushr <span>8</span>).toByte();  cdb[<span>5</span>] = lba.toByte()          <span>// 大端 LBA</span>
    cdb[<span>7</span>] = (blockCount ushr <span>8</span>).toByte(); cdb[<span>8</span>] = blockCount.toByte()
    <span>val</span> buf = ByteArray(blockCount * blockSize)
    transfer(cdb, buf, dataIn = <span>true</span>)
    <span>return</span> buf
}

读第 0 扇区的成功判据是引导签名:读回的字节数等于 blockSize(没短读),而且扇区最后两字节是 0x55 0xAA。这两字节是 MBR / 引导扇区雷打不动的结尾,读对了必有,读错了几乎不可能凑巧出现——比对着任何字段都可靠。

把这两条命令和各自判据合起来,就是一个 probe(),读完直接给出"成功没成功":

<span>data</span> <span>class</span> <span>ProbeResult</span>(
    <span>val</span> blockSize: <span>Int</span>, <span>val</span> blockCount: <span>Long</span>, <span>val</span> totalBytes: <span>Long</span>,
    <span>val</span> capacityOk: <span>Boolean</span>, <span>val</span> sector0Read: <span>Boolean</span>, <span>val</span> bootSigOk: <span>Boolean</span>,
    <span>val</span> layout: String, <span>val</span> sector0Head: String,
) {
    <span>val</span> allOk <span>get</span>() = capacityOk && sector0Read && bootSigOk
}

<span><span>fun</span> <span>probe</span><span>()</span></span>: ProbeResult {
    <span>// READ CAPACITY:blockSize 是 512/4096、lastLba>0 才算数</span>
    <span>val</span> (lastLba, blockSize) = readCapacity()
    <span>val</span> capacityOk = (blockSize == <span>512</span> || blockSize == <span>4096</span>) && lastLba > <span>0</span>

    <span>// READ(10) 第0扇区:读满 + 结尾 55 AA</span>
    <span>val</span> sector0 = read10(<span>0</span>, <span>1</span>, <span>if</span> (capacityOk) blockSize <span>else</span> <span>512</span>)
    <span>val</span> sector0Read = sector0.size == (<span>if</span> (capacityOk) blockSize <span>else</span> <span>512</span>)
    <span>val</span> bootSigOk = sector0.size >= <span>512</span> &&
            (sector0[<span>510</span>].toInt() and <span>0xFF</span>) == <span>0x55</span> &&
            (sector0[<span>511</span>].toInt() and <span>0xFF</span>) == <span>0xAA</span>

    <span>val</span> head = sector0.take(<span>16</span>).joinToString(<span>" "</span>) { <span>"%02x"</span>.format(it) }
    <span>val</span> layout = describeLayout(sector0, blockSize)
    <span>return</span> ProbeResult(blockSize, lastLba + <span>1</span>, (lastLba + <span>1</span>) * blockSize,
        capacityOk, sector0Read, bootSigOk, layout, head)
}

allOk 三条全绿,UI 顶上就打个"读盘成功";哪条没过,就标出是容量、短读还是签名。这样接一块盘,一眼就知道协议链路通没通。

第0扇区里认分区:MBR 还是 GPT

第 0 扇区读回来了,接着就是认它。最直觉的是按 MBR 解析:0x1BE 起是 4 个 16 字节的分区表项,每项 +4 是分区类型、+8 是起始 LBA(小端)。

但我插上盘一跑,结果有点意外:扇区头 16 字节全是 00,分区表里只有一项,类型 0xEE、起始 LBA 1。第一反应是不是读错了,核对下来才明白——这不是 MBR 盘,是 GPT 盘。

那三个特征其实互相印证。GPT 盘的第 0 扇区是一个"保护性 MBR(Protective MBR)":前 446 字节本来放启动引导代码,GPT 用不上,所以全填 0,扇区头自然全 00。它在分区表里只放一项、类型定为 0xEE——这是 GPT 专用的占位类型,作用是让只认 MBR 的老工具以为"整盘已被占用、别乱动"。而这一项的起始 LBA 写成 1,指的就是真正的 GPT 头所在扇区(GPT 头永远在 LBA 1,LBA 0 让给保护性 MBR)。

所以认布局时得先看有没有 0xEE:有,就跳到 GPT;没有,才按老 MBR 走。

<span>private</span> <span><span>fun</span> <span>describeLayout</span><span>(mbr: <span>ByteArray</span>, blockSize: <span>Int</span>)</span></span>: String {
    <span>// 有 0xEE 项 = GPT 盘的保护性 MBR,真表在 GPT 里</span>
    <span>for</span> (i <span>in</span> <span>0</span> until <span>4</span>) {
        <span>if</span> ((mbr[<span>0x1BE</span> + i * <span>16</span> + <span>4</span>].toInt() and <span>0xFF</span>) == <span>0xEE</span>)
            <span>return</span> <span>"GPT 盘(保护性MBR)\n"</span> + describeGpt(blockSize)
    }
    <span>// 否则按传统 MBR 解析</span>
    <span>val</span> sb = StringBuilder(<span>"分区表(MBR):\n"</span>)
    <span>for</span> (i <span>in</span> <span>0</span> until <span>4</span>) {
        <span>val</span> off = <span>0x1BE</span> + i * <span>16</span>
        <span>val</span> type = mbr[off + <span>4</span>].toInt() and <span>0xFF</span>
        <span>if</span> (type == <span>0</span>) <span>continue</span>
        <span>val</span> startLba = java.nio.ByteBuffer.wrap(mbr, off + <span>8</span>, <span>4</span>)
            .order(java.nio.ByteOrder.LITTLE_ENDIAN).int.toLong() and <span>0xFFFFFFFFL</span>
        <span>val</span> name = <span>when</span> (type) { <span>0x0B</span>, <span>0x0C</span> -> <span>"FAT32"</span>; <span>0x07</span> -> <span>"NTFS/exFAT"</span>; <span>else</span> -> <span>"0x%02X"</span>.format(type) }
        sb.append(<span>"  分区<span>$i</span>: <span>$name</span> 起始LBA=<span>$startLba</span>\n"</span>)
    }
    <span>return</span> sb.toString().trimEnd()
}

GPT 的分区表不在第 0 扇区,得顺着往下读:LBA 1 是 GPT 头(签名 EFI PART),里面记着分区数组从哪个 LBA 开始、共几项、每项多大;LBA 2 起就是那个数组,每项 128 字节,描述一个真实分区的起止 LBA 和名字。

<span>private</span> <span><span>fun</span> <span>describeGpt</span><span>(blockSize: <span>Int</span>)</span></span>: String {
    <span>val</span> hdr = read10(<span>1</span>, <span>1</span>, blockSize)                      <span>// GPT 头在 LBA 1</span>
    <span>val</span> sig = String(hdr, <span>0</span>, <span>8</span>, Charsets.US_ASCII)
    <span>if</span> (sig != <span>"EFI PART"</span>) <span>return</span> <span>"  GPT 头签名异常: '<span>$sig</span>'"</span>

    <span><span>fun</span> <span>le32</span><span>(a: <span>ByteArray</span>, o: <span>Int</span>)</span></span> = java.nio.ByteBuffer.wrap(a, o, <span>4</span>).order(java.nio.ByteOrder.LITTLE_ENDIAN).int
    <span><span>fun</span> <span>le64</span><span>(a: <span>ByteArray</span>, o: <span>Int</span>)</span></span> = java.nio.ByteBuffer.wrap(a, o, <span>8</span>).order(java.nio.ByteOrder.LITTLE_ENDIAN).long

    <span>val</span> entryLba   = le64(hdr, <span>0x48</span>)                       <span>// 分区数组起始 LBA(通常 2)</span>
    <span>val</span> entryCount = le32(hdr, <span>0x50</span>)                       <span>// 项数(常 128)</span>
    <span>val</span> entrySize  = le32(hdr, <span>0x54</span>)                       <span>// 每项字节数(常 128)</span>

    <span>val</span> sectors = (entryCount * entrySize + blockSize - <span>1</span>) / blockSize
    <span>val</span> arr = read10(entryLba, sectors, blockSize)

    <span>val</span> sb = StringBuilder(<span>"  分区数组: LBA=<span>$entryLba</span> 项数=<span>$entryCount</span>\n"</span>)
    <span>var</span> idx = <span>0</span>; <span>var</span> found = <span>0</span>
    <span>while</span> (idx + entrySize <= arr.size && found < entryCount) {
        <span>val</span> used = (<span>0</span> until <span>16</span>).any { arr[idx + it].toInt() != <span>0</span> }   <span>// 类型GUID非全0 = 已用</span>
        <span>if</span> (used) {
            <span>val</span> first = le64(arr, idx + <span>0x20</span>)                        <span>// FirstLBA</span>
            <span>val</span> last  = le64(arr, idx + <span>0x28</span>)                        <span>// LastLBA</span>
            <span>val</span> name  = String(arr, idx + <span>0x38</span>, <span>72</span>, Charsets.UTF_16LE).trim { it == <span>''</span> }
            <span>val</span> gb = (last - first + <span>1</span>) * blockSize / <span>1024.0</span> / <span>1024</span> / <span>1024</span>
            sb.append(<span>"  分区<span>$found</span>: \"<span>$name</span>\" LBA <span>$first</span>..<span>$last</span> (%.2f GB)\n"</span>.format(gb))
            found++
        }
        idx += entrySize
    }
    <span>return</span> sb.toString().trimEnd()
}

有一点跟 MBR 不同:GPT 分区项里的类型是 128 位 GUID,不是 MBR 那种一字节类型码,所以我没直接翻成 "FAT32"——GUID 只表明分区用途(比如 Basic Data),真正的文件系统还得进到分区起始 LBA 去读引导扇区才知道。

再往后:到文件

到这一层协议已经全部结束,剩下的是纯字节解析。进到某个分区的起始 LBA,读它的引导扇区(FAT32 的 BPB),里面有每簇扇区数、保留扇区数、FAT 表位置、根目录簇号——由这些能把"簇号"换算成"物理 LBA",再顺着 FAT 表的簇链一簇一簇读,就能遍历目录、读出文件。

完整的 FAT32/exFAT(长文件名、簇链、各种边界)代码量和易错程度都陡增。我自己写到"能按 LBA 读扇区、会认 MBR/GPT 分区"就收手了——到这一步协议算是吃透了;文件系统那层交给 libaums 收尾更省事,它底层就是这篇的 BOT/SCSI,上面把文件系统封好了。


几条用血换来的经验

字节序错是我碰到的头号杀手:CBW/CSW 小端,CDB 大端,LBA 和块数在 CDB 里全是大端。

bCBWCBLength 是 CDB 长度,不是 CBW 长度。写错就是一个非法命令包,而且不把整包 hex 打出来根本看不出来。

移动硬盘大概率是 UAS 而不是 BOT。我最后是靠只认 protocol==0x50、claim 后 setInterface 切回 BOT 才读通的;那串 03 00... 开头的"CSW",其实是 UAS Sense IU 在用另一套协议回话。

数据阶段没读满 expected 就退出,残留会顶到 CSW 位置伪装成签名错——这一条我栽过,后来改成读满为止。

调不通的时候我固定打三样:设备描述符、CBW 整包 hex、CSW 的 13 字节 hex。这三样齐了,几乎所有 BOT 问题都能当场定位。

最后,这东西只能真机测,模拟器造不出 USB 大容量设备;我的顺序是先拿普通 U 盘把代码跑通,再去碰移动硬盘这种 UAS 设备。