Empowered by the Ecosystem
Learning Embedded Rust with uFerris
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:
- Read — We give you a working example in the workshop repo
- Understand — You study the code and map it to the documentation
- Adapt — You modify the example to do something different by discovering new options in the docs
- Extend — Stretch goals push you to navigate unfamiliar documentation independently
The Hardware: uFerris Megalops
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
| Component | Description | Interface |
|---|---|---|
| ESP32-C3 Xiao | RISC-V MCU with WiFi + BLE | USB-C |
| LEDs | Onboard LEDs for GPIO output | GPIO |
| Buttons | User-accessible push buttons | GPIO (with pull-up) |
| IMU (ICM-42670) | 6-axis accelerometer + gyroscope | I2C |
| Additional I2C sensors | Temperature, 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
espflashinstalled (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
- Rust toolchain with the RISC-V target for ESP32-C3
- espflash for flashing firmware to the board
- esp-generate for creating new projects
- A test flash to verify everything works end-to-end
- 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
- Try a different USB cable (must support data, not just charging)
- Try a different USB port
- Press and hold the BOOT button on the ESP32-C3, then press RESET, then release BOOT — this forces download mode
- 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.
Option 1: VS Code Extension (Recommended)
Install the Extension
- Open VS Code
- Go to Extensions (
Ctrl+Shift+X/Cmd+Shift+X) - Search for "Wokwi Simulator"
- 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:
- Open an example project in VS Code
- Build it:
cargo build --release - Press
F1→ "Wokwi: Start Simulator" - 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:
| Organization | What They Do |
|---|---|
| rust-embedded | Core embedded Rust infrastructure — embedded-hal traits, the Discovery book, cortex-m crates |
| esp-rs | Everything ESP32 — esp-hal, esp-radio, esp-alloc, tooling |
| embassy-rs | Async 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 Need | Where to Look |
|---|---|
| Crate discovery | crates.io — search for driver crates, HALs |
| API documentation | docs.rs — auto-generated docs for every published crate |
| ESP32-C3 specific docs | docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/ |
| Examples & source code | GitHub repos — esp-rs/esp-hal, driver crate repos |
| Curated resources | awesome-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-haltraits (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:
- esp-hal implements
embedded-haltraits for ESP32-C3 hardware - Driver crates are written against
embedded-haltraits - 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_haltrait 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 initialLevel, and anOutputConfig
Configure
OutputConfighas methods like:.with_drive_strength(DriveStrength)— how much current can the pin source/sink?.with_open_drain(bool)— push-pull or open-drain output?
DriveStrengthenum: how many options are there? Look it up!
Control
- Inherent methods:
set_high(),set_low(),toggle(),set_level(Level) - Trait implementations: implements
OutputPinfromembedded_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
| Peripheral | Create | Configure | Control |
|---|---|---|---|
| GPIO Output | Output::new() | OutputConfig | set_high(), toggle() |
| GPIO Input | Input::new() | InputConfig, Pull | is_high(), get_level() |
| I2C | I2c::new().with_sda().with_scl() | Config, Rate | write(), read() |
| SPI | Spi::new().with_mosi().with_sck() | Config, Mode | transfer(), write() |
| UART | Uart::new().with_tx().with_rx() | Config, BaudRate | write(), 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()orbuilder()— 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) | |
|---|---|---|
| Create | Takes pins + peripheral instances | Takes an I2c or Spi bus (generic!) |
| Configure | Chip-specific settings (pin mode, clock speed) | Device-specific settings (sample rate, range) |
| Control | Low-level operations (write bytes, read registers) | High-level operations (read acceleration, read temperature) |
| Trait impls | Implements embedded-hal traits | Uses 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
- Blinky — adapt the workshop's blinky example for your uFerris board
- Explore configurations — discover GPIO options you didn't know existed by reading the docs
- 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
| Method | What 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
| Method | What It Does |
|---|---|
is_high() → bool | Is the pin high? |
is_low() → bool | Is the pin low? |
get_level() → Level | Get 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:
| Function | Pin | Notes |
|---|---|---|
| Onboard LED | GPIO? | Active high/low? |
| User Button | GPIO? | Needs pull-up? |
| I2C SDA | GPIO? | Used in Part 3 |
| I2C SCL | GPIO? | 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:
| Step | What to Look For | Where on docs.rs |
|---|---|---|
| Create | Output::new(pin, level, config) | Struct page → impl Output |
| Configure | OutputConfig, InputConfig, Pull, DriveStrength | Associated types / linked structs |
| Control | set_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.
- Check your pinout card — which GPIO pin is connected to the onboard LED?
- Open the docs — look up
Output::new()in the esp-hal GPIO documentation. What parameters does it take? - Change the pin to match your uFerris board
- 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:
-
What methods does
OutputConfighave? Look at theimpl OutputConfigsection. -
Drive Strength: Find the
DriveStrengthenum. 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)
-
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.
-
Create an input: Check the pinout card — which pin is the button?
#![allow(unused)] fn main() { let button = Input::new(peripherals.GPIOXX, InputConfig::default()); } -
Explore
Pulloptions: Look up thePullenum. 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
- If the button connects the pin to GND when pressed → you need
- Try the wrong pull setting. What happens?
- Which pull setting does your button need? Think about the circuit:
-
Read the button state: Don't just use
is_low(). Look at what other methods are available:is_high()→boolis_low()→boolget_level()→Level
Try using
get_level()with amatchstatement:#![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
OutputConfigsetting -
Created a button
Inputwith an appropriatePullsetting -
Read the button state using
get_level()(not justis_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
DriveStrengthvariants - Open-drain mode (does it work without external pull-up?)
- Different
Pullsettings 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:
- esp_hal::gpio module
- Your uFerris pinout card
- The Create → Configure → Control pattern
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
- Bus scan — discover what I2C devices are connected to your uFerris board
- Driver crate — use a third-party driver to read real sensor data
- 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
| Concept | What It Means |
|---|---|
| Controller | The device that initiates communication (our ESP32-C3) |
| Target | The device being addressed (sensors on the uFerris board) |
| Address | 7-bit identifier for each device (e.g., 0x68) |
| Write | Controller sends data to a target |
| Read | Controller reads data from a target |
| Clock Speed | How 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
I2cimplementation - You can switch chips without changing your driver code
- This is the whole point of the abstraction layers from Part 1
Configurations
| Setting | Options | docs.rs Location |
|---|---|---|
| Clock frequency | 100 kHz, 400 kHz, custom | Config::with_frequency() |
| SDA/SCL pins | Any GPIO with I2C capability | .with_sda(), .with_scl() |
| Timeout | Bus timeout duration | Config::with_timeout() |
Controls
| Method | What 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?
-
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?
-
Timeout: Is there a timeout setting? What happens if you change it?
-
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:
-
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)
-
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)
-
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?
- Create:
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
- Identify the second sensor from your bus scan results
- Search crates.io for a driver crate — does one exist?
- Read the driver crate's docs.rs page — find
new(), figure out what it needs - Add it to your project —
cargo add sensor-name - 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
- Interrupt-driven button — convert your polling button code to use interrupts
- Adaptation challenge — handle multiple inputs, explore different trigger configurations
- 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?
| Trigger | When It Fires |
|---|---|
Event::FallingEdge | Pin goes from HIGH → LOW (button press with pull-up) |
Event::RisingEdge | Pin goes from LOW → HIGH (button release with pull-up) |
Event::AnyEdge | Any 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
| Piece | Purpose |
|---|---|
Mutex<RefCell<Option<T>>> | Safe shared access between main code and interrupt handler |
critical_section::with | Temporarily 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:
- Shared state —
static BUTTON: Mutex<RefCell<Option<Input>>> - Moving the peripheral —
BUTTON.borrow_ref_mut(cs).replace(button) - The handler —
#[handler] fn gpio_handler() - Clearing the interrupt —
button.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:
-
Switch from
FallingEdgetoRisingEdge— what changes? Does the LED toggle on press or release now? -
Try
AnyEdge— the LED should toggle on both press and release. What happens? -
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); }
- In the handler: set the flag to
true(and clear the interrupt) - 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
-
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)
-
How does sharing work? You need another
static Mutex<RefCell<Option<Input>>>for the second pin. Or can you reuse the same handler? -
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
Eventtriggers 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:
- Find the timer peripheral in esp-hal docs
- Create → Configure → Control — set up a timer
- 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 BSP | With BSP |
|---|---|
Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default()) | board.led() |
| "Which pin is SDA again?" + checks pinout card | board.i2c() |
| Configure each peripheral manually | Pre-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 When | Use Raw HAL When |
|---|---|
| Quick prototyping | You need non-default configurations |
| Your board has a BSP | You're using custom hardware |
| You don't need fine control | You 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:
- How does the BSP map pin numbers to named functions?
- What configuration choices has the BSP author made? (Drive strength? Pull resistors? I2C speed?)
- 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:
| Layer | What You Did |
|---|---|
| HAL | Configured GPIO pins, I2C buses manually |
| embedded-hal | Used traits that make driver crates portable |
| Driver crates | Read sensor data with hardware-agnostic drivers |
| BSP | Used 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 crate | crates.io → search |
| Read crate docs | docs.rs/CRATE_NAME |
| ESP32-C3 specific docs | docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/ |
| See examples | GitHub repo → examples/ directory |
| Find a driver crate | crates.io → search sensor name |
On a Struct Page
- Top section — struct definition, what it is
impl StructName— constructors (new()) and inherent methodsimpl Trait for StructName— portable methods (embedded-hal)- 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
| Type | Key Variants |
|---|---|
Level | High, Low |
Pull | Up, 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
| Trigger | Fires When |
|---|---|
Event::FallingEdge | HIGH → LOW |
Event::RisingEdge | LOW → HIGH |
Event::AnyEdge | Any 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
Useful Links
| Resource | URL |
|---|---|
| esp-hal docs (ESP32-C3) | docs.espressif.com/projects/rust/esp-hal/latest/esp32c3/esp_hal/ |
| crates.io | crates.io |
| docs.rs | docs.rs |
| esp-hal GitHub | github.com/esp-rs/esp-hal |
| The Embedded Rustacean | theembeddedrustacean.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.
| Step | What You Do | Where in the Docs |
|---|---|---|
| Create | Instantiate a driver | Struct page → new() or builder |
| Configure | Set behavior options | Config structs, enums |
| Control | Read/write/interact | Trait 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 Rustacean — newsletter + blog
- Embedded Rust books by Omar Hiari
- esp-rs Matrix channel — community help for ESP32 Rust
- Rust Embedded Working Group — rust-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.