越过交叉编译的重重阻碍,我在将 ROS2 及其生态迁移到原生 OpenHarmony 平台上的过程中遇到了一个比较大的问题:ROS2 似乎无法加载插件形态的动态链接库!就是说,launch 一个 ROS2 应用(需要动态链接库)本身是可以的,但是这个 ROS2 应用如果使用到了动态链接库插件(一般通过 ROS2 的 class_loader 组件间接加载)就出问题了。

TL; DR:(太长不看版)最终解决方案请参见 “Final Solution” 一节;如果想看问题根源请参见 “问题根源” 一节。

下文将详细叙述调试经过,供读者思考、相互学习。举个实际迁移调试过程中的例子。下面是我编写的问题描述:


我在 OpenHarmony 上运行迁移的 Navigation2 框架时遇到一些问题,加载的 navigation2 相关的动态链接库出现加载失败的问题。

运行的应用环境是任意一个使用 navigation2 的 ROS 应用,例如 B 站鱼香肉丝 UP 开发的 fishbot 机器人的 navigation2.launch.py(删除 rviz2 结点)。

问题的图片信息如下:


并且不仅仅是一个 DifferentialMotionModellibmotions_lib.so),其他所有插件,如 StaticLayer(liblayers.so)全部报错。。

一、猜测:Symbols Not Found?

首先我考虑,会不会是符号缺失问题?在 OpenHarmony 上交叉编译可能有些链接参数和 runpath 设置有问题?

于是使用 readelf -dnm -D -Cldd 工具(从 glibc 上移植过来的),一通查找发现,并不缺少动态链接库,也不缺少符号。

那么排除符号缺失问题,只能是加载问题。为什么相同的动态链接库代码,在 Ubuntu 上正常运行,但在 OpenHarmony 上运行失败呢?它们最大的区别是 musl libc 和 glibc。

二、临时的解决方案?新问题出现

这样只能从 ROS2 加载所有插件的源码入手。通过源码层层查找可知,ROS2 主动加载动态链接库(尤其是插件)的逻辑位于 rcutils 包,以及 class_loader 包。

查找方法是先找加载 Nav2 插件(其实不仅仅是 Nav2 插件)的函数,按调用链溯源到 rcutils/src/shared_library.cclass_loader/include/class_loader/class_loader_core.hpp

我们发现,class_loader 中,创建插件中对象实例的函数中 dynamic_cast 总是返回空指针,提示转换失败。

我再次确认,Ubuntu 上并不存在这个问题,所以指针指向的数据区域理应是对的(因为代码逻辑没问题)?

为了快速确认是否是这里的问题,我临时将 dynamic_cast 改为 static_cast 来规避运行时检查,强制按照目标类型来使用目标内存,结果成功了?看起来没有,因为出现新的问题了:

注意:中间我们还修复了另一个问题,bad_weak_ptr,这个问题是 Humble 官方问题,和 OH 无关,解决方案 Github 都是有的,这里是 对应 issue对应 PR,我们照着修改 ros_navigation2/nav2_util/include/nav2_util/lifecycle_node.hppros_navigation2/nav2_util/src/lifecycle_node.cpp 就行,这里和讨论无关,不再赘述。

由于新问题 “Exception when loading BT: [Any:convert]: no known safe conversion between…” 在网络上甚至找不到相关求助帖,我们只能继续手动调试。

这个问题似乎是和 Behavior Tree 解析 XML(.../navigate_through_poses_w_replanning_and_recovery.xml)和实例化有关,并且看起来和前一个问题没关系(暂时的)。

因此我们继续补充日志,使用 execinfo.h 打印问题堆栈,在递归构造 Behavior Tree 的时候打印节点详细信息等等,一步步溯源。添加的第一版日志内容:

以及 XML 文件信息:

结合源码(behavior_tree_cpp_v3/src 中的 xml_parsing.cppbt_factory.cpp)看出,程序解析到 RemovePassedGoals 后,调用 XMLParser::Pimpl::createNodeFromXML 创建 Behavior Tree 节点时抛出上面的异常。

于是根据这个线索阅读源码、定向加强日志:

问题出在 BehaviorTreeFactory::instantiateTreeNode 执行加载 Behavior Tree 的插件 RemovePassedGoals 上!

阅读 bt_factory.hnav2_behavior_tree/plugins/action/remove_passed_goals_action.cpp 源码可知,插件加载过程中,会从 BehaviorTree::Blackboard(注释描述是在插件间存放 Behavior Tree 有类型数据的数据结构)上读取类型为 std::shared_ptr<tf2_ros::Buffer> 的数据。

这个数据在插件注册(configure)时被用 Any 的方法写到 Blackboard 中,而插件实例化(activate)时从 Any 中以 safe_any::cast<T> 的方法读取出来。

重点来了:但两种类型相同为什么会出错呢?我猛然想到上一个 dynamic_cast 失败的问题。。

三、问题的共性:C++ Template RTTI (Run-Time Type Information)

新问题中,std::shared_ptr<tf2_ros::Buffer> 恰好是一个模板实例化出来的类型,而旧问题中,dynamic_cast 映射的 impl::AbstractMetaObject<Base> 也是一个模板实例化的类型。

这两个问题的共性是,看起来它们的类型名称是一样的,但实际上运行时 libc 认为它们是不同的类型?这个验证起来简单,直接打印类型对应的 type info 和 typeid

以旧问题为例,使用打印调试法,检查 factoryMap 中究竟存放了什么,为什么类型不匹配?在 factory 赋值语句后面添加调试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// --- DEBUGGING BLOCK START ---
impl::AbstractMetaObjectBase* base_ptr = factoryMap[derived_class_name];
if (!factory && base_ptr) {
const std::type_info& src_type = typeid(*base_ptr);
const std::type_info& dst_type = typeid(impl::AbstractMetaObject<Base>);

CONSOLE_BRIDGE_logError("class_loader.impl: DYNAMIC CAST FAILURE DEBUGGING:");

// 1. Check raw pointer
CONSOLE_BRIDGE_logError(" Raw Pointer Address: %p", (void*)base_ptr);

// 2. Compare Type Names
CONSOLE_BRIDGE_logError(" Source Type Name: %s", src_type.name());
CONSOLE_BRIDGE_logError(" Target Type Name: %s", dst_type.name());

// 3. Compare Type Info Addresses
CONSOLE_BRIDGE_logError(" Source TypeInfo Address: %p", (void*)&src_type);
CONSOLE_BRIDGE_logError(" Target TypeInfo Address: %p", (void*)&dst_type);

// 4. Check Base Class TypeInfo
const std::type_info& base_param_type = typeid(Base);
CONSOLE_BRIDGE_logError(" Base Template Param TypeInfo Addr: %p", (void*)&base_param_type);
}
// --- DEBUGGING BLOCK END ---

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[component_container_isolated-1] Error:   class_loader.impl: DYNAMIC CAST FAILURE DEBUGGING:
[component_container_isolated-1] at line 295 in .../class_loader_core.hpp
[component_container_isolated-1] Error: Raw Pointer Address: 0x7f8ac2f430
[component_container_isolated-1] at line 298 in .../class_loader_core.hpp
[component_container_isolated-1] Error: Source Type Name: N12class_loader4impl10MetaObjectIN9nav2_amcl23DifferentialMotionModelENS2_11MotionModelEEE
[component_container_isolated-1] at line 301 in .../class_loader_core.hpp
[component_container_isolated-1] Error: Target Type Name: N12class_loader4impl18AbstractMetaObjectIN9nav2_amcl11MotionModelEEE
[component_container_isolated-1] at line 302 in .../class_loader_core.hpp
[component_container_isolated-1] Error: Source TypeInfo Address: 0x7ef19881a8
[component_container_isolated-1] at line 306 in .../class_loader_core.hpp
[component_container_isolated-1] Error: Target TypeInfo Address: 0x7efa22ad28
[component_container_isolated-1] at line 307 in .../class_loader_core.hpp
[component_container_isolated-1] Error: Base Template Param TypeInfo Addr: 0x7efa224840
[component_container_isolated-1] at line 311 in .../class_loader_core.hpp

关键来了:

1
2
[component_container_isolated-1] Error:      Source TypeInfo Address: 0x7ef19881a8
[component_container_isolated-1] Error: Target TypeInfo Address: 0x7efa22ad28

这两个类型的类型对象(type info object)的地址不同,这意味着 libc 将插件中的 AbstractMetaObject(0x7ef…)与 class_loader 中的 AbstractMetaObject(0x7efa…)视为完全不同的类型,尽管它们名称相同!

这正是典型的 Split RTTI(Run-Time Type Information)问题!

四、问题根源

为了验证我的想法,我先后执行:

1
2
nm -C -D libclass_loader.so | grep AbstractMetaObject
nm -C -D libmotions_lib.so | grep AbstracMetaObject

观察到第一条指令输出:

1
2
3
4
... (omit)
0000000000042078 V typeinfo for class_loader::impl::AbstractMetaObjectBase
0000000000016a6c V typeinfo name for class_loader::impl::AbstractMetaObjectBase
0000000000042060 V vtable for class_loader::impl::AbstractMetaObjectBase

第二条输出:

1
2
3
4
5
 ... (omit)
0000000000008110 V typeinfo for class_loader::impl::AbstractMetaObject<nav2_amcl::MotionModel>
0000000000008100 V typeinfo for class_loader::impl::AbstractMetaObjectBase
00000000000043f9 V typeinfo name for class_loader::impl::AbstractMetaObject<nav2_amcl::MotionModel>
000000000000443e V typeinfo name for class_loader::impl::AbstractMetaObjectBase

这证明了一件事,在两个动态链接库中,AbstractMetaObjectBase RTTI symbols 全标记为 Weak(V)。

询问了 GPT 后发现,这个加载行为对于 musl libc 和 glibc 是不同的:

Area glibc musl
Typeinfo merging forgiving strict
Symbol interposition permissive limited
RTLD_GLOBAL side effects common reduced
Weak RTTI symbols often merged not merged

于是我判断问题的根源:

  1. 两个库中都将符号 typeinfo for ... AbstractMetaObjectBase 标记为 V(弱引用)。
  2. musl libc 的动态链接器(ld-musl-xxx.so)对符号作用域边界要求更严格。当 class_loader 使用 dlopen 加载插件时,它不会将 RTTI symbols 加入全局符号表
  3. 再由于 RTTI symbols 不是全局可见(或插件处于隔离状态)的,插件无法 “看到” 主进程中的现有 TypeInfo;此时 musl libc 会 fallback 到使用 RTTI symbol local copy 模式:对每个没见过的 RTTI symbol,都在自己的内存空间中实例化创建一个新的 type info object

正因如此,跨动态链接库(DSOs)的 TypeInfo 对象的指针地址才会不一致,进而导致了两个相同的类型信息错误地存在两个不同的内存地址,进而导致 dynamic_cast<T> 以及 safe_any::cast<T> 这样的逻辑没法正确判断横跨插件传递的类型是否一致

到这里,我们根据调试结果以及分析过程,可以板上钉钉地说,就是这个原因,没有第二种可能(不过读者有异议欢迎讨论)。

五、解决方案

有了清晰的思路,问题就很好解决了。为了避免 RTTI symbols 在各个插件间不可见的问题,

  1. 首先是要确保 RTTI Symbols 能够正确导出,这个需要对所有可执行文件的链接过程(CMAKE_EXE_LINKER_FLAGS)加上 -rdynamic(或 -Wl,--export-dynamic);

  2. 然后需要让所有插件需要的 RTTI Symbols 显式加载到 global symbol table 中,让插件加载前这些 RTTI symbols 就已经位于内存中,这样再加载插件时,musl libc 能从 global symbol table 中找到,就不会错误创建额外的 type info object 了!

想要实现第二步有点难度,我们之前尝试更改所有 dlopen 加载 flags(例如 rcutils/src/shared_library.c),添加一个 RTLD_GLOBAL,但不行,因为 class_loader 自己定义了 AbstractMetaObject,它自己没法全局加载 RTTI Symbols。

替代方案是运行前使用 export LD_PRELOAD=/path/to/libclass_loader.so,强制预先加载 class_loader 的 RTTI symbols,大部分问题就解决了。

到这里,问题就能解决了吗?很可惜,还差一点。我们发现,仅仅是这样还是会出现 type info 不一致的问题。不应该啊?我们确定了问题根源,也找到了非常正确解决方案。。

最后这一个坑我怀疑是 OpenHarmony 留给我们的。因为我发现 OpenHarmony 的 musl libc 和官方的 musl libc 是不同的,美其名曰 “安全特性”(参见官方 third_party_musl 仓库):

我怀疑 musl libc 将不同动态链接库间共享全局 RTTI symbols 的机制改坏了,即便 LD_PRELOAD 提前加载了一些必要的库,RTTI 符号也没办法跨动态链接库共享(或者是 HUAWEI 官方为了“安全”有意为之。。)

为什么这么猜测?因为我使用 Final Solution 中的做法就能加载成功了。解法即原因,不言自明。。

欢迎懂 OpenHarmony musl libc 如何修改的朋友分享交流是不是这么回事。

Final Solution

  1. 确保 RTTI Symbols 能够正确导出,这个需要对所有可执行文件的链接过程(CMAKE_EXE_LINKER_FLAGS)加上 -rdynamic(或 -Wl,--export-dynamic);

  2. (OpenHarmony 上的权宜之计)运行前执行 export LD_PRELOAD=/path/to/libclass_loader.so:/path/to/liblayers.so/path/to 改成 libclass_loader.so 实际安装路径);

    liblayers.so 存放了其他插件所需的 RTTI Symbols,例如 Navigation2 StaticLayers 的定义。如果不添加,和 class_loader 在 OpenHarmony 下有同样问题,会导致 local/global_costmap 插件加载失败;

  3. (OpenHarmony 上的权宜之计)ROS2 源码将 src/ros/class_loader/include/class_loader/class_loader_core.hpp 中的 dynamic_cast 改为 static_cast

如果不是 OpenHarmony musl libc 而是普通 musl libc,则不需要 2、3 两步就行!

更多 ROS2 + OpenHarmony 技术解决方案,欢迎关注 OpenHarmony Robot PMC 一同交流!