STM32步进电机控制例程:定时器脉冲、串口协议与蓝牙遥控

发布时间:2026/11/5 8:11:13
STM32步进电机控制例程:定时器脉冲、串口协议与蓝牙遥控

简介:该资源是一套面向嵌入式初学者与中级开发者的 STM32 步进电机控制实例工程,重点解决如何用手机 APP 经蓝牙串口下发指令、由 STM32 解析并驱动电机精确转动的问题,涉及串口通讯、定时器脉冲输出与电机状态管理等实用技能。压缩包共 479 个文件,以 c 源文件、h 头文件、o 编译中间文件及 d 依赖文件为主体,另有 crf 浏览索引、chm 帮助文档与 Keil 工程文件(uvproj、uvopt、sct),整体约 71.95MB,构成一套可直接打开编译的完整工程目录。已有 841 人学习下载,说明其在电机控制与无线通讯结合的练手场景中具备一定参考价值。工程按模块划分主函数、蓝牙通信、步进电机驱动、定时器配置与命令解析等部分,读者可据此理清脉冲频率调节速度、中断周期控制步距的实现思路,并借鉴命令协议解析方式,迁移到智能家居、自动化设备等远程控制项目中。

1. 手机点一下,电机走 200 步:这套 stm32 步进电机例程拆开是什么样

需求其实很朴素:手机 APP 上点一下「正转 200 步」,电机就准准地走 200 步,中途能改速度、能急停。这套 stm32 步进电机例程就是奔着这个场景写的——MCU 侧用定时器生成脉冲驱动步进驱动器,串口接一块蓝牙透传模块,手机 APP 把指令透传到串口,固件里再把收到的字节翻译成方向、步数和速度。它不适合拿来做多轴联动或者闭环伺服,但想在一两天内把「手机控电机」这条链路跑通,拿它起步是划算的。真正耗时间的从来不是写脉冲,而是定时器频率与细分步数的换算、串口通讯里半包粘包的处理、以及电机一上电就抖着不转的排查。下面按硬件、脉冲、协议、翻车四个层次一层层拆。

2. 把线接对:脉冲方向信号、驱动器与蓝牙透传模块

2.1 驱动器只认三样东西:脉冲、方向、使能

不管是 TB6600 那一类铁壳驱动器,还是 A4988、DRV8825 这种小板子,对外接口的逻辑都一样:STEP/PUL 每来一个有效边沿,电机内部就走一个细分步;DIR 决定这一步步是正转还是反转;ENA 控制输出级通断。换句话说,STM32 要做的事情只有一件——按照你想要的节奏,把电平稳定地翻转出脉冲来,方向和使能只是两个静态电平。

有两组时间参数必须先弄清,否则后面调不出来会一直怀疑代码。第一组是脉宽:驱动器手册里通常写「最小脉宽 2.5us」,实际留到 5us 以上更稳,太窄会直接被光耦输入端滤掉。第二组是方向建立时间:DIR 电平改变之后,一般要等几微秒才能发第一个脉冲,中间夹得太紧,驱动器可能按上一拍的方向走一步,表现出来就是「偶尔丢一步、位置漂一点」,这种玄学现象在示波器上抓一次就明白。

还有一点很多人忽略:驱动器上标的「细分」,是驱动器内部的事,跟你的软件没关系。软件只需要知道「发多少个脉冲 = 走多少距离」。比如 1.8° 的两相步进,整步一圈 200 步,驱动器设 16 细分,那转一圈就是 3200 个脉冲。软件里的步数换算系数,就锁死在这个 3200 上。

如果是 28BYJ-48 配 ULN2003 那种五线四相的小电机,情况完全不同:它没有脉冲方向接口,得按四拍或八拍的相序去轮流拉高四个 IO。这种电机更适合做演示,扭矩小、转速低,做位置控制精度也不够。本文走的是脉冲+方向这条路,接的是带细分拨码的驱动器。

顺带说一句为什么不用 GPIO 翻转加 HAL_Delay 生成脉冲。延时函数的精度在毫秒级,抖动几百微秒很正常,电机走起来就会一顿一顿;而且整个 CPU 被死循环占住,串口收数据只能排队等着,指令一多就丢。定时器是硬件计数,抖动在纳秒量级,才是干这活的工具。

2.2 接线表:哪几根能随便挑,哪几根不能

信号STM32 引脚接到说明
STEP / PULPA6驱动器 PUL-由定时器通道产生,可换其他定时器引脚
DIRPB0驱动器 DIR-普通推挽输出,方向建立时间靠软件保证
ENAPB1驱动器 ENA-注意驱动器是高电平使能还是低电平使能
USART1_TXPA9蓝牙模块 RXDTX 接 RX,交叉
USART1_RXPA10蓝牙模块 TXD同上
GNDGND驱动器 GND、蓝牙 GND必须共地,这条最容易漏

驱动器输入端是光耦隔离的,接法分共阳和共阴两种。我一般用共阳:PUL+、DIR+、ENA+ 三根并起来接到驱动器自带的 5V 逻辑电源上,PUL-、DIR-、ENA- 分别接 MCU 引脚,MCU 输出低电平时光耦导通。这么接的好处是电流方向是灌入 MCU 引脚,而 STM32 的灌电流能力比拉电流更宽裕,也不容易因为 3.3V 摆幅不够而触发不了。

共阴接法(PUL- 接地,PUL+ 接 MCU 引脚)也能用,但 3.3V 减去光耦正向压降只剩两伏出头,限流电阻上分到的电流偏小,有些驱动器会出现「低速能走、高速丢步」的怪毛病。真碰上这种,中间加一级 74HC245 或者用三极管做电平转换就正常了。

蓝牙模块那边更简单:VCC 接 3.3V(很多模块标 5V 兼容,但 3.3V 直连最省事)、GND 共地、TXD 接 PA10、RXD 接 PA9。模块出厂默认波特率基本都是 9600,8 位数据位、1 位停止位、无校验,跟 CubeMX 里 USART1 的默认参数对得上,先别急着改。

提示:蓝牙模块和驱动器如果各自用不同的电源供电,一定把这几个 GND 连到一起。共地没做,表现是「串口能收到乱码」或者「电机偶尔抽一下」,很容易被误判成代码问题。

2.3 上电前的三步静态自检

写代码之前花十分钟做完这三件事,能省掉后面一大半的怀疑人生。

第一步,断开电机,只上控制电,用万用表量 MCU 的 3.3V 和驱动器的 5V 逻辑电源,确认都正常。第二步,量电机线:两相步进有四根线,同一相的两根之间电阻很小(几欧姆),不同相之间是开路的。线序搞反的典型症状是电机嗡嗡响但不转,或者转起来没劲。第三步,串口回环:把蓝牙模块的 TXD 和 RXD 临时短接,手机连上模块后发什么就应该收到什么,这一步能确认模块本身、配对、以及手机 APP 的收发通道都是通的。

这三步都过了再上电机电源,出问题时排查范围能缩小一大半。

3. 定时器发脉冲:把 PSC、ARR 和步/秒算清楚

3.1 参数推导:从 72MHz 到「每秒走多少步」

STM32F103 这类芯片,主频 72MHz,挂在 APB1 上的 TIM2/3/4 因为 APB1 分频系数不为 1,定时器时钟会被倍频回 72MHz。这一点在 CubeMX 里能直接看到,但手写代码时容易忘。

定时器的计数频率由预分频器决定:f_cnt = 72MHz / (PSC + 1)。取 PSC = 71,计数频率就是 1MHz,也就是每个计数 1 微秒,好算。

接下来是重点:如果用「更新中断里翻转 IO」的方式生成脉冲,一个完整的步进脉冲需要高电平和低电平各一次,也就是两次中断。所以:

中断频率 = 1MHz / (ARR + 1) 步进频率 = 中断频率 / 2 = 500000 / (ARR + 1)

反过来,给定目标速度求 ARR:

ARR = 500000 / 目标步每秒 - 1
目标速度(步/秒)中断频率ARR(PSC=71)
100200 Hz4999
5001 kHz999
10002 kHz499
20004 kHz249
40008 kHz124

TIM4 在 F103 上是 16 位定时器,ARR 最大 65535,代入公式能算出这套配置的最低速度大约是每秒 7.6 步。如果项目需要更慢的动作,就得把 PSC 调大,或者改成「一个脉冲一次中断、只在一个边沿翻转」的写法。

另外要意识到,中断频率是步进频率的两倍。跑到 4000 步/秒时,中断频率已经 8kHz,每次中断进出加上 HAL 的通用回调开销,占用的 CPU 时间开始肉眼可见。这也是后面要上加减速和 PWM 硬件输出的原因。

3.2 中断翻转式脉冲生成器

先看头文件,把引脚和接口固定下来,后面调参只改这一处:

/* stepper.h */ #ifndef __STEPPER_H #define __STEPPER_H #include "main.h" #define STEP_PORT GPIOA #define STEP_PIN GPIO_PIN_6 /* 接驱动器 PUL- */ #define DIR_PORT GPIOB #define DIR_PIN GPIO_PIN_0 /* 接驱动器 DIR- */ #define EN_PORT GPIOB #define EN_PIN GPIO_PIN_1 /* 接驱动器 ENA- */ void Stepper_Init(void); void Stepper_Move(int32_t steps, uint16_t sps); /* steps 带符号 */ void Stepper_Stop(void); int32_t Stepper_GetRemain(void); uint8_t Stepper_IsRunning(void); #endif

实现部分,核心是速度换算和中断回调两个函数:

/* stepper.c */ #include "stepper.h" #include <stdlib.h> extern TIM_HandleTypeDef htim4; static volatile int32_t s_remain = 0; /* 剩余步数 */ static volatile uint8_t s_running = 0; static volatile uint8_t s_level = 0; /* 当前 STEP 电平 */ /* 步/秒 转 ARR:计数时钟 1MHz,每步两次中断 */ static uint32_t sps_to_arr(uint16_t sps) { uint32_t arr; if (sps < 8) sps = 8; /* 16 位定时器的速度下限 */ arr = (500000UL / sps) - 1; /* 等价于 1e6/(2*sps)-1 */ if (arr > 65535UL) arr = 65535UL; return arr; } void Stepper_Init(void) { /* 共阳接法:MCU 输出低电平有效,这里先给高电平让输出级关断 */ HAL_GPIO_WritePin(EN_PORT, EN_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(STEP_PORT, STEP_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(DIR_PORT, DIR_PIN, GPIO_PIN_RESET); } void Stepper_Move(int32_t steps, uint16_t sps) { if (steps == 0) return; Stepper_Stop(); /* 先刹车,防止两条指令叠加 */ HAL_GPIO_WritePin(DIR_PORT, DIR_PIN, (steps > 0) ? GPIO_PIN_RESET : GPIO_PIN_SET); /* 共阳:低=正转,按实际调 */ __HAL_TIM_SET_AUTORELOAD(&htim4, sps_to_arr(sps)); __HAL_TIM_SET_COUNTER(&htim4, 0); s_remain = (steps > 0) ? steps : -steps; s_level = 0; s_running = 1; HAL_GPIO_WritePin(EN_PORT, EN_PIN, GPIO_PIN_RESET); /* 低电平使能 */ HAL_TIM_Base_Start_IT(&htim4); } void Stepper_Stop(void) { HAL_TIM_Base_Stop_IT(&htim4); s_running = 0; s_remain = 0; HAL_GPIO_WritePin(STEP_PORT, STEP_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(EN_PORT, EN_PIN, GPIO_PIN_SET); /* 释放电机,降低发热 */ } int32_t Stepper_GetRemain(void) { return s_remain; } uint8_t Stepper_IsRunning(void) { return s_running; } /* TIM4 更新中断:每个中断翻转一次电平,上升沿记一步 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM4) return; if (!s_running) { HAL_TIM_Base_Stop_IT(&htim4); return; } s_level ^= 1; HAL_GPIO_WritePin(STEP_PORT, STEP_PIN, s_level ? GPIO_PIN_SET : GPIO_PIN_RESET); if (s_level) { /* 只在上升沿计数 */ s_remain--; if (s_remain <= 0) { HAL_TIM_Base_Stop_IT(&htim4); /* 走完立刻停,避免多送脉冲 */ s_running = 0; HAL_GPIO_WritePin(STEP_PORT, STEP_PIN, GPIO_PIN_RESET); } } }

几个参数和逻辑细节值得单独说。sps_to_arr里保留了 8 的下限,是因为再慢就超出 16 位 ARR 的量程,会得到一个完全错误的周期。Stepper_Move开头先调Stepper_Stop,是因为手机上很容易连点,两条 M 指令间隔几十毫秒到达,如果不刹车,第一段的剩余步数会被第二段直接覆盖,位置就乱了。

__HAL_TIM_SET_AUTORELOAD改的是预装载寄存器,新值在下一个更新事件才生效,所以不会出现脉冲周期被从中间截断的情况——这个特性正好被后面的加减速利用。Stepper_Stop里最后把 ENA 拉回高电平释放电机,是因为步进电机在使能状态下线圈一直通着电流,静止时也会发热,长时间通电温度能到烫手的程度。

初始化部分在 CubeMX 里配置:TIM4 选内部时钟,PSC = 71,ARR 先随便填 999,计数模式向上,不需要开通道输出(因为是自己翻转 IO)。NVIC 里把 TIM4 的更新中断打开。USART1 配 9600-8-N-1,打开接收中断。

3.3 加减速:超过 1000 步/秒就必须做

步进电机有一个绕不过去的物理限制:启动频率。电机带着负载从静止直接切到 2000 步/秒,转子跟不上旋转磁场,表现出来的就是原地抖动、发出「哒哒」声但不转——这是新手最容易怀疑「是不是代码写错了」的时刻,其实代码没错,是物理不允许。

常见的处理办法是梯形加减速:起步时低速,按设定的加速度一点点把脉冲频率加上去,快到目标位置时再对称地降下来。放在这套代码里,就是每走完一步调用一次速度更新:

/* 每步调用一次,把当前速度往目标速度靠 */ static void accel_update(void) { if (s_cur < s_tgt) { s_cur += s_acc; /* s_acc 单位:步/秒 每步 */ if (s_cur > s_tgt) s_cur = s_tgt; } else if (s_cur > s_tgt) { s_cur -= s_acc; if (s_cur < s_sps_min) s_cur = s_sps_min; } __HAL_TIM_SET_AUTORELOAD(&htim4, sps_to_arr(s_cur)); }

s_acc就是加速度,取值跟负载惯量、电机扭矩强相关,一般从「每秒增加 500 步/秒」这个量级开始试,转起来再往上加。空载的小电机能到 3000 以上,带减速箱或皮带负载的可能只有几百。这里没有一套通用参数,我的习惯是拿一个可调电位器或者串口命令实时改,边转边调,找到不丢步的上限再打七折用。

还有一个更省 CPU 的思路:把 STEP 引脚配成定时器通道的 PWM 输出模式,占空比固定 50%,用硬件产生脉冲,中断只负责数脉冲。这样中断频率直接等于步进频率,省掉一半开销,代价是要额外占用一个通道,而且加减速时改 ARR 同样是下一个周期生效,逻辑上跟现在这套完全一致。项目里步进频率超过 5kHz 的时候,我会直接换成这个方案。

4. 串口协议与行解析:让手机指令不丢不粘

4.1 协议怎么定:先写文本行,再谈二进制帧

蓝牙透传模块本质上就是一根无形的串口线,手机 APP 发什么字节,MCU 就收什么字节,没有分包的概念。串口是字节流,一条指令可能被拆成两次中断收进来(半包),也可能两条指令挤在一次中断里(粘包)。如果按「收到一个字节就当一条指令」去写,手机稍微发快一点就全乱。

协议我一般先用文本行,格式定成:

M,<dir>,<steps>\r\n 例:M,1,200 正转 200 步 V,<sps>\r\n 例:V,800 设置速度 800 步/秒 S\r\n 停止 Q\r\n 查询状态,回 R,<remain>,<running>

选文本而不是二进制帧,理由很实际:调试阶段可以直接用手机上的蓝牙串口助手敲键盘发命令,不用另外写发包工具;抓包看到的也是人能读的字符,一眼就知道是协议错了还是没收到。等项目稳定了再换成带帧头、长度和 CRC 的二进制帧也不迟,那时候难的是协议设计本身,不是调试工具。

行解析的天然优势是以\n作为分界,粘包和半包问题自动消失——收到半截就先存着,等下一个换行符到了再一起处理。要做的只是把字节流缓冲好。

4.2 环形队列 + 行切分:接收侧的两段代码

第一段在中断里,只做一个动作:把字节塞进环形队列,然后立刻重新挂上接收。

/* 串口接收:中断里只搬运,不做任何解析 */ #define RX_BUF_SIZE 128 static volatile uint8_t s_rxbuf[RX_BUF_SIZE]; static volatile uint16_t s_head = 0, s_tail = 0; static uint8_t s_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t next = (s_head + 1) % RX_BUF_SIZE; if (next != s_tail) { /* 队列满则丢弃新字节,保留旧数据 */ s_rxbuf[s_head] = s_byte; s_head = next; } HAL_UART_Receive_IT(&huart1, &s_byte, 1); /* 必须重新挂,否则只收一个字节 */ } }

这段里有两个点必须注意。HAL_UART_Receive_IT是单次接收,回调里不重新挂上,串口就只工作一次,现象是「第一条指令有反应,之后就死了」。队列满时的处理选择丢新字节而不是覆盖旧数据,是因为旧数据可能是半条还没收完的指令,覆盖掉会让解析器收到一条残缺命令,反而更危险。

第二段在主循环里跑,负责切行和分发:

/* 主循环里轮询,把环形队列切成一行一行 */ static char s_line[48]; static uint8_t s_len = 0; void Proto_Poll(void) { while (s_tail != s_head) { char c = (char)s_rxbuf[s_tail]; s_tail = (s_tail + 1) % RX_BUF_SIZE; if (c == '\n' || c == '\r') { if (s_len > 0) { s_line[s_len] = '\0'; Proto_Exec(s_line); s_len = 0; } } else if (s_len < sizeof(s_line) - 1) { s_line[s_len++] = c; } else { s_len = 0; /* 超长行直接丢弃,防止缓冲区溢出 */ } } }

缓冲区大小 48 字节是个经验值:正常指令十几字节,留三倍余量足够,同时又能挡住手机 APP 误发的大段内容。超长就整行丢弃而不是截断,是因为被截断的指令很可能「看起来合法」,会执行出一个错误动作。

4.3 指令分发:把文本翻译成步数和速度

分发用strtok按逗号切开就行,裸机环境不存在线程安全问题:

static uint16_t s_speed = 500; /* 当前速度,步/秒 */ static void Proto_Exec(char *line) { char *tok = strtok(line, ","); if (tok == NULL) return; switch (tok[0]) { case 'M': { /* M,<dir>,<steps> */ char *d = strtok(NULL, ","); char *s = strtok(NULL, ","); if (d == NULL || s == NULL) { Proto_Reply("E,1"); return; } int32_t n = atol(s); if (n <= 0 || n > 100000) { Proto_Reply("E,2"); return; } Stepper_Move((atoi(d) != 0) ? n : -n, s_speed); Proto_Reply("OK"); break; } case 'V': { /* V,<sps> */ char *v = strtok(NULL, ","); if (v == NULL) { Proto_Reply("E,1"); return; } int32_t sps = atol(v); if (sps < 8 || sps > 8000) { Proto_Reply("E,3"); return; } s_speed = (uint16_t)sps; Proto_Reply("OK"); break; } case 'S': Stepper_Stop(); Proto_Reply("OK"); break; case 'Q': Proto_Reply_Status(Stepper_GetRemain(), Stepper_IsRunning()); break; default: Proto_Reply("E,0"); /* 未知指令 */ break; } }

atoi、atol的返回值必须做范围检查,这不是洁癖。手机 APP 的输入框里什么都能输,一个负数步数传进来,Stepper_Move里的取反会得到一个负的剩余步数,中断里的s_remain--永远到不了 0,

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