尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32驱动带字库LCD12864实战:接口时序、GPIO模拟与FSMC优化

STM32驱动带字库LCD12864实战:接口时序、GPIO模拟与FSMC优化 简介面向嵌入式开发者的STM32驱动带字库LCD12864完整工程包围绕意法半导体STM32与128×64点阵液晶的交互展开系统梳理SPI、I2C及并行接口的驱动写法涵盖PSB引脚电平配置、内置汉字库启用流程、帧缓冲区和显存操作以及清屏、画点、画线、显示字符串等常用接口的具体实现思路。压缩包共199个文件大小5.04MB以C源码、头文件、编译生成的中间文件o/crf和Keil工程配置uvproj/uvopt为主体同时包含HEX烧录文件、MAP映射文件和列表文件方便查看编译映射、排查链接错误并可直接烧录到开发板验证。目前已有916人浏览学习。借助这份工程读者既能掌握带字库LCD12864的底层初始化时序与寄存器配置细节也能获得可移植的驱动模块代码以及针对多字节传输、读写时序等常见问题的调试排错经验适合正在学习STM32外设驱动或需要快速集成LCD显示的中级开发者作为参考模板。一次讲透STM32 驱动带字库 LCD12864 的完整实战经验玩 STM32 的人大概率都会碰到 LCD12864 这块屏。带字库版本内部固化了常用汉字和 ASCII 字库不需要额外外挂字库芯片直接用并口或者 SPI 接口就能显示中文对做仪表、菜单、嵌入式交互界面来说非常省事。这篇文章把我从零开始驱动 LCD12864 的完整过程、踩过的坑、以及工程化封装经验一次性说清楚适合正在做毕业设计、竞赛项目或者产品原型的朋友参考。先说明一点市面上LCD12864这个名字对应的控制器至少有 ST7920 和 ST7565 两大类带字库的通常是指 ST7920这也是本文的主角。ST7920 内部集成中文字库支持 8 位并口、4 位并口和串行 SPI 三种接口模式灵活性很高。我最早接触它是在一个温控器项目上当时还纠结要不要外挂一个 AT24C02 存字库后来发现 ST7920 自带 GB2312 编码的 8192 个汉字直接把代码里的汉字编码发过去就能显示省了一大堆事。下面从接口选型、时序原理、代码实现到排错方法一条线讲完。1. 硬件接口与引脚分配为什么说 PSB 和复位时序决定成败LCD12864 的硬件连接看起来简单无非就是电源、背光、数据线和几根控制线但实际布线时至少有三个点会决定这块屏能不能正常工作我在项目里都逐一验证过。先看典型引脚定义。带字库 ST7920 模块最常见的是 20 针并口接口引脚包括 VSS、VDD、V0对比度调节、RS寄存器选择、RW读写选择、E使能信号、DB0-DB7并行数据、PSB并口/串口选择、RST复位、以及背光 LED 和 LED-。如果你用的是那种 4 线 SPI 版本引脚又不一样需要单独确认。PSB 引脚是第一个最容易犯错的点。PSB 接高电平选并口模式接低电平选串口模式。很多模块出厂默认在 PSB 上做了上拉电阻理论上悬空就能工作在并口模式但实际项目中我不建议依赖模块内部上拉——一旦上拉电阻虚焊或者模块走线受干扰电平不确定屏幕轻则无响应重则花屏。我的做法是直接用 GPIO 推挽输出一个确定的高电平并在初始化代码里先配置这个引脚再操作屏幕。复位引脚 RST 同样重要。ST7920 内部虽然有上电自动复位电路但外部手动复位更可靠。我习惯在初始化函数一开始给 RST 一个至少 10ms 的低电平脉冲然后拉高确保控制器完全恢复到初始状态。这个延时不是随便写的ST7920 的数据手册写了复位时序要求外部复位脉冲宽度典型值在微秒级但为了兼容不同厂家的模块给足 10ms 是零风险做法。第三个关键点是 E 使能引脚的脉冲宽度。ST7920 在并口模式下要求 E 高电平持续时间不小于 450ns这是很多简化例程没做好的地方。单片机主频一高GPIO 翻转速度很快如果延时不够E 脉冲宽度不足控制器采不到有效数据表现就是初始化大部分时间正常偶尔抽风。我实测过一块主频 72MHz 的 STM32F103GPIO 模拟时序时如果 E 高电平后不加延时直接拉低脉冲宽度只有 200ns 左右远低于规格要求。加上一个 1us 的延时后问题彻底消失。所以 GPIO 模拟方案的代码里delay_us(1)这种看似不起眼的延时恰恰是稳定性的关键。静电放电ESD防护问题上我也吃过亏。12864 的复位引脚对噪声比较敏感如果系统里有继电器、电机这类感性负载建议在复位引脚对地并一个 0.1uF 电容必要时串联 1K 电阻隔离。背光 LED 的限流电阻不要省按规格书的典型值选我见过不少人直接把背光正极接到 3.3V结果屏亮是亮了三个月后亮度明显下降这就是长期过流导致的 LED 光衰。电源方面如果屏的背光电流较大尤其是 5V 版本尽量不要和 MCU 共用同一路 LDO。我手头这块模块背光全开时电流能到 120mA 左右和其他外设叠加后会把 3.3V 拉低到 3.15VMCU 直接重启。后来改成背光单独一路供电问题立刻消失。这个问题在电池供电的设备上尤其明显启动瞬间的压降足以让 MCU 进入掉电复位。另一个容易被忽视的细节是 V0 对比度调节。很多模块出厂默认 V0 悬空这种情况下屏幕要么全黑要么全白根本看不到内容。正确做法是接一个 10K 电位器到 V0调节到显示清晰、底影最淡的位置。有些模块已经把对比度电路做在板上了就不需要外接这个要看具体模块的硬件设计。初始化等待时间在 FastIO 方案里同样存在。虽然 IO 翻转速度上去了但屏幕内部控制器在接收到指令后需要处理时间不可能瞬间完成。所以 FastIO 方案跑的也是标准初始化时序只不过每次延时更精准、不会因为中断或调度导致时序抖动。我在测试中发现一个有意思的现象如果用普通的 GPIO 模拟时序每次指令后的延时如果偏短屏幕偶尔能点亮但显示内容会出现随机乱码或整体偏移。这是因为 ST7920 内部控制器在时序不满足时部分寄存器状态会进入不确定状态。这个问题的复现率不高但一旦在用户现场出现排查起来非常痛苦。严格按照数据手册的时序参数给足延时是唯一可靠的解法。2. 两种主流驱动方案GPIO 模拟与 FSMC 总线对比LCD12864 的驱动方案最常用的就是 GPIO 模拟时序和 FSMC 总线这两种我都实际写过各有各的适用场景。GPIO 模拟方案是把数据线、控制线全部接到普通 IO 口通过软件翻转 IO 电平来产生读写时序。FSMC 方案则是把 LCD 当作一块外部存储器由 MCU 硬件自动产生读写时序软件只需要往指定地址写数据即可。GPIO 模拟方案的优点是非常灵活几乎任何一款 STM32 都能用引脚分配不受限制想接哪个口就接哪个口硬件布线也方便。缺点是 CPU 占用率高每传输一个字节都需要软件逐位控制时序显示大量数据时 MCU 几乎处于满负荷状态。实测下来GPIO 模拟方式驱动全屏刷新一帧128*64 像素8*8 点阵字符模式需要刷新 8 行大概需要几十毫秒复杂的动态界面会感觉到明显的刷新迟滞。FSMC 方案只适用于带有 FSMC 外设的大容量型号比如 STM32F103ZET6、STM32F407ZGT6 这类。接法上有固定的对应关系数据线 D0-D7 要接到 FSMC 的特定引脚地址线根据 LCD 的 RS 引脚决定接到 A0 还是其他地址线。这种方式下写一个字节的数据CPU 只需要执行一次存储器写指令时序由 FSMC 硬件自动完成速度快得多显示动画或波形刷新时优势明显。注意FSMC 方案的地址映射关系需要计算清楚。以 ST7920 控制器的 LCD12864 为例如果把 RS 接到 FSMC_A0那么命令寄存器的地址就是 Bank1 基地址0x00000000数据寄存器地址是 Bank1 基地址0x00000002。这里的偏移量 2 对应 A0 地址线的翻转写错的话屏幕会完全无响应。对于刚接触 LCD12864 的新手我的建议是优先用 GPIO 模拟方案把协议跑通。原因很简单你可以拿逻辑分析仪或示波器方便地观察每一根线的时序出问题了一根线一根线地排查。用 FSMC 的话一旦时序不对你很难分清是地址译码问题、总线配置问题还是引脚复用的冲突。把 GPIO 方案跑通之后再切换到 FSMC 就是水到渠成的事。还有一种折中方案是使用 STM32 的 SPI 外设配合带 SPI 接口的 LCD12864 模块。这类模块把并行接口和 SPI 接口做在一起用 SPI 模式可以省掉 5 根控制线适合引脚紧张的项目。但 SPI 模式下的刷新速率受限于 SPI 时钟和控制器接口转换速度我实测比 FSMC 慢比 GPIO 模拟快属于中间档位。如果项目对刷新率要求不高且引脚紧张SPI 模式是个好选择。性能对比方面我写过一份简单的测试数据供各位参考基于 STM32F103 主频 72MHz驱动方式引脚占用全屏刷新耗时CPU 占用适用场景GPIO 模拟11 个约 8-30ms高静态界面、低成本板卡SPI 模式6-7 个约 5-15ms中引脚紧张的中速刷新FSMC 总线19 个约 1-3ms极低波形刷新、动画、GUI这个数据不是绝对标准不同厂家的模块、不同控制器ST7920、ST7565以及是否开启加速模式都会影响结果但量级可以作为一个选型依据。2.1 GPIO 模拟方案的完整代码框架GPIO 模拟方案的基本操作是读写引脚电平核心是严格的时序控制。下面这段代码是我常用的一套初始化序列基于 ST7920 控制器晶振频率按最常见的模块默认值处理// LCD12864 GPIO引脚定义以STM32F103C8T6为例 #define LCD_RS_PORT GPIOB #define LCD_RS_PIN GPIO_PIN_0 #define LCD_RW_PORT GPIOB #define LCD_RW_PIN GPIO_PIN_1 #define LCD_E_PORT GPIOB #define LCD_E_PIN GPIO_PIN_2 #define LCD_PSB_PORT GPIOB #define LCD_PSB_PIN GPIO_PIN_3 #define LCD_RST_PORT GPIOB #define LCD_RST_PIN GPIO_PIN_4 // 数据口D0-D7接GPIOB的Pin8-Pin15 #define LCD_RS_H() HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_SET) #define LCD_RS_L() HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_RESET) #define LCD_RW_H() HAL_GPIO_WritePin(LCD_RW_PORT, LCD_RW_PIN, GPIO_PIN_SET) #define LCD_RW_L() HAL_GPIO_WritePin(LCD_RW_PORT, LCD_RW_PIN, GPIO_PIN_RESET) #define LCD_E_H() HAL_GPIO_WritePin(LCD_E_PORT, LCD_E_PIN, GPIO_PIN_SET) #define LCD_E_L() HAL_GPIO_WritePin(LCD_E_PORT, LCD_E_PIN, GPIO_PIN_RESET) // 写一个字节到LCD数据线 static void LCD_Data_Write(uint8_t dat) { // 注意这里直接在GPIOB的ODR寄存器上操作避免逐位调用HAL函数造成时序抖动 GPIOB-ODR (GPIOB-ODR 0xFF00) | dat; } // 写命令RS0,RW0 void LCD_Write_Cmd(uint8_t cmd) { LCD_RS_L(); LCD_RW_L(); LCD_Data_Write(cmd); LCD_E_H(); delay_us(1); // 使能脉冲宽度至少满足数据手册的450ns LCD_E_L(); delay_us(5); // 指令执行时间ST7920约为72us这里留足余量 } // 写数据RS1,RW0 void LCD_Write_Data(uint8_t dat) { LCD_RS_H(); LCD_RW_L(); LCD_Data_Write(dat); LCD_E_H(); delay_us(1); LCD_E_L(); delay_us(5); } // 初始化序列 void LCD_Init(void) { // 硬件复位 LCD_RST_L(); delay_ms(10); LCD_RST_H(); delay_ms(10); // 功能设置8位数据接口基本指令集 LCD_Write_Cmd(0x30); delay_ms(1); LCD_Write_Cmd(0x30); delay_ms(1); LCD_Write_Cmd(0x30); delay_ms(1); // 显示开关开显示不显示光标 LCD_Write_Cmd(0x0C); delay_ms(1); // 清除显示 LCD_Write_Cmd(0x01); delay_ms(2); // 进入模式设置地址自动1光标右移 LCD_Write_Cmd(0x06); delay_ms(1); }这段代码里有个关键点写数据时我直接操作 ODR 寄存器而不是调用 HAL_GPIO_WritePin因为 HAL 函数内部有大量参数检查和断言逐位调用时会产生纳秒级的延迟高频刷新时这些延迟会叠加导致时序波形失真。如果你用的是标准外设库处理方式类似直接操作寄存器永远比调用库函数可靠。2.2 FSMC 方案的地址映射与初始化配置FSMC 方案的代码核心是 GPIO 复用配置和 FSMC 时序参数配置。以 STM32F103ZET6 为例数据线用 PD0-PD7控制线用 PD4NOE、PD5NWE、PE7A0 作为 RS片选用 PD7NE1。关键配置如下// FSMC配置结构体 static void LCD_FSMC_Init(void) { FSMC_NORSRAMInitTypeDef FSMC_NORSRAMInitStruct; FSMC_NORSRAMTimingInitTypeDef FSMC_TimingInitStruct; // 使能FSMC和GPIO时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_FSMC, ENABLE); // 时序参数配置这是FSMC方案的核心 FSMC_TimingInitStruct.FSMC_AddressSetupTime 1; // 地址建立时间 FSMC_TimingInitStruct.FSMC_AddressHoldTime 1; // 地址保持时间 FSMC_TimingInitStruct.FSMC_DataSetupTime 4; // 数据建立时间关键参数 FSMC_TimingInitStruct.FSMC_BusTurnAroundDuration 0; FSMC_TimingInitStruct.FSMC_CLKDivision 0; FSMC_TimingInitStruct.FSMC_DataLatency 0; FSMC_TimingInitStruct.FSMC_AccessMode FSMC_AccessMode_A; FSMC_NORSRAMInitStruct.FSMC_Bank FSMC_Bank1_NORSRAM1; FSMC_NORSRAMInitStruct.FSMC_DataAddressMux FSMC_DataAddressMux_Disable; FSMC_NORSRAMInitStruct.FSMC_MemoryType FSMC_MemoryType_SRAM; FSMC_NORSRAMInitStruct.FSMC_MemoryDataWidth FSMC_MemoryDataWidth_8b; FSMC_NORSRAMInitStruct.FSMC_BurstAccessMode FSMC_BurstAccessMode_Disable; FSMC_NORSRAMInitStruct.FSMC_WaitSignalPolarity FSMC_WaitSignalPolarity_Low; FSMC_NORSRAMInitStruct.FSMC_WrapMode FSMC_WrapMode_Disable; FSMC_NORSRAMInitStruct.FSMC_WaitSignalActive FSMC_WaitSignalActive_BeforeWaitState; FSMC_NORSRAMInitStruct.FSMC_WriteOperation FSMC_WriteOperation_Enable; FSMC_NORSRAMInitStruct.FSMC_WaitSignal FSMC_WaitSignal_Disable; FSMC_NORSRAMInitStruct.FSMC_ExtendedMode FSMC_ExtendedMode_Disable; FSMC_NORSRAMInitStruct.FSMC_WriteBurst FSMC_WriteBurst_Disable; FSMC_NORSRAMInitStruct.FSMC_ReadWriteTimingStruct FSMC_TimingInitStruct; FSMC_NORSRAMInitStruct.FSMC_WriteTimingStruct FSMC_TimingInitStruct; FSMC_NORSRAMInit(FSMC_NORSRAMInitStruct); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE); }FSMC 的数据建立时间DataSetupTime这个参数需要特别说明。LCD12864 的写周期时序要求数据建立时间通常不小于几十纳秒FSMC 的 HCLK 频率是 72MHz一个时钟周期约 13.9ns所以配置为 4 个周期约 55ns满足 ST7920 的要求。如果屏幕刷新出现花屏或者偶尔写入无效优先把这个参数调大每次加 1 个周期再测试基本都能解决。FSMC 模式下读写命令和数据的代码非常简洁#define LCD_CMD_ADDR ((uint32_t)0x60000000) // RS0时的地址 #define LCD_DATA_ADDR ((uint32_t)0x60000002) // RS1时的地址 #define LCD_Write_Cmd(cmd) (*(volatile uint8_t *)LCD_CMD_ADDR (cmd)) #define LCD_Write_Data(dat) (*(volatile uint8_t *)LCD_DATA_ADDR (dat))这里地址偏移 2 的原因前面提过RS 接到了 FSMC_A0 地址线A0 为 0 时选中命令寄存器A0 为 1 时选中数据寄存器。存储器映射中 Bank1 的基地址是 0x60000000A0 的偏移正好是 28 位数据总线时地址线 A0 对应存储地址的 bit1。这个过程如果没想明白很容易在移植时写错地址。3. 显示内容布局汉字、字符与图形的坐标计算LCD12864 的显示区域是 128 列乘 64 行像素但内部显存并不是简单按 128*64 的线性排列。ST7920 控制器的显存按 16 行字符行组织每行又分成左右两个半屏每个半屏 64 列。这种结构导致地址计算非常容易出错我最早写驱动时就在这里翻了车显示位置和预期差了整整一列。具体来说ST7920 的显示地址从 0x80 到 0x9F共 32 个字节地址。其中 0x80-0x87 对应上半屏第一行的 8 个字符位置0x88-0x8F 对应下半屏第一行的 8 个字符位置0x90-0x97 对应上半屏第二行以此类推。所以从显示坐标行列到显存地址的换算公式是// 行坐标x范围0-3字符行列坐标y范围0-7字符位置 uint8_t addr 0x80; if (y 8) { addr 0x80 x * 16 y; // 上半屏 } else { addr 0x80 x * 16 y 8; // 下半屏偏移到下半屏首地址0x88 }这个公式理解起来不复杂但实际项目里很容易把 x 和 y 的对应关系搞反。我有一次写菜单界面第 3 行第 5 列的内容跑到第 4 行去了排查了半天才发现是地址计算时把行列顺序写反了。建议在做显示封装时就统一使用屏幕坐标行列作为对外接口函数内部再做地址换算这样应用层代码的可读性和维护性都好很多。字符显示方面ST7920 内置了 ASCII 码表直接调用基本指令集模式下的字符写入功能即可每个字符占一个字节对应屏幕上的 8*16 像素区域。ASCII 字符的显示不需要额外字库直接用 Write_Data(0x41) 就能显示字母 A。这个功能对于显示数值、单位、状态标识非常方便我在仪表类项目里经常用这种方式拼接动态数据。汉字显示是 LCD12864 最常用的功能。ST7920 内部固化了 8192 个中文字符编码遵循 GB2312 标准。在基本指令集模式下先写入两个字节的汉字编码高字节在前、低字节在后屏幕就会显示对应汉字。比如电流两个字// 显示电字GB2312编码为0xB5 0xE7 LCD_Write_Data(0xB5); LCD_Write_Data(0xE7); // 显示流字GB2312编码为0xC1 0xF7 LCD_Write_Data(0xC1); LCD_Write_Data(0xF7);有的模块支持直接写中文字符串底层做编码转换但标准写法是逐字节发送。这里有个坑如果使用 Keil 的 MDK 环境汉字字符串默认是 GB2312 编码可以直接把字符串里每个字节作为数据发送。但如果用了 IAR 或者内部编码是 UTF-8 的工具链字符串里的汉字编码和 GB2312 不一致直接发送会出现乱码。我项目里一般直接在代码中写好编码数组或用查表方式避免工具链编码差异带来的坑。ST7565 控制器的 LCD12864 则完全是另一套逻辑它没有内置汉字字库显存是按 128*64 位图组织的。用这种屏显示汉字需要自己做字模取模然后把字模数组按位写入显存。这种屏的优点是显示无级灰度图形更灵活缺点是汉字显示要自己做字库管理内存占用和开发量都更大。选型时如果项目以显示中文文本为主ST7920 带字库的屏是最省事的选择如果要做波形、曲线、图片显示ST7565 更合适。3.1 字模提取与取模软件的使用如果用的是 ST7565 这类无字库屏或者需要在 ST7920 上显示自定义图形符号比如电池图标、信号强度条就需要自己提取字模。市面上常见的取模软件有 PCtoLCD2002、Img2Lcd 等基本用法大同小异。以 PCtoLCD2002 为例提取一个 16*16 汉字的字模需要在软件中设置取模方式选逐行式、每行显示 8 个像素点、数据排列选从左到右、从上到下、输出格式选C51 格式。这样生成的数组是 16 个字节16 行*16 列/8 位正好对应屏幕上一个 16*16 像素的汉字区域// 以电字16*16字模为例实际数据以取模软件输出为准 const uint8_t hanzi_dian[32] { 0x00, 0x00, 0x07, 0xF0, 0x0C, 0x18, 0x18, 0x0C, 0x30, 0x06, 0x60, 0x03, 0xFE, 0x7F, 0x60, 0x03, 0x30, 0x06, 0x18, 0x0C, 0x0C, 0x18, 0x07, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };我这里只给出了字模数组的结构示例具体字节数据需要用取模软件针对你选的字体生成。有一点必须提醒取模时要注意行扫描方向如果软件设置的扫描顺序和 LCD 控制器的显存写入顺序不一致显示出来的汉字会旋转 90 度或者左右镜像这个问题很多新手遇到过。图形显示比如画矩形、画直线在 ST7920 上实现起来比较绕因为它不是按像素线性寻址的。最简单的做法是先把要显示的图形计算好写入一个 128*64/81024 字节的显存缓冲区然后通过绘图模式命令扩展指令集的 0x34 进入扩展功能再配合绘图命令把缓冲区内容整体刷到屏幕上。这种方式适合显示动态变化的图形界面代价是 1024 字节的 RAM 开销好在 STM32 的 RAM 普遍够用。我第一次用这个方案做实时波形显示时先把波形数据映射到位图缓冲区再用批量写命令刷新效果还不错。关键是批量刷新时要注意屏幕控制器对连续写操作的响应速度每写一个字节后延时不能省否则会出现行错位。4. 读状态与忙标志检测判断 LCD 是否就绪的正确方式很多初学者写完初始化代码后直接开始显示内容发现有时正常有时乱码这大概率跟没做忙标志检测有关。ST7920 提供了忙标志BF位位于状态寄存器的第 7 位通过读操作可以获取。忙标志为 1 表示控制器正在处理内部指令此时不应写入新数据否则指令会丢失或执行错误。标准外设库和 HAL 库的例程里一般推荐在每次写命令前检测忙标志检测方法是通过 RS0、RW1 来读状态寄存器// 读忙标志返回1表示忙 uint8_t LCD_Read_Busy(void) { uint8_t busy; // 数据线设置为输入模式 // 注意GPIO模拟方式下数据口需要在读和写之间切换方向 LCD_RS_L(); LCD_RW_H(); LCD_E_H(); delay_us(1); busy (GPIOB-IDR 7) 0x01; // 读取D7引脚电平 LCD_E_L(); LCD_RW_L(); // 数据线重新设置为输出模式 return busy; } #define LCD_Check_Busy() while(LCD_Read_Busy())忙标志检测在 GPIO 模拟方案里有个麻烦数据口需要频繁切换输入输出方向如果驱动代码里每个字节都要切换效率会低不少。我的处理方式是只在写命令前检测忙标志写数据前不检测——因为数据写入是紧跟在命令之后的此时控制器已经处于可接收数据状态。当然这个经验不是绝对的如果你写的是数据量很大的批量数据传输建议同样检测一下稳妥优先。说到读取忙标志还有一个替代方案纯延时等待。ST7920 执行一条指令的典型时间是 72us写数据类似。在时序要求不高、MCU 主频又足够快的场合直接用延时替代忙标志检测完全可行。我常用的经验值是写命令后延时 100us写数据后延时 50us这样即使控制器因为温度变化导致执行时间略有波动也能保证安全。两种方式怎么选我的建议是如果画面刷新不频繁用延时等待就行代码简单、逻辑清晰如果刷新频繁、追求效率用忙标志检测避免无谓的等待时间。实际项目中我通常两种结合初始化阶段用延时等待稳定运行后用忙标志检测。还有一个容易忽略的问题读忙标志时需要把 GPIO 数据口从输出模式切换到输入模式切换完成之后还需要等待至少一个 GPIO 时钟周期确保引脚方向切换完成否则读到的电平不稳定。这一点在 HAL 库的 GPIO_Init 调用中已经隐含了但如果你直接操作寄存器配置 CRL/CRH 寄存器一定要注意切换后加几个空操作延时。5. 常用功能模块代码菜单、数字、进度条的工程化封装驱动层跑通之后真正开发产品时一般不会直接调用 LCD_Write_Cmd 和 LCD_Write_Data 来拼界面而是封装出一层 UI 接口。我通常在驱动之上做这样几个功能模块字符串显示、整数和浮点数显示、菜单索引、进度条以及简单的反色高亮效果。字符串显示模块是基础支持 ASCII 字符串和中文字符串混合输出。实现时关键是维护一个屏幕当前位置光标每次显示完自动更新坐标// 显示一个汉字字符串str按GB2312编码 void LCD_Show_ChineseString(uint8_t row, uint8_t col, const uint8_t *str) { uint8_t addr 0x80 row * 16 col; LCD_Write_Cmd(addr); while (*str ! \0) { // 判断是否为ASCII字符GB2312汉字编码两个字节都大于0x80 if (*str 0x80) { LCD_Write_Data(*str); } else { LCD_Write_Data(*str); LCD_Write_Data(*str); } } }整数和浮点数显示是仪表类项目的刚需。我的做法是利用 C 标准库的 sprintf 把数字转成字符串再逐字符显示。这个方法简单可靠缺点是 sprintf 比较占资源和栈空间在资源紧张的 STM32F103C8T6 上要留意。如果不想引入标准库可以自己写一个简单的整数转字符串函数// 显示一个整数支持范围-9999到99999 void LCD_Show_Int(uint8_t row, uint8_t col, int16_t num) { char buf[7]; uint8_t i 0; if (num 0) { buf[i] -; num -num; } // 从低位开始转换 char tmp[6]; uint8_t len 0; if (num 0) tmp[len] 0; while (num 0) { tmp[len] 0 num % 10; num / 10; } while (len 0) buf[i] tmp[--len]; buf[i] \0; LCD_Show_String(row, col, (uint8_t *)buf); }进度条是设备交互界面里很实用的组件。LCD12864 的显示区域虽然不大但做一个 10 格或者 20 格的进度条完全够用。实现思路是按百分比计算要填充的格子数每个格子用实心的 8*16 像素块表示已填充和未填充部分用不同颜色黑底白字和白底黑字区分。菜单界面的封装比较灵活基本思路是维护一个当前选中项和菜单项总数每次按键触发重绘。我对菜单做了个简化处理因为 ST7920 的 RAM 结构是上下半屏分开的画菜单时让高亮条只在一个半屏内移动避免跨半屏操作导致地址计算的复杂化。菜单项不超过 8 个时这个方案非常稳定。反色高亮功能的实现原理是先读取目标区域的显存数据逐字节取反后再写回。ST7920 支持读显存操作但读之前需要发一条读命令和一次空读时序上比纯写复杂。实操中我一般用另一种思路用实心填充矩形覆盖选中项背景再用白色即 0x00显示文字视觉上达到反色效果避免了显存读回操作的麻烦。这一层 UI 封装做完之后应用层的代码就变得非常清爽比如一个温湿度显示界面LCD_Show_ChineseString(0, 0, 温度); LCD_Show_Int(0, 4, temp); LCD_Show_ChineseString(0, 6, ℃); LCD_Show_ChineseString(1, 0, 湿度); LCD_Show_Int(1, 4, humi); LCD_Show_ChineseString(1, 6, %);6. 踩坑实录花屏、乱码、白屏的排查链路写 LCD12864 驱动时遇到最多的问题集中在花屏、乱码和白屏三类我把实战中完整的排查链路记录下来希望能帮你少走弯路。这个章节不是直接给答案而是按照实际排查顺序展开遇到同类问题时可以照着走一遍。6.1 花屏初始化失败的第一信号最常见的花屏现象是屏幕出现随机点阵或者满屏噪点而不是预期的内容。花屏的本质是屏幕内部控制器没有成功进入正常工作模式。排查第一步检查初始化前的硬件复位是否到位。ST7920 在上电后需要一个低电平复位脉宽不同厂家的模块要求不同典型值是 1us 到 10ms 之间。如果复位引脚直接悬空或复位脉冲过短控制器可能处于异常状态。我在代码里统一用 10ms 低电平稳扎稳打。排查第二步确认初始化指令确实被控制器接收。这个环节最有效的工具是逻辑分析仪抓到 E 引脚的使能波形查看波形上的数据是否符合 0x30 的初始化序列。如果没有逻辑分析仪可以尝试在 0x30 指令后加更长的延时比如 10ms看症状是否改善。如果延长延时能改善花屏说明是时序太紧导致第一组指令没有被正确处理。排查第三步检查 PSB 引脚的电平是否确实为高。有一个非常隐蔽的错误PSB 引脚在模块内部有上拉芯片本身默认高电平就是并口模式但有些廉价的转接板或者杜邦线接触不良会导致 PSB 被拉低到串口模式。此时屏幕依然会响应但 SPI 模式下你发送的并口指令全部无效表现就是花屏。有些模块调试时一碰排线就正常松开又花屏多半就是这个问题。6.2 乱码地址计算与编码的双重陷阱乱码通常有两种表现一种是显示的内容完全不是预期文字另一种是显示位置错乱、正常字符出现在错误行。第一种情况如果显示的是中文优先怀疑编码问题。前面提过Keil MDK 里汉字默认 GB2312 编码直接发没问题但如果用 VSCode 加 arm-none-eabi-gcc 的工具链源文件保存为 UTF-8 时接收到的字节序列就是 UTF-8 编码和 ST7920 内置的 GB2312 字库对不上必然乱码。解决方案是在代码里显式用\xB5\xE7这种转义序列标注汉字编码或者写一个 UTF-8 到 GB2312 的转换函数。我个人的习惯是建立一张常用汉字编码表界面文案统一查表输出彻底绕开编码问题。第二种情况显示位置错乱。排查时先确认行列坐标转换公式是否正确再确认在写入数据前是否正确设置了 DDRAM 地址。ST7920 的地址设置指令是 0x80 加上目标地址我见过有人直接把行号当作地址去设置比如设置第 2 行就直接 0x02这显然是不对的。正确写法前面已经给出公式直接用 0x80 row * 16 col。还有一类乱码很容易被忽视初始化时少发了 0x30 指令的重复序列。ST7920 的数据手册要求 8 位模式下连续发送三次 0x30目的是在不同供电电压和晶振频率下都能可靠地切换到基本指令集。有些代码只发一次 0x30高速上电时偶尔会失败导致后续指令全部错位。我在正式代码里干脆把复位后的初始化序列重复了三次 0x30每次都加 1ms 延时彻底避免这个问题。6.3 白屏与对比度经常被错怪为硬件故障屏幕背光亮、但没有任何内容包括黑块这种情况先不要怀疑屏坏了很大概率是对比度问题或初始化不完整。对比度问题最典型。ST7920 的对比度由 V0 引脚电压决定电压越高显示越深。如果 V0 悬空屏幕可能完全不显示也可能整屏黑块。接一个 10K 电位器一端接 V0、一端接地、另一端接 VCC调节到显示正常即可。模块如果自带对比度调节电位器用螺丝刀微调就行。还有一种情况是初始化指令里把显示关掉了0x08 指令或者初始化序列不完整导致显示处于关闭状态。排查时可以先发一条 0x0C 指令强制打开显示、关闭光标同时发 0x01 清屏指令。如果屏幕出现八个整齐的黑块每行一个说明显示已经打开但 DDRAM 是空的黑块是未初始化显存数据的体现此时再写入内容就能正常显示。白屏还有一个常见原因数据口方向配置错误。GPIO 模拟方案下数据口如果配置成了输入模式写入的数据根本无法送达控制器屏幕自然没反应。检查 GPIO 模式配置时确认 D0-D7 是推挽输出模式且没有和别的外设共用引脚发生冲突。6.4 逻辑分析仪实战定位时序问题的利器如果你手头有逻辑分析仪排查 LCD12864 驱动问题的效率能提升一个量级。不用买很贵的型号几十块钱的 8 通道逻辑分析仪配 Saleae 软件就够用。抓取的信号优先级建议E 引脚必抓RS、RW 至少抓一路数据线 D7 抓一路足够D7 是忙标志检测线也是指令数据的最高位。抓时序时重点看三个参数E 引脚高电平脉冲宽度是否满足要求典型不小于 450ns-1us、每次 E 高电平前的数据建立时间是否足够、指令之间的间隔是否足够长。Saleae 自带的协议分析器甚至可以直接解析出总线上的数据你发出去的是 0x30 还是 0x38一眼就能看清。我遇到过最诡异的一次故障程序逻辑完全正确时序波形也标准但屏幕就是偶尔花屏。后来用逻辑分析仪长时间抓取才发现是某次中断服务函数里调用了 LCD 显示函数导致时序被中断打断本来连续的写操作被切成了两段。从那以后我定下规矩所有 LCD 显示函数必须在临界区或者关中断环境下调用至少显示期间不能让高优先级中断插入。这也是工程化开发中容易踩的隐形坑。7. 降低 CPU 占用的进阶优化DMA、中断与 UI 分层设计思路前面聊的大部分是基础驱动如果项目做到中后期、界面复杂程度提高你会发现 GPIO 模拟方案的 CPU 占用开始成为瓶颈。这时候可以做一些进阶优化。第一个思路是升级到 FSMC 或者 SPIDMA 方案。SPI 模式下用 SPI 的 DMA 功能可以把显存数据自动搬运到屏幕CPU 只需要发起一次 DMA 传输剩下的时间和中断都可以用来处理其他任务。这个方案需要两块缓冲一块是应用中绘图的显存缓冲一块是 SPI DMA 的发送缓冲。每次刷新时把绘图缓冲拷贝到发送缓冲然后启动 DMA。拷贝 1024 字节在现代 Cortex-M3 上大概几十微秒完全可接受。FSMC 方案本身不占用 CPU 太多真正的瓶颈在绘图层。如果你是在 ST7920 上做图形界面可以先把界面元素绘制到 1024 字节的显存缓冲然后一次性写入屏幕。这样做的好处是避免了逐点操作屏幕带来的重复时序开销而且多次绘图操作合并成一次屏幕刷新整个界面看起来更流畅。第二个思路是 UI 分层设计。我见过不少项目把 LCD 显示逻辑直接写在业务代码里时间长了以后改一个界面元素要在一个几百行的函数里找半天。更好的做法是三层结构驱动层只负责最底层的读写指令和数据、传输层负责把整帧显存缓冲或文本行刷新到屏幕、UI 层负责菜单导航、状态显示、动画效果。每一层只依赖下一层的接口不跨层调用。传输层的一个典型优化是把整屏刷新拆成脏矩形刷新。屏幕上的界面往往只有局部区域变化比如数字更新、进度条移动没必要每次都把整个屏幕内容重发一遍。我在代码里维护了一个脏矩形标记每次 UI 更新后只把变化区域的显存数据发到屏幕。这个优化在 FSMC 方案下收益不明显但在 GPIO 模拟方案下能把刷新耗时从几十毫秒降到几毫秒效果非常显著。第三个思路是正确使用 STM32 的 DMA 来配合更新。即便是 GPIO 模拟方案数据线如果接在同一组 GPIO 上也可以用 DMA 触发 GPIO 翻转来模拟时序。这种玩法比较进阶需要精准计算 DMA 传输周期和 GPIO 翻转时机我一般不建议新手直接上容易把自己绕晕。等你把上面的优化都做完、对时序消化透了再考虑这种操作也不迟。功耗方面还有一个容易忽视的点背光。LCD12864 的背光功耗占整个模块的大头如果产品是电池供电而界面不是一直需要显示建议用 MOS 管或三极管控制背光电源空闲时关闭背光。要注意的是背光关闭不等于屏幕断电屏幕即使黑屏内部控制器如果还在工作静态电流也有几毫安如果追求低功耗需要把整个 LCD 模块的电源彻底断开。8. 写在最后几个常被忽略但影响极大的细节驱动代码已经能稳定运行之后我再分享几个容易忽略但影响极大的细节。首先是引脚初始化顺序。STM32 上电后 GPIO 默认是浮空输入状态如果在配置好 LCD 数据口之前就发了 E 脉冲数据线电平不确定可能会给控制器写入随机数据。所以初始化代码的顺序应该是先配置所有 GPIO 为确定的输出状态再初始化 LCD 控制器而不是反过来。其次是长线传输的问题。如果 LCD 和 MCU 之间的排线超过 15 厘米建议在数据线上串联 33 欧姆到 100 欧姆的电阻。这个电阻可以抑制信号反射改善波形质量。在快速翻转的场景下长线不加电阻波形边缘会出现振铃严重时会导致数据采样错误。我做过一个分体式设备屏幕和主板之间用 20 厘米排线连接一开始不稳定加了 47 欧电阻后问题消失。再来是 LCD 控制器的电源去耦。VCC 引脚旁边并一个 0.1uF 瓷片电容最好再并一个 10uF 电解电容这在模块内部可能已经有了但如果你用的是裸屏而非模块这一步非常重要。控制器的电源引脚如果噪声大会导致内部逻辑误翻转表现就是偶发花屏或显示漂移。最后是代码的可维护性。LCD12864 的驱动代码不复杂但修改寄存器和引脚定义时一定要同步更新所有调用点。我见过一个同事把数据口从 GPIOB 换到 GPIOC结果只改了初始化函数没改写数据函数里操作 ODR 寄存器的代码屏幕当然不显示。建议把所有引脚定义和底层操作集中在一个头文件里上层代码不要直接引用寄存器地址这样改硬件时只需要动一个文件。我目前做的几个项目里LCD12864 的驱动代码基本都是这套结构底层时序、显示封装、UI 层三层分离。虽然当初为适配不同控制器ST7920 带字库、ST7565 无字库各写了一套驱动但上层 UI 接口完全通用。如果哪天需要换屏只需要替换底层驱动、保持上层接口不变整个应用层代码一行都不用改。这种设计带来的好处等你真正经历过一次换屏迁移就会深有体会。本文还有配套的精品资源点击获取
返回列表