1. 项目概述:从字符到图形的跨越
玩过单片机点液晶屏的朋友,对ST7920这颗控制器芯片应该都不陌生。它驱动的12864液晶模块,以其低廉的价格和自带中文字库的便利性,成为了无数电子爱好者、学生和工程师入门嵌入式显示的首选。我们最熟悉它的,莫过于调用几个简单的指令,就能在屏幕上显示出“欢迎使用”、“温度:25℃”这样的汉字和字符,非常省心。然而,当项目需求从简单的菜单、文本显示升级到需要绘制曲线、显示自定义图标甚至简单的动画时,很多人就会卡在“图形显示”这一关。你会发现,之前得心应手的字符显示指令突然不灵了,屏幕要么一片漆黑,要么布满雪花般的噪点,要么图形显示的位置完全不对。
这正是我当初踩过的坑。ST7920的图形显示(GDRAM)模式,其底层逻辑和寻址方式与字符显示(DDRAM)模式截然不同,它更像是在操作一块最原始的“点阵画布”。如果你不理解这块“画布”的像素是如何排布的,以及控制器如何在这块画布上“作画”,那么图形显示就会变得异常棘手。本文的目的,就是把我调试ST7920图形功能时积累的经验、遇到的典型问题以及最终的解决方案,系统地梳理出来。无论你是正在为课设发愁的学生,还是需要在产品中实现波形显示的工程师,希望这篇从实战中总结的笔记,能帮你绕过那些隐形的“坑”,快速、稳定地驱动起这块屏幕的图形功能。文章最后,我会附上一个经过验证的、可直接使用的图形显示测试程序,你可以把它当作模板,快速集成到你的项目中。
2. ST7920图形显示核心原理拆解
要驾驭ST7920的图形显示,绝不能停留在“调用库函数”的层面,必须深入理解其内部显存(GDRAM)的组织结构。你可以把ST7920控制的128x64点阵屏幕想象成一张坐标纸,但这张坐标纸的划分方式有些特别。
2.1 显存(GDRAM)的“立体”结构
ST7920的图形显示区(GDRAM)容量为256x32字节。注意,这里的单位是“字节”,而不是“像素”。一个字节有8个比特(bit),每个比特控制一个像素点的亮(1)或灭(0)。所以,在垂直方向(Y轴),它提供了32行,每行有256个字节。但这256个字节并不是连续控制128个像素点那么简单,这里存在一个关键的“垂直方向8点为一组”的设定。
更准确的理解方式是:将整个128x64的屏幕,在垂直方向上以8个像素点为高度,切割成8个“水平条带”。每个条带的高度是8像素,宽度是128像素。ST7920的GDRAM就是为这8个条带分别提供显存。每个条带对应一组垂直坐标(Vertical Coordinate, 或称“页地址” Page),从0到7。在每个条带内部,水平方向(X轴)的128个像素,则由16个字节来控制(因为128像素 / 8 bit每字节 = 16字节)。
因此,当你想要点亮屏幕上任意一个像素点(X, Y)时,你需要进行两步计算:
- 确定垂直条带(页地址):
Page = Y / 8。例如,Y坐标为15的像素点,位于15 / 8 = 1(取整)号条带,即第1页(从0开始计数)。 - 确定在该条带内的字节位置和位:
Byte_in_Page = X / 8,Bit_in_Byte = Y % 8。例如,X=20, Y=15的点,位于第1页的第20/8=2(取整)个字节,该字节内的第15%8=7位(通常最高位或最低位,取决于控制器定义,需查阅手册确认位序)。
注意:这是理解ST7920图形显示最核心、也最容易出错的概念。很多初学者直接套用其他液晶驱动芯片(如KS0108)的连续寻址思维,导致图形上下错乱或根本无法显示,根源就在于此。
2.2 图形显示指令与操作流程
ST7920通过一系列指令来切换模式和写入数据。对于图形显示,基本流程如下:
- 基本设置:首先发送指令,开启扩展指令集(因为图形显示功能在扩展指令集中),然后开启图形显示模式。
- 设置绘图地址:通过指令,先设定垂直坐标(即上述的“页地址”或“行地址”),再设定水平坐标(即字节地址)。这里地址的排列是交错的,这也是一个关键点。原始资料中给出的那串地址(0x80, 0x81,... 0x9f),正是这种交错寻址的体现。它并不是一个简单的线性递增序列,而是将上半屏(Y=0~31)和下半屏(Y=32~63)的地址交叉排列。具体来说,地址0x80~0x87对应上半屏第一行的16个字节,0x90~0x97对应下半屏第一行的16个字节,然后0x88~0x8f对应上半屏第二行,0x98~0x9f对应下半屏第二行,以此类推。理解这个序列,才能正确地将数据写入屏幕的指定位置。
- 写入图形数据:每次写入两字节数据。ST7920规定一次必须连续写入两个字节(16位)的数据到GDRAM。写入后,水平地址计数器会自动加1,指向下一个水平字节地址。当写完一行(16个双字节,即32字节)后,需要重新设置垂直和水平地址,开始写下一行。
3. 图形显示实战要点与避坑指南
理解了原理,我们进入实战环节。以下是几个决定成败的关键操作细节,每一个都是我从调试中总结出来的经验。
3.1 地址设置:交错寻址的精确映射
原始资料中给出的地址列表是核心。你必须建立一个清晰的映射关系:软件中的内存数组(或绘图缓冲区)索引,与屏幕上物理像素坐标(X,Y),以及ST7920指令地址(Addr)之间的映射关系。
我推荐在代码中定义一个二维数组作为显存缓冲区uint8_t GRAM[8][16]。其中第一维[8]对应8个垂直条带(页),第二维[16]对应每个条带水平方向的16个字节。 当你需要更新屏幕时,遍历这个缓冲区,按照0x80, 0x81,... 0x9f的顺序,将GRAM[0][0],GRAM[0][1]...GRAM[1][0],GRAM[1][1]... 的数据发送出去。这里的顺序至关重要,发送错一个,整个图形就会错位。
一个高效的编程技巧是,预先计算好地址查找表(LUT):
// 假设屏幕分为8行(页),每行16字节。地址交错排列。 const uint8_t GDRAM_Addr_LUT[8][16] = { {0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97}, // 页0 {0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F}, // 页1 // ... 依次类推,填充页2到页7的地址 };这样,在刷新函数中,你可以通过GDRAM_Addr_LUT[page][col]直接获取到要写入的地址,逻辑清晰不易出错。
3.2 清屏与防噪点:写入前的必要准备
这是原始资料中提到的关键一点,但原因需要深究。为什么在写GDRAM前要先写全0x00?ST7920的GDRAM在上电或模式切换后,其内容是不确定的(可能是随机值)。如果你直接写入新的图形数据,那么新数据字节中为0的位,会覆盖旧数据,使其熄灭;但新数据字节中为1的位,是“或”操作还是“写”操作呢?根据数据手册和实测,它通常是直接写入。问题在于,那些你“不打算操作”的位(即本次写入未覆盖到的内存区域),它们原有的随机值(可能是1)会导致屏幕上出现你预期之外的亮点,也就是“噪点”。
因此,可靠的作法是:在首次启用图形显示或需要完全清屏时,先向整个GDRAM区域写入0x00。具体操作就是遍历所有地址(从0x80到0x9F对应的所有页和列),连续写入数据0x00。这相当于给整个图形画布铺上了一层黑色的底色。之后你再在上面“作画”(写入你的图形数据),就能保证画布是干净的,不会有多余的噪点。
3.3 开关图形显示:消除写入时的闪烁
原始资料中“每次写16位数据前关闭图形显示,写完后开启”的建议,其目的是为了消除数据传输过程中屏幕上的闪烁或残影。 当你向GDRAM写入数据时,控制器内部的总线正在忙碌。如果此时图形显示是开启的,屏幕会不断地从GDRAM中读取数据来刷新显示。这个读取过程可能会与写入过程发生冲突,导致屏幕在瞬间显示出不完整或错误的数据,表现为闪烁或短暂的乱码。
因此,一个更优的策略是:在批量更新图形数据前,一次性关闭图形显示;待所有数据写入完毕后,再一次性开启图形显示。而不是在每写入16位数据时都开关一次。频繁地开关显示指令,本身也会增加通信开销,并可能因指令执行延迟导致其他问题。对于128x64的全屏刷新,这个“批量操作”的收益非常明显,屏幕会从“全黑 -> 瞬间显示完整图形”,而不是“闪烁多次后显示图形”。
3.4 Keil环境下汉字“三”的显示BUG与修复
这是一个非常经典且隐蔽的坑。如果你使用Keil MDK开发环境,并且用C代码直接定义中文字符串(如char str[] = "三";),编译后通过ST7920显示,可能会发现“三”字显示为空白或乱码。根本原因:在Keil的编译器(ARMCC或AC6)的某些版本中,对于源代码中的中文字符,其默认编码处理方式可能存在一个问题,特别是当字符的内码(GB2312/GBK)中包含0xFD这个值时。汉字“三”的GB2312编码正好是0xC8FD。在某些编译设置下,编译器会将0xFD识别为一个特殊字符(可能与调试信息有关),导致其在最终生成的二进制文件中被错误处理或丢失。
解决方案:
- 安装补丁(传统方法):正如原始资料所述,找到Keil安装目录下的
C:\Keil_v5\ARM\ARMCC\bin(具体路径因版本而异),寻找名为“ArmCC-CodePage-Problem-Fix.exe”或类似的补丁程序并运行。这个补丁会修改编译器的后端,使其正确处理0xFD字节。 - 编码转换(通用方法):更可靠且不依赖特定编译器的方法是,在代码中不使用直接的中文字符串,而是使用十六进制数组来表示汉字。例如:
这种方法完全规避了编译器对源代码中文字符的解析,百分百可靠,且便于代码移植。// “三”字的GB2312编码:0xC8, 0xFD uint8_t hanzi_san[] = {0xC8, 0xFD}; ST7920_WriteData(hanzi_san, 2); - 检查编译器设置:在Keil的
Options for Target -> C/C++ -> Misc Controls中,可以尝试添加--locale=english或--multibyte_chars等参数,但效果因版本而异,最推荐的还是方法2。
4. 完整的图形显示驱动实现与测试程序
下面我将提供一个基于STM32 HAL库(但逻辑通用)的ST7920图形显示驱动示例,包含初始化、画点、清屏和显示一个测试图案的函数。
4.1 硬件连接与底层通信函数
假设使用8位并行接口连接(PSB引脚接高电平)。连接线包括DB0-DB7(数据线),RS(命令/数据选择),RW(读/写选择),E(使能信号),以及RST(复位,可选)。 首先实现最基本的写命令和写数据函数:
// 引脚定义宏,请根据实际硬件连接修改 #define LCD_RS_GPIO_Port GPIOA #define LCD_RS_Pin GPIO_PIN_0 // ... 定义RW, E, D0-D7等引脚 // 延时微秒函数(需要根据MCU主频实现) void Delay_us(uint16_t us); // 写命令函数 void ST7920_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(LCD_RS_GPIO_Port, LCD_RS_Pin, GPIO_PIN_RESET); // RS=0,命令 HAL_GPIO_WritePin(LCD_RW_GPIO_Port, LCD_RW_Pin, GPIO_PIN_RESET); // RW=0,写 // 将cmd输出到数据线D0-D7 HAL_GPIO_WritePin(LCD_D0_GPIO_Port, LCD_D0_Pin, (cmd & 0x01)?GPIO_PIN_SET:GPIO_PIN_RESET); // ... 依次输出D1到D7 // 产生使能信号E的下降沿 HAL_GPIO_WritePin(LCD_E_GPIO_Port, LCD_E_Pin, GPIO_PIN_SET); Delay_us(1); // 保持时间,典型值>450ns HAL_GPIO_WritePin(LCD_E_GPIO_Port, LCD_E_Pin, GPIO_PIN_RESET); Delay_us(40); // 命令执行时间,典型值>37us } // 写数据函数(与写命令类似,仅RS置1) void ST7920_WriteData(uint8_t dat) { HAL_GPIO_WritePin(LCD_RS_GPIO_Port, LCD_RS_Pin, GPIO_PIN_SET); // RS=1,数据 HAL_GPIO_WritePin(LCD_RW_GPIO_Port, LCD_RW_Pin, GPIO_PIN_RESET); // ... 输出数据到端口 HAL_GPIO_WritePin(LCD_E_GPIO_Port, LCD_E_Pin, GPIO_PIN_SET); Delay_us(1); HAL_GPIO_WritePin(LCD_E_GPIO_Port, LCD_E_Pin, GPIO_PIN_RESET); Delay_us(40); }4.2 初始化与图形模式设置
void ST7920_Init(void) { // 硬件复位(如果连接了RST引脚) HAL_GPIO_WritePin(LCD_RST_GPIO_Port, LCD_RST_Pin, GPIO_PIN_RESET); HAL_Delay(50); // 复位保持时间 HAL_GPIO_WritePin(LCD_RST_GPIO_Port, LCD_RST_Pin, GPIO_PIN_SET); HAL_Delay(50); // 等待稳定 // 功能设置:8位接口,基本指令集 ST7920_WriteCmd(0x30); HAL_Delay(1); // 显示开关控制:开显示,关光标,关反白 ST7920_WriteCmd(0x0C); HAL_Delay(1); // 清屏 ST7920_WriteCmd(0x01); HAL_Delay(2); // 清屏需要较长延时 // 进入扩展指令集 ST7920_WriteCmd(0x34); HAL_Delay(1); // 开启图形显示模式(在扩展指令集中) ST7920_WriteCmd(0x36); HAL_Delay(1); } // 清空图形显示区(GDRAM) void ST7920_ClearGraphic(void) { uint8_t page, col; ST7920_WriteCmd(0x34); // 确保在扩展指令集 // 关闭图形显示,防止写入时闪烁(批量操作前关闭一次即可) ST7920_WriteCmd(0x30); ST7920_WriteCmd(0x0C); // 关图形显示,实际是关显示,但保留之前设置 for (page = 0; page < 8; page++) { // 垂直方向8页(0-7) for (col = 0; col < 16; col++) { // 水平方向16字节(0-15) // 设置GDRAM地址:先垂直坐标,再水平坐标 // 垂直坐标:0x80 + page (对于上半屏), 交错寻址需查表,这里简化演示线性写入 // 实际应根据地址表操作。这里演示原理:先写垂直地址,再写水平地址。 ST7920_WriteCmd(0x80 | page); // 设置垂直地址(页地址) ST7920_WriteCmd(0x80 | (col * 2)); // 设置水平地址(字节地址,假设连续) // 写入两个字节的0x00 ST7920_WriteData(0x00); ST7920_WriteData(0x00); } } // 重新开启显示(包括图形) ST7920_WriteCmd(0x30); ST7920_WriteCmd(0x0C); // 开显示 ST7920_WriteCmd(0x34); ST7920_WriteCmd(0x36); // 开图形显示 }4.3 画点函数与缓冲区的使用
直接操作GDRAM效率低且易闪屏,最佳实践是使用一个内存缓冲区(GRAM[8][16]),所有画图操作先在缓冲区进行,最后一次性刷新到屏幕。
uint8_t GRAM[8][16] = {0}; // 全局图形缓冲区 // 在缓冲区中画点 (x: 0-127, y: 0-63) void ST7920_DrawPoint_Buf(uint8_t x, uint8_t y, uint8_t mode) { // mode: 1点亮,0熄灭 uint8_t page, bit_mask, byte_pos; if (x >= 128 || y >= 64) return; // 边界检查 page = y / 8; // 确定垂直页(0-7) byte_pos = x / 8; // 确定在该页中的字节位置(0-15) bit_mask = 1 << (y % 8); // 确定在字节中的位(假设低位对应Y坐标低位,需根据屏幕调整,有时是1<<(7-(y%8))) if (mode) { GRAM[page][byte_pos] |= bit_mask; // 置1,点亮 } else { GRAM[page][byte_pos] &= ~bit_mask; // 清0,熄灭 } } // 将整个缓冲区刷新到屏幕GDRAM(根据交错地址表) void ST7920_RefreshScreen(void) { uint8_t page, col; const uint8_t addr_lut[8][16] = { /* 这里填入前面定义的地址查找表 */ }; ST7920_WriteCmd(0x34); // 扩展指令集 ST7920_WriteCmd(0x30); ST7920_WriteCmd(0x08); // 关闭显示(包括图形和字符),这是最彻底的防闪烁方式 for (page = 0; page < 8; page++) { for (col = 0; col < 16; col++) { // 使用查找表设置地址 ST7920_WriteCmd(addr_lut[page][col]); // 该指令同时设置了垂直和水平地址 // 连续写入两个字节(ST7920图形写入固定为16位) ST7920_WriteData(GRAM[page][col]); // 注意:这里简化了,实际addr_lut[page][col]可能只对应一个水平地址。 // 更严谨的做法是,根据地址设置指令分两次设置垂直和水平地址,然后写入双字节。 // 以下是更严谨的写法示例(假设地址表存储的是完整的指令字节): // ST7920_WriteCmd(addr_lut[page][col]); // 假设这个cmd已经包含了设置 // ST7920_WriteData(GRAM[page][col]); // ST7920_WriteData(GRAM[page][col+1]); // 注意双字节写入,可能需要处理列+1 // col++; // 内层循环步进需要调整 } } ST7920_WriteCmd(0x30); ST7920_WriteCmd(0x0C); // 开启显示 ST7920_WriteCmd(0x34); ST7920_WriteCmd(0x36); // 开启图形显示 }4.4 图形显示测试程序
下面是一个在屏幕中央画一个“田”字格方框的测试函数:
void ST7920_Test_Graphic(void) { uint8_t i; // 1. 初始化并清屏 ST7920_Init(); ST7920_ClearGraphic(); // 清空缓冲区 memset(GRAM, 0, sizeof(GRAM)); // 2. 在缓冲区中画图:一个矩形框,左上角(20,10),右下角(100,50) // 画上边和下边 for (i = 20; i <= 100; i++) { ST7920_DrawPoint_Buf(i, 10, 1); ST7920_DrawPoint_Buf(i, 50, 1); } // 画左边和右边 for (i = 10; i <= 50; i++) { ST7920_DrawPoint_Buf(20, i, 1); ST7920_DrawPoint_Buf(100, i, 1); } // 画中间十字线 for (i = 10; i <= 50; i++) { ST7920_DrawPoint_Buf(60, i, 1); // 垂直中线 } for (i = 20; i <= 100; i++) { ST7920_DrawPoint_Buf(i, 30, 1); // 水平中线 } // 3. 将缓冲区内容刷新到屏幕 ST7920_RefreshScreen(); // 4. 保持显示,可以加入延时观察 HAL_Delay(3000); // 5. 演示清屏 memset(GRAM, 0, sizeof(GRAM)); ST7920_RefreshScreen(); }在主函数中调用ST7920_Test_Graphic(),你应该能看到屏幕中央出现一个由像素点构成的“田”字格方框。这个测试程序验证了画点、缓冲区和刷新功能的正确性。
5. 常见问题排查与调试心得
即使按照上述步骤操作,你可能还是会遇到一些问题。下面是我总结的常见故障现象、原因及解决方法。
5.1 屏幕全黑,无任何显示
- 检查电源和背光:首先确认VCC和背光引脚(LED+, LED-)供电正常。背光不亮容易误判为屏不工作。
- 检查初始化序列:确保严格按照初始化流程:基本指令集 -> 开显示 -> 清屏 -> 扩展指令集 -> 开图形显示。每一步后建议加足够延时(ms级)。
- 检查PSB引脚电平:PSB引脚决定并行/串行模式。并行模式必须接高电平(VCC)。如果接错,控制器无法接收指令。
- 检查时序:特别是使能信号E的脉宽和保持时间。如果MCU速度太快,指令之间的延时不足,会导致控制器无法正确响应。尝试将所有
Delay_us(40)增加到Delay_us(100)甚至HAL_Delay(1)进行测试。
5.2 图形显示错位、乱码或只有部分显示
- 地址映射错误:这是最常见的原因。百分之九十的图形错乱问题都源于地址映射不对。请反复核对你的
GRAM缓冲区索引[page][col]与发送地址的顺序是否完全匹配addr_lut[page][col]。一个简单的验证方法是:写一个函数,只点亮屏幕的四个角(如(0,0), (127,0), (0,63), (127,63)),观察它们是否出现在正确位置。 - 字节内位顺序错误:在
DrawPoint_Buf函数中,bit_mask的计算1 << (y % 8)可能与你屏幕的物理连接相反。有些屏幕模块是高位对应Y坐标低位,即1 << (7 - (y % 8))。如果图形上下颠倒(在一个8像素高的条带内),请尝试修改这个位序。 - 未关闭显示导致写入错位:在批量写入GDRAM时,如果没有关闭显示,可能会因内部时序竞争导致数据写入错误的地址。务必确保在
ST7920_RefreshScreen()开始时关闭显示,结束时再打开。
5.3 屏幕有规律的点状噪点
- GDRAM未初始化:这是最可能的原因。确保在首次图形显示前或清屏时,执行了
ST7920_ClearGraphic()函数,向所有GDRAM地址写入了0x00。 - 电源噪声:MCU和液晶屏的电源不稳定或纹波过大,可能导致数据读写错误。尝试在VCC和GND之间并联一个10uF和0.1uF的电容,并确保地线连接良好。
- 总线干扰:如果数据线较长且未加保护,容易受到干扰。尽量缩短连接线,或为数据线串联一个22欧姆到100欧姆的电阻。
5.4 图形刷新速度慢,闪烁严重
- 频繁开关显示:避免在每次画点或画线后都刷新屏幕。务必使用缓冲区机制。所有绘图操作在内存缓冲区
GRAM中完成,只有需要更新屏幕时才调用一次ST7920_RefreshScreen()。这是提升刷新效率、消除闪烁的关键。 - 通信速度瓶颈:并行接口模式下,每次写入一个字节(或双字节)都需要多个GPIO操作和延时。如果刷新全屏(128x64/8 = 1024字节),即使每个字节操作只需100us,也需要约100ms,人眼能感知到刷新过程。优化方法:
- 使用DMA(如果MCU和硬件连接支持)来搬运数据到GPIO端口。
- 优化底层
ST7920_WriteData函数,使用寄存器直接操作替代HAL库函数,减少函数调用开销。 - 如果对实时性要求不高,可以只刷新屏幕上发生变化的部分区域,而不是全屏刷新。
5.5 与字符显示混合使用异常
ST7920允许同时开启文本显示(DDRAM)和图形显示(GDRAM)。但需要注意:
- 显示优先级:图形显示层通常位于字符显示层之下。也就是说,如果同一个位置既有图形点被点亮,又有字符显示,字符会覆盖图形。
- 独立控制:通过指令
0x36开图形显示,0x34关图形显示(但仍在扩展指令集)。文本显示则由0x0C和0x08控制。可以独立开关。 - 地址独立:DDRAM和GDRAM的地址空间是完全独立的,操作互不影响。你只需要在操作前,通过指令
0x30和0x34切换基本/扩展指令集即可。
最后,调试ST7920图形显示,逻辑分析仪或者示波器是极好的帮手。你可以抓取RS、RW、E以及数据线DB0-DB7的波形,与数据手册的时序图进行比对,精确判断是命令写错了、数据错了还是时序不满足。没有仪器的话,耐心和细致的代码审查,加上本文提供的这些经验点,也足以解决绝大部分问题了。