ESP32-CAM 網路影像串流,初學者完整教學(SD 卡存檔)
ESP32-CAM 不只能串流!一鍵拍照、手機直接下載,還能自動存到 SD 卡,檔名自動編號不怕覆蓋。完整程式碼加詳細註解,初學者也能輕鬆上手。
本系列前兩篇教學,大家已經學會監看串流影像,拍單張影像,並存到電腦或手機。這篇要介紹的,就是把拍到的單張照片,存到ESP32-CAM內建的SD中。如此一來,有關ESP32-CAM的基本操作,大家都可以搞定了!

本篇完成後,把 ESP32-CAM 連上 Wi-Fi ,用手機或電腦的瀏覽器就能:
- 即時串流 — 低畫質(320×240)流暢播放攝影機畫面
- 一鍵拍照 — 自動切換到 VGA(640×480)拍一張清晰照片
- 下載照片 — 拍完後在瀏覽器預覽,按按鈕下載到手機或電腦
- 存到 SD 卡 — 一鍵把照片寫入 ESP32-CAM 板上的 SD 卡,檔名自動編號不會覆蓋
不需要安裝任何 App,也不需要額外的 SD 卡模組(板子上已經內建)。
如果你尚未看過前兩篇,傑森強烈建議先看一下哦!


關於 SD 卡
ESP32-CAM(AI-Thinker)板上已經內建 Micro SD 卡插槽,不需要額外接線或外接模組。SD 卡在使用前必須格式化為 FAT32 格式。Windows 可以直接右鍵格式化;Mac 使用磁碟工具程式。
注意:SD 卡不是必須的。 沒有插卡一樣可以使用串流和下載功能,只是「存到 SD 卡」按鈕會提示未偵測到卡片。
一、程式碼
有關Arduino IDE的準備,還有基本的串流功能,我們這篇就不重複說明了,請需要了解的人前往前兩篇看看哦,這邊我們直就接進入程式的說明囉!


程式架構說明
這支程式在 ESP32-CAM 上同時運行兩個 HTTP 伺服器:
Port 80(網頁伺服器) 處理五個網址路徑:/ 首頁顯示串流畫面和拍照按鈕;/capture 拍照 API;/preview 預覽頁面;/photo 回傳照片資料;/save_sd 把照片存到 SD 卡。
Port 81(串流伺服器) 只處理 /stream,用 MJPEG 格式持續傳送畫面。
串流與拍照的解析度切換
平時串流維持在 QVGA(320×240)確保流暢,按下拍照時透過 setCameraParams() 切換到 VGA(640×480)拍一張照片,拍完再切回 QVGA。OV2640 初始化時必須用最大解析度,之後才能動態切換到較小的解析度。
拍照流程的三個關鍵處理
拍照不是單純呼叫一次 esp_camera_fb_get() 就好。程式做了三件事:切換解析度後等 300 毫秒讓攝影機穩定;連續拍 3 幀丟掉讓自動曝光調整好(避免偏綠或過亮);用 ps_malloc() 把照片複製到 PSRAM 暫存(攝影機的 frame buffer 歸還後資料會被覆蓋)。
SD 卡存檔機制
SD 卡使用 1-bit MMC 模式掛載,比 4-bit 穩定且不會佔用閃光燈的 GPIO4。開機時自動掃描已有的照片編號,每次存檔前用 SD_MMC.exists() 即時檢查檔名是否已存在,確保不會覆蓋舊檔。檔名格式為 photo_001.jpg、photo_002.jpg 自動遞增。
避免 crash 的設計
所有 HTML 字串用 static const 宣告放在全域記憶體而非 stack,同時把 httpd 的 stack_size 從預設 4096 加大到 8192,雙重保險避免 stack overflow。
完整程式碼
將以下程式碼貼到 Arduino IDE,只需修改最上方的 Wi-Fi 名稱和密碼。
#include "esp_camera.h" // ESP32 攝影機函式庫
#include <WiFi.h> // Wi-Fi 連線函式庫
#include "esp_http_server.h" // HTTP 伺服器函式庫
#include "FS.h" // 檔案系統函式庫
#include "SD_MMC.h" // SD 卡函式庫(ESP32-CAM 用 MMC 模式)
// ===== 請改成你自己的 Wi-Fi 名稱和密碼 =====
const char* ssid = "你的WiFi名稱";
const char* password = "你的WiFi密碼";
// ============================================================
// 攝影機設定區(日後要調整只改這裡就好)
// ============================================================
// --- 串流設定(低解析度,讓畫面流暢)---
#define STREAM_FRAME_SIZE FRAMESIZE_QVGA // 串流解析度 320x240
#define STREAM_QUALITY 12 // 串流 JPEG 品質(0~63,越小越好)
// --- 拍照設定(高解析度,讓照片清晰)---
#define CAPTURE_FRAME_SIZE FRAMESIZE_VGA // 拍照解析度 640x480
#define CAPTURE_QUALITY 6 // 拍照 JPEG 品質(VGA 建議 6~10)
// --- 其他設定 ---
#define XCLK_FREQ 10000000 // 攝影機時鐘 10MHz(降低可減少畫面條紋)
#define PHOTO_FILENAME "esp32cam_photo.jpg" // 瀏覽器下載時的預設檔名
#define HTTPD_STACK_SIZE 8192 // HTTP 伺服器 stack 大小(預設 4096 太小會 crash)
/*
* 解析度對照表(替換上面的 FRAMESIZE_XXX):
*
* FRAMESIZE_QVGA = 320 x 240 ← 串流推薦
* FRAMESIZE_CIF = 400 x 296
* FRAMESIZE_VGA = 640 x 480 ← 拍照目前使用
* FRAMESIZE_SVGA = 800 x 600
* FRAMESIZE_XGA = 1024 x 768
* FRAMESIZE_SXGA = 1280 x 1024
* FRAMESIZE_UXGA = 1600 x 1200 ← 拍照最大(如需更高畫質可改回此值)
*
* JPEG 品質建議:
* 串流用:10~15(檔案小,傳輸快)
* 拍照用:4~8(畫質好,VGA 約 30~80KB)
*/
// ============================================================
// AI-Thinker ESP32-CAM 攝影機腳位(固定的,不用改)
// ============================================================
#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
// ============================================================
// MJPEG 串流格式定義(固定的,不用改)
// ============================================================
#define PART_BOUNDARY "123456789000000000000987654321"
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";
// 兩個 HTTP 伺服器:網頁用 port 80,串流用 port 81
httpd_handle_t stream_httpd = NULL;
httpd_handle_t page_httpd = NULL;
// ============================================================
// 拍照暫存區
// ============================================================
//
// 拍照後照片暫存在這裡,直到下次拍照時被覆蓋。
// 使用 PSRAM(外部 4MB 記憶體)儲存,因為內部 SRAM 只有 520KB 太小。
//
uint8_t* captured_buf = NULL; // 指向照片資料的指標
size_t captured_len = 0; // 照片資料大小(bytes)
// ============================================================
// SD 卡相關變數
// ============================================================
//
// ESP32-CAM(AI-Thinker)板上內建 Micro SD 卡插槽,
// 使用 SD_MMC 函式庫以 1-bit MMC 模式存取。
//
// 為什麼用 1-bit 模式?
// ESP32-CAM 的 SD 卡 4-bit 模式會用到 GPIO4(也就是板載閃光燈的腳位),
// 導致 SD 卡和閃光燈衝突。改用 1-bit 模式只用 GPIO2 和 GPIO14,
// 速度稍慢但更穩定,也不會影響閃光燈。
//
bool sd_ready = false; // SD 卡是否可用(開機時偵測)
int sd_photo_count = 0; // 目前照片編號(自動遞增)
// ============================================================
// 工具函式:切換攝影機解析度和品質
// ============================================================
void setCameraParams(framesize_t frameSize, int quality) {
sensor_t *s = esp_camera_sensor_get();
s->set_framesize(s, frameSize);
s->set_quality(s, quality);
}
// ============================================================
// SD 卡初始化
// ============================================================
//
// 開機時呼叫一次,做三件事:
// 1. 掛載 SD 卡檔案系統
// 2. 確認卡片是否存在
// 3. 掃描已有的照片,找出最大的編號
//
// 掃描編號是為了讓新照片的編號接續下去,
// 例如卡片裡已經有 photo_001.jpg ~ photo_005.jpg,
// 下次存檔就從 photo_006.jpg 開始。
//
void initSDCard() {
// SD_MMC.begin() 的第二個參數 true 表示使用 1-bit 模式
if (SD_MMC.begin("/sdcard", true)) {
uint8_t cardType = SD_MMC.cardType();
if (cardType != CARD_NONE) {
sd_ready = true;
Serial.printf("SD 卡就緒!大小:%llu MB\n",
SD_MMC.cardSize() / (1024 * 1024));
// 掃描已有的照片檔案,找出最大的編號
File root = SD_MMC.open("/");
while (File f = root.openNextFile()) {
String name = f.name();
// f.name() 在不同版本的 ESP32 Arduino 核心回傳格式不同:
// 有些回傳 "/photo_001.jpg"(帶斜線)
// 有些回傳 "photo_001.jpg"(不帶斜線)
// 所以先統一去掉開頭的斜線
if (name.startsWith("/")) name = name.substring(1);
// 照片檔名格式:photo_001.jpg
if (name.startsWith("photo_") && name.endsWith(".jpg")) {
// 取出編號部分:"photo_005.jpg" → "005" → 5
int num = name.substring(6, name.length() - 4).toInt();
if (num > sd_photo_count) sd_photo_count = num;
}
f.close();
}
root.close();
Serial.printf("SD 卡已有 %d 張照片,下一張編號:%d\n",
sd_photo_count, sd_photo_count + 1);
} else {
Serial.println("SD 卡插槽偵測不到卡片");
}
} else {
Serial.println("SD 卡掛載失敗(沒插卡或格式不是 FAT32)");
}
}
// ============================================================
// 串流處理(MJPEG 持續傳送畫面給瀏覽器)
// ============================================================
static esp_err_t stream_handler(httpd_req_t *req) {
camera_fb_t *fb = NULL;
char part_buf[64];
esp_err_t res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE);
if (res != ESP_OK) return res;
// 無限迴圈:持續拍照 → 傳送 → 拍照 → 傳送...
while (true) {
fb = esp_camera_fb_get(); // 取得一幀 JPEG
if (!fb) { res = ESP_FAIL; break; }
size_t hlen = snprintf(part_buf, 64, _STREAM_PART, fb->len);
res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY));
if (res == ESP_OK) res = httpd_resp_send_chunk(req, part_buf, hlen);
if (res == ESP_OK) res = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
esp_camera_fb_return(fb); // 歸還 buffer
if (res != ESP_OK) break; // 瀏覽器關閉就停止
}
return res;
}
// ============================================================
// 拍照 API — /capture
// ============================================================
//
// 流程:釋放舊照片 → 切高畫質 → 丟幾幀穩定曝光 → 正式拍 → 存到 PSRAM → 切回低畫質
//
static esp_err_t capture_handler(httpd_req_t *req) {
// 釋放舊照片記憶體,避免 memory leak
if (captured_buf) {
free(captured_buf);
captured_buf = NULL;
captured_len = 0;
}
// 切到拍照模式(VGA 640x480 + 高品質)
setCameraParams(CAPTURE_FRAME_SIZE, CAPTURE_QUALITY);
delay(300); // 等攝影機穩定
// 丟掉前 3 幀,讓自動曝光穩定(避免偏綠或過亮)
for (int i = 0; i < 3; i++) {
camera_fb_t *fb = esp_camera_fb_get();
if (fb) esp_camera_fb_return(fb);
delay(100);
}
// 正式拍一張
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) {
Serial.println("拍照失敗");
setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY);
httpd_resp_send_500(req);
return ESP_FAIL;
}
// 複製到 PSRAM 暫存(fb 歸還後資料就沒了)
captured_buf = (uint8_t*)ps_malloc(fb->len);
if (!captured_buf) captured_buf = (uint8_t*)malloc(fb->len);
if (captured_buf) {
memcpy(captured_buf, fb->buf, fb->len);
captured_len = fb->len;
Serial.printf("拍照成功!解析度:%dx%d,大小:%u bytes\n",
fb->width, fb->height, captured_len);
} else {
Serial.println("記憶體不足");
}
esp_camera_fb_return(fb);
setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY); // 切回串流
httpd_resp_set_type(req, "text/plain");
httpd_resp_send(req, captured_buf ? "ok" : "fail", captured_buf ? 2 : 4);
return ESP_OK;
}
// ============================================================
// 照片 API — /photo(顯示或下載)
// ============================================================
//
// /photo → 瀏覽器直接顯示圖片(用在預覽頁的 <img>)
// /photo?download=1 → 瀏覽器跳出另存新檔視窗
//
static esp_err_t photo_handler(httpd_req_t *req) {
if (!captured_buf || captured_len == 0) {
httpd_resp_send_404(req);
return ESP_FAIL;
}
// 檢查網址有沒有帶 ?download=1
char query[32] = {0};
bool is_download = false;
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
char val[4] = {0};
if (httpd_query_key_value(query, "download", val, sizeof(val)) == ESP_OK) {
if (strcmp(val, "1") == 0) is_download = true;
}
}
httpd_resp_set_type(req, "image/jpeg");
if (is_download) {
// Content-Disposition: attachment 讓瀏覽器跳出下載視窗
httpd_resp_set_hdr(req, "Content-Disposition",
"attachment; filename=" PHOTO_FILENAME);
}
httpd_resp_send(req, (const char *)captured_buf, captured_len);
return ESP_OK;
}
// ============================================================
// 存到 SD 卡 API — /save_sd
// ============================================================
//
// 把暫存在 PSRAM 中的照片寫入 SD 卡。
//
// 檔名規則:
// photo_001.jpg → photo_002.jpg → photo_003.jpg ...
// 每次存檔前用 SD_MMC.exists() 檢查檔名是否已存在,
// 如果已存在就自動跳號,確保絕對不會覆蓋舊照片。
//
// 回傳 JSON 格式讓前端網頁判斷成功或失敗:
// 成功:{"ok":true, "file":"/photo_006.jpg", "size":45678}
// 失敗:{"ok":false, "msg":"no sd card"}
//
static esp_err_t save_sd_handler(httpd_req_t *req) {
httpd_resp_set_type(req, "application/json");
// 檢查:有沒有照片可以存?
if (!captured_buf || captured_len == 0) {
httpd_resp_send(req, "{\"ok\":false,\"msg\":\"no photo\"}", 28);
return ESP_OK;
}
// 檢查:SD 卡有沒有就緒?
if (!sd_ready) {
httpd_resp_send(req, "{\"ok\":false,\"msg\":\"no sd card\"}", 30);
return ESP_OK;
}
// 產生檔名,如果已存在就自動跳號,確保不會覆蓋舊照片
// 例如卡片裡已有 photo_001 ~ photo_005,
// 第一次 do-while 就會跳到 photo_006
char filepath[32];
do {
sd_photo_count++;
snprintf(filepath, sizeof(filepath), "/photo_%03d.jpg", sd_photo_count);
} while (SD_MMC.exists(filepath));
// 開啟檔案並寫入 JPEG 資料
// FILE_WRITE = 建立新檔或覆蓋(但我們已經用 exists() 確認不會覆蓋)
File file = SD_MMC.open(filepath, FILE_WRITE);
if (!file) {
Serial.println("SD 卡寫入失敗:無法開啟檔案");
sd_photo_count--; // 失敗就退回編號
httpd_resp_send(req, "{\"ok\":false,\"msg\":\"write failed\"}", 32);
return ESP_OK;
}
// 把暫存區的照片資料寫入檔案
size_t written = file.write(captured_buf, captured_len);
file.close();
if (written == captured_len) {
// 寫入成功,回傳檔名和檔案大小
Serial.printf("已存到 SD 卡:%s(%u bytes)\n", filepath, written);
char json[100];
snprintf(json, sizeof(json),
"{\"ok\":true,\"file\":\"%s\",\"size\":%u}", filepath, written);
httpd_resp_send(req, json, strlen(json));
} else {
// 寫入不完整(可能 SD 卡滿了)
Serial.println("SD 卡寫入不完整");
sd_photo_count--;
httpd_resp_send(req, "{\"ok\":false,\"msg\":\"incomplete write\"}", 36);
}
return ESP_OK;
}
// ============================================================
// 首頁 HTML — 串流畫面 + 拍照按鈕
// ============================================================
//
// 注意:用 static const 宣告 HTML 字串,放在全域記憶體而非 stack,
// 避免 httpd 的 stack(8192 bytes)被擠爆導致 crash。
//
static esp_err_t index_handler(httpd_req_t *req) {
static const char html[] = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32-CAM 網路串流 - 傑森創工</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#111;color:#eee;font-family:Arial,sans-serif;text-align:center}
h1{font-size:20px;padding:8px 0 2px;color:#e94560}
.sub{font-size:12px;color:#888;padding-bottom:6px}
img{width:100%;max-width:640px;display:block;margin:0 auto;border-radius:6px}
.btn{
display:inline-block;margin:10px 5px;padding:12px 28px;
font-size:15px;border:none;border-radius:6px;
cursor:pointer;color:#fff;background:#0f3460;text-decoration:none;
}
.btn:active{transform:scale(0.95)}
.btn:disabled{background:#333;cursor:not-allowed}
#status{color:#e94560;font-size:13px;margin-top:4px;min-height:20px}
</style>
</head>
<body>
<h1>ESP32-CAM 網路串流</h1>
<p class="sub">傑森創工</p>
<img id="stream" src="">
<div>
<button class="btn" id="captureBtn" onclick="takePhoto()">📷 拍照</button>
</div>
<p id="status"></p>
<script>
// 頁面載入時,自動開始串流(串流跑在 port 81)
window.onload=function(){
document.getElementById('stream').src='http://'+location.hostname+':81/stream';
}
// 拍照按鈕:呼叫 /capture API,成功後跳轉到預覽頁
function takePhoto(){
var btn=document.getElementById('captureBtn');
var st=document.getElementById('status');
btn.disabled=true;
btn.textContent='⏳ 拍照中...';
st.textContent='正在切換高畫質拍照,請稍候...';
fetch('/capture')
.then(function(r){return r.text()})
.then(function(d){
if(d==='ok') window.location.href='/preview';
else{st.textContent='拍照失敗';btn.disabled=false;btn.textContent='📷 拍照';}
})
.catch(function(){st.textContent='連線錯誤';btn.disabled=false;btn.textContent='📷 拍照';});
}
</script>
</body>
</html>
)rawliteral";
httpd_resp_set_type(req, "text/html");
return httpd_resp_send(req, html, strlen(html));
}
// ============================================================
// 預覽頁面 HTML — 三個按鈕:下載 / 存SD卡 / 返回
// ============================================================
static esp_err_t preview_handler(httpd_req_t *req) {
static const char html[] = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>照片預覽 - ESP32-CAM</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#111;color:#eee;font-family:Arial,sans-serif;text-align:center}
h1{font-size:20px;padding:8px 0 2px;color:#e94560}
.sub{font-size:12px;color:#888;padding-bottom:6px}
img{width:100%;max-width:640px;display:block;margin:0 auto;border-radius:6px}
.btn-row{margin:12px 0;display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
.btn{
display:inline-block;padding:12px 24px;
font-size:15px;border:none;border-radius:6px;
cursor:pointer;color:#fff;text-decoration:none;
}
.btn-download{background:#0f3460}
.btn-sd{background:#e67e22}
.btn-back{background:#555}
.btn:active{transform:scale(0.95)}
#msg{font-size:13px;margin-top:6px;min-height:20px;color:#2ecc71}
</style>
</head>
<body>
<h1>📷 照片預覽</h1>
<p class="sub">傑森創工</p>
<!-- /photo 回傳暫存的 JPEG 照片 -->
<img src="/photo">
<div class="btn-row">
<!-- download=1 讓瀏覽器跳出下載視窗 -->
<a class="btn btn-download" href="/photo?download=1">💾 下載照片</a>
<!-- 呼叫 /save_sd API 把照片寫入 SD 卡 -->
<button class="btn btn-sd" onclick="saveSD()">📂 存到 SD 卡</button>
<a class="btn btn-back" href="/">🔙 返回串流</a>
</div>
<p id="msg"></p>
<script>
// 存到 SD 卡:呼叫 /save_sd API,根據回傳的 JSON 顯示結果
function saveSD(){
var msg=document.getElementById('msg');
msg.textContent='⏳ 正在寫入 SD 卡...';
msg.style.color='#f39c12';
fetch('/save_sd')
.then(function(r){return r.json()})
.then(function(d){
if(d.ok){
msg.style.color='#2ecc71';
msg.textContent='✅ 已存到 SD 卡:'+d.file+'('+d.size+' bytes)';
} else {
msg.style.color='#e74c3c';
if(d.msg==='no sd card') msg.textContent='❌ 未偵測到 SD 卡,請確認已插入 FAT32 格式的卡片';
else if(d.msg==='no photo') msg.textContent='❌ 沒有照片可存,請先拍照';
else msg.textContent='❌ 寫入失敗:'+d.msg;
}
})
.catch(function(){
msg.style.color='#e74c3c';
msg.textContent='❌ 連線錯誤';
});
}
</script>
</body>
</html>
)rawliteral";
httpd_resp_set_type(req, "text/html");
return httpd_resp_send(req, html, strlen(html));
}
// ============================================================
// setup() — 程式啟動時執行一次
// ============================================================
void setup() {
Serial.begin(115200);
// --- 攝影機硬體初始化 ---
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;
// 【重要】用拍照的解析度初始化(OV2640 規則:初始化多大,之後最大只能到多大)
config.xclk_freq_hz = XCLK_FREQ;
config.pixel_format = PIXFORMAT_JPEG;
config.frame_size = CAPTURE_FRAME_SIZE;
config.jpeg_quality = STREAM_QUALITY;
config.fb_count = psramFound() ? 2 : 1;
config.grab_mode = CAMERA_GRAB_LATEST; // 永遠取最新的幀
if (esp_camera_init(&config) != ESP_OK) {
Serial.println("攝影機啟動失敗!請檢查排線");
return;
}
setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY); // 切回串流用低解析度
Serial.println("攝影機啟動成功");
// --- 初始化 SD 卡 ---
// SD 卡不是必須的,沒插卡也能用串流和下載功能
initSDCard();
// --- 連接 Wi-Fi ---
WiFi.begin(ssid, password);
WiFi.setSleep(false); // 關閉省電模式,串流更穩定
Serial.print("正在連接 Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi 已連線!");
// --- 啟動 Port 80 網頁伺服器(5 個路徑)---
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.stack_size = HTTPD_STACK_SIZE; // 加大 stack 避免 crash
cfg.max_uri_handlers = 8;
httpd_uri_t index_uri = { .uri = "/", .method = HTTP_GET, .handler = index_handler, .user_ctx = NULL };
httpd_uri_t capture_uri = { .uri = "/capture", .method = HTTP_GET, .handler = capture_handler, .user_ctx = NULL };
httpd_uri_t preview_uri = { .uri = "/preview", .method = HTTP_GET, .handler = preview_handler, .user_ctx = NULL };
httpd_uri_t photo_uri = { .uri = "/photo", .method = HTTP_GET, .handler = photo_handler, .user_ctx = NULL };
httpd_uri_t save_sd_uri = { .uri = "/save_sd", .method = HTTP_GET, .handler = save_sd_handler, .user_ctx = NULL };
if (httpd_start(&page_httpd, &cfg) == ESP_OK) {
httpd_register_uri_handler(page_httpd, &index_uri);
httpd_register_uri_handler(page_httpd, &capture_uri);
httpd_register_uri_handler(page_httpd, &preview_uri);
httpd_register_uri_handler(page_httpd, &photo_uri);
httpd_register_uri_handler(page_httpd, &save_sd_uri);
}
// --- 啟動 Port 81 串流伺服器 ---
cfg.server_port = 81;
cfg.ctrl_port += 1;
cfg.stack_size = HTTPD_STACK_SIZE;
httpd_uri_t stream_uri = { .uri = "/stream", .method = HTTP_GET, .handler = stream_handler, .user_ctx = NULL };
if (httpd_start(&stream_httpd, &cfg) == ESP_OK) {
httpd_register_uri_handler(stream_httpd, &stream_uri);
}
// 印出連線資訊
Serial.println("=========================================");
Serial.printf(" 開啟網頁:http://%s\n", WiFi.localIP().toString().c_str());
Serial.printf(" SD 卡狀態:%s\n", sd_ready ? "✅ 就緒" : "❌ 未偵測到");
Serial.println("=========================================");
}
// loop 裡不需要做任何事,伺服器在背景自動運作
void loop() {
delay(10000);
}
二、使用方式
上傳程式後,開啟 Serial Monitor(鮑率 115200),按 Reset,正常會看到:
攝影機啟動成功
SD 卡就緒!大小:29620 MB
SD 卡已有 5 張照片,下一張編號:6
正在連接 Wi-Fi..............
Wi-Fi 已連線!
=========================================
開啟網頁:http://192.168.1.143
SD 卡狀態:✅ 就緒
=========================================
用瀏覽器開啟網址後,操作流程:


三、SD 卡功能詳解
硬體原理
ESP32-CAM(AI-Thinker)板上已內建 Micro SD 卡插槽。SD 卡透過 SDMMC 介面連接 ESP32,共用部分 GPIO 腳位。本程式使用 1-bit MMC 模式 而非預設的 4-bit 模式,原因是 4-bit 模式會佔用 GPIO4(板載閃光燈的腳位),導致 SD 卡和閃光燈互相衝突。1-bit 模式只用 GPIO2 和 GPIO14,速度略慢但更穩定。
SD 卡初始化流程
開機
↓
SD_MMC.begin("/sdcard", true) ← true = 1-bit 模式
↓
成功?─── 否 → sd_ready = false,印出錯誤訊息
│ (串流和下載功能不受影響)
是
↓
檢查 cardType ─── CARD_NONE → 沒插卡
│
有卡
↓
sd_ready = true
↓
掃描根目錄所有 photo_XXX.jpg
↓
找出最大編號 → sd_photo_count
↓
下次存檔從 sd_photo_count + 1 開始
存檔防覆蓋機制
每次按下「📂 存到 SD 卡」時,程式不是直接用編號存檔,而是會先確認檔名不存在:
do {
sd_photo_count++;
snprintf(filepath, sizeof(filepath), "/photo_%03d.jpg", sd_photo_count);
} while (SD_MMC.exists(filepath)); // 如果檔名已存在就跳號
這個 do-while 迴圈確保即使你在電腦上手動把照片放回 SD 卡、或者從其他來源複製了同名檔案,程式都會自動跳過已存在的編號。例如:
| SD 卡內已有的檔案 | 程式會存成 |
|---|---|
| (空的) | photo_001.jpg |
| photo_001 ~ photo_005 | photo_006.jpg |
| photo_001, photo_003, photo_005 | photo_002.jpg(找到第一個空號) |
| photo_001 ~ photo_010,但 photo_007 被刪了 | photo_007.jpg |
API 回傳格式
/save_sd API 回傳 JSON 格式,讓前端網頁能正確顯示結果:
| 狀況 | 回傳的 JSON |
|---|---|
| 存檔成功 | {"ok":true, "file":"/photo_006.jpg", "size":45678} |
| 還沒拍照 | {"ok":false, "msg":"no photo"} |
| 沒有 SD 卡 | {"ok":false, "msg":"no sd card"} |
| 寫入失敗 | {"ok":false, "msg":"write failed"} |
| 寫入不完整 | {"ok":false, "msg":"incomplete write"} |
取出照片
拍照存好之後,關掉 ESP32-CAM 電源,把 Micro SD 卡拔出來插到電腦的讀卡機,就能看到根目錄下的 photo_001.jpg、photo_002.jpg 等檔案。

四、常見問題
| 問題 | 解決方法 |
|---|---|
| 上傳卡在 Connecting | 上傳底板:按住 IO0 → 按 Reset → 放開 IO0 |
| 攝影機啟動失敗 | 排線鬆了,打開卡扣重新壓好 |
| Wi-Fi 一直連不上 | 檢查 ssid 和 password 大小寫 |
| 畫面有水平條紋 | 供電不足,換 5V/2A 充電器 |
| SD 卡掛載失敗 | 確認格式為 FAT32,建議 4~32GB |
| SD 卡偵測不到 | 卡片沒插好,或接觸不良,重新插入 |
| 存到 SD 卡顯示寫入失敗 | SD 卡可能滿了,或卡片有寫入保護 |
| 照片編號沒有接續 | 正常現象,do-while 會跳過已存在的檔名 |
| 拍照後 crash | 確認 static const char html[] 和 cfg.stack_size = 8192 |
| 照片偏綠 | 程式已處理(丟掉前 3 幀),可增加到 5 幀 |
| 閃光燈不能用 | 1-bit MMC 模式不影響 GPIO4,閃光燈正常可用 |
五、自訂修改指南
| 要改什麼 | 改哪個參數 | 建議值 |
|---|---|---|
| 串流解析度 | STREAM_FRAME_SIZE |
QVGA 或 VGA |
| 串流品質 | STREAM_QUALITY |
10~15 |
| 拍照解析度 | CAPTURE_FRAME_SIZE |
VGA ~ UXGA |
| 拍照品質 | CAPTURE_QUALITY |
4~8 |
| 下載檔名 | PHOTO_FILENAME |
任意 .jpg 檔名 |
| 攝影機時鐘 | XCLK_FREQ |
10000000 或 20000000 |
| HTTP stack | HTTPD_STACK_SIZE |
8192(通常不用動) |
如果想把拍照解析度改回最大(1600×1200),只要改一行:
#define CAPTURE_FRAME_SIZE FRAMESIZE_UXGA
其他都不用動,程式會自動用 UXGA 初始化攝影機。
總結:
經過這三篇教學,大家已經把ESP3E2-CAM基本的操作都學會了!接下來就能夠以這些為基礎做更進階的應用了,大家加油!




