2. 状态机基础:有限状态机(FSM)概念、状态、事件、转移、动作的定义、状态图绘制规范

好,咱们正式开始聊状态机。说实话,我刚开始做嵌入式那会儿,觉得状态机就是个花架子。不就是几个 if-else 嘛?直到有一次,我接手一个同事留下的 LoRaWAN 项目,那代码写得跟蜘蛛网似的——各种标志位、嵌套 if、全局变量满天飞。调试了三天,我差点把键盘吃了。后来我痛定思痛,老老实实画了张状态图,重构了代码。嗯,从那以后,我再也不敢小看状态机了。

2.1 什么是有限状态机?

有限状态机,英文叫 Finite State Machine,简称 FSM。说白了,就是描述一个系统在有限个状态之间怎么跳来跳去的数学模型。

你想想看,LoRaWAN 终端设备其实特别适合用 FSM 来描述。为什么?因为它就那么几个状态:上电、入网、休眠、发送数据、接收数据……不会无限多。这就是「有限」二字的含义。

我个人习惯把 FSM 比作一个自动售货机。你投币(事件),它根据当前状态(比如「待投币」或「已投币」),决定给你什么饮料(动作),然后切换到下一个状态(转移)。是不是很形象?

2.2 FSM 的四大核心要素

要搞懂状态机,必须吃透这四个东西:状态事件转移动作。我一个一个说。

2.2.1 状态(State)

状态就是系统在某个时刻的「定格画面」。比如 LoRaWAN 终端,它要么在休眠,要么在发送,要么在接收——不可能同时干两件事。

我在项目中遇到过一个问题:有人把「等待 ACK」和「发送数据」混在一起,结果超时重传的逻辑乱成一锅粥。记住,每个状态必须是互斥的、可区分的

状态的特点:

  • 原子性:一个时刻只能处于一个状态
  • 有限性:状态数量是确定的,不能无限多
  • 可观测性:可以通过某些条件判断当前处于哪个状态

2.2.2 事件(Event)

事件就是「触发状态变化的东西」。它可以是外部输入(比如按键按下),也可以是内部信号(比如定时器超时)。

在 LoRaWAN 里,常见的事件有:

  • JOIN_REQUEST:发起入网请求
  • TX_DONE:数据发送完成
  • RX_TIMEOUT:接收窗口超时
  • ACK_RECEIVED:收到确认帧

这里有个坑,我曾经踩过:事件不能丢失。如果你的事件是边沿触发的(比如按键按下只产生一个脉冲),而状态机当时正在处理别的事,这个事件就丢了。所以,我建议用队列来缓存事件,或者用电平触发的方式。

2.2.3 转移(Transition)

转移就是「从状态 A 到状态 B 的路径」。它由三部分组成:当前状态 + 事件 + 条件(可选)→ 下一个状态

举个例子:

当前状态:IDLE(空闲)
事件:TX_REQUEST(请求发送)
条件:信道空闲
下一个状态:TRANSMITTING(发送中)

如果条件不满足呢?那就不能转移,或者转移到另一个状态(比如「等待信道空闲」)。这就是条件转移。

我的小技巧:画状态图时,先把所有「正常路径」画出来,再补「异常路径」。比如超时、重试、错误处理。很多新手只画了 happy path,结果产品一上线就崩。

2.2.4 动作(Action)

动作是「转移发生时执行的操作」。注意,动作不是状态!动作是瞬时的,执行完就结束了。

动作可以分三种:

  • 进入动作:进入某个状态时执行(比如进入休眠前关掉射频)
  • 退出动作:离开某个状态时执行(比如退出休眠前打开时钟)
  • 转移动作:转移过程中执行(比如发送数据帧)

我曾经犯过一个错:把「发送数据」这个动作写在了状态里,结果每次轮询到这个状态就发一次数据。嗯,那场面,服务器差点以为被攻击了。正确的做法是:动作只执行一次,状态只负责等待

2.3 状态图的绘制规范

画状态图,不是随便画几个圈连几条线就完事了。我见过太多「灵魂画手」画的状态图,连自己都看不懂。这里我给出一个实用的规范。

2.3.1 基本符号

符号 含义 说明
圆角矩形 状态 内部写状态名称,如 IDLE、SLEEP
实心圆 初始状态 系统上电后的第一个状态
带边框的实心圆 终止状态 系统结束时的状态(LoRaWAN 一般没有)
箭头 转移 从源状态指向目标状态
箭头上的标签 事件 [条件] / 动作 格式:事件名 [条件] / 动作名

2.3.2 标签格式

转移标签的标准格式是:

事件 [条件] / 动作

举个例子:

TX_DONE [ACK_REQUIRED] / START_RX_WINDOW

意思是:当发送完成事件发生,且需要 ACK 时,启动接收窗口。

如果条件为空,可以省略中括号。如果动作为空,可以省略斜杠。但事件不能省略,否则就是「自发转移」,容易引起歧义。

2.3.3 一个简单的 LoRaWAN 状态图示例

咱们画一个终端设备入网后的基本状态流转:

        [初始状态]
            |
            v
        +---------+
        |  IDLE   |<----------------+
        +---------+                  |
            |                        |
            | TX_REQUEST             |
            v                        |
        +------------+               |
        | TRANSMIT   |               |
        +------------+               |
            |                        |
            | TX_DONE                |
            v                        |
        +------------+               |
        | WAIT_ACK   |               |
        +------------+               |
            |          \             |
            | ACK_OK    \ ACK_TIMEOUT|
            v            v           |
        +---------+   +---------+    |
        |  SLEEP  |   | RETRY   |----+
        +---------+   +---------+
            |
            | WAKEUP_TIMER
            v
        +---------+
        |  IDLE   |
        +---------+

注意:这个图只是示意,实际 LoRaWAN 状态机要复杂得多。比如还要考虑 RX1 和 RX2 两个接收窗口、Class B 的 Beacon 同步、Class C 的连续接收等。但核心思想是一样的:状态清晰、事件明确、转移可控

2.4 状态机设计的三个原则

最后,我分享三个原则。这是我做了十几年嵌入式总结出来的,希望能帮你少走弯路。

  1. 单一职责原则:一个状态只做一件事。比如「发送中」就只管发送,别管接收。接收是另一个状态的事。
  2. 无死角原则:每个状态下的每个事件,都必须有明确的处理方式。哪怕只是「忽略」,也要写出来。否则就是未定义行为,bug 的温床。
  3. 可测试原则:状态机应该是可测试的。你可以枚举所有状态和所有事件,组合起来就是测试用例。如果组合太多,说明状态设计有问题。

我曾经接手过一个项目,状态图里有个状态叫「MAGIC_STATE」,没有任何事件能进入它,也没有任何事件能离开它。我问前任这是什么意思,他说:「我也不知道,但删了会崩。」嗯,这就是典型的反面教材。

好了,这一章就到这里。下一章咱们会深入 LoRaWAN 终端的具体状态机设计,包括入网、发送、接收、休眠的完整流程。到时候我会拿一个真实项目里的状态图出来,咱们一起拆解。