
简介这是一个面向STM32F1微控制器的双极性SPWM波形生成代码工程适用于逆变器、电机驱动、开关电源等需要正弦波输出的应用场景也适合正在学习电力电子与嵌入式实时控制的开发者参考可作为高校相关课程设计与毕业设计的参考模板。压缩包共142个文件、约5.84MB工程内既包含Keil项目文件.uvprojx/.uvoptx、CubeMX图形化配置.ioc也包含19个.c与46个.h的HAL库源码以及.hex/.axf等已编译产物和.map/.lst等映射文件整体是一套可直接导入重新编译的完整Keil工程。已有784人学习/下载。代码展示了从三角载波与正弦调制波比较生成SPWM到通过低通滤波提取近似正弦信号的完整流程通过修改载波频率、调制比和滤波参数即可调节最终输出频率。对深入理解SPWM数字控制、STM32定时器比较输出以及逆变器前级波形生成都有实际帮助。 最近拿到一个叫sjx_PWM.zip的工程包解压后看到熟悉的Core、HARDWARE、SYSTEM、OBJ目录我就知道这是个标准的 STM32F103ZET6 工程而且核心功能大概率躲不开 PWM。果不其然LED 呼吸灯、TIM3 输出、引脚重映射、占空比调节全都集成在一个看似不起眼的压缩包里。对很多刚接触定时器输出的朋友来说PWM 的概念不难理解但真要在工程里跑起来从引脚复用、时钟配置到波形调试每一步都藏着坑。这篇文章就借这个sjx_PWM.zip把 PWM 从原理到实操完整拆一遍。如果你正在学 STM32想搞懂 PWM 输出的完整链路或者做完了一个简单的 LED 点亮实验但不知道接下来怎么深入这个工程值得认真研究。它不只是一个呼吸灯更像一个 PWM 知识的小型集合体把重映射、占空比渐变、故障保护、波形调试这些点都串了起来。1. 项目解读一个呼吸灯zip里装了什么1.1 为什么小小的呼吸灯能串联这么多知识点呼吸灯的逻辑本身非常简单让 LED 的亮度从暗到亮、再从亮到暗循环。但这个效果背后牵扯的东西远比你想象的多。LED 要呼吸得顺滑输出 PWM 的定时器频率要足够高否则会有肉眼可感知的闪烁占空比需要一步一步渐变步进值刹车太猛会看到明显跳变太慢又显得死气沉沉引脚必须复用到正确的通道上不然你在寄存器里写了一堆配置信号却根本出不来如果后面还想控制电机或大功率负载故障保护和死区配置又是绕不开的话题。sjx_PWM.zip这个工程名义上是做呼吸灯实际上把这些 PWM 开发里的关键点都覆盖了一遍属于那种“麻雀虽小五脏俱全”的教学型代码。所以我建议拿到这个工程的朋友别只顾着编译下载看灯亮而是先把整个数据流走一遍时钟树怎么给的、TIM3 挂在哪条总线上、引脚怎么从默认位置映射到目标引脚、CCR 寄存器的值怎么改变输出脉宽把这些都理清楚你才算真正学会了 PWM。1.2 定时器选型TIM3 和引脚重映射背后的考量做呼吸灯大部分人的第一反应是用 GPIO 翻转加延时来做软件 PWM代码确实简单但 CPU 会被完全占死而且如果频率高一点延时函数带来的抖动会让波形很不稳定。硬件定时器 PWM 的好处在于定时器一旦启动引脚翻转完全由硬件完成CPU 只需要修改比较寄存器 CCR 的值就能实时改变占空比。这也是工程里选 TIM3 而不是软件模拟的根本原因。那为什么偏偏是 TIM3而不是 TIM1、TIM2 或者 TIM4STM32F103ZET6 的资源非常充裕TIM1 是高级定时器带互补输出和死区功能强但对这个实验来说有点牛刀杀鸡TIM2 和 TIM3 都是通用定时器TIM3 的四个通道分布在 PA6、PA7、PB0、PB1其中一个有意思的地方是有部分引脚默认被 JTAG 占用。把这个工程选 TIM3 的一个重要原因就是顺理成章地引出“引脚重映射”这个很多新手非常困惑的操作。方案优点缺点适用场景GPIO 软件翻转简单直观CPU 占用高频率不稳定临时验证TIM3 硬件 PWM稳定、资源占用低需要配置重映射和复用呼吸灯、电机调速TIM1 高级定时器带刹车/死区/互补输出配置复杂电机控制、逆变如果你的 PCB 上已经把 LED 接到了 PB4/PB5 这类非默认引脚就要通过重映射让 TIM3 的通道信号跑到你需要的引脚上去。这个设计思路在实际项目里非常常见芯片引脚规划从来不是“这个定时器只能用这几个引脚”而是通过 AFIO 重新分配功能让硬件设计更灵活。2. PWM 原理与关键参数计算2.1 从 LED 亮度到占空比PWM 的本质PWM脉宽调制的本质是用一个固定频率的方波通过改变高电平在一个周期内所占的比例来改变输出的平均电压。人眼对高频率亮灭切换并不敏感看到的只是平均亮度占空比越高等效电压越高LED 越亮占空比越低LED 越暗。呼吸灯的任务就是让占空比随时间周期性变化。为了更容易理解可以把定时器想象成一个不断往杯子里倒水再倒掉的循环自动重装载寄存器 ARR 决定杯子能装多少水比较寄存器 CCR 决定倒到哪个位置时打开阀门。当计数值 CNT 小于 CCR 时引脚输出高电平当计数超过 CCR 但还没超过 ARR 时引脚输出低电平。CNT 到达 ARR 后溢出重新从零开始下一个周期继续重复这个过程。改变 CCR就是改变阀门打开的位置也就是改变占空比。2.2 引脚重映射细节PB4/PB5 与 JTAG 的冲突很多人在做sjx_PWM.zip这个工程时最头疼的就是明明照着代码配了引脚却没有任何波形输出。这时候十有八九是踩了 JTAG 和重映射的坑。STM32F103 的 TIM3 通道默认映射是CH1 在 PA6CH2 在 PA7CH3 在 PB0CH4 在 PB1。但如果你查看芯片数据手册的 AFIO 部分会发现 TIM3 支持部分重映射和完全重映射。比如完全重映射后CH3 可以挪到 PB4CH4 可以挪到 PB5。问题在于PB4 和 PB3 在芯片复位后的默认功能是 JTAG 的 NJTRST 和 JTDO。要让这两个引脚变成普通 GPIO 或者复用功能必须先关闭 JTAG 功能保留 SWD 下载调试口。工程里常见的写法是GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_TIM3, ENABLE);第一条语句把 JTAG 关掉释放 PB3、PB4、PA15 这几个被 JTAG 占用的引脚第二条语句把 TIM3 的功能重映射到新的引脚组合上。顺序不能反而且要注意操作 AFIO 时钟也要打开否则寄存器配置不生效。如果你在 CubeMX 里操作就不用管这个细节了但用标准外设库的老工程里这两行是很多问题的根源。2.3 时序参数计算频率、占空比和呼吸节奏怎么搭配sjx_PWM.zip里默认用的时钟是外部 8MHz 晶振经过 PLL 倍频到 72MHzTIM3 挂载在 APB1 总线上所以定时器输入时钟就是 72MHz。PWM 频率、占空比、呼吸节奏三者是互相关联的。以sjx_PWM.zip的常用配置为例PSC 设为 71ARR 设为 999这样 72MHz 先被分频到 1MHz再计数 1000 次溢出得到 1kHz 的 PWM 频率。占空比计算公式为占空比 CCR / (ARR 1) * 100%呼吸灯节奏则取决于 CCR 的更新频率和步进值。如果每 10ms 让 CCR 加 1从 0 加到 999 需要差不多 10 秒呼吸一次太长灯看起来会很慵懒。工程里一般把步进值设成 5 或者 10也就是每 10ms 让 CCR 加 5这样从暗到亮大概需要 2 秒视觉上刚刚好。PWM频率 72MHz / ((PSC1) * (ARR1))举个例子如果 PSC 71ARR 999那么 PWM 频率 72MHz / (72 * 1000) 1kHz。这个频率驱动 LED 完全没有问题但如果驱动舵机一般要用 50Hz那就需要把 ARR 拉大或者调 PSC参数计算逻辑是一样的只是目标频率不同。3. 从 CubeMX 配置到跑通呼吸灯3.1 关键配置项逐项说明如果用 CubeMX 新建工程配置步骤并不复杂但有几个地方容易漏。第一步在 RCC 里把 HSE 设为 Crystal/Ceramic Resonator外部时钟源选好第二步在 SYS 里把 Debug 设置为 Serial Wire这一步非常关键否则一旦关闭 JTAG下次烧录可能连不上芯片第三步在定时器 TIM3 里把 Clock Source 设为 Internal ClockChannel1 模式选 PWM Generation CH1第四步把引脚从默认位置拖到目标引脚比如 PB4CubeMX 会自动生成重映射相关代码第五步在 TIM3 的参数配置里填入 PSC、ARRPulse 初始值可以设 500这样上电时 LED 正好是一半亮度方便确认配置是否生效。这里特别提醒一下引脚重映射在 CubeMX 里看似只是拖拽但工具自动帮你处理了 AFIO 和重映射寄存器。如果手写标准外设库代码千万不要漏掉RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE)这一条。3.2 核心代码解读初始化部分HAL 库的写法比较固定void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 71; htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 999; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(htim3); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 500; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); }呼吸灯的主循环逻辑不复杂关键是怎么让 CCR 平滑地来回变化。我推荐用一个目标值和方向标志位的思路比单纯两个 for 循环嵌套更干净uint16_t ccr 0; uint16_t target 999; uint16_t step 5; while (1) { if (ccr target) { ccr step; if (ccr target) { target 0; } } else if (ccr target) { ccr - step; if (ccr target) { target 999; } } __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, ccr); HAL_Delay(10); }为什么用target而不是 dash 两个 for 循环因为目标值方式更容易扩展以后你想让灯停在某个亮度再开始呼吸只需要修改 target 和 ccr 的初始值反复 while 循环嵌套也可以但代码嵌套深了可读性差。步进值 5 是我实测比较舒服的数值既不会看到灯光跳变也不会因为变化太慢让人犯困。3.3 故障保护与死区给 PWM 加一道“保险”PWM 不只用来点灯更多场合是用来驱动电机、加热器等功率设备。在这些应用里“故障保护”就非常重要了。STM32 的高级定时器 TIM1/TIM8 支持刹车输入Break Input可以把过流、过压等故障信号接到 BKIN 引脚。一旦该信号有效定时器输出会被硬件强制拉低或拉高同时触发中断通知 CPU 处理。这种保护不受程序卡死影响是纯硬件级别的响应比软件里判断一个 GPIO 再手动关闭 PWM 快得多。热词里还提到“28335 配置的 PWM 死区 DBFED 和 DBRED”。这让我想到很多做电机控制的朋友会混淆死区配置。STM32 上 DTG 寄存器负责死区插入而 TI 的 28335 DSP 把死区分成了上升沿延时DBRED和下降沿延时DBFED两个独立寄存器。虽然命名和寄存器架构不同但目的一致在互补 PWM 的上下桥臂切换之间插入一段“同时关断”的安全时间防止上下管直通烧毁。如果你将来做 H 桥或者三相电机驱动这个知识点几乎必定会遇到。回到sjx_PWM.zip这个工程如果只是点灯硬件刹车用不上但代码里仍然可以写一个软保护逻辑检测到某个按键或外部信号时立刻把 CCR 清零并停止定时器这个习惯值得养成。我见过很多新手在点灯阶段不去想保护真到驱动电机时又没有保护意识结果一上电就烧板子。从点灯阶段开始建立故障保护思维成本非常低。4. 调试实录常见问题与排查技巧4.1 输出恒高或 100% 占空比异常的真相我在用 STM32 做 PWM 的时候遇到过好几次 100% 占空比输出异常的情况。表现形式是理论上满占空比应该输出恒高电平但示波器上看波形偶尔会突然出现一个很窄的低电平毛刺或者灯亮度不是满亮而是略偏暗。排查到最后问题往往出在 CCR 的值超过了 ARR。定时器比较逻辑是 CNT 等于 CCR 的时候翻转如果 CCR 被设置成大于 ARR 的数硬件行为会变得不可预测。另外如果在更新 CCR 时没有考虑 ARR 的值比如把 CCR 设成了 1000而 ARR 只有 999某些芯片版本可能出现异常波形。解决办法很简单更新 CCR 之前做一次钳位确保ccr ARR。在sjx_PWM.zip这类工程里呼吸灯代码通常不会出错但如果你改过 ARR 却没有同步修改 step 和 target就要特别注意了。4.2 Keil 仿真和逻辑分析仪怎么看波形很多人来问 Keil 5 怎么查看 PWM 波形。Keil 的 Debug 模式里确实有逻辑分析窗口可以添加变量并配置成 bit 显示能看到 CNT 和 CCR 的变化但如果想看引脚真实的电平波形我更推荐用逻辑分析仪直接量引脚。具体在 Keil 里操作进入调试模式后打开 View - Watch 窗口添加TIM3-CNT、TIM3-CCR1再打开 View - Analysis Windows - Logic Analyzer把这两个变量拖进去配置格式选择 Bit 或 Analog就能看到时间轴上的波形变化。这个方式适合确认程序执行的逻辑但它模拟的是寄存器状态不是芯片引脚上实际输出的电平。真正验证 PWM 波形还是用示波器或逻辑分析仪量 PB4或你重映射后的目标引脚最直接。我个人的习惯是先在逻辑分析仪里看到干净的 1kHz 方波再去看 LED 亮度变化。如果波形正常但灯不亮那是硬件问题如果灯忽明忽暗但波形一直正常那是占空比渐变逻辑问题。按这个思路排查能把问题快速分成“软件逻辑”和“硬件连接”两个方向。4.3 引脚没波形的排查顺序如果代码编译下载后引脚上没有波形按顺序做这几步检查。第一步确认定时器时钟有没有打开标准外设库工程最容易漏掉这一点第二步确认 GPIO 模式是否设置成复用推挽输出AF_PP如果你不小心配置成通用推挽输出信号是出不去第三步确认重映射是否生效PB4 这种引脚如果不关闭 JTAG永远是高阻或具体芯片默认状态第四步确认初始化和 PWM 启动函数有没有执行如果你在初始化之后不小心把 CCR 清 0 或者关闭了输出波形自然没有。还有一个小细节接 LED 时要注意引脚极性。PWM 高电平有效时LED 正极接引脚、负极经限流电阻接地如果你把 LED 接反了占空比越小反而越亮容易误判成程序逻辑反了。这个低级错误我在带着学生做实验时遇到过不止一次。4.4 呼吸不顺畅、闪烁和亮度不均匀的问题呼吸灯如果看起来一顿一顿的问题大概率出在步进值和延时时间的配合上。比如你每 50ms 才更新一次 CCR每次只加 1LED 的变化就会非常生硬。我的经验是让 CCR 每 10ms 变化 5 到 10基本能达到平滑呼吸的效果。但如果 PWM 频率太低比如低于 500Hz即使占空比变化再平滑人眼还是能看到高频闪烁。这时候优先调高 PWM 频率而不是拼命改步进。亮度不均匀则是另一个问题。人眼对亮度的感知不是线性的CCR 从 0 加到 100 的亮度变化在人眼看来可能比从 500 加到 600 更明显。如果追求完美的呼吸效果可以对占空比做对数或指数校正但工程精度要求不高的话线性渐变反而更有呼吸灯原本的神韵。注意用HAL_Delay做主循环延时非常简单但它会阻塞主循环。如果工程里以后要同时处理按键、显示等其他任务建议把占空比更新放到定时器中断里做或者用非阻塞的状态机避免延时期间系统完全没有响应。最后再分享一个我在实际调试这个工程时收获很大的小技巧不要一上来就调代码先在初始化函数里把 CCR 设成固定值比如 500确认 LED 以固定亮度点亮。这一步能快速验证硬件链路和 PWM 基本输出是否正常。固定亮度没问题之后再改成渐变逻辑去调呼吸节奏。把一个大目标拆成两个“必现”状态来验证比直接跑完整程序、出了问题一头雾水要高效得多。sjx_PWM.zip这个工程值得你反复烧录几遍每次改一个参数观察现象变化很快你就能把 PWM 玩明白了。本文还有配套的精品资源点击获取