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 pin | Name | Connect to |
|---|---|---|
| 1 | VSS | GND |
| 2 | VDD | +5 V |
| 3 | VO (contrast) | Wiper of a 10 kΩ pot between 5 V and GND |
| 4 | RS | PB0 (register select: 0 = command, 1 = data) |
| 5 | RW | GND (we only write) |
| 6 | E | PB1 (enable strobe) |
| 7 to 10 | D0 to D3 | Not connected in 4-bit mode |
| 11 to 14 | D4 to D7 | PD4 to PD7 |
| 15 | A (backlight +) | +5 V through a resistor if the module has none |
| 16 | K (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
#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_CPUis 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.