The Peripheral Pattern: Create → Configure → Control

~15 min

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

The Pattern

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

1. Create — Instantiate the Driver

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

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

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

What to look for on docs.rs:

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

2. Configure — Set Up How It Behaves

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

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

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

What to look for on docs.rs:

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

3. Control — Read, Write, Toggle

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

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

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

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

What to look for on docs.rs:

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

Live Demo: GPIO Output on docs.rs

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

Create

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

Configure

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

Control

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

This Pattern Repeats Everywhere

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

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