简介:围绕 USB Host 驱动 CDC 类设备展开的原创技术文档,面向需要在嵌入式板卡上突破 RS232 接口限制、让 MCU 直接与串口转 USB 设备通信的开发者,属于进阶协议分析与实践层级。内容从插入检测、总线复位讲起,逐层拆解差分 NRZI 编码、SYNC 同步域、SOP/EOP 包边界、PID 类型划分、帧号与微帧机制,并结合 CH32V307 平台给出完整通信流程:以 0 地址读取 8 字节设备描述符、通过 Set Address 改址、再读 18 字节完整描述符确认 CDC 类,最终配置接口与端点建立双向链路。文中还记录了 Bus Hound 软件解包、SSCOM 串口读写、DSView 逻辑分析仪抓时序、USB Packet Viewer 硬件抓包的工具组合与实测数据包内容,便于对照波形逐帧复盘。整包仅含 1 个 PDF,约 4.12MB,内容集中无冗余附件;已有 2667 人学习,适合希望系统掌握 USB 通信流程与排错思路的工程师查阅。
1. 从一台只带 USB 口的仪器说起
上周有位做产线的朋友拿着一台仪器来找我,机身背后的 DB9 座换成了 USB 口,配套光盘里只有 Windows 驱动。他想让板子直接读这台仪器的数据,板子上没有主机,也没有系统,只有一颗 MCU。这就是典型的 USB Host 驱动 CDC 设备场景:仪器内部是串口转 USB 芯片,PC 端即插即用,MCU 端却常常卡在枚举第一步。这份 USB Host 驱动 CDC 类设备资料走的是底层路线,从 D+/D- 电平、NRZI 编码一路拆到 SETUP 包字节、配置描述符和端点,最后在某国产 RISC-V MCU 的 CH32V307 上跑通双向收发。适合已经会用串口、但没真正写过 USB Host 的嵌入式工程师,也适合想搞清楚 Bus Hound 里那些十六进制到底什么意思的人。
2. 插入检测与总线复位:D+ 上拉、10ms 拉低和时钟到底从哪来
USB 号称差分总线,但主机判断「有没有设备插进来」这一步,用的是单端电平。搞不清这一层,后面看波形全是玄学。这一章把插入检测、复位、编码三件事拆开讲,每一条都对应逻辑分析仪上能看见的东西。
2.1 D+/D- 单端观测:速度判定和那 10ms
先记住两个电阻。设备侧,全速设备在 D+ 上挂一个 1.5k 上拉;低速设备在 D- 上挂 1.5k 上拉;高速设备在插入初期也是按全速身份出现,D+ 上拉,等主机复位期间双方再通过 chirp K/J 序列协商切到高速。主机侧,D+ 和 D- 各有一个 15k 下拉到地。所以总线空闲、没插设备时,两根线都被拉低。
设备一插上,如果 D+ 变高、D- 还是低,主机就认为来了一个全速或高速设备;如果 D- 变高,那就是低速设备。这个判断在硬件层就完成了,MCU 的 USB 控制器通常有一个端口状态寄存器直接给出「已连接」和「速度」两个位,不需要软件去轮询电平。
注意:很多板子把 D+/D- 画反了,或者上拉电阻挂在主机侧,现象是插入毫无反应,别急着怀疑固件。
紧接着是复位。协议规定 D+ 和 D- 同时拉低(SE0 状态)持续 10ms 代表总线复位。原文用逻辑分析仪抓到的现象是:插入前两根线都是低,插入后 D+ 变高,主机发起第一次复位,然后设备以 0 地址开始握手,之后还有第二次复位。这里有个容易忽略的点——USB 总线虽然是差分信号,但复位这个动作可以按单端方式控制,也就是说 MCU 的 USB 控制器在 Host 模式下,能直接驱动两根线到指定电平,这也是为什么有些低端 MCU 能用普通 GPIO 模拟出复位时序,做最简单的设备检测。
用逻辑分析仪抓这段波形,接线是 D+、D-、VBUS、GND 四根。采样率是个硬指标:全速 12Mbps,一个位时间约 83ns,采样率低于 24MHz 基本看不清单个位,建议 100MHz 以上,记录长度按「插入前 50ms 到插入后 200ms」配置。触发条件设在 D+ 上升沿,单次触发,这样能完整录下从上拉到第一次 SETUP 包的全过程。
抓下来的数据导出成 CSV,可以写个小脚本自动找复位段,省得每次肉眼数格子:
import csv def load(path): """逻辑分析仪导出的 CSV:time_ns, dp, dm""" rows = [] with open(path, newline='') as f: r = csv.reader(f) next(r) # 跳过表头 for t, dp, dm in r: rows.append((int(t), int(dp), int(dm))) return rows def find_bus_reset(rows, min_ns=9_000_000): """ 找 D+/D- 同时为低的连续段。 协议规定 10ms,取 9ms 作为下限,容忍采样抖动。 """ segs, start = [], None for t, dp, dm in rows: if dp == 0 and dm == 0: if start is None: start = t else: if start is not None and t - start >= min_ns: segs.append((start, t - start)) start = None return segs rows = load("usb_insert.csv") for t, dur in find_bus_reset(rows): print(f"复位起始 {t/1e6:.3f} ms, 持续 {dur/1e6:.2f} ms")脚本做的事情很朴素:把电平序列扫一遍,凡是两根线同时为低的连续区间,持续时间超过 9ms 就记一次复位。min_ns这个参数不是拍脑袋定的,协议写的是 10ms,实际设备可能因为电容和驱动能力差一点,取 9ms 做下限能减少误判;如果发现抓不到,先看采样率是不是太低导致短脉冲被吞掉。输出里如果出现两次相隔几十毫秒的复位,那就是正常的枚举流程:第一次复位拿 8 字节描述符,第二次复位之后才配地址。
2.2 NRZI 编码与 SYNC 域:没有时钟线的频率锁定
USB 线上只有 D+ 和 D-,没有单独的时钟。主机怎么知道每一位什么时候采样?靠的是编码本身保证的边沿密度。
USB 用的是 NRZI(不归零反转)编码:遇到数据位 1,电平保持不变;遇到数据位 0,电平翻转。这么一来,只要数据里 0 出现的频率够高,线上就有足够的边沿让接收端做相位对齐。问题在于数据可能连续出现很多个 1,比如一串 0xFF,按 NRZI 规则电平一直不翻转,接收端的锁相环就没东西可跟了。所以协议加了一条硬规定:连续出现 6 个 1,发送端强制插入一个 0,接收端在解析时再把这个 0 丢掉。这就是位填充(bit stuffing)。
同步域(SYNC)是每条包的固定开头,也承担着锁频的任务。低速和全速设备的 SYNC 是 7 个 0 加 1 个 1,即 0000 0001;高速设备的 SYNC 是 31 个 0 后面跟一个 1。按 NRZI 遇 0 翻转的规则,这段数据在线上会呈现出一串高低交替的方波,主机就是拿这段方波的周期去校准自己的采样时钟,等 SYNC 结束时时钟已经稳定了,后面的 PID、地址、数据才敢采。
这里有个工程上常见做法:如果你在 MCU 里自己写 Host 协议栈的位解码层,SYNC 不需要真的去「识别 00000001」,而是把它当成一段时间窗内的边沿序列,检测到边沿间隔稳定后就开始按位采样。厂商的 USB 控制器硬件会把这个活干掉,但你在逻辑分析仪上看到的 SYNC 就是一段漂亮的密集方波,长度明显比后面任何一个字段都短促密集。这也是抓波形时判断「包从哪开始」的最直观依据。
另外补充差分线的 J/K 状态定义:全速和高速下,J 状态是 D+ 高、D- 低,K 状态是 D+ 低、D- 高。总线空闲态就是 J。低速设备反过来。记住这个,看波形时「空闲」两个字才有具体所指。
2.3 三个必看节点和一份参数表
逻辑分析仪抓一次插入,值得盯住的节点其实只有三个:
第一个是 D+ 从低跳到高的那一刻,这是插入检测的物理起点,比软件里收到中断要早几十微秒。第二个是第一次 SE0 段结束的位置,复位刚放开,设备进入默认状态,地址还是 0,所有后续通信都走 0 地址。第三个是复位结束后出现的第一个包,也就是 SETUP 令牌,它是主机主动发起的第一句话。
把这三件事在时间轴上对齐,你就有了整个枚举的基准时间线,后面所有包的先后顺序都能挂上去。我一般会把这三个位置在波形软件里打上标记,导出截图,跟 Bus Hound 抓的软件日志并排放,两边的包数应该能一一对上,对不上就说明硬件层丢了包或者软件层解析错了。
参数配置参考:
| 项目 | 建议值 | 说明 |
|---|---|---|
| 采样率 | ≥100 MHz | 全速一位 83ns,低速一位 1.5us,同一套配置兼顾 |
| 通道数 | 4(D+/D-/VBUS/GND) | VBUS 用来判断供电时序,很关键 |
| 触发方式 | D+ 上升沿,单次 | 抓插入瞬间 |
| 记录长度 | 插入前 50ms 到插入后 200ms | 足够覆盖完整枚举 |
| 阈值电压 | 1.5V 左右 | 3.3V 电平系统,别用 5V 阈值 |
VBUS 这一路很多人不接,结果排错时最想问的两个问题——设备到底有没有上电、上电和 D+ 拉高谁先谁后——反而答不上来。如果设备靠总线供电,VBUS 在插入后应该先起来,然后才是 D+ 上拉,中间可能有几毫秒的电源稳定时间;如果设备是自供电,VBUS 可能一直为高,这时候主机侧要靠 D+ 上拉来判断连接。这两种情况在代码里的初始化时序不一样,硬件上一眼就能分清。
3. 包结构与枚举流程:从80 06 00 01到 18 字节设备描述符
波形看完,接下来是协议层。这一章把包的结构、事务的阶段、以及一次完整枚举的动作序列拆到字节级别。原始资料里记录的那串十六进制80 06 00 01 00 00 04 00就是本章的核心素材,我会把它逐字节拆开,再说清楚为什么只回 8 字节。
3.1 SOP/EOP、PID、帧号:一帧里装了什么
USB 总线上所有通信都以包为单位,而且永远由主机发起。总线空闲时是 J 状态(D+ 高、D- 低),当驱动到相反状态的那一刻,就是包的开始,简称 SOP(Start of Packet)。包的结束叫 EOP(End of Packet),表现形式是 D+ 和 D- 同时为低,保持两个位时间,然后再回到 J。EOP 后面通常会跟一段总线空闲,给接收端留出处理时间。
紧跟在 SYNC 后面的是 PID,包标识符。它总共 8 位,低 4 位是类型码,高 4 位是这 4 位的按位取反,用作校验。比如 ACK 的类型码是 0010,高 4 位就是 1101,合起来 0xD2。接收端拿到 PID,把高 4 位取反跟低 4 位比对,不一致就丢包。这种设计让 PID 只有一位出错时能被发现。
PID 分四类:令牌类(SETUP、OUT、IN、SOF)、数据类(DATA0、DATA1)、握手类(ACK、NAK、STALL)、特殊类(PRE、ERR)。原始资料的表格里列了主要几种,我按标准补全成一张完整表,写解码脚本时可以直接查:
| PID 名 | PID[3:0] | 类别 | 用途 |
|---|---|---|---|
| SETUP | 1101 | 令牌 | 启动一次控制传输的建立阶段 |
| OUT | 0001 | 令牌 | 主机向设备发送数据 |
| IN | 1001 | 令牌 | 主机向设备索取数据 |
| SOF | 0101 | 令牌 | 帧起始,带 11 位帧号 |
| DATA0 | 0011 | 数据 | 数据包,翻转序列的偶数位 |
| DATA1 | 1011 | 数据 | 数据包,翻转序列的奇数位 |
| ACK | 0010 | 握手 | 接收方确认收到 |
| NAK | 1010 | 握手 | 接收方忙,不是错误 |
| STALL | 1110 | 握手 | 端点出错或请求不被支持 |
| PRE | 1100 | 特殊 | 低速设备前导 |
令牌包里除 PID 外还有 7 位设备地址(ADDR)和 4 位端点号(ENDP),外加 5 位 CRC 校验,一共 3 个字节。设备地址 0 是默认地址,新插入的设备都用它;上电配置阶段主机给它分配 1 到 127 之间的一个地址。7 位地址能枚举 128 个设备,但 USB 规范限制一层最多挂 127 个,因为地址 0 要保留给未配置设备。
帧号是另一条线索。主机每隔一段时间广播一个 SOF 包,里面带 11 位帧号,从 0 开始递增,到 0x7FF 回绕。全速设备 1ms 一个帧,高速设备 125us 一个微帧。SOF 的意义在于给所有设备一个公共的时间基准,等时传输和中断传输的调度都靠它。抓包时看到 SOF 密密麻麻刷屏是正常的,它不参与任何具体的数据交互,只提供节拍。原文里说「只有 SOF 包含帧号」,这句话是对的,帧号是 SOF 独有的字段,其他包都没有。
3.2 事务三阶段:建立、数据、状态
USB 的一次完整交互叫事务(Transaction),分三个阶段:
建立阶段,主机发 SETUP 令牌包,后面跟一个 DATA0 数据包,内容是 8 字节的请求结构,设备收到后回 ACK。这个阶段永远用 DATA0,不做翻转。
数据阶段,方向由请求决定。如果主机要读数据,就发 IN 令牌,设备回 DATA0 或 DATA1,主机回 ACK;如果主机要写数据,就发 OUT 令牌,然后送 DATA0/DATA1,设备回 ACK 或 NAK。数据阶段可以是多个包,每个包的 DATA0/DATA1 交替翻转,这个机制是为了处理重传:主机发出一个包没收到 ACK,重发时必须用同一个 PID,设备靠 PID 判断这是重传而不是新数据,从而避免重复写入。
状态阶段,是控制传输的收尾。主机写数据时,状态阶段方向反过来,主机发 IN 令牌,设备回一个长度为 0 的 DATA1 包,表示「我收完了,没问题」;主机读数据时,状态阶段主机发 OUT 令牌,送一个空的 DATA1 包。这个空包就是原文里说的「空包代表回复」。状态阶段的 PID 固定是 DATA1,目的是跟前面的数据阶段区分开。
提示:调试时如果看到状态阶段回了 STALL,说明设备明确拒绝了这次请求,这时候别去查时序,去查请求参数对不对。
这里顺手说一个常被忽略的点:NAK 不等于出错。设备返回 NAK,意思是「我现在忙,等会儿再来」,主机应该重试而不是放弃。很多自制 Host 代码在收到 NAK 时直接报错退出,结果设备稍微慢一点就通信失败,表现时好时坏,特别像玄学。正确做法是设一个重试次数上限,比如 100 次或者 500ms 超时,超了再报错。
3.3 完整枚举七步:0 地址、SetAddress 和描述符
现在把原始资料里记录的收发数据摊开看。插入检测和复位完成后,主机发的第一个包:
SETUP: 80 06 00 01 00 00 04 00这是 8 字节的请求结构,逐字节拆:
| 偏移 | 值 | 含义 |
|---|---|---|
| 0 | 0x80 | bmRequestType:方向为设备到主机,类型为标准,接收者为设备 |
| 1 | 0x06 | bRequest:GET_DESCRIPTOR |
| 2-3 | 0x0100 | wValue:高字节 0x01 表示设备描述符,低字节 0x00 是索引 |
| 4-5 | 0x0000 | wIndex:设备描述符不用这个字段 |
| 6-7 | 0x0040 | wLength:请求 64 字节 |
设备回的 IN 数据包:
12 01 00 02 00 00 00 08只有 8 个字节。为什么请求了 64 字节却只回 8 个?因为设备此刻还在 0 地址的默认状态,USB 规范规定这个状态下设备描述符的第一包最多回 8 字节,主机必须先拿到这 8 字节,从中读出bMaxPacketSize0(这里是 0x08,即端点 0 最大包长 8 字节),才能知道后面一次能传多少。这 8 个字节拆开是:12是描述符长度 18,01是描述符类型(设备描述符),00 02是 USB 版本 2.00,00是设备类,00是子类,00是协议,08是端点 0 最大包长。
接下来是设地址。主机发 OUT 数据包:
00 05 12 00 00 00 00 0000是 bmRequestType,方向主机到设备、标准、设备;05是 SET_ADDRESS;12 00是 wValue,低字节 0x12 就是要分配的地址 18;wIndex 和 wLength 都是 0。设备回一个长度为 0 的 IN 包表示应答。这一步之后,设备地址从 0 变成 18,后续所有通信都要用新地址。
然后是第二次获取设备描述符,这次请求 18 字节:
SETUP: 80 06 00 01 00 00 12 00 IN: 12 01 00 02 00 00 00 08 03 04 01 60 00 06 01 02 03 01这 18 个字节信息量很大:03 04是 bcdDevice 版本号,01是厂商字符串索引,02是产品字符串索引,03是序列号字符串索引,01是配置数量。往前的00 02是 USB 2.00,00是设备类,00是子类,00是协议,08是端点 0 包长。再往后是60 00和06 01——注意字节序,小端存储,所以 VID 是 0x0403、PID 是 0x6001。
这里有个原始资料里没展开、但实际很关键的点:设备类字段是 0x00,不是 0x02。原文说「01 代表设备描述符,0200 代表设备为 CDC 类」,这个说法在字节对应上有点串。实际情况是,很多串口转 USB 芯片把设备类放在接口描述符里,设备描述符的 bDeviceClass 写 0x00,表示「类信息在接口层定义」。所以判断一个设备是不是 CDC,不能只看设备描述符,得看配置描述符里的接口描述符。这是新手最容易翻车的地方之一,抓包看到 bDeviceClass = 0x00 就以为不是 CDC 类,直接把代码分支走错。
再往后是获取配置描述符,主机发80 06 00 02 00 00 FF FF,这次 wLength 是 0xFFFF,意思是「你有多少给我多少」。设备回的是一串多包数据,包含配置描述符、接口描述符、端点描述符。原始资料里解析出来的结果是:配置描述符里最大功率 44mA,1 个接口,接口下 2 个端点,IN 端点地址 0x81,最大包 64 字节,OUT 端点地址 0x02,最大包 64 字节,传输类型都是批量(Bulk)。
最后一步是 SET_CONFIGURATION,把配置值写进去,设备进入配置状态,端点使能,通信链路正式建立。到这一步,主机才算真正「认下」了这个设备。
把整个流程整理成步骤表:
| 步骤 | 主机动作 | 设备地址 | 目的 |
|---|---|---|---|
| 1 | 总线复位 | 0 | 设备进入默认状态 |
| 2 | GET_DESCRIPTOR 请求 64 字节 | 0 | 拿 8 字节,读端点 0 包长 |
| 3 | 总线复位 | 0 | 重新同步 |
| 4 | SET_ADDRESS 分配 0x12 | 0 → 18 | 分配唯一地址 |
| 5 | GET_DESCRIPTOR 请求 18 字节 | 18 | 拿完整设备描述符 |
| 6 | GET_CONFIGURATION 请求 9 字节 | 18 | 先拿配置描述符头部,读总长度 |
| 7 | GET_CONFIGURATION 请求全量 | 18 | 拿接口和端点描述符 |
| 8 | SET_CONFIGURATION | 18 | 激活配置,端点可用 |
第 6 步为什么要分两次?因为配置描述符的总长度字段在描述符的第三个字节,但主机事先不知道有多长,所以先请求 9 字节(配置描述符本身的长度),读出wTotalLength之后再按这个长度请求全量。原始资料里直接用了FF FF一次拿全,这在实际工程里也能工作,但如果设备返回的数据超过主机缓冲区,就会溢出。稳妥做法还是两次。
写解码代码时,这套描述符结构直接定义成紧凑结构体最省事,注意加 packed,否则编译器会插对齐字节:
#include <stdint.h> typedef struct __attribute__((packed)) { uint8_t bLength; /* 描述符长度,设备描述符固定 18 */ uint8_t bDescriptorType; /* 0x01 = 设备描述符 */ uint16_t bcdUSB; /* USB 版本,小端 */ uint8_t bDeviceClass; /* 0x00 表示类信息在接口层 */ uint8_t bDeviceSubClass; uint8_t bDeviceProtocol; uint8_t bMaxPacketSize0; /* 端点 0 最大包长,常见 8/16/32/64 */ uint16_t idVendor; uint16_t idProduct; uint16_t bcdDevice; uint8_t iManufacturer; uint8_t iProduct; uint8_t iSerialNumber; uint8_t bNumConfigurations; } usb_device_desc_t; typedef struct __attribute__((packed)) { uint8_t bLength; uint8_t bDescriptorType; /* 0x02 = 配置描述符 */ uint16_t wTotalLength; /* 配置+接口+端点描述符总长度 */ uint8_t bNumInterfaces; uint8_t bConfigurationValue; uint8_t iConfiguration; uint8_t bmAttributes; uint8_t bMaxPower; /* 单位 2mA,22 即 44mA */ } usb_config_desc_t; static void parse_device_desc(const usb_device_desc_t *d) { /* 打印时按小端解释 16 位字段,别直接输出结构体原始内存 */ printf("bcdUSB=%04X class=%02X vid=%04X pid=%04X ep0=%u\r\n", d->bcdUSB, d->bDeviceClass, d->idVendor, d->idProduct, d->bMaxPacketSize0); } static void parse_config_desc(const usb_config_desc_t *c) { printf("total=%u ifs=%u cfg=%u power=%umA\r\n", c->wTotalLength, c->bNumInterfaces, c->bConfigurationValue, c->bMaxPower * 2); }两个结构体的字段顺序完全按 USB 规范定义,bMaxPacketSize0决定了端点 0 一次能传多少,wTotalLength决定了后续要请求多少字节。bMaxPower的单位是 2mA,所以 44mA 对应的原始值是 22,打印时乘 2。用 packed 是必须的,因为 USB 描述符是字节紧密排列的,编译器默认按 4 字节对齐会把结构体撑大,直接按结构体指针解析缓冲区就会错位,表现是字段值全部串位,看起来像乱码。
3.4 标准请求结构:8 个字节里每位都有用
请求结构是控制传输的核心,8 个字节,字段固定:
| 字节偏移 | 字段 | 宽度 | 说明 |
|---|---|---|---|
| 0 | bmRequestType | 1 | D7 方向,D6-D5 类型,D4-D0 接收者 |
| 1 | bRequest | 1 | 请求代码 |
| 2-3 | wValue | 2 | 由请求定义 |
| 4-5 | wIndex | 2 | 由请求定义 |
| 6-7 | wLength | 2 | 数据阶段传输字节数 |
bmRequestType 的位域是排错时最常查的一处。D7 为 1 表示数据从设备到主机,为 0 表示主机到设备;D6-D5 中 00 是标准请求,01 是类请求,10 是厂商请求;D4-D0 中 0 是设备、1 是接口、2 是端点。所以0x80是「设备到主机 + 标准 + 设备」,0x00是「主机到设备 + 标准 + 设备」,0xA1是「设备到主机 + 类 + 接口」,0x21是「主机到设备 + 类 + 接口」。后面这两个在 CDC 设备上会频繁出现。
常用的标准请求码:GET_STATUS 0x00、SET_FEATURE 0x03、SET_ADDRESS 0x05、GET_DESCRIPTOR 0x06、GET_CONFIGURATION 0x08、SET_CONFIGURATION 0x09。原始资料里提到的「常用 05 06 08」,就是指设置地址、获取描述符、获取配置这三个。
对 CDC 类设备,还要额外记得几个类请求:SET_LINE_CODING 0x20、GET_LINE_CODING 0x21、SET_CONTROL_LINE_STATE 0x22。这几个请求的 bmRequestType 是 0x21 和 0xA1,即类请求、接口接收者。SET_LINE_CODING 带 7 字节数据:4 字节波特率(小端)、1 字节停止位、1 字节校验方式、1 字节数据位。这些都是后面驱动阶段真正让数据流起来的开关,光枚举成功不代表设备会往外吐数据。
4. CH32V307 上的 CDC Host 驱动:端点配置与收发缓冲
协议看明白,落到芯片上还有一层。这一章讲 CH32V307 做 Host 时的平台特性、CDC 描述符怎么解析、以及收发的代码骨架。原始资料用的验证方式很朴素——串口助手 A 发、助手 B 收,再反过来,能通就算成功——这个方法虽然土,但对第一次调 Host 的人来说是最快的正反馈。
4.1 平台与工具分工
CH32V307 是 RISC-V 内核,自带两路 USB:一路全速(FS),一路高速(HS)。高速那路内部集成 PHY,支持 OTG,也就是同一组引脚既能当设备也能当主机。做 Host 实验时这一点很省事,不用外挂 PHY 芯片,直接把设备的 D+/D- 接上来就行,VBUS 按需供电。
工具链的分工要理清楚,否则出问题时不知道该看哪一层:
| 工具 | 层次 | 用途 |
|---|---|---|
| 逻辑分析仪 | 物理层/时序层 | 看插入、复位、SOP/EOP、包间隔 |
| 硬件抓包软件 | 链路层 | 硬件解析 PID、地址、端点、CRC |
| Bus Hound | 协议层(PC 侧) | 在 PC 上抓同一设备的完整枚举与数据流 |
| 串口助手 | 应用层 | 配置参数、发数据、看回显 |
这里最有价值的对照是 Bus Hound 和硬件抓包。同一个设备先插在 PC 上,用 Bus Hound 录一遍完整枚举,把每个 SETUP 包的 8 个字节、每个 IN/OUT 的方向和长度都记下来;然后把设备插到 CH32V307 上,用硬件抓包录一遍。两份记录逐包对比,如果 MCU 发的请求跟 PC 发的不一样,问题就在这里,不用去猜。这个对照法我用得最多,尤其是设备厂商文档写得含糊的时候,抄 PC 的行为准没错。
原始资料里提到用串口工具做配置和读写,这里的串口工具其实扮演两个角色:一方面通过调试串口看 MCU 的打印日志,另一方面作为 CDC 设备的对端,验证数据能不能双向流动。判断标准就一条:A 发 B 能收,B 发 A 能收,两个方向都通才算链路真正建立。单向通通常是端点方向搞反或者只初始化了一个端点。
4.2 CDC 描述符解析:别按教科书的三接口写死
标准 CDC ACM 设备的描述符结构是:一个配置描述符,下面挂两个接口——通信接口(bInterfaceClass = 0x0A,子类 0x02 抽象控制模型,协议 0x01 AT 命令),带一个中断 IN 端点用于上报串口状态;数据接口(bInterfaceClass = 0x0A 或 0xFF),带一对批量端点,一个 IN 一个 OUT。总共两个接口、三个端点。
但实际市面上的串口转 USB 芯片,很多是简化实现:只有一个接口,两个批量端点,没有中断端点,也不响应 CDC 类请求。原始资料里解析出来的就是这种:1 个 interface,端点 0x81 IN、0x02 OUT,都是 Bulk,最大包 64 字节,最大功率 44mA。这种设备严格说不是完整 CDC ACM,但数据通路跟 CDC 数据接口一样,代码里只要不去等中断端点,照样能通。
所以写驱动的第一原则是:不要按教科书结构写死索引,而是遍历配置描述符后面的所有从属描述符,按 bDescriptorType 和 bInterfaceClass 动态识别。配置描述符后面紧跟的字节流是这样的结构:配置描述符(9 字节)→ 接口描述符(9 字节)→ 端点描述符(7 字节)→ 端点描述符(7 字节)→ …… 每个描述符第一个字节是自身长度,靠这个长度往后跳,直到跳完 wTotalLength。
端点地址的编码也要注意:bit7 是方向,1 表示 IN(设备到主机),0 表示 OUT(主机到设备);低 4 位是端点号。所以 0x81 是端点 1、IN 方向,0x02 是端点 2、OUT 方向。写代码时千万别把 0x81 当成「端点 81」,也别把方向位和端点号混在一起算。端点 0 是控制端点,双向复用,不参与这个编码规则。
还有一个实际会遇到的坑:批量端点的最大包长。全速设备 Bulk 端点最大 64 字节,高速可以到 512 字节。原始资料里是 64 字节,意味着一次最多传 64 字节,超过就要分包,而且最后一个短包(小于 64 字节)用来标识传输结束。如果接收端按「收满缓冲区才算一帧」的逻辑写,遇到小于 64 字节的包就会一直等,表现是数据卡住不吐。这条在第五章还会展开。
4.3 收发骨架:初始化、枚举、读写
下面这段是流程骨架,函数名按各家 Host 库的前缀会有差异,但顺序是通用的。重点看注释里的参数含义和等待时间:
/* 流程骨架,具体 API 前缀以所用 Host 库为准 */ void cdc_host_init(void) { /* 1. 时钟与 PHY:HS 口内部 PHY 需要单独使能并等待稳定 */ usb_hs_clock_enable(); usb_phy_init(); delay_ms(10); /* PHY 稳定时间,别省 */ /* 2. 切到 Host 模式,使能 VBUS 供电(如果是总线供电设备) */ usb_set_mode(USB_HOST); usb_vbus_on(); delay_ms(50); /* 设备上电到 D+ 拉高,留足时间 */ } /* 枚举结果 */ typedef struct { uint8_t dev_addr; /* 分配到的地址,1~127 */ uint8_t ep_in; /* IN 端点号,例如 1 */ uint8_t ep_out; /* OUT 端点号,例如 2 */ uint16_t mps_in; /* IN 端点最大包长,64 */ uint16_t mps_out; /* OUT 端点最大包长,64 */ } cdc_dev_t; int cdc_host_send(cdc_dev_t *d, const uint8_t *buf, uint16_t len, uint32_t timeout_ms) { uint16_t sent = 0; while (sent < len) { uint16_t chunk = len - sent; if (chunk > d->mps_out) chunk = d->mps_out; /* 按 MPS 分包 */ int rc = usb_host_out(d->dev_addr, d->ep_out, buf + sent, chunk, timeout_ms); if (rc == USB_NAK) { continue; /* NAK 是忙,重试,不是错误 */ } if (rc != USB_OK) { return rc; /* STALL 或超时,向上报 */ } sent += chunk; } return 0; } int cdc_host_recv(cdc_dev_t *d, uint8_t *buf, uint16_t cap, uint32_t timeout_ms) { int n = usb_host_in(d->dev_addr, d->ep_in, buf, cap, timeout_ms); if (n < 0) return n; /* 负值表示 NAK/STALL/超时 */ return n; /* 返回实际字节数,可能小于 MPS */ }cdc_host_init里两个延时不是随便写的:PHY 上电到时钟稳定需要时间,太早访问寄存器会读到无效值;VBUS 打开后设备内部芯片要启动、D+ 上拉要建立,50ms 是个保守值,实测有些芯片 10ms 就够了,但多等不亏。
cdc_host_send的关键在分包。chunk每次取剩余长度和mps_out的较小值,保证每个包不超过端点能力。USB_NAK时continue重试,这是批量传输的正常行为;如果返回 STALL 或超时,直接返回错误码,不要傻等。这个函数没有做「零长度包」处理,如果len恰好是 MPS 的整数倍,规范要求再发一个零长度包表示结束,否则对端可能一直在等后续数据。这个细节在原始资料的验证方法里体现不出来(因为发的都是短数据),但量产时会咬人。
cdc_host_recv只取一个包。实际使用时要放在轮询或者中断回调里反复调用,靠返回值和端点状态判断有没有数据。返回值小于mps_in说明这是一个短包,可以作为一帧的结束标志;等于mps_in说明可能还有后续包,继续读。
4.4 双向验证:A 发 B 收怎么才算真通
验证方式原始资料写得很直接:串口助手 A 发数据,助手 B 能收到;B 发,A 能收。这里 A 是跑在 PC 上的串口工具,通过 USB 转串口线连到 CH32V307 的某个串口;B 是接在 CH32V307 的 USB Host 口上的 CDC 设备,对端跑另一个串口工具。
这个拓扑里 CH32V307 干的是转发:从一个 UART 收数据,通过 USB Host 发给 CDC 设备;从 CDC 设备收数据,通过 UART 发出去。所以代码里要有两个方向的搬运。判断成功不要只看「有没有收到字符」,要看三个层面:
第一,枚举是否完整走完。地址分配成功、配置设置成功、两个端点都使能,任何一步失败后面都不用谈。第二,单向小数据能不能通。发 1 个字节,对端能收到,说明端点方向、地址、PID 翻转都对。第三,双向连续数据能不能通。发一串递增字节,对端校验内容,再反向发回来,校验一致。这一步能暴露缓冲区溢出、分包错误、DATA0/DATA1 翻转错误。
我一般会在第三步加一个压力测试:发 4096 字节的连续数据,对端统计收到的字节数和校验和,错一个字节就报。批量传输本身有 CRC16 保护,链路层不会出错,如果内容错了,一定是软件层的缓冲区管理问题,比如读指针没更新、覆盖写、或者把上一个包的内容当成本包的。
提示:验证时把 Bus Hound 或硬件抓包留着,看到数据不对先看包,别先改代码。包是对的,问题在你自己的缓冲区;包是错的,问题在端点配置。
5. 避坑与排查:枚举失败时的五个现场
前面几章讲的都是「应该怎样」,这一章讲「不怎样的时候长什么样」。这五条是我在实际调试里反复撞上的,每一条都按现象、原因、解决三段写,遇到问题可以对着症状找。
5.1 现象:设备插上毫无反应,抓包也没有任何电平变化
原因排查顺序是供电、接线、上拉。先量 VBUS,如果设备是总线供电的,主机没开 VBUS 或者 VBUS 开关没使能,设备根本不工作,D+ 自然不会拉高。再量 D+ 和 D- 对地电压,正常插上后应该有一根接近 3.3V。如果两根都是 0,看是不是 D+/D- 接反了——有些座子的定义和排线颜色不一致,肉眼看不出来。最后查上拉电阻,主机侧不该有上拉,设备侧必须有 1.5k;有的板子为了「兼容」在主机侧也焊了上拉,结果两根线都被拉住,插入检测直接失效。
解决方法是把 VBUS 控制单独做成一个可测的点,代码里初始化时先开 VBUS 再延时,别和 USB 控制器初始化绑死。接线用万用表蜂鸣档从座子量到芯片引脚,不要信丝印。上拉电阻用示波器看插入瞬间的上升沿,正常应该是一个干净的跳变,如果看到缓慢爬升的斜坡,说明上拉太弱或者线太长,容性负载压住了。
5.2 现象:卡在第一次复位循环,日志反复打印「复位中」
原因是第一次 GET_DESCRIPTOR 请求的 wLength 给了 64,主机按 64 字节等数据,但设备在默认地址状态最多只回 8 字节。如果主机的接收逻辑是「等满 wLength 才算完成」,就会一直等不到,超时后重新复位,如此循环。原始资料里记录的04 00其实就是 0x0040,这个值本身没错,错在接收端的判断条件。
解决方法有两个方向:一是接收端改成「收到短包即结束」,8 字节小于端点 0 最大包长,天然就是短包,收到就收工;二是把 wLength 直接改成 8,先拿 8 字节,再发第二次请求拿 18 字节。我倾向前者,因为后面拿配置描述符时也会遇到同样的问题(wLength 给 0xFFFF,设备实际返回的长度小于请求值),统一按短包判断结束更省事。改完之后如果还循环,就看设备有没有回 STALL,回了就是请求本身不被支持,检查 bmRequestType 和 bRequest。
5.3 现象:SET_ADDRESS 之后所有包都超时,设备像消失了
原因是地址切换的时机不对。SET_ADDRESS 的状态阶段完成后,设备并不是立刻就能用新地址通信,规范要求设备在收到这个请求后有 2ms 的恢复时间,这段时间内它不响应任何包。如果主机发完状态阶段就马上用新地址发下一个请求,设备会直接不理,主机超时后可能又复位,导致地址又回到 0,看起来像随机失败。
解决方法是在 SET_ADDRESS 的状态阶段 ACK 之后,强制延时 2ms 以上再用新地址发请求。这个延时在有些 Host 库里已经内置了,有些没有,需要自己加。另一个可能是地址本身有问题:分配地址时 wValue 的低字节才是地址,高字节必须是 0,写成0x0012是对的,写成0x1200就变成地址 0 了,设备会认为主机让它回到默认地址,后面全部错乱。
5.4 现象:枚举全部成功,但一个字节数据都收不到
原因通常是设备需要「握手信号」才肯开门。很多 CDC 设备(尤其带 DTR/RTS 控制的)在上位机没发 SET_CONTROL_LINE_STATE 之前,数据通路是关着的,它会把数据缓存住不往外发。PC 上的串口工具打开串口时会自动发这个请求,所以插电脑上好用;自己写的 Host 不发,就永远是静默。另一个可能是端点方向搞反:把 0x81 当 OUT 用,或者把 0x02 当 IN 用,主机发出去的包设备不认,回的包主机也不认,两边都超时。
解决方法是在 SET_CONFIGURATION 之后,紧接着发一次 SET_CONTROL_LINE_STATE,bmRequestType 用 0x21,wValue 置 DTR 和 RTS 位(0x0003),wIndex 用接口号,wLength 为 0。然后再发 SET_LINE_CODING,把波特率、数据位、停止位、校验方式设成设备支持的组合。这两个请求发完,再看数据通路。端点方向的问题用抓包软件直接看,包头上会标 IN 还是 OUT,跟代码里的端点号对一遍,一分钟就能确认。
5.5 现象:小数据能通,一发大文件就丢包或者卡死
原因是分包和 PID 翻转没处理干净。批量传输一次最多传端点最大包长,超过要拆成多个事务,每个事务的 DATA0/DATA1 交替翻转。如果代码里固定用 DATA0 发所有包,设备收到第二个包时会认为这是重传,直接丢弃或者回 ACK 但内容不更新,表现就是后面的数据全部丢失。另一种表现是最后一个包正好等于最大包长,主机没发零长度包,设备认为传输还没结束,一直等下一個包,接收缓冲区不提交,看起来就是卡住。
解决方法是把 PID 翻转做成端点状态机的一部分,每个成功的事务之后翻转一次,遇到重传保持原 PID 不变。零长度包的处理要在发送函数里加判断:如果len % mps == 0,循环结束后再发一个长度为 0 的包。接收端则要按「短包结束」判断,收到小于最大包长的包才提交缓冲区,收到等于最大包长的包继续累积。这两条加上去,大文件传输基本就稳了。
6. 把 Bus Hound 的日志当基线:一套可重复的回归方法
调通一次不算本事,换一台设备、换一版固件还能通才算。我现在的做法是:每接一款新的 CDC 设备,先在 PC 上用 Bus Hound 录一份完整枚举日志存下来,命名为设备型号加日期,这份日志就是基线。之后在 MCU 上调试时,把硬件抓包的结果跟基线逐包对比,重点看三处:GET_DESCRIPTOR 请求的 wLength 是不是一致、SET_ADDRESS 分配的地址有没有冲突、SET_CONFIGURATION 之前有没有额外的类请求。这三处对上了,剩下的基本是时序问题。
回归验证我固定跑三个场景,缺一个都不敢说这版代码能用:
| 场景 | 操作 | 观察点 |
|---|---|---|
| 冷启动 | 设备先插好,MCU 再上电 | 上电时序中 VBUS 与 D+ 的先后 |
| 热插拔 | MCU 运行中反复插拔 20 次 | 每次都能重新枚举,地址不残留 |
| 异常恢复 | 传输中拔掉设备再插回 | 端点状态清零,不进入死循环 |
热插拔这一项最容易出问题。很多代码在设备拔出后没有清除端点状态和地址,设备再插回来时主机还以为老设备在,用旧地址发请求,全部超时。正确做法是在端口状态变化中断里,检测到断开就把设备结构体整个复位,地址归零,端点禁用,等下一次插入重新走枚举。这个逻辑写起来就十几行,但不写的话现场表现是「插第一次好用,插第二次就不认」,用户会以为你东西坏了。
还有一个小技巧:把枚举过程的每一步打印成一行日志,带时间戳和结果码,格式统一。出问题时不用重新复现,直接看日志就知道卡在第几步。日志里我最看重的是 SET_ADDRESS 和 SET_CONFIGURATION 这两行的返回值,前者失败通常是时序,后者失败通常是描述符解析错了。
说个自己的教训。早期调一款转串口设备,枚举日志一切正常,就是收不到数据,折腾了一下午,最后发现是没发 SET_CONTROL_LINE_STATE,设备在等 DTR。从那以后我每次接新设备,都强制走一遍「枚举 → 类请求 → 收发 → 拔插」四步,一步不跳,哪怕看起来没必要。这套流程后来帮我省下的时间,远比当初那一下午多。希望帮到你。
本文还有配套的精品资源,点击获取