第2章 GPIO实战:从流水灯到位带操作
GPIO 是嵌入式开发最基础、也最绕不开的外设。说白了,它就是芯片的「手脚」——用来输出高低电平,或者读取外部信号。这一章我们不讲虚的,直接上手做四个实战:LED流水灯、按键输入、外部中断、位带操作。每个都是我项目中反复用到的套路。
- GPIO 输出模式与驱动能力
- 按键消抖的工程处理
- 外部中断的配置与优先级
- 位带操作的原理与加速技巧
2.1 LED流水灯——GPIO输出的基本功
流水灯是嵌入式界的「Hello World」。但你别小看它,这里藏着GPIO输出模式的选型门道。
STM32的GPIO输出模式有两种:推挽输出和开漏输出。驱动LED,我习惯用推挽输出。为什么?因为推挽输出能同时提供高电平和低电平的驱动能力,LED正极接VCC、负极接GPIO,低电平点亮,简单直接。
来看代码。初始化部分,我习惯用HAL库的HAL_GPIO_Init(),但底层寄存器怎么配的,心里要有数。
// 流水灯核心代码
#define LED1_PIN GPIO_PIN_0
#define LED2_PIN GPIO_PIN_1
#define LED3_PIN GPIO_PIN_2
#define LED_PORT GPIOB
void LED_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOB_CLK_ENABLE();
GPIO_InitStruct.Pin = LED1_PIN | LED2_PIN | LED3_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL; // 不拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速足够
HAL_GPIO_Init(LED_PORT, &GPIO_InitStruct);
// 初始全灭(低电平点亮)
HAL_GPIO_WritePin(LED_PORT, LED1_PIN | LED2_PIN | LED3_PIN, GPIO_PIN_SET);
}
void LED_Flow(void)
{
uint16_t pins[] = {LED1_PIN, LED2_PIN, LED3_PIN};
for(int i = 0; i < 3; i++)
{
HAL_GPIO_WritePin(LED_PORT, pins[i], GPIO_PIN_RESET); // 点亮
HAL_Delay(200);
HAL_GPIO_WritePin(LED_PORT, pins[i], GPIO_PIN_SET); // 熄灭
}
}
嗯,这里要注意:HAL_Delay()依赖SysTick中断。如果你后面跑FreeRTOS,这个延时函数会出问题——因为FreeRTOS接管了SysTick。到时候我们会换成vTaskDelay()。
2.2 按键输入——别被抖动坑了
按键输入看起来简单:读GPIO电平就行。但实际项目中,按键抖动是最大的坑。
机械按键按下和释放时,触点会弹跳10~20ms。如果不做消抖,一次按下可能被识别成多次。我在一个工业控制面板项目里就吃过这个亏——操作员按一次「启动」,机器抖了三次,差点出事故。
软件消抖的标准做法:检测到电平变化后,延时20ms再读一次。如果电平一致,才算有效。
// 按键消抖示例
#define KEY_PIN GPIO_PIN_13
#define KEY_PORT GPIOC
uint8_t Key_Scan(void)
{
static uint8_t last_state = 1; // 默认高电平(上拉)
uint8_t current_state = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN);
if(current_state != last_state)
{
HAL_Delay(20); // 等待抖动过去
current_state = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN);
if(current_state != last_state)
{
last_state = current_state;
if(current_state == 0) // 按下(低电平有效)
return 1;
}
}
return 0;
}
这里我用的是状态机思想——记录上一次状态,只有状态稳定变化才响应。比单纯延时更可靠。
2.3 外部中断——别让CPU空转
轮询按键太浪费CPU了。你想想看,主循环每秒要读几万次按键状态,大部分时间都在做无用功。这时候就该外部中断出场了。
STM32的EXTI(外部中断)可以配置为上升沿、下降沿或双边沿触发。我习惯用下降沿触发检测按键按下,配合内部上拉电阻,省掉外部上拉。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| 触发边沿 | 下降沿 | 按键按下时电平从高变低 |
| GPIO上拉 | 内部上拉 | 省外部电阻,默认高电平 |
| 中断优先级 | 2(0-15可配) | 数值越小优先级越高 |
| 抢占优先级 | 1 | FreeRTOS下建议不低于1 |
// 外部中断配置
void EXTI_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿中断
GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 1, 0); // 抢占1,子0
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}
// 中断服务函数
void EXTI15_10_IRQHandler(void)
{
if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_13) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13);
// 这里放你的按键处理逻辑
// 注意:中断里不要做耗时操作!
}
}
HAL_Delay()!它依赖SysTick中断,而中断嵌套会导致死锁。我在调试一个电机控制程序时就被这个坑过——中断里加了个延时,系统直接卡死。
2.4 位带操作——让GPIO飞起来
位带操作是Cortex-M3内核的一个「隐藏技能」。说白了,它把一个位映射到一个字地址上。你操作这个地址,就等于操作那个位,而且是一条指令完成,原子操作。
STM32的GPIO输出寄存器(ODR)和输入寄存器(IDR)都在位带区。映射公式很简单:
// 位带别名地址计算公式
#define BITBAND(addr, bit) ((addr & 0xF0000000) + 0x02000000 + \
((addr & 0x00FFFFFF) << 5) + (bit << 2))
// 以GPIOB的ODR寄存器为例
#define GPIOB_ODR_ADDR (GPIOB_BASE + 0x14)
#define PB0_BITBAND BITBAND(GPIOB_ODR_ADDR, 0)
// 使用位带操作点亮LED
*(volatile uint32_t *)PB0_BITBAND = 0; // PB0输出低电平,LED亮
*(volatile uint32_t *)PB0_BITBAND = 1; // PB0输出高电平,LED灭
为什么位带操作快?因为普通读写GPIO需要「读-改-写」三步,而位带操作直接写别名地址,一条STR指令搞定。我在做LED点阵屏驱动时,用位带操作把刷新率从30fps提到了60fps。
- HAL_GPIO_WritePin:约12个CPU周期
- 直接寄存器操作:约6个CPU周期
- 位带操作:约3个CPU周期
2.5 实战中的避坑总结
这一章内容不少,我最后列几个实战中容易踩的坑,你写代码时多留个心眼:
- GPIO速度别乱设——LED和按键用LOW速度就够了,设成HIGH反而会增加EMI干扰。我有个产品就因为GPIO速度设太高,导致辐射超标,改版才解决。
- 中断里别做printf——串口打印是阻塞的,中断里用printf会拖死系统。真要调试,用DMA+环形缓冲区。
- 位带操作只适用于SRAM和外设——Flash区没有位带功能,别搞错了。
- 按键中断要配合消抖——硬件消抖(RC滤波)+软件消抖(延时判断),两个都用才保险。
好了,GPIO这部分就到这里。代码你拿去直接编译就能跑,但建议你动手改改——比如把流水灯改成呼吸灯效果,或者用位带操作实现一个精确的PWM模拟。只有自己踩过坑,才算真正学会。