跳到正文

Wiki

第一个完整程序

约 36 分钟阅读

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

对应 BehaviorTree.CPP 4.x 官方文档: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、tickExactlyOnce()

例如:

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 BTCPP_format="4" 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 BTCPP_format="4"> 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 BTCPP_format="4" 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 BTCPP_format="4"> 中写:

<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;行为树依赖优先使用已安装的 4.10.0,只有未找到对应版本时才联网下载。

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

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

source /opt/ros/humble/setup.bash

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

v4 的 apt 包名是 ros-humble-behaviortree-cpp,末尾没有 -v4。

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

apt-cache policy ros-humble-behaviortree-cpp

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

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

本篇从获取 CMake 模板开始建立第一个行为树工程,练习目录命名为 bt4_first_tree。 下面的命令会创建这个文件夹,并在 VSCode 中打开;后面逐步添加行为树依赖、C++ 程序和 XML 文件。

git clone https://github.com/tungchiahui/CMake_Template bt4_first_tree
cd bt4_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。

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

bt4_first_tree/
├── CMakeLists.txt
├── CMakePresets.json          沿用 linux-debug / linux-release 预设
├── cmake/
│   ├── ProjectOptions.cmake   公共编译选项
│   └── BehaviorTree.cmake     新增:查找 4.10.0,未安装时获取源码
├── 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(bt4_first_tree VERSION 1.0.0 LANGUAGES C CXX),主要是把项目名改成 bt4_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++17。

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

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

include_guard(GLOBAL)

# 严格使用本教程验证的版本,避免链接到另一个 4.x 版本。
find_package(behaviortree_cpp 4.10.0 EXACT QUIET CONFIG)

if(behaviortree_cpp_FOUND)
    if(TARGET BT::behaviortree_cpp)
        set(BTCPP_TARGET BT::behaviortree_cpp)
    elseif(TARGET behaviortree_cpp::behaviortree_cpp)
        set(BTCPP_TARGET behaviortree_cpp::behaviortree_cpp)
    elseif(TARGET behaviortree_cpp)
        set(BTCPP_TARGET behaviortree_cpp)
    else()
        message(FATAL_ERROR "BehaviorTree.CPP 4.10.0 was found without a supported target")
    endif()
    message(STATUS "Using installed BehaviorTree.CPP 4.10.0: ${behaviortree_cpp_DIR}")
else()
    include(FetchContent)
    set(BTCPP_EXAMPLES OFF CACHE BOOL "" FORCE)
    set(BTCPP_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
    set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
    # 后面的 Groot2 和日志实践继续使用同一份依赖。
    set(BTCPP_GROOT_INTERFACE ON CACHE BOOL "" FORCE)
    set(BTCPP_SQLITE_LOGGING ON CACHE BOOL "" FORCE)

    FetchContent_Declare(
        behaviortree_cpp
        URL https://codeload.github.com/BehaviorTree/BehaviorTree.CPP/tar.gz/refs/tags/4.10.0
        URL_HASH SHA256=c758fdedb3666f7ca4c9998a4c0b243251e0a5fb5d03c99e2ee63d615af7a71d
    )
    # 4.10.0 上游用顶层版本生成库版本字符串,在此临时指定库版本。
    set(_BTCPP_PARENT_VERSION "${CMAKE_PROJECT_VERSION}")
    set(CMAKE_PROJECT_VERSION "4.10.0")
    FetchContent_MakeAvailable(behaviortree_cpp)
    set(CMAKE_PROJECT_VERSION "${_BTCPP_PARENT_VERSION}")
    set(BTCPP_TARGET behaviortree_cpp)
    message(STATUS "Building BehaviorTree.CPP 4.10.0 from source")
endif()

配置输出包含 Using installed BehaviorTree.CPP 4.10.0 时,使用的是已安装的精确版本;显示 Building BehaviorTree.CPP 4.10.0 from source 时,使用固定标签源码。BTCPP_TARGET 保存本次链接的目标,后续程序不必分别维护两种写法。

本教程先建立独立 CMake 程序,不需要启动 ROS 节点。Ubuntu 22.04 在 Configure 前安装源码分支所需依赖:

sudo apt update
sudo apt install build-essential cmake ninja-build git libzmq3-dev libsqlite3-dev

Groot2 发布器需要 ZeroMQ,SQLite 日志开关需要 SQLite 开发包。源码获取分支已为后续工具实践开启这两项。使用已安装库时,当前工程设置开关不能为现成库补回缺失功能,需要使用构建时已开启相应功能的 4.10.0 库。

(看不懂也不要紧,可以忽略)如果库来自 ROS 安装或自定义前缀,应让 CMake Tools 继承相应的环境,或在配置中设置 CMAKE_PREFIX_PATH。只在已打开的集成终端执行 source 不会更新扩展进程的环境。本例使用的依赖包名是 behaviortree_cpp,版本固定为 4.10.0。 首次 Configure 后,可在 CMakeCache.txt 搜索 behaviortree_cpp_DIR 或 behaviortree_cpp_SOURCE_DIR 确认来源。

4.10.0 的独立源码安装分支没有生成供 CMake 比较版本的 ConfigVersion 文件;这种安装可能被 EXACT 查找显示为 version unknown,随后会走固定源码分支,不能为方便而直接接受任意版本。 源码嵌入时,上游还用顶层 CMAKE_PROJECT_VERSION 生成库的版本字符串,因此本模块在添加库的短暂范围内指定为 4.10.0,再恢复练习工程的版本。

构建开关以 4.10.0 CMakeLists.txt 为准;本例用 BTCPP_EXAMPLES、BTCPP_BUILD_TOOLS 和 BUILD_TESTING 关闭上游示例与测试,自己的练习程序仍正常构建。

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/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.tickExactlyOnce();
    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 时,工厂再按注册名创建节点实例。

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

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

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

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

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

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

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

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

<root BTCPP_format="4" 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 依赖;已安装 4.10.0 时直接使用,否则第一次会下载源码。等待输出中出现配置和生成成功的信息。

7.11.构建并选择运行目标

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

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

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

预期输出:

[ReportReady] robot is ready
Tree result: SUCCESS

执行过程是:

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

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

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

想观察函数什么时候被调用,可以在 ReportReady() 内的 std::cout 语句左侧单击,设置一个断点。 保持 linux-debug 和 bt4_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() 的参数

函数原型:

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 BTCPP_format="4" 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/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.tickExactlyOnce();
    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::NodeConfig& 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::NodeConfig& 当前实例的配置,包含 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& 捕获。接口细节见 4.10.0 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.tickExactlyOnce();
        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 BTCPP_format="4" 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/bt_factory.h"
#include <iostream>
#include <exception>
#include <string>

class PrintGoal : public BT::SyncActionNode
{
public:
    PrintGoal(const std::string& name, const BT::NodeConfig& 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.tickExactlyOnce();
        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。这样就把节点的具体实现和本次配置的目标分开了。


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

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

10.1.增加 SelectGoal 类并注册

在 PrintGoal 类前面加入:

class SelectGoal : public BT::SyncActionNode
{
public:
    SelectGoal(const std::string& name, const BT::NodeConfig& 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 BTCPP_format="4" 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/bt_factory.h"
#include <iostream>
#include <exception>
#include <string>

class SelectGoal : public BT::SyncActionNode
{
public:
    SelectGoal(const std::string& name, const BT::NodeConfig& 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::NodeConfig& 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.tickExactlyOnce();
        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 保证。

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。

本篇使用 tickExactlyOnce() 执行一次。所有示例节点都立即结束,因此一次根节点 tick 就足够。tickWhileRunning() 将等待与重复驱动放在库内部,根结果不再是 RUNNING 时返回;tickOnce() 在收到唤醒信号时可能继续执行多次根 tick。需要精确观察“一次调用对应一次根 tick”时,使用本篇的 tickExactlyOnce()。

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

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

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


v4 学习路线 · 上一篇:控制与装饰规则 · 下一篇:节点之间传数据