Empowered by the Ecosystem

Learning Embedded Rust with uFerris

Rust Week 2026
The Embedded Rustacean

Rust Week 2026 · May 18 · Utrecht, Netherlands


This workshop takes a different approach to learning embedded development. Instead of walking you through peripheral configurations step by step, we'll teach you how to teach yourself — by navigating the embedded Rust ecosystem, reading documentation effectively, and adapting existing examples to your own needs.

What You'll Learn

By the end of this workshop, you will be able to:

  • Navigate the embedded Rust ecosystem — find the right crates, understand the abstraction layers, and know where to look for answers
  • Set up embedded Rust projects from scratch using esp-generate
  • Read embedded Rust documentation — on docs.rs, in crate source code, and in example repositories
  • Apply the Create → Configure → Control pattern — a mental model that works for every peripheral, every HAL, every driver crate
  • Adapt existing examples to new use cases by exploring configuration options and control methods in the documentation

How This Workshop Works

Every hands-on module follows the same workflow:

  1. Read — We give you a working example in the workshop repo
  2. Understand — You study the code and map it to the documentation
  3. Adapt — You modify the example to do something different by discovering new options in the docs
  4. Extend — Stretch goals push you to navigate unfamiliar documentation independently

The Hardware: uFerris Megalops

uFerris Megalops Baseboard

The uFerris Megalops Baseboard is a learning platform purpose-built for embedded Rust education. It's designed to give you a rich set of peripherals to explore without needing to wire anything up — just plug in and start coding.

What's On Board

ComponentDescriptionInterface
ESP32-C3 XiaoRISC-V MCU with WiFi + BLEUSB-C
LEDsOnboard LEDs for GPIO outputGPIO
ButtonsUser-accessible push buttonsGPIO (with pull-up)
IMU (ICM-42670)6-axis accelerometer + gyroscopeI2C
Additional I2C sensorsTemperature, light, etc.I2C

The ESP32-C3 Xiao

The Seeed Studio XIAO ESP32-C3 is the brain of the uFerris board:

  • Architecture: 32-bit RISC-V (single core, 160 MHz)
  • Memory: 400 KB SRAM, 4 MB Flash
  • Connectivity: WiFi 802.11 b/g/n, Bluetooth 5 (LE)
  • Peripherals: GPIO, I2C, SPI, UART, ADC, PWM
  • USB: Native USB-C (no external programmer needed)
  • Power: USB-C powered, 3.3V logic

Why uFerris?

Most embedded workshops require you to bring your own board and spend precious time wiring up breadboard circuits. uFerris eliminates that friction:

  • Zero wiring — all sensors and peripherals are pre-connected on the PCB
  • Consistent setup — every participant has the same hardware, so we can focus on software
  • Rich peripheral set — GPIO, I2C, and more ready to explore from minute one
  • Designed for learning — pin labels, clear silk screen, and documentation all matched to the workshop exercises

Fallback: Wokwi Simulation

If you have hardware issues during the workshop, a Wokwi simulation is available. See the Wokwi Setup page for instructions. The simulation covers all core exercises (GPIO, I2C) so you won't fall behind.


Prerequisites

  • Rust toolchain with espflash installed (see Setup Guide)
  • Basic Rust knowledge: variables, functions, structs, ownership
  • Curiosity about embedded systems

Workshop by Omar Hiari — The Embedded Rustacean

Rust Week 2026 · Utrecht, Netherlands

Pre-Workshop Setup

~30 min at home

Please complete this setup before the workshop day. Our goal is zero time spent on toolchain issues during the workshop itself.

If you run into problems, reach out on the workshop communication channel — we'll help you get sorted before May 18.

If you arrive without a working setup, you'll spend valuable hands-on time debugging your environment instead of learning embedded Rust. The [Wokwi fallback](./wokwi.md) is available, but a native setup is strongly recommended.

What You'll Set Up

  1. Rust toolchain with the RISC-V target for ESP32-C3
  2. espflash for flashing firmware to the board
  3. esp-generate for creating new projects
  4. A test flash to verify everything works end-to-end
  5. Wokwi (optional) as a simulation fallback

Quick Check

Already set up? Run this to verify:

rustup target list --installed | grep riscv32imc
espflash --version

If both commands produce output, you're probably good. Jump to Hardware Verification to confirm with a real flash.

Rust Toolchain Setup

1. Install Rust

If you don't have Rust installed yet:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Follow the prompts and accept the defaults. After installation, restart your terminal or run:

source $HOME/.cargo/env

Verify:

rustc --version
cargo --version

2. Add the RISC-V Target

The ESP32-C3 uses a RISC-V architecture. Add the target:

rustup target add riscv32imc-unknown-none-elf

3. Install espflash

espflash is used to flash firmware to ESP32 chips and monitor serial output:

cargo install espflash

This may take a few minutes to compile. Verify:

espflash --version

4. Install esp-generate

esp-generate creates new ESP32 Rust projects from templates:

cargo install esp-generate

5. Generate a Test Project

Let's make sure everything works together:

esp-generate --chip esp32c3 hello-uferris
cd hello-uferris

When prompted, select:

  • Which HAL?esp-hal
  • Enable WiFi/BLE? → No (for now)

The project should generate without errors.

6. Build the Test Project

cargo build --release

This first build will take a while as it downloads and compiles dependencies. Subsequent builds are fast.

If `cargo build --release` completes without errors, your toolchain is ready. Next: [Hardware Verification](./hardware.md).

Troubleshooting

cargo install is slow

This is normal for the first install — Rust compiles from source. Grab a coffee.

Target not found

Make sure you're using a recent rustup:

rustup self update
rustup update
rustup target add riscv32imc-unknown-none-elf

Permission errors on Linux

You may need to add your user to the dialout group for serial port access (this matters for the next step):

sudo usermod -a -G dialout $USER

Log out and back in for this to take effect.

Hardware Verification

Now let's make sure your computer can talk to the ESP32-C3.

1. Connect the Board

Plug the ESP32-C3 Xiao into the uFerris Megalops Baseboard (if not already), then connect the baseboard to your computer via USB-C.

Use the USB-C port on the ESP32-C3 Xiao module, not any other port on the baseboard.

2. Check the Serial Port

Your computer should recognize a new serial device:

Linux:

ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null

macOS:

ls /dev/cu.usbmodem* /dev/cu.usbserial* 2>/dev/null

Windows (PowerShell):

Get-WMIObject Win32_SerialPort | Select-Object Name, DeviceID

You should see a device listed. If not, see Troubleshooting below.

3. Flash and Monitor

Navigate to the test project you created in the previous step:

cd hello-uferris
cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/hello-uferris --monitor

You should see serial output from the ESP32-C3. Press Ctrl+C to exit the monitor.

If you see serial output, congratulations — your hardware setup is complete! You're ready for the workshop.

Troubleshooting

No serial device found

Linux — udev rules: Create a file /etc/udev/rules.d/99-esp32.rules:

SUBSYSTEMS=="usb", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", MODE="0666"

Then reload:

sudo udevadm control --reload-rules
sudo udevadm trigger

macOS — driver issues: The ESP32-C3 uses a built-in USB-JTAG interface. If it's not recognized, try a different USB cable (some cables are charge-only, without data lines).

Windows — driver: Install the USB-JTAG driver if the device isn't recognized.

Permission denied on Linux

sudo usermod -a -G dialout $USER

Log out and back in.

Board not responding

  1. Try a different USB cable (must support data, not just charging)
  2. Try a different USB port
  3. Press and hold the BOOT button on the ESP32-C3, then press RESET, then release BOOT — this forces download mode
  4. If all else fails, use the Wokwi fallback

Wokwi Fallback

If your hardware setup isn't working, Wokwi provides a browser-based simulation of the uFerris board. You can complete all workshop exercises in simulation.

The real hardware experience is part of what makes this workshop special. Use Wokwi only if your hardware setup fails and you can't resolve it before the workshop.

Install the Extension

  1. Open VS Code
  2. Go to Extensions (Ctrl+Shift+X / Cmd+Shift+X)
  3. Search for "Wokwi Simulator"
  4. Install it

Set Up for the Workshop

The workshop repo includes a wokwi.toml configuration file in each example project. This file tells Wokwi how to simulate the uFerris board.

To run a simulation:

  1. Open an example project in VS Code
  2. Build it: cargo build --release
  3. Press F1"Wokwi: Start Simulator"
  4. The simulator opens with a virtual uFerris board

Verify It Works

Open the hello-uferris project and run the simulation. You should see the virtual board and serial output in the Wokwi panel.

Option 2: Browser-Based

Visit wokwi.com and create a new ESP32-C3 project. You can paste code directly into the browser editor.

The browser version won't have the uFerris board definition. You'll need to map virtual pins manually. The VS Code extension is a better experience.

Switching Between Hardware and Wokwi

All workshop examples are written to work on both real hardware and Wokwi. The pin assignments are the same — the Wokwi board definition mirrors the physical uFerris board.

If you start on Wokwi and your hardware issue gets resolved during the workshop, you can switch to real hardware at any time by simply flashing with espflash instead of running the simulator.

Part 1: Lay of the Land

~60-75 min

Before we touch any hardware, we need to build a mental model. This part covers everything you need to understand before you start writing embedded Rust code — and more importantly, it gives you a framework for learning anything new in the ecosystem after this workshop is over.

Who Am I?

I'm Omar Hiari — The Embedded Rustacean.

  • Embedded engineer with years of experience in industry and academia
  • Author of books on embedded Rust
  • Publisher of The Embedded Rustacean newsletter
  • Creator of the uFerris learning platform you're holding right now
  • Over 100 blog posts at blog.theembeddedrustacean.com

The Problem

Embedded Rust is exciting. The ecosystem is growing fast. But that growth creates a real challenge for learners:

  • Tutorials go stale. APIs change. Examples that worked six months ago might not compile today.
  • Examples cover one use case. A blinky tutorial shows you how to blink one LED on one pin. But what if you need a different pin? A different configuration? A different peripheral entirely?
  • Copy-paste gets you started, but doesn't get you far. If you can only reproduce what someone else wrote, you're stuck the moment you need something slightly different.

Why Are You Here?

You're going to learn how to fish, not just eat fish.

After this workshop, you'll know how to:

  • Navigate the embedded Rust ecosystem
  • Read documentation effectively
  • Adapt examples to any use case — not just the one in the tutorial

What I'm Going to Teach You

A mental model that works for every peripheral, every HAL, every driver crate:

Create → Configure → Control

And how to find the answers in the docs when nobody wrote a tutorial for your exact use case.

Let's start with the ecosystem.

The Embedded Rust Ecosystem

~10 min

What Is no_std?

When you write normal Rust, you have access to the standard library (std) — file I/O, networking, threads, heap allocation. On a microcontroller, most of that doesn't exist. There's no operating system, no filesystem, no heap (by default).

no_std means: we're opting out of the standard library and working with just core — the subset of Rust that works everywhere, including bare-metal microcontrollers.

Every embedded Rust project starts with `#![no_std]` and `#![no_main]`. This tells the compiler: no standard library, no regular `main()` function. We'll set up our own entry point.

The Landscape

The embedded Rust ecosystem is organized around a few key GitHub organizations:

OrganizationWhat They Do
rust-embeddedCore embedded Rust infrastructure — embedded-hal traits, the Discovery book, cortex-m crates
esp-rsEverything ESP32 — esp-hal, esp-radio, esp-alloc, tooling
embassy-rsAsync embedded framework — Embassy executor, HAL integrations

For this workshop, we'll primarily work with esp-rs crates, since our hardware is the ESP32-C3.

Where to Find Things

What You NeedWhere to Look
Crate discoverycrates.io — search for driver crates, HALs
API documentationdocs.rs — auto-generated docs for every published crate
ESP32-C3 specific docsdocs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/
Examples & source codeGitHub repos — esp-rs/esp-hal, driver crate repos
Curated resourcesawesome-esp-rust
By default, `docs.rs` builds ESP-HAL documentation for the ESP32-C6. For **ESP32-C3 specific** documentation, use Espressif's hosted docs instead. The APIs are very similar, but chip-specific features may differ.

The Role of esp-hal

esp-hal is the Hardware Abstraction Layer for ESP32 chips. It provides:

  • Safe Rust APIs for every peripheral (GPIO, I2C, SPI, UART, timers, etc.)
  • Implementations of embedded-hal traits (more on this next)
  • Interrupt handling
  • DMA support

Think of esp-hal as the bridge between your Rust code and the raw hardware registers on the ESP32-C3.

Current version: esp-hal 1.0.0 (released October 2025).

Embedded Abstractions — The Layer Cake

~15 min

Embedded Rust is built in layers. Understanding these layers is the single most important thing you'll learn today — because once you see the pattern, you can navigate any crate in the ecosystem.

The Five Layers

From lowest (closest to hardware) to highest (most convenient):

┌─────────────────────────────────┐
│         BSP                     │  Board Support Package
│   (board-specific config)       │  e.g., uferris-bsp
├─────────────────────────────────┤
│       Driver Crates             │  Hardware-agnostic drivers
│   (sensor/device drivers)       │  e.g., icm42670, bmp180
├─────────────────────────────────┤
│     embedded-hal Traits         │  The portable interface
│    (the "contract")             │  e.g., InputPin, I2c
├─────────────────────────────────┤
│         HAL                     │  Hardware Abstraction Layer
│   (chip-specific safe API)      │  e.g., esp-hal
├─────────────────────────────────┤
│         PAC                     │  Peripheral Access Crate
│   (raw register access)         │  e.g., esp32c3 (auto-generated)
└─────────────────────────────────┘
        ↓ Hardware ↓

PAC — Peripheral Access Crate

The lowest layer. Auto-generated from SVD (System View Description) files — basically, a Rust representation of every register in the chip. You can use it directly, but you almost never need to.

#![allow(unused)]
fn main() {
// PAC-level: writing directly to a register
// You don't want to do this. But it's good to know it exists.
peripherals.GPIO.out_w1ts.write(|w| unsafe { w.bits(1 << 5) });
}

HAL — Hardware Abstraction Layer

This is where we'll spend most of our time. The HAL provides safe, ergonomic Rust APIs on top of the PAC. For ESP32 chips, this is esp-hal.

#![allow(unused)]
fn main() {
// HAL-level: safe, readable, type-checked
let mut led = Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default());
led.set_high();
}

embedded-hal Traits

The key to portability. These traits define a contract — a standard interface that any HAL can implement. If your code uses embedded_hal::digital::OutputPin instead of esp_hal::gpio::Output directly, it can work on any chip.

#![allow(unused)]
fn main() {
// This function works on ANY microcontroller that implements OutputPin
fn blink(pin: &mut impl OutputPin, delay: &mut impl DelayNs) {
    pin.set_high().unwrap();
    delay.delay_ms(500);
    pin.set_low().unwrap();
    delay.delay_ms(500);
}
}

Driver Crates

Built on top of embedded-hal traits. A driver crate provides a high-level API for a specific sensor or device — and it works on any chip because it only depends on the traits, not on any specific HAL.

#![allow(unused)]
fn main() {
// This driver doesn't know about ESP32. It just needs something that implements I2c.
let mut imu = Icm42670::new(i2c, Address::Primary);
let accel = imu.accel_norm().unwrap();
}

BSP — Board Support Package

The highest layer. A BSP pre-configures everything for a specific board — pin assignments, peripheral setup, default configurations. Instead of remembering pin numbers, you use meaningful names.

#![allow(unused)]
fn main() {
// Without BSP: which pin is the LED again?
let led = Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default());

// With BSP: the board knows
let led = board.led();
}
When you're reading documentation, knowing which layer you're looking at tells you *what kind of information to expect*. HAL docs show chip-specific APIs. Driver crate docs show device-specific APIs. embedded-hal docs show the contract between them.

How the Layers Connect

The power of this architecture:

  1. esp-hal implements embedded-hal traits for ESP32-C3 hardware
  2. Driver crates are written against embedded-hal traits
  3. So any driver crate works with any HAL — no glue code needed

This is why you can take an IMU driver written by someone who's never touched an ESP32, plug it into esp-hal's I2C, and it just works. The traits are the contract that makes this possible.

In the next section, we'll see how this maps to a pattern you'll use for every peripheral.

The Peripheral Pattern: Create → Configure → Control

~15 min

This is the mental model you'll carry out of this workshop. Every peripheral — GPIO, I2C, SPI, UART, timers — follows the same three-step pattern.

The Pattern

┌──────────┐     ┌─────────────┐     ┌───────────┐
│  CREATE  │ ──→ │  CONFIGURE  │ ──→ │  CONTROL  │
│          │     │             │     │           │
│ new()    │     │ Config      │     │ Methods   │
│ builder  │     │ structs     │     │ trait     │
│          │     │ enums       │     │ impls     │
└──────────┘     └─────────────┘     └───────────┘

1. Create — Instantiate the Driver

Every peripheral starts with creating an instance. You'll see patterns like:

#![allow(unused)]
fn main() {
// Direct construction
let led = Output::new(pin, initial_level, config);

// Builder pattern
let i2c = I2c::new(peripherals.I2C0, config)
    .with_sda(sda_pin)
    .with_scl(scl_pin);
}

What to look for on docs.rs:

  • The struct page — find new(), builder(), or associated functions
  • What parameters does the constructor need? Pins? Config? Peripheral instance?

2. Configure — Set Up How It Behaves

Before using a peripheral, you configure it. Configurations are typically expressed as:

  • Config structs — group related settings together
  • Enums — define the options for each setting
  • Builder methods — chain configuration calls
#![allow(unused)]
fn main() {
// GPIO: configure output behavior
let config = OutputConfig::default()
    .with_drive_strength(DriveStrength::_20mA)
    .with_open_drain(false);

// I2C: configure bus speed
let config = Config::default()
    .with_frequency(Rate::from_khz(400));
}

What to look for on docs.rs:

  • Config structs — what fields/methods do they have?
  • Enums — what are the possible values? (DriveStrength, Pull, Rate, etc.)
  • Default values — what happens if you just use Config::default()?

3. Control — Read, Write, Toggle

Once created and configured, you use control methods to interact with the peripheral:

#![allow(unused)]
fn main() {
// GPIO Output: control the pin state
led.set_high();
led.set_low();
led.toggle();

// GPIO Input: read the pin state
let pressed: bool = button.is_low();
let level: Level = button.get_level();  // Returns an enum, not just a bool

// I2C: read/write to devices
i2c.write(address, &data)?;
i2c.read(address, &mut buffer)?;
}

What to look for on docs.rs:

  • Trait implementations — scroll to "Trait Implementations" on the struct page
  • embedded_hal trait methods — these are your standard control interface
  • Inherent methods — chip-specific extras beyond the standard traits
The example you start with shows ONE configuration and ONE control method. The documentation shows ALL of them. Your job is to explore what else is possible.

Live Demo: GPIO Output on docs.rs

Let's walk through this pattern with a real example. Open the esp_hal::gpio::Output documentation:

Create

  • Output::new(pin, initial_level, config) — takes a GPIO pin, an initial Level, and an OutputConfig

Configure

  • OutputConfig has methods like:
    • .with_drive_strength(DriveStrength) — how much current can the pin source/sink?
    • .with_open_drain(bool) — push-pull or open-drain output?
  • DriveStrength enum: how many options are there? Look it up!

Control

  • Inherent methods: set_high(), set_low(), toggle(), set_level(Level)
  • Trait implementations: implements OutputPin from embedded_hal::digital
  • But also: is_set_high(), is_set_low() — you can read back the output state
Open [esp_hal::gpio::Output](https://docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/gpio/struct.Output.html) in your browser. Can you find:
1. All the variants of `DriveStrength`?
2. What `OutputConfig::default()` gives you?
3. A control method we haven't mentioned yet?

This Pattern Repeats Everywhere

PeripheralCreateConfigureControl
GPIO OutputOutput::new()OutputConfigset_high(), toggle()
GPIO InputInput::new()InputConfig, Pullis_high(), get_level()
I2CI2c::new().with_sda().with_scl()Config, Ratewrite(), read()
SPISpi::new().with_mosi().with_sck()Config, Modetransfer(), write()
UARTUart::new().with_tx().with_rx()Config, BaudRatewrite(), read()

Once you internalize Create → Configure → Control, you can pick up any peripheral by reading its documentation. You already know what to look for.

Reading the Documentation

~15 min

You now have a mental model: Create → Configure → Control. Let's turn that into a practical skill — reading embedded Rust documentation efficiently.

Anatomy of a docs.rs Page

When you open a struct page on docs.rs (like esp_hal::gpio::Output), here's what you'll find:

1. Module Path (top of page)

esp_hal::gpio → Output

This tells you where you are in the crate's hierarchy. The module path is your navigation breadcrumb.

2. Struct Definition

Shows the type parameters and fields (if public). Tells you what the struct is.

3. Implementations (impl Output)

This is where Create and Control live. Look for:

  • new() or builder() — how to create an instance
  • Control methods — set_high(), toggle(), get_level(), etc.

4. Trait Implementations (impl OutputPin for Output)

These are the portable methods. If a struct implements embedded_hal::digital::OutputPin, you know it has set_high() and set_low() — and you know those methods will exist on any HAL, not just esp-hal.

5. Associated Types and Enums

Click through to OutputConfig, DriveStrength, Pull, etc. This is where Configure lives.

1. Start at the **struct** page → find `new()` (Create)
2. Click through to the **Config struct** → explore fields and enums (Configure)
3. Scroll to **Trait Implementations** → find control methods (Control)
4. Check the **module page** → see related types you might have missed

HAL Docs vs. Driver Crate Docs

They look the same on docs.rs, but the context is different:

HAL Docs (esp-hal)Driver Crate Docs (e.g., icm42670)
CreateTakes pins + peripheral instancesTakes an I2c or Spi bus (generic!)
ConfigureChip-specific settings (pin mode, clock speed)Device-specific settings (sample rate, range)
ControlLow-level operations (write bytes, read registers)High-level operations (read acceleration, read temperature)
Trait implsImplements embedded-hal traitsUses embedded-hal traits as bounds
When a driver crate's `new()` takes `impl I2c` as a parameter, that's your signal: this driver is **hardware-agnostic**. It will work with any chip that implements the `I2c` trait. This is the embedded-hal abstraction doing its job.

Finding Examples

Examples are your starting point for every exercise. Here's where to find them:

Workshop Repo (primary)

All exercise starting points are in the examples/ directory of this workshop's repository. These are tested, working code that you'll adapt.

esp-hal Repo (side exploration)

The esp-hal GitHub repo has examples organized by category:

examples/
├── interrupt/gpio/     ← GPIO interrupt example
├── peripheral/spi/     ← SPI examples
├── async/embassy_*/    ← Async/Embassy examples
└── ...

Driver Crate READMEs

Most driver crates include usage examples in their README or docs.rs documentation. Check both.

The Workshop Workflow

Every hands-on module will follow this flow:

┌─────────────────────┐
│  1. READ the example │ ← Working code in the workshop repo
│     in the repo      │
├─────────────────────┤
│  2. UNDERSTAND it    │ ← Map it to Create → Configure → Control
│     using the docs   │
├─────────────────────┤
│  3. ADAPT it         │ ← Change config, try different controls,
│     to do something  │   use what you found in the docs
│     different        │
├─────────────────────┤
│  4. EXTEND it        │ ← Stretch goals: navigate unfamiliar
│     (stretch goal)   │   documentation on your own
└─────────────────────┘

The example shows one way to use a peripheral. The docs show all of them. Your job is to explore.

Ready? Let's get our hands dirty with GPIO.

Part 2: GPIO

~60-75 min

Time to get hands-on. In this module, you'll apply the Create → Configure → Control pattern to GPIO — the most fundamental peripheral on any microcontroller.

You'll start with a working example, then use the documentation to discover what else you can do with it.

What You'll Do

  1. Blinky — adapt the workshop's blinky example for your uFerris board
  2. Explore configurations — discover GPIO options you didn't know existed by reading the docs
  3. Adaptation challenge — combine inputs and outputs with configurations you chose yourself

GPIO Essentials

~10 min slides

GPIO — General Purpose Input/Output. The most basic peripheral: making pins go high or low, and reading whether they're high or low.

Configurations

Before you can use a GPIO pin, you need to decide how it will behave.

Direction

  • Output — you drive the pin (e.g., turn on an LED)
  • Input — you read the pin (e.g., detect a button press)

Output Modes

  • Push-pull (default) — actively drives the pin high and low
  • Open-drain — actively drives low, but floats when "high" (needs external pull-up)

Pull Resistors (for inputs)

  • Pull-up — pin reads high when nothing is connected; button pulls it low
  • Pull-down — pin reads low when nothing is connected; button pulls it high
  • Floating — no pull resistor; pin state is undefined when nothing is connected

Drive Strength (for outputs)

How much current the pin can source or sink. Higher drive strength = brighter LED, but more power consumption.

Controls

Output Controls

MethodWhat It Does
set_high()Drive the pin high
set_low()Drive the pin low
toggle()Flip the pin state
set_level(Level)Set to a specific level
is_set_high()Check what you last set

Input Controls

MethodWhat It Does
is_high()boolIs the pin high?
is_low()boolIs the pin low?
get_level()LevelGet the level as an enum
`is_high()` returns a `bool` — simple, but you lose information. `get_level()` returns a `Level` enum that you can `match` on. Both work; the enum version is more expressive and more Rusty.

uFerris GPIO Map

Check your pinout card for the exact pin assignments on the uFerris Megalops board:

FunctionPinNotes
Onboard LEDGPIO?Active high/low?
User ButtonGPIO?Needs pull-up?
I2C SDAGPIO?Used in Part 3
I2C SCLGPIO?Used in Part 3
The exact pin numbers depend on your uFerris board revision. Always check the pinout card provided in your workshop kit.

Mapping to docs.rs

Here's how GPIO maps to the Create → Configure → Control pattern:

StepWhat to Look ForWhere on docs.rs
CreateOutput::new(pin, level, config)Struct page → impl Output
ConfigureOutputConfig, InputConfig, Pull, DriveStrengthAssociated types / linked structs
Controlset_high(), toggle(), get_level()Trait impls + inherent methods

Now let's put this into practice.

Exercise A: Blinky

~15 min

Your first hands-on exercise. You'll take a working blinky example and adapt it for the uFerris board.

Starting Point

Open the examples/blinky/ project in the workshop repo. This is a complete, working blinky that toggles a GPIO output.

Read through src/main.rs. You should see the Create → Configure → Control pattern:

#![allow(unused)]
fn main() {
// CREATE: instantiate an Output driver for a GPIO pin
let mut led = Output::new(
    peripherals.GPIO0,      // ← Which pin? This might not be right for uFerris
    Level::Low,             // ← Initial state
    OutputConfig::default() // ← Default configuration
);

// CONTROL: toggle the LED in a loop
loop {
    led.toggle();
    // delay...
}
}

Your Task

The example works — but it's using GPIO0, which might not be the LED on your uFerris board.

  1. Check your pinout card — which GPIO pin is connected to the onboard LED?
  2. Open the docs — look up Output::new() in the esp-hal GPIO documentation. What parameters does it take?
  3. Change the pin to match your uFerris board
  4. Flash and verify — the LED should blink
cd examples/blinky
cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/blinky --monitor
Watch the LED blink. Notice the blink rate. How is the delay implemented in the code? Can you change it?

Understanding the Code

Before moving on, make sure you can answer:

  • What does Output::new() need? (a pin peripheral, an initial level, a config)
  • What does OutputConfig::default() give you? (Look it up in the docs!)
  • What does toggle() do? (Flip the pin state: high → low, low → high)
Look at your uFerris pinout card. Find the pin labeled "LED" or with an LED symbol. Replace `peripherals.GPIO0` with `peripherals.GPIOXX` where `XX` is the pin number.
The solution is in `solutions/blinky-adapted/src/main.rs`. But try to figure it out from the pinout card first — that's the whole point!

Done?

If your LED is blinking, you've just applied the Create → Configure → Control pattern for the first time. But we only used OutputConfig::default() — there's a lot more to explore. That's next.

Exercise B: Explore GPIO Configurations

~20 min

The blinky example used OutputConfig::default(). But what does "default" actually mean? And what other configurations are available?

This is where you start exploring the documentation on your own.

Part 1: Output Configuration

Your LED is blinking with the default config. Let's see what else exists.

Open [`OutputConfig`](https://docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/gpio/struct.OutputConfig.html) on docs.rs. Answer these questions:
  1. What methods does OutputConfig have? Look at the impl OutputConfig section.

  2. Drive Strength: Find the DriveStrength enum. How many variants does it have? What do they mean?

    • Try changing the drive strength in your blinky code:
    #![allow(unused)]
    fn main() {
    let config = OutputConfig::default()
        .with_drive_strength(DriveStrength::_20mA);
    }
    • Can you see any difference? (On an LED, higher drive strength = slightly brighter, but it depends on the circuit)
  3. Open Drain: What does .with_open_drain(true) do?

    • Try it. Does your LED still work? Why or why not?
    • (Hint: open-drain needs an external pull-up resistor to go high)

Part 2: Adding a Button Input

Now add a button to read. Keep your LED output — we'll control it with the button.

Open [`Input`](https://docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/gpio/struct.Input.html) and [`InputConfig`](https://docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/gpio/struct.InputConfig.html) on docs.rs.
  1. Create an input: Check the pinout card — which pin is the button?

    #![allow(unused)]
    fn main() {
    let button = Input::new(peripherals.GPIOXX, InputConfig::default());
    }
  2. Explore Pull options: Look up the Pull enum. What variants exist?

    • Which pull setting does your button need? Think about the circuit:
      • If the button connects the pin to GND when pressed → you need Pull::Up
      • If the button connects the pin to VCC when pressed → you need Pull::Down
    • Try the wrong pull setting. What happens?
  3. Read the button state: Don't just use is_low(). Look at what other methods are available:

    • is_high()bool
    • is_low()bool
    • get_level()Level

    Try using get_level() with a match statement:

    #![allow(unused)]
    fn main() {
    match button.get_level() {
        Level::Low => { /* button pressed? */ },
        Level::High => { /* button released? */ },
    }
    }
The blinky example showed you ONE configuration: `OutputConfig::default()`. By reading the docs, you just discovered drive strength, open-drain mode, pull resistors, and multiple ways to read a pin state. This is what ecosystem navigation looks like.

Checkpoint

Before moving on, you should have:

  • Tried at least one non-default OutputConfig setting
  • Created a button Input with an appropriate Pull setting
  • Read the button state using get_level() (not just is_high())
```rust
loop {
    match button.get_level() {
        Level::Low => led.set_high(),   // Button pressed (with pull-up)
        Level::High => led.set_low(),   // Button released
    }
}

```admonish hint title="Hint: Full solution" collapsible=true
See `solutions/button-configs/src/main.rs` for a complete example with multiple configurations demonstrated.

Exercise C: Adaptation Challenge

~15 min

No step-by-step instructions. No hints. Just you and the documentation.

The Challenge

Combine what you've learned to build something that uses a configuration or control method you haven't tried yet. Here are some ideas — pick one or come up with your own:

Option 1: Level-Matched Response

Use get_level() on both the button and the LED's output state (is_set_high()) to create logic:

  • If LED is already on and button is pressed → turn it off
  • If LED is off and button is pressed → turn it on
  • Basically: toggle on press, not just mirror the button state

Option 2: Configuration Explorer

Try every configuration option you can find in the docs:

  • All DriveStrength variants
  • Open-drain mode (does it work without external pull-up?)
  • Different Pull settings on the input
  • What happens with Pull::None (floating input)?

Option 3: Multi-Pin

Use multiple GPIO pins:

  • Blink two LEDs alternately
  • Use two buttons to control different behaviors
  • Map different pins to different outputs

Resources

Everything you need is in the documentation:

When You're Done

You've now experienced the full cycle: working example → docs exploration → independent adaptation. This is the same workflow we'll use for I2C and interrupts.

Take a break — lunch is next!

Part 3: I2C

~60-75 min

Welcome back from lunch. In this module, you'll move beyond GPIO to a communication protocol — I2C. More importantly, you'll experience the embedded-hal abstraction in action by using a driver crate that works across any microcontroller.

What You'll Do

  1. Bus scan — discover what I2C devices are connected to your uFerris board
  2. Driver crate — use a third-party driver to read real sensor data
  3. Adaptation challenge — explore a second sensor or different driver configuration

I2C Essentials

~10 min slides

I2C (Inter-Integrated Circuit) — a two-wire bus protocol for communicating with sensors, displays, and other devices.

How I2C Works

Two wires:

  • SDA — Serial Data (bidirectional)
  • SCL — Serial Clock (driven by the controller)

Multiple devices can share the same two wires. Each device has a unique address (7 bits = up to 128 devices on one bus).

  Controller (ESP32-C3)
       │  │
   SDA ┤  ├ SCL
       │  │
  ┌────┴──┴────┐
  │  I2C Bus   │
  ├────────────┤
  │            │
Sensor A    Sensor B
(addr 0x68) (addr 0x44)

Key Concepts

ConceptWhat It Means
ControllerThe device that initiates communication (our ESP32-C3)
TargetThe device being addressed (sensors on the uFerris board)
Address7-bit identifier for each device (e.g., 0x68)
WriteController sends data to a target
ReadController reads data from a target
Clock SpeedHow fast data transfers: 100 kHz (standard), 400 kHz (fast)

The Driver Crate Pattern

This is where the embedded-hal abstraction becomes powerful:

esp-hal ──implements──→ embedded_hal::i2c::I2c trait
                              ↑
                              │ uses
                              │
Driver crate ──────→ takes generic `impl I2c`
(e.g., icm42670)

The driver crate doesn't import esp-hal. It only depends on the embedded-hal trait. This means:

  • The driver works on ESP32, nRF52, STM32, RP2040 — any chip with an I2c implementation
  • You can switch chips without changing your driver code
  • This is the whole point of the abstraction layers from Part 1

Configurations

SettingOptionsdocs.rs Location
Clock frequency100 kHz, 400 kHz, customConfig::with_frequency()
SDA/SCL pinsAny GPIO with I2C capability.with_sda(), .with_scl()
TimeoutBus timeout durationConfig::with_timeout()

Controls

MethodWhat It Does
write(addr, &[u8])Send bytes to a device
read(addr, &mut [u8])Read bytes from a device
write_read(addr, &[u8], &mut [u8])Write then read in one transaction

What's on the uFerris Board?

Your uFerris board has I2C sensor(s) connected. You'll discover exactly which ones in the first exercise — by scanning the bus.

Check your pinout card for the SDA and SCL pin assignments.

Exercise A: I2C Bus Scan

~20 min

Before you can talk to an I2C device, you need to know its address. Let's scan the bus and see who's out there.

Starting Point

Open the examples/i2c-scan/ project in the workshop repo. This is a working I2C bus scanner.

Read through src/main.rs. Map it to Create → Configure → Control:

#![allow(unused)]
fn main() {
// CREATE: instantiate the I2C controller
let i2c = I2c::new(
    peripherals.I2C0,
    Config::default(),      // ← What does default give us?
)
.with_sda(peripherals.GPIO?)  // ← Which pin?
.with_scl(peripherals.GPIO?); // ← Which pin?

// CONTROL: scan all possible addresses
for addr in 0x01..=0x7F {
    if i2c.write(addr, &[]).is_ok() {
        // Device found at this address!
    }
}
}

Your Task

Step 1: Adapt the Pins

Check your uFerris pinout card:

  • Which GPIO is SDA?
  • Which GPIO is SCL?

Update the example with the correct pins.

Step 2: Run the Scanner

cd examples/i2c-scan
cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/i2c-scan --monitor

The scanner should print the addresses of all devices it finds. Write them down — you'll need them in the next exercise.

Common I2C addresses on embedded dev boards:
- `0x68` or `0x69` — IMU / accelerometer
- `0x44` or `0x70` — temperature/humidity sensor
- `0x3C` or `0x3D` — OLED display

Cross-reference with your uFerris board documentation to identify each device.

Step 3: Explore the Configuration

Now look at the docs:

Open [`esp_hal::i2c::master::Config`](https://docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/i2c/master/struct.Config.html). What can you configure?
  1. Clock frequency: The default is likely 100 kHz (standard mode). Try changing it:

    #![allow(unused)]
    fn main() {
    let config = Config::default()
        .with_frequency(Rate::from_khz(400)); // Fast mode
    }

    Does the scan still work at 400 kHz? What about other speeds?

  2. Timeout: Is there a timeout setting? What happens if you change it?

  3. What does Config::default() actually give you? Look it up in the docs.

esp-hal 1.0 uses a builder pattern for I2C:
```rust
let i2c = I2c::new(peripherals.I2C0, Config::default())
    .with_sda(peripherals.GPIOXX)
    .with_scl(peripherals.GPIOXX);

The .with_sda() and .with_scl() calls are chained after new().


```admonish hint title="Hint: Full scanner" collapsible=true
See `solutions/i2c-configs/src/main.rs` for the complete scanner with uFerris pin assignments and different configurations.

Exercise B: Using a Driver Crate

~25 min

You've found devices on the I2C bus. Now let's read real sensor data using a driver crate — and see the embedded-hal abstraction in action.

Starting Point

Open the examples/i2c-driver/ project in the workshop repo. This example reads data from an I2C sensor using a third-party driver crate.

Read through src/main.rs carefully:

#![allow(unused)]
fn main() {
// I2C setup (same as before — Create → Configure)
let i2c = I2c::new(peripherals.I2C0, Config::default())
    .with_sda(peripherals.GPIO?)
    .with_scl(peripherals.GPIO?);

// DRIVER CRATE: Create a sensor driver
// Notice: it takes `i2c` — a generic I2c implementor, not an esp-hal specific type!
let mut sensor = SensorDriver::new(i2c, address);

// CONTROL: Read sensor data
let reading = sensor.some_reading().unwrap();
}
Look at the driver crate's `new()` function signature. It takes `impl I2c` — not `esp_hal::i2c::master::I2c`. This driver was written without any knowledge of ESP32. It works because esp-hal implements the `embedded_hal::i2c::I2c` trait.

Your Task

Step 1: Adapt the Pins

Same as before — update the SDA/SCL pins for your uFerris board.

Step 2: Run It

cd examples/i2c-driver
cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/i2c-driver --monitor

You should see sensor readings printing to the serial console.

Step 3: Explore the Driver Crate Documentation

This is the key exercise. The example reads one value from the sensor. But the sensor can do much more.

Find the driver crate on [docs.rs](https://docs.rs). Look at:
1. The **struct** page for the sensor driver
2. What **methods** are available beyond the one used in the example?
3. Does the driver have a **Config** struct? What can you configure?

Answer these questions by reading the docs:

  1. What else can you read? The example reads one measurement. Can you read other values? (e.g., different axes, temperature, raw data vs. normalized data)

  2. Configuration: Can you change the sensor's behavior?

    • Sample rate / output data rate
    • Measurement range (e.g., ±2g vs ±16g for an accelerometer)
    • Power mode (low power vs. high performance)
  3. The driver has its own Create → Configure → Control pattern. Can you map it?

    • Create: SensorDriver::new(i2c, address) — what parameters?
    • Configure: Is there a config struct or initialization method?
    • Control: What readings can you take?

Step 4: Try a Different Configuration

Change something in the driver's configuration and observe how the readings change:

#![allow(unused)]
fn main() {
// Example: if the driver supports changing the measurement range
let mut sensor = SensorDriver::new(i2c, address);
sensor.set_range(Range::High).unwrap();  // ← Will this method exist? Check the docs!
}
You just navigated TWO levels of documentation:
1. **esp-hal I2C docs** — to set up the bus (HAL layer)
2. **Driver crate docs** — to read the sensor (Driver layer)

And the driver crate worked because of the `embedded-hal` trait in between. This is the layer cake from Part 1, in practice.
Based on the sensor address you found in Exercise A, search [crates.io](https://crates.io) for the sensor name. Most sensor driver crates follow the naming pattern: `sensor-name` (e.g., `icm42670`, `bmp180`, `shtcx`).
If you need to use the same I2C bus with multiple devices, you can't just pass `i2c` to two drivers (Rust's ownership won't let you). Use `embedded_hal_bus::i2c::RefCellDevice` to create shareable references:
```rust
use embedded_hal_bus::i2c::RefCellDevice;
use core::cell::RefCell;

let i2c_ref = RefCell::new(i2c);
let mut sensor1 = Driver1::new(RefCellDevice::new(&i2c_ref), addr1);
let mut sensor2 = Driver2::new(RefCellDevice::new(&i2c_ref), addr2);

Exercise C: Adaptation Challenge

~15 min

The Challenge

You found multiple devices on the I2C bus in Exercise A. You've used a driver crate for one of them. Now try the other.

What to Do

  1. Identify the second sensor from your bus scan results
  2. Search crates.io for a driver crate — does one exist?
  3. Read the driver crate's docs.rs page — find new(), figure out what it needs
  4. Add it to your projectcargo add sensor-name
  5. Create, configure, control — same pattern, different device

No Hints

This is pure ecosystem navigation practice. Everything you need is on crates.io and docs.rs. You've done this once already — now do it independently.

If No Second Sensor

If your uFerris board only has one I2C device, try one of these instead:

  • Read ALL available data from the first sensor — not just the one reading from the example. How many different measurements can you get?
  • Change every configurable setting on the driver and observe the effects
  • Implement formatted output — print readings in a human-readable format with units

Reflection

You've now navigated from raw I2C bytes to high-level sensor readings, using two separate layers of documentation. The same workflow works for any sensor, any bus protocol, any microcontroller.

Next up: interrupts — making things happen without polling in a loop.

Part 4: Interrupts

~45-60 min

So far, all your code has been polling — checking the button state in a loop, reading sensors repeatedly. This works, but it's wasteful. The CPU spins constantly even when nothing is happening.

Interrupts let the hardware notify your code when something happens. The CPU can do other work (or sleep) until an event occurs.

What You'll Do

  1. Interrupt-driven button — convert your polling button code to use interrupts
  2. Adaptation challenge — handle multiple inputs, explore different trigger configurations
  3. Debouncing (stretch goal) — handle the real-world problem of noisy button signals

Interrupt Essentials

~10 min slides

Polling vs. Interrupts

POLLING:                          INTERRUPTS:
┌──────────────┐                  ┌──────────────┐
│ loop {       │                  │ loop {       │
│   if pressed │ ← CPU busy      │   // sleep   │ ← CPU idle
│     do_thing │   checking       │   // or do   │   until event
│   }          │   constantly     │   // other   │
│ }            │                  │   // work    │
└──────────────┘                  └──────┬───────┘
                                         │ INTERRUPT!
                                  ┌──────▼───────┐
                                  │ handler() {  │ ← CPU wakes up
                                  │   do_thing   │   handles event
                                  │ }            │   goes back
                                  └──────────────┘

GPIO Interrupt Triggers

When should the interrupt fire?

TriggerWhen It Fires
Event::FallingEdgePin goes from HIGH → LOW (button press with pull-up)
Event::RisingEdgePin goes from LOW → HIGH (button release with pull-up)
Event::AnyEdgeAny transition — both press and release

The Interrupt Pattern in Rust

Interrupts introduce a challenge: the interrupt handler and your main code need to share state. In Rust, this means dealing with Send, Sync, and interior mutability.

The Standard Pattern

#![allow(unused)]
fn main() {
use core::cell::RefCell;
use critical_section::Mutex;

// 1. Shared state: wrap in Mutex<RefCell<Option<T>>>
static BUTTON: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));

// 2. In main: move the peripheral into the shared state
critical_section::with(|cs| {
    BUTTON.borrow_ref_mut(cs).replace(button);
});

// 3. The interrupt handler
#[handler]
fn gpio_handler() {
    critical_section::with(|cs| {
        let mut button = BUTTON.borrow_ref_mut(cs);
        if let Some(button) = button.as_mut() {
            // Handle the interrupt
            button.clear_interrupt();
        }
    });
}
}

Key Pieces

PiecePurpose
Mutex<RefCell<Option<T>>>Safe shared access between main code and interrupt handler
critical_section::withTemporarily disables interrupts to safely access shared state
#[handler]Marks a function as an interrupt handler (esp-hal attribute)
clear_interrupt()Must be called — tells the hardware you've handled the event
In esp-hal 1.0, interrupts are behind the `unstable` feature flag. Make sure your `Cargo.toml` includes:
```toml
[dependencies]
esp-hal = { version = "1.0", features = ["unstable"] }

```admonish key-concept title="Keep handlers short"
Interrupt handlers should do as little as possible — set a flag, clear the interrupt, return. Do the heavy work in your main loop. This prevents blocking other interrupts and keeps the system responsive.

Exercise A: Interrupt-Driven Button

~20 min

Starting Point

Open the examples/interrupt-button/ project in the workshop repo. This is a working GPIO interrupt example that toggles an LED when a button is pressed.

Read through src/main.rs carefully. This is the most complex code pattern in the workshop. Map it to what you learned in the slides:

  1. Shared statestatic BUTTON: Mutex<RefCell<Option<Input>>>
  2. Moving the peripheralBUTTON.borrow_ref_mut(cs).replace(button)
  3. The handler#[handler] fn gpio_handler()
  4. Clearing the interruptbutton.clear_interrupt()

Your Task

Step 1: Adapt and Run

Update the pin assignments for your uFerris board and flash:

cd examples/interrupt-button
cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/interrupt-button --monitor

Press the button. The LED should toggle.

Step 2: Explore Trigger Configurations

The example uses one specific edge trigger. Time to explore what else exists.

Look up the `Event` enum in esp-hal's GPIO module. What variants are available?

Try changing the trigger:

  1. Switch from FallingEdge to RisingEdge — what changes? Does the LED toggle on press or release now?

  2. Try AnyEdge — the LED should toggle on both press and release. What happens?

  3. Think about it: which trigger would you use for:

    • A light switch (toggle once per press)?
    • A camera shutter (action on press, not release)?
    • Measuring how long a button is held?

Step 3: Flag-Based Approach

The example toggles the LED inside the interrupt handler. But best practice says: keep handlers short, do work in the main loop.

Refactor to use a flag:

#![allow(unused)]
fn main() {
use core::sync::atomic::{AtomicBool, Ordering};

static BUTTON_PRESSED: AtomicBool = AtomicBool::new(false);
}
  1. In the handler: set the flag to true (and clear the interrupt)
  2. In the main loop: check the flag, toggle the LED, reset the flag
Look up `AtomicBool` in the Rust standard library docs. What methods does it provide? Try `store()`, `load()`, and `swap()`.

Step 4: Compare

Think about the difference between your Part 2 polling code and this interrupt code:

  • What can the main loop do now that it couldn't before?
  • When would you choose polling over interrupts?
  • When would you choose interrupts over polling?
```rust
#[handler]
fn gpio_handler() {
    critical_section::with(|cs| {
        let mut button = BUTTON.borrow_ref_mut(cs);
        if let Some(button) = button.as_mut() {
            button.clear_interrupt();
        }
    });
    BUTTON_PRESSED.store(true, Ordering::Relaxed);
}

// In main loop:
loop {
    if BUTTON_PRESSED.swap(false, Ordering::Relaxed) {
        led.toggle();
    }
    // CPU could do other work here, or sleep
}

Exercise B: Adaptation Challenge

~15 min

The Challenge

The example handles one button. Can you handle two?

What to Explore

  1. Add a second GPIO interrupt for another input on the uFerris board (or use a second pin connected to the same button if only one button is available)

  2. How does sharing work? You need another static Mutex<RefCell<Option<Input>>> for the second pin. Or can you reuse the same handler?

  3. Different behavior per pin: Can you make one button toggle the LED and the other change the blink speed?

Questions to Answer from the Docs

  • Can multiple GPIO pins share the same interrupt handler?
  • How do you tell which pin triggered the interrupt inside the handler?
  • Can you set different Event triggers for different pins?

Resources

  • esp_hal::gpio — look at the module-level docs for interrupt-related information
  • Your uFerris pinout card — what other pins can you use?
  • The Create → Configure → Control pattern applies to interrupt setup too

Exercise C: Debouncing (Stretch Goal)

~10 min

The Problem

If you've been pressing buttons, you may have noticed: sometimes a single press registers as multiple presses. This is called bounce — the mechanical contacts in the button vibrate when pressed, causing rapid on-off-on transitions.

What you expect:    What actually happens:
HIGH ─────┐         HIGH ─────┐ ┌┐ ┌┐
          │                   │ ││ ││
LOW       └──────   LOW       └─┘└─┘└──────
          ↑ press             ↑ bounce!

Your interrupt fires on every edge — so a bouncy button triggers multiple interrupts for one press.

The Challenge

Add software debouncing to your interrupt-driven button. The idea: after detecting a press, ignore further interrupts for a short time (e.g., 50ms).

Approach: Timer-Based Debounce

You'll need a timer — another peripheral! Same pattern applies:

  1. Find the timer peripheral in esp-hal docs
  2. Create → Configure → Control — set up a timer
  3. Record the time of each interrupt; ignore interrupts that happen within the debounce window
Look for timer-related modules in esp-hal. The same Create → Configure → Control pattern applies:
- **Create:** How do you instantiate a timer?
- **Configure:** What resolution/frequency?
- **Control:** How do you read the current time?

No Hints

This is an advanced stretch goal. You'll need to navigate unfamiliar documentation (timers) and combine it with what you already know (interrupts). This is exactly the kind of problem-solving the workshop is designed to prepare you for.

If you solve it — congratulations, you've independently navigated a new peripheral using only the documentation. That's the skill that will serve you long after this workshop.

Part 5: The uFerris BSP (Bonus)

~20-30 min (if time permits)

You've now worked at the HAL level (GPIO, I2C with esp-hal) and the driver crate level (sensor drivers). There's one more layer in the stack: the Board Support Package.

A BSP takes everything you've been doing manually — pin assignments, peripheral configuration, device initialization — and wraps it in board-specific convenience functions.

The uFerris Megalops has its own BSP.

What Is a BSP?

~5 min slides

The Top of the Stack

Remember the layer cake from Part 1:

BSP    ← You are here now
Driver Crates
embedded-hal Traits
HAL (esp-hal)
PAC

A Board Support Package encapsulates board-specific knowledge:

Without BSPWith BSP
Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default())board.led()
"Which pin is SDA again?" + checks pinout cardboard.i2c()
Configure each peripheral manuallyPre-configured with sensible defaults

It's Not Magic

The BSP is just Rust code that does exactly what you've been doing — but packages it up with meaningful names. Open the source and you'll see Output::new(), I2c::new(), and all the same patterns.

The value is:

  • No pin lookup errors — the board knows its own pin assignments
  • Sensible defaults — configurations chosen for the specific board's hardware
  • Convenience — less boilerplate for common setups

When to Use a BSP vs. Raw HAL

Use BSP WhenUse Raw HAL When
Quick prototypingYou need non-default configurations
Your board has a BSPYou're using custom hardware
You don't need fine controlYou want to learn the lower layers

In a workshop about ecosystem navigation, understanding the raw HAL is essential. The BSP is the reward: once you understand the layers underneath, you can appreciate what the BSP does for you.

Exercise: Explore the uFerris BSP

~15-20 min

Your Task

Step 1: Read the Source

Open the uFerris BSP crate source code. Don't use it yet — just read it.

Answer these questions:

  1. How does the BSP map pin numbers to named functions?
  2. What configuration choices has the BSP author made? (Drive strength? Pull resistors? I2C speed?)
  3. Can you find the Create → Configure → Control pattern inside the BSP code?
The BSP calls `Output::new()`, which calls into the HAL, which writes to PAC registers, which toggle actual hardware. Every layer you've learned is present — the BSP just wraps them up.

Step 2: Refactor Your Code

Take one of your earlier exercises — blinky or I2C scan — and refactor it to use the BSP:

Before (raw HAL):

#![allow(unused)]
fn main() {
let mut led = Output::new(
    peripherals.GPIO5,
    Level::Low,
    OutputConfig::default()
);
}

After (BSP):

#![allow(unused)]
fn main() {
let mut led = board.led();
}

How much simpler is the code? What did you lose (if anything)?

Step 3: Look Beyond

What other convenience functions does the BSP provide? Can you find:

  • I2C setup?
  • Button input?
  • Sensor initialization?

Try using one you haven't tried before.

Reflection

You've now seen every layer of the embedded Rust stack:

LayerWhat You Did
HALConfigured GPIO pins, I2C buses manually
embedded-halUsed traits that make driver crates portable
Driver cratesRead sensor data with hardware-agnostic drivers
BSPUsed board-specific convenience functions

And for every layer, the same pattern: Create → Configure → Control.

You know how to read the docs for any of them. You know how to adapt examples. You know where to look when you need something new.

That's the skill that lasts.

Cheatsheet

Print this. Tape it to your monitor. Refer to it whenever you're working with a new peripheral.


The Pattern: Create → Configure → Control

1. CREATE   → Find the struct, call new() or use the builder
2. CONFIGURE → Find the Config struct, explore the enums
3. CONTROL  → Find the trait impls and inherent methods

docs.rs Navigation

I Need To...Go To...
Find a cratecrates.io → search
Read crate docsdocs.rs/CRATE_NAME
ESP32-C3 specific docsdocs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/
See examplesGitHub repo → examples/ directory
Find a driver cratecrates.io → search sensor name

On a Struct Page

  1. Top section — struct definition, what it is
  2. impl StructName — constructors (new()) and inherent methods
  3. impl Trait for StructName — portable methods (embedded-hal)
  4. Associated types — click through to Config structs, enums

GPIO Quick Reference

#![allow(unused)]
fn main() {
// Output
let mut led = Output::new(pin, Level::Low, OutputConfig::default());
led.set_high();
led.set_low();
led.toggle();

// Input
let button = Input::new(pin, InputConfig::default().with_pull(Pull::Up));
let pressed: bool = button.is_low();
let level: Level = button.get_level();
}

Configuration Options

TypeKey Variants
LevelHigh, Low
PullUp, Down, None
DriveStrength_5mA, _10mA, _20mA, _40mA
OutputConfig.with_drive_strength(), .with_open_drain()

I2C Quick Reference

#![allow(unused)]
fn main() {
// Setup
let i2c = I2c::new(peripherals.I2C0, Config::default())
    .with_sda(sda_pin)
    .with_scl(scl_pin);

// Bus scan
for addr in 0x01..=0x7F {
    if i2c.write(addr, &[]).is_ok() {
        println!("Found device at 0x{:02X}", addr);
    }
}

// Using a driver crate
let mut sensor = DriverCrate::new(i2c, address);
let reading = sensor.read_value().unwrap();
}

Configuration

#![allow(unused)]
fn main() {
let config = Config::default()
    .with_frequency(Rate::from_khz(400));  // Standard: 100, Fast: 400
}

Interrupts Quick Reference

#![allow(unused)]
fn main() {
use core::cell::RefCell;
use critical_section::Mutex;
use core::sync::atomic::{AtomicBool, Ordering};

// Shared state
static BUTTON: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
static FLAG: AtomicBool = AtomicBool::new(false);

// Setup: configure interrupt and move into shared state
button.listen(Event::FallingEdge);
critical_section::with(|cs| {
    BUTTON.borrow_ref_mut(cs).replace(button);
});

// Handler
#[handler]
fn gpio_handler() {
    critical_section::with(|cs| {
        if let Some(button) = BUTTON.borrow_ref_mut(cs).as_mut() {
            button.clear_interrupt();  // MUST clear!
        }
    });
    FLAG.store(true, Ordering::Relaxed);
}

// Main loop
loop {
    if FLAG.swap(false, Ordering::Relaxed) {
        led.toggle();
    }
}
}

Event Triggers

TriggerFires When
Event::FallingEdgeHIGH → LOW
Event::RisingEdgeLOW → HIGH
Event::AnyEdgeAny transition
- Add `unstable` feature to esp-hal in `Cargo.toml`
- Always call `clear_interrupt()` in the handler
- Keep handlers short — set a flag, handle in main loop

Abstraction Layers

BSP ──────────── board.led(), board.i2c()
Driver Crate ─── SensorDriver::new(impl I2c, addr)
embedded-hal ─── trait I2c, trait OutputPin
HAL (esp-hal) ── I2c::new(), Output::new()
PAC ──────────── Raw registers (auto-generated)

Common Commands

# Create a new project
esp-generate --chip esp32c3 project-name

# Build
cargo build --release

# Flash and monitor
espflash flash target/riscv32imc-unknown-none-elf/release/PROJECT --monitor

# Monitor only (already flashed)
espflash monitor

# Add a dependency
cargo add crate-name

ResourceURL
esp-hal docs (ESP32-C3)docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/
crates.iocrates.io
docs.rsdocs.rs
esp-hal GitHubgithub.com/esp-rs/esp-hal
The Embedded Rustaceantheembeddedrustacean.com
Embassy (async)embassy.dev

Wrap-Up & Next Steps

What You Learned Today

The Mental Model

Create → Configure → Control — it works for every peripheral, every HAL, every driver crate.

StepWhat You DoWhere in the Docs
CreateInstantiate a driverStruct page → new() or builder
ConfigureSet behavior optionsConfig structs, enums
ControlRead/write/interactTrait implementations, methods

The Ecosystem Layers

BSP → Driver Crates → embedded-hal → HAL → PAC → Hardware

You've worked at every level. You know what each layer does and how to read its documentation.

The Workflow

Working example → Read the docs → Adapt → Extend

The example shows ONE way. The docs show ALL ways. Your job is to explore.

Where to Go From Here

Keep Learning with uFerris

Your uFerris board has more peripherals to explore:

  • SPI — faster serial communication (displays, SD cards)
  • ADC — read analog values (potentiometers, light sensors)
  • PWM — control LED brightness, servo motors
  • ESP-NOW — wireless communication between ESP32 devices

Same pattern: find the module in esp-hal docs, Create → Configure → Control.

Level Up with Async

Embassy is an async runtime for embedded Rust. Instead of interrupt handlers with mutexes, you write async/await code:

#![allow(unused)]
fn main() {
// Instead of interrupt handler + AtomicBool flag:
let level = button.wait_for_falling_edge().await;
led.toggle();
}

Check out embassy.dev and the embassy-executor crate.

Stay Connected

  • The Embedded Rustaceannewsletter + blog
  • Embedded Rust books by Omar Hiari
  • esp-rs Matrix channel — community help for ESP32 Rust
  • Rust Embedded Working Grouprust-embedded.github.io

Keep Your uFerris Board

The board is yours. Experiment, break things, build projects. The best way to learn is to have a problem you actually want to solve.


Thank you for attending. Now go build something.