
前阵子整理抽屉翻出两块吃灰的ESP32开发板。正好手头还有几套全向轮和N20电机就琢磨着干脆做一台能手机遥控的全向轮小车。这个项目做完以后我最大的感受是它把嵌入式里最常用的几块东西——WiFi通信、PWM电机控制、运动学解算——全串起来了难度不高但信息量很大。整台车不用装App不依赖路由器手机连上小车的WiFi热点打开浏览器就能控制前后左右横移旋转都能做属于典型的“低门槛、高延展”的入门机器人项目。如果你手里也有一块ESP32想玩点比“点灯”更有意思的东西又不想一上来就啃ROS、搞激光雷达那这个项目非常合适。它适合三类人刚学完Arduino基础想进阶的爱好者、要做毕设或课设又不想太卷的学生、以及想给小孩做个遥控玩具顺便自己练手的程序员。整个项目做完大概需要两三个晚上核心代码量不大但里面涉及的运动学换算、WiFi建站、电机驱动这些知识点每一个都能单独拉出来讲半天。1. 方案选型为什么是ESP32、全向轮和手机浏览器1.1 中配到底配在哪很多人的第一个小车项目是两轮差速底盘加蓝牙模块手机装个串口调试App发字符控制前进后退。这个方案便宜、简单但玩两天就腻了因为两轮底盘只能前进后退加转弯不能横移停车位置稍微偏一点就得反复倒车调整。我这次做的“中配”版本核心升级就两处底盘换成全向轮通信从蓝牙换成WiFi。全向轮带来的是三个自由度的运动能力前后、左右、原地旋转可以同时进行操控起来非常跟手WiFi带来的则是更灵活的控制方式手机浏览器直接控制不用装App也不怕蓝牙配对失效甚至允许电脑、平板同时连着操作。“中配”还体现在另一个地方不加编码器、不做PID闭环电机用开环PWM调速。这样省掉了编码器接口的焊接和PID参数调校把项目难度控制在“一个晚上能跑起来”的范围。但底盘结构、控制协议、代码框架都是按更高阶的方案留好接口的后续想加编码器、加MPU6050做姿态闭环不用推翻重来。1.2 为什么选ESP32而不是STM32、UNO或树莓派选主控这件事我几乎没有犹豫。ESP32在这类项目里几乎是“标准答案”原因很直接第一自带WiFi和蓝牙。两轮蓝牙小车如果用Arduino UNO还得外挂一个HC-05蓝牙模块多一组接线多一个故障点。ESP32直接把无线通信做在芯片里而且2.4GHz WiFi的传输距离和稳定性明显好于蓝牙手机在客厅另一头控制也没问题。第二双核240MHz的性能对这种实时控制场景来说非常富裕。它一边跑着Web服务器一边处理电机PWM输出完全没有压力。UNO的16MHz主频加2KB内存跑个像样点的网页都吃力更别说同时维护TCP连接了。第三Arduino生态成熟。ESP32可以用Arduino IDE开发网上例程一大把遇到问题搜一下基本都有答案。STM32性能也很强但配置引脚、搭建工程比ESP32繁琐不少对于这种小项目属于“杀鸡用牛刀”。树莓派就更不用说了成本高、启动慢拿来做这种微型小车既浪费又不嵌入式。至于为什么用手机浏览器控制而不是写App主要是图省事。做App要同时维护Android和iOS两个版本还要处理安装签名、权限申请纯属给自己找麻烦。把控制页面塞进ESP32的flash里手机连上热点后打开一个IP地址就能用跨平台问题直接消失。这个思路在智能家居设备里也很常见设备自带配网页用户不需要额外装任何东西。2. 运动学拆解三轮全向底盘的速度分配逻辑2.1 全向轮凭什么能横移普通轮子只能绕自己的轴滚动转向必须靠舵机或者差速。全向轮不一样它的外圈斜着安装了一排小辊子这些辊子可以自由转动相当于给轮子增加了一个侧向的自由度。当轮子正常滚动时小辊子与地面的接触会形成斜向的摩擦力在这个方向上轮子可以“滑”着走。用大白话讲普通轮子是“只能顺着一个方向滚”麦克纳姆轮是“能斜着滚”而全向轮是“能横着滚”。常见的全向轮底盘有两种布局一种是三个轮子呈120度均匀分布的三角底盘一种是四个轮子呈矩形布置的“X型”底盘。我这次选的是三个轮子的版本因为三轮天然自平衡不需要额外悬挂系统结构最简单还少一个电机和一个驱动模块成本更低。2.2 三轮底盘的运动学公式推导要让小车按你想要的轨迹走得先把手柄的“前后左右旋转”指令换算成三个轮子各自的速度。这一步是核心中的核心公式不复杂但一定得搞明白物理含义。先定坐标系以小车中心为原点车头方向为Y轴正方向车头右侧为X轴正方向。三个全向轮分别位于车头正前方、左后方、右后方互成120度。设底盘中心的目标速度为(vx, vy)单位为cm/s绕中心旋转的角速度为omega单位为rad/s。设底盘中心到每个轮子的距离为L单位为米。每个轮子的线速度由两部分叠加而成第一部分是底盘平移速度在该轮子朝向方向上的投影第二部分是旋转产生的切向速度L*omega。三个轮子的速度计算公式如下正前方轮子0号轮v0 vy L * omega左后方轮子1号轮v1 -0.866 * vx - 0.5 * vy L * omega右后方轮子2号轮v2 0.866 * vx - 0.5 * vy L * omega这里0.866就是sqrt(3)/2源于120度夹角的三角函数关系。如果只想前后动vx和omega设为0三个轮子速度相同小车直走如果想横移把vy设为0三个轮子配合出横向速度如果想原地旋转vx和vy都为0三个轮子以相同速度同方向转小车绕中心自转。这个公式看起来抽象但实际写到代码里就是四行乘法加法的事情。真正需要留意的是符号方向这取决于轮子安装角度和电机正极方向。我这次焊完接线发现1号轮方向反了直接把公式里的系数取反就解决了不用拆车。2.3 从速度到PWM的映射算出来的速度单位是cm/s但电机驱动板不认这个它只认PWM占空比和方向引脚。实际操作时把vx、vy的范围限制在-255到255之间omega也按比例映射到-255到255然后直接当作PWM值传给电机。这里有个很多新手会踩的坑N20减速电机存在死区PWM值低于30到50左右电机根本不转但一旦转起来转速和PWM又不是严格的线性关系。开环控制下不用想太多给个基础偏置量就能跑得挺好。等以后加了编码器做闭环PID再处理线性化不迟。我在代码里把速度映射和PWM输出单独写成函数控制逻辑只负责计算“每个轮子应该转多快”具体怎么输出交给底层函数处理。这个分层思路很大程度减少了调试时的头发损失。3. 硬件组装与接线从清单到通电的完整记录3.1 中配版硬件清单照着买就行这台的硬件成本大概在一百到两百元之间看你怎么买。我做了一个清单大部分零件淘宝都能一次买齐部件型号/规格数量备注主控ESP32 DevKit V138pin1带USB串口芯片全向轮60mm或65mm配联轴器3三轮布局电机N20减速电机6V1:50或1:303转速太快扭矩不够电机驱动TB6612FNG模块2一路驱动两个电机电池2S锂电池7.4V1000mAh1轻便够用降压模块MP1584或LM25961输出稳定6V供电机控制板供电AMS1117-3.3或直接用ESP32的VIN1留意压差底盘亚克力板或3D打印三角板1手头有洞洞板也行其他杜邦线、热缩管、自锁开关、铜柱、扎带若干走线用N20电机的减速比选型很重要。我第一台用的1:150扭矩大但转速慢横移起来像蜗牛。后来换成1:30速度上来了但起步抖动明显一点。综合下来1:50是比较舒服的平衡点适合室内平地上玩。3.2 电机驱动与ESP32的接线表TB6612FNG是一个非常好用的电机驱动芯片比老式L298N效率高体积也小。它内部是两个H桥所以一片正好驱动两个电机三个电机用两片。接线需要小心的是VM脚接电机电源VCC脚接逻辑电源两路电源一定不能搞混。我的接线分配是这样的TB6612引脚接ESP32/电源VM降压模块6V输出VCCESP32的3.3VGND电池负极共地PWMAGPIO 27AIN1GPIO 25AIN2GPIO 26PWMBGPIO 13BIN1GPIO 14BIN2GPIO 12第二片驱动同理PWMC接到GPIO 5CIN1、CIN2接GPIO 15和2。选这组引脚有几个原因它们都是支持PWM输出的引脚而且避开了ESP32上几个默认从flash启动时会被拉高的引脚减少上电时的误动作。GPIO 34到39是纯输入引脚不能用来输出PWM新手特别容易忽略这一点。接线时要把驱动模块的GND、ESP32的GND、电池负极三者连在一起这就是常说的“共地”。不共地会导致逻辑信号没有参考电平电机要么不动要么乱动这是排查问题时的第一个检查点。3.3 电源方案别让电机把主控拉死电源是整个项目里最容易出问题的地方。N20电机启动瞬间电流可以到1A以上如果直接把电池接给ESP32供电电压跌落会导致开发板反复重启现象就是小车一动就掉线非常憋屈。我的方案是2S锂电池7.4V先经过一个自锁开关分两路。一路给MP1584降压模块调成6V给两个TB6612的VM供电另一路进ESP32的VIN引脚通过板载稳压给芯片供电。虽然ESP32的VIN标称支持5V实际上接7.4V也能工作但发热明显所以我后来还是加了一个AMS1117-3.3的稳压模块单独给ESP32供电纹波更小。另外在电源输入端并联一个470uF/16V的电解电容实测能明显减少电机启动时的电压跌落。如果手头有1000uF的效果更好。这个电容体积不大直接焊在开关后面就行属于性价比极高的稳定措施。4. 软件实现ESP32建站 手机摇杆控制的完整代码4.1 开发环境与工程结构软件部分我用的Arduino IDE先在“开发板管理器”里搜索ESP32并安装esp32 by Espressif Systems然后选择“ESP32 Dev Module”开发板。如果遇到烧录时报错“A fatal error occurred: Failed to connect to ESP32”多数是USB驱动没装好或者没按住BOOT键稍后我会在调试部分细说。整个工程可以只用一个.ino文件但逻辑上要分成四块WiFi初始化、Web服务器路由、运动学解算、PWM输出。这个结构后续要加功能非常方便比如新加一个/status路由返回电池电压或者把控制方式改成WebSocket都只动局部代码。4.2 核心代码运动解算与控制回调下面是去掉注释后也能跑的完整框架我尽量写得直白。速度映射、方向指令解析和电机输出都包含在里面你可以直接抄也可以按自己接线改引脚。#include WiFi.h #include WebServer.h const char* ssid OmniBot; const char* password 12345678; // 电机引脚定义 const int pwmA 27, in1A 25, in2A 26; const int pwmB 13, in1B 14, in2B 12; const int pwmC 5, in1C 15, in2C 2; // 底盘中心到轮子距离单位米 const float L 0.12; // 速度缩放系数把-1..1映射到-255..255 const float SCALE 150.0; WebServer server(80); void setMotor(int pwmPin, int in1, int in2, int speed) { speed constrain(speed, -255, 255); if (speed 0) { digitalWrite(in1, HIGH); digitalWrite(in2, LOW); } else { digitalWrite(in1, LOW); digitalWrite(in2, HIGH); speed -speed; } analogWrite(pwmPin, speed); } void moveRobot(float vx, float vy, float omega) { // 三轮全向底盘运动学解算 int m0 (int)(vy * SCALE L * omega * SCALE); int m1 (int)((-0.866 * vx - 0.5 * vy) * SCALE L * omega * SCALE); int m2 (int)(( 0.866 * vx - 0.5 * vy) * SCALE L * omega * SCALE); setMotor(pwmA, in1A, in2A, m0); setMotor(pwmB, in1B, in2B, m1); setMotor(pwmC, in1C, in2C, m2); } void handleControl() { String dir server.arg(dir); float vx 0, vy 0, omega 0; if (dir forward) vy 1; else if (dir backward) vy -1; else if (dir left) vx -1; else if (dir right) vx 1; else if (dir forward_left) { vx -0.7; vy 0.7; } else if (dir forward_right) { vx 0.7; vy 0.7; } else if (dir backward_left) { vx -0.7; vy -0.7; } else if (dir backward_right){ vx 0.7; vy -0.7; } else if (dir rot_left) omega -1.5; else if (dir rot_right) omega 1.5; else if (dir stop) { vx 0; vy 0; omega 0; } moveRobot(vx, vy, omega); server.send(200, text/plain, OK); } void handleRoot() { String html Rrawliteral( !DOCTYPE html html head meta nameviewport contentwidthdevice-width, initial-scale1 style body { text-align:center; font-family: sans-serif; } .btn { display: inline-block; margin: 8px; width: 100px; height: 50px; font-size: 18px; border: none; border-radius: 8px; background: #4CAF50; color: white; } .btn:active { background: #2E7D32; } /style /head body h2OmniBot Control/h2 button classbtn onmousedownmove(forward) ontouchendmove(stop)前进/buttonbr button classbtn onmousedownmove(left)左移/button button classbtn onmousedownmove(stop)停止/button button classbtn onmousedownmove(right)右移/buttonbr button classbtn onmousedownmove(backward)后退/buttonbr button classbtn onmousedownmove(rot_left)左转/button button classbtn onmousedownmove(rot_right)右转/button script function move(dir) { fetch(/control?dir dir); } /script /body /html )rawliteral; server.send(200, text/html, html); } void setup() { Serial.begin(115200); pinMode(pwmA, OUTPUT); pinMode(in1A, OUTPUT); pinMode(in2A, OUTPUT); pinMode(pwmB, OUTPUT); pinMode(in1B, OUTPUT); pinMode(in2B, OUTPUT); pinMode(pwmC, OUTPUT); pinMode(in1C, OUTPUT); pinMode(in2C, OUTPUT); WiFi.softAP(ssid, password); // 固定APIP方便记忆 WiFi.softAPConfig(IPAddress(192,168,4,1), IPAddress(192,168,4,1), IPAddress(255,255,255,0)); server.on(/, handleRoot); server.on(/control, handleControl); server.begin(); Serial.println(WiFi AP started. Open http://192.168.4.1); } void loop() { server.handleClient(); }这套代码最关键的地方有两个。一个是handleControl里先用dir字符串判断用户指令再把指令转成vx、vy、omega三个运动学输入。另一个是moveRobot里那几行运动学解算直接套用了前面推导的公式把归一化的速度值映射成PWM输出。注意按钮事件我用了onmousedown和ontouchend的组合。PC上点击时按下就发指令、松开就停止手机触摸屏上onmousedown会带一点延迟但基本可用。如果你要做得更精细可以用touchstart和touchend分别注册这个后面再说。4.3 手机控制页面从按钮版到虚拟摇杆上面的HTML是一个按钮版控制台够用但手感一般。进阶做法是用虚拟摇杆把触摸点相对摇杆中心的偏移量连续发送给ESP32小车速度就能平滑过渡而不是像按钮那样只有“开”和“关”。虚拟摇杆的核心逻辑其实不复杂就是监听touch事件计算触点坐标和圆心坐标的差值除以半径得到-1到1的vx和vy然后通过WebSocket每50毫秒发一次数据。用HTTP GET也能做但每次请求都有握手开销连续高频发送会显得迟滞。这里我建议换成WebSocketESP32侧有WebSocketsServer库前端用原生WebSocket对象改动量不大但控制顺滑度会上一个台阶。按钮版和摇杆版不是二选一的关系。我实际做法是按钮版作为手动挡先跑通流程确认电机方向正确以后再上一版摇杆界面两套控制逻辑共用同一个moveRobot函数电机层面一行没改。4.4 进阶扩展OTA升级和状态反馈项目跑通以后我加了两个实用功能。第一个是OTA升级这样不用每次改代码都去插USB线。在Arduino环境装好ArduinoOTA库初始化时开启OTA服务之后就能通过WiFi上传固件。对于这种小车来说OTA几乎刚需因为底盘装好以后拆开插线真的太痛苦了。第二个是状态反馈页面在根路径的HTML里加一个显示电池电压或者WiFi信号强度的接口。ESP32有些开发板内部有霍尔传感器可以测电池电压没有的话也可以用分压电阻接一个ADC引脚。把读数做成/status接口返回JSON前端每秒拉一次小车快没电时手机会看到提示避免电机突然堵转。5. 调试实录常见问题与排查方法速查5.1 串口连不上、烧录报错这是我被问得最多的问题。烧录报错九成是三个原因开发板型号选错、串口端口选错、USB线只能充电不能传数据。ESP32市面上板子种类太多有些开发者用的是ESP32-S3或者C3选板的时候一定要对应上。我用的DevKit V1是经典38pin版选“ESP32 Dev Module”就行。如果USB线换了好几根都连不上大概率是线的问题很多便宜线只有正负极没有D和D-这种线充电凑合传数据完全不行。还有一种情况是板子进入了奇怪的模式按住板载BOOT键不放再点烧录等日志出现“Connecting…”时再松开。这能解决大部分“Failed to connect”错误。5.2 手机连不上WiFi或无法打开控制页ESP32开的AP热点和普通路由器不一样手机会提示“已连接但无法访问互联网”。这非常正常因为ESP32本身并没有外网它只是给小车和手机之间搭了一个局域网。很多人看到“无互联网”提示就以为失败了其实直接打开浏览器输入192.168.4.1就能进入控制页。如果完全搜不到ESP32的WiFi信号把代码里WiFi.softAP的调用放到setup最前面同时检查是不是上一次烧录的程序已经在运行占用了WiFi。我遇到过几次ESP32同时开启SoftAP和Station模式导致信号找不到的情况建议初始化时先WiFi.mode(WIFI_AP)再softAP能避免一些隐藏冲突。还有一个细节是SSID不要用中文部分手机对中文SSID兼容性不好而且网页加载也会出问题。5.3 电机不转、方向反了、越动越乱电机不转先看驱动板的电源灯亮不亮。如果亮但电机不动用串口打印每个引脚的PWM值排除代码逻辑问题。我的第一版代码就是在方向判断那里写错了一个变量名导致所有方向指令都发到了同一个电机。方向反了最简单把TB6612的IN1和IN2两根线对调就行或者直接在setMotor函数里把正负判断取反。不要小看这个问题三个轮子只要有任何一个方向不一致小车就会“画圈”而不是直走。三个轮子全接反了反而也能走但方向控制完全反过来怎么调都不对。如果是起步抖动大概率是PWM死区造成的。设定一个基础占空比阈值比如PWM绝对值低于40时直接给0或者高于40时补一个偏置小车起步会平顺很多。5.4 小车一启动就重启跑两步就掉线这个几乎都是电源问题。电机堵转或者急加速瞬间电流陡增电压跌落ESP32掉电重启WiFi断开手机上表现为“控制一下就没反应了”。排查顺序我建议这样先量电池空载电压低于6.5V就充电再量运行中的电压跌落用示波器或者万用表看看掉到多少然后检查降压模块有没有调到合理电压MP1584输出电压不是固定的需要用螺丝刀微调最后加上前面说的470uF电容基本能解决。另外代码里不要一上来就全速前进。可以在运动学函数里限制加速度比如每次控制指令更新时速度只允许在当前值基础上增加或减小一个固定步长。这既保护电源也让小车运动姿态更好看。5.5 控制延迟大按键反映迟钝延迟主要来源于HTTP请求的握手开销和页面资源加载。减少延迟的思路有三个第一把控制URL做得尽量短比如直接用/1、/2表示方向减少参数解析时间第二用WebSocket替代HTTP轮询长连接省去握手开销第三关闭网页上不必要的动画和图片ESP32自带的Web服务器性能有限一个几十KB的页面加载起来就需要一两秒。实测下来同样的网络环境HTTP点击方式延迟大概在100到200msWebSocket摇杆方式能压到50ms以内。如果只是按键控制100多毫秒的延迟其实感知不强但如果你想做那种连续转向的精细操控建议还是尽早切WebSocket。做完整台车以后我最大的收获不是“会做小车”了而是真正把课本上的运动学公式和硬件控制打通了。以前看全向轮运动学推导觉得挺玄乎直到自己算出某个方向需要哪个轮子加速、哪个轮子反转然后把小车放地上看到它真的按预想轨迹横移出去那种感觉还是很奇妙的。如果你也想做最后给你一个建议分步调试一步验证完再走下一步。先把接好线的电机挨个转起来再用手写固定速度值测试运动学是否一致最后才接手机控制页面全程最省事。