一、 架构的全面蜕变:从轮询死等,到优雅的事件驱动
裸机开发中通常使用 while(1) 轮询 + HAL_Delay。但在引入 FreeRTOS 和极低功耗(Tickless/STOP 模式)后,旧架构面临着极度浪费 CPU、打断休眠、动画卡顿冲突的致命问题。为此,我进行了底层大换血:
-
单任务极简调度: 整个系统只创建了一个核心的
GUI_Task,专门负责按键事件处理与 UI 刷新。在没有按键操作的普通状态下,任务会优雅地挂起,仅保持 1 秒 1 次的低频刷新。 -
按键机制重构(告别死循环): 彻底废弃了裸机时代的
HAL_Delay延时消抖。引入了 20ms 软件定时器,实现了零 CPU 占用的完美消抖,并在其中集成了单击、双击、长按的复杂状态机逻辑。 -
通信机制升级: 抛弃了丑陋的全局标志位,按键事件全部打包,通过 FreeRTOS 的 消息队列(Message Queue) 发送给主任务。
-
低功耗无缝衔接: 将 STM32 的 60 秒无操作进入 STOP 模式的逻辑,也交给了单次软件定时器,并完美融合了 FreeRTOS 的 Tickless Idle 低功耗模式。
-
硬件解耦: 将定时器 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 对总线电平噪声极度敏感,偶发干扰会导致系统进入死锁。
💀 灾难的发生机理(死锁是怎么形成的)
-
当 I2C 正在发送数据时,总线受外部干扰(静电/电容变化),SDA 出现意外毛刺。
-
STM32 瞬间捕捉到时序违规,触发 BERR(总线错误)。
-
STM32 立刻急刹车:停止输出 SCL 时钟,中止 DMA,进入 ErrorCallback 释放信号量。
-
从机的悲剧: OLED 并没有报错机制,它卡在了当前接收的某一个 bit。如果它恰好准备发送 ACK,它会死死拉低 SDA 线,苦等主机的第 9 个时钟脉冲。
-
永久死锁: 主机罢工不给时钟,从机拉低 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 失忆症
为了实现超长续航,我设计了三级功耗防护网:
-
微观休眠(Tickless Idle 模式): 一般状态下 1 秒 1 刷。在
GUI_Task中,获取消息队列的阻塞时间被设为pdMS_TO_TICKS(1000)。1 秒钟内系统自动进入 Sleep 模式,醒来几十毫秒刷屏幕,实现极致抠细节省电。 -
中观休眠(60s STOP 模式): 利用 60 秒单次软件定时器,超时进入保留 SRAM 的 STOP 模式。**【业务冲突防范】**在回调中加入"秒表白名单",防止秒表跑到一半黑屏。
void LowTimer_Callback(TimerHandle_t xTimer)
{
// 如果秒表正在运行,或者准备进双击待机了,绝对不能进入普通的 60s 休眠!
if (stopwatch_flag == 0 && switch_flag == 0) {
low_flag = 1; // 允许在大循环进入 STOP 模式
}
} -
宏观休眠(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 ...
}
// ...
}