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.tomlmanifest 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
Contextinstead 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
defmtlogging, 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:
- the primary publishes the descriptor and proposed layout;
- the secondary validates the magic, version, channel count, and MTU; and
- 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:
SramRegionmodels coherent on-chip SRAM and uses no-op cache maintenance;DdrRegionperforms AArch64 data-cache maintenance where required; andSerialRegionis 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:
- CA prepares data and invokes TA through
TA_InvokeCommandEntryPoint; - TA processes the request and returns the expected result or error information;
- 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 theCommandenum and theinvoke_command()function.#[tee_command]: allows a command to be written as a normal Rust function, then automatically generates a_dispatchedwrapper for TA-side parameter parsing and acall_wrapper for CA-side parameter packing. ThroughCodec, compatible structs or enums can also be passed throughmemref.#[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/.
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:
- describe the topology and resource ownership in
Consortium.toml; - define boundary-safe message types in a shared crate;
- write application and firmware logic against the generated
Context; - run
csti build; and - integrate the resulting
dist/tree with the target image.
The chapters follow that sequence:
- Getting Started tours the workspace and runs the first checks.
- System Architecture explains how the crates and generated code fit together.
- Project Manifest documents the current
Consortium.tomlschema. - Generated Runtime shows the endpoint entry points and IPC surface.
- Build and Deployment explains
csti build, its artifacts, and the packaging boundary.
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
| Layer | Main crates | Responsibility |
|---|---|---|
| Contracts | consortium-codec, consortium-ipc, consortium-tee, consortium-hmi | Portable types and traits shared by runtimes |
| Mechanisms | consortium-ipc-transport-*, consortium-ipc-doorbell-*, consortium-shared-memory | Byte movement, signaling, framing, and cache visibility |
| Hardware | consortium-hal-*, consortium-pac-* | Peripheral ownership and chip-specific register access |
| Configuration | consortium-cfg-common, consortium-cfg, consortium-data | Schema, chip databases, validation, lowering, and code generation |
| Runtime | consortium-runtime-app, consortium-runtime-mcu | Linux and firmware integration around generated code |
| Build | consortium-builder, csti | Compile, 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
Contextowns 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]
| Field | Required | Meaning |
|---|---|---|
chip | yes | Full part number resolved through the chip database |
shared | yes | Crate containing IPC and other cross-boundary types |
deploy_root | yes | On-target installation prefix used when generating paths; the builder does not write there |
devkit | no | Full board DTS file stem; falls back to the generic SoC tree |
sysroot | no | Linux target sysroot; overrides SDK environment variables |
kernel_recipe | no | Yocto kernel recipe name used to generate a .bbappend |
time_driver | no | Generate 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.
| Field | Meaning |
|---|---|
name | Rust field name under context.ipc_shm |
doorbell | Chip database doorbell, such as ipcc1 or mu7 |
primary, secondary | Endpoints participating in the descriptor handshake |
party.<name> | This party’s outgoing doorbell channel and Rust message type |
config.memory | Physical shared region containing the descriptor and slots |
mtu | Maximum encoded payload size in bytes |
codec | postcard 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
| Flag | Purpose |
|---|---|
--manifest <PATH> | Manifest to build; defaults to Consortium.toml |
--dist <DIR> | Local staging root; defaults to dist |
--release | Build endpoint artifacts in release mode |
--skip-tee-sign | Stage 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-run | Resolve and report the BitBake command without running it |
Pipeline
The builder keeps planning, compilation, staging, and external packaging as separate boundaries:
- parse, validate, and lower
Consortium.toml; - generate endpoint modules and build-time configuration;
- build controller firmware and post-process it for remoteproc;
- build the optional HMI and trusted application;
- build the Linux application;
- render device-tree, kernel, boot, and systemd integration;
- assemble
dist/; and - 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:
[profile].sysroot;SDKTARGETSYSROOT; thenOECORE_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:
- Platforms and Hardware
- IPC and Codecs
- Configuration and Runtime
- Build and Deployment
- Services and Debugging
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
uiofor Linux I/O onBellA(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:
- 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. - A panic, for example because
usize::from(nr / 32)exceeded the ISER array length. This was also inconsistent with the LED remaining on. - An infinite loop.
- 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.
-
Read
EXTI1_C{m}IMR2.Because
m=1corresponds to the A35 and the M33’sm=2register is inaccessible from the A35, we first readEXTI1_C1IMR2.root@stm32mp2-e3-cb-5f:~# devmem2 0x44220090 w /dev/mem opened. Memory mapped at address 0xffffab04b000. Read at address 0x44220090 (0xffffab04b090): 0x00003800This value showed that:
> 0x00003800 & (1 << 28) 0Bit
28(IM60) was0. -
Read
EXTI1_RPR2.root@stm32mp2-e3-cb-5f:~# devmem2 0x4422002c w /dev/mem opened. Memory mapped at address 0xffffbd3e2000. Read at address 0x4422002C (0xffffbd3e202c): 0x00000000The register was entirely zero.
-
Read
EXTI1_FPR2.root@stm32mp2-e3-cb-5f:~# devmem2 0x44220030 w /dev/mem opened. Memory mapped at address 0xffffae16e000. Read at address 0x44220030 (0xffffae16e030): 0x00000000The 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
-
The
IPCC_C1MRword 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 -
The
IPCC_C1TOC2SRword indicates the state:root@stm32mp2-e3-cb-5f:~# devmem2 0x4049000c w /dev/mem opened. Memory mapped at address 0xffffb493d000. Read at address 0x4049000C (0xffffb493d00c): 0x00000001Therefore, 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, exception127, last word before0x80100800) executes correctly.IRQ112(SPI1, exception128, first word at0x80100800) 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:
- The firmware was running successfully. This was ruled out because shared memory remained untouched.
- 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_vectorssection 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:
RIFblocked the read and issued an Illegal Access event.- No clock had been assigned to the peripheral.
- 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:
- Interrupt routing.
uio_pdrv_genirqallows an interrupt to be handled from user space. - 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
ortfor 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 matchescmd_idto function handlers- For each
#[tee_command]function, afunction_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, acall_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 Type | TEE Slot | Direction | Notes |
|---|---|---|---|
bool | ParamValue.a (u32) | in/out | |
u32, i32 | ParamValue.a | in/out | |
u64 | ParamValue (a + b) | in/out | split across a(low) and b(high) |
&[u8] | ParamMemref (read-only) | in | |
&mut [u8] | ParamMemref (read-write) | in/out | |
T: TeeParam | ParamMemref | in | deserialized via codec |
&mut T: TeeParam | ParamMemref | in/out | round-trip via codec |
Vec<u8> | ParamMemref (output buffer) | out | |
T: TeeParam | ParamMemref | out | serialized 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:
build.rsrunsoptee_utee_build::build()→ generatesuser_ta_header.rs- Macro generates
invoke_command()dispatcher cdylibcompiles to TA binary- CA depends on
rlibwithfeatures = ["ca"]→ generatescall_*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
| Model | Original | INT8 | FP16 | Speed (INT8) | Accuracy Loss |
|---|---|---|---|---|---|
| YOLOv8n (24 MB) | 24 MB | 6 MB | 12 MB | ~1.5x faster | < 0.5% mAP |
| ResNet50 (98 MB) | 98 MB | 25 MB | 49 MB | ~2x faster | < 1% top-1 acc |
| MobileNetV2 (14 MB) | 14 MB | 3.5 MB | 7 MB | ~1.2x faster | < 0.3% top-1 acc |
Process:
- Original model → load via ONNX Runtime
- Calibration → if INT8, run representative dataset to collect activation statistics
- Quantization → convert weights/activations to lower precision
- Optimization → fuse operations, fold constants, select best kernels
- Serialization → write as
.ortfile with metadata - Bundling → embed in binary or package separately
ORT Performance & Hardware Acceleration
| Provider | Hardware | Latency | Throughput |
|---|---|---|---|
| CPU (ONNX Runtime) | ARM Cortex-A | ~100–500 ms (YOLOv8) | 2–10 inferences/sec |
| GPU (CUDA) | NVIDIA | ~10–50 ms | 20–100 inferences/sec |
| NPU (OpenVINO) | Intel Movidius / VPU | ~20–100 ms | 10–50 inferences/sec |
| DSP (QNN) | Qualcomm Hexagon | ~50–200 ms | 5–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
- State is immutable from render perspective: Templates are pure functions of state
- Event-driven: All mutations go through the event processor
- Broadcast pattern: State changes sent to all connected clients instantly
- Error handling: User-friendly notifications for all error conditions
- Integration: Seamlessly connects IPC + TEE + ORT
HMI Features
| Feature | Implementation |
|---|---|
| Template Engine | Tera/Askama for HTML generation |
| State Management | Tokio channels + broadcast |
| WebSocket Transport | tokio-tungstenite for real-time updates |
| Multi-Protocol | HTTP, WebSocket, optional Tauri for desktop |
| Responsive Design | CSS Grid/Flexbox, mobile-first |
| Accessibility | WCAG 2.1 AA semantic HTML |
| Customization | CSS 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
| Feature | Implementation |
|---|---|
| Multi-language | Python, C, C++, via subprocess |
| Serialization | postcard, JSON, protobuf (pluggable) |
| Process pooling | Work-stealing queue, load balancing |
| Error handling | Automatic translation, stack traces |
| Type safety | Compile-time type checking via macros |
| Async/await | Full Tokio integration |
| Resource management | Automatic cleanup, memory limits |
| Monitoring | Execution time, memory usage, error rates |
FFI Performance
Latency breakdown (Python function call):
| Component | Time |
|---|---|
| Serialization | ~0.1–1 ms |
| IPC (socket) | ~0.5–2 ms |
| Python deserialization | ~0.1–0.5 ms |
| Function execution | variable |
| 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.tomlshould 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:
- RIF/RIFSC security configuration — derived entirely by the builder from the chip mapping already in
consortium-cfg-common; not user-specified. - PAC peripheral objects (
p.i2c1,p.spi2, …) for M-core firmware, exposed inembedded-hal-compatible format backed byembassy-stm32orimxrt-hal. - 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:
| Domain | Mechanism | Current approach |
|---|---|---|
| Linux (NS) | DTS status = "okay" | Manual |
| OP-TEE (S) | DTS secure-status = "okay" | Manual |
| M-core NS firmware | PAC + HAL + RIFSC/TRDC | Manual |
| M-core S firmware (TF-M) | PAC + HAL + RIFSC/TRDC | Manual |
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
| Field | Type | Required | Description |
|---|---|---|---|
master | string | yes | Who owns this peripheral: a core abbreviation ("cm33", "cm0p", "cm7_0"), "linux", or "optee" |
secure | bool | when 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" |
clock | string | no | Clock 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():
| Core | Single instance | Multiple 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-
Copytypes are moved into the struct, providing exclusive ownership. Copytypes 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:
- Reads
Consortium.tomlviaconsortium-cfg-common. - Resolves each
masterabbreviation to(CortexMcuCore, index)usingChip::programmable_rt_cores(). - Derives the RIF Compartment ID per core from
Chip::remoteproc_name(). - Looks up each peripheral name in the chip database to get RIFSC/TRDC index, DTS node, and HAL type path.
- 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:
- Uniqueness: Each
[peripheral.*]key is unique (TOML guarantees this syntactically; validated semantically against the chip DB). - Name validity: All keys must exist in the chip peripheral database for the configured
chip. - Master validity: Each
mastervalue is either"linux","optee", or a core abbreviation (with optional index) present inChip::programmable_rt_cores(). - Security consistency:
secure = falseis rejected for"optee"masters;secure = trueis rejected for"linux"masters. - Core availability: A
masterabbreviation must correspond to a core that actually exists on the chip (e.g."cm0p"is invalid on i.MX 95). - Clock validity: If
clockis specified, the value must appear in the chip DB’sclock_optionslist for that peripheral. Peripherals without aclock_regin the DB reject anyclockfield.
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> }toconsortium-cfg-common. - Parse
[peripheral.*]asHashMap<String, PeripheralEntry>inConfig::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+ChipPeripheralEntrystructs (TOML, embedded inconsortium-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-commonAPI so CLI and pac-gen share it.
Phase 3 — CLI Subcommands (1 week)
- Add
peripheralsubcommand group toconsortium-cli(usingclap). - Implement
list,show,validate,chip-infowith--jsonand--unassignedflags. - Test with STM32MP257 and i.MX 95 fixture configs.
Phase 4 — Code Generation (2 weeks)
- Create
consortium-pac-gencrate (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
Peripheralsstruct emitter (embassy-stm32 / imxrt-hal). - Implement DTS fragment emitter.
- Wire into
consortium-builderas build pipeline step 1.
Phase 5 — Runtime Integration (1 week)
-
consortium-runtime-mcu: call generatedconfigure_rif()frominit(). - Example firmware (STM32MP257) using generated
Peripheralswith embassy-stm32. - Example firmware (i.MX 95) using generated
Peripheralswith 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-builderbuild pipeline step 1. - Verify generated overlays compile with
dtc. - Document how to
#includeoverlays in the base board DTS.
Design Decisions
| # | Question | Decision |
|---|---|---|
| 1 | GPIO in RIF | GPIO 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. |
| 2 | Clock configuration | clock 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. |
| 3 | Interrupt routing | In 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. |
| 4 | Silicon revisions | Delegated 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. |
| 5 | Shared peripherals | Not 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. |
| 6 | Copy vs non-Copy peripheral tokens | Unowned, 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-stm32orimxrt-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 haveperid = None
iomux: Option<&IomuxConfig>
- Required for pin validation: Pass the loaded IOMUX YAML for the specific package variant (e.g.,
STM32MP257FAKx) - Optional: Pass
Noneto skip pin validation;positionandaliasfields 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:
| Chip | RIFSC Family | YAML File |
|---|---|---|
Stm32mp21x | stm32mp21xxxx | data/stm32mp/rifsc/stm32mp21xxxx.yaml |
Stm32mp23x | stm32mp23xxxx | data/stm32mp/rifsc/stm32mp23xxxx.yaml |
Stm32mp25x | stm32mp25xxxx | data/stm32mp/rifsc/stm32mp25xxxx.yaml |
Imx93, Imx95, etc. | None | No 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:
| Variant | IOMUX 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"supportstim2,i2c1,spi2, but notuart1 - 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
-
Variant is
Option<String>: Pin validation is optional. Callers can create a session without specifying the variant;peridis still assigned, butpositionandaliasremain empty. -
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).
-
Silent perid assignment: When a peripheral is not found in the RIFSC map,
peridstaysNonerather than erroring. This allows non-RIFSC-controlled peripherals to exist in the config. -
No i.MX TRDC yet: The
rifparameter isOptionto 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
-
peridis assigned correctly from RIFSC ID -
positionis derived correctly from mux index -
aliasis 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
- Priority: High-priority messages preempt low-priority ones at the receiver.
- Back-pressure: Receiver can signal “slow down” without dropping messages.
- Zero allocation: All data structures are statically sized; no
allocrequired. - 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:
| Bit | Name | Meaning |
|---|---|---|
| 0 | FLAG_VALID | Slot contains a new message |
| 1 | FLAG_CONSUMED | Receiver has read the message |
| 2 | FLAG_BACKPRESSURE | Receiver 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:
- Snapshot
FLAG_VALIDfor all N slots (volatile reads, no lock). - Among valid slots, pick the one with the lowest
prioritybyte. - If none valid, subscribe all slot notifiers and
awaitthe first to fire. - 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
| Crate | Change |
|---|---|
consortium-ipc | Add Priority enum; bump Transport::send signature; add FLAG_BACKPRESSURE |
consortium-ipc-transport-memory | Write priority byte in slot; check backpressure flag |
consortium-runtime-app UIO path | Read 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
-
PriorityMux and pinning:
Nchannel handles in aconst-generic array require the handles to beUnpin. VerifyUioChannelsatisfies this or wrap inPin<Box<_>>(std-only path). -
Version negotiation: The descriptor version bump is backward-incompatible. A migration shim (read old v1 layout, write v2) may be needed during rollout.
-
Scheduler fairness under saturation: Round-robin tie-breaking within a priority band is simple but not starvation-proof if a
RealTimechannel 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:
| Parameter | Role | Example |
|---|---|---|
D | Direction: Tx or Rx | Compile-time guard—can’t call recv() on a Tx channel |
T | Message type | TemperatureSensorData |
Tr | Transport | SharedMemoryTransport<HsemDoorbell, DdrRegion> |
C | Codec | PostcardCodec (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:
- Write payload into tx slot via volatile stores.
region.flush()— issuesdc cvac+dsb syfor each cache line if DDR; noop if SRAM.- Write
lenthenFLAG_VALIDinto slot header (volatile, ordered). doorbell.ring(ch)— notifies A-core.- Return immediately.
SendFutresolves on the first poll.
Receive path:
region.invalidate()— issuesdc ivac+dsb syto discard stale cache lines.- Check
FLAG_VALIDon rx slot (volatile read). - If present: copy payload, write
FLAG_CONSUMED, return. - If not: poll
doorbell.wait(ch)to register waker; returnPending. - 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):
- Opens
/dev/uio0, mmaps resource 0 (shared SRAM/DDR region). - Parses a binary descriptor from offset 0 (magic
0x434F4E53, version 1). Fails if state ≠Ready. - Spawns a background Tokio task: waits on
AsyncFd::readable()for M→A IRQs, then broadcastsirq_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):
- Check if tx slot is still consumed (previous message was processed).
- If not: subscribe to
irq_notify, await it, retry. - 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):
- Try-read rx slot (same
FLAG_VALIDcheck asSharedMemoryTransport). - If data present: copy payload, mark
FLAG_CONSUMED, return. - If not: subscribe to
irq_notify, await it. - 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;
}
}
| Type | Backing | flush / invalidate | is_coherent | Use case |
|---|---|---|---|---|
SramRegion | On-chip SRAM | Noops | true | M-core-local buffers |
DdrRegion | DDR DRAM | AArch64 dc cvac/dc ivac + dsb sy | false | Cross-core shared buffers |
SerialRegion | None (virtual) | Noops | true | UART/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
| Core | Transport | Doorbell | Region | Async runtime |
|---|---|---|---|---|
| Cortex-M (STM32MP21) | SharedMemoryTransport | IPCC | SramRegion / DdrRegion | cooperative executor or RTIC |
| Cortex-M (STM32MP23/25) | SharedMemoryTransport | IPCC preferred; HSEM stale | SramRegion / DdrRegion | cooperative executor or RTIC |
| Cortex-M (i.MX 95) | SharedMemoryTransport | MU | SramRegion / DdrRegion | cooperative executor |
| Cortex-A (Linux) | runtime-app UIO path | Linux UIO + tokio::sync::Notify | mmap’d via UIO driver | Tokio |
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.
| Feature | Effect |
|---|---|
| (none) | Pure no_std; MaybeSend has no bounds |
std | Adds Send bound on futures via MaybeSend: Send |
alloc | Enables 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,futuresgates;no_stdby 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-appUIO 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 (
KernelInit→Ready) - ~320 LOC, Linux-only, tested
Memory Region Implementations (consortium-ipc sub-modules)
SramRegion: Cache-coherent, no-op flush/invalidateDdrRegion: Non-coherent, AArch64dc cvac/dc ivac+dsb sySerialRegion: Virtual region for UART/SWD logging
Codec
- Default:
PostcardCodecviapostcardcrate (serde) - Compact binary,
no_stdcompatible - GAT-based
Decoded<'buf, T>for zero-copy future support
TEE Support (consortium-tee + consortium-tee-macros)
- Error types:
TeeError,TeeErrorOrigin,TeeResultwithis_transient() tee_service!macro: GeneratesCommandenum + dispatcher#[tee_command]macro: Advanced type support including:- Input types:
bool,u32,&[u8],&mut [u8], generics withSerialize/Deserialize - Return types:
u32,u64,bool,Vec<u8> - Context passing (first
&mutarg, excluded from TEE slots) - CA-side
call_*stubs (#[cfg(feature = "ca")]) - TA-side
*_dispatchedhandlers with full parameter unpacking
- Input types:
- ~929 LOC of sophisticated proc macros
Configuration (consortium-cfg-common)
Config: Top-level withgeneral+projectsectionsChipenum: STM32MP2x, i.MX 9x families with traits (has_rt_core(),remoteproc_name(), etc.)CortexMcuCoreenum: M0p, M33, M7, M4/M4f with Rust target mapping
Application Runtime (consortium-runtime-app)
RemoteProcsysfs 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() RemoteProcCoredumpenum:Enabled,Disabled,Inline
- Index-based constructor mapping to
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, typedbitfieldregister wrappers, andIpccChanValidate. STM32MP21 uses IPCC only. STM32MP23/25 should prefer IPCC over the stale HSEM path. IPCC1 is0x4049_0000..=0x4049_03FF; SmartRun IPCC2 is0x4625_0000..=0x4625_03FFon STM32MP23/25 only. - MU (
consortium-ipc-doorbell-mu): NXP i.MX Message Unit support with no_std and std paths, typedbitfieldregister wrappers, andMuChanValidate.
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:
ApplicationBuilderfor cross-compilation (Cortex-A35 / OpenSTLinux SDK)FirmwareBuilderfor 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: u8for 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: u8for selecting processor 1 or processor 2 register bank - IPCC1 base constants for STM32MP21/23/25 and IPCC2 SmartRun constants gated to STM32MP23/25
bitfieldregister 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
AtomicWakerper selected MU instance) - Linux A-core support (std + tokio::sync::Notify)
- Channel validation via
MuChanValidate(MU_INSTANCE_COUNT * 4flat channels; 0–1 reserved) - Chip features for
imx8mp(1 MU),imx93(2 MUs), andimx95(8 MUs) - Side features for
muaandmub; the crate default isimx95plusmua - IRQ handlers gated by chip feature (
MU1_IRQHandlerthroughMU8_IRQHandleron i.MX95) - i.MX95 CA55 <-> CM7 defaults use MU7 channels 24 and 25 (
IMX95_CA55_CM7_TX_CHANandIMX95_CA55_CM7_RX_CHAN) - Flexible base address configuration (no const generic needed)
- GI signaling uses
GCR[GIRn], pending status usesGSR[GIPn], and clear is W1C throughGSR[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:
-
Trait impl
DoorbellforXyzDoorbell:ring()— signal remote core (register write, semaphore lock, etc.)wait()→ returnsWaitFutfuture with state machinepending()— check if signal pending (without blocking)
-
Dual-path design (no_std + std):
- no_std: Static
AtomicWakerper instance + IRQ handler - std:
Arc<tokio::sync::Notify>+ external notifier integration
- no_std: Static
-
Channel validation: Implement
ChanValidatetrait to enforce valid channel ranges -
Constructor patterns:
- HSEM: Stale STM32MP23/25 path with const generic
PROC: u8for processor-specific interrupt lines - IPCC: Const generic
PROC: u8; no_stdipcc1()is available on STM32MP21/23/25 andipcc2()only on STM32MP23/25 - MU: Flexible base address configuration (no const generic needed)
- HSEM: Stale STM32MP23/25 path with const generic
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:
-
Firmware execution harness (M-core):
- Bootstrap code (stack setup, relocation, initialization)
- Async executor (cooperative or RTIC-based)
- Interrupt handler registration
- Integration with
SharedMemoryTransportfor IPC - Reset/panic handlers
- Example: minimal firmware that spins up a Channel and sends data
- Current design: use
Embassyfor async runtime withno_stdsupport. 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.
-
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
-
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)
| Name | Status | Description |
|---|---|---|
consortium-ipc | ✅ Core traits | Doorbell, Region, Transport, Channel, Codec |
consortium-ipc-transport-memory | ✅ Shared-memory transport | Generic over doorbell/region; supports no_std and std feature paths |
consortium-runtime-app UIO support | 🟡 Linux UIO runtime | UioDevice integration via Tokio |
consortium-ipc-doorbell-hsem | ⚠️ Stale HSEM doorbell | STM32MP23/25 only; prefer IPCC for STM32MP2 |
consortium-ipc-doorbell-ipcc | ✅ IPCC doorbell | IPCC for STM32MP21/23/25 |
consortium-ipc-doorbell-mu | ✅ MU doorbell | Message Unit for i.MX 9x series |
consortium-runtime-app | 🟡 A-core runtime | RemoteProc sysfs wrapper (requires expansion) |
consortium-runtime-mcu | ❌ M-core runtime | Stub (requires bootstrap + executor) |
consortium-builder | 🟡 Build system | Minimal (requires cross-compilation helpers) |
consortium-cfg-common | ✅ Configuration | Config, Chip, CortexMcuCore types |
consortium-tee | ✅ TEE support | TeeError, TeeResult types |
consortium-tee-macros | ✅ TEE macros | tee_service!, #[tee_command] with advanced types |
consortium-cli | ❌ Debug tool | Stub (requires IPC monitoring) |
consortium-hmi | ❌ HMI library | Stub (web-based GUI integration) |
consortium-ffi | ❌ FFI bindings | Stub (cross-language interop) |
consortium-ort | ✅ ONNX Runtime | Wrapper 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
| Component | Tests | Notes |
|---|---|---|
consortium-ipc | ✅ Trait tests | Basic contract verification |
consortium-ipc-transport-memory | ✅ Transport tests | Slot protocol, descriptor parsing, doorbell mocking |
consortium-runtime-app UIO | ⚠️ Partial | Linux UIO wrapper exists; no real device tests |
consortium-tee-macros | ✅ Macro expansion | Compile-time correctness |
consortium-ipc-doorbell-hsem | ✅ HSEM tests | Register access, async wakeup, channel validation |
consortium-ipc-doorbell-ipcc | ✅ IPCC tests | Register bitfields, channel validation, pending/wait clear behavior |
consortium-ipc-doorbell-mu | ✅ MU tests | Channel mapping plus fake-MMIO std wait-future tests |
| Runtime (M-core) | ❌ None | Not implemented |
| Runtime (A-core) | ⚠️ Partial | RemoteProc methods exist but not tested |
| E2E (firmware ↔ app) | ❌ None | Would require actual hardware or simulation |
Recommended Implementation Order
⚠️ Phase 0: HSEM Doorbell (STALE)
- 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)
-
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
-
A-core app skeleton (
consortium-runtime-app/src/lib.rsexpansion)- RemoteProc state machine for lifecycle management
- UioDevice integration with IPCC notifications
- Firmware ELF loading
- Simple recv loop
-
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)
- Implement MU Doorbell for i.MX 93, i.MX 95, etc., following HSEM pattern
- Extend M-core and A-core runtimes for NXP platform
- Test cross-core coordination on NXP hardware
Phase 3: Build Pipeline
- Implement
consortium-builderfor firmware compilation - Wire up device tree generation
- Add OP-TEE compilation
- Package everything into
.tar.gz
Phase 4: Polish & Documentation
- Add hardware-specific examples (HSEM, IPCC, MU)
- Write integration test suites
- Document chip selection and features
- 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-mem→consortium-ipc-transport-memoryconsortium-ipc-uio→ runtime-app UIO support usingconsortium-ipc-transport-memoryconsortium-ipc-driver-hsem→consortium-ipc-doorbell-hsemconsortium-ipc-driver-mu→consortium-ipc-doorbell-muconsortium-runtime-rt→consortium-runtime-mcu
Summary: What’s Left
| Layer | Status | Effort | Dependency |
|---|---|---|---|
| Traits + Channel | ✅ Done | — | — |
| SharedMemoryTransport (bare-metal) | ✅ Done | — | Doorbell trait |
| runtime-app UIO path (Linux) | ⚠️ Partial | — | Kernel 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) | ❌ TODO | High | consortium-runtime-mcu |
| A-core runtime + lifecycle (STM32MP2) | ❌ TODO | High | RemoteProc + UIO expansion |
| Build pipeline (8-step) | ❌ TODO | High | All 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.
| Aspect | HSEM | IPCC | MU (GI) |
|---|---|---|---|
| Platform | STM32MP23/25 | STM32MP21/23/25 | NXP i.MX 9x |
| Signal mechanism | 1-step read-lock then unlock semaphore | Set CxSCR.CHnS to mark a channel occupied | Set GCR[GIRn] |
| Backpressure guard | LOCKID ownership check; can report AlreadyLocked | Outgoing CxTOCySR.CHnF; returns ChannelOccupied while occupied | No guard; setting GIRn while pending is idempotent |
| Receive status | CnMISR masked interrupt status | Opposite direction CyTOCxSR.CHnF | GSR[GIPn] |
| Interrupt enable | CnIER bit per semaphore | CxCR.RXOIE plus CxMR.CHnOM unmask | GIER[GIEn] |
| Interrupt clear | Dedicated CnICR W1C bit | CxSCR.CHnC W1C-style clear | GSR[GIPn] W1C |
| Const generic | PROC: u8 selects C1/C2/C3 interrupt bank | PROC: u8 selects processor 1 or 2 register bank | None; base array and channel mapping select instance |
| IRQ wakeup | Single HSEM_IRQHandler | Board/PAC calls notify_rx_occupied_interrupt() from the verified IPCC RX occupied line | MU1_IRQHandler … MU8_IRQHandler by instance |
| Channel count | 16 flat semaphore IDs; 0-1 reserved | 16 channels per IPCC instance; 0-1 reserved | MU_INSTANCE_COUNT * 4; 0-1 reserved |
| Register style | bitfield register wrappers + volatile access | bitfield register wrappers + volatile access | bitfield register wrappers + volatile access |
MU notes:
- Chip features select the MU instance count:
imx8mp= 1,imx93= 2,imx95= 8. - Side features select register view:
muafor A-core andmubfor 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:
| Family | IPCC1 | IPCC2 |
|---|---|---|
| STM32MP21 | 0x4049_0000..=0x4049_03FF | not present |
| STM32MP23/25 | 0x4049_0000..=0x4049_03FF | SmartRun 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 headerdescriptor::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 setupstatic_buf!(NAME, SIZE)— Declares properly aligned static buffers
Features
stm32mp21x— Enables IPCC doorbell supportstm32mp23x/stm32mp25x— Enables IPCC and stale HSEM doorbell support; prefer IPCCimx93/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 aSharedMemoryTransportfor one logical channel.
State Polling
-
mmap::wait_for_ready(uio_num, timeout)— Polls descriptor untilReady(3)- Returns early if descriptor enters
ErrororSuspendedstate - Mmaps UIO resource 0 and polls at 50ms intervals
- Returns early if descriptor enters
-
RemoteProc::wait_state(target, timeout)— Polls remoteproc sysfs state- Example:
proc.wait_state(RemoteProcState::Running, Duration::from_secs(5))
- Example:
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::ErrororSuspended— M-core did not complete initializationWaitError::Timeout— Firmware did not boot or descriptor not written within timeoutUioDevice::openreturns “descriptor not ready” —wait_for_readywas not called or timed out before opening
Debugging Tips
- Check
/sys/class/remoteproc/remoteproc0/stateto verify firmware is running - Check
/sys/class/uio/uio0/maps/map0/to verify memory mapping - Verify M-core writes descriptor magic at offset 0x00 (should be
0x434F_4E53) - 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
-
/Cargo.toml(root)- Added
embedded-hal = "1.0"to[workspace.dependencies]
- Added
-
crates/consortium-ipc-doorbell-gpio/Cargo.toml- Added full dependencies:
consortium-ipc,atomic-waker,embedded-hal, optionaltokio+linux-embedded-hal - Added
stdfeature gate combiningtokioandlinux-embedded-hal
- Added full dependencies:
-
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
-
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 PENDINGfor software-tracked pending state - ring(): Pulses the GPIO pin (high → low) to generate interrupt
- wait(): Polls
PENDINGflag, 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
| Aspect | vs HSEM/MU | Justification |
|---|---|---|
| Interior mutability | Not needed | HSEM/MU use raw register writes; GPIO needs OutputPin which requires &mut |
| Software PENDING flag | Hardware MISR register | GPIO has no hardware status register |
| Single-channel | 16 channels (HSEM) / 8 channels (MU) | GPIO instance = one pin; use multiple instances for multi-channel |
wake() not extern "C" | HSEM_IRQHandler | GPIO IRQ names are board-specific; user calls this from their ISR |
ch argument ignored | Used for channel indexing | Single-channel design; argument accepted but unused |
Verification
✅ no_std path: cargo check -p consortium-ipc-doorbell-gpio --no-default-features
✅ no_std linting: cargo clippy -p consortium-ipc-doorbell-gpio --no-default-features -- -D warnings
✅ Compiles 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
unsafeblocks 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
&'staticstate. A driver is bound to a concrete peripheral instance by the base address you pass to itsnewconstructor, together with a&'staticstate value that owns the driver’s interrupt bookkeeping (anembassy_sync::waitqueue::AtomicWakerand 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(orembedded-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-asynctraits. Async methods park on the driver’s waker and expect the peripheral’s interrupt to be routed into the driver’son_interrupthandler (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’son_interrupt. Enable the chip HAL’srtfeature 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::newtakes 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::newadditionally 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 sharedCAN_SRAMwindow, 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 eachSpiBuscall viaTCR.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 hardwareNSSline — chip-select is the caller’s to manage with a GPIO. EachSpiBusmethod programsCR2.TSIZEwith the frame count and issues a singleCR1.CSTART, so it reads as one hardware transaction; buffers longer than the 16-bitTSIZEfield 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 thecafeature
tee_service! generates centrally:
pub enum Command { FunctionA = 0, FunctionB = 1, Unknown = N }—#[repr(u64)],#[default]on Unknownpub(crate) fn invoke_command(ctx: &mut ContextStruct, cmd: u32, params: &mut optee_utee::Parameters) -> Result<()>— TA dispatch entry point; dispatches viaCommand::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:
fn function_a_dispatched(ctx, params)— always compiled; called byinvoke_command#[cfg(feature = "ca")] fn call_function_a(...)— compiled only when thecafeature 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 type | GP TEE slot | TA unpacks via | CA packs via |
|---|---|---|---|
bool | VALUE_INPUT | unsafe { params.N.as_value()?.a() } != 0 | ParamValue::new(x as u32, 0, ValueInput) |
u32 | VALUE_INPUT | unsafe { params.N.as_value()?.a() } | ParamValue::new(x, 0, ValueInput) |
&[u8] | MEMREF_INPUT | let mut p = unsafe { params.N.as_memref()? }; p.buffer() | ParamTmpRef::new_input(x) |
&mut [u8] | MEMREF_INOUT | let 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 type | GP TEE slot | TA flushes via | CA reads back via |
|---|---|---|---|
u32 | VALUE_OUTPUT | let mut p = unsafe { params.N.as_value()? }; p.set_a(v) | _operation.param_N().as_value().a() |
u64 | VALUE_OUTPUT | p.set_a((v >> 32) as u32); p.set_b(v as u32) | (a as u64) << 32 | (b as u64) |
bool | VALUE_OUTPUT | let mut p = unsafe { params.N.as_value()? }; p.set_a(v as u32) | _operation.param_N().as_value().a() != 0 |
Vec<u8> | MEMREF_OUTPUT | let 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: TeeParamwith pluggable codecs via#[tee_command(codec = MyCodec)]
Future Work
- Lifecycle hooks —
triggers { on_create, on_open_session, on_close_session, on_destroy }block insidetee_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:
| Category | Implemented types |
|---|---|
| Input parameters | bool, u32, i32, u64, (u32, u32), &[u8], &mut [u8], &mut u32, &mut i32, &mut u64, T: TeeParam |
| Return / output | u32, u64, bool, Vec<u8>, T: TeeParam |
| Serialization | Pluggable 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 u64output 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 type | Slot kind | Encoding | Status |
|---|---|---|---|
u32 | ValueInput | a = value, b = 0 | ✅ done |
bool | ValueInput | a = value as u32 (0 or 1), b = 0 | ✅ done |
u64 | ValueInput/Return | a = low 32, b = high 32 | ✅ done |
i32 | ValueInput | a = value as u32, b = 0 | ✅ done |
(u32, u32) | ValueInput | a = .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 type | Slot kind | TA behaviour | CA behaviour | Status |
|---|---|---|---|---|
&mut u32 | ValueInout | reads a, writes back via set_a | reads a into *ptr after call | ✅ done |
&mut i32 | ValueInout | reads a as i32, writes back cast | reads a as i32 into *ptr after call | ✅ done |
&mut u64 | ValueInout | reconstructs from a/b, writes back | reconstructs 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 (TeeParam — ParamMemref 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 type | Slot kind | TA behaviour | CA behaviour | Status |
|---|---|---|---|---|
T: TeeParam | MemrefInput | deserialize from buffer with codec | serialize to buffer, pass as memref | ✅ done |
&mut T: TeeParam | MemrefInout | deserialize, pass &mut, re-serialize | serialize, 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
| Property | postcard | bincode | Custom |
|---|---|---|---|
no_std support | ✅ | partial | ✅ |
| Output size | minimal | small | varies |
| Allocation-free mode | ✅ | ❌ | ✅ |
| Schema evolution | manual | manual | custom |
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
| Path | Purpose | Consortium relevance |
|---|---|---|
framework/ | Solid/Vue renderers, components, input, animation, platform checks, virtual clock, and effect shell | Frontend 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 constants | Build integration and a future Consortium Linux target profile |
engine/core/ | no_std + alloc retained UI core with draw-list and deterministic software raster support | Reusable renderer-independent UI state; no Linux or QuickJS dependency |
engine/wasm/, hosts/web/ | WASM core mirror and browser development host | Fast frontend iteration, but not the intended production HMI runtime |
tests/ | Deterministic headless simulation, input tapes, goldens, and contract tests | Strong basis for HMI acceptance tests and replaying hardware/IPC traces |
engine/crates/pocket-mod | rquickjs guest lifecycle, named surface mounting, and one guest turn per tick | Native JS host and the extension point for consortium.* |
engine/crates/pocket-ui-wgpu | Pak loading, ui.* mounting, and DrawList-to-wgpu rendering | Closest reusable native Linux rendering layer |
engine/pocket3d/examples/uihost | winit window and headless example using pocket-mod and pocket-ui-wgpu | Bring-up reference, not yet a production embedded shell |
hosts/psp/, hosts/vita/ | PSP and Vita QuickJS/render/input hosts | Mature 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-hmiis a backend-neutral, cloneable command registry. It parses invocation envelopes and returnsSendresponse futures without depending on Tokio, Cog, GLib, WebKit, QuickJS, or a renderer.consortium-hmi-webkitcontainsKioskAppand adapts the registry to WebKit’s script-message channel using a caller-selected Tokio runtime.consortium-hmi-pocketloads JS/pak artifacts, mountsuiand 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-inputis the backend-neutral,no_stdinput layer: anInputEventmodel in logical UI coordinates plus a pull-basedInputSourcetrait.InputEventderivesIpcSafe/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’sPadStatefolds events into PocketJS’sframe(buttons, analog)contract; the winit shell and futurelibinput/evdevand IPC readers all present as anInputSource.
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
| Concern | Reusable now | Missing for a production Consortium HMI |
|---|---|---|
| UI core | pocketjs-core, Taffy layout, text, focus, animation, draw list | Product viewport and performance limits must be validated on A-core targets |
| JS runtime | pocket-mod embeds QuickJS and mounts named surfaces | Lifecycle, error reporting, watchdog policy, and Tokio handoff in Consortium |
| GPU | pocket-ui-wgpu and headless/windowed examples | A lean 2D-only dependency boundary and target GPU/driver validation |
| Display | winit window and wgpu surface | Fullscreen Wayland kiosk behavior; direct DRM/KMS presentation is not implemented |
| Input | Backend-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 delivery | Concrete libinput/evdev and IPC InputSource backends; delivering pointer/touch to the guest (packed-touch entry); focus, calibration, and hot-plug behavior |
| Build | Bun compiler produces deterministic JS/pak artifacts | Linux target profile, builder dispatch, staging layout, cross-build inputs, and BitBake integration |
| App services | Portable registry plus Pocket effect/Promise adapter | Typed errors, cancellation, backpressure, and production IPC/TEE handlers |
| Testing | Headless wgpu/software rendering, tapes, goldens | Consortium fixtures that record/replay IPC and assert both commands and pixels |
| Browser compatibility | Web/WASM development host | Browser-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;
Sendresponse 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:
- load the planned JS and pak artifacts;
- mount the PocketJS
uisurface; - mount a separate
consortiumservice surface; - evaluate the bundle only after both surfaces exist;
- owns the single-threaded guest and frame pump;
- drain completed service calls at a frame boundary;
- 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
__hostand__hostAbivalues; - 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
- Headless host for CI and command/effect tests.
- Fullscreen Wayland host using the existing winit/wgpu path.
- Keyboard and button navigation, then touch and pointer delivery in logical coordinates.
- Cross-compilation and GPU validation in the i.MX9 and STM32MP2 Linux SDKs.
- 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.
- 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. - Bridge extraction: turn the current WebKit handler map into a backend-neutral command router while keeping all Cog tests passing.
- Pocket service prototype: mount
consortium.*, invoke one synchronous and one Tokio-backed asynchronous command, and deliver results at frame boundaries. - Builder artifacts: add
engine = "pocketjs", compile JS/pak, stage them underdist/hmi, and inject their deployed paths into the application. - Wayland kiosk: add fullscreen presentation, resize policy, clean shutdown, keyboard/button input, and a systemd smoke test.
- Touch and system integration: map panel input, expose IPC/TEE handlers, and add recorded-effect/pixel-golden tests.
- 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
| Field | Value |
|---|---|
| Status | Proposal |
| Product | Consortium Chip Database |
| Relationship | Standalone partner project to Consortium |
| Initial consumers | Web application, MCP clients, Consortium CLI, code generators |
| Deployment target | Cloudflare Workers, D1, R2, and Queues |
| Last updated | 2026-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:
- Identify an exact chip, die, package, core, and security/address-space view.
- Find a peripheral, register, field, interrupt, memory region, or pin route.
- Understand where every important fact came from.
- Compare peripheral and silicon revisions without manually diffing manuals.
- Generate mutually consistent Rust, C, SVD, chiptool, DTS, and PDF outputs.
- Reproduce a build later using a locked canonical release.
- 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] chipto 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
- Published data is immutable. Corrections create new revisions.
- Identity is not a name. Renames do not create new hardware identities.
- Evidence is not truth. Imports create claims that require resolution.
- The IR is richer than its exports. Export limitations never constrain what the canonical model can preserve.
- Core and security views are explicit. Base addresses, access, and IRQs may differ by execution context.
- Generation is deterministic. No generator depends on a moving
latestchannel after resolution. - Portable releases are authoritative. Published D1 projections can be rebuilt from canonical release objects.
- 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, andMarketedPart.ProcessorCluster,Processor,Core,ExecutionContext,AddressSpace, andBus.MemoryRegionandAddressMapping.Peripheral,PeripheralRevision,RegisterBlock,RegisterCluster,Register,Field,EnumSet, andEnumValue.PeripheralInstanceandInstanceMapping.InterruptController,InterruptSource, andInterruptRoute.Pad,PackagePin,PeripheralSignal,PinFunction,PinRoute, andPinGroup.- Typed clock, reset, DMA, power-domain, IOMMU, and security-controller connections.
LinuxBindingandDeviceTreeNodeTemplate.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
dtcand applicabledt-schemabindings. - 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, andBB_NO_NETWORKvalidation. - 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
- Freeze and validate a draft.
- Canonicalize and hash all objects.
- Generate and validate required artifacts.
- Upload immutable objects and artifacts to R2.
- Verify object hashes and sizes.
- In one D1 transaction, insert the release and optionally move a channel.
- 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.
Recommended technology stack
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-rstargetingwasm32-unknown-unknown. serdeand 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/viteintegration. - 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:
- A versioned canonical schema and deterministic serializer are published.
- Existing i.MX93, i.MX95, and STM32MP2 controller-core data imports into it.
- Peripheral revisions are independent and reused by chip instances.
- Core/address-space-specific base addresses and IRQ routes are queryable.
- Published releases are content-addressed and downloadable from R2.
- D1 search projections can be rebuilt from a published bundle.
- The Vue website supports search, chip, peripheral, register, and provenance views.
- At least chiptool YAML, Rust PAC input, C header, SVD, and canonical bundle exports are available.
- Remote and local MCP servers implement the initial read-only tools.
- The Consortium CLI can lock, fetch, verify, and consume an exact release.
vp check,vp test, andvp buildpass for the frontend workspace.- Rust formatting, linting, unit tests, schema compatibility tests, importer fixtures, and generator golden tests pass in CI.
Risks and mitigations
| Risk | Mitigation |
|---|---|
| Conflicting vendor sources | Preserve claims, display conflicts, require resolution |
| Copyright restrictions | Separate facts from assets and enforce publication rights |
| IR constrained by SVD/chiptool | Define the IR independently and publish export loss reports |
| D1 growth or contention | Keep blobs in R2, index measured queries, design shard-safe IDs |
| Worker Wasm limitations | Keep generators portable and use sandboxed external runners when required |
| Parser silently invents semantics | Confidence levels, provenance, golden fixtures, review gates |
| Peripheral revision explosion | Content-addressed reuse and compatibility classification |
| MCP data exfiltration | Scoped authorization, tenant filters, bounded tools, audit logs |
| Prompt injection in source text | Treat all imported prose as data and separate it from instructions |
| Generated-code incompatibility | Version generators and test layouts and public API fixtures |
| Moving frontend toolchain | Exact 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:
- Import current Consortium datasets into the new IR.
- Demonstrate output and validation parity.
- Teach Consortium to consume an optional locked external bundle.
- Make the external bundle the default only after representative examples and CI pass.
- 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, andconsortium-ipc-transport-uart-unix. The plan’s proposed GAT-to-RPITIT transport refactor is complete, so its before/after trait sketches no longer matchconsortium-ipc. - Buffered UART RX Plan — the plan to bump the chip HALs to
embedded-io(-async)0.7 and add an IRQ-bufferedBufferedUartRX 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
TransportintoSendTransportandRecvTransport. Those half-traits andSharedMemoryTransport::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 generatedinit()/Contextflow, 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 singleContextaggregate.
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.rs—send/recvbodies already just.await; only theMockTransporttest impls change (droptype SendFut/RecvFut, return the sameready(..)values behind-> impl Futureor convert toasync fn).crates/consortium-ipc/src/transceiver.rs— delegates throughChannel; adjust bounds only if it repeats the GAT names.crates/consortium-ipc-transport-memory/src/transport.rs— the sixSendTransport/RecvTransportimpl blocks (combined + Tx/Rx halves) drop thetype SendFut/RecvFutassociated types and return the existing hand-rolledSendFut/RecvFutstructs from-> impl Future + MaybeSendmethods. The future structs themselves are untouched.- Transport-memory tests (
tests/async_atomic_waker/,tests/async_embassy/, etc.) implementDoorbell, not the transport traits — expected to pass unchanged; fix any stragglers found bycargo 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
chanis the logical channel byte per theDoorbelldoc contract; PoC accepts only the constructedChanand drops mismatches (single channel per port, like the GPIO doorbell is single-channel).- CRC32 via
crc32fastoverchan | payload. pub const fn max_frame_len(mtu: usize) -> usizesizing helper (COBS overhead = ⌈len/254⌉ + 1, plus delimiter) so callers/static_buf!can size scratch buffers.- Pure functions
encode_frame(chan, payload, out) -> usizeand an incrementalFrameDeframerstate machine (push_byte/push_slice→Option<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 viamax_frame_len(mtu)— same no-alloc discipline asChannel(consortium-runtime-mcu::static_buf!works for these). impl SendTransport:async fn-style body —encode_frameinto scratch,io.write_all(..).await,io.flush().await.max_send_size() == mtu.impl RecvTransport: loopio.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 viaconsortium-logwarn) and the loop continues —recvonly 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 simultaneousChannel<Tx,..>/Channel<Rx,..>use, mirroringSharedMemoryTransport::split(). - Error enum via
thiserror(Io(E),FrameTooLarge,BufferTooSmall),defmt::Formatbehind 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-1feature) wraps the tokio halves asembedded_io_async::Read/Write.- Public surface:
open(path, baud) -> Result<(UartRx, UartTx)>where the returned types areFromTokio<ReadHalf<SerialStream>>newtypes/aliases feeding straight intoUartTransport::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_stdcrate that re-exportsembedded_io_asyncand 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::Readfor 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_lenbounds.loopback.rs(std) — in-memory duplex fake implementingembedded_io_async::Read/Write(Tokio mpsc-backed, lives intests/common/); transport roundtrip, split halves, fragmented delivery, and a codec-backedChannel<Tx/Rx>postcard roundtrip mirroring the transport-memory channel tests.
In crates/consortium-ipc-transport-uart-unix/tests/:
pty_e2e.rs—SerialStream::pair()pty loopback: typedTransceiver-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.tomlschema (transport = "uart",config.uart = { device, baud }) →consortium-cfg-commontypes 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
- bumping the HALs to embedded-io(-async) 0.7, and
- adding an embassy-
BufferedUart-style buffered RX driver to both chip HALs (IRQ drains the FIFO into a ring; asyncReadpops the ring), keeping the existing direct interrupt-driven async TX (no TX ring), and - turning
consortium-ipc-transport-uart-embeddedinto 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_interruptRX 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, soreadnever returns an error for it; emit via the latched-flag warning instead, embassy-style). Wakerx_wakerwhenever ≥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 soBufferedUartState::on_interruptreuses it;UartTxkeeps working againststate.inner, givingBufferedUartits direct async TX for free. BufferedUartRx::read(mirror embassyBufferedUartRx::read, scratchpad copy lines 616–647):poll_fnthat popsreader().pop_slice()intobuf;Poll::Ready(n)if any bytes, else registerrx_waker+Pending. Implementembedded_io::ErrorType(Error = Error),embedded_io_async::Read,embedded_io::ReadReady(!ring.is_empty()), and a blockingembedded_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 inBufferedUartRx, writer only inon_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 = falseon 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 aspub mod imx9 { pub use consortium_hal_imx9::uart::*; }andpub mod stm32mp2 { ... }behindcfg(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::new→split()→UartTransport::new(chan, rx, tx, ...)→Channel<Tx/Rx>, plus the#[interrupt] fn LPUARTn/USARTn() { STATE.on_interrupt() }forwarding block (HALrtfeature), 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 tocheck-ipc-m8/check-ipc-m7(and the matchingbuild-ipc-*):cargo check -p consortium-ipc-transport-uart --no-default-features,cargo check -p consortium-ipc-transport-uart-embedded --features mimx95(and/orstm32mp25x) for both thumb targets. Add totest-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.mdUART IPC paragraph: replace “consortium-ipc-transport-uart-embeddedis 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.mdonly 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: Transportbound from the struct definition; push bounds toimplblocks. impl<Tx>block:Tr: SendTransportimpl<Rx>block:Tr: RecvTransport- Change
ChannelError<C: Codec, Tr: Transport>→ChannelError<C: Codec, Tr: TransportError>(same callers, relaxed bound). Debug/Displayimpls 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,
}
}
SharedMemoryTransportTximplementsTransportError+SendTransport(moves thesendlogic from the currentTransportimpl).SharedMemoryTransportRximplementsTransportError+RecvTransport(moves therecv+RecvFut::polllogic).- Both need their own
SendFut/RecvFutfuture types (copy the existing ones, updating the pointer type). - Add
split()onSharedMemoryTransport(requiresD: 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_IRQHandleris already#[no_mangle]indoorbell-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-muprovides chip-gatedMU1_IRQHandlerthroughMU8_IRQHandlerwhere 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()orMuDoorbell::new(bases)— needs[usize; N]base addresses, whereNis selected byimx8mp,imx93, orimx95. i.MX95 CA55 <-> CM7 code should default toIMX95_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()andset_state()helpers. - M-core writes
KernelInit → Proposed → MReadyafter transport is set up; then waits for A-core to setReady.
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 + mtubytes. Tx slot atbase + 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 inno_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-mcuprovides IPC init; the executor is the user’s responsibility.
runtime-app — What to Initialize
1. Keep Runtime Re-exports Focused
runtime-appnow re-exportsconsortium_ipc_transport_memoryasipc_transportand owns the Linux UIO wrapper underruntime_app::uio.
2. Firmware Lifecycle (existing, wire it up)
RemoteProcis 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 inReady(3). - Expose
async fn wait_for_ready(uio_num: usize, timeout: Duration) -> Result<(), Error>that mmaps resource 0 without full init, pollsDescStatebyte, and returns whenReady.
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-appis alreadytokio-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)
UioDevicehas its ownArc<Notify>for the IRQ loop (already wired).HsemDoorbell::<2>::new(vaddr)has its ownArc<Notify>— these are separate and currently not bridged.- For the UIO path the separate HSEM
Notifyis 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 callnotify.notify_waiters()from their IRQ handler. - No bridging code needed now; document the two paths clearly.
Critical Files to Modify
| File | Change |
|---|---|
crates/consortium-runtime-mcu/src/lib.rs | Add doorbell IRQ helpers where verified, descriptor write helpers, mem_transport_for_slot, static_buf! macro |
crates/consortium-runtime-app/src/lib.rs | Fix re-export name, add wait_for_ready, open_channel convenience fn |
crates/consortium-runtime-app/src/remoteproc.rs | Add wait_state async helper |
New: crates/consortium-runtime-mcu/src/descriptor.rs | Descriptor write + state helpers for M-core side |
New: crates/consortium-runtime-app/src/descriptor.rs | Descriptor 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 constantsconsortium-ipc-transport-memory/src/lib.rs— shared-memory transport constructor to wrapconsortium-runtime-app/src/remoteproc.rs—RemoteProc(complete, no changes needed)
Design Decisions (confirmed)
-
Descriptor ownership: M-core writes the full descriptor (magic
0x434F_4E53, version 1, channel count, slot offsets). A-core polls untilReady(3). Runtime needs a fulldescriptor::write()API on the M-core side. -
Executor: Embassy. Use
embassy_executor::taskentry points andstatic_cell::StaticCell(or Embassy’sStaticCell) for static scratch buffers. Addembassy-executorandstatic-celltoruntime-mcudependencies. -
MU chip features: Split
imx9xintoimx93andimx95sub-features, withimx9xas an umbrella that enables themudoorbell crate. Feature flags:imx9x = [],imx93 = ["imx9x", "consortium-ipc-doorbell-mu/imx93"],imx95 = ["imx9x", "consortium-ipc-doorbell-mu/imx95"].
Verification
After implementation:
cargo build -p consortium-runtime-mcu --features stm32mp25x --target thumbv7em-none-eabi— must compile cleancargo build -p consortium-runtime-app(Linux host) — must compile clean (fixes broken re-export)- Write an integration doc/example showing M-core init →
MReady→ A-corewait_for_ready→UioDevice::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], thenConsortiumLogger::init(),init_time_driver(),init()(sync MPU/NVIC) →.connect().await→ match/wfion error, thenperipherals(). - App (
examples/melt-pot/*/app/src/main.rs):#[tokio::main], thenmatch 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)
- IPC access stays
context.ipc_shm.sensor— keep the currentipc_shmfield name (matches AGENTS.md and today’s app example). Peripherals live atcontext.peripherals. - 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!+wfiloop; App:tracingerror +std::process::exit(1)). Spawnerstays a separate second parameter on MCU (embassy muscle memory; it isCopyexecutor infrastructure, not a per-manifest resource). App takes onlycontext.init_time_driver()anddefmt::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 intoconsortium.gen.rsand driven by the#[main]-calledinit(). From the user’smain.rsthey 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 awaitsdoorbell.wait().await— noembassy-time. So foldingconnect()into the generated init needs no time-driver ordering; the user’sinit_time_driver()can run in the body. Nopre_initattr arg is needed now. - Glob-import shadowing works on stable (edition 2024) for the
#[fail]dispatch: a module-scopefn __consortium_init_failshadows a glob-imported default with no conflict; when absent the glob default is used. Verified with a standalonerustccompile. The inserted globusegets#[allow(unused_imports)]. - Codegen entry points:
crates/consortium-cfg/src/bridge.rs—LoweredIr::initialize(AP path,bridge.rs:190) andcontroller_init(MCU path,bridge.rs:265). Endpoint structs fromsrc/components/ipc_shm.rs:576(ShuHan::initialize), NVIC fromsrc/components/bella.rs,Peripherals+peripherals()fromsrc/components/peripheral.rs:72(returnsOption<TokenStream>). - Runtime crates re-export nothing runtime-executor related today:
consortium-runtime-mcuhas noembassy-executordep;consortium-runtime-apphas notokio::main. The example crates own those deps (embassy-executor 0.10.0, featuresexecutor-thread+platform-cortex-m;tokiowithmacros,rt-multi-thread). The macro’s expansion references::embassy_executor/::tokiowhich resolve in the user crate. - Macro convention = thin facade (
proc-macro = true) +-implcrate overproc_macro2::TokenStream; shared syn helpers inconsortium-macros-helpers(already hasget_output_type,is_result_returning,extract_result_inner). Tests =instasnapshots calling impl fns onquote!{}input; errors emitted ascompile_error!tokens. Model:consortium-tee-macros{,-impl}(tee_commandattribute +syn::parse::Parseattr-args). RootCargo.tomlmembers = ["crates/*", …]auto-includes new crates. - Time driver: both HALs expose
timer_driver::init_timer(base: *mut (), clock_hz: u32)andtimer_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) beforeinit_timer, thennvic::set_priority+enable; imx95 assumes the platform already clocked LPIT1 and only doesinit_timer+nvic. No timer/time-driver metadata exists inconsortium-data/consortium-cfgyet — 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 monotonicAtomicU32counter, independent of the time driver. Codegen already emits peripheral ISRs (peripheral.rsemits#[unsafe(no_mangle)] extern "C" fn LPI2C1_IRQHandler), so emitting a timer ISR the same way lands it in the app-ownedconsortium.gen.rsand 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) -> TokenStream2pub fn app_main(attr: TokenStream2, item: TokenStream2) -> TokenStream2pub fn mcu_fail(attr: TokenStream2, item: TokenStream2) -> TokenStream2pub 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 asContext, 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 acompile_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-malready present). Logs a static message — the error type is notdefmt::Format-bounded; users who want detail use#[fail]with the concrete type.consortium-runtime-app/src/lib.rs: analogous,tracing-style error viacrate::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 defaultmacrosfeature (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};(samemacrosdefault feature, under the existingcfg(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): renamestruct ConsortiumEndpoints→struct Context(keep fields_consortium_dbg,uio,ipc_shm).init()alreadyasyncand returnsResult<_, ConsortiumInitError>; just swap the type name. KeepIpcMemoryEndpoints,IpcMemoryEndpointsConnected,ConsortiumConnectErrorunchanged (fromipc_shm.rs). - MCU (
controller_init,bridge.rs:265-351): replace the sync#[cfg(target_arch="arm")] fn init() -> IpcMemoryEndpointswith an aggregate:
Add a small MCU#![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 }) } }ConsortiumInitErrorwrappingConsortiumConnectError(derivedefmt::Formatundertarget_os="none",Debugotherwise — mirror theconnect_error_derivesplit inipc_shm.rs:645), with aFrom<ConsortiumConnectError>. KeepIpcMemoryEndpoints::new()andconnect()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:
__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”): addconsortium_hal_stm32mp2::timer_driver::bringup()andconsortium_hal_imx9::timer_driver::bringup()that encapsulate today’s example bodies (stm32: RCCTIM2CFGRenable →init_timer(TIM2_BASE, 64 MHz)→ nvic; imx95:init_timer(LPIT1_BASE, 24 MHz)→ nvic). Codegen emitsunsafe { <hal>::timer_driver::bringup(); }with a SAFETY comment.- 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 inperipheral.rs(which emitsLPI2C1_IRQHandlerthe same way). Gate on#[cfg(target_arch = "arm")](needs the HALrtfeature for#[interrupt], already enabled by the example crates). defmt::timestamp!— emit the monotonicAtomicU32counter (chip-agnostic, no dependency on time-driver state), gated on[dbg.<core>]/ defmt being configured, so it is defined exactly once. (Switching it to readembassy_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 APContextowns itsVec<UioDevice>and the connected transceivers borrow'staticscratch buffers + the owned mmap windows, exactly asConsortiumEndpointsdoes today. The user’s sketchedContext<'static>is not needed.- Keep
struct Contextungated; keep onlyfn init()under#[cfg(target_arch="arm")]so the host trybuild harness still compiles the module (it appends its ownfn main(){}and never linkscortex-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_Bdoorbell handler +panic-halt;LPIT1time driver,defmt::timestamp!, andLPIT1_IRQHandlerbecome generated; usecontext.peripherals.) -
app (both):
#[consortium_runtime_app::main] async fn main(context: Context) { … }; the receive loop usescontext.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 generatedContextwins 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):instasnapshots for each of the four impl fns overquote!{}inputs — happy path (mcu 2-arg, app 1-arg, fail handler) and error paths (non-async, wrong arity, non-unit return) emittingcompile_error!. Followconsortium-ipc-macros-impl/tests/snap.rs(parse →prettyplease::unparse→assert_snapshot!).consortium-cfg: regenerate withUPDATE_SNAPSHOTS=1:tests/snap.rsinsta: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 theContext/init()shape.tests/conf.rsper-configtests/configs/{imx95,stm32mp257}/artifacts.snap.tests/ui.rsregeneratestests/ui/linux/*__ap.rsandtests/ui/portable/*__<core>.rsand type-checks them; confirm the new async MCUinit()+ ungatedContextstill compile on host (init is arm-gated, Context is not). The generated timer ISR +__consortium_init_time_driverare also arm-gated so the host fixtures skip them; thedefmt::timestamp!emission is defmt-gated so it stays out of the host build.- New snapshot(s) for the time-driver component (ISR +
bringupcall + timestamp) per chip.
- HAL:
cargo test/cargo check -p consortium-hal-stm32mp2 -p consortium-hal-imx9for the newtimer_driver::bringup()helpers (host-buildable parts; full check under the chip target viajust).
Docs
AGENTS.md: update the IPC Core / Runtime Layers sections to describe theContextaggregate,#[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
- Scaffold
consortium-runtime-macros-impl+ facade; implementmcu_main/app_main(main path) with insta snapshots. - Add
mcu_fail/app_fail+ the hidden__rtdefault handlers in both runtime crates; wire themacrosfeature + re-exports. - HAL: add
timer_driver::bringup()toconsortium-hal-stm32mp2andconsortium-hal-imx9(lift the example bodies; chip constants live here). - Codegen: AP rename →
Context; MCU aggregate asyncinit()+Context+ MCUConsortiumInitError; newcomponents/time.rs(time-driver init call + timer ISR +defmt::timestamp!, per-chip table + opt-out); keep two-phase API. Regenerate consortium-cfg snapshots. - Update the four example
main.rs(+ imx95optee_teec::Contextalias); delete the now hand-written timestamp/time-driver/timer-ISR from all mcu examples. - Docs (AGENTS.md).
Verification
cargo test -p consortium-runtime-macros-impl— macro snapshots.cargo test -p consortium-cfg(thenUPDATE_SNAPSHOTS=1 cargo test -p consortium-cfgto accept intentional codegen diffs; re-run to confirm green) — snap + conf + ui fixtures, including the trybuildgenerated_init_code_compilesthat type-checks the rendered app + mcu modules.cargo check -p consortium-runtime-mcuandcargo check -p consortium-runtime-app(Linux) — re-exports + default fail handlers compile.just check/just lintfor the host bundle;just test ipc host.- Full example firmware/app builds go through the builder (
csti buildgeneratesconsortium.gen.rs); theconsortium-cfgui fixtures are the in-repo proxy that the generatedContext/init()compiles for both host-app and portable-mcu shapes. If a provisioned target is available,justthumbv8m/aarch64 recipes confirm the macro expansion links against the real embassy/tokio deps.
Risks
Contextname clash withoptee_teec::Contextin imx95 app — resolved by aliasing the TEE import (plan step 4).include!hygiene: the macro callsinitandContextunqualified; they resolve becauseconsortium.gen.rsisinclude!d into the same module as the#[main]fn. Any crate that puts the macro and the include in different modules must re-export/usethem — document in AGENTS.md.tokio::mainneeds themacrosfeature in the app crate; embassy needsexecutor-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 asinit/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 = falseopt-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()movesunsafeMMIO into the HAL: keep each block narrow with the RCC/timer register invariant documented (workspace lints deny undocumented unsafe).