你想在 Rust 中实现动态库热重载?

本文是对 So you want to live-reload Rust 的整理与翻译


这篇文章起源于一个问题:

"你能不能教我们,如何在文件改变时自动重新加载一个 dylib?"

可以,我们来实现它。

但在实现之前,得先搞清楚:dylib 到底是什么?


什么是 dylib?

dylib 是 "dynamic library"(动态库)的缩写,也叫 "shared library"(共享库)、"shared object"(共享对象)、"DLL" 等等。

要理解它,我们先从它的对立面------非动态库------开始讲起。

从 C 程序说起

以 Linux 下的 GCC 和 binutils 为例,假设我们要写一个打招呼的程序:

c 复制代码
// main.c
#include <stdio.h>

void greet(const char *name) {
    printf("Hello, %s!\n", name);
}

int main(void) {
    greet("moon");
    return 0;
}
bash 复制代码
$ gcc -Wall main.c -o main
$ ./main
Hello, moon!

这只是一个普通函数,不是动态库。

对象文件(Object File)

我们可以把 greet 函数拆到单独的文件里,分别编译成对象文件(.o):

bash 复制代码
$ gcc -Wall -c greet.c
$ file greet.o
greet.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

nm 查看符号表:

bash 复制代码
$ nm greet.o
                 U _GLOBAL_OFFSET_TABLE_
0000000000000000 T greet     # T = 已定义的文本段符号
                 U printf    # U = 未定义(需要外部提供)

$ nm main.o
                 U _GLOBAL_OFFSET_TABLE_
                 U greet     # main.o 需要 greet,但没有提供
0000000000000000 T main

把两个对象文件链接在一起,才能生成可执行文件:

bash 复制代码
$ gcc main.o greet.o -o main
$ ./main
Hello, stars!

这里虽然有个"动态链接"的可执行文件,但仍然没有我们自己的动态库参与。


动态加载:dlopen / dlsym / dlclose

想在运行时加载一个库,用的是动态链接器提供的接口。在 glibc 下,这套 API 藏在 libdl.so 里:

c 复制代码
// load.c
#include <dlfcn.h>
#include <stdio.h>

typedef void (*greet_t)(const char *name);

int main(void) {
    void *lib = dlopen("./libgreet.so", RTLD_LAZY);
    if (!lib) {
        fprintf(stderr, "failed to load library\n");
        return 1;
    }

    greet_t greet = (greet_t) dlsym(lib, "greet");
    if (!greet) {
        fprintf(stderr, "could not look up symbol 'greet'\n");
        return 1;
    }

    greet("venus");
    dlclose(lib);
    return 0;
}

编译时需要链接 libdl

bash 复制代码
$ gcc -Wall load.c -o load -ldl

为什么普通可执行文件不能被 dlopen 加载?

前面我们试图用 dlopen("./main", ...) 加载普通可执行文件,但失败了。原因是:

nm -D main(注意 -D 是"动态符号表")查看,发现 greet 根本不在动态符号表里。动态链接器只能看到动态符号表,所以找不到 greet

bash 复制代码
$ nm -D main
                 w __cxa_finalize@@GLIBC_2.2.5
                 # 注意:没有 greet!
                 U printf@@GLIBC_2.2.5

使用 LD_DEBUG 调试动态链接

LD_DEBUG 环境变量可以让动态链接器输出详细的调试信息:

bash 复制代码
$ LD_DEBUG=files ./load 2>&1 | sed -E -e 's/^[[:blank:]]+[[:digit:]]+:[[:blank:]]*//' -e '/^$/d'

过滤输出后,能看到库的加载、初始化和销毁全过程:

bash 复制代码
file=libdl.so.2 [0];  needed by ./load [0]
file=libc.so.6 [0];  needed by ./load [0]
calling init: /lib64/ld-linux-x86-64.so.2
calling init: /usr/lib/libc.so.6
calling init: /usr/lib/libdl.so.2
initialize program: ./load
transferring control: ./load
file=./libmain.so [0];  dynamically loaded by ./load [0]
calling init: ./libmain.so
calling fini: ./libmain.so [0]
file=./libmain.so [0];  destroying link map

这里的 LD_DEBUG=filesLD_DEBUG=all 精简得多,后者足有 666 行输出。

一个有趣的彩蛋:既是可执行文件又是动态库

通过一些特殊技巧,可以让一个文件同时作为可执行文件运行,也能被 dlopen 加载:

c 复制代码
#include <unistd.h>
#include <stdio.h>

// 手动指定解释器路径(正常情况下编译器会自动加,但 -shared 模式下不会)
const char interpreter[] __attribute__((section(".interp"))) = "/lib64/ld-linux-x86-64.so.2";

void greet(const char *name) {
    printf("Hello, %s!\n", name);
}

// 自定义入口点(不能 return,必须调 _exit)
void entry() {
    greet("rain");
    _exit(0);
}
bash 复制代码
$ gcc -Wall -shared main.c -o libmain.so -Wl,-soname,libmain.so -Wl,-e,entry

-Wl,-foo,bar 是 GCC 向链接器传参数的方式。-soname 设置库名,-e,entry 指定入口点。

bash 复制代码
$ ./libmain.so
Hello, rain!       # 作为可执行文件运行

$ ./load
Hello, venus!      # 作为动态库被 dlopen 加载

这也解释了一个有趣的事实:Linux 下的动态链接器 ld-linux-x86-64.so.2 本身就是一个可执行文件/动态库混合体!


动态库的查找路径

构建一个纯 C 动态库非常简单:

bash 复制代码
$ gcc -Wall -shared greet.c -o libgreet.so

C99 函数默认有外部链接(external linkage),不需要额外声明。如果不想导出,用 static 关键字申请内部链接。

为什么动态链接器找不到库?

库的查找分两个阶段:

  1. 编译时 :静态链接器(ld)通过 -L 参数找到库
  2. 运行时 :动态链接器(ld.so)通过自己的搜索路径找到库

这两套搜索路径是独立的。编译能通过,不代表运行时能找到!

动态链接器的搜索顺序大致是:

  • LD_LIBRARY_PATH 环境变量
  • /etc/ld.so.cache(由 ldconfig 维护的缓存)
  • ELF 文件中的 RPATH/RUNPATH 字段
bash 复制代码
$ LD_LIBRARY_PATH="${PWD}/.." ./target/debug/greet-rs
Hello, fresh coffee!

patchelf 可以在编译后直接修改 ELF 文件的 RUNPATH:

bash 复制代码
$ patchelf --set-rpath "${PWD}/.." ./target/debug/greet-rs
$ ./target/debug/greet-rs
Hello, fresh coffee!

甚至支持相对路径,$ORIGIN 代表可执行文件所在目录(注意用单引号,防止 shell 展开):

bash 复制代码
$ patchelf --set-rpath '$ORIGIN/../../..' ./target/debug/greet-rs

readelf 验证:

bash 复制代码
$ readelf -d ./target/debug/greet-rs | grep RUNPATH
 0x000000000000001d (RUNPATH)  Library runpath: [$ORIGIN/../../..]

顺带一提,很多人用 ldd 查看依赖,但它实际上是个 bash 脚本,背后是让动态链接器自己列出依赖。更推荐的方式是用 readelflddtree(树形显示依赖关系)。


进入 Rust 世界

一个简单的 Rust 程序

rust 复制代码
fn main() {
    greet("fresh coffee");
}

fn greet(name: &str) {
    println!("Hello, {}", name);
}
bash 复制代码
$ cargo run -q
Hello, fresh coffee

Rust 编译出的可执行文件同样是 ELF 格式,用 nm -D --defined-only 查看,它也不导出任何动态符号------因为它是个二进制,不是库。

Rust 如何链接 C 动态库?

在 Rust 中声明外部 C 函数,用 #[link] 属性和 extern "C" 块:

rust 复制代码
use std::{ffi::CString, os::raw::c_char};

#[link(name = "greet")]
extern "C" {
    fn greet(name: *const c_char);
}

fn main() {
    let name = CString::new("fresh coffee").unwrap();
    unsafe {
        greet(name.as_ptr());
    }
}

一个常见陷阱: 不要写成这样:

rust 复制代码
// 错误!临时 CString 在 as_ptr() 调用后立即被释放,指针成为悬空指针
let name = CString::new("fresh coffee").unwrap().as_ptr();

Rust 编译器目前不会报这个错,属于 unsafe 代码的危险地带,需要手动小心。

用 Cargo build.rs 指定库路径

不用每次都手写 RUSTFLAGS,可以用构建脚本自动化:

rust 复制代码
// build.rs(与 src/ 同级,不是在 src/ 里面)
use std::path::PathBuf;

fn main() {
    let manifest_dir =
        PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir should be set"));
    let lib_dir = manifest_dir
        .parent()
        .expect("manifest dir should have a parent");
    println!("cargo:rustc-link-search={}", lib_dir.display());
}

但前面说了,编译时找到了,运行时不一定找得到。还是要处理运行时的搜索路径。

用 dlopen/dlsym 动态加载(纯 Rust)

如果我们要在运行时加载,就不应该用 #[link] 静态链接,而应该直接调 dlopen

rust 复制代码
use std::{ffi::c_void, ffi::CString, os::raw::c_char, os::raw::c_int};

#[link(name = "dl")]
extern "C" {
    fn dlopen(path: *const c_char, flags: c_int) -> *const c_void;
    fn dlsym(handle: *const c_void, name: *const c_char) -> *const c_void;
    fn dlclose(handle: *const c_void);
}

pub const RTLD_LAZY: c_int = 0x00001;

fn main() {
    let lib_name = CString::new("../libgreet.so").unwrap();
    let lib = unsafe { dlopen(lib_name.as_ptr(), RTLD_LAZY) };
    if lib.is_null() {
        panic!("could not open library");
    }

    let greet_name = CString::new("greet").unwrap();
    let greet = unsafe { dlsym(lib, greet_name.as_ptr()) };

    type Greet = unsafe extern "C" fn(name: *const c_char);
    use std::mem::transmute;
    let greet: Greet = unsafe { transmute(greet) };

    let name = CString::new("fresh coffee").unwrap();
    unsafe { greet(name.as_ptr()); }

    unsafe { dlclose(lib); }
}

更好的方案:libloading crate

手写这些 unsafe 很繁琐,社区早有解决方案------libloading

bash 复制代码
$ cargo add libloading
$ cargo add cstr
rust 复制代码
use cstr::cstr;
use std::{error::Error, os::raw::c_char};
use libloading::{Library, Symbol};

fn main() -> Result<(), Box<dyn Error>> {
    let lib = Library::new("../libgreet.so")?;
    unsafe {
        let greet: Symbol<unsafe extern "C" fn(name: *const c_char)> = lib.get(b"greet")?;
        greet(cstr!("rust macros").as_ptr());
    }
    Ok(())
}

cstr! 宏来自 cstr crate,能在编译期生成 C 风格字符串,省去运行时的 CString::new()

libloading 还能跨平台工作------macOS 上的 .dylib 和 Windows 上的 .dll 都支持。


用 Rust 编写动态库

之前的 libgreet.so 是用 C 写的。能不能用 Rust 来写?

bash 复制代码
$ cargo new --lib greet  # 注意:起名 greet,不然会生成 liblib... 这种怪名字
rust 复制代码
// src/lib.rs
use std::{ffi::CStr, os::raw::c_char};

/// # Safety
/// Pointer must be valid, and point to a null-terminated string.
#[no_mangle]
pub unsafe extern "C" fn greet(name: *const c_char) {
    let cstr = CStr::from_ptr(name);
    println!("Hello, {}!", cstr.to_str().unwrap());
}

这里有几个关键点:

  • pub:外部可见
  • extern "C":使用 C 调用约定,保证与 C 侧兼容
  • unsafe:涉及原始指针
  • #[no_mangle]:禁止 Rust 对符号名进行 name mangling(否则符号会变成 _ZN5greet5greet17h... 这种乱码)

dylib vs cdylib:该用哪个?

Cargo.toml 里配置 crate 类型:

toml 复制代码
[lib]
crate-type = ["dylib"]   # 或 "cdylib"

重要区别:

使用 dylib(release + strip 后):

shell 复制代码
$ ls -lh ./target/release/libgreet.so
3.8M
$ nm -D ./target/release/libgreet.so | grep " T " | wc -l
2084   # 导出了 2084 个符号!

使用 cdylib(release + strip 后):

bash 复制代码
$ ls -lh ./target/release/libgreet.so
219K   # 体积缩小了约 17 倍!
$ nm -D ./target/release/libgreet.so | grep " T " | wc -l
2      # 只导出 2 个符号:greet 和 rust_eh_personality

dylib 会导出大量内部符号(主要是为了 Rust 内部 crate 之间互相链接用),而 cdylib 是专门为 C 接口设计的,只导出你明确标记的符号,体积更小,更干净。

结论:向外暴露 C 接口时,始终使用 cdylib


终于到了:动态重载

验证 libloading 会调用 dlclose

先确认 Library drop 时确实会调 dlclose------用 GDB 验证:

bash 复制代码
(gdb) break dlclose
(gdb) run
Breakpoint 1, dlclose () from /usr/lib/libdl.so.2
(gdb) bt
#0  dlclose () from /usr/lib/libdl.so.2
#1  <libloading::os::unix::Library as core::ops::drop::Drop>::drop (...)

Library 确实实现了 Drop,会在变量超出作用域时自动调 dlclose。这就是 Rust RAII 的威力。

基础重载循环

rust 复制代码
use cstr::cstr;
use std::{error::Error, io::BufRead, os::raw::c_char};
use libloading::{Library, Symbol};

fn main() -> Result<(), Box<dyn Error>> {
    let mut line = String::new();
    let stdin = std::io::stdin();

    loop {
        if let Err(e) = load_and_print() {
            eprintln!("Something went wrong: {}", e);
        }

        println!("-----------------------------");
        println!("Press Enter to go again, Ctrl-C to exit...");
        stdin.lock().read_line(&mut line)?;
    }
}

fn load_and_print() -> Result<(), libloading::Error> {
    let lib = Library::new("../libgreet-rs/target/release/libgreet.so")?;
    unsafe {
        let greet: Symbol<unsafe extern "C" fn(name: *const c_char)> = lib.get(b"greet")?;
        greet(cstr!("reloading").as_ptr());
    }
    Ok(())
}

代码逻辑是:每次循环时加载库,使用完后 lib 超出作用域自动 dlclose,下次循环再重新加载新版本的库。

运行起来看上去没问题:

markdown 复制代码
Hello, reloading!
-----------------------------
Press Enter to go again, Ctrl-C to exit...
Hello, reloading!
...

但是------当你在另一个终端修改库代码,重新编译,然后回来按 Enter,发现输出没有变化

为什么 dlclose 没有真正卸载库?

这是整篇文章最精髓的部分。


阻止 dlclose 真正卸载库的因素

研究这部分花了相当长时间。

关键在于 Rust 标准库。当你编译一个 Rust 动态库,它会把 Rust 标准库也链接进去(或者作为依赖)。而标准库使用了 线程局部存储(Thread-Local Storage,TLS)

TLS 的工作方式是:当一个动态库首次被加载时,动态链接器可能会为它分配线程局部变量的 slot。这些 slot 被其他库(比如 libpthread)追踪。即使你调用了 dlclose,只要这些 TLS slot 还被引用,动态链接器就拒绝真正卸载这个库

验证方式:在 LD_DEBUG=files 输出中,如果看到 dlclose 后没有出现 destroying link map,说明库并没有真正被卸载:

bash 复制代码
calling fini: ./libgreet.so [0]
# 注意:没有 "file=./libgreet.so [0]; destroying link map"!
# 库没有被真正卸载

解决方案 1:文件复制技巧

核心思路:不要重复打开同一个文件路径

每次加载时,把库文件复制到一个新的临时路径,用不同的文件名打开。这样 dlopen 看到的是不同的文件,即使旧版本的库因为 TLS 没有被卸载,新版本也能成功加载。

rust 复制代码
use std::path::Path;

fn copy_and_load(src: &Path, counter: u32) -> Result<Library, Box<dyn Error>> {
    let dst = format!("/tmp/libgreet_{}.so", counter);
    std::fs::copy(src, &dst)?;
    Ok(Library::new(&dst)?)
}

每次重载时递增计数器,用 /tmp/libgreet_0.so/tmp/libgreet_1.so...... 这样的文件名。旧的库文件即使没被真正卸载,新的库也已经以一个全新的文件身份加载进来了。

解决方案 2:规避 Rust 标准库的 TLS

另一个思路是在动态库侧避免使用 Rust 标准库,或者使用不带 TLS 的运行时配置。但这会带来相当多的限制,不如文件复制技巧来得实用。

完整的热重载示例

结合文件监听(notify crate)和文件复制技巧,可以实现真正的文件变更自动重载:

rust 复制代码
use libloading::{Library, Symbol};
use notify::{Watcher, RecursiveMode};
use std::{os::raw::c_char, path::Path, sync::mpsc::channel, time::Duration};
use cstr::cstr;

fn main() {
    let lib_path = Path::new("../libgreet-rs/target/release/libgreet.so");

    let (tx, rx) = channel();
    let mut watcher = notify::watcher(tx, Duration::from_millis(100)).unwrap();
    watcher.watch(lib_path, RecursiveMode::NonRecursive).unwrap();

    let mut counter = 0u32;
    let mut lib = copy_and_load(lib_path, counter).expect("initial load failed");

    loop {
        call_greet(&lib);

        // 等待文件变更事件
        if rx.recv_timeout(Duration::from_secs(1)).is_ok() {
            println!("Library changed! Reloading...");
            counter += 1;
            match copy_and_load(lib_path, counter) {
                Ok(new_lib) => lib = new_lib,
                Err(e) => eprintln!("Reload failed: {}", e),
            }
        }
    }
}

fn copy_and_load(src: &Path, counter: u32) -> Result<Library, Box<dyn std::error::Error>> {
    let dst = format!("/tmp/libgreet_{}.so", counter);
    std::fs::copy(src, &dst)?;
    Ok(unsafe { Library::new(&dst)? })
}

fn call_greet(lib: &Library) {
    unsafe {
        let greet: Symbol<unsafe extern "C" fn(*const c_char)> =
            lib.get(b"greet").unwrap();
        greet(cstr!("hot-reloading").as_ptr());
    }
}

总结与最佳实践

整条知识链路梳理:

从 C 的目标文件、符号表,到动态链接器的工作原理,再到 Rust 中如何暴露和调用动态库接口,最后落到热重载的实现------每一步都有其不可绕开的底层逻辑。

实践要点清单:

动态库编写方面,导出给 C 接口使用时,必须加 #[no_mangle]extern "C",必须声明为 pub,面向外部接口时选 cdylib 而非 dylib(体积更小,导出更干净)。

动态加载方面,推荐用 libloading crate 而非裸写 unsafedlopen/dlsym,要处理 ?unwrap 的错误传播,Library drop 时会自动调 dlclose,RAII 帮你管好资源。

热重载方面,dlclose 不保证真正卸载库(TLS 等原因可能阻止),解决方法是每次加载时复制库文件到新路径,文件监听用 notify crate,记得清理临时文件,避免磁盘被占满。

关于 unsafe 的思考:

整个过程涉及大量 unsafe,这是与 C 接口交互的代价。好的做法是把 unsafe 的边界控制在最小范围,对外提供安全的包装层,并充分利用文档注释说明安全前提条件(/// # Safety)。


参考资料

相关推荐
用户467245132231 小时前
分布式唯一序列号:万亿级订单不重复的奥秘
后端
未秃头的程序猿1 小时前
别再让大模型单打独斗了!Java 多 Agent 协作实战:任务拆解+结果聚合
java·后端·ai编程
XovH1 小时前
第29篇 k8s之Service 与 Endpoints 深入:服务发现原理
后端
人道领域1 小时前
【LeetCode刷题日记】538.把二叉搜索树转换为累加树
java·开发语言·后端·算法·leetcode
西凉的悲伤1 小时前
Spring Boot + ShardingSphere 介绍
java·spring boot·后端·shardingsphere·分库分表
不爱编程的小陈1 小时前
Go内存模型与GC机制:高性能编程的核心
开发语言·后端·golang
日月云棠1 小时前
12 Enum —— 枚举类型的底层实现
java·后端
工位植物人1 小时前
深入理解Java中的类、抽象类、接口与枚举类
后端
用户2181697049301 小时前
Gin (二) 参数 路由分组
后端