3、MCU外设驱动开发(一):GPIO驱动——按键输入与LED输出控制,中断服务函数编写规范
好,咱们开始聊GPIO。这是嵌入式开发里最基础、也最绕不开的外设。说白了,GPIO就是芯片的“手脚”——用来输出高低电平控制LED,或者读取外部电平变化,比如按键按下。
我个人习惯,拿到一个新板子,第一件事就是先点亮一个LED。这就像程序员写“Hello World”一样,是确认硬件链路和开发环境是否正常的最快方式。今天我们就从LED输出和按键输入这两个最经典的场景切入,顺便聊聊中断服务函数怎么写才规范。
3.1 GPIO输出:控制LED亮灭
先看输出。控制LED,本质上就是控制GPIO引脚输出高电平或低电平。以STM32为例,我们通常这样配置:
// 初始化LED引脚
void LED_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOB_CLK_ENABLE(); // 使能GPIOB时钟
GPIO_InitStruct.Pin = GPIO_PIN_0; // PB0
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL; // 不上拉不下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速即可
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}
// 控制LED
void LED_On(void) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); }
void LED_Off(void) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); }
void LED_Toggle(void) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); }
这里有个小细节:推挽输出和开漏输出的区别。推挽输出能主动输出高低电平,驱动能力强;开漏输出只能拉低,高电平要靠外部上拉电阻。驱动LED这种负载,推挽就够了。
我的经验:LED的限流电阻一定要加,别偷懒。我曾经见过一个同事,为了省一个电阻,直接把LED怼在GPIO上,结果LED倒是亮了,但GPIO的驱动电流超标,芯片发热严重。嗯,后来他老老实实补上了电阻。
3.2 GPIO输入:读取按键状态
按键输入比输出稍微复杂一点。核心问题是:机械按键有抖动。按下和释放的瞬间,电平会反复跳变几次,持续几毫秒到十几毫秒。如果不处理,一次按键可能被误判成多次。
消抖有两种常见方式:
- 硬件消抖:在按键两端并联一个电容(通常0.1μF),利用电容的充放电特性滤除抖动。简单粗暴,适合对成本不敏感的场景。
- 软件消抖:检测到电平变化后,延时10~20ms再读一次。如果电平稳定,才认为是有效动作。这是最常用的方法。
看一个软件消抖的示例:
// 按键初始化
void KEY_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_0; // PA0,按键引脚
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // 输入模式
GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉,按键按下为低电平
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}
// 带消抖的按键读取
uint8_t KEY_Read(void)
{
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) // 检测到按下
{
HAL_Delay(20); // 延时20ms消抖
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) // 再次确认
{
return 1; // 按键有效
}
}
return 0;
}
注意:HAL_Delay() 在中断服务函数里不能直接调用!因为它依赖SysTick中断,而中断嵌套可能导致死锁。如果你要在中断里做消抖,建议用定时器轮询或者状态机的方式。
3.3 中断服务函数编写规范
按键用轮询方式读取,在简单系统里没问题。但一旦系统复杂了,比如要同时处理传感器、电机、通信,轮询就会浪费CPU资源。这时候就该中断出场了。
中断服务函数(ISR)的编写,有几个铁律:
- 快进快出:ISR里不要做复杂运算,不要调用延时函数,不要做阻塞操作。只做最必要的处理,比如置一个标志位,然后退出。
- 保存和恢复上下文:编译器会自动处理,但如果你用汇编写ISR,一定要手动保存寄存器。
- 避免中断嵌套:除非你非常清楚自己在做什么,否则建议在ISR执行期间关闭其他中断,防止优先级反转导致系统崩溃。
- 使用volatile修饰共享变量:ISR和主循环共享的变量,一定要加volatile,防止编译器优化掉。
看一个规范的按键中断示例:
volatile uint8_t g_key_pressed = 0; // 共享标志位,必须加volatile
// 外部中断服务函数
void EXTI0_IRQHandler(void)
{
if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) // 检查中断源
{
// 这里只做一件事:置标志位
g_key_pressed = 1;
// 清除中断标志位
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
}
}
// 主循环中处理按键
int main(void)
{
HAL_Init();
LED_Init();
KEY_Init();
// 配置外部中断(略)
while (1)
{
if (g_key_pressed)
{
g_key_pressed = 0; // 清除标志位
LED_Toggle(); // 翻转LED
}
// 其他任务...
}
}
核心思想:ISR只负责“通知”主循环,不负责“处理”。主循环在合适的时机去处理事件。这样既保证了中断的实时性,又避免了ISR过于臃肿。
3.4 避坑指南
做GPIO驱动,有几个坑我踩过,分享给你:
- GPIO初始化顺序:先配置时钟,再配置GPIO参数。顺序反了,配置可能无效。我刚开始学的时候,就因为这个折腾了半天。
- 中断优先级配置:如果多个中断共用同一个优先级组,一定要明确抢占优先级和子优先级的分配。否则可能出现中断响应异常。
- 浮空输入:GPIO配置成输入但不设置上拉或下拉,引脚电平就是不确定的。按键输入一定要加上拉或下拉,否则读数会乱跳。
- 中断标志位清除时机:一定要在ISR退出前清除标志位,否则会反复触发中断。但也要注意,清除操作本身可能触发新的中断,需要根据芯片手册处理。
好了,GPIO驱动的基础就这些。说白了,输出就是控制电平,输入就是读取电平,中断就是让CPU别闲着。但越是基础的东西,越要做得扎实。下一节我们会聊定时器,那玩意儿比GPIO复杂多了,但有了GPIO的基础,上手会快很多。