
简介面向嵌入式初学者与电机控制实践者的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 VUSB 或独立适配器两路电源的负极在驱动器信号地附近单点接在一起不要形成环路。共地是必须的因为 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 脉冲对应转速12001.8°300 r/min48000.45°75 r/min816000.225°37.5 r/min1632000.1125°18.75 r/min3264000.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.66 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 MHzF1 系列常见配置计数时钟 f_cnt 72 MHz ÷ (PSC1)一个计数周期就是 1/f_cnt。ARR 是自动重装载值向上计数模式下从 0 数到 ARR 算一轮一轮就是一个输出周期PWM 模式下。所以f_out 72 000 000 ÷ ((PSC1) × (ARR1))设计顺序建议反过来推先定想要的脉冲频率 f_out再选 PSC 让 (ARR1) 落在一个不太小也不太大的区间。ARR 太小比如小于 20占空比调节精度会很差改速度时频率跳变也会很粗ARR 太大则每次改频率的响应变慢。经验值是把 ARR 控制在 10060000 之间。举几个实数目标 1 kHz。取 PSC71计数时钟 1 MHz(ARR1)1000ARR999。目标 1600 HzPSC71(ARR1) 1000000/1600 625ARR624。目标 50 Hz很低的启动频率PSC7199计数时钟 10 kHz(ARR1)200ARR199。可以看到 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/(psc1) 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 kHzMCU 一大半时间在进出中断主循环里稍微写点耗时逻辑比如浮点运算、串口组包脉冲周期立刻开始抖电机听着就是一串不均匀的“哒哒哒”。所以中断版适合低频、需要精确计数的场合比如一秒走几十个脉冲的精密位移PWM 版适合中高频、连续运转。两者不要混着用同一个定时器切来切去只会带来新的时序问题。4. 串口协议设计手机 APP 到 STM32 的指令怎么定才不翻车串口通讯这块代码写起来简单麻烦全在“一帧数据到底收全了没有”。手机通过蓝牙发过来的字节流是连续的中间没有任何边界你发三条指令单片机收到的可能是一段 30 字节的连续数据也可能是被拆成 5 次中断的碎片。没有协议就没法判断哪几个字节属于同一条命令。4.1 一帧指令的骨架帧头、命令字、长度、校验定协议有个原则能让解析器从任意位置重新同步。做法就是用两个固定字节的帧头比如 0xAA 0x55正常数据里几乎不会出现这个组合一旦解析跑偏只要再遇到 0xAA 0x55 就能重新对齐。字段长度说明帧头2 字节固定 0xAA 0x55命令字1 字节0x01 定位运动0x02 连续运动0x03 急停0x04 读状态数据长度1 字节数据域字节数016数据域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 校验。校验 (0x010x090x010x800x0C0x000x000x400x060x000x00) 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 电机只抖不转串口那边还正常返回现象发定位指令回执是成功电机发出“嗡”的一声轴在原地小幅抖动不转。原因九成是脉冲频率超过了电机的启动频率。步进电机静止起步时能跟上的频率有限常见两相电机空载也就 8001500 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 下老老实实转起来再谈提速和曲线。从那以后我每次接新的电机和驱动器都强制走一遍“低频空载 → 单圈定位 → 加减速 → 带载”这四步一步不跳省下来的时间远比调试时瞎猜要多。希望帮到你。本文还有配套的精品资源点击获取