Skip to content

1.11 状态机设计 ​

这节要搞懂:界面和流程怎么写才不会乱成一团。 需要基础:1.10

一、先看不用状态机会有多惨 ​

做一个闹钟界面,需求很简单:

平时显示时间 → 按 SET 进入设置 → 按 UP 调小时 → 再按 SET 调分钟 → 长按保存退出

用一堆标志位写出来是这样:

c
if (in_setting) {
    if (setting_hour) { /* ... */ }
    else if (setting_minute) { /* ... */ }
} else if (alarm_ringing && !snoozed) {
    /* ... */
}

再加两个功能,判断条件就会嵌套五六层。三个月后你自己都看不懂。

二、什么是状态机 ​

状态机(State Machine)是一种程序设计模型。它把系统抽象成有限个状态,系统在收到事件时,按预先定义的规则执行动作并转移到下一个状态。

三个关键词,记住:

名词含义例子
状态 State系统现在处于什么模式显示时间 / 设置小时 / 闹钟响
事件 Event发生了什么按了 SET / 按了 UP / 超时了
迁移 Transition收到事件后跳到哪"显示时间"收到"按 SET" → 跳到"设置小时"

打个比方:状态机就像自动售货机 —— 待机 → 投币 → 选货 → 出货 → 回到待机。每一步只能干这一步该干的事。

三、先画表,再写代码 ​

现在的状态发生了什么做什么跳到
显示时间按 SET显示设置界面设置小时
设置小时按 UP小时 +1设置小时
设置小时按 SET—设置分钟
设置小时10 秒没动放弃修改显示时间
设置分钟长按保存显示时间
闹钟响收到"停止"命令停止播放铃声显示时间

这张表既是需求文档,也是代码结构。

四、翻译成代码 ​

c
// 1. 列出所有状态
typedef enum {
    STATE_SHOW_TIME,        // 显示时间
    STATE_SET_HOUR,         // 设置小时
    STATE_SET_MINUTE,       // 设置分钟
    STATE_ALARM_RING,       // 闹钟响铃中
} ui_state_t;

// 2. 列出所有事件
typedef enum {
    EVENT_KEY_SET,          // 按了 SET
    EVENT_KEY_UP,           // 按了 UP
    EVENT_KEY_LONG,         // 长按
    EVENT_TIMEOUT,          // 超时
} ui_event_t;

// 3. 记住当前状态
static ui_state_t s_state = STATE_SHOW_TIME;

// 4. 照着表写
void ui_handle_event(ui_event_t event)
{
    switch (s_state) {

    case STATE_SHOW_TIME:
        if (event == EVENT_KEY_SET) {
            ui_show_setting();                 // 显示设置界面
            s_state = STATE_SET_HOUR;
        }
        break;

    case STATE_SET_HOUR:
        if (event == EVENT_KEY_UP) {
            g_alarm_hour = (g_alarm_hour + 1) % 24;
        } else if (event == EVENT_KEY_SET) {
            s_state = STATE_SET_MINUTE;
        } else if (event == EVENT_TIMEOUT) {
            s_state = STATE_SHOW_TIME;         // 10 秒没动,自动退出
        }
        break;

    case STATE_SET_MINUTE:
        if (event == EVENT_KEY_UP) {
            g_alarm_minute = (g_alarm_minute + 1) % 60;
        } else if (event == EVENT_KEY_LONG) {
            alarm_save();                      // 保存到 NVS
            s_state = STATE_SHOW_TIME;
        }
        break;

    case STATE_ALARM_RING:
        alarm_sound_stop();                    // 停止播放铃声
        s_state = STATE_SHOW_TIME;
        break;
    }
}

加新功能 = 加一个 case 或加一个 if,不用动别的地方。

五、和 FreeRTOS 配起来用 ⭐ ​

这是第三章反复出现的写法,记住这个骨架:

c
QueueHandle_t ui_queue;                        // 事件队列

void task_ui(void *pvParameters)
{
    ui_event_t event;

    while (1) {
        // 等事件来,最多等 1 秒
        if (xQueueReceive(ui_queue, &event, pdMS_TO_TICKS(1000)) == pdTRUE) {
            ui_handle_event(event);            // 来了事件就处理
        } else {
            ui_handle_event(EVENT_TIMEOUT);    // 1 秒没来,当"超时"处理
        }
    }
}

// 按键任务只管发事件,不管业务
void key_post_event(ui_event_t event)
{
    xQueueSend(ui_queue, &event, 0);
}

这样设计的三个好处:

  1. 所有界面变化都在一个任务里发生 → 不需要加锁(1.7 的问题自动没了)
  2. 按键任务只负责"报告发生了什么",不掺和业务逻辑
  3. 加新功能只改状态表

六、几个设计要点 ​

  • 状态之间必须互斥:任何时刻只能处于一个状态
  • 每个状态都要有出路:别让某个状态进去就出不来
  • 动作要短:状态机里别干耗时的活(比如刷全屏),发消息给别的任务做
  • 画出来再写:状态超过 4 个,先在纸上画个迁移图

本节要点 ​

  • 状态机 = 用"状态 + 事件 + 迁移"三要素描述流程
  • 先画表,再照表写 switch-case
  • ⭐ 任务 + 队列 + 状态机是嵌入式写业务逻辑的标准骨架
  • 好处:不用加锁、加功能不动老代码

动手试试 ​

  1. 在纸上画出上面闹钟的状态迁移图(4 个状态、4 个事件)。
  2. 把上面的代码敲一遍,用串口输入字母模拟按键(输入 s = 按 SET),打印每次状态变化。
  3. 加一个新功能:在"显示时间"状态下按 UP 可以切换显示温度,想想要加几行代码。