USB 基础
第 0 章 课程导论
0.1 这门课教什么
这门课带你从零开始,完整地理解并跑通一个 USB 虚拟串口(CDC)项目。学完之后,你应该能够:
- 讲清 USB 的基本架构(主机、设备、端点、管道、四种传输类型)
- 读懂 USB 包、事务、控制传输的工作机制
- 完整描述枚举流程(从插线到设备被识别)
- 逐字节读懂 CDC 描述符集合(67 字节)
- 理解 CDC 类请求(SET_LINE_CODING 等)的处理
- 明白虚拟串口数据通路的双向流转
- 知道遇到问题去哪里查(规范文档、芯片手册)
0.2 前置知识
- 会 C 语言基础(变量、数组、结构体、函数)
- 了解 STM32 基本外设(GPIO、USART)更好,不强求
- 不需要任何 USB 基础——这门课就是从零讲起
0.3 课程使用的硬件与软件
| 项目 | 内容 |
|---|---|
| 主控 MCU | STM32F103RB(Cortex-M3,72MHz) |
| USB 芯片 | PDIUSBD12(并行接口 USB 1.1 设备控制器) |
| 开发环境 | Keil MDK(ARMCC V5.06) |
| 工程文件 | mouse/(USB 鼠标,入门案例)、serial/(USB 虚拟串口,课程主线) |
| 串口调试 | USART2,115200,8N1 |
为什么用 D12 而不是 STM32 内置 USB?
因为 D12 把 USB 的物理层和协议层(SIE)都做进了芯片,MCU 只需要通过"命令+数据"和它打交道。这样你可以把精力集中在理解 USB 协议本身,而不是淹没在寄存器细节里。学懂 D12 方案后,再看 STM32 内置 USB 会轻松很多。
0.4 资料地图(全部在你手边)
| 资料 | 位置 | 用途 |
|---|---|---|
| 教程 PPT | resource/手把手教你玩USB开发.ppt |
中文浓缩讲义(38 页) |
| D12 数据手册 | resource/PIDUSBD12数据手册.pdf |
芯片命令、端点、时序的权威来源 |
| HID 规范 | resource/hid1_11.pdf |
HID 类规范(鼠标项目用) |
| CDC 规范 | resource/usbcdc11.pdf |
CDC 类规范的权威来源(本课程主线) |
| 鼠标工程 | mouse/ |
第一个 USB 设备案例(打基础) |
| 串口工程 | serial/ |
本课程主线(CDC 虚拟串口) |
0.5 最重要的学习方法:查规范,不背规范
这门课反复强调一个方法:遇到不懂的字段、位定义、请求,第一反应不是问人,而是查"定义它的规范文档"。
规范是"字典",不是"课本"——没有人背下 USB 规范的全部内容。正确姿势是:
- 判断问题属于哪一层(USB 通用规范 / 类规范 / 芯片手册)
- 看字段名猜测语义(
b=字节、w=2字节、bm=位图、bcd=BCD 编码) - 在规范目录里定位章节
- 读字段定义或位定义表
- 回到代码验证
本课程的 CDC 部分,所有"为什么是这个值"的答案都能在 resource/usbcdc11.pdf 里找到。
0.6 章节导航
| 章节 | 内容 | 对应项目阶段 |
|---|---|---|
| 第 1 章 | USB 基础概念 | 概念准备 |
| 第 2 章 | USB 包与事务 | 概念准备 |
| 第 3 章 | 枚举流程 | 项目骨架 |
| 第 4 章 | PDIUSBD12 芯片与驱动 | 底层驱动 |
| 第 5 章 | CDC 描述符详解 | serial 项目描述符 |
| 第 6 章 | CDC 类请求 | serial 项目 usb.c |
| 第 7 章 | 数据通路 | serial 项目数据流 |
| 第 8 章 | 实践与实验 | 动手环节 |
| 附录 | 术语表 / FAQ / 自测答案 | 查阅 |
建议按顺序学习,每章末尾有自测题,确认理解后再进入下一章。
第 1 章 USB 基础概念
1.1 学习目标
- 理解 USB 的主从结构(一切传输由主机发起)
- 理解端点(Endpoint)和管道(Pipe)
- 掌握 IN/OUT 方向(以主机为参照)
- 掌握四种传输类型
- 分清"接口(功能)"和"配置(模式)"
- 理解类、子类、协议三层身份
1.2 USB 是什么:主从结构
USB(Universal Serial Bus,通用串行总线)是一个主从结构的通信架构:
- 主机(Host):通常是电脑,是总线的"老板"
- 设备(Device):鼠标、键盘、串口、U 盘等,是"员工"
- 集线器(Hub):扩展口,让一个主机口接多个设备
最重要的规则:所有传输都由主机发起,设备永远被动响应。设备不能"主动说话"。
1.3 方向:IN 和 OUT(以主机为参照)
这是初学者最容易绕晕的点,记住一句话:
IN = 设备 → 主机;OUT = 主机 → 设备。
方向永远以主机视角定义:
| 方向 | 数据流向 | 举例 |
|---|---|---|
| IN | 设备 → 主机 | 鼠标报告发给电脑 |
| OUT | 主机 → 设备 | 电脑往虚拟串口发数据 |
1.4 端点(Endpoint):带地址的缓冲区
端点是 USB 设备内部、有真实硬件支撑的"数据通道终点"——具体表现为控制器里的缓冲区 + 寄存器。
每个端点有属性:
- 端点号:0~15(4 位),设备级分配
- 方向:IN 或 OUT(各是独立端点)
- 传输类型:控制/中断/批量/同步
- 最大包大小
- 轮询间隔(中断端点)
几个关键规则:
- 端点地址 = 编号 + 方向。
0x81= 1 号端点 IN,0x01= 1 号端点 OUT,是两个独立端点。 - 端点 0 特殊:双向、专用于控制传输,每个设备必须有。它不区分 IN/OUT 身份,因为控制传输本来就要一来一回。
- 设备最多 32 个端点(16 IN + 16 OUT)。
- 一个接口可以挂多个端点(比如 U 盘的批量 IN + 批量 OUT)。
类比:端点像快递公司的"窗口"。设备地址是收件人,端点号是窗口号,方向是这个窗口是收件还是发件。主机每发起一次事务,令牌包里就带着"设备地址 + 端点号 + 方向"。
1.5 管道(Pipe):主机与端点的数据通道
管道是主机与某个端点之间建立的数据通道。枚举完成后,主机和设备之间按"设备地址 + 端点号"建立管道,之后所有数据传输都通过管道进行。
1.6 四种传输类型
| 传输类型 | 特点 | 典型用途 | 端点属性码 |
|---|---|---|---|
| 控制 | 可靠、双向、用于配置 | 枚举、类请求 | 00 |
| 中断 | 可靠、低延迟、固定轮询 | 鼠标、键盘、CDC 通知 | 11 |
| 批量 | 可靠、大块、无时间保证 | U 盘、CDC 数据 | 10 |
| 同步(等时) | 无重传、保证带宽 | 音频、视频 | 01 |
本课程用到两种:
- 控制:端点 0,枚举和类请求全靠它
- 中断:EP1 IN,CDC 的"状态通知"管道
- 批量:EP2 IN/OUT,CDC 的"数据"管道
1.7 接口(Interface)= 功能,配置(Configuration)= 模式
这是 USB 描述符体系里最容易混淆的两个概念:
接口(功能):设备"能做什么"。一个接口对应一个可被操作系统独立驱动的功能单元。
- 鼠标 = 1 个接口
- 键盘+触控板 = 2 个接口
- USB 音箱(UAC)= 音频控制接口 + 音频流接口(2 个接口协作完成"播放")
- CDC 虚拟串口 = 通信接口(控制)+ 数据接口(传输),2 个接口
配置(模式):设备"以什么姿态工作"。同一硬件可以有多套配置,同一时刻只能处于一套。
- 经典例子:USB 无线网卡的"U 盘模式(装驱动)→ 网卡模式(工作)"
- 配置切换靠主机发
SET_CONFIGURATION(编号)
两者关系:配置是"容器",接口是"内容":
一句话记忆:接口管功能,配置管模式。
1.7.1 延伸:电流/功耗属于哪一层?
既然接口管功能、配置管模式,那"设备需要多大电流"属于哪一层?答案:配置层面。
因为电流字段(bMaxPower)在配置描述符里,接口描述符没有任何电流字段。bMaxPower 的规范定义:
"Maximum power consumption of the USB device from the bus in this specific configuration..."(设备在这个特定配置下从总线获得的最大电流)
关键词 "in this specific configuration"——电流是"每个配置"各自声明的属性。
假设一个场景:设备有两种工作状态,只有电流不同(比如低功耗 200mA / 高功耗 500mA),其他所有描述符(接口、端点)完全相同。能实现吗?
能,用两个配置实现:
设备描述符:bNumConfigurations = 2
配置 1(bConfigurationValue = 1) ← 低功耗档
├── bMaxPower = 100(200mA)
└── 接口/端点:与配置2完全相同
配置 2(bConfigurationValue = 2) ← 高功耗档
├── bMaxPower = 250(500mA)
└── 接口/端点:与配置1完全相同
主机枚举时读取两个配置,默认启用第一个,需要时用 SET_CONFIGURATION(2) 切换。
接口层面(和备用设置)为什么不行?
| 机制 | 有没有电流字段 | 能否表达电流差异 |
|---|---|---|
| 配置(bMaxPower) | 有 | 能 |
| 接口(bInterfaceClass 等) | 没有 | 不能 |
| 备用设置(bAlternateSetting) | 没有 | 不能(它只换端点组合) |
两个必须记住的修正:
- bMaxPower 是"电流上限承诺",不是实际功耗。设备承诺"在这个配置下最多取这么多电",主机拿它做电源预算。允许用得多 ≠ 真的用得多;但承诺了 200mA,就绝不能超 200mA。
- 配置 = 整套模式,电流只是其中一个属性。配置还包含接口组合、端点、供电方式(bmAttributes)等。"低电流配置"可以理解为"低功耗档位",但"模式"的范围比"电流"大。
现实工程补充:只为电流差异做两个配置并不常见——配置切换要主机参与(SET_CONFIGURATION),而且设备自行降功耗(挂起、降频)不需要通知主机。多配置的真正用武之地是"功能集合不同"(如 USB 网卡的 U 盘模式 → 网卡模式)。但协议上"只有电流不同、用双配置表达"完全可行。
相关电源规则:未配置状态取电 ≤100mA;总线供电设备挂起时 ≤2.5mA(见第 8 章 FAQ)。
1.8 类、子类、协议:三层身份
USB 用三层嵌套定义设备身份:
| 层级 | 回答的问题 | 例子(CDC) |
|---|---|---|
| 类(Class) | 哪一大类? | 0x02 通信类 |
| 子类(SubClass) | 哪种模型? | 0x02 ACM(抽象控制模型) |
| 协议(Protocol) | 按什么规则通信? | 0x01 AT 命令集 |
两层声明位置:
- 设备描述符(设备级):声明整台设备的类
- 接口描述符(接口级):声明每个接口的类/子类/协议(驱动匹配的真正依据)
规则:如果设备描述符 bDeviceClass 为 0,则子类/协议必须为 0(类留给接口各自声明,如 HID);如果非 0,取值由该类规范规定(如 CDC 规定设备级写 0x02/0x00/0x00)。
怎么知道这些规则?查规范。 设备描述符字段定义在 USB 规范第 9 章;CDC 的类/子类/协议代码表在
usbcdc11.pdf第 3 章。
1.9 两级声明详解:设备级 vs 接口级
类/子类/协议可以在两个地方声明,很多人在这里犯迷糊,单独开一节讲透。
1.9.1 两级的本质区别
| 设备描述符(设备级) | 接口描述符(接口级) | |
|---|---|---|
| 声明对象 | 整台设备 | 某个接口(功能单元) |
| 回答的问题 | "这台设备整体上属于哪类?" | "这个功能是什么、用什么模型、按什么协议工作?" |
| 谁真正用来加载驱动 | 提示信息 | 决定性依据(Windows 按接口找驱动) |
| 何时有意义 | 所有接口属于同一类时 | 永远有意义 |
1.9.2 USB 规范的规则(USB 2.0 第 9.6.2 节)
规则 1:bDeviceClass = 0 时——类由各接口自己声明,设备级子类/协议必须为 0。
规则 2:bDeviceClass ≠ 0 时——设备的所有接口必须都属于这个类;设备级子类/协议的取值由该类规范规定。
规则 3:驱动匹配看接口级。设备级只是"预期提示",Windows 实际是按接口描述符的 bInterfaceClass/SubClass/Protocol 找驱动。
1.9.3 赋值参考依据(4 步)
- 查 USB 通用规范(9.6.2):确定"何时必须为 0"的规则
- 查类代码表(USB-IF 官方):确定大类代码(HID=0x03、CDC=0x02、Mass Storage=0x08、CDC Data=0x0A、Misc=0xEF)
- 查具体类规范:确定子类/协议代码 + 该类对设备级写法的规定(如 CDC 规范规定设备级用
0x02/0x00/0x00) - 回代码验证:设备级和接口级不冲突
1.9.4 六个例子
例 1:HID 鼠标(mouse 项目)——设备级必须 0
设备描述符:0x00 / 0x00 / 0x00
接口描述符:0x03(HID)/ 0x01(Boot)/ 0x02(鼠标)
因为 HID 设备可能混合多类接口,HID 规范强制设备级写 0,类留给接口声明。
例 2:CDC 虚拟串口(serial 项目)——设备级写 CDC
设备描述符:0x02(CDC)/ 0x00 / 0x00
接口0(通信):0x02(通信类)/ 0x02(ACM)/ 0x01(AT命令)
接口1(数据):0x0A(CDC Data)/ 0x00 / 0x00
所有接口都属于通信类大族(0x02 + 0x0A),所以 CDC 规范规定设备级写 0x02/0/0 合法。
例 3:U 盘(Mass Storage)——两种写法都常见
写法A(最通用):设备 0x00/0/0
接口 0x08(Mass Storage)/ 0x06(SCSI)/ 0x50(Bulk-Only)
写法B(全机同类):设备 0x08/0x06/0x50
接口 0x08/0x06/0x50
写法 B 只在"所有接口都是存储类"时合法;实践中很多产品图省事直接写 0。
例 4:复合设备(键盘 + 触控板)——设备级必须 0
设备描述符:0x00 / 0x00 / 0x00
接口0(键盘):0x03 / 0x01 / 0x01
接口1(触控板):0x03 / 0x01 / 0x02
两个接口虽然都是 HID,但功能不同、各有驱动需求——设备级写 0,让每个接口独立声明。
例 5:混合设备(CDC 串口 + HID 按键)——设备级必须 0
设备描述符:0x00 / 0x00 / 0x00
接口0(CDC通信):0x02 / 0x02 / 0x01
接口1(CDC数据):0x0A / 0x00 / 0x00
接口2(HID): 0x03 / 0x00 / 0x00
接口类混合了通信类和 HID 类,违反规则 2,所以设备级只能写 0。
例 6:CDC 复合设备的另一种写法(进阶了解)
设备描述符:0xEF(杂项)/ 0x02(Common)/ 0x01(使用IAD)
当 CDC 需要和其他类组合时,USB-IF 定义了这个"杂项类 + Common 子类"的设备级写法,配合接口关联描述符(IAD)使用。本课程用不到,知道有这回事即可。
1.9.5 总结规律
| 场景 | 设备级写法 | 依据 |
|---|---|---|
| 单一 HID 功能 | 0/0/0 | HID 规范强制 |
| 单一 CDC 功能 | 0x02/0/0 或 0/0/0 | CDC 规范推荐前者,两者都合法 |
| 单一 Mass Storage | 0 或 0x08/... | 规范允许,惯例多写 0 |
| 复合/混合设备 | 必须 0/0/0 | USB 规范规则 2 |
| 厂商自定义 | 0xFF/... | 需要自写驱动 |
一句话记忆:接口级是"每个功能的精确身份证"(永远要填对);设备级是"整机的类声明"——接口功能单一且同类可以写,混合就必须写 0。拿不准就写 0,永远安全。
1.9.6 常见疑问:CDC 设备级写 0 会怎样?
完全可以,Windows 照样识别成 COM 口。 因为驱动匹配看接口描述符,设备级只是提示信息:
设备描述符:0x00/0x00/0x00 ← 提示信息:"类由接口自己声明"
接口0:0x02/0x02/0x01 ← Windows 真正看这里 → 识别为 CDC 通信接口
接口1:0x0A/0x00/0x00 ← 识别为 CDC 数据接口
| 设备级 0x02/0/0 | 设备级 0/0/0 | |
|---|---|---|
| 是否合法 | 合法(CDC 规范推荐) | 合法(USB 通用规范允许) |
| Windows 识别 | 正常 COM 口 | 正常 COM 口 |
| 通用性 | 只适用于"全机同类" | 任何设备都适用(万能安全写法) |
| 实际差异 | 给主机一个"整机是通信类"的预期 | 无预期,全靠接口 |
所以:写 0x02 更贴合 CDC 规范的推荐,写 0 是"万能安全写法"。真正决定设备身份的是接口描述符——设备级可以"懒",接口级必须"准"。
1.10 自测题
- IN 和 OUT 分别指哪个方向?以谁为参照?
0x81和0x01是什么关系?为什么是两个端点?- 四种传输类型分别是什么?CDC 数据用哪种、鼠标数据用哪种?
- 接口和配置的区别是什么?"USB 音箱 2 个接口"中的"2"指什么?
- 类/子类/协议分别回答什么问题?CDC 虚拟串口的三个值是什么?
- 设备级和接口级的类声明有什么区别?谁真正决定驱动加载?
- 什么情况下设备级必须写 0?CDC 虚拟串口设备级写 0 可以吗?
第 2 章 USB 包与事务
2.1 学习目标
- 理解"包"是 USB 总线上的最小传输单位
- 掌握三大类包:令牌、数据、握手
- 理解"setup 包"的两种含义
- 理解事务(SETUP/IN/OUT)的包序列
- 掌握控制传输的三阶段
- 分清 ACK(包级确认)和 ZLP(传输级完成信号)
2.2 包(Packet):最小传输单位
USB 总线上数据不是连续流动的,而是被切成一段段的包来传输。
每个包的物理组成:同步字段(SYNC) + 包标识符(PID) + 数据区(按类型可选) + 校验(CRC) + 结束(EOP)。
其中 PID 决定包的类型。所有包只分三类:
| 类别 | 一句话 | 包含的 PID |
|---|---|---|
| 令牌包 | 指挥信号 | IN / OUT / SETUP / SOF |
| 数据包 | 货物 | DATA0 / DATA1(2.0 还有 DATA2/MDATA) |
| 握手包 | 回执 | ACK / NAK / STALL / NYET |
一句话记忆:令牌包是"命令"、数据包是"货物"、握手包是"回执"。
2.2.1 三类包的实际物理结构
前面提到通用包结构有"数据区(按类型可选)"——这里展开:只有数据包才有数据区,令牌包和握手包没有。
令牌包(全速,共3字节):
SYNC | PID(8bit) | ADDR(7bit) | ENDP(4bit) | CRC5(5bit) | EOP
数据包:
SYNC | PID(8bit) | DATA(0~64字节...) | CRC16(16bit) | EOP
握手包:
SYNC | PID(8bit) | EOP
| 包类型 | 携带什么 | 有没有"数据区" | 校验 |
|---|---|---|---|
| 令牌包 | PID + 设备地址 + 端点号 | 没有(装不下载荷) | CRC5 |
| 数据包 | PID + 真正数据 | 有 | CRC16 |
| 握手包 | 只有 PID | 没有 | 无 |
令牌包只有 3 字节(PID 1 字节 + 地址/端点/CRC 2 字节),根本没有装数据的空间。这就是"数据区(按类型可选)"的含义——只有数据包才有数据区。
2.3 令牌包(Token):指挥
令牌包由主机发出,宣布"接下来要干什么、目标是谁"。只有令牌包携带"设备地址 + 端点号"——这就是 USB 寻址的方式。
| PID | 名称 | 含义 |
|---|---|---|
| 0x69 | IN | 主机说:"设备,把你的数据发上来" |
| 0xE1 | OUT | 主机说:"设备,接好,我要发数据给你" |
| 0x2D | SETUP | 主机说:"控制传输的建立阶段开始" |
| 0xA5 | SOF | 帧起始(同步时钟用) |
令牌包本身不带数据,它是"指挥信号"。
2.4 数据包(Data):货物
数据包携带真正的数据(描述符、报告、串口字节等)。DATA0/DATA1 交替使用,用于错误重传检测(机制见 2.8)。
2.5 握手包(Handshake):回执
握手包只有 PID,没有数据区,是纯确认信号:
| PID | 含义 |
|---|---|
| ACK | 收到且正确 |
| NAK | 设备没准备好,请重试 |
| STALL | 设备不支持该请求 |
| NYET | 收到但没空间(2.0) |
2.6 "setup 包"的两种含义(重点澄清)
中文教程里"setup 包"常被混用,必须拆开:
- SETUP 令牌包:令牌包的一种(PID=0x2D),控制传输第一阶段的"指挥信号"
- setup 数据(口语常说的"setup 包"):紧跟在 SETUP 令牌后的 DATA0 数据包里装的 8 字节请求数据(bmRequestType、bRequest、wValue、wIndex、wLength)
在本课程的代码语境里(usb.c 的 handle_last_status、USB_request 结构体),说的都是第 2 种——那 8 字节请求数据。
2.6.1 为什么 8 字节 setup 数据放在 DATA0 数据包里?
既然令牌包没有数据区(物理上只有 3 字节,见 2.2.1),8 字节请求数据只能由数据包承载。协议这样设计还有四个理由:
- 令牌必须短、快、能被所有设备监听:令牌是寻址信号,所有设备都要监听并做地址匹配;令牌越短,总线占用越少、匹配越快
- 校验强度不同:令牌用 CRC5(只校验地址/端点号),数据用 CRC16(校验数据完整性)
- 重传机制不同:数据包有 DATA0/DATA1 切换机制(见 2.10),令牌包不需要
- 与 IN/OUT 事务结构统一:SETUP/IN/OUT 都是"令牌+数据"分离(见 2.7),硬件(SIE)用同一套逻辑处理三种事务
类比:令牌包像"快递面单"(收件人=设备地址、窗口号=端点号、签收方式=PID),DATA0 数据包才是"货箱"(装着 8 字节请求数据)。
2.7 事务(Transaction):包的组合
包不是单独飞的,它们按规则组合成事务。事务通常由 2~3 个包组成:
| 事务类型 | 包序列 | 数据方向 |
|---|---|---|
| SETUP 事务 | SETUP 令牌 → DATA0(8字节)→ ACK | 主机→设备 |
| IN 事务 | IN 令牌 → 数据包 → ACK | 数据由设备发出 |
| OUT 事务 | OUT 令牌 → 数据包 → ACK | 数据由主机发出 |
注意 SETUP 事务的两个固定规则:
- 数据方向永远是主机→设备
- 数据包必须是 DATA0
设备收到 SETUP 令牌后会发生什么?
- D12 硬件识别 SETUP 令牌、校验地址
- 接收 DATA0(8 字节)存入 EP0 OUT 缓冲区
- 自动回 ACK
- 进入"锁定"状态:清空 IN 缓冲、禁用 Validate/Clear Buffer 命令(防止固件没读到 setup 旧数据就被发出去)
- 置中断位 EP0 OUT,拉低 INT_N 通知 MCU
- 固件读事务状态时发现 SETUP 标志位(bit5),从而知道"这是 setup 数据"
- 固件对 IN/OUT 双端点执行 Acknowledge Setup(解锁),再解析 8 字节
这正是代码里
handle_last_status中if(status & 0x20)判断和双端点 ACK 的由来。
2.8 控制传输:三个阶段的组合
控制传输是什么?
- 双向:既能设备→主机,也能主机→设备
- 可靠:有握手确认,出错会重试
- 只走端点 0(默认控制管道)
- 用途是"管理":配置设备、查询状态、发送命令——不传业务数据
类比:控制传输是设备的"管理通道"(办入职、发工牌、查状态),中断/批量传输是"业务通道"(干活的)。
控制传输何时发生?——整个生命周期都可能,枚举只是最集中的场景
| 阶段 | 控制传输例子 |
|---|---|
| 上电/枚举 | GET_DESCRIPTOR、SET_ADDRESS、SET_CONFIGURATION |
| 运行中 | SET_LINE_CODING(打开串口助手时)、GET_LINE_CODING、SET_CONTROL_LINE_STATE |
| 运行中 | GET_STATUS、SET_FEATURE、SET_INTERFACE 等标准请求 |
| 随时 | 主机想查询设备状态、改变配置时 |
四种传输回顾(与第 1 章 1.6 呼应):
| 传输 | 特点 | 本课程用到 |
|---|---|---|
| 控制 | 双向、可靠、端点 0 | EP0(枚举、类请求) |
| 中断 | 小数据、低延迟、周期轮询 | EP1 IN(通知) |
| 批量 | 大数据、可靠、无时间保证 | EP2 IN/OUT(数据) |
| 同步(等时) | 实时、无重传、保证带宽 | 不用 |
控制传输固定最多三段:建立 → 数据(可选)→ 状态。
以 GET_LINE_CODING(设备→主机)为例:
判断是否有数据阶段:请求参数能否塞进 setup 包的 wValue/wIndex(共 4 字节)?
- 塞得下(如 SET_ADDRESS 的地址、SET_CONTROL_LINE_STATE 的 DTR/RTS)→ 无数据阶段,SETUP 后直接状态阶段
- 塞不下(如 SET_LINE_CODING 的 7 字节参数)→ 有数据阶段
2.8.1 三阶段各自的作用
| 阶段 | 作用 | 内容 | 方向 |
|---|---|---|---|
| ① 建立 SETUP | 宣告请求:说明"我要做什么请求、参数是什么" | 8 字节请求数据(bmRequestType/bRequest/wValue/wIndex/wLength) | 恒为 主机→设备 |
| ② 数据(可选) | 搬运载荷:传输请求携带的数据 | 描述符、字符串、Line_Coding 等 | 由 bmRequestType D7 决定 |
| ③ 状态 Status | 判定成败:确认整个请求是否成功完成 | 零长度包或握手包 | 与数据阶段相反 |
类比:控制传输像"下单 → 收货 → 确认收货"——建立段下单(说清楚要什么)、数据段收货(货送过来)、状态段确认收货(系统判定订单成没成)。
2.8.2 重点:状态段的作用
状态段是"成败判定的瞬间"——它确认的不是"包收到了"(那是每个事务的 ACK 干的),而是"整个请求是否成功完成"。
设备在状态段可以表达三种态度:
| 设备的表态 | 含义 |
|---|---|
| ACK / 回 ZLP | 请求成功完成 |
| NAK | 设备忙,请重试 |
| STALL | 设备不支持/出错,别问了 |
状态段"包含什么数据"? 通常没有数据——它是一个零长度包(ZLP)或者一个握手包。它的"内容"不是数据,而是成败信号。
为什么必须有状态段? 因为"收到数据"和"请求成功"是两回事:
- 数据段的 ACK 只证明"数据字节到达了"
- 状态段才证明"设备真正处理完了"(比如波特率已生效、地址已切换)
2.8.3 四个详细例子
例 1:GET_DESCRIPTOR(Device)——读方向,有数据段
① SETUP :80 06 00 01 00 00 40 00 ← "我要设备描述符,最多64字节"
② 数据 :设备发 18 字节(分16+2两包,主机逐个 ACK)
③ 状态 :主机发 OUT 0长度 → 设备回 ACK
← 含义:"主机确认收到了完整数据,请求成功"
例 2:SET_LINE_CODING——写方向,有数据段
① SETUP :21 20 00 00 00 00 07 00 ← "我要设置线路编码,接下来7字节"
② 数据 :主机发 7 字节 → 设备 ACK ← 这里 ACK 只代表"收到了"
③ 状态 :主机发 IN → 设备回 ZLP ← 这里 ZLP 代表"参数已生效,请求完成"
注意例 2 里两个确认的区别:数据段的 ACK 是"货收到了",状态段的 ZLP 是"货已经用上了"。
例 3:SET_ADDRESS——无数据段
① SETUP :00 05 01 00 00 00 00 00 ← "你的新地址是1"
② 数据 :(无,wLength=0)
③ 状态 :主机发 IN → 设备回 ZLP ← 地址在"这一刻"才真正生效
例 4:设备不支持的请求——STALL 表态
假设固件没实现 SET_DESCRIPTOR:
① SETUP :00 07 ...(固件不认识)
③ 状态 :主机发 IN → 设备回 STALL ← "我不支持这个请求,别问了"
2.8.4 其他三种传输有三段吗?
不包含。三阶段结构是控制传输独有的。
其他三种传输(中断、批量、同步)的单元是"事务"——一个令牌 + 数据 + 握手就完事,没有"建立/状态"的请求语义:
| 传输类型 | 组成 | 事务数 |
|---|---|---|
| 控制 | SETUP事务 + 数据事务(可选)+ 状态事务 | 2~3 个 |
| 中断 | 1 个事务(IN 或 OUT) | 1 |
| 批量 | 1 个事务(IN 或 OUT) | 1 |
| 同步 | 1 个事务(无握手) | 1 |
对比直观感受:
控制传输(如 GET_DESCRIPTOR):
[SETUP事务] [IN事务] [IN事务] [OUT状态事务] ← 一串事务组成一次传输
批量传输(如 U 盘读扇区数据):
[IN事务] ← 一个事务就是一次传输
中断传输(如鼠标报告):
[IN事务] ← 一个事务就是一次传输
为什么控制需要三段而其他不需要? 因为控制传输是"请求-响应"模式——有明确的"我要什么"(建立)、"数据"(载荷)、"成没成"(状态)三个语义;而中断/批量/同步是"纯数据搬运"——发个令牌、传数据、确认(或不同步),一次搞定,不存在"请求"的语义。所以只有控制传输需要三段结构。
2.9 ACK 与 ZLP:两个不同层面的东西(重点)
初学者最容易混淆的就是这两个。
ACK:包级确认
- 每个事务完成后,接收方回 ACK,表示"这个包我收到了"
- D12 硬件自动处理,固件不用管
ZLP(零长度包):传输级完成信号
- 控制传输状态阶段里,设备可能需要发一个"0 字节的数据包",表示"整个请求处理完了"
- 必须由固件主动准备(写入 EP0 IN 缓冲 + Validate)
关键区别
| ACK | ZLP | |
|---|---|---|
| 层面 | 包级(每个事务) | 传输级(整个请求) |
| 谁负责 | D12 硬件自动 | 固件主动准备 |
| 包类型 | 握手包 | 数据包(长度 0) |
时序关系:设备发 ZLP → 主机回 ACK。ZLP 和 ACK 是链路上前后相邻的两个独立包,不是包含关系。
谁需要发 ZLP(取决于方向)
| 请求方向 | 状态阶段 | 谁发 ZLP |
|---|---|---|
| 主机→设备(SET_LINE_CODING、SET_ADDRESS) | 主机发 IN 令牌 | 设备回 ZLP(固件准备) |
| 设备→主机(GET_LINE_CODING、GET_DESCRIPTOR) | 主机发 0 长度 OUT | 设备只需回 ACK(D12 自动) |
固件不准备 ZLP 会怎样? 主机发 IN 令牌时 EP0 IN 缓冲空、未 validate,D12 只能回 NAK——主机超时重试,控制传输失败。所以代码里 endpoint0_send_count=0; zero_pack=1; endpoint0_send(); 就是准备 ZLP。
2.9.1 常见误解一:ZLP 是控制传输独有的吗?
不是。 ZLP 不是控制传输独有,但也不是所有传输都有:
| 传输类型 | 有 ZLP 吗 | ZLP 的用途 |
|---|---|---|
| 控制 | 有(两种用途) | ① 状态阶段:设备回 ZLP 表示"请求完成";② 数据阶段:长度是最大包整数倍时补 ZLP 终止 |
| 批量 | 有 | 数据长度是 wMaxPacketSize 整数倍时,必须补 ZLP 宣告传输结束(USB 2.0 规范 8.5.3.2) |
| 中断 | 有(规范适用) | 同样适用"短包或 ZLP 结束"规则,但实践极少遇到 |
| 同步 | 没有 | 同步是连续流、无握手、按帧消费,没有"传输结束"语义 |
批量传输的例子:U 盘传一个正好 4096 字节的文件(64×64),如果设备发完最后一个 64 字节包就停,主机无法判断"传完了"还是"还有下一包"——它只能傻等。所以最后必须紧跟 ZLP,主机看到 ZLP 才知道"文件传完了"。Linux/Windows 驱动自动处理这个,但设备固件必须知道这条规则。
本质:ZLP 是"终止标志"——宣告一次传输结束。控制/批量/中断的数据是一次性、有明确长度的传输,当长度恰好是最大包整数倍时短包终止失效,必须用 ZLP 兜底;而同步传输是持续流,没有"一次传输"的边界,自然不需要终止标志。
2.9.2 常见误解二:ZLP 是"状态事务"吗?
不是。 这是两个不同层级的概念:
USB 总线上的最小单位是"包"(Packet)
包按规则组成"事务"(Transaction)
事务按结构组成"传输"(Transfer)
- ZLP:一个数据包,只不过长度是 0(包层级)
- 状态事务:控制传输的第三阶段,承担"请求成败判定"的角色(事务层级)
批量传输发"64 字节 + ZLP"时,结构是:
事务1:OUT令牌 + DATA0(64字节) + ACK
事务2:OUT令牌 + DATA1(0字节) + ACK ← 这个 ZLP 是"数据事务",不是"状态事务"!
第二个事务仍然是数据事务(只是数据包长度 0)。批量传输没有"状态事务"概念,它的 ACK/NAK 握手已经在数据事务里完成了。
为什么批量没有状态事务? 因为状态事务的语义是"整个请求成功了吗"——这只有"请求-响应"模式(控制传输)才有。批量/中断是纯数据搬运:搬完数据、握手确认就结束,不存在"请求"需要判定成败。
所以 2.8.4 的表格统计的是"一次传输由几个事务组成"(传输结构)——批量补 ZLP 时是两个"数据事务",不是"数据事务 + 状态事务"。表格没有错。
2.9.3 常见误解三:ZLP 到底是"包"还是"传输级"?
两个说法都对,因为回答的是两个不同维度的问题。
| 维度 | ZLP | ACK |
|---|---|---|
| 物理形态(它是什么) | 一个数据包(长度 0) | 一个握手包 |
| 语义角色(它干什么) | 控制传输中:传输级完成信号 | 事务级确认 |
| 谁负责 | 固件主动准备 | D12 硬件自动 |
注意:ACK 也是一个"包"(握手包)。说 ACK 是"包级"指的是它确认的范围是"每个事务",不是说 ACK 不是包;说 ZLP 是"传输级"指的是它宣告的范围是"整个请求结束",不是说 ZLP 不是包。
"包级/传输级"说的是语义范围(管多大范围的事),不是物理形态(是不是包)。
为什么一个"包"能承担"传输级"的语义? 因为"传输级"说的是它宣告的事件范围,跟它物理上多大无关。一个 0 字节的包出现在控制传输的最后一个事务里,就宣告"整个请求结束"。
类比:终场哨声——一声哨响(一个物理信号,可以理解为一个"包"),宣告的是整场比赛结束(传输级语义)。意义来自它出现的时机和位置,不来自它的大小。
2.10 补充:DATA0/DATA1 切换机制(了解即可)
数据包交替使用 DATA0、DATA1 PID。发送方每次成功传输后切换 PID,接收方校验"来的 PID 是不是我期望的那个"——如果不匹配就说明重传或漏包,以此实现错误检测。这个机制由 D12 硬件自动处理,固件无感。
2.11 自测题
- USB 的包分哪三类?各自一句话作用?
- "setup 包"的两个含义是什么?代码里解析的是哪个?
- 哪一类包携带"设备地址+端点号"?
- SETUP 事务的包序列是什么?数据方向有什么固定规则?
- 控制传输的三阶段分别由什么事务组成?
- ACK 和 ZLP 的区别是什么?SET_LINE_CODING 的状态阶段谁发 IN 令牌、谁回 ZLP?
- 控制传输只发生在上电阶段吗?举例说明运行中的控制请求。
- 为什么 8 字节 setup 数据要放在 DATA0 数据包里而不是令牌包里?(提示:令牌包的物理结构)
- 控制传输三阶段各自的作用是什么?状态段设备可以表达哪三种态度?
- 中断/批量/同步传输包含"建立-数据-状态"三段吗?为什么?
- ZLP 在批量传输中什么时候需要?同步传输有 ZLP 吗?
- ZLP 是"状态事务"吗?"ZLP 是包"和"ZLP 是传输级完成信号"矛盾吗?
第 3 章 枚举流程
3.1 学习目标
- 理解"插线 ≠ 立即枚举",掌握 SoftConnect 的意义
- 掌握从上电初始化到主机发出第一个请求的完整过程
- 掌握枚举的请求序列(设备描述符 → 地址 → 配置 → 字符串 → SET_CONFIGURATION)
- 理解每个阶段设备端对应的处理函数
3.2 核心认知:USB 线插上 ≠ 设备立即被枚举
插 USB 线只提供电源和物理连接。主机要等 D+ 被拉高才认为"有设备来了",而"拉高"的时机由固件控制——这就是 SoftConnect。
D12 芯片内部集成了 1.5kΩ 上拉电阻,默认不接。MCU 通过 Set Mode 命令的 bit4 控制是否把 D+ 拉高:
// usb.c
void connect(void)
{
write_cmd(CMD_SET_MODE); // F3
write_data(0x10); // bit4=1:SoftConnect 接通,D+ 被拉高
write_data(0x40);
delay_ms(1000);
}
void disconnect(void)
{
write_cmd(CMD_SET_MODE);
write_data(0x00); // SoftConnect 断开
write_data(0x40);
delay_ms(1000);
}
为什么先 disconnect 再 connect? 让主机检测到一次"明确的插入事件"——从断开状态到连接状态的电平变化。如果直接 connect,可能被主机当作总线扰动。
为什么要等 MCU 初始化完才 connect? 主机一旦看到设备就马上开始枚举,如果那时描述符还没准备好、D12 还没初始化,枚举就会失败。SoftConnect 让 MCU 掌握"什么时候暴露自己"的主动权。
3.3 上电初始化(main 之前的启动代码)
MCU 上电后:
- 启动文件(
startup_stm32f10x_md.s)初始化栈和向量表 - 调用
SystemInit()(system_stm32f10x.c)配置时钟(8MHz 晶振 → 72MHz) - 跳进
main()
3.4 main() 的初始化(函数清单)
SysTick_Config(72000); // 1ms时基(delay_ms 依赖)
config_led(); // LED 引脚
config_exit(); // 外部中断(鼠标遗留,CDC 用不到)
config_PDIUSBD12(); // D12 并口引脚(A0=PB15, WR=PB13, RD=PB12, INT=PB14)
config_usart(); // USART2 115200(调试打印 + 数据转发)
init_descriptor(); // ★ 描述符全部就绪
printf("hello USB");
id = read_id(); // 读 D12 芯片ID(0x9212),确认硬件在线
disconnect(); // 先断开总线
connect(); // ★ 拉高 D+,暴露自己
init_descriptor() 必须在这之前就绪——一旦 D+ 拉高,主机随时会来请求,固件必须"随时能答"。
3.5 主机侧:检测到设备 → 总线复位
- D+ 被拉高 → 主机根集线器检测到电平变化 → 判定"有全速设备插入"
- 主机发总线复位(SE0 保持 10ms 以上),把设备"归零"
- D12 收到复位:
- 硬件自动复位:地址回 0、非控制端点禁用、EP0 待命
- 中断寄存器置 BUS RESET 位(0x40),拉低 INT_N
main的 while(1) 轮询到 INT_N 低:
while(1)
{
if(GPIO_ReadInputDataBit(INT_PORT, INT_PIN) == RESET) // INT_N 拉低?
{
write_cmd(CMD_Read_interrupt_register); // F4:问 D12"什么事"
it_status = read_data(); // 读到事件位图
if(it_status & 0x40) { reset_isr(); } // 总线复位(硬件已自动处理,只需打印)
if(it_status & 0x02) { endpoint0_in_isr(); }
if(it_status & 0x01) { endpoint0_out_isr(); } // 主机请求到达
// ...其他位
}
}
为什么
reset_isr只打印?因为 D12 硬件已自动完成复位(地址归0、端点复位、EP0 待命),软件无需干预。
3.6 枚举请求序列(完整剧本)
总线复位后,主机按固定剧本提问。所有请求都走同一条处理链:
D12 收到 SETUP → EP0 OUT 中断 → endpoint0_out_isr
→ read_endpoint_buf(0) 读8字节 → 读事务状态(SETUP标志)
→ 双端点 ACK + Clear buffer → handle_usb_request
→ handle_std_request / handle_class_request → 具体响应
| 顺序 | 主机动作 | 设备端处理 |
|---|---|---|
| 1 | GET_DESCRIPTOR(Device),地址0 | handle_descriptor → DEVICE_DESCRIPTOR 分支 |
| 2 | SET_ADDRESS(1) | case 5:写地址 + 回 ZLP |
| 3 | GET_DESCRIPTOR(Device),新地址 | 同第 1 步(验证地址) |
| 4 | GET_DESCRIPTOR(Configuration),wLength=9 | 返回 9 字节(探路) |
| 5 | GET_DESCRIPTOR(Configuration),wLength=67 | 返回 67 字节(全量) |
| 6 | GET_DESCRIPTOR(String) | STRING_DESCRIPTOR 分支 |
| 7 | SET_CONFIGURATION(1) | case 9:使能端点 |
| 8 | (Windows)CDC 类请求 | handle_class_request |
串口调试打印对照(插上设备后应该看到):
reset_isr
GET_DESCRIPTOR->DEVICE_DESCRIPTOR (send length 18)
SET_ADDRESS: 1
GET_DESCRIPTOR->DEVICE_DESCRIPTOR (send length 18)
GET_DESCRIPTOR->CONFIGURATION_DESCRIPTOR (send length 9)
GET_DESCRIPTOR->CONFIGURATION_DESCRIPTOR (send length 67)
GET_DESCRIPTOR->STRING_DESCRIPTOR ...
SET_CONFIGURATION:1
SET_LINE_CODING
SET_CONTROL_LINE_STATE
3.7 关键细节:SET_ADDRESS 的地址延迟生效
USB 规范要求:设备必须在 SET_ADDRESS 的状态阶段成功完成后才切换新地址。所以:
- 主机发状态阶段 IN 令牌时,用的还是旧地址 0
- 设备用地址 0 应答 ZLP
- 整个事务完成后新地址才生效
这也解释了为什么主机紧接着会用新地址重发一次设备描述符——就是验证地址切换是否成功。
3.8 关键细节:配置描述符为什么请求两次
主机第一次请求配置描述符时不知道总长——总长(wTotalLength)就藏在配置描述符第 3、4 字节里。所以:
- 先请求 9 字节(配置描述符头),读出 wTotalLength = 67
- 再请求 67 字节全量
设备端靠 min(wLength, wTotallLength) 自动区分两次:
if(request_data_length >= Descriptor_Set.Configuration_Desc.wTotallLength)
endpoint0_send_count = wTotallLength; // 第二次:发全量
else
endpoint0_send_count = request_data_length; // 第一次:发9
3.9 SET_CONFIGURATION:端点的"通电时刻"
case 9:
if(USB_request.wValue != 0)
{
write_cmd(CMD_Set_Endpoint_Enable); // D8
write_data(0x01); // 一次性使能 EP1/EP2
endpoint0_send_count = 0;
zero_pack = 1;
endpoint0_send(); // 回 ZLP 完成状态阶段
}
CMD_Set_Endpoint_Enable(D8)写 0x01,把 D12 的通用端点全部使能(一把开关)。之前 EP1/EP2 处于禁用状态——主机轮询它们无响应、endpoint2_out_isr 永远不会触发;这行执行后,数据通路才"通电"。
SET_CONFIGURATION(0) 的规范语义是"解除配置"(回到未配置状态),本工程对 wValue=0 不做处理(教学简化,严格应禁用端点)。
3.10 USB 设备完整生命周期(状态机 + 枚举细化 + 配置之后)
前面按"请求序列"讲了枚举,这一节换一个更完整的视角:把设备的一生看成状态机,并补充枚举之后可能经历的过程。
3.10.1 不是"一模一样":骨架相同,细节不同
规范强制的骨架(所有 USB 设备必须经历):复位 → 设备描述符 → SET_ADDRESS → 配置描述符 → SET_CONFIGURATION。这五步由 USB 2.0 规范的状态机(9.1 节)决定,任何主机、任何设备都绕不开。
操作系统会加细节(因 OS 而异):
| 细节 | Windows | Linux | 规范要求 |
|---|---|---|---|
| 第一次 GET_DESCRIPTOR(Device) 请求长度 | 通常 64 | 通常 64 | 至少能读到 bMaxPacketSize0(8 就够) |
| 地址分配后是否重读设备描述符 | 是 | 是 | 允许但非强制 |
| 是否请求字符串描述符 | 是(厂商/产品/序列号) | 是 | 可选 |
| 复位次数 | 通常 1~2 次 | 通常 1 次 | 至少 1 次 |
所以 3.6 的表格是"典型/标准"顺序,是骨架的真实写照,但不是每个主机的字节级拷贝——比如某些主机第一次只要 8 字节、有的不请求字符串。
3.10.2 设备状态机
USB 规范把设备的一生定义为状态机(九态,简化如下):
一句话记忆:设备一生在 Attached → Powered → Default → Address → Configured 之间前进,任何状态都可能被"总线空闲 3ms"拖进 Suspended(挂起),靠 Resume 唤醒;被复位则退回 Default。
3.10.3 九态详解:逐状态与跳转条件
光看图容易疑惑"什么时候跳转",这里逐状态讲清楚。九态 = 6 个常驻状态 + 3 个事件性状态:
| 类型 | 状态 |
|---|---|
| 常驻状态(6个) | Attached、Powered、Default、Address、Configured、Suspended |
| 事件性状态(3个) | Reset(复位)、Resume(恢复)、Detached(断开) |
① Attached(连接态)——设备"插上了"
- 进入:设备插入集线器端口,D+ 被拉高,主机检测到电平变化
- 行为:尚未获得电源,不响应任何 USB 事务
- 跳出:获得 VBUS 电源 → Powered;拔出 → Detached
② Powered(供电态)——有电,但没被复位
- 进入:VBUS 供电成功
- 行为:有电但未被主机初始化,不响应事务
- 跳出:总线复位(SE0 ≥10ms)→ Default;总线空闲 3ms → Suspended;掉电/拔出 → 回 Attached/Detached
注意:Powered 可以直接挂起——哪怕还没枚举过,总线空闲久了也会挂起。
③ Default(默认态)——复位完成,用地址 0 待命
- 进入:总线复位结束。这是枚举的起点
- 行为:设备地址为 0,EP0 待命,只响应发往地址 0 的控制请求
- 跳出:SET_ADDRESS 成功 → Address;总线空闲 3ms → Suspended;再次总线复位 → 仍回 Default(一切重来)
代码对应:
reset_isr就是"进入 Default 态"的通知;D12 硬件已自动归零。
④ Address(地址态)——有唯一地址,但还没配置
- 进入:SET_ADDRESS 的状态阶段成功完成(地址延迟生效)
- 行为:用新地址响应,但非控制端点仍未使能,不能传业务数据
- 跳出:SET_CONFIGURATION(非0) → Configured;SET_CONFIGURATION(0) → 留在 Address;总线复位 → Default;总线空闲 3ms → Suspended
⑤ Configured(配置态)——正常工作
- 进入:SET_CONFIGURATION(非0) 成功,端点使能
- 行为:业务数据传输的起点——中断/批量/同步端点开始工作;类请求、标准请求随时可能到来
- 跳出:SET_CONFIGURATION(0) → Address;总线复位 → Default;总线空闲 3ms → Suspended;拔出 → Detached
代码对应:
endpoint2_out_isr从这一刻才开始触发——这就是 3.9 说的"通电时刻"。
⑥ Suspended(挂起态)——省电待机
- 进入:任何状态下总线空闲超过 3ms(规范规定设备必须在 3ms 内进入挂起)
- 行为:停止一切总线活动,电流降到极低(总线供电设备 ≤2.5mA),保持内部状态不丢
- 跳出:Resume(主机发恢复信号)→ 回到挂起前的状态;Remote Wakeup(远程唤醒,需配置描述符声明且主机使能)→ 同样回挂起前状态;拔出 → Detached
⑦⑧⑨ Reset / Resume / Detached——事件性状态
- Reset:不是"待着"的状态,而是"总线复位事件"——把设备从任何地方(除 Attached)打回 Default。复位做的事:地址归 0、非控制端点禁用、EP0 待命(D12 硬件全自动)
- Resume:挂起后的恢复事件——主机发 ≥20ms 的恢复信号(K 态),或设备远程唤醒;结束后设备回到挂起前的状态
- Detached:拔出——设备从总线上移除,一切状态清零,回到初始
跳转事件速查表
| 事件 | 效果 |
|---|---|
| 获得 VBUS 电源 | Attached → Powered |
| 总线复位(SE0 ≥10ms) | 任意态 → Default(地址清零、端点禁用) |
| SET_ADDRESS 成功 | Default → Address |
| SET_CONFIGURATION(非0) | Address → Configured(端点使能) |
| SET_CONFIGURATION(0) | Configured → Address(解除配置) |
| 总线空闲 3ms | 任意态 → Suspended |
| Resume / 远程唤醒 | Suspended → 挂起前的状态 |
| 拔出 | 任意态 → Detached |
用本工程串起来看
插线 → Attached/Powered
connect() 拉高 D+ → 主机复位 → reset_isr → Default
GET_DESCRIPTOR → SET_ADDRESS → Address
GET_DESCRIPTOR(Config) → SET_CONFIGURATION → Configured ← 串口助手能用
电脑休眠 → 总线空闲3ms → Suspended(串口助手显示连接断开)
电脑唤醒 → Resume → 回到 Configured(重新可用)
主机重载驱动 → 总线复位 → 回到 Default,重新枚举一遍
拔线 → Detached
核心规律:前进靠"请求"(SET_ADDRESS、SET_CONFIGURATION),倒退靠"事件"(总线复位打回 Default、SET_CONFIGURATION(0) 退回 Address、空闲 3ms 挂起),结束靠"拔出"。
3.10.4 枚举请求序列(细化版)
对照 3.6:这张图是它的一次细化——补上了"总线复位后设备状态机推进"的视角,以及每个阶段结束后设备处于什么状态。
3.10.5 配置之后可能经历的过程
设备进入 Configured 状态后,一生还没结束:
3.10.6 四个关键认知
- Configured 不等于"只传业务数据"——类请求、标准请求随时可能插进来(走 EP0 控制管道,和业务传输互不干扰)
- 挂起是常态:USB 总线空闲 3ms 设备就进挂起,电脑休眠/待机时全总线设备都会挂起;唤醒后可继续工作
- 重新枚举随时可能:主机可以再次复位设备(驱动重载、配置变更),设备会被打回 Default 重新走一遍枚举——所以固件的
reset_isr和描述符处理必须能反复执行 - 断开:拔出后设备回到初始状态
3.11 自测题
- 为什么"插线 ≠ 立即枚举"?SoftConnect 解决了什么问题?
- 从插线到主机发出第一个请求,经历了哪些阶段?各用了哪些函数?
- 枚举请求的完整序列是什么?
- SET_ADDRESS 的地址为什么延迟生效?主机怎么验证?
- 配置描述符为什么请求两次?设备端怎么区分?
- SET_CONFIGURATION 之后,为什么 endpoint2_out_isr 才开始工作?
- 枚举骨架的五个强制步骤是什么?为什么说"细节因 OS 而异"?
- 设备状态机的推进路径是什么?什么事件会把设备打回 Default 状态?
- Configured 状态被 SET_CONFIGURATION(0) 后会到哪个状态?什么事件会让设备从 Configured 直接回到 Default?
第 4 章 PDIUSBD12 芯片与底层驱动
4.1 学习目标
- 了解 D12 芯片的整体架构和关键特性
- 理解"对单片机来说 D12 像一个存储器"这句话
- 掌握 D12 的端点结构和端点索引
- 掌握命令系统的三大类
- 理解缓冲区格式和中断寄存器
- 读懂
PDIUSBD12.c的底层驱动
4.2 D12 芯片是什么
PDIUSBD12 是飞利浦(现 NXP)的并行接口 USB 1.1 设备控制器。它把 USB 的物理层和协议层都做进了芯片:
- SIE(串行接口引擎):硬件的 USB 协议处理(同步、位填充、CRC、PID 校验、地址识别、握手)
- 集成收发器和 3.3V 稳压器:直接接 D+/D-
- 320 字节 FIFO:缓冲 USB 包
- SoftConnect:内部 1.5kΩ 上拉,由命令控制
- GoodLink:LED 指示连接状态
- 6MHz 晶振 + 内部 PLL:倍频到 48MHz
分工:MCU 只负责"命令 + 数据"(高层逻辑),D12 负责所有 USB 协议细节(底层苦活)。
4.3 对单片机来说,D12 像一个"两格储物柜"
数据手册 6.9 节原话:对单片机来说,D12 就像一个"8 位数据总线 + 1 位地址线、占 2 个地址的存储器"。
| A0(地址) | 写操作 | 读操作 |
|---|---|---|
| 1 | 写命令(F0、F3、FA…) | 基本不用 |
| 0 | 写数据(命令参数、缓冲区内容) | 读状态/读缓冲区 |
像 SRAM 一样,靠地址线区分单元:A0=1 是"命令格",A0=0 是"数据格"。MCU 永远先往命令格写一条指令,再往数据格写/读内容。
4.4 端点结构与端点索引(Mode 0)
D12 有 3 组硬件端点,Mode 0(非同步模式)下:
| 端点号 | 方向 | 端点索引 | 大小 |
|---|---|---|---|
| 0 | 控制 OUT | 0 | 16 B |
| 0 | 控制 IN | 1 | 16 B |
| 1 | 通用 OUT | 2 | 16 B |
| 1 | 通用 IN | 3 | 16 B |
| 2 | 通用 OUT | 4 | 64 B(双缓冲) |
| 2 | 通用 IN | 5 | 64 B(双缓冲) |
端点索引很重要:代码里 write_endpoint(3,...) 的 3 就是"EP1 IN",write_endpoint(5,...) 是"EP2 IN",read_endpoint_buf(4) 是"EP2 OUT"。
注意两个概念:
- 硬件端点:芯片里真实存在(EP0/EP1/EP2,IN+OUT 都有)
- 描述符端点:你通过描述符"声明"给主机的端点
两者不需要一一对应。本工程描述符只声明了 EP1 IN、EP2 IN/OUT,但 D12 中断寄存器包含全部 6 个方向的事件位——所以 endpoint1_out_isr 等"用不到"的函数也存在(防御性占位 + 清中断标志)。
4.5 命令系统:三大类
D12 的命令分三类(数据手册第 10 章):
初始化命令
| 命令 | 码 | 作用 |
|---|---|---|
| Set Address/Enable | D0 | 写设备地址(bit7=使能,bit6:0=地址) |
| Set Endpoint Enable | D8 | 使能通用端点(0x01 全使能) |
| Set Mode | F3 | 配置端点模式/SoftConnect/时钟 |
数据流命令
| 命令 | 码 | 作用 |
|---|---|---|
| Read Interrupt Register | F4 | 读中断来源(第一字节是事件位图) |
| Select Endpoint | 00~05 | 选中某端点(参数=端点索引) |
| Read/Write Buffer | F0 | 读写端点缓冲 |
| Read Last Transaction Status | 40~45 | 读事务状态 + 清中断标志 |
| Acknowledge Setup | F1 | 确认 SETUP 包(IN/OUT 都要) |
| Clear Buffer | F2 | 释放 OUT 缓冲 |
| Validate Buffer | FA | 使 IN 缓冲有效,等待主机取走 |
通用命令
| 命令 | 码 | 作用 |
|---|---|---|
| Send Resume | F6 | 发送恢复信号 |
| Read Current Frame Number | F5 | 读当前帧号 |
4.6 缓冲区格式
D12 的端点缓冲前两个字节是头部:
字节0:保留(写时填0)
字节1:数据长度
字节2..:真正的数据
write_endpoint() 和 read_endpoint_buf() 就是在维护这个格式:
void write_endpoint(uint8_t endpoint_index, uint8_t size, uint8_t * buf)
{
write_cmd(endpoint_index); // 选中端点
write_cmd(CMD_Write_Buffer); // F0
write_data(0); // 字节0:保留
write_data(size); // 字节1:长度
for(i = 0; i < size; i++)
write_data(buf[i]); // 数据
write_cmd(CMD_Validate_Buffer); // FA:标记缓冲有效
}
4.7 中断寄存器(F4 返回的第一字节)
| 位 | 含义 | 对应处理函数 |
|---|---|---|
| 0x80 | 挂起变化 | suspend_isr |
| 0x40 | 总线复位 | reset_isr |
| 0x20 | EP2 IN | endpoint2_in_isr |
| 0x10 | EP2 OUT | endpoint2_out_isr |
| 0x08 | EP1 IN | endpoint1_in_isr |
| 0x04 | EP1 OUT | endpoint1_out_isr |
| 0x02 | EP0 IN | endpoint0_in_isr |
| 0x01 | EP0 OUT | endpoint0_out_isr |
清标志机制(关键):
- 非端点位(挂起/复位):读中断寄存器时清除
- 端点位(0~5):读对应端点的 Last Transaction Status(0x40~0x45)时清除
如果标志不清,D12 会认为事件未处理完,INT_N 一直保持低电平,主循环死循环。
4.8 中断的"锁定"与解锁(SETUP 专用)
SETUP 包到达时(数据手册 11.3.10):
- 清空 IN 缓冲
- 禁用 Validate Buffer 和 Clear Buffer 命令(IN/OUT 都禁用)
固件必须对 IN 和 OUT 两个端点都执行 Acknowledge Setup(F1),才能解锁。之后才能 Clear Buffer。
write_cmd(1); // Select 控制 IN(索引1)
write_cmd(CMD_Acknowledge_setup); // 解锁 IN
write_cmd(0); // Select 控制 OUT(索引0)
write_cmd(CMD_Acknowledge_setup); // 解锁 OUT
write_cmd(CMD_Clear_buffer); // 清 OUT 缓冲,EP0 恢复接收
顺序有要求:必须先 ACK 再 Clear Buffer(因为 SETUP 到达后 Clear/Validate 被禁用);ACK IN 和 ACK OUT 顺序无所谓。
4.9 底层驱动代码解读(PDIUSBD12.c)
void write_cmd(uint8_t cmd) // 地址1写
{
GPIO_SetBits(A0_PORT, A0_PIN); // A0=1 → 命令格
GPIO_ResetBits(WR_PORT, WR_PIN); // 拉低写使能
GPIO_Write(DATA_PORT, cmd); // 数据总线上放命令字节
GPIO_SetBits(WR_PORT, WR_PIN); // 拉高写使能 → 锁存
}
void write_data(uint8_t data) // 地址0写
{
GPIO_ResetBits(A0_PORT, A0_PIN); // A0=0 → 数据格
GPIO_ResetBits(WR_PORT, WR_PIN);
GPIO_Write(DATA_PORT, data);
GPIO_SetBits(WR_PORT, WR_PIN);
}
uint8_t read_data(void) // 地址0读
{
// 数据线切换为输入
GPIO_ResetBits(A0_PORT, A0_PIN);
GPIO_ResetBits(RD_PORT, RD_PIN); // 拉低读使能
tmp = GPIO_ReadInputData(DATA_PORT);
GPIO_SetBits(RD_PORT, RD_PIN);
return tmp;
}
三个函数本质就是"往地址 1 写 / 往地址 0 写 / 从地址 0 读",时序和 SRAM 读写一模一样。
4.10 自测题
- "D12 像存储器"指的是什么?A0 线的两个电平分别代表什么?
- 端点索引 3、4、5 分别对应哪个端点?
- 命令分哪三大类?F0、F1、F2、FA、D0、D8 分别干什么?
- D12 缓冲的前两个字节是什么?
- 中断位怎么清除?为什么必须清?
- SETUP 到达后为什么要对 IN/OUT 双端点 Acknowledge Setup?
第 5 章 CDC 描述符详解
5.1 学习目标
- 理解 CDC 设备描述符与 HID 的差异
- 逐字节读懂 CDC 配置描述符集合(67 字节,10 段)
- 理解四个功能描述符的作用
- 理解双接口结构(通信接口 + 数据接口)
- 理解三个端点的分工
5.2 权威依据
本课程 CDC 部分的规范依据是 resource/usbcdc11.pdf。遇到疑问请对照:
| 内容 | 规范位置 |
|---|---|
| 功能描述符 | 5.2.3.1 ~ 5.2.3.10 |
| Line Coding 结构 | Table 50 |
| 类请求 | 第 6 章 |
| UART 状态位图 | Table 69 |
5.3 设备描述符(18 字节)
serial/User/descriptor.c 中 CDC 项目的设备描述符与鼠标项目只有三处实质差异:
| 字段 | 鼠标(HID) | 虚拟串口(CDC) | 含义 |
|---|---|---|---|
| bDeviceClass | 0 | 0x02 | 设备级声明"整台设备都是通信类" |
| idVendor | 0x66 | 0x77 | 随意编的厂商号 |
| idProduct | 0x12 | 0x88 | 随意编的产品号 |
其余字段(bLength=18、bDescriptorType=0x01、bcdUSB=0x0110、bMaxPacketSize0=16、iManufacturer=1、iProduct=2、iSerialNumber=3、bNumConfigurations=1)与鼠标相同。
为什么 CDC 可以 bDeviceClass=0x02 而 HID 必须为 0?
USB 规范规定:设备描述符类非 0 时,所有接口必须属于这个类。
- HID 设备可能混合多类接口(键盘+鼠标+音量旋钮),所以规范强制 HID 写 0,类留给接口各自声明
- CDC 设备所有接口都属通信类(0x02 通信 + 0x0A 数据),所以可以(且 CDC 规范规定)设备级写
0x02/0x00/0x00
5.3.1 如果 CDC 设备级也写 0 会怎样?
完全可以,Windows 照样识别成 COM 口。 因为驱动匹配的真正依据是接口描述符,设备级只是提示信息:
设备描述符:0x00/0x00/0x00 ← "类由接口自己声明"
接口0:0x02/0x02/0x01 ← Windows 真正看这里 → CDC 通信接口
接口1:0x0A/0x00/0x00 ← CDC 数据接口
| 设备级 0x02/0/0 | 设备级 0/0/0 | |
|---|---|---|
| 是否合法 | 合法(CDC 规范推荐) | 合法(USB 通用规范允许) |
| Windows 识别 | 正常 COM 口 | 正常 COM 口 |
| 规范依据 | CDC 规范:"所有接口都是 CDC 时,设备级应写 0x02/0/0" | USB 规范:"bDeviceClass=0 时类由接口声明" |
| 通用性 | 只适用于"全机同类" | 任何设备都适用 |
| 实际差异 | 给主机"整机是通信类"的预期 | 无预期,全靠接口 |
结论:两种写法功能完全等价。写 0x02 更贴合 CDC 规范推荐,写 0 是"万能安全写法"。真正决定设备身份的是接口描述符——设备级可以"懒",接口级必须"准"。
注意边界:一旦设备级非 0,所有接口必须属于该类。所以混合设备(CDC+HID 等)设备级必须写 0。
5.4 配置描述符集合:67 字节全景
总计:9+9+5+5+4+5+7+9+7+7 = 67 字节。
5.5 各段详解
5.5.1 配置描述符(9B)
| 字段 | 值 | 依据 |
|---|---|---|
| bLength | 9 | 规范固定 |
| bDescriptorType | 0x02 | 规范固定 |
| wTotalLength | 67 | sizeof(Descriptor_Set_t) 自动计算 |
| bNumInterfaces | 2 | 虚拟串口 = 通信接口 + 数据接口 |
| bConfigurationValue | 1 | 唯一配置,编号 1 |
| iConfiguration | 0 | 无配置名字符串 |
| bmAttributes | 0x80 | 总线供电(bit7 恒 1,bit6/5=0) |
| bMaxPower | 200 | 200×2mA = 400mA(≤500mA 合法;上限承诺,非实际功耗) |
5.5.2 通信接口描述符(9B)——接口 0 控制面
| 字段 | 值 | 含义 |
|---|---|---|
| bInterfaceNumber | 0 | 第 0 号接口 |
| bNumEndpoints | 1 | 只用 1 个端点(0x81 通知端点) |
| bInterfaceClass | 0x02 | 通信类 |
| bInterfaceSubClass | 0x02 | ACM(抽象控制模型) |
| bInterfaceProtocol | 0x01 | AT 命令集(V.250) |
这一组 0x02/0x02/0x01 是虚拟串口的"身份三连":通信类 + 抽象控制模型 + AT 命令。
补充:协议 1 只是"声明支持 AT 命令",代码并未实现 AT 命令解析——描述符是声明,行为是另一回事。
5.5.3 Header 功能描述符(5B)
bFunctionLength = 5; // 本描述符长度
bDescriptorType = 0x24; // CS_INTERFACE(类特定接口描述符)
bDescriptorSubtype = 0x00; // Header
bcdCDC = 0x0110; // CDC 规范版本 1.10
作用:声明"这一整套 CDC 接口遵循 CDC 规范 1.10"。
0x24 是什么? 类特定描述符类型码:HID 用 0x21/0x22,CDC 用 0x24 + 子类型(subtype)区分多个功能描述符:
| 子类型 | 功能描述符 |
|---|---|
| 0x00 | Header |
| 0x01 | Call Management |
| 0x02 | ACM |
| 0x06 | Union |
5.5.4 Call Management 功能描述符(5B)
bFunctionLength = 5;
bDescriptorType = 0x24;
bDescriptorSubtype = 0x01; // Call Management
bmCapabilities = 0x00; // D0=0:不处理呼叫管理
bDataInterface = 0;
bmCapabilities 位定义(规范 Table 27):
- D0:设备是否自己处理呼叫管理(拨号/挂断)。0=不处理(纯数据透传)
- D1:呼叫管理命令走哪个接口(D0=1 时才有意义)
虚拟串口不做拨号,所以 0x00。
5.5.5 ACM 功能描述符(4B)
bFunctionLength = 4;
bDescriptorType = 0x24;
bDescriptorSubtype = 0x02; // ACM
bmCapabilities = 0x02; // bit1=1:支持线路编码/控制线命令组
bmCapabilities 位定义(规范 Table 28,注意和讲解时对照原文):
| 位 | 支持的内容 |
|---|---|
| D0 | Set/Clear/Get_Comm_Feature |
| D1 | Set_Line_Coding、Get_Line_Coding、Set_Control_Line_State、Serial_State 通知 |
| D2 | Send_Break |
| D3 | Network_Connection 通知 |
| D7..D4 | 保留 |
本项目 0x02 = bit1:声明支持虚拟串口核心命令组——与代码里实现的 case 0x20/0x21/0x22 正好对应。
5.5.6 Union 功能描述符(5B)
bFunctionLength = 5;
bDescriptorType = 0x24;
bDescriptorSubtype = 0x06; // Union
bMasterInterface = 0; // 主接口 = 通信接口
bSlaveInterface[0] = 1; // 从接口 = 数据接口
作用:把接口 0 和接口 1 绑定成一个功能单元。规范原文:
"Union functional descriptor describes the relationship between a group of interfaces that can be considered to form a functional unit."
- bMasterInterface:主/控制接口。类特定消息发给它,作用于整个组;组的通知也从它发出
- bSlaveInterface[]:从接口数组(可以有多个)
没有 Union,主机就不知道"接口 0 和接口 1 是一伙的",会当成两个独立设备。
5.5.7 通信端点描述符(7B)——通知管道
| 字段 | 值 | 含义 |
|---|---|---|
| bEndpointAddress | 0x81 | EP1 IN |
| bmAttributes | 3 | 中断传输 |
| wMaxPacketSize | 16 | 每包 16 字节 |
| bInterval | 10 | 主机每 10ms 轮询 |
角色:通知管道。设备通过它向主机发 Serial_State 通知(DCD/DSR/Break/错误状态变化)。它不传数据,主机每 10ms 轮询取通知。
5.5.8 数据接口描述符(9B)——接口 1 数据面
| 字段 | 值 | 含义 |
|---|---|---|
| bInterfaceNumber | 1 | 第 1 号接口 |
| bNumEndpoints | 2 | 两个批量端点(IN+OUT) |
| bInterfaceClass | 0x0A | CDC Data(数据类) |
| bInterfaceSubClass | 0 | 不细分 |
| bInterfaceProtocol | 0 | 无特定协议 |
数据接口没有功能描述符——功能描述符只属于通信接口。数据接口就是"听话干活的纯数据管道"。
5.5.9 数据端点描述符 1(7B)——上行
0x82(EP2 IN)、批量、64 字节、bInterval=0。
方向:设备→主机。对应代码 write_endpoint(5, ...)(D12 索引 5)。
5.5.10 数据端点描述符 2(7B)——下行
0x02(EP2 OUT)、批量、64 字节、bInterval=0。
方向:主机→设备。对应代码 read_endpoint_buf(4)(D12 索引 4)。
0x82 和 0x02 唯一区别是方向位(bit7):同一个端点号 2,IN/OUT 是两个独立端点。
wMaxPacketSize=64 的依据:D12 数据手册端点配置表——EP2 就是 64 字节、双缓冲。描述符必须描述真实硬件能力。
bInterval=0 的依据:批量端点没有轮询间隔概念,规范规定忽略此字段,填 0。
5.6 三个端点分工总结
| 端点 | 类型 | 方向 | 干什么 |
|---|---|---|---|
| EP1 IN (0x81) | 中断 | 设备→主机 | 状态通知(Serial_State) |
| EP2 IN (0x82) | 批量 | 设备→主机 | 数据上行 |
| EP2 OUT (0x02) | 批量 | 主机→设备 | 数据下行 |
5.7 bNumEndpoints 怎么填:端点号与端点索引的两套系统
5.7.1 bNumEndpoints 的赋值规则
定义:这个接口使用了多少个端点(端点 0 除外)。
为什么端点 0 除外?因为端点 0 是每个设备都有的"默认控制端点"(公共设施),不属于任何接口——它是设备级的。接口描述符只统计"这个接口自己申请的功能端点"。
关键规则:IN 和 OUT 分别算一个。同一个端点号 1,有 IN 和 OUT 两个端点,就算 2。
| 设备/接口 | 使用的端点 | bNumEndpoints |
|---|---|---|
| HID 鼠标(mouse 项目) | EP1 IN(发报告) | 1 |
| CDC 通信接口(serial 接口0) | EP1 IN(通知) | 1 |
| CDC 数据接口(serial 接口1) | EP2 IN + EP2 OUT | 2 |
| 只用端点 0 的简单接口 | 无功能端点 | 0(合法!) |
| 键盘 + LED 接口 | EP1 IN(键码)+ EP1 OUT(LED) | 2 |
| 多功能接口 | EP1 IN + EP1 OUT + EP2 IN | 3 |
注意最后一个例子:EP1 IN + EP1 OUT 是两个端点,虽然端点号都是 1——数的是"端点"不是"端点号的种类"。
5.7.2 端点号 vs 端点索引:两个不同的"世界"
端点号(地址)属于 USB 协议世界(主机视角),端点索引属于 D12 芯片世界(MCU 视角)。
| 端点号(地址) | 端点索引 | |
|---|---|---|
| 属于哪个世界 | USB 协议(规范、主机) | D12 芯片(数据手册、固件) |
| 出现在哪 | 描述符 bEndpointAddress、USB 令牌包 | D12 命令参数(Select Endpoint、Last Transaction Status) |
| 取值范围 | 0~15(加方向位) | 0~5(D12 共 6 个端点方向) |
| 用途 | 主机在总线上寻址 | MCU 访问芯片内部缓冲区 |
D12 的端点索引映射(数据手册,务必记住):
| USB 端点地址 | 端点号 | 方向 | D12 端点索引 |
|---|---|---|---|
| 0x00 | 0 | OUT | 0 |
| 0x80 | 0 | IN | 1 |
| 0x01 | 1 | OUT | 2 |
| 0x81 | 1 | IN | 3 |
| 0x02 | 2 | OUT | 4 |
| 0x82 | 2 | IN | 5 |
联系:每个 USB 端点(编号+方向)在 D12 里唯一对应一个索引。0x81 ↔ 索引 3,0x02 ↔ 索引 4,0x82 ↔ 索引 5。
5.7.3 bNumEndpoints 到底数的是什么?
直接回答:bNumEndpoints 数的是"该接口在 USB 协议上声明的端点(地址,含方向)数量,端点 0 除外"。
- 不是 D12 端点索引的数量(D12 索引是芯片内部概念,描述符根本不知道)
- 不是 端点号的种类数(0x82 和 0x02 都是端点号 2,但算 2 个端点)
- 是 描述符里那个接口实际"宣告"给主机的功能端点个数
以 serial 项目的数据接口为例,三个数字各说各话:
| 概念 | 值 | 属于哪套系统 | 出现在哪 |
|---|---|---|---|
| bNumEndpoints | 2 | USB 协议 | 接口描述符 |
| 端点地址 | 0x82、0x02 | USB 协议 | 端点描述符 |
| D12 端点索引 | 5、4 | D12 芯片 | write_endpoint(5,...) / read_endpoint_buf(4) |
主机只认识前两套(协议),MCU 只认识最后一套(D12 索引)——它们通过端点地址这个"共同语言"对应起来。
5.7.4 赋值检查清单
- 数一数这个接口下面写了几个端点描述符(一个方向算一个)
- 端点 0 不数
- 填进去的数字必须和端点描述符的实际数量严格一致——填多了,主机按 bLength 解析时会期待更多端点描述符;填少了,多出来的端点描述符会被当成别的东西解析
5.8 自测题
- 为什么 CDC 的设备描述符 bDeviceClass 可以写 0x02,而 HID 必须写 0?
- 67 字节由哪 10 段组成?
- 四个功能描述符分别回答什么问题?
- Union 的 master 和 slave 分别填什么?作用是什么?
- 为什么 CDC 数据用批量端点而鼠标用中断端点?
- 0x82 和 0x02 是什么关系?
- bNumEndpoints 数的是什么?端点 0 为什么除外?
- 0x81 的 D12 端点索引是多少?bNumEndpoints 和 D12 索引是一套系统吗?
第 6 章 CDC 类请求
6.1 学习目标
- 区分标准请求和类请求
- 掌握"是否需要数据阶段"的判断规则
- 理解 SET_LINE_CODING 的三阶段处理
- 理解 GET_LINE_CODING 和 SET_CONTROL_LINE_STATE
- 理解 Line_Coding 默认值 vs 当前值
- 学会对照规范找代码 bug
6.2 标准请求 vs 类请求
| 标准请求 | 类请求 | |
|---|---|---|
| 由谁定义 | USB 通用规范 | 类规范(如 CDC 规范) |
| 哪些设备处理 | 所有 USB 设备 | 只有该类的设备 |
| 例子 | GET_DESCRIPTOR、SET_ADDRESS | SET_LINE_CODING、GET_LINE_CODING |
主机怎么区分? 看 setup 包第一字节 bmRequestType 的 D6:D5 类型位:
D7 D6 D5 D4..D0
方向 类型 接收者
00 = 标准请求
01 = 类请求
代码里(handle_usb_request):
switch((USB_request.bmRequestType >> 5) & 0x03)
{
case 0: handle_std_request(); // 标准请求
case 1: handle_class_request(); // 类请求 ← CDC 新内容
}
handle_usb_request 还设置全局变量 Request_type(0=标准,1=类),供后面的数据阶段识别用。
6.3 是否需要数据阶段?一个通用判断规则
请求参数能塞进 setup 包的 wValue/wIndex(共 4 字节),就不需要数据阶段;塞不下,才需要。
| 请求 | 参数 | 放哪 | 需要数据阶段 |
|---|---|---|---|
| SET_ADDRESS | 地址(7位) | wValue | 否 |
| SET_CONFIGURATION | 配置值(8位) | wValue | 否 |
| SET_CONTROL_LINE_STATE | DTR/RTS(2位) | wValue | 否 |
| SET_LINE_CODING | 7字节参数 | 塞不下 | 是 |
| GET_LINE_CODING | 无参数,但返回 7 字节 | 数据阶段(返回方向) | 是 |
6.4 SET_LINE_CODING:三阶段完整走一遍
SET_LINE_CODING 是"主机→设备、有数据阶段"的控制传输:
阶段 1:建立(SETUP)
主机发 8 字节 setup 数据:
21 20 00 00 00 00 07 00
| 字节 | 值 | 含义 |
|---|---|---|
| 0 | 0x21 | bmRequestType:主机→设备、类请求、接口 |
| 1 | 0x20 | bRequest:SET_LINE_CODING |
| 2~3 | 0 | wValue |
| 4~5 | 0 | wIndex = 接口 0 |
| 6~7 | 0x0007 | wLength = 7(预告:接下来传 7 字节数据) |
设备端处理链:
EP0 OUT 中断 → endpoint0_out_isr → read_endpoint_buf(0)
→ 读事务状态(SETUP 标志 bit5=1)→ handle_last_status 的 setup 分支
→ 解析 USB_request → 双端点 ACK + Clear buffer → handle_usb_request
→ 类型位=类 → handle_class_request → case 0x20 只打印
setup 阶段只打印,不干活——因为 7 字节参数还没到。
阶段 2:数据
主机发 OUT 事务,DATA0 携带 7 字节 Line Coding 参数(以 115200/8N1 为例):
00 E2 01 00 | 00 | 00 | 08
波特率115200 停止位 校验 数据位
设备端再次进 endpoint0_out_isr,但这次读事务状态时 SETUP 标志 = 0(普通数据包),走 handle_last_status 的 common pack 分支。
怎么认出"这是 Line Coding 数据"?靠两个全局"记忆"(setup 阶段存下来的):
if(Request_type == 1 && USB_request.bRequest == 0x20)
{
// 解析 7 字节进 Line_Coding 结构体
}
阶段 3:状态
主机发 IN 令牌 → 固件准备的 ZLP → 主机回 ACK。
完整时序
① SETUP事务:SETUP令牌 + 8字节请求 → D12自动ACK → 固件解析+打印
② 数据事务:OUT令牌 + 7字节参数 → D12自动ACK → 固件解析进 Line_Coding
③ 状态事务:IN令牌 → 固件回 ZLP → 主机ACK
6.5 Line_Coding:默认值 vs 当前值
Line_Coding 结构体(规范 Table 50,7 字节):
| 偏移 | 字段 | 例值 |
|---|---|---|
| 0~3 | dwDTERate(波特率) | 115200 |
| 4 | bCharFormat(停止位) | 0(1 停止位) |
| 5 | bParityType(校验) | 0(无校验) |
| 6 | bDataBits(数据位) | 8 |
两个写入时刻:
- 上电默认值(
descriptor.c的init_descriptor()):设备还没被主机设置时的合理默认 - 主机覆盖(
usb.c的 common pack 分支):串口助手打开 COM 口时,Windows 发 SET_LINE_CODING 覆盖
为什么必须有默认值?
- 规范要求设备能随时回答 GET_LINE_CODING(查询"当前配置")——没默认值就只能返回垃圾
- 主机不一定会发 SET_LINE_CODING(规范说"可能被某些应用需要")
- 固件自身需要(理想情况用 Line_Coding 配置 USART2)
概念:默认值 = 出厂默认;当前值 = 主机最近设置(或默认值)。GET_LINE_CODING 返回"当前值"。
6.6 GET_LINE_CODING:设备→主机
setup 第一字节是 0xA1(D7=1,设备→主机):
A1 21 00 00 00 00 07 00
处理代码(handle_class_request case 0x21):
endpoint0_send_count = sizeof(Line_Coding_t); // 7
endpoint0_send_buf = (uint8_t *)&Line_Coding; // 结构体当数据源
endpoint0_send(); // 7字节发回主机
和 GET_DESCRIPTOR 同套路:设备→主机、有数据阶段、把结构体当字节流发出。状态阶段是主机发 0 长度 OUT,设备回 ACK(D12 自动)。
6.7 SET_CONTROL_LINE_STATE:无数据阶段
setup 第一字节 0x21,wValue 低字节带 DTR/RTS 状态:
21 22 01 00 00 00 00 00 ← wValue=0x0001:DTR 有效
处理代码(case 0x22):
printf("SET_CONTROL_LINE_STATE\r\n");
endpoint0_send_count = 0;
zero_pack = 1;
endpoint0_send(); // 无数据阶段,直接回 ZLP 完成状态
wValue 低字节:bit0 = DTR(DTE 是否在场),bit1 = RTS(规范 Table 51)。串口助手"打开"时 DTR 拉高、"关闭"时拉低。
6.8 三个类请求总结
| 请求 | 方向 | 数据阶段 | 主机何时发 |
|---|---|---|---|
| SET_LINE_CODING | 主机→设备 | 有(7字节) | 打开 COM 口 |
| GET_LINE_CODING | 设备→主机 | 有(7字节) | 软件查询参数 |
| SET_CONTROL_LINE_STATE | 主机→设备 | 无 | 打开/关闭 COM 口 |
6.9 实战:对照规范找代码 bug
对照 usbcdc11.pdf Table 50,Line Coding 的正确偏移是:bCharFormat=4、bParityType=5、bDataBits=6。
但代码写的是:
Line_Coding.bCharFormat = buf[5]; // BUG:应为 buf[4]
Line_Coding.bParityType = buf[6]; // BUG:应为 buf[5]
Line_Coding.bDataBits = buf[7]; // BUG:应为 buf[6],且越界
整体错位 1 字节。波特率(buf[0..3])正确,所以 115200 能生效;其他三个参数存错位置。
为什么"看起来能用"?
- 代码没有真正用 Line_Coding 配置 USART2(USART2 是初始化时写死的 115200/8N1)
- 串口助手的默认参数恰好和初始化默认值一致
这就是"查规范"的价值——规范在手边,一眼就能揪出代码错误。
6.10 自测题
- 标准请求和类请求怎么区分?(bmRequestType 的哪两位)
- 判断是否需要数据阶段的规则是什么?SET_CONTROL_LINE_STATE 为什么不需要?
- SET_LINE_CODING 的三个阶段分别由哪些包组成?各阶段设备端怎么处理?
- Line_Coding 为什么要有默认值?
- GET_LINE_CODING 和 SET_LINE_CODING 的方向区别?setup 第一字节分别是什么?
- 代码解析 Line_Coding 的偏移 bug 在哪?正确值是什么?
第 7 章 数据通路
7.1 学习目标
- 理解虚拟串口设备的"桥"本质
- 分清"桥"和"终点"两种模式
- 掌握下行方向(EP2 OUT → USART2)
- 掌握上行方向(USART2 → EP2 IN)
- 掌握 Serial_State 通知的实现
7.2 核心认知:虚拟串口设备是一座"桥"
虚拟串口设备本身不产生数据、不消费数据,它是一座桥,负责在 USB 和真实串口之间搬运字节。
电脑(串口助手)
│ USB线
▼
USB口 ←── 板子(STM32+D12)──→ UART引脚(PA2/PA3)
│ TX/RX
▼
外部真实串口设备
(GPS/传感器/另一块单片机...)
和你在淘宝买的 CH340 / CP2102 USB 转 TTL 小板做的事完全一样。
两种模式(重要):
| 模式 A:USB 转串口桥 | 模式 B:设备自己处理 | |
|---|---|---|
| 主机发数据 | 转发给 USART2 → 外部设备 | 固件自己处理(如存文件/解析命令) |
| 回数据 | 外部设备 → USART2 → EP2 IN | 固件处理完 → EP2 IN |
| USART2 | 必须用 | 不需要 |
| 典型产品 | CH340、USB 转 TTL | USB 采集卡、USB 下载器 |
USB 侧代码两种模式完全一样,区别只在 endpoint2_out_isr 里"拿到数据后干什么"。
7.3 三个端点的分工(双管道)
| 端点 | 类型 | 方向 | 干什么 |
|---|---|---|---|
| EP1 IN (0x81) | 中断 | 设备→主机 | 通知:状态事件(Serial_State) |
| EP2 IN (0x82) | 批量 | 设备→主机 | 数据:上行 |
| EP2 OUT (0x02) | 批量 | 主机→设备 | 数据:下行 |
数据走 EP2(批量),状态通知走 EP1(中断),两条管道互不干扰——这是 CDC 双管道设计的核心。
7.4 下行方向:EP2 OUT → USART2
现有代码逐行分析(endpoint2_out_isr)
void endpoint2_out_isr(void)
{
printf("endpoint2_out_isr\n\r"); // 调试打印
write_cmd(CMD_Read_Last_Transaction_Status + 4); // 清 EP2 OUT 中断标志
read_endpoint_buf(4); // 读 EP2 OUT 数据到 buf(返回值被丢弃!)
printf("recieve data: %s",buf); // 把 buf 当字符串打印(有越界风险)
write_cmd(CMD_Clear_buffer); // 清缓冲,释放 EP2 OUT
write_endpoint(5, 1, buf); // 回显:只发第 1 字节
}
三个值得注意的点:
- size 被丢弃是"只回显 1 字节"的原因
%s打印无长度限制、无 '\0' 结尾,可能越界- 这是"桥"要改的位置:把回显换成 USART2 转发
改造第一步:加一个 USART2 发送函数
在 usart.c 加(参照 fputc 的模式):
void usart2_send_bytes(uint8_t *data, uint16_t len)
{
uint16_t i;
for(i = 0; i < len; i++)
{
USART_SendData(USART2, data[i]); // 放入发送寄存器
while(USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); // 等发完
}
}
为什么必须等 TC 标志? TC=1 才说明上一字节真的发出去了,不等待连续发送会丢数据。
改造第二步:修改 endpoint2_out_isr
void endpoint2_out_isr(void)
{
uint8_t size;
printf("endpoint2_out_isr\n\r");
write_cmd(CMD_Read_Last_Transaction_Status + 4);
size = read_endpoint_buf(4); // ★ 接住长度
write_cmd(CMD_Clear_buffer); // ★ 先清 USB 缓冲
usart2_send_bytes(buf, size); // ★ 转发给 USART2
}
为什么先 Clear buffer 再发 USART2? USART2 发送比 USB 慢,先清缓冲让 USB 侧立即解放,能继续收下一包。
下行数据流
串口助手发 "ABC" → EP2 OUT → endpoint2_out_isr
→ size=3, buf="ABC" → Clear buffer → USART2 逐个发 A、B、C
→ 外部串口设备收到 "ABC"
7.5 上行方向:USART2 → EP2 IN
改造第一步:打开 USART2 接收中断
在 config_usart() 里加:
USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // 收到字节时触发中断
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = USART2_IRQn;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStruct);
改造第二步:写中断服务函数
在 stm32f10x_it.c 加(函数名必须是 USART2_IRQHandler,向量表规定):
void USART2_IRQHandler(void)
{
uint8_t data;
if(USART_GetITStatus(USART2, USART_IT_RXNE) == SET) // 确认是接收中断
{
data = USART_ReceiveData(USART2); // 读字节(读后 RXNE 自动清除)
write_endpoint(5, 1, &data); // 发到 EP2 IN(索引5)
}
}
三个要点:
USART_GetITStatus区分中断来源(USART 有多种中断)USART_ReceiveData读数据本身会清除 RXNE,忘了读会一直进中断write_endpoint(5, ...)是 EP2 IN(数据上行)
上行数据流
外部设备发 "X" → PA3(RX) → USART2 置 RXNE → USART2_IRQHandler
→ 读出 'X' → write_endpoint(5,1) → EP2 IN 缓冲 → 主机 IN 令牌取走 → 串口助手显示
7.6 Serial_State 通知(EP1 IN)
通知格式(10 字节)
A1 20 00 00 00 00 02 00 | XX XX
└bmRequestType┘└通知码┘└wValue┘└wIndex┘└wLen┘└─UART状态位图─┘
UART 状态位图(规范 Table 69):
| 位 | 信号 | 对应传统串口 |
|---|---|---|
| D0 | bRxCarrier | DCD |
| D1 | bTxCarrier | DSR |
| D2 | bBreak | Break |
| D3 | bRingSignal | 振铃 |
| D4 | bFraming | 帧错误 |
| D5 | bParity | 校验错误 |
| D6 | bOverRun | 溢出 |
发送函数
void send_serial_state(uint16_t uart_state)
{
uint8_t notify[10];
notify[0] = 0xA1; // 设备→主机、类、接口
notify[1] = 0x20; // SERIAL_STATE
notify[2] = 0x00; notify[3] = 0x00; // wValue = 0
notify[4] = 0x00; notify[5] = 0x00; // wIndex = 接口0
notify[6] = 0x02; notify[7] = 0x00; // wLength = 2
notify[8] = uart_state & 0xFF;
notify[9] = (uart_state >> 8) & 0xFF;
write_endpoint(3, 10, notify); // ★ 3 = EP1 IN 的 D12 端点索引
}
触发示例:帧错误通知
void USART2_IRQHandler(void)
{
uint8_t data;
if(USART_GetITStatus(USART2, USART_IT_RXNE) == SET)
{
data = USART_ReceiveData(USART2);
write_endpoint(5, 1, &data); // 数据走 EP2 IN
}
if(USART_GetFlagStatus(USART2, USART_FLAG_FE) == SET) // 帧错误
{
USART_ClearFlag(USART2, USART_FLAG_FE);
send_serial_state(0x0010); // D4=1 → 帧错误,走 EP1 IN
}
}
注意分工:正常数据走 write_endpoint(5,...)(EP2 IN),状态事件走 send_serial_state()(EP1 IN)——数据管道和通知管道各走各的。
7.7 三个工程层面的提醒
- 缓冲满:如果 EP2 IN 上一包还没被主机取走(缓冲"满"),
write_endpoint(5,...)直接写会覆盖。完整实现应先查端点 FULL/EMPTY(Select Endpoint 命令)。 - 一字节一包效率低:每收一字节发一个 1 字节 USB 包很浪费;完整实现应攒满 64 字节或检测串口空闲再发。
- 流控:USB 快、UART 慢,大数据量时缓冲区会溢出,需要环形缓冲 + 流控策略。
这些是"教学能通"和"产品能用"之间的差距,先知道存在即可。
7.8 双向通路完整图
下行:串口助手 → EP2 OUT → endpoint2_out_isr → usart2_send_bytes → USART2 TX → 外部设备
上行:外部设备 → USART2 RX → USART2_IRQHandler → write_endpoint(5) → EP2 IN → 串口助手
通知:错误/状态变化 → send_serial_state → EP1 IN → 主机解析
7.9 自测题
- "桥"和"终点"两种模式的区别?USB 侧代码哪里一样哪里不一样?
- 下行方向改造:
endpoint2_out_isr里删掉什么、加上什么?为什么先 Clear buffer? usart2_send_bytes为什么等 TC 标志?- 上行方向需要哪两步改造?中断函数名必须叫什么?
- Serial_State 通知走哪个端点?数据走哪个端点?各自的 D12 端点索引是多少?
- 一字节一包有什么问题?完整实现应该怎么做?
第 8 章 实践与实验
8.1 学习目标
- 通过 6 个实验把前面学的知识落地
- 学会用串口打印观察枚举和类请求
- 完成数据通路的双向改造
- 掌握常见问题的排查思路
8.2 实验环境
- 硬件:STM32F103RB + PDIUSBD12 开发板
- 软件:Keil MDK(打开
serial/StdProj.uvproj) - 调试串口:USART2(PA2/PA3),115200,8N1——用 USB 转 TTL 小板接电脑
- 串口助手:任意一款(如 XCOM、SSCOM)
注意:调试串口(USART2)和虚拟串口(USB)是两回事。调试串口是"板子 ↔ 电脑"的打印通道;虚拟串口是"电脑 ↔ 外部设备"的数据通道。如果板子只有一个 USART2,改造数据通路前先把调试打印分清(打印占用了 USART2,转发也用 USART2 会冲突——实际开发中调试串口和数据串口应分开,或改造时去掉打印)。
8.3 实验一:观察枚举过程
目标:用串口打印亲眼看到枚举的每一步。
步骤:
- 打开
serial工程,编译烧录 - 用 USB 线连接开发板和电脑
- 打开串口助手(接调试串口),观察打印
预期输出:
ID = 0x9212
reset_isr
GET_DESCRIPTOR->DEVICE_DESCRIPTOR (send length 18)
SET_ADDRESS: 1
GET_DESCRIPTOR->DEVICE_DESCRIPTOR (send length 18)
GET_DESCRIPTOR->CONFIGURATION_DESCRIPTOR (send length 9)
GET_DESCRIPTOR->CONFIGURATION_DESCRIPTOR (send length 67)
GET_DESCRIPTOR->STRING_DESCRIPTOR ...
SET_CONFIGURATION:1
对照检查:
ID = 0x9212:D12 芯片在线- 两次设备描述符:第一次地址 0,第二次新地址(验证地址切换)
- 配置描述符 9 → 67:两次请求(探路 + 全量)
SET_CONFIGURATION:1:枚举完成
如果枚举失败(没有这些打印):先查 ID = 0x9212 是否打印——没打印说明 D12 硬件/并口接线有问题。
8.4 实验二:观察类请求
目标:打开虚拟 COM 口,观察 Windows 发的类请求。
步骤:
- 枚举成功后,在设备管理器找到新出现的 COM 口
- 用串口助手打开该 COM 口(设置 115200/8N1)
- 观察调试串口打印
预期输出:
SET_LINE_CODING
Line_Coding set
SET_CONTROL_LINE_STATE
理解:
SET_LINE_CODING:setup 阶段(只打印)Line_Coding set:数据阶段(7 字节参数解析进结构体)SET_CONTROL_LINE_STATE:DTR/RTS 通知
可选进阶:修改波特率为 9600 再打开 COM 口,观察打印(并可以检查 Line_Coding 是否被更新——注意 6.9 节的偏移 bug)。
8.5 实验三:回环测试(自发自收)
目标:验证 USB 数据通路(当前代码的回显功能)。
步骤:
- 串口助手打开虚拟 COM 口
- 勾选"发送新行",发送 "ABC"
- 观察串口助手的接收区
预期:收到回显的 "A"(只回 1 字节,因为 read_endpoint_buf 的返回值被丢弃)。
理解:这个回显不是最终功能,而是"没有外部串口设备时的自测"——证明 USB 收发链路是通的。
8.6 实验四:改造下行方向(USB → USART2)
目标:把 EP2 OUT 收到的数据转发给 USART2。
改造(参照第 7 章 7.4):
usart.c加usart2_send_bytes()函数endpoint2_out_isr改为:接住 size → Clear buffer →usart2_send_bytes(buf, size)
验证:
- 把 USART2 的 TX(PA2)接到另一块板/USB 转 TTL 的 RX
- 串口助手发送 "ABC"
- 在另一端串口工具上应能收到 "ABC"
8.7 实验五:改造上行方向(USART2 → USB)
目标:USART2 收到的数据转发到 EP2 IN。
改造(参照第 7 章 7.5):
config_usart()加接收中断使能 + NVIC 配置stm32f10x_it.c加USART2_IRQHandler
验证:
- 在 USART2 的 RX(PA3)上输入字节(用另一块板发送)
- 电脑串口助手应显示收到的字节
提醒:如果调试打印也走 USART2,实验时打印会和数据混在一起。可以把
endpoint2_out_isr里的printf去掉。
8.8 实验六:完整回环测试
目标:USB ↔ UART 双向打通后的自发自收。
接线:把 USART2 的 TX(PA2)和 RX(PA3)直接短接(自环)。
测试:
- 串口助手打开虚拟 COM 口
- 发送 "ABC"
- 数据流:串口助手 → EP2 OUT → 固件 → USART2 TX →(短接线)→ USART2 RX → 中断 → EP2 IN → 串口助手
- 应收到 "ABC"
成功标志:串口助手自发自收完全一致,说明双向通路都通了。
8.9 常见问题排查
问题 1:设备管理器没有出现 COM 口 / 枚举失败
排查顺序:
- 调试串口是否打印
ID = 0x9212?没有 → D12 硬件/接线问题 - 是否打印
reset_isr?没有 → SoftConnect 或 INT 轮询问题 - 是否卡在某个请求?对照实验一的打印逐条检查
- 检查描述符是否与硬件一致(尤其
bMaxPacketSize0必须为 16)
问题 2:枚举成功但 COM 口是"感叹号"
- 检查
bInterfaceClass/SubClass/Protocol是否 0x02/0x02/0x01 - 检查 Union 的 master/slave 是否填对
- 检查 Line_Coding 相关请求是否正常响应
问题 3:能打开 COM 口但收发无反应
- 检查 SET_CONFIGURATION 是否执行(
endpoint2_out_isr是否触发) - 检查 EP2 端点描述符的地址/类型/大小是否与代码使用一致(0x82/0x02,批量,64)
- 下行:检查
read_endpoint_buf(4)的参数(EP2 OUT 索引 4) - 上行:检查
write_endpoint(5,...)的参数(EP2 IN 索引 5)和 USART2 中断是否使能
问题 4:数据错乱/丢字节
- USART2 波特率是否和外部设备一致
- 是否等待了 TC 标志(连续发送丢数据)
- 是否出现缓冲满覆盖(大数据量时)
问题 5:编译警告
int8_t*传给uint8_t*:类型不匹配,确认补码表示是有意为之
8.10 扩展挑战
- 修复偏移 bug:把
buf[5]/buf[6]/buf[7]改成buf[4]/buf[5]/buf[6] - 让波特率真正生效:SET_LINE_CODING 收到参数后,用
USART_Init重新配置 USART2 - 实现 Serial_State 通知:参照 7.6,在错误发生时发通知
- 攒包发送:用缓冲区攒满 64 字节或空闲超时再发,提高效率
- 环形缓冲 + 流控:解决大数据量时的溢出问题
做完这些,你的"教学工程"就接近"产品可用"了。
附录 术语表、规范索引与 FAQ
A.1 术语表
| 术语 | 含义 |
|---|---|
| 主机 Host | USB 总线的控制者(电脑) |
| 设备 Device | USB 外设(鼠标、串口、U盘) |
| 端点 Endpoint | 设备内带地址的缓冲区,数据通道终点 |
| 管道 Pipe | 主机与端点之间的数据通道 |
| 接口 Interface | 功能单元,驱动加载的粒度 |
| 配置 Configuration | 设备的工作模式(一套接口+端点组合) |
| 类 Class | USB 设备大类(HID、CDC、Mass Storage…) |
| 子类 SubClass | 类下的具体模型(ACM、Boot…) |
| 协议 Protocol | 数据交换规则(AT 命令集…) |
| 包 Packet | USB 总线上的最小传输单位 |
| 令牌包 Token | 指挥信号(IN/OUT/SETUP/SOF) |
| 数据包 Data | 携带数据的包(DATA0/DATA1) |
| 握手包 Handshake | 确认信号(ACK/NAK/STALL/NYET) |
| 事务 Transaction | 包的组合(令牌+数据+握手) |
| 控制传输 Control | 用于枚举和类请求的双向可靠传输 |
| 中断传输 Interrupt | 低延迟小数据周期传输 |
| 批量传输 Bulk | 可靠大块无时间保证传输 |
| 枚举 Enumeration | 主机了解设备并分配资源的过程 |
| 描述符 Descriptor | 设备给主机看的结构化数据 |
| setup 包 | 控制传输建立阶段携带的 8 字节请求数据 |
| ZLP 零长度包 | 长度 0 的数据包,传输完成信号 |
| CDC | 通信设备类(Communication Device Class) |
| ACM | 抽象控制模型(虚拟串口的子类) |
| AT 命令 | 源自调制解调器的文本命令语言 |
| SoftConnect | D12 的软连接(命令控制 D+ 上拉) |
| Line Coding | 串口参数集合(波特率/停止位/校验/数据位) |
| Serial_State | CDC 的 UART 状态通知 |
| SIE | 串行接口引擎(USB 协议硬件) |
| BCD | 二进制编码十进制 |
| VID / PID | 厂商 ID / 产品 ID |
A.2 D12 端点索引速查表
| USB 端点地址 | 端点号 | 方向 | D12 端点索引 | 本课程用途 |
|---|---|---|---|---|
| 0x00 | 0 | 控制 OUT | 0 | 枚举/类请求接收(EP0 OUT) |
| 0x80 | 0 | 控制 IN | 1 | 枚举/类请求响应(EP0 IN) |
| 0x01 | 1 | 通用 OUT | 2 | 未用 |
| 0x81 | 1 | 通用 IN | 3 | CDC 通知(EP1 IN) |
| 0x02 | 2 | 批量 OUT | 4 | CDC 数据下行(EP2 OUT) |
| 0x82 | 2 | 批量 IN | 5 | CDC 数据上行(EP2 IN) |
记忆要点:端点地址是"协议世界"的寻址(主机用),端点索引是"D12 芯片世界"的内部编号(固件用)。代码里
write_endpoint(3,...)的 3 就是索引,对应 USB 端点地址 0x81。
A.3 规范资料索引
| 资料 | 位置 | 关键章节 |
|---|---|---|
| 教程 PPT | resource/手把手教你玩USB开发.ppt |
4-6 页体系结构、8 页请求、13-21 页描述符、22-28 页 CDC、29-33 页包与事务 |
| D12 数据手册 | resource/PIDUSBD12数据手册.pdf |
6 功能描述、8 端点结构、10 命令总表、11 命令详解 |
| HID 规范 | resource/hid1_11.pdf |
5 操作模型、6.2 描述符、7 请求、附录 B/E |
| CDC 规范 | resource/usbcdc11.pdf |
3 类/子类/协议、5.2.3 功能描述符、6.2 类请求、Table 50/51/69 |
A.4 学习方法 FAQ
Q1:这些位定义、字段含义都是哪来的?
来自规范文档。USB 通用规范定义标准描述符和标准请求;类规范(CDC/HID)定义类特定内容;芯片手册定义芯片命令。遇到不懂的先判断属于哪一层,再去对应文档查字段定义或位定义表。
Q2:要不要背规范?
不要。规范是字典,查就行。正确姿势是"遇到问题 → 定位章节 → 查表 → 懂了关掉 → 忘了再查"。
Q3:字段名里的 b、w、bm、bcd 是什么意思?
USB 命名约定:b=1 字节(byte)、w=2 字节(word)、bm=位图(bitmap)、bcd=BCD 编码。看名字能猜个大概。
Q4:描述符的值为什么"错一个就枚举失败"?
因为主机按描述符的声明调度(包大小、端点、类),声明和硬件/规范不符就会出错。尤其 bMaxPacketSize0、wMaxPacketSize 这类"硬件能力"字段,必须和芯片一致。
Q5:D12 的中断为什么要"清标志"?
中断位不清理,D12 认为事件未处理完,INT_N 保持低电平,主循环会反复处理同一事件(死循环)。端点位靠读 Last Transaction Status 清除,非端点位靠读中断寄存器清除。
Q6:为什么设备不能主动发数据?
USB 是主从结构,一切传输由主机发起。设备只能"准备好等取"(写入缓冲 + Validate),真正发送靠主机 IN 令牌。
Q7:描述符声明了端点,代码就必须用它吗?
不一定。描述符是"对外声明",代码是"实际行为"。本工程声明了 EP1 IN(通知)但代码没实现通知——声明存在、行为空转,不影响基本功能。
Q8:为什么枚举配置描述符要请求两次?
主机一开始不知道总长(wTotalLength 藏在配置描述符头里)。先要 9 字节读总长,再要全量。
Q9:虚拟串口和真实串口有什么区别?
虚拟串口是 USB 用软件模拟的串口,波特率等参数只是"配置数据"(存进 Line_Coding),真正传数据走的是 USB 批量端点。USB 侧没有波特率概念。
Q10:这个工程和真实产品的差距在哪?
主要有:类请求只实现了最少的三个、Serial_State 通知没实现、Line_Coding 没真正配置 USART2、一字节一包效率低、无缓冲满处理和流控、reset_isr 没清软件状态变量。这些是"教学能通"和"产品能用"的差距。
Q10.5:CDC 虚拟串口设备级写 0 可以吗?
可以,Windows 照样识别为 COM 口——驱动匹配看接口描述符,设备级只是提示信息。设备级写 0x02 更贴合 CDC 规范推荐,写 0 是"万能安全写法"。注意边界:设备级非 0 时所有接口必须属于该类,混合设备必须写 0。详细对比见第 1 章 1.9.6 和第 5 章 5.3.1。
A.5 自测题参考答案(简要)
第 1 章
- IN=设备→主机,OUT=主机→设备,以主机为参照
- 端点 1 的 IN 和 OUT,两个独立端点(方向是地址的一部分)
- 控制/中断/批量/同步;CDC 数据=批量,鼠标=中断
- 接口管功能、配置管模式;"2 个接口"指音频控制+音频流两个功能单元
- 大类/模型/规则;CDC 虚拟串口 = 0x02(类)/0x02(ACM 子类)/0x01(AT 协议)
- 设备级声明整机类别(提示信息),接口级声明每个功能(驱动加载依据);接口级真正决定
- 接口类混合时(设备级非 0 要求所有接口同类);可以,Windows 照样识别为 COM 口
- 配置层面(bMaxPower 在配置描述符);上限承诺,不是实际功耗
第 2 章
- 令牌(指挥)/数据(货物)/握手(回执)
- SETUP 令牌包 + 8 字节 setup 数据;代码解析的是后者
- 令牌包
- SETUP令牌 → DATA0(8字节) → ACK;方向恒为主机→设备,数据恒为 DATA0
- 建立=SETUP事务,数据=IN/OUT事务,状态=IN或OUT事务
- ACK 包级/D12 自动,ZLP 传输级/固件准备;SET_LINE_CODING 状态阶段主机发 IN、设备回 ZLP
- 不是;SET_LINE_CODING、GET_STATUS、SET_FEATURE 等运行中也会发生
- 令牌包只有 PID+地址+端点+CRC5(3字节),没有数据区装不下 8 字节;数据由数据包承载
- 建立宣告请求、数据搬载荷、状态判定成败;ACK/ZLP 成功、NAK 忙、STALL 不支持
- 不包含,三阶段是控制传输独有;其他三种是纯数据搬运,一次传输=一个事务
- 数据长度是 wMaxPacketSize 整数倍时补 ZLP 终止(规范8.5.3.2);同步传输没有 ZLP
- 不是,ZLP 是包、状态事务是事务;不矛盾——"是包"说物理形态,"传输级"说语义角色
第 3 章
- 插线只供电,主机靠 D+ 拉高识别;SoftConnect 让 MCU 掌握暴露时机
- 启动→main 初始化→SoftConnect→主机检测→总线复位→reset_isr→第一个请求
- 设备描述符→SET_ADDRESS→设备描述符(验证)→配置描述符(9+67)→字符串→SET_CONFIGURATION
- 规范要求状态阶段完成后才生效;主机用新地址重发设备描述符验证
- 主机先读 wTotalLength;设备靠 min(wLength, wTotalLength) 区分
- 端点使能(D8)之前 EP2 禁用,中断函数不会触发
- 复位→设备描述符→SET_ADDRESS→配置描述符→SET_CONFIGURATION;请求长度、字符串、复位次数等细节各 OS 不同
- Attached→Powered→Default→Address→Configured;主机再次总线复位会打回 Default
- 回到 Address(解除配置);主机再次总线复位会直接回到 Default
第 4 章
- 像 2 地址存储器;A0=1 命令格、A0=0 数据格
- 3=EP1 IN、4=EP2 OUT、5=EP2 IN
- 初始化(D0/D8/F3)/数据流(F0~F4/F1/F2/FA/40-45)/通用(F5/F6)
- 字节0保留 + 字节1长度
- 端点位读 40-45,非端点位读中断寄存器;不清会死循环
- SETUP 到达时 D12 锁定,须对 IN/OUT 都 ACK 解锁
第 5 章
- 设备级类非 0 时所有接口必须属于该类;CDC 所有接口都是通信类,HID 可能混合
- 配置9+通信接口9+Header5+CallMgmt5+ACM4+Union5+通信端点7+数据接口9+数据端点7+7
- Header 版本、CallMgmt 管不管呼叫、ACM 支持哪些命令、Union 绑定接口组
- master=0(通信接口)、slave=1(数据接口);绑定成功能单元
- 串口数据大且不要求实时(批量),鼠标数据小且延迟敏感(中断)
- 同编号(2)不同方向(IN/OUT)的两个独立端点
- 该接口声明的功能端点数量(方向区分、不含端点0);端点0是设备级公共设施不属于任何接口
- 索引 3;不是一套系统——bNumEndpoints 是协议计数,D12 索引是芯片内部编号
第 6 章
- bmRequestType 的 D6:D5(00 标准/01 类)
- 参数能否塞进 wValue/wIndex;DTR/RTS 只有 2 位,wValue 装得下
- 见 6.4 节时序
- 设备要能回答 GET_LINE_CODING、主机不一定会设置、固件自身要用
- GET=设备→主机(0xA1),SET=主机→设备(0x21)
buf[5]/[6]/[7]→ 应为buf[4]/[5]/[6]
第 7 章
- 桥转发到外部,终点自己处理;USB 侧一样,
endpoint2_out_isr中间一行不同 - 删掉回显,加上接住 size 和 usart2_send_bytes;先清缓冲让 USB 继续收
- 连续发送需要确认上一字节发完
- 开接收中断 + 写
USART2_IRQHandler - 通知走 EP1 IN(索引3),数据走 EP2 IN(索引5)
- 一个 64 字节包只装 1 字节,浪费带宽;攒满或空闲再发
A.6 推荐的学习路线回顾
第1章 基础概念 → 第2章 包与事务 → 第3章 枚举流程
→ 第4章 D12驱动 → 第5章 CDC描述符 → 第6章 类请求
→ 第7章 数据通路 → 第8章 动手实验
每章的自测题全部能独立回答后,你已经具备读懂这个工程、并动手改造它的能力了。祝学习顺利!

浙公网安备 33010602011771号