Skip to content

Wiki

第一个完整程序

About 35 min read

Existing English translations were reused. 233 current source blocks remain in Simplified Chinese, without triggering paid translation.

1.从节点、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++ 工程中逐步增加功能:先运行一个最小动作,再加入电量检查和目标传递。实际导航任务留到异步动作章节实现。


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

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

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

For example:

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

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

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

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

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


3.用 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       读取并打印该目标

3.1.分清 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 中的父子关系仍遵守前面的结构约束:控制节点包含一个或多个子节点,装饰节点包含一个,条件和动作节点没有子节点。


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

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

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

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

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

连接过程是:

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

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

4.1.固定值与 Blackboard 引用

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

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

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

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

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

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

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

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

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

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


5.认识 XML 中的几种补充写法

5.1.简写与显式写法

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

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

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

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

5.2.用 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 范围,后面再讲。

5.3.用 include 拆分文件

也可以在 <root> 中写:

<include path="report_task.xml"/>

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

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


6.入门总结

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

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

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


7.从最小 demo 开始搭建工程

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

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

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

7.1.沿用 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 与编译器应安装在当前 Ubuntu 系统中,VSCode 的 CMake Tools 使用同一套开发工具。

7.2.安装行为树库并加载 ROS 环境

如果使用已经配置 ROS 2 Humble 软件源的 Ubuntu 22.04,在运行练习的系统或容器中打开终端,安装行为树库:

sudo apt update
sudo apt install ros-humble-behaviortree-cpp-v3

安装后,先在这个终端加载 ROS 2 Humble 环境:

source /opt/ros/humble/setup.bash

再从同一个终端按下一节步骤打开工程、运行 code .,让 VSCode 的 CMake Tools、运行和调试进程继承这份环境。仅在已经打开的 VSCode 集成终端中执行 source,不会更新扩展进程的环境。

本教程使用 3.8.7。安装包的实际版本取决于当前软件源,可以用下面的命令查看已安装版本和候选版本:

apt-cache policy ros-humble-behaviortree-cpp-v3

后面的工程配置会优先使用已安装的目标版本,找不到时获取同版本源码。如果当前没有 ROS 2 环境,也可以使用后文的固定版本源码配置完成独立 CMake 练习。

7.3.在 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 配置、构建和运行。

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

将顶层 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 顶层的 version 3
CMakePresets.json 中的 cmakeMinimumRequired major: 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。

7.5.集中保存行为树依赖配置

将以下完整内容保存到新增的 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 环境中构建运行通过,配置无需额外添加模板兼容选项。

7.6.让主程序链接行为树库

将 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++ 直接使用它,不需要手写本机绝对路径,也不必随终端或编辑器的运行位置调整相对路径。

7.7.保存最小 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& 由框架传入,本例暂时不使用,所以没有为它命名。

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

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

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

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

7.8.1.registerSimpleAction()

Function prototype:

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。注册名重复等注册错误会抛出异常。

7.8.2.createTreeFromFile()

Function prototype:

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 错误或使用未注册节点等情况会抛出异常。

7.8.3.tickRoot() 与 BT::toStr()

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

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

7.9.保存只有一个动作的 XML

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

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

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

7.10.在 CMake Tools 中配置工程

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

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

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

7.11.构建并选择运行目标

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

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

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

Expected output:

[ReportReady] robot is ready
Tree result: SUCCESS

执行过程是:

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

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

7.12.在动作回调中设置断点

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

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


8.加入电量检查,观察 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);

8.1.registerSimpleCondition() 的参数

Function prototype:

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。 否则后面新增的动作仍然会因为电量条件失败而被跳过。


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

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

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

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

保留 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() 读取目标,打印后返回成功

9.1.1.构造函数的 name 与 config

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

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

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

9.1.2.providedPorts() 与 InputPort<T>()

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

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

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

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

9.1.3.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。

9.2.替换 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() 前面,保证注册时已经有对应定义。

9.2.1.registerNodeType<T>() 的参数

本例使用的原型为:

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

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

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

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

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

9.3.从 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。

Expected output:

[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。这样就把节点的具体实现和本次配置的目标分开了。


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

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

10.1.增加 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 等内置节点;本轮只是多注册一种自定义动作类型,不需要修改构建配置。

10.1.1.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 变量显式检查这个结果。

10.2.把固定值改成 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。

Expected output:

[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 保证。

10.3.解释端口名和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 负责指定它们怎么连接。


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

本篇各轮实践都沿用同一套框架配置、同一个可执行文件和 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 是不同情况。

按照文中的增量修改顺序运行四个版本,检查正常输出、电量不足、缺失输入和未注册节点等情况。分别记录树任务返回 FAILURE 与程序报告配置或输入错误时的差异。

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