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);
}

因此要确认两件事:

  1. 按键按下时输入是高电平还是低电平
  2. 蜂鸣器打开时需要高电平还是低电平

四、让蜂鸣器每隔五秒响一次

如果直接使用:

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();
}

程序逻辑是:

  1. 检查按键是否按下
  2. 获取当前系统时间
  3. 判断距离上次响铃是否已经超过五秒
  4. 如果超过五秒,让蜂鸣器响 50 毫秒
  5. 更新上一次响铃时间

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 或文本
  • 关闭自动添加回车
  • 关闭自动添加换行
  • 精确发送大写的 ONOFF

如果希望程序支持回车换行,就需要重新设计接收协议,以 \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 接反
  • 没有共地
  • 中断没有开启
  • 数组越界
  • 串口参数不一致
  • 在中断中执行过多阻塞代码

所以:

编译成功,只能说明语法和基本类型检查通过,
不能保证硬件连接和运行逻辑完全正确。

二十、这次学习的总结

这次主要学习了以下内容:

  1. CubeMX 生成代码和用户代码之间的区别
  2. USER CODE BEGINUSER CODE END 标记的作用
  3. GPIO 按键的上拉、下拉以及高低电平判断
  4. 按键控制蜂鸣器和 LED 的基本方法
  5. 使用 HAL_GetTick() 实现定时功能
  6. uint8_t RxBuff[3] 数组和串口接收缓冲区
  7. RxCount 计数变量的作用
  8. HAL_UART_Receive_IT() 中断接收函数
  9. HAL_UART_RxCpltCallback() 接收完成回调函数
  10. huart->Instance-> 运算符的含义
  11. strncmp() 字符串比较函数
  12. memset() 清空数组
  13. fputc() 重定向 printf() 到串口
  14. 自定义字符串发送函数的实现
  15. 数组越界和串口接收停止问题
  16. 串口助手自动添加回车换行造成的影响
  17. 阻塞式发送和中断、DMA 发送之间的区别

结语

STM32 学习不能只看代码能不能编译,还要同时关注:

  • 硬件电路是否正确
  • GPIO 的有效电平是什么
  • 中断是否正确配置
  • 数组访问是否越界
  • 串口参数是否匹配
  • 中断函数中是否执行了耗时操作

这次学习让我理解了一个完整的串口命令接收过程:

启动接收
    ↓
收到一个字节
    ↓
进入中断回调
    ↓
保存数据并更新计数
    ↓
判断是否是有效命令
    ↓
控制 LED 或蜂鸣器
    ↓
清空缓冲区
    ↓
重新开启接收

以后编写 STM32 程序时,应该养成一个习惯:不仅要让程序“能运行”,还要检查它是否存在越界、阻塞、逻辑反向和异常恢复等问题。


参考标签

STM32
STM32F103
HAL库
CubeMX
Keil
UART
串口通信
中断接收
GPIO
蜂鸣器
嵌入式开发
C语言
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐