新手零门槛搭建 ESP32 + OV3660 摄像头 MJPEG 流服务器(ESP-IDF v5.1.6 适配版)

前言

本文专为零基础新手打造,从硬件选型与接线开发环境准备代码编写与编译最终访问摄像头流,全程保姆级教程。基于 ESP-IDF v5.1.6 开发,完美适配 OV3660 摄像头与微软 Edge 浏览器,代码可直接复制使用,一次性解决「编译报错」「SOI 标记缺失」「Edge 无法解析流」等核心问题。

一、核心目标

用 ESP32-CAM 开发板 + OV3660 摄像头,搭建一个 MJPEG 视频流服务器,通过 WiFi 连接后,在浏览器中输入 ESP32 局域网 IP,即可实时查看摄像头画面。

二、硬件架构与选型

1. 核心硬件清单(新手必买,无兼容问题)

硬件名称规格要求作用
ESP32-CAM 开发板搭载 ESP32-WROOM-32 核心,无 PSRAM 版本即可核心控制单元,负责 WiFi 通信、摄像头驱动、HTTP 服务器运行
OV3660 摄像头模块适配 ESP32-CAM 引脚,自带排线图像采集,输出 JPEG 格式图像
ESP32-CAM 专用烧录座适配 ESP32-CAM 引脚,支持一键烧录替代 USB 转 TTL,简化烧录流程,无需手动接 IO0
USB 数据线Micro USB 接口,适配烧录座给烧录座供电,传输烧录数据

2. 硬件架构原理

图像采集

数据传输

处理核心
JPEG 校验、WiFi 通信、HTTP 服务器

网络中转 无线连接

解析 MJPEG 流并显示画面

OV3660 摄像头

ESP32-CAM 引脚

ESP32 核心

家庭 WiFi 路由器

浏览器 Edge/Chrome

显示画面

3. 硬件实物图(新手直观参考)

以下是 ESP32-CAM + OV3660 + 烧录座的实物图,新手可直接对照购买:
在这里插入图片描述

4. 硬件接线(使用烧录座,零难度)

(1)ESP32-CAM 与烧录座接线
  1. 将 ESP32-CAM 开发板金手指朝下,插入烧录座的对应插槽;
  2. 确保 ESP32-CAM 与烧录座引脚完全对齐,无偏移。
(2)烧录座与 USB 数据线接线

直接将 Micro USB 数据线插入烧录座的 Micro USB 接口,另一端插入电脑 USB 口即可(无需额外接线)。

(3)ESP32-CAM 与 OV3660 接线

直接将 OV3660 自带的排线插入 ESP32-CAM 的摄像头接口即可(注意排线方向,金手指朝向开发板正面)。

三、开发环境准备(ESP-IDF v5.1.6)

已安装 ESP-IDF v5.1.6 的新手可跳过此步骤,直接进入「四、项目搭建」。

1. 下载并安装 ESP-IDF Tools

  1. 访问 ESP 官方下载页:https://dl.espressif.com/dl/esp-idf/
  2. 下载 ESP-IDF v5.1.6 对应的 Windows 安装包(esp-idf-tools-setup-5.1.6.exe);
  3. 双击安装,一路默认下一步,记住安装路径(例如 D:\ESP32\esp-idf\v5.1.6)。

2. 配置 VS Code 开发环境

  1. 安装 VS Code,在扩展商店搜索并安装 Espressif IDF 插件(作者:Espressif Systems);
  2. 打开 VS Code,按 Ctrl+Shift+P,输入 ESP-IDF: Configure ESP-IDF Extension
  3. 选择「Use an existing ESP-IDF directory」,找到并选择刚才安装的 ESP-IDF v5.1.6 路径;
  4. 等待插件配置完成,底部状态栏显示「ESP-IDF: 5.1.6」即成功。

四、新手重建项目(核心步骤)

步骤 1:创建新项目

  1. 打开 VS Code,按 Ctrl+Shift+P,输入 ESP-IDF: New Project
  2. 项目名称填写 web-camera-ov3660,保存路径自定义(例如 D:\ESP32\web-camera-ov3660);
  3. 模板选择 esp32blank(空白项目),点击「Create」。

步骤 2:创建核心文件

在项目的 main 目录下,删除默认的 main.c,新建以下 6 个文件
app_main.capp_wifi.capp_wifi.happ_camera.capp_camera.happ_httpd.capp_httpd.h

步骤 3:复制代码(直接粘贴,无需修改)

所有代码已适配 ESP-IDF v5.1.6,解决「SOI 标记缺失」「Edge 兼容性」「编译报错」问题。

1. app_main.c(程序入口)
#include "esp_log.h"
#include "nvs_flash.h"
#include "app_wifi.h"
#include "app_camera.h"
#include "app_httpd.h"

static const char *TAG = "MAIN";

void app_main(void)
{
    // 1. 初始化 NVS(WiFi 依赖)
    esp_err_t err = nvs_flash_init();
    if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        err = nvs_flash_init();
    }
    ESP_ERROR_CHECK(err);
    ESP_LOGI(TAG, "NVS 初始化成功");

    // 2. 初始化 WiFi(直接调用,无返回值)
    wifi_init_sta();
    ESP_LOGI(TAG, "WiFi 初始化完成");

    // 3. 初始化 OV3660 摄像头
    err = app_camera_main();
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "摄像头初始化失败!错误码:0x%x", err);
        return;
    }
    ESP_LOGI(TAG, "摄像头初始化成功");

    // 4. 启动 HTTP 服务器
    httpd_handle_t server = start_webserver();
    if (server == NULL) {
        ESP_LOGE(TAG, "HTTP 服务器启动失败");
        return;
    }
    ESP_LOGI(TAG, "系统初始化完成!访问:http://ESP32_IP/ 查看摄像头");
}
2. app_wifi.c(WiFi 连接实现) 替换为你的 WiFi 名称和密码
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "app_wifi.h"

static const char *TAG = "WIFI";
static EventGroupHandle_t s_wifi_event_group;

// ===================== 新手修改处 =====================
// 替换为你的 WiFi 名称和密码
#define WIFI_SSID     "替换为你的 WiFi 名称"
#define WIFI_PASSWORD "替换为你的 WiFi 密码"
// =====================================================

#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT      BIT1

static void wifi_event_handler(void* arg, esp_event_base_t event_base,
                               int32_t event_id, void* event_data)
{
    if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
        esp_wifi_connect();
    } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
        ESP_LOGW(TAG, "WiFi 断开,重试连接...");
        esp_wifi_connect();
        xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
    } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
        ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
        ESP_LOGI(TAG, "WiFi 连接成功,IP地址:" IPSTR, IP2STR(&event->ip_info.ip));
        xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
    }
}

void wifi_init_sta(void)
{
    s_wifi_event_group = xEventGroupCreate();

    // 初始化网络接口
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    esp_netif_create_default_wifi_sta();

    // 初始化 WiFi 驱动
    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));

    // 注册事件回调
    esp_event_handler_instance_t instance_any_id;
    esp_event_handler_instance_t instance_got_ip;
    ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
                                                        ESP_EVENT_ANY_ID,
                                                        &wifi_event_handler,
                                                        NULL,
                                                        &instance_any_id));
    ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
                                                        IP_EVENT_STA_GOT_IP,
                                                        &wifi_event_handler,
                                                        NULL,
                                                        &instance_got_ip));

    // 配置 WiFi 账号密码
    wifi_config_t wifi_config = {
        .sta = {
            .ssid = WIFI_SSID,
            .password = WIFI_PASSWORD,
            .threshold.authmode = WIFI_AUTH_WPA2_PSK,
        },
    };
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
    ESP_ERROR_CHECK(esp_wifi_start());

    // 等待 WiFi 连接完成
    xEventGroupWaitBits(s_wifi_event_group,
                        WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
                        pdFALSE,
                        pdFALSE,
                        portMAX_DELAY);
}
3. app_wifi.h(WiFi 函数声明)
#ifndef _APP_WIFI_H_
#define _APP_WIFI_H_

#ifdef __cplusplus
extern "C" {
#endif

void wifi_init_sta(void);

#ifdef __cplusplus
}
#endif

#endif /* _APP_WIFI_H_ */
4. app_camera.c(OV3660 摄像头驱动,解决 SOI 缺失)
#include "app_camera.h"
#include "esp_camera.h"
#include "driver/gpio.h"
#include "esp_log.h"

// ESP32-CAM 与 OV3660 引脚匹配
#define CAM_PIN_PWDN    32
#define CAM_PIN_RESET   -1
#define CAM_PIN_XCLK    0
#define CAM_PIN_SIOD    26
#define CAM_PIN_SIOC    27
#define CAM_PIN_D7      35
#define CAM_PIN_D6      34
#define CAM_PIN_D5      39
#define CAM_PIN_D4      36
#define CAM_PIN_D3      21
#define CAM_PIN_D2      19
#define CAM_PIN_D1      18
#define CAM_PIN_D0      5
#define CAM_PIN_VSYNC   25
#define CAM_PIN_HREF    23
#define CAM_PIN_PCLK    22

static const char *TAG = "CAMERA";

esp_err_t app_camera_main(void)
{
    // OV3660 专用配置(解决 SOI 标记缺失)
    camera_config_t camera_config = {
        .pin_pwdn = CAM_PIN_PWDN,
        .pin_reset = CAM_PIN_RESET,
        .pin_xclk = CAM_PIN_XCLK,
        .pin_sccb_sda = CAM_PIN_SIOD,
        .pin_sccb_scl = CAM_PIN_SIOC,
        .pin_d7 = CAM_PIN_D7,
        .pin_d6 = CAM_PIN_D6,
        .pin_d5 = CAM_PIN_D5,
        .pin_d4 = CAM_PIN_D4,
        .pin_d3 = CAM_PIN_D3,
        .pin_d2 = CAM_PIN_D2,
        .pin_d1 = CAM_PIN_D1,
        .pin_d0 = CAM_PIN_D0,
        .pin_vsync = CAM_PIN_VSYNC,
        .pin_href = CAM_PIN_HREF,
        .pin_pclk = CAM_PIN_PCLK,
        .xclk_freq_hz = 10000000,  // 降频到 10MHz,稳定编码
        .ledc_timer = LEDC_TIMER_0,
        .ledc_channel = LEDC_CHANNEL_0,
        .pixel_format = PIXFORMAT_JPEG,  // 强制 JPEG 格式
        .frame_size = FRAMESIZE_QVGA,    // 320x240,适配无 PSRAM
        .jpeg_quality = 10,              // 降低质量,避免数据溢出
        .fb_count = 2,                   // 双缓冲区,防止数据丢失
        .fb_location = CAMERA_FB_IN_DRAM,
        .grab_mode = CAMERA_GRAB_WHEN_EMPTY
    };

    // 初始化摄像头
    esp_err_t err = esp_camera_init(&camera_config);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "摄像头初始化失败!错误码:0x%x", err);
        return err;
    }

    // 强制锁定 JPEG 配置,杜绝 RAW 格式
    sensor_t *s = esp_camera_sensor_get();
    if (s) {
        s->set_framesize(s, FRAMESIZE_QVGA);
        s->set_pixformat(s, PIXFORMAT_JPEG);
        ESP_LOGI(TAG, "OV3660 JPEG 配置生效");
    } else {
        ESP_LOGE(TAG, "获取摄像头传感器失败");
        return ESP_FAIL;
    }

    ESP_LOGI(TAG, "摄像头初始化成功!");
    return ESP_OK;
}
5. app_camera.h(摄像头函数声明)
#ifndef APP_CAMERA_H
#define APP_CAMERA_H

#include "esp_err.h"

esp_err_t app_camera_main(void);

#endif  // APP_CAMERA_H
6. app_httpd.c(HTTP 服务器 + MJPEG 流,适配 Edge)
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_camera.h"
#include "string.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "app_httpd.h"

static const char *TAG = "HTTPD";

// 主页 HTML(自带样式,适配浏览器)
static const char *STREAM_HTML = 
    "<!DOCTYPE html>"
    "<html>"
    "<head>"
    "<title>ESP32 OV3660 Camera</title>"
    "<meta charset=\"utf-8\">"
    "<style>"
    "body { margin: 0; padding: 20px; background-color: #f0f0f0; }"
    ".camera-container { max-width: 640px; margin: 0 auto; background: white; padding: 10px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }"
    "img { width: 100%; height: auto; border-radius: 4px; }"
    "h1 { text-align: center; color: #333; font-family: Arial, sans-serif; }"
    "</style>"
    "</head>"
    "<body>"
    "<div class=\"camera-container\">"
    "<h1>ESP32 OV3660 摄像头画面</h1>"
    "<img src=\"/stream\" alt=\"Camera Stream\">"
    "</div>"
    "</body>"
    "</html>";

static esp_err_t index_handler(httpd_req_t *req)
{
    httpd_resp_set_type(req, "text/html");
    httpd_resp_send(req, STREAM_HTML, strlen(STREAM_HTML));
    return ESP_OK;
}

// MJPEG 流核心处理(纯 ESP-IDF v5.1.6 支持 API)
static esp_err_t stream_handler(httpd_req_t *req)
{
    camera_fb_t *fb = NULL;
    esp_err_t res = ESP_OK;
    char part_buf[128];
    char conn_hdr[32];
    TickType_t start_tick;

    // Edge 浏览器兼容配置
    httpd_resp_set_type(req, "multipart/x-mixed-replace; boundary=frame");
    httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
    httpd_resp_set_hdr(req, "Connection", "close");

    while (true) {
        // 手动超时获取帧(替代高版本函数,避免编译报错)
        start_tick = xTaskGetTickCount();
        fb = NULL;
        while (xTaskGetTickCount() - start_tick < 500 / portTICK_PERIOD_MS) {
            fb = esp_camera_fb_get();
            if (fb) break;
            vTaskDelay(1 / portTICK_PERIOD_MS);
        }

        if (!fb) {
            ESP_LOGE(TAG, "获取帧超时");
            continue;
        }

        // 校验 JPEG 起始标记(0xFFD8),解决 SOI 缺失问题
        if (fb->format == PIXFORMAT_JPEG && fb->buf[0] == 0xFF && fb->buf[1] == 0xD8) {
            size_t hlen = snprintf(part_buf, sizeof(part_buf),
                                  "--frame\r\n"
                                  "Content-Type: image/jpeg\r\n"
                                  "Content-Length: %zu\r\n\r\n",
                                  fb->len);
            res = httpd_resp_send_chunk(req, part_buf, hlen);
            if (res != ESP_OK) break;

            res = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
            if (res != ESP_OK) break;
        } else {
            ESP_LOGE(TAG, "JPEG 数据异常,缺少 SOI 标记");
        }

        // 释放缓冲区,避免内存泄漏
        if (fb) {
            esp_camera_fb_return(fb);
            fb = NULL;
        }

        // 检测客户端断开连接(ESP-IDF v5.1.6 标准写法)
        ssize_t conn_hdr_len = httpd_req_get_hdr_value_len(req, "Connection");
        if (conn_hdr_len > 0 && conn_hdr_len < sizeof(conn_hdr)) {
            httpd_req_get_hdr_value_str(req, "Connection", conn_hdr, conn_hdr_len + 1);
            if (strcmp(conn_hdr, "close") == 0) {
                break;
            }
        }
    }

    // 清理资源
    if (fb) esp_camera_fb_return(fb);
    httpd_resp_send_chunk(req, "\r\n--frame--\r\n", strlen("\r\n--frame--\r\n"));
    ESP_LOGI(TAG, "流连接断开");
    return res;
}

httpd_handle_t start_webserver(void)
{
    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
    config.max_uri_handlers = 16;
    config.server_port = 80;

    httpd_handle_t server = NULL;
    if (httpd_start(&server, &config) == ESP_OK) {
        // 注册主页路由
        httpd_uri_t index_uri = {
            .uri       = "/",
            .method    = HTTP_GET,
            .handler   = index_handler,
            .user_ctx  = NULL
        };
        httpd_register_uri_handler(server, &index_uri);

        // 注册视频流路由
        httpd_uri_t stream_uri = {
            .uri       = "/stream",
            .method    = HTTP_GET,
            .handler   = stream_handler,
            .user_ctx  = NULL
        };
        httpd_register_uri_handler(server, &stream_uri);

        ESP_LOGI(TAG, "HTTP 服务器启动成功,端口:80");
    } else {
        ESP_LOGE(TAG, "HTTP 服务器启动失败");
    }

    return server;
}

void stop_webserver(httpd_handle_t server)
{
    if (server) {
        httpd_stop(server);
        ESP_LOGI(TAG, "HTTP 服务器已停止");
    }
}
7. app_httpd.h(HTTP 函数声明)
#ifndef APP_HTTPD_H
#define APP_HTTPD_H

#include "esp_http_server.h"

httpd_handle_t start_webserver(void);
void stop_webserver(httpd_handle_t server);

#endif  // APP_HTTPD_H

步骤 4:修改 CMakeLists.txt(编译配置)

打开 main 目录下的 CMakeLists.txt,替换为以下内容:

idf_component_register(SRCS "app_main.c" "app_wifi.c" "app_camera.c" "app_httpd.c"
                    INCLUDE_DIRS ".")

五、编译与烧录(使用烧录座,新手零门槛)

步骤 1:配置串口与目标芯片

  1. Ctrl+Shift+P,输入 ESP-IDF: Select Port,选择烧录座对应的串口(例如 COM3);
  2. 输入 ESP-IDF: Select Target,选择 esp32

步骤 2:清理并编译

在 VS Code 终端中执行以下命令(复制粘贴即可):

# 彻底清理旧缓存(解决编译残留问题)
idf.py fullclean

# 编译项目
idf.py build

编译成功会显示「Project build complete」,无任何报错。

步骤 3:烧录程序(烧录座专用)

  1. 将 ESP32-CAM 插入烧录座,确保引脚对齐;
  2. 用 USB 数据线连接烧录座与电脑,在终端执行:
idf.py flash
  1. 烧录完成后,按一下 ESP32-CAM 的复位键(RST),无需手动切换 IO0。

步骤 4:查看串口日志(获取 ESP32 IP)

在终端执行:

idf.py monitor

等待日志输出,找到以下关键信息:

I (5471) WIFI: WiFi 连接成功,IP地址:192.168.31.179
I (5941) MAIN: 系统初始化完成!访问:http://ESP32_IP/ 查看摄像头

记录下你的 ESP32 IP(例如 192.168.31.179)。

六、查看摄像头画面(最终验证)

  1. 确保电脑/手机与 ESP32 连接同一个 WiFi
  2. 打开微软 Edge/谷歌 Chrome 浏览器,输入刚才记录的 IP(例如 http://192.168.31.179);
  3. Ctrl+F5 强制刷新(避免浏览器缓存),即可看到实时摄像头画面!
  4. 画面如下
    在这里插入图片描述

七、新手常见问题排查

问题现象原因解决方法
编译报错「unknown type name」头文件缺失/顺序错误严格按本文代码的头文件顺序引入
日志显示「NO-SOI」OV3660 编码异常检查 app_camera.c 中 xclk_freq_hz 是否为 10MHz,fb_count 是否为 2
Edge 只显示标题,不显示画面浏览器缓存/流格式不兼容按 Ctrl+F5 强制刷新,关闭 Edge 安全增强模式
WiFi 连接失败账号密码错误/信号差检查 app_wifi.c 中的 WIFI_SSID 和 WIFI_PASSWORD,靠近路由器
烧录失败烧录座接触不良/串口错误重新插拔 ESP32-CAM,重新选择正确串口

结语

本文从硬件到软件,全程为新手打造,代码经过实测验证,完美适配 ESP-IDF v5.1.6、OV3660 摄像头与 Edge 浏览器。新手只需按步骤复制代码、接线、编译,即可快速搭建属于自己的 ESP32 摄像头流服务器,后续可在此基础上扩展人脸识别、视频录制等功能。

源码

已上传Gitee
https://gitee.com/yunjingshan/esp32-ov3660-mjpeg-server

评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值