简介:STM32F103ZET6步进电机智能小车完整程序源码基于Keil开发环境编写,支持红外遥控、避障、跟随、循迹四种工作模式,适合电子竞赛、课程设计与智能小车自制等场景。系统以ULN2003驱动28BYJ-48步进电机,通过VS1838B接收红外遥控信号,并借助1602液晶显示当前状态,代码按遥控、避障、跟随、循迹功能模块划分,结构清晰便于移植和学习。源码包共204个文件,包含40个C源文件、41个头文件,以及Keil工程配置文件、hex烧录文件、链接脚本和批处理工具等,压缩后大小仅4.12MB,可快速下载使用。全部程序已在作者自制的同型号小车上实测可用,烧录后可立即体验红外遥控、自动避障、跟随与循迹功能,且关键模块均有注释,适合入门参考。目前已有397人学习下载,对于需要掌握步进电机控制、红外通信与多传感器协作的STM32开发者来说,是一份实用的代码范本。
1. 一辆车上装四种控制逻辑,STM32F103ZET6步进小车的源码拆解路径
同一个底盘上集成红外遥控、避障、跟随、循迹四种功能,大多数人的第一反应是先砍掉两三样,只保核心。但这套STM32F103ZET6源码全部做完了,且执行机构用的是28BYJ-48步进电机加ULN2003驱动板,不是常见的直流减速电机。步进电机在开环状态下就能把车轮转角控制到固定角度,转弯半径和直线距离都可以按脉冲数推算,这是它适合教学实验的原因;代价是四相八拍时序、加减速调度和轮组差速都得自己写。下面按驱动接线、NEC红外协议解码、三模式仲裁、加减速与失步自查四个层次拆解这套源码,尤其适合正在做课程设计、竞赛练习或准备从直流电机迁移到步进电机的读者。
2. ULN2003驱动28BYJ-48的接线与八拍步序:先解决“走起来”的问题
2.1 四相八拍步序表和脉冲当量换算
28BYJ-48是永磁减速步进电机,内部转子齿数对应的电气步距角是5.625°,减速比标称1/64(实际为1/63.6835)。电机绕组每换相一次,输出轴只转动5.625°/64≈0.0879°,轮子转一整圈需要64×8=512个半步,这里的“步”指半步序列中的一次换相。实际项目里常把输出轴的脉冲当量直接写成360°/512≈0.703°/步,所有转向角度、直线距离都基于这个值换算。
12V版本的绕组电阻比5V版本高,工作电流小,但四拍模式下转矩波动大,低速启动时容易卡死在齿槽转矩最低点,所以源码采用八拍(半步)模式。八拍切换时绕组重叠导通,始终有两个或多个线圈保持励磁,不存在完全没有转矩的过渡点。下表是源码中实际使用的八拍序列表:
| 拍序 | A(IN1) | B(IN2) | C(IN3) | D(IN4) | 励磁相 |
|---|---|---|---|---|---|
| 1 | 1 | 0 | 0 | 0 | A |
| 2 | 1 | 1 | 0 | 0 | A+B |
| 3 | 0 | 1 | 0 | 0 | B |
| 4 | 0 | 1 | 1 | 0 | B+C |
| 5 | 0 | 0 | 1 | 0 | C |
| 6 | 0 | 0 | 1 | 1 | C+D |
| 7 | 0 | 0 | 0 | 1 | D |
| 8 | 1 | 0 | 0 | 1 | D+A |
在“前进=两轮同向正转”的安装方式下,左转和右转实际靠差速实现。差速转弯的步数差等于轮子走过的弧长差除以脉冲当量换算关系。若轮径65mm、轴距120mm,转过90°时两轮弧长差约为π×120/2≈188mm,对应半步数188/(π×65/512)≈471步。把这471步作为转向基础步数,在程序里乘上0.9~1.1的修正系数,可以补偿两个轮子打滑差异和装配误差。
2.2 引脚分配与GPIO初始化代码
源码的引脚分配不是随意定的,思路是:电机控制引脚集中在GPIOB低8位,便于一次BSRR指令同时更新两个轮子;红外信号输入集中在GPIOA低4位;LCD1602放在GPIOC,避免和调试用的USART1冲突。以我手里的实车接线为准,引脚分配如下:
| 功能 | STM32引脚 | ULN2003/模块端子 |
|---|---|---|
| 左轮步进A~D | PB0~PB3 | ULN2003(1) IN1~IN4 |
| 右轮步进A~D | PB4~PB7 | ULN2003(2) IN1~IN4,两块驱动板分开 |
| VS1838B红外输出 | PA0 | 接收头OUT接PA0,GND共地 |
| 红外避障模块OUT | PA1 | 数字输出,检测到障碍输出低电平 |
| 红外循迹左传感器 | PA2 | 白线高/黑线低 |
| 红外循迹右传感器 | PA3 | 白线高/黑线低 |
| LCD1602 | PC0~PC6 | RS/RW/EN占前三个,D4~D7占后四个 |
两块ULN2003驱动板必须与STM32F103ZET6共地,否则电机绕组电流回路会经信号线回流,轻则GPIO波形发毛,重则烧掉PA0/PB0端口。ULN2003板上的指示灯在无脉冲时偶尔闪烁,这是达林顿管漏电流导致的,不代表程序出错。
GPIO初始化部分这样写:
void GPIO_Init_MotorAndSensor(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // VS1838B输出端上拉 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); }关键点有两个。第一,电机引脚用推挽输出,STM32F103ZET6在50MHz翻转速率下,GPIO电平切换只要几十纳秒,完全满足28BYJ-48每拍最短1ms的换相周期;这里不需要PWM,ULN2003是达林顿开关电路,只有导通和关断两种状态。第二,VS1838B用上拉输入而不是浮空输入,因为接收头本身是集电极开路结构,无载波时输出高电平依赖外部上拉,启用内部上拉能省掉一颗4.7kΩ电阻,同时也让下降沿更陡,外部中断不容易漏触发。
2.3 步进电机驱动函数与转向执行逻辑
轮子动作的核心是Motor_Step函数,它同时更新左右两组绕组的励磁相位:
const uint8_t step_table[8] = {0x08, 0x0C, 0x04, 0x06, 0x02, 0x03, 0x01, 0x09}; void Motor_Step(int8_t dir_L, int8_t dir_R) { static uint8_t idx_L = 0, idx_R = 0; if (dir_L > 0) idx_L = (idx_L + 1) & 0x07; else if (dir_L < 0) idx_L = (idx_L + 7) & 0x07; if (dir_R > 0) idx_R = (idx_R + 1) & 0x07; else if (dir_R < 0) idx_R = (idx_R + 7) & 0x07; GPIOB->BSRR = (0xFF << 16) | (step_table[idx_L] << 0) | (step_table[idx_R] << 4); }step_table里的值是引脚位掩码,0x08对应PB3,值按A→AB→B→BC→C→CD→D→DA顺序排列。dir_L和dir_R取值为1、-1、0,分别控制正向、反向、停止。idx_R通过+7实现取模减法,等效于反向走一拍。GPIOB->BSRR低16位写1置位引脚、高16位写1清零引脚,这一句把左轮PB0~PB3和右轮PB4~PB7一次性更新完毕,避免分两次写GPIO时中间状态导致轮子抖动。
上层控制封装成四条指令:
void Car_Forward(uint16_t steps) { for (uint16_t i=0; i<steps; i++) Motor_Step(1, 1); } void Car_Backward(uint16_t steps) { for (uint16_t i=0; i<steps; i++) Motor_Step(-1, -1); } void Car_TurnLeft(uint16_t steps) { for (uint16_t i=0; i<steps; i++) Motor_Step(0, 1); } void Car_TurnRight(uint16_t steps){ for (uint16_t i=0; i<steps; i++) Motor_Step(1, 0); }转向采用“单轮停转”而不是“双轮反转”是有讲究的。28BYJ-48经过64倍减速后输出扭矩大约300g·cm,双轮反转转弯时两个轮子同时克服地面摩擦,车身会产生明显横摆抖动;单轮转弯时另一轮保持锁死,相当于绕固定点转动,轨迹更接近圆弧。如果要做原地掉头,才把左右轮步序反向,但循迹场景下单轮转弯更利于传感器留在线内。
3. VS1838B红外遥控解码:NEC协议按位拆分与按键映射
3.1 NEC协议的符号宽度和测量窗口
VS1838B输出的信号是反相的:无红外载波时输出高电平,检波到38kHz载波时输出低电平。所以NEC协议的引导码在接收端表现为9ms低电平加4.5ms高电平,典型逻辑0是562.5µs低加562.5µs高,逻辑1是562.5µs低加1687.5µs高。帧结构固定为32位:8位用户码、8位用户反码、8位键码、8位键反码,最后跟一个560µs的结束脉冲。
不需要上升沿下降沿同时采样,只测两个下降沿之间的间隔就能区分三个符号。原因是NEC协议两次下降沿的间隔完整携带信息:逻辑0的间隔是562.5+562.5=1125µs,逻辑1是562.5+1687.5=2250µs,引导码是9000+4500=13500µs。三者数值跨度很大,阈值可以给得很宽,抗干扰能力强。STM32F103ZET6主频72MHz,把TIM5预分频设为71后计数频率变成1MHz,TIM5->CNT每1µs加一,两次CNT的差值就是µs单位的下降沿间隔。
3.2 外部中断加定时器测宽解码源码
VS1838B的OUT接PA0,配置为下降沿触发外部中断,TIM5负责计数。main函数初始化时需要把EXTI_Line0下降沿触发、NVIC中断优先级分组都配好,然后在中断里完成解码:
void TIM5_Config(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM5, ENABLE); TIM_TimeBaseInitTypeDef TIM_Init; TIM_Init.TIM_Prescaler = 71; // 72MHz/72=1MHz,每格1µs TIM_Init.TIM_Period = 0xFFFF; // 16位自动重装 TIM_Init.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM5, &TIM_Init); TIM_Cmd(TIM5, ENABLE); } void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) == SET) { EXTI_ClearITPendingBit(EXTI_Line0); static uint16_t last = 0; static uint8_t bit_cnt = 0; static uint32_t tmp = 0; uint16_t now = TIM5->CNT; uint16_t diff = now - last; last = now; if (diff > 10000) { bit_cnt = 0; tmp = 0; // 引导码,重新开始收集 } else if (diff > 1500) { tmp |= (0x80000000 >> bit_cnt); // 逻辑1 bit_cnt++; } else if (diff > 800) { // 逻辑0 bit_cnt++; } else { bit_cnt = 0; // 小于0.8ms视为干扰 } if (bit_cnt >= 32) { g_ir_code = tmp; g_ir_flag = 1; bit_cnt = 0; } } }关键参数取值逻辑:diff大于10ms才认定是引导码,因为一次按键过程中不会出现第二次这么长的低电平窗口。逻辑0和逻辑1的分界阈值取1500µs,落在1125µs和2250µs中间偏安全侧,即使电机转动导致电源纹波让脉宽偏移10%也不会误判。小于800µs的间隔直接丢弃,这是为了过滤电机换相瞬间GPIO串扰产生的毛刺。
主循环用查询方式取结果,解码不卡电机:
if (g_ir_flag) { g_ir_flag = 0; uint8_t addr = g_ir_code & 0xFF; uint8_t addr_inv = (g_ir_code >> 8) & 0xFF; uint8_t cmd = (g_ir_code >> 16) & 0xFF; uint8_t cmd_inv = (g_ir_code >> 24) & 0xFF; if (cmd == (uint8_t)~cmd_inv) { // 反码校验通过,再执行按键动作 } }反码校验值得保留。NEC发送端会同时发送反码,校验通过才执行,能避免遥控器电池电压下降时产生的误码导致小车突然转向。
3.3 键码映射表与四模式下遥控接管
按键值取自帧的第16到第23位,也就是cmd字节。下面是这套源码实际使用的映射表:
| 键码(hex) | 遥控器按键 | 小车动作 |
|---|---|---|
| 0x45 | CH- | 前进100步 |
| 0x46 | CH | 后退100步 |
| 0x47 | CH+ | 左转80步 |
| 0x48 | PREV | 右转80步 |
| 0x44 | NEXT | 停止并释放绕组 |
| 0x43 | EQ | 切换到循迹模式 |
| 0x40 | PLAY | 切换到避障模式 |
| 0x41 | VOL- | 切换到跟随模式 |
| 0x42 | VOL+ | 切换到手动模式 |
模式切换用main函数里的变量g_mode控制,每个模式对应一个处理函数。遥控器在自动模式下按前进、后退键会强制切回手动模式,这样设计的好处是:循迹过程中车跑偏时,按一下遥控方向键就能立即接管,不需要先按VOL+再操作。
4. 循迹、避障、跟随三模式的状态机切换与实时仲裁
4.1 红外循迹的阈值判断与转向修正
循迹部分使用两个TCRT5000红外反射模块,分别接PA2、PA3。TCRT5000的模拟输出经比较器在模块上调好阈值后输出数字电平,源码里不重复做ADC采样,直接用GPIO读取。不同厂家模块的白/黑电平定义不同,有的是白线输出高、黑线输出低,有的正好相反,接线后先用万用表量一次再确定判断方向。
循迹状态机的判定逻辑如下表,这里把“1”定义为“检测到黑线”:
| 左传感器 | 右传感器 | 状态判定 | 执行动作 |
|---|---|---|---|
| 0 | 0 | 出线或十字路口 | 两轮停转200ms再原速前进 |
| 0 | 1 | 偏向右侧 | 右轮停转,左轮正转80步修正 |
| 1 | 0 | 偏向左侧 | 左轮停转,右轮正转80步修正 |
| 1 | 1 | 正常直线 | 两轮继续前进 |
“出线”状态下立刻原地停止200ms很重要,它让车体在十字路口停下来判断,而不会直接冲出赛道。步进电机在这个场景的优势很明显:突然停止不会像直流电机那样惯性滑行,80步修正量在程序里就是固定的角度增量,不需要PID误差积分,逻辑简单且可复现。如果要把两路传感器升级成五路循迹模块,中间一路专门处理路口判断,左右两路继续做偏差修正,这套代码的判定逻辑基本不用动。
4.2 避障模式的扫描式决策
单路红外避障模块只能判断正前方有没有障碍,判断不了障碍偏向哪边。源码采用时序扫描策略:发现障碍后先右转90°,此时若障碍离开探测范围,说明障碍在左侧,直接直行;若右转后仍探测到障碍,说明障碍范围较大,再左转180°从右侧绕开。写成状态机如下:
typedef enum { AVOID_GO, AVOID_TURN_R, AVOID_TURN_L } avoid_state_t; avoid_state_t avoid_state = AVOID_GO; void Avoid_Handle(void) { uint8_t obs = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1); switch (avoid_state) { case AVOID_GO: if (!obs) { // 1为无障,0为有障 Car_Stop(); avoid_state = AVOID_TURN_R; } else { Car_Forward(1); } break; case AVOID_TURN_R: Car_TurnRight(90); if (!obs) { avoid_state = AVOID_TURN_L; // 转过90°仍有障碍 } else { avoid_state = AVOID_GO; } break; case AVOID_TURN_L: Car_TurnLeft(180); avoid_state = AVOID_GO; break; } }注意TURN_R分支执行完Car_TurnRight(90)后立刻又读了一次obs,因为Car_TurnRight是阻塞式函数,执行完90步后障碍位置已经变化,必须以新状态重新判断。实际调试时把避障模块的VCC单独从5V引脚引线,不要和电机共用接线端子,否则电机绕组电流拉低模块供电,obs会误变。
4.3 跟随模式的差分速度控制
跟随模式与避障共用红外模块,区别在于避障只看有/无,跟随看的是连续反射强度。如果红外模块输出的只是数字量,就采用时间积分方式:检测到目标存在的时间占比越高,判定目标越近。更常规的做法是双路模块,把左右两个传感器放在车头两侧45°方向,用距离差生成差速:
err = left_dist - right_dist; speed_L = base_speed + Kp * err; speed_R = base_speed - Kp * err;Kp取值0.5~2,输出限幅到步进电机每拍间隔不小于1.2ms。若是单路模块,退化为PID距离保持:目标在设定距离内则前进,太近则后退,中间地带停车。这里的“距离”是红外模块导通占比估算出来的相对值,不是毫米数,跟随精度在5cm以内。要注意28BYJ-48带减速箱,响应速度天然比直流电机慢,因此跟随模式下Kp不宜超过2,否则会来回振荡。
4.4 用函数指针数组统一三模式调度
模式切换最容易写成一大段if-else嵌套,模块多了之后新增模式要翻动主函数。源码用函数指针数组做分发:
typedef enum { MODE_MANUAL, MODE_TRACK, MODE_AVOID, MODE_FOLLOW, MODE_MAX } mode_t; mode_t g_mode = MODE_MANUAL; typedef void (*mode_fn)(void); const mode_fn g_mode_table[MODE_MAX] = { [MODE_MANUAL] = Manual_Handle, [MODE_TRACK] = Track_Handle, [MODE_AVOID] = Avoid_Handle, [MODE_FOLLOW] = Follow_Handle, }; void Mode_Dispatch(void) { g_mode_table[g_mode](); }主循环只调用Mode_Dispatch(),遥控按键设置g_mode后,下一次循环立即生效。函数指针数组优于switch的地方在于:数组内容是const,存放在Flash里不占RAM;新增模式只需要加一个枚举成员和数组元素,后续维护成本低。实测Mode_Dispatch执行时间在10µs以内,远小于步进电机一拍1.2ms,不会造成电机丢步。
5. 步进加减速打断和失步自查:落地调试时的两个硬指标
5.1 定时器中断改频实现梯形加减速
阻塞式for循环驱动只适合低速演示,连续高速运行丢步率会直线上升。更稳的做法是把每拍触发挪进TIM3中断,速度由ARR实时调整:
const uint16_t interval_table[9] = {5000, 3000, 2200, 1800, 1500, 1300, 1200, 1100, 1000}; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); Motor_Step(g_dir_L, g_dir_R); static uint8_t level = 0; if (g_accel == 1 && level < 8) { level++; TIM3->ARR = interval_table[level]; } else if (g_accel == -1 && level > 0) { level--; TIM3->ARR = interval_table[level]; } } }interval_table单位是微秒,代表拍间隔;从5000µs出发逐级降到1000µs,对应速度从200步/s提高到1000步/s。每级停留200ms是下限,再快容易进共振区。28BYJ-48在200~400Hz有明显噪声,把每级停留时间放到300ms以上能缓解。停止时不要直接抬ARR,要逐级放回5000µs再停,避免惯性冲击齿轮打齿。
5.2 失步表现与绕组复位自检
失步是步进开环控制最隐蔽的问题:程序计数走完了,车轮实际位置没到,不报错、不停机,后续距离计算全偏。现场查失步先用机械法:断电后手拨车轮,28BYJ-48带减速箱,手感应均匀且有阻尼,某个角度明显卡顿说明绕组短路或某一路驱动断路。电学法留两组变量,调试时打印对比:
extern volatile int32_t g_steps_done; static int32_t g_steps_target; // TIM3中断里每走一拍 g_steps_done++ // 停车后判断是否等于目标值 if (g_steps_done != g_steps_target) g_fault = 1;有偏差先看中断嵌套是否把Motor_Step夹在中间,再看ULN2003的IN1~IN4和GPIOB是否错位。另外每次切进循迹前做一次绕组复位:按八拍序退到第一拍,最后两拍各保持10ms,让转子停稳在初始角,循迹的转向基准才一致。
最后一个参数经验:把interval_table最小值改到700µs空跑20秒,摸ULN2003温度。超过60℃说明绕组长时间导通导致温升偏高,把最小值放宽到1200µs,温度会立刻回落。12V的28BYJ-48参考这条经验线就能把寿命拉长;换成5V版本还需要再保守一档,否则电机内部温升会损坏减速箱润滑脂。
本文还有配套的精品资源,点击获取