Introduction
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 (IPC)
Inter-Processor Communication (IPC) is a core task in HAMPU development. Unlike MPU + MCU architectures that rely on serial links such as UART or SPI, HAMPU systems commonly use shared memory as the primary IPC transport between the CA and CM domains.
Traditional Path: RPMsg
A conventional Linux-oriented AMP IPC stack is often built around RPMsg. In this model, the CA side communicates through a Linux RPMsg interface, such as rpmsg_char, rpmsg_tty, or a custom RPMsg kernel driver. The RPMsg framework packages messages into VirtIO vrings located in a shared memory region, while a mailbox interrupt or IPI is used to notify the remote processor that new data is available. On the CM side, an OpenAMP, RPMsg-Lite, RTOS, or bare-metal endpoint consumes the message and delivers it to the CM application.
Conceptually, the traditional RPMsg path can be summarized as follows:
CA Linux user app / kernel driver
|
| /dev/rpmsg*, rpmsg_char, rpmsg_tty, or custom rpmsg driver
v
Linux RPMsg framework
|
VirtIO / vring
|
Shared memory region
|
Mailbox interrupt / IPI
|
CM OpenAMP / RPMsg-Lite / RTOS / bare-metal endpoint
|
CM application
In this architecture, RPMsg provides the logical messaging abstraction, VirtIO/vring manages shared-memory buffer exchange, shared memory carries the actual payload, and the mailbox or IPI acts as a lightweight notification mechanism rather than a data transport.
An Alternative Simpler Approach
In Consortium, we provide an alternative and simpler approach focused on simplicity and auditability. The design abstracts communication patterns first, then integrates them with concrete communication components, especially shared-memory IPC.
Generic Abstraction on IPC
An IPC transaction generally requires several components:
- a mechanism that prompts the receiver,
- a medium that carries the transmission content, and
- a pattern that converts messages into a communication-friendly format, namely serialization and deserialization.
Inspired by RPC, Consortium defines the following abstractions around these essential components:
-
Chan. Represents the hardware channel ID and is validated before use. -
Doorbell, paired withChan. ADoorbellis the mechanism that prompts the receiver. Consortium separates doorbells into three types:- Type-A (
BellA): Explicit on-chip inter-processor doorbell. This includes HAMPU hardware mailbox peripherals used to fire interrupts between processors, such as HSEM (Hardware Semaphore), MU (Messaging Unit), and IPCC (Inter-processor Communication Controller). - Type-B (
BellB): Implicit transport-coupled notification. This refers to notification behavior inherent to transport activity, such as UART, SPI, and similar communication interfaces. - Type-C (
BellC): External or board-level notification, especially GPIO-based interrupts.
A doorbell has a simple role:
ring()triggers an interrupt to the peer core,wait()asynchronously waits for an interrupt from the peer core, andpending()performs a non-blocking check for an arrived interrupt. Each operation is paired with a specificChan, which identifies the doorbell channel. - Type-A (
-
Transport. ATransportmay be half-duplex, full-duplex, split-directional, or shared bidirectional. LikeDoorbell, Consortium defines three transport categories:- Shared Memory. On-chip RAM-based transport, commonly used in HAMPU systems. In addition to the generic transport abstraction, Consortium introduces
DescriptorandSlot. - Wired. Transport over a specific wired protocol, such as UART, SPI, CAN, or EtherCAT.
- Wireless. Transport over a specific wireless protocol, such as Wi-Fi or Bluetooth Low Energy.
A transport provides
send()andrecv()operations for data transmission and reception. - Shared Memory. On-chip RAM-based transport, commonly used in HAMPU systems. In addition to the generic transport abstraction, Consortium introduces
-
Codec.Codecis a project-wide abstraction used across Consortium, including IPC, TEE, and HMI. It defines how typed messages are serialized and deserialized. Refer to the standaloneCodecsection for more information. -
Channel. In Consortium, IPC is composed of typed channels. All IPC transactions are strongly typed. A channel uses type generics to define its direction (TxorRx), the selectedTransport, the selectedCodec, and the message type. -
Transceiver. ATransceiveris commonly used during initialization to expose the initialized IPC endpoint to the user. After initialization, the user can directly use.[ipc name]to send and receive messages, or split the endpoint into separate transmitter and receiver channels.
Shared Memory Communication Fit
Abstraction Extensions
Based on the generic IPC abstraction, Consortium minimally extends the model to support shared-memory communication. The following abstractions are introduced:
-
Region: a memory region described by its base pointer, length, coherency policy, and cache maintenance actions such as flush and invalidate. -
Descriptor: a shared handshake region that describes the section using fields such as magic, version, and protocol-specific handshake conventions. -
Slot: a message payload region with flags and a defined format, either fixed-length or variable-length, optionally protected by CRC.
Memory Access
The Cortex-A side often runs a full operating system, which usually relies on virtual memory. In this scenario, Consortium adopts mmap for direct access to the shared physical memory region.
On Linux, Consortium provides a UIO-based access paradigm. Through UIO, the CA side can map the physical memory region into userspace and listen for interrupts through the UIO interrupt interface.
Cache Coherency
Consortium provides two options for maintaining cache coherency across different deployment scenarios:
-
Declare the shared memory region as non-cacheable.
- On Cortex-A, the shared region is declared as
reserved-memoryand markedno-map. - On Cortex-M, such as M7 and M33, the MPU is configured to declare the shared memory region as outer-shareable and non-cacheable.
In this case, the
flush()andinvalidate()actions defined byRegionmay be implemented as no-ops and eliminated by the compiler. - On Cortex-A, the shared region is declared as
-
Maintain cache explicitly for cached mappings.
In this mode,
Regionallows customflush()andinvalidate()actions to be defined. Whensend()andrecv()are called by the transport, the corresponding cache maintenance actions are invoked automatically and paired with the required memory fence or barrier operations.
Boundary Contract
We introduce a proc macros #[derive(IpcSafe)] to ensure boundary safety. Rejected field types (checked recursively through generics, arrays, tuples, etc.):
- Raw pointers (
*const T,*mut T) — meaningless across separate address spaces - References (
&T,&mut T) — local to one address space - Function pointers (
fn(...)) — addresses are not portable across cores usize/isize— width is platform-dependent (32-bit M-core vs 64-bit A-core)
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 (HMI)
A Human-Machine Interface (HMI) provides the user-facing control and visualization layer of an embedded system. In HAMPU systems, the HMI commonly runs in the CA domain, where Linux provides graphics, input, windowing, and application framework support.
Traditional Path: Qt
A common HMI development stack is Qt. In this model, the Linux side runs a Qt application that renders the user interface, handles user input, and communicates with lower-level services or processors.
User input / display
|
v
Qt application
|
| Qt Widgets / Qt Quick / QML
v
Linux graphics and input stack
|
| Wayland / DRM / framebuffer / evdev
v
Display and touch hardware
Qt applications are typically built using either Qt Widgets or Qt Quick/QML. Qt Widgets is commonly used for conventional desktop-style interfaces. Qt Quick/QML is commonly used for touch-oriented, animated, and embedded HMI designs.
The HMI application usually does not directly control safety-critical or real-time functions. Instead, it communicates with backend services, IPC endpoints, device drivers, or MCU-side firmware to display system state and submit user commands.
Chromium-based HMI
With modern MPUs providing stronger GPU and DSP capability, web-based HMI is becoming common in embedded systems. A typical approach is to run a Chromium-based application stack, including frameworks such as Tauri, for HTML, CSS, and JavaScript-based interfaces.
However, embedded HMI kiosk deployments usually need only a small subset of the full desktop/web-app feature set. Features such as complex window management, mobile targets, plugin systems, X11 support, and broad host integration are often unnecessary. This can make Chromium-based stacks relatively heavy for constrained embedded platforms.
WPE WebKit-based HMI
WPE WebKit provides a lighter web-based HMI option for embedded kiosk systems. It is built around libwpe, wpebackend-fdo, and wpewebkit. cog is a WPE launcher and web application container designed for kiosk-mode use.
HMI web assets
|
v
cog
|
v
WPE WebKit
|
v
Wayland or direct rendering
|
v
GPU / display hardware
Compared with a full Chromium stack, this approach is smaller and more focused. Through Wayland or direct GPU rendering, cog can provide a practical embedded web HMI runtime with fewer unnecessary desktop-oriented components.
Consortium provides Rust bindings around cog. The cogcore-sys crate exposes direct glib-based FFI access, while cogcore provides a safer Rust wrapper over cogcore.
For HMI development, the model is inspired by Tauri but remains kiosk-focused. It supports separation between debug mode using a localhost page and release mode using bundled assets, as well as controlled browser-host communication for interaction between the web UI and the embedded backend.
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 Yocto, July 3, 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 Logs
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.
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
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
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
}
}
}
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.