前言
在上一篇文章中,已经实现了驱动这块屏幕的基本函数,使用的主要是字符模式,即往显存中写入内建字库的字符地址。而在本文章中,则会使用它的图形模式来实现各种显示效果。
硬件
如果要显示图片之类的内容,则必须进入图形模式,这样单独控制每个像素点的亮灭。那么,要怎么才能进入图形模式呢?根据手册上的介绍,功能设置指令的第 2 位RE低电平为基本指令集,高电平为扩展指令集。而图形模式则仅能在启用了扩展指令集的时候才能被切换。启用之后,还需要将 bit1 的G切换为高电平。这样一来,就能启用图形模式了。请注意,bit4 的DL,bit2 的RE以及 bit1 的G不能同时设置。先要改变DL或G,才能再是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()即可切换成图形模式:
// 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 的屏幕,所有的像素点并非独立寻址,而是被划分成 个这样的小块。
ST7920 有个特殊之处,即整个屏幕的地址并非连续的,而是被拆分成两个独立区域。请注意,不同型号的屏幕拆分方式可能会有差异。笔者使用的这块屏幕是拆分成了上下两个区域,每个区域的大小为 128 × 32 px。有些屏幕会拆分成左右两块 64 × 64 px 大小的区域。因此实际操作时请务必查清楚手册。在这里面,上半屏的水平地址从0x80开始,递增至0x87;下半屏从0x88开始,递增至0x8F。而每个水平地址中又包含 32 个垂直地址,都是从0x80开始,0x9F结束。也就是说,定位一个 chunk 需要依次发送两条命令用来在 GDRAM 中定位。
这些小块在 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(),传入小块所在的行和列的地址,以此来寻址:
// 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。随后,将y与0x3F取&按位与运算。为什么要在此处进行这个运算呢?这种方法叫做位掩码(Bit Mask),即选取要对哪些位进行操作。要操作的位就设为1,无需操作的就设为0。由于垂直地址的范围为0x00-0x3F,因此只需要对 bit0 - bit5 这 6 位进行操作。这样就算传入错误的数据,也不会向硬件发送无效地址。而对于x也是一样的,选取 bit0 - bit3 这 4 位进行操作。这样就可以准确地设定好要写入数据的地址了。
除了上面提到的内容以外,目前容易混淆的点还有如下几个:
- 先写入的是高字节还是低字节?
- 先填充完高字节/低字节以后,另一半的数据要怎么写入?
- 指针会在完成一个水平地址的写入以后,自动指向下一个地址吗?
这些问题其实手册上已经回答了,但是为了能够更加清晰地展示,笔者写了如下的测试程序:
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 处的像素全部点亮。
- 第二阶段 测试水平地址的低位。不再重新设置地址,而是接着写入。写入的数据是
0xF0,可以看到 X = 8 到 X = 11 处的像素被点亮。
- 第三阶段 测试地址自动递增。在不设置新地址的前提下,继续写入新的数据。可以看到屏幕上 X = 16 到 X = 23 处像素以
10101010的方式点亮;X = 24 到 X = 31 处像素以01010101的方式点亮。
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
- 每个水平地址要写入两次,写入低八位时无需设置新的地址;
- 写完一个水平地址中的数据后,指针会自动指向下一地址。
在研究清楚了屏幕的显示原理以后,接下来就是图像的数据。目前有很多将位图转化为二进制数据的取模软件,用什么软件就随意。在这个项目中,我是用的 image2cpp这个项目,可以直接在浏览器中运行,无需额外安装,而且可以直接生成适用于 Arduino 的数据结构。笔者绘制了工作室 Logo 的像素画版本:
在 image2cpp 中打开要转换的位图以后,Image Settings 中还有一些设置。具体情况取决于想要的效果。由于位图是黑色背景的,因此要在这个设置里面将 Background color 设为 Black。同时在 Output 中也要将 Code output format 设为 Arduino Code, single bitmap。点击最下方的 Generate Code 按钮后就可以了。
其实严格来说,这类数据最好储存在 SD 卡或者 ROM 中,避免占用单片机过多的空间。关于如何烧录、读取 EEPROM,使用 SD 卡模块,以后会有文章进行介绍。由于篇幅限制,在本篇文章中将直接将这部分数据放在头文件logo_image.h中。生成的内容大致如下:
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。在写入数据时应将两半部分分开处理。
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进行自增操作,这样就指针就自动指向了数组中下一字节的地址。
最终效果