Interfacing a 16x2 LCD with AVR in 4-Bit Mode: Bare-Metal C Driver

The 16x2 character LCD based on the Hitachi HD44780 controller (or compatible chips) is the standard display in college projects: digital thermometers, voltmeters, token displays and so on. It shows two lines of 16 characters and is controlled through a simple parallel interface.

The display can use either an 8-bit or a 4-bit data bus. In 4-bit mode each byte is sent as two halves, saving four microcontroller pins at the cost of slightly slower writes. This tutorial builds a complete 4-bit driver from scratch for the ATmega328P, without any library, so you understand every step of the initialisation sequence.

LCD Pins and Wiring

LCD pinNameConnect to
1VSSGND
2VDD+5 V
3VO (contrast)Wiper of a 10 kΩ pot between 5 V and GND
4RSPB0 (register select: 0 = command, 1 = data)
5RWGND (we only write)
6EPB1 (enable strobe)
7 to 10D0 to D3Not connected in 4-bit mode
11 to 14D4 to D7PD4 to PD7
15A (backlight +)+5 V through a resistor if the module has none
16K (backlight -)GND

Using the upper four bits of port D for D4 to D7 makes the code simple: the high nibble of a byte can be written straight to PD4 to PD7. On an Uno these pins are D4 to D7, and RS and E are on D8 and D9.

How 4-Bit Communication Works

Every transfer puts a nibble on D4 to D7, sets RS, then pulses E high for at least about 450 ns and low again. The LCD latches the data on the falling edge of E. A full byte is sent as the high nibble first, then the low nibble. After each command the controller needs time to execute it: most instructions take about 37 µs, while Clear Display and Return Home take about 1.52 ms. Since RW is tied low we cannot read the busy flag, so the driver waits a fixed time that is safely longer.

Initialisation by instruction

After power-up the LCD is in 8-bit mode, and it may or may not have reset correctly if the supply rose slowly. The datasheet sequence below forces it into a known state and then switches to 4-bit mode.

Wait more than 40 ms after VDD reaches 4.5 V
Send nibble 0x3, wait > 4.1 ms
Send nibble 0x3, wait > 100 us
Send nibble 0x3, wait > 100 us
Send nibble 0x2            -> now in 4-bit mode
Command 0x28  function set: 4-bit, 2 lines, 5x8 font
Command 0x0C  display on, cursor off, blink off
Command 0x06  entry mode: increment, no shift
Command 0x01  clear display (wait 2 ms)

Complete Driver and Demo

HD44780 16x2 LCD 4-bit driver: RS = PB0, E = PB1, D4-D7 = PD4-PD7 (ATmega328P, 16 MHz)
#define F_CPU 16000000UL

#include <avr/io.h>
#include <util/delay.h>
#include <stdlib.h>
#include <stdint.h>

#define LCD_RS   PB0
#define LCD_E    PB1

/* Put a 4-bit value on PD4..PD7 and pulse E */
static void lcd_nibble(uint8_t nib)
{
    PORTD = (PORTD & 0x0F) | (nib << 4);   /* keep PD0..PD3 unchanged */
    PORTB |= (1 << LCD_E);
    _delay_us(1);                          /* E high > 450 ns */
    PORTB &= ~(1 << LCD_E);                /* data latched on falling edge */
    _delay_us(1);
}

/* Send a full byte: rs = 0 command, rs = 1 character */
static void lcd_byte(uint8_t value, uint8_t rs)
{
    if (rs) PORTB |=  (1 << LCD_RS);
    else    PORTB &= ~(1 << LCD_RS);

    lcd_nibble(value >> 4);                /* high nibble first */
    lcd_nibble(value & 0x0F);

    if (!rs && value <= 0x03)
        _delay_ms(2);                      /* clear / home are slow */
    else
        _delay_us(50);                     /* other commands ~37 us */
}

void lcd_cmd(uint8_t c)  { lcd_byte(c, 0); }
void lcd_data(uint8_t d) { lcd_byte(d, 1); }

void lcd_init(void)
{
    DDRB |= (1 << LCD_RS) | (1 << LCD_E);
    DDRD |= 0xF0;                          /* PD4..PD7 outputs */
    PORTB &= ~((1 << LCD_RS) | (1 << LCD_E));

    _delay_ms(50);                         /* power-on wait */
    lcd_nibble(0x03); _delay_ms(5);
    lcd_nibble(0x03); _delay_us(150);
    lcd_nibble(0x03); _delay_us(150);
    lcd_nibble(0x02); _delay_us(150);      /* switch to 4-bit */

    lcd_cmd(0x28);                         /* 4-bit, 2 lines, 5x8 */
    lcd_cmd(0x0C);                         /* display on, cursor off */
    lcd_cmd(0x06);                         /* auto-increment */
    lcd_cmd(0x01);                         /* clear */
}

/* row 0 or 1, col 0..15 */
void lcd_goto(uint8_t row, uint8_t col)
{
    uint8_t addr = (row == 0 ? 0x00 : 0x40) + col;
    lcd_cmd(0x80 | addr);                  /* set DDRAM address */
}

void lcd_print(const char *s)
{
    while (*s)
        lcd_data(*s++);
}

void lcd_print_uint(uint16_t n)
{
    char buf[6];
    utoa(n, buf, 10);                      /* number to decimal text */
    lcd_print(buf);
}

int main(void)
{
    uint16_t count = 0;

    lcd_init();
    lcd_goto(0, 0);
    lcd_print("AVR 4-bit LCD");

    while (1) {
        lcd_goto(1, 0);
        lcd_print("Count: ");
        lcd_print_uint(count++);
        lcd_print("    ");                  /* erase leftover digits */
        _delay_ms(250);
    }
}

Key Points in the Driver

lcd_nibble() changes only PD4 to PD7 by masking PORTD with 0x0F before OR-ing in the new nibble, so PD0 and PD1 remain free for the USART. The E pulse uses one-microsecond delays, which comfortably exceeds the minimum pulse width.

lcd_byte() checks whether the command is Clear Display (0x01) or Return Home (0x02 or 0x03) and waits 2 ms for those; all other operations get 50 µs. The DDRAM address for the start of line 2 is 0x40, not 16, which is why lcd_goto() adds 0x40 for row 1. Setting bit 7 (0x80) turns the address into a Set DDRAM Address command.

To print numbers, utoa() from <stdlib.h> converts a value to text. For floating point values such as the LM35 temperature from the ADC tutorial, you can print the integer part and the tenths separately, or use dtostrf(), which is much smaller than linking printf with float support.

Troubleshooting

  • Only black boxes on line 1: initialisation failed. Check RS, E and the D4 to D7 order; swapped data lines are very common.
  • Random characters after a reset: the LCD was left in the middle of a 4-bit transfer. The three 0x3 nibbles in lcd_init() exist exactly to recover from this.
  • Text appears but some characters are skipped: delays are too short, often because F_CPU is set lower than the real clock, or the module is a slow clone. Increase the 50 µs delay.
  • Second line text appears at the end of line 1: you wrote past column 15 instead of moving to address 0x40.

Summary

A 16x2 HD44780 LCD needs only six AVR pins in 4-bit mode. Each byte is sent as two nibbles latched by the falling edge of E, with RS choosing command or data. Follow the reset-by-instruction sequence of three 0x3 nibbles and one 0x2, then configure with 0x28, 0x0C, 0x06 and 0x01. With lcd_goto() and lcd_print() you now have a display for every project in this series.