本文是对 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=files 比 LD_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 关键字申请内部链接。
为什么动态链接器找不到库?
库的查找分两个阶段:
- 编译时 :静态链接器(
ld)通过-L参数找到库 - 运行时 :动态链接器(
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 脚本,背后是让动态链接器自己列出依赖。更推荐的方式是用 readelf 或 lddtree(树形显示依赖关系)。
进入 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 而非裸写 unsafe 的 dlopen/dlsym,要处理 ? 或 unwrap 的错误传播,Library drop 时会自动调 dlclose,RAII 帮你管好资源。
热重载方面,dlclose 不保证真正卸载库(TLS 等原因可能阻止),解决方法是每次加载时复制库文件到新路径,文件监听用 notify crate,记得清理临时文件,避免磁盘被占满。
关于 unsafe 的思考:
整个过程涉及大量 unsafe,这是与 C 接口交互的代价。好的做法是把 unsafe 的边界控制在最小范围,对外提供安全的包装层,并充分利用文档注释说明安全前提条件(/// # Safety)。
参考资料
- libloading crate:lib.rs/crates/libl...
- cstr crate:lib.rs/crates/cstr
- notify crate(文件监听):lib.rs/crates/noti...
- Rust Reference - Linkage:doc.rust-lang.org/reference/l...
- Linux 动态链接器文档:
man ld.so - LSB Core Specification(符号版本化):refspecs.linuxfoundation.org/LSB_5.0.0/L...