GitHub

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.

ResourceCPU0 (AI core)CPU1 (app core)
RoleAI runtime, NPU orchestration, servingApplication logic, inference requests
FPU / DSP / TrustZone / MPUAll presentNone (hardware)
ZephyrFull kernel + SynapticOS subsystem + shellMinimal kernel + app threads (no console)
BootPrimary — verifies CPU1's image, then releases itSecondary — released by CPU0 via SYSCON
NPU / PowerQuadExclusive ownershipVia IPC request to CPU0
CodeXIP from flash bank 0XIP from flash bank 1 (0x0010_0000)
SRAM256 KB private (128 KB tensor arena)64 KB private
Shared96 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)SizeOwnerContents
0x2000_0000 – 0x2003_FFFF256 KBCPU0 privateSynapticOS runtime, 128 KB tensor arena, scratch, model metadata
0x2004_0000 – 0x2005_7FFF96 KBSharedControl 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_7FFF64 KBCPU1 privateApplication 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:

c
/* 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

Phase 3 · Implemented and board-verified

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.

Phase 5 · CPU1 heartbeat supervision and hang recovery

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.

Phase 4 · CPU1 park/resume during OTA

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.