news 2026/9/16 12:39:00

STM32驱动LCD12864实战:从底层时序到多级菜单框架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32驱动LCD12864实战:从底层时序到多级菜单框架

简介:这套基于STM32单片机的LCD12864显示例程源码,面向嵌入式初学者与开发者,主要解决开机画面绘制与多级菜单交互设计问题。压缩包共157个文件,大小仅2.78MB,以34个.h与33个.c源文件为核心,同时包含.uvproj、.uvopt等Keil工程文件及.hex、.axf等编译产物,便于直接打开工程阅读和重新编译。LCD12864驱动部分覆盖GPIO配置、SPI/I2C接口通信、显示数据传输等关键环节,菜单部分采用多级结构设计,并融入状态机与中断处理逻辑;源码中还附带AD9851相关驱动,可作为外设扩展参考。已有937人学习下载,适合通过实际例程理解液晶控制原理,并在此基础上快速移植或扩展功能。

1. 为什么一个STM32驱动LCD12864的工程还能拆出这么多东西

接手过带屏小仪器的人都有这种经历:给设备加个「开机显示LOGO、按键进入设置」的交互,方案里塞一块串口屏嫌贵,上RGB彩屏又得折腾驱动板和DMA,最后回到LCD12864。这块128×64、内置ST7920控制器的点阵屏,串行时序只要CS、SCLK、SID三根线,3.3V能跑,还带中文字库,小批量成本压得很低。

这个工程跟网上常见的「点亮屏幕」demo不太一样。目录里除了lcd12864.__i,还有menu.__iad9851.__i,可以看出整套例程是「DDS信号源 + 多级菜单」的结构:开机画面只是第一步,后面频率设置、波形选择都走同一套菜单框架。想拿来做课程设计,或者把一台旧设备改成按键操作,这套代码是合适的参考底子。

适合谁:刚把F103点亮的入门者可以照着时序把屏刷起来;做过多级菜单的熟手,重点看菜单节点的组织和GDRAM刷图的方式。

2. LCD12864驱动底层:GPIO配置与ST7920串行时序

2.1 接口选型:为什么这颗屏的串行方式不能照搬SPI

LCD12864模块常见的控制器是ST7920,它原生支持并行和串行两种接口。并行8080方式需要DB0~DB7、RS、RW、E至少11根线,占用太多GPIO;串行方式把CS、SCLK、SID三根线拉出来,对STM32F103这种引脚紧张的单片机友好得多。

但要注意,ST7920的串行帧格式不是标准SPI。它每一帧固定是15位:先发5个高电平同步位,再发1个RW位(写为0),接着1个RS位(0命令/1数据),最后8位数据、高位在前。标准SPI是8位或16位一帧,直接拿硬件SPI去发,需要把15位拆拼成两个字节,处理起来很别扭,实际工程里反而很少这么做。

我一般直接用GPIO模拟这15位时序。这样做还有个好处:换到GD32F103、APM32这类兼容芯片时,只需要改RCC和GPIO初始化,时序代码完全不用动。这套例程虽然主体是STM32F103标准外设库写的,但搬运成本很低。

LCD12864模块这边需要确认一个引脚:PSB。它接地(低电平)才进入串行模式,悬空或者接高电平是并行模式。很多板子默认通过跳线或电阻把PSB拉高了,接好线不显示,先查它准没错。

LCD12864引脚接到STM32方向说明
CSPB12输出片选,高电平选中
SCLKPB13输出同步时钟,上升沿采样
SIDPB14输出串行数据,高位先出
PSBGND-接地进入串行模式
V010K电位器中点-调节对比度

2.2 GPIO初始化与底层写字节函数

先看GPIO配置。这里用的是标准外设库,时钟使能、推挽输出、50MHz速率:

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(); }

GPIO_Mode_Out_PP是推挽输出,驱动能力足够直接带ST7920的串行输入脚。如果你的模块是5V供电的,需要确认它的VIH门槛,部分5V模块用3.3V推挽能驱动,但也有模块需要加电平转换,这里建议先查模块手册,别直接上。

底层写位函数是整个驱动的基础,时序要求是:SID数据先稳定,然后SCLK拉高、再拉低,数据在上升沿被采样:

static void LCD_Delay(void) { volatile uint8_t i = 30; while (i--); } static void LCD_WriteBit(uint8_t bit) { if (bit) LCD_SID_H(); else LCD_SID_L(); LCD_SCLK_H(); // SID 稳定后,时钟上升沿 LCD_Delay(); LCD_SCLK_L(); LCD_Delay(); }

这段代码里,LCD_SID_H()这类宏建议直接用寄存器操作,比如GPIOB->BSRR = GPIO_Pin_14,比函数调用快,也方便在不同工程间复制。LCD_Delay()的空循环次数跟主频有关,STM32F103跑72MHz时30次循环大约在1~2微秒量级,能满足ST7920的时序要求。

有了写位函数,就能组合出写命令/写数据的完整帧:

void LCD_WriteByte(uint8_t isData, uint8_t dat) { uint8_t i; LCD_CS_H(); // 片选拉高,开始一帧 for (i = 0; i < 5; i++) // 5 位同步高电平 LCD_WriteBit(1); LCD_WriteBit(0); // RW = 0,写操作 if (isData) LCD_WriteBit(1); // RS = 1,写数据 else LCD_WriteBit(0); // RS = 0,写命令 for (i = 0; i < 8; i++) // 8 位数据,高位在前 { LCD_WriteBit((dat >> 7) & 0x01); dat <<= 1; } LCD_CS_L(); // 片选拉低,结束一帧 }

参数isData用来区分命令和数据:0发命令,1发数据。整个函数不关心这颗屏内部在做什么,它只负责把ST7920认识的串行帧送出去。后面所有显示操作,包括初始化、清屏、画图、写字符,最终都归结为调这个函数。

2.3 初始化序列和DDRAM地址映射

ST7920的初始化序列有一定要求,不能上电直接写显示命令。常见做法是连续写多次0x30做同步,再依次设地址归零、显示开关和清屏:

void LCD_Init(void) { LCD_GPIO_Init(); LCD_DelayMs(50); // 上电等待电源稳定 LCD_WriteByte(0, 0x30); LCD_DelayMs(5); LCD_WriteByte(0, 0x30); LCD_DelayMs(1); LCD_WriteByte(0, 0x30); // 连续三次 0x30,让控制器稳定在 8 位基本指令集 LCD_DelayMs(1); LCD_WriteByte(0, 0x02); // DDRAM 地址归零 LCD_WriteByte(0, 0x06); // 写入后光标右移 LCD_WriteByte(0, 0x0C); // 显示开,光标关,闪烁关 LCD_WriteByte(0, 0x01); // 清屏 LCD_DelayMs(2); }

初始化完成后,文本模式可以直接用了。ST7920文本模式下DDRAM地址是分四行的,行首地址不连续,这是最容易踩坑的地方:

显示行行首地址一行的汉字数
第1行0x808个
第2行0x908个
第3行0x888个
第4行0x988个

第3行地址是0x88而不是0xA0,因为ST7920内部DDRAM按两块64×16区域组织。写字符时先设地址再连续写,地址会自动递增,写到行尾后会自动跳到下一块,所以每行显示前都要重新设置一次行首地址。

3. 开机画面:字模数据、GDRAM写入与取模方向

3.1 用取模软件生成128×64的图像数组

LCD12864的图形区逻辑分辨率是128×64像素,一屏完整图像在代码里表现为1024字节的数组(128×64÷8)。生成这个数组,我一般用PCtoLCD2002或者字模大师,设置项比较关键,稍微设错,屏幕上出现的就是乱条纹。

参数推荐设置说明
图片尺寸128×64正好一屏
取模方式逐行式(行列式)和GDRAM写入顺序对应
字节顺序高位在前LCD_WriteByte移位方向一致
颜色阴码像素点为1,不点亮点为0

生成出来的数组长这样:

const uint8_t logo_12864[] = { 0x00, 0x00, 0x00, 0x1F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x80, 0x0F, 0x80, 0x00, 0x00, // ...... 共 1024 字节 };

图片不足128像素宽时,右侧补0x00。这里强调阴码,是因为LCD12864的LED背光像素点亮逻辑是写1点亮,取反后显示效果正好相反。

3.2 GDRAM写入函数:先关显示,再整帧写入

ST7920的图形区叫GDRAM,它和文本区DDRAM是两套独立地址空间。写GDRAM必须切到扩展指令集,也就是先发0x34。这里有个经验:写GDRAM之前最好先把显示关掉,全部写完再开,否则刷屏过程中能看到明显的雪花和撕裂感。

void LCD_DrawBitmap(const uint8_t *data) { uint8_t y, i; LCD_WriteByte(0, 0x34); // 扩展指令集,关显示,进入 GDRAM 访问模式 for (y = 0; y < 32; y++) // GDRAM 垂直地址 0~31 { LCD_WriteByte(0, 0x80 + y); // 设置垂直地址 LCD_WriteByte(0, 0x80); // 水平地址从 0 开始 for (i = 0; i < 16; i++) // 16 次,每次 2 字节,共 32 字节 { LCD_WriteByte(1, *data++); LCD_WriteByte(1, *data++); } } LCD_WriteByte(0, 0x36); // 扩展指令集,开显示 LCD_WriteByte(0, 0x30); // 回到基本指令集,恢复文本操作 }

参数data指向1024字节的图像数组。我按逐行式取模来写:每个垂直地址对应屏幕上下两条8像素行,前16字节是上方行,后16字节是下方行。如果你的取模软件输出的是按页排列的数据,把这两段交换即可。

最后那两条命令经常被漏掉。0x36是扩展指令集下的开显示,0x30是切回基本指令集。如果只写0x36不切回,后面调用LCD_ShowString时地址计算全会乱。

3.3 开机流程:从Logo到主菜单

实际开机显示流程,顺序是:清屏、刷Logo、停留、回文本模式、显示版本信息、切到菜单框架:

void ShowWelcome(void) { LCD_WriteByte(0, 0x01); // 清屏 LCD_DrawBitmap(logo_12864); // 全屏刷 Logo LCD_DelayMs(1500); LCD_WriteByte(0, 0x30); // 先切回基本指令集 LCD_WriteByte(0, 0x01); // 再清屏 LCD_ShowString(0, 0, (char*)"欢迎使用信号源"); LCD_ShowString(1, 0, (char*)"V1.0"); LCD_DelayMs(800); }

顺序上有个细节:清屏命令0x01在扩展指令集和基本指令集下含义不同,建议先切回0x30再清屏,避免清不干净。多级菜单的入口函数在开机流程最后调用,这样从开机画面到系统设置菜单的过渡是连续的。

4. 多级菜单:结构体数组、按键扫描与状态机

4.1 菜单数据结构:用数组代替链表

多级菜单最常见的实现方式是「结构体数组 + 父子节点索引」。菜单项数量在编译期就是确定的,用链表反而增加动态内存管理负担。STM32F103的RAM本来就紧张,菜单表放Flash更合适。

typedef struct { uint8_t id; // 节点编号 uint8_t parent_id; // 父节点编号,0xFF 表示根 uint8_t item_count; // 子项数量 uint8_t sub_start; // 第一个子项在数组中的下标 uint8_t *title; // 菜单标题(汉字字符串) void (*on_enter)(void); // 进入节点时执行的回调,NULL 表示无 } MenuNode;

idparent_id组成树形关系,item_countsub_start用来遍历子项。on_enter函数指针是关键设计:选中某个菜单项按下确认键时,直接调用对应的处理函数,比如设置频率、切换波形。

菜单表这样组织:

const MenuNode menu_table[] = { {0, 0xFF, 2, 1, (uint8_t*)"系统设置", NULL}, // 根菜单 {1, 0, 3, 3, (uint8_t*)"频率输出", NULL}, // 根菜单的第 1 个子项 {2, 0, 2, 6, (uint8_t*)"载波电平", NULL}, // 根菜单的第 2 个子项 {3, 1, 0, 0, (uint8_t*)"10MHz", set_freq_10M}, {4, 1, 0, 0, (uint8_t*)"20MHz", set_freq_20M}, {5, 1, 0, 0, (uint8_t*)"50MHz", set_freq_50M}, // 后续节点依次排列 };

节点顺序上,把同一个父节点的子项连续存放,sub_start就能直接索引到第一个子项。这种组织方式在调试时特别方便:menu_table[3]一定是menu_table[0]的第3个子项,不需要遍历链表。

4.2 按键扫描与菜单状态机

按键处理我习惯做成独立模块,返回一个事件而不是直接操作菜单,这样按键消抖逻辑和菜单逻辑解耦。四个按键:上翻、下翻、确认、返回。

uint8_t Key_Scan(void) { static uint8_t last_state = 0; uint8_t state = 0; if (KEY_UP_PIN == 0) state |= KEY_UP; if (KEY_DOWN_PIN == 0) state |= KEY_DOWN; if (KEY_ENTER_PIN == 0) state |= KEY_ENTER; if (KEY_BACK_PIN == 0) state |= KEY_BACK; if (state != last_state) { DelayMs(10); // 简单消抖 last_state = state; } return last_state; }

状态机处理菜单跳转,这是整个菜单系统的核心:

typedef struct { uint8_t current_node; // 当前所在节点 uint8_t selected_index; // 当前选中项在本层中的序号 } MenuState; void Menu_ProcessEvent(uint8_t key, MenuState *state) { const MenuNode *node = &menu_table[state->current_node]; switch (key) { case KEY_UP: if (state->selected_index == 0) state->selected_index = node->item_count - 1; // 循环回卷 else state->selected_index--; break; case KEY_DOWN: state->selected_index = (state->selected_index + 1) % node->item_count; break; case KEY_ENTER: { MenuNode *child = (MenuNode *)&menu_table[node->sub_start + state->selected_index]; if (child->item_count > 0) { state->current_node = child->id; // 进入子菜单 state->selected_index = 0; } else if (child->on_enter) { child->on_enter(); // 叶子节点,执行功能 } break; } case KEY_BACK: if (node->parent_id != 0xFF) { state->current_node = node->parent_id; // 返回父菜单 state->selected_index = 0; } break; } }

selected_index是本层序号,不是全局节点ID,这一点要注意。进入子菜单时selected_index归零,返回父菜单时也归零。循环回卷用的取模运算,上翻和下翻都能在首尾之间绕圈,避免菜单项多时翻到边界卡住。

4.3 菜单渲染:每屏四行与选中项指示

LCD12864文本模式下最多显示4行中文,所以每屏菜单最多放4个子项。绘制菜单时,先清屏,再显示当前节点标题,然后从sub_start开始取子项逐行打印:

void Menu_Draw(const MenuState *state) { uint8_t i; const MenuNode *node = &menu_table[state->current_node]; LCD_WriteByte(0, 0x01); // 清屏 LCD_ShowString(0, 0, node->title); for (i = 0; i < node->item_count && i < 4; i++) { const MenuNode *child = &menu_table[node->sub_start + i]; LCD_ShowString(i + 1, 0, child->title); if (i == state->selected_index) { // 选中行:在行首补一个 16x16 的三角图标字模 // 或者用反显方式,把整行区域填黑后重新写白字 } } }

菜单项超过4个时,需要做翻页而不是继续往下打,否则会冲到DDRAM下一行。一般做法是增加一个page_offset偏移量,一次最多显示当前页的4项。选中行指示,最省事的办法是在行首输出一个小三角图标字模,或者利用GDRAM画一个16×16的黑色方块反显整行。注意GDRAM和DDRAM是两个独立图层,反显后重新刷新文本时,GDRAM层不会被清掉,所以要在每次Menu_Draw开头把图形区一起清理。

5. 仿真与真机调试:从波形、PSB电平到字模方向的几个验证点

5.1 先看CS/SCLK/SID波形,再怀疑代码

串行LCD12864不上电没反应,先别急着改驱动。拿示波器或者逻辑分析仪探头接SID和SCLK,按一下复位键,抓初始化那段波形。正常应该看到CS拉高后,SCLK上先出现5个高电平,然后RW位、RS位,再跟着8位数据。如果SCLK没有上升沿,检查GPIO时钟是否使能;如果SID一直为低,检查是不是把命令和数据参数传反了。

没有示波器时,可以写一个测试循环,把PB12、PB13、PB14同时翻转,用万用表测引脚电平变化,能确认GPIO模式是不是推挽输出。

5.2 PSB引脚和对比度电平是两大隐形开关

串行模式必须把模块的PSB引脚接地。某些模块PSB引脚默认通过跳线帽接VCC,不拆跳线帽,发再多命令屏幕也不亮。另外V0引脚要接一个10K电位器到GND,调出合适的对比度电压,ST7920的显示电压典型值在3~5V之间,太高会全黑,太低会全白。

5.3 用0xAA和0x55图案验证字模方向

刷开机位图出现乱条纹,优先怀疑取模设置,而不是驱动逻辑。写一个测试图像填进GDRAM,用最直观的方法判断字节顺序:

uint8_t test_pattern[1024]; memset(test_pattern, 0xAA, sizeof(test_pattern)); // 每字节 10101010 LCD_DrawBitmap(test_pattern);

0xAA每字节是10101010,正常显示应该是横向棋盘格。如果看到的是竖条纹,说明字节内的位序反了,取模软件里把「高位在前」改成「低位在前」。如果看到的是斜向条纹,说明逐行式和逐列式选错了。这个测试图案比对着资料猜快得多。

最后说一个提升体验的技巧:开机Logo刷新时,整个GDRAM写入过程都处于0x34关显示状态,全部1024字节写完后再用0x36开显示,这样用户看到的是完整画面一次出现,而不是从上往下逐行刷出来。同理,菜单切换时先清GDRAM、再写新内容、最后开显示,视觉上就不会有残影和撕裂。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 12:38:28

Flutter APK安装卡死问题排查与解决方案

1. Flutter APK安装卡死问题概述作为一名长期使用Flutter进行跨平台开发的工程师&#xff0c;我经常遇到新手开发者反馈"APK安装到真机时卡住不动"的问题。这种情况通常发生在Windows平台&#xff0c;特别是通过Android Studio进行开发的环境下。根据我的经验&#x…

作者头像 李华
网站建设 2026/9/16 12:37:21

嵌入式ADC数据FFT频谱分析:从采样率到窗函数的完整指南

简介&#xff1a;一份完整的ADC采集与快速傅里叶变换频谱分析工程&#xff0c;面向嵌入式开发者和数字信号处理学习者&#xff0c;解决从模拟信号采样到频域可视化的实战问题。工程基于单片机平台&#xff0c;包含ADC驱动、FFT运算、液晶屏显示等模块&#xff0c;能对实时采集的…

作者头像 李华
网站建设 2026/9/16 12:37:09

CFileDialog定制实战:从标志位到IFileDialogCustomize

简介&#xff1a;面向MFC开发者的CFileDialog定制源码包&#xff0c;围绕打开/保存文件对话框的深度改造展开&#xff0c;适合需要在商业项目中扩展原生对话框功能、提升交互体验的Windows程序员。资源共34个文件&#xff0c;包含11个头文件与8个源文件&#xff0c;以及位图、图…

作者头像 李华
网站建设 2026/9/16 12:36:34

Gamma Space下还原Linear渲染效果:Unity Shader手动色彩空间转换实战

开头先交代一个场景。我前阵子接手一个Unity项目&#xff0c;Android端真机画面灰蒙蒙的&#xff0c;暗部细节糊成一片&#xff0c;渐变色带严重到像是用8位色硬拉的。美术那边在PC上同一个场景切到Linear Space对比&#xff0c;差异一眼就能看出来——暗部更通透&#xff0c;漫…

作者头像 李华
网站建设 2026/9/16 12:36:33

1GHz探头配500MHz示波器,系统带宽为何只有447MHz?

先给结论&#xff1a;1GHz探头加500MHz示波器&#xff0c;系统带宽算出来大概只有447MHz&#xff0c;而不是标题里那个整整齐齐的500MHz。这个组合在硬件信号测试里非常常见&#xff0c;但很多人只盯着示波器标称的500MHz带宽&#xff0c;把探头当成一根会衰减的导线&#xff0…

作者头像 李华
网站建设 2026/9/16 12:35:56

VC++实现蓝牙HCI命令帧与事件解析:从串口到RSSI测距

简介&#xff1a;基于Visual C的蓝牙HCI通信程序源代码&#xff0c;面向初学蓝牙编程或希望深入理解蓝牙协议栈的开发者&#xff0c;解决在Windows环境下通过HCI接口实现主机与蓝牙控制器之间数据交互的问题。压缩包共25个文件&#xff0c;以.h头文件和.cpp源文件为主体&#x…

作者头像 李华