Contents
Taking a break from pure software, I am also interested in checking out .NET Core's capabilities to control hardware, in this case using the I2C interface. This led me down a lot of rabbit holes and dead ends, initially finding three posts that provided the necessary solution:
- Lesson 30 I2C LCD1602 - C code example
- Using .NET Core 2 to read from an I2C device connected to a Raspberry Pi 3 with Ubuntu 16.04
- Debian Jessie I2C Communication With C# .Net Core
#2 seems to have been derived from #3, but left out the write
P/Invoke declaration. Odd that I couldn't find any examples of using the LCD1602 in C# -- to get this working, it was necessary to port the C code to C# (trivial) and make a couple guesses as to what was going on behind the scenes -- that wiringPiI2CWrite(fd, temp);
call in the C code. I've stumbled across "wiringpi
" before -- their website seems to be defunct but there are 6 repos in GitHub supporting C, Python, PHP, Ruby, Perl, and Node. But no C#.
It also took a good amount of sleuthing to find the actual datasheet on the display which is really necessary to understand what all the bit writes are doing to control the display -- it's a rather sad state of affairs that of the blog posts on using the LCD1602 with the rPi (or other SBC's like an Arduino), I found a reference to the hardware manufacturer and model, which led to the datasheet PDF, via some comments in C++ code!
I bought Freenove's Ultimate Starter Kit for Raspberry Pi and was pleasantly surprised when it arrived a few days later. Very packed package of goodies, most of which I haven't explored yet, and a very decent download of PDFs including projects, hardware datasheets, and the link to the GitHub repo. And to my surprise, the LCD1602 had already been assembled with an I2C interface which was great because I had originally intended to use the Grove LCD:
because it supported an I2C interface (the white connector) and I had a couple lying around courtesy of a client (I confess I bought Freenove's kit initially just for the hookup wires, hahaha.) The articles I'd seen on the LCD1602 used the GPIO lines and I wasn't keen on creating a wiring mess:
(image from Sunfounder Lesson 13 LCD1602)
Instead, the LCD1602 in Freenove's kit has a nice and tidy I2C:
(image from Sunfounder I2C LCD1602)
Much neater and faster to hook up!
As in my last article, we have to use sudo raspi-config
to enable the I2C. Select "Interface Options":
Then select I2C:
and select Yes to the prompt:
Exit out of the configuration app and reboot your rPi. I'd forgotten that step and so things didn't work too well for me at first!
Obviously (I hope), do this with the rPi powered off (the power cable physically disconnected.) You can read about the I2C interface here:
I2C is a serial protocol for two-wire interface to connect low-speed devices like microcontrollers, EEPROMs, A/D and D/A converters, I/O interfaces and other similar peripherals in embedded systems. It was invented by Philips and now it is used by almost all major IC manufacturers.
There is typically one master (the rPi) and an almost unlimited number of slave devices can be attached to the bus (limited I would imagine mainly by address availability -- some devices use multiple addresses.) There are only four wires required for power, ground, clock, and data. Many I2C devices work off of the 3.3V supply (or work in the range from 3.3V to 5V) but in the case of the LCD1602, it needs to be hooked up to the 5V supply. A wiring diagram from here:
And my implementation (I used pin 9 for ground):
Execute this command:
i2cdetect -y -r 1
and you should see:
Note the "27
" - this indicates that the LCD1602, which by default is at address 0x27, has been detected. If you see only dashes in this position, recheck your wiring.
By merging the pieces from the three articles mentioned in the introduction, I arrived at this code, which has been refactored a bit to handle eventually working with multiple devices (note though that I haven't tested this with multiple I2C devices!), requiring multiple file handles. I'll explain what is in the "..." (enum
definitions) in a bit.
using System.Runtime.InteropServices;
namespace consoleApp
{
public class I2C
{
...
private static int OPEN_READ_WRITE = 2;
[DllImport("libc.so.6", EntryPoint = "open")]
public static extern int Open(string fileName, int mode);
[DllImport("libc.so.6", EntryPoint = "close")]
public static extern int Close(int handle);
[DllImport("libc.so.6", EntryPoint = "ioctl", SetLastError = true)]
private extern static int Ioctl(int handle, int request, int data);
[DllImport("libc.so.6", EntryPoint = "read", SetLastError = true)]
internal static extern int Read(int handle, byte[] data, int length);
[DllImport("libc.so.6", EntryPoint = "write", SetLastError = true)]
internal static extern int Write(int handle, byte[] data, int length);
private int handle = -1;
public void OpenDevice(string file, int address)
{
handle = Open("/dev/i2c-1", OPEN_READ_WRITE);
var deviceReturnCode = Ioctl(handle, (int)IOCTL_COMMAND.I2C_SLAVE, address);
}
public void CloseDevice()
{
Close(handle);
handle = -1;
}
protected void WriteByte(byte data)
{
byte[] bdata = new byte[] { data };
Write(handle, bdata, bdata.Length);
}
}
}
And the class that initializes and writes to the LCD1602:
using System.Text;
using System.Threading;
namespace consoleApp
{
public class Lcd1602 : I2C
{
... (more enums, explained below)
protected void SendCommand(int comm)
{
byte buf;
buf = (byte)(comm & 0xF0);
buf |= 0x04;
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
buf &= 0xFB;
buf |= 0x08;
WriteByte(buf);
buf = (byte)((comm & 0x0F) << 4);
buf |= 0x04;
WriteByte(buf);
Thread.Sleep(2);
buf &= 0xFB;
WriteByte(buf);
}
protected void SendData(int data)
{
byte buf;
buf = (byte)(data & 0xF0);
buf |= 0x05;
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
buf &= 0xFB;
buf |= 0x08;
WriteByte(buf);
buf = (byte)((data & 0x0F) << 4);
buf |= 0x05;
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
buf &= 0xFB;
buf |= 0x08;
WriteByte(buf);
}
public void Init()
{
SendCommand(0x33);
Thread.Sleep(2);
SendCommand(0x32);
Thread.Sleep(2);
SendCommand(0x28);
Thread.Sleep(2);
SendCommand(0x0C);
Thread.Sleep(2);
SendCommand(0x01);
}
public void Clear()
{
SendCommand(0x01);
}
public void Write(int x, int y, string str)
{
int addr = 0x80 + 0x40 * y + x;
SendCommand(addr);
byte[] charData = Encoding.ASCII.GetBytes(str);
foreach (byte b in charData)
{
SendData(b);
}
}
}
}
To use this code to write a message to the LCD1602, we can now call this test method:
static void Main(string[] args)
{
Console.WriteLine("Testing 1602");
Test1602();
}
static void Test1602()
{
Lcd1602 lcd = new Lcd1602();
lcd.OpenDevice("/dev/i2c-1", LCD1602_ADDRESS);
lcd.Init();
lcd.Clear();
lcd.Write(0, 0, "Hello");
lcd.Write(0, 1, " World!");
lcd.CloseDevice();
}
After publishing and SCP'ing to the rPi, when we run consoleApp
, it displays:
The above code is all fine and good, but what is really going on, and in particular, what are all these magic bits that are being or'ed and and'ed in the LCD1602 class?
After some digging, I found this repo which contains, among many other files:
This described what the magic 0x07... numbers are, which I've recoded in enum
s, preserving the comments from the .h file:
private enum IOCTL_COMMAND
{
I2C_RETRIES = 0x0701,
I2C_TIMEOUT = 0x0702,
I2C_SLAVE = 0x0703,
I2C_TENBIT = 0x0704,
I2C_FUNCS = 0x0705,
I2C_SLAVE_FORCE = 0x0706,
I2C_RDWR = 0x0707,
I2C_PEC = 0x0708,
I2C_SMBUS = 0x0720,
}
Not that I actually understand what all these options are and do, but at least there is some explanation of what they mean. Also, note that the .h file will probably be useful in the future for I2C_SMSBUS
and I2C_RDWR
control functions, which I show here in their C form:
struct i2c_smbus_ioctl_data {
__u8 read_write;
__u8 command;
__u32 size;
union i2c_smbus_data __user *data;
};
struct i2c_rdwr_ioctl_data {
struct i2c_msg __user *msgs;
__u32 nmsgs;
};
To understand in more detail what all these magic bits are doing, I ended up reviewing the C++ code in the Arduino Liquid Crystal I2C Library, particularly:
The .h file in particular defines a variety of bit constants which I've recoded as C# enums
:
private enum Commands
{
LCD_CLEARDISPLAY = 0x01,
LCD_RETURNHOME = 0x02,
LCD_ENTRYMODESET = 0x04,
LCD_DISPLAYCONTROL = 0x08,
LCD_CURSORSHIFT = 0x10,
LCD_FUNCTIONSET = 0x20,
LCD_SETCGRAMADDR = 0x40,
LCD_SETDDRAMADDR = 0x80,
}
private enum DisplayEntryMode
{
LCD_ENTRYRIGHT = 0x00,
LCD_ENTRYLEFT = 0x02,
LCD_ENTRYSHIFTINCREMENT = 0x01,
LCD_ENTRYSHIFTDECREMENT = 0x00,
}
private enum DisplayControl
{
LCD_DISPLAYON = 0x04,
LCD_DISPLAYOFF = 0x00,
LCD_CURSORON = 0x02,
LCD_CURSOROFF = 0x00,
LCD_BLINKON = 0x01,
LCD_BLINKOFF = 0x00,
}
private enum DisplayCursorShift
{
LCD_DISPLAYMOVE = 0x08,
LCD_CURSORMOVE = 0x00,
LCD_MOVERIGHT = 0x04,
LCD_MOVELEFT = 0x00,
}
private enum FunctionSet
{
LCD_8BITMODE = 0x10,
LCD_4BITMODE = 0x00,
LCD_2LINE = 0x08,
LCD_1LINE = 0x00,
LCD_5x10DOTS = 0x04,
LCD_5x8DOTS = 0x00,
}
private enum BacklightControl
{
LCD_BACKLIGHT = 0x08,
LCD_NOBACKLIGHT = 0x00,
}
private enum ControlBits
{
En = 0x04,
Rw = 0x02,
Rs = 0x01
}
In the .cpp file, I found this gem:
Odd to see a code block that has just comments, isn't it. However, it pointed me to the Hitachi HD44780 datasheet. Google that, here's a link to one site of many having a downloadable PDF. Finally! The real "source" for what is going on. For example, looking at the C++ code that initializes the display:
write4bits(0x03 << 4);
delayMicroseconds(4500);
write4bits(0x03 << 4);
delayMicroseconds(4500);
write4bits(0x03 << 4);
delayMicroseconds(150);
write4bits(0x02 << 4);
This corresponds to the C# code that I'd found:
SendCommand(0x33);
Thread.Sleep(2);
SendCommand(0x32);
The difference here is that each nybble is being sent, hence writing 0x33 and 0x32 corresponds to the C++ code that writes 0x03, 0x03, 0x03, and 0x02.
This corresponds to the documentation in datasheet that describes the initialization workflow:
But it still doesn't explain these magic bits and why 2 writes occur for each 4 bits, for example, this fragment which writes the 4 most significant (MSB) bits of command:
buf = (byte)(comm & 0xF0);
buf |= 0x04;
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
buf &= 0xFB;
buf |= 0x08;
WriteByte(buf);
I also want to understand why the device is put into 4 bit mode as opposed to leaving it in 8 bit mode.
In the datasheet, page 22, we read: "The data transfer between the HD44780U and the MPU is completed after the 4-bit data has been transferred twice." Let's look at the write
functions in the C++ code:
void LiquidCrystal_I2C::write4bits(uint8_t value) {
expanderWrite(value);
pulseEnable(value);
}
void LiquidCrystal_I2C::expanderWrite(uint8_t _data){
Wire.beginTransmission(_addr);
Wire.write((int)(_data) | _backlightval);
Wire.endTransmission();
}
void LiquidCrystal_I2C::pulseEnable(uint8_t _data){
expanderWrite(_data | En); delayMicroseconds(1);
expanderWrite(_data & ~En); delayMicroseconds(50); }
This code is particularly odd because the write4bits
is calling expanderWrite
3 times! I wonder if that's a bug in the code.
So we see in this code that, as per the datasheet, each nybble is being sent twice, once with the Enable high, the second time with the Enable low. This is controlled by bit 2 (we always count from 0): En = 0x04, // Enable bit
. Furthermore, the display is always set to "on
" when writing a command, controlled by bit 3: LCD_BACKLIGHT = 0x08
. If we don't do this when writing a "command", the display "flashes" (it turns dark) when writing text to the display because first the command has to be sent (if bit 3 is not set, the display goes dark), then the text, which sets bit 3 and turns on the display.
Unfortunately, none of this "command/data in the upper four bits, and display + enable + read/write + register select in the lower bits", is described in the datasheet! After some serious digging, I came across this link that stated: "There are a couple ways to use I2C to connect an LCD to the Raspberry Pi. The simplest is to get an LCD with an I2C backpack. The hardcore DIY way is to use a standard HD44780 LCD and connect it to the Pi via a chip called the PCF8574." Ah ha! We can now look at the datasheet for the PCF8574! Furthermore, it finally dawns on me that the reason the LCD1602 is put into 4 bit mode is because 4 bits have to be used as the data and 3 bits need to be used as control of the LCD1602's enable (E), register select (RS) and read/write (R/~W) lines, as shown here (page 3 of the datasheet for the LCD1602):
That explains bits 0, 1 and 2:
private enum ControlBits
{
En = 0x04,
Rw = 0x02,
Rs = 0x01
}
but two questions remain:
- Where does it say in the LCD1602 datasheet that the enable line has to be toggled for each nybble: "...after the 4-bit data has been transferred twice".
- What is bit 3 (0x08) doing when we send a command or data? This is clearly controlling the display on/off!
Page 21:
"For 4-bit interface data, only four bus lines (DB4 to DB7) are used for transfer. Bus lines DB0 to DB3 are disabled. The data transfer between the HD44780U and the MPU is completed after the 4-bit data has been transferred twice."
The second question first. I've been setting this bit because of this C code from Lesson 30 I2C LCD1602 - C code example:
int LCDAddr = 0x27;
int BLEN = 1;
int fd;
void write_word(int data){
int temp = data;
if ( BLEN == 1 )
temp |= 0x08;
else
temp &= 0xF7;
wiringPiI2CWrite(fd, temp);
}
What is BLEN and why is it set to 1? And finally, I find what I'm looking for here: int BLEN = 0;//1--open backlight.0--close backlight
with the following hardware connection diagram (I added the red lines):
Note that P3 on the PCF8574 is wired to the base of transistor Q1 which controls K, which must control the backlight voltage! If P3 is 1, K grounds. This is verified from yet another Google find:
and the associated text:
15. BLA - Backlight Anode (+)
16. BLK - Backlight Cathode (-)
The last 2 pins (15 & 16) are optional and are only used if the display has a backlight.
If the backlight isn't grounded, then it isn't illuminated. Crazy. Furthermore, we also see how the first 3 LSBs if the 8 bit data byte are mapped to the enable, register select, and R/~W lines:
P2 (0x04) called "CS" (chip select) is the "Enable" pin 6 on the LCD1602. Going back to the LCD1602 datasheet, we see that the enable (E) pin starts the read/write operation:
Furthermore, let's look at this timing diagram from the LCD1602 datasheet, page 22:
Notice that the enable line must be toggled between the two nybbles. The only way to do this is to write "something" (anything should do) with bit 2 set to 0 to bring the line low. So now we can understand why the each nybble has to be written twice. Understanding this, all the code examples I've come across can be simplified to this:
byte buf;
buf = (byte)(comm & 0xF0);
buf |= 0x04;
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
WriteByte(8);
buf = (byte)((comm & 0x0F) << 4);
buf |= 0x04;
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
WriteByte(8);
Finally! (I'm saying that a lot. It's taken at least 8 hours of Googling to find the various links I've referenced to put all the pieces together!)
Now to clean the code up with the various enum
constants:
byte buf;
buf = (byte)(comm & 0xF0);
buf |= (byte)ControlBits.En | (byte)BacklightControl.LCD_BACKLIGHT;
WriteByte(buf);
Thread.Sleep(2);
WriteByte((byte)BacklightControl.LCD_BACKLIGHT);
buf = (byte)((comm & 0x0F) << 4);
buf |= (byte)ControlBits.En | (byte)BacklightControl.LCD_BACKLIGHT;
WriteByte(buf);
Thread.Sleep(2);
WriteByte((byte)BacklightControl.LCD_BACKLIGHT);
Using this key (page 23 of the LCD1602 datasheet):
Function set:
DL = 1; 8-bit interface data
N = 0; 1-line display
F = 0; 5 � 8 dot character font
Display on/off control:
D = 0; Display off
C = 0; Cursor off
B = 0; Blinking off
Entry mode set:
I/D = 1; Increment by 1
S = 0; No shift
we can now understand the commands we can send to the LCD1602 (table 6, page 24):
So, for example, writing to the display (the DDRAM) has the RS line low and bit 7 (0x80) is always high:
where the first line starts at address 0 and the second line starts at address 0x40:
which corresponds to the code int addr = 0x80 + 0x40 * y + x;
Other commands are determined similarly by reviewing table 6 (image above.) And yes, at this point, I'm done with this stuff, so I leave it to the reader to have enough understanding to explore all the commands. And I am not going to figure out how to program the character set!
The odd thing about this article is that getting the code to work took a couple hours of digging around and putting the pieces together. Understanding how the code works took 4 times as long. But it was well worth the journey as I now have a solid understanding of how to use the I2C interface and read hardware diagrams for future devices.