STM32F103 TIM1三相SPWM六路互补PWM死区与调频调压实现
发布时间:2026/10/26 8:44:18 锦皓数字建站

简介面向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/PA10PA9、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); }时基部分决定了载波频率。系统时钟 72MHzTIM1 挂在 APB2 上APB2 不分频时定时器时钟就是 72MHz。采用中心对齐模式PWM 频率 72MHz / (2 × ARR)要 20kHz 载波就把 ARR 设成 1799htim1.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 一定要在使能输出之前写好否则第一拍占空比是复位值 0N 通道会出现一个极窄的怪脉冲。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 是 179950% 就是 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是当前调制比下的最大偏移量M0.95 时 amp ≈ 855CCR 在 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; }如果要做电位器调压就加一路 ADC1ms 读一次做一阶滤波再映射到 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 ~ 38416 */ 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 的幂时会被编译成除法调用开销不小。中断频率这里有个坑要说清楚。中心对齐模式下更新事件在计数器上溢和下溢时都会产生RCR1 到底把中断降到 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_ConfigBreakDeadTimeTIM_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.89nsDTG 的低 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 现象改频率的瞬间波形畸变一下或者输出频率和设定值差一倍本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。