从裸机到 FreeRTOS:STM32 智能手表重构之路

一、 架构的全面蜕变:从轮询死等,到优雅的事件驱动

裸机开发中通常使用 while(1) 轮询 + HAL_Delay。但在引入 FreeRTOS 和极低功耗(Tickless/STOP 模式)后,旧架构面临着极度浪费 CPU、打断休眠、动画卡顿冲突的致命问题。为此,我进行了底层大换血:

  1. 单任务极简调度: 整个系统只创建了一个核心的 GUI_Task,专门负责按键事件处理与 UI 刷新。在没有按键操作的普通状态下,任务会优雅地挂起,仅保持 1 秒 1 次的低频刷新。

  2. 按键机制重构(告别死循环): 彻底废弃了裸机时代的 HAL_Delay 延时消抖。引入了 20ms 软件定时器,实现了零 CPU 占用的完美消抖,并在其中集成了单击、双击、长按的复杂状态机逻辑。

  3. 通信机制升级: 抛弃了丑陋的全局标志位,按键事件全部打包,通过 FreeRTOS 的 消息队列(Message Queue) 发送给主任务。

  4. 低功耗无缝衔接: 将 STM32 的 60 秒无操作进入 STOP 模式的逻辑,也交给了单次软件定时器,并完美融合了 FreeRTOS 的 Tickless Idle 低功耗模式。

  5. 硬件解耦: 将定时器 2 专门封装进秒表功能中,专车专用。

踩坑与修复:界面切换迟钝(状态机切换延迟与阻塞)

  • 问题现象: 按下按键切换页面时,UI 响应有明显的迟钝感(约 1 秒延迟)。

  • 根本原因: GUI_Task 任务中的状态切换机制存在执行周期的错位。由于界面跳转原本采用 if-else 结构,导致 NextMode 改变后,需要经历两次任务循环才能真正进入新页面的 Loop(第一遍执行 CurrMode = NextMode 的赋值,第二遍才执行真正的 Loop)。这多出来的一次循环,会触发 xQueueReceive 的 1 秒超时死等,从而造成了视觉上的严重卡顿。

  • 解决方案: 调整 GUI_Task 的逻辑执行顺序,把 else 改为独立的 if (CurrMode != NextMode)。一旦检测到模式改变,立刻在当前任务周期内完成 Exit 和 Init,避免多余的阻塞等待。

    // 【优化后的核心任务调度:无缝切换与动态阻塞】
    void GUI_Task(void *pvParameters)
    {
    GLOBAL_init(CurrMode);
    while (1)
    {
    TickType_t wait_time;

    复制代码
          // 动态计算阻塞时间:避免动画锁被吃掉
          if (move_state == 1) {
              vTaskDelay(pdMS_TO_TICKS(20)); // 动画时不去队列拿消息
          } else {
              if (stopwatch_flag == 1) wait_time = pdMS_TO_TICKS(20); // 秒表高频刷新
              else wait_time = pdMS_TO_TICKS(1000);                   // 平时1秒刷新,配合Tickless
              
              if (xQueueReceive(Queue_Key, &Current_Key_Msg, wait_time) != pdTRUE) {
                  Current_Key_Msg = 0; // 超时未收到消息,清零
              }
          }
    
          LOW_Loop();     // 检测是否需要休眠
          switch_Loop();
    
          // 【解决切换卡顿】:拆分 if-else,保证在同一周期内完成新界面的重绘!
          if (CurrMode == NextMode) {
              GLOBAL_Loop(CurrMode); 
          }
          if (CurrMode != NextMode) {  // 立刻检测并执行跳转
              GLOBAL_Exit(CurrMode); 
              GLOBAL_init(NextMode); 
              CurrMode = NextMode;   
          }
      }

    }


二、 按键底层的涅槃与"幽灵按键"防爆指南

为了推翻底层轮询,我设计了 "外部中断 (EXTI) + 20ms 单次软件定时器 + 状态机 + 消息队列" 的新架构。

1. 天然防抖的"中断触发 + 定时器锁"

当手指按下机械按键时,会产生十几次高频电平抖动。如果不加限制,软件定时器会被疯狂重置导致按键丢失。为此我引入了 key_timer_active 防抖锁。第一下真实的下降沿触发并锁死系统,启动 20ms 定时器。后续的物理抖动全被挡在门外,实现了纯天然绝对防抖!

复制代码
volatile uint8_t key_timer_active = 0; 

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == KEY1_PIN || GPIO_Pin == KEY2_PIN || GPIO_Pin == KEY3_PIN || GPIO_Pin == GPIO_PIN_0)
    {
        // 只有在锁解开(定时器闲置)时,才允许启动定时器!
        if (key_timer_active == 0)
        {
            key_timer_active = 1; // 瞬间上锁,屏蔽后续物理抖动!
            BaseType_t xHigherPriorityTaskWoken = pdFALSE;
            xTimerStartFromISR(KeyTimer_Handle, &xHigherPriorityTaskWoken);
            portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
        }
    }
}

2. 用完即毁,保卫 Tickless 休眠

绝不让定时器在后台空跑!当所有按键彻底松开(all_keys_idle == 1),立刻关闭定时器并解锁。这样 CPU 才能毫无顾忌地进入深度休眠。

复制代码
    // 在软件定时器回调的末尾:
    if (all_keys_idle == 1)
    {
        key_timer_active = 0;   // 解开防抖锁
        xTimerStop(xTimer, 0);  // 停掉软件定时器!
    }

3. 去延时化状态机与 16 位消息打包

为了降低 RTOS 队列开销,我将按键信息打包成 16 位整型(高 8 位存序号,低 8 位存动作:如 KEY_DOWN, KEY_DOUBLE)。 【技术高光:降维打击】 如果手速极快连按非休眠键,底层容易判定为双击。为了防止该事件被 UI 丢弃,我强行将它们降级为 KEY_DOWN 发送,保证了极速响应!

复制代码
        if (i != 3 && (msg & 0xFF) == KEY_DOUBLE) {
            msg = i << 8 | KEY_DOWN; // 强行降维成单击按下,防止"手速太快导致吃键"!
        }

踩坑与修复:幽灵按键(消息队列溢出)

  • 问题现象: 按键出现不受控的连续触发。

  • 根本原因: 按键状态机设计缺陷。针对简单按键,旧状态机会在一次完整的物理按压中,连续触发并发送"一直按住"、"按下瞬间"、"松开瞬间"等多条状态消息。每按一次键会向队列塞入 4 条消息,导致容量为 30 的消息队列只需按几下就被瞬间撑爆。

  • 解决方案: 删减冗余触发,只保留双击、单击、长按等操作,且只在动作发生时发送一次

    // 【优化后的状态机代码:精准过滤,只发一帧】
    void KeyTimer_Callback(TimerHandle_t xTimer)
    {
    // ... 前置数据与倒计时代码 ...
    for (int i = 0; i < KEY_COUNT; i++)
    {
    PrevState[i] = CurrState[i];
    CurrState[i] = KEY_State(i);
    uint16_t msg = 0;

    复制代码
          switch (S[i])
          {
              // ... case 0, 1 省略 ...
              case 2:
                  if (CurrState[i] == KEY_OK)      { msg = i<<8 | KEY_DOUBLE; S[i] = 3; }
                  else if (NUM[i] == 0)            { msg = i<<8 | KEY_SINGLE; S[i] = 0; }
                  break;
              // ... case 3 省略 ...
              case 4:
                  if (CurrState[i] == KEY_NO)      { msg = i<<8 | KEY_LONG; S[i] = 0; }
                  else if (NUM[i] == 0)            { msg = i<<8 | KEY_LONG_LONG; S[i] = 5; }
                  break;
              case 5:
                  if (CurrState[i] == KEY_NO)      { S[i] = 0; }
                  break;
          }
    
          // 关键逻辑:只有当 msg 被赋了有效值,才发送一次,发完清零!
          if(msg != 0) {
              xQueueSend(Queue_Key, &msg, 0); 
              msg = 0; 
          }
      }

    }


三、 OLED 硬件 I2C+DMA 极限排雷:防花屏与工业级自愈协议

裸机下的软件 I2C 极度占用 CPU。我将 OLED 驱动全面升级为:硬件 I2C + DMA 异步搬运 + 二值信号量同步 。并在 OLED_Init 中修改了原有的页寻址模式,配置为水平寻址 ,使 OLED_Update 可以直接触发 DMA 1024 字节全屏一波流刷新。

但硬件 I2C 对总线电平噪声极度敏感,偶发干扰会导致系统进入死锁。

💀 灾难的发生机理(死锁是怎么形成的)

  1. 当 I2C 正在发送数据时,总线受外部干扰(静电/电容变化),SDA 出现意外毛刺。

  2. STM32 瞬间捕捉到时序违规,触发 BERR(总线错误)。

  3. STM32 立刻急刹车:停止输出 SCL 时钟,中止 DMA,进入 ErrorCallback 释放信号量。

  4. 从机的悲剧: OLED 并没有报错机制,它卡在了当前接收的某一个 bit。如果它恰好准备发送 ACK,它会死死拉低 SDA 线,苦等主机的第 9 个时钟脉冲。

  5. 永久死锁: 主机罢工不给时钟,从机拉低 SDA 不松手。互相死等。

🛠️ 终极抢救流程:五道极限防线与 9 脉冲解锁法

我为 OLED_Update 设计了严密的严格弃帧机制总线疏通流程

防线一与防花屏:DMA与I2C的"时间差"陷阱与严格校验
  • 现象描述: OLED 画面偶尔出现撕裂,光标跑到屏幕顶部。

  • 原因: 发送"设置坐标"的指令受干扰丢失,OLED 绘图指针未重置,后续 DMA 数据导致显存溢出环绕。其次,DMA 传输结束不代表 I2C 物理层发完,若立即进入低功耗切断时钟,会导致最后的 bit 死在总线上。

  • 解决方案: 发送前严格轮询状态;寻址指令打包发送;任何一步失败立刻弃帧(绝不让残缺指令流向 OLED)。

    // 【解决代码:防撕裂严谨版 OLED_Update】
    void OLED_Update(void)
    {
    uint32_t timeout = 500000;
    // 第一步:起跑前检查。如果上一次传输还处于异常或忙碌,直接复位并退出(弃帧)
    while (HAL_I2C_GetState(&hi2c1) != HAL_I2C_STATE_READY) {
    timeout--;
    if (timeout == 0) { OLED_Reset(); return; }
    }

    复制代码
      // 第二步:寻址指令打包与严格校验。失败立刻复位并退出(弃帧)
      uint8_t cmd[6] = {0x21, 0x00, 0x7F, 0x22, 0x00, 0x07};
      if (HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, cmd, 6, 100) != HAL_OK) {
          OLED_Reset(); return;
      }
    
      // 第三步:安全触发 DMA。只有起跑前检查和坐标指令100%成功才发显存
      if (HAL_I2C_Mem_Write_DMA(&hi2c1, 0x78, 0x40, I2C_MEMADD_SIZE_8BIT, (uint8_t *)OLED_DisplayBuf, 1024) == HAL_OK)
      {
          // 防线四:排空"历史毒药"信号量
          xSemaphoreTake(OLEDSemaphore, 0); 
          // 防线五:100ms 超时强制打断,告别死机
          if (xSemaphoreTake(OLEDSemaphore, pdMS_TO_TICKS(100)) != pdTRUE) {
              OLED_Reset(); 
          }
      } else {
          OLED_Reset(); 
      }

    }

防重入锁、9 脉冲与"幽灵消息"(OLED_Reset 的艺术)

当检测到发送超时或出错,立刻进入 OLED_Reset() 拯救程序。

  • Step 1: 防御性拦截(防栈溢出): 引入 is_resetting 标志,防止复位出错导致无限递归爆栈。

  • Step 2: 降维夺权: DeInit 关闭 I2C,转为 GPIO 开漏输出。

  • Step 3: 智能 9 脉冲: 循环最多 9 次发送时钟。关键判断:每次发脉冲前读取 SDA,一旦外部上拉电阻拉高 SDA,说明 OLED 已松手,立刻 break,绝不多发!

  • Step 4: 强制失忆: 手动制造 I2C STOP 信号,清零 OLED 硬件状态机。

  • Step 5: 幽灵消息唤醒: 【避免 1 秒黑屏】抢救完后,底层驱动绝不越权调用 UI 函数。而是向消息队列发送一个 0(幽灵消息),瞬间惊醒阻塞死等的 GUI_Task 渲染全屏。做到零感恢复

    // 【解决代码:智能 9 脉冲与幽灵唤醒机制】
    void OLED_Reset(void)
    {
    // Step 1: 防重入,斩断套娃
    static uint8_t is_resetting = 0;
    if (is_resetting == 1) return;
    is_resetting = 1;

    复制代码
      // Step 2: 降维夺权
      HAL_I2C_DeInit(&hi2c1);
      GPIO_InitTypeDef GPIO_InitStruct = {0};
      GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
      GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
      GPIO_InitStruct.Pull = GPIO_PULLUP;
      GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
      HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
    
      // Step 3: 智能转动齿轮(见好就收的 9 脉冲)
      HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6 | GPIO_PIN_7, GPIO_PIN_SET);
      HAL_Delay(1);
      for (int i = 0; i < 9; i++) {
          if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7) == GPIO_PIN_SET) break; // OLED已松手
    
          HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1);
          HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);   HAL_Delay(1);
      }
    
      // Step 4: 强制失忆(制造物理 STOP)
      HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); vTaskDelay(pdMS_TO_TICKS(1));
      HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); vTaskDelay(pdMS_TO_TICKS(1));
      HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);   vTaskDelay(pdMS_TO_TICKS(1));
      HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET);   vTaskDelay(pdMS_TO_TICKS(1));
    
      // Step 5: 硬件复工
      HAL_I2C_Init(&hi2c1);
      OLED_Init();
    
      // 【神级解法】:发幽灵消息,打破 1 秒死等!
      extern QueueHandle_t Queue_Key;
      uint16_t ghost = 0;
      xQueueSend(Queue_Key, &ghost, 0);
    
      is_resetting = 0;

    }


四、 系统级低功耗:Tickless、STOP、STANDBY 与 RTC 失忆症

为了实现超长续航,我设计了三级功耗防护网:

  1. 微观休眠(Tickless Idle 模式): 一般状态下 1 秒 1 刷。在 GUI_Task 中,获取消息队列的阻塞时间被设为 pdMS_TO_TICKS(1000)。1 秒钟内系统自动进入 Sleep 模式,醒来几十毫秒刷屏幕,实现极致抠细节省电。

  2. 中观休眠(60s STOP 模式): 利用 60 秒单次软件定时器,超时进入保留 SRAM 的 STOP 模式。**【业务冲突防范】**在回调中加入"秒表白名单",防止秒表跑到一半黑屏。

    void LowTimer_Callback(TimerHandle_t xTimer)
    {
    // 如果秒表正在运行,或者准备进双击待机了,绝对不能进入普通的 60s 休眠!
    if (stopwatch_flag == 0 && switch_flag == 0) {
    low_flag = 1; // 允许在大循环进入 STOP 模式
    }
    }

  3. 宏观休眠(STANDBY 模式): 双击按键进入最深度的待机模式。

💀 治好 STANDBY 待机的"失忆症"(RTC 后备寄存器)

  • 痛点: STANDBY 唤醒等同于按下 Reset 键。代码会重新执行 MX_RTC_Init(),把顽强走秒的 RTC 硬件残忍重置为 12:00:00。

  • 神级解法: 利用单片机不断电的**后备寄存器(BKP)**机制。开机先读取寄存器(如 RTC_BKP_DR1),如果是冷启动则初始化并写入密码(如 0x32F2);如果是从 STANDBY 热启动唤醒,读到密码正确,果断跳过时间初始化,完美保留当前时间!

    复制代码
      // 伪代码演示 RTC 初始化中的密码校验逻辑
      if (HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1) != 0x32F2) 
      {
          // 暗号不对:说明是刚上电冷启动。初始化时间为 12:00
          sTime.Hours = 12; sTime.Minutes = 0; sTime.Seconds = 0;
          HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN);
          
          // 初始化完成后,立刻写入暗号密码!
          HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, 0x32F2); 
      }
      else
      {
          // 暗号正确:说明是从 STANDBY 唤醒,跳过 SetTime,完美保留当前时间!
      }

踩坑与修复:RTC 越界崩溃(无符号整型溢出)

  • 问题现象: 在设置界面调整时间或日期时,系统偶发性崩溃或死机。

  • 根本原因: STM32 的 RTC 底层时间单位均为 uint8_t(无符号 8 位整型)。在调节时缺乏边缘检测,当处于临界点(如 0 分钟减 1)时,直接进行运算会导致变量下溢变成 255。非法数据写入 RTC 硬件直接导致系统崩溃。

  • 解决方案: 在 UI 设置中增加严格的边界检测与数值循环回滚机制。

    // 【解决代码:SetTime.c 中的无符号边界保护】
    void SetMin_Loop(void)
    {
    if (KEY_Check(0, KEY_DOWN)) // 执行减操作
    {
    if (TIM.Minutes == 0) // 【核心修复】:临界点拦截,防止 0 - 1 = 255
    {
    TIM.Minutes = 59;
    if (TIM.Hours == 0) { TIM.Hours = 23; }
    else { TIM.Hours--; }
    }
    else
    {
    TIM.Minutes--;
    }
    TIM.Seconds = 0;
    HAL_RTC_SetTime(&hrtc, &TIM, RTC_FORMAT_BIN);
    // ... 更新 UI ...
    }
    // ...
    }

相关推荐
金戈鐡馬15 小时前
BetaFlight中的定时器引脚绑定详解
stm32·单片机·嵌入式硬件·无人机
Wave84516 小时前
FreeRTOS软件定时器详解
stm32·单片机·freertos
charlie11451419120 小时前
嵌入式现代C++工程实践——第10篇:HAL_GPIO_Init —— 把引脚配置告诉芯片的仪式
开发语言·c++·stm32·单片机·c
蒙奇·D·路飞-21 小时前
大模型时代下 Java 后端开发的技术重构与工程实践
java·开发语言·重构
AzusaFighting21 小时前
STM32F103R HAL CAN 通信实战 with Copilot
stm32·单片机·嵌入式硬件
himobrinehacken1 天前
Windows调试技巧:从Hello到I Love C++
stm32·单片机·嵌入式硬件
狂奔蜗牛(bradley)1 天前
使用数组重构责任链实现通信协议解析
网络·mcu·重构
M ? A1 天前
VuReact 编译器核心重构:统一管理组件元数据收集
前端·javascript·vue.js·react.js·重构·开源
V搜xhliang02461 天前
多期CT影像组学融合临床危险因素模型预测甲状腺乳头状癌中央区淋巴结转移的价值
人工智能·重构·机器人