驱动 LCD12864 ST7920(一)

驱动 LCD12864 ST7920(一)

通过 Arduino 驱动 LCD12864 ST7920。 Driving LCD12864 ST7920 by Arduino.

前言

继上次的成功驱动了 LCD1602 以后,这几天又入手了一块 LCD12864。之所以叫 LCD12864,是因为它的屏幕是由 128 * 64 大小的点阵构成的。本文章将以学习笔记加个人想法的形式,记录使用 Arduino Mega 2560 驱动它的过程,因此很多内容会写得相当细致。同样,为了更好地理解底层的工作原理,这个项目将不会借助譬如 U8glib/U8g2 这样的库,而是会通过自己编写控制 LCD12864 的各种命令和函数。

硬件准备

第一步还是一样的,弄清楚每个引脚的功能。不过需要注意的是,LCD12864 有很多变种,这些变种通常在引脚以及其功能上会有差异。笔者使用的这款驱动芯片是 ST7920。常见的芯片种类与差异如下表(由 Grok 汇总)[1][2][3][4]

芯片型号Chip Model ST7920 ST7565 T6963C KS0108
制造商 Sitronix Sitronix Toshiba Samsung
电压范围 2.7V ~ 5.5V (兼容 3.3V/5V) 1.8V ~ 3.3V (低压优先) 4.5V ~ 5.5V (需 5V) 4.5V ~ 5.5V (需 5V)
接口类型 并行 8-bit / 串行 (SPI-like,写-only) 并行 8-bit / 串行 (SPI,写-only) 并行 8-bit (需外部时钟) 并行 8-bit (多芯片页选择)
内置 RAM 是 (256×32 像素,约 1Kb,支持滚动) 是 (约 1Kb,支持读写) 否 (需外部 SRAM,灵活扩展) 是 (每个芯片 64×64 像素,总 1Kb,支持读写)
内存组织 水平字节 (8 像素/字节,类似您的图像模式:上半屏 0x80,下半屏 0x88) 垂直字节 (1 列 8 像素/字节,需转换数据) 水平字节 (灵活,8/16 像素/字节) 垂直字节 (分 2 页 64×64,需页切换)
速度 较慢 (串行模式 ~100kHz,适合简单图形) 较快 (SPI 模式支持高频,适合动态显示) 中等 (并行快,但外部 RAM 延迟) 慢 (页切换开销大,串行需缓冲)
特殊功能 兼容 HD44780 字符模式 (8×32 字符),内置电荷泵 (无需外部 7660);支持 CGRAM (自定义字符) 支持灰度 (部分变体),RGB 背光兼容;低功耗,适合电池设备 支持更大分辨率 (至 240×128);文本/图形混合,需外部字体 RAM 简单页模式 (2 芯片并联实现 128×64);支持读操作 (易调试)
引脚数 约 16-20 (RS, E, RST, PSB 选接口) 约 11-16 (SCLK, SID, CS, A0) 约 24 (DB0-7, FS, CE 等,多引脚) 约 16 (DB0-7, CS1/CS2 页选, R/W)
优缺点 优点:兼容字符模式,易移植;缺点:串行写-only (需 MCU 缓冲 优点:低压串行,速度快;缺点:垂直内存需数据转置 优点:扩展性强;缺点:需外部 RAM,复杂 优点:支持读写,简单;缺点:页切换慢,不兼容串行
成本/可用性 低 (~5-10元),常见于中国模块 低 (~5-10元),Raspberry Pi 友好 中等 (~10-20元),工业级 低 (~5元),老款但易得
库/兼容 U8g2/ST7920 U8g2/ST7565,需调整垂直布局 U8g2/T6963C,需配置外部 RAM U8g2/KS0108,支持但速度慢

LCD12864 ST7920 的元件引脚及功能如下表:

引脚Pins 符号Symbol 电平Level 描述Description
1 VDD - 接地
2 VSS - 电源正极
3 V0 - 对比度调节
4 RS L / H 寄存器选择,L:指令寄存器;H:数据寄存器
5 R/W L / H 读写控制,L:写入;H:读取
6 E H 使能信号,高电平有效
7 DB0 L / H 数据位 0
8 DB1 L / H 数据位 1
9 DB2 L / H 数据位 2
10 DB3 L / H 数据位 3
11 DB4 L / H 数据位 4
12 DB5 L / H 数据位 5
13 DB6 L / H 数据位 6
14 DB7 L / H 数据位 7
15 PSB L / H 接口选择,L:串行;H:8 / 4 位并行
16 NC - 悬空
17 RESET L / H 复位,低电平有效
18 VOUT - -10V 负电源输出
19 LEDA - 背光正极
20 LEDK - 背光负极

可以看到,与 LCD1602 相比,两者的在引脚上的安排是极为相似的。需要额外注意的是,在本项目中将直接使用 8 位并行的方式传输数据,因此需要将PSG引脚设为高电平;而用于提供 -10V 电源输出的 VOUT,原本应该是通过一个电位器接电源,同时输出端接V0,通过调整电阻以调节屏幕的对比度;但是有的模块上可能已经附带了用于调整的电位器,因此就无需额外连接电路。

在本项目中,读取功能不会使用,因此RW直接拉低。RSERESET分别接 Arduino 的 2,3,4 引脚。

LCD 的背面。RV1 是用于调整对比度的电位器。
LCD 的背面。RV1 是用于调整对比度的电位器。

在大多数项目中,会使用pinMode()函数来控制引脚的数据方向,pinOut()函数来设置引脚的输出电平。除了这个方法以外,Arduino 的每个引脚都由三个 8 位寄存器控制,通常以 B,C,D 等字母命名,对应不同的端口:

  1. DDRx, Data Direction Register控制引脚的数据方向。0 为输入,1 为输出
  2. PORTx, Port Output Register控制着该端口引脚上的输出电平。0 为高电平,1 为低电平
  3. PINx, Port Input Register读取输入引脚的状态。如果引脚是高电平,则3相应的位是 1;反之则为 0。[5]

虽然官方手册上并不推荐这种做法,但它的速度比通过for循环逐位写入快得多;而且这种底层操作对于深入理解硬件也大有裨益。不同的型号的芯片对于各个引脚的映射不一样,所以在使用时请务必先通过官方给出的 Pinout 确认各个引脚所属的端口。笔者使用的是 Arduino Mega 2560,采用的处理器是 ATmega2560。根据官方给出的引脚图,橘黄色背景的文字就是单片机的端口,而相同端口的引脚就可以直接使用寄存器来读写。

Arduino Mega 2560 的引脚图。橘黄色背景的文字是该引脚的端口编号。Arduino
Arduino Mega 2560 的引脚图。橘黄色背景的文字是该引脚的端口编号。Arduino

若要直接使用0-7号引脚作为数据接口,由于0号和1号引脚有编程的串行通信和调试的用途,直接将它们作为 GPIO可能会干扰下载或调试,因此不行;同时这些引脚也并不属于同一个端口,用寄存器来控制的话很麻烦。在这个项目中,笔者选择了A0-A7作为数据接口,因为它们可以直接使用PORTFDDRF来控制;而且排成一排,可以直接插排线,看起来就整洁很多。虽然板子上这几个引脚旁标注了 ANALOG IN 的字样,这并不妨碍它们作为 GPIO 使用。

在 Fritzing 中绘制的电路。
在 Fritzing 中绘制的电路。

实物连接图。买了杜邦线和面包板用硬芯线是个正确的决定。
实物连接图。买了杜邦线和面包板用硬芯线是个正确的决定。

程序编写

硬件的准备工作完成了以后,接下来就是软件层面的事情了。LCD12864 的初始化流程和 LCD1602 的非常相似。在等待电源电压稳定以后,两者都要经过功能设置 → 显示控制 → 清屏 → 输入模式这几步。指令格式如下:

指令Instruction RS R/W D7 D6 D5 D4 D3 D2 D1 D0
功能设置Function Set 0 0 0 0 1 DL X RE G X
显示控制Display Control 0 0 0 0 0 0 1 D C B
清屏Clear Screen 0 0 0 0 0 0 0 0 0 1
输入模式Entry Mode Set 0 0 0 0 0 0 0 1 I/D S

注:X表示该位可以为任意值,不会造成影响。功能设置指令的G仅有在启用扩展指令集的情况下才能使用。

第一条是功能设置 (Function Set) 指令。DL是用来选择数据位的宽度的。低电平是 4 位,高电平是 8 位;而RE用来选择指令集。低电平是基本指令集,高电平是扩展指令集。本项目将使用 8 位并行模式通信,因此DL = 1。扩展指令集在后期实现显示图像等功能的时候会派上用场,不过现在的任务是先在屏幕上显示内置字库,因此使用基本指令集即可,RE = 0

第二条是显示控制 (Display ON/OFF Control) 指令。D, C, B这三位分别对应着显示光标以及字符闪烁。高电平为启用,低电平为关闭。

第三条是清屏 (Clear Screen) 指令。这个很简单,没有需要设置的参数。

最后一条是输入模式 (Entry Mode Set) 指令。I/D控制光标的移动方向,高电平为向右移动(递增),低电平为向左移动(递减)。S控制全屏移动。低电平时禁用,高电平时则根据I/D来进行全屏移动。例如,当S启用时,I/D为高电平则向右移动,反之则向左移动。

笔者采用的指令如下:

指令Instruction RS R/W D7 D6 D5 D4 D3 D2 D1 D0
功能设置Function Set 0 0 0 0 1 1 0 0 0 0
显示控制Display Control 0 0 0 0 0 0 1 1 1 0
清屏Clear Screen 0 0 0 0 0 0 0 0 0 1
输入模式Entry Mode Set 0 0 0 0 0 0 0 1 1 0

OK,决定好要使用什么指令以后,接下来就是编程。上次驱动 LCD1602 时,是将这些指令的二进制数据直接保存在变量里。这样虽然调用时方便,但是若想使用不同的设置,还得手动更改变量的值,很不直观。因此这次将使用最近学会的一个好方法。

这个方法即自定义头文件。里面将声明 (Declaration) 一些函数。头文件取名为ST7920.h。初始化的流程一共要用到四条指令,现在就先声明四个函数用来专门控制:

c
// ST7920.H
#ifndef ST7920_H
#define ST7920_H

void FuncSet();
void DispCtrl();
void ClrScreen();
void EntryMode();

#endif

诸位读者想必已经发现了:除了要声明的函数以外,还有三条语句:#ifndef#define#endif。它们是什么意思呢?

这三条是 C/C++ 的预处理器指令 (Preprocessor Directives)。这类指令将用于控制编译器如何编译文件或编译哪些部分[6]。上述代码中出现的#ifndef#define#endif,是用于防止头文件被重复包含,通常成对出现。

那么为什么需要防止头文件被重复包含呢?简单来说,我们在一个源文件通过#include包含某个头文件时,就相当于直接将头文件里的内容复制粘贴在了代码里。假设有两个源文件file1.cfile2.c同时包含了common.h这个头文件,那么在编译时就相当于common.h中的内容被重复定义,这时编译器就会报错。#ifndef相当于一个if ()语句,会检测一个特定的,通常来说是头文件的文件名,例如COMMON_H,有没有被定义。如果没有定义,就执行接下来的#define定义这个宏,以及接下来的其他代码;而如果被定义了,那么接下来所有的代码都不会被执行,直到#endif结束。

除此之外,这三条指令也可以凝缩成为一条#pragma once。这个更简洁,同时也不用担心宏名冲突的问题。虽然是非标准的,不过目前大部分的编译器都支持这条命令,除非是那种特别特别老旧的版本。头文件也应尽量减少相互包含,避免形成循环依赖,正确的做法是在.c.cpp的源文件中包含。

确定好要使用的函数以后,接下来就是要传入的参数。这些参数都可以视作布尔类型。但是如果在调用的时候直接写DispCtrl(true, false, false)的话也很不直观。因此可以通过枚举enum来赋予这些参数一个有意义的名称。首先,根据每条指令要用到的参数,以这些参数为基准创建一个枚举类型,里面就可以列举出所有可能的情况。情况不外乎01两种,而这两种情况就可以分别取个易懂的名字:

c
// Enums
// Entry Mode Set command
enum AddrContCtrl   { ADDR_INC = 1, ADDR_DEC  	= 0 };
enum DispShiftCtrl  { SHIFT_ON = 1, SHIFT_OFF 	= 0 };

// Function Set command
enum DataLength     { DL_8BIT  	= 1, DL_4BIT 	= 0 };
enum InstructionSet { EXTENDED 	= 1, BASIC    	= 0 };
enum GraphicMode    { GRAPHIC  	= 1, CHAR    	= 0 };

// Display Control command
enum DispSwitch   	{ DISP_ON   = 1, DISP_OFF   = 0 };
enum CursorSwitch 	{ CURSOR_ON = 1, CURSOR_OFF = 0 };
enum CharBlink    	{ BLINK_ON  = 1, BLINK_OFF  = 0 };

// Write Byte command
enum RegSelect 		{ DATA = 1, INST = 0 };

之前声明的函数就可以这样设定形参 (Formal Parameters):

c
void FuncSet(enum DataLength DL, enum InstructionSet RE, enum GraphicMode G);
void DispCtrl(enum DispSwitch D, enum CursorSwitch C, enum CharBlink B);
void ClrScreen();
void EntryMode(enum AddrContCtrl ID, enum DispShiftCtrl S);

现在,就可以这样来设置要发送的指令,例如:

c
FuncSet(DL_8BIT, BASIC, CHAR);

意思就相当一目了然了,即 8 位数据模式,基础指令集,字符模式。

不过,这还只是声明好了函数。函数功能的实现,或者定义 (Definition) 应该放在.c.cpp的源文件中实现,例如再新建一个st7920.c的文件。

由于PORTF一次性传递的是一个字节的数据,因此需要进行一些位操作来更改这一字节内某一位的值。这一过程可以通过按位或 (Bitwise OR) 以及 位移 (Bitwise Shift) 来完成。例如,在FuncSet()中,需要将 bit4 和 bit2 分别设为10。读取到传入的参数以后,分别将这两个向高位左移 4 位和 2 位,随后再通过按位或把值赋给这一位,即:

c
uint8_t cmd = 0b00100000 | (DL << 4) | (RE << 2);

例如当DL = 1, RE = 0时:

Text
   0b00100000
   0b00010000 (1 << 4 = 0b00010000)
OR 0b00000000
--------------
   0b00110000

最终的计算结果就是要传输的指令。

最后,除了这些和 LCD 功能有关的指令以外,还得有个负责将它们写入的函数。要写入的内容分为指令和数据两种,同时还得控制写完后的延迟的时间,以确保正确写入。根据这些需求,就可以声明:

c
enum RegSelect { DATA = 1, INST = 0 };
void WriteByte(enum RegSelect rs, uint8_t data, int DelayUs)

第一个参数rs是选择要写入的寄存器类型,分为指令和数据两种寄存器。这个参数就直接控制着RS引脚上的电平。data即要写入的内容,通过uint8_t定义了一个大小为 8-bit 的形参。最后是要延迟的微秒数。根据手册可得,大部分的写指令都能在几百纳秒内完成,因此为了提升效率,一般延迟 10μs 左右就足够了,不过清屏的时间可以留得略久一点。除此之外,还得控制使能E引脚上的电平。这个引脚的功能可以理解为「发送数据」,高电平有效。这个函数的实现如下:

c
void WriteByte(enum RegSelect rs, uint8_t data, int delayUs) {
	// Set the mode of RS
	// true for data mode
	// false for instruction mode
	if (rs == true)
		digitalWrite(RS, HIGH);
	else
		digitalWrite(RS, LOW);
	   
	// Write the data the the data pins
	PORTF = data;

	// Generate a rising edge on pin Enable
	digitalWrite(E, HIGH);
	delayMicroseconds(10);
	digitalWrite(E, LOW);

	// Delay for delayMs for component ready
	delayMicroseconds(delayUs);
}

正式点亮

终于,所有最基本的函数已经写好了,现在只需要在主文件中包含自定义的头文件即可调用这些函数:

c
// LCD128+4.ino

#include "ST7920.h"

void setup() {
	pinMode(RST, OUTPUT); // Intialize RST pin (default HIGH)
	pinMode(E, OUTPUT); // Intialize E pin (default LOW)
	pinMode(RS, OUTPUT); // Intialize RS pin (default LOW)

	DDRF = 0xFF;  // Set data direction output
	PORTF = 0x00; // Intialize pin output

	DispClr();
	FuncSet(DL_8BIT, BASIC, CHAR);
	DispCtrl(DISP_ON, CURSOR_OFF, BLINK_OFF);
	DispClr();
	EntryMode(ADDR_INC, SHIFT_OFF);
}

这样就完成了屏幕的初始化流程(其实这些函数也可以打包在一起,放在一个用于初始化的函数中)。而此时如果想在屏幕上输出字符,可以直接通过WriteByte(),选择数据模式,数据就填要打印的 ASCII 字符即可。如果想输出字符串,可以定义一个char类型的指针,并且在每次写完数据后向后移动一位。在这里我写了个函数专门用于输出字符串:

c
void LcdPrint(const char *text) {
    while (*text) {
        WriteByte(DATA, *text, 10);
        text++;
    }
}

LcdPrint("Hello World!");

在屏幕上输出 Hello World!
在屏幕上输出 Hello World!

而如果此时直接输入中文字符串:

输入的原字符串是「你好!」,结果屏幕上出现的是乱码。这种展开属于是意料之外,情理之中了。
输入的原字符串是「你好!」,结果屏幕上出现的是乱码。这种展开属于是意料之外,情理之中了。

出现乱码 (Mojibake) 的原因很简单。西文部分由于是兼容 ASCII 码的,所以无论怎么样都没关系,能被正确解码;但是中文就不一样了。屏幕采用的编码是基于 GB2312 的双字节编码;而代码默认采用的是 UTF-8。缺少字符间的映射关系的话就会导致乱码。这部分的原理比较复杂,而且偏离了本文的主题,在此先按下不表。代替方案就是先通过一些工具,例如 Python 有个encode()`函数,将返回一串字节串,这时再将编码输入函数就可以正确显示:

python
("你好!").encode("GB2312") # Return b'\xc4\xe3\xba\xc3\xa3\xa1'

正确显示的字符串。
正确显示的字符串。

后记

这篇文章篇幅虽然很长,但内容还是相当基础的,只用到了这块屏幕的最基础的功能。在后面的文章中,我将会侧重于它的图形模式,显示自定义字形,以及开发一些用于绘图的函数。



  1. ST7920 Datasheet(PDF) - Sitronix Technology Co., Ltd. https://www.alldatasheet.com/datasheet-pdf/pdf/326219/SITRONIX/ST7920.html ↩︎

  2. ST7565 Datasheet(PDF) - Sitronix Technology Co., Ltd. https://www.alldatasheet.com/datasheet-pdf/pdf/326240/SITRONIX/ST7565.html ↩︎

  3. T6963C Datasheet(PDF) - Toshiba Semiconductor. https://www.alldatasheet.com/datasheet-pdf/pdf/31129/TOSHIBA/T6963C.html ↩︎

  4. KS0108B Datasheet(PDF) - Samsung semiconductor. https://www.alldatasheet.com/datasheet-pdf/pdf/37323/SAMSUNG/KS0108B.html ↩︎

  5. Arduino - PortManipulation | Arduino Documentation. https://docs.arduino.cc/retired/hacking/software/PortManipulation/ ↩︎

  6. C 预处理器 | 菜鸟教程. https://www.runoob.com/cprogramming/c-preprocessors.html ↩︎