驱动 LCD12864 ST7920(二):图像的显示

驱动 LCD12864 ST7920(二):图像的显示

在 LCD 12864 ST7920 上显示图像。 Display an image on LCD12864 ST7920.

前言

上一篇文章中,已经实现了驱动这块屏幕的基本函数,使用的主要是字符模式,即往显存中写入内建字库的字符地址。而在本文章中,则会使用它的图形模式来实现各种显示效果。

硬件

如果要显示图片之类的内容,则必须进入图形模式,这样单独控制每个像素点的亮灭。那么,要怎么才能进入图形模式呢?根据手册上的介绍,功能设置指令的第 2 位RE低电平为基本指令集,高电平为扩展指令集。而图形模式则仅能在启用了扩展指令集的时候才能被切换。启用之后,还需要将 bit1 的G切换为高电平。这样一来,就能启用图形模式了。请注意,bit4 的DL,bit2 的RE以及 bit1 的G不能同时设置。先要改变DLG,才能再是RE。要输入的指令如下:

指令Instruction RS RW D7 D6 D5 D4 D3 D2 D1 D0
功能设置Function Set 0 0 0 0 1 1 0 1 1 0

这样,通过调用之前写好的函数FuncSet()即可切换成图形模式:

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

FuncSet(DL_8BIT, EXTENDED, GRAPHIC);

软件

那么,屏幕是如何显示图像的呢?ST7920 通过 GDRAM (Graphic Display RAM) 储存图像数据。但是内存与像素点的对应关系略有些复杂。总的来说,ST7920 是以 16 位(即两个字节)为最小单位进行存储和寻址的,对应控制屏幕上一个宽 16 px,高 1 px 的区域。而屏幕的数据位宽度只有 8 bit(一个字节),因此,每个地址需要进行两次写入操作。对于一个大小为 128 × 64 px 的屏幕,所有的像素点并非独立寻址,而是被划分成 12816×641=8×64\frac{128}{16} \times \frac{64}{1} = 8 \times 64 个这样的小块。

屏幕上的像素点是以宽 16 px,高 1 px 的小块形式储存在 GDRAM 中的。每个小块都有唯一的地址。
屏幕上的像素点是以宽 16 px,高 1 px 的小块形式储存在 GDRAM 中的。每个小块都有唯一的地址。

ST7920 有个特殊之处,即整个屏幕的地址并非连续的,而是被拆分成两个独立区域。请注意,不同型号的屏幕拆分方式可能会有差异。笔者使用的这块屏幕是拆分成了上下两个区域,每个区域的大小为 128 × 32 px。有些屏幕会拆分成左右两块 64 × 64 px 大小的区域。因此实际操作时请务必查清楚手册。在这里面,上半屏的水平地址从0x80开始,递增至0x87;下半屏从0x88开始,递增至0x8F。而每个水平地址中又包含 32 个垂直地址,都是从0x80开始,0x9F结束。也就是说,定位一个 chunk 需要依次发送两条命令用来在 GDRAM 中定位。

屏幕每一小块的地址示意图。屏幕被分为上下两个部分,垂直地址从 0x80 开始递增至 0x9F,而位于同一行小块的列地址不变。水平地址上半部分从 0x80 开始,下半从 0x88 开始。
屏幕每一小块的地址示意图。屏幕被分为上下两个部分,垂直地址从 0x80 开始递增至 0x9F,而位于同一行小块的列地址不变。水平地址上半部分从 0x80 开始,下半从 0x88 开始。

这些小块在 GDRAM 中有唯一的字节地址。当我们要显示图像时,只需先通过指令指定要操作的 GDRAM 地址,然后向该地址写入对应的数据,即可控制这些像素点的亮灭。根据手册,寻址操作需要依次输入两条命令分别设置垂直和水平地址:

指令Instruction RS RW D7 D6 D5 D4 D3 D2 D1 D0
设置垂直 GDRAM 地址Set Verticle GDRAM Addr 0 0 1 0 AC5 AC4 AC3 AC2 AC1 AC0
设置水平 GDRAM 地址Set Horizontal GDRAM Addr 0 0 1 0 0 0 AC3 AC2 AC1 AC0

ACx就是地址计数器(Address Counter)。由此也可以看出,垂直地址的范围为0x00-0x3F,水平地址的范围为0x00-0x0F。现在就可以定义函数SetGraphicAddr(),传入小块所在的行和列的地址,以此来寻址:

c
// Set the Graphic RAM (GDRAM) Address
void SetGraphicAddr(uint8_t x, uint8_t y) {
    // Set vertical address
    WriteByte(INST, 0x80 | (y & 0x3F), 10);
    // Set horizontal address
    WriteByte(INST, 0x80 | (x & 0x0F), 10);
}

指令码中的0x80即将 bit7 设为1。随后,将y0x3F&按位与运算。为什么要在此处进行这个运算呢?这种方法叫做位掩码(Bit Mask),即选取要对哪些位进行操作。要操作的位就设为1,无需操作的就设为0。由于垂直地址的范围为0x00-0x3F,因此只需要对 bit0 - bit5 这 6 位进行操作。这样就算传入错误的数据,也不会向硬件发送无效地址。而对于x也是一样的,选取 bit0 - bit3 这 4 位进行操作。这样就可以准确地设定好要写入数据的地址了。

除了上面提到的内容以外,目前容易混淆的点还有如下几个:

  1. 先写入的是高字节还是低字节?
  2. 先填充完高字节/低字节以后,另一半的数据要怎么写入?
  3. 指针会在完成一个水平地址的写入以后,自动指向下一个地址吗?

这些问题其实手册上已经回答了,但是为了能够更加清晰地展示,笔者写了如下的测试程序:

c
void setup() {
    Serial.begin(9600);
    LCD_Reset();
    LCD_Initialize();

    LCD_FuncSet(DL_8BIT, EXTENDED, GRAPHIC);
    Serial.println("Start Test...");

    LCD_ClearGdram();
    delay(1000);

    // Phase 1: Phase 1: Higher byte test on addrX 0x80
    LCD_SetGdramAddr(0x80, 0x80);

    // Write first byte 0xFF
    LCD_WriteByte(DATA, 0xFF, 100);
    Serial.println("Phase 1: Written 1st Byte to X=0. Expect: Pixels 0-7 ON.");
    delay(2000);

    // Phase 2: Lower byte test on addrX 0x80
    LCD_WriteByte(DATA, 0xF0, 100);
    Serial.println("Phase 2: Written 2nd Byte to X=0. Expect: Pixels 8-15 show 11110000.");
    delay(2000);

    // Phase 3: Address automatically increment
    LCD_WriteByte(DATA, 0xAA, 100); // 10101010
    LCD_WriteByte(DATA, 0x55, 100); // 01010101
    Serial.println("Phase 3: Written 2 Bytes to X=1 (Auto-increment). Expect: Pixels 16-31 show pattern.");
}
  • 第一阶段 测试水平地址的高位。这会让X = 0 到 X = 7 处的像素全部点亮。

测试的第一阶段。X = 0 到 X = 7 处的像素全部点亮。
测试的第一阶段。X = 0 到 X = 7 处的像素全部点亮。

  • 第二阶段 测试水平地址的低位。不再重新设置地址,而是接着写入。写入的数据是0xF0,可以看到 X = 8 到 X = 11 处的像素被点亮。

测试的第二阶段。X = 8 到 X = 11 处的像素被点亮。
测试的第二阶段。X = 8 到 X = 11 处的像素被点亮。

  • 第三阶段 测试地址自动递增。在不设置新地址的前提下,继续写入新的数据。可以看到屏幕上 X = 16 到 X = 23 处像素以10101010的方式点亮;X = 24 到 X = 31 处像素以01010101的方式点亮。

测试的第三阶段。X = 16 到 X = 23 处像素以10101010的方式点亮;X = 24 到 X = 31 处像素以01010101的方式点亮。
测试的第三阶段。X = 16 到 X = 23 处像素以10101010的方式点亮;X = 24 到 X = 31 处像素以01010101的方式点亮。

对于第一个问题,阶段二输入1111 0000,而结果是 X = 8 到 X = 11 处的像素,也就是这个字节内靠左的部分被点亮。这就可以说明像素是按照**大端序(Big Endian)**的方式组织的。

在这里插一嘴计算机中字节储存(Endianness),这是个基础又关键的概念,它定义了多字节数据在存储或传输时的排列顺序。这个名字的来源很有意思,源自英国作家乔纳森·斯威夫创作的长篇小说《格列佛游记》中小人国的居民因争论该从大端还是小端敲开鸡蛋而分成两派,引发了一场荒诞的战争,后来这个典故被计算机科学家 Danny Cohen 在他的论文《论圣战与求和 On Holy Wars and a Plea for Peace》中借用,用来命名多字节数据存储时的分歧,这也就是大端序和小端序的来历。[1]简单来说,大端序就是一个数的最低位存在最高位的地址上,而小端序则恰恰相反。例如对于一个这样的整数0xAABBCCDD

字节地址Byte Address 0 1 2 3
小端序Small Endian 0xDD 0xCC 0xBB 0xAA
大端序Big Endian 0xAA 0xBB 0xCC 0xDD

回到正题,通过测试的结果,我们可以得出剩下的问题的答案: r

  1. 每个水平地址要写入两次,写入低八位时无需设置新的地址;
  2. 写完一个水平地址中的数据后,指针会自动指向下一地址。

在研究清楚了屏幕的显示原理以后,接下来就是图像的数据。目前有很多将位图转化为二进制数据的取模软件,用什么软件就随意。在这个项目中,我是用的 image2cpp这个项目,可以直接在浏览器中运行,无需额外安装,而且可以直接生成适用于 Arduino 的数据结构。笔者绘制了工作室 Logo 的像素画版本:

像素画版本的 Logo。尽可能地保留了原 Logo 的特色。
像素画版本的 Logo。尽可能地保留了原 Logo 的特色。

在 image2cpp 中打开要转换的位图以后,Image Settings 中还有一些设置。具体情况取决于想要的效果。由于位图是黑色背景的,因此要在这个设置里面将 Background color 设为 Black。同时在 Output 中也要将 Code output format 设为 Arduino Code, single bitmap。点击最下方的 Generate Code 按钮后就可以了。

其实严格来说,这类数据最好储存在 SD 卡或者 ROM 中,避免占用单片机过多的空间。关于如何烧录、读取 EEPROM,使用 SD 卡模块,以后会有文章进行介绍。由于篇幅限制,在本篇文章中将直接将这部分数据放在头文件logo_image.h中。生成的内容大致如下:

c
const unsigned char epd_bitmap_Sprite_0002 [] PROGMEM = {
    0x00, 0x00, 0x00, 0x00, 0x00, 
    ...
};

这一坨东西就是用来存放位图数据的数组。诸位读者可能注意到了,这个数组后面还有个PROGMEM的关键字。这是什么意思呢?Arduino 上有几种不同类型的内存,这些内存读写速度以及容量各不相同,各司其职:

  • 闪存(Flash),读写较快,存储编译后的程序代码和const常量;
  • 静态随机存取存储器(SRAM),读写速度最快的,用于储存程序运行时创建和修改的变量、对象实例、堆栈等;
  • 电可擦除可编程只读存储器(EEPROM),读写最慢,负责储存永久性配置参数等。

由于 SRAM 的容量有限,例如在 Arduino Mega 2560 上仅为 8 KB,因此储存了图像数据的数组不应占用 SRAM 的空间。而 Flash 的容量是最大的,有 256 KB,因此可以将数组存放在此处。PROGMEM的作用正是将数组存放到 Flash 中。如果要读取数据,方式会和 SRAM 略有不同。这点会稍后提到。

如图所示,每行的地址从0x80开始,按照垂直向下的方向递增到0x9F,一共 32 行。而同一行里面的小块共用同一个列地址,即0x80。其他的列也是如此。在下半部分的第一列中,其列地址为0x88。在写入数据时应将两半部分分开处理。

c
void Lcd_PrintImg(const unsigned char* bitmap) {
	// The upper part
	for (uint8_t i = 0; i < 32; i++) {
		SetGraphicAddr(0x80, 0x80 + i);
		for (uint8_t j = 0; j < 16; j++) {
			uint8_t data = pgm_read_byte(bitmap++);
			WriteByte(DATA, data, 10);
		}
	}

	// The lower part
	for (uint8_t i = 0; i < 32; i++) {
		SetGraphicAddr(0x88, 0x80 + i);
		for (uint8_t j = 0; j < 16; j++) {
			uint8_t data = pgm_read_byte(bitmap++);
			WriteByte(DATA, data, 10);
		}
	}
}

为了读取储存在 Flash 中的图像数据,须使用pgm_read_byte()。这个函数位于<avr/pgmspace.h>中。传参是一个地址。因此调用的时候传入Lcd_PrintImg(epd_bitmap_Sprite_0002)即可;而bitmap++的意思则是,当自增运算符++后置时,会先将bitmap当前的值传递给pgm_read_byte() 函数。函数执行完毕以后,才会对bitmap进行自增操作,这样就指针就自动指向了数组中下一字节的地址。

最终效果

效果相当不错。
效果相当不错。


  1. Endianness - Wikipedia ↩︎