อัปเดต ESP32 ผ่าน Wi-Fi ไม่ให้เครื่องพัง: OTA สองช่อง rollback และ signed image
OTA บน ESP-IDF ที่ปลอดภัย: ota_0/ota_1 + otadata เปิด app rollback ให้เฟิร์มแวร์ใหม่ต้องผ่าน self-test ดาวน์โหลดผ่าน HTTPS ตรวจลายเซ็น และคำนวณพาร์ทิชันบน flash 4 MB
- OTA ที่ปลอดภัยต้องมีช่องแอปสองช่อง เขียนเฟิร์มแวร์ใหม่ลงช่องที่ไม่ได้รันอยู่ แล้วค่อยสลับตอนเขียนเสร็จและตรวจผ่าน
- เปิด CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE แล้วเรียก esp_ota_mark_app_valid_cancel_rollback() หลัง self-test ผ่านเท่านั้น
- ถ้าเฟิร์มแวร์ใหม่ reboot ก่อนยืนยันตัวเอง bootloader จะถอยกลับไปเวอร์ชันเดิมให้เอง
- flash 4 MB ไม่ต้องมี factory: ตัด factory ออกแล้วได้ช่อง OTA ช่องละ 1,920 KB แทน 1 MB
อุปกรณ์ IoT ที่ติดตั้งไปแล้วเป็นสิบเป็นร้อยตัว ไม่มีใครอยากเดินไปเสียบสาย USB ทีละตัว OTA (over-the-air) คือการอัปเดตเฟิร์มแวร์ผ่าน Wi-Fi แต่ถ้าทำแบบง่ายเกินไป เฟิร์มแวร์ที่บูตไม่ขึ้นหรือต่อเน็ตไม่ได้หนึ่งครั้ง เท่ากับเครื่องค้างทั้งฝูงและต้องไปเก็บกลับมาแฟลชใหม่ บทความนี้ไล่โครงที่ทำให้ OTA บน ESP-IDF พลาดได้โดยไม่พัง: สองช่อง, rollback, HTTPS, ลายเซ็น และการแบ่ง flash
ข้อเท็จจริงทั้งหมดตรวจกับเอกสาร ESP-IDF v6.1 (stable ณ ตุลาคม 2026) สำหรับชิป ESP32 ชิปรุ่นอื่นในตระกูลใช้หลักเดียวกัน แต่ตำแหน่ง bootloader และตัวเลือก security บางตัวต่างกัน
ทำไมต้องสองช่อง
หลักคือ ห้ามเขียนทับโปรแกรมที่กำลังรันอยู่ ESP-IDF จึงให้มีช่องแอปอย่างน้อยสองช่อง ota_0 กับ ota_1 และพาร์ทิชันเล็ก ๆ ชื่อ otadata ที่จดว่าบูตช่องไหน
- เครื่องรันอยู่บน
ota_0 - ดาวน์โหลดเฟิร์มแวร์ใหม่ เขียนลง
ota_1ทีละก้อน - เขียนครบแล้วตรวจ image (checksum, และลายเซ็นถ้าเปิด) ผ่าน → แก้
otadataให้ชี้ota_1 - reboot บูต
ota_1
ระหว่างข้อ 1-3 จะไฟดับกี่ครั้งก็ได้ otadata ยังชี้ช่องเดิม เครื่องกลับมารันเวอร์ชันเก่า otadata มีขนาด 0x2000 คือ 2 sector เก็บสองสำเนาไว้ เผื่อไฟดับตอนกำลังเขียนตัวมันเอง ถ้า otadata ว่างหมด (0xFF) bootloader บูต factory ถ้ามี ถ้าไม่มีก็บูต ota_0
ช่องโหว่ที่เหลือคือข้อ 4: เฟิร์มแวร์ใหม่บูตขึ้น แต่ต่อ Wi-Fi ไม่ได้ หรือ crash วนตั้งแต่วินาทีแรก สองช่องอย่างเดียวไม่ช่วย ต้องมี rollback
App rollback: เฟิร์มแวร์ใหม่ต้องพิสูจน์ตัวเอง
เปิด CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE ใน menuconfig (ค่าเริ่มต้นปิด) แล้วช่องที่เพิ่งอัปเดตจะเดินตามสถานะนี้
| สถานะ | เกิดเมื่อ | bootloader จะบูตไหม |
|---|---|---|
ESP_OTA_IMG_NEW | esp_ota_set_boot_partition() เพิ่งเลือกช่องนี้ | บูตครั้งเดียว แล้วเปลี่ยนเป็น PENDING_VERIFY |
ESP_OTA_IMG_PENDING_VERIFY | บูตครั้งแรก รอแอปยืนยัน | ถ้า reboot ทั้งที่ยังค้างสถานะนี้ → เปลี่ยนเป็น ABORTED แล้วถอยกลับ |
ESP_OTA_IMG_VALID | แอปเรียก esp_ota_mark_app_valid_cancel_rollback() | บูต |
ESP_OTA_IMG_INVALID / ABORTED | ตรวจไม่ผ่าน หรือ reboot ก่อนยืนยัน | ไม่บูตอีก |
ผลคือ เฟิร์มแวร์ใหม่ที่ crash ก่อนยืนยันตัวเอง, watchdog รีเซ็ต, หรือไฟดับระหว่างบูตครั้งแรก จะถูกถอยกลับไปช่องเดิมอัตโนมัติ ไม่มีโค้ดเราเกี่ยวเลย
โค้ดตอนบูต ใช้ฟังก์ชันตามเอกสาร OTA ของ ESP-IDF
#include "esp_ota_ops.h"
static bool self_test(void)
{
// ทดสอบสิ่งที่เครื่องนี้ "ต้อง" ทำได้: ต่อ Wi-Fi, ถึงเซิร์ฟเวอร์อัปเดต, อ่าน sensor หลัก
return wifi_connected_within_ms(20000) && update_server_reachable();
}
void check_new_firmware(void)
{
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t state;
if (esp_ota_get_state_partition(running, &state) == ESP_OK &&
state == ESP_OTA_IMG_PENDING_VERIFY) {
if (self_test()) {
esp_ota_mark_app_valid_cancel_rollback(); // ยืนยัน ใช้ตัวนี้ต่อ
} else {
esp_ota_mark_app_invalid_rollback_and_reboot(); // ถอยกลับไปตัวเดิม
}
}
}
wifi_connected_within_ms() กับ update_server_reachable() เป็นฟังก์ชันของแอปเราเอง เขียนตามระบบของคุณ
self-test ที่ดีที่สุดคือ “ยังรับอัปเดตครั้งถัดไปได้ไหม” ถ้าต่อเซิร์ฟเวอร์อัปเดตได้ ต่อให้ฟีเจอร์อื่นมีบั๊ก ก็ส่งตัวแก้ตามไปได้
ดาวน์โหลดผ่าน HTTPS และเช็กเวอร์ชันก่อนเขียน
คอมโพเนนต์ esp_https_ota ทำงานดาวน์โหลด เขียนช่องถัดไป และตั้ง boot partition ให้ครบ แบบง่ายสุดคือเรียก esp_https_ota() ครั้งเดียว แต่เราอยากอ่านหัว image ก่อน เพื่อไม่เขียน flash ซ้ำด้วยเวอร์ชันเดิม จึงใช้แบบแยกขั้นตาม advanced_https_ota_example
#include <string.h>
#include "esp_https_ota.h"
#include "esp_crt_bundle.h"
#include "esp_ota_ops.h"
#include "esp_app_desc.h"
esp_err_t ota_update(const char *url)
{
esp_http_client_config_t http = {
.url = url, // เช่น https://fw.example.com/sensor.bin
.crt_bundle_attach = esp_crt_bundle_attach, // หรือ .cert_pem = CA ของเซิร์ฟเวอร์เราเอง
};
esp_https_ota_config_t cfg = { .http_config = &http };
esp_https_ota_handle_t h = NULL;
esp_err_t err = esp_https_ota_begin(&cfg, &h);
if (err != ESP_OK) return err;
esp_app_desc_t incoming;
err = esp_https_ota_get_img_desc(h, &incoming); // อ่านหัว image ก่อน perform
if (err == ESP_OK &&
strncmp(incoming.version, esp_app_get_description()->version,
sizeof(incoming.version)) == 0) {
esp_https_ota_abort(h); // เวอร์ชันเดิม ไม่ต้องเขียน
return ESP_OK;
}
do {
err = esp_https_ota_perform(h); // เขียนทีละก้อนลงช่องถัดไป
} while (err == ESP_ERR_HTTPS_OTA_IN_PROGRESS);
if (err != ESP_OK || !esp_https_ota_is_complete_data_received(h)) {
esp_https_ota_abort(h);
return ESP_FAIL;
}
err = esp_https_ota_finish(h); // ตรวจ image + ตั้ง boot partition
if (err == ESP_OK) esp_restart();
return err;
}
เวอร์ชันมาจาก PROJECT_VER ตอน build ตั้งใน CMakeLists.txt ด้วย set(PROJECT_VER "1.4.2") ก่อน include project.cmake ถ้าไม่ตั้ง ระบบหาไฟล์ version.txt แล้วถึง git describe ฟิลด์ version ยาวได้ 32 ตัว ตัวอย่างนี้เทียบแค่ “เท่ากันไหม” ถ้าต้องการกันการลงเวอร์ชันต่ำกว่า ให้แยกเลขแล้วเทียบเอง หรือใช้ anti-rollback (ด้านล่าง)
ถ้าเซิร์ฟเวอร์ตอบ 304 Not Modified ได้ esp_https_ota_begin() จะคืน ESP_ERR_HTTP_NOT_MODIFIED ใช้แทนการเทียบเวอร์ชันได้อีกทาง
Signed image: HTTPS ยังไม่พอ
HTTPS ยืนยันแค่ว่าเราคุยกับเซิร์ฟเวอร์ที่ถูก ถ้าเซิร์ฟเวอร์หรือบัญชีที่อัปไฟล์ถูกยึด ไฟล์ปลอมก็มาทาง HTTPS ได้สบาย ลายเซ็นปิดช่องนี้ เพราะ private key อยู่บนเครื่อง build ไม่ได้อยู่บนเซิร์ฟเวอร์ (แยก credential ตามหน้าที่ ใช้กับ signing key ด้วย)
ESP-IDF ตรวจลายเซ็นตอน OTA ได้โดยไม่ต้องเปิด hardware secure boot
- menuconfig → Security features → App Signing Scheme = RSA
- เปิด
CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT(และCONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT) - ปล่อย “Sign binaries during build” เปิดไว้ แล้วสร้าง key
# บนเครื่อง build ห้าม commit ไฟล์นี้
idf.py secure-generate-signing-key --version 2 secure_boot_signing_key.pem
ข้อควรรู้จากเอกสาร
- บน ESP32 ตัวเลือก RSA (Secure Boot v2) ใช้ได้กับชิป revision v3.0 ขึ้นไป ต้องตั้ง
CONFIG_ESP32_REV_MINเป็น v3.0 ก่อนถึงจะเห็นตัวเลือก - โหมดนี้ตรวจลายเซ็น ตอนอัปเดต ไม่ได้ตรวจทุกครั้งที่บูต และไม่กันคนที่เข้าถึงตัวเครื่องแล้วเขียน flash ตรง ถ้าต้องกันระดับนั้นต้องใช้ hardware secure boot
- anti-rollback (
CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK) กันการลงเวอร์ชันเก่าที่มีช่องโหว่ โดยเก็บ security version ไว้ใน eFuse บน ESP32 เพิ่มได้สูงสุด 32 ครั้ง และเพิ่มแล้ว ย้อนไม่ได้ ใช้เมื่อปิดช่องโหว่ร้ายแรงเท่านั้น ไม่ใช่ทุก release
ตัวอย่างคำนวณ: แบ่ง flash 4 MB
ตาราง “Factory app, two OTA definitions” ที่มากับ ESP-IDF
| ชื่อ | offset | ขนาด |
|---|---|---|
| nvs | 0x9000 | 0x4000 (16 KB) |
| otadata | 0xD000 | 0x2000 (8 KB) |
| phy_init | 0xF000 | 0x1000 (4 KB) |
| factory | 0x10000 | 1 MB |
| ota_0 | 0x110000 | 1 MB |
| ota_1 | 0x210000 | 1 MB |
ช่องสุดท้ายจบที่ 0x210000 + 0x100000 = 0x310000 flash 4 MB = 0x400000 เหลือ 0x400000 − 0x310000 = 0xF0000 = 960 KB ที่ไม่มีใครใช้ และแอปใหญ่ได้แค่ 1 MB ถ้าเฟิร์มแวร์ที่มี Wi-Fi + TLS + MQTT ของคุณ idf.py size ได้ 1.3 MB ตารางนี้ใช้ไม่ได้
ตัด factory ออก ให้สองช่องแบ่งที่เหลือ เงื่อนไขจากเอกสาร: ตารางพาร์ทิชันอยู่ที่ 0x8000 กิน 1 sector พาร์ทิชันแรกเริ่มได้ที่ 0x9000 และช่องแอปต้องเริ่มที่ offset หาร 0x10000 (64 KB) ลงตัว
# partitions.csv สำหรับ flash 4 MB
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x4000
otadata, data, ota, 0xd000, 0x2000
phy_init, data, phy, 0xf000, 0x1000
ota_0, app, ota_0, 0x10000, 0x1E0000
ota_1, app, ota_1, 0x1F0000, 0x1E0000
storage, data, spiffs, 0x3D0000, 0x30000
คิดทีละขั้น
- พื้นที่ตั้งแต่ช่องแรกถึงท้าย flash: 0x400000 − 0x10000 = 0x3F0000 (4,032 KB)
- กันไว้ให้ storage 0x30000 = 192 KB เหลือ 0x3C0000 = 3,840 KB
- แบ่งสองช่อง: 0x1E0000 = 1,920 KB ต่อช่อง (เกือบสองเท่าของ 1 MB)
- ตรวจ alignment:
ota_1เริ่มที่ 0x10000 + 0x1E0000 = 0x1F0000 หาร 0x10000 ลงตัว ผ่าน - ตรวจท้าย: 0x1F0000 + 0x1E0000 = 0x3D0000 และ storage จบที่ 0x3D0000 + 0x30000 = 0x400000 พอดี
ตั้ง menuconfig: Partition Table → Custom partition table CSV ชี้ไฟล์นี้ และ Serial flasher config → Flash size = 4 MB แล้ว idf.py partition-table พิมพ์ตารางที่ได้ออกมาให้ตรวจอีกรอบ
ช่อง OTA ทุกช่องต้องใหญ่พอสำหรับเฟิร์มแวร์ ในอนาคต ไม่ใช่แค่วันนี้ เปลี่ยนตารางพาร์ทิชันผ่าน OTA ไม่ได้ ต้องเสียบสายแฟลชใหม่
ข้อผิดพลาดที่เจอบ่อย
- ยืนยันตัวเองเร็วเกินไป เรียก
esp_ota_mark_app_valid_cancel_rollback()บรรทัดแรกของapp_mainเท่ากับปิด rollback ไปเปล่า ๆ ให้เรียกหลังต่อเน็ตและถึงเซิร์ฟเวอร์อัปเดตได้แล้ว - self-test ช้าเกิน ช่วงที่ยังเป็น PENDING_VERIFY ถ้า watchdog รีเซ็ตหรือ reboot จะถอยกลับทันที เอกสารแนะนำให้ทดสอบให้เร็วที่สุด
- ลืมว่า factory ไม่ถูก rollback rollback ย้อนเฉพาะช่อง OTA ถ้าอยากได้ทางกู้สุดท้าย ต้องออกแบบเอง
- ส่งอัปเดตทั้งฝูงพร้อมกัน ปล่อยให้ 1-2 ตัวก่อน ดูว่าผ่าน self-test แล้วรายงานเวอร์ชันกลับมา แล้วค่อยขยาย อุปกรณ์ต้องติดต่อกลับมาได้เสมอ (เรื่อง IP อุปกรณ์เปลี่ยน ช่วยตรงนี้)
- แอปโตจนเต็มช่อง ตั้ง CI ให้รัน
idf.py sizeแล้วเตือนเมื่อเกิน 80-90% ของขนาดช่อง ก่อนจะถึงวันที่ build ไม่ผ่าน - เก็บ signing key ไว้ใน repo ถ้า key รั่ว ลายเซ็นไม่มีความหมาย และถ้า key หาย อุปกรณ์ที่เปิดตรวจลายเซ็นจะรับอัปเดตใหม่ไม่ได้อีก สำรอง key ไว้ที่ปลอดภัยแยกจาก repo
แหล่งอ้างอิง
- Over The Air Updates (OTA) — ESP-IDF Programming Guide
- ESP HTTPS OTA — ESP-IDF Programming Guide
- Partition Tables — ESP-IDF Programming Guide
- Secure Boot v2 (Signed App Verification Without Hardware Secure Boot) — ESP-IDF
- App Version (PROJECT_VER) — ESP-IDF Miscellaneous System APIs
- advanced_https_ota example — espressif/esp-idf on GitHub
คำถามที่พบบ่อย
ต้องมี factory partition ไหม
ไม่ต้อง ถ้ามีช่อง ota_0 และ ota_1 ครบ bootloader บูตช่อง OTA แรกเมื่อ otadata ว่าง ข้อดีของ factory คือมีเฟิร์มแวร์ตั้งต้นไว้กู้ แต่กินที่ 1 ช่อง และ rollback ไม่ย้อนกลับไป factory ให้ ย้อนได้เฉพาะช่อง OTA
ถ้าไฟดับระหว่างดาวน์โหลดเฟิร์มแวร์จะเกิดอะไร
ไม่เป็นไร เพราะเขียนลงช่องที่ไม่ได้รันอยู่ และยังไม่ได้เปลี่ยน otadata บูตใหม่ก็กลับมารันเวอร์ชันเดิม otadata เองมีสองสำเนา (0x2000 = 2 sector) ไฟดับระหว่างเขียนก็ไม่เสียทั้งคู่
signed image ต่างจาก HTTPS ยังไง
HTTPS ยืนยันว่าคุยกับเซิร์ฟเวอร์ที่ถูกตัว แต่ถ้าเซิร์ฟเวอร์ถูกเจาะ ไฟล์ปลอมก็ส่งมาทาง HTTPS ได้ ลายเซ็นยืนยันว่าเฟิร์มแวร์มาจากคนที่ถือ private key ซึ่งไม่ได้อยู่บนเซิร์ฟเวอร์ ต้องมีทั้งสองชั้น
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ