前言
继上次的成功驱动了 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直接拉低。RS,E和RESET分别接 Arduino 的 2,3,4 引脚。
在大多数项目中,会使用pinMode()函数来控制引脚的数据方向,pinOut()函数来设置引脚的输出电平。除了这个方法以外,Arduino 的每个引脚都由三个 8 位寄存器控制,通常以 B,C,D 等字母命名,对应不同的端口:
DDRx, Data Direction Register控制引脚的数据方向。0 为输入,1 为输出。PORTx, Port Output Register控制着该端口引脚上的输出电平。0 为高电平,1 为低电平。PINx, Port Input Register读取输入引脚的状态。如果引脚是高电平,则3相应的位是 1;反之则为 0。[5]
虽然官方手册上并不推荐这种做法,但它的速度比通过for循环逐位写入快得多;而且这种底层操作对于深入理解硬件也大有裨益。不同的型号的芯片对于各个引脚的映射不一样,所以在使用时请务必先通过官方给出的 Pinout 确认各个引脚所属的端口。笔者使用的是 Arduino Mega 2560,采用的处理器是 ATmega2560。根据官方给出的引脚图,橘黄色背景的文字就是单片机的端口,而相同端口的引脚就可以直接使用寄存器来读写。
若要直接使用0-7号引脚作为数据接口,由于0号和1号引脚有编程的串行通信和调试的用途,直接将它们作为 GPIO可能会干扰下载或调试,因此不行;同时这些引脚也并不属于同一个端口,用寄存器来控制的话很麻烦。在这个项目中,笔者选择了A0-A7作为数据接口,因为它们可以直接使用PORTF和DDRF来控制;而且排成一排,可以直接插排线,看起来就整洁很多。虽然板子上这几个引脚旁标注了 ANALOG IN 的字样,这并不妨碍它们作为 GPIO 使用。
程序编写
硬件的准备工作完成了以后,接下来就是软件层面的事情了。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。初始化的流程一共要用到四条指令,现在就先声明四个函数用来专门控制:
// 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.c和file2.c同时包含了common.h这个头文件,那么在编译时就相当于common.h中的内容被重复定义,这时编译器就会报错。#ifndef相当于一个if ()语句,会检测一个特定的宏,通常来说是头文件的文件名,例如COMMON_H,有没有被定义。如果没有定义,就执行接下来的#define定义这个宏,以及接下来的其他代码;而如果被定义了,那么接下来所有的代码都不会被执行,直到#endif结束。
除此之外,这三条指令也可以凝缩成为一条#pragma once。这个更简洁,同时也不用担心宏名冲突的问题。虽然是非标准的,不过目前大部分的编译器都支持这条命令,除非是那种特别特别老旧的版本。头文件也应尽量减少相互包含,避免形成循环依赖,正确的做法是在.c或.cpp的源文件中包含。
确定好要使用的函数以后,接下来就是要传入的参数。这些参数都可以视作布尔类型。但是如果在调用的时候直接写DispCtrl(true, false, false)的话也很不直观。因此可以通过枚举enum来赋予这些参数一个有意义的名称。首先,根据每条指令要用到的参数,以这些参数为基准创建一个枚举类型,里面就可以列举出所有可能的情况。情况不外乎0和1两种,而这两种情况就可以分别取个易懂的名字:
// 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):
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);
现在,就可以这样来设置要发送的指令,例如:
FuncSet(DL_8BIT, BASIC, CHAR);
意思就相当一目了然了,即 8 位数据模式,基础指令集,字符模式。
不过,这还只是声明好了函数。函数功能的实现,或者定义 (Definition) 应该放在.c或.cpp的源文件中实现,例如再新建一个st7920.c的文件。
由于PORTF一次性传递的是一个字节的数据,因此需要进行一些位操作来更改这一字节内某一位的值。这一过程可以通过按位或 (Bitwise OR) 以及 位移 (Bitwise Shift) 来完成。例如,在FuncSet()中,需要将 bit4 和 bit2 分别设为1和0。读取到传入的参数以后,分别将这两个向高位左移 4 位和 2 位,随后再通过按位或把值赋给这一位,即:
uint8_t cmd = 0b00100000 | (DL << 4) | (RE << 2);
例如当DL = 1, RE = 0时:
0b00100000
0b00010000 (1 << 4 = 0b00010000)
OR 0b00000000
--------------
0b00110000
最终的计算结果就是要传输的指令。
最后,除了这些和 LCD 功能有关的指令以外,还得有个负责将它们写入的函数。要写入的内容分为指令和数据两种,同时还得控制写完后的延迟的时间,以确保正确写入。根据这些需求,就可以声明:
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引脚上的电平。这个引脚的功能可以理解为「发送数据」,高电平有效。这个函数的实现如下:
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);
}
正式点亮
终于,所有最基本的函数已经写好了,现在只需要在主文件中包含自定义的头文件即可调用这些函数:
// 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类型的指针,并且在每次写完数据后向后移动一位。在这里我写了个函数专门用于输出字符串:
void LcdPrint(const char *text) {
while (*text) {
WriteByte(DATA, *text, 10);
text++;
}
}
LcdPrint("Hello World!");
而如果此时直接输入中文字符串:
出现乱码 (Mojibake) 的原因很简单。西文部分由于是兼容 ASCII 码的,所以无论怎么样都没关系,能被正确解码;但是中文就不一样了。屏幕采用的编码是基于 GB2312 的双字节编码;而代码默认采用的是 UTF-8。缺少字符间的映射关系的话就会导致乱码。这部分的原理比较复杂,而且偏离了本文的主题,在此先按下不表。代替方案就是先通过一些工具,例如 Python 有个encode()`函数,将返回一串字节串,这时再将编码输入函数就可以正确显示:
("你好!").encode("GB2312") # Return b'\xc4\xe3\xba\xc3\xa3\xa1'
后记
这篇文章篇幅虽然很长,但内容还是相当基础的,只用到了这块屏幕的最基础的功能。在后面的文章中,我将会侧重于它的图形模式,显示自定义字形,以及开发一些用于绘图的函数。
ST7920 Datasheet(PDF) - Sitronix Technology Co., Ltd. https://www.alldatasheet.com/datasheet-pdf/pdf/326219/SITRONIX/ST7920.html ↩︎
ST7565 Datasheet(PDF) - Sitronix Technology Co., Ltd. https://www.alldatasheet.com/datasheet-pdf/pdf/326240/SITRONIX/ST7565.html ↩︎
T6963C Datasheet(PDF) - Toshiba Semiconductor. https://www.alldatasheet.com/datasheet-pdf/pdf/31129/TOSHIBA/T6963C.html ↩︎
KS0108B Datasheet(PDF) - Samsung semiconductor. https://www.alldatasheet.com/datasheet-pdf/pdf/37323/SAMSUNG/KS0108B.html ↩︎
Arduino - PortManipulation | Arduino Documentation. https://docs.arduino.cc/retired/hacking/software/PortManipulation/ ↩︎
C 预处理器 | 菜鸟教程. https://www.runoob.com/cprogramming/c-preprocessors.html ↩︎