< Back 

SolidCrystalI2C

2026-01-28

High performance and easy to use modern driver for LCD displays based on the HD44780 controller. Runs on AVR/Arduino systems.

C++AVREmbedded
Go to quick view

SolidCrystalI2C: a buffered driver for HD44780 displays over I2C

Mastedore, September 2026. Describes library version 2.0.0.

Abstract

SolidCrystalI2C drives HD44780 character LCDs connected through a PCF8574 I2C expander, the cheap “backpack” that most 16x2 and 20x4 modules ship with. It was written from the datasheets rather than adapted from the usual LiquidCrystal_I2C code, and it took about two months of iteration to reach its current form. Its main idea is that on this hardware the bus, and not the CPU, is the bottleneck. Every byte the LCD receives costs four I2C transactions, so the library keeps a copy of the screen in RAM, marks the cells that change, and only sends those. On top of that model it adds a second drawing layer for things that should float over the normal text, a cache for the eight custom characters, a cursor that stays where the program put it, and a small stream interface with no dependency on Arduino’s String or Print. This document explains how each of those parts works and why it was built that way.

1. The hardware

1.1 The HD44780

The HD44780 is the controller behind nearly every character LCD. It has 80 bytes of display RAM (DDRAM) for the characters on screen, 64 bytes of character generator RAM (CGRAM) for eight user-defined 5x8 characters, and a small instruction set to clear the screen, move the address counter, switch the display, cursor and blink on or off, and so on. Most instructions take about 37 microseconds to execute. Clear and home are the slow ones, at about 1.52 ms.

The controller has an 8-bit data bus that can also be used 4 bits at a time, sending each byte as two halves (nibbles). Besides the data lines there are three control lines: RS selects between instructions and data, RW selects between writing and reading, and a falling edge on E latches whatever is on the bus.

1.2 The PCF8574 backpack

The PCF8574 is an I2C device with eight output pins. Each byte written to it appears on those pins until the next one arrives. The usual LCD backpack wires them like this:

Expander pin LCD signal
P0 RS
P1 RW
P2 E
P3 backlight transistor
P4 - P7 D4 - D7

Only four pins reach the data bus, so the LCD has to run in 4-bit mode, and the expander has no notion of a strobe. To latch one nibble, the driver writes it once with E high and once with E low. Sending one LCD byte therefore takes four I2C writes.

1.3 What that costs

At the 100 kHz standard I2C speed, one write to the expander is a start condition, an address byte, a data byte and a stop condition, about 20 clock periods:

tw≈20100kHz=0.2ms

A nibble is two writes plus the 51 microseconds of delays in sendNibble(), and an LCD byte is two nibbles:

tbyte≈4tw+2×51μs≈0.9mstredraw≈R⋅C⋅2tbyte

where R and C are the rows and columns, and each cell needs a position byte and a character byte. That gives these figures. They are estimates from the bus timing, without the Wire library’s own overhead, not measurements:

Operation I2C writes Approximate time
One nibble 2 0.45 ms
One LCD byte (instruction or character) 4 0.9 ms
One character at a given position (address + data) 8 1.8 ms
One custom character upload (address + 8 rows) 36 8 ms
Full redraw of a 16x2 screen 256 58 ms
Full redraw of a 20x4 screen 640 145 ms

In 58 ms a 16 MHz AVR goes through 16MHz×58ms≈928000 clock cycles. On this hardware the question is never how fast the code runs, only how many bytes cross the bus. Nearly every design decision below follows from that.

2. Goals

The library was built around a few constraints:

  • Work on an 8-bit AVR with 2 KB of RAM, so memory use has to be small, fixed and predictable.
  • Follow the datasheets directly, with every byte on the bus traceable to a line of code and no hidden background work.
  • Avoid Arduino’s String (heap allocations at runtime) and Print (a virtual table and an API the library doesn’t need).
  • Remember what is on the screen, so the application can build a frame in memory and send only the difference.
  • Treat the display as write-only. The RW line stays low all the time and the library never reads the busy flag or DDRAM back. Reading through the expander would need extra bus traffic and pin direction changes, and the RAM copy already knows what the screen shows.

3. Architecture

The code is organized in three layers, each built only on the one below it.

  1. Transport (sendi2c, sendNibble, sendByte) turns an LCD byte into expander writes.
  2. Instructions (clearAll, display, cursor, blink, backlight, textDirection, writePosition, writeChar, defineGlyph) are the HD44780 instruction set, one function per operation.
  3. The buffered API (row, column, seti, home, the << operators and flush) writes into RAM and decides what has to reach the display.

An application can use layer 2 directly when it needs to (the credits animation in Owake types straight onto the glass with writePosition and writeChar), but normal drawing goes through layer 3.

4. Transport

sendi2c() writes one byte to the expander. It ORs in the backlight bit on every call. The PCF8574 doesn’t have separate registers for separate pins, and every write sets all eight outputs, so a write that forgot the backlight bit would switch the light off.

sendNibble() places four data bits on P4-P7, sets RS on P0 and leaves RW (P1) at 0. It writes the byte once with E (P2) high and once with E low, then waits 50 microseconds, which covers the 37 microseconds most instructions need. The datasheet asks for an enable pulse of a few hundred nanoseconds, and a single I2C write already takes much longer than that, so the 1 microsecond delay between the two writes is only a margin.

sendByte() sends the high nibble first, then the low one. Everything above this layer deals in whole bytes.

5. Instructions and initialization

5.1 Waking the controller

The HD44780 needs time after power-up before it accepts commands: more than 40 ms after the supply reaches 2.7 V, per the datasheet. start() doesn’t use a fixed delay for that. It waits until micros() passes 55 ms since the MCU booted, so if the application already spent that long doing something else, the wait costs nothing.

The controller may wake up in 8-bit mode, or in the middle of a 4-bit byte if only the MCU was reset. The datasheet’s “initialization by instruction” sequence handles both cases: send the nibble 0x3 three times (waiting >4.1ms after the first and >100μs after the second), then 0x2, which switches to 4-bit mode. Only after that are full bytes safe.

The function set instruction comes next, 0 0 1 DL N F x x. DL stays 0 for the 4-bit bus. N is set for displays with more than one row, and F (the 5x10 font) is set only for one-row displays, because the controller doesn’t support 5x10 characters in two-line mode. After that, start() turns the display on with the cursor hidden, clears it, sets left-to-right entry, and marks every CGRAM slot as empty in the glyph cache, since CGRAM holds random data after power-up.

5.2 Settings that share an instruction

Display on/off, cursor on/off and blink on/off are three bits of one instruction. The library keeps all three in its settings bitfield. Each setter updates its bit and resends the whole instruction, so changing one never disturbs the others. The entry mode instruction (text direction and display shift) works the same way.

5.3 Addressing

DDRAM isn’t laid out as one row after another. Row 0 starts at address 0x00 and row 1 at 0x40, and on four-row displays row 2 is the continuation of row 0 (0x14) and row 3 the continuation of row 1 (0x54). Each line has 40 bytes, of which a 16- or 20-column display shows only the first part. writePosition(column, row) converts a position into an address with a four-entry offset table,

A(r,c)=Or+cO=(𝟶𝚡𝟶𝟶, 𝟶𝚡𝟺𝟶, 𝟶𝚡𝟷𝟺, 𝟶𝚡𝟻𝟺)

and sends the “set DDRAM address” instruction.

5.4 Scrolling

The four increment and decrement operators map to the “cursor or display shift” instruction. Prefix ++lcd and --lcd scroll the whole display right or left. Postfix lcd++ and lcd-- move only the cursor.

6. The buffered model

6.1 Buffers

The library keeps, per display:

  • A text buffer (the user layer), one byte per cell, initialized to spaces.
  • An optional overlay buffer, one byte per cell, initialized to a transparent marker.
  • A dirty bitmap, one bit per cell.

Writing text never touches the bus. The << operators write into the active layer at the current write position, advance that position, and set the cell’s bit in the dirty bitmap. The single function that writes into the buffers (bufwrt) also marks the cell dirty, so no path can change a cell and forget to mark it.

The write position is a row and a column kept in the settings bitfield. seti() and geti() convert it to and from a flat index i=r⋅C+c, which is convenient for layouts computed as offsets. Cell i is tracked by bit imod8 of byte ⌊i/8⌋ in the dirty bitmap. When a write runs past the last column, the position wraps to the start of the next row, and past the last row to the first one. In right-to-left mode it wraps the other way.

6.2 Two control characters

The library reserves two character codes as markers inside the buffers: 22 (skip) and 23 (end). Both fall in the 0x10-0x1F range, which the common A00 character ROM leaves blank, so no printable character is lost. They never reach the display.

  • Skip means “nothing here”. In the overlay it makes the cell transparent, so the user layer shows through. In the user layer it means “leave whatever the display already has”.
  • End means “blank from here to the end of this row”. The application writes it with cchar::endl.

End exists because of how screens get rebuilt. When the text on a row gets shorter, for example “Chronometer” replaced by “Clock”, the old tail has to be erased. Without end, the application would pad with spaces up to the row width. With it, lcd << "Clock" << cchar::endl erases exactly what is left.

6.3 The overlay

The overlay is a second layer drawn on top of the text. Anything written to it hides the user layer at that cell, and writing skip gives the cell back. It is meant for things that come and go over content that shouldn’t be redrawn: navigation arrows at the edges of a menu, a confirmation prompt over a countdown, a status mark. The application selects the layer with lcd << writeLayer::overlay and returns with lcd << writeLayer::user. The overlay is optional. A display constructed without it doesn’t allocate the buffer, and requests to switch to it are ignored.

6.4 flush()

flush() walks the screen row by row and decides for each cell whether to send it and what to send:

for each row:
    reset the row's end flags
    for each cell in the row:
        if an overlay END appeared earlier in this row:
            send a space, continue
        if the cell is clean and no user END appeared earlier:
            skip it
        look at the overlay:
            END   -> send a space, remember "overlay END"
            SKIP  -> look at the user layer:
                       if a user END appeared earlier: send a space
                       END   -> send a space, remember "user END"
                       SKIP  -> send nothing
                       other -> send the character
            other -> send the overlay character
restore the cursor if it is pinned (section 8)
clear the dirty bitmap

A few properties follow from that order. The overlay wins over the text unless it is transparent. An end marker keeps working past its own cell: everything after it in the row is blanked even if those cells weren’t marked dirty, so text that used to be longer doesn’t leave a trail. After a user-layer end, overlay characters further along the row still show, so an arrow at the right edge survives a short line of text. The end flags reset at the start of each row, so an end marker never spills into the next one.

6.5 clear() and clearAll()

clearAll() sends the controller’s clear instruction. The glass goes blank immediately, and it costs a bus transfer and 1.52 ms. clear() only resets the buffers: text to spaces, overlay to transparent. It doesn’t mark any cell dirty, so the display keeps its old content until those cells get written again.

That behavior is deliberate, but it surprises people. clear() is for starting a new frame from a known state, where the application is going to write every visible cell anyway (usually with end markers doing the padding), and a physical clear would only add a visible blink between frames. When the screen really has to be empty right away, the two go together: clearAll() for the glass, clear() for the buffers.

7. Memory

7.1 The settings word

All display settings and the write position share one 32-bit bitfield:

Field Bits Meaning
DisplayShift 1 entry mode: shift the display with each write
CursorDirection 1 entry mode: left-to-right or right-to-left
BlinkState 1 blinking block cursor
CursorState 1 underline cursor
DisplayState 1 display on or off
BacklightState 1 backlight bit sent with every expander write
FontSize 1 5x10 characters (one-row displays only)
Ready 1 start() has completed
WriteRow 2 buffer write position, row
WriteColumn 6 buffer write position, column
ConstantCursorRow 2 pinned cursor, row
ConstantCursorColumn 6 pinned cursor, column
ConstantCursor 1 pinned cursor enabled
WriteLayer 1 user or overlay
WriteMode 2 raw, text, hex or binary
Unused 4

The per-row state of flush() (overlay enabled, user end seen, overlay end seen) lives in a second bitfield of one byte.

7.2 Totals

The object itself is 30 bytes on AVR: the I2C address and dimensions (3), the two bitfields (5), three buffer pointers (6) and the glyph cache (16). The buffers are allocated on the heap once, in the constructor, and never freed or resized. Their size depends on the geometry and on whether the overlay is enabled (o=1 with the overlay, o=0 without), plus the allocator’s 2-byte header per block:

B=R⋅C⋅(1+o)+⌈R⋅C8⌉

Display Without overlay With overlay
16x2 36 bytes 68 bytes
20x4 90 bytes 170 bytes

Allocating at construction time lets one build of the library serve any geometry without templates, and because nothing is ever freed, the heap can’t fragment.

8. The cursor

The HD44780’s visible cursor sits wherever the address counter points, and the address counter moves on every write. A buffered library writes many cells per flush, so after a flush the cursor would end up behind the last cell sent, which is never where the application wanted it.

The library solves this with a pinned cursor. cursorPosition(column, row) turns the hardware cursor on and stores its position in the settings word. At the end of every flush(), the library moves the address back to that position. constantCursor(false) turns the feature off. Owake’s time editor uses it to keep the cursor on the field being edited while digits around it are redrawn.

9. Custom characters

9.1 Uploading

defineGlyph(bitmap, slot) writes a 5x8 bitmap into one of the eight CGRAM slots: a “set CGRAM address” instruction followed by eight data bytes. The bitmaps are expected in flash (PROGMEM) and are read with pgm_read_byte on AVR. On other architectures the macro becomes a plain dereference. Printing slot n is simply writing character code n.

After an upload, the controller’s address counter still points into CGRAM, and a bare character write at that moment would overwrite part of a glyph. The buffered path is safe from this because flush() always sets a DDRAM address before writing. Code that uses layer 2 directly has to call writePosition() first.

9.2 The glyph cache

Applications usually need more than eight custom characters over their lifetime, and they swap sets as screens change. Uploading a set of eight costs about 65 ms of bus time, so re-uploading glyphs that are already there is a real waste. The library keeps an eight-entry table with the address of the bitmap currently loaded in each slot.

Glyphs are identified by the address of their bitmap and not by their pixels. That makes the check eight pointer comparisons (16 bytes of RAM for the table), where comparing contents would need up to 64 reads from flash. The consequence is that two arrays with identical pixels count as different glyphs, which in practice never matters because each glyph is defined once.

defineGlyph() returns the slot where the bitmap already is, without sending anything, or -1 after uploading it. An application can use that to test a whole set cheaply. Owake checks only whether the first glyph of its home set still sits in slot 0: if it does, the rest of the set is still there too, and seven more uploads are skipped. lcd << bitmap looks the bitmap up in the cache and prints its slot, or a * if it isn’t loaded, which is easier to spot than a wrong glyph.

9.3 5x10 characters

In 5x10 mode (one-row displays only) the controller has four slots instead of eight, placed 16 bytes apart in CGRAM, with 11 rows each (the eleventh is the cursor line). Slot n starts at CGRAM address 16n instead of 8n, and its character code is 2n instead of n, since the controller ignores the lowest bit of the code in this mode. The library handles the addressing and the code translation. The bitmap type still carries 8 rows, though, so the bottom three rows of a 5x10 glyph are sent blank.

10. The stream interface

Output uses operator<<, in the style of C++ streams:

lcd << "Temp: " << temperature << "'C" << cchar::endl;

The overloads cover characters, strings, uint8_t and the 16-, 32- and 64-bit integer types, the control characters, glyph bitmaps, and two setting types: writeMode and writeLayer. All integer types funnel into one 32-bit function (64-bit values are truncated), which formats according to the write mode:

  • raw sends the value itself as a character code. It is the default, so lcd << uint8_t(3) prints whatever glyph sits in slot 3. Applications that print numbers switch to writeMode::text once, as Owake does at boot.
  • text, hex and bin print the value in base 10, 16 or 2 through ltoa. The conversion buffer is sized for the worst case, 32+1+1=34 bytes for 32 binary digits, the terminator and a sign.

The same ergonomics could have come from inheriting Arduino’s Print class, and the library chose not to. Print works through virtual functions, which on AVR means a virtual table in SRAM, and it brings along an API (floating point formatting, String overloads) that this library deliberately stays away from. The operators are ordinary member functions that the compiler can inline or drop.

11. Lifetime and safety

An LCD owns its buffers and corresponds to one physical display, so copying one makes no sense. The copy constructor and copy assignment are deleted, and an accidental copy fails at compile time.

The destructor calls FAULT(ILLEGAL_OPERATION) from the author’s fault library. A display object is meant to be a global that lives as long as the program, and on AVR a global is never destroyed. If a destructor runs anyway, something created a temporary or a local LCD, which would also leak its buffers, and the fault screen says so.

The library also exposes isThereAnyLCD(), true once any display finished start(). The fault library uses it to decide whether it can print an error on the screen. That printing goes through an object named lcd, which the header declares as extern, so an application that uses both libraries is expected to name its display lcd.

12. Limitations

  • The display is write-only. The busy flag is never read, so every instruction waits a fixed time sized for the slowest case, and the screen contents can’t be read back. The RAM copy is the source of truth.
  • Geometry is limited by the settings fields and by the 8-bit flat index: up to 4 rows, and R⋅C≤255. 40x4 displays, which use two controllers, aren’t supported.
  • 5x10 glyphs only get 8 rows of pixel data.
  • 64-bit integers are printed as 32-bit.
  • clear() doesn’t mark cells dirty (section 6.5), which is intended but has to be kept in mind.
  • The library depends on Facebits (for its bitmask helpers) and on the fault library (for the destructor).

13. Future work

The remaining cost is in flush(), which sends a position and a character for every dirty cell, each as two bytes of two nibbles of two expander writes. Two changes would cut that down without changing what the display shows.

  1. Skip redundant addresses. After a character write, the controller’s address counter already points to the next cell. When the next dirty cell is the adjacent one, the position instruction can be left out, and a contiguous run of changed characters costs half as much.
  2. Batch expander writes. The PCF8574 updates its outputs after every data byte, including several bytes sent in a single I2C transmission. The E-high and E-low writes of a nibble, or all four writes of an LCD byte, could go in one transmission and pay the start condition and address byte once instead of four times.

Neither is implemented yet. The author is weighing the two, alone or combined.

14. Example

#include <SolidCrystalI2C.h>

const uint8_t arrow[8] PROGMEM = {0x10, 0x18, 0x1c, 0x1e, 0x1e, 0x1c, 0x18, 0x10};

LCD lcd(0x27, 16, 2, true);   // I2C address, columns, rows, overlay on

void setup()
{
    lcd.start();
    lcd.defineGlyph(arrow, 0);

    lcd << writeMode::text;
    lcd << "Temp: " << int16_t(23) << "'C" << cchar::endl;

    lcd.home(1);
    lcd << arrow << " Settings" << cchar::endl;

    // A mark in the corner that later text on row 0 won't erase
    lcd << writeLayer::overlay;
    lcd.row(0);
    lcd.column(15);
    lcd << '!';
    lcd << writeLayer::user;

    lcd.flush();
}

void loop()
{
}

References

  1. Hitachi, HD44780U (LCD-II) Dot Matrix Liquid Crystal Display Controller/Driver, datasheet, 1998.
  2. NXP Semiconductors, PCF8574 Remote 8-bit I/O expander for I2C-bus, datasheet.
  3. Huang, H. W. (2009). The HCS12/9S12: An Introduction to Software and Hardware Interfacing (2nd ed.), pp. 323-338. Delmar Learning.
  4. Hitachi HD44780 LCD controller, instruction set summary, Wikipedia.

This text by Mastedore. All rights reserved.