Docs / Concepts / Model lifecycle & OTA updates
Model lifecycle & OTA updatesv0.4.0
A deployed device outlives any single model. SynapticOS separates the concerns: syn_model.h is the registry that tracks every model's metadata and load state at runtime, the flash-backed model store (Phase 4) makes the active model survive reboots and power loss, and syn_model_ota.h is the update path that gets new model binaries onto the device safely — all implemented and board-verified as of v0.4.0.
The model registry
The registry is an array of slots — CONFIG_SYNAPTIC_MAX_MODELS of them (default 4, range 1–16) — with 1-based handles, so SYN_MODEL_INVALID is simply 0. The typical lifecycle is register → load → invoke → unload → unregister:
/* Registry operations */
int syn_model_register(const syn_model_info_t *info,
syn_model_handle_t *handle);
int syn_model_unregister(syn_model_handle_t handle);
int syn_model_get_info(syn_model_handle_t handle, syn_model_info_t *info);
int syn_model_get_by_name(const char *name, syn_model_handle_t *handle);
int syn_model_list(syn_model_handle_t *handles, uint8_t *count, uint8_t max);
/* Loading */
int syn_model_load(syn_model_handle_t handle);
int syn_model_unload(syn_model_handle_t handle);
bool syn_model_is_loaded(syn_model_handle_t handle);Registration performs duplicate-name detection: registering a second model with the name of an active one fails with -EEXIST, and a full registry returns -ENOMEM. Lookup by name (syn_model_get_by_name()) returns -ENOENT when nothing matches. Once loaded, a model handle is what you pass to the inference engine — for example syn_infer_run_sync(model, &input, &output, priority).
States and guards
Each slot tracks two flags: active (registered) and loaded (ready on the NPU). The transitions are guarded so state can never silently drift:
syn_model_load()on an already-loaded model returns-EALREADY; likewisesyn_model_unload()on one that is not loaded.- Loading a model that has attached binary data pushes it into the NPU HAL via
syn_hal_npu_load_model(); an NPU failure propagates its error code and the slot stays unloaded. syn_model_unregister()automatically unloads a still-loaded model before freeing the slot.- Any operation on an invalid or inactive handle returns
-EINVAL, andsyn_model_is_loaded()simply answersfalse.
Model metadata
Everything the runtime needs to know about a model travels in one struct, syn_model_info_t:
typedef struct {
char name[32];
char version[16]; /* Semantic version: "1.2.3" */
uint32_t input_size; /* Expected input tensor size (bytes) */
uint32_t output_size; /* Output tensor size (bytes) */
uint32_t flash_offset; /* Offset in flash */
uint32_t flash_size; /* Total size in flash */
uint32_t sram_required; /* Peak SRAM needed for inference */
uint32_t crc32; /* Integrity check */
syn_npu_dtype_t input_dtype;
syn_npu_dtype_t output_dtype;
uint8_t input_shape[4];
uint8_t output_shape[4];
} syn_model_info_t;The metadata covers identity (name, semantic version), tensor contracts (sizes, dtypes, 4-element shapes), placement (flash offset and size), memory requirements (sram_required), and integrity (crc32). The syn model list shell command prints the registered set — [1] test_classify v1.0.0 (loaded) in the Hello Inference sample.
Hot-swap
syn_model_swap(old, new) replaces which registered model is the loaded one without a reboot — and as of Phase 4 it does so safely under a live scheduler: a quiescence gate pauses dispatch (new submissions queue as normal), waits for the in-flight job to complete and deliver its result intact, unloads the old model, loads the new one through the CRC gate, and releases dispatch. Jobs queued during the swap run against the new model. If the incoming model fails its load-time CRC, the swap aborts with the old model still resident and serving — a corrupt update can cost an update, never availability. OTA activation and rollback ride this same mechanism.
The persistent model store
Phase 4 adds a flash-backed store beneath the RAM registry, built from two mechanisms:
- A ping-pong registry. A fixed 192-byte registry image (generation counter, active/staged slot assignments, per-slot metadata, wear counters, CRC32) is written alternately across two 8 KB flash sectors — each commit erases and programs the other copy, and boot adopts the newest valid one. A power loss anywhere during commit leaves the previous generation authoritative; alternating copies also halves sector wear, which the registry itself records. Board-measured: commit 1.9–2.6 ms, boot scan 23–189 µs.
- A/B model slots holding
.synmimages. Models rest and travel in one format: a 64-byte header (magic, name, size, shapes, payload CRC32) followed by the raw payload, produced on the host bytools/syn_model_pack.py. The slot not referenced by the active generation is the staging target for the next update, and everysyn_model_load()of a slot-backed model re-checks the payload CRC32 from flash before the model becomes loadable (-EILSEQon mismatch, registry state untouched).
On boards, CONFIG_SYNAPTIC_STORE_AUTO_INIT brings the store up before main() — and before CPU1 release — so a persistent active model is registered, payload mapped directly from flash (no RAM copy), without any application code.
OTA updates: the dual-core-safe flash map
The update design leans on the MCXN947's 2 MB dual-bank flash — with one constraint the original plan didn't have: since Phase 3, bank 1 is CPU1's execute-in-place bank. Bank 0 holds CPU0's firmware and is never erased at runtime; bank 1 opens with a 128 KB CPU1 image reserve that is excluded from every OTA erase and write range, followed by the registry pair and two 440 KB model slots:
BANK 0 0x00000000 cpu0_firmware 1024 KB never runtime-erased
BANK 1 0x00100000 cpu1_image 128 KB excluded from all OTA ranges
0x00120000 registry A 8 KB ping-pong copy A
0x00122000 registry B 8 KB ping-pong copy B
0x00124000 model_slot_a 440 KB
0x00192000 model_slot_b 440 KBThe map is generated from one source of truth (tools/syn_flash_layout.py emits both the C header and the devicetree overlay), enforced by build-time asserts that cross-check the two and pin the safety invariants, and enforced again at runtime by a flash port that rejects any erase or write outside the OTA window. The overlay also deletes the stock Zephyr MCUboot partitions — the stock slot1_partition straddles the CPU1 image. An update never touches the running model until the new one is fully staged and validated:
syn_ota_begin()erases the staging slot sector-by-sector; on a dual-core board it parks CPU1 first (flash operations in bank 1 disturb its instruction fetches — offload pauses, local CPU0 inference keeps serving).- The new model streams in chunks of any size, buffered to the 128-byte flash page.
syn_ota_finish()validates the staged image from flash — magic, size, name, payload CRC32 — and commits a staged record; a staged update survives reboot.- On command,
syn_ota_activate()makes the staged slot active with one power-loss-safe registry commit and hot-swaps inference to it; CPU1 is released through the normal blank-check-guarded boot path (resume: 1,514 µs boot + 1,519 µs handshake, board-measured). - If the new model disappoints,
syn_ota_rollback()reverts to the previous slot — demonstrated on the board recovering service from an unloadable update in one shell command.
The OTA state machine
The API in syn_model_ota.h exposes that flow as a small state machine: IDLE → DOWNLOADING → VALIDATING → STAGING → READY, with ERROR as the failure state.
typedef enum {
SYN_OTA_STATE_IDLE,
SYN_OTA_STATE_DOWNLOADING,
SYN_OTA_STATE_VALIDATING,
SYN_OTA_STATE_STAGING,
SYN_OTA_STATE_READY,
SYN_OTA_STATE_ERROR,
} syn_ota_state_t;
int syn_ota_begin(const char *model_name, size_t total_size);
int syn_ota_write_chunk(const uint8_t *data, size_t len);
int syn_ota_finish(void); /* Validates CRC & stages */
int syn_ota_activate(void); /* Atomic swap */
int syn_ota_rollback(void); /* Revert to previous */
syn_ota_state_t syn_ota_get_state(void);syn_ota_begin() opens a transfer, syn_ota_write_chunk() streams data into the staging slot, syn_ota_finish() runs the CRC32 validation and stages the result, and syn_ota_activate() performs the commit + hot-swap. syn_ota_rollback() reverts to the previous slot at any point after activation — and, thanks to the staged record surviving reboot, activate() also works from IDLE on the next boot. OTA support is gated by CONFIG_SYNAPTIC_OTA. See the API reference, and a live board capture in the Phase 4 OTA session.
Implementation status in v0.4.0
Everything on this page shipped in Phase 4 and was verified on the FRDM-MCXN947 on 2026-07-15: reboot persistence, CRC-gated loads, wear tracking, power loss mid-transfer and mid-commit (previous model boots), a staged update surviving reboot, activation, rollback (including recovering from an unloadable update), hot-swap continuity, and the dual-core park/resume path — plus 25 dedicated QEMU tests over a RAM-emulated flash port with fault injection. Two honest labels: model payloads execute on the deterministic stub NPU until the Neutron SDK invoke path lands, and the demo UART transport is throughput-bound at ~5.4 KB/s (a slot-max 432 KB update takes 80.5 s; the engine itself is chunk-size- and transport-agnostic).