STM32驱动28BYJ-48步进电机:ULN2003接线、相序表与定时器中断详解

发布时间:2026/10/20 1:27:51
STM32驱动28BYJ-48步进电机:ULN2003接线、相序表与定时器中断详解
简介基于STM32F103的28BYJ-48四相五线步进电机驱动源码包面向嵌入式初学者与电机控制开发者解决步进电机的精确角度控制、转向控制与脉冲调速问题。项目使用标准外设库编写代码中涵盖GPIO推挽输出配置、定时器中断产生脉冲、PWM调速思路以及按键切换控制逻辑能够帮助读者理解整步、半步与细分模式下的电机驱动时序并掌握从原理图到固件实现的关键链路。压缩包共79个文件压缩后大小约292KB。文件以.c/.h源文件和.s启动文件为主同时包含Keil工程配置uvprojx/uvoptx、调试配置、清理脚本以及STM32F10x标准库组件目录按Hardware、Library、Start、User等模块组织便于查阅、移植和二次开发。目前已有5254人学习下载适合希望通过完整可编译工程边读边练快速建立STM32定时器与步进电机驱动项目实战认知的嵌入式学习者。1. 小电机的大脾气28BYJ-48 为什么需要一套正经的 STM32 驱动源码有个正在做小型自动化设备的开发者曾经被一个算不上复杂的问题卡了一周手里拿到的源码STM32 驱动步进电机 28BYJ-48已经是别人跑通过的版本自己也改了三遍唯独电机上电后只“嗒”了一声就再没动静。最后查出来的原因特别简单——代码里的相序表按 A、B、C、D 设计而手上这块 ULN2003 驱动板的丝印顺序是 C、D、B、A正好错开三拍。这种问题靠看原理图很难看出来只有一份既讲接线又讲时序的驱动源码才能照着查。这份工程的价值就在这把 28BYJ-48 电机、ULN2003 驱动板和 STM32 的 GPIO、定时器串成一条完整驱动链相序表、半步时序、中断调速、转向与位置计数都有完整实现。适合刚把 HAL 库跑熟、准备做门禁、窗帘、转台这类小型步进设备的开发者也适合“项目就差电机不转”的救火场景。2. 硬件链路与工程骨架ULN2003 接线顺序和定时器的初始化设计要让电机转起来第一步不是写代码而是把“电”和“信号”两套关系理顺。很多人一上来就在 CubeMX 里点点点烧进去发现电机原地抖动问题十有八九出在接线顺序而不是代码上。2.1 接线顺序与电平转换GPIO 引脚选型与 ULN2003 驱动板的对应关系28BYJ-48 是五根线的小型减速步进电机五根线里一根是电源正其余四根是 A、B、C、D 四相绕组。电机额定电压 5V单相额定电流约 200mA。如果用 STM32 的 GPIO 直接去推线圈推不动因为 GPIO 拉电流能力有限3.3V 电平对电机绕组来说也偏低。所以中间必须夹一块 ULN2003 驱动板。ULN2003 是达林顿管阵列输入侧高电平有效输出侧为集电极开路灌电流相当于替 GPIO 做了电平转换和功率放大。驱动板和电机之间的接线通常有两排端子一排是 IN1、IN2、IN3、IN4另一排是电源和地。这里第一个坑就是线序不同批次的线颜色并不统一红、橙、黄、粉、蓝五根线里红色是 5V其余四根到底哪根算 A 相哪根算 D 相只能按实际板子丝印和测试来定不能只看颜色。信号28BYJ-48 端ULN2003 板端STM32 引脚电源红线5V、GND外部 5V 供电IN1橙线IN1PB0IN2黄线IN2PB1IN3粉线IN3PB2IN4蓝线IN4PB3上面的接线表是我第一版的样子它不一定和你手里的板子完全一致。所以后面的相序验证小节会专门讲怎么确认线序。选 GPIO 时有几个现实约束。第一别选被开发板占用的引脚例如某常见开发板的板载 LED 占用了 PB0、PB1接了电机就冲突换到 PA0~PA3 或者 PC0~PC3 更省事。第二GPIO 要配成推挽输出、无上下拉。开漏输出在没有上拉的板子上会让相线处于半浮空电机表现为低速抖、高速丢步。/* gpio_init.c —— 四路相线的 GPIO 初始化 */ static void MX_GPIO_Init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; gpio.Mode GPIO_MODE_OUTPUT_PP; /* 推挽输出别用开漏 */ gpio.Pull GPIO_NOPULL; /* 外部驱动板自带逻辑电平 */ gpio.Speed GPIO_SPEED_FREQ_LOW; /* 2MHz 摆率对步进够用 */ HAL_GPIO_Init(GPIOB, gpio); }这段代码把四个引脚全部设为推挽输出。Speed 用 LOW对 F103 来说对应 2MHz 输出摆率低速长线场景能减少波形振铃如果觉得换向沿太软、影响高速段改成 MEDIUM 也行。这个参数不会影响最终转速只会影响边沿陡峭程度不必过于纠结。需要特别说明GPIO 侧不需要额外做光耦隔离或电平转换。ULN2003 输入端兼容 3.3V 逻辑5V 侧由驱动板供电。所以板子给 STM32 供 3.3V电机 5V 从外部适配器取两边地线连一起但电源端不要共用一个 LDO否则电机启动电流会把 MCU 电压拉垮。2.2 工程搭建顺序时钟树、定时器和中断使能的先后关系代码基于 STM32CubeMX 生成的 HAL 工程芯片选 F103C8T6 这类常见款。搭建顺序建议是先配时钟再配 GPIO最后配定时器。先配时钟是因为 TIM3 的分频系数依赖 72MHz 系统时钟如果顺序反过来预分频值就要重算。时钟树部分HSE 外部晶振 8MHz 起振PLL 倍频到 72MHz 系统时钟。APB1 最大 36MHz所以挂在 APB1 上的 TIM3 会得到倍频后的 72MHz 时钟。这个 72MHz 是后面计算所有定时器参数的基础先记住它。CubeMX 里对 TIM3 的配置是预分频器 PSC 71计数器周期 ARR 999向上计数模式。这样 TIM3 的计数时钟等于 72MHz / (71 1) 1MHz即计时分辨率 1us更新周期是 (999 1) / 1MHz 1ms。定时器每 1ms 溢出一次溢出一次推进一步正好对应 1000 步/秒。把 ARR 改成 499就是 2000 步/秒改成 1999则是 500 步/秒。速度就是这样换来的。我一般把 PSC 固定、ARR 留给运行时改。原因是 PSC 决定计数分辨率和速度换算基准PSC 一变计数频率就不再是整数的 1MHz速度换算会变得别扭而只改 ARR是在同样的 1us 分辨率下改变溢出时刻换算只需一步。还有两处配置容易漏。第一处是“AutoReload Preload”设置为 Enable这样运行中改 ARR 时当前计数周期结束后才生效不会出现半周期被截断。第二处是 NVIC 里把 TIM3 全局中断打开并且优先级不要低于 SysTick。SysTick 优先级默认最低TIM3 优先级如果设得比它高HAL_Delay 就会卡死具体坑在第 4 章展开。/* timer_init.c —— TIM3 初始化定时器每 1ms 触发一次更新中断 */ static void MX_TIM3_Init(void) { htim3.Instance TIM3; htim3.Init.Prescaler 72 - 1; /* 计数时钟 1MHz */ htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 1000 - 1; /* 更新周期 1ms */ htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(htim3); HAL_TIM_Base_Start_IT(htim3); }第一处注释需要解释一下TIM3 挂在 APB1 上在 72MHz 系统时钟下APB1 分频后 TIM 时钟会被强制乘 2所以定时器输入仍是 72MHz。PSC 设 71 后计数器频率变成 1MHz后面所有速度换算都以这个 1MHz 为基准。第二处 AutoReloadPreload 和运行时改 ARR 是配套关系。初始化不启用这个位中途调用__HAL_TIM_SET_AUTORELOAD改周期计数器会在写入瞬间清零当前这一拍到下一拍的时间就会缩短步进节拍出现毛刺。到这里硬件与工程骨架已经就位可以进入驱动模块核心代码的拆解了。3. 核心模块拆解相序表、定时器中断与转向控制的完整驱动链驱动 28BYJ-48 其实只干三件事决定哪几个线圈通电、按什么节奏切换、切换后怎么知道自己在正转还是反转。这三个问题对应代码里的相序表、定时器中断和位置计数。3.1 相序表驱动的 GPIO 刷新8 拍半步时序的编码方式步进电机靠轮换线圈通电产生旋转磁场把转子一步步吸过去。28BYJ-48 最常用的驱动方式是 8 拍半步即八个通电状态循环切换。八个状态分别是A 单独通、A 与 B 同时通、B 单独通、B 与 C 同时通、C 单独通、C 与 D 同时通、D 单独通、D 与 A 同时通。这样做的效果是让转子每次挪动约 5.625 度的一半按 1:64 减速比折算到输出轴每一步转角约 0.0879 度转一整圈需要 4096 步。如果只用 4 拍全步即 A、B、C、D 四种状态循环步距角翻倍整圈约 2048 步力矩台阶也更明显。所以工程惯例直接用 8 拍本文代码也按 8 拍写。/* motor_step_table.h —— 8 拍半步相序表下标即步序号 */ static const uint8_t motor_seq[8][4] { {1, 0, 0, 0}, /* 状态 0A 相导通 */ {1, 1, 0, 0}, /* 状态 1A B 导通 */ {0, 1, 0, 0}, /* 状态 2B 相导通 */ {0, 1, 1, 0}, /* 状态 3B C 导通 */ {0, 0, 1, 0}, /* 状态 4C 相导通 */ {0, 0, 1, 1}, /* 状态 5C D 导通 */ {0, 0, 0, 1}, /* 状态 6D 相导通 */ {1, 0, 0, 1}, /* 状态 7D A 导通 */ };这个表的每一行代表一个通电状态四列依次对应 IN1、IN2、IN3、IN4。1 表示该相 GPIO 输出高电平0 表示低电平。切换表下标就是在选择下一步的通电组合。表头不一定非要从 A 开始从状态 4 开始也一样能转只要相邻两行之间的切换符合单方向变化即可。比如从{1,1,0,0}切到{0,1,1,0}只有第一列从 1 变 0、第三列从 0 变 1这是一次正确换相如果直接跳到{0,0,1,1}跳过了中间状态电机就会有明显的齿槽感和异响。/* motor_core.c —— 按相序表刷新四个 GPIOphase 指向表的一行 */ static void Motor_WritePhase(const uint8_t *phase) { HAL_GPIO_WritePin(MOTOR_A_PORT, MOTOR_A_PIN, phase[0] ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(MOTOR_B_PORT, MOTOR_B_PIN, phase[1] ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(MOTOR_C_PORT, MOTOR_C_PIN, phase[2] ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(MOTOR_D_PORT, MOTOR_D_PIN, phase[3] ? GPIO_PIN_SET : GPIO_PIN_RESET); }Motor_WritePhase是个纯函数式操作给它一行表内容它就把四个 GPIO 各输出一次。四个写入之间有微秒级的时间差而电机线圈是毫秒级的感性负载这点时间差不会产生实际影响。3.2 定时器中断把步进速度换算成硬件节拍电机要匀速转关键在每次换相的时间间隔要稳定。如果直接在主循环里调用 HAL_Delay主循环一旦被其他任务阻塞步序就会乱。更可靠的做法是让定时器每次溢出自动触发回调在回调里走一步。/* motor_core.c —— 定时器溢出回调每溢出一次推进一个步态 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { int32_t idx; if (htim-Instance ! TIM3) { return; } if (motor_dir MOTOR_DIR_FWD) { motor_position; } else { motor_position--; } /* 位置可能是负数先 %8 再 8 再 %8避免负数取模的坑 */ idx (int32_t)(motor_position % 8); idx (idx 8) % 8; Motor_WritePhase(motor_seq[idx]); }这里最值得解释的是最后两行取模处理。C 语言对负数取模结果正负号与被除数相同所以motor_position为负时motor_position % 8的结果是负数直接当数组下标会越界。先加 8 再取模保证下标落在 0 到 7 之间。这个细节我第一版没处理反向走到几十步后数组越界现象是电机走一步、停一下、又走一步相当迷惑。速度控制靠改 ARR 实现。因为计数时钟固定为 1MHz所以 ARR 的数值就等于“多少微秒走一步”。ARR 为 1999 时2ms 一步即 500 步/秒按 4096 步/圈换算约合 7.3 RPM。这个转速不算高但和 28BYJ-48 的定位匹配它本来就不是跑高速的电机。/* motor_speed.c —— 运行时改速度ARR 的单位是微秒间隔 */ void Motor_SetSpeedHz(uint32_t steps_per_sec) { uint32_t arr; if (steps_per_sec 0 || steps_per_sec 5000) { return; /* 超出合理范围拒绝设置 */ } arr 1000000 / steps_per_sec - 1; __HAL_TIM_SET_AUTORELOAD(htim3, arr); __HAL_TIM_SET_COUNTER(htim3, 0); /* 清计数器让新周期从零起算 */ }Motor_SetSpeedHz在运行中被调用HAL 通过写入 TIM3 的 ARR 寄存器改周期。因为初始化时打开了 AutoReloadPreload新 ARR 在当前计数周期结束后才生效不会破坏正在走的这一步。最后一行清零计数器是防止出现“新周期配旧计数”导致首次换相同隔异常。这里把 steps_per_sec 限制到 5000 是保守值。28BYJ-48 在 5V 下机械极限约为 30 RPM5000 步/秒约合 73 RPM已经超出额定范围。二次开发时想往上调要看实际负载来定不能照搬。3.3 方向与行程位置计数和“走指定步数”的半开环实现有了相序表和节拍方向控制其实只是在回调整体上加一减一。正转位置递增反转递减。这里要明确一点这套实现没有编码器回读真实位置位置准确性完全建立在“每一拍都可靠换相”的前提上所以它是半开环。定时器节奏别设太高电源也不能抖否则丢一步就丢一步位置不会自己恢复。/* motor_position.c —— 指定行程示例走 100 步后自动停 */ void Motor_StartSteps(int32_t target_steps) { motor_target target_steps; if (motor_target 0) { motor_dir MOTOR_DIR_FWD; } else if (motor_target 0) { motor_dir MOTOR_DIR_BWD; } Motor_SetSpeedHz(1000); HAL_TIM_Base_Start_IT(htim3); }在定时器回调里加一段行程判断/* motor_core.c —— 到达目标步数后停表避免无脑空转 */ if (motor_target ! 0 motor_position motor_target) { HAL_TIM_Base_Stop_IT(htim3); motor_target 0; return; }这段逻辑里motor_target为正表示正转行程目标为负表示反转行程目标初始化时置 0 表示不限行程。回调每次先判断是否到达到了就停表。这个“先判断再走一步”的顺序会带来最多一步的结束误差对多数结构件来说无关紧要但如果你要精确到步改成“先判断下一步是否越界不越界才走”即可。实际调试中要注意一点在回调里直接调用Motor_SetSpeedHz调速是安全的因为它只改 ARR但如果随手改了 PSC计数基准变了速度换算全部作废。所以我把调速和停止拆成两个函数避免在中断里写太复杂的状态逻辑。4. 避坑与排查不转、抖动、卡死和丢步的现场记录这一章把几类出现率最高的故障集中说明。每一条都是真实板子上反复出现过的排查顺序也按现场操作习惯排好了。4.1 现象电机通电后嗡嗡响、发热但轴不转这是 28BYJ-48 驱动里最典型的现场。症状通常是上电后电机发出连续“嗡嗡”声壳体温度上升但输出轴完全不动或轻微颤动。原因主要分三类按顺序排查。第一相序表与线圈实际接线不一致。比如相序表按 A、B、C、D 编码但板子丝印把 C、D、B、A 换了个顺序两个通电状态之间无法形成连续的旋转磁场电机就原地抱死。第二GPIO 模式配错比如用了开漏输出但板子没有上拉输入电平在临界区浮动。第三5V 电源带载能力不足。这个电机每相约 200mA堵转时接近 300mA如果 USB 口的 5V 同时供电机和主控电压会被拉到 4V 以下。解决套路也是三步。第一步给驱动板单独供 5V电流至少 1A先排除电源问题。第二步用万用表的通断档量电机红线和四根相线之间的电阻确认哪两个引脚属于同一绕组或者用 GPIO 逐拍输出拿示波器看 IN1~IN4 的波形是否按序列出现。第三步如果波形在但轴不动那就是相序表排列和板子丝印不一致。此时直接交换 IN1~IN4 中任意两路物理接线比改代码快得多改完看转向变化再决定是否调整代码。提示28BYJ-48 带减速齿轮输出轴本身比较紧上电后能听到电机内部齿轮在“咔咔”响但输出轴不动先用手轻轻转动输出轴判断减速箱是否卡涩再怀疑驱动问题。4.2 现象能转但方向反了或者步数和预期差很多方向反是最容易修正的一类。代码里正转对应motor_position也就是沿相序表下标递增走。想换方向只需要把回调里的增减逻辑反过来/* motor_core.c —— 反转时位置递减同样用负数取模修正下标 */ if (motor_dir MOTOR_DIR_FWD) { motor_position; } else { motor_position--; }有个常见误用有人为了改方向把相序表整体倒序重排代码变长还容易错。其实方向反转只需要改这一处判断表本身不用动。另一个现象是“步数对不上”。如果你按 4096 步一圈去数结果电机转两圈才停大概率是被配置成了 4 拍全步而不是 8 拍半步。4 拍全步一圈约 2048 步正好是 4096 的一半。相序表是四行的那种格式static const uint8_t motor_seq_4[4][4] { {1, 0, 0, 0}, /* A */ {0, 1, 0, 0}, /* B */ {0, 0, 1, 0}, /* C */ {0, 0, 0, 1}, /* D */ };这本身不是 bug但如果你拿 4096 做位置换算行程就会放大一倍。所以当电机走出的距离和预期不符先检查相序表是几行再看STEPS_PER_REV是不是 4096。最后是关于减速比的一个反直觉点28BYJ-48 标称减速比 1:64但实际齿轮组的减速比不是整数 64部分批次在 63.68 左右。也就是说严格算一圈需要的原步数不是精确的 4096而是 4076 到 4096 之间。短行程几十圈范围内无所谓跑几百圈之后位置会累积出明显偏移。工程上的处理办法是在机构上加光电限位开关每次上电回原点校正一次而不是去改步距角参数。4.3 现象定时器中断里调用 HAL_Delay 导致系统卡死或步数乱跳这种写法的坑是隐藏的。现象电机转起来之后整个系统像是死机串口无输出重新下载固件都要先按复位键才能进入下载模式。原因在于 HAL_Delay 依赖 SysTick 中断递减计数。如果 TIM3 优先级高于 SysTickTIM3 中断在跑回调时SysTick 永远没机会打断它HAL_Delay 里的 while 循环永远等不到计数归零形成死锁。比这更隐蔽的版本是不在回调里 delay而是在中断里调用阻塞式串口打印函数。打印几十个字符耗时百余微秒定时器节拍只有 1ms偶发一次还能撑过去速度一高就会丢步现象变成“位置计数漂移”。解决思路是让定时器中断“快进快出”只做改 GPIO 和计步两件事。涉及“走 200 步停 50ms再走”这类节奏用一个运行标志位在主循环里协调volatile uint8_t motor_pause_flag 0; /* 主循环置 1 表示暂停 */ volatile uint32_t motor_pause_count 0; /* 暂停剩余节拍数 */中断里只对这个标志做减减操作等待逻辑放主循环。串口日志更是要放到主循环甚至用 DMA 发送。这三类坑覆盖了“烧完代码不转”的大多数场景按现象对照排查大部分几分钟能定位。5. 二次开发把步进模块升级成带加减速的运行器基础驱动已经能转但真正要落到项目里还需要处理加减速、行程回调和堵转检测三个点。这三个方向是二次开发最通用的扩展路径其中加减速优先级最高。直接固定频率运转电机从静止瞬间切到高速频率会因为惯性丢步。最简单的工程做法是梯形加减速启动时低速每进一次定时器中断提升一档到达巡航速度后保持停止前再逐级降速。/* accel.c —— 梯形加速每次定时器回调提升一档速度 */ #define ACCEL_STEPS 16 /* 加速阶段分 16 档 */ uint16_t speed_table[ACCEL_STEPS]; uint8_t speed_index 0; static void Motor_AccelInit(void) { uint32_t i; uint32_t start_hz 500; /* 起步 500 步/秒 */ uint32_t peak_hz 2000; /* 巡航 2000 步/秒 */ for (i 0; i ACCEL_STEPS; i) { speed_table[i] (uint16_t)(start_hz (peak_hz - start_hz) * i / ACCEL_STEPS); } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { /* 这里保留原有位置与相序处理逻辑 */ if (speed_index ACCEL_STEPS) { Motor_SetSpeedHz(speed_table[speed_index]); } }这段代码用查找表预计算 16 档速度每次中断提升一档。起步 500 步/秒约合 7 RPM巡航 2000 步/秒约 29 RPM正好落在电机合理工作区内。真实产品中可以用 S 形曲线拟合更多档位但先跑通梯形骨架再改曲线参数表比直接上复杂算法更容易调试。行程回调和堵转检测则是工程化必须补的部分。行程回调可以扩展现有Motor_StartSteps到达目标后在回调函数指针里执行“断电”或“通知上位机”的动作。堵转检测不需要复杂的电流采样在输出轴上加一个光电遮片走固定步数后读光电信号如果没变化就认为堵转接着做复位流程。这个方案成本低稳定性也好。最后讲我自己的验证习惯。在轴上贴一片纸手动转一圈看起始位置是否回到同一处。然后用逻辑分析仪抓 IN1~IN4 四路波形确认相邻两个切换点时间间隔均匀。没有逻辑分析仪时用示波器双通道同时看 A、B 两相也能发现“半个波形被截断”——那是 ARR 在运行中被写坏的典型特征。从那以后我每次拿到新板件的第一件事都是先用万用表把 IN1~IN4 与相线的对应关系量一遍再烧 20 步低速试转调速度必带示波器看波形再也不敢跳过基础验证直接跑高速。希望帮到你。本文还有配套的精品资源点击获取