Hello! Welcome back to our hands-on exploration of embedded communication protocols.
In our last lesson, you built the essential low-level functions for I2C communication: I2C_Start, I2C_Address, I2C_Write, and I2C_Stop. You now have the fundamental tools to control the I2C bus at the register level. Today, we'll put that hard work into practice by interfacing with a real-world peripheral: the I2C LCD module in your kit.
Our goal is to interface with an I2C LCD to display text and sensor data. We will build a small driver that uses your I2C functions to initialize the display and then write both static text and dynamic numerical data to the screen. This lesson will solidify your understanding of how protocol-level functions are used to build device-specific drivers.
1. Demystifying the I2C LCD Module
The 16x2 character LCD in your kit may look like a single I2C device, but it's actually two components working together:
- A standard 16x2 LCD that uses a parallel interface (like the classic Hitachi HD44780).
- A small "backpack" board soldered to it, which contains a PCF8574 I/O Expander chip.
The PCF8574 acts as a bridge. It listens for I2C commands from your STM32 and translates them into the parallel signals (RS, E, D4-D7, etc.) that the LCD controller understands. This is a common technique in embedded systems to save GPIO pins.
To get a clear picture of this hardware and how to connect it, please review the following short article and wiring diagram.
Interface I2C LCD1602 (PCF8574) with STM32 HAL & CubeIDE
The article 'Interface I2C LCD1602 with STM32' from ControllersTech provides an excellent overview of the PCF8574 expander and the necessary connections. We'll use this as our primary guide.
Please read the sections titled 'What is LCD1602 I2C Display and How It Works with STM32', 'PCF8574 I2C Address Configuration Explained', and 'STM32 LCD1602 I2C Schematic and Pinout'. Focus on understanding the role of the PCF8574 and identifying the correct I2C address for your module. The default address is often either 0x27 or 0x3F (7-bit address), which corresponds to 0x4E or 0x7E when shifted for the 8-bit R/W format.
Here is a clear diagram for the physical connections.

A Professional's Tip: If you're ever unsure of a device's I2C address, a common industry practice is to use an "I2C scanner" program. This simple utility loops through all possible addresses (0 to 127) and tries to get an ACK from each one, printing the addresses of any devices that respond.
2. Sending Commands to the LCD
To control the LCD, we need to send bytes to the PCF8574. The state of the 8 I/O pins on the expander directly controls the LCD's input pins. The typical mapping is:
| PCF8574 Pin | LCD Pin | Function |
|---|---|---|
| P0 | RS | Register Select (0=Command, 1=Data) |
| P1 | RW | Read/Write (usually tied to GND for write-only) |
| P2 | E | Enable (pulse to latch data) |
| P3 | Backlight | Controls the backlight LED |
| P4 | D4 | Data Bit 4 |
| P5 | D5 | Data Bit 5 |
| P6 | D6 | Data Bit 6 |
| P7 | D7 | Data Bit 7 |
The LCD is operated in 4-bit mode, meaning we send an 8-bit command or data byte in two 4-bit chunks (nibbles). The most critical part is the Enable (E) pulse: to make the LCD accept a 4-bit nibble, we must set the E pin high, then low.
Let's build the functions to do this, using the I2C_Write function you created in the last lesson. We'll need a way to send a sequence of bytes in a single I2C transaction.
// Helper function to send multiple bytes in one transaction
// This builds upon the I2C_Write from the previous lesson
void I2C_Write_Multi(uint8_t slave_address, uint8_t* data, uint8_t size) {
I2C_Start();
I2C_Address(slave_address); // Address with R/W bit 0
for (int i = 0; i < size; i++) {
// Wait for DR to be empty
while (!(I2C1->SR1 & I2C_SR1_TXE));
I2C1->DR = data[i];
}
// Wait for transfer to be finished
while (!(I2C1->SR1 & I2C_SR1_BTF));
I2C_Stop();
}
// Function to send a command to the LCD
void lcd_send_cmd(char cmd) {
char data_u, data_l;
uint8_t data_t[4];
// 1. Isolate upper and lower nibbles of the command
data_u = (cmd & 0xF0);
data_l = ((cmd << 4) & 0xF0);
// 2. Create the I2C data sequence for the upper nibble
// Format: [Data Nibble | Backlight(1) | E(1) | RW(0) | RS(0)]
data_t[0] = data_u | 0x0C; // En=1, RS=0
data_t[1] = data_u | 0x08; // En=0, RS=0
// 3. Create the I2C data sequence for the lower nibble
data_t[2] = data_l | 0x0C; // En=1, RS=0
data_t[3] = data_l | 0x08; // En=0, RS=0
// 4. Send the 4-byte sequence
I2C_Write_Multi(SLAVE_ADDRESS_LCD, data_t, 4);
}
// Function to send data (a character) to the LCD
void lcd_send_data(char data) {
char data_u, data_l;
uint8_t data_t[4];
data_u = (data & 0xF0);
data_l = ((data << 4) & 0xF0);
// Format: [Data Nibble | Backlight(1) | E(1) | RW(0) | RS(1)]
// The only difference is RS=1
data_t[0] = data_u | 0x0D; // En=1, RS=1
data_t[1] = data_u | 0x09; // En=0, RS=1
data_t[2] = data_l | 0x0D; // En=1, RS=1
data_t[3] = data_l | 0x09; // En=0, RS=1
I2C_Write_Multi(SLAVE_ADDRESS_LCD, data_t, 4);
}
Notice how each 4-bit nibble requires two I2C writes to the PCF8574: one to set the E pin high, and a second to set it low. This creates the required pulse. The RS pin differentiates between a command (RS=0) and character data (RS=1).
3. Initializing the Display
Before we can display anything, the LCD requires a specific sequence of commands to configure it for 4-bit mode and set display parameters.
Interface I2C LCD1602 (PCF8574) with STM32 HAL & CubeIDE
Let's examine the initialization sequence and other helper functions from the ControllersTech article.
Read the subsections 'Initialize I2C LCD16x2', 'Send String to I2C LCD16x2', and 'Set Cursor Position on LCD16x2'. Pay attention to the sequence of commands in lcd_init and their purpose.
Based on that reference, here are the essential functions you'll need. Add these to your project, along with a delay_ms() function if you don't have one.
// Define your LCD's slave address (e.g., 0x4E for 0x27)
#define SLAVE_ADDRESS_LCD 0x4E
void lcd_init(void) {
HAL_Delay(50); // Use your existing delay function
lcd_send_cmd(0x30);
HAL_Delay(5);
lcd_send_cmd(0x30);
HAL_Delay(1);
lcd_send_cmd(0x30);
HAL_Delay(10);
lcd_send_cmd(0x20); // 4bit mode
HAL_Delay(10);
// Display initialization
lcd_send_cmd(0x28); // Function set: 4-bit, 2-line, 5x8 dots
HAL_Delay(1);
lcd_send_cmd(0x08); // Display off
HAL_Delay(1);
lcd_send_cmd(0x01); // Clear display
HAL_Delay(2);
lcd_send_cmd(0x06); // Entry mode: increment cursor, no shift
HAL_Delay(1);
lcd_send_cmd(0x0C); // Display on, cursor off, blink off
}
void lcd_put_cur(int row, int col) {
switch (row) {
case 0:
col |= 0x80;
break;
case 1:
col |= 0xC0;
break;
}
lcd_send_cmd(col);
}
void lcd_send_string(char *str) {
while (*str) lcd_send_data(*str++);
}
Activity 1: "Hello, Embedded World!"
Now it's time to bring it all together.
- Add the functions above (
I2C_Write_Multi,lcd_send_cmd,lcd_send_data,lcd_init,lcd_put_cur,lcd_send_string) to your project. Make sure you have the necessary I2C initialization code from the previous lessons. - In your
main()function, after initializing your clocks, GPIOs, and the I2C peripheral, call the LCD functions to display a message.
int main(void) {
// ... your system initializations ...
I2C_Init(); // From previous lesson
lcd_init();
lcd_put_cur(0, 2); // Set cursor to row 0, column 2
lcd_send_string("Hello,");
lcd_put_cur(1, 4); // Set cursor to row 1, column 4
lcd_send_string("STM32!");
while (1) {
// Loop forever
}
}
Compile, flash, and run your code. You should see your message appear on the LCD!

4. Displaying Sensor Data
Displaying static text is useful, but the real power comes from showing dynamic data. A common task is displaying sensor readings, which are often numerical values (integers or floats). The LCD, however, can only display ASCII characters. Therefore, we must convert numbers to strings.
The standard C library function sprintf (string print formatted) is perfect for this.
Interface I2C LCD1602 (PCF8574) with STM32 HAL & CubeIDE
The final section of the ControllersTech article explains how to use sprintf to display numbers and floats.
Read the subsections 'Print Number' and 'Print Floats'. This demonstrates the pattern of converting a number to a character array before sending it to the LCD.
Activity 2: Display a Counter
Let's simulate sensor data with a simple counter that increments every second.
- Modify your
mainfunction'swhile(1)loop. - Create a counter variable and a character buffer.
- Inside the loop, use
sprintfto format the counter's value into the buffer. - Position the cursor and send the formatted string to the LCD.
// Inside main()
int counter = 0;
char buffer[16]; // A buffer to hold the formatted string
while (1) {
// Format the string
sprintf(buffer, "Count: %d", counter);
// Display the string on the first line
lcd_put_cur(0, 0);
lcd_send_string(buffer);
counter++;
HAL_Delay(1000); // Wait for 1 second
}
Important Note on Floats: If you need to display floating-point numbers, you must enable float support for printf/sprintf in your project settings. In STM32CubeIDE, this is under Project Properties > C/C++ Build > Settings > Tool Settings > MCU GCC Linker > Miscellaneous. Add the flag -u _printf_float.
Troubleshooting Common Issues
If your LCD isn't working as expected, don't worry. This is a common experience. Here's a checklist based on common problems:
- Blank Screen or Solid Black Boxes: This is almost always a contrast issue. Carefully adjust the potentiometer on the I2C backpack.
- Blank Screen (Contrast is OK): Check your power. The LCD needs a solid 5V. Ensure VCC and GND are correctly wired. A poor USB connection can sometimes provide insufficient power.
- Garbage/Random Characters: This points to a communication problem.
- Double-check your
SDAandSCLwiring. - Verify you are using the correct I2C slave address. Try both
0x4Eand0x7E(or0x27and0x3Fif your functions take the 7-bit address). - Ensure your
I2C_Initfunction is correctly setting the clock speed (100kHz is standard).
- Double-check your
Conclusion
Congratulations! You have successfully built a driver for an I2C peripheral and used it for a practical application. You've bridged the gap from low-level protocol implementation to high-level device control.
Key Takeaways:
- I2C LCDs use a PCF8574 I/O expander to convert I2C signals to the LCD's native parallel interface.
- Controlling the LCD involves sending commands and data in 4-bit mode, which requires careful manipulation of the data and control lines (RS, E) via the I/O expander.
- The Enable (
E) pin must be pulsed (high then low) for the LCD to latch each 4-bit nibble. - Numerical data must be converted to an ASCII string using functions like
sprintfbefore it can be displayed.
Preview of the Next Lesson:
So far, we've been "flying blind," trusting that our code is sending the correct signals. In the next lesson, we will complete this module by learning to use a logic analyzer to capture and decode I2C bus traffic. You will visually verify the START, STOP, ADDRESS, ACK, and DATA bytes you sent to the LCD today, gaining an indispensable skill for professional debugging.