从PWM到PID:基于ESP32与TB6612的智能小车闭环控制实践

1. 项目缘起:从“仰望”到“盘古斧”的致敬之旅

前阵子,我翻出了几年前和一群创客小伙伴一起捣鼓的“仰望小车”项目。那是一个用Arduino Uno、L298N电机驱动模块和几个SG90舵机拼凑出来的简易小车,功能很简单,就是能跑、能转、能抬头“仰望”一下。虽然简陋,但那是我们很多人硬件入门的起点,充满了手忙脚乱接错线、代码死活调不通、最后成功跑起来那一刻的欢呼。

这次,我决定用一些更“现代”一点的家伙什儿,复刻并升级这个经典项目,于是就有了“仰望小车2-盘古斧版”。这个名字有两层意思:一是“仰望”,延续初代项目那份对未知领域的好奇与探索精神;二是“盘古斧”,借用了“盘古斧扩展板”这个名字,寓意着用更强大、更趁手的工具(这把“斧子”),在创客的世界里开天辟地,做出更有趣的东西。更重要的是,这个项目是向当年一起熬夜调试、分享代码、互相“坑”也互相“救”的小伙伴们致敬。我相信每个玩硬件的人,都有这么一群“战友”。所以,这不是一个冷冰冰的教程,更像是一次带着回忆的技术重温,我会把当年踩过的坑、悟出的道,以及用新工具带来的新体验,毫无保留地分享出来。

新版小车的核心,是一块掌控板(一款集成了ESP32、屏幕、按键和多种传感器的开源硬件)和与之配套的盘古斧扩展板。驱动部分,我们告别了经典的L298N,选用更高效、更小巧的TB6612电机驱动模块来驱动两个N20减速电机(带编码器反馈)。至于“仰望”的动作,则交给一个MG996R金属齿轮舵机来实现。整个系统的控制逻辑,从简单的延时驱动,升级为基于PWM(脉冲宽度调制)的精准速度控制,并为未来的PID闭环控制预留了空间。接下来,我就带你一步步“盘”出这辆致敬版小车。

2. 硬件选型与核心模块深度解析

硬件是项目的骨架,选型决定了项目的天花板和调试的难易程度。这次升级,每一个部件的选择背后都有明确的考量,绝非随意堆砌。

2.1 主控与扩展:掌控板 + 盘古斧扩展板

选择掌控板作为主控,首要原因是其极高的集成度和友好的开发环境。它核心是一颗ESP32,双核处理器,主频高达240MHz,性能远超传统的Arduino Uno(ATmega328P,16MHz)。这意味着我们可以运行更复杂的控制算法,处理编码器反馈数据也游刃有余。板载的1.3寸OLED屏幕、多个按键、RGB LED、麦克风、加速度计等传感器,为我们提供了丰富的人机交互和状态显示可能,无需额外焊接和接线,极大降低了入门门槛。

盘古斧扩展板,则是让掌控板如虎添翼的关键。它直接插在掌控板下方,解决了ESP32芯片GPIO引脚数量相对有限、驱动能力不足的问题。盘古斧板载了:

  • 电机驱动接口:直接连接TB6612模块,提供整齐的电源和信号接口。
  • 多路舵机接口:提供稳定的5V电源和信号线,专门为舵机设计,避免因电机工作导致电源波动影响舵机。
  • 丰富的扩展口:将掌控板的GPIO以更规整的3Pin(GND、VCC、Signal)或4Pin(I2C)接口引出,接线清晰,不易出错。
  • 电源管理:支持多种电源输入(如锂电池、USB),并提供稳压输出,确保各模块供电稳定。

为什么不用Arduino直接驱动?早期我们直接用Arduino Uno的PWM引脚驱动L298N,再连接舵机。问题在于,Arduino的5V引脚输出电流有限,同时驱动电机和舵机极易导致电源电压被拉低,引起舵机抖动、单片机复位。盘古斧扩展板通过独立的电源路径和稳压设计,完美隔离了数字逻辑部分和大功率负载部分,这是项目稳定的基石。

2.2 动力与执行单元:电机、驱动与舵机

电机及其驱动:从L298N到TB6612初代小车用的L298N是一个经典的H桥驱动芯片,但它有几个显著缺点:发热量大、效率低(压降大)、需要外接散热片、逻辑电源和电机电源需隔离处理,对新手不友好。

这次我们选用TB6612FNG驱动模块。它是一个双通道H桥驱动器,优势非常明显:

  • 高效率:MOSFET工艺,导通内阻小,发热远低于L298N。
  • 大电流:持续电流1.2A(单通道),峰值3.2A,驱动我们选用的N20编码电机(工作电流通常在200-300mA)绰绰有余。
  • 集成度高:模块集成了逻辑电平转换,可以直接用3.3V的掌控板GPIO控制,无需额外电路。同时具备待机(STBY)引脚,方便整体使能控制。
  • 接口清晰:PWMA/AIN1/AIN2控制电机A,PWMB/BIN1/BIN2控制电机B,接线一目了然。

关于电机选型,我们选择了带霍尔编码器的N20减速电机。编码器能反馈电机的转速信息,这是实现精准闭环控制(如PID调速)的前提。编码器一般输出两相正交脉冲(A相、B相),通过捕获脉冲频率和方向,可以计算出转速和转动距离。

舵机控制:PWM信号的奥秘“仰望”动作由一个MG996R舵机完成。舵机的控制本质是一个PWM信号,但不同于控制电机速度的PWM(频率固定,占空比变化对应速度),舵机控制是一个位置伺服系统。

舵机接收的PWM信号标准通常是:周期为20ms(频率50Hz),脉冲高电平宽度在0.5ms到2.5ms之间。这个脉冲宽度直接对应舵机输出轴的角度。

  • 0.5ms-> 对应0度(或-90度,取决于舵机标定)。
  • 1.5ms-> 对应90度(中位)。
  • 2.5ms-> 对应180度(或+90度)。

因此,控制舵机就是精确生成这个宽度可变的脉冲。掌控板的任意GPIO(在软件支持下)都可以通过LEDC(LED PWM控制器)硬件外设来产生非常精准的PWM信号,稳定性远超Arduino上用analogWrite实现的软件PWM。

注意:MG996R这类金属齿轮舵机堵转电流很大(可达1A以上),务必确保其供电电源(盘古斧扩展板的舵机接口VCC)能提供足够电流,最好与电机驱动电源分开或使用大容量锂电池,否则会出现舵机无力、抖动甚至导致整个系统重启。

2.3 感知与反馈:编码器与未来可能

本次项目的核心反馈来自电机上的霍尔编码器。它通常有3根线:VCC(3.3V/5V)、GND、OUT(信号)。电机转动时,OUT引脚会输出方波脉冲。单位时间内的脉冲数(频率)对应转速。为了辨别方向,高级的编码器会输出A、B两相相位差90度的脉冲。通过判断A相上升沿时B相的电平高低,即可确定转向。

在代码中,我们使用中断来捕获编码器脉冲的上升沿和/或下降沿,在中断服务函数里对一个计数器进行加减。例如,设定电机正转时计数器加1,反转时减1。主循环定期(比如每100ms)读取这个计数器的值,并清零,根据计数值和时间间隔就能算出该时间段内的平均转速。这就是速度闭环的反馈基础。

未来,如果想实现更复杂的“自主仰望”,可以加入一个超声波传感器或TOF激光测距模块(连接盘古斧的I2C或GPIO口),测量前方障碍物高度,从而动态控制舵机抬头角度,让小车真正具备“感知-决策-执行”的雏形。

3. 电路连接与电源管理实战

清晰的接线是成功的一半,混乱的接线则是调试的噩梦。下面给出详细的接线表,并解释关键点。

核心接线清单(掌控板+盘古斧+TB6612+电机/舵机):

起点终点 (TB6612模块)线色建议功能说明
盘古斧电机A PWMTB6612PWMA黄色控制电机A速度
盘古斧电机A方向1TB6612AIN1白色控制电机A方向
盘古斧电机A方向2TB6612AIN2灰色控制电机A方向
盘古斧电机B PWMTB6612PWMB橙色控制电机B速度
盘古斧电机B方向1TB6612BIN1紫色控制电机B方向
盘古斧电机B方向2TB6612BIN2蓝色控制电机B方向
盘古斧电机使能TB6612STBY绿色高电平使能驱动芯片
盘古斧VIN(+)TB6612VM(电机电源)红色电机驱动主电源 (建议7-12V)
盘古斧GNDTB6612GND黑色共地,至关重要!
TB6612AO1左电机 (电机A) 正极驱动左电机
TB6612AO2左电机 (电机A) 负极驱动左电机
TB6612BO2右电机 (电机B) 正极驱动右电机
TB6612BO1右电机 (电机B) 负极驱动右电机
盘古斧舵机1 信号MG996R 信号线 (黄色/白色)黄色舵机控制信号
盘古斧舵机1 VCCMG996R 电源线 (红色)红色舵机电源 (5V)
盘古斧舵机1 GNDMG996R 地线 (棕色/黑色)黑色舵机电源地
左电机编码器 OUT掌控板 GPIO (如 IO25)绿编码器脉冲输入 (需上拉)
右电机编码器 OUT掌控板 GPIO (如 IO26)编码器脉冲输入 (需上拉)
编码器 VCC (3.3V)盘古斧 3.3V编码器供电
编码器 GND盘古斧 GND编码器共地

电源管理详解与避坑指南:这是硬件项目中最容易出问题的地方。我们面临至少三个“用电大户”:两个直流电机(启动瞬间电流大)、一个舵机(堵转电流大)、主控及逻辑电路(要求电压稳定)。

  1. 双电源轨策略(推荐):最稳妥的方案是使用两节18650锂电池串联(约7.4V-8.4V)作为主电源。这个电源接入盘古斧扩展板的VIN口。盘古斧会将其一路供给TB6612的VM(电机电源),另一路通过内部的DC-DC降压模块,稳定输出5V和3.3V。其中5V供给舵机接口,3.3V供给掌控板及编码器等逻辑器件。这样,电机工作的电流波动不会直接影响逻辑电路的电压,从物理上实现了“强弱电隔离”。

  2. 单电源注意事项:如果只想用一块电池(如单节18650,3.7V),需要确认盘古斧扩展板是否支持低压输入并仍能输出稳定的5V给舵机。很多舵机在电压低于4.8V时就会工作异常。同时,单节锂电池电压经驱动板压降后,留给电机的电压更低,可能导致小车动力不足。

  3. 共地是生命线:无论采用何种供电方案,所有模块的GND(地线)必须连接在一起,形成统一的参考零电位。TB6612的GND、盘古斧的GND、电池的负极、编码器的GND,必须全部连通。否则PWM控制信号会因为参考电平不同而失效,导致电机不受控或乱转。

  4. 上拉电阻的必要性:编码器的信号输出线,在连接到掌控板GPIO时,建议在软件中启用内部上拉电阻(pinMode(encoderPin, INPUT_PULLUP)),或者外部接一个10kΩ电阻到3.3V。这样可以确保信号线在空闲时处于确定的高电平状态,避免因干扰产生误脉冲计数。

实操心得:接线时,强烈建议先不接电机,用万用表蜂鸣档或电阻档,按照上表逐一检查每一条线的连通性,特别是电源和地线。确认无误后再通电。通电后,先测量TB6612的VM电压、盘古斧输出的5V和3.3V电压是否正常。这一步能排除90%的硬件连接故障。

4. 软件架构与核心代码实现

硬件搭好了,接下来就是赋予它灵魂的代码。我们将使用Arduino IDE进行开发,因为其对ESP32和掌控板的支持已经非常完善。代码结构将分为几个核心部分:引脚定义与初始化、电机驱动函数、舵机控制函数、编码器中断服务函数以及主控制逻辑。

4.1 引脚定义与PWM配置

首先,我们需要根据之前的接线表,定义所有用到的GPIO引脚。

// 电机驱动引脚定义 (连接TB6612) #define MOTOR_STBY 4 // 使能引脚,高电平有效 // 电机A (左电机) #define MOTOR_A_PWM 13 // PWMA #define MOTOR_A_IN1 15 // AIN1 #define MOTOR_A_IN2 2 // AIN2 // 电机B (右电机) #define MOTOR_B_PWM 12 // PWMB #define MOTOR_B_IN1 17 // BIN1 #define MOTOR_B_IN2 16 // BIN2 // 编码器引脚定义 #define ENCODER_A_PIN 25 // 左电机编码器 #define ENCODER_B_PIN 26 // 右电机编码器 // 舵机引脚定义 #define SERVO_PIN 14 // 舵机信号线 // 全局变量 volatile long encoderCountA = 0; // 左编码器计数, volatile 因为会在中断中修改 volatile long encoderCountB = 0; // 右编码器计数 int targetAngle = 90; // 舵机目标角度,初始化为中位

接下来是PWM配置。对于ESP32,我们使用ledc功能来生成高质量的PWM。需要为每个PWM引脚设置通道、频率和分辨率。

// PWM通道和参数配置 #define PWM_FREQ 5000 // 电机PWM频率,5kHz,TB6612工作良好 #define PWM_RESOLUTION 8 // 8位分辨率,占空比范围0-255 #define LEDC_CHANNEL_MOTOR_A 0 #define LEDC_CHANNEL_MOTOR_B 1 #define LEDC_CHANNEL_SERVO 2 #define SERVO_FREQ 50 // 舵机PWM频率,必须50Hz void setupPWM() { // 配置电机PWM ledcSetup(LEDC_CHANNEL_MOTOR_A, PWM_FREQ, PWM_RESOLUTION); ledcAttachPin(MOTOR_A_PWM, LEDC_CHANNEL_MOTOR_A); ledcSetup(LEDC_CHANNEL_MOTOR_B, PWM_FREQ, PWM_RESOLUTION); ledcAttachPin(MOTOR_B_PWM, LEDC_CHANNEL_MOTOR_B); // 配置舵机PWM (50Hz, 14位分辨率以获得更精细的角度控制) ledcSetup(LEDC_CHANNEL_SERVO, SERVO_FREQ, 14); // 2^14 = 16384 ledcAttachPin(SERVO_PIN, LEDC_CHANNEL_SERVO); }

为什么电机PWM用5kHz,舵机用50Hz?电机驱动芯片TB6612对PWM频率有较宽的适应范围,通常在1kHz到几十kHz都可以。选择5kHz是一个平衡点:频率太低,电机运转噪音大(可听到的啸叫声);频率太高,开关损耗会增加。5kHz能保证电机运行平稳安静。而舵机必须使用50Hz(周期20ms)的标准伺服信号,这是舵机内部控制电路的设计决定的,不能更改。

4.2 电机驱动与基本运动函数

基于TB6612的驱动逻辑,我们可以封装出控制单个电机转向和速度的函数。TB6612的逻辑真值表如下:

IN1IN2PWM输出状态
HIGHLOW有效正转 (AO1高, AO2低)
LOWHIGH有效反转 (AO1低, AO2高)
LOWLOW有效刹车 (短路制动)
HIGHHIGH有效刹车 (短路制动)
XXLOW停止 (高阻态)
void setMotor(int motor, int speed, int direction) { // motor: 0 for A, 1 for B // speed: 0-255 // direction: 0 for brake, 1 for forward, -1 for backward int in1, in2, pwmChannel; if (motor == 0) { // 电机A in1 = MOTOR_A_IN1; in2 = MOTOR_A_IN2; pwmChannel = LEDC_CHANNEL_MOTOR_A; } else { // 电机B in1 = MOTOR_B_IN1; in2 = MOTOR_B_IN2; pwmChannel = LEDC_CHANNEL_MOTOR_B; } if (direction == 1) { // 正转 digitalWrite(in1, HIGH); digitalWrite(in2, LOW); ledcWrite(pwmChannel, speed); } else if (direction == -1) { // 反转 digitalWrite(in1, LOW); digitalWrite(in2, HIGH); ledcWrite(pwmChannel, speed); } else { // 刹车/停止 digitalWrite(in1, LOW); digitalWrite(in2, LOW); ledcWrite(pwmChannel, 0); // PWM置0,高阻态停止 } } // 封装小车基本动作 void carForward(int speed) { setMotor(0, speed, 1); setMotor(1, speed, 1); } void carBackward(int speed) { setMotor(0, speed, -1); setMotor(1, speed, -1); } void carTurnLeft(int speed) { // 原地左转 setMotor(0, speed, -1); setMotor(1, speed, 1); } void carTurnRight(int speed) { // 原地右转 setMotor(0, speed, 1); setMotor(1, speed, -1); } void carStop() { setMotor(0, 0, 0); setMotor(1, 0, 0); }

4.3 编码器中断与速度测量

为了获取电机转速,我们需要在编码器脉冲的上升沿(或双边沿)触发中断,并计数。

// 编码器中断服务函数 void IRAM_ATTR encoderISR_A() { encoderCountA++; // 假设正转时计数增加 } void IRAM_ATTR encoderISR_B() { encoderCountB++; } void setupEncoder() { pinMode(ENCODER_A_PIN, INPUT_PULLUP); // 启用内部上拉 pinMode(ENCODER_B_PIN, INPUT_PULLUP); // 附加中断,在上升沿触发 attachInterrupt(digitalPinToInterrupt(ENCODER_A_PIN), encoderISR_A, RISING); attachInterrupt(digitalPinToInterrupt(ENCODER_B_PIN), encoderISR_B, RISING); } // 读取并计算速度 (RPM) float getSpeedRPM(int motor) { volatile long *count; unsigned long currentTime = millis(); static unsigned long lastTime = 0; static long lastCountA = 0, lastCountB = 0; if (motor == 0) { count = &encoderCountA; } else { count = &encoderCountB; } long currentCount = *count; unsigned long timeDelta = currentTime - lastTime; if (timeDelta > 100) { // 每100ms计算一次速度 long countDelta = (motor == 0) ? (currentCount - lastCountA) : (currentCount - lastCountB); // 假设编码器为单相输出,每转产生N个脉冲 (例如,电机减速比+编码器分辨率) const int PULSES_PER_REV = 11 * 34; // 示例:电机减速比34:1,编码器每转11脉冲 float rpm = (countDelta / (float)PULSES_PER_REV) / (timeDelta / 60000.0); // 转换为RPM // 更新上一次的计数和时间 if (motor == 0) lastCountA = currentCount; else lastCountB = currentCount; lastTime = currentTime; return rpm; } return 0.0; // 时间间隔太短,返回0 }

关键点

  1. IRAM_ATTR:这个宏告诉编译器将中断服务函数放在内部RAM中,以确保中断能被快速响应,避免因从Flash读取代码导致的延迟。
  2. 脉冲数换算PULSES_PER_REV需要根据你的具体电机确定。N20电机通常自带一个减速箱(比如34:1),并且编码器是安装在电机轴(减速前)还是输出轴(减速后)?这决定了最终每转一圈输出的脉冲数。务必查阅电机手册或通过实验测量。
  3. 中断去抖:机械编码器可能存在抖动,产生毛刺脉冲。可以在中断服务函数中加入简单的延时去抖(delayMicroseconds(100)),但更优雅的方式是在硬件信号线上加入一个小的RC滤波电路(如100Ω电阻串联,0.1uF电容对地)。

4.4 舵机角度控制函数

控制舵机,就是将目标角度(0-180度)转换为对应的PWM脉冲宽度(0.5ms-2.5ms),再转化为ledcWrite所需的占空比值。

void setServoAngle(int angle) { // 将角度限制在0-180度 angle = constrain(angle, 0, 180); // 将角度转换为脉冲宽度(微秒) // 0度 -> 500us, 180度 -> 2500us int pulseWidth = map(angle, 0, 180, 500, 2500); // 将脉冲宽度转换为ledcWrite的占空比值 // 周期 = 1 / 频率 = 1 / 50Hz = 20,000 us // 占空比 = (脉冲宽度 / 周期) * (2^分辨率) // 我们使用14位分辨率 (16384) uint32_t duty = (pulseWidth * 16384) / 20000; ledcWrite(LEDC_CHANNEL_SERVO, duty); }

map()函数是Arduino内置的线性映射函数,非常方便。这里的关键是理解占空比的计算:PWM周期是20000微秒,我们需要的有效高电平时间是pulseWidth微秒,所以占空比 =pulseWidth / 20000。而ledcWrite写入的值是这个比例乘以最大计数值(2^分辨率 - 1)。对于14位分辨率,最大值是16383(0-16383),但计算时用16384更直观,误差可忽略。

4.5 主循环与致敬动作序列

最后,我们在setup()中初始化所有功能,在loop()中编写一个简单的动作序列,让小车完成一次“致敬”表演。

#include <Arduino.h> // ... 所有之前的引脚定义和函数声明 ... void setup() { Serial.begin(115200); // 初始化引脚模式 pinMode(MOTOR_STBY, OUTPUT); digitalWrite(MOTOR_STBY, HIGH); // 使能电机驱动 pinMode(MOTOR_A_IN1, OUTPUT); pinMode(MOTOR_A_IN2, OUTPUT); pinMode(MOTOR_B_IN1, OUTPUT); pinMode(MOTOR_B_IN2, OUTPUT); // 初始化PWM、编码器中断 setupPWM(); setupEncoder(); // 初始化舵机到中位 setServoAngle(90); delay(1000); // 等待舵机到位 Serial.println("仰望小车2-盘古斧版 初始化完成!"); } void loop() { // 动作序列:前进 -> 抬头 -> 旋转 -> 低头 -> 后退 -> 归位 Serial.println("动作开始:前进2秒"); carForward(150); // 以速度150前进 delay(2000); carStop(); delay(500); Serial.println("仰望星空!"); for (int ang = 90; ang >= 30; ang -= 2) { // 从90度慢慢抬头到30度 setServoAngle(ang); delay(50); } delay(1000); // 仰望1秒 Serial.println("旋转致敬"); carTurnRight(180); // 快速右转 delay(800); // 旋转约180度(需要根据实际情况调整时间) carStop(); delay(500); Serial.println("低头致意"); for (int ang = 30; ang <= 90; ang += 2) { // 从30度慢慢低头回90度 setServoAngle(ang); delay(50); } delay(500); Serial.println("后退返回"); carBackward(150); delay(2000); carStop(); Serial.println("一轮动作结束,等待5秒后重复..."); delay(5000); }

这个loop()函数实现了一个简单的自动化演示。你可以通过掌控板上的按键来触发不同的动作,或者通过蓝牙/Wi-Fi接收指令进行遥控,这只需要在loop()中加入状态判断或串口/网络命令解析即可。

5. 调试、优化与进阶玩法

代码烧录进去,小车动起来了,但很可能不完美。以下是调试中常见的问题和优化方向。

5.1 常见问题排查清单

  1. 电机不转,只有嗡嗡声

    • 检查电源:用万用表测量TB6612的VM引脚电压,确保在电机额定电压范围内(如7.4V)。电压过低会导致驱动力不足。
    • 检查使能:确认STBY引脚是否为高电平。
    • 检查PWM信号:可以用示波器或逻辑分析仪查看PWM引脚是否有波形输出。如果没有,检查代码中ledcSetupledcAttachPin是否正确,或者尝试用digitalWrite直接控制IN1/IN2,看电机是否以一个方向全速转动(跳过TB6612,直接给电机供电需极其谨慎,短时间测试即可)。
    • 检查接线:确认电机线是否牢固连接在TB6612的AO1/AO2BO1/BO2上,而不是电源端。
  2. 舵机抖动或不动

    • 电源不足:这是最常见原因。单独给舵机供5V/2A以上的电源测试。或者在舵机动作时,测量盘古斧舵机接口的5V电压是否被拉低(低于4.8V)。
    • PWM频率错误:确认舵机PWM频率是否为50Hz
    • 信号线接触不良:检查舵机信号线是否连接正确。
  3. 编码器计数不准或为0

    • 上拉电阻:确保编码器信号引脚启用了内部上拉(INPUT_PULLUP)或接了外部上拉电阻。
    • 中断冲突:ESP32某些引脚可能不支持中断,或与其他功能(如SPI、I2C)冲突。查阅掌控板引脚定义图,更换编码器引脚。
    • 机械安装:编码器码盘与传感器之间距离是否合适?太远信号弱,太近可能摩擦。
    • 中断服务函数过长:中断函数应尽可能短小,只做计数增减。避免使用Serial.print()delay()等耗时操作。
  4. 小车跑偏

    • 这是几乎必然出现的现象。即使两个电机型号相同,其内阻、减速箱摩擦、车轮摩擦力都有微小差异。
    • 软件校准:在carForward函数中,给两个电机设置不同的PWM值进行微调。例如,如果小车向右偏,就稍微调高左电机速度或调低右电机速度。可以写一个校准函数,通过串口发送指令来动态调整这个偏差值。

5.2 从开环到闭环:PID速度控制初探

开环控制(直接给固定PWM)简单,但无法保证速度恒定,尤其在负载变化、电池电压下降时。引入编码器反馈后,我们可以实现PID闭环控制,让电机转速更稳定。

这里给出一个极简的PID速度控制思路框架:

// 简化的PID结构体 typedef struct { float Kp, Ki, Kd; // PID参数 float integral; // 积分项 float prevError; // 上一次误差 long lastTime; // 上一次计算时间 } PIDController; PIDController pidLeft, pidRight; float computePID(PIDController *pid, float setpoint, float measured) { unsigned long now = millis(); float dt = (now - pid->lastTime) / 1000.0; // 转换为秒 if (dt <= 0) dt = 0.01; // 避免除零 float error = setpoint - measured; // 比例项 float proportional = pid->Kp * error; // 积分项 (抗积分饱和处理) pid->integral += error * dt; // 简单积分限幅 if (pid->integral > 100) pid->integral = 100; if (pid->integral < -100) pid->integral = -100; float integral = pid->Ki * pid->integral; // 微分项 float derivative = pid->Kd * (error - pid->prevError) / dt; pid->prevError = error; pid->lastTime = now; return proportional + integral + derivative; } void loopWithPID() { // 设定目标转速,例如 100 RPM float targetRPM = 100.0; // 获取当前左右电机转速 float speedLeft = getSpeedRPM(0); float speedRight = getSpeedRPM(1); // 计算PID输出(PWM调整量) float adjustLeft = computePID(&pidLeft, targetRPM, speedLeft); float adjustRight = computePID(&pidRight, targetRPM, speedRight); // 基础PWM值 + PID调整量,并限制在0-255范围 int basePWM = 150; int pwmLeft = constrain(basePWM + adjustLeft, 0, 255); int pwmRight = constrain(basePWM + adjustRight, 0, 255); // 应用新的PWM值 setMotor(0, pwmLeft, 1); setMotor(1, pwmRight, 1); delay(50); // PID计算周期 }

调参经验:PID参数(Kp, Ki, Kd)需要耐心调试。一般步骤是:

  1. 先调Kp:将Ki和Kd设为0。逐渐增大Kp,直到电机转速开始围绕目标值振荡,然后取这个值的50%-80%作为初步Kp。
  2. 再调Ki:加入Ki,用于消除静差(即最终稳定转速与目标转速的差值)。从小值开始慢慢增加,直到静差在可接受范围内。Ki太大会引起超调或振荡。
  3. 最后调Kd:Kd用于抑制振荡,使系统更稳定。如果系统响应已经很平滑,可以不加Kd或加一个很小的值。

5.3 进阶扩展思路

这辆“仰望小车2”是一个优秀的平台,可以扩展很多玩法:

  • 无线遥控:利用掌控板内置的蓝牙或Wi-Fi,开发手机APP或电脑上位机进行遥控,并实时回传传感器数据(如速度、角度)。
  • 巡线或避障:在车头加装红外巡线模块或超声波传感器,实现自动循迹或避障功能。
  • 姿态控制:利用掌控板自带的MPU6050(六轴陀螺仪加速度计),实现更稳定的直线行驶(通过PID调整左右轮速差来补偿偏航)。
  • 视觉处理:搭配一个简单的摄像头模块(如ESP32-CAM),虽然帧率有限,但可以尝试做颜色识别、物体跟踪等,让小车“看得见”。
  • 多车协同:如果有多辆小车,可以尝试通过无线模块进行简单的编队或通信。

回顾整个项目,从硬件选型、电路连接、代码编写到调试优化,每一步都充满了硬件开发的典型挑战和乐趣。用“盘古斧”这样的现代扩展板,确实让许多底层细节被封装起来,让我们能更专注于逻辑和控制算法的实现。这辆小车不仅仅是一个玩具,它涵盖了直流电机驱动、PWM控制、舵机伺服、编码器反馈、中断处理、甚至PID控制算法的入门知识,是一个非常好的综合实践平台。

最后,关于“致敬”的初衷,我想说,硬件开发的路很少是独自走完的。就像当年我们一群人围着一个小车调试,今天我把这些经验总结出来,也是希望能成为某个初学者或爱好者路上的一点微光。遇到问题别怕,查资料、问社区、动手试错,那个过程本身,就是最大的收获。这辆小车会一直放在我的工作台边,提醒我那份最初的好奇和与伙伴们一起创造的快乐。