1. 项目缘起:为什么要在RTOS上搞虚拟串口?
最近在做一个基于STM32F103的工业数据采集终端,项目里用到了FreeRTOS,几个任务分别负责传感器读取、数据处理和无线通信。调试和日志输出是个大问题:传统的物理串口(UART)只有一个,已经被用于和4G模块通信了,而开发过程中,我需要实时查看各个任务的运行状态、变量值,甚至能动态下发一些测试指令。
这时候,USB虚拟串口(VCP, Virtual COM Port)就成了一个绝佳的选择。它能把STM32的USB接口直接模拟成一个COM口,在电脑上显示为一个普通的串行设备。这样一来,我相当于凭空多了一个高速、稳定的调试通道,而且不占用任何额外的硬件UART资源。更重要的是,在FreeRTOS环境下,如何让这个USB设备稳定、可靠地工作,并且与多个任务安全地交互数据,这里面有不少门道。网上基于标准库和FreeRTOS的完整、可用的例子并不多,大多只讲了USB库的移植,对RTOS下的应用层设计、数据流管理避而不谈。今天,我就把整个从零搭建的过程、关键原理和踩过的坑,系统地梳理一遍。
2. 核心组件选型与工程框架搭建
在开始敲代码之前,得先把“舞台”搭好。我们的核心是STM32标准外设库、官方USB设备库和FreeRTOS。这三者的整合,需要清晰的层次。
2.1 硬件与软件环境确认
我手头的核心板是STM32F103C8T6,它有USB Device功能。软件环境是Keil MDK。首先,需要准备以下软件包:
- STM32标准外设库:我们使用V3.5.0版本。它提供了对MCU所有外设的寄存器级封装。
- STM32 USB设备库:同样去ST官网下载,里面包含了USB核心驱动、标准请求处理和CDC(通信设备类,Virtual COM Port就属于此类)的示例代码。我们主要用到
USB-CDC这个范例。 - FreeRTOS:直接从官网下载源码,我们只需要
Source文件夹下的核心文件。
工程目录结构我这样组织:
Project/ ├── CMSIS/ # Cortex-M内核相关文件 ├── STM32F10x_StdPeriph_Driver/ # 标准外设库 ├── USB_Device/ # USB设备库 │ ├── Core/ # USB核心驱动 │ └── Class/CDC/ # CDC类驱动 ├── FreeRTOS/ # FreeRTOS源码 │ ├── include/ │ ├── portable/Keil/ARM_CM3/ # 针对Cortex-M3的端口 │ └── *.c 核心文件 ├── User/ │ ├── main.c │ ├── stm32f10x_it.c # 中断服务程序 │ ├── usb_conf.h # USB配置 │ ├── usb_desc.c/.h # USB描述符 │ ├── usb_prop.c/.h # USB属性请求 │ └── usb_pwr.c/.h # USB电源管理 └── (其他系统配置文件)这个结构的关键在于,将USB库和FreeRTOS视为两个平行的“中间件”,我们的应用代码在User目录下将它们桥接起来。
2.2 基础工程创建与FreeRTOS移植
首先,创建一个最基础的STM32工程,点亮一个LED,确保编译下载没问题。然后开始移植FreeRTOS:
- 将FreeRTOS源码文件添加到工程,并设置正确的头文件包含路径。
- 修改
startup_stm32f10x_md.s启动文件,将PendSV_Handler、Systick_Handler、SVC_Handler这三个中断服务程序的标签注释掉(因为FreeRTOS要接管它们)。 - 在
stm32f10x_it.c中,实现FreeRTOS需要的那三个中断服务函数,内容直接调用FreeRTOS的API即可,例如PendSV_Handler里调用xPortPendSVHandler。 - 配置
FreeRTOSConfig.h。这里有几个关键配置关乎USB:configUSE_PREEMPTION必须设为1(使用抢占式调度)。configUSE_IDLE_HOOK可以设为0,因为我们不在空闲任务里处理USB。configTICK_RATE_HZ设置系统时钟节拍,通常为1000(1ms)。这个频率要和你后面USB轮询的节奏协调。- 最重要的是:
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。USB中断使用的是USB_LP_CAN1_RX0_IRQn,我们必须确保USB中断的优先级高于这个宏定义的优先级。因为FreeRTOS的临界区保护会屏蔽所有优先级低于或等于这个宏的中断。如果USB中断被错误屏蔽,会导致数据传输失败。我通常将SysTick和PendSV设为最低优先级,将configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设为5,然后将USB中断优先级设为4(数值越小优先级越高)。
注意:FreeRTOS中断优先级配置是早期最容易出错的地方。STM32的中断优先级数值越小优先级越高,而FreeRTOS的
configMAX_SYSCALL_INTERRUPT_PRIORITY定义的是一个“阈值”,所有优先级数值高于这个阈值的中断不会被FreeRTOS的开关中断API影响。一定要理清这个逻辑,否则USB会在你不经意间“卡死”。
3. USB CDC设备库的深度移植与改造
这是整个项目的核心硬件抽象层。ST提供的USB库是一个轮询架构,它期望在main函数的while(1)里不断调用USB_Istr()函数来处理USB事件。但在FreeRTOS里,我们不能这么干。
3.1 理解USB库的工作机制
ST的USB设备库是一个状态机。它通过USB中断接收主机(电脑)的事件(如总线复位、数据包到达等),将事件标志存储在全局变量中。然后,需要主循环定期调用USB_Istr()函数来查询这些标志并执行相应的处理,比如调用CDC类里的APP_FOPS函数指针指向的数据发送/接收函数。
我们的改造目标很明确:将“主循环轮询”模式,改为“中断+任务”事件驱动模式。
- 中断服务程序(ISR):仅做最紧急的工作,如读取接收到的数据到缓冲区,设置一个事件标志或释放一个信号量。
- 专用任务:在一个或多个FreeRTOS任务中,等待上述事件标志,然后进行耗时的处理,如解析数据、准备发送数据、调用
USB_Istr()处理底层状态。
3.2 关键文件修改与数据缓冲区设计
首先修改usb_conf.h,确保USB中断使能,并且注释掉库中自带的_Istr()函数在main中的调用。
然后,设计应用层的数据缓冲区。USB CDC类通过“端点”(Endpoint)通信,通常端点1(OUT)用于电脑下发数据到设备,端点1(IN)用于设备上传数据到电脑。库函数CDC_Receive_DATA会在中断里将数据从USB硬件缓冲区复制到我们提供的一个用户缓冲区。但这个缓冲区很小(通常64字节),且下一次中断会覆盖它。
因此,我们必须实现一个环形缓冲区(FIFO)来接收数据。
// usb_app.h #define APP_RX_DATA_SIZE 2048 // 接收环形缓冲区大小 typedef struct { uint8_t buffer[APP_RX_DATA_SIZE]; volatile uint16_t write_idx; volatile uint16_t read_idx; SemaphoreHandle_t semaphore; // 用于任务同步的信号量 } usb_cdc_rx_fifo_t; extern usb_cdc_rx_fifo_t cdc_rx_fifo;在USB_LP_CAN1_RX0_IRQHandler中断中,当检测到是OUT端点(数据到达)中断时,调用CDC_Receive_DATA获取数据,然后立刻将数据写入我们自己的环形缓冲区cdc_rx_fifo,并更新写指针。这里有个关键点:写缓冲区这个操作必须非常快,因此我们只是简单复制数据,不做任何处理。复制完成后,我们释放一个二值信号量(xSemaphoreGiveFromISR),通知等待中的数据处理任务。
发送端类似,我们需要一个发送环形缓冲区。当应用层有数据要发送时,先写入发送FIFO。然后在一个专用的“USB发送任务”中,或在一个定时器里,检查发送FIFO是否有数据,以及USB IN端点是否空闲(通过CDC_Send_DATA的返回值判断)。如果条件满足,则从发送FIFO取出数据,调用CDC_Send_DATA启动一次USB传输。
3.3 创建USB服务任务与中断整合
我们创建一个优先级较高的任务vTaskUSBService来作为USB的“管家”。
void vTaskUSBService(void *pvParameters) { // 初始化环形缓冲区和信号量 cdc_rx_fifo.semaphore = xSemaphoreCreateBinary(); for(;;) { // 等待接收数据信号量,超时时间设为portMAX_DELAY表示永久等待 if(xSemaphoreTake(cdc_rx_fifo.semaphore, portMAX_DELAY) == pdTRUE) { // 信号量被释放,说明有数据到达 // 这里可以处理数据,或者只是触发一次USB事件处理 process_rx_data(); // 用户自定义的数据处理函数 } // 无论是否有新数据,都需要定期处理USB底层状态 USB_Istr(); // 处理USB事件状态机 vTaskDelay(pdMS_TO_TICKS(1)); // 让出CPU,1ms周期轮询USB状态是常见做法 } }同时,我们需要修改USB中断服务函数,在数据接收完成后给出信号量:
// 在 usb_prop.c 或自定义的中断处理文件中 void USB_LP_CAN1_RX0_IRQHandler(void) { // ... USB库自带的中断处理 ... if (status & ISTR_CTR & wInterrupt_Mask) { if ((wIstr & ISTR_EP_ID & ISTR_EP_MASK) == EP1_OUT_ID) { // 如果是端点1 OUT // 库函数会调用 CDC_Receive_DATA,我们在其回调函数里写FIFO EP1_OUT_Callback(); // 这个函数需要我们自己实现 } } // ... 其他处理 ... } // 在 EP1_OUT_Callback 中 void EP1_OUT_Callback(void) { uint16_t byte_received = USB_SIL_Read(EP1_OUT, cdc_rx_fifo.buffer + cdc_rx_fifo.write_idx); // 更新写指针,注意环形缓冲区的越界回绕处理 cdc_rx_fifo.write_idx = (cdc_rx_fifo.write_idx + byte_received) % APP_RX_DATA_SIZE; BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR(cdc_rx_fifo.semaphore, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); // 如果需要,进行任务切换 }通过这样的改造,USB的通信就完全融入了FreeRTOS的调度体系,数据接收变成了事件驱动,高效且不易丢失数据。
4. 应用层设计:多任务安全访问与流控制
有了稳定的底层USB数据通道,接下来要在应用层用好它。在FreeRTOS多任务环境下,最大的挑战是资源竞争和数据流管理。
4.1 设计统一的通信接口
为了避免每个任务都直接操作底层的USB发送函数和环形缓冲区,我设计了一个简单的“VCP服务模块”(vcp_service.c/h),对外提供线程安全的接口。
// vcp_service.h typedef enum { VCP_OK, VCP_BUSY, VCP_ERROR } vcp_status_t; vcp_status_t vcp_send_data(const uint8_t *data, uint16_t len, TickType_t xTicksToWait); uint16_t vcp_receive_data(uint8_t *buf, uint16_t buf_len, TickType_t xTicksToWait); void vcp_printf(const char *fmt, ...); // 一个类似printf的封装,方便打印日志在vcp_send_data内部,它会先尝试获取一个互斥锁(Mutex),确保同一时间只有一个任务在写入发送FIFO。获取锁后,将数据拷贝到发送环形缓冲区,然后释放锁。如果获取锁超时(xTicksToWait),则返回VCP_BUSY。这样,即使多个任务同时调用打印函数,数据也会在缓冲区里排队,而不会互相覆盖。
4.2 处理大数据量与流控制
当应用层需要发送大量数据(比如传输一个文件)时,发送FIFO可能会被快速填满。单纯的互斥锁只能保证写入不冲突,不能解决缓冲区满的问题。这里需要更精细的流控制。
我采用“生产者-消费者”模型,并结合FreeRTOS的队列(Queue)或计数信号量(Counting Semaphore)来实现。
- 发送端:
vcp_send_data函数内部,在获取互斥锁后,先检查发送FIFO的剩余空间是否足够容纳本次数据。如果不够,它可以释放锁,然后等待一个“空间可用”的信号量(由USB发送任务在从FIFO取出数据后释放),再重新尝试。 - USB发送任务:这个任务持续检查发送FIFO和USB端点状态。每当它从发送FIFO成功取出一包数据并通过USB发出后,就计算一下FIFO空出了多少空间,如果空间大于某个阈值,就释放一个“空间可用”信号量,唤醒可能正在等待的发送任务。
对于接收端,如果数据处理任务(消费者)处理速度跟不上数据到达(生产者)的速度,接收FIFO也会满。此时,在中断服务程序里,当发现FIFO已满时,可以暂时不释放信号量,或者丢弃新数据(根据应用需求决定),并设置一个错误标志。应用层任务在读取数据时,可以检查这个标志,做出相应处理(如向上层报告溢出)。
4.3 实现一个实用的调试日志系统
基于改造好的VCP,我们可以轻松构建一个跨任务的调试信息输出系统。我实现了一个log_task,它创建一个FreeRTOS队列(Queue),用于接收来自其他所有任务的日志消息。
typedef struct { uint32_t task_id; char msg[LOG_MSG_MAX_LEN]; } log_msg_t; QueueHandle_t log_queue; void log_printf(const char *fmt, ...) { log_msg_t msg; va_list args; va_start(args, fmt); vsnprintf(msg.msg, LOG_MSG_MAX_LEN, fmt, args); va_end(args); msg.task_id = (uint32_t)xTaskGetCurrentTaskHandle(); // 非阻塞方式发送,避免日志输出阻塞关键任务 xQueueSend(log_queue, &msg, 0); } void vTaskLogger(void *pvParameters) { log_msg_t rx_msg; for(;;) { if(xQueueReceive(log_queue, &rx_msg, portMAX_DELAY) == pdTRUE) { // 添加时间戳和任务ID前缀 uint32_t tick = xTaskGetTickCount(); vcp_printf("[%lu][T%lX] %s\r\n", tick, rx_msg.task_id, rx_msg.msg); } } }这样,任何任务都可以调用log_printf来输出格式化的日志,而实际的USB传输工作由独立的vTaskLogger完成,实现了日志输出与业务逻辑的解耦,非常清晰。
5. 稳定性调优与常见问题排查
将USB VCP跑起来只是第一步,让它长期稳定工作,尤其是在复杂的多任务和电磁环境下,需要进一步的调优。
5.1 电源管理与连接稳定性
USB通信对电源质量非常敏感。在PCB设计时,USB的DP(D+)、DM(D-)信号线要尽量短,并做好阻抗控制。STM32内部的USB收发器需要稳定的3.3V供电。如果发现设备频繁被电脑识别又断开,首先要检查硬件电源,可以在USB的VBUS到地之间加一个10uF的钽电容和一个0.1uF的陶瓷电容进行退耦。
软件上,在usb_pwr.c中,ST的库提供了USB_Cable_Config函数来控制USB上拉电阻(D+上的1.5k电阻)的连接与断开。在系统初始化时,不要立刻连接上拉电阻。可以等待系统时钟稳定、所有外设初始化完成后,再连接上拉电阻,让电脑枚举设备。在进入低功耗模式前,需要先断开USB连接(调用USB_Cable_Config(DISABLE))。
5.2 中断优先级与系统节拍冲突
这是最隐蔽的坑之一。如前所述,必须确保USB中断优先级高于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。此外,SysTick中断是FreeRTOS的心跳,它负责任务调度和延时。如果USB中断处理时间过长,或者优先级设置不当导致USB中断频繁抢占SysTick,可能会引起系统节拍丢失,导致vTaskDelay不准,甚至整个系统看起来“卡顿”。
我的经验是:
- USB中断优先级设为“次高”(比如4),仅低于紧急的硬件故障中断。
- 在USB中断服务函数中,只做数据搬运和信号量释放,绝不做复杂计算或调用可能阻塞的API。
- 如果发现系统响应变慢,可以尝试稍微降低系统节拍频率(比如从1000Hz降到500Hz),给USB中断处理留出更多时间窗口。
5.3 电脑端驱动与缓冲区设置
STM32的USB CDC设备在Windows 10及以上系统通常能自动安装系统自带的usbser.sys驱动。但在某些旧系统或特定主机上,可能需要手动安装ST提供的驱动。驱动安装成功后,在设备管理器的“端口(COM和LPT)”下会看到一个“USB串行设备(COMx)”。
打开串口助手(如Putty、SecureCRT)连接这个COM口时,波特率等参数设置是无效的,因为USB是包通信,波特率概念不存在。这些参数设置只是为了兼容老式串口软件API,不会影响实际通信速度。实际速度取决于USB全速(12Mbps)的带宽和MCU的处理能力。
一个关键设置:在串口助手中,将接收和发送缓冲区都尽量调大(比如256K或更大)。因为USB是突发高速传输,如果电脑端软件缓冲区太小,来不及处理就会丢包。同样,在MCU端的发送FIFO大小也要合理设置,我通常设置为2048字节,这是一个在内存占用和突发数据处理能力之间的平衡点。
5.4 枚举失败与描述符检查
如果电脑完全无法识别设备,或者识别成一个未知设备,99%的问题出在USB描述符上。USB描述符是一系列数据结构,告诉主机“我是什么设备”、“我有哪些功能”。ST的库提供了模板文件usb_desc.c。你需要根据你的具体MCU型号和需求仔细检查并修改:
DeviceDescriptor: 里面的VID(厂商ID)和PID(产品ID)。如果只是自己用,可以用ST的默认值。如果想避免和别的设备冲突,可以去USB-IF申请自己的VID,或者使用一个不常见的PID。ConfigurationDescriptor: 这里面包含了CDC类特定的描述符,如通信接口(Communication Interface)和数据接口(Data Interface)。务必确保端点地址、大小、类型(Bulk/Interrupt)配置正确。- 字符串描述符:包括厂商字符串、产品字符串等。确保它们是合法的Unicode字符串,并且索引号正确。
调试描述符问题,可以借助USB协议分析仪(如Beagle USB),或者使用电脑端的USBView(Windows SDK工具)来查看设备枚举的详细过程,能看到主机到底在哪一步读取描述符失败了。没有硬件分析仪的情况下,最笨但有效的方法就是:逐字节对照一个已知能工作的例程的描述符,检查是否有拼写错误、长度错误或索引错误。
6. 进阶应用:CDC命令解析与固件升级
一个稳定的VCP通道建立后,它的用途就远不止于打印日志了。我们可以在此基础上,构建更强大的功能。
6.1 实现基于ASCII码的简单命令解析器
很多嵌入式设备都需要通过串口接收控制命令。我们可以利用VCP接收的数据,实现一个命令行接口(CLI)。
// 在 process_rx_data() 函数中 void process_rx_data(void) { uint8_t ch; while(vcp_receive_data(&ch, 1, 0) == 1) { // 非阻塞读取一个字符 if(ch == '\r' || ch == '\n') { // 回车或换行表示命令结束 if(cmd_buffer_idx > 0) { cmd_buffer[cmd_buffer_idx] = '\0'; // 字符串终结符 parse_and_execute_cmd(cmd_buffer); // 解析执行命令 cmd_buffer_idx = 0; // 重置缓冲区 } } else if(cmd_buffer_idx < CMD_BUF_MAX_LEN - 1) { cmd_buffer[cmd_buffer_idx++] = ch; // 存储字符 } // 忽略缓冲区满的情况 } }在parse_and_execute_cmd中,可以解析像set led on、get adc 1这样的命令,并调用相应的函数执行,最后通过vcp_printf返回结果。这为产品测试和现场调试提供了极大便利。
6.2 通过VCP实现IAP(在应用编程)
这是虚拟串口一个极具价值的应用:通过USB口更新固件,无需额外的下载器。其原理是利用STM32内置的Flash编程功能。
- 设计Bootloader:编写一段独立的程序(Bootloader),存储在Flash的起始地址。它上电后,先检查某个标志(比如某个备份寄存器的值,或者USB是否有特定的升级命令)。
- 进入升级模式:如果检测到升级标志,Bootloader就通过VCP与电脑上的上位机软件通信。上位机软件将新的应用程序固件文件(通常是.bin或.hex)通过USB发送下来。
- 接收与烧写:Bootloader将接收到的数据包,按照Flash的页(Page)大小进行校验和重组,然后调用
FLASH_ProgramWord等函数,将数据写入到Flash中预先留好的应用程序区域(比如从0x08004000开始)。 - 跳转执行:全部数据接收并校验成功后,Bootloader清除升级标志,然后通过函数指针跳转到应用程序的起始地址(0x08004000 + 4,第二个字是应用程序的复位向量)执行。
在这个过程中,VCP承担了高速、可靠的数据传输通道。由于USB的传输速率远高于普通串口,更新一个几百KB的固件只需要几秒钟。实现时需要注意Flash的擦写时间较长,Bootloader中需要有超时和断点续传机制,并且应用程序的链接脚本需要偏移到正确的地址。
整个移植和开发过程,从最初的硬件选型、软件框架搭建,到核心的USB库改造、多任务安全设计,再到最后的稳定性调优和功能扩展,每一步都需要对STM32的USB外设、FreeRTOS的内核机制有清晰的理解。最大的收获不是最终调通了代码,而是在解决“中断与任务同步”、“缓冲区管理”、“流控制”这些经典问题的过程中,对实时系统设计有了更深的体会。把虚拟串口这个看似简单的功能,在RTOS上做到工业级的稳定可靠,其背后的设计思想,完全可以复用到其他更复杂的通信协议或外设驱动开发中去。