ESP32-CAM 拍照实操:烧录接线、网页预览与取流

2026-11-05 20:22:44 7阅读

十几块钱的 ESP32-CAM,带一颗 OV2640 摄像头,板载闪光灯,还能插 microSD 卡,算是创客圈里性价比最高的一块"长了眼睛"的板子。做门口监控、桌面小相机、抓拍终端,第一块试水的板子基本都选它。但它有个劝退点:板上没有 USB 口,固件得靠 FTDI 这类外部烧录器灌进去。不少新手的 ESP32-CAM 就是卡在第一步,买回来直接吃灰。

这篇把整条链路捋顺:先过烧录接线,再走通 Arduino IDE 的板型设置,然后用两份现成工程分别跑起 Wi-Fi 取流预览和网页按钮拍照,照片存进 SPIFFS。中间的报错信息都是串口里原样贴出来的,遇到同名报错直接照着对就行。器件引脚和参数都对照教程原页核过,可以放心抄。

ESP32-CAM 开发板与 OV2640 摄像头

板子长这样:就是没有 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-CAMFTDI 编程器
GNDGND
5VVCC(5V)
U0RTX
U0TRX
GPIO 0GND

ESP32-CAM 与 FTDI 烧录器接线图

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

FTDI 编程器跳线帽选择 5V 档

软件这边,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,新固件就跑起来了。

Arduino IDE 中选择 AI-Thinker ESP32-CAM 板型

如果这一步失败,最常见的报错是这一行:

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 交叉这件事值得再强调一遍,串口通信里两端必须是一头发一头收,直连等于俩人对着喊、俩人捂着耳朵。

烧录报错 Timed out waiting for packet header 串口截图

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,十有八九是这里没对上。

ESP32-CAM 网页视频取流效果

拍照存 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 );
}

串口监视器显示 SPIFFS 挂载成功与网页地址

烧录后浏览器打开串口里那个 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 传感器做移动侦测抓拍,或者把取流页面嵌进家里的面板,都是顺手的扩展。烧录这步卡住的话,回头把接线表和那行报错对一遍,一般都能救回来。


作者:固件烧录仔
链接:https://www.aiddithome.com/p/88719228b0003.html
本作版权归作者所有,未经允许请勿转载。若存在违规行为,您可以点击 “举报”。


登录 后发表评论
4条评论
老张装机
1楼 · 3小时前

ESP32-CAM 我前后买过五六块,每块都是在烧录那步卡一下才开窍。这篇把 GPIO 0 接地、按 RST、拔线的顺序讲对了,这点最关键——很多人烧不进去就一直换线,其实是没进烧录模式。另外补一个我的经验:FTDI 那个跳线帽买回来默认在 3.3V,直接烧必翻车 Brownout,先拨到 5V 再说。串口监视器保持 115200,报错都是明文,照着排查就行。

电烙铁爱好者
2楼 · 3小时前

Timed out waiting for packet header 这行报错我都能背下来了,十次里八次是 GPIO 0 没接地。

锡点
3楼 · 3小时前

MJPEG 取流这套我白嫖了快两年,胜在零依赖零配置,就是别对帧率抱太大期望,看个门口够用。

板砖搬不动
4楼 · 3小时前

SPIFFS 存照片重启不丢这点确实省心,就是空间太小,我后来直接上了 microSD 方案,连拍存档都靠它。