Wiki
全局Blackboard與數據更新
本文由簡體中文內容確定性轉換,並受版本化術語表保護。
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 查看樹結構、執行狀態和日誌。