简介:一份面向嵌入式开发者的STM32 LCD12864串行驱动完整工程示例。资源基于STM32与LCD12864点阵屏,重点演示通过SPI或I2C串行接口实现屏幕初始化、字符与图形显示、滚动控制等操作,并提供硬件GPIO配置、波特率设定及调试优化思路,适合正在学习STM32外设驱动或需要快速集成LCD12864显示功能的工程师参考。压缩包内共188个文件,包含核心的C源文件与头文件、编译生成的o/crf等中间文件、Keil工程配置(uvprojx/uvoptx)以及说明文档与PDF资料,整体体积仅5.26MB,结构清晰便于查阅。目前已有344人学习下载,资源中包含示例代码、库函数调用演示和关键时序配置,可直接导入Keil工程,对照实物调试,能帮助使用者节省底层驱动开发时间,快速实现LCD12864的串口显示。
1. 为什么把 lcd12864 驱动从并行换成串行不会更慢
拿到一块 12864 屏,多数人第一反应是接 8 根数据线加 RS、RW、E,算下来十几个引脚,在 STM32 最小系统板上能剩下几个 GPIO 给按键和传感器?而 lcd12864 的控制器 ST7920 其实内置了串行模式,只需要 CS、SCLK、SID 三根线就能完成全部读写。速度上也不用担心:常见应用里往屏幕上写一帧字符数据本身就只有几百字节,ST7920 内部刷新率远高于人眼可感知的变化,串行传输那点时间差在菜单切换、仪表显示这种场景里完全感知不到。真正被并行方式消耗掉的,是宝贵的引脚资源和布线的耐心。这篇按 GPIO 模拟时序讲透串行驱动,从波形、初始化、中文编码到硬件 SPI 移植,给一套能直接抄进标准库或 CubeMX 工程的写法。
2. LCD12864 串行模式时序:ST7920 三线协议与 GPIO 模拟实现
2.1 串行模式下 CS、SCLK、SID 分别接什么
LCD12864 的接口排针上通常标着 RS、RW、E,但在 ST7920 的串行模式下,这三个引脚的语义完全变了。RS 变成 CS(片选),RW 变成 SID(串行数据),E 变成 SCLK(串行时钟)。也就是说,你不需要额外飞线,直接用原来的并行接口引脚,只是驱动方式不同。
下面是一张常见的连接表,以 STM32F103C8T6 为例:
| LCD12864 引脚 | 功能(并行) | 功能(串行) | STM32 GPIO |
|---|---|---|---|
| RS | 寄存器选择 | CS 片选 | PB12 |
| RW | 读写选择 | SID 数据 | PB13 |
| E | 使能时钟 | SCLK 时钟 | PB14 |
| PSB | 并/串选择 | 接 GND | GND |
| V0 | 对比度 | 对比度 | 接 10K 电位器 |
| LED_A / LED_K | 背光 | 背光 | 3.3V / GND |
这里最关键的引脚是 PSB。PSB 接高电平是并行模式,接低电平才启用串行模式。很多人代码写得没问题,屏幕却毫无反应,就是 PSB 悬空或接错。低电平要直接接地,不要通过电阻,防止接触不良导致模式漂移。
GPIO 配置上,三个信号脚全部设为推挽输出,速度选 50MHz。注意 STM32F1 的 GPIO 默认是开漏状态,不初始化直接拉电平是无效的。
2.2 一个字节的发送波形:同步位、高四位、低四位
ST7920 的串行协议每一帧数据是 24 bit,分成三段:同步位 + 数据高四位 + 数据低四位。同步位固定是11111加一个 RW 位和 RS 位。实际上代码里通常直接用两个魔数:0xF8表示写命令,0xFA表示写数据。
发送顺序是:先发0xF8或0xFA,再发命令或数据的高四位(放到字节的高 4 位,低 4 位为 0),最后发低四位(放到高 4 位,低 4 位为 0)。每个字节发送时按 MSB first,SCLK 拉高时 SID 上的电平被锁存。
下面是最小实现:
#define LCD_CS_H GPIO_SetBits(GPIOB, GPIO_Pin_12) #define LCD_CS_L GPIO_ResetBits(GPIOB, GPIO_Pin_12) #define LCD_SID_H GPIO_SetBits(GPIOB, GPIO_Pin_13) #define LCD_SID_L GPIO_ResetBits(GPIOB, GPIO_Pin_13) #define LCD_SCLK_H GPIO_SetBits(GPIOB, GPIO_Pin_14) #define LCD_SCLK_L GPIO_ResetBits(GPIOB, GPIO_Pin_14) void LCD_SendByte(u8 dat) { u8 i; for (i = 0; i < 8; i++) { LCD_SCLK_L; // 时钟拉低,准备数据 if (dat & 0x80) LCD_SID_H; else LCD_SID_L; LCD_SCLK_H; // 上升沿锁存数据 dat <<= 1; } }这段代码是一次发送一个完整字节的底层函数。循环里先拉低 SCLK,再设置 SID,最后拉高 SCLK,顺序不能反,否则第一个 bit 会被漏掉。dat <<= 1是把次高位移到最高位,配合0x80判断实现 MSB first。
2.3 为什么软件延时 5us 是安全值,不是越小越好
ST7920 串行时钟的极限频率并不高,而且不同批次屏幕体质差异很大。再加上 GPIO 模拟时序本身没有硬件 SPI 那样稳定的占空比,把延时压得太小,长排线或者接触不良时波形边沿会变差。我一般的做法是每个 bit 周期留 5us,对应一个字节 40us,一帧命令 3 个字节约 120us,初始化几十条指令加起来也就几毫秒,完全无感。
如果后续想提速,可以在写命令循环里逐步减小延时测试稳定性,但注意屏幕刷新不是瓶颈,功耗和 EMI 才是。给一个经验参考表:
| SCLK 周期 | 效果 | 适用场景 |
|---|---|---|
| 2us | 多数屏幕可跑,长线易出错 | 飞线较短、无干扰环境 |
| 5us | 稳定,推荐默认 | 大多数开发板和自制 PCB |
| 10us | 非常保守 | 排线超过 20cm 或与电机驱动共用电源 |
GPIO 速度等级设在 50MHz,但延时不加照样可能误码。核心原因是 ST7920 内部对 SID 的采样窗口有要求,数据建立时间不足时,采样到的就是上一次的电平。
3. 在标准库与 CubeMX 工程中搭建 lcd12864 串行驱动代码框架
3.1 工程接入:标准库新建工程后需要准备什么
这里默认你已经用标准库建好了 STM32F103 的最小工程。如果是从 Keil5 里新建,记得先装好对应的芯片包,否则 Device 列表里找不到芯片型号。标准库工程需要包含stm32f10x_gpio.c和stm32f10x_rcc.c,这两个文件会在编译时提供 GPIO 初始化和时钟开启的函数。
GPIO 初始化代码如下:
void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); LCD_CS_H; LCD_SCLK_H; LCD_SID_H; }注意GPIO_Mode_Out_PP是推挽输出,不是开漏。开漏模式下高电平要靠外部上拉,而 STM32 内部上拉太弱,会导致 SCLK 上升沿过缓,ST7920 采样出错。初始化末尾把三个引脚都拉高,是为了让屏幕在正式发送数据前处于空闲状态。
3.2 初始化序列:哪些指令可以省,哪些不能省
ST7920 上电后默认处于 8 位并行模式,即使 PSB 接地,控制器内部也需要一个“切换”过程。所以初始化序列里第一条 0x30 必须发送两次以上,确保控制器稳定进入基本指令集。下面是完整初始化函数:
void LCD12864_Init(void) { LCD_GPIO_Init(); delay_ms(50); LCD12864_WriteCmd(0x30); // 基本指令集,8 位数据长度 delay_ms(5); LCD12864_WriteCmd(0x30); delay_ms(5); LCD12864_WriteCmd(0x0C); // 显示开,光标关,反显关 delay_ms(5); LCD12864_WriteCmd(0x01); // 清屏 delay_ms(10); LCD12864_WriteCmd(0x06); // 地址自动加一 }初始化指令中 0x30 不能省,0x0C 不能省,0x01 不能省。0x06 是设置写入后地址自动加一,省掉的话连续写字符时每次都要重新设置地址,显示字符串会全部堆在同一个位置。0x30 发送后要留至少 5ms 延时,这是 ST7920 内部模式切换的稳定时间,不少人的黑屏问题就是这里延时不够。
3.3 写命令、写数据与 DDRAM 地址换算
写命令和写数据是整个驱动的中枢:
void LCD12864_WriteCmd(u8 cmd) { LCD_CS_L; // 片选拉低,开始传输 LCD_SendByte(0xF8); // 同步位 + RS=0(RW=0),写命令 LCD_SendByte(cmd & 0xF0); // 高四位 LCD_SendByte((cmd & 0x0F) << 4); // 低四位 LCD_CS_H; // 片选拉高,结束传输 } void LCD12864_WriteData(u8 dat) { LCD_CS_L; LCD_SendByte(0xFA); // 同步位 + RS=1,写数据 LCD_SendByte(dat & 0xF0); LCD_SendByte((dat & 0x0F) << 4); LCD_CS_H; }0xF8的二进制是11111000,前五位是同步位,最后三位中包含了 RS 和 RW 的置位组合。0xFA则是11111010,对应 RS=1。这里不要自己去拼每一位,直接用这两个魔数最不容易出错。
DDRAM 地址换算有个经典坑:ST7920 的第三行地址是 0x88 而不是线性递增的 0xA0。写一个定位函数:
void LCD12864_SetPos(u8 row, u8 col) { u8 addr[] = {0x80, 0x90, 0x88, 0x98}; if (row > 3) row = 3; if (col > 7) col = 7; LCD12864_WriteCmd(addr[row] + col); }行地址与坐标对应关系如下:
| 行号 | DDRAM 起始地址 | 实际显示行 |
|---|---|---|
| 0 | 0x80 | 第一行 |
| 1 | 0x90 | 第二行 |
| 2 | 0x88 | 第三行 |
| 3 | 0x98 | 第四行 |
列地址 0 到 7 对应屏幕左到右的 8 个汉字位置。这个函数在刷字符串前必须调用一次,否则字符串会接着上次的位置往下写。
3.4 一个最小显示例程:清屏、显示字符串、显示整数
驱动函数齐了,组合起来就是一个完整菜单:
void LCD12864_ShowString(u8 row, u8 col, char *str) { LCD12864_SetPos(row, col); while (*str) { LCD12864_WriteData(*str++); } } void LCD12864_ShowInt(u8 row, u8 col, int val) { char buf[12]; sprintf(buf, "%d", val); LCD12864_ShowString(row, col, buf); } int main(void) { delay_init(); LCD12864_Init(); LCD12864_ShowString(0, 0, "温度:"); LCD12864_ShowInt(0, 5, 26); while (1) { } }sprintf需要包含stdio.h,标准库工程默认支持。频繁调用sprintf有栈开销,MCU 内存吃紧时可以改用自定义的整数转字符串函数,但大多数 F103 工程这点开销可以忽略。字符串函数里的while (*str)会一直发送直到遇到\0,注意确认传入的字符串以空字符结尾。
4. 显示中文:内建字库、GB2312 编码和字符串乱码
4.1 ST7920 内建字库到底有哪几个区
ST7920 内部固化了多组字库:基本指令集下可以访问 ASCII 半角字符,扩展指令集下可以访问中文字库。中文区编码遵循 GB2312,每字两字节,高字节范围0xB0-0xF7,低字节范围0xA1-0xFE。写入时先高字节后低字节,屏幕才会显示对应汉字。
不要用 UTF-8 编码直接写字符串。UTF-8 的汉字是三字节编码,ST7920 按两字节解析会全部错位,显示出来就是乱码。这个问题在 Keil MDK 里尤其常见,默认保存编码是 UTF-8,直接LCD12864_ShowString(0, 0, "温度")大概率乱码。
解决办法有两个:在 Keil 的 Edit > Configuration > Editor 里把 Encoding 改成 Chinese GB2312,或者在代码中内置一张转换表。改编辑器编码是治本方案,但会影响到整个工程文件的换行和注释,老项目慎动。
如果工程里既有 UTF-8 文件又不能整体改编码,可以在代码里做一个简单的转码查表,只针对常用汉字做映射:
const char *gb2312_table[] = { "温", "度", "湿", "压", "设", "置", };这种方式不适合全字库,但做固定菜单文本足够。还有一种思路是在源码里直接写转义序列,比如"\xC4\xE3"是 UTF-8 还是 GB2312 取决于你如何生成这个序列,这种方法最可靠,但可读性差,只适合几个固定词。
4.2 显示自定义图形:16×16 点阵取模的方向问题
除了内建字库,ST7920 也支持用扩展指令集写绘图模式,或者直接在文本模式下通过 CGRAM 定义自定义字符。自定义字符需要自己取模。取模方向和 SSD1306 不一样,12864 常见的是逐行式、每行 2 字节、高位在前,这样才和 ST7920 的显存排列吻合。
取模软件设置如下:
| 设置项 | 推荐值 |
|---|---|
| 点阵大小 | 16×16 |
| 取模方式 | 逐行式 |
| 每行字节 | 2 字节 |
| 字节顺序 | 高位在前 |
| 行扫描方向 | 从上到下 |
发送自定义图形时,要把控制器切换到扩展指令集,写入 CGRAM 地址后再发数据。切回基本指令集要重新发 0x30,否则后面文本显示全部错乱。这个切来切去的过程非常容易漏掉恢复指令,写入前和写完后各打一条延时。
4.3 与 GPIO、定时器等外设共用时的刷新策略
LCD12864 串行驱动本身不占用中断,但如果显示函数在中断里调用,或者主循环里刷得太频繁,会出现两个典型问题:一是每个字符 3 个字节的发送过程被打断,导致花屏;二是 ST7920 内部命令处理需要时间,连续发送不等待,高字节和低字节会粘在一起。
常见的处理方式是主循环里设置一个display_dirty标志,需要刷新时置位,主循环检测到标志再调用显示函数。数据更新频率控制在 10Hz 左右就足够,毕竟 12864 的响应速度远低于这个值。与定时器中断、串口中断配合时,显示函数里发送指令的循环要避免被更高优先级中断长时间打断,可以在发送前关中断、发完再开。
__disable_irq(); LCD12864_ShowString(0, 0, "temp=26"); __enable_irq();这段代码很短,关中断时间约几百微秒,对实时性要求不高的场景完全可以接受。如果中断里有电机控制这类不允许长时间屏蔽的任务,那就把显示刷新放在中断优先级最低的上下文里,或者用硬件 SPI 加 DMA,把发送任务交给外设。
5. 硬件 SPI 与 DMA 驱动 lcd12864 串行的可行方案
5.1 硬件 SPI 为什么能跑但没人敢直接上 DMA
ST7920 的串行数据格式从电气角度看就是 SPI,具体来说是 SPI Mode 3(CPOL=1, CPHA=1),数据在上升沿被锁存,8 位数据宽度,MSB first。用 STM32 的硬件 SPI 发送时,每个字节的发送由外设完成,CPU 只需要往数据寄存器写值。
但 DMA 方案有一点尴尬:一帧命令由 3 个字节组成,片选 CS 要求在整个传输期间保持拉低。如果 DMA 发送完成后立刻拉高 CS,那发送间隔由 DMA 中断响应时间决定,抖动大;如果延长 CS 低电平时间,又拖慢了整体刷新速度。所以常见做法还是 DMA 发送完一整帧,最后一次传输完成中断里拉高 CS。
5.2 用 CubeMX 配置 SPI 并发送一帧命令
CubeMX 里把对应引脚复用为 SPI,配置如下:
| SPI 参数 | 设置值 |
|---|---|
| Mode | Transmit Only Master |
| Data Size | 8 Bit |
| Clock Polarity | High |
| Clock Phase | 2 Edge |
| Prescaler | 16(对应约 4.5MHz,保守值) |
| NSS | Software |
片选 CS 不要交给 SPI 硬件的 NSS 管理,用普通 GPIO。代码里发送一帧命令:
uint8_t frame[3]; void LCD_SPI_WriteCmd(uint8_t cmd) { frame[0] = 0xF8; frame[1] = cmd & 0xF0; frame[2] = (cmd & 0x0F) << 4; HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, frame, 3, 100); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); }frame数组复用同一块缓冲区,写数据和写命令可以共用一个发送函数。预分频选 16 是因为 ST7920 的串行时钟上限并不高,保守一点反而稳定。代码里HAL_SPI_Transmit是阻塞式,超时时间给 100ms 足够。
5.3 GD32F103 等替代芯片上直接移植的注意点
GD32F103 的 SPI 外设和 STM32F103 高度兼容,寄存器布局几乎一致,标准库和 HAL 代码基本可以无缝平移。移植时真正要改的是时钟树:GD32 的 APB2 总线时钟来源和分频系数与 STM32 不同,SPI 的波特率实际值会不一样,所以不要照抄 STM32 上的预分频值,先降到 8 分频跑通再说。
GPIO 的复用功能配置在 GD32 上是GPIO_Mode_AF_PP,这一点和 STM32 的标准库一致。唯一容易出问题的是 GD32 的 GPIO 翻转速度选项,必须设为最高档,否则 SCLK 边沿变缓,高速传输下会误码。如果用的是 GD32 自己的固件库,函数名不同但参数含义一致,对照数据手册逐个替换即可。
6. 黑屏、乱码、第一行不显示:lcd12864 串行驱动的验证与排错
6.1 上电先看三个信号,不要先改代码
遇到屏幕没反应,先别急着改延时和初始化序列。用示波器或逻辑分析仪抓 CS、SCLK、SID 三根线的波形,对比下面几个特征:
| 观察现象 | 可能原因 | 处理方向 |
|---|---|---|
| CS 一直高电平 | GPIO 配置错或代码没执行到写命令 | 检查初始化函数是否被调用 |
| SCLK 有脉冲但 SID 恒低 | SID 引脚驱动能力不足或 GPIO 开漏 | 检查推挽输出配置 |
| SCLK 无脉冲 | GPIO 时钟未开启 | 检查 RCC 时钟 |
| 波形正常但黑屏 | PSB 未接地或对比度电位器没调 | 检查硬件连接和 V0 电压 |
| 只有第一行花屏 | 初始化时 0x30 发送过快 | 加大初始化各步骤延时 |
逻辑分析仪采样率不用太高,10MHz 足够看清 5us 的脉冲时序。没有仪器也可以先把 SCLK 的延时调到最大,用 LED 观察 SID 发送数据时是否有电平跳变,能区分是软件问题还是硬件问题。
6.2 串行模式最常见的三个坑
第一个坑是 CS 在发送一个字节后就被拉高。ST7920 的串行数据是一帧一帧处理的,一帧由同步位和高低位组成,CS 必须保持低直到最后一个位发送完。很多人把三字节发送拆成三次片选,屏幕偶尔显示正常偶尔乱码,就是这个原因。
第二个坑是清屏指令 0x01 后不加延时。ST7920 清屏需要约 1.6ms 内部处理时间,立即写入下一条指令会丢失。我习惯给 10ms,反正不影响体验。
第三个坑是初始化时只发一次 0x30。控制器上电后可能停在 8 位并行模式,只发一次指令无法完成模式切换,必须连续两次以上并保证间隔。一个简单的最小验证代码如下:
LCD12864_WriteCmd(0x30); delay_ms(20); LCD12864_WriteCmd(0x30); delay_ms(20); LCD12864_WriteCmd(0x0C); delay_ms(20); LCD12864_WriteCmd(0x01); delay_ms(20); LCD12864_WriteCmd(0x80); LCD12864_WriteData('A');如果这段代码能在屏幕第一行显示一个A,说明协议、接线、GPIO 全部正常,剩下的问题都出在编码和坐标地址上。
6.3 验证显示通路后,再改编码和字体
显示通路通了,中文乱码就和驱动无关了。建议先用 ASCII 字符验证每一行的起始地址,比如在四行分别显示1234、5678、9101、1121,确认地址换算函数无误后再接中文。中文乱码时先检查源码文件编码是否为 GB2312,再看发送顺序是否先高字节后低字节。这两个位置排查完,剩下的就是取模方向问题了。
本文还有配套的精品资源,点击获取