持续任务与中止
让导航任务跨越多次 tick
对应 BehaviorTree.CPP 3.8 官方文档:Reactive and Asynchronous behaviors。实现细节参考 3.8.7 的 action_node.cpp、reactive_sequence.cpp 和 bt_factory.h。
前两篇实践分别完成了最小行为树和节点间的数据传递。 如果还没有运行,可以先完成它们,再进入本篇。 本篇仍使用模拟数据,集中解决一个问题:导航还没结束时,节点怎样立即返回,后面怎样继续检查,以及外部条件变化后怎样中止它。
先明确本篇的模拟规则:程序用计数器表示导航进度,约定检查三次就算任务完成。 目标暂时用 loading_area 这样的字符串表示。
这段程序没有控制机器人移动,也没有读取机器人的实际位置;日志中的 arrived 表示满足了我们设定的模拟完成条件。
先用这个简单规则看清行为树的调用过程,后面再说明真实导航应替换哪一部分。
本例中的 ticks="3" 表示执行三次 onRunning(),不是三秒,也不是主程序只调用三次 tickRoot()。
开始任务还要单独调用一次 onStart(),所以正常完成需要四次根 tick,后面会逐次展开。
ReactiveSequence
├── NavigationAllowed 每次根 tick 都重新检查是否允许导航
└── NavigateToGoal 开始一次任务,后续 tick 检查进度
├── 未完成:RUNNING
├── 已到达:SUCCESS
└── 被中止:执行 onHalted()
用三个回调管理一个持续任务
SyncActionNode 用于一次调用就能给出结果的动作,不能用它返回 RUNNING。
持续任务使用 StatefulActionNode,实现下面三个回调:
| 回调 | 调用时机 | 本例做什么 | 返回值 |
|---|---|---|---|
onStart() | 节点从 IDLE 开始执行 | 读取目标和所需次数,将计数清零 | RUNNING、SUCCESS 或 FAILURE |
onRunning() | 当前节点为 RUNNING,再次收到 tick | 将计数加一,判断是否达到所需次数 | RUNNING、SUCCESS 或 FAILURE |
onHalted() | 正在 RUNNING 的节点被中止 | 打印中止信息,清零模拟计数 | void |
三个回调都没有函数参数,由 StatefulActionNode 在对应时机调用,不需要在 main() 中直接调用。
它们的声明分别是 BT::NodeStatus onStart() override、BT::NodeStatus onRunning() override 和 void onHalted() override;override 表示实现基类规定的虚函数。
回调通过当前节点的 getInput() 读取输入,通过对象成员保存执行进度。
onStart() 返回 RUNNING 后,下一次 tick 才进入 onRunning();运行期间不会每次都重新调用 onStart()。
两个有返回值的回调都不能返回 IDLE。
StatefulActionNode 本身不会自动创建工作线程。
本例的回调都在主程序驱动行为树的线程里执行,所以每次回调要尽快返回。
真实导航可以在 onStart() 中发送一次请求,在 onRunning() 中检查请求结果,在 onHalted() 中发起取消。
从主程序的一次 tick 找到回调调用者
主程序驱动行为树时,调用的是:
result = tree.tickRoot();
tickRoot() 没有参数,返回这一次根 tick 的 BT::NodeStatus。
本例中,条件允许导航时,调用会沿着下面的路径传递:
main() 调用 tree.tickRoot()
↓
ReactiveSequence 从第一个子节点开始执行
↓
NavigationAllowed 检查是否允许导航
↓ 返回 SUCCESS
轮到 NavigateToGoal 节点
↓
库中 StatefulActionNode 的 tick() 检查导航节点的当前状态
├── IDLE → 调用 onStart()
└── RUNNING → 调用 onRunning()
↓
回调返回的状态沿父节点传回 main() 的 result
NavigateToGoal 继承 BT::StatefulActionNode,因此直接使用了基类已经写好的这套分派规则。
我们实现 onStart()、onRunning() 和 onHalted(),决定每个时机具体做什么。
基类调用这些虚函数时,会执行当前 NavigateToGoal 对象提供的实现。
声明中的 override 用于让编译器检查是否正确覆盖了基类虚函数;实际调用由库的执行流程发起,不是由这个关键字发起。
因此,第一次执行导航节点时,节点状态为 IDLE,进入 onStart();它返回 RUNNING 后,框架保存这个状态。
下一次根 tick 再次执行到导航节点,基类看到它仍为 RUNNING,才进入 onRunning()。
这些规则对应 3.8.7 StatefulActionNode 的实现。
onStart 准备一次任务,返回后把执行权交回去
先看本篇稍后要填写的导航 XML:
<NavigateToGoal goal="loading_area" ticks="3"/>
端口 goal 接收目标字符串,端口 ticks 接收模拟完成所需的运行检查次数。
onStart() 通过 getInput<std::string>("goal") 和 getInput<int>("ticks") 读取并检查它们,然后执行下面的关键语句。
这里先逐段理解,后面的完整程序再统一复制到工程中:
goal_ = goal.value(); // 保存本次目标:loading_area
required_ticks_ = ticks.value(); // 保存完成条件:检查三次
completed_ticks_ = 0; // 开始新任务,目前检查了零次
return BT::NodeStatus::RUNNING; // 报告任务已开始,还没有完成
| 成员变量 | 本次保存的值 | 含义 |
|---|---|---|
goal_ | "loading_area" | 本次导航目标 |
required_ticks_ | 3 | 达到多少次运行检查就算完成 |
completed_ticks_ | 0 | 已经执行过多少次运行检查 |
return RUNNING 会结束这一次函数调用,并把执行权交回父节点,最终回到主程序。
它报告的是“任务尚未完成”;函数返回后,没有一个 onStart() 还在后台继续执行。
这几个变量是节点对象的成员,对象仍然存在,所以保存的目标和计数可以留到下一次回调使用。
onRunning 每次加一,由节点自己判断是否成功
下面只保留 onRunning() 中与计数相关的语句,便于看清完成条件:
++completed_ticks_; // 每进入一次 onRunning,就把已检查次数加一
if (completed_ticks_ >= required_ticks_)
{
return BT::NodeStatus::SUCCESS; // 达到约定次数,报告模拟任务完成
}
return BT::NodeStatus::RUNNING; // 次数还不够,等待后续 tick 再检查
++completed_ticks_ 相当于把原来的计数加一,再保存回这个成员变量。
第一次进入 onRunning() 时,计数从 0 变成 1。接着判断 1 >= 3,条件不成立,就跳过大括号,执行最后的 return RUNNING。
第二次进入时,计数从 1 变成 2,仍然返回 RUNNING。
第三次进入时,计数从 2 变成 3,3 >= 3 成立,执行大括号里的 return SUCCESS,本次函数就结束了,不会再执行下面的 return RUNNING。
这里之所以返回成功,是因为我们编写节点时,把“计数达到三次”定义成了模拟任务的完成条件。
BehaviorTree.CPP 接收节点报告的状态,再按树的规则决定后续流程;它不会根据目标字符串自行判断机器人是否到达。
完整程序中的 std::cout 只负责打印日志,打印 arrived 本身不会产生导航行为。
把四次根 tick 连起来看
先保持条件始终允许导航。主程序每次调用 tree.tickRoot(),都会先检查 NavigationAllowed,再执行导航节点:
| 根 tick | 导航回调 | 计数怎样变化 | 完成条件 | 本次根节点返回 |
|---|---|---|---|---|
| 第一次 | onStart() | 初始化为 0 | 本次只准备任务 | RUNNING |
| 第二次 | onRunning() | 0 → 1 | 1 >= 3,不成立 | RUNNING |
| 第三次 | onRunning() | 1 → 2 | 2 >= 3,不成立 | RUNNING |
| 第四次 | onRunning() | 2 → 3 | 3 >= 3,成立 | SUCCESS |
前面三次根 tick 返回 RUNNING,主程序等待一下,再发起下一次 tick。
第四次导航返回 SUCCESS,而前面的条件也已成功,ReactiveSequence 因此返回 SUCCESS;主程序发现任务完成,退出这次循环。
一次启动,加上三次运行检查,共四次根 tick。 本例两次 tick 之间等待 100 毫秒,实际执行时间还受打印、调试暂停和系统调度影响,计数不代表固定时长。
onHalted 处理运行中的任务被中断
onHalted() 走的是另一条分支。当父节点要求停止导航,或主程序调用 tree.haltTree() 时,中止沿树传到导航节点。
基类用于处理中止的 StatefulActionNode::halt() 没有参数,返回 void;它发现导航节点仍为 RUNNING,才调用 onHalted()。
本例在回调里面打印中止信息,并把计数清零。
正常完成:
onStart() → onRunning() → onRunning() → onRunning()
RUNNING RUNNING RUNNING SUCCESS
中途中止:
onStart() → onRunning() → 收到中止要求 → onHalted()
RUNNING RUNNING
正常返回 SUCCESS 不会因此触发 onHalted();它不是每次任务结束都执行的公共收尾函数。
onHalted() 返回 void,负责取消和清理,不向父节点返回 SUCCESS 或 FAILURE。后面的条件中止示例中,树的 FAILURE 来自前置条件失败及控制节点的执行规则。
如果接入真实导航,需要在这个回调里实现对应的取消或停止逻辑。
沿用配置建立持续任务工程
本篇直接复制整个 bt_ports 文件夹,再把副本重命名为 bt_running,保留原来的数据传递工程供后续对照。
在系统文件管理器中完成下面几步:
- 打开
bt_ports所在的父目录,复制并粘贴整个文件夹,将副本重命名为bt_running,让两个工程位于同一层目录。 - 在新副本
bt_running中,删除复制过来的build和install文件夹(如果存在)。其中的构建缓存和安装结果属于旧工程,新工程稍后会重新生成。 - 在 VSCode 中选择 文件 → 新建窗口,再选择 文件 → 打开文件夹,打开新的
bt_running。
使用的是前两篇已经准备好的 BT 练习版配置,框架来源见 CMake工程模板。Ubuntu 22.04 可以继续使用 CMake 3.22.1;Ninja、支持 C++20 的编译器和 VSCode CMake Tools 的准备方式与前两篇相同。复用 Humble 安装的行为树库时,配置、运行和调试进程仍需继承 ROS 2 环境。
cmake、src、trees 目录及其中的文件都已随工程复制过来,不需要重新建立。
稍后直接修改顶层项目名,并用本篇内容替换已有的 src/main.cpp 和 trees/mission.xml。参与本篇构建的目录为:
bt_running/
├── CMakeLists.txt
├── CMakePresets.json
├── cmake/
│ ├── ProjectOptions.cmake
│ └── BehaviorTree.cmake
├── src/
│ ├── CMakeLists.txt
│ └── main.cpp
└── trees/
└── mission.xml
顶层 CMakeLists.txt 的项目名改为:
project(bt_running VERSION 1.0.0 LANGUAGES C CXX)
其他构建配置保持原样,BT_XML_PATH 会自动指向这个工程的 trees/mission.xml。

src/main.cpp
用下面的完整程序替换 src/main.cpp 的原有内容,再保存。
先保留 cancel_after_two_ticks = false,运行正常完成的情况。
#include "behaviortree_cpp_v3/bt_factory.h"
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>
class NavigationAllowed : public BT::ConditionNode
{
public:
NavigationAllowed(const std::string& name, const BT::NodeConfiguration& config)
: BT::ConditionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<bool>("allowed")};
}
BT::NodeStatus tick() override
{
auto allowed = getInput<bool>("allowed");
if (!allowed)
{
throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
}
std::cout << "[NavigationAllowed] allowed="
<< (allowed.value() ? "true" : "false") << '\n';
return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
}
};
class NavigateToGoal : public BT::StatefulActionNode
{
public:
NavigateToGoal(const std::string& name, const BT::NodeConfiguration& config)
: BT::StatefulActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<std::string>("goal"),
BT::InputPort<int>("ticks", 3, "完成导航所需的 onRunning 调用次数")};
}
BT::NodeStatus onStart() override
{
auto goal = getInput<std::string>("goal");
auto ticks = getInput<int>("ticks");
if (!goal)
{
throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
}
if (!ticks)
{
throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
}
if (goal.value().empty() || ticks.value() < 1)
{
throw std::runtime_error("goal must not be empty and ticks must be positive");
}
goal_ = goal.value(); // 保存本次目标,供后续回调使用
required_ticks_ = ticks.value(); // 保存模拟完成所需的检查次数,本例为 3
completed_ticks_ = 0; // 每次开始新任务,都从零次检查开始
// 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
// 结束本次回调并报告“尚未完成”,后续 tick 再进入 onRunning。
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus onRunning() override
{
// 每次进入这个回调,模拟检查次数就加一;函数返回后成员计数仍会保留。
++completed_ticks_;
std::cout << "[NavigateToGoal] progress=" << completed_ticks_
<< '/' << required_ticks_ << '\n';
// 本例把“检查次数达到要求”当作完成条件,没有查询机器人的真实位置。
if (completed_ticks_ >= required_ticks_)
{
std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
// 已满足模拟完成条件,向父节点报告成功,本次函数到这里结束。
return BT::NodeStatus::SUCCESS;
}
// 尚未达到要求,结束本次检查,等待后续 tick 再进入这个回调。
return BT::NodeStatus::RUNNING;
}
void onHalted() override
{
// 运行中的任务收到中止要求时,基类才会调用这个回调。
// 真实导航在这里取消请求、停止当前任务并清理资源。
std::cout << "[NavigateToGoal] halted goal=" << goal_
<< ", progress=" << completed_ticks_ << '\n';
completed_ticks_ = 0;
}
private:
std::string goal_; // 本次目标
int required_ticks_ = 0; // 模拟完成所需的运行检查次数,由 onStart 读取
int completed_ticks_ = 0; // 已完成的运行检查次数,可跨多次回调保存
};
int main()
{
// 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。(用于测试onHalted()触发)
const bool cancel_after_two_ticks = false;
try
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
factory.registerNodeType<NavigateToGoal>("NavigateToGoal");
auto blackboard = BT::Blackboard::create();
blackboard->set("navigation_allowed", true);
auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);
BT::NodeStatus result = BT::NodeStatus::RUNNING;
int tick_count = 0;
while (result == BT::NodeStatus::RUNNING)
{
std::cout << "--- tick " << ++tick_count << '\n';
// 从根驱动一次;执行到导航节点时,由基类按当前状态选择回调。
result = tree.tickRoot();
std::cout << "Root returned: " << BT::toStr(result) << '\n';
if (result != BT::NodeStatus::RUNNING)
{
break;
}
if (cancel_after_two_ticks && tick_count == 2)
{
blackboard->set("navigation_allowed", false);
std::cout << "[main] navigation is no longer allowed\n";
}
// 等待放在主循环中,节点回调本身不长时间阻塞。
tree.sleep(std::chrono::milliseconds(100));
}
std::cout << "Tree result: " << BT::toStr(result) << '\n';
return result == BT::NodeStatus::SUCCESS ? 0 : 1;
}
catch (const std::exception& error)
{
std::cerr << "Error: " << error.what() << '\n';
return 2;
}
}
goal_、required_ticks_、completed_ticks_ 是节点对象的成员,能够跨越多次回调保存当前任务的数据。
main() 中的 tick_count 则统计根节点执行次数,两者含义不同。
tree.sleep() 的参数与作用
前一篇的 tickRootWhileRunning() 在库内部等待并重复 tick。
本篇需要在两次 tick 之间模拟条件变化,因此把循环写在 main() 中,并直接使用 BT::Tree 的等待接口:
void sleep(std::chrono::system_clock::duration timeout);
作用:让驱动树的当前线程等待一段时间,为下一次 tick 留出间隔。唯一的参数 timeout 是最长等待时长;std::chrono::milliseconds(100) 构造一个表示 100 毫秒 的时长对象,可以传给这个参数。这个等待也可以被库的唤醒通知提前结束。
返回值:void。tree.sleep() 本身不会执行节点,也不会创建导航线程;等待结束后,主程序仍需调用 tree.tickRoot()。后者没有参数,返回这一次根 tick 的 BT::NodeStatus。本例在结果为 RUNNING 时继续循环,在成功或失败时退出。
其余注册、端口和 Blackboard 接口沿用前两篇的含义。例如 blackboard->set("navigation_allowed", true) 中,第一个参数是键名,第二个参数是布尔值,模板类型由 true 推导为 bool;InputPort<int>("ticks", 3, "...") 的三个参数依次是端口名、默认值和说明。
trees/mission.xml
用下面的 XML 替换 trees/mission.xml 的原有内容,再保存。
<root main_tree_to_execute="MainTree">
<BehaviorTree ID="MainTree">
<ReactiveSequence>
<NavigationAllowed allowed="{navigation_allowed}"/>
<NavigateToGoal goal="loading_area" ticks="3"/>
</ReactiveSequence>
</BehaviorTree>
</root>
普通节点端口仍然使用前一篇的写法:条件节点从父程序写入的 {navigation_allowed} 读取布尔值,导航节点从固定文本读取目标和次数。
先观察一次完整的导航
保存全部文件,在 CMake Tools 中选择 Linux Debug,执行 Configure → Build,将运行目标设为 bt_running,再点击 Launch。本例的完整输出为:
--- tick 1
[NavigationAllowed] allowed=true
[NavigateToGoal] start goal=loading_area
Root returned: RUNNING
--- tick 2
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=1/3
Root returned: RUNNING
--- tick 3
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=2/3
Root returned: RUNNING
--- tick 4
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=3/3
[NavigateToGoal] arrived goal=loading_area
Root returned: SUCCESS
Tree result: SUCCESS

这组输出对应前面的四次根 tick 表:第一次只有 start,表示进入 onStart();后面三次出现 progress,分别表示进入三次 onRunning()。
最后一次先打印 progress=3/3,随后因为计数达到要求,打印 arrived 并返回 SUCCESS,所以主程序最后显示 Tree result: SUCCESS。
这次完成的是我们约定的模拟任务,整个过程中不会调用 onHalted()。
主程序在 RUNNING 时继续 tick,在 SUCCESS 或 FAILURE 时结束本次任务,并用 tree.sleep() 留出等待间隔。等待放在主循环里,节点检查本身保持简短;把几秒钟的等待直接塞进 onRunning(),会同时拖慢整棵树对条件变化的响应。
3.8.7 的 tickRoot() 在返回终态时还会重置根节点状态,因此应保存它的返回值作为本次任务结果。稍后查看 rootNode()->status() 时可能已经是 IDLE,不能据此否定刚刚返回的 SUCCESS 或 FAILURE。
条件改变后中止正在运行的导航
在 main() 中将已有的设置改为:
const bool cancel_after_two_ticks = true;
保存后 Build → Launch。主程序会在第二次根 tick 结束后,将 navigation_allowed 改成 false,模拟外部系统不再允许继续导航。输出为:
--- tick 1
[NavigationAllowed] allowed=true
[NavigateToGoal] start goal=loading_area
Root returned: RUNNING
--- tick 2
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=1/3
Root returned: RUNNING
[main] navigation is no longer allowed
--- tick 3
[NavigationAllowed] allowed=false
[NavigateToGoal] halted goal=loading_area, progress=1
Root returned: FAILURE
Tree result: FAILURE
第三次根 tick 先检查 NavigationAllowed。条件失败后,ReactiveSequence 中止还在运行的导航,触发 onHalted(),并将失败结果返回给主程序。此时导航不会再收到第三次根 tick 对应的运行检查,所以打印的进度仍是 1。
这次中止的先后关系为:
第二次根 tick 结束:导航仍为 RUNNING,已检查一次
↓
main() 将 Blackboard 中的 navigation_allowed 改为 false
↓ 此时只是更新数据
第三次根 tick:NavigationAllowed 读取新值,返回 FAILURE
↓
ReactiveSequence 中止正在运行的导航
↓
StatefulActionNode::halt() 调用 NavigateToGoal::onHalted()
↓
ReactiveSequence 返回 FAILURE,传回 main()
因此,写入 false 本身没有直接调用 onHalted();本例要等下一次根 tick 重新检查条件,才会触发中止。
onHalted() 中的日志报告被中断时的进度,随后清零计数;它没有返回 FAILURE,失败结果由前置条件和父控制节点决定。

本例的取消只是清理模拟计数。对真实机器人,onHalted() 中还需要实现对应的取消请求或停止逻辑,行为树本身不会替你完成设备控制。
对照 Sequence 怎样处理同一个变化
保持取消设置为 true,将 XML 中的 <ReactiveSequence> 和 </ReactiveSequence> 分别改为 <Sequence> 和 </Sequence>。
保存 XML 后直接 Launch,无需手动重新编译 C++。

这次虽然主程序仍会打印 navigation is no longer allowed,导航却继续执行到成功。
在本次任务的 RUNNING 期间,普通 Sequence 从正在运行的导航继续,不会每次都重新检查已经成功的前置条件。
| 本例中的行为 | Sequence | ReactiveSequence |
|---|---|---|
| 正常导航期间检查条件 | 开始时一次 | 每次根 tick |
| 第二次根 tick 后将条件改成 false | 导航继续完成 | 下一次根 tick 中止导航 |
是否调用导航的 onHalted() | 否 | 是 |
| 这次任务的结果 | SUCCESS | FAILURE |

这个差别发生在一次尚未结束的任务内。
普通 Sequence 结束并重置后,下一次任务仍会从前面的条件重新开始。
- Sequence:直接继续导航,跳过前面已经成功的条件。
- ReactiveSequence:从第一个子节点重新执行,再检查条件;如果条件变成 FAILURE,就中止正在运行的导航。
ReactiveSequence它每次都从第一个子节点开始执行,前一个成功了,才继续下一个。 比如前面放的不是条件检查,而是一个打印日志的动作:
ReactiveSequence
├── PrintMessage 打印“准备导航”,然后返回 SUCCESS
└── NavigateToGoal 执行导航,未完成时返回 RUNNING
第一次 tick:打印“准备导航” → 启动导航
第二次 tick:又打印“准备导航” → 检查导航进度
第三次 tick:又打印“准备导航” → 检查导航进度
……
完成对照后,将 XML 恢复为 ReactiveSequence,再进行后面的观察。
主程序主动退出时先停止树
前面的中止由条件失败触发。
主程序也可以主动决定停止当前任务,并调用 tree.haltTree(),让中止沿树传播。
保持 cancel_after_two_ticks = true,将 main() 中修改 navigation_allowed 的整个 if 代码块替换为:
if (cancel_after_two_ticks && tick_count == 2)
{
std::cout << "[main] stop requested\n";
tree.haltTree();
std::cout << "Tree halted: " << BT::toStr(tree.rootNode()->status()) << '\n';
return 3;
}
#include "behaviortree_cpp_v3/bt_factory.h"
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>
class NavigationAllowed : public BT::ConditionNode
{
public:
NavigationAllowed(const std::string& name, const BT::NodeConfiguration& config)
: BT::ConditionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<bool>("allowed")};
}
BT::NodeStatus tick() override
{
auto allowed = getInput<bool>("allowed");
if (!allowed)
{
throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
}
std::cout << "[NavigationAllowed] allowed="
<< (allowed.value() ? "true" : "false") << '\n';
return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
}
};
class NavigateToGoal : public BT::StatefulActionNode
{
public:
NavigateToGoal(const std::string& name, const BT::NodeConfiguration& config)
: BT::StatefulActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<std::string>("goal"),
BT::InputPort<int>("ticks", 3, "完成导航所需的 onRunning 调用次数")};
}
BT::NodeStatus onStart() override
{
auto goal = getInput<std::string>("goal");
auto ticks = getInput<int>("ticks");
if (!goal)
{
throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
}
if (!ticks)
{
throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
}
if (goal.value().empty() || ticks.value() < 1)
{
throw std::runtime_error("goal must not be empty and ticks must be positive");
}
goal_ = goal.value();
required_ticks_ = ticks.value();
completed_ticks_ = 0;
// 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus onRunning() override
{
// 每次只检查一步,立即把执行权交回行为树。
++completed_ticks_;
std::cout << "[NavigateToGoal] progress=" << completed_ticks_
<< '/' << required_ticks_ << '\n';
if (completed_ticks_ >= required_ticks_)
{
std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
return BT::NodeStatus::SUCCESS;
}
return BT::NodeStatus::RUNNING;
}
void onHalted() override
{
// 真实导航在这里取消请求、停止当前任务并清理资源。
std::cout << "[NavigateToGoal] halted goal=" << goal_
<< ", progress=" << completed_ticks_ << '\n';
completed_ticks_ = 0;
}
private:
std::string goal_;
int required_ticks_ = 0;
int completed_ticks_ = 0;
};
int main()
{
// 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。
const bool cancel_after_two_ticks = true;
try
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
factory.registerNodeType<NavigateToGoal>("NavigateToGoal");
auto blackboard = BT::Blackboard::create();
blackboard->set("navigation_allowed", true);
auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);
BT::NodeStatus result = BT::NodeStatus::RUNNING;
int tick_count = 0;
while (result == BT::NodeStatus::RUNNING)
{
std::cout << "--- tick " << ++tick_count << '\n';
result = tree.tickRoot();
std::cout << "Root returned: " << BT::toStr(result) << '\n';
if (result != BT::NodeStatus::RUNNING)
{
break;
}
if (cancel_after_two_ticks && tick_count == 2)
{
// blackboard->set("navigation_allowed", false);
// std::cout << "[main] navigation is no longer allowed\n";
std::cout << "[main] stop requested\n";
tree.haltTree();
std::cout << "Tree halted: " << BT::toStr(tree.rootNode()->status()) << '\n';
return 3;
}
// 等待放在主循环中,节点回调本身不长时间阻塞。
tree.sleep(std::chrono::milliseconds(100));
}
std::cout << "Tree result: " << BT::toStr(result) << '\n';
return result == BT::NodeStatus::SUCCESS ? 0 : 1;
}
catch (const std::exception& error)
{
std::cerr << "Error: " << error.what() << '\n';
return 2;
}
}
保存后 Build → Launch。程序会在第二次根 tick 后直接停止树,输出末尾为:
[main] stop requested
[NavigateToGoal] halted goal=loading_area, progress=1
Tree halted: IDLE

这次没有再执行第三次根 tick,也没有让条件返回失败。主程序主动停止任务,并打印停止后的根状态 IDLE。示例用退出码 3 表示主动停止;正常成功为 0,任务失败为 1,异常报告为 2。
haltTree() 会重置节点状态,但不会自动清空 Blackboard,也不会替你重置所有 C++ 成员。
本例在 onStart() 初始化本次进度,在 onHalted() 清理中断时的进度,分别明确处理下一次任务的起点。
停止树与查看状态的 API
上面的停止代码使用了三个无参数成员函数:
| 接口 | 所属对象 | 返回值与作用 |
|---|---|---|
haltTree() | BT::Tree | 返回 void,停止当前树并将节点状态重置为 IDLE;运行中的 StatefulActionNode 会进入 onHalted() |
rootNode() | BT::Tree | 返回 BT::TreeNode*,指向根节点;空树会返回 nullptr,本例已经成功创建树,因此可以访问根节点 |
status() | BT::TreeNode | 返回 BT::NodeStatus,读取节点当前保存的状态,不执行 tick |
tree.rootNode()->status() 先取得根节点指针,再读取状态,最后由 BT::toStr(...) 转成字符串用于打印。这里在 haltTree() 后读取,所以得到 IDLE。注意,3.8.7 的 tickRoot() 在返回 SUCCESS 或 FAILURE 时也会把根节点保存的状态重置为 IDLE;判断一次 tick 的执行结果,应使用 tickRoot() 的返回值。
ReactiveSequence ← 根节点,rootNode() 指向它
├── NavigationAllowed ← 它的第一个子节点
└── NavigateToGoal ← 它的第二个子节点

在 VSCode 中观察三个回调
先观察正常完成时怎样从启动进入运行检查
恢复完整程序中原来的条件更新代码块,将 cancel_after_two_ticks 改为 false,
XML 使用 ReactiveSequence。
#include "behaviortree_cpp_v3/bt_factory.h"
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>
class NavigationAllowed : public BT::ConditionNode
{
public:
NavigationAllowed(const std::string& name, const BT::NodeConfiguration& config)
: BT::ConditionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<bool>("allowed")};
}
BT::NodeStatus tick() override
{
auto allowed = getInput<bool>("allowed");
if (!allowed)
{
throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
}
std::cout << "[NavigationAllowed] allowed="
<< (allowed.value() ? "true" : "false") << '\n';
return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
}
};
class NavigateToGoal : public BT::StatefulActionNode
{
public:
NavigateToGoal(const std::string& name, const BT::NodeConfiguration& config)
: BT::StatefulActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<std::string>("goal"),
BT::InputPort<int>("ticks", 3, "完成导航所需的 onRunning 调用次数")};
}
BT::NodeStatus onStart() override
{
auto goal = getInput<std::string>("goal");
auto ticks = getInput<int>("ticks");
if (!goal)
{
throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
}
if (!ticks)
{
throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
}
if (goal.value().empty() || ticks.value() < 1)
{
throw std::runtime_error("goal must not be empty and ticks must be positive");
}
goal_ = goal.value();
required_ticks_ = ticks.value();
completed_ticks_ = 0;
// 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus onRunning() override
{
// 每次只检查一步,立即把执行权交回行为树。
++completed_ticks_;
std::cout << "[NavigateToGoal] progress=" << completed_ticks_
<< '/' << required_ticks_ << '\n';
if (completed_ticks_ >= required_ticks_)
{
std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
return BT::NodeStatus::SUCCESS;
}
return BT::NodeStatus::RUNNING;
}
void onHalted() override
{
// 真实导航在这里取消请求、停止当前任务并清理资源。
std::cout << "[NavigateToGoal] halted goal=" << goal_
<< ", progress=" << completed_ticks_ << '\n';
completed_ticks_ = 0;
}
private:
std::string goal_;
int required_ticks_ = 0;
int completed_ticks_ = 0;
};
int main()
{
// 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。
const bool cancel_after_two_ticks = false;
try
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
factory.registerNodeType<NavigateToGoal>("NavigateToGoal");
auto blackboard = BT::Blackboard::create();
blackboard->set("navigation_allowed", true);
auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);
BT::NodeStatus result = BT::NodeStatus::RUNNING;
int tick_count = 0;
while (result == BT::NodeStatus::RUNNING)
{
std::cout << "--- tick " << ++tick_count << '\n';
result = tree.tickRoot();
std::cout << "Root returned: " << BT::toStr(result) << '\n';
if (result != BT::NodeStatus::RUNNING)
{
break;
}
if (cancel_after_two_ticks && tick_count == 2)
{
blackboard->set("navigation_allowed", false);
std::cout << "[main] navigation is no longer allowed\n";
return 3;
}
// 等待放在主循环中,节点回调本身不长时间阻塞。
tree.sleep(std::chrono::milliseconds(100));
}
std::cout << "Tree result: " << BT::toStr(result) << '\n';
return result == BT::NodeStatus::SUCCESS ? 0 : 1;
}
catch (const std::exception& error)
{
std::cerr << "Error: " << error.what() << '\n';
return 2;
}
}
选择 linux-debug,先 Build,然后在下面几行设置断点,点击 CMake Tools 的 Debug:
| 断点位置 | 暂停时看什么 | 对应前面的讲解 |
|---|---|---|
onStart() 中打印 start 的语句 | goal_ 为 "loading_area",required_ticks_ 为 3,completed_ticks_ 为 0 | 第一次根 tick 只准备任务 |
onRunning() 中的 ++completed_ticks_ | 第一次暂停时计数为 0,按 F10 执行这一行后变成 1 | 每次运行检查只把计数加一 |
onRunning() 中的 if (completed_ticks_ >= required_ticks_) | 三次到这里时,计数依次为 1、2、3,要求始终为 3 | 前两次条件不成立,第三次成立 |
断点暂停在某一行时,先看执行这一行之前的值;F10 表示单步执行,F5 表示继续运行到下一个断点。
同一次 onRunning() 中,先执行加一,再到达 if 判断,所以在这两个位置看到的计数会不同。
第一次和第二次到达 if 时,按 F10 会跳过成功分支,执行最后的 return RUNNING。
第三次到达 if 时,计数已经为 3;继续单步会进入打印 arrived 和 return SUCCESS 的分支。
它返回后,主程序拿到本次根 tick 的 SUCCESS,结束循环。
在回调断点暂停时,还可以展开 VSCode 的 调用堆栈(CALL STACK),查看是谁调用了当前回调。
从当前回调沿调用者向外看,可以对应到导航节点的执行、父控制节点,以及 main() 中的 tree.tickRoot(),帮助理解回调是怎样被库调用的。



再观察运行中的任务怎样被中止
结束这次调试,将 cancel_after_two_ticks 改为 true,保存后 Build。
保留 onStart() 和 onRunning() 的断点,并在 onHalted() 打印中止信息的语句处增加断点,再次点击 Debug。
这次先进入 onStart(),随后只进入一次 onRunning(),将计数加到 1。
第二次根 tick 结束后,主程序把允许标志改为 false;继续运行,第三次根 tick 会因为条件失败而进入 onHalted(),此时仍能看到目标为 loading_area、计数为 1。
在清零语句上单步后,计数变成 0。这次执行不会再走到 3 >= 3 的成功分支。
| 执行过程 | 实际观察到的回调 |
|---|---|
| 关闭取消 | onStart() 一次,onRunning() 三次,最后返回 SUCCESS |
| 开启取消 | onStart() 一次,onRunning() 一次,随后进入 onHalted(),本次根 tick 返回 FAILURE |

要观察本篇的取消逻辑,使用条件变化或 haltTree() 的对照。
VSCode 的“停止调试”控制的是调试进程,不能用它来保证程序一定执行到 onHalted()。
全部观察完成后,恢复最初的完整程序和 XML,再将取消设置改回 false。
本篇完整程序及正常完成、条件中止、普通 Sequence 对照、主动停止、无效次数和断点观察,已在 Ubuntu 22.04 的 ysu_ros_humble 容器中,使用 C++20、CMake 3.22.1、GCC 11.4、BehaviorTree.CPP 3.8.7 验证。
测试使用临时工程和 Ninja,没有修改容器内已有练习工程。
接入真实导航时替换什么
本篇用 completed_ticks_ >= required_ticks_ 作为成功条件,是为了让回调过程容易观察。
接入真实导航时,应把这个计数模拟换成导航系统的请求和结果检查:
| 回调 | 真实导航中对应的工作 |
|---|---|
onStart() | 发送一次导航请求,保存后续检查需要的任务信息 |
onRunning() | 检查该请求的最新结果;仍在执行就返回 RUNNING,确认到达后返回 SUCCESS,任务失败时返回 FAILURE |
onHalted() | 对仍在执行的请求发起取消,并处理当前任务需要的清理 |
成功条件由节点实现决定,再通过返回值告诉行为树。
因此,理解本篇代码时,要把“库根据状态选择哪个回调”和“节点根据什么条件报告成功”分开看:前者由 StatefulActionNode 提供,后者由我们编写的导航节点实现。
当前学习进度
本篇补齐了持续动作的生命周期、主循环驱动和中止处理。
下一篇组合实际任务复用持续导航,把电量检查、子树数据映射、有限重试和多个 XML 文件组织成一个完整任务。