☰
STM32F103C8T6用TIM1实现三相六路互补SPWM与死区控制
2026/10/26 17:06:22 网站建设 项目流程

简介:面向嵌入式电机控制学习者与课程设计人员的STM32F103C8T6实践工程,基于高级定时器TIM1查表法生成三相六路互补SPWM,三相互差120°,集成死区配置、调频调压,并以恒压频比方式驱动三相电机。压缩包共149个文件、约46.67MB,主要由C/H源码、KEIL5工程与目标文件、HEX固件、PDF学习文档及ZIP工具包构成,另附正弦表生成工具和死区计算器,可辅助理解SPWM原理、核算死区时间、替换正弦表并复现实验。工程注释较详细,支持在KEIL5中在线仿真观察六路互补波形与死区效果,适合电机驱动入门、课程设计及方案对照。当前已有8068人学习下载,可作为从原理学习到工程实现的参考样本。

1. 一块十几块钱的 C8T6,怎么把三相六路 SPWM 跑起来

很多人第一次做三相逆变或者 BLDC 驱动,看到 STM32F103C8T6 就犯嘀咕:这颗芯片主频才 72MHz、Flash 64KB,能撑得起 SPWM 三相六路互补输出吗?答案是能,而且不用外挂任何 PWM 芯片——TIM1 本身就是高级定时器,三对互补通道加可编程死区发生器全都在硅片里。真正卡住人的不是性能,是死区怎么算、调频调压怎么解耦、互补脚为什么上电就直通这些细节。这份工程把 TIM1 的三相互补输出、死区插入、在线调频调压全揉在一起,KEIL5 打开就能仿真看波形,适合做电机驱动入门、逆变电源课设,以及想把「互补」「死区」从寄存器层面搞明白的人。下面按实际调试顺序拆。

2. 把正弦表塞进 TIM1:三相六路互补 PWM 的寄存器级搭法

2.1 为什么三相六路只能落在 TIM1 上

STM32F103C8T6 内部一共四组定时器:TIM1 是高级定时器,TIM2/3/4 是通用定时器。通用定时器每组的四个通道都是独立单端输出,没有互补引脚、没有死区发生器、没有刹车输入,让它去驱三相全桥的六只管子,硬件上就少了一半的引脚。TIM1 不一样,它的 CH1/CH2/CH3 各自带一个 CHxN 互补通道,默认复用映射正好是 PA8/PB13(U 相上下管)、PA9/PB14(V 相)、PA10/PB15(W 相),这套映射是芯片内部固定的,不需要重映射寄存器。所以只要看到「三相六路互补」,定时器选型基本没有第二选项。

这里有个常被跳过的点:死区一定要用硬件做,别用软件延时。软件延时受中断响应影响,抖动可以到几微秒,而且换个主频就得重算。TIM1 的死区由内部独立计数器产生,计时基准是 tDTS,跟 CPU 执行什么代码完全无关,这才是能上桥的可靠性来源。

2.2 中心对齐加 72MHz:ARR 和 PSC 到底填多少

TIM1 挂在 APB2 上,C8T6 满配时 APB2 定时器时钟是 72MHz。中心对齐模式下计数器从 0 数到 ARR-1 再数回 0,一个完整 PWM 周期是 2×ARR 个计数时钟,所以载波频率的算法是:

f_pwm = 72_000_000 / ((PSC + 1) × 2 × ARR)

反过来定参数就是ARR = 72_000_000 / (2 × f_pwm × (PSC+1))。实际工程里 PSC 一般取 0,让计数分辨率最大。下表是几组常用载波频率对应的参数,可以直接抄:

载波频率 f_pwmPSCARR实际频率计数分辨率
8 kHz044998000.0 Hz4500 级
10 kHz0359910002.8 Hz3600 级
16 kHz0224916003.6 Hz2250 级
20 kHz0179920005.6 Hz1800 级

为什么非要中心对齐?边沿对齐也能出 SPWM,但三相脉冲的起点全部对齐在计数器清零那一刻,三相的开关时刻在时间轴上不对称,电流纹波偏大;中心对齐时每路 PWM 脉冲关于周期中点对称,三相互补通道插入死区的位置也更规整,而且更新事件统一发生在计数器归零,三路 CCR 同一时刻生效,不会出现某一相慢半拍的情况。代价是同 ARR 下载波频率只有边沿对齐的一半,所以算 ARR 时那个 2 别忘了乘。

2.3 256 点正弦表怎么生成、怎么定点化

正弦表的点数不是随便定的。载波 10kHz、输出 50Hz 时,一个基波周期里有 200 个载波周期;表取 256 点意味着即使输出频率漂到 39Hz,也还是一一对应。点数低于 64 时阶梯感明显,THD 会飙;点数拉到 1024 又白占 Flash,256 点 int16 才 512 字节,对 64KB 的 C8T6 完全不心疼。生成脚本用 Python 跑一遍就行:

import math N = 256 # 表点数,2 的幂,后面可以直接用相位高 8 位寻址 AMP = 1024 # Q10 定点,幅值 1024 表示 1.0 vals = [int(round(AMP * math.sin(2 * math.pi * i / N))) for i in range(N)] print("static const int16_t sin_q10[%d] = {" % N) for i in range(0, N, 8): print(" " + ", ".join("%5d" % v for v in vals[i:i + 8]) + ",") print("};")

跑完得到一个 256 项的int16_t数组,取值范围 -1024 到 1024。用 Q10 而不是浮点的原因很直接:C8T6 没有 FPU,一次 float 乘法在中断里要几十个周期,而 Q10 定点就是一次 32 位乘加再右移,几个周期搞定,在 10kHz 的更新中断里差别很明显。

表格生成后不用手抄,直接把脚本输出存成sin_table.h,工程里#include进来即可。

2.4 六路通道的初始化代码与三个容易漏的开关

下面这段是 TIM1 的完整初始化,用标准外设库写的,HAL 库思路一样只是函数名不同:

#include "stm32f10x.h" void bsp_tim1_pwm_init(uint16_t arr, uint16_t psc, uint16_t dtg) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; TIM_BDTRInitTypeDef bdtr; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_TIM1 | RCC_APB2Periph_AFIO, ENABLE); /* CH1/2/3 -> PA8/PA9/PA10,互补 CH1N/2N/3N -> PB13/PB14/PB15 */ gpio.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_Init(GPIOB, &gpio); TIM_TimeBaseStructInit(&tb); tb.TIM_Prescaler = psc; tb.TIM_Period = arr; tb.TIM_CounterMode = TIM_CounterMode_CenterAligned1; /* 中心对齐 */ tb.TIM_ClockDivision = TIM_CKD_DIV1; /* tDTS = tCK_INT */ tb.TIM_RepetitionCounter = 0; TIM_TimeBaseInit(TIM1, &tb); TIM_OCStructInit(&oc); oc.TIM_OCMode = TIM_OCMode_PWM1; /* CNT < CCR 时输出有效 */ oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_OutputNState = TIM_OutputNState_Enable; /* 互补通道必须显式开 */ oc.TIM_Pulse = arr / 2; /* 上电先给 50% 占空 */ oc.TIM_OCPolarity = TIM_OCPolarity_High; oc.TIM_OCNPolarity = TIM_OCNPolarity_High; oc.TIM_OCIdleState = TIM_OCIdleState_Reset; /* 空闲时上管关 */ oc.TIM_OCNIdleState = TIM_OCNIdleState_Reset; /* 空闲时下管也关 */ TIM_OC1Init(TIM1, &oc); TIM_OC2Init(TIM1, &oc); TIM_OC3Init(TIM1, &oc); /* 预装载必须开,否则运行中改 CCR 会出毛刺 */ TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_BDTRStructInit(&bdtr); bdtr.TIM_DeadTime = dtg; /* DTG[7:0] 编码 */ bdtr.TIM_Break = TIM_Break_Disable; bdtr.TIM_BreakPolarity = TIM_BreakPolarity_High; bdtr.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, &bdtr); TIM_Cmd(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); /* 置 MOE,这一句漏了全白干 */ }

三个最容易漏的开关:TIM_OutputNState_Enable不开,互补脚根本没波形;TIM_ARRPreloadConfig不开,在线调频时改 ARR 会当场产生一个畸形周期;TIM_CtrlPWMOutputs就是置 BDTR 的 MOE 位,漏了这一句所有通道都停在高阻态,示波器上什么都看不到。另外TIM_OCIdleState和TIM_OCNIdleState都设成 Reset,是为了让定时器停走或刹车时上下管同时关断,而不是同时导通——这一条是后面避坑章节的事故源头之一。

3. 死区时间不玄学:从 BDTR 编码到示波器上的 1.2 微秒

3.1 死区到底在防什么

三相全桥的每一相都是上下两只管子串联在母线上。上管关断不是瞬间完成的,栅极电荷要泄放、米勒电容要充放,实测关断延时常常有几百纳秒。如果下管的开通信号在这个窗口里来了,上下管同时导通,母线正负极直接短路,电流上升速率只有回路寄生电感能限制,几微秒之内就能把管子送走。死区的本质就是在这段窗口里强行把两路都拉低,让先开的那只彻底关断之后才允许另一只开。

死区时间取多少,取决于功率器件和驱动电路:小功率 MOSFET 直驱一般 200~500ns,经过光耦或隔离驱动要加上传输延时差,IGBT 模块动辄 1~3μs。这里没有万能值,只有一条经验——先用器件手册里的关断延时最大值乘 1.5,再拿示波器实测上下管栅极波形确认没有重叠,才算数。

3.2 DTG[7:0] 编码:四段折线和一张对照表

TIM1 的死区寄存器是 BDTR 的低 8 位 DTG[7:0],它不是线性纳秒值,而是分四段编码,每段的步进不一样:

DTG[7:5]死区时间公式覆盖范围(tDTS 单位)步进
0xxDT = DTG[7:0] × tDTS0 ~ 1271 tDTS
10xDT = (64 + DTG[5:0]) × 2 tDTS128 ~ 2542 tDTS
110DT = (32 + DTG[4:0]) × 8 tDTS256 ~ 5048 tDTS
111DT = (32 + DTG[4:0]) × 16 tDTS512 ~ 100816 tDTS

tDTS 由 TIM_ClockDivision 决定,配置成TIM_CKD_DIV1时 tDTS = 1/72MHz ≈ 13.89ns。按这个基准算:想要 1μs 死区,1000/13.89 ≈ 72 个 tick,落在第一段,DTG 直接填 72,也就是 0x48。想要 2μs,需要 144 个 tick,超出第一段的 127 上限,得进第二段:144 = (64 + x) × 2,解出 x = 8,DTG = 0x80 | 8 = 0x88。很多教程直接用deadtime * 72 / 1000当 DTG 填进去,一旦超过 1.76μs 就静默出错,波形上完全看不出来,这是血泪经验。

3.3 写一个死区编码函数,别再手算

与其每次查表,不如把换算封成函数,参数是想要的纳秒数和定时器输入时钟:

/* * dead_ns : 期望死区时间,单位纳秒 * f_ck_int: 定时器输入时钟,单位 Hz,C8T6 满配时 72000000 * 返回可直接写入 BDTR.TIM_DeadTime 的 DTG[7:0] */ uint8_t deadtime_encode(uint32_t dead_ns, uint32_t f_ck_int) { /* 先换成 tDTS 个数,四舍五入 */ uint32_t n = (uint32_t)(((uint64_t)dead_ns * f_ck_int + 500000000ULL) / 1000000000ULL); if (n < 128) return (uint8_t)n; /* 0xx */ if (n < 256) return (uint8_t)(0x80 | ((n / 2) - 64)); /* 10x */ if (n < 512) return (uint8_t)(0xC0 | ((n / 8) - 32)); /* 110 */ if (n < 1024) return (uint8_t)(0xE0 | ((n / 16) - 32)); /* 111 */ return 0xFF; /* 超上限,取最大 */ }

函数里用 64 位中间量是为了避免dead_ns * 72000000在 32 位下溢出——3000ns 乘 7200 万已经接近 2.16×10¹⁴,远超 uint32 范围。四个分段的边界值 128/256/512/1024 就是上一张表的覆盖上限。调用的时候写deadtime_encode(1200, 72000000),返回 0x56(1200/13.89 ≈ 86,86 落在第一段),比手算靠谱得多。

3.4 死区不是越大越好:占空比丢失与波形畸变

死区确实能防直通,但它同时在吃掉输出电压。每个开关周期里上下管各有一次死区,负载看到的有效脉宽比 CCR 设定的少了一个死区时间,平均电压损失近似为ΔV = (Td / Ts) × Vdc。10kHz 载波对应 Ts = 100μs,死区 1μs 就是 1% 的电压损失,24V 母线下白白丢掉 0.24V。这还只是平均值,真正难看的是电流过零点附近:那时占空比本来就接近 50%,死区让正负半周的脉宽不对称,电流波形在零点出现一个平台,低速轻载时尤其明显,电机听起来会有低频的「咕噜」声。

工程上的处理办法有两个方向。一是把死区压到够用就行,别为了保险随手填 3μs;二是做死区补偿,按相电流极性给 CCR 加一个偏置,电流为正时多给一点、为负时少给一点,把丢掉的脉宽补回来。补偿的前提是能测到相电流极性,这份工程没有做闭环,所以策略是保守取值:MOSFET 方案统一用 800ns,留出驱动延时的余量,实测栅极波形不重叠即可。

还有一条边界必须记住:CCR 不能太靠近 0 或者 ARR。当 CCR 小到有效脉宽比死区还窄时,输出会被死区完全吃掉,那一相直接丢波。后面第 4 章会给出 CCR 的钳位范围。

4. 调频调压怎么解耦:相位累加器加调制比,改参数不炸桥

4.1 调频和调压必须是两个独立旋钮

新手最容易犯的错是拿 ARR 去调输出频率:想从 50Hz 降到 30Hz,就把 ARR 从 3599 改成 5999。这么干表面上频率对了,实际上载波频率跟着从 10kHz 掉到 6kHz,输出滤波器的截止频率、死区占载波周期的比例、电流纹波全都变了,前面按 10kHz 算好的死区补偿直接失效。正确的分工是:载波频率由 ARR 固定死,运行期间不动;输出基波频率由相位累加器的步进决定;输出幅值由调制比 m 决定。三个量各管各的,互不干扰,这就是「解耦」的含义。

4.2 相位累加器:32 位定点怎么算步进

把整个基波周期 2π 映射到 uint32 的 0 ~ 2³²,相位累加器每个载波周期加一个步进值,溢出就自然回绕。步进的算法是:

step = 2^32 × f_out / f_pwm

因为 2³² 远超 32 位乘法的安全范围,中间必须用 64 位。相位累加器的高 8 位正好对应 256 点表的索引,这一步省掉了取模运算:

#define SINE_POINTS 256 #define PHASE_120 0x55555555UL /* 2^32 / 3 */ #define PHASE_240 0xAAAAAAABUL /* 2 × 2^32 / 3 */ extern const int16_t sin_q10[SINE_POINTS]; static uint32_t s_phase = 0; /* 相位累加器 */ static uint32_t s_step = 0; /* 每个载波周期的相位增量 */ static uint16_t s_m_q10 = 900; /* 调制比,Q10,0.879 */ /* 目标输出频率 f_out,载波频率 f_pwm,都由外部传入 */ void spwm_set_freq(uint32_t f_out, uint32_t f_pwm) { s_step = (uint32_t)(((uint64_t)f_out << 32) / f_pwm); } /* 调制比 0 ~ 1024,对应 0 ~ 1.0 */ void spwm_set_mod(uint16_t m_q10) { if (m_q10 > 972) m_q10 = 972; /* 上限 0.95,留过调制余量 */ s_m_q10 = m_q10; }

PHASE_120和PHASE_240是两个常量偏移:V 相落后 U 相 120°,在 32 位圆周上就是 2³²/3 ≈ 0x55555555;W 相落后 240°,就是它的两倍。用常量加法而不是重新算 sin,是为了让三相永远严格保持 120° 关系,不会因为三次独立计算产生舍入偏差。

4.3 调制比更新与 CCR 的钳位保护

拿到三相的 sin 值之后,占空比按d = 0.5 + 0.5 × m × sin(θ)算,再乘 ARR 得到 CCR。这个式子要拆成整数运算,中间结果用 int32 兜住:

static inline uint16_t calc_ccr(uint16_t arr, int16_t sin_val, uint16_t m_q10) { int32_t half = arr / 2; /* half * m/1024 * sin/1024,两次右移避免中间溢出 */ int32_t delta = (half * (int32_t)m_q10 >> 10) * (int32_t)sin_val >> 10; int32_t ccr = half + delta; /* 关键钳位:给死区留出脉宽,防止窄脉冲被吃掉 */ if (ccr < 60) ccr = 60; if (ccr > arr - 60) ccr = arr - 60; return (uint16_t)ccr; }

钳位那两行是这份工程里最容易被删掉、又最容易出问题的部分。假设 ARR = 3599,死区编码对应 72 个 tick,那么有效脉宽低于 72 个计数的脉冲会被死区完整吞掉,该相输出直接消失。把下限设成 60 到 80 之间,就能保证即使在 m = 0.95、sin = -1 的极端点,输出仍然有一个可观测的窄脉冲。上限对称处理,防止上管常开。60 这个数不是拍脑袋,它约等于死区 tick 数加一点余量,改死区参数时这里要跟着改。

4.4 更新时机:只在更新中断里动 CCR

CCR 有预装载寄存器,写入的值要等下一个更新事件才搬到影子寄存器。所以正确的写法是:主循环里只改s_m_q10和s_step这类软件变量,真正的 CCR 计算和写入全部放在 TIM1 的更新中断里,一次算完三相:

void TIM1_UP_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM1, TIM_IT_Update); s_phase += s_step; /* 推进相位 */ uint32_t pu = s_phase; uint32_t pv = s_phase + PHASE_120; uint32_t pw = s_phase + PHASE_240; uint16_t arr = TIM1->ARR; TIM1->CCR1 = calc_ccr(arr, sin_q10[pu >> 24], s_m_q10); TIM1->CCR2 = calc_ccr(arr, sin_q10[pv >> 24], s_m_q10); TIM1->CCR3 = calc_ccr(arr, sin_q10[pw >> 24], s_m_q10); /* 供 KEIL5 仿真观察用,实际产品里可去掉 */ s_dbg_ccr[0] = TIM1->CCR1; s_dbg_ccr[1] = TIM1->CCR2; s_dbg_ccr[2] = TIM1->CCR3; } }

中断里的计算量要心里有数:三次查表、三次乘加、三次钳位,在 72MHz 下大概 2~3μs,10kHz 载波的中断周期是 100μs,占用率不到 5%,还有余量。但这段代码不能有浮点、不能调printf、不能有除法,否则中断时间会涨到十几微秒,波形立刻出现周期性抖动。

调压的响应速度由 m 的更新频率决定,人在拧电位器或者上位机发串口命令时,几十毫秒更新一次完全够;调频则不能太猛,频率阶跃时相位累加器的 step 会突变,但 s_phase 本身不清零,所以相位是连续的,不会出现波形跳变——这也是用累加器而不是查表索引自增的好处。

5. 避坑与排查:六路波形最容易翻车的五个地方

5.1 上电瞬间上下管同时导通

现象:示波器抓到上电头几毫秒 U 相上下管栅极同时为高,或者电机「咔」一声就保护了。

原因:初始化的顺序有问题。GPIO 先被配成复用推挽,此时 TIM1 还没配置完,输出状态不确定;再加上TIM_OCNIdleState如果误设成 Set,互补通道在定时器未使能时就输出高电平,等于下管常开。

解决:把 GPIO 配置放在定时器配置之前但保持 TIM1 关闭,等 BDTR 里 MOE 置位那一刻才真正放行;TIM_OCIdleState和TIM_OCNIdleState必须都设 Reset;硬件上给每路栅极加 10k 下拉电阻,软件再稳也不如电阻兜底。

5.2 调 m 的时候占空比纹丝不动

现象:串口发命令把 m 从 0.5 改到 0.9,示波器上占空比一点没变。

原因:多半是只改了软件变量,但 CCR 的预装载没开,或者改完 CCR 没有产生更新事件,新值卡在预装载寄存器里等不到搬运时机。另一种情况是 CCR 确实写了,但写在了主循环里,而中断里每个周期又把旧值算回去覆盖了。

解决:确认TIM_OCxPreloadConfig全部 Enable,确认TIM_ARRPreloadConfig也是 Enable;所有 CCR 写入集中到更新中断这一个入口,别在别处再写一遍。

5.3 死区配了,波形上看不到

现象:BDTR 明明写了 0x48,双通道示波器测出来上下管切换还是严丝合缝。

原因有三个层次。最常见的是探头接错了,测的是 CH1 和 CH2 而不是 CH1 和 CH1N;其次是TIM_OutputNState没使能,互补脚压根没输出,看到的其实是一路悬空;还有一种很隐蔽——两路探头共地,而上下管并不共地,测出来的波形本来就不可信,得用差分探头或者隔离探头。

解决:先单测 CH1N(PB13)确认有波形,再双通道对比;用TIM_CtrlPWMOutputs确认 MOE 已置位;死区测量一定要用差分探头,或者至少保证两路探头接同一个参考点。

5.4 调频时电流抖动、电机啸叫

现象:输出频率从 50Hz 切到 30Hz 的瞬间,电流波形出现一个明显的包络跳变,电机发出「咯」的一声。

原因:改 ARR 时没有开预装载,计数器正在往上数的时候 ARR 突然变小,这一周期直接被截断,产生一个宽度异常的脉冲;另一种是s_step用整数除法算的,低频时舍入误差占比大,相位长时间漂移,三相的相对角度慢慢就偏了。

解决:ARR 预装载必须开,改完 ARR 后手动TIM_GenerateEvent(TIM1, TIM_EventSource_Update)触发一次搬运;s_step的计算用 64 位中间量,别用f_out * 1000 / f_pwm这种丢精度的写法;频率切换时绝对不要清零s_phase,相位连续才不会有突变。

5.5 KEIL5 仿真里波形是直线

现象:进入 Debug,Logic Analyzer 里加了一堆信号,全是平的,时间轴也不动。

原因:仿真器的 Dialog DLL 参数没配对。C8T6 要选DARMSTM.DLL,Parameter 填-pSTM32F103C8,填错成别的型号,外设寄存器模型就加载不出来。另外如果代码里用了HAL_Delay或者死等 SysTick,仿真模式下时钟不一定推进,程序卡在延时里,看起来就是不动。还有优化等级开到 -O2 时,局部变量被优化掉,Logic Analyzer 加的信号根本不存在。

解决:Options for Target → Debug → Use Simulator → Settings,确认 DLL 和 Parameter;把要观察的量声明成volatile全局变量;仿真时把看门狗关掉,延时函数临时换成空循环。

6. 仿真验证:在 KEIL5 里把死区和相位角量出来

仿真的价值不在替代示波器,而在于上电之前就能确认相位关系和三相对称性。配置好DARMSTM.DLL和-pSTM32F103C8之后,把优化等级降到 -O0,在s_dbg_ccr[3]上打断点确认数据在动,然后在 View → Analysis Windows → Logic Analyzer 里把这三个数组加进去,就能看到三条相差 120°

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

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

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

立即咨询