How to Interface the 8051 MCU with an LCD Display

Written by daniellegruber | Published 2022/01/21
Tech Story Tags: embedded-systems | tutorial | assembly | simulation | microcontroller | programming | electronics | hackernoon-top-story

TLDRIn this tutorial, we’ll interface the 8051 microcontroller with a 16x2 LCD and display a blinking “Hello World” message. The MCU is programmed in Assembly and the system is simulated in MultiSim. Code and hardware setup are provided.via the TL;DR App

In this tutorial, we’ll interface the 8051 microcontroller with an LCD (Liquid Crystal Display) and display a blinking “Hello World” message.

Introduction

Several months ago I knew nothing about electronics. Now I’m an electrical engineering major. Why? you might ask. If you’ve read any of my articles on Medium, you may know I’m a neuroscience and brain-computer interface enthusiast. While I headed into university thinking I’d study physics, I realized engineering would be more practical. Plus it’s super fun!
As I’m now delving into the world of computer engineering, I thought I’d take you along in a series of tutorials on programming the 8051 microcontroller using Assembly.
You can view my complete compilation of tutorials in this Github repository, and the code for this tutorial in this folder. Since I’m on break from university and don’t have access to the cool electronics from our lab, I’ll be using MultiSim to construct and program the microcontroller circuits. (You can download MultiSim on Windows with a free 7-day evaluation, extended to 45 days upon request.)
In this tutorial, we’ll interface the 8051 microcontroller with an LCD and display a blinking “Hello World” message. LCDs are widely used in electronics for displaying messages and data, so hopefully you can apply what you learn here to your other projects!

Hardware setup

Components

8051 microcontroller (MCU). Built with 40 pins, 4KB of ROM storage, 128 bytes of RAM storage, and 2 16-bit timers. It consists of four 8-bit input/output (I/O) ports, each with bidirectional ability, i.e., the ability to receive data as input or send data as output. We will use these ports to interface with the LCD, and primarily configure the ports to output data to the LCD.
LCD (Liquid Crystal Display). Electronic display module. Here we use a 16x2 LCD, which means there are two lines and each can display 16 characters. In this LCD, each character is displayed in a 5x7 pixel matrix.
Crystal. Generates clock pulses required for the synchronization of all internal operations. We connect it to pins XTAL1 and XTAL2 of the MCU. Here we use a frequency of 11MHz.
Capacitors C1 and C2. Serve as stabilizers, keeping the oscillator locked at the specified frequency. Here we set C1 = C2 = 33pF.
Bussed three-line resistor R1. Serves as pull-up resistor for port 0 pins (port 0 always requires external pull-up resistors). Here we use a resistance of 10K.

Understanding the LCD

Now let’s dive a bit deeper and take a look inside the LCD to see what’s going on. Below is a block diagram of the 16x2 LCD, adapted from this source and the HD44780U (LCD-II) datasheet. It may be a bit overwhelming, but we’ll break it down.
Block diagram of 16x2 LCD, adapted from this source and the HD44780U (LCD-II) manual.

LCD pins

Let’s start with a high-level overview of the 16 pins of LCD module. When looking at a 16x2 LCD, you’ll see something similar to the figures below. The first is a representation of an LCD in MultiSim, and the second is an actual HD44780 LCD. You’ll only see 14 pins on the MultiSim representation since the backlight pins aren’t necessary.
Top: 16X2 LCD, as represented in MultiSim. Bottom: 16x2 LCD Display Module HD44780 Drive Yellow.
We can divide the pins into five categories: power, contrast, control, data, and backlight. A summary of the pins and their functions is shown in the table below. Don’t worry if you don’t understand what some of the functions mean yet.

LCD registers

The LCD has two 8-bit registers, namely the data and instruction registers.
Data register. Temporarily stores data to be written into or read from DDRAM or CGRAM.
Command register. Stores command codes, such as those to clear the display, set the cursor, and read/write data. Some common commands are shown below, where the codes are given by the values of RS, RW, and D0-D7.

LCD memory

There are three main memories in the LCD to manipulate character displays: DDRAM, CGROM, and CGRAM.
Display Data RAM (DDRAM). Stores display data represented in 8-bit character codes. Each address in DDRAM corresponds to a position on the LCD, shown in the figure below.
Character Generator ROM (CGROM). Stores all permanent character patterns, e.g., letters of the alphabet. It generates 5 × 7 dot (pixel) character patterns from the 8-bit character codes in DDRAM. (If you’re not using the cursor, it’s 5 x 8.) Note that the patterns are stored such that their memory address is equivalent to the ASCII code for that pattern.
The workflow for sending character ‘A,’ for example, would be as follows:
  1. Send ASCII code of ‘A’ to LCD.
  2. DDRAM stores this ASCII code.
  3. Display controller matches ASCII code of ‘A’ in DDRAM with the CGROM address and displays ‘A’ on the LCD according to the character pattern in CGROM.
Character Generator RAM (CGRAM). Stores custom characters loaded by the user, e.g., a smiley face. This memory can store up to 8 user-defined characters, but as it is read-write memory, it is volatile and can be modified any time.
On the other hand, the workflow for loading a custom character would be as follows:
Step 1: Define custom character
  1. Send address of CGRAM as a command to LCD to store custom character in that location. The (hex) address of the first character you define would be 40, the second would be 48, etc.
  2. Send all hexadecimal bytes of the custom character to CGRAM. E.g., for the smiley face, you would send 00, 00, …, 0E, 00.
Step 2: Display custom character
  1. Set the LCD cursor where you want to display your custom character. E.g., if it’s the beginning of the first line, send command 80 (hex).
  2. Send the CGRAM address of your custom character to the LCD.
  3. DDRAM stores the ASCII code of custom character. (In this case, since the ASCII code is in the range mapped to CGRAM, it uses the patterns stored there to generate the character display.)
  4. Display controller matches ASCII code of custom character in DDRAM with the CGRAM address and displays it on the LCD according to the character pattern in CGRAM.
Notice the similarities between the last two steps of part two in this workflow with the last two in the standard character workflow, i.e., the one that uses CGROM.

Other

Busy flag (BF). Checks the internal status of the LCD. When the busy flag is 1, the LCD is still executing previously-received commands and the next instruction will not be accepted. When the busy flag is 0, the LCD is ready to accept new information and the next instruction can be written.
Address counter. Assigns addresses to both DDRAM and CGRAM. Any time you write to DDRAM or CGRAM, the address counter automatically increments by 1. If you read from DDRAM or CGRAM, it decrements by 1.
Instruction decoder. Processes the command code written into the command register.
LCD driver. Receives low-level instructions and turns them into waveforms for controlling commons (COM) and segments (SEG), i.e., sub-units of the display. (For a good explanation of this, see this pdf).
Timing generation circuit. Generates clock signals to ensure the proper timing and thus operation of internal circuits such as DDRAM, CGROM and CGRAM.
Cursor/blink control circuit. Generates the cursor or character blinking. The cursor or blinking will appear with the character located at the DDRAM address set in the address counter.

Using the control pins

Now that we have some background on the LCD structure, let’s discuss the control pins a bit more. Recall from the section on LCD pins that there are three signals that the microprocessor uses to control the data transfer with the LCD module. A summary of these pins and their functions is given in the table below.
RS (register select). As its name suggests, this pin switches between the command and data registers by setting RS = 0 or RS = 1, respectively.
RW (read/write). This pin switches between writing data to the LCD and reading data from the LCD by setting RW = 0 or RS = 1, respectively. (RW is usually set to 0 since only the “Get status” instruction requires it.)
E (enable). This pun functions as the command/data latching signal for the LCD. When E=0, the LCD does not care what is happening with RW, RS, and the data bus lines. When E = 1, the LCD is processing the incoming data. However, it is really an edge-triggered pin, in particular negative edge-triggered. This means the LCD will latch in whatever is on the Data Bits and process it on the falling edge of the E signal, i.e., transitioning from high (1) to low (0).

Timing of the write cycle

The diagram below shows the timing for the LCD write cycle, demonstrating the interactions between the RS, RW, E, and data (D0-D7) signals. All 8 data signals are grouped together in the bottom row as their individual values don’t matter in terms of influencing the events of the cycle.
At event 1, the RW signal is set low to indicate the LCD is ready to accept data, i.e., the MCU can write data. RS is set as high or low depending on whether the MCU is writing data or commands, respectively. The data signals are ignored during this period.
When the MCU has information to send to the LCD, it loads data to the Data Bits and then asserts the E signal high at event 2 to indicate the data is available to read.
At some point in the interval between events 2 and 4 (i.e., while E is high), the data byte is sent to the data lines and the LCD reads the data bus (event 3). The next region is labeled “valid data,” as this is the actual data that will be written to the LCD.
On the falling edge of the E signal at event 4, the LCD latches the data and the data is no longer available on the data bus. The data must not actually change until data hold time is met at event 5. The RS, RW, and data lines are all free to change to other values after event 5. The only thing that you can’t do is bring E high again too soon, as a certain time buffer is required between writes to the device.
The time intervals shown on the bottom of the diagram are all specified by the receiving device. To learn more about these, see the HD44780U datasheet.
Segments where signals are present in both high and low states indicate the state can take on either value. Image by author, inspired by the HD44780U datasheet.

Putting it all together

If we put all that we’ve learned together about the LCD structure and programming the control pins, we end up with a diagram that looks as follows. (Note that this represents “write mode,” i.e., RW = 0, and only considers CGROM for simplicity.)
Path of data in LCD write cycle. Image by author.

Assembly refresher

Before we break down the code, we’ll go over Assembly implementation of three key concepts we’ll be using in our code:
  1. For loops
  2. Infinite loops
  3. Subroutines
We won’t be using “regular” while loops in our code, but if you’d like to see how they’re implemented in Assembly, see this article.

For loops

In Assembly, any sort of conditional jump instruction (e.g.,
JNC
,
JNZ
,
DJNZ
) can be used for implementing for loops, but most often we use
DJNZ
(decrement and jump if not zero). In this directive, the register is decremented; if not zero, it jumps to the target address referred to by the label.
The structure of a for loop in Assembly is shown in the code below. Prior to the start of the loop a register is loaded with the number of iterations you desire. Once we arrive at the end of the loop, we use the
DJNZ
directive, which combines the register decrement and the jump back to the beginning of the loop.

Infinite loops

An infinite loop is a sequence of instructions in a program that repeats endlessly.
To implement infinite loops, we now use the unconditional jump instructions,
SJMP
(short jump) and
LJMP
(long jump). The difference between
LJMP
and
SJMP
is that
LJMP
takes a two-byte absolute address, allowing you to jump to any memory location from 0000 to FFFF (hex), while
SJMP
takes a one-byte relative address, allowing you jump to an address within a range of 00 to FF. Often, the target location we need to jump to is not that far, and since
SJMP
takes one less byte of memory, it is our preferred choice.
The structure of an infinite loop in Assembly is quite simple:

Subroutines

Similar to functions in high-level programming, subroutines are “self-contained” pieces of code in a program that accomplish specific sub-tasks. In a given program, we often need to perform a particular sub-task many times on different data values. Rather than repeat the same block of instructions every time, we can package the instructions in a subroutine to call upon when needed.
We can call a subroutine at any point in the program using the call instructions,
ACALL
and
LCALL
. Similar to the situation with
SJMP
and
LJMP
,
ACALL
restricts the target address to a smaller range than
LJMP
but takes less memory.
A subroutine is called by transferring control to its entry point, so when the subroutine completes its execution, it needs to return control back to the calling routine. Therefore, whenever we create a subroutine, we end it with a
RET
(return) instruction.
An example of the structure of a subroutine and the routine that calls it is shown below.

Code breakdown

Now, we’ll finally break down the code. Note that the order of this breakdown doesn’t align with the order of the code.
  1. Assigning symbolic names
  2. Define data and commands
  3. Define delay subroutine
  4. Define subroutine to send commands to LCD
  5. Define subroutine to send data to LCD
  6. Define subroutine to loop through each character (byte)
  7. Define main program
  8. Full code

Assigning symbolic names

In assembly code, the
EQU
directive gives a symbolic name (label) to a numeric constant, a register-relative value or a PC-relative value. It is essentially the same as setting a variable in higher-level programming languages.
Since we will use the RS, RW, and E pins a lot, we’ll assign symbolic names to the corresponding pins we’ve chosen (which depend on the wiring of the MCU to the LCD). In the circuit schematic above, you can see that bits 0, 1, and 2 of port 0 on the MCU have been wired to the RS, RW, and E pins, respectively.
We’ll also let
DATA_PORT
correspond to port 2, as bits 0–7 of port 2 are connected to pins D0-D7 of the LCD. We’ll discuss the labels defined in lines 6–7 in the section on the byte-looping subroutine.
Let’s say that rather than using the symbolic names
RS
,
RW
, and
E
, we used
P0.0
,
P0.1
, and
P0.2
everywhere. The first disadvantage of this is that if you changed the wiring such that the LCD control pins were connected to port 2 instead of port 0, you would have to replace every instance of P0 with P2. In the case of using variables, you would only have to change the defined ports and bits in the
EQU
statements.
The second disadvantage is readability. Looking through the code, you might forget, for example, that
P0.0
corresponds to RS. Using (well-named) variables eliminates this problem.

Define data and commands

In Assembly, the
DB
(define byte) directive defines a byte-size variable, which can be in decimal, binary, hex, or ASCII format. In the code below, hex format is indicated by appending an “H” to the numerical value, and ASCII format is indicated by placing characters in quotation marks. (The assembler will assign the ASCII code for the characters automatically.)
DB
followed by multiple operands simply defines a succession of bytes. Note that if the operand is a string, it is already a succession of bytes, as each character takes one byte in memory.
We can access the data we’ve defined by assigning labels before the directives. In other words, we create variables storing the data. Later, we’ll use these variables to load the data in the main program.
First, we define the variable
INIT_CMND
, which will hold a succession of commands to initialize the LCD display:
  1. 38H
    : 8-bit, 2 line, 5x7 dots
  2. 0EH
    : Display ON cursor, ON
  3. 06H
    : Auto increment mode, i.e., when we send char, cursor position moves right
This, in effect, creates a lookup table at the
INIT_CMND
address. We can think of each command as an entry in a table, with
38H
being the first entry,
0EH
being the 2nd, and
06H
being the 3rd. The value of each entry can be accessed via its index in the table, which we will see come into play when we write both commands and data to the LCD one byte at a time.
We define variables
LINE1
and
LINE2
, containing the commands to bring the cursor to lines 1 and 2 in a similar fashion. Finally, we define variables
DATA1
and
DATA2
, containing the data to display on lines 1 and 2 of the LCD.

Define delay subroutine

Proper delays between actions in embedded systems are important so that the system has time to execute instructions. In our system, there are two ways we can ensure the LCD staggers its internal operations:
  1. Using just delays
  2. Checking the busy flag
In practice, it is recommended to use the second method, as the delay produced is almost exactly the amount of time the LCD needs for processing. However, for our purposes, we’ll stick with the first method, which is simpler but serves the same purpose as long as the delay is appropriate. (For an example of method two, see this article.)
One classic way to make a delay is to use nested decrement loops. Every time the inner loop counts down to 0, then the next decrements, and so on. The code for the delay subroutine we’ll use is shown below.
We can calculate the length of the delay our subroutine produces by analyzing the number of machine cycles (MC) taken for each instruction and taking into account the number of repetitions. We’ll break down our calculations by the inner and outer loops.
MC of inner loop (L1)
MC of body: 2
Num repetitions: 4
Total MC: 4 x 2 = 8
MC of outer loop (L2)
MC of body: 1 + 8 + 2 = 11
Num repetitions: 255
Total MC: 255 x 11 = 2805
Not that it makes a big difference, but we can also add the MC of the first and last statements:
Total MC: 2805 + 3 = 2808
Now, we can find the delay in microseconds (us). The machine cycle for a system of 11.0592 MHz is 1.085 us, so the total delay of our delay subroutine is:
2808 x 1.085 us = 3.047 ms

Define subroutines to send data and commands to LCD

From the LCD write cycle, recall the steps for sending a command:
  1. Load D0-D7 with command.
  2. Select the command register by setting RS low.
  3. Select write mode by setting RW low.
  4. Send a high-to-low pulse to E.
The steps for sending data are similar, except RS is set high:
  1. Load D0-D7 with data.
  2. Select the data register by setting RS high.
  3. Select write mode by setting RW low.
  4. Send a high-to-low pulse to E.
As you can see in the excerpts of code above, setting each pin low or high is relatively straightforward. The
CLR
directive is used for the former and the
SETB
directive is used for the latter.
Understanding the first step, loading the data port, was more difficult for me, so let’s dive a bit deeper. In both excerpts, lines 3–4 correspond to step 1. The values of A (the accumulator register) and the DPTR (data pointer) function as parameters passed to the subroutine, together addressing the desired byte of data or command in code space. Here’s how it works:
  1. The DPTR points to the start of the lookup table.
  2. A is the offset from the start of the lookup table, i.e., the index of the entry we want to access.
  3. The instruction
    MOVC A, @A+DPTR
    moves the (A+1)th table entry into A (we use
    MOVC
    since the index is in code space).
The GIF below shows a visual of this process. Here, DPTR points to
DATA1
and A iterates through each character of the data. (As a side note, a great way to debug your code is to check the values of registers at various “checkpoints.”)
Iteration through lookup table using DPTR and A registers. Images by author.
So how do we control A in order to access all the entries in a table? That brings us to our next subroutine.

Define byte-looping subroutine

Since we write data and commands one byte at a time, we now define a subroutine that loops through each entry in a given lookup table (pointed to by DPTR) and sends it to the LCD.
Since Assembly is a low-level programming language, and thus cannot be object-oriented, we go about this using the following method:
  1. A for loop iterates for a number of times equal to the number of entries (bytes) in the table.
  2. At each iteration, we increment the index (A) so that it points to the next data byte (character) or command byte in the table.
  3. This byte is sent to the LCD via the
    WRT_DATA
    or
    WRT_CMND
    subroutine.
Going back to the section on assigning symbolic names, you’ll see that we assign register 0 (R0) the symbolic name
NBYTES
, and use it for storing the number of bytes in a table of data or commands. We also assign register 1 (R1) the symbolic name
BYTE_IDX
, and use it for tracking the current iteration we’re at when looping through the bytes.
You’ll notice from the code excerpt below that we increment A indirectly via the value of
BYTE_IDX
. We do this because the
WRT_DATA
and
WRT_CMND
subroutines make use A for storing table entries, and simply incrementing A would make the index have a large offset.

Define main program

Now that we’ve defined all of our subroutines, we can create the main body of our program. We begin by initializing the LCD with the commands in the
INIT_CMND
table. We first point the DPTR to the table and then specify the number of bytes the table has — 3 since there are 3 commands. After these “parameters” are set, we can call the
SEND_CMND_BYTES
subroutine.
We then proceed to the main loop, which infinitely repeats itself.
  1. Clear display and move cursor to line 1.
  2. Send data we want to display on line 1.
  3. Move cursor to line 2.
  4. Send data we want to display on line 2.
As described for the initialization process, every time we prepare to send commands or data to the LCD, we point the DPTR to the appropriate table and specify the number of entries.

Full code

Congratulations, we made it to the end! We can finally put everything together. Remember to check out my Github for the program and MultiSim files, as well as other 8051 projects!

About the author

👋 I am Danielle Gruber and I am a freshman at Yale University. I am an electrical engineering major interested in neuroscience, computer science, math, and everything in-between.
Contact me 👇

Sources cited and further reading

  1. Microcontrollers 8051 Input Output Ports
  2. HD44780U datasheet
  3. How to Use Character LCD Module
  4. LCD Interfacing with 8051 Microcontroller
  5. LCD Fundamentals and the LCD Driver Module of 8-Bit PIC® Microcontrollers
  6. LCD Interfacing

Written by daniellegruber | Freshman at Yale majoring in electrical engineering. Interested in neuroscience, CS, math, and everything in-between!
Published by HackerNoon on 2022/01/21