0%

STM32——按键输入控制小灯亮灭

硬件原理

按键电路

开发板上的两个按键分别连接在以下引脚:

按键 引脚 信号 说明
KEY1 PB12 按下=低电平,松开=高电平 板载上拉电阻
KEY2 PB13 按下=低电平,松开=高电平 内部上拉(芯片内部准备好了,只需要在CubeMX中配置,无需外加电阻)

按键电路原理:一端接地,另一端通过上拉电阻连接到GPIO引脚。按下时引脚读到低电平,松开时读到高电平。

image-20260726221901495

LED 电路

LED 引脚 控制信号
🟢 绿色 PA7 GPIO 高电平亮
🔴 红色 PB0 GPIO 高电平亮

key_schematic


CubeMX 引脚配置

新建项目

芯片选择 STM32F103C8T6,步骤与之前一样,略。

配置 GPIO 引脚

一共四个引脚需要配置:两个LED(输出)、两个按键(输入)。

LED 引脚(输出):

引脚 功能 User Label 其他参数
PA7 GPIO_Output LED_GREEN 推挽输出、无上下拉、低速
PB0 GPIO_Output LED_RED 推挽输出、无上下拉、低速

led_output

led_output_2

按键引脚(输入):

引脚 功能 User Label 上下拉
PB12 GPIO_Input KEY1 无(板载已有上拉)
PB13 GPIO_Input KEY2 Pull-up(内部上拉)

pullup_setting

image-20260726222710822

两个关键点:

  1. KEY1(PB12)——板子自带了外部上拉电阻,所以 CubeMX 里 Pull 选 No pull
  2. KEY2(PB13)——这个引脚板子没有外部上拉,需要在 CubeMX 里把 Pull 设为 Pull-up,这样芯片内部会开启上拉电阻

pinout_overview

image-20260726222919932

配置调试接口

这一步绝对不能忘!——System Core → SYS → Debug → Serial Wire。否则烧录一次后 SWD 就被禁用了。

生成代码

Project Manager 设置:

设置项 值
Project Name KEY
Toolchain / IDE CMake

点击 Generate Code,用 VS Code 打开。


VS Code 中编写代码

认识 main.h 中的宏定义

CubeMX 根据 User Label 生成了以下宏:

1
2
3
4
5
6
7
8
9
10
11
// LED
#define LED_GREEN_Pin GPIO_PIN_7
#define LED_GREEN_GPIO_Port GPIOA
#define LED_RED_Pin GPIO_PIN_0
#define LED_RED_GPIO_Port GPIOB

// 按键
#define KEY1_Pin GPIO_PIN_12
#define KEY1_GPIO_Port GPIOB
#define KEY2_Pin GPIO_PIN_13
#define KEY2_GPIO_Port GPIOB

核心逻辑设计

这次要实现两种不同的按键逻辑:

按键 功能 实现方式
KEY1 按下→绿灯亮,松开→绿灯灭 每个循环读取 KEY1 电平,直接控制绿灯
KEY2 每按一次→红灯翻转(亮↔灭) 检测到按键后翻转红灯,等待松开再继续

main.c 代码

打开 main.c,在 while(1) 中编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* USER CODE BEGIN WHILE */
while (1)
{
// ===== KEY1:按下亮,松开灭 =====
if (HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_SET);
}
else
{
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
}

// ===== KEY2:按一下翻转红灯 =====
if (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET)
{
HAL_Delay(10); // 消抖
if (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_TogglePin(LED_RED_GPIO_Port, LED_RED_Pin);

// 等待按键松开
while (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET) {}
}
}
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */

代码讲解

用到的函数:

函数 作用
HAL_GPIO_ReadPin(端口, 引脚) 读取引脚电平:GPIO_PIN_SET(高)或 GPIO_PIN_RESET(低)
HAL_GPIO_WritePin(端口, 引脚, 电平) 设置引脚高/低电平
HAL_GPIO_TogglePin(端口, 引脚) 翻转引脚电平

KEY1 的逻辑——直接读取直接控制:

每次 while 循环都判断 KEY1 的电平。按下就读到低电平→亮,松开读到高电平→灭。反应很及时,适合开关类控制。

KEY2 的逻辑——边缘触发(按一下变一次):

KEY2 要的是”按一下变一次”,而不是”按住的时候一直变”。所以多了三步:

  1. 消抖——机械按键按下瞬间会有抖动(几ms内电平反复跳变),加 10ms 延时后再读一次确认,防止误触发
  2. 翻转——确认按下后执行一次 TogglePin
  3. 等待松手——用 while 循环等待按键恢复高电平,防止一次按压被 while 循环反复检测到,造成很短的时间内多次翻转,肉眼看上去效果很差。

解读 MX_GPIO_Init —— 输出与输入引脚的配置

这个项目有四种引脚(两个输出、两个输入),MX_GPIO_Init() 比前两篇复杂了不少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};

/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();

/* 初始电平:两个 LED 默认熄灭 */
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);

/* ========== 输出引脚配置(与前两篇相同) ========== */

/* LED_GREEN (PA7) —— 推挽输出 */
GPIO_InitStruct.Pin = LED_GREEN_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(LED_GREEN_GPIO_Port, &GPIO_InitStruct);

/* LED_RED (PB0) —— 推挽输出 */
GPIO_InitStruct.Pin = LED_RED_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(LED_RED_GPIO_Port, &GPIO_InitStruct);

/* ========== 输入引脚配置(新内容) ========== */

/* KEY1 (PB12) —— 输入,外部已有上拉,内部不配 */
GPIO_InitStruct.Pin = KEY1_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // ← 输入模式
GPIO_InitStruct.Pull = GPIO_NOPULL; // ← 外部已有上拉电阻
HAL_GPIO_Init(KEY1_GPIO_Port, &GPIO_InitStruct);

/* KEY2 (PB13) —— 输入,开启内部上拉 */
GPIO_InitStruct.Pin = KEY2_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // ← 输入模式
GPIO_InitStruct.Pull = GPIO_PULLUP; // ← 内部上拉!关键区别
HAL_GPIO_Init(KEY2_GPIO_Port, &GPIO_InitStruct);
}

新知识点:输入模式配置

输出引脚没什么新内容,重点是两个输入引脚的配置:

GPIO_MODE_INPUT —— 输入模式

之前两篇都是 GPIO_MODE_OUTPUT_PP(输出),这次改成了 GPIO_MODE_INPUT(输入)。输入模式下,GPIO 不再输出电压,而是读取外部信号的电平高低,通过 HAL_GPIO_ReadPin() 获取。

Pull 参数 —— 上下拉电阻的选择

这是本次最有价值的部分——两种按键的上拉电阻配置方式不同:

按键 Pull 参数 原理
KEY1 (PB12) GPIO_NOPULL 板子自带了外部上拉电阻,芯片内部不需要再配
KEY2 (PB13) GPIO_PULLUP 板子没有外部上拉,需要开启芯片内部上拉电阻

为什么需要上拉?看按键电路:

1
2
3
4
5
6
7
8
9
无上拉:  PB12 -------- 按键 ---- GND
│
悬空!→ 电平不确定,按下是低电平,松开就不知道了

有上拉: VCC 3.3V
│
电阻(10kΩ)
│
PB12 -------- 按键 ---- GND
  • 按键松开:上拉电阻把电平拉到 3.3V,读到高电平
  • 按键按下:引脚直接接 GND,读到低电平(电流经过按键到地,不走电阻)

如果没有上拉电阻,按键松开时引脚悬空,电平是浮动的,读到的值可能随机变化。

内部上拉 vs 外部上拉

对比 外部上拉 内部上拉
实现 板子上焊接电阻 芯片内部集成,通过软件配置开启
阻值 可按需选择(通常 4.7kΩ~10kΩ) 固定约 40kΩ(STM32F1)
优点 阻值精确、驱动能力强 省元件、省PCB空间
缺点 占PCB空间、增加BOM成本 阻值偏大、抗干扰稍弱

KEY1 使用外部上拉是因为外部上拉相比内部上拉能提供更稳定的高电平,而 KEY2 使用内部上拉则是为了节省板子空间和成本。

GPIO 模式表(更新版)

加上输入模式后,现在接触到的 GPIO 模式更丰富了:

模式 含义 本篇中的用途
GPIO_MODE_OUTPUT_PP 推挽输出 驱动 LED
GPIO_MODE_INPUT 数字输入 读取按键状态
GPIO_NOPULL 无上下拉 输出引脚 / 外部已有上拉的输入引脚
GPIO_PULLUP 内部上拉 没有外部上拉的输入引脚

编译 & 烧录

  1. VS Code 底部工具栏点击齿轮图标 → 生成
  2. 连接 ST-Link,点击运行和调试 → 选择 STM32Cube:STM32 Launch STLink GDB Server
  3. 点击继续按钮

如果遇到烧录问题(连不上芯片),参考波特律动 FAQ 的解决方法。


效果展示

烧录成功后:

  • 按下 KEY1 → 🟢 绿灯亮
  • 松开 KEY1 → 🟢 绿灯灭
  • 按一下 KEY2 → 🔴 红灯翻转(亮↔灭),每按一次变一次

效果展示视频链接:

https://www.bilibili.com/video/BV1dFg96MEJq/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa


进阶思路

当前代码的局限性

仔细观察 KEY2 的处理逻辑,会发现一个问题:按下 KEY2 还没松开的时候,程序卡死在 while 循环里了。

1
while (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET) {}

这行代码的意思是”在这里空转,直到 KEY2 松开”。在这期间:

  • ❌ KEY1 按了没反应(while 循环不检测 KEY1)
  • ❌ 其他任何外设都无法响应
  • ❌ 如果一直不松手,程序就一直卡着

这在只有两个按键、两个LED的 demo 里问题不大,但如果在实际项目里——比如同时要检测多个传感器、控制电机、处理通信——这种”阻塞式等待”的方式就行不通了。

怎么解决?

有以下几种方案,由简单到专业:

方案一:状态机 + 非阻塞延时

不用 while 傻等,而是用变量记录按键状态和时间,每次循环只检查”按键是不是还被按着”。这样即使 KEY2 没松开,main loop 也能去干别的事。

1
2
3
4
5
6
7
8
// 伪代码思路
if (key2_pressed && !key2_released) {
if (当前时间 - 按下时间 > 消抖时间 && 按键已松开) {
翻转红灯;
key2_pressed = false;
}
}
// 同时还能检测 KEY1、处理其他任务……

方案二:定时器轮询

把按键检测放到定时器中断里,每隔 10ms 采样一次电平,用状态机判断按下和松开。主循环完全解放出来做正事。

方案三:外部中断(EXTI)

最专业的方法。按键按下时触发中断,CPU 暂停当前任务来处理按键,处理完再回去。不需要任何轮询,效率最高。

这也是嵌入式开发的典型学习路径——先学会在 main loop 里轮询,遇到阻塞问题后,自然就会去学中断和定时器。


参考资源