Skip to main content

Command Palette

Search for a command to run...

Embedded Rust BSPs with uFerris & Xiao: LED & Button Support with GPIO

Updated
10 min readView as Markdown
Embedded Rust BSPs with uFerris & Xiao: LED & Button Support with GPIO
O
I am an 📟 Embedded Engineer with years of experience in both industry 🏭 and academia 🏫. Passionate Mentor 👨‍💼 and Instructor 👨‍🏫. Rustacean 🦀

This is the fourth post in the µFerris & Xiao BSP series, where we build a Board Support Package from scratch, one peripheral at a time. In the previous post, we set up the template. This post is the first one that starts populating functions.

Series Past Posts

  1. Device Setup & Getting Started

  2. Getting started with µferris-bsp

  3. Intro to BSPs

Introduction

In the last post, we created a template for the BSP that compiles but does nothing. We created an empty struct for the board; UFerrisand a single initialization method uferris_init that takes the peripherals and throws them away. That was on purpose to give shape to what we are going to work with.

This post adds in the first two GPIO devices, the two simplest on the board. LED 1 hangs off GPIO3 and SW5 off GPIO5. Neither needs a bus, a clock, or a driver. This makes them the right place to establish the changes that every later post would repeat: add the peripheral to the board struct, configure it in the init function, then expose it through a control function.

Let's get started.

📚 Knowledge Prerequisites

To understand the content of this post, you need the following:

  • Basic knowledge of coding in Rust.

  • Basic understanding of embedded systems development concepts.

  • Familiarity with no_std development in Rust, preferably using esp-hal.

  • Familiarity with the µFerris platform and flashing a XIAO module.

💾 Software Setup

All the code presented in this post is available in the µFerris & XIAO series repo. Every post in the series has its own self-contained Cargo project in a numbered folder. The code for this post lives in 04-led-button-gpio/:

git clone https://github.com/theembeddedrustacean/learn-bsp-rs
cd learn-bsp-rs/04-led-button-gpio
cargo run --release

The examples revolve around the ESP32-C3. If you want to follow along with a different device, please refer to the xiao-generate post for more detail.

🛠 Hardware Setup

The required hardware includes:

  • Seeed Studio XIAO ESP32-C3 — the controller used throughout this phase of the series. Available from the SeeedStudio Store.
  • USB-C cable for power, flashing, and serial output.

🔌 Connections

No connections are required. Every component the BSP will eventually drive is prewired; you only need to assemble the board. If you haven't assembled the board yet, this post walks through it.

The two devices this post covers are connected on the µFerris as follows:

Device XIAO Pin Polarity
LED 1 GPIO3 Active high. Driving the pin high lights it up.
SW5 GPIO5 Active low. Pressing pulls the pin to ground (0V).

👨‍🎨 Software Design

This post builds on the BSP template from the previous post. This post assumes you understand the base template and its abstractions. Recall the BSP code is composed of three parts; The Board struct, The Board Initialization Function, and The Board Control Functions.

The Board Struct

Remember UFerris was empty. Also recall that the board struct should represent the board. This struct becomes the access point for methods that control the board. As such, the board struct members represent components on the board, capture the type for each component, and allow us to perform actions on them. This is the form of the updated struct:

pub struct UFerris {
    led1: Output<'static>,
    sw_btn5: Input<'static>,
}

Output and Input are GPIO types derived from esp-hal::gpio. Notice the following:

The board struct owns the pins. Once a GPIO pin has been turned into an Output or Input and moved into the struct, no other part of the program can reconfigure it or drive it.

The lifetime is 'static. Output<'d> & Input<'d> borrow the pins they drive. Since the peripherals singleton lives for the whole program, the pins taken out of it are 'static, and the board struct can be stored and passed around without dragging a lifetime parameter through every function that touches it.

The Board Initialization Function

We will update uferris_init to take the two pins out of Peripherals and configure each one as follows:

  • LED 1 as an Output starting at Level::Low. Because the LED is active high, low means off.

  • SW5 as an Input. We won't configure any pulls since the board has an external pull-up.

Everything else about the two pins is left at the defaults. A BSP should configure what the board demands and no more.

The Board Control Functions

We'll introduce three functions that the BSP will support:

Function Behaviour
led1_on() Drives GPIO3 high when called, lighting LED 1.
led1_off() Drives GPIO3 low when called.
sw5_pressed() Reads SW5 line when called. true while SW5 is pressed.

The names match the shipping uferris-bsp crate, which is where this series ends up. Also note how sw5_pressed returns true for pressed, which is the inverse of what the pin reads. Optionally, you can also add a led1_toggle function. That is left for the reader as an exercise.

Test Application Design

The test application steps are as follows:

  1. Poll sw5_pressed() in a loop.

  2. Mirror the result on LED 1; On while the button is held, off when it is released.

  3. Print the state over the serial port only when the state changes.

  4. Wait 20 ms (allows button to settle from bouncing).

  5. Go back to step 1.

👨‍💻 Code Implementation

1️⃣ Bring in the GPIO types. The template only needed Peripherals. Driving a pin needs the input and output types and their configuration structs:

use esp_hal::{
    gpio::{Input, InputConfig, Level, Output, OutputConfig},
    peripherals::Peripherals,
};

2️⃣ Populate the board struct. Add the components and their types as members to the board struct.

/// A handle to the µFerris board and everything on it.
pub struct UFerris {
    led1: Output<'static>,
    sw_btn5: Input<'static>,
}

3️⃣ Populate the initialization function. In the init function, we need to now configure both LED1 and SW5 pins, as Output and Input, respectively. Below is the code:

pub fn uferris_init(peripherals: Peripherals) -> UFerris {

    let led1 = Output::new(peripherals.GPIO3, Level::Low, OutputConfig::default());

    let sw_btn5 = Input::new(peripherals.GPIO5, InputConfig::default());

    UFerris { led1, sw_btn5 }
}

Note the configuration passed for each pin, as defined earlier.

4️⃣ Add the control functions. Now that the pins are added to the struct and configured, we can access the struct members and call the type methods. In an impl block on UFerris, we create board methods that call those type methods as follows:

impl UFerris {
    /// Turn LED 1 on.
    pub fn led1_on(&mut self) {
        self.led1.set_high();
    }

    /// Turn LED 1 off.
    pub fn led1_off(&mut self) {
        self.led1.set_low();
    }

    /// Read switch 5.
    pub fn sw5_pressed(&mut self) -> bool {
        self.sw_btn5.is_low()
    }
}

Note the difference between the board-level and the controller-level methods. Here is where the abstraction wrapping happens. The BSP method defined in the UFerris impl block has a method name that represents board behavior. Under the hood, it calls a HAL-level method that corresponds to that board behavior.

🏃‍♂️ Test Code & Run

The application never touches a pin. It initializes the board and then talks to it in the board's own vocabulary:

#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::uferris_init;

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // Configure the device and hand the peripherals to the BSP
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);
    let mut uferris = uferris_init(peripherals);

    // Variable to keep track of the last state of the button.
    let mut was_pressed = false;

    // Instantiate a delay provider
    let delay = esp_hal::delay::Delay::new();

    loop {
        let pressed = uferris.sw5_pressed();

        // LED 1 mirrors SW5
        if pressed {
            uferris.led1_on();
        } else {
            uferris.led1_off();
        }

        // Only announce a change
        if pressed != was_pressed {
            println!("SW5 {}", if pressed { "pressed" } else { "released" });
            was_pressed = pressed;
        }

        // Read every 20 ms so a bouncing settles
        delay.delay_millis(20);
    }
}

There is not a single GPIO3, Level, or is_low in that code. The application knows the board has an LED and a button; it does not know which pins they are on or which way round they read.

Flash it and open the monitor:

cargo run --release

Press and release SW5 a few times, and the terminal shows one line per transition while the LED tracks your finger:

SW5 pressed
SW5 released
SW5 pressed
SW5 released

If a single press produces several pressed lines in a row, the switch is bouncing through the 20 ms window. Raise the delay time, and it will settle.

✅ Conclusion

We created the first part of the BSP that does something. We started with GPIO. Now two devices live in the board struct, uferris_init we configure them, and three control functions are the only way to reach them, absorbing any board-specific behavior.

The pattern is set and won't change for the rest of the series: a field in the struct, configuration in the init function, and function(s) per component**.** From here, only the peripheral's difficulty changes.

📱 Full Code

src/lib.rs

#![no_std]

use esp_hal::{
    gpio::{Input, InputConfig, Level, Output, OutputConfig},
    peripherals::Peripherals,
};

/// A handle to the µFerris board and everything on it.
pub struct UFerris {
    led1: Output<'static>,
    sw_btn5: Input<'static>,
}

/// Consume the raw chip peripherals and hand back a ready-to-use board.
pub fn uferris_init(peripherals: Peripherals) -> UFerris {
    // LED 1 is driven by GPIO3 and is active high, so it starts out low.
    let led1 = Output::new(peripherals.GPIO3, Level::Low, OutputConfig::default());

    // SW5 sits on GPIO5. The baseboard pulls it up, so no internal pull.
    let sw_btn5 = Input::new(peripherals.GPIO5, InputConfig::default());

    UFerris { led1, sw_btn5 }
}

impl UFerris {
    /// Turn LED 1 on.
    pub fn led1_on(&mut self) {
        self.led1.set_high();
    }

    /// Turn LED 1 off.
    pub fn led1_off(&mut self) {
        self.led1.set_low();
    }

    /// Read switch 5. Returns `true` while the button is held down.
    pub fn sw5_pressed(&mut self) -> bool {
        self.sw_btn5.is_low()
    }
}

src/bin/main.rs

#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::uferris_init;

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // Configure the device and hand the peripherals to the BSP
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);
    let mut uferris = uferris_init(peripherals);

    // Variable to keep track of the last state of the button so we can announce changes.
    let mut was_pressed = false;

    // Instantiate a delay provider to use for debouncing the button.
    let delay = esp_hal::delay::Delay::new();

    loop {
        let pressed = uferris.sw5_pressed();

        // LED 1 mirrors SW5 for as long as it is held down.
        if pressed {
            uferris.led1_on();
        } else {
            uferris.led1_off();
        }

        // Only announce a change, not every sample.
        if pressed != was_pressed {
            println!("SW5 {}", if pressed { "pressed" } else { "released" });
            was_pressed = pressed;
        }

        // Sample every 20 ms so a bouncing settles between reads.
        delay.delay_millis(20);
    }
}