Interfacing LCD display with PIC

13 minute read

In this post I’ll show you how to interface an LCD display with a PIC microcontroller from scratch, using no external library. This means we’ll make our own functions for displaying characters, strings and numbers and functions for clearing the display, turning it on/off and so on. You’ll also be able to download my LCD library containing all the functions described in the post and many more which you can use in your own projects.

All the following setup is written for LCDs using the popular HD44780 driver (datasheet) or compatible (such as ST7066). We’ll interface this LCD to a PIC18F452 MCU (datasheet), although similar PICs should work as well. Also, we’re going to use the 8-bit interface, but it can easily be modified for 4-bit as well.

First, I recommend you to read Cytron’s tutorial on LCD interfacing (part 1 and part 2), which is a very good source on this topic and the one I learned from. Since the theory is, in my opinion, quite well written there, I won’t go into detail about RS, RW, EN, DDRAM, CGROM, busy flag, etc. What we are going to do, however, is to make our own functions for all the common uses using that theory. </p>

Hardware

What you’ll need

  • LCD display (with driver HD44780 or compatible, preferably 16×2 characters)
  • 10kΩ potentiometer

Schematic

We’ll start by wiring up the LCD to the PIC as shown on the schematic below.

PIC-interface-with-LCD

LCD’s power supply is (obviously) connected to GND and Vcc (in my case 5V). Pins RS, RW, EN of the LCD are connected to the PIC’s pins RB0, RB1, RB2. Data lines D0-D7 are connected to PIC’s pins RD0-RD7. The contrast of LCD is depends on the input voltage on pin CONTR, which is controlled by a 10kΩ potentiometer sitting between GND and Vcc. Additionally, if the pins 15 and 16 are present, they can be connected to Vcc and GND respectively with a resistor in series, to power the backlight of the LCD. You should check all of these connections using your LCD’s datasheet since each LCD can have different pinouts. And that’s it — now we’re ready to move to the programming part.

Software

Pin definitions

First, we need to define which pins of the LCD correspond to which pins of the PIC MCU. This will make it easier for us to reference these pins in the program — instead of using, say, PORTBbits.RB2 which doesn’t tell us anything about the pin’s function, we’ll be able to use LCD_en which stands for the LCD’s enable pin.

The following defines are based on our schematic above. We’ll call the three LCD pins RS, RW, EN LCD_rs, LCD_rw, LCD_en; then we’ll call the data lines on PORTD (RD0-RD7) LCD_data and specifically we’ll call the busy flag connected to the PORTD pin RD7 as LCD_busy.

We do the same for the direction of these pins, but instead of register PORT we need to use TRIS.

1
2
3
4
5
6
7
8
9
10
11
12
13
// pins
#define LCD_data PORTD
#define LCD_busy PORTDbits.RD7
#define LCD_rs   PORTBbits.RB0
#define LCD_rw   PORTBbits.RB1
#define LCD_en   PORTBbits.RB2

// directions
#define LCD_data_dir TRISD
#define LCD_busy_dir TRISDbits.RD7
#define LCD_rs_dir   TRISBbits.RB0
#define LCD_rw_dir   TRISBbits.RB1
#define LCD_en_dir   TRISBbits.RB2

Delay functions

We’ll need the following delay functions as usual. They wait the given number of system cycles, microseconds and milliseconds respectively. This is done by calling the default waiting functions starting with __delay with a low parameter (1) since they can’t take on large numbers on the order of hundreds.

1
2
3
void LCD_delay(int time) {for(int i = 0; i < time; i++);}
void LCD_delay_us(int us) {for(int i = 0; i < us; i++) __delay_us(1);}
void LCD_delay_ms(int ms) {for(int i = 0; i < ms; i++) __delay_ms(1);}

Send code

Probably the most basic LCD function will be to send codes to it. The LCD accepts codes in the form [RS][RW][DB7][DB6]…[DB0] where [x] represents one bit and this 10-bit binary number will be our function’s input. To be able to send a code, we need to output these values to the corresponding LCD’s pins. For example, to output the RS value the line LCD_rs = (code & 0b1000000000) >> 9; takes the most significant bit of input (which is RS), shifts it to the units place to become either 0 (low) or 1 (high) and this value is then outputted to the pin LCD_rs. After all the values are outputted to corresponding pins, we need to tell the LCD to process the command by bringing the enable (EN) pin high (1) and then to low (0). Finally, we have to wait for the LCD to process it by using LCD_wait() function which we’ll define in a moment.

We’ll write 2 versions of the LCD_code function, one waiting the delay time and the other not (this one will be used only during initialization where we’ll need to specify the delays manually).

1
2
3
4
5
6
7
8
9
10
11
12
void LCD_codeNoWait(int code) {
  LCD_rs   = (code & 0b1000000000) >> 9;
  LCD_rw   = (code & 0b0100000000) >> 8;
  LCD_data =  code & 0b0011111111;
  LCD_en   = 1; // enable pin HIGH-->LOW
  LCD_en   = 0;
}

void LCD_code(int code) {
  LCD_codeNoWait(code);
  LCD_wait();
}

To perform an operation on LCD, e.g. display a character or perform a command, you can look up the code of the specific operation in the HD44780 datasheet p. 24, Table 6 and just pass that number as a parameter to LCD_code();

Wait function

Now we need to define the LCD_wait() function that waits for the LCD to process a command. Here we have 2 options. The first is to wait a certain minimum amount of time, called execution time, which is the specific time needed for an execution of command (see HD44780 datasheet p. 24, Table 6). The second is that we can read the LCD’s busy flag which will tell us whether it’s still processing, in which case it will be high (1), or it has already finished, in which case it will be low (0). Since the latter method usually takes less time and is independent on the executed command, we’re going to use that one.

Before reading the busy flag, we need to make the data pins as inputs since the value of busy flag will be stored there. Then, we need to tell the LCD we’re using the command register (RS = 0) and that we’re reading (RW = 1) (refer to HD44780 datasheet p. 24, Table 6). However, now we don’t just make the enable pin High–>Low and then read the flag, but we must read it between these two operations. To check if the flag is still high we do this in a cycle — set enable pin to high which reads the flag, check if the flag is still high and set enable pin back to low; if the flag becomes low, we exit the cycle. After that, we need to set the data pins back to outputs so that we’ll later be able to send commands to the LCD.

1
2
3
4
5
6
7
8
void LCD_wait() {
  LCD_data_dir = 0xFF; // set RD to input pins
  LCD_rs = 0; // command register
  LCD_rw = 1; // read
  LCD_en = 1;
  while(LCD_busy) { LCD_en=0; LCD_en=1;}
  LCD_data_dir = 0x00; // set RD back to outputs
}

Initialization

Before we can actually send any codes to LCD, we must initialize it first. We’re going to do initialization by instructions (as opposed to by reset circuit) which uses a sequence of commands that need to be sent to the LCD to “activate it”. This sequence for 8-bit data lines is described in the HD44780 datasheet (p. 45, Fig. 23). Basically, we need to do the following:

  1. wait 40 ms or more
  2. send the code [0][0][0][0][1][1][*][*][*][*] (where * can be either 0 or 1)
  3. wait 4.1 ms or more
  4. send the code [0][0][0][0][1][1][*][*][*][*] (where * can be either 0 or 1)
  5. wait 100 µs or more
  6. send the code [0][0][0][0][1][1][*][*][*][*] (where * can be either 0 or 1)

Since we couldn’t read the busy flag and had to delay manually, that’s where our LCD_codeNoWait function came in handy. After sending these instructions, we’re able to read the busy flag so we can use our function LCD_code which waits using busy flag. We need to do the following:

  1. Set the function set by sending the command [0][0][0][0][1][1][N][F][*][*] where N is the number of lines (in our case 2 so N=1) and F is the font (in our case 5×8 dots so F=0).
  2. turn display off by sending code [0][0][0][0][0][0][1][0][0][0]
  3. clear display by sending code [0][0][0][0][0][0][0][0][0][1]
  4. Set entry mode by sending code [0][0][0][0][0][0][0][1][I/D][S] where I/D is increment/decrement (in our case increment so I/D=1) and S is display shift (in our case none so S=0).

The “official” initialization ends here. However, we also need to turn on the display, the cursor and set cursor to not blinking by sending [0][0][0][0][0][0][1][D][C][B] where D = display on/off, C = cursor on/off, B = cursor blinking. And now we’re ready to work with the LCD.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void LCD_init() {
  LCD_data_dir = LCD_rs_dir = LCD_rw_dir = LCD_en_dir = 0; // all LCD pins to output
  LCD_en       = LCD_rs     = LCD_rw     = LCD_data   = 0;// default values for LCD pins are 0

  LCD_delay_ms(50);
  LCD_codeNoWait(0b0000111000); // 8 bits, 2 rows, 5x8 dots
  LCD_delay_ms(10);
  LCD_codeNoWait(0b0000111000); // 8 bits, 2 rows, 5x8 dots
  LCD_delay_us(200);
  LCD_codeNoWait(0b0000111000); // 8 bits, 2 rows, 5x8 dots

  LCD_codeNoWait(0b0000111000); // 8 bits, 2 rows, 5x8 dots
  LCD_code(0b0000001000); // display OFF
  LCD_code(0b0000000001); // clear display
  LCD_code(0b0000000110); // cursor increment, no display shift

  LCD_code(0b0000001110); // display ON, cursor ON, cursor not blinking
}

Display character

After the initialization is complete, we can send commands to the LCD to perform various operations. One of them is writing an 8-bit value representing a character to DDRAM, which displays the character on the current position of cursor. The code for this from Table 6 is [1][0][D7][D6][D5][D4][D3][D2][D1][D0] where D7-D0 represent the 8 bits corresponding to a character. Depending on your LCD’s language modification, you can find the codes of all characters in the HD44780 datasheet p.17/18 Table 4. For example, character ‘A’ has the code 01000001. Notice that the codes for common characters in Table 4 (00100000 to 01111111) correspond to the codes in ASCII table so these 2 tables are compatible. And since each character in c is defined using an 8-bit ASCII code, it means that we can pass this code directly to the LCD and it will display the corresponding character. Our function would therefore look like the following.

1
2
3
void LCD_displayChar(char inputChar) {
  LCD_code(0b1000000000 + inputChar);
}

Display string

Since we now have a function for displaying characters, we can use this function for displaying strings, since they’re just a series of characters. Therefore, in a cycle we’ll go through every letter of the string and display it on the LCD. Since we have chosen Increment as the entry mode, after displaying every char the LCD will shift the cursor position to the right — that means that the displayed characters will align one after another and will form a string. Additionally, to use the strlen() function which returns the length of a string, we have to #include <stirng.h>.

1
2
3
void LCD_displayString(const char *str) {
  for(int i = 0; i < strlen(str); i++) LCD_displayChar(str[i]);
}

Display number

Since we already have a function for displaying strings, we can just convert the number to a string and display that. Unfortunately, c doesn’t have a standard function for doing that, but there is a non-standard function which depends on the compiler called itoa which converts an integer to a string; to our luck, it is supported by the MPLAB XC8 compiler so we can use it in our program.

1
2
3
4
5
void LCD_displayNumber(long int number) {
  char strNumber[20];
  itoa(strNumber, number, 10);
  LCD_displayString(strNumber);
}

Set cursor position

To set cursor position, we need to send the command [0][0][1][ADD][ADD][ADD][ADD][ADD][ADD][ADD] where ADD bits represent the cursor’s position. You can find which ADD codes represent which row/column in the HD44780 p. 10, Fig. 2 and p. 11, Fig. 4, depending if you have a one-line or a two-line display. Our function will have as parameters the x position and the y position starting with 1. Therefore, if we’re in 1st row and column C, the cursor position (DDRAM address) would be C -1, because the ADD codes start from 0. If we’re in 2nd row and column C, the position would be 0x40 + C -1, because positions in second row start form 0x40.  For example, if we wanted to move the cursor to (column 5; row 2), we would write LCD_setCursor(5,2).

1
2
3
4
void LCD_setCursor(int column, int row) {
  if(row == 1) LCD_code(0b0010000000 + (column-1));
  if(row == 2) LCD_code(0b0010000000 + 0x40 + (column-1));
}

Clear LCD

Here we just use the command 0000000001 as written in Table 6. This clears the display and sets the DDRAM address to 0, so the cursor returns to position (1,1).

1
2
3
void LCD_clear() {
    LCD_code(0b0000000001);
}

Return home

Here, we again just use the command form Table 6 (0000000010). This sets the DDRAM address to 0 (cursor position becomes (1,1)), unshifts the display to original position but doesn’t change the DDRAM contents (the text displayed on LCD will not change).

1
2
3
void LCD_home() {
    LCD_code(0b0000000010);
}

Turn display on/off

Using Table 6, the command to turn display on is [0][0][0][0][0][0][1][1][C][B] where C represents if the cursor is on and B if the cursor is blinking. For the purpose of this tutorial we’ll assume the cursor is on and it’s not blinking.

1
2
3
void LCD_on() {
  LCD_code(0b0000001110);
}

Similarly, to turn display off we use the command [0][0][0][0][0][0][1][0][C][B], but since we need neither the cursor being on nor the blinking of cursor while the LCD is off, we’ll make them both 0.

1
2
3
void LCD_off() {
  LCD_code(0b0000001000);
}

Other functions

In a similar way you could define many more functions, e.g. for turning the cursor on/off, turning the blinking of the cursor on/off, moving the cursor to the left/right/up/down, etc. But as this is just an intro to LCDs, these functions should be enough to get you started.

If you’d like to use my open-source LCD library containing even more functions in your projects, you can download it from my GitHub page for free, where’s always the most recent version.

Main

Here comes the fruit of our endeavor — the program itself. We’re going to test our functions by displaying a string, 3 characters and a number increasing in one-second intervals.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int main() {
    LCD_init();

    // Write "Hello, World!" starting at (2nd column, 1st row)
    LCD_setCursor(2,1);
    LCD_displayString("Hello, World!");

    // Display characters 'a', '!' and '$' on positions (3,2), (4,2), (5,2)
    LCD_setCursor(3,2);
    LCD_displayChar('a');
    LCD_displayChar('!');
    LCD_displayChar('$');

    // Write number -123 starting at (7th column, 2nd row)
    LCD_setCursor(7,2);
    LCD_displayNumber(-123);

    // In one second intervals display numbers 1-10 at position (12,2)
    for(int i = 1; i <= 10; i++) {
        LCD_setCursor(13,2);
        LCD_displayNumber(i);
        LCD_delay_ms(1000);
    }

    // Wait infinitely long
    while(1);
}

In the next post, I’ll show you how to set up timers, and using them, blink an LED in precise intervals (later we’ll also make a digital clock).

Documentation

If all went well, based on the code in your main function you should see something like this:

Source code

References

Leave a comment