简介面向学习STM32电机控制与SPWM算法的嵌入式开发者这份资料以STM32F103C8T6的高级定时器TIM1为核心采用查表法生成三相六路互补SPWM信号相位互差120°并加入死区控制与调频调压通过恒压频比方式驱动三相电机。包内共149个文件、约46.67MB以C源文件和头文件构成KEIL5工程主体配合uvprojx工程、hex烧录文件、map与lst编译信息以及PDF原理文档、正弦表生成工具、死区计算器和辅助压缩包方便对照查阅。已有8068人学习。工程注释详细可在KEIL5中在线仿真观察六路PWM波形与死区效果结合原理文档和计算工具读者能理解查表法SPWM的生成流程、互补输出配置及调频调压实现思路也便于修改正弦表、死区时间与载波频率开展自己的电机驱动实验。1. 一块 48 脚最小系统如何塞进三相 SPWM 六路互补输出、死区与调频调压前段时间有个学生拿着 STM32F103C8T6 的最小系统板来问48 个引脚、64KB Flash、20KB RAM能不能直接做出三相 SPWM 六路互补输出还要带死区并且能在线调频调压我当时的回答是能但这个“能”有前提得先把定时器资源、载波频率和死区时间三者之间的关系算清楚只要其中一个参数拍脑袋决定后面六路波形就会到处出问题。这套方案适合做电机控制、逆变电源、变频调速方向课设或毕设的人也适合需要一份能跑通、能仿真的最小验证工程的工程师。它不追求大功率目标是在一块十几块钱的板子上把互补输出、死区插入、调频调压这三件事做成可测量、可复现的闭环。2. 资源盘点与总体架构72MHz 下 TIM1 六个引脚的分工先把“能不能做”拆成资源清单。STM32F103C8T6 只有一个高级定时器 TIM1 带互补输出和死区发生器剩下三个通用定时器 TIM2/3/4 都没有互补通道和刹车功能所以三相六路的活儿只能由 TIM1 一个外设承接没有第二选择。这意味着从时钟树到引脚复用所有配置都必须一次做对没有多余的定时器可以补救。我一般会先画一张资源占用表把每个引脚、每个寄存器位对应到具体的相后面调代码时对着表查比翻手册快得多。2.1 引脚与外设分配清单TIM1 的 CH1/CH2/CH3 输出三相上管驱动CH1N/CH2N/CH3N 输出对应的下管驱动六路刚好占满三对互补通道。默认复用功能下引脚是固定的不需要重映射这一点对布线友好的同时也意味着没有腾挪空间。通道主输出互补输出对应相驱动对象TIM1_CH1PA8PB13U 相上管 / 下管TIM1_CH2PA9PB14V 相上管 / 下管TIM1_CH3PA10PB15W 相上管 / 下管TIM1_BKINPB12—急停刹车输入可选六个引脚全部配成复用推挽输出GPIO_MODE_AF_PP速度设 50MHz。这里常见的疏忽是把互补脚当成普通 GPIO 推挽来配结果主输出有波形、互补脚一直是低电平示波器一测就以为死区配置错了实际上是引脚模式没切到复用。BKIN 我一般也接出来串一个上拉电阻到 3.3V方便调试时用一根线短地做硬件封波比软件里改标志位可靠。2.2 载波、死区与正弦表点数的三角权衡这三个参数互相牵制必须先定死两个再算第三个。载波频率决定开关损耗和电流纹波20kHz 附近是听感边界但死区在周期里占的比例也随之上升死区太小会直通炸管太大会在低占空比时把有效脉宽吃掉输出电压畸变正弦表点数决定输出频率分辨率和 Flash 占用。我这套工程取的是 18kHz 载波、1.5µs 死区、360 点正弦表算式如下中心对齐模式下f_pwm 72MHz / (2 × (ARR1))ARR 取 1999 得 18kHz周期 55.6µs死区 1.5µs 占周期约 2.7%对小功率管来说在可接受范围低压调制时不会明显丢波360 点正弦表对应 1° 分辨率每点一个 PWM 周期刷新时输出频率 18kHz / 360 50Hz整数关系让验证变得很直观。如果要求输出频率范围很宽比如 560Hz靠“每周期走一个点”就只能得到 50Hz 这一个值所以方案里引入了相位累加器这一点放到第 4 章展开。提示载波频率不要为了“听起来更平滑”一味往上加。F103 的 TIM1 在 72MHz 下跑到 40kHz 以上时中断里做三路乘除法的时间就开始逼近一个 PWM 周期仿真时看不出来上实物才发现波形周期性抖动。总体结构上这套工程分三层底层是 TIM1 的时基与互补输出配置中间层是正弦表与相位累加器上层是调频调压接口和软启动。三层之间只通过几个全局参数ARR、调制比、相位步进通信改参数不用动底层代码这是后面能快速调参的前提。3. SPWM 六路互补输出从正弦表到 CCR 的完整链路这一章是核心。STM32 产生 SPWM 的常规路径是算出一张正弦表存在 Flash 里每个 PWM 周期在更新中断里取三个相位相差 120° 的值换算成比较值写进 CCR1/CCR2/CCR3硬件自动和计数器比较翻转输出。听起来简单但查表、映射、写入三个环节各有各的问题下面按数据流顺序讲。3.1 正弦表离线生成与 Q15 定点化表不需要在单片机上算用脚本生成后直接作为 const 数组编译进 Flash这样既不占 RAM 也不占 CPU。定点格式我选 Q15即 ±32767 表示 ±1.0乘调制比时右移 15 位就够Cortex-M3 没有 FPU用浮点会在中断里拖出几百个周期。import math N 360 # 一个正弦周期的采样点数对应 1 度分辨率 AMP 32767 # Q15 满量程避免 int16 正向上溢 vals [] for k in range(N): # 从 0 相位开始正向正弦方便与后级相位索引对齐 vals.append(int(round(AMP * math.sin(2 * math.pi * k / N)))) for i in range(0, N, 12): print( , .join(f{v:6d} for v in vals[i:i12]) ,)脚本输出 30 行、每行 12 个数直接粘进 C 文件的const int16_t sin_tab[360]。这里有个容易忽略的点用round而不是直接截断截断会让表整体偏小半个 LSB三相对称性受影响N 选 360 是因为它同时能被 3 和 120 整除B 相和 C 相的索引可以直接加 120、240 取模不用做浮点相位换算。/* 三相位索引固定相差 120 个点与 360 点表严格对应 */ #define PHASE_U 0 #define PHASE_V 120 #define PHASE_W 240 static const int16_t sin_tab[360] { /* 此处粘贴脚本输出 */ };表放在 Flash 里占 720 字节对 64KB 的 C8T6 来说几乎可以忽略。有人担心查表法“不够精确”实际上 360 点加上一阶保持在 18kHz 载波下谐波已经落在开关频率附近被 LC 或电机电感滤掉实测线电压 THD 与实时计算法差别在 1% 以内。3.2 CCR 刷新中断里做多少事才不算多CCR 的更新放在 TIM1 更新中断里做一次算三路。核心是把 Q15 正弦值和调制比合成占空比再映射到 0ARR 的比较值区间。下面这段是实际在用的映射函数输入输出全是整数没有一次浮点运算。/** * s : Q15 正弦值范围 [-32767, 32767] * arr : 当前自动重载值中心对齐下占空比上限 * m_q10 : 调制比Q10 格式1024 表示 1.0 * 返回 : 写入 CCRx 的比较值 */ static inline uint16_t ccr_from_mod(int16_t s, uint16_t arr, uint16_t m_q10) { int32_t duty ((int32_t)s * (int32_t)m_q10) 15; /* 归一到 [-1024, 1024] */ int32_t ccr ((int32_t)arr * (1024 duty)) / 2048; /* 线性映射到 [0, arr] */ if (ccr 0) ccr 0; /* 下限保护防负数截断 */ if (ccr arr) ccr arr; /* 上限保护防比较值越界 */ return (uint16_t)ccr; }映射关系来自中心对齐模式下占空比 ≈ CCR/ARR输出平均值随调制比线性变化。1024 duty这个写法把双极性正弦整体抬到 02048 的正区间除以 2048 就完成归一比先算浮点再乘 ARR 少一次类型转换。三路写入的代码如下uint16_t idx_u (uint16_t)(phase_acc 16) % 360; /* 相位累加器高 16 位取索引 */ uint16_t idx_v (idx_u PHASE_V) % 360; uint16_t idx_w (idx_u PHASE_W) % 360; TIM1-CCR1 ccr_from_mod(sin_tab[idx_u], arr_now, mod_now); TIM1-CCR2 ccr_from_mod(sin_tab[idx_v], arr_now, mod_now); TIM1-CCR3 ccr_from_mod(sin_tab[idx_w], arr_now, mod_now);整个中断体测下来大约 34µs72MHz、无优化 -O0 时更长18kHz 载波下一个周期 55.6µs余量充足。如果后面要加电流环或 ADC 采样建议把这一坨搬到 DMA 触发或者主循环里做双缓冲中断只负责切换指针别在中断里做除法。注意CCR 必须开预装载OCxPE1否则写入立即生效可能在计数器已经越过新值的周期里产生一个极窄的脉冲示波器上表现为周期性毛刺用低带宽示波器还看不出来。3.3 互补输出与死区DTG 编码不是随手填一个数互补输出要开三个位CCxE 使能主输出、CCxNE 使能互补输出、MOE 主输出使能。HAL 里用HAL_TIM_PWM_Start()分别启动三个通道内部会置位但更稳妥的做法是启动后手动__HAL_TIM_MOE_ENABLE(htim1)兜底避免某些 HAL 版本在刹车复位后漏开 MOE。死区时间的计算是这套工程里最容易被“估一个值”糊弄过去的地方。死区的时基是 tDTS由 CR1 的 CKD[1:0] 决定DTG[7:0] 有四段编码区间DTG[7:5]死区计算式取值范围tDTS0xxDT DTG[6:0] × tDTS0 12710xDT (64 DTG[5:0]) × 2 tDTS128 254110DT (32 DTG[4:0]) × 8 tDTS256 504111DT (32 DTG[4:0]) × 16 tDTS512 1008本工程 CKD 配成TIM_CLOCKDIVISION_DIV2tDTS 2 × 13.89ns 27.78ns。要 1.5µs即 1500/27.78 ≈ 54 个 tDTS落在第一段区间DTG[6:0] 54 0x36。如果 CKD 不分频tDTS 13.89ns第一段最大只有 127×13.89 ≈ 1.76µs勉强够用但分辨率粗如果要求 3µs 以上死区就得切到 10x 或 110 段公式完全不同不能拿一个数到处套。TIM_BreakDeadTimeConfigTypeDef bd {0}; bd.OffStateRunMode TIM_OSSR_ENABLE; /* 运行模式下关闭态由定时器接管 */ bd.OffStateIDLEMode TIM_OSSI_ENABLE; /* 空闲模式下输出安全电平 */ bd.LockLevel TIM_LOCKLEVEL_OFF; bd.DeadTime 0x36; /* 54 × 27.78ns ≈ 1.5µs */ bd.BreakState TIM_BREAK_ENABLE; /* 使能刹车输入接急停 */ bd.BreakPolarity TIM_BREAKPOLARITY_HIGH; bd.BreakFilter 0; /* 先不滤波调试期便于观察 */ bd.AutomaticOutput TIM_AUTOMATICOUTPUT_DISABLE; /* 刹车后不自动恢复强制人工确认 */ HAL_TIMEx_ConfigBreakDeadTime(htim1, bd);AutomaticOutput关掉是有意为之。自动恢复意味着刹车信号消失后输出立刻回来如果此时管子还热着或者故障没排除很容易二次损坏关掉之后必须软件重新置 MOE多一次人工确认代价是几行代码收益是安全。死区插入后实际上下管的导通时间比理论占空比少 1.5µs在低调制比时会出现波形顶部被削平的现象这是死区效应属于正常物理现象靠后面的死区补偿或者提高载波频率缓解不要试图把死区调小来“修波形”。4. 调频调压调制比与相位步进的解耦更新调压和调频是两件独立的事但很多工程把它们绑在一个函数里改频率顺带把调制比也重置了导致电机转速一变输出电压就跳。这套方案把两条链路拆开调压只动调制比mod_now调频只动相位步进phase_step互不干扰也便于做软启动。4.1 调压M 到 CCR 的线性映射与过调制保护调制比 M 定义为输出基波幅值与母线电压之比范围理论上是 01实际工程里上限压到 0.95。原因不是“留余量”这种笼统说法而是 SPWM 在 M 超过 1 之后进入过调制正弦波顶部被 ARR 削平线电压出现低次谐波电流波形会出现明显的平顶和尖峰电机上表现为转矩脉动和噪声突变。#define MOD_MAX 973 /* 0.95 × 1024Q10 表示 */ void set_modulation(uint16_t m_q10) { if (m_q10 MOD_MAX) m_q10 MOD_MAX; /* 硬限幅防上层误传 */ mod_now m_q10; }限幅放在这里而不是放在调用处是因为调压的入口可能不止一个——电位器采样、串口指令、上位机曲线都会走到这里每个入口都写一遍判断迟早漏掉一个。上层只负责传目标值边界由这一层守住这是我在多个项目里被“某条路径忘了限幅”坑过之后形成的习惯。调压的响应速度也要控制。从 0 直接跳到 0.9输出电流会有一个明显的冲击母线电容小的时候甚至会把电压拉塌。通常做法是加一个斜率限制每毫秒最多增加 0.01 的调制比10ms 一个台阶听感和电流冲击都能接受。4.2 调频固定载波改步进还是改 ARR 改载波这是本方案里最值得讲清楚的一个选型。两种调频路径都可行但后果完全不同。第一种是改 ARR。输出频率与 ARR 成反比f_out f_pwm × step / 360载波频率随输出频率一起变。优点是代码简单改一个寄存器缺点是整个系统的开关损耗、死区占比、电流纹波全都跟着变低频时载波也低电机噪声明显死区占比反而变大低速性能差。第二种是固定 ARR改相位累加步进。载波恒定在 18kHz输出频率由phase_step决定phase_step f_out × 360 × 65536 / f_pwm。50Hz 时正好是 655365Hz 时是 6553.6取整带来不到 0.01% 的频率误差完全可以接受。/* 相位累加器Q16 格式低 16 位是小数相位高 16 位是整数索引 */ static uint32_t phase_acc 0; static uint32_t phase_step 65536; /* 默认 50Hz */ void set_frequency_x10(uint32_t f_x10) { /* f_x10 单位 0.1Hz先算 f_out × 360 × 65536 / 18000再折回 0.1Hz 精度 */ uint64_t step ((uint64_t)f_x10 * 360ULL * 65536ULL) / 180000ULL; if (step 1000ULL) step 1000ULL; /* 下限约 0.76Hz防步进过小 */ if (step 200000ULL) step 200000ULL; /* 上限约 152Hz防索引跳过整周期 */ phase_step (uint32_t)step; /* 相位累加器本身不清零改频率时相位连续不会产生阶跃 */ }关键在于最后一行注释改频率时不动phase_acc。如果清零输出波形会在改频瞬间出现一次相位跳变三相电流里能看到一个尖峰保持累加器连续频率是“滑过去”的波形连续这就是软调频。两种方案的对比放在一张表里更直观对比项改 ARR改相位步进载波频率随输出频率变化恒定 18kHz死区占比低频时变大恒定 2.7%低速噪声明显小代码复杂度低中Q16 累加适用场景小功率风机、水泵宽调速、低噪场合4.3 软启动从 0 到目标值的那三秒上电直接给满调制比是课设里最常见的“炸管动作”。母线电容充电瞬间相当于短路再加上输出滤波器件的储能冲击电流能到额定值的数倍。软启动要做两件事调制比从 0 斜坡上升到目标值频率从最低频率爬升到目标频率两者同步推进通常用 23 秒走完。void soft_start_task(void) /* 放在 1ms 定时任务里调用 */ { if (mod_now mod_target) { mod_now MOD_STEP; /* MOD_STEP ≈ 10即 0.01/ms */ if (mod_now mod_target) mod_now mod_target; } if (freq_now freq_target) { freq_now FREQ_STEP; /* 0.1Hz/ms3 秒爬满 300 个 0.1Hz */ if (freq_now freq_target) freq_now freq_target; set_frequency_x10(freq_now); } }斜坡步长决定启动时间也决定冲击大小。步长太小启动慢但平稳太大则失去意义。3 秒这个值不是拍出来的是按电机空载启动电流不超过额定 1.5 倍反推的换负载类型要重新估。5. 避坑与排查六路波形最容易翻车的五个点这套工程我在仿真和实物上来回折腾过不少轮下面五条是实际遇到、并且值得写进检查清单的。每条按现象、原因、解决三段写遇到问题时对着查比盲目改寄存器快。5.1 输出频率总是设定值的一半现象设 50Hz实测 25Hz波形完整、三相对称就是不翻倍。原因中心对齐模式下计数器上溢到 ARR 和下溢到 0 各产生一次更新事件RCR 为 0 时中断每周期进两次正弦表就走两步频率看起来减半反过来说如果相位步进算的是“每周期一步”实际就是两倍频。解决把重复计数器设为 1htim1.Init.RepetitionCounter 1让更新中断与完整 PWM 周期同频或者在中断里读 CR1 的 DIR 位只在上溢时刷新 CCR。5.2 六路全无输出或只有主输出没有互补现象程序跑起来了PA8 有波形PB13 一直是低或者干脆六个脚都没动静。原因分两类一是 MOE 没置位高级定时器的输出总开关没开所有通道都出不来二是互补脚被配成了普通推挽没切到复用功能。解决启动后手动__HAL_TIM_MOE_ENABLE用万用表或仿真器读 GPIOB 的 CRH 寄存器确认 PB13/14/15 的 MODE 位是 10复用推挽而不是 01。刹车触发后 MOE 会被硬件清零恢复时必须软件重开这点在急停逻辑里要写死。5.3 波形上有周期性毛刺占空比偶尔跳变现象示波器上基波正常但每隔几十个周期出现一个极窄的尖脉冲电机有轻微异响。原因CCR 预装载没开OCxPE0写入的比较值立即生效如果写入时刻计数器已经越过新值本周期就不会翻转下一周期才补上形成相位错位。解决在HAL_TIM_PWM_ConfigChannel之后确认sConfigOC.OCFastMode为 DISABLE并检查 CCxE 对应的 OCxPE 位四路通道都要开只开 CH1 忘了 CH2/CH3 是常见疏漏。5.4 动态调频时出现异常宽脉冲现象改频率的瞬间输出有一拍特别宽或特别窄的脉冲电流跟着抖一下。原因ARR 预装载ARPE没开写 ARR 立即生效当前周期被强行截断或者相位累加器在改频率时被清零相位不连续。解决htim1.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE让新 ARR 在下一个更新事件生效改频率只改phase_step不碰phase_acc。5.5 仿真能看到波形下载到板子就没有现象Keil 里逻辑分析仪波形漂亮实物上电后六个脚静默。原因通常有两个仿真器的逻辑分析仪看的是外设寄存器的“意图值”不反映引脚实际电平GPIO 复用没配照样有波形另外仿真时不需要使能 TIM1 的时钟实物上如果__HAL_RCC_TIM1_CLK_ENABLE()被条件编译漏掉整个外设不工作。解决仿真只用来验证算法和时序引脚配置和时钟使能必须在实物上用示波器复核一遍这两个环节不能互相替代。提示这五条里至少有三条是“配置位没开”而不是“算法错了”。调试时先拿仿真器读一遍 TIM1 的 CR1、CCER、BDTR、CR2 四个寄存器比对期望值比逐行读代码快得多。6. 验收与答辩用一张表和两路波形证明这套方案跑通了验收不需要复杂仪器一台示波器、一个 Keil 工程就够。我一般按下面这个顺序走每一步都有明确的通过标准不通过就停下来查不往后跳。第一步静态配置核对。用仿真器连上把 TIM1 的 CR1、CCER、BDTR、ARR、PSC、CCR1~3 读出来对照设计值ARR1999、PSC0、CKD01、DTG0x36、CCER 低六位应为 0x0555三路主输出加三路互补全开、BDTR 的 MOE 位为 1。这一步能挡掉一半的低级错误。第二步单相双路波形。示波器两个通道接 PA8 和 PB13触发放在上升沿看死区。通过标准是两路严格互补跳变沿之间有 1.41.6µs 的平台平台期间两路都为低。这里有个读数技巧——把时基打到 1µs/div用光标卡死区宽度比目测准得多。第三步三相相位关系。三路主输出同时接上看 U、V、W 的基波过零点间隔是否为 6.67ms50Hz 下 120° 对应的时间。通过标准是三个间隔两两相等误差在 0.1ms 内。如果某一相明显偏移回头查正弦表的索引偏移是不是写成了 120 和 240 以外的数。第四步调频调压联动。把调制比从 0.3 逐步加到 0.9观察输出线电压幅值是否线性上升把频率从 10Hz 拉到 60Hz观察载波频率是否保持 18kHz 不动、死区宽度是否恒定。这两项都通过说明调频调压确实做了解耦。答辩时最常被问到的三个问题其实都能用上面的数据回答。第一个是“死区怎么定的”答 tDTS 和 DTG 编码报出 27.78ns 和 0x36 两个数比说“经验值”有说服力。第二个是“为什么不用 TIM2 做第三相”答 TIM2 没有互补通道和 BDTR根本不具备条件。第三个是“调频时载波变不变”答本方案改相位步进、载波恒定并把两种方案的对比表拿出来。验收项通过标准测量方法死区宽度1.5µs ± 0.1µs示波器双通道1µs/div三相对称过零点间隔 6.67ms ± 0.1ms三通道同时采集调压线性度0.30.9 区间幅值偏差 3%逐点记录线电压有效值调频载波恒定1060Hz 内载波保持 18kHz看相邻脉冲间隔软启动3 秒内无过流、无阶跃串电流探头看母线电流Keil 5 的仿真环境里也能做前两项进入 Debug 模式View → Analysis Windows → Logic Analyzer把TIM1-CCR1、TIM1-CCR2、TIM1-CCR3和对应的 GPIO 端口位加进去设置好显示范围就能看到 PWM 波形。要注意逻辑分析仪显示的是寄存器值不是引脚电平互补关系和死区还得靠实物示波器确认两者分工别搞混。从那以后我每次做带死区的互补输出工程都会在写第一行业务代码之前先把“MOE 是否置位、预装载是否打开、更新事件每周期几次”这三件事写进检查清单强制走一遍再往下写。这三条看着琐碎却能省掉大量返工。工程文件、正弦表生成脚本和 Keil 仿真配置都打包放在资源里导入后编译就能跑改参数的位置在第 4 章那两个函数里希望帮到你。本文还有配套的精品资源点击获取