上一篇我们从上帝视角拆完了整个工程的架构,这篇直接干最核心的菜单系统 + 通用定时器!
这两个东西是整个智能手表的灵魂,学会了不仅能看懂这个项目,你以后自己做任何带屏幕的单片机项目,都能直接套用这个通用框架。

一、先讲最基础也最重要的:通用定时器驱动
所有功能流畅运行的前提,就是这个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 步走:

  1. 定义选项标记(flag)
  2. 按键切换选项(Key1/Key2)
  3. 按键确认进入(Key3)
  4. 反显当前选中项

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,所以设备上电后,按确认键就会优先进入菜单。简单来说,这个标志位就是页面切换的 “指令信号”,配合分支判断,实现不同界面之间的跳转。

谢谢大家的观看呀,如果觉得有用就点个赞吧谢谢!!!

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐