在这个章节之前,你大概率已经完成过 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 |
| 电机驱动 | IN1 | D4 |
| 电机驱动 | IN2 | D5 |
| 电机驱动 | IN3 | D6 |
| 电机驱动 | IN4 | D7 |
| 电机驱动 | 左使能 PWM | D9 |
| 电机驱动 | 右使能 PWM | D10 |
| 超声波 | Trig | D11 |
| 超声波 | Echo | D12 |
| 舵机 | 信号线 | 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、D3,B 相接到 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 从脉冲速度换算到转速
“每秒脉冲数”能够直接用于左右轮对比,但不够直观。假如你想知道电机每分钟转多少圈,需要一个关键参数:每圈产生多少个脉冲。这个参数通常和编码器线数、减速比有关。
如果减速电机铭牌给出“每圈输出多少个计数脉冲”,那可以将脉冲速度换算成 RPM:
rpm = (脉冲每秒 / 每圈总脉冲数) * 60如果你的编码器安装在电机轴上,而轮子通过减速箱输出,实际轮子转速还要再除以减速比。不同厂家的电机不一致,所以代码里最好把这个参数定义为一个常量,方便校准时修改。
需要注意,M 法测速在低速时误差偏大。它的原理是“在一段固定时间内数脉冲数”,如果时间窗口内只到来 1 到 2 个脉冲,误差就会非常明显。让小车以较慢速度行驶时,就会看到速度值在高频抖动。解决办法是适当延长采样窗口,例如从 50ms 改为 100ms。若速度极低,则更适合使用 T 法,也就是测量单个脉冲的时间宽度。本章项目把采样周期控制在 50 到 100ms 比较合适。
4.4 外部中断、定时器与主循环的时间关系
Arduino Uno R3 的定时器资源不止一个。millis()本身依赖定时器 0,PWM 功能依赖定时器 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)是基础。它认为误差越大,修正越用力。例如当前速度比目标低 50,P 项提供 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