☰
STM32F103C8T6三相SPWM互补波形生成与死区调频调压实战
2026/10/26 23:28:20 网站建设 项目流程

简介:面向嵌入式初学者与电机控制入门者的STM32F103C8T6实战工程,聚焦高级定时器TIM1查表法生成三相六路互补SPWM信号,相位互差120°,支持死区插入与调频调压,并以恒压频比方式驱动三相电机。压缩包共149个文件、约46.67MB,以c与h源码、Keil工程文件(uvprojx/uvoptx)、hex与axf固件、map与lst编译产物为主,另含SPWM原理PDF文档、正弦表生成工具、死区计算器及批处理脚本,便于对照理解与二次开发。已有8068人学习下载。工程在KEIL5下带详细注解,可在线仿真观察六路波形与死区效果,配合原理文档和工具能帮助读者从查表、相位配置到死区计算逐层吃透SPWM实现,适合课程设计、电机驱动入门与波形调试参考。

1. 三相逆变控制的最小可用样板:从一块 C8T6 说起

手上只有一块 STM32F103C8T6 最小系统板,却要验证三相逆变桥的驱动时序,这事我干过不止一次。手边没有现成的驱动板,也没有高配的功率级,能做的就是把定时器、互补输出、死区、调频调压这几件事在芯片内部先跑通,用示波器看六路波形对不对,再谈接功率管。这个项目就是干这个的:基于 STM32F103C8T6 的高级定时器 TIM1,输出三相六路 SPWM 互补波形,带可调死区,支持调频调压,KEIL5 工程可直接编译并配合仿真器观察寄存器与波形。它适合正在做电机驱动、逆变电源、变频器原型的开发者,尤其是想先把 PWM 时序搞明白再上功率级的人。C8T6 只有 64KB Flash、20KB RAM,资源紧,但跑一个三相 SPWM 发生器绰绰有余,关键在于把定时器用对。

2. 硬件与工程结构:C8T6 上三相 PWM 的资源账

2.1 为什么是 TIM1 而不是普通定时器

STM32F103C8T6 有三个通用定时器 TIM2/3/4 和一个高级定时器 TIM1。三相六路互补输出必须用 TIM1,原因很直接:只有高级定时器带互补通道和死区插入单元(BDTR)。通用定时器每个通道只有一个输出引脚,做不出「上桥开、下桥关」这种成对的互补信号,更没有硬件死区。用软件去翻转两个 GPIO 模拟互补,死区靠延时函数凑,频率一高就抖得没法看,这是血泪经验。

TIM1 的通道结构是 CH1/CH1N、CH2/CH2N、CH3/CH3N,正好三对,对应三相桥的三个桥臂。每对通道由同一个比较值控制,互补关系由硬件保证,死区时间写在 BDTR 寄存器的 DTG 位段里。这样 CPU 只需要算好三个正弦占空比写进 CCR1/CCR2/CCR3,剩下的互补、死区、极性全交给硬件。

引脚分配上,C8T6 的 TIM1 通道分布在 PA8/PA9/PA10(CH1/CH2/CH3)和 PB13/PB14/PB15(CH1N/CH2N/CH3N)。这六根线就是三相六路输出的物理出口,接驱动芯片(比如半桥栅极驱动器)之前,务必先用示波器确认波形。

2.2 工程目录与文件职责

一个能跑的 KEIL5 工程,目录大致是这样组织的:

SPWM_3Phase/ ├── CORE/ │ ├── core_cm3.c │ ├── startup_stm32f10x_md.s │ └── system_stm32f10x.c ├── HARDWARE/ │ ├── timer.c / timer.h // TIM1 PWM 初始化、死区配置 │ ├── spwm.c / spwm.h // 正弦表、调频调压接口 │ └── led.c / led.h // 运行指示(可选) ├── STM32F10X_FWLib/ │ ├── src/stm32f10x_tim.c │ ├── src/stm32f10x_gpio.c │ ├── src/stm32f10x_rcc.c │ └── ... ├── USER/ │ ├── main.c │ ├── stm32f10x_it.c │ └── stm32f10x_conf.h └── OBJ/ // 编译输出

核心逻辑集中在timer.c和spwm.c两个文件。timer.c负责把 TIM1 配成 PWM 模式、使能互补输出、写死区;spwm.c维护一张正弦表,并提供「设定频率」「设定调制比」两个接口,内部换算成 ARR 和 CCR 的值。main.c只做初始化和一个简单的循环,真正的波形由定时器硬件和更新中断驱动。

提示:标准外设库版本建议用 3.5.0,启动文件选startup_stm32f10x_md.s(中容量),选错启动文件会导致中断向量表错位,程序跑飞。

2.3 时钟与定时器参数的关系

TIM1 挂在 APB2 总线上。C8T6 默认配置下,系统时钟 72MHz,APB2 不分频,TIM1 时钟就是 72MHz。PWM 频率、计数精度、正弦表点数三者互相牵制,先把关系理清再写代码:

  • 定时器计数频率 = 72MHz / (PSC + 1)
  • PWM 频率 = 计数频率 / (ARR + 1)
  • 每个正弦周期内的 PWM 周期数 = PWM 频率 / 输出正弦频率

假设要输出 50Hz 正弦、PWM 载波 10kHz,那么一个正弦周期有 200 个 PWM 周期,正弦表至少取 200 点才能保证波形平滑。若 PSC 设为 0,ARR = 7200 - 1 时 PWM 频率正好 10kHz。这个组合在 C8T6 上很常见,计算量小、波形也够看。

3. 核心代码拆解:六路互补、死区与调频调压怎么落地

3.1 TIM1 初始化:PWM 模式与互补通道使能

先看timer.c里的初始化函数,这是整个项目的地基:

#include "timer.h" // TIM1 三相六路互补 PWM 初始化 // pwm_freq: 载波频率(Hz),dead_time: 死区时间(ns) void TIM1_PWM_Init(u16 arr, u16 psc, u16 dead_time) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_BDTRInitTypeDef TIM_BDTRInitStructure; // 1. 时钟使能:TIM1 在 APB2,GPIOA/GPIOB 也在 APB2 RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // 2. 六路引脚配置为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_Init(GPIOB, &GPIO_InitStructure); // 3. 时基单元:ARR 决定 PWM 周期,PSC 决定计数频率 TIM_TimeBaseStructure.TIM_Period = arr; TIM_TimeBaseStructure.TIM_Prescaler = psc; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); // 4. 三个通道都配成 PWM1 模式,互补输出由 CHxN 硬件产生 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_OutputNState = TIM_OutputNState_Enable; // 关键:使能互补 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OCNPolarity = TIM_OCNPolarity_High; TIM_OCInitStructure.TIM_Pulse = 0; // 初始占空比 0 TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC2Init(TIM1, &TIM_OCInitStructure); TIM_OC3Init(TIM1, &TIM_OCInitStructure); // 5. 死区配置:DTG 位段,单位取决于时钟分频 TIM_BDTRInitStructure.TIM_OSSRState = TIM_OSSRState_Enable; TIM_BDTRInitStructure.TIM_OSSIState = TIM_OSSIState_Enable; TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_1; TIM_BDTRInitStructure.TIM_DeadTime = dead_time; // 死区计数值 TIM_BDTRInitStructure.TIM_Break = TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, &TIM_BDTRInitStructure); // 6. 使能预装载,避免更新时占空比撕裂 TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); // 7. 主输出使能,缺这一步六路全无波形 TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); }

逐段说清楚。第 2 步把六个引脚配成复用推挽,注意 CH1N/CH2N/CH3N 在 GPIOB 上,别只配了 GPIOA 就以为完事,这是新手最常翻的车。第 4 步里TIM_OutputNState_Enable是互补输出的开关,漏掉它 CHxN 永远是低电平。第 5 步的TIM_DeadTime不是纳秒,而是写进 DTG 位段的编码值,实际死区时间要按参考手册的公式换算,下面单独讲。第 7 步TIM_CtrlPWMOutputs是高级定时器特有的主输出使能,通用定时器没有这一步,很多人从 TIM3 迁过来会忘。

3.2 死区时间:DTG 编码与实际纳秒的换算

死区是防止同一桥臂上下管直通的关键。TIM1 的 DTG[7:0] 位段不是线性纳秒值,而是分段编码。以 72MHz、TIM_CKD_DIV1为例,常见区间的换算是:

DTG[7:5]死区计算方式范围(72MHz)
0xxDTG[7:0] × Tdts0 ~ 127 × 13.9ns
10x(64 + DTG[5:0]) × 2 × Tdts约 1.78us ~ 3.56us
110(32 + DTG[4:0]) × 8 × Tdts约 3.56us ~ 7.11us
111(32 + DTG[4:0]) × 16 × Tdts约 7.11us ~ 14.2us

Tdts = 1/72MHz ≈ 13.9ns。实际工程里死区一般取 0.5us ~ 2us,落在第一、第二区间。比如要 1us 死区,第一区间最大只有 127×13.9ns≈1.76us,可以直接用 DTG = 72(72×13.9≈1.0us)。写代码时我一般封装一个换算函数,把纳秒转成 DTG 值,避免手算出错:

// 将期望死区时间(ns)换算为 DTG 编码值,Tdts=13.9ns u8 DeadTime_Calc(u16 ns) { u16 tdts = 14; // 约 13.9ns,取整 if (ns / tdts < 128) { return (u8)(ns / tdts); // 第一区间 } else if (ns / (2 * tdts) < 64) { return (u8)(0x80 | (ns / (2 * tdts))); // 第二区间 } return 0xFF; // 超出范围,按最大死区处理 }

参数说明:输入是期望死区纳秒数,输出直接喂给TIM_BDTRInitStructure.TIM_DeadTime。注意这个函数只覆盖前两个区间,工程上够用;如果功率级需要更大死区,得补全后两段。死区设太小,上下管有直通风险;设太大,输出波形畸变、有效电压下降,调压时能明显看出来。

3.3 正弦表与调频调压接口

SPWM 的本质是用一张正弦表去调制占空比。spwm.c里维护一张 200 点的正弦表,值域映射到 0~ARR:

#include "spwm.h" #include <math.h> #define SIN_POINTS 200 static u16 sin_table[SIN_POINTS]; static u16 g_arr = 7200 - 1; // 与 TIM1 初始化一致 static u16 g_mod_index = 800; // 调制比,0~1000 对应 0~1.0 // 生成正弦表,幅值按当前调制比缩放 void SPWM_BuildTable(void) { for (u16 i = 0; i < SIN_POINTS; i++) { float s = sinf(2.0f * 3.1415926f * i / SIN_POINTS); // 映射到 0~ARR,中心点 ARR/2 sin_table[i] = (u16)((s + 1.0f) * 0.5f * g_arr * g_mod_index / 1000); } } // 调压:修改调制比后重建表 void SPWM_SetVoltage(u16 mod_index) { if (mod_index > 1000) mod_index = 1000; g_mod_index = mod_index; SPWM_BuildTable(); } // 调频:修改 ARR 与 PSC,同时重建表保持波形比例 void SPWM_SetFreq(u32 pwm_freq) { u32 tim_clk = 72000000; u16 psc = 0; u32 arr = tim_clk / pwm_freq - 1; while (arr > 65535) { psc++; arr = tim_clk / (psc + 1) / pwm_freq - 1; } g_arr = arr; TIM1->ARR = arr; TIM1->PSC = psc; SPWM_BuildTable(); }

逻辑说明:SPWM_BuildTable把正弦值从 [-1,1] 平移到 [0,1],再乘以 ARR 和调制比,得到每个点的比较值。调制比 1000 对应满调制,输出幅值最大;调小调制比就是调压。SPWM_SetFreq通过改 ARR/PSC 改变载波频率,同时重建表,保证占空比映射仍然正确。这里有个细节:改 ARR 后如果不重建表,表里的值还是按旧 ARR 算的,占空比会整体偏移,波形幅值就不对了。

三相之间的相位差 120 度,靠查表时错开索引实现。200 点表,120 度对应 200/3≈66.7 点,实际取 66 或 67 点会有微小相位误差,对原型验证够用;要精确就取 240 点表(能被 3 整除)。

3.4 更新中断里刷 CCR:三相相位怎么错开

波形刷新放在 TIM1 更新中断里,每个 PWM 周期推进一次表索引:

// stm32f10x_it.c 中的 TIM1 更新中断 volatile u16 phase_idx = 0; #define PHASE_SHIFT 67 // 120 度对应约 67 点(200 点表) void TIM1_UP_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET) { u16 i1 = phase_idx; u16 i2 = (phase_idx + PHASE_SHIFT) % SIN_POINTS; u16 i3 = (phase_idx + 2 * PHASE_SHIFT) % SIN_POINTS; TIM1->CCR1 = sin_table[i1]; // A 相 TIM1->CCR2 = sin_table[i2]; // B 相,滞后 120 度 TIM1->CCR3 = sin_table[i3]; // C 相,滞后 240 度 phase_idx = (phase_idx + 1) % SIN_POINTS; TIM_ClearITPendingBit(TIM1, TIM_IT_Update); } }

参数说明:phase_idx是当前正弦表索引,每个 PWM 周期加一,走完 200 点就是一个完整正弦周期。PHASE_SHIFT决定三相相位差,200 点表取 67 接近 120 度。三个 CCR 直接写寄存器,比调库函数快,中断里省时间。注意中断频率等于 PWM 频率,10kHz 载波就是每秒一万次中断,中断服务函数要尽量短,别在里面做浮点运算或查表以外的操作。

注意:CCR 写入要在更新事件之后、下一个周期开始前完成,预装载功能保证新值在下一个更新事件才生效,避免当前周期占空比被撕裂。

4. 编译、仿真与上电验证:从 KEIL5 到示波器

4.1 KEIL5 工程配置要点

打开工程后先检查几个地方,配错了编译能过但波形出不来:

  • Target 里晶振频率填 8MHz(外部晶振),系统时钟由system_stm32f10x.c倍频到 72MHz。
  • C/C++ 选项卡的 Define 里要有USE_STDPERIPH_DRIVER, STM32F10X_MD,中容量宏不能少。
  • Debug 选项卡选好仿真器(ST-Link 或 J-Link),勾选 Reset and Run,下载后直接跑。
  • 如果要用软件仿真看波形,Debug 里选 Use Simulator,在 Logic Analyzer 里添加TIM1->CCR1等变量,能看到占空比变化,但看不到真实引脚电平。

编译前确认stm32f10x_conf.h里#include了stm32f10x_tim.h、stm32f10x_gpio.h、stm32f10x_rcc.h,否则一堆未定义。工程里如果报TIM_BDTRConfig未定义,多半是没把stm32f10x_tim.c加进工程组。

4.2 上电前的自检清单

接功率级之前,先空载测波形,按这个顺序走:

  1. 只给 MCU 供电,六路引脚不接任何负载,示波器探头接地夹接板子 GND。
  2. 测 PA8/PA9/PA10,应看到三路相位互差 120 度的 SPWM,占空比在 0~100% 之间按正弦规律变化。
  3. 测 PB13/PB14/PB15,应与对应相严格互补,且互补跳变沿之间有死区间隙。
  4. 调SPWM_SetVoltage,观察占空比幅值变化;调SPWM_SetFreq,观察载波频率变化。
  5. 用双通道同时看 CH1 和 CH1N,放大跳变沿,确认死区时间与设定值一致。

这一步能挡掉大部分问题。我见过有人直接把六路接到栅极驱动,结果死区没配对,上下管同时导通,驱动芯片当场冒烟。空载验证花十分钟,能省一块板子。

4.3 调频调压的实测现象

调压时,调制比从 1000 降到 500,示波器上 SPWM 的包络幅值减半,但载波频率不变,这是对的。调频时,改 ARR 会让载波周期变化,如果正弦表点数不变,一个正弦周期内的 PWM 周期数就变了,输出正弦的频率也跟着变——这正是调频的目的。但要注意,调频后如果正弦表点数相对载波太少,波形会明显阶梯化,THD 变差。经验是每个正弦周期至少保留 100 个以上 PWM 周期,50Hz 输出时载波别低于 5kHz。

5. 避坑与排查:六路波形不对时先看这几处

5.1 六路里有一路或几路没输出

现象:示波器上 PA8 有波形,PB13 一直是低电平。原因通常是 GPIOB 时钟没使能,或者TIM_OutputNState_Enable漏配。解决:检查RCC_APB2PeriphClockCmd里有没有RCC_APB2Periph_GPIOB,再确认三个通道的TIM_OCxInit都带了 NState 使能。还有一种可能是引脚被复用成了 JTAG,PB13/PB14/PB15 默认不是 JTAG 引脚,但如果开了 SWD 之外的调试口要留意。

5.2 互补波形有重叠,死区没生效

现象:CH1 和 CH1N 的跳变沿几乎重合,看不到间隙。原因多半是TIM_BDTRConfig没调用,或者TIM_DeadTime传了 0。解决:确认 BDTR 配置在TIM_Cmd之前执行,死区值用换算函数算出来别手填。另外TIM_CtrlPWMOutputs必须在 BDTR 配置之后调用,顺序反了主输出使能可能不生效。

5.3 波形频率对不上,或者占空比整体偏移

现象:设 10kHz 载波,实测只有 5kHz;或者调压后波形中心点偏了。原因:前者是 PSC/ARR 算错,忘了-1,或者系统时钟没跑到 72MHz(检查SystemInit和晶振)。后者是改 ARR 后没重建正弦表,表值还按旧 ARR 映射。解决:用SPWM_SetFreq统一改 ARR/PSC 并重建表,别单独改寄存器。

5.4 中断里改 CCR 后波形抖动

现象:波形偶尔出现一个周期占空比突变。原因:CCR 预装载没使能,或者中断里同时改了 ARR 导致更新事件错位。解决:三个通道都调TIM_OCxPreloadConfig(ENABLE),调频操作放在主循环里做,别在更新中断里改 ARR。

5.5 下载后不运行,或一上电就进 HardFault

现象:KEIL5 提示下载成功,但引脚无输出。原因:启动文件选错(中容量用了大容量启动文件),或者中断向量表里TIM1_UP_IRQHandler名字拼错导致默认死循环。解决:确认启动文件是startup_stm32f10x_md.s,中断函数名严格对照启动文件里的向量表拼写,TIM1_UP_IRQHandler不能写成TIM1_UP_TIM10_IRQHandler(后者是 F4 系列的写法)。

6. 二次开发:把样板改成你自己的逆变控制

这套代码是个干净的起点,真正做产品要往上加东西。我一般按这个顺序改:先把正弦表从固定 200 点换成可配置点数,用 240 点让三相相位整除;再把调压从「重建整张表」改成「表值乘系数」,避免每次调压都跑一遍 sinf,C8T6 上浮点运算不便宜。下面这个改法把调制比从建表里剥离出来:

// 表里存归一化值 0~ARR,调压时只乘系数,不重建表 void SPWM_ApplyVoltage(u16 mod_index) { u16 scale = mod_index; // 0~1000 for (u16 i = 0; i < SIN_POINTS; i++) { // 用移位代替除法,1000 约等于 1024 sin_table[i] = (u16)(((u32)sin_table[i] * scale) >> 10); } }

这个写法牺牲一点精度换速度,调压响应快很多。再往下就是加闭环:采样输出电压或电流,用 PID 算出调制比,喂给SPWM_SetVoltage,就从一个开环波形发生器变成稳压/稳流控制。采样用 ADC + DMA,别在中断里阻塞读。保护方面,把 TIM1 的 Break 功能用起来,过流信号接到 BKIN 引脚,硬件直接封波,比软件判断快一个数量级。

验证方法上,我习惯每改一处就用 Logic Analyzer 或示波器对比改前改后的六路波形,尤其是死区和相位,这两处最容易在重构时被悄悄改坏。还有个小技巧:把三相 CCR 的值通过串口按周期打印出来,画成曲线,比盯着示波器更容易发现相位错位。

从那以后我每次动定时器配置,都强制先用空载六路波形过一遍再接功率级,这个习惯救过不止一块板子。希望帮到你。

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

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

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

立即咨询