外部函数接口 (FFI)

Article detail

Java

2025/11/19 · 30 分钟阅读

外部函数接口 (FFI)

外部函数接口 (FFI)
我们已经介绍的是纯粹的计算。在现实中,需要与真实世界互动。然而,对于每个后端(C、JS、Wasm、WasmGC),“世界”是不同的,并且基于运行时(Wasmtime、Deno、浏览器等)。

后端
MoonBit 目前有五个后端:

Wasm

Wasm GC

JavaScript

C

LLVM(实验性)

Wasm

Wasm GC

JavaScript

C
C 后端会生成一个 C 文件。MoonBit 工具链还会编译项目并根据 配置 生成可执行文件。

LLVM
声明外部类型
您可以利用 extern 属性,像这样声明一个外部类型:

#external
type ExternalRef

Wasm & Wasm GC

JavaScript

C
这将被解释为 void*。

声明外部函数
要和外部世界互动,您可以声明外部函数。

备注

MoonBit 不支持多态外部函数。

Wasm & Wasm GC

JavaScript

C
您可以通过函数名称导入一个外部函数:

extern "C" fn put_char(ch : UInt) = "function_name"
如果一个包需要动态链接外部 C 库,可以在它的 moon.pkg.json 里添加 cc-link-flags。它会被直接传递给 C 编译器。

{
// ...
"link": {
"native": {
"cc-link-flags": "-l"
}
},
// ...
}
要定义 C 胶水函数来连接 C 与 MoonBit,可以在一个包中引入一些 C 胶水文件,并向 moon.pkg.json 文件加入下面的内容:

{
// ...
"native-stub": [
// <包含胶水函数的 C 文件列表>
],
// ...
}
你可能会想要 #include "moonbit.h",这个头文件包含了 MoonBit 的 C FFI 接口中的类型定义和一些实用的辅助函数。这个头文件自身通常位于 ~/.moon/include,如果想要了解 moonbit.h 中有哪些可用的定义,可以直接查看它的内容。

类型
当声明函数时,您需要确保函数签名与实际的外部函数相对应。当一个函数不返回任何值(例如 void)时,忽略函数声明中的返回类型注释。下面的表格展示了一些 MoonBit 类型的底层表示:

Wasm

Wasm GC

JavaScript

C
MoonBit type

ABI

Bool

int32_t

Int

int32_t

UInt

uint32_t

Int64

int64_t

UInt64

uint64_t

Float

float

Double

double

constant enum

int32_t

abstract type (type T)

pointer (must be valid MoonBit object)

external type (#external type T)

void*

FixedArray[Byte]/Bytes

uint8_t*

FixedArray[T]

T*

FuncRef[T]

Function pointer

备注

如果 FuncRef[T] 中的 T 的返回类型是 Unit,那么它指向一个返回 void 的函数。

上表中未提及的类型不具有稳定的二进制接口,请尽量避免依赖它们的实际二进制表示。

回调
有时,我们想要将 MoonBit 函数作为回调传递给外部接口。在 MoonBit 中,可以定义闭包。根据 MDN 的定义:

闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。

在一些场合,我们希望将传递一个回调函数,其不捕获任何自由变量。为此,MoonBit 提供了一个特殊的类型 FuncRef[T],它表示类型为 T 的无捕获函数。类型为 FuncRef[T] 的值必须是类型为 T 的无捕获函数,否则会产生 类型错误 。

在其他情况下,MoonBit 函数参数会被表示为一个函数和一个包含周围状态的对象。

Wasm & Wasm GC

JavaScript

C
有些 C 函数允许在回调函数之外额外提供一些附加数据。例如,假设我们有下面的 C 函数:

void register_callback(void (callback)(void), void *data);
通过一个小技巧,我们可以向这个 C 函数传递 MoonBit 中的闭包:

extern "C" fn register_callback_ffi(
call_closure : FuncRef[(() -> Unit) -> Unit],
closure : () -> Unit
) = "register_callback"

fn register_callback(callback : () -> Unit) -> Unit {
register_callback_ffi(
fn (f) { f() },
callback
)
}
自定义常量枚举的整数表示
在所有后端,常量枚举(所有构造器都没有参数的枚举)都会被编译成整数。在此基础上,MoonBit 允许用户自定义常量枚举的构造器的整数表达式。只需在构造器的声明后加上 = <整数字面量> 即可:

enum SpecialNumbers {
Zero = 0
One
Two
Three
Ten = 10
FourtyTwo = 42
}
如果一个构造器没有用户指定的整数值,默认的值是上一个构造器的值加一。(第一个构造器的默认值是 0)。自定义整数表示的功能在绑定一些 C 库里的 flag 是很有用。

导出函数
对于既不是方法也不是多态的公开函数,可以通过配置 链接选项 中的 exports 字段来导出它们。

{
"link": {
"": {
"exports": [ "add", "fib:test" ]
}
}
}
上述例子中导出函数 add 和 fib,其中 fib 会被导出为 test。

Wasm & Wasm GC

JavaScript

C
备注

这仅对配置它的包有效,即它不会影响下游包。

目前不支持重命名导出的函数。

生命周期管理
MoonBit 是一门具有垃圾回收的编程语言。因此在处理外部对象或将 MoonBit 对象传递给宿主时,必须牢记生命周期管理。目前,MoonBit 对 Wasm 后端和 C 后端使用引用计数。对于 Wasm GC 后端和 JavaScript 后端,复用运行时的垃圾回收机制。

外部对象的生命周期管理
在 MoonBit 中处理来自外部的对象和资源时,需要及时释放这些外部对象占用的内存和资源以避免泄漏。

备注

仅限 C 后端

moonbit.h 中提供了一个实用的函数 moonbit_make_external_object,它可以借助 MoonBit 的自动内存管理系统来管理外部对象的生命周期:

void *moonbit_make_external_object(
void (*finalize)(void *self),
uint32_t payload_size
);
moonbit_make_external_object 会创建一个大小为 payload_size + sizeof(finalize) 的新的 MoonBit 对象,这个对象的内存布局如下:

| MoonBit 对象头 | ... 外部数据 | 释放资源的回调 |
^
|
|_
moonbit_make_external_object 返回的指针
因此,moonbit_make_external_object 返回的指针可以直接当作指向外部数据的指针使用。当 MoonBit 的自动内存管理系统发现 moonbit_make_external_object 返回的对象生命周期已经结束时,它会以对象自身为参数,调用创建对象时提供的 finalize 函数来释放这个对象占有的外部资源。

备注

finalize 绝对不能 释放对象自身,因为这部分工作由 MoonBit 运行时负责。

在 MoonBit 侧,moonbit_make_external_object 返回的对象应当被绑定到 抽象 类型,即用 type T 语法声明的类型。这样一来,MoonBit 的内存管理系统就不会无视这个对象。

MoonBit 对象的生命周期管理
当通过函数将 MoonBit 对象传递给宿主时,必须注意 MoonBit 对象本身的生命周期管理。如前所述,MoonBit 的 Wasm 后端和 C 后端使用编译器优化的引用计数来管理对象的生命周期。为了避免内存错误或泄漏,FFI 函数必须正确维护 MoonBit 对象的引用计数。

备注

仅限 C 后端和 Wasm 后端。

引用计数的调用约定
MoonBit 的引用计数默认使用被调用者持有所有权的调用约定。也就是说,被调用的函数需要调用 moonbit_decref 函数来释放它的参数。如果参数被多次使用,被调用的函数需要调用 moonbit_incref 函数来增加引用计数。下面是不同场合下维护正确引用计数需要做的操作:

场合

操作

读取字段/元素

什么都不做

存储进数据结构

调用 incref

作为参数传递给 MoonBit 函数

调用 incref

作为参数传递给其他外部函数

什么都不做

作为返回值被返回

什么都不做

作用域结束(且没有返回)

调用 decref

下面的例子是一个正确维护引用计数的、标准的打开文件的 open 函数的绑定:

extern "C" fn open(filename : Bytes, flags : Int) -> Int = "open_ffi"
int open_ffi(moonbit_bytes_t filename, int flags) {
int fd = open(filename, flags);
moonbit_decref(filename);
return fd;
}
被管理的类型
下面的类型不是分配在堆上的,不需要管理生命周期:

内置数字类型,例如 Int 和 Double

常量枚举(所有构造器都不带参数的枚举)

下面的类型总是分配在堆上的,并且需要引用计数:

FixedArray[T], Bytes and String

抽象类型(type T)

外部类型(#external type T)也被分配在堆上,但它们表示外部指针,因此 MoonBit 不会对它们执行任何引用计数操作。

struct/有参数的 enum 的内存表示是不稳定的。

borrow 和 owned 标记
通过 FFI 传递参数时,参数的所有权可能会被保留,也可能不会。#borrow 和 #owned 标记可以用来指定这两种情况。

警告

我们正在将默认语义从 #owned 迁移到 #borrow。

#borrow 和 #owned 的语法如下:

#borrow(params..)
extern "C" fn c_ffi(..) -> .. = ..
其中,params 是 c_ffi 的参数列表的一个子集。

被 #borrow 标记的参数会使用基于借用的调用约定,也就是说,被调用的函数不需要对这些参数调用 decref。如果 FFI 函数只会在运行期间读取它的参数(不会返回它的参数或把它们存进数据结构),那么使用 #borrow 标记可以直接绑定 FFI 函数。上面的 open 的例子可以使用 #borrow 标记来简化:

#borrow(filename)
extern "C" fn open(filename : Bytes, flags : Int) -> Int = "open"
这里不再需要 C 胶水函数:我们直接绑定到了原版 open。#borrow 标记保证了这个简化的版本依然能正确维护引用计数。

即使由于某些其他原因依然需要写 C 胶水函数,#borrow 标记也可以用于简化 C 胶水内部的生命周期管理。下面是不同场合下,正确维护 借用参数 的引用计数需要做的操作:

场合

操作

读取字段/元素

什么都不做

存储进数据结构

调用 incref

作为参数传递给 MoonBit 函数

调用 incref

传递给其他 C 函数 / #borrow MoonBit 函数

什么都不做

作为返回值被返回

调用 incref

作用域结束(且没有返回)

什么都不做

#owned 语义与之相反,表示参数会被 FFI 函数存储,并且需要在稍后手动调用 decref。一个使用场景是注册回调函数,其中闭包会被 持有所有权。

原生后端链接选项
cc 选项用于指定用于编译 moonc 生成的 C 源文件的编译器。它可以是编译器的完整路径,也可以是通过 PATH 环境变量可访问的简单名称。

{
"link": {
"native": {
"cc": "/usr/bin/gcc13"
}
}
}
cc-flags 选项用于覆盖传递给编译器的默认标志。例如,您可以使用以下标志来定义一个名为 MOONBIT 的宏。

{
"link": {
"native": {
"cc-flags": "-DMOONBIT"
}
}
}
cc-link-flags 选项用于覆盖传递给链接器的默认标志。由于链接器是通过编译器驱动程序调用的(例如,通过 cc 而不是 ld,通过 cl 而不是 link),因此在传递特定选项时,应该使用 -Wl, 或 /link 前缀。

以下示例从生成的二进制文件中剥离符号信息。

{
"link": {
"native": {
"cc-link-flags": "-s"
}
}
}
stub-cc 选项与 cc 类似,但控制用于编译存根的编译器。虽然它可以与 cc 不同,但不建议这样做,应仅用于调试目的。因此,我们强烈建议同时指定 cc 和 stub-cc 并使它们保持一致,以避免潜在冲突。

stub-cc-flags 选项类似于 cc-flags。它仅对存根编译有效。

stub-cc-link-flags 与 cc-link-flags 类似,但有微妙的区别。通常,存根被编译为目标文件,并与从 moonc 生成的 C 源文件的目标文件链接。这种链接仅由前面提到的 cc-flags 和 cc-link-flags 控制。然而,在特定模式下,存根目标文件将有一个单独的链接过程,在该过程中,stub-cc-link-flags 将生效。

原生后端的默认 C 编译器和编译器标志
以下是对 compiler_flags.rs 的简要总结。

C 编译器
在 PATH 中从上到下搜索以下项目。

cl

gcc

clang

cc

内部的 tcc

对于类似 GCC 的编译器,默认的编译和链接命令如下。[] 用于指示某些模式下可能不存在的标志。

cc -o $target -I$MOON_HOME/include -L$MOON_HOME/lib [-g] [-shared -fPIC]
-fwrapv -fno-strict-aliasing (-O2|-Og) [$MOON_HOME/lib/libmoonbitrun.o]
$sources -lm $cc_flags $cc_link_flags
对于 MSVC,默认的编译和链接命令如下。

cl (/Fo|/Fe)$target -I$MOON_HOME/include [/LD] /utf-8 /wd4819 /nologo (/O2|/Od)
/link /LIBPATH:$MOON_HOME/lib

完成第二阶段

评论

动作测试