The 16x2 character LCD

Two rows of sixteen characters, on sixteen pins or on four. The HD44780 command set, the busy flag that catches everybody, and the I2C backpack.

The character LCD is the display in every kit, and it is not a screen in the sense the OLED is. There is no framebuffer and no graphics: a sketch sends it characters, and the controller has the shapes for them in a ROM. That makes it the cheapest way to put a number in front of somebody, and it is why it has outlived several generations of nicer displays.

Mokxi has it twice, because the shops sell it twice.

Two parts, one controller

Character LCD, 16x2 is the bare module: sixteen pins along one edge, and your sketch drives the HD44780 directly. Six wires from the board is the usual hookup: RS, E and four data lines, plus power, a contrast pin and the backlight.

Character LCD with I2C backpack is the same panel with a little board soldered to the back of it: a PCF8574 eight-bit port expander, a contrast trimmer and a transistor for the backlight. It brings out four pins (GND, VCC, SDA, SCL), and it is the one most kits ship now.

Behind them is one model (crates/parts/src/parts/hd44780.rs): the same command decode, the same memory, the same busy timing, the same pixels. Only the way a byte arrives differs.

What the controller does

Everything the datasheet says, decoded from the highest set bit of the byte down: clear, home, entry mode, display and cursor and blink on and off, shift, function set, set the CGRAM address, set the DDRAM address.

Two of those are worth knowing about.

DDRAM is eighty bytes and the window is sixteen. Row 0 is addresses 0x00 to 0x27 and row 1 is 0x40 to 0x67, so each line is really forty characters and the panel is a window onto sixteen of them. Print past column 16 and the characters go somewhere nobody can see until you shift the display, which is the first surprise.

CGRAM is eight characters of your own. Write eight bytes of five bits each and print character code 0 to 7 to get them back. They are drawn by exactly the same code that draws the built-in font, so a custom degree sign or a battery icon looks like it belongs.

The busy flag, which is the thing that goes wrong

A write takes the controller 37 microseconds and a clear takes 1.52 milliseconds, and it ignores the bus entirely for that long. Mokxi models that: a byte clocked in while the controller is busy is dropped, exactly as it is on the bench.

That is not us being awkward. It is the bug behind every "my LCD prints rubbish" question on every forum: a driver with no waits loses half a byte, the four-bit nibble phase slips, and from then on every character is garbage. Our own driver (firmware/lib/mokxi_hd44780.h) waits 50 microseconds after a byte and 2 milliseconds after a clear, which is what the Arduino libraries do.

Blank displays, and which one you have

Both modules can show you a perfectly correct, perfectly invisible display, but for different reasons.

On the bare module it is contrast. V0 is an analog input, and at the supply rail the characters vanish completely. That is what the little blue pot on a real one is for, and it is why a first LCD so often looks dead. Wire V0 to ground for maximum contrast; leave it unwired and Mokxi treats it as ground, which is how the cheap modules with a fixed resistor behave.

On the backpack the trimmer is a property rather than a pin, so contrast is not the problem. The backlight is. It is bit 3 of the port byte and nothing else, so a driver that never sets that bit leaves you with a working display in a dark window. begin() in mokxi_lcd1602_i2c.h turns it on; backlight(false) turns it off again, and the panel goes blank on the canvas when it does.

The backpack's port byte

There is no display protocol on the I2C bus at all. The expander has one register (its eight output pins), and every byte after the address lands on them. The backpack wires them to the LCD in an order every Arduino library hard-codes, and so does Mokxi:

bit goes to
P0 RS
P1 RW
P2 E
P3 the backlight
P4 to P7 D4 to D7

So one character is six writes to the expander: the high nibble with E low, high, low again, then the same three for the low nibble. The controller latches on the falling edge of E, exactly as it does on the bare module.

The address is 0x27 on a PCF8574 module and 0x3F on a PCF8574A one, and the two are physically identical. It is a property, so you can set whichever your board would have been.

Writing to one

Both drivers share their text half, so the calls are the same:

#include "mokxi_lcd1602_i2c.h"
Lcd1602I2c lcd(A5, A4);        // SCL, SDA

void setup() {
  lcd.begin();                 // four-bit mode, backlight on, cleared
  lcd.print("Hello");
  lcd.setCursor(0, 1);
  lcd.printFixed(215, 1);      // "21.5"
  lcd.padLine();               // wipe the tail of anything longer
}

printFixed takes a number scaled by a power of ten rather than a float, which keeps the driver small on an Uno's 32 KB; lcd.print(21.5, 1) on a float works too, through LiquidCrystal_I2C.h. padLine spaces out to the end of the row, which is how a shorter reading wipes a longer one without a clear() and its 1.52 millisecond wait.

For the six-wire module it is mokxi_lcd1602.h and Lcd1602 lcd(rs, e, d4, d5, d6, d7), and everything after begin() is the same.

The LiquidCrystal_I2C spelling

A tutorial for the backpack module compiles as it stands:

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);

void setup() {
  lcd.init();
  lcd.backlight();
  lcd.setCursor(0, 0);
  lcd.print("Hello, world!");
}

LiquidCrystal_I2C.h is Mokxi's own header on the driver above, on Wire's pins: A4 and A5 on an Uno, the board's own I2C pins elsewhere (each board's help article says which), or wherever Wire.begin(sda, scl) put them. print is the board's own, so a float prints with two places as it does on Serial. init() turns the backlight on, where the upstream library leaves that to backlight(). The six-wire LiquidCrystal.h is not shipped: use mokxi_lcd1602.h, and the build says so if you forget.

What is not modeled

Reads of any kind. The busy flag, the address counter and DDRAM read back nothing, so a driver that polls the busy flag instead of waiting will hang. Wait instead; that is what every real library does anyway. Also not modeled: the 5x10 font a one-line module can select, and the viewing angle.

The busy timings are the datasheet's two typical numbers (37 microseconds for a write and 1.52 milliseconds for clear and home), used as exact values. A real HD44780's timing scales with whatever its internal oscillator happens to be running at, so those figures move by tens of percent between parts and with temperature, and the 4-bit nibble timing (the minimum E pulse width and the hold after it) is not checked here at all: a driver can clock E as fast as it likes.

A byte arriving while the controller is busy is dropped. That is a choice the model makes so the fault is visible and repeatable; on a real part the outcome is undefined and more often a corrupted character than a lost one.

On the backpack, the PCF8574 never stretches the clock and acknowledges every byte sent to it. There is no read of the expander's port, no NACK and no bus error of any kind, and the backlight transistor is bit 3 and nothing else: no current, no brightness. Contrast on the backpack is a property rather than the trimmer's actual divider.

See it: Thermometer on an I2C LCD for the backpack, and Thermometer in the templates for the six-wire hookup.

If an I2C LCD stays blank, shows blocks or prints leftovers, Arduino I2C LCD not working goes through the causes by symptom.