简介:LCD12864点阵液晶屏的串行控制驱动源码,面向嵌入式系统开发者与电子设计初学者,解决在单片机平台上通过串行接口(如SPI或I2C)控制该屏显示文本与简易图像的问题,适合智能仪表、小型人机交互面板等显示场景。压缩包内包含两个文件:一个C源文件集中实现字符显示、图片显示、光标定位和清屏等操作;一个头文件提供接口声明,方便程序调用;整个压缩包大小约3KB,结构简明,便于快速检视与移植。目前已有183人浏览学习,适合正在学习单片机外设驱动或希望直接复用显示逻辑的开发者。源码中涉及的串行时序控制、液晶像素位操作及字符到点阵的转换处理十分具体,有助于理解液晶显示底层原理;同时驱动代码采用分层写法,接口清晰,可较方便地移植到STM32、51等主流微控制器上,也能作为课程设计或竞赛作品的功能模块。
1. LCD12864 驱动最容易被忽略的起点:先确认控制器是 ST7920 还是 KS0108
LCD12864 在嵌入式圈子里被当成"过气外设",但一直到今天,工控仪表、充电桩操作面板、办公设备的状态窗口里仍然大量使用它。它的好处很直白:128×64 点阵能放下 4 行汉字,供货稳定,比同尺寸 TFT 便宜一个量级。真正的问题出在"LCD12864"这个叫法太笼统,市面上至少有两套完全不同的控制器:带中文字库的 ST7920 和不带字库、必须自己画点阵的 KS0108。两者的初始化命令、读写时序、引脚定义都不是一套,网上代码混着抄,十有八九抄完点不亮。这篇文章以 GD32F103 + ST7920 串口模式为主线,把从接线、初始化、显示汉字到绘图刷帧的完整驱动步骤拆开讲,最后收在从 STM32 迁移到 GD32 时最容易踩的三个坑上。没有字库的 KS0108 用户也可以对照着看,知道哪些环节需要换套路。
2. LCD12864 引脚与 GD32F103 接线:PSB 引脚决定串口还是并口模式
2.1 先认清手上的模块:ST7920 和 KS0108 的差异对照
拿到一块 LCD12864,第一步不是搜代码,而是翻到模块背面看控制器丝印。ST7920 方案的芯片上印 ST7920、SPLC792,引脚定义里一定有一个叫 PSB 的引脚;KS0108 方案的芯片印 KS0108B 或 HD61202U,没有 PSB,取而代之的是左右两个片选 CS1 和 CS2。这个差异直接决定驱动代码结构:ST7920 写一个字节前要先发同步字,KS0108 则要先用片选决定操作左半屏还是右半屏,再走 8080 并口时序。
| 对比项 | ST7920 控制器 | KS0108 控制器 |
|---|---|---|
| 内置汉字 | 8192 个 GB2312 简体字 | 无,必须外部取模 |
| 接口形式 | 8 位并口 / 4 位并口 / 串行 | 8 位并口,CS1/CS2 分两屏 |
| 硬件特征 | 有 PSB 引脚 | 有 CS1、CS2,无 PSB |
| 初始化 | 0x30 系列命令,串并口统一 | 0x3E 关显示、0x3F 开显示,左右片选分别设 |
| 显示汉字 | 直接写两字节 GB2312 编码 | 先在内存放好 16×16 字模再画点 |
| 典型应用 | 菜单、静态文本、低成本 HMI | 波形、曲线、需要局部高速刷新 |
KS0108 的读写时序是标准 8080 并口:RS 区分命令和数据,RW 控制读写方向,E 是使能脉冲。它的显存是 8 页×64 列,每页 8 个像素行,画点逻辑比 ST7920 直观,但没有内置字库,显示一个汉字就要自己准备 32 字节字模并做内存映射。如果手里的屏是 KS0108,后面章节里"发 0x30 初始化"和"0xF8/0xFA 串行写命令"这两节可以直接跳过,直接看绘图和帧缓冲部分的思想,接口函数整体要换成并口版本。
2.1.1 PSB 引脚并口与串口的选择原理
ST7920 的 PSB 是高电平并行、低电平串行。并口模式需要 DB0~DB7、RS、RW、E 再加上片选一共十几根线;串口模式只需要 CS、SCK、SID 三根数据线,模块把 RW、RS、E 全部忽略掉。用 GD32F103 这类引脚资源紧张的单片机,我通常选串口模式,理由是接线少、故障点少,而且 ST7920 串口速率做到 1MHz 到 2MHz 时,更新一个普通菜单界面完全够用。代价是每次写命令或数据都要被拆成"同步字 + 高四位 + 低四位"三个 8 位段,代码比并口啰嗦,性能上限也低,刷整屏图形时明显能看到从上到下的扫描过程。
提示:PSB 不能悬空。多数模块悬空默认串口,但也有模块内部没有可靠下拉,长时间运行后电平漂移导致花屏。上电前直接用电烙铁把 PSB 和 GND 短接。
2.2 ST7920 串行接口时序:0xF8/0xFA 三字节帧与 SCK 相位
ST7920 串口不是标准 SPI,是一套"类 SPI"协议。每一帧由三段组成,每段都是 8 个 SCK 脉冲:第一段是同步字,固定以 11111 开头,后面两位区分命令还是数据,写命令发 0xF8,写数据发 0xFA;第二段发送目标字节的高四位;第三段发送低四位,低四位要左移到字节的高四位再发。举个例子,发送命令 0xC0 时,三段依次是 0xF8、0xC0、0x00。如果写数据 0x55,三段是 0xFA、0x50、0x50。
SCK 空闲时为低电平,SID 在下降沿之后改变、上升沿被锁存,所以最稳妥的模拟顺序是:先拉低 SCK,再调整 SID,然后拉高 SCK。这正好对应 SPI Mode 0 的 CPOL=0、CPHA=0 相位,但由于 ST7920 要求每一段 8 位之间有明确停顿,硬件 SPI 连续发送时容易把三个段的边界吃掉,后面第 5 章会专门展开。下面这段是 GD32F103 GPIO 模拟串行发送的完整实现。
#include "gd32f10x.h" #define LCD_CS_PIN GPIO_PIN_0 #define LCD_SCK_PIN GPIO_PIN_1 #define LCD_SID_PIN GPIO_PIN_2 static void lcd_delay_us(volatile uint32_t us) { /* GD32F103 默认 108MHz,空循环大约 4 个周期/微秒 */ volatile uint32_t i; while (us--) { for (i = 0; i < 4; i++); } } static void lcd_send_bits(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { gpio_bit_reset(GPIOB, LCD_SCK_PIN); if (dat & 0x80) { gpio_bit_set(GPIOB, LCD_SID_PIN); } else { gpio_bit_reset(GPIOB, LCD_SID_PIN); } lcd_delay_us(1); gpio_bit_set(GPIOB, LCD_SCK_PIN); lcd_delay_us(1); dat <<= 1; } } static void lcd_write_byte(uint8_t is_data, uint8_t dat) { gpio_bit_reset(GPIOB, LCD_CS_PIN); lcd_send_bits(is_data ? 0xFA : 0xF8); lcd_delay_us(1); lcd_send_bits(dat & 0xF0); lcd_send_bits((dat & 0x0F) << 4); gpio_bit_set(GPIOB, LCD_CS_PIN); lcd_delay_us(1); }这段代码的关键点有三个。第一,lcd_send_bits 按 MSB first 一位一位发,先改 SID 再拉高 SCK,保证上升沿来临时 SID 已经稳定。第二,lcd_write_byte 用 is_data 区分同步字:命令传 0 发 0xF8,数据传 1 发 0xFA;后续两个数据段都只保留四位有效位,位置对齐到高四位。第三,CS 在整个三段的帧期间保持低电平,帧结束拉高,拉高动作会复位 ST7920 内部的串行状态机,这样下一帧无论如何都能从同步字开始,不会累积错位。延时不敏感,GPIO 模拟到 5MHz 也能跑,但建议保留 1us 级停顿,给 ST7920 内部移位寄存器留处理余量。
2.3 GD32F103 的 GPIO 初始化与 5V/3.3V 电平匹配
串口模式只有三个信号,GD32F103 侧全部配置为推挽输出即可。注意不要开复用功能,因为这里不经过 SPI 外设,直接用 GPIO 翻转。
void lcd_gpio_init(void) { rcu_periph_clock_enable(RCU_GPIOB); gpio_init(GPIOB, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, LCD_CS_PIN | LCD_SCK_PIN | LCD_SID_PIN); gpio_bit_set(GPIOB, LCD_CS_PIN); /* CS 默认拉高 */ gpio_bit_reset(GPIOB, LCD_SCK_PIN); gpio_bit_reset(GPIOB, LCD_SID_PIN); }GPIO 速度参数选 50MHz。ST7920 串口时钟实际是 1MHz 量级,GPIO_MODE_OUT_PP 和 50MHz 输出速度足够;如果选 2MHz 低速档,边沿会变缓,在长线上容易多翻转一次。初始化最后一步把 SCK 和 SID 都拉低,避免上电瞬间这两个引脚产生上升沿毛刺被模块当成数据。
电平匹配是串口驱动里最容易忽略的坑。很多 LCD12864 模块默认供电 5V,此时 ST7920 输入高电平门槛约为 0.7×5V=3.5V,而 GD32F103 是高电平 3.3V 输出,正好差临界值。示波器看波形幅值足够,但模块内部采样点刚好落在不确定区,表现是显示偶尔花屏、初始化有时成功有时失败。常见解决办法是看模块背面有没有 3.3V/5V 跳线,有就把 VCC 切到 3.3V;没有跳线就串接 74LVC245 做电平转换。不要用上拉电阻硬拉到 5V,GD32 引脚不是 5V 容忍,会通过保护二极管倒灌电流。
2.4 接线清单与并口模式对比
以 GD32F103 的 PB0/PB1/PB2 为例,串口模式接线如下。
| LCD12864 模块引脚 | 功能 | 接 GD32F103 |
|---|---|---|
| VCC | 模块电源 | 按跳线接 3.3V 或 5V |
| GND | 地 | GND |
| PSB | 并/串选择 | GND,强制串口 |
| CS | 片选 | PB0 |
| SCLK | 串行时钟 | PB1 |
| SID | 串行数据 | PB2 |
| BL+/BL- | 背光 | BL+ 经 100Ω 电阻接 3.3V,BL- 接 GND |
| VO | 对比度 | 10k 电位器分压,约 3.0V |
如果你手里的工程基于并口模式,接线则完全不同:PSB 接 3.3V,DB0~DB7 接八个 GPIO,RS、RW、E 各占一个引脚,CS 可以接地。并口的好处是每次只发一个字节,写命令开销小,绘图刷新快;坏处是占 11 个 GPIO,在 GD32F103 这种 LQFP48 封装上往往要牺牲一部分外设引脚。串口模式用三个引脚就能跑,所以下面的初始化、显示、绘图代码全部基于串口模式展开。
3. LCD12864 初始化序列与最小驱动代码:GD32F103 显示第一行字符
3.1 初始化命令表与执行顺序
ST7920 上电后不是立即能接收数据,电源稳定需要至少 40ms。之后连续发送三次 0x30,把控制器内部状态机锁定到"8 位数据长度、基本指令集"。注意这里说的 8 位是针对内部并行总线,串口模式下也必须发这些命令,只是传输方式变成三帧。
| 命令 | 值 | 作用 | 建议等待 |
|---|---|---|---|
| 功能设定 | 0x30 | 8 位数据长度,基本指令集 | 100us |
| 显示开关 | 0x08 | 先关显示,避免清屏闪烁 | 100us |
| 清屏 | 0x01 | 清 DDRAM,光标归位 | 2ms |
| 输入模式 | 0x06 | 写数据后地址自动 +1,画面不移动 | 100us |
| 显示开关 | 0x0C | 显示开、光标关、反白关 | 100us |
0x30 为什么发三次?第一次可能落在上电不稳期,第二次确保接口模式进入 8 位,第三次作为状态确认。只发一次 0x30 也能点亮一部分屏,但温度变化或供电波动时容易随机乱码,三次是工程上的冗余习惯。0x01 清屏命令一定要等多于 1.6ms,ST7920 清 DDRAM 是指针遍历操作,不等它完成就继续写数据,新内容会被清掉一部分,表现是"第一个字符能显示,后面的消失"。
void lcd_init(void) { lcd_gpio_init(); lcd_delay_us(50000); /* 上电稳定,手册要求 > 40ms */ lcd_write_byte(0, 0x30); lcd_delay_us(120); lcd_write_byte(0, 0x30); lcd_delay_us(120); lcd_write_byte(0, 0x30); lcd_delay_us(120); lcd_write_byte(0, 0x08); lcd_delay_us(120); lcd_write_byte(0, 0x01); lcd_delay_us(2000); lcd_write_byte(0, 0x06); lcd_delay_us(120); lcd_write_byte(0, 0x0C); lcd_delay_us(120); }这段初始化里的 120us 比手册典型的 72us 多留了余量。串口模式下每次命令要传三个 8 位段,模块内部串转并需要额外时间,余量太小会偶发丢命令。lcd_delay_us 用空循环实现是因为初始化阶段 SysTick 可能还没配置,直接用库函数 delay_us 会死等;等后面系统时钟初始化完,可以替换成基于 SysTick 的版本。
3.2 行地址映射与 GB2312 汉字发送
显示文本时最关键的不是怎么发数据,而是 DDRAM 地址怎么算。ST7920 内部 DDRAM 只排了两行,每行 16 个半角字符,但 12864 模块物理上要显示 4 行 16×16 汉字,于是厂商把两行各拆成前后两半,拼出四个显示行。行首地址是 0x80、0x90、0x88、0x98,而不是很多人以为的连续地址。
void lcd_set_cursor(uint8_t row, uint8_t col) { uint8_t addr; switch (row) { case 0: addr = 0x80 + col; break; case 1: addr = 0x90 + col; break; case 2: addr = 0x88 + col; break; default: addr = 0x98 + col; break; } lcd_write_byte(0, addr); } void lcd_put_char(uint8_t dat) { lcd_write_byte(1, dat); } void lcd_put_string(uint8_t row, uint8_t col, const char *s) { lcd_set_cursor(row, col); while (*s) { lcd_put_char((uint8_t)(*s)); s++; } }col 的单位是半角字符列,一行 16 个半角或 8 个汉字。设置地址时 col 直接加在行首地址上即可,ST7920 会在写数据后自动递增,所以连续发送一串字符不需要反复设置地址。显示汉字时直接把 GB2312 编码的两个字节依次通过 lcd_put_char 发出去,例如"温度"的编码是 0xCE 0xC2 0xB6 0xC8。要注意的是编译环境:源文件保存为 UTF-8 时,中文字符串字面量是 UTF-8 编码,ST7920 不认,必须把源文件改成 GBK/GB2312 编码,或者用 \x 转义字节数组。这个是小问题,但排错时最容易忽略。
int main(void) { lcd_init(); lcd_set_cursor(0, 0); lcd_put_string(0, 0, "LCD12864 OK"); lcd_set_cursor(1, 1); lcd_put_string(1, 1, "\xCE\xC2\xB6\xC8 36.5"); while (1); }第二行显示内容里前半段是 GB2312 字面量,后半段是 ASCII,两者混排时 ST7920 能自动处理,用户不需要按字符宽度切换。注意行地址 0x90 起点的 col=1 会把第一个汉字放在第二行第二列,16×16 汉字实际占据 DDRAM 两个半角列,后续 ASCII 字符会接着这个宽度排下去。如果不希望混排,统一用全角空格占位。
3.3 初始化失败时的排查顺序
屏不亮或满屏乱码时,按三查顺序走,不要先怀疑命令值。第一查 CS:用示波器测 CS 在发送期间是否确实拉低,很多模块的 CS 引脚被误接到 VCC 或 3.3V,整个串口状态机永远收不到帧头。第二查 SCK 波形:正常一帧应该是三段各 8 个脉冲,每段之间有明显空闲;如果看到连续 24 个脉冲没有停顿,说明 CS 没参与帧控制,需要检查 lcd_write_byte 里 CS 拉高的时机。第三查 0x01 后的延时:把 2ms 改成 5ms 再试。这三步能解决九成初始化问题。剩下的一成大概率出在电平匹配或 PSB 悬空,回到 2.3 节处理。
提示:用逻辑分析仪抓串口波形时,触发条件设 CS 下降沿,采样率不低于 10MHz,重点看 SID 在 SCK 上升沿前是否建立。ST7920 对上升沿前 200ns 的数据保持时间有要求,GPIO 模拟很容易满足,硬件 SPI 则不一定。
4. LCD12864 绘图模式与帧缓冲:GDRAM 地址映射和画点实现
4.1 GDRAM 的结构:32×16 个 16 位单元,不是 128×64 位平面
ST7920 的绘图模式是 LCD12864 区别于普通字符屏的核心能力,但它的 GDRAM 组织方式和 KS0108 完全不一样。KS0108 的显存是 8 页×64 列,每页对应 8 行像素;ST7920 则把整个 128×64 屏幕划分成 32 行×16 列个单元,每个单元是 16 位数据。垂直地址 0~31 中的每一个管理两行像素,水平地址 0~15 中的每一个管理 8 列像素。一个 16 位字里,高字节对应偶数像素行,低字节对应相邻的奇数像素行。
| GDRAM 垂直地址 | 覆盖显示行 | 数据字节安排 |
|---|---|---|
| 0 | 第 0 行、第 1 行 | 高字节画第 0 行,低字节画第 1 行 |
| 1 | 第 2 行、第 3 行 | 高字节画第 2 行,低字节画第 3 行 |
| 2 | 第 4 行、第 5 行 | 高字节画第 4 行,低字节画第 5 行 |
| ... | ... | ... |
| 31 | 第 62 行、第 63 行 | 高字节画第 62 行,低字节画第 63 行 |
水平方向上,hx=0 对应列 0~7,hx=1 对应列 8~15,依此类推,hx=15 对应列 120~127。每个字节内最高位对应这 8 列中最左边的一列。这个映射意味着:想直接在 GDRAM 里改一个点,必须先把所在的 16 位单元读出来修改再写回,而 ST7920 串口模式下读 GDRAM 几乎没有实用价值。工程上通用的方案是在 GD32 内部维护一个 32×16 的 uint16_t 数组作为帧缓冲,所有画点、画线、写字模先改这个数组,需要上屏时再整体刷进 GDRAM,一劳永逸避开读操作。
static uint16_t fb[32][16]; void lcd_fb_clear(void) { memset(fb, 0, sizeof(fb)); } void lcd_fb_point(uint8_t x, uint8_t y, uint8_t color) { uint8_t vy, hx, bitpos; uint16_t mask; if (x >= 128 || y >= 64) { return; } vy = y >> 1; hx = x >> 3; bitpos = 7 - (x & 7); mask = (uint16_t)1 << bitpos; if (y & 1) { if (color) { fb[vy][hx] |= mask; } else { fb[vy][hx] &= (uint16_t)~mask; } } else { if (color) { fb[vy][hx] |= (uint16_t)(mask << 8); } else { fb[vy][hx] &= (uint16_t)~(mask << 8); } } } void lcd_fb_flush(void) { uint8_t vy, hx; lcd_write_byte(0, 0x34); /* 进入扩充指令集,关闭图形显示 */ for (vy = 0; vy < 32; vy++) { for (hx = 0; hx < 16; hx++) { lcd_write_byte(0, 0x80 + vy); /* 设置垂直地址 */ lcd_write_byte(0, 0x80 + hx); /* 设置水平地址 */ lcd_write_byte(1, fb[vy][hx] >> 8); lcd_write_byte(1, fb[vy][hx] & 0xFF); } } lcd_write_byte(0, 0x36); /* 打开图形显示 */ }lcd_fb_point 中 y >> 1 是行号除以 2,得到垂直地址;x >> 3 是列号除以 8,得到水平地址;bitpos 用于定位这 8 列中的具体某一列。偶行数据放在 16 位字的高字节,所以掩码要左移 8 位;奇行数据放低字节,直接用原掩码。刷新函数先发 0x34 把图形显示关掉,再逐单元设置地址并写入两个字节,最后发 0x36 打开图形显示。关掉图形显示再写,是为了避免每个单元写入过程中叠加显示残影;这个"写完再开"的顺序如果反过来,屏幕会闪得厉害。
4.2 画线函数:Bresenham 在 128×64 上的简化实现
有了画点函数,画线可以直接用 Bresenham 算法。GD32F103 的主频对整数运算绰绰有余,不需要查表优化,代码短反而容易维护。
static void lcd_fb_line(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1, uint8_t color) { int16_t dx, dy, sx, sy, err; dx = abs((int16_t)x1 - x0); dy = abs((int16_t)y1 - y0); sx = (x0 < x1) ? 1 : -1; sy = (y0 < y1) ? 1 : -1; err = dx - dy; while (1) { lcd_fb_point(x0, y0, color); if (x0 == x1 && y0 == y1) { break; } if (2 * err > -dy) { err -= dy; x0 += sx; } if (2 * err < dx) { err += dx; y0 += sy; } } }这里的 Bresenham 实现把误差判断放在两个独立的 if 里,而不是 else if,这样在斜率接近 1 时能同时在一个像素步进上增加 x 和 y 的偏移,保证线的连续性。x0、y0 参数用 uint8_t,进入函数后转成 int16_t 做减法,防止无符号数溢出。如果只需要水平线或垂直线,可以单独写快速版本:水平线循环调用 point 时 x 递增,y 不变,省去每次计算 dx、dy;但 128×64 的点阵规模下,通用版瓶颈在刷新函数,画线本身消耗很小,先保证正确性再优化不迟。
4.3 16×16 汉字点阵字模的显示方法与取模参数
绘图模式下显示自定义汉字,字模数据通常来自 PCtoLCD2002。取模设置要固定为三项:逐行式、每行 8 位、高位在前。这样导出的 32 字节数组,每两字节对应汉字的一行 16 个点,顺序与行号一致。如果你用"列行式"取模,字节组织和屏幕像素方向不对应,会导致字模整体旋转 90 度。
void lcd_fb_put_hanzi(uint8_t x, uint8_t y, const uint8_t *font16) { uint8_t row, col; for (row = 0; row < 16; row++) { uint8_t b0 = font16[row * 2]; uint8_t b1 = font16[row * 2 + 1]; for (col = 0; col < 8; col++) { if (b0 & (0x80 >> col)) { lcd_fb_point(x + col, y + row, 1); } if (b1 & (0x80 >> col)) { lcd_fb_point(x + 8 + col, y + row, 1); } } } }逐行式字模里 b0 是 16 点中的左 8 点,b1 是右 8 点,每字节最高位对应最左像素,和 4.1 节 GDRAM 的位序定义一致,所以不需要额外反转。x 建议取 8 的整数倍,比如 0、8、16、24,原因是当 x 不是 8 的倍数时,字模字节的位会横跨两个水平地址单元,lcd_fb_point 虽然能正确处理,但每次刷新要额外计算脏矩形,性能没有提升。y 则没有对齐要求,任意 0~63 都能正确显示。
文本模式与绘图模式的切换也要注意。文本模式用 0x30 基本指令集操作 DDRAM,绘图模式用 0x34/0x36 扩充指令集操作 GDRAM。切换回文本模式后,DDRAM 内容不会丢,但地址指针停留在上次位置,所以切回后要重新执行 lcd_set_cursor。很多网上代码"绘图后文字乱码"的根因就在这里:漏了重设光标,字符显示到了屏幕外的 DDRAM 区域。
5. 从 STM32F103 移植到 GD32F103 的三个坑和局部刷新优化
5.1 坑一:GD32F103 的 108MHz 主频让空循环延时缩水
GD32F103 最高主频 108MHz,而 STM32F103 是 72MHz。从 STM32 工程移植时,如果延时函数是参照 72MHz 写死的空循环,在 GD32 上整个初始化流程会快 1.5 倍。ST7920 对命令间隔不敏感,但 0x01 清屏后的等待如果从 1.6ms 缩到 1ms 附近,低概率出现首屏乱码。正确的做法是配置 SysTick 后,把所有延时统一替换为基于 SystemCoreClock 的版本,或者至少把空循环次数按 108/72 比例放大。在还没有配置时钟树的裸机前期阶段,lcd_delay_us 维持空循环没问题,但要在头文件里用宏标注"此参数依赖主频",防止后续换芯片型号时踩坑。
5.2 坑二:硬件 SPI 驱动 ST7920 时,CS 和字节边界容易粘连
GD32F103 的 SPI0 可以轻松输出 9MHz 甚至 18MHz 时钟,理论上能驱动 ST7920 串口,实际却经常出现初始化成功后偶发乱码。原因在 ST7920 的帧格式:它需要"完整三段"作为一个处理单位,段与段之间最好有停顿,而硬件 SPI 连续发送 0xF8、0xC0、0x00 三个字节时,SCK 是无间隙连续翻转的,CS 如果由软件一直拉低,模块内部状态机会把三段数据当作一个连续比特流拼接,偶尔错一位导致整帧报废。我试过的可靠方案是每发送一段就把 CS 拉高再拉低,让模块状态机复位到帧头;但这样一来 CS 翻转开销抵消了 SPI 的速度优势,整屏刷新和 GPIO 模拟差不了太多。所以我的建议是:串口模式就用 GPIO 模拟,把 SPI 外设留给更需要它的外设。
5.3 坑三:VO 对比度电位器与背光调节互相干扰
LCD12864 模块上通常有一个蓝色电位器,丝印只标 VO。旋转它改变的是 ST7920 的液晶驱动电压,不是背光亮度。有些廉价款模块把背光限流电阻也串进了同一条电源支路,调 VO 时背光跟着变暗,字符同时变淡,造成"电压不稳"的错觉。正确的调理方式是:VO 接一个独立 10k 多圈电位器,VCC 到 GND 分压后滑动端接 VO;背光 BL+ 串 100Ω 电阻接 3.3V。调试时先把 VO 拧到 VCC 侧,上电看到屏幕有浅底色后再慢慢向 GND 侧旋转,直到背景无残影、黑点不发虚。这一步调不好,再正确的初始化代码都会显示成一片模糊。
5.4 局部刷新:用脏矩形把整屏刷新量降低一个量级
整屏刷新要遍历 32×16 共 512 个单元,每个单元至少 4 次串行传输,GPIO 模拟下大约 10ms 以上。这是 ST7920 绘图模式做动态波形的主要瓶颈。解决思路是只刷发生变化的区域,维护一个脏矩形记录最小和最大的 vy、hx 范围。
typedef struct { uint8_t min_vy, max_vy; uint8_t min_hx, max_hx; } dirty_rect_t; static dirty_rect_t dr = { 32, 0, 16, 0 }; static void dr_mark(uint8_t vy, uint8_t hx) { if (vy < dr.min_vy) dr.min_vy = vy; if (vy > dr.max_vy) dr.max_vy = vy; if (hx < dr.min_hx) dr.min_hx = hx; if (hx > dr.max_hx) dr.max_hx = hx; } void lcd_fb_flush_dirty(void) { uint8_t vy, hx; if (dr.min_vy > dr.max_vy) { return; } lcd_write_byte(0, 0x34); for (vy = dr.min_vy; vy <= dr.max_vy; vy++) { for (hx = dr.min_hx; hx <= dr.max_hx; hx++) { lcd_write_byte(0, 0x80 + vy); lcd_write_byte(0, 0x80 + hx); lcd_write_byte(1, fb[vy][hx] >> 8); lcd_write_byte(1, fb[vy][hx] & 0xFF); } } lcd_write_byte(0, 0x36); dr.min_vy = 32; dr.max_vy = 0; dr.min_hx = 16; dr.max_hx = 0; }在 lcd_fb_point 成功写入一个点之后,紧接着调用 dr_mark(vy, hx) 把该点所在的垂直地址和水平地址并入脏矩形。画一条竖线时,只有 1 个 hx 和若干 vy 被标记,刷新量从 512 个单元降到几十个单元。做滚动波形时还可以进一步优化:每次只把新增列扫进去,历史列不重复刷新,这样动态更新速度能提高一倍以上。对于纯静态菜单,显示一次后不再调用任何刷新函数,帧缓冲保留在 RAM 里备用,CPU 完全释放出来处理业务逻辑。
本文还有配套的精品资源,点击获取