news 2026/9/1 22:02:46

STM32F103+12864点阵LCD多级菜单设计:表驱动框架从零实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103+12864点阵LCD多级菜单设计:表驱动框架从零实现

简介:本资源是一份面向嵌入式初学者与中级开发者的STM32人机交互实战项目,聚焦STM32F103微控制器驱动12864点阵LCD并实现多级菜单功能,解决工业控制、智能家居等场景中图形界面开发与用户交互设计的实际问题。压缩包为RAR格式,大小1.64MB,包含完整Keil工程源码(含LCD底层驱动、SPI通信模块、按键扫描与去抖逻辑、树形结构菜单管理及字符/图形显示函数),所有代码均已适配标准12864模块,可直接编译下载运行。目前已有4001人学习下载,资源结构清晰,关键模块如LCD初始化、菜单状态机、按键响应机制均独立封装,便于理解软硬件协同流程;配套注释详尽,涵盖偏置电压配置、显示缓冲区管理、图标与文字混合排版等细节,是掌握嵌入式GUI基础开发的高实用性参考范例。 做嵌入式的朋友应该都有这个阶段:单片机基础外设玩得差不多了,GPIO、定时器、串口都能跑,但一说到“做个带菜单的人机界面”,马上就不知道从哪下手了。屏幕怎么刷、菜单怎么跳、参数怎么改、返回怎么处理,每一个问题拿出来都不算难,但合成一个完整需求就很容易卡住。这篇文章就围绕“STM32F103 + 12864点阵LCD + 多级菜单”这个组合,把我实际调试通过的完整方案拆开讲。从LCD底层驱动、菜单表设计、按键处理到代码结构,全部讲透,而且会附上能直接照抄的核心源码。

这个项目的价值在于它几乎是所有带参数设置类小设备的通用原型。温控器、变频电源面板、小型仪表、DIY工具,本质上都是“屏幕显示 + 菜单跳转 + 参数修改”这个套路。你把这一套跑通了,后面换屏、换MCU、换业务逻辑,都只是改配置的事。

1. 项目概述与整体设计思路

先说说这个项目到底做了什么。硬件是STM32F103最小系统板(我用的是C8T6),屏幕是常见的12864点阵LCD,也就是128x64分辨率、带中文字库的那类屏。软件上实现了三级菜单,从主菜单进入二级子菜单,再进入参数编辑页,支持数值加减、状态切换、返回上级、保存参数等操作。所有逻辑跑在裸机上,没有上RTOS,全部用状态机和表驱动的方式实现。

有人可能会问,STM32F103不是有很多现成的GUI库可以用吗,为什么还要自己写菜单框架?这个问题我在项目初期也纠结过。后来实际做下来发现,对于12864这种小屏,主流GUI库的收益其实很低:屏幕分辨率有限,复杂控件展示不出来;F103的RAM和Flash也紧张,跑一个完整的GUI库会吃掉不少资源;而且GUI库的学习成本和调试成本并不低,很多场景只是要几个菜单页加参数设置,用轻量级的自研方案便宜又灵活。

1.1 这个项目解决的本质问题

这个项目的本质问题不是“驱动12864”,因为LCD驱动本身很成熟,网上Demo一大把。真正的难点在于多级菜单的组织方式。我见过太多人写菜单是用一坨switch-case硬堆,或者用一个全局变量记录当前页面,然后每个页面再套if-else。这种方式做两级菜单还行,一旦菜单数量超过十个页面,代码就变成了意大利面条,加一个页面要改好几个地方,还要小心别把跳转逻辑改坏。

所以我一开始就确定:菜单一定要用“表驱动”的方式实现。把菜单项的定义、父子关系、回调函数、参数范围全部声明成结构体数组,程序运行时就靠这些表来导航。加新菜单只需要在表里加几行,其他代码完全不用动。这是这个项目最核心的设计决策。

另外还要解决一个刷新策略问题。12864这类屏本身刷新不快,尤其串行模式下一帧全屏数据要几百毫秒,如果不做局部刷新,按键时整个屏幕闪一下,体验极其糟糕。我的方案是分级处理:光标移动只重绘那一行,数值变化只更新对应的数字区域,只有页面切换才做全屏重绘。

1.2 为什么选12864和STM32F103这套组合

STM32F103现在看确实不算新,但它在实际项目里依然是最常见的MCU之一。主频72MHz,Flash最大512KB,RAM最大64KB,外设丰富,价格便宜,资料多到根本看不完。关键是它的性能做这种菜单界面完全够用,哪怕后期加一些传感器采样、PID控制逻辑,也不会紧张。

12864点阵LCD同样是非常成熟的显示方案。相比OLED它的功耗高一点,但胜在尺寸大、支持中文字库、强光下可视性好,而且便宜。最常见的12864有两种驱动芯片:ST7920和KS0108。ST7920自带中文和ASCII字库,可以用串行模式,接线少(三根数据线),适合快速开发;KS0108是纯图形屏,必须用并口,每个像素都要自己往显存里写,开发量明显更大。这个项目选用的是ST7920方案的带字库12864,代码也基于它的指令集编写。

有一点需要提醒:市面上标称“12864LCD”的模块,内部芯片不一定一样。买屏的时候一定要看清楚丝印或者问卖家是ST7920还是KS0108,这两者的驱动方式完全不同。如果你手里的屏是KS0108,那本文的串行驱动代码用不了,需要改成并口驱动并配合图形字模使用。

1.3 菜单方案的选型对比

做多级菜单,业界常见几种方案,我简单排一下它们的优缺点。

第一种是裸写状态机。用枚举定义每个页面状态,再用一个大型switch-case做状态迁移。优点是直观,适合菜单层级很固定的场景;缺点是页面多了以后,状态枚举和case数量爆炸,维护困难。

第二种是表驱动。菜单项用结构体数组描述,每个菜单项有类型(子菜单/参数/动作)、子页面指针、参数范围、回调函数等字段。程序通过当前页面的表和焦点索引来导航,代码量小,扩展方便。这个项目采用的就是这种方案。

第三种是在STM32上跑一个轻量级GUI框架,比如LittlevGL或u8g2。功能强大,支持各种控件和动画,但需要适配驱动、移植图形库、学习API,对F103来说资源占用也比较大。除非项目确实需要复杂的图形界面,否则小屏幕上用大框架属于过度设计。

我做这个项目时选定了表驱动方案,原因很简单:它是性能和可维护性之间的最优解。菜单定义在Flash里,不占RAM;导航逻辑集中在几个函数里,调试方便;后期加页面只需要改表,不碰核心逻辑。

2. 12864底层驱动关键细节

12864的驱动是整个项目的地基。底层不稳,上面菜单写得再漂亮也白搭。这一章我把驱动部分拆开讲,重点说清楚ST7920串行模式的时序、初始化顺序、字符显示和中文显示的区别,这些细节在网上很多Demo里要么不完整,要么干脆是错的。

2.1 ST7920串行模式接线与时序

ST7920串行模式一共用三根控制线:CS(片选)、SCLK(时钟)、SID(串行数据),再加上电源VCC(5V或3.3V)、地GND和背光LEDA/LEDK。有些模块还把PSB引脚引出来了,串行模式时PSB必须接低电平,并口模式接高电平,这个不能搞错。

我实际的接线是这样的:

12864引脚STM32F103引脚
VCC3.3V(模块带电平转换时)
GNDGND
PSBGND(串行模式)
CSPB2
SCLKPB1
SIDPB0
VO10K电位器中点(调对比度)
LEDA3.3V(或串限流电阻)
LEDKGND

这里要特别说明一个问题:STM32F103的工作电压是3.3V,但很多12864模块是5V逻辑。如果你的模块没有板载电平转换芯片,串行模式下SID和SCLK直接接MCU引脚会存在电平不匹配风险,轻则显示乱码,重则长期使用损坏引脚。最稳妥的做法是选购时确认模块支持3.3V供电,或者自己加一个电平转换电路。我用的这块模块是3.3V兼容版本,所以直接接没问题。

串行时序是ST7920一个比较特殊的点。它的串行协议不是标准SPI,而是“三字节一帧”的格式:每传输一个字节数据,需要先发一个控制字节(11111AB的格式),告诉控制器接下来是命令还是数据,然后数据本身要拆成高4位和低4位两个字节发送。看起来绕,实际上理解了就不难。

命令发送流程(以写命令0x80为例):

  1. CS拉低。
  2. 发送同步头+命令标志:0xF8。
  3. 发送命令高4位:0x80(命令低4位补0)。
  4. 发送命令低4位:0x00。
  5. CS拉高。

写数据时同步头换成0xFA,后面同样拆成两个字节。这种“高四位在前、低四位在后”的格式,了解ST7920的人应该都熟悉,很多第一次接触的人在这里栽跟头,因为用示波器看波形会觉得莫名其妙。

2.2 底层初始化与基础显示函数

ST7920需要在程序启动时做一次初始化,流程不复杂,但顺序不能乱。标准初始化序列是:延时40ms以上等待屏内部上电稳定,然后依次发送0x30(功能设定,基本指令集)、0x0C(开显示、关光标)、0x01(清屏)、0x02(地址归位),最后再延时几毫秒让命令执行完。

有些屏对时序要求严格,初始化时如果命令间隔不够,会出现清屏不彻底或者开机花屏的情况。稳妥起见,我在每个命令之间加了延时函数,实测下来稳定性很好。

下面是我项目中LCD初始化和基础写函数的代码:

void LCD_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); CS_H(); SCLK_H(); SID_H(); Delay_ms(50); LCD_WriteCmd(0x30); Delay_ms(5); LCD_WriteCmd(0x30); Delay_ms(5); LCD_WriteCmd(0x0C); Delay_ms(5); LCD_WriteCmd(0x01); Delay_ms(10); LCD_WriteCmd(0x02); Delay_ms(5); } void LCD_WriteByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) SID_H(); else SID_L(); SCLK_H(); Delay_us(2); SCLK_L(); Delay_us(2); dat <<= 1; } } void LCD_WriteCmd(uint8_t cmd) { CS_L(); LCD_WriteByte(0xF8); LCD_WriteByte(cmd & 0xF0); LCD_WriteByte((cmd & 0x0F) << 4); CS_H(); Delay_us(50); } void LCD_WriteData(uint8_t dat) { CS_L(); LCD_WriteByte(0xFA); LCD_WriteByte(dat & 0xF0); LCD_WriteByte((dat & 0x0F) << 4); CS_H(); Delay_us(20); }

这里有个小细节值得注意:LCD_WriteCmd和LCD_WriteData里的延时不能省。ST7920执行命令是需要时间的,比如清屏指令耗时最长,如果写完马上发下一条命令,偶发性的“丢命令”就会出现,症状是屏幕偶尔少个字符、或者某次按键后刷新不完整。宁可延时范围放宽一点,换稳定性。

2.3 字符、汉字和坐标定位

让我先对齐一个概念:ST7920内部的DDRAM是以“字节”为单位组织的,它不像纯图形屏那样每个点都能单独控制。普通字符模式下,一行128个像素点会被分成16个字节位置,一个ASCII字符占1个字节位置,显示半角字符;一个中文汉字占2个字节位置,显示16x16点阵。所以一行最多显示8个中文汉字,或者16个ASCII字符,或者混排。

DDRAM的地址映射也是有讲究的:第一行起始地址是0x80,第二行0x90,第三行0xA0,第四行0xB0,每一行有16个字节地址。写显示数据之前必须先发送“0x80 + 行偏移 + 列偏移”的命令,把光标定位到目标位置。由于地址是字节粒度,定位中文时列偏移要按偶数算,否则会出现半个汉字这种没法看的情况。

显示ASCII字符和显示中文数据的底层区别,就是写数据的方式。ST7920的ASCII字库是8x16点阵,直接发送一个ASCII码就显示一个字符。中文内部支持GB2312编码,需要连续发送两个字节的汉字内码,也就是字符串里连续的两个大于0x7F的字节。我的显示字符串函数做了这样的判断:

void LCD_ShowString(uint8_t row, uint8_t col, const char *str) { uint8_t addr = 0x80; if (row == 0) addr = 0x80; else if (row == 1) addr = 0x90; else if (row == 2) addr = 0xA0; else if (row == 3) addr = 0xB0; addr += col; LCD_WriteCmd(addr); while (*str) { if (*str < 0x80) { LCD_WriteData(*str++); } else { // GB2312汉字,两个字节连续写入 LCD_WriteData(*str++); LCD_WriteData(*str++); } } }

这个函数在菜单项显示里会反复用到。菜单标题、参数名、数值显示都靠它。实际使用中我还有一个体会:12864的DDRAM地址是有限的,16个字节位置写满了,继续写数据不会有任何效果。所以在显示比较长的字符串时,要么用截断函数,要么在设计菜单文字时控制在8个汉字以内,这个约束从一开始就要想清楚。

2.4 关于“绘图模式”有什么用

ST7920还有一个GDRAM绘图模式,开启后可以操作每个像素点,比如绘制进度条、画箭头、显示自定义图标。进入绘图模式需要发0x34扩展指令,然后操作坐标和数据。不过要注意,绘图模式会覆盖对应区域的文字显示,没法直接和字符模式混用,需要软件切换。

我在菜单项目里并没有依赖绘图模式,因为菜单里的图标和反白效果可以用“块操作”来实现,但如果你后边想给12864加简易曲线、进度条这类功能,那就要开始学GDRAM的坐标转换了。这块内容相对冷门,遇到了再对照数据手册去啃,不必一开始就追求全部掌握。

3. 多级菜单框架的设计与实现

底层驱动跑通之后,重头戏来了:菜单框架。这一章是整个项目的核心,我会从数据结构设计、按键事件处理、刷新策略三个角度拆解,保证你看完能自己复现一个可扩展的多级菜单系统,而不是只能抄一段代码。

3.1 菜单项的数据结构设计

菜单框架的核心是一个结构体,它描述了一个菜单项的所有信息。我定义如下:

typedef enum { MENU_ITEM_ENTRY, // 子菜单入口 MENU_ITEM_VALUE, // 参数项,可调整数值 MENU_ITEM_ACTION // 动作项,按下执行某个函数 } MenuItemType; typedef struct MenuPage MenuPage; typedef struct { const char *label; // 菜单显示文字 MenuItemType type; // 菜单项类型 int16_t *value; // 参数项的值指针 int16_t minVal; // 参数最小值 int16_t maxVal; // 参数最大值 int16_t step; // 参数步进 void (*action)(void); // 动作项回调函数 MenuPage *childPage; // 子菜单页面指针 } MenuItem; struct MenuPage { const char *title; // 页面标题 const MenuItem *items; // 菜单项数组 uint8_t itemCount; // 菜单项个数 MenuPage *parent; // 父页面指针 };

这套结构体的设计逻辑是这样的:

MENU_ITEM_ENTRY类型的菜单项,用于进入子菜单,点击后当前页面切换到childPage指向的页面。MENU_ITEM_VALUE类型用于可调参数,value指针指向一个int16_t变量,菜单界面边上会实时显示这个变量的当前值,按OK进入编辑模式后用上下键调整数值。MENU_ITEM_ACTION类型用于执行一次性动作,比如清空记录、恢复出厂设置、蜂鸣器测试,点击后直接调用action函数。

用这种结构,一个页面的菜单可以这样定义:

static int16_t tempLimit = 60; static int16_t humLimit = 80; static int16_t deviceAddr = 1; extern MenuPage pageSetting; extern MenuPage pageMonitor; extern MenuPage pageInfo; static const MenuItem mainItems[] = { {"参数设置", MENU_ITEM_ENTRY, NULL, 0, 0, 0, NULL, &pageSetting}, {"运行监控", MENU_ITEM_ENTRY, NULL, 0, 0, 0, NULL, &pageMonitor}, {"系统信息", MENU_ITEM_ENTRY, NULL, 0, 0, 0, NULL, &pageInfo}, {"蜂鸣器测试", MENU_ITEM_ACTION, NULL, 0, 0, 0, Buzzer_Test, NULL}, }; MenuPage pageMain = {"主菜单", mainItems, 4, NULL}; static const MenuItem settingItems[] = { {"温度上限", MENU_ITEM_VALUE, &tempLimit, 10, 100, 5, NULL, NULL}, {"湿度上限", MENU_ITEM_VALUE, &humLimit, 20, 90, 5, NULL, NULL}, {"通讯地址", MENU_ITEM_VALUE, &deviceAddr, 1, 16, 1, NULL, NULL}, }; MenuPage pageSetting = {"参数设置", settingItems, 3, &pageMain};

所有菜单项用const限定,放在Flash里,不占RAM。页面之间的父子关系通过parent指针连成一棵树。这套结构简单到不能再简单,但扩展性极强:想增加页面,就声明一个MenuPage和一个MenuItem数组;想调整入口,只需要修改主菜单的数组。

3.2 菜单导航运行时和按键处理

有了一张表,还需要一个运行时状态记录当前在哪个页面、焦点在哪一项。我定义了一个小结构体:

typedef struct { MenuPage *currentPage; // 当前页面 uint8_t focus; // 当前聚焦项 uint8_t offset; // 当前显示窗口起始项 uint8_t editMode; // 是否处于参数编辑模式 } MenuRuntime; static MenuRuntime menuRt;

focus表示当前聚焦的菜单项编号,offset表示当前屏幕窗口从哪一项开始。因为12864一行显示不了太多菜单项,一个页面如果有8项,屏幕上只能显示4项,就需要滚动。滚动策略很直接:当focus小于offset,offset跟随变小;当focus大于offset+3,offset跟随变大,保证聚焦项始终落在屏幕可见范围。

按键处理是整个菜单框架的“指挥中心”。我分配了4个独立按键:UP、DOWN、OK、BACK。主循环每10ms调用一次按键扫描,返回按下事件,然后按键处理函数根据当前状态决定动作。

void Menu_HandleKey(uint8_t keyEvent) { if (menuRt.editMode) { // 编辑模式:UP/DOWN调整数值,OK退出编辑 if (keyEvent == KEY_UP) { MenuItem *item = &menuRt.currentPage->items[menuRt.focus]; int16_t newVal = *item->value + item->step; if (newVal <= item->maxVal) *item->value = newVal; Menu_RefreshValueLine(menuRt.focus); } else if (keyEvent == KEY_DOWN) { MenuItem *item = &menuRt.currentPage->items[menuRt.focus]; int16_t newVal = *item->value - item->step; if (newVal >= item->minVal) *item->value = newVal; Menu_RefreshValueLine(menuRt.focus); } else if (keyEvent == KEY_OK) { menuRt.editMode = 0; Menu_RefreshLine(menuRt.focus); } else if (keyEvent == KEY_BACK) { // 放弃修改 menuRt.editMode = 0; Menu_RefreshLine(menuRt.focus); } return; } // 普通浏览模式 if (keyEvent == KEY_UP) Menu_MoveFocus(-1); else if (keyEvent == KEY_DOWN) Menu_MoveFocus(1); else if (keyEvent == KEY_OK) Menu_EnterCurrent(); else if (keyEvent == KEY_BACK) Menu_GoBack(); }

浏览模式下按OK,会去判断当前菜单项的类型。如果是MENU_ITEM_ENTRY,切换当前页面到childPage;如果是MENU_ITEM_VALUE,进入编辑模式并把焦点行反白显示,提醒用户当前正在改参数;如果是MENU_ITEM_ACTION,直接调用callback函数。按BACK则切换到parent指针指向的父页面,如果没有父页面就忽略。

3.3 显示刷新策略:如何不闪屏

显示刷新策略直接决定用户体验。第一次写菜单的时候,我图省事,每次按键都全屏重绘,结果按键后屏幕闪一下,尤其在串行模式下非常明显。后来改成按需刷新,体验立刻好了很多。

我的刷新原则只有三条:

第一,整屏重绘只在页面切换时发生,而且清屏和重绘之间不要加长延时,一口气画完。页面切换要的是“干净利落”,不追求动画效果。

第二,光标在菜单行之间移动时,只重绘光标所在的两行:原来那行去掉光标标识,新聚焦那行加上光标标识。12864的字符串写入是按字节地址定位的,局部重绘一个区域很容易,先定位到那一行的起始地址,然后重新写一遍完整行内容就行。

第三,参数值变化时,只刷新数值所在的列,不要刷新整行文字。等到退出编辑模式时,再整行重绘一次,把数值和标签的显示状态恢复成正常浏览模式。

这样处理之后,实测按键响应几乎是“所见即所得”,没有任何闪烁感。你要记住一个核心思想:小屏LCD的刷新是稀缺资源,能少写一个字节就少写一个字节,能用局部刷新解决就绝不整屏刷新。

下面这个函数演示了局部刷新一行菜单的方法:

void Menu_RefreshLine(uint8_t row) { MenuPage *page = menuRt.currentPage; uint8_t index = menuRt.offset + row; if (index >= page->itemCount) return; MenuItem *item = &page->items[index]; char tmp[17]; memset(tmp, 0, sizeof(tmp)); // 第一列显示光标 if (index == menuRt.focus) strcpy(tmp, "->"); else strcpy(tmp, " "); strncat(tmp, item->label, 6); // 参数项末尾显示当前值 if (item->type == MENU_ITEM_VALUE) { uint8_t len = strlen(tmp); sprintf((char *)&tmp[len], "%d", *item->value); } LCD_ShowString(row + 1, 0, tmp); }

3.4 参数保存与恢复

菜单里能改参数,那么参数怎么断电保存?最省事的方案是保存到STM32F103内部的Flash或者备份寄存器。备份寄存器只有20个16位寄存器,容量极有限,通常只存关键标志。要保存大量参数列表时,用内部Flash的最后一页比较合适。

我在项目里写了一个简单的参数存储模块,用“修改时擦写、掉电恢复”的思路:每次参数变化后,并不是立刻写Flash(Flash有擦写寿命限制),而是设置一个dirty标志,主循环里检测到标志后延时500ms再写,防止频繁修改时反复擦写。写入前需要先读Flash的写保护状态,擦除扇区,然后按顺序写入参数表。上电时在main函数初始化阶段先读取Flash参数,如果校验和正确就用保存值覆盖默认值,否则直接使用默认值。这段逻辑不复杂,但却是设备“可用”和“好用”的分水岭。

4. 完整源码结构与核心代码解析

光讲设计理念容易虚,这章把代码工程结构拆开,让你知道一个可运行的工程到底由哪些文件组成,每个文件干什么,哪一段是核心的,哪一段直接拷贝就能用。

4.1 工程文件组织方式

一个清晰的工程结构,对后续维护比什么都重要。我的工程目录是这样的:

STM32_12864_Menu/ ├── Core/ │ ├── main.c │ ├── delay.c │ ├── delay.h │ ├── stm32f10x_it.c ├── Hardware/ │ ├── lcd12864.c │ ├── lcd12864.h │ ├── key.c │ ├── key.h │ ├── menu.c │ ├── menu.h │ ├── param.c │ ├── param.h ├── System/ │ ├── stm32f10x_conf.h │ ├── stm32f10x.h └── Project/ ├── STM32_12864_Menu.uvprojx

main.c负责时钟初始化、外设初始化、菜单初始化和主循环调度。delay.c提供微秒和毫秒延时,12864时序里的延时靠它。lcd12864.c负责屏幕底层驱动。key.c封装按键扫描和事件检测。menu.c实现菜单导航、刷新、编辑逻辑。param.c负责参数的Flash存储与恢复。

这种按模块划分文件的方式特别适合中小型裸机项目。驱动、业务、存储互相独立,哪个文件出错就定位哪个文件,换硬件平台时只需要改lcd12864.c和key.c,菜单框架原封不动。

4.2 main函数和主循环调度

main函数本身没什么魔法,就是把各个模块初始化一遍,然后进入一个无限循环:

int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); Delay_Init(); Param_Init(); // 从Flash读取参数,没有有效数据则用默认值 LCD_Init(); Key_Init(); Menu_Init(); Menu_DrawPage(&pageMain); while (1) { uint8_t key = Key_Scan(); if (key != KEY_NONE) { Menu_HandleKey(key); } Param_Task(); // 处理参数保存任务 Delay_ms(10); } }

主循环就是一个裸机调度器。按键扫描和菜单事件处理在同一个循环里,通过延时控制节奏。Param_Task是参数保存的调度函数,如果检测到dirty标志并且超过设定时间,就执行一次Flash写入。这样即使菜单里狂按加减键,Flash也不会被频繁擦写。

4.3 按键扫描与事件检测

按键扫描的难点在于消抖和边沿检测。我的方案是“定时轮询 + 状态比对”:

uint8_t Key_Scan(void) { static uint8_t lastKeyState = 0x00; static uint8_t stableCnt = 0; uint8_t nowState = 0; if (KEY_UP_PIN == 0) nowState |= KEY_UP_BIT; if (KEY_DOWN_PIN == 0) nowState |= KEY_DOWN_BIT; if (KEY_OK_PIN == 0) nowState |= KEY_OK_BIT; if (KEY_BACK_PIN == 0) nowState |= KEY_BACK_BIT; // 状态变化时重新计数 if (nowState != lastKeyState) { lastKeyState = nowState; stableCnt = 0; return KEY_NONE; } // 状态连续稳定超过3次才认为有效 if (stableCnt < 3) { stableCnt++; return KEY_NONE; } return nowState; }

这段代码每次调用时检查引脚状态,如果状态连续多次不变才认为按键稳定。由于主循环10ms调用一次,3次就相当于30ms消抖,既不会误触发,也不会让操作有明显延迟。还有一个好处:它天然支持“按住不放”时的连续触发,长按上下键调整参数时,每10ms都会返回一次按键事件,数值会连续滚动,体验和三段式编码器类似。

4.4 菜单表驱动的导航实现重点

菜单导航的核心是三个函数:Menu_MoveFocus、Menu_EnterCurrent、Menu_GoBack。前面列过Menu_HandleKey的调用逻辑,这里把实现细节补上。

Menu_MoveFocus负责移动焦点并处理窗口滚动。它操作focus和offset,然后调用局部刷新函数:

void Menu_MoveFocus(int8_t dir) { MenuPage *page = menuRt.currentPage; if (page->itemCount == 0) return; int16_t newFocus = menuRt.focus + dir; if (newFocus < 0) newFocus = page->itemCount - 1; // 向上回头 else if (newFocus >= page->itemCount) newFocus = 0; // 向下回头 uint8_t oldRow = menuRt.focus - menuRt.offset; menuRt.focus = newFocus; // 调整显示窗口 if (menuRt.focus < menuRt.offset) menuRt.offset = menuRt.focus; else if (menuRt.focus > menuRt.offset + 3) menuRt.offset = menuRt.focus - 3; uint8_t newRow = menuRt.focus - menuRt.offset; // 只刷新相关行 Menu_RefreshLine(oldRow); Menu_RefreshLine(newRow); }

这段代码有几个细节值得注意。第一个是循环滚动,焦点到顶部再按UP会跳到末尾,到底部按DOWN会跳到开头,操作效率高。第二个是窗口滚动只发生在焦点离开可视区时,平时移动焦点不需要重绘整屏。第三个是用了oldRow和newRow的局部刷新,如果这两行是同一行,第二次刷新会覆盖第一次,也没问题,最多是重复写一次。

Menu_EnterCurrent的逻辑前面说过,这里补充一下进入编辑模式时的处理:进入编辑模式后,我只把当前行用反白块显示(12864支持“全点”反白,可以用绘图模式或者连续写满字节实现),然后数值部分每10ms刷新一次,这样用户调参数时能看到数字实时跳动。退出编辑模式再整行重绘,恢复正常显示。

Menu_GoBack则是直接切到parent页面:

void Menu_GoBack(void) { if (menuRt.currentPage->parent != NULL) { menuRt.currentPage = menuRt.currentPage->parent; menuRt.focus = 0; menuRt.offset = 0; menuRt.editMode = 0; Menu_DrawPage(menuRt.currentPage); } }

注意一点:返回时我把editMode强制清掉,避免从参数编辑状态退出后进入一个父级页面还带着编辑状态,这会导致UI状态错乱。这种“返回时重置状态”的思路贯穿整个菜单设计,我从一开始就要求所有状态必须可预期,绝不能让用户卡在一个自己都不知道怎么进来的状态里。

5. 常见问题排查与避坑指南

任何一个实际跑过的项目,避坑经验才是最有价值的部分。这部分我把做这个项目过程中踩过的、以及帮别人排查过的典型问题整理出来,按现象分类,每个都给出排查思路和解决办法。

5.1 白屏、花屏、对比度异常

白屏或屏幕出现一团黑块,是12864项目最常见的现象。按顺序排查:先看供电,屏的VCC和GND是否正确,3.3V/5V版本是否匹配;再看PSB引脚,串行模式下必须接低,有些模块出厂默认是高,需要跳线或者飞线;然后检查对比度,VO引脚不能悬空,必须接一个电位器或者固定分压电阻,电压不合适会导致屏幕颜色极浅或者全黑。

如果上面都正常,就要怀疑接线和时序了。CS、SCLK、SID三根线有没有接反,SCLK极性是否正确,延时是否足够。我用过一个屏,就是因为SCLK高电平保持时间太短,偶尔出乱码,把延时从1us加到3us就好了。

还有一个很容易踩的坑:STM32F103的PB3、PB4默认复用为JTAG的JTDO和JTRST,如果你把LCD的SCLK或SID接到了这两个引脚,初始化GPIO后仍然无法正常控制。解决办法是在GPIO初始化前调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)禁用JTAG,只保留SWD。我项目里没用PB3/PB4,直接避开了这个问题。

5.2 显示乱码或位置错乱

显示出来的汉字是乱码,大概率不是LCD驱动的问题,而是数据发送格式出了问题。我在代码里用LCD_WriteData发送的中文内码是连续两个字节,如果你的字符串编码不是GB2312,比如是UTF-8,那发送给屏幕的就是错误的字节序列,屏幕当然显示乱码。做12864开发时,源文件建议保存为GB2312或者GBK编码,编译出来的目标文件才会包含正确的汉字内码。

另一个位置错乱的原因是DDRAM地址定位不准。比如你想在第三行显示字符,结果发成了第二行的地址0x90,那么内容就会跑到上面去。我的做法是封装一个LCD_SetCursor(row, col)函数,在函数里对row做范围限制,防止越界。这类问题通常通过串口打印调试信息很快就能定位,用printf把每次定位的地址打出来,对照数据手册看对不对。

5.3 菜单跳转错乱、返回失效

菜单跳转错乱的原因,九成是因为页面表里的指针写错了。比如子菜单的parent没有指向正确的父页面,或者页面数组的itemCount和实际元素个数不一致。const结构体数组少写一个花括号都会导致编译期或者运行期的问题,而且这类问题不打印数据很难发现。

我的排查经验是:在Menu_HandleKey里加一个简单的串口调试宏,每次按键后打印当前菜单页面的标题、focus、offset和editMode。跑两轮操作,对照日志看菜单路径是否符合预期,问题几乎立刻暴露。这种“日志辅助调试”的方式在裸机开发里非常实用,不要舍不得那几行printf代码,调试完再删掉就行。

5.4 按键反应迟钝、误触、数值跳变

按键反应慢通常是消抖时间太长。有些例程的消抖延时用Delay_ms(20),如果按键处理里也有一堆Delay_ms(10),叠起来按下一次键要几十毫秒,体感就明显卡。我建议消抖用轮询计数的方式,把消抖时间控制在30ms以内,并且不要在消抖区内执行其他耗时操作。

数值跳变则主要是按键GPIO悬空导致的。如果按键引脚内部上拉没使能,引脚悬空时电平会抖动,程序就可能读到随机按键事件。确认每一个按键引脚都配置成上拉输入,接硬件下拉或上拉到确定电平。我习惯把按键一端接地,另一端接MCU引脚并开启内部上拉,这样读到低电平就代表按下,稳定且省外接电阻。

5.5 Flash擦写导致程序卡死或参数丢失

如果加入参数保存功能后,发现程序在修改参数后卡死,第一反应看Flash擦写代码是否被反复调用。Flash的擦写时序有严格要求,必须关中断、按流程执行、等待BSY标志,而且扇区擦除期间不能有中断服务程序访问Flash,否则会导致硬件错误。我的做法是:把Flash操作放在主循环的Param_Task里,操作前关闭所有中断,操作完成后重新使能中断;同时用dirty标志加延时的机制,避免频繁擦写。这样既保证了参数保存实时性,又不会影响系统运行。

6. 个人实操心得与后续扩展

这个项目做完之后,我对“嵌入式界面开发”这件事有了一个很深的体会:真正决定项目成败的,往往不是复杂的算法或高深的理论,而是代码的组织方式。表驱动菜单的核心优势在于,它把“显示”和“数据”彻底解耦了。菜单表里一个菜单项就是一个数据描述,程序逻辑不需要关心具体是哪个参数、哪个页面,只需要机械地处理“焦点移动”“进入”“返回”这几类动作。这种结构在我后来做的其他屏、其他MCU项目里也一直沿用,几乎没有改过框架,非常省心。

再说几个从实践中得来的小建议:

第一,菜单文字尽量控制在6个汉字以内。12864一行16个字节,6个汉字12个字节,左侧还有一个光标占位,右侧还要留出位置显示数值,设计得紧凑才能一屏放下。文字太长,显示不完整,界面会很挤。

第二,深菜单别超过四层。设备上的菜单是用来操作的,不是用来藏猫腻的,层级太深用户会迷路。我一般控制在三级以内,超过三级的尽量把页面合并成带参数的列表。

第三,参数保存时最好加一个校验和字段,比如把所有参数值做一个简单的异或或者CRC8。上电读取时校验失败就视为默认值,否则一旦Flash数据损坏,设备会以不可预测的参数运行,这种问题在外部用户手里几乎没法排查。

第四,如果以后想把菜单框架移植到HAL库或者换到其他MCU,只需要把lcd12864.c里的GPIO操作和延时函数换掉,menu.c完全不需要动。这是分层设计带来的最大红利。

最后再分享一个我自认为很值钱的小技巧:在做完菜单功能后,可以加一个隐藏的“系统信息”页面,显示固件版本号、设备地址、运行时间这些信息。这类页面既方便调试,又方便售后排查问题。加这个页面只需要在菜单表里增加一项,指向一个新的MenuPage,几分钟就搞定,但以后省下的沟通成本非常可观。

如果你正准备给自己的MCU项目加一个人机交互界面,完全可以照着这套思路先搭一个最小框架,把主菜单、一个二级页面、一个参数项跑通,然后再慢慢往里填功能。先把地基打牢,界面这东西,毕竟只是锦上添花,真正支撑它的是整个代码结构。

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

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

基于Pytorch的视觉操作关系推理与多物体抓取系统实战

简介&#xff1a;本资源是一个面向机器人视觉与工业自动化领域的PyTorch实战项目&#xff0c;聚焦于视觉操作关系推理与多物体协同抓取任务&#xff0c;适用于具备深度学习基础的算法工程师、高校研究者及智能机器人方向开发者。系统基于VMRD数据集训练验证&#xff0c;融合Cas…

作者头像 李华
网站建设 2026/9/1 21:56:00

x64dbg脚本编程:从手动调试到自动化逆向分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/1 21:55:36

Three.js 3D机房可视化项目源码拆解与二次开发指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/1 21:51:52

Python股票量化系统全解析:数据采集到深度学习选股实战

简介&#xff1a;这是一套面向计算机相关专业学生与初阶从业者的股票量化分析实战项目&#xff0c;适用于毕业设计、课程设计及算法实践场景&#xff0c;覆盖数据采集、存储、统计分析、可视化呈现与深度学习建模全流程。资源包共244个文件&#xff0c;包含71个核心Python源码&…

作者头像 李华
网站建设 2026/9/1 21:50:11

券商研报策略的Python复现:从逻辑翻译到回测验证

简介&#xff1a;本资源是一套面向金融量化研究与Python编程实践的券商研报策略复现方案&#xff0c;主要服务于计算机、人工智能、金融工程等专业的高校师生及行业从业者&#xff0c;帮助其将证券公司行业报告中的逻辑转化为可执行、可验证的量化模型。压缩包共253个文件&…

作者头像 李华
网站建设 2026/9/1 21:44:52

Boost电路电压单闭环控制:从MATLAB/Simulink建模到PI参数整定

在实际电力电子和电源控制项目中&#xff0c;Boost电路&#xff08;升压斩波电路&#xff09;是直流变换的核心拓扑之一。其核心挑战在于&#xff0c;当输入电压固定时&#xff0c;如何通过调节开关管的占空比&#xff0c;使输出电压能够快速、稳定、准确地跟踪给定值&#xff…

作者头像 李华