简介:这份资源面向嵌入式开发初学者与电子工程实践者,聚焦在12864液晶屏上实现汉字滚动显示这一典型场景。12864分辨率有限,滚动显示是扩展信息展示的常用方案,资源围绕驱动库调用、GBK与UTF-8汉字编码、滚动算法、字模渲染、定时器控制流程及代码优化调试等关键知识点展开,帮助读者理解从字模数据到屏幕动态刷新的完整链路。压缩包共18个文件,约593KB,包含C源码、A51汇编启动文件、HEX烧录文件、UV2工程配置、LST列表、OBJ目标文件及Protues仿真说明文档等,覆盖编译、仿真与下载各环节。目前已有980人学习下载,适合作为单片机课程设计或小型仪表显示项目的参考。通过分析源码与仿真文档,读者可掌握滚动速度设定、显示缓冲区移动及字模加载方法,并据此扩展至智能家居面板、工业仪表等实际应用。
1. 12864滚动显示汉字:从点亮到滚动的完整落地路径
很多人第一次把 12864 液晶屏点亮之后,都会卡在同一个地方:静态汉字能显示,但一让它滚动就翻车——要么整屏闪烁,要么字被切掉半截,要么滚到一半就乱码。这个标题说的就是这件事:在 12864 点阵液晶上实现汉字的平滑滚动显示。它解决的是小屏信息展示面积不足的问题,一块 128×64 的屏最多同时显示 4 行 16×16 汉字,滚动之后就能循环展示更多内容。适合做嵌入式仪表、工控面板、智能家居中控的开发者,也适合刚接触 ST7920 或 KS0108 驱动的新手拿来练手。下面按“先搞懂显存结构、再跑通静态显示、最后做滚动”的顺序讲透。
2. 先搞懂 12864 的显存结构和汉字取模方式
2.1 12864 的两种主流驱动与显存差异
12864 只是一个屏幕规格,背后驱动芯片不同,显存组织方式完全不同。市面上最常见的是 ST7920 和 KS0108 两类,前者自带字库、支持串行和并行,后者是纯图形模式、必须自己取模。选型时先确认你手上模块的驱动型号,这决定了后面滚动算法的写法。
ST7920 的显存分两块:上半屏 32 字节 × 8 行,下半屏 32 字节 × 8 行,每个字节对应横向 8 个像素。它的 GDRAM 地址不是线性的,写数据时要先设垂直地址再设水平地址,而且上下半屏的水平地址是分开的。KS0108 则是把 128 列分成左右两半,每半 64 列,每列 8 页,每页 8 个像素。这两种结构决定了滚动时你是按字节搬还是按位搬。
我一般建议新手先用 ST7920 做滚动,因为它有内置字库,汉字显示不用自己取模,能先把滚动逻辑跑通。等逻辑清楚了再换 KS0108 做纯图形滚动,理解会更透彻。
2.2 汉字取模:纵向取模与横向取模的选择
如果你用的是 KS0108 或者想在 ST7920 上显示自定义图形,就必须自己取模。取模方式直接决定滚动时数据怎么搬。常见的有纵向取模(字节高位在下)和横向取模(字节高位在左),还有逐行式、逐列式。
以 16×16 汉字为例,纵向取模会得到 32 个字节,前 16 个字节是左半部分从上到下,后 16 个字节是右半部分。横向取模则是每行 2 个字节,共 16 行。滚动时如果按页搬数据,纵向取模更顺手;如果按行搬,横向取模更直观。
取模工具用 PCtoLCD2002 就行,设置里选“阴码”“逐列式”“顺向”,输出格式选 C51。取完之后一定要在纸上画一遍,确认字节和像素的对应关系,这一步偷懒后面滚动必翻车。
// 16x16 汉字"中"的纵向取模数据示例(阴码、逐列式、顺向) // 每个字节对应一列 8 个像素,前 16 字节为左半,后 16 字节为右半 const unsigned char hanzi_zhong[] = { 0x00,0x00,0x3F,0xFC,0x20,0x04,0x20,0x04, 0x20,0x04,0x20,0x04,0x3F,0xFC,0x20,0x04, 0x20,0x04,0x20,0x04,0x20,0x04,0x3F,0xFC, 0x20,0x04,0x00,0x00,0x00,0x00,0x00,0x00 };这段数据里,每个字节的 bit0 到 bit7 对应一列中从上到下的 8 个像素。写显存时,左半部分写到左半屏对应的页,右半部分写到右半屏对应的页。参数上要注意:如果你的取模工具选了“逆向”,字节的位序会反过来,显示出来就是镜像的,滚动时也会跟着错位。
2.3 显存地址与像素坐标的换算
滚动的前提是你能精确控制每一个像素。以 ST7920 为例,上半屏第 y 行(y 从 0 到 31)、第 x 列(x 从 0 到 127)对应的显存地址是:垂直地址 = y,水平地址 = x。下半屏则是垂直地址 = y - 32,水平地址 = x + 8。这个 +8 是 ST7920 的坑,很多人第一次写下半屏会发现整体偏移了 8 列。
KS0108 的换算更直接:左半屏第 page 页(0 到 7)、第 col 列(0 到 63)对应左半芯片的 page 和 col;右半屏对应右半芯片的 page 和 col-64。写数据前要先选芯片、再选页、再选列,三步缺一不可。
提示:ST7920 在并行模式下写 GDRAM 需要先关绘图模式再开,否则数据写不进去。这个在数据手册里写得很隐蔽,我当初找了半天。
3. 用 ST7920 在 12864 上跑通静态汉字显示
3.1 硬件连接与并口初始化时序
先以并行模式为例,ST7920 的并口需要 RS、RW、E、DB0-DB7 共 11 根线。RS 接 P2.0,RW 接 P2.1,E 接 P2.2,数据口接 P0。初始化顺序不能乱:上电延时 40ms,发功能设定指令 0x30,再延时,再发 0x30,然后发 0x0C 开显示、0x01 清屏、0x06 设置光标移动方向。
// ST7920 并口写指令和写数据的基础函数 void lcd_write_cmd(unsigned char cmd) { LCD_RS = 0; // RS=0 表示写指令 LCD_RW = 0; // RW=0 表示写操作 LCD_DATA = cmd; // 数据放到 P0 口 LCD_E = 1; // 拉高使能 delay_us(5); // 保持至少 450ns,这里给 5us 余量 LCD_E = 0; // 拉低产生下降沿,芯片锁存数据 delay_us(100); // 等待指令执行,清屏等指令需要更久 } void lcd_write_data(unsigned char dat) { LCD_RS = 1; // RS=1 表示写数据 LCD_RW = 0; LCD_DATA = dat; LCD_E = 1; delay_us(5); LCD_E = 0; delay_us(50); }这里的关键参数是 E 的高电平宽度和指令间隔。ST7920 的 E 高电平最小 450ns,但实际用的时候建议给到 1us 以上,尤其是杜邦线连接时。清屏指令 0x01 需要 1.6ms 以上,如果你只延时 100us 就发下一条指令,屏幕会花。我一般清屏后直接延时 2ms,省心。
3.2 写 GDRAM 显示一个 16×16 汉字
静态显示一个汉字,要先把绘图模式打开(发 0x34),然后设置垂直地址和水平地址,再连续写 16 个字节。注意 ST7920 写 GDRAM 时,每写一个字节水平地址自动加一,写到第 16 个字节后要手动把水平地址设回起始位置,再设垂直地址加一,继续写右半部分。
// 在指定位置显示一个 16x16 汉字 // x: 起始列 0-127, y: 起始行 0-31 void lcd_show_hanzi(unsigned char x, unsigned char y, const unsigned char *hz) { unsigned char i; lcd_write_cmd(0x34); // 打开扩展指令集,进入绘图模式 lcd_write_cmd(0x80 + y); // 设置垂直地址(上半屏) lcd_write_cmd(0x80 + x); // 设置水平地址 for (i = 0; i < 16; i++) { lcd_write_data(hz[i]); // 写左半部分 16 个字节 } lcd_write_cmd(0x80 + y); // 垂直地址不变 lcd_write_cmd(0x80 + x + 16); // 水平地址跳到右半部分 for (i = 16; i < 32; i++) { lcd_write_data(hz[i]); // 写右半部分 16 个字节 } lcd_write_cmd(0x36); // 打开绘图显示 lcd_write_cmd(0x30); // 回到基本指令集 }这段代码里,0x34 是扩展指令集,0x36 是开绘图显示,0x30 是回基本指令集。顺序不能反,否则要么写不进数据,要么显示不出来。x 的范围是 0 到 112,因为一个汉字占 16 列,超过 112 就会写到屏幕外。y 的范围是 0 到 31,上半屏和下半屏的垂直地址是连续的,但下半屏的水平地址要加 8,这个在滚动时特别容易出错。
3.3 用取模数据验证显示是否正确
写完一个汉字后,先别急着做滚动。用上面的函数在 (0,0) 位置显示“中”,确认显示正常。如果显示的是乱码或者镜像,先检查取模设置。如果显示位置偏移,检查水平地址有没有加 8。如果完全不显示,检查 0x34 和 0x36 有没有发。
我一般会写一个测试函数,把屏幕上 4 行 8 列全部填满同一个汉字,这样能一次性看出地址换算对不对。
void lcd_test_pattern(void) { unsigned char row, col; for (row = 0; row < 4; row++) { for (col = 0; col < 8; col++) { lcd_show_hanzi(col * 16, row * 16, hanzi_zhong); } } }如果这个测试图案能整齐显示,说明地址换算和取模都没问题,可以进入滚动部分了。如果中间有错位,多半是下半屏的水平地址没加 8,或者垂直地址算错了。
4. 实现平滑滚动的三种方案与参数调优
4.1 整屏搬移法:最简单但最占 CPU
整屏搬移的思路是:每次滚动时,把整个显存的内容向左移动一个像素,然后在最右边补上新的一列数据。具体做法是开一个 128×8 字节的缓冲区,对应屏幕的 1024 个字节。滚动时把缓冲区整体左移一位,再把新数据填到最右边。
// 整屏左移一个像素,new_col 是右侧新进入的一列数据(8 个像素) void lcd_scroll_left_one_pixel(const unsigned char *new_col) { unsigned char page, col; for (page = 0; page < 8; page++) { for (col = 0; col < 15; col++) { // 每个字节左移一位,低位补相邻字节的高位 lcd_buffer[page][col] = (lcd_buffer[page][col] << 1) | (lcd_buffer[page][col + 1] >> 7); } // 最后一列用新数据补齐 lcd_buffer[page][15] = (lcd_buffer[page][15] << 1) | (new_col[page] >> 7); } lcd_refresh(); // 把整个缓冲区刷到屏幕 }这个方法的优点是逻辑简单,滚动效果平滑。缺点是每次滚动都要刷新整屏,ST7920 刷 1024 个字节大约需要 10ms 到 20ms,如果每 50ms 滚一次,CPU 占用率会很高。参数上,滚动速度由调用间隔决定,一般 30ms 到 100ms 比较合适,太快人眼跟不上,太慢会卡顿。
4.2 局部刷新法:只更新变化区域
局部刷新的思路是:滚动时只更新屏幕上真正变化的区域。比如向左滚动一个像素,理论上每一列的内容都变了,但如果你是按页存储的,可以只重写每一页的 16 个字节。实际上局部刷新在 12864 上收益不大,因为滚动本身就是全屏变化。真正有效的是“按字节滚动”,即每次滚动 8 个像素,这样只需要搬移整字节,不用处理位偏移。
// 按字节左移,每次移动 8 个像素,速度更快但视觉上有跳跃感 void lcd_scroll_left_byte(void) { unsigned char page, col; for (page = 0; page < 8; page++) { for (col = 0; col < 15; col++) { lcd_buffer[page][col] = lcd_buffer[page][col + 1]; } lcd_buffer[page][15] = 0x00; // 最后一列清空,等待新数据 } lcd_refresh(); }按字节滚动的速度是像素滚动的 8 倍,CPU 占用低,但视觉上会有 8 个像素的跳跃。如果你的应用对平滑度要求不高,比如只是循环显示几行文字,按字节滚动完全够用。我一般会在菜单界面用按字节滚动,在需要平滑效果的地方用像素滚动。
4.3 滚动速度与缓冲区大小的参数匹配
滚动速度不是随便定的。人眼对 12864 这种小屏的舒适滚动速度大约是每秒 20 到 40 个像素。换算成调用间隔,像素滚动每 25ms 到 50ms 调用一次,字节滚动每 200ms 到 400ms 调用一次。
缓冲区大小也要匹配。如果你用像素滚动,缓冲区必须是 128×8 字节,不能省。如果你用字节滚动,缓冲区可以只开 16×8 字节,每次只处理一页,但刷新时还是要刷整屏。我一般会开 128×8 的完整缓冲区,因为 ST7920 的显存读写不算快,一次性刷比多次刷更稳定。
注意:滚动时如果发现屏幕有拖影,多半是刷新速度跟不上滚动速度。把滚动间隔调大,或者改用按字节滚动。
5. 滚动显示汉字时最容易踩的五个坑
5.1 汉字被切掉半截:水平地址没对齐
现象:滚动到某个位置时,汉字左边或右边少了一列像素。原因:ST7920 的水平地址在写 GDRAM 时,每写一个字节自动加一,但如果你在写右半部分之前没有重新设置水平地址,数据会写到错误的位置。解决:每次写右半部分之前,必须重新发水平地址指令,把地址设到 x+16 的位置。
5.2 滚动时整屏闪烁:刷新和滚动没分开
现象:每次滚动屏幕都会闪一下。原因:滚动和刷新在同一个循环里,刷新时屏幕正在被清空或重写。解决:先把新数据准备好,再一次性刷到屏幕。ST7920 没有双缓冲,但你可以用软件缓冲区,刷的时候连续写,中间不要插入其他操作。
5.3 下半屏显示错位:水平地址少了 8
现象:上半屏正常,下半屏整体向右偏移了 8 列。原因:ST7920 的下半屏水平地址是从 8 开始的,不是从 0 开始。解决:写下半屏时,水平地址要加 8。这个在数据手册的 GDRAM 地址表里有写,但很容易被忽略。
5.4 滚动到边界后乱码:缓冲区越界
现象:滚动到最右边时出现乱码。原因:新数据填充时越过了缓冲区边界,或者最后一列的位偏移处理错了。解决:在滚动函数里加边界检查,确保 col+1 不超过 15。如果是像素滚动,最后一列的位偏移要用新数据的高位补齐,不能直接清零。
5.5 取模数据反了:显示镜像或倒置
现象:汉字显示出来是镜像的,或者上下颠倒。原因:取模时选了“逆向”或“倒置”。解决:重新取模,选“顺向”和“阴码”。如果已经取好了不想重取,可以在写数据时把字节顺序反过来,或者把位序反过来,但这样会增加 CPU 开销,不如重新取模。
6. 用状态机做多行循环滚动与速度微调
单行滚动跑通之后,实际项目里更常见的是多行循环滚动。比如屏幕上固定显示 4 行,每行独立滚动,或者整屏内容循环左移。这时候用状态机来管理比裸写循环清晰得多。
我的做法是给每一行定义一个状态结构体,包含当前滚动偏移、目标偏移、滚动速度、数据指针。主循环里每 10ms 调用一次状态机更新函数,根据当前状态决定是否推进偏移。这样滚动速度可以独立调节,也方便做暂停和加速。
typedef struct { unsigned char offset; // 当前滚动偏移 0-15 unsigned char speed; // 每多少次调用推进一次 unsigned char counter; // 计数器 const unsigned char *data; // 该行的汉字数据 unsigned char len; // 汉字个数 } scroll_line_t; scroll_line_t lines[4]; void scroll_update(void) { unsigned char i; for (i = 0; i < 4; i++) { lines[i].counter++; if (lines[i].counter >= lines[i].speed) { lines[i].counter = 0; lines[i].offset++; if (lines[i].offset >= 16) { lines[i].offset = 0; // 循环回起点 } lcd_render_line(i, lines[i].offset, lines[i].data, lines[i].len); } } }这个结构里,speed 控制滚动快慢,offset 控制当前偏移,counter 用来分频。speed 设成 3 表示每 3 次调用推进一个像素,按 10ms 调用一次算,就是 30ms 滚一个像素,视觉上比较舒服。offset 到 16 归零,实现循环滚动。
速度微调有个血泪经验:不要用 delay 来控制滚动速度,delay 会阻塞其他任务。用计数器分频,主循环保持固定节拍,这样按键响应和滚动互不干扰。我当初用 delay 做滚动,结果按键按下去要等滚动完才响应,被现场调试的同事吐槽了很久。
验证滚动是否平滑,可以用一个简单方法:在屏幕上显示一个竖线,让它滚动,用手机慢动作拍下来,看每一帧的位移是否均匀。如果发现某几帧跳得特别大,多半是刷新时间不稳定,检查一下有没有其他中断在抢 CPU。
最后说一个习惯:每次改滚动参数,我都会先把速度调到最慢,确认逻辑对了再逐步加快。直接上高速很容易把逻辑错误掩盖掉,慢速下才能看清每一像素的移动。希望帮到你。
本文还有配套的精品资源,点击获取