4、GPIO与基础外设驱动:GPIO模式配置(推挽、开漏)、中断编程、定时器PWM输出实战

各位同学,今天我们聊点硬核的。GPIO,说白了就是芯片的“手脚”。你让它动,它就动;你让它停,它就停。但很多人写驱动,GPIO 配置错了,整个板子就废了。我见过太多人,上来就怼代码,结果推挽和开漏都分不清,中断触发方式搞反,最后查 bug 查到怀疑人生。

嗯,今天这一章,我就带你把这些坑一个个填平。咱们从最基础的 GPIO 模式讲起,再到中断编程,最后用定时器输出 PWM,来一个完整的实战闭环。

4.1 GPIO 模式配置:推挽 vs 开漏

先问一个问题:你写 GPIO 输出时,有没有想过,为什么有的引脚能直接驱动 LED,有的却要加个上拉电阻?

答案就在模式里。

4.1.1 推挽输出(Push-Pull)

推挽输出,我习惯叫它“强推强拉”。内部有两个 MOS 管,一个负责推高电平,一个负责拉低电平。输出高时,上管导通,直接怼到 VCC;输出低时,下管导通,直接拉到 GND。

特点:

  • 输出高低电平都很“硬”,驱动能力强
  • 不能“线与”连接(多个输出直接短接会打架)
  • 适合驱动 LED、蜂鸣器、继电器等

重要:推挽模式下,输出高电平时,引脚内部直接连到 VCC。如果你把两个推挽输出引脚短接,一个输出高,一个输出低,那就是短路!我在项目中遇到过,一个同事调试时没注意,直接把两个 GPIO 短接了,芯片瞬间发烫。嗯,从那以后,我每次画板子都会在 GPIO 输出路径上加个 100Ω 限流电阻。

4.1.2 开漏输出(Open-Drain)

开漏输出,说白了就是“只拉低,不推高”。内部只有一个下管,输出低时拉到 GND,输出高时,引脚处于高阻态(相当于断开)。

所以,开漏输出必须外接上拉电阻,才能输出高电平。

特点:

  • 支持“线与”连接(多个开漏输出可以直接短接)
  • 电平转换方便(上拉到不同电压,就能输出不同电平)
  • 适合 I2C、SMBus 等总线协议

我的习惯:做 I2C 驱动时,我从来不用推挽。为什么?因为 I2C 是多主机总线,多个设备可能同时拉低 SCL/SDA。如果用推挽,一个设备拉低,另一个设备拉高,直接短路。开漏加外部上拉,才是正道。

4.1.3 模式选择速查表

场景 推荐模式 原因
驱动 LED(低电平点亮) 推挽 驱动能力强,无需外部上拉
I2C 总线 开漏 支持线与,电平灵活
按键输入检测 浮空输入 / 上拉输入 避免引脚悬空,减少干扰
驱动 MOSFET 栅极 推挽 需要快速充放电
多设备共享中断线 开漏 多个设备可同时触发

4.2 中断编程:别让 CPU 傻等

你想想看,如果 CPU 一直轮询 GPIO 电平变化,那得多浪费?中断就是用来解决这个问题的。引脚电平一变,CPU 立刻停下手里的事,跳去处理。

但中断编程,坑也不少。

4.2.1 中断触发方式

  • 上升沿触发:电平从低变高时触发
  • 下降沿触发:电平从高变低时触发
  • 双边沿触发:电平变化就触发
  • 电平触发:电平保持高或低时持续触发

注意:电平触发容易产生“抖动”。比如按键按下时,机械触点会弹跳,电平会反复变化。如果用上升沿触发,一次按键可能触发多次中断。我曾经被这个坑过,调试时发现按键按一次,串口打印了七八次。后来加了 20ms 的软件消抖,才搞定。

4.2.2 中断服务函数(ISR)的写法

ISR 里,我有一条铁律:能少写就少写。ISR 里不要做复杂运算,不要调用 printf,不要申请内存。你想想看,ISR 执行时间太长,其他中断就进不来了,系统实时性就崩了。

// 正确的做法:ISR 里只设置标志位
volatile uint8_t g_button_pressed = 0;

void EXTI0_IRQHandler(void)
{
    if (EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        g_button_pressed = 1;  // 只设标志
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

// 主循环里处理
while (1)
{
    if (g_button_pressed)
    {
        g_button_pressed = 0;
        // 在这里做实际处理,比如切换 LED
        GPIO_ToggleBits(GPIOA, GPIO_Pin_5);
    }
}

我的建议:中断标志位一定要用 volatile 修饰。为什么?因为编译器可能会优化掉对它的读取。我刚开始写嵌入式时,就因为这个 bug 查了整整两天。volatile 告诉编译器:这个变量随时可能被外部改变,别给我优化。

4.3 定时器 PWM 输出实战

PWM,说白了就是“快速开关”。通过调节高电平占整个周期的比例(占空比),来控制电机速度、LED 亮度、舵机角度。

用定时器硬件输出 PWM,比软件模拟要稳定得多。CPU 只需要配置好,定时器自己就会干活,CPU 可以腾出手来做别的事。

4.3.1 定时器 PWM 配置步骤

  1. 使能定时器时钟(别漏了,我经常忘)
  2. 配置 GPIO 为复用功能(不是普通推挽输出)
  3. 设置定时器时基(预分频、自动重装值)
  4. 配置 PWM 模式(向上计数、向下计数、中央对齐)
  5. 设置占空比(比较值)
  6. 使能定时器
// 以 STM32F103 为例,TIM2 输出 PWM
void PWM_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    TIM_OCInitTypeDef TIM_OCInitStructure;

    // 1. 使能时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    // 2. 配置 PA0 为 TIM2_CH1 复用输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;  // 复用推挽
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // 3. 定时器时基:72MHz / 72 = 1MHz, 计数到 1000 = 1kHz
    TIM_TimeBaseStructure.TIM_Period = 999;          // 自动重装值
    TIM_TimeBaseStructure.TIM_Prescaler = 71;        // 预分频
    TIM_TimeBaseStructure.TIM_ClockDivision = 0;
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);

    // 4. 配置 PWM 模式 1
    TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
    TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
    TIM_OCInitStructure.TIM_Pulse = 500;             // 占空比 50%
    TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
    TIM_OC1Init(TIM2, &TIM_OCInitStructure);

    // 5. 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}

4.3.2 占空比动态调整

实际项目中,占空比很少是固定的。比如呼吸灯,需要从 0% 慢慢变到 100%,再变回来。

void Set_Duty(uint16_t duty)
{
    if (duty > 999) duty = 999;  // 限幅
    TIM_SetCompare1(TIM2, duty);
}

// 呼吸灯效果
void Breath_LED(void)
{
    for (uint16_t i = 0; i < 1000; i++)
    {
        Set_Duty(i);
        delay_ms(1);
    }
    for (uint16_t i = 999; i > 0; i--)
    {
        Set_Duty(i);
        delay_ms(1);
    }
}

避坑指南:我曾经在调整占空比时,忘了限幅。结果 duty 传了个 2000 进去,而自动重装值只有 999。定时器直接“溢出”了,输出波形完全乱掉。嗯,从那以后,我所有设置占空比的函数,第一行一定是限幅判断。

4.4 实战:按键控制 LED 亮度

把今天学的串起来:按键中断 + PWM 调光。

需求:按一下按键,LED 亮度增加 10%,到 100% 后回到 0%。

实现思路:

  • 按键接 PA0,配置为下降沿中断
  • LED 接 PA1,配置为 TIM2_CH2 PWM 输出
  • 中断里设置标志位,主循环里调整占空比
volatile uint8_t g_key_pressed = 0;
uint16_t g_duty = 0;

void EXTI0_IRQHandler(void)
{
    if (EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        g_key_pressed = 1;
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

int main(void)
{
    PWM_Init();  // 初始化 PWM
    Key_Init();  // 初始化按键中断

    while (1)
    {
        if (g_key_pressed)
        {
            g_key_pressed = 0;
            g_duty += 100;  // 每次增加 10%
            if (g_duty > 999)
            {
                g_duty = 0;  // 回到 0%
            }
            Set_Duty(g_duty);
        }
    }
}

你看,代码量不大,但把 GPIO 模式、中断、PWM 全串起来了。这就是工业驱动开发的典型套路:外设配置 + 中断响应 + 状态机处理

好了,这一章就到这里。下一章我们聊 SPI 通信,那玩意儿比 I2C 快,但配置也更复杂。到时候我会分享一个我踩过的坑——SPI 时钟极性搞反,通信全是乱码。嗯,到时候细说。