☰
U盘USB描述符深度拆解:枚举流程与BOT协议实战指南
2026/10/21 9:48:37 网站建设 项目流程

做USB开发这些年,我打交道最多的设备就是U盘。它结构简单、协议成熟,看起来就是“插上就能用”的存储工具,但真正在调试枚举失败、写固件或者研究主机兼容性问题时,你会发现U盘的描述符里藏着大量值得琢磨的细节。这篇是“USB描述符系列”里专门讲U盘的一篇,我会从描述符的层级结构、U盘为什么要用Mass Storage类和BOT协议讲起,再逐字节拆解设备、配置、接口、端点、字符串描述符,最后分享我自己抓取描述符、排查枚举故障的真实经验。适合正在做U盘固件、USB主机端驱动,或者被“设备描述符请求失败”搞到头秃的人,看完应该能少走不少弯路。

1. U盘描述符的整体构成与设计逻辑

1.1 描述符不是一张表,而是一棵有层级的树

很多初学者拿到描述符数据后,第一反应是找“一张表”把18个字节的设备描述符、9个字节的配置描述符背下来。但实际工作中,描述符更像一棵树:主机从根节点(设备描述符)开始,一层一层往下问,设备再一层一层往回答。U盘描述符的典型形态是设备描述符下面挂一个配置描述符,配置描述符里有一个接口描述符,接口描述符下面挂着两个端点描述符——一个Bulk OUT,一个Bulk IN。如果有字符串描述符,还会在iManufacturer、iProduct、iSerialNumber这些字段里通过索引值指向具体的字符串。

理解“树”这个结构非常重要,因为主机枚举设备时并不是一次性把所有描述符读走,而是分阶段、按需读取。比如在地址还没分配之前,主机只会在默认地址0上请求设备描述符的前8个字节,用来获取bMaxPacketSize0,后面再请求完整18字节;配置描述符也是先拿前面9个字节,算出wTotalLength总长度后,再一次性读取完整配置。所以你的固件不能只把描述符数组放在那里,还要正确处理主机“要一段就给一段”的行为。

另一个容易忽略的点是描述符之间的依赖关系。U盘设备描述符里的bDeviceClass通常填0x00,意思是“类信息在接口描述符里定义”,而不是在设备级别声明。这是U盘和HID设备的一个明显差异:HID设备可能在设备描述符层级就声明类,但U盘几乎都是把Mass Storage类信息放在接口描述符里。主机看到bDeviceClass=0x00后,不会在设备层面绑定驱动,而是在枚举完配置描述符后,根据接口描述符里的bInterfaceClass、bInterfaceSubClass、bInterfaceProtocol三个字段去匹配驱动程序。

1.2 U盘为什么选Mass Storage类与BOT协议

U盘本质上是“带USB接口的块存储设备”,所以描述符里需要向主机声明两件事:我是干什么的,以及数据怎么传。前者由bInterfaceClass=0x08(Mass Storage Class)声明,后者由bInterfaceProtocol=0x50(Bulk-Only Transport,简称BOT)声明。几乎你能买到的普通U盘都是这个组合,没有例外。

为什么不用中断传输或等时传输?原因很直接:U盘传输的是大块数据,关心的是吞吐量和纠错重传,而不是实时性。Bulk传输在USB协议里就是为这种场景设计的——带宽是“尽力而为”,错了可以重传,且不占用固定时间片。中断传输一次最多只能带几十个字节,等时传输又没有任何重传机制,都不适合做块存储。U盘描述符里两个Bulk端点的存在,就是这种设计逻辑的外在表现。

Mass Storage类的子类也值得一提。bInterfaceSubClass=0x06表示使用SCSI透明命令集,也就是把U盘当成一个SCSI设备来操作,上层用INQUIRY、READ CAPACITY、READ(10)、WRITE(10)、TEST UNIT READY这些命令。你可能觉得U盘和SCSI八竿子打不着,但USB协议就是这么设计的——用一套通用的块设备命令来统一U盘、读卡器、移动硬盘等设备。主机侧不需要为每一种存储设备写单独驱动,只要是BOT+SCSI,通通走系统自带的存储驱动。

2. U盘描述符逐字节拆解:从设备到端点

2.1 设备描述符:U盘的身份牌

U盘设备描述符固定18个字节,是所有描述符里信息最密集的一段。我习惯用表格把它拆开看:

字段字节数U盘典型值说明
bLength10x12固定18字节,U盘的设备描述符不会变。主机靠这个字段判断该读多少字节。
bDescriptorType10x01表示“这是设备描述符”。
bcdUSB20x0200或0x0201USB版本号。USB2.0全速/高速U盘通常填0x0200,支持USB2.1的可以填0x0201。
bDeviceClass10x00U盘一般不在这里声明类,而是放到接口描述符里,所以填0。
bDeviceSubClass10x00配合bDeviceClass=0,这里也必须填0。
bDeviceProtocol10x00同上。
bMaxPacketSize010x40端点0的最大包长。全速设备固定64字节,高速设备也是64字节,写0x08(8字节)会导致控制传输异常。
idVendor2由厂商自定义厂商ID,需要向USB-IF申请,开发调试时可以先用一个临时值。
idProduct2由厂商自定义产品ID,厂商自己分配。
bcdDevice20x0100等设备版本号,很多厂商用这个字段区分硬件版本。
iManufacturer11或0制造商字符串的索引,0表示没有。
iProduct12或0产品字符串索引。
iSerialNumber13或0序列号字符串索引。建议要有,且保持唯一。
bNumConfigurations10x01U盘一般只有一种配置,所以这里是1。

这里有一个我反复跟人强调的点:bMaxPacketSize0千万不能填错。如果U盘是高速设备,端点0最大包长是64字节,主机在拿到设备描述符前8字节时就用这个值来决定后续控制传输的数据包大小。你哪怕填了0x20(32字节),在大多数主板上可能也能跑,但在严格的USB分析仪下就会看到控制传输的怪毛病。

2.2 配置与接口描述符:U盘的功能声明

配置描述符是整个描述符树的骨架,它告诉主机“这套配置里有哪些接口,总共多少字节”。U盘的配置描述符典型长度是9(配置描述符本身)+9(接口描述符)+7+7(两个端点描述符)=32字节,所以wTotalLength=0x0020。别小看这个总长度字段,很多自制U盘枚举失败都是因为wTotalLength只写了配置描述符自己的9字节,主机拿到后只读了9个字节,后面的接口和端点全部丢失。

配置描述符里的bmAttributes字段也值得注意。U盘是总线供电设备,所以这个字段通常填0x80(表示总线供电,不声明远程唤醒)或者0xC0(自供电)。注意0x80只用了bit7表示“存在”,bit6是“自供电”标志,bit5是“远程唤醒”标志。如果U盘实际上是总线供电,你却把bit6置1,某些对供电状态敏感的主机或扩展坞会有奇怪表现。

接口描述符是U盘的“数字化名片”。它的重点字段是:

  • bInterfaceNumber=0x00:这是第0号接口
  • bAlternateSetting=0x00:U盘几乎不需要Alternate设置,所以固定0
  • bNumEndpoints=0x02:BOT传输需要一对Bulk端点,所以是2
  • bInterfaceClass=0x08:Mass Storage
  • bInterfaceSubClass=0x06:SCSI透明命令集
  • bInterfaceProtocol=0x50:Bulk-Only Transport

这三个字段少一个都不行。我见过有人把bInterfaceClass写成0xFF(vendor-specific),结果Windows不识别、Mac不识别,只有装了自己驱动才能用;也见过bInterfaceProtocol写成0x00,导致主机走了Control/Bulk/Interrupt(CBI)传输去跟设备沟通,设备根本没有实现中断端点,命令永远没有响应。

2.3 端点描述符:数据走哪条路

BOT协议要求一对批量端点,一个负责主机到设备方向(OUT,输入方向用0x01这类地址),一个负责设备到主机方向(IN,用0x81这类地址)。我之前见过有人偷懒把两个端点都配成OUT方向,主机端枚举成功了,但一发起READ命令就卡死,因为设备没办法把数据回给主机。

端点描述符的关键字段是bmAttributes=0x02(Bulk传输类型)和wMaxPacketSize。全速U盘的Bulk端点最大包长是64字节,高速U盘则是512字节。这个值如果写错了,并不会在枚举阶段立即失败,而是在真正传输大块数据时出现丢包、超时。wMaxPacketSize还有一个隐藏陷阱:高速批量端点描述符里,wMaxPacketSize的低2位是“额外的传输事务”字段,BOT场景下必须为0,否则主机可能会在同一个微帧里对同一个端点发起多余的事务。

bInterval在批量端点里没有实际意义,全速设备按规范填0x00,高速设备也是0x00。有的厂商为了“看起来规范”,给批量端点填了1,这不会导致立即失败,但严格来说不合规,某些USB分析工具会把它标记为警告。

2.4 字符串描述符:能省则省,但要规范

字符串描述符在很多U盘固件里是被忽视的部分。因为它不影响基本枚举,很多开发者直接让iManufacturer、iProduct、iSerialNumber全部填0,Windows显示“大容量存储设备”,系统也能用。但如果你想做定制U盘、产品化U盘,字符串描述符的质量直接决定了设备管理器里显示的“友好名称”和用户体验。

这里有一个规范性问题:字符串描述符的结构是长度、类型、UTF-16LE编码的字符串数据,而且必须先有一个语言ID描述符。语言ID描述符本身也是一个“字符串描述符”,内容固定是0x04 0x03后跟语言ID列表。如果你的固件返回的字符串描述符里没有声明语言ID,或者语言ID支持的编码范围有问题,Windows主机会在读字符串时得到错误信息,然后直接跳过字符串解析,导致很多基于字符串的识别功能失效。

序列号字段还有一个容易踩的坑:我强烈建议给每一个U盘烧写唯一的序列号,而不是让固件里写死同一个iSerialNumber。因为Windows驱动会缓存设备实例,如果两个U盘的序列号相同,系统会认为你插的是同一个设备,可能不重新枚举、不刷新盘符、甚至在设备管理器中显示一个带黄色叹号的残留实例。这在产线量产的时候尤其恶心。

3. 实操:抓取并解读一个真实U盘的描述符

3.1 抓描述符的常见手段

要分析一个U盘描述符,最快的方法是先把它枚举的原始数据抓出来。我平时的做法分三层:软件层、系统层和硬件层。

软件层最简单,在Linux环境下直接看系统枚举结果,能看到设备描述符的完整字段,包括ID、类、端点信息。在Windows上也可以打开设备管理器查看设备实例路径和硬件ID,间接确认VID和PID,但拿不到完整的原始描述符字节。想看原始字节,最好用支持“USB抓包”的协议分析工具,在电脑上装一个抓包软件,它可以捕获主机与U盘之间的所有控制传输,包括每次Get Descriptor请求和返回的原始数据。

系统层走的是日志分析。Windows下USB枚举的详细日志可以通过事件查看器和相关USB诊断日志拿到,里面会记录枚举失败的具体阶段,对排查“设备描述符请求失败”“配置描述符请求失败”这类问题非常直接。不过日志是文字描述,看不到原始字节。

硬件层最彻底:用逻辑分析仪抓USB D+/D-线上的差分信号。USB 2.0高速信号很考验器材和分析软件,没那么容易直接上。但如果是全速U盘,逻辑分析仪方式完全可行,你能看到最原始的复位、地址0上的Get Descriptor、Set Address、后续的Get Descriptor序列,这对理解枚举协议非常直观。

3.2 枚举过程与描述符请求顺序复盘

不管用哪种工具,你都会看到一套固定的枚举流程。U盘插进主机后,主机先检测设备上拉电阻带来的电平变化,然后在总线发出复位信号。复位结束后,设备在默认地址0等待命令。主机发第一个请求:Get Descriptor(Device),但只请求8字节,目的是拿bMaxPacketSize0。设备必须正确返回前8字节,否则主机连地址都不会分配。

拿到最大包长后,主机给设备分配一个地址(Set Address),从这一刻起设备必须响应新地址上的请求。然后主机重新请求完整的18字节设备描述符,拿到VID、PID、配置数量等信息。接着主机发Get Descriptor(Configuration),先拿前9字节,从wTotalLength知道整段配置描述符的长度,再发一次完整长度的请求,把配置、接口、端点全部读走。有的主机会在这里请求字符串描述符,有的会放到Set Configuration之后,但多数Windows和Linux系统都会在枚举阶段按需读取iManufacturer、iProduct等字符串。

这段流程看着简单,实际调试时最容易出错的是主机请求描述符的“长度”不是固定的。比如第一次Get Descriptor(Device)只请求8字节,有些开发者写的描述符处理函数没有实现“按请求长度返回”,直接一次性把所有数据发出去,这在地址还没分配时会导致ISO包溢出或时序错乱,表现为设备“偶尔能识别,偶尔无法识别”。

3.3 固件里如何定义U盘描述符

以我自己写过的模拟U盘固件为例,描述符部分通常直接定义成一个只读字节数组,然后在USB中断或寄存器轮询里按请求响应。这里我给一个最简结构的示意:

static const uint8_t device_desc[] = { 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, 0xAA, 0x55, 0x00, 0x01, 0x00, 0x01, 0x01, 0x02, 0x03, 0x01 }; static const uint8_t config_desc[] = { 0x09, 0x02, 0x20, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32, 0x09, 0x04, 0x00, 0x00, 0x02, 0x08, 0x06, 0x50, 0x00, 0x07, 0x05, 0x01, 0x02, 0x40, 0x00, 0x00, 0x07, 0x05, 0x81, 0x02, 0x40, 0x00, 0x00 };

第一段设备描述符里,0xAA 0x55是我用的一个临时厂商ID,0x00 0x01是产品ID,后面的0x01、0x02、0x03分别指向制造商、产品、序列号三个字符串索引。第二段配置描述符,0x20 0x00表示总长度32字节,0x01表示一个接口,0x80是总线供电,0x32表示最大电流100mA(0x32=50,单位2mA,所以是100mA)。接口描述符里的0x08、0x06、0x50就是Mass Storage类、SCSI子类、BOT协议,最后是两个Bulk端点。

实际量产固件里,厂商ID、产品ID、序列号一般不会写成固定字节,而是从配置区或Flash里读取。但描述符结构本身基本都是这个套路。还有一点要提醒:如果你的U盘要跑高速模式,wMaxPacketSize要从0x0040改成0x0200,同时要记得在设备描述符的bcdUSB里体现USB2.0支持。

3.4 字符串描述符的编码细节

字符串描述符在固件里经常被写得“差不多能跑”,但我要强调一下字节序问题。USB字符串描述符使用的是UTF-16LE编码,和你在PC上常见的UTF-8完全不一样。比如“USB”这个字符串,不能直接写0x55 0x53 0x42,而是要写成0x55 0x00 0x53 0x00 0x42 0x00。中文“U盘”中的“盘”字,Unicode编码是0x76D8,LE字节序就是要写0xD8 0x76。

我见过一个坑:有人直接用某个字符串工具把中文转成UTF-8字节流填进描述符,结果Windows显示乱码、Linux直接跳过字符串。正确做法是先确认自己用的编译器/脚本能输出UTF-16LE字节序,再转成C语言数组。

语言ID描述符也很关键。如果你希望设备支持简体中文显示,可以声明0x0409(英语)和0x0804(简体中文)两个语言ID,Windows会根据系统语言自动选择合适的语言。但如果你只有一个0x0409,那中文U盘的产品名称在中文系统里也会显示英文。这不算错,但产品体验上会差一点。

4. 我踩过的U盘描述符坑:问题与排查

4.1 设备描述符请求失败

这是USB开发里最常见的报错,Windows设备管理器直接给你一个大大的“未知USB设备(设备描述符请求失败)”。很多人第一反应是硬件问题,但其实有相当比例是描述符配置问题。我自己遇到过的典型情况是bMaxPacketSize0返回了0x40但实际端点0配置成了8字节模式,主机第一次请求设备描述符8字节,还能勉强通信,等到Set Address之后端点0包长没有重新配置,后续的18字节请求就把缓冲区挤爆了。

排查这类问题,我的建议是先别信Windows报错,直接用逻辑分析仪抓电平。看看主机发出Setup包后,设备到底有没有返回ACK,返回的DATA包是不是正确数据。如果物理层完全没反应,那是硬件问题;如果物理层有响应但数据内容不对,那就是描述符内容或控制传输处理逻辑的问题。这一步能把问题的范围缩小一半。

4.2 接口协议不匹配导致SCSI命令无人响应

有时候枚举完全正常,Windows也识别出了“大容量存储设备”,但一点盘符就是不出,或者磁盘管理里看到设备却提示“未初始化”。这时候问题往往不在描述符本身,而在接口描述符声明的能力和固件实际实现不一致。

比如接口描述符里写了bInterfaceSubClass=0x06、bInterfaceProtocol=0x50,就意味着固件必须实现BOT协议基本的CBW(Command Block Wrapper,31字节)和CSW(Command Status Wrapper,13字节)交互,还要能响应SCSI命令。主机会发TEST UNIT READY、INQUIRY、READ CAPACITY等命令去探测设备,你的固件如果只是在描述符里“冒充”U盘,却没有真正实现SCSI命令处理,那主机就会一直等待响应,直到超时。

所以排查这类问题时,抓包重点不应该放在描述符上,而要放在Bulk端点上的CBW/CSW事务上。如果在枚举之后没有任何Bulk事务,说明主机没把设备当成存储设备,大概率是描述符匹配问题;如果有CBW但没有CSW,说明SCSI命令处理层有问题。

4.3 字符串描述符导致的枚举异常

字符串描述符看起来不影响功能,但会影响枚举稳定性。我遇到过一个案例:iProduct字符串描述符的内容长度和实际编码长度不一致——bLength字段写了20,但后面实际只跟了18字节数据。主机读完这个字符串后,整个枚举过程就会卡在“读取产品字符串”这一步。

另一个与字符串相关的坑是语言ID描述符缺失。Windows下如果设备只有一个语言ID描述符,但是声明的方式不规范,系统可能会跳过所有字符串读取。这本身不会导致设备完全不识别,但会留下两个问题:一是设备管理器里显示的名称变成“大容量存储设备”,二是有一些厂商工具依赖字符串里的序列号信息,读不到就报错。我现在的习惯是:字符串描述符相关的bLength、bDescriptorType、语言ID数量,全部在代码里用宏定义,绝不手写硬编码。

4.4 高速与全速切换时的描述符问题

U盘如果同时支持全速和高速模式,还有一个容易被忽略的环节——高速/全速切换时的描述符差异。主机在复位后会先让设备运行在全速模式,如果设备支持高速,会通过Chirp序列切换到高速模式。切换到高速后,设备描述符里的bcdUSB可能不变,但端点最大包长、传输事务数都应该按高速规范调整。

我在调试一个同时支持全速和高速的模拟U盘时踩过坑:固件里只准备了一套全速描述符,高速切换完成后,仍然回给主机wMaxPacketSize=0x0040的Bulk端点。结果是枚举能过,但读写文件时速度极慢,而且一传大文件就断。后来加上了对当前速率状态的判断,高速时返回512字节的端点描述符,问题立刻消失。所以如果你在做高速U盘,建议在描述符请求处理函数里根据当前的连接速度动态选择不同字节长度的描述符数组。

5. 一些调试习惯与后续扩展

5.1 我调试描述符的固定套路

调试U盘描述符这几年,我慢慢形成了一套固定打法。第一,每次只改一个字段。别同时改VID、字符串和端点配置,否则出了问题根本不知道是谁引起的。第二,抓包看原始字节,不要只看系统“识别成功还是失败”这个结果。之前用一个分析工具抓了很多次,发现Windows和Linux对同一份描述符的容忍度完全不同,你以为“能用”的描述符,可能换个主机就翻车。第三,先用最简配置跑通枚举,再加功能。我调试时先把字符串描述符全部去掉,iManufacturer、iProduct、iSerialNumber都填0,端点最小配置,先把最基本的Mass Storage枚举打通,然后再逐步加字符串、加序列号、加多LUN支持。这样出了新问题,大概率是刚加的那部分引起的。

5.2 从U盘描述符延伸出去的玩法

掌握U盘描述符之后,可以做的扩展其实挺多。比如把接口描述符里加一个CDC设备类接口,让设备同时变成U盘和虚拟串口,这就成了USB复合设备;也可以在配置描述符里定义多个接口,实现U盘+键盘的组合。这些玩法虽然看起来花哨,但底层都是基于你对描述符树的理解。

我自己做定制U盘项目时,最大的体会是“描述符是设备和主机之间的唯一契约”。别把描述符当成一份要交差的文档,主机驱动的每一步行为都依赖这份描述符。你在固件里少写一个端点、填错一个类代码、字符串编码不对,主机就会用各种匪夷所思的方式报错。与其去猜“为什么识别不了”,不如老老实实把描述符逐字节捋清楚,这份耐心会给你省下大量排查时间。

最后再分享一个小技巧:我现在每次拿到一个没见过的读卡器或者U盘,都会先抓一份它的完整描述符存下来存档。不同厂商对描述符细节的处理差异很大,尤其是一些老牌存储厂商,它们的端点描述符和字符串布局可以作为很好的参考。这些样本积累多了,你再写自己的U盘固件时,看一眼别人的描述符就知道它实现到哪一步了,调试起来会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询