ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Arduino Uno R3自导航小车:从编码器到PID的闭环控制进阶

Arduino Uno R3自导航小车:从编码器到PID的闭环控制进阶 先把这个结论放前面Arduino Uno R3 做自导航小车真正的分水岭不是电机能不能转而是控制算法能不能让车“自己修正错误”。上一章通常还在处理引脚亮灯、电机驱动、PWM 调速这类基础控制到第 2 章编码器、中断、PID、超声波、舵机、定时器这些词会全部砸过来。很多人就是在这里卡住的——不是硬件不行而是不知道每个模块解决的是哪一层问题。我见过不少初学者把接线接完烧录后小车冲墙就开始怀疑 PID 参数不对。实际上可能是编码器读数不可信可能是超声波滤波没做可能是舵机转向慢了半拍。这篇内容就按“感知 → 控制 → 决策”的顺序拆一遍先让车知道轮子转速再用 PID 稳住速度用超声波感知距离用舵机调整方向最后把这些串成一条能自己走的逻辑链。如果你是刚把手上的 Uno R3 跑通电机正反转现在想进入闭环控制阶段这篇文章正好合适。如果你是纯软件背景对中断和定时器不熟也没关系我会把每个环节为什么这么做讲清楚而不是只甩给你一堆库函数调用。1. 先明确“自己导航”背后是几层任务1.1 从开环到闭环小车才算“有感觉”刚开始玩小车的时候直接给电机一个固定 PWM 值比如analogWrite(9, 200)电机就能转起来。这种模式叫开环控制因为控制器不知道实际转速是多少也不知道轮子是不是因为电池电压下降而变慢。开环控制的问题很明显电池从 7.4V 掉到 6.8V电机转速就会变。左右电机哪怕型号相同机械阻力也存在差异给同样 PWM车走不了直线。遇到斜坡或者地面阻力变化车轮会打滑程序完全无感知。所以在第 2 章这个项目里第一件事是加编码器。编码器把轮子转了多少圈转换成脉冲信号单片机通过中断读取脉冲数再根据时间算出真实速度之后用 PID 控制算法调整 PWM 输出。这一步做完之后小车才算“有感觉”它能知道自己现在转得多快而不是靠猜。1.2 本章要打通的模块链路从一个普通电机小车到能自动避障的小车核心链路大致是四段编码器 中断 定时器测量左右轮速度。PID 速度环将测量速度稳定在目标速度。超声波 滤波 舵机感知前方和侧方障碍物并通过舵机带动超声波探头转向。决策状态机根据滤波后的距离信息决定直行、减速、转向还是后退同时进行简单的速度规划与位置控制。这里面有一个容易误解的地方很多人以为自动避障就是“超声波检测到障碍就转弯”实际上如果车轮速度不稳转弯半径会飘避障结果很难复现。所以必须先做速度闭环再做避障逻辑。2. 编码器、中断与定时器先把车子“感知”做出来2.1 霍尔编码器和 Arduino 中断引脚的配合Arduino Uno R3 的芯片是 ATmega328P主频 16MHz这不算快。编码器模块通常输出 A、B 两路方波信号电机转动时两路信号存在相位差单片机根据相位差可以判断正反转。测速时需要注意一个问题Uno R3 只有两个外部中断引脚分别是 D2 和 D3。如果你用的是“双编码器电机小车”最稳妥的方案是把左轮编码器接到 D2右轮编码器接到 D3并且只接一路 A 相或者接正交 A/B 两路但用同一个中断源去计数。这里我要强调一下Uno R3 的中断资源很紧张。如果两个电机的一路信号都接普通引脚再靠轮询读变化转速一高就会丢脉冲。所以建议左轮编码器 A 相 → D2外部中断 0右轮编码器 A 相 → D3外部中断 1编码器 GND 和 Arduino GND 必须共地否则信号会漂如果你需要判断正反转可以把 A 相接中断引脚B 相接普通数字引脚。在中断回调里读取 B 相电平就能判断当前是正转还是反转。2.2 为什么不能只用 delay 而要用定时器采样很多第一次接触编码器的朋友会写类似下面的逻辑// 不推荐的伪代码思路 while (1) { count 0; delay(100); speed count / 0.1; }问题是delay()会把主程序卡住。在等待的 100ms 里如果有另一个模块需要响应比如超声波要触发或者舵机要处理转向程序就没办法同时执行。正确做法是用定时器产生一个固定周期中断比如每 50ms 或者 100ms 读取一次编码器累计值然后计算速度并清空累计值。这样就相当于建立了一个“速度采样节拍”。在 Arduino 生态里可以用MsTimer2这类库也可以直接用 ATmega328P 的 Timer2 做中断。下面是一个很常见的示例写法volatile long leftCount 0; volatile long rightCount 0; unsigned int interval 50; // 采样周期 50ms void setup() { pinMode(2, INPUT_PULLUP); pinMode(3, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(2), leftEncoderISR, RISING); attachInterrupt(digitalPinToInterrupt(3), rightEncoderISR, RISING); // 初始化定时器这里示意为主循环用 millis 实现 } void loop() { static unsigned long lastTime 0; if (millis() - lastTime interval) { long l leftCount; long r rightCount; leftCount 0; rightCount 0; // 根据编码器线数计算实际转速 lastTime millis(); } } void leftEncoderISR() { leftCount; } void rightEncoderISR() { rightCount; }这个例子只是一个骨架。实际编码器如果是 13PPR 配减速比 30 的电机轮子转一圈会产生13 * 30 390个脉冲。如果采样周期是 50ms那么count * (1000 / 50) / 390就能算成圈/秒再乘上轮子周长就是线速度。2.3 编码器计数和速度计算的示例思路我一般建议先不接驱动负载直接手动转轮子在串口监视器看脉冲数是否稳定。比如手动把轮子匀速转一圈观察到的脉冲数应接近理论值。如果偏太多先确认是不是倍频问题。表常见速度换算所需参数参数示例值作用编码器 PPR13电机输出轴一圈的脉冲数减速比30轮子转一圈对应电机输出轴 30 圈采样周期50ms定时读取脉冲用于速度闭环轮子直径65mm计算速度时需要用到每圈总脉冲数13 * 30 390轮子每转一圈产生的脉冲速度计算的本质就是脉冲数 / 采样周期 → 每秒脉冲数每秒脉冲数 / 每圈脉冲数 → 每秒几圈每秒圈数 × 轮胎周长 → 线速度。注意在计算速度之前要先把“换向”考虑进去。如果你做的是前进后退双向控制编码器方向会有 B 相或者霍尔方向信号务必用方向确定后的计数结果。否则后退时计数还在增加PID 会得到错误的正速度反馈。3. PID 控制算法让速度稳定下来才有后面的导航3.1 PID 三个参数在电机转速场景里到底在调什么PID 是比例-积分-微分的缩写。在小车速度控制里它的输入是“目标速度”和“实际速度”的误差输出是 PWM 占空比。以比例 P 为例误差越大输出 PWM 变化越大。P 太小车加速很慢跟不上指令P 太大车会震荡能听到电机嗡嗡响甚至车轮一顿一顿。积分 I 用来消除稳态误差。比如你想让轮子保持每秒 1 圈但 P 控制到稳定时只有每秒 0.95 圈这个 0.05 的偏差就需要 I 项慢慢累加修正。I 太大也可能超调尤其在上坡后突然平路速度会冲过头。微分 D 有点像“踩刹车”当误差快速减小时D 项会抑制变化让系统更平稳。在数字噪声比较大的测速系统里D 项很容易放大干扰所以很多入门级小车只使用 PI 控制。PID 输出要限制范围比如 PWM 范围是 0255。如果你用带死区的驱动板还要考虑最小启动 PWM。电机不是线性器件PWM 过低可能转不起来PID 输出在这个区间内反复调整导致电机原地抖。3.2 用增量式 PID 控制 PWM 输出的稳定流程增量式 PID 是工程里很常用的一种写法。它不直接输出绝对 PWM而是输出当前 PWM 相对于上一次 PWM 的变化量。这样输出比较温柔不容易一瞬间从 0 跳到 255。伪代码如下float kp 0.6; float ki 0.02; float kd 0.0; float targetSpeed; float currentSpeed; float lastError; float integral; int computePID() { float error targetSpeed - currentSpeed; integral error; if (integral 200) integral 200; if (integral -200) integral -200; float output kp * error ki * integral kd * (error - lastError); lastError error; return constrain((int)output, -255, 255); }实际使用中要区分正反转。如果不需要反转就把 PWM 限制在 0255如果需要后退那么负 PWM 代表反转这时要确认电机驱动板的 IN1/IN2 逻辑已经配合好。我建议先单独测试左轮和右轮让它们分别跑同一目标速度观察串口打印的实际速度。如果左右实际速度相近说明速度闭环已经有效如果差得远可能是编码器安装松动、信号线互相干扰或者驱动板两个通道 PWM 抖动不同。等到两个轮子都能单独稳速再让它走直线。这里有一个经验普通面包板和排线在电机运行时容易受干扰编码器信号线尽量短最好用屏蔽线或者双绞线。3.3 调参顺序先 P再 ID 可以先小或不用PID 调参不建议直接三参数一起调那样出了问题根本不知道是谁引起的。我的习惯是先只保留 P。操作步骤把 I 和 D 设成 0。给一个小目标速度比如每秒 1 圈。从 kp0.1 开始一点点往上加直到轮速接近目标且不持续震荡。记录当前 kp。再加入 I从 ki0.01 开始观察长时间运行后轮速能否稳定在目标值。如果目标速度改变后轮子需要比较长时间才能跟上可以适当加 I。如果出现高频抖动检查一下是不是 D 写了但没置零也可能是编码器采样周期不稳定导致速度信号本身就抖。PID 并没有“万能参数”。不同电机、不同电池电压、不同减速比参数都会不同。把参数写死在某一个测试平台上可以但如果换硬件至少要重新跑一遍调参流程。4. 超声波测距与滤波避障前的第一步是信得过的距离4.1 HC-SR04 测距基本流程HC-SR04 是常见的超声波模块使用流程并不复杂Trig 引脚给一个 10us 以上的高电平模块开始发送超声波。模块自动发送 8 个 40kHz 的脉冲并等待回波。Echo 引脚输出高电平高电平持续时间代表声波往返时间。距离 高电平时间 × 声速 / 2。在 Arduino 中可以用pulseIn()函数。但要注意pulseIn()在等待时是阻塞的如果此时 PID 需要用中断采样倒还好如果你在主循环里用pulseIn()测距超声波测一次大约几个毫秒高负载时可能影响实时性。常见接线VCC → 5VGND → GNDTrig → D7Echo → D8给一段基础代码思路float readUltrasonic() { digitalWrite(trigPin, LOW); delayMicroseconds(2); digitalWrite(trigPin, HIGH); delayMicroseconds(10); digitalWrite(trigPin, LOW); long duration pulseIn(echoPin, HIGH); float distance duration * 0.034 / 2; return distance; }实际使用中超声波模块返回的数据会有不少毛刺。室内环境有很大影响墙角反射、衣服吸音、风扇噪声、别的超声波模块互相干扰都会让单次读数异常。4.2 毛刺与噪声怎么处理滤波的目标不是让数据变成完全平滑的曲线而是让决策层不要因为单次错误值突然转向。常用方法有三种中值滤波连续读 5 次取中间值。比平均值的抗干扰能力强适合超声波这种偶发毛刺。限幅滤波如果这次读数与上次读数之差超过某个阈值就用上次读数。适合小车速度不快时使用。滑动平均取最近 N 次读数的平均值。处理随机噪声有效但对瞬间突变反应较慢。我在这类小车项目里最常用的是“中值滤波 限幅”的组合。先连续读 5 次取中值限制单次最大变化量不超过比如 20cm。这样不会因为一次误报就让小车急刹车或猛打方向。如果你用舵机控制超声波探头左右扫描注意一个问题舵机转向需要时间。在转向瞬间测量会得到不稳定的数据因为探头角度还在变化。建议等舵机稳定 100ms 后再触发超声波。代码示意float filterDistance() { int samples[5]; for (int i 0; i 5; i) { samples[i] (int)readUltrasonic(); delay(10); } // 简单排序后取中值 for (int i 0; i 4; i) { for (int j i 1; j 5; j) { if (samples[i] samples[j]) { int tmp samples[i]; samples[i] samples[j]; samples[j] tmp; } } } return samples[2]; }注意连续 5 次测距会耗时约 50ms 甚至更多如果你希望避障循环在 100ms 内完成一次决策就会有些紧张。因此可以考虑减少采样次数或者在平滑与快速响应之间取舍。4.3 滤波后的数据如何喂给避障避障决策一般不会只看正前方一个点而是看几个方向。常见方案是舵机带超声波依次朝向 0°、90°、180°分别测三次距离左前距离正前距离右前距离完成一次扫描可能需要几百毫秒。如果车速太快扫描还没结束就可能撞墙。所以这里要控制速度规划在转向扫描期间小车应该减速或者短暂停止。把滤波后的三个方向距离作为输入交给决策层即可。不要在决策里直接用原始单次测距值否则你会遇到“明明前面没障碍却突然转向”的灵异现象。5. 舵机转向与自动避障策略决策层怎么组织5.1 舵机云台转向实现舵机的控制原理是 PWM 信号控制舵机角度。通常 50Hz 的 PWM高电平时间约 0.5ms 对应 0°2.5ms 对应 180°。Arduino 里可以用Servo库简化操作。常见接线舵机信号线 → D6 或者其他支持 PWM 的数字脚舵机电源 → 最好用外部电源供电舵机 GND → 与 Arduino GND 共地这里容易忽略电源问题。Arduino Uno R3 板载 5V 稳压芯片能输出的电流有限。如果用小舵机比如 SG90勉强能从 5V 引脚取电但小车上有电机驱动、超声波、主控同时运行电压跌落会很明显。当你发现舵机一转动Arduino 自动复位或者超声波数据跳变大概率是供电不足。一个更稳的方案是主控用独立稳定电源或 USB 供电电机和舵机使用电池组供电所有 GND 连在一起。如果要简单一点至少不要把大电流舵机接到 Arduino 的 5V 输出脚上。舵机转角程序#include Servo.h Servo ultrasonicServo; void setup() { ultrasonicServo.attach(6); ultrasonicServo.write(90); // 正前方 } void loop() { ultrasonicServo.write(0); // 左前 delay(300); int leftDistance readDistance(); ultrasonicServo.write(90); // 正前 delay(300); int frontDistance readDistance(); // 决策... }这里如果发现角度和方向对应相反先把舵机臂拆下来调整初始位置避免在代码里硬写很多补偿角度。5.2 自动避障的状态机设计自动避障程序最好写成状态机而不是一长串if-else。因为状态机容易维护而且能防止同一时刻执行互相冲突的车轮命令。状态可以这样定义GO_FORWARD前方距离足够继续按目标速度前进。STOP_OR_SLOW前方接近阈值减速或停车准备扫描。SCAN舵机转动扫描障碍物分布。TURN_LEFT/TURN_RIGHT执行转向。BACKWARD如果前方和侧方都很近后退一段距离再转向。比如距离判断可以这样前方大于 30cm正常直行。前方小于 30cm 大于 15cm减速。前方小于 15cm停车做进一步判断。左/右距离哪个更远转向那边。要注意的是超声波模块安装在舵机上后距离读数是相对于当前探头角度的而不是车体静态安装时的读数。所以转向决策用的左右距离是在舵机转到左侧和右侧时分别测的不能拿一个固定正前方的值当左右距离。5.3 速度规划和位置控制在 Arduino 上的落地思路标题里还提到了速度规划和位置控制。这个词听起来偏理论但在这个小车项目里有具体含义。速度规划指的不是给一个固定目标速度而是根据任务和路况动态改变目标速度。最简单的梯形速度规划就是起点阶段逐渐加速中间匀速接近目标位置前逐渐减速。代码大致框架float targetSpeed 0; float desiredMax 2.0; // 单位圈/秒 float accelStep 0.02; void updateSpeedByPlan() { if (shouldSpeedUp) { targetSpeed accelStep; if (targetSpeed desiredMax) targetSpeed desiredMax; } else if (shouldStop) { targetSpeed - accelStep; if (targetSpeed 0) targetSpeed 0; } }位置控制则关注“累计转了多少圈”或者“走了多少厘米”。如果编码器计数只用于速度环速度环每个周期会把计数清零位置信息就丢了。想要定位必须单独维护位置积分。例如每秒读取一次脉冲后不只在速度 脉冲增量 / 采样时间时使用增量还要维护累计脉冲 增量。这样如果编码器每圈 390 个脉冲1 米距离需要累计(100/轮子周长) * 390个脉冲。在 Arduino Uno 上做高精度位置控制并不容易因为主频低、内存小、电机和减速器也有误差。适合先做“走到指定距离停止”的简单位置控制。如果要精确到毫米级建议更换主控为 STM32 或带编码器接口的高性能单片机。6. 从单测到整车常用排查链路和调参边界6.1 启动不到预期按“输入、电源、信号、日志”顺序查整车上容易出现“明明逻辑对结果很乱”的情况因为涉及了太多模块。我给一个排查顺序先看现象是小车完全不转、只转一个轮、乱变向还是结果不稳定。查电源电机启动瞬间电压是否跌落到主控复位点。用万用表测电机和 USB 输入口的电压或者观察启动时串口是否重新打印开机信息。查信号轮子转动时串口打印的编码器计数是否正常有没有忽高忽低。查输入条件超声波模块发回的距离是否稳定探测范围内有没有细杆、地毯、软布等容易反射异常的物品。最后再调算法参数确认前几步没问题后再改 PID 参数和判断阈值。很多人的问题是“看到距离小于 20cm 就转弯但程序好像没反应”。这时候我会先看一眼串口监视器打印的实时距离可能超声波模块根本没触发或者 Echo 引脚接错引脚。6.2 整车上常见坑电压跌落、共地、延迟抖动下面几个问题我在实际测试中遇到过写出来帮你省时间。第一电压跌落导致舵机复位。电机启动瞬间会拉低电池电压如果舵机供电和逻辑供电没分开舵机或单片机可能瞬间掉电重启。重启后变量重新初始化看起来像“乱跑”。解决方法是单独供电和电容储能或者尽量选用低功耗舵机。第二共地问题。编码器、电机驱动、舵机、超声波、Arduino 必须共地。数码电位不同会导致电平判断错误有时是偶发故障特别难查。当你发现编码器读数漫天乱飞时先检查 GND 线是否有松动。第三delay太多导致主循环周期不稳定。超声波测距、舵机转动、串口打印都会占用时间。如果测一次距离要 20ms左右各一次再加上 PID 计算主循环可能达到 100ms 级别。对灵活性较差的小车也许还行但如果 PID 运算用了 float 除以采样时间时间抖动会直接导致速度反馈噪声。建议把控制分成两个频率层次高频任务用定时器中断比如 10ms 或 20ms 做一次 PID 更新低频任务放在主循环比如 200ms 做一次避障决策。这样即使避障判断卡了一下也不会让电机失控。6.3 低配硬件如何取舍 PID 和避障策略Arduino Uno R3 的性能确实不高。如果你用的是 16MHz ATmega328P跑双路编码器中断、浮点 PID、超声波滤波、舵机控制勉强能够但要注意几点尽量多用整数运算不要高频做大量浮点。速度环 50ms 跑一次浮点压力不大但如果 1ms 就做一次浮点 PID就没有必要。中断服务函数要精简不要在中断里调用串口打印、delay或pulseIn。如果超声波扫描导致主循环很慢适当减少测距点位从“左前、正前、右前”三个点简化为“正前 偏向一侧”。如果你的小车没有真正左右编码器只是单测速或者没有测速模块那也能做一个粗糙避障但“位置控制”和“速度规划”就无从谈起。这时可以先做开环节流避障测距、转向、再测距。等攒够编码器和驱动底座再升级闭环控制。如果你用的是仿真平台比如 wokwi可以先验证舵机扫描和状态机逻辑但要注意仿真里超声波的反射模型和实际差距很大。实际跑的时候需要根据真实环境重调阈值和滤波参数。7. 进阶思考从单台 Arduino 到更复杂的控制结构第 2 章做完后你得到的是一台能够基本走直线、检测障碍物并转弯的小车。这已经足够让你体会编码器、中断、PID、超声波、舵机之间的关系。继续往下走可能会有几条路线。一条是往“遥控 自动”方向走用蓝牙或 2.4G 模块切换手动自动。这时决策层会多一个串口通信任务需要注意通信内容和控制频率的冲突。另一条是往“更精确的定位”走加 IMU 惯性测量单元、MPU6050 等多传感器融合用互补滤波或卡尔曼滤波估计姿态和位置。Uno R3 处理这些会比较吃力很多项目会迁移到 ESP32 或 STM32。还有一条是使用 PID 双环结构外环位置控制输出目标速度内环速度控制输出 PWM。这种做法在许多大学的智能车竞赛里很常见也适合作为学习闭环控制的完整案例。但要在 Uno 上调试得好需要合理的定时器分配和采样频率设计。要注意一点控制算法的进阶并不代表代码越长越好。我见过不少项目把 PID 参数、滤波窗口、避障阈值全写在主循环里最后想调一个超声波阈值都要翻很久还容易改错变量。建议一开始就把函数按模块拆开EncoderSampling、SpeedPID、ObstacleDetect、DecisionLogic。每个模块只负责一件事串口输出清晰一点整机调试会轻松很多。如果你确实想长时间跑这个项目我比较推荐准备一个逻辑分析仪。它能直观地看到编码器 A、B 两路信号的相位关系判断到底是接线问题还是程序丢中断问题。价格不高对排查脉冲计数异常非常有帮助。再一个容易被忽视的问题是机械结构。编码器电机轴的固定是否牢固轮胎是否打滑舵机支架是否晃动超声波探头是否保持平行都会影响最终表现。控制算法能补一部分误差但补不了严重的机械偏差。如果你的车跑起来带有固定方向的偏斜先检查轮子固定和车架刚性不要急着把 PID 参数调到很极端。最后留几个我实测后会优先看的点串口打印左右轮编码器 1 秒累计值是否稳定且比例合理。PID 输出 PWM 有没有频繁顶到上限或掉到零。超声波滤波后的距离是不是还会偶尔跳变到极限值。舵机转到左右极限时Arduino 会不会因为供电波动复位。电池电量低时小车避障表现是不是明显变差如果是先评估供电再评估算法。把这几点整理清楚Arduino Uno R3 的控制算法就算是真正入门了。
返回列表