Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Consortium

Consortium is a Rust framework and build system for heterogeneous SoCs: systems where Linux-capable application cores work beside real-time Cortex-M, Cortex-R, or RISC-V cores. It turns the contracts between those processors into explicit, checked inputs instead of scattered board-specific glue.

A Consortium project starts with three things:

  • a Consortium.toml manifest describing processors, memory, IPC, peripherals, and optional TEE, debug, and HMI services;
  • a shared Rust crate containing the types that cross processor boundaries; and
  • one endpoint crate for each participating runtime.

From those inputs, csti build validates the complete system, generates endpoint initialization code, builds its artifacts, and assembles a local dist/ tree for the target’s packaging flow.

Consortium.toml + shared types + endpoint crates
                        |
                 validate and lower
                        |
       generated Context, DTS, UIO, and build inputs
                        |
             Linux app + firmware + optional services
                        |
                  local dist/ tree

What Consortium provides

  • Typed IPC. Allocation-free channels combine a transport, codec, direction, and message type. Shared-memory and UART transports use the same core contracts.
  • Cross-core configuration. Chip databases validate memory placement, doorbell channels, visible cores, peripheral ownership, and generated Linux integration.
  • Generated bring-up. Linux and firmware entry macros receive a ready Context instead of repeating transport, debug-ring, timer, and peripheral setup.
  • Target integration. The builder emits remoteproc-ready firmware, device-tree and kernel inputs, a systemd unit, and a BitBake-ready staging tree.
  • Optional services. OP-TEE contracts, shared-memory defmt logging, and Cog or Pocket HMI packaging integrate with the same project model.

Project status

Consortium is early-stage and its APIs are evolving. The i.MX 9 and STM32MP2 families are the active reference platforms. Prefer this user guide, current crate sources, and the melt-pot examples over older development notes when they disagree.

New readers should continue with Getting Started. Readers evaluating the design can begin with System Architecture.

Introduction

Modern heterogeneous SoCs combine processors with different execution models on one piece of silicon. Linux may own networking, storage, and the user interface while a real-time core owns deterministic control loops; a trusted application may protect a small security boundary alongside both.

That arrangement is powerful, but every boundary needs an explicit contract. The chapters in this section introduce the four domains Consortium connects:

  • HAMPU explains the hardware model and why asymmetric systems are useful.
  • IPC introduces typed communication between processors.
  • TEE describes calls across the normal-world/secure-world boundary.
  • HMI covers the application-facing user-interface layer.

The User Guide then maps those ideas to the current manifest, generated runtime, and build pipeline.

What HAMPU Is

Heterogeneous and Asymmetric Multiprocessing Units (HAMPU) refers to a computing system that integrates multiple processing units with different purposes and capabilities. A typical HAMPU system includes at least one application core and one real-time core, commonly Cortex-A and Cortex-M cores respectively.

The application core handles general-purpose computing tasks, often with a standard OS such as Linux. The real-time core is optimized for deterministic, time-sensitive workloads. Modern HAMPU systems may also include NPUs, GPUs, DSPs, VPUs, or other accelerators to support AI, graphics, multimedia, and specialized processing.

This architecture enables a single system to efficiently support general-purpose applications, real-time control, and hardware-accelerated workloads.

Comparison with Other Architectures

Compared with a traditional MCU that only includes Cortex-M or RISC-V cores, a HAMPU system can run advanced applications with standard OS support. This reduces the overhead of porting and maintaining applications while providing a more comfortable deployment environment. For example, the application core can run Linux-based HMI logic through WPE WebKit and execute image recognition workloads through an NPU, while the real-time core simultaneously handles peripheral data and actuator control, such as CAN bus communication.

Compared with application processors that only include Cortex-A or x86 cores, HAMPU systems can offload time-sensitive tasks to the real-time core. This improves system responsiveness and avoids the need to patch Linux for pseudo real-time behavior. Instead, bare-metal or RTOS programming on the real-time core can provide deterministic execution for tasks with strict timing constraints.

Compared with serial-wired upper-lower architectures, HAMPU systems integrate the application core and real-time core on the same chip. This reduces communication latency and improves system performance. The two cores can exchange data through shared memory or inter-core communication mechanisms, enabling tighter coordination than systems where the application processor and microcontroller are separate devices.

Typical HAMPU Chips

NXP i.MX 95

The NXP i.MX 95 is a flagship HAMPU chip that combines Cortex-A55 application cores, Cortex-M7 real-time cores, Cortex-M33 system manager cores, and integrated NPUs, GPUs, and VPUs. It is suitable for high-performance applications requiring AI, machine learning, multimedia processing, and real-time control.

Development can be done with the FRDM-IMX95 board. Consortium provides official examples and tutorials for setting up the development environment, programming both application and real-time cores, and leveraging integrated accelerators.

STM32MP257

The STM32MP257 combines Cortex-A35 application cores, Cortex-M33 real-time cores, and integrated NPUs and GPUs. It is designed for industrial automation, consumer electronics, and IoT applications. It is also suitable for critical-mission scenarios, with SIL 3 certification and support for TrustZone-based resource partition isolation.

Development can be done with the STM32MP257F-DK board. Consortium provides examples and tutorials for using both application and real-time cores, as well as the integrated NPU and GPU.

NXP i.MX 93

The NXP i.MX 93 is a more cost-effective HAMPU chip compared with the i.MX 95. It includes Cortex-A55 application cores, Cortex-M33 real-time cores, and integrated NPUs and GPUs. It is suitable for applications that need a balance between performance, cost, and acceleration, such as smart home devices, wearables, and consumer electronics.

Development can be done with the FRDM-IMX93 board. Consortium provides examples and tutorials covering environment setup, multi-core programming, and accelerator usage.

STM32MP215

The STM32MP215 is a more cost-effective option compared with the STM32MP257. It combines Cortex-A35 application cores and Cortex-M33 real-time cores, without integrated NPUs or GPUs. It targets applications that require a practical balance of performance, security, and real-time capability, such as industrial automation, consumer electronics, and IoT devices.

Development can be done with the STM32MP215F-DK board. Consortium provides tutorials and examples for developing applications across the application and real-time cores.

Chip Generations

Common HAMPU chips usually combine Cortex-A and Cortex-M cores. Earlier examples include the STM32MP1 series, with Cortex-A7 application cores and a Cortex-M4 real-time core, and the NXP i.MX 8M series, with Cortex-A53 application cores and a Cortex-M4 real-time core.

In Consortium, the focus is on newer HAMPU generations, especially the NXP i.MX 9x series and STM32MP2x series. Compared with previous generations, these chips provide stronger application cores, such as Cortex-A55, and newer real-time cores, such as Cortex-M33.

A key improvement is resource partition isolation through Cortex-M33-based designs. This helps reduce security risks caused by different cores competing for shared resources such as memory and peripherals. In older designs, similar functionality often required combining an application processor, such as the NXP i.MX 8M Plus, with a separate MCU, such as an STM32F4.

Benefits and Challenges

The main cost of HAMPU is increased software and system complexity. Developers must coordinate multiple processing units, manage inter-core communication, and handle resource partitioning. Integration on a single chip may also increase power consumption and thermal requirements, depending on workload and system design.

Despite these challenges, HAMPU systems provide strong benefits in performance, flexibility, and real-time capability. They are well suited for applications ranging from consumer electronics to industrial automation.

The Consortium framework is designed to address HAMPU development complexity by providing a unified programming model and toolchain. It abstracts coordination between different processing units, allowing developers to focus on application logic rather than low-level hardware details. With Consortium, developers can write code that runs across both application and real-time cores while taking full advantage of the HAMPU architecture.

Inter-Processor Communication

Inter-processor communication (IPC) lets processors with separate runtimes cooperate without pretending they share one address space. A Linux application and a real-time firmware image may observe the same SRAM, but they do not share Rust references, heaps, executors, or failure handling.

Consortium models that boundary as typed messages over an explicit transport.

The conventional path

Linux systems often use RPMsg: VirtIO vrings carry buffers in shared memory and a mailbox interrupt notifies the remote processor. RPMsg is a good fit when Linux kernel integration and OpenAMP interoperability are the primary requirements.

Consortium offers a smaller userspace-oriented path for systems that want the cross-core contract expressed directly in Rust. Linux maps validated memory and doorbell windows through UIO; firmware accesses the corresponding physical regions and hardware interrupt lines.

The Consortium model

message type
    |
CodecFor<T>       typed value <-> bytes
    |
Channel<Tx/Rx>    direction, channel id, and fixed scratch buffer
    |
Transport         byte movement and MTU
    |
Doorbell          peer notification when the transport needs one

A Transceiver pairs one Tx channel and one Rx channel. Application code normally sees only its generated transceivers and calls send().await or recv().await; the manifest selects the concrete codec, transport, doorbell, and memory region.

Channel does not allocate. It encodes or decodes through a fixed scratch buffer sized to the transport MTU. This keeps the same API usable under Tokio on Linux and in a no_std firmware image.

Shared-memory IPC

The shared-memory transport owns a CIPC descriptor followed by directional message slots. Directions are absolute—primary to secondary and secondary to primary—then resolved into local Tx and Rx halves for each processor.

Before traffic begins, both processors run a readiness rendezvous through Connect:

  1. the primary publishes the descriptor and proposed layout;
  2. the secondary validates the magic, version, channel count, and MTU; and
  3. the secondary acknowledges the descriptor.

Generated initialization performs this handshake once, before splitting the transport. Connect intentionally has no timeout; callers using the lower-level API own their deadline policy.

A doorbell carries no payload. It publishes the fact that transport state changed and provides the ordering edge that makes earlier shared-memory writes visible to the peer. Current hardware bindings cover STM32 HSEM/IPCC, i.MX MU, and GPIO signaling.

Memory visibility

consortium-shared-memory makes cache behavior part of the region type:

  • SramRegion models coherent on-chip SRAM and uses no-op cache maintenance;
  • DdrRegion performs AArch64 data-cache maintenance where required; and
  • SerialRegion is a virtual, unbacked region used for framed transports.

On Linux, shared and doorbell windows are mapped from /dev/uioN. On firmware, the generated code uses validated physical addresses and configures the expected memory attributes. The same manifest also drives reserved-memory and UIO device-tree output.

UART IPC

UART is a transport-coupled notification path: receiving bytes is itself the wakeup, so there is no separate Doorbell or processor Side. Frames use:

COBS(logical channel | payload | CRC32 little-endian) + 0x00

The host implementation uses Tokio serial I/O and provides pseudo-terminal test pairs. The firmware adapter works with embedded-io-async; chip features add interrupt-buffered UART drivers for i.MX9 and STM32MP2.

Safe message boundaries

#[derive(IpcSafe)] recursively rejects values that cannot be meaningful on another processor: raw pointers, references, function pointers, usize, and isize. It is designed to catch address-space and word-size mistakes at compile time.

The codec supplies the serialization contract. Postcard and prost produce owned decoded values; rkyv can return a validated view borrowing the receive buffer. For this reason recv() returns ReceivedMessage instead of assuming every codec creates an owned T.

See System Architecture for the crate layout and Generated Runtime for endpoint code.

Trusted Execution Environment (TEE)

A Trusted Execution Environment (TEE) is an isolated execution domain for security-sensitive code and data. On ARM platforms, a TEE is commonly built with TrustZone, which separates the system into the normal world and the secure world.

The normal world usually runs Linux and ordinary applications. The secure world runs a trusted OS and trusted applications.

Traditional Path: OP-TEE

A common TrustZone-based TEE stack is OP-TEE.

Normal world application
        |
        |  TEE Client API / libteec
        v
Linux TEE / OP-TEE driver
        |
        |  SMC or FF-A
        v
OP-TEE OS
        |
        v
Trusted application

In this model, developers typically implement two components: a normal-world client and a secure-world trusted application. The client opens a session, invokes command IDs, and passes parameters. The trusted application validates the request, executes the sensitive operation, and returns a result.

Trusted applications are commonly written in C using the OP-TEE TA development model. The main command dispatch point is usually TA_InvokeCommandEntryPoint.

Apache Teaclave TrustZone SDK

Apache Teaclave TrustZone SDK is a Rust-oriented development SDK for writing trusted applications on OP-TEE-compatible TrustZone systems.

It does not replace OP-TEE as the TEE stack. Instead, it provides a different trusted-application development model, allowing secure-world logic to be written in Rust while still relying on the underlying TrustZone and OP-TEE execution path.

A Refined Developer Experience

After investigating how TEE calls work, we can separate a TEE invocation into the following steps:

  1. CA prepares data and invokes TA through TA_InvokeCommandEntryPoint;
  2. TA processes the request and returns the expected result or error information;
  3. CA receives the result and proceeds.

Essentially, this resembles a generic function call with additional TEE-related boilerplate. Therefore, in Consortium, we introduce a better TEE developer experience through proc macros. The design contains three components:

  • tee_service!: initializes the Command enum and the invoke_command() function.
  • #[tee_command]: allows a command to be written as a normal Rust function, then automatically generates a _dispatched wrapper for TA-side parameter parsing and a call_ wrapper for CA-side parameter packing. Through Codec, compatible structs or enums can also be passed through memref.
  • #[derive(TeeParam)]: checks whether the target struct or enum is compatible across the Secure world and Non-Secure world boundary, such as rejecting pointers or architecture-specific-sized types, and implements conversion between native Rust types and TEE-compatible representations.

After proc macro expansion, the boilerplate code required by Apache Teaclave is generated automatically. The final code developers write therefore resembles a normal function in a standalone crate.

We recommend that developers adopt a dedicated ta crate for two purposes: lib.rs exports the generated code for both TA and CA usage, while main.rs contains the TA-side entry code. In the CA crate, the ta crate is imported with the ca feature enabled. This keeps the TEE interface as a single source of truth shared by both sides.

Human-Machine Interface

An HMI belongs on the Linux-capable side of a heterogeneous system, where display, input, graphics, and application services are available. Real-time or safety-critical control remains behind typed service and IPC boundaries instead of moving into the UI process.

Consortium separates the application-service bridge from the rendering engine. consortium-hmi is a backend-neutral command registry: a UI sends a command and string payload, and the bridge returns a response future. It contains no WebKit, GLib, Pocket, or Tokio runtime state.

Cog and WPE WebKit

consortium-hmi-webkit adapts the bridge to Cog/WPE WebKit. It is useful for existing web applications and products that need browser APIs while retaining a kiosk-focused embedded runtime.

web application
      |
Consortium command bridge
      |
Cog / WPE WebKit
      |
Wayland or direct display backend

In development, the application can load the [hmi].debug URL from a local frontend server. Release builds package the generated assets from [hmi].build and stage them under dist/www/.

Pocket

consortium-hmi-pocket is the native path. PocketJS compiles a reactive Solid or Vue Vapor UI into a JavaScript bundle and resource pack consumed by a retained Rust UI runtime. Consortium provides windowed and headless shells around the same frame contract, plus the common service bridge.

The build pipeline accepts engine = "pocket", invokes the Pocket build, and stages <app>.js with <app>.pak under dist/hmi/. The manifest also carries the logical viewport. Pocket currently supports Wayland and headless modes; a direct DRM/KMS shell is not yet available.

Choosing an engine

Choose Cog when compatibility with browser APIs or an existing web application matters most. Choose Pocket when a native retained renderer, smaller browser-free runtime, and headless visual testing fit the product better.

Both engines keep product services behind the same bridge, which limits engine-specific code and makes it possible to change the renderer without redesigning backend commands. See the Project Manifest for the current [hmi] fields.

User Guide

Consortium treats a multi-processor product as one buildable system. The normal workflow is deliberately short:

  1. describe the topology and resource ownership in Consortium.toml;
  2. define boundary-safe message types in a shared crate;
  3. write application and firmware logic against the generated Context;
  4. run csti build; and
  5. integrate the resulting dist/ tree with the target image.

The chapters follow that sequence:

The development notes later in the book record design work and hardware bring-up. They are useful context, but they are not the authoritative API reference.

Getting Started

This chapter has two paths: a fast host-side contributor check, and a complete SoC build. Use the first to explore the framework without hardware.

Requirements

The workspace requires Rust 1.87 or newer and just. The setup recipe installs the additional Rust targets and development tools used by the repository:

just init
just setup

Run just at any time to list the current recipes. On Linux, the main host check is:

just check

Focused checks are quicker while iterating:

just test ipc host
just test cfg host
just test dbg host

On macOS, run Rust builds through the repository’s Linux container. The base image is enough for focused subsystem checks:

just linux-image
just linux 'just test ipc host'
just linux 'just test cfg host'

The full image adds the WPE and OP-TEE dependencies needed by the complete workspace and melt-pot builds.

Tour the reference project

examples/melt-pot/ contains equivalent systems for STM32MP25 and i.MX95. Each has:

Consortium.toml   whole-system configuration
app/              Linux application endpoint
mcu/              real-time firmware endpoint
ta/               optional OP-TEE trusted application
../shared/        message types shared by both processors
website/          optional Cog/WPE frontend

Start with examples/melt-pot/stm32mp25/Consortium.toml or examples/melt-pot/imx95/Consortium.toml. These manifests track the current builder and chip databases.

The endpoint crates call include!("consortium.gen.rs"). That file is intentionally generated by the pipeline, so a plain cargo check in an endpoint directory may fail before the first system build. The shared crate remains an ordinary workspace member and can be checked independently.

Build the complete example

The complete pipeline needs a Linux environment and the target-facing dependencies used by the selected HMI and TEE configuration. From a prepared Linux host:

cargo run -p csti -- build \
  --manifest examples/melt-pot/imx95/Consortium.toml \
  --dist dist/imx95 \
  --release \
  --skip-tee-sign

On macOS, the repository’s full Linux image supplies the Linux-only dependencies:

just linux-image-full
just linux-full 'cargo run -p csti -- build --manifest examples/melt-pot/imx95/Consortium.toml --dist dist/imx95 --release --skip-tee-sign'

--skip-tee-sign is useful on development machines without OP-TEE signing keys. It still builds the trusted application and stages its unsigned ELF.

The command creates a local staging tree; it does not copy files to a board or modify a live target. Continue with Project Manifest before adapting the example, then read Build and Deployment for the output layout.

Read the book locally

mdbook serve docs/book/

The development server prints the local URL and rebuilds the book when Markdown files change.

System Architecture

Consortium separates the system’s contracts from its mechanisms. The manifest and shared Rust types define what must agree across processors; generated code chooses and initializes the mechanisms that satisfy those contracts on a particular chip.

From description to running endpoints

                         build time

  Consortium.toml ──> parse + validate ──> lowered system model
         +                                      |
  shared Rust types                             +── generated endpoint modules
         +                                      +── compile-time configuration
  endpoint crates                               +── DTS, UIO, Kconfig, boot files
                                                        |
                                                        v
                         run time

                Linux Context <── typed IPC ──> firmware Context
                     |                                |
              UIO / remoteproc                  HAL / interrupts

Validation happens before compilation. A channel with an unavailable doorbell, an out-of-range channel number, an overlapping memory region, or an invisible endpoint is rejected at the system boundary rather than discovered during board bring-up.

Layers

LayerMain cratesResponsibility
Contractsconsortium-codec, consortium-ipc, consortium-tee, consortium-hmiPortable types and traits shared by runtimes
Mechanismsconsortium-ipc-transport-*, consortium-ipc-doorbell-*, consortium-shared-memoryByte movement, signaling, framing, and cache visibility
Hardwareconsortium-hal-*, consortium-pac-*Peripheral ownership and chip-specific register access
Configurationconsortium-cfg-common, consortium-cfg, consortium-dataSchema, chip databases, validation, lowering, and code generation
Runtimeconsortium-runtime-app, consortium-runtime-mcuLinux and firmware integration around generated code
Buildconsortium-builder, cstiCompile, post-process, stage, and hand off artifacts

The core IPC and codec crates are no_std by default. Linux-only concerns such as UIO, remoteproc, and host-side debug decoding stay in consortium-runtime-app; firmware keeps a small runtime surface suitable for Embassy and bare-metal targets.

The IPC composition model

Typed IPC is built from small independent pieces:

Doorbell                      signal the peer; carries no payload
SendTransport / RecvTransport move bytes in one direction
Transport                     full-duplex marker
Connect                       one-time peer readiness rendezvous
CodecFor<T>                   convert one message type to and from bytes
Channel<Tx/Rx, ...>           typed, directed endpoint
Transceiver                   paired send and receive channels

For shared memory, the transport owns a CIPC descriptor and one slot per direction. The primary publishes the descriptor; the secondary validates its version and layout, then acknowledges it. Generated init() calls Connect::connect once before traffic and before splitting the transport into its Tx and Rx halves.

The UART transport uses the same byte-transport traits but needs no separate doorbell: byte arrival is the notification. Its wire format is a COBS-delimited frame containing a logical channel, payload, and little-endian CRC32.

Boundary safety

#[derive(IpcSafe)] rejects address-space-local values such as pointers, references, function pointers, usize, and isize. Serialization requirements remain codec specific: postcard messages derive serde traits, prost messages implement Message, and rkyv messages use the archive traits generated for that family.

This check is structural, not a replacement for protocol design. Stable integer widths, explicit units, bounded payloads, and versioning still belong in the shared message crate.

Ownership rules

Consortium makes ownership concrete at every layer:

  • a manifest assigns each peripheral and IPC endpoint to a visible processor;
  • a chip HAL’s init() hands out each peripheral singleton once;
  • a generated Context owns the live transports, UIO mappings, and peripherals for one endpoint; and
  • channels own their fixed scratch buffers, so normal send and receive paths do not allocate.

The application still owns interrupt handlers on firmware. Doorbell and HAL crates export wake functions, but do not install vectors behind the application’s back.

Project Manifest

Consortium.toml is the source of truth for the complete system. Paths are resolved relative to the manifest, while hardware names and address ranges are checked against the selected chip database.

A minimal shared-memory system

This i.MX95 fragment follows the current melt-pot reference layout:

[profile]
chip        = "mimx9596xvt"
devkit      = "imx95-19x19-evk"
shared      = "../shared"
deploy_root = "/opt/consortium"

[endpoints]
linux = "./app"
cm7   = "./mcu"

[[ipc.channel]]
name          = "sensor"
doorbell      = "mu7"
pattern       = "memory"
primary       = "linux"
secondary     = "cm7"
party.linux   = { chan = 6, type = "Command" }
party.cm7     = { chan = 7, type = "SensorReading" }
config.memory = { base = 0x00480000, length = 0x4000 }
mtu           = 128

The type under each party is the message sent by that party. Generated Linux code therefore exposes sensor as a transceiver that sends Command and receives SensorReading; the CM7 sees the inverse. Its chan is the doorbell line used for that party’s outgoing direction.

primary and secondary select the shared-memory protocol roles. Current systems use the Linux/A-core as primary and the firmware core as secondary.

Sections

[profile]

FieldRequiredMeaning
chipyesFull part number resolved through the chip database
sharedyesCrate containing IPC and other cross-boundary types
deploy_rootyesOn-target installation prefix used when generating paths; the builder does not write there
devkitnoFull board DTS file stem; falls back to the generic SoC tree
sysrootnoLinux target sysroot; overrides SDK environment variables
kernel_recipenoYocto kernel recipe name used to generate a .bbappend
time_drivernoGenerate the firmware Embassy time driver; defaults to true

The generated time driver uses the reference timer for the family: TIM2 on STM32MP2 or LPIT1 on i.MX9. Set time_driver = false when firmware owns another time base.

[endpoints]

This table maps runtime endpoint names to crate roots. Names must be visible on the selected chip, and every party used by an IPC channel must have a matching endpoint. The conventional application name is linux; controller names include cm33 and cm7 according to the chip.

[[ipc.channel]]

Each entry describes one bidirectional typed channel. The current manifest pattern is memory; the UART transport is available as a crate API but is not lowered from this table yet.

FieldMeaning
nameRust field name under context.ipc_shm
doorbellChip database doorbell, such as ipcc1 or mu7
primary, secondaryEndpoints participating in the descriptor handshake
party.<name>This party’s outgoing doorbell channel and Rust message type
config.memoryPhysical shared region containing the descriptor and slots
mtuMaximum encoded payload size in bytes
codecpostcard by default; prost and rkyv are also supported

Message types must exist in the [profile].shared crate and satisfy the selected codec. The region must be large enough for the descriptor and both directional slots.

[dbg] and [dbg.<core>]

The application processor always hosts debug decoding, so only coprocessor rings are declared:

[dbg]
mode = "auto"

[dbg.cm7]
memory = { base = 0x00484000, length = 0x2000 }
mtu    = 64

auto generates background readers that forward decoded defmt frames to tracing. interface exposes the readers for application-managed polling and decoding.

[dts]

[dts]
mode      = "merge"
linux     = "/path/to/linux"
configfs  = false

merge is the default and emits a full device tree with reserved memory, UIO nodes, and remoteproc fixups. overlay emits a .dtso; it cannot remove existing remoteproc mailbox properties, so use merge mode for those fixups. linux points to a kernel tree whose dt-bindings headers are needed to compile the generated source.

[peripheral.<name>]

[peripheral.lpi2c1]
owner     = "cm7"
secure    = false
clock     = 24000000
frequency = 100000
pin.sda   = "PB0"
pin.scl   = "PB1"

Controller-owned peripherals become typed fields under context.peripherals. The current generated singleton path supports non-secure peripherals; secure = true is rejected. Required clocks, frequencies, pins, and interrupt metadata depend on the driver and chip database.

Optional [tee] and [hmi]

[tee] contains the OP-TEE trusted application’s UUID. [hmi] selects cog or pocket, its display backend, source directory, and build output. Pocket additionally requires an app entry name; it supports wayland and headless today, not drm. The logical viewport defaults to 480 × 272 and can be set under [hmi.logical].

Validation

Manifest parsing does more than syntax checking. A session validates, among other constraints:

  • chip-visible processors, peripherals, and doorbells;
  • doorbell channel bounds and duplicate use;
  • shared and debug region bounds, overlap, and MPU alignment;
  • endpoint presence and peripheral ownership; and
  • HMI engine/backend combinations.

Treat validation failures as contract errors. Fix the manifest or chip data instead of bypassing the generated types with raw addresses.

Generated Runtime

Every endpoint includes a generated consortium.gen.rs. It defines the endpoint’s Context, error types, and async fn init(), all specialized from the same manifest. The runtime entry macros call that function before user code begins.

Define shared messages

With the default postcard codec, a shared message derives serde traits and IpcSafe:

use consortium_ipc::IpcSafe;
use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, Debug, Deserialize, IpcSafe, Serialize)]
pub struct SensorReading {
    pub temperature_mdc: i32,
    pub sequence: u32,
}

#[derive(Clone, Copy, Debug, Deserialize, IpcSafe, Serialize)]
pub struct Command {
    pub set_point_mdc: i32,
}

Fixed-width integers are intentional. References, pointers, function pointers, usize, and isize are not meaningful across different address spaces or word sizes and are rejected by IpcSafe.

Linux application entry

Keep the generated include and entry functions in the same module:

use shared::SensorReading;

include!("consortium.gen.rs");

#[consortium_runtime_app::fail]
fn on_init_failure(error: ConsortiumInitError) {
    eprintln!("Consortium initialization failed: {error:?}");
}

#[consortium_runtime_app::main]
async fn main(mut context: Context) {
    loop {
        let message = context.ipc_shm.sensor.recv().await.expect("IPC receive");
        let reading: SensorReading = message.into_inner();
        tracing::info!(sequence = reading.sequence, "sensor update");
    }
}

The macro installs a Tokio entry point and tracing subscriber, awaits generated initialization, then calls the function with a ready Context. The application crate still needs its own tokio dependency with the macros feature because the generated attribute resolves through the user crate.

Firmware entry

Firmware uses the matching Embassy entry macro:

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use shared::SensorReading;

include!("consortium.gen.rs");

#[consortium_runtime_mcu::main]
async fn main(context: Context, _spawner: Spawner) {
    let mut sensor = context.ipc_shm.sensor;
    let mut sequence = 0;

    loop {
        let reading = SensorReading {
            temperature_mdc: 25_000,
            sequence,
        };
        sensor.send(&reading).await.expect("IPC send");
        sequence = sequence.wrapping_add(1);
    }
}

The firmware crate must depend on embassy-executor with the target’s executor features. Without a custom #[consortium_runtime_mcu::fail] handler, initialization failure parks the core with wfi; the Linux fallback logs and exits non-zero.

What init() owns

Depending on the endpoint and manifest, generated initialization:

  • takes the chip HAL’s peripheral singletons once;
  • constructs UIO mappings or physical shared-memory regions;
  • binds Linux interrupt fan-out services;
  • performs every IPC readiness handshake, splits transports, and creates typed transceivers;
  • resets a configured firmware debug ring and starts its host decoder;
  • brings up the generated Embassy time driver; and
  • returns controller-owned peripheral drivers under context.peripherals.

The Context must stay alive while those services are used. In particular, a Linux context owns its UIO devices and automatic debug-console tasks.

Interrupt handlers remain application-owned

Hardware crates do not install interrupt vectors. Firmware enables the chip HAL’s rt feature, defines the relevant handler, and forwards it to the exported wake path:

  • HSEM: hsem::notify();
  • IPCC RX: ipcc::notify_rx_occupied_interrupt();
  • MU: mu::notify::<N>(); and
  • buffered UART: the HAL’s per-instance interrupt hook.

The melt-pot firmware main.rs files are the current worked examples. Generated code does install the selected time-driver vector when [profile] time_driver = true.

Receiving and codecs

recv() returns ReceivedMessage, not necessarily an owned message. Postcard and prost decode to owned values, while rkyv can borrow an archived value from the receive buffer. Use the wrapper by reference where possible, or call into_inner() only when the selected codec’s decoded type is appropriate.

For custom bring-up, the generated two-phase IpcMemoryEndpoints::new and connect API remains available. Callers must connect once before any traffic and before splitting a transport; timeout policy belongs to the caller.

Build and Deployment

csti build is the user-facing system build. It compiles every endpoint described by the manifest and assembles deployable inputs, but deliberately stops at a local staging directory.

Command

csti build --manifest Consortium.toml --dist dist --release

Inside the Consortium workspace, the equivalent command is:

cargo run -p csti -- build --manifest Consortium.toml --dist dist --release
FlagPurpose
--manifest <PATH>Manifest to build; defaults to Consortium.toml
--dist <DIR>Local staging root; defaults to dist
--releaseBuild endpoint artifacts in release mode
--skip-tee-signStage an unsigned TA ELF when signing material is unavailable
--bitbake-files-dir <DIR>Copy the assembled tree into a recipe’s files/ directory
--bitbake-recipe <PN>Invoke a BitBake recipe; requires its build directory
--bitbake-build-dir <DIR>Yocto build directory used for BitBake
--bitbake-dry-runResolve and report the BitBake command without running it

Pipeline

The builder keeps planning, compilation, staging, and external packaging as separate boundaries:

  1. parse, validate, and lower Consortium.toml;
  2. generate endpoint modules and build-time configuration;
  3. build controller firmware and post-process it for remoteproc;
  4. build the optional HMI and trusted application;
  5. build the Linux application;
  6. render device-tree, kernel, boot, and systemd integration;
  7. assemble dist/; and
  8. optionally hand the tree to BitBake.

Linux remoteproc expects the firmware vector table as .isr_vectors, while cortex-m-rt emits .vector_table. The pipeline performs that rename and retains the original/debug artifact alongside the deployable ELF.

Staging tree

Only configured subsystems appear:

dist/
├── app/          Linux executable
├── firmware/     deployable and debug firmware ELFs
├── ta/           signed TA or unsigned development ELF
├── www/          Cog/WPE assets
├── hmi/          Pocket bundle and resource pack
├── dts/linux/    generated DTS/DTSO and compiled DTB/DTBO when available
├── kernel/       UIO config fragment and optional kernel bbappend
├── boot/         family-specific boot inputs and integration notes
├── systemd/      generated application unit
└── *.bb          generated BitBake recipe

[profile].deploy_root describes where downstream packaging will install the application-facing files. It is used to generate paths in the systemd unit and runtime configuration; csti never writes to that path on the build host or target.

Toolchain inputs

The Linux application sysroot is resolved in this order:

  1. [profile].sysroot;
  2. SDKTARGETSYSROOT; then
  3. OECORE_TARGET_SYSROOT.

Device-tree source can be emitted without a kernel checkout. To compile it, set [dts].linux to a kernel source tree so the build can use its GPL-licensed dt-bindings headers. Consortium does not vendor those headers.

Cog HMI builds use vp build when available, then npm run build. Pocket builds use bun tools/build.ts. CONSORTIUM_HMI_BUILD_CMD overrides either command for custom or split-host workflows.

BitBake hand-off

To stage the generated files into an existing layer without running BitBake:

csti build \
  --bitbake-files-dir ../meta-product/recipes-apps/consortium/files

To stage and build in one flow, also provide the recipe and Yocto build directory:

csti build \
  --bitbake-files-dir ../meta-product/recipes-apps/consortium/files \
  --bitbake-recipe consortium-app \
  --bitbake-build-dir ../build

Add --bitbake-dry-run when the Consortium build and Yocto build happen on different machines. It records the resolved command without executing it.

Deployment remains an integration decision: the generated recipe, device tree, kernel configuration, boot arguments, and systemd unit are inputs to the product image rather than an imperative board updater.

Glossary

Consortium spans hardware, firmware, Linux, build tooling, and application services. Terms are grouped by subsystem so related concepts can be read together:

Definitions describe the current implementation. Historical maintenance notes may use older terminology.

General terms

Boundary contract
A rule shared by two execution domains, such as a message layout, memory region, command identifier, or ownership assignment. Consortium aims to make these contracts typed and validated.

Endpoint
One runtime participant and its crate root, such as a Linux application, CM33 firmware, CM7 firmware, or OP-TEE trusted application.

Subsystem
A cohesive part of Consortium—IPC, TEE, HMI, configuration, logging, or hardware support—with a portable contract and one or more platform-specific implementations.

Platforms and Hardware

A-core
An application-class processor, typically Arm Cortex-A, that runs Linux. Consortium normally assigns it the shared-memory Primary role.

Application processor (AP)
The Linux-capable processor in a Consortium system. In manifests, its endpoint is conventionally named linux; peripheral ownership uses ap.

Chip database
Generated hardware metadata used to resolve a part number and validate visible cores, memory regions, doorbells, peripherals, pins, and interrupts.

Controller core
A real-time or supervisory processor running firmware, such as Cortex-M, Cortex-R, or RISC-V. Current examples use CM33 and CM7 cores.

HAL
Hardware Abstraction Layer. Consortium’s chip HALs wrap PAC definitions with peripheral singletons and early firmware drivers.

HAMPU
Heterogeneous and Asymmetric Multiprocessing Unit: a system-on-chip containing processors with different architectures, capabilities, or runtime responsibilities.

Interrupt forwarding
The application-owned handler that passes a hardware interrupt to a Consortium driver’s wake function. Doorbell and HAL crates do not install application vectors themselves.

PAC
Peripheral Access Crate: generated low-level register and interrupt definitions for a chip family.

Peripheral singleton
A unique value proving ownership of one hardware peripheral. A chip HAL’s init() hands each singleton out once; a driver constructor consumes it.

UIO
Linux Userspace I/O. Consortium uses /dev/uioN devices to map shared memory and doorbell registers and to receive hardware interrupts in the Linux application.

IPC and Codecs

Channel
A typed, directed endpoint. Channel<Tx, ...> sends one message type; Channel<Rx, ...> receives one message type.

Chan
A validated identifier in the signaling or framing namespace, such as an MU line, IPCC channel, or logical UART channel.

Codec
A serialization family that converts typed values to and from bytes. Consortium supports postcard, prost, and rkyv families behind features.

CodecFor<T>
The codec contract for a particular message type. Its Decoded<'buf> associated type supports both owned decoding and values that borrow the receive buffer.

Connect
The one-time asynchronous readiness rendezvous between peers. It runs after transport construction, before traffic, and before splitting a full-duplex transport.

Descriptor
Metadata at the start of a shared-memory structure. The CIPC descriptor identifies the IPC protocol and records the layout the peer must validate.

Doorbell
A signaling mechanism that wakes or notifies another processor without carrying the message payload. Implementations include STM32 IPCC/HSEM, i.MX MU, and GPIO.

IpcSafe
A marker trait and derive macro for types that may cross an IPC boundary. The derive rejects address-space-local or word-size-dependent fields such as pointers, references, function pointers, usize, and isize.

MTU
Maximum transfer unit: the largest encoded payload a channel can carry. It also sizes the channel’s fixed scratch buffer.

Primary / Secondary
Compile-time side markers used by shared-memory IPC. The primary publishes the descriptor; the secondary validates and acknowledges it. They are protocol roles, not processor priorities.

ReceivedMessage
The value returned by recv(). It wraps a codec’s decoded representation so the same API supports owned and zero-copy codecs.

Region
A shared-memory abstraction with an address, length, and explicit cache-maintenance semantics. Current region types include SRAM, DDR, and a virtual serial region.

Slot
A shared-memory area holding one directional message and its protocol metadata.

Transceiver
A bidirectional typed endpoint formed by pairing one Tx channel with one Rx channel.

Transport
The byte-moving layer beneath a channel. SendTransport and RecvTransport model its directional halves; Transport marks a full-duplex implementation.

Type-A / Type-B IPC
Informal project categories. Type-A uses a separate doorbell, as shared-memory IPC does. Type-B is transport-coupled: UART byte arrival is the notification, so no doorbell or processor side is required.

Configuration and Runtime

Chip selection
The full part number in [profile].chip. It chooses the processor topology, memory map, doorbells, peripherals, and compilation targets from the chip database.

Consortium.toml
The whole-system manifest. It declares the target chip, endpoints, shared-memory IPC, debug rings, peripheral ownership, and optional TEE and HMI configuration.

consortium.gen.rs
The endpoint-specific Rust module produced by the build pipeline. It defines Context, initialization errors, and the generated init() function.

Context
The aggregate returned by generated initialization. It owns ready IPC transceivers and, where configured, UIO devices, debug services, and controller-owned peripherals.

Generated init()
The asynchronous bring-up function emitted for an endpoint. It constructs local hardware, connects IPC peers, starts configured services, and returns Context.

Lowering
The conversion from validated, user-facing configuration into a platform-neutral model used by code, device-tree, UIO, and build generation.

Party
One endpoint’s participation in an IPC channel. Its manifest entry names the message type and doorbell channel used for that party’s outgoing direction.

Session
The parsed manifest plus resolved chip data. Session validation checks cross-section constraints that TOML parsing alone cannot prove.

Visible core
A processor identity exposed by the selected chip database and therefore eligible to own an endpoint, peripheral, debug ring, or IPC side.

Build and Deployment

BitBake hand-off
The optional final pipeline step that stages dist/ into a Yocto recipe and may invoke BitBake. Deployment to hardware remains the product integrator’s responsibility.

csti
The Consortium command-line tool. csti build drives validation, generation, compilation, post-processing, and staging.

Deployment
Installation and activation of built artifacts on a target. csti prepares deployment inputs but does not modify a live device.

dist/
The local staging tree produced by csti build. It can contain applications, firmware, TEE and HMI artifacts, device-tree and kernel inputs, boot files, a systemd unit, and a BitBake recipe.

DTS / DTB
Device Tree Source and its compiled Device Tree Blob. Consortium can emit either a merged board tree or an overlay containing reserved-memory, UIO, and remoteproc integration.

Remoteproc
The Linux framework that loads and controls remote-processor firmware. Consortium post-processes firmware into the section layout expected by remoteproc.

Staging
Assembling built artifacts and generated integration files under dist/ without installing them on the target.

Sysroot
A target filesystem and development environment used when compiling the Linux application for another architecture or distribution.

Services and Debugging

Bridge
The backend-neutral HMI command registry. It maps command names and string payloads to synchronous or asynchronous response handlers.

CDBG
Consortium’s shared-memory debug descriptor and ring format for transporting firmware defmt frames to the application processor.

Cog / WPE WebKit
The browser-based HMI engine supported by consortium-hmi-webkit, suited to kiosk applications that need web-platform compatibility.

defmt
A compact logging format suited to firmware. Consortium can write encoded frames to a CDBG ring and decode them on Linux.

FFI
Foreign Function Interface. consortium-ffi contains integration contracts for code outside native Rust boundaries.

HMI
Human-Machine Interface. Consortium provides a common command bridge with Cog/WPE WebKit and Pocket rendering adapters.

ORT
ONNX Runtime. consortium-ort contains the project’s model-inference integration.

Pocket
The native retained HMI path. PocketJS produces a JavaScript bundle and resource pack consumed by consortium-hmi-pocket.

TEE
Trusted Execution Environment. Consortium’s current integration targets OP-TEE and generates typed client/trusted-application command glue.

TeeParam
The conversion contract between a Rust value and OP-TEE parameter slots. Primitive values use value slots; serialized values use memory-reference slots.

tracing
The structured logging facade used on Linux. Automatic debug readers forward decoded firmware logs into tracing.

Maintenance

This section collects material used to maintain and evolve Consortium rather than to learn its public workflow.

  • Engineering logs preserve dated hardware bring-up investigations and evidence.
  • Development notes record subsystem designs, implementation status, and proposed architecture.
  • Stale and superseded drafts retain completed plans for historical context.

These pages are intentionally separated from the user guide. Some describe an earlier implementation and may use names or APIs that no longer exist. For current behavior, prefer the User Guide, live crate sources, and tests.

Engineering log

With AI assisted. But working done is the testament of genuine work and capability.

NVIC, July 11, 2026 (Waking the M33)

On Cortex-M, the NVIC handles interrupts.

The Consortium IPC handshake graph is as follows:

┌──────────┐                                 ┌──────────┐
│          │    step 1. init descriptor      │          │
│          │————————————————————————————————>│          │
│ Cortex-A │       fires an interrupt        │ Cortex-M │
│          │                                 │          │
│          │    step 2. ack descriptor       │          │
│ (AP)     │<————————————————————————————————│ (CM7)    │
│          │      fires an interrupt         │ OR       │
│          │                                 │ (CM33)   │
└──────────┘                                 └──────────┘

The interrupt-handling paths are:

  • Cortex-A, running a rich OS (Linux/OP-TEE). We primarily use uio for Linux I/O on BellA (a Type-A doorbell). The path is:

    GIC -> Kernel --uio_pdrv_genirq--> /dev/uio2 -> Userspace Code (Consortium) running root
    
  • Cortex-M, running bare-metal software (Embassy/RTIC). This is where the NVIC comes into effect:

    NVIC -> vector table (.isr_vectors) -> interrupt handler.
    

On Cortex-M, the vector table is stored in .isr_vectors, as established in the previous log.

The Problem

While debugging the Cortex-M firmware, execution appeared to stop after the following log output:

root@stm32mp2-e3-cb-5f:~# ./consortium-example-defmt-host 0
CDBG uio0: mapped_len=0x2000 capacity=0x2000 read_ptr=0x10 write_ptr=0x71 pending~=0x61 flags=Flags(0x0)
drained 10 CDBG packet(s), 57 payload byte(s) total
packet 0: 4 byte(s) [00 11 7e 00]
consortium controller init: time driver bring-up
packet 1: 8 byte(s) [0a 01 05 3a 20 44 67 00]
consortium controller init: RCC register block at 0x44200000
packet 2: 4 byte(s) [0d 02 7a 00]
consortium controller init: TIM2 clock enabled
packet 3: 4 byte(s) [0e 03 7a 00]
consortium controller init: TIM2 timer driver initialized
packet 4: 4 byte(s) [0c 04 7a 00]
consortium controller init: TIM2 IRQ priority set to 0x80
packet 5: 4 byte(s) [0b 05 7a 00]
consortium controller init: TIM2 IRQ enabled/unmasked
packet 6: 8 byte(s) [2f 06 7a 06 10 80 78 00]
vtor value=2148533760
packet 7: 4 byte(s) [10 07 7a 00]
consortium controller init: mpu/nvic bring-up
packet 8: 4 byte(s) [12 08 7a 00]
peripherals stolen
packet 9: 13 byte(s) [08 09 06 3a 05 40 06 1b 20 05 40 6c 00]
consortium controller init: MPU carveout 0 base=0x20064000 size=0x00004000
drained 10 CDBG packet(s): decoded 10 defmt frame(s), 0 decode error(s)

This output came from fn init() -> IpcMemoryEndpoints in consortium.gen.rs. The relevant function excerpt is:

#![allow(unused)]
fn main() {
#[cfg(target_arch = "arm")]
fn init() -> IpcMemoryEndpoints {
    ::consortium_runtime_mcu::log::trace!(
        "consortium controller init: mpu/nvic bring-up"
    );
    // SAFETY: `init` runs once during controller bring-up, in
    // privileged mode, before any other code touches the MPU.
    let mut __core = unsafe { ::cortex_m::Peripherals::steal() };
    ::consortium_runtime_mcu::log::trace!("peripherals stolen");
    // SAFETY: `init` holds exclusive privileged MPU access during
    // bring-up; `base`/`size` are this core's validated shared
    // region from the Consortium hardware layout.
    unsafe {
        ::consortium_runtime_mcu::mpu::disable_cache(
            &mut __core.MPU,
            0u8,
            537280512u32,
            16384u32,
        );
        ::consortium_runtime_mcu::log::trace!(
            "consortium controller init: MPU carveout {} base={:#010x} size={:#010x}",
            0u8, 537280512u32, 16384u32
        );
    }
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    #[repr(u16)]
    pub enum BellaInterrupt {
        IPCC1 = 171u16,
    }
    unsafe impl ::cortex_m::interrupt::InterruptNumber for BellaInterrupt {
        fn number(self) -> u16 {
            self as u16
        }
    }
    // SAFETY: interrupt numbers are emitted from the validated
    // Consortium hardware database for this visible core.
    unsafe {
        ::consortium_hal_stm32mp2::hal::nvic::set_priority(
            BellaInterrupt::IPCC1 as u16,
            0x80,
        );
    }
    // SAFETY: interrupt numbers are emitted from the validated
    // Consortium hardware database for this visible core.
    unsafe {
        ::cortex_m::peripheral::NVIC::unmask(BellaInterrupt::IPCC1);
    }
    ::consortium_runtime_mcu::log::trace!(
        "consortium controller init: NVIC unmask {} (id={})", stringify!(IPCC1),
        BellaInterrupt::IPCC1 as u32
    );
    IpcMemoryEndpoints::new()
}
}

Progress 1: Execution Stops While Unmasking the NVIC

Because the next log message never appeared, the initial assumption was that execution stopped during the unmask operation:

#![allow(unused)]
fn main() {
unsafe {
    ::cortex_m::peripheral::NVIC::unmask(BellaInterrupt::IPCC1);
}
}

However, the cortex-m source code shows that this operation is only an MMIO write:

#![allow(unused)]
fn main() {
    /// Enables `interrupt`
    ///
    /// This function is `unsafe` because it can break mask-based critical sections
    #[inline]
    pub unsafe fn unmask<I>(interrupt: I)
    where
        I: InterruptNumber,
    {
        let nr = interrupt.number();
        // NOTE(ptr) this is a write to a stateless register
        (*Self::PTR).iser[usize::from(nr / 32)].write(1 << (nr % 32))
    }
}

Hypothesis 0: Four Possible Causes

Four possible explanations could account for execution stopping at the write:

  1. A HardFault, for example because of an unaligned write or execution in user mode. This was inconsistent with the LED remaining on, based on the handlers described in the previous log.
  2. A panic, for example because usize::from(nr / 32) exceeded the ISER array length. This was also inconsistent with the LED remaining on.
  3. An infinite loop.
  4. Disabling the cache through the MPU made the region unreadable. This was doubtful because logs could be written after the region was marked non-cacheable and read afterward.

Contradiction: TIM2 Works Normally

The log showed that TIM2 was unmasked successfully. We therefore compared the TIM2 initialization code:

#![allow(unused)]
fn main() {
fn init_time_driver() {
    consortium_runtime_mcu::log::trace!("consortium controller init: time driver bring-up");
    // STM32MP257F CM33 non-secure CMSIS names this TIM2_BASE_NS and TIM2_IRQn.
    // The boot/runtime handoff must leave TIM2's kernel clock at this rate.
    // SAFETY: RCC_BASE_NS is the STM32MP257F non-secure RCC register block.
    let rcc = unsafe { consortium_hal_stm32mp2::pac::peripherals::rcc::RCC::from_ptr(RCC_BASE_NS) };
    consortium_runtime_mcu::log::trace!(
        "consortium controller init: RCC register block at {:#010x}",
        RCC_BASE_NS as u32
    );
    rcc.TIM2CFGR().modify(|w| {
        w.set_TIM2RST(false);
        w.set_TIM2EN(true);
        w.set_TIM2LPEN(true);
    });
    consortium_runtime_mcu::log::trace!("consortium controller init: TIM2 clock enabled");

    // SAFETY: TIM2 is reserved for this demo firmware and clocked above; the
    // IRQ handler below forwards TIM2_IRQn to the HAL time driver.
    unsafe {
        consortium_hal_stm32mp2::timer_driver::init_timer(TIM2_BASE_NS, TIM2_CLOCK_HZ);
    }
    consortium_runtime_mcu::log::trace!(
        "consortium controller init: TIM2 timer driver initialized"
    );
    unsafe {
        consortium_hal_stm32mp2::hal::nvic::set_priority(TIM2_IRQ, 0x80);
    }
    consortium_runtime_mcu::log::trace!(
        "consortium controller init: TIM2 IRQ priority set to 0x80"
    );
    unsafe {
        consortium_hal_stm32mp2::hal::nvic::enable(TIM2_IRQ);
    }
    consortium_runtime_mcu::log::trace!("consortium controller init: TIM2 IRQ enabled/unmasked");
}
}

consortium_hal_stm32mp2::hal::nvic::enable is only a wrapper around NVIC::unmask:

#![allow(unused)]
fn main() {
/// Enable (unmask) an interrupt.
///
/// # Safety
///
/// Enabling an interrupt may break mask-based critical sections and requires
/// that a handler for it is installed in the active vector table.
pub unsafe fn enable(irq: u16) {
    // SAFETY: Propagates the caller's critical-section/handler contract.
    unsafe { NVIC::unmask(Irq(irq)) }
}
}

The two paths used the same setup and underlying operation, yet only one appeared to stop.

We checked the IRQ table again in RM0457, using page 1739 for TIM2:

Page 1741 lists IPCC1_RX:

The IRQ numbers were correct, so the discrepancy required another explanation.

Hypothesis 1: A Previously Pending Interrupt Caused Recursion

To test this hypothesis, we added an unpend operation before unmasking:

#![allow(unused)]
fn main() {
::cortex_m::peripheral::NVIC::unpend(BellaInterrupt::IPCC1);
}

This should prevent the NVIC from taking an already pending interrupt immediately after unmasking. It did not change the result: execution still stopped after unpend and before unmask returned.

Hypothesis 2: EXTI Routing

Unlike TIM2, IPCC1_RX has an EXTI1 path. We used devmem2 to inspect the relevant EXTI registers.

  1. Read EXTI1_C{m}IMR2.

    Because m=1 corresponds to the A35 and the M33’s m=2 register is inaccessible from the A35, we first read EXTI1_C1IMR2.

    root@stm32mp2-e3-cb-5f:~# devmem2 0x44220090 w
    /dev/mem opened.
    Memory mapped at address 0xffffab04b000.
    Read at address  0x44220090 (0xffffab04b090): 0x00003800
    

    This value showed that:

    > 0x00003800 & (1 << 28)
    0
    

    Bit 28 (IM60) was 0.

  2. Read EXTI1_RPR2.

    root@stm32mp2-e3-cb-5f:~# devmem2 0x4422002c w
    /dev/mem opened.
    Memory mapped at address 0xffffbd3e2000.
    Read at address  0x4422002C (0xffffbd3e202c): 0x00000000
    

    The register was entirely zero.

  3. Read EXTI1_FPR2.

    root@stm32mp2-e3-cb-5f:~# devmem2 0x44220030 w
    /dev/mem opened.
    Memory mapped at address 0xffffae16e000.
    Read at address  0x44220030 (0xffffae16e030): 0x00000000
    

    The register was entirely zero.

This did not explain the failure: both EXTI1_FPR2 and EXTI1_RPR2 mark the IM60 bit as reserved. The EXTI state showed the IPCC1_RX path as masked and clear, directing the investigation back to the on-chip NVIC rather than a missing EXTI direction. The EXTI hypothesis was therefore rejected.

Hypothesis 3: A Problem in the cortex-m Call

To exclude a problem in the cortex-m crate call, we replaced it with a direct MMIO write:

#![allow(unused)]
fn main() {
defmt::info!("before iser write");
unsafe {
    core::ptr::write_volatile(0xE000_E114 as *mut u32, 1u32 << 11);
}
defmt::info!("after iser write");
}

The result was:

packet 10: 4 byte(s) [2f 0a 7a 00]
before iser write
drained 11 CDBG packet(s): decoded 11 defmt frame(s), 0 decode error(s)
root@stm32mp2-e3-cb-5f:~#

Adding a marker write of 0x0926 to 0x20064000 in the IPC section also produced no observable indication.

root@stm32mp2-e3-cb-5f:~# ./2607092332_uio_batch /dev/uio1 r 0x0
/dev/uio1 [window 0] offset 0x0 (4 bytes):
  0x0000: 43 50 49 43
As ASCII:
  "CPIC"
As 32-bit words:
  0x0000: 43495043
root@stm32mp2-e3-cb-5f:~# ./2607092332_uio_batch /dev/uio0 r 0x0
/dev/uio0 [window 0] offset 0x0 (4 bytes):
  0x0000: 47 42 44 43
As ASCII:
  "GBDC"
As 32-bit words:
  0x0000: 43444247

ISER5 is selected because 171 / 32 = 5, with bit 171 % 32 = 11. This write replaced NVIC::unmask; it was not added alongside it, so the raw write was tested independently of the wrapper.

Progress 2: The IPCC Interrupt Should Be Generated

  1. The IPCC_C1MR word indicates the mask:

    /* (IPCC1) IPCC_C1MR */
    root@stm32mp2-e3-cb-5f:~# devmem2 0x40490004 w
    /dev/mem opened.
    Memory mapped at address 0xffff93a27000.
    Read at address  0x40490004 (0xffff93a27004): 0x0FFF0FFD
    
  2. The IPCC_C1TOC2SR word indicates the state:

    root@stm32mp2-e3-cb-5f:~# devmem2 0x4049000c w
    /dev/mem opened.
    Memory mapped at address 0xffffb493d000.
    Read at address  0x4049000C (0xffffb493d00c): 0x00000001
    

    Therefore, the interrupt should be asserted.

Hypothesis 3: An IPCC Clock Issue

Unlike the TIM2 initialization, the IPCC path did not explicitly configure the RCC. The peripheral therefore might not have worked for the M33 because it had not been powered in the M33’s domain.

This hypothesis was weak, however. As described in the earlier UIO investigation, assigning clocks to IPCC1 in the Linux DTS powered it; without that assignment, access produced a Bus error.

To determine whether the M-core could reset the IPCC1 peripheral, we inspected RCC_IPCC1CFGR on page 1172.

root@stm32mp2-e3-cb-5f:~# devmem2 0x44200570 w
/dev/mem opened.
Memory mapped at address 0xffff9510a000.
Read at address  0x44200570 (0xffff9510a570): 0x00000006

Both IPCC1LPEN and IPCC1EN were true, confirming that the clock was enabled.

We then identified the register that controls access to RCC_IPCC1CFGR on page 1075:

RCC_IPCC1CFGR is managed by RCC_R87CIDCFGR.

Page 1088 defines RCC_R87CIDCFGR as follows:

root@stm32mp2-e3-cb-5f:~# devmem2 0x442002e8 w
/dev/mem opened.
Memory mapped at address 0xffffbb8fe000.
Read at address  0x442002E8 (0xffffbb8fe2e8): 0x00020003

Here, CFEN=1 (bit 0), SEM_EN=1 (bit 1), and SEMWLC[7:0]=0x02 (bit 17 of the full register, the whitelist bit for compartment 1). The SCID bits are ignored because SEM_EN=1. According to the specification, these fields govern write access to RCC_IPCC1CFGR itself: they determine which CID may take the semaphore to enable, disable, or reset the IPCC1 clock. They do not govern access to the IPCC peripheral’s own registers or to anything in the CM33’s core-private address space.

The clock hypothesis was therefore rejected.

Hypothesis 4: Firmware VTOR Table Corruption

To investigate this possibility, we added the following statement:

#![allow(unused)]
fn main() {
    let vtor = unsafe { &*(cortex_m::peripheral::SCB::PTR) }.vtor().read();
    consortium_runtime_mcu::log::trace!("vtor value={}", vtor);
}

The result was:

vtor value=2148533760

(= 0x8010_0000)

This matched m33-cube-fw in the DTS, confirming that the value was correct. The VTOR-corruption hypothesis was rejected.

Hypothesis 5: Missing NVIC Priority Configuration

The MSP initialization from STM32CubeMX contains HAL_NVIC_SetPriority and HAL_NVIC_EnableIRQ.


/**
  * @brief IPCC MSP Initialization
  * This function configures the hardware resources used in this example
  * @param hipcc: IPCC handle pointer
  * @retval None
  */
void HAL_IPCC_MspInit(IPCC_HandleTypeDef* hipcc)
{
  if(hipcc->Instance==IPCC1)
  {
    /* USER CODE BEGIN IPCC1_MspInit 0 */

    /* USER CODE END IPCC1_MspInit 0 */
    /* IPCC1 interrupt Init */
    HAL_NVIC_SetPriority(IPCC1_RX_IRQn, 1, 0);
    HAL_NVIC_EnableIRQ(IPCC1_RX_IRQn);
    /* USER CODE BEGIN IPCC1_MspInit 1 */

    /* USER CODE END IPCC1_MspInit 1 */

  }

}

We added:

#![allow(unused)]
fn main() {
    // SAFETY: interrupt numbers are emitted from the validated
    // Consortium hardware database for this visible core.
    unsafe {
        ::consortium_hal_stm32mp2::hal::nvic::set_priority(
            BellaInterrupt::IPCC1 as u16,
            0x80,
        );
    }
}

Execution still stopped while unmasking the interrupt.

Experiment: TIM6

To determine whether this behavior was specific to IPCC or affected other interrupts, I tested TIM6, which is also assigned to the M33:

Cold Start: Execution Stops

Following the TIM2 example, I added the following setup code:

#![allow(unused)]
fn main() {
fn init_time_driver() {
    consortium_runtime_mcu::log::trace!("consortium controller init: time driver bring-up");
    // STM32MP257F CM33 non-secure CMSIS names this TIM2_BASE_NS and TIM2_IRQn.
    // The boot/runtime handoff must leave TIM2's kernel clock at this rate.
    // SAFETY: RCC_BASE_NS is the STM32MP257F non-secure RCC register block.
    let rcc = unsafe { consortium_hal_stm32mp2::pac::peripherals::rcc::RCC::from_ptr(RCC_BASE_NS) };
    consortium_runtime_mcu::log::trace!("consortium controller init: RCC register block at {:#010x}", RCC_BASE_NS as u32);
    rcc.TIM2CFGR().modify(|w| {
        w.set_TIM2RST(false);
        w.set_TIM2EN(true);
        w.set_TIM2LPEN(true);
    });
    consortium_runtime_mcu::log::trace!("consortium controller init: TIM2 clock enabled");

    consortium_runtime_mcu::log::trace!("test unmask tim6 irq start");
    unsafe {
        consortium_hal_stm32mp2::hal::nvic::enable(consortium_hal_stm32mp2::Interrupt::TIM6 as u16);
    }
    consortium_runtime_mcu::log::trace!("test unmask tim6 irq stop");
}

The result was:

root@stm32mp2-e3-cb-5f:~# ./consortium-example-defmt-host 0
CDBG uio0: mapped_len=0x2000 capacity=0x2000 read_ptr=0x10 write_ptr=0x34 pending~=0x24 flags=Flags(0x0)
drained 4 CDBG packet(s), 20 payload byte(s) total
packet 0: 4 byte(s) [00 13 7e 00]
consortium controller init: time driver bring-up
packet 1: 8 byte(s) [0c 01 05 3a 20 44 67 00]
consortium controller init: RCC register block at 0x44200000
packet 2: 4 byte(s) [0f 02 7a 00]
consortium controller init: TIM2 clock enabled
packet 3: 4 byte(s) [17 03 7a 00]
test unmask tim6 irq start
drained 4 CDBG packet(s): decoded 4 defmt frame(s), 0 decode error(s)

Execution stopped again.

Adding the Clock: Execution Still Stops

We added the clock-setup code:

#![allow(unused)]
fn main() {
    rcc.TIM6CFGR().modify(|w| {
        w.set_TIM6RST(false);
        w.set_TIM6EN(true);
        w.set_TIM6LPEN(true);
    });
}

The result did not change:

packet 3: 4 byte(s) [17 03 7a 00]
test unmask tim6 irq start
packet 4: 4 byte(s) [18 04 7a 00]
test unmask tim6 irq start: rcc done
drained 5 CDBG packet(s): decoded 5 defmt frame(s), 0 decode error(s)
root@stm32mp2-e3-cb-5f:~#

Adding a Handler: Execution Still Stops

We added the following handler:

#![allow(unused)]
fn main() {
#[interrupt]
fn TIM6() {
    unsafe { core::ptr::write_volatile(0x442b_0028 as *mut u32, 1u32 << 6); }
}
}

The LED remained on, indicating that the interrupt handler had not been entered.

Hypothesis 6: A Flat NVIC/ISER Implementation Limit at IRQ128 (ISER4+)

Because TIM2 (105) worked while TIM6 (128) and IPCC1 (171) stopped, we inspected the ICTR value:

#![allow(unused)]
fn main() {
let ictr = unsafe { core::ptr::read_volatile(0xE000_E004 as *const u32) };
defmt::info!("ICTR={:#010x} intlinesnum={=u32}", ictr, ictr & 0xF);
}

The result was:

ICTR=0x00000009 intlinesnum=9

ICTR=0x9 means INTLINESNUM=9, giving 32 × (9+1) = 320 implemented interrupt lines. Therefore, ISER0..ISER9 are backed by hardware. Both IRQ128 (ISER4) and IRQ171 (ISER5) are within this range, ruling out an unimplemented-ISER-register explanation.

Hypothesis 7: An Unconfigured AIRCR

HAL_NVIC_SetPriorityGrouping() writes SCB->AIRCR with VECTKEY (0x05FA in the upper halfword) to configure the priority-grouping split. Our init() did not modify AIRCR, leaving priority grouping at either the value configured by the boot chain or its architectural reset value.

#![allow(unused)]
fn main() {
unsafe {
    core::ptr::write_volatile(0xE000_ED0C as \*mut u32, (0x5FA << 16) | (3 << 8)); // AIRCR, VECTKEY + PRIGROUP=3
}
defmt::info!("aircr set");
}

The result was:

packet 3: 4 byte(s) [14 03 7a 00]
consortium controller init: setting AIRCR (for testing)
packet 4: 4 byte(s) [08 04 7a 00]
consortium controller init: AIRCR set
packet 5: 4 byte(s) [19 05 7a 00]
test unmask tim6 irq start
packet 6: 4 byte(s) [1a 06 7a 00]
test unmask tim6 irq start: rcc done
drained 7 CDBG packet(s): decoded 7 defmt frame(s), 0 decode error(s)
root@stm32mp2-e3-cb-5f:~#

This change also had no effect.

Hypothesis 8: The IPCC Doorbell Was Not Cleaned Up

Using Embassy’s IPCC implementation as a reference, we reimplemented the no_std wake mechanism:

#![allow(unused)]
fn main() {
pub unsafe fn notify_rx_occupied_interrupt<S: Side, const N: usize>(
    base: usize,
) {
    let ipcc = unsafe { Ipcc::<S, N>::new(base) };
    let occupied = ipcc.incoming_occupied_channels();

    ipcc.mask_rx_occupied_channels(occupied);

    for id in 0..IPCC_CHAN_COUNT {
        if occupied & (1 << id) != 0 {
            channel_waker::<N>(id).wake();
        }
    }
}

#[interrupt]
fn IPCC1_RX() {
    on_rx_occupied_interrupt::<Secondary, 1>()
}
}

Execution still stopped at the same point.

Progress 3: Distinguishing HardFault from IRQ

Even direct bare-metal access did not work:

#![allow(unused)]
fn main() {
#[interrupt]
fn IPCC1_RX() {
    unsafe {
        // Marker first.
        core::ptr::write_volatile(0x2006_4000 as *mut u32, 0x0926);

        // C2MR.CH0OM = 1: mask CM33 RX-occupied channel 0.
        let c2mr = 0x4049_0014 as *mut u32;
        let value = core::ptr::read_volatile(c2mr);
        core::ptr::write_volatile(c2mr, value | 1);
    }
}
}

The memory contained:

root@stm32mp2-e3-cb-5f:~# ./2607092332_uio_batch /dev/uio1 r 0x0
/dev/uio1 [window 0] offset 0x0 (4 bytes):
  0x0000: 43 50 49 43
As ASCII:
  "CPIC"
As 32-bit words:
  0x0000: 43495043

Because 0x0926 was absent at 0x2006_4000, the #[interrupt] handler had not executed.

To identify which path was actually taken, we split the handling logic between #[interrupt] fn IPCC_RX() and #[cortex_m_rt::exception] unsafe fn DefaultHandler(irqn: i16).

IPCC1_RX Interrupt

#![allow(unused)]
fn main() {
#[consortium_hal_stm32mp2::interrupt]
fn IPCC1_RX() {
    unsafe {
        core::ptr::write_volatile(0x442b_0028 as *mut u32, 1u32 << 6);
        core::ptr::write_volatile(0x2006_4000 as *mut u32, 0x0926);
    }

    unsafe {
        consortium_ipc_doorbell_ipcc::notify_rx_occupied_interrupt::<
            consortium_ipc::Secondary,
            1,
        >(0x4049_0000);
    }

    consortium_runtime_mcu::log::trace!(
        "ipcc1_rx interrupt: doorbell RX occupied"
    );
}
}

DefaultHandler Exception

#![allow(unused)]
fn main() {
#[cortex_m_rt::exception]
unsafe fn DefaultHandler(_irqn: i16) {
    let icsr = unsafe { core::ptr::read_volatile(0xE000_ED04 as *const u32) };
    let vectactive = icsr & 0x1ff;

    // Dedicated diagnostic output; preserve the existing diagnostics too.
    unsafe {
        core::ptr::write_volatile(0x2006_4000 as *mut u32, 0xD000_0000 | vectactive);
        core::ptr::write_volatile(0x2006_4010 as *mut i16, irqn);
        core::ptr::write_volatile(0x442b_0028 as *mut u32, 1u32 << 6);
    }

    loop {
        core::hint::spin_loop();
    }
}
}

The LED then turned off, showing that either IPCC1_RX or DefaultHandler had been entered. We then extracted the recorded data:

root@stm32mp2-e3-cb-5f:~# devmem2 0x20064000 h
/dev/mem opened.
Memory mapped at address 0xffff8621c000.
Read at address  0x20064000 (0xffff8621c000): 0x00BB
root@stm32mp2-e3-cb-5f:~# devmem2 0x20064000 w
/dev/mem opened.
Memory mapped at address 0xffffb6342000.
Read at address  0x20064000 (0xffffb6342000): 0xD00000BB
root@stm32mp2-e3-cb-5f:~# devmem2 0x20064010 w
/dev/mem opened.
Memory mapped at address 0xffffb67ef000.
Read at address  0x20064010 (0xffffb67ef010): 0x98BE00AB
root@stm32mp2-e3-cb-5f:~# devmem2 0x20064010 h
/dev/mem opened.
Memory mapped at address 0xffff8c189000.
Read at address  0x20064010 (0xffff8c189010): 0x00AB

The recorded value 0x00ab corresponds to:

VECTACTIVE = 0xBB = 187 = 16 + IRQ171
irqn       = 0xAB = 171

This confirmed that IRQ171 was taken and DefaultHandler executed. At this point, there was no remaining ambiguity about whether IPCC asserted the interrupt or the NVIC accepted it.

Why Does It Enter DefaultHandler?

We inspected the current ELF:

VTOR                       0x80100600
IRQ171 slot                0x801008EC
ELF word at 0x801008EC     0x80100BB5
IPCC1_RX                   0x80100BB5

We read the vector entry from both cores to eliminate cache-coherency issues:

  • A-core:

    root@stm32mp2-e3-cb-5f:~# devmem2 0x801008ec w
    /dev/mem opened.
    Memory mapped at address 0xffffaa860000.
    Read at address  0x801008EC (0xffffaa8608ec): 0x80100BB5
    
  • M-core:

    #![allow(unused)]
    fn main() {
    let vector = unsafe {
        core::ptr::read_volatile(0x8010_08ec as *const u32)
    };
    
    defmt::info!("cm33 irq171 vector={=u32:#010x}", vector);
    
    cortex_m::asm::dsb();
    cortex_m::asm::isb();
    
    cortex_m::peripheral::NVIC::pend(
        consortium_hal_stm32mp2::Interrupt::IPCC1_RX,
    );
    }

    Result:

    packet 20: 9 byte(s) [36 14 b5 3a 0b 10 80 78 00]
    cm33 irq171 vector=0x80100bb5
    drained 21 CDBG packet(s): decoded 21 defmt frame(s), 0 decode error(s)
    

The evidence was now:

VTOR written/read:             0x80100600
base with bit 0x200 removed:   0x80100400
IRQ171 offset:                 0x2EC
candidate fetch address:       0x801006EC
word at 0x801006EC:            0x80100B75
DefaultHandler:                0x80100B75

This exposed the problem: 0x80100400 + 0x2EC > 0x80100600.

Experiment: Whether Overflow Truncates Custom Interrupt Handler

The observed transition point could be tested precisely. Address 0x80100800 corresponds to exception number 128, which is external IRQ112:

IRQ111 I3C2 → vector at 0x801007FC, immediately below boundary
IRQ112 SPI1 → vector at 0x80100800, exactly at boundary

We therefore tested I3C2 and SPI1 on opposite sides of this boundary:

#![allow(unused)]
fn main() {
#[interrupt]
fn I3C2() {
    unsafe {
        core::ptr::write_volatile(0x2006_4014 as *mut u32, 0x1111_1111);
    }
}

#[interrupt]
fn SPI1() {
    unsafe {
        core::ptr::write_volatile(0x2006_4014 as *mut u32, 0x1121_1211);
    }
}

NVIC::unmask(Interrupt::I3C2);
NVIC::pend(Interrupt::I3C2);

NVIC::unmask(Interrupt::SPI1);
NVIC::pend(Interrupt::SPI1);
}

The test produced the following result:

packet 20: 9 byte(s) [3b 14 c9 3a 0b 10 80 78 00]
cm33 irq171 vector=0x80100bc9
packet 21: 4 byte(s) [2a 15 7a 00]
test interrupt i3c2
packet 22: 4 byte(s) [2b 16 7a 00]
test interrupt i3c2 unmasked
packet 23: 4 byte(s) [2c 17 7a 00]
test interrupt spi1
packet 24: 4 byte(s) [2d 18 7a 00]
test interrupt spi1 unmasked
drained 25 CDBG packet(s): decoded 25 defmt frame(s), 0 decode error(s)

This confirmed the exact boundary:

  • IRQ111 (I3C2, exception 127, last word before 0x80100800) executes correctly.
  • IRQ112 (SPI1, exception 128, first word at 0x80100800) enters the wrong path and sticks.

We extended NS_VECTOR_TBL:

NS_VECTOR_TBL: 0x80100000, length 0x800
FLASH:         0x80100800, length 0x7ff800

The resulting .vector_table was:

.vector_table address:  0x80100800
.vector_table size:     0x51c
IRQ171 slot:            0x80100aec
IRQ171 value:           0x80100dc9 → IPCC1_RX
.text starts:           0x80100d1c

After extending the vector-table region, the interrupt handler worked.

RemoteProc, July 10, 2026 (Powering On the M33)

remoteproc starts the Cortex-M33 core on STM32MP257 and the Cortex-M7 core on i.MX 95 (MIMX9596). The conventional workflow is:

# stop
echo stop > /sys/class/remoteproc/remoteproc0/state
# switch firmware
cp ~/cm33.elf /lib/firmware
echo cm33.elf > /sys/class/remoteproc/remoteproc0/firmware
# start
echo stop > /sys/class/remoteproc/remoteproc0/state

Attempt 1: Missing Resource Table

[  158.179437] remoteproc remoteproc1: stopped remote processor m33
[  194.588530] remoteproc remoteproc1: powering up m33
[  194.652612] remoteproc remoteproc1: Booting fw image cm33.elf, size 2026444
[  194.654000] remoteproc remoteproc1: no resource table found for this firmware
[  194.661328] remoteproc remoteproc1: bad phdr da 0x10000000 mem 0x800
[  194.667622] remoteproc remoteproc1: Failed to load program segments: -22
[  194.674620] remoteproc remoteproc1: Boot failed: -22
[  224.113632] remoteproc remoteproc1: powering up m33
[  224.180061] remoteproc remoteproc1: Booting fw image cm33.elf, size 2026444
[  224.181457] remoteproc remoteproc1: no resource table found for this firmware
[  224.188790] remoteproc remoteproc1: bad phdr da 0x10000000 mem 0x800
[  224.194972] remoteproc remoteproc1: Failed to load program segments: -22
[  224.202025] remoteproc remoteproc1: Boot failed: -22

Firmware Analysis

We inspected two firmware images: the preinstalled UCPD firmware and our custom firmware.

Official Firmware

Elf file type is EXEC (Executable file)
Entry point 0x801145e9
There are 4 program headers, starting at offset 52
Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x80100000 0x80100000 0x00540 0x00540 R   0x1000
  LOAD           0x001600 0x80100600 0x80100600 0x1a1f0 0x1a1f0 R E 0x1000
  LOAD           0x01c000 0x80a00000 0x80a00000 0x002d0 0x11220 RW  0x1000
  LOAD           0x01d000 0x81200000 0x81200000 0x00090 0x00090 R   0x1000
 Section to Segment mapping:
  Segment Sections...
   00     .isr_vectors
   01     .text .rodata .ARM
   02     .init_array .fini_array .data .bss ._user_heap_stack
   03     .resource_table

Custom Firmware

Elf file type is EXEC (Executable file)
Entry point 0x60000801
There are 6 program headers, starting at offset 52
Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x000114 0x60000000 0x60000000 0x00800 0x00800 R   0x4
  LOAD           0x000914 0x60000800 0x60000800 0x0bda8 0x0bda8 R E 0x4
  LOAD           0x00c6c0 0x6000c5a8 0x6000c5a8 0x07bc8 0x07bc8 R   0x8
  LOAD           0x014288 0x20040000 0x60014170 0x00018 0x00018 RW  0x8
  LOAD           0x0142a0 0x20040018 0x20040018 0x00000 0x0020c RW  0x8
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0
 Section to Segment mapping:
  Segment Sections...
   00     .vector_table
   01     .text
   02     .rodata
   03     .data
   04     .bss
   05

The program headers and remoteproc messages showed that our firmware did not contain a .resource_table section.

Adding ResourceTable

Adding a resource table was straightforward because this firmware does not use any OpenAMP- or RemoteProc-compatible vrings or RPMsg channels.

#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct ResourceTable {
    ver: u32,
    num: u32,
    reserved: [u32; 2],
}

#[link_section = ".resource_table"]
#[used]
static RESOURCE_TABLE: ResourceTable = ResourceTable {
    ver: 1,
    num: 0,
    reserved: [0, 0],
};
}

After this change, remoteproc reported that the remote processor was up.

[ 8960.582799] remoteproc remoteproc0: stopped remote processor m33
[ 8965.854170] remoteproc remoteproc0: powering up m33
[ 8965.877304] remoteproc remoteproc0: Booting fw image cm33.elf, size 698292
[ 8965.878734] remoteproc remoteproc0: no resource table found for this firmware
[ 8965.886106] remoteproc remoteproc0: remote processor m33 is now up

Attempt 2: Processor Up Without a GPIO Toggle or Shared-Memory Write

To determine whether the M-core was running, I added logic to toggle a GPIO upon entering the main loop and to write progress markers into shared memory.

According to UM3385, the board has LEDs on PH4, PH5, PH6, and PH7:

PH6 is assigned to the CM33:

The HAL implements the GPIO writes as follows:

#![allow(unused)]
fn main() {
    #[inline]
    fn write_high(&self) {
        // BSRR low half is atomic bit-set; other pins are unaffected.
        self.port.BSRR().write_value(GPIO_BSRR(self.mask()));
    }

    #[inline]
    fn write_low(&self) {
        // BRR is atomic bit-reset; other pins are unaffected.
        self.port.BRR().write_value(GPIO_BRR(self.mask()));
    }
}

We therefore added GPIO toggles to indicate whether the firmware entered its panic or HardFault handler.

#![allow(unused)]
fn main() {

#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    cortex_m::interrupt::disable();

    unsafe {
        core::ptr::write_volatile(0x442b_0028 as *mut u32, 1u32 << 6);
    }

    let mut current_addr = 0x2006_4000 as *mut u8;
    let end_addr = 0x2006_8000 as *mut u8;

    let prefix = b"[PANIC]";

    for &byte in prefix {
        unsafe {
            write_volatile(current_addr, byte as u8);
            current_addr = current_addr.add(1);
        }
    }

    if let Some(location) = info.location() {
        let file_bytes = location.file().as_bytes();
        let line_count = location.line();

        for &byte in file_bytes {
            unsafe {
                write_volatile(current_addr, byte as u8);
                current_addr = current_addr.add(1);
            }
        }

        if current_addr < end_addr {
            unsafe {
                write_volatile(current_addr, b':' as u8);
                current_addr = current_addr.add(1);
            }
        }

        let mut line_buf = [0u8; 10];
        let mut line_index = 0;
        let mut line = line_count;
        while line > 0 && line_index < line_buf.len() {
            line_buf[line_index] = b'0' + (line % 10) as u8;
            line /= 10;
            line_index += 1;
        }

        for i in (0..line_index).rev() {
            unsafe {
                write_volatile(current_addr, line_buf[i] as u8);
                current_addr = current_addr.add(1);
            }
        }
    }

    if current_addr < end_addr {
        unsafe {
            write_volatile(current_addr, b'\0' as u8);
        }
    }

    loop {
        compiler_fence(core::sync::atomic::Ordering::SeqCst);
        cortex_m::asm::wfi();
    }
}

#[cortex_m_rt::exception]
unsafe fn HardFault(ef: &cortex_m_rt::ExceptionFrame) -> ! {
    cortex_m::interrupt::disable();

    unsafe {
        core::ptr::write_volatile(0x442b_0028 as *mut u32, 1u32 << 6);
    }

    let addr = 0x2006_4000 as *mut u32;

    unsafe {
        core::ptr::write_volatile(addr, b'H' as u32);
    }
    unsafe {
        core::ptr::write_volatile(addr.add(1), b'F' as u32);
    }
    unsafe {
        core::ptr::write_volatile(addr.add(2), b'\0' as u32);
    }

    let pc = ef.pc();
    let pc_bytes = pc.to_le_bytes();
    for (i, &byte) in pc_bytes.iter().enumerate() {
        unsafe {
            core::ptr::write_volatile(addr.add(3 + i), byte as u32);
        }
    }

    loop {
        compiler_fence(core::sync::atomic::Ordering::SeqCst);
        cortex_m::asm::wfi();
    }
}
}

The orange LED remained on, leaving two possibilities:

  1. The firmware was running successfully. This was ruled out because shared memory remained untouched.
  2. The firmware never entered main.

Attempt 3: Manual Assembly

.section .text.entry
.global _start
_start:
    ldr r0, =0x442b0028
    mov r1, #0x40
    str r1, [r0]
b rust_main

The LED remained off, indicating that even this minimal entry sequence did not run.

Attempt 4: Is the Core Powered On?

While investigating the DTS, we found st,syscfg-cm-state = <0x98 0x204 0x0c>, which resolves to:

base  = 0x44210000   (from this PWR syscon node)
offset = 0x204
mask   = 0x0c         (bits [3:2])

According to pages 934–935 of the reference manual, this is the PWR_CPU2D2SR register:

We used the following script to detect the state automatically:

# 1. Before powering up
devmem 0x44210204 32

# 2. Trigger boot
echo start > /sys/class/remoteproc/remoteproc0/state
sleep 1
devmem 0x44210204 32

# 3. After it's "up" (per dmesg), wait a few seconds for any settle time
devmem 0x44210204 32

# 4. Then stop
echo stop > /sys/class/remoteproc/remoteproc0/state
devmem 0x44210204 32

The script produced the following result:

running
/dev/mem opened.
Memory mapped at address 0xffffb3674000.
Read at address  0x44210204 (0xffffb3674204): 0x00000001
/dev/mem opened.
Memory mapped at address 0xffff96ce2000.
Read at address  0x44210204 (0xffff96ce2204): 0x00000004
/dev/mem opened.
Memory mapped at address 0xffffa28e4000.
Read at address  0x44210204 (0xffffa28e4204): 0x00000004
/dev/mem opened.
Memory mapped at address 0xffff89f43000.
Read at address  0x44210204 (0xffff89f43204): 0x00000004
/dev/mem opened.
Memory mapped at address 0xffffa01f1000.
Read at address  0x44210204 (0xffffa01f1204): 0x00000001

The running state was therefore 0x00000004, while the stopped state was 0x00000001. This confirmed that the core did power on.

Attempt 5: Enabling Dynamic Debug for remoteproc

echo 'module stm32_rproc +p' > /sys/kernel/debug/dynamic_debug/control
echo start > /sys/class/remoteproc/remoteproc0/state
dmesg | tail -50

The resulting output was:

[ 175.077928] remoteproc remoteproc0: powering up m33
[ 175.194285] remoteproc remoteproc0: Booting fw image cm33.elf, size 2035168
[ 175.195647] stm32-rproc 0.m33: pa 0x0000000080100000 to da 80100000
[ 175.195664] stm32-rproc 0.m33: pa 0x0000000080a00000 to da 80a00000
[ 175.195675] stm32-rproc 0.m33: pa 0x0000000081200000 to da 81200000
[ 175.195686] stm32-rproc 0.m33: pa 0x00000000812f8000 to da 812f8000
[ 175.195697] stm32-rproc 0.m33: pa 0x00000000812f9000 to da 812f9000
[ 175.195707] stm32-rproc 0.m33: pa 0x00000000812fa000 to da 812fa000
[ 175.195717] stm32-rproc 0.m33: pa 0x00000000a060000 to da a060000
[ 175.200400] stm32-rproc 0.m33: map memory: 0x0000000080100000+800000
[ 175.200546] stm32-rproc 0.m33: map memory: 0x0000000080a00000+800000
[ 175.200560] stm32-rproc 0.m33: map memory: 0x0000000081200000+f8000
[ 175.200583] stm32-rproc 0.m33: map memory: 0x00000000812f8000+1000
[ 175.200601] stm32-rproc 0.m33: map memory: 0x00000000812f9000+1000
[ 175.200614] stm32-rproc 0.m33: map memory: 0x00000000a060000+20000
[ 175.200692] remoteproc remoteproc0: boot vector address = 0x0
[ 175.200832] rproc-virtio rproc-virtio.2.auto: assigned reserved memory node vdev0buffer@812fa000
[ 175.212945] virtio_rpmsg_bus virtio0: rpmsg host is online
[ 175.213021] rproc-virtio rproc-virtio.2.auto: registered virtio0 (type 7)
[ 175.224106] remoteproc remoteproc0: remote processor m33 is now up

The key line was [175.200692]:

[ 175.200692] remoteproc remoteproc0: boot vector address = 0x0

The boot-vector address appeared as 0x0. Inspecting the ELF showed:

readelf -h ./dist/firmware/cm33.elf | grep -i entry
  Entry point address:               0x80100e01

This identified the incorrect boot-vector address as the problem.

Attempt 6: Adding the .isr_vectors Section

According to an ST Community Forum:

The boot address is computed based on .isr_vectors section that should be defined in your Cortex-M33 firmware linker script.

 /* The startup code into "NS_VECTOR_TBL" Ram type memory
 * Do not update the ".isr_vectors" section name. the name
 * is used by the Linux kernel to retrieve the address
 * of the vector table*/
 .isr_vectors :
 {
 . = ALIGN(4);
 KEEP(*(.isr_vectors))
 . = ALIGN(4);
 } >NS_VECTOR_TBL

After adding this section to memory.x, the address was correct:

[  536.656497] remoteproc remoteproc0: boot vector address = 0x80100000

The firmware still did not run: there was no GPIO toggle or volatile write. We therefore inspected it with loadelf:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .isr_vectors      PROGBITS        80100000 000134 000000 00   A  0   0  1
  [ 2] .vector_table     PROGBITS        80100600 000134 000800 00   A  0   0  4

This output indicated that .isr_vectors was empty.

Attempt 7: .isr_vectors vs. .vector_table

We first verified that .vector_table was populated:

objcopy -O binary --only-section=.vector_table ./dist/firmware/cm33.elf vtor_orig.bin
xxd vtor_orig.bin | head -2

We then dumped the binary:

ethanwu@ethanwu:~/consortium/examples/melt-pot/stm32mp25$ xxd vtor_orig.bin | head -2
00000000: 0000 b080 010e 1080 f725 1080 2d48 1080  .........%..-H..
00000010: f725 1080 f725 1080 f725 1080 f725 1080  .%...%...%...%..

The original .vector_table was valid, suggesting that renaming the section might be sufficient:

arm-none-eabi-objcopy \
  --rename-section .vector_table=.isr_vectors \
  ./dist/firmware/cm33.elf ./dist/firmware/cm33_fixed.elf

After renaming the section, the firmware ran successfully.

UIO, July 9, 2026 (Enabling Read/Write Access)

To use mmap through UIO, the application must open and map the appropriate UIO device node. We began by running:

RUST_LOG=trace ./melt-pot-stm32mp25-app

The program terminated immediately with an uninformative error:

Bus error (core dumped)

Because the program provided no further diagnostic information, we used gdb to capture the point at which it crashed:

(gdb) run
Starting program: /home/root/melt-pot-stm32mp25-app
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
[New Thread 0xfffff7d1f080 (LWP 7877)]
[New Thread 0xfffff7b0f080 (LWP 7878)]

Thread 1 "melt-pot-stm32m" received signal SIGBUS, Bus error.
core::ptr::read_volatile<u32> (src=0xfffff7fe5080)
    at /home/ethanwu/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:2095
warning: 2095 /home/ethanwu/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs: No such file or directory

We did not need an interactive debugging session; the backtrace alone was sufficient for this investigation. We switched to a debug build, reproduced the Bus error, and requested the backtrace:

(gdb) bt
#0  core::ptr::read_volatile<u32> (src=0xfffff7fe5080)
    at /home/ethanwu/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:2095
#1  core::ptr::const_ptr::{impl#0}::read_volatile<u32> (self=0xfffff7fe5080)
    at /home/ethanwu/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/const_ptr.rs:1192
#2  0x0000aaaaaaace7d4 in consortium_ipc_doorbell_hsem::abstraction::Hsem<consortium_ipc::side::Primary, 1>::read_lock<consortium_ipc::side::Primary, 1> (self=0xffffffffe100, sem=0)
    at /home/ethanwu/consortium/crates/consortium-ipc-doorbell-hsem/src/abstraction.rs:118
#3  0x0000aaaaaaace0f4 in consortium_ipc_doorbell_hsem::doorbell::{impl#3}::ring<consortium_ipc::side::Primary, 1> (
    self=0xffffffffe0f8, ch=...) at /home/ethanwu/consortium/crates/consortium-ipc-doorbell-hsem/src/doorbell.rs:115

The backtrace made the failure clear: reading the hsem register caused the bus error. To compare behavior across the other UIO devices, we used a small diagnostic utility:

#include <sys/mman.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <getopt.h>

#define MAP_SIZE 4096UL

static int is_presentable_ascii(const unsigned char *bytes, unsigned int count) {
    if (count == 0) {
        return 0;
    }

    for (unsigned int i = 0; i < count; i++) {
        unsigned char b = bytes[i];
        if (b == '\n' || b == '\r' || b == '\t') {
            continue;
        }
        if (b < 0x20 || b > 0x7e) {
            return 0;
        }
    }

    return 1;
}

static void print_ascii(const unsigned char *bytes, unsigned int count) {
    printf("As ASCII:\n  \"");
    for (unsigned int i = 0; i < count; i++) {
        switch (bytes[i]) {
            case '\n': printf("\\n"); break;
            case '\r': printf("\\r"); break;
            case '\t': printf("\\t"); break;
            case '"':  printf("\\\""); break;
            case '\\': printf("\\\\"); break;
            default:   putchar(bytes[i]); break;
        }
    }
    printf("\"\n");
}

static void usage(const char *prog) {
    fprintf(stderr,
        "Usage:\n"
        "  %s [options] <dev> r <offset_hex> [count]\n"
        "  %s [options] <dev> w <offset_hex> <value_hex>\n"
        "\n"
        "Options:\n"
        "  -w, --window <n>    Select mmap window (default: 0)\n"
        "  -n, --count <n>     Number of bytes to read (default: 4)\n"
        "\n"
        "Examples:\n"
        "  %s /dev/uio1 r 0x0\n"
        "  %s /dev/uio1 r 0x0 16                    # read 16 bytes\n"
        "  %s /dev/uio1 -w 1 r 0x100\n"
        "  %s /dev/uio1 -w 2 -n 32 r 0x0            # read 32 bytes from window 2\n"
        "  %s /dev/uio1 w 0x0 0x7c\n",
        prog, prog, prog, prog, prog, prog, prog);
}

int main(int argc, char **argv) {
    int opt;
    unsigned int window = 0;
    unsigned int batch_count = 4;

    struct option long_opts[] = {
        { "window", required_argument, NULL, 'w' },
        { "count",  required_argument, NULL, 'n' },
        { NULL, 0, NULL, 0 }
    };

    while ((opt = getopt_long(argc, argv, "w:n:", long_opts, NULL)) != -1) {
        switch (opt) {
            case 'w':
                errno = 0;
                window = (unsigned int)strtoul(optarg, NULL, 0);
                if (errno != 0) { perror("strtoul(window)"); return 1; }
                break;
            case 'n':
                errno = 0;
                batch_count = (unsigned int)strtoul(optarg, NULL, 0);
                if (errno != 0) { perror("strtoul(count)"); return 1; }
                if (batch_count == 0 || batch_count > MAP_SIZE) {
                    fprintf(stderr, "error: count must be 1..%lu\n", MAP_SIZE);
                    return 1;
                }
                break;
            default:
                usage(argv[0]);
                return 1;
        }
    }

    if (argc - optind < 3) {
        usage(argv[0]);
        return 1;
    }

    const char *dev = argv[optind];
    char mode = argv[optind + 1][0];

    if (mode != 'r' && mode != 'w') {
        fprintf(stderr, "error: mode must be 'r' or 'w', got '%s'\n", argv[optind + 1]);
        usage(argv[0]);
        return 1;
    }

    errno = 0;
    unsigned long offset = strtoul(argv[optind + 2], NULL, 16);
    if (errno != 0) { perror("strtoul(offset)"); return 1; }

    unsigned int wval = 0;
    if (mode == 'w') {
        if (argc - optind < 4) {
            fprintf(stderr, "error: write mode requires <value_hex>\n");
            usage(argv[0]);
            return 1;
        }
        errno = 0;
        wval = (unsigned int)strtoul(argv[optind + 3], NULL, 16);
        if (errno != 0) { perror("strtoul(value)"); return 1; }
    } else {
        /* Read mode: optional count argument */
        if (argc - optind >= 4) {
            errno = 0;
            batch_count = (unsigned int)strtoul(argv[optind + 3], NULL, 0);
            if (errno != 0) { perror("strtoul(count)"); return 1; }
            if (batch_count == 0 || batch_count > MAP_SIZE) {
                fprintf(stderr, "error: count must be 1..%lu\n", MAP_SIZE);
                return 1;
            }
        }
    }

    if (offset + batch_count > MAP_SIZE) {
        fprintf(stderr, "error: offset 0x%lx + count %u exceeds mapped range (0x%lx)\n",
                offset, batch_count, MAP_SIZE);
        return 1;
    }

    if (mode == 'w' && offset % sizeof(unsigned int) != 0) {
        fprintf(stderr, "warning: offset 0x%lx is not 4-byte aligned for write\n", offset);
    }

    int fd = open(dev, O_RDWR);
    if (fd < 0) { perror("open"); return 1; }

    /* Calculate file offset: window_number * MAP_SIZE + offset */
    off_t file_offset = (off_t)window * (off_t)MAP_SIZE;

    void *map = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, file_offset);
    if (map == MAP_FAILED) {
        fprintf(stderr, "error: mmap failed at offset 0x%lx (window %u)\n", file_offset, window);
        perror("mmap");
        close(fd);
        return 1;
    }

    volatile unsigned char *byte_ptr = (volatile unsigned char *)((char *)map + offset);

    if (mode == 'r') {
        unsigned char bytes[MAP_SIZE];
        for (unsigned int i = 0; i < batch_count; i++) {
            bytes[i] = byte_ptr[i];
        }

        printf("%s [window %u] offset 0x%lx (%u bytes):\n", dev, window, offset, batch_count);

        /* Print bytes in rows of 16 */
        for (unsigned int i = 0; i < batch_count; i++) {
            if (i % 16 == 0) {
                printf("  0x%04lx: ", offset + i);
            }
            printf("%02x ", bytes[i]);
            if ((i + 1) % 16 == 0 || i == batch_count - 1) {
                printf("\n");
            }
        }

        if (is_presentable_ascii(bytes, batch_count)) {
            print_ascii(bytes, batch_count);
        }

        /* Also print as 32-bit words if aligned and count >= 4 */
        if (offset % 4 == 0 && batch_count >= 4) {
            printf("As 32-bit words:\n");
            volatile unsigned int *word_ptr = (volatile unsigned int *)byte_ptr;
            unsigned int word_count = batch_count / 4;
            for (unsigned int i = 0; i < word_count; i++) {
                if (i % 4 == 0) {
                    printf("  0x%04lx: ", offset + i * 4);
                }
                printf("%08x ", word_ptr[i]);
                if ((i + 1) % 4 == 0 || i == word_count - 1) {
                    printf("\n");
                }
            }
        }
    } else {
        printf("%s [window %u] offset 0x%lx : writing 0x%08x\n", dev, window, offset, wval);
        volatile unsigned int *ptr = (volatile unsigned int *)byte_ptr;
        *ptr = wval;
        unsigned int rb = *ptr;
        printf("%s [window %u] offset 0x%lx readback = 0x%08x\n", dev, window, offset, rb);
        if (rb != wval) {
            fprintf(stderr,
                "note: readback != written value (expected for HW-controlled "
                "fields like HSEM LOCK/MASTERID)\n");
        }
    }

    munmap(map, MAP_SIZE);
    close(fd);
    return 0;
}

HSEM, a memory-mapped I/O peripheral, was exposed through /dev/uio2, while the outer-shareable, non-cacheable shared-memory regions were exposed through /dev/uio0 and /dev/uio1. Only /dev/uio2 produced a Bus error, which narrowed the likely causes to the following:

  1. RIF blocked the read and issued an Illegal Access event.
  2. No clock had been assigned to the peripheral.
  3. The read was unaligned. This fails because all readings are word-by-word.

The first possibility was initially the most intuitive. In an earlier, unrelated case, an LLM fine-tuning process produced a Bus error on an RTX 4090 cluster while working on RTX 3090 nodes. The account did not have permission to use the 4090 nodes, which had temporarily been reserved for newcomers. That experience associated a bus error with an access-control problem analogous to RIF here; it was only a heuristic, not evidence for this specific failure.

Checking RM0457 did not support that hypothesis:

The official STM32CubeMX example assigned IPCC1 to all cores and disabled opt-out. Both CPU1 (ca35) and CPU2 (cm33) therefore had access to the peripheral, ruling out the RIF hypothesis.

dtc -I dtb -O dts -o stm32mp257f-dk.dts stm32mp257f-dk.dtb

We converted the dtb to dts to inspect the configuration passed to the device, focusing on the official ipcc1 node:

ipcc1: mailbox@40490000 {
	compatible = "st,stm32mp1-ipcc";
	#mbox-cells = <0x01>;
	reg = <0x40490000 0x400>;
	st,proc-id = <0x00>;
	interrupts = <0x00 0xab 0x04 0x00 0xac 0x04>;
	interrupt-names = "rx", "tx";
	clocks = <0x14 0x69>;
	status = "disabled";
	phandle = <0x9b>;
};

The node contained clocks = <0x14 0x69>;. By comparison, our hsem node was minimal:

hsem@46240000 {
	status = "okay";
	interrupts = <0x00 0xc7 0x04>;
	interrupt-parent = <0x06>;
	reg = <0x00 0x46240000 0x00 0x400>;
	compatible = "generic-uio";
};

To identify the clock, we inspected /include/dt-bindings/clock/st,stm32mp25-rcc.h in the Linux repository. This header is reached through the DTS include chain: stm32mp257.dtsi includes stm32mp255.dsti, which includes stm342mp253.dsti, which includes stm32mp251.dtsi, which in turn includes st,stm32mp255-rcc.h.

#include <dt-bindings/clock/st,stm32mp25-rcc.h>

The relevant definitions were:

#define CK_SCMI_HSEM		104
#define CK_SCMI_IPCC1		105
#define CK_SCMI_IPCC2		106

In the official IPCC1 configuration, 0x69 corresponded to CK_SCMI_IPCC1; for IPCC2, the value was 0x6a. This indicated that the second cell selected the relevant CK_SCMI_[peripheral] clock. We then identified the meaning of 0x14:

scmi_clk: protocol@14 {
	bootph-all;
	reg = <0x14>;
	#clock-cells = <0x01>;
	phandle = <0x14>;
};

From these definitions, we determined that HSEM required clocks = <0x14 0x68>;. After adding that property to the node, access succeeded.

The UIO mapping then worked as expected.

UIO, July 6, 2026 (Compiling into the Kernel)

Consortium uses UIO for Cortex-A-side handling. UIO provides two capabilities required by Consortium:

  1. Interrupt routing. uio_pdrv_genirq allows an interrupt to be handled from user space.
  2. Memory mapping. UIO maps a specific physical memory region into an accessible virtual memory region.

UIO Is Not Present in the Kernel

To get started, we first checked whether UIO support was included in the running kernel:

# Is it built-in? Any of these confirming = built-in, no build needed:
ls -d /sys/module/uio_pdrv_genirq 2>/dev/null
grep -w uio_pdrv_genirq /lib/modules/$(uname -r)/modules.builtin

# What did the kernel actually enable?
zcat /proc/config.gz 2>/dev/null | grep -iE 'CONFIG_UIO'
# if there's no /proc/config.gz:
grep -iE 'CONFIG_UIO' /boot/config-$(uname -r) 2>/dev/null

# Already-present uio devices?
ls -d /sys/class/uio/uio* 2>/dev/null

The result is:

root@stm32mp2-e3-aa-db:~# ls -d /sys/module/uio_pdrv_genirq 2>/dev/null
root@stm32mp2-e3-aa-db:~# grep -w uio_pdrv_genirq /lib/modules/$(uname -r)/modules.builtin
root@stm32mp2-e3-aa-db:~# zcat /proc/config.gz 2>/dev/null | grep -iE 'CONFIG_UIO'
# CONFIG_UIO is not set
root@stm32mp2-e3-aa-db:~# grep -iE 'CONFIG_UIO' /boot/config-$(uname -r) 2>/dev/null
root@stm32mp2-e3-aa-db:~# ls -d /sys/class/uio/uio* 2>/dev/null
root@stm32mp2-e3-aa-db:~#

Enabling UIO required rebuilding the kernel with Yocto. Following the July 3 Yocto investigation, which is not retained in this book, we modified config.cfg:

CONFIG_UIO=y
CONFIG_UIO_PDRV_GENIRQ=y
CONFIG_UIO_DMEM_GENIRQ=y
CONFIG_OF_OVERLAY=y
CONFIG_OF_CONFIGFS=y
CONFIG_CONFIGFS_FS=y

After the rebuild, the kernel included UIO support. However, no UIO device appeared, even though the DTS node had been added.

UIO Is in the Kernel, but No Device Is Available

The node was present in the device tree, but the corresponding UIO device was not:

root@imx95-15x15-lpddr4x-frdm:~# ls /sys/firmware/devicetree/base/ | grep -E "mu7|ipc-shm|dbg-shm"
dbg-shm@20484000
ipc-shm@20480000
mu7@42430000
root@imx95-15x15-lpddr4x-frdm:~#
root@imx95-15x15-lpddr4x-frdm:~# cat /sys/firmware/devicetree/base/mu7@42430000/compatible
generic-uio
root@imx95-15x15-lpddr4x-frdm:~# cat /sys/firmware/devicetree/base/mu7@42430000/compatible
generic-uio
root@imx95-15x15-lpddr4x-frdm:~#
root@imx95-15x15-lpddr4x-frdm:~# ls /sys/class/uio/
root@imx95-15x15-lpddr4x-frdm:~# dmesg | grep -iE "uio|42430000"
root@imx95-15x15-lpddr4x-frdm:~#

Because dmesg contained no indication that the driver had loaded, we checked the kernel configuration again:

root@imx95-15x15-lpddr4x-frdm:~# modprobe uio_pdrv_genirq
modprobe: FATAL: Module uio_pdrv_genirq not found in directory /lib/modules/6.18.20-2.0.0-gb096ce610e95

root@imx95-15x15-lpddr4x-frdm:~# zcat /proc/config.gz | grep UIO
CONFIG_FEC_UIO=y
CONFIG_UIO=y
# CONFIG_UIO_CIF is not set
# CONFIG_UIO_PDRV_GENIRQ is not set
# CONFIG_UIO_DMEM_GENIRQ is not set
# CONFIG_UIO_AEC is not set
# CONFIG_UIO_SERCOS3 is not set
CONFIG_UIO_PCI_GENERIC=y
# CONFIG_UIO_NETX is not set
# CONFIG_UIO_MF624 is not set
CONFIG_UIO_PRIME=y
CONFIG_UIO_IVSHMEM=y
CONFIG_CRYPTO_DEV_FSL_CAAM_JR_UIO=m

The output contained CONFIG_UIO_PDRV_GENIRQ is not set, even though we thought we had enabled it in the Linux configuration. Inspecting the Yocto build configuration revealed the following line:

CONFIG_UIO_PDRV_GENERIC=y

We had mistakenly written GENERIC; the correct suffix is GENIRQ. We corrected the configuration, rebuilt the kernel, reflashed the board, and tried again:

root@imx95-15x15-lpddr4x-frdm:~# lsmod | grep uio
enetc4_uio             20480  0
root@imx95-15x15-lpddr4x-frdm:~# cat /proc/iomem | grep -A2 -B2 42430000
42000000-4220ffff : 42000000.dma-controller dma-controller@42000000
42210000-4241ffff : 42210000.dma-controller dma-controller@42210000
42430000-4243ffff : 42430000.mailbox mailbox@42430000
42490000-4249ffff : 42490000.watchdog watchdog@42490000
42530000-4253ffff : 42530000.i2c i2c@42530000
root@imx95-15x15-lpddr4x-frdm:~#

The peripheral did appear in the system’s iomem map, but lsmod did not show the relevant UIO driver. In other words, uio_pdrv_genirq still was not loaded.

root@imx95-15x15-lpddr4x-frdm:~# find /lib/modules/$(uname -r)/ -name "*genirq*"
root@imx95-15x15-lpddr4x-frdm:~#
root@imx95-15x15-lpddr4x-frdm:~# modprobe uio_pdrv_genirq
root@imx95-15x15-lpddr4x-frdm:~#
root@imx95-15x15-lpddr4x-frdm:~# find /lib/modules/$(uname -r)/ -name "*genirq*"
root@imx95-15x15-lpddr4x-frdm:~#

Because the option was set to =y, the driver was compiled directly into the kernel image and therefore could not appear as a loadable module in lsmod. We checked dmesg again:

root@imx95-15x15-lpddr4x-frdm:~# find /lib/modules/$(uname -r)/ -name "*genirq*"
root@imx95-15x15-lpddr4x-frdm:~#
root@imx95-15x15-lpddr4x-frdm:~# modprobe uio_pdrv_genirq
root@imx95-15x15-lpddr4x-frdm:~#
root@imx95-15x15-lpddr4x-frdm:~# find /lib/modules/$(uname -r)/ -name "*genirq*"
root@imx95-15x15-lpddr4x-frdm:~# dmesg | grep -i "uio_pdrv_genirq\|generic-uio"
root@imx95-15x15-lpddr4x-frdm:~# dmesg | grep -iE "42430000|EBUSY|request_mem_region|resource"
[    1.154269] kvm [1]: GICv3: no GICV resource entry
[    2.598405] pci_bus 0001:00: root bus resource [bus 00]
[    2.603692] pci_bus 0001:00: root bus resource [mem 0x4cc00000-0x4ccdffff]
[    2.610589] pci_bus 0001:00: root bus resource [mem 0x4cd00000-0x4cd0ffff pref]
[    2.617912] pci_bus 0001:00: root bus resource [mem 0x4cd20000-0x4cd7ffff]
[    2.624786] pci_bus 0001:00: root bus resource [mem 0x4cd80000-0x4cddffff pref]
[    2.914973] pci_bus 0001:00: resource 4 [mem 0x4cc00000-0x4ccdffff]
[    2.921253] pci_bus 0001:00: resource 5 [mem 0x4cd00000-0x4cd0ffff pref]
[    2.927977] pci_bus 0001:00: resource 6 [mem 0x4cd20000-0x4cd7ffff]
[    2.934246] pci_bus 0001:00: resource 7 [mem 0x4cd80000-0x4cddffff pref]
[    3.355114] pci_bus 0002:01: root bus resource [bus 01]
[    3.360354] pci_bus 0002:01: root bus resource [mem 0x4cce0000-0x4ccfffff]
[    3.367220] pci_bus 0002:01: root bus resource [mem 0x4cd10000-0x4cd1ffff pref]
[    3.421859] pci_bus 0002:01: resource 4 [mem 0x4cce0000-0x4ccfffff]
[    3.428214] pci_bus 0002:01: resource 5 [mem 0x4cd10000-0x4cd1ffff pref]
[    5.535598] pci_bus 0000:00: root bus resource [bus 00-ff]
[    5.541113] pci_bus 0000:00: root bus resource [io  0x0000-0xfffff]
[    5.547387] pci_bus 0000:00: root bus resource [mem 0x910000000-0x98fffffff] (bus address [0x10000000-0x8fffffff])
[    5.641339] pci_bus 0000:00: resource 4 [io  0x0000-0xfffff]
[    5.647013] pci_bus 0000:00: resource 5 [mem 0x910000000-0x98fffffff]
root@imx95-15x15-lpddr4x-frdm:~# ls /sys/bus/platform/drivers/uio_pdrv_genirq/ 2>/dev/null
bind  uevent  unbind
root@imx95-15x15-lpddr4x-frdm:~# cat /sys/firmware/devicetree/base/mu7@42430000/status
okayroot@imx95-15x15-lpddr4x-frdm:~#

The driver existed (/sys/bus/platform/drivers/uio_pdrv_genirq/ contained bind and unbind), and the node had status = "okay". However, dmesg contained no trace of a probe attempt, not even an EBUSY error.

root@imx95-15x15-lpddr4x-frdm:~# find /sys/devices -name "*42430000.mu7*"
/sys/devices/platform/42430000.mu7
root@imx95-15x15-lpddr4x-frdm:~# udevadm info /sys/bus/platform/devices/42430000.mu7 2>/dev/null
P: /devices/platform/42430000.mu7
M: 42430000.mu7
R: 7
J: +platform:42430000.mu7
U: platform
E: DEVPATH=/devices/platform/42430000.mu7
E: OF_NAME=mu7
E: OF_FULLNAME=/mu7@42430000
E: OF_COMPATIBLE_0=generic-uio
E: OF_COMPATIBLE_N=1
E: MODALIAS=of:Nmu7T(null)Cgeneric-uio
E: SUBSYSTEM=platform
E: USEC_INITIALIZED=9461038
E: ID_PATH=platform-42430000.mu7
E: ID_PATH_TAG=platform-42430000_mu7
root@imx95-15x15-lpddr4x-frdm:~# cat /sys/bus/platform/devices/42430000.mu7/uevent
OF_NAME=mu7
OF_FULLNAME=/mu7@42430000
OF_COMPATIBLE_0=generic-uio
OF_COMPATIBLE_N=1
MODALIAS=of:Nmu7T(null)Cgeneric-uio
root@imx95-15x15-lpddr4x-frdm:~#
root@imx95-15x15-lpddr4x-frdm:~# ls -la /sys/bus/platform/devices/42430000.mu7/driver 2>/dev/null
root@imx95-15x15-lpddr4x-frdm:~#

Further inspection of the node produced the following properties:

OF_NAME=mu7
OF_FULLNAME=/mu7@42430000
OF_COMPATIBLE_0=generic-uio
OF_COMPATIBLE_N=1
MODALIAS=of:Nmu7T(null)Cgeneric-uio

The MODALIAS line indicated that the device was valid but unbound. This narrowed the problem to uio_pdrv_genirq: the driver existed, but it had not bound to this device.

Unlike most platform drivers, mainline Linux’s drivers/uio/uio_pdrv_genirq.c ships with an empty of_device_id match table:

#ifdef CONFIG_OF
static struct of_device_id uio_of_genirq_match[] = {
	{ /* This is filled with module_parm */ },
	{ /* Sentinel */ },
};
module_param_string(of_id, uio_of_genirq_match[0].compatible, 128, 0);
MODULE_PARM_DESC(of_id, "Openfirmware id of the device to be handled by uio");
#endif

This upstream behavior is deliberate: compatible = "generic-uio" was considered too broad for safe automatic binding through normal OF matching, because the driver could accidentally claim devices that should not be exposed through UIO. The driver therefore requires the compatible string to be supplied explicitly through a module parameter at load time, rather than matching the DT compatible property automatically. To provide that parameter, we needed to modify the U-Boot boot arguments:

uio_pdrv_genirq.of_id=generic-uio

During boot, the virtual COM port (VCP) displayed a countdown with the prompt “press any key to stop autoboot.” Pressing a key from the host serial console opened the U-Boot command line:

u-boot=> setenv mmcargs 'setenv bootargs ${jh_clk} console=${console} root=${mmcroot} uio_pdrv_genirq.of_id=generic-uio'
u-boot=> saveenv
u-boot=> boot

After booting with the updated arguments, we checked again and confirmed that it worked:

imx95-15x15-lpddr4x-frdm login: root
root@imx95-15x15-lpddr4x-frdm:~# cat /sys/module/uio_pdrv_genirq/parameters/of_id
cat: /sys/module/uio_pdrv_genirq/parameters/of_id: No such file or directory
root@imx95-15x15-lpddr4x-frdm:~# ls -la /sys/bus/platform/devices/42430000.mu7/driver
lrwxrwxrwx 1 root root 0 Mar 13 15:40 /sys/bus/platform/devices/42430000.mu7/driver -> ../../../bus/platform/drivers/uio_pdrv_genirq
root@imx95-15x15-lpddr4x-frdm:~# ls /sys/class/uio/
uio0  uio1  uio2

The Approach Worked on FRDM-IMX95 but Failed on STM32MP257F-DK

We then inspected the boot arguments on the STM32MP257F-DK:

bootargs= uio_pdrv_genirq.of_id=generic-uio (does the space after = matter?)
bootcmd=run bootcmd_stm32mp

The bootcmd_stm32mp command was:

bootcmd_stm32mp=echo "Boot over ${boot_device}${boot_instance}!";if test ${boot_device} = serial || test ${boot_device} = usb;then stm32prog ${boot_device} ${boot_instance}; else run env_check;if test ${boot_device} = mmc;then env set boot_targets "mmc${boot_instance}"; fi;if test ${boot_device} = nand || test ${boot_device} = spi-nand ;then env set boot_targets ubifs0 mmc0; fi;if test ${boot_device} = nor;then env set boot_targets mmc0; fi;run distro_bootcmd;fi;

It runs distro_bootcmd, the standard distro-boot script. That script locates extlinux.conf on the boot partition and boots with its APPEND line, rather than using a manually set bootargs environment variable.

We found the file at /boot/mmc0_extlinux/extlinux.conf; it contained:

MENU BACKGROUND /splash_landscape.bmp
TIMEOUT 20
LABEL OpenSTLinux
    KERNEL /Image.gz
    FDTDIR /
    INITRD /st-image-resize-initrd
    APPEND root=PARTUUID=e91c4e10-16e6-4c0e-bd0e-77becf4a3582 rootwait rw  earlycon console=${console},${baudrate}

That APPEND line exactly matched the contents of /proc/cmdline. Because it did not include uio_pdrv_genirq.of_id=generic-uio, this confirmed where the argument was being dropped.

We modified the line in vi:

APPEND root=PARTUUID=e91c4e10-16e6-4c0e-bd0e-77becf4a3582 rootwait rw earlycon console=${console},${baudrate} uio_pdrv_genirq.of_id=generic-uio

After this change, the device bound successfully.

Development Notes

These chapters preserve subsystem designs, implementation investigations, and hardware bring-up notes. They explain why parts of Consortium look the way they do, but some were written before the current manifest, runtime entry macros, or build pipeline existed.

For current usage, begin with the User Guide. When a development note conflicts with the guide or with crate source, the current source is authoritative.

Consortium Architecture

The Consortium is a asymmetric multiprocessing framework that cooperates non-secure Linux, secure OP-TEE, non-secure RTOS, and secure RTOS. The architecture of the Consortium is designed to provide a secure and efficient communication channel between the different components while maintaining the security and isolation of each component.

Traditionally, developing heterogeneous and asymmetric multiprocessing systems, e.g., NXP’s i.MX 8 series and ST’s STM32MP1 series, requires significant engineering effort to design and implement the communication channel between the different components. The Consortium aims to simplify this process by providing a unified framework that abstracts away the complexities of inter-component communication.

The Consortium architecture consists of the following components:

  • Consortium IPC: A communication channel that allows the different components to communicate with each other securely and efficiently. It provides a unified API for sending and receiving messages between the components, abstracting away the underlying communication mechanisms. Asynchronous and type-safe is the default choice. We implement hardware-specific IPC mechanisms, e.g., HSEM in STM32MP series and MU in i.MX series, as IPC backends. You only need to write .send().await? and .recv().await? to send and receive messages, and the IPC layer will take care of the rest, including serialization, deserialization, and synchronization.
  • Consortium TEE: A secure execution environment that runs on the OP-TEE OS. It provides a secure environment for executing sensitive code and handling sensitive data. The Consortium TEE is designed to be compatible with the GlobalPlatform TEE specifications, allowing developers to leverage existing TEE applications and libraries. It uses macro-based code generation to simplify the development of TEE applications and ensure type safety. You only need to write it like a normal Rust library, and the macro will generate the necessary boilerplate code for you.
  • Consortium Runtime: A runtime library that runs on the non-secure Linux and non-secure RTOS. It provides a unified API for interacting with the Consortium IPC and Consortium TEE, allowing developers to easily integrate the Consortium into their applications. The Consortium Runtime is designed to be lightweight and efficient, minimizing the overhead of using the Consortium in your applications.
  • Consortium HMI: A human-machine interface (HMI) library that provides a unified API for creating user interfaces on the non-secure Linux side. It abstracts away the complexities of different GUI frameworks and allows developers to create user interfaces using a simple and consistent API. The Consortium HMI is designed to be flexible and extensible, allowing developers to easily integrate it with their existing applications and frameworks. It allows whatever GUI framework you want, especially web-based GUI frameworks. We can bundle a WebGTK automatically, and you can also view the page via a browser on another device, e.g., your phone, to interact with the Consortium system. You only need to write the UI logic, and the HMI layer will take care of the rest, including rendering and event handling.
  • Consortium FFI: A foreign function interface (FFI) library that provides a unified API for calling functions across the different components. It allows developers to call functions in Python to run OpenCV for computer vision tasks, call functions in C to run TensorRT for AI inference tasks, etc. Though we recommend using Rust for the best experience, you can still use other languages if you want. The Consortium FFI is designed to be flexible and extensible, allowing developers to easily integrate it with their existing applications and frameworks.
  • Consortium ONNX Runtime: A library that provides a wrapper of ort for running ONNX models in the Consortium system. It allows developers to easily run ONNX models on the different components, leveraging the hardware acceleration capabilities of each component. The Consortium ONNX Runtime is designed to be efficient and easy to use, allowing developers to quickly integrate ONNX model inference into their applications. We also automatically bundle the runtime library and the model together, so you can just drop the model file into a specific directory and start using it without worrying about the details of loading the model and running inference.
  • Consortium Developer Tools: A set of tools that assist developers in building, testing, and debugging their applications using the Consortium framework. These tools include a build system that simplifies the process of building and deploying applications to the different components, a testing framework that allows developers to easily write and run tests for their applications, and a debugging tool that provides insights into the communication between the different components.

The Consortium architecture is designed to be modular and extensible, allowing developers to easily add new components and features as needed. By providing a unified framework for inter-component communication and secure execution, the Consortium aims to simplify the development of heterogeneous and asymmetric multiprocessing systems, enabling developers to focus on building innovative applications rather than dealing with the complexities of inter-component communication.

Integration: Complete Example

Here’s how TEE, HMI, ORT, and FFI work together:

// my-app/src/main.rs
use consortium_runtime_app::RemoteProc;
use consortium_tee::TeeSession;
use consortium_ort::ModelPool;
use consortium_ffi::FfiPool;
use my_app::models::ObjectDetectionModel;
use my_app::hmi::AppHmi;

#[tokio::main]
async fn main() -> Result<()> {
    // 1. Initialize all subsystems
    let remoteproc = RemoteProc::new(0);
    remoteproc.start()?;

    let tee = TeeSession::open().await?;
    let od_model = ObjectDetectionModel::load("yolo.onnx").await?;
    let ffi_pool = FfiPool::new()
        .add_python_worker("cv_workers", 4)?
        .start()
        .await?;

    let hmi = AppHmi;

    // 2. Incoming image stream (from sensor via IPC or camera)
    let mut image_stream = create_image_stream().await?;

    while let Some(image) = image_stream.next().await {
        // Step A: Run ML inference
        let detections = od_model.predict(&image).await?;

        // Step B: Call Python for post-processing
        let edges = ffi_pool.detect_edges(image.to_bytes()).await?;

        // Step C: Verify authenticity (in TEE)
        let is_legit = tee.call_verify_image_signature(&edges).await?;

        // Step D: Update HMI with results
        let event = AppEvent::ObjectsDetected {
            detections,
            verified: is_legit,
        };
        broadcast_event(event).await?;
    }

    Ok(())
}

This demonstrates the power of the Consortium framework: developers can seamlessly combine:

  • IPC for low-latency sensor data
  • TEE for security-critical operations
  • ORT for AI inference
  • FFI for specialized libraries
  • HMI for user interaction

All with a clean, ergonomic Rust API.

Inter-process communication (IPC)

Consortium IPC provides a unified API for sending and receiving messages between the different components of CPUs. It abstracts away the underlying communication mechanisms, allowing developers to focus on the logic of their applications rather than the details of inter-process communication.

The abstraction of the Consortium IPC is as follows:

doorbell -> transport -> codec -> channel
  • Doorbell: The doorbell is the hardware mechanism that triggers the communication between the different components. It can be implemented using various hardware mechanisms, such as semaphores, message units, or shared memory. The Consortium IPC provides a unified API for interacting with the doorbell, allowing developers to easily trigger communication between the components.
  • Transport: The transport layer is responsible for managing the communication channel between the different components. It handles the synchronization and coordination of messages, ensuring that messages are delivered reliably and in order. The Consortium IPC provides a unified API for interacting with the transport layer, allowing developers to easily manage the communication channel between the components.
  • Codec: The codec layer is responsible for serializing and deserializing messages between the different components. It ensures that messages are properly formatted and can be correctly interpreted by the receiving component. The Consortium IPC provides a unified API for interacting with the codec layer, allowing developers to easily serialize and deserialize messages between the components.
  • Channel: The channel layer is the abstraction that represents the communication channel between the different components. It provides a simple and consistent API for sending and receiving messages, abstracting away the complexities of the underlying communication mechanisms. The Consortium IPC provides a unified API for interacting with the channel layer, allowing developers to easily send and receive messages between the different components.

The final effect of the Consortium IPC is that developers can send and receive messages between the different components using a simple and consistent API, without worrying about the details of inter-process communication. This allows developers to focus on building innovative applications rather than dealing with the complexities of inter-component communication.

IPC Layer Stack

The Consortium IPC is organized as a series of abstraction layers:

┌──────────────────────────────────────────────────────────────┐
│  Application Layer                                           │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Channel<Tx/Rx, MessageType, Transport, Codec>          │ │
│  │  .send(msg).await? / .recv().await?                     │ │
│  │  Type-safe, allocation-free, async                      │ │
│  └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Codec Layer                                                 │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Codec Trait: T <─→ [u8]                                │ │
│  │  Default: PostcardCodec (serde-based, compact binary)   │ │
│  │  Future: Zero-copy codecs (rkyv)                        │ │
│  └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Transport Layer                                             │
│  ├─ SharedMemoryTransport (M-core / shared memory)           │
│  │  Generic over Doorbell + Region                          │
│  │  Manages slot protocol, async polling                    │
│  └─ runtime-app UIO path (Linux A-core)                      │
│     mmap-backed, Tokio interrupt task                       │
└──────────────────────────────────────────────────────────────┘
                           │
┌────────────┬─────────────┴──────────────┬───────────┐
│            │                            │           │
▼            ▼                            ▼           ▼
Doorbell   Slot Protocol              Region        Channel
 │           │                          │            ID
 │           ▼                          ▼            │
 │   [len:u32|flags:u32|data]   Cache coherency   Chan(u8)
 │                              Management
 ▼
Hardware Signaling
├─ HSEM (STM32MP23/25)
├─ IPCC (STM32MP21/23/25)
├─ MU (i.MX)
└─ GPIO/Notify (testing)

Developer Experience

M-Core (Bare-Metal Firmware)

// Static buffer for IPC (sized to max message MTU)
static mut TX_BUF: [u8; 256] = [0u8; 256];
static mut HSEM_DOORBELL: HsemDoorbell = /* ... */;
static mut REGION: DdrRegion = /* ... */;

#[main]
async fn main() {
    // 1. Create transport (owning doorbell, managing slots)
    let mut transport = SharedMemoryTransport::new(
        chan,
        doorbell,
        region,
        unsafe { SHARED_REGION_BASE },
    );

    // 2. Create typed channel for a specific message
    let mut tx: Channel<Tx, SensorData, _, PostcardCodec> =
        Channel::new(chan, transport, unsafe { &mut TX_BUF });

    // 3. Send data (async, awaitable)
    loop {
        let data = SensorData {
            temperature: 26.9,
            timestamp_ms: system_time_ms(),
        };
        tx.send(&data).await?;  // blocks until A-core reads
        delay_ms(1000);
    }
}

A-Core (Linux + Tokio)

use consortium_runtime_app::{mmap, remoteproc::RemoteProc, uio::UioDevice};

#[tokio::main]
async fn main() -> Result<()> {
    // 1. Load and start firmware
    let remoteproc = RemoteProc::new(0);
    remoteproc.set_firmware("firmware.elf")?;
    remoteproc.start()?;

    // 2. Wait for the descriptor, then open UIO
    mmap::wait_for_ready(0, Duration::from_secs(10)).await?;
    let uio = UioDevice::open(0, 2, 512)?;

    // 3. Create typed channel matching M-core
    let mut rx: Channel<Rx, SensorData, _, PostcardCodec> =
        Channel::new(chan, uio.channel_transport(chan, doorbell, region)?, unsafe { &mut RX_BUF });

    // 4. Receive messages in loop (async, awaitable)
    loop {
        let msg = rx.recv().await?;
        println!("Temp: {}°C at {}ms", msg.temperature, msg.timestamp_ms);
    }

    // Cleanup
    remoteproc.stop()?;
    Ok(())
}

Consortium TEE: Secure Execution Environment

The Consortium TEE provides a type-safe, macro-driven interface for building OP-TEE Trusted Applications (TAs) and calling them securely from the normal world (A-core Linux application).

TEE Architecture

┌──────────────────────────────────────────────────────────────┐
│  A-Core (Normal World - Linux)                              │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  Consortium Application                                │ │
│  │  ┌──────────────────────────────────────────────────┐  │ │
│  │  │  CA (Client Application)                         │  │ │
│  │  │  call_authenticate(secret)                       │  │ │
│  │  │  call_encrypt(plaintext) → ciphertext            │  │ │
│  │  │  call_derive_key(seed)                           │  │ │
│  │  │                                                  │  │ │
│  │  │  (generated by #[tee_command] macro)             │  │ │
│  │  └──────────────────────────────────────────────────┘  │ │
│  └──────────────┬───────────────────────────────────────────┘ │
│                 │ optee_teec (TEE Client API)                │
│                 │ UUID, session, invoke_command              │
│                 ▼                                             │
│  OP-TEE Kernel Driver                                        │
│  └─────────────────────────────────────────────────────────┘ │
└────────────────────┬──────────────────────────────────────────┘
                     │ SMC (Secure Monitor Call)
┌────────────────────▼──────────────────────────────────────────┐
│  Secure World (OP-TEE OS)                                    │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Trusted Application (TA)                              │  │
│  │  ┌──────────────────────────────────────────────────┐  │  │
│  │  │  TA Command Handlers (generated)                 │  │  │
│  │  │  fn authenticate_dispatched(ctx, params)         │  │  │
│  │  │  fn encrypt_dispatched(ctx, params)              │  │  │
│  │  │  fn derive_key_dispatched(ctx, params)           │  │  │
│  │  │                                                  │  │  │
│  │  │  Unpacks parameters, calls user logic            │  │  │
│  │  └──────────────────────────────────────────────────┘  │  │
│  │  ┌──────────────────────────────────────────────────┐  │  │
│  │  │  User-Defined Business Logic                     │  │  │
│  │  │  fn authenticate(ctx, secret) → bool             │  │  │
│  │  │  fn encrypt(ctx, plaintext) → Vec<u8>            │  │  │
│  │  │  fn derive_key(ctx, seed) → [u8; 32]             │  │  │
│  │  └──────────────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                               │
│  Hardware Crypto Engine (AES, SHA, RSA, etc.)               │
│  (optee_utee provides safe wrappers)                        │
└───────────────────────────────────────────────────────────────┘

Developer Experience: Define Once, Generate Twice

Developers write a single Rust function and the #[tee_command] macro generates both TA and CA code automatically.

Step 1: Define the Trusted Application (Shared Library)

#![allow(unused)]
fn main() {
// my-ta/src/lib.rs
use consortium_tee_macros::{tee_command, tee_service};

// Define context (shared state)
pub struct TaContext {
    counter: u32,
    hmac_key: [u8; 32],
}

// Define service with commands
tee_service! {
    context TaContext
    commands {
        authenticate,
        encrypt_aes_cbc,
        derive_key_hmac,
    }
}

// Business logic: just plain Rust
#[tee_command(ctx)]
fn authenticate(ctx: &mut TaContext, secret: &[u8]) -> Result<bool> {
    ctx.counter += 1;
    Ok(constant_time_compare(secret, &ctx.hmac_key))
}

#[tee_command(ctx)]
fn encrypt_aes_cbc(
    ctx: &mut TaContext,
    plaintext: &[u8],
    ciphertext: &mut [u8],
) -> Result<u32> {
    // ciphertext is pre-allocated by CA
    let iv = &ctx.hmac_key[0..16];
    let key = &ctx.hmac_key[16..32];

    // Use optee_utee::crypto (secure, never copied to normal world)
    optee_utee::crypto::aes_cbc_encrypt(key, iv, plaintext, ciphertext)?;
    Ok(ciphertext.len() as u32)
}

#[tee_command(ctx)]
fn derive_key_hmac(ctx: &mut TaContext, seed: &[u8]) -> Result<Vec<u8>> {
    // Vec<u8> return: CA pre-allocates buffer, TA writes into it
    let mut key = vec![0u8; 32];
    optee_utee::crypto::hmac_sha256(seed, &ctx.hmac_key, &mut key)?;
    Ok(key)
}
}

Step 2: TA Binary (with Generated Dispatcher)

#![allow(unused)]
fn main() {
// my-ta/src/main.rs
#![no_main]
#![no_std]

use my_ta::*;

// Generated by macro: invoke_command(ctx, cmd_id, params)
#[no_mangle]
extern "C" fn TA_InvokeCommand(
    sess_ctx: *mut c_void,
    cmd_id: u32,
    param_types: u32,
    params: *mut TEE_Param,
) -> TEE_Result {
    // Dispatcher generated by tee_service! macro
    invoke_command(
        &mut TA_CONTEXT,
        cmd_id,
        param_types,
        unsafe { &mut *params.cast() },
    )
}

// Generated TA header by build.rs
include!(concat!(env!("OUT_DIR"), "/user_ta_header.rs"));
}

What the macro generates:

  • invoke_command() dispatcher that matches cmd_id to function handlers
  • For each #[tee_command] function, a function_name_dispatched() handler that:
    • Unpacks parameters from TEE slots (ParamValue, ParamMemref)
    • Validates parameter types
    • Calls the user’s function
    • Packs return values back into output slots

Step 3: CA Client Application (with Generated Stubs)

// my-ca/src/main.rs
use my_ta::call_authenticate;
use my_ta::call_encrypt_aes_cbc;
use my_ta::call_derive_key_hmac;

#[tokio::main]
async fn main() -> Result<()> {
    // Generated stubs handle all TEE protocol machinery

    // Call 1: authenticate
    let secret = b"super-secret-password";
    let is_valid: bool = call_authenticate(secret).await?;
    println!("Authentication: {}", is_valid);

    // Call 2: encrypt
    let plaintext = b"Hello, Secure World!";
    let mut ciphertext = vec![0u8; plaintext.len() + 16];  // AES padding
    let encrypted_len = call_encrypt_aes_cbc(plaintext, &mut ciphertext).await?;
    ciphertext.truncate(encrypted_len as usize);
    println!("Encrypted: {:x?}", ciphertext);

    // Call 3: derive key
    let seed = b"random-seed-12345";
    let derived_key: Vec<u8> = call_derive_key_hmac(seed).await?;
    println!("Derived key: {:x?}", derived_key);

    Ok(())
}

What the macro generates:

  • For each #[tee_command] function, a call_function_name() stub that:
    • Creates a TEE session (if not already open)
    • Packs parameters into ParamValue / ParamMemref structures
    • Invokes the TA via optee_teec::Session::invoke_command()
    • Unpacks return values
    • Returns as a native Rust type

TEE Type System

The #[tee_command] macro supports advanced type conversion:

#![allow(unused)]
fn main() {
#[derive(TeeParam, Serialize, Deserialize)]
struct Config {
    threshold: u32,
    enabled: bool,
}

#[tee_command(codec = PostcardCodec)]  // ← specify codec for TeeParam types
fn process(
    ctx: &mut TaContext,
    // Input types (TEE slots → Rust types)
    flag: bool,                          // ParamValue.a()
    count: u32,                          // ParamValue.a()
    data: &[u8],                         // ParamMemref.buffer()
    output: &mut [u8],                   // ParamMemref (mutable)
    config: Config,                      // TeeParam (deserialized via codec)

    // Output types (Rust types → TEE slots)
) -> Result<Vec<u8>> {
    // User logic
    Ok(data.to_vec())
}
}

Mapping:

Rust TypeTEE SlotDirectionNotes
boolParamValue.a (u32)in/out
u32, i32ParamValue.ain/out
u64ParamValue (a + b)in/outsplit across a(low) and b(high)
&[u8]ParamMemref (read-only)in
&mut [u8]ParamMemref (read-write)in/out
T: TeeParamParamMemrefindeserialized via codec
&mut T: TeeParamParamMemrefin/outround-trip via codec
Vec<u8>ParamMemref (output buffer)out
T: TeeParamParamMemrefoutserialized via codec

For TeeParam types, the #[tee_command(codec = ...)] attribute is required. See tee-advanced-type-system.md.

TEE Performance & Security

Latency:

  • CA → TA call: ~100–500 µs (SMC + OP-TEE context switch)
  • Crypto operation (SHA256): ~1–10 ms
  • Total roundtrip: dominated by crypto, not IPC overhead

Memory:

  • TA binary: ~100–500 KB
  • CA shared memory buffers: 4 KB per call (for large payloads)

Security Guarantees:

  • Data in TA never leaves secure world in plaintext
  • Crypto keys isolated from normal world OS
  • TA isolation enforced by hardware (TrustZone)
  • Type safety prevents buffer overflow exploits in CA ↔ TA boundary

Build Integration

# my-ta/Cargo.toml
[lib]
crate-type = ["rlib"]

[[bin]]
name = "ta"
crate-type = ["cdylib"]

[dependencies]
optee_utee = "0.4"
consortium-tee = { path = "../../consortium-tee" }
consortium-tee-macros = { path = "../../consortium-tee-macros" }
consortium-cfg-common = { path = "../../consortium-cfg-common" }

# Optional: add only if using TeeParam types
# Example: if using PostcardCodec for serialization
serde = { version = "1.0", features = ["derive"], optional = true }
postcard = { version = "1.0", optional = true }

[build-dependencies]
optee_utee_build = "0.4"

[features]
ca = []  # CA-side code generation (enabled by CA's build)
postcard-codec = ["serde", "dep:postcard"]  # Enable postcard codec support

Build process:

  1. build.rs runs optee_utee_build::build() → generates user_ta_header.rs
  2. Macro generates invoke_command() dispatcher
  3. cdylib compiles to TA binary
  4. CA depends on rlib with features = ["ca"] → generates call_* stubs

ORT

Consortium ORT: ONNX Runtime Integration

The Consortium ORT (ONNX Runtime Wrapper) provides seamless AI model inference across all components (M-core, A-core, TEE) with automatic model bundling, hardware acceleration selection, and type-safe inference.

ORT Architecture

┌─────────────────────────────────────────────────────────────┐
│  Consortium Application                                     │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Inference Request                                   │  │
│  │  let output = model.predict(&input).await?           │  │
│  └────────┬────────────────────────────────────────────┘  │
│           │                                                 │
│  ┌────────▼────────────────────────────────────────────┐  │
│  │  Consortium ORT Layer                               │  │
│  │  ├─ Model loader (ONNX .onnx file)                 │  │
│  │  ├─ Provider selector (CPU, GPU, NPU, etc.)        │  │
│  │  ├─ Session manager (pooling, caching)             │  │
│  │  └─ Type-safe I/O (ndarray, glam, etc.)            │  │
│  └────────┬────────────────────────────────────────────┘  │
│           │                                                 │
│  ┌────────▼────────────────────────────────────────────┐  │
│  │  ONNX Runtime (ort crate)                           │  │
│  │  ├─ Model parsing (protobuf)                        │  │
│  │  ├─ Graph execution (CPU threading)                 │  │
│  │  └─ Memory management (arena allocators)            │  │
│  └────────┬────────────────────────────────────────────┘  │
│           │                                                 │
│  ┌────────▼────────────────────────────────────────────┐  │
│  │  Hardware Providers (Optional)                      │  │
│  │  ├─ NVIDIA CUDA (GPU inference)                     │  │
│  │  ├─ OpenVINO (Intel NPU/Movidius)                   │  │
│  │  ├─ QNN (Qualcomm Hexagon DSP)                      │  │
│  │  └─ CoreML (Apple Neural Engine)                    │  │
│  └─────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Developer Experience: Model-First API

Step 1: Define Inference Schema

#![allow(unused)]
fn main() {
// my-app/src/models.rs
use consortium_ort::Model;
use ndarray::Array2;

// 1. Define input/output types
pub struct ObjectDetectionInput {
    pub image: Array3<f32>,  // [height, width, 3] (RGB)
}

pub struct ObjectDetectionOutput {
    pub boxes: Vec<[f32; 4]>,      // [x1, y1, x2, y2]
    pub confidences: Vec<f32>,     // [0.0, 1.0]
    pub class_ids: Vec<u32>,       // class index
}

// 2. Define model type
pub struct ObjectDetectionModel {
    session: ort::Session,
}

impl Model for ObjectDetectionModel {
    type Input = ObjectDetectionInput;
    type Output = ObjectDetectionOutput;

    async fn load(path: &str) -> Result<Self> {
        let session = ort::Session::builder()
            .with_model_from_file(path)?
            .build()?;
        Ok(Self { session })
    }

    async fn predict(&self, input: &Self::Input) -> Result<Self::Output> {
        // 1. Prepare input tensor
        let input_tensor = input.image.view();

        // 2. Run inference
        let outputs = self.session.run(
            ort::inputs![input_tensor.into()]?
        )?;

        // 3. Parse output tensors
        let boxes_raw = outputs[0].try_extract_tensor::<f32>()?;
        let confs_raw = outputs[1].try_extract_tensor::<f32>()?;
        let classes_raw = outputs[2].try_extract_tensor::<i32>()?;

        // 4. Convert to user types
        let boxes = boxes_raw
            .iter()
            .chunks(4)
            .into_iter()
            .map(|mut chunk| [
                chunk.next().unwrap().clone(),
                chunk.next().unwrap().clone(),
                chunk.next().unwrap().clone(),
                chunk.next().unwrap().clone(),
            ])
            .collect();

        let confidences = confs_raw.to_vec();
        let class_ids = classes_raw
            .iter()
            .map(|&id| id as u32)
            .collect();

        Ok(ObjectDetectionOutput {
            boxes,
            confidences,
            class_ids,
        })
    }
}
}

Step 2: Load & Predict with Full Workflow

#![allow(unused)]
fn main() {
// my-app/src/models/detection.rs
use ndarray::Array3;
use std::sync::Arc;
use std::path::Path;

/// Load and preprocess image
pub fn load_and_preprocess(path: &Path) -> Result<ObjectDetectionInput> {
    use image::io::Reader as ImageReader;

    // 1. Load image
    let img = ImageReader::open(path)?
        .decode()?
        .to_rgb8();

    // 2. Resize to model input size (e.g., 640x640)
    let resized = image::imageops::resize(&img, 640, 640, image::imageops::FilterType::Lanczos3);

    // 3. Convert to ndarray and normalize
    let arr = ndarray::Array3::from_shape_fn((640, 640, 3), |(y, x, c)| {
        resized.get_pixel(x as u32, y as u32)[c] as f32 / 255.0
    });

    Ok(ObjectDetectionInput { image: arr })
}

/// Post-process model outputs
pub fn postprocess(output: &ObjectDetectionOutput, conf_threshold: f32) -> Vec<Detection> {
    let mut detections = Vec::new();

    for (i, conf) in output.confidences.iter().enumerate() {
        if *conf >= conf_threshold {
            detections.push(Detection {
                bbox: output.boxes[i],
                confidence: *conf,
                class_id: output.class_ids[i],
                class_name: get_class_name(output.class_ids[i]),
            });
        }
    }

    // Sort by confidence descending
    detections.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap());

    detections
}

#[derive(Debug, Clone)]
pub struct Detection {
    pub bbox: [f32; 4],
    pub confidence: f32,
    pub class_id: u32,
    pub class_name: String,
}

fn get_class_name(id: u32) -> String {
    match id {
        0 => "person".to_string(),
        1 => "bicycle".to_string(),
        2 => "car".to_string(),
        // ... etc
        _ => format!("class_{}", id),
    }
}
}

Step 3: Complete Application with Model Management

// my-app/src/main.rs
use my_app::models::{
    ObjectDetectionModel,
    ObjectDetectionInput,
    load_and_preprocess,
    postprocess,
};
use consortium_ort::ModelPool;
use std::time::Instant;
use std::path::Path;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .init();

    // 1. Load model from bundled assets
    tracing::info!("Loading YOLO model...");
    let model = ObjectDetectionModel::load(
        "/usr/share/consortium/models/yolov8n.onnx"
    ).await?;

    // 2. Create inference session pool (one per CPU core)
    let pool_size = num_cpus::get();
    tracing::info!("Creating session pool (size: {})", pool_size);
    let pool = ModelPool::new(model, pool_size)?;

    // 3. Batch inference on multiple images
    let image_paths = vec![
        "test1.jpg",
        "test2.jpg",
        "test3.jpg",
    ];

    tracing::info!("Processing {} images", image_paths.len());
    let batch_start = Instant::now();

    let mut tasks = Vec::new();
    for path in &image_paths {
        let pool_clone = pool.clone();
        let path = path.to_string();

        tasks.push(tokio::spawn(async move {
            process_image(&pool_clone, &path).await
        }));
    }

    // Await all tasks
    let results = futures::future::join_all(tasks).await;

    // 4. Aggregate results
    for (path, result) in image_paths.iter().zip(results.iter()) {
        match result {
            Ok(Ok(detections)) => {
                tracing::info!("✓ {}: {} objects detected", path, detections.len());
                for det in detections.iter().take(3) {
                    tracing::info!(
                        "  - {} ({:.2}): {:?}",
                        det.class_name, det.confidence, det.bbox
                    );
                }
            }
            Ok(Err(e)) => {
                tracing::error!("✗ {}: {}", path, e);
            }
            Err(e) => {
                tracing::error!("✗ {}: Task error: {}", path, e);
            }
        }
    }

    let elapsed = batch_start.elapsed();
    tracing::info!("Batch processing completed in {:.2}s", elapsed.as_secs_f32());

    // 5. Real-time stream example (from video or camera)
    process_video_stream(&pool).await?;

    Ok(())
}

/// Process a single image
async fn process_image(
    pool: &ModelPool<ObjectDetectionModel>,
    image_path: &str,
) -> Result<Vec<Detection>> {
    let start = Instant::now();

    // 1. Load and preprocess
    let input = load_and_preprocess(Path::new(image_path))?;

    // 2. Run inference
    let output = pool.predict(&input).await?;

    // 3. Post-process with confidence threshold
    let detections = postprocess(&output, 0.5);

    let elapsed = start.elapsed();
    tracing::debug!("{}: inference took {:.2}ms", image_path, elapsed.as_secs_f32() * 1000.0);

    Ok(detections)
}

/// Process video stream (e.g., from USB camera or RTSP stream)
async fn process_video_stream(
    pool: &ModelPool<ObjectDetectionModel>,
) -> Result<()> {
    use opencv::videoio::VideoCapture;
    use opencv::core::Mat;

    tracing::info!("Starting video stream processing...");

    let mut cap = VideoCapture::new_default(0)?; // Camera index 0
    let mut frame = Mat::default();

    let mut frame_count = 0;
    let stream_start = Instant::now();

    loop {
        if !cap.read(&mut frame)? {
            break;
        }

        frame_count += 1;

        // Skip frames for real-time performance (process every 3rd frame)
        if frame_count % 3 != 0 {
            continue;
        }

        // Convert OpenCV Mat to ndarray
        // (simplified; actual implementation depends on opencv crate version)
        let height = frame.rows() as usize;
        let width = frame.cols() as usize;

        // Prepare input
        let mut input_data = Vec::with_capacity(height * width * 3);
        for pixel in frame.data_mut().iter() {
            input_data.push(*pixel as f32 / 255.0);
        }

        let input = ObjectDetectionInput {
            image: ndarray::Array3::from_shape_vec(
                (height, width, 3),
                input_data,
            )?,
        };

        // Run inference
        match pool.predict(&input).await {
            Ok(output) => {
                let detections = postprocess(&output, 0.5);
                if !detections.is_empty() {
                    tracing::info!("Frame {}: {} objects", frame_count, detections.len());
                }
            }
            Err(e) => {
                tracing::error!("Inference error: {}", e);
            }
        }

        // Print stats every 100 frames
        if frame_count % 100 == 0 {
            let elapsed = stream_start.elapsed().as_secs_f32();
            let fps = frame_count as f32 / elapsed;
            tracing::info!("Processing: {:.1} FPS", fps);
        }
    }

    let total_elapsed = stream_start.elapsed();
    tracing::info!(
        "Video stream complete: {} frames in {:.2}s ({:.1} avg FPS)",
        frame_count,
        total_elapsed.as_secs_f32(),
        frame_count as f32 / total_elapsed.as_secs_f32()
    );

    Ok(())
}

Step 4: Model Performance Monitoring

#![allow(unused)]
fn main() {
// my-app/src/models/monitoring.rs
use std::time::Instant;
use std::sync::{Arc, Mutex};

pub struct InferenceMetrics {
    pub total_inferences: u64,
    pub total_latency_ms: f64,
    pub min_latency_ms: f64,
    pub max_latency_ms: f64,
    pub errors: u64,
}

impl InferenceMetrics {
    pub fn new() -> Arc<Mutex<Self>> {
        Arc::new(Mutex::new(Self {
            total_inferences: 0,
            total_latency_ms: 0.0,
            min_latency_ms: f64::INFINITY,
            max_latency_ms: 0.0,
            errors: 0,
        }))
    }

    pub fn record(&mut self, latency_ms: f64) {
        self.total_inferences += 1;
        self.total_latency_ms += latency_ms;
        self.min_latency_ms = self.min_latency_ms.min(latency_ms);
        self.max_latency_ms = self.max_latency_ms.max(latency_ms);
    }

    pub fn record_error(&mut self) {
        self.errors += 1;
    }

    pub fn average_latency_ms(&self) -> f64 {
        if self.total_inferences == 0 {
            0.0
        } else {
            self.total_latency_ms / self.total_inferences as f64
        }
    }

    pub fn print_summary(&self) {
        tracing::info!("=== Inference Metrics ===");
        tracing::info!("Total inferences: {}", self.total_inferences);
        tracing::info!("Errors: {}", self.errors);
        tracing::info!("Average latency: {:.2}ms", self.average_latency_ms());
        tracing::info!("Min latency: {:.2}ms", self.min_latency_ms);
        tracing::info!("Max latency: {:.2}ms", self.max_latency_ms);
        if self.total_inferences > 0 {
            let throughput = 1000.0 / self.average_latency_ms();
            tracing::info!("Throughput: {:.1} inferences/sec", throughput);
        }
    }
}

// Usage in inference loop
pub async fn monitored_inference(
    pool: &ModelPool<ObjectDetectionModel>,
    input: &ObjectDetectionInput,
    metrics: Arc<Mutex<InferenceMetrics>>,
) -> Result<ObjectDetectionOutput> {
    let start = Instant::now();

    match pool.predict(input).await {
        Ok(output) => {
            let elapsed = start.elapsed().as_secs_f64() * 1000.0;
            metrics.lock().unwrap().record(elapsed);
            Ok(output)
        }
        Err(e) => {
            metrics.lock().unwrap().record_error();
            Err(e)
        }
    }
}
}

ORT Model Quantization & Bundling

The build system provides a complete pipeline for model optimization and bundling:

Step 1: Define Models in Cargo.toml

# my-app/Cargo.toml
[package]
name = "my-app"

# Models to include
[package.metadata.consortium-models]
yolov8n = { path = "models/yolov8n.onnx", quantize = "int8", providers = ["cpu"] }
resnet50 = { path = "models/resnet50.onnx", quantize = "fp16", providers = ["cpu", "gpu"] }
pose = { path = "models/pose.onnx", quantize = false, providers = ["cpu"] }

[build-dependencies]
consortium-builder = { path = "../../consortium-builder" }

Step 2: Build Script with Quantization

// build.rs
use consortium_builder::{ModelOptimizer, ModelBundle};

fn main() {
    let models = vec![
        ModelConfig {
            name: "yolov8n",
            path: "models/yolov8n.onnx",
            quantization: Quantization::Int8,
            hardware_providers: vec!["cpu"],
            calibration_dataset: Some("data/calibration_images/"),
        },
        ModelConfig {
            name: "resnet50",
            path: "models/resnet50.onnx",
            quantization: Quantization::FP16,
            hardware_providers: vec!["cpu", "gpu"],
            calibration_dataset: None,  // FP16 doesn't need calibration
        },
        ModelConfig {
            name: "pose",
            path: "models/pose.onnx",
            quantization: Quantization::None,
            hardware_providers: vec!["cpu"],
            calibration_dataset: None,
        },
    ];

    for model in models {
        println!("Processing model: {}", model.name);

        // 1. Load original ONNX
        let mut onnx_model = ort::Model::load(&model.path)?;

        // 2. Quantize (if specified)
        let quantized_model = match model.quantization {
            Quantization::Int8 => {
                println!("  → Quantizing to INT8...");
                let calibrator = ModelOptimizer::create_calibrator(
                    &onnx_model,
                    model.calibration_dataset.unwrap(),
                )?;
                ort::quantization::quantize_dynamic(
                    &onnx_model,
                    Some(calibrator),
                    ort::quantization::QuantType::INT8,
                )?
            }
            Quantization::FP16 => {
                println!("  → Converting to FP16...");
                ort::quantization::convert_float_to_float16(&onnx_model)?
            }
            Quantization::None => onnx_model,
        };

        // 3. Optimize graph (operator fusion, constant folding, etc.)
        println!("  → Optimizing graph...");
        let optimized_model = ModelOptimizer::optimize(
            &quantized_model,
            &model.hardware_providers,
        )?;

        // 4. Serialize to binary format with metadata
        let out_dir = env::var("OUT_DIR").unwrap();
        let model_path = format!("{}/models/{}.ort", out_dir, model.name);

        let bundle = ModelBundle {
            onnx_bytes: optimized_model.to_bytes()?,
            input_shapes: optimized_model.input_shapes().clone(),
            input_types: optimized_model.input_types().clone(),
            output_shapes: optimized_model.output_shapes().clone(),
            output_types: optimized_model.output_types().clone(),
            quantization: model.quantization,
            hardware_providers: model.hardware_providers,
            size_original: std::fs::metadata(&model.path)?.len(),
            size_quantized: optimized_model.to_bytes()?.len(),
        };

        bundle.write_to_file(&model_path)?;

        println!(
            "  ✓ {} → {} ({:.1}% size reduction)",
            model.name,
            model_path,
            (1.0 - (bundle.size_quantized as f64 / bundle.size_original as f64)) * 100.0
        );
    }
}

Step 3: Load Models with Metadata

// my-app/src/models.rs
use consortium_ort::{ModelBundle, Model};

#[tokio::main]
async fn main() -> Result<()> {
    // Models are pre-compiled and bundled at build time
    // Read from embedded binary or file system
    let yolo_bundle = ModelBundle::load_embedded("yolov8n")?;
    let resnet_bundle = ModelBundle::load_embedded("resnet50")?;

    println!("YOLOv8n:");
    println!("  Quantization: {:?}", yolo_bundle.quantization);
    println!("  Original size: {} MB", yolo_bundle.size_original / 1_000_000);
    println!("  Quantized size: {} MB", yolo_bundle.size_quantized / 1_000_000);
    println!("  Compression: {:.1}%",
        (1.0 - yolo_bundle.size_quantized as f64 / yolo_bundle.size_original as f64) * 100.0);

    // Create session with auto-selected hardware provider
    let session = yolo_bundle.create_session()?;

    Ok(())
}

Quantization Impact

ModelOriginalINT8FP16Speed (INT8)Accuracy Loss
YOLOv8n (24 MB)24 MB6 MB12 MB~1.5x faster< 0.5% mAP
ResNet50 (98 MB)98 MB25 MB49 MB~2x faster< 1% top-1 acc
MobileNetV2 (14 MB)14 MB3.5 MB7 MB~1.2x faster< 0.3% top-1 acc

Process:

  1. Original model → load via ONNX Runtime
  2. Calibration → if INT8, run representative dataset to collect activation statistics
  3. Quantization → convert weights/activations to lower precision
  4. Optimization → fuse operations, fold constants, select best kernels
  5. Serialization → write as .ort file with metadata
  6. Bundling → embed in binary or package separately

ORT Performance & Hardware Acceleration

ProviderHardwareLatencyThroughput
CPU (ONNX Runtime)ARM Cortex-A~100–500 ms (YOLOv8)2–10 inferences/sec
GPU (CUDA)NVIDIA~10–50 ms20–100 inferences/sec
NPU (OpenVINO)Intel Movidius / VPU~20–100 ms10–50 inferences/sec
DSP (QNN)Qualcomm Hexagon~50–200 ms5–20 inferences/sec

Selection logic: Auto-detect available hardware; fall back to CPU if necessary.

ORT Features

  • Automatic quantization: Int8, FP16 support with accuracy preservation
  • Model optimization: Operator fusion, constant folding, pruning
  • Batching: Automatic batching for throughput optimization
  • Caching: Session / intermediate result caching
  • Monitoring: Inference time, memory usage, cache hit rates

HMI

Consortium HMI: Human-Machine Interface

The Consortium HMI provides a unified framework for building responsive, real-time UIs on the A-core Linux side, with automatic backend integration and multi-protocol support (web, embedded GUI, mobile).

HMI Architecture

┌─────────────────────────────────────────────────────────────┐
│  Consortium Application (A-Core Linux)                      │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Tokio Runtime                                        │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │  HMI Event Loop                                 │  │  │
│  │  │  ws_listen → recv_events → update_state         │  │  │
│  │  │              ↓                                   │  │  │
│  │  │  Render Template (Tera/Askama)                  │  │  │
│  │  │              ↓                                   │  │  │
│  │  │  Broadcast to WebSocket Clients                 │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │  State Management                               │  │  │
│  │  │  • System status (temps, voltages, frequencies) │  │  │
│  │  │  • Device state (on/off, mode, settings)        │  │  │
│  │  │  • User preferences (theme, language, etc.)     │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └────────┬────────────────────────────────────────────────┘  │
│           │                                                    │
│  ┌────────┴────────────────────────────────────────────────┐  │
│  │  IPC / TEE / ORT Integration                           │  │
│  │  • Fetch sensor data via IPC (M-core)                  │  │
│  │  • Get auth status via TEE (A-core)                    │  │
│  │  • Run inference via ORT (AI model)                    │  │
│  └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
        │              │              │
        ▼              ▼              ▼
    ┌────────┐   ┌─────────┐   ┌───────────┐
    │ WebGTK │   │ Browser │   │ Mobile    │
    │ (local)│   │ (remote)│   │ (Tauri)   │
    └────────┘   └─────────┘   └───────────┘

Developer Experience: Declarative UI + Reactive State

Step 1: Define State & Events

State in the HMI is the single source of truth. All UI updates derive from state changes.

#![allow(unused)]
fn main() {
// my-app/src/hmi/state.rs
use serde::{Deserialize, Serialize};
use consortium_hmi::State;

/// Application state (serializable for persistence/sync)
#[derive(State, Clone, Debug, Serialize, Deserialize)]
pub struct AppState {
    // Sensor data (from M-core via IPC)
    pub temperature: f32,
    pub humidity: f32,
    pub pressure_hpa: f32,

    // Device control
    pub device_mode: DeviceMode,
    pub target_temperature: f32,
    pub heating_enabled: bool,

    // User session
    pub is_authenticated: bool,
    pub username: String,
    pub user_role: UserRole,

    // UI state
    pub selected_tab: TabId,
    pub show_advanced_settings: bool,
    pub notification_queue: Vec<Notification>,

    // Metrics
    pub uptime_seconds: u64,
    pub last_update_ms: u64,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum DeviceMode {
    Idle,
    Heating { target: f32 },
    Cooling { target: f32 },
    Auto,
    Error { reason: String },
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum UserRole {
    Guest,
    User,
    Administrator,
}

#[derive(Clone, Debug, PartialEq)]
pub enum TabId {
    Overview,
    Control,
    Settings,
    Analytics,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Notification {
    pub level: NotificationLevel,
    pub message: String,
    pub timestamp_ms: u64,
    pub dismissible: bool,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum NotificationLevel {
    Info,
    Warning,
    Error,
}

impl Default for AppState {
    fn default() -> Self {
        Self {
            temperature: 20.0,
            humidity: 50.0,
            pressure_hpa: 1013.25,
            device_mode: DeviceMode::Idle,
            target_temperature: 22.0,
            heating_enabled: false,
            is_authenticated: false,
            username: String::new(),
            user_role: UserRole::Guest,
            selected_tab: TabId::Overview,
            show_advanced_settings: false,
            notification_queue: Vec::new(),
            uptime_seconds: 0,
            last_update_ms: 0,
        }
    }
}
}

Step 2: Define Events (User Interactions)

Events are actions triggered by user interaction or external systems. They mutate the state.

#![allow(unused)]
fn main() {
// my-app/src/hmi/events.rs
use consortium_hmi::Event;

/// All possible user interactions and external triggers
#[derive(Event, Clone, Debug)]
pub enum AppEvent {
    // Sensor data updates (from M-core)
    SensorData {
        temperature: f32,
        humidity: f32,
        pressure_hpa: f32,
    },

    // User interactions
    SetMode(DeviceMode),
    SetTargetTemperature(f32),
    ToggleHeating,
    SelectTab(TabId),
    ShowAdvancedSettings(bool),

    // Authentication
    Login { username: String, password: String },
    Logout,

    // System events
    SystemHealthCheck,
    ClearNotification(usize),
    AddNotification(Notification),

    // Periodic updates
    Tick { elapsed_ms: u64 },
}

impl AppEvent {
    pub fn describe(&self) -> String {
        match self {
            Self::SensorData { temperature, .. } => {
                format!("Sensor update: temp={:.1}°C", temperature)
            }
            Self::SetMode(mode) => format!("Device mode: {:?}", mode),
            Self::Login { username, .. } => format!("Login: {}", username),
            _ => format!("{:?}", self),
        }
    }
}
}

Step 3: Define HMI Handler with Full State Management

#![allow(unused)]
fn main() {
// my-app/src/hmi/handler.rs
use consortium_hmi::hmi;
use crate::hmi::{AppState, AppEvent, Notification, NotificationLevel};

#[hmi]
pub struct AppHmi {
    template_cache: std::sync::Arc<tokio::sync::Mutex<TemplateCache>>,
}

impl AppHmi {
    pub fn new() -> Self {
        Self {
            template_cache: std::sync::Arc::new(tokio::sync::Mutex::new(TemplateCache::new())),
        }
    }

    /// Render current state to HTML (< 50ms typical)
    pub async fn render(&self, state: &AppState) -> Result<String> {
        use tera::{Tera, Context};

        let mut tera = Tera::new("templates/**/*.html")?;
        let mut context = Context::new();
        context.insert("state", &state);

        let template_name = match state.selected_tab {
            TabId::Overview => "overview.html",
            TabId::Control => "control.html",
            TabId::Settings => "settings.html",
            TabId::Analytics => "analytics.html",
        };

        tera.render(template_name, &context)
            .map_err(|e| format!("Render error: {}", e).into())
    }

    /// Process event and update state atomically
    pub async fn on_event(
        &self,
        event: AppEvent,
        state: &mut AppState,
        ipc: &mut IpcChannels,
        tee: &TeeSession,
        ort: &ModelPool,
    ) -> Result<bool> {
        let old_state = state.clone();

        match event {
            // Sensor data with optional ML
            AppEvent::SensorData { temperature, humidity, pressure_hpa } => {
                state.temperature = temperature;
                state.humidity = humidity;
                state.pressure_hpa = pressure_hpa;

                if state.is_authenticated && temperature > 30.0 {
                    let prediction = ort.predict(&[temperature, humidity, pressure_hpa]).await?;
                    if prediction.anomaly_score > 0.8 {
                        state.notification_queue.push(Notification {
                            level: NotificationLevel::Warning,
                            message: "Temperature anomaly detected".to_string(),
                            timestamp_ms: state.last_update_ms,
                            dismissible: true,
                        });
                    }
                }
            }

            // Device control with authentication check
            AppEvent::SetMode(mode) => {
                if !state.is_authenticated && !matches!(mode, DeviceMode::Idle) {
                    state.notification_queue.push(Notification {
                        level: NotificationLevel::Error,
                        message: "Authentication required".to_string(),
                        timestamp_ms: state.last_update_ms,
                        dismissible: true,
                    });
                    return Ok(false);
                }

                state.device_mode = mode.clone();
                let cmd = match mode {
                    DeviceMode::Heating { target } => {
                        Command::SetHeating { enabled: true, target_temp: target }
                    }
                    DeviceMode::Cooling { target } => {
                        Command::SetCooling { enabled: true, target_temp: target }
                    }
                    DeviceMode::Idle => Command::SetIdle,
                    _ => return Ok(false),
                };

                ipc.cmd_channel.send(&cmd).await?;
            }

            // Temperature validation
            AppEvent::SetTargetTemperature(target) => {
                if target < 15.0 || target > 35.0 {
                    state.notification_queue.push(Notification {
                        level: NotificationLevel::Error,
                        message: "Temperature must be 15–35°C".to_string(),
                        timestamp_ms: state.last_update_ms,
                        dismissible: true,
                    });
                    return Ok(false);
                }
                state.target_temperature = target;
                ipc.cmd_channel.send(&Command::SetTargetTemp(target)).await?;
            }

            // TEE-based authentication
            AppEvent::Login { username, password } => {
                match tee.call_authenticate(&username, password.as_bytes()).await {
                    Ok(auth_result) if auth_result.valid => {
                        state.is_authenticated = true;
                        state.username = username.clone();
                        state.user_role = auth_result.role;

                        state.notification_queue.push(Notification {
                            level: NotificationLevel::Info,
                            message: format!("Welcome, {}!", username),
                            timestamp_ms: state.last_update_ms,
                            dismissible: false,
                        });

                        if !matches!(state.user_role, UserRole::Administrator) {
                            state.show_advanced_settings = false;
                        }
                    }
                    _ => {
                        state.notification_queue.push(Notification {
                            level: NotificationLevel::Error,
                            message: "Invalid credentials".to_string(),
                            timestamp_ms: state.last_update_ms,
                            dismissible: true,
                        });
                    }
                }
            }

            AppEvent::SelectTab(tab) => {
                state.selected_tab = tab;
            }

            AppEvent::Tick { elapsed_ms } => {
                state.uptime_seconds += 1;
                state.last_update_ms = elapsed_ms;
                state.notification_queue.retain(|n| {
                    elapsed_ms - n.timestamp_ms < 10_000 || !n.dismissible
                });
            }

            _ => {}
        }

        Ok(old_state != *state)
    }
}
}

Step 4: Complete Integration in Main App

// my-app/src/main.rs
use my_app::hmi::{AppHmi, AppState, AppEvent};
use tokio::sync::broadcast;

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();

    // 1. Setup runtime
    let remoteproc = RemoteProc::new(0);
    remoteproc.set_firmware("firmware.elf")?;
    remoteproc.start()?;

    let uio = UioDevice::open(0).await?;
    let tee = TeeSession::open().await?;

    // 2. Initialize state
    let mut state = AppState::default();
    let hmi = AppHmi::new();
    let (state_tx, _) = broadcast::channel(100);
    let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(1000);

    // 3. Task A: Listen for sensor updates from M-core
    let event_tx_clone = event_tx.clone();
    tokio::spawn(async move {
        let mut sensor_ch = uio.channel(1).await.unwrap();
        loop {
            match sensor_ch.recv::<SensorReading>().await {
                Ok(reading) => {
                    let event = AppEvent::SensorData {
                        temperature: reading.temperature,
                        humidity: reading.humidity,
                        pressure_hpa: reading.pressure,
                    };
                    let _ = event_tx_clone.send(event).await;
                }
                Err(e) => {
                    tracing::error!("Sensor error: {}", e);
                    tokio::time::sleep(Duration::from_secs(1)).await;
                }
            }
        }
    });

    // 4. Task B: Periodic tick
    let event_tx_clone = event_tx.clone();
    tokio::spawn(async move {
        let start = std::time::Instant::now();
        loop {
            tokio::time::sleep(Duration::from_secs(1)).await;
            let elapsed_ms = start.elapsed().as_millis() as u64;
            let _ = event_tx_clone.send(AppEvent::Tick { elapsed_ms }).await;
        }
    });

    // 5. Task C: WebSocket server
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    let state_tx_clone = state_tx.clone();
    let event_tx_clone = event_tx.clone();

    tokio::spawn(async move {
        loop {
            if let Ok((stream, peer_addr)) = listener.accept().await {
                tracing::info!("Client: {}", peer_addr);
                let state_tx = state_tx_clone.clone();
                let event_tx = event_tx_clone.clone();

                tokio::spawn(async move {
                    let _ = handle_websocket_client(stream, peer_addr, state_tx, event_tx).await;
                });
            }
        }
    });

    // 6. Main event loop
    while let Some(event) = event_rx.recv().await {
        tracing::debug!("Event: {}", event.describe());

        match hmi.on_event(event, &mut state, &mut IpcChannels {…}, &tee, &ort_pool).await {
            Ok(state_changed) if state_changed => {
                match hmi.render(&state).await {
                    Ok(html) => { let _ = state_tx.send(html); }
                    Err(e) => tracing::error!("Render: {}", e),
                }
            }
            Err(e) => {
                tracing::error!("Error: {}", e);
                state.notification_queue.push(Notification {
                    level: NotificationLevel::Error,
                    message: format!("Error: {}", e),
                    timestamp_ms: 0,
                    dismissible: true,
                });
            }
            _ => {}
        }
    }

    remoteproc.stop()?;
    Ok(())
}

async fn handle_websocket_client(
    stream: tokio::net::TcpStream,
    peer_addr: std::net::SocketAddr,
    state_tx: broadcast::Sender<String>,
    event_tx: tokio::sync::mpsc::Sender<AppEvent>,
) -> Result<()> {
    let ws = tokio_tungstenite::accept_async(stream).await?;
    let (mut ws_tx, mut ws_rx) = ws.split();
    let mut state_rx = state_tx.subscribe();

    loop {
        select! {
            msg = state_rx.recv() => {
                match msg {
                    Ok(html) => ws_tx.send(Message::Text(html)).await?,
                    Err(broadcast::error::RecvError::Lagged(_)) => {},
                    Err(e) => break,
                }
            }

            msg = ws_rx.next() => {
                match msg {
                    Some(Ok(Message::Text(json))) => {
                        if let Ok(event) = serde_json::from_str::<AppEvent>(&json) {
                            let _ = event_tx.send(event).await;
                        }
                    }
                    Some(Ok(Message::Close(_))) => break,
                    Some(Err(_)) => break,
                    None => break,
                }
            }
        }
    }

    tracing::info!("Client closed: {}", peer_addr);
    Ok(())
}

Step 5: Full HTML Template with Styling & Interactivity

<!-- templates/overview.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Consortium Control</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        min-height: 100vh;
        padding: 20px;
      }
      .container {
        max-width: 1200px;
        margin: 0 auto;
      }
      .header {
        color: white;
        margin-bottom: 30px;
      }
      .header h1 {
        font-size: 2.5em;
        margin-bottom: 10px;
      }

      .grid {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
        gap: 20px;
      }
      .card {
        background: white;
        border-radius: 12px;
        padding: 24px;
        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
        transition: transform 0.2s;
      }
      .card:hover {
        transform: translateY(-5px);
      }

      .sensor-grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 15px;
      }
      .sensor {
        text-align: center;
        padding: 15px;
        background: #f8f9fa;
        border-radius: 8px;
      }
      .sensor-value {
        font-size: 2.5em;
        font-weight: bold;
        color: #667eea;
      }

      button {
        padding: 12px 24px;
        border: none;
        border-radius: 8px;
        font-weight: 600;
        cursor: pointer;
        background: #667eea;
        color: white;
        transition: all 0.2s;
      }
      button:hover {
        background: #5568d3;
      }

      .notification {
        position: fixed;
        top: 20px;
        right: 20px;
        padding: 15px;
        background: white;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      }
    </style>
  </head>
  <body>
    <div class="container">
      <div class="header">
        <h1>🏠 Environmental Control</h1>
      </div>

      <div class="grid">
        <div class="card">
          <h2>📊 Sensors</h2>
          <div class="sensor-grid">
            <div class="sensor">
              <div>Temperature</div>
              <div class="sensor-value">{{ state.temperature | round(precision=1) }}°C</div>
            </div>
            <div class="sensor">
              <div>Humidity</div>
              <div class="sensor-value">{{ state.humidity | round }}%</div>
            </div>
          </div>
        </div>

        <div class="card">
          <h2>🎛️ Device</h2>
          <p>
            Mode:
            <strong
              >{% match state.device_mode %} {% when DeviceMode::Idle %}Idle {% when
              DeviceMode::Heating {target} %}Heating to {{ target }}°C {% endmatch %}</strong
            >
          </p>
          <button onclick="send('SetMode', 'Heating')">Heat</button>
          <button onclick="send('SetMode', 'Idle')">Off</button>
        </div>
      </div>
    </div>

    <script>
      const ws = new WebSocket('ws://' + location.host + '/ws')
      ws.onmessage = (e) => (document.documentElement.innerHTML = e.data)

      function send(action, value) {
        const event = {}
        event[action] = value
        ws.send(JSON.stringify(event))
      }
    </script>
  </body>
</html>

HMI Key Insights

  1. State is immutable from render perspective: Templates are pure functions of state
  2. Event-driven: All mutations go through the event processor
  3. Broadcast pattern: State changes sent to all connected clients instantly
  4. Error handling: User-friendly notifications for all error conditions
  5. Integration: Seamlessly connects IPC + TEE + ORT

HMI Features

FeatureImplementation
Template EngineTera/Askama for HTML generation
State ManagementTokio channels + broadcast
WebSocket Transporttokio-tungstenite for real-time updates
Multi-ProtocolHTTP, WebSocket, optional Tauri for desktop
Responsive DesignCSS Grid/Flexbox, mobile-first
AccessibilityWCAG 2.1 AA semantic HTML
CustomizationCSS variables, dark mode, internationalization

HMI Performance

  • State update latency: < 100 ms (WebSocket broadcast)
  • WebSocket message size: ~1–10 KB per update
  • Concurrent clients: 100+ (typical)
  • Memory overhead: ~1 MB per connected client

FFI

Consortium FFI: Foreign Function Interface

The Consortium FFI provides a unified interface for calling functions in Python, C, C++, and other languages from Rust code, with automatic serialization, process pooling, and type safety.

FFI Architecture

┌─────────────────────────────────────────────────────────────┐
│  Consortium Application (Rust)                              │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Rust Code                                            │  │
│  │  let result = python_fn("process", input).await?      │  │
│  │  let cv_result = cpp_fn("detect_edges", image).await? │  │
│  └────────┬────────────────────────────────────────────┘  │
│           │                                                 │
│  ┌────────▼────────────────────────────────────────────┐  │
│  │  Consortium FFI Layer                               │  │
│  │  ├─ Function registry (Python/C/C++)                │  │
│  │  ├─ RPC marshaler (pickle, protobuf, JSON)          │  │
│  │  ├─ Process pool (worker management)                │  │
│  │  └─ Error translation                               │  │
│  └────────┬────────────────────────────────────────────┘  │
│           │                                                 │
│  ┌────────▼────────────────────────────────────────────┐  │
│  │  IPC Transport (stdio, socket, shared memory)        │  │
│  └────────┬────────────────────────────────────────────┘  │
│           │                                                 │
│  ┌────────▼────────────────────────────────────────────┐  │
│  │  Worker Processes                                   │  │
│  │  ├─ Python subprocess (with OpenCV, TensorFlow)     │  │
│  │  ├─ C subprocess (with libfoo)                       │  │
│  │  └─ C++ subprocess (with custom algorithms)         │  │
│  └─────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Developer Experience: Function Proxies

Step 1: Define Worker Services (Python Side)

Worker processes expose functions via JSON-RPC or binary protocols. They run independently and communicate via sockets/stdio.

# ffi_workers/python_cv/main.py
"""
Computer Vision worker: provides OpenCV-based functions.
Each function runs in response to RPC calls from Rust.
"""

import cv2
import numpy as np
import json
import sys
from typing import Dict, List, Any
from dataclasses import dataclass, asdict


# Utilities
@dataclass
class EdgeDetectionResult:
    edges: bytes  # encoded as base64 in JSON
    width: int
    height: int


@dataclass
class PoseKeypoint:
    x: float
    y: float
    confidence: float


@dataclass
class PoseEstimateResult:
    keypoints: List[PoseKeypoint]
    average_confidence: float


# Worker functions
def detect_edges(
    image_bytes: bytes, threshold1: int = 100, threshold2: int = 200
) -> Dict:
    """
    Detect edges in image using Canny algorithm.

    Args:
        image_bytes: JPEG-encoded image as bytes
        threshold1: Lower Canny threshold
        threshold2: Upper Canny threshold

    Returns:
        Dict with edges (base64-encoded PNG), width, height
    """
    try:
        # 1. Decode image
        nparr = np.frombuffer(image_bytes, np.uint8)
        img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
        if img is None:
            raise ValueError("Failed to decode image")

        # 2. Convert to grayscale and detect edges
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        edges = cv2.Canny(gray, threshold1, threshold2)

        # 3. Encode result
        success, buffer = cv2.imencode(".png", edges)
        if not success:
            raise ValueError("Failed to encode result")

        # 4. Return as base64 for JSON serialization
        import base64

        edges_b64 = base64.b64encode(buffer).decode("utf-8")

        return {
            "status": "ok",
            "result": {
                "edges": edges_b64,
                "width": int(edges.shape[1]),
                "height": int(edges.shape[0]),
            },
        }
    except Exception as e:
        return {"status": "error", "error": str(e)}


def estimate_pose(image_bytes: bytes, model_path: str) -> Dict:
    """
    Estimate human pose using MediaPipe.

    Args:
        image_bytes: JPEG-encoded image
        model_path: Path to pose estimation model

    Returns:
        Dict with keypoints and confidence
    """
    try:
        import mediapipe as mp

        # 1. Decode image
        nparr = np.frombuffer(image_bytes, np.uint8)
        img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
        img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

        # 2. Run pose detection
        mp_pose = mp.solutions.pose
        with mp_pose.Pose() as pose:
            results = pose.process(img_rgb)

        if not results.pose_landmarks:
            return {
                "status": "ok",
                "result": {"keypoints": [], "average_confidence": 0.0},
            }

        # 3. Extract keypoints
        keypoints = []
        confidences = []
        for landmark in results.pose_landmarks.landmark:
            keypoints.append(
                {
                    "x": float(landmark.x),
                    "y": float(landmark.y),
                    "confidence": float(landmark.visibility),
                }
            )
            confidences.append(float(landmark.visibility))

        avg_conf = np.mean(confidences) if confidences else 0.0

        return {
            "status": "ok",
            "result": {
                "keypoints": keypoints,
                "average_confidence": float(avg_conf),
            },
        }
    except Exception as e:
        return {"status": "error", "error": str(e)}


# Worker server loop
if __name__ == "__main__":
    import json

    # Register available functions
    FUNCTIONS = {
        "detect_edges": detect_edges,
        "estimate_pose": estimate_pose,
    }

    while True:
        try:
            # Read JSON-RPC request from stdin
            line = input()
            request = json.loads(line)

            method = request.get("method")
            params = request.get("params", {})
            request_id = request.get("id")

            if method not in FUNCTIONS:
                response = {"id": request_id, "error": f"Unknown method: {method}"}
            else:
                # Call function
                result = FUNCTIONS[method](**params)
                response = {"id": request_id, "result": result}

            # Send response
            print(json.dumps(response))
            sys.stdout.flush()

        except EOFError:
            break
        except Exception as e:
            sys.stderr.write(f"Worker error: {e}\n")
            sys.stderr.flush()

Step 2: Define Rust Interface & RPC Proxies

#![allow(unused)]
fn main() {
// my-app/src/ffi/mod.rs
use serde::{Deserialize, Serialize};
use consortium_ffi::{FfiPool, RemoteCallError};

// Define return types that match Python functions
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EdgeDetectionResult {
    pub edges: Vec<u8>,  // decoded from base64
    pub width: u32,
    pub height: u32,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PoseKeypoint {
    pub x: f32,
    pub y: f32,
    pub confidence: f32,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PoseEstimateResult {
    pub keypoints: Vec<PoseKeypoint>,
    pub average_confidence: f32,
}

// FFI request/response wrapper
#[derive(Serialize, Deserialize)]
struct WorkerResponse<T> {
    status: String,
    result: Option<T>,
    error: Option<String>,
}

impl<T> WorkerResponse<T> {
    fn into_result(self) -> Result<T, RemoteCallError> {
        match self.status.as_str() {
            "ok" => self.result.ok_or_else(||
                RemoteCallError::InvalidResponse("Missing result".to_string())
            ),
            _ => Err(RemoteCallError::RemoteError(
                self.error.unwrap_or_else(|| "Unknown error".to_string())
            )),
        }
    }
}

/// Python CV worker functions
pub struct PythonCvWorker {
    pool: FfiPool,
}

impl PythonCvWorker {
    pub async fn new(num_workers: usize) -> Result<Self, Box<dyn std::error::Error>> {
        let pool = FfiPool::new()
            .add_python_worker("ffi_workers/python_cv", num_workers)?
            .start()
            .await?;

        Ok(Self { pool })
    }

    /// Detect edges in image
    pub async fn detect_edges(
        &self,
        image_bytes: Vec<u8>,
        threshold1: u32,
        threshold2: u32,
    ) -> Result<EdgeDetectionResult, RemoteCallError> {
        let params = serde_json::json!({
            "image_bytes": image_bytes,
            "threshold1": threshold1,
            "threshold2": threshold2,
        });

        let response: WorkerResponse<EdgeDetectionResult> =
            self.pool.call_python("detect_edges", params).await?;

        response.into_result()
    }

    /// Estimate pose in image
    pub async fn estimate_pose(
        &self,
        image_bytes: Vec<u8>,
        model_path: String,
    ) -> Result<PoseEstimateResult, RemoteCallError> {
        let params = serde_json::json!({
            "image_bytes": image_bytes,
            "model_path": model_path,
        });

        let response: WorkerResponse<PoseEstimateResult> =
            self.pool.call_python("estimate_pose", params).await?;

        response.into_result()
    }
}

// Error types
#[derive(Debug)]
pub enum RemoteCallError {
    Serialization(serde_json::Error),
    Transport(String),
    RemoteError(String),
    InvalidResponse(String),
    Timeout,
}

impl std::fmt::Display for RemoteCallError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Serialization(e) => write!(f, "Serialization: {}", e),
            Self::Transport(e) => write!(f, "Transport: {}", e),
            Self::RemoteError(e) => write!(f, "Remote error: {}", e),
            Self::InvalidResponse(e) => write!(f, "Invalid response: {}", e),
            Self::Timeout => write!(f, "RPC timeout"),
        }
    }
}

impl std::error::Error for RemoteCallError {}
}

Step 3: Comprehensive Application Integration

// my-app/src/main.rs
use my_app::ffi::PythonCvWorker;
use std::time::Instant;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init();

    // 1. Initialize FFI worker pool
    tracing::info!("Starting Python CV worker pool...");
    let cv_workers = PythonCvWorker::new(num_cpus::get()).await?;
    tracing::info!("Worker pool started");

    // 2. Load image
    let image_path = "test_image.jpg";
    let image_bytes = std::fs::read(image_path)?;
    tracing::info!("Loaded image: {} bytes", image_bytes.len());

    // 3. Single call example
    {
        tracing::info!("Calling detect_edges...");
        let start = Instant::now();

        match cv_workers.detect_edges(image_bytes.clone(), 100, 200).await {
            Ok(result) => {
                let elapsed = start.elapsed();
                tracing::info!("✓ Edge detection: {}x{} (took {:?})", result.width, result.height, elapsed);

                // Save edges
                let edges_bytes = base64::decode(&result.edges)?;
                std::fs::write("edges.png", edges_bytes)?;
            }
            Err(e) => {
                tracing::error!("✗ Edge detection failed: {}", e);
            }
        }
    }

    // 4. Concurrent calls example
    {
        tracing::info!("Running concurrent calls...");
        let start = Instant::now();

        let (edges_result, pose_result) = tokio::join!(
            cv_workers.detect_edges(image_bytes.clone(), 100, 200),
            cv_workers.estimate_pose(image_bytes.clone(), "models/pose.pb".to_string()),
        );

        let elapsed = start.elapsed();

        match (edges_result, pose_result) {
            (Ok(edges), Ok(pose)) => {
                tracing::info!("✓ Concurrent calls completed in {:?}", elapsed);
                tracing::info!("  - Edges: {}x{}", edges.width, edges.height);
                tracing::info!("  - Pose: {} keypoints (avg conf: {:.2})", pose.keypoints.len(), pose.average_confidence);
            }
            (e1, e2) => {
                tracing::error!("✗ One or more calls failed: {:?}, {:?}", e1, e2);
            }
        }
    }

    // 5. Batch processing example
    {
        tracing::info!("Batch processing...");
        let image_paths = vec!["img1.jpg", "img2.jpg", "img3.jpg"];

        let mut tasks = Vec::new();
        for path in image_paths {
            let image = std::fs::read(path)?;
            let cv_clone = &cv_workers;

            tasks.push(async move {
                cv_clone.detect_edges(image, 100, 200).await
            });
        }

        let results = futures::future::join_all(tasks).await;
        let successful = results.iter().filter(|r| r.is_ok()).count();
        tracing::info!("Batch: {}/{} images processed", successful, results.len());
    }

    Ok(())
}

Step 4: Error Handling & Retry Logic

#![allow(unused)]
fn main() {
// my-app/src/ffi/retry.rs
use async_trait::async_trait;
use tokio::time::{sleep, Duration};

#[async_trait]
pub trait Retryable<T>: Sized {
    async fn with_retry(self, max_attempts: u32) -> Result<T, Box<dyn std::error::Error>>;
}

pub struct RetryConfig {
    pub max_attempts: u32,
    pub initial_backoff: Duration,
    pub max_backoff: Duration,
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_attempts: 3,
            initial_backoff: Duration::from_millis(100),
            max_backoff: Duration::from_secs(5),
        }
    }
}

impl<T, E: 'static + std::error::Error> Retryable<T> for Result<T, E> {
    async fn with_retry(self, max_attempts: u32) -> Result<T, Box<dyn std::error::Error>> {
        let mut attempt = 0;
        let mut backoff = Duration::from_millis(100);

        loop {
            attempt += 1;

            match self {
                Ok(value) => return Ok(value),
                Err(e) if attempt >= max_attempts => {
                    return Err(Box::new(e) as Box<dyn std::error::Error>);
                }
                Err(e) => {
                    tracing::warn!(
                        "Attempt {} failed: {}. Retrying in {:?}...",
                        attempt, e, backoff
                    );
                    sleep(backoff).await;
                    backoff = (backoff * 2).min(Duration::from_secs(5));
                }
            }
        }
    }
}

// Usage in application
async fn detect_edges_with_retry(
    cv: &PythonCvWorker,
    image: Vec<u8>,
) -> Result<EdgeDetectionResult, Box<dyn std::error::Error>> {
    let mut attempt = 0;
    let max_attempts = 3;

    loop {
        attempt += 1;
        match cv.detect_edges(image.clone(), 100, 200).await {
            Ok(result) => return Ok(result),
            Err(e) if attempt >= max_attempts => {
                return Err(format!("Failed after {} attempts: {}", max_attempts, e).into());
            }
            Err(e) => {
                tracing::warn!("Attempt {}: {}, retrying...", attempt, e);
                sleep(Duration::from_millis(100 * attempt as u64)).await;
            }
        }
    }
}
}

FFI Best Practices

1. Function Design:

  • Keep functions pure (no side effects)
  • Serialize/deserialize at boundaries only
  • Batch operations where possible

2. Performance:

  • Use binary serialization (postcard) for large data
  • Pool workers to amortize process creation cost
  • Cache expensive initialization (models, connections)

3. Error Handling:

  • Implement retry logic for transient failures
  • Log detailed errors for debugging
  • Set timeouts for long-running calls

4. Resource Management:

  • Limit worker pool size to available CPU cores
  • Set memory limits per worker
  • Clean up resources on shutdown

FFI Features

FeatureImplementation
Multi-languagePython, C, C++, via subprocess
Serializationpostcard, JSON, protobuf (pluggable)
Process poolingWork-stealing queue, load balancing
Error handlingAutomatic translation, stack traces
Type safetyCompile-time type checking via macros
Async/awaitFull Tokio integration
Resource managementAutomatic cleanup, memory limits
MonitoringExecution time, memory usage, error rates

FFI Performance

Latency breakdown (Python function call):

ComponentTime
Serialization~0.1–1 ms
IPC (socket)~0.5–2 ms
Python deserialization~0.1–0.5 ms
Function executionvariable
Python serialization~0.1–0.5 ms
IPC response~0.5–2 ms
Rust deserialization~0.1–1 ms
Total overhead~2–7 ms

Optimization strategies:

  • Keep functions long-running (amortize overhead)
  • Batch multiple calls where possible
  • Use binary serialization (postcard) over JSON
  • Pool workers to avoid process spawn overhead

Resource Partition Isolation (RPI) Subsystem

The Resource Partition Isolation (RPI) subsystem is responsible for managing the isolation of resources in the system. It provides a mechanism for isolating resources such as memory, peripherals, and interrupts, and ensures that only authorized entities can access these resources.

In ST and NXP, they have respective names towards this subsystem, but we have decided to use the more generic name “RPI” to avoid confusion and to better reflect the purpose of the subsystem.

  • STM32MP series: RIF (Resource Isolation Framework)
  • i.MX series: RD (Resource Domain)

RPI provides compile-time peripheral ownership control, automatic DTS generation, and PAC customization to enforce strict, cross-core, cross-secure-domain isolation.

Partition ID (PartID)

The Partition ID (PartID) is a unique identifier for each partition in the system. It is used to identify the owner of a resource and to enforce access control policies. The PartID is assigned to each partition at compile time and is used by the RPI subsystem to determine which resources a partition can access.

  • STM32MP series: CID (Context ID)
  • i.MX series: RD (Resource Domain)

Resource ID (ResID)

The Resource ID (ResID) is a unique identifier for each resource in the system. It is used to identify the resource and to enforce access control policies. The ResID is assigned to each resource at compile time and is used by the RPI subsystem to determine which partitions can access the resource.

  • STM32MP series: rifsc_idx (RIF Secure Context Index)
  • i.MX series: trdc_periph_id (Trust Resource Domain Controller Peripheral ID)

Configuration in RPI Subsystem

The RPI regards Consortium.toml as the source of truth for configuration. The Consortium.toml is a configuration file that contains information about the resources in the system, such as the partitions, resources, and access control policies.

What does it look like

The RPI configuration in Consortium.toml is configured in peripheral field. Its convention is [peripheral.<name>], where <name> is the name of the peripheral. For example, for peripheral SPI1, the configuration would be under [peripheral.spi1].

The name of the peripheral must match the name used in the device tree source, the hardware-specific RPI configuration (namely RIF in STM32MP series and TRDC in i.MX series), and the PAC crate. This is to ensure consistency across the different components of the system and to avoid confusion.

In the [peripheral.<name>] section, we can configure the owner field, which specifies the owner of the peripheral. The owner is the partition that has access to the peripheral. For example, if SPI1 is owned by the cm33 core, the configuration would be owner = "cm33". If it should be running in the secure domain, the configuration should also be owner = "cm33", but you need to specify secure = true to indicate that it is in the secure domain.

In application core, we accept linux and optee. linux is always non-secure, while optee is secure by default. In the future, we will support u-boot and tf-a as well, and we will also support more flexible configuration for secure/non-secure in application core.

We currently only support single owner for each peripheral. In the future, we may support multiple owners for a peripheral, but this will require additional configuration to specify the access control policies for each owner, and we will introduce the concept of “shared peripherals” to handle this scenario, including semaphores, mutexes, etc. to ensure safe access to the shared peripherals.

We must ensure names in Consortium.toml are consistent with the names in:

  • Device Tree Source (DTS): The names used in Consortium.toml should match the names used in the DTS files, as these are used to generate the device tree blobs that are consumed by the operating system and the RPI subsystem.
  • Hardware-specific RPI Configuration: The names should also match the names used in the hardware-specific RPI configuration, such as RIF for STM32MP series and TRDC for i.MX series, to ensure that the correct resources are configured and accessed.
  • PAC Crate: The names should also be consistent with the names used in the Peripheral Access Crate (PAC) to ensure that the correct registers and fields are accessed when configuring the peripherals in the code. This consistency is crucial for the correct functioning of the system and to avoid confusion during development and debugging.
[peripheral.spi1]
owner = "cm33"

[peripheral.spi2]
owner = "linux"
secure = false

[peripheral.spi3]
owner = "optee"
secure = true

[peripheral.i2c1]
owner = "cm33"
secure = true

RIF Subsystem: Peripheral Configuration & PAC Generation Design

Overview

This document proposes a system for declaring peripheral ownership in Consortium.toml and automatically generating:

  1. RIF/RIFSC security configuration — derived entirely by the builder from the chip mapping already in consortium-cfg-common; not user-specified.
  2. PAC peripheral objects (p.i2c1, p.spi2, …) for M-core firmware, exposed in embedded-hal-compatible format backed by embassy-stm32 or imxrt-hal.
  3. Device Tree Source (DTS) fragments assigning peripherals owned by "linux" or "optee" to their respective domains.

RIF compartment IDs and DMA routing are derived automatically: the builder knows the chip, the chip mapping knows which CID each core occupies, and the DMA controllers on both STM32MP2x (RISAB) and i.MX 9x (TRDC) are RIF-aware — so claiming a peripheral implicitly covers its DMA access.

The goal is that a developer declares intent once and the build system derives safe, non-overlapping peripheral access across all execution environments.


Motivation

On a heterogeneous SoC such as the STM32MP257 (Cortex-A35 + Cortex-M33 + Cortex-M0+) or i.MX 95 (Cortex-A55 + Cortex-M7 + Cortex-M33), each peripheral must be owned by exactly one execution domain:

DomainMechanismCurrent approach
Linux (NS)DTS status = "okay"Manual
OP-TEE (S)DTS secure-status = "okay"Manual
M-core NS firmwarePAC + HAL + RIFSC/TRDCManual
M-core S firmware (TF-M)PAC + HAL + RIFSC/TRDCManual

Today all four configurations are written by hand and must stay in sync. A mismatch causes silent interrupt mis-attribution, broken DMA, or a security violation trap at runtime.

Consortium.toml becomes the single source of truth.


Consortium.toml Schema

Peripherals are declared as named subsections [peripheral.<NAME>], where <NAME> is the peripheral identifier in lower-snake-case matching the chip’s PAC (e.g. i2c1, spi2, lptim1). This mirrors TOML idioms like [dependencies.<crate>] and avoids the ordering noise of [[peripherals]] arrays.

Field reference

FieldTypeRequiredDescription
masterstringyesWho owns this peripheral: a core abbreviation ("cm33", "cm0p", "cm7_0"), "linux", or "optee"
secureboolwhen master is M-core or "optee"true → secure-world (RIFSC SEC bit / TRDC); false → non-secure. Defaults to false for M-core, must be true for "optee"
clockstringnoClock source name for this peripheral (chip-specific, e.g. "pll4_r", "hsi"). Omit to use the chip reset default.

No rif_cid — the builder derives each core’s compartment ID from Chip::programmable_rt_cores() and Chip::remoteproc_name() in consortium-cfg-common. No dma — DMA is RIF-aware on both STM32MP2x (RISAB) and i.MX 9x (TRDC); assigning a peripheral automatically covers its DMA paths.

GPIO banks (gpioa, gpiob, …) are first-class entries in the RIFSC/TRDC like any other peripheral and are declared the same way — no special handling required:

[peripheral.gpioa]
master = "linux"

[peripheral.gpiob]
master = "cm33"
secure = false

Core abbreviations

master for M-core owners uses CortexMcuCore::abbr():

CoreSingle instanceMultiple instances (indexed)
Cortex-M0+"cm0p""cm0p_0", "cm0p_1"
Cortex-M33"cm33""cm33_0", "cm33_1"
Cortex-M7"cm7""cm7_0", "cm7_1"
Cortex-M4"cm4""cm4_0", "cm4_1"

When a chip has only one instance of a core type, the plain abbreviation is used. When it has multiple instances of the same core type, an underscore-index suffix disambiguates. The builder resolves these against Chip::programmable_rt_cores() to validate correctness.

Example — STM32MP257

[general]
chip = "stm32mp257"

[project]
name = "my-app"

[project.firmware.sensor_fw]
core  = "CortexM33"
index = 0
path  = "firmware/sensor"

[project.firmware.motor_fw]
core  = "CortexM0p"
index = 0
path  = "firmware/motor"

# ── Peripheral assignments ─────────────────────────────────────────────────────

[peripheral.i2c1]
master = "cm33"
secure = false
clock  = "pll4_r"      # optional: override default clock source

[peripheral.lptim1]
master = "cm33"
secure = true          # claimed by the TF-M secure partition on M33

[peripheral.spi2]
master = "cm0p"
secure = false

[peripheral.uart4]
master = "linux"       # appears in DTS only; no RIFSC entry

[peripheral.i2c5]
master = "optee"
secure = true          # enforced: optee always requires secure = true

[peripheral.gpioa]
master = "linux"

[peripheral.gpiob]
master = "cm33"
secure = false

Example — i.MX 95 (Cortex-M7)

[general]
chip = "imx95"

[project.firmware.motor_fw]
core  = "CortexM7"
path  = "firmware/motor"

[peripheral.lpi2c1]
master = "cm7"
secure = false

[peripheral.lpspi3]
master = "cm7"
secure = false

[peripheral.lpuart2]
master = "linux"

Output Artifacts

1. RIFSC / TRDC Configuration (generated into consortium-runtime-mcu)

The builder derives each core’s RIF Compartment ID from Chip::remoteproc_name(), then emits a startup function. Users never write CIDs.

For STM32MP2x (RIFSC):

#![allow(unused)]
fn main() {
// Generated: generated/rif_config.rs
// DO NOT EDIT — regenerated by `consortium build`

use crate::pac::{RCC, RIFSC};

pub fn configure_rif(rcc: &mut RCC, rifsc: &mut RIFSC) {
    // ── Clock sources ─────────────────────────────────────────────────────────
    // i2c1: clock = pll4_r
    rcc.i2c1ckselr().write(|w| w.i2c1sel().pll4_r());

    // ── RIFSC assignments ─────────────────────────────────────────────────────
    // i2c1: master=cm33 (CID=2), secure=false
    rifsc.seccfgr1().modify(|_, w| w.sec8().clear_bit());
    rifsc.risc_cidcfgr8().write(|w| unsafe {
        w.cfen().set_bit().scid().bits(2)
    });

    // lptim1: master=cm33 (CID=2), secure=true
    rifsc.seccfgr5().modify(|_, w| w.sec40().set_bit());
    rifsc.risc_cidcfgr40().write(|w| unsafe {
        w.cfen().set_bit().scid().bits(2)
    });

    // spi2: master=cm0p (CID=3), secure=false
    rifsc.seccfgr1().modify(|_, w| w.sec15().clear_bit());
    rifsc.risc_cidcfgr15().write(|w| unsafe {
        w.cfen().set_bit().scid().bits(3)
    });

    // gpiob: master=cm33 (CID=2), secure=false
    rifsc.seccfgr0().modify(|_, w| w.sec33().clear_bit());
    rifsc.risc_cidcfgr33().write(|w| unsafe {
        w.cfen().set_bit().scid().bits(2)
    });
}
}

For i.MX 9x (TRDC), the equivalent domain controller registers are emitted instead.

2. Per-Firmware PAC Wrapper (generated/pac/<fw_key>/)

A thin generated crate per firmware entry exposes only the peripherals owned by that firmware as a Peripherals struct, wrapping embassy-stm32 or imxrt-hal tokens. This gives the firmware typed, compile-time-verified access — unowned peripherals are consumed at steal-time, preventing accidental access.

For owned peripherals:

  • Non-Copy types are moved into the struct, providing exclusive ownership.
  • Copy types are also included but could be accessed elsewhere (since they’re copyable). No artificial restriction is imposed.

For unowned peripherals, all non-Copy tokens are explicitly consumed (moved into a drop) at steal-time. Copy peripherals that aren’t owned can still be accessed via a second steal() call—this is acceptable since the type system cannot prevent it, and it’s a rare edge case.

#![allow(unused)]
fn main() {
// Generated: generated/pac/sensor_fw/src/lib.rs
// Chip: stm32mp257 | Firmware: sensor_fw (CortexM33)
// DO NOT EDIT — regenerated by `consortium build`

pub struct Peripherals {
    pub i2c1:   embassy_stm32::peripherals::I2C1,
    pub lptim1: embassy_stm32::peripherals::LPTIM1,
}

impl Peripherals {
    /// # Safety
    /// Must be called exactly once, after `configure_rif()`.
    /// Consumes unowned, non-Copy peripherals to prevent access.
    pub unsafe fn steal() -> Self {
        let p = embassy_stm32::Peripherals::steal();

        // Consume unowned non-Copy peripherals by moving them
        // They cannot be cloned, so this removes them from the namespace
        let _unused = (
            p.I2C2,
            p.SPI1,
            p.SPI2,
            p.UART4,
            p.LPTIM2,
            // ... all other unowned, non-Copy peripherals
        );

        Self {
            i2c1: p.I2C1,
            lptim1: p.LPTIM1,
        }
    }
}
}

Usage in firmware:

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    consortium_runtime_mcu::init();   // calls configure_rif() internally

    // Steal only owned peripherals; unowned non-Copy ones are consumed
    let p = unsafe { sensor_fw::Peripherals::steal() };

    let mut i2c = I2c::new(p.i2c1, Config::default());

    loop {
        let mut buf = [0u8; 2];
        i2c.read(0x48, &mut buf).await.unwrap();
    }
}

The firmware has compile-time assurance that it cannot use unowned, non-Copy peripherals—attempting to reference them will fail at compile time because they were consumed in steal(). Owned peripherals are available as fields in p.

For i.MX 9x, types come from imxrt-hal instead of embassy-stm32.

3. Device Tree Fragments (generated/dts/)

Only peripherals with master = "linux" or master = "optee" appear here.

/* Generated: generated/dts/linux-peripherals.dtsi */
/* DO NOT EDIT — regenerated by `consortium build`  */

&uart4 {
    status = "okay";
};

/* Generated: generated/dts/optee-peripherals.dtsi */

&i2c5 {
    secure-status = "okay";
};

Include these from the top-level board DTS via #include.


Architecture

Consortium.toml
      │
      ▼
consortium-cfg-common
  ├── parse [peripheral.*] → HashMap<String, PeripheralEntry>
  ├── Chip::programmable_rt_cores()  — resolve master abbr → (CortexMcuCore, index)
  └── validate_peripherals()         — run all rules, emit structured errors
      │
      ├──▶ consortium-pac-gen  (build-time binary)
      │       ├── derive CID from chip mapping        (no user input)
      │       ├── derive DMA routing from chip DB     (no user input)
      │       ├── emit generated/rif_config.rs
      │       ├── emit generated/pac/<fw_key>/src/lib.rs  (per firmware)
      │       └── emit generated/dts/{linux,optee}-peripherals.dtsi
      │
      ├──▶ consortium-builder  (orchestrates pac-gen as build step 1)
      │
      └──▶ consortium-cli  (human-facing inspection & integration tooling)

New crate: consortium-pac-gen

A build-time binary (and library API consumed by consortium-builder) that:

  1. Reads Consortium.toml via consortium-cfg-common.
  2. Resolves each master abbreviation to (CortexMcuCore, index) using Chip::programmable_rt_cores().
  3. Derives the RIF Compartment ID per core from Chip::remoteproc_name().
  4. Looks up each peripheral name in the chip database to get RIFSC/TRDC index, DTS node, and HAL type path.
  5. Emits the three artifact families into generated/.

Chip peripheral database

A TOML file per chip embedded via include_str! in consortium-pac-gen/data/<chip>.toml:

# consortium-pac-gen/data/stm32mp257.toml
[[peripheral]]
name          = "i2c1"
rifsc_idx     = 8
dts_node      = "i2c1"
hal_type      = "embassy_stm32::peripherals::I2C1"
clock_reg     = "i2c1ckselr"        # RCC register name for clock source selection
clock_options = ["pclk1", "pll4_r", "hsi", "csi"]   # valid values for `clock` field

[[peripheral]]
name          = "lptim1"
rifsc_idx     = 40
dts_node      = "lptim1"
hal_type      = "embassy_stm32::peripherals::LPTIM1"
clock_reg     = "lptim1ckselr"
clock_options = ["pclk1", "pll4_p", "pll3_q", "lse", "lsi", "per"]

[[peripheral]]
name          = "gpiob"
rifsc_idx     = 33
dts_node      = "gpiob"
hal_type      = "embassy_stm32::peripherals::GPIOB"
# no clock_reg: GPIO clocks are managed by RCC AHB enable bits, not selectors

Validation Rules

All rules run inside consortium-cfg-common’s Config::validate_peripherals(), shared by both the build pipeline and the CLI:

  1. Uniqueness: Each [peripheral.*] key is unique (TOML guarantees this syntactically; validated semantically against the chip DB).
  2. Name validity: All keys must exist in the chip peripheral database for the configured chip.
  3. Master validity: Each master value is either "linux", "optee", or a core abbreviation (with optional index) present in Chip::programmable_rt_cores().
  4. Security consistency: secure = false is rejected for "optee" masters; secure = true is rejected for "linux" masters.
  5. Core availability: A master abbreviation must correspond to a core that actually exists on the chip (e.g. "cm0p" is invalid on i.MX 95).
  6. Clock validity: If clock is specified, the value must appear in the chip DB’s clock_options list for that peripheral. Peripherals without a clock_reg in the DB reject any clock field.

Errors are emitted as cargo:error= messages during build and as structured output from consortium peripheral validate.


CLI Integration (consortium-cli)

consortium-cli gains a peripheral subcommand group. All subcommands read Consortium.toml from the working directory (or --config <path>). These are read-only inspection and validation tools; no code is generated from the CLI.

consortium peripheral list

$ consortium peripheral list

Peripheral  Master   Secure  Domain
──────────  ───────  ──────  ──────────
i2c1        cm33     no      M-core (sensor_fw)
lptim1      cm33     yes     M-core/S (sensor_fw)
spi2        cm0p     no      M-core (motor_fw)
uart4       linux    —       Linux DTS
i2c5        optee    yes     OP-TEE DTS

5 peripherals — 3 M-core, 1 Linux, 1 OP-TEE

--json emits machine-readable output for IDE integration.

consortium peripheral show <name>

$ consortium peripheral show i2c1

[peripheral.i2c1]
  Master     : cm33  → sensor_fw (CortexM33, index 0)
  Secure     : false
  Clock      : pll4_r  (valid options: pclk1, pll4_r, hsi, csi)
  RIF CID    : 2  (derived from chip mapping)

Chip database (stm32mp257):
  RIFSC index : 8
  DTS node    : &i2c1
  HAL type    : embassy_stm32::peripherals::I2C1
  DMA         : covered by RISAB (RIF-aware, no explicit config needed)

Generated artifacts:
  rif_config.rs : rcc.i2c1ckselr() → pll4_r; rifsc.risc_cidcfgr8() → cid=2, sec=0
  PAC struct    : generated/pac/sensor_fw/src/lib.rs :: Peripherals.i2c1
  DTS           : (not emitted — M-core peripheral)

consortium peripheral validate

$ consortium peripheral validate

✓ All peripheral names exist in chip database (stm32mp257)
✓ All master values resolve to known cores or linux/optee
✓ Security consistency: no conflicts
✓ Core availability: cm33 and cm0p both present on stm32mp257

Validation passed. 5 peripherals, 0 errors.

Exits non-zero on failure with one line per error. Suitable for CI pre-build checks.

consortium peripheral chip-info [--unassigned]

$ consortium peripheral chip-info --unassigned

Chip: stm32mp257

RIFSC  Peripheral  Status
─────  ──────────  ──────────────
0      tim1        (unassigned)
1      tim2        (unassigned)
...
(137 unassigned peripherals shown; use without --unassigned to see all)

Useful when starting a project to see what peripherals are available for assignment.


HAL Strategy

STM32MP2x — embassy-stm32

The generated Peripherals::steal() destructures embassy_stm32::Peripherals::steal() into only the owned subset. The firmware’s Cargo.toml only needs:

[dependencies]
embassy-executor = { version = "0.7", features = ["arch-cortex-m", "executor-thread"] }
embassy-stm32    = { version = "0.2", features = ["stm32mp257fai", "time-driver-any"] }
# pac wrapper is path-referenced from generated/

The correct embassy-stm32 chip feature (stm32mp257fai, etc.) is propagated automatically from Chip enum via consortium-builder.

i.MX 9x — imxrt-hal

The generated struct wraps imxrt_hal::instance::* tokens. imxrt-ral instance stealing follows the same pattern as embassy-stm32’s Peripherals::steal().


Implementation Plan

Phase 1 — Schema & Validation (1 week)

  • Add PeripheralEntry { master: String, secure: Option<bool>, clock: Option<String> } to consortium-cfg-common.
  • Parse [peripheral.*] as HashMap<String, PeripheralEntry> in Config::from_toml().
  • Implement Config::validate_peripherals() with rules 1–6.
  • Extend CortexMcuCore::from_abbr() to handle indexed variants ("cm7_0", "cm7_1").
  • Unit tests for each validation rule and index-suffix resolution.

Phase 2 — Chip Database (1 week)

  • Define ChipPeripheralDb + ChipPeripheralEntry structs (TOML, embedded in consortium-pac-gen).
  • Populate STM32MP257 database (RIFSC index, DTS node name, HAL type path).
  • Populate i.MX 93 and i.MX 95 databases (TRDC domain index, DTS node, HAL type path).
  • Expose DB lookup via consortium-cfg-common API so CLI and pac-gen share it.

Phase 3 — CLI Subcommands (1 week)

  • Add peripheral subcommand group to consortium-cli (using clap).
  • Implement list, show, validate, chip-info with --json and --unassigned flags.
  • Test with STM32MP257 and i.MX 95 fixture configs.

Phase 4 — Code Generation (2 weeks)

  • Create consortium-pac-gen crate (binary + library).
  • Implement CID derivation from Chip::remoteproc_name().
  • Implement RIFSC emitter (STM32MP2x) with RCC clock-source writes.
  • Implement TRDC emitter (i.MX 9x) with CCM clock-source writes.
  • Investigate embassy-stm32 and imxrt-hal init() for NVIC/EXTI coverage; emit STM32MP2x C2IMR routing if not covered.
  • Implement per-firmware Peripherals struct emitter (embassy-stm32 / imxrt-hal).
  • Implement DTS fragment emitter.
  • Wire into consortium-builder as build pipeline step 1.

Phase 5 — Runtime Integration (1 week)

  • consortium-runtime-mcu: call generated configure_rif() from init().
  • Example firmware (STM32MP257) using generated Peripherals with embassy-stm32.
  • Example firmware (i.MX 95) using generated Peripherals with imxrt-hal.
  • Integration test: build full config, verify generated files compile for target.

Phase 6 — DTS Integration (1 week)

  • Hook DTS fragment generation into consortium-builder build pipeline step 1.
  • Verify generated overlays compile with dtc.
  • Document how to #include overlays in the base board DTS.

Design Decisions

#QuestionDecision
1GPIO in RIFGPIO banks (gpioa, gpiob, …) are listed in RIFSC/TRDC like any other peripheral. Declared as [peripheral.gpioa] with the same master/secure fields. No special handling.
2Clock configurationclock field added to [peripheral.<name>]. Builder emits RCC/CCM clock source selection in configure_rif() alongside RIFSC config. Valid values per peripheral come from the chip DB (clock_options). System-level PLL/oscillator config is future work.
3Interrupt routingIn scope for generated init, but contingent on what embassy-stm32 / imxrt-hal cover in their own init(). If NVIC enable and EXTI routing are handled by the HAL driver constructors, no generated code is needed. If not (e.g. STM32MP2x EXTI C2IMR routing to M-core), the builder emits the necessary register writes. To be determined during Phase 4 implementation with reference to embassy-stm32 source.
4Silicon revisionsDelegated to the upstream PAC (stm32mp257-pac, imxrt-ral). The chip DB targets a specific PAC version; users pin the PAC version in Cargo.toml as usual. No revision logic inside Consortium.
5Shared peripheralsNot in scope for the initial implementation. A peripheral must have exactly one static owner. Time-multiplexed or arbitrated sharing between cores is a future concern and will require a separate design.
6Copy vs non-Copy peripheral tokensUnowned, non-Copy peripherals are consumed (moved into a drop) at steal-time, enforcing exclusivity through Rust’s type system. Owned peripherals (both Copy and non-Copy) are included in the wrapper struct. For Copy peripherals, no artificial restriction is imposed—they can be copied anyway. This pragmatic approach leverages type-level safety where it’s effective without unnecessary restrictions.

Non-Goals

  • Does not replace embassy-stm32 or imxrt-hal; wraps them.
  • Does not generate full DTS board files; generates peripheral assignment overlays only.
  • Does not configure system PLLs or oscillators (only peripheral clock source selection).
  • Does not support shared peripherals in this iteration (see decision 5 above).

RpiSession: Peripheral Configuration & Pin Validation

Overview

RpiSession is the core abstraction for resolving and validating peripheral configurations at the device/chip variant level. It combines three data sources — the project configuration, the RIFSC peripheral map, and the IOMUX pin database — to produce a validated peripheral inventory with complete DTS metadata.

The session ensures that:

  • Peripheral IDs (perid) are assigned from the chip’s RIFSC YAML
  • Pin mappings are validated against the IOMUX YAML for the specific package variant
  • Alternate function (AF) numbers are derived correctly from the pin mux list
  • Aliases are populated for DTS generation

Architecture

Data Flow

Consortium.toml
      ↓
  Config (chip: Chip, variant: Option<String>, peripheral: HashMap)
      ↓
RpiSession::new()
      ├─→ RifConfig (loaded from data/stm32mp/rifsc/stm32mp{21,23,25}xxxx.yaml)
      ├─→ IomuxConfig (loaded from data/stm32mp/iomux/STM32MP{variant}.yaml)
      ↓
  RpiSession { chip, variant, peripherals: HashMap<String, Peripheral> }
      ↓
 DTS generation, build validation, runtime configuration

Key Types

GeneralConfig (in consortium-cfg-common):

#![allow(unused)]
fn main() {
pub struct GeneralConfig {
    pub chip: Chip,                    // Family level: Stm32mp25x, Imx95, etc.
    pub variant: Option<String>,       // Package variant: "STM32MP257FAKx"
}
}

RpiSession (in consortium-rpi):

#![allow(unused)]
fn main() {
pub struct RpiSession {
    pub chip: Chip,
    pub variant: Option<String>,
    pub peripherals: HashMap<String, Peripheral>,
}
}

Peripheral (in consortium-rpi):

#![allow(unused)]
fn main() {
pub struct Peripheral {
    pub name: String,
    pub owner: String,
    pub secure: bool,
    pub pins: Vec<PeripheralPinMux>,  // Validated & enriched
    pub dts: Option<String>,
    pub perid: Option<u32>,            // RIFSC ID, assigned from RifConfig
}
}

PeripheralPinMux (in consortium-rpi):

#![allow(unused)]
fn main() {
pub struct PeripheralPinMux {
    pub group: char,                   // GPIO group: 'A', 'B', etc.
    pub pin_id: u8,                    // GPIO pin number
    pub position: String,              // AF function: "AF0", "AF1", ..., "ANALOG"
    pub alias: String,                 // DTS comment: "I2C1_SDA"
}
}

Usage

Creating an RpiSession

#![allow(unused)]
fn main() {
use consortium_cfg_common::config::{
    Config,
    load_rifsc_peripherals,
    load_stm32mp_iomux,
};
use consortium_rpi::RpiSession;

// 1. Load configuration from Consortium.toml
let config: Config = load_config("Consortium.toml")?;

// 2. Load RIFSC peripheral map for the chip family
let chip_family = config.general.chip.rifsc_family_name()
    .ok_or("Chip does not support RIFSC")?;
let rif_path = format!("data/stm32mp/rifsc/{}.yaml", chip_family);
let rif = load_rifsc_peripherals(&rif_path)?;

// 3. Optionally load IOMUX for pin validation
let iomux = if let Some(variant) = &config.general.variant {
    Some(load_stm32mp_iomux(variant)?)
} else {
    None
};

// 4. Create the session
let session = RpiSession::new(&config, Some(&rif), iomux.as_ref())?;

// 5. Iterate over validated peripherals
for (name, periph) in &session.peripherals {
    println!("Peripheral: {}", name);
    println!("  RIFSC ID: {:?}", periph.perid);
    println!("  Owner: {}", periph.owner);
    for pin in &periph.pins {
        println!("  Pin: P{}{} → {} ({})", pin.group, pin.pin_id, pin.position, pin.alias);
    }
}
}

Optional Parameters

rif: Option<&RifConfig>

  • Required for STM32MP: Pass the loaded RIFSC YAML to assign perid
  • Not applicable for i.MX: Pass None; peripherals will have perid = None

iomux: Option<&IomuxConfig>

  • Required for pin validation: Pass the loaded IOMUX YAML for the specific package variant (e.g., STM32MP257FAKx)
  • Optional: Pass None to skip pin validation; position and alias fields remain empty

Chip Family & Variant Mapping

Chip Family → RIFSC YAML

The Chip enum represents the family level. Use rifsc_family_name() to get the RIFSC YAML stem:

ChipRIFSC FamilyYAML File
Stm32mp21xstm32mp21xxxxdata/stm32mp/rifsc/stm32mp21xxxx.yaml
Stm32mp23xstm32mp23xxxxdata/stm32mp/rifsc/stm32mp23xxxx.yaml
Stm32mp25xstm32mp25xxxxdata/stm32mp/rifsc/stm32mp25xxxx.yaml
Imx93, Imx95, etc.NoneNo RIFSC (uses TRDC, not yet implemented)

Variant → IOMUX YAML

The variant string in GeneralConfig specifies the exact package. This maps directly to the IOMUX YAML filename:

VariantIOMUX File
"STM32MP255AAKx"data/stm32mp/iomux/STM32MP255AAKx.yaml
"STM32MP257FAKx"data/stm32mp/iomux/STM32MP257FAKx.yaml
"STM32MP213FANx"data/stm32mp/iomux/STM32MP213FANx.yaml

Pin Validation & AF Assignment

IOMUX YAML Structure

Each pin entry in the IOMUX YAML lists available alternate functions (mux options):

PA5:
  position: P11 # Physical package position (BGA ball)
  type: I/O
  mux:
    - name: tim2 # AF0: Timer2 Channel 4
      pin: ch4
    - name: i2c1 # AF1: I2C1 SDA
      pin: sda
    - name: spi2 # AF2: SPI2 MOSI
      pin: mosi

AF Number Assignment

The AF function number is derived from the mux list index (STM32MP convention):

  • mux[0]"AF0" (timer2)
  • mux[1]"AF1" (i2c1)
  • mux[2]"AF2" (spi2)

When validating a peripheral pin, RpiSession::new() finds the index of the peripheral in the mux list and stores it as PeripheralPinMux.position:

#![allow(unused)]
fn main() {
let af_idx = iomux_pin
    .mux
    .iter()
    .position(|sig| sig.name == "i2c1")?;  // Finds index 1

pin.position = format!("AF{}", af_idx);    // Sets "AF1"
pin.alias = "I2C1_SDA";                    // From mux[1].pin
}

Validation Errors

RpiError::InvalidPin(pin_name)

  • Pin does not exist in the IOMUX YAML for this package variant
  • Example: trying to use "PA100" on a 176-pin package that only has up to "PA63"

RpiError::PinNotAvailable(pin, peripheral, allowed_pins)

  • Pin exists in IOMUX but does not support the configured peripheral
  • Example: "PA5" supports tim2, i2c1, spi2, but not uart1
  • The error message includes the list of pins that do support the peripheral

DTS Generation

Once validated, the peripheral and its pins can generate DTS snippets for inclusion in the device tree:

#![allow(unused)]
fn main() {
// Generate DTS iomux definition for each pin
for pin in &periph.pins {
    let dts = pin.generate_dts_iommx(sleep: false);
    // Output: <STM32_PINMUX('A', 5, AF1)>, /* I2C1_SDA */
}
}

The position field (e.g., "AF1") is used directly in the DTS macro, ensuring correct pin function configuration.


Design Decisions

  1. Variant is Option<String>: Pin validation is optional. Callers can create a session without specifying the variant; perid is still assigned, but position and alias remain empty.

  2. AF number from mux index: The IOMUX YAML does not store AF numbers explicitly. We rely on mux list order matching the AF numbering convention (which is universal on STM32 devices).

  3. Silent perid assignment: When a peripheral is not found in the RIFSC map, perid stays None rather than erroring. This allows non-RIFSC-controlled peripherals to exist in the config.

  4. No i.MX TRDC yet: The rif parameter is Option to prepare for i.MX support, but TRDC loading is not yet implemented.


Testing & Validation

Manual Integration Test

# Build and test the RpiSession with real YAML data
cargo test -p consortium-rpi

# Verify a specific variant loads and validates correctly
cargo run --example rpi_session_example STM32MP257FAKx

Checklist

  • Config loads without errors
  • chip.rifsc_family_name() returns correct family string
  • RIFSC YAML loads successfully
  • IOMUX YAML loads successfully
  • All configured peripherals exist in RIFSC map
  • All configured pins exist in IOMUX map
  • All pins support the configured peripheral
  • perid is assigned correctly from RIFSC ID
  • position is derived correctly from mux index
  • alias is formatted as "PERIPHERAL_FUNCTION"
  • DTS generation produces valid STM32_PINMUX macros

Inter-processor Communication (IPC) Subsystem

IPC QoS Design


Problem

All channels in the current design are equal. A log-spam channel can starve a safety alarm. There’s no way to express “this message is urgent.”


Goals

  1. Priority: High-priority messages preempt low-priority ones at the receiver.
  2. Back-pressure: Receiver can signal “slow down” without dropping messages.
  3. Zero allocation: All data structures are statically sized; no alloc required.
  4. no_std compatible: Works on bare-metal M-core.

Non-Goals

  • Hard real-time guarantees (no RTOS scheduling, no hardware timer enforcement).
  • Multi-hop routing or network-style QoS (DSCP, 802.1p).
  • Dynamic priority inheritance.

Design

1. Priority Levels

Four levels, encoded as 2 bits in the slot header. No allocator needed; priority is a type-level constant at channel construction.

#![allow(unused)]
fn main() {
#[repr(u8)]
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
    RealTime   = 0,  // alarms, safety interlocks
    High       = 1,  // control commands
    Normal     = 2,  // sensor telemetry (default)
    Background = 3,  // logs, diagnostics
}
}

Priority is stamped into the slot at send time; the receiver reads it before deciding which pending slot to drain next.


2. Slot Header Extension

Current layout:

[len: u32][flags: u32][payload: [u8; mtu]]

Extended layout (one extra byte, backward-incompatible version bump):

[len: u32][flags: u32][priority: u8][_pad: [u8; 3]][payload: [u8; mtu]]

Flag additions:

BitNameMeaning
0FLAG_VALIDSlot contains a new message
1FLAG_CONSUMEDReceiver has read the message
2FLAG_BACKPRESSUREReceiver is slow; sender should pause

The descriptor version field (currently 1) bumps to 2 to signal the new layout to both sides.


3. Priority Scheduler (A-core)

On the A-core (Tokio), multiple channels may have pending data simultaneously. PriorityMux polls all registered channels and always returns the highest-priority pending message.

#![allow(unused)]
fn main() {
pub struct PriorityMux<const N: usize> {
    // Parallel arrays, sorted by declared priority at construction.
    slots: [SlotHandle; N],
}

impl<const N: usize> PriorityMux<N> {
    /// Returns the channel index and a reference to the raw slot
    /// with the highest priority among currently-valid slots.
    pub async fn next_ready(&mut self) -> (usize, &SlotHandle);
}
}

Algorithm:

  1. Snapshot FLAG_VALID for all N slots (volatile reads, no lock).
  2. Among valid slots, pick the one with the lowest priority byte.
  3. If none valid, subscribe all slot notifiers and await the first to fire.
  4. Return to step 1.

Ties within the same priority level are broken by round-robin (a simple last_served index).

The scheduler is optional. Callers who only care about a single channel use Channel directly as today.


4. Back-Pressure

When the A-core receiver cannot keep up (e.g., processing takes too long), it sets FLAG_BACKPRESSURE in the rx slot it has just consumed but not yet recycled.

On the M-core sender, after doorbell.ring(), SendFut checks FLAG_BACKPRESSURE on the tx slot in its next poll. If set, it yields for one executor cycle before retrying, giving lower-priority work a chance to run.

This is advisory, not blocking. The flag is best-effort; it does not prevent the sender from overwriting a slot. It only requests cooperative throttling.


5. Type-Level Integration

QoS priority is stamped as a const generic on Channel so misconfiguration is caught at compile time:

#![allow(unused)]
fn main() {
pub struct Channel<D, T, Tr, C = PostcardCodec, const P: Priority = Priority::Normal> {
    // ...
}

// Alarm channel — won't compile if you accidentally use it as a background sink
let alarm: Channel<Tx, AlarmEvent, SharedMemoryTransport<_, _>, PostcardCodec, {Priority::RealTime}> = ...;
}

The Priority const flows into Transport::send() via a new parameter:

#![allow(unused)]
fn main() {
fn send<'a>(&'a mut self, ch: Chan, data: &[u8], priority: Priority) -> Self::SendFut<'a>;
}

Existing code that omits the const defaults to Priority::Normal.


Slot Layout (v2 descriptor, version field = 2)

Byte offset  Size  Field       Notes
0x00         4     len         Payload length (volatile u32, LE)
0x04         4     flags       Bits: VALID(0) CONSUMED(1) BACKPRESSURE(2)
0x08         1     priority    0=RealTime … 3=Background
0x09         3     _pad        Reserved, must be zero
0x0C         mtu   payload     Raw encoded bytes

Total header overhead: 12 bytes (up from 8).


Crate Changes Summary

CrateChange
consortium-ipcAdd Priority enum; bump Transport::send signature; add FLAG_BACKPRESSURE
consortium-ipc-transport-memoryWrite priority byte in slot; check backpressure flag
consortium-runtime-app UIO pathRead priority byte; expose it from the runtime UIO receive path
consortium-ipc (new module qos)PriorityMux
Descriptor (UIO)Version field: 1 → 2; slot header size: 8 → 12

Open Questions

  1. PriorityMux and pinning: N channel handles in a const-generic array require the handles to be Unpin. Verify UioChannel satisfies this or wrap in Pin<Box<_>> (std-only path).

  2. Version negotiation: The descriptor version bump is backward-incompatible. A migration shim (read old v1 layout, write v2) may be needed during rollout.

  3. Scheduler fairness under saturation: Round-robin tie-breaking within a priority band is simple but not starvation-proof if a RealTime channel fires continuously. Consider a debt-based scheduler (DRR) if saturation is a realistic scenario.

Consortium IPC: Vision & Design

The Developer Experience

The entire system exists to make this work:

#![allow(unused)]
fn main() {
// M-core (bare-metal, no_std)
tx.send(TemperatureSensorData { temp: 26.9, time_elapsed: 6 }).await?;

// A-core (Linux, Tokio)
let data = rx.recv().await?;
println!("temp={}, elapsed={}ms", data.temp, data.time_elapsed);
}

No manual serialization. No raw pointer juggling at the call site. No explicit cache flushes. No doorbell management. Just typed, async send and receive across the processor boundary.

The complexity lives in the layers below. The application developer sees none of it.


The Layer Stack

┌─────────────────────────────────────────────────┐
│  Application                                    │
│  tx.send(SensorData { ... }).await?             │
│  rx.recv().await?                               │
└──────────────┬──────────────────────────────────┘
               │  Channel<Tx/Rx, T, Tr, C>
               │  typed, directed, allocation-free
               ▼
┌─────────────────────────────────────────────────┐
│  Codec                                          │
│  T → [u8]  (send)                               │
│  [u8] → T  (recv)                               │
│  default: PostcardCodec (postcard + serde)      │
└──────────────┬──────────────────────────────────┘
               │  raw bytes
               ▼
┌─────────────────────────────────────────────────┐
│  Transport                                      │
│  send(chan, &[u8])   recv(chan, &mut [u8])      │
│  owns Doorbell; drives slot protocol            │
│  ┌──────────────────┬────────────────────────┐  │
│  │  SharedMemory    │  runtime-app UIO       │  │
│  │  transport       │  Linux / Tokio         │  │
│  └──────────────────┴────────────────────────┘  │
└───────┬──────────────────────────┬──────────────┘
        │                          │
        ▼                          ▼
┌───────────────┐        ┌─────────────────────────┐
│  Slot (SHMEM) │        │  Doorbell               │
│  len/flags/   │        │  ring() / wait()        │
│  payload      │        │  HSEM, IPCC, MU, etc.   │
└───────────────┘        └─────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────────┐
│  Region                                         │
│  as_ptr() / flush() / invalidate()              │
│  SramRegion | DdrRegion | SerialRegion          │
└─────────────────────────────────────────────────┘

Layer 1: Channel — The Public API

Channel<D, T, Tr, C> is what application code touches. It is a typed, directed endpoint over a transport.

#![allow(unused)]
fn main() {
// Tx side (M-core)
let mut tx: Channel<Tx, TemperatureSensorData, SharedMemoryTransport<_, _>> = /* ... */;
tx.send(&TemperatureSensorData { temp: 26.9, time_elapsed: 6 }).await?;

// Rx side (A-core)
let mut rx: Channel<Rx, TemperatureSensorData, SharedMemoryTransport<_, _>> = /* ... */;
let msg = rx.recv().await?;
}

Type parameters:

ParameterRoleExample
DDirection: Tx or RxCompile-time guard—can’t call recv() on a Tx channel
TMessage typeTemperatureSensorData
TrTransportSharedMemoryTransport<HsemDoorbell, DdrRegion>
CCodecPostcardCodec (default)

Zero-allocation contract:

Channel never calls the allocator. Callers provide a &'static mut [u8] scratch buffer sized to transport.max_message_size(). The buffer is reused across every send and recv.

#![allow(unused)]
fn main() {
static mut BUF: [u8; 256] = [0u8; 256];
let tx = Channel::<Tx, SensorData, _>::new(chan, transport, unsafe { &mut BUF });
}

ReceivedMessage<'a, T, C>:

recv() returns ReceivedMessage rather than T directly. The lifetime 'a ties the decoded value to the scratch buffer. For PostcardCodec, T is copied off the wire and 'a is unused but structurally present. For future zero-copy codecs (e.g., rkyv), Decoded<'a, T> = &'a T::Archived—the returned reference directly into the buffer, and the borrow checker prevents the buffer from being reused until the handle is dropped.

Error type:

#![allow(unused)]
fn main() {
enum ChannelError<C: Codec, Tr: Transport> {
    Codec(C::Error),        // encode/decode failed
    Transport(Tr::Error),   // underlying send/recv failed
}
}

The ? operator propagates either variant.


Layer 2: Codec — Type ↔ Bytes

The Codec trait decouples serialization format from transport.

#![allow(unused)]
fn main() {
pub trait Codec {
    type Decoded<'buf, T: 'buf>;
    type Error: Debug;

    fn encode<T: Serialize>(msg: &T, buf: &mut [u8]) -> Result<usize, Self::Error>;
    fn decode<'buf, T: DeserializeOwned>(buf: &'buf [u8]) -> Result<Self::Decoded<'buf, T>, Self::Error>;
}
}

Default: PostcardCodec

Uses the postcard crate. Compact binary, no_std compatible, zero-copy-optional. A TemperatureSensorData { temp: 26.9, time_elapsed: 6 } encodes to ~10 bytes.

Future: zero-copy codec

A codec backed by rkyv would set Decoded<'buf, T> = &'buf T::Archived. The borrow lives in the scratch buffer. No copy happens on receive—the decoded reference points directly into shared memory. This is opt-in; the channel’s type signature changes and the borrow checker enforces it automatically.


Layer 3: Transport — Byte Mover

Transport moves raw byte slices across the processor boundary. It knows about channels (addressed by Chan(u8)) and owns a Doorbell internally.

#![allow(unused)]
fn main() {
pub trait Transport {
    type Error;
    type SendFut<'a>: Future<Output = Result<(), Self::Error>>;
    type RecvFut<'a>: Future<Output = Result<usize, Self::Error>>;

    fn send<'a>(&'a mut self, ch: Chan, data: &[u8]) -> Self::SendFut<'a>;
    fn recv<'a>(&'a mut self, ch: Chan, buf: &'a mut [u8]) -> Self::RecvFut<'a>;
    fn max_message_size(&self) -> usize;
}
}

Transport has two implementations for the two sides of an HMP SoC.

SharedMemoryTransport (M-core / UIO-backed paths)

Used on Cortex-M. No OS, no allocator, no async runtime beyond cooperative polling.

SharedMemoryTransport<D: Doorbell, R: Region> uses descriptor-backed slots in DMA-accessible shared memory. The doorbell and region types are injected at construction so the same transport compiles for HSEM, IPCC, MU, GPIO, or any platform-specific mechanism.

Send path:

  1. Write payload into tx slot via volatile stores.
  2. region.flush() — issues dc cvac + dsb sy for each cache line if DDR; noop if SRAM.
  3. Write len then FLAG_VALID into slot header (volatile, ordered).
  4. doorbell.ring(ch) — notifies A-core.
  5. Return immediately. SendFut resolves on the first poll.

Receive path:

  1. region.invalidate() — issues dc ivac + dsb sy to discard stale cache lines.
  2. Check FLAG_VALID on rx slot (volatile read).
  3. If present: copy payload, write FLAG_CONSUMED, return.
  4. If not: poll doorbell.wait(ch) to register waker; return Pending.
  5. When M→A IRQ fires, waker reschedules task; next poll retries from step 1.

Runtime-App UIO Path (A-core, Linux/Tokio)

Used on Cortex-A running Linux. The transport is backed by a UIO (/dev/uioN) kernel driver that mmaps shared memory and delivers M-core interrupts as readable file events.

UioDevice::open(uio_num, num_channels, transfer_size):

  1. Opens /dev/uio0, mmaps resource 0 (shared SRAM/DDR region).
  2. Parses a binary descriptor from offset 0 (magic 0x434F4E53, version 1). Fails if state ≠ Ready.
  3. Spawns a background Tokio task: waits on AsyncFd::readable() for M→A IRQs, then broadcasts irq_notify.notify_waiters().

uio.channel_transport(chan, doorbell, region) returns a SharedMemoryTransport backed by the parsed descriptor and the UIO mmap.

Send path (A→M):

  1. Check if tx slot is still consumed (previous message was processed).
  2. If not: subscribe to irq_notify, await it, retry.
  3. If consumed: write payload into slot, set FLAG_VALID. (Kernel/hardware delivers the interrupt; UIO’s MU TX register write triggers M-core IRQ.)

Receive path (M→A):

  1. Try-read rx slot (same FLAG_VALID check as SharedMemoryTransport).
  2. If data present: copy payload, mark FLAG_CONSUMED, return.
  3. If not: subscribe to irq_notify, await it.
  4. Background task fires on each M→A interrupt, waking all waiting receivers.

Layer 4: Slot Protocol — The Shared Contract

The slot is the fundamental unit of shared memory between M-core and A-core.

Offset  Size  Field    Description
0x00    4     len      Payload byte count (volatile u32)
0x04    4     flags    FLAG_VALID (0x01) | FLAG_CONSUMED (0x02) (volatile u32)
0x08    mtu   payload  Raw message bytes

State machine:

Free ─────────────(sender writes)──────────> Pending
                  len = N
                  copy payload
                  flags = FLAG_VALID

Pending ──────────(receiver reads)─────────> Consumed
                  check FLAG_VALID
                  copy payload
                  flags = FLAG_CONSUMED

Consumed ─────────(sender recycles)────────> Free
                  (checks FLAG_CONSUMED
                   before next write)

Why volatile? Both cores access this memory. The compiler must not cache reads or coalesce writes. Volatile loads/stores prevent that at the language level; cache operations at the hardware level (see Region).

Write ordering: len is written before flags. A receiver that observes FLAG_VALID is guaranteed to see the correct len.


Layer 5: Doorbell — Pure Signaling

The Doorbell trait abstracts the interrupt-signaling primitive. It carries no data—it only tells the other core “something is ready.”

#![allow(unused)]
fn main() {
pub trait Doorbell {
    type Error;
    type WaitFut<'a>: Future<Output = Result<Chan, Self::Error>>;

    fn ring(&self, ch: Chan) -> Result<(), Self::Error>;  // synchronous notify
    fn wait<'a>(&self, ch: Chan) -> Self::WaitFut<'a>;   // async wait
    fn pending(&self, ch: Chan) -> bool;                  // non-blocking check
}
}

Chan(u8) maps to whatever the platform uses: an HSEM semaphore ID, an IPCC channel, an MU transmit register index, a GPIO line number, a serial byte, or a Tokio Notify slot in unit tests.

SharedMemoryTransport owns one Doorbell. Application code never calls ring() or wait() directly—Transport manages that internally.


Layer 6: Region — Memory Backing & Cache

The Region trait describes a contiguous piece of memory, including its cache coherency properties.

#![allow(unused)]
fn main() {
pub trait Region {
    unsafe fn as_ptr(&self) -> *mut u8;
    fn len(&self) -> usize;
    fn flush(&self);       // clean dirty lines to PoC
    fn invalidate(&self);  // discard stale lines
    fn is_coherent(&self) -> bool;
}
}
TypeBackingflush / invalidateis_coherentUse case
SramRegionOn-chip SRAMNoopstrueM-core-local buffers
DdrRegionDDR DRAMAArch64 dc cvac/dc ivac + dsb syfalseCross-core shared buffers
SerialRegionNone (virtual)NoopstrueUART/SWD trace channels

When SharedMemoryTransport is monomorphized over SramRegion, the cache operations compile away entirely. When monomorphized over DdrRegion, they emit the correct data cache maintenance for that region implementation.


Descriptor Lifecycle (UIO)

Before any application can open a UioDevice, the shared-memory descriptor must have been negotiated between the kernel and M-core.

KernelInit (0)
    │  kernel writes magic, version, proposed MTU
    ▼
Proposed (1)
    │  M-core reads proposed MTU, writes accepted MTU
    ▼
MReady (2)
    │  kernel locks in effective MTU, writes channel table
    ▼
Ready (3)  ← UioDevice::open() requires this state
    │
    ├──> Suspended (4)   firmware update / sleep
    └──> Error (5)       fatal condition

The effective_mtu in the descriptor drives Transport::max_message_size(), which in turn determines the minimum scratch buffer size callers must provide to Channel.


Platform Targets

CoreTransportDoorbellRegionAsync runtime
Cortex-M (STM32MP21)SharedMemoryTransportIPCCSramRegion / DdrRegioncooperative executor or RTIC
Cortex-M (STM32MP23/25)SharedMemoryTransportIPCC preferred; HSEM staleSramRegion / DdrRegioncooperative executor or RTIC
Cortex-M (i.MX 95)SharedMemoryTransportMUSramRegion / DdrRegioncooperative executor
Cortex-A (Linux)runtime-app UIO pathLinux UIO + tokio::sync::Notifymmap’d via UIO driverTokio

The same Channel<D, T, Tr, C> API compiles for all rows. Only the concrete types in Tr differ.


no_std Compatibility

consortium-ipc (traits and Channel) is no_std by default.

FeatureEffect
(none)Pure no_std; MaybeSend has no bounds
stdAdds Send bound on futures via MaybeSend: Send
allocEnables allocator support in serde/postcard

consortium-mem inherits no_std. consortium-uio requires std + Tokio and is Linux-only.


Summary

The vision is a zero-cost, zero-allocation, fully typed async IPC layer where the application developer writes:

#![allow(unused)]
fn main() {
tx.send(TemperatureSensorData { temp: 26.9, time_elapsed: 6 }).await?;
let data = rx.recv().await?;
}

…and the system handles serialization (Codec), byte movement (Transport), shared memory slot management (slot protocol), cache coherency (Region), and inter-core signaling (Doorbell)—invisibly, correctly, and without ever calling malloc.

Consortium IPC Implementation Status

Date: 2026-04-05 Current Focus: Hardware-specific implementations and runtime layers


Executive Summary

The core IPC abstractions (traits, Channel, Transport, codec) are complete and tested. The high-level architecture (design documents, TEE proc macros with advanced type support) is well-defined.

What remains is hardware-specific glue and the application runtime layer to tie everything together into a deployable system.


Completed ✅

Core IPC Layer (consortium-ipc)

  • Traits: Doorbell, Region, Transport, Codec, Channel
  • Channel API: Full typed, allocation-free async sender/receiver
  • Error handling: ChannelError<C, Tr> distinguishing codec vs transport failures
  • Features: std, alloc, futures gates; no_std by default
  • Testing: Unit tests for trait contracts

Transport Implementations

  • consortium-ipc-transport-memory (bare-metal/no_std):

    • SharedMemoryTransport<D: Doorbell, R: Region> generic over doorbell and region types
    • Slot protocol (len/flags/payload) with volatile I/O
    • SendFut (eager, synchronous doorbell.ring)
    • RecvFut (polling with async doorbell.wait)
    • ~225 LOC, production-ready
  • consortium-runtime-app UIO support (Linux/Tokio):

    • UioDevice::open(uio_num) parsing binary descriptor
    • Channel multiplexing via uio.channel(id)
    • Background Tokio task for M→A interrupt handling
    • Descriptor lifecycle state machine (KernelInitReady)
    • ~320 LOC, Linux-only, tested

Memory Region Implementations (consortium-ipc sub-modules)

  • SramRegion: Cache-coherent, no-op flush/invalidate
  • DdrRegion: Non-coherent, AArch64 dc cvac/dc ivac + dsb sy
  • SerialRegion: Virtual region for UART/SWD logging

Codec

  • Default: PostcardCodec via postcard crate (serde)
  • Compact binary, no_std compatible
  • GAT-based Decoded<'buf, T> for zero-copy future support

TEE Support (consortium-tee + consortium-tee-macros)

  • Error types: TeeError, TeeErrorOrigin, TeeResult with is_transient()
  • tee_service! macro: Generates Command enum + dispatcher
  • #[tee_command] macro: Advanced type support including:
    • Input types: bool, u32, &[u8], &mut [u8], generics with Serialize/Deserialize
    • Return types: u32, u64, bool, Vec<u8>
    • Context passing (first &mut arg, excluded from TEE slots)
    • CA-side call_* stubs (#[cfg(feature = "ca")])
    • TA-side *_dispatched handlers with full parameter unpacking
  • ~929 LOC of sophisticated proc macros

Configuration (consortium-cfg-common)

  • Config: Top-level with general + project sections
  • Chip enum: STM32MP2x, i.MX 9x families with traits (has_rt_core(), remoteproc_name(), etc.)
  • CortexMcuCore enum: M0p, M33, M7, M4/M4f with Rust target mapping

Application Runtime (consortium-runtime-app)

  • RemoteProc sysfs wrapper:
    • Index-based constructor mapping to /sys/class/remoteproc/remoteprocN
    • State reader: Offline, Suspended, Running, Crashed, Deleted, Attached, Detached
    • Controls: start(), stop(), set_firmware(), set_coredump(), set_recovery()
    • RemoteProcCoredump enum: Enabled, Disabled, Inline

Hardware Doorbells

  • HSEM (consortium-ipc-doorbell-hsem): stale STM32MP23/25 hardware semaphore support retained for experiments and existing users. Prefer IPCC for new STM32MP2 work: IPCC is available on STM32MP21/23/25, while HSEM is limited to STM32MP23/25, and HSEM has produced surprising access failures even when clocks and RIF assignment appeared correct.
  • IPCC (consortium-ipc-doorbell-ipcc): STM32MP21/23/25 IPCC support with no_std and std paths, typed bitfield register wrappers, and IpccChanValidate. STM32MP21 uses IPCC only. STM32MP23/25 should prefer IPCC over the stale HSEM path. IPCC1 is 0x4049_0000..=0x4049_03FF; SmartRun IPCC2 is 0x4625_0000..=0x4625_03FF on STM32MP23/25 only.
  • MU (consortium-ipc-doorbell-mu): NXP i.MX Message Unit support with no_std and std paths, typed bitfield register wrappers, and MuChanValidate.

Documentation

  • ipc-design.md: Complete IPC architecture (traits, layers, slot protocol, descriptor lifecycle)
  • tee-sdk-design.md: OP-TEE Trusted Application SDK design
  • tee-advanced-type-system.md: Proc macro advanced type support

Partial / In Progress 🟡

Build System (consortium-builder)

Status: Minimal stub, ~19 LOC Planned:

  • ApplicationBuilder for cross-compilation (Cortex-A35 / OpenSTLinux SDK)
  • FirmwareBuilder for firmware binary compilation (M-core targets)
  • Environment variable handling (SDKTARGETSYSROOT, OECORE_*, etc.)
  • ELF magic verification post-build
  • Release mode support

What’s Missing:

  • SDK environment detection
  • Cargo invocation with proper flags
  • Binary name extraction from Cargo.toml
  • Target selection based on CortexMcuCore

CLI Tool (consortium-cli)

Status: Stub, no implementation Planned: Debug tooling for IPC monitoring, state inspection


Doorbell Implementations ✅

⚠️ Stale: HSEM (Hardware Semaphore) for STM32MP23/STM32MP25

HSEM is implemented in consortium-ipc-doorbell-hsem, but it is stale for new STM32MP2 designs. Prefer consortium-ipc-doorbell-ipcc: IPCC has formal support across STM32MP21/23/25, while HSEM exists only on STM32MP23/25 and has shown surprising behavior such as bus errors on reads even after the clock and RIF configuration appeared valid. The HSEM crate currently includes:

  • Bare-metal M-core support (no_std + AtomicWaker)
  • Linux A-core support (std + tokio::sync::Notify)
  • Channel validation and error handling (16 channels max)
  • Const generic PROC: u8 for selecting interrupt line (C1/C2/C3)

✅ Completed: IPCC (Inter-processor Communication Controller) for STM32MP21/23/25

IPCC is implemented in consortium-ipc-doorbell-ipcc with:

  • Bare-metal M-core support (no_std + AtomicWaker)
  • Linux A-core support (std + tokio::sync::Notify)
  • Channel validation and error handling (16 channels, 0-1 reserved)
  • Const generic PROC: u8 for selecting processor 1 or processor 2 register bank
  • IPCC1 base constants for STM32MP21/23/25 and IPCC2 SmartRun constants gated to STM32MP23/25
  • bitfield register wrappers for control, channel status/mask, and status set/clear registers

✅ Completed: MU (Message Unit) for i.MX 9x Series

MU is fully implemented in consortium-ipc-doorbell-mu with:

  • Bare-metal M-core support (no_std + one AtomicWaker per selected MU instance)
  • Linux A-core support (std + tokio::sync::Notify)
  • Channel validation via MuChanValidate (MU_INSTANCE_COUNT * 4 flat channels; 0–1 reserved)
  • Chip features for imx8mp (1 MU), imx93 (2 MUs), and imx95 (8 MUs)
  • Side features for mua and mub; the crate default is imx95 plus mua
  • IRQ handlers gated by chip feature (MU1_IRQHandler through MU8_IRQHandler on i.MX95)
  • i.MX95 CA55 <-> CM7 defaults use MU7 channels 24 and 25 (IMX95_CA55_CM7_TX_CHAN and IMX95_CA55_CM7_RX_CHAN)
  • Flexible base address configuration (no const generic needed)
  • GI signaling uses GCR[GIRn], pending status uses GSR[GIPn], and clear is W1C through GSR[GIPn]

Platform support: i.MX 93, i.MX 95, and other NXP i.MX 9x SoCs

Status: Hardware doorbell implementations are available for the current target families.

  • STM32MP21: IPCC only ✅
  • STM32MP23/25: IPCC preferred; HSEM stale ⚠️
  • i.MX 9x: MU only ✅

Reference: Doorbell Implementation Pattern

Existing implementations (HsemDoorbell, IpccDoorbell, and MuDoorbell) follow this structure:

  1. Trait impl Doorbell for XyzDoorbell:

    • ring() — signal remote core (register write, semaphore lock, etc.)
    • wait() → returns WaitFut future with state machine
    • pending() — check if signal pending (without blocking)
  2. Dual-path design (no_std + std):

    • no_std: Static AtomicWaker per instance + IRQ handler
    • std: Arc<tokio::sync::Notify> + external notifier integration
  3. Channel validation: Implement ChanValidate trait to enforce valid channel ranges

  4. Constructor patterns:

    • HSEM: Stale STM32MP23/25 path with const generic PROC: u8 for processor-specific interrupt lines
    • IPCC: Const generic PROC: u8; no_std ipcc1() is available on STM32MP21/23/25 and ipcc2() only on STM32MP23/25
    • MU: Flexible base address configuration (no const generic needed)

See consortium-ipc-doorbell-hsem, consortium-ipc-doorbell-ipcc, and consortium-ipc-doorbell-mu for complete working examples.

Application Runtime Layer (consortium-runtime-mcu)

Status: Stub, just a dummy add() function Critical gaps:

  1. Firmware execution harness (M-core):

    • Bootstrap code (stack setup, relocation, initialization)
    • Async executor (cooperative or RTIC-based)
    • Interrupt handler registration
    • Integration with SharedMemoryTransport for IPC
    • Reset/panic handlers
    • Example: minimal firmware that spins up a Channel and sends data
    • Current design: use Embassy for async runtime with no_std support. When the project is targeting wider market, try to support Zephyr RTOS as well, which is widely used in embedded space and has good support for STM32 and NXP platforms.
  2. Application harness (A-core Linux):

    • RemoteProc lifecycle management
    • Firmware loading and start
    • Error recovery and logging
    • Integration with UioDevice for IPC
    • Graceful shutdown
    • Example: minimal app that opens UIO, creates Channel, receives firmware messages
  3. Glue code:

    • SharedMemory descriptor initialization (kernel-side or M-core-side?)
    • MTU negotiation
    • Channel table setup

Full Compilation & Deployment Pipeline

The user specified an 9-step pipeline:

1. Meta crate: Config → device tree + PAC
2. Compile TF-M (NS-M) + bundle into firmware ELF per remoteproc
3. Compile TF-A (OP-TEE) + optionally sign
4. Compile HMI web interface (Vite) + bundle into app
5. Quantize ONNX models + bundle into app
6. Compile application
7. Generate config files (systemd, supervisor, nginx, etc.)
8. Attach installer script
9. Bundle into .tar.gz

Status: Not started. Requires:

  • Device tree generation tooling (from Config)
  • Build orchestration (Cargo build scripts or separate build crate)
  • Signing workflow (cargo-optee integration or custom)
  • Web asset bundling (Vite integration)
  • Systemd/supervisor config generation
  • Installer script templates

Distributed Testing

No current test harness for:

  • Two-core IPC over actual shared memory
  • Interrupt signaling between cores
  • Cache coherency on real hardware
  • Actual remoteproc firmware loading

Crate Mapping (Current)

NameStatusDescription
consortium-ipc✅ Core traitsDoorbell, Region, Transport, Channel, Codec
consortium-ipc-transport-memory✅ Shared-memory transportGeneric over doorbell/region; supports no_std and std feature paths
consortium-runtime-app UIO support🟡 Linux UIO runtimeUioDevice integration via Tokio
consortium-ipc-doorbell-hsem⚠️ Stale HSEM doorbellSTM32MP23/25 only; prefer IPCC for STM32MP2
consortium-ipc-doorbell-ipcc✅ IPCC doorbellIPCC for STM32MP21/23/25
consortium-ipc-doorbell-mu✅ MU doorbellMessage Unit for i.MX 9x series
consortium-runtime-app🟡 A-core runtimeRemoteProc sysfs wrapper (requires expansion)
consortium-runtime-mcu❌ M-core runtimeStub (requires bootstrap + executor)
consortium-builder🟡 Build systemMinimal (requires cross-compilation helpers)
consortium-cfg-common✅ ConfigurationConfig, Chip, CortexMcuCore types
consortium-tee✅ TEE supportTeeError, TeeResult types
consortium-tee-macros✅ TEE macrostee_service!, #[tee_command] with advanced types
consortium-cli❌ Debug toolStub (requires IPC monitoring)
consortium-hmi❌ HMI libraryStub (web-based GUI integration)
consortium-ffi❌ FFI bindingsStub (cross-language interop)
consortium-ort✅ ONNX RuntimeWrapper for embedded ONNX model inference

Dependency Graph

Application (user code)
├── consortium-runtime-app
│   ├── consortium-ipc-transport-memory (std)
│   │   ├── consortium-ipc (with std feature)
│   │   └── consortium-ipc-doorbell-hsem / consortium-ipc-doorbell-ipcc / consortium-ipc-doorbell-mu
│   └── consortium-tee (for CA-side calls)
│
└── Firmware (user code)
    └── consortium-runtime-mcu
        └── consortium-ipc-transport-memory (no_std)
            ├── consortium-ipc (with no_std)
            └── consortium-ipc-doorbell-hsem / consortium-ipc-doorbell-ipcc / consortium-ipc-doorbell-mu

Testing Status

ComponentTestsNotes
consortium-ipc✅ Trait testsBasic contract verification
consortium-ipc-transport-memory✅ Transport testsSlot protocol, descriptor parsing, doorbell mocking
consortium-runtime-app UIO⚠️ PartialLinux UIO wrapper exists; no real device tests
consortium-tee-macros✅ Macro expansionCompile-time correctness
consortium-ipc-doorbell-hsem✅ HSEM testsRegister access, async wakeup, channel validation
consortium-ipc-doorbell-ipcc✅ IPCC testsRegister bitfields, channel validation, pending/wait clear behavior
consortium-ipc-doorbell-mu✅ MU testsChannel mapping plus fake-MMIO std wait-future tests
Runtime (M-core)❌ NoneNot implemented
Runtime (A-core)⚠️ PartialRemoteProc methods exist but not tested
E2E (firmware ↔ app)❌ NoneWould require actual hardware or simulation

⚠️ Phase 0: HSEM Doorbell (STALE)

  1. HSEM Doorbell (consortium-ipc-driver-hsem)
    • Register-based implementation with no_std and std variants
    • Tests for async wakeup and channel validation
    • Platform support for STM32MP23/25 only; prefer IPCC for new STM32MP2 work

Phase 1: M-Core & A-Core Runtime (using IPCC on STM32MP2)

  1. M-core bootstrap (consortium-runtime-mcu/src/lib.rs)

    • Stack/BSS setup
    • Simple polling executor or RTIC integration
    • MemTransport initialization with IpccDoorbell on STM32MP2
    • Example firmware that sends a value per IRQ
  2. A-core app skeleton (consortium-runtime-app/src/lib.rs expansion)

    • RemoteProc state machine for lifecycle management
    • UioDevice integration with IPCC notifications
    • Firmware ELF loading
    • Simple recv loop
  3. Integration test (STM32MP21/23/25 with IPCC)

    • Load firmware on M-core
    • Open UioDevice on A-core
    • Exchange messages via IPCC signaling
    • Verify correctness on real hardware

Phase 2: NXP i.MX 9x Port (MU Doorbell)

  1. Implement MU Doorbell for i.MX 93, i.MX 95, etc., following HSEM pattern
  2. Extend M-core and A-core runtimes for NXP platform
  3. Test cross-core coordination on NXP hardware

Phase 3: Build Pipeline

  1. Implement consortium-builder for firmware compilation
  2. Wire up device tree generation
  3. Add OP-TEE compilation
  4. Package everything into .tar.gz

Phase 4: Polish & Documentation

  1. Add hardware-specific examples (HSEM, IPCC, MU)
  2. Write integration test suites
  3. Document chip selection and features
  4. Update CLAUDE.md ← Done

CLAUDE.md Update Required

The CLAUDE.md file references outdated crate names and needs immediate updates:

Changes: Crate names have been updated throughout all docs:

  • consortium-ipc-memconsortium-ipc-transport-memory
  • consortium-ipc-uio → runtime-app UIO support using consortium-ipc-transport-memory
  • consortium-ipc-driver-hsemconsortium-ipc-doorbell-hsem
  • consortium-ipc-driver-muconsortium-ipc-doorbell-mu
  • consortium-runtime-rtconsortium-runtime-mcu

Summary: What’s Left

LayerStatusEffortDependency
Traits + Channel✅ Done
SharedMemoryTransport (bare-metal)✅ DoneDoorbell trait
runtime-app UIO path (Linux)⚠️ PartialKernel UIO driver
Doorbell: HSEM (STM32MP23/25)✅ Done
Doorbell: IPCC (STM32MP21/23/25)✅ Done
Doorbell: MU (i.MX 9x series)✅ Done
M-core runtime + executor (STM32MP2)❌ TODOHighconsortium-runtime-mcu
A-core runtime + lifecycle (STM32MP2)❌ TODOHighRemoteProc + UIO expansion
Build pipeline (8-step)❌ TODOHighAll prior phases
TEE support (macros)✅ Done
Configuration types✅ Done

Progress: Both primary doorbell implementations are complete. Framework is ready for runtime layer development.

Total estimated effort for M-core + A-core runtime (STM32MP2 with IPCC): 2–3 weeks (doorbell reference patterns established).

Total estimated effort for NXP i.MX 9x port (M-core + A-core runtimes + build pipeline): 3–4 weeks (MU doorbell complete; runtimes follow STM32MP2 pattern).

Doorbell Register Model Comparison

HSEM is stale for new STM32MP2 work. Prefer IPCC where possible: IPCC is present across STM32MP21/23/25 and follows ST’s better supported inter-processor communication path, while HSEM exists only on STM32MP23/25 and has produced surprising hardware access failures in practice.

AspectHSEMIPCCMU (GI)
PlatformSTM32MP23/25STM32MP21/23/25NXP i.MX 9x
Signal mechanism1-step read-lock then unlock semaphoreSet CxSCR.CHnS to mark a channel occupiedSet GCR[GIRn]
Backpressure guardLOCKID ownership check; can report AlreadyLockedOutgoing CxTOCySR.CHnF; returns ChannelOccupied while occupiedNo guard; setting GIRn while pending is idempotent
Receive statusCnMISR masked interrupt statusOpposite direction CyTOCxSR.CHnFGSR[GIPn]
Interrupt enableCnIER bit per semaphoreCxCR.RXOIE plus CxMR.CHnOM unmaskGIER[GIEn]
Interrupt clearDedicated CnICR W1C bitCxSCR.CHnC W1C-style clearGSR[GIPn] W1C
Const genericPROC: u8 selects C1/C2/C3 interrupt bankPROC: u8 selects processor 1 or 2 register bankNone; base array and channel mapping select instance
IRQ wakeupSingle HSEM_IRQHandlerBoard/PAC calls notify_rx_occupied_interrupt() from the verified IPCC RX occupied lineMU1_IRQHandlerMU8_IRQHandler by instance
Channel count16 flat semaphore IDs; 0-1 reserved16 channels per IPCC instance; 0-1 reservedMU_INSTANCE_COUNT * 4; 0-1 reserved
Register stylebitfield register wrappers + volatile accessbitfield register wrappers + volatile accessbitfield register wrappers + volatile access

MU notes:

  • Chip features select the MU instance count: imx8mp = 1, imx93 = 2, imx95 = 8.
  • Side features select register view: mua for A-core and mub for M-core.
  • On current i.MX95 CA55 <-> CM7 defaults, use MU7 flat channels 24 and 25 (IMX95_CA55_CM7_TX_CHAN / IMX95_CA55_CM7_RX_CHAN).

IPCC address map:

FamilyIPCC1IPCC2
STM32MP210x4049_0000..=0x4049_03FFnot present
STM32MP23/250x4049_0000..=0x4049_03FFSmartRun 0x4625_0000..=0x4625_03FF

Runtime Initialization Guide

This document describes how to initialize and use the consortium-runtime-mcu and consortium-runtime-app crates for STM32MP2 (IPCC preferred on MP21/23/25; the stale HSEM path exists only on MP23/25) and i.MX 9x (MU) platforms.

Architecture Overview

The consortium runtime system requires coordination between M-core and A-core:

M-core                           A-core
------                           ------
1. Enable IRQs                   1. (optional) Start firmware
2. Create doorbell               2. Poll descriptor until Ready
3. Write descriptor + set MReady 3. Open UIO device
4. Construct Transport/Channel   4. Construct Transport/Channel
5. Run executor + tasks          5. Run Tokio + tasks

M-Core (consortium-runtime-mcu)

Setup: Interrupt and Descriptor Initialization

The snippet below uses the stale HSEM path. Prefer IPCC for new STM32MP2 firmware; retain HSEM only for existing STM32MP23/25 experiments.

#![allow(unused)]
fn main() {
use consortium_runtime_mcu::{consortium_ipc_transport_memory::descriptor, irq};
use consortium_ipc_transport_memory::transport::SharedMemoryTransport;
use consortium_ipc_doorbell_hsem::HsemDoorbell;

// 1. Enable the HSEM IRQ (must be called before doorbell.wait())
unsafe { irq::enable_doorbell_interrupt() };

// 2. Create the doorbell (zero-cost)
let doorbell = HsemDoorbell::<1>::new();

// 3. Write the descriptor header to shared memory
// SAFETY: region_base must point to the shared DMA-coherent region
unsafe {
    let channels = [
        descriptor::ChannelLayout {
            offset: 0x100,
            size: 0x208,    // 8-byte header + 512 bytes payload
            direction: descriptor::ChannelDirection::AToM,
        },
    ];
    descriptor::descriptor_init(
        region_base,
        1,                      // num_channels
        512,                    // transfer size
        &channels,
    );

    // Signal M-core readiness to A-core
    descriptor::set_state(region_base, descriptor::DescState::MReady);
}

// 4. Construct transport and channel
let region = SramRegion::new(region_base, region_size);
let transport = unsafe {
    SharedMemoryTransport::new(
        chan,
        doorbell,
        region,
        region_base,
    )
};

// 5. Declare and use static buffers
static_buf!(RX_BUF, 512);
let rx_buf = RX_BUF.init([0u8; 512]);
let rx_ch = Channel::new(chan, transport, rx_buf);

// 6. Run your async executor (Embassy, RTIC2, custom)
// Example with Embassy:
#[embassy_executor::task]
async fn ipc_receiver(ch: Channel<Rx, MyMessage, _>) {
    loop {
        match ch.recv().await {
            Ok(msg) => {
                // Process message
            }
            Err(e) => {
                // Handle error
            }
        }
    }
}
}

Key Components

  • descriptor::descriptor_init() — Initializes the shared descriptor header
  • descriptor::set_state(base, state) — Transitions descriptor state (e.g., MReady → Ready)
  • irq::enable_doorbell_interrupt() — Enables the currently selected HSEM/MU doorbell interrupt when the IRQ number is verified in runtime code. Prefer IPCC on STM32MP2; IPCC IRQ numbers/routing remain board/PAC-provided.
  • slot_pair_ptrs(base, idx, size) — Computes raw slot pointers for low-level setup
  • static_buf!(NAME, SIZE) — Declares properly aligned static buffers

Features

  • stm32mp21x — Enables IPCC doorbell support
  • stm32mp23x / stm32mp25x — Enables IPCC and stale HSEM doorbell support; prefer IPCC
  • imx93 / imx95 — Enables MU doorbell support (i.MX variants)
  • imx9x — Umbrella feature (requires one of the above)

On i.MX95, the current CA55 <-> CM7 IPC default is MU7 with flat channel IDs 24 and 25 (IMX95_CA55_CM7_TX_CHAN / IMX95_CA55_CM7_RX_CHAN). MU1-4 connect CA55 <-> CM33, MU5 connects CM7 <-> CM33, MU6 connects NONE <-> CM33, and MU7-8 connect CA55 <-> CM7.

STM32MP21/23/25 expose IPCC1 at 0x4049_0000..=0x4049_03FF. STM32MP23/25 also expose the SmartRun IPCC2 block at 0x4625_0000..=0x4625_03FF; STM32MP21 does not have IPCC2.

A-Core (consortium-runtime-app)

Setup: Firmware Lifecycle and UIO Device

use consortium_runtime_app::{
    mmap, remoteproc::{RemoteProc, RemoteProcState}, uio::UioDevice,
};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. (Optional) Start the M-core firmware
    let proc = RemoteProc::new(0);
    proc.set_firmware("firmware.elf")?;
    proc.start()?;

    // Wait for firmware to boot and transition the descriptor to Ready
    proc.wait_state(RemoteProcState::Running, Duration::from_secs(5)).await?;

    // 2. Poll descriptor until M-core signals Ready(3)
    mmap::wait_for_ready(0, Duration::from_secs(10)).await?;

    // 3. Open the UIO device
    let dev = UioDevice::open(0, 2, 512)?;

    // 4. Get a channel and construct the IPC Channel
    static RX_BUF: static_cell::StaticCell<[u8; 512]> =
        static_cell::StaticCell::new([0u8; 512]);

    let chan = Chan::new::<HsemChanValidate>(2)?;
    let transport = dev.channel_transport(chan, doorbell, region)?;
    let rx_buf = RX_BUF.init([0u8; 512]);
    let rx: Channel<Rx, MyMessage, _> = Channel::new(chan, transport, rx_buf);

    // 5. Spawn receiver task
    tokio::spawn(async move {
        loop {
            match rx.recv().await {
                Ok(msg) => println!("Received: {:?}", msg),
                Err(e) => eprintln!("Recv error: {}", e),
            }
        }
    });

    // Keep main task alive
    tokio::signal::ctrl_c().await?;
    Ok(())
}

UIO Transport Construction

  • UioDevice::open(uio_num, num_channels, transfer_size) — Opens UIO, mmaps resource 0, parses the descriptor, and starts the IRQ listener task.
  • UioDevice::channel_transport(chan, doorbell, region) — Creates a SharedMemoryTransport for one logical channel.

State Polling

  • mmap::wait_for_ready(uio_num, timeout) — Polls descriptor until Ready(3)

    • Returns early if descriptor enters Error or Suspended state
    • Mmaps UIO resource 0 and polls at 50ms intervals
  • RemoteProc::wait_state(target, timeout) — Polls remoteproc sysfs state

    • Example: proc.wait_state(RemoteProcState::Running, Duration::from_secs(5))

Shared Memory Layout

The descriptor occupies the first 0x20 + 16×N bytes of the shared region:

Offset  Size  Field
0x00    4     magic           (0x434F_4E53 = "CONS")
0x04    1     version         (1)
0x05    1     num_channels
0x06    2     reserved
0x08    4     proposed_mtu    (kernel/A-core → M-core)
0x0C    4     accepted_mtu    (M-core writes back)
0x10    4     effective_mtu   (final agreed MTU)
0x14    4     state           (DescState enum)
0x18    8     reserved
0x20    16×N  channel table   (tx_offset, tx_size, rx_offset, rx_size per channel)

Slot layout (at each offset):

0x00    4     length (payload bytes)
0x04    4     flags (0x01 = VALID, 0x02 = CONSUMED)
0x08    N     payload (up to effective_mtu bytes)

Stale Example: STM32MP25 with HSEM

Prefer IPCC for new STM32MP2 integrations. This HSEM example remains as a reference for existing STM32MP23/25 experiments only.

M-Core Firmware (consortium-runtime-mcu)

#![no_std]

use consortium_runtime_mcu::{consortium_ipc_transport_memory::descriptor, irq};
use consortium_ipc_transport_memory::transport::SharedMemoryTransport;
use consortium_ipc_doorbell_hsem::HsemDoorbell;
use embassy_executor::Spawner;

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    irq::enable_doorbell_interrupt();

    let doorbell = HsemDoorbell::<1>::new();
    let region = SramRegion::new(SHARED_MEMORY_BASE, SHARED_MEMORY_SIZE);

    unsafe {
        let channels = [descriptor::ChannelLayout {
            offset: 0x100,
            size: 0x208,
            direction: descriptor::ChannelDirection::AToM,
        }];
        descriptor::descriptor_init(
            SHARED_MEMORY_BASE,
            1,
            512,
            &channels,
        );
        descriptor::set_state(SHARED_MEMORY_BASE, descriptor::DescState::MReady);
    }

    let transport = unsafe {
        SharedMemoryTransport::new(
            Chan::new(2).unwrap(),
            doorbell,
            region,
            SHARED_MEMORY_BASE,
        )
    };

    static_buf!(BUF, 512);
    let buf = BUF.init([0u8; 512]);
    let ch = Channel::new(Chan::new(2).unwrap(), transport, buf);

    spawner.spawn(receiver(ch)).unwrap();
}

#[embassy_executor::task]
async fn receiver(ch: Channel<Rx, u32, _>) {
    loop {
        match ch.recv().await {
            Ok(msg) => {
                // Echo the message back
            }
            Err(e) => {
                // Log error
            }
        }
    }
}

A-Core Application (consortium-runtime-app)

use consortium_runtime_app::{mmap, remoteproc::*, uio::UioDevice};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proc = RemoteProc::new(0);
    proc.set_firmware("firmware.elf")?;
    proc.start()?;
    proc.wait_state(RemoteProcState::Running, Duration::from_secs(5)).await?;

    mmap::wait_for_ready(0, Duration::from_secs(10)).await?;

    let dev = UioDevice::open(0, 2, 512)?;
    let ch = Chan::new::<HsemChanValidate>(2)?;

    println!("IPC ready!");
    Ok(())
}

Error Handling

Common Errors

  • DescState::Error or Suspended — M-core did not complete initialization
  • WaitError::Timeout — Firmware did not boot or descriptor not written within timeout
  • UioDevice::open returns “descriptor not ready”wait_for_ready was not called or timed out before opening

Debugging Tips

  1. Check /sys/class/remoteproc/remoteproc0/state to verify firmware is running
  2. Check /sys/class/uio/uio0/maps/map0/ to verify memory mapping
  3. Verify M-core writes descriptor magic at offset 0x00 (should be 0x434F_4E53)
  4. Check descriptor state at offset 0x14 (expected: 3 = Ready)

See the plan file at .claude/plans/rustling-beaming-adleman.md for architectural rationale and design decisions.

GPIO Doorbell Implementation Summary

What Was Done

Implemented a complete, production-ready GPIO doorbell for the Consortium IPC system using embedded-hal 1.0 and following the same architectural patterns as the existing HSEM and MU doorbells.

Files Modified

  1. /Cargo.toml (root)

    • Added embedded-hal = "1.0" to [workspace.dependencies]
  2. crates/consortium-ipc-doorbell-gpio/Cargo.toml

    • Added full dependencies: consortium-ipc, atomic-waker, embedded-hal, optional tokio + linux-embedded-hal
    • Added std feature gate combining tokio and linux-embedded-hal
  3. crates/consortium-ipc-doorbell-gpio/src/lib.rs (complete rewrite)

    • ~290 lines of production code
    • Full documentation with examples for both M-core and A-core
  4. CLAUDE.md

    • Added codec implementations section
    • Added GPIO doorbell status to hardware section
    • Added design documentation references
    • Added common development tasks guide

Architecture

M-core (no_std) Path

  • Interior mutability: RefCell<P> (single-threaded M-core)
  • Statics: AtomicWaker + AtomicBool PENDING for software-tracked pending state
  • ring(): Pulses the GPIO pin (high → low) to generate interrupt
  • wait(): Polls PENDING flag, waker registered before check to prevent race conditions
  • wake(): Public static method; user calls from their GPIO ISR

A-core (Linux/std) Path

  • Interior mutability: std::sync::Mutex<P> (multi-threaded Linux)
  • Async wakeup: Arc<tokio::sync::Notify>
  • notifier(): Returns the Arc for wiring to external interrupt source
  • ring(): Same pulse logic via locked pin
  • wait(): Subscribes to Notify with lifetime-erased transmute (same pattern as HSEM)
  • pending(): Returns false (no hardware status register; Notify handles pending state)

Key Design Decisions

Aspectvs HSEM/MUJustification
Interior mutabilityNot neededHSEM/MU use raw register writes; GPIO needs OutputPin which requires &mut
Software PENDING flagHardware MISR registerGPIO has no hardware status register
Single-channel16 channels (HSEM) / 8 channels (MU)GPIO instance = one pin; use multiple instances for multi-channel
wake() not extern "C"HSEM_IRQHandlerGPIO IRQ names are board-specific; user calls this from their ISR
ch argument ignoredUsed for channel indexingSingle-channel design; argument accepted but unused

Verification

no_std path: cargo check -p consortium-ipc-doorbell-gpio --no-default-featuresno_std linting: cargo clippy -p consortium-ipc-doorbell-gpio --no-default-features -- -D warningsCompiles for bare-metal: Verified with thumbv7em-none-eabihf target

The std path (cargo check --features std) requires Linux to compile; the crate was not tested on the current macOS host due to linux-embedded-hal I2C dependencies.

Code Quality

  • ✅ All unsafe blocks documented with // SAFETY: comments
  • ✅ Exactly one unsafe operation per block (no multiple_unsafe_ops_per_block)
  • ✅ No unnecessary safety comments
  • ✅ Matches workspace lint requirements
  • ✅ Follows HSEM/MU code patterns exactly for consistency

Usage Example

M-core (Cortex-M, no_std)

#![allow(unused)]
fn main() {
// In your setup:
let pin = create_gpio_pin(); // your board-specific function
let doorbell = GpioDoorbell::new(pin);

// In your IRQ handler:
#[interrupt]
fn GPIO_IRQHandler() {
    GpioDoorbell::wake();
    // ... clear GPIO interrupt status ...
}

// In your async code:
match doorbell.wait(Chan(0)).await {
    Ok(_) => println!("Interrupt received!"),
    Err(e) => eprintln!("Error: {:?}", e),
}

doorbell.ring(Chan(0)).ok();  // Signal remote core
}

A-core (Linux, std)

#![allow(unused)]
fn main() {
// Setup:
let pin = CdevPin::new("/dev/gpiochip0", 42)?;  // linux-embedded-hal
let doorbell = GpioDoorbell::new(pin);
let notifier = doorbell.notifier();

// Wire notifier to your interrupt loop:
// When GPIO interrupt fires: notifier.notify_waiters()

// In async code:
match doorbell.wait(Chan(0)).await {
    Ok(_) => println!("Interrupt received!"),
    Err(e) => eprintln!("Error: {:?}", e),
}

doorbell.ring(Chan(0)).ok();  // Signal remote core
}

Next Steps

  • Test on actual hardware (STM32MP2 or i.MX95) with GPIO wired between M-core and A-core
  • Add integration tests comparing GPIO doorbell performance to HSEM/MU
  • Document pulse width requirements for specific GPIO receivers
  • Consider adding optional debounce timer for noisy GPIO lines

On-Chip Peripheral Drivers

Alongside the cross-core IPC contracts, Consortium ships firmware-side drivers for the on-chip peripherals a controller core drives directly. They live in the chip HAL crates — consortium-hal-imx9 for the NXP i.MX 9x family and consortium-hal-stm32mp2 for the STMicroelectronics STM32MP2 family — layered over the generated PAC crates and the small shared consortium-hal trait crate.

Each chip HAL exposes a module per peripheral class, and each module is behind a Cargo feature so a firmware image only compiles the drivers it uses. The current set is adc, can, dma, gpio, i2c, i3c, spi, timer, uart, and timer_driver; all are enabled by default alongside the crate’s default chip selection. STM32MP2 also exposes lptim. The crates are no_std, keep their unsafe blocks narrow, and hold chip-specific detail inside the chip HAL rather than in the shared traits.

A common shape

The drivers follow one construction and execution pattern, so moving between peripherals — and between the two chip families — stays predictable.

  • Construction takes an MMIO base and &'static state. A driver is bound to a concrete peripheral instance by the base address you pass to its new constructor, together with a &'static state value that owns the driver’s interrupt bookkeeping (an embassy_sync::waitqueue::AtomicWaker and any per-instance flags). Selecting an instance is a matter of passing the right base and a distinct state.
  • The blocking path needs no interrupts. Every driver implements the relevant embedded-hal (or embedded-can) blocking trait, and those methods work purely by polling hardware registers. This is the simplest way to bring a peripheral up.
  • The async path is opt-in and IRQ-driven. The same drivers implement the embedded-hal-async traits. Async methods park on the driver’s waker and expect the peripheral’s interrupt to be routed into the driver’s on_interrupt handler (CanState::on_interrupt, SpiState::on_interrupt, and so on), which wakes the pending future.
  • The application owns the vector table. Per the workspace convention shared with the doorbell crates, no HAL crate installs #[interrupt] handlers. Firmware enables the peripheral’s interrupt line and, from its own ISR, calls the driver’s on_interrupt. Enable the chip HAL’s rt feature to pull in the PAC vector table and the #[interrupt] attribute macro for that application code.

The sections below introduce the two bus drivers that most benefit from a conceptual overview. GPIO, I2C, UART, DMA, and the Embassy time driver follow the same construction and blocking/async split.

CAN

The CAN drivers present a classic (non-FD) 2.0A/2.0B controller through the embedded-can traits: blocking transfers via embedded_can::nb::Can, and async Can::transmit / Can::receive once the instance’s interrupt reaches CanState::on_interrupt. Both drivers deliberately implement a minimal, fixed mailbox layout as a first cut; FD frames, extra mailboxes, and hardware acceptance filtering are left as room to grow, and the receive filter accepts every identifier.

The two families differ in how message storage is arranged, reflecting the underlying IP:

  • i.MX 9x (FlexCAN). Can::new takes the instance base and a &'static CanState. The driver uses a fixed two-mailbox layout: message buffer 0 transmits and message buffer 1 receives every frame.
  • STM32MP2 (FDCAN / Bosch M_CAN). Can::new additionally takes the shared CAN message-RAM base, this instance’s byte offset within it, and the &'static CanState. The SoC gives all FDCAN instances one shared CAN_SRAM window, so the caller partitions that window by handing each instance a distinct message-RAM offset. Within its slice, the driver lays out a three-element receive FIFO 0 followed by a single dedicated transmit buffer, using 8-byte classic elements. The async path enables interrupt line 0 (FDCANx_IT0).

SPI

The SPI drivers are byte-oriented, full-duplex masters exposed through the embedded-hal SpiBus trait (blocking) and its embedded-hal-async counterpart (async, once the instance’s interrupt reaches SpiState::on_interrupt). Spi::new takes the instance base and a &'static SpiState. Frames are eight bits, and chip-select handling reflects each IP’s model:

  • i.MX 9x (LPSPI). The driver drives one hardware chip-select (PCS) and holds it asserted for the duration of each SpiBus call via TCR.CONT, releasing it on the final word of the call.
  • STM32MP2 (SPI2S v2). The driver uses software slave management (CFG2.SSM + CR1.SSI) so the peripheral stays a master without driving a hardware NSS line — chip-select is the caller’s to manage with a GPIO. Each SpiBus method programs CR2.TSIZE with the frame count and issues a single CR1.CSTART, so it reads as one hardware transaction; buffers longer than the 16-bit TSIZE field are split into back-to-back transactions.

Both drivers are structured so a DMA-backed data path can be layered on later — the byte primitives push through the transmit data register and drain the receive data register — but the interrupt-and-polling path is the default and only path today.

I3C

Both chip HALs expose controller-mode I3C through their i3c feature. The common public shape supports I3C SDR and legacy-I2C private transfers, repeated-start transactions, broadcast CCCs, RSTDAA, and ENTDAA. Blocking operations poll the peripheral; async operations require the application to forward the selected I3C interrupt to I3cState::on_interrupt.

The NXP implementation follows Embassy MCXA’s controller engine, which uses the same NXP I3C IP as i.MX 9. The STM32MP2 implementation follows the STM32CubeMP2 LL/HAL controller sequence and uses the same Embassy-style waker contract as the other Consortium drivers. Target mode, HDR transfers, IBI handling, and DMA are not part of this first controller implementation.

Where drivers fit

These drivers cover the mechanics of talking to a peripheral. Deciding which core owns a peripheral, and enforcing that ownership across cores and secure domains, is the job of the Resource Partition Isolation subsystem, which drives peripheral ownership from Consortium.toml and the generated device tree. A controller core reaches the peripherals its manifest assigns to it through context.peripherals, populated by the generated init().

TEE Subsystem

TEE SDK Design Document

Overview

A Rust macro-based SDK for building OP-TEE Trusted Applications (TAs) with ergonomic, type-safe APIs on both the TA (secure world) and CA (normal world) sides. Built on top of Apache Teaclave TrustZone SDK (optee_utee / optee_teec).

The core idea: the developer writes normal Rust functions, and the SDK generates all OP-TEE plumbing — parameter packing/unpacking, command dispatch, and the CA-side calling stubs.


Crate Structure

consortium-tee/                  # error types, TeeResult alias
consortium-tee-macros/           # proc-macro wrapper crate: #[tee_command], tee_service!
consortium-tee-macros-impl/      # proc-macro2 implementation crate for testable expansion logic

The proc-macro wrapper delegates to the implementation crate. The implementation crate depends on syn, quote, proc-macro2, and heck, and emits code that references optee_utee (TA side) and consortium_tee (for TeeResult on the CA side), but does not import those runtime crates itself.


Project Layout (User Code)

my-ta/               # Trusted Application
├── Cargo.toml       # [lib] (rlib) + [[bin]] (cdylib); features: ["ca"]
├── build.rs         # optee_utee_build: UUID, TA properties, user_ta_header
└── src/
    ├── lib.rs       # tee_service! + #[tee_command] definitions — CA-facing API
    └── main.rs      # #![no_main], use my_ta::*, include!(OUT_DIR/user_ta_header.rs)

my-ca/               # Client Application
├── Cargo.toml       # depends on my-ta (rlib) with features = ["ca"]
└── src/
    └── main.rs      # uses generated call_function_a(), call_function_b(), etc.

Cargo.toml for the TA crate

[features]
ca = []    # enabled by the CA when depending on this crate
           # must NOT be enabled in the TA binary build

[lib]
crate-type = ["rlib"]     # linked by CA

[[bin]]
name = "ta"
crate-type = ["cdylib"]   # loaded by TEE OS

The CA depends only on the rlib target, which compiles for the host architecture using optee_teec. The cdylib binary compiles for the secure world using optee_utee.

The ca feature flag gates all CA-side generated code. The TA binary build never enables it, so call_* stubs are not compiled into the secure-world binary.

# my-ca/Cargo.toml
[dependencies]
my-ta = { path = "../my-ta", features = ["ca"] }

Developer Experience

What the developer writes

#![allow(unused)]
fn main() {
// my-ta/src/lib.rs

use consortium_tee_macros::{tee_command, tee_service};

tee_service! {
    context TaContext
    commands { function_a, function_b }
}

#[tee_command(ctx)]
fn function_a(ctx: &mut TaContext, count: u32, data: &[u8]) -> Result<()> {
    // business logic
    Ok(())
}

#[tee_command(ctx)]
fn function_b(ctx: &mut TaContext, input: &[u8], output: &mut [u8]) -> Result<u32> {
    // writes return value into ValueOutput slot
    Ok(42)
}
}
#![allow(unused)]
fn main() {
// my-ta/src/main.rs
#![no_main]

use my_ta::*;

include!(concat!(env!("OUT_DIR"), "/user_ta_header.rs"));
}
// my-ca/src/main.rs
fn main() -> consortium_tee::TeeResult<()> {
    let mut ctx = optee_teec::Context::new()?;
    let uuid = optee_teec::Uuid::parse_str("12345678-1234-1234-1234-123456789abc")?;
    let mut session = ctx.open_session(uuid)?;

    // Generated by #[tee_command] — normal Rust function calls
    call_function_a(&mut session, 42u32, &my_data)?;
    let n: u32 = call_function_b(&mut session, &in_buf, &mut out_buf)?;
    Ok(())
}

What gets generated

#[tee_command] generates per function:

  • The original function — emitted unchanged
  • fn function_a_dispatched(...) — TA-side unpacking wrapper, always compiled, crate-private
  • #[cfg(feature = "ca")] fn call_function_a(...) — CA-side calling stub, compiled only with the ca feature

tee_service! generates centrally:

  • pub enum Command { FunctionA = 0, FunctionB = 1, Unknown = N }#[repr(u64)], #[default] on Unknown
  • pub(crate) fn invoke_command(ctx: &mut ContextStruct, cmd: u32, params: &mut optee_utee::Parameters) -> Result<()> — TA dispatch entry point; dispatches via Command::from(cmd) to match the #[ta_invoke_command] hook signature

Macro Design

#[tee_command]

An attribute macro applied to individual functions in lib.rs. It generates two companion functions alongside the original:

  1. fn function_a_dispatched(ctx, params) — always compiled; called by invoke_command
  2. #[cfg(feature = "ca")] fn call_function_a(...) — compiled only when the ca feature is enabled; dead in the TA binary
#![allow(unused)]
fn main() {
// Input
#[tee_command(ctx)]
fn function_a(ctx: &mut TaContext, count: u32, data: &[u8]) -> Result<()> { ... }

// Generated (1): TA-side dispatch wrapper — always present, crate-private
fn function_a_dispatched(ctx: &mut TaContext, params: &mut optee_utee::Parameters) -> Result<()> {
    // SAFETY: slot 0 is ValueInput — type match guaranteed by #[tee_command]
    let count: u32 = unsafe { params.0.as_value()?.a() };
    // SAFETY: slot 1 is MemrefInput — type match guaranteed by #[tee_command]
    let mut __p_1 = unsafe { params.1.as_memref()? };
    let data: &[u8] = __p_1.buffer();
    function_a(ctx, count, data)
}

// Generated (2): CA-side calling stub — only with `ca` feature
#[cfg(feature = "ca")]
fn call_function_a(session: &mut optee_teec::Session, count: u32, data: &[u8]) -> consortium_tee::TeeResult<()> {
    let _p0 = ParamValue::new(count, 0, ParamType::ValueInput);
    let _p1 = ParamTmpRef::new_input(data);
    let mut _operation = optee_teec::Operation::new(0, _p0, _p1, ParamNone, ParamNone);
    session.invoke_command(Command::FunctionA as u32, &mut _operation)
}
}

When the function has a non-() return type, an extra output slot is allocated and flushed after the call:

#![allow(unused)]
fn main() {
// Input
#[tee_command(ctx)]
fn function_b(ctx: &mut TaContext) -> Result<u32> { Ok(42) }

// TA-side: flushes return value into ValueOutput slot
fn function_b_dispatched(ctx: &mut TaContext, params: &mut optee_utee::Parameters) -> Result<()> {
    let __ret_val = function_b(ctx)?;
    // SAFETY: slot 0 is ValueOutput — type match guaranteed by #[tee_command]
    let mut __p_out = unsafe { params.0.as_value()? };
    __p_out.set_a(__ret_val);
    Ok(())
}

// CA-side: allocates ValueOutput, reads back after invoke
#[cfg(feature = "ca")]
fn call_function_b(session: &mut optee_teec::Session) -> consortium_tee::TeeResult<u32> {
    let _p0 = ParamValue::new(0, 0, ParamType::ValueOutput);
    let mut _operation = optee_teec::Operation::new(0, _p0, ParamNone, ParamNone, ParamNone);
    session.invoke_command(Command::FunctionB as u32, &mut _operation)?;
    Ok(_operation.param_0().as_value().a())
}
}

tee_service!

A function-like macro in lib.rs. Syntax:

#![allow(unused)]
fn main() {
tee_service! {
    context TaContext        // optional — omit for stateless TAs
    commands { function_a, function_b }
}
}

Generates:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u64)]
pub enum Command {
    FunctionA = 0,
    FunctionB = 1,
    #[default]
    Unknown = 2,
}

#[invoke_command]
pub(crate) fn invoke_command(
    ctx: &mut TaContext,
    cmd: u32,
    params: &mut optee_utee::Parameters,
) -> Result<()> {
    match Command::from(cmd) {
        Command::FunctionA => function_a_dispatched(ctx, params),
        Command::FunctionB => function_b_dispatched(ctx, params),
        Command::Unknown => Err(Error::UnknownCommand),
    }
}
}

Without a context clause the context parameter is omitted from invoke_command and all _dispatched wrappers.


Parameter Type Mapping

Slot assignment is sequential by argument order, skipping the context. Both the TA unpacking and the CA packing are generated from the same signature, so slot order is always consistent.

Input parameters

Rust typeGP TEE slotTA unpacks viaCA packs via
boolVALUE_INPUTunsafe { params.N.as_value()?.a() } != 0ParamValue::new(x as u32, 0, ValueInput)
u32VALUE_INPUTunsafe { params.N.as_value()?.a() }ParamValue::new(x, 0, ValueInput)
&[u8]MEMREF_INPUTlet mut p = unsafe { params.N.as_memref()? }; p.buffer()ParamTmpRef::new_input(x)
&mut [u8]MEMREF_INOUTlet mut p = unsafe { params.N.as_memref()? }; p.buffer() (mut)ParamTmpRef::new_inout(x)

Return value (output slot)

The output slot is always allocated as the last slot. A non-() return type triggers this; () / bare -> Result<()> does not.

Rust return typeGP TEE slotTA flushes viaCA reads back via
u32VALUE_OUTPUTlet mut p = unsafe { params.N.as_value()? }; p.set_a(v)_operation.param_N().as_value().a()
u64VALUE_OUTPUTp.set_a((v >> 32) as u32); p.set_b(v as u32)(a as u64) << 32 | (b as u64)
boolVALUE_OUTPUTlet mut p = unsafe { params.N.as_value()? }; p.set_a(v as u32)_operation.param_N().as_value().a() != 0
Vec<u8>MEMREF_OUTPUTlet mut p = unsafe { params.N.as_memref()? }; p.buffer()[..].copy_from_slice(...)ParamTmpRef::new_output(&mut buf); read buffer()[..size()] back

Slot Assignment ABI

Slots are assigned left-to-right from the function signature (context excluded). Both the TA dispatch wrapper and the CA calling stub are generated from the same signature, so slot order is always consistent — the developer never coordinates slot indices manually.

OP-TEE supports up to 4 parameter slots per invocation. The macro emits a compile_error! if a function exceeds this limit.


Context Detection

With #[tee_command(ctx)], the first parameter is treated as the TA context if and only if it has type &mut SomePath where SomePath is not a slice ([u8]). Explicit selectors such as ctx = name or ctx = 0 are accepted for compatibility and emit one warning; if the selected context is not the first parameter, the warning asks the user to move it first. Prefer bare ctx. A context parameter:

  • Is forwarded directly to the inner function on the TA side
  • Is omitted from the CA-side call_* signature (it is the TA’s internal state, not the caller’s concern)
  • Does not consume a TEE parameter slot

TA Build (build.rs)

The TA’s build.rs uses optee_utee_build (existing Teaclave tooling) to handle UUID registration and generate user_ta_header.rs:

// my-ta/build.rs
use optee_utee_build::{Error, RustEdition, TaConfig};

fn main() -> Result<(), Error> {
    let config = TaConfig::new_default_with_cargo_env("12345678-1234-1234-1234-123456789abc")?;
    optee_utee_build::build(RustEdition::Before2024, config)
}

The UUID lives here and nowhere else. It is not part of tee_service!.


Error Handling

consortium-tee owns all error types. The CA-side generated stubs return consortium_tee::TeeResult<T>, which is Result<T, consortium_tee::TeeError>. The TA-side generated stubs return Result<()> (the optee_utee::Result in scope).


Advanced Parameter Types

See tee-advanced-type-system.md for full documentation on:

  • Additional primitive types: i32, u64, (u32, u32) value pairs
  • Output value parameters: &mut u32, &mut i32, &mut u64
  • Serialized types: T: TeeParam with pluggable codecs via #[tee_command(codec = MyCodec)]

Future Work

  • Lifecycle hookstriggers { on_create, on_open_session, on_close_session, on_destroy } block inside tee_service!, mapping to GlobalPlatform entry points with compile-time signature checking

TEE Rust Macro System — Advanced Type System

Status

This document describes the advanced type system for #[tee_command]. Most features listed below are implemented; some remain planned.

What is already implemented

See tee-sdk-design.md for the baseline. Advanced types now fully implemented:

CategoryImplemented types
Input parametersbool, u32, i32, u64, (u32, u32), &[u8], &mut [u8], &mut u32, &mut i32, &mut u64, T: TeeParam
Return / outputu32, u64, bool, Vec<u8>, T: TeeParam
SerializationPluggable codec via #[tee_command(codec = MyCodec)]; any codec implementing consortium_cfg_common::codec::CodecFor<T>

What this document covers

  • i32, (u32, u32) value pairs ✅
  • &mut u32 / &mut u64 output parameters (inout slots) ✅
  • Arbitrary serializable types via TeeParam + pluggable codec system ✅

Architecture

┌────────────────────────────────────────────────────────────────┐
│  Developer writes one function signature                       │
│                                                                │
│  #[tee_command(codec = MyCodec)]  ← required if using TeeParam │
│  fn process(ctx: &mut Ctx, cfg: MyConfig) -> Result<()>        │
└────────────────────┬───────────────────────────────────────────┘
                     │  macro expands to
                     │
          ┌──────────┴───────────┐
          │                      │
          ▼                      ▼
  process_dispatched()      call_process()        [cfg(feature="ca")]
  (TA side)                 (CA side)
  unpack params             pack params
  → call process()          → invoke_command()

The original function is always emitted untouched, so internal TA-to-TA calls pay zero marshalling overhead.

The codec attribute is required when any parameter or return type implements TeeParam. It specifies which codec to use for serialization/deserialization of those types.


Parameter Classification

The macro classifies each parameter by its Rust type and maps it to a TEE parameter slot. A TEE function has at most 4 parameter slots.

Context Parameter

With #[tee_command(ctx)], the first parameter with type &mut SomePath (where SomePath is not a primitive or slice) is treated as the TA context. Explicit selectors such as ctx = name or ctx = 0 are accepted for compatibility and emit one warning; if the selected context is not the first parameter, the warning asks the user to move it first. The context is forwarded directly to the inner function and never packed into a TEE slot.


Planned Types

Additional Primitive Values (ParamValue slot)

Rust typeSlot kindEncodingStatus
u32ValueInputa = value, b = 0✅ done
boolValueInputa = value as u32 (0 or 1), b = 0✅ done
u64ValueInput/Returna = low 32, b = high 32✅ done
i32ValueInputa = value as u32, b = 0✅ done
(u32, u32)ValueInputa = .0, b = .1✅ done

Output Value Parameters (ValueInout slot)

A &mut T where T is a primitive scalar is treated as an output parameter. The TA writes a result back through the pointer; the CA reads it after invoke_command returns.

Rust typeSlot kindTA behaviourCA behaviourStatus
&mut u32ValueInoutreads a, writes back via set_areads a into *ptr after call✅ done
&mut i32ValueInoutreads a as i32, writes back castreads a as i32 into *ptr after call✅ done
&mut u64ValueInoutreconstructs from a/b, writes backreconstructs from a/b after call✅ done

Example

#![allow(unused)]
fn main() {
#[tee_command(ctx)]
fn compute(ctx: &mut Ctx, input: u32, result: &mut u64) -> Result<()> {
    *result = (input as u64) * 0xDEAD_BEEF;
    Ok(())
}

// CA side (generated):
// #[cfg(feature = "ca")]
// fn call_compute(input: u32, result: &mut u64) -> consortium_tee::TeeResult<()> {
//     let _p0 = ParamValue::new(input, 0, ParamType::ValueInput);
//     let _p1 = ParamValue::new(0u32, 0u32, ParamType::ValueInout);
//     let mut _params = Parameters::new(_p0, _p1, ParamNone, ParamNone);
//     invoke_command(Command::Compute, &mut _params)?;
//     *result = ((_params.param_1().as_value().b() as u64) << 32)
//              | (_params.param_1().as_value().a() as u64);
//     Ok(())
// }
}

Serialized Types (TeeParamParamMemref slot)

Any type implementing TeeParam can be passed through a memref slot. The macro serializes it using a pluggable codec specified via the #[tee_command(codec = ...)] attribute.

Derive TeeParam on your type to opt in:

#![allow(unused)]
fn main() {
#[derive(TeeParam, Serialize, Deserialize)]
struct MyConfig {
    threshold: u32,
    flags: u64,
    mode: OperationMode,
    label: heapless::String<32>,
}
}

The codec type must implement consortium_cfg_common::codec::CodecFor<T> for your serialization format (e.g., PostcardCodec for postcard).

Rust typeSlot kindTA behaviourCA behaviourStatus
T: TeeParamMemrefInputdeserialize from buffer with codecserialize to buffer, pass as memref✅ done
&mut T: TeeParamMemrefInoutdeserialize, pass &mut, re-serializeserialize, pass as memref, deserialize back✅ done

The TeeParam Trait

#![allow(unused)]
fn main() {
pub trait TeeParam {
    /// Maximum serialized size in bytes.
    /// Used to pre-size the inout buffer on the CA side.
    const MAX_SIZE: usize;
}
}

TeeParam is a codec-agnostic marker trait. Derive it on any type you want to pass through a memref slot. The actual serialization is handled by the codec you specify in #[tee_command(codec = ...)].

MAX_SIZE solves the inout buffer sizing problem: since the TA may return a value larger than what the CA sent, the CA must pre-allocate a buffer sized for the response. Defining it on the type means the size contract lives next to the type definition.

If #[derive(TeeParam)] is used without a manual impl, the macro uses a conservative default of 1024 bytes.


Slot Limit Enforcement

TEE permits at most 4 parameter slots per call (the context never counts as a slot). This is already enforced for current types via compile_error!. The enforcement will extend to cover all planned types.

If you exceed 4 slots, restructure parameters into a serialized struct instead:

#![allow(unused)]
fn main() {
// Too many slots
#[tee_command(ctx)]
fn bad(ctx: &mut Ctx, a: u32, b: u64, c: u32, d: &[u8], e: &mut [u8]) -> Result<()> { ... }
//                   [0]    [1]    [2]   [3]          [4]  ← 5 slots, compile error

// Pack logically related scalars into a struct
#[derive(TeeParam, Serialize, Deserialize)]
struct Params { a: u32, b: u64, c: u32 }

#[tee_command(ctx)]
fn good(ctx: &mut Ctx, params: Params, input: &[u8], output: &mut [u8]) -> Result<()> { ... }
//                     [0]             [1]            [2]   ← 3 slots
}

Type Classification Priority (full order)

When the macro inspects a parameter type, it will test these rules in order:

1. &[u8]              → MemrefInput
2. &mut [u8]          → MemrefInout
3. &mut u32/i32/u64   → ValueInout output
4. u32                → ValueInput (a)
5. i32                → ValueInput (a, bit-cast)
6. bool               → ValueInput (a, 0/1)
7. u64                → ValueInput (a=low, b=high)
8. (u32, u32)         → ValueInput (a=.0, b=.1)
9. &mut T (Path)      → MemrefInout (TeeParam, codec round-trip)
10. T (Path)          → MemrefInput (TeeParam, codec deserialize)
11. anything else     → compile error

All rules are now implemented. Rules 9–10 require #[tee_command(codec = MyCodec)] to be specified.


Codec Selection and Requirements

Pluggable Codec System

The #[tee_command(codec = MyCodec)] attribute lets you choose how TeeParam types are serialized. Your codec type must implement consortium_cfg_common::codec::CodecFor<T> for each TeeParam type you use.

Example: Using PostcardCodec

#![allow(unused)]
fn main() {
use consortium_cfg_common::codec::PostcardCodec;

#[derive(TeeParam, Serialize, Deserialize)]
struct Config {
    threshold: u32,
    enabled: bool,
}

#[tee_command(codec = PostcardCodec)]
fn process(ctx: &mut Ctx, cfg: Config) -> Result<(), TeeError> {
    // ...
}
}

Considerations for Codec Choice

PropertypostcardbincodeCustom
no_std supportpartial
Output sizeminimalsmallvaries
Allocation-free mode
Schema evolutionmanualmanualcustom

Schema Evolution Warning

Most binary codecs (including postcard and bincode) are not self-describing. If you change a TeeParam type (add/remove/reorder fields), both CA and TA must be recompiled together to stay in sync.


Codec Implementation Reference

To implement a custom codec for your type T:

#![allow(unused)]
fn main() {
impl consortium_cfg_common::codec::CodecFor<MyType> for MyCodec {
    type Error = MyError;
    type Decoded<'buf> = MyType;

    fn encode(msg: &MyType, buf: &mut [u8]) -> Result<usize, Self::Error> {
        // Serialize msg into buf, return number of bytes written
    }

    fn decode(buf: &[u8]) -> Result<Self::Decoded, Self::Error> {
        // Deserialize from buf
    }
}
}

HMI Subsystem

Consortium is moving toward a PocketJS-first HMI. PocketJS should be the primary runtime for native embedded interfaces; the existing Cog/WPE WebKit backend remains useful as a compatibility path for applications that require browser APIs or an existing web frontend.

The first backend split is implemented: consortium-hmi owns the portable command registry, consortium-hmi-webkit owns the existing Cog shell, and consortium-hmi-pocket hosts PocketJS with a headless renderer and frame-boundary service delivery. [hmi].engine and the builder still accept only the Cog/web directory pipeline, so PocketJS is not yet the packaged or default engine.

PocketJS Repository and Integration Notes

This page records what PocketJS provides, what is ready to reuse, and what is still required to make it Consortium’s primary HMI runtime.

The review is pinned to git commit 26d418103143cec26faa332529cbea86cfa8c530 (v0.6.0-1-g26d4181, reviewed 2026-07-19). The local checkout is the pocketjs submodule at submodules/pocketjs. PocketJS is moving quickly, so verify these notes against the pinned sources before implementing a phase.

What PocketJS is

PocketJS is a native retained-mode UI stack, not a small browser. Applications use Solid or Vue Vapor JSX and a compile-time Tailwind subset, but there is no DOM, CSS engine, browser networking stack, or WebView at runtime. A compiler turns an application into two main artifacts:

  • <app>.js: a bundled guest program executed by QuickJS on native hosts;
  • <app>.pak: compiled styles, baked font atlases, images, sprites, and other binary assets.

The guest issues synchronous ui.* operations to a Rust UI core. The core owns the tree, flex layout, text, focus, animation, textures, and draw-list generation. A host renders that draw list with a platform backend. This keeps the hot UI state and per-frame work in Rust while preserving a reactive JSX authoring model.

Solid/Vue Vapor JSX
        |
        v
PocketJS compiler -------> app.js + app.pak
                                |
                                v
                         QuickJS guest
                         /            \
                ui.* surface          consortium.* surface
                     |                         |
                     v                         v
             pocketjs-core              command router
                     |                 /      |       \
                  DrawList           IPC     TEE     Linux services
                     |
                     v
              wgpu renderer
                     |
              Wayland / headless
              (DRM/KMS is future work)

The separation between ui.* and consortium.* is important. PocketJS’s HostOps is a rendering protocol and should not acquire product-specific IPC, TEE, filesystem, or process operations. pocket-mod already supports mounting additional named QuickJS surfaces, which is the appropriate extension point for Consortium services.

Repository map

PathPurposeConsortium relevance
framework/Solid/Vue renderers, components, input, animation, platform checks, virtual clock, and effect shellFrontend SDK and the guest-side half of the host contract
framework/compiler/, contracts/JSX/style/font/asset compiler, manifest schema, target registry, and generated wire constantsBuild integration and a future Consortium Linux target profile
engine/core/no_std + alloc retained UI core with draw-list and deterministic software raster supportReusable renderer-independent UI state; no Linux or QuickJS dependency
engine/wasm/, hosts/web/WASM core mirror and browser development hostFast frontend iteration, but not the intended production HMI runtime
tests/Deterministic headless simulation, input tapes, goldens, and contract testsStrong basis for HMI acceptance tests and replaying hardware/IPC traces
engine/crates/pocket-modrquickjs guest lifecycle, named surface mounting, and one guest turn per tickNative JS host and the extension point for consortium.*
engine/crates/pocket-ui-wgpuPak loading, ui.* mounting, and DrawList-to-wgpu renderingClosest reusable native Linux rendering layer
engine/pocket3d/examples/uihostwinit window and headless example using pocket-mod and pocket-ui-wgpuBring-up reference, not yet a production embedded shell
hosts/psp/, hosts/vita/PSP and Vita QuickJS/render/input hostsMature examples of native packaging and input delivery; not Linux dependencies

The main design references are README.md, docs/DESIGN.md, docs/RUNTIMES.md, docs/DETERMINISM.md, and docs/PLATFORM.md in the submodule.

Useful contracts

UI and host ABI

framework/src/host.ts defines HostOps, the synchronous QuickJS-to-native UI surface. The operation numbers and shared binary formats are pinned in contracts/spec/spec.ts and generated into the Rust core. Manifest-driven bundles also embed a target name and host ABI and refuse to run when the native host reports a different contract.

The current stock target registry contains only psp and vita. The desktop wgpu surface reports __host = "desktop", does not report __hostAbi, and the uihost example normally consumes low-level compiler output without an embedded target contract. Therefore the desktop example demonstrates the runtime pieces, but it is not yet a manifest-backed Linux target that Consortium can package as-is.

Frame and effect model

The host gives the guest one turn per frame. PocketJS uses a virtual clock and delivers external effect results at frame boundaries, which makes a run a deterministic fold over input. This is a good fit for Consortium: IPC responses, TEE results, and Linux service events can be queued by Tokio and delivered to QuickJS only when the UI thread begins its next frame.

The guest must never be called from a Tokio worker or a device callback. QuickJS and the retained UI are single-threaded. Cross-thread work should use bounded command and completion queues, with all QuickJS access retained on the display/event-loop thread.

Build artifacts

The stable custom-host projection in src/manifest/host-build-inputs.ts provides the app output name, target, host ABI, logical and physical viewport, presentation mode, and raster density. It can also produce the POCKETJS_* environment expected by a native host build.

Consortium currently has a different assumption: its HMI compiler runs vp build or npm run build, verifies a non-empty directory, copies that directory to dist/www, and injects CONSORTIUM_HMI_DIST for Cog to serve. PocketJS needs an engine-specific artifact contract for its .js and .pak files; those should not be disguised as a website directory.

Prototype status

The initial adapter split now exists in the workspace:

  • consortium-hmi is a backend-neutral, cloneable command registry. It parses invocation envelopes and returns Send response futures without depending on Tokio, Cog, GLib, WebKit, QuickJS, or a renderer.
  • consortium-hmi-webkit contains KioskApp and adapts the registry to WebKit’s script-message channel using a caller-selected Tokio runtime.
  • consortium-hmi-pocket loads JS/pak artifacts, mounts ui and a separate Consortium QuickJS surface, installs __BRIDGE__ plus PocketJS’s host effect driver, delivers completed commands at frame boundaries, and exposes an offscreen wgpu renderer for smoke tests and goldens.
  • consortium-hmi-input is the backend-neutral, no_std input layer: an InputEvent model in logical UI coordinates plus a pull-based InputSource trait. InputEvent derives IpcSafe/serde, so a real-time core can serialize events with a Consortium codec and forward them over IPC to the Linux HMI host. The pocket crate’s PadState folds events into PocketJS’s frame(buttons, analog) contract; the winit shell and future libinput/evdev and IPC readers all present as an InputSource.

This is deliberately a headless/native-host prototype. It does not yet make engine = "pocketjs" valid, build PocketJS artifacts through csti, or own a Wayland/DRM window and Linux input loop.

Fit and current gaps

ConcernReusable nowMissing for a production Consortium HMI
UI corepocketjs-core, Taffy layout, text, focus, animation, draw listProduct viewport and performance limits must be validated on A-core targets
JS runtimepocket-mod embeds QuickJS and mounts named surfacesLifecycle, error reporting, watchdog policy, and Tokio handoff in Consortium
GPUpocket-ui-wgpu and headless/windowed examplesA lean 2D-only dependency boundary and target GPU/driver validation
Displaywinit window and wgpu surfaceFullscreen Wayland kiosk behavior; direct DRM/KMS presentation is not implemented
InputBackend-neutral consortium-hmi-input event model + InputSource trait, folded to button/analog by the pocket PadState; winit shell translates key/pointer/touch; Vita shows packed touch deliveryConcrete libinput/evdev and IPC InputSource backends; delivering pointer/touch to the guest (packed-touch entry); focus, calibration, and hot-plug behavior
BuildBun compiler produces deterministic JS/pak artifactsLinux target profile, builder dispatch, staging layout, cross-build inputs, and BitBake integration
App servicesPortable registry plus Pocket effect/Promise adapterTyped errors, cancellation, backpressure, and production IPC/TEE handlers
TestingHeadless wgpu/software rendering, tapes, goldensConsortium fixtures that record/replay IPC and assert both commands and pixels
Browser compatibilityWeb/WASM development hostBrowser-only libraries and arbitrary HTML/CSS cannot be reused unchanged

There is also a dependency-shape issue worth fixing upstream. The current pocket-ui-wgpu crate uses the GPU bootstrap from the broader pocket3d crate, whose manifest also brings 3D-oriented dependencies. A primary 2D HMI should either extract a small shared wgpu substrate or let the Consortium host construct the wgpu device and pass it into the UI renderer.

Proposed Consortium architecture

1. Make the application bridge backend-neutral (prototype complete)

The command handler registry and JSON invocation protocol have been extracted from the WebKit backend. The backend-neutral layer owns:

  • registration of synchronous and asynchronous handlers;
  • invocation IDs, typed serialization boundaries, and structured errors;
  • Send response futures without holding the registry lock across user work.

Cog is now one adapter over this router. This preserves the existing window.__BRIDGE__.invoke() behavior while removing Cog, GLib, and WebKit types from the service API.

Backpressure, cancellation, and task ownership remain adapter-level production work.

2. Add a PocketJS host adapter (headless prototype complete)

consortium-hmi-pocket builds a PocketApp shell from pocket-mod, pocketjs-core, and pocket-ui-wgpu. It currently:

  1. load the planned JS and pak artifacts;
  2. mount the PocketJS ui surface;
  3. mount a separate consortium service surface;
  4. evaluate the bundle only after both surfaces exist;
  5. owns the single-threaded guest and frame pump;
  6. drain completed service calls at a frame boundary;
  7. ticks the UI core and can render into a headless wgpu target.

Consortium owns the product viewport configuration. A Pocket HMI declares its logical pixel dimensions in the project manifest; csti build injects them into the compiled application and PocketApp::from_env() applies them when it creates the retained UI surface:

[hmi]
engine = "pocket"
backend = "wayland"
source = "submodules/pocketjs"
build = "submodules/pocketjs/dist"
app = "hero-main"

[hmi.logical]
width = 800
height = 480

Omitting [hmi.logical] preserves the 480 × 272 compatibility default.

A production display/input event loop and presentation shell are still pending.

The guest SDK should connect runEffect(kind, payload, callback) to this surface. An enqueue operation sends {id, kind, payload} to Rust; the UI thread later delivers {id, result} or {id, error} before a guest frame. This keeps external state in PocketJS’s deterministic effect trace and avoids Promise timing as an invisible input.

3. Introduce a real PocketJS Linux target

Do not ship a production HMI by compiling against the PSP profile or by disabling the runtime contract. Add a first-class target or a supported custom target registration mechanism with:

  • a target name and append-only host ABI;
  • product logical/physical viewport and raster density;
  • truthful capabilities for buttons, cursor, touch, and baked glyphs;
  • a native host that publishes matching __host and __hostAbi values;
  • compiler, host, and headless contract tests.

Linux panels are not all the same size. The target design must decide whether display geometry is a Consortium-generated product profile or a constrained set of PocketJS profiles. It should not hard-code the PSP’s 480×272 logical viewport merely because the desktop demo does so.

4. Make build and staging engine-specific

Extend HmiEngine with pocketjs and keep cog as an explicit fallback. The pipeline should dispatch on the engine instead of treating every HMI as a web directory:

pocketjs -> compile/validate plan -> app.js + app.pak -> dist/hmi/
cog      -> frontend build        -> web directory   -> dist/www/

The application build then receives explicit PocketJS artifact paths and contract metadata. Embedding the artifacts in the executable is attractive for integrity and atomic updates; staging them beside the executable is friendlier to iteration and BitBake packaging. The first implementation can stage files, then add an opt-in embed mode after measuring size and startup.

5. Bring up display and input in this order

  1. Headless host for CI and command/effect tests.
  2. Fullscreen Wayland host using the existing winit/wgpu path.
  3. Keyboard and button navigation, then touch and pointer delivery in logical coordinates.
  4. Cross-compilation and GPU validation in the i.MX9 and STM32MP2 Linux SDKs.
  5. Direct DRM/KMS only after the Wayland path is stable; the current PocketJS repository does not provide a direct KMS presenter.

This order makes PocketJS the primary design without making the initial work depend on board graphics bring-up. Cog remains available when a product needs browser APIs or while a target lacks the required native display backend.

Suggested implementation slices

Each slice should be independently testable and small enough to upstream PocketJS changes where they belong.

  1. Linux contract spike: add a manifest-backed desktop/Linux profile, publish __hostAbi, support product viewport input, and run a compiled demo through the headless wgpu host.
  2. Bridge extraction: turn the current WebKit handler map into a backend-neutral command router while keeping all Cog tests passing.
  3. Pocket service prototype: mount consortium.*, invoke one synchronous and one Tokio-backed asynchronous command, and deliver results at frame boundaries.
  4. Builder artifacts: add engine = "pocketjs", compile JS/pak, stage them under dist/hmi, and inject their deployed paths into the application.
  5. Wayland kiosk: add fullscreen presentation, resize policy, clean shutdown, keyboard/button input, and a systemd smoke test.
  6. Touch and system integration: map panel input, expose IPC/TEE handlers, and add recorded-effect/pixel-golden tests.
  7. Target hardening: validate Yocto/OpenSTLinux builds, startup time, steady-state memory, frame time, font coverage, GPU recovery, and watchdog behavior before making PocketJS the default engine.

Licensing and update policy

PocketJS is MIT licensed. Its vendored Inter fonts are under the SIL Open Font License. Both are compatible with Consortium’s dependency policy, subject to preserving their notices in source and binary distributions.

Keep the integration pinned through the git submodule while the native host contracts evolve. Prefer sending changes to PocketJS’s target registry, pocket-mod, and pocket-ui-wgpu upstream rather than carrying generated or protocol patches in Consortium. When updating the pin, rerun the host ABI, artifact, headless replay, and target cross-build checks before accepting it.

Chip Database

The Consortium Chip Database is a proposed standalone product for importing, reviewing, publishing, exploring, and generating software artifacts from versioned chip descriptions. It is intended to become a partner project to Consortium rather than another crate embedded in this workspace.

The database will cover register layouts, peripheral revisions, chip topology, memory maps, interrupts, pin multiplexing, security and address-space views, Linux integration, and source provenance. Published releases will support web, CLI, build-system, and Model Context Protocol consumers.

See the Product Requirements Document for the proposed scope, architecture, requirements, delivery phases, and acceptance criteria.

Consortium Chip Database: Product Requirements Document

FieldValue
StatusProposal
ProductConsortium Chip Database
RelationshipStandalone partner project to Consortium
Initial consumersWeb application, MCP clients, Consortium CLI, code generators
Deployment targetCloudflare Workers, D1, R2, and Queues
Last updated2026-07-19

Executive summary

The Consortium Chip Database is a versioned source of hardware truth for both humans and software. It imports vendor and community hardware descriptions, normalizes them into a canonical intermediate representation (IR), preserves property-level provenance, supports review and publication, and generates consistent outputs for firmware, embedded Linux, documentation, and AI tools.

The product is broader than a peripheral access crate generator. It describes the relationships among chips, dies, packages, processors, address spaces, memory, peripheral IP revisions, instances, registers, interrupts, pin routes, security domains, clocks, resets, DMA, power domains, and Linux bindings.

Published data is immutable and content-addressed. Cloudflare D1 stores authoring state and searchable relational projections. Cloudflare R2 stores source assets where licensing permits, canonical release bundles, and generated artifacts. A Rust Cloudflare Worker exposes the web API and remote MCP endpoint. A Vue and TypeScript frontend provides the human interface. The standalone repository uses Vite+ as its unified frontend toolchain.

Background

Consortium already contains early forms of this data:

  • Chip-family memory and AMP topology descriptions.
  • Per-core peripheral base-address and interrupt maps.
  • Peripheral register definitions represented in chiptool-compatible YAML.
  • CMSIS-header import scripts for STM32MP2 and i.MX9.
  • Vendor DTS and pinctrl sources.
  • Generated Rust PAC crates and interrupt metadata.

These datasets are useful but have implicit links, different schemas, and different update paths. The new product will extract the general hardware-data problem into its own repository while allowing Consortium to consume pinned, verified releases.

Product vision

A developer, silicon vendor, integrator, or AI assistant should be able to:

  1. Identify an exact chip, die, package, core, and security/address-space view.
  2. Find a peripheral, register, field, interrupt, memory region, or pin route.
  3. Understand where every important fact came from.
  4. Compare peripheral and silicon revisions without manually diffing manuals.
  5. Generate mutually consistent Rust, C, SVD, chiptool, DTS, and PDF outputs.
  6. Reproduce a build later using a locked canonical release.
  7. Access the same released knowledge through the website, API, CLI, or MCP.

Goals

G1: Canonical hardware model

Define a vendor-neutral IR that is a superset of the information needed by CMSIS-SVD, chiptool, Rust PACs, CMSIS-style C headers, Linux Device Tree, Consortium configuration, and generated reference documentation.

G2: Independent peripheral revisioning

Represent a peripheral IP block independently from the chips that instantiate it. Chips refer to immutable peripheral revisions, allowing reuse and explicit compatibility analysis.

G3: Reproducible publication and generation

Make every published release, source input, generator, and output artifact identifiable by a cryptographic digest. A released artifact must be reproducible from its locked inputs.

G4: Trust and provenance

Record source identity, version, hash, location, extraction method, confidence, review decision, and conflicts at property level.

G5: Human usability

Provide a fast modern interface for search, exploration, comparison, pin planning, memory visualization, provenance review, and artifact downloads.

G6: Tool and agent usability

Expose stable structured APIs, immutable resources, and narrowly scoped MCP tools. Agents must receive the same release identifiers and provenance that human users see.

G7: Offline and Yocto-compatible consumption

Allow the Consortium CLI and BitBake to fetch a versioned archive, verify its checksum, and perform all compilation and generation without later network access.

Non-goals

The initial product will not:

  • Replace a vendor reference manual as the legal or safety authority.
  • Claim that generated HAL drivers are functionally validated on hardware.
  • Provide live register debugging or direct target programming.
  • Redistribute copyrighted or confidential vendor documents without permission.
  • Treat OCR or model-extracted TRM content as trusted without review.
  • Model boards as if they were chip variants; board support is a later layer.
  • Guarantee lossless round trips through formats that cannot represent the full canonical IR.
  • Run live D1 or MCP queries inside BitBake compile tasks.

Users and jobs

Firmware developer

  • Look up a field’s access and side-effect behavior.
  • Generate or download a Rust PAC or C header for an exact core view.
  • Compare a peripheral revision used by two chips.
  • Find valid interrupt and pin routes.

Embedded Linux integrator

  • Generate SoC DTSI, pinctrl, reserved-memory, and integration fragments.
  • Trace compatible, clock, reset, DMA, power-domain, and interrupt data back to a source.
  • Obtain pinned archives suitable for a Yocto recipe.

Consortium user

  • Resolve [profile] chip to an exact database release.
  • Validate memory, peripheral ownership, doorbell, and address visibility.
  • Lock generated endpoint code to the same hardware release across builds.

Database maintainer

  • Import multiple source formats.
  • Review parser warnings and conflicting claims.
  • Correct data without mutating an existing release.
  • Publish a reviewed release and regenerate all supported artifacts.

Silicon or IP partner

  • Maintain private or pre-release datasets in an isolated organization.
  • Reuse peripheral revisions across chip families.
  • Publish selected data without exposing licensed source assets.

AI or IDE client

  • Search the catalog through MCP.
  • Retrieve bounded structured context for an exact release.
  • Compare revisions and trace provenance without scraping documentation.

Product principles

  1. Published data is immutable. Corrections create new revisions.
  2. Identity is not a name. Renames do not create new hardware identities.
  3. Evidence is not truth. Imports create claims that require resolution.
  4. The IR is richer than its exports. Export limitations never constrain what the canonical model can preserve.
  5. Core and security views are explicit. Base addresses, access, and IRQs may differ by execution context.
  6. Generation is deterministic. No generator depends on a moving latest channel after resolution.
  7. Portable releases are authoritative. Published D1 projections can be rebuilt from canonical release objects.
  8. Network access ends after fetch. Local and Yocto builds consume locked bundles.

Canonical IR

Identity model

The IR distinguishes stable identity, immutable revision, and publication:

entity_id       Stable UUID across renames and revisions
revision_id     SHA-256 of one canonical object revision
release_id      SHA-256 of a manifest selecting exact root revisions
channel         Mutable convenience pointer such as stable or nightly

Vendor revision strings and marketing versions are metadata, not primary keys. Compatibility classifications are explicit and may be documentation-only, codegen-compatible, source-compatible, breaking-layout, or unknown.

Canonical graph

The published object graph is directed and acyclic:

Release
└── Chip revision
    ├── Die and package revisions
    ├── Processor and execution topology
    ├── Address spaces and memory maps
    ├── Peripheral instances ──→ Peripheral revisions
    ├── Interrupt routes
    ├── Pinmux and package routes
    └── OS and security integration

Peripheral revisions do not point back to consuming chips. D1 derives reverse relationships such as “used by these chips.”

Principal object types

The first stable schema is expected to contain:

  • Vendor, ChipFamily, Chip, DieRevision, PackageRevision, and MarketedPart.
  • ProcessorCluster, Processor, Core, ExecutionContext, AddressSpace, and Bus.
  • MemoryRegion and AddressMapping.
  • Peripheral, PeripheralRevision, RegisterBlock, RegisterCluster, Register, Field, EnumSet, and EnumValue.
  • PeripheralInstance and InstanceMapping.
  • InterruptController, InterruptSource, and InterruptRoute.
  • Pad, PackagePin, PeripheralSignal, PinFunction, PinRoute, and PinGroup.
  • Typed clock, reset, DMA, power-domain, IOMMU, and security-controller connections.
  • LinuxBinding and DeviceTreeNodeTemplate.
  • SourceDocument, SourceRevision, SourceLocation, and provenance edges.

Numeric representation

Addresses, masks, and arbitrary-width register values are canonical hexadecimal strings because JSON and JavaScript cannot safely represent every 64-bit value. Small bounded structural values remain JSON numbers.

base: '0x02430000'
offset: '0x0014'
width: 32
lsb: 19
irq_number: 207
reset_value: '0xc0000000'

The schema defines one normalized hexadecimal spelling. D1 additionally stores indexed addresses as fixed-width big-endian blobs to preserve unsigned ordering.

Register access semantics

Read and write permissions are separate from side effects. The IR must support:

  • Read-only, write-only, and read-write access.
  • Privileged and secure access restrictions.
  • Read-clear, read-set, and externally modified values.
  • Write-one/zero-to-clear, set, and toggle.
  • Write constraints and enumerated-only writes.
  • Reset value and reset mask.
  • Aliased and overlapping registers.
  • Register and cluster arrays.
  • FIFO and atomic alias behavior.
  • Implementation-defined and reserved states.

Field position and width are canonical. Masks are derived and validated rather than independently edited.

Provenance

Every selected property can refer to one or more source locations:

target: /registers/7/fields/3/write_action
source_revision: sha256:...
locator:
  kind: cmsis-symbol
  value: LPUART_STAT_OR_MASK
extraction:
  method: cmsis-header-parser
  tool_version: 0.3.0
confidence: exact

Confidence values are exact, derived, inferred, conflicting, and unverified. Rejected claims remain in the authoring audit trail.

Canonical serialization

  • JSON is the normative portable serialization for schema version 1.
  • Object keys and ordered collections have deterministic canonical ordering.
  • Release identity is the SHA-256 of canonical manifest content.
  • Each immutable object has its own digest and may be reused by multiple releases.
  • CBOR and compressed JSON are derived distribution formats.
  • Archive timestamps, compression settings, and filenames do not affect semantic release identity.

Functional requirements

IR and releases

  • IR-001: The system shall validate every canonical object against a versioned schema.
  • IR-002: Published revisions and releases shall be immutable.
  • IR-003: A chip revision shall refer to exact peripheral revision digests.
  • IR-004: A release shall be reconstructable without querying D1.
  • IR-005: Reverse references shall be derivable from forward references.
  • IR-006: Schema migrations shall preserve the original published bytes and produce a new release when canonical content changes.
  • IR-007: A release manifest shall include schema version, root objects, object hashes, source-manifest hash, and generator compatibility metadata.

Import and normalization

  • IMP-001: Import CMSIS-SVD XML.
  • IMP-002: Import supported CMSIS C headers and macro conventions.
  • IMP-003: Import and export chiptool YAML.
  • IMP-004: Import selected DTS, DTSI, pinctrl, and dt-binding data.
  • IMP-005: Preserve the raw source hash and importer version.
  • IMP-006: Create claims rather than directly overwriting reviewed facts.
  • IMP-007: Detect conflicts among sources and existing canonical data.
  • IMP-008: Support deterministic importer reruns.
  • IMP-009: Treat TRM OCR or model-assisted extraction as unverified claims.
  • IMP-010: Record redistribution and use rights for every source asset.

Catalog and query

  • CAT-001: Search chips, families, peripheral types, instances, registers, fields, signals, and aliases.
  • CAT-002: Filter by vendor, architecture, core, package, capability, release, and publication state.
  • CAT-003: Resolve common part-number aliases without losing canonical identity.
  • CAT-004: Select an execution context before returning base address, accessibility, or interrupt information.
  • CAT-005: Compare two chip or peripheral revisions structurally.
  • CAT-006: Trace a displayed value to its provenance and review state.
  • CAT-007: Use opaque cursor pagination for large result sets.

Authoring and review

  • AUT-001: Create isolated drafts from an existing release or empty schema.
  • AUT-002: Review imported claims individually or in validated groups.
  • AUT-003: Display conflicting values and sources side by side.
  • AUT-004: Require validation before review submission.
  • AUT-005: Require an authorized reviewer before publication.
  • AUT-006: Record author, reviewer, timestamps, rationale, and changed canonical paths.
  • AUT-007: Prevent edits to released objects.
  • AUT-008: Support private organization datasets and public datasets without cross-tenant leakage.

Export and generation

  • EXP-001: Export CMSIS-SVD for a selected device and address-space view.
  • EXP-002: Export a versioned Consortium C-header profile with CMSIS compatibility where applicable.
  • EXP-003: Generate Rust PAC crates, initially through a pinned chiptool projection where practical.
  • EXP-004: Export chiptool-compatible YAML and a loss report.
  • EXP-005: Generate LaTeX and a rendered PDF reference manual.
  • EXP-006: Generate SoC DTSI, pinctrl includes, reserved-memory fragments, and optional board-integration templates.
  • EXP-007: Validate DTS output with dtc and applicable dt-schema bindings.
  • EXP-008: Include release and generator identity in every artifact.
  • EXP-009: Produce byte-reproducible artifacts where the format permits it.
  • EXP-010: Report canonical information that an export format omitted or approximated.

Website

  • WEB-001: Provide fast global search and stable release-aware URLs.
  • WEB-002: Display interactive memory maps and peripheral-instance views.
  • WEB-003: Render registers and bitfields with access and side effects.
  • WEB-004: Switch among core, security, privilege, and address-space views.
  • WEB-005: Compare chip and peripheral revisions.
  • WEB-006: Provide pin-route search and conflict detection.
  • WEB-007: Display provenance, confidence, and conflicting claims.
  • WEB-008: Offer code snippets and artifact downloads for exact releases.
  • WEB-009: Meet WCAG 2.2 AA for the core catalog and authoring workflows.
  • WEB-010: Remain usable on desktop and tablet-width displays.

MCP

  • MCP-001: Expose a remote Streamable HTTP endpoint at /mcp.
  • MCP-002: Provide a matching local stdio server backed by a locked bundle.
  • MCP-003: Expose immutable resource templates for schemas, releases, chips, peripherals, registers, provenance, and artifacts.
  • MCP-004: Initially expose read-only tools for search, address maps, peripherals, registers, IRQs, pin routes, revision comparison, provenance, and artifact retrieval.
  • MCP-005: Return both concise text and structured output with stable IDs.
  • MCP-006: Bound result sizes and paginate large collections.
  • MCP-007: Mark tool behavior with annotations while enforcing permissions independently on the server.
  • MCP-008: Treat imported descriptions and comments as untrusted content, never as server instructions.
  • MCP-009: Add draft-authoring tools only after read-only authorization and auditing are proven.
  • MCP-010: Keep global release publication outside MCP for the initial product.

Consortium CLI and Yocto

  • CLI-001: Resolve a chip identifier and channel to an exact release digest.
  • CLI-002: Download and verify an immutable canonical bundle.
  • CLI-003: Record the release, schema, URL, checksum, and generator version in a lockfile.
  • CLI-004: Operate entirely from the locked bundle after fetch.
  • CLI-005: Generate a BitBake-compatible source URI and SHA-256 pin.
  • CLI-006: Support mirrors, pre-populated DL_DIR, and BB_NO_NETWORK validation.
  • CLI-007: Preserve support for vendored bundles in source repositories.

Database schema

D1 is divided logically into evidence, canonical metadata, projections, and artifacts. Large binary content and canonical distribution objects reside in R2.

Evidence and authoring tables

organization
dataset
source
source_revision
source_asset
source_location
import_run
claim
claim_conflict
review_decision
draft
draft_change

Claims identify a target entity and canonical path, candidate JSON value, source location, extraction method, and confidence. Typed validation occurs before a claim can be selected into a draft.

Canonical metadata tables

entity
entity_alias
object_revision
object_revision_edge
release
release_root
release_channel
publication

object_revision stores the entity, object kind, schema version, canonical digest, R2 key, and compact canonical JSON when it remains below the configured row threshold. R2 remains the source for portable published bytes.

Search projection tables

chip_index
part_index
core_index
address_space_index
memory_mapping_index
peripheral_index
instance_mapping_index
register_index
field_index
interrupt_route_index
pin_route_index
linux_binding_index
provenance_index
catalog_fts

Projection rows are replaceable and carry a release digest. No released bundle depends on projection-specific row IDs.

Generation tables

generator
generation_job
artifact
artifact_variant
validation_run

Artifacts contain R2 key, SHA-256, size, media type, generator revision, canonical release, options digest, validation state, and publication state.

Indexing requirements

  • Case-folded exact indexes for canonical names and aliases.
  • FTS indexes for names and descriptions.
  • Composite indexes beginning with dataset and release for tenant isolation.
  • Fixed-width sortable address columns for memory and instance range queries.
  • Explicit indexes for every foreign key used in publication and deletion checks.
  • Query plans and measured latency included in migration review.

Storage architecture

D1 responsibilities

  • Drafts, claims, conflicts, and review workflow.
  • Published release catalog and channel pointers.
  • Detailed searchable projections.
  • User, organization, authorization, and audit references.
  • Generation and validation job metadata.

R2 responsibilities

  • Original source assets where storage and redistribution are permitted.
  • Import logs and parser diagnostics.
  • Canonical objects and release manifests.
  • Pre-bundled JSON/CBOR archives.
  • Generated SVD, C, Rust, YAML, DTS, LaTeX, and PDF artifacts.
  • Signatures, checksums, SBOMs, and source manifests.

Suggested immutable keys:

sources/sha256/<digest>
objects/<schema>/<kind>/<digest>.json
releases/<schema>/<release-digest>/manifest.json
bundles/<release-digest>/canonical.tar.zst
artifacts/<release-digest>/<generator>/<version>/<options-digest>/<file>

Mutable channel documents are separate from immutable object paths.

Publication transaction

  1. Freeze and validate a draft.
  2. Canonicalize and hash all objects.
  3. Generate and validate required artifacts.
  4. Upload immutable objects and artifacts to R2.
  5. Verify object hashes and sizes.
  6. In one D1 transaction, insert the release and optionally move a channel.
  7. Retain failed publication diagnostics and later collect abandoned staging objects.

System architecture

Vue web application
        │ HTTPS / JSON
        ▼
Rust Cloudflare Worker ──────────────── MCP clients
        │                                   │
        │                                   ├─ Streamable HTTP /mcp
        │                                   └─ local stdio via CLI
        ├─ D1: authoring and query projections
        ├─ R2: canonical objects and artifacts
        ├─ Queues: imports, exports, validation
        └─ external build runner when a job exceeds Worker limits

The Worker owns authorization and application policy. The frontend and MCP layers call the same domain services; neither calls the other as an internal API.

Long-running PDF, PAC, and validation jobs use a queue and idempotent job key. An external sandboxed runner may be introduced for native tools that cannot run within the Worker Wasm environment.

The standalone repository should pin exact dependency versions in its lockfiles and update deliberately. “Latest” below means the latest stable compatible release at repository creation or an approved dependency-update change.

Backend and shared domain

  • Rust 2024 workspace.
  • Cloudflare workers-rs targeting wasm32-unknown-unknown.
  • serde and JSON Schema generation for canonical types.
  • D1 through Worker bindings and versioned SQL migrations.
  • R2 through Worker bindings with explicit SHA-256 verification.
  • Cloudflare Queues for asynchronous import and generation dispatch.
  • Structured tracing with request, organization, release, import, and job IDs.
  • A protocol-conformance-tested Rust MCP implementation supporting Streamable HTTP and stdio. The concrete MCP crate is selected after a compatibility spike rather than embedded in the product contract.

The canonical model, validation, diffing, import normalization, and generator interfaces live in ordinary Rust crates without Cloudflare dependencies. The Worker is an adapter around those crates.

Frontend

  • Vue 3 with Composition API and <script setup lang="ts">.
  • Strict TypeScript.
  • Vue Router for release-aware stable URLs.
  • Pinia only for durable cross-view client state; server data uses a dedicated query/cache layer.
  • Vue - Official language tooling, formerly known as Volar, for SFC type and template support. See the Vue TypeScript guide.
  • Tailwind CSS through the official @tailwindcss/vite integration.
  • The current stable Vite line; at the time of this PRD, Vite 8 is the stable major and uses Rolldown. Avoid experimental Vite APIs in product-critical paths.
  • Accessible headless components where useful, with a small project-owned visual system rather than a large opaque component theme.
  • Canvas or SVG visualization modules for memory maps and register bitfields; ordinary semantic HTML remains the accessible source representation.

Vite+ workflow

Use Vite+ as the unified frontend runtime, package-management, development, build, test, formatting, linting, type-checking, and task entry point:

vp install
vp dev
vp check
vp test
vp build
vp run <task>

The root vite.config.ts should enable type-aware linting and type checking. Vite Task should orchestrate code generation and schema-client generation where its input/output tracking is appropriate. Rust commands may be registered as explicit tasks, but Cargo remains responsible for Rust dependency resolution and compilation.

Repository shape

One possible standalone layout is:

apps/
  web/                       Vue application
crates/
  ir/                        canonical types and serialization
  schema/                    schema generation and compatibility checks
  database/                  repository interfaces and D1 projections
  import/                    shared import framework
  import-svd/
  import-cmsis/
  import-chiptool/
  import-devicetree/
  generate-c/
  generate-rust-pac/
  generate-svd/
  generate-chiptool/
  generate-devicetree/
  generate-latex/
  mcp/                       transport-independent MCP surface
  cli/                       local, CI, and stdio MCP entry point
  worker/                    Cloudflare Worker adapter
packages/
  api-client/                generated TypeScript API types
  ui/                        shared Vue components and visual primitives
migrations/
schemas/
fixtures/

API design

  • Version public HTTP APIs under /api/v1.
  • Use release digests in URLs and responses after channel resolution.
  • Return stable entity IDs and revision IDs in all entity responses.
  • Use opaque cursor pagination.
  • Publish machine-readable error codes with human-readable explanations.
  • Accept conditional requests and emit immutable cache headers for released data.
  • Generate OpenAPI and TypeScript client types from backend contracts.
  • Do not expose generic SQL, arbitrary R2 keys, arbitrary source URLs, or shell options.

MCP surface

Remote server

The Rust Worker exposes /mcp using Streamable HTTP. Read-only public catalog access may be anonymous and rate-limited. Artifact generation and all private or authoring access require OAuth scopes.

Initial tools:

search_chips
resolve_chip
get_address_map
search_peripherals
get_peripheral
search_registers
get_register
get_irq_route
find_pin_routes
compare_revisions
trace_provenance
get_artifact

Initial resource URI forms:

consortium://schema/{version}
consortium://releases/{release_digest}
consortium://chips/{entity_id}@{revision_id}
consortium://peripherals/{entity_id}@{revision_id}
consortium://registers/{entity_id}@{revision_id}
consortium://artifacts/{release_digest}/{format}/{variant}

Local server

The CLI exposes the same read-only surface over stdio using a locked local bundle. It performs no remote channel resolution and requires an exact release.

Authorization

Initial scopes:

catalog:read
sources:read
artifacts:generate
drafts:write
imports:write
reviews:approve
releases:publish

The server enforces scopes at the domain-service boundary. Tool annotations are descriptive hints and never replace authorization checks.

Security and trust requirements

  • Validate OAuth audience, issuer, expiry, scopes, and organization membership.
  • Apply tenant predicates before every private D1 and R2 lookup.
  • Use separate public and private R2 prefixes or buckets where appropriate.
  • Reject arbitrary network fetches from import tools; use uploads or approved source connectors.
  • Sandbox parsers and native generators with CPU, memory, time, and output limits.
  • Treat source comments, descriptions, OCR, and generated text as untrusted content.
  • Escape all rendered descriptions and sanitize the supported documentation markup subset.
  • Require explicit confirmation and audit for publication and destructive draft actions.
  • Use immutable artifact keys and conditional writes.
  • Keep secrets out of bundles, logs, MCP results, and generated outputs.
  • Generate an SBOM for deployable services and generator containers.

Licensing requirements

Every source revision records:

  • Owner and publisher.
  • Source URL or acquisition record.
  • Document or package version.
  • Cryptographic hash.
  • Declared license.
  • Permission to store privately.
  • Permission to redistribute the original.
  • Permission to publish extracted facts.
  • Required attribution.
  • Access classification: public, organization-private, or restricted.

The publication validator blocks releases that would include unauthorized source assets. Published facts retain citations even when the original document cannot be redistributed.

Generated artifacts include the database project’s license plus applicable source attribution and generated-code notices. License policy must be reviewed before claiming that an exported vendor-derived PAC or header is freely redistributable.

Non-functional requirements

Correctness

  • Validate register overlap, bit overlap, reset widths, enum ranges, array bounds, address ranges, IRQ targets, and dangling references.
  • Preserve source evidence for every automatic correction or normalization.
  • Maintain golden fixtures and round-trip tests for supported imports.
  • Compare generated PAC layouts against canonical offsets and widths.

Performance

  • Public exact-ID reads: p95 server latency below 150 ms excluding client network latency and uncached R2 transfer.
  • Catalog search: p95 below 300 ms for expected production dataset size.
  • Initial application shell: usable within 2.5 seconds on a representative mid-range connection and device.
  • Register and field pages must virtualize large collections without hiding semantic table access from assistive technology.

Reliability

  • Idempotent imports and generation jobs.
  • No channel pointer moves until all required release objects are verified.
  • Restore and rebuild procedures tested from R2 canonical bundles.
  • Publication failures leave the prior channel unchanged.

Observability

  • Structured logs and traces across HTTP, MCP, D1, R2, queue, and runner work.
  • Metrics for search latency, cache hit rate, import conflicts, validation failures, generation duration, publication failures, and MCP tool errors.
  • Audit events for private reads, draft writes, reviews, and publication.

Compatibility

  • Schema compatibility tests for every change.
  • At least the current and previous stable canonical schema readers supported by the CLI.
  • Explicit exporter support matrix by schema and generator version.

Success metrics

Initial technical success

  • Import the existing Consortium STM32MP2 and i.MX9 datasets.
  • Represent all currently used memory, peripheral, IRQ, and register data without unreported loss.
  • Reproduce the current PAC input data and interrupt definitions.
  • Resolve existing Consortium example chips from a locked release.
  • Answer the initial MCP read tool suite from both remote and local servers.

Product success

  • A user can find any known register field from a part number in fewer than three search interactions.
  • Every published register offset and field position has provenance or an explicit unverified status.
  • A maintainer can understand and resolve a conflicting import without reading raw database rows.
  • Generated artifacts from the same release agree on names, addresses, widths, and interrupt numbers.
  • At least one Yocto build completes with networking disabled after fetch.

Delivery phases

Phase 0: Schema RFC and extraction plan

  • Finalize stable identity and canonicalization rules.
  • Specify register semantics and execution-context addressing.
  • Define source, claim, provenance, and licensing models.
  • Inventory reusable Consortium importers and fixtures.
  • Decide the standalone repository name and governance.

Exit criterion: reviewed schema RFC with representative STM32MP2 and i.MX9 examples.

Phase 1: Local canonical core

  • Create the Rust IR, schema, validation, diff, and bundle crates.
  • Import existing Consortium chip, memory, and peripheral data.
  • Publish local content-addressed release bundles.
  • Implement a read-only local CLI.

Exit criterion: current data converts deterministically and validates without unexplained loss.

Phase 2: Export parity

  • Implement chiptool YAML, Rust PAC, C, SVD, and interrupt exports.
  • Compare generated layout and API fixtures with current Consortium outputs.
  • Add generator manifests and reproducibility tests.

Exit criterion: selected current PAC and configuration consumers can use the new bundle.

Phase 3: Cloud catalog and read-only web

  • Deploy Rust Worker, D1 migrations, R2 release storage, and public API.
  • Build Vue search, chip, peripheral, register, memory, and provenance views.
  • Add public artifact downloads and release-aware URLs.

Exit criterion: a published release is searchable and browsable end to end.

Phase 4: MCP

  • Implement remote Streamable HTTP and local stdio servers.
  • Add read-only resources and tool suite.
  • Add authorization, rate limits, conformance tests, and prompt-injection handling.

Exit criterion: supported MCP clients can retrieve the same exact released facts as the web API and local CLI.

Phase 5: Authoring and publication

  • Add source upload, import runs, conflict review, drafts, validation, approval, and publication UI.
  • Add private organization datasets and audit logs.

Exit criterion: a maintainer can import, review, and publish a correction without direct D1 or R2 access.

Phase 6: Consortium and Yocto integration

  • Add release resolution and lockfile support to Consortium CLI.
  • Generate BitBake pins and validate offline builds.
  • Replace bundled legacy data only after output parity and migration review.

Exit criterion: Consortium examples build from locked database releases and one supported Yocto flow passes with post-fetch networking disabled.

Phase 7: Extended data

  • Add pinmux planning, Linux bindings, clocks, resets, DMA, and power domains.
  • Add reviewed TRM-assisted extraction.
  • Add LaTeX/PDF reference publication.
  • Evaluate board and module databases as separate layers.

MVP acceptance criteria

The MVP is complete when all of the following are true:

  1. A versioned canonical schema and deterministic serializer are published.
  2. Existing i.MX93, i.MX95, and STM32MP2 controller-core data imports into it.
  3. Peripheral revisions are independent and reused by chip instances.
  4. Core/address-space-specific base addresses and IRQ routes are queryable.
  5. Published releases are content-addressed and downloadable from R2.
  6. D1 search projections can be rebuilt from a published bundle.
  7. The Vue website supports search, chip, peripheral, register, and provenance views.
  8. At least chiptool YAML, Rust PAC input, C header, SVD, and canonical bundle exports are available.
  9. Remote and local MCP servers implement the initial read-only tools.
  10. The Consortium CLI can lock, fetch, verify, and consume an exact release.
  11. vp check, vp test, and vp build pass for the frontend workspace.
  12. Rust formatting, linting, unit tests, schema compatibility tests, importer fixtures, and generator golden tests pass in CI.

Risks and mitigations

RiskMitigation
Conflicting vendor sourcesPreserve claims, display conflicts, require resolution
Copyright restrictionsSeparate facts from assets and enforce publication rights
IR constrained by SVD/chiptoolDefine the IR independently and publish export loss reports
D1 growth or contentionKeep blobs in R2, index measured queries, design shard-safe IDs
Worker Wasm limitationsKeep generators portable and use sandboxed external runners when required
Parser silently invents semanticsConfidence levels, provenance, golden fixtures, review gates
Peripheral revision explosionContent-addressed reuse and compatibility classification
MCP data exfiltrationScoped authorization, tenant filters, bounded tools, audit logs
Prompt injection in source textTreat all imported prose as data and separate it from instructions
Generated-code incompatibilityVersion generators and test layouts and public API fixtures
Moving frontend toolchainExact lockfiles, stable APIs, Vite+ validation, scheduled upgrades

Open decisions

  • Final product and repository name.
  • Public contribution and governance model.
  • Exact canonical JSON normalization profile.
  • UUID representation in D1 and public APIs.
  • Whether private datasets share a D1 database or use database-per-tenant isolation.
  • Authentication provider and reviewer-role model.
  • Initial Rust MCP implementation after the compatibility spike.
  • Generator execution environment for LaTeX, dt-schema, and native chiptool.
  • Legal policy for derived facts from restricted vendor manuals.
  • Boundary among die revisions, package variants, and marketed part numbers for each vendor family.

Relationship to Consortium

The new repository becomes the upstream publisher of general chip data. Consortium remains responsible for AMP-specific configuration, validation, lowering, endpoint generation, build planning, and deployment artifacts.

Migration should be incremental:

  1. Import current Consortium datasets into the new IR.
  2. Demonstrate output and validation parity.
  3. Teach Consortium to consume an optional locked external bundle.
  4. Make the external bundle the default only after representative examples and CI pass.
  5. Retain a vendored or cached release for bootstrapping and offline builds.

The database must remain useful without Consortium, while Consortium should gain a richer and more reproducible hardware foundation by adopting it.

Hardware-in-the-Loop (HIL) Plans

For Consortium, the gold truth is to run the code on real hardware. However, this is not always possible during development, especially when the hardware is not yet available or when we want to test certain scenarios that are difficult to reproduce on real hardware.

We hereby plan the procedure of conducting the HIL tests.

Boards

We will test on the following boards:

Please be informed that I may be among the first to test on the FRDM-IMX95 board (expected April 2026). As of April 18, DigiKey listed 41 units in stock — I have placed an order for one. No other distributors appeared to have inventory at that time. I therefore expect to receive the board in late April or early May.

Stale & Superseded Drafts

This section archives design and planning drafts whose work has since landed in the workspace. They are kept for provenance — to show how a subsystem arrived at its current shape — but they no longer describe the code as it exists today, and in several places they describe approaches that were deliberately replaced.

Do not treat anything here as normative. When a detail in one of these drafts disagrees with the current crate sources, the sources win. For the present picture of each subsystem, use the architecture chapters, the live subsystem guides, and the crate documentation.

What lives here

  • UART Transport PoC Plan — the proof-of-concept plan for the UART IPC family. The transport now ships across consortium-ipc-transport-uart, consortium-ipc-transport-uart-embedded, and consortium-ipc-transport-uart-unix. The plan’s proposed GAT-to-RPITIT transport refactor is complete, so its before/after trait sketches no longer match consortium-ipc.
  • Buffered UART RX Plan — the plan to bump the chip HALs to embedded-io(-async) 0.7 and add an IRQ-buffered BufferedUart RX driver. Both are in place. The draft also refers to a scratch path on the original author’s machine.
  • Split Transport Halves Plan — the plan to split Transport into SendTransport and RecvTransport. Those half-traits and SharedMemoryTransport::split() now exist; the draft’s associated-type future signatures predate the RPITIT refactor.
  • Early Runtime Initialization Plan — an early sketch of runtime bring-up. It predates the #[consortium_runtime_*::main] entry macros and the generated init()/Context flow, and it assumes doorbell crates install their own #[no_mangle]/#[interrupt] handlers, which is the opposite of the current convention: the application owns the vector table and forwards each IRQ into the doorbell’s exported notifier.
  • Entry Macros Plan — the plan for the runtime entry-point attribute macros. #[consortium_runtime_mcu::main] and #[consortium_runtime_app::main] are implemented and hand the body a single Context aggregate.

UART IPC Transport PoC (consortium-ipc-transport-uart*)

Context

Consortium’s IPC today has one transport: shared memory (consortium-ipc-transport-memory). The docs (IPC introduction) already classify UART as a Wired transport with a Type-B doorbell (notification implicit in transport activity), and Doorbell’s docs reserve the Chan meaning “Serial: logical channel byte in frame header”. This plan adds UART as the second transport candidate, as a proof of concept:

  • embedded side speaks embedded-io-async,
  • _nix side speaks /dev/tty_,
  • split across consortium-ipc-transport-uart (unified entry + frame protocol), consortium-ipc-transport-uart-embedded, consortium-ipc-transport-uart-unix.

Key decision (user-approved): refactor SendTransport/RecvTransport in consortium-ipc from GAT named futures to RPITIT (-> impl Future<...> + MaybeSend, stable since 1.75). Rationale: embedded_io_async::Read/Write futures are unnameable, so they cannot satisfy type SendFut<'a> on stable without boxing (alloc on MCU) or nightly TAIT. Grep confirmed nothing outside the trait/impl files names SendFut/RecvFut; Doorbell (and its WaitFut GAT) stays unchanged.

PoC scope (user-approved): frame protocol + core transport + full unix backend with host tests; embedded crate created as a thin/stub layer with a thumbv8m compile check only. Config schema (Consortium.toml), builder env vars, multi-channel demux, DMA, and the IRQ-fed RX ring are explicitly out of scope for this step.

Step 1 — RPITIT refactor in consortium-ipc

crates/consortium-ipc/src/transport.rs:

#![allow(unused)]
fn main() {
pub trait SendTransport: TransportError {
    fn send(&mut self, data: &[u8])
        -> impl Future<Output = Result<(), Self::Error>> + MaybeSend;
    fn max_send_size(&self) -> usize;
}

pub trait RecvTransport: TransportError {
    fn recv(&mut self, buf: &mut [u8])
        -> impl Future<Output = Result<usize, Self::Error>> + MaybeSend;
    fn max_recv_size(&self) -> usize;
}
}

(Edition 2024 RPITIT captures all in-scope lifetimes automatically, so the old explicit 'a plumbing disappears.)

Update call sites / impls:

  • crates/consortium-ipc/src/channel.rssend/recv bodies already just .await; only the MockTransport test impls change (drop type SendFut/RecvFut, return the same ready(..) values behind -> impl Future or convert to async fn).
  • crates/consortium-ipc/src/transceiver.rs — delegates through Channel; adjust bounds only if it repeats the GAT names.
  • crates/consortium-ipc-transport-memory/src/transport.rs — the six SendTransport/RecvTransport impl blocks (combined + Tx/Rx halves) drop the type SendFut/RecvFut associated types and return the existing hand-rolled SendFut/RecvFut structs from -> impl Future + MaybeSend methods. The future structs themselves are untouched.
  • Transport-memory tests (tests/async_atomic_waker/, tests/async_embassy/, etc.) implement Doorbell, not the transport traits — expected to pass unchanged; fix any stragglers found by cargo test.

Step 2 — consortium-ipc-transport-uart (unified entry, owns the wire protocol)

New crate under crates/ (auto-picked-up by the workspace crates/* glob). no_std unless std; feature shape mirrors transport-memory:

[features]
default  = ["std"]
alloc    = ["consortium-ipc/alloc"]
defmt    = ["consortium-ipc/defmt", "consortium-log/defmt", "dep:defmt"]
embedded = ["dep:consortium-ipc-transport-uart-embedded"]
unix     = ["dep:consortium-ipc-transport-uart-unix", "std"]
std      = ["alloc", "consortium-log/tracing"]

Deps: consortium-ipc (default-features = false), consortium-log, embedded-io-async = 0.7 (no_std), cobs (default-features = false), crc32fast (already in workspace, default-features = false), thiserror.

Wire frame (src/frame.rs)

COBS-delimited for self-resynchronization after line noise:

COBS( chan: u8 | payload[0..=MTU] | crc32: u32 LE ) 0x00
  • chan is the logical channel byte per the Doorbell doc contract; PoC accepts only the constructed Chan and drops mismatches (single channel per port, like the GPIO doorbell is single-channel).
  • CRC32 via crc32fast over chan | payload.
  • pub const fn max_frame_len(mtu: usize) -> usize sizing helper (COBS overhead = ⌈len/254⌉ + 1, plus delimiter) so callers/static_buf! can size scratch buffers.
  • Pure functions encode_frame(chan, payload, out) -> usize and an incremental FrameDeframer state machine (push_byte/push_sliceOption<frame>), fully unit-testable without IO.

Transport (src/transport.rs)

No Side, no Doorbell — byte arrival is the Type-B doorbell:

#![allow(unused)]
fn main() {
pub struct UartTransportTx<W: embedded_io_async::Write> {
    chan: Chan, io: W, scratch: &'static mut [u8], mtu: usize,
}
pub struct UartTransportRx<R: embedded_io_async::Read> {
    chan: Chan, io: R, deframer: FrameDeframer, acc: &'static mut [u8], mtu: usize,
}
pub struct UartTransport<R: Read, W: Write> { tx: UartTransportTx<W>, rx: UartTransportRx<R> }
}
  • Caller-provided &'static mut [u8] scratch/accumulation buffers, sized via max_frame_len(mtu) — same no-alloc discipline as Channel (consortium-runtime-mcu::static_buf! works for these).
  • impl SendTransport: async fn-style body — encode_frame into scratch, io.write_all(..).await, io.flush().await. max_send_size() == mtu.
  • impl RecvTransport: loop io.read(chunk).await, feed the deframer; on complete frame verify CRC + chan, copy payload into the caller’s buffer, return length. Corrupt/oversize/foreign-chan frames are dropped (counted via consortium-log warn) and the loop continues — recv only resolves with a valid frame.
  • Constructed from separate R/W halves so HAL split UART halves and tokio split halves both fit; UartTransport::split() returns the Tx/Rx structs for simultaneous Channel<Tx,..>/Channel<Rx,..> use, mirroring SharedMemoryTransport::split().
  • Error enum via thiserror (Io(E), FrameTooLarge, BufferTooSmall), defmt::Format behind the feature, matching doorbell-crate error style.

Step 3 — consortium-ipc-transport-uart-unix

Thin Linux/macOS std crate:

  • tokio-serial (v5, MIT — meets dependency policy) opens /dev/tty* with raw mode + baud; embedded-io-adapters (tokio-1 feature) wraps the tokio halves as embedded_io_async::Read/Write.
  • Public surface: open(path, baud) -> Result<(UartRx, UartTx)> where the returned types are FromTokio<ReadHalf<SerialStream>> newtypes/aliases feeding straight into UartTransport::new.
  • SerialStream::pair() (pty pair) is exposed for tests behind #[cfg(unix)].

Step 4 — consortium-ipc-transport-uart-embedded (stub for PoC)

Because the core is already generic over embedded-io-async (which Embassy/modern HAL UARTs implement directly), this crate starts minimal:

  • no_std crate that re-exports embedded_io_async and the core transport types, plus doc comments describing the intended pattern (app owns #[interrupt], per repo convention).
  • Planned-but-deferred (documented in the crate docs, not built now): IRQ-fed RX ring implementing embedded_io_async::Read for PAC-level UARTs without an async HAL.
  • Serves as the compile-check anchor for thumbv8m.main-none-eabihf.

Step 5 — Tests

In crates/consortium-ipc-transport-uart/tests/:

  • frame.rs — encode/decode roundtrip, byte-at-a-time feeding, resync after leading garbage, CRC corruption dropped, oversize frame dropped, max_frame_len bounds.
  • loopback.rs (std) — in-memory duplex fake implementing embedded_io_async::Read/Write (Tokio mpsc-backed, lives in tests/common/); transport roundtrip, split halves, fragmented delivery, and a codec-backed Channel<Tx/Rx> postcard roundtrip mirroring the transport-memory channel tests.

In crates/consortium-ipc-transport-uart-unix/tests/:

  • pty_e2e.rsSerialStream::pair() pty loopback: typed Transceiver-style exchange both directions over a real tty fd (runs on macOS and Linux).

Verification

cargo test -p consortium-ipc
cargo test -p consortium-ipc-transport-memory            # refactor regression
cargo test -p consortium-ipc-transport-memory --no-default-features --test async_atomic_waker_test
cargo test -p consortium-ipc-transport-uart
cargo test -p consortium-ipc-transport-uart-unix
just test ipc host
cargo check -p consortium-ipc-transport-uart --no-default-features --target thumbv8m.main-none-eabihf
cargo check -p consortium-ipc-transport-uart-embedded --target thumbv8m.main-none-eabihf
just lint && just fix

End-to-end demo (the PoC deliverable): a small examples-style test or doc snippet driving two UartTransports over the pty pair exchanging postcard-encoded typed messages.

Out of scope (follow-ons, noted for later)

  • Consortium.toml schema (transport = "uart", config.uart = { device, baud }) → consortium-cfg-common types first, then validation/lowering/builder env vars (CONSORTIUM_IPC_UART_*).
  • Multi-channel demux over one port (needs a dispatcher task); PoC is one channel per port.
  • Embedded IRQ ring + DMA TX; AGENTS.md / ipc.md doc updates once the API settles.

Embedded UART IPC: buffered HAL RX + real uart-embedded glue

Context

The UART IPC first step (commit 03d694e) landed the COBS/CRC frame protocol, the UartTransport family (no-std path generic over embedded_io_async 0.7 Read/Write), and the unix backend. consortium-ipc-transport-uart-embedded is still a doc-only re-export stub, per Step 4 of docs/book/src/dev/ipc/probably-use-embedded-io-async-may-twinkly-orbit.md.

Meanwhile the chip HALs (feat(hal): dma and uart) gained embassy-style async UART drivers — Uart/UartRx/UartTx + UartState::on_interrupt with embassy_sync::waitqueue::AtomicWaker — but they implement embedded-io-async 0.6.1 traits, so they cannot satisfy the transport’s 0.7 bounds today (both versions coexist in Cargo.lock). Their async RX is also unbuffered: the FIFO is only drained when the future is polled, so bytes can be lost between IRQ wake and poll while the executor is busy deframing.

Goal (user-confirmed): make the UART transport work end-to-end from firmware by

  1. bumping the HALs to embedded-io(-async) 0.7, and
  2. adding an embassy-BufferedUart-style buffered RX driver to both chip HALs (IRQ drains the FIFO into a ring; async Read pops the ring), keeping the existing direct interrupt-driven async TX (no TX ring), and
  3. turning consortium-ipc-transport-uart-embedded into real glue.

Reference implementation: embassy-stm32/src/usart/buffered.rs (fetched copy at /private/tmp/claude-501/-Users-ethanwu-Developer-consortium/51031b88-5240-454d-961a-1b02f6d2c085/scratchpad/embassy_buffered.rs; or git submodule update --init submodules/embassy — the submodule is registered but not checked out). Ring primitive: embassy_hal_internal::atomic_ring_buffer::RingBuffer (crates.io 0.5.0, MIT/Apache-2.0, no_std — the exact type embassy’s BufferedUart uses). Also skim embassy-imxrt’s LPUART driver (github.com/OpenDevicePartnership/embassy-imxrt) for LPUART-specific flag handling if needed.

Step 1 — Bump HALs to embedded-io(-async) 0.7

crates/consortium-hal-imx9/Cargo.toml and crates/consortium-hal-stm32mp2/Cargo.toml: embedded-io = 0.7, embedded-io-async = 0.7 (lock resolves 0.7.1 / 0.7.0).

0.6→0.7 delta is small: BufRead: Read (we don’t impl BufRead), Write::flush lost its default impl (both uart.rs files already implement it), doc-level semantics clarifications. Expect no code changes; cargo check -p consortium-hal-imx9 -p consortium-hal-stm32mp2 confirms. After this, HAL UartRx/UartTx plug directly into UartTransportRx/UartTransportTx.

Check no other workspace crate pins embedded-io 0.6 (grep found only the two HALs).

Step 2 — Buffered RX driver in both chip HALs

Append to crates/consortium-hal-imx9/src/uart.rs (LPUART) and crates/consortium-hal-stm32mp2/src/uart.rs (USART); the two implementations mirror each other the way the existing unbuffered drivers do. Add embassy-hal-internal = { version = "0.5", optional = true } to the uart feature’s deps.

New API, following the existing UartState convention (library owns on_interrupt, the app owns #[interrupt] and forwards):

#![allow(unused)]
fn main() {
pub struct BufferedUartState {
    inner: UartState,          // base AtomicPtr + rx_waker + tx_waker (reused by UartTx)
    rx_ring: RingBuffer,       // embassy_hal_internal::atomic_ring_buffer::RingBuffer
    // latched line-error/overflow flags (AtomicU8 or AtomicBool set), surfaced as warnings
}
impl BufferedUartState {
    pub const fn new() -> Self;
    /// IRQ entry: drain RX FIFO into the ring, wake rx_waker if bytes arrived;
    /// then the same TIE/TCIE handling as UartState::on_interrupt (TX wakes).
    pub fn on_interrupt(&self);
}

pub struct BufferedUart { rx: BufferedUartRx, tx: UartTx }
impl BufferedUart {
    /// Same contract as Uart::new plus `rx_buf: &'static mut [u8]` ring storage
    /// (rx_ring.init(ptr, len)); enables the receiver interrupt permanently
    /// (RIE / RXFNEIE stays on — that is the point of buffered mode).
    pub unsafe fn new(base: *mut (), clock_hz: u32, config: Config,
                      state: &'static BufferedUartState,
                      rx_buf: &'static mut [u8]) -> Self;
    pub fn split(self) -> (UartTx, BufferedUartRx);
}

pub struct BufferedUartRx { regs, state: &'static BufferedUartState }
}

Details, following embassy’s buffered.rs:

  • on_interrupt RX path: while RDRF/RXFNE && ring writer has space { push DATA/RDR }; ring-full drops bytes (latch an overflow flag — the transport’s CRC layer discards the damaged frame, so read never returns an error for it; emit via the latched-flag warning instead, embassy-style). Wake rx_waker whenever ≥1 byte was pushed (skip embassy’s half-full/eager heuristics — unnecessary at our baud rates).
  • Line errors (PE/FE/NF/ORE): clear the flag, latch it, continue — embassy warns and keeps going; do not fail the read (corrupt bytes are caught by frame CRC).
  • Refactor the TX half of the existing UartState::on_interrupt (the TIE/TCIE blocks) into a shared helper so BufferedUartState::on_interrupt reuses it; UartTx keeps working against state.inner, giving BufferedUart its direct async TX for free.
  • BufferedUartRx::read (mirror embassy BufferedUartRx::read, scratchpad copy lines 616–647): poll_fn that pops reader().pop_slice() into buf; Poll::Ready(n) if any bytes, else register rx_waker + Pending. Implement embedded_io::ErrorType (Error = Error), embedded_io_async::Read, embedded_io::ReadReady (!ring.is_empty()), and a blocking embedded_io::Read (spin on pop).
  • unsafe blocks: document the MMIO base invariant (same wording as Uart::new) and the RingBuffer single-reader/single-writer invariant (reader only in BufferedUartRx, writer only in on_interrupt). Workspace lints deny undocumented unsafe.

Host unit tests (same fake-MMIO style as the doorbell crates / existing sbr_value tests): construct the register block over an aligned heap buffer, set RDRF/RXFNE + DATA, call state.on_interrupt(), assert ring contents and waker wake; assert no push when the ready flag is clear. (Note: a fake status register never self-clears, so the drain loop terminates via the ring-capacity bound — fine for the test.)

Step 3 — consortium-ipc-transport-uart-embedded becomes real glue

crates/consortium-ipc-transport-uart-embedded/:

  • Add optional chip-gated HAL re-exports, mirroring the doorbell-crate feature pattern (non-default chips must use default-features = false on the HAL dep):

    [features]
    defmt      = ["consortium-ipc-transport-uart/defmt", ...]
    mimx93     = ["dep:consortium-hal-imx9", "consortium-hal-imx9/mimx93"]
    mimx95     = ["dep:consortium-hal-imx9", "consortium-hal-imx9/mimx95"]
    stm32mp21x = ["dep:consortium-hal-stm32mp2", "consortium-hal-stm32mp2/stm32mp21x"]
    stm32mp23x = [...] ; stm32mp25x = [...]
    

    HAL deps declared default-features = false, features = ["uart"]; re-export as pub mod imx9 { pub use consortium_hal_imx9::uart::*; } and pub mod stm32mp2 { ... } behind cfg(feature = ...).

  • Rewrite the crate docs into the real end-to-end recipe: static STATE: BufferedUartState, static_buf! ring + transport scratch (rx_buf_len/tx_buf_len), BufferedUart::newsplit()UartTransport::new(chan, rx, tx, ...)Channel<Tx/Rx>, plus the #[interrupt] fn LPUARTn/USARTn() { STATE.on_interrupt() } forwarding block (HAL rt feature), matching the melt-pot mcu style (examples/melt-pot/stm32mp25/mcu/src/main.rs:143).

  • Keep the existing generic re-exports (UartTransport, buffer sizing fns, embedded_io_async).

Optional small addition (do it, it’s tiny and matches ipc-shm): a consortium-runtime-mcu feature ipc-uart = ["dep:consortium-ipc-transport-uart"] (dep with default-features = false) re-exporting the transport, in crates/consortium-runtime-mcu/Cargo.toml + src/lib.rs.

Step 4 — Recipes and docs

  • justfile: add to check-ipc-m8 / check-ipc-m7 (and the matching build-ipc-*): cargo check -p consortium-ipc-transport-uart --no-default-features, cargo check -p consortium-ipc-transport-uart-embedded --features mimx95 (and/or stm32mp25x) for both thumb targets. Add to test-ipc-host: cargo test -p consortium-ipc-transport-uart, cargo test -p consortium-ipc-transport-uart --no-default-features --test loopback_embedded, cargo test -p consortium-ipc-transport-uart-unix.
  • AGENTS.md UART IPC paragraph: replace “consortium-ipc-transport-uart-embedded is currently a firmware re-export; an IRQ-fed PAC-level adapter is planned but not implemented” with the buffered-HAL-driver + glue description; note the HAL embedded-io 0.7 bump in the HAL/PAC section if wording there mentions versions.
  • Update the Step-4 section of docs/book/src/dev/ipc/probably-use-embedded-io-async-may-twinkly-orbit.md only if the user wants the historical plan annotated (repo treats these as history — default: leave).

Files touched

  • crates/consortium-hal-imx9/{Cargo.toml,src/uart.rs}
  • crates/consortium-hal-stm32mp2/{Cargo.toml,src/uart.rs}
  • crates/consortium-ipc-transport-uart-embedded/{Cargo.toml,src/lib.rs}
  • crates/consortium-runtime-mcu/{Cargo.toml,src/lib.rs} (ipc-uart re-export)
  • justfile, AGENTS.md

Not touched: consortium-ipc (RPITIT traits final), the core transport crate (consortium-ipc-transport-uart already has the embedded impl family), frame protocol, unix backend, Consortium.toml schema/builder (still out of scope per the original plan).

Verification

# HAL bump + buffered driver
cargo test -p consortium-hal-imx9
cargo test -p consortium-hal-stm32mp2
cargo check -p consortium-hal-imx9 --target thumbv8m.main-none-eabihf
cargo check -p consortium-hal-stm32mp2 --target thumbv8m.main-none-eabihf

# transport still green
cargo test -p consortium-ipc-transport-uart
cargo test -p consortium-ipc-transport-uart --no-default-features --test loopback_embedded
cargo test -p consortium-ipc-transport-uart-unix

# glue compiles for firmware targets, both chips
cargo check -p consortium-ipc-transport-uart-embedded --features mimx95     --target thumbv7em-none-eabihf
cargo check -p consortium-ipc-transport-uart-embedded --features stm32mp25x --target thumbv8m.main-none-eabihf
cargo check -p consortium-runtime-mcu --no-default-features --features ipc-uart --target thumbv8m.main-none-eabihf

just test ipc host
just lint && just fix

Single cargo tree -p consortium-ipc-transport-uart-embedded --features mimx95 -i embedded-io-async should show only 0.7 in that graph (no 0.6/0.7 trait split on the firmware path).

Plan: Split Transport into SendTransport + RecvTransport Halves

Context

Channel<Tx> and Channel<Rx> both own a Tr: Transport. Because Transport::send and Transport::recv both take &mut self, two channels cannot hold the same transport simultaneously. The current workaround in tests is constructing two separate SharedMemoryTransport instances (with doorbell.clone()) to get a Tx/Rx channel pair. The cleaner design is to split Transport into SendTransport + RecvTransport half-traits, add a concrete split type pair for SharedMemoryTransport, and have each Channel direction own only the half it needs.


Step 1 — Split the Transport trait (crates/consortium-ipc/src/transport.rs)

Add a TransportError supertrait (just the Error associated type) so ChannelError can remain bounded on a single trait:

#![allow(unused)]
fn main() {
pub trait TransportError {
    type Error;
}

pub trait SendTransport: TransportError {
    type SendFut<'a>: Future<Output = Result<(), Self::Error>> + MaybeSend where Self: 'a;
    fn send<'a>(&mut self, data: &[u8]) -> Self::SendFut<'a>;
    fn max_send_size(&self) -> usize;
}

pub trait RecvTransport: TransportError {
    type RecvFut<'a>: Future<Output = Result<usize, Self::Error>> + MaybeSend where Self: 'a;
    fn recv<'a>(&mut self, buf: &mut [u8]) -> Self::RecvFut<'a>;
    fn max_recv_size(&self) -> usize;
}

/// Combined marker — implement for types that cover both directions.
pub trait Transport: SendTransport + RecvTransport {}
}

Keep Transport as the combined marker for backwards compatibility. Any type implementing both halves can explicitly impl Transport for T {}.


Step 2 — Update Channel and ChannelError (crates/consortium-ipc/src/channel.rs)

  • Remove the Tr: Transport bound from the struct definition; push bounds to impl blocks.
  • impl<Tx> block: Tr: SendTransport
  • impl<Rx> block: Tr: RecvTransport
  • Change ChannelError<C: Codec, Tr: Transport>ChannelError<C: Codec, Tr: TransportError> (same callers, relaxed bound).
  • Debug/Display impls stay structurally identical.

Step 3 — Add Clone to region types (crates/consortium-ipc/src/regions.rs)

SramRegion and DdrRegion hold a raw pointer + length. Add manual Clone impls (both halves intentionally alias the same shared memory region):

#![allow(unused)]
fn main() {
impl Clone for SramRegion { fn clone(&self) -> Self { Self { ptr: self.ptr, len: self.len } } }
impl Clone for DdrRegion  { fn clone(&self) -> Self { Self { ptr: self.ptr, len: self.len } } }
}

Step 4 — Add split halves and split() to SharedMemoryTransport (crates/consortium-ipc-transport-memory/src/transport.rs)

Add two new concrete types that mirror the existing struct fields, keeping only what each direction needs:

#![allow(unused)]
fn main() {
pub struct SharedMemoryTransportTx<D: Doorbell, R: SharedMemoryTransportable> {
    ch: Chan, doorbell: D, region: R, base: *mut u8, tx_mtu: u32,
}

pub struct SharedMemoryTransportRx<D: Doorbell, R: SharedMemoryTransportable> {
    ch: Chan, doorbell: D, region: R, base: *mut u8, rx_mtu: u32,
}
}
  • SharedMemoryTransportTx implements TransportError + SendTransport (moves the send logic from the current Transport impl).
  • SharedMemoryTransportRx implements TransportError + RecvTransport (moves the recv + RecvFut::poll logic).
  • Both need their own SendFut/RecvFut future types (copy the existing ones, updating the pointer type).
  • Add split() on SharedMemoryTransport (requires D: Clone, R: Clone):
#![allow(unused)]
fn main() {
pub fn split(self) -> (SharedMemoryTransportTx<D, R>, SharedMemoryTransportRx<D, R>)
where
    D: Clone, R: Clone,
}

The doorbell and region are cloned for the Rx half; the originals go to the Tx half. The base raw pointer is copied to both (intentional shared-memory aliasing, same safety contract as SharedMemoryTransport::new).

Keep impl Transport for SharedMemoryTransport<D, R> by delegating to SendTransport/RecvTransport impls, or have it implement all three traits directly. The combined SharedMemoryTransport is still useful for callers that don’t need to split.


Step 5 — Update re-exports (crates/consortium-ipc/src/lib.rs)

Add SendTransport, RecvTransport, TransportError to the pub use list alongside the existing Transport.


Step 6 — Update tests (crates/consortium-ipc-transport-memory/tests/channel_codec_test.rs)

Replace the two-transport workaround with split():

#![allow(unused)]
fn main() {
// Before
let tx_transport = unsafe { SharedMemoryTransport::new(ch, a_doorbell.clone(), tx_region, a_base) };
let mut tx = Channel::<Tx, Message, _, C>::new(ch, tx_transport, scratch::<MTU>());
// ... (sequential; can't hold both simultaneously)
let rx_transport = unsafe { SharedMemoryTransport::new(ch, a_doorbell, rx_region, a_base) };
let mut rx = Channel::<Rx, Message, _, C>::new(ch, rx_transport, scratch::<MTU>());

// After
let transport = unsafe { SharedMemoryTransport::new(ch, a_doorbell, region, a_base) };
let (tx_half, rx_half) = transport.split();
let mut tx = Channel::<Tx, Message, _, C>::new(ch, tx_half, scratch::<MTU>());
let mut rx = Channel::<Rx, Message, _, C>::new(ch, rx_half, scratch::<MTU>());
}

Also update the mock MockTransport in channel.rs unit tests to implement SendTransport/RecvTransport (or keep Transport impl and add blanket forward — whichever is less churn).


Verification

cargo test -p consortium-ipc
cargo test -p consortium-ipc-transport-memory
just test ipc host
cargo clippy -p consortium-ipc -p consortium-ipc-transport-memory

Plan: Runtime Initialization for runtime-mcu and runtime-app

Context

Both runtime crates are still mostly thin re-export/init surfaces — no full bootstrap, channel setup, or executor wiring. The core IPC, transport, and doorbell crates are complete; the runtimes need to wire them together into usable startup sequences.

The goal is to define what initialization code each crate should expose, and in what order those steps must occur.


Startup Sequence (both sides must agree)

M-core                           A-core
------                           ------
1. Init NVIC / enable IRQs       1. RemoteProc::start() [optional]
2. Construct doorbell            2. Poll descriptor until MReady
3. Construct transport           3. UioDevice::open() (needs Ready state)
4. Write DescState::MReady       4. dev.channel_transport(chan, ...) → SharedMemoryTransport
5. Wait for A-core ack           5. Channel::new(chan, uio_ch, buf)
6. Write DescState::Ready        6. Spawn Tokio tasks
7. Construct Channels
8. Run executor + spawn tasks

runtime-mcu — What to Initialize

1. NVIC / Interrupts

  • For HSEM: enable HSEM1 IRQ in NVIC. The HSEM_IRQHandler is already #[no_mangle] in doorbell-hsem.
  • For IPCC: enable the board/PAC-selected RX occupied interrupt line and call notify_rx_occupied_interrupt() from that ISR. STM32MP21 uses IPCC only; STM32MP23/25 should prefer IPCC over the stale HSEM path.
  • For MU: enable the IRQ for the MU instance selected by the SoC route. On current i.MX95 CA55 <-> CM7 defaults, use MU7 (runtime helper IRQ 207 on CM7) and channels 24/25. doorbell-mu provides chip-gated MU1_IRQHandler through MU8_IRQHandler where applicable.
  • Runtime exposes verified generic doorbell IRQ helpers only where IRQ numbers are known; IPCC IRQ routing should remain board/PAC-provided until the interrupt table is wired in.
  • These are one-liners but must be called before any doorbell.wait().

2. Doorbell Construction

  • HSEM: HsemDoorbell::<1>::new() — zero-cost, no args.
  • MU: MuDoorbell::default() or MuDoorbell::new(bases) — needs [usize; N] base addresses, where N is selected by imx8mp, imx93, or imx95. i.MX95 CA55 <-> CM7 code should default to IMX95_CA55_CM7_TX_CHAN/IMX95_CA55_CM7_RX_CHAN (flat channel IDs 24/25 on MU7).
  • Runtime should re-export these with correct feature gating.

3. Shared Memory Descriptor (new)

  • M-core must write the descriptor header (magic 0x434F_4E53, version 1, channel slots) at offset 0 of the shared SRAM region before A-core can open the UIO device.
  • Expose descriptor initialization around the existing consortium-ipc-transport-memory::descriptor::descriptor_init() and set_state() helpers.
  • M-core writes KernelInit → Proposed → MReady after transport is set up; then waits for A-core to set Ready.

4. SharedMemoryTransport Init Helper

  • Provide a safe(r) wrapper around SharedMemoryTransport::new() that validates the descriptor/channel setup:
    #![allow(unused)]
    fn main() {
    pub fn shared_memory_transport<D, R>(base: *mut u8, chan: Chan, doorbell: D, region: R) -> SharedMemoryTransport<D, R>
    }
  • Slot stride = 8 + mtu bytes. Tx slot at base + slot_idx * stride, Rx slot at the mirror offset negotiated via the descriptor.

5. Static Scratch Buffers

  • User must provide &'static mut [u8] — runtime cannot allocate in no_std.
  • Provide a macro static_buf!(NAME, SIZE) that declares a properly aligned static buffer. This is a common pattern in Embassy; we can replicate it.

6. No Executor — Bring Your Own

  • Do not embed an executor. Users will use Embassy, RTIC2, or a bare-metal spin loop.
  • Document that runtime-mcu provides IPC init; the executor is the user’s responsibility.

runtime-app — What to Initialize

1. Keep Runtime Re-exports Focused

  • runtime-app now re-exports consortium_ipc_transport_memory as ipc_transport and owns the Linux UIO wrapper under runtime_app::uio.

2. Firmware Lifecycle (existing, wire it up)

  • RemoteProc is already complete. Expose it at the top-level re-export.
  • Optionally add RemoteProc::wait_state(target, timeout) async helper that polls sysfs in a Tokio interval loop.

3. Descriptor Polling (new)

  • Before UioDevice::open(), the descriptor must be in Ready(3).
  • Expose async fn wait_for_ready(uio_num: usize, timeout: Duration) -> Result<(), Error> that mmaps resource 0 without full init, polls DescState byte, and returns when Ready.

4. UioDevice / Shared Transport Init

  • Re-export UioDevice.
  • UioDevice::open(uio_num, num_channels, transfer_size)dev.channel_transport(chan, doorbell, region)Channel::new(chan, transport, buf).
  • Provide a convenience function:
    #![allow(unused)]
    fn main() {
    pub async fn open_channel<T>(uio_num: usize, chan: Chan, buf: &'static mut [u8]) -> Result<Channel<Rx, T, SharedMemoryTransport<_, _>>, Error>
    }

5. Tokio Runtime

  • runtime-app is already tokio-based (UioDevice spawns tasks internally).
  • Do not embed #[tokio::main] — callers bring their own runtime. Document this.
  • No wrapping needed; just correct re-exports.

6. Doorbell / Notify Wiring (STM32 HSEM path only)

  • UioDevice has its own Arc<Notify> for the IRQ loop (already wired).
  • HsemDoorbell::<2>::new(vaddr) has its own Arc<Notify> — these are separate and currently not bridged.
  • For the UIO path the separate HSEM Notify is unused; UioDevice’s internal loop handles wakeup.
  • For the standalone (non-UIO) HSEM A-core path: expose HsemDoorbell::notifier() in the re-export and document that callers must call notify.notify_waiters() from their IRQ handler.
  • No bridging code needed now; document the two paths clearly.

Critical Files to Modify

FileChange
crates/consortium-runtime-mcu/src/lib.rsAdd doorbell IRQ helpers where verified, descriptor write helpers, mem_transport_for_slot, static_buf! macro
crates/consortium-runtime-app/src/lib.rsFix re-export name, add wait_for_ready, open_channel convenience fn
crates/consortium-runtime-app/src/remoteproc.rsAdd wait_state async helper
New: crates/consortium-runtime-mcu/src/descriptor.rsDescriptor write + state helpers for M-core side
New: crates/consortium-runtime-app/src/descriptor.rsDescriptor poll helpers for A-core side

Existing re-usable code:

  • consortium-ipc-transport-memory/src/descriptor.rs — already parses the descriptor format; M-core init should use the same layout constants
  • consortium-ipc-transport-memory/src/lib.rs — shared-memory transport constructor to wrap
  • consortium-runtime-app/src/remoteproc.rsRemoteProc (complete, no changes needed)

Design Decisions (confirmed)

  1. Descriptor ownership: M-core writes the full descriptor (magic 0x434F_4E53, version 1, channel count, slot offsets). A-core polls until Ready(3). Runtime needs a full descriptor::write() API on the M-core side.

  2. Executor: Embassy. Use embassy_executor::task entry points and static_cell::StaticCell (or Embassy’s StaticCell) for static scratch buffers. Add embassy-executor and static-cell to runtime-mcu dependencies.

  3. MU chip features: Split imx9x into imx93 and imx95 sub-features, with imx9x as an umbrella that enables the mu doorbell crate. Feature flags: imx9x = [], imx93 = ["imx9x", "consortium-ipc-doorbell-mu/imx93"], imx95 = ["imx9x", "consortium-ipc-doorbell-mu/imx95"].


Verification

After implementation:

  1. cargo build -p consortium-runtime-mcu --features stm32mp25x --target thumbv7em-none-eabi — must compile clean
  2. cargo build -p consortium-runtime-app (Linux host) — must compile clean (fixes broken re-export)
  3. Write an integration doc/example showing M-core init → MReady → A-core wait_for_readyUioDevice::open → message exchange

#[consortium_runtime_{mcu,app}::main] runtime entry macros

Context

Today every melt-pot endpoint hand-writes the same boilerplate around the generated consortium.gen.rs:

  • MCU (examples/melt-pot/*/mcu/src/main.rs): #[embassy_executor::main], then ConsortiumLogger::init(), init_time_driver(), init() (sync MPU/NVIC) → .connect().await → match/wfi on error, then peripherals().
  • App (examples/melt-pot/*/app/src/main.rs): #[tokio::main], then match init().await { Ok(endpoints) => …, Err(e) => … }.

The generated code exposes several differently-named handles (ConsortiumEndpoints, IpcMemoryEndpoints, IpcMemoryEndpointsConnected, Peripherals) and a two-phase init()/connect() dance on MCU that every firmware repeats. The user wants a single attribute macro that hides this and hands the body one aggregate value named Context, reinforcing “context” as the term for the whole per-manifest resource bundle:

// MCU firmware (no_std, embassy)
#[consortium_runtime_mcu::main]
async fn main(context: Context, spawner: Spawner) { /* context.ipc_shm.sensor … */ }

// Linux app (tokio)
#[consortium_runtime_app::main]
async fn main(context: Context) { /* context.ipc_shm.sensor … */ }

Intended outcome: the macro wraps the runtime (embassy_executor::main / tokio::main), calls the generated aggregate async init(), and binds its Context result into the user’s body. On init failure it dispatches to an optional user #[…::fail] handler, falling back to a hard fail.

Confirmed design decisions (from user)

  1. IPC access stays context.ipc_shm.sensor — keep the current ipc_shm field name (matches AGENTS.md and today’s app example). Peripherals live at context.peripherals.
  2. Init-failure policy: on Err, the glue calls a user handler defined with a companion attribute #[consortium_runtime_mcu::fail] / #[…app::fail]. If the user defined none, hard-fail (MCU: consortium_log::error! + wfi loop; App: tracing error + std::process::exit(1)).
  3. Spawner stays a separate second parameter on MCU (embassy muscle memory; it is Copy executor infrastructure, not a per-manifest resource). App takes only context.
  4. init_time_driver() and defmt::timestamp! are sealed too — the user no longer hand-writes the embassy time-driver bring-up, the timer ISR, or the defmt timestamp source. Because these are chip-specific, they cannot live in the (chip-agnostic) proc-macro; they are generated into consortium.gen.rs and driven by the #[main]-called init(). From the user’s main.rs they are gone — “sealed” as requested — the macro is just the seam that triggers them.

Verified facts

  • The descriptor handshake (descriptor_init/descriptor_ack, crates/consortium-ipc-transport-memory/src/descriptor.rs:315,379) only awaits doorbell.wait().awaitno embassy-time. So folding connect() into the generated init needs no time-driver ordering; the user’s init_time_driver() can run in the body. No pre_init attr arg is needed now.
  • Glob-import shadowing works on stable (edition 2024) for the #[fail] dispatch: a module-scope fn __consortium_init_fail shadows a glob-imported default with no conflict; when absent the glob default is used. Verified with a standalone rustc compile. The inserted glob use gets #[allow(unused_imports)].
  • Codegen entry points: crates/consortium-cfg/src/bridge.rsLoweredIr::initialize (AP path, bridge.rs:190) and controller_init (MCU path, bridge.rs:265). Endpoint structs from src/components/ipc_shm.rs:576 (ShuHan::initialize), NVIC from src/components/bella.rs, Peripherals + peripherals() from src/components/peripheral.rs:72 (returns Option<TokenStream>).
  • Runtime crates re-export nothing runtime-executor related today: consortium-runtime-mcu has no embassy-executor dep; consortium-runtime-app has no tokio::main. The example crates own those deps (embassy-executor 0.10.0, features executor-thread + platform-cortex-m; tokio with macros,rt-multi-thread). The macro’s expansion references ::embassy_executor / ::tokio which resolve in the user crate.
  • Macro convention = thin facade (proc-macro = true) + -impl crate over proc_macro2::TokenStream; shared syn helpers in consortium-macros-helpers (already has get_output_type, is_result_returning, extract_result_inner). Tests = insta snapshots calling impl fns on quote!{} input; errors emitted as compile_error! tokens. Model: consortium-tee-macros{,-impl} (tee_command attribute + syn::parse::Parse attr-args). Root Cargo.toml members = ["crates/*", …] auto-includes new crates.
  • Time driver: both HALs expose timer_driver::init_timer(base: *mut (), clock_hz: u32) and timer_driver::on_timer_interrupt(). The ISR mechanism differs per chip: stm32mp25 uses cortex-m-rt #[interrupt] fn TIM2(); imx95 uses #[unsafe(no_mangle)] extern "C" fn LPIT1_IRQHandler(). Bring-up differs too: stm32 pokes RCC (TIM2CFGR: RST off/EN/LPEN) before init_timer, then nvic::set_priority+enable; imx95 assumes the platform already clocked LPIT1 and only does init_timer+nvic. No timer/time-driver metadata exists in consortium-data/consortium-cfg yet — the examples hardcode base/IRQ/clock constants (stm32: TIM2 @ 0x4000_0000, IRQ 105, 64 MHz; imx95: LPIT1 @ 0x442f_0000, IRQ 15, 24 MHz). defmt::timestamp! is currently a hand-written monotonic AtomicU32 counter, independent of the time driver. Codegen already emits peripheral ISRs (peripheral.rs emits #[unsafe(no_mangle)] extern "C" fn LPI2C1_IRQHandler), so emitting a timer ISR the same way lands it in the app-owned consortium.gen.rs and respects the repo convention that libraries install no #[interrupt] handlers.

New crates

crates/consortium-runtime-macros-impl

Non-proc-macro logic crate. Deps: syn = "2" (full), quote, proc-macro2, consortium-macros-helpers (path). Dev-deps: insta, prettyplease. Edition 2024. Public fns operating on proc_macro2::TokenStream:

  • pub fn mcu_main(attr: TokenStream2, item: TokenStream2) -> TokenStream2
  • pub fn app_main(attr: TokenStream2, item: TokenStream2) -> TokenStream2
  • pub fn mcu_fail(attr: TokenStream2, item: TokenStream2) -> TokenStream2
  • pub fn app_fail(attr: TokenStream2, item: TokenStream2) -> TokenStream2

crates/consortium-runtime-macros

Facade, [lib] proc-macro = true. Single path dep on -impl. Four #[proc_macro_attribute] wrappers converting .into() both ways (mirrors consortium-tee-macros/src/lib.rs).

Macro behavior

mcu_main / app_main

Parse the item as syn::ItemFn. Validate (emit compile_error! on failure, per convention):

  • must be async;
  • name is main;
  • MCU: exactly two params — first context (any binding ident; type is written by the user as Context, but the macro ignores the annotated type and binds the init result to that ident), second is the spawner passed through to embassy;
  • App: exactly one param (context);
  • return type () (the aggregate flow owns error handling; a non-unit return is a compile_error! for now).

MCU expansion (idents: user’s context binding = $ctx, spawner = $sp, body $body):

#![allow(unused)]
fn main() {
#[allow(unused_imports)]
use ::consortium_runtime_mcu::__rt::__consortium_init_fail;   // glob-shadowable default
#[::embassy_executor::main]
async fn __consortium_entry($sp: ::embassy_executor::Spawner) {
    let $ctx = match init().await {
        ::core::result::Result::Ok(c) => c,
        ::core::result::Result::Err(e) => __consortium_init_fail(e),  // -> !
    };
    $body
}
}

(Use a glob use ::consortium_runtime_mcu::__rt::*; — not the named import above — so a user #[fail]-emitted __consortium_init_fail shadows it. Shown named for clarity; implement as the * glob with #[allow(unused_imports)].)

App expansion:

#![allow(unused)]
fn main() {
#[allow(unused_imports)]
use ::consortium_runtime_app::__rt::*;
#[::tokio::main]
async fn __consortium_entry() {
    let $ctx = match init().await {
        ::core::result::Result::Ok(c) => c,
        ::core::result::Result::Err(e) => __consortium_init_fail(e),
    };
    $body
}
}

init is called unqualified — it resolves to the generated init() in the same module (the crate include!s consortium.gen.rs at module scope). ::embassy_executor / ::tokio resolve in the user crate (they already depend on them; tokio needs its macros feature, which the app examples enable).

mcu_fail / app_fail

Parse syn::ItemFn fn on_fail(err: InitError) { … } (or -> !). Emit the user’s fn plus a diverging wrapper at the fixed name the glue calls:

#![allow(unused)]
fn main() {
fn on_fail(err: /* user type */) { … }          // user's item, unchanged
fn __consortium_init_fail<E>(e: E) -> ! {         // shadows the runtime default
    on_fail(e);                                   // if user fn is `-> !`, this diverges
    // MCU: loop { ::cortex_m::asm::wfi() }   App: ::std::process::exit(1)
    <hard-fail tail>
}
}

Generic <E> so it accepts whatever concrete error the generated init() returns without the impl needing to name it. If the user handler is -> (), the wrapper runs the hard-fail tail after it; if -> !, the tail is unreachable (allow warning).

Runtime default fail handlers (hidden __rt modules)

  • consortium-runtime-mcu/src/lib.rs: #[doc(hidden)] pub mod __rt { pub fn __consortium_init_fail<E>(_e: E) -> ! { crate::log::error!("consortium init failed"); loop { ::cortex_m::asm::wfi() } } }. No new deps (consortium_log, cortex-m already present). Logs a static message — the error type is not defmt::Format-bounded; users who want detail use #[fail] with the concrete type.
  • consortium-runtime-app/src/lib.rs: analogous, tracing-style error via crate::log + ::std::process::exit(1).

Runtime re-exports

  • consortium-runtime-mcu: pub use consortium_runtime_macros::{mcu_main as main, mcu_fail as fail}; behind a default macros feature (dep on the facade). no_std-safe: proc-macro deps don’t affect the target binary.
  • consortium-runtime-app: pub use consortium_runtime_macros::{app_main as main, app_fail as fail}; (same macros default feature, under the existing cfg(target_os="linux")).

Codegen changes (crates/consortium-cfg)

Unify the aggregate under a generated struct Context on both sides and provide one canonical async fn init() -> Result<Context, ConsortiumInitError> the macro calls.

  • AP (bridge.rs:190-244): rename struct ConsortiumEndpointsstruct Context (keep fields _consortium_dbg, uio, ipc_shm). init() already async and returns Result<_, ConsortiumInitError>; just swap the type name. Keep IpcMemoryEndpoints, IpcMemoryEndpointsConnected, ConsortiumConnectError unchanged (from ipc_shm.rs).
  • MCU (controller_init, bridge.rs:265-351): replace the sync #[cfg(target_arch="arm")] fn init() -> IpcMemoryEndpoints with an aggregate:
    #![allow(unused)]
    fn main() {
    struct Context {
        ipc_shm: IpcMemoryEndpointsConnected,
        peripherals: Peripherals,        // only when [peripheral.*] present for this core
    }
    #[cfg(target_arch = "arm")]
    async fn init() -> ::core::result::Result<Context, ConsortiumInitError> {
        // when [dbg.<core>] configured: ::consortium_dbg::logger::ConsortiumLogger::init();
        <steal + MPU carveouts + NVIC unmask, as today>
        __consortium_init_time_driver();     // sealed embassy time-driver bring-up (see below)
        let ipc_shm = IpcMemoryEndpoints::new().connect().await?;   // ConsortiumConnectError -> ConsortiumInitError
        let peripherals = peripherals();                             // if present
        ::core::result::Result::Ok(Context { ipc_shm, peripherals })
    }
    }
    Add a small MCU ConsortiumInitError wrapping ConsortiumConnectError (derive defmt::Format under target_os="none", Debug otherwise — mirror the connect_error_derive split in ipc_shm.rs:645), with a From<ConsortiumConnectError>. Keep IpcMemoryEndpoints::new() and connect() public for non-macro / two-phase users.

Sealed time driver + defmt timestamp (new codegen, MCU only)

New component (e.g. crates/consortium-cfg/src/components/time.rs) driven by the core’s chip identity (McoreName + chip string; reuse hal_crate_path). It emits into consortium.gen.rs, at module scope:

  1. __consortium_init_time_driver() — calls a HAL bring-up helper so the chip-specific RCC/base/clock detail stays in the HAL (per AGENTS.md “chip-specific details inside the chip HAL”): add consortium_hal_stm32mp2::timer_driver::bringup() and consortium_hal_imx9::timer_driver::bringup() that encapsulate today’s example bodies (stm32: RCC TIM2CFGR enable → init_timer(TIM2_BASE, 64 MHz) → nvic; imx95: init_timer(LPIT1_BASE, 24 MHz) → nvic). Codegen emits unsafe { <hal>::timer_driver::bringup(); } with a SAFETY comment.
  2. The timer ISR — chip-dependent form: stm32 #[interrupt] fn TIM2() { <hal>::timer_driver::on_timer_interrupt(); }; imx95 #[unsafe(no_mangle)] extern "C" fn LPIT1_IRQHandler() { <hal>::timer_driver::on_timer_interrupt(); }. Reuse the emission style already in peripheral.rs (which emits LPI2C1_IRQHandler the same way). Gate on #[cfg(target_arch = "arm")] (needs the HAL rt feature for #[interrupt], already enabled by the example crates).
  3. defmt::timestamp! — emit the monotonic AtomicU32 counter (chip-agnostic, no dependency on time-driver state), gated on [dbg.<core>] / defmt being configured, so it is defined exactly once. (Switching it to read embassy_time::Instant::now() is a future option once ordering guarantees are firmed up.)

Timer parameters (base, IRQ, clock_hz, ISR name/mechanism) come from a small per-chip default table in the new component for the two supported chips. These are demo-board values with a boot-handoff assumption (the platform leaves the timer clocked at the stated rate) — surface a config opt-out so integrators who own their own time base can disable the sealed driver (e.g. a [profile]/[runtime] time_driver = false key, or per-core); default on for MCU cores. When disabled, __consortium_init_time_driver() is a no-op and no ISR/timestamp is emitted, and the integrator supplies their own (as today).

  • struct Context (both sides) has no lifetime parameter — the AP Context owns its Vec<UioDevice> and the connected transceivers borrow 'static scratch buffers + the owned mmap windows, exactly as ConsortiumEndpoints does today. The user’s sketched Context<'static> is not needed.
  • Keep struct Context ungated; keep only fn init() under #[cfg(target_arch="arm")] so the host trybuild harness still compiles the module (it appends its own fn main(){} and never links cortex-m/embassy).

Example updates (all four main.rs)

  • stm32mp25 mcu (sketch) — note how much is removed:

    #![no_std] #![no_main]
    use embassy_executor::Spawner;
    use embassy_time::Timer;
    use melt_pot_shared::SensorReading;
    include!("consortium.gen.rs");
    // REMOVED (now sealed/generated): defmt::timestamp!, TIM2_* consts,
    //   init_time_driver(), #[interrupt] fn TIM2(), ConsortiumLogger::init().
    
    #[consortium_runtime_mcu::main]
    async fn main(context: Context, _spawner: Spawner) {
        let mut sensor = context.ipc_shm.sensor;   // time driver + logger already up
        let mut seq = 0u32;
        loop { … sensor.send(&reading).await …; Timer::after_secs(1).await; }
    }
    // Still user-owned: doorbell #[interrupt] fn IPCC1_RX/IPCC2_RX (forward to
    //   consortium_ipc_doorbell_ipcc::notify_*), panic_handler, HardFault.

    (imx95 mcu analogous: keep the MU7_B doorbell handler + panic-halt; LPIT1 time driver, defmt::timestamp!, and LPIT1_IRQHandler become generated; use context.peripherals.)

  • app (both): #[consortium_runtime_app::main] async fn main(context: Context) { … }; the receive loop uses context.ipc_shm.sensor. Drop the manual #[tokio::main] + match.

  • imx95 app name clash: it imports optee_teec::Context. Alias it (use optee_teec::Context as TeeContext;) so the generated Context wins the bare name. Flag in the plan; fix in that file.

  • Optional #[consortium_runtime_mcu::fail] handler can be added to one example to demonstrate, but keep default hard-fail elsewhere.

Tests

  • consortium-runtime-macros-impl/tests/snap.rs (new): insta snapshots for each of the four impl fns over quote!{} inputs — happy path (mcu 2-arg, app 1-arg, fail handler) and error paths (non-async, wrong arity, non-unit return) emitting compile_error!. Follow consortium-ipc-macros-impl/tests/snap.rs (parse → prettyplease::unparseassert_snapshot!).
  • consortium-cfg: regenerate with UPDATE_SNAPSHOTS=1:
    • tests/snap.rs insta: snap__shuhan_ap_side_emits_full_endpoint_struct.snap, snap__shuhan_controller_side_emits_single_endpoint_struct.snap, and any controller-init/bella snapshots that render the Context/init() shape.
    • tests/conf.rs per-config tests/configs/{imx95,stm32mp257}/artifacts.snap.
    • tests/ui.rs regenerates tests/ui/linux/*__ap.rs and tests/ui/portable/*__<core>.rs and type-checks them; confirm the new async MCU init() + ungated Context still compile on host (init is arm-gated, Context is not). The generated timer ISR + __consortium_init_time_driver are also arm-gated so the host fixtures skip them; the defmt::timestamp! emission is defmt-gated so it stays out of the host build.
    • New snapshot(s) for the time-driver component (ISR + bringup call + timestamp) per chip.
  • HAL: cargo test/cargo check -p consortium-hal-stm32mp2 -p consortium-hal-imx9 for the new timer_driver::bringup() helpers (host-buildable parts; full check under the chip target via just).

Docs

  • AGENTS.md: update the IPC Core / Runtime Layers sections to describe the Context aggregate, #[consortium_runtime_{mcu,app}::main], and #[…::fail]; add the new macro crates to the Macro helpers row and Workspace Shape table.
  • mdBook: optional short page under docs/book/src/ on the runtime entry macros (defer if scope-limited).

Implementation order

  1. Scaffold consortium-runtime-macros-impl + facade; implement mcu_main/app_main (main path) with insta snapshots.
  2. Add mcu_fail/app_fail + the hidden __rt default handlers in both runtime crates; wire the macros feature + re-exports.
  3. HAL: add timer_driver::bringup() to consortium-hal-stm32mp2 and consortium-hal-imx9 (lift the example bodies; chip constants live here).
  4. Codegen: AP rename → Context; MCU aggregate async init() + Context + MCU ConsortiumInitError; new components/time.rs (time-driver init call + timer ISR + defmt::timestamp!, per-chip table + opt-out); keep two-phase API. Regenerate consortium-cfg snapshots.
  5. Update the four example main.rs (+ imx95 optee_teec::Context alias); delete the now hand-written timestamp/time-driver/timer-ISR from all mcu examples.
  6. Docs (AGENTS.md).

Verification

  • cargo test -p consortium-runtime-macros-impl — macro snapshots.
  • cargo test -p consortium-cfg (then UPDATE_SNAPSHOTS=1 cargo test -p consortium-cfg to accept intentional codegen diffs; re-run to confirm green) — snap + conf + ui fixtures, including the trybuild generated_init_code_compiles that type-checks the rendered app + mcu modules.
  • cargo check -p consortium-runtime-mcu and cargo check -p consortium-runtime-app (Linux) — re-exports + default fail handlers compile.
  • just check / just lint for the host bundle; just test ipc host.
  • Full example firmware/app builds go through the builder (csti build generates consortium.gen.rs); the consortium-cfg ui fixtures are the in-repo proxy that the generated Context/init() compiles for both host-app and portable-mcu shapes. If a provisioned target is available, just thumbv8m/aarch64 recipes confirm the macro expansion links against the real embassy/tokio deps.

Risks

  • Context name clash with optee_teec::Context in imx95 app — resolved by aliasing the TEE import (plan step 4).
  • include! hygiene: the macro calls init and Context unqualified; they resolve because consortium.gen.rs is include!d into the same module as the #[main] fn. Any crate that puts the macro and the include in different modules must re-export/use them — document in AGENTS.md.
  • tokio::main needs the macros feature in the app crate; embassy needs executor-thread + platform-cortex-m — already satisfied by the examples; document as a requirement of #[…app::main] / #[…mcu::main].
  • Attribute re-expansion: our macro emits #[::embassy_executor::main] / #[::tokio::main], which the compiler expands after ours — standard attribute stacking, no reentrancy issue.
  • #[fail] glob shadowing relies on local-item-over-glob precedence (verified on stable edition 2024). If a user defines #[fail] in a different module than #[main], the default is used instead — document that both must share the module (same constraint as init/Context).
  • Sealed timer defaults are board-specific: the generated base/IRQ/clock and the RCC bring-up encode the demo-board (STM32MP257F-EV1 / FRDM-i.MX95) assumption that the boot handoff leaves the timer clocked at the stated rate. On a different board these are wrong — hence the time_driver = false opt-out and a follow-up to move timer selection into the config/chip DB rather than a hardcoded per-chip table.
  • Duplicate defmt::timestamp! / timer ISR: exactly one definition is allowed per binary, so the examples MUST drop their hand-written versions when codegen emits them; leaving both is a hard compile error. Covered by plan step 5.
  • bringup() moves unsafe MMIO into the HAL: keep each block narrow with the RCC/timer register invariant documented (workspace lints deny undocumented unsafe).