GPIO — General Purpose Input/Output

GPIO is how your microcontroller touches the physical world. Every LED you blink, every button you read, every sensor you talk to — it all starts with GPIO pins. Think of them as tiny, programmable electrical switches that you control with code.

What Exactly Is a GPIO Pin?

An STM32 chip has dozens (sometimes over a hundred) of metal pins sticking out of it. Many of these are General Purpose Input/Output pins — meaning you decide whether they send signals out or listen for signals coming in.

These pins are organized into ports, labeled GPIOA through GPIOK (depending on your chip). Each port holds up to 16 pins, numbered 0 through 15. So when someone says PA5, they mean Port A, Pin 5. PD12 is Port D, Pin 12. Simple as that.

💡 Fun Fact: The STM32H743 has 11 GPIO ports (A through K), giving it up to 176 GPIO pins. Your laptop's CPU has thousands of pins too, but you never touch them directly — that's what makes embedded programming special.

Pin Modes Explained

Every GPIO pin can be configured into one of several modes. Choosing the right mode is one of the first decisions you make when writing embedded code.

ModeDirectionDescriptionTypical Use
Input FloatingInPin reads whatever voltage is present, no internal biasSignals from other ICs with defined output
Input Pull-UpInInternal resistor pulls pin HIGH when nothing is connectedButtons that connect to GND when pressed
Input Pull-DownInInternal resistor pulls pin LOW when nothing is connectedButtons that connect to VCC when pressed
Output Push-PullOutPin actively drives HIGH or LOWLEDs, digital control signals
Output Open-DrainOutPin can pull LOW or float (needs external pull-up)I2C bus, level shifting, shared signal lines
Alternate FunctionSpecialPin is controlled by a peripheral (UART, SPI, I2C, etc.)Serial communication, PWM output
AnalogSpecialPin connects directly to ADC/DAC, digital logic disabledReading sensor voltages, audio output

🧠 Think About It: Why does "Input Floating" exist if it's unreliable? Because when another chip is actively driving the line HIGH or LOW, you don't want your internal pull resistor fighting it. Floating input is the polite listener — it doesn't impose its own opinion.

Output Speed

When you configure a pin as output, you also choose its speed — which controls how fast the voltage can transition between LOW and HIGH.

SpeedEdge RateWhen to Use
Low~2 MHzLEDs, relays, anything slow
Medium~12.5 MHzGeneral purpose signals
High~50 MHzSPI, SDIO
Very High~100 MHzHigh-speed interfaces, SDRAM

Higher speed means the pin switches faster, which is necessary for high-frequency communication. But faster edges also generate more electromagnetic noise and consume more power. Always pick the lowest speed that works for your application.

Blinking an LED with Embassy

Let's start with the embedded equivalent of "Hello, World" — blinking an LED.

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());

    // Configure PE3 as push-pull output, starting LOW
    let mut led = Output::new(p.PE3, Level::Low, Speed::Low);

    loop {
        led.set_high();
        Timer::after_millis(500).await;
        led.set_low();
        Timer::after_millis(500).await;
    }
}

That's it. No register manipulation, no volatile pointers, no unsafe blocks. Embassy's Output::new configures the pin mode, speed, and initial level in one call. The Timer::after_millis(500).await yields control back to the executor while waiting — your CPU can sleep instead of spinning.

Reading a Button

Reading a button is just as straightforward. Most buttons connect the pin to GND when pressed, so we use an internal pull-up resistor to keep the pin HIGH when the button is released.

use embassy_stm32::gpio::{Input, Pull};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());

    let button = Input::new(p.PC13, Pull::Up);

    loop {
        if button.is_low() {
            // Button is pressed (pulled to GND)
            defmt::info!("Button pressed!");
        }
        Timer::after_millis(10).await;
    }
}

This works, but it has a problem — we're polling. We check the button every 10 milliseconds whether anything happened or not. There's a better way.

Async Button with ExtiInput

Embassy can use the STM32's EXTI (External Interrupt) hardware to wake up only when a pin actually changes state.

use embassy_stm32::gpio::{Input, Pull};
use embassy_stm32::exti::ExtiInput;

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());

    let mut button = ExtiInput::new(p.PC13, p.EXTI13, Pull::Up);

    loop {
        // CPU sleeps here until the pin goes LOW
        button.wait_for_falling_edge().await;
        defmt::info!("Button pressed!");

        // Simple debounce: ignore further transitions for 50ms
        Timer::after_millis(50).await;

        // Wait for button release before accepting next press
        button.wait_for_rising_edge().await;
        Timer::after_millis(50).await;
    }
}

The wait_for_falling_edge().await call puts the task to sleep until the hardware detects the pin transitioning from HIGH to LOW. Zero CPU usage while waiting.

Button Debouncing

Mechanical buttons don't produce clean electrical transitions. When you press a button, the metal contacts physically bounce, creating 5 to 10 rapid transitions over a few milliseconds before settling.

What you expect:     HIGH ────────┐
                                  └──────── LOW

What actually happens: HIGH ──┐ ┌┐ ┌┐
                              └─┘└─┘└────── LOW
                              ← ~5ms →

Without debouncing, one press might register as multiple presses. The fix is simple: after detecting a transition, wait about 50 milliseconds before checking again. That's what the Timer::after_millis(50).await does in our code above.

💡 Fun Fact: Debouncing was a problem even in the 1960s. Early computer keyboards had hardware debounce circuits — tiny capacitor-resistor networks on every single key. Modern keyboards do it in firmware, just like we do.

Common LED Pins by Board

Different development boards wire their onboard LEDs to different pins. Here's a quick reference so you don't have to hunt through schematics every time.

BoardLED Pin(s)LED Active StateNotes
WeAct H743PE3LOW (active-low)Single blue LED
STM32F407 DiscoveryPD12, PD13, PD14, PD15HIGHGreen, Orange, Red, Blue
Black Pill (STM32F411)PC13LOW (active-low)Single blue LED
Blue Pill (STM32F103)PC13LOW (active-low)Single green LED
Nucleo-64 boardsPA5HIGHSingle green LED

🧠 Think About It: Notice that "active-low" means you set the pin LOW to turn the LED on. This is because the LED is wired between VCC and the pin — current flows (LED lights) when the pin pulls LOW. It's counterintuitive at first, but it's a common pattern in hardware design because GPIO pins can often sink more current than they can source.

What's Next?

You now know how to control the physical world — one pin at a time. But those pins need to switch at the right speed, and your UART needs the right baud rate, and your SPI needs the right clock. All of that depends on the clock system, which we'll dive into next.