第2章:STM32 GPIO控制——从寄存器到HAL库的实战
各位同学,欢迎来到第二章。上一章我们搭建好了开发环境,今天咱们来点实在的——直接操作GPIO。GPIO是嵌入式开发中最基础也最常用的外设,说白了就是芯片的“手脚”,用来跟外界打交道。
我个人习惯把GPIO的学习分成三个层次:理解模式配置 → 掌握寄存器操作 → 熟练使用HAL库。今天我们就按这个思路来。
2.1 GPIO模式配置:推挽、开漏、上拉下拉
先说说GPIO的几种工作模式。很多初学者一开始就被这堆模式搞晕了,其实没那么复杂。
2.1.1 推挽输出(Push-Pull Output)
推挽输出,你可以想象成两个开关:一个接VCC,一个接GND。输出高电平时,上面的开关闭合,下面的断开;输出低电平时反过来。这样输出能力强,驱动LED、蜂鸣器这类器件很合适。
我的经验:做LED流水灯时,我几乎都用推挽输出。驱动电流够大,而且电平切换速度快。但要注意,两个输出端不能直接短接,否则会烧管子——我在初学时犯过这个错。
2.1.2 开漏输出(Open-Drain Output)
开漏输出就只有一个下拉开关,高电平时靠外部上拉电阻拉高。这种模式常用于I2C总线、电平转换场景。
为什么要有开漏?你想想看,如果多个设备共用一根信号线,推挽输出会打架——一个输出高一个输出低,直接短路。开漏就不存在这个问题,谁想拉低就拉低,想输出高就靠上拉电阻。
避坑指南:我曾经在一个项目里用开漏输出驱动LED,结果发现LED亮度不够。后来才意识到,开漏输出高电平是靠上拉电阻的,电流有限。驱动LED还是用推挽靠谱。
2.1.3 上拉与下拉(Pull-Up / Pull-Down)
上拉下拉是给输入模式用的。芯片内部有弱上拉(约40kΩ)和弱下拉电阻,可以配置是否启用。
| 配置 | 引脚空闲状态 | 典型应用 |
|---|---|---|
| 上拉输入 | 高电平 | 按键检测(按下为低) |
| 下拉输入 | 低电平 | 按键检测(按下为高) |
| 浮空输入 | 不确定 | 外部已有上拉/下拉 |
嗯,这里要注意:浮空输入状态下,引脚电平是不确定的,容易受外界干扰。我建议除非外部电路已经处理好了,否则尽量用内部上拉或下拉。
2.2 寄存器版LED流水灯实现
直接操作寄存器,是理解芯片底层最好的方式。STM32F103的GPIO挂在APB2总线上,控制寄存器主要有这几个:
- CRL / CRH:配置模式(输入/输出/复用)和速度
- ODR:输出数据寄存器,写1输出高,写0输出低
- BSRR:位设置/复位寄存器,原子操作,推荐使用
- IDR:输入数据寄存器,读取引脚电平
下面是一个用寄存器操作的流水灯示例,控制PB0、PB1、PB2三个引脚:
// 寄存器版LED流水灯
#define GPIOB_BASE 0x40010C00
#define GPIOB_CRL *(volatile uint32_t *)(GPIOB_BASE + 0x00)
#define GPIOB_ODR *(volatile uint32_t *)(GPIOB_BASE + 0x0C)
#define GPIOB_BSRR *(volatile uint32_t *)(GPIOB_BASE + 0x10)
void GPIO_Init(void)
{
// 开启GPIOB时钟(RCC_APB2ENR寄存器)
*(volatile uint32_t *)0x40021018 |= (1 << 3);
// 配置PB0、PB1、PB2为推挽输出,50MHz
// CRL寄存器每4位控制一个引脚
GPIOB_CRL &= ~(0xF << 0); // 清除PB0配置
GPIOB_CRL |= (0x3 << 0); // 设置为推挽输出,50MHz
GPIOB_CRL &= ~(0xF << 4); // 清除PB1配置
GPIOB_CRL |= (0x3 << 4); // 设置为推挽输出,50MHz
GPIOB_CRL &= ~(0xF << 8); // 清除PB2配置
GPIOB_CRL |= (0x3 << 8); // 设置为推挽输出,50MHz
}
void LED_Flow(void)
{
uint8_t i;
uint16_t led_pins[] = {0, 1, 2}; // PB0, PB1, PB2
while(1)
{
for(i = 0; i < 3; i++)
{
// 使用BSRR寄存器:低16位置位,高16位复位
GPIOB_BSRR = (1 << led_pins[i]); // 点亮当前LED
GPIOB_BSRR = (1 << (led_pins[i] + 16)); // 熄灭上一个LED
// 简单延时
for(uint32_t j = 0; j < 500000; j++);
}
}
}
注意:操作CRL/CRH寄存器时,一定要先清除再设置。否则旧配置和新配置会叠加,产生意想不到的结果。我见过有人直接赋值,结果引脚模式全乱了。
2.3 HAL库版LED流水灯实现
用寄存器操作虽然底层,但可读性差、移植困难。HAL库封装了这些操作,让代码更清晰。同样的功能,用HAL库写是这样的:
// HAL库版LED流水灯
#include "stm32f1xx_hal.h"
void LED_Flow_HAL(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 使能GPIOB时钟
__HAL_RCC_GPIOB_CLK_ENABLE();
// 配置PB0、PB1、PB2为推挽输出
GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
uint8_t led_pins[] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2};
uint8_t i;
while(1)
{
for(i = 0; i < 3; i++)
{
HAL_GPIO_WritePin(GPIOB, led_pins[i], GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOB, led_pins[(i+2) % 3], GPIO_PIN_RESET);
HAL_Delay(200);
}
}
}
看到区别了吗?HAL库把寄存器操作封装成了结构体和函数,代码更易读。但代价是多了些函数调用开销。不过对于大多数应用来说,这点性能损失可以忽略。
2.4 按键中断与消抖处理
按键处理是嵌入式开发中的经典问题。硬件上简单,但软件处理不好就会出各种问题。
2.4.1 按键消抖原理
机械按键在按下和释放的瞬间,会产生抖动——电平在几毫秒内反复跳变。如果不处理,一次按键可能被误判成多次。
消抖方法有两种:
- 硬件消抖:加RC滤波电路或施密特触发器
- 软件消抖:延时采样法(检测到电平变化后,延时10-20ms再读一次)
我个人更倾向软件消抖,成本低、灵活。除非是工业级高可靠性场景,才考虑硬件消抖。
2.4.2 中断方式实现按键检测
用轮询方式检测按键会浪费CPU资源。中断方式更高效——按键按下时才触发处理。
// HAL库版按键中断 + 软件消抖
#define KEY_PIN GPIO_PIN_13 // PC13,开发板上的按键
#define KEY_PORT GPIOC
// 按键中断回调函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
static uint32_t last_tick = 0;
uint32_t current_tick = HAL_GetTick();
if(GPIO_Pin == KEY_PIN)
{
// 软件消抖:判断两次中断间隔是否大于20ms
if((current_tick - last_tick) > 20)
{
// 确认按键状态(低电平表示按下)
if(HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET)
{
// 按键有效,执行操作
// 比如切换LED状态
HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
}
}
last_tick = current_tick;
}
}
// 初始化按键中断
void Key_Interrupt_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_AFIO_CLK_ENABLE(); // 复用功能时钟
GPIO_InitStruct.Pin = KEY_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉
HAL_GPIO_Init(KEY_PORT, &GPIO_InitStruct);
// 配置中断优先级并使能
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}
我的经验:消抖时间不是固定的。我遇到过一种按键,抖动时间长达30ms。所以建议在实际项目中,用示波器看一下按键波形,再确定消抖延时。别盲目用20ms。
2.4.3 中断服务函数写法
HAL库的中断处理流程是这样的:中断触发 → 进入中断服务函数 → 调用HAL库的通用处理函数 → 回调用户函数。所以我们需要在stm32f1xx_it.c中写:
// stm32f1xx_it.c
void EXTI15_10_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(KEY_PIN);
}
然后HAL库会自动调用我们上面写的HAL_GPIO_EXTI_Callback函数。这种设计把底层中断处理和用户逻辑分开了,挺好的。
注意:中断服务函数里不要做耗时操作。比如延时、打印、复杂计算等。我曾经在回调里加了串口打印,结果按键响应变得很慢,还影响了其他中断。中断里只做标志位设置或简单状态切换,具体处理放到主循环里。
2.5 本章小结
这一章我们覆盖了GPIO的核心内容:
- 推挽输出适合驱动器件,开漏输出适合总线共享
- 上拉下拉让输入引脚有确定状态
- 寄存器操作让你理解底层,HAL库让你高效开发
- 按键消抖是基本功,中断方式更高效
下一章我们会深入定时器,那是嵌入式控制的灵魂。到时候我会分享一个我踩过的坑——定时器溢出中断没处理好,导致电机控制出了大问题。敬请期待。
课后练习:试着用寄存器方式实现按键中断,并对比HAL库方式的代码量。你会发现,HAL库虽然代码行数多,但可读性和可维护性确实好很多。