STM32F103定时器OC Toggle生成4路90°相位差方波详解

发布时间:2026/9/14 4:57:44
STM32F103定时器OC Toggle生成4路90°相位差方波详解 简介面向嵌入式开发者与电机控制、信号处理方向学生的一份STM32F103工程源码实现了4路PWM方波输出且各路相位差精确为90°可服务于四象限电机驱动、正交编码、无线通信调制等多种场景也适合作为学习定时器PWM高级应用的实战范例。压缩包共73个文件含34个.h头文件、33个.c源文件、2个启动汇编文件、1个hex烧录固件及Keil工程文件.uvprojx/.uvoptx仅277KB目录按libraries、inc、src、user、system等模块组织并附带辅助清理脚本结构清晰易上手。工程基于标准外设库完整演示了定时器PWM模式配置、比较寄存器实现90°相移、通道同步更新与中断处理等关键步骤并已编译为可直接烧录的hex文件便于对照实测代码采用模块化组织定时器、延时、串口等驱动均独立封装方便按需裁剪与移植。目前已有943人学习下载适合需要快速生成正交方波或理解STM32多通道PWM相位控制的开发者参考。1. 4通道90°相位差方波先搞清楚波形再决定用哪个定时器同步电机驱动、正交编码器仿真、步进电机细分控制里经常需要同时输出多路相位差恒定的方波。相位差90度的四路方波如果单纯靠GPIO中断去翻转中断响应一抖动相位就会飘而STM32F103的定时器输出比较翻转模式在一个定时器上就能同时输出四路相位差严格的方波几乎不占用CPU。本文以最常见的STM32F103最小系统为硬件前提用TIM2的四个通道给你一套可直接套用的配置随后推导PSC、ARR、CCR的计算关系最后用另一路定时器做多路捕获验证相位差是不是真的落在了90度。这套方案不依赖外部移相芯片也不吃CPU中断适合对相位稳定度有要求的工程场景。2. 用STM32F103定时器的输出比较翻转生成相位差方波2.1 90°相位差方波的本质一个标准的50%占空比方波周期为T高电平占T/2低电平占T/2。两路同频方波相位差90°意思是它们的上升沿之间相差T/4。对于四路方波0°、90°、180°、270°的边沿间隔正好是T/4一步。这里有一个容易被忽略的等价关系相位差180°就是反相。0°和180°互为高低反转90°和270°也互为高低反转。因此四路90°相位差方波本质上只要先生成两个相位差90°的方波A和B再各自取反就得到四路。这个结论直接决定了定时器的通道安排也解释了为什么后面有两个通道的CCR是重复的只是极性不同。2.2 为什么选择OC Toggle而不是PWM或DMASTM32F103的定时器生成波形主流有PWM模式、输出比较翻转模式、以及用DMA不断修改比较寄存器。PWM模式看起来最直观但四路PWM要同时保持50%占空比CCR必须相等CCR一旦相等就没有相位差。这不是配置问题是PWM原理决定的PWM的高电平时长由CCR决定CCR移动时占空比也变。输出比较翻转模式不同它做的只是“当计数器到达CCR时翻转一次引脚电平”占空比天然50%相位由CCR出现的时间点决定四路之间互不干扰正是最适合做相位差方波的模式。DMA方案也可以生成但它是靠CPU或DMA预先计算好一串比较值在运行时更新到CCR逻辑上能实现任意波形却要搬运数据而且DMA更新延迟会造成通道间相位抖动在要求低抖动的场合不如硬件OC通道稳定。下面用一个表格对比三种方式的差异方案占空比控制相位差精度CPU占用适合场景PWM模式受CCR限制与占空比耦合低单路或互补PWMOC Toggle固定50%仅由CCR决定低多路同频移相方波DMA比较灵活受DMA延迟影响中任意波形所以做4通道90°相位差方波OC Toggle是最干净的做法。2.3 引脚布局、时钟与最小系统前提STM32F103的TIM2有四个独立通道默认映射到PA0、PA1、PA2、PA3全部配置为复用推挽输出。这四个引脚在最小系统板上通常空置即使接了LED也不影响测试。前提是STM32F103最小系统的时钟正确8MHz晶振经过PLL倍频到72MHzAPB1预分频为2此时TIM2的输入时钟为72MHz而不是APB1总线上的36MHz这一点容易在计算时算错。如果PA11、PA12上接了USB而你又改用TIM1做四通道输出注意PA11/PA12复用冲突网上的stm32f103 pa11 bug就是USB寄存器被意外复位后PA11上的TIM1_CH4输出被拉回去导致相位错乱。提示验证相位差方波前先用SystemInit把系统时钟配置到72MHz再检查TIM2的PSC寄存器是否按72MHz计算。3. 基于标准外设库的TIM2四通道OC Toggle配置3.1 GPIO与TIM2时钟使能使用STM32标准外设库以V3.5.0为基础初始化分三步开时钟、配GPIO、配定时器。下面这段放到一个函数里#include stm32f10x.h void EncoderWave_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 使能GPIOA和TIM2时钟。TIM2挂在APB1上不是APB2。 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // PA0~PA3 复用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); }这段代码的关键是时钟总线。TIM2在APB1上使用RCC_APB1PeriphClockCmdGPIOA在APB2上使用RCC_APB2PeriphClockCmd。如果写成TIM2用APB2初始化不会报错但寄存器和时钟始终未被使能示波器上不会有波形。GPIO模式必须是复用推挽AF_PP不能是推挽输出GPIO_Mode_Out_PP因为定时器信号是通过复用功能接到引脚的。3.2 配置溢出周期和翻转点CCR接下来配置定时器时基。目标是输出1kHz方波四路相位差90度也就是四路上升沿相距250us。计数频率定为1MHz这样计数器每加1对应1usCCR直接写250就是250us。TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; // 72MHz/72 1MHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数 TIM_TimeBaseStructure.TIM_Period 500 - 1; // 溢出周期500us TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure);这里把PSC设成71实际分频是PSC1。ARR设成499所以计数器从0走到499共存500个计数每个计数1us定时器500us溢出一次。注意OC Toggle模式下每个溢出周期引脚只翻转一次所以一个完整方波周期需要两个定时器周期即1ms正好对应1kHz。CCR250的位置位于计数周期的中间对应半个定时器周期250us也就是方波周期的四分之一。3.3 四通道OC Toggle参数表与初始化代码四个通道的配置如下表通道引脚CCR输出极性初始引脚电平上升沿位置CH1PA00High低0°CH2PA1250High低90°CH3PA20Low高180°CH4PA3250Low高270°表格里的“上升沿位置”是在计数器从0开始的第一个周期内观察到的第一个上升沿。CH1和CH2都从低电平开始极性HighCCR分别为0和250所以CH2的上升沿比CH1晚250us即90度。CH3和CH4把极性设为Low引脚初始为高电平CCR0/250触发翻转后引脚变低下一次翻转才会变高因此它们比CH1、CH2滞后一个翻转周期形成180度、270度。具体初始化代码如下TIM_OCInitStructure.TIM_OCMode TIM_OCMode_Toggle; // 输出比较翻转 TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; // 通道1 CCR 0 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_OCInitStructure.TIM_Pulse 250; // 通道2 CCR 250 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC2Init(TIM2, TIM_OCInitStructure); TIM_OCInitStructure.TIM_Pulse 0; // 通道3 CCR 0 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_Low; TIM_OC3Init(TIM2, TIM_OCInitStructure); TIM_OCInitStructure.TIM_Pulse 250; // 通道4 CCR 250 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_Low; TIM_OC4Init(TIM2, TIM_OCInitStructure); // 关闭预装载让CCR改动立刻生效避免波形起始相位被更新事件延迟 TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Disable); TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Disable); TIM_OC3PreloadConfig(TIM2, TIM_OCPreload_Disable); TIM_OC4PreloadConfig(TIM2, TIM_OCPreload_Disable); TIM_Cmd(TIM2, ENABLE);每一个TIM_OCnInit调用会把TIM_Pulse和TIM_OCPolarity写入对应通道的比较寄存器和极性位。注意TIM_OCMode和TIM_OutputState不需要重复配置因为结构体里的值在上一次调用后没有变。TIM_Pulse是16位值必须小于等于ARR。CCR0是允许的计数器启动后第一次CNT0就触发翻转。如果担心启动瞬间的毛刺可以把CH1和CH3的CCR改为1把CH2和CH4改为251相位差依然是250us只是四路整体滞后1us。3.4 启动定时器与输出以上函数在main里直接调用即可int main(void) { SystemInit(); EncoderWave_Init(); while (1) { __NOP(); } }这里没有在while里做任何波形刷新因为波形的翻转完全由硬件比较器完成程序即使卡死在循环里波形也会继续输出。这也是OC Toggle方案相比软件翻转最明显的优势。如果换成TIM1做同样的四通道输出只需要把外设时钟改为RCC_APB2Periph_TIM1、通道引脚改为PA8-PA11并在TIM_Cmd之前增加一行TIM_CtrlPWMOutputs(TIM1, ENABLE);否则高级定时器的主输出使能没有打开引脚上仍然没有波形。4. 调整频率和相位的3个必调参数4.1 PSC、ARR、CCR的公式拿到这套配置后最常改的就是频率。输出方波频率的完整公式是f_out fclk / (2 * (PSC 1) * (ARR 1))其中fclk是定时器的输入时钟对TIM2来说等于72MHz。除以2是因为OC Toggle模式下每两个定时器周期才输出一个完整方波。以当前配置为例72MHz / (2 * 72 * 500) 1kHz。如果需要2kHz方波保持PSC71不变则ARR1 72MHz / (2722000) 250所以ARR249。这时相邻通道的CCR偏移也要改为(ARR1)/2 125。直接改寄存器的方式是TIM2-ARR 249; // 重装载值改为249 TIM2-EGR | TIM_EGR_UG; // 生成更新事件让ARR立刻生效 TIM_SetCompare1(TIM2, 0); TIM_SetCompare2(TIM2, 125); TIM_SetCompare3(TIM2, 0); TIM_SetCompare4(TIM2, 125);这里手动触发UG会产生更新中断如果开了更新中断可能误触发回调工程里要清理TIM_SR的UIF标志。使用TIM_SetCompare函数比直接写CCR寄存器更安全因为它会按预装载配置决定何时生效。本例中由于已经关闭预装载四个通道的新CCR会立刻参与比较。4.2 相位差与CCR偏移的计算相位差和CCR的关系是线性比例关系。对于四路90度相位差相邻通道的CCR差值固定为(ARR1)/2。如果只需要两路相差90度那更简单一路CCR设为base另一路设为base (ARR1)/2。base可以是任意值只要base(ARR1)/2不超过ARR即可。举一个30度相位差的例子先算出90度对应的偏移是(ARR1)/2所以30度偏移 (ARR1)/2 / 3 (ARR1)/6。若ARR499则偏移为500/683取整后实际相位差是83/500个定时器周期即(83/500)*18029.88度。这是计数分辨率的固有限制提高计数频率可以减少误差方法是减小PSC但保持计数周期数不变。4.3 常见坑CCR0、预装载、高级定时器MOE第一个坑是CCR0时的匹配事件。计数器的更新事件也在CNT0时发生配置不好会出现第一周期就多翻转一次。标准库默认不会影响翻转但如果你在运行中用预装载更新CCR就要小心更新事件和比较匹配的先后顺序。一般建议CCR避开0例如用1和251代价是所有通道增加1us的固定延迟相位差不受影响。第二个坑是预装载。上面代码特意关闭了OCx预装载这样改变TIM_Pulse后比较值立即生效适合频率在运行时快速变化的场景。如果不需要动态改频开启预装载更安全但必须在TIM_Cmd之后再设置新的Pulse。第三个坑只在高级定时器TIM1/TIM8上出现。高级定时器有MOE主输出使能位初始化后默认关闭很多人把TIM1的OC配置好却没有波形就是少了TIM_CtrlPWMOutputs(TIM1, ENABLE)这一步。另外TIM1的通道3是互补输出通道如果你同时用TIM1_CH3和TIM1_CH3N做相位差波形要关注死区插入那又是另一套参数。5. 用TIM3多路捕获验证相位差5.1 验证思路波形生成后不能只用眼睛对着示波器数格子更好的办法是用第二路定时器做多路输入捕获。把TIM2的四路输出PA0-PA3用杜邦线分别接到TIM3的四个输入引脚PA6、PA7、PB0、PB1四个通道都配置为上升沿捕获。由于TIM3只有一个计数器四路信号边沿到来时会分别把当前CNT快照到对应的CCR寄存器所以读取四个捕获值并做差就能直接算出相邻上升沿的时间差。如果时间差等于250us相位差就是90度。5.2 捕获配置代码下面给出TIM3输入捕获的关键配置void TIM3_IC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // PA6, PA7 为TIM3_CH1/CH2PB0, PB1为TIM3_CH3/CH4 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_Init(GPIOB, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; // 1MHz计数 TIM_TimeBaseStructure.TIM_Period 0xFFFF; // 65535的范围 TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; // 上升沿捕获 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; // 边沿不分频 TIM_ICInitStructure.TIM_ICFilter 0x0F; // 输入滤波抗毛刺 TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInit(TIM3, TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_2; TIM_ICInit(TIM3, TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_3; TIM_ICInit(TIM3, TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_4; TIM_ICInit(TIM3, TIM_ICInitStructure); TIM_ClearFlag(TIM3, TIM_FLAG_CC1 | TIM_FLAG_CC2 | TIM_FLAG_CC3 | TIM_FLAG_CC4); TIM_ITConfig(TIM3, TIM_IT_CC1 | TIM_IT_CC2 | TIM_IT_CC3 | TIM_IT_CC4, ENABLE); TIM_Cmd(TIM3, ENABLE); }这里把TIM3的计数频率也设为1MHz和TIM2一致因此捕获值的差值直接就是微秒数。每个通道的TIM_ICPolarity都设成上升沿ICSelection选择直接输入即捕获引脚的原始信号。输入滤波设为0x0F对百纳秒级毛刺有抑制作用但注意滤波会引入约几个计数周期的额外延迟实际测量时如果差值偏差在±2个计数内都算正常。中断里只需要把四个CCR读出来void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_CC1) ! RESET) { cap[0] TIM_GetCapture1(TIM3); TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); } if (TIM_GetITStatus(TIM3, TIM_IT_CC2) ! RESET) { cap[1] TIM_GetCapture2(TIM3); TIM_ClearITPendingBit(TIM3, TIM_IT_CC2); } // 同理读取cap[2]、cap[3] }在中断处理末尾计算相邻差值时使用无符号减法(uint16_t)(cap[1] - cap[0])这样即使TIM3计数器发生回绕也能得到正确的正差值。理想情况下cap[1]-cap[0]250cap[2]-cap[1]250cap[3]-cap[2]250同时cap[0]到cap[3]的分布代表0°、90°、180°、270°。如果看到250、500、750这样的累计值说明你正好测到了同一轮方波周期的四个连续上升沿。5.3 验证失败先查这三处第一处是跳线。TIM2输出是PA0-PA3TIM3捕获是PA6、PA7、PB0、PB1接错引脚会导致捕获值为0或恒为某一个固定值。第二处是TIM2的ARR或PSC写错导致频率不是1kHz捕获差值就不是250此时先量任意一路的周期是不是1ms。第三处是调试器连接异常导致程序没跑起来这时优先检查BOOT0和SWDIO配置再回到波形本身排查。当相位差要求低于1us时把TIM2和TIM3的PSC从71改成1计数分辨率就从1us降到28ns但ARR也要按新的计数频率重新计算。本文还有配套的精品资源点击获取