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

18 lỗi ESP32 thường gặp – Phần 2: Các lỗi nghiêm trọng

Cách mình đọc backtrace và xử lý crash, watchdog, heap, stack, FreeRTOS, Wi-Fi, ADC, deep sleep và OTA trên ESP32 theo một checklist runtime dễ áp dụng.

Chia sẻ

LinkedInFacebookX
ESP32 development board beside a laptop showing a runtime crash log on a practical electronics workbench

phần 1, mình đi từ nguồn, BOOT, serial đến GPIO và bus. Nhưng có một nhóm lỗi khó chịu hơn: board đã boot, chạy được vài phút hoặc vài ngày rồi mới crash, treo, mất mạng hoặc cập nhật OTA thất bại.

Với nhóm này, thay linh kiện ngẫu nhiên thường không giúp nhiều. Mình cần giữ lại bằng chứng theo đúng thứ tự:

Reset reason → backtrace → watchdog → heap/stack → task → network → power → OTA

Bài này dùng tên API của ESP-IDF vì chúng cho mình nhiều dữ liệu chẩn đoán nhất. Nếu bạn dùng Arduino-ESP32, phần lớn nguyên nhân vẫn giống nhau vì framework chạy trên cùng ESP-IDF và FreeRTOS bên dưới.

1. Guru Meditation và backtrace: đừng dừng ở dòng đầu tiên

Board ESP32 nối với laptop đang hiển thị crash log và backtrace tại bàn làm việc điện tử.

Giữ lại full log và đúng file ELF giúp biến một địa chỉ crash thành dòng code có thể kiểm tra.

Một log kiểu này đã chứa nhiều thông tin hơn câu “ESP32 tự reset”:

Guru Meditation Error: Core 1 panicked (LoadProhibited)
EXCVADDR: 0x00000000
Backtrace: 0x400d2abc:0x3ffb1f20 0x400d31f1:0x3ffb1f50 ...

Ba lỗi mình gặp nhiều nhất là:

  • LoadProhibited: CPU đọc từ địa chỉ không hợp lệ.
  • StoreProhibited: CPU ghi vào địa chỉ không hợp lệ.
  • IllegalInstruction: CPU cố chạy một instruction không hợp lệ; một nguyên nhân quen thuộc là task function kết thúc bằng return thay vì tự xóa bằng vTaskDelete(NULL). Lỗi flash, con trỏ hàm hỏng hoặc stack bị ghi đè cũng có thể dẫn tới đây.

Hãy nhìn thêm EXCVADDR. Giá trị 0x00000000 thường là dereference con trỏ null. Một địa chỉ rất gần 0 có thể truy cập field của một struct thông qua con trỏ null. Địa chỉ rác ở vùng lạ thường gợi ý con trỏ chưa khởi tạo, bị ghi đè hoặc đã free nhưng vẫn còn được dùng.

Ba mẫu code dễ gây crash là:

sensor_t *sensor = NULL;
printf("%d", sensor->value);       // null pointer

samples[count] = value;            // count vượt kích thước mảng

free(frame);
send_frame(frame);                 // use-after-free

Backtrace chỉ hữu ích khi được giải mã bằng đúng file .elf của firmware đang chạy. idf.py monitor thường tự đổi địa chỉ thành tên hàm và dòng code. Nếu giải mã thủ công trên ESP32 classic, bạn có thể dùng:

xtensa-esp32-elf-addr2line -pfiaC -e build/app.elf 0x400d2abc

ESP32-C3 dùng toolchain RISC-V, còn S3 dùng toolchain Xtensa tương ứng. Đừng lấy ELF của build mới để đọc crash của binary cũ; địa chỉ có thể vẫn ra tên hàm nhưng sai hoàn toàn.

Khi đọc stack trace, mình bỏ qua các frame của panic handler rồi tìm frame đầu tiên thuộc code ứng dụng. Sau đó, kiểm tra tham số, lifetime của buffer và mọi phép tính index quanh dòng đó. Với use-after-free hoặc out-of-bounds khó bắt, heap poisoning, heap integrity check và watchpoint thường hiệu quả hơn việc thêm log khắp nơi.

2. Task Watchdog: task nào đang không nhường CPU?

Laptop chạy công cụ debug FreeRTOS cạnh board ESP32 và logic analyzer trên bàn lab thực tế.

Watchdog thường là dấu hiệu task, callback hoặc critical section đã giữ CPU quá lâu.

Task Watchdog thường báo rằng một task chạy quá lâu khiến Idle Task không có cơ hội chạy. Bốn nguyên nhân quen thuộc là:

  • Vòng lặp tính toán không gọi API block/yield.
  • Chờ mutex, socket hoặc I/O với timeout vô hạn.
  • Callback làm decode ảnh, parse JSON lớn hoặc ghi flash ngay trong callback.
  • Critical section giữ quá lâu, đặc biệt khi có thao tác chậm bên trong.

Ví dụ này nhìn đơn giản nhưng có thể chiếm CPU mãi:

while (true) {
    process_next_audio_sample();
}

Thêm vTaskDelay(1) có thể làm watchdog im, nhưng chưa chắc sửa đúng kiến trúc. Với dữ liệu liên tục, mình chia việc thành block nhỏ, dùng queue để chờ dữ liệu và để task block khi không có gì làm:

audio_block_t block;
while (xQueueReceive(audio_queue, &block, pdMS_TO_TICKS(1000))) {
    process_audio_block(&block);
}

Callback mạng, timer và event handler nên làm phần việc ngắn: copy metadata cần thiết, gửi message sang queue rồi thoát. Phần encode, filesystem hoặc HTTP chạy trong worker task riêng.

Tăng timeout watchdog chỉ hợp lý khi workload dài là có chủ đích, chẳng hạn erase một vùng flash lớn và bạn đã đo thời gian worst case. Nếu task kẹt mutex, reconnect vô hạn hoặc spinloop, timeout lớn hơn chỉ khiến sản phẩm treo lâu hơn trước khi tự cứu.

3. Thiếu heap và phân mảnh bộ nhớ

Heap có thể hỏng theo ba kiểu khác nhau:

  1. Tổng bộ nhớ thật sự đã cạn.
  2. Có rò rỉ: mỗi chu kỳ mất thêm một ít và không lấy lại.
  3. Tổng free heap còn khá nhiều nhưng bị chia thành các block nhỏ, không có block nào đủ cho allocation tiếp theo.

JSON document, framebuffer ảnh, audio buffer, TLS và response HTTP thường tạo allocation lớn. Điều đầu tiên mình làm là kiểm tra mọi kết quả cấp phát:

uint8_t *frame = heap_caps_malloc(frame_size, MALLOC_CAP_8BIT);
if (frame == NULL) {
    ESP_LOGE(TAG, "frame alloc failed: %u bytes", (unsigned) frame_size);
    return ESP_ERR_NO_MEM;
}

Sau đó log ba con số thay vì chỉ log free heap:

ESP_LOGI(TAG, "free=%u min=%u largest=%u",
    (unsigned) heap_caps_get_free_size(MALLOC_CAP_8BIT),
    (unsigned) heap_caps_get_minimum_free_size(MALLOC_CAP_8BIT),
    (unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));

minimum_free_size cho biết đáy thấp nhất từ lúc boot. largest_free_block cho biết allocation đơn lớn nhất có thể thành công ở thời điểm đó. Nếu free heap là 80 KB nhưng largest block chỉ 12 KB, một buffer 32 KB vẫn thất bại.

Mình thường giảm phân mảnh bằng cách:

  • Cấp phát buffer dài hạn một lần lúc khởi động rồi tái sử dụng.
  • Parse hoặc stream theo chunk thay vì giữ toàn bộ JSON, ảnh hay audio trong RAM.
  • Tránh tạo và hủy hàng loạt object có kích thước khác nhau trong loop nóng.
  • Quy định rõ component nào sở hữu buffer và ai có trách nhiệm free.
  • Dùng heap tracing quanh đúng luồng nghi ngờ thay vì bật log allocation cho toàn bộ firmware.

Nếu free heap giảm sau mỗi reconnect, mỗi request hoặc mỗi lần mở màn hình, thì đó giống leak hơn fragmentation. Nếu free heap dao động nhưng largest block giảm dần, fragmentation đáng nghi hơn.

4. Tràn stack và dùng PSRAM sai chỗ

Stack của task thường bị ăn nhanh bởi biến cục bộ lớn, chuỗi call sâu, recursion hoặc hàm thư viện đặt buffer tạm trên stack:

void render_task(void *arg) {
    uint8_t line_buffer[24 * 1024]; // quá lớn cho nhiều task stack
    // ...
}

Buffer lớn nên chuyển thành static, cấp phát trên heap hoặc chia nhỏ. Đừng chỉ tăng stack của mọi task lên gấp bốn; internal RAM sẽ biến mất rất nhanh.

Mình kiểm tra khoảng stack nhỏ nhất còn lại bằng:

UBaseType_t remaining = uxTaskGetStackHighWaterMark(NULL);
ESP_LOGI(TAG, "stack high-water mark: %u", (unsigned) remaining);

Giá trị càng gần 0 thì task càng sát tràn stack. Hãy đo khi workload nặng nhất: TLS handshake, decode frame lớn, JSON sâu hoặc logging dày. Trong ESP-IDF, tham số stack khi tạo task được tính theo byte, khác với Vanilla FreeRTOS thường tính theo word.

PSRAM phù hợp với framebuffer lớn, audio cache, model data và payload ít cần DMA. Nhưng mình không xem nó là “RAM miễn phí”:

  • Task stack mặc định vẫn ưu tiên internal RAM.
  • DMA descriptor và nhiều DMA buffer cần memory capability phù hợp, thường là internal/DMA-capable RAM.
  • PSRAM có thể không truy cập được khi flash cache bị tắt.
  • Dữ liệu nhỏ nhưng truy cập liên tục có thể chạy chậm hơn và đẩy dữ liệu flash khỏi cache.
  • Mọi allocation MALLOC_CAP_SPIRAM vẫn phải kiểm tra NULL.

Một cách chia thực tế là giữ queue control, task stack, DMA và state nóng trong internal RAM; đưa frame ảnh, audio block lớn và cache ít truy cập sang PSRAM.

5. FreeRTOS và lỗi đồng thời

Hai task đọc cùng một buffer có thể vẫn ổn. Một task ghi trong lúc task kia đọc mới là lúc lỗi “thi thoảng” xuất hiện: frame bị rách, JSON hỏng, pointer đổi giữa chừng hoặc crash chỉ khi Wi-Fi bận.

Mình chọn primitive theo ý nghĩa dữ liệu:

  • Mutex khi một resource có một owner tại một thời điểm.
  • Queue khi cần chuyển message hoặc chuyển quyền sở hữu buffer giữa các task.
  • Event group khi cần biểu diễn một nhóm trạng thái như WIFI_READY, TIME_SYNCED, OTA_ACTIVE.
  • Task notification cho tín hiệu nhẹ giữa ISR/task hoặc một producer–consumer đơn giản.

Đừng giữ mutex trong lúc chờ network, filesystem hoặc sensor chậm. Hãy copy state cần thiết, nhả lock rồi mới làm I/O. Pin task vào hai core khác nhau cũng không sửa race condition; shared memory vẫn là shared memory.

Trong ISR, mình chỉ làm phần tối thiểu và dùng API có hậu tố FromISR để đánh thức task xử lý tiếp. ISR không nên block, chờ mutex, log dài hoặc cấp phát/free bộ nhớ tùy tiện. Nếu một thao tác có thể mất thời gian không xác định, nó không thuộc ISR.

6. Wi-Fi hoặc BLE thường xuyên mất kết nối

Board ESP32 được kiểm tra kết nối không dây với antenna để thoáng và thiết bị đo đặt cạnh laptop.

Trước khi viết lại reconnect loop, hãy tách lỗi RF, antenna và power-save khỏi lỗi server hoặc socket.

Trước khi sửa reconnect code, hãy nhớ rằng ESP32 Wi-Fi phổ biến chỉ làm việc ở 2,4 GHz. Router gộp SSID 2,4/5 GHz, band steering hoặc channel crowded có thể làm việc test khó đoán hơn.

Mình kiểm tra theo thứ tự:

  1. Log disconnect reason và RSSI trước khi reconnect.
  2. Test gần một access point 2,4 GHz đã biết tốt.
  3. Kiểm tra vùng ăng-ten có bị pin, vỏ kim loại, màn hình hoặc ground plane che không.
  4. Thử cấu hình power-save khác để xem latency hoặc packet loss có thay đổi không.
  5. Nếu Wi-Fi và BLE chạy đồng thời, đo lại throughput và latency vì hai giao thức chia sẻ RF theo time slice.

Một reconnect loop tệ thường khiến lỗi ban đầu nặng hơn: mỗi lần mất mạng lại tạo task mới, đăng ký event handler mới, mở socket mới hoặc cấp phát buffer mới. Sau vài giờ, heap giảm, watchdog xuất hiện và log không còn phản ánh nguyên nhân đầu tiên.

Reconnect nên có state machine, exponential backoff có giới hạn và một owner duy nhất. Đừng gọi reconnect liên tục trong callback disconnect. Đồng thời, hãy phân biệt “mất RF” với “Wi-Fi còn kết nối nhưng DNS/TCP/server lỗi”; reset toàn bộ Wi-Fi cho mọi lỗi HTTP thường quá mạnh tay.

7. ADC đọc sai hoặc nhiễu

Attenuation quyết định dải điện áp ADC có thể đo hữu ích, nhưng không phải là mạch bảo vệ quá áp. Điện áp tối đa và dải đo phụ thuộc vào từng chip, nên mình luôn kiểm tra datasheet của đúng ESP32/C3/S3 thay vì dùng một bảng chung.

ADC tích hợp không tuyến tính hoàn hảo và reference giữa các chip có sai số. Nếu giá trị tuyệt đối quan trọng, hãy dùng calibration API, đo nhiều điểm bằng nguồn chuẩn và cân nhắc ADC ngoài.

Với tín hiệu nhiễu, mình thường:

  • Giảm trở kháng nguồn tín hiệu hoặc thêm RC filter phù hợp.
  • Lấy nhiều mẫu rồi dùng median/average thay vì tin một sample.
  • Tách đường analog khỏi antenna, clock, SPI và nguồn switching.
  • Đôi khi Wi-Fi idle để so sánh, nhưng không coi đó là giải pháp cuối cùng nếu sản phẩm phải phát Wi-Fi khi chạy.
  • Giữ ground return của sensor sạch và chung mốc với ESP32.

Riêng ESP32 classic, ADC2 bị Wi-Fi sử dụng; đọc ADC2 khi Wi-Fi hoạt động có thể thất bại hoặc không ổn định. Đây là hạn chế của ADC conversion, không có nghĩa là các GPIO ADC2 bị cấm dùng làm digital I/O. Khi có thể, mình chọn ADC1 cho sensor analog trên sản phẩm có Wi-Fi.

8. Deep sleep vẫn tốn dòng hoặc thức sai

Board ESP32 được cấp nguồn qua đồng hồ đo dòng để kiểm tra mức tiêu thụ trong deep sleep.

Dòng deep sleep cần được đo trên cả board vì LED, regulator và USB-UART vẫn có thể tiêu thụ điện.

Con số deep-sleep trong datasheet là của chip ở điều kiện cụ thể, không phải mặc định của cả DevKit. USB-UART bridge, LED nguồn, regulator, sensor và pull resistor trên board có thể tiêu thụ nhiều hơn ESP32 đang ngủ.

Trước khi sleep, mình kiểm tra:

  • Tắt sensor, màn hình, amplifier và load switch không cần thiết.
  • Đưa CS, enable và GPIO ngoài về mức xác định; không để input floating.
  • Cấu hình đúng pull-up/pull-down và isolate GPIO có dòng rò qua mạch ngoài.
  • Xóa wake-up source cũ nếu firmware thay đổi chế độ ngủ.
  • In esp_sleep_get_wakeup_cause() ngay sau boot để biết ai đánh thức chip.

Deep sleep là một lần reset theo góc nhìn của ứng dụng. Biến thường trong DRAM không được giữ lại. Dữ liệu đặt bằng RTC_DATA_ATTR có thể còn nếu RTC memory được cấp nguồn; dữ liệu cần tồn tại chắc chắn qua mất nguồn phải vào NVS/flash hoặc storage ngoài. Ghi flash trước mỗi giấc ngủ ngắn cũng làm tăng wear, nên cần gom hoặc giảm tần suất.

Để đo đúng, mình bỏ qua cổng USB-UART nếu có thể, tắt LED nguồn hoặc dùng board đo được thiết kế cho low power. Đồng hồ phải có burden voltage đủ thấp và bắt được cả dòng sleep lẫn burst lúc wake. Một phép đo trung bình chậm có thể che mất spike hoặc khiến bạn tưởng board không bao giờ ngủ.

Nếu board thức ngay lập tức, hãy đọc wake cause rồi đo mức logic thật trên wake pin. Trigger theo level sẽ đánh thức ngay nếu pin đã ở đúng active level trước khi vào sleep.

9. OTA thất bại: kiểm tra slot trước khi kiểm tra mạng

Firmware 1,7 MB không thể vào OTA slot 1,5 MB dù flash tổng là 8 MB. OTA quan tâm đến kích thước partition đích, không phải con số flash in trên module.

Một layout an toàn thường có otadata và ít nhất hai app slot như ota_0, ota_1; factory app là lựa chọn thêm tùy sản phẩm. Trước khi phát hành, mình kiểm tra:

  • Binary mới nhỏ hơn slot OTA với khoảng trống hợp lý cho tăng trưởng.
  • Partition table trên thiết bị khớp với build đang phát hành.
  • Thiết bị xác thực image, version và target chip trước khi đổi boot partition.
  • Nguồn không sụt khi Wi-Fi và flash cùng hoạt động.
  • Download có timeout, kiểm tra lỗi TLS/HTTP và không tạo update loop vô hạn.

Rollback chỉ có ý nghĩa khi firmware mới chưa được xác nhận quá sớm. Sau lần boot đầu, app nên chạy self-test ngắn: storage đọc được, config hợp lệ, task thiết yếu chạy và network stack không crash. Chỉ sau đó mới gọi esp_ota_mark_app_valid_cancel_rollback(). Nếu test thất bại, đánh dấu invalid và quay về image cũ.

Với thiết bị ở xa, mình vẫn muốn có đường recovery ngoài OTA: factory/recovery image tối giản hoặc ít nhất là serial download mode có test point. Mất nguồn giữa safe OTA không nên làm hỏng image đang chạy, nhưng partition table update hoặc bootloader update là câu chuyện rủi ro hơn nhiều.

10. Checklist chẩn đoán runtime

Khi một ESP32 đang chạy ổn rồi lỗi, mình đi theo đúng chuỗi này:

  1. Reset reason: là panic, watchdog, brownout, deep-sleep wake hay software reset?
  2. Backtrace: lưu full log, đúng firmware version và đúng ELF.
  3. Watchdog: task nào không yield, callback nào làm việc nặng, lock nào chờ quá lâu?
  4. Heap: log free, minimum free và largest block; kiểm tra leak theo từng chu kỳ.
  5. Stack: đo high-water mark trong tình huống nặng nhất, tìm buffer local lớn.
  6. Task: xác định owner của buffer, mutex/queue và mọi đường đi từ ISR.
  7. Network: log reason, RSSI, backoff, power-save và Wi-Fi/BLE coexistence.
  8. Power/ADC: đo rail khi radio hoạt động, kiểm tra analog ground và attenuation.
  9. Deep sleep: đọc wake cause, tắt peripheral và đo cả board thay vì suy từ datasheet.
  10. OTA: kiểm tra slot, image size, first-boot self-test, rollback và recovery.

Quan trọng nhất là giữ log của lỗi đầu tiên. Sau một null pointer, reconnect storm hoặc heap corruption, những lỗi phía sau thường chỉ là hiệu ứng dây chuyền. Nếu bạn sửa từ reset reason và frame đầu tiên thuộc code ứng dụng, thời gian debug sẽ ngắn hơn rất nhiều so với việc bắt đầu bằng việc tăng timeout hoặc restart toàn bộ hệ thống.

Kết luận

Runtime bug trên ESP32 hiếm khi thật sự ngẫu nhiên. Nó chỉ xảy ra ở một timing, mức tải hoặc trạng thái bộ nhớ mà mình chưa ghi lại được. Backtrace cho biết code dừng ở đâu; watchdog cho biết scheduler bị giữ; heap/stack cho biết tài nguyên đã đi đâu; reset reason và wake cause nối các dấu vết đó thành một câu chuyện.

Mình thường chỉ đổi một thứ mỗi lần, giữ lại log trước và sau thay đổi, rồi chạy lại đúng workload đã gây lỗi. Cách này chậm hơn vài phút ở đầu nhưng tránh được nhiều ngày “sửa được mà không biết vì sao”.

Tham khảo

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.