本节摘要:主回路是飞行器的心脏。本节拆开闭环的三个阶段,看读数、航线、舵面指令三样数据在接缝上怎么流动;给出频率分层与时间预算的配比算法——回路卡顿的头号病因就在节拍失配;最后对比反应式、分层式、混合式三种布线流派的适用空域与代价,交付一个能空转的主循环骨架。
为什么自动驾驶团队要把感知、决策、控制拆成不同小组,而不是让一个天才模块包打天下?因为这三个阶段的天然节拍差了好几个数量级:摄像头的读数以几十毫秒一帧的速度涌进来,路径规划得出一条能用好几秒的航线,而方向盘的控制量每几毫秒就要刷新一拍。把它们塞进同一个循环里用同一个频率跑,快的等慢的、慢的被快的淹没——飞行器还没离开地面,回路先把自己勒死了。上一节钉好的舱段图纸,在本节要回答的就是"接线"问题:谁以什么频率、把什么数据、交给谁。
把主回路展开,是三段各司其职的接力。感知段把物理世界的光、声、距离变成结构化读数——读数的契约要求带时间戳与置信度,因为规划舱必须知道这份情报有多新、多可信;规划段把读数加目标变成航线——航线不是单个动作,而是一段带优先级的动作序列,外加"何时重算"的触发条件;行动段把航线当前步拆成执行器指令,并负责回执——指令是否送达、动作是否生效、环境是否如预期改变。
三段接力中最容易被忽视的是回执与再感知的接缝。很多实现里行动执行完就完事了,下一拍感知被动等新读数——这在快速变化的环境里等于闭着眼睛飞。正确的设计是行动段主动触发一次定向感知("我刚动了舵,雷达看我偏了多少"),把回执变成下一拍读数的一部分。闭环由此才算真正闭上:感知喂规划,规划喂行动,行动改变环境,变化回到感知。
主循环的第一设计决策不是算法,是频率配比。经验配比按信息变化速度倒排:控制层最快,本地规划次之,全局规划与感知融合再次之。每个舱段在自己的节拍窗口里有时间预算,超预算的处理策略必须预先声明——丢帧、降级还是排队,三种策略对应三种故障后果,混着用是最常见的翻车源。
import time TICK_BUDGET = { "perception": 0.020, # 感知融合:20 毫秒窗口 "planning": 0.100, # 规划:100 毫秒窗口 "action": 0.005, # 行动指令下发:5 毫秒窗口 } class MainLoop: def __init__(self, agent, watchdog): self.agent = agent self.watchdog = watchdog # 超时监控器 def run(self, world, max_ticks=None): tick = 0 while max_ticks is None or tick < max_ticks: t0 = time.monotonic() # 感知拍:读数进状态板 reading = self._run_stage( "perception", self.agent.perception.sense, world) self.agent.board.update_belief(reading) # 规划拍:航线可用就不重算(预算留给触发式重规划) if self.agent.board.route_expired() or \ self.agent.board.goal_changed(): route = self._run_stage( "planning", self.agent.planning.plan, self.agent.board) self.agent.board.load_route(route) # 行动拍:取航线当前步下发,回执写回状态板 receipt = self._run_stage( "action", self.agent.action.execute, self.agent.board.current_action()) self.agent.board.log_receipt(receipt) self._sleep_remaining(t0) # 补齐到节拍点,避免空转挤占 tick += 1 def _run_stage(self, name, fn, *args): t0 = time.monotonic() try: result = fn(*args) except Exception as exc: self.watchdog.report(name, exc) # 单舱段故障不上交主循环 return self.agent.board.last_good(name) # 退回上一帧有效值 spent = time.monotonic() - t0 if spent > TICK_BUDGET[name]: self.watchdog.report_overrun(name, spent) # 超预算留痕 return result
这段骨架有三个刻意的设计值得画重点。其一,规划不必每拍执行——航线没过期、目标没变就跳过,把预算省给感知与行动,这是多数新手实现跑不动的直接原因。其二,单舱段故障不上交:感知一帧失败就退回上一帧有效读数(last-good),主循环照转,飞行器绝不因为一帧雷达噪声而全机熄火。其三,超时必须留痕——watchdog 报告是第 7 章调试的主要线索来源。
同一套舱段,接线方式决定了飞行器的性格。工程史上沉淀出三种流派,各自的脾气与适用空域差异极大。
反应式把感知近乎直连行动:读数进来,规则表查一下,舵面就动。特点是没有显式规划舱——或者说规划被压缩成了规则查表。 Brooks 的包容架构是这一流派的鼻祖,用一层层简单行为(避障优先于前进)叠加出复杂表象。它赢在响应极快、几乎不会死机,输在没有远见:不会绕开死胡同,因为它不知道"死胡同"这个概念。
分层式把规划舱拆成多层:任务层定"去哪",路径层定"怎么走",动作层定"此刻怎么动舵",上层给下层下子目标,下层给上层报状态。特点是有远见、可解释,但层间延迟叠加,对突发障碍的响应取决于最底层规则是否兜底。
混合式把两种接在一起:规划层慢速出航线,反应层高速兜底,反应层平时服从航线、遇险时越权接管。经典的"三层"( deliberative–sequencing–reactive)架构就是这一路。它两头的好处都要,代价是两套逻辑的仲裁与一致性维护——什么时候允许越权、越权后规划层如何重新对齐,是混合式真正的工程难点。
| 维度 | 反应式 | 分层式 | 混合式 |
|---|---|---|---|
| 响应速度 | 最快(毫秒级查表) | 慢(层间传递) | 快(反应层兜底) |
| 远见能力 | 无 | 强 | 强 |
| 实现复杂度 | 低 | 中 | 高 |
| 典型失效 | 死循环、局部陷阱 | 层间失配、响应迟钝 | 仲裁冲突、状态打架 |
| 适用空域 | 动态、局部、无长程目标 | 静态、结构化、任务长 | 真实开放环境 |
选型代码上可以抽象成一个策略接线板,方便在同一批舱段上做布线实验:
class ReactiveWiring: """反应式:读数直连动作,规则表仲裁。""" def next_action(self, board): for rule in self.rules_by_priority: # 避障 > 靠近 > 巡航 if rule.triggered(board.belief): return rule.action(board) return self.default_action class LayeredWiring: """分层式:任务层 -> 路径层 -> 动作层逐级下发。""" def next_action(self, board): subgoal = self.task_layer.refine(board) # 慢拍 path = self.path_layer.refine(subgoal) # 中拍 return self.motor_layer.refine(path) # 快拍 class HybridWiring: """混合式:航线为主,反应层可越权。""" def next_action(self, board): route_cmd = self.layered.next_action(board) if self.reflex.danger(board.belief): # 高速险情检测 return self.reflex.override(board) # 越权接管并上报 return route_cmd
💡 关键直觉:三种流派不是进化关系,是空域适配关系。结构化仓库格路用反应式足够且最可靠;开放道路必须混合式。为静态空域上混合式,是在用复杂度买用不上的能力。
主回路的联调遵循"先断后通":先把三段接缝逐段断开做开环测试——只跑感知拍看状态板更新,只灌假航线看行动拍执行;确认各段独立健康后再闭环。闭环首跑用慢动作模式:把节拍窗口放大一个数量级,人工盯着状态板走完一圈,确认读数、航线、回执的时序对齐。
闭环特有的故障模式与处置:
回路转起来了,但驾驶舱里的仪表还没定义读法——状态板上该有哪些字段、目标怎么记、行为怎么仲裁,是下一节"飞行状态板"的事。