Impl Snake For Micro:bit - Embedded async Rust on BBC Micro:bit with Embassy

Cyril Marpaud - Oct 28 - - Dev Community

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:

stack diagram

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, let's generate a preconfigured project in the snake directory:

cargo embassy init --chip nrf52833_xxAA snake
Enter fullscreen mode Exit fullscreen mode

Project Structure

Here's a screenshot of the filetree structure of the project:

filetree

.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"
Enter fullscreen mode Exit fullscreen mode

Cargo.toml

It declares all our project's dependencies, including:

  • cortex-m and cortex-m-rt for supporting the microcontroller's architecture
  • defmt and defmt-rtt for execution logs
  • embassy-* for our application's functionality
  • panic-halt and panic-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, and debug 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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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).

arch diagram

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());
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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(),
];
Enter fullscreen mode Exit fullscreen mode

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]) { ... }
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Then, we need to wait for the first image to be signaled for display:

let mut img = image::IMG_SIG.wait().await;
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Modify the task prototype accordingly:

#[embassy_executor::task]
async fn control_task(btn_a: AnyPin, btn_b: AnyPin, touch: AnyPin) { ... }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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(()),
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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)),
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>;
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

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]

. . . .