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.
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 |
|---|---|---|---|---|
| 0 | Blank | 0b00000 | 0 | 0x00 |
| 1 | Blank | 0b00000 | 0 | 0x00 |
| 2 | Right bar only | 0b00001 | 1 | 0x01 |
| 3 | Right bar only | 0b00001 | 1 | 0x01 |
| 4 | Right + Middle | 0b00101 | 5 | 0x05 |
| 5 | Right + Middle | 0b00101 | 5 | 0x05 |
| 6 | All three bars | 0b10101 | 21 | 0x15 |
| 7 | All three bars | 0b10101 | 21 | 0x15 |
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 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 typically0x27. - PCF8574A: Base address is
0x38. With jumper pads open, the address is typically0x3F.
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.






