Docs / Concepts / Dual-core design & IPC
Dual-core design & IPCv0.5.0
The MCXN947 has two Cortex-M33 cores, and SynapticOS assigns them asymmetric roles: CPU0 is the AI core that owns the entire inference stack, CPU1 is the application core that requests inference as an OS service. The two meet in a 96 KB shared-SRAM region carrying a pair of lock-free message rings and a zero-copy tensor exchange slot. Everything on this page is implemented and verified on the FRDM-MCXN947 board as of Phase 3 (v0.3.0).
Two unequal cores
The asymmetry runs deeper than the role split: the MCXN947's second core has no FPU, no DSP extension, no TrustZone, and no MPU (its CMSIS device header sets all four *_PRESENT macros to zero). SynapticOS treats that as a design input. Everything that needs the capable core — models, the NPU, PowerQuad, the pipeline engine and scheduler — stays on CPU0; CPU1 gets a message-based service interface instead. Because CPU1 also lacks TrustZone, the two cores see the same physical memory at different addresses: CPU0 runs Secure and uses the +0x1000_0000 aliases (RAM at 0x3xxx_xxxx), CPU1 uses the plain addresses. One shared layout header folds the alias in per core, and payload references are offsets, never pointers.
| Resource | CPU0 (AI core) | CPU1 (app core) |
|---|---|---|
| Role | AI runtime, NPU orchestration, serving | Application logic, inference requests |
| FPU / DSP / TrustZone / MPU | All present | None (hardware) |
| Zephyr | Full kernel + SynapticOS subsystem + shell | Minimal kernel + app threads (no console) |
| Boot | Primary — verifies CPU1's image, then releases it | Secondary — released by CPU0 via SYSCON |
| NPU / PowerQuad | Exclusive ownership | Via IPC request to CPU0 |
| Code | XIP from flash bank 0 | XIP from flash bank 1 (0x0010_0000) |
| SRAM | 256 KB private (128 KB tensor arena) | 64 KB private |
| Shared | 96 KB: control block + two rings + exchange slot | |
The SRAM memory map
The MCXN947's main SRAM is 416 KB at 0x2000_0000 (the remaining 96 KB of the headline 512 KB is RAMX on the code bus). It tiles into three regions with no gaps and no overlap — and the shared layout header carries BUILD_ASSERTs proving exactly that, so a layout mismatch between the two images is a compile error:
| Range (CPU1 view) | Size | Owner | Contents |
|---|---|---|---|
0x2000_0000 – 0x2003_FFFF | 256 KB | CPU0 private | SynapticOS runtime, 128 KB tensor arena, scratch, model metadata |
0x2004_0000 – 0x2005_7FFF | 96 KB | Shared | Control block (64 B), two SPSC rings (16 × 20 B messages each), inference exchange slot (27,648 B input + 4,096 B output) |
0x2005_8000 – 0x2006_7FFF | 64 KB | CPU1 private | Application threads, stacks, IPC dispatch |
Cross-core memory protection
CPU0 programs its last MPU region (region 7) over CPU1's 64 KB at boot, read-only. A CPU0 write into CPU1's RAM raises a MemManage fault; the fault handler logs the full dump, aborts only the offending thread, and both cores keep running — the syn mpu test shell command demonstrates the whole sequence on demand, live. The guard is programmed at runtime rather than declared in devicetree, because a Zephyr flash-driver Kconfig (SOC_FLASH_MCUX selecting MPU_ALLOW_FLASH_WRITE) turned out to silently rewrite devicetree-declared read-only attributes to read-write.
The protection is one-directional and write-only, and the docs say so plainly: CPU1 has no MPU, so nothing on this silicon can constrain CPU1's accesses to CPU0's memory; and ARMv8-M offers no encoding that blocks privileged reads while the background map is enabled, so CPU0 reads of CPU1 RAM are not blockable. What is enforced — verified by fault injection on the board — is the integrity of the application core's memory against runtime-core bugs.
The lock-free IPC rings
The shared region carries two SPSC (single-producer, single-consumer) rings, one per direction, so every ring index has exactly one writer core: CPU0 produces into one ring and consumes the other, CPU1 the reverse. Indices are free-running 32-bit counters (slot = index mod CONFIG_SYNAPTIC_IPC_RING_SIZE, full when head − tail equals the entry count; wraparound at 2³² is safe by unsigned arithmetic and unit-tested at UINT32_MAX). Ordering uses data-memory barriers only — no locks, no cross-core atomics. Head and tail live in separate 64-byte blocks to avoid false sharing. The measured cost is 25 cycles per push. A full ring makes syn_ipc_send() return -EAGAIN; an empty ring makes syn_ipc_receive() block until its timeout.
Because ring state lives in shared SRAM rather than either core's private memory, a rebooting CPU1 re-attaches to live indices and the conversation continues — no CPU0 involvement. Signaling uses the MCXN947's MAILBOX inter-core interrupt via NXP's header-only HAL (Zephyr 3.7's mbox driver does not support this SoC's cores); a dispatch thread on each core is the sole ring consumer, running registered per-type handlers and forwarding unhandled types to the queue behind syn_ipc_receive().
Each slot carries a fixed-size message defined in syn_ipc.h:
/* IPC message structure (shared SRAM) */
typedef struct {
uint32_t msg_id;
uint8_t type; /* SYN_IPC_INFER_REQ, _RESP, ... */
uint8_t priority; /* scheduler priority class */
uint16_t payload_len;
uint32_t payload_offset; /* Offset into the shared region */
uint32_t timestamp_us;
int32_t status; /* 0=OK, negative=error */
} syn_ipc_msg_t;
int syn_ipc_init(void *shared_base, size_t shared_size);
int syn_ipc_send(const syn_ipc_msg_t *msg);
int syn_ipc_receive(syn_ipc_msg_t *msg, uint32_t timeout_ms);
int syn_ipc_register_handler(syn_ipc_type_t type,
syn_ipc_handler_t handler, void *ctx);Six message types are defined: SYN_IPC_INFER_REQ/SYN_IPC_INFER_RESP for inference, SYN_IPC_MODEL_LOAD/SYN_IPC_MODEL_UNLOAD for remote model control, and SYN_IPC_STATUS_REQ/SYN_IPC_STATUS_RESP for health checks and the boot handshake. The 20-byte wire format and every field offset are pinned by unit tests. The message payload is never the tensor itself — payload_offset is an offset (valid in both cores' address views) into the shared region, keeping ring messages small.
Inference as a remote system call
From CPU1's side, cross-core inference looks like a blocking system call: resolve a model by name (MODEL_LOAD returns the handle; the model itself never leaves CPU0), stage the input tensor directly into the shared exchange slot — it is the working buffer, not a copy target, because a 27,648-byte camera frame cannot exist twice in CPU1's 64 KB — then send INFER_REQ and block. On CPU0, the serving layer submits the tensor to the Phase 2 scheduler with the priority class carried in the message, so remote and local jobs contend in a single priority space. The response's status field carries the inference result code; a timeout surfaces as -ETIMEDOUT. One request is in flight at a time by design — callers on CPU1 serialize on a local mutex ahead of the slot.
Boot sequence and measured latencies
CPU0 boots first, initializes the runtime and the shared region, and — before touching the release registers — verifies that flash bank 1 actually contains an image, using the MCX ROM API's flash-controller commands (safe against erased pages in a way bus reads are not: erased-flash reads raise ECC bus errors on this part, and releasing CPU1 into a blank bank stalls the flash system both cores depend on, taking the debug port with it). A blank bank logs CPU1 image absent: single-core mode and the full single-core system comes up. With a valid image, CPU0 writes CPU1's vector address to SYSCON→CPBOOT, releases it via CPUCTRL, and waits for the ready flag and a STATUS_REQ handshake.
Measured on the board (FRDM-MCXN947, both cores at 150 MHz): CPU1 is ready 1,514 µs after release (budget: 100 ms) and the handshake completes 2,554–2,577 µs after release (budget: 200 ms) — bit-identical timing across 11 consecutive reset cycles. IPC round-trip, measured by CPU1's own cycle counter and published through the shared control block, is 15 µs typical, 81 µs worst-case against the 50 µs target (the tail occurs when a message lands while CPU0's dispatch thread is mid-inference). A 1,913-serve two-model soak ran with zero errors. Inference-latency figures from this setup are stub-NPU baselines (the Neutron invoke path is a later phase); the boot, handshake, and round-trip numbers measure the real mechanisms end to end.
Implementation status in v0.3.0
The frozen syn_ipc.h API defined in Phase 1 is now fully implemented — without amendment. The SPSC rings, MAILBOX signaling, dual-core boot with blank-bank fallback, the MPU guard, and the cross-core inference protocol are all verified on the FRDM-MCXN947 (2026-07-14 serial captures). Known limits, stated honestly: one cross-core request in flight at a time; protection is one-directional (CPU1 has no MPU) and write-only (ARMv8-M cannot block privileged reads with the background map on); inference latencies are stub-NPU baselines until the Neutron path lands.
Since v0.5.0 the shared control block carries a CPU1 heartbeat word (shared-layout version 2 — mismatched CPU0/CPU1 images refuse to pair, so both images must always be flashed together): a CPU1 timer ticks it every 100 ms, and CPU0's health monitor watches it while the link is up. A stall longer than CONFIG_SYNAPTIC_CPU1_HANG_MS (default 500 ms) triggers automatic recovery: park CPU1 and re-release it through the normal blank-check-guarded boot path, without disturbing CPU0. Demonstrated live on the board (2026-08-10) with genuine fault injection — syn health hang cpu1 spins CPU1’s heartbeat ISR with interrupts off — the stall was detected at 600 ms (threshold + one 100 ms tick), the core re-released in 1.3 ms, and serving resumed with CPU0’s uptime continuous. An OTA-parked CPU1 clears the link flag, so the monitor never mistakes a deliberate park for a hang. CPU1 loss is soft-recovered and deliberately not wired to CPU0’s hardware watchdog.
Because CPU1 executes in place from flash bank 1 — the bank that also hosts the model store — an OTA session parks CPU1 in reset for its duration (syn_ota_begin() uses the inverse of the release sequence) and every terminal state releases it through the normal blank-check-guarded boot path. Board-measured (2026-07-15, under continuous offload load): resume costs 1,514 µs boot + 1,519 µs handshake — the same as a cold release — and after the full OTA campaign the link showed 39,402 inferences served with zero errors, round-trip 16/79 µs, with syn mpu test passing before and after every OTA operation. Local CPU0 inference keeps serving during the session; only cross-core offload pauses. See Model lifecycle & OTA updates.