第 12.4.2.4 节

节点之间传数据

作者:董佳辉

用 Ports 与 Blackboard 传递机器人目标

对应 BehaviorTree.CPP 3.8 官方文档:Tutorial - Basics 中的 Blackboard and ports 和 Ports with generic types,并参考 3.8.7 tree_node.h、basic_types.h 与 blackboard.h。

上一篇已经给出“检查电量 → 选择目标 → 打印目标”的完整工程。 如果还没有运行,可以先按上一篇的实践部分建立工程; 本篇另建一个独立示例,保留前一个工程,方便对照。

接下来把字符串目标换成机器人常用的坐标数据,集中解决几个相关问题:不同名称的端口怎样连接,XML 文本怎样变成 C++ 类型,节点怎样读取并修改同一份数据,外部程序怎样与树交换数据。

本篇仍使用同步节点计算和打印数据,实际移动与导航留到“持续任务与中止”学习。


从外部位姿计算一个任务目标

假设移动机器人当前位姿为 (1.0, 2.0, 0.0), 需要沿当前朝向前进 2 米, 再给目标的 x 坐标增加 0.5 米的示例偏移:

外部程序初始化 Blackboard
├── robot_pose       = Pose2D{1.0, 2.0, 0.0}
└── travel_distance  = 2.0
         ↓
Sequence
├── CalculateGoal    根据起点、朝向和距离计算目标,写入 mission_goal
├── OffsetGoal       读取 mission_goal,修改后写回同一个条目
├── PrintGoal        从 mission_goal 读取计算结果
└── PrintGoal        从 XML 固定文本读取另一组坐标
         ↓
外部程序读取 mission_goal

这里的 Pose2D 是自行定义的 C++ 结构体,包含 x、y、yaw,不依赖 ROS 消息类型。 位置单位为米,朝向单位为弧度,示例中的坐标使用同一个参考坐标系。

计算时使用:

goal.x   = start.x + distance × cos(start.yaw)
goal.y   = start.y + distance × sin(start.yaw)
goal.yaw = start.yaw

起始位姿(1.0, 2.0, 0.0),起始朝向为 0,所以朝前(是x)2米,先得到 (3.0, 2.0, 0.0); 加上 x 偏移后,最终任务目标为 (3.5, 2.0, 0.0)。这是坐标计算示例,不包含路径规划、碰撞检查或导航控制。


端口名称与 Blackboard 键各管一层

本篇用以下连接传递目标:

<CalculateGoal start="{robot_pose}" distance="{travel_distance}" goal="{mission_goal}"/>
<OffsetGoal goal="{mission_goal}" offset_x="0.5"/>
<PrintGoal target="{mission_goal}"/>

读这段 XML 时,把节点接口和存储位置分开看:

名称所属位置作用
CalculateGoal 的 goal输出端口计算节点写出目标的接口
OffsetGoal 的 goal双向端口偏移节点读取并更新目标的接口
PrintGoal 的 target输入端口打印节点读取目标的接口
mission_goalBlackboard 键保存这几个端口共同使用的目标数据

生产者的端口叫 goal,消费者的端口叫 target,仍然可以连接,因为 XML 把它们映射到同一个 {mission_goal},而且使用相同的 Pose2D 类型。

C++ 中使用的是节点自己的端口名称:setOutput("goal", ...) 或 getInput<Pose2D>("target")。 将来把 Blackboard 键改为 delivery_goal,只需统一调整 XML 中的映射,节点实现不必跟着改名。

三种端口方向

声明读写方法适合的职责
BT::InputPort<T>getInput<T>()读取起点、距离、目标等输入
BT::OutputPort<T>setOutput()产生并写出一个结果
BT::BidirectionalPort<T>先读取,再按需写出读取已有目标,修改后写回

双向端口同时提供读写接口;它不会自动完成修改,也不保证条目已经有值。 本例先执行 CalculateGoal,再执行 OffsetGoal,就是为了在读写目标前先产生有效数据。

getInput() 将值读到本地变量,修改这个变量不会直接改变 Blackboard。 偏移节点需要明确调用 setOutput() 写回结果。


XML 文本与 C++ 类型怎样对应

数值可以来自固定文本、Blackboard 或默认值

计算节点的距离输入声明为 double,下面三种配置对应三种来源:

XML 配置取值方式
distance="2.0"将固定文本转换成 double
distance="{travel_distance}"从 Blackboard 读取对应条目
省略 distance 属性使用节点声明的默认值,本例为 1.0

BehaviorTree.CPP 已经提供常用数值类型的文本转换。 默认值通过 providedPorts() 声明; 下面完整程序使用 3.8 的三参数写法 BT::InputPort<double>("distance", 1.0, "前进距离,单位为米")。

默认值用于没有显式配置该输入的情况。 如果 XML 已经写成 {unknown_distance},但该条目没有有效值,读取仍会失败,不会自动退回默认值。

自定义类型需要约定文本格式

本篇约定 Pose2D 的文本格式为三个用分号分隔的实数:

x;y;yaw
2.0;3.0;1.57

因此,<PrintGoal target="2.0;3.0;1.57"/> 需要把字符串转换成 Pose2D。 做法是在 BT 命名空间中,为 convertFromString<Pose2D>() 提供模板特化,再分别转换三个数值。 特化放在使用该类型声明端口的节点类之前。

两条数据路径有所区别:

C++ 计算出的 Pose2D → setOutput() → Blackboard → getInput<Pose2D>()
                                           直接读取对应类型的值

XML 中的 "2.0;3.0;1.57" → getInput<Pose2D>() → convertFromString<Pose2D>()
                                           先解析文本,再得到值

Blackboard 可以保存结构体,不需要把所有数据先转成字符串。 XML 文本转换也不会让任意两个 C++ 类型自动互相兼容;生产者、消费者和外部程序仍应统一数据类型。


沿用框架建立数据传递工程

本篇继续使用上一篇的 BT 练习版,目录来自 CMake工程模板, 构建方式见 CMakePresets与构建安装。 需要支持 C++20 的编译器、CMake 3.22 或更新版本和 Ninja。 Ubuntu 22.04 可以继续使用 CMake 3.22.1,沿用上一篇的工程即可;C++20 和预设格式版本 3 都直接沿用当前模板。

如果还没有建立上一篇的框架工程,先按上一篇准备好工程文件。 本篇直接复制整个 bt_first_tree 文件夹,再把副本重命名为 bt_ports,保留原来的工程供后续对照。

在系统文件管理器中完成下面几步:

  1. 打开 bt_first_tree 所在的父目录,复制并粘贴整个文件夹,将副本重命名为 bt_ports,让两个工程位于同一层目录。
  2. 在新副本 bt_ports 中,删除复制过来的 build 和 install 文件夹(如果存在)。其中的构建缓存和安装结果属于旧工程,新工程稍后会重新生成。
  3. 在 VSCode 中选择 文件 → 新建窗口,再选择 文件 → 打开文件夹,打开新的 bt_ports。

cmake、src、trees 目录及其中的文件都已随工程复制过来,不需要重新建立。 稍后直接修改顶层项目名,并用本篇内容替换已有的 src/main.cpp 和 trees/mission.xml。

新工程的构建目录会在 Configure、Build 时由自己的 preset 重新生成。参与本篇学习的文件为:

bt_ports/
├── CMakeLists.txt
├── CMakePresets.json
├── cmake/
│   ├── ProjectOptions.cmake
│   └── BehaviorTree.cmake
├── src/
│   ├── CMakeLists.txt
│   └── main.cpp
└── trees/
    └── mission.xml

在新的顶层 CMakeLists.txt 中,将 project(...) 一行改为:

project(bt_ports VERSION 1.0.0 LANGUAGES C CXX)

其他配置原样沿用:BehaviorTree.cmake 继续优先使用已安装的 3.8.7,未找到时再获取同版本源码; src/CMakeLists.txt 自动以新项目名创建 bt_ports,并将新工程的 trees/mission.xml 路径交给 BT_XML_PATH。

在 VSCode 编辑器中,用下面的完整 C++ 和 XML 分别替换 src/main.cpp 与 trees/mission.xml 的原有内容,再保存。本篇继续使用 CMake Tools 配置、构建和运行。

src/main.cpp

#include "behaviortree_cpp_v3/bt_factory.h"

#include <cmath>
#include <exception>
#include <iomanip>
#include <iostream>
#include <string>

struct Pose2D
{
    double x;
    double y;
    double yaw;
};

// 统一坐标的打印方式,便于观察每一步的数据
std::ostream& operator<<(std::ostream& stream, const Pose2D& pose)
{
    return stream << '(' << pose.x << ", " << pose.y << ", " << pose.yaw << ')';
}

namespace BT
{
template <>
inline Pose2D convertFromString<Pose2D>(StringView text)
{
    // XML 中的位姿格式为 x;y;yaw,朝向单位为弧度
    const auto parts = splitString(text, ';');
    if (parts.size() != 3)
    {
        throw RuntimeError("Pose2D requires x;y;yaw");
    }

    Pose2D pose{
        convertFromString<double>(parts[0]),
        convertFromString<double>(parts[1]),
        convertFromString<double>(parts[2])};

    if (!std::isfinite(pose.x) || !std::isfinite(pose.y) || !std::isfinite(pose.yaw))
    {
        throw RuntimeError("Pose2D values must be finite");
    }
    return pose;
}
}  // namespace BT

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

    static BT::PortsList providedPorts()
    {
        return {
            BT::InputPort<Pose2D>("start"),
            BT::InputPort<double>("distance", 1.0, "前进距离,单位为米"),
            BT::OutputPort<Pose2D>("goal")};
    }

    BT::NodeStatus tick() override
    {
        const auto start = getInput<Pose2D>("start");
        const auto distance = getInput<double>("distance");
        if (!start)
        {
            throw BT::RuntimeError("Cannot read input [start]: ", start.error());
        }
        if (!distance)
        {
            throw BT::RuntimeError("Cannot read input [distance]: ", distance.error());
        }
        if (!std::isfinite(distance.value()) || distance.value() < 0.0)
        {
            throw BT::RuntimeError("Input [distance] must be finite and non-negative");
        }

        // 沿起始朝向计算目标,计算结果仍是一个 Pose2D 对象
        const Pose2D& origin = start.value();
        const Pose2D goal{
            origin.x + distance.value() * std::cos(origin.yaw),
            origin.y + distance.value() * std::sin(origin.yaw),
            origin.yaw};

        const auto written = setOutput<Pose2D>("goal", goal);
        if (!written)
        {
            throw BT::RuntimeError("Cannot write output [goal]: ", written.error());
        }
        std::cout << "[CalculateGoal] distance=" << distance.value()
                  << ", goal=" << goal << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

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

    static BT::PortsList providedPorts()
    {
        return {
            BT::BidirectionalPort<Pose2D>("goal"),
            BT::InputPort<double>("offset_x", 0.0, "目标在参考坐标系 x 方向的偏移")};
    }

    BT::NodeStatus tick() override
    {
        const auto input = getInput<Pose2D>("goal");
        const auto offset = getInput<double>("offset_x");
        if (!input)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", input.error());
        }
        if (!offset)
        {
            throw BT::RuntimeError("Cannot read input [offset_x]: ", offset.error());
        }
        if (!std::isfinite(offset.value()))
        {
            throw BT::RuntimeError("Input [offset_x] must be finite");
        }

        // 先修改本地副本,再通过双向端口写回同一个条目
        Pose2D goal = input.value();
        goal.x += offset.value();
        const auto written = setOutput<Pose2D>("goal", goal);
        if (!written)
        {
            throw BT::RuntimeError("Cannot write output [goal]: ", written.error());
        }
        std::cout << "[OffsetGoal] 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()
    {
        return {BT::InputPort<Pose2D>("target")};
    }

    BT::NodeStatus tick() override
    {
        const auto target = getInput<Pose2D>("target");
        if (!target)
        {
            throw BT::RuntimeError("Cannot read input [target]: ", target.error());
        }
        std::cout << "[PrintGoal:" << name() << "] target=" << target.value() << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

int main()
{
    try
    {
        // 在编辑器中修改这个模拟距离,再构建运行;XML 路径由构建配置提供
        const double distance = 2.0;
        std::cout << std::fixed << std::setprecision(2);

        // 外部程序在创建树之前,准备带有明确 C++ 类型的输入
        auto blackboard = BT::Blackboard::create();
        blackboard->set<Pose2D>("robot_pose", Pose2D{1.0, 2.0, 0.0});
        blackboard->set<double>("travel_distance", distance);

        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<CalculateGoal>("CalculateGoal");
        factory.registerNodeType<OffsetGoal>("OffsetGoal");
        factory.registerNodeType<PrintGoal>("PrintGoal");
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        const BT::NodeStatus result = tree.tickRootWhileRunning();
        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        if (result == BT::NodeStatus::SUCCESS)
        {
            // 树执行后,外部程序可以从同一块 Blackboard 取得输出
            const Pose2D goal = blackboard->get<Pose2D>("mission_goal");
            std::cout << "[main] mission_goal=" << goal << '\n';
        }
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

getInput<T>() 返回的是带有成功或错误信息的结果;检查有效后,再用 .value() 取得值。setOutput() 同样有返回结果,本例也进行检查;类型冲突等情况还可能抛出异常,由 main() 统一显示。

本例把缺少必要输入、文本格式错误等作为配置或数据错误报告,退出码为 2。实际任务中可以根据节点职责选择返回 FAILURE 或使用默认值,不必把所有情况都处理成异常。

创建、写入和读取 Blackboard 的 API

上一篇介绍了节点内部的 getInput() 和 setOutput()。 本篇在 main() 中直接访问 Blackboard,使用的是另一组接口。 下面列出本例用到的函数原型;声明用于阅读,不用再复制到工程中。

// Blackboard 类中的成员声明
static BT::Blackboard::Ptr create(BT::Blackboard::Ptr parent = {});

template<typename T>
void set(const std::string& key, const T& value);

template<typename T>
T get(const std::string& key) const;
调用参数返回值与作用
BT::Blackboard::create()本例不传参数;可选参数 parent 指定父 Blackboard,默认空指针返回 BT::Blackboard::Ptr,也就是 std::shared_ptr<BT::Blackboard>,用于持有这块 Blackboard
blackboard->set<Pose2D>("robot_pose", Pose2D{1.0, 2.0, 0.0})模板参数 Pose2D 指定值类型;第一个参数是键名,第二个参数是要保存的值返回 void,创建或更新条目;类型不兼容等错误可能抛出异常
blackboard->get<Pose2D>("mission_goal")模板参数指定期望读取的类型;唯一的函数参数是键名返回一个 Pose2D 值;本例使用的这个重载在键不存在或类型转换失败时抛出异常

这里的 key 是 Blackboard 键。 节点内部的 getInput("target")、setOutput("goal", value) 接收的则是 节点端口名,由 XML 决定它们连接哪个键。

factory.createTreeFromFile(BT_XML_PATH, blackboard) 的第一个参数仍是 XML 路径,第二个参数把刚准备好的 Blackboard 交给根树。 这里传递的是共享指针,因此节点执行时读写的就是 main() 中持有的同一块数据。 get<Pose2D>() 返回值的副本,修改取得的 goal 变量不会自动更新 Blackboard; 需要更新时仍应调用 set()。

带默认值的输入端口与双向端口

本篇第一次使用 InputPort<T>() 的三参数重载:

template<typename T = void>
std::pair<std::string, BT::PortInfo> InputPort(
    BT::StringView name,
    const T& default_value,
    BT::StringView description);

以 BT::InputPort<double>("distance", 1.0, "前进距离,单位为米") 为例:

项目含义
模板参数 double输入值的 C++ 类型
第一个参数 "distance"节点自己的端口名称,对应 XML 中的 distance 属性
第二个参数 1.0没有配置该输入时使用的默认值
第三个参数端口的文字说明,用于描述接口,不参与距离计算
返回值一项端口声明,由 providedPorts() 收集后交给工厂

在 3.8.7 中,这个默认值重载需要三个参数;没有说明文字时可以把第三个参数写成 ""。前面见过的两参数形式 InputPort<T>(name, description) 中,第二个参数是说明文字,不能把两种重载混为一谈。

BT::BidirectionalPort<Pose2D>("goal") 声明双向端口:模板参数指定 Pose2D 类型,第一个参数指定端口名;可选的第二个参数也是说明文字。它同样返回一项端口声明。OffsetGoal 随后用 getInput<Pose2D>("goal") 取得原值,再用 setOutput("goal", goal) 写回修改后的值;声明双向端口本身不会执行读写。

拆分文本与转换自定义类型的 API

下面两个接口来自 BT 命名空间:

std::vector<BT::StringView> splitString(
    const BT::StringView& strToSplit, char delimeter);

template<typename T>
T convertFromString(BT::StringView str);
调用参数返回值
splitString(text, ';')第一个参数是待拆分的文本;第二个参数是分隔字符 ';'std::vector<BT::StringView>,本例应得到 x、y、yaw 三个文本片段
convertFromString<double>(parts[0])模板参数 double 指定目标类型;函数参数是一个数值文本片段转换后的 double;无效文本会报告转换错误
convertFromString<Pose2D>(text)模板参数 Pose2D 指定自定义类型;函数参数是完整的位姿文本本例特化产生的 Pose2D;字段数量错误、数值无效等情况抛出异常

BT::StringView 是不拥有字符数据的字符串视图,拆分得到的片段仍引用原始文本。本例在转换函数内立即使用它们,不把这些视图保存为节点成员。

template <> 表示为 Pose2D 提供这个函数模板的特化。节点读取 XML 固定文本时,getInput<Pose2D>() 会通过库的转换机制调用它,主程序不需要再手动转换一次;从 Blackboard 读取已经保存的 Pose2D 时则直接取得对应类型的值。

连续驱动行为树与辅助函数

tree.tickRootWhileRunning() 把重复 tick 的循环放在库内部,其原型为:

BT::NodeStatus tickRootWhileRunning(
    std::chrono::milliseconds sleep_time = std::chrono::milliseconds(10));

唯一的可选参数 sleep_time 表示两次 tick 之间的最长等待时间,默认 10 毫秒。本例省略它。 函数先执行一次根 tick;只有结果为 RUNNING 时才等待并继续 tick,直到得到最终结果,返回 BT::NodeStatus。 本例全部是同步节点,正常情况下第一次根 tick 就得到 SUCCESS。

程序中另外几个辅助接口可以按下面理解:

写法参数与结果
name()节点成员函数,无参数,返回当前实例名称的 const std::string&;本例两个打印节点分别叫 computed 和 literal
std::isfinite(value)传入一个浮点数,返回 bool,检查它是否为有限值
std::cos(yaw)、std::sin(yaw)传入以弧度表示的角度,返回余弦、正弦值,用于分解前进距离
std::fixed、std::setprecision(2)前者设置固定小数格式,后者的参数 2 指定显示两位小数;只改变输出格式,不改变 Blackboard 中的数据

trees/mission.xml

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence name="goal_pipeline">
            <CalculateGoal start="{robot_pose}"
                           distance="{travel_distance}"
                           goal="{mission_goal}"/>
            <OffsetGoal goal="{mission_goal}" offset_x="0.5"/>
            <PrintGoal name="computed" target="{mission_goal}"/>
            <PrintGoal name="literal" target="2.0;3.0;1.57"/>
        </Sequence>
    </BehaviorTree>
</root>

两个 PrintGoal 是同一 C++ 类型的不同实例。name 帮助区分输出,target 则分别配置为 Blackboard 引用和固定文本。


在 VSCode 中运行并跟踪数据变化

保存所有修改后,在新窗口的 CMake 面板中选择 Linux Debug,对应 linux-debug Configure 预设,再点击 Configure。确认配置成功后,选择同名的 Build 预设并点击 Build。

截图预留:bt_ports 配置和构建成功。 展示当前 linux-debug 预设、构建成功信息,以及新工程名。

在 Launch 中选择 bt_ports,点击运行按钮,在集成终端中查看输出。新窗口的运行目标应是 bt_ports,与上一篇的 bt_first_tree 区分开。

截图预留:选择 bt_ports 运行目标。 展示 CMake Tools 的 Launch 目标和运行按钮。

预期输出:

[CalculateGoal] distance=2.00, goal=(3.00, 2.00, 0.00)
[OffsetGoal] goal=(3.50, 2.00, 0.00)
[PrintGoal:computed] target=(3.50, 2.00, 0.00)
[PrintGoal:literal] target=(2.00, 3.00, 1.57)
Tree result: SUCCESS
[main] mission_goal=(3.50, 2.00, 0.00)

本篇完整工程已在 Ubuntu 22.04 的 ysu_ros_humble 容器中,使用 C++20、CMake 3.22.1、GCC 11.4 和已安装的 BehaviorTree.CPP 3.8.7 编译运行验证;固定值、默认值、缺失输入和错误格式等对照也已检查。配置沿用上一篇,测试使用临时 Ninja 和工程副本。

数据变化可以对应到每一步:

执行位置读取什么写入或使用什么
main() 初始化编辑器中设置的模拟距离、示例位姿robot_pose 和 travel_distance
CalculateGoal起点与距离写入 mission_goal = (3.0, 2.0, 0.0)
OffsetGoal已有目标与 x 偏移更新 mission_goal = (3.5, 2.0, 0.0)
PrintGoal:computedmission_goal打印当前值
PrintGoal:literalXML 固定文本转换后打印,不修改 mission_goal
main() 读取结果mission_goal取得树计算后的目标

所有节点都是快速同步动作,本次序列在一次根节点 tick 内完成。Sequence 负责先后顺序,Ports 与 Blackboard 负责传值;共同映射到一个键不会自动安排生产者先执行。

与上一篇相同,程序使用构建配置提供的 BT_XML_PATH,自动读取当前源码工程的 trees/mission.xml。在 CMake Tools 中启动程序时,直接使用同一份文件,无需另外设置 XML 路径参数。

改变外部输入,节点取得新的数值

保持 XML 不变,在编辑器中将 src/main.cpp 的 main() 内模拟距离改为:

const double distance = 4.0;

保存后点击 Build → Launch。这里改的是 C++,因此需要先重新构建。

这次先计算出 (5.0, 2.0, 0.0),再偏移到 (5.5, 2.0, 0.0)。computed 实例和 main() 都取得新结果,literal 实例仍打印 XML 中的固定坐标。

对照完成后,将模拟距离恢复为 2.0 并点击 Build,后面继续使用初始设置。

这里每次运行程序都会重新建立 Blackboard。 程序持续运行时,也可以由外部更新输入,再发起下一次任务; 应让更新与树执行有明确的调度关系,本例没有引入并发读写。

在 XML 中切换距离来源

将 CalculateGoal 的 distance 属性改为固定文本:

<CalculateGoal start="{robot_pose}" distance="3.0" goal="{mission_goal}"/>

保存 trees/mission.xml 后再次点击 Launch,计算节点使用 3 米,最终目标为 (4.5, 2.0, 0.0)。外部程序仍然写入 travel_distance,但该节点已不再从这个条目读取距离。

如果完全省略 distance 属性:

<CalculateGoal start="{robot_pose}" goal="{mission_goal}"/>

则使用声明的默认值 1 米,最终目标为 (2.5, 2.0, 0.0)。这两种修改都只调整 XML,保存后点击 Launch 即可,无需手动先点 Build。继续后面的示例前,可以恢复主示例的 distance="{travel_distance}"。

用断点观察目标被读取和写回

恢复主示例的距离和 XML 配置,保存并点击 Build。 在 CalculateGoal::tick() 打印目标的语句处,以及 OffsetGoal::tick() 打印目标的语句处分别设置断点,再点击 CMake Tools 的 Debug。

第一次暂停时,在变量窗口展开本地变量 goal,可以看到 (3.0, 2.0, 0.0);按 F5 继续到偏移节点的断点,同名本地变量的 x 坐标已经变成 3.5。再继续执行,终端会显示完整结果。两次断点所在的函数不同,本地变量也分别属于各自回调。


输入错误要在读取位置明确处理

本例常见的数据问题可以按发生位置区分:

修改发生什么本例的处理
将 target 改为 {unknown_goal}找不到有效的输入值getInput() 失败,报告读取错误
将固定坐标改为 2.0;3.0字段数量不符合约定转换函数报告 Pose2D requires x;y;yaw
将距离改为 distance="far"文本无法转换成 double读取失败,报告转换错误
把 OffsetGoal 移到 CalculateGoal 前面目标还没有被写入读取双向端口失败,后面的节点不再执行

这些 XML 对照都在编辑器中修改并保存,再点击 Launch,在集成终端中查看错误信息。

工厂可以在创建树时建立端口和条目的关系,但这不代表每个输入已经有业务值。数据是否准备好,仍要看外部初始化与前面节点的执行结果。

外部读取也要使用对应类型。这里调用 blackboard->get<Pose2D>("mission_goal"),不能因为打印出来像文本,就改成读取 std::string。直接访问 Blackboard 的 get() 与节点的 getInput() 是不同接口,不应假定它会执行相同的文本转换。


Blackboard 数据不会随节点状态自动清空

本例在 CalculateGoal 中产生目标,在 OffsetGoal 中更新目标;树任务结束后,main() 仍能读到最终值。 节点结束或被重置到 IDLE,不会自动删除这些 Blackboard 条目。

这也意味着,上一次任务留下的值不能自动代表下一次任务已经准备好。 组织重复任务时,应明确哪些输入由外部更新,哪些输出由当前任务重新产生,以及失败后是否允许继续使用已有结果。

本例中的输出节点每次成功执行都会重新写入目标,再由后续节点读取。后面使用子树时,还需要明确不同 Blackboard 范围之间的数据映射;共享一个键的写法不能直接套用到所有子树。


当前学习进度

本篇把输入、输出、双向端口、自定义类型转换以及外部 Blackboard 读写串成了完整工程。 下一篇学习持续任务与中止:用 StatefulActionNode 模拟导航,分别处理启动、运行检查和取消,让任务能够跨越多次 tick。