In this article, I will guide you through creating a Snake game in embedded Rust on the BBC Micro:bit using the asynchronous framework Embassy.
The BBC Micro:bit is a small educational board. It is equipped with an ARM Cortex-M4F nRF52833 microcontroller, a 5⨉5 LED matrix, 3 buttons (one of which is touch-sensitive), a microphone, a speaker, Bluetooth capabilities, and much more.
The complete source code of the project is available on GitLab.
Although I tried to make this article accessible to as many people as possible, some prior knowledge of Rust is recommended to understand the technical details.
The Stack
Here’s an overview of the software stack we will be using:
I will briefly explain the function of each layer in the sections below. Note that for our implementation, we only need to focus on the application layer, as the others are provided by the Rust ecosystem thanks to the incredible efforts of the community.
Architecture Support
The lowest layer (i.e., the closest to the hardware) concerns architecture support. Our microcontroller is an ARM Cortex-M4F, so we will use the cortex-m
and cortex-m-rt
crates, but there are others, such as riscv
or x86_64
. In general, these crates provide the code necessary to boot a microcontroller and interact with it (interrupts, specific instructions, etc.).
Peripheral Access Crate
The next layer is called the PAC. These are crates that provide structures and functions for reading/writing to the registers of a microcontroller in a standardized way, enabling the configuration and access to all peripherals (GPIO, SPI, I²C, etc.) without risking memory corruption or hardware errors.
PACs are generated using the svd2rust
tool, which converts an SVD file describing a microcontroller's hardware into a comprehensive API.
This layer is inherently unsafe (as it reads/writes values to arbitrary addresses), but it serves the purpose of providing a safe API for embedded developers.
Hardware Abstraction Layer
In the embedded ecosystem, the embedded-hal
and embedded-hal-async
crates play a central role. They define traits that allow for interacting with peripherals in a generic way, which facilitates code reuse and portability across different targets.
Microcontroller manufacturers often provide specific HALs, but these are typically tailored to individual hardware and lack cross-platform compatibility. The embedded-hal
and embedded-hal-async
crates address this issue by offering a common high-level interface, allowing for the development of reusable, generic drivers across multiple targets.
The HAL we will use is an implementation of those traits called embassy-nrf
, which provides drivers for the peripherals of the nRF52833 microcontroller.
Operating System: Embassy
Although an operating system is not strictly necessary, it is often much more convenient to delegate task and interrupt management to a third-party library. This is the role of Embassy, which provides an asynchronous runtime for microcontrollers.
It allows multiple tasks to run concurrently (either in parallel or not) in a non-blocking, asynchronous manner. This enables quick responses to events while improving performance compared to a traditional preemptive kernel and keeping the code clean and organized, without the complexity of interrupt-based programming.
The Application
Our application sits atop all these software layers. Thanks to them, we have all the tools needed to schedule tasks and access the microcontroller’s peripherals through high-level abstractions. This allows us to focus on developing our Snake game without worrying about the technical details.
Flashing with Probe-rs
To flash our application onto the board, we will use probe-rs, a powerful and flexible open-source debugging and flashing tool for embedded systems. It allows developers to program and debug a wide variety of microcontroller targets, such as ARM Cortex-M, RISC-V, and STM32 families, among many others. Probe-rs abstracts away the intricacies of different debug probes and programming interfaces, making it easier to work with a wide range of hardware.
The tool supports a wide range of targets, including our nRF52833_xxAA, which we'll be using for this project. Installation instructions are available on the tool's documentation page.
We can verify the installation with the following command:
probe-rs --help
To run software on a target, you can use the command probe-rs run
, specifying the target chip with the --chip
argument. For example:
probe-rs run --chip nRF52833_xxAA
This command automatically flashes and starts the program on the specified microcontroller.
Software
Project Setup
Using cargo-embassy
It is possible to create a project manually by following the instructions in Embassy's documentation. However, it is much easier and quicker to use a template that will take care of creating all the necessary configuration for us.
This is exactly what cargo-embassy was created for. Let's start by installing it:
cargo install cargo-embassy
cargo embassy --help
Next, let's generate a preconfigured project in the snake
directory:
cargo embassy init --chip nrf52833_xxAA snake
Project Structure
Here's a screenshot of the filetree structure of the project:
.cargo/config.toml
It contains the configuration for the runner, which allows probe-rs to flash the target automatically when we use the cargo run
command, and the compilation target that has been set to thumbv7em-none-eabihf
:
[target.thumbv7em-none-eabihf]
runner = 'probe-rs run --chip nRF52833_xxAA'
[build]
target = "thumbv7em-none-eabihf"
Cargo.toml
It declares all our project's dependencies, including:
-
cortex-m
andcortex-m-rt
for supporting the microcontroller's architecture -
defmt
anddefmt-rtt
for execution logs -
embassy-*
for our application's functionality -
panic-halt
andpanic-probe
for panic handling
The src
folder
It contains both main.rs
, which I will describe in the next section, and fmt.rs
, which provides the components necessary for displaying execution or crash logs:
- assertions (
assert
,assert_eq
,assert_ne
, anddebug
versions) - panic (
panic
,unwrap
,unreachable
,todo
...) - logs (
trace
,debug
,info
,warn
,error
)
memory.x
It is a prerequisite for using the cortex-m-rt crate. It describes the memory layout of the microcontroller.
build.rs
The build script is preconfigured to ensure that memory.x
is available in the appropriate folder during compilation. We also need two specific linker scripts: link.x
(provided by the cortex-m-rt crate) and defmt.x
(for logs), which build.rs
also takes care of.
Embed.toml
It specifies the target for the cargo embed
command.
Testing the Project
Let's start with the main.rs
file, which for now consists of the minimal code to blink an LED.
We are in no_std
mode:
#![no_std]
#![no_main]
The main function is asynchronous, and the entry point is declared via the embassy_executor::main macro:
#[embassy_executor::main]
async fn main(_spawner: Spawner)
We will later see how to use this Spawner to start tasks.
We begin by initializing the board and retrieving the peripheral we are interested in (here, a simple output pin):
let p = embassy_nrf::init(Default::default());
let mut led = Output::new(p.P0_13, Level::Low, OutputDrive::Standard);
Then we loop, toggling its state on and off:
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
The pin used by the template (P0_13
) does not correspond to an LED on our board. By examining the schematic of the board, we can see that each LED is at the intersection of a row and a column of the matrix. To turn one on, we need to manipulate two LEDs (ROW1
and COL1
, for example).
The pin mapping gives the correspondence between the name and number of a pin. ROW1
corresponds to P0_21
, while P0_28
corresponds to COL1
.
According to the schematic, for the LED to light up, its row must be high and its column must be low. We will initialize these two pins to low, then toggle one regularly to see it blink:
let mut row1 = Output::new(p.P0_21, Level::Low, OutputDrive::Standard);
let mut col1 = Output::new(p.P0_28, Level::Low, OutputDrive::Standard);
loop {
row1.set_high();
Timer::after_millis(500).await;
row1.set_low();
Timer::after_millis(500).await;
}
If the LED blinks, congratulations, your project is now set up! We can move on to more advanced tasks.
Architecture
We will create three tasks:
- The first will control the display through the LED matrix rows and columns.
- The second will manage the controller through the button pins.
- The last will handle the game logic (using the RNG to generate the snake's food).
A task is nothing more than a simple function that runs continuously. It can communicate with other tasks via primitives like Channel or Signal.
We annotate the function with the task macro and start the task with Spawner::spawn:
#[embassy_executor::task]
async fn mytask() {
loop {
info!("Hello, World!");
Timer::after_millis(500).await;
}
}
spawner.spawn(mytask());
This task now runs concurrently with other tasks and uses asynchronous programming to yield control whenever it encounters an await point. This is the principle of a cooperative system, as opposed to preemptive.
The Screen
Our first task involves display, so let's start by creating a structure representing the screen and a mechanism to receive the images to display:
mod image {
pub const ROWS: usize = 5;
pub const COLS: usize = 5;
pub type Image = [[u8; COLS]; ROWS];
pub static IMG_SIG: Signal<CriticalSectionRawMutex, Image> = Signal::new();
}
A Signal is a synchronization primitive that allows tasks to send data to each other. It is declared static to be shared and is protected from concurrent access by a CriticalSectionRawMutex.
Next, let's pass the pins corresponding to the LED matrix rows and columns to our task via two arrays:
use embassy_nrf::gpio::Pin;
let rows = [
p.P0_21.degrade(),
p.P0_22.degrade(),
p.P0_15.degrade(),
p.P0_24.degrade(),
p.P0_19.degrade(),
];
let cols = [
p.P0_28.degrade(),
p.P0_11.degrade(),
p.P0_31.degrade(),
p.P1_05.degrade(),
p.P0_30.degrade(),
];
Since each pin has a different type, we need to convert them to a unique type to insert them into an array. The degrade() method of the Pin trait allows us to do this by transforming them into AnyPin.
We can now modify the task prototype:
#[embassy_executor::task]
async fn display_task(r_pins: [AnyPin; image::ROWS], c_pins: [AnyPin; image::COLS]) { ... }
Let's move on to implementing the task itself: we'll start by initializing the pins in output mode:
let mut r_pins = r_pins.map(|r| Output::new(r, Level::Low, OutputDrive::Standard));
let mut c_pins = c_pins.map(|c| Output::new(c, Level::High, OutputDrive::Standard));
Then, we need to wait for the first image to be signaled for display:
let mut img = image::IMG_SIG.wait().await;
The wait function returns a Future that will resolve when the signal is emitted (i.e., when the main task sends its first image). We can thus await it.
Considering the electrical schematic, it's not possible to manage the image all at once because lighting two diagonal LEDs also lights the other two LEDs on the same row and column. We will therefore display the image line by line very quickly, relying on persistence of vision.
To do this, we need a Ticker with the appropriate frequency. 60Hz is enough for the human eye not to notice. Knowing that we have 5 lines, we can initialize it as follows:
let mut ticker = Ticker::every(Duration::from_hz(60 * img.len() as u64));
From here, we can start the display loop, which will:
- Display a row of the image
- Wait for a tick
- Turn off the row
- If a new image is signaled, update it
loop {
for (r_pin, r_img) in r_pins.iter_mut().zip(img) {
c_pins
.iter_mut()
.zip(r_img)
.filter(|(_, c_img)| *c_img != 0)
.for_each(|(pin, _)| pin.set_low());
r_pin.set_high();
ticker.next().await;
r_pin.set_low();
c_pins.iter_mut().for_each(|pin| pin.set_high());
if let Some(new_img) = IMG_SIG.try_take() {
img = new_img;
break;
}
}
}
We can test this mechanism by sending it a fixed image:
const HAPPY: Image = [
[0, 1, 0, 1, 0],
[0, 1, 0, 1, 0],
[0, 0, 0, 0, 0],
[1, 0, 0, 0, 1],
[0, 1, 1, 1, 0],
];
image::IMG_SIG.signal(HAPPY);
We now have a functional screen; all that remains is to send actual images to display.
The Controller
The control task is a bit simpler: the touch button will be used to start a game, and buttons A and B will direct the snake. We will pass them separately to the main task via a Channel and a Signal, respectively:
pub static BTN_CHAN: Channel<CriticalSectionRawMutex, Button, 10> = Channel::new();
pub static TOUCH_SIG: Signal<CriticalSectionRawMutex, ()> = Signal::new();
pub enum Button {
A,
B,
}
The type transmitted by the signal is ()
, meaning it transmits no data, only the "start signal" matters.
As with the screen, pass the pins corresponding to the buttons to the task:
let (btn_a, btn_b, touch) = (p.P0_14.degrade(), p.P0_23.degrade(), p.P1_04.degrade());
spawner.spawn(control_task(btn_a, btn_b, touch));
Modify the task prototype accordingly:
#[embassy_executor::task]
async fn control_task(btn_a: AnyPin, btn_b: AnyPin, touch: AnyPin) { ... }
Convert these pins to Inputs, allowing us to retrieve their respective states. We can see from the electrical schematic that A and B have pull-up resistors, while the touch button does not:
let mut btn_a = Input::new(btn_a, Pull::Up);
let mut btn_b = Input::new(btn_b, Pull::Up);
let mut touch = Input::new(touch, Pull::None);
Electrically, a button is a switch that closes when pressed. We will wait for a button state change from high to low, then transmit it to the main task using the Input::wait_for_falling_edge function.
This function is asynchronous and returns a future. We have three events to monitor, and we can use the select3
macro from the embassy-futures crate to handle this:
match select3(
btn_a.wait_for_falling_edge(),
btn_b.wait_for_falling_edge(),
touch.wait_for_falling_edge(),
).await { ... }
When a button is pressed, one of the match arms is executed, allowing us to determine which button it is via Either3
:
match { ... } {
Either3::First(_) => BTN_CHAN.send(Button::A).await,
Either3::Second(_) => BTN_CHAN.send(Button::B).await,
Either3::Third(_) => TOUCH_SIG.signal(()),
}
Since switches are mechanical components, they can be subject to bouncing. To prevent triggering the same event multiple times, we can add a debounce delay:
Timer::after_millis(200).await;
Finally, place this match in an infinite loop to continuously listen for commands:
loop {
match select3(
btn_a.wait_for_falling_edge(),
btn_b.wait_for_falling_edge(),
touch.wait_for_falling_edge(),
)
.await
{
Either3::First(_) => BTN_CHAN.send(Button::A).await,
Either3::Second(_) => BTN_CHAN.send(Button::B).await,
Either3::Third(_) => TOUCH_SIG.signal(()),
}
Timer::after_millis(200).await;
}
Our controller is ready; we will retrieve commands in the main task when appropriate.
Handling the Game
This section is not intended to describe the operation of the Snake game in detail, but to focus on the interesting aspects related to embedded systems. For reference, the complete source code of the project is available on GitLab.
Broadly speaking, the main task will loop over the play function, which itself consists of the game’s three stages:
struct Game {
state: State,
snake: Snake,
food: Food,
}
#[embassy_executor::task]
pub async fn main_task(rng: RNG) {
let mut game = Game::new(rng);
loop {
game.play().await;
}
}
pub async fn play(&mut self) {
self.waiting_state().await;
self.ongoing_state().await;
self.over_state().await;
}
Generating the Food: Random Numbers
Start this task by passing it the RNG (Random Number Generator):
let rng = p.RNG;
spawner.spawn(main_task(rng));
As the name suggests, it allows us to generate random numbers, which is useful for placing the snake's food randomly on the screen.
As with the LEDs and buttons, the first step is to transform this peripheral into a usable programmatic object. This is what the new
method does when initializing the game's state:
pub fn new(rng: RNG) -> Game {
Game {
state: Default::default(),
snake: Default::default(),
food: Food::new(Rng::new(rng, Irqs)),
}
}
The Rng
object returned by the Rng::new
method implements the RngCore
trait. This is very interesting because the rand crate defines the Rng
trait as a subtrait of RngCore
.
This means that we can use the Rng
trait with our peripheral to generate random numbers more sophisticatedly than using the low-level API of the embassy_nrf crate.
For example, to generate a random number between 0 and 25:
// Import the trait into the local scope
use rand::Rng;
// ...
let rng = Rng::new(rng, Irqs);
let rand = rng.gen_range(0..25)
Binding Interrupts
The more curious among you might be wondering what the Irqs
structure passed to Rng::new
is and what it is for. It is a structure generated by the bind_interrupts
macro, which ensures at compile-time that the RNG interrupts are bound to ISRs (Interrupt Service Routines).
Indeed, most peripherals in a microcontroller can generate interrupts to inform the core of an event. For example:
- An I²C or SPI peripheral generates an interrupt to signal that a message is available on the bus.
- An RNG generates an interrupt to indicate that it has finished generating a random number.
- etc.
When one of these interrupts occurs, the core is interrupted and executes the corresponding ISR, which is a specific function that handles the interrupt. It is important to bind the interrupts we are interested in to ISRs to ensure they are executed when the events of interest occur.
In main.rs
, we just need to bind the RNG interrupt to its ISR for everything to work correctly:
bind_interrupts!(struct Irqs {
RNG => embassy_nrf::rng::InterruptHandler<RNG>;
});
Storing Data on no_std
Finally, I want to mention the heapless crate, which allows us to define static data structures. This means that the size of these structures is known at compile-time, and they do not require dynamic allocation. This is very useful for embedded systems where memory is limited and dynamic allocation is undesirable.
The Snake structure in my source code is a Vec with a capacity of 25 elements, the size of the screen. This means that the snake can never exceed this size, and the memory needed to store it is allocated at compile-time:
use heapless::Vec;
struct Coords {
x: usize,
y: usize,
}
enum Direction {
Up,
Right,
Down,
Left,
}
struct Snake(Vec<Coords, { ROWS * COLS }>, Direction);
Other structures like Deque, IndexMap, or String are also available.
Bonus: Adding Sound
The example project contains an additional task that uses the board's speaker to play simple sounds. To do this, it uses the PWM (Pulse Width Modulation) peripheral, which generates signals of variable frequency.
We can see from the pin mapping that the speaker is assigned to pin P0_00
. We need to send the PWM signal to this pin. A simple technique is to use the SimplePwm driver.
For the rest, feel free to explore this path ;)
Conclusion
This project demonstrates Rust’s strengths for embedded development, especially with async programming. Rust’s memory safety and zero-cost abstractions translate directly into reliability and performance, making it well-suited for production-ready embedded and IoT.
The async model in Embassy introduces a concurrency approach where tasks are efficiently woken by wakers, similar to the way microcontrollers respond to hardware interrupts. This approach allows tasks to react quickly to events while maintaining a clean and organized code structure, without the complexity of traditional interrupt-driven programming. The PAC+HAL stack further showcases Rust's flexibility, combining low-level control with high-level abstractions to balance precision and ease of use.
Rust’s rich ecosystem, including tools like probe-rs and built-in cross-compiling support, streamlines the development process compared to more traditional languages. Setting up projects, flashing, debugging, and leveraging crates like heapless for memory-efficient data handling become more intuitive, enabling scalable and reliable embedded solutions.
As embedded systems demand modern practices, Rust’s approach to safety, async programming, and hardware flexibility, empowered by a rich ecosystem, establishes it as a mature, production-ready option for modern embedded and IoT projects.
Resources
Further Reading
- Rust Lyon Meetup #8: impl Snake for Micro:bit
- Async Rust vs RTOS showdown!
- Why choose async/await over threads?
- Asynchronous Rust on Cortex-M Microcontrollers
- Snake (video game genre) - Wikipedia
- Pulse Width Modulation - Wikipedia
- Cooperative Multitasking - Wikipedia
- Preemption (Computing) - Wikipedia
- Interrupt Handler - Wikipedia
BBC Micro:bit
The Rust Language
Embassy
Probe-rs
Dependencies
Whoami
My name is Cyril Marpaud, I'm an embedded systems freelance engineer and a Rust enthusiast 🦀 I have 10 years experience and am currently living in Lyon (France).
[![LinkedIn][linkedin-shield]][linkedin-url]