Post

Linux 动态库卸载:dlclose 无法卸载 ROS 插件

Linux 动态库卸载:dlclose 无法卸载 ROS 插件

Linux 动态库卸载:dlclose 无法卸载 ROS 插件

Linux 动态库卸载:dlclose 无法卸载 ROS 插件

在开发长期运行的 Linux C/C++ 程序(尤其是机器人领域的中间件)时,为了节省内存或实现“热更新”,我们经常会用到动态库的运行时加载技术——dlopendlclose

在 ROS1 和 ROS2 中,大名鼎鼎的 pluginlibclass_loader 底层正是基于这一机制实现了插件化架构。然而,在内存受限的嵌入式设备上,许多开发者会绝望地发现:调用了 dlclose 后,内存并没有降下来,查看 /proc/<pid>/maps 发现该库的内存映射依然稳如泰山。

dlclose 到底是个纸老虎,还是我们用错了?本文将通过实际代码带你一探究竟。


1. 理想中的世界:dlclose 的基本机制

在操作系统的设计中,动态库加载的核心机制是引用计数(Reference Counting)

  1. 加载与计数 (dlopen):当调用 dlopen 加载一个动态库时,动态链接器(Dynamic Linker)会将其映射到进程的虚拟地址空间,并将其引用计数设为 1。如果该库依赖了其他库,依赖库的引用计数也会随之增加。如果对同一个库再次 dlopen,它不会重新加载,而是仅仅将引用计数 +1。
  2. 卸载与递减 (dlclose):当调用 dlclose 时,动态链接器会将该库及其依赖库的引用计数 -1。
  3. 真正的卸载条件只有当一个动态库的引用计数严格降为 0 时,动态链接器才会真正执行卸载操作。这包括:
    • 执行该库的析构清理(如被 __attribute__((destructor)) 标记的函数,以及 C++ 全局/静态对象的析构函数)。
    • 从进程的地址空间中解除内存映射(调用 munmap)。
    • 从动态链接器内部维护的数据结构(如 link_map 链表)中彻底移除记录。

2. 实践检验:一个干净的动态库是可以被卸载的

为了验证 dlclose 的有效性,我们编写一个包含大内存分配和内存碎片的测试用例。

我们创建两个动态库 a.sob.soa.so 分配 10MB,b.so 依赖 a.so,分配 20MB 并在堆上制造大量内存碎片。

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// a.cpp (编译为 liba.so)
#include <vector>
extern "C" {
    static std::vector<char> large_buffer_a(10 * 1024 * 1024); // 占 10MB
    int func_from_a() {
        for (int i = 0; i < 1000; i++) large_buffer_a[i * 1024] = i % 256;
        return 42;
    }
}

// b.cpp (编译为 libb.so,链接 liba.so)
#include <vector>
#include <cstdlib>
extern "C" int func_from_a();
extern "C" {
    static std::vector<char> large_buffer_b(20 * 1024 * 1024); // 占 20MB
    int func_from_b() {
        // 模拟复杂的内存碎片场景
        for (int i = 0; i < 1000; i++) {
            void* ptr = malloc(1024 + (i % 100));
            if (i % 3 == 0) free(ptr);
            large_buffer_b[i * 1024] = i % 256;
        }
        return func_from_a() + 1;
    }
}

主程序逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// main.cpp
#include <dlfcn.h>
#include <stdio.h>
#include <malloc.h>
// ... 省略 get_rss() 函数,用于读取 /proc/self/status 中的 VmRSS ...

int main() {
    printf("初始 RSS: %ld KB\n", get_rss());

    // 1. 加载 A
    void* handle_a = dlopen("./liba.so", RTLD_LAZY | RTLD_GLOBAL);
    printf("加载 liba.so 后 RSS: %ld KB\n", get_rss());

    // 2. 加载 B
    void* handle_b = dlopen("./libb.so", RTLD_LAZY);
    printf("加载 libb.so 后 RSS: %ld KB\n", get_rss());

    // 3. 执行 B
    typedef int (*func_t)();
    func_t func_b = (func_t)dlsym(handle_b, "func_from_b");
    if (func_b) func_b();
    printf("执行 func_from_b 后 RSS: %ld KB\n", get_rss());

    // 4. 卸载 B
    dlclose(handle_b);
    printf("卸载 libb.so 后 RSS: %ld KB\n", get_rss());

    // 强制堆内存回收给操作系统
    malloc_trim(0);
    printf("malloc_trim 后 RSS: %ld KB\n", get_rss());

    // 5. 卸载 A
    dlclose(handle_a);
    printf("卸载 liba.so 后 RSS: %ld KB\n", get_rss());

    return 0;
}

运行结果:

1
2
3
4
5
6
7
初始 RSS: 1528 KB
加载 liba.so 后 RSS: 13284 KB    (+ 约11MB,符合预期)
加载 libb.so 后 RSS: 33856 KB    (+ 约20MB,符合预期)
执行 func_from_b 后 RSS: 34604 KB
卸载 libb.so 后 RSS: 14296 KB    (- 约20MB,b.so 成功卸载!)
malloc_trim 后 RSS: 14292 KB
卸载 liba.so 后 RSS: 4032 KB     (- 约10MB,a.so 成功卸载!)

结论: 只要动态库是“干净”的,dlclose 是绝对能正常卸载库并归还内存的。


3. 进阶测试:C++ 实例能否被安全卸载?

在实际工程中,我们通常不会只导出普通的 C 函数,而是想在动态库里创建 C++ 对象。这需要通过 C 接口(Opaque Pointer)来导出工厂函数:

1
2
3
4
5
6
// 插件代码:导出实例的创建与销毁
extern "C" {
    void* create() { return new Controller(); }
    void destroy(void* p) { delete static_cast<Controller*>(p); }
    void doSomething(void* p) { static_cast<Controller*>(p)->Compute(); }
}

在主进程中:

1
2
3
4
5
6
7
8
9
10
// 强转函数指针并执行
auto create = (CreateFunc)dlsym(handle, "create");
auto destroy = (DestroyFunc)dlsym(handle, "destroy");
auto doSomething = (DoSomethingFunc)dlsym(handle, "doSomething");

void *obj = create();
doSomething(obj);
destroy(obj); // 必须在 dlclose 之前销毁!

dlclose(handle); // 成功卸载!

测试证明:只要遵循“在库内 new,在库内 delete”,并在 dlclose 前清理所有实例,基于 C 接口导出的 C++ 对象同样可以被完美卸载。

这其实就是 ROS pluginlibPLUGINLIB_EXPORT_CLASS 宏想要做的事情。


4. 残酷的现实:为什么 ROS 的插件无法被卸载?

既然上述基于 C 接口封装的 C++ 类能被卸载,为什么 ROS 的 pluginlib 插件一旦加载,就变成了“常驻内存”的牛皮癣呢?

这是因为 ROS 插件系统引入了复杂的 C++ 语言特性,导致了 引用计数永远无法清零底层装载器拒绝卸载

  1. 罪魁祸首一:全局静态注册宏 ROS 插件需要在编译时通过 PLUGINLIB_EXPORT_CLASS 宏将类注册到工厂中。这个宏的底层原理是生成了一个全局静态对象。当宿主程序通过 class_loader 加载库时,这些静态对象会被初始化并注册到宿主进程的内存单例中。只要宿主的注册表没有被彻底清空,宿主就一直持有该库的符号引用。

  2. 罪魁祸首二:C++ RTTI 与 type_info pluginlib 深度依赖了 C++ 的运行时类型信息(typeiddynamic_cast)。在 Linux (glibc) 环境下,如果动态库包含独特的 RTTI 符号,为了防止抛出异常时找不到类型信息导致进程崩溃,链接器(ld)或动态加载器往往会隐式地将该动态库标记为 RTLD_NODELETE。这意味着即使你调用了 dlclose,操作系统也拒绝解除该库的内存映射。

  3. 罪魁祸首三:底层依赖链的纠缠 插件编译时必然依赖了 pluginlibclass_loader 等底层核心库。而这些核心库又被 ROS 的宿主节点(Node)强持有。这形成了一个复杂的符号网,导致插件动态库的引用计数极难降到绝对的 0。

总结与建议

在 Linux 环境下:

  1. dlclose 本身是完全有效且无内存泄漏的。
  2. 纯 C 接口严格隔离的 C++ 工厂模式 开发的动态库,可以实现完美的热加载与卸载。
  3. ROS 的 pluginlib 在设计之初,就不是为了“热卸载”而生的。 它的主要目的是为了实现“多态配置”和“延迟加载”。在内存受限的嵌入式设备上,如果你寄希望于通过反复加载/卸载 ROS 插件来压榨内存,这注定会是一场徒劳。

如果你真的需要在 ROS 中实现严格的内存回收与隔离,放弃单进程下的动态库卸载,转而使用多进程架构(如 ROS2 的 Lifecycle Nodes),让操作系统的进程管理来为你擦屁股,才是最安全、最稳妥的选择。

This post is licensed under CC BY 4.0 by the author.