STM32F103C8T6最小系统实现三相SPWM六路互补与死区调频调压

发布时间:2026/10/26 8:43:06
STM32F103C8T6最小系统实现三相SPWM六路互补与死区调频调压

简介:面向学习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 × (ARR+1)),ARR 取 1999 得 18kHz,周期 55.6µs;
  • 死区 1.5µs 占周期约 2.7%,对小功率管来说在可接受范围,低压调制时不会明显丢波;
  • 360 点正弦表对应 1° 分辨率,每点一个 PWM 周期刷新时输出频率 = 18kHz / 360 = 50Hz,整数关系让验证变得很直观。

如果要求输出频率范围很宽,比如 5~60Hz,靠“每周期走一个点”就只能得到 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:i+12]) + ",")

脚本输出 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 正弦值和调制比合成占空比,再映射到 0~ARR 的比较值区间。下面这段是实际在用的映射函数,输入输出全是整数,没有一次浮点运算。

/** * 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这个写法把双极性正弦整体抬到 0~2048 的正区间,除以 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);

整个中断体测下来大约 3~4µs(72MHz、无优化 -O0 时更长),18kHz 载波下一个周期 55.6µs,余量充足。如果后面要加电流环或 ADC 采样,建议把这一坨搬到 DMA 触发或者主循环里做双缓冲,中断只负责切换指针,别在中断里做除法。

注意:CCR 必须开预装载(OCxPE=1),否则写入立即生效,可能在计数器已经越过新值的周期里产生一个极窄的脉冲,示波器上表现为周期性毛刺,用低带宽示波器还看不出来。

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]死区计算式取值范围(tDTS)
0xxDT = DTG[6:0] × tDTS0 ~ 127
10xDT = (64 + DTG[5:0]) × 2 tDTS128 ~ 254
110DT = (32 + DTG[4:0]) × 8 tDTS256 ~ 504
111DT = (32 + DTG[4:0]) × 16 tDTS512 ~ 1008

本工程 CKD 配成TIM_CLOCKDIVISION_DIV2,tDTS = 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 定义为输出基波幅值与母线电压之比,范围理论上是 0~1,实际工程里上限压到 0.95。原因不是“留余量”这种笼统说法,而是 SPWM 在 M 超过 1 之后进入过调制,正弦波顶部被 ARR 削平,线电压出现低次谐波,电流波形会出现明显的平顶和尖峰,电机上表现为转矩脉动和噪声突变。

#define MOD_MAX 973 /* 0.95 × 1024,Q10 表示 */ 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 时正好是 65536,5Hz 时是 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 斜坡上升到目标值,频率从最低频率爬升到目标频率,两者同步推进,通常用 2~3 秒走完。

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/ms,3 秒爬满 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 时中断每周期进两次,正弦表就走两步,频率看起来减半;反过来说,如果相位步进算的是“每周期一步”,实际就是两倍频。解决:把重复计数器设为 1(htim1.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 预装载没开(OCxPE=0),写入的比较值立即生效,如果写入时刻计数器已经越过新值,本周期就不会翻转,下一周期才补上,形成相位错位。解决:在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 读出来,对照设计值:ARR=1999、PSC=0、CKD=01、DTG=0x36、CCER 低六位应为 0x0555(三路主输出加三路互补全开)、BDTR 的 MOE 位为 1。这一步能挡掉一半的低级错误。

第二步,单相双路波形。示波器两个通道接 PA8 和 PB13,触发放在上升沿,看死区。通过标准是:两路严格互补,跳变沿之间有 1.4~1.6µs 的平台,平台期间两路都为低。这里有个读数技巧——把时基打到 1µs/div,用光标卡死区宽度,比目测准得多。

第三步,三相相位关系。三路主输出同时接上,看 U、V、W 的基波过零点间隔是否为 6.67ms(50Hz 下 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.3~0.9 区间幅值偏差 < 3%逐点记录线电压有效值
调频载波恒定10~60Hz 内载波保持 18kHz看相邻脉冲间隔
软启动3 秒内无过流、无阶跃串电流探头看母线电流

Keil 5 的仿真环境里也能做前两项:进入 Debug 模式,View → Analysis Windows → Logic Analyzer,把TIM1->CCR1、TIM1->CCR2、TIM1->CCR3和对应的 GPIO 端口位加进去,设置好显示范围就能看到 PWM 波形。要注意逻辑分析仪显示的是寄存器值,不是引脚电平,互补关系和死区还得靠实物示波器确认,两者分工别搞混。

从那以后我每次做带死区的互补输出工程,都会在写第一行业务代码之前先把“MOE 是否置位、预装载是否打开、更新事件每周期几次”这三件事写进检查清单,强制走一遍再往下写。这三条看着琐碎,却能省掉大量返工。工程文件、正弦表生成脚本和 Keil 仿真配置都打包放在资源里,导入后编译就能跑,改参数的位置在第 4 章那两个函数里,希望帮到你。

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