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, I'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 abstractions, understand the abstraction layers, and know where to look for answers
  • Read embedded Rust documentation - in the documentation, in crate source code, and in example repositories
  • Apply the Instantiate → 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 - I 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 Baseboard

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.

ComponentQuantity
Buttons5
LEDs3
RTC1
LDR1
Switches2
Buzzer1
Qwiic Connector1
4-Digit Seven Segment Display1

Xiao ESP32-C3

Xiao ESP32-C3

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?

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


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 to hi@theembeddedrustacean.com with subject "RW2026: Workshop Setup Help" - I'll help you get sorted before May 18.

⚠ Do This Before the Workshop

There will be no time to debug setup issues during the workshop. If you arrive without a working setup, please use the Wokwi fallback so you can follow along. A native setup is strongly recommended — get it done ahead of time.

What You'll Set Up

In the upcoming pages you will install/setup the following:

  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

This guide assumes you have VS Code installed as your editor.

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 --locked

5. Generate a Test Project

Let's make sure everything works together:

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless hello_uferris
cd hello_uferris

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.

Checkpoint: If cargo build --release completes without errors, your toolchain is ready. Next: Hardware Verification.

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. Open the Project in VS Code

Open the test project you generated in the previous step:

code hello_uferris

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

3. Build, Flash, and Monitor

Open a terminal in VS Code (Terminal → New Terminal) and run:

cargo run

You should see the project build, flash to the board, and then serial output from the ESP32-C3. Press Ctrl+C to exit the monitor.

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

Troubleshooting

espflash can't find the serial port

If espflash fails to auto-detect your board, check that the device is recognized:

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

If no device appears, see below.

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

The workshop is designed for real hardware — that's the experience. Use this fallback only if your hardware setup fails and you can't resolve it before the workshop.

GitHub Codespace with Wokwi

The easiest way to get a working simulation environment is to use the Simplified Embedded Rust book project, which includes a pre-configured devcontainer with Wokwi support for the ESP32-C3.

Steps

  1. Go to the Simplified Embedded Rust book project branch
  2. Click Code → Codespaces → Create codespace on project
  3. Wait for the devcontainer to build (this includes the Rust toolchain and Wokwi extension)
  4. Replace the code in src/main.rs with your workshop exercise code
  5. Build and run in the Wokwi simulator

The Wokwi board definition in this repo simulates the ESP32-C3. Pin assignments may not exactly match the uFerris board — check the simulation diagram and adjust as needed.

Switching to Hardware

If 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. The code is the same — only the deployment target changes.

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. More importantly, it gives you a framework for learning anything new in the ecosystem after this workshop is over.

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?
  • Device and HAL crates are often underdocumented. Newer crates may have minimal docs, incomplete examples, or no usage guide at all. You need to know how to extract what you need from the source and API surface.
  • 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 crate documentation effectively
  • Adapt examples to different boards and use cases

What I'm Going to Teach You

A mental model:

Instantiate → 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.

Embedded Rust Development

~10 min

Embedded Development Options

Bare Metal RTOS OS-based

Bare Metal — Direct hardware access with no OS. Maximum determinism and minimal overhead, but you manage everything yourself. Best for simple, resource-constrained, or timing-critical applications.

RTOS — A lightweight real-time operating system provides task scheduling and timing guarantees. Adds some overhead but enables multitasking with predictable behavior. Suited for applications requiring multiple concurrent tasks with real-time constraints.

Embedded OS — A full operating system (e.g., Linux) running on the device. Highest overhead and lowest determinism, but provides rich functionality (networking, filesystems, drivers). Requires more capable hardware.

In this workshop, we'll be doing bare metal Rust.

Development with the Standard Library

"The Rust Standard Library is the foundation of portable Rust software, a set of minimal and battle-tested shared abstractions for the broader Rust ecosystem. It offers core types, like Vec<T> and Option<T>, library-defined operations on language primitives, standard macros, I/O and multithreading, among many other things."

Source: https://doc.rust-lang.org/std/

"Out of the Box" Rust is based on the Standard Library. It depends on system primitives provided by the OS system interface to implement functionality. It provides a runtime that sets up stack overflow protection, processes command-line arguments, and spawns the main thread. RTOS and Embedded OS can support it, but not bare metal.

Development with the Core Library

"The Rust Core Library is the dependency-free foundation of The Rust Standard Library. It is the portable glue between the language and its libraries, defining the intrinsic and primitive building blocks of all Rust code. It links to no upstream libraries, no system libraries, and no libc.

The core library is minimal: it isn't even aware of heap allocation, nor does it provide concurrency or I/O. These things require platform integration, and this library is platform-agnostic."

Source: https://doc.rust-lang.org/core/

The Core Library is a platform-agnostic subset of the Standard Library. It makes no assumptions about the underlying system and is necessary for bare metal implementations.

ESP supports both approaches. The std approach relies on the ESP-IDF framework, which has an underlying RTOS, through the esp-idf-hal crate — note that this is community supported only. In this workshop, we will be doing core (bare metal) development using the esp-hal, which is officially supported by Espressif.

Embedded Abstractions

~20 min

A Generic Peripheral Abstraction

A Generic Peripheral Abstraction

A peripheral abstraction, once created, needs methods to configure it and control it. The PAC provides memory-mapped interfaces for different peripherals to control pins — but this requires low-level knowledge and datasheet access. HALs take in the PAC abstractions and wrap them with user-friendly methods to perform the same operations without needing to manipulate registers. Note that this removes the need for datasheets to look at control and configuration specifics, but not the knowledge of how a peripheral works. You may still need to refer to a datasheet to understand certain properties of a peripheral for a particular controller.

The Full Rust "no-std" Stack

As shown in the figure below, the Rust no-std stack can comprise even more layers beyond just the PAC and HAL. Let's walk through each layer.

The Full Rust no-std Stack

Micro Architecture Crate

Provides low-level access to CPU-specific features: interrupt handling, system timer, core registers. For RISC-V (ESP32-C3), this is the riscv crate.

Not something you interact with directly in most application code, but it's the foundation everything else builds on.

#![allow(unused)]
fn main() {
// Example: enabling interrupts at the CPU level
unsafe { riscv::interrupt::enable() };
}

The Peripheral Access Crate

Auto-generated from SVD files. A Rust representation of every register in the chip. Type-safe but low-level. You can use it directly, but you almost never need to.

For ESP32-C3: the esp32c3 crate.

#![allow(unused)]
fn main() {
// PAC-level: writing directly to a register
peripherals.GPIO.out_w1ts
    .write(|w| unsafe { w.bits(1 << 5) });
}

The Hardware Abstraction Layer

Safe, ergonomic Rust APIs on top of the PAC. This is where we'll spend most of our time. For ESP32 chips: esp-hal.

Provides the Instantiate → Configure → Control pattern for every peripheral.

#![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 standard interface that any HAL can implement.

This is the contract between HALs and driver crates.

#![allow(unused)]
fn main() {
// Works on ANY microcontroller
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. Ideally drivers would all use embedded-hal traits but they don't need to. A driver that does use the traits though, is what makes it hardware-agnostic.

#![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();
}

Board Support Packages

The highest layer. 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:
let led = Output::new(
    peripherals.GPIO5,
    Level::Low,
    OutputConfig::default(),
);

// With BSP: the board knows
let led = board.led();
}

Embedded Patterns

~15 min

Mental Model

Embedded Development All Follows a Similar Pattern

Instantiate → Configure → Control

We will adopt this as a Mental Model to navigate the Rust Ecosystem

Peripheral Singletons

Microcontroller Diagram

The singleton pattern ensures that only one instance of each peripheral exists in your program. In a microcontroller, there is physically a single block for each peripheral — the software gives us access to only that one instance, not duplicates or copies of it.

At the PAC level, we use Peripherals::take() to claim ownership of all peripherals at once. This can only be called once — calling it again returns None. This guarantees exclusive access.

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

From that single call, we can access individual peripherals (one instance per block). We then use these PAC instances to create HAL-level driver instances, as shown below.

GPIO Instantiation Examples

Although the methods differ between HALs, they are all doing the same thing — taking a peripheral singleton and creating a GPIO output driver instance:

rp2040-hal

#![allow(unused)]
fn main() {
let pac = pac::Peripherals::take()
    .unwrap();
let sio = Sio::new(pac.SIO);
let pins = Pins::new(
    pac.IO_BANK0,
    pac.PADS_BANK0,
    sio.gpio_bank0,
    &mut pac.RESETS,
);
let led = pins.gpio25
    .into_push_pull_output();
}

esp-hal

#![allow(unused)]
fn main() {
let peripherals = esp_hal::init(
    Config::default()
);
let led = Output::new(
    peripherals.GPIO0,
    Level::High,
    OutputConfig::default(),
);
}

stm32f4xx-hal

#![allow(unused)]
fn main() {
let pac = Peripherals::take()
    .unwrap();
let gpioa = pac.GPIOA.split();
let led = gpioa.pa5
    .into_push_pull_output();
}

Configure

Once we have an instance, it gives us methods to configure it. Again, although the API style varies across HALs, they are all configuring a GPIO input with a pull-up resistor:

rp2040-hal

#![allow(unused)]
fn main() {
let button = pins.gpio15
    .into_pull_up_input();
}

esp-hal

#![allow(unused)]
fn main() {
let button = Input::new(
    peripherals.GPIO9,
    InputConfig::default()
        .with_pull(Pull::Up),
);
}

stm32f4xx-hal

#![allow(unused)]
fn main() {
let button = gpioa.pa0
    .into_pull_up_input();
}

Control

And finally, the instance provides methods to control the peripheral. All three HALs are checking if a button is pressed and setting an LED high — the syntax is nearly identical:

rp2040-hal

#![allow(unused)]
fn main() {
if button.is_high().unwrap() {
    led.set_high().unwrap();
}
}

esp-hal

#![allow(unused)]
fn main() {
if button.is_high() {
    led.set_high();
}
}

stm32f4xx-hal

#![allow(unused)]
fn main() {
if button.is_high() {
    led.set_high();
}
}

Things to Note

  • HAL Peripheral instances are drivers for microcontroller peripheral blocks

  • Off-controller device drivers (e.g. an I2C sensor) are a similar concept. They are instances that use microcontroller peripheral drivers to provide abstraction. Same concept as how peripherals use the PAC.

  • Configuration is sometimes passed as an argument to the instantiation statement rather than as a separate step

  • HALs often implement embedded-hal traits to provide device drivers with a common interface across controllers

Navigating Documentation

~15 min

Each peripheral typically gets its own module in a HAL crate. You can find the module list by navigating to the bottom of the crate's documentation page.

Navigate to the Peripheral Module

Find an Example for Your Peripheral

Simple examples are often shown directly in the module documentation. Most HAL repos also have a dedicated examples directory that you can browse on GitHub.

Find an Example

Identify the Peripheral Driver Struct

Use the example to apply the mental model. Identify the driver struct and how it is instantiated, configured, and controlled.

Identify the Driver Struct

Access the Driver Struct

Once you have identified the driver struct, navigate to its documentation page. There you will find the configuration and control methods available to you.

Access the Driver Struct

Part 2: GPIO

~60-75 min

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

You'll start with a live demo walkthrough, then use the documentation to navigate and adapt examples on your own.

What You'll Do

  1. Exercise A: Blinky - find the blinky example in esp-hal docs, adapt it to the uFerris LED pin
  2. Exercise B: Button Input - find the Input abstraction, configure pull-up, detect a button press
  3. Exercise C: Alternative Blink - find a different way to achieve the same blink behavior, without using the same approach as Exercise A
  4. Cross-HAL Comparison - inspect how rp2040-hal and stm32f4xx-hal handle the same GPIO tasks

GPIO Foundations

~10 min slides

What is GPIO?

General Purpose Input/Output

Standard digital interface to the outer world. The most basic peripheral: making pins go high or low, and reading whether they're high or low.

Configurations: Direction

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

Configurations: Output

Modes

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

Drive Strength

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

Configurations: Input

Internal Pull Resistor

  • 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

GPIO Exercises

~50 min

Exercise A: Blinky

  1. Find the blinky example in the esp-hal GPIO documentation
  2. Identify the Instantiate, Configure, and Control steps
  3. Adapt the pin to match the uFerris LED pin (check your pinout card)
  4. Flash and verify — the LED should blink

Exercise B: Button Input

  1. Find the Input abstraction in the esp-hal GPIO documentation
  2. Configure it with pull-up (since the hardware button pulls to ground)
  3. Find the method that detects when the button is pressed (low)
  4. Modify your blinky to turn on the LED when the button is pressed
  1. Look into the Output documentation in esp-hal
  2. Find a different way to achieve the same blink behavior, without using the same approach as in Exercise A
  3. Modify your code to blink the LED using this alternative approach

Cross-HAL Comparison

How do other HALs handle GPIO? Navigate the documentation for these two libraries and find how they approach the same tasks:

Compare how each HAL handles Instantiate, Configure, and Control for GPIO.

Exercise A: Blinky

~15 min

Goal

Get an LED blinking on the uFerris board by finding and adapting an existing example from the esp-hal documentation.

Steps

1. Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless gpio_blinky
cd gpio_blinky

2. Find the Example

Navigate to the esp-hal GPIO documentation.

Look for a blinky example — check:

3. Apply the Mental Model

Read the example and identify:

  • Instantiate — How is the Output created? What arguments does it take?
  • Configure — What configuration is applied? (Level, OutputConfig)
  • Control — What method is used to change the LED state?

4. Adapt to uFerris

  • Check your pinout card for the LED pin on the uFerris board
  • Update the GPIO pin in the example to match
  • Make sure the initial level matches your hardware (active high or active low?)

5. Build and Flash

cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/blinky --monitor

Verify: the LED should blink at a steady rate.

What to Notice

  • The Output::new() call combines Instantiate and Configure in one statement
  • Look at what other methods Output provides besides the one used in the example

Exercise B: Button Input

~20 min

Goal

Read a physical button press and use it to control the LED. You'll need to find the Input abstraction and configure it correctly for the uFerris hardware.

Steps

1. Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless gpio_button
cd gpio_button

2. Find the Input Abstraction

Navigate to the esp-hal GPIO documentation.

  • Find the Input struct
  • Read what arguments its constructor takes
  • Look at what configuration options are available

3. Understand the Hardware

The uFerris button is wired to pull the pin to ground when pressed:

  • When not pressed: pin floats (needs pull-up to read high)
  • When pressed: pin is pulled low

This means you need:

  • Pull-up resistor configuration (internal)
  • Detect a low level to know the button is pressed

4. Configure the Input

Apply the mental model:

  • Instantiate — Create an Input instance for the button pin
  • Configure — Set the pull resistor to pull-up

Check the InputConfig struct and its methods. How do you set the pull direction?

5. Read the Button and Control the LED

Find the method on Input that lets you read the current pin state. Use it to:

  • Detect when the button is pressed (low)
  • Turn on the LED when pressed
  • Turn off the LED when released

6. Build and Flash

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

What to Notice

  • How does Input configuration compare to Output configuration?
  • The Input struct has multiple methods for reading state

Exercise C: Alternative Blink

~15 min

Goal

Find a different way to achieve the same blink behavior, without using the same approach as in Exercise A.

Steps

1. Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless gpio_challenge
cd gpio_challenge

2. Explore the Output Documentation

Go back to the Output struct documentation and scroll through all the methods available on Output.

3. Find an Alternative

The blinky example in Exercise A used one specific approach to switch the LED on and off. Your task: find a different method that achieves the same result.

Hints:

  • There's more than one way to change pin state
  • Some methods combine multiple operations
  • Check both the inherent methods and the trait methods

4. Modify and Test

Update your blinky code to use the alternative approach. The LED should still blink at the same rate.

cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/blinky --monitor

What to Notice

  • How many different ways can you find to control an output pin?
  • Which approach results in simpler code?

Part 3: I2C

~60-75 min

In this module, you'll move beyond GPIO to a communication protocol — I2C.

What You'll Do

  1. Exercise A: Bus Scan - set up an I2C bus on the uFerris board and discover what devices are connected
  2. Exercise B: GPIO over I2C - use the TCA6424 I/O expander to control GPIO pins over I2C, replicating what you did with direct GPIO
  3. Exercise C: Adaptation Challenge - read a button input through the I/O expander, controlling an LED entirely over I2C
  4. Cross-HAL Comparison - inspect how rp2040-hal and stm32f4xx-hal handle I2C

I2C Foundations

~10 min slides

What is I2C?

I2C (or IIC) stands for Inter-Integrated Circuit. It is a two-wire bus protocol for communicating with sensors, displays, and other devices.

The two wires are:

  • SDA — Serial Data (bidirectional). Used to propagate data bits.
  • SCL — Serial Clock (driven by the controller). Used to propagate a clock to synchronize data exchanges on the bus.

Since it is a bus, multiple devices can share the same two wires. Each device has a unique address (7-bit) that the controller (master) can use to address any other device (slave) on the same bus.

Theory of Operation

I2C Architecture
  • Each bus has at least one master and one or more slaves that it communicates with. The master is the entity that orchestrates operations on the bus.
  • Typically, the microcontroller we are programming would be the master. The master addresses the slaves using the 7-bit address.
  • The exchange speed of the data is governed by the clock speed (propagated on the SCL line).
  • A master can perform two operations: either read or write.
  • I2C exchanges data in one-byte chunks.

Write Operations

A master writes data to a slave. You need to provide:

  • The address of the slave
  • An array of bytes that need to be written

Read Operations

A master reads data from a slave. You need to provide:

  • The address of the slave
  • A byte array buffer for the received data

Configurations

SettingDescription
Clock frequency100 kHz (standard), 400 kHz (fast), or custom
SDA/SCL pinsAny GPIO with I2C capability

I2C Exercises

~55 min

uFerris uses an I/O expander. An I/O expander is used to expand the number of GPIO pins on a controller. The expander communicates over I2C to configure and control GPIO pins. This means we can get many more GPIO using only two I2C lines!

The expander used is the TCA6424, and the first exercise would be to find out its address.

Exercise A: Bus Scan

  1. Find a ready I2C example in the esp-hal I2C documentation
  2. Identify the Instantiate, Configure, and Control steps
  3. Adapt the pins to match the uFerris board (check your pinout card for SDA and SCL)
  4. Scan all addresses (0x01–0x7F) by attempting a write to each — note which devices respond

Exercise B: GPIO over I2C

The uFerris board uses a TCA6424 I/O expander — its address should have appeared when scanning in Exercise A.

  1. Configure a pin on the I/O expander as output
  2. Blink an LED connected to the I/O expander

Exercise C: Adaptation Challenge

  1. Configure a pin on the I/O expander as input
  2. Read the button state through the I/O expander
  3. Use it to control the LED — same logic as the GPIO exercise, but entirely over I2C

Cross-HAL Comparison

How do other HALs handle I2C? Navigate the documentation for these two libraries and find how they approach the same tasks:

Compare how each HAL handles Instantiate, Configure, and Control for I2C.

Exercise A: Bus Scan

~15 min

Goal

Set up an I2C bus on the uFerris board and scan for connected devices.

Steps

1. Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless i2c_scan
cd i2c_scan

2. Find an I2C Example

Navigate to the esp-hal I2C documentation.

Look for an I2C example in:

3. Apply the Mental Model

Read the example and identify:

  • Instantiate — How is the I2c created? What peripheral and pins does it need?
  • Configure — What configuration is applied? (Clock speed, pins)
  • Control — What methods are available for reading/writing?

4. Adapt to uFerris

  • Check your pinout card for the I2C SDA and SCL pins
  • Update the pins in the example to match the uFerris board

5. Scan the Bus

Write a loop that attempts to communicate with every address from 0x01 to 0x7F:

  • For each address, try a zero-length write
  • If the write succeeds, a device is present at that address
  • Print the address of each device found

6. Build and Flash

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

What to Notice

  • Which addresses respond? Write them down — you'll need them in the next exercise
  • One of those addresses is the TCA6424 I/O expander
  • Compare the I2C Instantiate pattern to GPIO — what's similar? What's different?

Exercise B: GPIO over I2C

~25 min

Goal

Use the TCA6424A I/O expander on the uFerris board to control GPIO pins over I2C. This replicates what you did with direct GPIO — but through an I2C device.

Background

The TCA6424A is a 24-bit I/O expander connected via I2C. It provides three banks (ports) of 8 GPIO pins each (P0, P1, P2), giving you 24 additional GPIO pins over just two I2C wires. Its address should have appeared when you scanned the bus in Exercise A.

How I2C Communication Works with the TCA6424A

To interact with the I/O expander, communication happens in two cycles:

  1. First cycle (write): You send the register address you want to access — this tells the device which internal register you want to read from or write to.
  2. Second cycle (read or write): You either write a value to that register (to configure or control a pin) or read back the current value.

For a write operation, you send the register address followed by the data byte in the same I2C write transaction.

For a read operation, you first write the register address, then perform a separate I2C read to get the data back.

TCA6424A Register Map

RegisterPort 0Port 1Port 2Purpose
Input0x000x010x02Read pin levels
Output0x040x050x06Set output pin levels
Configuration0x0C0x0D0x0ESet pin direction: 0 = output, 1 = input (default)

Steps

1. Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless i2c_expander
cd i2c_expander

2. Configure Pin Direction

To use a pin as output, you need to write to the Configuration register for the appropriate port. Set the corresponding bit to 0 for output. Find the I2C abstraction in esp-hal that lets you write to a device at a specific address.

3. Set a Pin High

To turn on an LED, write to the Output register for the appropriate port. Set the corresponding bit to 1 for high. Use the same I2C write approach.

Combine the above in a loop to blink an LED connected to the I/O expander:

  1. Configure the port direction as output (once at startup)
  2. In a loop: set the pin high, delay, set the pin low, delay

5. Build and Flash

cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/i2c-expander --monitor

What to Notice

  • Compare the complexity of GPIO-over-I2C vs direct GPIO — what's the tradeoff?

Exercise C: Adaptation Challenge

~15 min

Goal

Extend your I/O expander setup to read a button input over I2C — replicating the GPIO button exercise, but entirely through the I2C I/O expander.

Steps

1. Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless i2c_challenge
cd i2c_challenge

2. Configure an Input Pin

Using the TCA6424 registers:

  • Set a pin's configuration bit to 1 (input)
  • This pin should be connected to a button on the uFerris board

3. Read the Button State

  • Read the input register for the relevant bank
  • Check the bit corresponding to your input pin
  • Detect when the button is pressed

4. Control the LED

Combine input reading with output control:

  • Read the button state from the I/O expander
  • When pressed, turn on the LED (on the I/O expander)
  • When released, turn off the LED

5. Build and Flash

cargo build --release
espflash flash target/riscv32imc-unknown-none-elf/release/i2c-button --monitor

What to Notice

  • The logic is identical to the direct GPIO exercise — only the hardware interface changed
  • All communication goes through I2C read/write operations
  • Think about latency: how does I2C polling compare to direct GPIO polling?
  • Could you use interrupts with the I/O expander? (The TCA6424 has an INT pin)

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 - configure the existing GPIO input button example to use interrupts instead of polling
  2. I/O Expander interrupt - use the TCA6424's INT output to detect input changes without polling over I2C

Interrupts

~15 min

Polling vs Interrupts

Polling

With polling, the CPU constantly checks the state of a peripheral in a loop. This is simple to implement but wastes CPU cycles — the processor is busy-waiting even when nothing is happening.

#![allow(unused)]
fn main() {
loop {
    if button.is_low() {
        // react to button press
    }
    // CPU is busy-waiting here, doing nothing useful
}
}

Interrupts

With interrupts, the hardware notifies the CPU when an event occurs. The CPU can sleep or do other work in the meantime, only waking when needed. This is more efficient but requires additional setup.

#![allow(unused)]
fn main() {
loop {
    // CPU can sleep or do other work
    // it is idle until an event occurs
}

// Hardware triggers this handler automatically
#[handler]
fn gpio_handler() {
    // react to the event
}
}

Components of Interrupt Code

Interrupt code is formed of three components:

  1. Global Shared Data — Any data that needs to be shared between the main thread and the interrupt handler
  2. Interrupt Setup — Configuring the interrupt per peripheral (what event to listen for, enabling it, moving data to shared state)
  3. Interrupt Service Routine (ISR) — The code that reacts to the interrupt event
// 1. Global Shared Data
static SHARED: Mutex<RefCell<Option<Input>>> =
    Mutex::new(RefCell::new(None));

fn main() {
    // ... peripheral setup ...

    // 2. Interrupt Setup
    button.listen(Event::FallingEdge);
    critical_section::with(|cs| {
        SHARED.borrow_ref_mut(cs).replace(button);
    });
}

// 3. Interrupt Service Routine
#[handler]
fn gpio_handler() {
    critical_section::with(|cs| {
        // handle event, clear interrupt
    });
}

Setup Happens in the Configure Stage

Setup entails three steps:

  1. Configuring the interrupt — What do we want to listen to?

    • Edge events (rising, falling, any)
    • Level events (high, low)
  2. Enabling the interrupt — Allowing the interrupt events to go through

    • Peripheral-level enable
    • Interrupt controller enable
  3. Configuring global shared data — Setting up shared state

    • Mutex<RefCell<Option<T>>> pattern
    • Moving peripherals into global statics via critical_section

Interrupt Exercises

~30 min

Exercise A: GPIO Interrupts

Configure the existing GPIO input button example to use interrupts instead of polling.

  • Check the esp-hal abstractions and code examples
  • See if you can find examples that you can adapt
  • Apply the mental model: find the Instantiate, Configure, and Control steps for interrupt-driven GPIO

Exercise B: I/O Expander Interrupt

The TCA6424 I/O expander has an INT output connected to a Xiao GPIO pin. Use it to get notified of input changes instead of polling over I2C.

  • Configure a GPIO interrupt on the INT pin
  • When triggered, read the I/O expander over I2C to determine what changed
  • This combines GPIO interrupts with I2C communication

Exercise A: Interrupt-Driven Button

~20 min

Goal

Configure the existing GPIO input button example from Part 2 to use interrupts instead of polling. Check the esp-hal abstractions and code examples to see if you can find any examples that you can adapt.

Steps

1. Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless gpio_interrupt
cd gpio_interrupt

2. Find Interrupt Examples

Search the GPIO Input documentation or the esp-hal examples directory for interrupt examples.

Look for how interrupts are set up — recall the three components from the slides:

  1. Interrupt Setup — configuring what event to listen for and enabling the interrupt
  2. Interrupt Service Routine — the code that reacts to the interrupt event
  3. Global Shared Data — any data shared between the main thread and the handler

3. Apply the Pattern

Convert your polling code to use interrupts:

  • Configure the interrupt (what event do you want to listen to?)
  • Enable the interrupt (allow events to go through)
  • Set up global shared data (how will the handler communicate with the main loop?)

4. Build and Flash

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

Press the button. The LED should toggle.

5. Explore Trigger Configurations

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

What to Notice

  • The interrupt setup happens in the Configure stage
  • You must always call clear_interrupt() in the handler
  • Compare: what can the main loop do now that it couldn't when polling?

Exercise B: I/O Expander Interrupt

~15 min

The Challenge

The TCA6424 I/O expander on the uFerris board has an INT output pin that connects to one of the Xiao GPIO pins. Instead of polling the I/O expander over I2C to check for button presses, use this interrupt line to get notified when an input changes.

Steps

  1. Create a new project
esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless expander_interrupt
cd expander_interrupt
  1. Find the INT pin — check your pinout card to see which Xiao GPIO pin the I/O expander's INT output is connected to.

  2. Configure a GPIO interrupt on that pin — the INT line is typically active-low, so configure for a falling edge trigger.

  3. In the interrupt handler, set a flag indicating that the I/O expander state has changed.

  4. In the main loop, when the flag is set, read the I/O expander's input register over I2C to determine which button was pressed, then react accordingly (e.g., toggle an LED on the expander).

What to Notice

  • This combines two peripherals: GPIO interrupts and I2C communication
  • The interrupt tells you something changed, but you still need I2C to find out what changed
  • Compare this to your earlier polling approach — what's the advantage?
  • The Instantiate → Configure → Control pattern applies to both the GPIO interrupt setup and the I2C I/O expander

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 used driver crates (I/O expander). There's one more layer in the stack: the Board Support Package.

A BSP allows us to skip the Configure step. The pattern becomes simply Instantiate → Control.

Same idea as HAL with PAC — BSP takes PAC singletons as input to create HAL instances and use them.

What You'll Do

  1. Redo earlier exercises using the BSP — repeat your GPIO and I2C exercises using the uFerris BSP instead of raw HAL calls
  2. Examine the adapter layer — look at how the ESP32-C3 adapter was created

What Is a BSP?

~5 min slides

The Top of the Stack

A Board Support Package encapsulates board-specific knowledge — pin assignments, peripheral configuration, and hardware defaults.

BSP allows us to skip the configure step. Pattern becomes Instantiate → Control.

Same idea as HAL with PAC — BSP takes PAC singletons as input to create HAL instances and use them.

Without BSP (raw HAL)

#![allow(unused)]
fn main() {
// Instantiate
let peripherals = esp_hal::init(Config::default());

// Configure
let led = Output::new(
    peripherals.GPIO5,
    Level::Low,
    OutputConfig::default(),
);

// Control
led.set_high();
}

With BSP

#![allow(unused)]
fn main() {
// Instantiate (BSP takes HAL peripherals as input)
let peripherals = esp_hal::init(Config::default());
let mut uferris = uferris_init(peripherals).unwrap();

// Control — no configure step needed
uferris.led1_on();
}

The BSP still requires instantiation — you pass the HAL peripherals into uferris_init(). But the board-specific configuration (which pin, what drive strength, which pull resistor) is all handled internally. You go straight from Instantiate to Control.

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.

BSP Exercises

~20 min

Exercise: Redo with the BSP

Repeat the earlier GPIO and I2C exercises using the uFerris BSP

  • Redo blinky, button input, and I2C exercises using BSP abstractions instead of raw HAL
  • Notice how the BSP skips the Configure step — pattern becomes Instantiate → Control
  • Examine the adapter layer to see how it was created for the ESP32-C3

Exercise: Explore the uFerris BSP

~15-20 min

Goal

Redo your earlier exercises using the uFerris BSP instead of raw HAL calls. See how the BSP simplifies the Instantiate → Configure → Control pattern down to Instantiate → Control.

Steps

Step 1: Create a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless bsp_blinky
cd bsp_blinky

Step 2: Read the BSP 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 Instantiate → 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 3: Redo Blinky with BSP

Refactor your blinky exercise to use the BSP. You still need to instantiate the board, then get the LED from it. Notice how the pin number and configuration are no longer your concern.

Step 4: Redo Button Input with BSP

Refactor the button exercise. How does the BSP handle:

  • Pin assignment?
  • Pull-up configuration?

Step 5: Redo I2C with BSP

Refactor the I2C exercise. The BSP should provide a pre-configured I2C bus — no need to specify pins or clock speed.

Step 6: Examine the Adapter Layer

Look at the BSP source code for the ESP32-C3 adapter:

  • How are HAL types mapped to board-level names?
  • What abstractions does the adapter provide?
  • Could you write an adapter for a different Xiao module?

What to Notice

  • The BSP skips Configure — that's the whole point
  • The pattern becomes simply Instantiate → Control
  • Every layer builds on the one below it

Cheatsheet

uFerris Pinout Reference

uFerris Board

Signal NameXiao PinI/O Expander PinDirectionESP32-C3 Pin
LED 1D1/A1-OutputGPIO3
LED 2-P14Output-
LED 3-P15Output-
BuzzerD2/A2-OutputGPIO4
SW1-P07Input-
SW2-P06Input-
SW3-P05Input-
SW4-P04Input-
SW5D3-InputGPIO5
SW6 Pos 1-P16Input-
SW6 Pos 2-P17Input-
SW7 Pos 1-P00Input-
SW7 Pos 2-P01Input-
SDAD4-CommsGPIO6
SCLD5-CommsGPIO7
LDRD0/A0-AnalogGPIO2
Digit 1-P10Output-
Digit 2-P11Output-
Digit 3-P12Output-
Digit 4-P13Output-
Seg A-P20Output-
Seg B-P21Output-
Seg C-P22Output-
Seg D-P23Output-
Seg E-P24Output-
Seg F-P25Output-
Seg G-P27Output-
DP-P26Output-
XiaoTxD6nINT-GPIO21
XiaoMosiD10--GPIO10
XiaoMisoD9--GPIO9
XiaoSclD8--GPIO8
XiaoRxD7--GPIO20

Install esp-generate

cargo install esp-generate --locked

Start a New Project

esp-generate --chip esp32c3 -o unstable-hal -o vscode -o esp-backtrace -o log --headless [project_name]


TCA6424A Register Reference

RegisterPort 0Port 1Port 2Purpose
Input0x000x010x02Read pin levels
Output0x040x050x06Set output pin levels
Configuration0x0C0x0D0x0EPin dir: 0=out, 1=in

Wrap-Up & Next Steps

What You Learned Today

The Mental Model

Instantiate → Configure → Control - it works for every peripheral, every HAL, every driver crate.

StepWhat You DoWhere in the Docs
InstantiateCreate a driver instanceStruct 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 know what each layer does and how to read its documentation.

The Skills

  • Navigate crate documentation to find peripheral modules, examples, driver structs, and methods
  • Apply the mental model across different HALs — you compared esp-hal, rp2040-hal, and stm32f4xx-hal
  • Use the BSP layer to simplify the pattern to Instantiate → Control

The Workflow

Find a basic example → Apply the mental model → Map to documentation → Modify

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, Instantiate → Configure → Control.

Dig Deeper

For a more comprehensive guide, check out the Simplified Embedded Rust: ESP Core Library Edition book.

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

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.