简介:围绕STM32智能小车的黑线循迹与红外避障综合实验,这套工程源码面向刚接触嵌入式控制、希望从电机驱动与传感器采集入手理解小车运动逻辑的学习者。代码以定时器配置电机为核心,封装前进、刹车、左转、左旋转、右转、右旋转、后退等基础运动函数,注释较为详细,便于把巡线判断与避障策略拆成可读、可改的模块,适合课程实验与入门小车项目对照练习。压缩包共192个文件,约6.49MB,主体是34个c源文件与35个h头文件,覆盖外设驱动和工程组织;同时保留Keil工程配置、编译中间文件以及hex烧录文件,拿到后可直接对照目录梳理定时器、GPIO与中断相关配置。已有1187人学习,说明其在基础巡线避障实验中有一定参考价值,读者可据此快速定位运动控制与传感器处理的代码入口,并在此基础上调整速度、转向时长与避障阈值。
1. 从一块循迹底板说起:STM32 智能小车综合实验到底要解决什么
很多人拿到 STM32 智能小车套件后做的第一件事,是让电机转起来;结果轮子一转,黑线循迹和红外避障就全乱了——车要么在原地画龙,要么一头顶到墙上。这套 ZYSTM32-A1 综合实验的价值,不在于把两个实验拼在同一个工程里,而在于让黑线循迹和红外避障共用一块主控、一套主循环、一份状态机,并且在两种工况切换时不打架。它适合刚点完 LED、想完整做第一个嵌入式小车项目的人:课程设计、毕设前期验证、入门竞赛练手都能直接用。读完你应该拿到一套能跑、能调、能讲清楚“为什么这么写”的方案,而不是一张只会照抄的接线图。
2. 需求拆解与硬件选型:三种工况、五路红外和一套电机驱动
动手画原理图之前,先要把“这个小车要干什么”写死。需求边界不清,后面选型就会一路纠结:传感器到底买三路还是五路,驱动到底用 L298N 还是 TB6612,主控用 51 够不够。这一章按功能边界、器件选型、引脚分配的顺序把这些定下来。
2.1 功能边界:循迹、避障、协同三种工况各自的验收口径
我把这套小车的功能切成三个工况,每个工况都要有能测的验收口径,不然答辩时说不清“做完了”是什么状态。
第一种是纯循迹工况,场地是一条 1.5~2 cm 宽的黑色电工胶带,直道加弯道,最小曲率半径按 30 cm 设计。验收口径是:从起点到终点约 5 m 全程不脱线,单圈时间稳定在 ±1 s 以内。之所以把线宽限定在 2 cm 以内,是因为五路红外传感器的探头间距通常在 10~15 mm,线太宽会让相邻两路同时压线,偏差计算的平均值会变钝,弯道就跟不上。
第二种是纯避障工况,车前 15~30 cm 出现障碍物时,小车要在 0.3 s 内做出反应:单侧有障碍就向另一侧让,正前方堵死就先后退再转向。这个距离不是随口定的,红外避障模块的有效检测距离通常在 3~80 cm 之间可调,调太远会在弯道被场地边沿误触发,调太近则留给转向的时间不够。
第三种是协同工况,也是这套“综合实验”真正的难点:小车正在循迹,前方突然出现障碍物,要先切到避障动作,绕开之后还得自己找回黑线继续跑。很多人的工程在单工况测试时都正常,一到协同就露馅——避障结束后车在原地转圈找线,或者刚回到线上又被同一个障碍物触发第二次。
提示:需求边界一次就写清楚“不做什么”,比写“做什么”更重要。这套方案不做摄像头视觉、不做 SLAM 建图、不做电机编码器闭环调速,纯粹用开关量传感器加 PWM 差速实现。边界定了,后面所有取舍都有依据。
2.2 主控与传感器选型:为什么是 STM32F103 加五路 TCRT5000
主控这块,市面上常见三条路:8 位机、STM32F103 这类 Cortex-M3、以及跑 Linux 的板子。选 8 位机的理由是便宜、资料老;但它的问题在于定时器和硬件 PWM 通道太少,两路电机 PWM 加上一路 1 ms 系统节拍,基本就把资源吃满了,再想加串口调试都紧张,中断响应也慢。
STM32F103C8T6 这类芯片刚好卡在甜点上:72 MHz 主频、多个通用定时器、通道足够跑两路互补 PWM 加一路 1 ms 节拍,GPIO 数量也够接五路循迹加两路避障。选它的真正理由不是“性能强”,而是“外设资源刚好够,而且不用抢”。
循迹传感器选五路 TCRT5000 反射式模块,而不是摄像头。摄像头能拿到更丰富的路面信息,但要做图像二值化、边缘提取,帧率还受限于串口传输,对一个小车项目来说投入产出比不划算。五路红外的信息量是 5 bit,虽然粗,但实时性极好——GPIO 一读就有结果,控制周期可以压到 10 ms。代价是它对地面反光敏感,浅色瓷砖上和在深色地毯上,同一个模块的输出可能完全相反,这一点必须靠电位器现场整定。
避障用的也是红外反射式模块,检测距离可调。这里有个容易被忽略的差别:循迹模块是贴地近距离检测,避障模块是朝前十几厘米检测,两者虽然都是红外反射原理,但发射功率和比较器阈值完全不同,不能混用同一型号。至于电机驱动,L298N 和 TB6612 都行,区别在压降和效率:
| 对比项 | L298N | TB6612FNG |
|---|---|---|
| 导通压降 | 约 1.4~2 V,电池电压低时明显掉速 | 约 0.5 V,低压差 |
| 效率与发热 | 发热大,长时间跑需要散热片 | 发热小,可长时间连续工作 |
| 逻辑电平 | 5 V 逻辑,3.3 V 主控需注意电平匹配 | 兼容 3.3 V 逻辑 |
| PWM 频率上限 | 建议 10 kHz 以内 | 可上到 20 kHz 以上 |
| 适合场景 | 教学演示、短时跑动 | 长时间连续跑、电池供电 |
我一般建议这套实验用 TB6612,不是因为贵,而是因为电池从 8.4 V 掉到 7 V 的时候,L298N 会先掉 2 V,电机实际只拿到 5 V,速度肉眼可见地变慢,而循迹的 PD 参数是按某个速度整定的,速度一变参数就全部作废——这种“跑两圈就变慢”的问题排查起来非常磨人。
2.3 引脚与外设资源分配表
选型定完之后先把引脚表排出来,避免写到一半发现某个引脚被复用冲突。下面这张表是这套方案实际用的分配,可以直接照着接线:
| 功能 | 引脚 | 模式/外设 | 备注 |
|---|---|---|---|
| 循迹 最左 | PB0 | GPIO 输入上拉 | 权重 -4 |
| 循迹 左 | PB1 | GPIO 输入上拉 | 权重 -2 |
| 循迹 中 | PB2 | GPIO 输入上拉 | 权重 0 |
| 循迹 右 | PB3 | GPIO 输入上拉 | 默认 JTDO,需关 JTAG |
| 循迹 最右 | PB4 | GPIO 输入上拉 | 默认 NJTRST,同上 |
| 避障 前左 | PB5 | GPIO 输入上拉 | 检测到障碍为低 |
| 避障 前右 | PB6 | GPIO 输入上拉 | 检测到障碍为低 |
| 左电机 PWM | PA6 | TIM3_CH1 | 10 kHz |
| 左电机 IN1/IN2 | PA4 / PA5 | GPIO 推挽输出 | 方向控制 |
| 右电机 PWM | PA7 | TIM3_CH2 | 10 kHz |
| 右电机 IN3/IN4 | PB12 / PB13 | GPIO 推挽输出 | 方向控制 |
| 调试串口 | PA9 / PA10 | USART1 | 115200,打印状态 |
| 1 ms 节拍 | TIM2 | 定时中断 | 系统时基 |
| 启动按键 | PA0 | GPIO 输入 | 按下开始跑 |
注意:PB3 和 PB4 上电默认被 JTAG 占用,不关掉的话读回来永远是固定值,表现为“这两路传感器失灵”。初始化里加一句
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);只保留 SWD,问题就没了。这个坑几乎每个第一次用 F103 的人都会踩一次。
3. 总体架构设计:三层结构、两个节拍和一个状态机
引脚定完,接下来是软件架构。小车项目最容易写成“一个大 while 循环里塞满 if-else”,能跑,但改一处崩三处。这套方案分成三层加一个状态机,好处是每层都能单独测:传感器可以只打印不驱动电机,电机可以只给定值不看传感器,状态机可以拿串口喂假数据验证切换逻辑。
3.1 感知层、决策层、执行层的职责划分
感知层的职责只有一个:把物理量翻译成干净的整数。五路循迹读出来是 5 个 bool,经过权重加权平均变成 -4 到 +4 的偏差值;两路避障读出来是两个 bool,但要做 3 次采样的多数表决,滤掉瞬时的抖动。感知层绝对不做判断,也不调用电机函数——这条纪律很重要,否则后面想换传感器就得动半个工程。
决策层是整个方案的大脑,包含两件事:控制律和状态机。控制律输入偏差、输出转向量;状态机输入避障标志和丢线标志,输出当前应该执行哪种动作。把这两件事分开写,是因为它们的调试方法完全不同:控制律看的是曲线和参数,状态机看的是状态跳转表。
执行层负责把决策结果翻译成电机能懂的东西:给定 -999 到 +999 的带符号占空比,映射成 TIM 比较寄存器的值和两个方向引脚的高低电平。执行层还要做限幅,因为差速时base + turn很容易超过量程。
三层的调用频率也不一样。感知层每 10 ms 采一次,决策层每 20 ms 算一次,执行层是跟随决策层立即输出的。这个频率差不是随便定的:10 ms 采样配合 0.3 m/s 的车速,每 3 mm 走一次采样,五路探头的间距在 10 mm 量级,采样间隔远小于探头间距,不会漏掉线。
3.2 定时器节拍:1 ms 基准怎么切出 10 ms 与 20 ms 任务
裸机项目里最省事的调度方式是“1 ms 中断打拍子,主循环看拍子做事”。TIM2 配置成 1 ms 中断,中断里只做一件事:g_tick++。中断函数里千万不要读传感器、不要算 PID、更不要延时——中断里做的事情越多,抖动越大,PWM 输出的节拍就越不稳。
/* TIM2:72MHz / 72 = 1MHz,ARR = 999 → 1ms 中断一次 */ volatile uint32_t g_tick = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_tick++; /* 中断里只做计数,其他全交给主循环 */ } } int main(void) { uint32_t t_sample = 0, t_ctrl = 0; board_init(); /* 时钟、GPIO、PWM、串口、TIM2 */ while (1) { if (g_tick - t_sample >= 10) { /* 10ms:采一次传感器 */ t_sample = g_tick; sensor_scan(); /* 更新 g_err、g_ob_l、g_ob_r */ } if (g_tick - t_ctrl >= 20) { /* 20ms:跑一次决策 */ t_ctrl = g_tick; car_step(); /* 状态机 + 控制律 + 输出 */ } debug_report(); /* 空闲时打印,不影响节拍 */ } }这里用g_tick - t_sample >= 10而不是g_tick % 10 == 0,是为了防止某次任务执行时间过长导致漏拍——减法写法即使错过一个节拍,下一次也会立刻补上,而取模写法会整整丢掉一轮。参数上,10 ms 和 20 ms 是最常用的组合;如果场地弯道特别急,可以把决策周期压到 10 ms,代价是串口打印会挤占时间,调试信息要相应减量。
3.3 状态机设计:循迹态、避障态、搜索态、停车态怎么切
状态机是这个方案里最值得写进答辩的部分。四个状态,跳转条件写死,谁也别抢谁的活:
| 当前状态 | 跳转条件 | 下一状态 | 动作 |
|---|---|---|---|
| 循迹 | 左右避障同时触发 | 后退 | 双轮反转 300 ms |
| 循迹 | 仅左侧触发 | 右让 | 右转差速,持续 400 ms |
| 循迹 | 仅右侧触发 | 左让 | 左转差速,持续 400 ms |
| 循迹 | 偏差值 = 丢线标志 | 搜索 | 沿上次偏差方向原地找线 |
| 后退 | 障碍消失且计时结束 | 循迹 | 恢复 PD 循迹 |
| 左让 / 右让 | 计时结束且障碍消失 | 循迹 | 恢复 PD 循迹 |
| 搜索 | 重新检测到黑线 | 循迹 | 清空 PD 的历史误差 |
| 任意 | 按键长按 2 s | 停车 | PWM 归零 |
两个设计细节决定了它能不能稳定跑。第一是退出条件必须是“计时结束且障碍消失”,只写“障碍消失”会在障碍物边沿反复横跳,车头就在那里来回抖。第二是从避障或搜索回到循迹时,必须清空 PD 的last_err,否则上一状态残留的历史误差会让微分项突然打出一个大输出,车会猛地甩一下。
4. 关键模块实现:偏差计算、比例微分循迹与避障优先级
架构讲完,落到代码。这一章四段代码对应四个模块,每段都能单独烧进去验证:先只打印偏差,确认五路读数对得上位置,再接控制律,最后接电机。跳过任何一步,后面调参数都是盲调。
4.1 五路红外采样与加权偏差计算
/* 极性统一:TCRT5000 模块 DO 压黑线时输出低电平,取反成 1 = 检测到黑线 */ #define S_LL (!GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0)) #define S_L (!GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1)) #define S_M (!GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_2)) #define S_R (!GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_3)) #define S_RR (!GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_4)) #define LINE_LOST 99 static const int16_t kWeight[5] = { -4, -2, 0, 2, 4 }; /* 位置权重,单位"半格" */ /* 返回 -4 ~ +4 的偏差,负数表示线偏左;返回 LINE_LOST 表示五路全白 */ int16_t line_read_error(void) { uint8_t s[5]; int16_t sum = 0, cnt = 0; s[0] = S_LL; s[1] = S_L; s[2] = S_M; s[3] = S_R; s[4] = S_RR; for (uint8_t i = 0; i < 5; i++) { if (s[i]) { sum += kWeight[i]; cnt++; } } if (cnt == 0) return LINE_LOST; return sum / cnt; /* 取平均,抑制多路同时压线造成的偏差跳变 */ }第一段代码的核心是“极性收敛”和“取平均”两个动作。极性收敛指的是不管模块输出高有效还是低有效,都在宏定义这一层统一成“1 = 检测到黑线”,这样后面所有逻辑只认一种约定,换一批模块只要改五个宏。取平均是为了处理一种常见情况:车正好压在两条线的交汇处,或者线宽超过探头间距,两三路同时为 1,此时直接把权重相加会得到 -6 这种超出量程的值,除法一平均就回到 -2,量程始终锁在 -4 到 +4 之内。
丢线返回 99 是个约定值,比返回 0 好——返回 0 会被当成“线在正中间”,车会保持直行冲出场地;返回 99 就能在状态机里单独分支处理。
4.2 循迹控制律:为什么用 PD 而不是纯比例
typedef struct { float kp; float kd; int16_t last_err; int16_t out_limit; } pd_t; static pd_t g_pd = { 12.0f, 30.0f, 0, 320 }; /* 初始参数,需按实车整定 */ /* 输入偏差,输出转向量(带符号),已限幅 */ int16_t track_output(int16_t err) { int16_t diff; float out; diff = err - g_pd.last_err; g_pd.last_err = err; out = g_pd.kp * err + g_pd.kd * diff; if (out > g_pd.out_limit) out = g_pd.out_limit; if (out < -g_pd.out_limit) out = -g_pd.out_limit; return (int16_t)out; } /* 差速执行:base 是基础速度,turn 是转向修正 */ void track_run(int16_t err) { int16_t base = 250; /* 0 ~ 999 */ int16_t turn = track_output(err); motor_set(MOTOR_L, base + turn); motor_set(MOTOR_R, base - turn); }只用比例项(P)的问题是明显的:偏差一出现就立刻修正,修正过头就反向超调,车在直道上左右小幅摆动,也就是俗称的“画龙”。微分项(D)看的是偏差的变化量,偏差快速变化时提前给一个反向的抑制量,等效于给系统加了阻尼。这套参数里kp = 12、kd = 30是初始值,实际整定顺序是:先把kd设为 0,kp从 5 开始往上加,加到车在直道上刚好开始小幅摆动;然后把kp退回一档,开始加kd,加到摆动消失为止。
out_limit设成 320 而不是 999,是因为它要跟base = 250配合——如果限幅太宽,一次修正就能让一侧轮子反转,车会原地打转。经验上把out_limit控制在base的 1.2 倍左右,即单侧轮子最多减到接近 0 但不会反转。
4.3 避障优先级与转向策略
#define OB_L (!GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_5)) /* 检测到障碍为 1 */ #define OB_R (!GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_6)) typedef enum { ST_TRACK, ST_BACK, ST_AVOID_L, ST_AVOID_R, ST_SEARCH, ST_STOP } state_t; static state_t g_state = ST_TRACK; static uint16_t g_state_tick = 0; /* 状态内计时,单位 20ms */ /* 每 20ms 调用一次,返回新状态 */ state_t car_sm_step(uint8_t ob_l, uint8_t ob_r, int16_t err) { g_state_tick++; switch (g_state) { case ST_TRACK: if (ob_l && ob_r) { g_state_tick = 0; return ST_BACK; } if (ob_l) { g_state_tick = 0; return ST_AVOID_R; } if (ob_r) { g_state_tick = 0; return ST_AVOID_L; } if (err == LINE_LOST){ g_state_tick = 0; return ST_SEARCH; } return ST_TRACK; case ST_BACK: /* 后退 15 拍 = 300ms */ if (g_state_tick >= 15 && !ob_l && !ob_r) return ST_TRACK; return ST_BACK; case ST_AVOID_R: /* 右让 20 拍 = 400ms */ if (g_state_tick >= 20 && !ob_l) { g_pd.last_err = 0; return ST_TRACK; } return ST_AVOID_R; case ST_AVOID_L: if (g_state_tick >= 20 && !ob_r) { g_pd.last_err = 0; return ST_TRACK; } return ST_AVOID_L; case ST_SEARCH: /* 最多找 1.5s,找不到就停 */ if (err != LINE_LOST) { g_pd.last_err = 0; return ST_TRACK; } if (g_state_tick >= 75) return ST_STOP; return ST_SEARCH; default: return ST_STOP; } }这段状态机里有三个刻意写进去的保护。第一,每个避障状态的退出都要求“计时到 + 障碍消失”两个条件同时成立,缺一个都会在障碍物边缘反复切换。第二,ST_AVOID_R的退出只判断!ob_l,不判断!ob_r,因为向右让的时候右侧传感器可能会扫到新的障碍,如果两个都要求消失,车会卡死在让行状态。第三,搜索状态有超时上限,1.5 s 还找不到线就停车,避免车在原地无限转圈烧电机——这个上限比“一直找下去”更实用,至少在测试时你能看出它停在了哪里。
4.4 PWM 调速与电机方向控制
#define MOTOR_L 0 #define MOTOR_R 1 void motor_pwm_init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; /* TIM3_CH1 / CH2 */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); /* 72MHz / 72 = 1MHz 计数,ARR = 99 → PWM 频率 10kHz */ tb.TIM_Prescaler = 71; tb.TIM_Period = 99; tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tb); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_OCPolarity = TIM_OCPolarity_High; oc.TIM_Pulse = 0; TIM_OC1Init(TIM3, &oc); TIM_OC2Init(TIM3, &oc); TIM_Cmd(TIM3, ENABLE); } /* duty: -999 ~ +999,负数为反转 */ void motor_set(uint8_t ch, int16_t duty) { if (duty > 999) duty = 999; if (duty < -999) duty = -999; if (ch == MOTOR_L) { GPIO_WriteBit(GPIOA, GPIO_Pin_4, duty >= 0 ? Bit_RESET : Bit_SET); GPIO_WriteBit(GPIOA, GPIO_Pin_5, duty >= 0 ? Bit_SET : Bit_RESET); TIM_SetCompare1(TIM3, (uint16_t)(duty >= 0 ? duty : -duty)); } else { GPIO_WriteBit(GPIOB, GPIO_Pin_12, duty >= 0 ? Bit_RESET : Bit_SET); GPIO_WriteBit(GPIOB, GPIO_Pin_13, duty >= 0 ? Bit_SET : Bit_RESET); TIM_SetCompare2(TIM3, (uint16_t)(duty >= 0 ? duty : -duty)); } }PWM 频率取 10 kHz 有两个理由:一是高于人耳可听范围的下沿,电机不会发出尖锐的啸叫声;二是 TB6612 这类 MOS 驱动在 10 kHz 时的开关损耗可以接受,长时间跑不会烫手。如果换成 L298N,频率不建议超过 10 kHz,否则三极管来不及完全导通,效率会更低。
motor_set里把“方向引脚”和“占空比”这两件事绑在一个函数里,是为了避免出现“改了占空比忘了改方向”导致电机反着转的问题。传入负占空比时,方向引脚翻转、比较寄存器取绝对值,逻辑上单调,接线反了也只需要在调用处加个负号。
5. 避坑与排查:这套小车上最容易翻车的五个点
前面四章讲的是“应该怎么写”,这一章讲的是“实际会怎么坏”。这五个问题在我自己搭和帮别人调的过程中反复出现,每一个都有明确的现象、成因和解法,照着对号入座能省掉大量时间。
5.1 一上电就往一边猛冲,或者读出来的传感器永远不变
现象:车通电后两个轮子朝相反方向转,或者串口打印出来的五路循迹值永远是1 1 1 1 1。
原因:前者是电机方向引脚的电平组合和预期相反,两个轮子一个正转一个反转;后者几乎都是 PB3、PB4 被 JTAG 占用。F103 复位后 PB3 默认是 JTDO、PB4 默认是 NJTRST,配置成普通 GPIO 输入也没用,读回来的值固定。
解决:方向问题不要靠改接线,在motor_set里把对应通道的 IN1/IN2 互换即可,接线保持整齐便于排查。JTAG 问题在 GPIO 初始化之前加一句GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);,只保留 SWD 下载口,烧录不受影响。
5.2 直道跑得很稳,一进弯道就冲出去
现象:场地直道上小车走得笔直,进入半径 30 cm 的弯道后,车头贴着外侧冲出去,纠正不回来。
原因:三种可能,按概率排序是控制周期太长、kp偏小、差速饱和限幅太窄。控制周期 20 ms 时,车速 0.4 m/s 每拍走 8 mm,弯道上的偏差累积得很快,等算出来已经晚了;kp太小则修正力度不够;out_limit太小则想修正也修不动。
解决:先把决策周期压到 10 ms 试一次,如果明显改善就是周期问题。否则把out_limit从 320 放到 400,再看弯道表现,最后才动kp。顺序很重要——先放限幅再调增益,不然你会以为kp不够,一路加大,结果直道上开始画龙。
5.3 避障模块在深色地面上一直误报
现象:场地换到深色地胶或者黑色桌面上,避障模块不管前面有没有东西都输出“有障碍”,小车一直在后退。
原因:红外反射式模块靠接收反射光强判断距离,深色表面吸光,接收端收到的光很弱,比较器就判定为“没有反射 → 有障碍”。这不是模块坏了,是原理决定的。
解决:一是现场重新调电位器,把阈值调到实际能用的位置;二是给模块加个遮光筒,用黑色热缩管剪一段套在探头外面,挡住环境光干扰;三是软件上做多数表决,连续 3 次采样有 2 次以上为 1 才算真障碍。第三条最省事,代价是响应延迟一个采样周期,10 ms 完全能接受。
5.4 电机一转,单片机就复位
现象:空载时串口打印正常,一旦给电机加占空比,单片机随机复位,串口输出乱码,或者程序跑飞。
原因:电机启动瞬间电流是堵转电流的量级,电池内阻上产生压降,主控的供电被拉低到复位阈值以下。另外电机是感性负载,换向时会产生反向尖峰,通过电源和地线串回主控。
解决:硬件上,电机电源和主控电源分开走线,只在电池端单点共地;电机两端并 0.1 μF 电容加一个续流二极管;主控电源入口加 100 μF 电解电容和 0.1 μF 陶瓷电容。软件上,启动时让占空比斜坡上升,每 20 ms 加 50,而不是直接从 0 跳到 250,冲击电流能小一半。这套小车上电就复位的问题,九成是这两条之一没做到。
5.5 避障结束回不了线,在原地转圈
现象:小车成功绕开障碍物,但之后一直在原地左右转,找不到黑线,最后超时停车。
原因:绕障动作把车带离黑线太远,五路探头全部落在白区,返回LINE_LOST,而搜索逻辑只是原地转,转向方向和线的实际位置相反。另外g_pd.last_err没有清零,微分项一上来就是一个大值。
解决:搜索时不要原地转,改成“沿着绕障的反方向小半径画弧”,也就是右让之后向左找,左让之后向右找,这个方向和绕开时车头偏转的方向相反,命中率最高。同时把搜索速度降到基础的 60%,慢一点更容易被探头扫到。回到线之后第一件事是清空last_err,让 PD 从零开始。
6. 验收与答辩:用一套可复现的测试把车“跑通”讲清楚
功能做完不等于项目做完,验收测试要能重复、能记录、能拿给不懂的人看。我一般按下面这张表跑一遍,每项测三次取最差的那次,因为答辩现场发挥失常是常态,按最差成绩准备才稳。
| 序号 | 测试项 | 测试方法 | 合格线 |
|---|---|---|---|
| 1 | 直道循迹 | 3 m 直线胶带,全速通过 | 3 次全部不脱线 |
| 2 | 弯道循迹 | R=30 cm 连续弯,跑 5 圈 | 脱线次数 ≤ 1 |
| 3 | 单侧避障 | 车前左侧 20 cm 放障碍物 | 0.3 s 内开始右让,绕过后 2 s 内回到线上 |
| 4 | 正前避障 | 车前正中 20 cm 放障碍物 | 先后退再转向,不撞不卡死 |
| 5 | 协同 | 弯道中段放障碍物 | 完成绕障并继续循迹,全程不超时停车 |
| 6 | 掉电重启 | 连续上电 20 次 | 20 次均正常启动,无复位异常 |
测试时把串口打印打开,每 100 ms 输出一行err / ob_l / ob_r / state / duty,跑完之后把日志导出来,画一条偏差随时间的曲线。这张曲线在答辩时比任何口头描述都有说服力——评委一眼能看出来你的系统在弯道上是“提前修正”还是“撞上去才修”,这两种的控制水平完全不同。
答辩讲述我建议按“工况 → 取舍 → 一次翻车”的顺序走,不要从技术栈开始讲。先说清楚这套车分了哪三种工况、每种工况的验收口径是什么,这能证明你想过需求;再说你做了什么取舍,比如为什么用五路红外不用摄像头、为什么用 TB6612 不用 L298N、为什么控制周期是 20 ms 而不是 5 ms,每个取舍都要落到具体的数字上;最后讲一次你踩过的坑和怎么定位的,比如“车一进弯就冲出去,我一开始以为是 kp 不够,加了两轮之后直道开始画龙,才回头去查控制周期,发现 20 ms 下每拍走 8 mm,改成 10 ms 就好了”。这个故事里包含了现象、错误假设、纠正过程,比背概念更能说明你真的动手做过。
从那以后我每次做这种带传感器的车类项目,都会在写控制逻辑之前先把“只打印不驱动”的版本跑通,让串口把五路原始值、两路避障值、偏差值一起打出来,人工推着车沿场地走一遍,确认每一路的跳变位置和物理位置对得上。这一步花不了二十分钟,但能挡掉后面八成莫名其妙的“算法不收敛”——毕竟传感器读错了,再好的 PD 参数也是白搭。希望帮到你。
本文还有配套的精品资源,点击获取