Wiki
全局Blackboard与数据更新
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 复制工程,复用已经准备好的构建配置:
- 在文件管理器中复制整个
bt4_script文件夹,将副本重命名为bt4_updates。 - 删除副本中的
build、install文件夹(如果存在),再用 VSCode 新窗口打开bt4_updates。 - 在顶层
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 查看树结构、执行状态和日志。