Skip to main content

consortium_builder/compile/
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//! Coprocessor firmware compilation, plus the remoteproc section rename.
18//!
19//! Builds a bare-metal ELF with `cargo`, injects the `CONSORTIUM_IPC_*` and
20//! `CONSORTIUM_DBG_*` environment variables the firmware reads with `option_env!`, and
21//! verifies the result is really an ELF before handing it on.
22//!
23//! Linux remoteproc loaders use a chip-specific vector section name while
24//! `cortex-m-rt` emits `.vector_table`. The deployable artifact is therefore a
25//! renamed copy, with the original kept alongside it for debugging.
26
27use consortium_cfg::components::remoteproc::RemoteprocMemoryLayout;
28use object::{Object, ObjectSection, ObjectSegment};
29use std::path::{Path, PathBuf};
30use thiserror::Error;
31use tokio::process::Command;
32
33use super::Compiler;
34use crate::compile::env::{CstiWorkspace, DbgConfig, SharedMemConfig};
35use crate::compile::utils::{
36    elf_path, inject_csti, inject_dbg, inject_shared_mem, resolve_cargo, run_command, verify_elf,
37};
38use crate::progress::BuildReporter;
39
40/// Error type for firmware compilation.
41#[derive(Error, Debug)]
42pub enum FirmwareCompileError {
43    #[error("Failed to resolve cargo: {0}")]
44    CargoResolveError(String),
45
46    #[error("Cargo failed: {0}")]
47    BuildFailed(String),
48
49    #[error("ELF not found at expected path: {0:?}")]
50    ElfNotFound(PathBuf),
51
52    #[error("copying firmware ELF to {0:?}: {1}")]
53    Copy(PathBuf, std::io::Error),
54
55    #[error("objcopy post-processing failed: {0}")]
56    Objcopy(String),
57
58    #[error("remoteproc ELF validation failed: {0}")]
59    ElfLayout(String),
60
61    #[error("{0}")]
62    UtilityError(String),
63}
64
65/// The two firmware ELF images the build emits from one Rust compilation.
66#[derive(Clone, Debug)]
67pub struct FirmwareArtifacts {
68    /// `<name>_original.elf`: an unmodified copy of the Rust-compiled ELF,
69    /// retaining its `.defmt` section. This is the image the AP embeds with
70    /// `include_bytes!` so it can decode the coprocessor's defmt logs.
71    pub original: PathBuf,
72    /// `<name>.elf`: the remoteproc-loadable image, produced from the original by
73    /// `arm-none-eabi-objcopy` — `.vector_table` renamed to `.isr_vectors` and the
74    /// host-only `.defmt` section stripped. This is the image deployed to the
75    /// target.
76    pub deploy: PathBuf,
77}
78
79/// The `<name>_original.elf` sibling of a firmware crate's raw cargo output.
80///
81/// The raw cargo binary has no extension (e.g. `.../release/melt-pot-mcu`); the
82/// original is written alongside it as `.../release/melt-pot-mcu_original.elf`.
83pub fn original_elf_path(raw: &Path) -> PathBuf {
84    sibling(raw, "_original.elf")
85}
86
87/// The `<name>.elf` sibling of a firmware crate's raw cargo output — the
88/// remoteproc-loadable, objcopy-processed image (see [`FirmwareArtifacts`]).
89pub fn deploy_elf_path(raw: &Path) -> PathBuf {
90    sibling(raw, ".elf")
91}
92
93fn sibling(raw: &Path, suffix: &str) -> PathBuf {
94    let stem = raw
95        .file_name()
96        .and_then(|n| n.to_str())
97        .unwrap_or("firmware");
98    raw.with_file_name(format!("{stem}{suffix}"))
99}
100
101/// Resolves `arm-none-eabi-objcopy`, honouring a `CONSORTIUM_OBJCOPY` override.
102fn resolve_objcopy() -> String {
103    std::env::var("CONSORTIUM_OBJCOPY").unwrap_or_else(|_| "arm-none-eabi-objcopy".to_string())
104}
105
106/// Post-processes the raw firmware ELF into a remoteproc-loadable image.
107///
108/// Renames `.vector_table` to `.isr_vectors` (the section name the remoteproc ELF
109/// loader expects) and strips the host-only `.defmt` section, writing the result
110/// to `dest`.
111async fn objcopy_firmware(
112    src: &Path,
113    dest: &Path,
114    layout: Option<&RemoteprocMemoryLayout>,
115    reporter: Option<&dyn BuildReporter>,
116) -> Result<(), FirmwareCompileError> {
117    let vector_section = layout.map_or(".isr_vectors", |layout| layout.vector_section.as_str());
118    if let Some(layout) = layout {
119        validate_firmware_elf(src, ".vector_table", layout).await?;
120    }
121    let objcopy = resolve_objcopy();
122    let mut cmd = Command::new(&objcopy);
123    cmd.arg("--rename-section")
124        .arg(format!(".vector_table={vector_section}"))
125        .arg("--remove-section")
126        .arg(".defmt")
127        .arg(src)
128        .arg(dest);
129
130    let status = run_command(&mut cmd, reporter)
131        .await
132        .map_err(|e| FirmwareCompileError::Objcopy(format!("{objcopy}: {e}")))?;
133
134    if !status.success() {
135        return Err(FirmwareCompileError::Objcopy(format!(
136            "{objcopy} exited with status: {status}"
137        )));
138    }
139    if let Some(layout) = layout {
140        validate_firmware_elf(dest, vector_section, layout).await?;
141    }
142    Ok(())
143}
144
145async fn validate_firmware_elf(
146    path: &Path,
147    vector_section: &str,
148    layout: &RemoteprocMemoryLayout,
149) -> Result<(), FirmwareCompileError> {
150    let bytes = tokio::fs::read(path)
151        .await
152        .map_err(|error| FirmwareCompileError::ElfLayout(format!("{}: {error}", path.display())))?;
153    let file = object::File::parse(bytes.as_slice())
154        .map_err(|error| FirmwareCompileError::ElfLayout(format!("{}: {error}", path.display())))?;
155    let section = file.section_by_name(vector_section).ok_or_else(|| {
156        FirmwareCompileError::ElfLayout(format!(
157            "{} has no '{vector_section}' section",
158            path.display()
159        ))
160    })?;
161    if section.size() == 0 {
162        return Err(FirmwareCompileError::ElfLayout(format!(
163            "{} section '{vector_section}' is empty",
164            path.display()
165        )));
166    }
167    if section.address() != layout.vector.window.origin {
168        return Err(FirmwareCompileError::ElfLayout(format!(
169            "{} section '{vector_section}' starts at {:#x}, expected vector origin {:#x}",
170            path.display(),
171            section.address(),
172            layout.vector.window.origin
173        )));
174    }
175    if section.size() > layout.vector.window.length {
176        return Err(FirmwareCompileError::ElfLayout(format!(
177            "{} section '{vector_section}' is {:#x} bytes, exceeding vector region '{}' ({:#x} bytes)",
178            path.display(),
179            section.size(),
180            layout.vector.name,
181            layout.vector.window.length
182        )));
183    }
184    let resource_table = file.section_by_name(".resource_table").ok_or_else(|| {
185        FirmwareCompileError::ElfLayout(format!(
186            "{} has no mandatory '.resource_table' section",
187            path.display()
188        ))
189    })?;
190    let resource_window = layout.shmem.window;
191    if resource_table.size() == 0
192        || !resource_window.contains(resource_table.address(), resource_table.size())
193    {
194        return Err(FirmwareCompileError::ElfLayout(format!(
195            "{} '.resource_table' at {:#x}..{:#x} is outside mandatory region '{}'",
196            path.display(),
197            resource_table.address(),
198            resource_table
199                .address()
200                .saturating_add(resource_table.size()),
201            layout.shmem.name
202        )));
203    }
204    for segment in file.segments().filter(|segment| segment.size() != 0) {
205        let address = segment.address();
206        let size = segment.size();
207        if !layout
208            .load_windows()
209            .any(|window| window.contains(address, size))
210        {
211            return Err(FirmwareCompileError::ElfLayout(format!(
212                "{} PT_LOAD segment {address:#x}..{:#x} is outside configured remoteproc memory regions",
213                path.display(),
214                address.saturating_add(size)
215            )));
216        }
217    }
218    Ok(())
219}
220
221/// Build parameters for bare-metal coprocessor firmware.
222#[derive(Clone)]
223pub struct FirmwareConfiguration {
224    /// Rust target triple to cross-compile for (e.g.
225    /// `thumbv8m.main-none-eabihf`). Resolved by the caller from the coprocessor's
226    /// core name (`McoreName`/`RcoreName`/`RvCoreName::as_rust_triple`), the
227    /// single source of truth for triples.
228    pub target: String,
229    /// Path to the firmware Cargo project root.
230    pub path: PathBuf,
231    /// Build in release mode.
232    pub release: bool,
233    /// Pass `--offline` to cargo: resolve and build from the local registry cache
234    /// only. Populate that cache first with `csti fetch`.
235    pub offline: bool,
236    /// IPC shared-memory layout, injected as `CONSORTIUM_IPC_*` env vars.
237    pub ipc: Option<SharedMemConfig>,
238    /// Debug ring layout, injected as `CONSORTIUM_DBG_*` env vars.
239    pub dbg: Option<DbgConfig>,
240    /// Resolved remoteproc linker/ELF/DTS contract. The pipeline always sets
241    /// this; `None` retains the legacy `.isr_vectors` behavior for direct users.
242    pub remoteproc: Option<RemoteprocMemoryLayout>,
243    /// Build-host directories injected as `CSTI_*` env vars, and the workspace
244    /// layout locating `target/`. `None` injects nothing and uses the crate's
245    /// own `target/`.
246    pub workspace: Option<CstiWorkspace>,
247}
248
249/// Compiler for bare-metal Cortex-M firmware.
250pub struct FirmwareCompiler;
251
252impl FirmwareCompiler {
253    pub async fn build_with_reporter(
254        config: FirmwareConfiguration,
255        reporter: Option<&dyn BuildReporter>,
256    ) -> Result<FirmwareArtifacts, FirmwareCompileError> {
257        Self::cargo(&config, "build", reporter).await?;
258
259        let elf = Self::output_path(&config).await?;
260
261        if !elf.exists() {
262            return Err(FirmwareCompileError::ElfNotFound(elf));
263        }
264
265        verify_elf(&elf)
266            .await
267            .map_err(FirmwareCompileError::UtilityError)?;
268
269        // `<name>_original.elf`: the untouched Rust-compiled image (keeps `.defmt`
270        // for host-side decoding), which the AP embeds via `include_bytes!`.
271        let original = original_elf_path(&elf);
272        tokio::fs::copy(&elf, &original)
273            .await
274            .map_err(|e| FirmwareCompileError::Copy(original.clone(), e))?;
275
276        // `<name>.elf`: the remoteproc-loadable image deployed to the target
277        // (`.vector_table` → `.isr_vectors`, `.defmt` stripped).
278        let deploy = deploy_elf_path(&elf);
279        objcopy_firmware(&elf, &deploy, config.remoteproc.as_ref(), reporter).await?;
280
281        Ok(FirmwareArtifacts { original, deploy })
282    }
283
284    /// Type-checks the firmware with `cargo check`, under the same target, flags,
285    /// and injected environment as [`build_with_reporter`](Self::build_with_reporter).
286    /// Produces no ELF and runs no post-processing.
287    pub async fn check_with_reporter(
288        config: &FirmwareConfiguration,
289        reporter: Option<&dyn BuildReporter>,
290    ) -> Result<(), FirmwareCompileError> {
291        Self::cargo(config, "check", reporter).await
292    }
293
294    /// Runs `cargo <subcommand>` for the firmware crate.
295    async fn cargo(
296        config: &FirmwareConfiguration,
297        subcommand: &str,
298        reporter: Option<&dyn BuildReporter>,
299    ) -> Result<(), FirmwareCompileError> {
300        let cargo = resolve_cargo()
301            .await
302            .map_err(FirmwareCompileError::CargoResolveError)?;
303
304        let mut cmd = Command::new(&cargo);
305        cmd.arg(subcommand)
306            .arg("--target")
307            .arg(&config.target)
308            .current_dir(&config.path);
309
310        inject_shared_mem(&mut cmd, "IPC", config.ipc.as_ref());
311        inject_dbg(&mut cmd, config.dbg.as_ref());
312        inject_csti(&mut cmd, config.workspace.as_ref());
313
314        if config.release {
315            cmd.arg("--release");
316        }
317
318        if config.offline {
319            cmd.arg("--offline");
320        }
321
322        let status = run_command(&mut cmd, reporter)
323            .await
324            .map_err(|e| FirmwareCompileError::BuildFailed(e.to_string()))?;
325
326        if !status.success() {
327            return Err(FirmwareCompileError::BuildFailed(format!(
328                "cargo {subcommand} failed with status: {status}"
329            )));
330        }
331        Ok(())
332    }
333}
334
335impl Compiler for FirmwareCompiler {
336    type Configuration = FirmwareConfiguration;
337    type Error = FirmwareCompileError;
338    type Output = FirmwareArtifacts;
339
340    async fn build(config: Self::Configuration) -> Result<Self::Output, Self::Error> {
341        Self::build_with_reporter(config, None).await
342    }
343
344    async fn output_path(config: &Self::Configuration) -> Result<PathBuf, Self::Error> {
345        elf_path(
346            &config.path,
347            config.workspace.as_ref(),
348            &config.target,
349            config.release,
350        )
351        .await
352        .map_err(FirmwareCompileError::UtilityError)
353    }
354}
355
356/// Backward-compatible builder; delegates to [`FirmwareCompiler`].
357///
358/// Always builds online (`offline: false`); use [`FirmwareCompiler`] directly to
359/// build from the local registry cache.
360pub struct FirmwareBuilder {
361    /// Rust target triple to cross-compile for.
362    pub target: String,
363    pub path: PathBuf,
364    pub release: bool,
365    pub ipc: Option<SharedMemConfig>,
366    pub dbg: Option<DbgConfig>,
367}
368
369impl FirmwareBuilder {
370    /// Builds the firmware, returning the path to the deployable (objcopy-processed)
371    /// output ELF.
372    pub async fn build(&self) -> Result<PathBuf, String> {
373        FirmwareCompiler::build(FirmwareConfiguration {
374            target: self.target.clone(),
375            path: self.path.clone(),
376            release: self.release,
377            offline: false,
378            ipc: self.ipc,
379            dbg: self.dbg,
380            remoteproc: None,
381            workspace: None,
382        })
383        .await
384        .map(|artifacts| artifacts.deploy)
385        .map_err(|e| e.to_string())
386    }
387
388    /// Returns the expected output ELF path without building.
389    pub async fn elf_path(&self) -> Result<PathBuf, String> {
390        FirmwareCompiler::output_path(&FirmwareConfiguration {
391            target: self.target.clone(),
392            path: self.path.clone(),
393            release: self.release,
394            offline: false,
395            ipc: self.ipc,
396            dbg: self.dbg,
397            remoteproc: None,
398            workspace: None,
399        })
400        .await
401        .map_err(|e| e.to_string())
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn artifact_paths_derive_from_raw_cargo_output() {
411        // The raw cargo binary has no extension; the two emitted images sit beside
412        // it as `<name>_original.elf` and `<name>.elf`.
413        let raw = Path::new("/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu");
414        assert_eq!(
415            original_elf_path(raw),
416            Path::new(
417                "/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu_original.elf"
418            )
419        );
420        assert_eq!(
421            deploy_elf_path(raw),
422            Path::new("/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu.elf")
423        );
424    }
425
426    #[test]
427    fn objcopy_resolution_honours_override() {
428        // SAFETY: single-threaded test; restores the prior value after asserting.
429        let prev = std::env::var("CONSORTIUM_OBJCOPY").ok();
430        unsafe { std::env::set_var("CONSORTIUM_OBJCOPY", "riscv64-unknown-elf-objcopy") };
431        assert_eq!(resolve_objcopy(), "riscv64-unknown-elf-objcopy");
432        unsafe { std::env::remove_var("CONSORTIUM_OBJCOPY") };
433        assert_eq!(resolve_objcopy(), "arm-none-eabi-objcopy");
434        match prev {
435            Some(v) => unsafe { std::env::set_var("CONSORTIUM_OBJCOPY", v) },
436            None => unsafe { std::env::remove_var("CONSORTIUM_OBJCOPY") },
437        }
438    }
439}