STM32双路步进电机控制:定时器PWM与脉冲频率精调实践
简介基于STM32的两路步进电机控制代码面向工业自动化、3D打印、机器人技术以及科学实验等需要精确调速与换向的应用场景可帮助开发者快速实现双电机协同控制。压缩包共129个文件约2.76MB包含34个h头文件、33个c源文件以及工程配置、编译依赖、HEX固件、链接映射等辅助文件资源内还集成了STM32标准外设库的底层驱动省去手动添加库文件的步骤既可直接查看源码学习也能编译烧录到开发板验证。代码基于定时器PWM输出围绕步进电机的初始化、速度设置、方向切换和停止控制等基本操作展开同时整合了多任务调度与中断处理逻辑便于应对不同工况下的实时控制需求。注释详尽、模块划分清晰适合嵌入式初学者理解电机驱动原理也便于工程师在现有框架上二次开发用作教学实验或产品原型验证。目前已有184人学习下载可视为一套简洁实用的双路步进电机控制参考方案。1. 两路步进电机控制难点不在转而在怎么转得准步进电机控制看起来门槛很低给一个脉冲它就转一个角度给一串脉冲它就连续转。但真把两路电机放到一起做协调运动时问题会成堆冒出来定时器通道分配不合理导致互相干扰转速换算公式算错导致实际速度对不上设定值加减速没做直接一启动就丢步。这份基于STM32F103标准外设库的工程代码正好把这些坑趟了一遍。它通过定时器PWM输出驱动两路步进电机的速度和方向串口负责收指令代码里已经写好初始化、速度设置、方向切换、停止以及一个可扩展的定时器中断调度框架。适合正在做3D打印、小型机械臂、实验载物台或者想搞懂步进电机控制细节的人拿出来改。2. 原理先行为什么用STM32定时器PWM驱动步进电机而不是GPIO翻转2.1 步进电机驱动模型与脉冲-方向接口步进电机本体通常不直接接MCU中间必须有一个驱动器。常见的步进驱动器接收三路信号STEP脉冲、DIR方向、ENA使能。每来一个STEP上升沿驱动器就让电机的某一相绕组通电换一次电机转过一个步距角加一个细分级差。所以控制步进电机本质就是控制STEP引脚上脉冲的频率和数量以及DIR引脚的电平状态。用GPIO翻转模拟脉冲也能让电机转但有两个致命问题一是脉冲频率受延时函数和主循环影响速度不稳定电机容易堵转二是两路电机同时要脉冲时CPU必须忙个不停根本没有余力处理串口、传感器或显示任务。定时器PWM输出恰好解决这两个问题脉冲生成完全由硬件完成并且可以同时开多个定时器通道或复用定时器让两路电机独立跑CPU只负责改寄存器参数。2.2 定时器PWM输出与中断的配合机制STM32的定时器PWM输出基于一个自由运行的计数器和一个自动重装载寄存器ARR。计数器从0计数到ARR然后归零重新开始这个过程就是PWM的周期。输出引脚的电平翻转由比较寄存器CCR决定计数器小于CCR时输出一种电平大于时输出另一种电平。这样一来脉冲频率由ARR和预分频器决定占空比由CCR决定两路脉冲如果放到同一个定时器上主要注意ARR是共用的。工程里选用两个独立定时器各自输出一路STEP脉冲这样两路电机的脉冲频率完全独立。同时可以打开定时器的更新中断在计数器归零那一刻产生一个中断这样每产生一个脉冲就进一次中断可以在中断里维护脉冲计数也能在中断里动态修改ARR实现加减速。需要注意定时器更新中断频率等于脉冲频率例如电机要跑20kHz的脉冲CPU就会每秒被中断2万次每次中断函数必须非常精简只做累加和简单判断不能放延时或耗时运算。2.3 工程文件结构与标准外设库依赖从文件列表能看到这是一个典型的Keil标准外设库工程。stm32f10x_tim.c是定时器驱动stm32f10x_rcc.c管理时钟stm32f10x_usart.c负责串口指令接收stm32f10x_adc.c、stm32f10x_i2c.c、stm32f10x_can.c等是标准库内的可选外设实际用到哪些取决于项目配置。USART.axf是编译生成的镜像keilkilll.bat则是一个批处理脚本用来一键清理工程里的中间编译产物避免每次备份时把一堆临时文件带走。文件作用在本项目中的位置stm32f10x_tim.c定时器控制产生两路PWM脉冲触发更新中断stm32f10x_rcc.c时钟复位控制使能GPIO和定时器时钟stm32f10x_usart.c串口通信接收上位机控制指令stm32f10x_flash.cFlash读写可用于保存电机参数keilkilll.bat清理脚本删除编译中间文件保留源码这套代码依赖标准外设库而不是HAL库好处是代码直接操作寄存器中间层薄实时性更好适合做高速脉冲输出缺点是低版本Keil工程需要手动把stm32f10x.h里的器件型号宏打开不然编译时系统时钟和GPIO映射全乱。3. 核心代码拆解初始化、速度设置与方向切换3.1 引脚与定时器初始化首先确定两路电机的资源分配。我这里给出一种常见接法电机1的STEP口接TIM2_CH1对应的PA0DIR接PB0ENA接PB1电机2的STEP口接TIM3_CH1对应的PA6DIR接PB2ENA接PB3。这个方案不冲突两个定时器都在APB1总线上配置方式一致。void Stepper_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; // 使能时钟GPIOA、GPIOB挂在APB2TIM2/TIM3挂在APB1 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2 | RCC_APB1Periph_TIM3, ENABLE); // STEP引脚PA0/PA6复用为推挽输出因为定时器PWM才会控制它们 gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_6; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); // DIR和ENA引脚PB0~PB3普通推挽输出 gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; gpio.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOB, gpio); // 预分频为0定时器计数频率就是72MHz tim.TIM_Prescaler 0; tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 0xFFFF; // 初始给一个保守频率后面按速度改 tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tim); TIM_TimeBaseInit(TIM3, tim); // PWM1模式CNT CCR 输出有效电平CNT CCR 输出无效电平 oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse 0; oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, oc); TIM_OC1Init(TIM3, oc); // 更新中断每产生一个脉冲进入一次中断服务函数 TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); // 开定时器脉冲开始输出 TIM_Cmd(TIM2, ENABLE); TIM_Cmd(TIM3, ENABLE); }这段初始化有几个关键点第一PA0和PA6必须先配成复用推挽否则定时器信号无法输出到引脚第二TIM_Prescaler0意味着定时器计数器每1/72微秒跳一次这样PWM频率计算最简单第三初始TIM_Period0xFFFF会让脉冲频率只有1kHz左右不会让电机意外乱转相当于安全的默认值。如果电机在没有任何指令时就锁紧发热多半是ENA引脚初始化成低电平使能了需要在上电后立刻把ENA置高释放电机。3.2 速度设置本质修改PWM频率而不是占空比很多新手会把控制电机速度想成调节PWM占空比这是误区。步进电机速度只看脉冲频率占空比只要保证脉冲宽度足够让驱动器识别就行。这里把51%占空比作为标准配置占空比略微大于50%是为了保证高电平时长足够稳定。// 设置电机转速motor_id1或2rpm为目标转速 void Motor_SetSpeed(uint8_t motor_id, uint16_t rpm) { uint32_t pulses_per_rev 200 * 16; // 1.8°电机16细分一转为3200脉冲 float pulse_freq (float)rpm * pulses_per_rev / 60.0f; // 每秒脉冲数 uint32_t timer_freq 72000000; // 72MHz计数频率 uint32_t arr_value (uint32_t)((timer_freq / pulse_freq) 0.5f) - 1; // 边界保护ARR过小导致频率过高ARR过大导致低数不兼容 if (arr_value 0xFFFF) arr_value 0xFFFF; if (arr_value 0) arr_value 1; TIM_TimeBaseInitTypeDef tim; tim.TIM_Prescaler 0; tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period arr_value; tim.TIM_ClockDivision TIM_CKD_DIV1; if (motor_id 1) { TIM_TimeBaseInit(TIM2, tim); TIM_SetCompare1(TIM2, arr_value / 2); // 占空比设置 } else { TIM_TimeBaseInit(TIM3, tim); TIM_SetCompare1(TIM3, arr_value / 2); } }这个函数逻辑是先算出目标转速下每秒需要的脉冲数再用72MHz除以脉冲数得到定时器周期最后写入ARR和CCR。比如设定RPM100细分数16pulse_freq3200*100/60≈5333ARR72000000/5333-1≈13499于是PWM频率就是5333Hz正好让电机每秒转5333步折算成转速就是100转/分钟。要注意这里的细分数必须和驱动器拨码开关的实际设置一致否则速度会成倍偏移。常见错误是驱动器拨码设在8细分程序里却按16细分算结果转速虚高实际距离也少一半。3.3 方向切换与停止/使能控制方向控制比较简单DIR引脚的电平决定了电机正转还是反转但这个电平必须在脉冲发送前稳定下来不然第一个脉冲的上升沿可能被错误解释。工程里方向函数和PWM设置是分离的顺序上先调方向再给速度。// dir0逆时针dir1顺时针具体哪个方向看驱动器接线 void Motor_SetDir(uint8_t motor_id, uint8_t dir) { if (motor_id 1) { GPIO_WriteBit(GPIOB, GPIO_Pin_0, dir ? Bit_SET : Bit_RESET); } else { GPIO_WriteBit(GPIOB, GPIO_Pin_2, dir ? Bit_SET : Bit_RESET); } } // 停止电机关闭定时器输出同时置高ENA让电机失力 void Motor_Stop(uint8_t motor_id) { if (motor_id 1) { TIM_Cmd(TIM2, DISABLE); GPIO_WriteBit(GPIOB, GPIO_Pin_1, Bit_SET); // 断开驱动器使能 } else { TIM_Cmd(TIM3, DISABLE); GPIO_WriteBit(GPIOB, GPIO_Pin_3, Bit_SET); } }停止时同时处理TIM_Cmd和ENA非常关键。如果只停PWM不放ENA电机会处于锁死状态用手拧轴会感到明显阻力时间长了驱动器也会发热。反过来如果只想暂停位置保持功能则可以不断PWM同时拉低ENA让电机锁位。3.4 中断处理与多任务调度定时器更新中断承担两个职责一是统计脉冲个数用于位置追踪二是作为加速曲线的执行时机。下面这个简单版本专门处理脉冲计数为后续做闭环或精确角度控制留好变量。volatile uint32_t motor1_steps 0; volatile uint32_t motor2_steps 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); motor1_steps; // 每来一个脉冲自增可以做位置判断 } } void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); motor2_steps; } }有了这两个计数器就可以在主循环或串口回调里判断是否到达目标脉冲数。中断函数的执行时间必须远小于脉冲周期假设脉冲频率20kHz周期50微秒中断里只做自增和清标志而如果加入复杂的浮点加减速计算就可能超过50微秒造成脉冲丢失。所以工程里速度改变逻辑一般放在主循环里做中断只保留最轻量的操作。4. 实战两路电机协调控制、串口指令与排错清单4.1 通过USART下发指令控制电机既然工程文件里有stm32f10x_usart.c串口控制就是最自然的人机接口。我建议的指令格式为1 500\r\n表示1号电机500RPM正转2 300 2000\r\n表示2号电机300RPM转2000步后自动停止。串口中断负责收字节攒到换行符才解析。void USART1_IRQHandler(void) { static char rx_buf[32] {0}; static uint8_t idx 0; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { char c USART_ReceiveData(USART1); if (c \n || c \r) { rx_buf[idx] \0; UART_ParseCmd(rx_buf); // 解析整条指令 idx 0; } else if (idx 31) { rx_buf[idx] c; } } }解析函数用sscanf或者手动分割数字第一段是电机号第二段是速度第三段是步数步数为0代表持续转。这种设计适合从PC端发指令也可以接上位机脚本批量控制。4.2 脉冲当量与速度换算脉冲当量指的是一个STEP脉冲让机械移动的距离或角度这个值直接影响定位精度。以1.8°步距角、16细分为例一个脉冲电机转1.8/160.1125°。如果带同步带和减速箱还需要把传动比算进去。下面的表可以快速查速度换算。细分数每转脉冲数设定500RPM时脉冲频率ARR72000000/频率-112001666.7Hz4319924003333.3Hz2159948006666.7Hz107998160013333.3Hz539916320026666.7Hz2699注意当转速到600RPM且细分数16时脉冲频率已经达到26kHz这个时候普通光电驱动器可以接受但如果是传统驱动器内部光耦载频不足波形就会失真。CoreXY结构控制代码里两个电机经常要同时跑到不同的脉冲频率这种独立定时器分配方式才能支持。脉冲当量还关系到坐标换算比如CoreXY的X轴移动需要A电机正转B电机反转换算公式必须在应用层先算好再把两个电机的速度分别写入。4.3 典型坑电机抖动、丢步、两路不同步第一电机抖动。先看PWM占空比和频率是否正确用示波器量STEP引脚确认波形没有毛刺再看驱动器拨码细分数是否与程序一致如果驱动器是400步每转程序按3200步每转算速度差8倍低速时会明显一顿一顿。第二丢步。加速过猛是丢步的最大诱因。步进电机有一个极限启动频率超过这个频率直接给全速脉冲转子跟不上磁场变化就会失步。另外供电电压偏低也会让电流爬升速度跟不上高速区转矩下降。我一般建议把直流供电电压保持在驱动器标称范围的中上值并加大电源滤波电容。第三两路不同步。同时用两个定时器输出时如果两路速度不同很难做到严格相位同步因为两个定时器的起始时刻有微秒级误差。如果项目必须完全同步例如双Z轴结构请把两条STEP脉冲并联到同一个定时器的同一引脚或者使用同一定时器输出但频率相同的两路这样相位天然一致。下面的表整理了判断依据。现象可能原因检查方法单个电机抖动脉冲宽度不足示波器测STEP高电平时间带载丢步加速太快或电压不足降速测试或看电流灯两路电机转向相反DIR初始电平不同上电测PB0和PB2电平串口命令偶尔不执行中断接收缓冲溢出延长命令间隔或加校验4.4 用逻辑分析仪验证波形调试这代码时逻辑分析仪比示波器好用。把两路STEP分别接到逻辑分析仪的CH0和CH1DIR接CH2和CH3采样率设到500kHz以上就能直接读出脉冲频率和脉冲数。我会直接用Saleae软件量出1秒内的上升沿数量再除以细分数看转速是否与设定值吻合。还能顺便测量两个STEP通道的相位关系在CoreXY测试场景里尤其重要。5. 进阶把开环控制升级为带加减速和闭环预判5.1 为什么速度突变会导致丢步步进电机的转矩在高速区会下降启动瞬间如果脉冲频率超过最大自启动频率转子惯性拖不住磁场切换就会丢失脉冲而且这种丢失是无声的最后位置完全对不上。解决办法是让频率按曲线爬升常见的是梯形加减速加速段逐步缩短ARR减速段逐步拉长ARR。5.2 梯形加减速状态机实现在定时器更新中断里写入以下状态机代码是改动最小的一种方式。加速段每进一次中断就把当前频率提高一档直到达到目标频率然后匀速减速时按步数余量提前降频。typedef enum { ACCEL 0, RUN 1, DECEL 2 } MotionState; void Motor_Update(uint8_t motor_id) { static uint32_t accel_arr 5000; // 起步ARR频率约14kHz static uint32_t target_arr 1000; // 目标ARR频率约72kHz if (accel_arr target_arr) { accel_arr - 20; // 加速步长根据电机惯性调整 if (accel_arr target_arr) accel_arr target_arr; if (motor_id 1) { TIM_SetAutoreload(TIM2, accel_arr); } else { TIM_SetAutoreload(TIM3, accel_arr); } } }这里TIM_SetAutoreload直接写ARR下一次计数器归零就会生效。步长20意味着大约需要200个脉冲从14kHz加速到72kHz加速度很温和。如果电机带重负载把步长改成5或10避免电流冲击过大如果电机是轻载步长可以加大到50缩短启动时间。减速段一般也是对称的但更可靠的做法是测量剩余脉冲数当剩余步数小于当前ARR的某个比例时才开始减速。5.3 闭环步进模块与CoreXY联动验证如果外接的是闭环步进驱动器通常驱动器会返回一个编码器脉冲信号用于确认实际到位。把这路反馈接到STM32的一个外部中断引脚上在中断里对比反馈脉冲数和下发脉冲数差值超过阈值就停机报警。这个资源虽然没有直接给出闭环代码但预留的脉冲计数变量正好用得上你只需要在中断里比较motor1_steps与目标值即可。最后的验证技巧是搭建一个最小测试台两个电机都装上同步轮用细绳交叉连成一个类似CoreXY的框架然后发1 100 2000和2 100 2000两个电机会朝相反方向转让打印头沿X轴移动。再用示波器分别抓两个STEP引脚应该能看到完全相同的频率和相位差。如果相位差恒定不变说明两路脉冲定时器的时钟和分频完全对齐可以放心跑更高的速度如果相位差有漂移就要查两个定时器的TIM_PrescalerConfig初始化顺序确保它们在同一时间点启停。本文还有配套的精品资源点击获取