
1. 项目概述与整体设计思路ESP32小车一直是DIY圈子里热度很高的项目原因很简单ESP32这颗芯片把WiFi、蓝牙、双核240MHz处理器、丰富外设全塞进了一块十几块钱的板子里天生就是做无线遥控小车的料。我最早接触智能车是用STM32NRF24L01的组合那时候光是调无线模块的SPI通信就折腾了三天后来换到ESP32直接在浏览器里开个网页就能遥控体验完全不在一个量级。这个项目我用的是最简单也最稳的方案一块ESP32开发板做主板两个TT马达加轮子一个L298N电机驱动模块一块18650电池供电手机浏览器打开网页实现无线遥控。整个流程走通之后你手里就有了一辆可以随时改造成循迹、避障、图传小车的底盘平台后续加摄像头、加传感器都很方便。这篇文章面向的是有一定基础但没做过完整小车项目的同学。我会把从硬件选型到代码烧录再到实车调试的全过程拆开讲包含完整可运行的代码以及我在实际调试中踩过的坑。有些坑网上很少有人说清楚比如供电不足导致ESP32反复重启、PWM频率选不对导致电机啸叫、GPIO引脚选错导致功能冲突这些我都会逐一说明。先说结论ESP32小车做起来并不难真正的难点在于“供电”和“引脚规划”这两个容易被忽略的细节。把这两件事处理好了剩下的就是纯粹的代码拼接工作。1.1 为什么选ESP32做主控市面上做小车常用的主控有三类Arduino Uno/Nano、STM32、ESP32。如果你只是做个玩具Arduino最简单如果是参加智能车竞赛需要跑算法STM32是主流但如果是“既要无线控制、又要代码好写、还想后续扩展物联网功能”ESP32是综合体验最好的选择。ESP32的优势主要体现在三个层面。第一板载WiFi和蓝牙省掉了外接无线模块的麻烦也不用像NRF24L01那样自己去封装通信协议直接走TCP/WebSocket就能把数据传到手机上。第二性能足够用双核240MHz主频跑电机控制的同时还能开个Web服务器完全不会卡顿。第三生态成熟Arduino框架下资料满天飞遇到问题基本一搜就有答案。有人可能会问ESP32-C3不是更便宜吗为什么不选它C3是单核160MHz虽然也能跑这个项目但引脚数量少而且没有WiFi天线一体化设计需要的匹配电路买模块还要自己焊天线。新手做小车老老实实用经典的ESP32 DevKit板子32个引脚随便造扩展性最好。1.2 项目功能拆解与技术架构这个小车最终要实现的功能清单如下手机浏览器打开网页实时控制小车前进、后退、左转、右转、停止支持速度调节而不是只有全速和停止两个档位小车状态连接状态、当前速度方向实时显示在网页上断电重启后自动连接上次的WiFi不需要重新配置对应的技术架构是ESP32内部搭建一个WiFi AP模式或STA模式下的Web服务器利用WebSocket实现浏览器与ESP32之间的双向实时通信。浏览器端通过虚拟方向键发送指令ESP32收到指令后通过LEDC PWM通道控制两个电机的转速和转向。为什么要用WebSocket而不是普通的HTTP请求因为HTTP是单向的、有连接的每次发送指令都要重新建立连接延迟高而且浪费资源。WebSocket建立一次连接后就可以持续双向推送数据实测下来指令延迟能做到50ms以内手感非常跟手。1.3 方案选型的取舍逻辑电机驱动模块我选了L298N而不是TB6612FNG理由很直接L298N虽然效率低、发热大但它耐造输入电压范围宽新手接错线不容易烧。TB6612FNG体积小效率高但引脚细、布局密焊错了板子就废了。等把整个流程走通之后再换成TB6612做减重优化也不迟。供电方案是很多人容易搞混的地方。ESP32的输入电压范围是5V-12V通过VIN引脚供电L298N的逻辑供电也是5V电机供电是6V-12V。我用两节18650串联7.4V给L298N的电机供电端输入再从L298N的5V输出口给ESP32供电。这样只需要一组电池就能给整个系统供电电路布线比单独给ESP32配电池简单得多。但这里有个坑L298N的5V输出是从电机供电端线性稳压出来的电机启动瞬间电流很大会导致5V电压跌落引起ESP32重启。我后面单独用一节3.7V锂电池给ESP32供电一劳永逸地解决了这个问题。这个坑具体怎么踩的我在第5节会详细讲。2. 硬件准备与组装要点硬件清单是这种DIY项目里最枯燥但最关键的环节。我的经验是宁可多买几个备份件也不要追求“刚刚好”。电机驱动模块这种低值易损件烧一个就等三天快递非常影响进度。2.1 元器件清单与选型说明我实际用到的物料如下序号元器件型号/规格数量参考价格选型理由1主控板ESP32 DevKitC V4ESP32-WROOM-321约20元引脚多WiFi稳定资料丰富2电机驱动L298N模块1约8元便宜耐造接线空间大3直流减速电机TT马达 1:48 带霍尔编码器版2约10元/个转速适中扭矩足够爬坡4车轮65mm橡胶轮配合TT马达2约3元/个抓地力好5万向轮1.5寸金属万向轮1约5元转向顺滑6车底盘亚克力3mm双层底盘1约15元刚性够方便钻孔7电池18650锂电池 2000mAh2约15元/节电压平台合适8电池盒18650双节串联带电量显示1约10元带电量显示方便判断剩余电量9降压模块MP1584可调降压模块1约5元给ESP32独立供电10杜邦线公对公、公对母各若干若干约5元接线用11面包板400孔1约3元调试用方便改线这里有几个选型细节值得展开说。TT马达选带编码器的版本而不是普通版价格只差两块钱但多出来的霍尔编码器可以后期做速度闭环控制PID调速。如果你第一次做先不接编码器也能正常跑但留了这个扩展接口后面想升级就不需要重买电机。万向轮一定要选金属珠款不要选那种塑料珠的。塑料珠在粗糙地面上滚动阻力大小车转向的时候容易卡死。金属珠款顺滑程度好很多代价只是贵两块钱。亚克力底盘买双层还是单层我建议双层。一层放电机和电池一层放电路板和主控层高差可以避免电池直接压在主控板上导致短路风险。而且双层底盘的螺丝孔位更标准后续加装超声波传感器、摄像头支架都方便。2.2 底盘组装与电机安装底盘组装这一步没什么技术含量但有几个细节会影响后续整车性能。第一两个驱动轮的间距要对称。装电机之前先把四个固定螺丝都拧上但别拧紧放平底盘调整两个电机的位置让两侧轮子到底盘边缘的距离一样再锁紧螺丝。如果左右不对称车子行驶时就会自动跑偏这个毛病后期靠代码调PWM补偿也能解决但能通过安装解决就不要用代码去补。第二TT马达的固定方向要注意。TT马达是圆柱体固定支架有两个螺丝孔。安装的时候保证两个马达的出线端朝后车尾方向还是朝前车头方向要统一。我自己喜欢把出线端朝后这样走线更整齐而且不容易在转向时被前轮干涉。第三万向轮的安装高度要匹配。装完两个驱动轮后量一下驱动轮底边到安装面的距离然后调节万向轮的固定孔位让车身处于水平状态。这里差一两毫米问题不大但差太多了会导致驱动轮打滑影响遥控的手感。电池盒的位置也有讲究。我在底盘上先找平衡点——把放好所有电子元件的车子用手托起来看哪个位置是重心——然后把电池盒固定在这个平衡点的正下方。这样小车加减速时不会出现抬头或者点头的现象跑起来稳得多。2.3 核心接线方案与GPIO规划接线是整个项目里最容易出问题的地方尤其是GPIO引脚的选择。ESP32的引脚不像Arduino那么“自由”有些引脚有特殊功能选错了会导致功能冲突或者烧录失败。我的最终接线方案如下信号ESP32引脚说明左电机PWM1GPIO13LEDC通道0左电机方向1GPIO14数字输出左电机方向2GPIO27数字输出右电机PWM2GPIO12LEDC通道1右电机方向1GPIO26数字输出右电机方向2GPIO25数字输出编码器左A相GPIO34仅输入引脚用于编码器编码器左B相GPIO35仅输入引脚编码器右A相GPIO36仅输入引脚编码器右B相GPIO39仅输入引脚关于引脚选择有两条铁律必须记住。第一ESP32的GPIO34、35、36、39是输入专用引脚没有输出能力不能接电机方向控制信号。很多人第一次画接线图时直接按照引脚编号顺序随便接结果编译上传都正常但就是控制不了电机查了半天才发现是引脚没有输出能力。这四只引脚最好的去处就是接编码器信号。第二GPIO12是“MTDI”引脚内部连接着Flash的IO电压控制逻辑。这个引脚如果在上电时被拉高ESP32会进入下载模式导致无法正常启动。所以我用GPIO12做右电机的PWM信号开始调试时发现车子一上电就不动后来才知道是因为GPIO12的电平状态影响了启动过程。解决方法是加一个下拉电阻但如果不想焊接电阻就直接换一个引脚。我在代码里最终把右电机PWM改到了GPIO23。电机驱动模块与电机的接线相对固定L298N的OUT1和OUT2接左电机OUT3和OUT4接右电机。ENA和ENB引脚是使能引脚用来控制PWM的必须接到ESP32的PWM输出引脚上不能直接接VCC跳过PWM控制否则速度调节功能就废了。IN1、IN2控制左电机方向IN3、IN4控制右电机方向接到ESP32的数字输出引脚即可。L298N的12V输入接电池正极GND接电池负极5V输出给ESP32供电如果采用独立供电方案则不用接。注意L298N的GND和ESP32的GND必须共地否则控制信号的电平参考点不一致会出现电机乱转或者不受控的情况。这是好多新手忽略的致命细节。3. 开发环境搭建与核心代码实现硬件组装完成之后进入软件环节。我用的是Arduino IDE加ESP32开发板支持包原因很简单生态成熟、例程多、调试方便。如果你有PlatformIO的基础也可以用PlatformIO体验会更好但对纯新手来说Arduino IDE更友好。3.1 Arduino IDE环境配置安装Arduino IDE后需要添加ESP32开发板支持。打开“文件-首选项-附加开发板管理器网址”填入官方的JSON地址。注意国内网络访问GitHub有时不稳定如果添加后下载失败可以用国内镜像源如乐鑫官网提供的镜像地址。添加完成后在“工具-开发板-开发板管理器”中搜索ESP32安装Espressif Systems提供的esp32 by Espressif Systems包。开发板选择的时候认准“ESP32 Dev Module”在“工具”菜单里把Flash Size选为4MB大多数ESP32开发板都是4MB FlashPartition Scheme选“Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)”。这个分区方案给程序存储留了1.2MB做小车完全够用同时预留了SPIFFS文件系统空间以后想放网页文件在Flash里也方便。端口选择要注意把ESP32用Micro USB线连接电脑后在“工具-端口”里选择对应的COM口。ESP32使用的是CP2102或CH340串口芯片如果设备管理器里看不到COM口说明你电脑缺驱动CH340芯片驱动的安装方法是去官网下载对应驱动CP2102同理。3.2 电机控制原理PWM与H桥在写代码之前有必要理解一下电机控制的底层原理。直流电机的转速与两端电压成正比电压越高转得越快。但数字接口只能输出高电平和低电平要模拟出“中间电压”就得靠PWM脉冲宽度调制——用高频率地开关电压信号通过改变占空比来等效输出电压。ESP32的LEDCLED Control外设就是用来生成PWM信号的。它的用法和Arduino的analogWrite类似但需要额外配置频率和分辨率。PWM频率决定电机的“听感”频率太低会听到明显的啸叫声频率太高会让电机驱动模块的开关损耗变大。我实测下来15kHz是TT马达的最佳工作频率——没有可闻噪音电机驱动也不发热。PWM分辨率决定了速度调节的细腻程度。我选择了8位分辨率0-255这个档位对遥控小车来说足够用了。如果你设置成10位0-1023控制会更细腻不过电机启动的线性区可能会让你感觉低速段不响应反而增加调参难度。H桥驱动电路是电机驱动模块的核心。它通过四个开关管组成H型电路同一时间只让对角线的两个开关管导通电流流向不同电机转向就不同。L298N模块在内部集成了这个H桥电路外部只需要通过IN1、IN2的电平组合来控制IN1电平IN2电平电机状态LOWHIGH正转HIGHLOW反转LOWLOW刹车短接HIGHHIGH停止高阻注意LOW/LOW是刹车而不是停止这时电机的两个端子在驱动模块内部被短接会产生强烈的刹车效果。如果遥控时按“停止”按钮车子急停就是因为在停止函数里给两个方向引脚都输出了LOW。如果需要平滑减速停止应该让PWM占空比逐渐降为0然后再置为高阻状态。基于此我定义了电机的三个状态函数代码逻辑如下。// 电机引脚定义右侧PWM已修正为GPIO23 #define LEFT_MOTOR_PWM 13 #define LEFT_MOTOR_IN1 14 #define LEFT_MOTOR_IN2 27 #define RIGHT_MOTOR_PWM 23 #define RIGHT_MOTOR_IN1 26 #define RIGHT_MOTOR_IN2 25 #define PWM_FREQ 15000 #define PWM_RESOLUTION 8 // LEDC通道分配 #define LEFT_PWM_CH 0 #define RIGHT_PWM_CH 1 void motorInit() { pinMode(LEFT_MOTOR_IN1, OUTPUT); pinMode(LEFT_MOTOR_IN2, OUTPUT); pinMode(RIGHT_MOTOR_IN1, OUTPUT); pinMode(RIGHT_MOTOR_IN2, OUTPUT); ledcSetup(LEFT_PWM_CH, PWM_FREQ, PWM_RESOLUTION); ledcSetup(RIGHT_PWM_CH, PWM_FREQ, PWM_RESOLUTION); ledcAttachPin(LEFT_MOTOR_PWM, LEFT_PWM_CH); ledcAttachPin(RIGHT_MOTOR_PWM, RIGHT_PWM_CH); stopCar(); } // 设置电机速度和方向的底层函数 void setMotor(int pwmChannel, int in1Pin, int in2Pin, int speed) { if (speed 255) speed 255; if (speed -255) speed -255; if (speed 0) { digitalWrite(in1Pin, LOW); digitalWrite(in2Pin, HIGH); ledcWrite(pwmChannel, speed); } else { digitalWrite(in1Pin, HIGH); digitalWrite(in2Pin, LOW); ledcWrite(pwmChannel, -speed); } } void setLeftMotor(int speed) { setMotor(LEFT_PWM_CH, LEFT_MOTOR_IN1, LEFT_MOTOR_IN2, speed); } void setRightMotor(int speed) { setMotor(RIGHT_PWM_CH, RIGHT_MOTOR_IN1, RIGHT_MOTOR_IN2, speed); } // 基础动作控制 void stopCar() { digitalWrite(LEFT_MOTOR_IN1, HIGH); digitalWrite(LEFT_MOTOR_IN2, HIGH); digitalWrite(RIGHT_MOTOR_IN1, HIGH); digitalWrite(RIGHT_MOTOR_IN2, HIGH); ledcWrite(LEFT_PWM_CH, 0); ledcWrite(RIGHT_PWM_CH, 0); } void forward(int speed) { setLeftMotor(speed); setRightMotor(speed); } void backward(int speed) { setLeftMotor(-speed); setRightMotor(-speed); } void turnLeft(int speed) { setLeftMotor(0); setRightMotor(speed); } void turnRight(int speed) { setLeftMotor(speed); setRightMotor(0); } void rotateLeft(int speed) { // 原地左旋 setLeftMotor(-speed); setRightMotor(speed); } void rotateRight(int speed) { // 原地右旋 setLeftMotor(speed); setRightMotor(-speed); }3.3 WiFi配网与WebSocket服务器ESP32的WiFi工作在STA模式连接路由器还是AP模式自己开热点我的建议是优先用STA模式。AP模式下ESP32自己当热点手机连接后不需要额外网络但问题是手机一旦连了ESP32热点就上不了外网而且ESP32作为AP的信号覆盖范围很小隔三五米就开始掉线。STA模式下ESP32连接家里路由器手机也在同一网络下访问ESP32的IP地址。信号稳定、距离远、手机仍然可以上外网体验好很多。WiFi连接的代码逻辑相当简单。我在代码里加了一个智能重连机制如果WiFi连接失败自动进入SoftAP模式作为兜底方案这样即使路由器没信号也能用手机直连小车控制。#include WiFi.h #include WebServer.h #include WebSocketsServer.h const char* ssid YOUR_WIFI_SSID; const char* password YOUR_WIFI_PASSWORD; const char* ap_ssid ESP32_Car; const char* ap_password 12345678; WebServer server(80); WebSocketsServer webSocket(81); bool wifiSTAConnected false; void setupWiFi() { WiFi.mode(WIFI_AP_STA); WiFi.begin(ssid, password); unsigned long startTime millis(); while (WiFi.status() ! WL_CONNECTED millis() - startTime 10000) { delay(500); Serial.print(.); } if (WiFi.status() WL_CONNECTED) { wifiSTAConnected true; Serial.println(); Serial.print(WiFi connected, IP address: ); Serial.println(WiFi.localIP()); } else { wifiSTAConnected false; WiFi.softAP(ap_ssid, ap_password); Serial.println(STA failed, AP mode started); Serial.print(AP IP address: ); Serial.println(WiFi.softAPIP()); } }WebSocket服务器的初始化也很简单void setupWebSocket() { webSocket.begin(); webSocket.onEvent(webSocketEvent); } void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { switch (type) { case WStype_CONNECTED: Serial.printf(Client %u connected\n, num); webSocket.sendTXT(num, connected); break; case WStype_DISCONNECTED: Serial.printf(Client %u disconnected\n, num); stopCar(); break; case WStype_TEXT: handleCommand((char*)payload); break; } }3.4 手机端网页遥控界面开发网页遥控界面我直接写在ESP32的代码里用字符串方式存储通过HTTP服务在浏览器中加载。这个方法的好处是不需要额外挂载文件系统一个文件搞定的代码结构简单明了。界面布局用一个四方向方向键控制运动中间放停止按钮底部加减速的滑动条。核心是WebSocket发送指令的JavaScript代码。网页的HTMLJS核心代码如下!DOCTYPE html html head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno titleESP32小车遥控器/title style body { background: #1a1a2e; color: #fff; font-family: Arial, sans-serif; text-align: center; margin: 0; padding: 20px; touch-action: none; } h2 { margin-bottom: 10px; } #status { padding: 8px 16px; border-radius: 20px; display: inline-block; margin-bottom: 20px; background: #556; } #status.online { background: #2e7d32; } #status.offline { background: #c62828; } .btn { width: 80px; height: 80px; border: none; border-radius: 12px; background: #4a4a6a; color: #fff; font-size: 28px; cursor: pointer; user-select: none; -webkit-user-select: none; transition: background 0.1s; touch-action: none; } .btn:active, .btn.pressed { background: #7c6bd6; } .dpad { display: grid; grid-template-columns: 80px 80px 80px; grid-template-rows: 80px 80px 80px; gap: 8px; justify-content: center; margin: 20px auto; } #btnUp { grid-column: 2; grid-row: 1; } #btnLeft { grid-column: 1; grid-row: 2; } #btnStop { grid-column: 2; grid-row: 2; background: #b71c1c; } #btnRight { grid-column: 3; grid-row: 2; } #btnDown { grid-column: 2; grid-row: 3; } #speedBar { margin: 20px auto; width: 250px; display: flex; align-items: center; gap: 10px; } #speedSlider { flex: 1; height: 30px; } #speedVal { width: 50px; font-size: 18px; } /style /head body h2ESP32 智能小车遥控器/h2 div idstatus classoffline未连接/div div classdpad button classbtn idbtnUp↑/button button classbtn idbtnLeft←/button button classbtn idbtnStop■/button button classbtn idbtnRight→/button button classbtn idbtnDown↓/button /div div idspeedBar span↓/span input typerange idspeedSlider min60 max255 value170 span↑/span /div div当前速度: span idspeedVal170/span/div script var ws; var reconnectTimer null; var currentSpeed 170; var holdInterval null; function connectWS() { ws new WebSocket(ws:// location.host :81); ws.onopen function() { var statusEl document.getElementById(status); statusEl.textContent 已连接; statusEl.className online; }; ws.onclose function() { var statusEl document.getElementById(status); statusEl.textContent 未连接; statusEl.className offline; reconnectTimer setTimeout(connectWS, 2000); }; ws.onerror function() { ws.close(); }; ws.onmessage function(e) { if (e.data connected) { document.getElementById(status).textContent 已连接; document.getElementById(status).className online; } }; } function sendCommand(cmd) { if (ws ws.readyState WebSocket.OPEN) { ws.send(cmd); } } function press(btnId, cmd) { var el document.getElementById(btnId); el.classList.add(pressed); sendCommand(cmd : currentSpeed); // 长按持续发送 clearInterval(holdInterval); holdInterval setInterval(function() { sendCommand(cmd : currentSpeed); }, 80); } function release() { clearInterval(holdInterval); document.querySelectorAll(.btn).forEach(function(b) { b.classList.remove(pressed); }); sendCommand(stop); } // 触摸和鼠标事件绑定 [btnUp, btnDown, btnLeft, btnRight].forEach(function(id) { var el document.getElementById(id); var cmd id btnUp ? F : id btnDown ? B : id btnLeft ? L : R; el.addEventListener(touchstart, function(e) { e.preventDefault(); press(id, cmd); }); el.addEventListener(touchend, function(e) { e.preventDefault(); release(); }); el.addEventListener(mousedown, function(e) { press(id, cmd); }); el.addEventListener(mouseup, release); el.addEventListener(mouseleave, release); }); // 停止按钮单独绑定不触发长按 document.getElementById(btnStop).addEventListener(touchstart, function(e) { e.preventDefault(); clearInterval(holdInterval); sendCommand(S); }); document.getElementById(btnStop).addEventListener(mousedown, function() { clearInterval(holdInterval); sendCommand(S); }); // 速度调节 var slider document.getElementById(speedSlider); var speedVal document.getElementById(speedVal); slider.addEventListener(input, function() { currentSpeed parseInt(this.value); speedVal.textContent currentSpeed; }); connectWS(); /script /body /html网页这块有几个交互细节值得说明。按钮事件同时绑定了touchstart和mousedown。如果只绑mousedown手机浏览器上点击会有约300ms的延迟手感极差。触摸事件里先调preventDefault()阻止默认行为否则长按按钮会触发手机的文字选择。长按持续发送指令是无线遥控手感的关键。如果不做长按重复每按一次按钮只发一条指令车子只会动一下停下来完全没法操控。我在按下时启动一个80ms间隔的定时器持续发送指令松手时清除定时器并发送停止指令这样就能实现按住前进一直走、松手马上停的效果。速度调节用滑块实时设置PWM占空比。我设置的范围是60到255为什么下限是60而不是0因为TT马达在PWM约50以下时驱动力矩不足以克服静摩擦力电机根本不会转。如果允许设到0从静止启动时按前进键车子半天不动很容易误判为代码故障。把最低速度限制在60能保证每一次按下方向键车子都会立即响应。3.5 完整控制逻辑接收指令与执行动作ESP32端的指令解析是控制逻辑的核心。接收到的指令格式为“F:170”字母代表动作类型冒号后是速度值。根据指令执行对应的电机控制。void handleCommand(String cmd) { cmd.trim(); char action cmd.charAt(0); int speedVal 170; int colonIndex cmd.indexOf(:); if (colonIndex ! -1) { speedVal cmd.substring(colonIndex 1).toInt(); } switch (action) { case F: forward(speedVal); break; case B: backward(speedVal); break; case L: turnLeft(speedVal); break; case R: turnRight(speedVal); break; case S: stopCar(); break; case X: // 原地左旋 rotateLeft(speedVal); break; case Y: // 原地右旋 rotateRight(speedVal); break; default: stopCar(); break; } }这里关于“原地旋转”多说一句。普通的前进、后退、转弯是差速控制的入门级别但如果你想让小车具备更灵活的操作性——比如在狭小空间里调头——最好实现原地旋转功能。原地旋转的逻辑是两个电机同速反向运转左电机正转右电机反转就是向右原地转反之向左。我网页界面上没有单独加原地旋转的按钮但保留了这个指令接口。实际使用中如果你操控熟练了可以用左转按钮加右转按钮的组合来模拟但手感不如直接加两个旋转按钮。后面升级版可以在界面上增加两个旋转按钮把控制维度从4方向扩展到6方向。4. 网页服务与遥控联调实测硬件、代码都就位后进入联调阶段。这个阶段的核心任务是确认每一个环节都通畅ESP32能连接WiFi、网页能正常加载、WebSocket能建立连接、按键指令能驱动电机执行预期动作。这个阶段也是问题最多的阶段我建议按照从简到繁的顺序逐项排查。4.1 网页服务器代码注册ESP32需要把网页代码注册到HTTP服务里客户访问根路径时返回网页。void setupWebServer() { server.on(/, HTTP_GET, []() { server.send(200, text/html, WEB_PAGE); }); server.begin(); }我在代码里加了额外的调试信息。访问根路径时在Serial端打印访问者的IP地址和时间方便确认到底是谁连上了小车。调试阶段的访问记录对于排查问题帮助很大——特别是当小区WiFi里有邻居连上了你的小车而你一脸懵不知道谁在遥控的时候。4.2 联调测试流程联调的第一步是串口监视器检查。打开Arduino IDE的串口监视器波特率设置为115200。ESP32启动后会打印WiFi连接日志和IP地址。如果看到“STA connected”和IP说明WiFi正常如果看到“AP mode started”说明STA模式连接失败自动进入热点模式了。第二步是浏览器访问小车。手机或者电脑连接到同一路由器在浏览器输入ESP32的IP地址。如果网页正常加载说明HTTP服务正常。确认手机和电脑能访问后打开浏览器控制台F12看有没有WebSocket连接报错。第三步是WebSocket指令测试。在浏览器控制台手动执行ws.send(F:200)观察小车是否有响应。如果小车不动检查电机接线或者用万用表量一下L298N的ENA/ENB引脚是否有PWM波形。4.3 实测数据与手感调优完成基础联调后我记录了多组实测数据来评估小车的性能。测试项目实测数据说明WiFi信号覆盖室内15米无遮挡使用路由器2.4GHz频段指令延迟约40-60msWebSocket长连接快于HTTP最长遥控距离25米隔一堵墙衰减明显取决于路由器的信号质量最高速度约0.8m/sPWM 255TT马达1:48减速比续航时间约1.5小时连续行驶2000mAh×2电池组满载电流约1.2A加速瞬间可达2A堵转时电流更大手感调优方面我踩了不少坑分享三个比较关键的调参经验。第一是速度档位的非线性问题。PWM 100和PWM 200的转速差不是两倍关系因为电机的机械损耗和电池电动势会让低速区间响应迟缓。实测发现TT马达在PWM 100以下几乎没转速100到170之间转速提升明显170到255区间又趋于平缓。针对这个特性我把网页滑块的默认值设为170让用户在常用区间获得比较细腻的控制。第二是左右电机的一致性。我测试的时候发现即使给两侧电机同样的PWM值小车也会轻微向右跑偏。用编码器测速后发现右电机比左电机转速高约5%。这个差异的原因一方面是电机个体差异另一方面是两个驱动轮与地面的摩擦力不完全一致。代码层面的解决方法是给右侧电机一个小的PWM偏移系数经过实测定为0.95效果最好。// 速度校正右电机实际输出 设定速度 * 0.95 void setRightMotor(int speed) { if (speed 0) { setMotor(RIGHT_PWM_CH, RIGHT_MOTOR_IN1, RIGHT_MOTOR_IN2, (int)(speed * 0.95)); } else { setMotor(RIGHT_PWM_CH, RIGHT_MOTOR_IN1, RIGHT_MOTOR_IN2, (int)(speed * 0.95)); } }第三是转向半径的调整。turnLeft和turnRight函数中我一开始设计的是转弯时一侧电机全速另一侧完全停止。这样转得快但运动轨迹不够平滑速度稍快就会明显甩尾。改成“差速转向”——即一侧电机全速另一侧保留30%转速——后小车的转弯曲线变得非常平滑操控质感直接提升一个档次。void turnLeft(int speed) { setLeftMotor((int)(speed * 0.3)); setRightMotor(speed); }4.4 手机触屏适配与体验优化网页遥控器在手机浏览器上的适配是最容易被忽视的环节。电脑上好好的页面一到手机上就各种问题点击没反应、画面缩放错乱、方向键按不准。第一个问题是viewport设置。没有加meta viewport标签的网页在手机上默认按980px宽度渲染页面看起来很迷你点击按钮需要手动放大。我在HTML头部加了一行标准视口设置并禁止用户缩放确保页面以1:1比例适配手机屏幕。第二个问题是触摸事件的300ms延迟。前文已经提到touchstart配合preventDefault能解决这个问题。但还有个细节有些浏览器对长按默认触发“文本选择”或者“图片预览”需要在CSS里设置user-select: none和-webkit-user-select: none。不加这两个属性的话玩着玩着屏幕上会弹出选择框极其烦人。第三个问题是方向键的尺寸和间距。80px的按钮尺寸在手机上实际体验偏小特别是在6.5寸屏以下的手机上。我调整到90px后手感明显改善。按钮间距8px还能防误触——如果按钮贴得太近按“前进”的时候手指边缘很容易同时触碰到“左转”。第四个问题是WebSocket的重连机制。真实使用场景中手机会锁屏、WiFi会切换WebSocket连接随时可能断开。如果JavaScript不做自动重连断开后必须刷新页面才能恢复。我在connectWS()函数里加了2秒定时重连逻辑同时在小车端也做了处理客户端断开后自动执行stopCar()防止小车在失去控制的状态下继续飞驰。5. 常见问题与避坑指南实录做ESP32小车这个项目我前前后后踩了不少坑有些问题网上资料很少花了不少时间去排查。下面按问题出现的频率和杀伤力排序把最值得注意的几个问题列出来每个问题都附上排查思路和最终解决方案。5.1 供电导致ESP32反复重启这个坑排第一因为它会直接让你怀疑人生代码烧录后打开电源还没按任何按键ESP32就开始不停重启。串口监视器里循环打印“boot”日志或者干脆完全没有输出。原因是我一开始采用单电池组供电方案两节18650串联的7.4V电压接到L298N的12V输入口L298N的5V输出口再接ESP32的VIN。理论上是可行的但实际运行时电机一启动瞬时电流达到1.5A以上L298N内部的线性稳压器在这样的大电流瞬变下输出的5V会出现明显的电压跌落。ESP32是精密器件工作电压低于4.8V就可能导致复位。排查方法是用万用表并联在L298N的5V输出端和ESP32的VIN端观察电机启动瞬间的电压变化。我实测到电压会跌到4.3V这足够让ESP32重启了。解决方案是给ESP32独立供电。我加了一块MP1584可调降压模块从电池组直接取电降到5V后单独给ESP32供电。这样ESP32的电源完全不受电机干扰一路稳定供电再没出现过随机重启的情况。同时保持电池组的负极与ESP32的GND共地确保信号参考点一致。5.2 GPIO12导致的启动异常第二个影响很大的坑是GPIO12MTDI引脚问题。我最初把右电机PWM信号挂在GPIO12上烧录完成后小车通电极不稳定——按道理应该正常启动并连接WiFi但测试时完全不响应串口也没有任何输出。查阅资料发现GPIO12在上电时的电平状态会影响ESP32的启动模式。如果GPIO12在上电时被外部电路拉高ESP32会进入下载模式而不是正常运行模式。我的电路里L298N的ENA引脚在没有PWM信号时会被内部上拉电阻拉高——好巧不巧这个高电平就通过杜邦线传到了GPIO12上直接干扰了芯片启动。排查这个问题的思路是先拔掉所有外设连线只保留电源线和USB烧录线看ESP32是否能正常启动。如果单独启动正常逐个接回外设每接一个就重启一次试试。接回右电机PWM线时出现问题基本定位到GPIO12。解决方案很简单把PWM信号换到GPIO23一条引脚定义修改就解决了。大家在选GPIO的时候尽量避开GPIO0BOOT按键占用了、GPIO1和GPIO3UART串口、GPIO6-11Flash通信引脚、GPIO12MTDI。我在硬件章节给的接线方案里已经避开了这些坑但如果你是从别的教程抄的接线一定要留意这一点。5.3 L298N接地不共地导致的失控这个问题属于接线错误但非常隐蔽。第一次接完后车子完全不受控制电机要么不动要么全速转根本没有中间状态。排查程序逻辑反复检查浪费了两个小时才发现是共地问题。原因是我采用了双电源供电方案后电池组的负极只和L298N的GND相连没有连到ESP32的GND。控制信号的参考点不一致ESP32输出的PWM高低电平相对于L298N的逻辑电路来说完全不是预期的电位关系驱动模块自然无法正确解析指令。排查方法是拿万用表量驱动模块GND和ESP32 GND之间的电压。正常应该是0V我实测到2.1V的电位差说明共地线没有接好。解决方式很简单用一根额外杜邦线把电池组负极、L298N GND、ESP32 GND三者串联接在一起。这是所有单片机控制外部设备的通用需求——控制信号必须有一个共同的参考地把这根线接好之后所有紊乱现象立刻消失。5.4 浏览器缓存导致的“程序没更新”错觉这个问题在调参阶段特别容易遇到。我修改了网页界面代码重新烧录后打开浏览器访问小车IP看到的还是旧版页面。反复刷新F5也是旧页面一度怀疑代码没有烧录进去。原因在于浏览器的缓存机制。ESP32返回的页面默认带上了cache-control头浏览器会缓存静态页面资源下次访问时直接使用缓存内容不会重新请求服务器。排查方法是查看浏览器网络面板F12-Network里的状态。如果看到“200 (from disk cache)”说明你用上的是本地缓存。解决办法两种一是添加一个随机参数绕过缓存比如访问“http://192.168.1.100/?v123”加个不同的数字强制刷新二是在ESP32代码里设置响应头禁用缓存。server.on(/, HTTP_GET, []() { server.sendHeader(Cache-Control, no-cache, no-store, must-revalidate); server.sendHeader(Pragma, no-cache); server.sendHeader(Expires, 0); server.send(200, text/html, WEB_PAGE); });加了三行响应头之后每次访问都是最新页面再也不用为缓存问题浪费生命。5.5 编码器脉冲干扰导致测速失灵做PID调速时需要用到编码器数据。TT马达编码器是霍尔传感器信号输出是漏极开路结构需要接上拉电阻才能在空闲时保持高电平。我第一次接编码器时直接四个引脚全接上了ESP32的输入引脚GPIO34、35、36、39结果串口读到的脉冲数完全是乱的转速为零时也在不停跳变。怀疑是硬件接线问题示波器一看编码器信号线上空闲电平是浮动的完全没有稳定的高电平。排查原理霍尔编码器的输出端是集电极开路/漏极开路结构只能把信号拉到低电平释放时需要外部上拉电阻把信号拉回高电平。TT马达模块上通常自带上拉电阻但我买的这个模块上上拉电阻是焊在PCB上的只是没有用跳线帽激活或者我接的引脚不一致。解决方式是在代码里启用ESP32的内部上拉电阻——Arduino框架中pinMode(pin, INPUT_PULLUP)一行代码就搞定了。开启后编码器信号稳定读到的脉冲数也符合正常范围。如果你后续做PID调速记得要启用这个上拉否则编码器数据永远都是乱码。5.6 常见问题速查表现象可能原因排查顺序ESP32 反复重启供电不足、GPIO12被拉高量电源电压、拔外设测试电机不动接线错误、PWM引脚无输出检查IN/EN引脚定义、量EN引脚电压电机不受控乱转未共地、IN引脚接触不良测量电位差、重新插紧杜邦线页面加载出来但控制无反应WebSocket未连接、代码端口不匹配看浏览器控制台、确认端口81小车往一边跑偏电机不平衡、轮子安装不对称调PWM偏移系数、检查机械安装行驶一段距离后失控电池电压降低、WiFi信号衰减测量电池电压、靠近路由器测试手机打开网页自动缩放缺少viewport标签检查HTML头部meta标签6. 项目总结与后续扩展建议这套ESP32小车做完之后其实已经不止是一辆玩具车了而是一个功能完整的移动机器人底盘平台。后面想扩展什么功能都基于这个底盘往上加就行。如果你想让小车自动行驶加一个超声波传感器HC-SR04或者红外避障传感器写一个简单的避障逻辑就能实现。如果想做循迹小车加一个灰度传感器模块沿着黑线跑的任务就能完成。想升级成图传遥控车配一个ESP32-CAM摄像头模块用同一个控制面板把视频流和遥控指令结合起来。代码层面的扩展方向我也简单提一下。WebSocket协议可以双向传输任意文本和二进制数据当前只用来传输遥控指令完全可以扩展为双向状态上报——比如把电池电压、电机转速、传感器数据实时推送到网页上显示。ESP32的BLE蓝牙功能也可以作为遥控通道但蓝牙配对、连接稳定性不如WiFi直连我只在备选方案里用了它。最后再分享一个实际使用中的小技巧这个小车不玩的时候建议把电池取下来单独存放别留在电池盒里。因为即使关机状态下L298N模块的待机电流仍然会缓慢消耗电池电量放一周可能就亏电了。另外车轮长时间压在一个位置会让橡胶变形影响后续行驶的平稳性。我习惯把小车倒扣着放让轮子悬空。如果你按着这篇文章的思路做完了自己的小车遇到什么奇怪的问题欢迎留言交流。做这种硬件项目最有意思的地方就在这里——同样一套方案每个人做出来遇到的问题都不太一样解决每一个问题的过程其实就是对底层原理理解加深的过程。