Skip to content

Wiki

全局Blackboard与数据更新

About 8 min read

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

1.让多层子树读取外部更新的数据

官方资料:Global blackboard、4.10.0 示例。接口参考 blackboard.h、tree_node.h 与 updated_decorator.cpp。

组合任务篇通过 SubTree 显式映射输入输出。这适合表示子任务接口。另一类数据由外部长期更新,例如定位有效性、任务目标和电量;多层子树都可能需要访问。本篇建立独立的全局数据层,观察同名数据的作用域、写入序号与更新时间。

全局 Blackboard 仍然是运行时的数据容器。本篇先由主程序写入目标,再让主树和子树读取,逐步观察共享数据与更新记录。

2.global、root 与 subtree 是不同的容器

auto global_blackboard = BT::Blackboard::create();
auto root_blackboard = BT::Blackboard::create(global_blackboard);
auto tree = factory.createTreeFromFile(BT_XML_PATH, root_blackboard);

第一个 create 没有父容器,建立整个层级的最上层;第二个 create 的参数是父 Blackboard 指针,为主树建立独立容器。两个调用都返回 BT::Blackboard::Ptr,也就是共享指针。树持有主树 Blackboard,节点通过端口访问它。

global_blackboard       外部数据:mission_goal
    └── root_blackboard 主任务的数据
            └── 子树 Blackboard,各自保存局部任务数据
XML 输入 查找位置
goal="{mission_goal}" 当前 Blackboard 中的条目,或显式映射到的父条目
goal="{@mission_goal}" 沿父层级到最顶层,读取其 mission_goal
goal="loading_area" 固定字符串,不读取 Blackboard

@ 表示当前层级最顶层容器,没有独立的进程级单例。如果只创建一个 root Blackboard,最顶层就是它;本例额外建立 global 层,使外部数据与主树数据的归属更清楚。普通父子关系也不等于自动开放所有父键,跨边界仍需映射、autoremap 或显式 @。

子任务的必需输入输出继续适合显式端口映射,方便复用;稳定的跨任务外部数据才考虑全局层。不要把所有临时值都放进去,否则子树会依赖难以看见的全局键。

3.值相同不代表没有更新

假设外部两次报告相同目标:

第一次 set:loading_area
第二次 set:loading_area

两次字符串内容相同,但 Blackboard 收到了两次写入。条目的更新序号会随写入增加,时间戳也会更新。因此需要分别定义:

要判断什么 应比较什么
业务内容是否变化 当前值和上次值
是否发生过新写入 更新序号
最新数据是否超过允许年龄 写入时间戳与同一时钟的当前时间
是否是同一条业务任务 应用自己的 mission_id 等标识

Blackboard 序号不是 ROS 消息序号,也不是业务任务编号。它只说明这个条目被重新写过。

4.建立数据更新工程

本篇从本系列 脚本与前后置条件 中建立的 bt4_script 复制工程,复用已经准备好的构建配置:

  1. 在文件管理器中复制整个 bt4_script 文件夹,将副本重命名为 bt4_updates。
  2. 删除副本中的 build、install 文件夹(如果存在),再用 VSCode 新窗口打开 bt4_updates。
  3. 在顶层 CMakeLists.txt 修改项目名。依赖模块、预设和 BT_XML_PATH 配置保留,下面将完整替换 C++ 程序与任务 XML:
project(bt4_updates VERSION 1.0.0 LANGUAGES C CXX)

4.1.src/main.cpp

#include "behaviortree_cpp/bt_factory.h"

#include <chrono>
#include <cstdint>
#include <iostream>
#include <string>

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

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<std::string>("goal")};
    }

    BT::NodeStatus tick() override
    {
        std::string goal;
        auto stamp = getInputStamped<std::string>("goal", goal);
        if (!stamp)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", stamp.error());
        }

        const bool updated = stamp.value().seq != last_seq_;
        const auto age = std::chrono::steady_clock::now().time_since_epoch()
                         - stamp.value().time;
        std::cout << '[' << name() << "] goal=" << goal
                  << ", seq=" << stamp.value().seq
                  << ", updated=" << std::boolalpha << updated
                  << ", age_ms="
                  << std::chrono::duration_cast<std::chrono::milliseconds>(age).count()
                  << '\n';
        last_seq_ = stamp.value().seq;
        // 此处只打印观察结果;没有更新不代表本次观察失败。
        return BT::NodeStatus::SUCCESS;
    }

private:
    std::uint64_t last_seq_ = 0;
};

int main()
{
    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<ConsumeGoal>("ConsumeGoal");
        auto global_blackboard = BT::Blackboard::create();
        auto root_blackboard = BT::Blackboard::create(global_blackboard);
        global_blackboard->set("mission_goal", std::string("loading_area"));
        auto tree = factory.createTreeFromFile(BT_XML_PATH, root_blackboard);

        for (int round = 1; round <= 4; ++round)
        {
            if (round == 3)
            {
                // 相同值重新写入,序号仍会增加。
                global_blackboard->set("mission_goal", std::string("loading_area"));
            }
            if (round == 4)
            {
                global_blackboard->set("mission_goal", std::string("unloading_area"));
            }
            std::cout << "--- round " << round << '\n';
            const auto result = tree.tickExactlyOnce();
            std::cout << "Tree result: " << BT::toStr(result) << '\n';
        }
        return 0;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

4.2.trees/mission.xml

<root BTCPP_format="4" main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence>
            <ConsumeGoal name="main_reader" goal="{@mission_goal}"/>
            <SubTree ID="ObserveGoal"/>
        </Sequence>
    </BehaviorTree>
    <BehaviorTree ID="ObserveGoal">
        <ConsumeGoal name="sub_reader" goal="{@mission_goal}"/>
    </BehaviorTree>
</root>

ObserveGoal 没有为 mission_goal 写 SubTree 映射,仍能通过 @ 到达最顶层条目。两个 ConsumeGoal 是不同实例,各自保存 last_seq_,因此第一次读取时都会把同一次写入识别为新数据。

5.getInputStamped 的参数和结果

本例调用的重载可概括为:

template<typename T>
BT::Expected<BT::Timestamp> getInputStamped(const std::string& key, T& destination) const;
部分 本例含义
模板参数 std::string 期望读取的值类型
第一个参数 "goal" 当前节点的输入端口名,由 XML 映射到全局条目
第二个参数 goal 用引用接收读出的字符串
返回的 Expected 成功时包含时间信息;失败时可以读取 error()
stamp.value().seq 条目写入序号
stamp.value().time 最近写入时间;4.10.0 的条目写入使用 steady_clock

端口若绑定固定字面量,不具有外部 Blackboard 条目的正常写入历史,不能将其默认时间信息当成有效数据年龄。本例必须保留 {@mission_goal} 映射。

6.在 VSCode 中比较四轮结果

Configure、Build,选择 bt4_updates 为 Launch 目标。输出中的时间随实际运行变化,序号关系应如下:

轮次 本轮外部操作 两个读者的目标 seq updated
1 初始写入后首次读取 loading_area 1 true
2 没有重新写入 loading_area 1 false
3 相同字符串重新写入 loading_area 2 true
4 写入新目标 unloading_area 3 true

截图预留:全局目标的四轮读取。 展示主树与子树的两个读者,以及 seq 与 updated 的变化。

在 ConsumeGoal::tick() 读取后设置断点,比较 main_reader 与 sub_reader 的 this 指针、goal 和 last_seq_。第二轮容器里仍然有值,但没有新写入;第三轮值相同,更新序号却不同。

截图预留:两个读者的独立状态。 展示不同 this 地址及各自的 last_seq_。

7.用内置节点按更新驱动工作

4.10.0 还提供下面的组合方式,先作为独立 XML 对照片段:

<SkipUnlessUpdated entry="{@mission_goal}">
    <ConsumeGoal goal="{@mission_goal}"/>
</SkipUnlessUpdated>
内置节点 没有新写入时 有新写入时
WasEntryUpdated 返回 FAILURE 返回 SUCCESS
SkipUnlessUpdated 返回 SKIPPED,不启动子节点 执行子节点
WaitValueUpdate 返回 RUNNING,等待后续 tick 执行子节点

检查的是写入序号。SkipUnlessUpdated 与 WaitValueUpdate 的子节点一旦已经 RUNNING,会继续当前子任务;不能把“下一次数据没有更新”理解为立即中止已启动的动作。它们也不会自动订阅 ROS 消息。

把主树第一次 ConsumeGoal 包在 SkipUnlessUpdated 中,而保留后面的子树读者,再运行四轮。第二轮主读者被跳过,子树读者仍执行;第三轮相同值重新写入,主读者又执行。普通 Sequence 在部分子节点 SKIPPED 时可以继续并最终成功。

截图预留:只在更新后执行。 对照第二轮跳过与第三轮重新执行。

8.写入时间与业务有效性分别处理

本例 age_ms 是“本地条目距离最近写入过了多久”。如果收到一条本来就很旧的 ROS 消息,再把它写入 Blackboard,写入年龄会很小,但消息本身仍然可能过期。因此实际数据通常还需要携带传感器时间、接收时间或有效标志。

计时应使用一致时钟,不能把 steady_clock 的值当成 Unix 时间戳打印成日期。判断超时时限时,也应先确认是数据产生时间、接收时间还是 Blackboard 写入时间。

本例的外部写入与树执行顺序发生在同一线程。接入 ROS 回调后,多个相关字段的更新不是一个自动的业务事务;可将相关字段打包成一个结构体并一次写入,或由执行器统一调度。若通过引用操作 Blackboard 中的可变对象,应使用合适的锁定接口并控制引用生命周期,不能把库内部有锁等同于整段业务天然线程安全。

9.当前学习进度

本篇完成全局层级、显式 @ 访问、更新序号与写入年龄的观察。v4 扩展实践到此结束;下一篇通过 Groot2 查看树结构、执行状态和日志。


v4 学习路线 · 上一篇:脚本与前后置条件 · 下一篇:Groot2