第三章:GPIO应用实战

好,咱们今天来点真家伙。前面两章把GPIO的寄存器、模式配置讲了个透,但说实话,光看寄存器是写不出好驱动的。你得真刀真枪地干几个项目,才能把那些配置项用活。

这一章,我挑了五个最常见的GPIO应用场景:按键输入、LED流水灯、蜂鸣器、继电器,还有用GPIO模拟I2C。每个场景我都会把代码拆开揉碎了讲,顺便聊聊我踩过的坑。

3.1 按键输入与消抖处理

按键,看起来最简单,其实最容易出问题。你想想看,一个机械按键按下去,触点会弹跳好几次,每次弹跳大概持续5-20ms。如果不做处理,一次按键会被识别成几十次。

我刚开始做项目时,就吃过这个亏。有一次做智能家居面板,用户按一下“开灯”,灯却闪了七八下。后来查了半天,发现就是没做消抖。

3.1.1 硬件消抖 vs 软件消抖

硬件消抖,说白了就是加个RC滤波电路。电容一充一放,把毛刺滤掉。但这样会增加BOM成本,而且电容值选不好反而会引入新问题。

我个人更推荐软件消抖。不花钱,还灵活。常用的方法有两种:

  • 延时消抖:检测到电平变化后,等10-20ms再读一次。简单粗暴,但会阻塞CPU。
  • 状态机消抖:用定时器轮询,记录连续N次采样结果。不阻塞,适合多按键场景。

核心原则:工业级产品必须用状态机消抖。延时消抖只适合教学演示,千万别用在量产项目里。

3.1.2 状态机消抖实现

来看代码。我用一个简单的三态机来实现:

typedef enum {
    KEY_STATE_IDLE,      // 空闲态
    KEY_STATE_DEBOUNCE,  // 消抖态
    KEY_STATE_PRESSED    // 确认按下态
} KeyState_t;

KeyState_t keyState = KEY_STATE_IDLE;
uint8_t debounceCount = 0;

void Key_Scan(void) {
    uint8_t level = GPIO_ReadPin(KEY_GPIO_PORT, KEY_GPIO_PIN);
    
    switch(keyState) {
        case KEY_STATE_IDLE:
            if(level == 0) {  // 检测到低电平(假设按键按下为低)
                keyState = KEY_STATE_DEBOUNCE;
                debounceCount = 0;
            }
            break;
            
        case KEY_STATE_DEBOUNCE:
            if(level == 0) {
                debounceCount++;
                if(debounceCount >= 5) {  // 连续5次采样为低,确认按下
                    keyState = KEY_STATE_PRESSED;
                    Key_EventCallback(KEY_EVENT_PRESSED);
                }
            } else {
                keyState = KEY_STATE_IDLE;  // 中间有高电平,复位
                debounceCount = 0;
            }
            break;
            
        case KEY_STATE_PRESSED:
            if(level == 1) {  // 检测到释放
                keyState = KEY_STATE_IDLE;
                Key_EventCallback(KEY_EVENT_RELEASED);
            }
            break;
    }
}

经验之谈:这个函数要放在1ms的定时器中断里调用。5次采样就是5ms的消抖时间,对于大多数机械按键足够了。如果按键特别老旧,可以调到10次。

3.2 LED流水灯

流水灯,每个嵌入式工程师的入门仪式。但别小看它,这里藏着GPIO输出速度、电流驱动能力这些关键知识点。

3.2.1 硬件设计要点

LED不能直接接GPIO。为什么?因为GPIO的驱动电流一般只有几mA到20mA,而LED需要5-20mA才能正常发光。直接接的话,要么LED不够亮,要么GPIO被烧坏。

正确的做法是:GPIO → 限流电阻 → LED → GND。电阻值用欧姆定律算:R = (Vcc - Vf) / If。Vf是LED正向压降,红色约1.8V,蓝色约3.0V。

3.2.2 软件实现

流水灯的核心就是循环移位。我习惯用查表法,可读性好,也方便修改流水模式:

const uint8_t ledPattern[] = {
    0x01, 0x02, 0x04, 0x08,  // 单灯左移
    0x10, 0x20, 0x40, 0x80,  // 继续左移
    0x80, 0x40, 0x20, 0x10,  // 单灯右移
    0x08, 0x04, 0x02, 0x01   // 继续右移
};

void LED_Flow(void) {
    static uint8_t index = 0;
    
    GPIO_WritePort(LED_GPIO_PORT, ledPattern[index]);
    index++;
    if(index >= sizeof(ledPattern)) {
        index = 0;
    }
}

注意:如果LED数量超过8个,记得用多个GPIO端口组合。别把不同端口的引脚混在一个字节里操作,那样会出大问题。我曾经见过有人把PA0和PB0放在同一个字节里操作,结果LED乱闪——因为两个端口的基地址不同。

3.3 蜂鸣器驱动

蜂鸣器分两种:有源和无源。有源蜂鸣器给电就响,无源的需要给PWM波才能发声。工业上常用有源的做报警,无源的做音乐提示。

3.3.1 有源蜂鸣器驱动

有源蜂鸣器驱动最简单,GPIO输出高电平就响。但要注意:蜂鸣器是感性负载,关断时会产生反向电动势。所以驱动电路里一定要加续流二极管,否则容易烧GPIO。

我建议用三极管或MOS管驱动,别直接用GPIO推。STM32的GPIO输出电流有限,推不动大功率蜂鸣器。

3.3.2 无源蜂鸣器PWM驱动

无源蜂鸣器需要特定频率的方波才能发声。人耳最敏感的频率范围是2-4kHz。我一般用定时器输出PWM,占空比50%时声音最响。

void Buzzer_Init(void) {
    // 配置定时器,输出2.7kHz PWM
    TIM_TimeBaseInitTypeDef tim;
    TIM_OCInitTypeDef oc;
    
    tim.TIM_Prescaler = 72 - 1;      // 72MHz / 72 = 1MHz
    tim.TIM_Period = 370 - 1;        // 1MHz / 370 ≈ 2.7kHz
    TIM_TimeBaseInit(TIM2, &tim);
    
    oc.TIM_OCMode = TIM_OCMode_PWM1;
    oc.TIM_Pulse = 185;              // 50%占空比
    oc.TIM_OutputState = TIM_OutputState_Enable;
    TIM_OC1Init(TIM2, &oc);
    
    TIM_Cmd(TIM2, ENABLE);
}

void Buzzer_On(void) {
    TIM_CtrlPWMOutputs(TIM2, ENABLE);
}

void Buzzer_Off(void) {
    TIM_CtrlPWMOutputs(TIM2, DISABLE);
}

小技巧:如果想做“嘀嘀嘀”的报警音,可以用一个定时器中断,每500ms切换一次蜂鸣器状态。这样听起来就是标准的报警节奏。

3.4 继电器控制

继电器是弱电控制强电的桥梁。MCU的3.3V GPIO控制不了220V的交流电,但通过继电器就可以。

3.4.1 驱动电路设计

继电器线圈需要的电流通常在50-100mA,GPIO根本带不动。所以必须用三极管或达林顿管驱动。我常用的电路是:GPIO → 三极管基极(串1k电阻)→ 继电器线圈 → 12V电源。线圈两端并联续流二极管,方向要反着接。

血的教训:续流二极管绝对不能省!我曾经在一个项目中偷懒没加,结果继电器关断时产生的反向电压直接把MCU的GPIO烧了。那次返修花了我整整两天时间。

3.4.2 软件控制

继电器控制本身很简单,GPIO输出高低电平就行。但要注意:继电器动作时会产生电磁干扰,所以不要在继电器动作的瞬间去读ADC或进行高精度定时。

void Relay_Control(uint8_t channel, uint8_t state) {
    switch(channel) {
        case 0:
            GPIO_WriteBit(RELAY_GPIO_PORT, RELAY_PIN_1, 
                         (state) ? Bit_SET : Bit_RESET);
            break;
        case 1:
            GPIO_WriteBit(RELAY_GPIO_PORT, RELAY_PIN_2,
                         (state) ? Bit_SET : Bit_RESET);
            break;
        default:
            break;
    }
    
    // 继电器动作后,等待10ms让干扰过去
    Delay_ms(10);
}

3.5 GPIO模拟I2C时序

I2C总线用两根线:SCL(时钟)和SDA(数据)。很多MCU有硬件I2C外设,但有时候引脚不够用,或者硬件I2C有bug,就得用GPIO模拟。

3.5.1 I2C时序基础

I2C的时序其实不复杂,就几个关键动作:

  • 起始信号:SCL高电平时,SDA从高变低
  • 停止信号:SCL高电平时,SDA从低变高
  • 数据发送:SCL低电平时改变SDA,SCL高电平时采样SDA
  • 应答信号:第9个时钟周期,从机拉低SDA表示应答

3.5.2 模拟实现

我习惯用宏定义来封装GPIO操作,这样代码移植起来方便:

#define I2C_SCL_HIGH()   GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN)
#define I2C_SCL_LOW()    GPIO_ResetBits(I2C_GPIO_PORT, I2C_SCL_PIN)
#define I2C_SDA_HIGH()   GPIO_SetBits(I2C_GPIO_PORT, I2C_SDA_PIN)
#define I2C_SDA_LOW()    GPIO_ResetBits(I2C_GPIO_PORT, I2C_SDA_PIN)
#define I2C_SDA_READ()   GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN)

void I2C_Start(void) {
    I2C_SDA_HIGH();
    I2C_SCL_HIGH();
    Delay_us(5);
    I2C_SDA_LOW();
    Delay_us(5);
    I2C_SCL_LOW();
}

void I2C_Stop(void) {
    I2C_SDA_LOW();
    I2C_SCL_HIGH();
    Delay_us(5);
    I2C_SDA_HIGH();
    Delay_us(5);
}

uint8_t I2C_SendByte(uint8_t data) {
    uint8_t i;
    uint8_t ack;
    
    for(i = 0; i < 8; i++) {
        if(data & 0x80) {
            I2C_SDA_HIGH();
        } else {
            I2C_SDA_LOW();
        }
        data <<= 1;
        Delay_us(2);
        I2C_SCL_HIGH();
        Delay_us(5);
        I2C_SCL_LOW();
        Delay_us(2);
    }
    
    // 接收应答
    I2C_SDA_HIGH();  // 释放SDA
    Delay_us(2);
    I2C_SCL_HIGH();
    Delay_us(5);
    ack = I2C_SDA_READ();
    I2C_SCL_LOW();
    
    return ack;  // 0表示应答,1表示非应答
}

经验之谈:模拟I2C的延时时间要根据实际使用的MCU主频调整。我一般先用100kHz标准模式调试,没问题后再提速到400kHz快速模式。如果遇到通信不稳定,先把速度降下来,八成能解决问题。

3.6 综合应用示例

最后,我把这五个功能整合到一个例子里。假设我们要做一个智能控制板:按键控制LED流水灯启停,蜂鸣器在按键时响一声,继电器控制外接设备,I2C读取温湿度传感器。

void main(void) {
    System_Init();
    
    while(1) {
        Key_Scan();  // 1ms定时器中断中调用
        
        if(Key_GetEvent() == KEY_EVENT_PRESSED) {
            Buzzer_On();
            Delay_ms(50);
            Buzzer_Off();
            
            LED_Flow_Toggle();  // 切换流水灯启停
            Relay_Control(0, !Relay_GetState(0));  // 切换继电器
        }
        
        // 每1秒读取一次温湿度
        if(Timer_GetFlag_1s()) {
            uint8_t temp, hum;
            I2C_ReadSensor(&temp, &hum);
            UART_SendData(temp);
            UART_SendData(hum);
        }
    }
}

嗯,这一章的内容就到这里。五个应用场景,每个都包含了硬件设计和软件实现。你可能会觉得有些地方讲得不够细,没关系,后面的章节会针对每个外设做更深入的剖析。

下一章,咱们聊聊定时器。这可是MCU里最灵活的外设,没有之一。