ESP32-CAM là gì: sơ đồ chân, cách nạp code, xem video trên trình duyệt, chụp ảnh lưu thẻ nhớ
Giới thiệu mạch ESP32-CAM AI Thinker, sơ đồ chân, nạp code bằng đế MB hoặc USB-TTL, chương trình xem video MJPEG trên trình duyệt, chụp ảnh có đèn flash và lưu vào thẻ microSD.
ESP32-CAM là mạch ESP32 nhỏ cỡ bao diêm có sẵn camera, khe thẻ nhớ microSD và đèn flash, giá rẻ hơn nhiều so với camera IP. Mạch này hay được dùng làm camera giám sát tự làm, chuông cửa có hình, chụp ảnh định kỳ cây trồng, hoặc làm "mắt" cho xe robot. Bài này giới thiệu mạch, sơ đồ chân, cách nạp code (mạch không có cổng USB riêng), rồi viết chương trình xem video trực tiếp trên trình duyệt, chụp ảnh và lưu ảnh vào thẻ nhớ.
1. ESP32-CAM là gì

| Thành phần | Thông số thường gặp (loại AI Thinker) |
|---|---|
| Vi điều khiển | ESP32 hai nhân 240 MHz, Wi-Fi 2,4 GHz, Bluetooth |
| Bộ nhớ | Flash 4 MB, PSRAM 4 MB (cần cho ảnh lớn và luồng video) |
| Camera | OV2640 2 MP, ảnh tối đa 1600 × 1200; có bản dùng OV3660, OV5640 |
| Khe thẻ nhớ | microSD, nối kiểu SD_MMC |
| Đèn | Đèn flash trắng ở GPIO4, LED đỏ nhỏ ở GPIO33 |
| Nguồn | 5 V qua chân 5V, nên dùng nguồn chịu được 1–2 A |
| Cổng USB | Không có: nạp code bằng mạch USB-TTL hoặc đế nạp ESP32-CAM-MB |
So với ESP32 DevKit, ESP32-CAM ít chân trống hơn nhiều vì camera và thẻ nhớ đã chiếm phần lớn GPIO. Muốn vừa camera vừa nhiều cảm biến thì thường dùng ESP32-CAM lo phần hình ảnh và một mạch ESP32 khác lo phần còn lại.
2. Sơ đồ chân

- 5V, GND: cấp nguồn. Cấp 3,3 V vào chân 3V3 cũng được nhưng dễ bị sụt áp khi camera và Wi-Fi chạy cùng lúc.
- IO0: nối GND khi bật nguồn thì mạch vào chế độ nạp code. Khi chạy chương trình thì để hở.
- U0R, U0T: cổng Serial (GPIO3, GPIO1), dùng để nạp code và xem Serial Monitor.
- IO2, IO4, IO12, IO13, IO14, IO15: nối thẻ nhớ. Khi chạy thẻ nhớ ở chế độ 1 bit (như chương trình trong bài), IO4, IO12, IO13 được trả lại; IO4 thì dùng cho đèn flash.
- IO16: dành cho PSRAM trên mạch, không dùng.
- VCC: chân ra nguồn (3,3 V hoặc 5 V tùy miếng hàn trên mạch), không phải chân cấp vào.
3. Linh kiện cần chuẩn bị
- Mạch ESP32-CAM loại AI Thinker kèm camera OV2640.
- Đế nạp ESP32-CAM-MB (có cổng micro USB) hoặc một mạch USB-TTL CH340/CP2102 đặt ở 5 V.
- Dây USB nạp code loại có truyền dữ liệu và dây cắm cái–cái.
- Thẻ microSD (FAT32, 32 GB trở xuống) nếu muốn lưu ảnh.
- Khi lắp cố định: nguồn 5 V ổn định. Lấy từ adapter 12 V thì hạ áp bằng mạch hạ áp MP1584 hoặc LM2596 chỉnh ra 5 V.
4. Nạp code cho ESP32-CAM

- Trong Arduino IDE 2, chọn bo AI Thinker ESP32-CAM (Tools → Board → esp32). Phân vùng mặc định của bo này là Huge APP, đủ chỗ cho chương trình camera.
- Dùng đế ESP32-CAM-MB: cắm mạch vào đế, cắm USB, chọn cổng COM rồi bấm Upload. Đa số đế tự đưa mạch vào chế độ nạp; nếu báo lỗi kết nối thì giữ nút IO0 trên đế, bấm RST, nhả IO0 rồi nạp lại.
- Dùng mạch USB-TTL: nối dây như hình, nối IO0 với GND, bấm nút RST trên ESP32-CAM, rồi bấm Upload.
- Nạp xong: tháo dây IO0–GND (nếu có), bấm RST để chạy chương trình, mở Serial Monitor ở 115200.
Chưa cài gói ESP32 cho Arduino IDE thì làm theo bài cài ESP32 vào Arduino IDE 2. Bài dùng gói esp32 phiên bản 3.3.10, đã có sẵn thư viện camera.
5. Chương trình xem video, chụp ảnh, lưu thẻ nhớ
Sửa TEN_WIFI, MAT_KHAU_WIFI thành Wi-Fi 2,4 GHz nhà bạn rồi nạp:
// PYWORLD.VN - ESP32-CAM (AI Thinker): xem video trên trình duyệt, chụp ảnh JPEG, lưu ảnh vào thẻ microSD
// Chọn bo "AI Thinker ESP32-CAM"; trang chính ở cổng 80, luồng video MJPEG ở cổng 81
// Thẻ nhớ chạy chế độ 1 bit để chân GPIO4 (đèn flash) không bị dùng chung với thẻ
#include "esp_camera.h"
#include "esp_http_server.h"
#include <WiFi.h>
#include "FS.h"
#include "SD_MMC.h"
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const int CHAN_DEN_FLASH = 4;
// Chân camera OV2640 trên mạch AI Thinker ESP32-CAM
#define CHAN_PWDN 32
#define CHAN_RESET -1
#define CHAN_XCLK 0
#define CHAN_SIOD 26
#define CHAN_SIOC 27
#define CHAN_Y9 35
#define CHAN_Y8 34
#define CHAN_Y7 39
#define CHAN_Y6 36
#define CHAN_Y5 21
#define CHAN_Y4 19
#define CHAN_Y3 18
#define CHAN_Y2 5
#define CHAN_VSYNC 25
#define CHAN_HREF 23
#define CHAN_PCLK 22
httpd_handle_t mayChuTrang = NULL;
httpd_handle_t mayChuLuong = NULL;
bool coTheNho = false;
uint32_t soAnh = 0;
const char TRANG[] = R"HTML(<!doctype html><html lang="vi"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>ESP32-CAM</title>
<style>body{font-family:sans-serif;margin:12px;background:#111;color:#eee}img{width:100%;max-width:640px;border-radius:8px}
button{font-size:18px;padding:10px 16px;margin:8px 8px 0 0}</style>
<h2>ESP32-CAM</h2><img id="v"><br>
<button onclick="location.href='/chup'">Chụp ảnh</button>
<button onclick="location.href='/chup?den=1'">Chụp có đèn</button>
<button onclick="fetch('/luu').then(r=>r.text()).then(t=>alert(t))">Lưu vào thẻ nhớ</button>
<script>document.getElementById('v').src=location.protocol+'//'+location.hostname+':81/stream'</script></html>)HTML";
bool khoiDongCamera() {
camera_config_t c = {};
c.pin_pwdn = CHAN_PWDN;
c.pin_reset = CHAN_RESET;
c.pin_xclk = CHAN_XCLK;
c.pin_sccb_sda = CHAN_SIOD;
c.pin_sccb_scl = CHAN_SIOC;
c.pin_d7 = CHAN_Y9;
c.pin_d6 = CHAN_Y8;
c.pin_d5 = CHAN_Y7;
c.pin_d4 = CHAN_Y6;
c.pin_d3 = CHAN_Y5;
c.pin_d2 = CHAN_Y4;
c.pin_d1 = CHAN_Y3;
c.pin_d0 = CHAN_Y2;
c.pin_vsync = CHAN_VSYNC;
c.pin_href = CHAN_HREF;
c.pin_pclk = CHAN_PCLK;
c.xclk_freq_hz = 20000000;
c.ledc_timer = LEDC_TIMER_0;
c.ledc_channel = LEDC_CHANNEL_0;
c.pixel_format = PIXFORMAT_JPEG; // cảm biến tự nén JPEG, ESP32 chỉ việc gửi đi
c.frame_size = FRAMESIZE_VGA; // 640 x 480
c.jpeg_quality = 12; // 0-63, số nhỏ là ảnh đẹp hơn nhưng nặng hơn
c.fb_count = psramFound() ? 2 : 1;
c.fb_location = psramFound() ? CAMERA_FB_IN_PSRAM : CAMERA_FB_IN_DRAM;
c.grab_mode = CAMERA_GRAB_LATEST; // luôn lấy khung hình mới nhất
esp_err_t loi = esp_camera_init(&c);
if (loi != ESP_OK) {
Serial.printf("Loi khoi dong camera 0x%x: kiem tra cap camera, nguon 5V\n", loi);
return false;
}
return true;
}
// Đếm tiếp số thứ tự ảnh đã có trên thẻ, để khởi động lại không ghi đè ảnh cũ.
uint32_t soAnhLonNhat() {
uint32_t lonNhat = 0;
File thuMuc = SD_MMC.open("/");
for (File f = thuMuc.openNextFile(); f; f = thuMuc.openNextFile()) {
unsigned so;
if (sscanf(f.name(), "anh_%u.jpg", &so) == 1 && so > lonNhat) lonNhat = so;
f.close();
}
return lonNhat;
}
esp_err_t xuLyTrang(httpd_req_t *req) {
httpd_resp_set_type(req, "text/html");
return httpd_resp_send(req, TRANG, HTTPD_RESP_USE_STRLEN);
}
esp_err_t xuLyChup(httpd_req_t *req) {
char truyVan[16] = "";
bool batDen = httpd_req_get_url_query_str(req, truyVan, sizeof(truyVan)) == ESP_OK && strstr(truyVan, "den=1");
if (batDen) {
digitalWrite(CHAN_DEN_FLASH, HIGH);
delay(150); // chờ cảm biến chỉnh lại độ sáng
esp_camera_fb_return(esp_camera_fb_get()); // bỏ khung chụp lúc đèn chưa sáng hẳn
}
camera_fb_t *khung = esp_camera_fb_get();
digitalWrite(CHAN_DEN_FLASH, LOW);
if (!khung) {
httpd_resp_send_500(req);
return ESP_FAIL;
}
httpd_resp_set_type(req, "image/jpeg");
httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=anh.jpg");
esp_err_t kq = httpd_resp_send(req, (const char *)khung->buf, khung->len);
esp_camera_fb_return(khung);
return kq;
}
esp_err_t xuLyLuu(httpd_req_t *req) {
char traLoi[64];
if (!coTheNho) {
snprintf(traLoi, sizeof(traLoi), "Khong co the nho");
} else {
camera_fb_t *khung = esp_camera_fb_get();
if (!khung) {
snprintf(traLoi, sizeof(traLoi), "Loi chup anh");
} else {
char ten[24];
snprintf(ten, sizeof(ten), "/anh_%04lu.jpg", (unsigned long)++soAnh);
File f = SD_MMC.open(ten, FILE_WRITE);
bool duoc = f && f.write(khung->buf, khung->len) == khung->len;
if (f) f.close();
snprintf(traLoi, sizeof(traLoi), duoc ? "Da luu %s (%u byte)" : "Loi ghi %s", ten, (unsigned)khung->len);
esp_camera_fb_return(khung);
Serial.println(traLoi);
}
}
httpd_resp_set_type(req, "text/plain");
return httpd_resp_send(req, traLoi, HTTPD_RESP_USE_STRLEN);
}
// Luồng MJPEG: gửi liên tục từng ảnh JPEG, ngăn cách bằng một chuỗi biên (boundary).
esp_err_t xuLyLuong(httpd_req_t *req) {
static const char *BIEN = "\r\n--khunghinh\r\n";
httpd_resp_set_type(req, "multipart/x-mixed-replace;boundary=khunghinh");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char dau[64];
while (true) {
camera_fb_t *khung = esp_camera_fb_get();
if (!khung) return ESP_FAIL;
int n = snprintf(dau, sizeof(dau), "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", (unsigned)khung->len);
esp_err_t kq = httpd_resp_send_chunk(req, BIEN, strlen(BIEN));
if (kq == ESP_OK) kq = httpd_resp_send_chunk(req, dau, n);
if (kq == ESP_OK) kq = httpd_resp_send_chunk(req, (const char *)khung->buf, khung->len);
esp_camera_fb_return(khung);
if (kq != ESP_OK) return kq; // trình duyệt đóng trang: dừng luồng
}
}
void khoiDongMayChu() {
httpd_config_t cauHinh = HTTPD_DEFAULT_CONFIG();
httpd_uri_t trang = {"/", HTTP_GET, xuLyTrang, NULL};
httpd_uri_t chup = {"/chup", HTTP_GET, xuLyChup, NULL};
httpd_uri_t luu = {"/luu", HTTP_GET, xuLyLuu, NULL};
if (httpd_start(&mayChuTrang, &cauHinh) == ESP_OK) {
httpd_register_uri_handler(mayChuTrang, &trang);
httpd_register_uri_handler(mayChuTrang, &chup);
httpd_register_uri_handler(mayChuTrang, &luu);
}
cauHinh.server_port = 81; // luồng video chạy máy chủ riêng để trang chính không bị chặn
cauHinh.ctrl_port += 1;
httpd_uri_t luong = {"/stream", HTTP_GET, xuLyLuong, NULL};
if (httpd_start(&mayChuLuong, &cauHinh) == ESP_OK) httpd_register_uri_handler(mayChuLuong, &luong);
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_DEN_FLASH, OUTPUT);
digitalWrite(CHAN_DEN_FLASH, LOW);
Serial.printf("PSRAM: %s\n", psramFound() ? "co" : "khong");
if (!khoiDongCamera()) return;
coTheNho = SD_MMC.begin("/sdcard", true); // true: chế độ 1 bit
if (coTheNho) soAnh = soAnhLonNhat();
Serial.println(coTheNho ? "Da gan the nho" : "Khong thay the nho (van xem, chup duoc)");
WiFi.mode(WIFI_STA);
WiFi.setSleep(false); // tắt tiết kiệm điện Wi-Fi cho video mượt hơn
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
Serial.print("Dang vao Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nMo trinh duyet: http://%s\n", WiFi.localIP().toString().c_str());
khoiDongMayChu();
}
void loop() {
delay(1000); // máy chủ HTTP chạy trong task riêng
}
6. Trang web và hai máy chủ HTTP

Mở Serial Monitor để xem địa chỉ IP, rồi gõ địa chỉ đó vào trình duyệt của máy tính hoặc điện thoại cùng mạng Wi-Fi. Trang hiện video trực tiếp và ba nút: Chụp ảnh mở một ảnh JPEG độ phân giải 640 × 480, Chụp có đèn bật đèn flash lúc chụp, Lưu vào thẻ nhớ ghi ảnh thành file anh_0001.jpg, anh_0002.jpg…
7. Giải thích các phần chính
camera_config_t: khai báo các chân nối camera của mạch AI Thinker, tần số xung nhịp 20 MHz, kiểu ảnh JPEG (cảm biến tự nén), cỡ ảnh VGA và chất lượng nén 12. Mạch ESP32-CAM của hãng khác có thể nối chân khác: phải lấy đúng bảng chân của mạch đó.- PSRAM: có PSRAM thì chương trình giữ 2 bộ đệm khung hình trong PSRAM, camera chụp khung sau trong lúc khung trước đang gửi đi.
CAMERA_GRAB_LATESTluôn đưa ra khung mới nhất nên video ít bị trễ. - Luồng MJPEG: trình duyệt nhận một dòng dữ liệu kiểu
multipart/x-mixed-replace, trong đó các ảnh JPEG nối tiếp nhau, ngăn bởi chuỗi biên--khunghinh. Ảnh sau thay ảnh trước nên ta thấy thành video. - Hai máy chủ: luồng video không bao giờ kết thúc nên chiếm trọn kết nối của nó. Đặt luồng ở máy chủ riêng (cổng 81) thì các nút ở cổng 80 vẫn trả lời được. Đây cũng là cách làm của ví dụ CameraWebServer trong gói esp32.
- Thẻ nhớ 1 bit:
SD_MMC.begin("/sdcard", true)chỉ dùng một đường dữ liệu. Tốc độ ghi chậm hơn chế độ 4 bit nhưng đủ cho ảnh, và chân GPIO4 không bị thẻ nhớ dùng chung nên đèn flash không chớp mỗi lần ghi. soAnhLonNhat()đọc tên các file đã có trên thẻ để đánh số tiếp, khởi động lại không ghi đè ảnh cũ.WiFi.setSleep(false)tắt chế độ tiết kiệm điện của Wi-Fi: tốn điện hơn nhưng video ít giật.
8. Kết quả trên Serial Monitor

9. Chỉnh cho ảnh đẹp hơn
- Cỡ ảnh: đổi
FRAMESIZE_VGAthànhFRAMESIZE_SVGA(800 × 600),FRAMESIZE_XGA(1024 × 768) hoặcFRAMESIZE_UXGA(1600 × 1200). Ảnh càng lớn thì video càng ít khung hình mỗi giây. - Chất lượng JPEG:
jpeg_qualitytừ 10 đến 15 là hợp lý; nhỏ hơn 10 ảnh nặng, dễ hết bộ nhớ. - Lấy nét: ống kính OV2640 xoay được để chỉnh nét, thường dính keo cố định; muốn chụp gần dưới 20 cm thì cạo keo và xoay nhẹ.
- Ánh sáng: cảm biến cần vài giây để tự chỉnh độ sáng sau khi bật. Trong tối, đèn flash chỉ chiếu tốt trong khoảng 1 m.
- Anten: sóng yếu thì video giật. Mạch có chỗ gắn anten rời (IPEX) nhưng phải chuyển một điện trở 0 Ω trên mạch sang vị trí anten rời mới dùng được.
10. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Nạp báo "Failed to connect", "Wrong boot mode" | IO0 chưa nối GND lúc reset, đảo TX/RX, mạch USB-TTL đặt 3,3 V | Nối IO0–GND rồi bấm RST; TX → U0R, RX → U0T; đặt USB-TTL ở 5 V |
| "Brownout detector was triggered", mạch khởi động lại liên tục | Nguồn yếu: cổng USB máy tính, dây USB dài, dây cắm nhỏ | Dùng nguồn 5 V từ 1 A trở lên, dây ngắn; thêm tụ 470–1000 µF giữa 5V và GND |
| "Loi khoi dong camera 0x105" | Cáp camera chưa cắm hết, chưa gài khóa; chọn sai bo nên sai chân | Mở khóa đầu nối, cắm lại cáp đúng chiều, gài khóa; chọn bo AI Thinker ESP32-CAM |
| Trang mở được nhưng không có video | Mạng chặn cổng 81, đang có trình duyệt khác xem | Đóng các tab đang xem; thử mở thẳng http://<IP>:81/stream |
| "Khong thay the nho" | Thẻ trên 32 GB hoặc định dạng exFAT, thẻ cắm lệch | Định dạng FAT32, dùng thẻ 4–32 GB |
| Ảnh có sọc, màu tím xanh | Thiếu PSRAM hoặc xung nhịp cao với nguồn yếu | Kiểm tra dòng "PSRAM: co"; giảm xclk_freq_hz xuống 10000000 |
11. Xem thêm
- Module thẻ nhớ Micro SD với ESP32: cách đọc ghi file trên thẻ.
- Cảm biến chuyển động PIR với ESP32: dùng PIR để ESP32-CAM chỉ chụp khi có người.
- ESP32 làm web server: cách tạo trang web điều khiển trên ESP32.
- Bài dự án chuông cửa ESP32-CAM gửi ảnh qua Telegram trong cùng loạt dùng lại chương trình chụp ảnh của bài này.
Xem thêm