使用 C/Rust 开发 Emacs 动态模块

发布: 2022-03-12   上次更新: 2023-12-23   标签: c rust

文章目录

Emacs 在 25 版本后,支持了动态模块(dynamic modules),这为 Emacs 插件的开发打开了新的一扇大门,任何能够编译生成符合 Emacs ABI 要求的语言都可以使用。

本文就来介绍,如何使用 C/Rust 两种语言来进行 Emacs 动态模块的开发。本文所有代码可在 emacs-dynamic-module 这里找到。

C

C 是开发动态模块最直接的语言,Emacs 核心部分就是用 C 开发的。一个简单的 hello world 示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// emacs 动态模块的头文件,一般在 Emacs 安装目录内可找到
#include <emacs-module.h>
#include <string.h>
// 声明该模块是 GPL 兼容的
int plugin_is_GPL_compatible;

// 模块的入口函数,相当于普通 C 程序的 main
int emacs_module_init (struct emacs_runtime *ert)
{
  emacs_env *env = ert->get_environment(ert);

  emacs_value message = env->intern(env, "message");
  char *msg = "hello world";
  emacs_value args[] = { env->make_string(env, msg, strlen(msg)) };
  env->funcall(env, message, 1, args);
  return 0;
}

把上面的代码编译成动态链接库,macOS 下可以用如下命令:

1
cc -shared -fpic -o helloworld.so -I"/Applications/Emacs.app/Contents/Resources/include/" $main
  • -shared 表示生成动态链接库
  • -fpic 表示生成地址无关代码(position-independent code)

其他环境下的编译命令可参考 Building a Dynamic Library from the Command Line。 动态链接库后缀名在不同平台是不一样的:

OSsuffix
Linuxso
Windowsdll
macOSdylib

但 Emacs 在加载 dylib 后缀的动态库时,可能会报类似下面的错误:

1
Error: error ("omg-dyn.dylib:0:0: error: scan-error: (Containing expression ends prematurely 82277 82278)")

而且 Emacs 在 macOS 上支持加载 so 后缀,因此建议直接生成 so 后缀即可。

生产动态链接库后,可以用下面的命令加载:

1
(module-load (expand-file-name "~/helloworld.so"))

这时,会在 *Message* 内打印出 hello worldmodule-load 函数本身返回 t

为了简化数据类型在 C 与 ELisp 之间的转化,Emacs 提供了一系列函数,比如:

C–>ElispElisp–>C
make_integerextract_integer
make_floatextract_float
make_stringcopy_string_contents

更多类型转化可参考官方文档:

这里着重介绍下如何将 C 里面的函数导出到 ELisp 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
emacs_value c_add(emacs_env *env, ptrdiff_t nargs, emacs_value *args, void *data) {
  intmax_t ret = 0;
  for(int i=0;i<nargs;i++) {
    ret += env->extract_integer(env, args[i]);
  }
  return env->make_integer(env, ret);
}

void define_elisp_function(emacs_env *env) {
  emacs_value func = env->make_function (env, 1, emacs_variadic_function, // 任意多个参数,类似 &rest
                                         c_add, "C-based adder", NULL);
  emacs_value symbol = env->intern (env, "c-add");
  emacs_value args[] = {symbol, func};
  env->funcall (env, env->intern (env, "defalias"), 2, args);
}

emacs_module_init 中调用 define_elisp_function 即可将 c-add 导出到 ELisp 中,使用示例:

1
2
3
4
5
6
(c-add 1 2)
;; 3
(apply 'c-add (number-sequence 1 100))
;; 5050
(c-add)
;; Debugger entered--Lisp error: (wrong-number-of-arguments #<module function c_add from /tmp/helloworld.so> 0)

M-x describe-function RET c-add RET 返回如下:

c-add is a module function.

(c-add ARG1 &rest REST)

C-based adder

上面的示例代码虽然功能简单,但把开发『动态模块』的核心功能都介绍到了,像如何进行错误处理、如何在 C 与 ELisp 间传递自定义结构等高级功能,可以参考文档:

简化方法调用

从上面介绍的示例可看出,基本所有函数都需要 env 这个参数,这是由于 C 的 struct 不支持成员函数,可以用宏来简化,比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define lisp_integer(env, integer)              \
  ({                                            \
    emacs_env *_env_ = env;                     \
    _env_->make_integer(_env_, (integer));      \
  })                                            \

#define lisp_string(env, string)                        \
  ({                                                    \
    emacs_env *_env_ = env;                             \
    char* _str_ = string;                               \
    _env_->make_string(_env_, _str_, strlen(_str_));    \
  })

#define lisp_funcall(env, fn_name, ...)                 \
  ({                                                    \
    emacs_env *_env_ = env;                             \
    emacs_value _args_[] = { __VA_ARGS__ };             \
    int _nargs_ = sizeof(_args_) / sizeof(emacs_value); \
    _env_->funcall(_env_,                               \
                   env->intern(env, (fn_name)),         \
                   _nargs_,                             \
                   _args_                               \
                   );                                   \
  })

需要注意的是,上面的宏使用了 Statement Expression,不是 C 语言的标准,是 GNU99 的扩展,但由于十分有用,大多数编译器都支持了这种语法(可通过 -std=gnu99 指定),所以可以放心使用。其次是用到了可变参的宏,这是 C99 引入的。使用方式如下:

1
2
3
4
5
lisp_funcall(env,
             "message",
             lisp_string(env, "(1+ %d) is %d"),
             (lisp_integer(env, 1)),
             lisp_funcall(env, "1+", lisp_integer(env, 1)));

由于 C 中的宏仅仅只是文本替换,所以即便使用了宏,代码也还是显得有些冗余。后文会介绍到,在 Rust 中是如何用宏来简化方法调用的。

热加载

在开发过程中,热加载是非常重要的需求,不能每次重启服务来让新代码生效。但是通过 module-load 加载的动态模块,是无法卸载的,那是不是必须要重启 Emacs 呢?xuchunyang 给出了一种不需要重启的热加载方案:

1
2
3
4
5
6
(defun fake-module-reload (module)
  (interactive "fReload Module file: ")
  (let ((tmpfile (make-temp-file
                  (file-name-nondirectory module) nil module-file-suffix)))
    (copy-file module tmpfile t)
    (module-load tmpfile)))

该方式很巧妙,虽然已经加载的 so 不能卸载,但可通过重新加载另一个功能相同的 so 来覆盖之前的,这间接实现了热加载的效果。 在 Rust 中,还有一个更有技术含量的方案,后文会具体介绍。

Rust

使用 Rust 开发动态模块要比 C 简单不少,毕竟作为新时代的语言,单就包管理这一方面,就比 C 好用不少。这里主要会用到 emacs-module-rs 这个 crate,示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use emacs::{defun, Env, Result, Value};

emacs::plugin_is_GPL_compatible!();

// 相当于 C 里面的 emacs_module_init
#[emacs::module(name = "greeting")]
fn init(_: &Env) -> Result<()> { Ok(()) }

#[defun]
fn say_hello(env: &Env, name: String) -> Result<Value<'_>> {
    env.message(&format!("Hello, {}!", name))
}

相比 C 代码,这里的代码简洁不少,方法的参数都是 Rust 类型,内部通过 FromLispIntoLisp 这两个 trait,进行 C 与 Rust 的类型转化。

通过 #[defun]say_hello 函数导出到 ELisp 中,并且函数名自动加上了前缀 greeting ,并提供了相应 featurecargo build 成功后,执行下面的命令:

1
2
3
4
5
6
7
(module-load "/tmp/helloworld-rust/target/debug/libhelloworld_rust.dylib")

(greeting-say-hello "rust")
;; 输出 "Hello, rust!"

;; 或把 dylib 所在目录追加到 load-path,然后执行
;; (require 'greeting)

更多使用细节可以参考官方文档,里面有非常详细的描述。

实现原理

emacs-module-rs 使用了大量过程宏来简化代码的编写,比如上面的 defun, emacs::module ,利用 cargo-expand 可以将这些宏代码展开,可以看到实现原理如下:

  1. 使用 defun 声明的函数会被添加到 __INIT_FNS__,这是一个全局的 map
  2. 在生成的 emacs_module_init 中,去遍历 __INIT_FNS__ ,调用 fset 将 Rust 到 C 的 binding 函数导出到 ELisp 中

完整的宏展开代码在 expanded.rs,对细节感兴趣的读者可自行研究。

热加载

使用 emacs-module-rs 开发的动态模块,除了会生成 emacs_module_init 外,还会额外生成一个 emacs_rs_module_init 函数,rs-module/load 通过执行这个方法来实现热加载。热加载相关命令如下:

1
2
git clone https://github.-com/ubolonton/emacs-module-rs.git
cd emacs-module-rs && cargo build

这会生成 libemacs_rs_module.dylib ,它会暴露 rs-module/load 方法,用这个方法去加载其他模块即可实现热加载:

1
2
3
(module-load "/path/to/emacs-rs-module/target/debug/libemacs_rs_module.dylib")

(rs-module/load "/tmp/helloworld-rust/target/debug/libhelloworld_rust.dylib")

参考



收听方式

反馈