简介:这份资源面向嵌入式开发初学者与单片机爱好者,聚焦ST7920控制器驱动12864 LCD显示屏的实战应用,重点解决自定义字符、反白显示、字体映射与菜单图片绘制等图形界面开发问题。压缩包共14个文件,约43KB,以Keil工程文件为主,包含uvproj工程配置、c源码、hex烧录文件、lst与m51编译列表、obj目标文件及bak备份等,覆盖从源码到可烧录固件的完整链路,便于直接编译验证。目前已有1121人学习下载,说明其在12864显示开发中具有较高参考价值。读者可从中获取ST7920初始化、SPI通信、自定义字符写入CGRAM、字符串定位显示及菜单位图转换等关键实现思路,配合工程文件快速搭建实验环境,适合需要快速上手图形LCD界面开发的中级学习者对照调试与二次开发。
1. 带字库 12864 与 ST7920:为什么你写的自定义字符总是不显示
如果你手头有一块标注“12864”的液晶屏,背面印着 ST7920 或者 ST7920-0B,而且它带中文字库,那你大概率遇到过这个场景:想显示一个自定义的图标或者特殊符号,按照手册把 CGRAM 写进去,结果屏幕上要么一片空白,要么出现几个乱码方块,甚至整个显示直接花屏。这不是你代码写错了,而是 ST7920 这颗控制器的“带字库”版本在自定义字符这件事上,和常见的 KS0108、T6963 方案完全不是一个逻辑。
ST7920 是一颗内置 8192 个中文字形和 128 个 ASCII 字符的 LCD 控制器,支持并行和串行两种接口,常见的 12864 模块就是用它驱动的。它的“带字库”意味着你不需要自己取模显示汉字,直接发 GB2312 编码就能出字。但代价是:它的显示 RAM 和字库 ROM 是分开管理的,自定义字符必须通过 CGRAM 写入,而且 CGRAM 只有 16 个 16×16 点阵的空间,每个自定义字符占 32 字节。更关键的是,ST7920 在扩展指令集下才能操作 CGRAM,而很多例程默认跑在基本指令集,你写进去的数据根本没落到正确的地方。
这篇笔记面向的是已经上手 12864、能点亮屏幕但卡在自定义字符这一步的嵌入式开发者。我会把 ST7920 的指令集切换、CGRAM 地址映射、取模方式、串行时序这几个环节拆开讲,每一步给出可复现的代码和参数,最后说清楚哪些坑是新手必踩的。如果你用的是带字库的 ST7920 模块,想显示自定义图标、LOGO 或者非标准符号,下面的内容可以直接抄作业。
2. ST7920 指令集与 CGRAM 映射:先搞懂它和 KS0108 的本质区别
2.1 基本指令集和扩展指令集的切换逻辑
ST7920 有两套指令集,通过指令中的 RE 位切换。基本指令集用于常规显示控制,扩展指令集用于操作 CGRAM、设置绘图模式、待机等。很多例程初始化之后就一直跑在基本指令集,然后直接往 CGRAM 地址写数据,结果写进去的是 DDRAM 的显示数据,不是自定义字模。
切换指令的格式是:0b001111xx,其中 bit2 是 RE 位。RE=0 是基本指令集,RE=1 是扩展指令集。注意切换之后需要等待一段时间,ST7920 的指令执行时间在 72μs 左右,串行模式下更慢。我一般会在切换后加一个 200μs 的延时,虽然手册说 72μs 就够,但不同批次的屏响应速度有差异,多等一点不会出问题。
// ST7920 指令集切换,RE=1 进入扩展指令集 void ST7920_Enter_Extended(void) { LCD_Write_Cmd(0x34); // 0b00110100,RE=1,进入扩展指令集 delay_us(200); // 等待指令生效,保守取 200us } // RE=0 回到基本指令集 void ST7920_Enter_Basic(void) { LCD_Write_Cmd(0x30); // 0b00110000,RE=0,回到基本指令集 delay_us(200); }这里的LCD_Write_Cmd是你底层写指令的函数,并行模式下就是往 RS=0、RW=0 的时序写一个字节,串行模式下需要按 ST7920 的串行协议打包成两个字节发送。参数 0x34 和 0x30 是固定的,不要改成其他值,RE 位之外的位在切换指令里没有定义,写 0 就行。
2.2 CGRAM 地址与 16×16 点阵的写入顺序
ST7920 的 CGRAM 支持 4 个 16×16 自定义字符,地址范围是 0x40 到 0x7F,每个字符占 32 字节。但它的地址映射不是线性的,而是分成了两段:0x40 到 0x5F 对应前两个字符,0x60 到 0x7F 对应后两个字符。每个 16×16 字符的 32 字节又分成上半部分 16 字节和下半部分 16 字节,写入顺序是先写上半部分,再写下半部分。
具体来说,第一个自定义字符的地址是 0x40 到 0x5F,其中 0x40 到 0x4F 是上半部分 16 字节,0x50 到 0x5F 是下半部分 16 字节。第二个字符是 0x60 到 0x7F,以此类推。但这里有个容易翻车的地方:ST7920 的地址指针在写入过程中会自动递增,但递增到 0x4F 之后不会自动跳到 0x50,而是继续递增到 0x50,这看起来没问题,但如果你写第一个字符的上半部分时地址设成了 0x40,写完 16 字节后地址指针停在 0x50,这时候你直接写下半部分是可以的。但如果你中间穿插了其他指令,地址指针可能会被重置。
我一般会显式设置地址,不依赖自动递增:
// 写入一个 16x16 自定义字符到 CGRAM // index: 0~3,对应 4 个自定义字符槽位 // font_data: 32 字节的点阵数据,前 16 字节上半部分,后 16 字节下半部分 void ST7920_Write_CGRAM(uint8_t index, const uint8_t *font_data) { uint8_t base_addr; uint8_t i; // 计算该槽位的起始地址 // 槽位 0: 0x40, 槽位 1: 0x60, 槽位 2: 0x40+0x20, 槽位 3: 0x60+0x20 // 实际上 ST7920 的 CGRAM 地址是 0x40~0x7F,每 32 字节一个字符 // 但地址不是连续的,需要按手册映射 switch(index) { case 0: base_addr = 0x40; break; case 1: base_addr = 0x60; break; case 2: base_addr = 0x40; break; // 注意:槽位 2 和 3 需要配合显示地址使用 case 3: base_addr = 0x60; break; default: return; } ST7920_Enter_Extended(); // 必须先进入扩展指令集 // 写上半部分 16 字节 LCD_Write_Cmd(0x40 | (base_addr & 0x3F)); // 设置 CGRAM 地址 delay_us(100); for(i = 0; i < 16; i++) { LCD_Write_Data(font_data[i]); delay_us(50); } // 写下半部分 16 字节 LCD_Write_Cmd(0x40 | ((base_addr + 0x10) & 0x3F)); delay_us(100); for(i = 16; i < 32; i++) { LCD_Write_Data(font_data[i]); delay_us(50); } ST7920_Enter_Basic(); // 回到基本指令集 }这段代码里LCD_Write_Cmd和LCD_Write_Data的区别在于 RS 引脚:写指令时 RS=0,写数据时 RS=1。串行模式下没有 RS 引脚,而是通过发送字节的第 5 位来区分,具体协议后面讲。延时 50μs 是为了满足 ST7920 的数据建立时间,串行模式下这个延时需要更长,我一般用 100μs。
2.3 自定义字符在 DDRAM 中的显示地址
写完 CGRAM 之后,怎么在屏幕上显示出来?ST7920 的 DDRAM 地址和 CGRAM 槽位的对应关系是固定的:CGRAM 槽位 0 对应 DDRAM 地址 0x00,槽位 1 对应 0x02,槽位 2 对应 0x01,槽位 3 对应 0x03。这个映射关系在手册里有表格,但很容易看漏。
也就是说,如果你想在屏幕第一行第一个位置显示自定义字符 0,你需要把 DDRAM 地址设为 0x80(第一行起始地址),然后写入数据 0x00。注意这里写入的是数据,不是指令。ST7920 会把 DDRAM 里的值 0x00 解释为“显示 CGRAM 槽位 0 的字符”。
// 在指定位置显示自定义字符 // x: 列坐标 0~7(每个字符占 2 个字节宽度) // y: 行坐标 0~3 // cgram_index: 0~3 void ST7920_Display_CGRAM(uint8_t x, uint8_t y, uint8_t cgram_index) { uint8_t addr; // 计算 DDRAM 地址 switch(y) { case 0: addr = 0x80 + x; break; case 1: addr = 0x90 + x; break; case 2: addr = 0x88 + x; break; case 3: addr = 0x98 + x; break; default: return; } LCD_Write_Cmd(addr); // 设置 DDRAM 地址 delay_us(100); LCD_Write_Data(cgram_index); // 写入 CGRAM 槽位号 delay_us(100); }这里的cgram_index直接写 0 到 3,ST7920 会自动去 CGRAM 里取对应的字模。注意 DDRAM 地址的计算:第一行是 0x80 起始,第二行是 0x90,第三行是 0x88,第四行是 0x98。这个地址顺序不是线性的,是 ST7920 特有的分行方式,写错行地址会导致字符显示在错误的位置。
3. 取模与数据生成:从图片到 32 字节点阵的完整链路
3.1 取模软件的参数设置
自定义字符的 32 字节数据来自取模。常见的取模软件有 PCtoLCD2002、字模提取软件等,但参数设置不对,取出来的数据在 ST7920 上就是乱的。我用的参数是:点阵格式选“阴码”,取模方式选“逐行式”,取模走向选“顺向”,输出数制选“十六进制”,自定义格式选“C51 格式”。
关键参数是“逐行式”和“顺向”。ST7920 的 CGRAM 是按行存储的,每个 16×16 字符的上半部分 16 字节对应第 0 到第 15 行,每行 2 个字节,共 16 行。下半部分同理。如果你选“逐列式”,数据顺序就完全错了,显示出来会是旋转或者错位的图案。
还有一个容易忽略的点:取模时要把图片做成单色位图,背景设为白色,图案设为黑色。如果背景是黑色,取出来的数据会反相,显示出来就是黑底白字,和屏幕其他区域不一致。
3.2 手动构造点阵数据的 Python 脚本
有时候手头没有取模软件,或者需要批量生成图标,我一般用 Python 脚本直接生成 32 字节数组。下面这个脚本可以把一个 16×16 的二维数组转成 ST7920 的 CGRAM 数据格式:
def generate_st7920_cgram_data(pixel_matrix): """ 将 16x16 的二维像素矩阵转换为 ST7920 CGRAM 的 32 字节数据 pixel_matrix: 16 行 x 16 列的列表,1 表示点亮,0 表示熄灭 返回: 32 字节的列表,前 16 字节上半部分,后 16 字节下半部分 """ if len(pixel_matrix) != 16 or any(len(row) != 16 for row in pixel_matrix): raise ValueError("输入必须是 16x16 的矩阵") data = [] # 上半部分:第 0 到第 7 行,每行 2 字节 for row in range(8): byte_high = 0 byte_low = 0 for col in range(8): if pixel_matrix[row][col]: byte_high |= (0x80 >> col) for col in range(8, 16): if pixel_matrix[row][col]: byte_low |= (0x80 >> (col - 8)) data.append(byte_high) data.append(byte_low) # 下半部分:第 8 到第 15 行,每行 2 字节 for row in range(8, 16): byte_high = 0 byte_low = 0 for col in range(8): if pixel_matrix[row][col]: byte_high |= (0x80 >> col) for col in range(8, 16): if pixel_matrix[row][col]: byte_low |= (0x80 >> (col - 8)) data.append(byte_high) data.append(byte_low) return data # 示例:生成一个简单的“向上箭头”图标 arrow = [ [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0], [0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0], [0,0,0,1,1,1,1,1,1,1,1,1,1,0,0,0], [0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0], [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], ] cgram_data = generate_st7920_cgram_data(arrow) print("const uint8_t arrow_icon[32] = {") for i in range(0, 32, 8): print(" " + ", ".join(f"0x{b:02X}" for b in cgram_data[i:i+8]) + ",") print("};")这个脚本的逻辑是:ST7920 的每个 16×16 字符在 CGRAM 里按行存储,每行 16 个像素对应 2 个字节,高字节在前,低字节在后。上半部分 8 行占 16 字节,下半部分 8 行占 16 字节。注意这里的“上半部分”是指第 0 到第 7 行,不是第 0 到第 15 行的一半,因为 ST7920 的 CGRAM 地址映射把 16 行分成了两个 8 行的块。
运行后会输出类似这样的数组:
const uint8_t arrow_icon[32] = { 0x00, 0x00, 0x01, 0x80, 0x03, 0xC0, 0x07, 0xE0, 0x0F, 0xF0, 0x1F, 0xF8, 0x3F, 0xFC, 0x7F, 0xFE, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x03, 0xC0, 0x00, 0x00, };把这个数组传给前面的ST7920_Write_CGRAM函数,再调用ST7920_Display_CGRAM就能在屏幕上看到箭头图标了。
3.3 串行模式下的数据打包
如果你用的是串行接口,ST7920 的数据传输协议和并行不一样。串行模式下,每个字节要拆成两个字节发送:第一个字节的高 5 位是 11111,低 3 位是数据的 bit7 到 bit5;第二个字节的高 5 位是 11110,低 3 位是数据的 bit4 到 bit0。等等,这个描述不准确,我重新说一下。
ST7920 的串行协议是:每次传输 24 个时钟周期,前 5 个周期是同步头 11111,接着是 1 个周期表示传输方向(0 表示写指令,1 表示写数据),然后是 8 个周期的数据,最后是 4 个周期的 0000。但很多例程用的是另一种打包方式:把 8 位数据拆成两个字节,第一个字节是 0xF8 | (data >> 4),第二个字节是 0xF0 | (data & 0x0F)。这两种方式都能工作,但时序不同。
我一般用后者,因为代码简单:
// ST7920 串行模式写一个字节 // rs: 0 表示指令,1 表示数据 void ST7920_Serial_Write(uint8_t rs, uint8_t data) { uint8_t high_byte = 0xF8 | (rs << 2) | ((data >> 4) & 0x03); uint8_t low_byte = 0xF0 | (data & 0x0F); // 发送高字节 SPI_Transfer(high_byte); // 发送低字节 SPI_Transfer(low_byte); delay_us(100); }这里的SPI_Transfer是你底层 SPI 发送一个字节的函数,ST7920 的串行接口是 SPI 模式 3(CPOL=1,CPHA=1),时钟频率不要超过 1MHz,我一般用 500kHz。注意high_byte的计算:0xF8 是同步头,bit2 是 RS 位,bit1 和 bit0 是数据的高 2 位。low_byte的 0xF0 是同步头,低 4 位是数据的低 4 位。这样打包之后,ST7920 就能正确解析。
4. 避坑与排查:自定义字符不显示的 5 个血泪教训
4.1 现象:写 CGRAM 后屏幕花屏或闪烁
原因:没有切换到扩展指令集,或者切换后没有等待足够的时间。ST7920 在基本指令集下,0x40 到 0x7F 的地址是 DDRAM 的显示区域,你往这些地址写数据会直接改变屏幕内容,而不是写入 CGRAM。另外,指令集切换需要时间,如果紧接着写数据,ST7920 可能还没完成切换。
解决:在写 CGRAM 之前,先发送 0x34 进入扩展指令集,延时至少 200μs。写完 CGRAM 后,发送 0x30 回到基本指令集,再延时 200μs。我习惯在每次切换后都加延时,虽然手册说 72μs 就够,但不同批次的屏响应速度有差异,多等一点不会出问题。
4.2 现象:自定义字符显示为乱码或错位
原因:取模方式不对,或者 CGRAM 地址计算错误。ST7920 要求逐行式取模,如果你用了逐列式,数据顺序就完全错了。另外,CGRAM 的地址映射不是线性的,槽位 0 和槽位 1 的地址是 0x40 和 0x60,但槽位 2 和槽位 3 的地址需要配合 DDRAM 地址使用,不是简单的 0x80 和 0xA0。
解决:用取模软件时,点阵格式选“阴码”,取模方式选“逐行式”,取模走向选“顺向”。如果手动构造数据,确保每行 2 个字节,高字节在前,低字节在后。CGRAM 地址用0x40 | (base_addr & 0x3F)计算,不要直接写 0x80 以上的地址。
4.3 现象:自定义字符只显示上半部分或下半部分
原因:CGRAM 写入时地址指针没有正确设置,或者只写了 16 字节就结束了。ST7920 的每个 16×16 字符需要 32 字节,上半部分 16 字节,下半部分 16 字节。如果你只写了上半部分,下半部分就是随机值,显示出来就是半个字符。
解决:在写下半部分之前,重新设置 CGRAM 地址为base_addr + 0x10,然后写入后 16 字节。不要依赖地址自动递增,因为 ST7920 的地址指针在跨行时不会自动跳转。
4.4 现象:串行模式下完全没反应
原因:串行时序不对,或者 SPI 模式设置错误。ST7920 的串行接口是 SPI 模式 3,CPOL=1,CPHA=1,时钟频率不要超过 1MHz。如果你用了模式 0 或者频率太高,ST7920 可能无法正确解析数据。
解决:检查 SPI 初始化代码,确保 CPOL=1,CPHA=1,时钟频率设为 500kHz 左右。另外,串行模式下 RS 位是通过数据字节的 bit2 传递的,不是独立的引脚,写指令时 RS=0,写数据时 RS=1。
4.5 现象:自定义字符显示位置不对
原因:DDRAM 地址计算错误。ST7920 的 DDRAM 地址不是线性的,第一行是 0x80 起始,第二行是 0x90,第三行是 0x88,第四行是 0x98。如果你按 0x80、0x90、0xA0、0xB0 计算,第三行和第四行就会错位。
解决:用正确的行地址映射:第一行 0x80,第二行 0x90,第三行 0x88,第四行 0x98。列地址直接加在行地址上,每列占 1 个字节。注意 ST7920 的每个字符占 2 个字节宽度,所以列坐标 0 到 7 对应 8 个字符位置。
5. 进阶技巧:用 CGRAM 做动态图标和简单动画
5.1 动态刷新 CGRAM 实现闪烁效果
ST7920 的 CGRAM 是可以重复写入的,不需要先擦除。这意味着你可以在运行时动态更新自定义字符的内容,实现闪烁、旋转等简单动画。我一般用定时器每 500ms 切换一次 CGRAM 数据,让图标在“实心”和“空心”之间切换。
// 两个状态的点阵数据 const uint8_t icon_on[32] = { /* 实心图标数据 */ }; const uint8_t icon_off[32] = { /* 空心图标数据 */ }; void Toggle_Icon(void) { static uint8_t state = 0; if (state) { ST7920_Write_CGRAM(0, icon_on); state = 0; } else { ST7920_Write_CGRAM(0, icon_off); state = 1; } }这个技巧的关键是:CGRAM 写入不需要先清屏,直接覆盖即可。但要注意,写入过程中屏幕可能会短暂闪烁,因为 CGRAM 更新时 DDRAM 还在显示旧数据。如果闪烁明显,可以在写入前先把该位置的 DDRAM 数据改成空格,写完再改回来。
5.2 用 4 个 CGRAM 槽位拼出 32×32 图标
ST7920 只有 4 个 16×16 的 CGRAM 槽位,但你可以把 4 个槽位拼成一个 32×32 的大图标。具体做法是:把 32×32 的图标切成 4 个 16×16 的小块,分别写入 4 个 CGRAM 槽位,然后在 DDRAM 的 2×2 字符位置上依次显示这 4 个槽位。
// 显示 32x32 图标,起始位置 (x, y) void ST7920_Display_32x32(uint8_t x, uint8_t y) { // 左上角 ST7920_Display_CGRAM(x, y, 0); // 右上角 ST7920_Display_CGRAM(x + 1, y, 1); // 左下角 ST7920_Display_CGRAM(x, y + 1, 2); // 右下角 ST7920_Display_CGRAM(x + 1, y + 1, 3); }这个技巧的坑在于:CGRAM 槽位 2 和槽位 3 的地址映射和槽位 0、1 不同,写入时需要特别注意。另外,DDRAM 的列坐标是字符位置,不是像素位置,每个字符占 2 个字节宽度,所以 x+1 表示向右移动一个字符位置,对应 16 个像素。
5.3 验证 CGRAM 写入是否成功的快速方法
如果你不确定 CGRAM 是否写入成功,可以用一个简单的测试:写入一个全 0xFF 的 32 字节数据,然后在屏幕上显示这个槽位。如果显示出来是一个 16×16 的实心方块,说明 CGRAM 写入和显示都正常。如果显示出来是乱码或者空白,说明指令集切换或地址计算有问题。
// 测试 CGRAM 写入 const uint8_t test_pattern[32] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, }; void Test_CGRAM(void) { ST7920_Write_CGRAM(0, test_pattern); ST7920_Display_CGRAM(0, 0, 0); }这个测试我每次调试新屏都会跑一遍,确认底层驱动没问题之后再写正式的点阵数据。从那以后我每次拿到一块新的 ST7920 模块,都强制先跑这个全 0xFF 测试,确认 CGRAM 写入和显示链路都通了,再开始做正式的自定义字符。希望帮到你。
本文还有配套的精品资源,点击获取