简介:面向嵌入式初学者与电机控制实践者的STM32步进电机驱动例程,围绕手机APP经串口蓝牙与STM32通讯、远程控制电机正反转与调速展开,涉及串口协议解析、定时器中断/PWM脉冲生成及步进电机状态管理等知识点。压缩包共479个文件、约71.95MB,以c源文件与h头文件为主体,配套编译生成的o、d目标与依赖文件,另有crf、__i等Keil工程中间文件及少量chm帮助文档,构成可直接打开参考的完整工程目录。已有841人学习下载。读者可据此梳理main、蓝牙通信、电机驱动、定时器配置与命令解析等模块的协作关系,理解APP指令到脉冲输出的完整链路,并借鉴其工程组织与调试思路,用于智能家居、自动化设备等短距离无线控制场景的二次开发。
1. 手机点一下,电机转一圈:这条链路比想象中短
步进电机、STM32、串口通讯、定时器,这四个词凑在一起就是一件很具体的小工程:手指在手机屏上点一下“正转十圈”,蓝牙把一帧字节送进单片机,定时器按算好的频率吐出脉冲,驱动器把脉冲放大成绕组电流,电机带着负载一格一格地转过去。难点从来不在某一行代码写得多漂亮,而在这四段之间的换算和时序能不能对得上——脉冲频率推错,转速就不对;定时器配错,脉冲数就差;串口收帧没收干净,指令就会时灵时不灵。这套东西适合做过一点单片机、手上有一块最小系统板和一台步进电机、想把它挂到手机上去的开发者,也适合课程设计、小型工装治具、需要“定量位移”但不追求伺服精度的场景。下面按能直接复现的顺序拆。
2. 硬件链路与参数选型:脉冲当量、细分和电平先定死
先把物理层理清楚,后面所有代码才有意义。步进电机不是“给电就转”的器件,它按脉冲走,来一个脉冲走一个步距角,来得太快它跟不上就直接丢步,连响都不响一声。整条链路是:手机 APP → 蓝牙透传模块 → 单片机串口 → 定时器输出脉冲 → 驱动器 → 电机绕组。任何一环的电气参数没对上,表现出来的现象都像“程序有 bug”,这也是后面排查章要反复用到的一条判断依据。
2.1 电机、驱动器与主控的供电边界
常见的两相混合式步进电机是四线制,两组线圈 A+/A-、B+/B-,步距角 1.8°,整步一圈 200 步。驱动器一侧通常给三个信号:PUL(脉冲)、DIR(方向)、ENA(使能),有些模块把 ENA 省掉或者默认使能。这三根线是弱电信号,驱动器内部一般有光耦隔离,也就是说单片机这边只需要提供很小的电流,但它对脉冲的最小宽度有要求,常见手册里写的是不小于 2.5 μs,保守起见按 5 μs 设计。
供电上最容易翻车的地方是“省一个电源”。电机是感性负载,启停瞬间的电流冲击和反向电动势会把电机电源那条线搅得一塌糊涂,如果单片机跟电机共用同一路 12 V 再降压,脉冲丢失、串口收到乱码、甚至复位都是家常便饭。我一般的做法是:电机一路独立电源(12 V 或 24 V 视电机额定电压而定),单片机一路 5 V(USB 或独立适配器),两路电源的负极在驱动器信号地附近单点接在一起,不要形成环路。共地是必须的,因为 PUL/DIR 是电平信号,没有共同参考点驱动器就分不清高低电平。
注意:先接线后上电,尤其是 DIR 和 PUL,热插拔信号线容易打坏驱动器输入级。
2.2 细分、步距角与脉冲当量:把“转多少度”换算成脉冲数
驱动器面板上的拨码开关设的是细分(microstep),它决定了控制器每发一个脉冲,电机转子实际转多少。整步 200 步/圈,设成 m 细分之后,一圈需要的脉冲数是 200×m。反过来,单个脉冲对应的机械角度就是 1.8°/m。这个换算关系必须写死在代码里,用宏或者配置结构体存着,不要靠脑子里记。
转速和脉冲频率的关系是:n(r/min) = 60 × f_pulse ÷ (200 × m)。反推就是 f_pulse = n × 200 × m ÷ 60。举个具体的:细分 16,想让电机跑 30 r/min,需要 30×200×16÷60 = 1600 Hz 的脉冲。这个数字就是后面配置定时器 ARR 和 PSC 的目标值。
| 细分 m | 每圈脉冲数 | 单脉冲角度 | 1 kHz 脉冲对应转速 |
|---|---|---|---|
| 1 | 200 | 1.8° | 300 r/min |
| 4 | 800 | 0.45° | 75 r/min |
| 8 | 1600 | 0.225° | 37.5 r/min |
| 16 | 3200 | 0.1125° | 18.75 r/min |
| 32 | 6400 | 0.05625° | 9.375 r/min |
细分不是越高越好。细分高,同样转速下需要的脉冲频率成倍上升,单片机定时器和中断的负担跟着涨;而且高细分下每个脉冲对应的力矩变小,抗负载扰动的能力反而不如中低细分。做定位类应用,8 或 16 是常用的折中;单纯要“转得快”,细分 2 到 4 更合适,因为低频共振区被绕过去了。拨码开关设的是几,代码里的 m 就必须是几,两边对不上,走十圈能给你走出九圈半,这个坑后面还会单列。
2.3 蓝牙透传模块的接口:连不上多半不是代码的事
手机和单片机之间用串口透传的蓝牙模块,本质是把蓝牙链路当成一根虚拟的串口线。这类模块(HC-05、HC-06 这一类)对外就四根常用线:VCC、GND、TXD、RXD。接线是交叉的——模块的 TXD 接单片机的 RX,模块的 RXD 接单片机的 TX,接反了现象就是“能连上、能配对、发指令毫无反应”,很多人在这里查半天代码。
电平是第二个坑。模块的供电范围一般写 3.6~6 V,很多人直接给 5 V,模块能工作,但它的 IO 电平未必是 3.3 V;单片机这边如果是 3.3 V 的 IO,模块 TXD 输出 5 V 电平打进来,短时间可能没事,长时间就是玄学故障。规矩的做法是模块也用 3.3 V 供电(如果模块支持),或者在模块 TXD 到单片机 RX 之间串一个分压电阻网络。第一次上电前用万用表量一下模块 TXD 的静态电平,比事后猜要省事。
波特率方面,这类模块出厂默认多数是 9600,配对密码 1234 或 0000,具体看模块说明。串口通讯的波特率必须和单片机侧配置完全一致,包括数据位 8、停止位 1、无校验。如果需要改波特率,模块要先进入 AT 模式(一般是上电时按住按键或者把某个引脚拉高),这一步骤和正常通讯模式是互斥的,很多新手在正常模式下发 AT 指令怎么都不回 OK,就是因为压根没进配置模式。
手机端用通用的蓝牙串口调试 APP 就够了,能扫描、配对、发十六进制或文本都行。这类 APP 通常发文本时会在末尾自动补 \r\n 或者只补 \n,这一点在写解析代码时要提前想好,别到时候只认 \n 结果对方发的是 \r\n,一帧指令永远解析不出来。
3. 定时器发脉冲:从转速公式反推 PSC 和 ARR
定时器是这套东西的心脏。脉冲的频率、数量、什么时候停,全由它决定。两条技术路线:定时器 PWM 输出通道直接吐方波,或者定时器更新中断里手动翻转 IO。前者省 CPU、频率稳,适合中高频;后者灵活、脉冲数好数,低频和超高精度场合更合适。先掌握参数怎么算,再选路线。
3.1 定时器参数反推:PSC 决定刻度,ARR 决定周期
STM32 的定时器时钟来源一般是 72 MHz(F1 系列常见配置),计数时钟 f_cnt = 72 MHz ÷ (PSC+1),一个计数周期就是 1/f_cnt。ARR 是自动重装载值,向上计数模式下从 0 数到 ARR 算一轮,一轮就是一个输出周期(PWM 模式下)。所以:
f_out = 72 000 000 ÷ ((PSC+1) × (ARR+1))
设计顺序建议反过来推:先定想要的脉冲频率 f_out,再选 PSC 让 (ARR+1) 落在一个不太小也不太大的区间。ARR 太小(比如小于 20)占空比调节精度会很差,改速度时频率跳变也会很粗;ARR 太大则每次改频率的响应变慢。经验值是把 ARR 控制在 100~60000 之间。
举几个实数:目标 1 kHz。取 PSC=71,计数时钟 1 MHz,(ARR+1)=1000,ARR=999。目标 1600 Hz:PSC=71,(ARR+1) = 1000000/1600 = 625,ARR=624。目标 50 Hz(很低的启动频率):PSC=7199,计数时钟 10 kHz,(ARR+1)=200,ARR=199。可以看到 PSC 是用来“把计数刻度调到合适档位”的,改频率时 PSC 一般不动,只改 ARR。
3.2 PWM 输出脉冲:配置代码与 50% 占空比的理由
用 PWM 通道输出,占空比固定 50%,这样高电平和低电平各占半个周期,脉宽随频率自动变化。低频时脉宽很宽,没问题;高频时要注意脉宽不能低于驱动器要求的 2.5 μs,比如 1 kHz 时半周期是 500 μs,远大于要求;跑到 100 kHz 时半周期只剩 5 μs,已经贴边了。
static TIM_HandleTypeDef htim3; // 句柄必须放文件作用域,HAL 的中断回调要靠它认实例 /* psc / arr 由 Step_SetFreq 反推后传入 */ void StepPwm_Init(uint16_t psc, uint16_t arr) { TIM_OC_InitTypeDef oc = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = psc; // 预分频,决定计数时钟 = 72MHz/(psc+1) htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = arr; // ARR,决定脉冲周期 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; // 改 ARR 下个周期生效,避免撕裂 HAL_TIM_PWM_Init(&htim3); oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = (arr + 1) / 2; // 50% 占空比 oc.OCPolarity = TIM_OCPOLARITY_HIGH; oc.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 启动后立刻开始吐脉冲 } /* 运行中改频率:只动 ARR 和比较值,PSC 保持不变 */ void Step_SetFreq(uint32_t f_hz) { uint32_t cnt_clk = 72000000UL / (htim3.Init.Prescaler + 1); uint32_t arr = cnt_clk / f_hz; if (arr < 2) arr = 2; // 保护:ARR 太小占空比没法调 __HAL_TIM_SET_AUTORELOAD(&htim3, arr - 1); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, arr / 2); }AutoReloadPreload这一行是重点。开启预装载之后,写进 ARR 的新值会等到当前计数周期结束才生效,不会出现“正在计数时被改小,计数器直接越过重装载值”的异常波形。关掉它,改速度的瞬间偶尔会多吐一个窄脉冲,电机听着就是“咔”一声。
这段代码只负责频率,不管数量。PWM 一旦启动就会一直吐,要发 N 个脉冲就停,得靠计数。最简单的方式是在更新中断里累加,到达目标值后HAL_TIM_PWM_Stop;另一种是让定时器工作在单脉冲模式(One Pulse Mode),由主循环反复启动,每次只发一个脉冲,脉冲数天然可控但开销大。我一般用中断计数的版本,中断里只做减法和一个标志位,不做任何耗时操作。
3.3 中断翻转 IO:什么时候该放弃 PWM
中断版本的长相是这样:定时器配置成普通更新中断,频率还是那个公式,回调里翻转一次 GPIO。注意翻转两次才算一个完整脉冲,所以翻转动作要么在回调里做两次(中间加个极短的延时,不推荐),要么每两次中断处理一次脉冲计数。
volatile int32_t step_remain = 0; // 剩余脉冲数,负数表示不限 volatile uint8_t phase = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM4) return; if (step_remain == 0) { // 到点了,直接关定时器 HAL_TIM_Base_Stop_IT(&htim4); return; } phase ^= 1; if (phase) { // 上升沿 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); } else { // 下降沿,一个完整脉冲结束 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); if (step_remain > 0) step_remain--; } }它的适用边界很清楚:脉冲频率上到 20 kHz 以上,中断频率就是 40 kHz,MCU 一大半时间在进出中断,主循环里稍微写点耗时逻辑(比如浮点运算、串口组包),脉冲周期立刻开始抖,电机听着就是一串不均匀的“哒哒哒”。所以中断版适合低频、需要精确计数的场合,比如一秒走几十个脉冲的精密位移;PWM 版适合中高频、连续运转。两者不要混着用同一个定时器,切来切去只会带来新的时序问题。
4. 串口协议设计:手机 APP 到 STM32 的指令怎么定才不翻车
串口通讯这块,代码写起来简单,麻烦全在“一帧数据到底收全了没有”。手机通过蓝牙发过来的字节流是连续的,中间没有任何边界,你发三条指令,单片机收到的可能是一段 30 字节的连续数据,也可能是被拆成 5 次中断的碎片。没有协议,就没法判断哪几个字节属于同一条命令。
4.1 一帧指令的骨架:帧头、命令字、长度、校验
定协议有个原则:能让解析器从任意位置重新同步。做法就是用两个固定字节的帧头,比如 0xAA 0x55,正常数据里几乎不会出现这个组合,一旦解析跑偏,只要再遇到 0xAA 0x55 就能重新对齐。
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 固定 0xAA 0x55 |
| 命令字 | 1 字节 | 0x01 定位运动,0x02 连续运动,0x03 急停,0x04 读状态 |
| 数据长度 | 1 字节 | 数据域字节数,0~16 |
| 数据域 | N 字节 | 小端序,定位运动时是方向(1)+脉冲数(4)+频率(4) |
| 校验 | 1 字节 | 命令字到数据域末尾的累加和取低 8 位 |
举例:让电机正转、走 3200 个脉冲、频率 1600 Hz。方向 0x01,脉冲数 3200 = 0x00000C80,小端是 80 0C 00 00,频率 1600 = 0x00000640,小端是 40 06 00 00。整帧就是 AA 55 01 09 01 80 0C 00 00 40 06 00 00 校验。校验 = (0x01+0x09+0x01+0x80+0x0C+0x00+0x00+0x40+0x06+0x00+0x00) & 0xFF。
调试期建议同时保留一条文本通道,比如发 "M 3200 1600\n" 这种可读格式,用串口助手能直接手敲。二进制协议适合正式 APP,文本协议适合现场调参,两条通道在解析器里按首字节分流就行(0xAA 走二进制,其他走 ASCII),互不干扰。
4.2 接收中断 + 环形缓冲:把碎片拼成整帧
单字节接收中断配环形缓冲是最稳的写法,比 HAL 的HAL_UART_Receive一次收固定长度靠谱得多,因为你永远不知道下一条指令有多长。
#define RX_SIZE 256 static volatile uint8_t rx_buf[RX_SIZE]; static volatile uint16_t rx_head = 0, rx_tail = 0; static uint8_t rx_byte; /* 每收一个字节进一次中断 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t next = (rx_head + 1) % RX_SIZE; if (next != rx_tail) { // 缓冲满就丢弃新字节,绝不覆盖未处理数据 rx_buf[rx_head] = rx_byte; rx_head = next; } HAL_UART_Receive_IT(huart, &rx_byte, 1); // 重新武装接收 } } /* 主循环里调用,返回 1 表示解析出一帧 */ uint8_t Proto_Poll(uint8_t *cmd, uint8_t *data, uint8_t *len) { static uint8_t buf[32]; static uint8_t idx = 0; static uint8_t need = 0; while (rx_tail != rx_head) { uint8_t b = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_SIZE; if (idx == 0 && b != 0xAA) continue; // 等帧头 if (idx == 1 && b != 0x55) { idx = 0; continue; } buf[idx++] = b; if (idx == 4) need = buf[3]; // 拿到长度字段 if (idx >= 5 && idx == 5 + need) { // 数据收齐,等校验 uint8_t sum = 0; for (uint8_t i = 2; i < idx; i++) sum += buf[i]; if (sum == buf[idx]) { // 校验通过,交给上层 *cmd = buf[2]; *len = need; for (uint8_t i = 0; i < need; i++) data[i] = buf[4 + i]; idx = 0; return 1; } idx = 0; // 校验失败,整帧丢弃重新找头 } if (idx >= sizeof(buf)) idx = 0; } return 0; }Proto_Poll放在主循环里跑,不要在中断里解析。中断里做的是“搬字节”这一件事,耗时固定几十个周期,不会把其它中断的响应挤掉。状态机用静态变量保存进度,即使一条指令被拆成七八次中断送达,只要顺序不乱,也能拼回来。缓冲区满时丢弃新字节而不是覆盖旧数据,是为了保证已经收到的一半帧不被破坏——宁可丢一条新指令,也别让解析器收到半截脏数据。
4.3 串口通讯里最容易被忽略的应答回执
单向发指令的协议在调试台上能跑,一上真机就开始怀疑人生:指令到底收到没有?电机没动是因为指令没到,还是到了但参数不对?所以协议里必须留一条反向通道,每收到一帧有效指令就回一帧应答,格式可以简单到AA 55 命令字 状态码 校验,状态码 0x00 表示已接受,0x01 表示参数越界,0x02 表示电机忙。
手机上收到应答再更新界面,比“点了没反应就再点一次”体验好得多,更重要的是能防重发。有些蓝牙串口 APP 在链路不稳时会自动重发同一帧,如果单片机这边不判重,两次定位运动叠加,电机就走了两倍的距离。简单的判重办法是在应答里带上当前剩余脉冲数,APP 侧看到还在执行就不再重复发。
发送节奏上,两条指令之间至少留 20 ms 间隔,别在毫秒级连续发。蓝牙链路本身有几十毫秒的延迟,模块内部的缓冲区也不大,连续猛发的结果是数据在模块里排队、甚至被丢弃,表现出来就是“十条指令执行了六条”。
5. 上电就跑飞的那些坑:五条现场排查记录
这一章全是真踩过的,按“现象 → 原因 → 解决”排,遇到类似症状可以按顺序对号入座。
5.1 电机只抖不转,串口那边还正常返回
现象:发定位指令,回执是成功,电机发出“嗡”的一声,轴在原地小幅抖动,不转。原因九成是脉冲频率超过了电机的启动频率。步进电机静止起步时能跟上的频率有限,常见两相电机空载也就 800~1500 Hz 起步,直接给 5 kHz 它只会抖。解决:加梯形加减速,从 500 Hz 起步,每若干个脉冲提一次频率,升到目标速度再匀速跑。如果暂时不想写加减速,先把目标频率降到 600 Hz 验证机械链路是通的,再往上加。
5.2 走十圈实际走九圈半,误差还挺稳定
现象:指令走 3200 个脉冲(细分 16 的一圈),实际量出来少了一截,重复几次误差差不多。原因基本是代码里的细分 m 和驱动器拨码开关的实际档位不一致,或者电机的步距角不是 1.8°(有 0.9° 的型号)。解决:把驱动器拨码拍照对照手册确认一遍,再把 m 和步距角做成两个宏放在配置头文件里,改的时候只改一处。另外注意使能信号极性,有些驱动器 ENA 低有效,上电默认是松开状态,第一帧指令跑着跑着被使能打断,也会少步。
5.3 连续发三条指令,只执行了第一条
现象:APP 连发三条,电机只动一次,后面两条石沉大海。原因通常是“忙”状态没有正确清除。定位运动执行期间如果主循环还在轮询解析,第二条指令进来时被直接丢弃且没回应答,APP 以为发了就完事。解决:解析出来的指令先入一个深度为 4 的小队列,运动状态机从队列取任务;同时把“忙”通过应答回给 APP,让它排队而不是重发。另一种可能是HAL_UART_Receive_IT没有在回调里重新武装,收完一个字节就再也进不了中断,这个用调试器看rx_head有没有在增长就能定位。
5.4 电机一转,串口就开始收到乱码
现象:不转的时候通讯一切正常,电机一启动,APP 上收到的回执变成乱码,偶尔还断连。原因是电机电源和单片机共地不良,或者信号线与电机线捆在一起走。解决:电机电源独立、单点共地;PUL/DIR 用双绞线或者尽量短;单片机电源入口加 100 μF 电解并 0.1 μF 瓷片;蓝牙模块的电源脚就近再并一个 10 μF。做到这几条,九成的干扰问题能压下去。剩下那一成,考虑给信号加光耦或者用差分驱动。
5.5 蓝牙连着连着就断,重连后必须重新上电
现象:跑十几分钟后链路断开,手机重连上但发指令没反应,单片机重新上电才好。原因多半是串口接收溢出。链路断开重连的瞬间,模块可能吐出一串垃圾字节,如果接收缓冲满了又没有清空逻辑,解析状态机卡在一个永远凑不齐的半帧里,后续所有数据都被当成帧内数据处理。解决:加一个链路超时判断,超过 2 秒没收到任何合法帧就清空环形缓冲和解析状态;同时给 APP 加一个握手帧,重连后先发握手,收到回执才开始发正式指令。
6. 梯形加减速与整机验证:把最后那点抖动磨掉
前面几章能跑通“能转、能停、能按指令走”,但真正让它像台设备而不是像个玩具的,是加减速。最朴素的做法是梯形曲线:加速段按固定步长递增频率,匀速段保持,减速段按同样步长递减。离散化到定时器上,就是每发一个脉冲改一次 ARR,改的次数等于加速段的脉冲数。
typedef struct { uint32_t f_start; // 启动频率,如 500 Hz uint32_t f_max; // 匀速频率,由指令给出 uint32_t acc_pulses; // 加速段脉冲数,一般取总脉冲数的 1/4 uint32_t total; // 总脉冲数 uint32_t issued; // 已发脉冲数 } ramp_t; /* 每发完一个脉冲调用一次,返回下一个脉冲该用的频率 */ uint32_t Ramp_NextFreq(ramp_t *r) { r->issued++; if (r->issued >= r->total) return 0; // 走完,停 uint32_t remain = r->total - r->issued; if (r->issued < r->acc_pulses) // 加速段:线性插值 return r->f_start + (r->f_max - r->f_start) * r->issued / r->acc_pulses; if (remain < r->acc_pulses) // 减速段:对称下降 return r->f_start + (r->f_max - r->f_start) * remain / r->acc_pulses; return r->f_max; // 匀速段 }加速段脉冲数取总脉冲数的四分之一是个懒人系数,短行程(几十个脉冲)时加速段会被压得很短,效果相当于直接起步,这时候要么把系数调小到 1/8,要么在指令里显式给加速脉冲数。频率变化太陡,电机听着会有“咯”的顿挫;变化太平缓,走一小段也要磨半天。实践里 500 Hz 起步、每 8 个脉冲提 100 Hz,这个梯度对大多数小电机都友好。
验证环节别只靠眼睛看轴转没转。三个手段:一是用逻辑分析仪或者示波器夹在 PUL 上,看脉冲频率是不是按预期的阶梯变化,脉冲总数对不对;二是在固件里维护一个已发脉冲计数器,通过读状态命令回传给 APP,跟逻辑分析仪的数对一遍;三是空载跑 100 圈,在轴上贴个记号,看停下来的位置偏不偏。三个都对上,再挂负载。
提示:改完加减速参数先空载验证,别带着机构跑,丢步了分不清是曲线的问题还是负载的问题。
血泪经验是:先让电机在 500 Hz 下老老实实转起来,再谈提速和曲线。从那以后我每次接新的电机和驱动器,都强制走一遍“低频空载 → 单圈定位 → 加减速 → 带载”这四步,一步不跳,省下来的时间远比调试时瞎猜要多。希望帮到你。
本文还有配套的精品资源,点击获取