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

22 阅读10分钟

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

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

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


先把地图画出来

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

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

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

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

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

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


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

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

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

    private lateinit var usb: UsbManager

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

    fun close() {
        if (this::conn.isInitialized) {
            conn.releaseInterface(itf)
            conn.close()
        }
    }
}

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

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

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

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

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

interface[0]: id=0 alt=0 class=8 subclass=6 protocol=0x50(BOT) endpoints=2
    ep[0] addr=0x81 IN  BULK
    ep[1] addr=0x02 OUT BULK
interface[1]: id=0 alt=1 class=8 subclass=6 protocol=0x62(UAS) endpoints=4
    ep[0] addr=0x81 IN  BULK
    ep[1] addr=0x02 OUT BULK
    ep[2] addr=0x83 IN  BULK
    ep[3] addr=0x04 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 我一直默认留着:

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

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


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

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

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

每一条 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、校验,一条龙。它同时是我踩第二个坑的地方——先看完整代码,坑标在注释里:

private var tag = 0
private val TIMEOUT = 5000

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

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

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

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

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

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

private companion object { const val CSW_BAD = -1; const val TAG = "UsbStorage" }

坑二: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,我这样调:

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

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

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

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

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

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

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

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)。

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

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

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

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

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

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

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

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


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

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

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

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

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

byte0 = 0x03      → UAS IU 类型 = Sense IU(CSW 的签名头应该是 55 53 42 53)
byte1 = 0x00      → 保留
byte2..3 = 00 01  → IU Tag(大端)= 1,正好等于我发的 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 之后,都用它回到干净起点。

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

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

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

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

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

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

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

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

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

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

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

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

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

    val head = sector0.take(16).joinToString(" ") { "%02x".format(it) }
    val layout = describeLayout(sector0, blockSize)
    return ProbeResult(blockSize, lastLba + 1, (lastLba + 1) * 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 走。

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

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

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

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

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

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

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