USB CDC这玩意儿,刚上手的时候觉得特别简单——CubeMX里勾一下,生成代码,插上电脑就能识别出一个串口,收发几个字节轻轻松松。但真正把它用到产品里,尤其是要跑大数据量连续传输的时候,问题就全冒出来了:丢包、卡死、主机端读不到数据、传输速度死活上不去、跑几分钟就断连。我在几个基于STM32F4的项目里都踩过这些坑,从最开始用阻塞式发送,到后来上DMA、调缓冲、改NAK处理逻辑,前后折腾了不少时间。这篇就把我在STM32F4上做USB CDC大数据量稳定传输的完整经验梳理一遍,包括底层机制、缓冲设计、DMA双缓冲的配合、常见故障的排查链路,以及那些文档里不会写的细节。不管你是刚接触USB CDC的新手,还是已经调了一阵子但传输总不稳定的老哥,应该都能从里面找到有用的东西。
1. 先搞清楚STM32F4的USB CDC到底卡在哪
很多人一上来就问"为什么我的CDC传不快",其实在动手优化之前,得先弄明白STM32F4这颗片子的USB外设到底是个什么水平,瓶颈可能出在哪些环节。不搞清楚这些,你调参数就是瞎调。
1.1 STM32F4的USB FS外设能力边界
STM32F4系列大部分型号带的是USB OTG FS(全速)外设,注意是Full Speed,不是High Speed。全速USB的理论上限是12 Mbps,换算成字节大概是1.5 MB/s。但这是物理层的理论值,实际能跑到的有效吞吐要打不少折扣。
我实测下来,STM32F4的USB CDC在理想条件下(主机端读取及时、缓冲配置合理),单向传输大概能稳定在600 KB/s到900 KB/s之间。超过这个范围,要么开始丢数据,要么延迟抖动变得很大。如果你看到别人说跑到1 MB/s以上,那多半是短时突发或者测试方法有问题。
这里有个关键点容易被忽略:全速USB的帧周期是1ms,每帧最多传输19个64字节的批量端点包(Bulk Endpoint),算下来理论峰值是19×64×1000 = 1.216 MB/s。但这是纯数据负载,实际还要扣除协议开销、握手包、以及主机控制器的调度间隙。所以能跑到900 KB/s已经算是调得相当不错了。
1.2 大数据量传输时最容易崩的几个环节
我把实际项目中遇到的崩溃点归了几类,你可以对照看看自己卡在哪:
| 故障现象 | 可能的瓶颈环节 | 典型原因 |
|---|---|---|
| 传输几十KB后卡死 | 发送缓冲管理 | 上一包没发完就写下一包,覆盖了正在传输的数据 |
| 速度只有几十KB/s | 端点配置/轮询方式 | 用了阻塞式发送,每次等传输完成 |
| 主机端偶尔丢包 | NAK处理/流控 | 设备端发送太快,主机来不及取 |
| 跑几分钟后断连 | 缓冲溢出/内存越界 | 环形缓冲指针回绕时算错,踩了别的变量 |
| 大数据量下延迟忽大忽小 | 中断优先级/DMA冲突 | USB中断被其他高优先级中断打断太久 |
这张表里的每一行,背后都是一类具体的机制问题。下面我会逐个拆开讲,但你先有个整体印象:USB CDC的稳定性问题,八成不是USB协议本身的问题,而是你的数据搬运逻辑和缓冲设计有问题。
1.3 为什么"能收发小数据"和"能稳定传大数据"是两回事
小数据量测试(比如每秒发几个几十字节的包)根本压不到系统的边界。这时候缓冲永远是空的,中断永远来得及处理,DMA永远有空闲通道。你看到的"正常工作"其实是一种假象。
一旦数据量上来,比如你要连续传一个几百KB的固件、或者持续采集高速ADC数据往上传,情况就完全变了:
- 发送缓冲会被填满,你必须处理"缓冲满了怎么办"
- USB中断会变得非常频繁,如果中断里做的事情太多,就会丢中断
- 主机端的读取节奏和你设备端的发送节奏如果不匹配,NAK就会大量出现
- DMA如果和CPU同时访问同一块内存,没有正确同步的话,数据就会错乱
所以我的建议是:从项目一开始就按"大数据量"的标准来设计传输架构,不要等到出问题了再改。后面会讲具体怎么设计。
2. 发送路径的架构设计:从阻塞式到DMA双缓冲
发送路径是CDC大数据传输里最容易出问题的地方,也是优化空间最大的地方。我按演进顺序讲,你可以看看自己现在处于哪个阶段。
2.1 阻塞式发送为什么在大数据量下必然失败
CubeMX生成的CDC代码,默认用的是CDC_Transmit_FS()这个函数。它的内部逻辑大致是:把数据拷贝到USB端点的发送缓冲,然后等待传输完成。如果你在调用它之后立刻又调用一次,而上一包还没发完,它就直接返回USBD_BUSY,你的数据就丢了。
很多人图省事,在发送函数外面套一个while循环:
while(CDC_Transmit_FS(buf, len) != USBD_OK) { // 等待上一次传输完成 }这个写法在小数据量下能跑,但在大数据量下是灾难。原因很简单:你在死等USB传输完成,这期间CPU什么都干不了。如果USB主机端因为某种原因(比如驱动调度延迟)没有及时取数据,你这个while就会卡很久,整个系统的实时性就崩了。
更隐蔽的问题是:如果你在中断里调用这个while循环,那就更危险了——USB中断被自己阻塞住,后续的传输完成中断进不来,直接死锁。
2.2 环形缓冲+非阻塞发送的基本框架
正确的做法是引入一个发送环形缓冲(Ring Buffer),把"生产数据"和"通过USB发送"这两件事解耦。
基本思路是这样:
- 应用层只管往环形缓冲里写数据,写满了就等待或者丢弃(看你的策略)
- USB发送由一个独立的状态机驱动,只要端点空闲且缓冲里有数据,就取一包发出去
- 发送完成中断里,标记端点空闲,然后触发下一次发送
环形缓冲的实现有很多讲究,我这里给一个经过实战验证的结构:
#define TX_BUF_SIZE 8192 // 必须是2的幂,方便用位运算取模 typedef struct { uint8_t buffer[TX_BUF_SIZE]; volatile uint32_t head; // 写入位置 volatile uint32_t tail; // 读取位置 } ring_buf_t; static ring_buf_t tx_ring; // 写入数据,返回实际写入的字节数 uint32_t ring_write(ring_buf_t *rb, const uint8_t *data, uint32_t len) { uint32_t available = TX_BUF_SIZE - (rb->head - rb->tail); if (len > available) len = available; for (uint32_t i = 0; i < len; i++) { rb->buffer[(rb->head + i) & (TX_BUF_SIZE - 1)] = data[i]; } rb->head += len; return len; } // 读取数据用于发送 uint32_t ring_read(ring_buf_t *rb, uint8_t *data, uint32_t len) { uint32_t used = rb->head - rb->tail; if (len > used) len = used; for (uint32_t i = 0; i < len; i++) { data[i] = rb->buffer[(rb->tail + i) & (TX_BUF_SIZE - 1)]; } rb->tail += len; return len; }注意head和tail都加了volatile,因为它们会在中断和主循环里被同时访问。另外用& (TX_BUF_SIZE - 1)代替取模运算,是因为在Cortex-M4上除法很慢,位运算快得多——前提是缓冲大小必须是2的幂。
提示:
head和tail用无符号32位整数,即使溢出回绕也不会出错,因为head - tail的减法在无符号运算下自动得到正确的已用长度。这个技巧在嵌入式环形缓冲里非常常用。
2.3 DMA双缓冲在USB发送中的正确用法
STM32F4的USB OTG FS外设内部其实有自己的FIFO,但很多人在做大数据量传输时会想用DMA来搬运数据。这里要特别小心:USB OTG FS的端点FIFO访问和普通外设的DMA请求不是一回事。
STM32F4的USB OTG FS在设备模式下,端点数据的搬运是通过专用的DMA(在OTG模块内部)来完成的,不是你平时用的那个DMA1/DMA2控制器。你在CubeMX里看到的"USB OTG FS"相关的DMA配置,实际上配置的是OTG内部的那个DMA。
所以正确的做法是:
- 应用层数据先放进你的环形缓冲
- USB发送状态机从环形缓冲取一包数据,调用HAL库的发送函数
- HAL库内部会把数据拷贝到端点FIFO,然后由OTG内部DMA发送出去
- 发送完成中断触发后,状态机再取下一包
如果你非要用DMA1/DMA2来做"内存到内存"的搬运(比如从ADC缓冲搬到USB发送缓冲),那是可以的,但要注意和USB发送状态机的同步。我一般不建议在这个环节引入额外的DMA,因为USB发送本身已经是异步的了,再加一层DMA只会让同步逻辑更复杂。
真正需要DMA双缓冲的场景,是在数据采集端——比如ADC连续采样,用DMA双缓冲把数据分块搬到处理缓冲,处理完再送USB。这个后面会单独讲。
2.4 发送状态机的实现细节
发送状态机是整个发送路径的核心,它决定了数据能不能连续、稳定地流出去。我一般这样实现:
typedef enum { TX_IDLE, TX_SENDING, } tx_state_t; static tx_state_t tx_state = TX_IDLE; static uint8_t tx_packet[64]; // 全速USB批量端点最大包长 void usb_tx_poll(void) { if (tx_state != TX_IDLE) return; uint32_t used = tx_ring.head - tx_ring.tail; if (used == 0) return; uint32_t to_send = (used > 64) ? 64 : used; ring_read(&tx_ring, tx_packet, to_send); tx_state = TX_SENDING; if (CDC_Transmit_FS(tx_packet, to_send) != USBD_OK) { // 发送失败,把数据还回去(简化处理,实际项目要更严谨) tx_ring.tail -= to_send; tx_state = TX_IDLE; } } // 在CDC_TransmitCplt_FS回调里调用 void usb_tx_complete(void) { tx_state = TX_IDLE; usb_tx_poll(); // 立刻尝试发下一包 }这个状态机在主循环里被周期性调用(usb_tx_poll),同时在发送完成回调里也被调用。这样只要缓冲里有数据,发送就会一直连续进行,不会出现"发一包等半天"的情况。
有个细节要注意:CDC_Transmit_FS返回USBD_OK不代表数据已经发出去了,只代表数据已经被接受并放进了端点FIFO。真正的发送完成是在CDC_TransmitCplt_FS回调里通知的。所以tx_state的清除必须在回调里做,不能在调用CDC_Transmit_FS之后立刻做。
3. 接收路径与NAK流控:主机端配合才是关键
发送路径调好了,很多人以为就完事了,结果发现主机端读到的数据还是有问题。这时候问题往往出在接收路径和流控上。
3.1 接收缓冲的设计与溢出保护
接收路径和发送路径是对称的,也需要一个环形缓冲。但接收有个特殊之处:数据是主机主动推过来的,你无法控制它什么时候来、来多少。所以接收缓冲的溢出保护特别重要。
#define RX_BUF_SIZE 4096 static ring_buf_t rx_ring; // 在CDC_Receive_FS回调里调用 int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len) { uint32_t written = ring_write(&rx_ring, Buf, *Len); if (written < *Len) { // 缓冲满了,发生了溢出 rx_overflow_count++; // 这里可以选择丢弃多余数据,或者做流控 } // 重新武装接收端点 USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return USBD_OK; }注意最后两行:每次接收完必须重新设置接收缓冲并重新武装端点,否则下一次接收不会触发。这是CubeMX生成代码里已经有的,但如果你自己改了接收逻辑,很容易漏掉。
rx_overflow_count这个计数器在实际调试时非常有用。如果它一直在涨,说明你的应用层读取速度跟不上主机发送速度,要么加大缓冲,要么优化应用层处理逻辑。
3.2 NAK是怎么产生的,以及它为什么不是坏事
NAK(Negative Acknowledge)在USB协议里表示"设备暂时无法处理这个请求"。在批量传输(Bulk Transfer)中,NAK是一种正常的流控机制。
当主机要发送数据到设备时,它会先发一个OUT令牌包。如果设备的接收端点缓冲满了(还没被应用层取走),设备就会回一个NAK,告诉主机"我现在收不了,你等会儿再试"。主机会在下一帧重试。
很多人看到逻辑分析仪上大量NAK就慌了,以为出了问题。其实在批量传输里,适量NAK是完全正常的,它恰恰说明流控在起作用。真正有问题的是:
- NAK持续不断,说明设备端一直没取走数据,应用层处理太慢
- 完全没有NAK但数据丢了,说明缓冲管理有bug
- NAK和传输交替出现但吞吐很低,说明缓冲太小,主机和设备在频繁地"试探"
我一般会这样判断:如果NAK的比例在10%到30%之间,且吞吐稳定,那是健康的。如果NAK超过50%,就要检查应用层读取逻辑了。
3.3 主机端驱动的读取节奏对稳定性的影响
这一点经常被忽略:USB CDC的稳定性不只取决于设备端,主机端的读取方式同样关键。
在Windows上,如果你用普通的串口API(比如ReadFile)去读CDC设备,默认的读取超时和缓冲设置可能不适合大数据量场景。我遇到过好几次设备端明明发得好好的,主机端就是丢数据,最后发现是主机端读取缓冲太小、读取间隔太长。
几个主机端的调优建议:
- 把串口读取缓冲设大一些(比如64KB),减少读取频率
- 用重叠I/O(Overlapped I/O)做异步读取,不要用阻塞式读取
- 读取线程的优先级适当提高,避免被其他任务抢占
- 如果用的是Linux,注意
tty层的缓冲和termios配置,必要时用raw模式
在Linux下,可以用stty命令查看和设置串口参数:
stty -F /dev/ttyACM0 -a # 查看当前配置 stty -F /dev/ttyACM0 raw -echo # 设置为raw模式主机端和设备端是配合关系,任何一端拖后腿,整体吞吐和稳定性都会受影响。调优的时候一定要两端一起看。
4. 实战排查:几个典型故障的完整定位过程
前面讲的都是"应该怎么做",这一节讲"出问题了怎么查"。我挑几个实际项目中遇到的典型故障,把排查链路完整还原一遍。
4.1 故障一:传输到128KB左右必然卡死
现象:设备端连续发送数据,主机端能正常接收,但每次传到大约128KB的时候,传输就停了,设备端还在跑但主机端收不到数据了。
排查过程:
第一步,我先确认是设备端没发还是主机端没收。在设备端的发送状态机里加了个计数器,记录实际调用CDC_Transmit_FS的次数。发现卡死的时候,设备端的发送计数器还在涨,说明设备端认为自己在正常发送。
第二步,用逻辑分析仪抓USB总线上的包。发现卡死之后,设备端一直在回NAK,主机端一直在发IN令牌,但设备端就是不给数据。这说明设备端的发送端点FIFO是空的,但状态机认为有数据要发。
第三步,检查发送状态机的逻辑。发现tx_state在某种情况下没有被正确清除——具体来说,当CDC_Transmit_FS返回USBD_BUSY的时候,我的代码把tx_state设回了TX_IDLE,但没有把数据还回环形缓冲。这样数据就"丢"了,但更严重的是,下一次poll的时候,tx_state是IDLE,但端点实际上还在忙,于是又调用CDC_Transmit_FS,又返回BUSY,陷入死循环。
根因:CDC_Transmit_FS返回BUSY时,状态机没有正确处理,导致状态和实际端点状态不一致。
修复:在返回BUSY时,不清除tx_state,也不还回数据,而是等下一次发送完成回调再重试。修改后的逻辑:
void usb_tx_poll(void) { if (tx_state != TX_IDLE) return; uint32_t used = tx_ring.head - tx_ring.tail; if (used == 0) return; uint32_t to_send = (used > 64) ? 64 : used; // 先拷贝到临时缓冲,不要直接移动tail uint8_t temp[64]; ring_peek(&tx_ring, temp, to_send); if (CDC_Transmit_FS(temp, to_send) == USBD_OK) { ring_consume(&tx_ring, to_send); // 只有成功才移动tail tx_state = TX_SENDING; } // 如果BUSY,什么都不做,等下次poll或回调 }这个bug的教训是:环形缓冲的tail指针只能在数据真正被接受之后才能移动。先peek再consume,比直接read要安全。
4.2 故障二:传输速度只有100KB/s,远低于预期
现象:功能正常,不丢数据,但速度就是上不去,稳定在100KB/s左右。
排查过程:
第一步,算了一下理论值。全速USB批量传输,每帧最多19个64字节包,理论峰值1.2MB/s。现在只有100KB/s,差了十倍多。
第二步,用逻辑分析仪看总线利用率。发现每帧只传了2到3个包,总线大部分时间是空闲的。这说明不是USB带宽不够,而是设备端供不上数据。
第三步,检查发送状态机的调用频率。发现usb_tx_poll只在主循环里调用,而主循环里还有其他任务(比如ADC数据处理),导致poll的调用间隔不稳定,有时候几十微秒,有时候几毫秒。
第四步,检查发送完成回调。发现回调里虽然调用了usb_tx_poll,但回调本身的执行频率受限于USB帧周期(1ms)。也就是说,即使缓冲里有数据,最快也只能每1ms发一包。
根因:发送状态机的驱动频率不够,且没有充分利用USB帧内的多个包传输机会。
修复:把usb_tx_poll放到一个高优先级的定时器中断里,周期设为100微秒。同时在发送完成回调里也调用。这样在1ms的一帧内,可以连续发出多个包。
修改后速度提升到了700KB/s以上。这里的关键认知是:USB批量传输在设备端是可以"连续发"的,只要端点FIFO有空位,就可以一直往里塞数据,不需要等上一帧结束。
4.3 故障三:跑几分钟后随机断连
现象:传输一开始正常,跑几分钟后随机断连,主机端报"设备已断开",但设备端没有复位。
排查过程:
第一步,怀疑是看门狗或者电源问题。检查了电源和看门狗配置,都正常。
第二步,在断连的时候打印设备端的USB状态寄存器。发现断连时USB外设进入了某种错误状态,但具体是什么错误看不出来。
第三步,怀疑是内存越界。检查了所有缓冲的边界条件,发现接收环形缓冲的ring_write函数在缓冲满的时候,written的计算有问题——当len大于可用空间时,written被设成了len而不是实际写入的字节数。这导致应用层以为写入了更多数据,后续读取时越界访问了缓冲之外的内存。
根因:环形缓冲的边界计算错误,导致内存越界,破坏了USB外设的配置寄存器。
修复:修正ring_write的返回值计算,并在所有缓冲操作里加上断言检查。修改后的代码:
uint32_t ring_write(ring_buf_t *rb, const uint8_t *data, uint32_t len) { uint32_t available = TX_BUF_SIZE - (rb->head - rb->tail); uint32_t to_write = (len > available) ? available : len; for (uint32_t i = 0; i < to_write; i++) { rb->buffer[(rb->head + i) & (TX_BUF_SIZE - 1)] = data[i]; } rb->head += to_write; return to_write; // 返回实际写入的字节数 }这个bug的教训是:环形缓冲的返回值必须精确反映实际写入量,调用方必须检查返回值。另外,在调试阶段加上assert可以尽早发现这类问题。
4.4 故障四:DMA双缓冲和USB发送抢内存导致数据错乱
现象:ADC用DMA双缓冲采集数据,处理完通过USB发送。发现偶尔有数据错乱,表现为某些采样点的值明显不对。
排查过程:
第一步,先确认是采集端的问题还是传输端的问题。在DMA完成中断里把数据拷贝一份到调试缓冲,对比USB发送出去的数据。发现调试缓冲里的数据是对的,USB发出去的是错的。
第二步,检查USB发送路径。发现USB发送状态机直接从DMA的当前缓冲里读数据,而DMA可能正在往这个缓冲里写新数据。这就是典型的竞态条件。
根因:DMA双缓冲的"当前缓冲"和"处理缓冲"没有正确切换,USB发送读到了正在被DMA写入的缓冲。
修复:在DMA完成中断里,先切换缓冲,再通知USB发送状态机。USB发送只读"已完成"的缓冲,不读"正在采集"的缓冲。具体做法是维护两个指针:dma_active_buf和usb_read_buf,在DMA完成中断里原子地交换它们。
volatile uint8_t *dma_active_buf; volatile uint8_t *usb_read_buf; volatile uint8_t buf_ready_flag; void DMA_Complete_Callback(void) { // 切换缓冲 if (dma_active_buf == buf_a) { dma_active_buf = buf_b; usb_read_buf = buf_a; } else { dma_active_buf = buf_a; usb_read_buf = buf_b; } buf_ready_flag = 1; }这个bug的教训是:只要涉及DMA和CPU同时访问内存,就必须仔细设计同步机制。DMA双缓冲的切换点必须在DMA完成中断里,不能在主循环里。
5. 让传输更稳的几个进阶技巧
前面讲的都是基础架构和故障排查,这一节分享几个让传输更稳、更高效的进阶技巧。这些是我在实际项目中慢慢摸索出来的,文档里基本不会写。
5.1 合理设置端点FIFO大小
STM32F4的USB OTG FS外设有一块共享的FIFO内存(通常是1.25KB或320字节,取决于具体型号)。你可以给不同的端点分配不同大小的FIFO。
对于CDC的大数据量传输,我一般这样分配:
- 控制端点EP0:64字节(必须)
- CDC命令端点:16字节(够用就行)
- CDC数据IN端点:128字节或256字节(发送用,越大越好)
- CDC数据OUT端点:64字节(接收用,全速USB批量端点最大就是64)
在CubeMX里可以配置这些FIFO大小,但要注意总大小不能超过硬件限制。如果配置超了,USB枚举会失败。
给IN端点分配更大的FIFO,可以让设备端在主机来取数据之前就预先填好多个包,减少NAK,提高吞吐。我实测把IN端点FIFO从64字节加到256字节,吞吐能提升15%左右。
5.2 中断优先级的安排
USB中断的优先级安排很讲究。设得太低,会被其他中断打断太久,导致USB协议超时;设得太高,又会影响系统的实时性。
我的经验值是:USB中断优先级设为中等偏上,比ADC、定时器等周期性中断略低,但比串口、按键等非实时中断高。在STM32F4的NVIC里,数值越小优先级越高,我一般把USB中断设为优先级2或3(总共16级)。
另外,USB中断处理函数里不要做耗时操作。CubeMX生成的USB中断处理函数会调用一系列回调,如果你在回调里做了大量数据处理,就会阻塞USB中断。正确的做法是在回调里只做标记和缓冲操作,把数据处理放到主循环或低优先级任务里。
5.3 用定时器驱动发送状态机
前面提到把usb_tx_poll放到定时器中断里,这里展开说一下具体怎么配。
我一般用一个基本定时器(比如TIM6或TIM7),配置成100微秒周期中断。在中断里只做一件事:调用usb_tx_poll。这个函数本身很快(就是检查状态、取数据、调用发送),不会占用太多时间。
void TIM6_DAC_IRQHandler(void) { if (TIM6->SR & TIM_SR_UIF) { TIM6->SR &= ~TIM_SR_UIF; usb_tx_poll(); } }定时器中断的优先级要低于USB中断,这样USB发送完成回调可以打断定时器中断,及时清除tx_state。
这个方案的好处是发送节奏非常稳定,不受主循环其他任务的影响。实测下来,比在主循环里poll的吞吐高20%以上,而且延迟抖动小很多。
5.4 主机端读取缓冲的调优参数
设备端调好了,主机端也不能拖后腿。以Windows为例,用Win32 API打开CDC设备时,可以设置读写缓冲大小:
DCB dcb; COMMTIMEOUTS timeouts; // 设置读写缓冲 SetupComm(hCom, 65536, 65536); // 64KB读写缓冲 // 设置超时 timeouts.ReadIntervalTimeout = 1; timeouts.ReadTotalTimeoutMultiplier = 0; timeouts.ReadTotalTimeoutConstant = 10; timeouts.WriteTotalTimeoutMultiplier = 0; timeouts.WriteTotalTimeoutConstant = 100; SetCommTimeouts(hCom, &timeouts);ReadIntervalTimeout设为1ms,表示如果两个字节之间间隔超过1ms就返回。这个值对大数据量连续读取很关键,设得太大会导致读取延迟,设得太小会导致频繁返回小包。
在Linux下,可以用select或epoll做异步读取,配合VMIN和VTIME设置:
struct termios tty; tcgetattr(fd, &tty); tty.c_cc[VMIN] = 0; // 非阻塞 tty.c_cc[VTIME] = 1; // 100ms超时主机端的调优没有万能参数,要根据你的具体数据速率和延迟要求来调。我的建议是先用默认参数跑通,然后逐步调整,每次只改一个参数,观察效果。
6. 关于STM32F4 USB CDC的几个常见误解
在社区里经常看到一些关于STM32F4 USB CDC的说法,有些是误导性的。我挑几个典型的澄清一下。
6.1 "STM32F4的USB CDC跑不到1MB/s是因为芯片太弱"
这个说法不准确。STM32F4的USB OTG FS外设本身性能是够的,跑不到1MB/s通常是软件架构的问题,不是硬件瓶颈。我前面提到的发送状态机优化、FIFO分配、中断优先级调整,都是软件层面的。把这些做好,跑到800KB/s以上是完全可行的。
真正的硬件瓶颈在于全速USB的12Mbps上限,以及OTG FS外设的FIFO大小。但这些是物理限制,不是"芯片弱"。
6.2 "必须用DMA才能跑大数据量"
不一定。USB OTG FS外设内部有自己的DMA来处理端点数据传输,你在应用层用不用DMA1/DMA2是另一回事。对于发送路径,用环形缓冲+状态机的方式,不用额外的DMA也能跑到很高的吞吐。
DMA真正有用的场景是在数据采集端,比如ADC连续采样。这时候用DMA双缓冲可以解放CPU,让CPU专注于数据处理和USB发送。
6.3 "NAK越少越好"
前面讲过,NAK在批量传输里是正常的流控机制。完全没有NAK反而可能意味着你的发送速度不够快,没有充分利用USB带宽。适量的NAK说明流控在工作,系统是健康的。
当然,如果NAK比例过高(超过50%),那确实说明有问题,通常是应用层处理太慢或者缓冲太小。
6.4 "CDC的波特率设置会影响传输速度"
这是一个非常常见的误解。USB CDC的波特率设置(比如115200、921600)实际上只是一个"虚拟"参数,它不会真正限制USB的传输速度。USB CDC的数据传输速率取决于USB总线的调度和设备端的处理能力,跟波特率设置无关。
你在设备端设置波特率为115200,不代表传输速度就是115200bps。实际上,USB CDC可以跑得比这个快得多。波特率参数主要是为了兼容那些依赖波特率设置的串口应用程序。
7. 一个完整的发送路径参考实现
讲了这么多原理和技巧,最后给一个完整的、经过实战验证的发送路径实现。你可以直接拿去用,也可以根据自己的需求调整。
7.1 数据结构定义
#define USB_TX_BUF_SIZE 16384 // 16KB发送缓冲 #define USB_TX_PACKET 64 // 全速USB批量端点包长 typedef struct { uint8_t buf[USB_TX_BUF_SIZE]; volatile uint32_t head; volatile uint32_t tail; volatile uint32_t overflow_cnt; } usb_tx_ring_t; static usb_tx_ring_t tx_ring; static volatile uint8_t tx_busy = 0; static uint8_t tx_temp[USB_TX_PACKET];7.2 核心函数实现
// 应用层调用:写入待发送数据 uint32_t usb_send(const uint8_t *data, uint32_t len) { uint32_t available = USB_TX_BUF_SIZE - (tx_ring.head - tx_ring.tail); uint32_t to_write = (len > available) ? available : len; for (uint32_t i = 0; i < to_write; i++) { tx_ring.buf[(tx_ring.head + i) & (USB_TX_BUF_SIZE - 1)] = data[i]; } tx_ring.head += to_write; if (to_write < len) { tx_ring.overflow_cnt++; } // 触发一次发送尝试 usb_tx_poll(); return to_write; } // 发送状态机:在定时器中断和发送完成回调里调用 void usb_tx_poll(void) { if (tx_busy) return; uint32_t used = tx_ring.head - tx_ring.tail; if (used == 0) return; uint32_t to_send = (used > USB_TX_PACKET) ? USB_TX_PACKET : used; // 从环形缓冲拷贝到临时缓冲(不移动tail) for (uint32_t i = 0; i < to_send; i++) { tx_temp[i] = tx_ring.buf[(tx_ring.tail + i) & (USB_TX_BUF_SIZE - 1)]; } if (CDC_Transmit_FS(tx_temp, to_send) == USBD_OK) { tx_ring.tail += to_send; // 成功后才移动tail tx_busy = 1; } // 如果BUSY,等下次poll重试 } // 发送完成回调(在usbd_cdc_if.c里) static int8_t CDC_TransmitCplt_FS(uint8_t *Buf, uint32_t *Len, uint8_t epnum) { tx_busy = 0; usb_tx_poll(); // 立刻尝试发下一包 return USBD_OK; }7.3 定时器配置
// TIM6配置为100us周期中断 void TIM6_Init(void) { RCC->APB1ENR |= RCC_APB1ENR_TIM6EN; TIM6->PSC = 84 - 1; // 84MHz / 84 = 1MHz TIM6->ARR = 100 - 1; // 1MHz / 100 = 10kHz (100us) TIM6->DIER |= TIM_DIER_UIE; TIM6->CR1 |= TIM_CR1_CEN; NVIC_SetPriority(TIM6_DAC_IRQn, 3); NVIC_EnableIRQ(TIM6_DAC_IRQn); } void TIM6_DAC_IRQHandler(void) { if (TIM6->SR & TIM_SR_UIF) { TIM6->SR &= ~TIM_SR_UIF; usb_tx_poll(); } }这套实现我在几个项目里都用过,配合前面讲的主机端调优,稳定跑到700KB/s以上没有问题。如果你的数据量更大,可以适当加大环形缓冲,但要注意STM32F4的RAM限制。
7.4 调试用的统计信息
最后加一个调试用的统计结构,方便你在开发阶段观察传输状态:
typedef struct { uint32_t total_sent; uint32_t total_packets; uint32_t busy_retries; uint32_t overflow_cnt; } usb_tx_stats_t; static usb_tx_stats_t tx_stats;在usb_tx_poll里更新这些计数器,然后通过一个独立的调试命令或者串口打印出来。这些数据能帮你快速判断瓶颈在哪里:如果busy_retries很高,说明端点FIFO经常满,可能需要加大FIFO或降低发送频率;如果overflow_cnt在涨,说明应用层写入太快,需要加大缓冲或做流控。
我在实际调试的时候,就是靠这些计数器发现了一个隐藏的bug:busy_retries在某个特定数据模式下会突然飙升,最后定位到是主机端读取线程被其他任务阻塞了。如果没有这些统计数据,这个问题很难发现。
这套东西搭起来之后,USB CDC的大数据量传输基本就稳了。当然,具体项目里还会遇到各种奇怪的问题,但只要你理解了底层的机制,排查起来就有方向。我踩过的这些坑,希望你能少踩几个。