The task is to set up I²C communication between two master microcontrollers and a 16x2 LCD (slave) using the PCF8574 I²C module, enabling both masters to display their respective data on a shared LCD screen.
Requirements
In a multi-master I²C system, multiple masters share the same SDA (data) and SCL (clock) lines.
Arbitration ensures that only one master controls the bus at a time:
This process of arbitration is automatically handled by hardware, while synchronisation keeps all devices aligned in timing — enabling reliable communication with shared peripherals like the LCD using minimal wiring.
In 4-bit mode, the LCD uses only D4–D7 data pins.
Each 8-bit instruction or data byte is sent in two 4-bit parts (high nibble first, then low).

This task will be implemented on the following microcontrollers:
We’re using an STM32 NUCLEO-F103RB board for both the masters, which operate at a 3.3V logic level.
To interface the STM32 with the I2C 16x2 LCD, we are using the custom library lcd_i2c.c and lcd_i2c.h, which are included in the project.
To understand the implementation in detail, download the project and explore these files.
We developed this driver to interface a 16×2 HD44780 LCD with an STM32 microcontroller using the I²C communication protocol.
Instead of connecting the LCD directly to many GPIO pins, I used a PCF8574 I²C expander — this reduces wiring and uses only two pins (SCL & SDA).
When you call lcd_init(), it performs the LCD startup sequence:
The backlight turns ON by default. After this, the LCD is ready to display characters.
Data is sent using interrupt-based I²C (HAL_I2C_Master_Seq_Transmit_IT), so the MCU isn’t stuck waiting for transfers to finish.
HAL_I2C_MasterTxCpltCallback() → called when data is sentHAL_I2C_ErrorCallback() → handles errors and can call lcd_recover_bus() to reset I²C if neededThis approach keeps communication fast, safe, and non-blocking.
Category | Functions | Purpose |
|---|---|---|
Display |
| Display operations |
Backlight |
| Control LCD light |
Status & Recovery |
| Check errors & recover communication |
When using this driver in your STM32 project:
HAL_Delay() calls in the init and write functions — they ensure proper LCD timing.This driver:
Key Peripherals Used
Circuit Diagram

Project Setup in STM32CubeIDE
HAL_Init() – HAL and SysTick.SystemClock_Config() – HSI+PLL configuration.MX_GPIO_Init() – Port clocks.MX_I2C1_Init() – I²C setup.MX_USART2_UART_Init() – UART setup.This code sets up the hardware and prepares the project for firmware development, so we only need to add our application logic in the user code sections
I²C Initialization (MX_I2C1_Init)
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000; // 100 kHz
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.OwnAddress1 = 0;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); }Initializes I²C1 as a 100 kHz 7-bit master, with clock stretching enabled on the master side.
#include <string.h>
#include <stdio.h>
#include "lcd_i2c.h"I2C_HandleTypeDef hi2c1;
UART_HandleTypeDef huart2;
static char lcd_buffer[20];
static uint32_t lastTick = 0;lcd_buffer stores text to send to the LCD.lastTick tracks the last time the LCD was updated.printf() to UARTint __io_putchar(int ch)
{
HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
} printf() over UART2.static void display_uptime(void)
{
uint32_t uptime = HAL_GetTick() / 1000U;
uint8_t hours = uptime / 3600U;
uint8_t minutes = (uptime % 3600U) / 60U;
uint8_t seconds = uptime % 60U;
snprintf(lcd_buffer, sizeof(lcd_buffer), "Uptime:%03u:%02u:%02u",
hours, minutes, seconds);
lcd_write(0, 0, lcd_buffer);
}int main(void)
{
HAL_Init(); // Start HAL and SysTick
MX_GPIO_Init(); // GPIO init (auto-gen)
MX_USART2_UART_Init(); // UART init (auto-gen)
MX_I2C1_Init(); // I2C init (auto-gen)
printf("STM32F103RB I2C LCD Master 1\r\n");
// Initialize LCD via I2C
lcd_i2c_attach(&hi2c1);
if (lcd_init() == HAL_OK) {
lcd_clear();
lcd_backlight_on();
} else {
printf("LCD init failed\r\n");
}
// Main loop
while (1)
{
uint32_t now = HAL_GetTick();
if ((now - lastTick) >= 1000U) {
lastTick = now;
display_uptime();
uint8_t err; lcd_state_t st;
lcd_get_status(&err, &st);
printf("Uptime:%lu s, I2C Errors:%u, State:%d\r\n",
now / 1000U, err, (int)st);
if (st == LCD_STATE_ERROR) {
printf("LCD error -> recovering bus...\r\n");
lcd_recover_bus();
HAL_Delay(1000);
}
}
HAL_Delay(10); // Allow I²C sharing with other masters
}
}Explanation:
lcd_i2c_attach() – provides the I²C handle to the LCD driver.lcd_init() – runs the HD44780 4-bit init sequence via the PCF8574 (enable strobes, function set, clear, entry mode, display on).lcd_write(row, col, str) – sets DDRAM address and streams characters in a single I2C transaction to overcome an interrupt from another master.lcd_get_status() – reports driver state and I²C error counter.lcd_recover_bus() – re-inits I²C peripheral after error conditions (bus hung/NACK).Note: The driver uses interrupt-driven I²C (HAL_I2C_Master_Seq_Transmit_IT) and implements HAL_I2C_MasterTxCpltCallback and HAL_I2C_ErrorCallback. Ensure I2C1 event/error interrupts are enabled in the NVIC.
The complete STM32CubeIDE project (including .ioc configuration, main.c, and HAL files) is available here:
📥 Download Project
Key Peripherals Used
Circuit Diagram

SystemClock_Config, SYSCLK ≈ 64 MHz).ADC_CHANNEL_0 (PA0).Code Generation
CubeMX will automatically generate all the startup code, including:
HAL_Init() – HAL and SysTick.SystemClock_Config() – HSI+PLL configuration.MX_GPIO_Init() – Port clocks.MX_I2C1_Init() – I²C setup.MX_ADC1_Init() – ADC setupMX_USART2_UART_Init() – UART setup.This code sets up the hardware and prepares the project for firmware development, so we only need to add our application logic in the user code sections
ADC Initialization (MX_ADC1_Init)
hadc1.Instance = ADC1;
hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE;
hadc1.Init.ContinuousConvMode = DISABLE;
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 1;
HAL_ADC_Init(&hadc1);
ADC_ChannelConfTypeDef sConfig = {0};
sConfig.Channel = ADC_CHANNEL_0; // PA0
sConfig.Rank = ADC_REGULAR_RANK_1;
sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; // robust sampling
HAL_ADC_ConfigChannel(&hadc1, &sConfig);
Configures ADC1 for single, software-triggered conversions on PA0, with long sample time for stable pot readings.
I²C Initialization (MX_I2C1_Init)
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000; // 100 kHz
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.OwnAddress1 = 0;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); }Initializes I²C1 as a 100 kHz 7-bit master, with clock stretching enabled on the master side.
#include <string.h>
#include <stdio.h>
#include "lcd_i2c.h"ADC_HandleTypeDef hadc1;
I2C_HandleTypeDef hi2c1;
UART_HandleTypeDef huart2;
static char lcd_buffer[20];
static uint32_t lastTick = 0;int __io_putchar(int ch)
{
HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}Redirects all printf() output to USART2, so logs appear on a serial terminal (115200 baud).
Useful for debugging and monitoring ADC readings.
static void display_ADC_value(void)
{
HAL_ADC_Start(&hadc1); // Start conversion
HAL_ADC_PollForConversion(&hadc1, 20); // Wait (timeout 20 ms)
uint16_t adcValue = HAL_ADC_GetValue(&hadc1); // Read 12-bit result
snprintf(lcd_buffer, sizeof(lcd_buffer),
"POT Value:%04d ", adcValue);
lcd_write(1, 0, lcd_buffer); // Show on LCD (row 1)
}What it does:
int main(void)
{
HAL_Init(); // HAL & SysTick setup
MX_GPIO_Init();
MX_USART2_UART_Init();
MX_ADC1_Init();
MX_I2C1_Init();
printf("STM32F103RB I2C LCD Master 2\r\n");
// LCD Initialization
lcd_i2c_attach(&hi2c1);
if (lcd_init() == HAL_OK) {
lcd_clear();
lcd_backlight_on();
} else {
printf("LCD init failed\r\n");
}
// Main loop
while (1)
{
uint32_t now = HAL_GetTick();
if ((now - lastTick) >= 500U) {
lastTick = now;
display_ADC_value(); // Show updated ADC value
uint8_t err; lcd_state_t st;
lcd_get_status(&err, &st);
printf("I2C Errors:%u, State:%d\r\n", err, (int)st);
if (st == LCD_STATE_ERROR) {
printf("LCD error -> recovering bus...\r\n");
lcd_recover_bus();
HAL_Delay(1000);
}
}
HAL_Delay(10); // Keeps I²C bus cooperative in multi-master setup
}
}Explanation:
HAL_Delay(10) between iterations for stable multi-master communication.HAL_ADC_Start() → begins a single conversion.HAL_ADC_PollForConversion() → waits (blocking) for completion.HAL_ADC_GetValue() → reads right-aligned 12-bit value (0–4095).The complete STM32CubeIDE project (including .ioc configuration, main.c, and HAL files) is available here:
📥 Download Project
We are using the ESP32 DevKitC v4 development board and programming it using the Arduino IDE.
In both ESP32 master and slave codes, the Wire.h library is used for I²C communication.
By default, the ESP32 Arduino core assigns:
In the given task, we used the default I²C Pins.
Solution of the given task:
This creates a shared I²C bus, allowing both masters to communicate with the same LCD using only two wires (SDA and SCL) along with common VCC and GND.
Circuit Diagram

#include <Wire.h>
#include <Arduino.h>
/*User Configuration*/
#define LCD_I2C_ADDRESS 0x27 // I2C address of the LCD module
#define LCD_I2C_FREQ_HZ 100000 // I2C communication frequency (100kHz)
#define LCD_SDA_PIN 21 // ESP32 SDA pin
#define LCD_SCL_PIN 22 // ESP32 SCL pin
#define LCD_DELAY_MS(ms) delay(ms) // Macro for millisecond delay
#define LCD_DELAY_US(us) delayMicroseconds(us) // Macro for microsecond delay
/*LCD Bit Mapping
PCF8574 → LCD pin connection (typical):
P0 = RS, P1 = RW, P2 = EN, P3 = Backlight, P4–P7 = D4–D7
*/
#define LCD_EN 0x04U // Enable bit (latch signal)
#define LCD_RW 0x02U // Read/Write bit (0 = Write)
#define LCD_RS 0x01U // Register Select (0 = Command, 1 = Data)
#define LCD_BACKLIGHT 0x08U // Backlight control bit
static uint8_t s_backlight = LCD_BACKLIGHT; // Keeps LCD backlight ON
//Prepares 4-byte sequence to send 8-bit data (split into two 4-bit nibbles).
//Each nibble is latched using EN HIGH→LOW transition.
static int build_nibble_seq(uint8_t data, uint8_t control, uint8_t *out) {
out[0] = s_backlight | control | (data & 0xF0) | LCD_EN; // High nibble + EN=1
out[1] = s_backlight | control | (data & 0xF0); // High nibble + EN=0
out[2] = s_backlight | control | ((data << 4) & 0xF0) | LCD_EN; // Low nibble + EN=1
out[3] = s_backlight | control | ((data << 4) & 0xF0); // Low nibble + EN=0
return 4; // Returns number of bytes generated
}
//Sends a command byte to the LCD (RS=0).
static void lcd_command(uint8_t cmd) {
uint8_t seq[4];
build_nibble_seq(cmd, 0, seq);
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
Wire.endTransmission();
}
//Sends data (a display character) to the LCD (RS=1).
static void lcd_data(uint8_t data) {
uint8_t seq[4];
build_nibble_seq(data, LCD_RS, seq);
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
Wire.endTransmission();
LCD_DELAY_US(50); // Short delay for LCD to process
}
//LCD Initialization
//Initializes the LCD in 4-bit mode as per HD44780 datasheet sequence.
void lcd_init() {
Wire.begin(LCD_SDA_PIN, LCD_SCL_PIN); // Initialize I2C bus
Wire.setClock(LCD_I2C_FREQ_HZ); // Set I2C speed
// Turn ON backlight
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(s_backlight);
Wire.endTransmission();
LCD_DELAY_MS(50); // Wait for LCD power stabilization
// Initialization sequence for 4-bit interface
lcd_command(0x30);
LCD_DELAY_MS(5);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x20); // Set 4-bit mode
LCD_DELAY_MS(5);
// Function set: 4-bit, 2 lines, 5x8 font
lcd_command(0x28);
LCD_DELAY_US(50);
// Display control: display OFF
lcd_command(0x08);
LCD_DELAY_US(50);
// Clear display
lcd_command(0x01);
LCD_DELAY_MS(3);
// Entry mode: increment cursor
lcd_command(0x06);
LCD_DELAY_US(50);
// Display ON, cursor OFF
lcd_command(0x0C);
LCD_DELAY_US(50);
}
//LCD Write Function
void lcd_write(uint8_t row, uint8_t col, const char *str) {
if (!str) return; // Skip if string is null
// LCD DDRAM starting addresses for each line
static const uint8_t row_addr[] = { 0x00, 0x40, 0x14, 0x54 };
uint8_t addr = 0x80 | (col + row_addr[row]); // Set cursor position
// Build full I2C transmission buffer (cursor + text)
uint8_t txbuf[4 + 4 * 80];
int idx = 0;
// Add cursor set command (RS=0)
idx += build_nibble_seq(addr, 0, &txbuf[idx]);
// Add character data (RS=1)
for (size_t i = 0; str[i] && i < 80; i++) {
idx += build_nibble_seq(str[i], LCD_RS, &txbuf[idx]);
}
// Transmit entire buffer in one I2C transaction
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(txbuf, idx);
Wire.endTransmission(); // Send STOP condition
}
//Clears the LCD display and resets cursor position.
void lcd_clear() {
lcd_command(0x01);
LCD_DELAY_MS(3);
}
void setup() {
delay(1000); // Allow power and peripherals to stabilize
Serial.begin(115200);
Serial.println("Initializing LCD...");
lcd_init(); // Initialize LCD
lcd_clear(); // Clear display
}
void loop() {
static unsigned long lastUpdate = 0;
// Update every 1 second
if (millis() - lastUpdate > 1000) {
lastUpdate = millis();
// Calculate uptime in hours, minutes, and seconds
unsigned long timeSec = millis() / 1000;
uint8_t hours = timeSec / 3600;
uint8_t minutes = (timeSec % 3600) / 60;
uint8_t seconds = timeSec % 60;
// Format uptime text
char buff[20];
sprintf(buff, "Uptime:%03d:%02d:%02d", hours, minutes, seconds);
// Display uptime on LCD line 1
lcd_write(0, 0, buff);
}
}
Wire.begin(LCD_SDA_PIN, LCD_SCL_PIN)Wire.setClock(LCD_I2C_FREQ_HZ)Step | Command |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
These commands ensure the LCD is correctly set up to receive 4-bit data via I²C.
build_nibble_seq() function splits each 8-bit command or character into two 4-bit parts (high nibble and low nibble).lcd_command() and lcd_data()lcd_command(cmd) sends instruction bytes (like clear screen, set mode).lcd_data(data) sends printable character data to the display.Wire.beginTransmission() → Wire.write() → Wire.endTransmission() for I²C data transfer.Line 4 → 0xD4 + column (20x4 LCD only)
In the code, this is calculated as: uint8_t addr = 0x80 | (col + row_addr[row]);
The cursor is set before displaying the formatted uptime text.
lcd_write(row, col, str)build_nibble_seq().lcd_clear()unsigned long timeSec = millis() / 1000;uint8_t hours = timeSec / 3600;uint8_t minutes = (timeSec % 3600) / 60;uint8_t seconds = timeSec % 60;millis() counts milliseconds since the ESP32 started running.sprintf(buff, "Uptime:%03d:%02d:%02d", hours, minutes, seconds); if (millis() - lastUpdate > 1000) lcd_write(0, 0, buff);lcd_init() and lcd_clear().This creates a shared I²C bus, allowing both masters to communicate with the same LCD using only two wires (SDA and SCL) along with common VCC and GND.
Circuit Connection

#include <Wire.h>
#include <Arduino.h>
/*User Configuration*/
#define LCD_I2C_ADDRESS 0x27 // I2C address of LCD (PCF8574 module)
#define LCD_I2C_FREQ_HZ 100000 // I2C bus frequency (100 kHz standard mode)
#define LCD_SDA_PIN 21 // ESP32 SDA pin
#define LCD_SCL_PIN 22 // ESP32 SCL pin
#define ADC_PIN 34 // Analog input pin connected to potentiometer
#define LCD_DELAY_MS(ms) delay(ms) // Delay in milliseconds
#define LCD_DELAY_US(us) delayMicroseconds(us) // Delay in microseconds
/*LCD Bit Mapping
PCF8574 pin → LCD pin:
P0 = RS, P1 = RW, P2 = EN, P3 = Backlight, P4–P7 = D4–D7
*/
#define LCD_EN 0x04U // Enable bit (latch trigger)
#define LCD_RW 0x02U // Read/Write bit (0 = Write)
#define LCD_RS 0x01U // Register Select (0 = Command, 1 = Data)
#define LCD_BACKLIGHT 0x08U // Backlight control bit
static uint8_t s_backlight = LCD_BACKLIGHT; // Keep backlight ON
//Splits an 8-bit value into two 4-bit nibbles and prepares
//the 4-step sequence needed to latch both nibbles using EN pulses.
static int build_nibble_seq(uint8_t data, uint8_t control, uint8_t *out) {
out[0] = s_backlight | control | (data & 0xF0) | LCD_EN; // High nibble + EN=1
out[1] = s_backlight | control | (data & 0xF0); // High nibble + EN=0
out[2] = s_backlight | control | ((data << 4) & 0xF0) | LCD_EN; // Low nibble + EN=1
out[3] = s_backlight | control | ((data << 4) & 0xF0); // Low nibble + EN=0
return 4; // Returns total 4 bytes created
}
//Sends a command byte (RS=0) to the LCD.
static void lcd_command(uint8_t cmd) {
uint8_t seq[4];
build_nibble_seq(cmd, 0, seq);
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
Wire.endTransmission();
}
//Sends a single data byte (RS=1) to the LCD.
static void lcd_data(uint8_t data) {
uint8_t seq[4];
build_nibble_seq(data, LCD_RS, seq);
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
Wire.endTransmission();
LCD_DELAY_US(50); // Short delay for LCD to process data
}
// LCD Initialization
void lcd_init() {
Wire.begin(LCD_SDA_PIN, LCD_SCL_PIN); // Start I2C with defined SDA/SCL
Wire.setClock(LCD_I2C_FREQ_HZ); // Set I2C clock
// Turn on LCD backlight
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(s_backlight);
Wire.endTransmission();
LCD_DELAY_MS(50); // Wait for LCD power-up
// Initialization sequence for 4-bit operation
lcd_command(0x30);
LCD_DELAY_MS(5);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x20); // Switch to 4-bit mode
LCD_DELAY_MS(5);
// Function set: 4-bit mode, 2-line display, 5x8 font
lcd_command(0x28);
LCD_DELAY_US(50);
// Display control: display OFF
lcd_command(0x08);
LCD_DELAY_US(50);
// Clear display
lcd_command(0x01);
LCD_DELAY_MS(3);
// Entry mode: increment cursor, no shift
lcd_command(0x06);
LCD_DELAY_US(50);
// Display ON, cursor OFF
lcd_command(0x0C);
LCD_DELAY_US(50);
}
//LCD Write Function
void lcd_write(uint8_t row, uint8_t col, const char *str) {
if (!str)return; // Exit if string is null
// LCD DDRAM starting addresses for 4 lines
static const uint8_t row_addr[] = {0x00, 0x40, 0x14, 0x54};
uint8_t addr = 0x80 | (col + row_addr[row]); // Set cursor address
// Create transmission buffer (cursor set + string data)
uint8_t txbuf[4 + 4 * 80];
int idx = 0;
// Add cursor set command
idx += build_nibble_seq(addr, 0, &txbuf[idx]);
// Add each character (data)
for (size_t i = 0; str[i] && i < 80; i++) {
idx += build_nibble_seq(str[i], LCD_RS, &txbuf[idx]);
}
// Send entire buffer in one I2C transmission
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(txbuf, idx);
Wire.endTransmission(); // Stop condition
}
//Clears the LCD screen and resets cursor position.
void lcd_clear() {
lcd_command(0x01);
LCD_DELAY_MS(3);
}
void setup() {
delay(1000); // Allow system to stabilize
Serial.begin(115200);
Serial.println("Initializing LCD...");
lcd_init(); // Initialize LCD
lcd_clear(); // Clear screen before start
}
void loop() {
static unsigned long lastUpdate = 0;
// Update display every 1 second
if (millis() - lastUpdate > 1000) {
lastUpdate = millis();
uint16_t adcValue = analogRead(ADC_PIN); // Read potentiometer ADC value
char buff[20];
sprintf(buff, "POT Value: %04d", adcValue); // Format ADC value string
lcd_write(1, 0, buff); // Display on line 2 (row index = 1)
}
}
Wire.begin(LCD_SDA_PIN, LCD_SCL_PIN)Wire.setClock(LCD_I2C_FREQ_HZ)lcd_command(0x30) (×3) → Ensures LCD is awake and in 8-bit mode (default).lcd_command(0x20) → Switches LCD to 4-bit mode, reducing the number of data lines needed.lcd_command(0x28) → Function Set: 4-bit mode, 2-line display, 5x8 font.lcd_command(0x08) → Display OFF (temporary).lcd_command(0x01) → Clear display.lcd_command(0x06) → Auto-increment cursor after each character.lcd_command(0x0C) → Display ON, cursor OFF.build_nibble_seq() divides each 8-bit command or character into two 4-bit parts (nibbles). uint8_t addr = 0x80 | (col + row_addr[row]);and sends it to the LCD as a command before displaying text.
lcd_write(row, col, str) build_nibble_seq() and sent sequentially to the LCD. uint16_t adcValue = analogRead(ADC_PIN); sprintf(buff, "POT Value: %04d", adcValue);This ensures the display always shows 4 digits (e.g., POT Value: 0325).
if (millis() - lastUpdate > 1000)lcd_clear()lcd_init() and clears it before display begins.We are using the Arduino UNO development board and programming it using the Arduino IDE.
In both Arduino UNO master and slave codes, the Wire.h library is used for I²C communication.
Solution of the given task
First, let's establish the hardware connection.
This creates a shared I²C bus, allowing both masters to communicate with the same LCD using only two wires (SDA and SCL) along with common VCC and GND.
Circuit Diagram

#include <Wire.h>
#include <Arduino.h>
#define LCD_I2C_ADDRESS 0x27 // I2C address of the LCD module
#define LCD_DELAY_MS(ms) delay(ms) // Delay in milliseconds
#define LCD_DELAY_US(us) delayMicroseconds(us) // Delay in microseconds
/* LCD Bit Mapping
PCF8574 (I2C chip) to LCD pins:
P0 = RS, P1 = RW, P2 = EN, P3 = Backlight, P4–P7 = D4–D7
*/
#define LCD_EN 0x04U // Enable pin (used for latching data)
#define LCD_RW 0x02U // Read/Write (0 = Write)
#define LCD_RS 0x01U // Register Select (0 = Command, 1 = Data)
#define LCD_BACKLIGHT 0x08U // Backlight control
static uint8_t s_backlight = LCD_BACKLIGHT; // Keep backlight ON
//Converts an 8-bit command or character into two 4-bit data transfers.
//Each 4-bit transfer (nibble) is latched using EN HIGH→LOW transitions.
static int build_nibble_seq(uint8_t data, uint8_t control, uint8_t *out) {
out[0] = s_backlight | control | (data & 0xF0) | LCD_EN; // High nibble + EN=1
out[1] = s_backlight | control | (data & 0xF0); // High nibble + EN=0
out[2] = s_backlight | control | ((data << 4) & 0xF0) | LCD_EN; // Low nibble + EN=1
out[3] = s_backlight | control | ((data << 4) & 0xF0); // Low nibble + EN=0
return 4; // Returns total 4 bytes to send
}
//Send a command to the LCD (RS=0).
static void lcd_command(uint8_t cmd) {
uint8_t seq[4];
build_nibble_seq(cmd, 0, seq);
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
Wire.endTransmission();
}
//Send a single data byte to LCD (RS=1).
static void lcd_data(uint8_t data) {
uint8_t seq[4];
build_nibble_seq(data, LCD_RS, seq);
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
Wire.endTransmission();
LCD_DELAY_US(50); // Short delay between characters
}
// LCD Initialization
void lcd_init() {
Wire.begin(); // Initialize I2C communication
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(s_backlight); // Turn on backlight
Wire.endTransmission();
LCD_DELAY_MS(50); // Wait for LCD power-up
// LCD initialization sequence (4-bit mode)
lcd_command(0x30);
LCD_DELAY_MS(5);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x20);
LCD_DELAY_MS(5); // Set 4-bit mode
// Function set: 4-bit, 2-line, 5x8 dots
lcd_command(0x28);
LCD_DELAY_US(50);
// Display OFF
lcd_command(0x08);
LCD_DELAY_US(50);
// Clear display
lcd_command(0x01);
LCD_DELAY_MS(3);
// Entry mode: increment cursor
lcd_command(0x06);
LCD_DELAY_US(50);
// Display ON, cursor OFF
lcd_command(0x0C);
LCD_DELAY_US(50);
}
/* LCD Write String
* Positions cursor and writes a full string to the LCD.
*/
void lcd_write(uint8_t row, uint8_t col, const char *str) {
if (!str) return; // Skip if no string provided
// Calculate DDRAM address based on row and column
uint8_t addr;
switch (row) {
case 0: addr = 0x80 + col; break; // Line 1
case 1: addr = 0xC0 + col; break; // Line 2
case 2: addr = 0x94 + col; break; // Line 3 (for 20x4)
case 3: addr = 0xD4 + col; break; // Line 4 (for 20x4)
default: addr = 0x80 + col; break;
}
uint8_t seq[4];
// Send cursor position command (RS=0)
Wire.beginTransmission(LCD_I2C_ADDRESS);
build_nibble_seq(addr, 0x00U, seq);
Wire.write(seq, 4);
Wire.endTransmission(false); // Keep bus active
// Send characters (RS=1)
size_t len = strlen(str);
for (size_t i = 0; i < len; i++) {
build_nibble_seq((uint8_t)str[i], LCD_RS, seq);
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
// Stop only after the last character
if (i < len - 1)
Wire.endTransmission(false);
else
Wire.endTransmission(true);
}
LCD_DELAY_MS(2); // Allow LCD to process data
}
/* Utility Function */
void lcd_clear() {
lcd_command(0x01); // Clear display
LCD_DELAY_MS(3);
}
/* Setup */
void setup() {
delay(1000); // Allow power and peripherals to stabilize
Serial.begin(115200);
Serial.println("Initializing LCD...");
lcd_init(); // Initialize LCD
lcd_clear(); // Clear screen
}
//Updates and displays system uptime every 1 second.
void loop() {
static unsigned long lastUpdate = 0;
// Update display every 1 second
if (millis() - lastUpdate > 1000) {
lastUpdate = millis();
// Calculate uptime in hours, minutes, and seconds
unsigned long timeSec = millis() / 1000;
uint8_t hours = timeSec / 3600;
uint8_t minutes = (timeSec % 3600) / 60;
uint8_t seconds = timeSec % 60;
// Format uptime string
char buff[20];
sprintf(buff, "Uptime:%03d:%02d:%02d", hours, minutes, seconds);
// Display uptime on first line
lcd_write(0, 0, buff);
}
}
Wire.begin()lcd_command(0x28) → Configures LCD for 4-bit mode, 2 lines, and 5x8 font.lcd_command(0x0C) → Turns display ON, hides the cursor.lcd_command(0x06) → Enables auto cursor increment after each character.lcd_command(0x01) → Clears the display and resets the cursor to the home position.build_nibble_seq() function splits 8-bit data or commands into two 4-bit nibbles (high and low).lcd_command() sends LCD control instructions (RS = 0), while lcd_data() sends display characters (RS = 1).This command sets the DDRAM address to control where characters will appear on the LCD.
millis() to calculate total elapsed time since power-up.lcd_write(0, 0, buff) Wire.endTransmission(false) keeps the bus active—allowing the whole string to be sent in one continuous I²C transaction without losing bus control.First, let's establish the hardware connection.
This creates a shared I²C bus, allowing both masters to communicate with the same LCD using only two wires (SDA and SCL) along with common VCC and GND.
Circuit Diagram

#include <Wire.h>
#include <Arduino.h>
/*User Configuration*/
#define LCD_I2C_ADDRESS 0x27 // I2C address of LCD (check using I2C scanner)
#define ADC_PIN A0 // Analog pin connected to potentiometer
#define LCD_DELAY_MS(ms) delay(ms) // Millisecond delay
#define LCD_DELAY_US(us) delayMicroseconds(us) // Microsecond delay
/*LCD Bit Mapping
PCF8574 I2C Expander Pin Mapping:
P0 = RS, P1 = RW, P2 = EN, P3 = Backlight, P4–P7 = D4–D7 (4-bit data)
*/
#define LCD_EN 0x04U // Enable signal bit
#define LCD_RW 0x02U // Read/Write control (0 = Write)
#define LCD_RS 0x01U // Register Select (0 = Command, 1 = Data)
#define LCD_BACKLIGHT 0x08U // Backlight ON bit
static uint8_t s_backlight = LCD_BACKLIGHT; // Keeps LCD backlight always ON
// Splits 8-bit data into two 4-bit nibbles and generates the sequence
// required to latch them into the LCD using EN HIGH→LOW pulses.
static int build_nibble_seq(uint8_t data, uint8_t control, uint8_t *out) {
out[0] = s_backlight | control | (data & 0xF0) | LCD_EN; // Send upper nibble with EN=1
out[1] = s_backlight | control | (data & 0xF0); // EN=0
out[2] = s_backlight | control | ((data << 4) & 0xF0) | LCD_EN; // Send lower nibble with EN=1
out[3] = s_backlight | control | ((data << 4) & 0xF0); // EN=0
return 4; // Returns total 4 bytes created
}
//Sends an LCD command (RS=0).
static void lcd_command(uint8_t cmd) {
uint8_t seq[4];
build_nibble_seq(cmd, 0, seq); // RS=0 for command
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4); // Send data bytes
Wire.endTransmission(); // End I2C transmission
}
//Sends a character (RS=1) to LCD.
static void lcd_data(uint8_t data) {
uint8_t seq[4];
build_nibble_seq(data, LCD_RS, seq); // RS=1 for data
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
Wire.endTransmission();
LCD_DELAY_US(50); // Short delay after data
}
/* LCD Initialization */
void lcd_init() {
Wire.begin(); // Start I2C communication
// Turn on LCD backlight
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(s_backlight);
Wire.endTransmission();
LCD_DELAY_MS(50); // Wait for LCD power-up
// Initialization sequence (switch to 4-bit mode)
lcd_command(0x30);
LCD_DELAY_MS(5);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x30);
LCD_DELAY_US(150);
lcd_command(0x20);
LCD_DELAY_MS(5); // Set 4-bit mode
// Function set: 4-bit, 2-line, 5x8 dots
lcd_command(0x28);
LCD_DELAY_US(50);
// Display OFF
lcd_command(0x08);
LCD_DELAY_US(50);
// Clear display
lcd_command(0x01);
LCD_DELAY_MS(3);
// Entry mode: increment cursor automatically
lcd_command(0x06);
LCD_DELAY_US(50);
// Display ON, cursor OFF
lcd_command(0x0C);
LCD_DELAY_US(50);
}
/*LCD Write Function
Writes a string at the specified row and column on the LCD.
*/
void lcd_write(uint8_t row, uint8_t col, const char *str) {
if (!str) return; // Skip if string is empty
// Calculate DDRAM address for given row and column
uint8_t addr;
switch (row) {
case 0: addr = 0x80 + col; break; // Line 1
case 1: addr = 0xC0 + col; break; // Line 2
case 2: addr = 0x94 + col; break; // Line 3 (for 20x4 LCDs)
case 3: addr = 0xD4 + col; break; // Line 4 (for 20x4 LCDs)
default: addr = 0x80 + col; break;
}
uint8_t seq[4];
/* Send Cursor Position Command */
Wire.beginTransmission(LCD_I2C_ADDRESS);
build_nibble_seq(addr, 0x00U, seq); // RS=0 for command
Wire.write(seq, 4);
Wire.endTransmission(false); // No STOP (keep I2C active)
/*Send Characters to LCD */
size_t len = strlen(str);
for (size_t i = 0; i < len; i++) {
build_nibble_seq((uint8_t)str[i], LCD_RS, seq); // RS=1 for data
Wire.beginTransmission(LCD_I2C_ADDRESS);
Wire.write(seq, 4);
if (i < len - 1)
Wire.endTransmission(false); // Keep I2C active
else
Wire.endTransmission(true); // Last char → send STOP
}
LCD_DELAY_MS(2); // Short wait for LCD
}
/*Utility Functions*/
void lcd_clear() {
lcd_command(0x01); // Clear display
LCD_DELAY_MS(3);
}
/*Setup*/
void setup() {
delay(1000); // Allow power stabilization
Serial.begin(115200);
Serial.println("Initializing LCD...");
lcd_init(); // Initialize LCD
lcd_clear(); // Clear any junk data
}
void loop() {
static unsigned long lastUpdate = 0;
// Update every 1 second
if (millis() - lastUpdate > 1000) {
lastUpdate = millis();
uint16_t adcValue = analogRead(ADC_PIN); // Read analog input (0–1023)
char buff[20];
sprintf(buff, "POT Value: %04d ", adcValue); // Format display text
lcd_write(1, 0, buff); // Print on LCD line 2
}
}
Wire.begin()lcd_command(0x30) (sent three times) ensures the LCD is awake and in 8-bit mode.lcd_command(0x20) switches it to 4-bit communication mode, reducing the number of data pins needed.build_nibble_seq() function splits each 8-bit command or character into two 4-bit nibbles (upper and lower).Wire.write() and Wire.endTransmission() functions handle I²C communication between Arduino and the LCD module.These base addresses are defined by the LCD’s internal memory map.
analogRead(ADC_PIN) reads the voltage from a potentiometer connected to analog pin A0. sprintf(buff, "POT Value: %04d ", adcValue);which ensures a clean display format like "POT Value: 0450".
if (millis() - lastUpdate > 1000) lcd_write(1, 0, buff);lcd_write() Wire.endTransmission(false) keeps the bus active—allowing the whole string to be sent in one continuous I²C transaction without losing bus control.lcd_clear() lcd_init() and clears the display.