Phase 2: Setup & Development 14 min read read

ESP32 Serial Monitor Guide: Debug, Plot, and Monitor Your Projects

Master the Arduino Serial Monitor and Serial Plotter with ESP32. Learn Serial.print, printf, timestamps, debugging state machines, binary protocol decoding, and multiple UART ports.

Updated June 18, 2026

The Serial Monitor: Your Most Valuable Debug Tool

When an ESP32 project behaves unexpectedly — incorrect sensor readings, missed Wi-Fi connections, unexpected resets — the Serial Monitor is almost always the fastest path to the root cause. It provides a real-time text window into your running firmware at a cost of two GPIO pins and a USB cable. For the vast majority of ESP32 development, Serial output is the primary diagnostic technique, and investing time in learning to use it well returns dividends across every project you build.

This guide covers the complete Serial API, formatting techniques, receiving input, the Serial Plotter, multiple UART ports, debugging state machines, and how to structure Serial output for readability in complex projects.

Basic Serial Output: print, println, printf

The three core print functions serve different purposes:

Serial.print("Hello");           // print without newline
Serial.println("Hello");         // print with newline
Serial.printf("x=%d y=%.2fn", xVal, yVal);  // formatted print

Serial.printf() is the most versatile and the one you will use most often. It accepts the same format specifiers as C’s printf: %d for int, %u for unsigned int, %f for float, %s for string (char*), %c for char, %x for hexadecimal. Width and precision modifiers work too: %8d right-justifies an integer in 8 characters, %.3f prints a float with 3 decimal places, %08x prints hex zero-padded to 8 digits.

void setup() {
  Serial.begin(115200);
  float temperature = 23.45;
  int   raw_adc     = 2048;
  bool  wifi_up     = true;

  Serial.printf("Temperature: %.2f°Cn",   temperature);
  Serial.printf("ADC raw:     %4d (0x%03X)n", raw_adc, raw_adc);
  Serial.printf("Wi-Fi:       %sn",        wifi_up ? "connected" : "offline");
}

The output:

Temperature: 23.45°C
ADC raw:     2048 (0x800)
Wi-Fi:       connected

Formatted, aligned output is far easier to read at a glance than unformatted strings — especially when values are scrolling quickly in the monitor during a live run.

Adding Timestamps to Serial Output

Raw print statements tell you what happened but not when. Adding timestamps transforms the Serial Monitor from a log viewer into a timing analyser:

void logf(const char* format, ...) {
  char buf[256];
  va_list args;
  va_start(args, format);
  vsnprintf(buf, sizeof(buf), format, args);
  va_end(args);

  unsigned long ms = millis();
  Serial.printf("[%7lu.%03lu] %sn",
                ms / 1000, ms % 1000, buf);
}

// Usage:
logf("Wi-Fi connected, IP: %s", WiFi.localIP().toString().c_str());
logf("Sensor reading: %.2f°C", temperature);

Output:

[      1.234] Wi-Fi connected, IP: 192.168.1.42
[      3.891] Sensor reading: 23.45°C

The [7lu.%03lu] format prints seconds with 7 digits and milliseconds with 3 digits, giving you sub-second resolution. This is invaluable for diagnosing timing issues: if Wi-Fi connection takes 4 seconds but should take 1, the timestamps reveal exactly where the delay occurs.

Receiving Serial Input

The Serial Monitor is bidirectional. Type text in the input box at the top of the monitor and press Enter to send it to the ESP32. A basic command handler:

void setup() {
  Serial.begin(115200);
  Serial.println("Commands: led_on, led_off, reboot, status");
  pinMode(2, OUTPUT);
}

void loop() {
  if (Serial.available()) {
    String cmd = Serial.readStringUntil('n');
    cmd.trim();  // remove trailing rn

    if      (cmd == "led_on")  { digitalWrite(2, HIGH); Serial.println("LED on"); }
    else if (cmd == "led_off") { digitalWrite(2, LOW);  Serial.println("LED off"); }
    else if (cmd == "reboot")  { Serial.println("Rebooting..."); delay(100); ESP.restart(); }
    else if (cmd == "status")  {
      Serial.printf("Heap: %d bytes freen", ESP.getFreeHeap());
      Serial.printf("Uptime: %lu sn",      millis() / 1000);
    }
    else { Serial.printf("Unknown command: '%s'n", cmd.c_str()); }
  }
}

Note: always call cmd.trim() — Serial Monitor on different operating systems sends different line endings (n, rn, or r). trim() removes all whitespace and line ending characters from both ends of the string, preventing “command not found” errors caused by invisible trailing characters.

The Serial Plotter

The Serial Plotter (Tools → Serial Plotter in the Arduino IDE menu, or the chart icon in IDE 2.x’s top bar) reads comma-separated numeric values and plots them as scrolling lines. Separate multiple values with commas; each gets its own coloured line.

void loop() {
  int raw   = analogRead(34);
  float volts = raw * 3.3f / 4095.0f;
  int smooth  = 0;

  // Simple moving average (not the right place for static, but illustrative)
  static int buffer[8] = {0};
  static int idx = 0;
  buffer[idx++ % 8] = raw;
  for (int i = 0; i < 8; i++) smooth += buffer[i];
  smooth /= 8;

  // Print values for plotter: label:value syntax gives named traces in IDE 2.x
  Serial.printf("Raw:%d,Smooth:%d,Volts:%.0fn",
                 raw, smooth, volts * 100); // scale volts × 100 for same Y axis
  delay(50);
}

In Arduino IDE 2.x, the plotter supports the Label:Value syntax — each trace gets a named legend. This makes it easy to identify which line is which when multiple sensors are plotted simultaneously. Use the plotter for: visualising sensor noise before and after filtering, tuning PID controllers, monitoring battery voltage over time, and debugging oscillating or unstable signal paths.

Debugging State Machines

Many ESP32 projects are state machines — code that transitions between states (IDLE, CONNECTING, READING, POSTING, SLEEPING) and must behave differently in each. Serial output combined with state names makes debugging dramatically easier:

enum State { IDLE, CONNECTING, READING, POSTING, SLEEPING };
State state = IDLE;

const char* stateNames[] = {
  "IDLE", "CONNECTING", "READING", "POSTING", "SLEEPING"
};

void setState(State next) {
  if (next != state) {
    Serial.printf("[%lu] State: %s → %sn",
                  millis()/1000, stateNames[state], stateNames[next]);
    state = next;
  }
}

void loop() {
  switch (state) {
    case IDLE:
      setState(CONNECTING);
      break;
    case CONNECTING:
      WiFi.begin(SSID, PASS);
      if (WiFi.status() == WL_CONNECTED) setState(READING);
      break;
    // ...
  }
}

The output shows every state transition with a timestamp, making it easy to see where the system hangs, how long each state takes, and whether state transitions occur in the expected order.

Using Multiple UARTs

The ESP32 has three hardware UARTs. UART0 is the programming and Serial Monitor port. UART1 overlaps with flash SPI (avoid it). UART2 (GPIO 16 RX, GPIO 17 TX) is the safe general-purpose second UART for communicating with GPS modules, GSM modems, RS232 sensors, and other serial devices.

void setup() {
  Serial.begin(115200);   // UART0 — for Serial Monitor / debugging
  Serial2.begin(9600, SERIAL_8N1, 16, 17);  // UART2 — for GPS module
}

void loop() {
  if (Serial2.available()) {
    String nmea = Serial2.readStringUntil('n');
    Serial.println("GPS: " + nmea);  // echo GPS data to Serial Monitor
  }
}

You can also remap UARTs to arbitrary pins. Serial2.begin(9600, SERIAL_8N1, rxPin, txPin) — where rxPin and txPin are any output-capable GPIOs — exploits the ESP32’s GPIO matrix to route UART2 to whichever physical pins suit your board layout.

Controlling Serial Output Verbosity

For production builds you want minimal Serial output to avoid performance overhead. A common pattern is a global verbosity flag that controls which messages print:

#define LOG_LEVEL 2  // 0=off, 1=error, 2=info, 3=debug

#define LOG_E(fmt, ...) if (LOG_LEVEL >= 1) Serial.printf("[ERR] " fmt "n", ##__VA_ARGS__)
#define LOG_I(fmt, ...) if (LOG_LEVEL >= 2) Serial.printf("[INF] " fmt "n", ##__VA_ARGS__)
#define LOG_D(fmt, ...) if (LOG_LEVEL >= 3) Serial.printf("[DBG] " fmt "n", ##__VA_ARGS__)

// Usage:
LOG_E("Wi-Fi connection failed after %d attempts", retries);
LOG_I("Sensor reading: %.2f°C", temp);
LOG_D("ADC raw value: %d", raw);

Change LOG_LEVEL to 0 before production deployment to silence all output, eliminating the CPU and timing overhead of Serial transmission. Because the condition is a compile-time constant, the compiler optimises away the unreachable branches entirely.

Using Serial.flush() Before Deep Sleep

Serial output is buffered — if your sketch calls esp_deep_sleep_start() while data is still in the UART transmit buffer, that data will never reach the Serial Monitor because the UART clock stops when the chip sleeps. Always call Serial.flush() before any sleep or restart command to ensure all pending output is transmitted:

Serial.printf("Going to sleep for %d seconds. Uptime: %lu sn",
              SLEEP_SECONDS, millis()/1000);
Serial.flush();
esp_sleep_enable_timer_wakeup(SLEEP_SECONDS * 1000000ULL);
esp_deep_sleep_start();

Frequently Asked Questions

Projects to Build

Put this knowledge to work — try one of these hands-on projects.