多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Navigation2自定义Behavior插件开发指南:从原理到实战

Navigation2自定义Behavior插件开发指南:从原理到实战 我一直觉得Navigation2最容易被低估的部分就是它的Behavior Tree插件机制。很多人用默认的导航行为树用得很顺但一旦遇到我要让机器人在某个特殊位置停下来转一圈再走这种需求就卡住了。改主程序太粗暴。硬在行为树XML里塞一堆Condition又做不到复杂逻辑。其实Navigation2早就把扩展口留好了就是Behavior插件。这篇文章就围绕怎么创建自己的Behavior插件把从原理到落地、从编译到调试的完整路径讲清楚适合已经在用Navigation2跑导航、但还没动过插件机制的开发者。1. 先搞清楚Behavior插件在Navigation2里到底扮演什么角色1.1 导航任务为什么需要行为树Navigation2的导航任务从A点到B点看似简单实际包含了一长串决策先算路径然后控制机器人沿着路径走途中可能遇到障碍物要绕开走不动了要重新规划到了目标点要确认确实到位了。这些决策有先后、有条件、会循环用传统的C状态机或者大循环写起来非常啰嗦。Behavior Tree行为树本身就是干这个的它把导航拆分成了控制流节点Sequence、Fallback、Parallel和执行节点Action、Condition。Navigation2把整套导航逻辑组织成了几棵固定结构的行为树默认的bt_navigator会在启动时加载这些XML文件运行时按照树的结构执行。也就是说导航策略的骨架是树树的叶子就是各种Behavior插件。理解这一点很重要你改导航行为不是在改nav2_bt_navigator这个节点本身而是在改树叶子节点的行为。1.2 什么情况下必须自己写插件默认安装的Navigation2已经带了一批官方插件比如ComputePathToPose、FollowPath、NavigateThroughPoses、IsStuck、RecoveryNode这些。但实际项目里默认插件很快就不够用了。举几个我真实遇到过的场景你希望机器人先执行一个检查目标点是否可达的预判逻辑如果不可达走另一条分支你希望在路径规划前先等待某个传感器数据稳定你希望机器人执行完FollowPath之后额外做一个发出声光提示的动作你希望行为树能够动态读取任务ID并写入日志便于现场调试。这些需求靠组合现有插件是凑不出来的必须写新的Behavior节点。所以会写自定义插件本质上是把Navigation2从能用的导航变成适合你业务的导航的关键一步。1.3 pluginlib机制Navigation2如何做到加载你写的插件自定义Behavior插件不是被编译进bt_navigator主程序的它依靠ROS2的动态加载机制pluginlib来运行。pluginlib的原理可以用一句话概括你的插件以共享库.so形式存在包描述文件里声明了插件类型和对应类主程序在运行时根据字符串名动态加载这个类。这种行为叫依赖倒置。bt_navigator不需要知道你的插件类具体叫什么、在哪个包里它只需要知道你提供的插件名比如sensor_check就能在运行时找到对应的共享库并实例化。所以写插件主要做四件事继承官方基类实现接口用宏导出插件类在package.xml里声明插件在行为树XML里引用插件名。前面的准备工作很关键因为很多人写着写着就发现自己明明写了正确的类行为树却报Behavior Tree Node xxx not found原因基本都在第三步或者编译链接上。2. 搭好工程框架从零创建一个可被pluginlib发现的ROS2包2.1 环境准备我默认你用的是ROS2 Humble或更高版本已经装好了Navigation2。没有装的话先确认一下基础依赖sudo apt install ros-$ROS_DISTRO-navigation2 ros-$ROS_DISTRO-nav2-bringup创建包的时候记得把依赖带上ros2 pkg create nav2_behavior_plugin_tutorial \ --build-type ament_cmake \ --dependencies rclcpp nav2_core nav2_msgs nav2_behavior_tree \ behaviortree_cpp_v3 pluginlib这里nav2_behavior_tree和behaviortree_cpp_v3尤其重要前者提供基类和工具函数后者是行为树C库本身。漏掉任何一个编译期或者运行期都会莫名其妙出问题。2.2 包结构设计一个规范的插件包建议这样组织nav2_behavior_plugin_tutorial/ ├── CMakeLists.txt ├── package.xml ├── include/ │ └── nav2_behavior_plugin_tutorial/ │ ├── check_remaining_path.hpp │ └── set_led_action.hpp ├── src/ │ ├── check_remaining_path.cpp │ └── set_led_action.cpp ├── behavior_trees/ │ └── custom_bt.xml └── config/ └── params.yaml我的经验是behavior_trees目录很有必要。因为插件写完最终要在一个自定义行为树XML里使用放同一个包里版本好对应。2.3 package.xml里容易被忽略的插件声明package.xml不只是依赖声明它还会被pluginlib用来发现插件。你必须在package标签内加上这样一段export build_typeament_cmake/build_type nav2_behavior_tree plugin namecheck_remaining_path classnav2_behavior_plugin_tutorial::CheckRemainingPath/ /nav2_behavior_tree /export注意这里的nav2_behavior_tree标签名是约定好的对应pluginlib在bt_navigator里配置的plugin_info查找规则。如果你的包没有这段声明即使编译成功bt_navigator启动时也会提示找不到插件。这个位置的坑我踩过很多次。有一次我只是把export标签写到了package外面编译无报错但运行时死活加载不出来查了一个下午才发现是XML结构层级错了。建议写完后用xmllint --noout package.xml先验证一下。3. 核心实现以剩余路径长度检查为例写一个Condition节点为了说明问题我设计一个实际的插件检查当前剩余路径是否超过了设定阈值。这个在长廊、隧道类场景里很好用——如果剩余路径还很长就让机器人继续走如果很短就走一个独立的收尾分支。这个节点属于Condition类型返回SUCCESS或FAILURE不修改导航状态。3.1 理解nav2_behavior_tree的节点基类Navigation2给Behavior插件的开发者提供了几个现成基类nav2_behavior_tree::BtActionNode对应行为树里的Action节点可调on_tick()、on_wait_for_result()nav2_behavior_tree::BtConditionNode对应Condition节点实现condition_check()即可nav2_behavior_tree::BtDecoratorNode对应Decorator节点nav2_behavior_tree::BtServiceNode如果节点里需要通过Service调其它节点这个更省事。如果你查看源码会发现这些基类最终都继承自BT::ActionNodeBase或BT::ConditionNodeBase。所以就算你想完全脱离框架手写也不难只是会丢很多东西比如initialize()、on_tick()这些统一模板方法。写Condition节点最简单因为处理逻辑相对独立适合作为第一个练手插件。3.2 头文件声明先看头文件#ifndef NAV2_BEHAVIOR_PLUGIN_TUTORIAL__CHECK_REMAINING_PATH_HPP_ #define NAV2_BEHAVIOR_PLUGIN_TUTORIAL__CHECK_REMAINING_PATH_HPP_ #include string #include behaviortree_cpp_v3/condition_node.h #include nav2_behavior_tree/bt_condition_node.hpp #include nav2_msgs/msg/behavior_tree_log.hpp #include rclcpp/rclcpp.hpp namespace nav2_behavior_plugin_tutorial { class CheckRemainingPath : public nav2_behavior_tree::BtConditionNode { public: CheckRemainingPath( const std::string condition_name, const BT::NodeConfiguration conf); BT::NodeStatus condition_check() override; static BT::PortsList providedPorts() { return { BT::InputPortdouble(threshold, 1.0, Path remaining distance threshold), BT::InputPortdouble(remaining_path, 0.0, Current remaining path length) }; } }; } // namespace nav2_behavior_plugin_tutorial #endifprovidedPorts()是BehaviorTree.CPP定义输入输出的入口。插件节点通过端口从行为树接收数据。端口类似函数的参数允许行为树XML在运行时传入不同的阈值/路径值插件本身不用硬编码。如果没有端口节点就只能读全局参数灵活性大打折扣。3.3 源码实现与状态返回逻辑实现文件#include nav2_behavior_plugin_tutorial/check_remaining_path.hpp #include memory #include string #include behaviortree_cpp_v3/bt_factory.h #include nav2_behavior_tree/bt_utils.hpp #include rclcpp/rclcpp.hpp namespace nav2_behavior_plugin_tutorial { CheckRemainingPath::CheckRemainingPath( const std::string condition_name, const BT::NodeConfiguration conf) : BtConditionNode(condition_name, conf) { node_ config().blackboard-getrclcpp::Node::SharedPtr(node); } BT::NodeStatus CheckRemainingPath::condition_check() { double threshold 0.0; double remaining 0.0; getInput(threshold, threshold); getInput(remaining_path, remaining); RCLCPP_DEBUG( node_-get_logger(), CheckRemainingPath: threshold%.2f, remaining%.2f, threshold, remaining); if (remaining threshold) { return BT::NodeStatus::SUCCESS; } return BT::NodeStatus::FAILURE; } } // namespace nav2_behavior_plugin_tutorial #include behaviortree_cpp_v3/bt_factory.h BT_REGISTER_NODES(factory) { factory.registerNodeTypenav2_behavior_plugin_tutorial::CheckRemainingPath( check_remaining_path); }这段代码有几个细节值得展开构造函数里从黑板Blackboard拿node。Navigation2的bt_navigator会把ROS2节点指针放进黑板键名是node。这是官方约定很多自定义节点都会用到比如打日志、发话题。如果你忘了这一行节点里想用ROS2日志就得自己另外想办法。condition_check()里用了getInput()。BehaviorTree.CPP的端口读取和模板类型紧密相关。你必须在providedPorts()里声明这个端口并且在XML里正确传入否则getInput会返回false或抛异常。我习惯先判断返回值或者用默认值兜底避免因为某次XML漏传参数导致整个行为树崩溃。状态返回Condition节点只能返回SUCCESS或FAILURE不能返回RUNNING。这是行为树语义决定的。如果你想实现等待一下再看看这种逻辑那得用Action节点不是Condition节点。插件导出用了BT_REGISTER_NODES(factory)。这个宏会指导BehaviorTree.CPP在加载插件时把类注册到工厂。注册名check_remaining_path必须和XML里使用的节点名一致也和package.xml里plugin name一致。三个名字如果对不上一定是运行时找不到。3.4 加入端口对接从黑板上读真实剩余路径上面例子里的remaining_path是通过端口从外部传入的但导航过程中谁来计算剩余路径呢一般是在行为树里用另一个节点或者自定义的Action计算好通过黑板或端口传过来。为了让插件在真实的Navigation2管线里用起来我建议在XML里这样配置BehaviorTree IDCustomNav Sequence nameroot ComputePathToPose goal{goal}/ FollowPath path{path} controller_idFollowPath/ CheckRemainingPath threshold0.5 remaining_path{computed_remaining_path}/ /Sequence /BehaviorTree这里的{computed_remaining_path}对应黑板上某个字段它可能来自另一个自定义Action的setOutput。BehaviorTree.CPP的端口绑定用花括号语法和直接传字面值有本质区别。传字面值就是固定的用花括号就是动态从黑板读。写端口代码时我的建议是尽量用端口去接数据而不是自己从节点参数里取。这样插件复用性高同一个插件可以在不同的树里用不同的阈值不用改代码。4. CMakeLists和编译配置大多数加载失败都在这里出问题4.1 核心CMake配置写完代码最关键的就是让编译器生成一个可被pluginlib发现的共享库。CMakeLists.txt需要这样写cmake_minimum_required(VERSION 3.8) project(nav2_behavior_plugin_tutorial) if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES Clang) add_compile_options(-Wall -Wextra -Wpedantic) endif() find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(nav2_core REQUIRED) find_package(nav2_msgs REQUIRED) find_package(nav2_behavior_tree REQUIRED) find_package(behaviortree_cpp_v3 REQUIRED) find_package(pluginlib REQUIRED) include_directories( include ) add_library(nav2_behavior_plugin_tutorial_plugins SHARED src/check_remaining_path.cpp ) ament_target_dependencies( nav2_behavior_plugin_tutorial_plugins rclcpp nav2_core nav2_msgs nav2_behavior_tree behaviortree_cpp_v3 pluginlib ) pluginlib_export_plugin_description_file(nav2_behavior_tree plugins/plugins.xml) install(TARGETS nav2_behavior_plugin_tutorial_plugins ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin ) install(DIRECTORY include/ DESTINATION include ) ament_export_include_directories(include) ament_export_libraries(nav2_behavior_plugin_tutorial_plugins) ament_export_dependencies(${dependencies}) ament_package()这里最关键的一行是pluginlib_export_plugin_description_file(nav2_behavior_tree plugins/plugins.xml)这个宏会把plugins.xml文件安装到固定位置并建立pluginlib的索引。如果缺少这行即使你在package.xml里写了插件声明系统也搜不到你的插件描述文件。4.2 plugins.xml的正确书写方式有些开发者把插件声明只写在package.xml里而漏了plugins.xml这是不对的。我建议plugins.xml和package.xml里的插件声明同时存在。plugins.xml长这样library pathlib/libnav2_behavior_plugin_tutorial_plugins class namecheck_remaining_path typenav2_behavior_plugin_tutorial::CheckRemainingPath base_class_typenav2_behavior_tree::BtConditionNode description Condition node that checks whether the remaining path length is below a threshold. /description /class /librarylibrary path对应生成的共享库路径。name要和代码注册名、XML节点名一致。type是全限定类名。base_class_type写基类名。这四项任何一个对不上插件加载都会失败。4.3 为什么我建议同时维护两处插件声明pluginlib的插件发现机制是这样当你调用pluginlib::ClassLoader时它会扫描所有包的package.xml里的export段找到对应的nav2_behavior_tree标签然后去读取里面指向的plugins.xml文件。也就是说package.xml是入口索引plugins.xml是实际描述。两者是链式关系不是二选一。只写一个的话运行时经常出现Class not found或者Library not loaded的诡异错误。4.4 编译和快速验证编译就用常规方式colcon build --packages-select nav2_behavior_plugin_tutorial source install/setup.bash编译完成后先别急着跑导航用ROS2自带工具验证插件是否被正确识别ros2 pkg prefix nav2_behavior_plugin_tutorial ls $(ros2 pkg prefix nav2_behavior_plugin_tutorial)/lib能看到libnav2_behavior_plugin_tutorial_plugins.so就说明编出来了。然后验证插件描述是否被pluginlib索引到可以写一小段Python或C测试或者直接用bt_navigator的日志。更轻量的是用内置工具ros2 run nav2_behavior_tree bt_navigator --ros-args -p plugin_names:[check_remaining_path]虽然这个命令不完整但能快速暴露出插件找不到的问题。保险起见我还是习惯直接启动导航观察启动日志里的加载列表。5. 运行时验证与实战调试别怕报错一步步把问题摊开5.1 在行为树XML里使用自定义插件写一个测试用行为树custom_bt.xml放到behavior_trees目录root main_tree_to_executeMainTree BehaviorTree IDMainTree Sequence namecustom_root CheckRemainingPath threshold0.8 remaining_path{actual_remaining}/ NavigateToPose goal{goal}/ /Sequence /BehaviorTree /root注意NavigateToPose本身也是一个Behavior插件要确保你的Navigation2环境里有这个插件。如果你的环境中没有可以用别的现有插件替代。5.2 通过bt_navigator加载自定义树要让bt_navigator加载你的自定义行为树一般有两种方式。一种是把Nav2参数里的default_bt_xml_filename指到你自定义的XML另一种是在nav2_bringup的launch文件里修改参数。我测试时更喜欢直接用命令行改参数ros2 run nav2_bt_navigator bt_navigator \ --ros-args -p default_bt_xml_filename:/path/to/your/custom_bt.xml当然bt_navigator依赖一堆服务/话题通常还是通过完整导航launch来启动。为了快速测试插件也可以单独写一个极简的测试节点不启动整个导航栈直接加载行为树运行。这个方式对于复杂插件我认为非常有价值因为它能隔离插件本身逻辑问题和导航环境问题。5.3 加载失败排查清单我把最常见的几类问题整理成了一个表照着查效率高现象大概率原因排查方向Behavior Tree Node check_remaining_path not found插件未被注册检查BT_REGISTER_NODES里的注册名、XML节点名、plugins.xml里的name是否一致Failed to load library共享库路径不对确认library path是不是对应实际生成的.so文件能加载但执行时崩溃端口类型不匹配确认getInput的模板类型和XML传入类型一致插件执行但日志无输出node_为空确认构造函数里是否从黑板正确取到了node指针colcon build找不到某些头文件依赖缺失检查ament_target_dependencies和find_package是否都覆盖这里面节点找不到是最常见的。我自己的教训是不要只查代码先查三个名字插件名、类名、文件名是否完全一致再查package.xml和plugins.xml是否都写了最后再怀疑代码逻辑。5.4 我调试自定义Behavior插件的完整流程分享一个我实际常用的调试套路。先起导航环境然后把bt_navigator的日志级别调到debugros2 run nav2_bt_navigator bt_navigator \ --ros-args -p default_bt_xml_filename:/path/to/custom_bt.xml \ --log-level debug日志里会有这样一行[bt_navigator]: Initializing behavior tree MainTree [bt_navigator]: Creating behavior tree node check_remaining_path如果看到第二行说明插件加载成功。如果只看到第一行然后就报not found那问题基本就在注册名或XML节点名。我曾经遇到过一个特别隐蔽的问题插件名在BT_REGISTER_NODES里注册为check_remaining_pathXML里也写的这个但是plugins.xml里的name字段写成了CheckRemainingPath首字母大写。XML大小写敏感运行时完全匹配不上一直报not found。这个教训我记了很久。6. 进阶从同步节点到异步节点以及更复杂的现实场景6.1 当Condition不够用时Action和Service节点实际项目中很多行为不只是判断一下状态还要真正去执行某个动作。比如让机器人旋转一个角度、播放一段语音、设置一个IO。这类任务需要的是Action节点比如BtActionNode或者Service节点比如BtServiceNode。判断标准很简单如果只是读取状态后返回成功/失败不阻塞、不耗时 → Condition节点如果执行一个持续动作需要等待完成、可能耗时较长 → Action节点如果执行一个短请求/响应式服务调用 → Service节点。6.2 一个Action节点的骨架以设置一个LED状态为例它的头文件大概长这样#include nav2_behavior_tree/bt_action_node.hpp #include your_msgs/action/set_led.hpp class SetLedAction : public nav2_behavior_tree::BtActionNodeyour_msgs::action::SetLED { public: SetLedAction( const std::string action_name, const BT::NodeConfiguration conf); void on_tick() override; BT::NodeStatus on_success() override; BT::NodeStatus on_aborted() override; static BT::PortsList providedPorts() { return { BT::InputPortbool(led_on, false, LED state), BT::OutputPortbool(result, false, Operation result) }; } };在on_tick()里把端口数据转成action goal在on_success()里根据反馈设置输出端口。这个模式是Navigation2官方插件比如ComputePathToPose的标准写法照着官方源码的套路写基本不会错。6.3 异步技巧不要阻塞行为树线程一个常见误区在tick()里执行一个长时间阻塞的操作。BehaviorTree.CPP的tick是同步的如果你在tick里sleep(10)整棵树都会卡住所有其他节点都无法继续。正确的做法是Action类内部维护状态返回RUNNING直至操作完成BtActionNode基类本身已经封装了action client的异步等待逻辑所以尽量用action通信来承载耗时操作如果只是短暂等待可以考虑BT::DelayNode这类现成的装饰器但不要把它当成万能方案。我在自己的项目里也写过一些假异步的代码当时图省事把等待逻辑放在tick里结果导致星型拓扑下多个机器人同时调度时行为树乱掉。后来改成真正的异步Action问题才消失。6.4 让自定义Behavior与NavigateThroughPoses等官方插件联动Navigation2的NavigateThroughPoses本身也支持多目标点导航它也是一个插件。你可以把自定义插件插在它前后NavigateThroughPoses goals{goals}/ CheckRemainingPath threshold0.5 remaining_path{computed_remaining_path}/ RecoveryNode number_of_retries3 nameRecoverAfterShortPath ReachedGoal/ Spin/ /RecoveryNode这样当剩余路径较短时行为树可以决定进入一个收尾恢复模式而不是继续盲目导航。这个组合是很多实际项目里最后几米靠手动微调思路的程序化表达。7. 最后的实战建议把插件写清楚运维才能省心写自定义Behavior插件代码本身的难度不算高难的是调试链路长、报错提示不直观、还涉及三个配置文件之间的名字一致性。我的建议是在项目里沉淀一个插件清单文档记录每个插件的注册名、类名、plugins.xml中的name、用途、输入输出端口。后续接手的人不用从零开始猜。另外尽量用端口传参少用全局静态变量。多机器人系统里多个行为树实例可能共享同一个插件类静态变量会导致数据串扰。我之前遇到过机器人A设置的阈值跑到了机器人B的环境里排查了很久才意识到是静态变量惹的祸。最后如果你想深入学习我建议直接阅读Navigation2官方仓库里nav2_behavior_tree包里的plugins目录。里面既有BtActionNode的用法也有各种Condition、Decorator的完整写法比任何文档都直接。对照着改写出自己的插件是最快的学习路径。
返回列表