บทความ › IoT และ Embedded

อัปเดต ESP32 ผ่าน Wi-Fi ไม่ให้เครื่องพัง: OTA สองช่อง rollback และ signed image

OTA บน ESP-IDF ที่ปลอดภัย: ota_0/ota_1 + otadata เปิด app rollback ให้เฟิร์มแวร์ใหม่ต้องผ่าน self-test ดาวน์โหลดผ่าน HTTPS ตรวจลายเซ็น และคำนวณพาร์ทิชันบน flash 4 MB

กลาง อ่าน 14 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • 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 ที่จดว่าบูตช่องไหน

  1. เครื่องรันอยู่บน ota_0
  2. ดาวน์โหลดเฟิร์มแวร์ใหม่ เขียนลง ota_1 ทีละก้อน
  3. เขียนครบแล้วตรวจ image (checksum, และลายเซ็นถ้าเปิด) ผ่าน → แก้ otadata ให้ชี้ ota_1
  4. reboot บูต ota_1

ระหว่างข้อ 1-3 จะไฟดับกี่ครั้งก็ได้ otadata ยังชี้ช่องเดิม เครื่องกลับมารันเวอร์ชันเก่า otadata มีขนาด 0x2000 คือ 2 sector เก็บสองสำเนาไว้ เผื่อไฟดับตอนกำลังเขียนตัวมันเอง ถ้า otadata ว่างหมด (0xFF) bootloader บูต factory ถ้ามี ถ้าไม่มีก็บูต ota_0

ช่องโหว่ที่เหลือคือข้อ 4: เฟิร์มแวร์ใหม่บูตขึ้น แต่ต่อ Wi-Fi ไม่ได้ หรือ crash วนตั้งแต่วินาทีแรก สองช่องอย่างเดียวไม่ช่วย ต้องมี rollback

เขียนลงช่องที่ว่างช่องที่รันอยู่ไม่ถูกแตะotadata ชี้ช่องใหม่สถานะ: รอตรวจบูต แล้ว self-testWi-Fi เซนเซอร์ เซิร์ฟเวอร์ผ่าน = VALIDไม่ผ่านหรือรีบูต = ถอยกลับ
ช่องที่รันอยู่ไม่ถูกเขียนทับ เฟิร์มแวร์ใหม่ต้องยืนยันตัวเองก่อนถึงจะได้อยู่ต่อ

App rollback: เฟิร์มแวร์ใหม่ต้องพิสูจน์ตัวเอง

เปิด CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE ใน menuconfig (ค่าเริ่มต้นปิด) แล้วช่องที่เพิ่งอัปเดตจะเดินตามสถานะนี้

สถานะเกิดเมื่อbootloader จะบูตไหม
ESP_OTA_IMG_NEWesp_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

  1. menuconfig → Security features → App Signing Scheme = RSA
  2. เปิด CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT (และ CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT)
  3. ปล่อย “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ขนาด
nvs0x90000x4000 (16 KB)
otadata0xD0000x2000 (8 KB)
phy_init0xF0000x1000 (4 KB)
factory0x100001 MB
ota_00x1100001 MB
ota_10x2100001 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

คิดทีละขั้น

  1. พื้นที่ตั้งแต่ช่องแรกถึงท้าย flash: 0x400000 − 0x10000 = 0x3F0000 (4,032 KB)
  2. กันไว้ให้ storage 0x30000 = 192 KB เหลือ 0x3C0000 = 3,840 KB
  3. แบ่งสองช่อง: 0x1E0000 = 1,920 KB ต่อช่อง (เกือบสองเท่าของ 1 MB)
  4. ตรวจ alignment: ota_1 เริ่มที่ 0x10000 + 0x1E0000 = 0x1F0000 หาร 0x10000 ลงตัว ผ่าน
  5. ตรวจท้าย: 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

แหล่งอ้างอิง

คำถามที่พบบ่อย

ต้องมี 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 ซึ่งไม่ได้อยู่บนเซิร์ฟเวอร์ ต้องมีทั้งสองชั้น

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#IoTกลาง

เขียน ESP32 ด้วย Rust: esp-hal 1.0 อะไรเสถียรแล้ว และไฟกะพริบตัวแรก

esp-hal 1.0 คือ Rust SDK สำหรับ ESP32 ที่ Espressif หนุนเอง GPIO UART SPI I2C เสถียรแล้ว Wi-Fi ยังไม่ ตั้งเครื่องมือ สร้างโปรเจกต์ด้วย esp-generate และเขียนไฟกะพริบ

อ่าน 10 นาที
#IoTกลาง

ต่อเครื่องพิมพ์ 3D ด้วย MQTT: snapshot + delta ต่อใหม่เอง และเฝ้าว่ายังทำงาน

เรียนรู้ MQTT จากงานจริง ดึงสถานะเครื่องพิมพ์ 3D หลายยี่ห้อมาแสดงบนไลฟ์ ขอ snapshot แล้วรวม delta ต่อใหม่เมื่อหลุด แยก process และเฝ้า token ที่หมดอายุเงียบ ๆ

อ่าน 7 นาที
#IoTเริ่มต้น

กล้องและอุปกรณ์ IoT เปลี่ยน IP เองทุกครั้งที่รีบูต แก้ยังไง

ทำไมกล้อง เครื่องพิมพ์ และบอร์ด ESP32 ได้ IP ใหม่หลังปิดเปิด และ 4 วิธีแก้ ตั้งแต่จอง IP ในเราเตอร์ จนถึงให้ระบบสแกนหาเองอัตโนมัติ

อ่าน 3 นาที