Preface
This guide will attempt to demystify the following:
1.) How to find & map physical pins directly to C++ variables using binary literals (and how to use bitmasking to build instructions).
2.) How to actually begin sending information to your LCD in C++.
3.) How you can begin abstracting functions to minimize the amount of low-level instructions you have to write in your code.
DISCLAIMER:
This is not an extensive guide on all the features the LCD has; it is meant to help bridge the gap between knowing nothing at all and sending your first instructions to the LCD.
I'm also very new & fully self taught. There might be errors which I haven't caught... With that being said, I hope this is helpful to somebody.
References:
Kernel.org I2C Dev Interface (i2c-dev.h)
HD44780 Datasheet (RELEVANT PAGES: 23, 24, 25, 42, 46)
I2C 1602 LCD Specs (For device hex address)
Background Information
The reason I'm writing this is because most tutorials and guides out there are going to tell you how to use an Arduino and I wanted to change that with this post. In the industry arduino is not the standard for everything, and aspiring students like me will sometimes be be expected to use devices where there are no premade solutions like the ones arduino offers. Sometimes we'll have no choice but to write our own driver. And so I wanted to simulate that struggle. I went down that route, and this is the knowledge I've gained from it.
Now, when I was getting started with this thing, the biggest hurdle was this question… What do I actually write?
To answer that question, you need to know exactly what you’re working with first and what our code needs to do:
Many LCD1602 modules come with a PCF8574 IO Expander already soldered to the back of the unit. The LCD1602 has an HD44780U microcontroller on board.
The IO expander & i2c-dev header will allow us to implement I2C without having to handle the logic of the protocol ourselves. That leaves us with the relatively simple task of writing code that relays instructions to the LCD's microcontroller through the expander. Here is a map of the expander’s pins & their roles in the context of our microcontroller:
Figure 1: I2C LCD Adapter Schematic from HvandeVen/PCF8574-Display on GitHub.
*Note: This is a common but not GUARANTEED mapping. Pin maps may vary.
This schematic does some of the heavy lifting for us. In the image, it's clearly outlined which physical pin on the expander corresponds to what role it plays on the microcontroller. From the image & microcontroller's datasheet, you can come up with the following table:
| Pin | Role | Description |
|---|---|---|
| P0 | RS | Register Select: 0 = Command, 1 = Data |
| P1 | RW | Read/Write: 0 = Write, 1 = Read |
| P2 | EN | Enable: (Stage/Confirm Changes) |
| P3 | BL | Backlight: 1 = ON, 0 = OFF |
| P4 | D4 | Data Bit 4 |
| P5 | D5 | Data Bit 5 |
| P6 | D6 | Data Bit 6 |
| P7 | D7 | Data Bit 7 |
Before moving on I want to make two crucial clarifications:
1.) The PCF8574 only has 8 physical pins as shown by the table above. The first 4 of which are always allocated to what I call "byte control", meaning they only refer to specific actions the LCD is going to perform. The remaining pins (D4-D7) are the pins we're going to use to send information. Think of each byte we send like this:
DATA CTRL // Four data bits, Four control bits
0000 0000
2.) Because of this limitation, we're going to be using the LCD in a 4-bit mode, meaning it will expect 4 data bits at a time.
However, that does not mean the data the LCD expects is 4-bit. The LCD's internal system will automatically wait until it has received 2 sets of 4 data bits before considering the communication complete and printing a new letter to the screen. The idea is that the control bits are discarded and the 4 data bits from both instructions are combined into a single 8-bit byte which then represents a full letter.
How communication works & building instructions
Using the i2c-dev header, you send one byte (8 bits) at a time to the IO expander. Each byte is a set of instructions telling the expander exactly which pins should be ON (1) or OFF (0).
We use these 8 physical pins to send information. Think of it like this: Each pin state corresponds to a bit in that byte, we're using a controller with 8 buttons that can each be toggled on or off. We send instructions to toggle those buttons (turning a pin ON or OFF) and then take a snapshot of the pin state and send that information as an instruction.
It’s not practical to manually synthesize binary for every instruction though. So to make this efficient, we treat the pins as their own bytes and abstract away the actual binary into variables. This way, we can use bitmasking to build the instructions we'll be sending to the LCD.
In my code, I chose to do it like this above my main function:
// CONTROL PINS
const uint8_t PIN0_RS{ 0b00000001 };
const uint8_t PIN1_RW{ 0b00000010 }; // usually remains 0
const uint8_t PIN2_EN{ 0b00000100 };
const uint8_t PIN3_BL{ 0b00001000 }; // 1 for ON, 0 for OFF
// DATA PINS
const uint8_t PIN4_D4{ 0b00010000 };
const uint8_t PIN5_D5{ 0b00100000 };
const uint8_t PIN6_D6{ 0b01000000 };
const uint8_t PIN7_D7{ 0b10000000 };
// More declarations later...
I used binary literals here because when you're mapping software bits to physical GPIO pins on an expander, binary is more readable than Hex. No one wants to do extra math when referencing pins. And abstracting away hex, an abstraction of binary, is redundant in and of itself.
Now that we've abstracted our pins away, we can use some bitwise "OR" operations ( | ) to build instructions from the ground up. Here's an example of what that would look like now that we have our variables:
uint8_t instruction = PIN4_D4 | PIN0_RS | PIN3_BL;
There are two crucial pieces of logic that you need to understand before you can begin building instructions:
First, take a look at the type. All your instructions need to be 8-bit unsigned integer types. An empty instruction byte, for example, should look like this: 0000 0000.
The second thing you need to understand is bitmasking with the "OR" ( | ) operator. Since you've abstracted each pin to a specific name, you can layer them one on top of the other to create a single instruction. It looks something like this:
0001 0000 -> PIN4_D4
0000 0001 -> PIN0_RS
0000 1000 -> PIN3_BL
----------- +
0001 1001 -> instruction
This is what the ( | ) operator does: it takes two or more bytes and returns a singular byte where a bit is set to 1 if it was 1 in any of the original bytes, effectively combining 3 instruction bytes into 1 instruction. The result 0001 1001 is actually the result of the snippet I showed you before:
uint8_t instruction = PIN4_D4 | PIN0_RS | PIN3_BL;
If you wanted to turn a pin off, you simply just wouldn't include it in the next instruction. For example:
uint8_t instruction = PIN4_D4 | PIN0_RS; -> Backlight turns off.
THIS is what we're going to be sending to the LCD. And now that you know the what, we can get into the how. I'm going to structure this into three main blocks:
- Opening your I2C device
- Setting the LCD to 4-bit mode
- Sending 4-bit instruction bytes
Opening your I2C device
Now we can get into some code. If you want a full reference to what this header can do, you can check the official documentation here.
#include <iostream>
#include <fcntl.h> // open files
#include <sys/ioctl.h> // input/output for files
#include <linux/i2c-dev.h>
#include <unistd.h> // write(), usleep()
// ... FUNCTION & CONSTANTS DECLARATIONS HERE
int main() {
// --- LCD ---
const std::string filepath{ "/dev/i2c-1" };
const int i2c_adapter = open(filepath.c_str(), O_RDWR);
if (i2c_adapter < 0){
std::cout << "Failed to open bus." << std::endl;
exit(1);
}
// You can find the address hex value in your LCD's data sheet or if it's
// already connected via GPIO you can find it via the `i2cdetect -y 1`
// command in your terminal
const uint8_t lcd_address = 0x27;
if(ioctl(i2c_adapter, I2C_SLAVE, lcd_address) < 0) {
std::cout << "Failed to reach I2C slave." << std::endl;
}
}
In this block of code, I am opening a connection to the physical I2C hardware bus on my Pi via the device file "/dev/i2c-1". This returns a file descriptor I’ve named i2c_adapter. We then use an ioctl (Input/Output Control) call to tell the driver that all future communications on this bus should be directed to the slave device at address 0x27.
Essentially, every time we want to write information, we're going to pass this i2c_adapter handle as a sort of guide. When we pass this handle to the write function, it's like saying write information to the slave associated with this adapter. And so we can build our first function:
// ---- FUNCTIONS ----
void pulse(int i2c_adapter, uint8_t pin_state){
// Stage our instructions by flipping the pin up:
uint8_t up_instruction = PIN2_EN | pin_state;
write(i2c_adapter, &up_instruction, 1);
usleep(500);
// Confirm instructions by flipping the pin down:
uint8_t down_instruction = pin_state;
write(i2c_adapter, &down_instruction, 1);
usleep(500);
}
Before I talk about the code, I should clarify: PIN2_EN is a special pin whose role is something I like to compare to staging and committing changes.
Basically, for the IO expander to actually acknowledge the changes in pin state, the EN pin needs to be toggled up and down. Once to get the expander to look at the change in pin state (staging) and again to confirm the change in pin state (committing). This process is commonly referred to as a pulse, hence the name of the function.
Before we do that, we should make a couple more declarations above our main function.
// CONTROL PINS
const uint8_t PIN0_RS{ 0b00000001 };
const uint8_t PIN1_RW{ 0b00000010 }; // usually remains 0
const uint8_t PIN2_EN{ 0b00000100 };
const uint8_t PIN3_BL{ 0b00001000 }; // 1 for ON, 0 for OFF
// DATA PINS
const uint8_t PIN4_D4{ 0b00010000 };
const uint8_t PIN5_D5{ 0b00100000 };
const uint8_t PIN6_D6{ 0b01000000 };
const uint8_t PIN7_D7{ 0b10000000 };
// STARTUP COMMANDS PRESETS:
const uint8_t S_FUNCTION_SET{ 0b00101000 };
const uint8_t S_DISPLAY_SET{ 0b00001100 };
const uint8_t S_CHAR_ENTRY_SET{ 0b00000110 };
// OTHER:
const uint8_t CLEAR_DISPLAY{ 0b00000001 };
I've chosen to include these specific pin states because they're common values that will be used during startup/other processes, and if they need to be changed, it's more convenient to change them here than writing them inline. It helps for readability, too.
Setting the LCD to 4-bit mode
By default, the LCD will be set to run in an 8-bit state. Because we only have 4 data pins to work with, we have to change that during the initialization process. On page 46 of the MCU's datasheet, you can find the following diagram:
This diagram shows the order in which we should use our pulse method to send instructions and properly wake the device. That little red arrow is the instruction which sets the LCD to 4-bit operation.
The formatting looks strange because the datasheet omits two of the irrelevant control bits, but you can safely ignore it and assume that they are zero. From there, all we have to do is simulate the wake-up instructions given by page 46 in code with our pulse method:
void wake_lcd(const int i2c_adapter){
// Wake up the LCD and set it to receive 4-bit instructions
const uint8_t wake_up = PIN4_D4 | PIN5_D5;
const uint8_t set_4_bit = PIN5_D5;
pulse(i2c_adapter, wake_up);
usleep(5000);
pulse(i2c_adapter, wake_up);
usleep(5000);
pulse(i2c_adapter, wake_up);
usleep(5000);
pulse(i2c_adapter, set_4_bit);
}
Now that you have that complete, your main function should look a little something like this:
#include <iostream>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#include <unistd.h>
int main() {
const std::string filepath{ "/dev/i2c-1" };
const int i2c_adapter = open(filepath.c_str(), O_RDWR);
if (i2c_adapter < 0){
std::cout << "Failed to open bus." << std::endl;
exit(1);
}
const uint8_t lcd_address = 0x27;
if(ioctl(i2c_adapter, I2C_SLAVE, lcd_address) < 0) {
std::cout << "Failed to reach I2C slave." << std::endl;
}
wake_lcd(i2c_adapter);
}
And at that point, you're almost done; the LCD is ready to be instructed via 4-bit operations. We just have to write one last crucial function before you can intuitively create more abstractions on your own. The last function we need to make is a 4-bit send_byte method. I chose to do this using bit-shifting.
Sending 4-bit instruction bytes
Recall what our instruction bytes should look like in 4-bit operation:
DATA CTRL // Four data bits, Four control bits
0000 0000
Like I said before, the first 4 bits of an instruction will ALWAYS be interpreted as control bytes; however, this causes problems because our letters will be in 8-bit format. Our solution is to send 2 instructions in one method. We're going to break up a letter into two halves and send them separately. The LCD will discard the control bits and only take the data bits.
So here's how we're gonna do this:
HALF2 HALF1 // This is the binary representation of the letter H.
0100 1000
To implement this, we'll pass one letter to the function at a time and call it an instruction. We're going to apply bitmasking to the instruction. We're going to bit-shift the first 4-bits of the letter to the left such that we lose the latter half of the letter and we have space for both the first half of the letter and the control bits:
DATA
HALF1 CTRL // This is half the representation of the letter H.
1000 0000 // The first half is shifted left to the data columns
DATA
HALF2 CTRL // This is the other half of the representation of the letter H.
0100 0000 // The second half is already in position so nothing changes.
We'll then merge the basic instructions for writing a letter with the halves of the letters and send them as 2 sets of instructions. Here's how I implemented that:
void send_byte(int i2c_adapter, uint8_t value, uint8_t rs_mode){
uint8_t half_one = (value & 0b11110000) | PIN3_BL | rs_mode;
pulse(i2c_adapter, half_one);
uint8_t half_two = ((value << 4) & 0b11110000) | PIN3_BL | rs_mode;
pulse(i2c_adapter, half_two);
}
So what's happening? This function takes 2 new arguments: the value or letter we intend to send in its 8-bit format, and another value rs_mode.
That argument refers to the state of PIN0_RS. If you don't remember its function, here it is:
| P0 | RS | Register Select: 0 = Command, 1 = Data |
In the cases where we're sending normal letter information, we'll be setting rs_mode = 1. Otherwise, if we're working with the LCD's other functions (not covered by this post), we'd use 0.
The & operator filters out the first 4 bits of the given value and replaces them with our preset LCD actions (keeping the backlight on and giving an RS value). The bit-shifting allows us to use the same value except for different parts of it, as described before.
We pulse both of those manipulated instructions and just like that, we've sent a value.
With the send_byte function finished we can expand our wake_lcd function to include the initialization steps for the LCD with the additional declarations we made before:
void wake_lcd(const int i2c_adapter){
// Wake up the LCD and set it to receive 4-bit instructions
const uint8_t wake_up = PIN4_D4 | PIN5_D5;
const uint8_t set_4_bit = PIN5_D5;
pulse(i2c_adapter, wake_up);
usleep(15000);
pulse(i2c_adapter, wake_up);
usleep(5000);
pulse(i2c_adapter, wake_up);
usleep(5000);
pulse(i2c_adapter, set_4_bit);
// Initialization:
// STARTUP ORDER: check page 23 for the values associated with each byte command shown here
// 1.) FUNCTION SET
// 2.) DISPLAY ON/OFF CONTROL
// 3.) ENTRY MODE SET
// 4.) CLEAR DISPLAY
// --- READY FOR USE ---
// Because we're sending an LCD command, rs_mode set to 0.
send_byte(i2c_adapter, S_FUNCTION_SET, 0);
send_byte(i2c_adapter, S_DISPLAY_SET, 0);
send_byte(i2c_adapter, S_CHAR_ENTRY_SET, 0);
send_byte(i2c_adapter, CLEAR_DISPLAY, 0);
}
Wrapping up
By the end of all this, if you're following along, this is how your main function should look so far:
#include <iostream>
#include <fcntl.h> // open files
#include <sys/ioctl.h> // input/output for files
#include <linux/i2c-dev.h>
#include <unistd.h> // write(), usleep()
#include <string> // Needed for std::string
// --- CONSTANTS (Global scope so they are accessible to all functions) ---
// CONTROL PINS
const uint8_t PIN0_RS{ 0b00000001 };
const uint8_t PIN1_RW{ 0b00000010 }; // usually remains 0
const uint8_t PIN2_EN{ 0b00000100 };
const uint8_t PIN3_BL{ 0b00001000 }; // 1 for ON, 0 for OFF
// DATA PINS
const uint8_t PIN4_D4{ 0b00010000 };
const uint8_t PIN5_D5{ 0b00100000 };
const uint8_t PIN6_D6{ 0b01000000 };
const uint8_t PIN7_D7{ 0b10000000 };
// STARTUP COMMANDS PRESETS:
const uint8_t S_FUNCTION_SET{ 0b00101000 };
const uint8_t S_DISPLAY_SET{ 0b00001100 };
const uint8_t S_CHAR_ENTRY_SET{ 0b00000110 };
// OTHER:
const uint8_t CLEAR_DISPLAY{ 0b00000001 };
// ---- FUNCTIONS ----
void pulse(int i2c_adapter, uint8_t pin_state){
// Stage our instructions by flipping the pin up:
uint8_t up_instruction = PIN2_EN | pin_state;
write(i2c_adapter, &up_instruction, 1);
usleep(500);
// Confirm instructions by flipping the pin down:
uint8_t down_instruction = pin_state;
write(i2c_adapter, &down_instruction, 1);
usleep(500);
}
void send_byte(int i2c_adapter, uint8_t value, uint8_t rs_mode){
// Capture the high half (bits 7,6,5,4) and add control bits
uint8_t half_one = (value & 0b11110000) | PIN3_BL | rs_mode;
pulse(i2c_adapter, half_one);
// Shift the low half (bits 3,2,1,0) into the high position (bits 7,6,5,4)
// This is required because physical pins D4-D7 are on the expander's high pins
uint8_t half_two = ((value << 4) & 0b11110000) | PIN3_BL | rs_mode;
pulse(i2c_adapter, half_two);
// Clear display needs extra time (about 2ms) to finish internal processing
if (value == CLEAR_DISPLAY) usleep(2000);
}
void wake_lcd(const int i2c_adapter){
// Wake up the LCD and set it to receive 4-bit instructions
// We send 0b0011 (3) in the data slots to reset the controller interface
const uint8_t wake_up = PIN4_D4 | PIN5_D5;
const uint8_t set_4_bit = PIN5_D5;
pulse(i2c_adapter, wake_up);
usleep(15000);
pulse(i2c_adapter, wake_up);
usleep(5000);
pulse(i2c_adapter, wake_up);
usleep(5000);
pulse(i2c_adapter, set_4_bit);
// Initialization:
// STARTUP ORDER: check page 23 for the values associated with each byte command
// 1.) FUNCTION SET
// 2.) DISPLAY ON/OFF CONTROL
// 3.) ENTRY MODE SET
// 4.) CLEAR DISPLAY
// Because we're sending an LCD command, rs_mode set to 0.
send_byte(i2c_adapter, S_FUNCTION_SET, 0);
send_byte(i2c_adapter, S_DISPLAY_SET, 0);
send_byte(i2c_adapter, S_CHAR_ENTRY_SET, 0);
send_byte(i2c_adapter, CLEAR_DISPLAY, 0);
}
int main() {
// --- LCD ---
const std::string filepath{ "/dev/i2c-1" };
const int i2c_adapter = open(filepath.c_str(), O_RDWR);
if (i2c_adapter < 0){
std::cout << "Failed to open bus." << std::endl;
exit(1);
}
// You can find the address hex value in your LCD's data sheet
const uint8_t lcd_address = 0x27;
if(ioctl(i2c_adapter, I2C_SLAVE, lcd_address) < 0) {
std::cout << "Failed to reach I2C slave." << std::endl;
exit(1);
}
wake_lcd(i2c_adapter);
// Usually we'll have rs_mode set to 1 since we're sending data
// Sending the character 'H' (Binary: 0100 1000)
send_byte(i2c_adapter, 'H', PIN0_RS);
send_byte(i2c_adapter, 'I', PIN0_RS);
send_byte(i2c_adapter, '!', PIN0_RS);
close(i2c_adapter);
return 0;
}
Once you've reached this point you've officially bridged the point between datasheet and code that actually communicates with the LCD.
If you go ahead and compile this (if all goes well) it should print the message "HI!". If you want to take it a step further and build a function to send full strings at a time you can do that using the send_byte function under the hood. Here's a simple way you could do that:
// Note that this doesn't cover edgecases.
// Just meant to be an example for sending full strings.
void send_msg(int i2c_adapter, const std::string& message){
for(char character : message){
send_byte(i2c_adapter, character, 1);
}
}
Conclusion
That's pretty much it. If you followed along this far, thank you for reading.
I want to make it apparent again that this is NOT a definitive guide on everything you can do with the LCD, and a likely didn't use any of the best practices either. I'm just trying to share what I learned in the hope I this post can be a reference point to someone else starting out with the same device.
I'm self taught and still very new to embedded systems, so if you have any suggestions or spot any glaring errors I'm happy to acknowledge them!


Top comments (0)