1. 项目概述从“发不出”到“发得稳”的USB数据包发送实战最近在折腾一个基于STM32F103的USB HID设备需要用它通过中断端点EP1 IN发送超过64字节的数据包。这听起来是个挺基础的需求对吧但实际操作起来却遇到了一个典型的“坑”当数据包长度不超过64字节时一切正常一旦超过数据就像石沉大海程序没死机但USB分析仪上就是抓不到包。这个问题在论坛和社群里其实挺常见的很多朋友从官方例程比如Custom_HID修改过来都会卡在这里。核心矛盾点在于我们想当然地认为“分两次发”就行了但USB通信的底层机制远比这精细。本文将彻底拆解这个问题不仅告诉你“怎么改”更会深入解释“为什么要这样改”以及分享我在调试过程中积累的、数据手册里不会写的那些实操心得。无论你是刚接触USB协议栈的嵌入式新手还是想优化现有代码的老手这篇从实战中总结的笔记都能帮你避开弯路。2. 问题根源深度剖析为什么“分两次发”会失败2.1 USB端点与数据包大小的硬性约束首先必须明确一个核心概念USB协议中每个端点的最大包长度Maximum Packet Size是在设备描述符里定义的这是一个硬件和协议层面的硬性限制。对于全速USBSTM32F103支持的模式中断传输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_TXADDRi*64)并再次设置VALID。这极有可能导致第一个64字节的数据刚刚被USB控制器从原始缓冲区地址ENDP1_TXADDR开始读取了一部分。此时代码执行了SetEPTxAddr将发送缓冲区地址指向了ENDP1_TXADDR64。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数组里面包含了端点1IN的描述符。确保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字节的包分成641。成功后再测试发送两个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控制器的缓冲区管理机制否则极易出错。相信库函数它们封装了硬件细节提供了更安全的接口。