Bỏ qua, đến nội dung chính
Bài viếtFirmwareMới

ESP32 Memory Management Deep Dive: SRAM, PSRAM, Heap và Stack FreeRTOS

Bài hướng dẫn thực tế về layout SRAM, cách dùng PSRAM, phân mảnh heap, cấp phát stack cho task FreeRTOS và các mẹo tối ưu bộ nhớ hữu ích trong firmware ESP32.

Chia sẻ

LinkedInFacebookX
Technical workbench illustration showing an ESP32 memory map, heap blocks and a FreeRTOS task stack

Các lỗi bộ nhớ trên ESP32 hiếm khi xuất hiện thành một lỗi rõ ràng. Board vẫn boot, Wi-Fi vẫn kết nối, UI vẫn chạy, rồi vài tiếng sau, một buffer lớn không được cấp phát. Hoặc task chỉ crash khi audio và network cùng hoạt động.

Cách nghĩ hữu ích không phải là “còn bao nhiêu KB trống?”, mà là: tác vụ này cần loại memory nào, và hiện còn một block liền mạch đủ lớn hay không?

1. SRAM không phải một cái thùng duy nhất

ESP-IDF phân loại memory theo capability. Internal SRAM được dùng cho dữ liệu (DRAM), instruction cần chạy khi cache tắt (IRAM), dữ liệu tĩnh và internal heap. IROM/DROM nằm trên flash rất hữu ích cho code và constant, nhưng không thay thế được writable RAM.

Sơ đồ đơn giản về memory map ESP32 gồm code và constant trên flash, internal SRAM và workload trên PSRAM.

Tổng memory trống mới chỉ là một nửa câu chuyện; capability và block liền mạch lớn nhất cũng rất quan trọng.

Vì vậy hai con số sau có thể cùng đúng:

free internal heap: 72 KB
largest internal free block: 18 KB

Tổng cộng còn 72 KB, nhưng một request 24 KB liên tục vẫn fail. Với buffer, DMA và một số đường đi trong driver, largest free block thường đáng quan tâm hơn tổng heap trống.

Mình thường xem cả ba giá trị khi hệ thống đang tải:

#include "esp_heap_caps.h"

ESP_LOGI(TAG, "internal free=%u largest=%u low=%u",
    heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT),
    heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT),
    heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));

Low-water mark cho biết hệ thống từng tiến sát mức thấp nhất ra sao. Hãy log sau khi boot, kết nối Wi-Fi, mở màn hình và trong tác vụ nặng; đừng chỉ đo lúc board đang rảnh.

2. Dùng PSRAM đúng chỗ

PSRAM rất hợp cho dữ liệu lớn: camera frame, image buffer, vùng làm việc audio, JSON document, cache và model arena. Nó giúp board có thêm không gian, nhưng không có nghĩa là external RAM có latency và quy tắc truy cập giống internal RAM.

Mình thường giữ hot path ở internal memory. Các control structure nhỏ, DMA buffer, phần housekeeping của network và dữ liệu được truy cập trong đoạn timing-sensitive không nên chuyển mù quáng sang PSRAM. Khi flash operation tạm tắt cache, task liên quan cũng cần stack ở internal RAM; ESP-IDF có ghi rõ giới hạn này.

Khi cần chỉ rõ nơi cấp phát, viết rõ ý định trong code:

uint8_t *frame = heap_caps_malloc(frame_size,
                                  MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!frame) {
    ESP_LOGE(TAG, "no PSRAM buffer for %u bytes", frame_size);
    return ESP_ERR_NO_MEM;
}

Nếu allocation cần DMA hoặc executable memory, thì PSRAM thường không phù hợp. Hãy xem capability mà API của component yêu cầu, đừng mặc định mọi kết quả từ malloc() đều có thể thay thế cho nhau.

Một quy tắc thực tế: dùng PSRAM để chứa bulk data, không dùng nó để che một pattern cấp phát không kiểm soát. Chuyển memory leak từ SRAM sang PSRAM chỉ làm lỗi đến muộn hơn.

3. Fragmentation là hình dạng của vùng trống

Phân mảnh xảy ra khi allocation sống lâu và ngắn hạn bị trộn lẫn. Ví dụ quen thuộc là liên tục tạo string, JSON document hoặc packet có kích thước thay đổi trong khi vài buffer lớn vẫn giữ nguyên. Tổng memory còn trống nhìn có vẻ ổn, nhưng bị chia thành nhiều lỗ nhỏ.

Thử đơn giản nhất là ghi lại free bytes và largest free block trước/sau một workload:

static void log_heap(const char *where)
{
    size_t free8 = heap_caps_get_free_size(MALLOC_CAP_8BIT);
    size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT);
    ESP_LOGI(TAG, "%s: free=%u largest=%u", where, free8, largest);
}

Nếu free gần như giữ nguyên nhưng largest cứ giảm, hãy tìm allocation churn. Các cách sửa mình hay dùng khá nhàm chán nhưng hiệu quả:

  • Cấp phát buffer kích thước cố định một lần lúc startup.
  • Dùng lại scratch buffer thay vì cấp phát cho từng message.
  • Giao ownership của buffer cho một task rồi trả về qua queue.
  • Gom các object có cùng vòng đời, tránh trộn object tạm với object sống lâu.
  • Reserve capacity cho string/container nếu đã biết giới hạn trên.

Với leak hoặc overwrite khó tìm, bật heap poisoning hoặc heap tracing trong debug build. Heap tracing có thể chỉ ra caller của allocation và, tùy mode, nơi block được free. Nó có overhead nên mình không bật trong production build cần hiệu năng.

So sánh pattern cấp phát heap bị phân mảnh với pattern dùng fixed buffer pool ổn định hơn.

Dùng lại buffer giúp dữ liệu ngắn hạn không tạo ra các lỗ trống xen giữa object sống lâu.

4. Stack FreeRTOS cũng là allocation

Mỗi task đều cần stack. Trên ESP-IDF, stack của task thường được cấp phát từ heap, nên stack đặt quá lớn không phải chỉ là “chừa sẵn vùng riêng cho task”; nó còn ăn vào memory pool mà buffer sẽ cần.

Bắt đầu với một kích thước hợp lý, chạy task qua đúng workload rồi đo high-water mark:

void sensor_task(void *arg)
{
    for (;;) {
        read_sensor();
        ESP_LOGD("sensor", "stack spare=%u bytes",
                 uxTaskGetStackHighWaterMark(NULL));
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

Trên ESP-IDF, giá trị trả về được tính theo byte. Đây là lượng stack chưa dùng ít nhất từng được quan sát trong vòng đời của task. Hãy chạy qua JSON payload lớn nhất, log line dài nhất và cả error path trước khi kết luận margin đủ an toàn.

Mình tránh đặt local array quá lớn trong task. Một uint8_t packet[8192] có thể âm thầm ăn gần hết stack; buffer cấp phát có chủ đích sẽ dễ kiểm tra lifetime và capability hơn. Bật stack overflow checking trong debug config, nhưng nhớ rằng canary không bắt được mọi kiểu overflow.

Static allocation cũng đáng dùng khi vòng đời của task cố định. Cách này làm ownership rõ hơn và bỏ được một runtime heap operation, nhưng không làm memory biến mất—stack vẫn phải nằm ở đâu đó.

5. Vài mẹo tối ưu thường có hiệu quả

Các tối ưu đáng tiền nhất thường khá bình thường:

  • Đưa lookup table chỉ đọc lên flash.
  • Tránh copy cả struct nếu pointer hoặc message descriptor nhỏ là đủ.
  • Dùng ring buffer cho audio/serial streaming thay vì cấp phát từng chunk.
  • Đừng format log trong tight loop; chuỗi format có thể cần stack tạm khá lớn.
  • Dùng heap_caps_malloc() cho buffer quan trọng để capability mismatch lộ ra gần nguyên nhân.
  • Đo lại sau khi bật Wi-Fi, Bluetooth, display và OTA; mỗi component đều thay đổi ngân sách memory.

Đừng tối ưu chỉ dựa trên log lúc boot. Firmware còn 200 KB khi khởi động có thể có ngân sách hoàn toàn khác sau khi radio, TLS, display driver và các application task cùng sống.

Checklist memory runtime ESP32 gồm free heap, largest block, low-water mark, PSRAM và margin của task stack.

Đo ở các mốc workload thật, không chỉ ngay sau khi boot.

Workflow mình thường dùng

Đầu tiên, ghi lại internal free heap, largest free block, PSRAM free heap và stack high-water mark của từng task. Sau đó, đo lại ở các mốc có ý nghĩa: boot, Wi-Fi connected, mở màn hình, chạy xong một transaction và sau một giờ hoạt động bình thường.

Nếu lỗi xảy ra ở allocation lớn, so sánh tổng free bytes với largest block. Nếu largest block giảm dần, nghi fragmentation. Nếu cả hai cùng giảm, nghi leak hoặc object tăng không có giới hạn. Nếu heap ổn nhưng task crash, xem stack margin và local array.

Quản lý memory trên ESP32 bớt bí hiểm hẳn khi mỗi buffer đều trả lời được ba câu hỏi: nó cần capability nào, ai sở hữu và khi nào được release?** Đưa ba câu trả lời đó vào code và log, phần lớn lỗi “random” sẽ trở thành bài toán kỹ thuật rất bình thường.

References

Chia sẻ

LinkedInFacebookX

Tiếp tục khám phá

Đọc tiếp

Bài viết liên quan

Xem thêm trong Bài viết

Nastrotek sử dụng cookie để phân tích truy cập và cá nhân hóa quảng cáo, giúp hiểu cách website được sử dụng. Bạn có thể chấp nhận hoặc từ chối các cookie không thiết yếu. Chính sách bảo mật