基于STM32F103的4路步进电机控制:PWM+DMA与加减速实战 简介STM32F103四路步进电机控制完整工程面向嵌入式学习者和电机控制开发者解决多路电机精确调速与定位的实际需求。工程共236个文件以C源码、H头文件及编译中间文件为主包含Keil工程配置、可烧录hex文件、Libraries硬件驱动库和User用户逻辑并配有Doc文档辅助理解系统设计。压缩包8.38MB结构清晰适合直接导入工程进行二次开发。项目展示了时钟、GPIO、定时器与串口中断的配置方法自定义通信协议可解析正转、反转、复位和位置移动指令并通过脉冲序列控制四台电机。目前已有2878人学习下载适合希望通过完整实例掌握STM32 HAL/标准库开发及步进电机控制技术的入门与进阶者。 做4轴运动控制的时候很多不经意的细节会突然跳出来砸你一下。如果你只是点动过一个步进电机会觉得STM32F103这种芯片性能已经溢出可当你把第2、第3、第4个电机都挂上去让它们各自按不同速度走指定步数还要在启动和停止时不丢步麻烦就开始排队出现了。这篇文章记录的就是基于STM32F103的4路步进电机控制完整程序从定时器资源怎么分配、PWMDMA方案怎么选、加减速表怎么生成到四路联调时遇到的各种怪问题都会讲到。想拿来做小型雕刻机、3D打印机、多轴云台或者自动化小设备的朋友可以直接把这里的思路拿走用。1. 为什么是STM32F103四路电机控制的资源账本1.1 定时器资源盘点4路脉冲刚好卡在F103的甜点上步进电机控制的核心是“脉冲即指令”。驱动器收到一个脉冲就走一步收到的脉冲频率越高电机转得越快。所以每一路电机稳定地需要一路PWM输出。F103的定时器资源在这个场景下显得特别“够用又刚好”TIM1是高级定时器TIM2、TIM3、TIM4是通用定时器刚好凑出4个独立的定时器。这里要特别提醒千万不要指望用一个定时器的4个通道去控制4路不同速度的电机。同一个定时器的PWM输出共享同一个ARR周期寄存器所有通道的脉冲频率会被绑死在一起你根本没法让1号电机慢慢转、2号电机快速转。真正要做到4路独立调速每个电机必须占用一个独立定时器。我实际采用的引脚分配是电机1TIM1_CH1PA8电机2TIM2_CH1PA0电机3TIM3_CH1PA6电机4TIM4_CH1PB6方向引脚DIR和使能引脚ENA则用普通GPIO。算下来一个电机至少占用3个IOSTEP由PWM引脚消化掉4路就是4个PWM引脚加8个普通GPIOF103的IO数量完全撑得住。如果你想把所有PWM都放在PA0~PA3一组那就会撞上我前面说的问题它们大多是同一个定时器TIM2的不同通道做不到独立变速。1.2 最小系统板上容易翻车的三个点“STM32F103最小系统”这几个字看上去很简单但真正裸板跑起来问题往往出在最不起眼的地方。第一是电源去耦。芯片附近的VDD和VDDA引脚旁边必须放0.1uF陶瓷电容而且距离引脚越近越好。很多自制最小系统板一上电定时器就疯狂跑飞查半天发现是去耦电容没贴或者距离太远。第二是BOOT0必须接低电平让芯片从Flash启动。否则一上电就进串口ISP下载模式程序根本不跑。用串口烧录的板子还要注意BOOT1的跳线用SWD调试时这两个引脚倒是没那么敏感。第三是晶振的布局。STM32F103默认使用8MHz外部晶振通过PLL倍频到72MHz系统时钟。晶振两个引脚上接的负载电容不是摆设布局不好会导致起振不稳进而影响PWM频率精度。对步进电机来说PWM频率就是速度频率不稳等于速度不准单独用内部HSI振荡器也能跑但频率精度和温漂摆在那里除非是做低成本大批量产品否则不建议一开始就省这颗晶振。1.3 驱动板选型和电源设计90%的丢步问题其实在硬件驱动板的选择我推荐A4988、DRV8825或者TB6600具体看电机电流。A4988适合小电流NEMA17DRV8825耐压更高TB6600就属于工业味比较重的选择。这些驱动板上的STEP、DIR、ENA输入一般都能兼容3.3V逻辑但有个老生常谈又总有人踩的坑驱动板的电源地和单片机的地必须可靠地连在一起。光连信号线不连地脉冲根本识别不了。另外要分清“逻辑电源”和“电机电源”。STM32F103一般3.3V供电驱动板逻辑部分5V电机电源那一端则根据驱动板要求往往要12V甚至24V。四路电机同时启动时电流波动非常大如果电机电源和逻辑电源混用一个瞬时压降就能让单片机复位表现就是“程序明明在跑电机突然全停或者乱走”。我最后是给每一路驱动模块的电机电源入口都加了470uF电解电容并在靠近驱动板位置并联0.1uF高频电容问题基本消失。2. 软件方案选型PWMDMA是怎么治好了我的脉冲抖动2.1 先放弃“软件延时发脉冲”网上很多入门教程教的是GPIO翻转延时一个脉冲一个脉冲地发。这个方法控制1路电机、速度也不快的时候能用但一旦上4路CPU全被延时吃掉什么都干不了。更致命的是延时函数稍微被中断打断脉冲间隔就会抖动电机声音会变得毛躁速度不均匀。定时器中断翻转IO是第二层方案。相比纯延时脉冲频率由硬件时基决定稳定性好了不少。但4路电机的脉冲频率如果都到几十千赫每路都需要频繁进入中断去翻转引脚和计数中断服务函数的压力会迅速堆积实际上每一路都会有微小的相位偏移和延迟多轴联动时尤其是低速段能听到明显的步进声不均匀。2.2 PWMDMA硬件干粗活CPU干细活最终我采用的是每个定时器输出PWM定时器的更新事件触发DMA搬运数据动态改变定时器周期值。DMA干了一个关键的活它让“改频率”这件事变成硬件自动完成的而不是软件在中断里去改寄存器。你可以这样理解DMA就是一个专门的搬运工。它在内存里放了一张“频率表”每次定时器更新事件发生DMA就自动把一个数搬到定时器的ARR寄存器里ARR变了PWM频率就变了。搬运全程不需要CPU参与CPU只需要在DMA把所有数据都搬完之后才被“DMA传输完成中断”叫醒处理下一次任务。这种方式最直接的好处是脉冲极其均匀。步进电机对脉冲间隔的抖动很敏感抖得厉害就会产生噪音、共振甚至丢步。只要定时器时钟稳定PWM输出每个周期的误差都在一个时钟周期以内这是任何软件翻转方案都比不了的。我用的典型配置代码如下标准外设库老项目用库函数的好处就是结构清晰、改动直观/* 以一路电机为例TIM2_CH1 - PA0 */ void Motor_PWM_Init(uint16_t period) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period period; TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_Pulse period / 2; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OC1Init(TIM2, TIM_OCInitStructure); }ARR的初值决定了启动频率之后DMA会把这个值不断改成运行频率表中的值。2.3 脉冲数怎么精确控制DMA传输长度就是脉冲个数步进电机每收到一个脉冲走一步所以“走多少步”最终要落在“输出多少个脉冲”上。用PWMDMA时一个很自然的做法是把DMA的传输长度设为需要的脉冲数并让DMA在普通模式非循环下工作数据搬运完就停止在DMA传输完成中断里关闭定时器PWM输出。这里有一个细节ARR寄存器是16位的DMA搬运的是16位数据所以频率表用uint16_t数组。DMA每次搬运一个数据需要占用一个定时器更新周期DMA传输的总次数恰好对应PWM脉冲数。如果你需要输出5000个脉冲就设置DMA_BufferSize为5000DMA传输完5000次更新事件后进入中断关PWM。这个方案在低速和中高速场景下都非常准。内存占用方面要注意一下如果每次都把整张频率表和步数绑在一起遇到几万步的长行程数组会很大。我的做法是把加速段、匀速段、减速段拆成三段短表三段分别用DMA排队搬运而不是把完整行程的每一个脉冲周期都存进内存。2.4 DMA通道怎么分配F103的DMA1一共有7个通道4个定时器的更新事件分别占用其中4个即可剩下还能给串口留2个资源刚刚好。例如TIM1_UPDMA1_CH6TIM2_UPDMA1_CH7TIM3_UPDMA1_CH3TIM4_UPDMA1_CH1这是我翻着参考手册一个个对照后确定的组合初始化时别再跟串口的DMA通道冲突就行。如果只是做4路步进电机控制这个分配已经非常宽松了。3. 完整程序架构与核心代码解析3.1 软件资源分配表写4路控制程序前一定要先画一张“资源分配表”否则后面引脚一多保证乱。电机序号脉冲PWM方向DIR使能ENA对应定时器DMA通道电机1PA8 / TIM1_CH1PB0PB1DMA1_CH6电机2PA0 / TIM2_CH1PB2PB10DMA1_CH7电机3PA6 / TIM3_CH1PB3PB4DMA1_CH3电机4PB6 / TIM4_CH1PB5PB7DMA1_CH1方向引脚和使能引脚其实有很大弹性原则是避开调试口和晶振引脚。PA13/PA14是SWD调试口尽量别占用不然每次下载程序都要折腾。3.2 用结构体管理4路电机状态4路电机的控制逻辑完全一样只是定时器、GPIO、DMA通道不同。这时候千万别把代码复制4份用结构体把每路电机的“资源句柄”和“运动状态”包起来后面扩展功能会舒服非常多。typedef struct { TIM_TypeDef *tim; uint8_t tim_channel; uint8_t dma_channel; DMA_Channel_TypeDef *dma_stream; GPIO_TypeDef *dir_port; uint16_t dir_pin; GPIO_TypeDef *ena_port; uint16_t ena_pin; uint16_t *speedTable; uint16_t tableLength; uint32_t totalSteps; uint8_t busy; } Motor_TypeDef;这样描述一个电机所需的全部状态都在一个结构体里。初始化时填好每路电机对应的定时器、GPIO、DMA信息然后所有控制逻辑只要接收Motor_TypeDef指针就能工作。3.3 DMA初始化和运动启动接口初始化DMA时有一个很容易踩的坑DMA_PeripheralBaseAddr必须指向定时器的ARR寄存器地址而不是CCR。因为你要改的是PWM频率频率由ARR决定。CCR决定的是占空比步进电机驱动只关心脉冲频率和方向占空比固定50%就够了。void Motor_Start(Motor_TypeDef *motor, uint16_t *table, uint16_t length) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(motor-dma_stream); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)motor-tim-ARR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)table; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize length; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(motor-dma_stream, DMA_InitStructure); DMA_Cmd(motor-dma_stream, ENABLE); TIM_DMACmd(motor-tim, TIM_DMA_Update, ENABLE); TIM_Cmd(motor-tim, ENABLE); }DMA传输完成中断里要做的三件事停掉定时器TIM_Cmd关闭、关掉DMA请求、清掉busy标志。之后主循环就可以处理下一段运动指令了。3.4 方向切换的时机问题方向信号的切换必须在脉冲发送之前完成不能在PWM输出过程中直接翻转。芯片引脚电平翻转虽然快但驱动器内部对方向信号建立时间是有要求的一般脉冲信号到来前需要提前几个微秒。我的做法是Motor_Start之前先把DIR引脚设置好然后稍微延时再打开定时器。这十几微秒的延时换来的是长期稳定性和电机寿命值得。4. 加减速曲线不让电机丢步的数学与工程妥协4.1 失步的物理本质为什么直接给最高频率会“咔咔咔”步进电机本质上是一个“同步”电机它的转子要跟随定子磁场一步一步前进。如果脉冲频率突然给得很高磁场转速跑到转子跟不上转子就会失步轻则啸叫重则彻底丢位置。失步的特点是控制端完全不知道因为步进电机是开环控制脉冲发出去了电机实际走了多少步没有反馈。避免失步的最好方法是加加减速过程启动时频率低一点然后逐步加速到目标速度停止时反过来先降速再停下来。从力学角度理解频率就是速度频率变化率就是加速度。F103做加减速完全够用问题在于怎么高效地算。4.2 梯形加减速用查表代替实时计算最简单实用的加减速模型是梯形加减速加速过程频率线性上升匀速过程频率不变减速过程线性下降。关键参数是加速段步数、匀速段步数、减速段步数和最高频率。在F103这种72MHz主频的MCU上我建议直接把加减速表提前算好运行时查表不要实时计算。一个典型的加速表是这样生成的for (i 0; i accel_steps; i) { freq start_freq (target_freq - start_freq) * i / accel_steps; speedTable[i] (uint16_t)(72000000UL / freq); }这里的ARR值就是72MHz除以目标频率。实际项目中可以按整数运算做近似误差控制在0.1%以内。加速段结束、进入匀速段时DMA再搬运匀速段对应的同一个ARR值数组。到减速段时再从最高频率逐渐回到起始频率。整个过程就是一个“频率随时间变化的曲线”DMA按节奏把表中的值逐个喂给ARR寄存器。4.3 更平滑的S曲线不用FFT也能让启停更柔顺梯形加减速在加速度突变点从0突跳到一个常数再突跳回0会产生机械冲击电机震动明显。S曲线加减速更进一步让加速度从0开始平滑上升再平滑下降速度曲线变成S形。听起来复杂但在F103上完全可以用“查归一化S表”的方式实现不用实时算曲线函数。思路是在内存里放一张S形系数表例如256个点的0到1之间的归一化增量。启动时把S表某一区间映射到加速段步数上每个步进周期从S表取系数再乘以目标频率和起始频率的差值得到当前频率。实际工程上我一般只用16到32个S形系数点分段线性逼近就够平滑了过度追求点数的收益很小DMA搬运压力还变大。一句话总结低速段加减速周期可以短高速段加减速周期必须长。为什么因为速度越高同样的加速度变化带来的频率变化百分比更小但转子惯量带来的滞后更大所以高速时把加减速多铺一点步数丢步概率明显下降。5. 四路联调排错实录抖动、丢步、异常发热5.1 四路中总有“一路跑偏”先查电源而不是程序我第一版程序烧进去之后电机1、2、3都跑得好好的只有电机4总是走不准位置。我用逻辑分析仪对比了4路PWM输出波形和数量都对说明程序逻辑没问题。最后用万用表一量电机4的驱动板供电端子只有7V多一点而其他几路都在12V左右。原因是我从电源适配器到驱动板之间用了一段又细又长的杜邦线电机启动电流一上来线缆压降直接把供电电压拖垮驱动板频繁欠压丢步自然不可避免。后来把电源线换成粗短的多股软线每一个驱动板都直接从主电源端子取电故障消失。排错时记住一条固定链路先看电源电压在启动瞬间有没有跌落再看PWM波形是否正常最后才查软件配置。90%的丢步都不在代码上。5.2 电机“嗡嗡嗡”响但不动多半是PWM频率太低或占空比不对步进电机没反应但驱动器指示灯正常先听声音。听到隐约的高频“嗡嗡嗡”说明STEP信号确实进去了大概率是脉冲频率太低驱动器识别不了或者PWM占空比不是50%。我做初始化时曾经把TIM_Pulse设成了period/3结果占空比严重不对称驱动板对最小脉宽要求不满足电机纹丝不动。这类问题用示波器看PWM波形一眼就能确认。没有示波器的话可以先用一个固定低速值手动翻转引脚做点动测试排除驱动器参数问题。5.3 方向信号和脉冲信号的“时序钳位”联调中发现一个很隐蔽的问题电机在改变方向时经常发出“咔”的一声偶尔还会多走半步。原因是我的方向信号在PWM停止后立即被翻转而定时器虽然关了定时器输出引脚还残留一个半高不低的电平驱动器误判成一个毛刺脉冲。解决方法是停止PWM之后不要立刻改方向先关定时器、把STEP引脚强制拉低等待50微秒以上再翻转DIR。用现在主流的工业驱动器思路来理解就是对“方向建立时间”和“脉冲禁止时间”做软件钳位。驱动器的数据手册里一般会写这两个时间参数查一下你的驱动板按参数留余量就行。5.4 四路同时跑长行程时DMA传输完成中断要“轻”DMA传输完成中断里最忌讳做复杂运算。我一开始在中断里直接计算下一段速度表并启动下一次DMA结果偶尔出现中断嵌套时间长、定时器又被重新触发的问题。后来改成DMA中断里只挂标志位把“下一段运动准备”放到主循环状态机里处理。主循环在4路都空闲的间隙去做这件事完全够快中断里永远只做最少的操作。6. 从Demo到产品还能怎么扩展这套系统6.1 给F103搭配FreeRTOS让运动控制变成任务调度四路电机的程序全部写在主循环里一旦后续要加按键扫描、串口通信、屏幕刷新主循环的节奏很容易乱。移植FreeRTOS之后我把每路电机封装成独立任务任务之间通过队列传递运动指令加减速执行放到DMA驱动层任务只负责“下发指令”和“等待完成”。这样主循环就把运动逻辑和交互逻辑彻底解耦代码结构好看了很多。FreeRTOS上有一件事要特别注意DMA传输完成中断里不要调用任何带阻塞的API。中断服务函数只置位事件标志组任务中等待标志位后再处理下一段运动这样可以避免优先级反转和系统调度抖动影响脉冲输出。6.2 低功耗考虑空闲时让F103进入STOP模式如果这套控制器要做电池供电的便携设备低功耗就绕不开。F103的STOP模式可以在保持RAM数据不丢失的同时把主时钟关掉电流降到一个很小的值。问题是运动控制过程中绝对不能随便进STOP否则PWM输出会突然断掉。我的做法是所有电机都处于空闲状态时先释放DMA和定时器再配置外部中断引脚然后执行PWR_EnterSTOPMode。当外部中断到来时系统重新切换到HSI再切换到PLL恢复72MHz时钟重新初始化定时器继续控制后续脉冲。这个过程中晶振起振时间一般在1到10毫秒级别如果应用上不能接受这个唤醒延迟就老老实实让它跑在IDLE模式别一上来就追求STOP。6.3 掉电保存电机位置Flash和备份寄存器的选择做开环步进控制最怕掉电后位置信息丢失。F103在掉电瞬间通常还有几个毫秒的供电余量可以利用ADC检测主电源电压一旦电压掉到阈值以下就触发外部中断把各电机当前的位置写入备份寄存器。备份寄存器由VBAT供电系统掉电后数据能保留。如果位置数据和运动参数比较多可以写到内部Flash的一个专用扇区但Flash擦写次数有限不能频繁写入备份寄存器则没有擦写寿命问题适合保存频繁变化的小数据。6.4 内部晶振模式省掉晶振的代价是什么低成本产品通常希望把最小系统简化到极致用内部HSI振荡器替代外部8MHz晶振。F103内部HSI是8MHz通过PLL可以倍频到64MHz或者72MHz官方配置流程里只需要把RCC的时钟源从HSE改成HSI再配置PLL即可。代价是HSI的精度一般在±1%左右比晶振的精度差不少而且频率会随温度变化漂移。对步进电机控制来说PWM频率整体偏1%意味着速度偏差但因为是恒定偏差机械上通常可以接受。如果同时要跑USB CDC多串口通信那对频率精度要求就高了USB协议对时钟误差有严格规定此时建议还是保留外部晶振。6.5 加入触屏和多路ADC做一个真正可控的运动平台4路步进电机控制器的潜力远不止“让电机转起来”。后续可以加一块ILI9341之类的3.2寸触摸屏通过串口或FSMC接口连接屏幕上输入目标坐标系统自动换算成各路电机的脉冲数和方向。如果每个轴再接上电位器或者编码器用F103的4路ADC就能实时读取位置反馈构建一个简单的半闭环系统。这样整套系统就从纯粹的控制器变成了有交互界面、有反馈感知的小型运动平台。最后再分享一个我个人的体会做多路电机控制不要试图在中断里做太多“聪明”的事。硬件定时器负责精确输出DMA负责数据搬运CPU只负责调度和状态管理这个分工一旦定下来后面所有扩展都会变得很顺。F103虽然是一颗老芯片但把它的定时器和DMA资源用透做一套可靠的4路步进电机控制器绰绰有余关键是每一步都要想清楚“为什么这样做”。本文还有配套的精品资源点击获取