ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ESP32四驱小车实战:ROS2串口桥、Web控制与蓝牙遥控全解析

ESP32四驱小车实战:ROS2串口桥、Web控制与蓝牙遥控全解析

台上那块印着“ESP32-P4NRW32X”的板子,我盯了整整两分钟才想起来,这是我给第四个ESP32实战项目起的代号。晒到群里时,好几个人私信问我是不是乐鑫出了新型号,其实没有——P4是Project 4,NRW是Networked Robot Wheelbase,也就是“联网机器人轮式底盘”,32X指的是基于ESP32-WROOM-32做的扩展板。说白了,这就是一台用ESP32做底层主控的四驱小车,同时跑着ROS2 Humble串口桥接、内嵌Web控制页、经典蓝牙App遥控、DS18B20温度采集和OTA升级。写这篇文章是想把整个项目从命名逻辑、环境配置、引脚规划到通信协议完整复盘一遍,给正在折腾ESP32小车、ROS2串口桥、或者想搞TCP/IP内嵌Web页面的人提供一份能直接照着抄的工程笔记。

1. 代号背后的系统:这不是一块板子,是一套轮式机器人方案

先把这个代号拆开讲清楚,因为它直接决定了项目的整体架构和选型方向,而不是随手写个名字那么随意。

1.1 命名拆解:P4、NRW、32X分别指什么

P4代表这是我第四个ESP32项目。前三个分别是:ESP32-S3触摸屏环境监测仪、ESP32-C3低功耗门磁、基于ESP32的Wi-Fi音频播放器。到第四个项目时,我明显感觉到单纯做“一个功能”已经没意思了,真正有价值的是把一个机器人底盘该有的东西全部打通——运动控制、通信总线、远程调试、传感器融合。所以P4后面跟了NRW这个模块化定义,因为它不是单一功能板,而是一个底盘平台。NRW里最核心的词是Networked,这个项目的灵魂在于联网能力,不是跑得快,而是能被人通过网络控制。

32X则是两个含义的叠加:“32”就是ESP32-WROOM-32,“X”代表eXtension扩展。因为这块底板不只是一块开发板转接,我在上面做了电源管理、TB6612电机驱动接口、编码器接口、I2C接口扩展和DC-DC降压电路。拿到的是一块扩展了“轮式底盘专用外设”的ESP32系统。

1.2 为什么主控选了ESP32-WROOM-32而不是STM32或树莓派Pico

这是我被问得最多的问题。STM32F103确实便宜,裸机跑起来延迟低,但我要面对的场景是“底盘控制+Wi-Fi网页+蓝牙App+ROS2桥接”四件事并发。STM32如果要实现这些,外挂Wi-Fi模块(如ESP8266)后仍要处理两套固件联动,调试成本直接翻倍。树莓派Pico也很强,但Pico W的Wi-Fi性能和ESP32差距不大,外设生态却没ESP32丰富。

真正让我定下ESP32-WROOM-32的,是这颗芯片的“三栖能力”:双核240MHz Xtensa LX6主频能同时处理运动控制和网络协议栈;520KB SRAM扛得住WebServer+蓝牙+TCP三重资源占用;内置Wi-Fi和蓝牙Classic/LE双模,意味着我不需要任何外部通信芯片。更重要的是Arduino生态和PlatformIO生态都非常成熟,社群案例多到随手一搜就能找到参考,这对项目快速迭代帮助极大。

1.3 整体拓扑与数据流

整个系统分成三层:

  • 执行层:四路N20减速电机(带霍尔编码器),由两片TB6612FNG驱动。编码器A/B相信号接入ESP32的外部中断或PCNT外设做测速。
  • 控制层:ESP32-WROOM-32作为主控,处理三路通信(串口桥接到上位机、Wi-Fi Web页面、蓝牙App),同时输出PWM控制电机方向与速度。
  • 决策层:树莓派4B运行ROS2 Humble,通过USB-TTL串口与ESP32通信,订阅/cmd_vel话题后将线速度、角速度换算成左右轮速发下来;ESP32回传里程计和温度数据。

数据流是这样的:树莓派节点发串口帧0xAA 0x55...到ESP32,解析后LEDC输出PWM给TB6612;编码器中断记录脉冲数,ESP32内部PID/速度环算实际速度;需要上报时,把速度、温度、电压打成一帧发回树莓派。Wi-Fi Web页面在同一芯片上跑,AP模式供手机直连,STA模式供局域网访问。整套系统的关键就是让这些任务在同一颗芯片上稳定共存,这是后面几章要逐个拆解的难点。

2. 开发环境从零搭:Arduino IDE、离线包与国内镜像的取舍

我见过太多人卡在“装了Arduino IDE但看不到ESP32开发板”这一步,尤其是网络受限的环境。这一章给出我当时尝试的完整路径,包括最后实际采用的方案。

2.1 开发板管理器JSON地址的真实问题

Arduino IDE安装ESP32支持包的第一步是在“文件-首选项-附加开发板管理器网址”里填JSON地址。官方的地址是:

https://espressif.github.io/arduino-esp32/package_esp32_index.json

问题出在后续下载工具链时,这个JSON里指向的https://github.com/espressif/arduino-esp32/releases/download/...在部分地区速度不理想,甚至中途断开,导致整个安装失败。我也用过阿里云或国内开源镜像站的加速地址,实测体验比较依赖镜像的实时同步状态,有时镜像版本比官方慢半拍,装完后IDE会提示“版本不匹配”。所以我的建议是:优先直接下载离线包,而不是反复折腾JSON在线装。

2.2 离线包装法(适合网络不畅)

离线安装是我最终稳定的方案,操作并不复杂:

  1. 从espressif/arduino-esp32的GitHub Releases页面下载与Arduino IDE匹配的版本包,例如ESP32-2.0.17.zip,对应2.0.x系列可以在Arduino IDE 1.8.19和2.x版本下工作。
  2. 在Arduino IDE的草图本目录下手动创建hardware/espressif/esp32文件夹。
  3. 将下载的zip解压,把其中内容放入hardware/espressif/esp32目录,最终路径要保证esp32目录下直接放着cores、libraries、tools等文件夹。
  4. 首次打开IDE时会自动运行“获取工具”的步骤,此时仍可能尝试联网下载一些工具。如果这个步骤失败,可以通过GitHub Releases里的esp32-2.0.17附带工具包手动补齐,或换用PlatformIO Community版离线包管理工具。

提示:不同Arduino IDE大版本的目录结构稍微有差异,最稳妥的方式是提前确认一下hardware目录的完整路径再动手。别随手把文件夹放错层级,否则开发板列表里永远看不到ESP32选项。

我身边有同事直接用PlatformIO替代Arduino IDE来做这个项目,用的就是PlatformIO离线包方式。PlatformIO在VSCode里管理库更方便,pio run -t upload一行命令搞定编译烧录。但对快速调Web页面或临时改引脚来说,Arduino IDE的即时编译上传确实更直接。两个工具我都跑过一遍,结论是:如果只是玩单一类型项目,Arduino IDE加离线包足够;如果打算长期维护一个系列,直接上VSCode+PlatformIO,同时准备好离线依赖包。

2.3 Tools菜单里容易被忽略的烧录参数

选对开发板只是第一步。我在“工具”菜单里吃过两个亏:

  • Upload Speed:默认115200非常慢,即便代码只有几百KB,每次烧录也要等一分多钟。后来设置成921600,对ESP32-WROOM-32来说稳定,烧录速度快了七八倍。如果串口线质量一般,降到460800比较保险。
  • Partition Scheme:这个直接影响OTA升级和文件系统大小。默认的“Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)”适合需要大量SPIFFS存储的场景,但不适合OTA。如果要做网页OTA,建议使用“Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)”并发版,或干脆选“Huge APP (3MB NO OTA/1MB SPIFFS)”但那样就不能OTA了。我最终为了既保留Web页面又保留OTA,选了“Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)”,同时把编译输出的bin文件通过网页上传到另一分区完成升级。分区表的具体取舍放到第六章再展开。

3. 硬件底盘的引脚道道:TB6612驱动、编码器外部中断与GPIO禁忌

软件再花哨,硬件引脚规划错了就是反复跳坑。这一章我把自己在底板上绘制的引脚分配和踩过的GPIO坑全部摊开讲。

3.1 电源与电平匹配

底盘的电源树是很多新手最先翻车的地方。N20电机额定6V,TB6612FNG的VM引脚支持4.5V~13.5V,所以我用了两节18650串联(7.4V)作为主电源。7.4V直接进TB6612的VM,同时经过一个MP1584降压模块降到5V,再给到板载的AMS1117-3.3V稳压器产出3.3V逻辑电压。

关键点在于:ESP32的GPIO电平是3.3V,TB6612的逻辑电源VCC必须接3.3V,而不是5V。虽然很多TB6612模块自带稳压,但如果你把VCC接5V,AIN1等引脚的电平就可能超过ESP32 GPIO的耐压范围,长期运行有损伤风险。电机电源和逻辑电源的地必须单点共地,否则编码器信号飘得没法看。

3.2 LEDC PWM给电机驱动:频率、分辨率和通道分配

ESP32的LEDC硬件支持16个PWM通道,可以分配到任意支持输出的GPIO。但要注意,LEDC通道并不是都能输出到所有引脚,部分引脚在特定封装下有限制,所以规划时要查一下技术手册的IO矩阵表格。

我实际的PWM分配如下:四路电机需要两个PWM通道(一路PWM控制左右两个电机,通过IN1/IN2决定正反转),所以用LEDC通道0和通道1。通道0给左侧电机PWM,通道1给右侧电机PWM。TB6612的STBY引脚接3.3V拉高,保持芯片工作状态;AIN1、AIN2、BIN1、BIN2分别接普通GPIO控制方向。

PWM频率设置成20000Hz(20kHz),分辨率8位。选择20kHz是因为超过人耳听觉范围,电机不会发出尖锐的啸叫;同时TB6612的开关损耗在这个频率下可控。分辨率8位对应占空比0~255,对底盘速度控制来说足够细了。有些教程把频率设成1000Hz,你会发现电机低速时一顿一顿的,就是因为频率太低了。

3.3 编码器外部中断的正确姿势

N20电机自带的霍尔编码器输出A/B两相方波,我通过编码器测速做闭环。第一版代码用了attachInterrupt,直接在ISR里计数:

volatile long leftCount = 0; volatile long rightCount = 0; void IRAM_ATTR leftEncoderISR() { if (digitalRead(ENC_LEFT_B)) { leftCount++; } else { leftCount--; } } void setup() { pinMode(ENC_LEFT_A, INPUT_PULLUP); pinMode(ENC_LEFT_B, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(ENC_LEFT_A), leftEncoderISR, CHANGE); }

这段代码在低速时没毛病,但轮子转速拉高后,ISR里的digitalRead调用太频繁,CPU负载飙升,甚至干扰了WebServer响应。后来我把方式改成了ESP32自带的PCNT(脉冲计数)外设,用硬件计数替代外部中断:

pcnt_config_t pcntConfig = { .pulse_gpio_num = ENC_LEFT_A, .ctrl_gpio_num = ENC_LEFT_B, .lctrl_mode = PCNT_MODE_KEEP, .hctrl_mode = PCNT_MODE_REVERSE, .pos_mode = PCNT_COUNT_INC, .neg_mode = PCNT_COUNT_DEC, .counter_h_lim = 10000, .counter_l_lim = -10000, .unit = PCNT_UNIT_0, .channel = PCNT_CHANNEL_0, }; pcnt_unit_config(&pcntConfig);

PCNT的优势是完全不占CPU时间,直接在硬件层面计数,我只需要每隔一定周期读取计数值,然后清零即可。这也是为什么最后标题里能同时承担Web页面、蓝牙和ROS2串口通信——硬件占用CPU的资源被降到了最低。

3.4 完整引脚分配表与踩过的GPIO坑

这是我在这个项目里最终确定的引脚分配:

功能GPIO备注
左侧电机PWMGPIO18LEDC通道0
左侧电机AIN1GPIO19方向控制
左侧电机AIN2GPIO21方向控制
右侧电机PWMGPIO5LEDC通道1
右侧电机BIN1GPIO22方向控制
右侧电机BIN2GPIO23方向控制
左编码器A相GPIO34仅输入,外部上拉
左编码器B相GPIO35仅输入,外部上拉
右编码器A相GPIO36仅输入,外部上拉
右编码器B相GPIO39仅输入,外部上拉
DS18B20数据GPIO4OneWire总线,需4.7k上拉
I2C SCLGPIO16接温度/IMU等外部I2C设备
I2C SDAGPIO17接温度/IMU等外部I2C设备
串口TXGPIO1板载USB转串口
串口RXGPIO3板载USB转串口
UART2 TX(ROS)GPIO25接树莓派USB-TTL的RX
UART2 RX(ROS)GPIO26接树莓派USB-TTL的TX

踩坑经验里最值得说的是GPIO12、GPIO34到GPIO39那批。GPIO12(MTDI)在芯片上电时要求是高电平,如果外部接了大下拉电阻会导致无法正常启动,所以电机方向控制不要分配到GPIO12。GPIO34~39是纯输入引脚,不能输出PWM,我刚开始想把编码器A相接GPIO13做输入,后来发现不是不行,但GPIO13同时是ADC2通道,Wi-Fi开启时ADC2会失效,白白浪费一个模拟采样口。

另外,编码器A/B相建议外接10k上拉电阻。ESP32内部虽然有上拉,但编码器是开漏输出,内部上拉在长线传输时信号边沿不够陡,高速下会丢脉冲。加了外部上拉后,实测编码器计数稳定性明显提升。

4. 让小车“说话”:ROS2 Humble串口桥接、内嵌Web页面与蓝牙App

这一章是整个项目的精髓。底盘能跑是一回事,能被各种方式控制是另一回事。我需要让ESP32同时成为ROS2底盘节点、一个Web服务器、一个蓝牙外设。三者互不干扰才算成功。

4.1 ROS2串口桥接:帧协议设计 + 上位机节点

跟ROS2通信,第一反应是用micro-ROS,但micro-ROS在ESP32上需要配置额外的Agent,且固件体积和内存开销都不小。对于我这个项目,自认为固件里跑一套轻量级的串口协议更可控,所以采用了“帧协议 + 上位机Python节点”的方案。

帧格式设计成:

帧头 0xAA 0x55 | 数据长度 | 命令字 | 数据区 | 校验和

例如,上位机下发的速度指令:

AA 55 06 01 [左轮速度高8位] [左轮速度低8位] [右轮速度高8位] [右轮速度低8位] [校验和]

校验和取“长度+命令字+数据区所有字节”的低8位。ESP32端解析时先找AA 55,再读长度和命令字,计算校验和匹配后执行。不匹配就丢弃,等待下一帧。这样可以有效避免串口半包、粘包错位。

ESP32端收到命令字01后,把左右轮速度值写入两个全局变量,并通过LEDC更新PWM。同时ESP32每100ms主动上报一次状态帧:

AA 55 0B 10 [温度] [左轮速度Int16] [右轮速度Int16] [电池电压] [校验和]

树莓派端我用rclpy写了一个最小节点:

import rclpy from rclpy.node import Node from geometry_msgs.msg import Twist import serial class Esp32Bridge(Node): def __init__(self): super().__init__('esp32_bridge') self.ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.05) self.sub = self.create_subscription(Twist, '/cmd_vel', self.cmd_callback, 10) def cmd_callback(self, msg): linear = msg.linear.x angular = msg.angular.z wheel_base = 0.2 left = int((linear - angular * wheel_base / 2) * 100) right = int((linear + angular * wheel_base / 2) * 100) left_hi = (left >> 8) & 0xFF left_lo = left & 0xFF right_hi = (right >> 8) & 0xFF right_lo = right & 0xFF data = bytes([0xAA, 0x55, 0x06, 0x01, left_hi, left_lo, right_hi, right_lo]) checksum = (sum(data[2:])) & 0xFF self.ser.write(data + bytes([checksum]))

这段代码可以直接放到ROS2 Humble的工作包里跑,效果就是ros2 run teleop_twist_keyboard teleop_twist_keyboard.py之后,小车跟着键盘指令动。我强烈建议把波特率固定在115200,ESP32的UART2外设在这个波特率下丢包率极低,而更高波特率在长线模式下偶发乱码。

4.2 WebServer内嵌控制页,不依赖外网

内嵌Web页面是我在这个项目里最满意的功能之一。它解决了一个特别实在的痛点:现场调车时不想每次都在电脑上装串口终端,手机浏览器打开一个地址就能看当前速度、温度、电池电压,还能手动发PWM指令。

用Arduino的WebServer.h库实现非常直接:

#include <WiFi.h> #include <WebServer.h> WebServer server(80); void handleRoot() { String html = R"rawliteral( <!DOCTYPE html> <html> <body> <h2>ESP32-P4NRWX32 Control</h2> <p>Left PWM: <span id="lv">0</span></p> <p>Right PWM: <span id="rv">0</span></p> <input type="range" id="lpwm" min="-255" max="255" value="0"> <input type="range" id="rpwm" min="-255" max="255" value="0"> <button onclick="send()">Apply</button> <script> function send() { var l = document.getElementById('lpwm').value; var r = document.getElementById('rpwm').value; fetch('/set?l=' + l + '&r=' + r); } setInterval(() => { fetch('/state').then(r => r.json()).then(d => { document.getElementById('lv').innerText = d.left; document.getElementById('rv').innerText = d.right; }); }, 500); </script> </body> </html> )rawliteral"; server.send(200, "text/html", html); } void handleSet() { int l = server.arg("l").toInt(); int r = server.arg("r").toInt(); setMotorPwm(l, r); server.send(200, "text/plain", "OK"); } void setup() { WiFi.mode(WIFI_AP_STA); WiFi.softAP("P4NRW32X", "12345678"); // 同时尝试连接家里的路由器 WiFi.begin("MySSID", "MyPassword"); server.on("/", handleRoot); server.on("/set", handleSet); server.on("/state", handleState); server.begin(); }

我把ESP32配置成AP+STA双模式。AP模式的SSID是P4NRW32X,IP地址固定为192.168.4.1,手机断开家里Wi-Fi直连这个热点就能控制;STA模式则让小车接入局域网,电脑通过路由器分配的IP也能控制。两套入口并存,现场调试和实验室远程控制都不耽误。

要注意的是:Wi-Fi AP模式同时开着时,蓝牙Classic会不会有干扰?实测ESP32是双模蓝牙,理论上Wi-Fi和蓝牙共用2.4GHz频段,但只要不同时做大数据传输,AP模式下开BluetoothSerial基本可用,有一点延迟但不影响遥控。把Web页面的轮询间隔设在500ms以上,能明显减少干扰冲突。

4.3 蓝牙Class2与手机App联调

蓝牙部分我选了经典蓝牙SPP(Serial Port Profile),不是BLE。原因很简单:BLE的GATT服务配置流程对快速制作遥控器来说太繁琐,而经典蓝牙的BluetoothSerial库一行代码就能跑通:

#include "BluetoothSerial.h" BluetoothSerial SerialBT; void setup() { SerialBT.begin("P4NRW32X"); } void loop() { if (SerialBT.available()) { char c = SerialBT.read(); switch (c) { case 'F': setMotorPwm(160, 160); break; case 'B': setMotorPwm(-160, -160); break; case 'L': setMotorPwm(-120, 120); break; case 'R': setMotorPwm(120, -120); break; case 'S': setMotorPwm(0, 0); break; } } }

顺便回答一下很多人在查的问题:ESP32的经典蓝牙是Class 2吗?是的。ESP32的蓝牙BR/EDR发射功率标称通常在+0dBm左右,属于Class 2设备,通信距离大约10米。实测在桌面上无遮挡大概8~12米,这对手持遥控已经完全够用。

手机端我用的是市面上常见的“Serial Bluetooth Terminal”这类串口调试App,搜索到P4NRW32X配对后,发送单个字符F/B/L/R/S就能控制方向。痛点在于:这个App不支持自定义按键布局,操作起来像在打电报。后来我打算做一个简单的Android App封装按键,但发现BluetoothSerial配合任何串口终端已经能完成联调验证了,于是先把App这块放一放,核心功能先跑通比界面好看更重要。

4.4 三路通信同时存在的调度经验

单线程的Arduino主循环里,三路通信同时存在时最怕的就是互抢CPU。我的处理顺序是:

  • 串口接收(ROS2)放在最高优先级,因为上位机的速度指令不能丢,丢了小车会一顿一顿。
  • 蓝牙App指令次之,这类低速指令即使延迟50ms,人感知不明显。
  • Web页面轮询放最后,反正500ms才请求一次,不要求实时。

另外注意,不要在loop()里用delay()卡循环。我用一个lastTick变量做非阻塞定时:

unsigned long lastReport = 0; void loop() { if (millis() - lastReport >= 100) { reportStatusFrame(); lastReport = millis(); } handleSerialBT(); handleWebServer(); }

这样三路通信在同一个循环里各取所需,实测CPU占用率在Wi-Fi AP模式下大约40%,没有出现漂移或卡死。如果你跑的代码里有delay(1000),那其他两路通信必然被阻塞,这是新手最常见的问题。

5. 传感、休眠与I2C复位:底盘上的小数据也不容忽视

底盘不只有电机。温度、电压这些看似不起眼的传感器数据,在自动化场景(比如长时间巡检)里反而是设备健康的核心指标。这一章讲讲我在传感器集成和低功耗上踩过的坑。

5.1 DS18B20温度采集与OneWire总线细节

DS18B20是一款单总线式数字温度传感器,测量范围-55℃到+125℃,使用非常简单。常见的就是三根引脚(VCC、DQ、GND),DQ必须接一个4.7k欧姆上拉电阻到电源,因为单总线的开漏通信需要外部上拉。

我用的库是OneWire和DallasTemperature:

#include <OneWire.h> #include <DallasTemperature.h> #define ONE_WIRE_BUS 4 OneWire oneWire(ONE_WIRE_BUS); DallasTemperature sensors(&oneWire); void setup() { sensors.begin(); } void loop() { sensors.requestTemperatures(); float tempC = sensors.getTempCByIndex(0); // 将tempC填充到状态帧中 delay(1000); // 非阻塞定时更好,这里简化 }

DS18B20每次转换时间最长750ms(12位分辨率)。我不需要这么高的精度,把分辨率设成9位后,转换时间缩短到94ms,温度分辨率0.5℃,足以监测电池和驱动芯片有没有过热。如果发现读到85这个固定值,别慌,这是DS18B20上电后的默认寄存器值,通常代表没有成功转换,要么是引脚接错,要么是上拉电阻没接。

5.2 Deep Sleep唤醒后I2C外部设备卡死的复位方案

这个项目在后期做低功耗改造的时候,我让ESP32进入Deep Sleep,定时唤醒采集一次温度并通过Wi-Fi上报。然后遇到了一个百思不得其解的问题:每次唤醒后,I2C总线上的设备(外接的BME280传感器)第一次读取总是失败,返回0xFFFFFFFF,要复位重启才能恢复。

后来排查发现,这是I2C外设在总线电源被切断或设备进入异常状态后挂死了。ESP32从Deep Sleep唤醒后,外设的I2C总线状态没有彻底重新初始化,从机没收到正常的通信时序,就一直卡在某个状态。解决办法是做一个“总线复位函数”,利用I2C两根线的开漏特性,手动产生9个SCL脉冲,让卡死的从机复位:

void i2cBusReset() { pinMode(I2C_SCL, OUTPUT_OPEN_DRAIN); pinMode(I2C_SDA, OUTPUT_OPEN_DRAIN); digitalWrite(I2C_SCL, HIGH); digitalWrite(I2C_SDA, HIGH); for (int i = 0; i < 9; i++) { digitalWrite(I2C_SCL, LOW); delayMicroseconds(5); digitalWrite(I2C_SCL, HIGH); delayMicroseconds(5); } digitalWrite(I2C_SDA, LOW); delayMicroseconds(5); digitalWrite(I2C_SDA, HIGH); Wire.begin(I2C_SDA, I2C_SCL); }

在Wire.begin()之前调用这个函数,设备卡死概率大幅下降。还有一个更稳妥的方案是给I2C从机单独加一个由GPIO控制的MOSFET电源开关,唤醒后先断电再上电,从物理上强制复位。我在BME280供电线上串了一个由GPIO27控制的AO3401 MOSFET,一次搞定了所有卡死问题。

5.3 功耗实测数据

低功耗模式下的整机电流,我实际测过三档:

  • 正常全速运行:约360mA(Wi-Fi开启、四轮全速、蓝牙开启)。
  • 低频待机:约180mA(Wi-Fi连接但不传数据、电机停止)。
  • Deep Sleep:约65μA,仅RTC定时器在工作。

Deep Sleep唤醒一次完整流程(初始化传感器、读温度、Wi-Fi连接、发送数据)大概耗时2~4秒,平均功耗可以从65μA提升到几毫安级别。如果项目要做电池巡检,这个模式一小时唤醒几次,两节18650撑一星期是没问题的。

6. 烧录与OTA:三种升级路径的实战对比

固件迭代是所有嵌入式项目的常态。我实际用过的烧录和升级方式就三种:Arduino IDE直接烧、网页OTA远程更新、esptool刷裸BIN。三种路径各自适用场景不同,我都试过一遍,把心得写在这里。

6.1 常规USB一键烧录与boot按钮姿势

最基础的路径是USB数据线插到电脑,Arduino IDE选择正确的端口后点击上传。ESP32进入下载模式有两种办法:

  1. 通过RTS/DTR信号自动控制:开发板的USB转串口芯片会在IDE点击上传时自动拉低EN和IO0,无需手动干预。
  2. 手动模式:按住BOOT键不放,再点一下EN键复位,松开BOOT键。串口终端如果能看到Connecting........__这样的提示符,说明此时芯片已经处于下载模式。

手动模式虽然麻烦,但对一些CH340方案的劣质线缆来说是救命稻草,因为自动下载时序有时不稳定。如果烧录一直卡在Connecting...,先换一根好点的USB线,再检查CH340驱动是否正常,最后才考虑芯片是否真的进入下载模式。

6.2 网页OTA与ArduinoOTA双备份

远程升级是我的刚需,因为底盘装好后,线束凌乱,拆开重新插线烧录太麻烦了。我做了网页OTA和ArduinoOTA双通道。

ArduinoOTA是Arduino IDE自带的OTA库,配置简单:

#include <ArduinoOTA.h> void setup() { ArduinoOTA.begin(); } void loop() { ArduinoOTA.handle(); }

配好后,IDE的“端口”菜单里会出现以esp32-xxxxxx命名的网络端口,点击上传就自动走Wi-Fi烧录了。这里有一个必须处理的坑:ArduinoOTA要预留一个App分区。如果分区表选了“Huge APP (3MB NO OTA)”,那就没有OTA空间了,必须选双App分区方案,比如Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)虽然在OTA模式下格式有点特殊,但依然保留了两个APP槽。

网页OTA则是在WebServer里增加一个上传接口,通过HTTP POST方式上传新的固件bin文件。用Update.h库处理:

#include <Update.h> server.on("/update", HTTP_POST, []() { server.sendHeader("Connection", "close"); server.send(200, "text/plain", (Update.hasError()) ? "FAIL" : "OK"); ESP.restart(); }, []() { HTTPUpload& upload = server.upload(); if (upload.status == UPLOAD_FILE_START) { if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { Update.printError(Serial); } } else if (upload.status == UPLOAD_FILE_WRITE) { if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { Update.printError(Serial); } } else if (upload.status == UPLOAD_FILE_END) { if (Update.end(true)) { // 升级成功,交由回调处理重启 } } });

浏览器打开Web控制页,拖入编译好的.bin文件就能升级,整个过程大概十几秒。我平时固件版本迭代频繁,基本都是靠这个页面完成,只有第一次烧录引导程序时才用USB线。

提示:网页OTA升级前务必确认当前分区表和固件编译时的分区表一致。如果分区方案不匹配,轻则升级后无法启动,重则把另一个App分区也覆盖坏,就只能USB线重烧了。

6.3 用esptool刷裸BIN与救砖

用esptool.py刷裸BIN是另一种常用路径。Arduino IDE编译时开启“显示详细输出”,可以在输出日志里看到每个bin文件的路径。手动刷写命令大致是:

esptool.py --port COM3 --baud 921600 write_flash \ 0x1000 bootloader.bin \ 0x8000 partitions.bin \ 0xe000 boot_app0.bin \ 0x10000 firmware.bin

如果固件实在找不到了,也可以用完整的离线工具从编译目录里提取。救砖场景尤其推荐这个方案:ESP32的启动流程是先读bootloader.bin,再读分区表,最后加载firmware.bin。只要bootloader和分区表没坏,重新烧firmware就能恢复。如果两个都坏了,先用erase_flash清空整片Flash,再按上面顺序完整写入四个bin文件,基本都能救回来。

6.4 分区表选择与烧录速度的取舍

到了项目后期,分区表的选择直接影响功能边界。我的经验是:

分区方案App空间文件系统是否支持OTA适用场景
Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)1.2MB1.5MB SPIFFS是需要Web页面大资源 + OTA
Huge APP (3MB NO OTA/1MB SPIFFS)3MB1MB SPIFFS否固件很大但不需要OTA
Minimal SPIFFS (1.9MB APP/190KB SPIFFS)1.9MB190KB SPIFFS是固件中等 + 需要OTA,文件系统小

我最终选了第三档“Minimal SPIFFS (1.9MB APP/190KB SPIFFS)”。因为我内嵌的Web页面全部用R"rawliteral存进固件了,不需要额外SPIFFS存HTML文件,190KB文件系统足够存一些配置参数或里程计累计值。这样App空间大,OTA也有保障。

烧录速度方面,921600和115200的差异非常明显,但只要串口芯片和线缆质量合格,921600完全稳定。如果遇到A fatal error occurred: Failed to connect to ESP32,多半不是速度问题,而是板子没进入下载模式或线缆质量太差。这时降到460800再试一次,同时按一下板载EN按键重新触发下载时序。

最后分享一个整体项目调试的小技巧:把Web页面的/state接口同时返回当前左右轮PWM、编码器计数值、DS18B20温度、电池电压,这样我在调PID参数时,完全不需要串口线,浏览器里就能实时观察参数变化。这个习惯帮我节省了大量插拔USB线的时间,尤其当底盘被固定在测试台上的时候。P4NRW32X跑起来后,我最常干的其实是躺在沙发上,端着手机切到Web控制页,把小车从走廊一头开到另一头,再慢慢调转弯半径的PWM差——这种“代码和实体小车实时互动的爽感”,大概就是做嵌入式项目最上头的部分了。

返回列表