智能搬运车源码全解析:STM32、PID与状态机实战

发布时间:2026/9/16 20:20:22
智能搬运车源码全解析:STM32、PID与状态机实战 简介这是一份面向工程训练综合能力竞赛智能搬运车赛项的源码与项目说明包适合参赛选手以及计算机、电子信息等专业的学生作为课程设计、期末大作业或毕业设计的参考资料。项目基于树莓派平台覆盖颜色识别、二维码检测、路径规划、串口通信、WiFi控制等关键环节源码以Python为主并配有详细说明文档与图片素材便于对照理解智能搬运车的整体工作流程。压缩包共57个文件包含21个py源代码文件、10个pyc编译文件、9个md说明文档及若干图片、字体、PDF等整体大小约23.6MB目录按功能模块组织查找方便。目前已有381人学习参考适合具备一定编程基础、愿意深入钻研的读者可在现有代码基础上进行二次开发与调试。1. 工程训练智能搬运车源码与项目说明先搞懂整机再谈读代码工程训练综合能力竞赛里的“智能搬运车”赛题比的核心从来不是某颗芯片多新而是整机能不能可靠完成“循迹出发—识别取料—搬运途中不脱线—精准投放”的闭环。所以这份智能搬运车源码项目说明.zip 里最有价值的不是某个外设的驱动文件而是任务状态机怎么组织、电机闭环怎么调、机械动作和移动动作怎么同步。对准备参赛的在校生它是一条完整的复现路径对常年写嵌入式代码的工程师它是一个多传感器协作项目的模块拆分样本。这篇文章按主控驱动、循迹算法、抓取机械、现场排错的顺序展开把源码里不会直接写出来、但调试时一定会撞上的边界条件讲清楚。手头有同类型 STM32 小车项目的读者可以直接对照自己的工程一起读重点看误差模型、PID 量纲和状态机的超时保护这三处。2. 智能搬运车源码的硬件映射主控选型、电机驱动与传感器接口2.1 从搭车思路反推整机硬件构成这类竞赛项目的源码通常按功能模块分文件而项目说明第一页就是物料清单和接线表。常见做法是主控用 STM32F103C8T6 最小系统板两个带霍尔编码器的直流减速电机TB6612FNG 电机驱动5 路红外循迹模块一个 SG90 舵机驱动夹爪再加上超声波或灰度传感器辅助入库。整套代码量不大但每一路外设的初始化都对应源码里的独立文件读代码前先对照这张表能少走半小时弯路。模块常见型号接口形式在源码中的对应主控STM32F103C8T6GPIO / TIM / UART系统初始化、任务调度电机驱动TB6612FNG2 路 PWM 4 路方向 IO左右轮速度输出循迹5 路红外模块5 个 GPIO 数字量误差值计算测速霍尔编码器TIM 编码器模式速度闭环夹爪SG90 舵机TIM PWM 单通道抓取动作序列选型把 TB6612 而不是 L298N是因为前者内部是 MOS 管半桥导通压降比达林顿管低 1V 以上。竞赛电池电压本来就不高压降越大极限速度下电机越容易掉转速转弯时左右轮速度差也会被压缩。编码器优先选霍尔式而不是光电式场地里的灰尘和反光都会干扰光电码盘计数霍尔式的抗污染能力明显更好。2.2 电机 PWM 与编码器测速的初始化代码电机初始化是读源码的第一站也是改完最容易发现“编译过了但车不动”的地方。下面这段是标准外设库风格的 PWM 和编码器配置注意编码器 A/B 相必须接到支持编码器模式的 TIM 通道上。void motor_pwm_init(void) { GPIO_InitTypeDef gpio {0}; TIM_TimeBaseInitTypeDef tim {0}; TIM_OCInitTypeDef oc {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); /* PA0/PA1 复用推挽输出, 挂到 TIM2 的 CH1/CH2 */ gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); /* 72MHz / 72 1MHz, 周期 62 - 约 16kHz */ tim.TIM_Prescaler 72 - 1; tim.TIM_Period 62 - 1; TIM_TimeBaseInit(TIM2, tim); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse 0; TIM_OC1Init(TIM2, oc); TIM_OC2Init(TIM2, oc); TIM_Cmd(TIM2, ENABLE); } void encoder_init(void) { /* TIM3 编码器模式, 上升沿和下降沿都计数, 即 4 倍频 */ TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE); }代码里两个参数值得盯住预分频 72 和周期 62 决定了 PWM 频率约 16kHz这个频率避开人耳敏感区同时让 TB6612 的开关损耗处于可接受范围。编码器配置成 TI12 模式后硬件自动完成正交解码和方向判别主循环里每隔 10ms 读一次计数器差值就能得到速度不需要在中断里做繁琐的边沿判断。2.3 接线阶段最容易出问题的三个点驱动板 GND 必须与主控 GND 相连。否则 PWM 信号没有公共参考地占空比输出会随机漂移表现为车轮时而全速时而停转而且时有时无排查起来非常耗时。接线第一步就是共地。编码器信号不要和电机线绑在一起走线。编码器线用双绞或屏蔽方式远离电机大电流线否则电机换向瞬间的尖峰会把编码器计数冲乱速度环会突然震荡。硬件上还可以在编码器供电脚并联 0.1uF 电容。舵机供电不要从主控 3.3V 取。SG90 堵转时电流接近 1A会把主控电压拉低导致复位。常见做法是舵机单独接 5V且与电机的动力电源共地不共线。3. 智能搬运车循迹算法误差计算、PID 调参与比赛地图应对3.1 五路红外怎么变成一个误差值循迹的起点是把 5 路红外二值化结果映射成一个带方向的偏差值。源码里一般会先读回一个字节掩码再用查表或 switch 转成误差负值表示车相对线偏左正值表示偏右。uint8_t track_mask read_track_sensors(); /* bit0~bit4 对应左到右 */ int16_t calc_track_error(uint8_t mask) { switch (mask) { case 0x04: return 0; /* 只有中间压线 */ case 0x06: return 1; /* 中间右侧第1路 */ case 0x02: return 2; /* 右侧第2路压线 */ case 0x03: return 3; /* 最右侧两路压线 */ case 0x0C: return -1; /* 中间左侧第1路 */ case 0x08: return -2; case 0x18: return -3; default: return 0; /* 全黑/全白时用上次误差, 由上层处理 */ } }误差值为什么要分两级而不是直接传掩码因为 PID 的输入需要连续变化掩码从 0x04 跳到 0x02 时误差从 0 跳到 2如果中间没有 0x06 这一档转向输出会突变车身容易左右甩头。5 路传感器给出 0 到正负 3 共 7 档对竞赛场地足够再多路数的模块在 1.5 米宽的赛道上反而浪费。3.2 位置式 PID 的最小实现与参数顺序速度控制用增量式居多循迹转向用位置式更直观因为误差本身就是绝对量。下面这段实现加了一路上限和输出限幅避免积分饱和把 PWM 打满。typedef struct { float kp, ki, kd; float integral; float last_error; float i_limit; /* 积分上限, 防止长时间离线上限饱和 */ } PID_t; float pid_update(PID_t *p, float error) { p-integral error; if (p-integral p-i_limit) p-integral p-i_limit; if (p-integral -p-i_limit) p-integral -p-i_limit; float out p-kp * error p-ki * p-integral p-kd * (error - p-last_error); p-last_error error; return out; }在主循环里把误差换算成左右轮速度误差为正表示线在右侧加大左轮、减小右轮float steer pid_update(track_pid, calc_track_error(track_mask)); int16_t left (int16_t)(BASE_SPEED - steer); int16_t right (int16_t)(BASE_SPEED steer); set_motor_speed(MOTOR_LEFT, left); set_motor_speed(MOTOR_RIGHT, right);调参顺序比参数本身更重要典型参考区间如下PWM 刻度对应第 2 章的计数器 0~61参数参考区间作用kp1.5 ~ 4.0决定转向力度过小转弯不足过大直道抖动kd8 ~ 25抑制过冲消除往复摆动ki0.01 ~ 0.1消除环形弯道上的残差BASE_SPEED20 ~ 45直道基础速度必须低于电机切换极限我一般先把 ki 置零只调 kp 到直道出现小幅来回摆动再加 kd 压下去最后用很小的 ki 处理弯道稳态偏差。判断是“该加 kd”而不是“该减 kp”看串口输出的误差波形周期性等幅摆动是 kp 过大或 kd 不足往单侧缓慢偏移则是积分项缺失。3.3 竞赛地图上容易翻车的三个场景第一是十字路口。5 路全黑或全白时不同程序反应完全不同常见做法是维护“上次有效误差”并保持输出等到越过路口后再按任务方向拐弯而不是在路口中央立刻转向否则车身会骑在十字交叉点上摇摆。第二是起跑死区。车身刚放上赛道时可能没压线此时误差计算出来的数值没有意义。程序启动后先强制开环直行 200ms或者等中间传感器第一次压线后再切入闭环能避免起跑瞬间原地打转。第三是环境光干扰。竞赛场地灯光从正上方打下来白色地板反光会把红外模块的阈值冲掉。常见做法是上电时做一次阈值自校准先扫一遍读最大值和最小值把判定阈值设为两者中点而不是用源码里的固定比较值。4. 智能搬运车的抓取与投放舵机标定、动作状态机与超时保护4.1 舵机角度到 PWM 占空比的标定SG90 这类模拟舵机的控制逻辑是固定 50Hz 周期靠脉宽决定角度0 度对应 500us180 度对应 2500us。角度转脉宽的公式很简单但机械臂实际限位往往达不到理论两端。void servo_set_angle(uint8_t angle) { /* 20ms 周期下, 500us~2500us 线性对应 0~180 度 */ uint16_t pulse_us 500 (uint16_t)angle * 2000 / 180; /* TIM1 计数频率 1MHz, 比较值直接用 us 为单位 */ TIM_SetCompare1(TIM1, pulse_us); }机械结构装好后理论角度并不可信我一般先用手把夹爪掰到两个机械极限位再写一段角度扫描代码把夹爪实际咬合、张开、抬臂到顶、下放取料这几个关键姿势的角度记下来写进自己的标定表。这张表比源码里的注释更值得放进项目说明因为它记录了这台车的真实装配结果。动作实测角度对应脉宽夹爪闭合32 度856us夹爪张开128 度1922us抬臂到顶95 度1556us下放取料55 度1111us标定表在不同车上不通用换了支架或换了舵机都要重新扫一遍。这里最容易犯的错是把角度宏定义写死成理论值现场更换机械臂后不做重新标定导致夹爪合不拢、物料半路掉落。4.2 用状态机组织“取料—搬运—投放”完整流程搬运任务天然是一段顺序动作但顺序动作不等于简单的延时串联。用状态机组织的好处是每个时刻只有一个状态在“负责”循迹、抓取、投放三种逻辑不会互相抢占控制权。typedef enum { TASK_INIT, TASK_TRACK_TO_PICK, TASK_GRAB, TASK_TRACK_TO_DROP, TASK_RELEASE, TASK_DONE } task_state_t; task_state_t task_state TASK_INIT; void task_runner(void) { switch (task_state) { case TASK_INIT: servo_set_angle(95); /* 抬臂, 防止出发时刮到物料 */ task_state TASK_TRACK_TO_PICK; break; case TASK_TRACK_TO_PICK: if (pick_zone_flag) { /* 循迹模块检测到取料区 */ set_motor_speed(MOTOR_LEFT, 0); set_motor_speed(MOTOR_RIGHT, 0); task_state TASK_GRAB; } break; case TASK_GRAB: servo_set_angle(32); /* 夹爪闭合 */ delay_ms(300); task_state TASK_TRACK_TO_DROP; break; /* TASK_TRACK_TO_DROP / TASK_RELEASE / TASK_DONE 逻辑类似 */ default: break; } }状态迁移只由标志位触发不在中断里直接修改状态这是源码里值得重点看的设计边界。把抓取动作和循迹放在同一个时间片里也是常见误用舵机运动时 PWM 占空比会被分时影响电机出现可察觉的抖动比赛录视频时尤其明显。4.3 超时保护与失败恢复竞赛现场车况不如调试时理想夹爪空抓、车轮打滑、物料卡住都可能导致某个状态永远不退出。给每个状态加超时判断是源码里体现现场经验的一处设计。uint32_t state_enter_ms HAL_GetTick(); while (task_state ! TASK_DONE) { task_runner(); /* 单个状态超过 8 秒未能迁移, 判定为卡死, 复位到起点 */ if (HAL_GetTick() - state_enter_ms 8000) { dbg(0, state %d timeout, reset to INIT\n, task_state); set_motor_speed(MOTOR_LEFT, 0); set_motor_speed(MOTOR_RIGHT, 0); task_state TASK_INIT; state_enter_ms HAL_GetTick(); } }超时值不是越小越好取料区识别本身可能就需要 2 到 3 秒我一般按“正常动作耗时上限再乘 1.5”来设。超时后直接回到 INIT 而不是停在原地至少保证小车能重新开始一轮尝试不把整场比赛耗在同一个死循环里。这个保护逻辑在项目说明里通常不会展开写但恰恰是现场不翻车的底线。5. 源码阅读顺序与现场排错的三个可复制手段5.1 按依赖顺序读源码不要按文件列表读解压之后的文件列表一般有 bsp、app、doc 几类按字母序读会迷失在杂乱的驱动文件里。我一般按“启动与时钟配置 → 电机测速 → 循迹与传感器 → PID 控制 → 任务状态机”的顺序读一路读下来正好覆盖从底层寄存器到上层业务的全链路。注意一件事项目说明里的接线图可能是早期版本遇到引脚宏定义和接线图冲突时以源码里的#define为准再回头改线。5.2 串口分级打印宏回来能看波形比赛现场没法接调试器串口是唯一稳定的观测通道。把打印封装成带级别和时间的宏只在状态切换、PID 输出、异常分支三个位置打点数据量可控回来后能直接画出误差和输出的关系曲线。#define DBG_LEVEL 1 #define dbg(level, fmt, ...) \ do { \ if ((level) DBG_LEVEL) \ printf([%lu] fmt, HAL_GetTick(), ##__VA_ARGS__); \ } while (0)调参时把DBG_LEVEL调高观察误差是否周期摆动正常比赛时调到 0避免串口阻塞主循环。时间戳用HAL_GetTick()而不是自加计数器因为能直接看到两个事件之间的绝对间隔判断舵机动作和电机响应是否拖沓。5.3 按键单步执行 LED 状态码把任务状态机改成“按键触发才迁移”的调试模式现场就能手动验证每个动作的机械行程不需要满场追着车跑。具体做法是加一个DEBUG_STEP编译开关按键中断里只置一个标志位主循环检测到标志后才执行下一个状态迁移。LED 按状态码闪烁是比串口更快的排错手段。车出问题时先看灯的颜色和闪烁次数2 次快闪表示编码器计数没有更新3 次表示舵机堵转检测触发4 次表示超时复位生效。这类状态码直接映射到源码里的枚举值现场不开电脑就能定位到具体模块比逐一拔线排查省得多。本文还有配套的精品资源点击获取