经典三问
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 分别保持在几个亮度档位:
c
#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:
text
PWM 频率 = 定时器时钟 / (PSC + 1) / (ARR + 1)
20,000Hz = 72,000,000Hz / 72 / 50
所以寄存器里的两个值写成:
c
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 波形。
text
CNT: 0 1 2 ... 24 | 25 26 27 ... 49 | 回到 0
PA0: 高 高 高 ... 高 | 低 低 低 ... 低 | 重新输出高
PSC 决定计数器走得多快,ARR 决定一个周期有多长,CCR1 决定每个周期高电平保持多久。三者分工不同:改 CCR1 会改变占空比,改 ARR 会改变周期和 PWM 频率。
c
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 秒:
c
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 仍然只有亮和灭两种状态,但看到的平均亮度会随比较值增加而变亮。对应的计数关系如下:
| 比较值 | 占空比 | 预期高电平时间 |
|---|---|---|
| 0 | 0% | 近似 0us |
| 10 | 20% | 约 10us |
| 25 | 50% | 约 25us |
| 40 | 80% | 约 40us |
比较值等于周期计数长度时,输出接近 100%。具体边沿可能因 PWM 模式和定时器实现略有差异。需要确认的是:频率稳定、占空比随比较值改变、比较值为 0 时不会莫名输出一串窄脉冲。
如果 LED 的亮度完全不随比较值变化,先查 TIM2 时钟、PA0 是否配置为复用推挽、工程是否重映射了 TIM2_CH1,以及初始化后有没有把 PA0 又当普通 GPIO 使用。
PWM 接入电机
PWM 输出脚不能直接连接电机。PA0 能输出 3.3V 方波,但不能提供电机启动电流,也不能承受电机断电时的反冲电压。电机必须通过 TB6612FNG、L298N 等驱动板连接。
驱动板可以是 TB6612FNG,也可以是 L298N;电机可以是 TT 马达,也可以是 MG310 一类带编码器的电机。它们的额定电压、逻辑电平、停止方式和启动门槛都要以自己买到的产品资料为准,不能因为接口名字看起来一样,就把一张接线图照抄到底。
正确的电流路径是:
text
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_CH1 |
PWM |
控制有效驱动时间比例 |
PB12 |
IN1 |
方向输入 1 |
PB13 |
IN2 |
方向输入 2 |
PB10 |
STBY |
TB6612 的待机控制 |
GND |
GND |
与驱动板共地 |
TB6612FNG 的 A 通道可以这样对应:
text
PA0 -> PWMA
PB12 -> AIN1
PB13 -> AIN2
PB10 -> STBY
AO1/AO2 -> 电机两根线
VM/VCC/GND -> 按模块和芯片资料连接
L298N 常见模块的名字会变成 ENA、IN1、IN2、OUT1、OUT2:
text
PA0 -> ENA(拔掉 ENA 跳帽,才能让 PWM 接管使能)
PB12 -> IN1
PB13 -> IN2
OUT1/OUT2 -> 电机两根线
PB10 不接,L298N 没有 TB6612 那个 STBY 输入
L298N 的模块版本很多,有的带 5V 稳压、有的跳帽决定 5V 的进出;TB6612 模块也有不同的电源标注。把逻辑输入、电机电源、逻辑电源和地按手上模块的资料分别接好。
工程中的这个宏只负责告诉代码"有没有待机脚":
c
#define MOTOR_HAS_STBY 1
接 TB6612 保持 1;接 L298N 改成 0。方向和 PWM 的上层接口不需要变化。
接口相似,功率级完全不是一回事
TB6612FNG 使用 MOSFET 输出级,通常压降和发热都比较小;L298N 是更老的双极型晶体管驱动,输出压降明显,低电压电机尤其容易遇到"输入已经给了,电机端却少了一截电压"的情况。L298N 模块上那块散热片不是装饰,电流一大就会发热。
L298N 的耐压和资料普及度有它的价值,TB6612 更适合低压、小车这类希望少浪费一点电压的场景。代码只统一 PWM/IN1/IN2,电机实际拿到多少电压、驱动板发多少热,仍然要回到具体芯片和负载上看。
接上驱动板,再接一个轮子
LED 现象确认后,按驱动板资料接好逻辑电源、电机电源和共地。第一次只接一个电机,把小车架空,旁边留着断电开关或直接拔掉电机电源。
占空比用一个有符号百分比表示,负号管方向,所以类型是 int16_t 而不是 uint8_t。周期里的计数总数先写成宏:
c
#define MOTOR_PWM_PERIOD (50)
MOTOR_PWM_PERIOD 就是前面算过的 ARR + 1 = 50,也就是一个 PWM 周期里有几个计数。按百分比换比较值的函数是:
c
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 小函数,它只动两根方向脚:
c
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);
}
}
然后设置几个档位,每档停一会儿,看轮子的转速和方向:
c
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 马达的结果。
可以记录一张参考表,但不要把它当成固件常量:
text
电机型号 | 架空最低启动占空比 | 落地最低启动占空比 | 供电电压 | 备注
换一块电池、换一个驱动板、换一只同型号电机,表里的数字都可能变。占空比已经不是 0 而电机仍然没有动作,说明启动门槛还没有被越过。
电机嗡嗡响
如果转子只是抖、线圈发出声音,先把两种原因分开:
- 频率落在可闻范围附近。 把 PWM 从几百 Hz 调到 20kHz,声音可能明显变化。频率太低,电流和转矩的脉动更容易被听见。
- 占空比卡在静摩擦门口。 频率已经是 20kHz,但 5% 只够让磁场拉一下转子,下一段关断又停回去,听见的是抖动和嗡声。提高到能连续越过静摩擦的占空比,或者先给一个短暂的启动占空比,现象才会改变。
不要为了让耳朵安静,就盲目把频率一路加高。驱动器的开关损耗、MOSFET 发热和电机电感都会限制可用范围;这里使用 20kHz,不代表所有硬件都适合这个频率。
同样 30%,为什么两个轮子不会一样快
左右两个电机都接上后,很可能会看到:同样给到 30% 的占空比,左轮和右轮的转速不一样;把小车放在地上,差异会更明显。
电机磁钢、绕组、电刷、齿轮箱摩擦、驱动板压降、轮胎接触地面的阻力都可能不同。PWM 命令描述的是"给多久的驱动",不是"保证每秒转多少圈"。
如果两个轮子收到同样的占空比,小车却慢慢拐弯,先把 PA0 的输出设置、方向、电源和机械负载逐项对上,再测量两个轮子的实际转速。
代码里那几个不显眼的保护
Motor_Init() 最后调用 Motor_Stop(),main() 也再次明确停机。电机控制程序最不该出现的现象,就是下载完成、程序刚跳进 main(),轮子没有任何人为动作却突然转起来。
Motor_SetPercent() 内部那道 ±100 限幅,是给功率输出留的最后一道边界:越界进来的数会被夹到 100% 或 -100%,而不是让比较值算出一个超过周期的数。它管的是代码自己的调用,不替上层做输入校验。
方向切换时,Motor_SetPercent() 先把比较值写成 0,再改方向,最后恢复新的比较值。它没有处理完整的斜坡加减速,也没有测量电机是否真的停下来;但这个顺序比直接翻方向脚可靠很多。
验证顺序
- 不接电机,先用 LED 观察 0/20/50/80% 占空比带来的亮度变化。
- 连接 TB6612 或 L298N 的逻辑侧,确认共地和待机/使能脚状态。
- 电机架空,从 10% 逐步加到 50%,记录启动门槛和声音变化。
- 用 -20% 测反转;先回到 0%,再切方向,不直接高速反打。
- 保持 50% 改 PWM 频率,区分可闻啸叫和低占空比抖动。
- 最后才把轮子放到地面,观察负载变化;左右轮差异留到后续测量环节。
PA0 是否按设置改变输出、驱动板有没有响应、电机有没有越过启动门槛、方向有没有按预期改变、负载有没有改变现象,都需要分别确认。不要把"轮子不转"当成一个只需要调占空比的问题。
PWM 只负责把一个固定频率的开关命令送到驱动器;它能控制电机得到多少驱动力,却不会保证电机转得多快。
最后橘猫说
GPIO 明明只有高电平和低电平,却能让 LED 呈现不同亮度,让电机得到不同的平均驱动力。PWM 没有制造一个不存在的"中间电平",只是把高、低电平放进固定周期里,用时间比例表达控制量。想出这种方法的人,真的是天才。