从一个 helloworld 动态库到 rpi package:ABI 插件是怎样被加载的

5,765 字#rpi #Rust #ABI #插件 #架构

很多人第一次看到 rpi 的 Rust 插件,会把它理解成“编译出一个动态库,然后让 rpi 找到它”。这句话方向没错,但省略了最关键的部分:动态库如何证明自己是一个 rpi 插件?注册的工具如何跨过 Rust 边界?一次调用结束后,插件里的内存由谁释放?源码目录里的 crate 又是怎样变成项目里的 package 的?

这篇文章从 examples/plugin-stub 这样的最小 demo 开始,沿着 rpi-plugin-sdk、rpi-extensions 和 rpi-cli 的实现,解释这条链路:

flowchart LR
    A[插件源码\nRust crate] --> B[cdylib\n.dll / .so / .dylib]
    B --> C[rpi 加载器\nlibloading]
    C --> D[rpi_plugin_register]
    D --> E[HostApi + ExtensionRegistry]
    E --> F[RegistrySnapshot]
    F --> G[Agent 会话中的工具\n事件与其他扩展能力]

这里的“插件”指 rpi 的 Rust 原生 ABI 扩展,不是 MCP Server,也不是一个通过 HTTP 或 stdio 运行的外部进程。它和宿主运行在同一个进程里,因此能力强、开销低,但也意味着插件是本机可信代码,ABI、内存所有权和生命周期必须非常明确。

本文讨论的是 rpi 的原生 ABI 插件机制。由于 ABI、loader 和 package 命令会随仓库版本变化,下面的文件名、符号名和命令都应与所读源码版本一起核对;代码块中明确标注为“示意”的部分,不等于可直接复制运行的最小插件。文章关注一条主线:插件如何从动态库进入注册表,再被会话驱动;不把它扩展成完整的 Rust FFI 教程。

先从 helloworld 动态库被加载的那一刻开始

为了让链路足够具体,先假设我们有一个只做一件事的 helloworld 插件:宿主调用它,它返回一段问候文本。仓库里的 examples/plugin-stub 实际注册的工具名是 echo,下面把它当作这个 helloworld 示例来讲;工具名不同,不影响动态库加载和 ABI 注册流程。

先不要从 ABI 结构体或 package 命令开始。假设这个 helloworld demo 已经编译出一个动态库,rpi 启动时拿到这个文件。宿主侧发生的事情可以压缩成下面八步:

1. 找到插件文件
   → 2. libloading 打开动态库
   → 3. 查找 rpi_plugin_register 符号
   → 4. 创建该插件自己的 HostApi / ExtensionRegistry
   → 5. 调用注册入口
   → 6. 插件登记 helloworld 的 schema 和函数表
   → 7. 保存 Library 句柄与注册结果
   → 8. 合并 RegistrySnapshot,交给 Agent 会话

关键点是第 5 步:加载动态库并不等于加载出一个可用工具。 Library::new 只让宿主拿到了代码,rpi_plugin_register 才让插件把“我提供什么能力、怎样调用、怎样释放”交给宿主。注册入口返回成功后,宿主才有机会把 helloworld 放进统一注册表;用户后续调用它时,才会沿着注册时保存的函数表回到插件。

把这条链分成两个时间点更容易理解:

启动阶段:动态库 → 入口符号 → 注册表
调用阶段:注册表中的函数表 → execute → poll → destroy

接下来按这个顺序展开。第一部分看 demo 产物和 ABI 合约,第二部分回到 loader,第三部分再解释 package 如何把产物放到宿主能发现的位置。

第一步:先把 helloworld 编译成 cdylib

一个最小扩展至少需要一个 Cargo crate,并把库类型声明为 cdylib。下面是构建形状;SDK 版本故意不写死,避免把示意版本误当成当前 ABI 兼容版本:

[package]
name = "my-rpi-extension"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
# rpi-plugin-sdk 的版本或 workspace path 以所读源码仓库为准
serde_json = "1"

cdylib 是宿主实际加载的产物;保留 rlib 则方便写普通 Rust 单元测试。插件只应该依赖 rpi-plugin-sdk,而不要依赖 rpi-cli、rpi-extensions 或 harness。后面三个 crate 是宿主实现,插件如果反过来链接它们,就失去了边界,也容易把宿主内部改动变成插件的编译负担。

第二步:先写出 helloworld 的调用状态

在进入 rpi-plugin-sdk 之前,先把插件真正要做的事情写成普通 Rust 状态机。这样可以把“业务逻辑”和“ABI 胶水”分开:前者只负责保存本次调用的状态,后者负责把 C ABI 的指针、状态码和 JSON 接到宿主。

下面是一个教学版的 helloworld 核心。它不是仓库 SDK 的逐字源码,因此不能直接替代 plugin-stub 编译;它刻意保留了真实 ABI 驱动器必须面对的四个动作:创建状态、轮询状态、请求取消、销毁状态。

use std::sync::atomic::{AtomicBool, Ordering};

/// 一次 helloworld 调用自己的状态。
/// 宿主拿到的 StepHandle 最终会指向这一块状态。
struct HelloWorldCall {
    cancelled: AtomicBool,
    finished: bool,
}

/// execute:每次工具调用只创建一次状态。
fn hello_execute(_input_json: &str) -> *mut HelloWorldCall {
    Box::into_raw(Box::new(HelloWorldCall {
        cancelled: AtomicBool::new(false),
        finished: false,
    }))
}

/// poll:必须能够返回 Pending,不能把驱动线程永久堵住。
/// 这里用返回值模拟 SDK 的 Pending/Done/Err 三种 ABI 状态。
fn hello_poll(call: &mut HelloWorldCall) -> PollResult {
    if call.cancelled.load(Ordering::Acquire) {
        return PollResult::Err("helloworld cancelled".into());
    }
    if call.finished {
        return PollResult::Done("hello, world!".into());
    }

    call.finished = true;
    PollResult::Done("hello, world!".into())
}

/// cancel:只改变状态,不释放 call。
fn hello_cancel(call: &HelloWorldCall) {
    call.cancelled.store(true, Ordering::Release);
}

/// destroy:只有宿主不再 poll 之后才能调用。
fn hello_destroy(call: *mut HelloWorldCall) {
    if !call.is_null() {
        // 这里重新取得 Box 的所有权,离开作用域时释放状态。
        unsafe { drop(Box::from_raw(call)); }
    }
}

enum PollResult {
    Pending,
    Done(String),
    Err(String),
}

这个例子里最重要的不是返回了哪句文本,而是指针的生命周期:

execute  创建 Box<HelloWorldCall>,交出裸指针
poll     借用裸指针指向的状态,不取得所有权
cancel   修改状态,不释放指针
poll     看到取消并返回终态
 destroy 重新取得 Box 所有权,只执行一次释放

如果 cancel 里直接执行 Box::from_raw,宿主随后还可能继续调用 poll,这就是 use-after-free;如果 destroy 和错误分支都各自释放一次,就是 double-free。ABI 函数表的设计,本质上是在把这条生命周期交给两个独立编译的组件共同遵守。

再把 helloworld 接到 rpi-plugin-sdk

SDK 的真实函数签名、字段顺序和状态码必须以当前仓库为准。为了看清注册动作,可以先把注册入口写成下面这种“接口适配层”示意:

rpi_plugin_sdk::export_plugin!(|api| {
    let Some(register_tool) = api.register_tool else {
        return 1;
    };

    let schema = StableToolSchema {
        name: "helloworld",
        description: "Return a hello-world message",
        input_schema: r#"{"type":"object"}"#,
    };

    let callbacks = ToolCallbacks {
        execute: hello_execute_abi,
        poll: hello_poll_abi,
        cancel: hello_cancel_abi,
        destroy: hello_destroy_abi,
    };

    register_tool(&schema, callbacks);
    0
});

这段代码的每一行都对应一次跨边界承诺:

  • export_plugin!:导出宿主要查找的 rpi_plugin_register;
  • StableToolSchema:告诉宿主工具叫什么、如何展示、参数是什么形状;
  • ToolCallbacks:把工具的四个生命周期函数交给宿主保存;
  • register_tool:把这条能力写入当前插件自己的 ExtensionRegistry;
  • 返回 0:表示注册成功;非零返回表示插件没有完成注册。

这里的 hello_execute_abi 等函数不是直接把上面的安全 Rust 函数原样暴露出去。它们还要做 ABI 转换:从 StbString 读取 JSON、把 *mut HelloWorldCall 包成不透明 StepHandle、把 PollResult 映射为 SDK 状态码,并用 catch_unwind 防止 panic 穿过 extern "C" 边界。真实 plugin-stub 的价值,就是把这层转换和宿主驱动约定落实下来。

因此,读 demo 时建议分两遍:第一遍只看 HelloWorldCall 如何活着、完成、取消和释放;第二遍再看 SDK 如何把它包装成 StbString、函数指针和 StepHandle。

第三步:为什么 ABI 不直接传 Rust 类型

插件和宿主虽然都用 Rust 编写,但动态库边界不能随意传 String、Vec、Option<String>、serde_json::Value 或带 Drop 的结构体。两边可能使用不同的 Rust 编译器、不同版本的 SDK,Rust 类型布局和析构约定不应被当作稳定 ABI。

rpi-plugin-sdk 因此手工定义了一组稳定类型,核心规则是:

  • 跨边界的结构体使用 #[repr(C)];
  • 枚举固定为明确宽度的整数表示;
  • 结构化数据以 JSON 字符串传输,而不是直接传 serde_json::Value;
  • 字符串以 StbString 的 ptr + len 表示;
  • 谁产生一块拥有的数据,谁同时提供释放函数;
  • extern "C" 调用不能让 panic 穿过边界。

这使得 ABI 的重点不在“Rust 类型能不能被另一个 Rust crate 识别”,而在“双方是否遵守同一份内存和调用协议”。ptr + len 只描述一段内存,并不能单独说明谁负责释放;输入通常是借用的数据,输出、回调参数和注册表中保存的字符串则要分别按照 SDK 的 owner/free 约定处理。阅读这部分源码时,应同时找到产生指针的一方、消费它的一方和对应的释放函数,不能根据字段名猜所有权。

这也是为什么插件开发不能只写业务逻辑。一个工具至少要同时考虑参数解析、结果编码、所有权、panic 保护和取消路径。

第四步:一个工具如何完成一次调用

SDK 为插件工具定义了四个函数:

execute → StepHandle
poll    → Pending / Done / Err
cancel  → 设置取消状态,不释放 handle
destroy → 最终释放 handle

execute 收到宿主传入的 JSON,创建本次调用自己的状态,并返回一个不透明的 StepHandle。在 demo 中,这通常是 Box::into_raw 得到的指针。

poll 必须是非阻塞的。它可以通过 partial_cb 报告中间进度,然后返回 Pending;完成时返回 Done,失败时返回 Err。宿主侧的 PluginToolAdapter 会在 spawn_blocking 中反复驱动 poll,将部分结果转发回异步 Agent 循环。

取消和销毁是两个不同的动作:

  • cancel 只通知插件停止工作,必须幂等,也不能释放 handle;
  • destroy 由宿主驱动器在轮询结束后调用一次,负责最终释放内存。

把二者混为一谈,会在驱动器仍然使用 handle 时提前释放它,形成 use-after-free;或者在多个路径重复释放,形成 double-free。

sequenceDiagram
    participant H as rpi PluginToolAdapter
    participant T as 插件工具
    H->>T: execute(JSON 参数)
    T-->>H: StepHandle
    loop spawn_blocking 驱动
        H->>T: poll(handle)
        T-->>H: Pending / partial
    end
    alt 用户取消
        H->>T: cancel(handle)
        T-->>H: 后续 poll 返回终态
    else 正常完成
        T-->>H: Done / Err
    end
    H->>T: destroy(handle)

这里的 spawn_blocking 不是让插件可以无限阻塞。ABI 约定仍要求 poll 非阻塞,并且取消后在有限次数内进入终态,否则宿主的阻塞驱动可能永远无法结束。

第五步:从导出符号到注册表

真正加载插件的是 crates/rpi-extensions/src/loader.rs。先把它看成一段宿主侧伪代码,后面再对应每一步的 ABI 含义:

let library = unsafe { Library::new(path)? };
let register = unsafe { library.get(b"rpi_plugin_register")? };
let registry = ExtensionRegistry::new();
let api = HostApi::for_registry(&registry);
let plugin_api = PluginApi::new(api);

let status = register(&plugin_api);
if status != 0 {
    return Err("plugin registration failed");
}

Ok(LoadedPlugin { library, registry })

上面是帮助阅读调用关系的伪代码,不是可直接复制的真实函数签名;真实类型、unsafe 范围和错误类型以 loader 源码为准。它表达的重点是:注册函数调用期间,插件只是在宿主提供的 API 上登记能力,真正的工具执行发生在之后。

load_one 的步骤很固定:

  1. 使用 libloading::Library 打开动态库;
  2. 查找 rpi_plugin_register;
  3. 为这个插件创建独立的 ExtensionRegistry 和 HostApi;
  4. 构造 PluginApi,调用注册入口;
  5. 注册成功后取出注册表;
  6. 把动态库句柄和注册结果放入 LoadedPlugin。

加载器会处理几类失败:动态库打不开、符号不存在、注册函数返回非零,以及 ABI 版本不匹配。单个插件失败不会污染其他插件的注册表;注册过程中已经写入的内容会随该插件的失败一起丢弃。

ABI 版本和结构体大小放在 PluginApi 等 ABI 数据里,而不是藏在符号名中。加载器会按 SDK 规定检查 ABI 版本和布局是否兼容;本文不把某个具体 ABI 数字泛化为所有 rpi 版本的永久约定。这样做的好处是入口符号保持稳定,宿主可以在真正调用注册逻辑前发现兼容性问题。

插件注册时,HostApi 的各个函数指针会把请求累积到注册表。例如,一个工具注册项不仅包含名称和 schema,也包含 execute、poll、cancel、destroy 这些函数句柄。事件处理器、Provider、slash command、renderer、resources discover 等能力也通过类似的可选注册槽位加入注册表。

所有插件注册完成后,loader 再把各个注册表合并为一个 RegistrySnapshot,交给会话使用。加载器还会保留 Library 句柄:注册表里保存着指向动态库代码的函数指针,如果句柄提前析构,后续调用就会跳进已经卸载的代码。因此,动态库的存活期至少要覆盖整个使用它的会话。

第六步:rpi 到底从哪里发现动态库

loader 只负责“给定目录或文件后如何加载”。目录从哪里来,则由 rpi-cli 根据运行模式组织:项目扩展目录、配置目录、全局扩展目录,以及命令行显式指定的 --extensions-dir;单个文件也可以通过 --extension 传入。

普通启动时,session.rs 会把这些目录交给 load_session_mixed。loader 对目录做非递归扫描,只识别源码当前支持的平台动态库后缀。常见形式是:

.dll   Windows
.so    Linux
.dylib macOS

具体后缀应以 loader 的实现为准;不要因为某个平台也能加载其他 PE 动态库,就把该后缀写成 rpi 已支持的插件格式。

目录顺序会影响同名能力的优先级。源码注释明确规定:默认目录先加载,命令行追加目录后加载;合并注册表时,先出现的插件在工具或命令重名时优先。

开发时也可以绕过目录扫描,直接使用:

rpi --extension ./target/debug/my_rpi_extension.dll
rpi --extensions-dir ./target/debug

rpi dev 则在构建后使用版本化 staging,避免正在运行的进程直接覆盖已加载的 DLL。尤其在 Windows 上,已加载 DLL 通常不能原地替换,所以不应该手工把 Cargo 产物复制成一个固定文件名。

第七步:从动态库目录演进到 package

只让用户记住一个 target/debug 路径,适合 demo,不适合项目协作。当前 rpi 的项目级 package 机制把构建产物和资源组织到项目的 .rpi 目录中。

执行:

rpi package link \
  --path ../my-rpi-extension \
  --package my-rpi-extension

rpi-cli/src/packages.rs 会完成几件事:

  1. 对源码目录运行 cargo metadata,找到目标 Cargo package;
  2. 确认其中存在 cdylib target;
  3. 按 release(或 --debug)构建动态库;
  4. 把产物复制到项目的 .rpi/packages/<name>/extensions/;
  5. 把名称、版本、源码路径、Cargo package 名和 artifacts 写入 .rpi/packages.json。

最终目录大致是:

.rpi/
├── packages.json
└── packages/
    └── my-rpi-extension/
        ├── extensions/
        │   └── my_rpi_extension.dll
        ├── skills/       # 可选
        ├── prompts/      # 可选
        └── themes/       # 可选

这里的 package 不是 npm package,也不是一个远程协议服务。它是 rpi 项目级的 Rust 扩展交付单元:动态库负责 ABI 能力,旁边的 skills、prompts、themes 提供可被项目发现的资源。

启动时,CLI 读取 .rpi/packages.json,把每个 package 的 extensions 目录交给扩展 loader,把资源目录交给相应的资源发现流程。也就是说,package 并没有另造一套 ABI 加载器;它只是把“构建、登记、放置和发现”这些步骤固定下来,最后仍然回到 load_session、load_dir 和同一个 rpi_plugin_register。

更新和移除也围绕这份项目注册表进行:

rpi package list
rpi package update my-rpi-extension
rpi package unlink my-rpi-extension

update 会根据注册表中记录的源码路径和 Cargo package 重新构建并替换产物;unlink 删除项目 package 目录和登记项。--force 用于允许 link 覆盖已有 package。

一条链路看完:package 没有取代 ABI

把几个阶段合在一起,当前流程是:

flowchart TD
    A[开发者编写 Rust 扩展] --> B[export_plugin!]
    B --> C[cargo build --release\n生成 cdylib]
    C --> D[rpi package link]
    D --> E[.rpi/packages/name/extensions]
    E --> F[rpi 读取 packages.json]
    F --> G[按目录扫描 cdylib]
    G --> H[libloading 打开动态库]
    H --> I[查找 rpi_plugin_register]
    I --> J[校验 ABI 并调用注册入口]
    J --> K[HostApi 累积到 ExtensionRegistry]
    K --> L[合并 RegistrySnapshot]
    L --> M[Agent 会话驱动工具、事件和资源]

所以“package 加载”不是另一种插件协议。它解决的是分发和项目隔离;ABI 解决的是动态库和宿主之间如何安全地交换能力;registry 解决的是注册结果如何进入 Agent 会话。三层职责分开后,源码路径、构建产物和运行时调用链就不再混成一个概念。

源码阅读入口与事实边界

建议按这条顺序打开源码,而不是先从 package 命令倒推运行时:

  1. examples/plugin-stub:看 echo 工具如何构造 handle、返回状态并注册入口;
  2. rpi-plugin-sdk:看 export_plugin!、ABI 结构体、字符串和工具函数表;
  3. rpi-extensions/src/loader.rs:看动态库、符号、ABI 校验和 Library keepalive;
  4. rpi-extensions 的 registry/adapter:看注册项如何变成会话可以调用的工具;
  5. rpi-cli/src/session.rs:看扩展目录如何进入一次会话;
  6. rpi-cli/src/packages.rs:看 package link、构建、登记和更新。

本文解释的是这条源码关系,不把示意 Cargo 配置、命令行和 Mermaid 图当作已经在当前环境中实测通过的步骤。具体 ABI 版本、SDK 依赖版本、动态库后缀和 package 参数,应以目标仓库对应提交的源码为准。

它适合本地工具、系统集成、纯 Rust 能力和对调用开销敏感的扩展。插件可以注册工具、事件处理器、Provider、渲染器、资源发现等能力,且不需要启动额外进程。

代价也同样明确:

  • 动态库是本机代码,不能把不可信插件当作沙箱;
  • ABI 变化必须考虑版本、结构体大小和所有权兼容;
  • 插件崩溃、错误的指针或错误的释放策略可能影响宿主进程;
  • 需要跨语言、跨机器或独立部署时,MCP 这类进程边界协议更合适;
  • package 的资源和动态库来自本地项目,登记前应确认源码和构建产物可信。

从 helloworld demo(仓库中的 echo 工具)到 package,真正演进的不是“把一个函数变成了安装命令”,而是把同一条 ABI 合约逐层产品化:先让一个 cdylib 能注册,接着让宿主可靠地驱动它,再让项目能够记录、更新和加载它。理解这条链路,也就理解了当前 rpi Rust 插件系统的大部分边界。


云服务器推荐

如果你需要部署博客、Docker 服务或个人项目,可以参考:

本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。

评论