用ESP32做一台迷你WiFi机器人,核心目标只有两个:把摄像头采集到的画面通过局域网实时回传到浏览器,同时通过网页按钮控制小车前进、后退和左右转。这套方案不需要昂贵开发板,也没有复杂的Linux环境,却能在低成本、低功耗、小体积的前提下完成床底、桌底、空调夹层这类狭小空间的临时巡检。更重要的是,它把嵌入式联网、摄像头采集、Web控制、GPIO驱动这几块知识串成了一条完整的工程链路。
这篇文章会从方案原理、硬件选型、接线、开发环境、摄像头初始化、运动控制、烧录验证一直讲到问题排查。读完以后,你能得到一台可复现的ESP32 WiFi小车,也能理解为什么很多ESP32-CAM小车教程把重点放在GPIO分配和供电上。
1. 先理解方案:这套WiFi小车为什么能同时搞定视频和控制
很多第一次看到ESP32-CAM小车教程的人,第一反应是把树莓派或者旧手机搬出来。其实在“局域网实时视频回传”这个场景里,ESP32-CAM已经足够了,而且整个方案可以拆成两条非常清晰的链路:图像链路和控制链路。
1.1 图像链路:摄像头采集JPEG,通过WiFi推给浏览器
OV2640摄像头采集到的画面,经过ESP32内部ISP处理后直接输出JPEG数据。ESP32将这些JPEG帧通过WiFi以HTTP协议发送给浏览器。浏览器端不需要安装任何插件,只要打开页面,就能看到动态画面。
这里用到的是一种常见的流媒体方案:multipart/x-mixed-replace。浏览器请求一个URL后,服务器不停地返回多帧JPEG图片,每一帧之间用固定分隔符标出。前端不需要JavaScript去轮询拉图,img标签直接显示这个流。缺点是它没有音频,也不适合高动态场景,但用来给小车做实时画面回传足够。
1.2 为什么选ESP32-CAM而不是树莓派或旧手机
选型要看场景。如果目标是快速做一台能在房间角落里跑来跑去的巡查小车,ESP32-CAM的成本和体积优势非常明显。如果目标是做SLAM导航、深度学习识别,那ESP32完全不够用,得靠树莓派这类带Linux系统的设备。
| 方案 | 成本 | 体积 | 功耗 | 开发难度 | 适合场景 |
|---|---|---|---|---|---|
| ESP32-CAM | 低 | 很小 | 低 | 中等 | 局域网视频小车、环境监测、传感器终端 |
| 树莓派 | 高 | 大 | 高 | 较低 | 图像处理、SLAM、服务机器人原型 |
| 旧手机 + App | 中 | 大 | 高 | 较高 | 遥控类玩具、移动直播 |
ESP32-CAM的核心优势在于把ESP32处理器、WiFi蓝牙、摄像头接口、TF卡槽和PSRAM集成到了一块很小的板子上。只要接上电源和下载器,就能跑视频流。局限也很明显:没有Linux,不能装OpenCV,做不了太重的图像算法,GPIO也非常紧张。这些局限会在接线时严重影响方案设计。
1.3 视频信道和控制信道要分开考虑
同一块板子既要推视频流,又要接收控制命令。如果把视频流和按钮操作放在同一个逻辑里,很可能出现“一开视频,按钮就卡死”的情况。
推荐的做法是把HTTP服务拆成两个路径:/stream只做视频流,一直往浏览器推JPEG帧;/control只处理控制指令,命令到达后立即操作GPIO。两条路径互不阻塞,视频流是持续长连接,控制指令是短连接,浏览器在点击按钮时发出一个普通GET请求即可。
这段设计看起来简单,但它决定了小车是否“能用”。很多移植摄像头例程的教程只保留了画面,没有加入控制通道,原因就是没有把这两条链路分开设计。
2. 硬件选型与接线:先把GPIO冲突问题看清楚
ESP32-CAM硬件选型是整套项目里最容易被忽略的一步。买错模块、接错引脚,后面写再多代码都无济于事。尤其要注意:ESP32-CAM的可用GPIO非常少,摄像头已经占用了大部分引脚。
2.1 材料清单
以最常见的AI-Thinker ESP32-CAM底板为例,需要准备以下物料:
| 部件 | 推荐型号 | 说明 |
|---|---|---|
| 主控模块 | ESP32-CAM (AI-Thinker) | 必须选择带PSRAM版本 |
| 摄像头 | OV2640 | 通常与ESP32-CAM集成在同一块板上 |
| 电机驱动 | TB6612FNG | 比L298N体积小、压降低 |
| 电机 | N20减速电机或TT马达 | 2个,需要减速比合适 |
| 底盘 | 2WD/4WD小车底盘 | 根据电机选择 |
| 电池 | 3.7V锂电池或两节18650 | 必须给电机和主控稳定供电 |
| 稳压模块 | 5V/3.3V降压模块 | 为ESP32和摄像头供电 |
| USB转TTL | CP2102或CH340 | 电脑通过它给ESP32-CAM烧录 |
ESP32-CAM开发板本身没有USB口,电脑无法直接用USB线给它烧录。这里必须用一个USB转TTL模块,连接UART引脚。这个细节会卡住很多第一次接触ESP32-CAM的人。
2.2 摄像头占用的引脚要先列出来
AI-Thinker ESP32-CAM板载OV2640摄像头占用了一组固定引脚,这组引脚在代码里通过camera_config_t配置。典型映射如下:
#define PWDN_GPIO_NUM -1 #define RESET_GPIO_NUM -1 #define XCLK_GPIO_NUM 21 #define SIOD_GPIO_NUM 26 #define SIOC_GPIO_NUM 27 #define Y9_GPIO_NUM 36 #define Y8_GPIO_NUM 37 #define Y7_GPIO_NUM 38 #define Y6_GPIO_NUM 39 #define Y5_GPIO_NUM 35 #define Y4_GPIO_NUM 34 #define Y3_GPIO_NUM 5 #define Y2_GPIO_NUM 4 #define VSYNC_GPIO_NUM 25 #define HREF_GPIO_NUM 23 #define PCLK_GPIO_NUM 22可以看到,GPIO 4、5、21、22、23、25、26、27、34、35、36、37、38、39都已经被摄像头占用。GPIO 16、17通常被PSRAM或Flash占用。剩余可自由使用的引脚很少,实际项目中常用的是GPIO 12、13、14、15和GPIO 2。
注意:不要用GPIO 4或GPIO 16去接电机控制线。GPIO 4是摄像头数据线,GPIO 16在带PSRAM的模块上已经被占用了,接到电机后轻则画面花屏,重则模块无法启动。
2.3 电机接线:四路GPIO驱动两路电机,先保证能跑起来
ESP32-CAM剩余的自由GPIO正好可以满足两路电机的方向控制。以TB6612FNG为例,接线如下:
| 电机驱动引脚 | 连接ESP32-CAM GPIO | 作用 |
|---|---|---|
| AIN1 | GPIO 14 | 左电机方向1 |
| AIN2 | GPIO 15 | 左电机方向2 |
| BIN1 | GPIO 13 | 右电机方向1 |
| BIN2 | GPIO 2 | 右电机方向2 |
| VM | 电池正极 | 电机电源(5-6V) |
| VCC | 3.3V | 逻辑电源 |
| GND | GND | 与主控共地 |
这四路GPIO只负责控制正反转,不控制速度。对一台巡检小车来说,固定速度已经可以完成大部分操作。如果一定要调速,需要使用支持PWM的电机驱动板,但ESP32-CAM的可用GPIO不够分配PWM脚,必须要换引出更多IO的板子,或者改成串口控制的驱动模块。
供电方面,强烈建议电机和ESP32分开供电,再共地。电机启动瞬间电流很大,如果和主控共用同一个稳压器,容易造成电压跌落,表现为摄像头初始化失败或小车重启。推荐使用两个独立电源,或者使用能承受大电流的稳压方案。
电池正极 -> TB6612 VM 电池负极 -> TB6612 GND ESP32-CAM 5V -> 单独5V稳压输出 ESP32-CAM GND -> TB6612 GND (共地)3. 开发环境准备:Arduino IDE 装好开发板包和库
环境准备阶段最常见的错误是按默认配置直接编译,最后出现分区不够、摄像头初始化失败、烧录超时等问题。这些问题大部分都可以在Arduino IDE里提前规避。
3.1 安装ESP32开发板包
打开Arduino IDE,进入“文件 -> 首选项 -> 附加开发板管理器网址”,填入:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json然后打开“工具 -> 开发板 -> 开发板管理器”,搜索esp32,选择由 Espressif Systems 提供的包进行安装。
如果安装过程出现类似failed to install platform: 'esp32:3.3.11'的日志,通常是在下载离线压缩包时网络超时。可以在网络空闲时段重试,或者根据报错信息手动下载对应的离线包并放置到Arduino的staging目录。不需要反复卸载重装。
3.2 板卡、烧录参数、分区表一次性设置对
开发板包安装完成后,在“开发板”菜单中选择AI Thinker ESP32-CAM。然后确认以下参数:
| 配置项 | 推荐值 | 原因 |
|---|---|---|
| Flash Mode | QIO | 默认模式,读写速度更快 |
| Flash Size | 4MB | ESP32-CAM通常为4MB Flash |
| Partition Scheme | Huge APP (3MB No OTA/1MB SPIFFS) | 摄像头示例编译后体积较大 |
| Upload Speed | 115200 | 使用CH340等模块时更稳定 |
| Core Debug Level | 根据需要 | 调试时选Info或Debug |
分区表选错会导致编译成功但上传后无法启动,或者启动后反复重启。esp32-camera的示例代码编译后往往超过默认分区大小,所以需要把应用分区扩到最大,牺牲OTA分区来换稳定性。
3.3 确认摄像头库可用
Arduino IDE的库管理器里搜索esp32-camera可以找到封装好的库,也可以从 Espressif 的GitHub仓库克隆arduino-esp32下的examples。实际开发中,最省事的做法是直接把官方CameraWebServer示例复制一份,在里面删减人脸识别代码,保留摄像头初始化和HTTP服务框架。
一个容易混淆的点是:ESP32开发板包自带的示例里已经包含了摄像头驱动文件,所以不需要额外复制esp32-camera库到项目目录。编译时如果找不到esp_camera.h,优先检查开发板包是否安装完整,而不是急着下载第三方库。
4. 最小可运行:先把摄像头JPEG视频流跑通
在加入小车运动之前,先把视频流跑通。这一步能确认摄像头、PSRAM、WiFi、HTTP服务是否正常,后续所有问题排查都会基于这条链路。
4.1 最小工程结构
一个可以跑通视频流的最小工程只需要三个部分:WiFi连接、摄像头初始化、HTTP服务器。下面这个工程可以作为一个基础模板。
#include "esp_camera.h" #include <WiFi.h> #include "esp_http_server.h" const char* ssid = "your_wifi_ssid"; const char* password = "your_wifi_password"; #define PWDN_GPIO_NUM -1 #define RESET_GPIO_NUM -1 #define XCLK_GPIO_NUM 21 #define SIOD_GPIO_NUM 26 #define SIOC_GPIO_NUM 27 #define Y9_GPIO_NUM 36 #define Y8_GPIO_NUM 37 #define Y7_GPIO_NUM 38 #define Y6_GPIO_NUM 39 #define Y5_GPIO_NUM 35 #define Y4_GPIO_NUM 34 #define Y3_GPIO_NUM 5 #define Y2_GPIO_NUM 4 #define VSYNC_GPIO_NUM 25 #define HREF_GPIO_NUM 23 #define PCLK_GPIO_NUM 22 static esp_err_t stream_handler(httpd_req_t *req); static esp_err_t index_handler(httpd_req_t *req);4.2 初始化摄像头:引脚映射不能错
摄像头初始化是视频流项目中最容易出错的地方。camera_config_t结构体里的所有引脚必须和实际硬件匹配,否则esp_camera_init()会返回错误码。
bool camera_init() { camera_config_t config; config.ledc_channel = LEDC_CHANNEL_0; config.ledc_timer = LEDC_TIMER_0; config.pin_d0 = Y2_GPIO_NUM; config.pin_d1 = Y3_GPIO_NUM; config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM; config.pin_d4 = Y6_GPIO_NUM; config.pin_d5 = Y7_GPIO_NUM; config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM; config.pin_xclk = XCLK_GPIO_NUM; config.pin_pclk = PCLK_GPIO_NUM; config.pin_vsync = VSYNC_GPIO_NUM; config.pin_href = HREF_GPIO_NUM; config.pin_sscb_sda = SIOD_GPIO_NUM; config.pin_sscb_scl = SIOC_GPIO_NUM; config.pin_pwdn = PWDN_GPIO_NUM; config.pin_reset = RESET_GPIO_NUM; config.xclk_freq_hz = 20000000; config.pixel_format = PIXFORMAT_JPEG; config.frame_size = FRAMESIZE_VGA; config.jpeg_quality = 12; config.fb_count = 2; esp_err_t err = esp_camera_init(&config); if (err != ESP_OK) { Serial.printf("Camera init failed with error 0x%x", err); return false; } return true; }pixel_format选择PIXFORMAT_JPEG是因为后续要直接推送JPEG帧,服务器不需要做格式转换。fb_count设置为2表示使用双帧缓冲,摄像头在输出当前帧时,另一帧可以被HTTP服务读取,能减少画面撕裂。xclk_freq_hz是摄像头时钟频率,20MHz是常用值。
PSRAM在初始化过程中起着关键作用。JPEG帧缓冲需要较大内存,ESP32内部只有几百KB RAM,如果模块不带PSRAM或PSRAM未启用,esp_camera_init()很容易失败。这也是购买时必须选择带PSRAM版本的原因。
4.3 用HTTP multipart推送视频帧
视频流使用HTTP长连接,每取到一帧摄像头数据就立即发送给浏览器。
static esp_err_t stream_handler(httpd_req_t *req) { camera_fb_t *fb = NULL; esp_err_t res = ESP_OK; res = httpd_resp_set_type(req, "multipart/x-mixed-replace; boundary=frame"); while (res == ESP_OK) { fb = esp_camera_fb_get(); if (!fb) { res = ESP_FAIL; break; } char part[64]; snprintf(part, sizeof(part), "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", fb->len); res = httpd_resp_send_chunk(req, part, strlen(part)); res = httpd_resp_send_chunk(req, (const char*)fb->buf, fb->len); res = httpd_resp_send_chunk(req, "\r\n", 2); esp_camera_fb_return(fb); } return res; }这个handler会一直循环,直到浏览器关闭连接或ESP32出错。每一次循环都调用esp_camera_fb_get()获取最新帧,然后用httpd_resp_send_chunk()分块发送。boundary=frame是MJPEG流的常见分隔方式,浏览器看到这个Content-Type后,会自动把这些JPEG帧渲染成动态视频。
需要注意的是,如果摄像头没有新帧可用,esp_camera_fb_get()会阻塞。实际生产代码