简介:面向STM32嵌入式学习者与电机控制入门者的SPWM实践工程,基于STM32F103C8T6高级定时器TIM1查表法生成三相六路互补SPWM,相位互差120°,集成死区设置与调频调压,并以恒压频比方式驱动三相电机,适合熟悉定时器、PWM与电机基础后开展进阶实验。压缩包共149个文件、约46.67MB,包含18个c与26个h源码、KEIL5工程文件及hex输出,另有PDF原理文档、正弦表生成工具和死区计算器,便于编译、仿真和参数核对。已有8068人学习下载,可作为课程设计、毕业设计或电赛准备的参考。代码注释较详细,可在KEIL5中在线仿真观察六路波形,配合原理资料理解查表、互补输出与死区计算,并借鉴调频调压的实现思路和排错方法。
1. 调三相电机最容易翻车的地方,从来不是算法
调三相无刷电机、做一块小型逆变板,最先出问题的往往不是控制算法,而是六路 PWM 的时序——上管还没完全关断,下管就导通了,一瞬间母线短路的电流足够把功率管送走。这个基于 STM32F103C8T6 的项目做的事情很直接:用 TIM1 的三对互补通道输出三相六路 SPWM,死区时间可配,输出频率和调制比可以在线调节(也就是调频调压),并且在 KEIL5 下可以跑软件仿真,先把波形算法验证一遍再上功率级。它适合正在做变频器、三相逆变电源、BLDC/PMSM 开环驱动的开发者,尤其是手上只有一块最小系统板加一台示波器、想先把六路波形跑对再谈电流环的人。下面按工程骨架、调制波生成、互补与死区、调频调压、排查、二次开发这个顺序拆一遍,代码能直接抄。
2. 工程骨架与定时器资源:六路 PWM 为什么全压在 TIM1 上
STM32F103C8T6 是 48 脚的 M3 小容量型号,64KB Flash、20KB RAM,高级定时器只有一个 TIM1,通用定时器 TIM2/3/4 都没有互补输出和死区单元。所以三相六路互补 PWM 没有第二个选择,只能全部压在 TIM1 上。这不是限制,反而是好事:三对互补通道共用同一个计数器、同一个死区发生器和同一个刹车输入,三相之间的相位关系由硬件保证,不会因为软件里三个定时器启动时刻不同而产生相位漂移。
2.1 引脚映射与时基参数:20kHz 载波是怎么算出来的
引脚分配是固定的,TIM1 的通道复用位置没有太多腾挪空间:
| 功能 | 主输出 | 互补输出 | 备注 |
|---|---|---|---|
| U 相 | PA8 (CH1) | PB13 (CH1N) | 一般对应上桥臂 |
| V 相 | PA9 (CH2) | PB14 (CH2N) | PA9 与 USART1_TX 复用 |
| W 相 | PA10 (CH3) | PB15 (CH3N) | PA10 与 USART1_RX 复用 |
| 刹车 | PB12 (BKIN) | — | 硬件封波输入 |
| 调试串口 | PB10/PB11 | — | 换成 USART3,避开 PA9/PA10 |
PA9、PA10 和串口 1 冲突是这块板子上最常见的资源争抢,调频调压如果打算用串口指令来做,串口一定要挪到 USART3 或者用软件模拟。引脚配成复用推挽、高速模式,这里没什么玄学:
static void pwm_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_AFIO_CLK_ENABLE(); /* CH1/CH2/CH3 主输出:PA8 / PA9 / PA10 */ gpio.Pin = GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10; gpio.Mode = GPIO_MODE_AF_PP; /* 复用推挽,由 TIM1 直接驱动 */ gpio.Speed = GPIO_SPEED_FREQ_HIGH; /* 20kHz 开关沿要快,别用 LOW */ HAL_GPIO_Init(GPIOA, &gpio); /* CH1N/CH2N/CH3N 互补输出:PB13 / PB14 / PB15 */ gpio.Pin = GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15; HAL_GPIO_Init(GPIOB, &gpio); }时基部分决定了载波频率。系统时钟 72MHz,TIM1 挂在 APB2 上,APB2 不分频时定时器时钟就是 72MHz。采用中心对齐模式,PWM 频率 = 72MHz / (2 × ARR),要 20kHz 载波就把 ARR 设成 1799:
htim1.Instance = TIM1; htim1.Init.Prescaler = 0; /* 72MHz 直接计数 */ htim1.Init.CounterMode = TIM_COUNTERMODE_CENTERALIGNED1; /* 中心对齐,谐波好 */ htim1.Init.Period = 1799; /* 72M/(2*1800)=20kHz */ htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; /* tDTS = 1/72MHz */ htim1.Init.RepetitionCounter = 1; /* 更新中断降频,见 3.3 */ htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; /* ARR 预装载,调频时必须 */ HAL_TIM_Base_Init(&htim1);中心对齐而不是边沿对齐,是因为三相逆变器用中心对齐时,开关时刻对称,线电压谐波集中在两倍载波附近,滤波器好设计,电流纹波也小。代价是同样 ARR 下频率减半,所以 ARR 要按 2 倍算。ClockDivision保持 DIV1 很重要,死区时间的基准 tDTS 就是 1/72MHz ≈ 13.89ns,改了它死区换算全乱。AutoReloadPreload必须开,否则运行中改 ARR 会在计数器中途生效,输出直接出现一个畸变周期。
2.2 目录结构与初始化顺序:三个文件撑起整个工程
工程不用搞得太复杂,Keil5 下这几个文件就够:
spwm_3phase/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── spwm.h /* 正弦表、调频调压接口 */ │ │ └── bsp_pwm.h /* TIM1 初始化、死区配置 */ │ └── Src/ │ ├── main.c /* 初始化顺序 + 主循环 */ │ ├── spwm.c /* 相位累加、CCR 计算、中断回调 */ │ ├── bsp_pwm.c /* GPIO/TIM1/BDTR */ │ └── stm32f1xx_it.c /* 中断入口 */ ├── Drivers/ /* HAL 库 */ └── MDK-ARM/spwm_3phase.uvprojx初始化顺序错一步就会看到奇怪的波形,正确顺序是这样:时钟 → GPIO → TIM1 时基 → 四个通道的 OC 配置 → BDTR 死区与刹车 → 先写 CCR 到 50% → 启动 PWM 输出 → 最后开更新中断。CCR 一定要在使能输出之前写好,否则第一拍占空比是复位值 0,N 通道会出现一个极窄的怪脉冲。
2.3 四个通道配置:主输出和互补输出要分别启动
OC 配置里主输出和互补输出是两个独立开关,这点在 HAL 里体现得很明显:
TIM_OC_InitTypeDef oc = {0}; oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = 900; /* 初始 50% 占空比,先给中间值 */ oc.OCPolarity = TIM_OCPOLARITY_HIGH; /* 主输出高有效 */ oc.OCNPolarity = TIM_OCNPOLARITY_HIGH; /* 互补输出高有效 */ oc.OCFastMode = TIM_OCFAST_DISABLE; oc.OCIdleState = TIM_OCIDLESTATE_RESET; /* 刹车时主输出拉低 */ oc.OCNIdleState = TIM_OCNIDLESTATE_RESET; /* 刹车时互补输出也拉低 */ HAL_TIM_PWM_ConfigChannel(&htim1, &oc, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim1, &oc, TIM_CHANNEL_2); HAL_TIM_PWM_ConfigChannel(&htim1, &oc, TIM_CHANNEL_3);Pulse是 CCR 初值,ARR 是 1799,50% 就是 900,实际占空比约 900/1800。OCIdleState和OCNIdleState都设成 RESET,意思是刹车触发后六路全部拉到无效电平,功率管全关,这是安全默认值。启动的时候三对通道都要单独调用:
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); /* 只置 CC1E */ HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); /* 置 CC1NE,互补通道才会动 */ /* CH2、CH3 同样各来一遍 */只调HAL_TIM_PWM_Start的话,主输出有波形、互补输出一直是低电平,示波器上一看就是三路有、三路没有。这个坑在后面第 5 章还会展开。
3. SPWM 调制波生成:384 点正弦表、Q16 相位累加与调压映射
SPWM 的本质是把一个正弦参考波和三角载波比较,得到占空比按正弦规律变化的方波。在单片机里不用真的去生成三角波比较,只要在每个载波周期算出一个 CCR 值就行。核心问题有三个:正弦表多少个点、相位怎么推进、调制比怎么映射成 CCR。
3.1 正弦表点数:必须能被 3 整除
点数选择有个硬约束——三相相位差 120°,索引偏移必须是整数。如果取 400 点,400/3 = 133.33,取整之后 B 相偏移 133、C 相偏移 266,三相之间的相位差变成 119.7° 和 120.3°,看起来只差零点几度,实际输出三相不平衡,电机低速时抖动、电流里出现低频分量。我第一次就是随手写了 400 点,查了两天才发现是这个原因。
正确做法是取 3 的倍数,384 点最顺手:384/3 = 128,索引偏移干净,同时 384 和 20kHz 中断配合,50Hz 输出时每个基波周期正好 400 次中断,步进 0.96 个点,用 Q16 定点累加刚好能表达这个小数。表用脚本离线生成:
import math N = 384 # 必须能被 3 整除 AMP = 32767 # Q15 定点,范围 -32767 ~ 32767 table = [int(round(AMP * math.sin(2 * math.pi * i / N))) for i in range(N)] print("const int16_t sin_table[%d] = {" % N) for i in range(0, N, 12): print(" " + ", ".join("%6d" % v for v in table[i:i + 12]) + ",") print("};")表里存的是归一化的正弦值,不带 ARR、不带调制比。这样做的好处是调压的时候不用重新算表,改一个系数就行;调频的时候也不动表,只改相位步进。表是 const,放 Flash 里,384 × 2 字节 = 768 字节,对 64KB 的 C8T6 完全没压力。
3.2 从正弦值到 CCR:调制比就是调压旋钮
CCR 的计算公式是:CCR = ARR/2 + (ARR/2) × M × sin(θ),其中 M 是调制比,0 到 1 之间。M 越大,正弦波幅度越大,输出基波电压越高,这就是调压。代码里全部用定点:
#define PWM_ARR 1799 #define SIN_POINTS 384 #define M_MAX_Q15 31130 /* 0.95 * 32768,留 5% 余量防过调制 */ static int32_t mod_q15 = 16384; /* 当前调制比,默认 0.5 */ /* 由正弦表索引算出一个通道的 CCR 值 */ static inline uint16_t ccr_from_index(uint16_t idx) { int32_t center = (PWM_ARR + 1) / 2; /* 900 */ int32_t amp = (center * mod_q15) >> 15; /* 幅度随 M 缩放 */ int32_t ccr = center + ((sin_table[idx] * amp) >> 15); /* 最小脉宽限制:太窄的脉冲功率管来不及开通,直接钳掉 */ if (ccr < 30) ccr = 30; if (ccr > PWM_ARR - 30) ccr = PWM_ARR - 30; return (uint16_t)ccr; }center是载波中点,对应 50% 占空比;amp是当前调制比下的最大偏移量,M=0.95 时 amp ≈ 855,CCR 在 45 到 1755 之间摆动。sin_table[idx]是 Q15 的 -32767 到 32767,乘完再右移 15 位还原。最后那两行最小脉宽钳位很关键:M 很小或者接近过调制时,CCR 会贴近 0 或 ARR,算出来的脉冲只有几十纳秒,驱动芯片根本传不过去,实际输出电压会莫名其妙地非线性。钳在 30 个计数(约 0.42μs)是个经验值,具体看你的驱动光耦或栅极驱动器的最小脉宽指标。
调压接口就一个函数,外部按键、串口指令、ADC 采样都往这里送:
void spwm_set_modulation(uint16_t m_q15) { if (m_q15 > M_MAX_Q15) m_q15 = M_MAX_Q15; /* 限幅,防过调制 */ mod_q15 = m_q15; }如果要做电位器调压,就加一路 ADC,1ms 读一次,做一阶滤波再映射到 0.05 到 0.90 的调制比范围。滤波系数别太大,否则拧电位器的时候电压响应明显滞后,手感很差。
3.3 更新中断里推进相位:Q16 累加器解决小数步进
相位推进用 Q16 定点累加器,高 16 位就是当前索引,低 16 位是小数部分。这样 50Hz 时步进 0.96 个点也能精确表达,不会像整数步进那样只能输出 50Hz 的整数倍频率:
#define ISR_FREQ 20000u /* 更新中断频率,实测后填这里 */ static uint32_t phase_acc = 0; /* Q16 相位累加器,0 ~ 384<<16 */ static uint32_t phase_step = 0; /* 每个中断前进的 Q16 步长 */ void spwm_set_frequency(uint32_t f_out_hz) { /* step = f_out / ISR_FREQ * SIN_POINTS * 65536 */ phase_step = (uint32_t)(((uint64_t)f_out_hz * SIN_POINTS * 65536u) / ISR_FREQ); } /* HAL 的周期回调,20kHz 进来一次 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM1) return; phase_acc += phase_step; if (phase_acc >= ((uint32_t)SIN_POINTS << 16)) phase_acc -= ((uint32_t)SIN_POINTS << 16); /* 取模,比 % 快 */ uint16_t ia = (uint16_t)(phase_acc >> 16); uint16_t ib = (ia + SIN_POINTS / 3) % SIN_POINTS; /* +120° */ uint16_t ic = (ia + SIN_POINTS * 2 / 3) % SIN_POINTS; /* +240° */ __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr_from_index(ia)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, ccr_from_index(ib)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, ccr_from_index(ic)); }phase_step在 50Hz 时是 62914 左右,接近 65536,也就是每次中断差不多前进一个点。1Hz 对应 1258,频率分辨率远高于 1Hz,做 0.1Hz 起步的软启动也没问题。取模那里用减法而不是%,是因为 20kHz 中断下每个周期省几十个时钟也是省,而且% SIN_POINTS在 SIN_POINTS 不是 2 的幂时会被编译成除法调用,开销不小。
中断频率这里有个坑要说清楚。中心对齐模式下更新事件在计数器上溢和下溢时都会产生,RCR=1 到底把中断降到 20kHz 还是 10kHz,不同版本的参考手册描述看起来容易混。稳妥做法是在回调里翻转一个空闲 GPIO,用示波器量出真实中断频率,再把这个值填回ISR_FREQ。这个宏不对,输出频率就整体偏一倍,而且波形看着还挺正常,属于典型的黑匣子问题。
至于用 HAL 回调还是直接写在TIM1_UP_IRQHandler里,20kHz 下 HAL 那层分派函数大概多花几十个周期,72MHz 主频完全扛得住,先用回调把功能跑通,真到极限了再内联优化。
4. 互补输出与死区:BDTR 寄存器、1μs 死区和调频的两条路
六路互补输出不是把三个通道配成互补就完事,真正的安全边界在 BDTR 寄存器里。死区时间不够,上下管直通;死区时间太长,低压时占空比被吃掉,波形畸变。刹车输入不配,一个过流信号过来只能靠炸管子来保护。
4.1 BDTR 配置:MOE 是总闸,死区是保险
BDTR 里有几个关键位:DTG[7:0] 死区时间、OSSR/OSSI 空闲状态、BKE 刹车使能、AOE 自动输出使能、MOE 主输出使能。HAL 把这部分单独拿出来,要用HAL_TIMEx_ConfigBreakDeadTime:
TIM_BreakDeadTimeConfigTypeDef bdt = {0}; bdt.OffStateRunMode = TIM_OSSR_ENABLE; /* 运行时关断,引脚进空闲态 */ bdt.OffStateIDLEMode = TIM_OSSI_ENABLE; /* 空闲时也保持关断,别浮空 */ bdt.LockLevel = TIM_LOCKLEVEL_OFF; /* 不锁寄存器,方便在线调死区 */ bdt.DeadTime = 72; /* 0x48,对应 72*13.89ns ≈ 1.0us */ bdt.BreakState = TIM_BREAK_ENABLE; /* 使能刹车输入 PB12 */ bdt.BreakPolarity = TIM_BREAKPOLARITY_HIGH; bdt.BreakFilter = 0; bdt.AutomaticOutput = TIM_AUTOMATICOUTPUT_ENABLE; /* 刹车后下一个更新事件自动恢复 */ HAL_TIMEx_ConfigBreakDeadTime(&htim1, &bdt); __HAL_TIM_MOE_ENABLE(&htim1); /* 总闸,不开这个六路全哑 */DeadTime填的不是纳秒,是 DTG 编码值,这是最容易搞错的地方。tDTS = 1/72MHz ≈ 13.89ns,DTG 的低 7 位直接乘以 tDTS。要 1μs,就是 1000/13.89 ≈ 72,直接填 72。超过 127 之后编码规则变了,得走分段公式,所以我一般写个换算函数,避免手算出错:
/* 输入死区纳秒数,返回 BDTR 的 DTG 编码;tDTS 固定 13.89ns */ static uint8_t deadtime_encode(uint32_t ns) { uint32_t ticks = (ns * 72u + 999u) / 1000u; /* 向上取整到 tDTS 个数 */ if (ticks < 128) return (uint8_t)ticks; /* 0xx 段 */ if (ticks < 256) return (uint8_t)(0x80 | ((ticks / 2) - 64)); /* 10x 段 */ if (ticks < 512) return (uint8_t)(0xC0 | ((ticks / 8) - 32)); /* 110 段 */ return (uint8_t)(0xE0 | ((ticks / 16) - 32)); /* 111 段 */ }四段编码的精度不一样:第一段 13.89ns 一档,第二段 27.8ns,第三段 111ns,第四段 222ns。1μs 落在第一段,精度最好。如果你的功率管比较慢,需要 2μs 以上,就会掉到第二段,实际死区和设定值可能差几十纳秒,上电前用示波器量一遍双通道的间隔比较稳。
AutomaticOutput使能后,刹车信号消失、下一个更新事件到来时会自动恢复输出。做实验很方便,但在真实功率级上我一般关掉它,让刹车后必须软件复位才能重新输出,避免故障反复触发。
4.2 死区对波形的实际影响:占空比被吃掉一块
加了死区之后,实际输出的有效占空比和 CCR 算出来的不一样。以上桥臂为例,理想情况下高电平时间是 CCR/ARR,但下管关断到上管开通之间要插死区,上管的实际导通时间变短,输出电压比理论值低。M 越小这个偏差占比越大,低速时电机电流明显畸变,这就是常说的死区效应。
开环跑的时候可以不管,反正没有反馈;要做闭环或者对低压波形有要求,就得补。最简单的补偿方法是按电流方向给 CCR 加一个偏置:电流流出桥臂时多给一点,流入时少给一点。这需要采样电流,属于二次开发的内容,第 6 章再说。开环阶段至少要保证死区时间一致,别出现某一相死区 1μs、另一相 1.5μs 的情况,那会造成三相不对称。
4.3 调频的两条路:改步进还是改 ARR
调频调压里,调压已经用调制比解决了,调频有两条路可以走,选错了会连带出一堆问题。
第一条路是固定载波 20kHz,只改phase_step,也就是 3.3 节的做法。载波频率恒定,死区占比恒定,滤波器设计简单,输出频率可以从 0.1Hz 连续调到几百 Hz。缺点是载波比不固定,低频时一个基波周期里有几万个载波周期,开关损耗相对偏大,但小功率场合完全无所谓。这条路我推荐默认用。
第二条路是固定载波比,改 ARR 让载波频率跟着输出频率走。比如固定每周期 400 个载波,输出 10Hz 时载波 4kHz,输出 50Hz 时载波 20kHz。好处是低频时开关损耗低、谐波分布固定;坏处是载波频率变化时死区占比也变,而且 ARR 改了之后中断频率跟着变,ISR_FREQ这个宏就失效了,得动态算步进。代码大概是这样:
/* 方案 B:固定载波比,改 ARR。仅示意,注意 ARR 预装载必须开 */ void spwm_set_frequency_by_arr(uint32_t f_out_hz, uint32_t carrier_ratio) { uint32_t arr = 36000000u / (f_out_hz * carrier_ratio); /* 72M/(2*f*N) */ if (arr < 200) arr = 200; /* 上限保护,别把载波推到几百 kHz */ if (arr > 60000) arr = 60000; /* 下限保护,C8T6 的 ARR 是 16 位 */ __HAL_TIM_SET_AUTORELOAD(&htim1, arr - 1); /* 中断频率变成 2*f_out*N,相位步进要按新频率重算 */ }问题是 ARR 一改,PWM 周期变了,CCR 的有效范围也变了,正弦表的幅度映射要跟着改,否则调制度跟着频率一起漂。而且 ARR 写入的时机必须在更新事件之后,否则当前周期会被截断。所以这条路上手难度明显高一些,建议先把方案 A 跑稳,确有需求再切。
4.4 刹车输入:一条线保六路
PB12 作为 BKIN 输入,配置成复用功能或者普通输入都行,关键是别让它悬空。使能了刹车但引脚没接、也没加下拉,浮空电平随机触发,示波器上会看到波形毫无规律地断续,很容易误判成程序跑飞。实际接法是过流比较器的输出接到 PB12,一旦过流,硬件在几十纳秒内把六路全部封掉,比中断响应快一个数量级。
外搭电路时,把比较器阈值设在母线电流的 1.5 倍左右,比较器输出加个 RC 滤波防误触发,滤波时间常数别超过 1μs,否则真过流的时候已经晚了。
5. 常见问题排查:从 N 通道没波形到低速电流畸变的 5 个坑
调试这块板子的时候,示波器上能看到的现象就那几种,但原因可能差很远。下面这几条是我自己踩过的,按「现象 → 原因 → 解决」写,遇到类似情况可以先对号入座。
5.1 现象:主输出三路有波形,互补输出三路一直是低电平
原因:只调用了HAL_TIM_PWM_Start,它只置位 CCER 里的 CCxE,互补通道的 CCxNE 没动。HAL 里主输出和互补输出是两个独立的启动函数,很多人以为启动一个通道就把一对都启动了。
解决:每个通道都补一句HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_x)。另外检查 BDTR 的 MOE 位,虽然 HAL 在启动函数里会帮你置 MOE,但如果中途调用了__HAL_TIM_MOE_DISABLE或者刹车触发过,MOE 会被清掉,六路一起哑。
5.2 现象:波形出来了,但电机低速抖动、三相电流不均衡
原因:正弦表点数不能被 3 整除。400 点、256 点这类看着顺眼的数字都会出问题,三相相位偏移取整之后差值不是严格 120°,低频时误差被放大成明显的转矩脉动。这个问题的隐蔽之处在于示波器看单相波形完全正常,只有对比三相或者看电流才能发现。
解决:点数改成 384、360 或 480 这类能被 3 整除的值。如果因为其他原因必须用不能被 3 整除的点数,那就别用索引偏移,直接在 Q16 相位上加 65536/3,让三相各自维护一个相位累加器,精度更高但中断里多两次加法。
5.3 现象:波形时有时无,没有任何规律,复位后又正常
原因:刹车引脚 PB12 悬空。使能了 BreakState,但引脚没接信号也没有内部上下拉,浮空电平在开关噪声下随机翻转,每次触发都会封波。另一种可能是AutomaticOutput关掉之后刹车触发过一次,程序里没有恢复逻辑,看起来就像死机。
解决:PB12 配置成下拉输入或者外部加 10k 下拉,确认比较器没输出时是低电平。调试阶段可以先把 BreakState 关掉,等波形跑通了再打开,这样能快速区分是刹车误触发还是算法问题。
5.4 现象:改频率的瞬间波形畸变一下,或者输出频率和设定值差一倍
本文还有配套的精品资源,点击获取