STM32 HAL 入门实战:GPIO按键、蜂鸣器控制与UART串口中断接收详解
STM32 HAL 学习记录:GPIO 按键、蜂鸣器与 UART 串口中断接收
前言
最近学习 STM32F103 的过程中,接触到了 GPIO、按键、蜂鸣器、LED、UART 串口通信、中断接收以及 CubeMX 自动生成代码。
一开始看代码时,很多内容都比较陌生,例如:
uint8_t RxBuff[3] = {0};
huart->Instance
HAL_UART_RxCpltCallback()
HAL_UART_Receive_IT()
这些代码看起来比较复杂,但拆开以后可以发现,它们分别负责:
- 保存串口接收到的数据
- 记录已经接收了多少个字节
- 在串口接收完成后自动执行处理函数
- 判断收到的命令是
ON还是OFF - 控制 LED 或蜂鸣器
- 重新开启下一次串口接收
本文记录一下这次学习到的主要知识点。
一、CubeMX 生成代码和自己编写代码的区别
使用 STM32CubeMX 创建 HAL 工程后,程序中经常可以看到这样的代码:
/* USER CODE BEGIN PV */
/* USER CODE END PV */
或者:
/* USER CODE BEGIN 4 */
/* USER CODE END 4 */
这些注释不是 C 语言语法,而是 CubeMX 使用的代码标记。
CubeMX 会根据配置自动生成很多代码,例如:
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init();
这些代码主要负责:
- 初始化 HAL 库
- 配置系统时钟
- 初始化 GPIO
- 初始化 USART1
- 配置中断和外设寄存器
而我们自己的业务代码,应该写在 USER CODE 区域内。
例如:
/* USER CODE BEGIN PV */
uint8_t RxBuff[3] = {0};
uint8_t RxCount = 0;
/* USER CODE END PV */
常见 USER CODE 区域
| 区域 | 常见用途 |
|---|---|
USER CODE BEGIN Includes |
添加头文件 |
USER CODE BEGIN PD |
添加宏定义 |
USER CODE BEGIN PV |
添加全局变量 |
USER CODE BEGIN PFP |
添加函数声明 |
USER CODE BEGIN 0 |
添加普通函数 |
USER CODE BEGIN 2 |
初始化外设后执行一次的代码 |
USER CODE BEGIN WHILE |
主循环中的代码 |
USER CODE BEGIN 4 |
添加回调函数和自定义函数 |
如果把自己的代码写在正确的 USER CODE 区域中,CubeMX 重新生成代码时,一般可以保留这些内容。
二、GPIO 按键输入:上拉和下拉
按键电路主要有两种方式:
- 上拉输入
- 下拉输入
1. 上拉输入
上拉输入的特点是:
- 按键没有按下时,GPIO 读取到高电平
- 按键按下时,GPIO 被接到 GND,读取到低电平
程序通常这样判断:
if (HAL_GPIO_ReadPin(GPIOA, K1_Pin) == GPIO_PIN_RESET)
{
// 按键被按下
}
也就是:
未按下:GPIO_PIN_SET
按下:GPIO_PIN_RESET
2. 下拉输入
下拉输入的特点是:
- 按键没有按下时,GPIO 读取到低电平
- 按键按下时,GPIO 被接到 VCC,读取到高电平
程序通常这样判断:
if (HAL_GPIO_ReadPin(GPIOA, K1_Pin) == GPIO_PIN_SET)
{
// 按键被按下
}
也就是:
未按下:GPIO_PIN_RESET
按下:GPIO_PIN_SET
3. 如何判断自己的电路是上拉还是下拉
最直接的方法是使用万用表或者直接观察程序读取结果:
if (HAL_GPIO_ReadPin(GPIOA, K1_Pin) == GPIO_PIN_RESET)
{
// 认为按键按下
}
如果按键不按时程序一直进入这个条件,说明判断逻辑可能写反了。
判断 GPIO 按键时,不能只看程序,还要结合实际电路。
三、按键控制蜂鸣器
例如,按键按下时打开蜂鸣器:
if (HAL_GPIO_ReadPin(GPIOA, K1_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_WritePin(Beep_GPIO_Port,
Beep_Pin,
GPIO_PIN_SET);
}
else
{
HAL_GPIO_WritePin(Beep_GPIO_Port,
Beep_Pin,
GPIO_PIN_RESET);
}
这段代码的意思是:
- 如果检测到按键按下,就输出高电平打开蜂鸣器
- 如果检测到按键松开,就输出低电平关闭蜂鸣器
但是,蜂鸣器模块也可能是低电平有效。
如果蜂鸣器是低电平有效,逻辑就应该反过来:
if (HAL_GPIO_ReadPin(GPIOA, K1_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_WritePin(Beep_GPIO_Port,
Beep_Pin,
GPIO_PIN_RESET);
}
else
{
HAL_GPIO_WritePin(Beep_GPIO_Port,
Beep_Pin,
GPIO_PIN_SET);
}
因此要确认两件事:
- 按键按下时输入是高电平还是低电平
- 蜂鸣器打开时需要高电平还是低电平
四、让蜂鸣器每隔五秒响一次
如果直接使用:
HAL_Delay(5000);
程序会阻塞五秒,期间主循环中的其他代码无法正常执行。
更推荐使用 HAL_GetTick() 获取系统运行时间。
uint32_t BeepTick = 0;
在主循环中:
if (HAL_GPIO_ReadPin(GPIOA, K1_Pin) == GPIO_PIN_RESET)
{
if (HAL_GetTick() - BeepTick >= 5000)
{
HAL_GPIO_WritePin(Beep_GPIO_Port,
Beep_Pin,
GPIO_PIN_SET);
HAL_Delay(50);
HAL_GPIO_WritePin(Beep_GPIO_Port,
Beep_Pin,
GPIO_PIN_RESET);
BeepTick = HAL_GetTick();
}
}
else
{
HAL_GPIO_WritePin(Beep_GPIO_Port,
Beep_Pin,
GPIO_PIN_RESET);
BeepTick = HAL_GetTick();
}
程序逻辑是:
- 检查按键是否按下
- 获取当前系统时间
- 判断距离上次响铃是否已经超过五秒
- 如果超过五秒,让蜂鸣器响 50 毫秒
- 更新上一次响铃时间
HAL_GetTick() 返回的是系统启动以来经过的毫秒数。
例如:
HAL_GetTick() = 1000
表示系统运行了大约 1000 毫秒。
五、uint8_t RxBuff[3] = {0}; 是什么意思
代码如下:
uint8_t RxBuff[3] = {0};
可以拆成三部分理解。
1. uint8_t
uint8_t 是一个 8 位无符号整数,范围是:
0~255
一个 UART 字节正好是 8 位,所以经常使用 uint8_t 保存串口数据。
2. RxBuff[3]
表示定义了一个长度为 3 的数组:
RxBuff[0]
RxBuff[1]
RxBuff[2]
有效下标只有:
0、1、2
不能访问:
RxBuff[3];
因为 RxBuff[3] 已经超出数组范围。
3. = {0}
表示数组初始化为 0:
RxBuff[0] = 0
RxBuff[1] = 0
RxBuff[2] = 0
这三个字节可以用来接收:
ON
或者:
OFF
六、RxCount 的作用
代码如下:
uint8_t RxCount = 0;
RxCount 表示已经接收了多少个字节。
例如接收字符串 OFF:
| 接收到的字符 | 写入位置 | RxCount |
|---|---|---|
O |
RxBuff[0] |
1 |
F |
RxBuff[1] |
2 |
F |
RxBuff[2] |
3 |
每接收完成一个字节,就执行:
RxCount++;
当一条命令处理完成后,需要清零:
RxCount = 0;
这样下一条命令才会从 RxBuff[0] 开始保存。
七、UART 中断接收流程
启动第一次接收:
HAL_UART_Receive_IT(&huart1, RxBuff, 1);
这个函数的含义是:
使用 USART1
接收 1 个字节
把数据保存到 RxBuff
接收完成后进入中断回调
参数说明:
HAL_UART_Receive_IT(
&huart1, // USART1 句柄地址
RxBuff, // 接收数据保存的位置
1 // 本次接收 1 个字节
);
这个函数只负责安排一次接收,并不是永久接收。
因此,在回调函数的末尾,还要重新调用:
HAL_UART_Receive_IT(&huart1,
&RxBuff[RxCount],
1);
程序的完整流程是:
主函数启动第一次接收
↓
串口收到一个字节
↓
HAL 自动调用回调函数
↓
RxCount 加一
↓
判断是否组成 ON 或 OFF
↓
控制 LED 或其他外设
↓
重新启动下一次接收
如果不在回调函数末尾重新启动接收,程序通常只能收到第一个字节。
八、HAL_UART_RxCpltCallback() 回调函数
典型代码如下:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
RxCount++;
if (RxCount == 2 &&
strncmp((char *)RxBuff, "ON", 2) == 0)
{
HAL_GPIO_WritePin(GPIOA,
LED2_Pin | LED1_Pin,
GPIO_PIN_RESET);
RxCount = 0;
memset(RxBuff, 0, sizeof(RxBuff));
}
if (RxCount == 3 &&
strncmp((char *)RxBuff, "OFF", 3) == 0)
{
HAL_GPIO_WritePin(GPIOA,
LED2_Pin | LED1_Pin,
GPIO_PIN_SET);
RxCount = 0;
memset(RxBuff, 0, sizeof(RxBuff));
}
HAL_UART_Receive_IT(&huart1,
&RxBuff[RxCount],
1);
}
}
1. 函数名为什么固定
HAL_UART_RxCpltCallback()
这是 HAL 库规定的回调函数名称。
当 UART 中断接收完成时,HAL 库会自动调用这个函数。
虽然函数名称是 HAL 规定的,但是函数里面的具体处理逻辑是我们自己编写的。
2. huart->Instance 是什么意思
huart->Instance
huart 是一个指向结构体的指针。
-> 表示通过结构体指针访问结构体成员。
下面两种写法含义相同:
huart->Instance
(*huart).Instance
因此:
if (huart->Instance == USART1)
表示判断这次回调是不是由 USART1 产生的。
如果工程中同时使用 USART1 和 USART2,就可以避免不同串口之间的逻辑混乱。
九、strncmp() 字符串比较
代码如下:
strncmp((char *)RxBuff, "ON", 2) == 0
它的作用是比较接收到的内容是否为 ON。
函数格式:
strncmp(字符串1, 字符串2, 比较长度);
这行代码可以拆成:
(char *)RxBuff
把 RxBuff 转换成字符指针,方便字符串函数使用。
"ON"
表示要比较的目标字符串。
2
表示只比较前两个字符。
== 0
strncmp() 返回 0 表示两个字符串相同。
所以:
strncmp((char *)RxBuff, "ON", 2) == 0
完整意思是:
如果 RxBuff 前两个字符是 ON,就执行后面的代码
类似地:
strncmp((char *)RxBuff, "OFF", 3) == 0
表示比较前三个字符是否为 OFF。
十、memset() 清空接收缓冲区
代码如下:
memset(RxBuff, 0, sizeof(RxBuff));
作用是把整个 RxBuff 数组清零。
RxBuff[0] = 0
RxBuff[1] = 0
RxBuff[2] = 0
sizeof(RxBuff) 可以自动得到数组所占的字节数。
当前:
sizeof(RxBuff) = 3
使用 sizeof() 比直接写数字更安全。
如果以后把数组改成:
uint8_t RxBuff[10];
那么:
sizeof(RxBuff)
会自动变成 10。
十一、原程序存在的数组越界问题
原程序的接收代码存在一个重要问题。
数组定义为:
uint8_t RxBuff[3] = {0};
有效位置只有:
RxBuff[0]
RxBuff[1]
RxBuff[2]
但是,如果收到三个错误字符,例如:
ABC
执行过程可能是:
收到 A:RxCount = 1,下一次写 RxBuff[1]
收到 B:RxCount = 2,下一次写 RxBuff[2]
收到 C:RxCount = 3,下一次写 RxBuff[3]
这时程序会访问:
&RxBuff[3]
但 RxBuff[3] 并不存在。
这就是数组越界。
数组越界不一定会产生编译错误,但运行时可能出现:
- 串口接收突然停止
- LED 控制异常
- 变量值被破坏
- 程序跑飞
- 单片机进入异常状态
因此,在重新接收之前必须判断 RxCount 是否仍然在有效范围内。
十二、安全改进版 UART 接收代码
下面的代码保留原来的设计思路,只增加越界保护:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
RxCount++;
if (RxCount == 2 &&
strncmp((char *)RxBuff, "ON", 2) == 0)
{
HAL_GPIO_WritePin(GPIOA,
LED2_Pin | LED1_Pin,
GPIO_PIN_RESET);
RxCount = 0;
memset(RxBuff, 0, sizeof(RxBuff));
}
else if (RxCount == 3 &&
strncmp((char *)RxBuff, "OFF", 3) == 0)
{
HAL_GPIO_WritePin(GPIOA,
LED2_Pin | LED1_Pin,
GPIO_PIN_SET);
RxCount = 0;
memset(RxBuff, 0, sizeof(RxBuff));
}
else if (RxCount >= sizeof(RxBuff))
{
/*
* 3 个字节仍然没有匹配成功,
* 丢弃当前错误命令,防止数组越界
*/
RxCount = 0;
memset(RxBuff, 0, sizeof(RxBuff));
}
HAL_UART_Receive_IT(&huart1,
&RxBuff[RxCount],
1);
}
}
这段代码的关键点是:
else if (RxCount >= sizeof(RxBuff))
当收到三个字符但仍然不是正确命令时,立即清空缓冲区,重新从头接收。
这样就不会访问:
RxBuff[3]
十三、串口助手自动添加回车换行的问题
很多串口助手在发送 ON 时,实际发送的是:
ON\r\n
也就是:
O
N
回车
换行
但是当前程序只识别:
ON
识别到 ON 后,程序已经清空了缓冲区。随后接收到的回车和换行可能会被当成下一条命令的内容。
调试时建议:
- 发送格式选择 ASCII 或文本
- 关闭自动添加回车
- 关闭自动添加换行
- 精确发送大写的
ON或OFF
如果希望程序支持回车换行,就需要重新设计接收协议,以 \r\n 作为一条命令的结束标志。
十四、使用 fputc() 让 printf() 从串口输出
为了让 printf() 从 USART1 输出,可以重定向 fputc():
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1,
(uint8_t *)&ch,
1,
HAL_MAX_DELAY);
return ch;
}
标准 C 库中的:
printf()
最终会逐个字符调用:
fputc()
因此,重新实现 fputc() 后,就可以把每个字符发送到 USART1。
调用:
printf("LED ON\r\n");
程序就会把这段文字通过 USART1 发出去。
fputc() 参数含义
| 代码 | 含义 |
|---|---|
int ch |
当前要发送的字符 |
FILE *f |
标准 C 库要求的文件流参数 |
&huart1 |
使用 USART1 发送 |
(uint8_t *)&ch |
把字符地址转换成 HAL 需要的类型 |
1 |
发送 1 个字节 |
HAL_MAX_DELAY |
一直等待,直到发送完成 |
return ch |
返回已经发送的字符 |
需要注意,HAL_MAX_DELAY 可能会让程序一直等待。如果串口发生故障,程序可能长时间卡在发送函数中。
另外,不建议在 UART 接收中断回调里大量使用 printf(),因为 printf() 是阻塞式发送,会延长中断执行时间。
十五、自定义字符串发送函数
我们还学习了下面这个函数:
void USART_SendString(UART_HandleTypeDef *huart, char *str)
{
while (*str)
{
HAL_UART_Transmit(&huart1,
(uint8_t *)str,
1,
HAL_MAX_DELAY);
str++;
}
}
它的作用是逐字节发送一个字符串。
例如调用:
USART_SendString(&huart1, "Hello\r\n");
程序会依次发送:
H
e
l
l
o
回车
换行
while (*str)
这句话等价于:
while (*str != '\0')
字符串的最后有一个特殊结束符:
'\0'
当指针指向 '\0' 时,循环结束。
str++
str 是字符指针。
执行:
str++;
表示指针向后移动一个字符。
指针移动过程:
H -> e -> l -> l -> o -> \0
十六、字符串发送函数中的问题
虽然函数有一个参数:
UART_HandleTypeDef *huart
但是原代码实际发送时写的是:
HAL_UART_Transmit(&huart1, ...);
这意味着即使调用:
USART_SendString(&huart2, "Hello");
数据仍然会从 USART1 发送。
正确写法应该使用传入的 huart:
HAL_UART_Transmit(huart,
(uint8_t *)str,
1,
HAL_MAX_DELAY);
另外,函数只是读取字符串,不会修改字符串,因此参数最好写成:
const char *str
推荐版本如下:
HAL_StatusTypeDef USART_SendString(UART_HandleTypeDef *huart,
const char *str)
{
if (huart == NULL || str == NULL)
{
return HAL_ERROR;
}
while (*str != '\0')
{
if (HAL_UART_Transmit(huart,
(uint8_t *)str,
1,
100) != HAL_OK)
{
return HAL_ERROR;
}
str++;
}
return HAL_OK;
}
调用方式:
if (USART_SendString(&huart1, "LED ON\r\n") != HAL_OK)
{
Error_Handler();
}
十七、一次性发送整个字符串
逐字节发送比较容易理解,但是效率相对较低。
也可以使用 strlen() 获取字符串长度,然后一次性发送:
#include <string.h>
void USART_SendString(UART_HandleTypeDef *huart,
const char *str)
{
HAL_UART_Transmit(huart,
(uint8_t *)str,
strlen(str),
1000);
}
这种写法会一次性把整个字符串交给 HAL 库发送。
但是它仍然属于阻塞式发送,数据量较大时仍然会占用 CPU。
如果需要发送大量数据,更适合使用:
- UART 中断发送
- UART DMA 发送
- 环形缓冲区
- RTOS 消息队列
十八、串口没有输出时的检查方法
如果程序编译没有错误,但串口助手没有显示内容,可以依次检查以下内容。
1. 检查串口接线
USART1 常见引脚:
PA9 = USART1_TX
PA10 = USART1_RX
USB-TTL 接线应该交叉连接:
STM32 PA9 TX -> USB-TTL RX
STM32 PA10 RX -> USB-TTL TX
STM32 GND -> USB-TTL GND
2. 检查串口参数
常见配置:
波特率:115200
数据位:8
校验位:无
停止位:1
串口助手的设置必须和 CubeMX 中的 USART 配置一致。
3. 检查中断配置
需要确认:
- USART1 全局中断已经启用
stm32f1xx_it.c中存在 USART1 中断处理函数- 中断处理函数调用了
HAL_UART_IRQHandler(&huart1)
4. 检查首次接收是否启动
初始化串口之后,必须执行:
HAL_UART_Receive_IT(&huart1, RxBuff, 1);
如果没有启动第一次接收,回调函数不会执行。
5. 检查回调函数是否重新启动接收
回调结束时需要再次调用:
HAL_UART_Receive_IT(&huart1,
&RxBuff[RxCount],
1);
否则只能接收到一次数据。
6. 检查中文编码
如果串口显示乱码,可能是:
- 源文件使用 UTF-8 编码
- 编译器按照 GBK 编码处理
- 串口助手使用了另一种编码
调试时建议先发送英文:
printf("USART1 OK\r\n");
先确认串口通信正常,再处理中文编码问题。
十九、编译没有错误不代表程序一定正确
STM32 程序出现问题时,需要区分:
编译错误
例如:
- 拼写错误
- 少了分号
- 函数参数类型不匹配
- 变量没有声明
- 函数重复定义
运行错误
例如:
- GPIO 高低电平逻辑写反
- 按键上拉和下拉判断错误
- 串口 TX、RX 接反
- 没有共地
- 中断没有开启
- 数组越界
- 串口参数不一致
- 在中断中执行过多阻塞代码
所以:
编译成功,只能说明语法和基本类型检查通过,
不能保证硬件连接和运行逻辑完全正确。
二十、这次学习的总结
这次主要学习了以下内容:
- CubeMX 生成代码和用户代码之间的区别
USER CODE BEGIN和USER CODE END标记的作用- GPIO 按键的上拉、下拉以及高低电平判断
- 按键控制蜂鸣器和 LED 的基本方法
- 使用
HAL_GetTick()实现定时功能 uint8_t RxBuff[3]数组和串口接收缓冲区RxCount计数变量的作用HAL_UART_Receive_IT()中断接收函数HAL_UART_RxCpltCallback()接收完成回调函数huart->Instance中->运算符的含义strncmp()字符串比较函数memset()清空数组fputc()重定向printf()到串口- 自定义字符串发送函数的实现
- 数组越界和串口接收停止问题
- 串口助手自动添加回车换行造成的影响
- 阻塞式发送和中断、DMA 发送之间的区别
结语
STM32 学习不能只看代码能不能编译,还要同时关注:
- 硬件电路是否正确
- GPIO 的有效电平是什么
- 中断是否正确配置
- 数组访问是否越界
- 串口参数是否匹配
- 中断函数中是否执行了耗时操作
这次学习让我理解了一个完整的串口命令接收过程:
启动接收
↓
收到一个字节
↓
进入中断回调
↓
保存数据并更新计数
↓
判断是否是有效命令
↓
控制 LED 或蜂鸣器
↓
清空缓冲区
↓
重新开启接收
以后编写 STM32 程序时,应该养成一个习惯:不仅要让程序“能运行”,还要检查它是否存在越界、阻塞、逻辑反向和异常恢复等问题。
参考标签
STM32
STM32F103
HAL库
CubeMX
Keil
UART
串口通信
中断接收
GPIO
蜂鸣器
嵌入式开发
C语言
更多推荐



所有评论(0)