Skip to content

Distributor Guide Writing Compilation Passes

Zakaria Madaoui edited this page Aug 17, 2026 · 8 revisions

Writing Compilation Passes

This page explains how to write a new compilation pass that can be plugged into an RTICX distribution.

What is a compilation pass?

A compilation pass is a self-contained crate that implements a subset of RTICX functionality. It transforms user application syntax from a higher to a lower level syntax representation, usually expanding the input so that the next pass or the core pass can understand it.

For example, the software-tasks pass transforms #[sw_task] and spawn() calls into hardware tasks and dispatcher interrupts. The deadline pass converts deadline = D into priority = N. The auto-assign pass infers core = N from shared resource usage.

The RticPass trait

Every pass implements RticPass from rticx-core:

use proc_macro2::TokenStream as TokenStream2;
use syn::ItemMod;
use rticx_core::{InfoBus, MainInjectionPoint, RticPass};

pub trait RticPass {
    fn subscribe(&mut self, info_bus: InfoBus);

    fn run_pass(
        &self,
        args: TokenStream2,
        app_mod: ItemMod,
    ) -> syn::Result<(TokenStream2, ItemMod)>;

    fn pass_name(&self) -> &str;

    fn main_injection(&self, point: &MainInjectionPoint, core: u32) -> Option<TokenStream2> {
        None
    }
}
  • args: the token stream of the #[<distro>::app(...)] attribute arguments.
  • app_mod: the annotated module.
  • The return value is the transformed (args, app_mod).
  • pass_name is used in error messages: errors are wrapped as in `<pass_name>` compilation pass: <original error>, pointing at the user's span.
  • subscribe is the only place a pass receives a (clonable) handle to the shared InfoBus (see Using the InfoBus).
  • main_injection lets a pass inject tokens into specific spots of the generated entry functions (EntryStart, BeforeInit, BeforePostInit, BeforeIdle). It is called once per injection point per core (core is the target core index), so passes can target individual cores in multicore apps. The core pass calls it after parsing/analysis.

Pass ordering

Passes are registered as pre-core passes. The RticMacroBuilder subscribes the core backend first, then each pre-core pass in insertion order, before invoking that pass's run_pass:

use rticx_core::RticMacroBuilder;

let mut builder = RticMacroBuilder::new(my_backend);
builder.bind_pre_core_pass(MyPass);
let tokens = builder.build_rtic_macro(args, input);
  • Pre-core passes run before rticx-core parses the module. Use them to expand high-level syntax into core RTICX syntax.
  • There is no bind_post_core_pass anymore. If a pass needs to react after the core codegen, register it as the last pre-core pass and read the rticx_core::App / rticx_core::Analysis entries from the InfoBus once they have been published.

Consuming #[app(...)] arguments

A pass that consumes an #[app(...)] argument (e.g. dispatchers) must remove it from the args token stream before returning from run_pass. Leftover arguments are reported by the core pass as warnings (see Parsing attributes with RticAttr), so consuming a key and leaving it behind produces a misleading warning.

Use RticAttr::args_tokens to reconstruct the stripped arguments:

use quote::format_ident;
use rticx_core::parse_utils::RticAttr;

fn run_pass(&self, args: TokenStream2, app_mod: ItemMod) -> syn::Result<(TokenStream2, ItemMod)> {
    // ...parse and transform...
    let mut attr = RticAttr::parse_from_tokens(args.clone(), format_ident!("app"))?;
    attr.elements.remove("dispatchers");
    let args = attr.args_tokens();
    Ok((args, code))
}

The same rule applies to task-level attributes your pass re-emits: strip the keys only your pass understands (e.g. spawn_by, capacity on #[sw_task]) before renaming the attribute to #[task(...)], because the core pass rejects unknown task arguments with an error.

Pass-specific backend traits

If a pass needs target-specific information, it can define its own backend trait. The distribution implements this trait and passes the implementation to the pass constructor.

For example, rticx-sw-pass defines SwPassBackend:

pub trait SwPassBackend {
    fn queue_path(&self) -> syn::Path;
    fn generate_local_pend_fn(&self, core: u32, empty_body_fn: ItemFn) -> ItemFn;
    fn generate_cross_pend_fn(&self, core: u32, empty_body_fn: ItemFn) -> Option<ItemFn>;
    fn custom_interrupt_path(&self, core: u32) -> Option<syn::Path> { None }
    fn subscribe(&mut self, _info_bus: rticx_core::InfoBus) {}
}
  • generate_local_pend_fn fills the body of the core-local interrupt-pending function used by spawn.
  • generate_cross_pend_fn fills the cross-core interrupt-pending function used by spawn_from. Returns None on single-core targets.
  • custom_interrupt_path optionally overrides the default PAC interrupt path.
  • subscribe is the default no-op; the SoftwarePass wrapper forwards the InfoBus to its backend, so this is where a SwPassBackend implementation can grab the bus.

Parsing attributes with RticAttr

rticx_core::parse_utils::RticAttr is the standard tool for parsing #[name(key = value, ...)]-style attribute arguments: the #[app] attribute and any task/struct attribute:

use rticx_core::parse_utils::RticAttr;
use quote::format_ident;

// from a syn::Attribute
let attr = RticAttr::parse_from_attr(&attribute)?;

// from a syn::Meta (e.g. `attr.meta` of an ItemFn/ItemStruct)
let attr = RticAttr::from_meta(&meta)?;

// from a bare token stream of arguments, e.g. the `args` given to run_pass
let attr = RticAttr::parse_from_tokens(tokens, format_ident!("app"))?;
  • attr.name: the attribute name (e.g. task for #[task(...)]).
  • attr.elements: HashMap<String, syn::Expr> of the key = value pairs.

Typed accessors

Rather than matching on syn::Expr by hand, use the typed accessors. Each removes the key and parses it, keeping the original spans and producing precise errors:

Method Parses Error if
take_ident("key") single identifier (e.g. binds = UART) not an identifier
take_path("key") path (e.g. device = my_pac) not a path
take_u16("key") / take_u32("key") unsigned integer literal not an integer literal
take_ident_array("key") array of identifiers (e.g. shared = [a, b]) not an identifier array
take_expr("key") any expression never
get_expr("key") borrows without removing never
let priority = attr.take_u16("priority")?.unwrap_or(DEFAULT_TASK_PRIORITY);
let shared = attr.take_ident_array("shared")?.unwrap_or_default();

Reconstructing attributes

RticAttr implements ToTokens (re-emits #[name(k = v, ...)]), which is handy for renaming attributes: the software/async passes rename #[sw_task] to #[task] this way:

let mut attr = RticAttr::parse_from_attr(&task_attr)?;
attr.name = format_ident!("task");
attr.elements.remove("spawn_by"); // pass-only key
quote! { #attr }

attr.args_tokens() emits only the bare k = v, ... arguments (without the #[name(...)] wrapper): use it when returning stripped #[app] arguments from run_pass.

Anatomy of a pass crate

A typical pass crate contains:

  1. A Cargo.toml with rticx-core as a dependency.
  2. A public type implementing RticPass.
  3. Optionally, a public backend trait for target-specific hooks.
  4. A public constructor that accepts the backend trait implementation.

Example skeleton

use proc_macro2::TokenStream as TokenStream2;
use syn::ItemMod;
use rticx_core::{InfoBus, RticPass};

pub struct MyPass;

impl RticPass for MyPass {
    fn subscribe(&mut self, _info_bus: InfoBus) {}

    fn run_pass(
        &self,
        args: TokenStream2,
        mut app_mod: ItemMod,
    ) -> syn::Result<(TokenStream2, ItemMod)> {
        // Inspect and transform app_mod and args here
        Ok((args, app_mod))
    }

    fn pass_name(&self) -> &str {
        "my-pass"
    }
}

Using the InfoBus

The InfoBus is the shared, typed information bus that lets passes and backends exchange data during a single macro expansion. An InfoBus is created by RticMacroBuilder and clones are handed out to the core backend and each compilation pass via their subscribe methods before any run_pass is invoked.

Conventions:

  • Entry keys are namespaced by the publishing crate and the type name: crate_name::TypeName. The core pass publishes rticx_core::App and rticx_core::Analysis after parsing and analysis. The software-tasks pass publishes rticx_sw_pass::App and rticx_sw_pass::Analysis (exported as the constants INFO_APP / INFO_ANALYSIS).
  • Entries are write-once: a second publish to an existing key is an error. Pick a key namespace you own to avoid colliding with other passes.
  • publish and get errors are descriptive: EntryOccupied, EntryNotFound, and InvalidTargetType. The latter includes the expected and stored type names via std::any::type_name.
  • Subscribe ordering matters: the core backend receives the bus first, then each pre-core pass in insertion order, in each case before its run_pass runs. A later pass can therefore get entries published by an earlier pass.

A pass that wants to publish its own parsed/analyzed data typically stashes the InfoBus clone in subscribe and uses it in run_pass:

pub struct MyPass {
    info_bus: Option<rticx_core::InfoBus>,
}

impl RticPass for MyPass {
    fn subscribe(&mut self, info_bus: rticx_core::InfoBus) {
        self.info_bus = Some(info_bus);
    }

    fn run_pass(
        &self,
        args: TokenStream2,
        app_mod: ItemMod,
    ) -> syn::Result<(TokenStream2, ItemMod)> {
        // ...parse/transform...
        if let Some(bus) = &self.info_bus {
            bus.publish("my_pass::App", my_app)
                .expect("no other crate should publish my_pass::App");
        }
        Ok((args, app_mod))
    }

    fn pass_name(&self) -> &str { "my-pass" }
}

Testing a pass

Because passes are pure syntax transformers, you can test them by feeding them a parsed ItemMod and asserting on the output. For passes that change the module items, you can also write trybuild-style compilation tests as distribution examples.

For interactive debugging, use rticx-expand to snapshot the module after every pipeline stage. See Debugging and Inspection fore more details.

Next steps

Clone this wiki locally