Introduction
In this blog, I demonstrate how to use Raspberry Pi Pico’s Programmable IO (PIO) interface to drive an LCD module using either the 8-bit and 4-bit parallel data bus.
In this demo project I started with an FDCC0802B 8x2 LCD module (shown above) as I had one and I had modified it before to fit on my breadboard. Of course, a 16x2 LCD module would work equally as well here too.
I then progressed onto a 20x4 OLED Display (NHD-0420DZW-AY5) from Newhaven Displays, which also can be driven using the same driver firmware although I found it was not perfect and probably would need a few tweaks.
The reason behind this project was to help me learn more about the PIO interface than anything else. I am also sticking with C/C++ and I used the Arduino IDE and associated libraries to develop the project.
My LCD module controllers
So to start, here are the key specifications for the two LCD modules I used in this project.
Fordata FDCC0802B (8x2 LCD)
source: Fordata on-line LCD Module catalogue ( V6.0 )
According to the Fordata Electronic Co. COB(Chip On Board) SERIES on-line catalogue ( V6.0 ), the LCD module is controlled internally by the Sunplus SPLC780D LCD Controller (or equivalent). This controller is also compatible with the Hitatchi HD44780 driver, which can be found on many text-based LCDs.
Thus this LCD module is pin compatible and has a similar pin configuration, which can be broken down as follows:
- Power
- VSS: GND (pin 1)
- Vdd: +5V (pin 2)
- Control/Signal lines
- V0: LCD Contrast Adjustment via a variable resistor (pin 3)
- RS: Register Select informs controller whether the value to be received is a command (when set LOW) or is a display character (when set HIGH)
- R/W: Want to read data (when set HIGH) or want to write data (when set LOW)
- E: a pulse Enable Signal that causes the internal controller to read the value sent via the data bus.
- Data Bus
- DB0 - DB7 for 8 bit mode or DB4 - DB7 for 4 bit mode
- LED Backlight
- A: LED Anode (usually 5V with current limiting resistor added)
- K: LED Cathode (GND)
Newhaven Display NHD-0420DZW-AY5 (20x4 Character OLED Display Module)
source: NHD_0420DZW_AY5-2953149.pdf
The NHD 20x4 character OLED display module is pin compatible with a standard 16x2 or 8x2 LCD module and similarly the same driver commands will also work. It is worth noting that there are some timing differences in terms of the internal processor "busy periods", which may or may not impact display performance. I did have to slow timings down a bit.
Pico Programmable Input Output (PIO)
I decided to start with the Arduino’s LiquidCrystal library as this library was developed for the Hitatchi HD44780 driver and is available for all Arduino compatible boards and is compatible with the above LCD modules I was planning to use.
After a review of the Arduino LiquidCrystal library and the datasheets I knew that in order to get the Pico’s PIO to drive all or parts of the LCD module, I had to mimic the LCD controller instruction sequence and bus timings to successfully write to the LCD module to display data.
source: Waveshare's LCD1602.pdf
Having reviewed a couple of different datasheets, it appears that they all have the following sequence characteristics with some minor minimal nanosecond timing differences, namely:
- When the Register Select (RS) signal is set then there needs to be a minimum delay (tsu1) before the Enable signal can be changed from LOW to HIGH. In the above diagram the minimum delay time is 100ns.
- The Enable pulse high signal has a minimal time it needs to stay high, which in the above diagram is shown as tw. The high signal needs to be at least 300ns long.
- The Enable signal can only be pulsed again (shown in diagram as the cycle time (tc) after at least 500ns. Some driver datasheets have a minimum cycle time of 1000ns (1us).
- The data sent to the 8 pin data bus (DB0-DB7) has to remain set for at least 80ns (a derive value, made up of tsu2 + tf + th2).
Based on the above diagram, I figured that there was probably no point getting the PIO to drive the RS signal as this only changes once to indicate if the data about to be sent is a command or is data to be displayed. But this needed further evaluation.
The HD44780 driver datasheet also provided a timing diagram for 4-bit mode operation, which informed me that the 1st (IR4-IR7) and 2nd (IR0-IR3) nibbles of data could be pulsed quickly before the busy read flag (BF) was set and then cleared.
The part that was unclear in the above diagram was the duration of internal operation, when the busy flag was set. Thankfully all the datasheets I reviewed provided timing guidance within an instruction table. Thus execution times depended on what commands were sent. For example, a Clear Display command would typically take up to 2ms to execute, while a Display ON/OFF Control command would typically take about 40us to complete.
So after a bit of thought, I decided to use PIO for the data bus as I could shift data in parallel using the PIO OUT command and I would use the PIO SET for the Enable signal as this was timing critical during operation.
Thus in my project, the R/W signal will be tied to ground as I am not checking the busy flag and I would then set the RS signal outside of the PIO using a GPIO to tell the LCD whether I am sending a command or writing data.
To get the PIO code started I began with the 8 bit mode as this transferred all data at once.
Here is the 8-bit databus PIO code:
.program data8Pins ; Shifts 8 bits of data to the LCD data pins d0, d1, d2, d3, d4, d5, d6, d7 ; Use set pin (for ENABLE) to pulse. set pindirs, 1 ; set ENABLE pin (set pins) as output set pins, 0 ; initialise ENABLE pin to 0 loop: pull block ; wait for data ; -------------------------------------------------------------------- ; shift data to the lcd data pins (8 bit mode) out pins, 8 ; shift last 8 bits from OSR to LCD data pins ; -------------------------------------------------------------------- ; wait a short period (tsu1) before setting enable pin high to send the data to LCD set x 24 ; set x to 11000 (and clear the higher bits) mov ISR x ; copy x to ISR in NULL 3 ; shift in 3 more 0 bits mov x ISR ; move the ISR to x (now contains 11000 000) delay1: jmp x-- delay1 ; count down to 0: a delay of (about) 1us set pins, 1 ; set ENABLE pin HIGH set x 24 ; set x to 11000 (and clear the higher bits) mov ISR x ; copy x to ISR in NULL 3 ; shift in 3 more 0 bits mov x ISR ; move the ISR to x (now contains 11000 000) delay2: jmp x-- delay2 ; count down to 0: a delay of (about) 1us ; set ENABLE to LOW and wait a short period (> t cycle time) set pins, 0 ; set ENABLE pin LOW set x 20 ; set x to 11010 (and clear the higher bits) mov ISR x ; copy x to ISR in NULL 4 ; shift in 4 more 0 bits mov x ISR ; move the ISR to x (now contains 11010 0000) delay3: jmp x-- delay3 ; count down to 0: a delay of (about) 1us set x 1 mov ISR x push block ; push a 1 to say complete jmp loop
The above PIO instruction sequence can be broken down into 4 steps:
- It waits for data (PULL).
- It shifts data to the 8 output pins (OUT Pins, 8).
- It latches the data by setting the Enable pin high (SET pins, 1) and then low (SET pins, 0) with a specific timing sequence.
- It pushes the Input Shift Register (PUSH), which has a dummy value, so say sequence is complete - this is to ensure that the next byte of data does not arrive too early.
With 4 bit mode I could make some improvement to the Arduino code as I could send 8 bits of data at once and then get the PIO to repeat the 4 bit pattern twice by shifting the MSB nibble (4 bits) of data first and then the LSB nibble of data. As the HD44780 driver datasheet 4-bit timing diagram showed, the delay between the two nibbles is shorter. So in this case I used the Y Scratch Register to create a repeat sequence in the PIO code.
.program data4Pins ; Shifts 4 bits of data to the LCD data pins d4, d5, d6, d7 ; Use set pin (for ENABLE) to pulse. set pindirs, 1 ; set ENABLE pin (set pins) as output set pins, 0 ; initialise ENABLE pin to 0 loop: pull block ; wait for data set y 1 ; used to repeat the cycle for 4 bit mode repeat4bit: ; -------------------------------------------------------------------- ; shift data to the lcd data pins (4 bit mode) out pins, 4 ; shift last 4 bits from OSR to LCD data pins ; -------------------------------------------------------------------- ; wait a short period (tsu1) before setting enable pin high to send the data to LCD set x 24 ; set x to 11000 (and clear the higher bits) mov ISR x ; copy x to ISR in NULL 3 ; shift in 3 more 0 bits mov x ISR ; move the ISR to x (now contains 11000 000) delay1: jmp x-- delay1 ; count down to 0: a delay of (about) 1us set pins, 1 ; set ENABLE pin HIGH set x 24 ; set x to 11000 (and clear the higher bits) mov ISR x ; copy x to ISR in NULL 3 ; shift in 3 more 0 bits mov x ISR ; move the ISR to x (now contains 11000 000) delay2: jmp x-- delay2 ; count down to 0: a delay of (about) 1us ; set ENABLE to LOW and wait a short period (> t cycle time) set pins, 0 ; set ENABLE pin LOW set x 20 ; set x to 10100 (and clear the higher bits) mov ISR x ; copy x to ISR in NULL 4 ; shift in 4 more 0 bits mov x ISR ; move the ISR to x (now contains 10100 0000) delay3: jmp x-- delay3 ; count down to 0: a delay of (about) 1us jmp y-- repeat4bit ; repeat 4bit data shift one more time set x 1 mov ISR x push block ; push a 1 to say complete jmp loop
I then created a custom PIO initialisation routine that would handle both 4-bit or 8-bit shift mode.
static inline void LCDPIO_program_init(PIO pio, uint sm, uint offset, uint Basepin, uint Setpin, uint bitmode) { // Ensure that the state machine is not running before config pio_sm_set_enabled(pio, sm, false); pio_sm_clear_fifos(pio, sm); pio_sm_restart(pio, sm); pio_sm_config c = data4Pins_program_get_default_config(offset); if (bitmode == 8) c = data8Pins_program_get_default_config(offset); // Initialise all the pin's GPIO function (connect PIO to the pad) for (uint i = 0; i < bitmode; i++) pio_gpio_init(pio, Basepin+i); pio_gpio_init(pio, Setpin); // Map the DATA PINS state machine's OUT pin group to one pin, namely the `Basepin_DP` sm_config_set_out_pins(&c, Basepin, bitmode); // Define the output shift direction for the DP pins - set at 8 (max shift for both 4-bit and 8-bit transfer) sm_config_set_out_shift(&c, true, true, 8); // Map the ENABLE state machine's SET pin group to one pin, namely the `Setpin_ENABLE` sm_config_set_set_pins(&c, Setpin, 1); // Set all the DP pin directions to output at the PIO pio_sm_set_consecutive_pindirs(pio, sm, Basepin, bitmode, true); // Load our configurations, and jump to the start of the program pio_sm_init(pio, sm, offset, &c); // Now enable the 2 state machines to run pio_sm_set_enabled(pio, sm, true); }
With the PIO code out the way, I needed to work out how to incorporate the PIO into the Arduino LiquidCrystal library, as there was no point reinventing the wheel and starting from scratch. It turned out to be quite straightforward.
Modified Arduino LiquidCrystal library
The first step was to amend the parameters found within the LiquidCrystal class constructor as the DB0 to DB7 pins and the Enable Pin would be defined within PIO. This was achieved by creating a separate PIO class and a simple constructor, as follows:
/************************************************************************* @brief Constructor @param AllocatedPio: reference to chosen PIO *************************************************************************/ LCDPicoPIO::LCDPicoPIO(PIO AllocatedPio, uint8_t bitmode): pio(AllocatedPio) { offset_DP = 0; if (pio == pio0 || pio == pio1) { // Check to see if pio can be added pio_clear_instruction_memory (pio); if (bitmode == LCD_4BITMODE) { mode = true; if (pio_can_add_program(pio, &data4Pins_program)) { offset_DP = pio_add_program(pio, &data4Pins_program); } else offset_DP = -1; // failed could not get a valid memory offset value } else if (bitmode == LCD_8BITMODE) { mode = false; if (pio_can_add_program(pio, &data8Pins_program)) { offset_DP = pio_add_program(pio, &data8Pins_program); } else offset_DP = -1; // failed could not get a valid memory offset value } } } // @brief Destructor LCDPicoPIO::~LCDPicoPIO(){}
LiquidCrystal_PicoPIO::LiquidCrystal_PicoPIO(LCDPicoPIO &PicoPio, const uint8_t rs, const uint8_t en, const uint8_t basepin): _PicoPio(PicoPio), _rs_pin(rs), _enablePin(en), _basePin(basepin) { _error = 1; if (_PicoPio.offset_DP > 0) { _smDP = pio_claim_unused_sm(_PicoPio.pio, false); if (_smDP >= 0) { if (_PicoPio.mode) NumPins = 4; else NumPins = 8; LCDPIO_program_init(_PicoPio.pio, _smDP, _PicoPio.offset_DP, _basePin, _enablePin, NumPins); // PIO Program initialised - now set up some defaults // Assume 4 bit mode only (for now) _error = 0; LCD_init(_PicoPio.mode); } else _error = -1; // failed to get sm } } // @brief Destructor LiquidCrystal_PicoPIO::~LiquidCrystal_PicoPIO(){}
The second step was to amend two private functions write4bits(uint8_t) and write8bits(uint8_t) as this is where my code would push data to the PIO to drive the LCD. In fact it turned out that it was simpler just to call the PIO routine from the send function using a new single writeDB function.
/************ low level data pushing commands **********/ // write either command or data, with automatic 4/8-bit selection void LiquidCrystal_PicoPIO::send(uint8_t value, uint8_t mode) { digitalWrite(_rs_pin, mode); // if there is a RW pin indicated, set it low to Write if (_rw_pin != 255) { digitalWrite(_rw_pin, LOW); } if (_displayfunction & LCD_8BITMODE) { writeDB(value); } else { writeDB((value<<4 & 0xff) | (value>>4 & 0xff)); } } void LiquidCrystal_PicoPIO::writeDB(uint8_t value) { /********* PIO Function call **************/ /******************************************/ pio_sm_put_blocking(_PicoPio.pio, _smDP, value); pio_sm_get_blocking(_PicoPio.pio, _smDP); }
And finally the third and last step was to delete the pulseEnable() function as this was all handled inside the PIO routine.
LCD module demos
Fordata FDCC0802B (8x2 LCD)
This module works in either 8 bit mode or 4 bit mode. Here is the 8-bit mode in operation.
/* LiquidCrystal Library - Custom Characters Demonstrates how to add custom characters on an LCD display. The LiquidCrystal library works with all LCD displays that are compatible with the Hitachi HD44780 driver. There are many of them out there, and you can usually tell them by the 16-pin interface. This sketch prints "I <heart> Arduino!" and a little dancing man to the LCD. created 21 Mar 2011 by Tom Igoe modified 11 Nov 2013 by Scott Fitzgerald modified 7 Nov 2016 by Arturo Guadalupi Based on Adafruit's example at https://github.com/adafruit/SPI_VFD/blob/master/examples/createChar/createChar.pde This example code is in the public domain. http://www.arduino.cc/en/Tutorial/LiquidCrystalCustomCharacter Also useful: http://icontexto.com/charactercreator/ */ // include the library code: #include <LiquidCrystal_PicoPIO.h> // define the pin numbers: static const int RS_PIN = 20; static const int EN_PIN = 21; static const int BASE_PIN = 12; // This class will provide the PIO memory offset for the capsensing PIO (only need once) // Bitmode parameter is optional (default is LCD_4BITMODE). Bitmode determines which PIO routine to use LCDPicoPIO PicoPIO(pio0, LCD_8BITMODE); LiquidCrystal_PicoPIO lcd(PicoPIO, RS_PIN, EN_PIN, BASE_PIN); // make some custom characters: static byte heart[8] = { 0b00000, 0b01010, 0b11111, 0b11111, 0b11111, 0b01110, 0b00100, 0b00000 }; static byte armsDown[8] = { 0b00100, 0b01010, 0b00100, 0b00100, 0b01110, 0b10101, 0b00100, 0b01010 }; static byte armsUp[8] = { 0b00100, 0b01010, 0b00100, 0b10101, 0b01110, 0b00100, 0b00100, 0b01010 }; static int delayTime = 0; static uint8_t i = 0; bool ManArmsDown = true; char lcdbuff1[8]; char lcdbuff2[8]; void setup() { // initialize LCD and set up the number of columns and rows: lcd.begin(8, 2); // create a new character lcd.createChar(0, heart); // create a new character lcd.createChar(1, armsDown); // create a new character lcd.createChar(2, armsUp); // set the cursor to the top left lcd.setCursor(0, 0); // Print a message to the lcd. lcd.print("I "); lcd.write(byte(0)); // when calling lcd.write() '0' must be cast as a byte lcd.print(" e14! "); delay(1000); randomSeed(analogRead(A0)); } void loop() { delayTime = random(200, 2000); // set the cursor to the bottom row, 5th position: lcd.setCursor(0, 1); memset(lcdbuff1, '\0', 8); memset(lcdbuff2, '\0', 8); if (i) { sprintf(lcdbuff1, "%*s", i, ""); lcd.print(lcdbuff1); } // draw the little man, arms down: if (ManArmsDown) lcd.write(1); else lcd.write(2); ManArmsDown = !ManArmsDown; if (i < 7) { sprintf(lcdbuff2, "%*s", 6-i, ""); lcd.print(lcdbuff2); } delay(delayTime); if (i < 8) i++; else i = 0; }
Newhaven Display NHD-0420DZW-AY5 (20x4 LCD)
The same cannot be said with this module. Whilst the NHD OLED module is pin compatible and the same LiquidCrystal library works (successfully tested using Arduino UNO), I simply could not get anything displayed on the screen.
Talk about frustration. I kept thinking there was something wrong with my code, but it turned out to be a power problem and once I had reduced the voltage a fraction, it worked.
As I don’t have a variable desktop power supply, I decided to improvise by using different dev boards as my power source. Here are the results of my power source experiments:
- Raspberry Pi Pico USB Vbus pin: V5.13 (Does not work).
- Raspberry Pi Pico USB Vbus pin through diode: V4.3 (works although row text issue more common - see below).
- Arduino Duemilanove Vin pin: V4.56 (works).
- Arduino Duemilanove 5V pin: V5.08 (Does not work).
- FRDM-KL25Z Vin pin: V4.70 (works).
- FRDM-KL25Z 5V pin: V5.06 (works)
So I determined that the voltage drop across Vss and Vdd could range from 4.3V up to 5.06V. I also discovered that I could operate this module at below the recommended voltage of 4.8V although I suspect (can’t say for certain) that voltage causes the quirk described below.
Yes, there was still one thing that was perplexing. For some reason the rows of text displayed often get jumbled. So for example row 1 text is displayed in row 2, and vice versa, and row 3 text is displayed in row 4, and vice versa. The problem is that it is not consistent. So every now and then it switches back to normal.
The other quirk that I have not sorted is creating and displaying customer characters correctly. But I am sure this can be resolved by digging deeper into the datasheet. It was something I observed but did not investigate further.
Other than these quirks, it almost works seamlessly.
Here is a demo of the Pico-W driving this 20x4 character OLED module (hint this is a precursor to another project - a Passenger Information Display to show my local train arrival/departure times at home).
/* LiquidCrystal Library Demonstrates the use on a 20x4 NHD character OLED display. This sketch prints out four lines of text to the LCD and then clears the screen to show another four lines of text etc. The text was obtained from an example developed by Newhaven Displays and their code was used as guidance. Source: https://support.newhavendisplay.com/hc/en-us/articles/4413878167191-NHD-0420DZW-6800-4-Bit-8-Bit-with-Arduino */ // include the library code: #include <LiquidCrystal_PicoPIO.h> // define the pin numbers: static const int RS_PIN = 10; static const int EN_PIN = 11; static const int BASE_PIN = 16; /**************************************************** * Text Strings * ****************************************************/ static char const text1[] = (" Newhaven Display "); static char const text2[] = (" International "); static char const text3[] = (" CHARACTER TEST "); static char const text4[] = (" 4-Bit Parallel "); static char const text5[] = (" 8-Bit Parallel "); static char const text6[] = ("ABCDEFGHIJKLMOPQRSTU"); static char const text7[] = ("VWXYZabcdefghijklmno"); static char const text8[] = ("pqrstuvwxyz123456789"); static char const text9[] = (" <(' ')> || <(' ')> "); // This class will provide the PIO memory offset for the capsensing PIO (only need once) LCDPicoPIO PicoPIO(pio0, LCD_4BITMODE); LiquidCrystal_PicoPIO lcd(PicoPIO, RS_PIN, EN_PIN, BASE_PIN); uint32_t t_start = 0; void setup() { delay(300); // set up the LCD's number of columns and rows: lcd.begin(20, 4); lcd.home(); lcd.clear(); lcd.home(); lcd.clear(); // Print a message to the LCD. lcd.setCursor(0, 0); lcd.print(text1); lcd.setCursor(0, 1); lcd.print(text2); lcd.setCursor(0, 2); lcd.print(text3); lcd.setCursor(0, 3); lcd.print(text4); delay(5000); lcd.clear(); lcd.setCursor(0, 0); lcd.print(text6); lcd.setCursor(0, 1); lcd.print(text7); lcd.setCursor(0, 2); lcd.print(text8); lcd.setCursor(0, 3); lcd.print(text9); delay(5000); lcd.home(); lcd.clear(); lcd.home(); lcd.clear(); lcd.setCursor(0, 0); lcd.setCursor(0, 0); lcd.print(F("Hello Element14.")); // set the cursor to the top left lcd.setCursor(0, 1); // Print a message to the lcd. lcd.print(F("I love electronics!")); //lcd.write(byte(1)); // when calling lcd.write() '0' must be cast as a byte delay(5000); t_start = millis(); } void loop() { // set the cursor to column 0, line 1 // (note: line 1 is the second row, since counting begins with 0): lcd.setCursor(0, 2); // print the number of seconds since reset: lcd.print((millis()-t_start) / 1000); }