Skip to main content

consortium_builder/pipeline/
firmware.rs

1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Stage 1: compile firmware for remote coprocessors.
18//!
19//! Only [`CoprocessorVariant::Remote`] cores are firmware targets. The AP is
20//! never in the chip database's `cp` list, and `Manager` cores (e.g. STM32MP25's
21//! `cm0p`, MIMX95's `cm33`) are excluded here even though they may still appear
22//! as IPC/dbg peers.
23
24use std::path::PathBuf;
25
26use consortium_cfg::Session;
27use consortium_cfg_common::config::{Party, VisibleCore};
28use consortium_cfg_common::database::{ChipDatabase, Coprocessor, CoprocessorVariant};
29
30use super::{CargoOptions, PipelineError, core_rust_triple, visible_core_of};
31use crate::compile::env::CstiWorkspace;
32use crate::compile::firmware::FirmwareConfiguration;
33
34/// A resolved firmware build for one remote coprocessor.
35pub struct FirmwareBuildPlan {
36    /// The coprocessor this firmware runs on.
37    pub core: VisibleCore,
38    /// The manifest party projecting to that core (its `[endpoints]` key).
39    pub party: Party,
40    /// The `cargo build` parameters for this firmware.
41    pub config: FirmwareConfiguration,
42    /// The generated per-core init module, rendered to Rust source, to be
43    /// written into the firmware crate before building.
44    pub generated: String,
45}
46
47/// Every remote (firmware-target) coprocessor in `database`.
48pub fn remote_coprocessors(database: &ChipDatabase) -> impl Iterator<Item = &Coprocessor> {
49    database
50        .cp
51        .iter()
52        .filter(|cp| cp.variant == CoprocessorVariant::Remote)
53}
54
55/// Every remote coprocessor that declares an `[endpoints]` entry: its core, the
56/// manifest party projecting to it, and its crate root as written in the
57/// manifest.
58///
59/// A remote coprocessor with no matching entry is skipped: there is no firmware
60/// crate to build for it.
61pub fn firmware_endpoints(
62    session: &Session,
63) -> Result<Vec<(VisibleCore, Party, &str)>, PipelineError> {
64    let mut endpoints = Vec::new();
65    for cp in remote_coprocessors(&session.database) {
66        let core = visible_core_of(cp)?;
67        // The party whose core projects to this coprocessor and has an endpoint.
68        if let Some((party, root)) = session
69            .config
70            .endpoints
71            .iter()
72            .find(|(party, _)| party.core() == core)
73        {
74            endpoints.push((core, *party, root.as_str()));
75        }
76    }
77    Ok(endpoints)
78}
79
80/// Plans one firmware build per remote coprocessor that declares an endpoint.
81///
82/// A remote coprocessor with no matching `[endpoints]` entry is skipped: there is
83/// no firmware crate to build for it. Each plan resolves the Cargo project path,
84/// the Rust target triple (from the core name, the single source of truth), the
85/// IPC/debug shared-memory layouts, and the generated init module.
86pub fn plan_firmware_builds(
87    session: &Session,
88    workspace: &CstiWorkspace,
89    cargo: CargoOptions,
90    ty_crate: &syn::Path,
91) -> Result<Vec<FirmwareBuildPlan>, PipelineError> {
92    let ir = session.lower()?;
93    let mut plans = Vec::new();
94
95    for (core, party, root) in firmware_endpoints(session)? {
96        let path = workspace.manifest_dir.join(root);
97        let target = core_rust_triple(core);
98
99        // The controller core's generated init module (MPU/NVIC weave plus its
100        // disconnected IPC endpoints). This is the existing codegen; the pipeline
101        // only writes it and builds.
102        consortium_log::trace!("rendering firmware init module for core {}", core);
103        let generated = consortium_cfg::render(ir.initialize(core, ty_crate)).map_err(|e| {
104            consortium_log::trace!("firmware init codegen render failed for {}: {}", core, e);
105            PipelineError::Codegen(e.to_string())
106        })?;
107
108        plans.push(FirmwareBuildPlan {
109            core,
110            party,
111            config: FirmwareConfiguration {
112                target,
113                path,
114                release: cargo.release,
115                offline: cargo.offline,
116                remoteproc: ir.remoteproc.get(&core).cloned(),
117                strict_remoteproc: true,
118                workspace: Some(workspace.clone()),
119            },
120            generated,
121        });
122    }
123
124    Ok(plans)
125}
126
127/// The build-host path of a firmware crate's compiled ELF for the given target.
128pub async fn firmware_elf_path(config: &FirmwareConfiguration) -> Result<PathBuf, PipelineError> {
129    crate::compile::utils::elf_path(
130        &config.path,
131        config.workspace.as_ref(),
132        &config.target,
133        config.release,
134    )
135    .await
136    .map_err(PipelineError::Codegen)
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use std::path::Path;
143
144    const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
145
146    fn session() -> Session {
147        Session::from_toml(STM32).expect("example manifest loads")
148    }
149
150    #[test]
151    fn remote_coprocessors_excludes_managers() {
152        let session = session();
153        let remote: Vec<_> = remote_coprocessors(&session.database)
154            .map(|cp| cp.name.clone())
155            .collect();
156        // STM32MP25: cm33 is the remote firmware target; cm0p is a manager.
157        assert!(remote.iter().any(|n| n == "cm33"));
158        assert!(!remote.iter().any(|n| n == "cm0p"));
159    }
160
161    #[test]
162    fn plans_one_build_per_remote_endpoint() {
163        let session = session();
164        let ty_crate: syn::Path = syn::parse_str("::shared").unwrap();
165        let cargo = CargoOptions {
166            release: true,
167            offline: true,
168        };
169        let workspace = crate::pipeline::resolve_workspace(&session, Path::new("/proj"))
170            .expect("nested workspace");
171        let plans = plan_firmware_builds(&session, &workspace, cargo, &ty_crate).expect("plans");
172
173        // Only cm33 (remote, with a `cm33 = "./firmware"` endpoint) is planned.
174        assert_eq!(plans.len(), 1);
175        let plan = &plans[0];
176        assert_eq!(plan.core, VisibleCore::Mcore("cm33".parse().unwrap()));
177        assert_eq!(plan.config.target, "thumbv8m.main-none-eabihf");
178        assert_eq!(plan.config.path, Path::new("/proj/firmware"));
179        // The lone firmware crate is exposed as `CSTI_MCU_PATH`.
180        assert_eq!(
181            workspace.mcu_dir.as_deref(),
182            Some(Path::new("/proj/firmware"))
183        );
184        assert!(plan.config.release);
185        // The shared cargo flags reach every firmware plan.
186        assert!(plan.config.offline);
187        // cm33 declares `[dbg.cm33]`, so its generated init hands the ring to the
188        // logger; no `CONSORTIUM_DBG_*` variable is involved.
189        assert!(plan.generated.contains("ConsortiumLogger::init("));
190        let remoteproc = plan.config.remoteproc.as_ref().expect("remoteproc layout");
191        assert_eq!(remoteproc.flash.origin, 0x8010_1000);
192        assert_eq!(remoteproc.ram.origin, 0x80a0_0000);
193        assert_eq!(remoteproc.vector_section, ".isr_vectors");
194        // The generated module is this core's controller init: the synchronous
195        // entry point handing back the peripheral and channel bundles, plus the
196        // `sensor` endpoint the manifest declares for cm33. `render` already
197        // fails if the emitted tokens are not valid Rust.
198        assert!(
199            plan.generated
200                .contains("fn init(config: Config) -> (Peripherals, Channels)"),
201            "{}",
202            plan.generated
203        );
204        assert!(
205            plan.generated.contains("channels!(SENSOR)"),
206            "{}",
207            plan.generated
208        );
209    }
210}