☰
基于ESP32的智能遥控小车:从硬件选型到PID闭环的完整实战指南
2026/10/14 19:00:37 网站建设 项目流程

简介:面向嵌入式开发初学者与物联网爱好者的ESP32智能遥控小车项目,以乐鑫ESP32为主控,整合Wi-Fi/蓝牙通信、电机驱动、传感器采集与手机端控制,覆盖从硬件选型、电路连接、固件编写到整机调试的完整流程。压缩包共19个文件,体积仅1.39MB,以16个Python源码为主,包含主控制程序、红外遥控、样例与引脚调整版本等;另有2张实物/接线参考图和1份Markdown项目说明,方便对照接线与理解代码结构。目前已有4816人学习下载。通过该资源可获取可运行的遥控小车固件、核心功能模块的拆分思路、引脚定义修改方法,以及超声波避障、加速度计/陀螺仪数据处理等常用传感器应用;同时也能看到无线通信协议在真实硬件上的落地实现,适合用于课程设计、竞赛练习或个人项目二次开发。

1. 基于ESP32的智能遥控小车:为什么说它是嵌入式入坑的最佳载体

很多想学嵌入式的人第一步都卡在“不知道做什么项目”。做纯点灯太无聊,做四轴飞行器成本又高,而基于ESP32的智能遥控小车恰好卡在一个舒服的中间位置:主板成本低、外设丰富、坏了换配件不心疼,而且能一次性把WiFi、蓝牙、PWM、中断、PID这些嵌入式主力知识点串起来。我拆过几台类似的底盘,也给人调过不少翻车现场,结论很直接——如果你只有一块ESP32开发板和一辆玩具车底盘,你离一台能手机遥控、能跑闭环速度的智能小车,只差几个关键的接线和两百行代码。这篇笔记就按“硬件选型 → 电机控制 → 遥控链路 → 踩坑记录 → 进阶改造”的逻辑,把整个落地过程压缩给你看。

2. 硬件选型:从H桥驱动到电源分配,动手前的四步检查

很多人买齐配件,接线一插就发现板子冒烟,问题几乎都出在没先做硬件层面的规划。ESP32本身是一块3.3V逻辑的芯片,而小车电机是6V到12V的直流电机,两者之间必须有驱动芯片做“功率放大”,同时电源部分也得把电池电压分开处理。这一章先把选型理由说透,再给你一套可照抄的接线方案。

2.1 电机驱动选型:为什么我推荐TB6612而不是L298N

驱动直流电机最常用的方案是H桥电路,本质上就是四个开关管控制电机两端电压极性,换向就能反转。市面上给DIY玩家用的驱动模块里,L298N和TB6612是出现频率最高的两块。

L298N最大的问题是内部使用双极型晶体管,饱和压降能达到2V左右。什么意思呢?你电池电压7.4V,经过L298N到电机两端就只剩5.4V左右,动力被白白吃掉一大截。而且L298N发热明显,模块体积大,插在ESP32小车上占空间。TB6612FNG使用的是MOSFET,导通阻抗低,压降一般只有0.3V左右,输出效率高得多,模块尺寸也只有指甲盖那么大,非常适合小车这类紧凑场景。

接线逻辑其实不难。TB6612模块上有两个通道,每个通道控制一个电机:

  • VM接电池正极(2S锂电,约7.4V)
  • VCC接逻辑电源(5V即可)
  • PWMA与AIN1、AIN2控制电机A的转速和方向
  • PWMB与BIN1、BIN2控制电机B
  • GND必须和ESP32共地
引脚功能接线目标说明
VM电机供电电池7.4V直接决定电机输出功率
VCC逻辑电平5V驱动芯片内部逻辑供电
PWMA / PWMBPWM调速输入ESP32任意LEDC通道高电平比例决定平均电压
AIN1 / AIN2方向控制两个GPIO一高一低正转,反之反转
AO1 / AO2电机A输出电机A两端没有极性要求,接反就反转

我一般会先用万用表量一遍模块各引脚对地阻值,确认没有短路再上电。这一步能省掉后面80%的排查时间。

2.2 引脚分配与PWM通道:先用表格定好GPIO再接线

ESP32不像Arduino UNO那样引脚随意用,它有自己一套规则,其中最容易坑人的是GPIO34到GPIO39这六个脚是纯输入脚,不能输出PWM。另一个是部分引脚(比如GPIO12、GPIO15)在上电时有特殊状态,可能导致电机在上电瞬间误动作,需要避开或外加下拉电阻。

我习惯把每个功能用的GPIO先用表格定下来,再动手接线。下面是一份经过测试的分配方案:

功能引脚说明
左电机方向AGPIO12AIN1,注意上电状态
左电机方向BGPIO13AIN2
左电机PWMGPIO14LEDC通道0
右电机方向AGPIO26BIN1
右电机方向BGPIO27BIN2
右电机PWMGPIO25LEDC通道1
左编码器A相GPIO34PCNT脉冲输入
左编码器B相GPIO35方向判断
右编码器A相GPIO32PCNT脉冲输入
右编码器B相GPIO33方向判断
I2C预留GPIO21 / GPIO22可接OLED或传感器

这里有两个关键点。GPIO12在部分开发板上电时是高电平状态,如果你直接接TB6612的方向脚,通电瞬间电机可能会抽动一下,解决办法是方向脚不怕这个抖动、等初始化后再写电平,或用10kΩ电阻下拉。另外,把LEDC通道固定下来后,后面所有代码都围绕这张表进行,改起来也方便。

编码器电机通常引出6根线,其中两根是电机电源线,四根是编码器信号线。编码器信号里A相输出脉冲,B相用于判断正反转。如果你不用闭环控制,编码器线可以不接,但如果要做速度闭环,这四根线必须在选型时就确认好。

2.3 电源分配:别让电机拉低电压把ESP32带崩

电源是整个小车最容易隐蔽翻车的环节。纯电池供电时,电机启动瞬间电流可能冲到1A以上,电池电压被瞬间拉低。ESP32内部虽然有稳压,但如果输入电压低于阈值,会触发欠压复位,表现就是小车一启动,ESP32就重启,串口反复打印乱码或brownout信息。

我常用的做法是双层供电:

  1. 2S锂电池(7.4V)直接给TB6612的VM供电,驱动电机。
  2. 从电池并联出一个5V降压模块(比如MP1584或AMS1117),单独给ESP32的5V引脚供电。

这样电机的大电流变化不会直接冲击ESP32的供电轨。如果不想再加稳压模块,也可以把5V降压模块直接焊在电池接口上,用热缩管包好。关键原则是:电机电源和逻辑电源从源头分开,只在GND处汇合。

顺带一个容易被忽略的点:降压模块和电池的正极之间最好串联一个自恢复保险丝或开关,我第一次做的时候没有加,有一次电机堵转直接把稳压模块烧了,从那以后所有样机都强制加装保护。

3. 电机控制:PWM调速与编码器速度闭环的落地代码

选好硬件之后,真正的核心代码就集中在电机控制上。ESP32的PWM输出用的是LEDC控制器,比Arduino UNO的analogWrite更灵活,但API的使用方式略有区别。这一章从最基础的PWM生成开始,一直写到能用的速度闭环,每一段代码都附参数说明。

3.1 用LEDC生成PWM:初始化、调速与调速函数封装

ESP32的LEDC支持16个通道,频率可独立配置,分辨率从1位到16位可调。对于直流电机来说,我一般选择1kHz到20kHz之间的频率。频率太低,电机转动时能听到明显的“哒哒哒”声;频率太高,MOSFET的开关损耗增加,驱动模块发热明显。实测下来,1kHz到10kHz是大部分人接受的区间。

先看初始化代码:

#include <driver/ledc.h> #define PWM_FREQ 1000 // PWM频率1kHz,人耳能感知轻微声音但可接受 #define PWM_RES 8 // 8位分辨率,占空比0~255 #define PWM_CH_L 0 // 左电机LEDC通道号 #define PWM_CH_R 1 // 右电机LEDC通道号 #define PWM_GPIO_L 14 // 左电机PWM引脚 #define PWM_GPIO_R 25 // 右电机PWM引脚 void pwmInit() { ledcSetup(PWM_CH_L, PWM_FREQ, PWM_RES); ledcSetup(PWM_CH_R, PWM_FREQ, PWM_RES); ledcAttachPin(PWM_GPIO_L, PWM_CH_L); ledcAttachPin(PWM_GPIO_R, PWM_CH_R); }

这段代码里,ledcSetup的第一个参数是通道号,第二个是PWM频率,第三个是分辨率位数。8位分辨率对应的占空比范围是0到255,够用了。分辨率越高,频率可选范围越小,因为底层计数器是同一个时钟源。ledcAttachPin把物理引脚绑定到通道上,之后对通道写占空比,对应引脚就会输出方波。

接下来封装一个完整的电机控制函数,同时处理方向和速度:

void motorControl(int pwmChannel, int pinA, int pinB, int speed) { // speed范围 -255 ~ 255,正数正转,负数反转 if (speed > 0) { digitalWrite(pinA, HIGH); digitalWrite(pinB, LOW); } else if (speed < 0) { digitalWrite(pinA, LOW); digitalWrite(pinB, HIGH); } else { digitalWrite(pinA, LOW); digitalWrite(pinB, LOW); } int duty = abs(speed); // 取绝对值作为占空比 if (duty > 255) duty = 255; // 限幅保护 ledcWrite(pwmChannel, duty); }

speed为0时,把两个方向脚都拉低,相当于让驱动模块输出高阻态,电机自由滑行而不是急刹。如果你希望停车时立刻刹住,可以把两根方向脚都拉高,形成制动效果。不同场景有不同选择,巡线小车建议用自由滑行,爬坡车建议用制动。

3.2 编码器接入:用PCNT外设读取脉冲而不是傻傻写中断

如果只是做遥控,PWM调速就够了。但如果你想实现速度闭环,就必须知道电机当前实际转速,这里靠的是编码器。最常见的霍尔编码器电机,内部有磁环和两个霍尔传感器,输出两路90度相位差的方波脉冲。电机轴每转一圈会输出固定数量的脉冲,减速齿轮后输出轴转一圈对应的脉冲数更多,一般是减速比乘以编码器PPR。

读取脉冲有两种思路。第一种是在GPIO上注册一个中断服务函数,每来一个上升沿就计数一次。这种写法直观,但在高转速下中断频繁触发,会占用大量CPU时间,甚至影响WiFi稳定。第二种是用ESP32自带的PCNT(脉冲计数器)外设,它由硬件完成计数,CPU完全不用干预,效率高出几个量级。工程上我强烈建议用PCNT。

PCNT初始化代码:

#include "driver/pcnt.h" #define PCNT_UNIT_L PCNT_UNIT_0 // 左电机计数器单元 #define PCNT_UNIT_R PCNT_UNIT_1 // 右电机计数器单元 #define PCNT_A_L 34 // 左编码器A相 #define PCNT_B_L 35 // 左编码器B相 void pcntInitLeft() { pcnt_config_t cfg = {}; cfg.pulse_gpio_num = PCNT_A_L; // 脉冲输入引脚 cfg.ctrl_gpio_num = PCNT_B_L; // 方向控制引脚 cfg.lctrl_mode = PCNT_MODE_REVERSE; // 低电平时计数方向取反 cfg.hctrl_mode = PCNT_MODE_KEEP; // 高电平时保持计数方向 cfg.pos_mode = PCNT_COUNT_INC; // 正沿加1 cfg.neg_mode = PCNT_COUNT_DEC; // 负沿减1 cfg.counter_h_lim = 32767; // 计数上限 cfg.counter_l_lim = -32767; // 计数下限 cfg.unit = PCNT_UNIT_L; cfg.channel = PCNT_CHANNEL_0; pcnt_unit_config(&cfg); pcnt_counter_clear(PCNT_UNIT_L); // 清零计数 }

这段配置里最重要的是lctrl_mode和hctrl_mode,它们决定了B相电平如何影响计数方向。标准正交编码器在电机正转时A相上升沿对应B相为高,反转时B相为低,所以设置成高电平保持、低电平反转。如果你的电机线序接反了,表现是计数方向反了,调整这两个模式即可,不用拆线。

读取当前计数值很简单:

int16_t leftCount = 0; pcnt_get_counter_value(PCNT_UNIT_L, &leftCount);

每次循环读一次,显式乘以符号方向即可获得相对位移。我通常在控制周期里定时读取,和上一次差值相减就是该周期内的脉冲数。

一段空白这里需要插入一个“小标题”作为过渡。如果你不打算做闭环,PCNT可以跳过;但做闭环的话,务必在电机通电前先手动转动电机轴,确认计数方向和实际旋转方向一致。

3.3 速度闭环:一个位置式PID就够,别一上来就整复杂模型

电机实际转速和PWM占空比并不是线性关系,因为电池电压波动、负载变化、摩擦力都会影响转速。要实现稳定的直线行驶或巡线,做速度闭环是必然选择。最常见的做法是PID控制:根据目标转速和当前转速的误差,调整PWM占空比输出。

代码量不大,用一个位置式PID即可:

typedef struct { float kp, ki, kd; float integral; float lastError; } PID_t; float pidUpdate(PID_t *pid, float target, float current, float dt) { float error = target - current; pid->integral += error * dt; pid->integral = constrain(pid->integral, -100, 100); // 积分限幅 float derivative = (error - pid->lastError) / dt; pid->lastError = error; return pid->kp * error + pid->ki * pid->integral + pid->kd * derivative; }

这个函数里,dt是控制周期,一般取10ms到50ms之间。积分限幅是必须的,不然当电机堵转时,积分项会无限累积,PWM输出瞬间拉满,恢复后产生剧烈超调。

实际控制循环写成:

void loop() { static unsigned long lastTime = 0; unsigned long now = millis(); if (now - lastTime >= 20) { // 50Hz控制频率 float dt = (now - lastTime) / 1000.0; lastTime = now; int16_t rawLeft = 0; pcnt_get_counter_value(PCNT_UNIT_L, &rawLeft); float rpmLeft = (rawLeft - lastLeftCount) / (PPR * REDUCTION_RATIO) / dt * 60.0; lastLeftCount = rawLeft; int pwmOut = (int)pidUpdate(&pidLeft, targetRPM, rpmLeft, dt); motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, pwmOut); } }

PPR是编码器每圈脉冲数,比如11;REDUCTION_RATIO是减速比,常见30:1,那么输出轴一圈是330个脉冲。这个公式每次中断周期都能算出当前RPM,从而让PID闭环生效。初调参数时,我会先把ki和kd设为0,只给kp一个较小的值,比如1.0,然后逐步加大,直到电机转速不震荡为止,再按固定流程整定积分项。

4. 遥控端实现:HTTP网页与经典蓝牙Serial两条路线

小车能动之后,遥控链路是第二步重点。ESP32同时支持WiFi和经典蓝牙。WiFi可以做网页遥控,界面直观,不依赖第三方App;而经典蓝牙适合用手机上的串口助手直接发指令,延迟低、调试方便。这一章分别给出两条路线的可行实现和适用场景。

4.1 HTTP网页遥控:手机浏览器直接操作,无需安装App

HTTP方案的核心是让ESP32自己变成一个小的Web Server,手机浏览器访问它的IP地址,页面上的按钮触发HTTP请求,ESP32收到请求后解析路径,执行对应的动作。

代码结构如下:

#include <WiFi.h> #include <WebServer.h> WebServer server(80); const char* ssid = "ESP32-Car"; // 热点名称 const char* password = "12345678"; // 热点密码,至少8位 void handleRoot() { String html = "<!DOCTYPE html><html><body>"; html += "<h2>ESP32小车遥控</h2>"; html += "<button ontouchstart=\"fetch('/cmd?dir=f')\" ontouchend=\"fetch('/cmd?dir=s')\">前进</button>"; html += "<button ontouchstart=\"fetch('/cmd?dir=l')\" ontouchend=\"fetch('/cmd?dir=s')\">左转</button>"; html += "<button ontouchstart=\"fetch('/cmd?dir=r')\" ontouchend=\"fetch('/cmd?dir=s')\">右转</button>"; html += "<button ontouchstart=\"fetch('/cmd?dir=b')\" ontouchend=\"fetch('/cmd?dir=s')\">后退</button>"; html += "</body></html>"; server.send(200, "text/html", html); } void handleCmd() { String dir = server.arg("dir"); if (dir == "f") { motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, 200); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, 200); } else if (dir == "l") { motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, 150); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, -150); } else if (dir == "r") { motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, -150); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, 150); } else if (dir == "b") { motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, -200); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, -200); } else if (dir == "s") { motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, 0); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, 0); } server.sendHeader("Access-Control-Allow-Origin", "*"); server.send(200, "text/plain", "ok"); } void setup() { WiFi.softAP(ssid, password); server.on("/", handleRoot); server.on("/cmd", handleCmd); server.begin(); } void loop() { server.handleClient(); }

这里有个细节:按钮用的是ontouchstart和ontouchend事件,而不是传统的onmousedown与onmouseup。原因很简单,在手机浏览器上触摸事件的响应更快,而且手指按住不动会持续触发,松开才发停车指令,操作手感更接近摇杆。用fetch发请求比用window.location跳转更合适,页面不会刷新,MPU和WiFi负载也更小。

server.on("/cmd", handleCmd)注册了路径处理器,在浏览器里每次请求都会调用handleCmd,通过server.arg("dir")解析参数。响应头加一条Access-Control-Allow-Origin: *是防止某些浏览器因为CORS策略拦截请求,虽然同源场景下不需要,但加上能省掉一部分兼容性排查时间。

4.2 蓝牙串口遥控:延迟更低,更适合做底层调试

蓝牙方案适合在开发阶段使用。ESP32的经典蓝牙支持SPP协议,可以直接被手机上的蓝牙串口App识别,连上之后就是一个透明串口,发送字符串即可控制小车。代码比HTTP端更简洁:

#include "BluetoothSerial.h" BluetoothSerial SerialBT; void setup() { Serial.begin(115200); SerialBT.begin("ESP32-Car"); // 蓝牙设备名 } void loop() { if (SerialBT.available()) { char cmd = SerialBT.read(); switch (cmd) { case 'w': motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, 200); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, 200); break; case 'a': motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, -150); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, 150); break; case 'd': motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, 150); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, -150); break; case 's': motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, -200); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, -200); break; case 'x': motorControl(PWM_CH_L, DIR_L_A, DIR_L_B, 0); motorControl(PWM_CH_R, DIR_R_A, DIR_R_B, 0); break; } } }

蓝牙指令的延迟通常在几十毫秒量级,比HTTP方案稳定不少,而且不用担心网页连接被自动释放的问题。调试PID参数的时候,我通常先用蓝牙方案配合串口助手发送指令,因为改参数和观察速度数据都更顺手。

需要额外注意的是,BluetoothSerial库依赖于ESP32的蓝牙协议栈。如果你在固件里同时启用了WiFi和经典蓝牙,在部分开发板上可能出现冲突,表现是WiFi连接后蓝牙无法被发现。解决办法是在初始化蓝牙之前先WiFi.mode(WIFI_MODE_STA)并完成连接,或者两者分时使用。实在不行可以用回调方式处理蓝牙接收,避免loop里反复读取造成蓝牙丢包。

4.3 通信协议:别只用单个字符,留好扩展余地

上面两个方案的指令都比较简单,每个字符代表一个动作。如果你后续要加入速度调节、灯光控制、舵机云台,单字符协议马上就不够用了。我建议从一开始就把协议设计成“命令:参数”的格式,比如SP:200表示设置速度200,LT:90表示左转90度。

void handleBluetoothCommand(String line) { int sep = line.indexOf(':'); if (sep < 0) return; String cmd = line.substring(0, sep); String val = line.substring(sep + 1); if (cmd == "SP") targetSpeed = val.toInt(); else if (cmd == "SV") targetSpeed = 0; }

这样做的好处是扩展指令时只需添加新的else if分支,而不用改动主循环。对于调试来说,还能在蓝牙端随时查看当前速度、编码器计数等状态,走一遍完整的“指令-反馈-确认”流程,后期上传感器时也方便。

5. 避坑与常见问题排查:五条实测踩坑记录

这一章写的都是我在实际测试中遇到过的问题,每条按“现象 → 原因 → 解决”的方式列出,应该能帮你省下不少排查时间。

5.1 通电瞬间电机猛抽一下,然后正常

  • 现象:插上电池的一瞬间,电机不受控制地转了一下,持续不到一秒钟。
  • 原因:方向控制引脚GPIO12在ESP32上电阶段处于高电平状态,而PWM引脚在初始化前默认也是高电平,TB6612两个通道同时有效,相当于给了电机一个瞬时驱动信号。
  • 解决:把方向引脚换到上电默认低电平的引脚,比如GPIO14和GPIO27;或者在TB6612的AIN1和AIN2引脚分别加上10kΩ下拉电阻,保证上电瞬间保持低电平。

5.2 电机一启动,ESP32立刻重启,串口乱码

  • 现象:按下前进按钮,电机还没转起来,ESP32就重启了,串口反复打印乱码。
  • 原因:电机启动瞬间电流大,电池电压跌落到ESP32稳压芯片的最小输入电压以下,触发了欠压复位。常见于电源直接共用同一路。
  • 解决:把锂电池正极接到TB6612的VM,同时用单独的5V降压模块给ESP32供电,GND共地。如果手头没有降压模块,可以把电池换成电压更高的4S锂电加降压,但体积和成本都上去了,不如直接加降压模块。

5.3 PWM占空比很低的时候电机不转,调高一点又猛冲

  • 现象:设置PWM占空比为30/255时电机完全不动,占到80/255时转速又很高,中间调节区很窄。
  • 原因:直流电机存在启动电压,占空比太低时平均电压低于电机的起始转动电压,电机不转;一旦超过启动电压,转速又快速上升。比例关系不是线性的。
  • 解决:外加控制时避开这个死区,把低速段映射到PID输出的非线性曲线上。最简单的方法是设定一个最小占空比阈值,比如低于50/255直接按0处理,高于200/255按255处理;进阶做法是用查表或分段线性映射,效果更好。

5.4 WiFi遥控时松手停车指令发送不及时,车子冲出好远

  • 现象:手机上松开按钮,小车并没有马上停下,而是滑行一小段距离才停。
  • 原因:松手触发ontouchend事件后,HTTP请求本身的延迟加上浏览器可能在保持TCP连接时有缓冲,导致停车指令晚了几十毫秒才被ESP32执行。小车速度一旦快起来,这几十毫秒就是几十厘米的误差。
  • 解决:降低控制周期,让ESP32内部有一个50ms级别的定时中断,定时检查当前位置和目标位置,如果距离太近就不等停车指令,主动刹停;或者直接用蓝牙方案替代HTTP,蓝牙指令的延迟通常只有5ms左右。

5.5 编码器计数方向反了,导致PID越控越飞

  • 现象:小车越走越偏,PID调节看起来完全反向,电机越调越快。
  • 原因:编码器B相接入顺序与预期相反,导致PCNT的计数方向和电机实际旋转方向不一致。误差信号被反向放大,系统进入正反馈。
  • 解决:把编码器B相和A相对调;或者不改接线,把PCNT配置里lctrl_mode和hctrl_mode对调,一样能修正。验证方法很简单:在PID闭合前先用手转动电机轮子,观察串口打印的计数值方向是否和实际转向一致。

6. 进阶:PID参数整定与里程计统计,两个用得上的实战技巧

到这里,小车的基本功能已经完整了。这一章给出两个立刻能用的进阶技巧,正好把前面的硬件和控制知识串起来。

6.1 PID参数整定的搜索顺序

很多人第一次调PID,上来就同时调三个参数,结果越调越乱。我的方法是三步走:先只调Kp,再添加Ki,最后加Kd。Kp从小往大加,观察编码器反馈的RPM波形,如果出现明显的周期性震荡,就说明Kp过大,回调到震荡前值的70%左右。接着加Ki,用来消除稳态误差,从0.01开始翻倍试探,一旦出现低频摆动就减半。Kd最后加,从0.1开始,用于抑制超调。

参数范围可以参考这张表,但必须结合你自己的机械结构修正:

参数起始值观察现象调整方向
Kp0.5转速跟不上目标,误差大每次增加0.5,直到出现震荡
Ki0.01目标附近稳态误差每次翻倍,直到低频摆动
Kd0.1启动和停止时的超调量增加0.1,直到响应变钝

记住一个原则:PID的最终目标不是让曲线完美,而是让小车在不同负载下都够稳。如果只是固定地面跑,P就够了;想复杂地形,再引入积分和微分。

6.2 里程计:把编码器脉冲换算成实际行驶距离

有了编码器,里程计几乎是白送的。每个控制周期里读取左右轮编码器计数差,结合减速比和轮径就能算出移动距离。公式是:

距离 = 脉冲数 / (PPR × 减速比) × 轮周长

比如编码器PPR=11,减速比30:1,轮子直径65mm,那么一个脉冲对应的距离约等于0.62mm。把左右轮的距离平均一下就是小车中心点位移。这个数据可以用来做直线校准,也可以和陀螺仪数据融合做定位。

float getDistance(int16_t pulseCount) { const float PULSE_PER_REV = 11.0 * 30.0; // 输出轴一圈脉冲数 const float WHEEL_CIRC = 3.14159 * 65.0; // 轮径65mm的周长 return (float)pulseCount / PULSE_PER_REV * WHEEL_CIRC; }

我当初做直线测试时发现一个问题:左右轮编码器读数总是不一致,因为两轮机械阻力不完全相同。后来加了一个纯比例校正项,让高速轮的PWM输出略低一些,十几米直线就基本不偏了。这个补偿值不需要很大,一般3到8个单位就够了,必须在实测中慢慢标定,没有通用值。

从那以后,我每做完一版小车,都会先做“供电检查 → 引脚表核对 → 编码器方向确认 → PID整定 → 直线测试”这五步流程,再考虑加传感器或做更复杂的控制,基本没有再遇到那种查一个下午楞找不到原因的玄学问题。希望这份踩坑后的经验,能让你少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询