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).


#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:

StatementConcept
#define LED_PIN 2Compile-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 / LOWLiterally 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:

  1. Edge detection: react to the moment of change, not the held level — lastBtn == HIGH && cur == LOW is the falling edge
  2. Software debounce: mechanical contacts bounce for 5–20ms producing dozens of phantom triggers; millis() - lastDebounce > 50 fixes it in one line
  3. 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

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