GPIO and the
Type-State Pattern
A GPIO (General Purpose Input/Output) pin is a connection between the chip's digital logic and the physical world. At the silicon level, each GPIO pin on the RP2350 consists of several configurable components: an output driver (push-pull or open-drain), an input buffer (Schmitt trigger for noise immunity), programmable pull-up and pull-down resistors (nominally 50kΩ), and interrupt generation logic that can trigger on rising edges, falling edges, or both.
The RP2350 has 48 GPIO pins, each connected to a programmable IO block (PIO). Every pin can be configured as a digital input, digital output, or assigned to a peripheral function (I2C, SPI, PWM, UART). The multiplexer that selects which function controls a pin is called the "pad control" register. Embassy's HAL configures this register for you when you construct a peripheral type, but understanding what happens underneath helps you debug wiring and signal issues.
§ 7.2The type-state pattern encodes the current state of a resource in its type rather than in a runtime field. This means illegal state transitions become compile errors rather than runtime panics. Embassy's GPIO implementation is a classic example.
use embassy_rp::gpio::{Input, Output, Flex, Level, Pull}; // Output pin — type carries the Output state let mut led = Output::new(p.PIN_25, Level::Low); led.set_high(); // compiles — Output has set_high() // led.is_high() // COMPILE ERROR — Output does not have is_high() // You cannot read an output pin as if it were an input. // The type itself enforces directionality. // Input pin — type carries the Input state, with pull configuration let button = Input::new(p.PIN_15, Pull::Up); // internal pull-up enabled let level = button.is_high(); // compiles — Input has is_high() // button.set_high() // COMPILE ERROR — Input does not have set_high() // Flex pin — bidirectional, can be switched at runtime // Used for protocols like TM1637's DIO line (read and write) let mut dio = Flex::new(p.PIN_3); dio.set_as_output(); // reconfigure at runtime dio.set_high(); dio.set_as_input(); // switch direction let ack = dio.is_low(); // now read the ACK bit
An alternative design would be a single Gpio type with a direction: Direction field. Calling set_high() on an input-configured pin would be a runtime panic. With the type-state pattern, it is a compile error. Runtime panics in embedded code are dangerous because there may be no easy way to observe them in production. A panic on a deployed sensor node means silent failure. A compile error is caught before the firmware exists. The type-state pattern trades runtime panic risk for compile-time safety, at zero runtime cost — the direction is not stored in memory, it is encoded in the type that disappears after compilation.
§ 7.3A GPIO input pin that is not connected to anything is called a floating pin. Its value is undefined — it picks up electromagnetic noise from nearby signals, power supply ripple, and radio interference. It may read high, low, or oscillate unpredictably. This creates non-deterministic behaviour that is extremely difficult to debug.
Pull-up and pull-down resistors solve this by connecting the pin to VDD or GND through a high-value resistor (typically 10kΩ to 100kΩ on discrete components, nominally 50kΩ internally on the RP2350). The resistor is weak enough that an external signal can overcome it — a button press connecting the pin to GND will drive it low despite the pull-up — but strong enough to hold the pin at a defined level when disconnected. Always configure a pull direction on input pins unless you have a specific reason not to.
// Button wired between GPIO15 and GND → use Pull::Up // When button is open: pin → 3.3V through resistor = HIGH // When button is pressed: pin → GND = LOW // Logic is inverted: is_low() means button pressed let button = Input::new(p.PIN_15, Pull::Up); if button.is_low() { /* button pressed */ } // Sensor with active-high data line → use Pull::Down // Idle: sensor not driving = pin pulled to GND = LOW // Active: sensor drives high = pin = HIGH let sensor_data = Input::new(p.PIN_6, Pull::Down); // I2C lines: always need pull-ups (usually external, not internal) // I2C is open-drain — devices can pull low but not drive high. // The pull-up resistor pulls the line high when no device pulls low. // Internal pull-ups (50kΩ) are too weak for most I2C speeds. // Use external 4.7kΩ pull-ups for 100kHz, 2.2kΩ for 400kHz.
The naive approach to detecting a button press is to poll the pin in a loop: while button.is_high() {}. This burns 100% CPU waiting for a signal that might come seconds later. Embassy's GPIO provides async edge detection — you .await on the edge event, the task suspends, and the executor runs other tasks until the GPIO interrupt fires.
#[embassy_executor::task] async fn button_handler(mut button: Input<'static>) { loop { // Suspend this task entirely until the falling edge arrives. // 0% CPU while waiting. GPIO interrupt wakes this task. button.wait_for_falling_edge().await; // Edge detected — debounce: wait 20ms for signal to settle Timer::after_millis(20).await; // Verify it is still low (not a noise spike) if button.is_low() { defmt::info!("button pressed — confirmed"); handle_button_press().await; } // Wait for release before listening for next press button.wait_for_rising_edge().await; Timer::after_millis(20).await; // debounce release too } } // Available async edge methods: // wait_for_high() — wait until pin reads HIGH // wait_for_low() — wait until pin reads LOW // wait_for_rising_edge() — wait for LOW→HIGH transition // wait_for_falling_edge() — wait for HIGH→LOW transition // wait_for_any_edge() — wait for any transition
A push-pull output actively drives the line both high and low. When it outputs high, it connects the pin to VDD through an N-channel FET. When low, it connects to GND through a P-channel FET. This produces a strong, fast signal with low output impedance.
An open-drain output can only pull the line low (connect to GND) or leave it floating (disconnect). It cannot actively drive high. For the line to go high, an external pull-up resistor must do it. This is how I2C works — multiple devices share a bus, any one can pull low (asserting a 0 bit), and the pull-up brings the line high when nobody is pulling. Multiple push-pull outputs on the same wire would fight each other; open-drain outputs cooperate because they can only pull, never push. The TM1637's DIO line uses open-drain — your bit-banged implementation must respect this by only driving the line low and releasing it (tristate/input) for high.
§ 7.6Count button presses and display on TM1637
Wire a button between PIN_15 and GND. Write a task that counts rising-edge debounced button presses and displays the count on your TM1637 display. The count should increment only on confirmed presses (use the 20ms debounce pattern from §7.4). Reset to zero after 9999. Verify that rapid pressing (bouncing contacts) counts each distinct press only once. This exercise is the foundation of any user interface code in embedded systems.
Model a 2-wire protocol's states as types
Implement the type-state pattern for a bit-bang protocol. Create marker types Idle and Transmitting. Create a Bus<State> generic struct wrapping two Flex pins. Implement start(self) -> Bus<Transmitting> that returns a bus in the transmitting state. Implement write_bit(&mut self, bit: bool) and stop(self) -> Bus<Idle> only on Bus<Transmitting>. Verify that calling write_bit() on a Bus<Idle> is a compile error — the type-state machine enforces the protocol.