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//! The step that is easy to overlook: **Linux remoteproc expects the vector table in a
24//! section named `.isr_vectors`**, while `cortex-m-rt` emits `.vector_table`. The
25//! deployable artifact is therefore a renamed copy, with the original kept alongside it
26//! for debugging — an ELF that boots and an ELF that a debugger understands are not
27//! quite the same file.
28
29use std::path::{Path, PathBuf};
30use thiserror::Error;
31use tokio::process::Command;
32
33use super::Compiler;
34use crate::compile::env::{DbgConfig, SharedMemConfig};
35use crate::compile::utils::{
36    elf_path, 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 build 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("{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    reporter: Option<&dyn BuildReporter>,
112) -> Result<(), FirmwareCompileError> {
113    let objcopy = resolve_objcopy();
114    let mut cmd = Command::new(&objcopy);
115    cmd.arg("--rename-section")
116        .arg(".vector_table=.isr_vectors")
117        .arg("--remove-section")
118        .arg(".defmt")
119        .arg(src)
120        .arg(dest);
121
122    let status = run_command(&mut cmd, reporter)
123        .await
124        .map_err(|e| FirmwareCompileError::Objcopy(format!("{objcopy}: {e}")))?;
125
126    if !status.success() {
127        return Err(FirmwareCompileError::Objcopy(format!(
128            "{objcopy} exited with status: {status}"
129        )));
130    }
131    Ok(())
132}
133
134/// Build parameters for bare-metal coprocessor firmware.
135#[derive(Clone)]
136pub struct FirmwareConfiguration {
137    /// Rust target triple to cross-compile for (e.g.
138    /// `thumbv8m.main-none-eabihf`). Resolved by the caller from the coprocessor's
139    /// core name (`McoreName`/`RcoreName`/`RvCoreName::as_rust_triple`), the
140    /// single source of truth for triples.
141    pub target: String,
142    /// Path to the firmware Cargo project root.
143    pub path: PathBuf,
144    /// Build in release mode.
145    pub release: bool,
146    /// IPC shared-memory layout, injected as `CONSORTIUM_IPC_*` env vars.
147    pub ipc: Option<SharedMemConfig>,
148    /// Debug ring layout, injected as `CONSORTIUM_DBG_*` env vars.
149    pub dbg: Option<DbgConfig>,
150}
151
152/// Compiler for bare-metal Cortex-M firmware.
153pub struct FirmwareCompiler;
154
155impl FirmwareCompiler {
156    pub async fn build_with_reporter(
157        config: FirmwareConfiguration,
158        reporter: Option<&dyn BuildReporter>,
159    ) -> Result<FirmwareArtifacts, FirmwareCompileError> {
160        let cargo = resolve_cargo()
161            .await
162            .map_err(FirmwareCompileError::CargoResolveError)?;
163
164        let mut cmd = Command::new(&cargo);
165        cmd.arg("build")
166            .arg("--target")
167            .arg(&config.target)
168            .current_dir(&config.path);
169
170        inject_shared_mem(&mut cmd, "IPC", config.ipc.as_ref());
171        inject_dbg(&mut cmd, config.dbg.as_ref());
172
173        if config.release {
174            cmd.arg("--release");
175        }
176
177        let status = run_command(&mut cmd, reporter)
178            .await
179            .map_err(|e| FirmwareCompileError::BuildFailed(e.to_string()))?;
180
181        if !status.success() {
182            return Err(FirmwareCompileError::BuildFailed(format!(
183                "cargo build failed with status: {status}"
184            )));
185        }
186
187        let elf = Self::output_path(&config).await?;
188
189        if !elf.exists() {
190            return Err(FirmwareCompileError::ElfNotFound(elf));
191        }
192
193        verify_elf(&elf)
194            .await
195            .map_err(FirmwareCompileError::UtilityError)?;
196
197        // `<name>_original.elf`: the untouched Rust-compiled image (keeps `.defmt`
198        // for host-side decoding), which the AP embeds via `include_bytes!`.
199        let original = original_elf_path(&elf);
200        tokio::fs::copy(&elf, &original)
201            .await
202            .map_err(|e| FirmwareCompileError::Copy(original.clone(), e))?;
203
204        // `<name>.elf`: the remoteproc-loadable image deployed to the target
205        // (`.vector_table` → `.isr_vectors`, `.defmt` stripped).
206        let deploy = deploy_elf_path(&elf);
207        objcopy_firmware(&elf, &deploy, reporter).await?;
208
209        Ok(FirmwareArtifacts { original, deploy })
210    }
211}
212
213impl Compiler for FirmwareCompiler {
214    type Configuration = FirmwareConfiguration;
215    type Error = FirmwareCompileError;
216    type Output = FirmwareArtifacts;
217
218    async fn build(config: Self::Configuration) -> Result<Self::Output, Self::Error> {
219        Self::build_with_reporter(config, None).await
220    }
221
222    async fn output_path(config: &Self::Configuration) -> Result<PathBuf, Self::Error> {
223        elf_path(&config.path, &config.target, config.release)
224            .await
225            .map_err(FirmwareCompileError::UtilityError)
226    }
227}
228
229/// Backward-compatible builder; delegates to [`FirmwareCompiler`].
230pub struct FirmwareBuilder {
231    /// Rust target triple to cross-compile for.
232    pub target: String,
233    pub path: PathBuf,
234    pub release: bool,
235    pub ipc: Option<SharedMemConfig>,
236    pub dbg: Option<DbgConfig>,
237}
238
239impl FirmwareBuilder {
240    /// Builds the firmware, returning the path to the deployable (objcopy-processed)
241    /// output ELF.
242    pub async fn build(&self) -> Result<PathBuf, String> {
243        FirmwareCompiler::build(FirmwareConfiguration {
244            target: self.target.clone(),
245            path: self.path.clone(),
246            release: self.release,
247            ipc: self.ipc,
248            dbg: self.dbg,
249        })
250        .await
251        .map(|artifacts| artifacts.deploy)
252        .map_err(|e| e.to_string())
253    }
254
255    /// Returns the expected output ELF path without building.
256    pub async fn elf_path(&self) -> Result<PathBuf, String> {
257        FirmwareCompiler::output_path(&FirmwareConfiguration {
258            target: self.target.clone(),
259            path: self.path.clone(),
260            release: self.release,
261            ipc: self.ipc,
262            dbg: self.dbg,
263        })
264        .await
265        .map_err(|e| e.to_string())
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn artifact_paths_derive_from_raw_cargo_output() {
275        // The raw cargo binary has no extension; the two emitted images sit beside
276        // it as `<name>_original.elf` and `<name>.elf`.
277        let raw = Path::new("/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu");
278        assert_eq!(
279            original_elf_path(raw),
280            Path::new(
281                "/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu_original.elf"
282            )
283        );
284        assert_eq!(
285            deploy_elf_path(raw),
286            Path::new("/proj/firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu.elf")
287        );
288    }
289
290    #[test]
291    fn objcopy_resolution_honours_override() {
292        // SAFETY: single-threaded test; restores the prior value after asserting.
293        let prev = std::env::var("CONSORTIUM_OBJCOPY").ok();
294        unsafe { std::env::set_var("CONSORTIUM_OBJCOPY", "riscv64-unknown-elf-objcopy") };
295        assert_eq!(resolve_objcopy(), "riscv64-unknown-elf-objcopy");
296        unsafe { std::env::remove_var("CONSORTIUM_OBJCOPY") };
297        assert_eq!(resolve_objcopy(), "arm-none-eabi-objcopy");
298        match prev {
299            Some(v) => unsafe { std::env::set_var("CONSORTIUM_OBJCOPY", v) },
300            None => unsafe { std::env::remove_var("CONSORTIUM_OBJCOPY") },
301        }
302    }
303}