STM32单定时器生成SPWM的原理、参数计算与工程实现
2026/9/17 0:30:17 网站建设 项目流程

简介:这是一份STM32 SPWM程序资源包,面向从事电机控制与电源转换开发的嵌入式工程师,目标是帮助具备C语言与STM32基础的开发者快速实现PWM调制功能。压缩包内含40个文件,整体约570KB,以C源文件、头文件以及编译生成的o、lst、axf为主,并配有Keil uv2工程配置、map映射和调试信息,结构清晰,适合直接导入开发环境。内容围绕“一个定时器实现SPWM”的方案展开,涵盖定时器时钟源与计数模式设置、CCRx占空比调节、死区时间保护、边缘/中心对齐模式选择,以及HAL库中PWM启动和回调函数的实际用法。资源提供了从定时器初始化到SPWM波形输出的完整工程链路,可帮助快速理解配置逻辑,并在此基础上进行电机控制或逆变电路的二次开发与优化。当前已有703人学习下载,对于正在学习STM32 PWM调制或准备开展电源类项目开发的读者具有直接参考价值。

1. 一个定时器出 SPWM,201003.rar里到底藏了什么

解压201003.rar后,工程名201003 SPWM-一个定时器 SPWM已经说明了它的做法:只使用 STM32 的一个定时器,就输出一路按正弦规律变化的 PWM 波形。这个程序没有操作屏幕、没有协议栈,核心资源就是一张正弦表和一次更新中断。单定时器方案相比用两个定时器做载波和调制波同步的项目,省定时器资源,逻辑清晰,非常适合做单相正弦逆变、SPWM 变频器异步电机调速、正弦波电源这类场景的波形生成。下面我会按原理、参数、代码、排错、可变频/DMA 的顺序把这条线索完整展开,拿到 201003 这个工程的人可以照着验证。

2. 单定时器 SPWM 的生成原理与定时器选择

2.1 查表更新 CCR:数字 SPWM 的落地点

模拟 SPWM 用正弦波和三角波比较,数字实现多数采用规则采样:把正弦波一个周期等分成 N 份,在第 k 个 PWM 周期把占空比设成正弦表里的第 k 个值。占空比公式是:

D[k] = 0.5 + 0.5 * M * sin(2π * k / N)

M 是调制比,一般取 0.8~0.95。0.5 意味着在 50% 基础上上下摆动,这样 PWM 的平均电压是正弦,再经低通滤波后就是正弦波。STM32 的定时器输出 PWM 时,ARR 决定周期,CCR 决定占空比,所以真正写入比较寄存器的值是:

CCR[k] = (uint16_t)(ARR + 1) * D[k]

为什么用ARR + 1?因为定时器的计数范围是 0~ARR,一共 ARR + 1 个计数点。很多人在这里忘了 +1,导致最大占空比比预期小 1/ARR,波形顶部变浅。对于 ARR 只有几十的 20kHz 载波,这个误差有 2% 量级,示波器上能看出来。

#define SPWM_TABLE_SIZE 400 #define MODULATION_INDEX 0.9f static uint16_t spwm_table[SPWM_TABLE_SIZE]; void spwm_table_init(int arr) { for (int i = 0; i < SPWM_TABLE_SIZE; i++) { float ratio = 0.5f + 0.5f * MODULATION_INDEX * sinf(2.0f * 3.14159f * i / SPWM_TABLE_SIZE); spwm_table[i] = (uint16_t)((float)(arr + 1) * ratio + 0.5f); } }

0.5f是做四舍五入,表在上电初始化时生成,中断服务里只做查表和写 CCR。MODULATION_INDEX用 0.9 是为了给死区留出空白窗口,如果调到 1.0,最大 CCR 会顶到 ARR,驱动全桥时容易出问题。

2.2 单定时器如何同时决定载波频率和正弦频率

一个定时器只有一个 ARR,只能决定 PWM 周期;正弦周期靠的是“每隔一个 PWM 周期把占空比换成表的下一个值”。所以更新事件频率和表长度一旦固定,正弦波频率就确定了:

f_out = f_carrier / SPWM_TABLE_SIZE

比如载波 20kHz,表 400 点,每秒跳 20000 次表,循环一遍需要 400 次,正好 50Hz。也就是说,改 PSC/ARR 改的是载波,同时也改变了输出频率,表长则是两者之间的比例桥。很多程序把表和定时器参数写死,换一种输出频率就要重新生成表,根源就在这里。

参数取值对本程序的影响
PSC7172MHz 定时器时钟分频到 1MHz
ARR49每个 PWM 周期 50 个计数点,载波 20kHz
SPWM_TABLE_SIZE400正弦波频率 20kHz / 400 = 50Hz

这段映射关系在边沿对齐模式下非常直接。中断里的骨架就是一个查表加一个索引自增:

void timer_update_isr(void) { tim_ccr = spwm_table[idx]; idx = (idx + 1) % SPWM_TABLE_SIZE; }

更新中断频率等于 PWM 频率,也就是说整个 SPWM 的行为完全由这一个定时器驱动。如果应用里还在跑 Modbus、触摸屏或者状态机,这套方案的 CPU 占用可以压得很低。

2.3 定时器选型:TIM1/TIM8 还是 TIM2/TIM3

单定时器 SPWM 对定时器型号本身没有苛刻要求,通用定时器也能做。但 201003 这种面向电机逆变/变频的程序,最终要驱动 MOS 或 IGBT 桥臂,一对互补 PWM 是少不了的。TIM1/TIM8 属于高级定时器,自带互补输出和可编程死区;TIM2/TIM3 没有死区发生器,要自己在外面加 RC 延迟或者逻辑电路,麻烦且参数随温度漂移。选型顺序一般是这样:需要互补输出和死区就选 TIM1/TIM8,只输出单极性 SPWM 给运放才考虑通用定时器。另一个差别是时钟源:TIM1/TIM8 在 APB2 上,本例直接是 72MHz;TIM2/TIM5 在 APB1 上,如果 APB1 分频不是 1,定时器时钟可能变成 36MHz。改定时器实例时,RCC 时钟和 IRQn 要一起改,否则频率全错。

__HAL_RCC_TIM1_CLK_ENABLE(); /* TIM1 时钟来自 APB2,72MHz */

不光是 TIM1,TIM8 也挂在 APB2。常有人从 TIM4 的工程改到 TIM1,却只换实例名,不换 APB1/APB2 使能,结果 PWM 频率差一半,找半天找不到原因。

3. 在 STM32 上实现单定时器 SPWM:参数、初始化与中断更新

3.1 先算 PSC、ARR 和正弦表长度

目标:载波 20kHz,正弦波 50Hz,定时器时钟 72MHz。先定 ARR = 49,则一个 PWM 周期有 50 个计数点。PSC 由下面的公式反推:

PSC = 72MHz / (20kHz * (ARR + 1)) - 1 = 72MHz / (20kHz * 50) - 1 = 71

此时计数频率 1MHz,PWM 周期 50us,正好 20kHz。表长度取 400,则正弦波周期为 400 / 20000 = 20ms,也就是 50Hz。用 Python 生成这张表最省事:

import math N = 400 ARR = 49 M = 0.9 table = [] for i in range(N): ratio = 0.5 + 0.5 * M * math.sin(2 * math.pi * i / N) ccr = int((ARR + 1) * ratio + 0.5) ccr = min(ccr, ARR) table.append(ccr) print("const uint16_t spwm_table[400] = {") for i in range(0, len(table), 16): print(" " + ", ".join(f"{v:3d}" for v in table[i:i+16]) + ",") print("};")

代码里min(ccr, ARR)做了钳制,防止 M 设成 1.0 时出现 CCR 大于 ARR 的越界值。PWM 模式下 CCR 大于 ARR 会变成恒高电平,SPWM 波形直接毁掉。对 400 点表来说,M=0.9 最大 CCR 只有 47,不会越界,但这个保护在调试中能挡掉很多手误。

3.2 初始化 TIM1 并输出 PWM

下面这段基于 STM32 HAL 库,目标芯片以 STM32F103 为例。

TIM_HandleTypeDef htim1; TIM_OC_InitTypeDef sConfigOC; void mx_tim1_spwm_init(void) { __HAL_RCC_TIM1_CLK_ENABLE(); htim1.Instance = TIM1; htim1.Init.Prescaler = 72 - 1; /* 计数频率 1MHz */ htim1.Init.CounterMode = TIM_COUNTERMODE_UP; /* 边沿对齐 */ htim1.Init.Period = 50 - 1; /* 20kHz 载波 */ htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; HAL_TIM_PWM_Init(&htim1); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 25; /* 初始 50% 占空比 */ sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCNPolarity = TIM_OCNPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; sConfigOC.OCIdleState = TIM_OCIDLESTATE_RESET; sConfigOC.OCNIdleState = TIM_OCIDLESTATE_RESET; HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1); /* 关键:让 CCR 值到更新事件才生效 */ __HAL_TIM_ENABLE_OCxPRELOAD(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); __HAL_TIM_ENABLE_IT(&htim1, TIM_IT_UPDATE); HAL_NVIC_EnableIRQ(TIM1_UP_IRQn); }

Prescaler = 72 - 1把 72MHz 分频成 1MHz,Period = 50 - 1让定时器计数 50 个节拍溢出一次,得到 20kHz。__HAL_TIM_ENABLE_OCxPRELOAD是必须的,它让 CCR 写入在更新事件时才装载,也就是影子寄存器生效。很多 SPWM 程序在示波器上看到脉宽偶尔跳一下,多半就是漏了这个宏。

3.3 更新中断里改占空比

extern const uint16_t spwm_table[400]; volatile uint16_t spwm_index = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, spwm_table[spwm_index]); spwm_index++; if (spwm_index >= 400) { spwm_index = 0; } } }

__HAL_TIM_SET_COMPARE本质是写 CCR1,因为开了预装载,写入会在本 PWM 周期结束、计数器回到 0 时生效。如果没开,CCR 的修改立即作用到当前正在输出的脉冲,可能导致脉冲被截断或加宽,电机相电流上就会出现毛刺。这个中断周期是 50us,中断里只有读表、写寄存器和索引判断,大部分 Cortex-M 内核都能轻松跑。但要注意,如果中断里再塞浮点 PI、串口打印或者阻塞延时,50us 可能不够,错过更新事件后输出波形的相位会抖。

HAL 的中断路径是TIM1_UP_IRQHandlerHAL_TIM_IRQHandler,再回调HAL_TIM_PeriodElapsedCallback。所以初始化时必须把TIM1_UP_IRQn的优先级配好,不能让定时器中断被低优先级任务卡太久。

3.4 为什么占空比会突然跳变:影子寄存器

CCR 寄存器在 STM32 中本身有影子寄存器,但默认预装载功能未开启,比较值是否立即可见取决于 OCxPE 位。__HAL_TIM_ENABLE_OCxPRELOAD就是在置这个位。调试时有个省事的判断方法:把示波器放大到单个 PWM 周期,如果高电平时间不是相邻表项之间的渐进变化,而是偶尔跳一大格,基本就是预装载未开。这个坑和 PSC/ARR 算错不一样,它不是系统性偏差,而是随机毛刺,光看频率不一定发现得了。

4. 波形质量、死区参数与示波器常见坑

4.1 边沿对齐与中心对齐:更新频率翻倍后的表长问题

3.2 用的是向上计数,也就是边沿对齐模式。如果改成中心对齐模式TIM_COUNTERMODE_CENTERALIGNED1,定时器向上计数到 ARR 再向下计数到 0,一个完整 PWM 周期里会产生两次更新事件,更新中断频率约等于 PWM 频率的两倍。沿用 3.2 的 PSC=71、ARR=49,PWM 频率会变成约 10.2kHz,而更新事件频率约 20.4kHz,中断里仍然每次跳一个表项,输出频率就是 20.4kHz / 400 ≈ 51Hz,载波却低了一半。

配置PWM 频率更新事件频率400 点表输出频率
向上计数 ARR=4920kHz20kHz50Hz
中心对齐 ARR=2520kHz40kHz100Hz

看第二行:中心对齐模式下把 ARR 改成 25,PWM 频率回到 20kHz,但更新事件频率是 40kHz,400 点表扫完只需要 10ms,输出变成了 100Hz。要保持 50Hz,必须把表加长到 800 点。这是单定时器 SPWM 里最容易踩的隐性翻倍问题,示波器上波形看着是正弦,频率就是不对,而且听电机声音会高一个八度。

4.2 单定时器方案的死区设置

用 TIM1 的 CH1 和 CH1N 驱动半桥时,死区必须处理。HAL 库里死区在 BreakDeadTime 配置结构体里:

TIM_BreakDeadTimeConfigTypeDef bdt; bdt.OffStateRunMode = TIM_OSSR_DISABLE; bdt.OffStateIDLEMode = TIM_OSSI_DISABLE; bdt.LockLevel = TIM_LOCKLEVEL_OFF; bdt.DeadTime = 100; /* 100 / 72MHz = 1.39us */ bdt.BreakState = TIM_BREAK_DISABLE; bdt.BreakPolarity = TIM_BREAKPOLARITY_HIGH; bdt.BreakFilter = 0; bdt.AutomaticOutput = TIM_AUTOMATICOUTPUT_DISABLE; HAL_TIMEx_ConfigBreakDeadTime(&htim1, &bdt); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1);

DeadTime的单位是定时器时钟周期。72MHz 时步长约 13.9ns,100 对应 1.39us,普通低压 MOS 够用。开了死区之后,占空比表的最大值必须受限。上面生成的表 M=0.9,最大 CCR 是 47,ARR 是 49,高电平时间占 96%,剩余 4% 给死区,只是勉强够。如果是 IGBT,建议把 M 降到 0.85,给功率器件留更多关断时间。

4.3 从示波器现象反推配置问题

示波器现象代码/配置原因修改方法
PWM 频率偏一半分频或 ARR 少加 1核对 (PSC+1)*(ARR+1)
脉宽偶尔突变CCR 预装载未开或中断被抢占开启 OCxPRELOAD;优化中断优先级
正弦波削顶调制比过大或表值越界M 降到 0.9,生成表时 clamp CCR
中心对齐后频率翻倍表长没跟着更新事件频率调整表长从 400 改成 800
高频噪声明显偏大死区太小或栅极电阻不合适加大 DeadTime,检查驱动电路

有个隐蔽情况:正弦波削顶不是顶部真的变成直线,而是峰值附近的占空比连续多拍饱和在高电平,看起来像削平。这时先查表生成脚本里有没有把ratio限制在 0~1,再看MODULATION_INDEX是不是被误写成 1.0。

4.4 调试 SPWM 的步骤建议

不要一上来就接功率板。先把 PWM 引脚接到示波器,确认频率和占空比渐变规律。用示波器光标量第 1、第 100、第 200 个 PWM 的高电平时间,应该能看到从窄到宽再回到窄的完整正弦趋势。确认之后再接 LC 滤波。如果改了 ARR 而表没有同步重建,输出频率看起来对,波形却会乱。常见做法是在 main 里启动时生成一次表,调参数时把表的前 10 个值通过串口打出来,和脚本生成的结果对比,避免表损坏但波形半对半错的情况。

5. 在线调频与 DMA 搬运:把 SPWM 程序用得再灵活一点

5.1 不用改表的变频:相位累加器

固定 50Hz 的做法是每个中断索引加 1。想在线调频,可以把索引增量从常量 1 改成可变量 step。为了省掉取模运算,表长最好取 2 的幂,比如 512 点。

#define PHASE_TABLE_SIZE 512 volatile uint32_t spwm_phase = 0; volatile uint32_t spwm_step = 1; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { uint16_t idx = spwm_phase & (PHASE_TABLE_SIZE - 1); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, spwm_table[idx]); spwm_phase += spwm_step; } }

spwm_phase & 511等价于对 512 取模,没有除法,中断里开销很小。输出频率关系是:

f_out = (f_carrier * spwm_step) / PHASE_TABLE_SIZE

但整数步进有个限制:载波 20kHz、表 512 点时,step=1 对应 39.06Hz,step=2 对应 78.13Hz,没有精确的 50Hz。变频场景里,我会把载波设成 12.8kHz、表长 256,step=1 正好 50Hz,step=2 是 100Hz,作为 50/100Hz 两档切换非常干净。如果要求任意频率且精度高,需要用更长的相位累加器和 DDS 思想,但单定时器 SPWM 的核心逻辑仍然是这条。

5.2 DMA 搬运正弦表,占 CPU 最少

固定频率的 SPWM 可以把中断整个去掉,用定时器的 DMA 请求把正弦表逐个搬到 CCR:

HAL_TIM_PWM_Start_DMA(&htim1, TIM_CHANNEL_1, (uint32_t *)spwm_table, SPWM_TABLE_SIZE);

每个更新事件到来时,DMA 自动把内存里的下一项写入 CCR1,CPU 完全不被打扰。需要注意表放在 RAM 且按 32 位地址对齐,DMA 宽度配置成半字;放在 Flash 虽然部分系列也能读,但保险起见 RAM 更省心。DMA 方式的缺点是改频率和修改表不灵活,适合固定 50Hz 正弦波电源这类应用。如果要做变频器,还是 5.1 的相位累加器更实用。

5.3 最后验证:看 PWM 和滤波后波形

接一个一阶 RC 低通:R=1kΩ,C=220nF,截止频率约 724Hz,20kHz 载波衰减约 29dB,50Hz 基波基本无损。示波器 CH1 测 PWM 引脚,CH2 测 RC 输出,应当看到 PWM 高电平时长围绕 50% 正弦变化,CH2 是一条干净的 50Hz 正弦。用万用表频率档测 PWM 引脚是 20kHz,测 RC 输出是 50Hz,就可以把程序接到驱动级。如果 CH2 有明显台阶,说明表长不够或载波频率太低;如果正弦波相位在抖,说明中断响应不稳定,优先查 NVIC 优先级和中断里是否藏了浮点运算。把 PWM 引脚频率量到 19.98kHz,滤波输出量到 49.9Hz,这个工程才算真正能往下接功率级。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询