Rust on XIAO nRF52840 with Embassy · Part 2 of 2

Blinky

Let’s start with the embedded equivalent of hello world: blinky.

In this first programming tutorial of Rust on XIAO nRF52840 with Embassy, we’ll cycle the board’s onboard RGB LED through red, green, and blue, keeping each color on for one second. It’s a small example, but it introduces the pieces we’ll keep using: initializing peripherals, controlling GPIO pins, logging, and waiting with an async timer.

We’re using the Seeed Studio XIAO nRF52840, also known as the XIAO BLE, and Embassy, an embedded Rust framework built around async and .await. No external LEDs or resistors are needed for this example.

Before you begin

Complete Installation and setup first if you haven’t configured Rust, the ARM target, probe-rs, and your SWD debug probe. That guide also covers cloning the repository.

From here on, we’ll focus on the example in crates/01-blinky, following the video. Open crates/01-blinky/src/main.rs to follow along.

Know your LED pins

Before writing code, we need to map the board’s LEDs to the microcontroller’s GPIO pins. The Seeed pin mapping gives us:

LED colornRF52840 GPIOEmbassy pin
RedP0.26p.P0_26
GreenP0.30p.P0_30
BlueP0.06p.P0_06

These are chip GPIO identifiers, not the board’s numbered edge pins. For example, P0_26 means port 0, pin 26.

There’s one more important detail: the onboard RGB LED is active-low.

  • Set a pin low to turn that color on.
  • Set a pin high to turn that color off.

Keep that in mind when reading the code. On this board, high does not mean illuminated.

Start without the standard library

At the top of crates/01-blinky/src/main.rs, we have:

#![no_std]
#![no_main]

#![no_std] tells Rust not to link the standard library. We’re running directly on a microcontroller, without an operating system providing facilities such as files or OS threads. We still have Rust’s core library, including types such as Option and Result.

#![no_main] opts out of Rust’s usual entry point. We’ll use Embassy’s entry-point macro instead.

Next, bring in the pieces we need:

use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{Level, Output, OutputDrive};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

Here’s what each one does:

  • info! emits compact log messages using defmt.
  • Spawner lets us start Embassy tasks. We won’t spawn any additional tasks yet, but Embassy’s main takes it as an argument.
  • Output, Level, and OutputDrive configure and control GPIO outputs.
  • Timer lets us wait asynchronously.
  • defmt_rtt provides the RTT logging transport, and panic_probe provides a panic handler. The underscore imports link these crates without introducing names we’ll use directly.

RTT carries logs through the debug probe to the terminal. It serves a similar purpose to Arduino’s serial monitor, but this example isn’t sending text over USB serial.

Initialize the board and LEDs

Our entry point looks like this:

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_nrf::init(Default::default());
    // The LED setup and loop go here.
}

The macro sets up Embassy’s executor to run our async main task. We prefix spawner with an underscore because we aren’t using it in this example.

embassy_nrf::init(Default::default()) initializes the chip using the default configuration and gives us access to its peripherals. For this example, we don’t need a custom configuration.

Inside main, configure each LED pin as an output:

let mut red = Output::new(p.P0_26, Level::High, OutputDrive::Standard);
let mut green = Output::new(p.P0_30, Level::High, OutputDrive::Standard);
let mut blue = Output::new(p.P0_06, Level::High, OutputDrive::Standard);

Each call takes a pin, an initial level, and an output-drive setting. We use the standard drive setting and start all three pins high, so all three colors start off.

Cycle through the colors

To show red, set red low and the other two colors high:

red.set_low();
green.set_high();
blue.set_high();
Timer::after_millis(1000).await;

Then do the same for green and blue, inside an infinite loop.

The timer is the interesting part. Instead of keeping the CPU busy counting down a delay, Timer::after_millis(1000).await suspends this task until the delay has elapsed. While it waits, the executor can run other ready tasks, or idle when there’s no work to do. The nRF timer driver uses hardware to arrange a wake-up.

We only have one task here, so the visible result is simply a one-second pause. Later, this same pattern will let one task wait without holding up another. It also allows the core to sleep while idle, though actual board power consumption depends on the peripherals, LEDs, and debug setup too.

The complete program

Here’s the full example from the repository:

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{Level, Output, OutputDrive};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_nrf::init(Default::default());

    // Initialize RGB LED pins (active low)
    let mut red = Output::new(p.P0_26, Level::High, OutputDrive::Standard);
    let mut green = Output::new(p.P0_30, Level::High, OutputDrive::Standard);
    let mut blue = Output::new(p.P0_06, Level::High, OutputDrive::Standard);

    info!("Starting RGB LED pattern...");

    loop {
        info!("Simple RGB cycle");

        // Red on
        red.set_low();
        green.set_high();
        blue.set_high();
        Timer::after_millis(1000).await;

        // Green on
        red.set_high();
        green.set_low();
        blue.set_high();
        Timer::after_millis(1000).await;

        // Blue on
        red.set_high();
        green.set_high();
        blue.set_low();
        Timer::after_millis(1000).await;
    }
}

The first log message runs once, after initialization. Simple RGB cycle is logged at the start of every loop, roughly once every three seconds.

Flash and run

With the board powered and the debug probe connected, run this from the repository root:

cargo run --bin blinky

--bin blinky selects the binary named blinky from the workspace. It’s the binary name, not the directory name 01-blinky. Being explicit avoids ambiguity when the workspace contains multiple examples.

Cargo builds the firmware, then the configured probe-rs runner flashes it, starts execution, and displays the RTT logs. You should see the LED repeat this pattern:

Red → green → blue → red → …

Each color stays on for about one second. The log messages should look like this, with timestamps and other formatting supplied by the logging tools:

Starting RGB LED pattern...
Simple RGB cycle
Simple RGB cycle
...

If probe-rs can’t find a probe, check the debug probe connection first; plugging only the XIAO into USB isn’t enough for this runner. If the probe is found but it can’t connect to the chip, check target power and the SWD wiring. If the code runs but the colors aren’t what you expect, revisit the pin mapping and active-low logic.

Try a small change

Change the delays to 250 milliseconds, or reverse the color order. To add a dark pause between cycles, set all three outputs high and await another timer at the end of the loop.

That’s our first Embassy program: three GPIO outputs, an async timer, and logs that let us follow execution. Next, we’ll move from outputs to inputs and read a button, before exploring how to wait for input changes asynchronously.