
1. 先说清楚这里的BOT不是聊天机器人是U盘最底层的传输机制做USB设备端开发的人几乎没人能绕开MSC协议。无论是U盘、读卡器、移动硬盘还是打印机里的存储单元最基础的传输方式都是Bulk-Only Transport也就是标题里的BOT。有朋友一听BOT就联想到最近各种AI Bot我在这里先泼盆冷水USB MSC里的BOT全称是Bulk-Only Transport跟聊天机器人没有任何关系。它是USB Mass Storage Class海量存储类里定义的一套纯数据传输机制核心内容就是两样东西31字节的CBWCommand Block Wrapper命令块包裹和13字节的CSWCommand Status Wrapper命令状态包裹。为什么说它最底层因为整个MSC协议做的事情本质上就是把SCSI命令封装到USB的Bulk端点上传送。你在电脑上看到的U盘分区、文件系统、读写速度这些都得靠这套封装来承载。上层再花哨底层命令还是得经过CBW下发、数据阶段搬运、CSW回报这一条固定链路。换句话说搞懂了CBW和CSW你就搞懂了U盘固件最核心的那一半。这篇文章适合谁看正在写U盘主控固件的嵌入式工程师、想搞懂总线抓包数据的底层开发人员以及准备面试时被问到CBW里有哪些字段的求职者。我会把字节对齐、字段含义、传输流程、异常恢复全部拆开讲最后还会分享几个我实际调试中踩过的坑。这些坑在协议手册上找不到但每一个都能让你少熬几个通宵。2. CBW31字节的命令包裹主机到设备的唯一入口2.1 字段逐字节拆解CBW是USB主机发给设备端的第一个东西固定31字节。USB协议里很少有结构体是固定长度的CBW之所以卡死在31字节是为了让设备端能用一个简单的状态机去接收收满31字节再解析解析完再决定下一步。这个设计对MCU资源很友好尤其早期很多U盘主控只有几KB RAM一个固定长度的结构体可以直接映射到缓冲区连动态内存分配都省了。31字节的排布如下偏移字段长度说明0dCBWSignature4字节固定为0x43425355总线上字节序表现为55 53 42 43ASCII对应USBC4dCBWTag4字节命令标签主机自定义CSW必须原样带回8dCBWDataTransferLength4字节数据阶段期望传输的字节数12bmCBWFlags1字节bit70主机发往设备(OUT)1设备发往主机(IN)13bCBWLUN1字节逻辑单元号多LUN设备使用14bCBWCBLength1字节CBWCB有效长度范围1~1615CBWCB16字节SCSI命令块即CDBdCBWSignature是最直观的识别特征。如果你在总线抓包里看到 55 53 42 43 开头的数据那基本就是一次CBW传输。注意这里的字节序协议里写的是0x43425355是USBC按小端序排出来的整数真到了USB线上先发的是低字节0x55接着0x53、0x42、0x43。很多人在代码里直接拿uint32_t比较0x43425355如果用的是小端MCU这么写正好是对的换到大端MCU就得小心后面我会详细说这个坑。dCBWTag是主机用来关联CBW和CSW的。USB是共享总线同一时刻可能有多个命令在排队虽然BOT协议严格说是一条命令走完再走下一条但操作系统层面会有重试、排队机制靠tag就能区分CSW到底对应哪条命令。设备端不需要理解tag的具体含义但必须原样带回这是协议里少数几条绝对命令之一。bmCBWFlags看起来只有一个字节实际上只有bit7有意义。bit7为0表示数据方向是OUT也就是主机往设备写数据bit7为1表示IN设备往主机发数据。其余7位保留必须为0。这里有个容易忽略的细节如果dCBWDataTransferLength为0这个方向位就没有意义设备应该直接忽略它因为它不会影响任何后续行为。bCBWLUN是针对多LUN设备的。像读卡器经常有SD卡和TF卡两个逻辑单元就需要靠这个字节区分。只有一个存储介质的设备这个字段一般是0。如果主机下发的LUN超出设备支持范围设备应该在CSW里返回失败而不能默默接受。bCBWCBLength表示后面CBWCB数组的有效长度范围是1到16。0是非法值因为SCSI命令最少也有一个操作码字节。USB规范要求设备收到长度为0的CBW时应当按非法CBW处理——这个细节在USBCV协议测试里专门有一个用例很多初版固件就是栽在这。CBWCB是真正的SCSI命令块。比如最常见的INQUIRY命令操作码是0x12后面15字节是参数和保留区READ(10)操作码是0x28WRITE(10)是0x2A。对MSC设备来说这一块的解析逻辑其实就是一套SCSI命令解析器。U盘之所以能被Windows、Linux、macOS通吃就是因为上层操作系统只认标准的SCSI命令设备端把SCSI命令翻译成对Flash/NAND介质的读写操作即可。2.2 一个真实的CBW报文长什么样光说字段有点抽象我贴一个典型的INQUIRY命令CBW报文十六进制如下55 53 42 43 21 00 00 00 24 00 00 00 80 00 06 12 00 00 00 24 00 00 00 00 00 00 00 00 00 00 00 00逐段解析55 53 42 43签名USBC21 00 00 00dCBWTag 0x00000021主机自定义的标签24 00 00 00dCBWDataTransferLength 0x24 36期望返回36字节数据80bmCBWFlags 0x80bit7为1数据方向是IN设备发给主机00LUN 0单LUN设备06bCBWCBLength 6SCSI命令块有效长度6字节从第15字节开始12 00 00 00 24 00这是6字节的INQUIRY CDB操作码0x12分配长度0x24 36控制字节0x00后面10个字节全0是填充保证CBW总长31字节注意CBWCB后面的填充字节可以不是0理论上主机可以填任意值设备解析时只认bCBWCBLength声明的长度。我建议固件里直接忽略CBWCB后半部分的内容不要做任何校验因为不同操作系统对这一段的填充值并不一致。2.3 填CBW时最容易翻车的三个细节第一个是签名写反。不少人直接在结构体里写uint32_t sig USBC在x86上编译出来没问题但换到ARM大端交叉编译环境出来的字节序就反了。问题在于USBC这种多字符常量在不同编译器下的行为并不保证。最稳妥的写法是直接定义字节数组static const uint8_t csw_signature[4] {0x55, 0x53, 0x42, 0x53}; // USBS static const uint8_t cbw_signature[4] {0x55, 0x53, 0x42, 0x43}; // USBC第二个是dCBWDataTransferLength和设备实际传输不一致。比如主机下发READ(10)期望读4096字节数据阶段设备只发了2048字节。这种情况设备必须在CSW的dCSWDataResidue里如实上报差值不能假装成功。主机侧会根据Residue判断是重试还是报错你虚报成功上层文件系统就会读到半截数据后果很隐蔽。第三个是CBWCB长度写0。很多初写的驱动在传输不需要CDB的命令时直接把bCBWCBLength置0这在规范里属于非法CBW设备端可以整体拒绝。规范虽然允许设备对无意义CBW返回错误但最好的做法是任何命令都至少给一个操作码字节比如TEST UNIT READY也是0x00开头的一个完整CDB不要省。3. CSW13字节的状态回执设备对命令的最终裁决3.1 字段逐字节拆解CSW比CBW还短只有13字节是设备在命令处理完成后回给主机的最终裁决。它的结构和CBW一一对应偏移字段长度说明0dCSWSignature4字节0x53425355线上字节为55 53 42 53ASCIIUSBS4dCSWTag4字节必须等于对应CBW的dCBWTag8dCSWDataResidue4字节期望传输字节数与实际传输字节数的差值12bCSWStatus1字节0成功1失败2阶段错误CSW的签名和CBW就差最后一个字母CBW是USBCCSW是USBS。抓包时靠这个就能快速区分当前是命令还是状态。我经常看到有人把这两个签名记混这里有个记忆技巧C代表Command命令S代表Status状态所以CBW最后一个字母是CCSW最后一个字母是S。dCSWTag必须回显CBW里的tag这是一条硬性规定。主机发出CBW后会记录下自己用的tag收到CSW时先比对不一致就直接按错误处理。所以设备端最简单的做法是收到CBW时把dCBWTag单独存到一个变量里构造CSW时原样填回去。千万不要直接从收到的CBW缓冲区里拷贝——万一你后面复用了这个缓冲区tag就被覆盖了。dCSWDataResidue是很多人理解不到位的地方。它的定义是期望传输的数据长度减去实际传输的数据长度用公式表示Residue dCBWDataTransferLength - 实际传输字节数这个差值永远是非负的。如果设备提前结束了数据阶段比如只发了2048字节但期望4096Residue就是2048。如果数据全部传完Residue为0。注意如果期望是0无数据阶段Residue也必须是0。3.2 dCSWDataResidue到底怎么算我给一个具体的例子。主机下发WRITE(10)命令dCBWDataTransferLength 4096表示主机要往设备写4KB数据。数据阶段设备实际从Bulk OUT端点收了4096字节那么CSW的Residue 4096 - 4096 0状态填0整个命令完美收官。另一种情况主机要写4096但设备在数据阶段接收过程中发现介质只读、写入失败于是在收了1024字节后就halt了Bulk OUT端点。这时候Residue应该填多少按规范应该填 4096 - 1024 3072同时bCSWStatus填1。主机看到这个CSW就知道本来要写4KB设备实际只收了1KB还有3KB压根没传成功。操作系统此时会向上层报告I/O错误文件系统会把这个区域标记为坏块或触发重试。Residue算错会导致上层文件系统判断异常。比如实际没收满但Residue填0主机觉得数据全写进去了后续读到一半的数据就全错了。这种问题极难排查因为文件系统层面看到的是写入报告成功但数据是坏的。等你注意到文件损坏的时候往往已经过去很久了。还有一种边界情况数据阶段比期望的传得更多。比如期望4096但设备发了5120字节。这在协议里也是错误属于阶段错误的一种。正确的做法是设备端Bulk IN传输绝不允许超过dCBWDataTransferLength声明的字节数一旦到达期望值就必须停止立即转入CSW阶段。如果固件逻辑有bug导致多传了数据主机端的Bulk传输会以STALL或溢出错误收场。3.3 三种状态的完整语义bCSWStatus 0 是最理想的所有阶段正常结束。bCSWStatus 1 表示命令本身失败了但传输链路没崩主机可以通过后续的SCSI Request Sense命令去取具体的错误码比如介质写保护、逻辑块地址越界等。bCSWStatus 2 是最麻烦的它表示整个传输流程出现阶段错误主机通常要发起Reset恢复——注意这里是Bulk-Only Mass Storage Reset不是USB总线复位是一个专门针对MSC接口的类特定请求。什么情况会触发阶段错误最常见的是CBW本身非法签名不对、CBWCB长度为0、或者收到的CBW不足31字节。设备端的标准做法是STALL Bulk OUT端点、不进数据阶段、然后直接STALL Bulk IN端点并发一个bCSWStatus2的CSW。注意这里有个细节规范要求设备必须通过Bulk IN反向发送CSW哪怕CBW非法也要告诉主机我这边出问题了。这里有个容易踩的设计点当你STALL了Bulk IN端点再往里写CSW数据硬件上会先发STALL握手主机收到STALL后会执行Clear Feature清除端点Halt状态然后才能正常收CSW。如果你的固件在STALL之后没有正确等待主机的Clear Feature直接把CSW塞进端点FIFO那这个CSW可能被硬件丢弃。我在第5章会详细讲这个时序问题。4. BOT传输流程命令、数据、状态三阶段如何协同工作4.1 正常流程走一遍BOT的一次完整命令周期分三个阶段命令阶段主机通过Bulk OUT端点发送31字节CBW数据阶段可选根据dCBWDataTransferLength和bmCBWFlags决定方向与长度状态阶段设备通过Bulk IN端点发送13字节CSW阶段转换由设备端状态机驱动。收到CBW后设备解析出命令类型和数据方向然后进入对应的数据处理分支。全部处理完再回到状态阶段发送CSW最后回到空闲状态等待下一条CBW。为什么三个阶段必须严格串行这是BOT协议设计上的取舍。SCSI命令本身有依赖关系比如先发送TEST UNIT READY查询介质状态再发送READ(10)读取数据。如果允许多条命令并行处理设备端要维护多个上下文对低成本MCU不现实。BOT选择用一条命令跑完全程再走下一条的串行模型换取了实现的简单性和确定性——代价是性能上限低这也是后来UASP出现的原因。4.2 带数据阶段的IN和OUT场景按数据方向正常流程可以拆成四种情况场景命令阶段数据阶段状态阶段无数据命令OUT发送CBW无IN发送CSW主机读数据(IN)OUT发送CBW设备在Bulk IN发数据IN发送CSW主机写数据(OUT)OUT发送CBW主机在Bulk OUT发数据IN发送CSW未定义方向OUT发送CBW无IN发送CSW主机读数据IN的场景比如操作系统发READ(10)读取U盘文件内容。主机先通过Bulk OUT发CBWbmCBWFlags 0x80数据长度4096。设备收到后解析SCSI命令从Flash/NAND介质里把数据搬出来通过Bulk IN端点一包一包发出去。发完4096字节后设备再构造CSW通过Bulk IN端点发出状态码0Residue 0。主机收到CSW后一次完整的读命令才算结束。主机写数据OUT的场景比如WRITE(10)。主机通过Bulk OUT发CBWbmCBWFlags 0x00数据长度4096。之后主机立刻在Bulk OUT上把4096字节数据发过来。设备一边收一边往介质里写收完后发送CSW回报写结果。这里有个性能相关的点设备端如果实现为收满整个缓冲区才写Flash那它的Bulk OUT缓冲至少要等于一次最大传输长度否则会出现缓冲区溢出。这也是很多U盘主控的端点缓冲要配到512字节甚至更大的原因——不是为了单次传输快而是为了匹配一轮完整的传输会话。无数据命令的例子是TEST UNIT READY0x00和INQUIRY0x12这两条命令都没有数据阶段INQUIRY其实有36字节返回算IN方向数据TEST UNIT READY才是纯无数据。无数据命令省掉数据阶段主机会直接等待CSW。4.3 出错时stall和Reset如何介入USB Bulk端点的错误处理靠STALL握手包。BOT协议里STALL是一个核心的异常信号设备端所有错误路径都以STALL为起点。数据阶段出错如果设备在IN数据阶段发现介质读错误无法继续发送数据它应该STALL Bulk IN端点停止发送然后仍然要构造一个CSW发出去。这个CSW的bCSWStatus填1Residue填剩余的未传输字节数。这个过程有个时序要点STALL之后主机通常会发Clear Feature清除Bulk IN端点的Halt状态之后设备才能往这个端点里填CSW。有些MCU的USB外设提供发送STALL后自动等待清除的状态机用起来很方便如果没提供你需要手动管理等端点STALL状态。命令阶段出错如果设备收到非法CBW应该STALL Bulk OUT端点。因为CBW是从OUT方向来的必须STALL OUT端点让主机知道你的指令我没接受。规范特别说非法CBW情况下设备不应进入数据阶段而是直接尝试发送CSW。主机侧的恢复流程是这样的主机发Bulk-Only Mass Storage Reset类特定请求bRequest 0xFF设备端BOT状态机回到空闲态主机对Bulk OUT和Bulk IN端点分别执行Clear Feature端点Halt清除STALL状态恢复后主机重新发送CBW类特定请求还有一个是Get Max LUNbRequest 0xFE主机用它查询设备支持的最大LUN号。这两个请求都是通过控制端点Endpoint 0发送的不走Bulk端点。值得注意的是Reset请求必须在控制端点的数据阶段返回一个0长度包表示没有数据要传——很多初版固件会漏掉这个空包导致主机以为Reset没有完成。5. 调USB MSC时我踩过的坑5.1 日志里全是55 53 42 43设备却不认有一次我在调新平台上的U盘固件逻辑分析仪抓到的数据非常完美CBW里签名对、长度对、SCSI命令对但设备就是没有任何响应。排查了很久最后发现是MCU的USB外设FIFO大小设置问题——Bulk OUT端点的FIFO被配成了8字节而CBW是31字节。硬件一次只能塞进8字节后续的数据直接被丢弃设备端状态机永远等不到完整的CBW。这个问题在芯片手册上很难发现因为大部分例程的Bulk端点FIFO都配置成64字节或更大。但从8字节配到64字节之后一切就正常了。所以如果你是先在评估板上调通、再移植到自制板卡务必确认USB外设的端点FIFO大小和目标应用匹配。别小看这个配置它在你改PCB时很容易被当成软件不归我管的边界问题忽略掉。5.2 tag匹配与residue的连锁问题还有一次踩坑是在做UASP兼容性测试时发现的虽然最终项目用的是BOT但测试工具覆盖了两者。我的设备对BOT命令处理太慢主机侧超时重发了相同的CBW但换了一个新的tag。我设备端的实现里上一个命令还没处理完状态机还停在等待数据阶段的状态于是把新CBW当成了旧命令的数据包整个流程彻底错乱。后来我加了一条规则任何状态下只要Bulk OUT收到的是31字节且签名匹配就按新CBW处理先中止当前命令再启动新命令。同时保证了CSW的tag永远取自最近一次收到的CBW。改完之后主机的超时重试机制就再没把设备搞崩过。这个经验我用在很多USB设备固件上——状态机设计一定要考虑任意时刻都可能收到新命令这个前提不能假设流程永远是线性的。说到residue有一个实践建议如果设备在命令处理中发现了错误不管数据阶段实际收发了多少都要保证CSW仍能被主机正确接收。也就是说STALL之后要正确恢复端点、再发送CSW的时序要处理对。很多设备在STALL之后忘了恢复导致CSW永远发不出去主机只能靠超时来兜底体验极差。5.3 枚举正常但读写失败这个问题是社区里被问得最多的U盘在电脑上能枚举出盘符但一格式化就报错或者复制文件中途失败。这类问题的根源往往不在MSC协议本身而在数据阶段的传输完整性。一个典型的场景设备端Bulk IN端点每次只能发512字节但主机期望一次传4096字节。如果固件里没有把数据拆包成多个512字节的USB传输或者拆包之间丢了数据主机收到的数据就不完整。这时候CSW里的Residue会暴露问题——如果设备声称传输了4096但实际只发了3072主机就会判定传输异常。还有一种情况是缓冲区对齐问题。有些MCU的DMA要求缓冲区地址4字节对齐如果你的SCSI数据缓冲定义成了结构体成员而不是独立数组就可能出现未对齐地址导致DMA读取错误数据。我见过最隐蔽的一次代码在编译优化级别-O2时一切正常换到-O0就随机读写错误最后定位到就是缓冲区对齐问题。从那以后我的代码里所有USB数据缓冲都强制用__attribute__((aligned(4)))或等效语法声明。6. 调试工具选型与从BOT到UASP的演进6.1 调试MSC必备工具如果只是做主机侧开发抓包工具选择比较多。Wireshark配合usbmonLinux或者USB抓包设备比如基于FPGA的总线分析仪都能看到CBW/CSW。Wireshark对MSC协议有专门的解析器能直接展开CBW和CSW字段非常方便。不过要注意Wireshark的MSC解析器依赖抓包设备能正确识别Bulk传输的边界总线上如果有错误包它可能把后续数据全解析错。做设备侧开发的话我建议USB-IF官方的USB Command VerifierUSBCV一定要跑。USBCV专门用来做协议符合性测试里面针对Mass Storage有完整的测试用例包括CBW长度校验、CSW签名校验、tag匹配、Residue计算、异常STALL和Reset恢复流程。把USBCV跑通了基本可以认为BOT协议实现是合格的。我第一次跑的时候直接被它测出来三个问题CBWCB长度0的处理、STALL后CSW的发送时序、以及无数据命令的方向位处理。还有一个实用的排查技巧在固件里加一个调试用计数器统计收到的CBW总数、发送的CSW总数、出现的STALL次数、收到的Reset请求次数。设备端实际表现有时候跟抓包工具看到的不完全一致因为抓包工具可能漏掉极短时间的硬件事件。这时候固件侧的统计数字反而是最可靠的。我通常在调试串口上每秒打印一次这些统计值看递增是否正常比盯着一帧帧抓包数据高效得多。6.2 UASP为什么更快BOT的串行模型简单可靠但性能上限低。一条命令必须等CSW回来才能发下一条SCSI命令的延迟全部暴露在总线上。UASPUSB Attached SCSI Protocol就是为了解决这个问题出现的。UASP改用两组端点一组Bulk IN/OUT用于数据和状态一组Bulk OUT专门用于命令。命令不再用CBW包裹而是用更灵活的Information UnitIU结构支持多条命令同时在路上跑类似SATA的NCQ。这样的设计能把USB 3.0时代的顺序读性能从BOT的300MB/s级别拉到400MB/s以上具体取决于设备和SSD主控。代价是设备端的实现复杂度显著上升需要管理命令队列、乱序完成和超时重试。如果你在选型时纠结BOT还是UASP我的建议是对于U盘、读卡器这类低成本设备BOT够用对于移动固态硬盘这类追求性能的产品直接上UASP但一定留足固件调试时间队列管理比想象中难得多。USB 2.0时代BOT占据绝对主流到了USB 3.2 Gen2时代支持UASP基本是移动存储产品的标配了。6.3 最后分享一个调试的土办法遇到CSW死活对不上的时候最有效的办法往往不是盯着协议手册而是把主机发的CBW原样打印出来同时打印设备回的状态机节点。我之前调试一个设备主机一直报phase error打印出来发现CBW的dCBWDataTransferLength是0但bmCBWFlags的bit7是1。数据长度为0却要求IN方向本来就是无意义的组合主机和设备对这种情况的处理方式不一致就会互相扯皮。最后统一成数据长度为0时忽略方向位两边都消停了。另一个土办法是人为制造错误场景来测试恢复路径。比如调试时在固件里加一个测试宏让设备在收到特定LBA的READ命令时故意STALL并返回错误。然后观察操作系统会不会重试、会不会触发Reset、恢复后能不能继续正常读写。这套测试能提前暴露很多生产环境才会遇到的异常时序问题比单纯跑正常流程有用得多。抓包、打日志、加计数器三管齐下MSC协议调试基本没有解不开的问题。