做嵌入式这几年,但凡接手带屏幕的项目,总绕不开菜单交互这件事。我见过有人用状态机硬写四层嵌套if-else,也见过为了一个菜单逻辑上了全套RTOS,其实在绝大多数屏显+按键的方案里,一套设计良好的三级菜单完全可以在裸机上优雅运行。今天我想把STM32+ST7789这套组合的实战心得完整拆给你,从硬件选型的理由、底层驱动的坑,到三层菜单的状态机设计和按键消抖,再到完整代码的逐段注释,一次说透。这篇内容适合手里有开发板、想从点亮屏幕进阶到做出可交互界面的朋友,也适合正在做课设或小项目的嵌入式初学者参考。
先说结论:这套方案的设计原则是最小依赖、逻辑清晰、可移植。显示部分只依赖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引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 供电 |
| GND | GND | 共地 |
| SCL | PB13(SPI1_SCK) | 时钟线 |
| SDA | PB15(SPI1_MOSI) | 数据线(只用写) |
| RES | PB0 | 复位引脚,低电平复位 |
| DC | PB1 | 数据/命令选择,高为数据 |
| CS | PB12 | 片选,低有效 |
| BLK | PB2 | 背光控制,高电平点亮 |
按键这边采用三键方案:上一项、下一项、确认/返回复用。三个按键分别接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.c | SPI1初始化,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模式0(CPOL=0,CPHA=0)或者模式3(CPOL=1,CPHA=1),要看驱动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模式下能够看到具体刷新耗时,也可以通过优化状态观察是不是有意外重复刷屏。刷屏频率超过指标要求时,优先检查是不是有某个回调在非事件触发时被反复调用了,这个问题比性能本身更值得防。
这套代码从头到尾写完验证,大约用了两个完整周末。第一个周末写驱动和字库,第二个周末把菜单状态机理顺并且画出了比较满意的界面。如果你按这篇的步骤走,时间应该只需要一半甚至更短。遇到具体卡住的地方欢迎留言交流。