前言
在嵌入式开发的世界里,USB 可能是最矛盾的存在:一方面它无处不在,从键盘鼠标到U盘摄像头,几乎所有外设都在用;另一方面它又以复杂著称,四层协议栈、十几种传输类型、上百页的规格书,让很多工程师望而却步。
我第一次接触 USB 开发是在 2018 年。当时项目需要做一个自定义的 USB 数据采集设备,我拿着 STM32 的参考手册看了三天,愣是没搞懂端点(Endpoint)和管道(Pipe)到底有什么区别。网上的教程要么是「打开 CubeMX 点几下就行了」,要么是直接扔给你一整个库的代码,中间的关键步骤一概省略。
那一周我熬了三个通宵,把 USB 协议栈的源代码一行一行地啃完,才终于明白:USB 其实没那么难,难的是没有人把它讲清楚。
这篇文章就是为了解决这个问题。我会从最基础的 USB 协议概念讲起,一步步带你完成 HID 自定义设备和 CDC 虚拟串口的完整开发。不需要你有任何 USB 开发经验,只要你会用 STM32 和 HAL 库,跟着这篇文章走,就能做出自己的 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 设备插到电脑上时,主机会依次请求这些描述符:
- 设备描述符:厂商 ID、产品 ID、设备类别、配置数量
- 配置描述符:有多少个接口、每个接口有多少端点
- 接口描述符:接口类别、子类、协议
- 端点描述符:端点号、方向、类型、最大包长、轮询间隔
- 类特殊描述符:如 HID 报告描述符、CDC 功能描述符
这就是所谓的枚举过程。枚举完成后,主机就知道这是什么设备,该加载哪个驱动程序了。
新手提示:90% 的 USB 枚举失败问题,都是描述符写错了。如果设备插上后电脑提示「无法识别的设备」,先检查描述符,再检查硬件。
1.5 请求与状态机
主机与设备的所有控制通信,都是通过一种叫做**设置包(Setup Packet)**的结构发起的。每个设置包包含 8 个字节,定义了:
- 请求类型(标准请求 / 类请求 / 厂商请求)
- 具体请求码
- 两个参数值
- 数据阶段的长度
设备收到设置包后,根据请求类型进入相应的处理流程。这就是为什么 USB 设备的固件看起来像一个大的状态机——它永远在等待主机的下一个请求。
二、STM32 的 USB 硬件:OTG FS 与 HS
STM32 系列芯片的 USB 外设经历了几代演进,目前主流的是OTG_FS 和 OTG_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 生成代码
这一步我们要做的是:
- 使能 USB_OTG_FS 或 USB_OTG_HS 外设
- 配置 USB 时钟(必须是 48MHz)
- 在 “Middleware” 中选择 USB Device
- 选择设备类(HID / CDC / MSC 等)
- 配置端点参数
- 生成代码
第三步:实现用户回调函数
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: 高速模式轮询间隔,默认 5USBD_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 报告描述符的常见坑
-
长度不匹配:
USBD_HID_REPORT_DESC_SIZE宏定义必须和实际的描述符字节数完全一致,差一个字节都不行。 -
最大包长限制:全速 HID 设备的报告长度不能超过端点的最大包长(64 字节)。超过了就需要分包发送。
-
报告 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 设备句柄,全局变量hUsbDeviceFSreport: 要发送的数据缓冲区指针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 设备」,设备管理器里显示黄色感叹号。
排查步骤:
- 先检查硬件:D+ 上拉电阻接了吗?电压是 3.3V 吗?
- 再检查时钟:USB 时钟是不是精确的 48MHz?
- 然后检查描述符:VID/PID 有没有和其他设备冲突?字符串描述符的编码正确吗?
- 最后用 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 大同小异:
- 同样先配置时钟,USB 必须是 48MHz
- 使能 USB_OTG_FS 的 Device_Only 模式
- 在 USB_DEVICE 中间件中选择 “Communication Device Class (CDC)”
- 参数保持默认就行
- 生成代码
生成的代码结构也差不多,主要改的是 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_SetRxBuffer和USBD_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 放在同一个配置里。
十三、总结
回顾一下我们这篇文章讲的内容:
- USB 基础概念:主从架构、端点、传输类型、描述符
- STM32 USB 硬件:OTG_FS 和 OTG_HS 的区别
- HID 设备开发:报告描述符、数据收发、常见问题
- CDC 虚拟串口:接口结构、收发函数、命令行实现
- 性能优化:提高传输速度的几个技巧
USB 开发确实有很多细节和坑,但只要你理解了底层的工作原理,剩下的就只是按部就班的代码实现。记住几个关键点:
- 时钟必须精确:48MHz 差一点都不行
- 描述符不能错:90% 的枚举失败都是描述符的问题
- 发送要有流控:永远不要在上一包发完之前发下一包
- 接收要重新准备:CDC 收完一包一定要调用
ReceivePacket
从实用性角度,我给大家的建议是:
- 如果只是小数据量采集,优先用 HID,真正免驱动
- 如果需要调试控制台、大数据传输,优先用 CDC,开发最简单
- 如果需要超高速传输,上 高速 USB + 批量传输
最后,USB 开发最好的老师就是 USB 协议规范本身。虽然它有 600 多页,但你不需要全部看完。遇到问题的时候,去查对应的章节,你会发现所有的答案都在里面。
希望这篇文章能帮你跨过 USB 开发的门槛。当你第一次看到自己做的设备被电脑识别出来,串口助手里打出 “Hello World” 的时候,那种成就感是无法言喻的。
(全文完,约 7800 字)