4. GPIO 驱动实战:点亮 LED 与读取按键状态
好,我们正式开始动手写代码。前面几章讲了寄存器、时钟、GPIO 模式,都是理论。这一章,咱们直接拿两块最基础的外设——LED 和按键——来练手。你想想看,一个嵌入式系统最朴素的“输出”和“输入”就是这两样东西了。
我个人习惯,学一个新 MCU 平台,第一个工程永远是点灯。灯亮了,说明你的时钟配置对了、GPIO 模式设对了、代码下载进去了。这一步走通,后面才有信心。
4.1 硬件连接与原理回顾
先看硬件。我假设你手头的板子上,LED 接在 GPIOA Pin 5,按键接在 GPIOC Pin 13。当然,不同开发板引脚不一样,但原理完全一样。
| 外设 | GPIO 端口 | Pin | 默认电平 | 有效电平 |
|---|---|---|---|---|
| LED | GPIOA | 5 | 低电平(灭) | 高电平(亮) |
| 按键 | GPIOC | 13 | 高电平(通过上拉电阻) | 低电平(按下) |
这里有个细节:按键按下时为什么是低电平?因为外部电路把按键一端接地了,另一端通过上拉电阻接到 VCC。没按时,引脚被拉到高电平;按下去,引脚直接接地,变成低电平。嗯,这个电路很常见,但新手容易搞反。
4.2 GPIO 输出模式配置(点亮 LED)
点亮 LED 需要把 GPIO 设为推挽输出模式。为什么是推挽?因为推挽输出既能灌电流也能拉电流,驱动 LED 最合适。开漏输出一般用于 I2C 等总线,驱动 LED 反而麻烦。
配置步骤其实就三步:
- 开启 GPIO 时钟 —— 不开启时钟,寄存器写不进去。
- 配置 GPIO 模式寄存器 —— 设为输出模式,推挽结构。
- 写输出数据寄存器 —— 置 1 点亮,清 0 熄灭。
直接上代码。我用的是寄存器操作,不依赖 HAL 库。为什么?因为寄存器操作让你真正理解硬件在干什么。
/* 步骤1:开启 GPIOA 时钟(挂在 AHB1 总线上) */
RCC->AHB1ENR |= (1 << 0); // GPIOA 时钟使能
/* 步骤2:配置 PA5 为输出模式,推挽结构 */
GPIOA->MODER &= ~(0x3 << (5 * 2)); // 先清空 PA5 的模式位
GPIOA->MODER |= (0x1 << (5 * 2)); // 设为 01:通用输出模式
GPIOA->OTYPER &= ~(1 << 5); // 推挽输出(0 表示推挽)
/* 步骤3:点亮 LED */
GPIOA->ODR |= (1 << 5); // PA5 输出高电平,LED 亮
你看,代码就这么几行。但每一行都有讲究。比如 MODER 寄存器每两位控制一个引脚,所以清位时要左移 5 * 2 位。这个计算方式我建议你记牢,后面所有 GPIO 配置都会用到。
4.3 GPIO 输入模式配置(读取按键)
按键读取需要把 GPIO 设为输入模式。输入模式也有讲究:要不要上拉?要不要下拉?要不要用施密特触发器?
对于按键这种机械开关,我建议:
- 模式: 输入模式(MODER = 00)
- 上拉/下拉: 如果外部电路已经有上拉电阻,内部上拉可以不开。但为了保险,我习惯开启内部上拉。
- 施密特触发器: 默认开启,不用管。
配置代码:
/* 步骤1:开启 GPIOC 时钟 */
RCC->AHB1ENR |= (1 << 2); // GPIOC 时钟使能
/* 步骤2:配置 PC13 为输入模式 */
GPIOC->MODER &= ~(0x3 << (13 * 2)); // 清位,设为 00:输入模式
/* 步骤3:开启内部上拉电阻 */
GPIOC->PUPDR &= ~(0x3 << (13 * 2)); // 先清位
GPIOC->PUPDR |= (0x1 << (13 * 2)); // 设为 01:上拉
/* 步骤4:读取按键状态 */
uint8_t key_state = (GPIOC->IDR >> 13) & 0x1; // 读 IDR 寄存器
读按键时,读的是 IDR(输入数据寄存器),不是 ODR。这个别搞混了。ODR 是输出用的,IDR 是输入用的。
key_state == 0。如果你发现按键没反应,先检查一下是不是逻辑写反了。
4.4 按键消抖处理
机械按键有个讨厌的特性:抖动。按下和松开的一瞬间,电平会来回跳变几次,持续 5~20ms。如果不做消抖,一次按下可能被误判成多次。
消抖方法有两种:
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 硬件消抖 | RC 低通滤波器 + 施密特触发器 | 不占 CPU | 增加 BOM 成本 |
| 软件消抖 | 检测到电平变化后,延时 10~20ms 再读一次 | 零成本,灵活 | 占用 CPU 时间 |
我一般用软件消抖,简单可靠。代码实现:
uint8_t Read_Key_GPIO(void)
{
if ((GPIOC->IDR >> 13) & 0x1) {
return 1; // 按键未按下
}
/* 延时 10ms(简单忙等,实际项目建议用定时器) */
for (volatile uint32_t i = 0; i < 100000; i++);
/* 再次读取确认 */
if ((GPIOC->IDR >> 13) & 0x1) {
return 1; // 抖动,不是真的按下
}
return 0; // 确认按下
}
这个函数的核心逻辑是:第一次读到低电平不急着信,等 10ms 再读一次。如果还是低电平,那就是真的按下了。如果变成高电平,说明是抖动,忽略。
4.5 综合实战:按键控制 LED 亮灭
好了,把上面两部分合起来。按下按键,LED 亮;松开按键,LED 灭。这就是一个最简单的输入输出闭环。
int main(void)
{
/* 初始化 */
RCC->AHB1ENR |= (1 << 0) | (1 << 2); // 开启 GPIOA 和 GPIOC 时钟
/* PA5 输出模式 */
GPIOA->MODER &= ~(0x3 << (5 * 2));
GPIOA->MODER |= (0x1 << (5 * 2));
GPIOA->OTYPER &= ~(1 << 5);
/* PC13 输入模式 + 上拉 */
GPIOC->MODER &= ~(0x3 << (13 * 2));
GPIOC->PUPDR &= ~(0x3 << (13 * 2));
GPIOC->PUPDR |= (0x1 << (13 * 2));
while (1)
{
if (Read_Key_GPIO() == 0) // 按键按下
{
GPIOA->ODR |= (1 << 5); // LED 亮
}
else
{
GPIOA->ODR &= ~(1 << 5); // LED 灭
}
}
}
这段代码跑起来,你按下按键,灯就亮;松开,灯就灭。是不是很简单?但别小看它,这个模式是所有 GPIO 驱动的基础。后面你要驱动继电器、蜂鸣器、传感器,本质上都是这套逻辑。
4.6 知识体系总览
为了让你更清楚地看到本章的知识结构,我画了一张图。你可以把它当作一个“思维导图”来看。
这张图把本章内容分成了三块:输出、输入、综合。你看,所有 GPIO 驱动都绕不开“时钟 → 模式 → 数据寄存器”这个核心链路。把这个链路刻在脑子里,后面不管接什么外设,你都知道从哪下手。
4.7 避坑指南
最后,分享几个我实际项目中踩过的坑,你遇到了可以少走弯路。
- 时钟没开就配寄存器: 写进去的值根本不会生效。调试时先检查时钟寄存器有没有置位。
- MODER 清位不彻底: 多个位共用同一个寄存器,不清干净就设新值,结果模式乱掉。我习惯先 &= 清位,再 |= 设值。
- 按键电平极性搞反: 按下是高还是低?先看原理图,别想当然。实在不确定,用万用表量一下。
- 消抖延时太长: 10~20ms 足够,别超过 50ms。否则按键响应慢,用户会骂娘。
好了,这一章的内容就到这。代码不多,但都是基本功。你最好自己动手敲一遍,别复制粘贴。敲完跑起来,看到灯亮了、按键有反应了,那种成就感是看一百遍书都比不了的。