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)的编写,有几个铁律:

  1. 快进快出:ISR里不要做复杂运算,不要调用延时函数,不要做阻塞操作。只做最必要的处理,比如置一个标志位,然后退出。
  2. 保存和恢复上下文:编译器会自动处理,但如果你用汇编写ISR,一定要手动保存寄存器。
  3. 避免中断嵌套:除非你非常清楚自己在做什么,否则建议在ISR执行期间关闭其他中断,防止优先级反转导致系统崩溃。
  4. 使用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的基础,上手会快很多。