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 规范的全部内容。正确姿势是:

  1. 判断问题属于哪一层(USB 通用规范 / 类规范 / 芯片手册)
  2. 看字段名猜测语义(b=字节、w=2字节、bm=位图、bcd=BCD 编码)
  3. 在规范目录里定位章节
  4. 读字段定义或位定义表
  5. 回到代码验证

本课程的 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):扩展口,让一个主机口接多个设备

最重要的规则:所有传输都由主机发起,设备永远被动响应。设备不能"主动说话"。

flowchart LR H["🖥️ 主机 Host"] -->|USB总线| Hub["🔌 集线器 Hub"] Hub --> D1["🖱️ 设备1 鼠标"] Hub --> D2["🔢 设备2 虚拟串口"] Hub --> D3["💾 设备3 U盘"] style H fill:#e1f5fe,stroke:#0288d1,stroke-width:2px style Hub fill:#fff3e0,stroke:#f57c00,stroke-width:2px style D1 fill:#e8f5e9,stroke:#388e3c style D2 fill:#e8f5e9,stroke:#388e3c style D3 fill:#e8f5e9,stroke:#388e3c

1.3 方向:IN 和 OUT(以主机为参照)

这是初学者最容易绕晕的点,记住一句话:

IN = 设备 → 主机;OUT = 主机 → 设备。

方向永远以主机视角定义:

方向 数据流向 举例
IN 设备 → 主机 鼠标报告发给电脑
OUT 主机 → 设备 电脑往虚拟串口发数据

1.4 端点(Endpoint):带地址的缓冲区

端点是 USB 设备内部、有真实硬件支撑的"数据通道终点"——具体表现为控制器里的缓冲区 + 寄存器。

每个端点有属性:

  • 端点号:0~15(4 位),设备级分配
  • 方向:IN 或 OUT(各是独立端点)
  • 传输类型:控制/中断/批量/同步
  • 最大包大小
  • 轮询间隔(中断端点)

几个关键规则:

  1. 端点地址 = 编号 + 方向0x81 = 1 号端点 IN,0x01 = 1 号端点 OUT,是两个独立端点。
  2. 端点 0 特殊:双向、专用于控制传输,每个设备必须有。它不区分 IN/OUT 身份,因为控制传输本来就要一来一回。
  3. 设备最多 32 个端点(16 IN + 16 OUT)。
  4. 一个接口可以挂多个端点(比如 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(编号)

两者关系:配置是"容器",接口是"内容":

flowchart TD D[设备] --> C1[配置1(模式A)] D --> C2[配置2(模式B,可选)] C1 --> I1[接口0(功能A)] C1 --> I2[接口1(功能B)] I1 --> E1[端点] I2 --> E2[端点]

一句话记忆:接口管功能,配置管模式。

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) 没有 不能(它只换端点组合)

两个必须记住的修正:

  1. bMaxPower 是"电流上限承诺",不是实际功耗。设备承诺"在这个配置下最多取这么多电",主机拿它做电源预算。允许用得多 ≠ 真的用得多;但承诺了 200mA,就绝不能超 200mA。
  2. 配置 = 整套模式,电流只是其中一个属性。配置还包含接口组合、端点、供电方式(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 节)

规则 1bDeviceClass = 0 时——类由各接口自己声明,设备级子类/协议必须为 0

规则 2bDeviceClass ≠ 0 时——设备的所有接口必须都属于这个类;设备级子类/协议的取值由该类规范规定。

规则 3:驱动匹配看接口级。设备级只是"预期提示",Windows 实际是按接口描述符的 bInterfaceClass/SubClass/Protocol 找驱动。

1.9.3 赋值参考依据(4 步)

  1. 查 USB 通用规范(9.6.2):确定"何时必须为 0"的规则
  2. 查类代码表(USB-IF 官方):确定大类代码(HID=0x03、CDC=0x02、Mass Storage=0x08、CDC Data=0x0A、Misc=0xEF)
  3. 查具体类规范:确定子类/协议代码 + 该类对设备级写法的规定(如 CDC 规范规定设备级用 0x02/0x00/0x00
  4. 回代码验证:设备级和接口级不冲突

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 自测题

  1. IN 和 OUT 分别指哪个方向?以谁为参照?
  2. 0x810x01 是什么关系?为什么是两个端点?
  3. 四种传输类型分别是什么?CDC 数据用哪种、鼠标数据用哪种?
  4. 接口和配置的区别是什么?"USB 音箱 2 个接口"中的"2"指什么?
  5. 类/子类/协议分别回答什么问题?CDC 虚拟串口的三个值是什么?
  6. 设备级和接口级的类声明有什么区别?谁真正决定驱动加载?
  7. 什么情况下设备级必须写 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 包"常被混用,必须拆开:

  1. SETUP 令牌包:令牌包的一种(PID=0x2D),控制传输第一阶段的"指挥信号"
  2. setup 数据(口语常说的"setup 包"):紧跟在 SETUP 令牌后的 DATA0 数据包里装的 8 字节请求数据(bmRequestType、bRequest、wValue、wIndex、wLength)

在本课程的代码语境里usb.chandle_last_statusUSB_request 结构体),说的都是第 2 种——那 8 字节请求数据。

2.6.1 为什么 8 字节 setup 数据放在 DATA0 数据包里?

既然令牌包没有数据区(物理上只有 3 字节,见 2.2.1),8 字节请求数据只能由数据包承载。协议这样设计还有四个理由:

  1. 令牌必须短、快、能被所有设备监听:令牌是寻址信号,所有设备都要监听并做地址匹配;令牌越短,总线占用越少、匹配越快
  2. 校验强度不同:令牌用 CRC5(只校验地址/端点号),数据用 CRC16(校验数据完整性)
  3. 重传机制不同:数据包有 DATA0/DATA1 切换机制(见 2.10),令牌包不需要
  4. 与 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 令牌后会发生什么?

  1. D12 硬件识别 SETUP 令牌、校验地址
  2. 接收 DATA0(8 字节)存入 EP0 OUT 缓冲区
  3. 自动回 ACK
  4. 进入"锁定"状态:清空 IN 缓冲、禁用 Validate/Clear Buffer 命令(防止固件没读到 setup 旧数据就被发出去)
  5. 置中断位 EP0 OUT,拉低 INT_N 通知 MCU
  6. 固件读事务状态时发现 SETUP 标志位(bit5),从而知道"这是 setup 数据"
  7. 固件对 IN/OUT 双端点执行 Acknowledge Setup(解锁),再解析 8 字节

这正是代码里 handle_last_statusif(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(设备→主机)为例:

sequenceDiagram participant H as 主机 participant D as 设备 Note over H,D: 阶段1 建立(SETUP事务) H->>D: SETUP令牌 + DATA0(8字节请求) D->>H: ACK Note over H,D: 阶段2 数据(IN事务,设备→主机) H->>D: IN令牌 D->>H: DATA1(7字节 Line_Coding) H->>D: ACK Note over H,D: 阶段3 状态(OUT事务,主机→设备) H->>D: OUT令牌 + DATA1(0长度) D->>H: ACK

判断是否有数据阶段:请求参数能否塞进 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 自测题

  1. USB 的包分哪三类?各自一句话作用?
  2. "setup 包"的两个含义是什么?代码里解析的是哪个?
  3. 哪一类包携带"设备地址+端点号"?
  4. SETUP 事务的包序列是什么?数据方向有什么固定规则?
  5. 控制传输的三阶段分别由什么事务组成?
  6. ACK 和 ZLP 的区别是什么?SET_LINE_CODING 的状态阶段谁发 IN 令牌、谁回 ZLP?
  7. 控制传输只发生在上电阶段吗?举例说明运行中的控制请求。
  8. 为什么 8 字节 setup 数据要放在 DATA0 数据包里而不是令牌包里?(提示:令牌包的物理结构)
  9. 控制传输三阶段各自的作用是什么?状态段设备可以表达哪三种态度?
  10. 中断/批量/同步传输包含"建立-数据-状态"三段吗?为什么?
  11. ZLP 在批量传输中什么时候需要?同步传输有 ZLP 吗?
  12. 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 上电后:

  1. 启动文件(startup_stm32f10x_md.s)初始化栈和向量表
  2. 调用 SystemInit()system_stm32f10x.c)配置时钟(8MHz 晶振 → 72MHz)
  3. 跳进 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 主机侧:检测到设备 → 总线复位

  1. D+ 被拉高 → 主机根集线器检测到电平变化 → 判定"有全速设备插入"
  2. 主机发总线复位(SE0 保持 10ms 以上),把设备"归零"
  3. D12 收到复位:
    • 硬件自动复位:地址回 0、非控制端点禁用、EP0 待命
    • 中断寄存器置 BUS RESET 位(0x40),拉低 INT_N
  4. 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 字节里。所以:

  1. 先请求 9 字节(配置描述符头),读出 wTotalLength = 67
  2. 再请求 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 规范把设备的一生定义为状态机(九态,简化如下):

stateDiagram-v2 [*] --> Attached: 设备插入(检测到D+变化) Attached --> Powered: 获得VBUS电源 Powered --> DefaultState: 主机发总线复位 DefaultState --> Address: SET_ADDRESS成功 Address --> Configured: SET_CONFIGURATION成功 Note right of Configured: 正常工作状态<br/>可收发业务数据 Powered --> Suspended: 总线空闲3ms DefaultState --> Suspended: 总线空闲3ms Address --> Suspended: 总线空闲3ms Configured --> Suspended: 总线空闲3ms Suspended --> Powered: 收到恢复信号(Resume) Configured --> Address: 主机解除配置SET_CONFIGURATION(0) Address --> DefaultState: 主机再次总线复位 Attached --> [*]: 拔出设备

一句话记忆:设备一生在 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 枚举请求序列(细化版)

sequenceDiagram participant H as 主机 participant D as 设备 Note over H,D: 阶段0 物理接入 H->>H: 检测到 D+ 拉高 → 判断设备插入 H->>D: 总线复位(SE0 保持 ≥10ms) Note over D: D12 硬件自动归零:地址0、端点禁用<br/>触发 reset_isr Note over H,D: 阶段1 读设备描述符(地址0) H->>D: GET_DESCRIPTOR(Device), wLength=64 D->>H: 18字节设备描述符(16+2分包) Note over H,D: 阶段2 分配地址 H->>D: SET_ADDRESS(1) D->>H: ZLP(状态阶段完成,地址此刻生效) Note over H,D: 阶段3 验证 + 获取准确bMaxPacketSize0 H->>D: GET_DESCRIPTOR(Device), wLength=18(新地址) D->>H: 18字节设备描述符 Note over H,D: 阶段4 读配置描述符集合 H->>D: GET_DESCRIPTOR(Config), wLength=9(探路) D->>H: 9字节 → 主机读出 wTotalLength=67 H->>D: GET_DESCRIPTOR(Config), wLength=67(全量) D->>H: 67字节(16+16+16+16+3分包) Note over H,D: 阶段5 字符串描述符(可选但通常做) H->>D: GET_DESCRIPTOR(String)(语言ID + 厂商/产品/序列号) D->>H: 字符串描述符(UTF-16LE) Note over H,D: 阶段6 启用配置 H->>D: SET_CONFIGURATION(1) D->>H: ZLP Note over H,D: 设备进入 Configured 状态,枚举完成

对照 3.6:这张图是它的一次细化——补上了"总线复位后设备状态机推进"的视角,以及每个阶段结束后设备处于什么状态。

3.10.5 配置之后可能经历的过程

设备进入 Configured 状态后,一生还没结束:

flowchart TD C[Configured 正常工作] --> B[业务传输<br/>中断/批量/同步数据] C --> R[类请求<br/>CDC: SET_LINE_CODING等<br/>HID: SET_IDLE等] C --> S[标准请求<br/>GET_STATUS/SET_FEATURE等] C --> P[总线空闲3ms → 挂起 Suspended] P --> W[Resume 唤醒 → 回到 Configured] P --> R2[远程唤醒 Remote Wakeup<br/>设备主动发恢复信号] R2 --> C C --> RST[主机再次总线复位 → 退回 Default<br/>重新枚举一遍] RST --> C C --> DET[拔出 → 回到 Attached → 断开]

3.10.6 四个关键认知

  1. Configured 不等于"只传业务数据"——类请求、标准请求随时可能插进来(走 EP0 控制管道,和业务传输互不干扰)
  2. 挂起是常态:USB 总线空闲 3ms 设备就进挂起,电脑休眠/待机时全总线设备都会挂起;唤醒后可继续工作
  3. 重新枚举随时可能:主机可以再次复位设备(驱动重载、配置变更),设备会被打回 Default 重新走一遍枚举——所以固件的 reset_isr 和描述符处理必须能反复执行
  4. 断开:拔出后设备回到初始状态

3.11 自测题

  1. 为什么"插线 ≠ 立即枚举"?SoftConnect 解决了什么问题?
  2. 从插线到主机发出第一个请求,经历了哪些阶段?各用了哪些函数?
  3. 枚举请求的完整序列是什么?
  4. SET_ADDRESS 的地址为什么延迟生效?主机怎么验证?
  5. 配置描述符为什么请求两次?设备端怎么区分?
  6. SET_CONFIGURATION 之后,为什么 endpoint2_out_isr 才开始工作?
  7. 枚举骨架的五个强制步骤是什么?为什么说"细节因 OS 而异"?
  8. 设备状态机的推进路径是什么?什么事件会把设备打回 Default 状态?
  9. 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 自测题

  1. "D12 像存储器"指的是什么?A0 线的两个电平分别代表什么?
  2. 端点索引 3、4、5 分别对应哪个端点?
  3. 命令分哪三大类?F0、F1、F2、FA、D0、D8 分别干什么?
  4. D12 缓冲的前两个字节是什么?
  5. 中断位怎么清除?为什么必须清?
  6. 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 字节全景

flowchart TD A[配置描述符 9B] --> B[通信接口描述符 9B<br/>接口0 控制面] B --> C[Header 功能描述符 5B] C --> D[Call Management 功能描述符 5B] D --> E[ACM 功能描述符 4B] E --> F[Union 功能描述符 5B] F --> G[通信端点描述符 7B<br/>0x81 中断IN 通知] G --> H[数据接口描述符 9B<br/>接口1 数据面] H --> I[数据端点描述符1 7B<br/>0x82 批量IN] I --> J[数据端点描述符2 7B<br/>0x02 批量OUT]

总计: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)。

0x820x02 唯一区别是方向位(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 赋值检查清单

  1. 数一数这个接口下面写了几个端点描述符(一个方向算一个)
  2. 端点 0 不数
  3. 填进去的数字必须和端点描述符的实际数量严格一致——填多了,主机按 bLength 解析时会期待更多端点描述符;填少了,多出来的端点描述符会被当成别的东西解析

5.8 自测题

  1. 为什么 CDC 的设备描述符 bDeviceClass 可以写 0x02,而 HID 必须写 0?
  2. 67 字节由哪 10 段组成?
  3. 四个功能描述符分别回答什么问题?
  4. Union 的 master 和 slave 分别填什么?作用是什么?
  5. 为什么 CDC 数据用批量端点而鼠标用中断端点?
  6. 0x82 和 0x02 是什么关系?
  7. bNumEndpoints 数的是什么?端点 0 为什么除外?
  8. 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_statuscommon 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

两个写入时刻

  1. 上电默认值descriptor.cinit_descriptor()):设备还没被主机设置时的合理默认
  2. 主机覆盖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 能生效;其他三个参数存错位置。

为什么"看起来能用"?

  1. 代码没有真正用 Line_Coding 配置 USART2(USART2 是初始化时写死的 115200/8N1)
  2. 串口助手的默认参数恰好和初始化默认值一致

这就是"查规范"的价值——规范在手边,一眼就能揪出代码错误。

6.10 自测题

  1. 标准请求和类请求怎么区分?(bmRequestType 的哪两位)
  2. 判断是否需要数据阶段的规则是什么?SET_CONTROL_LINE_STATE 为什么不需要?
  3. SET_LINE_CODING 的三个阶段分别由哪些包组成?各阶段设备端怎么处理?
  4. Line_Coding 为什么要有默认值?
  5. GET_LINE_CODING 和 SET_LINE_CODING 的方向区别?setup 第一字节分别是什么?
  6. 代码解析 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 字节
}

三个值得注意的点:

  1. size 被丢弃是"只回显 1 字节"的原因
  2. %s 打印无长度限制、无 '\0' 结尾,可能越界
  3. 这是"桥"要改的位置:把回显换成 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 三个工程层面的提醒

  1. 缓冲满:如果 EP2 IN 上一包还没被主机取走(缓冲"满"),write_endpoint(5,...) 直接写会覆盖。完整实现应先查端点 FULL/EMPTY(Select Endpoint 命令)。
  2. 一字节一包效率低:每收一字节发一个 1 字节 USB 包很浪费;完整实现应攒满 64 字节或检测串口空闲再发。
  3. 流控: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 自测题

  1. "桥"和"终点"两种模式的区别?USB 侧代码哪里一样哪里不一样?
  2. 下行方向改造:endpoint2_out_isr 里删掉什么、加上什么?为什么先 Clear buffer?
  3. usart2_send_bytes 为什么等 TC 标志?
  4. 上行方向需要哪两步改造?中断函数名必须叫什么?
  5. Serial_State 通知走哪个端点?数据走哪个端点?各自的 D12 端点索引是多少?
  6. 一字节一包有什么问题?完整实现应该怎么做?

第 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 实验一:观察枚举过程

目标:用串口打印亲眼看到枚举的每一步。

步骤

  1. 打开 serial 工程,编译烧录
  2. 用 USB 线连接开发板和电脑
  3. 打开串口助手(接调试串口),观察打印

预期输出

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 发的类请求。

步骤

  1. 枚举成功后,在设备管理器找到新出现的 COM 口
  2. 用串口助手打开该 COM 口(设置 115200/8N1)
  3. 观察调试串口打印

预期输出

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 数据通路(当前代码的回显功能)。

步骤

  1. 串口助手打开虚拟 COM 口
  2. 勾选"发送新行",发送 "ABC"
  3. 观察串口助手的接收区

预期:收到回显的 "A"(只回 1 字节,因为 read_endpoint_buf 的返回值被丢弃)。

理解:这个回显不是最终功能,而是"没有外部串口设备时的自测"——证明 USB 收发链路是通的。

8.6 实验四:改造下行方向(USB → USART2)

目标:把 EP2 OUT 收到的数据转发给 USART2。

改造(参照第 7 章 7.4):

  1. usart.cusart2_send_bytes() 函数
  2. 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):

  1. config_usart() 加接收中断使能 + NVIC 配置
  2. stm32f10x_it.cUSART2_IRQHandler

验证

  • 在 USART2 的 RX(PA3)上输入字节(用另一块板发送)
  • 电脑串口助手应显示收到的字节

提醒:如果调试打印也走 USART2,实验时打印会和数据混在一起。可以把 endpoint2_out_isr 里的 printf 去掉。

8.8 实验六:完整回环测试

目标:USB ↔ UART 双向打通后的自发自收。

接线:把 USART2 的 TX(PA2)和 RX(PA3)直接短接(自环)。

测试

  1. 串口助手打开虚拟 COM 口
  2. 发送 "ABC"
  3. 数据流:串口助手 → EP2 OUT → 固件 → USART2 TX →(短接线)→ USART2 RX → 中断 → EP2 IN → 串口助手
  4. 应收到 "ABC"

成功标志:串口助手自发自收完全一致,说明双向通路都通了。

8.9 常见问题排查

问题 1:设备管理器没有出现 COM 口 / 枚举失败

排查顺序:

  1. 调试串口是否打印 ID = 0x9212?没有 → D12 硬件/接线问题
  2. 是否打印 reset_isr?没有 → SoftConnect 或 INT 轮询问题
  3. 是否卡在某个请求?对照实验一的打印逐条检查
  4. 检查描述符是否与硬件一致(尤其 bMaxPacketSize0 必须为 16)

问题 2:枚举成功但 COM 口是"感叹号"

  • 检查 bInterfaceClass/SubClass/Protocol 是否 0x02/0x02/0x01
  • 检查 Union 的 master/slave 是否填对
  • 检查 Line_Coding 相关请求是否正常响应

问题 3:能打开 COM 口但收发无反应

  1. 检查 SET_CONFIGURATION 是否执行(endpoint2_out_isr 是否触发)
  2. 检查 EP2 端点描述符的地址/类型/大小是否与代码使用一致(0x82/0x02,批量,64)
  3. 下行:检查 read_endpoint_buf(4) 的参数(EP2 OUT 索引 4)
  4. 上行:检查 write_endpoint(5,...) 的参数(EP2 IN 索引 5)和 USART2 中断是否使能

问题 4:数据错乱/丢字节

  • USART2 波特率是否和外部设备一致
  • 是否等待了 TC 标志(连续发送丢数据)
  • 是否出现缓冲满覆盖(大数据量时)

问题 5:编译警告

  • int8_t* 传给 uint8_t*:类型不匹配,确认补码表示是有意为之

8.10 扩展挑战

  1. 修复偏移 bug:把 buf[5]/buf[6]/buf[7] 改成 buf[4]/buf[5]/buf[6]
  2. 让波特率真正生效:SET_LINE_CODING 收到参数后,用 USART_Init 重新配置 USART2
  3. 实现 Serial_State 通知:参照 7.6,在错误发生时发通知
  4. 攒包发送:用缓冲区攒满 64 字节或空闲超时再发,提高效率
  5. 环形缓冲 + 流控:解决大数据量时的溢出问题

做完这些,你的"教学工程"就接近"产品可用"了。

附录 术语表、规范索引与 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:描述符的值为什么"错一个就枚举失败"?

因为主机按描述符的声明调度(包大小、端点、类),声明和硬件/规范不符就会出错。尤其 bMaxPacketSize0wMaxPacketSize 这类"硬件能力"字段,必须和芯片一致。

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 章

  1. IN=设备→主机,OUT=主机→设备,以主机为参照
  2. 端点 1 的 IN 和 OUT,两个独立端点(方向是地址的一部分)
  3. 控制/中断/批量/同步;CDC 数据=批量,鼠标=中断
  4. 接口管功能、配置管模式;"2 个接口"指音频控制+音频流两个功能单元
  5. 大类/模型/规则;CDC 虚拟串口 = 0x02(类)/0x02(ACM 子类)/0x01(AT 协议)
  6. 设备级声明整机类别(提示信息),接口级声明每个功能(驱动加载依据);接口级真正决定
  7. 接口类混合时(设备级非 0 要求所有接口同类);可以,Windows 照样识别为 COM 口
  8. 配置层面(bMaxPower 在配置描述符);上限承诺,不是实际功耗

第 2 章

  1. 令牌(指挥)/数据(货物)/握手(回执)
  2. SETUP 令牌包 + 8 字节 setup 数据;代码解析的是后者
  3. 令牌包
  4. SETUP令牌 → DATA0(8字节) → ACK;方向恒为主机→设备,数据恒为 DATA0
  5. 建立=SETUP事务,数据=IN/OUT事务,状态=IN或OUT事务
  6. ACK 包级/D12 自动,ZLP 传输级/固件准备;SET_LINE_CODING 状态阶段主机发 IN、设备回 ZLP
  7. 不是;SET_LINE_CODING、GET_STATUS、SET_FEATURE 等运行中也会发生
  8. 令牌包只有 PID+地址+端点+CRC5(3字节),没有数据区装不下 8 字节;数据由数据包承载
  9. 建立宣告请求、数据搬载荷、状态判定成败;ACK/ZLP 成功、NAK 忙、STALL 不支持
  10. 不包含,三阶段是控制传输独有;其他三种是纯数据搬运,一次传输=一个事务
  11. 数据长度是 wMaxPacketSize 整数倍时补 ZLP 终止(规范8.5.3.2);同步传输没有 ZLP
  12. 不是,ZLP 是包、状态事务是事务;不矛盾——"是包"说物理形态,"传输级"说语义角色

第 3 章

  1. 插线只供电,主机靠 D+ 拉高识别;SoftConnect 让 MCU 掌握暴露时机
  2. 启动→main 初始化→SoftConnect→主机检测→总线复位→reset_isr→第一个请求
  3. 设备描述符→SET_ADDRESS→设备描述符(验证)→配置描述符(9+67)→字符串→SET_CONFIGURATION
  4. 规范要求状态阶段完成后才生效;主机用新地址重发设备描述符验证
  5. 主机先读 wTotalLength;设备靠 min(wLength, wTotalLength) 区分
  6. 端点使能(D8)之前 EP2 禁用,中断函数不会触发
  7. 复位→设备描述符→SET_ADDRESS→配置描述符→SET_CONFIGURATION;请求长度、字符串、复位次数等细节各 OS 不同
  8. Attached→Powered→Default→Address→Configured;主机再次总线复位会打回 Default
  9. 回到 Address(解除配置);主机再次总线复位会直接回到 Default

第 4 章

  1. 像 2 地址存储器;A0=1 命令格、A0=0 数据格
  2. 3=EP1 IN、4=EP2 OUT、5=EP2 IN
  3. 初始化(D0/D8/F3)/数据流(F0~F4/F1/F2/FA/40-45)/通用(F5/F6)
  4. 字节0保留 + 字节1长度
  5. 端点位读 40-45,非端点位读中断寄存器;不清会死循环
  6. SETUP 到达时 D12 锁定,须对 IN/OUT 都 ACK 解锁

第 5 章

  1. 设备级类非 0 时所有接口必须属于该类;CDC 所有接口都是通信类,HID 可能混合
  2. 配置9+通信接口9+Header5+CallMgmt5+ACM4+Union5+通信端点7+数据接口9+数据端点7+7
  3. Header 版本、CallMgmt 管不管呼叫、ACM 支持哪些命令、Union 绑定接口组
  4. master=0(通信接口)、slave=1(数据接口);绑定成功能单元
  5. 串口数据大且不要求实时(批量),鼠标数据小且延迟敏感(中断)
  6. 同编号(2)不同方向(IN/OUT)的两个独立端点
  7. 该接口声明的功能端点数量(方向区分、不含端点0);端点0是设备级公共设施不属于任何接口
  8. 索引 3;不是一套系统——bNumEndpoints 是协议计数,D12 索引是芯片内部编号

第 6 章

  1. bmRequestType 的 D6:D5(00 标准/01 类)
  2. 参数能否塞进 wValue/wIndex;DTR/RTS 只有 2 位,wValue 装得下
  3. 见 6.4 节时序
  4. 设备要能回答 GET_LINE_CODING、主机不一定会设置、固件自身要用
  5. GET=设备→主机(0xA1),SET=主机→设备(0x21)
  6. buf[5]/[6]/[7] → 应为 buf[4]/[5]/[6]

第 7 章

  1. 桥转发到外部,终点自己处理;USB 侧一样,endpoint2_out_isr 中间一行不同
  2. 删掉回显,加上接住 size 和 usart2_send_bytes;先清缓冲让 USB 继续收
  3. 连续发送需要确认上一字节发完
  4. 开接收中断 + 写 USART2_IRQHandler
  5. 通知走 EP1 IN(索引3),数据走 EP2 IN(索引5)
  6. 一个 64 字节包只装 1 字节,浪费带宽;攒满或空闲再发

A.6 推荐的学习路线回顾

第1章 基础概念 → 第2章 包与事务 → 第3章 枚举流程
→ 第4章 D12驱动 → 第5章 CDC描述符 → 第6章 类请求
→ 第7章 数据通路 → 第8章 动手实验

每章的自测题全部能独立回答后,你已经具备读懂这个工程、并动手改造它的能力了。祝学习顺利!

posted @ 2026-08-22 23:12  博客侦探  阅读(23)  评论(0)    收藏  举报