technique

GPIO: buttons and LEDs

Driving outputs and reading inputs on ESP32 pins in all four languages, with pull-ups, debouncing, interrupts, and the pins you must not use.

Before this

This page assumes you are comfortable with:

Why you need this

A button and an LED are the smallest useful gadget: one input, one output. Every bigger project still uses them, for a status light, a reset-to-setup button, or a screen's backlight. This page is stage 4 of the pipeline on the hub, "Talk to hardware". It shows the same two jobs in MicroPython, CircuitPython, C with ESP-IDF, and assembly, and it lists the pins that will stop your board from booting if you wire them carelessly.

The idea

GPIO means general-purpose input/output: a pin your program controls. Each pin is in one of two modes at a time.

  • Output: your program sets the pin to 1 (3.3 V) or 0 (0 V). An LED with a resistor on the pin lights when current flows.
  • Input: your program reads whether the voltage on the pin is high (1) or low (0).

An input with nothing driving it floats: it picks up noise and reads random values. Digital logic and pull-up resistors explains the fix: a pull-up resistor to 3.3 V holds the pin at 1 until a button connects it to ground, which makes it 0. That wiring is active low: pressed reads 0. Most ESP32 pins have an internal pull-up you can switch on in code, so a button needs no extra parts.

Outputs can be active low too. On the CYD (ESP32-2432S028), the author's pinout says the RGB LED's red, green, and blue sit on GPIO 4, GPIO 16, and GPIO 17, and each turns on when the pin is 0. The backlight on GPIO 21 is the opposite: active high, so 1 turns the screen's light on.

Bounce

A mechanical button does not close cleanly. Its contacts bounce for a short time, so one press can read as 0, 1, 0, 1, 0 before settling. Code that counts presses counts several. Debouncing in software means accepting a new value only after it has stayed the same for a while, a settle window you choose to be longer than the bounce you see.

Edges and interrupts

An edge is the moment a pin changes: a falling edge goes 1 to 0 (an active-low press), a rising edge goes 0 to 1. Instead of reading the pin in a loop (polling), you can ask the chip to run a short function, an interrupt handler, on an edge. Polling and interrupts covers the trade. The rule for handlers: set a flag and return; do the real work in the main loop.

Pins you must not use

These come from the author's board notes and pinouts for the classic ESP32.

Pins Why
GPIO 6 to 11 wired to the module's flash chip; "do not use"
GPIO 0, 2, 5, 12, 15 strapping pins: read at reset to pick the boot mode. GPIO 0 low at reset enters flash mode; GPIO 12 high at reset selects 1.8 V flash and the board will not boot
GPIO 34, 35, 36, 39 input only, and no internal pull-ups or pull-downs
GPIO 1 and 3 UART0, the serial console

A strapping pin is one the chip samples during reset to decide how to start. A button or LED circuit that holds one at the wrong level at power-on changes how the board boots. Other chips differ: the author's P4 notes list strapping pins 34 to 38, so check the board's own notes every time.

Worked example

The task: an LED that lights while a button is held, on a WROOM-32 DevKit. From the author's DevKit pinout, GPIO 32 is a plain pin (not strapping, not input-only, not flash) for the button, and GPIO 26 is a plain pin for the LED.

The LED resistor. From Voltage, current, and Ohm's law: with a 2.0 V red LED at 10 mA from 3.3 V,

R=3.3−2.00.010=130 ΩR = \frac{3.3 - 2.0}{0.010} = 130\ \Omega

Round up to the next common value, 150 Ω: the current becomes 1.3/150≈8.71.3 / 150 \approx 8.7 mA, and the resistor dissipates 1.32/150≈111.3^2 / 150 \approx 11 mW.

The button. One side to GPIO 32, the other to GND, internal pull-up on. Released, the pin reads 1. Pressed, it reads 0. Espressif's ESP32 datasheet gives the internal pull-up as about 45 kΩ, so pressing draws about 3.3/45,000≈0.0733.3 / 45{,}000 \approx 0.073 mA. (With an external 10 kΩ pull-up instead, pressing would draw 3.3/10,000=0.333.3 / 10{,}000 = 0.33 mA.)

The debounce. Read the pin every 5 ms and accept a new value after 4 identical readings in a row, so a press must be steady for 4×5=204 \times 5 = 20 ms to count:

Time (ms) Raw read Same-in-a-row count Accepted value
0 1 4 1 (released)
5 0 1 1
10 1 1 1 (a bounce)
15 0 1 1
20 0 2 1
25 0 3 1
30 0 4 0 (pressed)

MicroPython

The author's simplest output is ESP_32_DAD's main.py, excerpted here (author's project, hardware-proven on a CYD). It blinks the CYD's active-low red LED.

from machine import Pin
import time

led = Pin(4, Pin.OUT, value=1)  # GPIO 4 = red LED, start OFF (active low)

while True:
    led.value(0)        # ON
    time.sleep_ms(500)
    led.value(1)        # OFF
    time.sleep_ms(500)

None of the author's projects reads a plain button, so this button-and-LED version was written for this page (not hardware-tested). It debounces as in the table and also counts falling edges with an interrupt whose handler only bumps a counter.

from machine import Pin
import time

led = Pin(26, Pin.OUT, value=0)
btn = Pin(32, Pin.IN, Pin.PULL_UP)
presses = 0

def on_fall(pin):
    global presses
    presses += 1          # raw edges, bounces included

btn.irq(trigger=Pin.IRQ_FALLING, handler=on_fall)

stable, count, last = 1, 0, 1
while True:
    v = btn.value()
    count = count + 1 if v == last else 1
    last = v
    if count >= 4 and v != stable:
        stable = v
        led.value(1 if stable == 0 else 0)   # pressed = 0 = LED on
    time.sleep_ms(5)

Keep led and btn in named variables for the whole program. The author's notes warn that MicroPython can garbage collect a Pin object nobody holds, which is how a CYD backlight turns off at random.

CircuitPython

Written for this page (not hardware-tested), for an Adafruit Feather ESP32-S3, whose boot button is on GPIO 0 and red LED on GPIO 13 per the author's board notes:

import digitalio, microcontroller, time

led = digitalio.DigitalInOut(microcontroller.pin.GPIO13)
led.direction = digitalio.Direction.OUTPUT
btn = digitalio.DigitalInOut(microcontroller.pin.GPIO0)
btn.switch_to_input(pull=digitalio.Pull.UP)

while True:
    led.value = not btn.value     # pressed reads False
    time.sleep(0.005)

C with ESP-IDF

Written for this page (not hardware-tested), using ESP-IDF's GPIO driver on the DevKit pins:

#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void) {
    gpio_set_direction(GPIO_NUM_26, GPIO_MODE_OUTPUT);
    gpio_set_direction(GPIO_NUM_32, GPIO_MODE_INPUT);
    gpio_set_pull_mode(GPIO_NUM_32, GPIO_PULLUP_ONLY);
    while (1) {
        gpio_set_level(GPIO_NUM_26, gpio_get_level(GPIO_NUM_32) == 0);
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

Assembly

This listing is Xtensa LX6 assembly for the classic ESP32, excerpted from ESP_32_DAD's blink_asm, an assembly blink written for the CYD (author's project; the project's notes record its MicroPython blink running on the board, not this file specifically). It drives GPIO registers directly.

# GPIO registers (memory-mapped, ESP32 Technical Reference Manual):
#   0x3FF44024  GPIO_ENABLE_W1TS_REG  - write 1s to enable GPIO output
#   0x3FF44008  GPIO_OUT_W1TS_REG     - write 1s to drive pin HIGH
#   0x3FF4400C  GPIO_OUT_W1TC_REG     - write 1s to drive pin LOW
app_main:
    entry   sp, 32                  # Xtensa function prologue, 32-byte frame
    movi    a2, 0x3FF44024          # GPIO_ENABLE_W1TS_REG
    movi    a3, 0x10                # bit 4 = GPIO4
    s32i    a3, a2, 0               # enable GPIO4 output direction
blink_loop:
    movi    a2, 0x3FF4400C          # GPIO_OUT_W1TC_REG  (clear = drive LOW)
    s32i    a3, a2, 0
    movi    a4, 40000000
delay_on:
    addi    a4, a4, -1
    bnez    a4, delay_on
    ...

W1TS means "write 1 to set" and W1TC "write 1 to clear": writing the mask 0x10 (1≪41 \ll 4, bit 4) changes only GPIO 4 and leaves every other pin alone. The delay is a counted loop: the file's comments estimate about 3 cycles per pass at 240 MHz, so 500 ms is 240,000,000×0.5=120,000,000240{,}000{,}000 \times 0.5 = 120{,}000{,}000 cycles, or 120,000,000/3=40,000,000120{,}000{,}000 / 3 = 40{,}000{,}000 passes.

In an ESP32 project

Every one of the author's CYD projects starts by setting GPIO outputs before anything else: TeachingMachineCYD sets the backlight high and parks the RGB LED pins high (off, since they are active low). On the CYD the BOOT button is GPIO 0, active low, so it doubles as a user button once the board is running, as long as nothing holds it low at reset. The language changes the syntax, not the steps: pick the mode, set the pull, then read or write.

Common mistakes

  • Floating input. Symptom: the LED flickers with no one touching the button. Turn on the pull-up.
  • Button on a strapping pin. Symptom: the board enters flash mode or will not boot when powered with the button held, or with a circuit pulling the pin. Move it to a plain pin.
  • Pull-up on GPIO 34 to 39. Symptom: the input still floats. These pins have no internal pulls; add a 10 kΩ resistor.
  • Active-low confusion. Symptom: the CYD's LED is on when you expect it off. Writing 0 turns it on.
  • Pin object collected. Symptom: in MicroPython, an LED or backlight turns off at a random time. Hold the Pin in a variable.
  • No debounce. Symptom: one press counts as two or three.

Cost

GPIO costs almost nothing in memory: one pin object in Python, a few instructions in C or assembly. Speed differs by language. The assembly writes a register in one store instruction; MicroPython's led.value() goes through the interpreter and is far slower, which only matters for very fast signals. The polling debounce wakes every 5 ms; an interrupt lets the CPU idle between presses, which saves power on a battery. The busy-wait delay in the assembly blink keeps the CPU at full power doing nothing for the whole half second. Edit-to-run time is seconds in MicroPython and CircuitPython and a build-and-flash cycle in C and assembly.

Going further

Back to ESP32 development: assembly, C, MicroPython, and CircuitPython