十几块钱的 ESP32-CAM,带一颗 OV2640 摄像头,板载闪光灯,还能插 microSD 卡,算是创客圈里性价比最高的一块"长了眼睛"的板子。做门口监控、桌面小相机、抓拍终端,第一块试水的板子基本都选它。但它有个劝退点:板上没有 USB 口,固件得靠 FTDI 这类外部烧录器灌进去。不少新手的 ESP32-CAM 就是卡在第一步,买回来直接吃灰。
这篇把整条链路捋顺:先过烧录接线,再走通 Arduino IDE 的板型设置,然后用两份现成工程分别跑起 Wi-Fi 取流预览和网页按钮拍照,照片存进 SPIFFS。中间的报错信息都是串口里原样贴出来的,遇到同名报错直接照着对就行。器件引脚和参数都对照教程原页核过,可以放心抄。

板子长这样:就是没有 USB
ESP32-CAM AI-Thinker 版,本质是一颗 ESP32-S 芯片加一颗 OV2640 摄像头,OV2640 是 200 万像素的 CMOS 传感器,数据手册里的上限尺寸就是它。板上还有闪光灯和 microSD 卡槽,侧面留了若干 GPIO 可以接外设。对想给小车加"眼睛"、给门禁加抓拍的人来说,这一颗芯片就够撑起整个视觉终端。
麻烦的地方也在这块板子的精简上:没有板载 USB 转串口,烧录用的 UART 串口(U0R、U0T)和 GPIO 0 都藏在侧面那排排针里,得自己接出来。所以动手前先备齐三样东西:FTDI 编程器(或者 CP2102 之类的 USB 转 TTL 模块)、几根杜邦线、一个能给到 5V 的 USB 口。烧录时 GPIO 0 要拉到 GND,这个后面细说。
还有一点提前打好招呼:这块板的引脚本来就紧张,摄像头和 SD 卡接口占走了大头,剩下的里 GPIO 0 还兼作烧录判定脚。想接外设的话先看引脚映射表,别随手往 GPIO 0 上挂东西,不然下次烧录就是玄学现场。
烧录接线:FTDI、5V 跳线和一根关键的 GPIO 0
接线照下表来,一共五根。注意 ESP32-CAM 的 U0R 是接收脚,要接 FTDI 的发送脚 TX,反过来同理——两边是交叉的。另外 FTDI 编程器上的排针丝印可能和你手上的板子对不上号,动手前以丝印为准,别照搬位置。
| ESP32-CAM | FTDI 编程器 |
|---|---|
| GND | GND |
| 5V | VCC(5V) |
| U0R | TX |
| U0T | RX |
| GPIO 0 | GND |

很多人栽在供电上:FTDI 编程器上有个跳线帽,务必放在 5V 那一档。3.3V 供电时板子容易反复复位,串口里刷屏 "Brownout detector was triggered",这就是欠压保护在报警,不是固件写错了。

软件这边,Arduino IDE 装好 ESP32 支持包之后,Tools > Board 里选 AI-Thinker ESP32-CAM,Port 选板子对应的 COM 口。建议先拿个空工程试烧,不碰任何外设逻辑,纯粹验证整条链路是通的:
void setup() {
// put your setup code here, to run once:
}
void loop() {
// put your main code here, to run repeatedly:
}点 Upload,等调试窗口开始刷小圆点时,如果板子没自动进烧录模式,按一下板上的 RST 键。GPIO 0 接地加 RST 复位,就是让 ESP32 进串口下载模式的开关组合。看到 "Done uploading" 之后,把 GPIO 0 那根线从 GND 上拔掉,再按一次 RST,新固件就跑起来了。

如果这一步失败,最常见的报错是这一行:
Failed to connect to ESP32: Timed out waiting for packet header
意思很直白:板子没进烧录模式,或者线和 FTDI 没接对。重点检查 GPIO 0 有没有接 GND、接好之后有没有按 RST。另一类报错是 "Board at COMX is not available",那是 Port 没选,或者串口连接压根没建立起来,回接口和线缆那层去查。
排查顺序按概率来:先看 GPIO 0 跳线,再看 RST 有没有按,然后查 TX、RX 是不是接反了,最后才怀疑 FTDI 本身。TX、RX 交叉这件事值得再强调一遍,串口通信里两端必须是一头发一头收,直连等于俩人对着喊、俩人捂着耳朵。

Wi-Fi 取流:浏览器里直接看画面
烧录链路通了,就可以上正题。取流环节用 Random Nerd Tutorials 的视频流方案,它的教程页可以整段拷走工程代码。原理说穿了不值钱:板子起一个 HTTP 服务,浏览器请求根路径,服务端把摄像头帧打包成 MJPEG 连续吐出去,你看到的就是一段不断刷新的画面。
工程代码原样贴在下面,一个字符没动,只有一处例外:WiFi.h 那行头文件的尖括号写法改成了引号写法。两种写法对库的搜索路径是等价的,但站点编辑器会把尖括号当 HTML 标签吃掉,不改没法显示,特此说明。
/*********
Rui Santos
Complete project details at https://RandomNerdTutorials.com/esp32-cam-video-streaming-web-server-camera-home-assistant/
IMPORTANT!!!
- Select Board "AI Thinker ESP32-CAM"
- GPIO 0 must be connected to GND to upload a sketch
- After connecting GPIO 0 to GND, press the ESP32-CAM on-board RESET button to put your board in flashing mode
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*********/
#include "esp_camera.h"
#include "WiFi.h"
#include "esp_timer.h"
#include "img_converters.h"
#include "Arduino.h"
#include "fb_gfx.h"
#include "soc/soc.h" //disable brownout problems
#include "soc/rtc_cntl_reg.h" //disable brownout problems
#include "esp_http_server.h"
//Replace with your network credentials
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
#define PART_BOUNDARY "123456789000000000000987654321"
// This project was tested with the AI Thinker Model, M5STACK PSRAM Model and M5STACK WITHOUT PSRAM
#define CAMERA_MODEL_AI_THINKER
//#define CAMERA_MODEL_M5STACK_PSRAM
//#define CAMERA_MODEL_M5STACK_WITHOUT_PSRAM
// Not tested with this model
//#define CAMERA_MODEL_WROVER_KIT
#if defined(CAMERA_MODEL_WROVER_KIT)
#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 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 19
#define Y4_GPIO_NUM 18
#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
#elif defined(CAMERA_MODEL_M5STACK_PSRAM)
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM 15
#define XCLK_GPIO_NUM 27
#define SIOD_GPIO_NUM 25
#define SIOC_GPIO_NUM 23
#define Y9_GPIO_NUM 19
#define Y8_GPIO_NUM 36
#define Y7_GPIO_NUM 18
#define Y6_GPIO_NUM 39
#define Y5_GPIO_NUM 5
#define Y4_GPIO_NUM 34
#define Y3_GPIO_NUM 35
#define Y2_GPIO_NUM 32
#define VSYNC_GPIO_NUM 22
#define HREF_GPIO_NUM 26
#define PCLK_GPIO_NUM 21
#elif defined(CAMERA_MODEL_M5STACK_WITHOUT_PSRAM)
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM 15
#define XCLK_GPIO_NUM 27
#define SIOD_GPIO_NUM 25
#define SIOC_GPIO_NUM 23
#define Y9_GPIO_NUM 19
#define Y8_GPIO_NUM 36
#define Y7_GPIO_NUM 18
#define Y6_GPIO_NUM 39
#define Y5_GPIO_NUM 5
#define Y4_GPIO_NUM 34
#define Y3_GPIO_NUM 35
#define Y2_GPIO_NUM 17
#define VSYNC_GPIO_NUM 22
#define HREF_GPIO_NUM 26
#define PCLK_GPIO_NUM 21
#elif defined(CAMERA_MODEL_AI_THINKER)
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
#else
#error "Camera model not selected"
#endif
static const char* _STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY;
static const char* _STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n";
static const char* _STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";
httpd_handle_t stream_httpd = NULL;
static esp_err_t stream_handler(httpd_req_t *req){
camera_fb_t * fb = NULL;
esp_err_t res = ESP_OK;
size_t _jpg_buf_len = 0;
uint8_t * _jpg_buf = NULL;
char * part_buf[64];
res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE);
if(res != ESP_OK){
return res;
}
while(true){
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Camera capture failed");
res = ESP_FAIL;
} else {
if(fb->width > 400){
if(fb->format != PIXFORMAT_JPEG){
bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len);
esp_camera_fb_return(fb);
fb = NULL;
if(!jpeg_converted){
Serial.println("JPEG compression failed");
res = ESP_FAIL;
}
} else {
_jpg_buf_len = fb->len;
_jpg_buf = fb->buf;
}
}
}
if(res == ESP_OK){
size_t hlen = snprintf((char *)part_buf, 64, _STREAM_PART, _jpg_buf_len);
res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen);
}
if(res == ESP_OK){
res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len);
}
if(res == ESP_OK){
res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY));
}
if(fb){
esp_camera_fb_return(fb);
fb = NULL;
_jpg_buf = NULL;
} else if(_jpg_buf){
free(_jpg_buf);
_jpg_buf = NULL;
}
if(res != ESP_OK){
break;
}
//Serial.printf("MJPG: %uB\n",(uint32_t)(_jpg_buf_len));
}
return res;
}
void startCameraServer(){
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = 80;
httpd_uri_t index_uri = {
.uri = "/",
.method = HTTP_GET,
.handler = stream_handler,
.user_ctx = NULL
};
//Serial.printf("Starting web server on port: '%d'\n", config.server_port);
if (httpd_start(&stream_httpd, &config) == ESP_OK) {
httpd_register_uri_handler(stream_httpd, &index_uri);
}
}
void setup() {
WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); //disable brownout detector
Serial.begin(115200);
Serial.setDebugOutput(false);
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_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_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;
if(psramFound()){
config.frame_size = FRAMESIZE_UXGA;
config.jpeg_quality = 10;
config.fb_count = 2;
} else {
config.frame_size = FRAMESIZE_SVGA;
config.jpeg_quality = 12;
config.fb_count = 1;
}
// Camera init
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed with error 0x%x", err);
return;
}
// Wi-Fi connection
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.println("WiFi connected");
Serial.print("Camera Stream Ready! Go to: http://");
Serial.print(WiFi.localIP());
// Start streaming web server
startCameraServer();
}
void loop() {
delay(1);
}
用的时候只改两处:把开头 ssid 和 password 换成你自己的 Wi-Fi 名和密码;确认 #define CAMERA_MODEL_AI_THINKER 处于打开状态,默认就是。板型还是选 AI-Thinker ESP32-CAM,烧录流程和上一节完全一样,烧完拔掉 GPIO 0 跳线再按 RST。
打开串口监视器,波特率 115200,复位后能看到一行 "Camera Stream Ready! Go to: http://192.168.x.x",这个地址就是取流页面。浏览器一开,画面就出来了,局域网里看相当流畅。顺带看一眼 config 那段:拍照初始化档位给到 UXGA,取流路径走的是 SVGA,觉得卡顿就在 config 里把帧尺寸往下调一档,流畅度立竿见影。
提醒一句,这个地址是局域网 IP,出了家门就访问不到,想在外面看 得走内网穿透或者端口映射,那是另一个话题了。串口监视器要是只出乱码,先看波特率是不是 115200,十有八九是这里没对上。

拍照存 SPIFFS:网页按钮一按就出图
取流是"看",这个方案是"拍":网页上三个按钮,ROTATE 旋转照片、CAPTURE PHOTO 拍一张、REFRESH PAGE 刷新页面。拍到的照片存在板载 SPIFFS 里,断电重启都还在。SPIFFS 是从板载 flash 里划出来的一个小文件系统,存个几百 KB 的照片绰绰有余。工程依赖 ESPAsyncWebServer 和 AsyncTCP 两个库,库管理器里搜名字,装 ESP32Async 维护的版本就行。
整个流程是一圈闭环:浏览器请求 /capture,服务端只把拍照标志位翻成 true 就立刻应答;主循环里看到标志位,才真正执行拍照和写文件;浏览器再请求 /saved-photo,服务端把 SPIFFS 里的照片读出来吐回去。拍照是异步的,所以页面才要你手动等几秒再刷新。
下面把关键片段贴出来,都是从原工程里原样搬的。先贴 Wi-Fi 凭据:
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";照片路径定义成 /photo.jpg:
#define FILE_PHOTO "/photo.jpg"OV2640 与 ESP32 之间的引脚映射,AI-Thinker 版是固定的,这段直接抄:
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22初始化里三件事:串口 115200、连 Wi-Fi、挂载 SPIFFS。SPIFFS.begin 的参数填 true,意思是挂载失败就顺手格式化重来,第一次烧录的板子靠这个兜底:
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi...");
}if (!SPIFFS.begin(true)) {
Serial.println("An Error has occurred while mounting SPIFFS");
ESP.restart();
}
else {
delay(500);
Serial.println("SPIFFS mounted successfully");
}拿到 IP 打印出来,然后挂三个路由:首页返回页面,/capture 触发拍照标志位,/saved-photo 把 SPIFFS 里的照片吐给浏览器:
Serial.print("IP Address: http://");
Serial.println(WiFi.localIP());server.on("/", HTTP_GET, [](AsyncWebServerRequest * request) {
request->send(200, "text/html", index_html);
});server.on("/capture", HTTP_GET, [](AsyncWebServerRequest * request) {
takeNewPhoto = true;
request->send(200, "text/plain", "Taking Photo");
});server.on("/saved-photo", HTTP_GET, [](AsyncWebServerRequest * request) {
request->send(SPIFFS, FILE_PHOTO, "image/jpg", false);
});server.begin();页面本体是一个内嵌的 index_html 字符串,三个按钮的点击逻辑都写在里面。这段含大量 HTML 标签,直接贴出来会被站点编辑器剥掉标签变成残废代码,所以完整工程请去原教程页整段拷贝,这里不重复贴。主循环就干一件事:看到拍照标志位就执行拍照函数:
void loop() {
if (takeNewPhoto) {
capturePhotoSaveSpiffs();
takeNewPhoto = false;
}
delay(1);
}拍照并保存的核心函数长这样,用文件大小做真假校验,写入失败自动重拍:
bool checkPhoto( fs::FS &fs ) {
File f_pic = fs.open( FILE_PHOTO );
unsigned int pic_sz = f_pic.size();
return ( pic_sz > 100 );
}void capturePhotoSaveSpiffs( void ) {
camera_fb_t * fb = NULL; // pointer
bool ok = 0; // Boolean indicating if the picture has been taken correctly
do {
// Take a photo with the camera
Serial.println("Taking a photo...");
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Camera capture failed");
return;
}
// Photo file name
Serial.printf("Picture file name: %s\n", FILE_PHOTO);
File file = SPIFFS.open(FILE_PHOTO, FILE_WRITE);
// Insert the data in the photo file
if (!file) {
Serial.println("Failed to open file in writing mode");
}
else {
file.write(fb->buf, fb->len); // payload (image), payload length
Serial.print("The picture has been saved in ");
Serial.print(FILE_PHOTO);
Serial.print(" - Size: ");
Serial.print(file.size());
Serial.println(" bytes");
}
// Close the file
file.close();
esp_camera_fb_return(fb);
// check if file has been correctly saved in SPIFFS
ok = checkPhoto(SPIFFS);
} while ( !ok );
}
烧录后浏览器打开串口里那个 IP,点 CAPTURE PHOTO,等至少 5 秒再刷新页面。拍照加写入要几秒,刷得太早看到的还是上一张。旋转按钮是给倒装场景准备的,摄像头装反了点一下就正。

几个坑,提前替你踩了
- GPIO 0 悬空才能跑固件:烧完不拔线,板子永远停在烧录模式,串口没输出先别怪板子。
- 供电尽量给到 5V:串口反复刷 "Brownout detector was triggered",九成是线太长、USB 口供电不足,换短线换口基本能救。
- 刷新页面前等 5 秒:CAPTURE PHOTO 不是即时出图,SPIFFS 写入需要时间。
- 照片存在 SPIFFS 里:重启不丢,但空间有限,大尺寸连拍得上 microSD 卡方案。
- 报 Timed out waiting for packet header:先摸 GPIO 0 有没有接地、RST 有没有按,再查 TX、RX 有没有接反。
- 串口监视器乱码:多半是波特率没对上,工程统一用 115200。
这套组合打完,ESP32-CAM 就是一台能远程看的迷你相机。板子便宜,烧录链路理顺了,后面加 PIR 传感器做移动侦测抓拍,或者把取流页面嵌进家里的面板,都是顺手的扩展。烧录这步卡住的话,回头把接线表和那行报错对一遍,一般都能救回来。











ESP32-CAM 我前后买过五六块,每块都是在烧录那步卡一下才开窍。这篇把 GPIO 0 接地、按 RST、拔线的顺序讲对了,这点最关键——很多人烧不进去就一直换线,其实是没进烧录模式。另外补一个我的经验:FTDI 那个跳线帽买回来默认在 3.3V,直接烧必翻车 Brownout,先拨到 5V 再说。串口监视器保持 115200,报错都是明文,照着排查就行。
Timed out waiting for packet header 这行报错我都能背下来了,十次里八次是 GPIO 0 没接地。
MJPEG 取流这套我白嫖了快两年,胜在零依赖零配置,就是别对帧率抱太大期望,看个门口够用。
SPIFFS 存照片重启不丢这点确实省心,就是空间太小,我后来直接上了 microSD 方案,连拍存档都靠它。