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





5.00/5 (4投票s)
使用.NET Core将文本发送到连接到rPi的2行LCD的旅程
目录
引言
我暂时放下纯粹的软件,也对探索.NET Core控制硬件的能力感兴趣,在这种情况下,我使用了I2C接口。这让我陷入了许多困境和死胡同,最初发现了三篇提供了必要解决方案的文章
- 第30课 I2C LCD1602 - C语言代码示例
- 使用.NET Core 2从连接到运行Ubuntu 16.04的Raspberry Pi 3的I2C设备读取数据
- 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_SMSBUS
和I2C_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
}
但仍有两个问题
- LCD1602数据手册中哪里说过使能线必须为每个半字节切换:“...4位数据传输两次后完成”。
- 当我们将命令或数据发送出去时,位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接口和阅读硬件图纸以用于未来设备有了扎实的理解。