本篇操作:
- 通过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虚拟串口,简称VPC,Virtual 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 Resonator
5、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分频 | 系统 时钟 |
---|---|---|---|---|---|---|
8 | 1 | 9 | 1.5 | 2 | 1 | 72MHz |
④ STM32F4xx 时钟配置:
注意:F4系列,各板商略有不同,大部分是25M, 少部分是8M,使用效果一样。
晶振值 | 输入分频 | 输出倍频 | 输出分频 | USB分频 | APB1分频 | APB2分频 | 系统时钟 |
---|---|---|---|---|---|---|---|
25 | 25 | 336 | 2 | 7 | 4 | 2 | 168MHz |
7、工程配置
- 工程名称、路径,这两项,必须英文。否则,生成的工程将会缺少启动文件
- 开发工具:MDK-ARM, 即生成Keil工程
- 堆大小,建议:0x400
- 栈大小,建议:0x1000
8、文件和代码的配置
9、生成
稍等 片刻:
生成的工程文件夹:
Keil工程的入口文件:
三、Keil 工程配置
按上述,双击打开Keil工程。
1、新建的工程,需要设置一次仿真器参数 。(点击 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_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左右;
完成后,是这个样子的:
增加这段代码后,再无需手动插拔 虚拟串口的USB 线了,程序将模拟 “ 断开、插入”;
再次编译,确保上述操作正常 (先别烧录)。
五、发送
发送数据的函数;
uint8_t CDC_Transmit_FS ( uint8_t* Buf, uint16_t Len );
函数接受两个参数:数据缓冲区的地址、字节数。
如果USB设备正忙,它会返回USBD_BUSY
状态。
这个函数的作用是设置传输数据的缓冲区,并标记数据包为待发送。数据并非立刻发出,而是被存储在USB外设的缓冲区中,等待主机轮询请求传输。
发送操作:
- 在main.c 的 while 循环中,添加三行测试代码:1行延时、两行发送数据;
注意:是共三行,1行间隔延时,2行发送,下面会解释具体原因!
新手,要省时间,就请按步骤操作 :
- 先别插虚拟串口所用的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 (主机)每隔1ms扔出1枚鸡蛋,B(中断服务函数)负责接鸡蛋。
- 当B处置鸡蛋的时间占时极短(只要比A扔出的间隔更短,如0.5ms),那,没问题。
- 当B处置鸡蛋的时间较长,接了鸡蛋还要写上价格,再放置到货架,共20ms, 那肯定就接不住A持续扔过来的鸡蛋了,每接1个,就会丢失后面的19个,再接1个,再丢失19个.......
我们需要采取一种策略,使得程序的中断响应、“卡死”占时,尽可能地短:
- 接收数据:在中断回调函数中,我们仅执行必要的数据复制操作,即把接收到的数据迅速复制到外部缓存中。这一操作的耗时通常在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,以便于下一轮的判断。
再次烧录,烧录程序。
打开串口助手,发送测试文本,可以发现,能成功收到STM32发过来的回传数据。
如果,外部处理数据的速度跟不上,如,在while里每次收到数据都要显示到LCD,LCD的速度远慢于USB的传输,那,还不是变相丢了数据?!是的,会有这种情况!
但,那已经是程序逻辑和时间片机制的问题了,3天3夜也嗑不完!
本节只讨论:确保每一包数据,都能被正常接收到。外部能否及时处理,不述。
至此,本篇完结。
如有错漏,望留言指正,及时更新!!