If you need to drive a standard 16x2 character screen with an ESP32, the most reliable approach is using an I2C backpack (PCF8574) wired to GPIO 21 (SDA) and GPIO 22 (SCL). Because the ESP32 is a 3.3V logic device and the LCD requires 5V for the backlight, you must use a logic level shifter to prevent frying the ESP32's GPIO pins. The exact board targeted in this guide is the ESP32 DevKit V1 (38-pin variant), and the complete, compilable LCD display code provided below includes built-in I2C bus error handling to prevent watchdog crashes.
The Verdict: Choosing the Right LCD Interface
Before writing a single line of code, you have to decide how the microcontroller will talk to the screen. Hobbyists usually choose between three interfaces for HD44780-compatible displays. Here is the decision path to pick the right one for your bench:
| Interface | Pins Required | 3.3V Safe? | Best For |
|---|---|---|---|
| Parallel 4-Bit (Raw HD44780) | 6 to 10 | No (Needs 5V logic) | 5V Arduinos (Uno/Mega) with spare pins |
| SPI TFT (ST7789/ILI9341) | 5 | Yes | High-speed graphics, custom UIs, >30 FPS |
| I2C with PCF8574 Backpack | 2 | Yes (with level shifter) | Simple text, low pin count, ESP32/ESP8266 |
LiquidCrystal_I2C library abstracts away the timing quirks of the HD44780 controller.
Parts List and ESP32 Pin Mapping
This build assumes you are using the most common off-the-shelf modules. Total BOM cost is roughly $12.
- Microcontroller: ESP32 DevKit V1 (38-pin variant, ESP32-WROOM-32 module)
- Display: 16x2 Character LCD (HD44780 controller) with PCF8574T I2C backpack pre-soldered
- Level Shifter: BSS138 Bidirectional Logic Level Converter (4-channel)
- Wiring: 22 AWG solid core jumper wires
The 3.3V vs 5V Logic Trap
The HD44780 LCD and its backlight require 5V. If you power the PCF8574 backpack with 5V, the module's onboard pull-up resistors will pull the SDA and SCL lines to 5V. Feeding 5V into the ESP32's 3.3V-tolerant GPIO pins will degrade or destroy them. You must shift the logic levels.
| ESP32 DevKit V1 Pin | Level Shifter (Low Voltage Side) | Level Shifter (High Voltage Side) | PCF8574 Backpack Pin |
|---|---|---|---|
| 3V3 | LV (Low Voltage Ref) | - | - |
| VIN (5V) | - | HV (High Voltage Ref) | VCC |
| GND | GND (LV Side) | GND (HV Side) | GND |
| GPIO 21 (SDA) | TX1 / RX1 | TX1 / RX1 | SDA |
| GPIO 22 (SCL) | TX2 / RX2 | TX2 / RX2 | SCL |
Complete ESP32 LCD Display Code
This code targets the ESP32 DevKit V1 using the Arduino IDE (board package: esp32 by Espressif Systems). It requires the LiquidCrystal_I2C library by Frank de Brabander (install via Arduino Library Manager).
Unlike basic tutorials, this script includes I2C bus verification. If the display disconnects or the bus locks up, the code catches the error instead of triggering a hardware watchdog reset.
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// --- PIN DEFINITIONS & CONFIG ---
#define SDA_PIN 21
#define SCL_PIN 22
#define I2C_ADDRESS 0x27 // Change to 0x3F if using a PCF8574A backpack
#define LCD_COLS 16
#define LCD_ROWS 2
// Initialize the library with the I2C address and dimensions
LiquidCrystal_I2C lcd(I2C_ADDRESS, LCD_COLS, LCD_ROWS);
unsigned long lastUpdate = 0;
const long updateInterval = 1000; // Update every 1 second
void setup() {
Serial.begin(115200);
delay(500); // Allow serial monitor to connect
Serial.println("ESP32 I2C LCD Booting...");
// Initialize I2C bus with explicit ESP32 pins
Wire.begin(SDA_PIN, SCL_PIN);
// Verify I2C device presence before initializing LCD
Wire.beginTransmission(I2C_ADDRESS);
byte error = Wire.endTransmission();
if (error == 0) {
Serial.println("I2C LCD found at configured address.");
} else {
Serial.print("ERROR: I2C LCD not found! Error code: ");
Serial.println(error);
Serial.println("Check wiring, level shifter, and I2C address (0x27 vs 0x3F).");
// Halt execution to prevent I2C bus lockups and watchdog panics
while(1) { delay(1000); }
}
// Initialize LCD
lcd.init();
lcd.backlight();
// Custom character example (Thermometer icon)
byte thermometer[8] = {
B00100,
B01010,
B01010,
B01010,
B01110,
B11111,
B11111,
B01110
};
lcd.createChar(0, thermometer);
lcd.setCursor(0, 0);
lcd.print("System Online");
delay(1500);
lcd.clear();
}
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - lastUpdate >= updateInterval) {
lastUpdate = currentMillis;
// Simulate sensor data
float tempC = 22.5 + (random(-10, 10) / 10.0);
// Check I2C bus health before writing to prevent Core 1 panics
Wire.beginTransmission(I2C_ADDRESS);
if (Wire.endTransmission() == 0) {
lcd.setCursor(0, 0);
lcd.write((uint8_t)0); // Print custom thermometer char
lcd.print(" Temp: ");
lcd.print(tempC, 1);
lcd.print("C ");
lcd.setCursor(0, 1);
lcd.print("Uptime: ");
lcd.print(currentMillis / 1000);
lcd.print("s ");
} else {
Serial.println("WARNING: I2C bus lost. Attempting reset...");
Wire.end();
Wire.begin(SDA_PIN, SCL_PIN);
}
}
}
Troubleshooting: First Three Things to Check
When your LCD display code compiles but the screen stays blank, don't start rewriting the sketch. Hardware and I2C addressing cause 95% of failures. Here are the first three things to check, ranked by probability.
1. The I2C Address Mismatch (0x27 vs 0x3F)
Symptom: The Serial Monitor prints ERROR: I2C LCD not found! Error code: 2 or the screen backlight turns on but no text appears.
Cause: Manufacturers use two different I2C expander chips. The PCF8574 defaults to 0x27. The PCF8574A defaults to 0x3F.
Fix: Run an I2C scanner sketch. If the scanner outputs I2C device found at address 0x3F, change line 7 in the code above to #define I2C_ADDRESS 0x3F.
2. The Contrast Potentiometer
Symptom: The backlight is on, row 1 shows solid white rectangular blocks, and row 2 is blank.
Cause: The LCD contrast voltage (V0) is misaligned. The code is actually working perfectly, but the liquid crystals are biased incorrectly.
Fix: Take a small Phillips screwdriver and turn the blue trimpot on the back of the PCF8574 backpack. Turn it counter-clockwise until the white blocks disappear and the text becomes crisp.
3. ESP32 Watchdog Panic from I2C Lockup
Symptom: The Serial Monitor outputs the exact error string: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1) followed by a register dump and a reboot.
Cause: The I2C bus has locked up due to electrical noise, missing pull-up resistors, or a loose SDA wire. The ESP32's Wire library waits indefinitely for an I2C acknowledgment that never comes, triggering the hardware watchdog timer (WDT).
Fix: Ensure your BSS138 level shifter has pull-ups on both sides. If using long wires (>15cm), drop the I2C clock speed by adding Wire.setClock(10000); immediately after Wire.begin() in the setup function.
Extending and Simplifying the Build
Once you have the baseline LCD display code running, you can adapt it to fit your specific project constraints.
How to Simplify
If you are running low on flash memory or just need a static dashboard, strip out the custom character generation (lcd.createChar) and the dynamic I2C health checks in the loop(). For a pure static display, move all lcd.print() commands into setup() and leave the loop() entirely empty. This reduces CPU wakeups and saves battery life in portable ESP32 builds.
How to Extend
- Upgrade to a 20x4 Display: The 20x4 LCD uses the exact same PCF8574 backpack and HD44780 controller. Simply change the defines to
#define LCD_COLS 20and#define LCD_ROWS 4. The library automatically handles the non-linear memory addressing of the 4-row variant. - Add a Rotary Encoder: Use the
ESP32Encoderlibrary to read a KY-040 rotary encoder on GPIO 34 and 35. Map the encoder ticks to a menu system, using the LCD to display the selected parameter. Because the I2C bus is relatively slow (100kHz), avoid updating the LCD more than 10 times per second to prevent UI lag. - Network Integration: Since the ESP32 has built-in WiFi, use the
WiFi.hlibrary to fetch a local IP address or MQTT payload, and print it to the second row of the LCD. This turns the module into a standalone networked status monitor.
For deeper technical specifications on ESP32 GPIO constraints and I2C timing, refer to the official Espressif GPIO API Reference and the Arduino Wire Library Documentation. If you are mapping out multiple I2C sensors on the same bus alongside the LCD, consult the Adafruit I2C Address List to ensure your BME280 or MPU6050 doesn't conflict with the PCF8574's default address.






