做嵌入式这几年但凡接手带屏幕的项目总绕不开菜单交互这件事。我见过有人用状态机硬写四层嵌套if-else也见过为了一个菜单逻辑上了全套RTOS其实在绝大多数屏显按键的方案里一套设计良好的三级菜单完全可以在裸机上优雅运行。今天我想把STM32ST7789这套组合的实战心得完整拆给你从硬件选型的理由、底层驱动的坑到三层菜单的状态机设计和按键消抖再到完整代码的逐段注释一次说透。这篇内容适合手里有开发板、想从点亮屏幕进阶到做出可交互界面的朋友也适合正在做课设或小项目的嵌入式初学者参考。先说结论这套方案的设计原则是最小依赖、逻辑清晰、可移植。显示部分只依赖STM32的硬件SPI菜单逻辑完全不依赖任何图形库全部手写绘制函数和状态机。这样做的优势是你不需要引入类似GUI Guider或第三方库一个工程从编译到烧录整个链路完全可控。菜单的层级通过数组和函数指针实现也就是常说的查表法相比链表实现更省内存访问速度也更快这对MCU来说非常友好。1. 项目整体方案与硬件选型思路在做这个项目之前我其实纠结过几个方案。第一个是用OLED屏幕SSD1306驱动那种显示中文要自己取模做菜单特别憋屈。第二个是用带触摸的屏幕面板成本上去了不说对于简单控制类设备实体按键的确认感和盲操作体验其实是触摸比不了的。最后定下来ST7789这颗驱动IC的IPS屏理由很简单便宜、色彩表现好、SPI接口省引脚、1.3寸到2.4寸各种规格随便挑社区资料丰富踩坑成本低。MCU的选择上我用的是STM32F103C8T6也就是大家常说的“蓝丸”核心板。为什么选它首先是量大管饱主频72MHz跑这种简单GUI完全够用Flash 64KB、RAM 20KB虽然资源不算富余但做三级菜单加图标界面绰绰有余。其次是资料和工具链成熟Keil MDK直接开搞ST-Link或者串口下载都方便对新手试错极其友好。如果你手头是其他型号的STM32比如F407或者G0系列方案同样适用只需要根据芯片头文件微调SPI初始化的部分。硬件连接上我整理了一个表格你可以照着接线特别注意不要把背光引脚接到3.3V就完事最好串一个三极管或者MOS管用GPIO控制方便后续做屏幕休眠和功耗优化。屏幕引脚STM32引脚说明VCC3.3V供电GNDGND共地SCLPB13(SPI1_SCK)时钟线SDAPB15(SPI1_MOSI)数据线只用写RESPB0复位引脚低电平复位DCPB1数据/命令选择高为数据CSPB12片选低有效BLKPB2背光控制高电平点亮按键这边采用三键方案上一项、下一项、确认/返回复用。三个按键分别接PA0、PA1、PA2对地接法内部上拉。这里有个细节值得强调按键引脚一定要选支持外部中断的端口不过在这个设计里我并没有用中断而是放在主循环里轮询。原因后面讲状态机的时候我会详细解释先说结论菜单操作是低频事件轮询加状态机消抖比中断更可靠也不会打断关键显示时序。2. ST7789底层驱动显示与字库的配合逻辑ST7789这颗IC本身支持RGB565和RGB666两种颜色格式通过SPI发送时最常用的是RGB565也就是每个像素两个字节。屏幕分辨率常见的是240x320也有240x240的方形屏。240x320全屏缓冲需要150KB显然不可能全部放进F103C8T6的20KB RAM所以这个项目采用“部分缓冲窗口绘制”的策略定义一块40行高的行缓冲每次把要显示的画面按行切割传入通过显存窗口设置一次性刷到屏幕。初始化这块很多人从网上找到的代码五花八门真正能稳定驱动的是需要跟屏幕ID匹配的。ST7789虽然型号一样但不同厂家的模组初始化参数有细微差异。我这边测试过比较稳妥的初始化序列是软件复位、休眠退出、像素格式设置为16位色、设置扫描方向、开启显示。关键坑在于扫描方向和RGB/BGR的排列会影响字体和图片显示方向这个在代码里可以通过修改RAM访问控制指令的参数来调整。绘制字库有两种思路。第一种是全字库取模适合做完整的中文二级字库但需要好几MB的Flash单片机的Flash显然装不下一般配合外部Flash芯片。第二种是只把用到的字取模到数组里也就是常见的“自绘字库表”。我采用的是后者做法是把ASCII字符全部生成16x24的字体数组中文按需生成几个常用字比如“主菜单”“设置”“返回”这些固定文本。这套方案的优点是代码简单不用解析字库文件缺点是换字要重新取模。实际开发中如果项目界面文本基本固定这个方案最省心。字体取模工具用的是PCtoLCD2002参数设置上选择“逐行式”、阴码、列行式字体大小按16x24输出。取模的方式直接决定了程序里数据排布的顺序这一点要注意取出来的数组是按行排列还是按列排列直接对应绘制函数里两层循环的嵌套顺序。排反了会出现镜像或者乱码。绘制的核心函数可以精简到下面这组。第一个是设置窗口第二个是填充颜色第三个是画单个字符第四个是画字符串。void ST7789_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { ST7789_WriteCmd(0x2A); ST7789_WriteData(x0 8); ST7789_WriteData(x0 0xFF); ST7789_WriteData(x1 8); ST7789_WriteData(x1 0xFF); ST7789_WriteCmd(0x2B); ST7789_WriteData(y0 8); ST7789_WriteData(y0 0xFF); ST7789_WriteData(y1 8); ST7789_WriteData(y1 0xFF); ST7789_WriteCmd(0x2C); } void ST7789_FillRect(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint16_t w x1 - x0 1; uint16_t h y1 - y0 1; ST7789_SetWindow(x0, y0, x1, y1); for (uint32_t i 0; i (uint32_t)w * h; i) { ST7789_WriteData(color 8); ST7789_WriteData(color 0xFF); } ST7789_SetWindow(0, 0, ST7789_WIDTH - 1, ST7789_HEIGHT - 1); }写数据这里有一个性能关键点。如果你使用SPI字节传输函数一次发一个字节绘制全屏就会非常慢。正确做法是开启SPI的DMA传输或者至少把数据打包成多个字节一次性发送。我在代码里用了硬件SPI加DMA方式颜色数据打包到缓冲区之后一次性DMA发送。实际测试下来16x24的字符刷新肉眼几乎无延迟整屏刷新的耗时也在可接受范围内。如果你的MCU没有DMA那就要考虑降低刷新频率或者缩减每次要重绘的区域。3. 三级菜单架构设计从状态机到可视化的界面层映射这是整个项目最核心的部分。三级菜单的“级”在逻辑上对应的是界面跳转的深度一个典型场景是“主界面--进入设置--选择亮度--调节亮度参数--确认返回”。如果每个界面都写死一个处理函数四五个界面还可以控制一旦界面数量上到十几个维护起来就是噩梦。所以这里用状态机加查表的方式管理页面。状态机的核心是两个数组一个是页面定义表一个是菜单项定义表。页面定义表用来描述当前页面属于什么类型、拥有哪些菜单项、进入这个页面之前的上一页是什么。菜单项定义表则描述每一项的文本、对应的动作以及分组的数量。页面类型的划分上我做了一个小抽象分为四类列表页(用来展示下级菜单或者选项列表)、数值调节页(用来加减参数)、开关页(来表示开和关)、对话框页面(提示和确认)。这个抽象的好处是绘制和按键逻辑都能复用新增页面的时候只需要往表里加一行数据不需要在逻辑代码里增加分支判断。菜单的结构本质上是数据和操作分离你可以非常直观地感受到后续加一级菜单、加一个调节项都不再触碰按键处理逻辑这也是这套设计最值得学习的地方。状态结构体如下所示typedef struct { MenuPageType type; uint8_t page_id; uint8_t item_count; uint8_t select_index; int16_t value; int16_t value_min; int16_t value_max; int16_t value_step; } MenuContext; MenuContext menu_ctx;当前页面的状态全部保存在这个结构体里。select_index记录当前选中的菜单项位置value是给调节页使用的参数值。页面跳转的时候只需要切换menu_ctx中page_id的值并重置select_index即可。这样实现的一个隐含好处是菜单系统是纯内存操作不涉及复杂的全局变量交叉引用整个系统的可重入性、可测试性都显著增强。页面栈这块我并没有用整个栈结构存储历史路径而是简单的parent_page_id字段记录每一页的父页面ID。因为在嵌入式场景中菜单层级一般不会太深三级到底返回的时候只需要跳回父页面。三级菜单真正复杂的地方不在跳转而在状态记录和返回逻辑。很多朋友的方案里按键返回时找不到要回到哪个页面就是因为每个页面没有存自己的父页面ID。4. 菜单项表格与页面跳转的代码实现菜单项定义表我用的是结构体数组。每个条目保存了当前页面的id、菜单项的数目、回调函数指针。这里面的关键创新点在于回调函数指针指向的是动作逻辑而不是绘制逻辑绘制逻辑统一由页面类型决定动作逻辑才用回调区分。这就把“这个菜单长什么样”和“这个菜单按键后干什么”彻底解耦了。typedef struct { uint8_t current_page; uint8_t parent_page; void (*on_enter)(uint8_t page_id); void (*on_key_up)(void); void (*on_key_down)(void); void (*on_key_ok)(void); } PageAction; const PageAction page_action_table[] { { PAGE_MAIN, PAGE_NONE, NULL, NULL, NULL, Main_OnOk }, { PAGE_SETTING, PAGE_MAIN, NULL, NULL, NULL, Setting_OnOk }, { PAGE_BRIGHTNESS, PAGE_SETTING, Brightness_OnEnter, Brightness_Up, Brightness_Down, Brightness_Ok }, // 其他页面... };按键处理的逻辑就是查这张表只要拿到了当前page_id就能拿到对应的回调函数。每个页面只需要把自己关注的按键事件写进回调函数即可。返回动作统一检查父页面ID如果父页面不为空则显示父页面。这里有个很实用的细节在进入页面的时候强制把select_index清零。否则用户上次在列表中停在第5项退出之后再次进入直接停留在中间位置虽然也不算bug但体验不够好。清空后每次进入都从第一项开始逻辑最简单也最不容易出问题。三层菜单的典型逻辑可以这样描述第一级是主菜单展示“状态”“设置”“关于”三个入口。第二级设置页里展示“亮度”“音量”“对比度”三个调节项。第三级进入亮度调节页此时屏幕显示一个滑动条上方标注当前数值按键上下调整确认后保存并返回设置页。这套结构覆盖了多级列表、参数调节、入口跳转、返回导航四类最常见的嵌入式GUI场景你能把这套跑通稍加修改就能适配到90%以上的类似项目。5. 按键扫描、消抖与长按处理的完整方案物理按键看起来简单真正做稳定并不容易。最简单的按键扫描是延时消抖检测到电平变化后延时20ms再读一次虽然能用但主循环会被延时卡死显示刷新容易闪烁。这个项目里的定位是裸机裸奔显示和按键共享一个主循环所以必须用非阻塞的消抖方案。我的做法是基于定时器扫描的状态机消抖。用SysTick或者TIM2产生一个5ms的周期中断在中断服务函数中调用按键扫描函数。按键状态机的状态包括空闲状态、可能的按下状态、按下确认状态、可能的释放状态、释放确认状态。在空闲状态检测到输入引脚为低时进入可能的按下状态如果在5ms后的下一次中断依然为低则进入按下确认状态同时置位一个按键事件标志位。释放过程同理。整个消抖过程不阻塞任何程序主循环只要检查事件标志位即可。长按处理这里我做了两层设计。短按用来逐项移动长按用来快速连续调节数值。实现的方式是在按下确认状态之后每次定时器中断里累计按键持续时间超过800ms后每100ms触发一次按键重复事件。这种效果类似电脑键盘按住不松连续输入的机制对于亮度调节这类光标移动操作极其好用。由于按键状态机在中断中执行回调函数位于主循环中这中间通过全局的event标志进行通信。事件标志如下typedef enum { KEY_EVENT_NONE 0x00, KEY_EVENT_UP 0x01, KEY_EVENT_DOWN 0x02, KEY_EVENT_OK 0x04, KEY_EVENT_BACK 0x08, } KeyEvent;主循环中读走事件后必须立刻清标志否则会重复触发。这个过程和操作系统的信号量机制是类似的思路只是这里用简单的全局变量就够了。需要注意的是中断和主循环共享这个变量需要加volatile关键字修饰。这些细节在调试的时候坑得很隐蔽你不加volatile有时候也能跑但是编译器优化开启后主循环可能一直读到旧值按键完全失灵。6. 界面绘制细节与缓存刷新优化三级菜单的界面设计上我参考了常见的嵌入式设备布局。顶部是标题栏深色背景、留白显示当前页面名称下方是菜单区域使用高亮色块作为当前选中项的背景。高亮色块和文字颜色反色处理这样用户扫一眼就能确认焦点在哪一项。右侧留出箭头符号hint表示还有子菜单可进入。绘制时采用全清重绘和局部重绘结合的策略。页面切换时全清重绘因为内容变化太大不值得去抠局部的差异。但是同一页面内选中项上下移动时只需要重绘两行旧选中行改成普通背景重新画文字新选中行改高亮背景重新画文字。其他行物理上没变化不浪费刷新时间。这一小点优化在240x240屏幕上能把按键响应速度从肉眼可见的慢提升到跟手。实际操作中我遇到了一个很典型的问题直接全屏刷白色背景再重新绘制所有文本在快速按键时会看到明显的闪烁和拖影。原因是ST7789属于非同步刷新LCD刷屏节奏取决于MCU写入数据的节奏。简单粗暴的清除加绘制会产生撕裂感。解决方法是先更新行缓冲区的数据在内存中完成所有绘制运算后一次性将缓冲提交到屏幕。这其实就是双缓冲的思想区别在于SRAM放不下整帧就用行缓冲分块但Benito绘制菜单这样的场景只重绘变化区域也能达到等效效果。我把菜单绘制抽象成三个函数层次底层是填充矩形和绘制字符串中间层是根据页面类型绘制菜单列表顶层是页面控制器调用中间层。中间层有下面的函数void Menu_DrawListItem(uint8_t index, uint8_t selected) { uint16_t y MENU_ITEMS_START_Y index * ITEM_HEIGHT; uint16_t bg_color selected ? ITEM_SELECT_BG : ITEM_NORMAL_BG; uint16_t text_color selected ? ITEM_SELECT_FG : ITEM_NORMAL_FG; ST7789_FillRect(MENU_ITEMS_START_X, y, MENU_ITEMS_END_X, y ITEM_HEIGHT - 1, bg_color); ST7789_DrawString(MENU_ITEMS_START_X ITEM_TEXT_PADDING, y (ITEM_HEIGHT - FONT_HEIGHT) / 2, menu_ctx_type_get_current_text(index), text_color, bg_color); }高亮块颜色我用的是一种偏亮的蓝色选中项文字用白色普通项白底黑字。UI配色方面不要追求花哨MCU上的GUI最重要的信息层级高亮项和中前景的对比度够强即可。这里如果选红色作为高亮色长时间盯着会疲劳蓝色系的对比度在IPS屏上表现更好是我调试多块屏以后确定下来相对稳妥的选择。7. 完整代码结构讲解与关键函数分析这里给出一份可编译的代码骨架结构工程目录大致规划为System/系统时钟、GPIO、定时器、Hardware/SPI、ST7789驱动、按键驱动、Gui/字体、绘制函数、菜单逻辑、User/主函数和中断处理。工程分层的核心思想是底层驱动和上层业务严格分离。ST7789驱动里只保留与IC交互的最基本函数比如SetWindow、写命令、写数据、填充和画点。字库模块只负责把字符数据渲染成像素数据不管菜单逻辑。菜单模块调用字库和显示驱动按键模块独立运行。只有入口模块也就是主函数才将它们串起来。关键文件清单如下文件职责bsp_spi.cSPI1初始化DMA配置bsp_st7789.c屏幕初始化、窗口设置、绘制基础函数bsp_key.c按键扫描状态机、定时器扫描gui_font.c字库数据与字符绘制gui_menu.c页面类型定义、菜单表格、页面切换逻辑main.c初始化、主循环事件分发主循环的逻辑非常简洁真正的应用逻辑全在回调函数里。主循环大致是这样的while (1) { KeyEvent evt Key_GetEvent(); if (evt ! KEY_EVENT_NONE) { Menu_HandleKey(evt); } }所有的绘制和动作更新都在事件处理里。刷屏动作本身需要时间所以如果没有任何事件主循环就空转等待功耗上也更友好。这里我重点说明一下为何不在主循环里高频刷新显示除了浪费CPU周期之外还会造成屏幕不断重绘反而更容易出现闪烁。只有在事件驱动调用绘制时才把画布重新渲染一次。这个设计思路普遍应用于小型嵌入式设备中也是产品级代码和教学代码最大的区别。亮度调节页的数值处理我贴一下代码万变不离其宗void Brightness_Up(void) { if (menu_ctx.value menu_ctx.value_max) { menu_ctx.value menu_ctx.value_step; Brightness_Apply(menu_ctx.value); Brightness_DrawValue(); } } void Brightness_Down(void) { if (menu_ctx.value menu_ctx.value_min) { menu_ctx.value - menu_ctx.value_step; Brightness_Apply(menu_ctx.value); Brightness_DrawValue(); } }这里Brightness_Apply函数在真实设备上会操作PWM寄存器改变屏幕背光亮度。在裸机系统里值域检查和边界判断都放在回调函数内部不要在按键事件分发里做统一判断。因为不同页面的数值范围不同按键本身并不知道当前处于调节状态还是列表状态统一判断会导致代码侵入性强、不好维护。各页面自己管好自己那部分逻辑才是状态机查表设计的精髓。8. 常见问题排查与debug经验速查这里汇总了我实际调试中遇到的最典型的几个问题也是后台留言里被问得最多的几类。覆盖面比较广建议直接收藏遇到问题对着查。第一个是屏幕白屏无显示。优先排查复位时序。ST7789的RES引脚要先拉低至少10ms再拉高。部分屏幕模块没有集成RC复位电路MCU上电后没有主动拉低复位引脚IC可能直接处于乱码状态。另外检查SPI的极性相位ST7789一般配置为SPI模式0CPOL0CPHA0或者模式3CPOL1CPHA1要看驱动IC对数据建立时间的要求经验上模式0最常用。如果确认接线没问题用示波器看SCK和SDA波形查看是否有数据时钟输出能快速定位是哪一边的问题。第二个是汉字乱码或方向颠倒。绝大多数情况是取模参数选错了。PCtoLCD2002里要选逐行式、阴码、字节倒序根据字体大小调整如果画面出现镜像检查RAM访问控制指令0x36的扫描方向位。如果汉字上下颠倒但字母正常通常是取模的时候选择了逐列式绘制函数按逐行解析行列索引就反了。第三个是SPI刷新慢大面积填色时肉眼可见地一格一格往下走。这种情况通常是调用WriteData太频繁每条数据都重新判断一次片选和忙标志。改进方案是合并数据到本地缓冲开启DMA传输。F103的SPI1支持DMA配置之后直接把缓冲地址丢给外设CPU干别的去整体效率能提升好几倍。第四个是按键偶尔失灵或者一次触发两次。按键插脚接触不良是一个物理因素软件层面优先检查事件标志是否在进入处理前被重复设置。可以在中断服务函数中加一个按键释放后的状态复位并确认消抖状态机在释放状态下等足够时间才允许重新检测按下把弹跳窗口完全过滤掉。第五个是进入三级菜单后按返回键直接黑屏或者花屏。花屏基本是列地址和页地址计算越界比如在屏幕底部绘制半行不可见的像素超出分辨率范围。黑屏大概率是父页面ID设置错误返回到了不存在的页面导致绘制函数没有匹配到页面类型于是什么都不画。迭代排查时先打印当前的page_id和父页面id对照定义表逐条核对。现象大概率原因解决方向屏幕白屏RES时序、SPI模式错误检查复位单、SPI模式0/3汉字乱码取模参数不对检查逐行/逐列、阴码/阳码方向颠倒/镜像0x36指令扫描方向配置按模组的实际面板调整刷新速度慢缺少DMA、字节发送缓冲区合并、开启DMA按键偶尔无响应消抖不彻底或标志未清检查状态机时序、事件标志volatile菜单返回黑屏父页面ID错误核对页面对照表9. 项目移植与扩展思路这套代码的移植性相当好。从F103移植到G0系列或者F4系列主要改动集中在三处SPI外设时钟配置、GPIO初始化、DMA通道号。ST7789驱动本身不依赖具体MCU型号字库模块更不用改。你只需要重新映射一下bsp_spi.c里的引脚定义和RCC外设时钟使能其他文件一行都不动。扩展方向我特别看好两个。第一个是加一个小型文件系统到外部Flash用来保存配置参数。F103内置的Flash寿命有限频繁改写参数不合适。外挂一个W25Q64通过SPI接口存储参数配置和开机画面参数持久化的问题就彻底解决了。如果你把字库存到外部Flash开机动态加载字库界面文本就不再受限于固定字模了扩展性会好非常多。第二个方向是引入RTOS。菜单逻辑本身不依赖操作系统但如果项目同时要跑通信、传感器采集、UI刷新多个任务裸机主循环就会越来越臃肿。移植到FreeRTOS的思路是菜单状态机和动作逻辑封装成一个独立任务按键事件通过队列发给这个任务。显示模块加互斥锁保护避免多个任务同时写屏幕导致画面错乱。这个移植思路如果展开单独写估计又是一整篇的量等你把裸机版本吃透了再上系统理解会深刻得多。调试工具方面花小钱办大事的工具是逻辑分析仪。不要觉得几十块钱的8通道逻辑分析仪是玩具在排查SPI时序、按键抖动这类问题上足够用。我实际调试时发现一次“偶发按键无响应”的根因就是定时器中断优先级设置太低被SPI DMA中断抢占期间刚好丢了脉冲。没有逻辑分析仪抓波形这个问题我可能得排查三个小时。我在实际绘制过程中还有一个小技巧调试时可以在屏幕上画版本号和帧率计数器每次刷新画面Debug模式下能够看到具体刷新耗时也可以通过优化状态观察是不是有意外重复刷屏。刷屏频率超过指标要求时优先检查是不是有某个回调在非事件触发时被反复调用了这个问题比性能本身更值得防。这套代码从头到尾写完验证大约用了两个完整周末。第一个周末写驱动和字库第二个周末把菜单状态机理顺并且画出了比较满意的界面。如果你按这篇的步骤走时间应该只需要一半甚至更短。遇到具体卡住的地方欢迎留言交流。