
Interfacing of LCD with 8051 Microcontroller in Proteus ISIS

Hello friends! Today, we are going to interface a character LCD with an 8051 microcontroller in Proteus. We will use an AT89C51 to send commands and text to the display, then explain how the LCD decides where each character should appear.
This is the next step after our 8051 serial communication tutorial. A serial terminal is useful during development, while an LCD lets a project present information directly to its user. Both are valuable when we need to observe values inside an embedded program.
The original project actually uses an LM044L-style 20×4 display, so we will follow that arrangement throughout this lesson. A 20×4 LCD has twenty character positions on each of four rows. The same interface principles apply to many 16×2 modules, but their visible layout and row handling are different.
Understanding the Character LCD
A character LCD contains a controller that manages character patterns and display memory. The microcontroller does not draw every visible pixel individually. Instead, it sends character codes and instructions through the module's interface.
For example, sending the character 'A' as data places an A at the current cursor address. Sending a cursor-position instruction changes where the next character will be written. These two operations use the same data wires, so a separate control signal tells the LCD how to interpret the byte.
This project uses a parallel interface compatible with the familiar HD44780 command structure. It is not an I2C display backpack, a graphical LCD or a modern phone screen. Those devices may also display text, but their communication and initialization procedures are different.
Commands and character data
The RS signal selects the register being accessed. With RS low, the byte is interpreted as a command. With RS high, it is interpreted as display data. The enable signal provides the write pulse after the control and data signals have been established.
R/W determines whether the host reads or writes. We connect it to ground in this tutorial, giving a write-only interface. Because the program cannot read the busy flag in that arrangement, it must allow enough time for each instruction to finish.
The original HD44780U controller datasheet documents the command set and bus timing. A compatible physical module may have its own timing and voltage specifications, which should be checked before treating an example as a finished hardware design.
Components Used in This Project
| Component | Purpose |
|---|---|
| AT89C51 | Runs the C program and drives the display interface |
| 20×4 character LCD | Displays four rows of text |
| 11.0592 MHz crystal | Clock used by the illustrated microcontroller circuit |
| Crystal load capacitors | Complete the oscillator network |
| Reset resistor, capacitor and pushbutton | Provide startup and manual reset functions |
| Contrast adjustment | Sets the readable contrast on physical LCD hardware |
| Proteus and Keil C51 | Simulate the circuit and build its firmware |
The older tutorial used Keil µVision 3. You need the C51 tools for this processor family; the exact editor version is less important than selecting the correct device and generating the right machine code. An Arduino sketch cannot be loaded directly into an AT89C51.
The AT89C51 is the specific device shown here. Refer to its manufacturer datasheet when checking package pins, reset behavior and electrical limits. Do not silently substitute another 8051 derivative and assume every timing detail remains the same.
Connecting the LCD to the AT89C51
The original schematic uses Port 2 as an eight-bit data bus. P1.0 controls RS and P1.1 controls E. That gives a straightforward connection in which one write transfers a complete byte.
| LCD signal | Connection | Function |
|---|---|---|
| VSS | Ground | Supply reference |
| VDD | Suitable positive supply | LCD logic power |
| V0 or VEE | Contrast-control voltage | Character contrast |
| RS | P1.0 | Command/data selection |
| R/W | Ground | Write-only operation |
| E | P1.1 | Enable pulse |
| D0 through D7 | P2.0 through P2.7 respectively | Parallel data bus |
Keep the data bits in order. If P2.0 reaches D7 instead of D0, the LCD receives a different bit pattern from the one the program intended. Matching labels on the schematic are useful, but inspect the actual net connections before assuming the bus is correct.
Two details to correct when using the old drawing
The manual-reset button in the historical image connects RST toward ground. The AT89C51 reset input is active high, so that button connection does not provide the intended reset action. For a manual reset, arrange the button to assert the appropriate high level on RST while retaining a suitable pull-down and startup network.
The LCD contrast pin is also shown at a fixed connection in the simulation. For typical physical character modules, an adjustable contrast voltage makes setup much easier. A potentiometer across the appropriate supply rails with its wiper connected to V0 is a common arrangement, but follow the particular module's specified contrast circuit.
Backlight pins, when present, are separate from the LCD data interface. Check whether the module includes a current-limiting resistor and what supply it requires. A working backlight does not establish that the controller has initialized or that its contrast is correctly adjusted.
Eight-Bit Mode and Four-Bit Mode
In eight-bit mode, D0 through D7 carry a byte together. In four-bit mode, only D4 through D7 are used, and each byte is transferred in two parts. Four-bit mode saves four microcontroller pins, but the initialization and transfer functions must match that choice.
We will retain eight-bit mode because it matches the original circuit and keeps the first driver easy to inspect. The LCD therefore occupies eight Port 2 pins plus RS and E. R/W is grounded, so it does not consume another controlled output.
Do not connect only four data lines while using the eight-bit driver below. Likewise, adding an I2C backpack does not make this program communicate over I2C. A wiring change that alters the interface requires a corresponding driver change.
LCD Commands Used in the Program
| Command | Meaning in this example |
|---|---|
| 0x30 | Part of the explicit eight-bit startup sequence |
| 0x38 | Eight-bit interface with the controller's two-line organization and 5×8 characters |
| 0x08 | Display off during setup |
| 0x01 | Clear display memory and return the address to the beginning |
| 0x06 | Increment the address after each character without shifting the display |
| 0x0C | Display on with cursor and blinking disabled |
The controller's “two-line” setting is not a statement that the physical module can show only two rows. A common 20×4 module arranges its visible rows within the controller's internal line structure. This is also why its row addresses do not advance in the simple order a beginner might expect.
Clear and return-home instructions need more time than an ordinary character write. Sending another instruction too early can make a circuit work intermittently. A delay function should have a defined relationship to the processor clock instead of being an unexplained empty loop.
Positioning Text on a 20×4 Display
For the common 20×4 mapping used here, the row-start DDRAM addresses are 0x00, 0x40, 0x14 and 0x54. A set-address command adds the command bit 0x80, producing the values in the following table.
| Visible row | Zero-based row index | Start command |
|---|---|---|
| First | 0 | 0x80 |
| Second | 1 | 0xC0 |
| Third | 2 | 0x94 |
| Fourth | 3 | 0xD4 |
A manufacturer's 20×4 example, the Newhaven NHD-0420DZ specification, illustrates this memory arrangement. Check the actual module if its controller or organization differs.
To start at column four using zero-based indexing on the second row, the command is 0x80 | (0x40 + 4) = 0xC4. That is the fifth visible character position because the first position has index zero.
Do not rely on automatic wrapping to place long strings correctly. Explicitly select each row and limit the number of characters written there. This prevents text from entering an unexpected part of the controller's memory.
Complete Keil C51 Example
The following program retains the original Port 2 data bus and P1 control pins. It uses Timer0 for conservative millisecond delays at 11.0592 MHz. Timer0 is reserved for this driver in the demonstration, so another feature must not reconfigure it while these functions are in use.
#include <REG51.H>
#include <intrins.h>
sbit LCD_RS = P1^0;
sbit LCD_E = P1^1;
unsigned char code row_address[4] = {0x00, 0x40, 0x14, 0x54};
void delay_ms(unsigned int milliseconds)
{
while (milliseconds != 0) {
milliseconds--;
TR0 = 0;
TF0 = 0;
TH0 = 0xFC;
TL0 = 0x66;
TR0 = 1;
while (TF0 == 0) { }
TR0 = 0;
TF0 = 0;
}
}
void lcd_bus_write(unsigned char value, bit is_data)
{
LCD_E = 0;
LCD_RS = is_data;
P2 = value;
_nop_();
LCD_E = 1;
_nop_(); _nop_(); _nop_(); _nop_();
LCD_E = 0;
delay_ms(1);
}
void lcd_command(unsigned char command)
{
lcd_bus_write(command, 0);
if (command == 0x01 || command == 0x02) delay_ms(2);
}
void lcd_data(unsigned char character)
{
lcd_bus_write(character, 1);
}
void lcd_position(unsigned char row, unsigned char column)
{
if (row < 4 && column < 20)
lcd_command(0x80 | (row_address[row] + column));
}
void lcd_text(unsigned char row, unsigned char column,
const char code *text)
{
if (row >= 4 || column >= 20) return;
lcd_position(row, column);
while (*text != '\0' && column < 20) {
lcd_data(*text++);
column++;
}
}
void lcd_init(void)
{
LCD_RS = 0;
LCD_E = 0;
P2 = 0;
ET0 = 0;
TMOD = (TMOD & 0xF0) | 0x01;
delay_ms(40);
lcd_bus_write(0x30, 0);
delay_ms(5);
lcd_bus_write(0x30, 0);
delay_ms(1);
lcd_bus_write(0x30, 0);
lcd_command(0x38);
lcd_command(0x08);
lcd_command(0x01);
lcd_command(0x06);
lcd_command(0x0C);
}
void main(void)
{
lcd_init();
lcd_text(0, 1, "www.TheEngineering");
lcd_text(1, 4, "Projects.com");
lcd_text(2, 0, "8051 LCD interface");
lcd_text(3, 0, "8-bit parallel bus");
while (1) { }
}
Why the delay preload is 0xFC66
With the classic twelve-clock timer relationship, an 11.0592 MHz oscillator produces 921,600 timer counts per second. Approximately one millisecond requires 921.6 counts. We round up to 922 and calculate 65,536 − 922 = 64,614 = 0xFC66.
The timer's counting portion is therefore slightly longer than one millisecond, and program overhead adds more time. That is acceptable for these conservative display waits. It should not be described as a calibrated one-millisecond scheduler.
The short no-operation sequence keeps E high for a comfortably observable interval at the stated clock. If you move the driver to a much faster 8051 derivative, recalculate both the timer and bus timing rather than assuming that the same number of instructions has the same duration.
Why use a string-writing function?
The original code sent the website address one character at a time through repeated statements. A string function keeps the same behavior with less repetition. It also provides one place to enforce the twenty-column limit.
The code qualifier in this C51 example places constant text and the row-address table in program memory. It is specific to the toolchain's memory model. When porting the driver, adapt those declarations instead of treating them as ordinary desktop C syntax.
Building and Running the Project
- Create an AT89C51 target in Keil and add the C source file.
- Enable HEX-file generation and build the project.
- Resolve build errors before attempting to run the circuit.
- Open the AT89C51 properties in Proteus and select the new HEX file.
- Set the processor clock to 11.0592 MHz.
- Check R/W, RS, E and the data-bus connections.
- Run the simulation and compare each line with the program's text strings.
The original result displays the website address across the first two rows. The revised program preserves that idea and adds explanatory text on the lower rows. Those extra strings identify the interface without changing the underlying circuit.
For a first hardware check, use short text and adjust contrast slowly. A row of dark blocks often points toward a contrast or initialization issue; an illuminated but empty screen may be a contrast, wiring or command problem. Neither symptom alone identifies a single guaranteed cause.
Download the Original Files
The original simulation and source attachment are retained here. Its earlier delay loops and reset-button drawing may differ from the corrected explanation above. Keep the archive as a reference and use a separate build for the revised program.
Download Proteus Simulation & Code
When changing the code, load the corresponding new HEX file into Proteus. Editing a C file does not automatically change a simulation that still points to an older firmware image.
Updating Values without Leaving Old Characters
Suppose a field initially displays 1000 and the next value is 9. Writing only the new digit at the same address can leave 9000 on the screen. The LCD does not know that the unused trailing positions should disappear.
Use a fixed-width field, write enough trailing spaces or clear only the relevant display region before replacing a shorter value. For repeated sensor updates, this is usually preferable to clearing the entire screen every time.
Repeated full-screen clearing can create visible flicker and adds unnecessary instruction delays. Static labels should normally be written once, with only the changing values updated afterward. This also makes the program easier to organize when several measurements share one display.
Troubleshooting the LCD Interface
| Symptom | Useful checks |
|---|---|
| No visible characters | Supply, contrast, R/W connection and initialization |
| Random symbols | Data-bit order, RS state, E pulse and common ground |
| Text appears on unexpected rows | DDRAM row addresses and column bounds |
| Works only after another reset | Startup delay and reset sequence |
| Old digits remain after an update | Field width and trailing-space handling |
| Manual reset has no effect | Button must assert active-high RST |
If all characters are wrong in a consistent way, inspect the parallel data lines before changing the text strings. If the first message is correct but later commands fail, inspect delays and competing uses of Timer0 or Port 2. A working initial screen does not guarantee that the rest of the application preserves the driver's resources.
Project Review
Three experiments to understand the interface
First, change a single character in one string, rebuild and reload the HEX file. Only that character should change. This confirms that you are editing the intended program and that the basic data path is working before you experiment with commands.
Second, move a short word by changing the row and column arguments. For example, lcd_text(2, 5, "READY") should begin at the sixth column of the third row. Predict the address before running: 0x80 | (0x14 + 5) = 0x99. Comparing that calculation with the visible placement makes the controller's addressing less mysterious.
Third, write a twenty-character line and then replace it with a shorter message at the same position. Observe the remaining characters, then add spaces to erase them. This exercise demonstrates why a display driver and an application need a clear agreement about field width.
The screen contains eighty visible character positions, but our function intentionally writes only within one row at a time. It stops at column twenty even if the source string is longer. To show a longer message, split it into separate strings and select the next row explicitly.
Reserve Port 2 for the LCD bus. Connecting another actively driven data source there can cause contention whenever the two devices drive different logic states.
With the conservative one-millisecond wait after each byte, writing twenty characters takes more than twenty milliseconds once addressing and software overhead are included. That is reasonable for static labels, but repeated updates should be planned around the rest of the application. A keypad scanner, for example, should not depend on running every millisecond while a long blocking display update is taking place.
This interface is useful when a project needs a simple local display for measurements, menus or debugging. Eight-bit mode makes the bus relationship easy to understand, while the string and positioning functions provide a small reusable driver.
The main tradeoffs are pin usage and blocking delays. A later version can save pins with four-bit mode or an interface expander, and it can improve responsiveness by scheduling display updates. Those changes should follow a working understanding of the command, data and timing sequence.
Frequently Asked Questions
Does 16×2 mean sixteen rows and two columns?
No. It means sixteen character columns and two rows. This project's 20×4 module provides twenty columns and four rows.
Why is R/W connected to ground?
The example only writes to the display. Grounding R/W simplifies wiring, but requires suitable delays because the program cannot read the busy flag.
Can I use the same code with a 16×2 LCD?
The basic bus functions can be reused for a compatible controller. Adjust the visible row count, column limit, row-address table and text layout for the smaller module.
Why does the third row start at 0x94?
The visible rows are arranged within the controller's internal memory lines. The third row commonly starts at DDRAM address 0x14, combined with the 0x80 set-address command bit.
Can the LCD display custom symbols?
Compatible character controllers provide character-generator RAM for a limited set of user-defined patterns. That requires a separate pattern-loading routine; ordinary text strings use the existing character set.
Hi Sir,
Thank you for the very easy and understandable explanation. In the above code, the display is static. If I want my display to scroll towards the left direction, does it mean that I just need to manipulate the writecmd(int t) function so that the column will change? What if the LCD is composed 4 rows, what will now be the value for writecmd(int t)?
Thanks,
Cris

























Comments
2