【STM32G4】【CubeMX+HAL库】第十五届蓝桥杯嵌入式组编程备赛指南(2024)


  笔者在学习过程中参考了以下课程视频,但是这些课程中的代码并不是完全正确合理的,有些是逻辑错误,有些是可能不适用于比赛答题思路(以笔者拙见)。笔者也在实践和学习过程中融合了自己的思考,对代码逻辑进行了改进。基础较为薄弱的同学可以先学习这些课程。


一、建立工程

1.1 利用官方例程自建工程

  比赛开始,不需要自建工程,直接复制一份下发资料中的../5-液晶驱动参考程序/HAL_06_LCD工程,打开CubeMX进行配置。

在这里很容易遇到工程和CubeMX版本不匹配的问题,如下图所示,点击中间的Migrate即可。
在这里插入图片描述

  紧接着离线加载开发库。打开CubeMX上方Help窗口中的Manage embedded software packages,点击左下角的From local…导入本地.zip文件(无需解压),找到开发资料中的../4-库文件/stm32cube_fw_g4_v120.zip文件,选择后等待解压完成即可。
在这里插入图片描述

在这里插入图片描述
  找到工程管理Project Manager中的Mcu and Firmware Package,取消勾选Use Default Firmware Location,并在下方的固件路径中选择刚刚安装的文件包。安装好的固件包默认存储在C:\Users\DELL\STM32Cube\Repository。笔者这里拿到的资料中的固件包版本为STM32Cube_FW_G4_V1.2.0(实际上已经更新到1.5.2了,不知道为什么官方不更新固件包) (线上比赛资料包已下发,发现官方提供了V1.2.0和V1.4.0两个版本的固件包,但依然不是最新版本)。这时,点击GENERATE CODE就可以自动生成工程文件了。直接打开工程,编译烧录,测试拿到手的开发板是否可以正常亮屏。正常而言,此时会执行官方的例程。

这里使用V1.2.0的固件包有可能会遇到以下Bug (所以正式比赛时还是希望能使用最新的固件包)

  1. Keil的Pack Installer中找不到STM32G4系列器件。笔者练习时采用的方法是直接在官网下载安装对应的CMSIS器件包,目前还不清楚如果比赛过程中遇到这个问题该如何解决。
  2. 编译后提示报错:Undefined symbol HAL_PWREx_DisableUCPDDeadBattery (referred from stm32g4xx_hal_msp.o).。目前原因未知,解决方法是双击定位到目标函数位置(stm32g4xx_hal_msp.c),将HAL_PWREx_DisableUCPDDeadBattery改为HAL_PWREx_DisableUSBDeadBatteryPD,之后再编译烧录,发现报错消失,可以正常烧录。

  现在可以简单测试一下程序了。在main函数中删除历程中有关LCD的代码,初始化LCD屏幕后在第一行显示一行字符:

	LCD_Clear(Blue);
	LCD_SetBackColor(Blue);
	LCD_SetTextColor(White);
	
	LCD_DisplayStringLine(Line0 ,(unsigned char *)"Hello, Blue Bridge.");

1.2 自行建立工程

  虽然利用官方历程建立工程省去了很多配置麻烦,节省了时间,但是使用官方历程很容易因为版本不匹配产生很多未知的Bug。所以熟悉自行建立工程的步骤还是很有必要的。

1.2.1 STM32CubeMX配置

  1. 打开STM32CubeMX,点击File下的New Project…,选择器件STM32G431RBT6后打开工程设置界面。

  2. 配置RCC,打开高速外部时钟。找到System Core下的RCC配置窗口,在High Speed Clock(HSE)窗口选择Crystal/Ceramic Resonator
    在这里插入图片描述

  3. 配置时钟树,输入时钟设置为24MHz(和CT1117E-M4开发板保持一致),PLL Source Mux选择HSE,System Clock Mux选择PLLCLK,HCLK输入80后按回车(选择的值可以改变,但是例程中都是80,这里选80比较稳妥),CubeMX会自动计算完成对应配置。
    请添加图片描述

  4. 更改SYS的Debug选项。找到System Core下的SYS窗口,将Debug设置为串行线Serial Wire,这样可以保证在烧录程序后不会出现烧录错误。
    在这里插入图片描述

  5. 在Project Manager下的Project窗口中更改工程名,工程文件夹和IDE。注意工程名和文件夹路径都不能包含中文字符。
    在这里插入图片描述

  6. 在下面的Mcu and Firmware Package窗口选择合适的固件开发包。目前固件开发包已经更新到V1.5.2的版本,但是蓝桥官方提供的固件包仍然是V1.2.0的版本(也有可能是其他版本)。既然已经自建工程了,我认为使用最新的固件包可能更好,所以在这里保持默认。如果出问题也可以使用回较老的版本。
    在这里插入图片描述

  7. 在Code Generator中的Generated files勾选第一项Generate peripheral initialization as a pair of '.c/.h' files per peripheral,其余保持默认。

  8. 点击GENERATE CODE生成代码,打开工程。

1.2.2 Keil5配置

  打开工程后,可以先编译一次,如果有问题就先解决Bug,没有问题就可以进行下面的配置。

  1. 点击魔术棒(工程选项)在Debug窗口下设置调试器为CMSIS-DAP Debugger。点击Settings,打开Flash Download窗口,勾选Reset and Run选项。点击OK退出设置。

请添加图片描述
在这里插入图片描述

  1. 编译工程,确认没有错误没有警告,之后就可以开始编写代码了。

1.3 BSP(板级支持包)的建立

  建立BSP(Board Support Package)是结构化编程的基础。

  1. 在工程文件夹中创建一个文件夹bsp
  2. 打开Keil 5工程,添加bsp组(Group)到工程目录中。
  3. 在魔术棒选项中,找到C/C++,将bsp组添加到Include Paths中。

1.4 模板编写

  笔者习惯使用模板,将重复性很高的代码只写一遍,充分利用Keil 5提高代码编辑效率。在Settings中找到Text Templates。在这里分享使用频率最高的几个模板。

|字符表示双击载入模板后光标悬停的位置。

  • ifndef(main.h):头文件中使用
#ifndef __|_H_
#define ___H_

#include "main.h"

#endif

  • @brief:函数注释模板(比赛时很可能来不及写注释,但是以防万一还是准备一下)
/**
  * @brief  |
  * @param  
  * @retval 
  */

二、模块代码编写

2.1 LED

2.1.1 基础操作

  通过以下方法可以实现一个“指哪打哪”的LED显示函数。
在这里插入图片描述

  • led.h
#ifndef __LED_H_
#define __LED_H_

#include "main.h"

void BSP_LED_Disp(uint8_t LED_data);

#endif

  • led.c
#include "led.h"

uint8_t LED_DATA_SAVE;

void BSP_LED_Lock(void)
{
  HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_RESET);
}

void BSP_LED_Unlock(void)
{
  HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_SET);
}

void BSP_LED_Disp(uint8_t LED_data)
{
  BSP_LED_Unlock();
  
  HAL_GPIO_WritePin(GPIOC, GPIO_PIN_All, GPIO_PIN_SET);
  HAL_GPIO_WritePin(GPIOC, LED_data << 8, GPIO_PIN_RESET);
  
  BSP_LED_Lock();
  LED_DATA_SAVE = LED_data;
}

2.1.2 单独操作某个LED灯而不影响其他LED

  笔者在备赛过程中,还编写过如下的操作单个LED的函数。下面的函数即使在理论上可行,但是被实践证实是不可用的。因为在蓝桥杯赛题中经常涉及让LED灯以固定周期闪烁的操作,这时如果采用下面的函数单独操作某IO口,并在定时中断回调函数中操作LED灯,就可能会出现当LCD屏幕显示期间GPIOC的电平混乱,此时该函数打开了PD2,就会导致LED灯显示错乱。

typedef enum
{ 
  LED_ON = 1,
  LED_OFF = 0,
  LED_TOGGLE = 2
} LED_State;

void BSP_LED_SigleLED(uint8_t LED_num, LED_State state)
{
  BSP_LED_Unlock();
  
  if (state == LED_ON)
  {
    HAL_GPIO_WritePin(GPIOC, 0x0001 << (LED_num + 7), GPIO_PIN_RESET);
  }
  else if (state == LED_OFF)
  {
    HAL_GPIO_WritePin(GPIOC, 0x0001 << (LED_num + 7), GPIO_PIN_SET);
  }
  else if (state == LED_TOGGLE)
  {
    HAL_GPIO_TogglePin(GPIOC, 0x0001 << (LED_num + 7));
  }
  
  BSP_LED_Lock();
}

  如果想在任何地方单独操作某LED,下面的方法更合理,也更安全,通过笔者的实践证实是可行的。这里仅将LED1的操作列出,其他的LED操作也是同理的,通过与或操作改写此时的LED即可。

	BSP_LED_Disp(LED_DATA_SAVE | 0x01);		// 仅点亮LED1,不影响其他位
	BSP_LED_Disp(LED_DATA_SAVE & 0xFE);		// 仅熄灭LED1,不影响其他位

2.2 LCD

2.2.1 基础操作

  在CubeMX对照开发板原理图把对应引脚配置为GPIO_Output模式即可。复制样例工程中的lcd.clcd.hfonts.h到bsp文件夹下。在main.h中添加#include "lcd.h",main函数中初始化后即可显示一些字符:

  /* USER CODE BEGIN Init */
  LCD_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();
  /* USER CODE BEGIN 2 */
  LCD_Clear(Black);
  LCD_SetTextColor(White);
  LCD_SetBackColor(Black);
  LCD_DisplayStringLine(Line1, (unsigned char*)"        DATA        ");
  /* USER CODE END 2 */

  但是蓝桥杯官方并没有提供实时的变量显示函数,所以需要我们自行编写。在这里借助sprintf函数将格式化字符串复制到目标字符串中:

  int i = 5;
  float f = 12.3;

  char text[30];
  sprintf(text, "%d   %.2f", i, f);
  LCD_DisplayStringLine(Line2, (unsigned char*)text);

2.2.2 LCD屏幕使用注意事项

  需要特别注意:不要频繁刷新LCD屏幕,CT117E上的LCD屏幕刷新速度较慢,频繁刷新会造成显示内容闪烁的问题。具体的解决方式笔者将在后文给出。

关于操作LCD屏幕LED闪烁的问题,可以参考以下几点:

  1. 在官方给出的lcd库函数中找到LCD_WriteReg()LCD_WriteRAM_Prepare()LCD_WriteRAM()三个函数,在三个函数体开始暂存GPIOC->ODR的值,并在函数结束后恢复。
  2. 一定要确保在不操作LED时PD2处于低电平状态。

2.3 按键模块

2.3.1 短按识别

  蓝桥杯赛题中程序任务颇多,不能通过传统的暴力延时来消抖,所以需要通过定时扫描按键实现消抖。这里涉及STM32定时器TIM的使用,可以参考博主的博客 STM32学习笔记(四)丨TIM定时器及其应用(定时中断、内外时钟源选择)了解TIM的定时器的工作原理,这里仅对CubeMX配置定时器的步骤作一个简要介绍。

  1. 根据产品手册配置相关的GPIO引脚为GPIO_Input模式,并将上下拉模式设置为上拉(Pull-Up);
    在这里插入图片描述
    在这里插入图片描述

  2. 激活定时器。由于这里仅仅需要简单的定时中断功能,所以使用一个通用定时器即可。在这里使用TIM3。配置时需要设置时钟源(基本定时器时钟源只能是内部时钟)、设置PSC和ARR,打开NVIC中断
    请添加图片描述
    在这里插入图片描述

  3. CubeMX中的配置完成,生成代码,打开工程。在bsp文件夹中创建两个文件interrupt.cinterrupt.h,写好对应的模板格式,在stm32g4xx_hal_tim.h中找到定时器中断回调函数的声明(第一个HAL_TIM_PeriodElapsedCallback就是中断回调函数):

/** @defgroup TIM_Exported_Functions_Group9 TIM Callbacks functions
  *  @brief   TIM Callbacks functions
  * @{
  */
/* Callback in non blocking modes (Interrupt and DMA) *************************/
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_PeriodElapsedHalfCpltCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_IC_CaptureHalfCpltCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_PWM_PulseFinishedHalfCpltCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_TriggerCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_TriggerHalfCpltCallback(TIM_HandleTypeDef *htim);
void HAL_TIM_ErrorCallback(TIM_HandleTypeDef *htim);
  1. 编写中断回调函数。在函数HAL_TIM_PeriodElapsedCallback中,所有的定时器定时中断请求发生后都会调用这个函数,所以需要首先判断这个中断请求是否来自TIM3。在下面的历程中,主要编程思想是通过一个简易的状态机来实现消抖。
  • interrupt.h
#ifndef __INTERRUPT_H_
#define __INTERRUPT_H_

#include "main.h"

/**
  * @brief  按键状态结构体
  */
struct keys
{
  uint8_t judge_state;    // 状态机标志,0: 检测到一个低电平;1:确实被按下;2:等待抬起
  uint8_t key_state;      // 按键是否被按下(信号来自GPIO输入)
  uint8_t key_isPressed;  // 按键被按下标志位
};

extern struct keys key[];

// void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);

#endif

  • interrupt.c
#include "interrupt.h"

struct keys key[4] = {0, 0, 0};

/**
  * @brief  TIM定时器定时中断回调函数 
  */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
  if (htim->Instance == TIM3)
  {
    key[0].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0);
    key[1].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1);
    key[2].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2);
    key[3].key_state = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
    
    for (uint8_t i = 0; i < 4; i ++)
    {
      switch (key[i].judge_state)
      {
        case 0:
          if (key[i].key_state == 0)    // 如果检测到一个低电平
          {
            key[i].judge_state = 1;
          }
        break;
        case 1:
          if (key[i].key_state == 0)    // 如果10ms后依然检测到这个按键是低电平,就说明这个按键确实被按下了
          {
            key[i].judge_state = 2;
            // key[i].key_isPressed = 1;	// 按下即响应
          }
          else
          {
            key[i].judge_state = 0;
          }
        break;
        case 2:
          if (key[i].key_state == 1)    // 如果按键被松开
          {
            key[i].judge_state = 0;
            key[i].key_isPressed = 0;		// 松开后响应
          }
        break;
        default: break;
      }
    }
  }
}

  在main函数中,记得在TIM初始化函数MX_TIM3_Init()中打开中断HAL_TIM_Base_Start_IT(&htim3)。下面展示一个简单的main函数检测按键,在LCD屏幕上显示按键信息示例:

/* 部分自动生成代码 -----------------------------------------------------------*/

/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "main.h"
#include "tim.h"
#include "gpio.h"

/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include <stdio.h>

#include "led.h"
#include "lcd.h"
#include "interrupt.h"
/* USER CODE END Includes */

/* 部分自动生成代码 -----------------------------------------------------------*/

/**
  * @brief  The application entry point.
  * @retval int
  */
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 */
  LCD_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_TIM3_Init();
  /* USER CODE BEGIN 2 */
  HAL_TIM_Base_Start_IT(&htim3);
  
  LCD_Clear(Black);
  LCD_SetTextColor(White);
  LCD_SetBackColor(Black);
  LCD_DisplayStringLine(Line1, (unsigned char*)"        DATA        ");
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
    char text[20];
    
    for (uint8_t i = 0; i < 4; i ++)
    {
      if (key[i].key_isPressed == 1)
      {
        sprintf(text, "B%d down.", i + 1);
        LCD_DisplayStringLine(Line3, (unsigned char*)text);
        
        key[i].key_isPressed = 0;
      }
    }
  }
  /* USER CODE END 3 */
}

2.3.2 长按识别(区分短按、长按)

  在短按识别的基础上很容易实现长按识别,只需要添加结构体的成员变量,并且在judge_state的不同阶段执行判断逻辑即可。

  1. judge_state == 0:计数变量清零。
  2. judge_state == 1:消抖,同时将按键按下标志位赋值交给下一个状态。
  3. judge_state == 2:判断按键是否抬起。如果没有抬起,则持续计时,并在计时达到阈值时立即设置长按标志位;如果按键抬起,则判断按下时间是否小于阈值,如果小于阈值(短按)则设置短按标志位。
  • interrupt.h
#ifndef __INTERRUPT_H_
#define __INTERRUPT_H_

#include "main.h"

/**
  * @brief  按键状态结构体
  */
struct keys
{
  uint8_t judge_state;    // 状态机标志,0: 检测到一个低电平;1:确实被按下;2:等待抬起
  uint8_t key_state;      // 按键是否被按下(信号来自GPIO输入)
  uint8_t key_isPressed;  // 按键短按标志位
  
  uint8_t key_long_flag;  // 按键长按标志位
  uint32_t key_time;      // 按键计时
};

extern struct keys key[];

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);

#endif

  • interrupt.c
#include "interrupt.h"

struct keys key[4] = {0, 0, 0, 0, 0};

/**
  * @brief  TIM定时器定时中断回调函数 
  */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
  if (htim->Instance == TIM3)
  {
    key[0].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0);
    key[1].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1);
    key[2].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2);
    key[3].key_state = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
    
    for (uint8_t i = 0; i < 4; i ++)
    {
      switch (key[i].judge_state)
      {
        case 0:
          if (key[i].key_state == 0)    // 如果刚开始检测到一个低电平
          {
            key[i].key_time = 0;        // 时间计数清0
            key[i].judge_state = 1;
          }
        break;
        case 1:
          if (key[i].key_state == 0)    // 如果10ms后依然检测到这个按键是低电平,就说明这个按键确实被按下了
          {
            key[i].judge_state = 2;
          }
          else
          {
            key[i].judge_state = 0;
          }
        break;
        case 2:
          if (key[i].key_state == 1)    // 如果按键被松开
          {
            key[i].judge_state = 0;
            if (key[i].key_time <= 70)  // 如果按下时间不足700ms(中断每10ms触发一次)
            {
              key[i].key_isPressed = 1; 
            }
          }
          else                          // 如果按键没有松开,则持续计时
          {
            key[i].key_time ++;
            if (key[i].key_time > 70)   // 如果按键按下时间大于700ms
            {
              key[i].key_long_flag = 1;
            }
          }
        break;
        default: break;
      }
    }
  }
}

  在main函数中编写如下语句测试按键按下标志:

  if (key[i].key_long_flag == 1)
  {
    sprintf(text, "B%d long down.", i + 1);
    LCD_DisplayStringLine(Line4, (unsigned char*)text);
    
    key[i].key_long_flag = 0;
  }

2.3.3 双击识别(区分短按、长按、双击)

  双击主要的编程思路如下:

  1. 在循环状态机外首先判断双击计时器使能是否打开,如果打开,则开始计时。同时判断如果超时(没有发现紧跟的第二次短按),则判定此次按下为短按,关闭计时器使能。
  2. 在长短按检测时(judge_state == 2)判断是长按还是短按,如果是长按(一直没有松开)则不断计时,当到达700ms后立刻将长按标志位置1;如果是短按,则判断双击计时器是否打开,如果没有打开则说明是第一次短按,使能计数器并将计时时间清0,如果已经打开则说明这次短按是第二次短按(并且没有超时),并将双击标志位置1。

一定注意,在长按松开后再将judge_state标志位置0,如果在长按标志位置1时将judge_state标志位置0,按键还未抬起逻辑进入judge_state == 0中,此时按键依然按下(长按计数器不断清0)。此时松开按键,由于长按计数器的值较小,电路将探测到一个错误的短按开始信号,并开始双击计时,双击计时结束后会将短按标志位置1,会造成一个额外的错误。

  • interrupt.h
#ifndef __INTERRUPT_H_
#define __INTERRUPT_H_

#include "main.h"

/**
  * @brief  按键状态结构体
  */
struct keys
{
  uint8_t judge_state;            	// 状态机标志,0: 检测到一个低电平;1:确实被按下;2:等待抬起
  uint8_t key_state;              	// 按键是否被按下(信号来自GPIO输入)
  uint8_t key_isPressed;          	// 按键短按标志位
			
  uint8_t key_long_flag;          	// 按键长按标志位
  uint32_t key_time;              	// 按键计时
  
  uint32_t key_double_click_time;	// 双击计时器
  uint8_t key_double_click_EN;		// 双击计时器使能
  uint8_t key_double_click_flag;	// 双击标志位
};

extern struct keys key[];

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);

#endif

  • interrupt.c
#include "interrupt.h"

struct keys key[4] = {0};

/**
  * @brief  TIM定时器定时中断回调函数 
  */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
  if (htim->Instance == TIM3)
  {
    key[0].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0);
    key[1].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1);
    key[2].key_state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2);
    key[3].key_state = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
    
    for (uint8_t i = 0; i < 4; i ++)
    {
      if (key[i].key_double_click_EN == 1)      // 计数器使能开启时,计时并判断
      {
        key[i].key_double_click_time ++;
        if (key[i].key_double_click_time > 35)  // 如果超时,则说明是短按,置标志位后关闭计数器使能
        {
          key[i].key_isPressed = 1;
          key[i].key_double_click_EN = 0;
        }
      }
      
      switch (key[i].judge_state)
      {
        case 0:
          if (key[i].key_state == 0)    // 如果刚开始检测到一个低电平
          {
            key[i].key_time = 0;        // 时间计数清0
            key[i].judge_state = 1;
          }
        break;
        case 1:
          if (key[i].key_state == 0)    // 如果10ms后依然检测到这个按键是低电平,就说明这个按键确实被按下了
          {
            key[i].judge_state = 2;
          }
          else
          {
            key[i].judge_state = 0;
          }
        break;
        case 2:  
          if (key[i].key_state == 1 && key[i].key_time <= 70)    // 如果是一次短按
          {
            key[i].judge_state = 0;
          
            if (key[i].key_double_click_EN == 0)          // 如果是第一次按下
            {
              key[i].key_double_click_EN = 1;
              key[i].key_double_click_time = 0;
            }
            else                                          // 双击计时开启,说明这次短按还在双击计时的时间范围内
            {
              key[i].key_double_click_EN = 0;
              key[i].key_double_click_flag = 1;
            }
          }
          else if (key[i].key_state == 1 && key[i].key_time > 70)   // 长按松开按键,则回到state0
          {
            key[i].judge_state = 0;
          }
          else if (key[i].key_state == 0)                           // 如果按键没有松开,则持续计时
          {
            key[i].key_time ++;
            if (key[i].key_time > 70)   // 如果按键按下时间大于700ms
            {
              key[i].key_long_flag = 1;
            }
          }
        break;
        default: break;
      }
    }
  }
}

2.4 PWM 输出

在这里插入图片描述

  这里以PA6(TIM16_CH1)和PA7(TIM17_CH1)输出两路PWM波为例。TIM16和TIM17只有一个CCR通道(如上图所示),所以只能选择CH1作为PWM输出通道。首先需要在CubeMX里完成配置:

  1. 使能定时器,选择CH1为PWM生成模式(PWM Generation CH1);
    在这里插入图片描述

  2. 设置预分频系数PSC和比较寄存器CCR。这里的PSC和ARR直接决定了输出PWM的频率;同时ARR决定了输出PWM占空比调节的分辨率,默认为100;CCR决定了输出PWM的占空比。具体的PWM输出原理可以参考下图,只不过这里的输出比较单元只有1个而不是4个 (笔者的博客STM32学习笔记(五)丨TIM定时器及其应用(输出比较丨PWM驱动呼吸灯、舵机、直流电机)对这一部分的原理有较为详细的介绍)
    在这里插入图片描述
    要使PA6输出100Hz的PWM波,并让初始占空比设置为20%可以如下配置,操作PWM Pulse就相当于直接改变CCR的值,TIM16和TIM17的配置都是类似的:

在这里插入图片描述

在这里插入图片描述
3. 生成代码,在main.c文件中的TIM16、TIM17初始化后使用函数HAL_TIM_PWM_Start()打开PWM输出,之后就可以在指定的端口测量到PWM波形了:

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_TIM3_Init();
  MX_TIM16_Init();
  MX_TIM17_Init();
  /* USER CODE BEGIN 2 */
  HAL_TIM_Base_Start_IT(&htim3);    // Key Detect
  
  HAL_TIM_PWM_Start(&htim16, TIM_CHANNEL_1);    // PA6 PWM output, default pulse duty is 20%
  HAL_TIM_PWM_Start(&htim17, TIM_CHANNEL_1);    // PA7 PWM output, default pulse duty is 20%
  1. 可以使用HAL库的内置函数更改CCR的值,从而达到更改占空比的效果。下面是一个更改的示例:
  uint8_t PWM_PA6_Duty = 20;

  __HAL_TIM_SetCompare(&htim16, TIM_CHANNEL_1, PWM_PA6_Duty);

  下面提供一个通过按键更换显示界面,并更改占空比的程序段示例:

/* USER CODE BEGIN PV */
uint8_t interface_num = 0;      // interface number, 0 for first interface, 1 for another

uint8_t PWM_PA6_Duty = 20;
uint8_t PWM_PA7_Duty = 20;
/* USER CODE END PV */

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */
void Key_Proc(void);
void Display_Proc(void);
/* USER CODE END PFP */

/* Some Code... */

/* USER CODE BEGIN 4 */
void Key_Proc(void)
{
  if (key[0].key_isPressed == 1)    // PB1
  {
    LCD_Clear(Black);
    interface_num ++;
    if (interface_num > 1)
    {
      interface_num = 0;
    }
    key[0].key_isPressed = 0;
  }
  if (key[1].key_isPressed == 1 && interface_num == 1)    // PB2, Display Interface 1
  {
    LCD_Clear(Black);
    PWM_PA6_Duty += 10;
    if (PWM_PA6_Duty > 90)
    {
      PWM_PA6_Duty = 0;
    }
    
    __HAL_TIM_SetCompare(&htim16, TIM_CHANNEL_1, PWM_PA6_Duty);
    
    key[1].key_isPressed = 0;
  }
  if (key[2].key_isPressed == 1 && interface_num == 1)    // PB3, Display Interface 1
  {
    LCD_Clear(Black);
    PWM_PA7_Duty += 10;
    if (PWM_PA7_Duty > 90)
    {
      PWM_PA7_Duty = 0;
    }
    
    __HAL_TIM_SetCompare(&htim17, TIM_CHANNEL_1, PWM_PA7_Duty);
    
    key[2].key_isPressed = 0;
  }
}

void Display_Proc(void)
{
  char text[20];
  if (interface_num == 0)     // Display Interface 1
  {
    // LCD_Clear(Black);
    sprintf(text, "        DATA        ");
    LCD_DisplayStringLine(Line1, (uint8_t *)text);
  }
  if (interface_num == 1)     // Display Interface 2
  {
    // LCD_Clear(Black);
    sprintf(text, "        PARA        ");
    LCD_DisplayStringLine(Line1, (uint8_t *)text);
    sprintf(text, "    PA6:%d%%        ", PWM_PA6_Duty);
    LCD_DisplayStringLine(Line3, (uint8_t *)text);
    sprintf(text, "    PA7:%d%%        ", PWM_PA7_Duty);
    LCD_DisplayStringLine(Line4, (uint8_t *)text);
  }
}
/* USER CODE END 4 */

2.5 PWM 频率和占空比测量

在这里插入图片描述

  在这里使用TIM2、TIM3的CH1和CH2进行频率和占空比的测量,时钟源选择内部时钟,配置PSC和ARR参数(注意根据被测量频率的范围防止计数器CNT溢出),打开NVIC全局中断使能。CH1配置为Input Capture direct mode(直接模式),CH2配置为Input Capture indirect mode(间接模式),实现如下图所示的连接模式。相关原理请参考STM32学习笔记(六)丨TIM定时器及其应用(输入捕获丨测量PWM波形的频率和占空比)
在这里插入图片描述
  CH1中断触发方式选择为上升沿,CH2中断触发方式配置为下降沿,一般不需要配置CH1和CH2的输入捕获通道滤波器(输入信号抖动较为严重时可以考虑)。
在这里插入图片描述
  在中断函数中,对信号的处理十分重要。这一部分程序逻辑不难,难点在于库函数的正确使用。首先在interrupt.c中声明所需要的全局变量,并且在interrupt.h中作extern全局声明:

	uint16_t TIM2_CCR1_val, TIM2_CCR2_val;
	uint16_t TIM3_CCR1_val, TIM3_CCR2_val;
	uint16_t Freq_PA15, Freq_PB4;
	double Duty_PA15, Duty_PB4;

  在interrupt.c中添加输入捕获(IC)中断回调函数。

/**
  * @brief  输入捕获中断回调函数 
  */
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2) // IC signal from TIM2_CH1 (PA15, controlled by R40)
    {
        if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_2) {
            TIM2_CCR2_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
            HAL_TIM_IC_Start(htim, TIM_CHANNEL_2); // Restart CH2 IC
        }
        if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) {
            TIM2_CCR1_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
            __HAL_TIM_SetCounter(htim, 0); // Can be done by TRGO Reset?
            Freq_PA15 = (80000000 / 80) / TIM2_CCR1_val;
            Duty_PA15 = (double)TIM2_CCR2_val / (double)TIM2_CCR1_val * 100;
            HAL_TIM_IC_Start(htim, TIM_CHANNEL_1); // Restart CH1 IC
        }
    }
    if (htim->Instance == TIM3) // IC signal from TIM3_CH1 (PB4, controlled by R39)
    {
        if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_2) {
            TIM3_CCR2_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
            HAL_TIM_IC_Start(htim, TIM_CHANNEL_2); // Restart CH2 IC
        }
        if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) {

            TIM3_CCR1_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
            __HAL_TIM_SetCounter(htim, 0); // Can be done by TRGO Reset?

            Freq_PB4 = (80000000 / 80) / TIM3_CCR1_val;
            Duty_PB4 = (double)TIM3_CCR2_val / (double)TIM3_CCR1_val * 100;
            HAL_TIM_IC_Start(htim, TIM_CHANNEL_1); // Restart CH1 IC
        }
    }
}

  在这里记录笔者第一次编写这一块程序时遇到的一些Bug,供读者排错参考:

  • 进入中断函数后,首先需要判断是哪个通道的中断被触发了。如果是下降沿,则仅将CNT的值录入CCR2;如果是上升沿,则将CNT的值录入CCR1后计算占空比,并将CNT清零(在中断函数中添加CNT清零函数__HAL_TIM_SetCounter(htim, 0);有降低中断函数执行效率的嫌疑,在笔者之前的学习中,了解到可以通过定时器的主从触发模式使用TRGO触发CNT的Reset操作,但是笔者并没有查阅到在CubeMX中如何配置定时器参数使得定时器能通过硬件自动完成清零操作,日后查阅到相关操作细节再在此补充。如有读者了解如何在CubeMX中配置,敬请在评论区不吝告知,感激不尽!)。
  • 计算占空比时,存放结果的Duty_PXx变量是double型,其实也可以定义为float型。但是在做除法操作前一定要将整数强制转换为浮点数后再进行除法操作,否则占空比会得到一个恒为0的值(这里与C语言整数除法的规则有关,知识点不难,但极其容易被忽略)。
  • 在LCD显示中,需要注意定义输出浮点型数据的小数位,否则LCD屏幕可能造成卡顿或不显示的问题。具体可以参考以下代码。
void Display_Proc(void)
{
  char text[20];
  if (interface_num == 0)     // Display Interface 1
  {
    // LCD_Clear(Black);
    sprintf(text, "        DATA        ");
    LCD_DisplayStringLine(Line1, (uint8_t *)text);
    sprintf(text, "     Freq1:%dHz        ", Freq_PA15);
    LCD_DisplayStringLine(Line3, (uint8_t *)text);
    sprintf(text, "     Duty1:%.1lf%%        ", Duty_PA15);
    LCD_DisplayStringLine(Line4, (uint8_t *)text);
    sprintf(text, "     Freq2:%dHz        ", Freq_PB4);
    LCD_DisplayStringLine(Line5, (uint8_t *)text);
    sprintf(text, "     Duty2:%.1lf%%        ", Duty_PB4);
    LCD_DisplayStringLine(Line6, (uint8_t *)text);
  }
  if (interface_num == 1)     // Display Interface 2
  {
    // LCD_Clear(Black);
    sprintf(text, "        PARA        ");
    LCD_DisplayStringLine(Line1, (uint8_t *)text);
    sprintf(text, "    PA6:%d%%        ", PWM_PA6_Duty);
    LCD_DisplayStringLine(Line3, (uint8_t *)text);
    sprintf(text, "    PA7:%d%%        ", PWM_PA7_Duty);
    LCD_DisplayStringLine(Line4, (uint8_t *)text);
  }
}

2.6 ADC 模数转换

2.6.1 基础操作(单个ADC或不同ADC)

  ADC在蓝桥杯中的使用相较于其他模块比较简单。开发板上用于ADC输入的端口是R37和R38,这样ADC的输入脚就确定下来了。首先在CubeMX里完成配置,将ADC1的IN11和ADC2的IN15配置为单端模式(Single-ended):
在这里插入图片描述
在这里插入图片描述
  CubeMX中的配置完成了,接下来就可以点击GENERAGE CODE生成代码并在Keil 5中进行代码编写了。在bsp文件夹中创建以下两个文件并添加到工程中:

  • BSP_adc.h
#ifndef __BSP_ADC_H_
#define __BSP_ADC_H_

#include "main.h"

double BSP_ADC_GetValue(ADC_HandleTypeDef *hadc);

#endif

  • BSP_adc.c
#include "BSP_adc.h"

double BSP_ADC_GetValue(ADC_HandleTypeDef *hadc)
{
	uint16_t adc_value;
	HAL_ADC_Start(hadc);
	adc_value = HAL_ADC_GetValue(hadc);
	
	return (double)adc_value / 4096 * 3.3;
}

  在main.c文件中声明后,就可以使用BSP_ADC_GetValue(&hadc1/2)函数直接获取ADC的值了。

  关于ADC的使用有很多需要注意的地方和了解的概念,如果在蓝桥杯中出现了其他的概念和操作,笔者再进行补充。相关使用原理可以参考STM32学习笔记(八)丨ADC模数转换器(ADC单、双通道转换)

2.6.2 同个ADC的两个不同通道进行AD采集

  参考2022年第13届蓝桥杯嵌入式国赛题,题目一反常态,要求采用PA4和PA5进行AD采集,好巧不巧,这两个引脚的ADC均为ADC2,那么上面的方法就不适用了,需要用下面的方法进行双通道的AD采集。
在这里插入图片描述

  首先,还是在CubeMX中将两个通道(IN13和IN17)都定义为单端模式(Single-ended),打开扫描模式,并将转换通道数设置为2,接下来在ADC值获取函数中需要进行修改:

uint16_t PA4_adc_value, PA5_adc_value;
double PA4_Voltage, PA5_Voltage;

void BSP_ADC_GetValue(void)
{
  HAL_ADC_Start(&hadc2);
  
  // 获取IN13的值
  HAL_ADC_PollForConversion(&hadc2, 50);
  PA4_adc_value = HAL_ADC_GetValue(&hadc2);
  
  // 获取IN17的值
  HAL_ADC_PollForConversion(&hadc2, 50);
  PA5_adc_value = HAL_ADC_GetValue(&hadc2);
  
  HAL_ADC_Stop(&hadc2);
  
  PA4_Voltage = (double)PA4_adc_value / 4096 * 3.3;
  PA5_Voltage = (double)PA5_adc_value / 4096 * 3.3;
}

  如果要单独修改某个通道的ADC配置,例如采样时间和采样顺序等,也是可以在CubeMX中单独进行设置的:
在这里插入图片描述

2.7 I2C通信协议

2.7.1 EEPROM读写

  在熟悉I2C协议的基础上,通过官方给出的I2C库文件和相关芯片手册实现I2C读写还是比较简单的。官方给出的I2C引脚固定在了PB6和PB7引脚,是通过软件操作GPIO口模拟出的I2C,并没有使用STM32内部的I2C外设,在一定程度上降低了使用难度。所以关于I2C的重难点在于熟悉通信协议。相关通信原理可以参考STM32学习笔记(十)丨I2C通信(使用I2C实现MPU6050和STM32之间通信)。在比赛中如果忘记了,还可以参考AT24C02的参考手册中的读写时序:
在这里插入图片描述
在这里插入图片描述

  首先在CubeMX中使能PB6和PB7作为GPIO输出引脚,并设置为开漏输出(Output Open Drain)模式。
在这里插入图片描述
  点击GENERAGE CODE生成代码,将官方给的I2C库(HAL)移植到工程中。这里以EEPROM读写为例,直接在库文件的最后添加EEPROM读写函数:

void EEPROM_WriteByte(uint8_t addr, uint8_t data)
{
	I2CStart();
	I2CSendByte(0xA0);  // slave device address, write command
	I2CWaitAck();
	I2CSendByte(addr);  // byte address
	I2CWaitAck();
	I2CSendByte(data);
	I2CWaitAck();
	I2CStop();
}

uint8_t EEPROM_ReadByte(uint8_t addr)
{
	uint8_t data_temp;
	
	// dummy write 
	I2CStart();
	I2CSendByte(0xA0);
	I2CWaitAck();
	I2CSendByte(addr);
	I2CWaitAck();
	I2CStop();	// not necessary
	
	// read data
	I2CStart();
	I2CSendByte(0xA1);
	I2CWaitAck();
	data_temp = I2CReceiveByte();
	I2CSendNotAck();
	I2CStop();
	
	return data_temp;
}

  需要注意的是,由于ROM需要较长的写入时间,所以每次写入数据后,都需要延时一段时间:

if (key[3].key_isPressed == 1)
{
	LCD_Clear(Black);
	
	EEPROM_WriteByte(0x00, Freq_PA15 >> 8);
	HAL_Delay(10);
	EEPROM_WriteByte(0x01, Freq_PA15);
	HAL_Delay(10);
	
	key[3].key_isPressed = 0;
}

2.7.2 MCP4017数字电位器

不定期更新补充…

2.8 UART 串口通信

在这里插入图片描述
  首先在CubeMX中选择USART1作为串口使用(和主机通信只能选择USART1),并配置为异步模式(Asynchronous),根据赛题要求设置波特率(这里设置为9600)。之后就可以生成代码,在Keil 5中进行代码编写了。
  串口发送相对而言比较简单,下面是一段代码示例:

#include <stdio.h>
#include <string.h>

/* some code... */

void UART_Send(void)
{
  char send_data[20];
  sprintf(send_data, "Freq = %d\r\n", Freq_PA15);
  HAL_UART_Transmit(&huart1, (uint8_t *)send_data, strlen(send_data), 50);
}

  串口接收相较于串口发送难度较大,主要难点在于接收信息的解码和错误信息的处理操作。笔者在学习阶段了解到V1.4.0固件包已经支持了使用HAL_UART_Receive()函数进行阻塞式串口接收,但是实际用下来没有中断接收HAL_UART_Receive_IT()稳定。使用中断接收的缺点是每次只能接收一个字节,虽然中断接收函数中支持了接收长度的设置,但实际用起来非常不稳定,很容易造成程序跑飞的问题。相信官方提供这样的函数一定有他的道理和优势,笔者在这里先列出一个中断接收的例子,日后再对其他的串口接收函数进行学习和测试。
  首先在interrupt.c文件中添加串口中断接收的回调函数,并使用一些全局变量记录收到的数据(在头文件中声明为extern外部可调用变量),并判断数据是否接收完成(以第十二届蓝桥杯嵌入式真题为例,使用串口接收一些车辆信息的数据)。

char ReceiveString[22];
uint8_t RxByte;
uint8_t RxIndex;
uint8_t RxDoneFlag;

/**
  * @brief  串口中断回调函数
  */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
  ReceiveString[RxIndex ++] = RxByte;   // RxIndex plus after assignment, first Receive is in main function
  
  BSP_LED_Disp(0x01);
  if (RxByte == '\n' || RxIndex == 22)
  {
    RxDoneFlag = 1;
  }
  
  HAL_UART_Receive_IT(huart, &RxByte, 1);
}

之后在main.c中添加关于中断接收判断的函数,该函数需要放在主循环中重复执行。

#include <string.h>

/* Some code... */

/* USER CODE BEGIN PV */
char car_type[5];
char car_data[5];
char car_time[13];
/* USER CODE END PV */

/* Some code... */

void UART_Receive_Proc(void)
{
  if (RxDoneFlag == 1)
  {
    if (RxIndex == 22)
    {
      sscanf(ReceiveString, "%4s:%4s:%12s", car_type, car_data, car_time);
    }
    else
    {
      char send_data[20];
      sprintf(send_data, "Error\r\n");
      HAL_UART_Transmit(&huart1, (uint8_t *)send_data, strlen(send_data), 50);
    }
    RxIndex = 0;
    memset(ReceiveString, 0, 22);
    RxDoneFlag = 0;
  }
}

这里记录一些笔者在学习的时候遇到的调试问题(UART接收确实很不容易调试):

  1. 当需要接收车辆信息时,需要定义字符串数组。一定要将字符数组长度定义为需要接收字符串的长度+1。因为在sscanf()函数中接收的字符串需要以字符'\0'作为字符串的结束符。如果没有给结束符留出位置,可能会导致LCD屏幕显示错误的问题。
  2. 确认接收到的字符串的格式,灵活进行信息解码。这里笔者遇到的一大难点就是如何判断该条信息是否结束,是通过字符'\n'来判断接收完成,还是通过接收字符串的长度来判断接收是否完成?对于不同的串口接收格式,有不同的处理方式和处理思想。笔者认为这里是考察编程思想运用的一大难点。笔者在这里仅完成了接收信息长度错误的检测,相信实际考题中可能会出现接收数据超出范围、未知字符的检测等更加复杂的检测方式。
  3. 在上面的例子中,我将接收字符串数组的下标通过一个变量来记录,但实际上这样的操作是非常危险的。如果该下标超出了数组长度的定义范围,数组溢出后STM32会自动跳转到一个硬件错误函数中执行死循环操作。通过改变下标来操作数组其实相当于直接使用指针操作数据了,所以在使用中需要特别注意。解码完成后一定要及时将字符串数组的内容清空,否则同样可能会出现数组溢出的问题。

2.9 RTC 实时时钟

不定期更新补充…

三、赛题常用编程思路

3.1 按键实现界面切换

  很多赛题都要求最后的程序能在LCD显示不同的界面。通过定义两个函数,分别管理按键处理和现实界面的功能即可方便的完成按键切换界面的操作。需要特别注意的是,LCD的刷新操作需要放在页面上数据需要改变的地方,如果在每一次显示字符串前都刷新,则会造成LCD屏幕不断闪烁的问题。在这里,我将LCD屏幕刷新函数LCD_Clear(Black);放在了每次按键操作有效之前,解决了LCD屏幕不断闪烁的问题。

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */
void Key_Proc(void);
void Display_Proc(void);
/* USER CODE END PFP */

/* Some Code... */

/* USER CODE BEGIN 4 */
void Key_Proc(void)
{
  if (key[0].key_isPressed == 1)    // PB1
  {
    LCD_Clear(Black);

	/* Some code... */
    
    key[0].key_isPressed = 0;
  }
  if (key[1].key_isPressed == 1 && interface_num == 1)    // PB2, Display Interface 2
  {
    LCD_Clear(Black);

	/* Some code... */
    
    key[1].key_isPressed = 0;
  }
  if (key[2].key_isPressed == 1 && interface_num == 1)    // PB3, Display Interface 2
  {
    LCD_Clear(Black);

	/* Some code... */
    
    key[2].key_isPressed = 0;
  }
}

void Display_Proc(void)
{
  char text[20];
  if (interface_num == 0)     // Display Interface 1
  {
    // LCD_Clear(Black);
    sprintf(text, "        DATA        ");
    LCD_DisplayStringLine(Line1, (uint8_t *)text);
  }
  if (interface_num == 1)     // Display Interface 2
  {
    // LCD_Clear(Black);
    sprintf(text, "        PARA        ");
    LCD_DisplayStringLine(Line1, (uint8_t *)text);
    
  }
}
/* USER CODE END 4 */

3.2 定时器选择

  在开始赛题代码编写之前,就要明确要用到哪几个定时器。需要特别注意的有如下几点:

  • 首先根据PWM输出通道确定被占用的定时器:在赛题中,一般都会指定引脚作PWM的输出引脚或者PWM输入作频率和占空比测量的引脚,这些引脚和具体的定时器资源是确定的,所以这些引脚对应的定时器需要首先考虑,注意只有普通定时器(TIM2-5、TIM15-17)和高级定时器(TIM1、TIM8)才有PWM输出和边沿检测的功能;
  • 按键检测和消抖:在这里使用的定时器只需要能够实现定时中断的功能即可,基本上所有的定时器都可以完成这个功能。

3.3 中断回调函数

  在赛题中对中断回调函数的编写考察是很多的。需要注意不能在中断回调函数中执行太复杂的操作,例如更新LCD画面,强行延时等,但是很多操作又是需要定时进行的。更合理的操作是在中断函数中定义和操作相关的标志位,并在主循环中根据这些标志位进行相关的复杂操作。


  不定期更新完善中…


  原创笔记,码字不易,欢迎点赞,收藏~ 如有谬误敬请在评论区不吝告知,感激不尽!博主将持续更新有关嵌入式开发、FPGA方面的学习笔记。


  • 15
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Include everything

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值