Your First ESP32 Program: Reading Blink Line by Line, Serial Debugging & Button-controlled LED (with Debounce)
Prerequisite: environment set up (see the setup guide) and Blink running. This post makes you read every line of the code and finishes with the L1 capstone: a button-controlled LED. All you need is one board + one button + two jumper wires.
1. The Arduino program skeleton
void setup() { // runs exactly once at power-on/reset: "initialization"
}
void loop() { // after setup, loops forever: "main logic"
}
Analogy: setup() is moving in and wiring the utilities; loop() is daily life afterwards.
⚠️ Beginner trap #1: never write while(1) or long delay() inside loop() — they block background tasks (the Wi-Fi stack, watchdog) and cause resets or Task watchdog got triggered. Keep loop spinning fast; express logic as state checks (see §4).
2. Exercise 1: understand every line of Blink
#define LED_PIN 2 // macro: onboard LED on GPIO2 (most boards)
void setup() {
pinMode(LED_PIN, OUTPUT); // declare GPIO2 as output (mandatory!)
}
void loop() {
digitalWrite(LED_PIN, HIGH); // GPIO2 → 3.3V → LED on
delay(500); // pause 500 ms (1 second = 1000)
digitalWrite(LED_PIN, LOW); // GPIO2 → 0V → LED off
delay(500);
}
Line-by-line knowledge:
| Statement | Concept |
|---|---|
#define LED_PIN 2 | Compile-time substitution, zero RAM cost; change the pin in one place |
pinMode(pin, OUTPUT) | Undefined pins have undefined behavior — forgetting this is beginner trap #2 |
HIGH / LOW | Literally 1 and 0; digitalWrite(LED_PIN, 1) is identical |
delay(500) | Blocking pause — for teaching; real code goes non-blocking (§4) |
💡 LED not blinking on your board? Some S3/C3 boards don’t use GPIO2 for the onboard LED — check the silkscreen and change the
2.
3. Exercise 2: Serial — your debugging microscope
Two extra lines and you have logging:
void setup() {
Serial.begin(115200); // init serial at 115200 baud
Serial.println("System booted!");
}
void loop() {
static uint32_t count = 0; // static: survives across loop iterations
Serial.printf("loop #%u\n", ++count);
delay(1000);
}
Open Tools → Serial Monitor and set 115200 bottom-right.
If nothing prints: ① baud mismatch (garbage counts too) ② S3/C3 without USB CDC On Boot ③ charging cable.
Coming up: the ESP32 has three hardware UARTs (Serial/Serial1/Serial2) for GPS and Bluetooth modules — an L2 topic.
4. Exercise 3: button-controlled LED (L1 capstone)
Wiring (30 seconds)
Button pin A → GPIO4 (any safe GPIO works)
Button pin B → GND
No external resistor needed — we use the internal pull-up.
Code (hold-to-light version)
#define BTN_PIN 4
#define LED_PIN 2
void setup() {
pinMode(BTN_PIN, INPUT_PULLUP); // input + ~45kΩ internal pull-up: default HIGH
pinMode(LED_PIN, OUTPUT);
Serial.begin(115200);
}
void loop() {
if (digitalRead(BTN_PIN) == LOW) { // pressed = grounded = LOW
digitalWrite(LED_PIN, HIGH);
Serial.println("button pressed");
} else {
digitalWrite(LED_PIN, LOW);
}
}
Why is pressed LOW? — with INPUT_PULLUP the pin idles at 3.3V (HIGH) through the internal resistor; pressing shorts it to GND → LOW. That’s “active-low” wiring, the most common convention in hardware.
Upgrade: press-to-toggle (with debounce) — the truly useful version
#define BTN_PIN 4
#define LED_PIN 2
bool ledState = false;
bool lastBtn = HIGH; // last read level
uint32_t lastDebounce = 0; // timestamp of last level change
void setup() {
pinMode(BTN_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
bool cur = digitalRead(BTN_PIN);
// falling edge (released→pressed), and >50ms since last trigger (debounce)
if (lastBtn == HIGH && cur == LOW && millis() - lastDebounce > 50) {
ledState = !ledState; // toggle
digitalWrite(LED_PIN, ledState);
lastDebounce = millis();
}
lastBtn = cur;
}
Three new concepts:
- Edge detection: react to the moment of change, not the held level —
lastBtn == HIGH && cur == LOWis the falling edge - Software debounce: mechanical contacts bounce for 5–20ms producing dozens of phantom triggers;
millis() - lastDebounce > 50fixes it in one line - millis() over delay():
millis()returns ms since boot — time checks without blocking. That’s the non-blocking paradigm, the real dividing line of ESP32 skill
5. Classic faceplants FAQ
Q1: LED stuck on/off, button ignored?
→ Check pinMode(BTN_PIN, INPUT_PULLUP) for typos (plain INPUT floats and flickers); check jumper seating.
Q2: one press flashes the LED several times? → No debounce, or threshold too small. Use the 50ms scheme in §4.
Q3: a button on GPIO 34 does nothing?
→ GPIO 34–39 are input-only without internal pull-ups — INPUT_PULLUP is a no-op there! Use a normal pin like GPIO4, or add an external 10kΩ pull-up. Pin capabilities: GPIO Complete Guide.
Q4: why not wire the button to 3.3V and detect HIGH? → You can, but it needs an external pull-down or the pin floats when released. The chip has internal pull-ups only — “to GND + INPUT_PULLUP” is the zero-component optimum.
6. L1 acceptance checklist
- Write Blink from scratch (with pinMode) without peeking
- Serial printing works in the monitor
- Button controls LED (hold version) works
- You can explain why pressed reads LOW
- Challenge: edge-detect + debounce toggle version works
- You can state why GPIOs can’t drive motors directly (answer: 12mA safe limit — see GPIO current limits)
All passed? Congratulations — the first threshold of embedded is behind you. Next up, L2: ADC input and PWM dimming (scheduled in our topic pool).
License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com