教程 · 入门 · 阅读约 17 分钟

用有限状态机(FSM)实现机器人行为

为机器人行为设计一台有限状态机:显式的状态迁移、超时机制、恢复状态、非阻塞的更新方式,以及可以测试的安全规则。

机器人的程序常常以一循环的 if 语句开始:跟着线走,除非障碍很近;转到路让开为止;电池电量低就停;传感器超时时不知怎么就恢复了。随着行为变多,同一个条件可能触发互相矛盾的指令。

**有限状态机(FSM)**把当前活跃的行为显式化。任何一瞬间机器人处在一个状态;事件和守卫决定它什么时候转换到另一个状态。这个模式小到 Arduino 放得下,结构化到能被测试。

把状态、事件和动作分开

状态描述机器人在一段时间里做什么:FOLLOW_LINE、AVOID、SEARCH 或 FAULT。事件是某个被观察到的东西:障碍进入停车区、线丢了、某个超时到期、操作员按了停车。动作是输出:设定轮速、清掉一个积分器、或者拉起一个警报。

不要为瞬时的事实创建状态。OBSTACLE_DETECTED 通常是一个事件或守卫;AVOIDING_OBSTACLE 才是一个状态,因为机器人在执行某个行为期间它持续存在。

写代码之前先画转换表:

当前状态 守卫或事件 下一状态 进入动作
空闲 收到启动请求且传感器有效 循迹 重置控制器
循迹 障碍进入停车阈值内 避障 停掉前进驱动
循迹 线丢失超过 200 ms 搜索 记住最后一次在的哪一侧
避障 路径连续 500 ms 畅通 循迹 重置控制器
任何活跃状态 传感器超时 故障 停电机
故障 操作员复位且健康检查有效 空闲 清故障

这张表把缺失的情况暴露出来,也让安全优先级变得可见。

状态图,四个方框排成一个环:DRIVE、STOP、SCAN 和 TURN。箭头依次连接,每条都标注了引发该次转换的条件:从 DRIVE 到 STOP 是障碍物距离小于二十厘米,从 STOP 到 SCAN 是已停住,从 SCAN 到 TURN 是扫描完成,从 TURN 回到 DRIVE 是已到达目标航向。
同一时刻只有一个状态生效,每次转换都有一个命名的条件。另一种做法是一堆嵌套条件分支,没人能就它回答任何问题。 下载 SVG

保持更新循环非阻塞

一个状态应当做一点小更新然后返回。AVOID 里一个两秒的 delay() 会让程序在这两秒里既看不到停车请求,也看不到传感器故障。

enum class State { Idle, Follow, Avoid, Search, Fault };

State state = State::Idle;
unsigned long enteredAt = 0;

void transitionTo(State next) {
  state = next;
  enteredAt = millis();
}

void loop() {
  Sensors s = readSensors();

  if (!s.healthy && state != State::Fault) transitionTo(State::Fault);

  switch (state) {
    case State::Idle:   updateIdle(s); break;
    case State::Follow: updateFollow(s); break;
    case State::Avoid:  updateAvoid(s); break;
    case State::Search: updateSearch(s); break;
    case State::Fault:  updateFault(s); break;
  }
}

每次更新都读取已经抓好的传感器快照,并写出一条自洽的执行器指令。这避免了同一个循环周期里不同状态在不同时刻读到不同数值。

把转换放在清晰的边界上

选一种归属规则。简单的嵌入式设计是让每个状态自己提出它的对外转换,而顶层的安全检查可以覆盖任何活跃状态。

void updateFollow(const Sensors& s) {
  if (s.obstacleMm < stopThresholdMm) {
    setWheels(0, 0);
    transitionTo(State::Avoid);
    return;
  }

  if (s.lineMissingForMs > 200) {
    transitionTo(State::Search);
    return;
  }

  followLine(s.lineError);
}

转换之后立刻返回,这样旧状态就不会又去下发执行器指令。进入某个控制状态时,要重置那些不该跨行为泄漏的历史量,比如 PID 的积分项或微分采样。

退出动作要省着用。大多数清理工作可以放在集中的 transitionTo() 函数里,或者下一个状态的进入路径上。只有当好几个状态确实都需要时,把 onEntry、onUpdate 和 onExit 拆成独立函数才划算。

用超时和迟滞当守卫

物理事件是嘈杂的。如果 AVOID 在距离一超过让它进来的那个阈值就立刻退出,测量噪声会引起状态快速抖动。用两个阈值或一段持续时间:低于 250 mm 进入,但只有距离连续 500 ms 高于 350 mm 才离开。

每个指望有进展的状态都需要一个超时。如果机器人进了 SEARCH 却永远找不到线,它不该永远转下去。经过一段测试过的时长就转到 FAULT 或者一个有界恢复。

定时器要么基于进入以来的时间,要么基于上次有进展以来的时间。用 now - enteredAt >= timeout 这样的减法比较,只要类型一致,它在无符号定时器回绕时依然正确。

让安全转换拥有优先权

先定义清楚什么能打断一切:急停、关键传感器数据无效、电机驱动器故障,或者看门狗超时。这些条件要在行为专属的转换之前求值,并把安全输出显式地做出来。

FAULT 状态应当在自动恢复可能重新引发危险运动时自锁。要求原因清除之后由操作员复位,通常比直接跳回 FOLLOW 更安全。

不要让两个独立的状态机在没有仲裁规则的情况下写同一份电机指令。如果导航和电池管理都影响运动,就该由一个归属方选出最终指令并施加安全限值。

不开电机也能测转换

把决策搬进接受传感器快照和时间的函数,然后返回一个下一状态或指令。喂进一串合成的快照:

  1. 有效的线加上畅通的路径,停在 FOLLOW。
  2. 一次嘈杂的近距离采样,在要求持续性的前提下不触发。
  3. 持续近距的障碍进入 AVOID,并且先下发停车指令。
  4. 畅通路径保持超过退出间隔,回到 FOLLOW。
  5. 传感器超时从每一个活跃状态都能到达 FAULT。

把状态变化记成 旧 → 新、触发它的事件和时间戳。每轮都记的连续日志会把转换淹没在噪声里;只记转换的日志则解释了行为为什么变。

知道 FSM 什么时候不再是最简模型

当机器人只有数量不多、互斥的模式时,FSM 很好用。当状态为了表达“一边导航一边监控电池一边重试定位”这类组合而成倍增加时,它就变得难做了。转换的数量可能比行为本身长得还快。

行为树用顺序、选择和恢复这些结构来组合可复用的动作,Nav2 这类导航系统为复杂任务采用它们就是出于这个原因。不要为四个状态引入一整套行为树框架。等到转换表真的难以评审,或者行为需要分层复用时再迁。

常见设计失败的诊断

  • **转弯时机器人不听停车:**某个状态里含阻塞循环或延时。
  • **电机每周期收到两条指令:**转换代码在改变状态之后还继续执行。
  • **机器抖个不停:**守卫太嘈杂,又缺迟滞或持续性要求。
  • **恢复永不结束:**那个状态没有进展条件,也没有超时。
  • **故障一发生就立刻重新启动运动:**安全故障没有自锁,或者复位条件太弱。
  • **测试必须整机进行:**决策和硬件访问混在一起,而不是使用传感器快照和指令输出。

避障模拟器提供了一段很适合建模的行为序列:巡航、绕开威胁、抵达,或者在没有任何安全动作可做时停车。迷宫求解模拟器展示了感知、地图更新、规划和单格移动之间的另一种分层。迷你相扑车是上面那条自锁安全规则最锋利的检验:它的边缘反射必须能从任何状态抢先于其他所有状态生效,否则机器人会自己开出擂台。

探索关联图谱

本教程所属的搭建

包含本教程的项目和学习路线。

延伸阅读

参考资料