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