A single Rust package can contain one library and multiple binaries. Rust dependencies can only be specified at the package level, not for each binary in a package. If multiple binaries require different dependencies, using a cargo workspace is the appropriate way to organize a project.

There are multiple ways to organize a workspace. This post will cover workspace organization for a no_std library that is called from both std and no_std binaries. The library will accept an FFI-safe pointer to a logging function. The binaries will call the library, and log command line arguments using the same function without passing it to the library.

Software Versions

$ date -u "+%Y-%m-%d %H:%M:%S +0000"
2022-12-01 18:34:26 +0000
$ uname -vm
Darwin Kernel Version 20.6.0: Thu Sep 29 20:15:11 PDT 2022; root:xnu-7195.141.42~1/RELEASE_X86_64 x86_64
$ ex -s +'%s/<[^>].\{-}>//ge' +'%s/\s\+//e' +'%norm J' +'g/^$/d' +%p +q! /System/Library/CoreServices/SystemVersion.plist | grep -E 'ProductName|ProductVersion' | sed 's/^[^ ]* //g' | sed 'N; s/\n/ /g'
macOS 11.7.1
$ sysctl -n machdep.cpu.brand_string
Intel(R) Core(TM) i7-4790K CPU @ 4.00GHz
$ cargo --version
cargo 1.67.0-nightly (ba607b23d 2022-11-22)

Instructions

Workspace

First, create workspace directory and add all of the member packages for the project.

mkdir workspace
cd workspace
cargo new pc
cargo new embedded
cargo new core_library --lib

Next, add a top level Cargo.toml file that lists project level metadata and all of member packages.

Cargo.toml

[workspace.package]
version = "0.1.0"
edition = "2021"

[workspace]
members = [
  "pc",
  "embedded",
  "core_library",
]

Core Library

Move into the core_library package.

cd core_library

Modify Cargo.toml. Note that version and edition are inherited from the workspace.

core_library/Cargo.toml

[package]
name = "core_library"
version.workspace = true
edition.workspace = true

[dependencies]

Add the no_std library code. It accepts an FFI-safe logging function and uses it to print a message.

core_library/src/lib.rs

#![no_std]
extern crate alloc;
use alloc::ffi::CString;
use core::ffi::c_char;

#[no_mangle]
pub extern "C" fn run(log: extern "C" fn(*const c_char)) {
  let message = CString::new("Hello, core_library!")
    .expect("CString::new failed");
  log(message.as_ptr());
}

It should now be possible to build but not run the library.

cargo build

std Rust Binary

Move into the pc package.

cd ../pc

Modify Cargo.toml. Note the core_library sibling dependency, in addition to inherited metadata.

pc/Cargo.toml

[package]
name = "pc"
version.workspace = true
edition.workspace = true

[dependencies]
core_library = { path = "../core_library" }

The code for the binary follows. Note that the core_library sibling dependency can be used like any other dependency. The code defines an FFI-safe logging function for the library, and an adapter to the logging function that takes a String for use in local code. It then logs a message, calls the library, and echoes the command line arguments.

pc/src/main.rs

use core_library::run;
use std::{ env, ffi::CStr, ffi::CString, os::raw::c_char, };

extern "C" fn log(message: *const c_char) {
  let cstr = unsafe { CStr::from_ptr(message) };
  let output = String::from_utf8_lossy(cstr.to_bytes()).to_string();
  println!("{}", output);
}

fn local_log(message: String) {
  let output = CString::new(message)
    .expect("CString::new failed");
  log(output.as_ptr());
}

fn main() {
  let message = String::from("Hello, pc!");
  local_log(message);
  run(log);
  let args: Vec<String> = env::args().collect();
  for i in 1..args.len() {
    local_log(format!("{}: {}", i, args[i]));
  }
}

Run the binary with and without arguments to make sure everything works.

cargo run
cargo run -- a bc def

no_std Rust Binary

Finally, move into the embedded package.

cd ../embedded

Modify Cargo.toml. This binary depends on libc_alloc because it is a core+alloc no_std binary.

embedded/Cargo.toml

[package]
name = "embedded"
version.workspace = true
edition.workspace = true

[dependencies]
core_library = { path = "../core_library" }
libc_alloc = "1.0.3"

The code for the binary does exactly the same thing as the std Rust code. Lacking std results in more verbose code that is a little harder to follow.

embedded/src/main.rs

#![no_std]
#![no_main]
#![feature(lang_items)]
#![feature(rustc_private)]
#![feature(default_alloc_error_handler)]
#[global_allocator]
static ALLOCATOR: ::libc_alloc::LibcAlloc = ::libc_alloc::LibcAlloc;

#[macro_use]
extern crate alloc;
extern crate libc;

use alloc::{ ffi::CString, string::String, string::ToString };
use core::{ ffi::c_char, ffi::CStr, };
use core_library::run;
use libc::c_int;

pub extern "C" fn log(message: *const c_char) {
  let format = format!("%s\n\0");
  unsafe {
    libc::printf(
      format.as_ptr() as *const _,
      message,
    );
  }
}

fn local_log(message: String) {
  let output = CString::new(message)
    .expect("CString::new failed");
  log(output.as_ptr());
}

#[no_mangle]
pub extern "C" fn main(argc: c_int, argv: *const *const c_char) -> c_int {
  let message = String::from("Hello, embedded!");
  local_log(message);
  run(log);
  for i in 1..argc {
    let cstr = unsafe { CStr::from_ptr(*argv.offset(i as isize)) };
    let safe_string = String::from_utf8_lossy(cstr.to_bytes()).to_string();
    let output = format!("{}: {}", i, safe_string);
    local_log(output);
  }
  return 0;
}

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
  loop {}
}

#[lang = "eh_personality"]
extern "C" fn eh_personality() {}

It should run just like the std version, with slightly different output.

cargo run
cargo run -- a bc def

Specifying Targets in a Workspace

Commands like cargo build will operate on the curent package, or on all package if in the top level of the workspace. The –workspace command-line flag can be used to build all targets from anywhere in the workspace, and the -p or –package flag can be used to specify a particular package.

# return to the top level of the workspace
cd ..

# this is where built targets live
ls target/debug/

# remove all built targets to verify that they are rebuilt
cargo clean

# run the following commands from anywhere in the workspace
cargo build --workspace
cargo build -p core_library
cargo run --package pc
cargo run --package pc -- one two three
cargo run -p embedded
cargo run -p embedded -- one two three

More Information

The Cargo and Rust Programming Language books have sections on workspaces. The documentation for std::ffi can be useful for FFI work involving strings. Note that the types are reexported to std, but they need to be pulled in from core or alloc in no_std Rust. The Embedded Rust Book is a good place to get started with no_std Rust.

References: