基于STM32F103的4路步进电机控制:PWM+DMA与加减速实战
2026/9/2 9:48:01 网站建设 项目流程

简介:STM32F103四路步进电机控制完整工程,面向嵌入式学习者和电机控制开发者,解决多路电机精确调速与定位的实际需求。工程共236个文件,以C源码、H头文件及编译中间文件为主,包含Keil工程配置、可烧录hex文件、Libraries硬件驱动库和User用户逻辑,并配有Doc文档辅助理解系统设计。压缩包8.38MB,结构清晰,适合直接导入工程进行二次开发。项目展示了时钟、GPIO、定时器与串口中断的配置方法,自定义通信协议可解析正转、反转、复位和位置移动指令,并通过脉冲序列控制四台电机。目前已有2878人学习下载,适合希望通过完整实例掌握STM32 HAL/标准库开发及步进电机控制技术的入门与进阶者。 做4轴运动控制的时候,很多不经意的细节会突然跳出来砸你一下。如果你只是点动过一个步进电机,会觉得STM32F103这种芯片性能已经溢出;可当你把第2、第3、第4个电机都挂上去,让它们各自按不同速度走指定步数,还要在启动和停止时不丢步,麻烦就开始排队出现了。这篇文章记录的就是基于STM32F103的4路步进电机控制完整程序,从定时器资源怎么分配、PWM+DMA方案怎么选、加减速表怎么生成,到四路联调时遇到的各种怪问题,都会讲到。想拿来做小型雕刻机、3D打印机、多轴云台或者自动化小设备的朋友,可以直接把这里的思路拿走用。

1. 为什么是STM32F103:四路电机控制的资源账本

1.1 定时器资源盘点:4路脉冲刚好卡在F103的甜点上

步进电机控制的核心是“脉冲即指令”。驱动器收到一个脉冲就走一步,收到的脉冲频率越高,电机转得越快。所以,每一路电机稳定地需要一路PWM输出。

F103的定时器资源在这个场景下显得特别“够用又刚好”:TIM1是高级定时器,TIM2、TIM3、TIM4是通用定时器,刚好凑出4个独立的定时器。这里要特别提醒:千万不要指望用一个定时器的4个通道去控制4路不同速度的电机。同一个定时器的PWM输出共享同一个ARR周期寄存器,所有通道的脉冲频率会被绑死在一起,你根本没法让1号电机慢慢转、2号电机快速转。真正要做到4路独立调速,每个电机必须占用一个独立定时器。

我实际采用的引脚分配是:

  • 电机1:TIM1_CH1,PA8
  • 电机2:TIM2_CH1,PA0
  • 电机3:TIM3_CH1,PA6
  • 电机4:TIM4_CH1,PB6

方向引脚DIR和使能引脚ENA则用普通GPIO。算下来一个电机至少占用3个IO(STEP由PWM引脚消化掉),4路就是4个PWM引脚加8个普通GPIO,F103的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适合小电流NEMA17,DRV8825耐压更高,TB6600就属于工业味比较重的选择。这些驱动板上的STEP、DIR、ENA输入一般都能兼容3.3V逻辑,但有个老生常谈又总有人踩的坑:驱动板的电源地和单片机的地必须可靠地连在一起。光连信号线不连地,脉冲根本识别不了。

另外要分清“逻辑电源”和“电机电源”。STM32F103一般3.3V供电,驱动板逻辑部分5V,电机电源那一端则根据驱动板要求往往要12V甚至24V。四路电机同时启动时电流波动非常大,如果电机电源和逻辑电源混用,一个瞬时压降就能让单片机复位,表现就是“程序明明在跑,电机突然全停或者乱走”。我最后是给每一路驱动模块的电机电源入口都加了470uF电解电容,并在靠近驱动板位置并联0.1uF高频电容,问题基本消失。

2. 软件方案选型:PWM+DMA是怎么治好了我的脉冲抖动

2.1 先放弃“软件延时发脉冲”

网上很多入门教程教的是GPIO翻转+延时,一个脉冲一个脉冲地发。这个方法控制1路电机、速度也不快的时候能用,但一旦上4路,CPU全被延时吃掉,什么都干不了。更致命的是,延时函数稍微被中断打断,脉冲间隔就会抖动,电机声音会变得毛躁,速度不均匀。

定时器中断翻转IO是第二层方案。相比纯延时,脉冲频率由硬件时基决定,稳定性好了不少。但4路电机的脉冲频率如果都到几十千赫,每路都需要频繁进入中断去翻转引脚和计数,中断服务函数的压力会迅速堆积,实际上每一路都会有微小的相位偏移和延迟,多轴联动时尤其是低速段能听到明显的步进声不均匀。

2.2 PWM+DMA:硬件干粗活,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传输长度就是脉冲个数

步进电机每收到一个脉冲走一步,所以“走多少步”最终要落在“输出多少个脉冲”上。用PWM+DMA时,一个很自然的做法是把DMA的传输长度设为需要的脉冲数,并让DMA在普通模式(非循环)下工作,数据搬运完就停止,在DMA传输完成中断里关闭定时器PWM输出。

这里有一个细节:ARR寄存器是16位的,DMA搬运的是16位数据,所以频率表用uint16_t数组。DMA每次搬运一个数据需要占用一个定时器更新周期,DMA传输的总次数恰好对应PWM脉冲数。如果你需要输出5000个脉冲,就设置DMA_BufferSize为5000,DMA传输完5000次更新事件后进入中断,关PWM。这个方案在低速和中高速场景下都非常准。

内存占用方面要注意一下:如果每次都把整张频率表和步数绑在一起,遇到几万步的长行程,数组会很大。我的做法是把加速段、匀速段、减速段拆成三段短表,三段分别用DMA排队搬运,而不是把完整行程的每一个脉冲周期都存进内存。

2.4 DMA通道怎么分配

F103的DMA1一共有7个通道,4个定时器的更新事件分别占用其中4个即可,剩下还能给串口留2个,资源刚刚好。例如:

  • TIM1_UP:DMA1_CH6
  • TIM2_UP:DMA1_CH7
  • TIM3_UP:DMA1_CH3
  • TIM4_UP:DMA1_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路步进电机控制器绰绰有余,关键是每一步都要想清楚“为什么这样做”。

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

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

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

立即咨询