Translate

Search this blog

Tuesday, April 15, 2025

Microcontroller as LCD driver library

In this article I will be showing you how to drive a raw LCD, that doesn't have a driver chip, using a microcontroller. Usually one would use a dedicated LCD driver chip or a microcontroller with build-in dedicated hardware, but they might be out of stock, or for some reason don't fit your needs. A generic microcontroller can be used to drive the LCD and also to run the application code. For a better understanding of this article I recommend reading the previous one: https://www.programming-electronics-diy.xyz/2025/04/its-all-about-lcd.html

The library presented here is developed on ATmega328PB but since is C code, it can be ported on other platforms with slight modifications of timer registers. The LCD driver can be controlled with the user application running on the driver MCU or using I2C commands.

Contents

 

Characteristics

Like with any solution there is advantages and disadvantages. Here are few points that I am aware of.

Microcontroller as LCD driver

Pros

  • Availability and flexibility: any type or family of microcontrollers can be used since the C code can be ported to other platforms with minor tweaking.
  • Only one IC: a single microcontroller could be used for both driving the LCD and running your code provided the application is not very resource-intensive. For example generating high frequency interrupts that could interfere with the LCD driver interrupts. Driving the display does not take too much CPU time. At 30Hz refresh rate, the interrupt will trigger every 4ms and at 16MHz will complete in about 120us. Rest of the time the CPU is idle. Another resource is number of pins. Using an ATmega324 with 44 pins could drive a regular display and the remaining pins can be used for other purposes. 
  • Memory saving: mapping of segments to pins and ASCII bitmaps can be stored on the LCD driver in a custom form, saving memory on the controller microcontroller.
  • Short interface commands: the display driver can be controller via I2C using only 3 bytes to set up a digit.

Cons

  • Requires 2 quad buffers and 8 resistors, to drive 4 COM pins. However I believe those can be replaced with 4 PWM pins to produce VCC/2.
  • Limited bias: only 1/2 bias using PWM or buffers. Works well for 1/4 MUX displays. 
 

Hardware

Schematic of microcontroller as an LCD driver

The project was tested on a 1/4 MUX display, meaning it has 4 COMs or backplanes on an ATmega328PB microcontroller. Two quad 74AHC125D buffers are used to produce 1/2 Bias with 3 voltage levels: VCC, VCC/2 and GND. Instead of buffers, two microcontroller pins could be used for each COM pin but that would require 8 pins for 4 COMs. The buffers are connected in pairs to reduce the number of pins needed. When the number of COMs is even, an extra microcontroller pin is needed. So for 4 COMs 5 pins are needed, and for 3 COMs 3 pins are needed. This idea was inspired by AN016203-0708 application note.

To set a COM pin to VCC both buffer inputs are VCC. For VCC/2 one input is VCC and the other one GND. For a GND output both inputs are set to GND. In the links section there is a spreadsheet calculator made in LibreOffice used to generate the binary values to drive the COM pins. The relationship between microcontroller pins and buffers is color coded.

Spreadsheet calculator for buffer LCD drive
 

LCD driver at 1.5V

Usually LCD displays are used in battery powered devices using one or two cells. A microcontroller such as ATmega328PB can run down to 1.8V at a lower frequency. Near depletion, a 1.5V battery will give 1V so two batteries can power the microcontroller. When using only one 1.5V battery, you could use a one stage voltage doubler made using two capacitors and two diodes. However since the microcontroller won't start at 1.5V, a low power oscillator is needed to generate the required frequency.

I have tried doubling the voltage only for supplying the buffers but that doesn't work. The microcontroller driving the segments, needs to output the same voltage as the buffers driving the COMs otherwise there will be DC across the LCD segments which is not good.

Software

Configuration

Since this solution offers flexibility, it needs some configuration to fit your requirements. These can be found in the lcd.h file.

F_CPU

The CPU frequency. Consult the datasheet for the maximum frequency vs voltage. On battery powered devices, the lower the CPU frequency the less power consumed.

FRAME_FREQUENCY

Display frame rate. Typically between 30 and 120Hz. A lower frame rate consumes less power. Higher frame rate can produce ghosting and also the timer interrupt will trigger more often.

BACKPLANES

Number of COM pins (backplanes) excluding the extra COM pin needed when number of backplanes is even.

BACKPLANES_MASK

Mask used to change only the COM pins without affecting the rest of the port pins. Set 1 for each backplane starting from LSB. If the number of backplanes is even, then one extra pin is needed. Add a 1 for that too.

For a 4 COM LCD this would be: 0b00011111. Four 1's for COM pins and another 1 for the extra COM pin needed.

LCD_I2C_ADDR

Device I2C address starting from 1. Address 0 is reserved for general call.

I/O pins

The LCD pins for COMs and segments must be configured in the I/O section by adding or removing defines depending on your display. Here the defines are for DDR, PORT and pin number. The pins must also be set in _lcd_setPins() and the interrupt inside lcd.c.

Display memory

Display memory is organized in rows and columns as illustrated below.

Display RAM organization bitmap for MUX 1:4
Display RAM organization bitmap for MUX 1:4 from NXP PCF8551 datasheet

Each row represent a segment number starting from 0 and each COM column has it's own copy of rows. The decoding function is used to set each segment in the display RAM.

Mapping of COM and segment pins

An array is used as a LUT (Look-up-table) to map a configuration of COM and segment pin number needed to activate an LCD segment or icon. As a template example I have included the HTC1.h and HTC1.c files that are used to drive the LCD display found in the HTC-1 clocks.

const uint8_t LUTcom_seg_digit[][2] = {
    {0, 12}, // a: 0,12 - [0] of the digit with lowest segment number
    {1, 12}, // b: 1,12 - [1]
    {2, 12}, // c: 2,12 - [2]
    {3, 12}, // d: 3,12 - [3]
    {2, 13}, // e: 2,13 - [4]
    {0, 13}, // f: 0,13 - [5]
    {1, 13}, // g: 1,13 - [6]
};

The above array represents a 7-segment digit.

7-segment digit

Byte 0 represents the COM number and byte 1 the segment number. First array element represents segment A and for this particular display, the segment is connected to COM0 segment pin 12. To control other digits of the display, an offset is used that is added to all segment numbers in the array. Thus this array must be formed for the digit with the lowest segment number value.

Above array is used to form ASCII characters such as numbers and letters. For displays that include icons, there is another array.

const uint8_t LUTcom_seg_symbols[][2] = {
    {0, 17}, // volume
    {2, 17}, // volume (very dim if only one is used)
};

For example the speaker icon on HTC-1 display is located on a set of two lines COM0 segment 17 and COM2 and segment 17.

Mapping of segments to ASCII

To form ASCII characters, the following array is used.

const uint8_t ASCII_Symbols[][2] = {
	{0b00000000, 0b00000000}, // space 0x20
	{0b00000000, 0b00000000}, // ! N/A
	{0b00000000, 0b00000000}, // " N/A
	{0b00000000, 0b00000000}, // # N/A
	{0b00000000, 0b00000000}, // $ N/A
	{0b00000000, 0b00000000}, // % N/A
	{0b00000000, 0b00000000}, // & N/A
	{0b00000000, 0b00000000}, // ' N/A
	{0b00000000, 0b00000000}, // ( N/A
	{0b00000000, 0b00000000}, // ) N/A
	{0b00000000, 0b00000000}, // * N/A
	{0b00000000, 0b00000000}, // + N/A
	{0b00000000, 0b00000000}, // , N/A
	{0b00000001, 0b10000000}, // - (bit 6)
	{0b00000000, 0b00000000}, // . N/A
	{0b00000000, 0b00000000}, // / N/A
	{0b00000000, 0b00111111}, // 0 (bits 0, 1, 2, 3, 4, 5)
	{0b00000000, 0b00000110}, // 1 (bits 1, 2)
	{0b00000000, 0b01011011}, // 2 (bits 0, 1, 6, 4, 3)
	{0b00000000, 0b00000000}, // 3 (bits 16, 15, 11, 9, 7, 3, 1)
	{0b00000000, 0b00000000}, // 4 (bits 16, 15, 9, 3, 2, 1)
	{0b00000000, 0b00000000}, // 5 (bits 16, 15, 11, 9, 7, 2, 1)
	{0b00000000, 0b00000000}, // 6 (bits 16, 15, 14, 11, 9, 7, 2, 1)
	{0b00000000, 0b00000000}, // 7 (bits 13, 9, 7, 4)
	{0b00000000, 0b00000000}, // 8 (bits 16, 15, 14, 11, 9, 7, 3, 2, 1)
	{0b00000000, 0b00000000}, // 9 (bits 16, 15, 11, 9, 7, 3, 2, 1)
	{0b00000000, 0b00000000}, // : N/A
	{0b00000000, 0b00000000}, // ; N/A
	{0b00000000, 0b00000000}, // < N/A
	{0b00000000, 0b00000000}, // = N/A
	{0b00000000, 0b00000000}, // > N/A
	{0b00000000, 0b00000000}, // ? N/A
	{0b00000000, 0b00000000}, // @ N/A
	{0b00000000, 0b00000000}, // A N/A
	{0b00000000, 0b00000000}, // B N/A
	{0b00000000, 0b00000000}, // C N/A
	{0b00000000, 0b00000000}, // D N/A
	{0b00000000, 0b00000000}, // E N/A
	{0b00000000, 0b00000000}, // F N/A
	{0b00000000, 0b00000000}, // G N/A
	{0b00000000, 0b00000000}, // H N/A
	{0b00000000, 0b00000000}, // I N/A
	{0b00000000, 0b00000000}, // J N/A
	{0b00000000, 0b00000000}, // K N/A
	{0b00000000, 0b00000010}, // L N/A
	{0b00000000, 0b00000000}, // M N/A
	{0b00000000, 0b00000000}, // N N/A
	{0b00000000, 0b00000000}, // O N/A
	{0b00000000, 0b00000000}, // P N/A
	{0b00000000, 0b00000000}, // Q N/A
	{0b00000000, 0b00000000}, // R N/A
	{0b00000000, 0b00000000}, // S N/A
	{0b00000000, 0b00000000}, // T N/A
	{0b00000000, 0b00000000}, // U N/A
	{0b00000000, 0b00000000}, // V N/A
	{0b00000000, 0b00000000}, // W N/A
	{0b00000000, 0b00000000}, // X N/A
	{0b00000000, 0b00000000}, // Y N/A
	{0b00000000, 0b00000000}, // Z N/A
	{0b00000000, 0b00000000}, // [ N/A
	{0b00000000, 0b00000000}, // \ N/A
	{0b00000000, 0b00000000}, // ] N/A
	{0b00000000, 0b00000000}, // ^ N/A
	{0b00000000, 0b00000000}, // _ N/A
};

At the moment most are set to 0 but numbers 0, 1 and 2 can be used as an example. Depending on the display type, some characters can not be formed. For example letter D cannot be formed on a 7-segment display but on a 14-segments it can.

The ASCII character is selected based on it's number value in ASCII table. For example the space is represented by the hexadecimal number 0x20 and the exclamation mark by 0x21 and so on. Since the array starts from 0, the 0x20 is subtracted from the input ASCII when the array is accessed like this: ASCII_Symbols['1'].

Segment encoding

Each ASCII character or symbol is represented by 2 bytes which adds up to 16 bits. Bits are counted from 0 from right to left up to 15. Each bit maps to an element in the LUTcom_seg_digit array. To form number 1 for example, wee need to activate segments B and C that in the array are found at index 1 and 2. There is room for up to 16 segments. The unused elements in the ASCII array should not be removed since that would break the indexing.

To define icons there is another array, but this time it maps to the equivalent symbols array.

const uint8_t Symbols[][2] = {
	{0b00000000, 0b00000011},  // volume (entry 0, 1) (index 0)
};

 

API

Initialization

void lcd_Init(void)

Set COM and segment pins as output low.  Configure the timer used to generate interrupts to drive the active segments. Configure TWI0 to accept commands.

Decoding

void lcd_Decode(uint8_t symbol, uint8_t offset, uint8_t type, uint8_t state)

Decode function that sets the active segments in the display memory array.

symbol

Encoded symbol from the ASCII or symbols array.

offset

Used to select the digit based on the segment offset relative to the digit with the lowest segment number.

type

Digit or symbol. Available constants: LCD_DECODE_TYPE_DIGIT, LCD_DECODE_TYPE_SYMBOL.

state

When the symbol is of LCD_DECODE_TYPE_SYMBOL type, then the state is used to turn on or off the symbol. Can be: SYMBOL_OFF, SYMBOL_ON.

Display On

void lcd_DisplayON(void)

Enables the timer interrupt to drive the display.

Display Off

void lcd_DisplayOFF(void)

Disables the timer interrupt that drives the display. Set display pins low.

Fill display

void lcd_FillDisplay(uint8_t value)

Set all segments in display memory array to 0x00 or 0xFF.

Frame complete

bool lcd_FrameComplete(void)

Returns true if a frame is completed. Not sure if it's really needed but it might help  in preventing flickering if the display is updated mid-frame.

Set/clear pin

void lcd_SetPin(uint8_t pin)
void lcd_ClearPin(uint8_t pin)

These are located inside the main.c and can be used to control the unused pins of the LCD driver. If used, these functions should be customized according to your project.

Serial interface

When the LCD driver is used with an external controller, the above functions can be accessed using I2C interface.

Regardless of what function is used, the protocol consists of the following 3 byte format: command, argument 1, argument 2. If the function doesn't take an argument, the arguments must still be sent with any value.

List of commands

enum Commands{
    DISPLAY_DIGIT = 0,
    DISPLAY_SYMBOL,
    DISPLAY_ON,
    DISPLAY_OFF,
    DISPLAY_FILL,
    SET_PIN,
    CLEAR_PIN
};

v1.0 LCD driver for AVR Folder including:
- lcd, HTC1, twi
Changelog
v1.0 (12-04-2025)
Public release under GNU GPL v3 license.

No comments:

Post a Comment