OTA cập nhật firmware qua mạng là điểm rủi ro cao nhất trong vòng đời thiết bị IoT — một lần cập nhật lỗi có thể biến hàng trăm thiết bị ngoài field thành "gạch" nếu không có đường lùi. Bài này dành cho kỹ sư firmware đang thiết kế cơ chế OTA hai phân vùng (A/B), cần hiểu rõ điều kiện confirm, rollback, health-check, và cách phân biệt "OTA báo thành công" với "firmware thực sự đã đổi".
#Vì sao cần sơ đồ hai phân vùng (A/B)
Nguyên lý cơ bản: ảnh firmware mới được ghi vào phân vùng không hoạt động (inactive slot), phân vùng đang chạy (active slot) giữ nguyên không bị đụng tới. Nếu quá trình OTA thất bại giữa chừng — mất điện, mất kết nối, ghi lỗi — thiết bị vẫn còn phân vùng cũ nguyên vẹn để chạy tiếp, không rơi vào trạng thái "brick".
Bootloader chọn phân vùng nào để boot dựa trên cờ trạng thái lưu trong vùng metadata riêng (thường là một sector nhỏ ngoài hai slot chính), không phụ thuộc vào việc đọc và xác minh toàn bộ nội dung ảnh mới. Đây là điểm khác biệt quan trọng so với cơ chế OTA một phân vùng (ghi đè trực tiếp lên ảnh đang chạy) — cách này không có đường lùi nếu ảnh mới lỗi.
Layout flash điển hình gồm: vùng bootloader, slot A, slot B, và vùng metadata/trạng thái. Kích thước mỗi vùng phụ thuộc dung lượng flash của board và kích thước ảnh firmware thực tế theo dung lượng flash của board thực tế trong dự án — cần tra lại datasheet/linker script khi triển khai.
#Điều kiện xác nhận ảnh mới (image confirm)
Sau khi bootloader chuyển sang chạy ảnh mới, ảnh đó ở trạng thái "pending" hoặc "trial" — chưa được coi là ổn định. Nếu thiết bị reset trước khi có xác nhận, bootloader hiểu đây là dấu hiệu ảnh mới có vấn đề và tự động quay lại ảnh cũ.
Điểm cần lưu ý: việc xác nhận (confirm) phải do logic ứng dụng gọi, sau khi đã tự kiểm tra các điều kiện sống cơ bản — không được để bootloader tự động confirm ngay khi boot lên được. Nếu tự động confirm ở boot, một ảnh lỗi nhưng vẫn khởi động được (crash sau vài giây, hoặc không kết nối được mạng) sẽ bị coi là "thành công" vĩnh viễn, mất khả năng rollback.
// Minh hoạ luồng gọi confirm, chưa biên dịch — tên hàm cụ thể phụ thuộc SDK đang dùng
void app_post_boot_selftest(void) {
if (heap_free_check() && network_connect_check() && main_service_started()) {
ota_image_confirm(); // đánh dấu ảnh hiện tại là ổn định
} else {
// không gọi confirm — để watchdog/timeout xử lý rollback
}
}
> [!NOTE] Cần dẫn đúng mục tài liệu SDK (tên hàm confirm/self-test, chương/section) đang dùng trong dự án — không suy diễn tên hàm nếu chưa tra lại datasheet SDK.
## Điều kiện kích hoạt rollback
Có hai đường dẫn tới rollback:
1. **Rollback bị động qua watchdog**: nếu watchdog không được feed trong một khoảng thời gian X giây, MCU reset. Bootloader kiểm tra cờ trạng thái, thấy ảnh chưa được confirm, sẽ quay lại slot cũ.
2. **Rollback chủ động từ ứng dụng**: khi ứng dụng tự phát hiện lỗi nghiêm trọng — crash loop (reset liên tục trong thời gian ngắn), lỗi khởi tạo driver quan trọng — và gọi API rollback thay vì chờ watchdog.
| Điều kiện | Nguồn phát hiện | Hành động |
|---|---|---|
| Watchdog timeout trước confirm | Bootloader/watchdog HW | Rollback tự động về slot cũ |
| Crash loop (reset liên tục) | Ứng dụng đếm số lần reset | Gọi rollback chủ động |
| Lỗi init driver chính | Ứng dụng self-test | Gọi rollback chủ động, không confirm |
| Số lần thử boot vượt ngưỡng | Bootloader đếm số lần retry | Rollback tự động |
Số lần thử lại trước khi rollback tự động, và giá trị X giây timeout, là cấu hình mặc định của từng SDK — không nên giữ nguyên mặc định mà không kiểm tra lại theo thời gian boot thực tế của ứng dụng tuỳ theo SDK cụ thể — cần tra lại tài liệu SDK đang dùng thay vì giữ nguyên số mặc định.
## Health-check: định nghĩa "sống" đúng nghĩa
Đây là phần dễ bị làm hời hợt nhất. "Boot lên được, in log ra UART" không đồng nghĩa firmware hoạt động đúng. Health-check cần kiểm tra tối thiểu ba nhóm:
- **Heap free**: còn đủ dư địa cho các module chạy sau này (không chỉ đủ tại thời điểm boot).
- **Kết nối mạng**: đã kết nối được tới hạ tầng cần thiết (Wi-Fi/mạng LAN, hoặc broker MQTT) trong thời gian chấp nhận được.
- **Service chính**: các task/service quan trọng đã khởi động và báo trạng thái OK, không chỉ được tạo (task created) mà chưa chắc chạy đúng.
Điểm mấu chốt: ngưỡng health-check phải khớp với hành vi thực tế của ứng dụng ở điều kiện tải bình thường — không lấy số mặc định của SDK, vì mặc định thường được đặt cho ứng dụng mẫu (demo), không phản ánh heap thực tế sau khi thiết bị tải đủ module (driver cảm biến, stack mạng, logic nghiệp vụ).
| Tiêu chí | Ngưỡng cần đo | Nguồn số đo |
|---|---|---|
| Heap free tối thiểu sau boot | cần đo thực tế trên board sau khi tải đủ module | Đo thực tế, không lấy mặc định SDK |
| Thời gian kết nối mạng tối đa | cần xác định theo yêu cầu cụ thể của ứng dụng (thường vài giây đến vài chục giây tuỳ hạ tầng mạng) | Đo thực tế ở điều kiện mạng field |
| Mã lỗi service chính | cần xác định theo danh sách mã lỗi cụ thể của service chính trong ứng dụng | Log ứng dụng |
> [!WARNING] Đặt ngưỡng heap quá chặt (copy từ ảnh cũ, chưa tính module mới thêm vào) là nguyên nhân phổ biến khiến health-check fail sai — ảnh mới hoàn toàn ổn định nhưng vẫn bị rollback.
## Version string: cái bẫy "OTA thành công mà không đổi gì"
Một hiện tượng gây khó hiểu: OTA báo thành công, thiết bị reset, nhưng khi kiểm tra version vẫn thấy số cũ. Nguyên nhân thường không phải do OTA lỗi, mà do version hiển thị được lấy từ macro build-time (ví dụ nhúng ngày giờ build hoặc số version cố định trong file cấu hình), và hệ thống build không re-run bước tạo macro này ở lần build sau — kết quả là file object cũ được tái sử dụng, version string không đổi dù code logic đã đổi.
```bash
# Minh hoạ lỗi cache version, chưa kiểm chứng trên toolchain cụ thể
# Nếu bước "configure" không được re-run, macro BUILD_VERSION giữ giá trị cũ
cmake --build build/ # có thể dùng lại cache cấu hình cũ
# Cách buộc regenerate:
cmake -S . -B build/ --fresh # hoặc xóa cache/configure lại thủ công
Cách phòng tránh: buộc bước sinh version string (thường qua git commit hash hoặc timestamp) chạy lại ở **mọi** lần build, không phụ thuộc vào cơ chế cache của công cụ build. Nên tách version string ra một file riêng được generate bởi script, không nhúng trực tiếp bằng macro tĩnh trong source.
## Chúng tôi đã gặp gì trên thiết bị thật
Một lần OTA hiện trường báo "Success" trên hệ thống quản lý, nhưng khi kiểm tra lại, thiết bị vẫn chạy hành vi cũ. Sau khi truy log, thứ tự sự kiện thực tế là:
[OTA] transfer complete, writing to slot B... OK [OTA] boot pending image, slot B active (trial) [APP] self-test: heap_free=xx KB, threshold=yy KB -> FAIL [APP] confirm SKIPPED (health-check failed) [WDT] timeout, no confirm received [BOOT] rollback triggered, switching to slot A [APP] running on slot A (old firmware)
Nguyên nhân gốc là **hai lỗi độc lập cộng lại**:
> [!MEASURED] Ngưỡng heap free trong health-check được đặt theo số đo cũ, trước khi thêm module mới vào ảnh — heap thực tế sau khi tải module mới thấp hơn ngưỡng này dù ảnh hoàn toàn hoạt động ổn định, khiến health-check fail và kích hoạt rollback tự động.
> [!MEASURED] Đồng thời, hệ thống build không re-run bước configure nên version string hiển thị vẫn là bản cũ — kỹ sư nhìn version, tưởng OTA chưa chạy, đi tìm nguyên nhân ở tầng transfer, mất thời gian trước khi phát hiện ảnh mới đã bị rollback từ trước đó.
Hai lỗi này riêng lẻ đều dễ tìm, nhưng cộng lại tạo ra hướng chẩn đoán sai — nhìn version cũ, nghĩ OTA chưa chạy, chứ không nghĩ tới rollback đã xảy ra.
## Thiết kế log/telemetry để phân biệt "không đổi" do đâu
Để tránh lặp lại tình huống trên, cần log tách bạch bốn tín hiệu độc lập, không gộp chung thành một dòng "OTA success/fail":
ota_transfer_result: OK
ota_confirm_result: FAIL (heap_free below threshold)
ota_healthcheck_result: FAIL
active_partition: slot_A (rolled_back)
firmware_version: v1.2.3
build_hash:
Bốn tín hiệu này — kết quả transfer, kết quả confirm, kết quả health-check, phân vùng đang active — phải log riêng và gửi lên server giám sát, không chỉ log "success" chung.
Ngoài version string, nên gửi kèm build hash (lấy từ git commit hoặc timestamp build cụ thể, không phải macro tĩnh dễ bị cache) để đối chiếu chính xác ảnh nào đang thực sự chạy trên thiết bị, tránh nhầm do version string bị cache ở bước build.
> [!NOTE] Định dạng log và trường build hash cụ thể nên thống nhất theo chuẩn nội bộ của team, kèm ví dụ log thật (ẩn danh) từ hệ thống giám sát đang dùng.
## Checklist khi thiết kế
- [ ] Đã định nghĩa tiêu chí health-check bằng số đo thực tế trên board (heap, thời gian kết nối, mã lỗi service), không dùng giá trị mặc định của SDK
- [ ] Đã kiểm tra version string không bị cache qua nhiều lần build liên tiếp (build 2 lần, so sánh hash)
- [ ] Đã test rollback bằng cách chủ động gây lỗi (giảm heap giả lập, cắt mạng) trước khi ra field
- [ ] Đã log đủ bốn tín hiệu: transfer, confirm, health-check, active-partition
- [ ] Đã gửi kèm build hash (không chỉ version string) lên server giám sát
- [ ] Đã xác nhận confirm được gọi từ logic ứng dụng, không tự động ở boot
- [ ] Đã kiểm tra số lần retry/timeout rollback khớp với thời gian boot thực tế của ứng dụng
## Đọc tiếp
Bài kế tiếp trong series: **Kết nối mạng bền vững: reconnect, backoff, và phát hiện mất kết nối âm thầm**.