stm32 USB虚拟串口
通常情况下我习惯使用ch340或者cp2102这一类uart转usb芯片实现串口通信的功能,但是这意味着在电路设计时需要多花一个电路的原理图、占用更多的PCB面积以及更多的器件。最近在设计一块较小的电路板,由于板子上的空间有限,再加上刚好串口芯片用完了,不想再买,因此想要尝试一下使用STM32的USB虚拟串口来实现串口通讯的功能。故记录一下使用方法。
开发平台
软件:CubeMX、Clion
硬件:STM32ZET6核心板
CubeMX配置
首先使用Clion创建工程,创建方法可以参考这里。随后打开CubeMX,进行USB配置,配置参数如下所示:
配置GPIO,这两个引脚是板子上led灯的引脚,测试用,可以不用管。

然后配置时钟,高速时钟选择外部晶振。

选择DEBUG模式,这个和你的烧录器有关。

在Connectivity中选择USB,勾选Device(FS)。

选择Middleware and Software Packs中的USB_DEVICE,然后选择Virtual Port Com。

最后设置一下时钟

按照常规流程生成工程,注意这里在生成的时候把不同的初始化代码分开生成不同的文件。

功能实现
串口发送
打开main.c文件,在26行左右的位置,引用头文件#include "usbd_cdc_if.h",在这个头文件的104行处声明了发送函数CDC_Transmit_FS(),这个函数有两个参数。Buf是一个指针,指向待发送的数据,Len是待发送数据的长度。

因此,可以直接调用这个函数即可,比如在main()函数中写入
c
CDC_Transmit_FS("Hello\r\n", 7);
CDC_Transmit_FS("from VPC\r\n", 10);
HAL_Delay(300);
如下图所示,注意代码尽量写在带有/* BEGIN */和/* END */的注释中间,否则CubeMX重新生成代码时,会将这些代码删去。

将烧录器和usb连接至板子上正确的位置后,烧录。重新插拔一下usb。
注意usb数据线这时候接,直连STM32的就行了,不要接连串口芯片的那个引脚
打开串口调试助手,可以看到可以定时收到数据了。如果不知道是哪个端口的话,随便插拔一下,看一下哪个端口消失了就能确定,波特率啥的都不用设置。

但是可以发现,连续发送两行数据的时候,第二行消失不见了。
这并非上位机没有收到数据,实际上是因为函数CDC_Transmit_FS()的发送机制问题,当发送一个数据包时,需要间隔一段时间才能继续发送,连续调用发送函数会导致第二段数据无法发送出去。
因此,解决这个问题,最简单的方法就是在两个中间加一个延时。
c
CDC_Transmit_FS("Hello\r\n", 7);
HAL_Delay(1);
CDC_Transmit_FS("from VPC\r\n", 10);
HAL_Delay(300);
烧录后,重新插拔一下usb,打开串口助手,可以发现可以接收到第二行数据了。

优化发送功能
在刚才的demo中,我们虽然可以顺利发送数据了,但是还是存在两个不优雅的地方。
- 每次烧录后都需要重新插拔一下USB才能重新被电脑识别。
- 连续调用函数
CDC_Transmit_FS()时都需要添加一个延时函数。
为了解决这两个问题,我们需要对几个函数做一些修改。
首先是第一个问题,我们需要通过模拟插拔 来解决,即在电路上电后,通过将usb接口中的D+(PA12)引脚拉低一段时间,来模拟usb拔出的动作。
我们在main()函数的while()前面可以看到几个初始化函数,其中有MX_USB_DEVICE_Init();,跳转到这个函数的定义处。

可以看到,这个函数调用了很多配置函数,一旦某个配置出现问题,就会跳转到错误的句柄函数。
我们在这些函数的调用之前,加入以下代码:
c
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef GPIOInitStructure = {0};
GPIOInitStructure.Mode = GPIO_MODE_OUTPUT_PP;
GPIOInitStructure.Pin = GPIO_PIN_12;
GPIOInitStructure.Speed = GPIO_SPEED_FREQ_HIGH;
GPIOInitStructure.Pull = GPIO_PULLDOWN;
HAL_GPIO_Init(GPIOA, &GPIOInitStructure);
HAL_Delay(5);
这段代码的功能就是将PA12引脚设置成GPIO后拉低,并让它延时一段时间,保证上位机识别到usb拔出。
随后程序便正常对usb进行初始化,上位机就能识别到了。
这里同样要注意将代码写在/* BEGIN */和/* END */的注释中间。

对于第二个问题,我们来需要改写一下CDC_Transmit_FS()函数。
跳转到函数CDC_Transmit_FS()的定义,如下

导致连续发送失败的语句,已经用红框框出。当TxState不为0,即不空闲的时候,那么就直接返回USBD_BUSY,导致后面的发送函数没执行。因此,我们需要注释掉这几行代码,改写成忙时等待,超时才返回错误的功能。实现代码如下:
c
uint32_t timeStart = HAL_GetTick(); // 获取当前时间
while (hcdc->TxState) {
if (HAL_GetTick() - 10 > timeStart) { // 等待超时
return USBD_BUSY;
}
}
最终,这个函数被改写为

这次删去main()中的HAL_Delay(1);,编译后烧录。这次我们直接就能打开串口并接收到完整的数据了。
串口接收
当usb接收到数据后,stm32会触发中断,进而调用回调函数static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len)。
该函数拥有两个参数:
uint8_t *Buf:指向缓存区的指针,记录缓存区地址uint32_t *Len:记录当前数据包的字节数
因此,我们直接在回调函数中对这两个参数进行处理就实现串口的接收了。
当然了,为了保证回调函数的实时性,不宜在回调函数中执行特别费时的逻辑,回调函数的执行时间应当尽可能的短。
所以我们采用以下方法:
- 在回调函数中将缓存区的内容读出来,清空缓存区
- 在主函数中对读取的内容进行进一步的逻辑处理
首先,在usbd_cdc_if.c 中相应的位置(约97行)定义全局变量,作为接收数据的储存区
c
uint8_t myUsbRxData[64] = {0};
uint16_t myUsbRxLen = 0;
然后在回调函数中加入以下代码:
c
memset(myUsbRxData, 0, 64); // 清空存储区
memcpy(myUsbRxData, Buf, *Len); // 将数据从缓存区中读到存储区
myUsbRxLen = *Len; // 复制字节数
memset(Buf, 0, 64); // 清空缓冲区
回调函数定义在函数CDC_Transmit_FS()定义位置的上方,usbd_cdc_if.c 中约258行的位置。
再在main.h中对刚才的两个变量进行外部调用
c
extern uint8_t myUsbRxData[];
extern uint16_t myUsbRxLen;
最后在main()函数的主循环中写处理逻辑:
c
if (myUsbRxLen) {
static char myStr[100] = {0};
sprintf(myStr, "\r\n收到 %d 个字节:\r内容为:%s\r\n", myUsbRxLen, (char *) myUsbRxData);
CDC_Transmit_FS((uint8_t *) myStr, strlen(myStr));
myUsbRxLen = 0;
}
这里注意一下,由于是在发送代码的基础上修改的,因此我最后重新调整了一下原来的代码。
最后main()函数中的内容为:
c
int main(void) {
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_USB_DEVICE_Init();
/* USER CODE BEGIN 2 */
/* USER CODE END 2 */
/* Infinite loop */ /* USER CODE BEGIN WHILE */
while (1) {
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
HAL_Delay(1);
if (myUsbRxLen) {
static char myStr[100] = {0};
sprintf(myStr, "\r\n收到 %d 个字节:\r内容为:%s\r\n", myUsbRxLen, (char *) myUsbRxData);
CDC_Transmit_FS((uint8_t *) myStr, strlen(myStr));
myUsbRxLen = 0;
}
static uint16_t sendTime = 0;
if (++sendTime >= 2000) {
sendTime = 0;
CDC_Transmit_FS("Hello\r\n", 7);
CDC_Transmit_FS("from VPC\r\n", 10);
}
}
/* USER CODE END 3 */
}
最后的效果如下:

注意事项
由于功能限制,cdc类的设备一次性最多只能接收64个字节的数据,如果想要接收更长的数据,需要在回调函数中增加一些代码,才能实现完整接收。