3、状态图设计:绘制状态转换图、定义状态ID、定义事件ID、定义动作函数

好,咱们进入状态机设计的核心环节——状态图设计。这一步说白了,就是把脑子里的逻辑变成一张看得见、摸得着的图。我个人习惯,画图之前先想清楚三件事:系统有哪些状态?什么事件能触发状态跳转?跳转的时候要执行什么动作?

你想想看,按摩仪的模式切换其实并不复杂。无非就是用户按一下按键,机器从当前模式切到下一个模式。但这里有个坑——如果用户长按、短按、或者同时按了两个键,系统该怎么反应?嗯,这就是状态图要解决的问题。

3.1 绘制状态转换图

先看一张我手绘的状态转换图(别笑,我画图水平就那样,但逻辑是清楚的):

                    +-----------+
                    |  待机模式  |
                    +-----+-----+
                          |
                短按电源键(事件)
                          |
                          v
                    +-----------+
          +---------+ 放松模式  +---------+
          |         +-----+-----+         |
          |               |               |
    短按模式键       短按模式键       短按模式键
          |               |               |
          v               v               v
    +-----------+   +-----------+   +-----------+
    | 舒缓模式  |   | 强劲模式  |   | 睡眠模式  |
    +-----------+   +-----------+   +-----------+
          |               |               |
          +-------+-------+-------+-------+
                  |               |
            长按电源键         长按电源键
                  |               |
                  v               v
            +-----------+
            |  待机模式  |
            +-----------+

这张图里,每个方框代表一个状态,箭头代表状态转换的方向,箭头上的文字就是触发转换的事件。我在项目中遇到过,有些同事喜欢把状态图画得特别复杂,恨不得把所有细节都塞进去。其实没必要,状态图的核心是「谁在什么条件下能到谁那里去」,其他的细节交给代码实现就好。

我的小技巧:画状态图时,先用铅笔在纸上画草稿,确认逻辑没问题了,再用工具画正式版。我吃过亏,直接在Visio上画,画到一半发现逻辑错了,改起来特别痛苦。

3.2 定义状态ID

状态图画好了,接下来要给每个状态起个名字、编个号。我习惯用枚举类型来定义状态ID,这样代码可读性高,编译器还能帮你检查拼写错误。

/* 状态ID定义 */
typedef enum {
    STATE_IDLE      = 0x00,  /* 待机模式 */
    STATE_RELAX     = 0x01,  /* 放松模式 */
    STATE_SOFT      = 0x02,  /* 舒缓模式 */
    STATE_STRONG    = 0x03,  /* 强劲模式 */
    STATE_SLEEP     = 0x04,  /* 睡眠模式 */
    STATE_MAX       = 0x05   /* 状态总数,用于边界检查 */
} state_id_t;

这里有个细节:我把STATE_MAX放在最后,值为5。这样如果以后要增加新状态,只需要在它前面插入,然后更新STATE_MAX的值就行。我曾经在一个项目里忘了更新这个值,结果数组越界,程序跑飞了。嗯,从那以后我每次加状态都会检查这个宏。

状态ID 枚举名称 说明
0x00 STATE_IDLE 待机模式,电机不转,等待用户操作
0x01 STATE_RELAX 放松模式,低速轻柔按摩
0x02 STATE_SOFT 舒缓模式,中速适中按摩
0x03 STATE_STRONG 强劲模式,高速深度按摩
0x04 STATE_SLEEP 睡眠模式,间歇性轻柔按摩

3.3 定义事件ID

事件就是「什么东西触发了状态转换」。在按摩仪这个场景里,事件主要来自按键操作。我建议把事件也定义成枚举,和状态ID保持一致的风格。

/* 事件ID定义 */
typedef enum {
    EVT_NONE        = 0x00,  /* 无事件 */
    EVT_POWER_SHORT = 0x01,  /* 电源键短按 */
    EVT_POWER_LONG  = 0x02,  /* 电源键长按 */
    EVT_MODE_SHORT  = 0x03,  /* 模式键短按 */
    EVT_MODE_LONG   = 0x04,  /* 模式键长按 */
    EVT_TIMEOUT     = 0x05,  /* 定时超时 */
    EVT_MAX         = 0x06   /* 事件总数 */
} event_id_t;

你可能会问:为什么要把短按和长按分开定义?因为它们在状态机里的行为完全不同。短按是「切到下一个模式」,长按是「回到待机」。如果混在一起,状态转换逻辑会变得很混乱。

注意:事件ID的编号最好从1开始,把0留给「无事件」。这样在状态机主循环里,可以用0表示「没有新事件发生」,避免误触发。我曾经因为事件ID从0开始,结果初始化时忘记清零,导致状态机一启动就乱跳。

3.4 定义动作函数

动作函数,就是状态转换时要执行的具体操作。比如进入放松模式时,要设置电机转速、开启LED指示灯、启动定时器等等。我习惯把每个状态的动作封装成一个函数,这样状态机的主循环看起来特别清爽。

/* 动作函数声明 */
void action_enter_idle(void);    /* 进入待机模式 */
void action_enter_relax(void);   /* 进入放松模式 */
void action_enter_soft(void);    /* 进入舒缓模式 */
void action_enter_strong(void);  /* 进入强劲模式 */
void action_enter_sleep(void);   /* 进入睡眠模式 */
void action_exit_idle(void);     /* 退出待机模式 */
void action_exit_relax(void);    /* 退出放松模式 */
/* ... 以此类推 */

每个动作函数内部做什么?举个例子:

void action_enter_relax(void)
{
    /* 设置电机转速为低速 */
    motor_set_speed(MOTOR_SPEED_LOW);
    
    /* 开启放松模式指示灯(蓝色) */
    led_set_color(LED_BLUE);
    led_on();
    
    /* 启动30分钟定时器 */
    timer_start(30 * 60 * 1000);  /* 30分钟,单位毫秒 */
    
    /* 记录当前模式,用于下次切换 */
    g_current_mode = MODE_RELAX;
    
    /* 输出调试信息 */
    printf("[状态机] 进入放松模式\n");
}

这里有个设计原则:动作函数只做「进入状态时该做的事」,不做「状态转换的判断」。判断逻辑全部交给状态机的主循环去处理。这样做的最大好处是——每个函数只干一件事,出了问题好排查。

核心要点:状态ID、事件ID、动作函数,这三者是状态机的「三驾马车」。状态ID告诉你「我在哪」,事件ID告诉你「发生了什么」,动作函数告诉你「我要做什么」。把这三样定义清楚了,状态机代码写起来就像填表格一样简单。

最后,我建议把这三样东西放在同一个头文件里,比如state_machine_def.h。这样不管是自己看还是给别人看,都能一目了然。我在团队里推行这个做法后,代码review的效率提高了不少——因为大家不用翻来翻去找定义在哪了。