简介:这份PWM程序案例资源面向嵌入式初学者与电机控制、LED调光方向的开发者,帮助理解脉宽调制从原理到代码落地的完整过程。内容围绕频率设置、占空比调节、通道选择、定时器初始化与中断处理等关键环节展开,并配有可编译的工程示例,便于读者对照学习如何在具体平台上配置PWM输出。资源包共12个文件,压缩后约18KB,包含C源码、Keil工程文件(uvproj、uvopt)、编译中间产物(obj、lst、m51、hex)以及备份文件,覆盖从源码编写到生成烧录文件的完整链路,其中hex文件可直接用于验证输出效果。目前已有620人学习下载,适合希望快速上手PWM编程、理解占空比与频率实际影响的读者参考,也可作为课程实验或小型项目的起步模板。
1. PWM程序案例:从调光到电机,为什么你的占空比总是不对
很多人第一次写 PWM 程序,都是从一个 LED 调光 Demo 开始的。代码烧进去,灯亮了,亮度也能变,于是觉得 PWM 不过如此。可一旦换成电机调速、舵机控制或者开关电源,立刻翻车:电机嗡嗡响不转、舵机抖得像筛糠、MOS 管烫得能煎蛋。问题往往不在硬件,而在那段看起来只有几行的 PWM 程序——占空比算错了、频率选偏了、死区忘了加、更新时机不对。PWM 程序案例要讲的,就是这些藏在“设置比较值”背后的真实工程细节。它适合已经会点灯、但一上负载就出问题的嵌入式开发者,也适合想系统梳理 PWM 配置逻辑的熟手。接下来我会按“先搞懂它到底在控制什么,再动手写能复现的代码,最后把坑一个个填上”的顺序展开,所有参数和步骤都落到可抄作业的程度。
2. PWM 程序到底在控制什么:周期、占空比与频率的工程含义
2.1 从寄存器视角看 PWM 的四个核心量
PWM 程序本质上是在操作一个定时器,让它按固定节奏翻转输出引脚。不管你是用 STM32 的 TIM、ESP32 的 LEDC,还是树莓派的软件 PWM,底层都绕不开四个量:计数时钟源、自动重装载值(ARR)、比较值(CCR)、预分频系数(PSC)。它们的关系可以用一个公式串起来:
输出频率 = 时钟源频率 / ((PSC + 1) * (ARR + 1))
占空比 = CCR / (ARR + 1)
很多人写 PWM 程序时只改 CCR,觉得占空比对了就行,结果频率飘到几十赫兹,LED 肉眼可见地闪,电机发出刺耳啸叫。这就是没把 ARR 和 PSC 一起算进去的后果。我一般会先定频率,再反推 ARR 和 PSC,最后才去调 CCR。比如要 1 kHz 的电机驱动频率,时钟源 72 MHz,那就让 PSC 取 71,ARR 取 999,这样频率正好 1 kHz,CCR 在 0 到 999 之间调占空比,分辨率千分之一,足够大多数场景用。
这里有个容易忽略的点:ARR 决定的是周期,不是频率。ARR 越大,周期越长,频率越低,但占空比的分辨率越高。如果你既要低频又要高分辨率,就得把 PSC 调大,让计数器跑慢一点,ARR 留大一点。代价是计数器溢出中断的频率变低,动态更新 CCR 的响应会变慢。这个取舍在电机低速启动和 LED 呼吸灯里特别明显。
2.2 边沿对齐与中心对齐:什么时候必须用中心对齐
边沿对齐模式下,计数器从 0 数到 ARR 再回到 0,输出在计数过程中翻转一次。中心对齐模式下,计数器先向上数到 ARR,再向下数回 0,输出在上下两个方向各翻转一次。两者最直观的区别是:边沿对齐的 PWM 波形不对称,中心对齐的波形关于周期中点对称。
对于普通 LED 调光和直流电机调速,边沿对齐就够了,配置简单,中断时机明确。但如果你在做三相无刷电机驱动、或者需要低谐波失真的音频功放,就必须用中心对齐。原因在于中心对齐能让互补输出的两个通道在周期中点附近对称翻转,死区时间更容易插入,谐波分量也更集中在开关频率附近,滤波器的设计压力小很多。
我见过一个案例:某开发者用边沿对齐驱动三相桥,电机低速时抖动严重,示波器一看,三相波形不对称,换中心对齐后抖动明显减小。这不是玄学,是波形对称性直接影响转矩脉动。所以选对齐方式之前,先问自己:负载对波形对称性敏感吗?敏感就上中心对齐,不敏感就别给自己加复杂度。
2.3 频率怎么选:LED、电机、舵机的三条经验线
PWM 频率没有万能值,但有三条经验线可以照着走。
LED 调光:频率低于 200 Hz 人眼会感知闪烁,高于 2 kHz 又可能和摄像头拍摄产生频闪条纹。我一般取 1 kHz 到 2 kHz 之间,既避开可见闪烁,又不会让开关损耗太大。如果是 RGB 混色,三路 PWM 频率要一致,否则颜色会随刷新周期漂移。
有刷直流电机:频率太低(低于 1 kHz)电机会听到明显啸叫,频率太高(高于 20 kHz)又会让 MOS 管开关损耗急剧上升。常见做法是取 8 kHz 到 16 kHz,刚好在音频范围之上,人耳听不见,开关损耗也可接受。如果是大功率电机,还要看驱动芯片的手册,有些集成 H 桥明确要求 10 kHz 到 20 kHz。
舵机:舵机内部有控制电路,外部 PWM 是位置指令,不是功率驱动。标准舵机要求 50 Hz,脉宽 0.5 ms 到 2.5 ms 对应 0 到 180 度。这个频率不能随便改,改了舵机会抖或者不动。我一般用 50 Hz,ARR 设成 19999,PSC 设成 71,这样 72 MHz 时钟下计数周期 20 ms,CCR 从 500 到 2500 对应脉宽 0.5 ms 到 2.5 ms,刚好覆盖标准舵机行程。
提示:频率选定后,先写进注释,再写代码。后面调占空比时只动 CCR,不要回头改 ARR 和 PSC,否则频率会跟着变,负载行为可能完全不一样。
3. 用 STM32 HAL 库跑通一个可调占空比的 PWM 程序
3.1 定时器配置:从 CubeMX 到寄存器参数核对
我一般先用 CubeMX 生成初始化代码,再回头核对寄存器值。以 TIM3 通道 1 为例,时钟源 72 MHz,目标频率 1 kHz,分辨率 1000 级。CubeMX 里选 PWM Generation CH1,Prescaler 填 71,Counter Period 填 999,Pulse 先填 500。生成代码后,打开 tim.c 确认这几行:
/* TIM3 init function */ void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 71; // 72MHz / (71+1) = 1MHz 计数时钟 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 999; // 1MHz / (999+1) = 1kHz PWM 频率 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(&htim3) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK) { Error_Handler(); } if (HAL_TIM_PWM_Init(&htim3) != HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK) { Error_Handler(); } sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 初始占空比 50% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; if (HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK) { Error_Handler(); } HAL_TIM_MspPostInit(&htim3); }这段代码里,Prescaler 和 Period 决定了频率,Pulse 决定了初始占空比。AutoReloadPreload 我一般使能,这样更新 ARR 时不会产生毛刺。OCMode 选 PWM1 表示计数器小于 CCR 时输出高电平,大于等于时输出低电平;如果选 PWM2 则反过来。OCPolarity 决定有效电平是高还是低,驱动共阳 LED 时通常要改成 LOW。
3.2 启动 PWM 与动态改占空比的最小代码
初始化完成后,还需要显式启动 PWM 输出,否则引脚不会动。下面这段放在 main 函数里:
/* 启动 TIM3 通道 1 PWM 输出 */ HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); /* 动态修改占空比:50% -> 75% -> 25% */ __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 750); // 75% HAL_Delay(1000); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 250); // 25% HAL_Delay(1000);__HAL_TIM_SET_COMPARE是宏,直接写 CCR 寄存器,比调 HAL 函数快,适合在中断里用。注意它不会等当前周期结束,如果在一个周期中间改,输出波形可能被截断,产生一个窄脉冲。对 LED 调光无所谓,对电机驱动就可能引起电流尖峰。稳妥做法是在更新中断里改,或者使能 CCR 的预装载功能,让新值在下一个周期生效。
3.3 用示波器验证:三个必须看的波形指标
代码跑起来后,别急着接负载,先用示波器看三个指标。
第一,频率。探头接输出引脚,测周期,算频率,确认和设计值一致。如果差很多,回头查 PSC 和 ARR 是不是被别的代码改了。
第二,占空比。测高电平时间除以周期,看是不是等于 CCR/(ARR+1)。如果偏差超过一个计数周期,检查 OCMode 和 OCPolarity 是不是配反了。
第三,上升沿和下降沿。如果边沿明显变缓,说明引脚驱动能力不足或者负载电容太大,需要加驱动芯片或者减小上拉电阻。如果边沿有振铃,可能是走线太长或者没有匹配电阻,高频场景下要特别注意。
我一般会把这三个指标记在调试笔记里,后面换负载时对照着看,能快速判断问题是出在程序还是硬件。
4. 避坑:PWM 程序最常见的五个翻车现场
4.1 占空比设了但输出没变化
现象:代码里改了 CCR,示波器看占空比纹丝不动。
原因:最常见的是忘了调HAL_TIM_PWM_Start,或者启动的是错误的通道。还有一种情况是 CCR 的值超过了 ARR,比如 ARR 是 999,你写 1500,实际输出会一直是高电平,看起来像没变化。
解决:先确认启动函数调了,通道号对得上。再把 CCR 限制在 0 到 ARR 之间,写个宏做钳位。如果用的是高级定时器,还要检查 MOE(主输出使能)位有没有置位,否则输出被强制关断。
4.2 电机低速时抖动或啸叫
现象:电机在低速段抖动明显,伴随刺耳啸叫。
原因:PWM 频率落在音频范围内,或者占空比分辨率不够,低速时每次调整的步进太大。比如 ARR 只有 100,低速段 1% 的占空比变化对应转速跳变就很明显。
解决:把 ARR 加大到 1000 以上,提高分辨率;同时把频率提到 8 kHz 以上,避开音频段。如果还抖,检查电源是不是被 PWM 开关噪声干扰了,加个 LC 滤波试试。
4.3 互补输出直通烧 MOS 管
现象:三相桥或 H 桥上下管同时导通,瞬间大电流烧管。
原因:死区时间没加,或者死区时间太短,开关管关断延迟导致直通。
解决:用高级定时器的死区生成功能,根据 MOS 管手册里的开通延迟和关断延迟算死区。一般 IGBT 死区在 1 到 3 微秒,MOS 管在 100 到 500 纳秒。算完再留一倍余量,用示波器看上下管驱动波形,确认没有重叠。
4.4 动态改占空比时输出毛刺
现象:调光时 LED 偶尔闪一下,或者电机听到咔哒声。
原因:在周期中间直接写 CCR,当前周期被截断,产生窄脉冲。
解决:使能 CCR 预装载,让新值在更新事件时生效。或者把改 CCR 的操作放到更新中断里,确保在周期边界更新。
4.5 频率算对了但实际输出差很多
现象:按公式算出来 1 kHz,实测只有 800 Hz。
原因:时钟源频率不是你以为的那个值。比如系统时钟没跑到 72 MHz,或者定时器挂载的 APB 总线有分频,实际时钟和预期不一致。
解决:先查系统时钟配置,确认定时器时钟源频率。用示波器测一个已知 ARR 和 PSC 下的输出频率,反推实际时钟,再重新算参数。这个步骤我每次换芯片都会做一遍,花两分钟,省后面两小时。
5. 进阶:把 PWM 程序写成可复用的驱动模块
5.1 用结构体封装通道配置
每次新建工程都重写一遍 PWM 初始化太累,我一般会封一个轻量驱动。核心思路是用结构体描述一个 PWM 通道,初始化时传参,运行时只调 set_duty 和 set_freq。
typedef struct { TIM_HandleTypeDef *htim; uint32_t channel; uint32_t arr; // 当前 ARR 值 uint32_t psc; // 当前 PSC 值 uint32_t clk_freq; // 定时器时钟频率 } pwm_channel_t; /* 初始化:根据目标频率和分辨率反算 PSC 和 ARR */ void pwm_init(pwm_channel_t *ch, TIM_HandleTypeDef *htim, uint32_t channel, uint32_t clk_freq, uint32_t target_freq, uint32_t resolution) { ch->htim = htim; ch->channel = channel; ch->clk_freq = clk_freq; // 先算 ARR:分辨率减一 ch->arr = resolution - 1; // 再算 PSC:时钟 / (目标频率 * 分辨率) - 1 ch->psc = clk_freq / (target_freq * resolution) - 1; __HAL_TIM_SET_PRESCALER(htim, ch->psc); __HAL_TIM_SET_AUTORELOAD(htim, ch->arr); HAL_TIM_PWM_Start(htim, channel); } /* 设置占空比:0 到 resolution-1 */ void pwm_set_duty(pwm_channel_t *ch, uint32_t duty) { if (duty > ch->arr) duty = ch->arr; __HAL_TIM_SET_COMPARE(ch->htim, ch->channel, duty); }这个模块的好处是频率和分辨率解耦,换目标频率时只改 target_freq,PSC 和 ARR 自动重算。注意 clk_freq 要传定时器的实际时钟,不是系统时钟。比如 TIM3 挂在 APB1,APB1 预分频不为 1 时定时器时钟是 APB1 的两倍,这个细节查手册确认。
5.2 用更新中断做同步更新
多通道 PWM 需要同时更新占空比时,直接写 CCR 会有先后顺序,导致短暂的不对称。稳妥做法是在更新中断里统一更新。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { // 在周期边界统一更新三路 CCR __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty_r); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, duty_g); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, duty_b); } }这个回调在每次计数器溢出时触发,三路 CCR 在同一个中断里更新,下一个周期同时生效。代价是中断频率等于 PWM 频率,1 kHz 时还好,20 kHz 时 CPU 开销就上来了。高频场景可以改用 DMA 传输 CCR 值,或者用定时器的预装载功能,让硬件在更新事件时自动加载。
5.3 一个验证习惯:先空载看波形,再带载调参数
我这些年养成的习惯是:PWM 程序写完,先不接负载,示波器看波形,频率、占空比、边沿都对了,再接负载。接上负载后,再看波形有没有变形,电源有没有跌落,管子有没有过热。这个顺序能挡住八成以上的问题。很多人一上来就接电机,结果电机不转,分不清是程序问题还是驱动问题,来回折腾。先空载验证,等于给自己留了一颗后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取