【STM32 + CubeMX】 USB 虚拟串口通信
本篇操作:通过CubeMX Keil配置STM32作为USB设备端与电脑上位机进行通信CDC通用带USB功能的 STM32 芯片 如F1、F4等系统时钟配置不同代码通用。工程链接已放在文末。目录一、 STM32内置USB、虚拟串口简述二、CubeMX 新建工程三、Keil 工程配置四、实现USB模拟插拔五、发送六、发送优化连续发送七、接收八、接收优化在外部处理数据一、 STM32内置USB、虚拟串口简述STM32 芯片绝大部分型号都带内置USB如常用的 F1、F4、H7、G4 等系列能够通过USB接口与计算机或其他USB设备进行通信。STM32内置的USB均可支持USB 2.0标准可以支持三种传输速率高速模式最高可达480 Mbps 部分型号支持且需搭配外部芯片不常用 )全速模式最高可达12 Mbps (最常用低速模式最高可达1.5 Mbps高速模式需要搭配外围USB PHY芯片如USB3300硬件成本偏高 。全速模式电路很简单。从机在PCB布线时仅需把STM32的引脚PA11、PA12, 连接至USB座的DP、DM然后PA12(DP线)用1.5K电阻上拉至3.3V。具体如下图上拉说明插拔检测设备未插入时主机端DP、DM为低电平当发现被置高即为有设备插入区分速率DM线上拉是低速模式DP线上拉是全速\高速模式上拉电压3.3V。USB通信电平是3.3V而不是总线供电的5V。USB虚拟串口简称VPCVirtual Port Com 的简写。但更习惯于把虚拟串口叫作: CDC因为它是利用 USB 的 CDC类 实现的一种通信接口。我们可以利用STM32自带的USB功能通过CubeMX的配置很方便地实现一个USB虚拟串口从而通过USB线实现电脑与STM32的数据互传。哪些win系统支持虚拟串口Win10、Win11 已带虚拟串口驱动无需安装任何驱动;Win7 要提前手动安装驱动否则无法识别 虚拟串口驱动 下载二、CubeMX 新建工程本篇为了工程的清晰将从0开始 新建一个虚拟串口通信的工程。日常做项目不建议新建而是复制已有的旧工程通过CubeMX增删需要的功能。这样能减少一些常用功能的再次配置如按键、UART等复用旧工程里已验证过的功能能有效地减少常用功能的调试时间。1、以芯片型号新建2、搜索芯片型号3、设置调试模式进入配置页面后养成习惯优先设置调试模式Serial Wire。4、选择晶振源外部高速晶振源(HSE)Crystal/Ceramic Resonator5、USB工作模式USB_OTG_FS选择 Device_Only; 设备模式(从机模式); 其它参数默认。有些芯片型号如F103系列CubeMX上的显示是USB_FS配置步骤是一样的。6、中间件组件USB_DEVICE选择 CDC (VPC) 其它参数默认;6、配置系统时钟① 当启用USB功能后进入时钟配置页面时弹窗 是否自动配置系统时钟? 选择No 。② 先确认板上的晶振值配置时钟前很重要的一个事先核对开发板上的晶振频率在晶振上的数字晶振频率配置错误时编译不会报错但系统可能不运行、通信错乱等后期排查很费时间目前STM32的板子常用的外部高速晶振有三种8M、12M、25M。③ STM32F103 时钟配置晶振值输入分频输出倍频USB分频APB1分频APB2分频系统 时钟8191.52172MHz④ STM32F4xx 时钟配置注意F4系列各板商略有不同大部分是25M, 少部分是8M使用效果一样。晶振值输入分频输出倍频输出分频USB分频APB1分频APB2分频系统时钟25253362742168MHz如上F1和F4最终都是把USB时钟配置为48Mhz。7、工程配置工程名称、路径这两项必须英文。否则生成的工程将会缺少启动文件开发工具MDK-ARM, 即生成Keil工程堆大小建议0x400栈大小建议0x10008、文件和代码的配置9、生成稍等 片刻生成的工程文件夹Keil工程的入口文件三、Keil 工程配置按上述双击打开Keil工程。1、新建的工程需要设置一次仿真器参数按你所用仿真器(烧录器)进行设置即可。本篇使用cmsis dap参数如下。注意一定要点击 OK 才是保存并返回。而点击叉叉只是退出参数是不会保存的。2、配置常用的调试选项下面这两项是非必要的建议打勾使用编译后生效; 打勾会令编译速度变慢Debug Infomation: 生成调试信息。debug模式中无法设置断点就是这个选项没打勾。Bowse Infomation: 生成追踪信息。如右击函数、变量点击弹出菜单Go To Definition...3、编译 验证0 Error正程正常。有 Error失败应该是 (2-7) 那一步工程名称、路径有中文。修改后重新生成即可。先别烧录别烧录别烧录。四、实现USB模拟插拔通过 CubeMX 配置后生成的工程它已带需要的初始化代码、配置代码、基础函数等。我们只需在工程里按需进行简单的配置、修改代码即可使用。1、包含 USB接口 的头文件打开 main.c文件大约第26行配对的/* USER CODE ...... Includes */注释之间,添加#include usbd_cdc_if.h完成后是这个样子的2、增加 USB模拟插拔我们在调试STM32程序期间需要反复地 修改程序、编译、烧录; 这是常规操作用于调试其它通信模块如DHT11、ESP8266等是没有问题的但用于调试USB的通信就会翻车。当虚拟串口所用的USB线一直插在USB口上程序重新烧录后程序的重新运行将导致通信错误、USB端口假死等现象;上文中已介绍电脑端USB口没有插入设备时DP和DM线是低电平状态而设备端的DP线有1.5K电阻上拉到3.3V当设备插入到电脑USB口USB口的DP线就会被置高电平主机是依靠这个机制判断设备是否插入、拔出继而触发不同的动作如枚举、释放端口等。当虚拟串口所用的USB线一直插在USB口上在STM32烧录程序重新运行后程序里的USB代码等待着主机方发起枚举过程而这个期间虚拟串口的USB线没有断开主机方认为设备方一直在线早已枚举成功一直对其轮询数据收发。双方“南辕北辙胡言乱语”注定翻车。正常操作是每次烧录前先把虚拟串口的USB线拔下来烧录好了再插上......。为了避免调试期间频繁地手动操作我们可以在程序开跑后、USB初始化前用代码把PA12置低使D线为低电平持续一段时间模拟USB拔出动作令主机认为设备已断开连接释放端口;然后当程序运行到后面的USB初始化函数时PA12会被正常配置DP线电平被置高)USB主机就会发现有设备插入开始尝试枚举、配置;具体操作打开usbd_conf.c文件大约第70行附近找到HAL_PCD_MspInit ( )函数;在两对 /* USER ... 0 */ 注释之间约75行添加PA12引脚置低电平操作, 如下可复制:__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能GPIOA端口 GPIO_InitTypeDef GPIO_InitStruct {0}; // 声明结构体; 如果与文中位置相同这行可不写 GPIO_InitStruct.Pin GPIO_PIN_12; // 引脚PA12, 即D GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; // 引脚工作模式 GPIO_InitStruct.Pull GPIO_PULLDOWN; // 下拉 GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; // 引脚反转速度 HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // 初始化 HAL_Delay(5); // 持续片刻注意最后一行的延时是必须的建议在5ms左右;完成后是这个样子的 (F1 \ F4两个版本参考(STM32F1参考下图)(STM32F4参考下图)增加这段代码后再无需手动插拔 虚拟串口的USB 线了程序将模拟 “ 断开、插入”;再次编译确保上述操作正常 先别烧录。五、发送发送数据的函数uint8_t CDC_Transmit_FS ( uint8_t* Buf, uint16_t Len );函数接受两个参数数据缓冲区的地址、字节数。如果USB设备正忙它会返回USBD_BUSY状态。这个函数的作用是设置传输数据的缓冲区并标记数据包为待发送。数据并非立刻发出而是被存储在USB外设的缓冲区中等待主机轮询请求传输。1、发送示范在main.c 的 while 循环中添加三行测试代码1行延时、两行发送数据;注意是共三行1行延时2行发送。请严格按照这三行来写因为我们要顺带实现一个bug, 然后下面方便解释具体原因新手想省时间就请按步骤操作 先别插虚拟串口所用的USB线编译、烧录代码打开串口助手, 这时是没有插虚拟串口USB线的查看目前有哪些端口号插入虚拟串口的USB线到开发板的 USB-Slave接口( 前提win10、11系统已带虚拟串口驱动; win7要手动安装驱动虚拟串口下载)( 电脑会自动识别到设备如果是第一次使用虚拟串口电脑将自动安装驱动程序)检查串口助手发现多了一个端口号 选择它波特率等参数不用修改打开端口;接线如下图示例所用的开发板左侧为用户USB接口已连接PA11、PA12我们就是用这个U口实现虚拟串口通信。右侧是板载仿真器CMSIS DAP的接口它自带了USB转TTL功能。注意端口的选择当使用的开发板已带USB转TTL功能如上面这个。在烧录虚拟串口的程序后串口助手会有至少两个端口号板子自带的USB转TTL右侧、程序实现的虚拟串口左侧注意不要选择错了。如果不知道哪根线对应哪个端口在烧录后拔一下USB线看看哪个端口消失了。另一方法有些串口助手的端口列表能显示设备信息找到带“STM...”描述的那个。现在能看到串口助手能接收到程序持续发出的数据了效果如下图六、发送优化连续发送上节的发送实现时只能收到第一行Hello而第二行发出的数据:借点钱 却没收到不是没收到。其实从STM32程序的角度是没有发出数据先说说USB虚拟串口通信的几个重点 (特指USB2.0、全速模式、中断传输USB是轮询机制主机对设备不断轮询间隔最小1ms不是固定的1ms, 是最小间隔时间;USB的数据是按包传输的;每个设备每1ms最多传输1包数据;每包最多64字节有效负载;再说说CDC_Transmit_FS ( ) 函数它的第2个参数字节数范围0~2048; 这个2048可以在CubeMX里进行设置大小;字节数 64算1包。如发3个字节也算1包。字节数 0也算1包。俗称空包; 如果上一帧刚好发送64字节再发一个空包作为结束包;字节数 64 CDC_Transmit_FS ( ) 背后有缓存它自动分包1ms左右发1包直至发完;如果上一包还没发完再次调用CDC_Transmit_FS ( ) 将放弃本次调用。上面while循环中连续、两次调用CDC_Transmit_FS ( ) .第1次调用将正常发出一包数据;第2次调用再发送一包。但是两包没有间隔1ms以上导致了第2次的发送被舍弃了。我们先打开 CDC_Transmit_FS ( ) 函数看看函数原型。在代码中右击CDC_Transmit_FS ( ) 弹出菜单选择Go To Definition...将跳转到函数位置;或者在左侧文件树中双击打开usbd_cdc_if.c文件 CDC_Transmit_FS ( ) 位于大约280行 ;特别注意usbd_cdc_if.c是应用层文件我们对收、发有啥特殊需求通过修改文件里的发送函数、接收回调函数、类请求函数基本都能实现。下图是CDC_Transmit_FS ( )函数截图。红框的代码作用是当设备忙时直接放弃发送,修改注释掉刚才 if 体的3行代码;增加等待发送空闲、判断超时如下6行;整个CDC_Transmit_FS ( ) 函数如下:可复制uint8_t CDC_Transmit_FS(uint8_t *Buf, uint16_t Len) { uint8_t result USBD_OK; /* USER CODE BEGIN 7 */ USBD_CDC_HandleTypeDef *hcdc (USBD_CDC_HandleTypeDef *)hUsbDeviceFS.pClassData; // 获得设备的状态信息结构体 // if (hcdc-TxState ! 0){ // return USBD_BUSY; // } uint32_t timeStart HAL_GetTick(); while (hcdc-TxState) { if (HAL_GetTick() - timeStart 20) return USBD_BUSY; } USBD_CDC_SetTxBuffer(hUsbDeviceFS, Buf, Len); result USBD_CDC_TransmitPacket(hUsbDeviceFS); /* USER CODE END 7 */ return result; }完成后是这个样子再次编译、烧录程序。串口助手现在是这个样子的连续发送已实现了。上述方法能够解决连续发送失败的问题。但它存在一个显著缺点由于其阻塞性质频密连续发送时将导致运行“死等”影响程序效率。对于大部分场景上述方法已足够。本节内容只是为你预埋一种备用思路无需死磕。如果项目对实时性有较高的要求可以通过结合使用发送数据函数CDC_Transmit_FS()和发送完成回调函数CDC_TransmitCplt_FS()来提高传输效率。CDC_TransmitCplt_FS()会在CDC_Transmit_FS()函数发送数据完毕后自动被调用。根据这两个函数的特点可以设计一套高效的发送缓存机制。例如可以维护一个发送队列当CDC_Transmit_FS()完成发送后CDC_TransmitCplt_FS()被调用时从队列中取出下一个数据项进行发送这样可以确保数据传输的连续性。这个发送完成回调函数CDC_TransmitCplt_FS()位于发送函数CDC_Transmit_FS()的正下方。至于具体的代码实现不同项目需求各异无法提供一个通用的解决方案。因此需要根据具体的项目需求进行针对性的设计和优化不能一药治百病。七、接收1、接收方式的简述当USB CDC接收到来自USB主机的数据时触发中断进入中断函数继而自动调用接收回调函数:int8_t CDC_Receive_FS (uint8_t* Buf, uint32_t *Len);uint8_t* Buf: 指向接收缓冲区的指针即数据缓存的地址。uint16_t* Len: 当前数据包的字节数。我们就在这个回调函数里处理接收到的数据它在usbd_cdc_if.c文件位于发送函数的正上方函数内部生成的代码里只有2行执行代码指定下次接收的存放位置; 如下图所示而本次所接收到的数据该如何处理需要我们自行添加代码。接收机制注意事项每当接收到一包数据硬件自动触发中断函数 继而调用此接收回调函数无需人工调用。与发送机制相似每间隔1ms最多接收1包数据每包最大64字节。。如果需要接收超过64字节的数据帧注意指上位机发送的1个完整数据帧而非USB的单包数据如上位机发来一张图片数据8350个字节则需要在此回调函数中添加额外的代码来判断帧数据传输完整结束 、手动将多个数据包拼接成完整的数据帧。接收到数据时缓存不会提前自动清零新数据从Buf的起始位置开始覆盖存放。由于该回调函数是被中断函数调用的因此建议函数内部的处理尽可能地简短以避免影响系统的实时性中断函数运行期间会令程序持续挂起。2、接收示范本节将示范通过串口助手发送字符串STM32设备端收到数据后把收到的字节数、字符串发回串口助手显示主机端在函数内的注释行 /* USER CODE BEGIN */ 下方添加4行自定义代码可复制。char myStr[64] {0}; // 定义一个数组用于存放要输出的字符串 sprintf(myStr, \r\r收到 %d 个字节\r内容是%s\r\r, *Len, (char *)Buf); // 格式化字符串 CDC_Transmit_FS((uint8_t *)myStr, strlen(myStr)); // 发送 memset(Buf, 0, 64); // 处理完数据清0接收缓存;注意用char 声明myStr[ ], 是因为此处想把它作为一段字符串空间;sprintf是C语言标准输入输出库的函数如果报错没有这个函数就#include stdio.h获取字节数是*Len而不是Len; 因为它在函数参数里的声明是一个指针;为了格式化成字符串Buf用了(char*)进行强制转换成字符类型;CDC_Transmit_FS( )里用了strlen获取字符串的字节数它只对字符串有效对其它数据类型无效; 如果报错没有这个函数就#include string.h如果传输的是16进制数用uint8_t 声明上面数组然后修改sprintf的格式化方式。添加完成后文件是这样子的再次编译、烧录程序。串口助手打开对应的端口号波特率等参数不用修改在发送区以ASCII方式发送字符串因为添加的代码里用%s格式化处理的是字符串)然后串口助手的接收区马上能接收到刚才发出的数据至此已实现接收的处理。八、接收优化在外部处理数据上面我们已实现获取、使用接收到的数据。在接收回调函数中直接操作数据的发送通常是安全的因为这种操作耗时非常短最多等待1次主机轮询周期1ms。这种情况下不会对系统的稳定性和数据接收造成显著影响。但是如果在接收回调函数中执行耗时较长的操作如显示到LCD或存储到Flash等这些操作可能需要数毫秒到数十毫秒才能完成。耗时较长的操作可能会导致接收过程中出现漏包现象。因为接收回调函数是由USB中断服务程序调用的属于中断处理的一部分在回调函数执行期间主程序还处于中断挂起状态其他代码和中断也会被暂停执行形象地描述“卡死”。如果中断服务程序的执行操作较耗时会导致下一包数据无法及时进入中断从而造成数据丢失。举例说明A (电脑USB主机)每隔1ms扔出1枚鸡蛋B(STM32中断服务函数负责接鸡蛋。当B接鸡蛋处理得比A快即比A扔出的间隔更短如0.5ms那没问题。但是当B接鸡蛋的处理时间较长如接了鸡蛋还要写上价格再放置到货架共20ms 那肯定就接不住A持续扔过来的鸡蛋了每接1个就会丢失后面的19个再接1个再丢失19个.......; 注意A是不管B是否数据丢包的它只负责每1ms扔(发送)一次。我们需要采取一种策略使得程序的中断响应、“卡死”占时尽可能地短接收数据在中断回调函数中我们仅执行必要的数据复制操作即把接收到的数据迅速复制到外部缓存中。这一操作的耗时通常在us级别;处理数据在主程序的while循环中我们再对数据进行进一步的处理。由于这一处理过程不占用中断资源因此不会影响程序对新数据的接收;这种策略通过分离数据接收和数据处理两个步骤确保了程序能够快速响应连续的数据流同时避免了因处理时间过长而导致的数据丢失。操作共4个步骤具体如下1、增加全局变量在usbd_cdc_if.c文件大约97行配对的注释内定义两个变量/* USER CODE BEGIN PRIVATE_VARIABLES */ uint8_t myUsbRxData[64] { 0 }; // 接收到的数据 uint16_t myUsbRxNum 0; // 接收到的字节数 /* USER CODE END PRIVATE_VARIABLES */现在它俩只是本地变量等会要在main中用extern再声明一次才能被外部调用。完成后是这个样子的2、修改接收回调函数在CDC_Receive_FS() 里删除我们上节增加的测试代码;把Buf和*Len的数据复制到我们刚才的两个变量里。函数修改成static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len) { /* USER CODE BEGIN 6 */ // 把Buf里面的数据复制到外部缓存 memset(myUsbRxData, 0, 64); // 清0缓存区 memcpy(myUsbRxData, Buf, *Len); // 把接收到的数据复制到自己的缓存区中 myUsbRxNum *Len; // 复制字节数 memset(Buf, 0, 64); // 处理完数据清0接收缓存; // CubeMX生成的代码保留 USBD_CDC_SetRxBuffer(hUsbDeviceFS, Buf[0]); // 设置下-个接收缓冲区 USBD_CDC_ReceivePacket(hUsbDeviceFS); // 启动下一个数据包的接收 return (USBD_OK); /* USER CODE END 6 */ }完成后是这个样子的现在数据接收部分已处理好了。以后回调函数运行时只复制数据至外部缓存备用中断时间占用极短不会影响下包接收。3、 在外部用extern声明变量令外部可调用数据外部哪个文件里要使用CDC接收的数据就在这个文件里用extern声明那俩变量。如可以在LCD文件也可以在SD卡的文件中都行。建议在main.h文件中声明其它文件再#include main.h这样可以令变量全局可用。打开 main.c右击空白点击Toggle Header/Code File可以跳转到头文件main.h在main.h中大约38行找到 配对的注释行 /* USER CODE BEGIN ET */用 extern 再次声明刚才两个变量。如下可复制注意是只声明不要赋值否则编译错误。/* USER CODE BEGIN ET */ extern uint8_t myUsbRxData[ ] ; extern uint16_t myUsbRxNum ; /* USER CODE END ET */完成后是这个样子的代码规范这里的示范使用全局变量只是为了更清晰地演示操作思路。项目中尽量避免使用全局变量不同文件间的数据获取可以封装成函数如 CDC_GetRxData()、CDC_GetRxNum()返回数据地址、接收的字节数。4、使用接收到数据在main.c的while循环中通过判断myUsbRxNum的值只要大于0就表示收到数据了记得每次处理完数据把myUsbRxNum置0以便于下一轮的判断。下面我们编写几行代码示范如何判断接收并处理数据 以字符串为例)// 判断USB CDC 接收 if(myUsbRxNum) // 判断接收字节数量如果数量大于0即为已收到一包数据 { static char myStr[100] {0}; // 创建一个缓冲区; 本示例针对字符串进行测试所以创建的是char sprintf (myStr, \r\r收到 %d 个字节 \r, myUsbRxNum); // 拼接字符串格式 CDC_Transmit_FS((uint8_t *)myStr, strlen(myStr)); // 发送到主机 (输出到串口助手) sprintf (myStr, 内容是%s\r\r , (char *)myUsbRxData); // 拼接字符串格式 CDC_Transmit_FS((uint8_t *)myStr, strlen(myStr)); // 发送到主机 (输出到串口助手) myUsbRxNum 0; // 每次处理完一包数据清零接收字节数以便于下次判断 }编写后位置如下图再次编译烧录程序。打开串口助手发送测试文本可以发现能成功收到STM32发过来的回传数据。如果外部处理数据的速度跟不上如在while里每次收到数据都要显示到LCDLCD的速度远慢于USB的传输那还不是变相丢了数据是的会有这种情况但那已经是程序逻辑和时间片机制的问题了3天3夜也嗑不完本节只讨论确保每一包数据都能被正常接收到。外部能否及时处理不述。至此本篇完结。如有错漏望留言指正及时更新九、相关资源1、实验所用开发板参考【 STM32F407 开发板 】2、CubeMX Keil 工程【 STM32F407 - 25M晶振版本 】