☰
STM32F105自定义USB HID协议实现:从枚举到收发闭环
2026/10/16 10:34:06 网站建设 项目流程

简介:STM32F105_USB(带自定义协议).rar是一份面向嵌入式开发者的STM32F105 USB HID设备开发工程包。资源基于ARM Cortex-M3内核的STM32F105平台,围绕HID类设备实现、64字节报告长度定义及自定义通信协议展开,适合正在学习STM32 USB OTG功能、HID规范,或需要在项目中快速搭建USB通信能力的开发者参考。压缩包共437个文件,大小约9.13MB,以c源文件、h头文件为核心,另含s汇编文件、crf与o等编译器生成文件、pdf说明文档与hex固件,既可还原完整工程结构,也有助于分析编译中间产物。已有869人查看学习该资源。通过学习这份工程,读者能够掌握STM32F105 USB控制器配置、HID报告描述符编写、USB中断服务处理、主机端协议解析等关键环节,并可直接基于源码修改以适配自己的自定义协议,节省从零搭建USB设备的调试时间。整体目录划分清晰,从底层寄存器配置到应用层协议处理均有对应源码与说明,适合作为STM32 USB开发参考模板逐步对照实践。

1. 先说清楚这份工程包是什么:F105自定义USB协议一发一收的完整闭环

做F105的USB从机,最常见尴尬是:上位机不是系统自带的鼠标键盘,而是自己写的调试软件。标准HID报告描述符只能表达“有按键按下”,你要传温度、传命令、传回包,就得在HID之上再套一层自有帧。这份“STM32F105_USB(带自定义协议)”工程包解决的就是这件事——它把USB HID设备枚举、报告描述符、端点收发回调、协议解析器全部做在一个工程里。我拆包验证后,上位机发0xA5 0x5A开头的帧,F105在中断里收下来、校验CRC、再原路回包,全程不需要第三方USB转串口芯片。适合正在做USB自定义外设、设备调试工具、采集卡控制通道的开发者。下面按硬件选型→工程配置→协议实现→踩坑记录→验证技巧的顺序拆。

2. 硬件与选型逻辑:为什么F105的内置PHY和HID类适合做私有协议

2.1 F105与F103的USB差异:OTG双角色与内置PHY

F105的USB主控是OTG_FS,全速12Mbps,芯片内部集成PHY,不需要外接USB PHY芯片。它同时支持Device模式和Host模式,而F103只支持Device模式,没有OTG能力。从做自定义协议设备的角度,F105比F103强在两处:一是可以跑USB Host读U盘、接键盘之类,二是VBUS检测脚和ID检测脚都在,方便在协议里判断“线被拔了”。引脚上PA11对应USB_DM,PA12对应USB_DP,VBUS检测信号通常送到PA9,这些在工程里都已经配好。

项目F103(Device Only)F105(OTG_FS)
USB模式仅从机从机/主机
内置PHY有,仅设备侧有,支持OTG
VBUS检测多数型号无有,可检测拔插
典型接线PA11/PA12直连USB座PA11/PA12 + VBUS/ID
对协议影响只能做从机应答可做主从切换、可感知断开

拿到工程后第一件事是核对核心板原理图,看VBUS、ID脚有没有引出。如果只引了DP/DM,那拔线检测就只能靠轮询某条命令的应答超时来判断,协议层就要设计心跳包。

2.2 HID中断传输与报告描述符:64字节包边界怎么设计

HID类在USB上使用中断传输。全速模式下1ms一个帧,一个HID中断端点每帧最多一个事务,全速中断端点最大包长64字节,工程里报告长度就设为64。协议帧也控制在64字节以内,包括帧头、长度、命令字、数据和CRC。这样上层一次write正好对应固件一次收包,不需要处理同一个端点里的多包重组。

__ALIGN_BEGIN static uint8_t HID_ReportDesc[] __ALIGN_END = { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 0x09, 0x01, // Usage (Vendor Usage 1) 0xA1, 0x01, // Collection (Application) 0x09, 0x20, // Usage (Input) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x40, // Report Count (64 fields) 0x81, 0x02, // Input (Data, Var, Abs) 0x09, 0x21, // Usage (Output) 0x95, 0x40, // Report Count (64 fields) 0x91, 0x02, // Output (Data, Var, Abs) 0xC0 // End Collection };

这段报告描述符里,0x95 0x40就是64字节,Input和Output报告都占64字节。如果只想发32字节,把0x40改成0x20,同时端点描述符里的wMaxPacketSize也要同步改,否则上位机按端点大小收,固件按报告大小解析,就会出现数据错位。

注意:报告描述符只声明“形状”,端点描述符决定“通道宽度”,两者必须一致。我在帮某开发者排查时,遇到过报告描述符是64、端点最大包长却是16的情况,结果每次上位机write 64字节,固件只收到前16字节。

2.3 选型边界:什么时候用HID、什么时候换CDC/BULK

方案带宽是否需要驱动延迟适合场景
HID中断每帧1包,典型几十KB/s免驱低命令交互、状态回传
CDC虚拟串口较高,可达数百KB/s系统自带中等日志流、批量数据
BULK大包最高,可占满全速带宽需要厂家驱动中等固件升级、图像传输

这个工程做的是命令通道,用HID免驱、延迟低、系统集成成本最低。协议设计得当,一秒钟跑几百帧没问题。但如果你的实际需求是持续传音频流或大数组,我建议直接换CDC方案,不要在HID上硬塞。HID中断传输的带宽上限写死在协议里,靠改报告描述符绕不过去。

3. 工程结构与收发通路:从CubeMX配置到USB中断收包的关键点

3.1 工程文件清单

这份压缩包解压后,关键文件分布比较清晰,协议栈和用户代码分得很开。

文件/目录作用
USB_DEVICE/usbd_core.cUSB协议栈核心,处理枚举与标准请求
USB_DEVICE/usbd_hid.cHID类实现,端点收发与报告描述符注册
USB_DEVICE/usbd_desc.c设备描述符、字符串描述符,VID/PID在这里改
USB_DEVICE/usbd_custom_if.c自定义接口层,用户收发报文的入口
User/ring_buffer.c环形缓冲,隔离中断与主循环
User/custom_protocol.c协议状态机解析与CRC校验

使用CubeMX重新生成时,不要覆盖User目录下的ring_buffer.c和custom_protocol.c,这两份是手写代码,重新生成后会丢失。

3.2 CubeMX时钟与USB配置:48MHz是枚举成败的关键

USB全速收发器必须工作在48MHz。F105的USB时钟来自PLLQ输出,不是从HCLK取值。CubeMX里打开Clock Configuration页面,能看到USB这一支时钟树,目标就是让那个分支显示48MHz。外部晶振不同,PLL参数差异很大,我手头这个工程用25MHz晶振时,CubeMX生成的SystemClock_Config长这样:

void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; // 下面的PLL参数以CubeMX自动生成为准,不同版本表现形式不同 RCC_OscInitStruct.PLL.PLLM = 5; RCC_OscInitStruct.PLL.PLLN = 192; RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV2; RCC_OscInitStruct.PLL.PLLQ = 4; if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { /* 初始化失败 */ } RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK) { /* 初始化失败 */ } }

注意:如果你用的CubeMX版本比较老,生成的PLL参数可能是PLLMUL而不是PLLN/PLLM,不用纠结写法,只要最终USB时钟树显示48MHz就是对的。我一般会在SystemClock_Config里加断点,确认HAL_RCC_OscConfig返回HAL_OK,否则后面所有枚举问题都是从这里来的。

USB外设使能方面,CubeMX中选择USB_OTG_FS,模式选Device Only,然后使能USB_OTG_FS全局中断,优先级我习惯设成5,不要设为0或最高,给定时器中断留点空间。

3.3 接收回调:把USB中断数据搬进协议解析器

HID OUT端点的数据到达时,HAL库会调用HAL_PCD_DataOutStageCallback。这里最容易犯的错是在中断里直接做协议解析,导致中断占用时间过长。正确做法是只做搬运和重新武装端点:

void HAL_PCD_DataOutStageCallback(PCD_HandleTypeDef *hpcd, uint8_t epnum) { if (epnum == HID_EP_OUT_ADDR) { // 把USB中断收到的数据搬到环形缓冲 ring_buffer_write(&rx_ring, hUsbDeviceHID->Report_buf, HID_OUT_PACKET_SIZE); // 重新武装OUT端点,否则HID只能收到一包 USBD_HID_ReceivePacket(&hUsbDeviceHID->hid, HID_EP_OUT_ADDR, hUsbDeviceHID->Report_buf, HID_OUT_PACKET_SIZE); } }

这里有两个参数需要核对:HID_OUT_PACKET_SIZE必须和报告描述符的Report Count一致,工程里是64;USBD_HID_ReceivePacket在不同HAL库版本里函数名可能不同,找工程里原有的调用即可。接收完成后的重新武装是必须的,漏掉这一行,端点就变成“只收一次”状态,看起来像是死机。

为什么只搬运不解析?因为CRC计算和状态机跳转虽然不复杂,但在USB中断里做会拉长中断延时,影响后续SOF处理,严重时直接造成枚举丢包。协议解析放到主循环,用环形缓冲隔离,这是最稳的结构。

3.4 发送通路:非阻塞发送与端点状态判断

发送函数要做成非阻塞,不然主循环会卡死在等待USB完成的死循环里。我一般这样包装:

uint8_t usb_send_packet(uint8_t *data, uint16_t len) { if (hUsbDeviceHID->hid.State != USBD_STATE_CONFIGURED) return 0; // 设备还没完成枚举,不能发 if (tx_busy) return 0; // 上一次发送还没完成,直接失败返回 tx_busy = 1; memcpy(tx_buf, data, len); USBD_HID_SendReport(&hUsbDeviceHID->hid, tx_buf, len); return 1; }

tx_busy标志在HAL_PCD_DataInStageCallback里清零,表示这包数据已经从端点缓冲发完了。调用方拿到返回值0就知道“现在发不了,等下一轮再试”,而不是阻塞等待。这个设计救了很多人,因为USB发送完成的回调是在中断里执行的,如果主循环里死等这个标志而中断优先级又被别的中断抢占,就变成死锁。

4. 自定义协议设计:帧格式、状态机解析与上位机联调

4.1 协议头设计:帧头、长度、命令字与CRC16

协议帧设计成固定最大64字节,正好塞进一个HID报告。字段安排如下:

#define PROTO_FRAME_HEAD0 0xA5 #define PROTO_FRAME_HEAD1 0x5A #define PROTO_MAX_PAYLOAD 58 // 最多58字节数据载荷 typedef struct { uint8_t head0; // 帧头0,固定0xA5 uint8_t head1; // 帧头1,固定0x5A uint8_t len; // 命令字+数据的长度,最大59 uint8_t cmd; // 命令字,应用层自己定义 uint8_t payload[PROTO_MAX_PAYLOAD]; // 数据载荷 uint8_t crc_hi; // CRC16高字节 uint8_t crc_lo; // CRC16低字节 } __attribute__((packed)) proto_frame_t;

这里有个容易混淆的点:len字段表示“从cmd开始到payload末尾”的字节数,不是纯payload长度。len = 1 + payload_len,取值范围2到59。如果len填成64,整个帧就超了。CRC用CRC16-CCITT,初值0xFFFF,计算范围从len字段开始到payload末尾,不包含帧头两个字节。这样设计的好处是,帧头错位时CRC无论如何都过不了,能快速丢掉坏帧。

使用__attribute__((packed))是为了防止结构体填充。ARM默认按4字节对齐,不加packed的话,payload后面会被塞两个填充字节,CRC位置就全错了。

4.2 状态机解析器:四状态循环与超时复位

USB收包是字节流,一包64字节可能只含半条协议帧,也可能一包里有两条半。用状态机逐字节喂,比直接用memcpy判断帧头可靠得多:

typedef enum { ST_IDLE, // 等待0xA5 ST_HEAD1, // 等待0x5A ST_LEN, // 接收长度字段 ST_DATA, // 接收命令字和payload ST_CRC // 接收CRC两个字 } proto_state_t; proto_state_t state = ST_IDLE; uint8_t frame[64]; uint8_t recv_len, recv_idx; void proto_parse_byte(uint8_t b) { switch (state) { case ST_IDLE: if (b == 0xA5) { frame[0] = b; state = ST_HEAD1; } break; case ST_HEAD1: if (b == 0x5A) { frame[1] = b; state = ST_LEN; } else state = ST_IDLE; // 帧头错位,重新找 break; case ST_LEN: recv_len = b; if (recv_len < 2 || recv_len > 59) { state = ST_IDLE; // 非法长度,丢帧 } else { frame[2] = b; recv_idx = 0; state = ST_DATA; } break; case ST_DATA: frame[3 + recv_idx] = b; recv_idx++; if (recv_idx >= recv_len - 1) state = ST_CRC; // payload收完 break; case ST_CRC: if (recv_idx_crc == 0) { frame[2 + recv_len] = b; // crc_hi } else { frame[3 + recv_len] = b; // crc_lo // 从len字段开始校验,长度recv_len if (crc16_check(&frame[2], recv_len, frame[2 + recv_len], frame[3 + recv_len]) == 0) { on_proto_frame(&frame[3], recv_len); } state = ST_IDLE; } break; } }

这个状态机的容错逻辑很关键:只要某一帧CRC错,状态机自动回到ST_IDLE,下一帧只要开头对齐就能恢复。如果不用状态机而是直接按固定偏移找帧头,一旦pc端因为驱动排队把两帧粘在一起,后面所有帧都会解析错位。

参数说明:recv_len上限59是因为帧总长=2(帧头)+1(len)+59+2(CRC)=64;下限2表示至少有1个命令字和1个数据字节。CRC的接收用一个单独的计数变量recv_idx_crc,不要复用recv_idx,否则会干扰payload计数。

4.3 上位机联调:Python + hidapi 一收一发

上位机我用Python的hidapi库验证。注意VID和PID要跟usbd_desc.c里保持一致,工程里默认的是0x1234和0x0005,你可以改,但要两面对齐。

import hid import time VID = 0x1234 PID = 0x0005 dev = hid.device() dev.open(VID, PID) def calc_crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b << 8 for _ in range(8): if crc & 0x8000: crc = ((crc << 1) ^ 0x1021) & 0xFFFF else: crc = (crc << 1) & 0xFFFF return crc def send_cmd(cmd: int, payload: bytes): if len(payload) > 58: raise ValueError("payload too long") frame = bytearray(64) frame[0] = 0xA5 frame[1] = 0x5A frame[2] = 1 + len(payload) # len = cmd + payload frame[3] = cmd frame[4:4 + len(payload)] = payload crc = calc_crc16(bytes(frame[2:4 + len(payload)])) frame[4 + len(payload)] = (crc >> 8) & 0xFF frame[5 + len(payload)] = crc & 0xFF # hidapi的write需要在前面补0x00作为Report ID dev.write(b'\x00' + bytes(frame)) resp = dev.read(65, 500) # 读回包,65含Report ID return resp[1:] # 去掉Report ID就是协议帧

这里有一个很重要的适配细节:Windows的HID栈总是要求在数据前加一个Report ID字节,即使报告描述符没有定义Report ID,默认也是0x00。所以上位机write实际发出65字节,固件端收到的Report_buf第一字节是0x00,后面64字节才是协议帧。固件解析时要么偏移1字节,要么直接把Report_buf当协议帧的第0字节开始。我建议固件里判断一下Report_buf[0] == 0x00,是就从buf[1]开始喂状态机。细节虽然小,但这是联调时最隐蔽的翻车点。

5. 避坑排查:F105 USB枚举与通信最常见的五个翻车现场

5.1 电脑提示“未知USB设备”或“设备描述符请求失败”

现象:USB插上后,设备管理器里直接出现“未知USB设备(设备描述符请求失败)”,上位机根本找不到设备。

原因:绝大多数是USB时钟不是48MHz。外部晶振标称值和实际偏差、CubeMX里USB时钟树没配到48MHz、或者PLL初始化失败但代码没处理返回值,都会导致枚举时设备无法正确响应SETUP请求。其次,PA11/PA12被复用配置成GPIO或串口功能,DP/DM信号根本没到USB控制器。

解决:先用CubeMX打开工程,看Clock Configuration页面里USB那一支是否显示48MHz。再看GPIO配置,USBDM和USBDP的模式必须为AF_PP。最后在SystemClock_Config里加断点,确认HAL_RCC_OscConfig返回HAL_OK。我习惯插着USB时用调试器读一下RCC_CFGR的USBPRES位,确认分频结果,比用眼睛看晶振靠谱。

5.2 设备管理器有设备但上位机打不开

现象:Windows正确识别为“HID-compliant device”,设备管理器里没有黄色感叹号,但用hid.enumerate()就是找不到这个VID/PID。

原因:设备描述符里的VID/PID与上位机不一致,或者报告描述符里的Report Count和端点描述符的wMaxPacketSize矛盾,导致打开后读取超时或直接失败。

解决:把usbd_desc.c里的VID改成0x1234、PID改成0x0005,重新编译下载后拔插USB。如果还打不开,用BusHound抓包看设备是否返回STALL。出现STALL基本就是报告描述符和端点描述符没对齐,检查Report Count是64还是被改成了别的值。

5.3 大批量发送时丢包严重

现象:上位机一次发送几十包,固件只收到一半,或者上位机收到的回包少了几个字节。

原因:常见原因是USB中断里做了耗时的协议解析,导致中断延时过长;或主循环解析速度跟不上,环形缓冲被覆盖。另一个隐蔽原因是读写指针没有做临界区保护,USB中断在写数据的同时主循环在读,读到半个包。

解决:接收回调里只做memcpy和重新ARM端点,协议解析全部放到主循环。环形缓冲的读写指针操作,在关中断或加互斥锁的前提下进行。我用的是单生产者单消费者环形缓冲,只需要在读时关中断,写时不需要,因为USB中断不会主动读数据。

5.4 发送函数卡死

现象:调用发送函数后,程序卡在循环里出不来,调试器暂停后发现PC停在等待发送完成标志的while里。

原因:发送完成标志在USB中断里清除,但USB中断优先级配置得过低,被高优先级定时器中断持续抢占;或者上一包数据已经写入端点缓冲但还没发完,又在同一个缓冲里写入新数据,导致协议栈状态错乱。

解决:改成非阻塞发送,像第3章那样用tx_busy标志快速失败返回。发送完成中断里清标志,调用方拿返回值决定是重试还是丢弃。这样即使端点忙,主循环也不会死锁。

5.5 CRC校验总是失败

现象:上位机发的帧固件能收到,帧头长度都正确,但CRC一直错,偶尔成功一两帧。

原因:字节序混乱。上位机按小端发送CRC(低字节在前),固件按大端校验(高字节在前);或计算CRC时把帧头两个字节也算进去了,范围两边不一致。

解决:约定CRC算法为CRC16-CCITT,初值0xFFFF,计算范围统一从len字段开始到payload末尾,不包含帧头。两边各写一个函数,先用一组固定测试向量对拍:比如len=0x02、cmd=0x10、payload第一个字节为0x01,算出的CRC预期值两边必须一致。我一般在代码里注释里写死这组测试向量,防止以后改协议时把规则改乱。

6. 进阶验证:逻辑分析仪抓包与抖动测试的实用技巧

6.1 用逻辑分析仪验证枚举和报告描述符

调USB协议,强烈建议别只靠上位机报错猜。找一台采样率至少24MS/s的逻辑分析仪,因为全速USB是12Mbps,采样率太低抓不出波形。把DP和DM分别接到两个通道,触发条件设为USB SOF。重点看三个东西:枚举时的SETUP事务、GET_DESCRIPTOR请求返回的报告描述符、IN/OUT端点的wMaxPacketSize。

如果抓到的SETUP事务正常,但后续IN事务返回STALL,基本就是报告描述符和端点描述符矛盾。我修过最典型的一例:报告描述符里Input报告是64字节,而端点描述符里wMaxPacketSize写的是16,上位机write 64字节,固件只能收16,协议解析进ST_CRC永远等不到正确长度,最后表现为偶发性丢包。逻辑分析仪抓包一眼就看到了STALL。

6.2 用上位机脚本做抖动与吞吐测试

协议调通只是开始,稳定性要量化。我习惯在Python脚本里循环发1000帧,统计每帧往返耗时:

import time import statistics times = [] for i in range(1000): t0 = time.perf_counter() send_cmd(0x10, b'\x00\x01' + (i & 0xFF).to_bytes(4, 'little')) resp = dev.read(65, 100) times.append((time.perf_counter() - t0) * 1000) print(f"avg: {statistics.mean(times):.3f} ms") print(f"max: {max(times):.3f} ms") p99 = sorted(times)[989] print(f"p99: {p99:.3f} ms")

全速HID每1ms一个帧,加上PC调度和排队,单包往返典型值在0.5到2ms之间。如果p99超过10ms,先怀疑PC端的HID栈排队,再怀疑固件解析阻塞。测试时拔掉无关的USB设备,因为USB是共享总线,其他设备的事务会挤占帧间隙。

做这个验证的起因是某一次我连续发1000帧出现6帧丢包,查了一天最后发现是中断优先级把USB放在最低档,被定时器中断挤掉了。从那以后我每次改协议描述符或收发逻辑,都强制先跑一遍抓包和抖动测试,确认链路没问题再动固件逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询