【STM32 智能手表源码剖析 02】OLED 菜单系统 + 主页 UI + 时间设置|从 0 写通用菜单框架 + 定时器
上一篇我们从上帝视角拆完了整个工程的架构,这篇直接干最核心的菜单系统 + 通用定时器!
这两个东西是整个智能手表的灵魂,学会了不仅能看懂这个项目,你以后自己做任何带屏幕的单片机项目,都能直接套用这个通用框架。
一、先讲最基础也最重要的:通用定时器驱动
所有功能流畅运行的前提,就是这个1ms 通用定时器。网上 90% 的同类项目都是纯轮询,按键卡顿、秒表不准、游戏掉帧,根源就在这里
1. 为什么一定要用定时器?
纯轮询版本的致命问题:
// 垃圾纯轮询写法
while(1)
{
Key_Scan(); // 扫描按键
OLED_Update(); // 刷新屏幕
Delay_ms(10); // 延时10ms
}
纯轮询方式会极大程度上占用CPU,而定时器可以避免消耗CPU资源。
且按键响应延迟大,经常按了没反应
秒表计时误差极大,滑动菜单时秒表会变慢
传感器采样率不稳定,心率计算完全错误
游戏卡顿,跳跃手感稀烂
而选择了定时器的驱动架构完美解决了这些问题:不仅不用消耗CPU资源
所有需要精确计时的任务都放在 1ms 中断里执行
永远准时,不会被前台的界面绘制卡住
按键响应零延迟,秒表精度 1ms,游戏 60 帧流畅运行
2. 通用定时器配置(TIM2)
你的代码里用的是 TIM2 定时器,配置成 1ms 中断一次:
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_Period = 999; // 自动重装值
TIM_TimeBaseInitStructure.TIM_Prescaler = 71; // 预分频器
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
TIM_Cmd(TIM2, ENABLE);
}
计算公式:中断频率 = 72MHz / (71+1) / (999+1) = 1000Hz = 1ms
3. 1ms 中断里到底做了什么?
void TIM2_IRQHandler(void)
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) == SET)
{
MAX30102_Tick(); // 心率血氧定时采样
Key3_Tick(); // 按键3长按检测
Key_Tick(); // 按键扫描+消抖
StopWatch_Tick(); // 秒表计时
Dino_Tick(); // 小恐龙游戏帧更新
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
}
| 函数名 | 执行频率 | 核心功能 | 为什么放中断里? |
|---|---|---|---|
Key_Tick() |
1ms 一次 | 扫描 3 个按键,20ms 消抖 | 轮询消抖占 CPU,中断方式响应更快 |
Key3_Tick() |
1ms 一次 | 统计按键 3 按下时间,实现长按返回 | 长按检测需要精确计时 |
StopWatch_Tick() |
1ms 一次 | 秒表毫秒计数 | 纯轮询秒表会因为界面刷新变慢 |
MAX30102_Tick() |
1ms 一次 | 读取传感器 FIFO 数据 | 心率计算对采样率要求极高 |
Dino_Tick() |
1ms 一次 | 游戏帧更新 | 保证游戏速度恒定,不卡顿 |
二、主页 UI 设计(First_Page_Clock)
主页是用户开机看到的第一个界面,也是整个项目的入口。它的逻辑非常简单,但设计得非常巧妙。
1. 主页整体结构
int First_Page_Clock(void)
{
while(1) // 死循环,不按键就一直显示时钟
{
1. 读取按键
2. 处理按键1/2:切换菜单/设置选项
3. 处理按键3:返回选中值,跳转到对应页面
4. 处理按键4:打开手电筒
5. 显示时钟UI
6. 反显当前选中的选项
7. 刷新屏幕
}
}
// 全局标志位:标记时钟主页当前选中项
// clkflag = 1 :选中【菜单】
// clkflag = 2 :选中【设置】
int clkflag = 1;
/**
* @brief 时钟主页功能函数
* @retval 1 :跳转菜单页面
* 2 :跳转时间设置页面
* @note 硬件仅配备3个独立按键
*/
int First_Page_Clock(void)
{
// 死循环:常驻时钟主页,按下确认键才退出函数
while(1)
{
KeyNum = Key_GetNum(); // 读取按键扫描结果,获取当前按下的按键编号
// 按键1:向上切换选中项
if(KeyNum == 1)
{
clkflag--; // 选中标志位自减
if(clkflag <= 0) clkflag = 2; // 边界判断,循环切换至设置
}
// 按键2:向下切换选中项
else if(KeyNum == 2)
{
clkflag++; // 选中标志位自增
if(clkflag >= 3) clkflag = 1; // 边界判断,循环切换至菜单
}
// 按键3:确认选择,退出当前页面并返回标志位
else if(KeyNum == 3)
{
OLED_Clear(); // 清空OLED屏幕缓存
OLED_Update(); // 执行屏幕刷新
return clkflag; // 返回当前选中值,供主函数跳转页面
}
// 根据标志位,绘制界面并添加选中反显效果
switch(clkflag)
{
// 选中菜单:绘制主页,反显左下角菜单区域
case 1:
Show_Clock_UI(); // 绘制时钟基础UI(时间、电量等)
OLED_ReverseArea(0,48,32,16); // 局部反显,标识当前选中项
OLED_Update(); // 刷新屏幕显示
break;
// 选中设置:绘制主页,反显右下角设置区域
case 2:
Show_Clock_UI(); // 绘制时钟基础UI(时间、电量等)
OLED_ReverseArea(96,48,32,16); // 局部反显,标识当前选中项
OLED_Update(); // 刷新屏幕显示
break;
}
}
}
这是一个非常聪明的设计:不用重新绘制整个界面,只需要反显选中的区域,既省 CPU 又流畅。
2. 主页按键功能表(3 按键版)
| 按键 | 功能 |
|---|---|
| Key1 | 切换到上一个选项(菜单 ↔ 设置) |
| Key2 | 切换到下一个选项(菜单 ↔ 设置) |
| Key3 | 确认选择,返回当前选中值并跳转页面 |
三、OLED 滑动菜单系统核心
菜单系统是整个项目最复杂也最有技术含量的部分。它实现了平滑的滑动动画,并且可以无限扩展新功能。
1. 菜单数据结构设计
这是通用菜单框架的核心,用一个结构体数组来定义所有菜单项:
// 菜单标记:1~8 对应8个功能选项
uint8_t menu_flag = 1;
/**
* 函数功能:菜单界面主逻辑,按键切换、滑动动画、功能跳转
* 返 回 值:返回 0 表示退回主页面
* 硬件按键:3个(Key1上切换 / Key2下切换 / Key3确认进入)
*/
int Menu(void)
{
move_flag = 1; // 滑动动画标志
uint8_t DirectFlag = 2; // 方向标志:1=左滑 2=右滑
// 死循环:一直停留在菜单界面
while(1)
{
KeyNum = Key_GetNum(); // 获取当前按键值
uint8_t menu_flag_temp = 0; // 临时标记:保存确认选中的菜单号
// ================================
// 按键1:上一个菜单(向上切换)
// ================================
if(KeyNum == 1)
{
DirectFlag = 1; // 方向:左滑
move_flag = 1; // 启动滑动动画
menu_flag--; // 菜单号 -1
if(menu_flag <= 0) menu_flag = 8; // 边界循环:最小1
}
// ================================
// 按键2:下一个菜单(向下切换)
// ================================
else if(KeyNum == 2)
{
DirectFlag = 2; // 方向:右滑
move_flag = 1; // 启动滑动动画
menu_flag++; // 菜单号 +1
if(menu_flag >= 9) menu_flag = 1; // 边界循环:最大8
}
// ================================
// 按键3:确认选中当前菜单
// ================================
else if(KeyNum == 3)
{
OLED_Clear(); // 清空屏幕
OLED_Update(); // 刷新屏幕
menu_flag_temp = menu_flag; // 把当前菜单号给临时变量
}
// ================================
// 根据确认的菜单号,进入对应功能
// ================================
if(menu_flag_temp == 1) // 菜单1 → 返回主界面
{
return 0;
}
else if(menu_flag_temp == 2) // 菜单2 → 秒表
{
MenuToFunction();
StopWatch();
move_flag = 1;
}
else if(menu_flag_temp == 3) // 菜单3 → LED手电筒
{
LED();
move_flag = 1;
}
else if(menu_flag_temp == 4) // 菜单4 → 陀螺仪
{
MPU6050();
move_flag = 1;
}
else if(menu_flag_temp == 5) // 菜单5 → 游戏
{
Game();
move_flag = 1;
}
else if(menu_flag_temp == 6) // 菜单6 → 表情
{
Emoji();
move_flag = 1;
}
else if(menu_flag_temp == 7) // 菜单7 → 水平仪
{
Gradienter();
move_flag = 1;
}
else if(menu_flag_temp == 8) // 菜单8 → 心率血氧(你扩展的)
{
HeartRate();
move_flag = 1;
}
// ================================
// 执行菜单滑动动画 + UI显示
// ================================
if(menu_flag == 1)
{
if(DirectFlag == 1)
Set_Selection(move_flag, 1, 0); // 左滑到第1项
else if(DirectFlag == 2)
Set_Selection(move_flag, 0, 0); // 右滑到第1项
}
else
{
if(DirectFlag == 1)
Set_Selection(move_flag, menu_flag, menu_flag - 1); // 左滑动画
else if(DirectFlag == 2)
Set_Selection(move_flag, menu_flag - 2, menu_flag - 1); // 右滑动画
}
}
}
菜单函数整体逻辑
menu_flag = 1~8 代表 8 个功能
选项 Key1 / Key2 上下切换菜单,触发滑动动画
Key3 确认进入对应功能 menu_flag_temp 临时保存选中的菜单号,用于跳转功能 Set_Selection() 执行滑动动画 + 菜单 UI 刷新
3 按键功能表
| 按键 | 功能 |
|---|---|
| Key1 | 上一个菜单(左滑动画) |
| Key2 | 下一个菜单(右滑动画) |
| Key3 | 确认进入功能 / 返回主页 |
4. 滑动动画实现原理
/*----------------------------------滑动菜单界面-----------------------------------*/
// 上一个显示的菜单编号
uint8_t pre_selection;
// 要滑到的目标菜单编号
uint8_t target_selection;
// 图标当前的 X 坐标(48是屏幕正中间)
uint8_t x_pre = 48;
// 滑动速度:每帧移动4个像素
uint8_t Speed = 4;
// 滑动标志:1=正在滑 0=滑完了
uint8_t move_flag;
/**
* @brief 菜单滑动动画主函数(左右平滑滑动)
*/
void Menu_Animation(void)
{
OLED_Clear(); // 清空屏幕
OLED_ShowImage(42,10,44,44,Frame); // 画菜单的外框
// ==========================================
// 向左滑动:目标菜单在右边 → 图标整体左移
// ==========================================
if(pre_selection < target_selection)
{
x_pre -= Speed; // 图标X坐标减小 → 往左走
if(x_pre == 0) // 滑到最左边了
{
pre_selection++; // 菜单号+1
move_flag = 0; // 动画结束
x_pre = 48; // 回到中间位置
}
}
// ==========================================
// 向右滑动:目标菜单在左边 → 图标整体右移
// ==========================================
if(pre_selection > target_selection)
{
x_pre += Speed; // 图标X坐标增大 → 往右走
if(x_pre == 96) // 滑到最右边了
{
pre_selection--; // 菜单号-1
move_flag = 0; // 动画结束
x_pre = 48; // 回到中间位置
}
}
// ==========================================
// 一次性画一串图标 → 看起来像连续滑动
// ==========================================
if(pre_selection >= 1)
{
// 左边第1个图标
OLED_ShowImage(x_pre-48,16,32,32,Menu_Graph[pre_selection-1]);
}
if(pre_selection >= 2)
{
// 左边第2个图标
OLED_ShowImage(x_pre-96,16,32,32,Menu_Graph[pre_selection-2]);
}
// 当前正中间的图标
OLED_ShowImage(x_pre, 16,32,32,Menu_Graph[pre_selection]);
// 右边第1个图标
OLED_ShowImage(x_pre+48,16,32,32,Menu_Graph[pre_selection+1]);
// 右边第2个图标
OLED_ShowImage(x_pre+96,16,32,32,Menu_Graph[pre_selection+2]);
OLED_Update(); // 刷新屏幕 → 显示这一帧动画
}
改变 x_pre 坐标 → 图标左右移动
一次画一排图标 → 看起来连续滑动
Speed=4 → 滑动速度正常
5.设置菜单滑动的起始项、目标项,并启动滑动动画
// 设置菜单滑动的起始项、目标项,并启动滑动动画
void Set_Selection(uint8_t move_flag, uint8_t Pre_Selection, uint8_t Target_Selection)
{
// 如果允许滑动(move_flag=1)
if(move_flag==1)
{
pre_selection = Pre_Selection; // 保存当前菜单(起点)
target_selection = Target_Selection; // 保存目标菜单(终点)
}
Menu_Animation(); // 执行滑动动画
// 正确写法:Menu_Animation() 必须放在 if 外面,让程序一直刷新画面
}
6.进入功能前的过渡动画:菜单图标 向下滑动退出屏幕
// 进入功能前的过渡动画:菜单图标 向下滑动退出屏幕
void MenuToFunction()
{
// 循环 7 次,让图标一点点向下移动
for(uint8_t i=0;i<=6;i++)
{
if(pre_selection>=1)
{
OLED_Clear(); // 清空屏幕
// 显示左边图标,Y 坐标不断增加 → 向下移动
OLED_ShowImage(x_pre-48,16+8*i,32,32,Menu_Graph[pre_selection-1]);
}
// 显示当前选中的图标,Y 坐标不断增加 → 向下移动
OLED_ShowImage(x_pre, 16+8*i,32,32,Menu_Graph[pre_selection]);
// 显示右边图标
OLED_ShowImage(x_pre+48,16+8*i,32,32,Menu_Graph[pre_selection+1]);
OLED_Update(); // 刷新屏幕,形成动画
}
}
四. 页面跳转完整流程
| 操作 | 执行内容 |
|---|---|
| 主页按 Key3 | 进入菜单,显示第一个菜单项 |
| 按 Key1/2 | 切换菜单项,播放滑动动画 |
| 按 Key3 | 调用对应功能函数,进入功能页面 |
| 功能页面按 Key3 | 返回菜单 |
| 菜单按 Key3 | 返回主页 |
五、时间设置页面实现
时间设置页面是最典型的 "参数修改" 页面,学会了这个,你就能写任何类似的设置页面。
1. 时间设置逻辑
/*----------------------------------设置界面-------------------------------------*/
/**
* @brief 显示设置界面UI
* @param 无
* @retval 无
*/
void Show_SettingPage_UI(void)
{
OLED_ShowImage(0,0,16,16,Return); // 显示左上角返回图标
OLED_ShowString(0,16,"日期时间设置",OLED_8X16); // 显示标题
}
// 设置界面选择标志:1=返回 2=日期时间设置
int setflag = 1;
/**
* @brief 设置界面主函数:按键切换、选项反显、功能跳转
* @param 无
* @retval 0:返回上一级界面
*/
int SettingPage(void)
{
// 死循环:一直停留在设置界面
while(1)
{
KeyNum = Key_GetNum(); // 获取按键值
uint8_t setflag_temp = 0; // 临时标志,保存确认选中的选项
// ================================
// 按键1:上一个选项(循环切换)
// ================================
if(KeyNum == 1)
{
setflag--; // 选项编号-1
if(setflag <= 0) setflag = 2; // 边界循环
}
// ================================
// 按键2:下一个选项(循环切换)
// ================================
else if(KeyNum == 2)
{
setflag++; // 选项编号+1
if(setflag >= 3) setflag = 1; // 边界循环
}
// ================================
// 按键3:确认选中当前选项
// ================================
else if(KeyNum == 3)
{
OLED_Clear(); // 清空屏幕
OLED_Update(); // 刷新屏幕
setflag_temp = setflag; // 把当前选项给临时变量
}
// ================================
// 根据确认选项执行对应功能
// ================================
if(setflag_temp == 1) // 选中【返回】→ 退回上一级
{
return 0;
}
else if(setflag_temp == 2) // 选中【日期时间设置】→ 进入设置
{
SetTime();
}
// ================================
// 反显当前选中的选项
// ================================
switch(setflag)
{
// 选项1:返回,反显左上角图标区域
case 1:
Show_SettingPage_UI();
OLED_ReverseArea(0,0,16,16); // 反显返回图标
OLED_Update();
break;
// 选项2:日期时间设置,反显标题区域
case 2:
Show_SettingPage_UI();
OLED_ReverseArea(0,16,96,16); // 反显标题文字
OLED_Update();
break;
}
}
}
5.1 选项说明
setflag = 1 → 选中 返回
setflag = 2 → 选中 日期时间设置
六.菜单模板
1.不管做什么菜单,永远按这 4 步走:
- 定义选项标记(flag)
- 按键切换选项(Key1/Key2)
- 按键确认进入(Key3)
- 反显当前选中项
2.万能菜单模板(直接复制就能用)
// 1. 定义选项标志
int menu_flag = 1; // 1=选项1 2=选项2 3=选项3...
// 菜单主函数
void MyMenu(void)
{
while(1)
{
// 2. 读取按键
KeyNum = Key_GetNum();
// ==============================
// 3. 按键切换选项
// ==============================
if(KeyNum == 1) // 上一个
{
menu_flag--;
if(menu_flag < 1) menu_flag = 最大选项;
}
else if(KeyNum == 2) // 下一个
{
menu_flag++;
if(menu_flag > 最大选项) menu_flag = 1;
}
// ==============================
// 4. 按键确认进入
// ==============================
else if(KeyNum == 3)
{
if(menu_flag == 1) 功能1();
if(menu_flag == 2) 功能2();
if(menu_flag == 3) 功能3();
}
// ==============================
// 5. 反显当前选中项
// ==============================
switch(menu_flag)
{
case 1:
显示UI();
反显选项1区域();
break;
case 2:
显示UI();
反显选项2区域();
break;
}
OLED_Update();
}
}
七、为啥整个项目要用定时器来驱动各类任务?
最开始写代码时,我习惯直接用Delay函数做延时、控节奏,结果发现问题特别明显。一旦代码里加了阻塞延时,单片机 CPU 就会卡在原地不动,这段时间既不会去扫描按键,界面动画也会停住,菜单操作起来卡顿严重,秒表计时也会不准。
后来改用定时器就解决了这个问题。我们这套代码用的是典型的前后台运行思路:主函数里的while(1)循环作为前台,专门负责界面刷新、菜单逻辑判断、功能跳转这些工作;定时器中断相当于后台,硬件独立运行,不用 CPU 持续干预。像按键扫描、精准计时这类对实时性要求高的任务,全都放在中断里执行。
两者分开运行互不干扰,不管前台菜单怎么滑动、页面怎么切换,后台的计时和按键检测都能稳定跑起来,这也是嵌入式裸机项目里很常用、也很稳妥的写法。
八.1ms 定时器中断里具体做了哪些工作?
我们配置的 TIM2 定时器,每隔 1 毫秒就会触发一次中断,相当于整个程序的 “心跳节拍器”,每次进入中断服务函数,固定执行几项核心操作。
首先是按键相关处理,包括周期性扫描按键引脚电平,完成软件消抖;其次是长按计时统计,为设置界面长按返回的功能提供时间依据;最后还有秒表的毫秒计数累加,保证计时功能正常。
这些任务都要求时间精度高,不适合放在主循环里受其他代码影响,所以统一放在 1ms 中断中循环执行。每次执行完对应逻辑,清除中断标志位就退出,不会占用过多资源。
九、按键消抖、长按检测的实现逻辑
1. 按键消抖
我们用的是机械按键,按下或者松开的瞬间,电平会出现 10~20ms 的不规则抖动。如果只读取一次电平,很容易把一次按键误识别成多次触发。
借助 1ms 定时器就能轻松解决:每毫秒读取一次按键状态,只有连续多次检测到电平保持稳定,才判定为一次有效的按键动作。这种软件消抖方式不用额外加硬件电路,成本低、改动灵活,也是单片机项目里最主流的做法。
2. 长按检测
原理其实很简单。程序里定义了一个计时变量,当检测到按键处于按下状态时,这个变量每过 1 毫秒就自增 1。我们设定阈值为 1000,当计数值超过 1000,就代表按键按住时长达到 1 秒,此时判定为长按动作。
项目里时间设置界面、系统设置界面的 “长按保存并返回” 功能,就是依靠这套计时逻辑实现的。
十、秒表为什么能保证 1ms 的计时精度?
秒表的计数值完全是在定时器中断内部累加的。定时器属于单片机硬件外设,计数、触发中断的节奏由硬件时钟决定,精度很高,不会因为前台代码运行快慢出现偏差。
哪怕此时前台正在切换菜单、刷新屏幕,后台定时器依旧会严格按照 1ms 的间隔触发中断,给秒表变量加一。全程不需要主循环参与计时,自然不会出现计时变慢、跳数错乱的情况,轻松实现 1ms 级别的精准计时。如果把计时逻辑放在主循环里,界面一卡顿,秒表误差就会特别明显。
十一、为什么 clkflag=1 进入菜单,clkflag=2 进入设置界面?
这并不是变量本身有特殊功能,而是主函数提前写好了页面跳转的判断逻辑。
程序开机后首先运行时钟主页函数,主页执行结束后会返回一个clkflag标志值。主函数的死循环会持续判断这个返回值:当clkflag等于 1 时,就调用菜单相关函数,跳转到菜单页面;当clkflag等于 2 时,则调用设置函数,进入系统设置界面。
主页默认返回 1,所以设备上电后,按确认键就会优先进入菜单。简单来说,这个标志位就是页面切换的 “指令信号”,配合分支判断,实现不同界面之间的跳转。
谢谢大家的观看呀,如果觉得有用就点个赞吧谢谢!!!
更多推荐



所有评论(0)