2. Blinky
Used includes for this chapter:
use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{Level, Output, OutputDrive};
use {defmt_rtt as _, panic_probe as _};
As a first step, we are going to make an embedded hello-world program: a blinking LED. This will be combined with sending logging information back to your computer.
For this first exercise, you will not need any extra wiring or hardware as the development kit already has 4 LEDs ready to be used.
For now we will only use the first LED at GPIO pin P0.13
.
Anatomy of an embedded Rust program
Programming for an embedded device is different in comparison to programming an application for a full blown operating system.
Normally you would have access to a standard library, an easy way of defining an entry point (main
or start
function), and access to standard out/in/err.
This is however not available on an embedded device where the only program running, is the code you write.
You would need to write your own operating system in some sense.
This is however not trivial. As the chip you are programming for only understands numbers, basic instructions and registers. Most of the more interesting registers available can be accessed by so called memory mapped IO. These can be accessed by writing to a specific place in memory and control most of the chip's functionality.
However, changing these memory mapped IO registers directly is tedious and very error prone.
A small typo in a memory address may result in many hours of head scratching.
Rust therefore provides a tool to help us with this exact problem.
Meet svd2rust
(https://docs.rs/svd2rust/latest/svd2rust/).
Many manufacturers provide an svd
file with all the available hardware features for a specific chip.
This tool takes such a file and creates idiomatic Rust bindings for it.
These generated bindings are most often called PACs (Peripheral Access Crates)
and are for the most part already available on https://crates.io/search?q=pac.
Using these PACs directly still requires a deep understanding of the chip's internals and is not what you would use day-to-day. Instead most would use a HAL (Hardware Abstraction Layer). These again are mostly provided on https://crates.io/search?q=hal, and this is what we will use in today's workshop.
Although knowledge of the PAC is not always required, it provides an escape hatch for when more fine grained control is needed or a specific hardware feature is not provided by the chosen HAL.
Note that at the time of writing, there are two big ecosystems.
embedded-hal
(https://docs.rs/embedded-hal/latest/embedded_hal/) is currently the most prominent API for new HAL implementations to conform to.
This means that if you are a driver/library author, you should be able to write against this API and have your code being agnostic to any chip with a compatible HAL.
This however, comes sometimes at the cost of having to write for a common denominator.
Another provider of HALs is embassy
(https://embassy.dev/) which uses embedded-hal
at its core,
but also provides its own types and systems better suited for async programming.
Actually, embassy
provides first and foremost an async executor enabling async code on embedded hardware for supported architectures (ARM, RISCV, WASM, std-enabled).
This means that you can run different tasks concurrently without needing to resort to something like a complete operating system like FreeRTOS
in the C world.
We will use embassy
as our base for todays workshop.
Now enough about PACs and HALs, lets look at some code.
In the repository you cloned last chapter, there is a minimal example in src/main.rs
that already can be compiled.
#![no_std]
#![no_main]
// Ignore these following two lines, we are going to use these in a later chapter
// and are not important right now
mod frame_buffer;
mod game;
use defmt::info;
use embassy_executor::Spawner;
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let _p = embassy_nrf::init(Default::default());
info!("Hello world");
}
The first line is needed to tell Rust not to include the standard library by default as for our development board there is none available. The second line is needed to tell Rust to not emit a main or start symbol, as the hardware does not know of the existence of such symbol.
However, you still want a program to start at a certain known entrypoint.
On ARM, this is done by putting the address of your program's entrypoint at a specific location in a vector table.
Once this is done, your code will be executed, starting at the location specified there.
To make this easier, we put the attribute macro #[embassy_executor::main]
right above async fn main
.
To make Rust compile and work properly, there needs to be a way for panic!
to be defined (eh_personality
error).
In a normal setting, Rust would either unwind the stack or immediately abort and log a message with the error.
As this is not always possible in an embedded setting, we must ourselves define the desired behavior for our target device.
Luckily for us, some other nice people already did most of the hard work and packaged this in a crate called panic_probe
(https://docs.rs/panic-probe/0.3.1/panic_probe/), which configures a correct panic handler.
Next, we should take a better look at the signature of main
. It's async.
This is possible due to the attribute macro right above which also initiates an executor and starts our main
function in an async context together with a spawner.
This way, we can start spawning new tasks as needed.
Later on, we will get rid of the macro and add our own executors with which we will be able to manage priorities for certain tasks.
As a last step, we have the contents of our main
function:
let _p = embassy_nrf::init(Default::default());
info!("Hello world");
The first line calls the HAL for our chip and initializes it to a working state.
When you have worked with an Arduino in the past, think of this as everything that must happen before setup
is called.
The second line prints Hello world
.
This is possible through the crate defmt
which provides a way of communicating with your development PC through the RTT protocol.
This is not the same as you would have with Serial.println
in Arduino, but rather a trick that works with the debugger interface available on the development kit.
Blinking LED
To make an LED blink, we must first acquire ownership of the desired resource.
In our case for the first LED, we must get ownership of the peripheral P0_13
.
The result of the initialization of the HAL gives us a struct with ownership over all peripherals available to the device.
To configure this pin into a GPIO out pin, we must write the following:
let p = embassy_nrf::init(Default::default()); // changed
info!("Hello world");
// NEW
// Without access to p, we cannot construct the output pin
let mut led = Output::new(p.P0_13, Level::Low, OutputDrive::Standard);
Now that we have an output pin, we can start finalizing our blinking LED example.
Add the following to main
:
let interval = embassy_time::Duration::from_millis(1000);
loop {
led.set_high();
embassy_time::Timer::after(interval).await; // wait for a second
led.set_low();
embassy_time::Timer::after(interval).await; // wait for a second
}
The final result looks like:
#![no_std]
#![no_main]
// Ignore these following 2 lines, we are going to use these in a later chapter
// and are not important right now
mod frame_buffer;
mod game;
use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{Level, Output, OutputDrive};
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_nrf::init(Default::default());
info!("Hello world");
// Configure led to be an output
// P0.13 is LED 1
let mut led = Output::new(p.P0_13, Level::Low, OutputDrive::Standard);
let interval = embassy_time::Duration::from_millis(1000);
loop {
led.set_high();
embassy_time::Timer::after(interval).await; // wait for a second
led.set_low();
embassy_time::Timer::after(interval).await; // wait for a second
}
}
Running the blinky LED
If you have followed this far, you should have a blinking LED that is ready to be programmed on the development kit. To do this, we must first set some environment variables before running the application.
On bash/fish:
$ DEFMT_LOG="trace" cargo run
On PowerShell:
$ $env:DEFMT_LOG="trace"
$ cargo run
This will build the project, report on possible errors and flash the final binary to the development kit. After this is done, it will show the log generated on the device, and hiding all the ones that have not a high enough importance. The level of logging is controlled through the environment variables set in the above commands.
Troubleshooting
ARM error, not enough permissions to erase all
Some of these boards used during this workshop are new, and are still locked.
Before you are able to program these, you must first unlock them.
A first option is to try to erase them through probe-rs
.
You may need to run this command a second time, before the chip actually gets unlocked.
cargo run -- --allow-erase-all
If this does not work, you may try to install the vendor specific tools. You can install them from the Nordic website https://www.nordicsemi.com/Products/Development-tools/nrf-command-line-tools/download.
For Arch Linux
You can install these tools from the AUR: paru -S nrf5x-command-line-tools
.
For MacOS
You can install these tools through Homebrew: brew install nordic-nrf-command-line-tools
Once you have installed the necessary tools, run the following commands:
$ nrfjprog --recover
$ nrfjprog --eraseall # Only if previous did not work, if succesful, retry previous
The instructors have these tools installed and can help you if you are stuck.