This README is based on an early snapshot of the project and it's not going to offer a detailed view of its components. That's relegated to the living documentation generated by cargo doc, as any code examples in it are verified to compile, ensuring it does not go out of date.
There are two main goals:
- To develop a sound, portable, secure and feature-rich bootloader, applicable to common client requirements. It should be secure from the top down, which implies security at the protocol level (e.g. image signing, rollback protection, etc) and at the code level (statically guaranteed absence of buffer overflows and other memory bugs).
- Research the drawbacks and benefits of using Rust as a general purpose embedded language, developing generic interfaces and drivers for later reuse.
The installation steps are Unix specific, but Windows equivalents can be found in the rust and embedded book documentation.
- Install Rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- Select Rust Nightly:
rustup default nightly
rustup update
- Install rust toolchains:
cargo install cargo-binutils
rustup component add llvm-tools-preview
rustup target add thumbv7em-none-eabi
-
Install other toolchains: This step depends on your distribution. If yours isn't listed, search your repository list for a local equivalent:
- Arch
sudo pacman -S arm-none-eabi-gdb qemu-arch-extra openocd
- Ubuntu/Debian
sudo apt install gdb-multiarch openocd qemu-system-arm
- Fedora
sudo dnf install arm-none-eabi-gdb openocd qemu-system-arm
Optionally, it's also recommended to install VSCode as it makes it easy to debug on-target with visual access to peripheral registers.
cargo b
cross-compiles an optimized binary.cargo rb
does the above, then loads the image via STLink using probe-run (will then display defmt traces).cargo test
runs all unit, integration and documentation tests.cargo d
documents the project and opens documentation in your local browser../debug.sh
, after having ranopenocd
on a separate terminal, starts a gdb session for live debugging.
There is a VSCode project available at the root of the project, with tasks equivalent to the scripts above.
As one of the implicit goals of the project is getting Bluefruit developers comfortable with Rust, this section will cover some of the usual pain points, especially around the usual ways we work at Bluefruit.
At Bluefruit, we are used to looking at two places when approaching a new codebase:
- The API (header files in C/C++)
- Unit tests (usually in their own files)
Rust projects are structured a bit differently to C and C++ projects, which can make it difficult to find an equivalent approach.
Rust has no textual inclusion, so the header/source file separation doesn't exist. Instead Rust uses a module system. This can make it difficult to simply open a source file and try to understand its API directly. To solve this issue, the Rust community relies on the powerful documentation tools built into the cargo package manager.
To generate the project documentation and open it in your default browser window, run the following command:
cargo doc --open
This will expose all publicly available types and functions in an easier to digest way, with links to the source for further inspection. All code examples in the documentation are guaranteed to compile and kept "living" by the built in cargo test framework.
As for unit tests, the usual approach in Rust codebases is to split them in three categories:
- Integration tests: These live at the crate (library) root, generally under a tests/ directory, and work against the library's public API.
- Unit tests: These are generally inlined in the module files themselves.
- Doctests: These live within the code examples in documentation.
Embedded programming is unsafe by nature. As far as the compiler is concerned, peripheral registers look like arbitrary regions of memory, and we need to write and read from them in ways the compiler can't a priori verify.
However, that doesn't mean we can't leverage the compiler. The role of the lower driver layers is to encode that information (e.g. correctness of reads/writes to peripheral registers, rules of access) in the type system through a collection of simple interfaces, so higher level constructs and business logic can be developed safely backed by the guarantees of the borrow checker.
This safe wrapper is already mostly implemented by the Peripheral Access Crate
(PAC), but we might occasionally need to write our own unsafe blocks. All uses
of unsafe
must be documented, with a clear explanation of the reasons
why it's safe to use, and should be restricted to the lowest driver layer
under the drivers
submodule.
At the time of writing, this codebase has far too many lines of code for a simple blinky example. This can seem daunting, but the important thing to realize is that the great majority of that code exists only in the type system. The sample layers are written following current embedded Rust best practices, generally using typestate programming. This means that information that would usually exist at run time is instead managed in the type system. For example, a pin configured as an input is an entirely different type from a pin configured as an output, with different restrictions and methods.
Thanks to type erasure, none of the above has any impact in code or ram size, and generates much leaner code with lower binary/ram footprints. The major gain of this approach however is safety; encoding this information in the type system allows us to make stronger static guarantees and stop behavioural bugs at compile time.
For example, the typestate approach makes it impossible to write a logical high to an input pin; code that attempts this will not compile, while a similar mistake in a usual C codebase would result in a runtime crash at best, or undefined behaviour at worst. Similarly, a USART driver will not compile if it's passed a pin that doesn't support USART functionality. The relative complexity of expressing these contracts is the reason why seemingly basic drivers occupy so much code real estate.