A custom LCD character is a user-defined 5x8 pixel glyph stored in the display controller's volatile CGRAM, allowing you to render symbols not found in the factory ROM. When you program a custom character, you are not changing the physical wiring or the I2C/SPI bus state; instead, you are altering the display controller's internal memory map. You map standard ASCII address slots to user-defined byte arrays, tricking the controller into drawing your specific pixel pattern whenever that memory address is called.

The most common mistake makers make is confusing CGRAM (Character Generator RAM, which is volatile and limited to 8 custom slots) with CGROM (Character Generator ROM, which is factory-masked and holds thousands of standard ASCII and Japanese Katakana characters). Another frequent point of confusion is treating a character LCD (like the ubiquitous HD44780) like a graphical LCD (like the ST7920 or ILI9341). Graphical LCDs use a framebuffer where you can draw pixel-by-pixel anywhere on the screen; character LCDs are strictly bound to a grid (usually 5x8 pixels per cell) and require you to load your glyph into a specific memory slot before rendering it.

The Math Behind the Pixels: A Worked Numeric Example

Every custom character on an HD44780-compatible display is exactly 5 pixels wide and 8 pixels tall. To define a character, you must translate a visual grid into an 8-byte array. Let's build a custom "Wi-Fi Signal" icon to display on an ESP32-based smart home sensor.

Grid Size: 5 columns × 8 rows | Memory Limit: 8 custom slots (0-7) | Controller: HD44780 / ST7066U

We map the pixels row by row, from top (Row 0) to bottom (Row 7). A lit pixel is a 1, and an unlit pixel is a 0. Because the grid is 5 bits wide, the maximum binary value per row is 0b11111 (31 in decimal).

Row Visual Grid (5-bit) Binary Value Decimal Hex
0Blank0b0000000x00
1Blank0b0000000x00
2Right bar only0b0000110x01
3Right bar only0b0000110x01
4Right + Middle0b0010150x05
5Right + Middle0b0010150x05
6All three bars0b10101210x15
7All three bars0b10101210x15

In your Arduino or ESP32 code, this translates directly into a byte array:

byte wifiSignal[8] = {
  0b00000,
  0b00000,
  0b00001,
  0b00001,
  0b00101,
  0b00101,
  0b10101,
  0b10101
};

Where You Meet This in Practice

You will encounter custom LCD characters heavily in embedded systems that require status indicators but lack the budget, pin count, or processing overhead for a full graphical TFT display.

  • 3D Printer Firmware: Marlin and Klipper screens use custom glyphs to render the heated bed icon, the nozzle thermometer, and the progress bar blocks on standard 12864 or 20x4 character displays.
  • DIY Battery Management Systems (BMS): Makers building 18650 or LiFePO4 power banks use custom characters to draw battery outlines that dynamically fill in based on voltage readings from an ADS1115 ADC.
  • Smart Home Dashboards: Wall-mounted ESP32 thermostats use custom arrows and degree symbols (°) that aren't reliably mapped in the standard ASCII table of cheaper clone displays.

Real-World Scenario Walkthrough: ESP32 Battery Monitor

The Setup: An ESP32 DevKit V1 connected to a 20x4 character LCD via a PCF8574 I2C backpack. The goal is to display a custom "Low Battery" warning icon next to the voltage reading.

The Numbers: We define a custom battery outline in slot 0. The I2C address of the backpack is scanned and found to be 0x27. We allocate slot 0 using lcd.createChar(0, batteryIcon);.

The Outcome: The code compiles and uploads. The LCD backlight turns on, the voltage reads "12.4V", but the custom battery icon prints as a garbled Japanese character, and the rest of the text on the line is shifted or missing entirely.

What Went Wrong (The CGRAM Pointer Trap): This is the most common failure mode for beginners. When you call createChar(), the HD44780 controller moves its internal memory pointer from DDRAM (where text lives) to CGRAM (where custom characters are built). If you immediately call lcd.print() after creating the character, the controller tries to write your text into the CGRAM or behaves unpredictably because the pointer hasn't been reset.

The Fix: You must explicitly command the pointer back to DDRAM by calling lcd.setCursor(col, row) or lcd.home() immediately after createChar() and before you print anything to the screen.

Hardware Limits and I2C Backpack Quirks

When wiring up these displays, the physical layer introduces its own set of constraints that dictate how your custom characters behave.

The 8-Slot Hard Limit

The HD44780 controller has exactly 64 bytes of CGRAM. Since each 5x8 character takes 8 bytes, you can only store 8 custom characters (slots 0 through 7) at any given time. If your dashboard requires 10 different custom icons, you must dynamically overwrite slots in CGRAM on the fly using createChar() right before you need to print them. Be aware that rewriting CGRAM takes a few milliseconds; doing it inside a tight loop() without delays can cause I2C bus congestion on the ESP32.

PCF8574 vs PCF8574A Addressing

Most 16x2 and 20x4 LCDs use an I2C backpack based on the PCF8574 or PCF8574A chip. They are not interchangeable in code without changing the address:

  • PCF8574: Base address is 0x20. With A0, A1, A2 jumper pads open (pulled high), the address is typically 0x27.
  • PCF8574A: Base address is 0x38. With jumper pads open, the address is typically 0x3F.

If your custom characters are failing to load and the screen is blank, run an I2C scanner sketch first. The ESP32's I2C implementation is stricter than the Arduino Uno's; it requires proper pull-up resistors (usually 4.7kΩ on SDA and SCL) to prevent clock-stretching timeouts during the multi-byte CGRAM write sequence.

Library Selection for ESP32

While the legacy LiquidCrystal library works on 8-bit AVRs, it frequently fails on ESP32 boards due to I2C timing issues. For ESP32 projects, always use Bill Perry's hd44780 library. It includes an auto-detect diagnostic sketch that identifies the I2C address, the backpack pin mapping, and verifies CGRAM write speeds, saving you hours of bench debugging.

FAQ: Custom LCD Glyph Troubleshooting

Why does my custom character look like a solid black block?

You likely passed a slot number greater than 7 into your write() function. The HD44780 maps slots 0-7 to addresses 0-7, but if you call lcd.write(8) or higher, you are addressing the CGROM, which often defaults to a solid block or a specific Japanese character depending on the ROM mask (A00 vs A02).

Can I animate custom characters?

Yes, but with caveats. You can animate a character by repeatedly calling createChar() to overwrite the same slot with new byte arrays. However, I2C bandwidth is limited. At 100kHz, rewriting an 8-byte character takes roughly 1-2ms. If you animate 4 characters at 30fps, you will consume a noticeable portion of your I2C bus bandwidth, potentially delaying sensor reads on the same bus.

Why is the 5th column of my custom character always lit?

Character LCDs actually have a 5x8 pixel grid for the glyph, plus a 1-pixel wide blank column used for spacing between characters. If your binary values exceed 5 bits (e.g., you accidentally use 0b111111 instead of 0b011111), the controller truncates the 6th bit, but poorly cloned controllers sometimes bleed the overflow into the spacing column, causing visual artifacts.

Do I need to redefine custom characters after a power cycle?

Yes. CGRAM is volatile SRAM. Every time the display loses power or the VCC line browns out below 4.5V, the CGRAM is wiped. You must run your createChar() routines inside setup() or immediately after waking the ESP32 from deep sleep.