EthicsComputerCourses
Join training +91 90532 10052

Displays: I2C LCD & OLED

IoT & Arduino · Lesson 22 of 29 Intermediate 7 min read Updated

By the end of this lesson you will be able to find any I2C device's address, print text on a 16x2 character LCD, draw text and simple graphics on a small OLED screen, and format lines so old characters never linger. Your projects will finally work without a computer attached.

A display turns a gadget into a product. A plant monitor that shows "Soil 42%" on its own screen is much more useful than one that needs a laptop and the Serial Monitor.

DisplayWhat it showsConnectionTypical I2C address
16x2 LCD with I2C backpack2 rows of 16 characters, with a backlightI2C (4 wires) through a PCF8574 "backpack" board0x27 or 0x3F
0.96 inch OLED, SSD1306 driver128 × 64 pixels, text and graphics, no backlight neededI2C (4 wires)0x3C (sometimes 0x3D)

Both use I2C, a two-wire bus where many devices share the same two wires and each one has an address, like flats in one building sharing a single postbox lane but each with its own flat number. You will study I2C properly in Communication: UART, I2C & SPI.

Step 1: Wire the I2C bus

Both displays connect the same way, and you can connect both at once because their addresses differ.

PartPinConnects to
LCD backpack and OLEDVCCArduino 5V (OLED modules usually accept 3.3 V to 5 V; check yours)
LCD backpack and OLEDGNDArduino GND
LCD backpack and OLEDSDAArduino SDA (A4)
LCD backpack and OLEDSCLArduino SCL (A5)

On the UNO R4 WiFi you can also use the Qwiic connector, but that is a second I2C bus called Wire1, so libraries must be told to use it. Stick to SDA and SCL on the header for now.

Step 2: Find the addresses with an I2C scanner

Before using any library, check that the board can see each device. This sketch tries every possible address and reports which ones answer:

C++
#include <Wire.h>

void setup() {
  Serial.begin(9600);
  while (!Serial && millis() < 3000) { }
  Wire.begin();
  int found = 0;
  for (byte address = 1; address < 127; address++) {
    Wire.beginTransmission(address);
    if (Wire.endTransmission() == 0) {        // 0 means "someone answered"
      Serial.print("Found device at 0x");
      if (address < 16) Serial.print("0");
      Serial.println(address, HEX);
      found++;
    }
  }
  Serial.print(found);
  Serial.println(" device(s) found");
}

void loop() {
}

With an LCD backpack at 0x27 and an OLED at 0x3C connected, the scanner prints:

Output
Found device at 0x27
Found device at 0x3C
2 device(s) found

If it finds nothing, check the four wires, especially SDA and SCL swapped. Write down your addresses; the code below needs them.

Step 3: Text on the 16x2 LCD

Install the hd44780 library by Bill Perry from the Library Manager. Its hd44780_I2Cexp class detects the backpack's address and wiring automatically, which avoids the most common beginner problem. Older tutorials use a library called LiquidCrystal_I2C; it works similarly, with the address written in the code.

C++
#include <Wire.h>
#include <hd44780.h>
#include <hd44780ioClass/hd44780_I2Cexp.h>

hd44780_I2Cexp lcd;                  // finds the address by itself

void setup() {
  int status = lcd.begin(16, 2);     // 16 columns, 2 rows
  if (status != 0) {
    Serial.begin(9600);
    Serial.println("LCD not found");
    return;
  }
  lcd.setCursor(0, 0);               // column 0, row 0 (top line)
  lcd.print("Namaste, Rohan!");
  lcd.setCursor(0, 1);               // column 0, row 1 (bottom line)
  lcd.print("Soil: 42 %");
}

void loop() {
}

The top row should show Namaste, Rohan! and the bottom row Soil: 42 %. If the backlight is on but no text appears, turn the small blue contrast potentiometer on the backpack slowly with a screwdriver until the characters appear.

setCursor(column, row) counts from 0, so (0, 1) is the start of the second line.

Step 4: Fixed-width lines

An LCD keeps whatever you printed until you overwrite it. If the reading drops from 100 to 8, printing "8" over "100" leaves "800" on the screen. Calling lcd.clear() every time fixes that, but makes the screen flicker.

The neat solution is to always print exactly 16 characters, padding with spaces. snprintf() builds text into a character array, and the format %-16s pads it to 16 characters. This sketch shows the padded lines in the Serial Monitor, with brackets to make the spaces visible:

C++
void makeLine(char *line, const char *text) {
  snprintf(line, 17, "%-16s", text);      // pad to exactly 16 characters
}

void setup() {
  Serial.begin(9600);
  while (!Serial && millis() < 3000) { }
  char text[17];
  char line[17];
  const int soils[] = {72, 8, 100};
  for (int i = 0; i < 3; i++) {
    snprintf(text, sizeof(text), "Soil: %3d %%", soils[i]);
    makeLine(line, text);
    Serial.print("[");
    Serial.print(line);
    Serial.println("]");
  }
}

void loop() {
}
Output
[Soil:  72 %     ]
[Soil:   8 %     ]
[Soil: 100 %     ]

%3d prints the number in 3 spaces, right-aligned, so the % sign never moves. %% prints a single %. Each array has 17 places: 16 characters plus the invisible end marker that every C string needs. On the LCD, lcd.setCursor(0, 1); lcd.print(line); now updates the row cleanly with no flicker.

Step 5: The OLED display

Install Adafruit SSD1306 from the Library Manager and accept its dependencies, including Adafruit GFX, which provides text and drawing functions.

C++
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

Adafruit_SSD1306 display(128, 64, &Wire, -1);   // width, height, bus, no reset pin

void setup() {
  Serial.begin(9600);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {   // address from the scanner
    Serial.println("OLED not found");
    while (true) { }                                  // stop here
  }
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);                  // small text: 21 characters per line
  display.setCursor(0, 0);
  display.println("Plant monitor");
  display.setTextSize(2);                  // double size for the main value
  display.setCursor(0, 20);
  display.println("Soil 42%");
  display.drawRect(0, 50, 128, 12, SSD1306_WHITE);     // empty bar outline
  display.fillRect(2, 52, 52, 8, SSD1306_WHITE);       // 42% of the inner width
  display.display();                       // nothing appears until this line
}

void loop() {
}

The OLED shows a small title, a big "Soil 42%" and a progress bar. The bar's inner width is 124 pixels, and 42% of 124 is about 52, so the filled part is 52 pixels wide.

The key idea: the library draws into a buffer (a copy of the screen in RAM) and nothing changes on the glass until you call display.display(). That avoids flicker. The buffer for 128 × 64 pixels needs 1,024 bytes, which is half of an UNO R3's entire RAM but easy for the UNO R4's 32 KB.

Common mistakes

  • Wrong address. Run the I2C scanner first and use the address it reports.
  • Blank LCD with backlight on. Adjust the contrast potentiometer on the backpack.
  • Forgetting display.display() on the OLED. Everything is drawn into memory but never shown.
  • Leftover characters on the LCD. Print fixed-width lines padded with spaces.
  • Using A4 and A5 as analog inputs while the I2C bus is connected.

Key points

  • Both the 16x2 I2C LCD and the SSD1306 OLED use SDA (A4) and SCL (A5) plus power and GND.
  • An I2C scanner finds each device's address; common ones are 0x27 or 0x3F (LCD) and 0x3C (OLED).
  • setCursor(column, row) counts from 0; pad lines to the display width to avoid leftovers.
  • The OLED library draws into a RAM buffer; call display() to show it.
  • Several I2C devices can share the same two wires as long as their addresses differ.

Exercise

Write a function barWidth(int percent) for the OLED progress bar whose inner width is 124 pixels: it should return how many pixels to fill, and keep the answer between 0 and 124 even for bad inputs. Test it with -5, 0, 42, 100 and 130.

Show solution
C++
int barWidth(int percent) {
  percent = constrain(percent, 0, 100);
  return (long)percent * 124 / 100;
}

void setup() {
  Serial.begin(9600);
  while (!Serial && millis() < 3000) { }
  const int tests[] = {-5, 0, 42, 100, 130};
  for (int i = 0; i < 5; i++) {
    Serial.print(tests[i]);
    Serial.print(" % -> ");
    Serial.print(barWidth(tests[i]));
    Serial.println(" px");
  }
}

void loop() {
}
Output
-5 % -> 0 px
0 % -> 0 px
42 % -> 52 px
100 % -> 124 px
130 % -> 124 px

constrain() protects the drawing code from impossible percentages, and 42 × 124 ÷ 100 = 52.08, which becomes 52 in integer maths.

Quick Quiz

1. What does an I2C scanner sketch tell you?

2. On a 16x2 LCD, where does lcd.setCursor(0, 1) put the cursor?

3. You drew text on the OLED but the screen is blank. What is missing?

4. Why pad LCD lines to 16 characters?

Learn IoT & Arduino with a mentor

Free lessons get you started. Our IoT & Embedded Systems training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call