前言

在嵌入式开发的世界里,USB 可能是最矛盾的存在:一方面它无处不在,从键盘鼠标到U盘摄像头,几乎所有外设都在用;另一方面它又以复杂著称,四层协议栈、十几种传输类型、上百页的规格书,让很多工程师望而却步。

我第一次接触 USB 开发是在 2018 年。当时项目需要做一个自定义的 USB 数据采集设备,我拿着 STM32 的参考手册看了三天,愣是没搞懂端点(Endpoint)和管道(Pipe)到底有什么区别。网上的教程要么是「打开 CubeMX 点几下就行了」,要么是直接扔给你一整个库的代码,中间的关键步骤一概省略。

那一周我熬了三个通宵,把 USB 协议栈的源代码一行一行地啃完,才终于明白:USB 其实没那么难,难的是没有人把它讲清楚。

这篇文章就是为了解决这个问题。我会从最基础的 USB 协议概念讲起,一步步带你完成 HID 自定义设备和 CDC 虚拟串口的完整开发。不需要你有任何 USB 开发经验,只要你会用 STM32 和 HAL 库,跟着这篇文章走,就能做出自己的 USB 设备。

STM32 USB 设备开发架构


一、USB 协议基础:五分钟搞懂核心概念

在写代码之前,我们必须先搞懂 USB 的几个核心概念。这部分是整个 USB 开发的基石,理解了这些,后面的代码就只是按部就班而已。

1.1 主机与设备的主从关系

USB 是一个严格的主从架构

  • 主机(Host):只能是一个,通常是 PC 或手机,负责发起所有通信
  • 设备(Device):可以有多个,只能被动响应主机的请求

这一点非常重要。USB 设备永远不能主动给主机发数据,它只能在主机询问的时候才能回复。这是很多新手踩的第一个坑——他们在设备端写了一个发送数据的循环,然后奇怪为什么主机收不到任何东西。

1.2 端点:USB 通信的基本单元

如果把 USB 比作一条公路,那么**端点(Endpoint)**就是这条公路上的车道。每个端点都有:

  • 编号:0-15,其中端点 0 是控制端点,所有设备必须实现
  • 方向:IN(设备→主机)或 OUT(主机→设备)
  • 类型:控制传输、批量传输、中断传输、等时传输
  • 最大包长:全速设备最大 64 字节,高速设备最大 1024 字节

举个例子:一个 USB 鼠标通常有两个端点:

  • 端点 0(控制):用于枚举和配置
  • 端点 1 IN(中断):用于上报鼠标位置和按键状态

1.3 四种传输类型,什么时候用哪个?

USB 定义了四种传输类型,分别应对不同的场景:

传输类型 特点 典型应用 可靠性 延迟
控制传输 双向、可靠、低频率 设备枚举、配置命令 100%可靠 不确定
中断传输 单向、有保证的轮询间隔 鼠标、键盘、数据采集 100%可靠 低且确定
批量传输 单向、可靠、无延迟保证 U盘、打印机、固件更新 100%可靠 不确定
等时传输 单向、不可靠、固定带宽 音频、视频、摄像头 可能丢包 极低且固定

对于我们做嵌入式设备来说,中断传输批量传输是最常用的。中断传输适合小数据量的实时数据上传,批量传输适合大数据量的可靠传输。

1.4 描述符:告诉主机我是什么设备

USB 设备的「自我介绍」是通过一系列**描述符(Descriptor)**完成的。当你把 USB 设备插到电脑上时,主机会依次请求这些描述符:

  1. 设备描述符:厂商 ID、产品 ID、设备类别、配置数量
  2. 配置描述符:有多少个接口、每个接口有多少端点
  3. 接口描述符:接口类别、子类、协议
  4. 端点描述符:端点号、方向、类型、最大包长、轮询间隔
  5. 类特殊描述符:如 HID 报告描述符、CDC 功能描述符

这就是所谓的枚举过程。枚举完成后,主机就知道这是什么设备,该加载哪个驱动程序了。

新手提示:90% 的 USB 枚举失败问题,都是描述符写错了。如果设备插上后电脑提示「无法识别的设备」,先检查描述符,再检查硬件。

1.5 请求与状态机

主机与设备的所有控制通信,都是通过一种叫做**设置包(Setup Packet)**的结构发起的。每个设置包包含 8 个字节,定义了:

  • 请求类型(标准请求 / 类请求 / 厂商请求)
  • 具体请求码
  • 两个参数值
  • 数据阶段的长度

设备收到设置包后,根据请求类型进入相应的处理流程。这就是为什么 USB 设备的固件看起来像一个大的状态机——它永远在等待主机的下一个请求。


二、STM32 的 USB 硬件:OTG FS 与 HS

STM32 系列芯片的 USB 外设经历了几代演进,目前主流的是OTG_FSOTG_HS 两种。

2.1 OTG_FS:最常用的全速 USB

几乎所有中高端 STM32 都集成了 OTG_FS 外设:

  • 支持 USB 2.0 全速(12Mbps)
  • 内置 USB PHY,不需要外部芯片
  • 最多 4 个双向端点(加上端点 0 共 5 个)
  • 内置 DMA,支持数据自动传输

代表型号:STM32F105/107、STM32F405/407、STM32F767、STM32H743、STM32L476

这是我们最常用的配置,一根 USB 线直接连到芯片的 PA11(DM)和 PA12(DP)引脚,不需要额外元件。

2.2 OTG_HS:高速 USB 选项

高端 STM32 还提供 OTG_HS 外设:

  • 支持 USB 2.0 高速(480Mbps)
  • 可以使用内置 FS PHY 工作在全速模式
  • 配合外部 ULPI PHY 芯片(如 USB3300)实现高速
  • 最多 8 个双向端点
  • 更强大的 DMA 能力

代表型号:STM32F429、STM32F767、STM32H743

如果你需要做高速数据传输,比如视频流或者高速数据采集,就需要 OTG_HS + 外部 PHY 的组合。

2.3 硬件连接的几个细节

做 USB 硬件时,有几个容易忽略的细节:

1. D+ 上拉电阻

  • 全速设备:在 D+(PA12)上接 1.5kΩ 上拉电阻到 3.3V
  • 低速设备:在 D-(PA11)上接 1.5kΩ 上拉电阻
  • 很多 STM32 内置了这个上拉电阻,可以通过软件控制

2. 电源处理

  • 如果是总线供电设备,注意 USB 最大只能提供 500mA
  • 如果是自供电设备,需要处理 Vbus 检测

3. ESD 保护

  • USB 接口是对外的,一定要加 ESD 保护二极管
  • 推荐用专门的 USB ESD 芯片,比如 SRV05-4

2.4 ST 的 USB 设备库

ST 为我们提供了完整的 USB 设备库,叫做 STM32_USB_Device_Library。这个库的结构非常清晰:

STM32_USB_Device_Library/
├── Core/          # USB 核心层,处理控制传输和枚举
└── Class/         # 各类设备驱动
    ├── HID/       # 人机接口设备(键盘、鼠标、自定义)
    ├── CDC/       # 通信设备类(虚拟串口)
    ├── MSC/       # 大容量存储(U盘)
    ├── Audio/     # 音频设备
    └── Video/     # 视频设备

对我们来说,好消息是:我们几乎不需要修改核心层的代码,只需要实现类驱动层的几个回调函数就行。这正是 CubeMX 能帮我们自动生成的部分。


三、USB 设备开发的一般流程

在进入具体的代码之前,我们先梳理一下 USB 设备开发的完整流程:

第一步:确定设备类型和需求

首先想清楚几个问题:

  • 我的设备要实现什么功能?(数据采集?串口通信?)
  • 需要传输的数据量有多大?(每秒几字节?还是几 KB?)
  • 对延迟有要求吗?(可以等 10ms,还是必须 1ms 以内?)
  • 主机端需要驱动吗?(HID 和 CDC 是标准类,Windows 自带驱动)

举几个常见的选择:

  • 小型传感器数据采集 → HID 中断传输(免驱动)
  • 调试串口、控制台 → CDC 虚拟串口(通用方便)
  • 固件更新、大数据传输 → 批量传输(速度快)

第二步:配置 CubeMX 生成代码

这一步我们要做的是:

  1. 使能 USB_OTG_FS 或 USB_OTG_HS 外设
  2. 配置 USB 时钟(必须是 48MHz)
  3. 在 “Middleware” 中选择 USB Device
  4. 选择设备类(HID / CDC / MSC 等)
  5. 配置端点参数
  6. 生成代码

第三步:实现用户回调函数

CubeMX 生成的代码只是一个「骨架」,真正的业务逻辑需要我们在回调函数中实现:

  • USBD_*_Init():设备初始化
  • USBD_*_DeInit():设备反初始化
  • USBD_*_Setup():处理类特殊的控制请求
  • USBD_*_DataIn():数据发送完成回调
  • USBD_*_DataOut():数据接收完成回调

这是整个开发过程中代码量最大的一步,也是我们后面要重点讲解的部分。

第四步:调试与验证

USB 开发的调试相对麻烦,推荐几个工具:

  • USBlyzer / Wireshark:抓包分析 USB 总线数据
  • STM32 ST-Link:单步调试固件代码
  • 串口调试:在关键位置加打印,观察状态机流转
  • HID Test Tool / TeraTerm:主机端测试工具

(第一部分完,约 2400 字)


四、实战第一步:CubeMX 配置详解

理论讲了这么多,是时候动手写代码了。我们从最简单的 HID 自定义设备开始,这也是新手最好的入门项目。

4.1 新建工程与基础配置

打开 STM32CubeMX,新建一个工程,选择你的芯片型号(我用的是 STM32F407VGT6):

1. 配置系统时钟

这是最关键的一步,USB 的时钟必须精确到 48MHz:

SYSCLK = 168MHz (F4 的标准配置)
USB OTG FS clock = 48MHz (必须精确!)

在 “Clock Configuration” 页面,确保 USB 时钟那一行显示的是 48MHz。如果不是,调整 PLL 分频系数。

致命坑提示:USB 时钟不对是枚举失败的头号原因。即使差 0.1%,设备也完全无法工作。

2. 使能 USB_OTG_FS 外设

在 “Pinout & Configuration” 页面,找到 “Connectivity” → “USB_OTG_FS”:

  • Mode: 选择 “Device_Only”(我们只做设备,不做主机)
  • 下面的 “Activate_VBUS” 可以不选(自供电设备不需要)

确认 PA11 和 PA12 引脚已经被自动配置为 USB_OTG_FS_DM 和 USB_OTG_FS_DP。

3. 配置中间件

在 “Middleware” 选项卡中,找到 “USB_DEVICE”:

  • 勾选 “USB_DEVICE”
  • Class For FS IP: 选择 “Human Interface Device Class (HID)”

4. 调整 HID 参数

点击 “Parameter Settings”,这里有几个重要参数:

  • USBD_HID_IN_EP: 默认 0x81(端点 1 IN),不用改
  • USBD_HID_OUT_EP: 默认 0x01(端点 1 OUT),如果不需要双向可以删掉
  • USBD_HID_HS_BINTERVAL: 高速模式轮询间隔,默认 5
  • USBD_HID_FS_BINTERVAL: 全速模式轮询间隔,默认 10(单位 ms)

轮询间隔是什么意思?就是主机每隔多少毫秒来问一次设备有没有数据要发。设为 10ms 意味着设备最快每 10ms 才能上报一次数据。如果你需要更快的响应,可以改成 1ms。

5. 生成代码

设置好工程名称和路径,Toolchain/IDE 选择 “MDK-ARM” 或者 “Makefile”,点击 “GENERATE CODE”。

4.2 生成的代码结构

CubeMX 生成的 USB 相关代码主要在这几个地方:

Core/Src/
├── usbd_conf.c          # 硬件配置,如 GPIO、DMA
└── usbd_desc.c          # 描述符定义(VID、PID、字符串等)

USB_DEVICE/
├── App/
│   ├── usbd_hid.c       # HID 应用层代码
│   └── usb_device.c     # USB 设备初始化
└── Target/
    ├── usbd_hid_if.c    # HID 接口层(我们主要改这个)
    └── usbd_conf_template.c

对我们来说,90% 的工作都在 usbd_hid_if.c 这个文件里


五、HID 报告描述符:HID 设备的灵魂

HID 设备和其他 USB 设备最大的区别,就是它有一个叫做**报告描述符(Report Descriptor)**的东西。

这也是 HID 开发中最让人头疼的部分——它不是简单的结构体,而是一种微型的字节码,解释起来相当反人类。

5.1 什么是报告描述符?

报告描述符的作用是告诉主机:「我的数据是什么格式的」。

举个例子,一个标准的 3 键鼠标报告描述符会说:

  • 第 0 字节的 bit 0 是左键状态
  • 第 0 字节的 bit 1 是右键状态
  • 第 0 字节的 bit 2 是中键状态
  • 第 1 字节是 X 轴相对位移(-127 到 +127)
  • 第 2 字节是 Y 轴相对位移(-127 到 +127)

主机收到数据后,就按照这个描述来解析每一位的含义。

5.2 自定义 HID 设备的报告描述符

对于自定义的数据采集设备,我们通常会把报告描述符简化成「原始字节数组」,让主机自己去解析:

__ALIGN_BEGIN static uint8_t HID_ReportDesc_FS[USBD_HID_REPORT_DESC_SIZE] __ALIGN_END =
{
    0x06, 0xFF, 0x00,      // Usage Page (Vendor Defined)
    0x09, 0x01,            // Usage (Vendor Usage 1)
    0xA1, 0x01,            // Collection (Application)
    
    // Input report: 设备 -> 主机,64 字节
    0x09, 0x02,            //   Usage (Vendor Usage 2)
    0x15, 0x00,            //   Logical Minimum (0)
    0x25, 0xFF,            //   Logical Maximum (255)
    0x75, 0x08,            //   Report Size (8 bits)
    0x95, 0x40,            //   Report Count (64 items)
    0x81, 0x02,            //   Input (Data, Variable, Absolute)
    
    // Output report: 主机 -> 设备,64 字节
    0x09, 0x03,            //   Usage (Vendor Usage 3)
    0x15, 0x00,            //   Logical Minimum (0)
    0x25, 0xFF,            //   Logical Maximum (255)
    0x75, 0x08,            //   Report Size (8 bits)
    0x95, 0x40,            //   Report Count (64 items)
    0x91, 0x02,            //   Output (Data, Variable, Absolute)
    
    0xC0                   // End Collection
};

这个描述符定义了:

  • 输入报告(设备→主机):64 字节原始数据
  • 输出报告(主机→设备):64 字节原始数据

简单粗暴,但非常实用。主机端不需要关心每一位是什么,直接按字节读写就行。

5.3 报告描述符的常见坑

  1. 长度不匹配USBD_HID_REPORT_DESC_SIZE 宏定义必须和实际的描述符字节数完全一致,差一个字节都不行。

  2. 最大包长限制:全速 HID 设备的报告长度不能超过端点的最大包长(64 字节)。超过了就需要分包发送。

  3. 报告 ID:如果你用了报告 ID,那么每个数据的第一个字节必须是 ID,实际数据从第二个字节开始。新手建议不要用报告 ID。


六、HID 数据收发:三个核心函数

现在我们来看具体的代码实现。HID 设备的数据收发主要靠三个函数。

6.1 发送数据:USBD_HID_SendReport

设备主动给主机发数据用这个函数:

uint8_t USBD_HID_SendReport(USBD_HandleTypeDef *pdev, 
                            uint8_t *report, 
                            uint16_t len);

参数说明

  • pdev: USB 设备句柄,全局变量 hUsbDeviceFS
  • report: 要发送的数据缓冲区指针
  • len: 数据长度,不能超过最大包长

使用示例

// 定义发送缓冲区
uint8_t tx_buffer[64];

// 填充数据
tx_buffer[0] = 0x01;        // 状态字节
tx_buffer[1] = sensor1 >> 8; // 传感器数据高字节
tx_buffer[2] = sensor1 & 0xFF;
tx_buffer[3] = sensor2 >> 8;
tx_buffer[4] = sensor2 & 0xFF;

// 发送数据
USBD_HID_SendReport(&hUsbDeviceFS, tx_buffer, 64);

重要注意事项

  • 这个函数是非阻塞的,它只是把数据放到发送队列里就返回了
  • 上一包发送完成之前,不能调用下一次发送
  • 如果返回 USBD_BUSY,说明上一包还在发,需要等一会儿再试

6.2 发送完成回调:HID_OutEvent_FS

当一帧数据成功发送到主机后,会调用这个回调函数:

static int8_t HID_OutEvent_FS(uint8_t event_idx, uint8_t state)
{
    // 数据已经成功发出,可以准备下一包了
    tx_in_progress = 0;  // 清除发送忙标志
    
    return (USBD_OK);
}

这是实现连续数据发送的关键。正确的做法是:

volatile uint8_t tx_busy = 0;

void send_data(uint8_t *data, uint16_t len)
{
    while (tx_busy) {
        // 等待上一次发送完成
    }
    
    tx_busy = 1;
    USBD_HID_SendReport(&hUsbDeviceFS, data, len);
}

static int8_t HID_OutEvent_FS(uint8_t event_idx, uint8_t state)
{
    tx_busy = 0;  // 发送完成,清除忙标志
    return USBD_OK;
}

6.3 接收数据:HID_DataOut

当主机给设备发数据时,数据会先存在内部缓冲区,然后调用 Receive 回调:

static int8_t HID_DataOut_FS(uint8_t* buf, uint32_t *len)
{
    // buf 里是主机发来的数据
    // len 是实际收到的字节数
    
    // 处理接收到的数据
    process_host_command(buf, *len);
    
    return (USBD_OK);
}

注意:接收到的数据长度可能不是 64 字节。如果主机只发了 10 字节,len 就是 10。

6.4 一个完整的示例:ADC 数据采集

把这些组合起来,我们可以做一个通过 USB 上传 ADC 数据的例子:

// 在主循环中
while (1)
{
    if (!tx_busy && adc_data_ready)
    {
        // 采集 ADC 数据
        uint16_t adc_val = HAL_ADC_GetValue(&hadc1);
        
        // 填充发送缓冲区
        tx_buffer[0] = 0xAA;           // 帧头
        tx_buffer[1] = adc_val >> 8;   // 高字节
        tx_buffer[2] = adc_val & 0xFF; // 低字节
        tx_buffer[3] = 0x55;           // 帧尾
        
        // 发送
        tx_busy = 1;
        USBD_HID_SendReport(&hUsbDeviceFS, tx_buffer, 64);
        
        adc_data_ready = 0;
    }
}

// 在 ADC 中断回调中
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
    adc_data_ready = 1;
}

就这么简单。每隔一段时间,ADC 采集完成后,设备就会把数据通过 USB HID 发给主机。


七、HID 开发的常见问题与解决

HID 开发听起来简单,但实际做起来坑不少。我把最常见的问题列在这里。

问题 1:设备能枚举,但收不到数据

现象:设备管理器里能看到 HID 设备,但是主机端程序收不到任何数据。

可能原因

  • 轮询间隔设得太大了。比如设成 255ms,主机每隔 255ms 才来问一次,你感觉就像没反应。改成 10ms 试试。
  • USBD_HID_SendReport 的长度参数超过了最大包长。全速设备不能超过 64 字节。
  • 你在中断里调用了发送函数,但是中断优先级比 USB 中断高,导致死锁。

问题 2:连续发送丢包

现象:发第一包正常,第二包开始就丢包。

原因:上一包还没发完就调用了下一次 USBD_HID_SendReport。这个函数不会帮你排队,后发的会直接覆盖先发的。

解决:用一个标志位,只有在上一次发送完成回调之后才能发下一次。参考上面的 tx_busy 例子。

问题 3:Windows 下识别为「未知设备」

现象:设备插上去,Windows 提示「无法识别的 USB 设备」,设备管理器里显示黄色感叹号。

排查步骤

  1. 先检查硬件:D+ 上拉电阻接了吗?电压是 3.3V 吗?
  2. 再检查时钟:USB 时钟是不是精确的 48MHz?
  3. 然后检查描述符:VID/PID 有没有和其他设备冲突?字符串描述符的编码正确吗?
  4. 最后用 USBlyzer 抓包,看枚举过程卡在哪一步了。

问题 4:热插拔后不工作

现象:第一次插上去正常,拔下来再插就没反应了。

原因:热插拔时 USB 的中断状态机没有正确复位。

解决:在 Vbus 检测的中断处理里调用 USBD_Stop(&hUsbDeviceFS) 然后重新 USBD_Start(&hUsbDeviceFS)

(第二部分完,约 2600 字)


八、CDC 虚拟串口:嵌入式开发者的瑞士军刀

如果说 HID 适合小数据量的免驱动设备,那么 CDC(通信设备类) 就是嵌入式开发中最通用的 USB 方案。

CDC 虚拟串口的好处太多了:

  • Windows / Linux / macOS 全都自带驱动
  • 主机端用普通的串口软件就能通信
  • 波特率是「假的」,实际速度可以达到 1MB/s 以上
  • 协议简单,就是字节流,不用搞复杂的报告描述符

对于调试控制台、数据记录仪、固件更新工具这些场景,CDC 虚拟串口几乎是完美的选择。

8.1 CDC 的原理:两个接口 + 三个端点

CDC 和 HID 最大的区别是:它用了两个接口三个端点

  • 通信接口(Communication Interface)

    • 端点 0:控制端点,用于设置波特率、奇偶校验等
    • 端点 2 IN:中断端点,用于上报线路状态变化
  • 数据接口(Data Interface)

    • 端点 1 OUT:批量端点,主机 → 设备
    • 端点 1 IN:批量端点,设备 → 主机

这就是为什么你在设备管理器里看到 CDC 设备会显示两个接口。不过这些细节 ST 的库都帮我们处理好了,我们只需要关心数据收发。

8.2 CubeMX 配置 CDC

配置 CDC 和配置 HID 大同小异:

  1. 同样先配置时钟,USB 必须是 48MHz
  2. 使能 USB_OTG_FS 的 Device_Only 模式
  3. 在 USB_DEVICE 中间件中选择 “Communication Device Class (CDC)”
  4. 参数保持默认就行
  5. 生成代码

生成的代码结构也差不多,主要改的是 usbd_cdc_if.c 这个文件。


九、CDC 数据收发:环形缓冲区是关键

CDC 的数据收发接口比 HID 稍微复杂一点,因为它是字节流模型,不是数据包模型。

9.1 发送数据:CDC_Transmit_FS

发送函数的原型:

uint8_t CDC_Transmit_FS(uint8_t* Buf, uint16_t Len);

这和 HID 的 USBD_HID_SendReport 很像,用法也差不多:

uint8_t data[] = "Hello, USB CDC!\r\n";
CDC_Transmit_FS(data, sizeof(data) - 1);

但是这里有个和 HID 一样的坑:上一包发完之前,不能发下一包。如果你连续调用两次 CDC_Transmit_FS,第二包会直接丢了。

9.2 发送完成回调:CDC_TransmitCplt_FS

数据发送完成后会调用这个回调:

static int8_t CDC_TransmitCplt_FS(uint8_t *Buf, uint32_t *Len, uint8_t epnum)
{
    tx_busy = 0;  // 发送完成,清除忙标志
    return USBD_OK;
}

9.3 接收数据:CDC_Receive_FS

主机发来的数据会通过这个回调交给我们:

static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len)
{
    // Buf 是接收到的数据
    // Len 是收到的字节数
    
    // 处理数据,比如放入环形缓冲区
    for (uint32_t i = 0; i < *Len; i++) {
        ring_buffer_write(&rx_ring, Buf[i]);
    }
    
    // 重要!必须重新准备接收
    USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
    USBD_CDC_ReceivePacket(&hUsbDeviceFS);
    
    return (USBD_OK);
}

非常重要的细节:处理完数据后,必须调用 USBD_CDC_SetRxBufferUSBD_CDC_ReceivePacket 重新准备下一次接收。如果忘了这两行,收完第一包之后就再也收不到数据了。这是 CDC 开发的第一大坑。


十、实战:一个完整的 USB 串口调试控制台

现在我们把这些知识整合起来,做一个实用的 USB 调试控制台。

10.1 实现 printf 重定向

最常用的需求就是让 printf 的输出通过 USB 串口发出去。在 STM32 上这很简单:

#include "usbd_cdc_if.h"
#include <stdio.h>

volatile uint8_t cdc_tx_busy = 0;

// 发送完成回调
static int8_t CDC_TransmitCplt_FS(uint8_t *Buf, uint32_t *Len, uint8_t epnum)
{
    cdc_tx_busy = 0;
    return USBD_OK;
}

// 重定向 fputc
int fputc(int ch, FILE *f)
{
    // 等待上一次发送完成
    while (cdc_tx_busy);
    
    static uint8_t buf[1];
    buf[0] = (uint8_t)ch;
    
    cdc_tx_busy = 1;
    CDC_Transmit_FS(buf, 1);
    
    return ch;
}

现在你就可以直接用 printf 输出调试信息了:

printf("System started. CPU Clock = %lu MHz\r\n", SystemCoreClock / 1000000);
printf("ADC Value = %d, Temperature = %.1f C\r\n", adc_val, temperature);

10.2 实现命令行解析

更进一步,我们可以实现一个简单的命令行,让主机可以发命令控制设备:

// 简单的命令解析
void process_command(char *cmd)
{
    if (strcmp(cmd, "help") == 0) {
        printf("Available commands:\r\n");
        printf("  help    - Show this help\r\n");
        printf("  status  - Show system status\r\n");
        printf("  led on  - Turn LED on\r\n");
        printf("  led off - Turn LED off\r\n");
        printf("  reset   - System reset\r\n");
    }
    else if (strcmp(cmd, "status") == 0) {
        printf("System Status:\r\n");
        printf("  CPU Clock: %lu MHz\r\n", SystemCoreClock / 1000000);
        printf("  SysTick: %lu ms\r\n", HAL_GetTick());
        printf("  USB Connected: %s\r\n", 
               hUsbDeviceFS.dev_state == USBD_STATE_CONFIGURED ? "Yes" : "No");
    }
    else if (strcmp(cmd, "led on") == 0) {
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
        printf("LED turned ON\r\n");
    }
    else if (strcmp(cmd, "led off") == 0) {
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
        printf("LED turned OFF\r\n");
    }
    else {
        printf("Unknown command: '%s'\r\n", cmd);
    }
}

10.3 环形缓冲区实现

为了处理不连续的数据流,我们需要一个环形缓冲区:

#define RING_BUF_SIZE 1024

typedef struct {
    uint8_t buf[RING_BUF_SIZE];
    volatile uint32_t head;
    volatile uint32_t tail;
} ring_buffer_t;

void ring_buffer_init(ring_buffer_t *rb)
{
    rb->head = 0;
    rb->tail = 0;
}

void ring_buffer_write(ring_buffer_t *rb, uint8_t data)
{
    uint32_t next_head = (rb->head + 1) % RING_BUF_SIZE;
    if (next_head != rb->tail) {
        rb->buf[rb->head] = data;
        rb->head = next_head;
    }
}

int ring_buffer_read(ring_buffer_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }
    *data = rb->buf[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 0;
}

然后在主循环中处理接收到的命令:

ring_buffer_t rx_ring;
char line_buf[256];
int line_pos = 0;

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_USB_DEVICE_Init();
    ring_buffer_init(&rx_ring);
    
    printf("USB CDC Console Ready!\r\n");
    printf("Type 'help' for available commands\r\n");
    printf("> ");
    
    while (1)
    {
        uint8_t ch;
        if (ring_buffer_read(&rx_ring, &ch) == 0) {
            // 回显
            printf("%c", ch);
            
            if (ch == '\r' || ch == '\n') {
                if (line_pos > 0) {
                    line_buf[line_pos] = '\0';
                    printf("\r\n");
                    process_command(line_buf);
                    line_pos = 0;
                    printf("> ");
                }
            }
            else if (ch == '\b' && line_pos > 0) {
                line_pos--;
            }
            else if (line_pos < sizeof(line_buf) - 1) {
                line_buf[line_pos++] = ch;
            }
        }
    }
}

这样你就有了一个功能完整的 USB 调试控制台。插上 USB,打开串口助手,就能像操作 Linux 终端一样操作你的 STM32 了。


十一、CDC 开发的常见坑

坑 1:收完第一包就再也收不到了

原因:在 CDC_Receive_FS 回调里忘了重新准备接收。

解决:一定要在回调最后加上:

USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
USBD_CDC_ReceivePacket(&hUsbDeviceFS);

坑 2:连续发送丢包

原因:上一包没发完就发下一包。

解决:用 tx_busy 标志位,发送完成回调里再清除。

坑 3:Windows 下能枚举但打不开串口

原因:Windows 10 之后的 CDC 驱动有个 bug,如果设备描述符里的 iSerialNumber 是空的,有时候会打不开。

解决:在 usbd_desc.c 里给序列号字符串设一个非空的值:

#define USBD_SERIALNUMBER_FS_STRING     "202405290001"

坑 4:Linux 下权限不够

现象:Windows 正常,Linux 下提示 Permission denied。

解决:添加 udev 规则:

echo 'SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", MODE="0666"' | sudo tee /etc/udev/rules.d/99-stm32-cdc.rules
sudo udevadm control --reload-rules

十二、进阶技巧与性能优化

12.1 提高 CDC 发送速度

默认的 CDC 发送速度可能只有几十 KB/s,但优化后可以达到 1MB/s 以上:

优化 1:增大最大包长

// 在 usbd_cdc.h 里修改
#define CDC_DATA_FS_MAX_PACKET_SIZE    64  // 改成 64(全速最大)

优化 2:用 DMA 传输数据 如果你的数据放在 SRAM 里,可以让 DMA 直接从 SRAM 搬到 USB FIFO,节省 CPU 时间。

优化 3:批量发送 不要一个字节一个字节地发,攒到 64 字节再发一次:

#define TX_BUF_SIZE 64
uint8_t tx_buf[TX_BUF_SIZE];
int tx_buf_len = 0;

void cdc_putc(uint8_t ch)
{
    tx_buf[tx_buf_len++] = ch;
    
    if (tx_buf_len >= TX_BUF_SIZE) {
        cdc_flush();
    }
}

void cdc_flush(void)
{
    if (tx_buf_len > 0 && !tx_busy) {
        tx_busy = 1;
        CDC_Transmit_FS(tx_buf, tx_buf_len);
        tx_buf_len = 0;
    }
}

12.2 支持多串口

需要多个虚拟串口?没问题,CDC 支持复合设备。你可以配置多个 CDC 接口,每个接口有自己的端点,这样就能在主机端看到多个 COM 口了。

12.3 HID + CDC 复合设备

最实用的组合:用 CDC 做调试控制台,用 HID 做实时数据传输。这样调试和数据采集互不干扰,而且都免驱动。

CubeMX 里可以直接配置成多接口的复合设备,也可以手动修改描述符把 HID 和 CDC 放在同一个配置里。


十三、总结

回顾一下我们这篇文章讲的内容:

  1. USB 基础概念:主从架构、端点、传输类型、描述符
  2. STM32 USB 硬件:OTG_FS 和 OTG_HS 的区别
  3. HID 设备开发:报告描述符、数据收发、常见问题
  4. CDC 虚拟串口:接口结构、收发函数、命令行实现
  5. 性能优化:提高传输速度的几个技巧

USB 开发确实有很多细节和坑,但只要你理解了底层的工作原理,剩下的就只是按部就班的代码实现。记住几个关键点:

  • 时钟必须精确:48MHz 差一点都不行
  • 描述符不能错:90% 的枚举失败都是描述符的问题
  • 发送要有流控:永远不要在上一包发完之前发下一包
  • 接收要重新准备:CDC 收完一包一定要调用 ReceivePacket

从实用性角度,我给大家的建议是:

  • 如果只是小数据量采集,优先用 HID,真正免驱动
  • 如果需要调试控制台、大数据传输,优先用 CDC,开发最简单
  • 如果需要超高速传输,上 高速 USB + 批量传输

最后,USB 开发最好的老师就是 USB 协议规范本身。虽然它有 600 多页,但你不需要全部看完。遇到问题的时候,去查对应的章节,你会发现所有的答案都在里面。

希望这篇文章能帮你跨过 USB 开发的门槛。当你第一次看到自己做的设备被电脑识别出来,串口助手里打出 “Hello World” 的时候,那种成就感是无法言喻的。

(全文完,约 7800 字)