4、GPIO与基础外设:LED指示灯、按键输入、中断配置、定时器应用

好,咱们开始聊嵌入式开发里最基础、也最绕不开的几个东西——GPIO、按键、中断和定时器。你想想看,不管多复杂的物联网终端,最终跟物理世界打交道的,就是这些引脚和寄存器。我做了十几年嵌入式,每次拿到一块新板子,第一件事就是点个灯、读个按键,这几乎成了肌肉记忆。

4.1 LED指示灯:从硬件到驱动的第一课

LED指示灯,说白了就是GPIO输出。但这里有个坑——很多人以为直接给引脚高电平就能亮灯。其实不一定。你得看电路是“高电平点亮”还是“低电平点亮”。

关键点: 查看原理图,确认LED的阳极接VCC还是接GPIO。阳极接VCC,那就是低电平点亮;阳极接GPIO,那就是高电平点亮。

我在项目中遇到过一件事:一个同事把LED接反了,程序里写高电平点亮,结果灯死活不亮。查了半天,最后发现是硬件上LED的阴极直接接地了,阳极接的GPIO。嗯,这种低级错误其实挺常见的。

代码示例(基于STM32 HAL库):

// 初始化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(假设低电平点亮)
#define LED_ON()   HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET)
#define LED_OFF()  HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET)
#define LED_TOGGLE() HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0)

我的习惯: 用宏定义封装LED操作,而不是直接写HAL函数。这样后期换引脚或者改电平逻辑,只需要改宏定义,不用满世界找代码。

4.2 按键输入:去抖是门玄学

按键输入,看起来简单——读GPIO电平嘛。但实际用起来,你会发现按键按下和松开的时候,电平会抖动。这个抖动时间大概5-20ms,如果不处理,一次按下可能被识别成几十次。

为什么会这样?因为机械触点在接触瞬间会弹跳。你想想看,两个金属片碰在一起,不可能一下子就稳定接触。

我曾经在一个工业控制面板项目里,因为没做去抖处理,操作员按一次“启动”,机器连续启动了三次。嗯,那次差点出安全事故。从那以后,我对按键去抖格外重视。

去抖有两种常见方式:

  • 硬件去抖: 加RC滤波电路,电容充放电平滑掉抖动。适合对成本不敏感的场景。
  • 软件去抖: 检测到电平变化后,延时10-20ms再读一次。如果电平一致,才确认按键状态。这是最常用的方法。

代码示例(软件去抖):

#define KEY_PIN   GPIO_PIN_13  // PC13
#define KEY_PORT  GPIOC

uint8_t Key_Scan(void) {
    if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) {
        HAL_Delay(20);  // 延时去抖
        if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) {
            // 等待按键释放
            while (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET);
            return 1;  // 有效按键
        }
    }
    return 0;
}

注意: 上面的代码用了阻塞式延时(HAL_Delay),在简单应用里没问题。但在复杂系统中,阻塞延时会影响其他任务。这时候应该用定时器或状态机来做非阻塞去抖。

4.3 中断配置:别让CPU空转

轮询读按键,CPU一直在忙。如果系统里只有这一个任务还好,但工业设备通常要同时处理通信、显示、数据采集……这时候轮询就不合适了。中断就是用来解决这个问题的——让CPU在事件发生时再响应,平时该干嘛干嘛。

我个人习惯,只要可能,按键和外部事件都用中断。但要注意,中断服务函数(ISR)里不能做耗时操作。我在项目中见过有人直接在ISR里做printf、延时、甚至调用HAL_Delay,结果系统卡死。中断里只做标志位设置,具体处理放到主循环里。

配置外部中断的步骤:

  1. 使能GPIO时钟和SYSCFG时钟
  2. 配置GPIO为中断模式(上升沿、下降沿或双边沿触发)
  3. 配置NVIC(嵌套向量中断控制器),设置优先级
  4. 编写中断服务函数

代码示例:

// 配置PC13为外部中断,下降沿触发
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, 2, 0);  // 设置优先级
    HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);          // 使能中断
}

// 中断服务函数
void EXTI15_10_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_13) != RESET) {
        // 设置标志位,不做具体处理
        key_pressed_flag = 1;
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13);  // 清除中断标志
    }
}

避坑指南: 我曾经在中断里忘了清除中断标志位,结果中断一直触发,CPU一直在处理中断,主循环根本跑不起来。所以,清除标志位这个动作,一定要写在ISR里,而且要在处理完事件之后立即执行。

4.4 定时器应用:精准的时间管理

定时器是嵌入式系统里最灵活的外设之一。你可以用它做延时、产生PWM、测量脉冲宽度、做系统心跳……我甚至用定时器做过一个简易的软件串口。

STM32的定时器分几类:

类型 特点 典型应用
基本定时器(TIM6/TIM7) 只有定时功能,无外部引脚 产生时基、触发DAC
通用定时器(TIM2-TIM5) 有输入捕获、输出比较、PWM 测量频率、产生PWM、编码器接口
高级定时器(TIM1/TIM8) 功能最全,支持互补输出、刹车功能 电机控制、三相逆变

咱们先看一个最简单的应用——用定时器产生1ms中断,作为系统心跳。这样就不用HAL_Delay那种阻塞延时了。

// 配置TIM2,产生1ms中断
void TIM2_Init(void) {
    TIM_HandleTypeDef htim2 = {0};
    __HAL_RCC_TIM2_CLK_ENABLE();

    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 72 - 1;      // 72MHz / 72 = 1MHz
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim2.Init.Period = 1000 - 1;       // 1MHz / 1000 = 1kHz = 1ms
    htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    HAL_TIM_Base_Init(&htim2);

    HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0);
    HAL_NVIC_EnableIRQ(TIM2_IRQn);

    HAL_TIM_Base_Start_IT(&htim2);  // 启动定时器并使能中断
}

// 定时器中断服务函数
void TIM2_IRQHandler(void) {
    HAL_TIM_IRQHandler(&htim2);  // HAL库统一处理
}

// 回调函数(HAL库自动调用)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim->Instance == TIM2) {
        system_tick++;  // 系统心跳计数
        // 可以在这里处理周期性任务
    }
}

核心思路: 定时器的预分频器(Prescaler)和自动重装载值(Period)共同决定了中断频率。公式很简单:中断频率 = 定时器时钟 / (Prescaler + 1) / (Period + 1)。记住这个公式,调定时器就不慌了。

你想想看,有了这个1ms的心跳,你就可以做很多事情了:

  • 按键去抖:每隔10ms扫描一次按键状态
  • LED闪烁:每隔500ms翻转一次LED
  • 超时检测:如果某个事件超过1000ms没发生,就报错
  • 任务调度:简单的非抢占式任务调度

我在一个LoRa网关项目里,就是用这个1ms定时器做所有任务的时基。LoRa模块的发送周期、传感器采集间隔、心跳包上报……全部基于这个system_tick。这样整个系统的时间基准统一,不会出现各个模块各自为政的情况。

注意: 定时器中断的优先级要合理设置。如果优先级太高,会抢占其他关键中断;如果太低,可能被其他中断频繁打断,导致定时不准。我一般把定时器中断设为中等优先级,比外部中断低,比普通任务高。

好了,这一章的内容就这些。GPIO、按键、中断、定时器,这四个东西是嵌入式开发的基石。你把这些玩熟了,后面做LoRa通信、传感器采集、协议栈移植,都会顺手很多。下一章咱们开始聊串口通信,那是物联网设备跟外界对话的嘴巴。