
简介这是一份完整的智能小车控制系统设计PDF文档面向嵌入式、物联网及Android开发方向的在校学生和工程师可作为课程设计、毕业设计或相关课题的参考。内容围绕基于手绘控制的智能小车展开涵盖STM32F103处理器控制、电机驱动、寻迹、超声波测距、摄像头采集、WiFi转UART通信等硬件模块以及Android移动端的登录、网络通信、摄像头监控、手绘路径、参数设置等软件模块并重点介绍了网络通信实现步骤和基于A*算法的任意两点最短路径规划。资源仅1个PDF文件大小226KB结构包含摘要、主要研究内容、小车软件模块设计、Android移动端开发及结束语等章节。目前已有103人学习下载对需要快速建立系统框架、理解手绘控制与自动规划实现逻辑的读者具有较好的实用价值。1. 智能小车控制系统设计从零搭建一套能跑、能停、能避障的最小系统智能小车控制系统设计说白了就是把「单片机决策 电机驱动 传感器感知 电源分配」这四件事用代码串成一个闭环让它能在你设定的规则下自主行动。很多人一开始以为难点在算法真正动手才发现硬件选型不匹配、PWM 频率不对、PID 参数调不出来、传感器数据抖动得没法看——这些才是让项目卡壳的元凶。这篇笔记会从系统拆解讲起一路给你可复现的最小系统清单、核心控制代码、参数整定方法和排查路径让你照着搭完至少能让小车稳稳跑直线、按预期转弯、在障碍物前停下来。这套方案适合三类人正在准备课程设计或竞赛的学生、想从纯软件开发转嵌入式方向的技术人、以及想快速验证一个移动机器人想法的硬件爱好者。下文提到的代码和参数均基于我实际调过的某模拟项目X芯片以常见国产与进口单片机为主你换用同级别的其他型号也完全成立只是引脚号需要对应调整。2. 系统框架与硬件选型先定控制回路再定物料清单顺序别反2.1 控制回路是系统设计的骨架感知-决策-执行-反馈智能小车控制系统的本质是一个闭环这个闭环在任何一本教材里都有但实际落地时大家容易犯一个错先买齐一堆传感器和电机再想怎么把它们拼起来。正确做法是先画出控制框图明确信号流向否则后面每加一个模块都要改一遍主循环结构。我常用的最小控制回路分四层感知层编码器测轮速、红外/超声波测障碍物距离、灰度传感器循迹。它们的输出要么是数字电平要么是模拟电压或 PWM 脉冲最终都要归一化成程序里的变量。决策层单片机根据感知数据运算常见策略是 PID比例-积分-微分控制叠加简单的状态机来做避障或循迹模式切换。执行层通过 H 桥驱动芯片或集成电机驱动模块如常见 L298N、TB6612 类控制直流电机正反转和转速。反馈层编码器脉冲数换算成实际速度回送给决策层形成闭环。设计上的一个关键取舍是如果只做开环控制不给编码器反馈小车通常能「动起来」但很难「走直」。电机出厂参数有离散性两个同型号电机的空载转速可能差 5%电池电压下降时转速也会变这些误差在没有反馈时只能靠肉眼修正非常痛苦。我给的方案里强制加入编码器闭环理由很简单闭环之后的代码量只增加大约 80 行但直线稳定性提升是肉眼可见的——开环时小车跑 3 米就偏出去闭环后基本能保持一条直线。这一项投入性价比极高。2.2 最小硬件清单与选型理由照着这张表买不踩接口坑我按「成本可控、资料多、接线简单」三个标准筛出了一套最小清单你照着买就行。下表是我用过的某套配置属于最常见的组合方式不同商家对应物料型号有差异务必以实物丝印为准。模块选型建议关键参数为什么这么选主控板常见 ARM Cortex-M 系列开发板引脚不少于 20 个、定时器不少于 4 路编码器测速要占用定时器外部计数通道引脚太少会非常痛苦电机驱动集成 H 桥驱动模块持续电流 2A 以上带散热片普通 TT 电机堵转电流可能冲到 1.5A驱动不够会烧直流电机带霍尔编码器的减速电机减速比 1:30空载转速约 300RPM编码器直接嵌在电机尾部省去外加码盘的空间传感器红外避障模块 灰度循迹模块红外探测距离可调2-30cm两种传感器凑齐可以做模式切换电源两节 18650 锂电池7.4V放电倍率 1C 以上驱动板和主控分开供电防止电机启动瞬间拉低单片机电压导致复位车架4 轮或 2 驱塑料底盘带万向轮或后驱设计2 驱比 4 驱少两个电机、两条线、两路 PWM新手容错率高2.3 供电拓扑电机、单片机、传感器必须分路这是血泪经验新手最容易翻车的地方不是程序是供电。电机启动瞬间电流很大如果传感器和单片机跟它共用一根电源线电压会被瞬间拉低产生的结果非常玄学——程序运行得好好的一加速就复位重启。我一般这样分配电源电池正极 → 电机驱动板电源输入VBAT电池正极 → 稳压模块如常见 5V 降压模块→ 单片机供电单片机 5V 输出 → 传感器模块供电注意电机驱动的逻辑电源和单片机电源最好也分开即使它们是同一块板上的不同引脚。驱动板的逻辑电源如果接了单片机的 5V而主电源用了高电压电池部分驱动芯片的逻辑电平会被「拉偏」造成输出不按预期翻转。另外所有模块的 GND 必须接在一起——绝不存在「隔离地」这回事数字地不共地信号电平就没有参考点I2C 和串口都会乱。这是新手最容易忽略的一根线。3. 核心控制代码PID 调速、避障状态机与循迹逻辑的实现顺序3.1 主循环框架先写出稳定的时基再往上叠功能程序结构建议采用非阻塞式主循环不要用delay()。原因很直接电机调速和传感器采样都需要周期性执行一旦被delay卡住编码器脉冲计数就会丢失。我的主循环模板如下// main.c 主循环框架某模拟项目X volatile uint32_t g_sys_tick 0; // 1ms 时基 void SysTick_Handler(void) { g_sys_tick; // 中断里只做计数不做任何业务逻辑 } int main(void) { Motor_Init(); Encoder_Init(); Sensor_Init(); uint32_t last_pid_time 0; uint32_t last_sensor_time 0; while (1) { uint32_t now g_sys_tick; // 每 10ms 执行一次 PID 速度闭环 if (now - last_pid_time 10) { PID_Control(); last_pid_time now; } // 每 50ms 执行一次传感器采样与状态机更新 if (now - last_sensor_time 50) { Sensor_Update(); StateMachine_Update(); last_sensor_time now; } // 主循环其余时间可以处理串口调试输出等低优先级任务 UART_Debug_Output(); } }这段代码的逻辑说明SysTick_Handler每 1ms 进一次中断只做自增不在中断里做 PID 因为中断里算浮点会阻塞其他中断主循环里用时间差判断是否该执行任务把实时性要求高的 PID 放在 10ms 周期把传感器状态机放在 50ms 周期合理错开。参数说明10ms的 PID 周期对常见 TT 电机足够因为电机的机械时间常数通常在 50ms 以上50ms的传感器采样周期适合循迹能保证 2m/s 速度下小车每移动 10cm 至少更新 4 次状态太快的采样反而会被信号毛刺干扰调试输出用的这一个串口我建议只打印控制量而不打印原始传感器值防止大数据量阻塞主循环。3.2 增量式 PID电机速度闭环的完整实现PID 是智能小车控制系统设计的核心算法。位置式 PID 输出的是绝对 PWM 占空比启动时超调大我一般推荐使用增量式 PID。增量式本质是输出 PWM 的增加量递推公式经过推导最终只跟上一次和上上次的误差有关运算量小天然带有抗积分饱和特性。// pid.c 增量式 PID 速度环某模拟项目X typedef struct { float kp, ki, kd; // 三个系数 float out_max; // 输出限幅 float err_now; // 当前误差 float err_last; // 上一次误差 float err_before_last; // 上上次误差 float output; // 当前输出 } PID_Handle; float PID_Process(PID_Handle *pid, float target, float feedback) { pid-err_now target - feedback; float delta pid-kp * (pid-err_now - pid-err_last) pid-ki * pid-err_now pid-kd * (pid-err_now - 2 * pid-err_last pid-err_before_last); // 增量累加到输出上 pid-output delta; // 输出限幅 if (pid-output pid-out_max) pid-output pid-out_max; if (pid-output -pid-out_max) pid-output -pid-out_max; // 更新历史误差 pid-err_before_last pid-err_last; pid-err_last pid-err_now; return pid-output; } // 初始化参数每 10ms 调用一次 void PID_Control(void) { float left_speed Encoder_GetSpeed(LEFT); // 单位 cm/s float right_speed Encoder_GetSpeed(RIGHT); float left_out PID_Process(g_pid_left, g_target_speed, left_speed); float right_out PID_Process(g_pid_right, g_target_speed, right_speed); Motor_SetPWM(LEFT, 50 (int)left_out); // 50 是初始占空比 Motor_SetPWM(RIGHT, 50 - (int)right_out); // 差速转向一个加一个减 }逻辑说明左右轮各一个 PID 实例共用一个目标速度。如果左轮因为阻力变慢误差变大PID 输出增量增大PWM 提高右轮方向相反地减少——这既是调速也是转向。这里的50是启动占空比实际值要看电机特性让小车以目标速度行驶时的基准占空比。增量式 PID 的好处在这个场景里很明显它输出的是 PWM 增量而不是绝对值即使初始占空比设得不太准积分项也会渐进修正不会出现积分饱和带来的大幅超调。参数说明kp初始值可以从 1.0 开始试:ki从 0.05 开始:kd从 0.2 开始。如果你发现小车低速抖、高速稳通常是kd太大把微分系数调到 0.1 以下如果小车速度有持续静差误差一直存在但不再减小说明ki太小按 0.02 步进往上加。上下限out_max我一般设为 30因为超过 30 意味着 PWM 从 20 跳到 80电机转速突变反而会让车体点头。3.3 避障状态机if-else 的嵌套别太深用状态表更清晰避障逻辑如果全写在主循环里代码会变成一坨不可读的 if-else。我用一张状态表来约束行为这也是工程上最常见的做法。// state_machine.c 避障状态机某模拟项目X typedef enum { STATE_FORWARD, // 前进 STATE_LEFT, // 左转 STATE_RIGHT, // 右转 STATE_STOP // 停止 } CarState; void StateMachine_Update(void) { static CarState state STATE_FORWARD; uint16_t front_dist Sensor_GetDistance(FRONT); uint16_t left_dist Sensor_GetDistance(LEFT); uint16_t right_dist Sensor_GetDistance(RIGHT); switch (state) { case STATE_FORWARD: if (front_dist 20) { // 优先选择障碍物距离更大的一侧转弯 if (left_dist right_dist) state STATE_LEFT; else state STATE_RIGHT; } break; case STATE_LEFT: Motor_SetPWM(LEFT, 20); // 左轮慢 Motor_SetPWM(RIGHT, 60); // 右轮快实现左转 if (front_dist 30) { state STATE_FORWARD; // 已避开回正 // 注意要恢复到左右等速 } break; case STATE_RIGHT: Motor_SetPWM(LEFT, 60); Motor_SetPWM(RIGHT, 20); if (front_dist 30) state STATE_FORWARD; break; case STATE_STOP: Motor_SetPWM(LEFT, 0); Motor_SetPWM(RIGHT, 0); break; } }逻辑说明状态机的好处是每个状态的处理逻辑互不干扰。前进时只做距离判断——当前方障碍小于 20cm根据左右哪边空间更大来选择转的方向左转时维持左轮低速、右轮高速的差速组合同时持续检测前方距离一旦大于 30cm 说明已经绕开立刻回到前进状态。参数说明20cm 和 30cm 的阈值是「触发距离」和「恢复距离」故意做出一段滞回区间。如果没有这个滞回车头在障碍边缘时会来回切换转向状态出现「犹豫式」抖动两个数值差 10cm 就能有效消除。你根据实际车速调整车速越快触发距离越要大建议车每跑快 0.5m/s触发距离增加 5cm。3.4 循迹逻辑灰度传感器的边沿检测比直接读值可靠循迹时灰度传感器输出的是数字量黑线为 0 或者 1直接用左右两个传感器的电平做差速效果很好。但新手常犯的错误是把「读取」直接拿来做判断而工程上更稳的是「边沿检测」——也就是说要看的是传感器有没有「刚从黑变到白」或「刚从白变到黑」而不是当前在不在黑上。代码如下// line_track.c 循迹边沿检测某模拟项目X #define LINE_BLACK 0 typedef struct { uint8_t last_state; uint8_t current_state; } EdgeDetector; uint8_t Edge_Rising(EdgeDetector *d) { d-current_state GPIO_ReadPin(); uint8_t rising (d-last_state LINE_BLACK) (d-current_state ! LINE_BLACK); d-last_state d-current_state; return rising; } // 循迹控制 void LineTrack_Control(void) { static EdgeDetector left_edge, right_edge; // 注意此处用的是“若左传感器上升沿出现 // 说明左轮刚离开黑线需要往左修正” if (Edge_Rising(left_edge) 1) { Motor_SetPWM(LEFT, 30); Motor_SetPWM(RIGHT, 50); } if (Edge_Rising(right_edge) 1) { Motor_SetPWM(LEFT, 50); Motor_SetPWM(RIGHT, 30); } }此处逻辑说明边沿检测省去了对同一个传感器值的持续跟踪直接在变换瞬间做一次修正。这样车的姿态会呈「微折线」式前进但整体不会跑偏。如果直接用电平判断黑线的宽度、传感器的刷新频率都会影响稳定性——宽线上传感器持续输出 0程序会反复转向效果反而不如边沿检测。参数说明循迹的修正占空比30 和 50需要根据赛道转弯半径调。弯越急修正差速越大但过大会导致小车蛇形我一般从差值 20 开始看着跑完一个弯道再微调。你如果用的是 4 路灰度传感器核心逻辑同样适用只需要把左右各一组变成一个数组的边沿检测循环。4. 传感器数据的去抖与校准不处理噪声PID 就是空中楼阁4.1 红外测距的非线性修正查表比多项式拟合更实用红外传感器输出的电压随距离变化是非线性的且不同个体特性差异明显。如果你用固定公式例如distance f(voltage)的直线方程去算大概率近距离和远距离误差都很大。工程上常用查表法把实测距离和 ADC 值做成一张对应表。// sensor_calib.c 红外传感器查表校准某模拟项目X // 实测标定ADC值 - 距离(cm) const uint16_t adc_table[] { 512, 645, 770, 910, 1050, 1180, 1320, 1485 }; const float dist_table[] { 3.0f, 5.0f, 8.0f, 12.0f, 16.0f, 20.0f, 25.0f, 30.0f }; float Infrared_GetDistance(uint16_t adc_value) { // 限幅处理超出范围直接用端点值 if (adc_value adc_table[0]) return dist_table[0]; if (adc_value adc_table[7]) return dist_table[7]; // 线性插值查找 for (int i 0; i 7; i) { if (adc_value adc_table[i] adc_value adc_table[i1]) { float ratio (float)(adc_value - adc_table[i]) / (adc_table[i1] - adc_table[i]); return dist_table[i] ratio * (dist_table[i1] - dist_table[i]); } } return -1.0f; // 异常值 }逻辑说明标定过程只需要做一次把障碍物放在已知距离3cm、5cm、8cm……处记录 ADC 值填入表格。运行时用线性插值逼近真实距离曲线误差通常小于 1cm。查表法的好处是适应每个传感器的个体差异——你换一个传感器只需要重新标定这张表代码里表格替换即可。一般不建议对红外做多项式拟合因为高阶多项式在端点附近会出现震荡低阶的精度又不如查表。参数说明ADC 值是以 12 位采样0-4095为例的你的板子如果是 10 位 ADC需要把表整体缩放。标定点在 3cm 到 30cm 之间取 8 个点即可覆盖小车的常用避障范围——红外传感器测到超过 30cm 基本就不准了所以更远距离建议用超声波补。4.2 温度漂移与光线干扰为什么超声波和红外要用不同的去抖策略红外的测量结果会受环境光线影响尤其是阳光直射超声波则对温度导致的声速变化敏感。这两种去抖策略不能混用红外用滑动平均滤波取最近 5 次的平均值能有效滤除瞬态光线干扰。超声波用中值滤波取 3 次采样的中间值因为超声波的噪声通常是尖峰脉冲多路径反射造成的数值突变平均值会被尖峰拉偏中值则能直接去掉极值。// filter.c 滤波策略分流某模拟项目X uint16_t MovingAverage(uint16_t *buf, uint8_t len, uint16_t new_val) { static uint8_t idx 0; buf[idx] new_val; idx (idx 1) % len; uint32_t sum 0; for (uint8_t i 0; i len; i) sum buf[i]; return (uint16_t)(sum / len); } uint16_t MedianFilter(uint16_t a, uint16_t b, uint16_t c) { // 经典三个数找中值代码略 uint16_t arr[3] {a, b, c}; for (int i 0; i 2; i) { for (int j i1; j 3; j) { if (arr[i] arr[j]) { uint16_t tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } } } return arr[1]; }这里的核心思想是滤波的选型由噪声的分布决定。红外噪声接近白噪声有正有负、幅度小滑动平均有效超声波噪声是长尾的尖峰偶尔跳出一个远大于真实值的读数中值滤波能一刀切。参数调整上滑动平均的窗口长度5已经足够再大会带来响应延迟小车撞上障碍物时传感器输出变化被延迟 100ms 会造成刹车距离变长超声波 3 次中值为经验值再多会增加采样周期同样延迟反应。4.3 编码器测速的两种模式测频法和测周法怎么选编码器测速有 M 法测频率和 T 法测周期两种。M 法在低速时误差大单位时间内计数脉冲少丢一个就偏差很大T 法在高速时误差大脉冲间隔太短定时器分频跟不上。对于常见的减速电机我建议车速在 10cm/s 以上用 M 法计 100ms 内的脉冲数换算速度。车速在 10cm/s 以下用 T 法测一个脉冲的时间间隔反推速度。但大多数智能小车项目不会做变速范围这么宽的场景所以我默认用 M 法就够了。简化代码// encoder.c M法测速某模拟项目X volatile uint32_t g_pulse_cnt_left 0; // 定时器0中断配置为 100ms 周期 void TIM0_IRQHandler(void) { static uint32_t last_cnt 0; uint32_t now_cnt g_pulse_cnt_left; uint32_t delta now_cnt - last_cnt; // 100ms 内的脉冲数 last_cnt now_cnt; float speed_cms (float)delta / (4.0f * 30.0f) * 100.0f * 3.14f; // 4编码器4倍频30减速比100换算成秒3.14圆周率简算 }逻辑说明100ms 计一次数脉冲数差值除以编码器每圈脉冲数再乘以每秒的圈数最后乘以轮子周长得到线速度。这里用4.0f做倍频是因为常见的霍尔编码器输出两路正交信号单片机检测边沿可以做到四倍频能提升低速时的分辨率。参数说明你的编码器如果是 13 脉冲/圈、减速比 1:30那么轮轴转一圈输出 13*30390 个相位四倍频后每圈 1560 个计数。这个数值决定了测速分辨率你可以在初始化时打印实际值如果 100ms 内计数很少比如只有个位数就考虑把 PID 周期延长到 50ms 或者换用 T 法。5. 避坑与排查几个让我痛苦了一天一夜的真实问题记录5.1 现象小车上电后电机「嗡嗡」响但不转程序看起来没问题原因电机驱动板的使能引脚没拉高。很多驱动模块有 ENA/ENB 引脚控制输出悬空默认是低电平H 桥不导通但 PWM 信号还在灌入于是电机线圈上有电压却无法形成回路发出高频嗡鸣。解决初始化代码里必须显式把使能引脚设置为高电平输出。注意别只拉一个左右两个通道都要拉。调试时先用万用表量使能引脚电压确认接近 VCC 再查其他。这是新手最容易忽视、老手偶尔也会犯的低级错误。5.2 现象电源电压正常但小车一加速就复位重启程序从头跑原因电机启动瞬间的电流尖峰把电源电压拉低到单片机复位阈值以下。理论上单片机供电和电机供电已经分开但共用了同一块电池电池内阻大时启动瞬间的电压跌落会同时影响两条支路。解决在主电源到单片机稳压模块之间串一个二极管防止反向尖峰同时在单片机电源入口并联一个大电容470uF 以上。如果无法加硬件退而求其次在程序里做「软启动」——用 100ms 斜坡把 PWM 从 0 慢慢升到目标值电流冲击会小很多。我最终采用的是软件软启动加稳压模块前的大电容组合再没出现过复位问题。5.3 现象PID 参数整定后直线跑得挺稳但一转弯就疯狂抖动原因转向时左右轮的目标速度不同PID 积分项还保留着跑直线时的累积值转弯瞬间输出过冲。这正是位置式 PID 容易出的问题而增量式 PID 里积分是通过增量间接累积的理论上不该这么明显——如果你的抖动很严重多半是转弯状态下左右轮的out_max没有单独限制。解决给转弯单独设一组更保守的 PID 参数或者在入弯时对 PID 输出做一次「清零」把output置为基准占空比 50。我用了后一种方案代价是转弯后的恢复速度慢但稳定性显著提升。记录一个血泪经验PID 参数不是一组调完就完直线、转弯、避障三种场景要准备三套参数。5.4 现象循迹时黑白线边缘反复横跳车走不了直线原因灰度传感器模块的阈值是固定的但环境光线变化导致 Vref 参考电压漂移。出厂默认阈值在室内灯光下合适拿到阳光或暗光环境就失效。解决校准逻辑要开机就跑一次。让小车在黑白交界处摆动实时采集传感器最大值和最小值取中值作为动态阈值。我在代码里用的是 10ms 采集一次、持续 500ms 校准的方式效果比固定阈值好得多。5.5 现象串口打印的传感器数据正常但小车行为完全不对原因大概率是引脚复用冲突。比如你用了单片机的某个引脚控制电机但另一个外设也占用了同一个定时器通道代码编译能过因为宏定义没冲突但运行时硬件资源抢占了。解决硬件初始化后第一步先对照 datasheet 检查每个引脚的第二功能。常见雷区PWM 输出和编码器输入共用定时器I2C 引脚被接成普通 GPIO 控制电机。这时候不要在代码里调试先拿万用表量引脚电平确认功能复用在硬件层面没问题再继续。6. 调试效率提升技巧让 PID 整定不再靠猜让问题定位不再靠眼调试智能小车最大的时间浪费不是写代码而是「改一个参数、烧录、看跑动、再改」这个循环。我后来琢磨出一套基于上位机虚拟示波器的调试方法把整定时间从一晚上压缩到半小时内。具体做法是单片机通过串口把目标速度、实际速度、PID 输出这三个变量以「帧格式」发到上位机用带波形的串口调试工具实时绘制。帧格式如下// debug_output.c 串口调试数据帧某模拟项目X printf(T%.1fS%.1fO%.1f\n, (double)g_target_speed, (double)Encoder_GetSpeed(LEFT), (double)PID_GetOutput(g_pid_left));上位机端用支持分帧识别的解析器就能画出一条目标速度曲线、一条实际速度曲线、一条 PWM 输出曲线。这时候你会发现PID 调参的逻辑彻底变了实际速度曲线「追不上」目标曲线说明kp太小直接加倍试试。实际速度在目标曲线附近持续震荡说明kp太大或kd太小把kp减半先消除低频振荡。实际速度稳定但始终和目标差一截静差存在说明ki没起作用按 0.02 步长往上加直到误差消失。目标速度突变时实际速度「回弹」一下再继续跟说明kd偏大。这个流程不再需要反复跑车因为波形直接告诉你系统处于什么响应形态。还有一个额外的收益传感器噪声也能在波形上看得出来。遇到低频振荡时你还能顺便验证滤波参数是否设置合理——如果实际速度曲线边缘有明显毛刺说明滤波窗口太小。我还有一个日常习惯每次调参前先把上一组参数用一个宏定义记录下来例如#define PID_KP_LAST 1.20f不要覆盖掉旧值。这样试错失败后可以一分钟内回到上一个可用状态。这个习惯帮我避免过多次「调出新参数后想还原却忘了旧值」的翻车现场。最后回到项目本身。智能小车控制系统设计的成败通常不取决于某个算法有多先进而是取决于你有没有一个清晰的系统拆解、一套合理的供电拓扑、一组基于数据而非玄学的调试手段。把本文里的最小系统搭起来让 PID 波形在你面前滚起来再回头修改参数你会发现之前那些让人头疼的问题大多数会自己消失。希望这篇笔记能帮你少踩几个坑把更多时间花在真正有意思的控制策略上。本文还有配套的精品资源点击获取