4、状态机设计模式:层次状态机(HSM)、并发状态、历史状态、守卫条件,以及常用设计模式(状态模式、状态表驱动)
在ROS机器人系统中,状态机是行为决策的核心骨架。从简单的顺序执行到复杂的多任务协同,状态机的设计质量直接决定了系统的可维护性、可扩展性和鲁棒性。本章将深入剖析层次状态机(HSM)、并发状态、历史状态与守卫条件这四大高级特性,并结合状态模式与状态表驱动两种工程实现范式,帮助你在实际机器人项目中做出合理的设计决策。我们将从原理出发,通过对比、案例与误区分析,建立一套完整的状态机设计方法论。
4.1 层次状态机(HSM)
核心原理
层次状态机(Hierarchical State Machine)通过引入父状态与子状态的概念,将扁平状态机中爆炸式增长的状态转移关系进行结构化压缩。其核心思想是“共性上提,特性下沉”:父状态封装所有子状态共享的行为与转移逻辑,子状态仅关注自身特有的响应。在ROS机器人中,一个典型的应用是导航任务:父状态“导航”包含“直线行驶”“转弯”“避障”等子状态,而“紧急停止”逻辑则定义在父状态层,任何子状态下触发急停信号都会统一响应,无需在每个子状态中重复编写转移条件。
判断方法与操作流程
判断是否需要引入HSM,可依据以下三条准则:
- 状态数量膨胀:若扁平状态数量超过15个,且存在大量重复的转移条件,则考虑层次化。
- 共享行为明显:多个状态对同一事件(如急停、低电量)有相同响应。
- 嵌套生命周期:存在“进入某大状态后必须经历一系列子状态”的强顺序逻辑。
实施HSM的推荐流程:
- 梳理所有状态与事件,绘制扁平状态图。
- 识别可归并的公共行为,提取为父状态。
- 定义父子状态间的进入(entry)与退出(exit)动作。
- 使用状态转移表验证:子状态未处理的事件是否被父状态正确捕获。
案例拆解:移动机器人自主充电任务
假设机器人需要从任意位置自主返回充电桩。扁平设计需要处理“寻找充电桩”“对接”“充电中”“充电完成”等状态,且每个状态都要响应“电量极低”“碰撞”“任务取消”等事件,状态数会迅速膨胀至20+。采用HSM后,设计如下:
| 层级 | 状态 | 职责 |
|---|---|---|
| 父状态 | 充电任务 | 管理整体流程,响应急停、电量极低(强制进入对接) |
| 子状态1 | 寻找充电桩 | 使用SLAM导航至充电桩附近 |
| 子状态2 | 对接 | 使用红外/视觉进行精确对接 |
| 子状态3 | 充电中 | 监控电流电压,等待充满 |
当机器人在“寻找充电桩”时突然检测到前方障碍物,子状态不处理该事件,事件自动上抛至父状态“充电任务”,父状态统一调用避障子状态机,待避障完成后返回原子状态。这一设计将状态转移从N×M的复杂度降为N+M,极大简化了维护成本。
smach库的Concurrence容器或flexbe框架的状态机嵌套功能。
4.2 并发状态与历史状态
核心原理
并发状态(Concurrent State)允许系统同时处于多个正交区域的状态中。例如,机器人导航时,底盘控制与机械臂操作可以并行运行,互不干扰。在UML状态图中,并发状态用虚线分割区域表示。历史状态(History State)则是一种记忆机制,它记录子状态机退出时的最后一个活动子状态,当重新进入父状态时,可以直接恢复到上次中断的位置,而非从初始子状态重新开始。
操作流程与判断方法
使用并发状态前,必须验证两个区域之间不存在共享变量竞争或事件依赖。若区域A的转移需要等待区域B的状态结果,则不应使用并发,而应通过显式的事件同步机制(如ROS中的actionlib或std_srvs::Trigger)进行通信。
历史状态的使用场景非常明确:当父状态被临时抢占(如紧急避障),避障结束后希望恢复之前的子状态(如“直线行驶”而非“初始校准”)。在smach中,可以通过smach.StateMachine的remap机制保存和恢复内部状态值,但更优雅的方式是使用smach.Concurrence配合outcomes实现类似效果。
案例拆解:仓库巡检机器人
巡检机器人需要同时执行“路径巡逻”和“图像采集”两个并发任务。设计如下:
- 区域1(导航):包含“沿路径行驶”“避障”“等待指令”三个子状态。
- 区域2(感知):包含“摄像头扫描”“图像上传”“空闲”三个子状态。
当机器人遇到障碍物时,导航区域进入“避障”,感知区域继续运行不受影响。避障结束后,导航区域通过历史状态恢复到“沿路径行驶”,而感知区域可能正处于“图像上传”中,两者互不阻塞。实际实现时,两个区域分别运行在独立的ROS节点或独立线程中,通过actionlib进行任务级同步。
4.3 守卫条件(Guard Conditions)
核心原理
守卫条件是状态转移的“门禁”。一个转移只有在守卫条件评估为真时才会触发。守卫条件通常基于外部输入(传感器数据)、内部变量(任务计数器)或时间条件(超时标志)。在ROS中,守卫条件常通过回调函数或条件变量实现,例如:
def guard_battery_low(userdata):
return userdata.battery_level < 20.0
守卫条件与事件的区别在于:事件是“发生了什么”,守卫是“是否允许响应”。两者必须同时满足,转移才有效。
判断方法与设计原则
- 单一职责:每个守卫条件只判断一个逻辑命题,避免复合条件难以复用。
- 无副作用:守卫条件内禁止修改状态或发送命令,只做只读判断。
- 可测试性:守卫条件应设计为纯函数,便于单元测试。
实战场景:多条件融合的导航决策
考虑机器人通过路口时的决策:
| 事件 | 守卫条件 | 动作 |
|---|---|---|
| 到达路口 | 前方无车 && 绿灯亮 | 直行 |
| 到达路口 | 前方有车 || 红灯亮 | 停车等待 |
| 到达路口 | 任务优先级为高 | 鸣笛并谨慎通过 |
在smach中,守卫条件通过StateMachine.add_transition的conditions参数指定。注意守卫条件的评估顺序:应优先评估计算代价低的条件,避免频繁调用昂贵的传感器融合算法。
functools.lru_cache缓存短时间内的重复计算结果,减少ROS话题订阅的实时计算压力。
4.4 状态模式与状态表驱动
核心原理
状态模式(State Pattern)是面向对象设计模式的一种,它将每个状态封装为一个独立的类,状态间的转移通过切换类实例实现。该模式适合状态行为复杂、需要多态扩展的场景。状态表驱动(State Table-Driven)则是一种数据驱动的方法,将状态转移关系存储在二维表或字典中,通过查表执行转移。它适合状态数量多、转移规则频繁调整的场景。
对比分析
| 维度 | 状态模式 | 状态表驱动 |
|---|---|---|
| 代码结构 | 每个状态一个类,行为内聚 | 转移逻辑集中在表中 |
| 扩展性 | 新增状态需新增类,符合开闭原则 | 新增状态只需添加表项 |
| 可读性 | 状态行为直观,但类数量多 | 表结构清晰,但行为逻辑分散 |
| 适用场景 | 状态行为差异大,需要独立方法 | 状态行为相似,转移规则复杂 |
| ROS集成 | 适合与actionlib结合 | 适合与dynamic_reconfigure结合 |
案例拆解:ROS导航状态机实现
以move_base的全局规划状态机为例,使用状态表驱动实现:
# 状态表定义
state_table = {
('IDLE', 'start'): ('PLANNING', 'init_planner'),
('PLANNING', 'plan_success'): ('EXECUTING', 'send_goal'),
('PLANNING', 'plan_fail'): ('RECOVERY', 'clear_costmap'),
('EXECUTING', 'goal_reached'): ('IDLE', 'stop_robot'),
('EXECUTING', 'plan_aborted'): ('RECOVERY', 'reset_planner'),
('RECOVERY', 'recovery_done'): ('PLANNING', 'replan'),
}
def execute_state(current_state, event):
if (current_state, event) in state_table:
next_state, action = state_table[(current_state, event)]
action()
return next_state
else:
return current_state # 忽略未定义事件
该实现将状态转移逻辑与业务动作解耦,当需要新增“暂停”功能时,只需在表中添加('EXECUTING', 'pause'): ('PAUSED', 'hold_robot'),无需修改任何状态类。相比之下,状态模式则需要为每个状态类添加on_pause()方法。
常见误区与复盘
- 误区一:滥用HSM导致过度设计。对于少于8个状态的简单顺序流程,扁平状态机更直观。
- 误区二:并发状态间直接共享可变数据。应通过ROS消息传递或加锁机制保证线程安全。
- 误区三:守卫条件内执行耗时操作。例如在守卫中调用
rospy.wait_for_service,会阻塞状态机主循环。 - 误区四:状态表驱动中忽略默认转移。必须定义
('*', 'unknown_event')的兜底策略,防止静默失败。
rqt中可视化状态机时,清晰的层次结构和明确的守卫条件远比花哨的架构重要。