尧图精选

用Arduino构建智能小车闭环控制系统:编码器测速与PID避障

🕒 发布时间:2026/9/5 17:49:55 📁 来源:尧图网络
在这个章节之前你大概率已经完成过 Arduino 基础项目点灯、按键输入、用 PWM 控制电机转速。看起来小车已经能“动”起来了但只要你把它放到地上问题立刻就会出现两个电机明明写了同样的占空比车却总往一边偏前面放一把椅子它不知道减速想让它走一条直线几乎不可能。很多人到这里的第一反应是“换一套更好的电机”或者“去抄一段避障代码”但真正的瓶颈不在硬件而在系统没有形成反馈闭环。本章会用 Arduino Uno R3 这个非常基础的平台把一个项目拆成一条完整的控制链路编码器测速、中断计数、定时采样、滤波、PID 闭环、超声波避障。最终小车不再只是“执行你下发的转速”而是“实时感知自己实际跑了多快、前方有没有障碍再决定下一步怎么走”。这才是“导航”的开端。先说判断做 Arduino 小车真正拉开差距的不是某个单独模块而是你是否把数据采集、控制周期、执行输出当成一个整体来看。很多教程单独讲编码器、单独讲 PID读者照着接好线却发现车还是不走直线因为每个环节的时间基准不一致中断计数是一套逻辑PID 循环又用 delay 卡住超声波一测距就阻塞整个主循环。这一章不仅要讲每一个模块还会把模块之间的“时序关系”串起来。读完后你可以照着完成一辆能直行、能绕障的小型差速车也基本理解了嵌入式控制项目的典型结构。1. 从“能跑”到“会自己走”差在哪里先明确这个项目的定位它不是一个只做“红外避障”的玩具。很多人做过那种带红外对管的小车遇到障碍就随便拐一下方向完全随机。那种小车本质上没有“感知模型”只有条件判断。真正的自主导航至少要形成一条链路。这条链路可以用一句话概括测量当前状态 - 与期望状态比较 - 计算修正量 - 输出给电机 - 再测量当前状态。缺少其中任何一环小车的行为就会变得不可预测。对照这个链路初学者最容易犯的错误有三个。第一把传感器和电机当成两个独立任务。编码器只负责“给串口打印数字”电机控制只负责“执行 PWM 命令”。两者之间没有任何关系。这就导致小车到底跑没跑直、轮子是不是打滑、电池电压下降后转速有没有降低主控一概不知道。第二时间基准混乱。Arduino 本体没有操作系统main loop 里的代码全部线性执行。如果为了读取超声波用了delay()那么编码器计数、PID 计算都会停摆。测速采样周期漂移太大PID 算出来的值就没有工程意义。第三没有先让数据稳定就直接上控制算法。PID 的输入是编码器速度可编码器原始数据一抖一抖的。新手看到读数跳动第一反应是加大 PID 参数结果小车越调越晃。先做滤波让反馈数据稳定再谈控制参数才是正确顺序。所以本章的技术路线是一条典型的工程路径编码器 外部中断 - 周期性采样 - 滤波 - PID 速度闭环 - 超声波/舵机感知 - 避障状态机。从输入信息看这一章涉及的技术热词集中在编码器、中断、PID、超声波、舵机、滤波、定时器这些点说明读者最关心的正是这条链路中的关键环节。做好这件事Arduino Uno R3 这颗 16MHz 芯片的性能完全够用。它不是算不过来而是开发者的“软件节奏”经常不对。2. 硬件组成与接线先确认每个模块的角色本章项目不是一个模块实验而是一辆完整小车。为了让后面代码不跑偏接线这一步要提前规划好。常见败因是边写代码边加传感器到最后引脚冲突、定时器打架。2.1 部件清单一个典型配置如下Arduino Uno R3 主控板。带 AB 相霍尔编码器的直流减速电机左右各一个常见的是 TT 马达类底盘。H 桥电机驱动模块例如 L298N 或 TB6612FNG也可用其他基于 H 桥的驱动板。HC-SR04 超声波模块用来测前方障碍距离。SG90 型小舵机用来改变超声波探头的朝向。电池组和稳压部分。电机供电必须独立考虑不能指望 Arduino 的 5V 引脚为舵机和电机同时提供大电流。这个组合足够跑通“局部导航”链路。需要提醒的是全局导航通常还需要陀螺仪或里程计模型本章先不引入。2.2 接线规划Arduino Uno R3 只有一个重要的稀缺资源需要提前考虑外部中断引脚只有 D2、D3分别对应中断 0 和中断 1。编码器需要实时响应所以左右编码器的 A 相必须接到这两个引脚。为了留出 PWM 引脚并避免与串口冲突建议按下面的表格接线。模块信号Arduino Uno R3 引脚左编码器A 相D2左编码器B 相A2右编码器A 相D3右编码器B 相A3电机驱动IN1D4电机驱动IN2D5电机驱动IN3D6电机驱动IN4D7电机驱动左使能 PWMD9电机驱动右使能 PWMD10超声波TrigD11超声波EchoD12舵机信号线D8接线时做到“先电源后信号”。电池正负极接电机驱动板的 VM 和 GND同时把驱动板 GND、Arduino GND、升压模块 GND 连接到同一个公共地。很多小车第一次上电后串口读数乱跳甚至电机不转一大半是因为共地没有做。电源方面舵机 SG90 和 HC-SR04 最好单独从具有足够电流输出能力的 5V 电源取电。USB 口给 Arduino 供电用于下载和调试没问题但如果靠着 USB 5V 驱动两个电机和一个舵机电压跌落会非常严重。可靠的供电方案是电池组直供驱动模块的高压输入端Arduino 通过 Vin 或独立降压模块供电所有地线连在一起。2.3 接线后先做最小验证接完线不要立刻烧写完整避障程序否则出了问题根本不知道是接线还是算法。先做最小的静态检查把电机程序里的 PWM 占空比固定为一个不会直接撞车的低值让车轮缓慢转动再用示波器、逻辑分析仪或者普通串口观察编码器是否产生脉冲。没有条件时把手转动车轮也可以观察计数变化。3. 编码器与中断整个控制系统测量数据的来源许多刚接触编码器的读者会有一个疑问编码器不就是两个引脚输出脉冲吗慢慢轮询读取不也行吗对小功率电机确实可以嵌入式领域的核心技术难点在于实时性。Arduino Uno R3 的主循环如果正在执行超声波测距、LCD 刷新或其他耗时逻辑处理器根本没有时间回头读取编码器引脚就可能丢失计数。这就是中断存在的意义。3.1 编码器输出两路什么信号编码器是一种位置或速度传感器。电机转动时内部的码盘会让霍尔传感器输出脉冲信号。常见配置有两路输出标记为 A 相和 B 相。A、B 两路信号之间有固定的相位差电机正转时 A 相超前 B 相 90 度反转时 B 相超前 A 相 90 度。从一个脉冲边沿看只要判断另一路的电平就能知道方向。需要区分两个概念有些传感器叫“正交解码编码器”有些电机模块则只提供“测速接口”。如果电机模块只输出单路脉冲你只能测速度不能判断方向。本章使用 AB 双路输出的编码器除了测速还能为后续里程计计算打基础。3.2 为什么必须挂中断Arduino Uno R3 的外部中断引脚只有 D2、D3。使用中断后每次 A 相出现上升沿CPU 都会立刻进入对应中断服务函数在中断函数里对计数变量加一。整个过程不需要主循环参与即使主循环正在执行其他代码也不会丢脉冲。如果采用“循环读取”方式主循环里一个稍长的delay(50)就可能造成计数遗漏。尤其当系统还要处理超声波、PID、舵机时中断方案几乎是必须的。设计中断程序有一个铁律中断服务函数内部尽量只做简短操作不要调用Serial.print()、delay()这类耗时函数。计数代码足够短正好适合放在中断里。3.3 计数与方向判断的示例下面是一个最小可用程序用于验证左右编码器。把两个编码器的 A 相接到 D2、D3B 相接到 A2、A3。程序启动后手动转动车轮串口监视器可以看到左右计数变化数值增减也能反映转向方向。// 文件路径02_encoder/02_encoder.ino #define ENC_LEFT_A 2 #define ENC_LEFT_B A2 #define ENC_RIGHT_A 3 #define ENC_RIGHT_B A3 volatile long leftTicks 0; volatile long rightTicks 0; void leftEncoderISR() { if (digitalRead(ENC_LEFT_B) HIGH) { leftTicks; } else { leftTicks--; } } void rightEncoderISR() { if (digitalRead(ENC_RIGHT_B) HIGH) { rightTicks; } else { rightTicks--; } } void setup() { Serial.begin(9600); pinMode(ENC_LEFT_A, INPUT_PULLUP); pinMode(ENC_LEFT_B, INPUT_PULLUP); pinMode(ENC_RIGHT_A, INPUT_PULLUP); pinMode(ENC_RIGHT_B, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(ENC_LEFT_A), leftEncoderISR, RISING); attachInterrupt(digitalPinToInterrupt(ENC_RIGHT_A), rightEncoderISR, RISING); } void loop() { Serial.print(L); Serial.print(leftTicks); Serial.print( R); Serial.println(rightTicks); delay(200); }代码逻辑是使用volatile修饰中断里修改的计数变量避免编译器优化导致主循环读到过期副本。attachInterrupt(digitalPinToInterrupt(引脚), 中断函数, RISING)是在 A 相上升沿触发。中断函数内通过读取 B 相电平判断方向。这里的正负与电机安装方向有关如果发现转轮时计数方向相反调整接线或者对计数值取反即可。这段程序先不驱动电机只做测试。成功标志是轮子每转过一圈串口数字持续变化且正反转时计数分别增大和减小。如果发现只有一只轮子有计数不要急着怀疑程序优先检查对应编码器的供电、B 相上拉电阻和信号线是否接触良好。霍尔编码器需要供电电压没有供电时信号线是悬空的。4. 采样节拍与速度换算把原始计数变成可用速度有了中断计数下一个问题是计数值不能直接用于 PID因为leftTicks虽然越来越大但没法知道轮子“转得多快”。速度的定义是一段时间内位置的变化量。要得到速度必须定时读取左右编码器的计数差值再做换算。这一步的关键是“采样节拍”必须稳定。4.1 不要用 delay 做采样用下面的方式写会很自然while (true) { delay(100); speed leftTicks - lastLeftTicks; }但它有一个缺点程序在delay(100)期间完全阻塞。如果此刻需要同时读取另一个传感器整个系统就卡住了。超声波测距时pulseIn又会阻塞一段时间PID 更新就会被延迟。采样周期忽长忽短PID 的积分项和微分项都会变得不稳定。正确做法是用millis()做非阻塞时间判断。millis()返回开机至今的毫秒数我们可以不断检查“现在距离上次采样是否已经超过 100 毫秒”。4.2 速度采样的完整实现下面的代码就是把上一节的计数值转换为每秒脉冲数。这个量在后续 PID 里作为“反馈速度”使用。// 文件路径03_speed_sampling/03_speed_sampling.ino #define SAMPLE_MS 100 unsigned long lastSampleTime 0; long lastLeftTicks 0; long lastRightTicks 0; void updateSpeedSampling() { unsigned long now millis(); if (now - lastSampleTime SAMPLE_MS) { unsigned long dt now - lastSampleTime; if (dt 0) { dt 1; } long deltaL leftTicks - lastLeftTicks; long deltaR rightTicks - lastRightTicks; float leftSpeed deltaL * (1000.0 / dt); // 单位脉冲数/秒 float rightSpeed deltaR * (1000.0 / dt); lastSampleTime now; lastLeftTicks leftTicks; lastRightTicks rightTicks; Serial.print(L); Serial.print(leftSpeed); Serial.print( R); Serial.println(rightSpeed); } }这里没有用固定 100 作为分母而是用实际间隔dt即使主循环偶尔被其他代码阻塞速度计算也不会因此出现明显跳变。4.3 从脉冲速度换算到转速“每秒脉冲数”能够直接用于左右轮对比但不够直观。假如你想知道电机每分钟转多少圈需要一个关键参数每圈产生多少个脉冲。这个参数通常和编码器线数、减速比有关。如果减速电机铭牌给出“每圈输出多少个计数脉冲”那可以将脉冲速度换算成 RPMrpm (脉冲每秒 / 每圈总脉冲数) * 60如果你的编码器安装在电机轴上而轮子通过减速箱输出实际轮子转速还要再除以减速比。不同厂家的电机不一致所以代码里最好把这个参数定义为一个常量方便校准时修改。需要注意M 法测速在低速时误差偏大。它的原理是“在一段固定时间内数脉冲数”如果时间窗口内只到来 1 到 2 个脉冲误差就会非常明显。让小车以较慢速度行驶时就会看到速度值在高频抖动。解决办法是适当延长采样窗口例如从 50ms 改为 100ms。若速度极低则更适合使用 T 法也就是测量单个脉冲的时间宽度。本章项目把采样周期控制在 50 到 100ms 比较合适。4.4 外部中断、定时器与主循环的时间关系Arduino Uno R3 的定时器资源不止一个。millis()本身依赖定时器 0PWM 功能依赖定时器 0、1、2。外部中断则是由引脚电平边沿直接触发的不依赖某个定时器。这带来一个很好的工程结构外部中断负责对编码器事件计数。定时器 0 溢出驱动millis()产生系统时间戳。主循环控制 100ms 采样周期。同一段主循环还会执行 PID 和避障逻辑。这个结构的价值在于编码器的每一个边沿都不会因为测距代码而丢失同时控制逻辑有确定的时间节拍。对 Uno 这种小内存芯片来说不用上 RTOS只要把任务按时间片划分清楚也能获得很稳定的控制效果。5. 滤波让速度反馈先稳定下来现在的问题是速度数据并不漂亮。哪怕车轮稳定匀速转采样得到的脉冲速度也可能在 150 和 200 之间跳动。原因包括机械震动、采样周期开始时编码器刚好落在边沿附近、以及 M 法低速误差。如果把这种数据直接给 PID控制量会跟着反馈一起抖动结果就是电机声音发闷、电流波动、小车行驶不平顺。5.1 低通滤波的直观理解低通滤波器不是嵌入式领域的高深数学概念。一句话理解它允许缓慢变化的速度信号通过抑制突然跳变的高频噪声。用公式写出来是filtered filtered alpha * (raw - filtered)其中alpha是一个 0 到 1 之间的系数。alpha越接近 1滤波器越“激进”响应快但平滑能力弱alpha越接近 0输出越平滑但需要更长时间才能跟随真实速度变化。另一种常见写法是滑动窗口平均保存最近 N 次采样值求平均。它的缺点是每次需要存储多个数据并且在速度快速变化时会产生滞后。一阶低通滤波只需要一个变量占用资源少适合 Arduino Uno R3。5.2 一阶低通滤波代码把上一节的leftSpeed、rightSpeed换成smoothLeft即可。下面代码放在主循环中频率为 100ms 的采样块里。// 文件路径04_filter/04_filter.ino float smoothLeft 0; float smoothRight 0; const float ALPHA 0.35f; // 在采样块中调用 // smoothLeft lowPassFilter(rawLeftSpeed, smoothLeft, ALPHA); // smoothRight lowPassFilter(rawRightSpeed, smoothRight, ALPHA); float lowPassFilter(float rawValue, float lastFiltered, float alpha) { return lastFiltered alpha * (rawValue - lastFiltered); }第一次运行时如果电机尚未启动smoothLeft从 0 开始。当实际速度上升时滤波后的曲线会有一段时间的爬升过程。如果想要启动阶段响应更快可以在初始化时直接把第一次采样值赋给滤波器变量。5.3 alpha 参数怎么选alpha不能盲目乱调。从控制工程角度看alpha 与采样周期 Ts 和滤波器时间常数 tau 有关alpha Ts / (Ts tau)假设采样周期是 0.1 秒希望滤波平滑时间常数约为 0.2 秒那么近似估算alpha 0.1 / (0.1 0.2) 0.33这意味着滤波后的速度值大约要经过几分钟不是是经过若干个采样周期才能完全跟上真实值。对速度控制来说不需要完全达到真实值只要稳定且误差在可控范围即可。实际调试时你可以先设 alpha 为 0.5观察串口曲线如果数据仍然跳动明显把 alpha 调小到 0.2 或 0.3如果响应太慢、直行时转弯反应迟钝把 alpha 调大。要注意滤波是双刃剑。滤波会带来延迟这是控制系统中常常被忽略的代价。速度环信号可以滤波但避障的距离判断如果同样用很重的一阶低通就可能使小车在发现障碍后仍然晚刹车。距离传感器更适合用“多次采样 取有效值 超时剔除”的思路而不是一味低通。6. PID 控制与自动纠偏速度环和方向环前面几节解决了“测得准”的问题现在要进入“自动纠偏”。PID 是本章最容易被误解的部分。不少人把 PID 当成一个神秘的“黑盒”直接去 Arduino 库搜索 PID 调用然后复制参数。这不是项目该有的方式因为不懂原理就不知道怎么调参数。6.1 PID 要解决的控制目标回到小车的直接问题现在程序给两个电机各发送一个 PWM 占空比比如左边 150、右边 150。结果小车往右偏因为左右电机在相同 PWM 下实际转速不同。如果手动纠正你可以说把右边 PWM 减到 140。但这只是开环补偿。随着电池电压变化、地面摩擦不同、负载不同误差会不断变化。PID 的作用就是给定一个目标速度它根据实际速度与目标速度的差值自动计算一个合适的 PWM 输出。6.2 P、I、D 三个词的直观意义比例项P是基础。它认为误差越大修正越用力。例如当前速度比目标低 50P 项提供 2 倍的比例增益输出增加 100。积分项I用来消除长期误差。比例控制有一个特点当误差小到一定程度后输出可能刚好抵消阻力误差就永远剩一点这叫稳态误差。积分项把历史误差累积起来只要误差持续存在积分输出就会逐渐增大直到把误差推回零。微分项D看误差变化趋势。它用来抑制过冲。目标速度到达后误差在快速变小微分项会给一个反向修正避免轮子一下子冲过头。可以这样理解P 处理“现在差多少”I 处理“过去差了多少”D 预测“接下来会差多少”。6.3 位置式 PID 的轻量实现Arduino 平台有 PID 库可用但对学习项目来说自己写一个小型 PID 函数反而更清楚也更容易调整限制条件。下面代码采用位置式 PID每次更新都把目标速度、实际速度作为输入输出一个 0 到 255 的控制量直接对应 PWM。// 文件路径05_pid_controller/pid_controller.ino struct PidController { float kp; float ki; float kd; float integral; float lastError; float outMin; float outMax; }; float pidUpdate(PidController* pid, float target, float current, float dt) { float error target - current; pid-integral error * dt; if (pid-integral pid-outMax) { pid-integral pid-outMax; } if (pid-integral pid
上一篇/下一篇内容由系统自动关联 返回资讯列表 →