dyn-loader
Dynamic library loader with two plugin-loading modes:
dynmode — Rust fat-pointer bridge: load Rust trait objects from.so/.dylibplugin files, with Arc-like retain/release.cdynmode — COM-style C function tables (VTablePlugin<T>): plain#[repr(C)]function-pointer structs, ABI-stable, usable from C/C++/Zig.
Core types:
DynLib: wrapslibloading::LibrarywithArcfor shared ownership.AbiDynFatPtr: ABI-stable representation of a Rust fat pointer (data ptr + vtable ptr),#[repr(C)].AbiStableDynRef: fat pointer + retain/release function pointers, enabling safe cross-boundary Arc-like reference counting.SafeArcDyn<T>: safe, cloneable handle over anAbiStableDynRef.DynPlugin<T>: loaded plugin holding aSafeArcDyn<T>that dereferences to&Tfor calling trait methods on the loaded object.VTablePlugin<T>: loaded COM-style function table (cdyn mode).
⚠️ IMPORTANT — ABI compatibility / compiler version alignment
You must strictly align the Rust compiler version. Every library and executable that exchanges dyn fat pointers across the boundary must be built with the exact same Rust compiler version to guarantee a stable ABI. Rust makes no ABI stability guarantees between compiler releases (vtable layout, metadata encoding, etc. may change). Mixing compiler versions between the host and plugins is undefined behavior.
Pin one toolchain (e.g. via
rust-toolchain.toml) and rebuild all crates, libs and executables together with that single version.
Usage
Mode 1: dyn — Rust fat-pointer bridge
Plugin side (the .so/.dylib)
use ;
use Arc;
pub extern "C"
Host side
use DynPlugin;
let plugin = load?;
let transform: &dyn Transform = plugin.trait_ref;
Mode 2: cdyn — COM-style C function table (cross-language)
The plugin exports a function returning a *const to a plain
#[repr(C)] struct of function pointers. No trait objects involved —
callable from C, C++, Zig, anything with a C FFI.
Plugin side (Rust)
use VTablePlugin;
unsafe extern "C"
unsafe extern "C"
pub static MY_PLUGIN_VTABLE: MyVtable = MyVtable ;
pub extern "C"
Host side (Rust)
let plugin = unsafe ;
let vtable = plugin.vtable;
let sum = unsafe ; // 5
The same vtable layout can be produced from any language with a C FFI
(C, C++, Zig, ...): just export a function returning *const MyVtable
to a #[repr(C)]-equivalent struct.
Mode 2 shortcut: #[abi_vtable] — shadow mode
Writing the vtable struct, thunks, static and getter by hand (as above) is
error-prone. The abi_vtable attribute macro generates all of it from a
plain trait definition — abi mode with dyn-mode ergonomics:
Plugin side (Rust)
use abi_vtable;
// The ONLY thing you write: a trait + an impl.
;
// One line — generates: CalcVtable struct, `calc_add_thunk` /
// `calc_is_even_thunk`, static `CALC_VTABLE`, and `#[no_mangle]
// extern "C" fn calc_get_vtable()`.
abi_vtable_impl_calc!;
What the macro generates from #[abi_vtable(name = "calc")]:
| Entity | Name | Role |
|---|---|---|
| vtable struct | CalcVtable |
#[repr(C)], one unsafe extern "C" fn(ctx, ...) field per method, in declaration order |
| thunks | calc_add_thunk, ... |
cast ctx back to $impl_ty and dispatch |
| static instance | CALC_INSTANCE |
the $instance expression |
| static vtable | CALC_VTABLE |
thunks wired in field order |
| getter | calc_get_vtable |
#[no_mangle] extern "C" entry point for hosts |
Host side — identical to plain Mode 2:
let plugin = unsafe ;
let vt = plugin.vtable;
let sum = unsafe ; // ctx = *const MyCalc as *mut c_void
Rules the macro enforces for you:
- vtable field order is the protocol — it follows trait method declaration order; never reorder after release.
- all fn pointers are
extern "C"(cdecl) with a leadingctx: *mut c_void(the two protocol laws: 谁分配谁释放 / 谁创建谁操作). $instancemust be const-constructible (unit struct,const fn, literal).
The cdyn mechanism — how cross-language loading works
(This section explains the mechanism behind "Mode 2". If you just want to use it, skip to the tutorial below.)
The problem it solves
A Rust dyn Trait fat pointer is two words: a data pointer and a pointer
to a compiler-generated vtable whose layout is private to the Rust
toolchain. That is why the fat-pointer bridge requires identical compiler
versions. The cdyn mechanism removes the compiler from the equation:
The vtable is not something the compiler generates — it is a plain
#[repr(C)]struct that you define and you fill.
The three pieces
plugin module (.so) host module
┌─────────────────────────────────┐ ┌──────────────────────────────┐
│ #[repr(C)] struct MyVtable { │ │ #[repr(C)] struct MyVtable │
│ add: unsafe extern "C" │ │ (same field order — │
│ fn(i32,i32)->i32, │ │ this IS the protocol) │
│ name: unsafe extern "C" │ │ │
│ fn() -> *const c_char, │ │ AbiTable::<MyVtable>::load( │
│ } │ │ path, b"get_vtable\0") │
│ │ │ │
│ static MY_VTABLE: MyVtable = … │ │ vtable.add(2, 3) │
│ │ │ └─ direct fn-ptr call, │
│ #[no_mangle] │ │ executes in plugin │
│ pub extern "C" fn │ │ │
│ my_get_vtable() -> *const MyVtable ─► copied by value into host │
└─────────────────────────────────┘ └──────────────────────────────┘
-
The vtable struct — a
#[repr(C)]struct whose fields are allunsafe extern "C"function pointers. Field order is the calling contract: position 0 isadd, position 1 isname, and so on. Both sides must compile the same struct definition (same order, same signatures) — that is the entire compatibility requirement. -
The static instance — the plugin fills one static instance of that struct with its function implementations. It lives in the plugin's static storage and stays valid as long as the library is loaded.
-
The getter entry point — a
#[no_mangle] extern "C"function returning*const MyVtable. The host resolves this one symbol, dereferences it, and copies the vtable by value (MyVtable: Copy). After that, every call is a direct function-pointer call — zero indirection beyond the pointer itself, no runtime involvement.
Why this is ABI-stable
- The struct layout is fixed by
#[repr(C)]and C rules, not by any compiler's internal representation. - Function pointers use the standard C calling convention (
extern "C"/ cdecl), which every language can produce. - Nothing Rust-specific crosses the boundary: no trait objects, no
panics, no
Result, no allocator. The host never allocates or frees plugin memory in this mode.
The ownership tiers on top
The bare vtable is stateless. Two smart-pointer tiers extend it while keeping the same C-ABI discipline (see the crate docs for the full ownership model):
AbiRef<T>(instance, ref-counted) — the plugin exports a second entry point returning anAbiStableDynRef:{data, vtable, retain, release}. The host wraps it inAbiRef<T>;clone()calls the plugin'sretain,drop()calls itsrelease. The vtable pointer inside is reconstructed to&Ton demand.AbiBox(data, single-owner) — a{data, len, free}triple for raw buffers.freeis the producer's deallocator;AbiBoxHandlecalls it exactly once on drop.
Both follow the two invariants: whoever allocates, deallocates and whoever creates, operates.
Tutorial — plain vtable bridge (cdyn mode)
A complete, minimal walkthrough. No theory — just the steps.
Step 1: Define the vtable (shared header)
Write the struct once and give both sides the identical definition. In Rust:
// vtable_def.rs — include this file in BOTH host and plugin crates
Rules:
- All fields are
unsafe extern "C" fn(...). - Derive
Clone, Copy(required byAbiTable<T>). - Never reorder or insert fields after release — that is a breaking ABI change. Append new functions at the end only.
Step 2: Write the plugin
// plugin crate — Cargo.toml: [lib] crate-type = ["cdylib"]
use CalcVtable;
unsafe extern "C"
unsafe extern "C"
unsafe extern "C"
pub static CALC_VTABLE: CalcVtable =
CalcVtable ;
pub extern "C"
Build it:
Step 3: Load and call from the host
use AbiTable;
use CalcVtable;
let table = unsafe ;
let vt = table.get;
unsafe
// `table` keeps the library loaded; dropping it unloads (ref-counted
// via Arc — other clones of the handle keep it alive).
That is the whole flow: define struct → fill static → export getter →
AbiTable::load → call fields.
Step 4 (optional): probe before loading
use looks_like_plugin;
if unsafe
Common pitfalls
| Pitfall | Consequence | Fix |
|---|---|---|
| Field order differs between host and plugin | Silent wrong-function calls | Share one definition file; never reorder |
Forgetting #[no_mangle] on the getter |
Symbol not found at load | Always #[no_mangle] pub extern "C" |
Plugin built as rlib instead of cdylib |
No .so produced |
crate-type = ["cdylib"] in Cargo.toml |
| Calling a vtable fn after the library unloaded | UB (stale fn pointer) | Keep the AbiTable/DynLib handle alive |
Non-extern "C" fn pointer in the struct |
Compile error or UB | Every field must be unsafe extern "C" fn |
C++ / Zig side of the same protocol
The identical vtable is trivially produced from other languages:
// C++ plugin
extern "C"
// Zig plugin
pub const CalcVtable = extern struct { // same field order
add: *const fn (i32, i32) callconv(.c) i32,
mul: *const fn (i32, i32) callconv(.c) i32,
name: *const fn () callconv(.c) [*:0]const u8,
};
As long as the field order and signatures match, the Rust host above loads them unchanged.
Toolchain / ABI compatibility matrix
The two loading modes have different ABI stability guarantees:
| Mode | Module | ABI stability | Cross-compiler-version safe? | Cross-language (C++/Zig) safe? |
|---|---|---|---|---|
| Rust fat-pointer bridge | dyn_mod |
❌ Unstable — depends on Rust vtable layout | No — host & plugin must use the exact same compiler version | ❌ No (Rust trait objects only) |
| COM-style C function table | cdyn (VTablePlugin<T>) |
✅ Stable — plain #[repr(C)] struct of function pointers |
✅ Yes, to a large extent (layout is fixed by #[repr(C)]) |
✅ Yes — any language with a C FFI |
Verified compiler versions
Cross-version interoperability was verified with an actual host/plugin matrix
test (plugin compiled to a .so with toolchain A, loaded by a host binary
compiled with toolchain B). All 16 combinations passed (both modes) as of
2026-09:
| Toolchain pair (host ↔ plugin) | dyn_mod (fat-pointer bridge) |
cdyn (C function table) |
|---|---|---|
nightly-2025-05-06 ↔ stable 1.98.1 (cross) |
✅ Verified | ✅ Verified |
nightly-2025-05-06 ↔ 1.95.0 (cross) |
✅ Verified | ✅ Verified |
nightly-2025-05-06 ↔ 1.92.0 (cross) |
✅ Verified | ✅ Verified |
stable 1.98.1 ↔ 1.95.0 (cross) |
✅ Verified | ✅ Verified |
stable 1.98.1 ↔ 1.92.0 (cross) |
✅ Verified | ✅ Verified |
1.95.0 ↔ 1.92.0 (cross) |
✅ Verified | ✅ Verified |
| Same-version pairs (all 4 toolchains) | ✅ Verified | ✅ Verified |
| Other / future versions | ❌Undefined behavior — do not rely on it | ⚠️ Usually works, not guaranteed |
Notes:
- The cross-version
dyn_modresults above are an observation, not a guarantee: vtable layout happened to be identical across these four toolchains (spanning nightly-2025-05-06 through stable-1.98.1). Rust officially makes no ABI stability promise between compiler releases — a future release may break it silently. Always re-run the matrix test when adopting a new toolchain, and prefer exact version alignment in production. cdynmode is layout-stable by construction (#[repr(C)]struct of function pointers), but both sides must still compile the sameTdefinition (same field order, same pointer widths).- The
AbiDynFatPtr/AbiStableDynRefstructs are#[repr(C)]and layout-stable across versions; what is not guaranteed stable is the vtable contents behind adyn Traitpointer, which is whydyn_modrequires version alignment.
Safety
Loading dynamic libraries and unpacking raw fat pointers is inherently unsafe.
See the # Safety sections on each API. The retain/release function pointers
give you Arc-like reference counting across the library boundary, but the
caller is still responsible for ABI compatibility (see the warning above).
License
Apache License 2.0 — see LICENSE and NOTICE.
Redistributions must retain the attribution notices in NOTICE
(Apache-2.0 §4(c)/(d)).