STM32架构基于调度器的非阻塞按键状态机设计

文章目录

在采用轻量级任务调度器的裸机开发中,为了实现"非阻塞"的任务执行,状态机(FSM)是不可或缺的基石。本篇文章是基于系统调度器的基础下进行拓展进阶,具体链接如下:

系统调度器文章参考:STM32 别再死等延时了!教你一招:不用 RTOS 也能让多个任务同时跑

什么是状态机?

状态机全称有限状态机(Finite State Machine)。简单来说,它将一个任务的生命周期拆分为多个离散的状态(State)。系统根据当前的状态和输入条件(如电平变化、计时到期),决定执行什么动作并跳转到下一个状态。

想象一下你在煮一壶水。

  • 普通思维:盯着水壶看,直到水开了(while 循环 + Delay 等待)。这时候如果有人敲门,你根本听不见,因为你正"死等"着水开。

  • 状态机思维:你给煮水分了几个状态:

    • 空闲态:水壶没动静。

    • 加热态:水正冒热气。

    • 沸腾态:水开了,该关火了。

你每隔几分钟看一眼水壶(这就是轮询),看完立刻去干别的(比如扫地)。哪怕水没开,你也知道现在处于"加热态",下次再来看。

这就是状态机的核心:不原地等待,只记住进度。

为什么要配合系统调度器?

在嵌入式工程领域,抛开感性的比喻,状态机(FSM) 与 系统调度器(Task Scheduler) 的结合实际上是逻辑维度与时间维度的精准对齐。

状态机的行为逻辑高度依赖于物理时间阈值。以按键交互为例,其核心逻辑(如 20ms 消抖、2s 长按)必须建立在稳定的时间参考之上。

  • 非调度环境:在 while(1) 裸跑中,循环周期受代码分支影响(如:这帧处理了屏幕,下帧只读了 GPIO),循环周期是不确定的。这会导致状态机内部的计数逻辑(ticks++)失去物理参考意义。
  • 调度架构:调度器基于硬件定时器(如 SysTick)产生固定频率。当调度器确保任务每 10ms 执行一次时,状态机内的计数器就获得了绝对的时间标尺。

调度器不仅提供了时间基准,还通过分时复用解决了多任务冲突。

  • 传统阻塞逻辑: 在没有调度的系统中,实现 20ms 消抖通常依赖 HAL_Delay(20)。这会强制 CPU 进入空转状态,阻塞其他实时任务(如串口数据接收或电机控制)。

  • 调度器 + 状态机: 状态机将长达 2s 的长按动作拆解为 200 个离散的逻辑切片。每次任务轮询时,状态机仅执行当前状态的瞬时判定(耗时微秒级)后立即返回(Return),释放 CPU 所有权。

架构优势: 这种"快进快出"的模式,使系统能在宏观上实现多个状态机(按键、传感器、通信协议)的并发运行。

案例演示

现在,我们通过一个经典的按键状态机案例,演示如何在 10ms 调度任务中实现"消抖、单击、长按"功能。

为了让按键驱动具备工业级的扩展性,我们首先将按键的所有属性封装进一个结构体(Struct)。这种"面向对象"的处理方式,允许我们用同一套逻辑管理成百上千个按键。

按键对象的定义

c 复制代码
typedef struct
{
    GPIO_TypeDef *gpiox; // 硬件端口 (如 GPIOB)
    uint16_t pin;        // 硬件引脚 (如 GPIO_PIN_0)
    uint16_t ticks;      // 状态内计时器
    uint8_t id;          // 按键唯一识别 ID
    uint8_t state;       // 当前状态机状态
    uint8_t debouce_cnt; // 消抖计数器
    uint8_t repeat;      // 记录点击次数(用于双击判定)
} button;

// 初始化按键列表
button btn_list[4] = {
    {GPIOB, GPIO_PIN_0, 0, 0, 1, 0, 0, 0}, // ID: 1 (PB0)
    {GPIOB, GPIO_PIN_1, 0, 0, 2, 0, 0, 0}, // ID: 2 (PB1)
    {GPIOB, GPIO_PIN_2, 0, 0, 3, 0, 0, 0}, // ID: 3 (PB2)
    {GPIOA, GPIO_PIN_0, 0, 0, 4, 0, 0, 0}  // ID: 4 (PA0)
};

状态机核心算法

这是整个系统的"引擎"。它被调度器每 10ms 调用一次,每次进入只进行一次电平采样和状态跳转,绝不原地停留。

在双击下,传统的做法通常是阻塞等待双击,而这里进入 State 4 后立即返回。调度器会在接下来的 150ms 内反复询问。如果这期间没按下且计时到期,才判定为单击。这种设计保证了在等待双击时,系统的其他任务(如 PWM 控制)依然在运行。

c 复制代码
/**
 * @brief 按键核心逻辑,建议每 10ms 调用一次
 * @return 返回触发按键的 ID,如果没有事件则返回 0
 */
uint8_t button_process(button* btn) {
    uint8_t cur_level = HAL_GPIO_ReadPin(btn->gpiox, btn->pin);
    uint8_t event_id = 0;

    switch (btn->state) {
        case 0: // 【空闲】
            if (cur_level == 0)
            {
                btn->state = 1; // 去消抖
                btn->debouce_cnt = 0;
            }
            break;

        case 1: // 【消抖】
            if (cur_level == 0)
            {
                if (++btn->debouce_cnt >= 3)
                {
                    btn->state = 2;
                }
            }
            else
                btn->state = 0;
            break;

        case 2: // 【确认按下】
            if (cur_level == 1)// 弹起了
            { 
                btn->repeat++;     // 记录点击次数
                btn->ticks = 0;    // 重置计时器,准备计算双击间隔
                btn->state = 4;    // 进入双击等待判定
            }
            else
            {
                if (++btn->ticks >= 150)
                { // 长按 1.5s
                    btn->state = 3;
                    btn->repeat = 0;
                    // event_id = btn->id + 20; // 如果需要长按,可以返回 ID+20
                }
            }
            break;

        case 3: // 【长按结束等待松手】
            btn->ticks = 0;
            if (cur_level == 1)
                btn->state = 0;
            break;

        case 4: // 【双击等待判定】
            if (cur_level == 0)
            { // 在时间内再次按下,说明是双击
                btn->state = 1;   // 回到消抖,去确认第二次按下
                // 注意:这里由于 repeat 已经是 1 了,下次弹起就会变成 2
            }
            else
            {
                btn->ticks++;
                if (btn->ticks >= 13)
                { // 等待约 150ms (10ms * 15)
                    // 超时了,根据 repeat 次数判定
                    if (btn->repeat == 1)
                        event_id = btn->id;        // 单击:返回原 ID (1,2,3,4)
                    else if
                        (btn->repeat >= 2) event_id = btn->id + 10; // 双击:返回 ID+10 (11,12,13,14)
                    
                    btn->repeat = 0;
                    btn->state = 0;
                    btn->ticks = 0;
                }
            }
            break;
    }
    return event_id;
}

业务层对接:事件分发

有了底层的事件输出,业务层逻辑(Key_Proc)变得异常清爽。我们不再关心按键是怎么消抖的,只关心"发生了什么事件"。通过判断返回来不同的ID进行相应的事件处理。

c 复制代码
void Key_Proc(void) {
    for (int i = 0; i < 4; i++) {
        uint8_t key_event = button_process(&btn_list[i]);
        if (key_event == 0) continue; // 无事发生,跳过

        switch (key_event) {
            case 1:  // KEY1 单击
                uled ^= 0x01; 
                break;
            case 11: // KEY1 双击
                uled &= ~0x01;
                break;
            case 21: // KEY1 长按
                Save_System_Params(); // 执行耗时保存操作
                break;
            // ... 其他按键事件
        }
    }
}

在调度器的节拍下,我们只需遍历按键列表并处理返回的 event_id。这种方式将底层的"电平扫描"与上层的"业务逻辑"彻底解耦。

通过这套"调度器 + 状态机"的架构,原本复杂的按键交互变得极易扩展和维护。底层驱动只需关注状态跳转,而业务层只需关注事件结果。

众多应用场景

  1. 通信协议解析(UART/SPI/I2C)

    在接收串口数据包时,使用状态机逐个字节处理:

    • 状态 A:等待帧头(如 0x55)。
    • 状态 B:接收长度字节。
    • 状态 C:接收数据荷载。
    • 状态 D:校验和验证。
    • 优势:无需使用 Delay 或长时间阻塞等待完整包,收一个字节处理一次,极大地提高了串口的吞吐率。
  2. 传感器异步读取(DS18B20/DHT11)

    这类传感器通常需要发送指令后等待数百毫秒才能读取数据。

    • 状态 A:发送启动信号。
    • 状态 B:记录当前 Tick,进入"等待态"。
    • 状态 C:调度器检测时间到期,跳转到"读取态"。
    • 优势:在传感器转换数据的 750ms 漫长等待中,CPU 可以自由执行按键扫描、屏显更新等任务。

通过这套"系统调度器 + 状态机"的架构,原本杂乱无章的按键信号、传感器数据和通信协议,在物理世界中被赋予了精确的时间尺度与执行秩序。

很多开发者会问:既然这种方案如此高效,我们还需要 RTOS(实时操作系统)吗?

事实上,这种架构是裸机开发的天花板,也是理解 RTOS 核心逻辑的敲门砖:

  • 资源占用:本架构只需极小的 RAM 和 Flash,在 Flash 空间吃紧、RAM 只有几 KB 的单片机(如 STM8、STM32G0/F0)上,它是平衡性能与资源的最优解。
  • 执行效率:由于省去了 RTOS 繁重的上下文切换(Context Switch)和任务堆栈开销,它的执行效率甚至高于 RTOS。
  • 思维转变:RTOS 允许你"阻塞"任务,而本架构强制你使用"非阻塞"的状态跳转。这种**"分时复用"**的思维,是每一位高级嵌入式工程师必备的底层内功。

如果你面对的是资源有限、实时性要求高且逻辑复杂的工程,这套架构将是你手中最锋利的武器。它不仅让你的程序告别了低效的 Delay,更让你的裸机代码拥有了足以媲美操作系统的流畅度。


相关推荐
Wang's Blog3 小时前
Lua: 事件处理深度解析之从协程到跨平台架构实践
junit·架构·lua
哔哩哔哩技术3 小时前
2025年哔哩哔哩技术精选技术干货
前端·后端·架构
创界工坊工作室3 小时前
DPJ-148 基于Arduino六自由度机械手设计(源代码+proteus仿真)
stm32·单片机·嵌入式硬件·51单片机·proteus
金色光环3 小时前
裸机stm32移植双串口modbus从机(附源码)
stm32·单片机·嵌入式硬件
一路往蓝-Anbo3 小时前
C语言从句柄到对象 (五) —— 虚函数表 (V-Table) 与 RAM 的救赎
c语言·开发语言·stm32·单片机·物联网
古译汉书3 小时前
keil编译错误:Error: Flash Download failed
开发语言·数据结构·stm32·单片机·嵌入式硬件
浩子智控4 小时前
高可靠电子产品软件工程化
测试工具·架构·系统安全·软件工程·敏捷流程
踏浪无痕4 小时前
从救火到防火:我在金融企业构建可观测性体系的实战之路
后端·面试·架构
d111111111d5 小时前
使用STM32 HAL库配置ADC单次转换模式详解
笔记·stm32·单片机·嵌入式硬件·学习