基于Arduino Uno的自动导航小车:编码器测速与PID双闭环控制实现 📅 发布时间:2026/9/5 17:50:42 👁 浏览次数: 这一章我们把小车从“按键能跑但每次都跑歪”改成真正意义上有“感知、决策、执行”的自动导航小车。板上钉钉的是这块 Arduino Uno R3ATmega328P16MHz 主频2KB SRAM放现在连玩具都算不上高性能但把一个完整的“编码器测速 舵机云台超声波避障 PID 双闭环控制 状态机导航”工程塞进去依然绰绰有余。前提是你得把代码结构理清楚别到处写 delay别让慢速传感器去打断快速控制环。在开始正文之前先亮明核心结论能不用换主控Uno R3 足够跑完整套控制算法编码器测量轮速必须用中断不能靠 while 轮询只调开环 PWM小车一定走不直必须上 PID 速度环超声波不适合“实时连续控制”适合在状态机里做低频避障决策位置控制不是靠累计 PWM 时间而是靠编码器脉冲闭合回来本文会把自动避障状态机、速度规划、双闭环控制全部串到一个 main-loop 里给出一套可运行框架。全文是 CSDN 技术教程写法不是 PPT 课程建议打开 Arduino IDE屏幕分成左右两边左边读代码右边对照接线表然后一步一步把代码烧进去验证。1. 核心能力速览先说本套方案的能力边界和数据通路这样你能快速判断这个方案是否符合预期。能力项说明主控平台Arduino Uno R3ATmega328P16MHz开发环境Arduino IDE 1.8.x 或 2.x运动底盘两轮差速驱动左右电机均为编码器减速电机测速传感器霍尔/光电编码器AB 相输出A 相接外部中断外部中断使用左电机 A 相接 D2右电机 A 相接 D3测距传感器HC-SR04 超声波模块Trig 和 Echo 各占用 1 个 IO角度扫描机构SG90 或 MG90 舵机用于超声波左右扫描电机控制算法PID 速度环PID 位置外环 速度内环滤波方案一阶低通滤波 / 滑动窗口中值滤波根据数据性质选择行为控制有限状态机 FSM前进、扫描、转向、停车任务调度方式millis() 非阻塞调度速度环周期 5ms测距 20ms 级调试手段串口监视器 Arduino IDE 串口绘图器需要特别说明一点下面所有代码我按“最常见的两轮差速小车”来写也就是两个驱动轮带编码器外加一个万向轮。如果你的车是四轮独立驱动、四路编码器都要测那需要把中断部分换成 Pin Change Interrupt或者换 Mega / ESP32 这类中断资源更多的板子代码的主干逻辑不变。2. 先想清楚小车自动导航到底是谁在指挥很多新手拿到这种项目第一个误区就是以为“导航”是一个大 while 循环里塞满 if else。真这么写代码会在超声波测距的时候卡死也会在舵机扫描的时候把速度控制全部停掉。控制小车自动跑本质是三层任务在同时工作感知层编码器脉冲、超声波距离、舵机当前角度控制层测速、速度环 PID、位置外环 PID、PWM 输出决策层根据当前任务目标决定是前进、转弯、扫描还是停车。三层任务对实时性的要求完全不同层数据典型周期用途感知编码器脉冲每 1~10ms速度环反馈控制PID 输出每 5~20ms保持轮速稳定感知超声波距离每 20~100ms避障判断决策状态机切换每 50~200ms判断下一步动作如果你用 delay() 把所有任务串起来超声波等待回波默认最长能到几十毫秒舵机转动又要几十毫秒速度控制早乱了。这章代码最终会收敛成一段runTaskByTime()调度框架。这个框架比具体的“避障三行判断”更重要因为后面你要加蓝牙遥控、陀螺仪、视觉巡线能复用的是这个调度结构而不是那几行避障 if。3. 编码器测速与外部中断3.1 编码器为什么必须搭配中断现在市面上的 Arduino 学习小车电机后面通常带着一个霍尔编码器。电机转动时编码器输出 A、B 两路方波两路信号相差 90 度通过 A/B 相可以知道轮子是否在转、转了多少、朝哪个方向转。一个典型问题能不能直接在 main-loop 里用 digitalRead() 去读 A 相脉冲答案是不能。如果你主循环里有超声波测距、PID 运算、串口打印这些函数执行时间加起来很容易超过 1ms轮子高速旋转时脉冲早丢了一大截。编码器测速必须用“边沿触发中断”A 相每产生一个上升沿MCU 立刻跳进中断服务函数在中断里累加计数。只要中断服务函数足够短就不会丢脉冲。3.2 最小可用的编码器计数代码下面这段代码按照两轮差速小车接线A 相接在 Uno 的外部中断引脚 D2 和 D3B 相接普通 IO用于判断方向。#define ENC_L_A 2 #define ENC_L_B 4 #define ENC_R_A 3 #define ENC_R_B 7 volatile long encCountL 0; volatile long encCountR 0; // 左电机 A 相上升沿中断 void encLeftISR() { if (digitalRead(ENC_L_B)) { encCountL; // B 相高电平默认正转 } else { encCountL--; // B 相低电平默认反转 } } // 右电机 A 相上升沿中断 void encRightISR() { if (digitalRead(ENC_R_B)) { encCountR; } else { encCountR--; } } void setupEncoders() { pinMode(ENC_L_A, INPUT_PULLUP); pinMode(ENC_L_B, INPUT_PULLUP); pinMode(ENC_R_A, INPUT_PULLUP); pinMode(ENC_R_B, INPUT_PULLUP); // 在 A 相上升沿触发中断 attachInterrupt(digitalPinToInterrupt(ENC_L_A), encLeftISR, RISING); attachInterrupt(digitalPinToInterrupt(ENC_R_A), encRightISR, RISING); }注意三点encCountL、encCountR声明成volatile因为这两个变量会被中断修改编译器优化时必须保证每次都从内存重读。示例是“RISING 上升沿单倍频”计数。若想要更高分辨率可以把模式改成CHANGE一周期计两次精度翻倍但中断频率也翻倍。digitalRead()在中断服务函数里速度不算快但只有 4 个数字引脚读取单次中断开销极小实测不会拖垮 16MHz 主控。更讲究的做法是用端口寄存器直读课程阶段不必过度优化。3.3 从脉冲数换算成转速编码器脉冲要换算成轮子实际转速必须先知道产品的两个参数编码器本体每转一圈输出多少脉冲减速箱减速比是多少。例如电机规格书上写着“编码器 20 PPR减速比 1:30”那么电机输出轴转一圈编码器累计脉冲大约是PULSES_PER_REV 20 * 30 600也就是编码器计数从 0 变到 600说明车轮实际转了一圈。取速度的时候不需要读瞬时脉冲只需要在上一次控制周期结束时把encCountL和现在的encCountL相减得到当前周期内的脉冲增量。const float ENCODER_PPR 20.0f; const float GEAR_RATIO 30.0f; const float PULSES_PER_REV ENCODER_PPR * GEAR_RATIO; const float TICK_TO_RPM_L 60.0f / PULSES_PER_REV; // 每隔 5ms 调用一次 float calcSpeedRPM(long currentCount, long *lastCount) { long delta currentCount - *lastCount; *lastCount currentCount; // 把 5ms 内的脉冲增量换算成“每分钟转数” float revPerSecond (float)delta / PULSES_PER_REV / 0.005f; return revPerSecond * 60.0f; }这里有个工程细节速度环周期越短测速“分辨率”越低。如果周期是 5ms但电机转速较低5ms 内只有 1~2 个脉冲那速度反馈就会一跳一跳PID 输出也会跟着抖。所以主控能力允许时把速度环周期选在 5~20ms 比较合理如果是低速高精度场景则需要改用“定时器捕获单脉冲周期”的测速方式也叫 T 法测速。4. 超声波测距、舵机扫描与数据滤波