PWM 为什么能调速:从 LED 亮度变化到直流电机转起来

0 阅读17分钟

经典三问

Q: PWM 是什么?
A: PWM 是以固定周期重复输出高低电平,并用高电平所占时间比例表达控制量的信号。

Q: 为什么 PWM 能控制电机速度?
A: 驱动器把脉冲转换成电机线圈获得的平均能量,改变占空比就能改变平均转矩和转速范围。

Q: PWM 常见使用场景?
A: 电机调速、LED 调光、舵机脉宽控制、加热功率调节和开关电源控制。


把 TT 马达的两根线接到电池上,轮子突然冲起来;把线拔掉,它又立刻安静。STM32 的 GPIO 只有高电平和低电平,程序里写的也是 SetBits()、ResetBits()。那“慢一点”从哪里来?难道要让 GPIO 输出一个中间电压?

GPIO 做不到把 3.3V 变成某个稳定的“半高电平”,也没有必要真的去找这个电压。既然每一次输出只能是 3.3V 或 0V,就把注意力放到另一个地方:这一段时间里,3.3V 出现了多久,0V 又出现了多久?如果高电平只出现一小段时间,电机得到的驱动能量就少一些;高电平保持得更久,得到的能量就多一些。

所以问题变成了:在一个固定的时间段里,怎样安排高电平和低电平的持续时间,才能让这种“多一点”或“少一点”稳定地重复?

可以把一个固定的时间段切成两部分:比如一个周期是 100us,前 20us 输出高电平,后 80us 输出低电平,然后不断重复。PA0 上的电压从头到尾仍然只有 3.3V 和 0V,并没有真的出现 0.66V;只是这一周期里,高电平占了 20% 的时间。把高电平换成 50us,就是 50% 占空比;换成 80us,就是 80% 占空比。

这个切换速度足够快时,人的眼睛来不及分辨每一次亮灭,看到的是不同的平均亮度;电机的线圈、电感和转子也不会在每个瞬间立刻停下,而是把连续很多个周期里的能量积累成平均转矩。高电平占得多,平均得到的能量通常就多一些,电机也就有机会转得更快。

这种“只输出高或低,却能控制平均效果”的方法,就是 PWM。它没有制造一个中间电压,而是用一个固定周期里的时间比例表达控制量。

LED 的亮度会随高电平所占时间变化。LED 本身仍然只有亮和灭两种状态,快速重复这两种状态后,人眼看到的是不同的平均亮度。下面的程序用 Delay_us() 手动翻转 GPIO,让 LED 分别保持在几个亮度档位:

#include "Delay.h"
#include "led.h"

static void SoftwarePwm_ShowLevel(uint8_t brightness, uint16_t cycles)
{
    uint16_t phase;

    if (brightness > 20)
    {
        brightness = 20;
    }

    while (cycles > 0)
    {
        for (phase = 0; phase < 20; phase++)
        {
            if (phase < brightness)
            {
                LED_On();
            }
            else
            {
                LED_Off();
            }
            Delay_us(50);
        }

        cycles--;
    }
}

int main(void)
{
    LED_Init();
    Delay_Init();

    while (1)
    {
        SoftwarePwm_ShowLevel(5, 500);
        SoftwarePwm_ShowLevel(10, 500);
        SoftwarePwm_ShowLevel(15, 500);
    }
}

以 brightness = 5 为例,内层循环一共执行 20 次:前 5 次让 LED 亮,每次保持 50us;后 15 次让 LED 灭,同样每次保持 50us。一个完整周期就是 20 × 50us = 1000us,其中高电平持续 5 × 50us = 250us,所以占空比约为 25%。cycles = 500 表示把这个周期重复 500 次,这一档亮度会保持约 500ms。brightness 等于 10、15 时,占空比约为 50%、75%,肉眼能看到三个不同的亮度档位。

这段程序没有让 LED 输出某个中间电压。LED_On() 和 LED_Off() 每次仍然只让 LED 亮或灭,亮度档位来自一个周期内亮、灭时间比例的变化。它只是用软件模仿 PWM,不是连续调光。

这个函数没有返回以前,主循环无法执行其他代码,按键、OLED 和蜂鸣器也会一起停下来。原因是每一次亮灭都由 CPU 执行 LED_On()、LED_Off() 和 Delay_us() 完成。

TIM2 怎样把 PA0 变成 20kHz

下面使用 TIM2_CH1 / PA0 输出 PWM。系统时钟按 72MHz、APB1 定时器时钟按 72MHz 计算,PWM 频率设为 20kHz:

PWM 频率 = 定时器时钟 / (PSC + 1) / (ARR + 1)
20,000Hz = 72,000,000Hz / 72 / 50

所以寄存器里的两个值写成:

timer.TIM_Prescaler = 72 - 1;
timer.TIM_Period = 50 - 1;

计数器实际以 1MHz 的速度走,ARR=49 让它从 0 数到 49,一共 50 个计数,刚好每 50us 更新一次。

硬件 PWM 的一次周期可以按下面的过程理解:TIM2 的计数器从 0 开始递增,比较器不断把当前计数值 CNT 和 CCR1 比较。计数值小于 CCR1 时,PWM 输出单元让 PA0 保持高电平;计数到 CCR1 后,输出单元切换为低电平;计数到 ARR=49 产生更新事件,计数器回到 0,下一周期重新开始。这个过程完全在定时器内部进行,不需要中断,也不需要 CPU 逐次改写 PA0。

例如 CCR1=25 时,一个周期的 50 个计数里有 25 个计数为高,占空比就是 50%。把 CCR1 改成 10、40,得到的就是约 20%、80% 占空比。定时器的复用输出电路再把这个结果连接到 PA0,GPIO 配成复用推挽后,PA0 看到的就是这条硬件 PWM 波形。

CNT:  0  1  2  ... 24 | 25 26 27 ... 49 | 回到 0
PA0:  高 高 高  ... 高 | 低 低 低 ... 低 | 重新输出高

PSC 决定计数器走得多快,ARR 决定一个周期有多长,CCR1 决定每个周期高电平保持多久。三者分工不同:改 CCR1 会改变占空比,改 ARR 会改变周期和 PWM 频率。

void PWM_Init(void)
{
    GPIO_InitTypeDef gpio;
    TIM_TimeBaseInitTypeDef timer;
    TIM_OCInitTypeDef pwm;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

    gpio.GPIO_Pin = GPIO_Pin_0;
    gpio.GPIO_Mode = GPIO_Mode_AF_PP;
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &gpio);

    timer.TIM_Prescaler = 72 - 1;
    timer.TIM_CounterMode = TIM_CounterMode_Up;
    timer.TIM_Period = 50 - 1;
    timer.TIM_ClockDivision = TIM_CKD_DIV1;
    timer.TIM_RepetitionCounter = 0;
    TIM_TimeBaseInit(TIM2, &timer);

    pwm.TIM_OCMode = TIM_OCMode_PWM1;
    pwm.TIM_OutputState = TIM_OutputState_Enable;
    pwm.TIM_Pulse = 0;
    pwm.TIM_OCPolarity = TIM_OCPolarity_High;
    TIM_OC1Init(TIM2, &pwm);
    TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable);
    TIM_ARRPreloadConfig(TIM2, ENABLE);

    TIM_SetCompare1(TIM2, 0);
    TIM_Cmd(TIM2, ENABLE);
}

TIM_OC1Init() 配置输出比较通道和 PWM 模式,TIM_SetCompare1() 把新的比较值写入 CCR1。它改的不是 GPIO 电平,而是“这一周期高电平持续到哪个计数值”。计数和翻转由 TIM2 硬件持续完成,主循环不需要每几十微秒手动翻一次 PA0。

用 LED 验证占空比

将 PA0 的 PWM 输出接到 LED 的控制端,初始化后循环设置四个比较值,每档保持 1 秒:

PWM_Init();

while (1)
{
    TIM_SetCompare1(TIM2, 0);
    Delay_ms(1000);

    TIM_SetCompare1(TIM2, 10);
    Delay_ms(1000);

    TIM_SetCompare1(TIM2, 25);
    Delay_ms(1000);

    TIM_SetCompare1(TIM2, 40);
    Delay_ms(1000);
}

LED 仍然只有亮和灭两种状态,但看到的平均亮度会随比较值增加而变亮。对应的计数关系如下:

比较值占空比预期高电平时间
00%近似 0us
1020%约 10us
2550%约 25us
4080%约 40us

比较值等于周期计数长度时,输出接近 100%。具体边沿可能因 PWM 模式和定时器实现略有差异。需要确认的是:频率稳定、占空比随比较值改变、比较值为 0 时不会莫名输出一串窄脉冲。

如果 LED 的亮度完全不随比较值变化,先查 TIM2 时钟、PA0 是否配置为复用推挽、工程是否重映射了 TIM2_CH1,以及初始化后有没有把 PA0 又当普通 GPIO 使用。

PWM 接入电机

PWM 输出脚不能直接连接电机。PA0 能输出 3.3V 方波,但不能提供电机启动电流,也不能承受电机断电时的反冲电压。电机必须通过 TB6612FNG、L298N 等驱动板连接。

驱动板可以是 TB6612FNG,也可以是 L298N;电机可以是 TT 马达,也可以是 MG310 一类带编码器的电机。它们的额定电压、逻辑电平、停止方式和启动门槛都要以自己买到的产品资料为准,不能因为接口名字看起来一样,就把一张接线图照抄到底。

正确的电流路径是:

12V 电池
    |
    +--> 降压模块 --> 按电机/驱动板规格提供电机电源
    |
    +--> 降压模块 --> STM32 和逻辑电源

STM32 PA0 / PB12 / PB13 / PB10
    |
    v
TB6612FNG 或 L298N 的逻辑输入
    |
    v
驱动板功率输出 --> 电机

电池、驱动板、STM32 必须共地。共地只是让“高”和“低”有同一个参考,不是让电机启动电流沿着 Blue Pill 的 3.3V 细线绕一圈。电机电源线、逻辑电源线和地线怎么走,直接影响 STM32 会不会在轮子启动时重启。

降压模块的输出电压不要凭记忆填写。TB6612 的 VM 可以接到 12V,很多 TB6612FNG 芯片资料给出的电机电源上限约为 13.5V,12V 在范围内。L298N 模块常见的电源和板载稳压方式也不完全一样。TT 马达的额定电压和 MG310 的电机绕组电压更不能混用,MG310 的编码器供电也要看它的商品资料。先看你手上的板子丝印、数据手册和电机铭牌,再把 12V 电池降到它们允许的范围。

12V 是电池的输入,不是所有电机和所有驱动板都应该直接看到的电压。

同一组控制信号,接两种驱动板

STM32 这一侧的信号约定如下:

STM32 引脚逻辑名字作用
PA0 / TIM2_CH1PWM控制有效驱动时间比例
PB12IN1方向输入 1
PB13IN2方向输入 2
PB10STBYTB6612 的待机控制
GNDGND与驱动板共地

TB6612FNG 的 A 通道可以这样对应:

PA0  -> PWMA
PB12 -> AIN1
PB13 -> AIN2
PB10 -> STBY
AO1/AO2 -> 电机两根线
VM/VCC/GND -> 按模块和芯片资料连接

L298N 常见模块的名字会变成 ENA、IN1、IN2、OUT1、OUT2:

PA0  -> ENA(拔掉 ENA 跳帽,才能让 PWM 接管使能)
PB12 -> IN1
PB13 -> IN2
OUT1/OUT2 -> 电机两根线
PB10 不接,L298N 没有 TB6612 那个 STBY 输入

L298N 的模块版本很多,有的带 5V 稳压、有的跳帽决定 5V 的进出;TB6612 模块也有不同的电源标注。把逻辑输入、电机电源、逻辑电源和地按手上模块的资料分别接好。

工程中的这个宏只负责告诉代码“有没有待机脚”:

#define MOTOR_HAS_STBY 1

接 TB6612 保持 1;接 L298N 改成 0。方向和 PWM 的上层接口不需要变化。

接口相似,功率级完全不是一回事

TB6612FNG 使用 MOSFET 输出级,通常压降和发热都比较小;L298N 是更老的双极型晶体管驱动,输出压降明显,低电压电机尤其容易遇到“输入已经给了,电机端却少了一截电压”的情况。L298N 模块上那块散热片不是装饰,电流一大就会发热。

L298N 的耐压和资料普及度有它的价值,TB6612 更适合低压、小车这类希望少浪费一点电压的场景。代码只统一 PWM/IN1/IN2,电机实际拿到多少电压、驱动板发多少热,仍然要回到具体芯片和负载上看。

接上驱动板,再接一个轮子

LED 现象确认后,按驱动板资料接好逻辑电源、电机电源和共地。第一次只接一个电机,把小车架空,旁边留着断电开关或直接拔掉电机电源。

占空比用一个有符号百分比表示,负号管方向,所以类型是 int16_t 而不是 uint8_t。周期里的计数总数先写成宏:

#define MOTOR_PWM_PERIOD  (50)

MOTOR_PWM_PERIOD 就是前面算过的 ARR + 1 = 50,也就是一个 PWM 周期里有几个计数。按百分比换比较值的函数是:

void Motor_SetPercent(int16_t percent)
{
    uint16_t compare;

    if (percent > 100)
    {
        percent = 100;
    }
    else if (percent < -100)
    {
        percent = -100;
    }

    TIM_SetCompare1(TIM2, 0);

    if (percent >= 0)
    {
        Motor_SetDirection(1);
        compare = (uint16_t)percent;
    }
    else
    {
        Motor_SetDirection(0);
        compare = (uint16_t)(-percent);
    }

    compare = (uint16_t)((compare * MOTOR_PWM_PERIOD) / 100);
    TIM_SetCompare1(TIM2, compare);
}

Motor_SetDirection() 是同一份文件里的一个 static 小函数,它只动两根方向脚:

static void Motor_SetDirection(uint8_t forward)
{
    if (forward != 0U)
    {
        GPIO_SetBits(GPIOB, GPIO_Pin_12);
        GPIO_ResetBits(GPIOB, GPIO_Pin_13);
    }
    else
    {
        GPIO_ResetBits(GPIOB, GPIO_Pin_12);
        GPIO_SetBits(GPIOB, GPIO_Pin_13);
    }
}

然后设置几个档位,每档停一会儿,看轮子的转速和方向:

int16_t motor_percent = 20;

Motor_SetPercent(motor_percent);
OLED_ShowNum(0, 0, (uint32_t)(motor_percent + 100), 3, OLED_6X8);
OLED_Update();

motor_percent + 100 是为了把 -100 到 100 落成屏幕上的 0 到 200,一眼看得出方向。依次把 motor_percent 改成 10、20、50、0、-20、0,每改一次重新烧录,对着轮子看现象。

先把 PWM 置零,再改变方向,最后写入新的比较值。转子还在向前转时,突然把方向脚翻成反向,相当于让驱动器立刻施加反向力矩,齿轮、驱动芯片和电源都会收到一记很硬的冲击。

负号不是 PWM 的“负电压”。它只是 Motor_SetDirection() 的输入,决定 IN1/IN2 哪一根为高;真正送进驱动板的 PWM 仍然是 0 到 100% 的正向脉冲。

两种“停”不要混在一句话里

Motor_Stop() 会将 PWM 比较值清零,并把方向脚回到低电平。TB6612 和 L298N 对 IN1=0、IN2=0、PWM/ENA=0 的具体输出状态,要以各自真值表为准,可能表现为滑行、关闭或某种制动组合。需要区分滑行停止和主动制动时,再根据驱动器真值表分别实现 Motor_Coast()、Motor_Brake()。

两种电机,两个很容易撞上的门槛

低占空比不转

把 TT 马达架空,从 1% 开始往上加。它可能在 1% 到 15% 之间一直不动,某个点突然抖一下才转;落地之后,门槛通常还会提高。MG310 这类带齿轮和编码器的电机,负载、减速箱阻力和额定电压不同,门槛不会照搬 TT 马达的结果。

可以记录一张参考表,但不要把它当成固件常量:

电机型号 | 架空最低启动占空比 | 落地最低启动占空比 | 供电电压 | 备注

换一块电池、换一个驱动板、换一只同型号电机,表里的数字都可能变。占空比已经不是 0 而电机仍然没有动作,说明启动门槛还没有被越过。

电机嗡嗡响

如果转子只是抖、线圈发出声音,先把两种原因分开:

  1. 频率落在可闻范围附近。 把 PWM 从几百 Hz 调到 20kHz,声音可能明显变化。频率太低,电流和转矩的脉动更容易被听见。
  2. 占空比卡在静摩擦门口。 频率已经是 20kHz,但 5% 只够让磁场拉一下转子,下一段关断又停回去,听见的是抖动和嗡声。提高到能连续越过静摩擦的占空比,或者先给一个短暂的启动占空比,现象才会改变。

不要为了让耳朵安静,就盲目把频率一路加高。驱动器的开关损耗、MOSFET 发热和电机电感都会限制可用范围;这里使用 20kHz,不代表所有硬件都适合这个频率。

同样 30%,为什么两个轮子不会一样快

左右两个电机都接上后,很可能会看到:同样给到 30% 的占空比,左轮和右轮的转速不一样;把小车放在地上,差异会更明显。

电机磁钢、绕组、电刷、齿轮箱摩擦、驱动板压降、轮胎接触地面的阻力都可能不同。PWM 命令描述的是“给多久的驱动”,不是“保证每秒转多少圈”。

如果两个轮子收到同样的占空比,小车却慢慢拐弯,先把 PA0 的输出设置、方向、电源和机械负载逐项对上,再测量两个轮子的实际转速。

代码里那几个不显眼的保护

Motor_Init() 最后调用 Motor_Stop(),main() 也再次明确停机。电机控制程序最不该出现的现象,就是下载完成、程序刚跳进 main(),轮子没有任何人为动作却突然转起来。

Motor_SetPercent() 内部那道 ±100 限幅,是给功率输出留的最后一道边界:越界进来的数会被夹到 100% 或 -100%,而不是让比较值算出一个超过周期的数。它管的是代码自己的调用,不替上层做输入校验。

方向切换时,Motor_SetPercent() 先把比较值写成 0,再改方向,最后恢复新的比较值。它没有处理完整的斜坡加减速,也没有测量电机是否真的停下来;但这个顺序比直接翻方向脚可靠很多。

验证顺序

  1. 不接电机,先用 LED 观察 0/20/50/80% 占空比带来的亮度变化。
  2. 连接 TB6612 或 L298N 的逻辑侧,确认共地和待机/使能脚状态。
  3. 电机架空,从 10% 逐步加到 50%,记录启动门槛和声音变化。
  4. 用 -20% 测反转;先回到 0%,再切方向,不直接高速反打。
  5. 保持 50% 改 PWM 频率,区分可闻啸叫和低占空比抖动。
  6. 最后才把轮子放到地面,观察负载变化;左右轮差异留到后续测量环节。

PA0 是否按设置改变输出、驱动板有没有响应、电机有没有越过启动门槛、方向有没有按预期改变、负载有没有改变现象,都需要分别确认。不要把“轮子不转”当成一个只需要调占空比的问题。

PWM 只负责把一个固定频率的开关命令送到驱动器;它能控制电机得到多少驱动力,却不会保证电机转得多快。

最后橘猫说

GPIO 明明只有高电平和低电平,却能让 LED 呈现不同亮度,让电机得到不同的平均驱动力。PWM 没有制造一个不存在的“中间电平”,只是把高、低电平放进固定周期里,用时间比例表达控制量。想出这种方法的人,真的是天才。