How to drive a HD44780 with I2C backpack with a STM32

I2C backpack advantages

The HD44780 is a de-facto standard controller for display. We have already use it providing a source code to use a 16×2 LCD with a STM32. In this article we will step over introducing an I2C backpack for that display. Of course we will explain how to edit old code in order to get it work with this new hardware configuration.

Even if it is still popular, this controller was made commercially available in the late eighties. At that time serial communications were not so widespread because their were costly and involved constraint about clock speed. Because of that, the HD44780 comes with a parallel bus and requires up to 16 lines to work (8 for data, 3 for handshake, 2 for supply, 1 for contrast and 2 for backlight). With fastest MCU it is possible to use the 4-bit mode and with a couple of welds and a potentiometer we can reduce to 9 the required lines.

Nowadays, serial communication are largely used and SIPO (Serial-in Parallel-Out) converters are very cheap. Using a SIPO, it is possible to drive a HD44780 with less wires accepting a small extra charge due to additional hardware. Today it is possible to buy our display with the SIPO already mounted with a small extra amount (about 3.90 USD instead of 2.80 USD considering a 16×2 LCD).

The most used solution is based on PCF8574A, an I2C I/O expander from NXP. This solution is available as a backpack ready to be  soldered on our display. The backpack reduce the number of wires to 4: 2 for supply and 2 for the I2C communication.

Backpack schematic

I2C backpack PCF8574AT
I2C backpack PCF8574AT

The backpack used in this tutorial is almost standard. It is a black PCB designed to be soldered on the backside of the LCD, indeed,as the display, it has a 1×16 PIN header connector.

Aside the 1×16 header connector, the backpack has another 1×4 header which PINs are:

  1. GND, which should be connected to ground;
  2. VDD, which should be connected to 5V;
  3. SDA, which should be connected to I2C serial data;
  4. SCL, which should be connected to I2C serial clock.

In order to understand how we have edited the old code, it is very important to take a look to the backpack schematic. We will note that:

  • The chip on the backpack is the root of the problem. We already said it is an I/O expander, hence it should be clear that it is somehow capable to read/write from its I/O PINs which are P0~P7.
  • The chip mounted on the backpack could be both PCF8574T or PCF8574AT.
  • The chip is connected to 3 control PINs (RS, RW, CS) and to 4 data PINs. This suggests it uses the display in 4-bit mode.
  • P3 manages the K pin and A is connected to VCC through the jumper. Hence, P3 works like a backlight enabler (backlight is enabled if P3 is HIGH).
  • The chip has also three additional PINs (A0, A1 and A2) connected to VCC through a 10k resistance. They could be also shortcircuited to GND (They are not by default).
HD44780 with I2C backpack schematic
The schematic of a HD44780 based LCD with a PCF8574AT I2C backpack schematic.

These notes lead us to ask self-questions:

  1. How it is possible to read write from the I/O expander?
  2. What are the differences between PCF8574T and PCF8574T?
  3. What is the purpose of the A0~A2 PINs?

PCF8574 documentation

To answer these questions and solve our problem we need to read the documentation. Fortunately, the PCF8574T and the PCF8574AT are described by the same document:

PCF8574 Datasheet

Reading that document, we can answer to our questions. Question 2 and question 3 are strictly related. With the I2C bus each slave device could be identified through a slave address (in this way it is possible to connect more slave devices on the same bus using only two wires).

The slave address is partially hardcoded and depends on chip version, the non-hardcoded part depends on A0~A2 PINs connection (See Fig.3). As example, since my backpack equips a PCF8574AT having A2, A1 and A0 connected to VCC my slave address is 0b0011 1111 or 0x3F.

PCF8574 slave address
PCF8574 slave address

Question 1 is even simpler: to write/read from the PCF8574 we need to write/read an 8 bit word from the I2C bus. From the datasheet, we can also understand (in the timing section) that we need to use a 100kHz clock and standard duty cycle.

As a separate note, the I2C Specification establishes specific clock speeds (100kHz, 400KHz and 1MHz) and specific duty cycles (Standard, Fast Mode and Fast Mode Plus). These specifications are easily achievable through the I2C configuration which in ChibiOS for an STM32 Nucleo-64 F401RE is:

static const I2CConfig i2ccfg = {
  OPMODE_I2C,
  100000,
  STD_DUTY_CYCLE,
};

So let’s now focus on code and edits required to make old example work with this new hardware.

Editing old code

Updating my old project I have made all the edits at once. I will try to explain what I have done step by step, in this way a reader who wants to use this article like a kind of exercise will be facilitated.

We have seen that somehow backpack doesn’t allow the backlight dimming and the 8-bit mode. So, before to start edit code, I have simplified the old library removing all that parts which could not be used with this new hardware configuration. I have removed all the code related to backlight dimming  (including the API) and all the code related to the 8-bit mode.

This step is simple to perform and easy to test since it doesn’t impact code functionality and could be hence tested using a classical HD44780 LCD connected in 4-bit mode.

LCDDriver

Starting from driver, old structure was:

/**
 * @brief   Structure representing an LCD driver.
 */
typedef struct {
  /**
   * @brief Driver state.
   */
  lcd_state_t         state;
  /**
   * @brief  Current Back-light percentage (from 0 to 100)
   *
   * @detail When LCD_USE_DIMMABLE_BACKLIGHT is false, this is considered like boolean
   */
  uint32_t           backlight;
  /**
   * @brief Current configuration data.
   */
  const LCDConfig    *config;
} LCDDriver;

it will remain almost the same except for backlight type which now will become a boolean

/**
 * @brief   Structure representing an LCD driver.
 */
typedef struct {
  /**
   * @brief Driver state.
   */
  lcd_state_t        state;
  /**
   * @brief  Current Back-light status.
   */
  bool               backlight;
  /**
   * @brief Current configuration data.
   */
  const LCDConfig    *config;
} LCDDriver;

LCDConfig

Edits to the configuration structure require foresight. The old version of this structure (after removal of backlight dimming related fields )is:

/**
 * @brief   LCD configuration structure.
 */
typedef struct {
  /**
   * @brief  LCD cursor control
   */
  lcd_cursor_t cursor;
  /**
   * @brief  LCD blinking control
   */
  lcd_blinking_t blinking;
  /**
   * @brief LCD font setting
   */
  lcd_set_font_t font;
  /**
   * @brief LCD lines settings
   */
  lcd_set_lines_t lines;
  /**
   * @brief  LCD PIN-map
   */
  lcd_pins_t const *pinmap;
  /**
   * @brief  Initial Back-light status.
   */
  bool backlight;
} LCDConfig;

Note that backlight was a uint32_t and now has been already updated to bool. While configurations (cursor, blinking, font, lines) should be left untouched, pinmap structure has no more sense.

Now our code don’t need to act on PAL. We need hence to replace pinmap structure with an I2C driver, its configuration and a slave address which strictly depends on hardware:

/**
 * @brief   LCD configuration structure.
 */
typedef struct {
  /**
   * @brief  Pointer to the I2C driver used by this driver
   */
  I2CDriver *i2cp;
  /**
   * @brief  Pointer to the I2C configuration used by this driver
   */
  I2CConfig const *i2ccfg;
  /**
   * @brief  I2C slave address
   */
  uint8_t slaveaddress;
  /**
   * @brief  LCD cursor control
   */
  lcd_cursor_t cursor;
  /**
   * @brief  LCD blinking control
   */
  lcd_blinking_t blinking;
  /**
   * @brief  LCD font setting
   */
  lcd_set_font_t font;
  /**
   * @brief  LCD lines settings
   */
  lcd_set_lines_t lines;
  /**
   * @brief  Initial Back-light status.
   */
  bool backlight;
} LCDConfig;

Edits to internally used functions

The old library was based on three functions which were not exported but internally used and their was:

  • hd44780IsBusy(), which reads the busy flag from the D7 PIN;
  • hd44780WriteRegister(), which writes a value in command/data register;
  • hd44780InitByInstructions(), which is executed once on driver start-up. It initializes the device.
hd44780IsBusy()

This function has been removed in this new library to avoid complication. It has been replaced with a small sleep enough large to ensure the completion of internal operations.

hd44780WriteRegister()

This function has been completely rewritten replacing the logical write on PINs with I2C transmits. The old function was:

/**
 * @brief   Write a data into a register for the lcd
 *
 * @param[in] lcdp          LCD driver
 * @param[in] reg           Register id
 * @param[in] value         Writing value
 *
 * @notapi
 */
static void hd44780WriteRegister(LCDDriver *lcdp, uint8_t reg, uint8_t value){

  unsigned ii;

  while (hd44780IsBusy(lcdp))
    ;

  /* Configuring Data PINs as Output Push Pull. */
  for(ii = 0; ii < LINE_DATA_LEN; ii++)
    palSetLineMode(lcdp->config->pinmap->D[ii], PAL_MODE_OUTPUT_PUSHPULL |
                   PAL_STM32_OSPEED_HIGHEST);

  palClearLine(lcdp->config->pinmap->RW);
  palWriteLine(lcdp->config->pinmap->RS, reg);

#if LCD_USE_4_BIT_MODE
  for(ii = 0; ii < LINE_DATA_LEN; ii++) {
    if(value & (1 << (ii + 4)))
      palSetLine(lcdp->config->pinmap->D[ii]);
    else
      palClearLine(lcdp->config->pinmap->D[ii]);
  }
  palSetLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
  palClearLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);

  for(ii = 0; ii < LINE_DATA_LEN; ii++) {
    if(value & (1 << ii))
      palSetLine(lcdp->config->pinmap->D[ii]);
    else
      palClearLine(lcdp->config->pinmap->D[ii]);
  }
  palSetLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
  palClearLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
#else
  for(ii = 0; ii < LINE_DATA_LEN; ii++){
      if(value & (1 << ii))
        palSetLine(lcdp->config->pinmap->D[ii]);
      else
        palClearLine(lcdp->config->pinmap->D[ii]);
  }
  palSetLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
  palClearLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
#endif
}

The new one is:

/**
 * @brief   Write a data into a register for the lcd
 *
 * @param[in] lcdp          LCD driver
 * @param[in] reg           Register id
 * @param[in] value         Writing value
 *
 * @notapi
 */
static void hd44780WriteRegister(LCDDriver *lcdp, uint8_t reg, uint8_t value) {
  uint8_t txbuf[4];
  osalThreadSleepMilliseconds(2);

  txbuf[0] = reg | LCD_D_HIGHER(value) | LCD_E;
  if(lcdp->backlight)
    txbuf[0] |= LCD_K;
  txbuf[1] = reg | LCD_D_HIGHER(value);
  if(lcdp->backlight)
    txbuf[1] |= LCD_K;
  txbuf[2] = reg | LCD_D_LOWER(value) | LCD_E;
  if(lcdp->backlight)
    txbuf[2] |= LCD_K;
  txbuf[3] = reg | LCD_D_LOWER(value);
  if(lcdp->backlight)
    txbuf[3] |= LCD_K;

  i2cMasterTransmitTimeout(lcdp->config->i2cp, lcdp->config->slaveaddress,
                           txbuf, 4, NULL, 0, TIME_INFINITE);
}

We should notice that the busy flag check has been replaced with a 2 milliseconds sleep. To send a whole 8-bit word we need for 4 transactions. This because the I/O expander can operate only a 4-bit mode communication and we need to send out each half-word twice changing the status of the E pin from high to low.

We have created a couple of macro in order to get the higher and lower part of the word

#define LCD_D_HIGHER(n)                 (n & 0xF0)
#define LCD_D_LOWER(n)                  ((n & 0x0F) << 4)
hd44780InitByInstructions()

Even the initialization by instructions requires an edit since this function directly acts on LCD PIN. The oldest version was:

/**
 * @brief   Perform a initialization by instruction as explained in HD44780
 *          datasheet.
 * @note    This reset is required after a mis-configuration or if there aren't
 *          condition to enable internal reset circuit.
 *
 * @param[in] lcdp          LCD driver
 *
 * @notapi
 */
static void hd44780InitByIstructions(LCDDriver *lcdp) {
  unsigned ii;

  osalThreadSleepMilliseconds(50);
  for(ii = 0; ii < LINE_DATA_LEN; ii++) {
    palSetLineMode(lcdp->config->pinmap->D[ii], PAL_MODE_OUTPUT_PUSHPULL |
                   PAL_STM32_OSPEED_HIGHEST);
    palClearLine(lcdp->config->pinmap->D[ii]);
  }

  palClearLine(lcdp->config->pinmap->E);
  palClearLine(lcdp->config->pinmap->RW);
  palClearLine(lcdp->config->pinmap->RS);
  palSetLine(lcdp->config->pinmap->D[LINE_DATA_LEN - 3]);
  palSetLine(lcdp->config->pinmap->D[LINE_DATA_LEN - 4]);

  palSetLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
  palClearLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(5);

  palSetLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
  palClearLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);

  palSetLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
  palClearLine(lcdp->config->pinmap->E);

#if LCD_USE_4_BIT_MODE
  palSetLine(lcdp->config->pinmap->D[LINE_DATA_LEN - 3]);
  palClearLine(lcdp->config->pinmap->D[LINE_DATA_LEN - 4]);
  palSetLine(lcdp->config->pinmap->E);
  osalThreadSleepMilliseconds(1);
  palClearLine(lcdp->config->pinmap->E);
#endif

  /* Configuring data interface */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_FS | LCD_DATA_LENGHT |
                       lcdp->config->font | lcdp->config->lines);

  /* Turning off display and clearing */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_DC);
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_CLEAR_DISPLAY);

  /* Setting display control turning on display */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_DC | LCD_DC_D |
                       lcdp->config->cursor | lcdp->config->blinking);

  /* Setting Entry Mode */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_EMS | LCD_EMS_ID);
}

the new one is more compact even because it hasn’t to discriminate between 8-bit and 4-bit mode:

/**
 * @brief   Perform a initialization by instruction as explained in HD44780
 *          datasheet.
 * @note    This reset is required after a mis-configuration or if there aren't
 *          condition to enable internal reset circuit.
 *
 * @param[in] lcdp          LCD driver
 *
 * @notapi
 */
static void hd44780InitByIstructions(LCDDriver *lcdp) {
  uint8_t txbuf[2];
  osalThreadSleepMilliseconds(50);

  txbuf[0] = LCD_D(4) | LCD_D(5) | LCD_E;
  txbuf[1] = LCD_D(4) | LCD_D(5);
  i2cMasterTransmitTimeout(lcdp->config->i2cp, lcdp->config->slaveaddress,
                           txbuf, 2, NULL, 0, TIME_INFINITE);
  osalThreadSleepMilliseconds(5);

  txbuf[0] = LCD_D(4) | LCD_D(5) | LCD_E;
  txbuf[1] = LCD_D(4) | LCD_D(5);
  i2cMasterTransmitTimeout(lcdp->config->i2cp, lcdp->config->slaveaddress,
                           txbuf, 2, NULL, 0, TIME_INFINITE);
  osalThreadSleepMilliseconds(1);

  txbuf[0] = LCD_D(4) | LCD_D(5) | LCD_E;
  txbuf[1] = LCD_D(4) | LCD_D(5);
  i2cMasterTransmitTimeout(lcdp->config->i2cp, lcdp->config->slaveaddress,
                           txbuf, 2, NULL, 0, TIME_INFINITE);

  txbuf[0] = LCD_D(5) | LCD_E;
  txbuf[1] = LCD_D(5);
  i2cMasterTransmitTimeout(lcdp->config->i2cp, lcdp->config->slaveaddress,
                           txbuf, 2, NULL, 0, TIME_INFINITE);

  /* Configuring data interface */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_FS | LCD_DATA_LENGHT |
                       lcdp->config->font | lcdp->config->lines);

  /* Turning off display and clearing */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_DC);
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_CLEAR_DISPLAY);

  /* Setting display control turning on display */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_DC | LCD_DC_D |
                       lcdp->config->cursor | lcdp->config->blinking);

  /* Setting Entry Mode */
  hd44780WriteRegister(lcdp, LCD_INSTRUCTION_R, LCD_EMS | LCD_EMS_ID);
}

A simple demo

The main.c has been changed a little bit. Indeed we need to update the LCD configuration structure according to our edits and we can remove all the PWM related stuff.

Connections

The PINs are connected as follow:

  1. VCC, to 5V;
  2. GND, to Ground;
  3. SDA, to PB9;
  4. SCL, to PB8.

Demo explained

This demo is completely equivalent to the old one. It writes a string for each line performing a smooth shift effect.

/*
    PLAY Embedded demos - Copyright (C) 2014-2017 Rocco Marco Guglielmi

    This file is part of PLAY Embedded demos.

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.
*/

/*
 *  Tested under ChibiOS 16.1.7, Project version 1.1.
 *  Please open readme.txt for changelog.
 */

#include "ch.h"
#include "hal.h"

#include "lcd.h"

static uint8_t ii;

#define LINE_A                      PAL_LINE(GPIOA, 8U)
#define LINE_ARD_D14                PAL_LINE(GPIOB, 9U)
#define LINE_ARD_D15                PAL_LINE(GPIOB, 8U)

/*===========================================================================*/
/* LCD configuration                                                         */
/*===========================================================================*/

static const I2CConfig i2ccfg = {
  OPMODE_I2C,
  100000,
  STD_DUTY_CYCLE,
};

static const LCDConfig lcdcfg = {
  &I2CD1,                   /* I2C driver */
  &i2ccfg,                  /* I2C configuration */
  0x3F,                     /* Slave address */
  LCD_CURSOR_OFF,           /* Cursor disabled */
  LCD_BLINKING_OFF,         /* Blinking disabled */
  LCD_SET_FONT_5X10,        /* Font 5x10 */
  LCD_SET_2LINES,           /* 2 lines */
  LCD_BL_ON                 /* backlight initial status */
};

/*
 * Green LED blinker thread, times are in milliseconds.
 */
static THD_WORKING_AREA(waThread1, 128);
static THD_FUNCTION(Thread1, arg) {

  (void)arg;
  chRegSetThreadName("LED blinker");
  while (true) {
    palClearPad(GPIOA, GPIOA_LED_GREEN);
    chThdSleepMilliseconds(500);
    palSetPad(GPIOA, GPIOA_LED_GREEN);
    chThdSleepMilliseconds(500);
  }
}

/*
 * Button checker. This thread turn on and off LCD backlight when USER button
 * is pressed. Fade transition is applied when library use PWM.
 */
static THD_WORKING_AREA(waThread2, 128);
static THD_FUNCTION(Thread2, arg) {

  (void)arg;
  chRegSetThreadName("Back_light handler");
  while (true) {
    if(palReadPad(GPIOC, GPIOC_BUTTON)) {
      chThdSleepMilliseconds(50);
      if(!palReadPad(GPIOC, GPIOC_BUTTON))
        LCDD1.backlight ? lcdBacklightOff(&LCDD1) : lcdBacklightOn(&LCDD1);
    }
    chThdSleepMilliseconds(10);
  }
}

/*
 * Application entry point.
 */
int main(void) {

  /*
   * System initializations.
   * - HAL initialization, this also initializes the configured device drivers
   *   and performs the board-specific initializations.
   * - Kernel initialization, the main() function becomes a thread and the
   *   RTOS is active.
   */
  halInit();
  chSysInit();

  lcdInit();

  /* Creating blinker and backlight handler threads. */
  chThdCreateStatic(waThread1, sizeof(waThread1), NORMALPRIO, Thread1, NULL);
  chThdCreateStatic(waThread2, sizeof(waThread2), NORMALPRIO, Thread2, NULL);

  /* Configuring I2C related PINs */
  palSetLineMode(LINE_ARD_D15, PAL_MODE_ALTERNATE(4) |
                 PAL_STM32_OTYPE_OPENDRAIN | PAL_STM32_OSPEED_HIGHEST |
                 PAL_STM32_PUPDR_PULLUP);
  palSetLineMode(LINE_ARD_D14, PAL_MODE_ALTERNATE(4) |
                 PAL_STM32_OTYPE_OPENDRAIN | PAL_STM32_OSPEED_HIGHEST |
                 PAL_STM32_PUPDR_PULLUP);

  lcdStart(&LCDD1, &lcdcfg);
  lcdWriteString(&LCDD1, "PLAY            Learn", 0);
  lcdWriteString(&LCDD1, "Embedded        by doing",40);
  chThdSleepMilliseconds(2000);
  while (true) {
    for(ii = 0; ii < 16; ii++){
      lcdDoDisplayShift(&LCDD1, LCD_LEFT);
      chThdSleepMilliseconds(50);
    }
    chThdSleepMilliseconds(2000);
    for(ii = 0; ii < 16; ii++){
      lcdDoDisplayShift(&LCDD1, LCD_RIGHT);
      chThdSleepMilliseconds(50);
    }
    chThdSleepMilliseconds(2000);
  }
}

Project download

The attached demo has been tested under ChibiOS 18.2.x. Note that you can find more recent version of this project int the Download Page. Note also that starting from the version 20 all the demos from PLAY Embedded will be distributed together with ChibiStudio.

RT-STM32F401RE-NUCLEO64-LCD_II-HD44780-PCF8574-182

Replies to How to drive a HD44780 with I2C backpack with a STM32

    • Ciao Gaetan,
      in an I2C communication, the slave address is a 7-bit value. Now, this value is left aligned which means this value should be left shifted by 1 and the less significant bit is used to indicate the read/write bit (0 write, 1 read).

      In my case, the address is 0b011 1111 (i.e. 0x3F). Left-shifted, this becomes 0b011 111x. In a write operation, it is 0b011 1110 while in a read 0b011 1111.

      The operation of left shifting and bit masking is performed internally by the API of ChibiOS thus we should remain stuck with the standard definition of the I2C slave address which, again, is a 7-bit value and in my case 0x3F.

      I hope this clarifies things.

Leave a Reply