收集整理了一份《2024年最新物联网嵌入式全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升的朋友。
需要这些体系化资料的朋友,可以加我V获取:vip1024c (备注嵌入式)
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人
都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
一、建立工程
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 (所以正式比赛时还是希望能使用最新的固件包):
- Keil的Pack Installer中找不到STM32G4系列器件。笔者练习时采用的方法是直接在官网下载安装对应的CMSIS器件包,目前还不清楚如果比赛过程中遇到这个问题该如何解决。
- 编译后提示报错:
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配置
- 打开STM32CubeMX,点击File下的
New Project…
,选择器件STM32G431RBT6后打开工程设置界面。 - 配置RCC,打开高速外部时钟。找到System Core下的RCC配置窗口,在High Speed Clock(HSE)窗口选择
Crystal/Ceramic Resonator
。
- 配置时钟树,输入时钟设置为
24
MHz(和CT1117E-M4开发板保持一致),PLL Source Mux选择HSE
,System Clock Mux选择PLLCLK
,HCLK输入80
后按回车(选择的值可以改变,但是例程中都是80,这里选80比较稳妥),CubeMX会自动计算完成对应配置。
- 更改SYS的Debug选项。找到System Core下的SYS窗口,将Debug设置为串行线
Serial Wire
,这样可以保证在烧录程序后不会出现烧录错误。
- 在Project Manager下的Project窗口中更改工程名,工程文件夹和IDE。注意工程名和文件夹路径都不能包含中文字符。
- 在下面的Mcu and Firmware Package窗口选择合适的固件开发包。目前固件开发包已经更新到V1.5.2的版本,但是蓝桥官方提供的固件包仍然是V1.2.0的版本(也有可能是其他版本)。既然已经自建工程了,我认为使用最新的固件包可能更好,所以在这里保持默认。如果出问题也可以使用回较老的版本。
- 在Code Generator中的Generated files勾选第一项
Generate peripheral initialization as a pair of '.c/.h' files per peripheral
,其余保持默认。 - 点击
GENERATE CODE
生成代码,打开工程。
1.2.2 Keil5配置
打开工程后,可以先编译一次,如果有问题就先解决Bug,没有问题就可以进行下面的配置。
- 点击魔术棒(工程选项)在Debug窗口下设置调试器为
CMSIS-DAP Debugger
。点击Settings,打开Flash Download窗口,勾选Reset and Run
选项。点击OK退出设置。
- 编译工程,确认没有错误没有警告,之后就可以开始编写代码了。
1.3 BSP(板级支持包)的建立
建立BSP(Board Support Package)是结构化编程的基础。
- 在工程文件夹中创建一个文件夹
bsp
。 - 打开Keil 5工程,添加bsp组(Group)到工程目录中。
- 在魔术棒选项中,找到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.c
,lcd.h
,fonts.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闪烁的问题,可以参考以下几点:
- 在官方给出的lcd库函数中找到
LCD_WriteReg()
、LCD_WriteRAM_Prepare()
、LCD_WriteRAM()
三个函数,在三个函数体开始暂存GPIOC->ODR
的值,并在函数结束后恢复。- 一定要确保在不操作LED时PD2处于低电平状态。
2.3 按键模块
2.3.1 短按识别
蓝桥杯赛题中程序任务颇多,不能通过传统的暴力延时来消抖,所以需要通过定时扫描按键实现消抖。这里涉及STM32定时器TIM的使用,可以参考博主的博客 STM32学习笔记(四)丨TIM定时器及其应用(定时中断、内外时钟源选择)了解TIM的定时器的工作原理,这里仅对CubeMX配置定时器的步骤作一个简要介绍。
- 根据产品手册配置相关的GPIO引脚为GPIO_Input模式,并将上下拉模式设置为上拉(Pull-Up);
- 激活定时器。由于这里仅仅需要简单的定时中断功能,所以使用一个通用定时器即可。在这里使用TIM3。配置时需要设置时钟源(基本定时器时钟源只能是内部时钟)、设置PSC和ARR,打开NVIC中断。
- CubeMX中的配置完成,生成代码,打开工程。在bsp文件夹中创建两个文件
interrupt.c
和interrupt.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);
- 编写中断回调函数。在函数
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
的不同阶段执行判断逻辑即可。
judge_state == 0
:计数变量清零。judge_state == 1
:消抖,同时将按键按下标志位赋值交给下一个状态。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 双击识别(区分短按、长按、双击)
双击主要的编程思路如下:
- 在循环状态机外首先判断双击计时器使能是否打开,如果打开,则开始计时。同时判断如果超时(没有发现紧跟的第二次短按),则判定此次按下为短按,关闭计时器使能。
- 在长短按检测时(
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里完成配置:
- 使能定时器,选择CH1为PWM生成模式(PWM Generation CH1);
- 设置预分频系数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%
- 可以使用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
收集整理了一份《2024年最新物联网嵌入式全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升的朋友。
需要这些体系化资料的朋友,可以加我V获取:vip1024c (备注嵌入式)
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人
都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
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):
![在这里插入图片描述](https://img-blog.csdnimg.cn/direct/a0facf48f8464764a3335cc8a4a86515.png#pic_center)
![在这里插入图片描述](https://img-blog.csdnimg.cn/direct/4f4b11feb5fe4779bad593bdc3adc56e.png#pic_center)
CubeMX中的配置完成了,接下来就可以点击`GENERAGE CODE`生成代码并在Keil 5中进行代码编写了。在bsp文件夹中创建以下两个文件并添加到工程中:
* `BSP_adc.h`
**收集整理了一份《2024年最新物联网嵌入式全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升的朋友。**
[外链图片转存中...(img-l7AUaWBv-1715797670734)]
[外链图片转存中...(img-acYeNKQz-1715797670735)]
**[如果你需要这些资料,可以戳这里获取](https://bbs.csdn.net/topics/618679757)**
**需要这些体系化资料的朋友,可以加我V获取:vip1024c (备注嵌入式)**
**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人**
**都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**