Digital pins

GPIO (general-purpose input/output) is a pin on a microcontroller or single-board computer whose function is decided by software: the firmware configures it as a digital input or a digital output, and reads or sets its level. Most other functions — a serial port, a PWM output, an analog input — are alternative functions that the same pins can take on. An ESP32 development board brings out a few dozen of them; a Raspberry Pi has a 40-pin header, of which most are GPIO.

As an output, a pin is driven to 3.3 V (high, logic 1) or to 0 V (low, logic 0): enough to light an LED or to signal another chip, not to power a motor (see actuators). As an input, the pin draws almost no current and reports whether the voltage on it counts as high or low. In MicroPython that looks like this:

from machine import Pin

led = Pin(2, Pin.OUT)                 # pin numbers depend on the board
button = Pin(4, Pin.IN, Pin.PULL_UP)  # input with the internal pull-up on

led.value(1)                          # LED on
print(button.value())                 # 1 = released, 0 = pressed

On a microcontroller, setting a pin is a write to a hardware register and takes well under a microsecond. On a Raspberry Pi the same thing goes through Linux, a driver and a library — still fast, but with less predictable timing, which matters for the topics further down this page.

Logic levels

A logic level is the voltage range a digital circuit treats as 0 or 1. A chip with 3.3 V logic reads a voltage close to 0 V as low and one close to 3.3 V as high; in between is a band where the result is undefined. The datasheet gives the exact thresholds and, just as important, the absolute maximum voltage a pin may ever see.

Board Logic level 5 V on a pin?
ESP32 (all variants) 3.3 V no — above the absolute maximum rating
Raspberry Pi (all models) 3.3 V no — "Do not use 5V for 3.3V components"
Raspberry Pi Pico (RP2040) 3.3 V no
Raspberry Pi Pico 2 (RP2350) 3.3 V digital pins tolerate 5 V while the chip is powered; analog pins do not
Arduino Uno R3 and R4 5 V yes — these are 5 V boards

Level shifters

When a 3.3 V board must talk to a 5 V device, a level shifter sits in between: a small circuit that translates a digital signal from one logic level to another, in one or both directions. Ready-made level shifter boards with a few transistors are cheap; for a single input that only goes from 5 V to 3.3 V, a voltage divider of two resistors also works. A 3.3 V output into a 5 V input often works without help, because 3.3 V is usually above the 5 V chip's "high" threshold — but check its datasheet rather than hoping.

Example: the P1 data line

Lotte Kortenhoeve's ESP32 reads her Dutch smart meter's P1 port, and that port shows why logic levels need attention. Its data output is an open-collector output: the meter can only pull the line to ground or let go of it, so the receiver must provide a pull-up resistor (see the next section and Bus Protocols: UART, I2C and SPI). The signal is also logically inverted: the idle state (a serial "1") is below 1 V, and data pulses go high. With the 5 V pull-up that many examples show, those pulses exceed 4 V — too much for a 3.3 V pin. Because the output only ever pulls down, Lotte's board pulls the line up to its own 3.3 V instead, and the firmware inverts the received signal. The details, including the meter's 5 V supply pin, are on Smart Meters and the P1 Port.

Pull-up resistors

An input pin that is not connected to anything is said to float: it picks up interference from nearby wires and from your hand, and reads 0 and 1 at random. A pull-up resistor connects the input to the supply voltage through a resistance (typically a few kilohms), so the pin reads a defined high level when nothing else drives it. A button or an open-collector output between the pin and ground then pulls it low when it closes; the resistor limits the current that flows. A pull-down resistor does the reverse. Most microcontrollers have weak internal pull-ups that the firmware can switch on (Pin.PULL_UP in MicroPython), which is enough for a button on a short wire; longer wires and buses such as I2C need stronger external ones.

Left: a circuit with a pull-up resistor from 3.3 volts to a GPIO input, and a push button from that input to ground. Right: the voltage on the pin over time. It is high while the button is released, then bounces between high and low several times when the button is pressed, stays low, and bounces again briefly when the button is released before returning high. 3.3 V pull-up GPIO 4 button ground 1 0 released bounce pressed bounce released time (a few milliseconds of bounce)
With the pull-up, the pin reads 1 until the button connects it to ground. The contacts do not close cleanly: for a few milliseconds the level jumps back and forth.

Debouncing

The metal contacts in a button or switch spring back a few times when they close or open, so the pin sees a burst of changes instead of one, as the right half of the figure shows. Code that counts every change counts one press as three or four. Debouncing is filtering out these rapid, unwanted changes so that one physical press or release produces exactly one event — in hardware with a small capacitor, or in software by accepting a new level only after it has been stable for a few milliseconds.

The example simulates a button sampled once per millisecond, with invented but typical bounce, and counts presses with and without a debouncer:

# A push button on a pin with a pull-up: 1 = released, 0 = pressed.
# One sample per millisecond. The contacts bounce when pressed and released.
SAMPLES = "11111" + "01010010" + "0" * 30 + "101101" + "1" * 20

def count_naive(samples):
    """Count every 1 -> 0 change as a press."""
    return sum(1 for a, b in zip(samples, samples[1:]) if a == "1" and b == "0")

def count_debounced(samples, stable_ms=5):
    """Accept a new level only after it has been steady for stable_ms samples."""
    state, run, presses = "1", 0, 0
    for s in samples:
        if s == state:
            run = 0
            continue
        run += 1
        if run >= stable_ms:
            state, run = s, 0
            if state == "0":
                presses += 1
    return presses

print("samples:", len(SAMPLES), "ms")
print("naive presses:", count_naive(SAMPLES))
print("debounced presses:", count_debounced(SAMPLES))
samples: 69 ms
naive presses: 6
debounced presses: 1

Firmware does the same in C, in a function that a timer calls once per millisecond. The state lives in static variables, so it survives between calls without any memory allocation:

#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>

#define STABLE_MS 5

/* Called once per millisecond with the raw pin level (1 = released).
   Returns true exactly once per real press. */
static bool button_pressed(int raw)
{
    static int state = 1;
    static uint8_t run = 0;

    if (raw == state) {
        run = 0;
        return false;
    }
    if (++run < STABLE_MS) {
        return false;
    }
    state = raw;
    run = 0;
    return state == 0;
}

int main(void)
{
    /* Two presses, each with contact bounce. */
    const char *samples = "11111" "01010010" "0000000000" "101101" "11111111"
                          "0110" "0000000000" "1011" "11111111";
    int presses = 0;

    for (int ms = 0; samples[ms] != '\0'; ms++) {
        if (button_pressed(samples[ms] - '0')) {
            presses++;
            printf("press %d accepted at %d ms\n", presses, ms);
        }
    }
    printf("total: %d presses\n", presses);
    return 0;
}
press 1 accepted at 16 ms
press 2 accepted at 44 ms
total: 2 presses

The price of debouncing is a small delay: the press is accepted 5 ms after the contacts settle. For a person pressing a button that is unnoticeable. Choose the stable time from the switch's datasheet or by measuring; 5 to 20 ms covers most buttons.

Pulse-width modulation

A GPIO output is either fully on or fully off, yet LEDs can be dimmed and motors slowed down. The trick is pulse-width modulation (PWM): switching an output on and off at a fixed, fast frequency and varying how long it stays on in each period, so that the average power delivered to the load varies. An LED switched at 5 kHz is too fast for the eye to see flicker; it just looks dimmer. A motor's inertia smooths the pulses into a lower speed. Microcontrollers generate PWM in hardware timers, so the CPU is not involved once the output is set up.

Duty cycle

The duty cycle of a PWM signal is the fraction of each period during which the output is on, given as a percentage: 0% is always off, 100% always on, 25% on for a quarter of every period. The average voltage is the duty cycle times the high voltage, so 25% of 3.3 V averages about 0.83 V.

Three PWM signals with the same period, drawn over three periods each. At a 25% duty cycle the output is high for a quarter of each period, averaging about 0.83 volts; at 50% for half, averaging 1.65 volts; at 75% for three quarters, averaging about 2.48 volts. One period is 200 microseconds at 5 kilohertz. one period: 200 µs at 5 kHz 25% average ≈ 0.83 V 50% average 1.65 V 75% average ≈ 2.48 V
Same frequency, different duty cycles. The period stays the same; only the time the output is high changes.

MicroPython sets the duty cycle as a 16-bit number, duty_u16, from 0 (always off) to 65535 (always on):

from machine import Pin, PWM

led = PWM(Pin(5), freq=5000, duty_u16=32768)  # about 50%
led.duty_u16(16384)                           # about 25%

The arithmetic behind those numbers:

# PWM arithmetic for MicroPython's PWM(pin, freq=..., duty_u16=...).
# duty_u16 runs from 0 (always off) to 65535 (always on).
FREQ_HZ = 5000

period_us = 1_000_000 / FREQ_HZ
print(f"frequency {FREQ_HZ} Hz -> period {period_us:.0f} µs")

for duty_u16 in (0, 16384, 32768, 65535):
    duty = duty_u16 / 65535
    on_us = duty * period_us
    print(f"duty_u16 {duty_u16:5} = {duty * 100:8.4f}%  on {on_us:6.2f} µs "
          f"of {period_us:.0f} µs")

# The other way round: which duty_u16 gives 25%?
print("25% ->", round(0.25 * 65535))
frequency 5000 Hz -> period 200 µs
duty_u16     0 =   0.0000%  on   0.00 µs of 200 µs
duty_u16 16384 =  25.0004%  on  50.00 µs of 200 µs
duty_u16 32768 =  50.0008%  on 100.00 µs of 200 µs
duty_u16 65535 = 100.0000%  on 200.00 µs of 200 µs
25% -> 16384

Because 65535 is odd, "half" is not exactly representable: 32768 is 50.0008%. That never matters for an LED, but it shows that the value you set is a number of hardware steps, not a percentage. The hardware may have fewer steps than 16 bits at high frequencies; the driver then rounds. Also note that the eye does not see brightness linearly: 25% duty looks brighter than a quarter of full brightness, so dimming curves are usually corrected in software.

Lotte's P1 reader has a status LED that glows dimly green, through PWM, while telegrams arrive with a correct checksum, and switches to red when one fails its check — a quick sign in the meter cupboard that something is wrong.

Polling versus interrupts

Firmware can find out that an input changed in two ways. It can check the pin regularly — polling — or it can ask the hardware to raise an interrupt when the pin changes, so the processor stops what it is doing and reacts at once. Polling is simple and predictable, but it wastes energy checking pins that did not change, and it misses anything shorter than the polling interval. The simulation shows how many 2 ms pulses a poller sees, for pulses arriving at every possible moment within a 10 ms window:

# Does polling catch a 2 ms pulse? Try the pulse at every possible
# starting millisecond within one 10 ms window and see which ones a poller sees.
PULSE_MS = 2

def caught(start, poll_every):
    """True if a poll happens while the pulse [start, start + PULSE_MS) is high."""
    return any(t % poll_every == 0 for t in range(start, start + PULSE_MS))

for poll_every in (10, 5, 2, 1):
    seen = sum(caught(start, poll_every) for start in range(10))
    print(f"poll every {poll_every:2} ms: {seen:2} of 10 pulses seen, "
          f"{1000 // poll_every:4} wake-ups per second")
poll every 10 ms:  2 of 10 pulses seen,  100 wake-ups per second
poll every  5 ms:  4 of 10 pulses seen,  200 wake-ups per second
poll every  2 ms: 10 of 10 pulses seen,  500 wake-ups per second
poll every  1 ms: 10 of 10 pulses seen, 1000 wake-ups per second

Polling every 10 ms misses 80% of the pulses. To catch them all, the interval must be no longer than the pulse, which means 500 wake-ups per second for something that may happen twice a day. An interrupt catches every pulse and costs nothing while nothing happens — which is also how a sleeping battery device can wake up on a button press or a sensor alert (see Power and Batteries). Polling remains the right choice for slow signals that are read on a schedule anyway, such as a temperature every 10 seconds, and for buttons that are debounced in a timer as above.

Interrupt service routines

An interrupt service routine (ISR), also called an interrupt handler, is the function the processor runs when an interrupt fires. It runs in between whatever the main program was doing, possibly halfway through a calculation, and while it runs other work — sometimes other interrupts — waits. That leads to three rules:

  • Keep it short. Record what happened (set a flag, store a timestamp, put a byte in a buffer) and return. Do the real work in the main loop. ESP-IDF even has an interrupt watchdog that resets the chip when interrupt handlers block for too long.
  • Do not allocate memory, print or wait. These calls can block or are not safe to call from an interrupt; in MicroPython, allocating memory inside a hard interrupt handler raises an error.
  • Share data carefully. A variable that both the ISR and the main loop change can be corrupted halfway through an update — the embedded version of a race condition. In C, mark it volatile and keep updates atomic.

Lotte has a small ESP32 panel in the hallway with a "charge now" button for the home battery. The handler only sets a flag; the main loop sends the request to Home Assistant:

import time
from machine import Pin

pressed = False

def on_press(pin):
    global pressed
    pressed = True            # just set a flag: no printing, no allocation

button = Pin(4, Pin.IN, Pin.PULL_UP)
button.irq(trigger=Pin.IRQ_FALLING, handler=on_press)

while True:
    if pressed:
        pressed = False
        request_charge_now()  # the real work happens here, outside the handler
    time.sleep_ms(10)

A falling-edge interrupt on a bouncing button fires several times per press, so request_charge_now() must ignore repeats within, say, 200 ms — or the handler must be combined with the debouncing above. The structure of the main loop itself, and the watchdog that guards it, are covered on Firmware and Embedded Programming.

Common mistakes

  • No pull-up. A button input without one reads random values; switch on the internal pull-up or fit a resistor.
  • No debouncing. One press is counted several times, and an interrupt fires several times.
  • Heavy work in an ISR. Printing, network calls or long calculations in an interrupt handler cause missed interrupts, watchdog resets and crashes.
  • Polling a short signal slowly. Anything shorter than the polling interval can be missed; use an interrupt or a hardware counter.
  • Confusing the three duty cycles. PWM duty cycle, duty cycling for power and the radio duty-cycle limit are different things.