先把这个结论放前面: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 编码器计数和速度计算的示例思路
我一般建议先不接驱动负载,直接手动转轮子,在串口监视器看脉冲数是否稳定。比如手动把轮子匀速转一圈,观察到的脉冲数应接近理论值。如果偏太多,先确认是不是倍频问题。
表:常见速度换算所需参数
| 参数 | 示例值 | 作用 |
|---|---|---|
| 编码器 PPR | 13 | 电机输出轴一圈的脉冲数 |
| 减速比 | 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 范围是 0~255。如果你用带死区的驱动板,还要考虑最小启动 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 限制在 0~255;如果需要后退,那么负 PWM 代表反转,这时要确认电机驱动板的 IN1/IN2 逻辑已经配合好。
我建议先单独测试左轮和右轮,让它们分别跑同一目标速度,观察串口打印的实际速度。如果左右实际速度相近,说明速度闭环已经有效;如果差得远,可能是编码器安装松动、信号线互相干扰,或者驱动板两个通道 PWM 抖动不同。
等到两个轮子都能单独稳速,再让它走直线。这里有一个经验:普通面包板和排线在电机运行时容易受干扰,编码器信号线尽量短,最好用屏蔽线或者双绞线。
3.3 调参顺序:先 P,再 I,D 可以先小或不用
PID 调参不建议直接三参数一起调,那样出了问题根本不知道是谁引起的。我的习惯是先只保留 P。
操作步骤:
- 把 I 和 D 设成 0。
- 给一个小目标速度,比如每秒 1 圈。
- 从 kp=0.1 开始,一点点往上加,直到轮速接近目标且不持续震荡。
- 记录当前 kp。
- 再加入 I,从 ki=0.01 开始,观察长时间运行后,轮速能否稳定在目标值。
如果目标速度改变后轮子需要比较长时间才能跟上,可以适当加 I。如果出现高频抖动,检查一下是不是 D 写了但没置零,也可能是编码器采样周期不稳定,导致速度信号本身就抖。
PID 并没有“万能参数”。不同电机、不同电池电压、不同减速比,参数都会不同。把参数写死在某一个测试平台上可以,但如果换硬件,至少要重新跑一遍调参流程。
4. 超声波测距与滤波:避障前的第一步是信得过的距离
4.1 HC-SR04 测距基本流程
HC-SR04 是常见的超声波模块,使用流程并不复杂:
- Trig 引脚给一个 10us 以上的高电平,模块开始发送超声波。
- 模块自动发送 8 个 40kHz 的脉冲,并等待回波。
- Echo 引脚输出高电平,高电平持续时间代表声波往返时间。
- 距离 = 高电平时间 × 声速 / 2。
在 Arduino 中可以用pulseIn()函数。但要注意,pulseIn()在等待时是阻塞的,如果此时 PID 需要用中断采样,倒还好;如果你在主循环里用pulseIn()测距,超声波测一次大约几个毫秒,高负载时可能影响实时性。
常见接线:
- VCC → 5V
- GND → GND
- Trig → D7
- Echo → 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 的控制算法就算是真正入门了。