[i=s] 本帖最后由 kai迪皮 于 2025-6-24 18:00 编辑 [/i]<br />
<br />
1 背景
为啥突然想用 USB CDC 做日志输出? 很多开发者在做 MCU 项目时,往往会用 UART 串口来打印调试信息。可是一旦项目复杂了,UART 线缆多、资源占用大、带宽还有限,就会开始琢磨:“能不能利用 USB 的特性,让它既传输数据又打印日志,一根线搞定?” 正好,APM32F402 这颗芯片具备 USB OTG 功能,我们可以让设备模拟成一个“复合设备(Composite Device)”,同时跑 WinUSB(高速数据传输)和 CDC(虚拟串口)这两个接口。这样只要插上一条 USB 数据线,就能既供应电源、又上传下载数据、还输出调试日志,堪称“一线三用”。
举个具体例子:
-
比如一款 Data Logger(数据记录器)要定期或实时采集某种传感器数据,比如压力、流量、震动幅度等;
-
现场维护人员可以用笔记本通过 USB 与设备建立 WinUSB 通道,一次性下载大量的历史数据;
-
另一方面,调试工程师能在终端软件里打开 CDC 虚拟串口,看到 MCU 的实时运行状态或告警信息,刷刷往外吐;
-
整个过程只需要一根 USB 线,简化了线缆管理,也不耽误高带宽的数据传输。

2 为什么选择 USB CDC?它比 UART 好在哪儿?
说到日志输出,传统串口 UART 一般满足日常需求,不过也有不可忽视的缺点:
- 带宽不足:常见波特率 115200bps,一旦数据量大了,容易拖慢速度;若是使用高波特率又容易受干扰,对线材的性能又要求。
- 需要额外转接:如果开发板只提供 TTL 接口,还得准备 USB 转 TTL 线;
- 接线繁琐:调试线、电源线、串口线挤在一起,非常容易打结。
而 USB CDC(虚拟串口)能直接呈现给操作系统一个 COM 口,速率理论上可比常规 UART 更高,加上只要一根 USB 数据线就能兼顾供电、调试和传输。尤其是在需要同时跑其他 USB 功能(如 WinUSB、U 盘模拟、HID 等)时,CDC 可以与它们共存,大大提升灵活度。想象一下,我们在 PC 端通过 USB 与设备高速交换数据的同时,也能在一个虚拟 COM 终端看到调试日志,何乐而不为?
3 什么是 USB 复合设备?
“USB 复合设备”指的是在同一个物理 USB 接口上,开启多个逻辑接口(Interface)。典型组合像下面这样:
- CDC 接口(虚拟串口)——输出调试日志;
- WinUSB 接口(自定义协议)——跟上位机程序进行高速数据交互;
- 其他 HID、MSC(U 盘)、Audio 之类也可以加进去,只要资源够用。
对于 APM32F402 而言,Geehy 官方提供了一个名为 OTGD_Composite_CDC_WINUSB 的例程。它已经帮我们写好了复合设备的描述符和端点分配,我们只需要在对应的工程里稍微改动或者增加一点代码,就能把 printf 输出定向到 CDC 虚拟串口上。不影响 WinUSB 通道的数据传输,也不会阻塞其他 USB 功能,完美实现“一条 USB 线,双通道齐头并进”。

4 搞定 printf 重定向
4.1 USB CDC 初始化
先确认当前工程里已经包含了 USB 库的相关文件,例如 usb_core、usb_init、usb_cdc 等驱动。如果是基于官方 OTGD_Composite_CDC_WINUSB 工程,那么在 main()
或者您自定义的初始化函数里,需要调用 USB_DeviceInit()
,让系统启动时就注册并枚举 CDC 和 WinUSB 接口。
4.2 找到“重定向”大本营
在 MDK 或者其他编译器环境下,通常实现了针对 printf 的底层函数,比如 fputc(int ch, FILE *f)
或者 _write()
。咱们只要在这些函数中,把原本“写到 UART 的”那部分改成“调用 USB CDC 的发送函数”。考虑到如果逐字发送,会频繁调用发送函数,会占用资源,我们可以在这里用一个静态缓冲区,凑够一定长度再将数据一次性打包发给 USB。
4.3 核心代码示例
下面给出一个简单的 fputc()
示例,带有缓冲区机制,供大家参考。请注意,此处示例函数名、宏定义等可能需要和您具体的工程环境保持一致。
#define CDC_TX_BUF_SIZE (128)
/*!
* Redirect C Library function printf to serial port.
* After Redirection, you can use printf function.
*
* @param ch: The characters that need to be send.
*
* @param *f: pointer to a FILE that can recording all information
* needed to control a stream
*
* @retval The characters that need to be send.
*
* @note
*/
int fputc(int ch, FILE* f)
{
/* send a byte of data to the serial port */
// USART_TxData(DEBUG_USART, (uint8_t)ch);
// /* wait for the data to be send */
// while (USART_ReadStatusFlag(DEBUG_USART, USART_FLAG_TXBE) == RESET);
/* static 关键字确保在函数内部维持状态 */
static uint8_t s_cdcTxBuf[CDC_TX_BUF_SIZE];
static uint16_t s_cdcTxCount = 0;
/* 如果在输出时需要将 '\n' 转化为 Windows 习惯的 "\r\n",可按需处理 */
if (ch == '\n')
{
/* 注意下一步操作前先检查避免缓冲区越界 */
if (s_cdcTxCount >= CDC_TX_BUF_SIZE)
{
USBD_FS_CDC_ItfSend(s_cdcTxBuf, s_cdcTxCount);
s_cdcTxCount = 0;
}
}
/* 将当前字符装入缓冲区 */
s_cdcTxBuf[s_cdcTxCount++] = (uint8_t)ch;
/* 如果缓冲区已满,或者本次输出是换行字符,就立刻发送 */
if (s_cdcTxCount >= CDC_TX_BUF_SIZE || ch == '\n')
{
USBD_FS_CDC_ItfSend(s_cdcTxBuf, s_cdcTxCount);
s_cdcTxCount = 0;
}
return (ch);
}
相关说明
- s_cdcTxBuf[ ]:用来累积数据,避免频繁调用发送函数;
- s_cdcTxCount:记录当前已经存进缓冲区的字节数;
- 若遇到
'\n'
或者缓冲区满了,我们就调用 USBD_FS_CDC_ItfSend(...)
一次性输出;
4.4 编译 & 测试
写完重定向函数后,编译并下载到 APM32F402 板子上,插上 USB 线,看看电脑里面设备管理器是否出现了“虚拟 COM 端口”和一个 WinUSB 设备。如果都枚举成功,用任意串口调试软件打开该虚拟 COM 口,就能实时看见 printf 的日志输出了。
- 波特率填多少通常没太大影响,因为 USB CDC 其实是“假装”有一个波特率;
- 如果你要传输的数据量很大,就可以在 WinUSB 接口那边跑高速协议,而这边 CDC 不受影响,依旧可以当调试输出使用。

-
5 总结
通过在 Geehy 官方 OTGD_Composite_CDC_WINUSB 例程中做少量改动,我们就能成功把 APM32F402 的 printf 重定向到 USB CDC 虚拟串口上,实现以下优点:
- 只用一根 USB 数据线,开发、供电、调试都搞定;
- 节省硬件 UART 资源,留给需要硬件流控或者其他串口外设;
- 还能让 WinUSB 通道继续高效传输大数据,CDC 专心搞日志,两条通路互不打架。
如果你打算让这个 Data Logger 或其他设备在工业、医疗或消费领域使用,那么通过 USB CDC + WinUSB 的融合方式,不仅能减少线缆成本,还能统一管理数据和日志。至于更高阶玩法,比如 DMA、RTOS 下的多线程发送队列,就可以在此基础上继续扩展。
希望这篇文章能帮到还在为“哪里再插一条串口线”而头痛的朋友,也欢迎留言一起探讨更多进阶技巧。
借助Geehy 官方 OTGD_Composite_CDC_WINUSB 例程,实现PM32F402 的 printf 重定向到 USB CDC 虚拟串口。节省UART资源,传输更为高效