把U盘插进电脑,系统几乎在同一时间弹出盘符。多数人觉得这事稀松平常,但USB子系统内部刚刚完成了一次相当讲究的入职流程:设备要自报家门,操作系统要核验身份,分配唯一地址,激活配置,然后才开始真正干活的大容量存储传输。这整套流程叫枚举,而枚举过程中设备交给主机的每一份"简历",就是一组叫USB描述符的数据结构。
这篇笔记我拿U盘当例子,把设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符逐个拆开,讲清楚它们为什么这样写、主机怎么读、实际写U盘固件时最容易被卡住的几个细节。内容按"枚举流程 → 描述符字段 → 抓包实测 → BOT传输 → 可直接抄的模板 → 故障排查"的顺序展开,适合刚接触USB协议栈、想搞懂Mass Storage设备,或者正在调自己板子上U盘功能的开发者参考。
1. 枚举对话:从D+被拉高到SET_CONFIGURATION,主机靠什么认出U盘
1.1 总线复位与高速握手不只是"复位"
设备插入后,USB主机控制器首先看到的是总线状态变化。全速和高速设备通过D+线上的1.5kΩ上拉电阻告诉主机"我来了",低速设备则是在D-线上做同样的事。主机看到上拉后,会先对端口做一次总线复位,也就是把D+和D-同时拉低至少10ms,让设备回到默认状态。高速设备不会老实待在复位状态里,它会检测到主机发出的Chirp K信号,然后以Chirp K/J序列回应,主机确认后双方才进入高速模式。
很多第一次接触USB的人会忽略这一步,把复位单纯理解成"清零重来"。实际上一旦设备声明自己支持高速,后续所有描述符解析的语义都会变化,最典型的就是端点最大包长从全速的64字节变成高速的512字节。如果高速握手失败,设备并不会直接变成废品,而是退化为全速运行,现象就是U盘能用但速度上不去。调试的时候如果发现设备枚举成功、读写速度却始终只有每秒几MB,先不要怀疑闪存,多半是高速握手没成功。
1.2 为什么第一次GET_DESCRIPTOR往往只读8字节
复位之后,设备处于Default状态,地址是0,主机发出第一个标准请求:GET_DESCRIPTOR,请求设备描述符。这个阶段有个很微妙的细节——主机通常只请求前8字节,或者说即使请求了64字节,设备可以只返回18字节,但主机最少需要拿到前8字节。
原因在于设备描述符的前8字节里包含了三个关键信息:描述符长度、描述符类型、USB规范版本、设备类,以及最重要的bMaxPacketSize0。bMaxPacketSize0告诉主机端点0的最大包长是多少,主机只有知道这个值,才能确定后续SET_ADDRESS请求的状态阶段该怎么拆分数据包。换句话说,这是先探路,再正式通信的策略。
实际抓包里常见的组合是:第一次GET_DESCRIPTOR(Device, wLength=64),设备返回完整的18字节;也有的固件实现只响应8字节,主机拿到bMaxPacketSize0后并不重新请求,直接进入SET_ADDRESS。两种情况都合法,但要明白背后的逻辑:主机需要的是足够完成下一步动作的信息,而不是全部信息。
1.3 SET_ADDRESS的时机差一个字都不行
主机从设备描述符里拿到bMaxPacketSize0后,会发送SET_ADDRESS请求,给设备分配一个非0地址。这一步最容易踩坑的地方是实现细节:USB规范规定,设备必须在SET_ADDRESS请求的状态阶段完成之后,才把内部地址寄存器切换到新地址。
我见过不止一个新手写的固件在控制处理函数里收到SET_ADDRESS后立即切换地址,结果设备状态阶段出错,主机完全找不到设备。如果用的是现成USB控制器,通常寄存器层面已经帮你处理了这个时序;如果是自己用FPGA或者简单MCU硬搭协议栈,这一步必须严格按规范来。
SET_ADDRESS完成后,设备进入Address状态。主机紧接着用新地址重新读取一次完整的设备描述符,然后开始请求配置描述符。此时设备已经具备独立身份,不再是总线上那个"匿名设备"了。
2. 五类核心描述符逐字节拆解:U盘在协议层的"身份证"
2.1 设备描述符:整体身份与EP0能力
U盘的设备描述符固定18字节,第一个字节是bLength=0x12,第二个字节是bDescriptorType=0x01。USB协议里每种描述符都有固定类型编号,主机靠这个字段来决定怎么解析后续数据。
我对典型USB 2.0全速U盘设备描述符做了个字段展开,这样看最直观:
| 字段 | 长度 | 典型值 | 含义 |
|---|---|---|---|
| bLength | 1 | 0x12 | 设备描述符总长18字节 |
| bDescriptorType | 1 | 0x01 | 设备描述符类型 |
| bcdUSB | 2 | 0x0200 | 实现USB 2.0规范 |
| bDeviceClass | 1 | 0x00 | 类定义在接口描述符层 |
| bDeviceSubClass | 1 | 0x00 | 保留 |
| bDeviceProtocol | 1 | 0x00 | 保留 |
| bMaxPacketSize0 | 1 | 0x40 | EP0最大包长64字节 |
| idVendor | 2 | 0x1234(举例) | 厂商识别号 |
| idProduct | 2 | 0x5678(举例) | 产品识别号 |
| bcdDevice | 2 | 0x0100 | 设备版本号 |
| iManufacturer | 1 | 0x01 | 厂商字符串索引 |
| iProduct | 1 | 0x02 | 产品字符串索引 |
| iSerialNumber | 1 | 0x03 | 序列号字符串索引 |
| bNumConfigurations | 1 | 0x01 | 配置数量 |
U盘的设备描述符里bDeviceClass通常是0x00,这意味着设备级不声明大类,真正的身份要等到接口描述符再宣布。这是USB协议里很关键的分层思想:设备描述符只描述"我是谁",接口描述符描述"我会干什么"。
2.2 配置描述符:wTotalLength的连锁效应
配置描述符9字节,但它引领的是一整套配置集合。主机第一次请求配置描述符时通常只请求9字节,目的是读取wTotalLength字段。wTotalLength表示整个配置集合的总长度,包括配置描述符本身、所有接口描述符、端点描述符,以及可能存在的类特定描述符。
U盘的配置集合很标准:配置描述符9字节 + 接口描述符9字节 + 两个端点描述符各7字节,总共32字节,即wTotalLength=0x0020。注意USB协议的多字节字段一律采用小端序,所以wTotalLength在字节流里写作20 00。
bmAttributes字段也值得留心。U盘通常写0x80,表示总线供电且不支持远程唤醒。bit7必须恒为1,bit6置1表示设备从总线取电,bit5置1表示支持远程唤醒。如果你把bit5置1却又没有实现远程唤醒逻辑,主机尝试唤醒时会发现总线没反应,轻则驱动日志报错,重则设备被标记为异常。
bMaxPower字段的单位是2mA,0x32等于50×2mA=100mA。总线供电U盘如果实际峰值电流超过声明值,某些严格的hub会直接拒绝供电。自供电设备可以写很小甚至写0x01,反正不需要从总线拿多少电。
2.3 接口描述符:0x08 / 0x06 / 0x50才是U盘身份
接口描述符是U盘身份的真正来源。bInterfaceClass=0x08表示Mass Storage类,bInterfaceSubClass=0x06表示使用SCSI透明命令集,bInterfaceProtocol=0x50表示使用Bulk-Only Transport协议,也就是常说的BOT。三个字段组合起来,主机才确定:这是一个使用SCSI命令、通过Bulk端点传输数据的U盘。
bNumEndpoints=2表示这个接口下有两个端点,通常是一个Bulk OUT和一个Bulk IN。bInterfaceNumber=0表示接口编号,bAlternateSetting=0表示当前备用设置。U盘一般只有一个接口一个备用设置,不需要玩接口聚合那套复杂操作。
补充一点:如果bInterfaceProtocol=0x62,那就不属于BOT而是UASP协议。UASP在USB 3.0时代很常见,它能支持命令队列,突破BOT一次只能执行一条命令的瓶颈。但USB 2.0下的绝大多数U盘走BOT,所以本篇只深入讲0x50。
2.4 端点描述符:两个Bulk通道的约定
U盘的端点描述符有两个,结构完全一样,只是方向不同。OUT端点地址通常为0x01,IN端点地址通常为0x82。端点地址的bit7表示方向,低4位是端点号,所以0x01是端点1的OUT方向,0x82是端点2的IN方向。这里经常有人读迷糊:0x82看起来像个很大的数,其实是IN方向加端点号2的组合。
bmAttributes=0x02表示Bulk传输类型。wMaxPacketSize在全速U盘下是0x0040(64字节),在高速U盘下是0x0200(512字节)。bInterval字段对Bulk端点必须为0,如果从别的例程里抄来一个非0值,某些主机控制器会给出警告甚至拒绝加载驱动。
2.5 字符串描述符:厂商、产品与序列号
字符串描述符不是枚举必需的,但如果设备描述符里的iManufacturer、iProduct、iSerialNumber指向非0索引,主机就会来请求对应字符串。字符串描述符的结构很直接:第一个字节是总长度,第二个字节是类型0x03,后面是UTF-16LE编码的字符数据。
语言标识符描述符也是字符串描述符家族的一员,固定4字节:04 03 09 04,表示默认语言是0x0409美国英语。字符串长度计算有一个最常见的错误来源,我后面专门说。
对于U盘来说,序列号字符串的意义远超显示层面。Windows会拿厂商ID、产品ID和序列号三者组合来区分同一台机器上插过的不同U盘,如果两块U盘序列号相同,系统可能把它们当成同一设备,盘符缓存、驱动绑定全都会乱。所以量产的U盘固件通常会提供一个动态更新序列号的接口。
3. 一次真实枚举的抓包回放:每个请求都在问什么
3.1 抓包的完整请求序列
下面这段是某块手写U盘固件在总线分析仪上抓到的完整枚举序列,设备描述符数据里的VID/PID是保留给示例用的假值,不要对号入座:
[0] RESET [1] 主→设: SETUP 80 06 00 01 00 00 40 00 // GET_DESCRIPTOR, 设备描述符, index=0, len=64 设→主: DATA 12 01 00 02 00 00 00 40 34 12 78 56 00 01 01 02 03 01 [2] 主→设: SETUP 00 05 01 00 00 00 00 00 // SET_ADDRESS, 地址=1 [3] 主→设: SETUP 80 06 00 01 00 00 12 00 // GET_DESCRIPTOR, 设备描述符, len=18 设→主: DATA 12 01 00 02 00 00 00 40 34 12 78 56 00 01 01 02 03 01 [4] 主→设: SETUP 80 06 00 02 00 00 09 00 // GET_DESCRIPTOR, 配置描述符, len=9 设→主: DATA 09 02 20 00 01 01 00 80 32 [5] 主→设: SETUP 80 06 00 02 00 00 20 00 // 再次请求配置集合, len=32 设→主: DATA 09 02 20 00 01 01 00 80 32 // 配置描述符 09 04 00 00 02 08 06 50 00 // 接口描述符 07 05 01 02 40 00 00 // 端点 OUT 07 05 82 02 40 00 00 // 端点 IN [6] 主→设: SETUP 80 06 00 03 00 00 04 00 // GET_DESCRIPTOR, 字符串: 语言ID 设→主: DATA 04 03 09 04 [7] 主→设: SETUP 80 06 01 03 00 00 40 00 // GET_DESCRIPTOR, 字符串: iProduct 设→主: DATA 18 03 4D 00 59 00 20 00 55 00 53 00 42 00 20 00 44 00 49 00 53 00 4B 00 [8] 主→设: SETUP 00 09 01 00 00 00 00 00 // SET_CONFIGURATION, 配置值=1 [9] 主→设: SETUP 21 FF 00 00 00 00 00 00 // Mass Storage Reset, 类请求 [10] 主→设: SETUP A1 FE 00 00 00 00 01 00 // Get Max LUN 设→主: DATA 00 // LUN 数量 = 13.2 描述符字节流里容易读反的字段
看抓包数据时最容易犯的错是把多字节字段按大端读。USB协议统一使用小端序,设备描述符里的idVendor=0x1234在字节流里写作34 12,idProduct=0x5678写作78 56,配置描述符的wTotalLength=0x0020写作20 00。刚开始分析抓包时,我经常在纸上把字段反着写,导致一个简单的设备描述符半天对不上号。
还有一点:第一次GET_DESCRIPTOR(Device)请求的wLength虽然是64,但设备只需返回18字节的实际描述符,不需要补齐64字节。如果固件死板地按请求长度填充响应缓冲区,反而可能把缓冲区里的残留垃圾一起发出去,主机解析到多余的字节后不一定报错,但总线分析仪里看起来会很奇怪。
3.3 SET_CONFIGURATION后还有两个类请求
很多第一次做Mass Storage设备的同学以为枚举在一句SET_CONFIGURATION之后就结束了,实际上主机接下来还会发两个BOT类特定的控制请求。第一个是Mass Storage Reset,bmRequestType=0x21,bRequest=0xFF,用来把设备的BOT状态机复位到空闲状态。第二个是Get Max LUN,bmRequestType=0xA1,bRequest=0xFE,wLength=1,设备需要返回一个字节表示逻辑单元数量,通常是0x00。
这两个请求在Linux的usb-storage驱动和Windows的USBSTOR驱动里都会出现。固件里如果只实现了标准请求而没有处理这两个类请求,常见的现象是枚举看似成功,设备管理器也认出了"大容量存储设备",但随后一直卡住无法读出盘符。处理起来很简单:收到Mass Storage Reset就重置BOT内部状态机并返回一个零长度状态包;收到Get Max LUN就返回单字节0x00。
4. 类接口背后的BOT传输:CBW与CSW怎样完成一次扇区读写
4.1 CBW:主机下达的命令"邮票"
描述符只负责"自我介绍",真正读写U盘数据靠的是BOT协议的三步走:命令块CBW、数据阶段、状态块CSW。CBW固定31字节,由主机通过Bulk OUT端点发给设备。
CBW里的关键字段包括:dCBWSignature固定是"USBC"四个ASCII字符,即0x43425355;dCBWTag由主机随机生成,设备在后续CSW里必须原样回显;dwCBWTransferLength表示数据阶段的字节数;bmCBWFlags为0x80表示数据阶段是设备到主机(IN),为0x00表示主机到设备(OUT);bCBWCBLength表示后面CBWCB命令块的长度,范围1到16字节。
4.2 CSW:设备交回的"收据"
设备处理完命令后,通过Bulk IN端点返回13字节CSW。dCSWSignature固定是"USBS"四字节,即0x53425355;dCSWTag回显CBW里的tag;dCSWDataResidue表示还剩多少数据没传完,0表示数据传输量和预期完全一致;bCSWStatus为0x00表示成功,0x01表示命令执行失败,0x02表示阶段错误。
阶段错误是BOT协议里特别要小心的状态。USB协议规定设备如果发现主机在数据阶段收发方向与CBW声明不符,或者主机在非预期时机发来CBW,设备应进入错误恢复状态,并在CSW里报告Phase Error,之后主机应该发Mass Storage Reset来恢复。
4.3 READ(10)的一次完整交互
拿最常见的SCSI READ(10)命令举例,命令码0x28。假设主机要读LBA 0的一个512字节扇区,完整交互如下:
主机先发31字节CBW:
55 53 42 43 // "USBC" 04 03 02 01 // dCBWTag = 0x01020304 00 02 00 00 // dwCBWTransferLength = 512, 小端 80 // 数据阶段: 设备→主机 00 // LUN = 0 0A // CBWCB长度 = 10 28 00 00 00 00 00 00 00 01 00 // READ(10), LBA=0, 传输块数=1 00 00 00 00 00 00 // 填充, 使总长度到31设备收到后从闪存里读出512字节数据,通过Bulk IN端点分8包发给主机,每包64字节。因为512正好是64的整数倍,设备必须在数据包全部发完后补一个零长度包,主机才知道数据阶段结束了。这一点极容易漏:如果不发零长度包,主机会一直等待数据,最终超时。
之后设备发13字节CSW:
55 53 42 53 // "USBS" 04 03 02 01 // dCSWTag, 回显CBW中的tag 00 00 00 00 // residue = 0 00 // status = 0, 成功主机收到合法CSW后,这次读操作才算正式完成。
4.4 为什么U盘必须用Bulk端点而不是中断或同步
这个问题是很多初学者的疑问。U盘的传输特征是突发大量块数据,且数据绝对不容丢失。Bulk端点具备CRC校验和错误重传机制,传输效率和可靠性平衡得最好。
中断端点适合鼠标键盘这类低频、低延迟的小数据;同步端点适合音频视频这类对时延敏感但允许偶发丢包的数据流。这些都不符合U盘的需求场景。BOT协议选Bulk相当于是把"可靠吞吐"放到了第一位。
5. 可直接抄的U盘描述符模板与配置要点
5.1 C语言描述符数组模板
我把自己经常用的一组U盘描述符C数组贴出来,这套结构在我的全速U盘固件里验证过很多次。数组顺序和USB规范要求的返回顺序完全一致,配置描述符集合连在一起返回。
/* 设备描述符, 18字节 */ static const uint8_t usbd_dev_desc[] = { 0x12, /* bLength */ 0x01, /* bDescriptorType */ 0x00, 0x02, /* bcdUSB = 0x0200 */ 0x00, /* bDeviceClass */ 0x00, /* bDeviceSubClass */ 0x00, /* bDeviceProtocol */ 0x40, /* bMaxPacketSize0 = 64 */ 0x34, 0x12, /* idVendor = 0x1234 */ 0x56, 0x78, /* idProduct = 0x5678 */ 0x00, 0x01, /* bcdDevice = 0x0100 */ 0x01, /* iManufacturer */ 0x02, /* iProduct */ 0x03, /* iSerialNumber */ 0x01 /* bNumConfigurations */ }; /* 配置描述符集合, 32字节 */ static const uint8_t usbd_cfg_desc[] = { /* 配置描述符, 9字节 */ 0x09, 0x02, 0x20, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32, /* 接口描述符, 9字节 */ 0x09, 0x04, 0x00, 0x00, 0x02, 0x08, 0x06, 0x50, 0x00, /* 端点 OUT 1, Bulk, 64字节 */ 0x07, 0x05, 0x01, 0x02, 0x40, 0x00, 0x00, /* 端点 IN 2, Bulk, 64字节 */ 0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00 }; /* 语言ID描述符 */ static const uint8_t usbd_str_langid[] = { 0x04, 0x03, 0x09, 0x04 }; /* 厂商字符串, "MYCORP", 7字符 */ static const uint8_t usbd_str_vendor[] = { 0x0E, 0x03, 'M',0x00, 'Y',0x00, 'C',0x00, 'O',0x00, 'R',0x00, 'P',0x00 }; /* 产品字符串, "MY USB DISK", 11字符 */ static const uint8_t usbd_str_product[] = { 0x18, 0x03, 'M',0x00, 'Y',0x00, ' ',0x00, 'U',0x00, 'S',0x00, 'B',0x00, ' ',0x00, 'D',0x00, 'I',0x00, 'S',0x00, 'K',0x00 }; /* 序列号字符串, "202600001234", 12字符 */ static const uint8_t usbd_str_serial[] = { 0x1A, 0x03, '2',0x00,'0',0x00,'2',0x00,'6',0x00,'0',0x00,'0',0x00, '0',0x00,'0',0x00,'1',0x00,'2',0x00,'3',0x00,'4',0x00 };字符串描述符的bLength计算要特别细心。bLength=2+2×N,其中N是字符个数。厂商字符串"MYCORP"是6个字符,bLength=2+2×6=14=0x0E;产品字符串"MY USB DISK"是11个字符,bLength=24=0x18;序列号12位数字,bLength=26=0x1A。写错一个十六进制值,主机解析字符串时长度错乱,轻则显示乱码,重则跳过后续字符串索引。
5.2 高速与全速的参数差异
如果目标是做一个USB 2.0高速U盘,描述符里需要改动的地方不少:设备描述符的bMaxPacketSize0必须为0x40(64字节),bcdUSB可以保持0x0200;两个Bulk端点的wMaxPacketSize要改成0x0200(512字节);控制器必须正确响应高速Chirp握手。
这里要提醒一个关键点:高速设备的EP0最大包长在枚举早期就是64字节,而全速设备在总线复位后的默认EP0包长可以是8字节,随后在设备描述符里报告真正的包长。如果全速设备报告64字节,它的控制器FIFO实际上必须能吞吐64字节的控制数据包,否则枚举阶段设备描述符都传不完。
高速U盘的Bulk端点声明512字节后,固件里的FIFO和DMA配置必须同步跟上。我见过一个项目把端点描述符改成512,但USB控制器的端点FIFO只配了128字节,结果所有超过128字节的读请求全卡死。硬件资源不支持之前,先别把描述符值写得比能力大。
5.3 端点编号与控制器映射约束
端点号不是随便写的。USB规范只要求端点号从1到15(实际端点地址里还有方向位),但具体MCU的USB设备控制器往往有限制。有的芯片要求Bulk端点必须按偶数OUT、奇数IN配对使用,如果你声明的OUT端点号是1、IN端点号是2,固件初始化时就会报端点配置冲突。
写描述符之前先翻芯片手册里的端点分配表,搞清楚哪些端点号可用、能否任意组合方向。很多人第一版固件死在"描述符逻辑完全正确但硬件配不出来"上,这运气成分其实完全可以避免。
另外一个常见坑:字符串描述符的iSerialNumber指向0x03,意味着主机一定会来请求字符串索引3。如果你固件里只实现了索引1和2,却把设备描述符里的索引3写上,在Windows上枚举大概率会卡住,表现为设备管理器报"无法识别的USB设备"或直接连续复位。字符串索引和实际实现的对应表必须一致。
6. 六个常见枚举与传输故障的排查链路
6.1 设备描述符请求阶段的STALL / 超时
现象最好辨认:设备插上后,Windows提示"无法识别的USB设备",Linux的dmesg里出现"device descriptor read/64, error -71"之类的错误。
排查顺序从物理层往上走:先看D+/D-上是否有1.5kΩ上拉,没有上拉主机根本检测不到连接;再看设备是否能正确收到SETUP包,在固件里给端点0的SETUP中断打一个调试断点,看8字节的请求数据是否完整;最后看设备在收到GET_DESCRIPTOR之后,有没有往端点0 IN方向写入18字节描述符。这个阶段如果设备用MCU模拟EP0,还要检查EP0的最大包长是否与bMaxPacketSize0一致,不一致会导致主机按64字节收,设备却按8字节发,数据阶段永远无法正确结束。
6.2 SET_ADDRESS之后设备失联
设备在SET_ADDRESS之前一切正常,主机发了SET_ADDRESS之后总线上一片死寂。最可能的根因就是我前面说过的时序问题:固件在状态阶段完成前就切换了新地址。USB规范说得很清楚,设备必须在SET_ADDRESS请求的状态阶段完成后才启用新地址。如果你的协议栈是参考Linux gadget框架那种成熟实现,这部分不用操心;如果是自己写的裸机状态机,请务必检查控制传输状态机的状态切换逻辑。
还有一种可能:设备把新地址写错了位置。地址是主机在SETUP包wValue的低7位里给的,如果代码里把wValue整个字节当成地址,一旦主机分配0x80以上的临时地址,设备就会跑到另一个不存在的地址上,主机自然找不到。
6.3 枚举成功但读写卡死 / 报错
如果枚举一切顺利,系统也认出了大容量存储设备,但打开盘符时卡死、读写超时、或者Windows提示"磁盘结构损坏且无法读取",问题往往出在BOT传输层而不是描述符层。
先用分析仪或日志看主机发出的CBW设备有没有收到。设备收到CBW后,如果命令是READ(10)或WRITE(10),必须严格按照"CBW→数据→CSW"的顺序响应。数据阶段存在一个我说过的陷阱:传输字节数正好是wMaxPacketSize整数倍时,必须在数据末尾发零长度包,否则主机永远等不到数据阶段结束。CSW必须紧跟数据阶段,且tag要与CBW一致。如果设备在执行命令期间又收到了新的CBW,这说明设备的状态机可能进入了Phase Error分支,需要按规范响应bCSWStatus=0x02,并等待主机的Mass Storage Reset。
6.4 高速握手缺席导致的速度异常
设备枚举成功,U盘也能正常读写,但速度稳定在全速水平。先确认描述符里写的确实是高速参数,如果wMaxPacketSize还停留在64,主机自然会按全速处理。排除描述符后,就要怀疑高速握手过程:复位后的Chirp序列需要设备在检测到主机Chirp K后发出不少于1ms的Chirp K/J序列响应。上拉电阻的精度、走线长度都会影响握手成功率。
有些开发板为了方便调试,会通过拨码开关或跳线强制设备进入全速模式。如果跳线没还原,设备永远不会做高速握手。调试时先确认硬件没有锁在全速模式。
6.5 字符串描述符长度错误导致的引导异常
字符串描述符的错误比较隐蔽。bLength如果写短了,比如"MY USB DISK"实际需要24字节,你却写成0x10,主机读完16字节后发现字符串数据不完整,可能直接判定描述符非法。如果写长了,主机会把后面无关数据当成字符串的一部分,显示出来的产品名就是乱码。
我自己调试序列号更新功能时还遇到过一种状况:量产工具修改序列号字符串后,字符串长度没有同步更新,新序列号比旧序列号多一位字符,bLength还是旧值,结果系统读到的序列号被截断,Windows不认这个U盘,老是重新装驱动。修这种问题最好的办法是把字符串生成逻辑集中到一个函数里,每次更新内容时重新计算bLength。
6.6 描述符与实际FIFO不匹配的隐蔽传输故障
这类问题最头疼,因为枚举阶段不会失败,系统也认设备,但一旦数据传输量超过某个值,U盘就死掉。根因通常是端点描述符声明的wMaxPacketSize大于硬件实际配置的FIFO容量。主机按512字节组织数据包,设备FIFO只有64字节容量,多出来的数据直接丢。
排查链路建议:先用lsusb -v或设备管理器看系统解析出来的描述符参数,确认wMaxPacketSize和执行环境一致;然后用总线分析仪连续读写大文件,观察数据阶段在哪一包开始出现NAK或数据截断;最后回到控制器寄存器,核对FIFO配置和端点描述符是否匹配。百分之九十的"偶发性传输卡死"都能在这个链条里定位。
最后再说一个我的个人调试习惯:做新的U盘固件时,不要急着把完整固件一次写完。先用一个最简单的Bulk回环端点跑通枚举和收发的完整链路,确认描述符和端点FIFO完全匹配,再往上叠SCSI命令解释器和闪存读写。描述符这类东西,一旦跑通后面很少需要改动,真正让你头疼的永远是传输层和SCSI层。把调试范围缩小了,问题才好定位。