1. 项目概述:从“发不出”到“发得稳”的USB数据包发送实战
最近在折腾一个基于STM32F103的USB HID设备,需要用它通过中断端点(EP1 IN)发送超过64字节的数据包。这听起来是个挺基础的需求,对吧?但实际操作起来,却遇到了一个典型的“坑”:当数据包长度不超过64字节时,一切正常;一旦超过,数据就像石沉大海,程序没死机,但USB分析仪上就是抓不到包。这个问题在论坛和社群里其实挺常见的,很多朋友从官方例程(比如Custom_HID)修改过来,都会卡在这里。核心矛盾点在于,我们想当然地认为“分两次发”就行了,但USB通信的底层机制远比这精细。本文将彻底拆解这个问题,不仅告诉你“怎么改”,更会深入解释“为什么要这样改”,以及分享我在调试过程中积累的、数据手册里不会写的那些实操心得。无论你是刚接触USB协议栈的嵌入式新手,还是想优化现有代码的老手,这篇从实战中总结的笔记都能帮你避开弯路。
2. 问题根源深度剖析:为什么“分两次发”会失败?
2.1 USB端点与数据包大小的硬性约束
首先必须明确一个核心概念:USB协议中,每个端点的最大包长度(Maximum Packet Size)是在设备描述符里定义的,这是一个硬件和协议层面的硬性限制。对于全速USB(STM32F103支持的模式),中断传输(Interrupt Transfer)端点的最大包长度通常是1到64字节。你在CustomHID_ConfigDescriptor里看到的那个wMaxPacketSize字段(比如设置为64),就是告诉主机:“我这个端点一次最多能发64个字节的数据”。主机在发起一次传输请求时,会基于这个值来规划事务(Transaction)。
注意:这里容易产生一个误解,认为这个“最大包长”只是软件上的一个约定,可以随意突破。实际上,这个值深刻影响着USB外设控制器(USB IP Core)内部的缓冲区管理和状态机逻辑。控制器会严格按照这个长度来切割和封装数据,并管理发送状态。
2.2 发送状态机的缺失:数据覆盖的元凶
原提问者的代码逻辑是:把一个128字节的包,分成两个64字节的片段,然后快速连续地调用SetEPTxValid来启动发送。这里隐藏了一个致命问题:缺乏对端点发送状态的查询或等待。
USB通信是主机主导的(Host-driven)。设备端的SetEPTxValid只是将端点状态设置为“有效”(VALID),告诉USB控制器:“我这里有数据,可以发送了”。但数据何时被真正取走,是由主机定期发送的IN令牌(Token)来触发的。从你设置VALID,到主机发起IN事务并成功收到ACK,这中间存在一个时间窗口。
原代码在这个时间窗口内,直接修改了发送缓冲区的地址(SetEPTxAddr(ENDP1, ENDP1_TXADDR+i*64)),并再次设置VALID。这极有可能导致:
- 第一个64字节的数据刚刚被USB控制器从原始缓冲区地址
ENDP1_TXADDR开始读取了一部分。 - 此时,代码执行了
SetEPTxAddr,将发送缓冲区地址指向了ENDP1_TXADDR+64。 - USB控制器继续读取数据时,读到的已经是新地址的内容,导致第一个包的数据后半部分被破坏。
- 更糟糕的是,如果第一个包的事务尚未完成(状态仍是EP_TX_VALID),第二次设置VALID可能不被控制器响应,或者造成状态机混乱,最终导致整个发送流程静默失败。
这就是为什么“程序没死机,但没数据出来”的根本原因。发送数据不是“一放了之”,而是需要“握手”的。
2.3 缓冲区地址管理的误区
原代码中直接对ENDP1_TXADDR进行算术偏移(+i*64)是一个危险操作。ENDP1_TXADDR是一个在usb_conf.h中定义的宏,它代表的是USB包缓冲区(Packet Buffer)中,分配给端点1发送(TX)区域的起始地址。这个缓冲区是一块特殊的SRAM,由USB外设直接访问。
ST的USB库提供了专用的函数UserToPMABufferCopy来操作这块缓冲区。这个函数不仅完成了数据拷贝,更重要的是它处理了数据对齐和缓冲区边界等底层细节。直接使用指针偏移来修改EP_TX_ADDR寄存器,绕过了库的安全机制,极易引发对齐错误或缓冲区溢出,导致不可预知的行为。其他MCU可能因为其USB控制器架构或库函数实现方式不同,允许更灵活的操作,但这在STM32的USB库框架下是一个需要严格遵守的规则。
3. 解决方案:状态查询与双缓冲策略详解
理解了问题所在,解决方案就清晰了:我们必须确保在上一个数据包被主机成功确认(ACK)取走之前,不去触碰它的缓冲区。这里提供两种最常用且稳定的实现方法。
3.1 方法一:轮询查询端点状态(Polling)
这是最直接、最易于理解和调试的方法。核心思想是:在准备发送下一个数据包之前,主动查询端点的当前状态,只有确认上一个包已发送完成(状态变为EP_TX_NAK),才填充新数据并启动下一次发送。
下面是一个增强版的示例代码,增加了更多错误处理和日志输出:
#define EP1_MAX_PACKET_SIZE 64 #define TOTAL_DATA_SIZE 150 uint8_t g_tx_buffer[TOTAL_DATA_SIZE]; volatile uint8_t g_next_packet_index = 0; // 下一个待发送包的索引 volatile uint8_t g_tx_in_progress = 0; // 发送任务是否已启动 /** * @brief 初始化待发送的数据(示例) */ void PrepareData(void) { for (int i = 0; i < TOTAL_DATA_SIZE; ++i) { g_tx_buffer[i] = (uint8_t)(i & 0xFF); // 填充测试数据 } g_next_packet_index = 0; g_tx_in_progress = 1; // 启动发送任务 } /** * @brief 在主循环或定时器中断中调用的发送状态机 * @note 此函数应被频繁调用,例如放在1ms定时器中断或主循环中。 */ void EP1_Tx_StateMachine(void) { // 如果没有活跃的发送任务,直接返回 if (!g_tx_in_progress) { return; } // 关键步骤:查询端点1的发送状态 uint16_t ep_status = GetEPTxStatus(ENDP1); // 只有当端点处于NAK状态(空闲,等待数据)时,才能加载新数据 if (ep_status == EP_TX_NAK) { uint16_t bytes_remaining = TOTAL_DATA_SIZE - (g_next_packet_index * EP1_MAX_PACKET_SIZE); uint16_t bytes_to_send = (bytes_remaining > EP1_MAX_PACKET_SIZE) ? EP1_MAX_PACKET_SIZE : bytes_remaining; if (bytes_to_send > 0) { // 使用库函数安全拷贝数据到USB包缓冲区 UserToPMABufferCopy(&g_tx_buffer[g_next_packet_index * EP1_MAX_PACKET_SIZE], GetEPTxAddr(ENDP1), // 获取当前TX缓冲区地址 bytes_to_send); // 设置本次要发送的字节数 SetEPTxCount(ENDP1, bytes_to_send); // 将端点状态设置为VALID,通知USB控制器数据已就绪 SetEPTxStatus(ENDP1, EP_TX_VALID); // 调试输出(实际项目可能通过串口打印) // printf(“Sent packet %d, size %d\n”, g_next_packet_index, bytes_to_send); g_next_packet_index++; // 检查是否所有数据包都已加载到缓冲区(注意:不代表已全部发送到主机) if (bytes_remaining <= EP1_MAX_PACKET_SIZE) { // 所有数据包已提交。注意:此时最后一个包可能还在发送中。 // 我们在这里只标记本地任务完成,真正的发送完成需等待最后一个包状态变为NAK。 // 更严谨的做法是等待最后一个包也变为NAK,再清除g_tx_in_progress。 // 这里为简化,假设提交即算任务结束,实际应用需根据需求调整。 g_tx_in_progress = 0; // printf(“All packets submitted.\n”); } } else { // 没有数据需要发送了 g_tx_in_progress = 0; } } else if (ep_status == EP_TX_VALID) { // 端点正忙,上一次发送还未完成,只需等待,什么也不做 // 这是正常状态,无需打印日志,避免刷屏 } else { // 其他状态(如STALL),表示可能出错了,需要错误处理 // printf(“EP1 TX Error status: 0x%X\n”, ep_status); // 可以尝试重置端点或进行其他恢复操作 g_tx_in_progress = 0; // 停止发送任务 } }关键点解析与实操心得:
GetEPTxStatus是核心:这个函数查询的是USB外设控制器的内部状态寄存器。EP_TX_NAK意味着端点“空闲且已应答”,即上一个包已被主机ACK,可以接受新数据。EP_TX_VALID意味着数据已就绪但尚未被主机取走,或正在传输中。- 状态机逻辑:上述代码实现了一个简单的状态机。它避免了在忙等待中死循环,而是优雅地“让出”CPU,等待状态改变。这是嵌入式系统中处理异步事件的高效模式。
g_tx_in_progress标志位的作用:这是一个非常重要的设计。它分离了“数据准备”和“数据发送”两个过程。你的应用层代码(如ADC采样完成)只需要准备好数据并设置这个标志位,底层的EP1_Tx_StateMachine会自动、安全地将数据分片发送出去。这种解耦使得程序结构更清晰。- 关于发送完成的判断:代码中在提交完最后一个包后立即清除了
g_tx_in_progress。这表示“数据提交完毕”,但不保证数据已全部到达主机。对于需要严格确认的场景(如文件传输),你应该等待最后一个包的状态也变为EP_TX_NAK后再清除标志。这可以通过在状态机中增加一个“等待最后确认”的状态来实现。
3.2 方法二:利用发送完成中断回调(Interrupt Callback)
这是一种更事件驱动、更高效的方法,尤其适合在低功耗或主循环忙于其他任务的系统中使用。当USB控制器成功发送一个数据包并收到主机的ACK后,会触发一个发送完成中断。ST的USB库提供了回调函数来通知用户程序。
首先,你需要找到并修改端点1的发送完成回调函数。在usb_prop.c(或类似的文件)中,通常会有一个弱定义的函数EP1_IN_Callback。
// 在usb_prop.c中找到并修改这个函数,或者在自己的文件中重新实现它 // 弱定义示例: // __weak void EP1_IN_Callback(void) { /* 默认空实现 */ } // 你的强实现: void EP1_IN_Callback(void) { // 这个中断回调意味着上一个64字节(或更短)的包已经成功发送给主机了。 // 在这里,我们可以安全地准备下一个包。 extern volatile uint8_t g_next_packet_index; // 引用全局变量 extern uint8_t g_tx_buffer[]; extern volatile uint8_t g_tx_in_progress; if (!g_tx_in_progress) { return; // 没有活跃的发送任务 } uint16_t bytes_remaining = TOTAL_DATA_SIZE - (g_next_packet_index * EP1_MAX_PACKET_SIZE); if (bytes_remaining > 0) { uint16_t bytes_to_send = (bytes_remaining > EP1_MAX_PACKET_SIZE) ? EP1_MAX_PACKET_SIZE : bytes_remaining; UserToPMABufferCopy(&g_tx_buffer[g_next_packet_index * EP1_MAX_PACKET_SIZE], GetEPTxAddr(ENDP1), bytes_to_send); SetEPTxCount(ENDP1, bytes_to_send); SetEPTxStatus(ENDP1, EP_TX_VALID); // 启动下一次发送 g_next_packet_index++; if (bytes_remaining <= EP1_MAX_PACKET_SIZE) { // 这是最后一个包,发送已启动,但回调会在它完成后再次触发 // 可以在下次回调中清除g_tx_in_progress } } else { // 所有数据包都已发送完毕 g_tx_in_progress = 0; // 可以在这里设置一个标志,通知主程序发送完成 } }中断方式的优缺点:
- 优点:CPU占用率低,响应及时,没有轮询的开销。
- 缺点:调试相对复杂,因为中断是异步发生的。如果回调函数执行时间过长,可能会影响其他中断或导致USB通信异常。务必确保回调函数尽可能短小精悍。
重要提示:在
CustomHID_Reset函数中,除了设置SetEPTxCount,还必须正确初始化端点的状态,通常设置为EP_TX_NAK,表示初始时空闲,等待数据。同时,确保USB中断已正确启用。
4. 关键配置与底层细节排查清单
即使逻辑正确,配置错误也会导致发送失败。以下是一个必须逐项检查的清单,这些坑我都踩过。
4.1 端点描述符与缓冲区配置
这是最容易出错的地方,需要联动修改多个文件。
修改端点描述符(
usb_desc.c或usb_prop.c): 找到CustomHID_ConfigDescriptor数组,里面包含了端点1(IN)的描述符。确保wMaxPacketSize字段设置为你期望的单个数据包的最大长度,对于全速中断传输,有效值是1-64。即使你要发128字节,这里也填64,因为这是每次事务的最大负载。// 示例:端点描述符片段 0x07, // bLength: 端点描述符长度 0x05, // bDescriptorType: 端点描述符类型 0x81, // bEndpointAddress: 端点1 IN (0x81) 0x03, // bmAttributes: 中断传输类型 (0x03) 0x40, 0x00, // wMaxPacketSize: 最大包大小64字节 (低字节在前) 0x0A, // bInterval: 轮询间隔10ms重新计算并分配USB缓冲区地址(
usb_conf.h):这是最关键的步骤!USB外设有固定大小的包缓冲区(例如STM32F103是512字节)。每个端点(TX和RX)都需要在这个缓冲区中分配一块独占的区域。当你增大某个端点的wMaxPacketSize时,它占用的缓冲区大小也增加了,后面所有端点的缓冲区地址都必须重新计算,否则会发生缓冲区重叠,导致数据损坏。- 找到定义:在
usb_conf.h中,找到类似#define BTABLE_ADDRESS 0x00和端点缓冲区地址的定义,如:#define ENDP0_TXADDR (0x40) #define ENDP0_RXADDR (0x80) #define ENDP1_TXADDR (0xC0) #define ENDP1_RXADDR (0x100) // ... 其他端点 - 理解计算规则:每个缓冲区地址都是相对于
BTABLE_ADDRESS的偏移量,单位是字节。STM32的USB缓冲区通常以2字节为边界对齐。每个端点TX或RX缓冲区的大小至少应等于其wMaxPacketSize。 - 手动计算:
- 假设
ENDP0_RXADDR在0x80,大小为64字节。 ENDP1_TXADDR就应该是0x80 + 64 = 0xC0。- 如果你设置EP1的
wMaxPacketSize为64,那么ENDP1_TXADDR需要占用64字节。 ENDP1_RXADDR就应该是0xC0 + 64 = 0x100。- 务必确保计算后的地址没有超出芯片USB缓冲区的总大小(查数据手册),且各个端点的缓冲区没有重叠。一个简单的画图或表格检查非常有效。
- 假设
- 找到定义:在
4.2 初始化与复位流程
在usb_prop.c的CustomHID_Reset函数中,确保对端点1进行了正确的初始化:
void CustomHID_Reset(void) { // ... 其他端点初始化 // 初始化端点1为中断输入端点 SetEPType(ENDP1, EP_INTERRUPT); SetEPTxAddr(ENDP1, ENDP1_TXADDR); // 使用在usb_conf.h中计算好的地址 SetEPTxCount(ENDP1, 64); // 设置初始TX计数,通常等于wMaxPacketSize SetEPTxStatus(ENDP1, EP_TX_NAK); // 初始状态设为NAK,等待数据 // 如果端点1也用于接收,则需要类似地初始化RX部分 // SetEPRxAddr(ENDP1, ENDP1_RXADDR); // SetEPRxCount(ENDP1, 64); // SetEPRxStatus(ENDP1, EP_RX_VALID); // ClearDTOG_RX(ENDP1); }4.3 调试与验证技巧
当代码修改后仍然不成功时,可以按以下步骤排查:
- 使用USB协议分析仪:这是最强大的工具。它能让你看到底层的USB事务(Token, Data, Handshake),直接确认设备是否发出了DATAx包,主机是否回复了ACK。如果没有分析仪,可以尝试下一步。
- 软件模拟与状态打印:在关键位置(如
EP1_IN_Callback、状态查询处)通过串口打印日志。输出当前端点状态、g_next_packet_index、g_tx_in_progress等变量的值。观察状态机是否按预期流转。 - 简化测试:先不要发送150字节。先测试发送一个65字节的包(分成64+1)。成功后,再测试发送两个64字节的包(共128字节)。逐步增加复杂度,能帮你快速定位问题是在分片逻辑还是状态管理上。
- 检查编译优化:高等级的编译器优化(如-O2, -O3)有时会扰乱对
volatile变量的访问或精简掉看似“无用”的状态查询循环。在调试阶段,可以尝试使用-O0(无优化)进行编译,确保逻辑正确后,再考虑优化。 - 参考官方库和例程:ST的USB库可能随版本更新。仔细阅读你所用版本库文件中的注释,特别是
usb_regs.h,usb_mem.c,usb_int.c。有时答案就藏在头文件的宏定义或某个函数的注释里。
5. 进阶优化与生产环境考量
当基本功能实现后,为了代码的健壮性和可维护性,可以考虑以下优化。
5.1 封装健壮的发送函数
将上述状态机或中断回调逻辑封装成一个独立的、线程安全的发送函数。这个函数应该处理所有边界情况,并返回明确的执行状态。
typedef enum { TX_IDLE, TX_BUSY, TX_COMPLETE, TX_ERROR } usb_tx_state_t; typedef struct { uint8_t *data_ptr; uint16_t total_len; uint16_t bytes_sent; usb_tx_state_t state; void (*completion_callback)(void); // 可选:发送完成回调 } usb_tx_job_t; usb_tx_job_t ep1_tx_job; /** * @brief 启动一个USB端点1的异步发送任务 * @param data 待发送数据指针 * @param len 数据总长度 * @return 0: 成功提交任务, -1: 端点忙(上一个任务未完成) */ int8_t USB_EP1_SendAsync(uint8_t *data, uint16_t len) { // 检查端点是否空闲 if (ep1_tx_job.state == TX_BUSY) { return -1; // 忙,拒绝新任务 } // 初始化任务结构 ep1_tx_job.data_ptr = data; ep1_tx_job.total_len = len; ep1_tx_job.bytes_sent = 0; ep1_tx_job.state = TX_BUSY; // 如果使用轮询,则设置标志,主循环会处理 // 如果使用中断,这里可以直接启动第一个包 if (GetEPTxStatus(ENDP1) == EP_TX_NAK) { // 立即发送第一个包 uint16_t chunk = (len > EP1_MAX_PACKET_SIZE) ? EP1_MAX_PACKET_SIZE : len; UserToPMABufferCopy(data, GetEPTxAddr(ENDP1), chunk); SetEPTxCount(ENDP1, chunk); SetEPTxStatus(ENDP1, EP_TX_VALID); ep1_tx_job.bytes_sent = chunk; } // 如果端点忙(VALID),则等待中断回调或轮询状态机来启动 return 0; } // 然后在你的状态机或中断回调中,基于ep1_tx_job来管理发送过程。5.2 处理背压与流控
在高速数据流场景下,设备生产数据的速度可能快于USB主机读取的速度。单纯的“发送-等待ACK”模式可能导致数据丢失。你需要实现简单的流控:
- 增加发送队列:当
USB_EP1_SendAsync被调用时,如果端点忙,不是直接返回错误,而是将任务放入一个队列(FIFO)中。当EP1_IN_Callback触发时,从队列中取出下一个任务执行。这平滑了数据流。 - 应用层反馈:当发送队列满时,向上层应用(如ADC采样)反馈“忙”信号,让其暂停或丢弃数据。这需要设计一个清晰的上层接口。
5.3 功耗与实时性权衡
- 轮询模式:在
main循环或高速定时器中查询状态。优点简单,实时性相对可控。缺点是一直占用CPU,功耗高。适合对功耗不敏感、主循环本来就很忙的系统。 - 中断模式:CPU利用率低,功耗优。但中断响应时间、中断嵌套优先级需要仔细配置。如果系统中有其他高优先级中断长时间关闭总中断,可能导致USB中断丢失,通信超时。务必评估系统的整体中断负载。
我个人在多数项目中倾向于使用**“中断驱动 + 状态标志”**的模式。即在EP1_IN_Callback中只做最必要的操作(拷贝数据、设置寄存器、更新索引),然后设置一个标志位(如tx_pending)。主循环中检查这个标志位,进行后续的业务逻辑处理(如准备下一批数据)。这样既享受了中断的低功耗和及时响应,又将耗时的操作放在主循环,避免了在中断中处理复杂逻辑的风险。
最后,关于SetEPTxAddr的误区,这里再强调一次:不要直接修改它。GetEPTxAddr和SetEPTxAddr函数主要用于库内部管理和在CustomHID_Reset中的初始化。在正常的数据发送过程中,UserToPMABufferCopy函数会自动处理数据写入到正确的缓冲区位置,我们只需要关心数据源和长度。直接操纵地址寄存器是底层硬件操作,除非你非常清楚USB控制器的缓冲区管理机制,否则极易出错。相信库函数,它们封装了硬件细节,提供了更安全的接口。