Setting up per package targets in Rust

In a lot of the current embedded Rust examples the target architecture is specified in .cargo/config.toml in the build section:

target = "thumbv7em-none-eabihf"

However, if you are using a cargo workspace, this will be applied to every package. This can complicate how you organize packages within a repo (or even force you to move some packages to a new repo).

I’m currently working on an embedded Rust project and wanted to have the following workspace structure:


Where both the desktop and firmware binaries share the common-lib dependency.

The per-package-target cargo feature allows the target architecture of firmware to be specified at the package level, enabling this project structure.

Firmware package setup

At the moment I’m using an STM32F3 Discovery Kit so my setup look very similar to the Embedded Rust Book. I suggest checking that out.

per-package-target is currently an unstable cargo feature, so it needs to be switched on in Cargo.toml using cargo-features. Then forced-target is used to specify the target architecture for the package.

cargo-features = ["per-package-target"]

name = "firmware-disco"
version = "0.1.0"
edition = "2018"
forced-target = "thumbv7em-none-eabihf"

cortex-m = "0.6.0"
cortex-m-rt = "0.6.10"
cortex-m-semihosting = "0.3.3"
panic-halt = "0.2.0"
stm32f3xx-hal = {version = "0.7", features = ["stm32f303xc", "rt"]}

I found that when I had a relatively simple, like the following from the Embedded Rust Book:


use panic_halt as _;

use cortex_m::asm;
use cortex_m_semihosting::hprintln;

fn main() -> ! {
    hprintln("hello world").unwrap();

    loop {

rust-lld would throw an linker error complaining about interrupt vectors not being specified. The reason for this is because no device crate has been linked in at this point and can be solved by simple adding:

use stm32f3xx_hal as _;

This is line in particular is unnecessary the moment you want to actually access device peripherals.


use panic_halt as _;

use stm32f3xx_hal::{

use cortex_m::asm;
use cortex_m_semihosting::hprintln;

fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();

    // ... init ...

    loop {
        // ... fancy code ...

As to why this doesn’t happen when not using per-package-target I’m not entirely sure.

At this point the package can be added as a workspace member:

members = [


An all inclusive cargo build will not work. The package must be specified:

$ cargo build --package firmware-disco


I found the cargo run command doesn’t treat the firmware package as an arm architecture package unless the target is specified. It therefore did not use the configured runner and tried to directly run the firmware binary as an exe.

To run the firmware package, ensure the runner is specified in .cargo/config.toml and pass --target to the run command:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "arm-none-eabi-gdb -q -x firmware-disco/openocd.gdb"

rustflags = [
  "-C", "link-arg=-Tlink.x",

Start openocd in another terminal and then run:

$ cargo run --package firmware-disco --target thumbv7em-none-eabihf

A bit lengthy. This can be added as an alias in .cargo/config.toml:

disco = "run --package firmware-disco --target thumbv7em-none-eabihf"
$ cargo disco