STM32F103C8T6实现三相SPWM:TIM1互补PWM与死区配置详解
简介针对STM32F103C8T6的SPWM三相六路互补输出工程基于高级定时器TIM1查表法实现适合电机驱动、电力电子及嵌入式控制学习者参考可作为三相电机调速控制开发的参考模板。资源包体积约46.67MB共149个文件核心包括C源码与头文件、KEIL5工程配置、可烧录调试的hex/axf以及PDF原理文档、正弦表生成工具和死区计算器等辅助内容。工程输出相位互差120°的三相六路带死区SPWM信号采用恒压频比方式调频调压可直接驱动三相电机程序注释详细支持KEIL5在线仿真直接观察输出波形。另配套SPWM生成原理学习资料和实用工具能帮助理解查表法、死区设置以及恒压频比调频调压的实现细节。目前已有8067人学习下载适合需要完整参考工程或入门三相SPWM驱动开发的读者。1. 一颗 F103C8T6 做三相 SPWM别等专用驱动板到手才动手很多开发者第一次接触三相逆变或电机驱动第一反应是“得上 DSP 或者专用电机控制芯片”。实际上一块普通的 STM32F103C8T6 系统板靠片上一颗高级定时器 TIM1就能生成三相逆变器需要的六路互补 PWM死区由硬件插入调频调压靠一张正弦表和相位累加器完成不需要外挂 PWM 芯片。这套工程就是围绕这个思路组织的 KEIL5 工程编译之后可以直接在模拟器里跑用逻辑分析仪观察六路波形和死区宽度确认波形形态后再上真板。想做三相 SPWM、想搞清楚高级定时器互补输出怎么配的开发者这份工程能省下不少找资料的时间。2. 方案与架构一颗 TIM1 如何撑起三相六路互补输出先把这个工程拆成三个层次看功率层、脉冲层、调制层。功率层是三相全桥的六个开关管脉冲层由 TIM1 负责产生六路带死区的互补 PWM调制层在 CPU 里由相位累加器和正弦表决定每个开关周期的占空比。很多教程一上来就把代码塞给你结果看到“TIM_CtrlPWMOutputs”“TIM_BDTRInit”这些函数完全不知道在干嘛。实际上只要把架构理顺后面每个寄存器都是可以倒推出来的。2.1 三相全桥和互补 PWM六路信号与六个开关管的对应关系三相逆变器的功率部分本质上是三个半桥并联每个半桥有上管和下管。要让电机或负载得到正弦变化的电压控制逻辑就是让上下管以互补方式导通上管开的时间比例按正弦变化下管在剩下时间里开。三路正弦彼此相差 120°于是就有了 U、V、W 三相。在 STM32F103C8T6 上TIM1 恰好提供三对互补通道。普通定时器也有 PWM 输出但只有 TIM1 这类高级定时器能把“互补”和“死区”用硬件做出来。工程里常用的引脚映射关系如下输出信号定时器通道引脚对应半桥U 相上管TIM1_CH1PA8U 半桥上臂U 相下管TIM1_CH1NPB13U 半桥下臂V 相上管TIM1_CH2PA9V 半桥上臂V 相下管TIM1_CH2NPB14V 半桥下臂W 相上管TIM1_CH3PA10W 半桥上臂W 相下管TIM1_CH3NPB15W 半桥下臂注意 TIM1 的 CH4 没有互补输出所以三相 PWM 必须用 CH1、CH2、CH3 这前三路。工程里的初始化代码也只开了这三个通道而不是把 CH4 也配成普通 PWM。2.2 死区为什么要由硬件做软件插死区在逆变器场景下不现实上下管互补意味着翻转瞬间存在一个危险窗口如果上管还没完全关断下管已经导通直流母线就相当于被短路。这个现象做硬件的人叫“直通”轻则波形畸变重则炸管子。软件能不能在翻转沿之间手动插入一段死区可以但工程上没人这么做。原因有两个一是死区宽度通常是微秒级而中断响应存在延迟和抖动软件翻转很难稳定到几百纳秒二是每次都要关中断、算延时、再翻转会把简单逻辑拖得很复杂。TIM1 的做法是让 OCx 和 OCxN 的输出本身错开一段时间这段错开的时间由死区寄存器配置硬件自动完成CPU 不需要参与每次翻转。这个设计还带来一个额外好处如果需要紧急刹车可以直接把刹车输入接到 TIM1 的刹车引脚硬件会在一个计数周期内封锁全部输出。工程里把刹车功能关掉是为了先让波形跑起来后面做电动工具这类有急停需求的项目时再把它打开即可。2.3 调频调压的结构选择固定载波加 DDS 相位累加器SPWM 的本质很简单用高频三角波或锯齿波作为载波用一个频率较低的正弦波作为调制波两者比较得到占空比随时间变化的 PWM。载波频率一般固定而正弦波的频率和幅度需要可调。工程里我选的是“固定载波 相位累加器”这种方式。载波固定为 10kHz正弦表用 1024 个点表示一个完整的 360°。要让输出是 50Hz那么每 1ms 的载波周期里正弦表指针应该走 1024 × 50 / 10000 5.12 个点。问题来了指针不能走小数步于是工程里用了一个 32 位相位累加器来累积相位增量每次中断只取相位的高 10 位作为表格索引。这样输出频率就和 DDS 一样连续可调不会有“只能出 19.5Hz、39Hz、58Hz 这种跳变”的问题。调压的方式则是乘一个调制比。正弦表保存的是 0 到 32767 之间的无符号值实际计算 CCR 时先根据目标电压算出幅度 amp再把表值换算到 [0, amp] 区间。若做电机驱动还要让调制比跟随频率变化这就是常说的恒压频比低频时再叠一点电压补偿。这套逻辑都集中在 SPWM 核心文件里和硬件寄存器解耦。3. 核心代码拆解从定时器寄存器到六路波形输出的完整链路这一章是整个工程最容易照抄的部分。我会按“先看工程目录再配定时器再算死区最后写调制算法”的顺序把代码过一遍。代码可能和你手头工程略有出入但原理一致。3.1 工程目录速览KEIL5 工程文件职责这份工程不是把所有代码都塞进 main.c而是按标准外设库的习惯分了几层。目录大致长这样spwm_f103 ├── SPL │ ├── inc # 标准外设库头文件 │ └── src # 标准外设库源码 ├── USER │ ├── main.c # 主流程、按键调频调压 │ ├── stm32f10x_it.c # TIM1 更新中断入口 │ └── system_stm32f10x.c # 系统时钟初始化 ├── BSP │ ├── bsp_tim.c # TIM1 GPIO、PWM、死区初始化 │ ├── bsp_tim.h │ ├── spwm_core.c # 正弦表、相位累加器、CCR 计算 │ └── spwm_core.h └── Project ├── spwm.uvprojx # KEIL5 工程文件 └── spwm.uvopt # 调试和仿真配置main.c 只负责调用初始化函数和响应按键真正干活的逻辑在三个文件里bsp_tim.c 管硬件spwm_core.c 管波形算法stm32f10x_it.c 做中断回调。这样分的好处是以后要移植到 HAL 库或换到 F4只需要改 bsp_tim.c正弦表和相位累加器可以原样搬走。3.2 TIM1 初始化三对互补通道和主输出使能的坑先用标准外设库写一遍 TIM1 初始化。代码里每一步都和寄存器一一对应注释标清楚void SPWM_TIM1_Init(uint16_t arr, uint16_t psc) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tbase; TIM_OCInitTypeDef oc; TIM_BDTRInitTypeDef bdtr; /* 开启 TIM1、GPIOA、GPIOB、AFIO 时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); /* CH1/CH2/CH3 主输出引脚PA8/PA9/PA10 */ GPIO_StructInit(gpio); 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); /* CH1N/CH2N/CH3N 互补输出引脚PB13/PB14/PB15 */ gpio.GPIO_Pin GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_Init(GPIOB, gpio); /* 时基配置72MHz / (psc1)向上计数周期 arr */ tbase.TIM_Prescaler psc; tbase.TIM_CounterMode TIM_CounterMode_Up; tbase.TIM_Period arr; tbase.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, tbase); /* PWM1 模式打开主输出和互补输出 */ oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_OutputNState TIM_OutputNState_Enable; oc.TIM_Pulse 0; 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); /* 死区寄存器后面单独讲 */ bdtr.TIM_OSSRState TIM_OSSRState_Enable; bdtr.TIM_OSSIState TIM_OSSIState_Disable; bdtr.TIM_LOCKLevel TIM_LOCKLevel_OFF; bdtr.TIM_DeadTime 0x96; /* 约 2.39us */ bdtr.TIM_Break TIM_Break_Disable; bdtr.TIM_BreakPolarity TIM_BreakPolarity_High; bdtr.TIM_AutomaticOutput TIM_AutomaticOutput_Enable; TIM_BDTRInit(TIM1, bdtr); /* 预装载让 CCR/ARR 修改在更新事件统一生效避免中途跳变 */ TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); /* 主输出使能也就是 MOE 位 */ TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); }这段代码里最容易被忽略的是TIM_CtrlPWMOutputs(TIM1, ENABLE)。高级定时器里有一个主输出使能位官方手册里叫 MOE它不使能的话哪怕 CH1 已经工作引脚上也不会有输出波形。很多人第一次调这种工程看到寄存器都对了但示波器没信号翻车基本都是栽在这一句上。参数说明arr 7199, psc 0时定时器时钟 72MHz周期 7200载波频率正好是 72MHz / 7200 10kHz。如果想让载波变成 20kHz可以把 arr 改成 3599。3.3 死区时间计算BDTR 寄存器里填的不是纳秒数TIM1 的死区由 BDTR 寄存器的低 8 位 DTG 控制。新手最容易踩坑的地方就是直接把这 8 位当成死区计数以为填多少就是多少微秒结果实际差了一大截。这 8 位的含义是分组的DTG 的高三位决定步长低五位决定计数DTG[7:5]死区计算公式最细步长0xxDT DTG[7:0] × Tdts1 × Tdts10xDT (64 DTG[4:0]) × 2 × Tdts2 × Tdts110DT (32 DTG[4:0]) × 8 × Tdts8 × Tdts111DT (32 DTG[4:0]) × 16 × Tdts16 × TdtsTdts 等于定时器计数时钟的周期。工程里TIM_ClockDivision TIM_CKD_DIV1所以 Tdts 1 / 72MHz ≈ 13.9ns。代码里填的0x96二进制是 1001 0110。高三位是 100属于第二组公式低五位是 0x16 22。那么死区时间就是 (64 22) × 2 × 13.9ns ≈ 2.39us。这个值对常用 MOS 管驱动来说比较合适实际使用时要根据你的功率管栅极电阻和驱动芯片调整一般 IGBT 会用到 2us 以上低压 MOS 可以收到 1us。验算方式很简单先把 DTG 拆成高三位和低五位确定用哪一档公式再代入 Tdts 计算。我一般会在代码里把死区值做成宏比如#define PWM_DEADTIME_NS 2390然后在调试器里直接看最终写入 BDTR 的寄存器值省得每次手算。3.4 SPWM 核心正弦表、相位累加器和 CCR 更新波形生成放在 spwm_core.c 里。首先准备一张 1024 点的正弦表每个点保存 0 到 32767 的幅值这样后面可以直接用移位代替除法#include math.h #define TABLE_SIZE 1024 #define CARRIER_HZ 10000 uint16_t sin_table[TABLE_SIZE]; void SinTableInit(void) { for (uint16_t i 0; i TABLE_SIZE; i) { float s sinf(2.0f * 3.1415926535f * i / TABLE_SIZE); sin_table[i] (uint16_t)((s 1.0f) * 32767.0f / 2.0f); } }这里把负半周也平移成正的因为定时器比较寄存器只能给正数。相位处于 180° 时表值接近 0占空比最小相位 0° 时表值约 16383一半占空比90° 时表值接近 32767占空比最大。接下来是调频调压的核心32 位相位累加器static uint32_t phase_acc; volatile uint32_t g_phase_inc; volatile uint16_t g_volt_m 1000; /* 调制比1000 表示 100% */ void SetSpwmFrequency(uint32_t fs_hz) { /* fs_hz 是正弦波目标频率例如 50Hz */ g_phase_inc (uint32_t)(((uint64_t)fs_hz 32) / CARRIER_HZ); } void SetSpwmVoltage(uint16_t volt_per_thousand) { g_volt_m volt_per_thousand; /* 0~1000 */ }频率设定采用 DDS 思路累加器是 32 位累加满 2^32 次计数相当于跑完一个正弦周期。每个载波周期做一次累加所以写入 50Hz 时相位增量是 50 × 2^32 / 10000 ≈ 21474836这个数在 32 位整数里正好对应 49.9999Hz 左右精度足够。中断里做查表和 CCR 更新void TIM1_UP_IRQHandler(void) { uint32_t idx, arr, amp; uint16_t ccr_u, ccr_v, ccr_w; if (TIM_GetITStatus(TIM1, TIM_IT_Update) RESET) { return; } TIM_ClearITPendingBit(TIM1, TIM_IT_Update); phase_acc g_phase_inc; idx phase_acc (32 - 10); /* 取高 10 位正好映射 1024 点 */ arr TIM1-ARR; /* 当前载波周期值10kHz 时是 7199 */ amp arr * g_volt_m / 1000U; /* 调压后的最大比较值 */ /* U 相直接用当前相位V/W 相分别移 120° 和 240° */ ccr_u (uint16_t)(((uint32_t)sin_table[idx] * amp) 15); ccr_v (uint16_t)(((uint32_t)sin_table[(idx 341) 1023] * amp) 15); ccr_w (uint16_t)(((uint32_t)sin_table[(idx 682) 1023] * amp) 15); TIM_SetCompare1(TIM1, ccr_u); TIM_SetCompare2(TIM1, ccr_v); TIM_SetCompare3(TIM1, ccr_w); }注意三点第一1024 / 3 不是整数所以 V 相偏移取 341W 相偏移取 682误差只有 0.12° 左右对 SPWM 来说完全可接受第二正弦表最高值是 32767右移 15 位相当于除以 32768所以当表值最大时ccr 就是 amp第三TIM1-ARR直接读寄存器比读缓存变量更保险因为预装载会让 ARR 在边界处更新两者一旦不同步就可能产生一个周期的异常占空比。3.5 载波、调制比和主循环按键调频调压怎么接进来主循环里只需要处理按键和参数更新int main(void) { /* 系统时钟、GPIO、串口等基础初始化省略 */ SinTableInit(); SPWM_TIM1_Init(7199, 0); /* 10kHz 载波 */ SetSpwmFrequency(50); SetSpwmVoltage(800); /* 开 TIM1 更新中断 */ TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE); NVIC_EnableIRQ(TIM1_UP_IRQn); while (1) { /* 扫描按键改频率时先调 SetSpwmFrequency再调 SetSpwmVoltage */ } }调压和调频是两套独立参数互不干扰。做电机驱动时通常把恒压频比写成一个函数比如set_vf(fs_hz)内部先算目标电压再同时调 g_phase_inc 和 g_volt_m。低频启动时还需要加一个 boost 电压这个后面在二次开发里说。4. 在 KEIL5 里跑工程并观察六路波形这一章讲怎么把工程在模拟器里跑起来。很多人拿到一个能编译的工程却卡在“点开 Debug 后没有任何波形”这一步这里把配置细节都过一遍。4.1 导入工程和芯片型号配置在 KEIL5 里打开工程的 uvprojx 文件第一次编译前要做三件事。第一确认工程设置里的芯片型号是 STM32F103C8如果 pack 没装在 Pack Installer 里补齐对应的器件包第二检查 C/C 选项里的 Include Path 是否包含了 SPL 的 inc 目录和 USER 目录否则会报找不到 stm32f10x.h第三在 Target 标签页里把 Xtal 设置成板子实际晶振频率比如 8MHz这样 SystemInit 算出的 72MHz 和模拟器里的时间基准才对得上。工程默认配置了“Use Simulator”调试方式。这里有个很容易忽略的选项如果你之前用过真机调试Keil 可能还停留在“Use ULINK”这类状态需要在 Options for Target 的 Debug 标签页切换到 Use Simulator并且在右边窗口勾选 Run to main否则一进调试就是汇编入口新手容易懵。4.2 逻辑分析仪设置从哪里看六路波形进入 Debug 后从菜单打开 View → Analysis Windows → Logic Analyzer。逻辑分析仪可以显示 GPIO 引脚状态所以只需要观察 PA8、PA9、PA10、PB13、PB14、PB15 六个引脚。在 Logic Analyzer 窗口里添加信号时不同版本的 KEIL 语法不太一样。新版本可以直接输入PORTB.13如果输入不了就写PORTB 0x2000然后在 Setup 里把 Bit Mask 设成对应值。添加完成后设置信号颜色连续运行一段时间再暂停就能看到六个方波。要确认死区比较同一组主通道和互补通道。比如观察 PA8 和 PB13放大到单周期能看到两者同时为低的一小段电平那段宽度就是死区。用光标测量这个低电平时间对照 BDTR 寄存器算出的 2.39us能验证配置是否正确。4.3 模拟器里“占空比不动”的常见原因如果运行后波形是固定的不像正弦变化先检查 TIM1 更新中断有没有被触发。在中断函数入口打断点运行一次看是否进入。常见的原因有几个一是没有调用TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE)中断根本没开二是 NVIC 优先级没配但默认情况下也有响应三是模拟器对高级定时器的支持有时不完整更新事件和死区生成未必完全复现真机行为。遇到这种情况我一般会加一个辅助变量g_int_count每进一次中断加一在 Watch 窗口观察它是否在运行中增长。如果计数值在涨说明中断链路通了问题出在 CCR 更新或者逻辑分析仪的采样设置上如果计数值为零问题就在中断配置。4.4 真板运行前的最小验证思路模拟器能确认逻辑但没办法验证功率级的接线和驱动芯片的极性。上真板之前建议先断开直流母线用低压 12V 或 24V 给逆变桥供电输出端接一个几十欧的功率电阻或者白炽灯再给定时器引脚接上示波器。这一步只验证开关管的栅极波形和死区是否正常不要直接接三相电机和高压母线。我习惯把真板验证分成两步先看栅极波形确认六个引脚开关时序正确再接电机空载测试确认输出频率和电压可调。如果第一步栅极波形有重叠导通立刻断电查极性配置这是整个工程里最不能侥幸的环节。5. 避坑记录死区寄存器、MOE 和仿真波形的五个踩坑现场这里把开发过程中最容易翻车的五个问题整理出来每条按现象、原因、解决来写方便你对照排查。5.1 死区时间怎么调都差一个数量级现象在 BDTR 里填了 0x32预期死区是 50 × 13.9ns ≈ 0.7us但示波器量出来明显偏大或偏小怎么都不对。原因0x32 的二进制是 0011 0010高三位是 001属于第一组公式确实可以按 DTG × Tdts 算。但很多人把 0x96、0xF0 这类值当成相同含义来填没有意识到高三位不同的时候计算公式完全不同。死区寄存器不是一个线性计数器而是一个分段映射表。解决先拆高三位确定用哪一组公式再算低五位系数。例如想要 2.4us先选择 100 组公式是 (64 DTG[4:0]) × 2 × Tdts代回去算出 DTG[4:0] 是 22即 0x96。不要把记忆中的“0x96 对应 2us”直接套到不同芯片上关键看 Tdts 是多少。5.2 寄存器全对引脚就是没有输出现象模拟器和真板上都能看到定时器计数在跳CCR 也在更新但 PA8、PB13 这些引脚就是没有 PWM 波形。原因TIM1 这类高级定时器有一个主输出使能位 MOE它位于 BDTR 寄存器中。标准外设库的TIM_CtrlPWMOutputs(TIM1, ENABLE)就是控制这个位。如果没有调用整个定时器的输出级被封锁引脚只能保持复位状态。这个位在日常的普通 PWM 例程里不涉及所以很多人第一次用高级定时器时会漏掉。解决确认初始化代码里调用了TIM_CtrlPWMOutputs(TIM1, ENABLE)并且它要放在TIM_Cmd之前还是之后都不是关键关键是必须执行。真机调试时可以直接看 BDTR 寄存器的 MOE 位是否为 1。5.3 主引脚有波形互补引脚却是死的现象PA8 有正常的 PWMPB13 一直保持低电平怎么看都不像互补输出。原因互补引脚和主引脚虽然属于同一个定时器通道但 GPIO 复用的是两套引脚。如果只初始化了 GPIOA没有初始化 GPIOB那么 PB13、PB14、PB15 根本不会被复用成定时器输出功能。芯片内部定时器确实在输出但这些引脚仍作为普通 GPIO 保持默认电平。解决在初始化里把 GPIOB 的时钟打开并把 PB13/PB14/PB15 配置成复用推挽输出。检查引脚一定要对着参考手册的复用功能表不要凭直觉以为 CH1N 也在 PA 口上。5.4 PWM 极性配反上真板一开就炸现象模拟器里看到的波形很正常但接到驱动板上母线一旦上电就过流甚至烧掉功率管。原因OCPolarity 和 OCNPolarity 决定的是输出信号有效电平而这个有效电平必须和你的栅极驱动电路匹配。如果驱动芯片是低电平导通而代码里配成高电平有效那关断指令会变成导通指令。换句话说你以为是互补 PWM实际输出变成了同相 PWM上下管一起导通。解决上真板之前先把驱动原理图的逻辑搞清确认栅极是高电平有效还是低电平有效再决定极性。这一步在模拟器里是看不出来的必须用数据手册核对。验证时先用低压小功率测试严禁空手去摸母线段。5.5 输出频率跳档50Hz 变成 39Hz 或 58Hz现象程序里写了 50Hz实际测出来却不是整 50Hz波形频率会在某些值之间跳变。原因直接用整数步进去走正弦表比如每周期前进 2 步1024 点表在 10kHz 载波下只能得到 19.5Hz 的倍数没有 50Hz 这个档。这就是没有用相位累加器导致的频率量化问题。解决把“正弦表索引”改成 32 位相位累加器用phase_inc (uint64_t)fs 32 / carrier来设定频率每个载波周期取相位的高 10 位作为索引。这样频率分辨率能做到接近 0.001Hz调频才是真正连续的。6. 二次开发把三相 SPWM 核心改造成可移植的电机驱动基础这套工程跑通之后下一步通常是把主循环里的按键调频调压改成串口或上位机下发指令。改造时最值得保留的是 spwm_core.c它不应该依赖任何 STM32 外设库。为此我一般会再把 CCR 计算封装成纯函数void spwm_get_ccr(uint32_t phase, uint16_t arr, uint16_t volt_m, uint16_t *ccr_u, uint16_t *ccr_v, uint16_t *ccr_w) { uint32_t amp (uint32_t)arr * volt_m / 1000U; *ccr_u (uint16_t)(((uint32_t)sin_table[phase] * amp) 15); *ccr_v (uint16_t)(((uint32_t)sin_table[(phase 341) 1023] * amp) 15); *ccr_w (uint16_t)(((uint32_t)sin_table[(phase 682) 1023] * amp) 15); }这样 spwm_core.c 甚至可以在 PC 上做单元测试。喂入不同的 phase 数组直接打印输出曲线检查三相相位差和调制比线性度不用上板就能完成算法验证。中断里调用这个函数整个 ISR 逻辑会变得很短也更容易审查。另一个值得做的改动是增加恒压频比启动流程。现在的工程把频率和电压分开设置但电机驱动里低频时电压如果太低电流可能推不动转子。通常做法是加一个 boost 系数频率低于 20Hz 时电压不是按线性比例下降而是保留一个最小占空比。这个逻辑可以做成查表放在主循环里每次设置频率后自动更新电压。如果后续想向 SVPWM 演进这套架构也能平滑过渡。SVPWM 的本质是六个基本电压矢量的时间组合它最终输出的依然是三路互补 PWM只是 CCR 计算方式从正弦表改成了扇区矢量作用时间。替换掉 spwm_get_ccr 内部实现即可TIM1 初始化完全不动。从那以后我每次移植到新板子都会先做同一个动作把死区寄存器按分档公式算一遍在初始化函数里打印出最终写入的 BDTR 值再用模拟器量一次波形。这个“算、写、量”的循环看起来笨但正因为养成了这个习惯后来再没在死区和极性问题上前功尽弃过。希望这个工程能帮你少踩几个我踩过的坑。本文还有配套的精品资源点击获取