有限状态机(FSM)
1 组合逻辑和时序逻辑
组合逻辑 和时序逻辑是数字电路设计中的两个非常重要的概念。数字电路通过实现这两个概念,搭建了冯诺依曼体系结构(布尔代数+数字逻辑电路),进而模拟了几乎整个物理世界的运行规律。
- 组合逻辑
组合逻辑不涉及时序,只涉及"瞬时的"布尔运算------当前时刻的逻辑运算不依赖任何历史状态------对应冯诺依曼体系结构中单个tick的逻辑计算。 - 时序逻辑
时序逻辑是组合逻辑的扩展,具备记忆能力,使得当前时刻(或tick)的逻辑运算可以依赖历史状态。
有限状态机就是一种描述时序逻辑的建模语言。
2 四要素(四元组)
2.1 状态和迁移
状态机最重要的两个概念是状态 和状态迁移 。
状态用于描述某个时刻系统的特性,可以是标量或者向量,在状态迁移图中用节点(Node)表示。状态迁移用于描述各个状态之间的转换关系,在状态迁移图中用边(Edge)表示。
示意图如下[1]

Tips: 状态迁移图是一个网状拓扑结构。
2.2 事件和动作
光有状态和状态迁移是不够的,构建状态机的最终目的是和外界进行交互,因此,状态机必须设计输入和输出。
- 输入 对应事件 的概念,输出 对应动作的概念。
- 事件 对应观测 ,是被动的,动作 对应控制,是主动的。
为了提升状态机的应激能力和减少状态数量(降低FSM的复杂性),一般建模时选择Mealy Machine(即输出既和当前的状态相关,也和当前的输入相关)
Tips: 和Mealy Machine相对的是Moore Machine,可自行查阅相关资料。
为了进一步减少状态数量,从是否会触发状态迁移划分,可以将事件分为 导致状态迁移的事件 和 常规事件(不会导致状态转移)。常规事件的作用是使得FSM在当前状态下,不进行状态切换时仍具备对事件的响应能力。严格来讲,常规事件的响应不属于状态机研究的范畴,但是,从实用角度来讲,却是必要的(见最佳实践章节说明)。
这里为什么称作 事件 ,而没有称作 条件 (大部分状态机中状态跳转使用是Condition这个概念,即满足一定的条件后进行跳转)呢?因为 状态 + 事件 = 条件 。
用概率建模状态机的迁移过程就很容易看到这点:状态机的迁移概率可以表示为
P ( s t + 1 ∣ s t , e t ) (2.1) P(s_{t+1} \mid s_t, e_t) \tag{2.1} P(st+1∣st,et)(2.1)
其中 s t s_t st 是当前状态, e t e_t et 是当前事件,在条件概率中,这两者都属于 条件 。
3 优缺点
3.1 优点
- 建模直观易懂;
- 易实施。
3.2 缺点
- 维护和扩展性差:由于是网状拓扑结构,当状态增多时不好维护;
- 复用性:由于状态间可见,因此单个状态并不是完全独立的模块,因此很难复用。
3.3 使用条件
- 一个阶段/任务可能会持续比较长时间(不能在一个周期/tick内完成),比如该阶段内需要等待与协调工作;
- 当需要明确当前所处的状态时,比如人机交互场景,需要让用户知道当前所处的状态。
4 最佳实践
状态机最大的问题就是拓扑结构复杂,加入和删除一个节点都比较困难。
为了缓解这个问题,从以下多个途径进行解决。最终实现对FSM的扬长避短。
4.1 配置工厂
用配置文件对FSM进行管理,各个状态以及状态间的迁移一目了然。
在配置文件中配置需要实例化的类/子类是工厂设计模式的最佳实现方案。
下面的json配置文件是一个不完整示例,详细解释见注释。
json
{ "NOA_STATES": # 根Key,用于校验加载的该配置文件是否是目标配置文件
[
{
"StateName": "Start", # 初始状态类名(该名称需要和C++代码中的类名保持一致,否则实例化时报错)
"Parent": "*", # 这里的*代表该状态没有父状态
"Transition": [ # 描述状态迁移定义
{
"Event": "noa_start", # 事件名称(导致状态迁移的事件;注意事件名称需要和代码中保持一致)
"Priority": 0, # 事件优先级,提升对重要事件的响应能力,同时防止歧义切换导致的状态冲突
"Key": "->Pending",
"Target": "Pending" # 目标状态
}
]
},
...
{
"StateName": "Driving", # 状态名,和C++代码中的类名保持一致
"Parent": "*",
"Transition": [ # 一个状态下可以有多个迁移路径
{
"Event": "cancel_button_pressed",
"Priority": 100,
"Key": "->Failed_CauseByUser",
"Target": "Failed_CauseByUser"
},
{
"Event": "moving_with_brake_pedal_pressed",
"Priority": 100,
"Key": "->Failed_CauseByEvent",
"Target": "Failed_CauseByEvent"
},
{
"Event": "steering_torque_exceeds_limit",
"Priority": 100,
"Key": "->Failed_CauseByEvent",
"Target": "Failed_CauseByEvent" # 即使是迁移到同一个状态,也可以是因为不同的事件
},
{
"Event": "steering_angle_exceeds_limit",
"Priority": 100,
"Key": "->Failed_CauseByEvent",
"Target": "Failed_CauseByEvent"
}
]
},
{
"StateName": "Driving_Active", # Driving状态下的子状态Active
"Parent": "Driving", # 父状态是Driving
"Transition": [
{
"Event": "guardian",
"Priority": 1,
"Key": "->Failed_CauseByEvent", # 任意子状态间也可以直接迁移
"Target": "Failed_CauseByEvent"
},
{
"Event": "radar_data_lost",
"Priority": 1,
"Key": "->Failed_CauseByEvent",
"Target": "Failed_CauseByEvent"
}
]
}
]}
4.2 状态机的分离和分层
状态粒度的把控和功能模块划分需要遵循的原则是一致的,需要进行权衡。状态数目太少,每个状态内的逻辑就会变得复杂,状态数目太多,状态间的迁移和通信又会变得十分复杂。
最佳实践是先采用状态的分离和分层策略。
4.2.1 状态机的分离
虽然大家都在这么做,但很少提到这个概念。状态机的分离就是把两个业务模块完全拆开,各自用状态机进行建模,两个模块间的状态不涉及相互迁移,完全分离。
比如决策模块的状态机和控制模块的状态机之间的关系。
4.2.2 状态机的分层
分层有限状态机(HFSM),也称为状态图,是为弥补有限状态机(FSM)的缺点而建立的。在HFSM中,状态可以包含一个或多个子状态。包含两个或两个以上子状态的父状态成为超状态。在HFSM中,一种设计是不允许跨超状态的子状态之间的直接跳转,另外一种则允许------这两种设计有利有弊,需要根据应用场景进行权衡------如果对系统的"应激性"要求较高,则选择支持跨超状态的子状态之间的直接跳转(在一个tick内就可以完成 子状态A->父状态A->父状态B->子状态B 的跳转,以及相关的处理),否则选择另一种,以降低连接的复杂性。
4.3 状态模式与事件驱动
最简单也最容易想到的一种FSM实现方式就是定义一个state_flag,然后用if-else进行状态的判断与切换。但是这种方式的可读性和扩展性会很差,会凸显加重FSM的缺点。
设计模式中的 状态模式 将面向状态(此时状态为对象)编程进行了完美诠释。
下图为状态模式的UML图[2]

每个状态用一个类进行实现,每个类覆写父类中的方法,在不同的状态下有不同的响应。
State虚类的定义示例如下:
cpp
class State
{
public:
/**
* @brief State 类的构造函数
*
* 初始化 State 对象,设置状态名称、状态 ID 和父状态。
*
* @param [in]state_name 状态名称
* @param [in]state_id 状态 ID
* @param [in/out]node_config 节点配置信息,用于在状态之间传递数据
* @param [in]parent_state 父状态对象的共享指针
*/
State(const std::string &state_name, uint64_t state_id, NodeConfiguration &node_config, std::shared_ptr<State> parent_state = nullptr);
~State() override = default;
/**
* @brief 更新状态
*
* 在状态机中更新当前状态,并处理传入的事件。
* 如果有子状态,则更新子状态。
* 会被周期性执行。每次被周期性执行时,event有可能会不同。
*
* @param [in]event 事件对象
*
* @return 如果状态更新成功,则返回 true;否则返回 false
*/
bool onUpdate(const event_pulse::Event &event);
/**
* @brief 状态进入时的处理函数
*
* 当状态机进入当前状态时,调用该函数进行状态进入时的处理。
* 如果有子状态,则进入子状态。
* @param [in]event 事件对象
*/
void onEntry(const event_pulse::Event &event);
/**
* @brief 状态退出时的处理函数
*
* 当状态机退出当前状态时,调用该函数进行状态退出时的处理。
*
* @param [in]event 事件对象
*/
void onExit(const event_pulse::Event &event);
/**
* @brief 更新状态
*
* 根据传入的事件更新当前状态。会被周期性的执行。每次被周期性执行时,event有可能会不同。
*
* @param [in]event 事件对象
*
* @return 如果状态更新成功则返回true,否则返回false(在此示例中始终返回true)
*/
virtual bool stateUpdate(const event_pulse::Event &event);
/**
* @brief 进入状态
*
* 当状态机进入当前状态时,会调用此方法。进入状态时,只被执行一次。
*
* @param [in]event 事件对象
*
* @return 如果状态进入成功则返回true,否则返回false(在此示例中始终返回true)
*/
virtual bool stateEntry(const event_pulse::Event &event);
/**
* @brief 退出状态
*
* 当状态机从当前状态退出时,会调用此方法。退出状态时,只被执行一次。
*
* @param [in]event 事件对象
*
* @return 如果状态退出成功则返回true,否则返回false(在此示例中始终返回true)
*/
virtual bool stateExit(const event_pulse::Event &event);
...
}
状态模式表现出的"多态":在不同状态下对同一事件会产生不同的响应。也就是说事件的生成和处理是可以分离的(类似生产者-消费者模式)。
如下图所示,在状态模式的基础上引入 事件驱动 的编程范式。

在每个状态内会对相应的事件做对应的处理。处理分为两层含义:对于任意事件可以在状态内部写一个Process方法对接收的事件进行处理;对于 导致状态迁移的事件 除了Process处理,还会进行状态迁移。
需要注意的是,为了防止 状态死锁 的产生,不允许状态处理自己产生的事件。
4.4 状态机的迁移
4.4.1 事件优先级
(1) 导致状态迁移事件的优先级
为了防止状态冲突产生,需要对 导致状态迁移的事件 设置优先级,并放入优先级队列。
每个周期内选取优先级队列中最高优先级的事件,并触发状态切换,然后清空该队列(这个操作体现了马尔科夫性)。
(2) 常规事件的优先级
在每个状态内需要响应产生的所有事件,而且必须在一个周期内完成(如果完不成就需要把Process拆分成异步任务,类似PCIe中的Split总线传输方式)。
一般来说一个周期(tick)是非常短的(例如100ms),但是为了更快速的响应高优事件(比如刹车事件),能提前10ms也是值得的,因此,需要对所有事件设置优先级,以便优先处理高优事件。
Tips:导致状态迁移的事件其实是同时有两个"身份"的,会被同时进入状态迁移事件的优先级队列和常规事件的优先级队列。
4.4.2 一个周期两次迁移
从事件产生的来源划分,可以将事件划分为外部事件和内部事件。这样定义,不光是为了清晰,而是可以更方便实现"状态不可以处理自己产生的内部事件"约束,以防止 状态死锁 的产生。
因为有了上述约束,内部产生的事件必须放在下一个状态中进行处理。例如下述的事件处理代码中,步骤3一定要排在步骤2后面进行。至于步骤4可以省略,省略之后的效果就是3步骤产生的状态切换事件导致的状态切换发生在下一个周期;添加步骤4,则可保证更优的更及时的状态切换。
此外,探讨步骤1和2(步骤3和4)之间顺序是否可以颠倒的问题------先进行事件处理(Action),再进行状态切换(Transition)更符合思维习惯,防止出现目标状态中未定义对事件的处理(该顺序不是强制性的)。
C++
bool EventManager::ProcessEvents(const std::shared_ptr<StateContext> &stateContext) {
//0.process abnormal events
ProcessAbNormalEvent(stateContext); //对异常事件的特殊处理(和状态机不相关)
//1.process normal event (outer)
ProcessNormalEvent(stateContext, normal_outer_event_queue_); // 在当前状态处理外部事件
//2.process state-switch event (outer & inner events)
SwitchState(stateContext); // 根据外部事件和步骤1产生的内部事件进行状态切换(切换时会执行当前状态的Exit函数和目标状态的Entry函数)
//3.process normal event (inner events)
ProcessNormalEvent(stateContext, normal_inner_event_queue_); // 在当前状态(有可能已经切换过一次状态)处理步骤2产生的内部事件
//4.process state-switch event (inner events)
SwitchState(stateContext); // 根据第3步产生的内部事件进行状态切换
return true;
}
4.4.3 支持子状态间跳转
示例代码如下。
C++
bool StateContext::DoStateSwitch(const std::shared_ptr<State> &target_state, const event_pulse::Event &event) {
std::shared_ptr<State> state = root_state_;
if (target_state->getParent()) {// 处理目标状态有父状态的情况
// switch in current state branch(处理目标状态与当前状态同属一个父状态的情况;需要注意的是目前不支持目标状态与当前状态同属一个族系,但是父状态不同的情况------例如"叔叔状态"切换到"侄子状态"暂不支持。因为设置超过2层的HFSM逻辑会异常复杂,不建议这么使用。)
std::shared_ptr<State> in_state = root_state_;
do {
if (in_state == target_state->getParent()) {
if (in_state->getChild()) {
in_state->getChild()->onExit(event);
}
in_state->setChild(target_state);
target_state->onEntry(event);
return true;
}
in_state = in_state->getChild();
} while (in_state);
// switch skipping state branch(处理目标状态与当前状态不同属于一个族系的情况)
std::shared_ptr<State> tar_child = target_state;
std::shared_ptr<State> tar_parent = tar_child->getParent();
//tar_parent->setChild(tar_child);
while (true) {
if (tar_parent) {
tar_parent->setChild(tar_child);
} else {
root_state_->onExit(event);
root_state_ = tar_child;
root_state_->onEntry(event);
return true;
}
tar_child = tar_parent;
tar_parent = tar_child->getParent();
}
} else { //处理目标状态没有父状态的情况
state->onExit(event);
root_state_ = target_state;
root_state_->setChild(nullptr);
root_state_->setParent(nullptr);
root_state_->onEntry(event);
}
return true;
}
4.4.4 子状态继承父状态的动作
如下述代码所示,HFSM中的父子关系和C++中的继承有一点点区别------C++中的子类可以"单独行动",但是HFSM中的子状态必须和父状态一起行动。
C++
void State::onEntry(const event_pulse::Event &event) {
if (!stateEntry(event)) { //进入状态时先执行父状态的动作
//std::cerr << "State entry failed!"; GT-s20587
}
if (child_state_) {// 再执行子状态的动作。这里有一个隐含的技巧:父状态执行过的动作,子状态不用再重复实现------相当于继承了父状态的动作(因此,对于所有的子状态来说,公共动作可以抽取出来放到父状态中实现)
child_state_->onEntry(event);
}
}
bool State::onUpdate(const event_pulse::Event &event) {
stateUpdate(event);
if (child_state_) {
if (!child_state_->onUpdate(event)) {
AWARN << "child state onUpdate Failed!";
return false;
}
}
return true;
}
void State::onExit(const event_pulse::Event &event) {
if (child_state_) {
child_state_->onExit(event);
}
setChild(nullptr);
if (!stateExit(event)) {
//std::cerr << "State exit failed!"; GT-s20587
}
}
参考文献
- Behavior Trees in Robotics and AI.
- 大话设计模式。