第四章:GPIO与PWM基础

各位同学,欢迎来到第四章。这一章我们要聊的是嵌入式控制里最基础、也最实用的两个东西——GPIO和PWM。

说实话,我刚入行那会儿,觉得GPIO不就是个高低电平嘛,有啥好学的?直到我在一个电机驱动项目里,因为GPIO模式没配对,导致整个板子电流倒灌、芯片发热严重……嗯,从那以后我再也不敢小看GPIO的配置了。

至于PWM,它更是无处不在。调光、调速、调音,说白了就是靠它。今天我们就从原理到实战,把这两个东西彻底讲透。

4.1 GPIO模式配置——不只是输入输出那么简单

很多初学者以为GPIO只有两种模式:输入和输出。其实不然。我习惯把GPIO模式分成以下几类:

  • 推挽输出:最常用的输出模式。可以输出高电平或低电平,驱动能力较强。
  • 开漏输出:只能输出低电平,高电平要靠外部上拉电阻。常用于I2C总线这类需要“线与”的场合。
  • 浮空输入:引脚电平完全由外部决定,内部没有上下拉。容易受干扰,我一般不太用。
  • 上拉/下拉输入:内部有弱上拉或弱下拉电阻,防止引脚悬空时电平不确定。
  • 模拟输入:用于ADC采样,信号直接进ADC模块,不走数字逻辑。

重点提醒:配置GPIO时,一定要先想清楚外设的需求。比如驱动LED,用推挽输出就够了;但如果要读取按键,最好用上拉输入,省掉外部电阻。

我在一个项目中遇到过这样的情况:用开漏输出驱动一个LED,结果LED死活不亮。查了半天才发现,开漏输出高电平是靠外部上拉的,而我根本没加上拉电阻。你想想看,这不就相当于把LED的正极悬空了吗?

4.2 PWM原理——方波里的学问

PWM,全称Pulse Width Modulation,脉冲宽度调制。说白了,就是通过调节一个方波里高电平占的比例,来模拟出不同的电压效果。

为什么会这样?因为人眼、电机这类“惯性系统”对快速变化的信号不敏感。你让LED以几百赫兹的频率闪烁,人眼看到的就是一个稳定的亮度。占空比越大,亮度越高。

PWM的几个关键参数:

  • 频率:方波每秒钟重复的次数,单位Hz。频率太低会闪烁,太高则增加功耗。
  • 周期:频率的倒数,单位秒。一个完整方波的时间长度。
  • 占空比:高电平时间占整个周期的百分比。0%就是一直低电平,100%就是一直高电平。

我的经验:做LED呼吸灯,PWM频率选1kHz左右就够用了。电机调速的话,频率要选高一些,比如10kHz以上,否则电机会发出刺耳的啸叫声。我曾经就被这个声音折磨过好几天……

4.3 频率与占空比计算——手把手教你算

假设我们用一个定时器来产生PWM。定时器的时钟频率是72MHz,我们想要输出一个1kHz的PWM,占空比50%。怎么算?

第一步,算周期。1kHz对应的周期是1ms,也就是1000微秒。

第二步,算定时器的计数周期。定时器通常有一个自动重装载寄存器ARR,它决定了计数器的上限。ARR + 1 就是总的计数值。

第三步,算分频系数。72MHz / (ARR + 1) / (PSC + 1) = 目标频率。其中PSC是预分频器。

举个例子:

// 目标:1kHz PWM,50%占空比
// 时钟:72MHz

// 先选预分频,让计数频率降下来
// 选 PSC = 71,则计数频率 = 72MHz / (71+1) = 1MHz
// 1MHz 计数一次是 1微秒

// 要得到1kHz,周期 = 1000微秒
// 所以 ARR = 1000 - 1 = 999

// 占空比50%,则比较值 CCR = 500
// 当计数器 < 500 时输出高电平,>= 500 时输出低电平

TIM_TimeBaseInitTypeDef TIM_InitStruct;
TIM_OCInitTypeDef TIM_OCInitStruct;

// 使能时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

// 配置时基
TIM_InitStruct.TIM_Prescaler = 71;        // 预分频
TIM_InitStruct.TIM_Period = 999;          // 自动重装载值
TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_InitStruct);

// 配置PWM通道
TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1;
TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable;
TIM_OCInitStruct.TIM_Pulse = 500;         // 占空比50%
TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_OC1Init(TIM2, &TIM_OCInitStruct);

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

注意:不同型号的MCU,定时器配置方式略有差异。但原理是一样的:ARR决定周期,CCR决定占空比。我曾经在STM32F1和F4之间移植代码时,就因为TIM_OCMode的枚举值不同,折腾了半天。

4.4 用PWM控制LED呼吸灯——实战演练

好了,理论讲完了,我们来做个呼吸灯。呼吸灯的效果就是LED从暗到亮,再从亮到暗,循环往复。

实现思路很简单:定时改变PWM的占空比。从0%逐渐增加到100%,再从100%逐渐减小到0%。

我习惯用定时器中断来更新占空比。每过一段时间,在中断服务函数里修改CCR的值。

// 全局变量
uint16_t duty = 0;
uint8_t direction = 1;  // 1: 增加, 0: 减少

// 定时器中断服务函数(假设每10ms进入一次)
void TIM2_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
    {
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
        
        // 更新占空比
        if (direction)
        {
            duty += 10;  // 每次增加10
            if (duty >= 1000)
            {
                duty = 1000;
                direction = 0;  // 改为减少方向
            }
        }
        else
        {
            if (duty >= 10)
                duty -= 10;
            else
            {
                duty = 0;
                direction = 1;  // 改为增加方向
            }
        }
        
        // 设置新的占空比
        TIM_SetCompare1(TIM2, duty);
    }
}

你想想看,这个代码里有个小细节:占空比从0到1000,对应0%到100%。每次变化10步,总共100步。如果定时器每10ms中断一次,那么一个完整的呼吸周期就是100步 × 10ms × 2(上升+下降)= 2秒。

调优建议:如果你觉得呼吸太快或太慢,可以调整中断频率或者每次变化的步长。我个人喜欢把步长设小一点,比如5,这样呼吸更平滑。但要注意,步长太小的话,MCU频繁进中断,会占用CPU资源。

4.5 避坑指南——我踩过的那些坑

做PWM控制,有几个坑是新手最容易踩的。我一个个说:

  • 坑一:GPIO复用功能没开。很多MCU的PWM输出引脚需要配置为复用功能模式,否则输出不了PWM信号。我曾经在STM32上忘了开AFIO时钟,结果PWM死活没输出。
  • 坑二:定时器时钟没使能。这个看起来简单,但忙起来真的容易忘。尤其是多个定时器同时使用时,漏掉一个就全白干。
  • 坑三:占空比和ARR搞混。记住,ARR决定周期,CCR决定占空比。CCR不能大于ARR,否则输出会一直高电平。
  • 坑四:频率选得太低。LED呼吸灯如果频率低于50Hz,人眼就能看到闪烁。电机调速如果频率低于1kHz,电机会发出嗡嗡声。

总结一下:GPIO配置要选对模式,PWM计算要算准参数,呼吸灯实现要控制好节奏。这些东西看起来简单,但每一个细节都可能让你的项目翻车。我当年做第一个呼吸灯时,就因为忘了配置GPIO的复用功能,查了整整两天……

好了,这一章的内容就到这里。下一章我们会讲定时器的更多高级用法,包括输入捕获和编码器接口。到时候你会发现,PWM只是定时器能力的冰山一角。

记住,动手实践是最好的学习方式。拿起你的开发板,把今天的呼吸灯代码跑一遍。有问题随时交流。