Mục Tiêu Project
Xây dựng một camera viewer hiển thị live feed từ OV2640 lên màn hình LCD ST7789 (240×240), dùng kiến trúc Dual-Core FreeRTOS và PSRAM framebuffer. Project này áp dụng tất cả kiến thức từ các bài trước: DMA, PSRAM, Queue, Dual-Core, Memory management.
Phần cứng:
- ESP32-S3-DevKitC-1 (8 MB PSRAM, 16 MB Flash)
- Camera OV2640 (module với connector 24-pin)
- LCD ST7789 240×240 SPI (ví dụ: 1.3 inch round display)
- Breadboard + dây jumper
Kiến Trúc Phần Mềm
Core 0 (PRO_CPU):
├── WiFi stack (tự động)
├── cameraTask: capture frame → PSRAM → frameQueue
└── networkTask: stream JPEG qua HTTP (tuỳ chọn)
Core 1 (APP_CPU):
├── displayTask: nhận frame từ queue → DMA SPI → LCD
└── statsTask: in FPS, memory mỗi 5 giây
Shared:
├── frameQueue: QueueHandle_t, chứa pointer frame
├── displayMutex: bảo vệ SPI bus
└── PSRAM: frame buffer pool (double buffering)
Sơ Đồ Kết Nối
Camera OV2640 → ESP32-S3
| Camera Pin | ESP32-S3 GPIO | Ghi Chú |
|---|---|---|
| PWDN | -1 | Không dùng |
| RESET | -1 | Không dùng |
| XCLK | GPIO15 | 20 MHz clock |
| SIOD (SDA) | GPIO4 | I2C data |
| SIOC (SCL) | GPIO5 | I2C clock |
| D7–D0 | GPIO16,17,18,12,10,8,9,11 | Parallel data |
| VSYNC | GPIO6 | Frame sync |
| HREF | GPIO7 | Line sync |
| PCLK | GPIO13 | Pixel clock |
LCD ST7789 → ESP32-S3 (SPI)
| LCD Pin | ESP32-S3 GPIO | Ghi Chú |
|---|---|---|
| SCK | GPIO36 | SPI Clock |
| MOSI | GPIO35 | SPI Data |
| CS | GPIO34 | Chip Select |
| DC | GPIO33 | Data/Command |
| RST | GPIO37 | Reset |
| BL | GPIO38 | Backlight |
Code Đầy Đủ
#include <Arduino.h>
#include "esp_camera.h"
#include <Arduino_GFX_Library.h>
#include <JPEGDecoder.h>
// ── Cấu Hình LCD ─────────────────────────────────────────────────────────────
#define LCD_SCK 36
#define LCD_MOSI 35
#define LCD_CS 34
#define LCD_DC 33
#define LCD_RST 37
#define LCD_BL 38
#define LCD_W 240
#define LCD_H 240
Arduino_DataBus* bus = new Arduino_ESP32SPI(LCD_DC, LCD_CS, LCD_SCK, LCD_MOSI,
GFX_NOT_DEFINED, VSPI, true); // DMA
Arduino_GFX* gfx = new Arduino_ST7789(bus, LCD_RST, 0, true, LCD_W, LCD_H);
// ── Camera Config ─────────────────────────────────────────────────────────────
camera_config_t cam_config = {
.pin_pwdn = -1, .pin_reset = -1,
.pin_xclk = 15,
.pin_sccb_sda = 4, .pin_sccb_scl = 5,
.pin_d7 = 16, .pin_d6 = 17, .pin_d5 = 18, .pin_d4 = 12,
.pin_d3 = 10, .pin_d2 = 8, .pin_d1 = 9, .pin_d0 = 11,
.pin_vsync = 6, .pin_href = 7, .pin_pclk = 13,
.xclk_freq_hz = 20000000,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0,
.pixel_format = PIXFORMAT_JPEG,
.frame_size = FRAMESIZE_240X240, // 240×240 khớp với LCD
.jpeg_quality = 15, // 0-63: thấp hơn = tốt hơn
.fb_count = 2, // Double buffering
.fb_location = CAMERA_FB_IN_PSRAM, // Frame buffer trong PSRAM
.grab_mode = CAMERA_GRAB_WHEN_EMPTY,
};
// ── Queue và Stats ────────────────────────────────────────────────────────────
QueueHandle_t frameQueue;
volatile uint32_t captureCount = 0;
volatile uint32_t displayCount = 0;
// ── Task: Camera Capture (Core 0) ─────────────────────────────────────────────
void cameraTask(void* p) {
camera_fb_t* fb;
camera_fb_t* prevFb = nullptr;
while (true) {
fb = esp_camera_fb_get();
if (!fb) {
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
// Gửi frame vào queue, không chờ nếu queue đầy (drop frame)
if (xQueueSend(frameQueue, &fb, 0) != pdPASS) {
// Queue đầy → display task chậm hơn capture → drop frame
esp_camera_fb_return(fb);
} else {
captureCount++;
}
vTaskDelay(pdMS_TO_TICKS(1)); // Nhường CPU một chút
}
}
// ── Task: LCD Display (Core 1) ────────────────────────────────────────────────
// Buffer JPEG decode trong PSRAM
uint16_t* jpegDecodeBuffer = nullptr;
void displayTask(void* p) {
// Cấp phát decode buffer 1 lần
jpegDecodeBuffer = (uint16_t*)ps_malloc(LCD_W * LCD_H * 2);
if (!jpegDecodeBuffer) {
Serial.println("FATAL: Không đủ PSRAM cho decode buffer!");
vTaskDelete(NULL);
return;
}
camera_fb_t* fb;
while (true) {
// Chờ frame từ camera task
if (xQueueReceive(frameQueue, &fb, pdMS_TO_TICKS(1000)) == pdPASS) {
// Decode JPEG → RGB565
if (JpegDec.decodeArray(fb->buf, fb->len)) {
uint16_t* pImg = jpegDecodeBuffer;
uint32_t pixelCount = 0;
while (JpegDec.read()) {
uint16_t* pSrc = JpegDec.pImage;
for (int i = 0; i < JpegDec.MCUWidth * JpegDec.MCUHeight; i++) {
if (pixelCount < (uint32_t)(LCD_W * LCD_H)) {
// Convert RGB888 → RGB565
uint16_t pixel = *pSrc++;
pImg[pixelCount++] = ((pixel & 0xF800) >> 0) |
((pixel & 0x07E0) << 0) |
((pixel & 0x001F) << 0);
}
}
}
// Đẩy toàn bộ framebuffer lên LCD qua DMA SPI
gfx->draw16bitRGBBitmap(0, 0, jpegDecodeBuffer, LCD_W, LCD_H);
displayCount++;
}
// Trả frame buffer cho camera
esp_camera_fb_return(fb);
}
}
}
// ── Task: Stats mỗi 5 giây (Core 1, priority thấp) ──────────────────────────
void statsTask(void* p) {
uint32_t lastCapture = 0, lastDisplay = 0;
uint32_t lastTime = millis();
while (true) {
vTaskDelay(pdMS_TO_TICKS(5000));
uint32_t now = millis();
uint32_t elapsed = (now - lastTime) / 1000;
float captureFPS = (float)(captureCount - lastCapture) / elapsed;
float displayFPS = (float)(displayCount - lastDisplay) / elapsed;
Serial.printf("[Stats] Capture: %.1f fps | Display: %.1f fps\n",
captureFPS, displayFPS);
Serial.printf("[Mem] Heap: %lu KB | PSRAM: %lu KB\n",
ESP.getFreeHeap() / 1024,
ESP.getFreePsram() / 1024);
lastCapture = captureCount;
lastDisplay = displayCount;
lastTime = now;
}
}
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(1000);
// Kiểm tra PSRAM
if (!psramFound()) {
Serial.println("FATAL: Không có PSRAM! Cần board có PSRAM.");
while (1) delay(1000);
}
Serial.printf("PSRAM: %lu MB\n", ESP.getPsramSize() / (1024 * 1024));
// Khởi tạo LCD
pinMode(LCD_BL, OUTPUT);
digitalWrite(LCD_BL, HIGH);
gfx->begin(40000000); // 40 MHz SPI
gfx->fillScreen(BLACK);
gfx->setTextColor(WHITE);
gfx->setTextSize(2);
gfx->setCursor(20, 100);
gfx->print("Initializing...");
// Khởi tạo camera
esp_err_t err = esp_camera_init(&cam_config);
if (err != ESP_OK) {
Serial.printf("Camera init failed: 0x%x\n", err);
gfx->fillScreen(RED);
gfx->setCursor(10, 100);
gfx->printf("Camera fail: %x", err);
while (1) delay(1000);
}
// Điều chỉnh cài đặt camera
sensor_t* s = esp_camera_sensor_get();
s->set_vflip(s, 1); // Lật dọc nếu ảnh ngược
s->set_hmirror(s, 0); // Mirror ngang
s->set_quality(s, 15); // JPEG quality
// Queue chứa tối đa 2 frame pointer
frameQueue = xQueueCreate(2, sizeof(camera_fb_t*));
// Tạo tasks
xTaskCreatePinnedToCore(cameraTask, "Camera", 4096, NULL, 4, NULL, 0);
xTaskCreatePinnedToCore(displayTask, "Display", 8192, NULL, 3, NULL, 1);
xTaskCreatePinnedToCore(statsTask, "Stats", 2048, NULL, 1, NULL, 1);
Serial.println("Setup complete!");
}
void loop() {
vTaskDelay(portMAX_DELAY);
}
Kết Quả và Tối Ưu
Với cấu hình trên, bạn sẽ đạt được khoảng:
- Capture: 15–20 fps (JPEG 240×240 quality 15)
- Display: 12–18 fps (giới hạn bởi JPEG decode time)
- Heap free: ~180 KB (hầu hết PSRAM)
Tăng FPS:
- Tăng JPEG quality (số nhỏ hơn) → file nhỏ hơn → decode nhanh hơn
- Dùng
FRAMESIZE_QVGA(320×240) thay vì 240×240 nếu LCD hỗ trợ - Thử thư viện JPEG decode nhanh hơn (libjpeg-turbo port)
Tổng Kết
| Thành Phần | Vai Trò | Core |
|---|---|---|
| cameraTask | I2S DMA capture → frameQueue | 0 |
| displayTask | JPEG decode → SPI DMA → LCD | 1 |
| statsTask | Monitor FPS và memory | 1 |
| PSRAM | Frame buffer + decode buffer | – |
| frameQueue | Tách producer và consumer | – |
Bài tiếp theo: Bài 10 — Firmware IoT Production Cho ESP32-S3: OTA, Health Monitoring và State Machine — Thiết kế firmware production-ready: OTA update, watchdog, error recovery, logging và device state.
📚 Series: Sức Mạnh ESP32-S3 Dual-Core
⬅️ Bài trước: Kỹ thuật xử lý đa nhân trên ESP32-S3: Dual-Core, FreeRTOS và ứng dụng thực tế trong IoT
➡️ Bài tiếp theo: S3 Dual-Core – Bài 10: Firmware IoT Production – OTA Health Monitor


