Skip to content

Distributor Guide Writing Compilation Passes

Zakaria Madaoui edited this page Aug 15, 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) -> 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 main() (BeforeInit, BeforePostInit, BeforeIdle). 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.elementsHashMap<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
get_expr("key") borrows without removing
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