简介:基于STM32单片机控制LCD12864液晶屏的软件工程源码,完整演示了开机画面与多级菜单的显示交互流程,适合嵌入式爱好者、单片机初学者及需要快速完成人机界面开发的工程师。压缩包内共157个文件,以34个.h头文件和33个.c源文件为主体,同时保留.uvproj工程、.sct分散加载文件、.o/.d/.crf等编译中间产物,以及.axf、.hex、.map、.lst等链接烧录与调试辅助文件,整体体积仅2.78MB,目录清晰,便于直接查阅。工程源码覆盖GPIO时钟与上下拉配置、SPI/I2C接口驱动、LCD12864初始化与绘图底层、开机图片数据存储、按键扫描以及基于链表或数组的多级菜单状态机,支持上翻、下翻、确认、返回等操作;菜单项可携带功能回调指针,便于后续扩展。代码按照硬件层、驱动层和应用层进行模块划分,注释明确,中断处理和通信异常检测也有相应逻辑。已有937人学习该源码包,适合课程设计、毕业设计或产品原型阶段快速验证STM32显示方案。
1. 开机画面只是敲门砖,多级菜单才是嵌入式 UI 的分水岭
LCD12864 在嵌入式项目里几乎和流水灯一样普及,但多数人写完静态显示、翻页显示之后就停住了。等到需要做一个带开机 logo、多级菜单、参数编辑、返回导航的人机界面时,往往发现最难的不是「把字画上去」,而是菜单怎么组织、按键怎么分发、屏幕怎么刷新不闪烁。这个标题里最有价值的不是「开机画面」这四个字,而是「多级菜单」背后的软件结构设计。
这篇博文就围绕 STM32 控制 LCD12864 这一场景,从显示控制器选型、底层驱动、菜单数据结构、按键状态机到渲染性能优化,把这套例程里最常见也最可靠的实现思路拆开讲。适合正在做课程设计、毕设或者产品原型,手头有一颗 STM32F103 和一块 12864 屏,想在一周内把界面框架跑起来的人。读完你会得到一个能复用到彩色屏、OLED 甚至带触摸屏项目上的菜单架构,而不是只会抄一份例程。
2. 先读懂 LCD12864:控制器、引脚和初始化序列
2.1 ST7920 还是 SSD1306:同样是 12864,驱动方式完全不同
市面上叫「LCD12864」的屏幕至少有三种常见方案。带中文字库的 ST7920 并行接口最流行,课题和例程里绝大多数都是它;不带字库的 SSD1306 是 OLED 的控制器,走 SPI/I2C;还有更老的 KS0108,需要外部显存,编程麻烦。买屏之前先确认丝印:主控带 ST7920 的一定能直接用标题里这类例程的并行时序,SSD1306 则要移植绘图 API,两者的初始化序列完全不同。
建议优先选 ST7920 并口屏,理由有三个:字库自带 ASCII 和中文字符,直接写字符编码就能显示,省下取模时间;并行时序在 STM32 上刷全屏也不慢;网上现成的 GPIO 模拟时序例程最多,调试门槛低。SSD1306 适合以后升级到小体积产品,但在这类教学和课程设计场景里,ST7920 是不争议的省心选择。
2.2 引脚分配:用 GPIO 模拟时序,还是用 FSMC
STM32F103 的 FSMC 可以驱动 LCD12864,但是为 TFT 彩屏设计的,接 ST7920 并口要额外处理时序适配,实际收益不大。最稳妥、最可移植的做法是枚举任意 GPIO 做并行数据总线。下表是例程里最常见的接法,供你对照自己的板子修改。
| ST7920 引脚 | STM32 GPIO | 说明 |
|---|---|---|
| RS (CS) | PB0 | 寄存器选择:高电平数据,低电平指令 |
| RW (SID) | PB1 | 读写选择,写操作时拉低 |
| E (SCLK) | PB2 | 使能信号,下降沿锁存 |
| DB0-DB7 | PB8-PB15 | 8 位并行数据总线 |
| PSB | 3.3V | 拉高选择并行模式 |
| RST | PB3 | 复位,低电平有效 |
| BLA/BLK | 三极管电路 | 背光控制,不能直连 GPIO |
接线时最容易被坑的是 PSB 悬空。ST7920 内部有上下拉,悬空可能导致进不去并行模式,表现为屏幕只有背光亮、无任何内容。另外背光引脚通常耗流 50mA 左右,请勿直接灌到 GPIO,用 NPN 三极管或 MOSFET 做开关。
2.3 初始化序列的完整代码与参数说明
下面这套初始化函数适用于 ST7920 并行接口,在系统上电复位后调用一次即可。延时参数在 72MHz 主频下可以直接用,不同频率要稍微调整。
#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_DATA_PORT GPIOB #define LCD_DATA_PINS (GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | \ GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15) static void lcd_write_byte(uint8_t data, uint8_t is_data) { // 先切总线方向,再写数据,最后用 E 下降沿锁存 GPIO_InitTypeDef gpio = {0}; gpio.Pin = LCD_DATA_PINS; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(LCD_DATA_PORT, &gpio); HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, is_data ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_RW_PORT, LCD_RW_PIN, GPIO_PIN_RESET); // 只改高 8 位,低 8 位不受影响 LCD_DATA_PORT->ODR = (LCD_DATA_PORT->ODR & 0x00FF) | ((uint16_t)data << 8); HAL_GPIO_WritePin(LCD_E_PORT, LCD_E_PIN, GPIO_PIN_SET); delay_us(1); HAL_GPIO_WritePin(LCD_E_PORT, LCD_E_PIN, GPIO_PIN_RESET); delay_us(1); } static void lcd_write_cmd(uint8_t cmd) { lcd_write_byte(cmd, 0); } static void lcd_write_data(uint8_t data) { lcd_write_byte(data, 1); } void lcd_init(void) { // ST7920 上电后需要等待内部复位完成 HAL_Delay(50); lcd_write_cmd(0x30); // 8 位并行,基本指令集 HAL_Delay(5); lcd_write_cmd(0x30); HAL_Delay(1); lcd_write_cmd(0x0C); // 显示开,光标关,反白关 lcd_write_cmd(0x01); // 清屏 HAL_Delay(10); lcd_write_cmd(0x06); // 写入后光标右移,整体不移动 }0x30是 ST7920 的必经初始化序列,重复发两次是为了应对部分批次上电时序不稳定的情况;0x0C控制显示开关和光标,如果想把光标显示出来改成0x0E即可;0x06决定写入字符后地址指针是自增还是自减。这段代码没有用 FSMC,全部走 GPIO,换到 GD32、APM32 这类国产替代 MCU 时只需要重写最底层的GPIO_InitTypeDef初始化和delay_us,上层的字符绘制、菜单逻辑完全不用动。
3. 多级菜单的骨架:用数据表驱动,而不是用 if-else 堆界面
3.1 为什么说多级菜单的本质是「数据结构 + 状态机」
很多新手写多级菜单会陷入一个不可持续的循环:每新增一个界面就加一个全局变量记录当前状态,然后写一坨switch-case在函数里跳来跳去。界面少还能撑住,菜单深度到第三层、每层五六个条目时,改动一个位置就会牵扯十几个判断分支,代码逻辑直接崩盘。
核心解法是把菜单抽象成几个基本元素:页面(screen)、条目(item)、动作(action)和层级关系。页面的关系本质上就是一棵树,树的每个节点保存自己的索引、父节点索引、这一层的条目数量、当前选中项,以及进入、退出时执行的回调函数。树的遍历在嵌入式实现里可以不建链表,直接用数组存所有节点的信息,再用一个状态变量记录当前处于哪个节点。
3.2 菜单数据表的 C 语言实现
下面这个结构体定义是这套例程里最核心的部分,推荐直接抄进工程。
typedef struct menu_item { char *label; // 条目显示文本,如 "温度校准" uint8_t child_screen; // 进入该条目后跳转的屏幕 ID,0xFF 表示没有子菜单 void (*on_enter)(void); // 选中并确认时执行,如打开加热器 void (*on_select)(void); // 光标移到该条目时执行,可用于预览参数 } menu_item_t; typedef struct menu_screen { const char *title; // 屏幕标题,显示在首行 uint8_t item_count; // 本屏条目数 int16_t parent; // 父屏幕 ID,-1 表示根屏幕 uint8_t selected; // 当前选中下标 const menu_item_t *items; // 指向条目数组 } menu_screen_t; void menu_init(void); void menu_key_handle(uint8_t key_code); void menu_render(void);对照这段结构体解释数据表驱动:根屏幕的parent是 -1,按下返回键时判断parent是否为 -1,如果是就可以弹出退出确认框;child_screen存的是数组下标而不是指针,拷贝和序列化都方便,数组存 Flash 里还能省 RAM。on_select回调的设计考虑的是 LCD12864 单屏显示不了多少内容,光标移动时最好实时刷新底部状态行。
3.3 菜单跳转的三种状态迁移方式
常见的按键动作无非三种:向上移动光标、向下移动光标、确认进入或返回。下面的状态机函数覆盖这六个基本操作。
#define KEY_UP 1 #define KEY_DOWN 2 #define KEY_ENTER 3 #define KEY_BACK 4 static menu_screen_t *cur_screen; static menu_screen_t screen_pool[]; // 实际工程中放在 Flash 里 void menu_key_handle(uint8_t key_code) { menu_screen_t *scr = cur_screen; if (key_code == KEY_ENTER) { uint8_t child = scr->items[scr->selected].child_screen; if (child != 0xFF) { cur_screen = &screen_pool[child]; cur_screen->selected = 0; } else if (scr->items[scr->selected].on_enter) { scr->items[scr->selected].on_enter(); // 执行动作 } } else if (key_code == KEY_BACK) { if (scr->parent >= 0) { cur_screen = &screen_pool[scr->parent]; } else { power_off_confirm(); // 根菜单按返回:询问关机 } } else if (key_code == KEY_UP) { scr->selected = (scr->selected == 0) ? scr->item_count - 1 : scr->selected - 1; } else if (key_code == KEY_DOWN) { scr->selected = (scr->selected == scr->item_count - 1) ? 0 : scr->selected + 1; } menu_render(); }screen_pool在真实工程里建议用const修饰放到 Flash,因为屏幕定义不会在运行时改变;selected是唯一需要读写的字段,这样每个屏幕的 RAM 占用只有 1 字节。返回根菜单时的power_off_confirm是一个可选动作,如果你的产品没有关机概念,可以跳转到主页面或者直接忽略。这段代码的隐含前提是 UI 线程独占 LCD 和按键,如果 RTOS 环境中菜单操作和其他任务并发,需要加上互斥锁保护cur_screen指针的切换。
3.4 屏幕渲染:按需刷新,不全屏清空
渲染函数menu_render如果每次按键都把整屏清掉重写,LCD12864 会出现肉眼可见的闪烁。更好的做法是只更新变化区域,例程里的具体策略如下表。
| 更新区域 | 触发条件 | 刷新内容 | 耗时 |
|---|---|---|---|
| 标题行 | 屏幕切换时 | 显示title,背景取反 | 短 |
| 条目区 | 屏幕切换或光标移动 | 绘制所有条目,当前项反白 | 中 |
| 状态栏 | on_select被调用时 | 显示选中项的参数值 | 短 |
| 辅助图标 | 翻页时 | 箭头、滚动条 | 短 |
menu_render内部建议维护一个last_screen_id变量,屏幕没有切换时只更新条目区中光标所在行的反白状态,即把旧选中行恢复白底黑字、新选中行设为黑底白字。这样每按一次按键最多写两行文本对应的 DDRAM 区域,刷新耗时从几十毫秒降到几毫秒,不阻塞主循环。LED12864 配合 ST7920 的0x80到0x8F、0x90到0x9F两段地址做整行更新,效率比逐字刷新高得多。
4. 开机画面与菜单渲染的工程实现:取模、分层和动画切换
4.1 开机画面的取模方式与存储格式
LCD12864 是逐行扫描的显存结构,开机 logo 通常是 128x64 像素的点阵图。做这张图的流程一般是用 Image2Lcd 取模,颜色设为单色,扫描方式选择「水平扫描」,输出为 C 语言数组。一张 128x64 的全屏 logo 占 1024 字节,直接用const uint8_t数组定义,占用 Flash 而不占 RAM。
static const uint8_t logo_12864[] = { 0x00, 0x00, 0x00, 0x3E, 0x7F, 0x00, // 第 1 行... // 此处是取模工具生成的 1024 字节数据 };绘制函数需要把这张图按 8 个像素一字节的形式写给 ST7920 的图形地址。注意 ST7920 的图形显示地址不是连续线性的,而是每 16 行一个 bank,地址空间从 0x80 开始每行 +0x10。直接写一整段连续数据会出现上下行错位,正确做法是外层循环 8 个 bank、内层循环 16 列。
void lcd_draw_bitmap(const uint8_t *bmp, uint8_t x, uint8_t y) { uint8_t row, col; for (row = 0; row < 64 / 16; row++) { for (col = 0; col < 16; col++) { uint8_t addr = 0x80 + row * 0x10 + col; lcd_write_cmd(0x34); // 进入扩展指令集 lcd_write_cmd(addr + (row % 2) * 8); // 设定显示地址 lcd_write_cmd(0x30); // 回到基本指令集 lcd_write_cmd(0x80 + y / 8 + col); // 设定上半屏地址 lcd_write_data(bmp[row * 16 + col]); } } }这段代码里0x34是 ST7920 打开扩展指令集和绘图显示的开关,0x30切回基本指令集。编辑图形时整个 128x64 区域会被分为上下两个 64x64 的屏幕,地址不连续,经常有人在这里踩坑,我一般直接避免用lcd_write_data循环刷连续地址,改为按上面bank + column的双重循环处理。视频教程里那套「直接把数组全量发过去就能显示」的写法,在大部分 ST7920 上是错的。
4.2 开机画面的显示流程:等待、切换和错误回退
开机画面不只是把 logo 扔到屏幕上,还需要考虑显示时长和与菜单的衔接。常见做法是在main函数中先初始化 LCD 和按键,然后执行下面的时序。
int main(void) { HAL_Init(); lcd_init(); key_init(); menu_init(); // 开机画面:全屏 logo + 底部文字提示 lcd_draw_bitmap(logo_12864, 0, 0); lcd_set_cursor(0, 4); lcd_show_string("SYSTEM STARTING"); // 延时 2 秒,可换成「按键跳过」逻辑 uint32_t tick = HAL_GetTick(); while (HAL_GetTick() - tick < 2000) { if (key_pressed(KEY_ENTER)) { break; // 用户按确认键直接跳过开机画面 } } lcd_clear(); menu_render(); while (1) { uint8_t key_code = key_scan(); if (key_code) { menu_key_handle(key_code); } // 其余任务循环 } }开机画面的延时不要用HAL_Delay(2000)硬等,因为在等待期间用户按键没反应,体验很僵硬。用HAL_GetTick()做非阻塞延时,同时在循环里扫描按键,用户按任意键可以直接跳过,这个细节在演示和答辩时很加分。开机文字「SYSTEM STARTING」显示在屏幕下方第 4 行的位置上,底行不遮挡 logo 的主体内容,取模时记得给底部留白。
4.3 多级菜单的渲染细节:反白光标、滚动条和不可见字符
LCD12864 每屏固定显示 4 行文字,每行 8 个汉字或 16 个 ASCII 字符。当菜单条目超过 4 个时,需要把条目列表按「当前选中项所在页」来计算显示哪几行。这个分页逻辑放在menu_render中处理。
void menu_render(void) { menu_screen_t *scr = cur_screen; // 更新标题行,屏幕切换时才会走到这里 if (last_screen_id != current_screen_id) { lcd_clear(); lcd_set_cursor(0, 0); lcd_show_string(scr->title); last_screen_id = current_screen_id; } uint8_t start_idx = (scr->selected / 4) * 4; for (uint8_t i = start_idx; i < start_idx + 4 && i < scr->item_count; i++) { uint8_t row = i - start_idx; lcd_set_cursor(row + 1, 1); if (i == scr->selected) { lcd_show_string_inverse(scr->items[i].label); // 反白显示 } else { lcd_show_string(scr->items[i].label); } } }注意滚动条的处理,如果你的菜单超过 8 个条目,可以在屏幕最右侧画一列像素代表滚动条位置,在高分屏上更方便用户理解。ST7920 的反白字符实现方式是把字符编码的显示模式设为反白,而不是手动画一块黑底再写字,具体是通过0x30 0x34扩展指令里的行反白控制实现的,但不同批次芯片行为略有差异,我在实际项目中更倾向于先把整行的背景用画矩形填充成黑色,再在这块区域上正常输出白字。这样虽然多耗一点时间,但视觉效果统一。
4.4 渲染性能:图形模式与文本模式混用的两个坑
LCD12864 做多级菜单时,文本模式和图形模式往往交替使用。菜单项文字用文本模式显示,开机画面和进度条、光标条用图形模式,如果切换不彻底,会产生两个典型的 bug。
第一个坑是文本模式下写入的字符在图形模式下不显示,因为 ST7920 的文本 DDRAM 和图形 GDRAM 是两套独立的存储区,切换模式需要先关显示,否则还会出现花屏。第二个坑是图形模式只支持整行写入,禁止单字节改写,想要局部更新某个图标时,应该读取当前 GDRAM 内容再与新的像素数据做或非运算。实践里我一般会在lcd_set_cursor函数内做一个状态标记,每一次操作前判断是否需要发送显示开关命令,避免重复发送0x34引起的未知状态。
5. 把例程改成自己的板子时,这 5 个手法最能省时间
5.1 用宏开关切换字体大小和中英文模式
ST7920 支持 16x16 汉字和 8x16 半角字符。如果你的菜单项全是中文,最好把所有字符串统一定义成 UTF-8 编码,并在编译前用脚本检查字库是否覆盖。很多例程里中英文混排会发生光标错位,因为一个汉字占两个字符宽度,计数器要相应 +2。
5.2 按键扫描的连按处理
多级菜单翻页用短按,长按返回根目录是常见需求。按键扫描函数里加一个计时器,超时 1 秒自动返回根屏幕,这对现场调试很好用,不用一遍一遍手动按返回键。
5.3 RAM 不够时把菜单表扔进 Flash
STM32F103C8T6 只有 20KB RAM,如果条目数上百,菜单结构体全部装进 RAM 会撑爆 20KB 空间。用const修饰符把屏幕表和条目表放到 Flash,运行时只维护selected和current_screen_id两个变量即可。
5.4 把绘制接口抽象成 HAL 层
哪怕现在只跑 STM32F103,也建议把lcd_draw_pixel、lcd_draw_bitmap、lcd_set_cursor、lcd_show_char这几个函数集中在一个lcd_hal.c文件里。后面若是换用 GD32F103、APM32F103,只需要改lcd_hal.c中的 GPIO/SPI 部分,菜单、按键、开机画面逻辑完全不动。
5.5 用模拟器先行验证菜单结构
在写屏幕驱动之前,先在 PC 上把menu_screen_t和menu_key_handle函数用命令行窗口模拟跑一遍,打印当前屏幕和选中项。这一步不是浪费时间,它能让你把多级菜单的状态迁移逻辑调通,再对接液晶屏时,屏幕驱动的问题和菜单逻辑的问题就不会混在一起难排查。推荐用 VSCode + GCC 本地编译,轻量开箱即用。
本文还有配套的精品资源,点击获取