☰
ST7920 12864 驱动实战:CGRAM 自定义字符与 GDRAM 整屏绘图
2026/11/5 4:22:35 网站建设 项目流程

简介:这份压缩包面向使用ST7920控制器驱动12864点阵液晶的嵌入式开发者与单片机学习者,聚焦自定义字符、反白显示、字体字库及菜单图片的显示实现。包内共14个文件,约43KB,以Keil工程文件、C源码、hex烧录文件以及LST、M51等编译列表与映射文件为主,另有工程备份文件,便于直接打开工程、对照编译结果并复用代码框架。已有1123人学习下载,适合需要快速验证ST7920显示方案的读者参考。内容围绕12864分辨率下的字符模板写入、字库查找表、指定坐标输出字符串和位图逐像素发送等环节展开,读者可从中获取初始化流程、显示函数组织方式及反白模式切换思路,并借助工程与烧录文件缩短调试周期,理解图形界面在小型LCD上的落地方法。

1. 一块带字库的 12864,卡住我的不是汉字而是图标

去年做一个面板项目,屏用的是 12864、控制器 ST7920、带中文字库的那种。最开始进度很顺:上电、初始化、写两行汉字,十分钟就看到「系统就绪」四个字,当时心想这屏真省事,汉字都不用自己取模。真正的麻烦在后头——面板上要显示一个电池图标,四格电量要能变,还要一个自定义的 Logo。带字库的屏,字库里没有我的图标。

很多人到这一步就以为要换屏,换成纯图形点的 12864。其实 ST7920 留了两条路:一条是在字库里挤 4 个 16×16 的自定义字符坑位(CGRAM),够放电池、信号格、箭头这类小图标;另一条是切到绘图模式,往 GDRAM 里按位写,整屏 128×64 随便画。这两条路我都踩过坑,下面按「接线—初始化—自定义字符—整屏图片」的顺序,把能直接抄的代码和翻车点一次说清。

2. 硬件接口与底层时序:三根线和十一根线的差别

ST7920 支持并行和串行两种接法,选哪种不是风格问题,是引脚预算和刷新速度的取舍。8 位并口要占 11 根 IO,但写一个字节就是一次总线操作;串行只占 3 根,代价是每个字节要拆成 3 个字节串出去,刷全屏图片会明显变慢。

2.1 引脚定义与 PSB 的模式选择

模块上那根 PSB 是模式开关,接高电平走并口,接低电平走串行,中间悬空是最常见的翻车原因——上电后模块可能按另一种模式在等指令,你发什么都石沉大海。我一般直接在排针上焊死。

引脚并口 8 位模式串行模式说明
RS指令/数据选择CS(片选)0 写指令、1 写数据
R/W读/写SID(数据输入)串行时是单向数据线
E使能脉冲SCLK(时钟)数据在上升沿被采样
DB0–DB78 位数据总线不用,接地串行只用到 3 根
PSB接 VCC接 GND模式选择,不能悬空
RST复位,低有效同左有的模组没引出来
V0对比度同左接电位器中点,别直接接地

V0 这一脚值得多说一句。5V 模组通常要求 V0 在 0 到 -10V 之间的负压,模组板上一般自带电荷泵,你只需要一个 10k 电位器分压。调不出字的时候,九成不是代码问题,是 V0 电压不对——拧电位器的过程中会先出现一排黑块,再变清晰,那个点才对。

2.2 并口模式下最小的三个函数

不管后面写多少功能,并口底层只有三个动作:等空闲、写指令、写数据。把这三个写对,上层就顺了。

/* 并口 8 位模式,PSB 接 VCC。宏按你自己的 MCU 补全 */ static void lcd_bus_write(uint8_t dat) { LCD_DB_OUT(dat); /* 8 根数据线一次性输出 */ LCD_E_H(); delay_us(1); /* ST7920 的 E 脉宽要求 450ns 以上,1us 很保险 */ LCD_E_L(); } static uint8_t lcd_read_status(void) { uint8_t sta; LCD_RS_L(); /* RS=0:指令寄存器 */ LCD_RW_H(); /* RW=1:读 */ LCD_DB_IN(); /* 数据口切成输入,很多 MCU 要单独配方向 */ LCD_E_H(); delay_us(1); sta = LCD_DB_READ(); LCD_E_L(); LCD_DB_OUT(); /* 读完立刻切回输出,别留悬念 */ return sta; } static void lcd_wait_idle(void) { uint16_t guard = 0; /* bit7 是忙标志,1 表示还在处理上一条指令 */ while ((lcd_read_status() & 0x80) && (++guard < 5000)) { } } void lcd_write_cmd(uint8_t cmd) { lcd_wait_idle(); LCD_RS_L(); LCD_RW_L(); lcd_bus_write(cmd); } void lcd_write_data(uint8_t dat) { lcd_wait_idle(); LCD_RS_H(); LCD_RW_L(); lcd_bus_write(dat); }

lcd_wait_idle里那个guard计数别省。ST7920 忙标志在某些模组上会一直读到高(尤其是数据线方向没切对的时候),没有超时保护就是死循环,现象是程序卡在初始化里、看门狗反复复位,很难往屏上想。加上计数器,最坏情况等 5000 次也就过去了,比死机好查。

LCD_DB_OUT/LCD_DB_IN这两块要单独处理:如果用 GPIO 拼的 8 位总线,输出和输入都得切方向;如果用 FSMC 或者并口外设,读的时候要改成输入模式并把建立时间拉长一点。血泪经验是 E 脉冲高电平期间别去改数据线方向,读数会随机错。

2.3 串行模式:省引脚,但要放弃读忙

串行接法只有 CS、SID、SCLK 三根线,代价是 ST7920 在串行模式下只接受写,读不回来,忙标志没法查,只能靠延时兜底。

/* 串行模式,PSB 接 GND。一个字节分三拍:同步头 + 高 4 位 + 低 4 位 */ static void lcd_spi_tx(uint8_t b) { for (uint8_t i = 0; i < 8; i++) { /* 高位在前 */ LCD_SID_W((b & 0x80) ? 1 : 0); b <<= 1; LCD_SCLK_H(); delay_us(1); LCD_SCLK_L(); } } void lcd_write(uint8_t dat, uint8_t rs) { LCD_CS_L(); lcd_spi_tx(0xF8 | (rs << 1)); /* 11111 + RW=0 + RS + 0 */ lcd_spi_tx(dat & 0xF0); /* 高 4 位放到字节高位 */ lcd_spi_tx((dat << 4) & 0xF0); /* 低 4 位挪到高位发 */ LCD_CS_H(); delay_us(72); /* 用延时替代忙检测,72us 覆盖最慢指令 */ }

同步头那 5 个 1 是用来做帧对齐的,如果某次传输被中断打断,模组会靠这 5 位重新找边界。所以CS拉低一次必须完整发完 3 个字节再拉高,中途不能插别的事情——中断里操作同一个屏是最容易出乱码的操作。

延时取 72us 是按最慢指令估的。清屏指令 0x01 需要 1.6ms 左右,所以lcd_write(0x01, 0)之后要单独补delay_ms(2)。串行模式省下的引脚,会在调时序的时候还回来一点,这个账要提前算。

3. 初始化与字库显示:从复位到第一行汉字

3.1 上电初始化序列,顺序不能乱

ST7920 上电后内部复位需要时间,手册给的要求是等 40ms 以上再发第一条指令。我见过为了省这几毫秒把延时删掉,结果一半板子正常、一半板子白屏,产线上抽检都查不出来。

void lcd_init(void) { LCD_RST_L(); delay_ms(50); /* 上电到第一条指令,至少 40ms */ LCD_RST_H(); delay_ms(10); lcd_write_cmd(0x30); /* 功能设定:8 位接口,基本指令集,RE=0 */ delay_ms(1); lcd_write_cmd(0x30); /* 重发一次,模组第一次上电常收不进去 */ lcd_write_cmd(0x0C); /* 显示开,光标关,不反白 */ lcd_write_cmd(0x01); /* 清屏:只清 DDRAM,不清 GDRAM */ delay_ms(2); lcd_write_cmd(0x06); /* 光标右移,地址计数器自增 */ lcd_write_cmd(0x80); /* DDRAM 地址回到第一行行首 */ }

0x30发两遍是行业惯例,不是玄学。ST7920 上电时如果电源爬升较慢,第一条指令可能在内部复位完成前就到了,被丢掉;重发一次成本极低,能救一批边缘板子。0x0C里的低两位控制光标和反白,调试阶段可以先发0x0F,把光标和闪烁都打开,能直观看到地址计数器跑到哪儿了,定位写错位置的问题特别快。

0x01清屏只作用于 DDRAM(文字区),GDRAM 里的图形数据还在。这个特性有时能利用——先刷一张底图,后面反复改写文字区,底图不会被清掉。但如果你的预期是「清屏后全白」,那就要手动把 GDRAM 也刷一遍。

3.2 DDRAM 地址映射:四行不是连着排的

字符模式下,每个 16×16 汉字占两个 DDRAM 地址,一行 128 点正好 8 个汉字 16 个地址。四行的起始地址不是顺序递增,第三行插在第二行前面,这是从 KS0108 迁过来的人最容易翻车的地方。

显示行起始地址一行容量
第 1 行0x808 个 16×16 汉字 / 16 个 8×16 半宽字符
第 2 行0x90同上
第 3 行0x88同上
第 4 行0x98同上

封装成函数之后就不用记这些了:

/* line: 0~3, col: 0~7(按 16x16 字符算) */ void lcd_set_pos(uint8_t line, uint8_t col) { static const uint8_t base[4] = {0x80, 0x90, 0x88, 0x98}; lcd_write_cmd(base[line & 0x03] | ((col << 1) & 0x1F)); } void lcd_put_hanzi(uint8_t line, uint8_t col, const char *gb) { lcd_set_pos(line, col); lcd_write_data((uint8_t)gb[0]); /* GB2312 高字节 */ lcd_write_data((uint8_t)gb[1]); /* GB2312 低字节 */ }

col << 1是因为一个汉字吃两个地址。如果你的工程源码是 UTF-8,gb[0]gb[1]直接写进去会变成两个乱码——ST7920 认的是 GB2312 区位码,不是 Unicode。常见做法是源码用 GB2312 编码保存,或者自己写一张小的 Unicode 到 GB2312 映射表,只映射你实际用到的那几十个字。前者省事,后者干净,取决于你的工具链对源文件编码的态度。

3.3 中英文混排与半宽字符

ASCII 字符在 ST7920 里是 8×16 的半宽字形,占一个 DDRAM 地址。所以一行 128 点可以放 16 个 ASCII,或者 8 个汉字,或者混着来。混排的关键是地址推进的步长不一样:写汉字地址 +2,写 ASCII 地址 +1。

void lcd_put_string(uint8_t line, uint8_t col, const char *s) { lcd_set_pos(line, col); while (*s) { if ((uint8_t)*s < 0x80) { /* ASCII:半宽,占 1 个地址 */ lcd_write_data((uint8_t)*s++); } else { /* GB2312:全宽,占 2 个地址 */ lcd_write_data((uint8_t)*s++); lcd_write_data((uint8_t)*s++); } } }

这个函数没有做行尾保护。写到第 16 个地址的时候地址计数器会继续往上加,超出当前行范围就窜到别处去,现象是第二行开头冒出几个不该出现的字。稳妥的做法是传入之前自己数一下宽度,或者在循环里维护col计数,到 16 就换行。

还有一个细节:ASCII 字符在 ST7920 的字库里是右对齐还是左对齐,不同批次的模组略有差异,混排时看着会有点豁牙。如果对排版斤斤计较,可以把 ASCII 也用 16×16 的自定义字符画出来,代价是 4 个 CGRAM 坑位很快就不够用。

4. 自定义字符与图片显示:CGRAM 的四个坑位和 GDRAM 的整屏位图

到这一章才进入正题。ST7920 的自定义显示有两条路,选哪条取决于你要几个图标、要不要动画。

4.1 CGRAM:4 个 16×16 坑位,每个 32 字节

ST7920 在字库区留了 4 个空位给用户,字符码固定是 0x0000、0x0002、0x0004、0x0006。这 4 个字模存在 CGRAM 里,一共 4×32=128 字节。写进去之后,往 DDRAM 写对应的字符码就能显示,和写普通汉字没区别。

每个 16×16 字符占 32 字节,按行排列,一行 16 个点用两个字节表示:高字节是左半边 8 个点,低字节是右半边 8 个点,字节内高位在左。

/* 电池满格图标,16x16。每行两字节:左 8 点 + 右 8 点,1 表示点亮 */ static const uint8_t icon_bat_full[32] = { 0x00, 0x00, 0x7F, 0xFE, 0x40, 0x02, 0x5F, 0xFA, 0x53, 0xEA, 0x53, 0xEA, 0x53, 0xEA, 0x53, 0xEA, 0x53, 0xEA, 0x53, 0xEA, 0x53, 0xEA, 0x5F, 0xFA, 0x40, 0x02, 0x7F, 0xFE, 0x00, 0x00, 0x00, 0x00, }; void lcd_load_cgram(uint8_t idx, const uint8_t *dots) { if (idx > 3) return; /* 只有 4 个坑位 */ lcd_write_cmd(0x34); /* 扩展指令集,RE=1,G=0 */ lcd_write_cmd(0x40 | (idx << 5)); /* CGRAM 地址 = idx * 32 */ for (uint8_t i = 0; i < 32; i++) lcd_write_data(dots[i]); /* 地址计数器自动递增 */ lcd_write_cmd(0x30); /* 必须回基本指令集 */ }

0x34是进扩展指令集但不开绘图(G=0),0x40 | 地址是设 CGRAM 指针。写完 32 个字节后一定要发0x30退回基本指令集,否则后续写汉字会全乱——这个坑我在第 5 章单独展开。

idx << 5就是idx * 32,位运算写法在 8 位机上省一点周期,也更直观地表达「每个字符占 32 字节」这件事。写完 4 个图标占满 128 字节,再加就得换 GDRAM 方案了。

4.2 把自定义字符显示出来:字符码 0x0000 和 0x0002

字符码是 16 位的,但在 DDRAM 里是当两个字节写的。所以第一个坑位对应「0x00 0x00」,第二个是「0x00 0x02」,依此类推。

/* 在指定位置显示第 idx 个自定义字符,idx 取 0~3 */ void lcd_put_icon(uint8_t line, uint8_t col, uint8_t idx) { lcd_set_pos(line, col); /* 和汉字一样占两个地址 */ lcd_write_data(0x00); /* 高字节固定 0 */ lcd_write_data(idx << 1); /* 低字节:0x00 / 0x02 / 0x04 / 0x06 */ }

idx << 1这个细节值得记住。很多人写完 CGRAM 之后屏上一片空白,就是这里写成了idx,结果去字库里找了一个不存在的码位。另外这 4 个字符码占的是两个 DDRAM 地址,和汉字一样宽——所以一行还是最多 8 个,别指望靠它塞下 16 个图标。

电池电量要分四格动画,正好 4 个坑位全用上,分别存 0 格、1 格、2 格、3 格。这也是 CGRAM 最典型的用法:状态固定、数量少、需要反复切换。运行时只改 DDRAM 里的字符码,不用重写 CGRAM,切换速度极快,几乎不占 CPU。

4.3 GDRAM 位映射:和 KS0108 完全不是一回事

要显示超过 4 个图标,或者要刷一张带渐变、带 LOGO 的整屏图,就得进绘图模式往 GDRAM 里写。GDRAM 是 256×64 位,128 宽的屏只用左半边。

它的寻址方式先垂直后水平:垂直地址 0~63 直接对应 64 个点行,水平地址 0~15 每格管 16 个点。每次写数据是两个字节,高字节进左边 8 点,低字节进右边 8 点,写完地址计数器自动往右挪一格。

/* v: 0~63 点行, h: 0~7(128 宽只用前 8 格), GDRAM 地址设定 */ void lcd_gram_addr(uint8_t v, uint8_t h) { lcd_write_cmd(0x34); /* 扩展指令集 */ lcd_write_cmd(0x36); /* 打开绘图显示,G=1 */ lcd_write_cmd(0x80 | (v & 0x3F)); /* 垂直地址 */ lcd_write_cmd(0x80 | (h & 0x0F)); /* 水平地址 */ }

这里的0x34和0x36是两个不同的功能设定:0x34进扩展指令集但不显示图形,0x36才是图形显示打开。有模组对这两条的顺序不敏感,但我习惯先0x34再0x36再设地址,兼容性最好。

这一点和 KS0108 差别很大。KS0108 是按 8 点一页组织,一字节直接对应竖着的 8 个点;ST7920 是横着来的,一字节对应一行里的 8 个点。从 KS0108 迁过来的人照搬取模数据,屏上会出现竖条纹错位,看着像屏坏了,其实是位序完全对不上。

4.4 整屏刷图与取模脚本

128×64 的图一共 1024 字节,每行 16 字节(左右各 8 点,共 8 格)。刷的时候就按点行循环,每行重设一次地址。

/* bmp: 128x64 单色位图,按行存放,每行 16 字节,共 1024 字节 */ void lcd_draw_full(const uint8_t *bmp) { for (uint8_t v = 0; v < 64; v++) { lcd_gram_addr(v, 0); for (uint8_t h = 0; h < 8; h++) { lcd_write_data(bmp[v * 16 + h * 2]); /* 左边 8 点 */ lcd_write_data(bmp[v * 16 + h * 2 + 1]); /* 右边 8 点 */ } } lcd_write_cmd(0x30); /* 收尾:回基本指令集,否则后面写汉字全乱 */ }

每画完一行不要贪省事继续写下一行,垂直地址不会自动进位,写完一行必须重新设地址。有人试过不重设地址直接连着写,结果第 16 格之后地址回绕到行首,整屏图变成一张被横向拉花的马赛克。

图片数据的来源,用取模软件也行,但我更推荐写个脚本,参数固定、可复现

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询