外部函数接口

引言

外部函数接口(FFI)允许 Wolfram 语言调用从外部库导出的函数. 这些库必须是动态共享库,但不需要修改以添加特殊的接口层. 如果一个库可以从其他程序(如用 C 语言编写的程序)中使用,那么它应该可以通过 FFI 调用. 由于不需要创建编译后的代码,设置起来也很快.

Wolfram 语言 FFI 的一些功能包括:可以使用多种类型(包括原子类型和复合类型),支持回调函数的使用,可以处理非常低级的结构,并与自动内存管理系统集成.

一个关键函数是 ForeignFunctionLoad,这里使用它从示例库 "compilerDemoBase" 加载函数 "addone",给出参数和结果类型. 结果是一个 ForeignFunction 对象:

该函数可以按照 Wolfram 语言函数的正常方式调用:

当调用此函数时,来自 Wolfram 解释器的输入(一个表达式)会被转换为原生 C 类型. 这种转换形式是 Wolfram 编译器的关键部分.

使用 FFI 与库配合使用的 Wolfram 语言函数可以接受相对路径或绝对路径的库. 此外,可以省略特定于平台的扩展名.

一个用于处理库的有用函数是 FindLibrary,它会返回绝对路径名,在小程序包(paclets)和其他库路径中查找. 它还会自动添加适合您平台的相应扩展名:

compilerDemoBase 库包含在 Wolfram 语言发行版中. 您可以按如下方式查看其源代码:

这显示了 addone 函数以及本教程中将用作示例的许多其他函数:

源代码包含一个头文件 "WolframLibrary.h" ,仅仅是为了获取宏 DLLEXPORT 的定义. 这个宏指定了函数要从库中导出;在不同平台上,具体的实现细节有所不同. 这是包含此头文件的唯一原因.

您还可以加载现有库. 另外,你可以在 Wolfram 语言内创建自己的库. 这对于学习使用 FFI 非常有用,并将在本教程的其他示例中使用.

函数参数和结果

FFI 函数的参数和结果通过指定其类型来确定,支持多种不同的类型. 在 ForeignFunctionLoad 的函数页面上有一个列表. 一些类型是原子类型,例如 "CInt""CDouble",它们映射到平台的标准定义. 这些类型在 Wolfram 编译器中也受支持. 本节将展示如何使用不同的类型.

指针

指针是可以传递到函数中的内存块,可能用于存储单个值;FFI 函数支持使用指针. 一个例子是来自 compilerDemoBase 库的 addonePointer 函数;C 源代码如下所示.

int addonePointer (int in, int* out) {
    *out = in + 1;
    return 0;
}

此函数可以使用 ForeignFunctionLoad 通过 FFI 调用,如下所示. 指针参数被赋予一个"RawPointer",包裹着指针所指向的内容在本例中为 "CInt". 同样,这种类型规范形式是 Wolfram 编译器的重要组成部分:

当然,这创建了函数,但要调用它,需要能够制作一个适用于指针的 Wolfram 表达式. 这可以通过 RawMemoryAllocate 来完成:

RawMemoryAllocate 的结果实际上是一个包含 RawPointerManagedObject. 管理对象允许在不再使用时进行内存回收,这将在后面的章节中详细讨论. 通常,你可以在期望使用被管理对象的地方使用 ManagedObject.

这将调用该函数. 返回值为 0,正如预期的那样:

要查看指针中存储的值,可以使用 RawMemoryRead

以上就是如何加载一个将值存储在指针中的函数. 创建一个合适的指针,调用该函数并检索存储的值.

RawMemoryAllocate 的结果实际上是一个被 "ManagedObject" 封装的 "RawPointer". 这为原始内存块提供了一个内存管理系统. 有关这方面的详细信息将在后面的章节中讨论.

一种重要的指针类型是 "OpaqueRawPointer". 这相当于C语言中的 void*,即一个不真正指向任何特定内容的指针.

数组

数组是内存使用的另一种形式,是一个可以存储一个或多个相同类型元素的内存块;数组也受到 FFI 函数的支持. compilerDemoBase 库中的 populateArray 函数是一个例子,如下所示.

int populateArray(long* arr, long len) {
    for(int i = 0; i < len; i++)
        arr[i] = i*i;
    return 0;
}

这个函数可以通过适当调用 ForeignFunctionLoad 来使用 FFI 进行调用. 数组被赋予了一个 "RawPointer" 类型,其中包含数组所持有的元素类型在本例中是 "CLong"

当然,这创建了函数,但要调用它,需要能够制作一个适用于指针的 Wolfram 表达式. 这可以通过 RawMemoryAllocate 来完成:

现在可以调用该函数;返回值正如预期为 0

可以使用 RawMemoryImport 将数组的全部内容读入列表:

可以使用 RawMemoryRead 读取数组开头的值:

RawMemoryRead 还可以从数组的开头获取偏移量.(请注意,这是一个偏移量,而不是部分编号.):

RawMemoryImport 可以返回 NumericArray;如果您想保留数据的类型,这很有用:

RawMemoryImport 可以返回 ByteArray. 这将从内存中读取原始字节,因此如果您的数据元素大于一个字节,您将需要调整长度. 在这个例子下,每个整数有 8 个字节:

当您查看实际字节时,可以看到它们是如何存储以构成更大的数据元素的(这称被为字节序). 使用原始内存可能会开始涉及到这样的低级细节:使用原始内存可以开始深入到诸如此类的底层细节:

RawMemoryImportRawMemoryExport 将在后面的部分中更详细地介绍.

字符串

在C语言中,字符串被表示为以零字节结尾的字节数组. 下面展示了一个创建并返回字符串的 C 程序.

需要注意的是,这个例子是用 C 语言创建一个库,但也可以使用任何其他与 C 兼容的语言来制作该库.

该程序可以用 Wolfram 语言编译成库.(请注意,这需要在您的机器上安装 C 编译器。):

这将使用 ForeignFunctionLoad 加载函数. 返回类型是 "UnsignedInteger8" 数组:

调用该函数. 结果是一个原始内存块;它是非托管的,因此如果不以某种方式释放它,内存将会丢失:

原始数据可以导入到字符串中. 由于它以空字符结尾,因此无需提供长度:

采用 "String" 格式的 RawMemoryImport 很有用,因为它可以计算出长度. 但长度可以这样给出:

结构体

结构体是一种通常用于保存多个相关数据元素的数据类型. 大多数编译语言(例如 C 语言)都支持结构体. 返回指向结构体的指针的示例代码如下所示:

该程序可以用 Wolfram 语言编译成库.(请注意,这需要在您的机器上安装 C 编译器。):

这将使用 ForeignFunctionLoad 加载函数. 返回一个指针,指向包含字符串和 "CInt" 的结构体. 该结构体通过将字段类型放在 { } 内构成. 无需为字段命名:

调用该函数. 结果是一个原始内存块;它是非托管的,因此如果不释放它,内存将会丢失(这将在后面的章节中讨论):

可以使用 RawMemoryRead 导入数据. 它以列表形式返回结构体的字段:

可以读取存储在结构体中的字符串:

作为参数的结构体

结构体可以作为参数传递. 在此示例中,传入了一个结构体(请注意,这不是指向结构体的指针):

编译库:

加载函数;参数是一个类型均为 "CInt" 的两个字段的结构体:

调用该函数,通过将其参数放入列表 {args} 中来制作结构体:

嵌套在其他结构体中的结构体

一个结构体可以嵌套在另一个结构体中:

编译库:

加载函数;参数是一个类型均为 "CInt" 的两个字段的结构体:

调用该函数;结果是一个指向结构体的指针:

使用 RawMemoryRead 导入指针的参数. 有两个字段,一个 "CInt" 和一个指向另一个结构体的指针:

读取内部结构体的内容:

回调函数

回调函数允许外部库执行从调用它们的环境传递的代码. 下面提供了一个来自 compilerDemoBase 库的 createArray 函数的示例.

long* createArray(long (*fun)(long)) {
    long* out = (long*)malloc(sizeof(long) * 10);
    for(long i = 0; i < 10; i++) {
        out[i] = fun(i);
    }
return out;
}

此函数可以通过适当调用 ForeignFunctionLoad 使用 FFI 调用,如下所示. 回调以 "OpaqueRawPointer" 类型传递,这意味着可以传入许多内容. 但如果传入的不是正确函数,则会发生不好的事情:

可以使用 CreateForeignCallback 轻松创建一个调用评估器的回调:

这里显示数组中存储的数据:

原始数据

本节讨论原始数据如何在外部函数和 Wolfram 语言之间传递. 一些关键问题是数据如何在 Wolfram 语言中创建、如何写入和读取以及管理(例如如何释放数据).

外部函数分配

来自 compilerDemoBase 库的 createArray 函数使用 C 内存分配器 malloc 创建数据,如下所示. 调用此函数时,分配完成,数据返回给 Wolfram Evaluator.

long* createArray(long (*fun)(long)) {
    long* out = (long*)malloc(sizeof(long) * 10);
    for(long i = 0; i < 10; i++) {
        out[i] = fun(i);
    }
return     out;
}

此内存可以通过调用 C 内存函数 free 来释放. 这可以通过 FFI 函数或 Wolfram 编译器函数来完成. 为了自动完成此操作,可以将其封装在 ManagedObject 中,如下所示.

Wolfram 语言分配

数据可以在 Wolfram 语言中创建并传递给 FFI 函数. RawMemoryAllocate 创建未初始化的数据并返回托管的原始指针. 未初始化的数据可以传递给 FFI 函数并在那里初始化,或者可以通过 RawMemoryWrite 写入.

下面分配一个指向 64 位整数的指针:

以下写入指针:

可以看到有关指针的信息. 有关指针性质的更多细节将在后面介绍:

RawMemoryAllocate 也可以分配一个数组:

要填充数组的元素,需要一个使用偏移量来写入每个位置的循环. 请记住,偏移量从 0 开始:

RawMemoryRead 可以读取元素;同样,这必须在一个循环中完成,偏移量从 0 开始:

理解数组和指向数组的指针之间的区别很重要. 在这个例子中,创建了一个指向 "UnsignedInteger8"(即 C 字符串)原始指针的指针:

要写入此内容,需要一个 "UnsignedInteger8" 的原始指针,可以使用 RawMemoryExport 创建:

现在可以使用保持字符串的指针来写入这个指针:

RawMemoryRead 读取存储的值,但不会更深入地解释它. 因此,如果您从一个指向指针的指针读取,会得到一个指针作为返回值:

可以创建指向结构体的指针:

将数据写入指向结构体的指针:

可以从结构体中读取数据:

关于 RawMemoryAllocate 的结果如何释放的详细信息将在后面展示.

总结一下,RawMemoryAllocate 可以创建指针和数组. RawMemoryWrite 可以填充它们,但每次只能填充一个元素. RawMemoryRead 可以从指针和数组中读取,但每次也只能读取一个元素.

导出和导入

与分配、读取和写入函数相比,RawMemoryExportRawMemoryImport 提供了用于处理原始指针和数组的更高级函数.

下面创建一个指向 "Integer16" 的指针,并将其初始化为值 10:

如果使用列表,则会创建一个使用适当值初始化的数组:

此数据可以使用 RawMemoryImport 导入到列表中:

它也可以导入到 NumericArray 中;这保留了结果中数据的类型:

它也可以导入到 ByteArray 中,以字节形式返回结果:

由于导入的数组是字节大小,因此结果的长度与原始数据相同:

这里导入的数组具有包含两个字节的元素,这反映在输出字节数组中:

RawMemoryExportRawMemoryImport 处理数组数据更加方便.

字符串

某些表达式,比如字符串,有自动转换功能,所以对于字符串来说,不需要类型参数(这也适用于 NumericArrayByteArray):

当导入字符串时,不需要指定长度:

如果指定了长度,字符串可能会被截断:

存储在字符串中的实际字节可以被导入:

字符串还涉及字符编码的问题. 这个字符串包含一些非 ASCII 字符:

它们可以正常读取:

但如果检查实际的字节,会显示默认的 UTF8 编码:

字符编码可以更改. 这里使用了 ISOLatin1. 现在只存储了 3 个字节:

使用默认编码导入这个字符串会遇到错误:

如果设置了正确的字符编码,就不会出现错误:

UTF8 的默认编码非常常见,所以通常不需要考虑这些编码问题.

字符串的另一个问题是,如果字符串实际包含零字节,会涉及零终止. 这里有一个只包含一个字符(零字节)的字符串. 当它被导入时,零字节的内容被解释为零字节终止:

可以看到两个零字节:

如果指定了字符串的长度,就可以正确读取:

传递给使用 C 字符串的程序的字符串不能包含零字节内容,所以这实际上是一个次要问题.

结构体

您可以使用 RawMemoryExport 处理结构体. 以下代码创建了一个指向结构体的指针. 注意这里使用了两层列表. 一层是必需的,用于从其参数构建结构体,另一层用于指定这是数组中的一个元素:

您可以使用 RawMemoryImport 读取数据:

如果您希望结构体包含其他原始数据,比如字符串,需要明确地填写:

使用 RawMemoryImport 读取时,数据以原始格式返回,不会进行更深入的解释. 要进行更深入的解释,需要一个单独的步骤:

RawMemoryImport 难以更深入地解释数据,因为数据可能包含循环引用,这会阻止返回表达式树.

原始指针和内存管理

Wolfram 语言 FFI 函数的一个关键元素是处理原始内存. 本节讨论这是如何工作的,以及如何管理这种内存.

RawMemoryAllocate 为某种类型的元素分配内存. 它返回一个托管指针或数组:

Information 返回一些关于结果的有用细节:

一个用于处理原始指针的示例函数是 addonePointer

可以调用该函数,将原始指针作为第二个参数. 注意,尽管原始指针被封装在 ManagedObject 中,但在进行调用时,这个封装会被去掉:

这将从指针读取数据.

ManagedObject 实际上包含一个非托管原始指针. 实际的原始指针可以提取出来:

再次,Information 返回有用的详细信息:

可以提取原始指针的地址:

原始指针可以传递到外部函数中:

管理分配的一个关键优势是,当 Wolfram 语言不再使用内存时,内存将被回收. 这可以通过运行从测试工具加载的内存泄漏检查函数来演示:

这将运行 RawMemoryAllocate 并显示内存没有泄漏:

当你使用 ManagedObject 调用 FFI 函数时,值会被提取并传入. 然而,这个值仍受管理,所以这实际上只是借用该值. 如果函数保留了对借用值的句柄,那么当持有它的 ManagedObject 被释放时,这将是不安全的,因为借用的值也会被释放.

如果您想要将某个值保留比其 ManagedObject 更长的时间,则可以使用 UnmanageObject

您可以看到 ManagedObject 不再处于活动状态.

如果对 ManagedObject 使用 UnmanageObject,则内存可能会丢失. 内存泄漏检查器检测到每次执行都会丢失内存:

防止内存泄漏的一种方法是在非受托管的原始指针上调用 RawMemoryFree. 内存泄漏检查器返回 True,这表示没有内存被丢失:

也可以获取未托管的对象并再次对其进行管理. 这样做有点奇怪,因为您可以一开始就不调用 UnmanageObject. 但是,它确实有效:

Wolfram 编译器还支持 ManagedObject. 在编译的代码中,它对于处理低级内存分配非常有用,确保内存能适当释放.

请注意,使用 RawMemoryAllocate 分配的内存如果需要释放(可能是因为它与 ManagedObject 分离),必须使用 RawMemoryFree 释放.

从 FFI 分配中收集

当 FFI 调用的函数中分配的内存返回到 Wolfram 语言时,通常需要另一个函数来收集它.

以下代码包含一个用于分配内存的函数和另一个用于释放内存的函数. 在典型的应用程序中,可能一个函数返回结构数据类型的实例,另一个函数释放它(以及它所拥有的任何资源):

该程序可以用 Wolfram 语言编译成库.(请注意,这需要在您的机器上安装 C 编译器.):

分配和释放内存的函数用 ForeignFunctionLoad 加载:

这将调用函数来分配内存:

这将释放内存.

有时,总是必须记住调用释放函数是很麻烦的. 为了避免这种情况,您可以使用 CreateManagedObject,使用释放函数.

现在,当托管对象被创建时,它将在不再使用时被释放:

请注意,由原始分配函数(例如 C 函数 malloc)分配的内存必须通过调用其相应函数(例如 C 函数 free)来释放.

转换原始指针

您可以将原始指针转换为其他原始指针.

首先,创建一个指向 "Integer64" 的指针:

将一些数据写入指针:

创建另一个指针,使用相同地址,但被视为指向 "Real64"

"Integer64" 指针中存储的数据与预期一致:

"Real64" 指针中存储的数据是整数到浮点数的位的转换:

您还可以将指向整数的指针转换为 "OpaqueRawPointer"

这对于调用采用 void* 参数的函数很有用.

您还可以创建指向整数的原始指针. 这将创建一个指向地址为 10 的 "Integer64" 的原始指针.(使用这种方法可能会导致问题.):

可以对 "OpaqueRawPointer" 执行等效操作. 同样,这几乎肯定是一个问题:

一个有用的操作是创建一个指向类型的指针并存储 0:

对于 "OpaqueRawPointer" 也是如此:

这对于构建空指针很有用,它在 C 和某些其他语言中使用.

生成指向相同内存位置的指针的一个重要方面是托管对象被设置为适当地释放其内容. 只要一个引用仍然有效,内存就不会被释放. 这可以通过内存泄漏检查器看到:

以这种方式使用原始指针非常有用,因为它允许您在不同类型的指针之间进行转换,并从整数创建指针. 在许多语言中,这称为位转换(bit casting). 当然,这非常危险;任何错误都可能导致程序终止或未定义的行为.

函数指针

您可以使用 ForeignPointerLookup 从库中提取导出函数的地址.

这将返回示例库 "compilerDemoBase" 中函数 "addone" 的地址:

它的地址如下:

您可以使用此指针作为另一个函数的回调.

如果您知道函数的类型,则可以将它用在 ForeignFunctionLoad 中:

如果您知道函数的类型,则可以将它用在 ForeignFunctionLoad 中:

Information 还返回有关 ForeignFunction 对象的有用信息:

这将返回与上面匹配的函数地址:

使用 ForeignFunctionLoadForeignPointerLookup 等库的 FFI 函数可以接受缩写形式或完整路径名的库名称.

这将使用删除了库扩展名的名称:

您可以添加适合您平台的库扩展名. 在这台机器上它是 dylib

使用库的 FFI 函数使用 FindLibrary 来解析库名称. 您可以直接调用它;它返回一个绝对名称:

绝对名称可用于加载函数:

FindLibrary 查找由 $LibraryPath 指定的多个标准位置:

它还会在声明了库扩展的包中查找. 作为一个效率提示,如果需要加载许多 FFI 函数,使用 FindLibrary 一次创建绝对名称,然后在 ForeignFunctionLoad 中使用这个名称可能会更快.

依赖关系

当加载一个库时,需要加载它所需的任何库. 如果系统库路径设置正确,这将自动发生. 如果没有,可以先使用 LibraryLoad 加载它们.

This loads the compilerDemoBase library:

如果库之间存在循环依赖关系,例如两个库相互依赖,则唯一的解决方案是设置系统库路径. 通常这种情况很少见.

Wolfram 编译器

Wolfram 编译器支持适用于 FFI 的类型. 例如,编译后的代码可以返回原始指针:

通过 "OpaqueRawPointer" 可以执行相同的操作:

应该注意到,编译器函数 ToRawPointer 也会生成一个原始指针,但这个指针是在调用该函数的栈帧中分配的. 因此,返回这样的指针是不安全的.

可以使用编译后的代码生成诸如 "CArray" 之类的类型,然后将其转换为原始指针:

这些类型的操作对于准备和使用 FFI 工具很有用.

托管

您可以在编译器中使用托管对象. 这对于准备要传递给其他程序的原始数据很有用,同时确保这些数据被正确回收.

这里创建一个整数数组,然后将其丢弃:

代码运行时,出现内存泄漏:

此代码将数组放入托管对象中:

没有出现内存泄漏:

编译器中托管对象的语法与 FFI 语法略有不同. 未来的工作将统一这些语法.

有趣的范例

调用 OpenSSL 库

OpenSSL 库与 Wolfram 语言捆绑在一起,用于支持安全通信. 它有一些可以通过 FFI 调用的有趣函数.

这会返回库的路径. 在不同平台上路径是不同的,这是在当前平台上的值:

这将加载一个返回库版本的函数:

调用版本号函数:

OpenSSL 库有一个生成随机字节的函数. 它接受一个字节数组和数组长度作为参数. 该函数可以按如下方式加载:

生成一个待填充的字节数组:

调用该函数:

返回生成的字节列表:

使用 Wolfram 编译器调用库

也可以使用 Wolfram 编译器从库中调用函数. 通常,这更繁琐并且生成函数的速度较慢. 但是,它的执行路径更简单.

首先,需要对库中的函数进行声明:

现在编译一个调用库函数的函数:

当调用该函数时,会产生预期的结果:

在 Wolfram 编译器创建的库上使用 FFI

您可以使用 Wolfram 编译器生成库,然后用 FFI 调用它们. 一种便捷的方法是创建一个导出原始函数的 CompiledComponent.

以下代码声明了一个名为 "demoLibrary" 的组件,其中包含一个名为 square 的函数:

以下代码为 square 声明了一个原始库导出:

现在组件的库已经构建完成:

这里显示已创建的库:

要使用该库,必须加载组件:

现在可以使用 FFI 从库中加载该函数:

调用函数:

有很多方法可以创建库并调用函数,包括使用 Wolfram 编译器和 FFI. 将这些方法混合使用是一种有用的学习和探索途径,可以找到最适合您的解决方案.

使用 Wolfram 编译器创建回调

您可以使用 Wolfram 编译器创建回调函数. 这里提供一个回调,其执行速度比使用 Wolfram 解释器编写的回调更快.

这将从 compilerDemoBase 库加载 createArray 函数:

可以使用 CreateForeignCallback 创建调用评估器的回调:

这将调用传入回调的函数:

现在要使用 Wolfram 编译器创建回调. 首先,创建一个具有正确类型和功能的编译函数:

实际的原始函数存储在 CompiledCodeFunction 中:

现在需要将 CompiledCodeFunction 对象中的原始函数指针转换为 OpaqueRawPointer

现在您可以使用此回调调用 createArray 函数:

返回的数组具有预期值: