第三章: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里最灵活的外设,没有之一。