STM32 OLED多级菜单GUI实现:轻量级嵌入式界面设计实战
1. 项目概述与核心价值
最近在整理手头的几个STM32开发板,发现几乎每块板子上都挂着一个0.96寸或1.3寸的OLED屏幕。这些屏幕成本低廉、接口简单(I2C或SPI),显示效果又足够清晰,简直是嵌入式开发的“万金油”。但很多朋友拿到手,除了显示几行固定的文字、画个简单的图形,就不知道还能玩出什么花样了。这让我想起之前一个简化版智能手表的点子——它不需要复杂的触摸屏和强大的处理器,核心就是通过几个物理按键,在一个小小的OLED屏上流畅地操作多级菜单,实现时间、闹钟、计步等基础功能。这不正是检验我们嵌入式GUI设计能力的好项目吗?
这个“基于STM32的OLED多级菜单GUI实现”项目,本质上是在资源极其有限的单片机(通常是STM32F103C8T6这类)上,构建一个结构清晰、响应迅速、可维护性高的用户界面框架。它要解决的核心问题是:如何用有限的RAM和Flash,以及简单的非触摸屏,管理复杂的菜单层级、状态切换和界面刷新。对于正在从点灯、串口通信迈向更综合系统设计的开发者来说,这是一个绝佳的练手项目。它能让你深刻理解状态机、数据结构、显示驱动与用户输入处理的协同工作,这些思想在日后开发更复杂的嵌入式系统时同样受用。接下来,我就把自己实现过程中的设计思路、关键代码和踩过的坑,毫无保留地分享出来。
2. 整体系统设计与框架选型
2.1 硬件平台与核心需求分析
我的硬件核心是一块STM32F103C8T6(Blue Pill),64KB Flash,20KB RAM,完全够用。显示器件是一块0.96寸的OLED,驱动芯片是SSD1306,通过I2C接口通信,分辨率128x64。输入部分,我使用了三个机械按键,分别定义为“上翻”、“下翻”、“确认/返回”。这就是全部家当,非常典型且廉价的组合。
需求很明确:要做一个能模拟智能手表基础交互的系统。这意味着需要:
- 多级菜单导航:例如,从主菜单(显示时间)进入“设置”菜单,再进入“时间设置”子菜单。
- 状态持久化:设置的时间、闹钟等参数,断电后不能丢失,需要存入Flash或EEPROM。
- 实时信息显示:主界面需要动态更新时间。
- 有限的响应资源:CPU主频72MHz,需要兼顾界面响应和后台任务(如计时)。
基于这些需求,我排除了使用现成嵌入式GUI库(如LVGL、emWin)的想法。它们功能强大,但对于这个简单的单色屏和有限的资源来说过于臃肿,且学习成本不低。我决定采用一种更轻量、更直接的方式:基于链表和状态机的菜单管理系统。这套方案完全自主可控,代码量小,效率高,且特别适合理解GUI背后的运行机制。
2.2 软件框架与数据结构设计
整个系统的软件框架可以划分为三层:
- 驱动层:最底层,包括OLED的SSD1306驱动、按键的GPIO扫描驱动、RTC(实时时钟)驱动等。这部分保证硬件正常工作。
- 核心管理层:中间层,也是本项目的灵魂。它用数据结构定义菜单,用状态机管理界面跳转逻辑。
- 应用层:最上层,定义具体的菜单项内容、回调函数(如按下“确认”后做什么)和界面绘制函数。
核心在于如何设计“菜单”这个数据结构。我定义了一个MenuItem结构体:
typedef struct MenuItem { char displayText[20]; // 在屏幕上显示的文本 MenuItem *parentMenu; // 指向父菜单的指针,用于返回操作 MenuItem *childMenu; // 指向子菜单的指针,进入下一级 MenuItem *prevSibling; // 指向前一个兄弟菜单项 MenuItem *nextSibling; // 指向后一个兄弟菜单项 void (*menuFunction)(void); // 当前菜单项对应的功能函数指针 uint8_t isSelected; // 当前是否被选中(高亮显示) } MenuItem;这个结构体构成了一个树形链表。parentMenu和childMenu让你能在菜单树中上下导航,prevSibling和nextSibling让你能在同级菜单中左右(上下)切换。menuFunction是一个函数指针,当用户在该菜单项上按下“确认”键,且该项没有子菜单时,就会执行这个函数,比如进入一个数值设置界面或执行某个任务。isSelected标志位用于界面渲染,标明当前焦点在哪一项。
所有菜单项在程序初始化时被创建并链接起来,形成一个静态的菜单树。系统全局维护一个currentMenu指针,永远指向当前显示的菜单页面的链表头。还维护一个currentItem指针,指向当前被选中的菜单项。按键事件会修改这两个指针,而主循环的任务就是根据这两个指针的状态,去调用相应的界面绘制函数。
设计心得:为什么用链表而不用数组?数组索引确实简单,但在菜单项动态增减(虽然本项目不常用)和内存利用上不灵活。链表结构更直观地反映了菜单的树形关系,代码的可读性和可扩展性更好。虽然需要多几个指针占点内存,但在STM32F103上完全可接受。
3. 核心模块实现详解
3.1 菜单树的构建与初始化
菜单树的构建是在系统初始化时完成的。我们以智能手表的经典菜单为例:
主菜单 (Root) ├── 时间显示 (Time Display) -> 直接显示,无子菜单 ├── 闹钟设置 (Alarm Set) -> 进入子菜单 │ ├── 闹钟1 (Alarm 1) -> 进入设置界面 │ └── 闹钟2 (Alarm 2) -> 进入设置界面 └── 系统设置 (System Settings) -> 进入子菜单 ├── 时间设置 (Set Time) -> 进入设置界面 ├── 亮度调节 (Brightness) -> 进入设置界面 └── 关于 (About) -> 显示信息在代码中,你需要先为每一个菜单项声明一个MenuItem变量,然后像搭积木一样把它们链接起来。
MenuItem mainMenu, timeDisplay, alarmSetMenu, alarm1, alarm2, sysSetMenu, setTime, setBrightness, about; void Menu_Init(void) { // 1. 初始化主菜单项 strcpy(mainMenu.displayText, "Main Menu"); mainMenu.parentMenu = NULL; // 根菜单没有父菜单 mainMenu.childMenu = &timeDisplay; // 第一个子项是“时间显示” mainMenu.prevSibling = NULL; mainMenu.nextSibling = NULL; mainMenu.menuFunction = NULL; // 主菜单本身不执行功能 mainMenu.isSelected = 0; // 2. 初始化“时间显示”项 strcpy(timeDisplay.displayText, ">Time"); timeDisplay.parentMenu = &mainMenu; timeDisplay.childMenu = NULL; // 没有子菜单,按下确认可能直接跳转到时钟界面 timeDisplay.prevSibling = NULL; timeDisplay.nextSibling = &alarmSetMenu; // 下一个兄弟是“闹钟设置” timeDisplay.menuFunction = SwitchToClockView; // 指向切换界面的函数 timeDisplay.isSelected = 1; // 初始选中第一项 // 3. 初始化“闹钟设置”菜单(它是一个文件夹,有子项) strcpy(alarmSetMenu.displayText, " Alarm"); alarmSetMenu.parentMenu = &mainMenu; alarmSetMenu.childMenu = &alarm1; // 它的子菜单链表头是alarm1 alarmSetMenu.prevSibling = &timeDisplay; alarmSetMenu.nextSibling = &sysSetMenu; alarmSetMenu.menuFunction = NULL; // 作为文件夹,没有直接功能 alarmSetMenu.isSelected = 0; // 4. 初始化“闹钟1”项(属于“闹钟设置”的子项) strcpy(alarm1.displayText, ">Alarm 1"); alarm1.parentMenu = &alarmSetMenu; alarm1.childMenu = NULL; alarm1.prevSibling = NULL; // 它是“闹钟设置”下的第一个子项 alarm1.nextSibling = &alarm2; alarm1.menuFunction = EnterAlarm1Setting; alarm1.isSelected = 1; // 在子菜单中默认选中第一项 // ... 以此类推,初始化alarm2, sysSetMenu等所有项 // 5. 设置全局指针 currentMenu = &mainMenu; // 当前显示主菜单 currentItem = &timeDisplay; // 当前选中“时间显示” }这个过程虽然看起来繁琐,但逻辑清晰。一旦树建好,后续的导航逻辑就会非常简洁。
避坑指南:在链接
prevSibling和nextSibling时,一定要形成闭环或妥善处理边界。例如,主菜单的最后一项sysSetMenu的nextSibling应该指向NULL,而当你按“下”键走到最后一项时,代码需要判断nextSibling是否为NULL,如果是,则可能循环回到第一项(currentMenu->childMenu),或者停止不动,这取决于你的交互设计。我推荐循环滚动,体验更友好。
3.2 按键扫描与状态机处理
按键处理是GUI响应的源头。我采用简单的扫描去抖方式,并在按键释放时触发事件。关键是将物理按键事件转化为逻辑事件,交给菜单状态机处理。
typedef enum { KEY_NONE, KEY_UP, KEY_DOWN, KEY_ENTER, KEY_BACK // 有时可以用“长按ENTER”作为返回,这里单独定义 } KeyEvent_t; KeyEvent_t GetKeyEvent(void) { // 简单的扫描和去抖逻辑,返回上述枚举值 // ... } void Menu_KeyHandler(KeyEvent_t key) { if (key == KEY_NONE) return; switch(key) { case KEY_UP: // 找到当前选中项的前一个兄弟节点 if (currentItem->prevSibling != NULL) { currentItem->isSelected = 0; // 取消旧项选中 currentItem = currentItem->prevSibling; currentItem->isSelected = 1; // 设置新项选中 needRedraw = 1; // 标记需要重绘 } else { // 如果是第一项,可以跳到同级最后一项,实现循环 MenuItem* p = currentItem; while(p->nextSibling != NULL) p = p->nextSibling; currentItem->isSelected = 0; currentItem = p; currentItem->isSelected = 1; needRedraw = 1; } break; case KEY_DOWN: // 逻辑与KEY_UP对称,找到下一个兄弟节点 // ... break; case KEY_ENTER: if (currentItem->childMenu != NULL) { // 如果有子菜单,进入子菜单 currentMenu = currentItem; // 当前菜单变为这个文件夹项 currentItem = currentItem->childMenu; // 选中项变为子菜单的第一项 currentItem->isSelected = 1; // 需要清屏并重绘整个新菜单 needFullRedraw = 1; } else if (currentItem->menuFunction != NULL) { // 如果没有子菜单但有功能函数,则执行该函数 // 例如,进入一个具体的设置页面 currentItem->menuFunction(); // 注意:执行函数后,可能切换了系统状态(如进入设置模式), // 需要另外的状态机来管理,不一定立即返回菜单。 } break; case KEY_BACK: if (currentMenu->parentMenu != NULL) { // 如果有父菜单,则返回 // 首先取消当前所有子项的选中状态(可选) MenuItem* p = currentMenu->childMenu; while(p != NULL) { p->isSelected = 0; p = p->nextSibling; } // 跳回父菜单 currentItem = currentMenu; // 返回后,焦点落在刚才进入的文件夹项上 currentMenu = currentMenu->parentMenu; currentItem->isSelected = 1; // 高亮它 needFullRedraw = 1; } break; } }这个状态机是菜单导航的核心。它只负责指针的移动和重绘标志位的设置,不负责具体的屏幕绘制,实现了逻辑与显示的分离。
3.3 OLED界面绘制与优化
绘制部分相对独立。当needRedraw或needFullRedraw标志被置位时,主循环调用绘制函数。
void Menu_Draw(void) { OLED_Clear(); // 如果是needFullRedraw,则清屏;如果只是needRedraw,可以局部刷新 // 1. 绘制标题栏,例如显示当前菜单的名称 OLED_ShowString(0, 0, currentMenu->displayText, 8); // 2. 遍历绘制当前菜单下的所有子项 MenuItem* p = currentMenu->childMenu; uint8_t yPos = 16; // 从第16像素行开始绘制 while (p != NULL) { // 判断是否是被选中的项,选中项前面加'>'并高亮反显 if (p->isSelected) { OLED_SetInverseMode(); // 设置反显 OLED_ShowString(2, yPos, ">", 8); OLED_ShowString(12, yPos, p->displayText, 8); OLED_SetNormalMode(); // 恢复正常显示 } else { OLED_ShowString(12, yPos, p->displayText, 8); // 非选中项缩进显示 } yPos += 10; // 行间距 p = p->nextSibling; } // 3. 绘制底部状态栏,如返回提示 OLED_ShowString(0, 56, "[B]Back", 8); }这里有几个重要的优化点:
- 局部刷新:如果只是光标(选中项)移动,理论上只需要重绘移动前和移动后的两行,而不是清屏重绘整个菜单。这可以显著提高刷新速度,避免闪烁。我的做法是,在
KEY_UP/KEY_DOWN处理中,记录oldItem和newItem,在绘制函数中只刷新这两行对应的区域。 - 显示优化:OLED是自发光,纯黑色背景。反显(白底黑字)比正常显示(黑底白字)更耗电,但作为选中标志很醒目。可以根据需要调整。
- 字库与图形:可以使用现成的ASCII和汉字字库(取模软件生成)。如果想更美观,可以设计简单的图标,用
OLED_DrawBMP函数显示在菜单项前面。
3.4 功能页面与设置界面的实现
菜单项最终要导向具体的功能。例如,“时间设置”被按下后,会调用EnterTimeSetting()函数。这个函数会将系统从“菜单浏览模式”切换到“时间设置模式”。在这个模式下,你需要一个新的状态机来处理“时”、“分”、“秒”等字段的切换和数值增减。
typedef enum { SET_HOUR, SET_MINUTE, SET_SECOND, SET_CONFIRM } TimeSetState_t; TimeSetState_t setState = SET_HOUR; uint8_t tempHour, tempMinute, tempSecond; void EnterTimeSetting(void) { // 1. 从RTC读取当前时间到临时变量 RTC_GetTime(&tempHour, &tempMinute, &tempSecond); setState = SET_HOUR; // 2. 切换系统主状态机到“设置模式” sysState = SYS_STATE_TIME_SETTING; // 3. 绘制设置界面 DrawTimeSettingView(); } // 在系统主循环中,当sysState == SYS_STATE_TIME_SETTING时 void TimeSetting_KeyHandler(KeyEvent_t key) { switch(key) { case KEY_UP: if (setState == SET_HOUR) tempHour = (tempHour + 1) % 24; else if (setState == SET_MINUTE) tempMinute = (tempMinute + 1) % 60; // ... 其他字段 DrawTimeSettingView(); // 刷新数值显示 break; case KEY_DOWN: // 数值递减 break; case KEY_ENTER: // 切换到下一个设置字段,或确认保存 if (setState == SET_HOUR) setState = SET_MINUTE; else if (setState == SET_MINUTE) setState = SET_SECOND; else if (setState == SET_SECOND) setState = SET_CONFIRM; else if (setState == SET_CONFIRM) { // 保存到RTC和Flash RTC_SetTime(tempHour, tempMinute, tempSecond); SaveTimeToFlash(); // 退出设置模式,返回菜单 sysState = SYS_STATE_MENU; Menu_Draw(); // 重绘菜单 } DrawTimeSettingView(); // 刷新界面,高亮当前设置字段 break; case KEY_BACK: // 直接放弃修改,返回菜单 sysState = SYS_STATE_MENU; Menu_Draw(); break; } } void DrawTimeSettingView(void) { OLED_Clear(); OLED_ShowString(20, 10, "Set Time", 16); // 绘制时间,例如 "12 : 34 : 56" // 根据setState高亮对应的字段(如小时部分反显) char buf[20]; sprintf(buf, "%02d : %02d : %02d", tempHour, tempMinute, tempSecond); // 根据setState计算高亮区域并反显 // ... OLED_ShowString(30, 35, buf, 16); OLED_ShowString(40, 55, "[ENT]Next [B]Cancel", 8); }这种“模式切换”的思想非常重要。系统在同一时刻只处于一种明确的模式(如菜单浏览、时间设置、闹钟设置、实时时钟显示),每种模式有自己独立的按键处理函数和绘制函数。通过一个全局的sysState变量来切换,逻辑非常清晰。
4. 系统整合与主循环设计
将所有模块整合起来,一个典型的主循环(main.c中的while(1))结构如下:
int main(void) { // 硬件初始化:时钟、GPIO、I2C、OLED、RTC、按键、Flash... // 菜单树初始化 Menu_Init(); // 显示开机画面... sysState = SYS_STATE_MENU; // 初始状态为菜单 Menu_Draw(); // 绘制主菜单 while(1) { // 1. 按键扫描与事件获取 KeyEvent_t key = GetKeyEvent(); // 2. 根据系统状态,分发按键事件 switch(sysState) { case SYS_STATE_MENU: Menu_KeyHandler(key); break; case SYS_STATE_TIME_SETTING: TimeSetting_KeyHandler(key); break; case SYS_STATE_CLOCK_VIEW: ClockView_KeyHandler(key); // 在时钟界面,按某个键返回菜单 break; // ... 其他状态 } // 3. 检查并执行界面刷新 if (sysState == SYS_STATE_MENU) { if (needFullRedraw) { Menu_Draw(); needFullRedraw = 0; needRedraw = 0; } else if (needRedraw) { Menu_RedrawCursorOnly(); // 局部刷新函数 needRedraw = 0; } } // 其他状态的刷新在其各自的按键处理函数中直接调用 // 4. 后台任务(非阻塞式) if (sysState == SYS_STATE_CLOCK_VIEW) { // 在时钟界面,每秒更新一次时间显示 static uint32_t lastTick = 0; if (HAL_GetTick() - lastTick > 1000) { UpdateClockDisplay(); lastTick = HAL_GetTick(); } } // 其他后台任务,如读取传感器(计步器)... // 5. 低功耗处理(可选) // __WFI(); // 等待中断,进入睡眠 } }这个主循环结构清晰,职责分明。它确保了界面响应的实时性,同时又能处理后台任务。
5. 关键问题排查与优化经验
在实际调试中,你肯定会遇到各种问题。下面是我总结的几个典型问题及其解决方案:
5.1 显示闪烁或残影
- 问题描述:刷新菜单时屏幕有明显闪烁,或上次的内容有残留。
- 原因分析:
- 全屏清屏重绘速度慢:
OLED_Clear()和后续的OLED_ShowString需要一定时间,人眼能感知到刷新过程。 - 刷新策略不当:在按键处理中频繁调用全屏刷新。
- SSD1306显存未正确更新:部分驱动库的
OLED_Refresh()函数可能有问题。
- 全屏清屏重绘速度慢:
- 解决方案:
- 启用局部刷新:如前所述,只刷新需要变化的区域。这是最有效的办法。
- 双缓冲(如果RAM足够):在STM32内部开辟一个和OLED显存(1024字节)一样大的缓冲区。所有绘制操作先修改这个缓冲区,修改完成后,一次性通过I2C将整个缓冲区数据发送给OLED。这能完全消除闪烁,但对I2C传输速度有要求,且占用较多RAM。对于128x64的OLED,1024字节的缓冲区在STM32F103上是可以接受的。
- 优化清屏函数:不要用画矩形函数来清屏,直接向SSD1306发送命令
0xA4(整个显示开启)和0xA6(正常显示),然后发送全零的显存数据,或者使用芯片的Set Display ON/OFF命令快速切换。
5.2 按键响应不灵敏或连击
- 问题描述:按下一次,菜单跳动多项;或者需要长按才有反应。
- 原因分析:
- 消抖处理太简单或太复杂:简单的延时消抖可能遗漏快速按键,而状态机消抖如果参数设置不当,会导致响应迟钝。
- 主循环阻塞:如果
Menu_Draw()或某个功能函数执行时间过长,会阻塞主循环,导致按键扫描间隔变大,感觉不跟手。 - 按键事件处理位置不对:在长任务中处理按键,而不是在主循环中统一处理。
- 解决方案:
- 采用状态机消抖:这是最可靠的方法。定义一个按键状态结构体,记录当前电平、稳定计数、上次状态等。在主循环中高频(如1ms)扫描,当检测到稳定按下一定次数(如5ms)后才认为有效按下,并在释放时触发事件。
typedef struct { uint8_t currState; uint8_t lastState; uint8_t stableCount; uint8_t isPressed; uint8_t isLongPressed; uint32_t pressTick; } Key_t; void Key_Scan(Key_t* key, uint8_t gpioState) { key->lastState = key->currState; key->currState = gpioState; if (key->currState == key->lastState) { key->stableCount++; if (key->stableCount >= 5) { // 稳定5ms if (!key->isPressed && key->currState == PRESSED_LEVEL) { key->isPressed = 1; key->pressTick = HAL_GetTick(); } else if (key->isPressed && key->currState != PRESSED_LEVEL) { key->isPressed = 0; if (HAL_GetTick() - key->pressTick < 500) { // 短按 // 触发短按事件 } } // 长按判断... } } else { key->stableCount = 0; } }- 确保主循环非阻塞:所有耗时操作(如写入Flash)尽量拆分成非阻塞的步骤,或者放在独立的任务中。确保按键扫描频率在10ms以内。
- 分离事件与动作:按键扫描只产生事件(
KEY_UP_SHORT,KEY_ENTER_LONG),主循环根据当前系统状态消费这些事件。不要在中断或扫描函数里直接执行复杂的菜单跳转或显示操作。
5.3 菜单结构修改困难
- 问题描述:每次增减菜单项,都要在代码里手动修改一堆指针链接,容易出错。
- 解决方案:
- 使用表驱动法:这是更高级、更优雅的解决方案。定义一个结构体数组(表格)来描述菜单树,数组的索引隐式表达了父子、兄弟关系。
在初始化时,根据这个表格动态生成链表。这样,修改菜单结构只需要修改这个表格即可,逻辑清晰,不易出错。当然,这需要更复杂的初始化函数来解析表格并构建链表。 2.使用脚本或工具生成代码:对于非常复杂的菜单,可以编写一个Python脚本,用更直观的方式(如JSON)描述菜单树,然后自动生成typedef struct { uint16_t id; uint16_t parentId; char text[20]; void (*func)(void); } MenuItemTable_t; const MenuItemTable_t menuTable[] = { {0, 0, "Main Menu", NULL}, // id=0为根 {1, 0, "Time Display", SwitchToClockView}, {2, 0, "Alarm Set", NULL}, {3, 2, "Alarm 1", EnterAlarm1Setting}, {4, 2, "Alarm 2", EnterAlarm2Setting}, // ... };Menu_Init函数里的C代码。这是工程化的做法。
5.4 Flash存储参数丢失或错误
- 问题描述:保存的闹钟时间,重启后恢复默认值或变成乱码。
- 原因分析:
- 擦写地址不对齐:STM32的Flash通常要求按页(Page)擦除,按字(Word)或半字(Half-Word)编程。地址不对齐会导致硬件错误。
- 擦写次数超限:Flash有擦写寿命(通常10万次)。频繁保存会快速耗尽。
- 未处理写入过程中的断电:写入一半时断电,数据可能损坏。
- 解决方案:
- 使用EEPROM模拟库:ST官方提供了
EEPROM Emulation库,它通过占用两页Flash并磨损均衡的算法,提供了类似EEPROM的接口,简化了操作。这是首选方案。 - 如果自己操作Flash:
- 确保写入地址是所在扇区的起始地址,并且是字或半字的整数倍。
- 先解锁Flash,擦除整个扇区,再写入数据。
- 采用“标志位+数据”的结构。例如,先写一个
0xA5A5标志位表示数据有效,再写数据。读取时先检查标志位。 - 对于频繁更新的数据(如计步数),可以只在数值变化较大或系统空闲时保存,避免频繁擦写。
- 加入数据校验:保存时计算CRC校验和一并存储。读取时验证CRC,如果不匹配,则使用默认值。
- 使用EEPROM模拟库:ST官方提供了
6. 功能扩展与进阶思路
这个基础框架搭建好后,你可以很方便地扩展功能,让它更像一个真正的简化版智能手表:
- 动画与过渡效果:在菜单切换时,可以尝试实现简单的滑动动画。例如,进入子菜单时,新菜单从右侧滑入,旧菜单向左滑出。这需要你能够控制整屏像素的移动,可以通过连续多次绘制并改变偏移量来实现,虽然会牺牲一些流畅性,但能极大提升观感。
- 图标化菜单:用简单的位图图标代替纯文字菜单项。例如,用一个钟表图标表示时间设置,一个铃铛图标表示闹钟。这需要你事先制作好图标的位图数组。
- 多任务与后台运行:即使在菜单界面,后台的RTC也在走时。你可以更进一步,让计步器传感器在后台持续计数,并在主菜单界面以小字显示实时步数。这需要妥善处理前台显示和后台数据采集的优先级,避免互相阻塞。
- 低功耗优化:智能手表对功耗敏感。当用户一段时间无操作后,可以自动降低OLED亮度(通过调整对比度命令),甚至关闭OLED显示,进入停机(Stop)模式。任何按键按下产生外部中断唤醒MCU,恢复显示。这需要精心设计电源管理状态机。
- 与上位机通信:通过串口或蓝牙,让手表能够接收手机发送的消息提醒,并在OLED上显示。这需要定义简单的通信协议,并设计一个消息列表界面。
这个项目从简单的菜单显示出发,其内涵却覆盖了嵌入式系统设计的多个核心概念:状态机、数据结构、驱动封装、前后台系统、低功耗设计等。把它吃透,再去看那些复杂的GUI框架或者RTOS,你会觉得豁然开朗。我建议你在实现基础功能后,挑选一两个扩展方向深入尝试,过程中遇到的问题和解决方案,会成为你更宝贵的经验。
