状态图基础:从画图到写代码

状态机建模,说白了就是给嵌入式系统画「行为地图」。我见过太多工程师上来就写 switch-case,写到一半发现状态跳转乱成一锅粥。嗯,这时候你需要的是一张状态图。

UML 状态图(Statechart)是描述状态机最直观的工具。它不是花架子,而是能直接映射成 C 代码的「设计蓝图」。今天我们就把它拆开揉碎了讲。

状态图的核心元素

先看一张完整的状态图长什么样:

UML 状态图核心元素示例 空闲 start() 运行(复合状态) 正常 告警 阈值超 stop() 停止 图例说明: ● 实心圆 = 初始状态(进入状态机第一站) ● 靶心圆 = 终态(状态机结束) 虚线框 = 复合状态(内部包含子状态机) 实线箭头 = 转换(带触发事件标签) 实心矩形 = 简单状态(原子状态,不可再分)

核心三要素:状态(State)、转换(Transition)、事件(Event)。缺一个,状态图就不完整。

初始状态与终态

这两个元素看着简单,但坑不少。

初始状态:实心圆点,表示状态机启动后第一个进入的状态。每个状态图有且只有一个初始状态。我在项目中见过有人画两个初始状态,结果代码里不知道先执行哪个入口动作——这种低级错误,review 时一眼就能揪出来。

终态:靶心圆(外圈实心+内圈实心),表示状态机生命周期结束。注意,不是所有状态机都需要终态。比如一个永远在跑的通信协议栈,它就没有终态。我曾经在一个电源管理模块里忘了画终态,结果代码里 shutdown 流程永远走不完——嗯,那次加班到凌晨两点。

元素 符号 含义 代码映射
初始状态 ● 状态机启动入口 初始化函数中设置初始状态变量
终态 ◎ 状态机结束 返回特定退出码或释放资源
简单状态 □ 原子状态,不可再分 枚举值 + case 分支
复合状态 □ 虚线框 包含子状态机 嵌套 switch 或子状态机函数

复合状态:状态机里的「俄罗斯套娃」

复合状态,说白了就是状态里面套状态。为什么要用它?你想想看,一个设备「运行中」这个状态,内部可能包含「初始化」「正常工作」「降级模式」「异常恢复」等多个子状态。如果全部平铺开,状态图会变成一团乱麻。

我习惯这样处理:把「运行中」定义成复合状态,内部再画一张子状态图。这样做有两个好处:

  • 层次清晰:外部只关心「运行中」这个整体,内部细节被封装了
  • 复用性强:同样的子状态机可以在不同父状态中复用

我的经验:复合状态的嵌套深度不要超过 3 层。超过 3 层,代码可读性急剧下降,调试时你会在状态栈里迷路。

状态图到代码的映射:不只是 switch-case

很多人以为状态图转代码就是写个 switch-case。其实远不止如此。我总结了三种常见的映射模式:

模式一:简单状态 → 枚举 + switch

// 状态枚举
typedef enum {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_STOP,
    STATE_ERROR
} state_t;

// 状态机主循环
void state_machine_run(event_t event) {
    switch (current_state) {
        case STATE_IDLE:
            if (event == EVT_START) {
                current_state = STATE_RUNNING;
                // 执行 entry 动作
                motor_enable();
            }
            break;
        case STATE_RUNNING:
            if (event == EVT_STOP) {
                current_state = STATE_STOP;
                motor_disable();
            }
            break;
        // ... 其他状态
    }
}

模式二:复合状态 → 状态栈 + 子状态机函数

// 复合状态用状态栈实现
typedef struct {
    state_t main_state;   // 父状态
    state_t sub_state;    // 子状态
} state_stack_t;

void sub_state_machine_run(event_t event) {
    switch (state_stack.sub_state) {
        case SUB_INIT:
            // 子状态逻辑
            break;
        case SUB_NORMAL:
            // ...
            break;
    }
}

模式三:带 entry/exit 动作的状态

// 进入/退出动作单独处理
void state_machine_run(event_t event) {
    state_t prev_state = current_state;

    // 状态转换逻辑
    switch (current_state) {
        case STATE_IDLE:
            if (event == EVT_START) {
                current_state = STATE_RUNNING;
            }
            break;
        // ...
    }

    // 如果状态变了,执行退出和进入动作
    if (prev_state != current_state) {
        state_exit_action(prev_state);   // 退出旧状态
        state_entry_action(current_state); // 进入新状态
    }
}

注意:entry/exit 动作里不要调用可能触发状态转换的函数,否则会形成递归。我曾经在一个项目里踩过这个坑——entry 里调用了发送消息,发送消息又触发了状态转换,结果栈溢出了。

状态图与代码的对应关系表

UML 元素 C 代码实现 注意事项
状态 枚举常量 命名要语义化,如 STATE_MOTOR_RUN
转换 switch-case 分支 + 状态赋值 每个转换都要有明确的事件触发
事件 枚举常量或消息结构体 事件参数用联合体传递
entry 动作 状态赋值后调用的函数 不要在里面触发新转换
exit 动作 状态改变前调用的函数 用于清理资源、停止定时器等
复合状态 嵌套 switch 或子状态机函数 用状态栈记录父子关系
初始状态 状态机初始化函数 上电或复位后执行
终态 返回特定值或释放资源 非必须,看业务需求

避坑指南:我踩过的三个坑

做状态机设计这么多年,有些坑我印象特别深:

  1. 状态爆炸:把所有状态平铺,没有用复合状态。结果状态图有 50 多个状态,代码里 switch-case 写了 800 行。后来重构,用复合状态压缩到 15 个,代码量减少 70%。
  2. 事件遗漏:画图时只画了正常流程,异常事件(超时、错误)没画。代码写完后发现,有些状态收到非法事件后直接死机了。从那以后,我每个状态都会问自己:「这个状态下,所有可能的事件怎么处理?」
  3. 状态图与代码脱节:画完图扔一边,直接写代码。结果代码和状态图对不上,后来维护的人根本看不懂。我现在要求团队:状态图必须和代码一起 review,图变代码必须变。

总结一句话:状态图不是画完就完的文档,它是代码的「设计契约」。图怎么画,代码就怎么写——两者必须严格对应。