I’ve been messing around with embedded rust recently, using the BBC micro:bit as a learning platform. Its really cool to see a high level language achieving the same results as low level c.
However, one of my favorite features of rust, the ease of unit testing, is a bit less straightforward to do in cross-compiled, no-std projects. Obviously we cant run tests on our local machine that rely on hardware only found on the target board, but most of a project is going to be logic independent of the hardware its running on. What we really want to do is be able to unit test those independent blocks of logic on our local dev machine.
The Root Problem
The root of the problem is that our entire project is setup to depend on our target architecture and its hardware features. (you are using cargo embed, right?) As long as there is no compiler separation between our logic and our hardware interaction, we cant compile only one for our local machine while leaving out the rest. Fortunately, that realization leads us directly to…
The Solution
In Rust, the minimum unit of compilation is the crate. This means that if we want to separate our logic from our hardware interaction, we have to put them in separate crates. Thankfully, Rust has a feature called workspaces dedicated to managing several crates in a single project/repo.
So, what we will do is make a virtual workspace at the top level of our repo,
containing 2 or more crates: one (or more, if it makes sense) for our main hardware layer that is set up for cross compilation and flashing using cargo-embed,
one #![no-std]
crate containing only hardware independent logic that can be used on any architecture, and do not have cross-compilation explicitly setup.
The latter set of crate(s) are the ones that we will be able to put unit tests in.
Implementation
Our main_hardware
crate will depend on the independent_logic
crate, and our independent_logic
crate cannot depend on any crate that is hardware specific, such as any HALs or BSPs in use.
Lets say that we have a project that looks something like this:
.
├── .cargo
│ └── config
├── .gitignore
├── Cargo.toml
├── Embed.toml
├── LICENCE
├── README.md
├── build.rs
├── memory.x
└── src
├── calibration.rs
├── heading_drawing.rs
├── led.rs
├── line_drawing.rs
├── main.rs
└── tilt_compensation.rs
Separating the crates
calibration.rs
and main.rs
are the only modules that depend on hardware features, so they will be the modules going into hardware_main
.
The rest will be going into independent_logic
.
First we create the two new crates, with hardware_main
being a binary crate and independent_logic
being a library one, and we move the files to their respective crate.
Then, we move the existing Cargo.toml
, Embed.toml
, build.rs
, memory.x
, and .cargo/config
into the hardware_main
.
Then, we create a new Cargo.toml
at the top level, with the only section being a [workspace]
section,
including both of the crates in the workspace.
We edit the Cargo.toml
of hardware_main
to point to independent_logic
as a path dependency, and remove any dependencies that are no longer used.
We edit the Cargo.toml
of independent_logic
to add any packages that our code depends on.
We edit lib.rs
to declare all the modules in independent_logic
(and declare the crate as #![no_std]
),
and remove those declarations from main.rs
.
Finally, in the hardware_main
crate, we start running cargo check
and fix all the imports that went from being crate::
imports to independent_logic::
imports.
In the end, our file tree should look something like this:
.
├── .gitignore
├── Cargo.toml
├── LICENCE
├── README.md
├── hardware_main
│ ├── .cargo
│ │ └── config
│ ├── Cargo.toml
│ ├── Embed.toml
│ ├── build.rs
│ ├── memory.x
│ └── src
│ ├── calibration.rs
│ ├── led.rs
│ └── main.rs
└── independent_logic
├── Cargo.toml
└── src
├── heading_drawing.rs
├── lib.rs
├── line_drawing.rs
└── tilt_compensation.rs
Adding tests
Now that we have separated the crates from each other, we are free to add unit tests to independent_logic
the same way we would for any ’normal’ rust project.
However, we must keep in mind that we will only be able to run cargo test
from inside the independent_logic
crate.
Likewise, we will not be able to run cargo build/check/embed
from the top level workspace, but must run it from the hardware_main
crate, as that crate is bound to a specific target.
Cargo commands will generally also not work in the root workspace, as it will try to compile hardware_main
for our local architecture, inevitably failing.