STM32按键去抖防竞争方案

在 FreeRTOS 实时操作系统中运行 STM32 按键去抖动逻辑时,由于按键状态通常由中断(如 GPIO 外部中断或定时器中断)更新,而业务逻辑在任务中读取和处理这些状态,因此极易产生临界区竞争问题。如果不对共享变量(如按键状态标志、计数值)进行保护,可能导致读取到的数据不完整(撕裂读)或逻辑状态错乱。

以下是避免临界区竞争的详细步骤、代码实现及原理解析。


一、 问题根源:为何产生竞争?

在 FreeRTOS 中,竞争通常发生在以下场景:

  1. 中断服务程序(ISR)写入 :按键触发外部中断或定时器中断,在中断中修改全局变量(例如 key_state = 1 或 counter++)。
  2. 任务读取 :主控任务在 while(1) 循环中读取该全局变量进行判断。

如果任务的读取操作被中断打断,且该中断正好修改了该变量,就会出现数据不一致。例如,一个 16 位的整型变量在 8 位 MCU 上需要两次指令才能读写,若在两次指令之间发生中断,读取到的值就是"旧的一半"和"新的一半"拼凑成的错误数据。虽然 STM32 是 32 位 MCU,对单次 32 位访问通常是原子的,但编译器优化、多字节变量(如 struct)以及逻辑判断的非原子性("读-改-写")仍需保护 。


二、 方法一:进入临界区(任务级保护)

最直接的方法是利用 FreeRTOS 的任务调度器锁机制。当任务需要访问按键共享变量时,暂时关闭调度器或屏蔽中断,确保访问过程不被打断。

实现步骤

  1. 识别共享资源 :确定在任务和 ISR 中都会访问的变量(如 u8 Key_Flag)。
  2. 进入临界区 :在读取变量前调用 taskENTER_CRITICAL()。
  3. 访问变量:执行读取或逻辑判断。
  4. 退出临界区 :操作完成后立即调用 taskEXIT_CRITICAL()。

代码示例

c 复制代码
#include "FreeRTOS.h"
#include "task.h"

// 定义共享变量:按键按下标志
volatile uint8_t g_key_press_flag = 0;

// 按键扫描任务
void vKeyTask(void *pvParameters) {
    while (1) {
        // --- 进入临界区开始 ---
        taskENTER_CRITICAL(); // 挂起调度器,屏蔽中断 
        
        if (g_key_press_flag == 1) {
            g_key_press_flag = 0; // 清除标志
            
            // --- 退出临界区 ---
            taskEXIT_CRITICAL();  // 恢复调度器和中断
            
            // 执行按键处理逻辑(耗时操作放在临界区外)
            // Handle_Key_Event();
        } else {
            taskEXIT_CRITICAL(); // 如果没按下也要退出
        }
        
        vTaskDelay(pdMS_TO_TICKS(20)); // 任务延时
    }
}

// 模拟中断服务函数(例如在定时器中断中置位标志)
void TIMx_IRQHandler(void) {
    // 清除中断标志...
    
    // 在 ISR 中修改共享变量
    // 注意:在 ISR 中通常使用 taskENTER_CRITICAL_FROM_ISR(),但这里演示任务端保护
    g_key_press_flag = 1; 
}

原理解析

  • taskENTER_CRITICAL() 会将中断优先级阈值提升到 configMAX_SYSCALL_INTERRUPT_PRIORITY,屏蔽掉该优先级及以下的中断。
  • 优点:实现简单,能彻底防止竞争。
  • 缺点 :严重 impacting 系统实时性 。临界区内的代码不能阻塞,且必须非常短。如果在临界区内调用 vTaskDelay 或进行复杂计算,会导致系统死锁或高优先级中断无法响应。

三、 方法二:使用任务通知(推荐,零拷贝)

FreeRTOS 的任务通知是一种比信号量更轻量、更快的同步机制。它直接利用任务控制块(TCB)中的变量,无需额外的队列结构,非常适合"按键中断 -> 唤醒处理任务"这种一对一的同步场景。它天然避免了全局变量的竞争问题 。

实现步骤

  1. 创建处理任务:任务创建后进入阻塞状态,等待通知。
  2. 发送通知(ISR中) :按键中断触发后,使用 xTaskNotifyFromISR() 直接向任务发送通知。
  3. 接收通知(任务中) :任务使用 xTaskNotifyWait() 等待。如果没有通知,任务自动挂起,不占用 CPU。

代码示例

c 复制代码
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

// 任务句柄
TaskHandle_t xKeyTaskHandle = NULL;

// 按键处理任务
void vKeyHandlerTask(void *pvParameters) {
    uint32_t ulNotifyValue;
    
    while (1) {
        // 等待任务通知,阻塞在这里,不消耗 CPU
        // ulTaskNotifyTake 会清除通知值,返回成功接收到的通知数
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        
        // 收到通知说明按键稳定按下(假设中断侧已做硬件滤波或简单延时)
        // 直接执行业务逻辑,无需访问共享全局变量,完全避免竞争
        // Handle_Key_Event(); 
        
        // 简单的松手检测(可选)
        // while (HAL_GPIO_ReadPin(...) == GPIO_PIN_RESET);
    }
}

// 外部中断回调函数(如 EXTI)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    if (GPIO_Pin == KEY_PIN) {
        // 直接发送通知给处理任务,不操作全局变量 
        // eNoAction 表示不增加计数,只是通知一下
        vTaskNotifyGiveFromISR(xKeyTaskHandle, &xHigherPriorityTaskWoken);
        
        // 如果唤醒的任务优先级高于当前被中断的任务,需要进行上下文切换
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}

原理解析

  • 零拷贝:数据不经过全局变量,直接"点对点"传递。
  • 无竞争:因为不再有共享资源,所以根本不需要临界区保护。
  • 高效:任务通知的速度比二值信号量更快,RAM 占用更少 。

四、 方法三:使用二值信号量(经典同步)

如果不想用任务通知,或者需要"生产者-消费者"模型(例如按键抖动逻辑在独立任务中,业务逻辑在另一个任务中),可以使用二值信号量。

实现步骤

  1. 创建信号量 :使用 xSemaphoreCreateBinary() 创建一个空的二值信号量。
  2. ISR 中释放信号量 :按键发生时,调用 xSemaphoreGiveFromISR()。
  3. 任务中获取信号量 :任务调用 xSemaphoreTake()。如果信号量无效,任务阻塞;有效则执行逻辑。

代码示例

c 复制代码
#include "semphr.h"

SemaphoreHandle_t xKeySemaphore;

// 初始化函数中
void App_Init(void) {
    xKeySemaphore = xSemaphoreCreateBinary();
    // 创建任务...
}

// 任务
void vKeyTask(void *pvParameters) {
    while (1) {
        // 尝试获取信号量,死等
        if (xSemaphoreTake(xKeySemaphore, portMAX_DELAY) == pdTRUE) {
            // 获取成功,说明按键中断触发了
            // Handle_Key_Event();
        }
    }
}

// 中断服务程序
void EXTI0_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    // 清除中断线标志...
    
    // 给出信号量
    xSemaphoreGiveFromISR(xKeySemaphore, &xHigherPriorityTaskWoken);
    
    // 如果必要,请求上下文切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

原理解析

  • 二值信号量本质上就像一个长度为 1 的队列。中断"给"一个令牌,任务"取"一个令牌。
  • 同步机制:它保证了中断和任务之间的同步,且 FreeRTOS 内部已经实现了信号量的原子操作,因此是线程安全的 。

五、 方法四:中断专用临界区(ISR 内部保护)

如果在 ISR 内部需要修改多字节变量(如结构体),或者 ISR 之间共享变量,必须使用 ISR 专用的临界区保护,而不是任务级的 taskENTER_CRITICAL。

代码示例

c 复制代码
// 定义一个结构体存储按键信息
typedef struct {
    uint8_t state;
    uint32_t timestamp;
} KeyInfo_t;

volatile KeyInfo_t g_key_info;

void TIMx_IRQHandler(void) {
    // 进入临界区(ISR 版本)
    UBaseType_t uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();
    
    // 安全地修改多字节共享变量
    g_key_info.state = 1;
    g_key_info.timestamp = xTaskGetTickCountFromISR();
    
    // 退出临界区
    taskEXIT_CRITICAL_FROM_ISR(uxSavedInterruptStatus);
    
    // 清除中断标志...
}

原理解析

  • taskENTER_CRITICAL_FROM_ISR() 只会屏蔽掉优先级低于或等于 configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断,不会屏蔽更高优先级的中断(如紧急故障处理),这比任务级的临界区更灵活安全 。

六、 总结与方案选型

方案 核心机制 优点 缺点 推荐场景
临界区保护 关闭调度/屏蔽中断 代码简单粗暴,适合极短操作 影响实时性,不能阻塞 保护极短的变量访问(如 flag=1)
任务通知 TCB 内部直接通知 最快、最省内存,无共享变量 仅限一对一同步 STM32 按键驱动首选方案
二值信号量 队列同步机制 功能强大,支持多任务同步 内存开销稍大于通知 需要解耦按键检测与复杂业务逻辑时
ISR 临界区 精确屏蔽中断 保护 ISR 内部的多字节操作 使用稍繁琐 中断间有共享数据时

在 STM32 + FreeRTOS 开发中,**强烈推荐使用"任务通知"配合"中断服务程序"**来实现按键去抖动后的逻辑处理。这不仅避免了临界区竞争,还能让 CPU 在没有按键时处于低功耗挂起状态,兼顾了实时性与稳定性 。​​​​

相关推荐
HQB202610 小时前
EG1198|屹晶微电子 120V 内置 MOS 异步降压 DC‑DC(高性价比)
嵌入式硬件·硬件工程
果壳science12 小时前
张祥前实验如何获取正电荷
笔记·科技·嵌入式硬件·硬件工程·制造
南山电子nscn12 小时前
中科芯CETC汽车电池管理系统BMS进口芯片国产型号替代指南
单片机·汽车·bms
沉淀的.晴天12 小时前
can总线
stm32
桀人13 小时前
认识AT24C02
stm32·单片机·嵌入式硬件
国科安芯14 小时前
星间激光通信终端CANFD控制网络的时延确定性与辐射可靠性
网络·嵌入式硬件·信息与通信·canfd·低轨卫星·商业航天·抗辐射
一直C14 小时前
嵌入式 Linux 驱动开发学习笔记:内核定时器、platform 总线与 I²C 驱动
linux·驱动开发·嵌入式硬件·学习·imx6ull
传感器与混合集成电路14 小时前
150MHz主频、8路DAC与6路PWM:JLH221360伺服控制SIP解析
嵌入式硬件
comyccccc14 小时前
STM32PID控制板设计全解析学习笔记
单片机·嵌入式硬件
碧海银沙音频科技研究院15 小时前
实现炬芯ATS308X的usb cdc 串口IAP 固件升级功能实现
人工智能·嵌入式硬件·深度学习·算法