第 12.4.2.3 节

第一个完整程序

作者:董佳辉

从节点、XML 到第一个完整程序

对应 BehaviorTree.CPP 3.8 官方文档:Learn the Basic Concepts → Main Concepts、The XML schema,以及 Tutorial - Basics 中的 Your first Behavior Tree 和 Blackboard and ports。

前面已经能读懂节点与控制规则。接下来把它们变成可运行的程序,同时补齐 C++、XML、Ports 和 Blackboard 的基础关系。

本篇前半部分完成阶段 1 的概念学习,后半部分进入阶段 2 的编程实践。 实践部分在同一个独立 C++ 工程中逐步增加功能:先运行一个最小动作,再加入电量检查和目标传递。实际导航任务留到异步动作章节实现。


C++ 实现节点,XML 组织节点

可以把一个程序分成三部分:

部分负责什么例子
C++ 节点实现具体检查或操作,声明输入输出接口检查电量、选择目标、打印目标
XML描述树的结构,配置节点参数和数据连接把三个节点放进 Sequence
外部程序注册节点、创建树、驱动执行BehaviorTreeFactory、tickRoot()

例如:

C++ 注册可用的节点类型
    ↓
工厂读取 XML,创建具体节点实例并连接成树
    ↓
外部 tick 根节点
    ↓
按树的规则调用各节点的回调

“注册节点”是告诉工厂:遇到某个类型标识时,怎样创建对应的 C++ 节点。它不会立即执行任务。

例如 factory.registerNodeType<PrintGoal>("PrintGoal") 将字符串 PrintGoal 与 C++ 类关联;之后 XML 中每出现一次 <PrintGoal/>,工厂就可以创建一个实例。注册类型和创建实例是两个步骤,同一类型可以创建多个实例。

普通自定义节点通常通过继承实现,例如继承 ConditionNode、SyncActionNode。简单检查也可以通过 registerSimpleCondition() 包装函数或 lambda,简单同步动作可以用 registerSimpleAction()。

树通常在程序初始化时创建一次,后续反复 tick,而不是每轮重新解析 XML。调整已有节点的排列或配置后,可以重新运行程序加载新的 XML;新增 C++ 节点实现时,仍要重新编译。


用 XML 描述一棵完整的树

下面是本篇逐步扩展后的 XML,用来认识文件结构。实践部分会先运行更小的树,最后再得到这个版本:

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence name="mission_sequence">
            <CheckBattery name="check_battery"/>
            <SelectGoal name="select_goal" goal="{mission_goal}"/>
            <PrintGoal name="print_goal" goal="{mission_goal}"/>
        </Sequence>
    </BehaviorTree>
</root>

它对应:

Sequence
├── CheckBattery
├── SelectGoal      输出选定的目标
└── PrintGoal       读取并打印该目标

分清 XML 中几种不同的标识

写法含义
<root>XML 文档的外层容器,不是可执行的行为树节点
main_tree_to_execute="MainTree"指定默认执行哪棵树
<BehaviorTree ID="MainTree">定义一棵名叫 MainTree 的树
<Sequence>这棵树实际的根节点
<PrintGoal>注册到工厂的节点类型标识,必须能找到对应实现
name="print_goal"当前节点实例的名称,便于日志和调试,可省略
goal="{mission_goal}"把节点的 goal 端口连接到 Blackboard 的 mission_goal 条目

因此,树的 ID、节点类型的注册标识、实例的 name 和数据条目的名称,各自有不同用途。

一个 XML 文件可以定义多棵 <BehaviorTree>;每棵树下面应有一个实际根节点。 只有一棵树时,可以省略 main_tree_to_execute; 有多棵树、需要从文件直接确定默认入口时,应明确填写。

XML 中的父子关系仍遵守前面的结构约束:控制节点包含一个或多个子节点,装饰节点包含一个,条件和动作节点没有子节点。


Ports 与 Blackboard:让数据在节点之间流动

执行状态和业务数据走的是不同的通道:

SUCCESS / FAILURE / RUNNING   告诉父节点任务的执行情况
Ports + Blackboard           传递目标、路径、观测值等业务数据

节点的输入输出端口类似函数接口; Blackboard 则用键值条目保存数据:

概念本例中的作用
输出端口 OutputPortSelectGoal 通过 goal 端口写出目标
Blackboard 条目使用键 mission_goal 保存目标值
输入端口 InputPortPrintGoal 通过自己的 goal 端口读取目标

连接过程是:

SelectGoal 的输出端口 goal
    ↓ setOutput()
Blackboard["mission_goal"] = "loading_area"
    ↓ getInput()
PrintGoal 的输入端口 goal

两个端口恰好都叫 goal,但真正把它们连接起来的是 XML 中相同的 {mission_goal}。 生产者和消费者的端口名称可以不同,只要它们映射到同一个条目。

固定值与 Blackboard 引用

下面两种输入配置含义不同:

<PrintGoal goal="loading_area"/>
<PrintGoal goal="{mission_goal}"/>

第一种直接提供固定字符串; 第二种读取 Blackboard 中键为 mission_goal 的值。

花括号表达条目引用,不是 C++ 指针,也不会自动创建有效的业务数据。

读取前必须有人写入该值,例如前面的输出节点,或者由外部程序初始化 Blackboard。引用了一个从未写入的条目,输入读取就会失败。

接口由 C++ 声明,连接由 XML 配置

节点用静态方法 providedPorts() 声明端口的名称、方向和 C++ 类型。 XML 则给端口配置固定值,或把它映射到 Blackboard。

输入使用 getInput<T>(),输出使用 setOutput()。 读取可能因为缺少数据、类型或转换问题而失败,节点需要检查结果;本篇选择在配置错误时抛出异常,由主程序报告。

应在执行回调中读取输入,让后续执行能取得当前值。不要只在构造函数中读取一次,再假定这个值永远不变。

Blackboard 可以保存多种 C++ 类型,并不限于字符串;从 XML 文本转换自定义类型、双向端口以及子树的数据映射,会在后面的数据传递实践中学习。


认识 XML 中的几种补充写法

简写与显式写法

下面两种写法指向同一种动作节点:

<PrintGoal name="print_goal" goal="loading_area"/>
<Action ID="PrintGoal" name="print_goal" goal="loading_area"/>

前者直接把注册标识写作标签名; 后者用 Action 指明分类,再用 ID 指定注册标识。 本教程以简写为主。

XML 中还可能出现 <TreeNodeModel>,为 Groot 等工具提供自定义节点的分类和端口描述。 它是模型信息,不是执行分支,也不会替代 C++ 节点注册。 运行本篇示例时不需要额外填写它。

用 SubTree 引用另一棵树

同一个 XML 中可以定义并复用一段行为:

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence>
            <CheckBattery/>
            <SubTree ID="ReportTask"/>
        </Sequence>
    </BehaviorTree>

    <BehaviorTree ID="ReportTask">
        <PrintGoal goal="loading_area"/>
    </BehaviorTree>
</root>

这里先检查电量,再执行 ReportTask。 <SubTree ID="ReportTask"/> 引用的是树定义,不是普通 C++ 动作类;工厂在创建树时展开并建立相应结构。

所以这个 XML 标签本身虽然没有嵌套内容,运行时仍连接到所引用的子树。子树如何传数据,需要明确考虑其 Blackboard 范围,后面再讲。

用 include 拆分文件

也可以在 <root> 中写:

<include path="report_task.xml"/>

它把其他 XML 中的树定义引入当前文件,再通过 SubTree 使用。 相对路径以包含它的 XML 文件位置为基准;引入定义不会自动执行一遍那棵树。

另一种方式是在 C++ 中用 registerBehaviorTreeFromFile() 加载各个文件的树定义,再用 createTree("MainTree") 创建选定的树。 当前先掌握单文件示例,多文件组织留到任务组合时实践。


入门总结

读完以上概念部分,加上前面的入门、基本概念和控制规则,主线已经覆盖:

官方部分已建立的认识
Introduction to BTs树结构、tick、状态、节点分类和基本执行规则
Main Concepts可复用节点、回调、运行时创建树,以及 Ports/Blackboard
The XML schema树与节点标识、端口映射、简写与显式写法、子树及文件引用

Learn the Basic Concepts 到此结束。 从下面的完整工程开始,进入基础编程实践。 后面通过例子深化端口、异步和子树,不需要现在把所有接口一次记住。


从最小 demo 开始搭建工程

先把“注册一个动作 → 读取 XML → tick 执行”跑通,再逐步加功能。 本篇只使用一个工程目录,按下面的顺序修改同一组文件:

实践内容本次新增什么能观察到什么
最小 demo一个打印信息的动作C++ 动作被 XML 调用
电量检查一个条件和 Sequence电量不足时,后面的动作被跳过
固定目标一个带输入端口的节点类从 XML 读取目标字符串
目标传递一个输出节点和 Blackboard 映射前面的节点产生目标,后面的节点读取

这些例子都使用模拟数据,不需要启动 ROS 2 或连接机器人。先运行最小版本,后续每完成一处修改,再编译或运行对应版本。

沿用 C++ 教程中的 CMake 框架

本篇基于 CMake工程模板 建立 BT 练习版, 沿用 CMakePresets.json、公共编译选项和 src 目录。相关文件的作用可以对照阅读:

本篇使用的部分对应 C++ 教程
Debug / Release 预设、Ninja 和编辑器构建CMakePresets与构建安装
顶层入口、C++ 标准和公共选项顶层CMake与公共编译选项
主程序与依赖的链接src与lib模块CMake详解

先准备 Git、支持 C++20 的编译器、CMake 3.22 或更新版本,以及 Ninja; 工具安装见 C++开发环境搭建与测试。 下载模板需要访问 GitHub;行为树依赖优先使用已安装的 3.8.7,只有未找到对应版本时才联网下载。

Ubuntu 22.04 可以使用系统软件源提供的 CMake 3.22.1,当前模板已采用对应的最低版本和预设格式,直接沿用即可。 本篇使用的配置功能已受该版本支持,无需为这些练习升级 CMake。

在容器中开发时,CMake 和 Ninja 都应准备在容器内,VSCode 的 CMake Tools 也应使用容器内的工具。

在 VSCode 中打开工程并准备文件

先准备一份新的模板副本,练习目录命名为 bt_first_tree。

git clone https://github.com/tungchiahui/CMake_Template bt_first_tree
cd bt_first_tree
code .

左侧资源管理器中应直接看到顶层 CMakeLists.txt 和 CMakePresets.json,说明打开的是工程根目录。

在扩展面板确认已安装微软的 CMake Tools 和 C/C++。 本篇通过 CMake Tools 的 Configure、Build、Launch 和 Debug 入口操作工程;断点调试还需要前面 C++ 环境章节准备好的 GDB。

在左侧资源管理器中,右键工程根目录,新建 trees 文件夹;在其中新建 mission.xml。 再在已有的 cmake 文件夹中新建 BehaviorTree.cmake。 后面直接在编辑器中填写这些文件,先完成配置和代码,再执行 Configure。

本篇参与构建和运行的文件如下,模板中的其他演示文件没有列出:

bt_first_tree/
├── CMakeLists.txt
├── CMakePresets.json          沿用 linux-debug / linux-release 预设
├── cmake/
│   ├── ProjectOptions.cmake   公共编译选项
│   └── BehaviorTree.cmake     新增:查找 3.8.7,未安装时获取源码
├── src/
│   ├── CMakeLists.txt         创建主程序、链接依赖、设置 XML 路径
│   └── main.cpp              本篇逐步扩展的 C++ 程序
└── trees/
    └── mission.xml           本篇逐步扩展的树

下面先在编辑器中准备构建配置,再保存最小程序与 XML。 完成后统一通过 CMake Tools 配置、构建和运行。

调整工程名称并沿用模板配置

将顶层 CMakeLists.txt 里的project(cmake_template VERSION 1.0.0 LANGUAGES C CXX)改成project(bt_first_tree VERSION 1.0.0 LANGUAGES C CXX),主要是把项目名改成 bt_first_tree。

当前模板已经使用下列配置,保留原值即可:

文件与位置当前配置
顶层 CMakeLists.txt 的最低版本声明cmake_minimum_required(VERSION 3.22)
CMakePresets.json 顶层的 version3
CMakePresets.json 中的 cmakeMinimumRequiredmajor: 3、minor: 22、patch: 0

其中,预设文件的 version 表示 JSON 格式版本,cmakeMinimumRequired 表示工程要求的 CMake 版本。 当前预设只使用格式 3 已支持的功能,说明见 CMake 3.22 预设文档。 继续保留原有的 linux-debug / linux-release 配置、构建预设和 Ninja 生成器。

cmake/ProjectOptions.cmake 已默认使用 C++20,下面两项同样沿用模板原值;project_options、project_warnings 等其他配置继续保留:

set(CMAKE_CXX_STANDARD 20)
target_compile_features(project_options INTERFACE cxx_std_20)

本篇练习主程序统一使用 C++20。链接现成的行为树库时不重新编译该库;走源码获取分支时,库按自己的构建规则使用 C++14。

集中保存行为树依赖配置

将以下完整内容保存到新增的 cmake/BehaviorTree.cmake。这份配置只准备一次,后续几轮不用反复修改。它先查找已安装的 3.8.7;未找到对应版本时,再获取同一版本的源码:

include_guard(GLOBAL)

# 优先查找当前构建环境中已安装的目标版本
find_package(behaviortree_cpp_v3 3.8.7 EXACT QUIET CONFIG)

if(behaviortree_cpp_v3_FOUND)
    # ROS 包和独立安装的源码包可能导出不同名称的 CMake target
    if(TARGET behaviortree_cpp_v3::behaviortree_cpp_v3)
        set(BTCPP_TARGET behaviortree_cpp_v3::behaviortree_cpp_v3)
    elseif(TARGET BT::behaviortree_cpp_v3)
        set(BTCPP_TARGET BT::behaviortree_cpp_v3)
    else()
        message(FATAL_ERROR "BehaviorTree.CPP 3.8.7 was found without a supported target")
    endif()
    message(STATUS "Using installed BehaviorTree.CPP 3.8.7: ${behaviortree_cpp_v3_DIR}")
else()
    include(FetchContent)

    # 未安装目标版本时,固定获取 3.8.7 源码
    set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
    set(BUILD_UNIT_TESTS OFF CACHE BOOL "" FORCE)
    set(BUILD_TOOLS OFF CACHE BOOL "" FORCE)
    set(BUILD_MANUAL_SELECTOR OFF CACHE BOOL "" FORCE)
    set(ENABLE_COROUTINES OFF CACHE BOOL "" FORCE)

    FetchContent_Declare(
        behaviortree_cpp_v3
        URL https://codeload.github.com/BehaviorTree/BehaviorTree.CPP/tar.gz/refs/tags/3.8.7
    )
    FetchContent_MakeAvailable(behaviortree_cpp_v3)

    # 3.8.7 会创建 sample_nodes,本篇将它排除在默认构建之外
    set_property(DIRECTORY "${behaviortree_cpp_v3_SOURCE_DIR}/sample_nodes"
        PROPERTY EXCLUDE_FROM_ALL TRUE)

    set(BTCPP_TARGET behaviortree_cpp_v3)
    message(STATUS "Building BehaviorTree.CPP 3.8.7 from source")
endif()

在已加载 ROS 2、能找到安装包的 Humble 环境中,Configure 输出会包含 Using installed BehaviorTree.CPP 3.8.7,并显示包配置所在路径。此时直接链接容器里的库,不下载或重新编译它。当前构建环境找不到该版本时,则显示 Building BehaviorTree.CPP 3.8.7 from source。

复用 Humble 安装包时,配置和运行程序都需要 ROS 2 环境。本篇测试在加载 /opt/ros/humble/setup.bash 后完成;VSCode 的 CMake Tools 与运行、调试进程也应继承这份环境。 只在已打开窗口的集成终端中加载环境,不会自动更新扩展进程。 未准备 ROS 2 环境的独立工程可以使用同版本源码分支,程序本身不需要启动 ROS 节点。

BTCPP_TARGET 保存本次应链接的 CMake target,主程序统一使用这个变量。这样两种依赖来源使用同一份 C++ 和 XML,框架也不需要手动切换。

源码分支的构建选项可对照官方 3.8.7 CMakeLists.txt。3.8.7 不再提供 BUILD_SAMPLES 开关,所以本篇直接将 sample_nodes 排除在默认构建之外。关闭协程不影响本篇同步节点,也不影响后面使用 StatefulActionNode。

3.8.7 的源码分支已在 GCC 16 环境中构建运行通过,配置无需额外添加模板兼容选项。

让主程序链接行为树库

将 src/CMakeLists.txt 整个替换为:

include("${PROJECT_SOURCE_DIR}/cmake/BehaviorTree.cmake")

add_executable(${PROJECT_NAME}
    ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp
)

target_link_libraries(${PROJECT_NAME}
    PRIVATE
        project_options
        project_warnings
        ${BTCPP_TARGET}
)

# 将源码目录中的 XML 完整路径作为字符串交给程序
target_compile_definitions(${PROJECT_NAME}
    PRIVATE BT_XML_PATH="${PROJECT_SOURCE_DIR}/trees/mission.xml"
)

模板中的 lib1、lib2 目录可以保留,但这份文件已经不再构建或链接它们;替换主程序后,本篇也不需要 Eigen 示例依赖。

BT_XML_PATH 是这份配置提供的字符串,自动指向当前工程的 trees/mission.xml。后面的 C++ 直接使用它,不需要手写本机绝对路径,也不必随终端或编辑器的运行位置调整相对路径。

保存最小 C++ 程序

将以下完整内容保存到 src/main.cpp,替换模板原来的演示主程序:

#include "behaviortree_cpp_v3/bt_factory.h"

#include <iostream>

// 一个立即完成的动作:打印机器人已就绪
BT::NodeStatus ReportReady(BT::TreeNode&)
{
    std::cout << "[ReportReady] robot is ready\n";
    return BT::NodeStatus::SUCCESS;
}

int main()
{
    BT::BehaviorTreeFactory factory;
    factory.registerSimpleAction("ReportReady", ReportReady);

    // 根据 XML 创建树,再从根节点执行一次
    auto tree = factory.createTreeFromFile(BT_XML_PATH);
    const BT::NodeStatus result = tree.tickRoot();
    std::cout << "Tree result: " << BT::toStr(result) << '\n';

    return result == BT::NodeStatus::SUCCESS ? 0 : 1;
}

ReportReady() 是普通 C++ 函数。

通过 registerSimpleAction() 注册后,框架可以将它作为同步动作调用。 参数 BT::TreeNode& 由框架传入,本例暂时不使用,所以没有为它命名。

这里先保留最小正常路径;等到读取端口输入时,再加入输入检查和异常报告。

注册简单动作与创建树的 API

BT::BehaviorTreeFactory factory; 创建一个节点工厂对象,本例不需要给构造函数传参。 后面的注册操作告诉工厂怎样创建自定义节点;加载 XML 时,工厂再按注册名创建节点实例。

下面的函数声明用于理解接口,不用再次粘贴到工程中。 原型按官方 3.8.7 bt_factory.h 核对。

registerSimpleAction()

函数原型:

void registerSimpleAction(
    const std::string& ID,
    const BT::SimpleActionNode::TickFunctor& tick_functor,
    BT::PortsList ports = {});

作用:把一个回调函数注册成简单同步动作,之后可以在 XML 中使用对应的节点名。

参数类型与含义本例传入
第一个 ID字符串,注册到工厂的节点类型名称,对应 XML 标签"ReportReady",对应 <ReportReady/>
第二个 tick_functor执行该节点时调用的函数,签名为 BT::NodeStatus(BT::TreeNode&)普通函数 ReportReady
第三个 ports可选的端口声明,类型为 BT::PortsList,默认空列表省略,因为这个最小动作不读写端口

第二个参数传的是 ReportReady 函数本身,没有调用括号。注册时先保存回调;树执行到这个动作时,框架才调用它,并传入代表当前节点实例的 BT::TreeNode&。本例不使用这个引用,所以函数定义中只保留类型,没有写形参名。

返回值:void,注册函数本身不返回任务状态;ReportReady() 回调返回的 BT::NodeStatus 才是节点执行结果。这个简单动作属于同步动作,回调应返回 SUCCESS 或 FAILURE,不能返回 RUNNING。注册名重复等注册错误会抛出异常。

createTreeFromFile()

函数原型:

BT::Tree createTreeFromFile(
    const std::string& file_path,
    BT::Blackboard::Ptr blackboard = BT::Blackboard::create());

作用:读取 XML 文件,并按照工厂已有的节点类型和 XML 结构创建一棵树。

参数含义本例用法
第一个 file_path入口 XML 的路径字符串BT_XML_PATH,由 CMake 提供的完整文件路径
第二个 blackboard可选,指定根树使用的 Blackboard本例省略,由默认参数创建;下一篇会从外部传入

返回值:BT::Tree,本例用 auto tree 接收。创建时会构造节点并检查 XML,但还没有开始执行动作的 tick()。文件不存在、XML 错误或使用未注册节点等情况会抛出异常。

tickRoot() 与 BT::toStr()

tree.tickRoot() 不接收参数,返回 BT::NodeStatus。调用一次,就从根节点驱动一次;一次根 tick 可能继续执行多个立即完成的节点,也可能在某个节点返回 RUNNING 时先返回主程序。

BT::toStr(result) 的参数是需要显示的节点状态,返回对应的 std::string,例如 SUCCESS。它只负责把状态变成文本;真正决定程序退出码的仍是后面的状态比较。

保存只有一个动作的 XML

将以下内容保存到 trees/mission.xml:

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <ReportReady/>
    </BehaviorTree>
</root>

这棵树只有一个动作,ReportReady 就是实际根节点,不需要为了凑结构而添加 Sequence。

在 CMake Tools 中配置工程

保存前面填写的所有文件,再打开左侧 CMake 面板。选择 Configure 预设 Linux Debug,对应 CMakePresets.json 中的 linux-debug。

也可以在命令面板中执行 CMake: Select Configure Preset,选择同一个预设。这里使用模板已有的预设,不需要重新创建 CMakePresets.json。

点击 CMake Tools 的 Configure。 这一步读取工程配置并准备 BehaviorTree.CPP 依赖;已安装 3.8.7 时直接使用,否则第一次会下载源码。等待输出中出现配置和生成成功的信息。

构建并选择运行目标

在 CMake 面板中确认 Build 预设为 linux-debug,点击 Build。等待构建完成,确认没有编译或链接错误。

在 CMake Tools 的 Launch 项中选择 bt_first_tree,然后点击旁边的运行按钮。 也可以使用 CMake Tools 提供的状态栏 Launch 入口。 这里启动的是整个 CMake 工程生成的可执行文件。

程序输出会显示在 VSCode 的集成终端中。配置、构建和运行的详细界面说明可对照 CMakePresets与构建安装,运行入口也可参考 CMake Tools 官方说明。

预期输出:

[ReportReady] robot is ready
Tree result: SUCCESS

执行过程是:

注册 ReportReady 对应的函数
    ↓
工厂读取 XML,创建 ReportReady 节点
    ↓
tree.tickRoot()
    ↓
调用 ReportReady(),打印信息并返回 SUCCESS

先看到这两行输出,再往下加功能。 到这里已经完成了一个可以独立运行的行为树程序。

在动作回调中设置断点

想观察函数什么时候被调用,可以在 ReportReady() 内的 std::cout 语句左侧单击,设置一个断点。 保持 linux-debug 和 bt_first_tree 目标不变,点击 CMake Tools 的 Debug。

程序会在执行打印语句前暂停。此时可以查看调用堆栈,按 F10 执行当前语句,再按 F5 继续到程序结束。后面的普通运行继续使用 Launch;这个最小示例由 CMake Tools 直接启动调试,不需要另外编写 launch.json。


加入电量检查,观察 Sequence 怎样执行

保留刚才的工程和 ReportReady()。在 src/main.cpp 中、main() 函数前面,增加下面的条件函数:

BT::NodeStatus CheckBattery(BT::TreeNode&)
{
    // 修改这里的数值,可以模拟电量变化
    const double battery_level = 80.0;
    std::cout << "[CheckBattery] battery=" << battery_level << '\n';

    if (battery_level >= 20.0)
    {
        return BT::NodeStatus::SUCCESS;
    }
    return BT::NodeStatus::FAILURE;
}

然后在 main() 中,已有的 registerSimpleAction() 后面、createTreeFromFile() 前面,增加这一行注册:

factory.registerSimpleCondition("CheckBattery", CheckBattery);

registerSimpleCondition() 的参数

函数原型:

void registerSimpleCondition(
    const std::string& ID,
    const BT::SimpleConditionNode::TickFunctor& tick_functor,
    BT::PortsList ports = {});

作用:把回调注册成简单条件节点。

  • 第一个参数 ID 是 XML 中使用的注册名,本例为 "CheckBattery",对应 <CheckBattery/>。
  • 第二个参数 tick_functor 是条件检查函数,本例传 CheckBattery,签名同样为 BT::NodeStatus(BT::TreeNode&)。收到 tick 时才执行检查,本例用 SUCCESS 表示电量足够,用 FAILURE 表示不足。
  • 第三个参数 ports 是可选端口列表。本例直接使用函数中的模拟电量,所以省略它。

返回值:void。条件是否满足由 CheckBattery() 的返回状态表达;注册调用本身不执行这次电量检查。

将 trees/mission.xml 整个替换为:

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence name="mission_sequence">
            <CheckBattery name="check_battery"/>
            <ReportReady name="report_ready"/>
        </Sequence>
    </BehaviorTree>
</root>

树变为:

Sequence
├── CheckBattery
└── ReportReady

保存 src/main.cpp 和 trees/mission.xml,在 CMake Tools 中先点击 Build,成功后点击 Launch。这次只改了 C++ 和 XML,构建配置没有变化,沿用当前预设和运行目标即可。

#include "behaviortree_cpp_v3/bt_factory.h"

#include <iostream>

// 一个立即完成的动作:打印机器人已就绪
BT::NodeStatus ReportReady(BT::TreeNode&)
{
    std::cout << "[ReportReady] robot is ready\n";
    return BT::NodeStatus::SUCCESS;
}

// 电量检查
BT::NodeStatus CheckBattery(BT::TreeNode&)
{
    // 修改这里的数值,可以模拟电量变化
    const double battery_level = 80.0;
    std::cout << "[CheckBattery] battery=" << battery_level << '\n';

    if (battery_level >= 20.0)
    {
        return BT::NodeStatus::SUCCESS;
    }
    return BT::NodeStatus::FAILURE;
}


int main()
{
    BT::BehaviorTreeFactory factory;
    factory.registerSimpleAction("ReportReady", ReportReady);
    factory.registerSimpleCondition("CheckBattery", CheckBattery);

    // 根据 XML 创建树,再从根节点执行一次
    auto tree = factory.createTreeFromFile(BT_XML_PATH);
    const BT::NodeStatus result = tree.tickRoot();
    std::cout << "Tree result: " << BT::toStr(result) << '\n';

    return result == BT::NodeStatus::SUCCESS ? 0 : 1;
}

电量为 80 时,预期输出:

[CheckBattery] battery=80
[ReportReady] robot is ready
Tree result: SUCCESS

接着在编辑器中将 CheckBattery() 的 battery_level 改为 10.0,保存后再次点击 Build → Launch:

[CheckBattery] battery=10
Tree result: FAILURE

CheckBattery 失败后,Sequence 不再执行 ReportReady。程序退出码为 1,表示树任务失败,不是编译错误。

继续下一步前,把 battery_level 改回 80.0。 否则后面新增的动作仍然会因为电量条件失败而被跳过。


将打印动作换成带输入端口的节点类

现在希望目标可以从 XML 配置。把原来固定打印“机器人已就绪”的动作,换成读取并打印 goal 的 PrintGoal。

这一轮只增加输入端口,先使用 XML 固定字符串;下一轮再连接 Blackboard。

添加头文件并替换动作实现

保留 bt_factory.h 和 <iostream>,再在文件顶部增加两个头文件:

#include <exception>
#include <string>

删除原来的 ReportReady() 函数,换成下面的完整 PrintGoal 类; CheckBattery() 函数继续保留,电量恢复为 80.0:

class PrintGoal : public BT::SyncActionNode
{
public:
    PrintGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        // 节点需要一个名为 goal 的字符串输入
        return {BT::InputPort<std::string>("goal")};
    }

    BT::NodeStatus tick() override
    {
        // 每次执行时读取输入,读取成功后再使用
        auto goal = getInput<std::string>("goal");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }

        std::cout << "[PrintGoal] target=" << goal.value() << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

类里的几个部分各有职责:

部分作用
继承 BT::SyncActionNode表示这是立即完成的同步动作
构造函数将实例名称和配置交给基类保存
providedPorts()声明字符串输入端口 goal
tick()读取目标,打印后返回成功

构造函数的 name 与 config

本例构造函数的两个参数由工厂在创建节点实例时提供:

参数类型含义
第一个 nameconst std::string&当前节点的实例名称;XML 可以用 name 属性设置,省略时通常使用节点的注册名
第二个 configconst BT::NodeConfiguration&当前实例的配置,包含 Blackboard,以及输入、输出端口的映射

初始化列表中的 BT::SyncActionNode(name, config) 将这两个参数交给基类保存,后面的 getInput() 才能按本实例的 XML 配置读取数据。 这里不需要在 main() 中手动调用 PrintGoal 的构造函数。

providedPorts() 与 InputPort<T>()

static BT::PortsList providedPorts() 不接收参数,返回本节点的端口声明。它是自定义节点按约定提供的静态函数,工厂注册节点类时会读取它;声明里保存的是接口信息,实际目标值由 XML 或 Blackboard 提供。

本例使用 BT::InputPort<std::string>("goal"):

位置含义
模板参数 <std::string>输入值的 C++ 类型
第一个函数参数 "goal"节点自己的输入端口名,对应 XML 的 goal 属性
可选的第二个函数参数端口说明文字,本例省略;带默认值的三参数形式在下一篇说明

返回值:一个端口名称和 BT::PortInfo 组成的键值对,加入 BT::PortsList 后供工厂使用。这次调用声明端口,不会立即读取目标。

getInput<T>() 的参数与返回值

本例使用的原型为:

template <typename T>
BT::Optional<T> getInput(const std::string& key) const;

作用:根据当前节点的端口映射读取输入,并按 T 获取或转换数据。

  • 模板参数 T 是期望读取的类型,本例为 std::string。
  • 唯一的函数参数 key 是节点自己的端口名,本例为 "goal"。XML 决定它来自固定文本还是某个 Blackboard 条目。

返回值:BT::Optional<T>,这是 BehaviorTree.CPP 使用的带错误信息的结果类型。先用 if (!goal) 判断读取是否失败;有效时用 goal.value() 取得值,失败时用 goal.error() 取得错误字符串。

throw BT::RuntimeError("Cannot read input [goal]: ", goal.error()); 将固定提示和库返回的错误信息组成异常,再交给 main() 的 catch 显示。BT::RuntimeError 是库提供的异常类型,可以被 const std::exception& 捕获。接口细节见 3.8.7 tree_node.h。

替换 main 函数

将 main() 整个替换为以下版本。这里保留电量条件的注册,将动作注册改成 registerNodeType<PrintGoal>(),并添加错误报告:

int main()
{
    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerSimpleCondition("CheckBattery", CheckBattery);
        factory.registerNodeType<PrintGoal>("PrintGoal");

        auto tree = factory.createTreeFromFile(BT_XML_PATH);
        const BT::NodeStatus result = tree.tickRoot();
        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;
    }
}

PrintGoal 类和 CheckBattery() 都应放在 main() 前面,保证注册时已经有对应定义。

registerNodeType<T>() 的参数

本例使用的原型为:

template <typename T>
void registerNodeType(const std::string& ID);

作用:将一个自定义节点类注册到工厂,让工厂能够按 XML 创建该类的实例。

位置含义本例
模板参数 TC++ 中已经定义的节点类PrintGoal
第一个函数参数 IDXML 使用的注册名"PrintGoal",对应 <PrintGoal/>

<PrintGoal> 是模板参数,("PrintGoal") 中的字符串才是函数实参。这两个名字在本例中保持一致以便阅读;C++ 类名和 XML 注册名分别负责不同的一层。

返回值:void。注册时读取这个类的端口声明并保存创建方式;加载 XML 时,工厂再为每个节点实例提供 name 和 config,调用前面说明的构造函数。

从 XML 传入固定目标

将 trees/mission.xml 整个替换为:

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence name="mission_sequence">
            <CheckBattery name="check_battery"/>
            <PrintGoal name="print_goal" goal="loading_area"/>
        </Sequence>
    </BehaviorTree>
</root>
#include "behaviortree_cpp_v3/bt_factory.h"
#include <iostream>
#include <exception>
#include <string>

class PrintGoal : public BT::SyncActionNode
{
public:
    PrintGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        // 节点需要一个名为 goal 的字符串输入
        return {BT::InputPort<std::string>("goal")};
    }

    BT::NodeStatus tick() override
    {
        // 每次执行时读取输入,读取成功后再使用
        auto goal = getInput<std::string>("goal");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }

        std::cout << "[PrintGoal] target=" << goal.value() << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

// 电量检查
BT::NodeStatus CheckBattery(BT::TreeNode&)
{
    // 修改这里的数值,可以模拟电量变化
    const double battery_level = 80.0;
    std::cout << "[CheckBattery] battery=" << battery_level << '\n';

    if (battery_level >= 20.0)
    {
        return BT::NodeStatus::SUCCESS;
    }
    return BT::NodeStatus::FAILURE;
}


int main()
{
    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerSimpleCondition("CheckBattery", CheckBattery);
        factory.registerNodeType<PrintGoal>("PrintGoal");

        auto tree = factory.createTreeFromFile(BT_XML_PATH);
        const BT::NodeStatus result = tree.tickRoot();
        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;
    }
}

保存修改,在 CMake Tools 中点击 Build,成功后点击 Launch。

预期输出:

[CheckBattery] battery=80
[PrintGoal] target=loading_area
Tree result: SUCCESS

现在只把 XML 中的 goal="loading_area" 改为 goal="charging_station",保存 trees/mission.xml 后再次点击 Launch。这一步没有改 C++,不需要手动重新 Build。

打印目标会变为 charging_station。这样就把节点的具体实现和本次配置的目标分开了。


再增加输出节点,让两个动作传递目标

固定值已经能读到了,现在让前面的节点产生目标,后面的节点通过 Blackboard 读取。保留上一轮的 PrintGoal、CheckBattery() 和 main(),只增加一个类和一行注册。

增加 SelectGoal 类并注册

在 PrintGoal 类前面加入:

class SelectGoal : public BT::SyncActionNode
{
public:
    SelectGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        // 节点通过 goal 输出选定的目标
        return {BT::OutputPort<std::string>("goal")};
    }

    BT::NodeStatus tick() override
    {
        const std::string goal = "loading_area";
        setOutput<std::string>("goal", goal);
        std::cout << "[SelectGoal] goal=" << goal << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

在 main() 中、注册 PrintGoal 的前面增加:

factory.registerNodeType<SelectGoal>("SelectGoal");

工厂已有 Sequence 等内置节点;本轮只是多注册一种自定义动作类型,不需要修改构建配置。

OutputPort<T>() 与 setOutput()

BT::OutputPort<std::string>("goal") 声明字符串输出端口:模板参数指定值类型,第一个函数参数是端口名,可选的第二个参数是说明文字。返回值同样是用于组成 BT::PortsList 的端口键值对。

写入使用下面的接口:

template <typename T>
BT::Result setOutput(const std::string& key, const T& value);
参数含义本例
第一个 key当前节点自己的输出端口名"goal"
第二个 value要写出的 C++ 值,类型为 T保存 "loading_area" 的字符串变量 goal

XML 会把这个输出端口连接到 Blackboard 条目;节点实现仍然只使用自己的端口名。返回值为 BT::Result,成功时可判真,失败时可判假并调用 .error() 查看原因。本篇先展示正常写入路径,下一篇会用 written 变量显式检查这个结果。

把固定值改成 Blackboard 映射

将 trees/mission.xml 整个替换为下面的最终版本:

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence name="mission_sequence">
            <CheckBattery name="check_battery"/>
            <SelectGoal name="select_goal" goal="{mission_goal}"/>
            <PrintGoal name="print_goal" goal="{mission_goal}"/>
        </Sequence>
    </BehaviorTree>
</root>
#include "behaviortree_cpp_v3/bt_factory.h"
#include <iostream>
#include <exception>
#include <string>

class SelectGoal : public BT::SyncActionNode
{
public:
    SelectGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        // 节点通过 goal 输出选定的目标
        return {BT::OutputPort<std::string>("goal")};
    }

    BT::NodeStatus tick() override
    {
        const std::string goal = "loading_area";
        setOutput<std::string>("goal", goal);
        std::cout << "[SelectGoal] goal=" << goal << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};


class PrintGoal : public BT::SyncActionNode
{
public:
    PrintGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        // 节点需要一个名为 goal 的字符串输入
        return {BT::InputPort<std::string>("goal")};
    }

    BT::NodeStatus tick() override
    {
        // 每次执行时读取输入,读取成功后再使用
        auto goal = getInput<std::string>("goal");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }

        std::cout << "[PrintGoal] target=" << goal.value() << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

// 电量检查
BT::NodeStatus CheckBattery(BT::TreeNode&)
{
    // 修改这里的数值,可以模拟电量变化
    const double battery_level = 80.0;
    std::cout << "[CheckBattery] battery=" << battery_level << '\n';

    if (battery_level >= 20.0)
    {
        return BT::NodeStatus::SUCCESS;
    }
    return BT::NodeStatus::FAILURE;
}


int main()
{
    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerSimpleCondition("CheckBattery", CheckBattery);
        factory.registerNodeType<SelectGoal>("SelectGoal");
        factory.registerNodeType<PrintGoal>("PrintGoal");

        auto tree = factory.createTreeFromFile(BT_XML_PATH);
        const BT::NodeStatus result = tree.tickRoot();
        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;
    }
}

保存修改,在 CMake Tools 中点击 Build,成功后点击 Launch。

预期输出:

[CheckBattery] battery=80
[SelectGoal] goal=loading_area
[PrintGoal] target=loading_area
Tree result: SUCCESS

这时再对照数据流:

SelectGoal::tick()
    ↓ setOutput("goal", "loading_area")
Blackboard["mission_goal"]
    ↓ getInput<std::string>("goal")
PrintGoal::tick()

C++ 操作各自的 goal 端口,XML 中的 {mission_goal} 决定它们连接哪个 Blackboard 条目。 SelectGoal 先写入,PrintGoal 后读取,执行顺序由 Sequence 保证。

解释端口名和blackboard

Blackboard 存放数据;端口名更像“函数参数名”,数据类型是另外指定的。

拿“柜子取东西”来比喻:

  • Blackboard:一个储物柜。
  • Blackboard 键名:抽屉上的标签,例如 pickup_goal。
  • 数据:抽屉里放的内容,例如 "loading_area"。
  • 端口名:节点接收数据的接口名称,例如 goal。

看这一行 C++:

BT::InputPort<std::string>("goal")

意思是:这个节点有一个叫 goal 的输入接口,接收字符串。 其中 std::string 才是类型,goal 是端口名。

再看 XML:

<NavigateToGoal goal="{pickup_goal}"/>

意思是:到 Blackboard 中找到标着 pickup_goal 的抽屉,把里面的数据交给这个导航节点的 goal 输入接口。

对应关系就是:

Blackboard 的 pickup_goal 抽屉
里面保存 "loading_area"
          ↓ XML 指定从这里取
导航节点的 goal 输入端口
          ↓
节点知道:这次去 loading_area

端口负责让节点接收或写出数据;Blackboard 负责保存数据;XML 负责指定它们怎么连接。


保持每一步的编译、执行和路径一致

本篇各轮实践都沿用同一套框架配置、同一个可执行文件和 trees/mission.xml:

改了什么接下来执行什么
第一次建立工程选择 linux-debug,点击 Configure → Build → Launch
修改 C++ 函数、类或注册代码保存后点击 Build → Launch
只改 XML 结构或端口配置保存后再次点击 Launch
修改 CMake 配置或移动工程目录点击 Configure → Build → Launch

C++ 中使用 createTreeFromFile(BT_XML_PATH),实际 XML 路径由 src/CMakeLists.txt 根据源码目录提供。无论从工程根目录、构建目录,还是编辑器中启动,程序读取的都是源码中的 trees/mission.xml;只修改 XML 后重新运行即可。

这个完整路径也避免了向解析器传入不含目录部分的裸文件名。本篇的路径设置用于源码工程的开发和调试,安装发布时的资源布局后续按需处理;当前练习编译后直接运行,不要求执行安装。

本篇以 VSCode 的 CMake Tools 为操作入口:Configure 准备构建配置,Build 编译程序,Launch 直接运行,Debug 启动断点调试。不同扩展版本的按钮位置可能略有不同,以 CMake 面板中的操作名称为准;界面细节可对照 CMakePresets与构建安装。

CMake Tools 默认可能在 Launch 前自动检查并构建。只修改 XML 时,即使界面出现这一步,通常也没有 C++ 文件需要重新编译;本篇所说“直接 Launch”表示无需手动先点 Build。

本篇使用 tickRoot() 执行一次。所有示例节点都立即结束,因此一次根节点 tick 就足够。3.8 还有 tickRootWhileRunning():先 tick 一次,如果得到 RUNNING 就继续驱动,直到成功或失败。持续任务如何多次 tick,后面实现导航时再实践。

引入异常报告后的两个版本中,如果将 PrintGoal 输入改为 {unknown_goal},又没有节点写入该条目,主程序会显示读取错误并返回 2;未注册的节点名则会在创建树时报告错误。它们与电量不足时返回的树任务 FAILURE 是不同情况。

以上四个版本已按文中的增量修改顺序,在 Ubuntu 22.04 的 ysu_ros_humble 容器中,使用 C++20、CMake 3.22.1、GCC 11.4 和已安装的 BehaviorTree.CPP 3.8.7 编译运行验证,输出与示例一致。测试使用临时 Ninja 和工程副本,容器系统软件及已有工程没有升级或改动。

本篇逐步串起了“最小动作 → 条件控制 → 固定输入 → 节点间传值”。 节点之间传数据继续使用坐标数据,练习类型转换、双向端口和外部 Blackboard。