这篇记录我怎么从零把一块 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 端点 | 传输模型 |
|---|---|---|---|
0x50 | BOT | 2 个(IN/OUT) | 本文的 CBW → 数据 → CSW |
0x62 | UAS | 4 个 + 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 字节,小端)
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCBWSignature | 固定 0x43425355("USBC") |
| 4 | 4 | dCBWTag | 自定义序号,CSW 会原样返回,用来配对 |
| 8 | 4 | dCBWDataTransferLength | 数据阶段要传多少字节 |
| 12 | 1 | bmCBWFlags | bit7:1=IN(读),0=OUT(写) |
| 13 | 1 | bCBWLUN | 逻辑单元号,U 盘一般 0 |
| 14 | 1 | bCBWCBLength | 后面 CDB 的长度(1~16) |
| 15 | 16 | CBWCB | 真正的 SCSI 命令(CDB),不足补 0 |
CSW:结果的回执(13 字节,小端)
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | dCSWSignature | 固定 0x53425355("USBS") |
| 4 | 4 | dCSWTag | 必须等于你发的 CBW 的 tag |
| 8 | 4 | dCSWDataResidue | 期望 − 实际,还差多少没传 |
| 12 | 1 | bCSWStatus | 0=成功,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,右侧用空格补齐:
| 偏移 | 长度 | 字段 | 含义 |
|---|---|---|---|
| 0 | 1 | 外设类型 | 低 5 位:0x00=直接存取(磁盘/U盘) |
| 8 | 8 | Vendor Identification | 厂商 |
| 16 | 16 | Product Identification | 产品名 |
| 32 | 4 | Product 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 设备。