起因

立项清单里"读取 U 盘"是明确需求:现场拷加工程序、导出加工报表、固件升级的入口都是它。U 盘的命令集是借来的:SCSI 的前身 SASI 是 1979 年 Shugart 给磁盘驱动器定的接口,1986 年标准化成 SCSI,在企业存储里摸爬了十几年;USB 大容量存储类没有另起炉灶,把 SCSI 命令装进 CBW/CSW 信封走批量传输——所以驱动一个 U 盘,实际写的是一套打了折的 SCSI 主机。USB 协议详解把 CBW/CSW 的理论讲完了,EHCI 篇有了批量传输的队列机制,NVMe 篇立了 BlockDevice 抽象——这篇把三样东西拧起来:一个真的能挂 exFAT 的 U 盘驱动。

一、识别:接口三元组与端点

枚举完的设备逐接口检查 class/subclass/protocol:

字段 含义
interface class 0x08 大容量存储(MSC)
subclass 0x06 SCSI 透明命令集
protocol 0x50 Bulk-Only Transport(BOT)

命中就从端点描述符里拿 bulk-in / bulk-out 两个端点,在 EHCI 异步调度里给它们各挂一个 QH(队列挂法见 EHCI 篇)。端点的认法:bEndpointAddress bit7 是方向(1=IN)、bit0-6 是端点号;高速盘的 wMaxPacketSize 固定 512。设备速度枚举时顺手记下:EHCI 端口经 TT 挂进来的全速盘批量包长只有 64,吞吐掉到 1MB/s 量级——能识别就让它用,但界面要提示"全速盘,拷大文件请换高速盘",别让操作工以为系统坏了。

坑:subclass 0xFF(厂商私有,老 U 盘和"送的小礼品盘"常见)一律拒绝加载并提示换盘,别试图猜协议——那种盘各有各的脾气,工控系统不陪它玩。

二、命令层:CBW/CSW 的字节布局

一次传输 = CBW(out)→ DATA(in 或 out,长度可以为 0)→ CSW(in),三段在同一个 QH 上用 qTD 链串起来。

31 字节 CBW:

偏移 内容
0–3 签名 0x43425355(“USBC”)
4–7 tag(自增,回包核对用)
8–11 data 阶段字节数(大端)
12 flags:bit7=1 表示 IN
13 保留(0)
14 LUN(单 LUN 盘就是 0)
15 命令块长度(通常 10 或 16)
16–30 CBWCB(SCSI 命令块)

13 字节 CSW:

偏移 内容
0–3 签名 0x53425355(“USBS”)
4–7 tag(必须与本次 CBW 对上
8–11 residue(没传完的字节数)
12 status:0 成功 / 1 命令失败→查 sense / 2 相位错误→复位恢复

坑:tag 不核对等于没校验。 总线抖动时可能收到上一次传输的残留 CSW,tag 对不上就整条重传。

坑:dCBWDataTransferLength 必须与 DATA 阶段实际字节数严格一致。 READ 多要、WRITE 少给,守规矩的盘 STALL 或报 phase error,不守规矩的直接超时——宁可在上层把缓冲凑齐,也别让 CBW 的声明和实际传输对不上。

三、SCSI 最小命令集

工控 U 盘只需要 6 条:

命令 操作码 说明
TEST UNIT READY 0x00 探测就绪(配合 REQUEST SENSE)
REQUEST SENSE 0x03 18B:KEY@2 / ASC@12 / ASCQ@13
INQUIRY 0x12 36B:厂商@8 产品@16 版本@32(自检报告的显示素材)
READ CAPACITY 0x25 返回末 LBA + 块大小(u32 大端 ×2)
READ(10) 0x28 LBA@2–5,块数@7–8(大端)
WRITE(10) 0x2A 同上
1
2
3
4
5
6
7
8
fn read10_cb(lba: u32, n: u16) -> [u8; 16] {
    let mut cb = [0u8; 16];
    cb[0] = 0x28;
    cb[2..6].copy_from_slice(&lba.to_be_bytes());        // 起始 LBA
    cb[7..9].copy_from_slice(&n.to_be_bytes());          // 块数
    cb
    // CBW: data 长度 = n*512,flags bit7=1(IN)
}

坑:单命令块数上限规范不保证,老盘常见 128/255 块的隐性上限——保守 64 块(32KB)一条,超了就拆。 撞上限的症状是 STALL 或短读,不要硬试着加大。

坑:U 盘也有"就绪"状态机。 刚插上/spin-up 要几秒:TEST UNIT READY 失败 → REQUEST SENSE 看到 KEY 06 / ASC 28(介质变化)→ 轮询重试,上限 5 秒,别死等。

两个现场够用的细节:READ CAPACITY 回的末 LBA 是 0xFFFFFFFF 时表示真容量超 2TB,要换 READ CAPACITY(16)(0x9E)问 8 字节 LBA——U 盘撞上这条的概率很低,但代码里留个判断分支,比事后解释便宜。REQUEST SENSE 的 18 字节里,现场排障最常碰的组合:KEY 02/ASC 3A(无介质)、KEY 06/ASC 28(介质变化)、KEY 06/ASC 27(写保护)、KEY 03/ASC 11(读介质错误——盘快不行了,提示备份换盘)、KEY 05/ASC 21(LBA 越界——上层算错了,查自己代码)。错误处理按 KEY 大类路由,ASC 留给日志和界面文案,别一条条 if 硬编码。

四、错误处理:STALL 与复位恢复

bulk 传输的一切异常最终都表现为端点 STALL,处理分三级:

  1. 数据/状态阶段 STALL → 控制传输 CLEAR_FEATURE(ENDPOINT_HALT) 清两个 bulk 端点各一遍(setup 包:bmRequestType 0x02、bRequest 0x01、wValue 0、wIndex=端点地址、wLength 0)→ 当前命令重试一次
  2. CSW status=1 → REQUEST SENSE 拿原因分类:介质没准备好→轮询;写保护→报错给上层
  3. status=2 或清了三次还 STALL → Bulk-Only Mass Storage Reset(类请求:bmRequestType 0x21、bRequest 0xFF、wIndex=接口号)→ 再 CLEAR_FEATURE → 重新 READ CAPACITY

坑(必踩):CLEAR_FEATURE 会把该端点的 data toggle 复位回 DATA0。 重试的 QH 里 DT 位必须同步清零,否则接下来第一次传输被静默丢弃——表现是"复位成功了但永远读不出数据",极难定位。

坑:拔盘瞬间在途传输会 NAK/超时混着来,错误路径必须把传输缓冲还回 DMA 池(不泄漏)且不持有文件系统锁。所以整个重试状态机放在协作层的一个 step 函数里跑(调度篇的框架),USB 永远不阻塞别人。

五、接入 BlockDevice

NVMe 篇的 trait 直接实现:

1
2
3
4
5
impl BlockDevice for UsbMsc {
    fn read_blocks(&mut self, lba: u64, buf: &mut [u8]) -> Result { /* 64 块分段循环 */ }
    fn write_blocks(&mut self, lba: u64, buf: &[u8]) -> Result { ... }
    fn flush(&mut self) -> Result { /* 可选映射 SCSI SYNC CACHE 0x35;写直达策略下可空 */ }
}

exFAT 层根本不知道底下是 NVMe 还是 U 盘——加工程序从 U 盘拷到硬盘,就是两个卷之间的普通读写。拔盘事件由枚举层广播,文件系统层收到后卸载卷、关闭打开的句柄,界面弹提示。卸载前发一次 SYNC CACHE(0x35)等 CSW 回来再收尾——写直达策略下它多半是空操作,但这几十毫秒的保险,比教会每个操作工"安全弹出"便宜。

性能预期摆正:USB 2.0 高速理论 480Mbps,批量传输实际 30~38MB/s 封顶,再乘单命令 64 块的往返效率,稳定 25MB/s 上下就该满意。想要快,靠 QH 链把多条读命令批量挂进异步调度,而不是加大单命令。

总结

实战清单:三元组识别 + 双 bulk 端点挂 QH;CBW/CSW 字节布局表常备;六条 SCSI 命令;64 块保守分段;STALL 三级处理(清端点→查 sense→类复位);DT 位复位纪律;tag 核对;重试状态机放协作层 step。跑通标志:INQUIRY 打出厂商/产品字符串;READ CAPACITY 容量与盘身标称一致;exFAT 卷挂载后读一个 100MB 文件 CRC 与宿主机一致;写入中途拔盘系统不崩、插回后重挂载继续用。

下一篇:日志系统——这个驱动里已经埋了好几处"记 ERROR 日志",该兑现它了。

参考链接