Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Intro - Embedded Rust Programming with Microbit

In this book, we use the Microbit (v2) with Rust to build simple and fun projects. The board is officially called "micro:bit". I will refer to it as both microbit and micro:bit interchangeably. The Microbit is widely used for learning purposes and comes with several built-in components, including an LED matrix, microphone, buttons, speaker, Bluetooth, and more.

Prerequisites

  • Rust basics: You should have a basic understanding of Rust. This book doesn't cover the fundamentals of the language. If you're new to Rust, I recommend starting with the official Rust book. You can also find other resources here.

Meet the Hardware

You can search for "Micro Bit V2" on e-commerce websites and choose the option that suits your needs. You may buy just the board, or a package that includes accessories like a battery and micro USB cable. Some sellers also offer kits with additional sensors. It is up to you to decide which version fits your project best. You can refer the official microbit website also to find the seller.

microbit

Note: I purchased the Micro Bit V2.21 with the accessories(micro USB cable). You might receive a different V2 version, which is fine as long as it is a V2 variant (not the older V1). I also bought other sensors as I went along (but dont worry about that now).

Why this book?

There's already a nice book called "Discovery" that covers embedded Rust with the micro:bit. So you might be thinking - why write another book? Well, why not? :) Honestly, one of the best ways I learn and really dig into something is by teaching it to others. When I explain stuff to others, it helps me understand it better too. So this book is me learning out loud and bringing you along for the ride.

Like the other "impl Rust" books for the ESP32 and Raspberry Pi Pico, this one is meant to be fun and hands-on too. Hopefully someone out there finds it useful - that's the goal behind writing it.

Other Learning Resources

  • The Embedded Rust Book : This is a great resource if you're just getting into embedded Rust. You don't have to read it before jumping into this book, but it's a good place to start. I'll do my best to explain things as we go, but if I miss something or don't cover it clearly, this book can really come in handy. One way or another, I definitely recommend giving it a read.

  • Discovery: This is the book I mentioned earlier. It covers embedded Rust programming with the micro:bit. You can read it in any order - either start with "impl Rust for Microbit" and then read "Discovery," or the other way around.

  • The Rusty Bits [Youtube] : This is one of my favorite youtube channel. It has a great videos on embedded rust programming with microbit.

License

The "impl Rust for Microbit" book(this project) is distributed under the following licenses:

  • The code samples and free-standing Cargo projects contained within this book are licensed under the terms of both the MIT License and the Apache License v2.0.
  • The written prose contained within this book is licensed under the terms of the Creative Commons CC-BY-SA v4.0 license.

Support this project

You can support this book by starring this project on GitHub or sharing this book with others 😊

Disclaimer:

The experiments and projects shared in this book have worked for me, but results may vary. I'm not responsible for any issues or damage that may occur while you're experimenting. Please proceed with caution and take necessary safety precautions.

Hardware details of Micro:bit

Everything I am going to explain here is already covered in detail in the official Microbit documentation. I will give brief on the hardware details. For more in depth technical details, you will need to read the official documentation here.

At the heart of the board is the nRF52833 system-on-chip (SoC). This is where all our code will run. It's built around a 32-bit Arm Cortex-M4 processor with a floating point unit(FPU). It comes with 128KB of RAM (yep, just 128KB!) and runs at 64 MHz speed.

microbit details

  • Buttons A and B: These are two user buttons you can use as input. For example, if you're making a simple game, you can use them to move a player or trigger actions.
  • 5x5 LED Matrix: This grid of red LEDs can display text, symbols, or animations.
  • Edge Connector Pins: Pins labeled 0, 1, 2, 3V, and GND let you connect external components like sensors, LEDs, or motors.
  • Microphone: Used to detect sound levels or respond to audio input (This was the fun part when i first purchased the board)
  • Speaker: You can play sounds and tones directly from the board.
  • USB Connector: Used to connect the board to your computer for programming or power.
  • Battery Connector: You can power the board using batteries when not plugged into USB (If you purchased with accessories, you will get the batter and the cable for this)
  • BLE Antenna: Enables Bluetooth communication, so you can connect the board wirelessly to other devices.

Don't be bothered by other details at the moment.

Datasheets and Manuals

Datasheets and technical manuals are helpful for understanding the pin layout, electrical specifications, communication methods, and other details of hardware components.

  • Overview: This webpage "https://tech.microbit.org/hardware/2-0-revision/" provides an overview of each component in the microbit

  • nRF52833: As i mentioned earlier, the microbit utilizes the nRF52833 System-on-Chip (SoC). To understand how to configure its pins and manage input/output operations, it's essential to read the official product specification. You can access the document here.

  • LSM303AGR: A low-power 3-axis accelerometer and magnetometer sensor used for motion detection and orientation tracking. You can access its datasheet here

  • Schematic: This provides a detailed diagram showing the electrical connections and components of the device. The full schematic in PDF format is available on GitHub here. Detailed schematic information is also available on this webpage: https://tech.microbit.org/hardware/schematic/

Don't be overwhelmed by all the information here or in the document. We will take it step by step, starting with some fun exercises. You can always return later to explore the details at your own pace.

Development Environment

I am going to assume you already have Rust installed on your machine and that you know the basics. If not, it might be a bit challenging to follow along. I highly recommend learning the basics of Rust first, then coming back to this.

probe-rs

probe-rs is a toolkit for working with embedded ARM and RISC-V devices. It supports flashing firmware, debugging programs, and printing logs via various debug probes.

The project includes tools like:

  • cargo-flash -quickly flash firmware to a target
  • cargo-embed - open a full RTT terminal with support for multiple channels and command input

We will be using this to flash (i.e writing the code into the device and running it) our program onto the microbit and run it; also for debugging purposes.

You can find more information here. Please see the installation guide for setup instructions.

You can confirm the installation was successful by running this command:

cargo embed --version

Cross compilation target

Since the micro:bit runs on an ARM Cortex-M processor, we need to compile our Rust code for that architecture. This requires setting up a specific compilation target for cross-compiling.

For the micro:bit v2, the correct target is:

thumbv7em-none-eabihf

You can add this target using Rust's built-in toolchain manager:

rustup target add thumbv7em-none-eabihf

Once added, you can specify this target when building or flashing your project. For example:

cargo build --release --target thumbv7em-none-eabihf

Or it will be used automatically when running tools like cargo embed, as long as your project is configured correctly(this part we will get into later chapter).

Quick Start

Before diving into the theory and concepts of how everything works, let's jump straight into action. Use this simple code to create blink effect on the LED Matrix of the microbit.

The microbit has a 5x5 LED matrix that you can control to show patterns, characters, or animations. Each LED can be turned on or off to create different effects.

blinky

The Full code

Don't worry about the code for now - we will explain it in the next chapter. This code simply turns on the LED at the top-left corner, then turns it off after a short delay in a loop; this will create a blinking effect.

#![no_std]
#![no_main]

use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::{board::Board, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);

    let _ = board.display_pins.col1.set_low();
    let mut row1 = board.display_pins.row1;

    loop {
        let _ = row1.set_low();
        timer.delay_ms(500);
        let _ = row1.set_high();
        timer.delay_ms(500);
    }
}

Clone the Quick start project

You can clone the quick start project I created and navigate to the project folder and run it.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/blinky

Flash - Run Rust Run

All that's left is to flash the code onto our device and watch it go!

Run the following command from your project folder:

#![allow(unused)]
fn main() {
cargo embed
}

The first LED in the top row of the display matrix should start blinking now. If you are able to flash successfully and see the blinking effect, congratulations!

Abstraction Layers

When working with embedded Rust, you will often come across terms like PAC, HAL, and BSP. These are the different layers that help you interact with the hardware. Each layer offers a different balance between flexibility and ease of use.

Let's start from the highest level of abstraction down to the lowest.

Board Support Package (BSP)

A BSP, also referred as Board Support Crate in Rust, tailored to specific development boards. It combines the HAL with board-specific configurations, providing ready to use interfaces for onboard components like LEDs, buttons, and sensors. This allows developers to focus on application logic instead of dealing with low-level hardware details. micro:bit also has Board support crate, you can find it here.

In the quick start chapter, we in fact used this.

Example code snippet for BSP

// Turn on the first LED in the first row
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use microbit::board::Board;

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();

    let _ = board.display_pins.col1.set_low();
    let mut row1 = board.display_pins.row1;
    let _ = row1.set_high();

    loop {}
}

Hardware Abstraction Layer (HAL)

The HAL sits just below the BSP level. If you work with boards like the Raspberry Pi Pico or ESP32 based boards, you'll mostly use the HAL level. In this book, after some BSP examples, we will focus more on HAL.

The HAL builds on top of the PAC and provides simpler, higher-level interfaces to the microcontroller's peripherals. Instead of handling low-level registers directly, HALs offer methods and traits that make tasks like setting timers, setting up serial communication, or controlling GPIO pins easier.

HALs usually implement the embedded-hal traits, which are standard, platform-independent interfaces for peripherals like GPIO, SPI, I2C, and UART. This makes it easier to write drivers and libraries that work across different hardware as long as they use a compatible HAL.

Later, we will explore the nrf52833-hal. As you can see, this crate is no longer specific to a dev board but instead tied to the nRF52833 chip. So if another dev board uses the same chip, you can mostly use the same code.

Example code snippet for HAL

// Turn on the first LED in the first row
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use nrf52833_hal::gpio::{p0, Level};
use nrf52833_hal::pac::Peripherals;

#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();
    let port0 = p0::Parts::new(p.P0);
    let mut col1 = port0.p0_28.into_push_pull_output(Level::High);
    let mut row1 = port0.p0_21.into_push_pull_output(Level::Low);

    col1.set_low().unwrap();
    row1.set_high().unwrap();

    loop {}
}

If you compare this to BSP code, you'll find BSP code easier to read. But at the HAL level, things get more complex. Unless you have some background in embedded programming or electronics, these terms might seem strange. Don't worry; we'll cover all this step by step later.


NOTE:

The layers below the HAL are rarely used directly. In most cases, the PAC is accessed through the HAL, not on its own. Unless you are working with a chip that does not have a HAL available, there is usually no need to interact with the lower layers directly. In this book, we will focus on the BSP and HAL layers.


Peripheral Access Crate (PAC)

PACs are the lowest level abstraction. They are auto generated crates that give type-safe access to a microcontroller's peripherals. These crates are usually created from the manufacturer's SVD (System View Description) file using tools like svd2rust. PACs give you a structured and safe way to work with hardware registers directly.

Example code snippet for PAC

// Turn on the first LED in the first row

use cortex_m_rt::entry;
use nrf52833_pac::Peripherals;

#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();
    let gpio0 = p.P0;

    gpio0.pin_cnf[21].write(|w| {
        w.dir().output();
        w.input().disconnect();
        w.pull().disabled();
        w.drive().s0s1();
        w.sense().disabled();
        w
    });
    gpio0.pin_cnf[28].write(|w| {
        w.dir().output();
        w.input().disconnect();
        w.pull().disabled();
        w.drive().s0s1();
        w.sense().disabled();
        w
    });

    gpio0.outclr.write(|w| w.pin28().clear());
    gpio0.outset.write(|w| w.pin21().set());

    loop {}
}

Raw MMIO

Raw MMIO (memory-mapped IO) means directly working with hardware registers by reading and writing to specific memory addresses. This approach mirrors traditional C-style register manipulation and requires the use of unsafe blocks in Rust due to the potential risks involved. We will not touch this area; I haven't seen anyone using this approach, and even if they do, it's outside the scope of this book.

Example code snippet

// Turn on the first LED in the first row

#![no_main]
#![no_std]

extern crate panic_halt as _;

use nrf52833_pac as _;

use core::mem::size_of;
use cortex_m_rt::entry;

const GPIO_P0: usize = 0x5000_0000;

const PIN_CNF: usize = 0x700;
const OUTSET: usize = 0x508;
const OUTCLR: usize = 0x50c;

const DIR_OUTPUT: u32 = 0x1;
const INPUT_DISCONNECT: u32 = 0x1 << 1;
const PULL_DISABLED: u32 = 0x0 << 2;
const DRIVE_S0S1: u32 = 0x0 << 8;
const SENSE_DISABLED: u32 = 0x0 << 16;

#[entry]
fn main() -> ! {
    let pin_cnf_21 = (GPIO_P0 + PIN_CNF + 21 * size_of::<u32>()) as *mut u32;
    let pin_cnf_28 = (GPIO_P0 + PIN_CNF + 28 * size_of::<u32>()) as *mut u32;
    unsafe {
        pin_cnf_21.write_volatile(
            DIR_OUTPUT | INPUT_DISCONNECT | PULL_DISABLED | DRIVE_S0S1 | SENSE_DISABLED,
        );
        pin_cnf_28.write_volatile(
            DIR_OUTPUT | INPUT_DISCONNECT | PULL_DISABLED | DRIVE_S0S1 | SENSE_DISABLED,
        );
    }

    let gpio0_outset = (GPIO_P0 + OUTSET) as *mut u32;
    let gpio0_outclr = (GPIO_P0 + OUTCLR) as *mut u32;
    unsafe {
        gpio0_outclr.write_volatile(1 << 28);
        gpio0_outset.write_volatile(1 << 21);
    }

    loop {}
}

Reference

Micro:bit Project Template with cargo-generate

To simplify project setup and learning for the micro:bit, I've created a reusable project template. We'll use the cargo-generate tool to get started.

What is cargo-generate?
cargo-generate is a tool that helps you quickly create new Rust projects using pre-made templates, avoiding boilerplate setup and code.

You can learn more about it here.

Prerequisites

Before installing cargo-generate, make sure you have libssl-dev installed.

On Ubuntu or Debian-based systems, run:

sudo apt install libssl-dev

Then, install cargo-generate with:

cargo install cargo-generate

Step 1: Generate a New Project

Once cargo-generate is installed, you can generate a new project using the following command:

cargo generate --git https://github.com/ImplFerris/mb2-template.git

You will be prompted to enter a project name.

You will then prompted to choose "BSP" or "HAL";

After that, a new directory with that name will be created. Navigate into it:

cd your-project-name

Now you're ready to build and run your micro:bit project.

To flash and run the code on the micro:bit, use:

cargo embed

Help & Troubleshooting

If you face any bugs, errors, or other issues while working on the exercises, here are a few ways to troubleshoot and resolve them.

1. Compare with Working Code

Check the complete code examples or clone the reference project for comparison. Carefully review your code and Cargo.toml dependency versions. Look out for any syntax or logic errors. If a required feature is not enabled or there is a feature mismatch, make sure to enable the correct features as shown in the exercise.

If you find a version mismatch, either adjust your code(research and find a solution; it's a great way for you to learn and understand things better) to work with the newer version or update the dependencies to match the versions used in the tutorial.

2. Search or Report GitHub Issues

Visit the GitHub issues page to see if someone else has encountered the same problem: https://github.com/ImplFerris/microbit-book/issues?q=is%3Aissue

If not, you can raise a new issue and describe your problem clearly.

3. Ask the Community

The Rust Embedded community is active in the Matrix Chat. The Matrix chat is an open network for secure, decentralized communication.

Here are some useful Matrix channels related to topics covered in this book:

You can create a Matrix account and join these channels to get help from experienced developers.

You can find more community chat rooms in the Awesome Embedded Rust - Community Chat Rooms section.

Project Walkthrough

We have successfully flashed and run our first program, which creates a blinking effect. However, we have not yet explored the code or the project structure in detail. In this section, we will recreate the same project from scratch instead of using the template. I will explain each part of the code and configuration along the way. Are you ready for the challenge?

Create a Fresh Project

We will start by creating a standard Rust binary project. Use the following command:

cargo new blinky

At this stage, the project will contain the usual files as expected.

β”œβ”€β”€ Cargo.toml
└── src
    └── main.rs

Our goal is to reach the following final project structure:

β”œβ”€β”€ .cargo
β”‚   └── config.toml
β”œβ”€β”€ Cargo.toml
β”œβ”€β”€ Embed.toml
β”œβ”€β”€ memory.x
└── src
    └── main.rs

Dependencies

We will begin by adding the required dependencies for the project. Update the Cargo.toml file with the following entries:

cortex-m-rt = "0.7.3"
microbit-v2 = "0.15.0"
embedded-hal = "1.0.0"

We are using the Board Support Package (BSP) approach, so the microbit-v2 crate provides the board support layer for the micro:bit v2.

We will also take a closer look at the other two dependencies, cortex-m-rt and embedded-hal, in separate sections where I can explain their roles in more detail.

Runtime

Let's start with the first dependency cortex-m-rt. This crate provides the startup code and a minimal runtime for Cortex-M microcontrollers. As you may already know, our micro:bit is also based on a Cortex-M core.

Why do we need?

In embedded development, there is typically no underlying OS (though specialized operating systems for microcontrollers do exist). This means you must set up everything yourself: how the program starts, how memory is initialized, and how the device responds to events like button presses or incoming data.

To make all of this work, we will use a runtime crate. A runtime in embedded Rust provides the minimal startup code that runs before your main function, sets up memory (like the stack and heap), and helps you define how your program should react to interrupts.


Entry point

From a developer's perspective, it might seem like the main function is the first code executed when a program runs. However, this isn't actually the case. In most languages, a runtime system sets up the environment before eventually calling main.

In contrast, embedded systems like the micro:bit don't have a standard runtime. Instead, we use a custom runtime, such as the one provided by the cortex-m-rt crate. In this setup, we need to explicitly specify the program's entry point. For the micro:bit v2, it is done using the #[entry] attribute provided by cortex-m-rt, which tells the runtime which function to run first.

no_main

We will use the #![no_main] directive to tell the Rust compiler that we don't want to use the normal program entry point. Instead, we will provide our own entry point and main function.

By adding #![no_main], we disable the default program's startup logic, allowing the embedded runtime (like cortex-m-rt) to take control.

Modify code

Let's update our program to include these attributes. Open your project in a code editor and modify the src/main.rs file as follows:

#![no_std]
#![no_main]

use cortex_m_rt::entry;

#[entry]
fn main() {
    println!("Hello, world!");
}

After you update the code, you will likely see an error from rust-analyzer saying:

#![allow(unused)]
fn main() {
error: `#[entry]` function must have signature `[unsafe] fn() -> !`
}

This happens because the #[entry] function is required to never return. It must have the return type ! (called the "never" type) to show that the program runs indefinitely and does not exit. This requirement is critical in embedded or bare-metal systems because, unlike applications running on a traditional operating system, there is no OS to hand control back to once the program finishes.

To fix this, update your main function signature to:

#[entry]
fn main() -> ! {
    loop {
        // keep running forever
    }
}

If you have Clippy enabled, you might see a warning saying "empty loop {} wastes CPU cycles." You can safely ignore this warning for now.

References:

Embedded HAL

The embedded-hal crate is the heart of the embedded Rust ecosystem. It provides a foundation of common hardware abstraction traits for things like I/O, SPI, I2C, PWM, and timers. These traits create a standard interface that lets high-level drivers, like those for sensors or wireless devices, work across different hardware platforms.

Because drivers are written as generic libraries on top of embedded-hal, they can support a wide range of targets, from Cortex-M and AVR microcontrollers to embedded Linux systems.

Example

In the quick-start example, we used embedded-hal traits to control pins and timers on a micro:bit board. The set_low and set_high functions come from the OutputPin trait, and the delay_ms function comes from the DelayNs trait, both part of embedded-hal.

You might still wonder why not just write set_low and set_high functions directly without using traits. To illustrate this, consider two versions of a simple function that turns an LED on or off:

#![allow(unused)]
fn main() {
// Example with a concrete pin type (imaginary MicrobitPin)
struct MicrobitPin;

impl MicrobitPin {
    fn set_low(&mut self) {
        // Hardware-specific code to set the pin low
    }
    fn set_high(&mut self) {
        // Hardware-specific code to set the pin high
    }
}
}

Your application/driver code:

#![allow(unused)]
fn main() {
fn control_led_concrete(pin: &mut MicrobitPin, light_up: bool) {
    if light_up {
        pin.set_low();
    } else {
        pin.set_high();
    }
}
}

This is your application code to control the LED. This function only works with the MicrobitPin type. What if you want to port your application or driver to support other microcontrollers? Then you have to write a new crate or add separate logic to handle those.

#![allow(unused)]
fn main() {
fn control_led_another_mcu(pin: &mut AnotherMcuPin, light_up: bool) {
    if light_up {
        pin.set_low();
    } else {
        pin.set_high();
    }
}
}

Now let's compare it with the embedded-hal trait-based approach:

#![allow(unused)]
fn main() {
use embedded_hal::digital::OutputPin;

// This function works with any pin type that implements OutputPin
fn control_led_generic<P: OutputPin>(pin: &mut P, light_up: bool) {
   if light_up {
       let _ = pin.set_low();
   } else {
       let _ = pin.set_high();
   }
}
}

By using the OutputPin trait, this function works on any hardware platform that implements the trait. This makes your code reusable and portable without rewriting it for every board.

This trait-based approach is why embedded-hal is so important in embedded Rust - it provides a common interface that works across different hardware.

no_std Rust environment

When you write a regular Rust program, you get access to the full standard library (std). It gives you features like heap allocation, threads, file systems, and networking. But all these features assume one thing: there's an operating system underneath.

In embedded systems, we usually don't have an operating system. There's no filesystem. No network stack. No heap allocator unless you bring your own. You are running directly on the hardware.

That's where no_std comes in.

By adding this line at the top of your code:

#![allow(unused)]
#![no_std]
fn main() {
}

You tell the Rust compiler: "I don't need the standard library. I'll manage with just the core language features."

Rust will now link only the minimal core crate, which includes the essentials: basic types, error handling, and so on. This is enough to write logic for most embedded applications.

Modify code

Let's update our program to include this directive. Open your project in a code editor and modify the src/main.rs file as follows:

#![no_std]
#![no_main]

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    loop {}
}

LED Matrix

On the micro:bit board, the onboard LEDs are arranged in a 5x5 matrix, giving a total of 25 LEDs. Unlike other boards that have just one or two LEDs each connected to dedicated GPIO (General Purpose I/O) pins, the micro:bit can't use a separate GPIO pin for every LED. If it did, all the GPIO pins would be used up just for the LEDs, leaving none available for things like sensors or other inputs.

Multiplexing: Sharing Pins to Control Many LEDs

Instead of using one pin per LED, the micro:bit's 5x5 matrix uses only 10 GPIO pins: 5 for rows and 5 for columns. The LEDs are wired in a grid where each LED sits at the intersection of a row and a column.

  • Row pins provide power (set to logic HIGH).
  • Column pins provide a path to ground (set to logic LOW).

By selecting the right row and column, the microcontroller can light up a single LED.

microbit

How Lighting a Single LED Works

To turn on a specific LED, for example the one in row 2 and column 3:

  1. We will set the row 2 to HIGH. This supplies voltage to that row.
  2. We will set the column 3 to LOW. This connects that column to ground.
  3. The current flows from the row through the LED at that intersection to the column, lighting it up.

LED matrix

Lighting Multiple LEDs Using Fast Scanning

When we want to light up several LEDs in different rows, the micro:bit turns on one row at a time, very quickly.

For example:

  • It activates row 1 and sets the appropriate columns LOW to light some LEDs.
  • Then it deactivates row 1, activates row 2, and updates the column pins.
  • This continues rapidly for all 5 rows.

This scanning happens so fast (dozens of times per second) that our eyes cannot detect the flickering. Instead, we see a steady image. This effect is known as persistence of vision.

NOTE: You don't need to worry about how this scanning is done internally. In code, we just set the required columns to LOW and the target row to HIGH. The micro:bit takes care of the rest.

GPIO pin mapping

This info isn't very useful for this chapter, but later when we work with HAL, we'll need to know which row and column match which pin.

As per the micro:bit V2 schematic, the LED matrix pins connect to the following GPIOs on the nRF52833 microcontroller:

Matrix RoleRolePortPin
ROW1SourceP021
ROW2SourceP022
ROW3SourceP015
ROW4SourceP024
ROW5SourceP019
COL1SinkP028
COL2SinkP011
COL3SinkP031
COL4SinkP105
COL5SinkP030

Reference

Implementing the Core logic

We started with a fresh project, and in the last few chapters, we mainly focused on theory. That might have felt a bit dry (or maybe exciting, depending on your perspective). In this section, we will switch gears and focus on writing actual code to keep things hands-on and engaging.

Keep in mind: the code will not compile or run just yet, since we still need to set up a few configurations specific to the microcontroller. But don't worry! We will take it step by step. For now, let's concentrate on building the core logic.

Imports

Let's begin with the necessary imports. We will use the DelayNs and OutputPin traits provided by the embedded HAL. The DelayNs trait allows us to add delays between turning the LED on and off, creating the visible blinking effect;without it, the LED would toggle too fast to see.

As mentioned earlier, the OutputPin trait provides methods(set_low, set_high) to change the state of a microcontroller's output pin. We will use it to toggle the output pin connected to our target LED between low and high states.

#![allow(unused)]
fn main() {
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::{board::Board, hal::timer::Timer};
}

We will also import the necessary structs from the microbit crate.

Main function

Now let's update the main function.

First, we acquire access to the micro:bit board peripherals by calling:

#![allow(unused)]
fn main() {
let mut board = Board::take().unwrap();
}

This line gives us a singleton instance of the board, which contains handles to all the microcontroller's hardware peripherals like pins, timers, and more. The take() method returns an Option because the peripherals can only be taken once during the program's lifetime to prevent multiple mutable accesses that could cause conflicts or data races. Calling unwrap() here is safe because we expect to call take() only once in our program.

After obtaining the board peripherals, the next step is to create a timer instance:

#![allow(unused)]
fn main() {
let mut timer = Timer::new(board.TIMER0);
}

Display Matrix

As we learned in the LED Matrix section, to turn on the LED at first row and first column, we will set column 1 to LOW (i.e connecting it to ground). Then inside a loop, we will keep toggling row 1 between HIGH(i.e connecting it to power) and LOW, with a 500 ms delay in between to create a blinking effect.

#![allow(unused)]
fn main() {
let _ = board.display_pins.col1.set_low();
let mut row1 = board.display_pins.row1;

loop {
    let _ = row1.set_low();
    timer.delay_ms(500);
    let _ = row1.set_high();
    timer.delay_ms(500);
}
}

Panic Handler

I assume you already have a basic idea of what a panic is in Rust. When a panic happens, the program does not immediately exit. Instead, control is passed to the panic handler provided by the standard lib. By default, it starts unwinding the stack of the panicking thread. But if the user has chosen to abort on panic, then the program will terminate right away without unwinding.

If we try to build the program at this stage, we will see this error:

error: `#[panic_handler]` function required, but not found

error: unwinding panics are not supported without std

This means we need to define a custom panic handler, since we're not using the standard library.

Panic handler in no_std

In a no_std environment, we have to provide our own panic handler. There are crates that do this for us, and we can pick one based on the behavior we want.

For example:

  • If we want the program to abort immediately, we can use the panic-abort crate.

  • If we want the program (or the current thread) to halt by entering an infinite loop, we can use the panic-halt crate.

If you check the source code of these crates, you will notice they are just simple functions.

You can either import one of these crates like this:

#![allow(unused)]
fn main() {
use panic_halt as _;
}

Or define the panic handler function yourself. That's what we're going to do.

The function must be marked with the #[panic_handler] attribute, and it must accept a reference to core::panic::PanicInfo. It should never return, so its return type is !.

Here's the function (equivalent to what panic-halt provides). Let's update the src/main.rs file and include the following code

#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}
}

Reference

Target

Let's try building it. Wait, it still doesn't build and shows this error:

rustc: found duplicate lang item `panic_impl`
the lang item is first defined in crate `std` (which `test` depends on)

The solution is simple: you just need to specify the target platform explicitly. When you don't specify the target, the compiler builds for your host machine by default, which includes its own panic handler from std. This causes a conflict because your code also provides a panic handler, leading to the duplicate lang item error.

Since our micro:bit uses an ARM Cortex-M4 32-bit processor with a Floating Point Unit (FPU), the correct target to use is thumbv7em-none-eabihf. We already added this target in the quickstart section.

So we can simply build it using the following command:

cargo build --target thumbv7em-none-eabihf

.cargo/config.toml

There are two things to fix. First, the code editor might still highlight the panic function and show a duplicate error. Second, it's not convenient to type the target every time we build.

To solve this, we can create a config.toml file inside the .cargo directory and set the default target there.

Run the following commands from the root of your project (the same directory where Cargo.toml is located) to create the .cargo directory. Then create the config.toml inside the .cargo directory.

mkdir .cargo
cd .cargo

Update the config.toml with the following content:

[build]
target = "thumbv7em-none-eabihf"

Now try running the build command without specifying the target. It should compile successfully:

cargo build

Let's take a look at the src/main.rs code we have up to this point.

#![no_std]
#![no_main]

use cortex_m_rt::entry;

use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::{board::Board, hal::timer::Timer};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);

    let _ = board.display_pins.col1.set_low();
    let mut row1 = board.display_pins.row1;

    loop {
        let _ = row1.set_low();
        timer.delay_ms(500);
        let _ = row1.set_high();
        timer.delay_ms(500);
    }
}

Are we there yet?

We have successfully been able to build the program, but are we ready to flash it (write it into the micro:bit's persistent memory and run it)? Not yet but we are close. There are a few more steps we need to complete before that.

If you try running cargo embed now, you'll see this error:

...other warnings...
WARN probe_rs::flashing::loader: No loadable segments were found in the ELF file.
Error No loadable segments were found in the ELF file.

This error means that the compiler created a file (ELF file) that doesn't have any actual code or data for the flasher to write into the micro:bit. In other words, the file is empty from the flasher's point of view.

Why did this happen? Because the compiler doesn't know where in memory to place the program. Even though we are using the cortex-m-rt crate (which gives us startup code and other support), the linker still needs to know how the micro:bit's memory is laid out. Without this information, it skips putting the actual program into the output file.

Fixing the Error

To solve this, we need to tell the compiler to use a special script called link.x. This script is provided by the cortex-m-rt crate and tells the compiler how to place code and data into memory.

Update the .cargo/config.toml with this:

[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]

This line adds a flag that tells the compiler: "Please use link.x to figure out the memory layout."

memory.x

According to the cortex-m-rt crate documentation, we need a file called memory.x to define the memory layout of the microcontroller. This file tells the linker where the flash and RAM start, and how large they are.

So... where is it?

If you try to flash the device at this stage itself, the program should get flashed and run on the micro:bit. That's because the memory.x file is automatically included from the nrf52833-hal crate. You can see it here: https://github.com/nrf-rs/nrf-hal/blob/master/nrf52833-hal/memory.x

However, it's better to define the memory.x file in our own project to avoid any kind of ambiguity. So in the project root folder, create a file named memory.x with the following content:

MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 512K
  RAM : ORIGIN = 0x20000000, LENGTH = 128K
}

This tells the linker where the flash and RAM start, and how much memory is available.

  • ORIGIN = 0x00000000 means the flash memory begins at address 0x00000000.
  • LENGTH = 512K means the flash memory size is 512 kilobytes (512 Γ— 1024 bytes).
  • ORIGIN = 0x20000000 means the RAM starts at address 0x20000000.
  • LENGTH = 128K means the RAM size is 128 kilobytes.

These addresses and sizes are based on the nRF52833 document.


At this point, your project folder should look like this:

β”œβ”€β”€ .cargo
β”‚   └── config.toml
β”œβ”€β”€ Cargo.toml
β”œβ”€β”€ memory.x
└── src
    └── main.rs

Flash it

Now try building and flashing your program:

cargo flash

# OR

cargo embed

This time, the ELF file will contain valid loadable segments, and the flasher will be able to write it into the micro:bit's flash memory.

If all goes well, your program should now be running on the micro:bit! You should see the blinking effect on the first LED.

Reference

Fun with LED

Phew... that was a long chapter, and it might have felt a bit overwhelming if you're just getting started. But now, let's take a break and have some fun with the LED matrix!

The BSP gives us a simple and beginner-friendly API. You can just define a 2D array that matches the actual layout of the LED matrix on the micro:bit. Use 1 to turn an LED on and 0 to turn it off. The BSP takes care of everything else behind the scenes.

This is an example LED matrix displaying a heart shape. I tried creating a Ferris (crab) shape, but it didn't quite look right, and I'd have to convince you it was a crab. So, it's better to just show a heart shape instead.

#![allow(unused)]
fn main() {
// LED matrix for heart shape
let led_matrix = [
    [0, 1, 0, 1, 0],
    [1, 1, 1, 1, 1],
    [1, 1, 1, 1, 1],
    [0, 1, 1, 1, 0],
    [0, 0, 1, 0, 0],
];
}

Display

The BSP provides a Display struct that you can initialize using the display_pins from the board. It offers a few useful functions, the most important ones being show and clear. The show function takes a 2D array and turns on the LEDs based on the values you provide. If you're curious about how it works under the hood, you can check out the source code here.

Create Project from template

We will no longer create .cargo/config.toml, memory.x, or manually add dependencies (only the basic dependencies) every time we start a new project. Instead, we will use a template-based approach to make project setup much easier.

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/ImplFerris/mb2-template.git

When prompted for a project name, enter something like led-matrix.

When it prompts to select "BSP" or "HAL", select "BSP".

Once the project is created, update src/main.rs with the following code.

The full code

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();

    let mut timer = Timer::new(board.TIMER0);

    let mut display = Display::new(board.display_pins);
    let led_matrix = [
        [0, 1, 0, 1, 0],
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
        [0, 1, 1, 1, 0],
        [0, 0, 1, 0, 0],
    ];
    loop {
        display.show(&mut timer, led_matrix, 1000);
        display.clear();
        timer.delay_ms(1000);
    }
}

Everything in this code is pretty straightforward and simple, except for one thing: what is the third argument to show function, the one where we pass 1000?

It is called duration_ms, but what is that duration used for? No, it is not for the blinking effect. We already handle blinking separately using:

#![allow(unused)]
fn main() {
display.clear();
timer.delay_ms(1000);
}

So what is duration_ms really doing?

As we learned earlier, the micro:bit's 5x5 LED matrix is multiplexed. Only one row is turned on at a time, and the columns are set based on the image. Internally show function goes through each row, lights up the correct LEDs, waits a bit using delay.delay_us(...), then moves to the next row.

This scanning happens so quickly that it looks like the whole image is being displayed at once. The duration_ms value tells the display how long to keep repeating this scan.

In short: duration_ms(third argument) controls how long the image stays on the screen. You can adjust its value and see the effect.

Clone the existing project

You can also clone (or refer) project I created and navigate to the led-matrix folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/led-matrix

Flash

You can flash the program into the micro:bit and should see the heart shape with blinking effect

cargo flash

Display Character

We were able to display a heart shape on the LED matrix. Now let's take it a step further and try showing some characters on it.

Let's start by displaying the character 'R'.

#![allow(unused)]
fn main() {
// Matrix for 'R'
[
    [1, 1, 1, 0, 0],
    [1, 0, 0, 1, 0],
    [1, 1, 1, 0, 0],
    [1, 0, 1, 0, 0],
    [1, 0, 0, 1, 0],
],
}

Create Project from template

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/ImplFerris/mb2-template.git

When prompted for a project name, enter something like led-char.

Once the project is created, update src/main.rs with the following code.

The full code

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();

    let mut timer = Timer::new(board.TIMER0);

    let mut display = Display::new(board.display_pins);
    let led_matrix = [
        [1, 1, 1, 0, 0],
        [1, 0, 0, 1, 0],
        [1, 1, 1, 0, 0],
        [1, 0, 1, 0, 0],
        [1, 0, 0, 1, 0],
    ];
    loop {
        display.show(&mut timer, led_matrix, 1000);
        display.clear();
        timer.delay_ms(1000);
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the led-char folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/led-char

Flash

You can flash the program into the micro:bit and should see the character

cargo flash

Scrolling Effect

In this section, we will create a scrolling effect for a single character. The character will scroll from right to left,disappear off the edge,and then reappear from the right again.

There is another BSP crate called microbit-bsp that provides built-in support for scrolling text. However, it uses the Embassy framework and asynchronous programming (async). Since we have not yet introduced Embassy or async concepts, we will avoid using that crate for now.

So for now, we will stick with the microbit-v2 crate and implement the scrolling logic ourselves.

Logic

You already know how to turn on the LED matrix using a 2D array. Now, try to think of a way to create a scrolling effect using that knowledge. There are multiple ways to do this. I encourage you to come up with your own logic first.

Below, I will show you one possible way to implement it. Keep in mind that this is just one approach, and it may not be the most efficient or elegant solution.

The Full code

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};
use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);

    // 5x5 representation of 'R'
    let r_char = [
        [1, 1, 1, 0, 0],
        [1, 0, 0, 1, 0],
        [1, 1, 1, 0, 0],
        [1, 0, 1, 0, 0],
        [1, 0, 0, 1, 0],
    ];

    let mut offset = 0;

    loop {
        let mut frame = [[0; 5]; 5];

        for row in 0..5 {
            for col in 0..5 {
                let char_col = col as isize + offset - 4;

                if char_col >= 0 && char_col < 5 {
                    frame[row][col] = r_char[row][char_col as usize];
                } else {
                    frame[row][col] = 0;
                }
            }
        }

        display.show(&mut timer, frame, 500);
        timer.delay_ms(100);

        offset += 1;
        if offset > 8 {
            offset = 0;
        }
    }
}

We use a variable called offset to control which part of the character we show on the screen. As offset increases, the character moves to the left.

Scrolling with Offset

Our LED matrix is 5 columns wide. To scroll the character in from the right, move it fully across, and have it scroll out to the left, we need to allow for more than 5 steps.

Let's break it down:

  • The character starts completely off-screen on the right.

  • Then, it enters one column at a time.

  • It becomes fully visible in the center.

  • Finally, it moves out one column at a time to the left until it disappears.

We want to cover this full path:

[off-screen right] --> [entering display] --> [fully visible] --> [leaving display] --> [off-screen left]

This takes a total of 9 steps (offsets from 0 to 8):

OffsetWhat happens on screenHow many columns of character are shown?Character's position relative to the display
0First column of the character appears at far right1Character is mostly off-screen to the right
1First two columns appear2Character slides in more
2First three columns appear3Half-visible
3First four columns appear4Almost fully visible
4Entire character is fully visible5Just as how it appears normally
5First column starts disappearing from the left4Character starts sliding off to the left
6Only middle and right side remain visible3More of character has exited
7Only last part of the character remains2Nearly gone
8Character is completely gone0Fully off-screen to the left

Creating the Frame

We create a new 5x5 frame that we will send to the display. For each LED in the frame:

We calculate which column of the character (r_char) should be shown in that position. This is done with:

#![allow(unused)]
fn main() {
let char_col = col as isize + offset - 4;
}

This shifts the character slowly to the left.

We check if char_col is in the valid range (0 to 4). If yes, we copy that pixel from the character. If not, we set it to 0 (LED off).

Loop and Animate

We keep repeating this in a loop:

  • Show the current frame for 500 ms then wait 100 ms

  • Increase the offset

  • Reset offset back to 0 after it reaches 9

This gives the illusion that the character is scrolling from right to left and disappearing, then reappearing again.

offset, led matrix "col", char_col relation

Let's look at how these values change as the offset increases to understand it better.

offset = 0

Only the first column of R (index 0) is visible at the rightmost column.

char_col = col + offset - 4 = col - 4

col01234
char_col-4-3-2-10
. . . . #
. . . . #
. . . . #
. . . . #
. . . . #

Note: Using 0s and 1s directly can make it harder to see the shape clearly. So, in this illustration, the # symbol shows where an LED is turned on (i.e value 1). The dots (.) represent LEDs that are off (value 0).

offset = 1

Columns 3 and 4 show character columns 0 and 1 respectively.

char_col = col + 1 - 4 = col - 3

col01234
char_col-3-2-101
. . . # #
. . . # .
. . . # #
. . . # .
. . . # .

offset = 4

We will skip to the case where the offset is 4. At this point, the full character is completely visible on the display.

char_col = col + 4 - 4 = col

This means char_col and col are equal, so the frame array directly matches the original character array.

col01234
char_col01234
# # # . .
# . . # .
# # # . .
# . # . .
# . . # .

offset = 5

char_col = col + 5 - 4 = col + 1

col01234
char_col12345
# # . . .
. . # . .
# # . . .
. # . . .
. . # . .

Now, each char_col is one more than its corresponding col. When char_col becomes 5, it goes out of bounds (since our array only has indices from 0 to 4).

To prevent this, we add a bounds check. If char_col is outside the valid range, we fill that column with 0s. This creates the vanishing effect as the character scrolls out.

if char_col >= 0 && char_col < 5 {
    frame[row][col] = r_char[row][char_col as usize];
} else {
    frame[row][col] = 0;
}

The Full code

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};
use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);

    // 5x5 representation of 'R'
    let r_char = [
        [1, 1, 1, 0, 0],
        [1, 0, 0, 1, 0],
        [1, 1, 1, 0, 0],
        [1, 0, 1, 0, 0],
        [1, 0, 0, 1, 0],
    ];

    let mut offset = 0;

    loop {
        let mut frame = [[0; 5]; 5];

        for row in 0..5 {
            for col in 0..5 {
                let char_col = col as isize + offset - 4;

                if char_col >= 0 && char_col < 5 {
                    frame[row][col] = r_char[row][char_col as usize];
                } else {
                    frame[row][col] = 0;
                }
            }
        }

        display.show(&mut timer, frame, 500);
        timer.delay_ms(100);

        offset += 1;
        if offset > 8 {
            offset = 0;
        }
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the led-scroll folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/led-scroll

Flash

You can flash the program into the micro:bit.

cargo flash

Smiley Buttons

In this chapter, we will explore how to use the two onboard buttons of the micro:bit. The Smiley Buttons project is a great beginner-friendly exercise that introduces interactive input. By pressing the built-in buttons A and B, we will display different facial expressions on the micro:bit's LED screen.

  • Press button A to show a happy face 😊

  • Press button B to show a sad face πŸ™

Understanding Buttons

πŸͺ From the micro:bit documentation:

Buttons operate in a typical inverted electrical mode, where a pull-up resistor ensures a logical β€˜1’ when the button is released, and a logical β€˜0’ when the button is pressed

This may sound a bit technical at first, so let us explain it more clearly.

When the button is not pressed, the micro:bit reads the input as a logical HIGH (i.e 1). This is due to the presence of a pull-up resistor, which maintains a high voltage level on the input pin.

When the button is pressed, the circuit is connected to ground, and the micro:bit reads a logical LOW (i.e 0).

microbit buttons

Although pressing a button might intuitively seem like activating something, the hardware works in an inverted way. In code, we check for a LOW signal to detect when the button is pressed. Therefore, we will use the is_low() method on the button inputs to check whether it is being pressed.

Create Project from template

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/ImplFerris/mb2-template.git

When prompted for a project name, enter something like smiley-buttons

When it prompts to select "BSP" or "HAL", select the option "BSP".

Matrix for emojis

Here is a 2D arrays representing the happy and sad faces.

#![allow(unused)]
fn main() {
let happy_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [1, 0, 0, 0, 1],
        [0, 1, 1, 1, 0],
    ];

    let sad_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [0, 1, 1, 1, 0],
        [1, 0, 0, 0, 1],
    ];
}

Initialization

As usual, we begin by initializing the board, followed by the display and the timer. We also access button_a and button_b from the board and store them in variables for convenience.

#![allow(unused)]
fn main() {
    let board = Board::take().unwrap();

    let mut display = Display::new(board.display_pins);
    let mut timer = Timer::new(board.TIMER0);

    let mut button_a = board.buttons.button_a;
    let mut button_b = board.buttons.button_b;
}

Button Input and Showing Smiley

Now that the buttons and display are initialized, we can write a loop that reacts to button input and shows the appropriate facial expression on the LED screen.

#![allow(unused)]
fn main() {
    loop {
        let a_pressed = button_a.is_low().unwrap_or(false);
        let b_pressed = button_b.is_low().unwrap_or(false);

        if a_pressed {
            display.show(&mut timer, happy_face, 1000);
            timer.delay_ms(100);
        } else if b_pressed {
            display.show(&mut timer, sad_face, 1000);
            timer.delay_ms(100);
        }
    }
}

In this loop, we check the state of each button using the .is_low() method. Since the buttons on the micro:bit are active-low, this method returns true when the button is pressed. We use .unwrap_or(false) to handle any potential errors. If the result cannot be read for any reason, it will simply return false, treating the button as unpressed.

When button A is pressed, the happy face pattern is shown on the LED display for one second. Similarly, if button B is pressed, the sad face is displayed.

A short delay of 100 milliseconds follows each display to give a clear visual effect and to avoid repeated updates caused by the button being held down.

The Full code

This exercise includes a new import not present in the previous one: embedded_hal::digital::InputPin. This trait is part of the Embedded HAL and provides methods such as is_low() and is_high() for reading the state of input pins.

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::InputPin};
use microbit::{display::blocking::Display, hal::Timer, Board};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();

    let mut display = Display::new(board.display_pins);
    let mut timer = Timer::new(board.TIMER0);

    let mut button_a = board.buttons.button_a;
    let mut button_b = board.buttons.button_b;

    let happy_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [1, 0, 0, 0, 1],
        [0, 1, 1, 1, 0],
    ];

    let sad_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [0, 1, 1, 1, 0],
        [1, 0, 0, 0, 1],
    ];

    loop {
        let a_pressed = button_a.is_low().unwrap_or(false);
        let b_pressed = button_b.is_low().unwrap_or(false);

        if a_pressed {
            display.show(&mut timer, happy_face, 1000);
            timer.delay_ms(100);
        } else if b_pressed {
            display.show(&mut timer, sad_face, 1000);
            timer.delay_ms(100);
        }
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the bsp/smiley-buttons folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/smiley-buttons

Flash

With the code complete, you can now flash the program to the micro:bit using the following command:

cargo flash

Once the program is running on the device, pressing button A will display the happy face, and pressing button B will show the sad face on the LED display.

Touch Sensing

The micro:bit V2 has built-in support for basic touch sensing. It allows you to detect when someone touches certain pins or the gold logo on the front of the board. Unlike a mechanical button, this feature works by detecting small changes in electrical charge when your finger comes close to the pin. This enables you to build interactive projects where a simple touch can trigger actions, just like tapping on a touchscreen.

Touch sensing on the micro:bit is possible because of special circuitry and software that detect changes in voltage when your body comes in contact with the pin.

How Touch Sensing Works

The micro:bit V2 uses capacitive touch sensing on specific GPIO pins (P0, P1, P2) and the logo (which is connected to the P1_04 GPIO pin, as shown in the micro:bit v2 pinmap). This is different from regular digital input, which typically involves pressing a mechanical switch.

Capacitive sensing relies on detecting changes in capacitance. Your body is a conductor and forms a capacitor with the micro:bit pin when you touch or come near it. The board monitors how long it takes for a pin to charge or discharge electrically, and when your finger is present, this time changes because of the additional capacitance from your body.

Weak Pull-Up Resistor

Touch sensing mode uses an internal weak pull-up resistor, typically 10 MΞ©, connected to the GPIO pin. This resistor pulls the pin up to the supply voltage (~3.0V), keeping the input in a logical HIGH state when not touched.

touch sensing

When you touch the pin (or the logo), your finger acts as a conductor and introduces a path to ground (through your body and the environment), slightly discharging the pin. This results in a detectable voltage drop that is read as a logical LOW.

Pin Configuration

When using touch sensing, you should configure the pin as a floating input. This mode allows the voltage on the pin to be affected by small currents (like those introduced by a human touch) because nothing else is driving the pin.

#![allow(unused)]
fn main() {
let mut touch_input = board.pins.p1_04.into_floating_input();
}

This disables the default pull-down resistor and allows the external capacitance to influence the pin voltage.

Detecting the Touch in Code

Once the pin is configured, you can use the is_low() to check the current voltage level:

#![allow(unused)]
fn main() {
if touch_input.is_low().unwrap() {
    // Pin is being touched
}
}

Simple Touch

Let's write a simple program that displays a character or emoji on the LED matrix when the micro:bit logo is touched. In this example, we will show a voltage emoji symbol (⚑) whenever the logo is touched.

microbit logo

Create Project from template

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/ImplFerris/mb2-template.git

When prompted for a project name, enter something like smiley-buttons

When it prompts to select "BSP" or "HAL", select the option "BSP".

Initialize Board, Timer, and Display

Start by setting up the board, timer, and display as usual:

#![allow(unused)]
fn main() {
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
}

LED Matrix for Voltage Symbol

#![allow(unused)]
fn main() {
let led_matrix = [
    [0, 0, 0, 1, 0],
    [0, 0, 1, 0, 0],
    [0, 1, 1, 1, 0],
    [0, 0, 1, 0, 0],
    [0, 1, 0, 0, 0],
];
}

Configure the Logo Pin as Touch Input

Set the GPIO pin p1_04 (which is internally connected to the small micro:bit logo) as a floating input. This allows it to detect touch using capacitive sensing.

When the logo is touched, the micro:bit will show a voltage symbol on the LED matrix for 500 milliseconds, then clear the display.

#![allow(unused)]
fn main() {
// Pin connected to the Logo
let mut touch_input = board.pins.p1_04.into_floating_input();

loop {
    if touch_input.is_low().unwrap() {
        display.show(&mut timer, led_matrix, 500);
    } else {
        display.clear();
    }
}
}

The Full Code

#![no_std]
#![no_main]

use embedded_hal::digital::InputPin;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);
    let led_matrix = [
        [0, 0, 0, 1, 0],
        [0, 0, 1, 0, 0],
        [0, 1, 1, 1, 0],
        [0, 0, 1, 0, 0],
        [0, 1, 0, 0, 0],
    ];

    // Pin connected to the Logo
    let mut touch_input = board.pins.p1_04.into_floating_input();

    loop {
        if touch_input.is_low().unwrap() {
            display.show(&mut timer, led_matrix, 500);
        } else {
            display.clear();
        }
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the bsp/logo-touch folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/logo-touch

Flash

With the code complete, you can now flash the program to the micro:bit using the following command:

cargo flash

Embassy

So far, we have worked with code that runs in blocking mode. This means that whenever we ask the program to do something like delay for a while or wait for a button press, the CPU stops and waits until that task is finished before continuing. This is simple to understand and works well for small programs, but it becomes limiting when we want to handle multiple tasks at the same time, like reading a sensor, and listening for input; all without blocking each other.

This is where Embassy comes in. Embassy is an async runtime designed for embedded systems. It allows us to write non-blocking code using Rust's async and await features. Instead of waiting and wasting CPU time, tasks can pause and let others run, making better use of the processor and enabling more responsive and power-efficient applications.

For example, with Embassy, we can blink an LED and listen for a touch or button input at the same time, without writing complex interrupt-based code manually.

HALs

Embassy provides async-ready Hardware Abstraction Layers (HALs) for several microcontroller families, offering safe and idiomatic Rust APIs so you can interact with hardware without dealing with low-level registers.

Official HALs include embassy-stm32 (STM32), embassy-nrf (nRF52/53/54/91), embassy-rp (RP2040), and embassy-mspm0 (TI MSPM0). Embassy also works with community HALs like esp-hal (ESP32), ch32-hal (CH32V), mpfs-hal (PolarFire), and py32-hal (Puya PY32), making it easy to write portable, async code across many platforms.

Batteries included

Embassy comes with many built-in features that make embedded development easier. For example, it includes embassy-time for handling timers and delays, embassy-net for networking support, and embassy-usb for building USB device functionality and much more.

Example Code Using Embassy (from the Official Website)

use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{AnyPin, Input, Level, Output, OutputDrive, Pin, Pull};
use embassy_nrf::Peripherals;
use embassy_time::{Duration, Timer};

// Declare async tasks
#[embassy_executor::task]
async fn blink(pin: AnyPin) {
    let mut led = Output::new(pin, Level::Low, OutputDrive::Standard);

    loop {
        // Timekeeping is globally available, no need to mess with hardware timers.
        led.set_high();
        Timer::after_millis(150).await;
        led.set_low();
        Timer::after_millis(150).await;
    }
}

// Main is itself an async task as well.
#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // Initialize the embassy-nrf HAL.
    let p = embassy_nrf::init(Default::default());

    // Spawned tasks run in the background, concurrently.
    spawner.spawn(blink(p.P0_13.degrade())).unwrap();

    let mut button = Input::new(p.P0_11, Pull::Up);
    loop {
        // Asynchronously wait for GPIO events, allowing other tasks
        // to run, or the core to sleep.
        button.wait_for_low().await;
        info!("Button pressed!");
        button.wait_for_high().await;
        info!("Button released!");
    }
}

Useful Resources

Microbit BSP crate that supports Embassy

So far, we have used the microbit-v2 BSP crate, which operates in blocking mode. Now, let me introduce you to another BSP crate that supports async programming with Embassy: microbit-bsp. In addition to Embassy integration, this crate includes some handy utilities such as the scroll function, which makes it easy to display scrolling text on the LED matrix.

Let's jump in and create a simple async program using this crate.

Embassy Project Template

Until now, we have been using a custom project template designed specifically for this book. Now, we will use the Embassy Project Template, created by Ulf Lilleengen. This template is designed for Embassy-based projects and includes support for a wide range of microcontrollers. In fact, it was created by the same person who maintains the microbit-bsp crate.

To generate a new project using this template, run:

#cargo generate --git https://github.com/lulf/embassy-template.git
cargo generate --git https://github.com/lulf/embassy-template.git -r f3179dc

Note: I have included the specific rev (revision) value in the cargo generate command to ensure the setup is reproducible. Without it, future changes to the template might break compatibility with this tutorial.

You will be prompted to enter a project name.

After that, you will be asked to select the target microcontroller (MCU). From the list, choose:

nrf52833

This will generate a new project set up with Embassy support for the nrf52833 chip (which is used in the micro:bit v2)

Now you can open the Cargo.toml file to see the dependencies included by the template. You will notice Embassy-related crates and async-supported libraries like embedded-hal-async already added for you.

Update Cargo.toml

We will use the microbit-bsp crate, open the Cargo.toml and add the following line:

# microbit-bsp = "0.3.0"
microbit-bsp = { git = "https://github.com/lulf/microbit-bsp", rev = "9c7d52e" }

The microbit-bsp crate version 0.3.0 (current version at the time of writing) is not compatible with the embassy project template and will cause dependency conflicts. However, the latest version on GitHub is compatible. To prevent future updates from breaking compatibility with this tutorial, we lock the dependency to a specific rev.

Prepare main.rs for BSP Code

Open the src/main.rs file and remove any existing code inside the main function. We will replace it with code that uses the BSP crate.

Initialization

We start by creating an instance of the Microbit struct, which gives us access to the board’s peripherals. Then, we retrieve the display from it:

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut display = board.display;
}

Adjusting Brightness

The BSP crate provides a nice function to control the brightness of the LED matrix. The brightness value can range from 0 (Brightness::MIN) to 10 (Brightness::MAX). You can experiment with different values to see how the LED brightness changes.

#![allow(unused)]
fn main() {
display.set_brightness(Brightness::new(5));
}

Scorlling Text

The BSP crate provides two functions to scroll text across the LED display. The scroll function automatically calculates the duration based on the text length, while scroll_with_speed gives us full control over the scrolling speed by letting you specify a Duration.

In our examples, we will use scroll_with_speed so that we can control how fast the text scrolls.

#![allow(unused)]
fn main() {
display.scroll_with_speed("EMBASSY", Duration::from_secs(10)).await;
}

Full Code

Inside the main loop, we continuously scroll the text "EMBASSY" across the display. After each scroll, we add a short delay using embassy_time::Timer before repeating.

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use microbit_bsp::Microbit;
use microbit_bsp::display::Brightness;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut display = board.display;

    display.set_brightness(Brightness::new(5));

    loop {
        display
            .scroll_with_speed("EMBASSY", Duration::from_secs(10))
            .await;
        Timer::after_secs(1).await;
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the bsp-embassy/led-scroll folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/led-scroll

Flash

You can flash the program into the micro:bit and see the scrolling text. Also, adjust the brightness value and observe.

cargo flash

Speaker

The micro:bit v2 comes with a built-in speaker, which means we can play sounds, tones, and even simple melodies without connecting any extra hardware. The speaker is wired internally to one of the GPIO pins, and by sending the right signals through software, we can make it produce different pitches.

Using the microbit-bsp API

The microbit-bsp crate makes it easy to use the speaker. It gives us a high-level API that handles all the low-level details for us. Behind the scenes, it uses something called PWM (Pulse-Width Modulation) to generate tones. Don't worry about what PWM is just yet; we'll get into that in a later chapter. For now, we'll just use the API.

Example Code

This example is taken from the official GitHub repository of the microbit-bsp crate. It plays a simple tune using the built-in speaker. The crate provides a helper enum like NamedPitch, where each variant represents a musical note. This makes it easy to define your own custom tunes using familiar note names.

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_time::Timer;
use microbit_bsp::{
    embassy_nrf::pwm::SimplePwm,
    speaker::{NamedPitch, Note, PwmSpeaker},
    Microbit,
};
use {defmt_rtt as _, panic_probe as _};

const TUNE: [(NamedPitch, u32); 18] = {
    #[allow(clippy::enum_glob_use)]
    use NamedPitch::*;
    [
        (D4, 1),
        (DS4, 1),
        (E4, 1),
        (C5, 2),
        (E4, 1),
        (C5, 2),
        (E4, 1),
        (C5, 3),
        (C4, 1),
        (D4, 1),
        (DS4, 1),
        (E4, 1),
        (C4, 1),
        (D4, 1),
        (E4, 2),
        (B4, 1),
        (D5, 2),
        (C4, 4),
    ]
};

#[embassy_executor::main]
async fn main(_s: Spawner) {
    let board = Microbit::default();
    defmt::info!("Application started!");
    let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));
    loop {
        defmt::info!("Playing tune!");
        for (pitch, ticks) in TUNE {
            speaker.play(&Note(pitch.into(), 200 * ticks)).await;
        }
        Timer::after_secs(5).await;
    }
}

Here, it loops through each pitch defined in the TUNE array and plays it for a given number of ticks. The duration is just ticks * 200 milliseconds. So if a note has ticks = 2, it plays for 400 ms.

Play a Tone

In this section, we will build a simple program that plays a sound when a button is pressed. To keep it interesting, we will use both buttons on the micro:bit board. When Button A is pressed, we will play a tone with the pitch A4. When Button B is pressed, the tone will stop.

You should already be familiar with how buttons work and how to detect button presses from the Buttons chapter. So in this section, we will not go into those details again.

Create Project from template

For this exercise also, we will be using the microbit-bsp crate with Embassy support.

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/lulf/embassy-template.git -r f3179dc

You will be prompted to enter a project name.

After that, you will be asked to select the target microcontroller (MCU). From the list, choose:

nrf52833

Update Cargo.toml

We will use the microbit-bsp crate. Open the Cargo.toml file and add the following lines:

# microbit-bsp = "0.3.0"
microbit-bsp = { git = "https://github.com/lulf/microbit-bsp", rev = "9c7d52e" }

Initialization

Let's, first initialize the board. From this board instance, we get access to the pwm0 peripheral and the built-in speaker. We pass both of these to SimplePwm, a helper struct from the embassy-nrf crate that sets up a basic PWM output.

Then, we take the SimplePwm instance and pass it to PwmSpeaker, a struct from the microbit-bsp crate. This will let us play tones by giving it a pitch and a duration.

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));
}

Buttons

Unlike the microbit-v2 crate, the microbit-bsp crate does not group the buttons into a separate buttons struct. Instead, you can access them directly using board.btn_a and board.btn_b, as shown below:

#![allow(unused)]
fn main() {
let mut button_a = board.btn_a;
let mut button_b = board.btn_b;
}

Wait...for...it...

Now we will use a loop to keep checking for button presses. Depending on which button is pressed, the playing state will change. If Button A is pressed (i.e. it goes low), it will start playing a tone. It will keep playing until Button B is pressed (i.e. it goes low).

We will use the wait_for_low() async function to pause the program until the button is pressed, without blocking or wasting CPU power.

#![allow(unused)]
fn main() {
loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));
        button_b.wait_for_low().await;
        speaker.stop();
    }
}

The Full code

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_nrf::pwm::SimplePwm;
use microbit_bsp::{
    Microbit,
    speaker::{NamedPitch, Pitch, PwmSpeaker},
};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();

    let mut button_a = board.btn_a;
    let mut button_b = board.btn_b;

    let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));

    loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));
        button_b.wait_for_low().await;
        speaker.stop();
    }

}

Clone the existing project

You can also clone (or refer) project I created and navigate to the bsp-embassy/play-tone folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/play-tone

Flash

You can flash the program into the micro:bit and should hear the tone

cargo flash

The Beauty of Embassy

In this section, we will extend the playing tone exercise by introducing a background task. This demonstrates the er of Embassy's async task model.

The main task will scroll the text "EMBASSY" continuously on the display. At the same time, a background task will wait for button presses. Depending on which button is pressed, it will either start or stop the tone playback, just like in the previous example.

While it is possible to achieve the same result without Embassy, doing so would require much more effort and complexity. Embassy simplifies embedded development.

You can modify the previous exercise or create project from scratch.

Create Project from template

For this exercise also, we will be using the microbit-bsp crate with Embassy support.

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/lulf/embassy-template.git -r f3179dc

You will be prompted to enter a project name.

After that, you will be asked to select the target microcontroller (MCU). From the list, choose:

nrf52833

Update Cargo.toml

We will use the microbit-bsp crate. Open the Cargo.toml file and add the following lines:

# microbit-bsp = "0.3.0"
microbit-bsp = { git = "https://github.com/lulf/microbit-bsp", rev = "9c7d52e" }

Initialization

As usual, we begin by initializing the board, the display, and the speaker.

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut display = board.display;
let speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker)); // We are not marking Speaker mut here
}

Using the Spawner Argument

Until now, we have not used the Spawner argument passed to the main function, nor have we discussed its purpose. In this section, we will start using it. So, remove the underscore from the argument name to make it usable.

// async fn main(_spawner: Spawner) -> ! {
// To:
async fn main(spawner: Spawner) -> ! {

The Spawner allows you to launch background tasks. We will use it to start the button_task, which we will define shortly.

Button Task

Now let's define the background task that will handle button presses. This is a simple async task marked with the #[embassy_executor::task] attribute. It takes ownership of both buttons and the speaker.

In the loop, we wait for Button A to be pressed (goes low), and when that happens, we start playing a note. Then we wait for Button B to be pressed, and when it is, we stop the note.

#![allow(unused)]

fn main() {
#[embassy_executor::task]
async fn button_task(
    mut button_a: Input<'static>,
    mut button_b: Input<'static>,
    mut speaker: PwmSpeaker<'static, PWM0>,
) {
    loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));

        button_b.wait_for_low().await;
        speaker.stop();
    }
}
}

Launch the Task

Now that we have defined the button_task, we can launch it from the main function using the Spawner.

#![allow(unused)]
fn main() {
 spawner
        .spawn(button_task(board.btn_a, board.btn_b, speaker))
        .unwrap();
}

That's it. With just this line, the button task starts running in the background, waiting for button presses while the main task continues doing its own work.

Main Task loop

The main task is simple. It keeps scrolling the text "EMBASSY" on the display in a loop. After each scroll, we add a small delay using a timer.

#![allow(unused)]
fn main() {
 loop {
        display
            .scroll_with_speed("EMBASSY", Duration::from_secs(10))
            .await;
        Timer::after_millis(300).await;
    }
}

While this loop runs continuously, the button task we spawned earlier runs in the background without blocking the main task. This is the beauty of async with Embassy.

The Full code

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_nrf::{gpio::Input, peripherals::PWM0, pwm::SimplePwm};
use embassy_time::{Duration, Timer};
use microbit_bsp::{
    Microbit,
    speaker::{NamedPitch, Pitch, PwmSpeaker},
};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut display = board.display;

    let speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));

    spawner
        .spawn(button_task(board.btn_a, board.btn_b, speaker))
        .unwrap();

    loop {
        display
            .scroll_with_speed("EMBASSY", Duration::from_secs(10))
            .await;
        Timer::after_millis(300).await;
    }
}

#[embassy_executor::task]
async fn button_task(
    mut button_a: Input<'static>,
    mut button_b: Input<'static>,
    mut speaker: PwmSpeaker<'static, PWM0>,
) {
    loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));

        button_b.wait_for_low().await;
        speaker.stop();
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the bsp-embassy/background-tasks folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/background-tasks

Flash

Now you can flash the program to the micro:bit:

cargo flash

Once flashed, the text "EMBASSY" will scroll continuously on the display. You can press the buttons to start or stop the tune, and it will work smoothly in the background without interrupting the scrolling.

Writing Rust Code to Play "Happy Birthday" on micro:bit v2

In this section, we will play the "Happy Birthday" tune on the microbit speaker.

The functions provided by the microbit-bsp crate is great for micro:bit, but I wanted something that can be used across different MCUs like ESP32 or Raspberry Pi Pico. So I made a separate crate that defines notes and durations more cleanly, using musical terms like Quarter, Half, etc. In the next section, we'll start using that crate to play tunes in a more reusable and portable way.

For this, we will use a crate called tinytones. It comes with the "Happy Birthday" tune built in, so we do not need to define the pitches and durations ourselves.

The crate also provides a Pitch enum and a Tone struct for defining your own melodies. It includes helper functions to convert musical durations like Quarter or Half into actual time values based on the tempo of the song.

Create Project from template

For this exercise also, we will be using the microbit-bsp crate with Embassy support.

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/lulf/embassy-template.git -r f3179dc

You will be prompted to enter a project name.

After that, you will be asked to select the target microcontroller (MCU). From the list, choose:

nrf52833

Update Cargo.toml

We will use the microbit-bsp crate, and also add tinytones. Open the Cargo.toml file and add the following lines:

# microbit-bsp = "0.3.0"
microbit-bsp = { git = "https://github.com/lulf/microbit-bsp", rev = "9c7d52e" }
tinytones = { version="0.1.0" }

Imports

These are the imports needed for this program. Open the main.rs file and update it with the following:

#![allow(unused)]
fn main() {
// Default that comes with the Template
use embassy_executor::Spawner;
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

// Additional Import
use embassy_nrf::pwm::SimplePwm;
use microbit_bsp::{
    Microbit,
    speaker::{Note, Pitch, PwmSpeaker},
};
use tinytones::{Tone, songs};

}

Initialization

Let's, first initialize the board. From this board instance, we get access to the pwm0 peripheral and the built-in speaker. We pass both of these to SimplePwm, a helper struct from the embassy-nrf crate that sets up a basic PWM output.

Then, we take the SimplePwm instance and pass it to PwmSpeaker, a struct from the microbit-bsp crate. This will let us play tones by giving it a pitch and a duration.

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));
}

Choose a Tune

The tinytones crate provides a set of built-in songs and tunes; You can browse the full list in the documentation.

In this example, we will be playing the "Happy Birthday" tune. To do that, we initialize a Tone struct by calling Tone::new. The first argument is the tempo (how fast or slow the song plays) of the song. You can either use the predefined tempo provided with the tune or specify your own (e.g: 150). The second argument is the melody, which is a list of notes (each with a pitch and duration).

#![allow(unused)]
fn main() {
let song = Tone::new(songs::happy_birthday::TEMPO, songs::happy_birthday::MELODY);
}

Play the Tune in a Loop

Once we have the song loaded, we can play it. The Tone provides an iter() method, which gives us an iterator over each note in the melody. Each note is a pair of (pitch, duration).

Inside the loop, we go through each note one by one. If the pitch is Rest, that means it's a silent pause. In that case, we just wait for the note's duration using Timer::after_millis.

For all other notes, we play the sound using the speaker.play() method. This takes a Note, which we create by converting the pitch into a frequency using pitch.freq_u32(), and then passing in the note's duration.

#![allow(unused)]
fn main() {
loop {
    for (pitch, note_duration) in song.iter() {
        if pitch == tinytones::note::Pitch::Rest {
            Timer::after_millis(note_duration).await;
            continue;
        }

        speaker
            .play(&Note(
                Pitch::Frequency(pitch.freq_u32()),
                note_duration as u32,
            ))
            .await;
    }
    Timer::after_secs(5).await;
}
}

After finishing the whole tune, we wait for 5 seconds before looping again and playing the song from the beginning.

The Full code

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_nrf::pwm::SimplePwm;
use embassy_time::Timer;
use microbit_bsp::{
    Microbit,
    speaker::{Note, Pitch, PwmSpeaker},
};
use tinytones::{Tone, songs};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));

    let song = Tone::new(songs::happy_birthday::TEMPO, songs::happy_birthday::MELODY);
    loop {
        for (pitch, note_duration) in song.iter() {
            if pitch == tinytones::note::Pitch::Rest {
                Timer::after_millis(note_duration).await;
                continue;
            }

            speaker
                .play(&Note(
                    Pitch::Frequency(pitch.freq_u32()),
                    note_duration as u32,
                ))
                .await;
        }
        Timer::after_secs(5).await;
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the bsp-embassy/play-song folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/play-song

Flash

You can flash the program into the micro:bit and should hear the melody

cargo flash

Custom Tune

//TODO

Temperature Sensor

A temperature sensor is an input device used to measure temperature. You can find them in many places around your home, such as in thermostats that control heating and cooling systems. Many temperature sensors are built into devices with a display, so you can directly see the temperature reading.

The micro:bit includes a temperature sensor inside its nRF52 processor. It measures the chip's internal temperature, which gives an approximation of the surrounding air temperature.

Note: The reading reflects the internal temperature of the chip; It is not a direct measurement of the ambient air temperature. However, it gives us an approximate idea of the surrounding temperature.

The sensor has an accuracy of around +/-5Β°C (uncalibrated) and can sense temperatures in the range of -40Β°C to 105Β°C.


We will use Embassy again, but this time without the BSP. Instead, we will work directly with the embassy-nrf HAL.

Create Project from template

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/lulf/embassy-template.git -r f3179dc

You will be prompted to enter a project name.

After that, you will be asked to select the target microcontroller (MCU). From the list, choose:

nrf52833

The Full code

This time, we will jump straight into the full code example. The reason is simple: the concepts involved here would take a fair amount of theory to explain first. So instead, we will start by running the code, and then we will break it down step by step.

In this example, we will use the "TEMP" peripheral (the temperature sensor) exposed by the embassy-nrf HAL. The HAL also provides a struct called "Temp" which allows us to interact with the temperature hardware.

But there's one more thing: we also need to set up something called an Interrupt Request Handler. Yes, this is a new concept we haven't discussed yet. If you have some experience with interrupt handlers, great. If not, don't worry; we will go over what it means and how it works in detail.

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

use embassy_nrf::{
    bind_interrupts,
    temp::{self, Temp},
};

bind_interrupts!(struct Irqs {
    TEMP => temp::InterruptHandler;
});

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let p = embassy_nrf::init(Default::default());
    let mut temp = Temp::new(p.TEMP, Irqs);

    loop {
        let value = temp.read().await;
        info!("temperature: {}℃", value.to_num::<u16>());
        Timer::after_secs(1).await;
    }
}

In the loop, we just read the temperature and print it using the "info!" macro from defmt. Once you flash this onto the micro:bit, it'll start printing temperature readings to your computer's console. Pretty cool, right? This is something we haven't really taken advantage of before. But yes, you can absolutely run a program on your board and see live logs on your computer!

Clone the existing project

You can also clone (or refer) project I created and navigate to the hal-embassy/temperature folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/hal-embassy/temperature

Run

You can flash the program into the micro:bit and should see the logs getting printed on your computer

cargo run

The temperature you see here won't be the exact room temperature. Like we mentioned earlier, this sensor measures the internal temperature of the chip, not the surrounding air. So if you want to get a more accurate room temperature, you'll need to calibrate it yourself. That means testing it in different environments, comparing it with a real thermometer, and then adjusting the value in your code based on that.

We can also buy and use external sensors, if we want more accurate temperature readings. We'll explore how to use them in the future chapters.

Interrupt Request (IRQ)

An Interrupt Request (IRQ) is a hardware signal triggered by a peripheral (e.g., sensor, timer). This signal immediately captures the CPU's attention, pausing its current tasks so it can handle the event through a specialized routine called an Interrupt Service Routine (ISR). Upon completion of the ISR, the CPU restores its previous context and resumes the original task.

Without IRQs, the CPU would need to continuously check (poll) each peripheral to see if something happened. This wastes time and energy, especially when most of the time, nothing is changing.

Why Use IRQ Instead of Polling?

Imagine this analogy: you are playing your favorite video game, fully focused and trying to defeat the final boss. At the same time, you are expecting a friend to visit.

Polling: You keep pausing the game every few seconds to check the door. It is distracting, inefficient, and ruins the game play.

IRQ (Doorbell): You install a doorbell. When your friend arrives, they press it. You hear the ring, quickly pause the game, open the door, then return to your game exactly where you left off.


In this section, we will not go into the full details or all the steps involved in defining your own ISR. That requires its own dedicated chapter. For now, we will just have a gentle introduction to the interrupt macro and handler support provided by the HAL.

Interrupt Handler for the TEMP Peripheral

The first question you might ask is: Why do we even need an interrupt handler here? Why are we talking about this now?

Well, the temperature sensor is just another peripheral. If we want to measure temperature, the CPU has to ask the sensor to start a measurement and then wait for the result. But the measurement takes a bit of time, and it's wasteful for the CPU to sit idle and keep checking if the value is ready (this is called polling). A better approach is to let the sensor interrupt us when it's ready.

Setting up interrupts involves multiple steps, but for our case we'll only focus on what's directly relevant.

First, we bound the TEMP interrupt to its handler

The embassy-nrf crate provides a macro called "bind_interrupts!" that helps us connect specific "Interrupt" to their corresponding handler.

The general usage looks like this:

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs{
    INTERRUPT_NAME => INTERRUPT_HANDLER;
    INTERRUPT_NAME2 => INTERRUPT_HANDLER2;
});
}

In our case, we bind the "TEMP" interrupt to handler "temp::InterruptHandler" which is also provided by the embassy-nrf.

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    TEMP => temp::InterruptHandler;
});
}

Here, we are basically telling: "Hey, if any interrupt comes from the TEMP peripheral, let temp::InterruptHandler take care of it."

Then, we initialized the Temp struct

We created the Temp driver by passing in the p.TEMP peripheral and the Irqs struct we just defined:

#![allow(unused)]
fn main() {
let mut temp = Temp::new(p.TEMP, Irqs);
}

You might be thinking - "Wait, that Irqs thing doesn't look like a unit struct". And you're right, it doesn't. But trust me, it is one. The macro we used earlier creates that unit struct. I will shortly show you what it looks like after the macro expands.

Finally, we read the temperature asynchronously

Now we just needed to read the temperature like this:

#![allow(unused)]
fn main() {
let value = temp.read().await;
}

From nRF52833 doc: TEMP is started by triggering the START task. When the temperature measurement is completed, a DATARDY event will be generated and the result of the measurement can be read from the TEMP register.

This function internally sends a request to the sensor to start the temperature measurement. Then it enables the interrupt so that the sensor could notify us when the data is ready.

It waits for the result asynchronously. When the sensor finished the measurement, it triggers an interrupt. That interrupt is handled by temp::InterruptHandler, which woke up the read() function so it could go ahead, read the temperature value.


Temperature Interrupt Handler

If we look at the definition of the temp::InterruptHandler struct, we can see that it implements the Handler trait. The main part is the "on_interrupt" function, which tells what should happen when the TEMP interrupt is received.

Like we said earlier, it does not do much. It simply informs Embassy to wake up the read() function that was waiting for the sensor.

#![allow(unused)]
fn main() {
impl interrupt::typelevel::Handler<interrupt::typelevel::TEMP> for InterruptHandler {
    unsafe fn on_interrupt() {
        let r = pac::TEMP;
        r.intenclr().write(|w| w.set_datardy(true));
        WAKER.wake();
    }
}
}

This function first disables the interrupt so it does not fire again. Then it calls WAKER.wake() to resume the async task that was waiting for the temperature value. That allows the read() function to continue and read the result

Expanding Macro

Let's go back to the "bind_interrupts!" macro we used earlier:

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    TEMP => temp::InterruptHandler;
});
}

At first glance, the syntax might look strange-especially the struct part. It may seem like we are trying to instantiate a struct, but that's not the case. This is just macro input. The macro internally creates a unit struct named "Irqs" and implements the necessary interrupt binding traits for it.

Unit struct is an empty struct with no fields; most commonly used as marker. And they have a size of zero bytes.

If you are curious, you can use "cargo expand" to see the generated code. It will look something like this:

#![allow(unused)]
fn main() {
use embassy_nrf::interrupt::typelevel;

struct Irqs;

// Ignoring this: ... impl Copy and Clone for Irqs

#[allow(non_snake_case)]
#[no_mangle]
unsafe extern "C" fn TEMP() {
    <temp::InterruptHandler as typelevel::Handler<typelevel::TEMP,>>::on_interrupt();
}

unsafe impl typelevel::Binding<typelevel::TEMP, temp::InterruptHandler,> for Irqs {
}
}

It creates a unit struct named "Irqs". It also creates a function called TEMP(), which is the actual interrupt handler that the hardware will call when the TEMP interrupt happens. Inside that function, it calls on_interrupt() from temp::InterruptHandler. After that, it implements the Binding trait for the Irqs struct. This tells Embassy that the TEMP interrupt is connected to our handler.

Marker

If you are wondering why we implement the Binding trait for the Irqs struct when we are not even defining any function inside it, we need to look at the Temp::new function to understand why.

#![allow(unused)]
fn main() {
// let mut temp = Temp::new(p.TEMP, Irqs);

pub fn new(
    _peri: impl Peripheral<P = TEMP> + 'd,
    _irq: impl interrupt::typelevel::Binding<interrupt::typelevel::TEMP, InterruptHandler> + 'd,
) -> Self {
    into_ref!(_peri);

    // Enable interrupt that signals temperature values
    interrupt::TEMP.unpend();
    unsafe { interrupt::TEMP.enable() };

    Self { _peri }
}
}

Wait a minute, the second argument "_irq" is never used. So what is the point of it?

Even though it is not used directly, its type is important. This is how Embassy checks that we have set up a handler for the TEMP interrupt. The type of _irq must implement the Binding trait for TEMP. If it doesn't, the code will not compile.

For example, if you write this:

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    TWISPI0 => twis::InterruptHandler<peripherals::TWISPI0>;
});
}

The macro will generate a Binding trait for the TWISPI0 interrupt, like this:

#![allow(unused)]
fn main() {
// I'm commenting this to keep the Focus on the Binding trait to understand the marker
// #[allow(non_snake_case)]
// #[no_mangle]
// unsafe extern "C" fn TWISPI0() {
//     <twis::InterruptHandler<
//         peripherals::TWISPI0,
//     > as typelevel::Handler<
//         typelevel::TWISPI0,
//     >>::on_interrupt();
// }

unsafe impl typelevel::Binding<typelevel::TWISPI0,twis::InterruptHandler<peripherals::TWISPI0>,> for Irqs {

}
}

But this does not match what Temp::new expects. It expects a Binding for the TEMP interrupt. So the compiler will give an error.

Introduction to nRF HAL

Before we move on to other examples, let us first introduce the Hardware Abstraction Layer (HAL) for the nRF51, nRF52, and nRF91 families of microcontrollers.

As you may already know, the micro:bit v2 uses the Nordic nRF52833 microcontroller.

Until now, we have worked with BSP-level crates. Now, we will go one layer deeper into the HAL. For this purpose, we will use the nrf-hal, which provides support for the nRF52833 as well.

You can refer to the nrf-hal GitHub repository for more details. It also includes examples for various use cases.

Rewrite Blinky

To keep things simple, let us rewrite the blinky example using nrf-hal.

If you are creating a project from scratch, you would typically add nrf52833-hal as a dependency manually. However, we will use our template that already includes it. In the template's Cargo.toml, you will find a line like this:

nrf52833-hal = "0.18.0" # Version might be different in the template

Create Project from template

To generate a new project using the template, run the following command:

cargo generate --git https://github.com/ImplFerris/mb2-template.git

When prompted for a project name, enter something like led-blinky (If you already have a project with this name, use a different name or place HAL-based projects in a separate folder, like I do.)

When it prompts to select "BSP" or "HAL", select the option "HAL".

Peripherals

In embedded systems, peripherals are hardware components that extend the capabilities of a microcontroller (MCU). They allow the MCU to interact with the outside world by handling inputs and outputs, communication, timing, and more.

While the CPU is responsible for executing program logic, peripherals do the heavy lifting of interacting with hardware, often offloading work from the CPU. This allows the CPU to focus on critical tasks while peripherals handle specialized functions independently or with minimal supervision.

Offloading

Offloading refers to the practice of delegating certain tasks to hardware peripherals instead of doing them directly in software via the CPU. This improves performance, reduces power consumption, and enables concurrent operations. For example:

  • A UART peripheral can send and receive data in the background using DMA (Direct Memory Access), while the CPU continues processing other logic.
  • A Timer can be configured to generate precise delays or periodic interrupts without CPU intervention.
  • A PWM controller can drive a motor continuously without the CPU constantly toggling pins.

Offloading is a key design strategy in embedded systems to make efficient use of limited processing power.

Common Types of Peripherals

Here are some of the most common types of peripherals found in embedded systems:

PeripheralDescription
GPIO (General Purpose Input/Output)Digital pins that can be configured as inputs or outputs to interact with external hardware like buttons, LEDs, and sensors.
UART (Universal Asynchronous Receiver/Transmitter)Serial communication interface used for sending and receiving data between devices, often used for debugging and connecting modules like Bluetooth.
SPI (Serial Peripheral Interface)High-speed synchronous communication protocol used to connect microcontrollers to peripherals like SD cards, displays, and sensors using a master-slave architecture.
I2C (Inter-Integrated Circuit)Two-wire serial communication protocol used for connecting low-speed peripherals such as sensors and memory chips to a microcontroller.
ADC (Analog-to-Digital Converter)Converts analog signals from sensors or other sources into digital values that the microcontroller can process.
PWM (Pulse Width Modulation)Generates signals that can control power delivery, used commonly for LED dimming, motor speed control, and servo actuation.
TimerUsed for generating delays, measuring time intervals, counting events, or triggering actions at specific times.
RTC (Real-Time Clock)Keeps track of current time and date even when the system is powered off, typically backed by a battery.

Peripherals in Rust

In embedded Rust, peripherals are accessed using a singleton model. One of Rust's core goals is safety, and that extends to how it manages hardware access. To ensure that no two parts of a program can accidentally control the same peripheral at the same time, Rust enforces exclusive ownership through this singleton approach.

The Singleton Pattern

The singleton pattern ensures that only one instance of each peripheral exists in the entire program. This avoids common bugs caused by multiple pieces of code trying to modify the same hardware resource simultaneously.

We have already encountered this pattern in the BSP crate, where the entire board (including all peripherals, pins, and configuration) is wrapped as a singleton.

In the nRF hal, the peripherals are also exposed as a singleton object:

Modify the Code

Let's now modify the src/main.rs file to create an instance of the microcontroller peripherals using Rust's singleton model.

Step 1: Import the Peripherals

First, open src/main.rs and add the necessary import:

#![allow(unused)]
fn main() {
use nrf52833_hal::pac::Peripherals;
}

Step 2: Take Ownership of the Peripherals

Inside the main function, add the following line:

#![allow(unused)]
fn main() {
let peripherals = Peripherals::take().unwrap();
}

This line does the following:

  • Takes ownership of the device's peripherals.
  • Returns an instance of the Peripherals struct, which contains access to all hardware blocks like GPIO and others.
  • Can only be called once during the program's lifetime. If called again, it will return None.

GPIO Pins

To turn on the first LED, we had to set the first column to LOW and then set the first row to HIGH. This would complete the circuit and light up the LED.

When we were using the BSP crate, it was pretty straightforward. The BSP gave us names like row1, row2 or col1, col2 so we could easily use them in code. We could also use a 2D array to represent the whole LED matrix.

But now when using the HAL, we work directly with GPIO pins. Instead of row1 or col1, we use pin names like p0_28, p0_21, etc. These are the actual pins on the microcontroller that we configure as inputs or outputs.

What is GPIO?

GPIO stands for General Purpose Input Output. These are pins on a microcontroller that we can control from our code.

Each GPIO pin can be:

  • An output : We can set it to HIGH (like power) or LOW (like ground)
  • An input : We can read if it is HIGH or LOW from a sensor or button

Think of GPIO pins like switches that we control from our code. When we set a pin to HIGH, it acts like a power source. When we set it to LOW, it acts like a path to ground.

By setting some pins HIGH and others LOW, we can control LEDs, buttons, sensors, and more.

How to Know Which GPIO to Use?

To figure out which GPIO pins to use, you usually check the datasheet or technical reference manual for the microcontroller or board you are working with. You can also refer the Pinmap in the micro:bit documentation.

To make things easier, here is a table that shows how each row and column in the LED matrix connects to the actual GPIO pins on the nRF52833 microcontroller.

Matrix RoleRolePortPin
ROW1Sourcep021
ROW2Sourcep022
ROW3Sourcep015
ROW4Sourcep024
ROW5Sourcep019
COL1Sinkp028
COL2Sinkp011
COL3Sinkp031
COL4Sinkp105
COL5Sinkp030

The Pin column refers to the GPIO pin number. Port is the term we have not introduced yet. It is just a group of GPIO pins that are managed together by the microcontroller. Microcontrollers often organize their GPIO pins into ports , such as Port 0 (p0) or Port 1 (p1) . Each port can control multiple pins.

Our goal is to turn on the first LED, which is in the first row and first column. For the first row, the GPIO pin is p0_21 . For the first column, the GPIO pin is p0_28 .

Modify the Code

Now, let's modify the src/main.rs file to initialize the GPIO port and the specific pins we need.

First, add the necessary import:

#![allow(unused)]
fn main() {
use nrf52833_hal::gpio::Level; 
use nrf52833_hal as hal;
}

Here, Level is an enum that represents the logic level of a pin: either Level::High (for a logical high voltage) or Level::Low (for a logical low voltage).

We also import the nrf52833_hal crate using the alias hal. This makes it easier to refer to its modules and types throughout the code without repeatedly writing the full crate name.

Inside the main function, add the following code:

#![allow(unused)]
fn main() {
let port0 = hal::gpio::p0::Parts::new(peripherals.P0);
let _col1 = port0.p0_28.into_push_pull_output(Level::Low);
let mut row1 = port0.p0_21.into_push_pull_output(Level::High);
}

Here's what this does:

  • port0 gives us access to the individual pins in Port 0.
  • _col1 is set as a push-pull output and driven LOW, which means the pin is connected to ground. This "activates" the column by allowing current to flow into it.
  • row1 is also set as a push-pull output, but driven HIGH, which means the pin is connected to the power supply (like 3.3V). This "selects" the row by providing the source of current.

What is a Push-Pull Output?

A push-pull output is a common electrical configuration for GPIO pins on microcontrollers. In this mode, the pin can actively drive the output both high and low:

  • When set high, the pin is connected to the supply voltage (typically 3.3V or 5V), allowing it to source current to connected components.
  • When set low, the pin is connected to ground, allowing it to sink current.

This means the pin is never left floating and always has a definite voltage level. It is ideal for driving digital outputs like LEDs, relays, or for controlling logic signals.

Unlike open-drain or open-collector outputs, which can only pull the line low and require an external pull-up resistor to go high, push-pull does both, making it simple and reliable for general-purpose output.

Timers in Embedded Systems

In embedded systems, a timer is a special hardware peripheral that counts clock cycles. It helps you measure time or trigger things after a delay. Unlike a simple loop that wastes CPU time, a timer keeps counting in hardware, so the CPU can do other work or even go to sleep.

You can use timers to:

  • Blink LEDs at fixed intervals
  • Measure how long an operation takes
  • Trigger periodic tasks
  • Generate precise delays
  • Control PWM signals for motors or LEDs

Timers are one of the most useful peripherals in any microcontroller. Most microcontrollers have multiple hardware timers: TIMER0, TIMER1, etc. Each one works independently.

Timers on the nRF52833

The nRF52833 microcontroller includes five 32-bit timers with counter mode. We are not going more in-depth into the inner working of the time for now.

We will use the timer to introduce a delay between turning the LED on and off, to create a blinking effect.

Modify the code

Now let's update the src/main.rs file to initialize the timer peripheral.

First, add the necessary import:

#![allow(unused)]
fn main() {
use nrf52833_hal::timer::Timer;
}

Inside the main function, add the following line:

#![allow(unused)]
fn main() {
let mut timer0 = Timer::new(peripherals.TIMER0);
}

This line initializes hardware timer TIMER0 using the HAL's Timer abstraction. Later, we will use this instance to create delays between turning the LED on and off.

Putting It All Together: Using a nrf-hal to Blink an LED

After setting up the peripherals, initializing the timer, and configuring the required GPIO pin as output, the main loop becomes straightforward. It closely resembles what we did in the BSP example.

In the loop, we toggle the state of row1 between high and low; with a delay from the timer. This creates the blinking effect.

#![allow(unused)]
fn main() {
    loop {
        timer0.delay_ms(500);
        row1.set_high().unwrap();

        timer0.delay_ms(500);
        row1.set_low().unwrap();
    }
}

Full code

#![no_std]
#![no_main]

use cortex_m_rt::entry;

// Embedded HAL traits
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;

// nRF HAL
use nrf52833_hal::gpio::Level;
use nrf52833_hal::pac::Peripherals;
use nrf52833_hal::{self as hal, timer::Timer};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let peripherals = Peripherals::take().unwrap();
    let mut timer0 = Timer::new(peripherals.TIMER0);

    let port0 = hal::gpio::p0::Parts::new(peripherals.P0);
    let _col1 = port0.p0_28.into_push_pull_output(Level::Low);
    let mut row1 = port0.p0_21.into_push_pull_output(Level::High);

    loop {
        timer0.delay_ms(500);
        row1.set_high().unwrap();

        timer0.delay_ms(500);
        row1.set_low().unwrap();
    }
}

Clone the existing project

You can also clone (or refer) project I created and navigate to the hal/led-blinky folder.

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/hal/led-blinky

Flash

You can flash the program into the micro:bit and should see the blinking effect

cargo flash