65.9K
CodeProject 正在变化。 阅读更多。
Home

物联网之年 - 连接一个 2 行 LCD 显示屏

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2019年1月12日

CPOL

11分钟阅读

viewsIcon

19402

使用.NET Core将文本发送到连接到rPi的2行LCD的旅程

目录

引言

我暂时放下纯粹的软件,也对探索.NET Core控制硬件的能力感兴趣,在这种情况下,我使用了I2C接口。这让我陷入了许多困境和死胡同,最初发现了三篇提供了必要解决方案的文章

  1. 第30课 I2C LCD1602 - C语言代码示例
  2. 使用.NET Core 2从连接到运行Ubuntu 16.04的Raspberry Pi 3的I2C设备读取数据
  3. Debian Jessie C# .Net Core I2C通信

#2似乎是从#3派生出来的,但省略了write P/Invoke声明。奇怪的是,我找不到任何使用C#中的LCD1602的示例——为了让它工作,有必要将C代码移植到C#(很简单),并对幕后发生的事情做出一些猜测——C代码中的wiringPiI2CWrite(fd, temp);调用。我以前曾偶然发现“wiringpi”——他们的网站似乎已经失效,但GitHub上有6个仓库支持C、Python、PHP、Ruby、Perl和Node。但没有C#。

我还花了大量精力去查找显示屏的实际数据手册,这对于理解所有位写入如何控制显示屏至关重要——关于使用LCD1602与rPi(或其他SBC,如Arduino)的博客文章,我找到了一篇提及硬件制造商和型号的文章,通过一些C++代码中的注释,找到了数据手册PDF!

步骤0:获取带I2C扩展板的LCD1602

我购买了 Freenove的树莓派终极入门套件,几天后收到货,令我惊喜不已。包装里面有很多好东西,其中大部分我还没有探索过,还有非常不错的PDF资料,包括项目、硬件数据手册和GitHub仓库链接。令我惊讶的是,LCD1602已经组装好了I2C接口,这很好,因为我最初打算使用Grove LCD

因为它支持I2C接口(白色连接器),而且我有一些客户端提供的(我承认我最初购买Freenove套件只是为了接线,哈哈哈)。我看到的关于LCD1602的文章都使用了GPIO线,我不太想弄得接线一团糟

(图片来自 Sunfounder Lesson 13 LCD1602

相反,Freenove套件中的LCD1602拥有一个整洁的I2C接口

(图片来自Sunfounder I2C LCD1602

更加整洁,连接更快!

步骤1:启用I2C

和上一篇文章一样,我们必须使用sudo raspi-config来启用I2C。选择“接口选项

然后选择I2C

并在提示时选择

退出配置应用程序并重启您的rPi。我忘记了这一步,所以一开始事情对我来说不太顺利!

步骤2:连接LCD1602

很明显(我希望如此),在rPi断电的情况下(物理上断开电源线)进行此操作。你可以在这里阅读I2C接口

I2C是一种串行协议,用于双线接口,在嵌入式系统中连接微控制器、EEPROM、A/D和D/A转换器、I/O接口以及其他类似外设等低速设备。它由飞利浦发明,现在几乎被所有主要的IC制造商使用。

通常有一个主设备(rPi)和几乎无限数量的从设备可以连接到总线(我相信主要限制在于地址可用性——有些设备使用多个地址)。只需要四根线就可以完成电源、地、时钟和数据。许多I2C设备使用3.3V电源(或在3.3V到5V范围内工作),但在LCD1602的情况下,它需要连接到5V电源。接线图来自这里

以及我的实现(我使用了pin 9作为地线)

步骤3:验证设备是否可被发现

执行此命令

i2cdetect -y -r 1

你应该会看到

注意“27”——这表示LCD1602(默认地址为0x27)已被检测到。如果在此位置只看到破折号,请仔细检查接线。

步骤4:代码

通过合并介绍中提到的三篇文章中的部分内容,我得到了这段代码,它已经被重构了一下,以便将来能处理多个设备(请注意,我还没有用多个I2C设备测试过!),需要多个文件句柄。我将在稍后解释“...”(enum定义)中的内容。

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)
    {
      // From: https://stackoverflow.com/a/41187358
      // The I2C slave address set by the I2C_SLAVE ioctl() is stored in an i2c_client
      // that is allocated everytime /dev/i2c-X is opened. So this information is local 
      // to each "opening" of /dev/i2c-X.
      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);
    }
  }
}

以及初始化并写入LCD1602的类

using System.Text;
using System.Threading;

namespace consoleApp
{
  public class Lcd1602 : I2C
  {
    ... (more enums, explained below)

    protected void SendCommand(int comm)
    {
      byte buf;
      // Send bit7-4 firstly
      buf = (byte)(comm & 0xF0);
      buf |= 0x04; // RS = 0, RW = 0, EN = 1
      buf |= 0x08;
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      buf |= 0x08;
      WriteByte(buf);

      // Send bit3-0 secondly
      buf = (byte)((comm & 0x0F) << 4);
      buf |= 0x04; // RS = 0, RW = 0, EN = 1
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      WriteByte(buf);
    }

    protected void SendData(int data)
    {
      byte buf;
      // Send bit7-4 firstly
      buf = (byte)(data & 0xF0);
      buf |= 0x05; // RS = 1, RW = 0, EN = 1
      buf |= 0x08;
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      buf |= 0x08;
      WriteByte(buf);

      // Send bit3-0 secondly
      buf = (byte)((data & 0x0F) << 4);
      buf |= 0x05; // RS = 1, RW = 0, EN = 1
      buf |= 0x08;
      WriteByte(buf);
      Thread.Sleep(2);
      buf &= 0xFB; // Make EN = 0
      buf |= 0x08;
      WriteByte(buf);
    }

    public void Init()
    {
      SendCommand(0x33); // Must initialize to 8-line mode at first
      Thread.Sleep(2);
      SendCommand(0x32); // Then initialize to 4-line mode
      Thread.Sleep(2);
      SendCommand(0x28); // 2 Lines & 5*7 dots
      Thread.Sleep(2);
      SendCommand(0x0C); // Enable display without cursor
      Thread.Sleep(2);
      SendCommand(0x01); // Clear Screen
    }

    public void Clear()
    {
      SendCommand(0x01); //clear Screen
    }

    public void Write(int x, int y, string str)
    {
      // Move cursor
      int addr = 0x80 + 0x40 * y + x;
      SendCommand(addr);

      byte[] charData = Encoding.ASCII.GetBytes(str);

      foreach (byte b in charData)
      {
        SendData(b);
      }
    }
  }
}

要使用此代码向LCD1602写入消息,我们现在可以调用此测试方法

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();
}

发布并SCP到rPi后,当我们运行consoleApp时,它会显示

到底是怎么回事?

上面的代码都很好,但到底是怎么回事?特别是,LCD1602类中被按位或和按位与处理的这些神奇的位都是什么意思?

I2C基础知识

经过一番挖掘,我找到了这个仓库,其中包含许多文件

这解释了神奇的0x07…数字是什么,我已经用enum重新编码了,保留了.h文件中的注释

// From: https://github.com/spotify/linux/blob/master/include/linux/i2c-dev.h
private enum IOCTL_COMMAND
{
  /* /dev/i2c-X ioctl commands. The ioctl's parameter is always an
  * unsigned long, except for:
  * - I2C_FUNCS, takes pointer to an unsigned long
  * - I2C_RDWR, takes pointer to struct i2c_rdwr_ioctl_data
  * - I2C_SMBUS, takes pointer to struct i2c_smbus_ioctl_data
  */

  // number of times a device address should be polled when not acknowledging 
  I2C_RETRIES = 0x0701,

  // set timeout in units of 10 ms
  I2C_TIMEOUT = 0x0702,

  // Use this slave address 
  I2C_SLAVE = 0x0703,

  // 0 for 7 bit addrs, != 0 for 10 bit 
  I2C_TENBIT = 0x0704,

  // Get the adapter functionality mask
  I2C_FUNCS = 0x0705,

  // Use this slave address, even if it is already in use by a driver!
  I2C_SLAVE_FORCE = 0x0706,

  // Combined R/W transfer (one STOP only) 
  I2C_RDWR = 0x0707,

  // != 0 to use PEC with SMBus 
  I2C_PEC = 0x0708,

  // SMBus transfer 
  I2C_SMBUS = 0x0720, 
}

我并不是说我完全理解所有这些选项是什么以及它们的作用,但至少有对它们含义的解释。另外,请注意.h文件将来可能对I2C_SMSBUSI2C_RDWR控制功能有用,我在这里以C语言形式展示它们

/* This is the structure as used in the I2C_SMBUS ioctl call */
struct i2c_smbus_ioctl_data {
  __u8 read_write;
  __u8 command;
  __u32 size;
  union i2c_smbus_data __user *data;
};

/* This is the structure as used in the I2C_RDWR ioctl call */
  struct i2c_rdwr_ioctl_data {
  struct i2c_msg __user *msgs; /* pointers to i2c_msgs */
  __u32 nmsgs; /* number of i2c_msgs */
};

向LCD1602 I2C写入数据

为了更详细地了解所有这些神奇的位是如何工作的,我最终回顾了Arduino Liquid Crystal I2C库中的C++代码,特别是

特别是.h文件定义了各种位常量,我已经重新编码为C# enums

// Source for enums:
// https://github.com/fdebrabander/Arduino-LiquidCrystal-I2C-library/blob/master/LiquidCrystal_I2C.h
// commands
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,
}

// flags for display entry mode
private enum DisplayEntryMode
{
  LCD_ENTRYRIGHT = 0x00,
  LCD_ENTRYLEFT = 0x02,
  LCD_ENTRYSHIFTINCREMENT = 0x01,
  LCD_ENTRYSHIFTDECREMENT = 0x00,
}

// flags for display on/off control
private enum DisplayControl
{
  LCD_DISPLAYON = 0x04,
  LCD_DISPLAYOFF = 0x00,
  LCD_CURSORON = 0x02,
  LCD_CURSOROFF = 0x00,
  LCD_BLINKON = 0x01,
  LCD_BLINKOFF = 0x00,
}

// flags for display/cursor shift
private enum DisplayCursorShift
{
  LCD_DISPLAYMOVE = 0x08,
  LCD_CURSORMOVE = 0x00,
  LCD_MOVERIGHT = 0x04,
  LCD_MOVELEFT = 0x00,
}

// flags for function set
private enum FunctionSet
{
  LCD_8BITMODE = 0x10,
  LCD_4BITMODE = 0x00,
  LCD_2LINE = 0x08,
  LCD_1LINE = 0x00,
  LCD_5x10DOTS = 0x04,
  LCD_5x8DOTS = 0x00,
}

// flags for backlight control
private enum BacklightControl
{
  LCD_BACKLIGHT = 0x08,
  LCD_NOBACKLIGHT = 0x00,
}

private enum ControlBits
{
  En = 0x04, // Enable bit
  Rw = 0x02, // Read/Write bit
  Rs = 0x01 // Register select bit
}

.cpp文件中,我找到了这个精华

// put the LCD into 4 bit mode
// this is according to the hitachi HD44780 datasheet
// figure 24, pg 46

看到一个只有注释的代码块是不是很奇怪?然而,它指向了日立HD44780数据手册。谷歌搜索一下,这里有一个链接指向了多个提供可下载PDF的网站之一。终于!真正“来源”了这里发生的事情。例如,查看初始化显示器的C++代码

// we start in 8bit mode, try to set 4 bit mode
write4bits(0x03 << 4);
delayMicroseconds(4500); // wait min 4.1ms

// second try
write4bits(0x03 << 4);
delayMicroseconds(4500); // wait min 4.1ms

// third go!
write4bits(0x03 << 4);
delayMicroseconds(150);

// finally, set to 4-bit interface
write4bits(0x02 << 4);

这对应于我找到的C#代码

SendCommand(0x33); // Must initialize to 8-line mode at first
Thread.Sleep(2);
SendCommand(0x32); // Then initialize to 4-line mode

这里的区别在于,每个半字节都被发送了,因此写入0x33和0x32对应于写入0x03、0x03、0x03和0x02的C++代码。

这与数据手册中描述初始化工作流程的文档相对应

但它仍然没有解释这些神奇的位,以及为什么每次传输4位需要进行2次写入,例如,这个片段写入命令的4个最高有效位(MSB)

// Send bit7-4 firstly
buf = (byte)(comm & 0xF0);
buf |= 0x04; // RS = 0, RW = 0, EN = 1
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
buf &= 0xFB; // Make EN = 0
buf |= 0x08;
WriteByte(buf);

我还想了解为什么设备被置于4位模式而不是保持8位模式。

在数据手册的第22页,我们读到:“HD44780U和MPU之间的数据传输在4位数据传输两次后完成。”我们来看看C++代码中的write函数

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); // En high
  delayMicroseconds(1); // enable pulse must be >450ns

  expanderWrite(_data & ~En); // En low
  delayMicroseconds(50); // commands need > 37us to settle
}

这段代码特别奇怪,因为write4bits调用了3次expanderWrite!我怀疑这是代码中的一个bug。

因此,我们从这段代码中可以看到,根据数据手册,每个半字节都被发送了两次,一次是Enable置高,第二次是Enable置低。这由位2(我们总是从0开始计数)控制:En = 0x04, // Enable bit。此外,写入命令时,显示屏总是设置为“on”,由位3控制:LCD_BACKLIGHT = 0x08。如果写入“命令”时不这样做,当向显示屏写入文本时,显示屏会“闪烁”(变暗),因为必须先发送命令(如果位3未设置,显示屏会变暗),然后是文本,这会设置位3并打开显示屏。

不幸的是,数据手册中没有描述“上半字节是命令/数据,下半字节是显示+使能+读/写+寄存器选择”的任何内容!经过一番艰苦的挖掘,我偶然发现了这个链接,其中提到:“有两种方法可以使用I2C将LCD连接到Raspberry Pi。最简单的方法是购买一个带有I2C背包的LCD。硬核DIY方法是使用标准的HD44780 LCD,并通过一个名为PCF8574的芯片将其连接到Pi。”啊哈!现在我们可以查看PCF8574的数据手册了!此外,我终于明白了为什么LCD1602被置于4位模式,因为4位必须用作数据,而3位需要用于控制LCD1602的使能(E)、寄存器选择(RS)和读/写(R/~W)线,如下图所示(LCD1602数据手册第3页)

这就解释了位0、1和2

private enum ControlBits
{
  En = 0x04, // Enable bit
  Rw = 0x02, // Read/Write bit
  Rs = 0x01 // Register select bit
}

但仍有两个问题

  1. LCD1602数据手册中哪里说过使能线必须为每个半字节切换:“...4位数据传输两次后完成”。
  2. 当我们将命令或数据发送出去时,位3(0x08)在做什么?这显然是在控制显示屏的开/关!

第21页

对于4位接口数据,仅使用四条总线(DB4到DB7)进行传输。总线DB0到DB3被禁用。HD44780U和MPU之间的数据传输在4位数据传输两次后完成。

位3(0x08)的谜团已解开

先回答第二个问题。我设置这个位是因为下面C代码来自第30课 I2C LCD1602 - C代码示例

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);
}

BLEN是什么?为什么设置为1?最后,我在这里找到了我要找的东西:这里int BLEN = 0;//1--打开背光。0--关闭背光,以及以下硬件连接图(我添加了红线)

请注意,PCF8574上的P3连接到晶体管Q1的基极,该晶体管控制K,K必须控制背光电压!如果P3为1,K接地。这可以通过另一个Google找到的内容得到验证

以及相关的文本

15. BLA - 背光阳极(+)
16. BLK - 背光阴极(-)

最后2个引脚(15和16)是可选的,仅当显示屏有背光时才使用。

如果背光未接地,则不会发光。太疯狂了。此外,我们还看到了8位数据字节的前3个最低有效位如何映射到使能、寄存器选择和R/~W线

在4位模式下写入时切换P2的谜团已解开

P2(0x04)称为“CS”(芯片选择)是LCD1602上的“使能”引脚6。回到LCD1602数据手册,我们看到使能(E)引脚启动读/写操作

此外,让我们看一下LCD1602数据手册第22页的这个时序图

请注意,在两个半字节之间必须切换使能线。唯一的方法是写入“某物”(任何东西都可以)并将位2设置为0以使线路变为低电平。所以现在我们可以理解为什么每个半字节都必须写两次。理解这一点后,我遇到的所有代码示例都可以简化为

byte buf;
// Send bit7-4 firstly
buf = (byte)(comm & 0xF0);
buf |= 0x04; // RS = 0, RW = 0, EN = 1
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
WriteByte(8);   // <<< === Set EN = 0, keep the display on, we don't care what the data is.

// Send bit3-0 secondly
buf = (byte)((comm & 0x0F) << 4);
buf |= 0x04; // RS = 0, RW = 0, EN = 1
buf |= 0x08;
WriteByte(buf);
Thread.Sleep(2);
WriteByte(8);	// <<< === Set EN = 0, keep the display on, we don't care what the data is.

终于!(我说了很多次。花了至少8个小时的谷歌搜索才找到我引用的各种链接,将所有碎片拼凑起来!)

现在用各种enum常量来清理代码

byte buf;
// Send bit7-4 firstly
buf = (byte)(comm & 0xF0);
buf |= (byte)ControlBits.En | (byte)BacklightControl.LCD_BACKLIGHT;
WriteByte(buf);
Thread.Sleep(2);
WriteByte((byte)BacklightControl.LCD_BACKLIGHT);

// Send bit3-0 secondly
buf = (byte)((comm & 0x0F) << 4);
buf |= (byte)ControlBits.En | (byte)BacklightControl.LCD_BACKLIGHT;
WriteByte(buf);
Thread.Sleep(2);
WriteByte((byte)BacklightControl.LCD_BACKLIGHT);

命令和数据

使用此键(LCD1602数据手册第23页)

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

我们现在可以理解可以发送给LCD1602的命令(表6,第24页)

因此,例如,写入显示屏(DDRAM)时,RS线为低电平,位7(0x80)始终为高电平

第一行的地址为0,第二行的地址为0x40

这对应于代码int addr = 0x80 + 0x40 * y + x;

其他命令的确定方法类似,通过查看表6(上图)。是的,此时,我厌倦了这些东西,所以我留给读者去理解,以便他们有足够的理解来探索所有命令。而且我不会去弄清楚如何编程字符集!

结论

这篇文章奇怪的地方在于,让代码工作花了几个小时来挖掘和拼凑碎片。理解代码是如何工作的花了4倍的时间。但这是值得的旅程,因为我现在对如何使用I2C接口和阅读硬件图纸以用于未来设备有了扎实的理解。

© . All rights reserved.