1. 项目概述:从串口到USB的认知跃迁
上次我们聊了CH552开发环境的搭建和点灯,算是和这位“老朋友”打了个招呼。这次,咱们得动点真格的了,主题是USB。对于很多从51、STM32转过来的朋友,一提到USB,心里可能就有点发怵:协议栈复杂、描述符眼花缭乱、调试还得靠抓包工具。但我想说,用CH552玩USB,尤其是实现最常用的USB转串口(CDC)功能,其友好程度可能会颠覆你的认知。它不像在STM32上,你需要小心翼翼地配置USB外设的每一个寄存器,或者集成一个庞大的中间件库。沁恒微电子直接把USB的底层物理层(PHY)和协议引擎(SIE)都集成在了芯片内部,并提供了相当完善的库函数和例程,我们的工作更像是“填空”和“连线”,把业务逻辑和USB的框架对接起来。
简单来说,这次“学习之旅”的目标,就是让CH552模拟成一个电脑能即插即用的USB串口设备。你可能会问,市面上CH340、CP2102、FT232这些USB转串口芯片不是一抓一大把吗?为什么还要用MCU自己实现?首先,这是学习USB通信最直观、最实用的切入点,理解了CDC,就掌握了USB设备开发的一大半核心概念。其次,自己实现意味着完全的控制权,你可以在串口数据收发的基础上,轻松地添加自定义的命令、协议,或者混合其他USB设备类(如HID键盘鼠标),实现一个复合设备,这是专用芯片做不到的。最后,对于产品原型开发或小批量生产,一颗CH552既能当主控又能当USB桥接,可以简化设计、降低成本。
所以,无论你是想彻底搞懂USB通信的流程,还是希望在你的下一个项目中灵活地使用USB接口,跟着我一步步把CH552配置成一个稳定的USB CDC设备,都会是一次收获满满的实践。
2. 核心思路与框架解析:庖丁解牛看USB CDC
在动手写代码之前,我们必须先理清思路。USB通信不是简单的两根线(TX/RX)对接,它是一套完整的、基于主从架构和严格时序的协议体系。我们的CH552将作为“设备”(Device),电脑(或主机,Host)作为控制方。整个通信过程,可以粗略地理解为一次“面试”和后续的“工作”。
2.1 USB枚举:设备的“面试”过程
当你把CH552插入电脑USB口的瞬间,一场精密的对话就开始了,这个过程叫“枚举”。电脑会问一系列标准问题,我们的CH552必须按照USB协议的规定格式进行回答。这些问题和答案,就是通过“描述符”来传递的。这是整个USB开发中最核心、也最容易出错的部分。
- 设备描述符:这是设备的“身份证”。它告诉电脑:“我是一个USB设备,我的厂商ID(VID)是0x4348(沁恒的PID),产品ID(PID)是0x5523(CH552的默认PID),我属于CDC类设备。” 电脑根据VID/PID来寻找和加载对应的驱动程序。
- 配置描述符:一个设备可以有多种工作模式(配置),比如高功耗高性能模式,或低功耗模式。我们通常只用一种配置。这个描述符定义了设备的供电方式(总线供电/自供电),最大功耗等。
- 接口描述符:这是“技能描述”。一个配置下可以有多个接口。对于CDC设备,通常至少有两个接口:一个“通信接口”用于管理(设置波特率、流控等),一个“数据接口”用于实际收发串口数据。这就像是公司里的“管理部”和“业务部”。
- 端点描述符:这是“沟通渠道”。USB通信是基于端点的,每个端点有独立的地址和方向。CDC设备通常需要:
- 控制端点0 (EP0):双向。这是所有USB设备都必须有的,专门用于传输枚举命令、设备请求等控制信息。它由硬件和底层库自动管理,我们通常不需要直接操作。
- 中断输入端点 (EP1_IN):用于通信接口,以中断传输方式向主机报告串口线路状态(如DCD、DSR)或通知主机有数据到来。这是可选的,但标准CDC驱动会期待它。
- 批量传输端点 (EP2_OUT, EP3_IN):用于数据接口。这是主力通道,我们通过EP2_OUT接收来自电脑的数据(相当于串口RX),通过EP3_IN发送数据给电脑(相当于串口TX)。批量传输保证数据可靠,但不保证实时性,非常适合串口数据。
2.2 数据流与库函数分工
理解了框架,再看沁恒提供的库,就清晰多了。CH55xUSB.C和CH55xUSB.H这两个文件,封装了USB底层中断、端点缓冲区操作、标准设备请求响应等所有脏活累活。我们需要关心的,主要是三件事:
- 描述符表:在
usb_desc.c中,我们需要定义一个完整的描述符数组,按顺序包含上述所有描述符。这是我们的“剧本”。 - 端点数据回调函数:在
usb_desc.c或主程序中,我们需要实现几个关键的回调函数。例如,当电脑通过EP2_OUT发来数据时,库函数会在中断里把数据存好,然后调用我们预先注册好的EP2_OUT_Callback()函数,我们在这个函数里把数据从USB缓冲区读出来,扔给我们的串口发送函数(或者反之,把要发送的数据填入EP3_IN的缓冲区)。 - 业务逻辑:在主循环中,我们像平常一样编程。想发送数据时,就调用
USB_SendData(EP3_IN, buffer, length);接收数据则在回调函数中自动处理。USB通信变成了两个简单的API调用。
这种架构极大地降低了开发难度。我们不必纠结于USB协议每个位的含义,而是聚焦于“设备长什么样”(描述符)和“数据来了怎么处理”(回调函数)。
3. 关键步骤与代码实现:手把手构建CDC设备
理论说得再多,不如一行代码。我们基于沁恒官方提供的CDC例程进行修改和剖析。假设你的工程目录已经搭建好,包含必要的库文件和启动文件。
3.1 描述符配置:定义设备的“人格”
这是最需要耐心的一步。我们打开或创建usb_desc.c文件。
// 首先,定义设备的各种描述符 const uint8_t MyDevDescriptor[] = { // 设备描述符 0x12, // bLength: 描述符长度 (18字节) USB_DESCR_TYP_DEVICE, // bDescriptorType: 设备描述符类型 (0x01) 0x20, 0x01, // bcdUSB: USB协议版本 (USB 2.0) 0x02, // bDeviceClass: 设备类 (CDC类,0x02表示通信设备) 0x00, // bDeviceSubClass: 设备子类 0x00, // bDeviceProtocol: 设备协议 0x40, // bMaxPacketSize0: 端点0最大包长度 (64字节) 0x48, 0x43, // idVendor: 厂商ID (沁恒: 0x4348) 0x23, 0x55, // idProduct: 产品ID (示例: 0x5523) 0x00, 0x01, // bcdDevice: 设备版本号 (1.00) 0x01, // iManufacturer: 厂商字符串索引 0x02, // iProduct: 产品字符串索引 0x00, // iSerialNumber: 序列号字符串索引 (0表示无) 0x01 // bNumConfigurations: 配置数量 }; // 接着是配置描述符、接口描述符、端点描述符等,它们通常被连接成一个大的“配置描述符集合” const uint8_t MyCfgDescriptor[] = { // 配置描述符 (9字节) 0x09, // bLength USB_DESCR_TYP_CONFIG, // bDescriptorType: 配置描述符 (0x02) 0x43, 0x00, // wTotalLength: 整个配置描述符集合的总长度 (67字节) 0x02, // bNumInterfaces: 接口数量 (2个) 0x01, // bConfigurationValue: 配置值 (1) 0x00, // iConfiguration: 配置字符串索引 (0表示无) 0x80, // bmAttributes: 属性 (总线供电) 0x32, // MaxPower: 最大功耗 (100mA) // 接口0描述符 (通信接口) 0x09, // bLength USB_DESCR_TYP_INTERFACE, // bDescriptorType: 接口描述符 (0x04) 0x00, // bInterfaceNumber: 接口编号 (0) 0x00, // bAlternateSetting: 备用设置 (0) 0x01, // bNumEndpoints: 端点数量 (1个,即EP1_IN) 0x02, // bInterfaceClass: 接口类 (CDC类,0x02) 0x02, // bInterfaceSubClass: 接口子类 (ACM抽象控制模型) 0x01, // bInterfaceProtocol: 接口协议 (AT命令V.250协议) 0x00, // iInterface: 接口字符串索引 // 功能描述符 (CDC头功能描述符) 0x05, // bLength 0x24, // bDescriptorType: CS_INTERFACE (0x24) 0x00, // bDescriptorSubtype: 头功能描述符 (0x00) 0x20, 0x01, // bcdCDC: CDC版本 (1.20) // 功能描述符 (呼叫管理描述符) 0x05, // bLength 0x24, // bDescriptorType 0x01, // bDescriptorSubtype: 呼叫管理描述符 (0x01) 0x00, // bmCapabilities: 能力 (设备自己处理呼叫管理) 0x01, // bDataInterface: 关联的数据接口编号 (1) // 功能描述符 (ACM功能描述符) 0x04, // bLength 0x24, // bDescriptorType 0x02, // bDescriptorSubtype: ACM功能描述符 (0x02) 0x02, // bmCapabilities: 能力 (支持线路编码和串口状态) // 功能描述符 (联合功能描述符) 0x05, // bLength 0x24, // bDescriptorType 0x06, // bDescriptorSubtype: 联合功能描述符 (0x06) 0x00, // bMasterInterface: 主接口 (0) 0x01, // bSlaveInterface0: 从接口0 (1) // 端点1描述符 (中断输入端点) 0x07, // bLength USB_DESCR_TYP_ENDPOINT, // bDescriptorType: 端点描述符 (0x05) 0x81, // bEndpointAddress: 端点地址 (EP1_IN, 0x80 | 0x01) 0x03, // bmAttributes: 传输类型 (中断传输) 0x08, 0x00, // wMaxPacketSize: 最大包大小 (8字节) 0x0A, // bInterval: 轮询间隔 (10ms) // 接口1描述符 (数据接口) 0x09, // bLength USB_DESCR_TYP_INTERFACE, 0x01, // bInterfaceNumber: 接口编号 (1) 0x00, // bAlternateSetting 0x02, // bNumEndpoints: 端点数量 (2个,EP2_OUT和EP3_IN) 0x0A, // bInterfaceClass: 接口类 (CDC数据类,0x0A) 0x00, // bInterfaceSubClass 0x00, // bInterfaceProtocol 0x00, // iInterface // 端点2描述符 (批量输出端点) 0x07, // bLength USB_DESCR_TYP_ENDPOINT, 0x02, // bEndpointAddress: EP2_OUT (方向OUT,编号2) 0x02, // bmAttributes: 批量传输 0x40, 0x00, // wMaxPacketSize: 64字节 0x00, // bInterval: 忽略 // 端点3描述符 (批量输入端点) 0x07, // bLength USB_DESCR_TYP_ENDPOINT, 0x83, // bEndpointAddress: EP3_IN (方向IN,编号3) 0x02, // bmAttributes: 批量传输 0x40, 0x00, // wMaxPacketSize: 64字节 0x00 // bInterval: 忽略 };注意:描述符的每个字节都有严格定义,修改时务必参考《USB CDC规范》和沁恒的数据手册。上面数组中的长度值(如
0x43, 0x00)需要根据你实际描述符的总字节数计算。一个字节顺序错误或长度不对,都可能导致枚举失败。
3.2 回调函数实现:数据交换的枢纽
描述符定义了“我是谁”,回调函数则定义了“我怎么做”。我们在usb_desc.c中实现它们。
// 当主机通过控制端点0发送设置命令(如设置波特率)时,会调用此函数 // 我们主要处理CDC类特定请求,例如设置线路编码(波特率、停止位等) void USB_DeviceCfg( uint8_t cfg ) { switch (cfg) { case 0: // 取消配置 // 可以在这里禁用非0端点 break; case 1: // 配置生效 // 配置端点:使能EP1_IN, EP2_OUT, EP3_IN,并设置它们的回调函数 USB_DeviceEndPointCfg( EP1_IN, EP_TX_EN | EP_TYPE_INTERRUPT ); USB_DeviceEndPointCfg( EP2_OUT, EP_RX_EN | EP_TYPE_BULK ); USB_DeviceEndPointCfg( EP3_IN, EP_TX_EN | EP_TYPE_BULK ); // 预装载EP2_OUT缓冲区,准备接收主机数据 UEP2_T_LEN = 0; UEP2_CTRL = UEP_R_RES_ACK | UEP_T_RES_NAK; break; default: break; } } // 当主机通过EP2_OUT发送数据到来时,硬件触发中断并最终调用此函数 void EP2_OUT_Callback( void ) { uint8_t len; len = USB_RX_LEN( EP2_OUT ); // 获取接收到的数据长度 if(len > 0) { // 1. 从USB端点缓冲区读取数据 // USB_ReadEpBuffer(EP2_OUT, RxBuffer, len); // 方式一:手动读取 // 2. 这里就是你的“串口RX数据”! // 你可以将RxBuffer中的数据通过真正的UART发送出去(如果CH552同时连接了另一个设备) // 或者,更常见的是,我们只是模拟一个串口,所以直接回环测试: // 将接收到的数据,原样通过EP3_IN发送回电脑 USB_SendData( EP3_IN, (uint8_t *)USB_EP2_Buffer, len ); } // 3. 重新使能EP2_OUT接收,准备下一次数据传输 UEP2_CTRL = UEP_R_RES_ACK | UEP_T_RES_NAK; } // 当通过EP3_IN发送数据完成时,会调用此函数 void EP3_IN_Callback( void ) { // 一次批量传输完成,可以准备下一次发送了 // 例如,可以清除发送完成标志,或触发下一次发送 UEP3_CTRL = UEP_R_RES_NAK | UEP_T_RES_NAK; }3.3 主程序整合:让一切运转起来
最后,我们在主函数中初始化USB,并进入主循环。
#include "CH552.H" #include "Debug.H" #include "CH55xUSB.H" void main() { // 1. 系统时钟初始化 CfgFsys(); // 2. USB功能初始化 USB_DeviceCfg(); // 初始化USB设备模式 USB_DeviceIntCfg(); // 使能USB中断 UEP0_T_LEN = 0; UEP1_T_LEN = 0; UEP2_T_LEN = 0; UEP3_T_LEN = 0; // 3. 设置描述符表地址(告诉USB库我们的“剧本”在哪) pDeviceDescr = (uint8_t *)MyDevDescriptor; pConfigDescr = (uint8_t *)MyCfgDescriptor; // 4. 使能USB端口和全局中断 USB_PortCfg(); EA = 1; // 开启总中断 // 5. 主循环 while(1) { // 这里可以放置你的其他应用代码 // 例如,如果你想主动通过“串口”发送数据,可以调用: // uint8_t dataToSend[] = "Hello from CH552!\r\n"; // USB_SendData(EP3_IN, dataToSend, sizeof(dataToSend)-1); // 注意:USB_SendData是非阻塞的,实际发送在中断中完成。 // 你需要管理好发送缓冲区,避免覆盖。 // 简单的延时或任务调度 DelayMs(10); } }编译、下载,将CH552通过USB线连接到电脑。如果一切顺利,几秒钟内,你会在设备管理器的“端口(COM和LPT)”下看到一个新的串行设备,例如“USB-SERIAL CH552 (COMx)”。恭喜,你的CH552已经成功“伪装”成了一个USB转串口芯片!
4. 深度调试与问题排查:从“能用”到“稳定”
看到COM口出现只是第一步。在实际使用中,你可能会遇到各种问题:枚举失败、无法打开串口、数据传输丢包、电脑蓝屏(极少数驱动冲突情况)等。下面是我在多次项目中总结的排查清单和技巧。
4.1 枚举失败(设备管理器出现未知设备或感叹号)
这是最常见的问题,根本原因在于主机无法正确识别我们的描述符。
- 检查VID/PID:确保设备描述符中的VID/PID与电脑中已有的驱动程序不冲突。如果你没有自定义的INF驱动文件,使用沁恒的默认ID(0x4348, 0x55XX)通常没问题,系统会将其识别为“USB-SERIAL CH552”并使用内置的CDC驱动。如果你想用自己的ID,可能需要提供INF文件。
- 逐字节核对描述符:这是最痛苦的,但也是最有效的。使用USB协议分析工具(如
USBlyzer,Wireshark+USBPcap)抓取枚举过程的数据包。对比你的描述符和工具抓到的“Device Descriptor”、“Configuration Descriptor”,看哪里对不上。重点检查:- 描述符总长度
wTotalLength是否正确。 - 端点地址
bEndpointAddress的方向位(最高位,1为IN,0为OUT)和端点号是否正确。 - 端点最大包长度
wMaxPacketSize是否合理(控制端点一般为8, 16, 32, 64;批量端点常用64)。 - 字符串描述符(如果有)的索引和内容是否正确。
- 描述符总长度
- 简化测试:先注释掉所有接口和端点,只保留最基本的设备描述符和一个配置描述符(里面什么接口和端点都没有),看设备能否被识别为一个最简单的USB设备。然后逐步添加接口和端点描述符,每加一步测试一次,定位出错的位置。
- 电源与硬件:确保CH552的USB-DP(P3.0)和USB-DM(P3.1)引脚通过串联22Ω电阻(或根据数据手册推荐)连接到USB接口,且电源稳定。USB线材质量也可能影响枚举。
4.2 串口能打开但无法收发数据
枚举成功,但用串口助手打开COM口后,发送数据无反应,或接收不到数据。
- 检查端点配置与回调:确保在
USB_DeviceCfg函数中正确配置并使能了EP2_OUT和EP3_IN,并且EP2_OUT_Callback和EP3_IN_Callback函数正确定义并被USB库正确调用(通常需要在CH55xUSB.C中声明为外部函数,并在中断处理流程里调用)。 - 缓冲区管理:这是数据丢失的常见原因。在
EP2_OUT_Callback中,当你调用USB_SendData(EP3_IN, ...)进行回环测试时,如果主机发送数据过快,而USB_SendData是非阻塞的(它只是启动发送),可能上一次发送还没完成,新的回环数据又来了,导致数据被覆盖。一个简单的改进方法是设置一个“发送忙”标志。volatile uint8_t EP3_IN_Busy = 0; // 发送忙标志 void EP2_OUT_Callback(void) { uint8_t len = USB_RX_LEN(EP2_OUT); if(len > 0 && !EP3_IN_Busy) { EP3_IN_Busy = 1; // 将数据复制到自己的发送缓冲区,避免直接使用USB_EP2_Buffer(可能被新数据覆盖) memcpy(TxBuffer, USB_EP2_Buffer, len); USB_SendData(EP3_IN, TxBuffer, len); } UEP2_CTRL = UEP_R_RES_ACK | UEP_T_RES_NAK; } void EP3_IN_Callback(void) { // 发送完成,清除忙标志 EP3_IN_Busy = 0; UEP3_CTRL = UEP_R_RES_NAK | UEP_T_RES_NAK; } - 串口助手设置:确保串口助手的波特率、数据位、停止位、校验位设置与你在代码中通过CDC“线路编码”请求设置的保持一致(如果你处理了该请求)。对于简单的回环测试,这些设置通常不影响二进制数据的收发,但会影响文本显示。
- 驱动问题:在某些Windows系统上,系统自带的
usbser.sys(CDC驱动)可能行为异常。可以尝试手动更新驱动,指定为“标准串行设备”或“USB串行设备”的驱动。在Linux或macOS下,CDC设备通常由内核直接支持,问题较少。
4.3 性能优化与稳定性提升
当基本功能实现后,我们可以考虑让它更可靠、更高效。
- 双缓冲与乒乓操作:对于高速数据传输,可以使用端点双缓冲机制。CH552的某些端点支持硬件双缓冲。原理是:当主机正在读取缓冲区A的数据时,MCU可以同时向缓冲区B填充下一包数据,实现无缝连续传输。这需要仔细配置端点的
UEPn_CTRL寄存器。在官方库中,可能已经有所体现,需要查阅具体型号的数据手册。 - 流控(XON/XOFF或RTS/CTS):标准的CDC ACM支持通过中断端点(EP1_IN)上报和接收串口流控信号。你可以在描述符中声明支持这些能力,并在代码中响应相应的CDC类请求(
SET_CONTROL_LINE_STATE,SET_LINE_CODING),从而在串口助手中启用RTS/CTS硬件流控,防止缓冲区溢出。 - 功耗管理:在USB挂起状态(Suspend)下,CH552可以进入低功耗模式。你需要实现相应的中断处理,在总线恢复时唤醒。这对于电池供电设备很重要。
- 复合设备:如果你想在CDC的基础上,再增加一个HID设备(比如用按键模拟键盘),就需要构建一个“复合设备描述符”。这需要定义多个接口,并确保
bDeviceClass设置为0xEF(Miscellaneous),bDeviceSubClass为0x02(Common Class),bDeviceProtocol为0x01(Interface Association Descriptor)。同时,你需要为每个接口实现独立的端点和回调函数。这是进阶内容,但对打造多功能USB工具非常有用。
5. 超越CDC:CH552 USB能力的更多可能
成功实现CDC,就像拿到了USB世界的入门钥匙。基于CH552,你还可以探索更多有趣的USB设备类:
- HID(人机接口设备):这是实现USB键盘、鼠标、游戏手柄、自定义控制面板的最简单方式。HID设备不需要安装额外驱动(系统自带),描述符相对固定,数据传输通过中断端点。你可以用CH552做一个宏键盘、一个传感器数据报告器(通过自定义HID报告描述符)。
- MSC(大容量存储设备):让CH552模拟成一个U盘。这需要你实现FAT文件系统(可以使用开源库如FatFs),并响应主机的SCSI命令集。你可以用它来记录数据日志,或者制作一个需要特殊软件才能读取内容的加密U盘。
- 自定义Vendor类:如果你有特殊的数据传输需求,不想受限于标准设备类,可以定义自己的Vendor类。你需要为设备编写专用的Windows驱动(.inf, .sys)或提供libusb兼容的驱动。这给了你最大的灵活性,但开发门槛也最高。
无论选择哪条路,其核心框架都与CDC开发类似:定义描述符、配置端点、实现回调。CH552丰富的USB资源(多个端点、足够缓冲区)和沁恒提供的底层库,为这些探索提供了坚实的基础。
这次旅程,我们从对USB的模糊认知,到亲手打造出一个被系统认可的串口设备,并深入了调试和优化的细节。过程中遇到的每一个错误和解决过程,都是比单纯阅读文档更宝贵的经验。USB开发不再神秘,它变成了一套可以按部就班配置的流程。希望这份详细的梳理,能帮你绕过我当年踩过的那些坑,更顺畅地开启你的CH552 USB应用开发。