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
17use std::path::{Path, PathBuf};
18use thiserror::Error;
19use tokio::process::Command;
20
21use super::Compiler;
22use crate::compile::env::{DbgConfig, SharedMemConfig};
23use crate::compile::utils::{
24    elf_path, inject_dbg, inject_shared_mem, resolve_cargo, run_command, verify_elf,
25};
26use crate::progress::BuildReporter;
27
28/// Error type for firmware compilation.
29#[derive(Error, Debug)]
30pub enum FirmwareCompileError {
31    #[error("Failed to resolve cargo: {0}")]
32    CargoResolveError(String),
33
34    #[error("Cargo build failed: {0}")]
35    BuildFailed(String),
36
37    #[error("ELF not found at expected path: {0:?}")]
38    ElfNotFound(PathBuf),
39
40    #[error("copying firmware ELF to {0:?}: {1}")]
41    Copy(PathBuf, std::io::Error),
42
43    #[error("objcopy post-processing failed: {0}")]
44    Objcopy(String),
45
46    #[error("{0}")]
47    UtilityError(String),
48}
49
50/// The two firmware ELF images the build emits from one Rust compilation.
51#[derive(Clone, Debug)]
52pub struct FirmwareArtifacts {
53    /// `<name>_original.elf`: an unmodified copy of the Rust-compiled ELF,
54    /// retaining its `.defmt` section. This is the image the AP embeds with
55    /// `include_bytes!` so it can decode the coprocessor's defmt logs.
56    pub original: PathBuf,
57    /// `<name>.elf`: the remoteproc-loadable image, produced from the original by
58    /// `arm-none-eabi-objcopy` — `.vector_table` renamed to `.isr_vectors` and the
59    /// host-only `.defmt` section stripped. This is the image deployed to the
60    /// target.
61    pub deploy: PathBuf,
62}
63
64/// The `<name>_original.elf` sibling of a firmware crate's raw cargo output.
65///
66/// The raw cargo binary has no extension (e.g. `.../release/melt-pot-mcu`); the
67/// original is written alongside it as `.../release/melt-pot-mcu_original.elf`.
68pub fn original_elf_path(raw: &Path) -> PathBuf {
69    sibling(raw, "_original.elf")
70}
71
72/// The `<name>.elf` sibling of a firmware crate's raw cargo output — the
73/// remoteproc-loadable, objcopy-processed image (see [`FirmwareArtifacts`]).
74pub fn deploy_elf_path(raw: &Path) -> PathBuf {
75    sibling(raw, ".elf")
76}
77
78fn sibling(raw: &Path, suffix: &str) -> PathBuf {
79    let stem = raw
80        .file_name()
81        .and_then(|n| n.to_str())
82        .unwrap_or("firmware");
83    raw.with_file_name(format!("{stem}{suffix}"))
84}
85
86/// Resolves `arm-none-eabi-objcopy`, honouring a `CONSORTIUM_OBJCOPY` override.
87fn resolve_objcopy() -> String {
88    std::env::var("CONSORTIUM_OBJCOPY").unwrap_or_else(|_| "arm-none-eabi-objcopy".to_string())
89}
90
91/// Post-processes the raw firmware ELF into a remoteproc-loadable image.
92///
93/// Renames `.vector_table` to `.isr_vectors` (the section name the remoteproc ELF
94/// loader expects) and strips the host-only `.defmt` section, writing the result
95/// to `dest`.
96async fn objcopy_firmware(
97    src: &Path,
98    dest: &Path,
99    reporter: Option<&dyn BuildReporter>,
100) -> Result<(), FirmwareCompileError> {
101    let objcopy = resolve_objcopy();
102    let mut cmd = Command::new(&objcopy);
103    cmd.arg("--rename-section")
104        .arg(".vector_table=.isr_vectors")
105        .arg("--remove-section")
106        .arg(".defmt")
107        .arg(src)
108        .arg(dest);
109
110    let status = run_command(&mut cmd, reporter)
111        .await
112        .map_err(|e| FirmwareCompileError::Objcopy(format!("{objcopy}: {e}")))?;
113
114    if !status.success() {
115        return Err(FirmwareCompileError::Objcopy(format!(
116            "{objcopy} exited with status: {status}"
117        )));
118    }
119    Ok(())
120}
121
122/// Build parameters for bare-metal coprocessor firmware.
123#[derive(Clone)]
124pub struct FirmwareConfiguration {
125    /// Rust target triple to cross-compile for (e.g.
126    /// `thumbv8m.main-none-eabihf`). Resolved by the caller from the coprocessor's
127    /// core name (`McoreName`/`RcoreName`/`RvCoreName::as_rust_triple`), the
128    /// single source of truth for triples.
129    pub target: String,
130    /// Path to the firmware Cargo project root.
131    pub path: PathBuf,
132    /// Build in release mode.
133    pub release: bool,
134    /// IPC shared-memory layout, injected as `CONSORTIUM_IPC_*` env vars.
135    pub ipc: Option<SharedMemConfig>,
136    /// Debug ring layout, injected as `CONSORTIUM_DBG_*` env vars.
137    pub dbg: Option<DbgConfig>,
138}
139
140/// Compiler for bare-metal Cortex-M firmware.
141pub struct FirmwareCompiler;
142
143impl FirmwareCompiler {
144    pub async fn build_with_reporter(
145        config: FirmwareConfiguration,
146        reporter: Option<&dyn BuildReporter>,
147    ) -> Result<FirmwareArtifacts, FirmwareCompileError> {
148        let cargo = resolve_cargo()
149            .await
150            .map_err(FirmwareCompileError::CargoResolveError)?;
151
152        let mut cmd = Command::new(&cargo);
153        cmd.arg("build")
154            .arg("--target")
155            .arg(&config.target)
156            .current_dir(&config.path);
157
158        inject_shared_mem(&mut cmd, "IPC", config.ipc.as_ref());
159        inject_dbg(&mut cmd, config.dbg.as_ref());
160
161        if config.release {
162            cmd.arg("--release");
163        }
164
165        let status = run_command(&mut cmd, reporter)
166            .await
167            .map_err(|e| FirmwareCompileError::BuildFailed(e.to_string()))?;
168
169        if !status.success() {
170            return Err(FirmwareCompileError::BuildFailed(format!(
171                "cargo build failed with status: {status}"
172            )));
173        }
174
175        let elf = Self::output_path(&config).await?;
176
177        if !elf.exists() {
178            return Err(FirmwareCompileError::ElfNotFound(elf));
179        }
180
181        verify_elf(&elf)
182            .await
183            .map_err(FirmwareCompileError::UtilityError)?;
184
185        // `<name>_original.elf`: the untouched Rust-compiled image (keeps `.defmt`
186        // for host-side decoding), which the AP embeds via `include_bytes!`.
187        let original = original_elf_path(&elf);
188        tokio::fs::copy(&elf, &original)
189            .await
190            .map_err(|e| FirmwareCompileError::Copy(original.clone(), e))?;
191
192        // `<name>.elf`: the remoteproc-loadable image deployed to the target
193        // (`.vector_table` → `.isr_vectors`, `.defmt` stripped).
194        let deploy = deploy_elf_path(&elf);
195        objcopy_firmware(&elf, &deploy, reporter).await?;
196
197        Ok(FirmwareArtifacts { original, deploy })
198    }
199}
200
201impl Compiler for FirmwareCompiler {
202    type Configuration = FirmwareConfiguration;
203    type Error = FirmwareCompileError;
204    type Output = FirmwareArtifacts;
205
206    async fn build(config: Self::Configuration) -> Result<Self::Output, Self::Error> {
207        Self::build_with_reporter(config, None).await
208    }
209
210    async fn output_path(config: &Self::Configuration) -> Result<PathBuf, Self::Error> {
211        elf_path(&config.path, &config.target, config.release)
212            .await
213            .map_err(FirmwareCompileError::UtilityError)
214    }
215}
216
217/// Backward-compatible builder; delegates to [`FirmwareCompiler`].
218pub struct FirmwareBuilder {
219    /// Rust target triple to cross-compile for.
220    pub target: String,
221    pub path: PathBuf,
222    pub release: bool,
223    pub ipc: Option<SharedMemConfig>,
224    pub dbg: Option<DbgConfig>,
225}
226
227impl FirmwareBuilder {
228    /// Builds the firmware, returning the path to the deployable (objcopy-processed)
229    /// output ELF.
230    pub async fn build(&self) -> Result<PathBuf, String> {
231        FirmwareCompiler::build(FirmwareConfiguration {
232            target: self.target.clone(),
233            path: self.path.clone(),
234            release: self.release,
235            ipc: self.ipc,
236            dbg: self.dbg,
237        })
238        .await
239        .map(|artifacts| artifacts.deploy)
240        .map_err(|e| e.to_string())
241    }
242
243    /// Returns the expected output ELF path without building.
244    pub async fn elf_path(&self) -> Result<PathBuf, String> {
245        FirmwareCompiler::output_path(&FirmwareConfiguration {
246            target: self.target.clone(),
247            path: self.path.clone(),
248            release: self.release,
249            ipc: self.ipc,
250            dbg: self.dbg,
251        })
252        .await
253        .map_err(|e| e.to_string())
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn artifact_paths_derive_from_raw_cargo_output() {
263        // The raw cargo binary has no extension; the two emitted images sit beside
264        // it as `<name>_original.elf` and `<name>.elf`.
265        let raw = Path::new("/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu");
266        assert_eq!(
267            original_elf_path(raw),
268            Path::new(
269                "/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu_original.elf"
270            )
271        );
272        assert_eq!(
273            deploy_elf_path(raw),
274            Path::new("/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu.elf")
275        );
276    }
277
278    #[test]
279    fn objcopy_resolution_honours_override() {
280        // SAFETY: single-threaded test; restores the prior value after asserting.
281        let prev = std::env::var("CONSORTIUM_OBJCOPY").ok();
282        unsafe { std::env::set_var("CONSORTIUM_OBJCOPY", "riscv64-unknown-elf-objcopy") };
283        assert_eq!(resolve_objcopy(), "riscv64-unknown-elf-objcopy");
284        unsafe { std::env::remove_var("CONSORTIUM_OBJCOPY") };
285        assert_eq!(resolve_objcopy(), "arm-none-eabi-objcopy");
286        match prev {
287            Some(v) => unsafe { std::env::set_var("CONSORTIUM_OBJCOPY", v) },
288            None => unsafe { std::env::remove_var("CONSORTIUM_OBJCOPY") },
289        }
290    }
291}