目录
- 前言
- 一、多按键与多LED案例的硬件配置与CubeMX设置
-
- [1.1 硬件原理图深度解析](#1.1 硬件原理图深度解析)
-
- [1.1.1 LED电路连接分析](#1.1.1 LED电路连接分析)
- [1.1.2 按键电路连接分析](#1.1.2 按键电路连接分析)
- [1.2 STM32CubeMX 工程配置实战](#1.2 STM32CubeMX 工程配置实战)
-
- [1.2.1 LED引脚配置(Output模式)](#1.2.1 LED引脚配置(Output模式))
- [1.2.2 按键引脚配置(Input模式)](#1.2.2 按键引脚配置(Input模式))
- 二、软件架构设计与BSP层封装
-
- [2.1 枚举类型的定义与映射](#2.1 枚举类型的定义与映射)
-
- [2.1.1 LED枚举定义](#2.1.1 LED枚举定义)
- [2.1.2 按键枚举定义](#2.1.2 按键枚举定义)
- [2.2 LED控制函数封装](#2.2 LED控制函数封装)
-
- [2.2.1 通用控制函数 BSP_LED_Control](#2.2.1 通用控制函数 BSP_LED_Control)
- [2.2.2 便捷开关函数](#2.2.2 便捷开关函数)
- 三、按键检测逻辑与消抖算法实现
-
- [3.1 基础版:未消抖检测 (Version 1)](#3.1 基础版:未消抖检测 (Version 1))
- [3.2 进阶版:软件延时消抖 (Version 2)](#3.2 进阶版:软件延时消抖 (Version 2))
- [3.3 主循环中的并列检测逻辑](#3.3 主循环中的并列检测逻辑)
- [3.4 输入源码深度解析:从CubeMX生成到寄存器底层逻辑](#3.4 输入源码深度解析:从CubeMX生成到寄存器底层逻辑)
-
- [3.4.1 GPIO 输入读取的核心逻辑](#3.4.1 GPIO 输入读取的核心逻辑)
- [3.4.2 按键引脚配置:浮空输入模式](#3.4.2 按键引脚配置:浮空输入模式)
- [3.4.3 从 HAL 库到寄存器的底层映射](#3.4.3 从 HAL 库到寄存器的底层映射)
-
- [3.4.3.1 宏定义的数值含义](#3.4.3.1 宏定义的数值含义)
- [3.4.3.2 HAL 库内部的配置逻辑](#3.4.3.2 HAL 库内部的配置逻辑)
- [3.4.3.3 寄存器位的组合与操作](#3.4.3.3 寄存器位的组合与操作)
- [3.5 调试避坑指南:为什么看不到初始化效果?](#3.5 调试避坑指南:为什么看不到初始化效果?)
- 结语


🎬 云泽Q :个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32》《Python》
⛺️遇见安然遇见你,不负代码不负卿~
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、多按键与多LED案例的硬件配置与CubeMX设置
1.1 硬件原理图深度解析
该文衔接上一篇的案例三:多路联动的软件工程化封装,本次案例的核心任务是从单按键单LED扩展到四按键四LED的控制。
1.1.1 LED电路连接分析
首先来看LED部分的电路设计。在原理图的LEDs区域,我们可以清晰地看到四个发光二极管的连接方式:

- LED4(红色) :对应网络标号
SYS_LED1,连接电阻 R47 (680Ω),最终连接到芯片的 PF8 引脚。 - LED5(绿色) :对应网络标号
SYS_LED2,连接电阻 R49 (680Ω),最终连接到芯片的 PF9 引脚。 - LED7(黄色) :对应网络标号
SYS_LED3,连接电阻 R52 (680Ω),最终连接到芯片的 PF10 引脚。 - LED8(蓝色) :对应网络标号
SYS_LED4,连接电阻 R55 (680Ω),最终连接到芯片的 PF11 引脚。
这里需要特别注意一个细节:虽然板子上的丝印可能是LED4、LED5等,但在我们的软件逻辑映射中,它们分别对应系统定义的 SYS_LED1 到 SYS_LED4。这四个引脚(PF8-PF11)在电气特性上,采用开漏输出模式。这意味着,当我们输出低电平(0)时,LED点亮;输出高电平(1)时,LED熄灭。
1.1.2 按键电路连接分析
接下来看按键部分。原理图左下角展示了四个按键(SW6-SW9)的连接逻辑:
- SW6 :对应网络标号
SYS_SW1,连接到芯片的 PG10 引脚。 - SW7 :对应网络标号
SYS_SW2,连接到芯片的 PG6 引脚。 - SW8 :对应网络标号
SYS_SW3,连接到芯片的 PG8 引脚。 - SW9 :对应网络标号
SYS_SW4,连接到芯片的 PG7 引脚。
从电路结构上看,这些按键的一端接地(GND),另一端连接MCU引脚。同时,每个按键旁边都并联了一个10kΩ的上拉电阻(如R43-R46)和一个100nF的电容(如C51-C55)。
- 上拉电阻的作用:确保在按键未按下(开关断开)时,引脚被拉高到3.3V,读取到的电平为高电平(1)。
- 电容的作用:配合电阻形成RC滤波电路,辅助硬件消抖,滤除高频干扰。
- 逻辑电平:当按键按下(开关闭合)时,引脚直接接地,读取到的电平为低电平(0)。
1.2 STM32CubeMX 工程配置实战
理清了硬件连接后,我们打开STM32CubeMX进行具体的引脚配置。这一步是将物理连接转化为软件驱动的关键。

1.2.1 LED引脚配置(Output模式)
我们需要将PF8、PF9、PF10、PF11这四个引脚配置为GPIO输出模式。具体操作如下:
- 在芯片视图中找到 PF8 ,左键点击选择
GPIO_Output。 - 同样的方法,依次将 PF9 、PF10 、PF11 设置为
GPIO_Output。 - 进入
System Core->GPIO配置页,对这四个引脚进行详细参数设置:- GPIO output level :设置为
High(高电平)。因为我们的LED是低电平点亮,初始化时给高电平可以让灯默认处于熄灭状态,避免上电瞬间闪烁。 - GPIO mode :设置为
Output Open Drain(开漏输出)。 - GPIO Pull-up/Pull-down :设置为
No pull-up and no pull-down(无上下拉)。 - Maximum output speed :设置为
Low(低速)。LED控制不需要高速翻转,低速有助于降低EMI干扰。
- GPIO output level :设置为
1.2.2 按键引脚配置(Input模式)
接下来配置四个按键对应的引脚:PG6、PG7、PG8、PG10。
- 找到 PG6 ,左键点击选择
GPIO_Input。 - 依次将 PG7 、PG8 、PG10 设置为
GPIO_Input。注意,有些版本的CubeMX可能在点击时反应迟钝,需要多点几次或者确认选中。 - 进入
GPIO配置页检查参数:- GPIO mode :确认为
Input mode(输入模式)。 - GPIO Pull-up/Pull-down :这里硬件有上拉,
No pull-up。
- GPIO mode :确认为
配置完成后,点击 GENERATE CODE 生成工程代码。此时,CubeMX已经帮我们完成了所有底层的GPIO初始化工作,包括时钟开启和寄存器配置。
二、软件架构设计与BSP层封装
为了让代码更具可读性和可移植性,我们不能直接在 main.c 的 while(1) 循环里写死引脚号(如 HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8...))。我们需要构建一个板级支持包(BSP, Board Support Package),将硬件细节抽象化。
2.1 枚举类型的定义与映射
首先,我们需要定义两套枚举类型,分别代表LED和按键的逻辑编号。这样做的好处是,如果未来硬件引脚变了,我们只需要修改枚举定义或底层实现,上层业务逻辑完全不用动。
2.1.1 LED枚举定义
在代码中定义 LED_t 类型:
c
typedef enum
{
LED1 = 1,
LED2,
LED3,
LED4
} LED_t;
这里 LED1 对应物理上的 PF8,LED2 对应 PF9,以此类推。
2.1.2 按键枚举定义
定义 KEY_t 类型:
c
typedef enum
{
KEY1 = 1,
KEY2,
KEY3,
KEY4
} KEY_t;
特别注意物理引脚与逻辑编号的对应关系,这也是初学者最容易晕的地方:
- KEY1 对应 PG10
- KEY2 对应 PG6
- KEY3 对应 PG8
- KEY4 对应 PG7
大家可以看到,物理引脚并不是连续的(6,7,8,10),而且顺序也是打乱的。通过枚举封装,我们在写业务逻辑时只需要关心"按下KEY1做什么",而不需要每次都去查原理图确认KEY1到底是PG几。
2.2 LED控制函数封装
我们需要封装三个核心函数来控制LED:一个通用的控制函数,以及两个便捷函数(开和关)。
2.2.1 通用控制函数 BSP_LED_Control
这个函数接收两个参数:一个是哪个灯(LED_t led),另一个是亮还是灭(bool flag)。
c
// flag: true -> 点亮, false -> 熄灭
void BSP_LED_Control(LED_t led, bool flag)
{
// 核心逻辑:根据flag决定写入的电平状态
// 注意:因为是开漏输出且低电平点亮,所以 true(亮) 对应 RESET(低电平/0)
GPIO_PinState state = flag ? GPIO_PIN_RESET : GPIO_PIN_SET;
switch(led)
{
case LED1:
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, state);
break;
case LED2:
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_9, state);
break;
case LED3:
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_10, state);
break;
case LED4:
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_11, state);
break;
default:
break;
}
}
这段代码非常关键。它利用了C语言的三目运算符 ? : 来简化逻辑。当 flag 为真(想点亮)时,state 被赋值为 GPIO_PIN_RESET(即低电平0),从而导通LED;反之则熄灭。然后通过 switch-case 结构将逻辑编号映射到具体的 GPIOF 端口和 PIN_x 引脚上。
2.2.2 便捷开关函数
为了调用更简单,我们再封装两个"语法糖"函数:
c
void BSP_LED_On(LED_t led)
{
BSP_LED_Control(led, true); // 直接调用通用函数,传入true
}
void BSP_LED_Off(LED_t led)
{
BSP_LED_Control(led, false); // 直接调用通用函数,传入false
}
这样,在主程序里,我们只需要写 BSP_LED_On(LED1) 就能点亮第一个灯,代码意图一目了然。
三、按键检测逻辑与消抖算法实现
按键检测看似简单,实则暗藏玄机。机械按键在闭合和断开的瞬间,触点会发生抖动,导致电平在高低之间快速跳变。如果不处理,单片机可能会把一次按压误判为多次按压。因此,我们需要分版本来实现按键检测。
3.1 基础版:未消抖检测 (Version 1)
这是最直观的逻辑,主要用于理解基本原理,但在实际工程中不推荐直接使用。
c
// 未消抖版本 -- version1
bool BSP_KEY_IsPressed_V1(KEY_t key)
{
bool ret = false;
switch(key)
{
case KEY1: // PG10
ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET);
break;
case KEY2: // PG6
ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_6) == GPIO_PIN_RESET);
break;
case KEY3: // PG8
ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_8) == GPIO_PIN_RESET);
break;
case KEY4: // PG7
ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_7) == GPIO_PIN_RESET);
break;
default:
break;
}
return ret;
}
逻辑解析 :
这个函数非常简单,它直接读取指定引脚的电平。因为按键按下是接地(低电平),所以我们判断读取值是否等于 GPIO_PIN_RESET(即0)。如果是,返回 true(表示按下了);否则返回 false。
缺点:如果在抖动期间调用这个函数,可能会得到不稳定的结果。
3.2 进阶版:软件延时消抖 (Version 2)
为了解决抖动问题,我们引入经典的"延时再确认"机制。
c
// 消抖版本 -- version2
#define KEY_DEBOUNCE_MS 20 // 按键消抖时间,单位毫秒
bool BSP_KEY_IsPressed_V2(KEY_t key)
{
bool ret = false;
switch(key)
{
case KEY1: // PG10
if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
{
HAL_Delay(KEY_DEBOUNCE_MS); // 延时20ms等待抖动结束
if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET) // 再次确认
{
ret = true;
}
}
break;
// ... KEY2, KEY3, KEY4 逻辑相同,此处省略 ...
case KEY2:
if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_6) == GPIO_PIN_RESET)
{
HAL_Delay(KEY_DEBOUNCE_MS);
if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_6) == GPIO_PIN_RESET) ret = true;
}
break;
// ... 其他按键同理 ...
}
return ret;
}
逻辑解析:
- 第一次检测:先读一次引脚,如果是低电平,说明可能按下了,但也可能是抖动。
- 延时等待 :调用
HAL_Delay(20),死等20毫秒。这段时间足以让机械触点的抖动平息下来。 - 第二次确认 :延时结束后,再次读取引脚。如果还是低电平,说明这是稳定的人为按压,而非干扰,此时才返回
true。
3.3 主循环中的并列检测逻辑
在 main.c 的 while(1) 循环中,我们将上述功能串联起来。这里有一个非常重要的概念:并列关系。
c
while (1)
{
// 检测 KEY1,控制 LED1
if(BSP_KEY_IsPressed_V2(KEY1))
{
BSP_LED_On(LED1); // 按下点亮
}
else
{
BSP_LED_Off(LED1); // 松开熄灭
}
// 检测 KEY2,控制 LED2
if(BSP_KEY_IsPressed_V2(KEY2))
{
BSP_LED_On(LED2);
}
else
{
BSP_LED_Off(LED2);
}
// ... KEY3/LED3, KEY4/LED4 同理 ...
}
代码也可以这样写
c
// version3 - 消抖版本
BSP_KEY_IsPressed_V2(KEY1) ? BSP_LED_On(LED1) : BSP_LED_Off(LED1);
BSP_KEY_IsPressed_V2(KEY2) ? BSP_LED_On(LED2) : BSP_LED_Off(LED2);
BSP_KEY_IsPressed_V2(KEY3) ? BSP_LED_On(LED3) : BSP_LED_Off(LED3);
BSP_KEY_IsPressed_V2(KEY4) ? BSP_LED_On(LED4) : BSP_LED_Off(LED4);
深度剖析 while 循环的运行机制 :
很多初学者会担心:"我在检测KEY1的时候延时了20ms,会不会导致KEY2检测不及时?"
实际上,在这个简单的案例中,这种影响微乎其微。
- 疯狂轮询 :CPU在
while循环里跑得飞快。它不断地检查每个按键的状态。 - 状态跟随 :这段代码实现的是一种"状态跟随"逻辑,而不是"事件触发"逻辑。
- 只要你按着不动(开关闭合),引脚一直是低电平,消抖函数一直返回
true,灯就一直亮着。 - 当你手松开的那一瞬间,引脚变高,
else分支执行,灯立刻熄灭。
- 只要你按着不动(开关闭合),引脚一直是低电平,消抖函数一直返回
- 关于延时的副作用 :虽然V2版本用了
HAL_Delay,这确实会阻塞CPU。如果你同时按下两个键,CPU处理完第一个键的20ms延时后,再去处理第二个键,会有极微小的时间差。但在人眼看来,这四个灯几乎是同时响应你的操作的,属于"同时可看"的效果。
3.4 输入源码深度解析:从CubeMX生成到寄存器底层逻辑
接下来深入剖析 STM32CubeMX 自动生成的 GPIO 输入源码。这部分内容不仅涉及代码层面的调用,更包含了从 HAL 库到底层寄存器的完整映射关系。
3.4.1 GPIO 输入读取的核心逻辑
首先,我们来快速回顾一下输入的逻辑。关于读取数据存储器(Data Register)的操作,核心在于通过 HAL_GPIO_ReadPin 函数读取指定 GPIO 端口(如 GPIOG)所对应地址空间中的输入数据寄存器(IDR),获取特定引脚的电平状态。
其底层原理是将读取到的引脚电平与复位值进行比较:
- 返回 1 (SET):表示引脚检测到高电平。
- 返回 0 (RESET):表示引脚检测到低电平。
需要特别强调的是,这个返回值究竟代表"外部信息有效"还是"无效",或者代表"开关按下"还是"松开",完全取决于你的硬件电路设计(例如是低电平触发还是高电平触发)。软件层面只负责如实反馈物理电平,具体的业务逻辑映射由开发者自行定义。
3.4.2 按键引脚配置:浮空输入模式
当我们使用 STM32CubeMX 生成代码后,MX_GPIO_Init() 函数是配置 GPIO 的入口。其中,输入部分的配置代码如下:
c
/* Configure GPIO pins : PG6 PG7 PG8 PG10 */
GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7|GPIO_PIN_8|GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // Input Floating Mode
GPIO_InitStruct.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOG, &GPIO_InitStruct);
这里配置了 PG6、PG7、PG8、PG10 为输入模式。关键在于 GPIO_MODE_INPUT 配合 GPIO_NOPULL,这在底层对应的是浮空输入模式。
3.4.3 从 HAL 库到寄存器的底层映射
很多初学者只停留在调用 HAL 库函数的层面,但理解底层的寄存器操作对于排查问题至关重要。我们结合代码定义来看看 GPIO_MODE_INPUT 到底做了什么。
3.4.3.1 宏定义的数值含义
在 stm32f1xx_hal_gpio.h 中,我们可以看到模式的定义:
c
#define GPIO_MODE_INPUT 0x00000000u /*!< Input Floating Mode */
#define GPIO_MODE_OUTPUT_PP 0x00000001u /*!< Output Push Pull Mode */
#define GPIO_MODE_OUTPUT_OD 0x00000011u /*!< Output Open Drain Mode */


可以看到,GPIO_MODE_INPUT 的值是 0x00,对应就为输入模式。
3.4.3.2 HAL 库内部的配置逻辑
当我们调用 HAL_GPIO_Init 并传入 GPIO_MODE_INPUT 时,库函数内部会进入 case GPIO_MODE_INPUT: 分支:

c
/* Check the GPIO pull parameter */
assert_param(IS_GPIO_PULL(GPIO_Init->Pull));
if (GPIO_Init->Pull == GPIO_NOPULL)
{
config = GPIO_CR_MODE_INPUT + GPIO_CR_CNF_INPUT_FLOATING;
}
// ... 其他上拉下拉逻辑
这里涉及两个关键的寄存器位段配置宏:
GPIO_CR_MODE_INPUT定义为0x00000000u,对应 GPIOx_CRL/CRH 寄存器中的 MODEy1:0 = 00,即"输入模式(复位后的状态)"。GPIO_CR_CNF_INPUT_FLOATING定义为0x00000004u,对应 GPIOx_CRL/CRH 寄存器中的 CNFy1:0 = 01,即"浮空输入模式(复位后的状态)"。
3.4.3.3 寄存器位的组合与操作
对于 STM32F1 系列,每个引脚由 4 个位控制(CNF1:0 + MODE1:0):
- MODE1:0 = 00:表示输入模式。
- CNF1:0 = 01:表示浮空输入。
组合起来就是 0100(二进制),即十六进制的 0x4。这个值就是浮空输入模式的完整配置值。
这意味着,如果我们想手动配置 PG6 为浮空输入,我们需要修改 GPIOG_CRL 寄存器(因为 Pin6 < 8),找到对应的 Bit27:24,将其设置为 0100。HAL 库正是通过 Modify Register 机制来精准完成这一操作的------先读出当前寄存器的值,再将目标引脚对应的 4 个位清零,最后将新的配置值写入对应位置,从而确保修改一个引脚时不会影响同组的其他引脚。
3.5 调试避坑指南:为什么看不到初始化效果?
在实际开发中,我们可能会遇到一个现象:明明在初始化代码里写了让 LED 点亮或熄灭,但上电后根本看不到这个动作,程序仿佛直接跳过了初始化。
问题根源:执行速度过快
程序的执行流程是这样的:
- 系统启动,执行
MX_GPIO_Init()。 - 初始化完成,立即进入
main()函数的while(1)循环。 while(1)循环的第一件事通常就是检测按键状态。- 如果此时你没有按下按键,检测逻辑会立刻执行"关灯"操作。
由于 MCU 的运行速度是 MHz 级别的,从"初始化点亮 LED"到"循环检测并熄灭 LED"之间的时间间隔可能只有几微秒。人眼的视觉暂留效应根本无法捕捉这么短的光脉冲,所以看起来就像是 LED 从来没亮过,或者直接就是灭的。
解决方案:人为引入延时
为了验证我们的初始化配置是否正确(例如验证上电默认电平),我们需要强行让程序"等一等"。
修改建议:
在 MX_GPIO_Init() 调用之后,进入 while(1) 循环之前,加入一个延时函数:
c
HAL_Delay(5000); // 延时5秒,等待按键稳定
结语
