USB MSC BOT协议解析:CBW/CSW数据结构与嵌入式实现
1. 项目概述:从零开始理解USB大容量存储设备
最近在调试一个基于STM32的USB设备,需要实现U盘功能,也就是USB Mass Storage Class。翻出几年前的学习笔记,发现当时对Bulk-Only传输协议的理解还不够透彻,导致在调试CBW和CSW时踩了不少坑。这次重新梳理,结合实际的固件开发经验,希望能把USB MSC的核心机制讲清楚,特别是CBW和CSW这两个数据结构的解析与实现,这对于任何想在MCU或FPGA上实现U盘、读卡器功能的工程师来说,都是必须跨过的门槛。
USB大容量存储设备协议,本质上定义了一套让主机(比如你的电脑)能够通过USB接口,像访问本地硬盘一样访问设备(比如你的U盘)的“语言”。这套“语言”的核心就是Bulk-Only Transport协议,它规定了命令、数据和状态这三种信息该如何打包、传输和确认。而CBW和CSW,就是这套语言里最关键的两个“信封”,一个用来装主机下达的指令,一个用来回复指令执行的结果。理解透这两个数据包的结构和交互流程,你的USB设备才能和主机正确“对话”。
2. USB Mass Storage Bulk-Only传输协议深度解析
2.1 协议框架与三种传输流
Bulk-Only传输协议是USB MSC类设备最常用、最基础的通信方式。它摒弃了控制传输承载数据的方式,专门使用Bulk端点来传输命令、数据和状态,从而获得了更高的数据传输效率和可靠性。整个通信流程可以清晰地划分为三条“单向车道”:
命令传输流:主机到设备的单向车道。主机通过Bulk-Out端点发送一个命令块包装给设备。这个CBW里封装了主机想让设备执行的具体操作指令,比如“读取第1024个扇区开始的10个扇区”。
数据传输流:根据命令方向,可能是数据输入或数据输出。如果命令是读操作,数据从设备流向主机,使用Bulk-In端点;如果是写操作,数据从主机流向设备,使用Bulk-Out端点。数据量由CBW中的
dCBWDataTransferLength字段明确指定。状态传输流:设备到主机的单向车道。无论命令执行成功与否,设备都必须通过Bulk-In端点向主机回复一个命令状态包装。这个CSW相当于设备的“回执”,告诉主机“你刚才让我干的活,结果是成功、失败还是忙不过来了”。
这三条流必须严格按顺序执行:CBW -> (可选的数据阶段) -> CSW。设备必须在完整处理完上一个命令的整个流程(收到CSW)后,才能准备接收下一个CBW。这个顺序性是协议可靠性的基石。
注意:很多初学者在调试时遇到的“设备无法识别”或“传输卡住”的问题,根源往往在于这个顺序被打乱了。例如,设备可能在数据还没传完时就提前发送了CSW,或者主机在收到CSW前就发送了下一个CBW。在固件设计时,必须用一个明确的状态机来严格管理这三个阶段。
2.2 核心数据结构:CBW详解
命令块包装是主机发给设备的“任务书”。它是一个31字节固定长度的数据结构,必须从一个USB封包边界开始传输,并且以一个短包(长度小于端点最大包长)结束,以此来明确标识CBW的结束。所有字段均采用小端字节序。
下面我们逐字段拆解,并说明在固件中如何解析和处理:
dCBWSignature (4字节)这是CBW的“魔术字”,固定值必须为0x43425355。在ASCII码中,它对应的是“USBC”(注意是小端,所以内存中低位是55('U'),高位是43('C'))。设备固件在接收数据时,首先要检查这4个字节是否正确。如果不匹配,说明数据流已经错乱,可能收到了损坏的数据或非CBW数据,设备应该丢弃该数据并尝试恢复同步。
dCBWTag (4字节)这是一个由主机生成的标签,可以看作是这个CBW的“流水号”。它的值由主机随意指定,没有特殊含义。但设备有一个至关重要的职责:在后续回复的CSW中,必须将dCSWTag字段设置为与这个dCBWTag完全相同的值。这是主机用来关联命令与状态响应的唯一依据。在实现上,固件在解析CBW时,需要将这个标签值暂存起来,以备后续构造CSW时使用。
dCBWDataTransferLength (4字节)这个字段指明了主机期望在数据阶段传输的数据总量(单位:字节)。例如,主机发送一个“读取512字节”的命令,这个值就是512。这里有几个关键点:
- 值为0:表示该命令没有数据阶段(例如,一些查询类命令)。此时,
bmCBWFlags中的方向位将被设备忽略。 - 设备职责:设备需要根据实际能处理的数据量,在CSW的
dCSWDataResidue字段中报告差额。比如主机要求读512字节,但设备因为错误只准备了500字节,那么就需要报告Residue为12。
bmCBWFlags (1字节)这是一个位域字段,包含了命令的元信息:
- Bit 7 - Direction (方向):这是最重要的位。
0: 数据输出,即数据从主机到设备(写操作)。1: 数据输入,即数据从设备到主机(读操作)。- 当
dCBWDataTransferLength为0时,此位无效。
- Bit 6 - Obsolete (已废弃):协议规定主机应将其置0,设备应忽略此位。
- Bits 5..0 - Reserved (保留):主机应置0,设备应忽略。
在固件中,你需要用掩码操作来提取方向位,以决定后续是准备从Bulk-In端点发送数据,还是从Bulk-Out端点接收数据。
bCBWLUN (1字节)逻辑单元号。对于简单的单LUN设备(比如一个普通的U盘),这个值通常为0。对于复合设备(比如一个读卡器支持SD卡和TF卡两个槽),主机通过不同的LUN号来区分操作哪个存储介质。你的设备固件需要根据这个值将命令路由到对应的存储后端处理。
bCBWCBLength (1字节)指定了紧随其后的CBWCB字段的实际有效长度。它的值必须在1到16之间(0x01到0x10)。这告诉你需要从CBWCB数组中解析多少字节作为有效的SCSI命令。
CBWCB (16字节)这是真正的命令内容,通常是一个SCSI命令描述块。它的具体结构由bCBWCBLength定义。例如,一个读取10个扇区的SCSI Read(10)命令,会占据这里的部分字节。固件需要根据bCBWCBLength解析这16字节数组,提取出操作码、逻辑块地址、传输长度等参数。
2.3 核心数据结构:CSW详解
命令状态包装是设备发给主机的“回执单”,长度为13字节。它标志着一条命令处理的最终完结。
dCSWSignature (4字节)CSW的“魔术字”,固定值必须为0x53425355,对应ASCII“USBS”。主机依靠这个签名来确认收到的是一个有效的CSW,而不是残留的数据。
dCSWTag (4字节)必须原样复制自对应CBW的dCBWTag。这是实现命令-状态配对的核心机制。在固件中,你需要将之前暂存的标签值填回这里。
dCSWDataResidue (4字节)残留数据长度。这是最容易出错和理解偏差的字段。它表示主机期望传输的数据量(dCBWDataTransferLength)与实际成功传输的数据量之间的差值。
- 对于Data-Out(写操作):
Residue = dCBWDataTransferLength - 设备实际成功接收并处理的字节数。 - 对于Data-In(读操作):
Residue = dCBWDataTransferLength - 设备实际成功发送的字节数。 - 重要规则:
Residue的值必须小于等于dCBWDataTransferLength。如果设备处理的数据量比主机期望的少,这里就是一个正数;如果意外处理多了(理论上不应该发生),协议规定也按期望值算,Residue报告0。
例如,主机要求写入1024字节,但设备在收到512字节后存储介质发生错误,无法继续接收,那么设备应该报告Residue为512。主机收到后,就知道只有前一半数据写入了。
bCSWStatus (1字节)命令执行的最终状态。
0x00:命令通过。表示命令被设备成功执行。0x01:命令失败。表示设备执行命令时出错(例如,遇到了介质错误、非法地址等)。此时主机通常会尝试重试或向上层报告错误。0x02:阶段错误。这是一个严重的通信协议错误,表示设备在处理CBW/Data/CSW的某个阶段发现了顺序或内容上的问题(例如,收到的CBW签名错误、数据阶段长度不对等)。遇到此状态,主机和设备都需要进行错误恢复。
3. 固件实现要点与状态机设计
理解了数据结构,下一步就是在嵌入式固件中实现它。一个健壮的实现离不开一个清晰的状态机。
3.1 核心处理状态机
一个典型的USB MSC设备端点处理状态机应包含以下几个状态:
IDLE状态:等待主机发送CBW。当Bulk-Out端点收到恰好31字节的数据(且签名正确)时,跳转到
CBW_RECEIVED状态。CBW_RECEIVED状态:
- 解析CBW各字段。
- 检查
bCBWLUN是否在设备支持范围内。 - 检查
bCBWCBLength是否合法(1-16)。 - 根据
bmCBWFlags的方向位和dCBWDataTransferLength,决定下一个状态。- 如果数据长度>0且方向为IN,进入
DATA_IN状态,准备数据。 - 如果数据长度>0且方向为OUT,进入
DATA_OUT状态,准备接收数据。 - 如果数据长度=0,直接跳转到
SEND_CSW状态。
- 如果数据长度>0且方向为IN,进入
DATA_IN状态:
- 从存储介质(如Flash、SD卡)读取数据到发送缓冲区。
- 通过Bulk-In端点将数据分批次发送给主机。
- 实时更新已发送字节数。
- 当发送的字节数等于
dCBWDataTransferLength,或发生错误无法继续时,跳转到SEND_CSW状态。同时计算dCSWDataResidue(期望值 - 已发送值)。
DATA_OUT状态:
- 通过Bulk-Out端点接收主机发来的数据。
- 将数据写入存储介质。
- 实时更新已接收并成功处理的字节数。
- 当接收的字节数等于
dCBWDataTransferLength,或发生错误(如写保护、存储空间满)时,跳转到SEND_CSW状态。同时计算dCSWDataResidue(期望值 - 已处理值)。
SEND_CSW状态:
- 根据命令执行结果(成功、失败、阶段错误)设置
bCSWStatus。 - 填入计算好的
dCSWDataResidue。 - 复制
dCBWTag到dCSWTag。 - 设置签名
0x53425355。 - 将组装好的13字节CSW通过Bulk-In端点发送给主机。
- 发送完成后,状态机回到
IDLE状态,等待下一个CBW。
- 根据命令执行结果(成功、失败、阶段错误)设置
3.2 关键代码片段示例(以C语言伪代码为例)
以下是解析CBW和构造CSW的关键代码逻辑:
// 假设 usb_rx_buffer 存放了从Bulk-Out端点收到的31字节数据 void process_cbw(uint8_t* usb_rx_buffer) { // 强制转换为CBW结构体指针(注意字节对齐和填充问题,实际工程需处理) CBW_t* pCBW = (CBW_t*)usb_rx_buffer; // 1. 检查签名 if (pCBW->dCBWSignature != CBW_SIGNATURE) { // 签名错误,进入错误处理,可能需要发送CSW with Phase Error handle_phase_error(); return; } // 2. 保存Tag,用于后续CSW current_tag = pCBW->dCBWTag; // 3. 检查LUN是否支持 if (pCBW->bCBWLUN >= SUPPORTED_LUN_COUNT) { // LUN不支持,命令失败 prepare_csw(current_tag, pCBW->dCBWDataTransferLength, CSW_STATUS_FAILED); send_csw(); return; } // 4. 检查命令长度 if ((pCBW->bCBWCBLength == 0) || (pCBW->bCBWCBLength > 16)) { // 非法命令长度,阶段错误 prepare_csw(current_tag, pCBW->dCBWDataTransferLength, CSW_STATUS_PHASE_ERROR); send_csw(); return; } // 5. 解析SCSI命令 (假设第一个字节是操作码) scsi_cmd_opcode = pCBW->CBWCB[0]; expected_data_length = pCBW->dCBWDataTransferLength; data_direction = (pCBW->bmCBWFlags & 0x80) ? DIR_IN : DIR_OUT; // 6. 根据命令、数据长度和方向,设置状态机,准备数据阶段或直接发送CSW if (expected_data_length > 0) { if (data_direction == DIR_IN) { state_machine = STATE_DATA_IN; // 调用函数准备要发送的数据 prepare_data_in(scsi_cmd_opcode, &pCBW->CBWCB[0], pCBW->bCBWCBLength); } else { state_machine = STATE_DATA_OUT; // 准备接收数据的缓冲区 prepare_data_out_buffer(expected_data_length); } } else { // 无数据阶段,直接执行命令并发送CSW scsi_status = execute_scsi_command(pCBW->CBWCB, pCBW->bCBWCBLength, 0, NULL); prepare_csw(current_tag, 0, scsi_status); send_csw(); } } // 准备CSW的函数 void prepare_csw(uint32_t tag, uint32_t expected_length, uint8_t status) { CSW_t csw; csw.dCSWSignature = CSW_SIGNATURE; csw.dCSWTag = tag; // 计算Residue:期望长度 - 实际成功传输的长度 // actual_transferred 需要在数据阶段实时更新 uint32_t residue = expected_length - actual_transferred; // 确保Residue不大于期望长度(虽然理论上actual_transferred不应大于expected_length) if (actual_transferred > expected_length) { residue = 0; } csw.dCSWDataResidue = residue; csw.bCSWStatus = status; // 将csw结构体拷贝到USB发送缓冲区 memcpy(usb_tx_buffer, &csw, sizeof(CSW_t)); }4. 调试实战与常见问题排查
理论结合实践,下面分享几个在调试USB MSC设备时最常见的问题和排查技巧。
4.1 问题现象:电脑提示“无法识别的USB设备”或“设备描述符请求失败”
可能原因1:USB基础层未就绪
- 排查:首先确保你的USB控制器(如STM32的USB IP核)初始化正确,时钟配置无误,DP/DM线连接正确且上拉电阻已使能。使用USB协议分析仪(如Saleae、Beagle等)抓取总线数据,看设备是否有对主机复位和获取描述符的请求做出响应。
- 技巧:在MCU的USB中断服务例程或SOF中断里设置一个翻转的GPIO引脚,用示波器观察,可以快速判断USB核心是否在正常运行。
可能原因2:描述符配置错误
- 排查:仔细检查设备描述符、配置描述符、接口描述符和端点描述符。对于MSC设备,接口类代码应为
0x08(Mass Storage),子类代码常见为0x06(SCSI透明命令集),协议代码为0x50(Bulk-Only Transport)。端点描述符必须正确声明Bulk-In和Bulk-Out端点。 - 技巧:在代码中,将你的描述符数组内容通过调试串口打印出来,与USB规范文档逐字节比对。很多IDE的USB配置工具生成的代码也可能有细微错误,需要手动核对。
- 排查:仔细检查设备描述符、配置描述符、接口描述符和端点描述符。对于MSC设备,接口类代码应为
4.2 问题现象:设备能被识别为“大容量存储设备”,但弹出“需要格式化”或“磁盘无媒体”
可能原因1:CBW/CSW通信失败
- 排查:这是最可能的原因。主机在枚举后,会发送一系列SCSI命令来查询设备容量、读取扇区等。如果设备对CBW的响应(CSW)不正确,主机就会认为设备无法访问。
- 技巧:
- 打印日志:在固件中,将收到的CBW的签名、标签、数据长度、命令首字节等信息通过串口打印出来。同时,也将准备发送的CSW内容打印出来。对比是否符合规范。
- 检查签名:确保CBW签名是
0x43425355,CSW签名是0x53425355。我遇到过因为内存对齐或字节序问题导致签名错位的坑。 - 检查Tag匹配:确认CSW中的
dCSWTag与触发它的CBW的dCBWTag完全一致。 - 检查状态:在初始查询阶段,
bCSWStatus必须返回0x00(成功)。任何失败或阶段错误都会导致主机放弃初始化。
可能原因2:SCSI命令响应错误
- 排查:主机发送的SCSI命令,如
TEST UNIT READY,INQUIRY,READ CAPACITY(10),READ(10)等,你的设备必须正确解析并返回合规的数据。 - 技巧:实现一个简单的SCSI命令解析器。对于不支持的命令,可以返回CSW状态为失败(
0x01),但对于上述几个基本查询命令,必须正确实现。可以参考usb_storage或tinyusb等开源库的SCSI命令处理部分。
- 排查:主机发送的SCSI命令,如
4.3 问题现象:数据传输不稳定,复制大文件时容易出错或断开
可能原因1:数据残留处理不当
- 排查:重点检查
dCSWDataResidue的计算和上报。如果设备实际处理的数据量少于主机请求量,但Residue上报为0,主机会认为所有数据都成功传输了,但实际上后续的数据是错的。这会导致文件系统错误。 - 技巧:在数据阶段,精确计数成功发送或接收的字节数。在存储介质操作(如SD卡读写)失败时,立即终止数据阶段,并准确计算Residue。在CSW中报告失败状态和正确的残留值。
- 排查:重点检查
可能原因2:端点缓冲区管理与状态机冲突
- 排查:USB传输是异步的。当设备正在发送数据(DATA_IN阶段)时,主机可能已经发来了下一个CBW(如果上一个CSW已发出)。如果固件状态机没有设计好,可能会覆盖缓冲区或导致状态混乱。
- 技巧:使用双缓冲区或乒乓缓冲区。确保“CBW解析”、“数据搬运”、“CSW发送”这三个关键任务在逻辑和缓冲区上是分离的。状态机的状态转换必须原子且清晰。
可能原因3:存储介质访问速度慢
- 排查:如果SD卡或Flash的读写速度跟不上USB 2.0全速(12 Mbps)甚至高速(480 Mbps)的带宽,会导致设备端无法及时提供或消耗数据,造成USB NAK(未就绪)过多,最终主机可能超时。
- 技巧:
- 优化存储介质驱动,使用DMA、提高时钟频率。
- 增加数据缓存区大小,允许预读或缓写。
- 对于低速介质,可以考虑在设备描述符中报告一个较短的
bInterval(对于中断端点)或适当降低Bulk端点的最大包大小,虽然这会影响峰值速度,但能提高稳定性。
4.4 实用调试工具与方法速查表
| 工具/方法 | 用途 | 说明 |
|---|---|---|
| 逻辑分析仪 + USB协议分析软件 | 抓取USB总线原始数据 | 终极调试利器。可以直观看到每一个封包、每一个CBW/CSW、每一次NAK/STALL。能快速定位是物理层、协议层还是应用层的问题。推荐Saleae配合专用USB分析插件。 |
| 设备端串口打印 | 输出固件内部状态、CBW/CSW内容 | 成本最低、最直接的调试方式。将关键变量、状态机切换、接收到的命令打印出来。注意打印本身会占用时间,可能影响USB实时性,建议仅在调试时开启。 |
| PC端软件:USBlyzer, Wireshark | 在主机端捕获和分析USB数据包 | 无需硬件工具。可以查看主机发送了哪些命令,设备返回了哪些数据。对于调试SCSI命令响应非常有用。 |
| Bus Hound | 捕获系统级USB通信数据 | Windows下老牌工具,能捕获到SCSI命令层的数据,方便查看文件系统级别的读写请求。 |
| 手动构造测试命令 | 验证设备对特定命令的响应 | 编写一个简单的PC端程序,通过WinUSB或libusb库直接向设备发送自定义的CBW,并读取CSW。可以绕过文件系统,直接测试设备底层协议栈的正确性。 |
调试USB MSC设备是一个需要耐心和细致的过程。从确保USB物理连接和描述符正确开始,再到验证BOT协议层(CBW/CSW)的精准交互,最后完善SCSI命令层的逻辑。建议分阶段测试:先让设备能被识别,再处理简单的查询命令,最后测试大数据量的读写。每完成一个阶段,就离一个稳定的USB大容量存储设备更近一步。
