USB协议核心机制详解:从总线枚举到端点通信 如果你做嵌入式、单片机或者驱动开发迟早得面对USB协议。上篇我们把USB的身世捋了一遍从并口串口的痛点到USB 1.0/2.0/3.x/4.0的版本演进再到D/D-电平、连接器形态这些物理层内容。这篇中我打算把USB协议最核心的“操作系统”讲清楚——它到底是一个什么样的总线主机和设备之间怎么建立关系端点之间又是怎么通信的。说白了前半部分回答“USB世界怎么管人”后半部分回答“数据走到哪儿、怎么走”。这篇文章适合正在写USB设备端固件、做驱动移植或者被设备枚举折磨得想砸电脑的同学。1. 先纠正一个直觉USB绝对不是对等网络1.1 主从轮询所有通信都由主机发起很多第一次接触USB的人会默认它跟以太网差不多设备连上就能互相发数据。这个直觉错得离谱。USB是一个严格的主从总线一条总线上只有一个主机Host其余全是设备Device。设备永远不会主动“开口”即便鼠标动了、键盘被按下了设备也只是把状态放在端点的缓冲区里等主机来读。你可以把它想象成老师点名老师叫到谁谁才能站起来说话。老师没叫到学生就算手举断了也得等着。这个设计是刻意为之。USB诞生于1990年代中期核心诉求是“低成本、易用、可靠”。主从轮询制让设备端省掉了冲突检测、仲裁这些逻辑设备控制器做简单点成本就压下来了。同时主机完全掌握总线节奏不会出现两个设备同时发数据导致碰撞的问题可靠性比以太网的CSMA/CD那种“先听后说、撞了重来”要可控得多。这个特性直接影响了所有上层设计USB的“中断传输”名不副实、带宽分配由主机决定、设备端几乎不需要调度逻辑。你要是按“设备能主动上报”的思路去写固件第一步就走偏了。1.2 星型拓扑与“最多127个设备”的真实含义USB的物理拓扑是一棵星型树。主机里有根集线器Root Hub根集线器向下接设备也可以接普通的集线器Hub再扩展端口。逻辑上每个设备都挂在主机这根“总线”上但物理形态是分层的。两个数建议记一下。第一设备地址是7位主机在枚举时给每个设备分配1~127的地址0保留给“还没分配地址的设备”使用所以理论上最多127个设备。但实际根本插不到这么多——带宽、电源、Hub端口数都会先撑不住。第二USB规定从根集线器到设备最多7层也就是说级联的Hub数量有限制。超过7层就会枚举失败原因不难理解每次过Hub都有转发延迟Hub芯片也要处理Split事务后面细说层级太深时序就绷不住了。曾经有朋友在实验室里把Hub串了五层手机死活识别不到U盘查了半天才发现是层级超了。2. 主机和设备内部到底是谁在跑腿2.1 主机侧的三块拼图控制器、根集线器、软件栈主机侧要干活的部件有三个主机控制器Host Controller、根集线器和软件驱动栈。主机控制器是真正的“发号施令者”它把软件栈交下来的任务翻译成一个一个总线事务按帧/微帧的调度表发出去。老一点的主机控制器规范有OHCI、UHCI对应USB 1.x时代EHCI对应USB 2.0的高速模式现在主流是xHCI通吃USB 2.0/3.x/4.x还能向下兼容。从驱动开发角度看xHCI把中断、队列头、TRB这些概念重新组织了一遍但USB协议本身的逻辑没变。根集线器就是主机上的那几个物理USB口它负责检测设备的插拔、供电、复位和速度识别。设备插上去之后根集线器会通过D/D-上的电平上拉判断是全速还是低速设备然后向操作系统报告“有新设备”。软件栈在操作系统里干的事更琐碎解析描述符、匹配驱动、把应用层的读写请求封装成URBUSB Request BlockUSB请求块然后交给主机控制器。Linux下的usbmon、Android的usb设备树、Windows的setupapi日志都是这套软件栈的观测窗口。调试时如果设备枚举失败先分清是硬件没响应、控制器没发包还是软件栈解析描述符出错问题范围就小了一半。2.2 设备侧的小脑袋设备控制器与端点缓冲区设备侧的核心是设备控制器UDCUSB Device Controller。它实现协议状态机、PID解析、CRC校验、地址匹配这些底层的活儿。现在单片机里的USB外设基本都是这个思路只是寄存器封装不一样。以STM32为例F1系列用的是简单的USB Device外设F4/H7带OTG既能做Host也能做Device内部有FIFO和端点寄存器固件要做的就是配置端点、处理中断、搬数据。真正的重点在“端点”Endpoint。端点是设备里的数据缓冲区/队列也是主机和设备通信的“门”。每个端点有四个属性端点号、方向、传输类型、最大包长。端点0很特殊它是双向的、只用来做控制传输所有USB设备必须有端点0。其他端点号从1到15方向分IN设备发给主机和OUT主机发给设备。我们平时说“端点2 IN”指的就是设备向主机发送数据的2号端点。写STM32或类似MCU的USB固件时很多人的第一个误区是想在中断里逐字节处理数据。实际上硬件FIFO/包缓冲已经帮你扛了绝大部分体力活CPU要做的是在端点完成中断里把用户缓冲区里的数据交给硬件或者把硬件收到的数据拿回内存。你在代码里看到的“端点FIFO配置”“端点描述符表”本质上就是在告诉硬件“哪个缓冲区对应哪个端点”。把这个模型想清楚再看HAL库或USB协议栈的源码就不容易迷路。3. 设备模型与枚举USB世界的第一份简历3.1 四级描述符树设备、配置、接口、端点USB设备靠“描述符”向主机自我介绍。描述符是结构化的二进制数据块存在设备端的Flash里主机通过控制传输读取。整个描述符体系是四层树形结构设备Device包含多个配置Configuration配置包含多个接口Interface接口里放端点Endpoint。这个层级设计不是闲得慌它解决了一个实际问题一个物理设备可以有多种功能。最典型的例子是USB耳机带线控耳机的音频流走等时端点线控的按键走HID中断端点两个功能在一个配置里通过两个接口并存。主机侧枚举时看到配置描述符里有多个接口就会分别绑定对应的类驱动。另一个经典是USB CDC虚拟串口它由通信接口负责控制和通知和数据接口负责收发生成数据组成数据接口上通常是两个批量端点。还有个“备用设置”Alternate Setting的概念容易让人懵。你可以把接口看成是一个功能模块备用设置是它的不同工作模式。比如UAC音频设备默认设置下接口的等时端点最大包长可能是0不传数据设备切换到某个备用设置后端点的最大包长才变大开始传音频流。主机根据用户切换采样率会重新选择备用设置从而动态调整带宽。3.2 描述符字段里最容易踩的三个坑描述符字段本身不多但我见过的初学者错误高度集中在三处。第一个坑是多字节字段的小端序。USB协议规定所有多字节字段一律小端低字节在前。idVendor、wTotalLength、bcdUSB这些字段在代码里写常量时习惯写0x1234但实际存储是0x34 0x12。经常有人拿协议分析仪抓包发现读到的是0x3412然后怀疑总线坏了。第二个坑是端点0的最大包长bMaxPacketSize0。全速设备必须是8/16/32/64之一高速设备必须是64低速设备必须是8。很多人在USB 2.0全速设备上随便填了个非法的值结果主机枚举时反复重置设备表现为“无法识别的USB设备”。这个值主机在枚举最开始就会读千万别乱设。第三个坑是配置描述符的wTotalLength。主机读配置描述符时不是只读一个9字节的配置头而是把配置描述符、接口描述符、端点描述符以及可能的类特殊描述符比如HID描述符一次性读走。这一坨数据的实际总长度由wTotalLength字段告诉主机而且各种描述符靠bLength和bDescriptorType字段来区分。要是你wTotalLength填小了主机截断解析后面的接口、端点全乱套填大了主机多读可能直接超时。调试时用Bus Hound或者Wireshark抓一次枚举包对照描述符表看一遍问题基本就找到了。3.3 枚举完整时序从插上的那一秒开始枚举是USB设备“投胎”的过程理解了它后面所有传输类型都好懂。完整时序大概是这样设备插入后设备端的上拉电阻让D全速/高速或D-低速变成高电平。主机检测到电平变化后先对端口做总线复位D和D-都拉低一段时间。设备收到复位后进入Default状态默认地址清零。如果是高速设备复位期间会有Chirp K/J握手序列主机和设备通过这串脉冲确认双方都支持480Mbps然后设备才切换到高速模式不支持的话就留在全速模式。复位完成后主机向默认地址0发送GET_DESCRIPTOR(Device)请求。注意这次只读前8个字节目的就是拿bMaxPacketSize0。为什么只读8字节因为这8字节就够让主机知道“端点0最多能扛多大的包”后续事务才有依据。拿到这个值之后主机发SET_ADDRESS请求给设备分配一个1~127的地址设备收到后切到新地址进入Address状态。接下来主机用新地址重新读取完整的设备描述符然后读配置描述符先读9字节拿到wTotalLength再按wTotalLength读全量描述符数据。最后主机发SET_CONFIGURATION(1)设备进入Configured状态这个瞬间开始设备才算真正“上线”批量、中断、等时端点都可以跑数据了。注意USB设备状态机是Attached → Powered → Default → Address → Configured → Suspend。做固件调试时卡在哪个状态基本就是哪个环节出了问题。卡在Address说明SET_ADDRESS没处理对卡在Configured之前说明配置描述符有问题。4. 包、事务与传输三层结构一次说清4.1 包协议的最小句子USB通信可以拆成三层包Packet、事务Transaction、传输Transfer。包是最小单位相当于一句话里的单词。一个包由SYNC同步字段、PID包标识、载荷字段和EOP结束符组成。PID本身是4位类型码加4位反码所以总长8位用来抗错误。不同包干不同活最常见的三种令牌包Token包含OUT、IN、SETUP三种。携带7位设备地址和4位端点号。它相当于通告“下面要跟谁说话、是读还是写”。数据包DataDATA0、DATA1、DATA2、MDATA等。数据传输的真正载体。数据包后面跟CRC16校验。握手包HandshakeACK、NAK、STALL、NYET。只有PID没有载荷但信息量很大。ACK表示正确接收NAK表示“我没准备好你再试试”STALL表示“这个端点罢工了”NYET在高速批量传输里表示“我还没准备好进入下一事务”。有个细节可以多说一句握手包为什么不需要CRC因为它太短了PID里已经带了反码校验绝大部分错误都能发现而真正重要的数据都在数据包里有CRC保护再加上事务出错主机还会重试所以握手包没必要再画蛇添足。4.2 事务包与包之间的对白事务是一组包的组合可以理解成一句话。常见事务有三种结构IN事务主机发IN令牌设备应答数据包主机再回ACK/NAK/STALL。用于设备向主机送数据。OUT事务主机发OUT令牌主机发数据包设备回ACK/NAK/STALL。用于主机向设备送数据。SETUP事务跟OUT事务很像但SETUP令牌加DATA0数据包用于控制传输的建立阶段。等时传输有点特殊它只有令牌段和数据段没有握手段。因为等时传输追求的是带宽和时延数据丢了就丢了不重传所以不需要握手。理解事务层的握手机制后你就会明白为什么USB主机速度那么“轴”每个事务都要一发一收一确认协议开销占比很高。USB 2.0高速理论带宽480Mbps60MB/s实际批量传输有效吞吐量做到30~40MB/s就算不错了。测USB性能时如果达不到理论值先别怀疑芯片多半是协议开销和调度机制吃掉了带宽。4.3 四种传输类型怎么选先看需求表USB 2.0时代定义了四种传输类型主要区别在可靠性和实时性。传输类型可靠性实时性典型场景典型设备控制传输高有握手和重试无保证枚举、类命令、设备配置所有USB设备必经之路批量传输高有握手和重试无保证排队等待大块数据搬运U盘、UVC视频、CDC数据通道中断传输高有握手和重试保证轮询周期低频、周期性数据鼠标键盘HID、游戏手柄等时传输低无握手无重试保证带宽和时延音视频流、传感器采样UAC音频、UVC摄像头选型时很多人会在“中断传输”上犯迷糊。USB里的“中断”跟MCU中断完全是两回事。USB中断传输本质是主机每个bInterval时间片轮询一次端点保证在指定周期内至少有一次机会收发数据。它不会“打断”主机主机还是主动方。所以做低延迟设备时不要以为把bInterval设成1就无敌了真正的延迟取决于主机调度、协议栈和操作系统调度裸芯片层面能做到的只是尽量卡住bInterval。再举个例子。USB CDC虚拟串口也就是大家熟悉的STM32 USB转串口方案的数据通道用的是批量传输不是等时也不是中断。因为串口数据要求不能丢能容忍延迟批量正好合适。CDC里的“通知”元素才是中断传输用来发线路状态、Break信号等小控制信息。搞清这个你去读CDC类驱动代码时会顺畅很多。5. 帧/微帧与总线调度主机凭什么一碗水端平5.1 帧/微帧时间轴USB是主机调度总线那主机用什么节奏调度答案是帧和微帧。低速和全速模式下一帧是1ms高速模式下一帧被切成8个微帧每个微帧125us。每帧/每微帧的开始主机会广播一个SOF包Start of Frame里面带帧号。这个SOF不只是计时用的等时和中断设备经常以它为时间基准做同步。主机控制器内部维护着一张调度表。每个帧/微帧里周期类的传输等时中断会优先被安排在前面批量传输见缝插针填满剩余时间。所以批量传输的延迟和吞吐都是“弹性”的总线越闲它越快总线越忙它越惨。这个机制解释了为什么你把一个USB 3.0 U盘插在繁忙的集线器后面速度会掉得厉害——你的批量传输只是在等时、中断传输剩下的缝隙里抢食吃抢到多少算多少。5.2 带宽预算上板前先做一个算术题如果你只做主从一对一通信带宽预算不用太操心。但只要做复合设备或者在同一个Hub上接多个音视频设备就必须提前算账。规范给的指导是周期类传输等时中断在全速模式下大概占一帧的90%以内在高速模式下占一个微帧的80%以内剩下的给控制和批量。举个例子。做一个全速的UAC1音频设备48kHz采样、16bit、双声道每帧需要传的数据量是48000/1000 × 4字节 192字节/帧。那等时端点的最大包长至少要设为192字节每帧一个等时事务这个音频流才能稳定跑起来。如果同一条总线上还要挂一个4通道麦克风阵列那你就要把两路等时流的预算加起来再算上协议开销和Hub转发开销。算完发现超了就得降低采样率、减少声道数或切换备用设置来缩小包长。带宽预算这块我的经验是别只盯着表格上的数字。实际主机控制器对等时事务、Split事务的处理都有额外开销保险起见按理论值的70%~80%做预算。否则辛辛苦苦写完固件上机发现音频爆音或者视频掉帧排查起来非常痛苦。5.3 Split事务高速Hub与低速设备的“翻译官”USB 2.0总线跑高速时总线上的信号速率统一是480Mbps但总线和Hub下可能挂着全速或低速的老设备。这就需要一个翻译机制Split事务。具体流程是高速主机先发一个高速的Split事务令牌告诉Hub“我有一笔全速设备的业务你先记下”Hub把这段高速传输转换成对应的全速/低速事务转发给下游设备之后主机再来一次“Complete Split”事务从Hub取回结果。这样整条高速总线的速率不会被低速设备拖垮但代价是Hub芯片必须处理两段式事务复杂度比普通中继器高得多。对开发者的实际意义是低速设备接在高速Hub后面时枚举和通信时序会更复杂如果设备端时序比较极限可能会出现偶发失败。测兼容性时一定要把设备接到高速Hub后面试一遍很多“电脑上能用、插Hub上就不行”的案例就是这么来的。6. 调试USB的实战手册枚举失败与数据不畅6.1 枚举失败排查三板斧做USB开发遇到最多的问题就是枚举失败。我自己的排查顺序固定三板斧。第一板斧看供电。VBUS是否稳定、线缆是否过长过细、Hub是否过流保护、ESD器件是否把信号拉坏。这些问题特征很明显时好时坏、插特定口才失败、用手摸一下线缆就恢复。先排除物理层再往下查别一上来就抓协议。第二板斧看硬件连接。全速设备要在D上拉1.5k电阻到3.3V低速设备要在D-上拉高速设备枚举前的握手依赖于这个上拉。上拉电压接错了、芯片内部上拉没使能、D/D-接反都会导致主机检测不到设备。用示波器量一下D或D-在插入瞬间有没有明显的高电平变化三分钟就能定位。第三板斧看协议交互。用协议分析仪或者USB抓包工具看枚举过程。用Windows的话Wireshark配合USBPcap或者Bus Hound都行Linux下直接usbmon抓包。重点看GET_DESCRIPTOR请求有没有正确返回、SET_ADDRESS有没有成功、配置描述符取回来后主机能不能正确解析出接口和端点。6.2 固件的经典坑我在STM32上踩过的写USB设备端固件有些坑属于“经典中的经典”。bMaxPacketSize0填错我前面已经提过这里不再重复。还有一个高频坑是配置描述符的接口数量bNumInterfaces填得跟实际不符。比如CDC设备需要两个接口通信接口数据接口如果不小心填了1主机枚举时会以为只有一个接口CDC驱动加载就失败。控制传输的状态阶段也经常让人翻车。控制传输分Setup阶段、Data阶段可能没有和Status阶段。很多MCU开发者在处理完数据阶段后忘了处理状态阶段或者对状态阶段的事务方向搞反了——数据阶段是IN时状态阶段必须是OUT数据阶段是OUT时状态阶段必须是IN。主机迟迟等不到状态阶段的握手就会超时重置设备。还有一类问题是NAK风暴。设备端点数据没准备好时会回NAK主机则会反复重试。如果你在中断端点里长时间不准备数据主机每次轮询都收到NAK虽然不会报错但整条总线被无效事务白白占掉一大截带宽。尤其多个设备共用一条总线时一个NAK风暴设备能拖慢所有人。解决思路是设备端的数据生产和端点的“可读/可写”状态尽量由硬件事件驱动别用软件死等。6.3 用抓包工具定位问题的三条心得抓包工具不是接上就能看懂的新手容易在茫茫URB里迷路。第一条心得是先看“请求-回复”配对。枚举阶段几乎所有交互都是标准请求每个请求都应该有对应的完成状态。如果看到请求发出后没有回复或者回的是STALL那问题就锁定了STALL通常意味着设备端对请求的类型、长度或值不认可多半是描述符或标准请求处理代码写错了。第二条心得是关注端点0的最大包长。抓包时能看到主机第一次GET_DESCRIPTOR的长度只有8字节这是完全正常的别误判为“请求被截断”。如果设备在这8字节里返回的bMaxPacketSize0非法后面的所有交互都会异常。第三条心得是学会看总线的错误重试。同一笔事务反复重试大概率是信号完整性问题或设备响应超时。设备已经回ACK但主机还重试则多半是握手包或数据包CRC出错这时要回头查物理层和时钟稳定性而不是死磕协议栈。USB对时钟精度有要求MCU的USB外设一般需要48MHz的时钟源晶振偏差太大或RC时钟不准高速/全速时序就会出问题这是我见过最多的一类“玄学”故障。做USB开发这几年我最大的体会是USB看起来协议栈厚真正难的不是概念多而是“你假设的字段和主机读到的不一致”。所以我每次做一个新设备都会先把描述符按字节从头到尾啃一遍再画一张端点和FIFO/中断之间的映射表最后才写业务逻辑。这个习惯帮我少走了不少弯路。下一篇我们讲类协议和实战项目如果你在做HID或者CDC设备可以先按这篇文章把枚举和端点通信跑通基础打牢了后面的事就顺了。