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夜也嗑不完!

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

至此,本篇完结。

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

相关推荐
学生董格19 小时前
[嵌入式embed]Keil5-STM32F103C8T6(江协科技)+移植RT-Thread v3.15模版
stm32·嵌入式硬件·rt-thread·keil5·江协科技
酷飞飞19 小时前
掌握DMA基于GD32F407VE的天空星的配置
stm32·单片机·嵌入式硬件·arm
李永奉1 天前
STM32-认识STM32
stm32·单片机·嵌入式硬件
La Pulga1 天前
【STM32】I2C通信—软件模拟
c语言·stm32·单片机·嵌入式硬件·mcu
典则2 天前
STM32FreeRtos入门(四)——任务状态和调度
stm32·单片机·嵌入式硬件
充哥单片机设计2 天前
【STM32项目开源】基于STM32的智能天然气火灾监控
stm32·单片机·嵌入式硬件
充哥单片机设计2 天前
【STM32项目开源】基于STM32的智能仓库火灾检测系统
stm32·单片机·嵌入式硬件
就叫飞六吧2 天前
普中stm32大Dap烧录流程
stm32
A9better2 天前
嵌入式开发学习日志38——stm32之看门狗
stm32·嵌入式硬件·学习
小莞尔2 天前
【51单片机】【protues仿真】基于51单片机智能路灯控制系统
c语言·stm32·单片机·嵌入式硬件·51单片机