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 檢視樹結構、執行狀態和日誌。