GPIO, Interrupts and Debouncing
Reading and driving pins in firmware, pull-ups, interrupt service routines, debouncing and the rules that keep ISRs safe.
GPIO: configuring pins
A GPIO (general-purpose input/output) pin is a digital pin that firmware can read or drive. Each pin has a direction (input or output), an output level (high or low), and often options: pull-up or pull-down resistor, open-drain output, drive strength, slew rate, and an alternate function that connects the pin to a peripheral (UART, SPI, PWM, ADC). On most chips several pins share one peripheral, and a pin can serve only one function at a time; the MCU Pinout and Peripheral Multiplexer helps you assign them without clashes.
| Pin mode | Meaning | Use |
|---|---|---|
| Input (floating) | High impedance; the pin reads whatever is connected | Driven by another chip; never leave unconnected |
| Input with pull-up / pull-down | Internal resistor (typically 20 to 50 kΩ) to VCC or ground | Switches and open-collector signals |
| Push-pull output | Drives high and low actively | LEDs, logic signals, transistor bases |
| Open-drain output | Only pulls low; a resistor pulls high | I²C, wired-OR lines, level shifting, interrupt lines |
| Analog | Pin connected to the ADC (digital buffer off) | Analog inputs; see Analog Input and Signal Processing |
| Alternate function | Pin owned by a peripheral | UART, SPI, I²C, PWM, timer capture |
Set every pin on start-up, including the unused ones: an unused floating input draws extra current and picks up noise, so configure unused pins as outputs low or as inputs with the pull-up enabled (read the chip’s low-power notes).
Driving outputs
A pin can source or sink only a limited current: the recommended maximum on an AVR is 20 mA per pin (40 mA absolute), and many 3.3 V chips allow 8 to 12 mA, with limits per port and for the whole package. Beyond that, use a transistor or MOSFET (see Microcontroller Electronics).
Worked example: eight LEDs on one port
- Each LED runs at 10 mA, so one pin sources 10 mA (below the 20 mA pin limit).
- All 8 on together draw 8 × 10 = 80 mA through the supply pins of the chip, against a typical package limit of about 150 to 200 mA total (check the datasheet): fine here, but 16 LEDs at 20 mA (320 mA) would exceed it.
- Multiplexing (lighting one LED at a time, fast) uses fewer pins and less average current, but each LED’s peak current is higher.
- Set the pin before the direction on chips where a new output pin briefly drives low when its direction changes: write the output latch to the safe level first, then make it an output, so a relay or motor does not glitch at start-up.
// Set, clear, toggle and test single bits with bitwise operators
PORTB |= (1 << 5); // set bit 5 (pin high)
PORTB &= ~(1 << 5); // clear bit 5 (pin low)
PORTB ^= (1 << 5); // toggle bit 5
if (PINB & (1 << 2)) { } // test bit 2 of the input register
Reading inputs: pull-ups and floating pins
An input with nothing connected floats and reads random noise, so every switch needs a pull-up or pull-down resistor. The internal pull-up (about 20 to 50 kΩ) is fine for a switch on a short wire: with the switch to ground, the pin reads high when open and low when pressed. In a noisy place or on a long cable use a stiffer external pull-up (1 to 10 kΩ), which also gives better noise immunity.
Worked example: pull-up current and noise
A 4.7 kΩ pull-up to 5.0 V carries 5.0 ÷ 4 700 = 1.06 mA whenever the switch is closed. The 35 kΩ internal pull-up carries only 0.14 mA (143 µA): good for battery life, but a weak pull-up on a long wire lets noise pull the pin across its threshold, because the pin’s voltage divider has high impedance. A lower resistor is a stiffer, quieter signal at the cost of current.
- Logic thresholds depend on the supply: a 5 V CMOS input reads low below about 0.3 × VCC and high above 0.7 × VCC (1.5 V and 3.5 V at 5 V). Between them the value is undefined, which is why a slowly changing signal needs a Schmitt trigger input.
- Never feed 5 V into a 3.3 V-only pin unless the datasheet says the pin is 5 V tolerant; use a level shifter.
- Read a stable value: a signal that changes near the moment you read it can be captured in a half-state; for signals from another clock domain read twice and compare.
- Protect pins from long cables and the outside world with a series resistor (1 kΩ or more), and clamp diodes or a TVS where static or inductive spikes are possible.
Switches and debouncing
A mechanical switch does not change cleanly: its contacts bounce for a few milliseconds (typically under 10 ms, and up to 20 ms for a worn or cheap switch), producing several edges. If firmware counts every edge, one press registers as several. There are two cures: hardware (an RC filter, a Schmitt trigger or a dedicated debounce chip) and software, which costs nothing.
Worked example: a sampling debouncer
- A switch bounces for up to 5 ms. Sample it every 1 ms in a timer tick, shifting the last 8 samples into a byte. Accept a change only when the byte is all ones (pressed) or all zeros (released): 8 consecutive equal samples.
- The decision therefore comes 8 ms after the last bounce at the earliest, longer than the bounce itself, so every bounce is ignored (the debounce time must exceed the longest bounce). A person cannot notice 8 ms.
- Cost: the check takes a few instructions per switch per millisecond, trivial next to the CPU’s 16 000 cycles per millisecond at 16 MHz.
// Debounce one active-low button; call this from a 1 ms timer tick
uint8_t history = 0xFF; // 1 = released (pull-up), 0 = pressed
bool pressed = false;
void debounce_tick(void) {
history = (history << 1) | (digitalRead(BUTTON_PIN) ? 1 : 0);
if (history == 0x00 && !pressed) { pressed = true; on_press(); }
if (history == 0xFF && pressed) { pressed = false; on_release(); }
}
- Hardware option: a 10 kΩ pull-up with a 100 nF capacitor across the switch has a time constant of 1 ms, enough for fast bounce; a Schmitt trigger input gives a clean edge.
- Long presses, repeats and double clicks are built from the same sampled state with a timer: record the time of the press and compare.
- Do not debounce with delay(): it blocks the rest of the program and misses events.
Keypads and button matrices
A matrix wires 16 keys as 4 rows and 4 columns, so only 8 pins are needed instead of 16. Firmware drives one row low at a time (others inputs with pull-ups or high-impedance) and reads the columns; a low column means the key at that row and column is pressed. Scanning 4 rows with a 20 µs settling delay takes 80 µs per full scan, so scanning every millisecond uses under 10 % of the time. A plain matrix cannot tell some combinations of three or more keys apart (ghosting); add a diode in series with each key for reliable multi-key detection.
Interrupts: what happens
An interrupt makes the processor stop what it is doing, run a short handler called the interrupt service routine (ISR), and then resume exactly where it was. The hardware saves the return address (and the compiler saves the registers the ISR uses); on return everything is restored. Sources include a pin changing (external interrupt, rising, falling, any edge or low level), a timer reaching a value, a character arriving on a UART, an ADC conversion finishing, and many more. The interrupt vector table maps each source to its handler, and a priority scheme decides which one runs when several are waiting.
Worked example: interrupt latency
- AVR at 16 MHz: the hardware needs about 7 cycles to finish the current instruction, push the return address and jump to the vector: 7 ÷ 16 MHz = 0.44 µs. The compiler-generated code then saves the registers (about 14 more cycles), so the first useful instruction runs after about 1.31 µs. Longer instructions, other interrupts and code that disables interrupts add to this.
- Cortex-M at 48 MHz: hardware stacking takes 12 cycles: 12 ÷ 48 MHz = 0.25 µs, with the registers saved by hardware.
- Jitter is the variation of that latency; it matters when an interrupt must start an output at a precise time (use a hardware timer output instead).
- Level or edge: an edge interrupt fires once per transition; a level interrupt keeps firing while the line stays low, so the ISR must clear its cause.
- Pin-change interrupts fire on any change of any pin in a group, and the ISR must read the pins to find which one changed.
- Wake from sleep: an interrupt can wake a sleeping chip; see Power and Low-Power Firmware.
- Priorities and nesting: on ARM Cortex-M a higher-priority interrupt can interrupt a lower one; on AVR interrupts do not nest unless the ISR re-enables them.
Rules for interrupt service routines
- Keep them short. Set a flag, copy a value, push a byte into a buffer, and return. Do the rest in the main loop.
- No blocking calls: no
delay(), no serial printing, no waiting for another event, no memory allocation, and no floating-point (it may be slow and may need saved registers). - Declare shared variables
volatileso the compiler re-reads them every time instead of keeping a copy in a register. - Clear the interrupt flag (when the hardware does not do it for you), or the ISR runs again at once.
- Do not call library functions that are not documented as safe in an ISR; many use interrupts themselves or take locks.
- Protect shared data that is bigger than the chip’s word (see below).
- Count the load (see below): an ISR that takes a significant part of the time starves everything else.
volatile bool edge_seen = false; // set by the ISR, cleared by the main loop
volatile uint32_t edge_time = 0;
void on_edge(void) { // attachInterrupt(digitalPinToInterrupt(2), on_edge, FALLING)
edge_time = micros(); // copy the time stamp
edge_seen = true; // tell the main loop
}
void loop() {
if (edge_seen) {
noInterrupts(); uint32_t t = edge_time; edge_seen = false; interrupts();
handle_edge(t); // the slow work happens here
}
}
The interrupt budget: when polling beats interrupts
Every interrupt costs its entry and exit overhead plus the ISR body. The fraction of the CPU used is the interrupt rate times the time per interrupt.
Worked example: counting an encoder in software
- The load of an ISR. An ISR of 5 µs called 100 000 times per second takes 100 000 × 5 × 10-6 = 50 % of the CPU.
- An encoder. A 1 000 pulse-per-revolution encoder at 3 000 rpm (50 rev/s) gives 50 000 pulses/s on each channel, and counting all four edges per pulse (quadrature) gives 200 000 edges/s, one every 5.0 µs.
- At 3 µs per edge interrupt the load is 200 000 × 3 × 10-6 = 60 % of the CPU, and edges arrive only 5.0 µs apart, close to the latency itself, so counts will be missed. Use the chip’s hardware quadrature decoder or timer counter, which counts in hardware with no CPU load, and read the count when needed (see Motion Control and Actuators for encoders).
- Rule of thumb: interrupts for events that are rare and must not wait (a few hundred a second); hardware peripherals (timers, DMA, counters) for fast repetitive events; polling in a timer tick for slow, regular signals like switches.
Shared data and atomic access
On an 8-bit MCU, reading a 16- or 32-bit variable takes several instructions. If an interrupt changes the variable between the reads, the main code gets a torn value (half old, half new): a race condition. The window is tiny, so the fault appears rarely and is hard to find.
Worked example: how often does it go wrong?
Suppose a 32-bit counter is updated 1 000 times a second and the main loop reads it 100 times a second. The read takes about 2 cycles in which an interrupt can split it; at 16 MHz that is a window of 0.13 µs, so the chance per read is 1 000 × 0.13 × 10-6 = 0.00013. With 100 reads a second that is a torn value about 45 times an hour: a bug that “happens sometimes”.
- Disable interrupts around the copy (
noInterrupts()…interrupts()), keeping the window to a few instructions, as in the code above. - Read twice and compare a value that changes slowly, and repeat if the reads differ.
- Use a ring buffer (head written by the ISR, tail by the main loop) for streams of data: each index is written by only one side, so no locking is needed.
- Use atomic types and the RTOS’s queues when you have them (see Firmware Architecture, State Machines and RTOS).
Related
- Embedded Systems: the overview of this guide.
- Platforms and Toolchains: tools and boards.
- Timers, PWM and Timing: timer ticks for debouncing and periodic work.
- Firmware Architecture, State Machines and RTOS: flags, state machines and queues.
- Microcontroller Electronics: pull-up, level-shift and driver circuits.
FAQ
Why does my button press count several times?
The contacts bounce for a few milliseconds, and each bounce is an edge. Debounce in firmware by requiring a stable state for 5 to 20 ms, or filter in hardware, and never count edges of a mechanical switch in an interrupt without it.
Can I use delay() in an interrupt?
No. Delays block everything else and may not work inside an ISR at all because the timer used for timing is itself interrupt-driven. Set a flag and do timing in the main loop.
Why is my input reading random values?
The pin is floating: nothing defines its level. Enable the pull-up or add a resistor to VCC or ground, and shorten or filter long wires.
What does volatile do?
It tells the compiler that the variable can change at any time outside the normal flow of the program (in an ISR, or by hardware), so it must read it from memory every time. It does not make multi-byte reads atomic.
References
- Microcontroller datasheets (interrupt response time, pin electrical characteristics, GPIO current limits).
- Ganssle, J., “A Guide to Debouncing”, The Embedded Muse (switch bounce times and software debouncing).
- Arm, Cortex-M Technical Reference Manuals (interrupt latency); White, E., Making Embedded Systems, O’Reilly.
Last reviewed: October 2026. Cycle counts and timing are for the common parts named: check the datasheet and measure on your own hardware.
