4. GPIO与基础外设:GPIO配置、按键输入(去抖处理)、LED指示、蜂鸣器控制

做嵌入式开发,GPIO 是最基础的东西。但说实话,我见过不少项目栽在 GPIO 上——不是配置错了,就是没处理好抖动。窗帘电机这种产品,按键和指示灯天天跟用户打交道,搞不好体验就很糟糕。

这一章,咱们就把 GPIO 的配置、按键去抖、LED 指示、蜂鸣器控制这些基本功,一次性讲透。

4.1 GPIO 配置:别小看这步

GPIO 配置看似简单,其实坑不少。我习惯把 GPIO 分为三类:输入、输出、复用功能。窗帘电机里,按键是输入,LED 和蜂鸣器是输出,PWM 控制电机则用复用功能。

配置时,有几点要注意:

  • 上拉/下拉电阻:按键输入一般用内部上拉,省一颗电阻。但要注意,内部上拉电阻值通常在 40kΩ 左右,功耗比外部 10kΩ 大一点。电池供电的产品,我建议用外部上拉。
  • 输出速度:LED 和蜂鸣器这种低频信号,用低速档就够了。高速档会增加 EMI 和功耗。我见过有人把 LED 配成 50MHz 速度,结果辐射超标,得不偿失。
  • 初始状态:上电瞬间,GPIO 默认可能是浮空输入。如果直接驱动蜂鸣器,可能会“嘀”一声。我一般会在初始化函数里,先把所有输出引脚拉到确定电平,再配置模式。

核心原则:GPIO 配置要“先定电平,再定模式”。这样能避免上电瞬间的毛刺。

下面是一个典型的 GPIO 初始化代码(以 STM32 为例):

void GPIO_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};

    // 使能时钟
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();

    // LED 输出 - PA0
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 按键输入 - PB1,内部上拉
    GPIO_InitStruct.Pin = GPIO_PIN_1;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    // 蜂鸣器输出 - PB2,初始低电平
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_2, GPIO_PIN_RESET);
    GPIO_InitStruct.Pin = GPIO_PIN_2;
    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);
}

小技巧:我习惯把 GPIO 初始化放在一个函数里,按“先时钟、再写电平、后配置模式”的顺序写。这样逻辑清晰,也方便后期维护。

4.2 按键输入与去抖处理

按键去抖,是每个嵌入式工程师的必修课。机械按键按下和松开时,触点会弹跳几毫秒到几十毫秒。如果不处理,一次按键可能被识别成多次。

去抖方法有两种:硬件去抖和软件去抖。

4.2.1 硬件去抖

硬件去抖就是在按键引脚上加一个 RC 低通滤波器。R 取 10kΩ,C 取 0.1μF,时间常数 τ = 1ms 左右。这样能滤掉大部分弹跳。

但硬件去抖会增加 BOM 成本。量产产品里,除非按键特别重要(比如急停按钮),否则我一般用软件去抖。

4.2.2 软件去抖

软件去抖的核心思路:检测到电平变化后,延迟一段时间再读一次。如果两次结果一致,就认为是有效按键。

我常用的去抖流程:

  1. 检测到按键按下(电平从高变低)
  2. 启动一个 20ms 的定时器
  3. 20ms 后再次读取按键电平
  4. 如果还是低电平,确认按键按下
  5. 如果变成高电平,认为是抖动,忽略

为什么要 20ms?这是我踩过坑总结出来的。太短(比如 5ms)去不干净,太长(比如 50ms)用户会觉得按键反应慢。20ms 是个折中值,大部分机械按键都能覆盖。

注意:不要用 HAL_Delay(20) 这种阻塞延时去抖。它会卡死整个系统,电机都转不了。要用状态机或者定时器轮询的方式。

下面是一个非阻塞去抖的代码示例:

typedef enum {
    KEY_IDLE,
    KEY_PRESS_DETECTED,
    KEY_PRESS_CONFIRMED,
    KEY_RELEASE_DETECTED
} KeyState_t;

KeyState_t keyState = KEY_IDLE;
uint32_t keyTimer = 0;

void Key_Scan(void)
{
    uint8_t keyLevel = HAL_GPIO_ReadPin(KEY_GPIO_PORT, KEY_PIN);

    switch (keyState) {
        case KEY_IDLE:
            if (keyLevel == 0) {  // 检测到按下
                keyState = KEY_PRESS_DETECTED;
                keyTimer = HAL_GetTick();
            }
            break;

        case KEY_PRESS_DETECTED:
            if (HAL_GetTick() - keyTimer > 20) {
                if (keyLevel == 0) {
                    keyState = KEY_PRESS_CONFIRMED;
                    // 执行按键动作
                    Key_Action();
                } else {
                    keyState = KEY_IDLE;  // 抖动,忽略
                }
            }
            break;

        case KEY_PRESS_CONFIRMED:
            if (keyLevel == 1) {  // 检测到松开
                keyState = KEY_RELEASE_DETECTED;
                keyTimer = HAL_GetTick();
            }
            break;

        case KEY_RELEASE_DETECTED:
            if (HAL_GetTick() - keyTimer > 20) {
                if (keyLevel == 1) {
                    keyState = KEY_IDLE;  // 松开确认
                } else {
                    keyState = KEY_PRESS_CONFIRMED;  // 抖动,回到按下状态
                }
            }
            break;

        default:
            keyState = KEY_IDLE;
            break;
    }
}

经验之谈:我曾经在一个项目中,按键去抖时间设成了 5ms。结果用户反馈“按一下有时会触发两次”。后来改成 20ms,问题就解决了。别小看这 20ms,它能让用户体验上一个台阶。

4.3 LED 指示

LED 指示在窗帘电机里很常见:电源指示灯、运行状态灯、故障报警灯。控制方式很简单,就是 GPIO 输出高低电平。

但有几个细节要注意:

  • 限流电阻:LED 不能直接接 GPIO,要串一个电阻。一般 5V 系统用 330Ω,3.3V 系统用 220Ω。电流控制在 5-10mA 就够了,太亮反而刺眼。
  • 低电平驱动 vs 高电平驱动:有些 MCU 的 GPIO 灌电流能力比拉电流强。如果 LED 阳极接 VCC,阴极接 GPIO,用低电平点亮,这样驱动能力更好。
  • 呼吸灯效果:用 PWM 控制 LED 亮度,可以实现呼吸灯。频率设到 100Hz 以上,人眼就感觉不到闪烁了。

下面是一个简单的 LED 控制函数:

void LED_Control(LED_t led, uint8_t state)
{
    switch (led) {
        case LED_POWER:
            HAL_GPIO_WritePin(LED_POWER_PORT, LED_POWER_PIN, state);
            break;
        case LED_RUN:
            HAL_GPIO_WritePin(LED_RUN_PORT, LED_RUN_PIN, state);
            break;
        case LED_ERROR:
            HAL_GPIO_WritePin(LED_ERROR_PORT, LED_ERROR_PIN, state);
            break;
        default:
            break;
    }
}

建议:把 LED 控制封装成函数,不要直接操作 GPIO。这样后期换引脚或者换 MCU,只需要改底层,上层逻辑不用动。

4.4 蜂鸣器控制

蜂鸣器分两种:有源蜂鸣器和无源蜂鸣器。

类型 驱动方式 频率控制 适用场景
有源蜂鸣器 直流电平 固定频率 简单提示音
无源蜂鸣器 PWM 方波 可调频率 音乐、多音调

窗帘电机里,我一般用有源蜂鸣器。成本低,驱动简单。只需要一个 GPIO 输出高电平,蜂鸣器就响。但要注意驱动电流——MCU 的 GPIO 通常只能输出几毫安,而蜂鸣器可能需要 30-50mA。所以要用三极管或 MOS 管驱动。

驱动电路很简单:GPIO 接三极管基极,蜂鸣器接集电极,发射极接地。GPIO 输出高电平,三极管导通,蜂鸣器响。

软件控制也很直接:

void Buzzer_On(void)
{
    HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_SET);
}

void Buzzer_Off(void)
{
    HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_RESET);
}

// 短促提示音:响 100ms,停 100ms
void Buzzer_Beep(void)
{
    Buzzer_On();
    HAL_Delay(100);
    Buzzer_Off();
    HAL_Delay(100);
}

避坑指南:我曾经在一个产品里,蜂鸣器直接接在 GPIO 上,没加驱动管。结果 GPIO 被拉低,MCU 直接复位。后来加了 2N2222 三极管,问题解决。记住:蜂鸣器是感性负载,启动电流大,一定要用驱动管。

4.5 综合示例:按键控制 LED 和蜂鸣器

把上面几个知识点串起来,做一个简单的功能:按一下按键,LED 翻转,蜂鸣器响一声。

void System_Process(void)
{
    Key_Scan();  // 非阻塞按键扫描

    if (keyState == KEY_PRESS_CONFIRMED) {
        // 翻转 LED
        static uint8_t ledState = 0;
        ledState = !ledState;
        LED_Control(LED_RUN, ledState);

        // 蜂鸣器响一声
        Buzzer_Beep();

        // 重置按键状态
        keyState = KEY_IDLE;
    }
}

这个例子虽然简单,但涵盖了 GPIO 配置、按键去抖、LED 控制、蜂鸣器驱动四个知识点。实际项目中,你只需要在这个基础上扩展——比如长按、双击、组合键等。

最后说一句:GPIO 是嵌入式开发的“手脚”,看似简单,但细节决定成败。我见过太多项目因为 GPIO 配置不当导致的问题。希望这一章能帮你少走弯路。