STM32 -- USB CDC 虚拟串口通信

本篇操作:

  • 通过CubeMX + Keil,配置STM32作为USB设备端,与电脑上位机进行通信(CDC);
  • 通用带USB功能的 STM32 芯片 (如F1、F4等,系统时钟配置不同,代码通用)。

目录

[一、 STM32内置USB、虚拟串口简述](#一、 STM32内置USB、虚拟串口简述)

[二、CubeMX 新建工程](#二、CubeMX 新建工程)

[三、Keil 工程配置](#三、Keil 工程配置)

四、实现USB模拟插拔

五、发送

六、发送优化(连续发送)

七、接收

八、接收优化(在外部处理数据)


一、 STM32内置USB、虚拟串口简述

STM32 芯片,绝大部分型号都带内置USB,如常用的 F1、F4、H7、G4 等系列,能够通过USB接口与计算机或其他USB设备进行通信。

STM32内置的USB,均可支持USB 2.0标准,可以支持三种传输速率:

  1. 高速模式:最高可达480 Mbps (部分型号支持,且需搭配外部芯片,不常用 )
  2. 全速模式:最高可达12 Mbps (最常用)
  3. 低速模式:最高可达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引脚置低电平操作, 如下(可复制):
cpp 复制代码
    __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 线了,程序将模拟 " 断开、插入";

再次编译,确保上述操作正常 (先别烧录)。


五、发送

发送数据的函数;

cpp 复制代码
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 ( ) 函数,如下:(可复制)
cpp 复制代码
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主机的数据时,触发中断进入中断函数,继而自动调用接收回调函数:

cpp 复制代码
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行自定义代码(可复制)。

cpp 复制代码
    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行,配对的注释内,定义两个变量:

cpp 复制代码
/* 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的数据,复制到我们刚才的两个变量里。函数修改成:

cpp 复制代码
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 再次声明刚才两个变量。如下(可复制):

注意,是只声明,不要赋值,否则编译错误。

cpp 复制代码
/* 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夜也嗑不完!

本节只讨论:确保每一包数据,都能被正常接收到。外部能否及时处理,不述。

至此,本篇完结。

如有错漏,望留言指正,及时更新!!

相关推荐
u0101526581 小时前
STM32F103C8T6学习笔记2--LED流水灯与蜂鸣器
笔记·stm32·学习
北京迅为5 小时前
【北京迅为】《STM32MP157开发板嵌入式开发指南》-第七十八章 Qt控制硬件
linux·stm32·单片机·嵌入式硬件
llhm12 小时前
stm32 踩坑笔记
stm32·单片机·嵌入式硬件
charlie11451419112 小时前
从0开始的STM32之旅8 串口通信(II)
stm32·单片机·嵌入式硬件·c·串口通信
小狗爱吃黄桃罐头1 天前
江协科技STM32学习- P40 硬件SPI读写W25Q64
stm32·江科大
Whappy0011 天前
6. STM32之TIM实验--编码器接口()--(实验5:PWM驱动直流电机)
stm32·单片机·嵌入式硬件
极客小张2 天前
基于STM32的实时监测环境系统设计:Flask、SQLite、Vue.js、HTTP/TCP技术
c语言·stm32·单片机·物联网·毕业设计·课程设计·python后端
Lin2012302 天前
STM32 + CubeMX + 硬件SPI + W5500 +TcpClient
stm32·单片机·嵌入式硬件
极客小张2 天前
基于STM32的智能温室环境监测与控制系统设计(代码示例)
c语言·arm开发·stm32·单片机·嵌入式硬件·物联网·毕业设计
玉树临风江流儿2 天前
STM32标准库-待机模式
stm32·单片机·嵌入式硬件