03 · Digital I/O¶
GPIO — general-purpose input/output — is the foundation of everything a
microcontroller does. Each pin can be configured as an output (the chip
drives it to 0 V or to the supply voltage) or an input (the chip reads
whether an external voltage is high or low). This module covers the three
core functions (pinMode, digitalWrite, digitalRead), how to wire LEDs
and buttons correctly, and debouncing — the first genuinely embedded problem
you'll solve.
All circuits here can be built in the Wokwi diagram pane: click +, add an LED, a resistor, and a pushbutton, and drag wires between pin holes exactly as described.
Outputs: driving an LED¶
const uint8_t LED_PIN = 5;
void setup() {
pinMode(LED_PIN, OUTPUT); // configure once
}
void loop() {
digitalWrite(LED_PIN, HIGH); // pin outputs 5V (Uno) / 3.3V (ESP32)
delay(250);
digitalWrite(LED_PIN, LOW); // pin outputs 0V
delay(250);
}
Wiring: board pin 5 → resistor (220 Ω) → LED anode (long leg, bent-leg side in Wokwi); LED cathode → GND.
Always use a current-limiting resistor
An LED is not a light bulb — it will draw as much current as the supply allows and burn out (possibly damaging the pin, which can only source ~20–40 mA). A 220 Ω–330 Ω resistor in series limits the current to a safe few milliamps. Wokwi is forgiving about this; real hardware is not, so build the habit in the simulator.
HIGH and LOW are just 1 and 0. A useful idiom for toggling:
Inputs: reading a button¶
A pushbutton connects two points when pressed. The subtlety is what the input pin sees when the button is not pressed: if it's connected to nothing, the pin floats, picking up electrical noise and reading random values. Every digital input needs a defined idle state, provided by a pull-up or pull-down resistor.
The standard solution costs no parts — microcontrollers have built-in pull-up resistors you can enable in software:
const uint8_t BUTTON_PIN = 4;
const uint8_t LED_PIN = 5;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP); // internal resistor holds pin HIGH when idle
pinMode(LED_PIN, OUTPUT);
}
void loop() {
// With INPUT_PULLUP the logic is inverted:
// not pressed → HIGH, pressed → LOW
if (digitalRead(BUTTON_PIN) == LOW) {
digitalWrite(LED_PIN, HIGH);
} else {
digitalWrite(LED_PIN, LOW);
}
}
Wiring: one side of the button → pin 4, the other side → GND. That's all — the internal pull-up replaces an external resistor.
| Configuration | Idle reads | Pressed reads | Wiring |
|---|---|---|---|
INPUT_PULLUP (most common) |
HIGH |
LOW |
button between pin and GND |
INPUT + external pull-down |
LOW |
HIGH |
button between pin and VCC, 10 kΩ resistor pin→GND |
INPUT, nothing attached |
random noise | — | never do this |
The inverted logic of INPUT_PULLUP (LOW = pressed) trips up every
beginner once. Hide it behind a well-named helper:
Debouncing¶
Mechanical button contacts don't close cleanly — they bounce, making and breaking contact for a few milliseconds. The chip is fast enough to see every bounce, so naive "count the presses" code counts one press as 5–10:
The fix: after seeing a change, ignore further changes until the reading has been stable for ~20–50 ms. Here is the classic non-blocking debounce, which also demonstrates edge detection (acting once per press, not continuously while held):
const uint8_t BUTTON_PIN = 4;
const uint8_t LED_PIN = 5;
const uint32_t DEBOUNCE_MS = 30;
bool ledOn = false;
int lastReading = HIGH; // raw reading last time we looked
int stableState = HIGH; // debounced, "official" button state
uint32_t lastChangeMs = 0;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
int reading = digitalRead(BUTTON_PIN);
if (reading != lastReading) {
lastChangeMs = millis(); // signal changed — restart the stability timer
lastReading = reading;
}
if (millis() - lastChangeMs > DEBOUNCE_MS && reading != stableState) {
stableState = reading; // reading has been stable long enough — accept it
if (stableState == LOW) { // falling edge = the moment of the press
ledOn = !ledOn; // toggle the LED once per press
digitalWrite(LED_PIN, ledOn);
}
}
}
Press the button: the LED toggles on. Press again: off. Note there is no
delay() anywhere — loop() spins freely, which matters as soon as a sketch
does more than one thing (module 7 builds this into a general technique).
Wokwi's virtual button bounces realistically if you enable it: click the
button, and in its properties set "bounce": "1" — then try the buggy counter
version and watch it miscount, exactly like real hardware.
How It Actually Works¶
pinMode/digitalWrite/digitalRead are library convenience wrappers
around direct memory-mapped I/O: each GPIO pin is controlled by bits in
special registers that live at fixed addresses in the chip's address space,
alongside RAM. On AVR, pin 5 (Uno's PD5) is bit 5 of three registers —
DDRD (Data Direction Register — 1 = output), PORTD (output level when
configured as output, or pull-up enable when input), and PIND (the pin's
actual sampled input level). pinMode(5, OUTPUT) compiles down to something
equivalent to DDRD |= (1 << 5); — a single bitwise OR into that register's
memory address — and digitalWrite is PORTD |= (1 << 5) or
PORTD &= ~(1 << 5). On the ESP32, the equivalent registers
(GPIO_ENABLE_REG, GPIO_OUT_REG, GPIO_IN_REG) sit in its peripheral
address space and are 32 bits wide, one bit per pin. This is why raw register
access (PORTD = 0b00100000; on AVR) can toggle 8 pins in one clock cycle —
useful when digitalWrite's function-call overhead (pin-to-register lookup,
argument checking) is too slow, as it will be by Level 3.
Why floating inputs read noise: an unconnected pin is electrically
"high impedance" — nothing is actively holding its voltage anywhere, so it
acts like a tiny antenna, and its ADC/comparator input stage floats to
whatever stray capacitive coupling (mains hum, neighboring switching signals,
even your hand near the board) charges it to. A pull-up resistor solves this
by giving the pin a well-defined, low-current path to VCC (or a pull-down to
GND) so that the moment nothing else is driving it, it settles to a known
logic level. INPUT_PULLUP doesn't add a physical part — it sets a bit in
the same port register (on AVR, writing PORTD HIGH while DDRD for that
bit is 0 internally routes ~20-50 kΩ from VCC to the pin) that turns on an
internal resistor already built into the chip's I/O pad circuitry.
Why contacts bounce: a mechanical switch is a spring-loaded metal contact;
when it closes, the two surfaces physically strike each other and
micro-bounce apart and together several times over microseconds to
milliseconds before settling, due to contact elasticity — the same physics as
a dropped ball bouncing to rest. Because the CPU's clock runs at millions of
cycles per second, digitalRead() is fast enough to sample the switch mid-bounce
and see each micro-contact as a separate transition. The debounce loop's
millis() - lastChangeMs > DEBOUNCE_MS window works because it's comparing
against the CPU's own free-running millisecond timer (driven by a hardware
timer/counter peripheral incrementing on a fixed clock tick, covered in
module 7) rather than blocking with delay(), so the chip keeps sampling
during the bounce instead of ignoring it.
Cheat sheet¶
| Function / idiom | Purpose |
|---|---|
pinMode(pin, OUTPUT) |
Configure pin to drive HIGH/LOW |
pinMode(pin, INPUT_PULLUP) |
Input with internal pull-up — idle HIGH, pressed LOW |
digitalWrite(pin, HIGH/LOW) |
Set output level |
digitalRead(pin) |
Read input level (returns HIGH or LOW) |
| LED wiring | pin → 220 Ω resistor → LED anode; cathode → GND |
| Button wiring (pull-up) | pin → button → GND, INPUT_PULLUP, pressed = LOW |
| Floating input | Unconnected input pin reads noise — always define idle state |
| Bounce | Mechanical contacts chatter for ~1–10 ms; debounce with a ~30 ms stability window |
| Edge detection | Compare current vs previous state to act once per press |
Exercise¶
Build a two-button counter in Wokwi (ESP32 or Uno): one button increments a
counter, the other resets it to zero, and the count is shown by blinking the
LED that many times (e.g. count 3 → three quick blinks, pause, repeat). Both
buttons must be debounced and edge-detected — holding a button must not keep
incrementing. Print the count over serial on every change. Then enable
"bounce": "1" on both virtual buttons and verify your counter still counts
correctly.