本 EEP 描述了 Erlang/OTP 的外部函数接口 (FFI),允许轻松直接调用外部 C 函数。它引入了三个新的 BIF(ffi:raw_call/3
、erl_ddll:load_library/3
和 ffi:raw_call/2
),这些 BIF 完成了主要的 FFI 任务:加载通用 C 库、进行外部函数调用以及执行自动 Erlang 到 C 和 C 到 Erlang 的类型转换。
它还引入了两个辅助 BIF,用于将 C 缓冲区/字符串转换为二进制数据(ffi:raw_buffer_to_binary/2
和 ffi:raw_cstring_to_binary/1
),一个新的 ffi
Erlang 模块,它提供具有更严格类型检查的更高级别 API,以及一些实用宏。最后,它使用 FFI 信息扩展了 erl_ddll:info/2
。
当前的 Erlang 扩展机制可以分为两大类:
绝对的稳定性,但以速度为代价(C 节点,管道驱动程序);
更高的速度,但以(潜在的)稳定性为代价(链接的驱动程序)。
因此,当效率成为问题时,链接驱动程序已成为创建库绑定的标准方法。然而,在这两种情况下,Erlang 驱动程序接口都意味着要开发大量的粘合代码,这主要是因为 Erlang 和 C 之间的通信总是需要数据解析和(反)序列化。已经创建了几个工具来自动生成(至少部分)该粘合代码:从(现在已不再维护的)IG 驱动程序生成工具到较新的 Erlang 驱动程序工具包 (EDTK) 和 Dryverl。
但是,即使在这些工具的帮助下,开发 Erlang 驱动程序也是一项困难且耗时的任务(尤其是在与具有数十或数百个函数的外部库接口时),并且粘合代码本身会增加引入错误的可能— 这在链接驱动程序的情况下,通常意味着 VM 崩溃。由于所有这些原因,缺乏库以及难以从其他语言连接它们是通常与 Erlang 相关的负面方面之一。
当开发人员需要用优化的 C 函数替换其 Erlang 代码的性能关键部分时,也会出现同样的问题。在这种情况下,数据序列化/反序列化的开销也可能是一个重要问题。
一种更简单的接口 Erlang 和 C 代码的方法可以极大地扩展 Erlang 的功能并打开新的使用场景。
本 EEP 提出了一种外部函数接口 (FFI) 扩展,该扩展将允许轻松执行直接 C 函数调用。这个概念几乎在每种语言中都有实现,有两种主要的(非排他的)方法:
此 EEP 遵循第一种方法,但(在可能的情况下)也重用了现有 C 驱动程序 API 的一部分(因此,允许在外部 C 函数中管理 ErlDrvBinary
和 ErlIOVec
指针)。
FFI 的设计不需要更改语言或引入不兼容性。
此 EEP 中提出的 BIF 和函数不会提供对 Erlang VM 内部结构的任何访问权限 — 但被调用的 C 函数可能会泄漏内存和/或导致 Erlang VM 崩溃。因此,FFI 并非旨在供“临时” Erlang 开发人员使用:这是一个为库绑定开发人员(应注意向最终用户隐藏 FFI 调用)和高级程序员设计的工具,他们正在寻找一种从 Erlang 调用 C 代码的简单(且高效)方法。
为了调用 C 函数,FFI 需要打开一个指向所需 C 代码的端口。因此,使用当前的驱动程序加载机制,开发人员将需要:
创建一个带有 void ErlDrvEntry
结构和驱动程序初始化函数的 C 文件;
编译它,并可能将其与所需的 C 库链接,从而获得一个空的 Erlang 驱动程序;
通过使用 erl_ddll:load/2
将驱动程序加载到 Erlang VM 中。
为了简化此过程,此 EEP 提出了 erl_ddll:load_library/3
函数,该函数允许将通用库加载到 Erlang VM 中 — 即使它缺少 Erlang 链接驱动程序的结构。
erl_ddll:load_library/3
还提供了一个选项来预加载 C 函数符号和签名的列表,从而预编译执行动态函数调用所需的内部结构。可以使用 erl_ddll:info/2
检索有关预加载数据的信息。
一旦加载了库或驱动程序,就可以使用 erlang:open_port/2
或 erlang:open_port/1
来获取 FFI 函数的端口,并通过低级或高级 API 执行调用。
低级 FFI 方法以 raw_
前缀表示。主要函数是 ffi:raw_call/3
BIF,它通过打开的端口执行直接 C 函数调用。它将 C 类型转换为 Erlang 类型,反之亦然。
单独使用时,ffi:raw_call/3
存在一个主要缺点:它会引入很大的调用开销,这是因为 C 符号查找和函数调用的动态构建。
为了利用 erl_ddll:load_library/3
的预加载选项,引入了 ffi:raw_call/2
BIF:它避免了符号查找和调用结构编译,从而保证了比 ffi:raw_call/3
更低的调用开销。
此外,低级接口提供了两个 BIF,用于从 C 指针(可能是 FFI 调用返回的)创建 Erlang 二进制文件。这些 BIF 是 ffi:raw_buffer_to_binary/2
和 ffi:raw_cstring_to_binary/1
。
高级接口建立在低级接口之上。它引入了类型标记值的概念:传递给 FFI 调用或从 FFI 调用返回的任何值都采用 {Type, Value}
元组的形式。这允许:
提高 FFI 调用的可读性;
使 C 调用更安全:在将值本身传递给低级 API 之前,会检查标记值的一致性。此外,提供给 erl_ddll:load_library/3
的预加载信息(如果可用)用于确保标记值实际匹配函数签名;
模拟 C 代码的静态类型,因此当需要将标记值转换为另一种类型时,需要正确且显式的“转换”。
这些检查由 ffi:call/3
、ffi:buffer_to_binary/2
和 ffi:cstring_to_binary/1
(低级 BIF 的类型标记等效项)执行。还可以使用 ffi:check/1
检查类型标记的值。此外,可以使用 ffi:min/1
和 ffi:max/1
检查每个 FFI 类型的允许最小值和最大值。
FFI 在 ffi_hardcodes.hrl
头文件中定义了一系列实用宏,这些宏可用于 C 缓冲区和结构的二进制匹配。
c_func_name()
#c_func_name() = atom() | string()
C 函数的名称。
type_tag()
#type_tag() = atom()
有效的 FFI 类型原子。有关允许值的列表,请参阅附录。
tagged_value()
#tagged_value() = tuple(type_tag(), term())
用于 FFI 调用的类型标记值。
tagged_func_name()
#tagged_func_name() = tuple(type_tag(), c_func_name())
带有返回类型的 C 函数名称。
func_index()
#func_index() = integer()
erl_ddll:load_library/3
预加载列表中的函数位置。
tagged_func_index()
#tagged_func_index() = tuple(type_tag(), func_index())
带有返回类型的 C 函数索引。
signature()
#signature() = tuple(type_tag(), ...)
C 函数的签名:返回类型后跟参数类型(如果有)。
erl_ddll:load_library/3
#erl_ddll:load_library(Path, Name,
OptionsList) -> ok | {error, ErrorDesc}
类型
Path = Name = string() | atom()
OptionList = [Option]
Option = tuple(preload, [Preload])
Preload = tuple(c_func_name(), signature())
加载通用共享库。
如果在加载库时找到 ErlDrvEntry
结构和驱动程序初始化函数,则此 BIF 的行为将类似于 erl_ddll:load/2
。函数参数也与 erl_ddll:load/2
相同,但有以下附加项:
OptionList 是用于库/驱动程序加载的选项列表。支持的选项是:
{preload, PreloadList}
预加载给定的函数列表,并准备它们的调用结构。每个 PreloadList 元素都是一个元组,格式如下:
tuple(c_func_name(), signature())
即,函数名称后跟其返回和参数类型。
函数返回值与 erl_ddll:load/2
相同。
加载库后,可以使用 erlang:open_port/2
获取端口。该端口始终可以与 ffi:call/3
、ffi:raw_call/3
或 ffi:raw_call/2
一起使用。但是,如果加载的库不包含正确的 ErlDrvEntry
结构和驱动程序初始化函数,则该端口将不与 erlang:port_command/2
、erlang:port_control/3
等一起使用。
以下示例加载 C 标准库并预加载一些函数:
ok = erl_ddll:load_library("/lib", libc,
[{preload,
[{puts, {sint, nonnull}},
{putchar, {sint, sint}},
{malloc, {nonnull, size_t}},
{free, {void, nonnull}}]}]).
erl_ddll:load_library/2
#erl_ddll:load_library(Path, Name)
实用程序函数,使用空 OptionsList 调用 erl_ddll:load_library/3
。
erlang:open_port/1
#erlang:open_port(Library)
类型
Library = string() | atom()
打开一个指向指定共享库的端口,该端口可能使用 erl_ddll:load_library/3
加载。调用此函数等效于:
erlang:open_port({spawn, Library}, [binary])
erl_ddll:info/2
#此 EEP 为 erl_ddll:info/2
BIF 提出了一个新参数:'preloads' 原子。它允许检索有关给定库的 FFI 预加载的信息。
预加载信息是一个 proplist 列表,每个预加载函数一个。每个 proplist 又具有以下格式:
[ { index, integer() }, % Position in the preload list
{ name, string() }, % Function name
{ address, integer() }, % Function address
{ signature, signature() } ] % Function signature
此信息也可以通过 erl_ddll:info/0
和 erl_ddll:info/1
获得。
ffi:raw_call/3
#ffi:raw_call(Port, CallArgs, Signature) -> term()
类型
Port = port()
CallArgs = tuple(
c_func_name(), Arg1, ...)
Arg1, ... = term()
Signature = signature()
调用指定的 C 函数。
此 BIF 接受以下参数
Port
一个指向所需驱动程序/库打开的端口。
CallArgs
一个元组,包含函数名(原子或字符串),后跟其参数(如果有)。
Signature
函数签名。
此 BIF 返回被调用的 C 函数的返回值(如果返回类型为 void,则返回 ‘void’)。它会自动将 Erlang 术语与 C 值之间进行转换。附录中报告了支持的 C 类型和转换。
以下示例调用标准 C 库中的 malloc()
和 free()
函数(它应该适用于任何 Erlang 链接的驱动程序):
Pointer = ffi:raw_call(Port, {malloc, 1024}, {pointer, size_t}),
ok = ffi:raw_call(Port, {free, Pointer}, {void, pointer}).
警告: 外部 C 函数的错误和/或误用可能会影响 Erlang VM,可能导致其崩溃。请极其小心地使用此 BIF。
ffi:raw_call/2
#ffi:raw_call(Port, OptimizedCall) -> term()
类型
Port = port()
OptimizedCall = {FuncIndex, Arg1, ...}
FuncIndex = func_index()
Arg1, ... = term()
调用使用 erl_ddll:load_library/3
的 ‘preload’ 选项预加载的函数。
此 BIF 接受以下参数
Port
一个指向所需驱动程序/库打开的端口(该端口**必须**使用 erl_ddll:load_library/3
加载)。
OptimizedCall
一个元组,包含函数索引(即其在预加载列表中的位置),后跟其参数(如果有)。
此 BIF 返回被调用的 C 函数的返回值(如果返回类型为 void,则返回 ‘void’)。它会自动将 Erlang 术语与 C 值之间进行转换。附录中报告了支持的 C 类型和转换。
以下示例调用 malloc()
和 free()
,在它们使用 erl_ddll:load_library/3
中显示的代码示例进行预加载之后
Port = open_port({spawn, "libc"}, [binary]),
Pointer = ffi:raw_call(Port, {3, 1024}),
ffi:raw_call(Port, {4, Pointer})
警告: 外部 C 函数的错误和/或误用可能会影响 Erlang VM,可能导致其崩溃。请极其小心地使用此 BIF。
ffi:raw_buffer_to_binary/2
#ffi:raw_buffer_to_binary(Pointer, Size) -> binary()
类型
Pointer = integer()
Size = integer()
返回一个二进制数据,其中包含从给定 C 指针(由一个整数表示,可能由 FFI 调用返回)读取的 Size 个字节的副本。
警告: 将错误的指针传递给此 BIF 可能会导致 Erlang VM 崩溃。请极其小心地使用。
ffi:raw_cstring_to_binary/1
#ffi:raw_cstring_to_binary(CString) -> binary()
类型
CString = integer()
返回一个二进制数据,其中包含给定以 NULL 结尾的 C 字符串的副本(表示指针的整数,可能由 FFI 调用返回)。二进制数据将包括尾部的 0。
警告: 将错误的指针传递给此 BIF 可能会导致 Erlang VM 崩溃。请极其小心地使用。
ffi:call/3
#call(Port, CFunc, Args) -> RetVal
类型
Port = port()
CFunc = c_func_name() | func_index() | tagged_func_name() | tagged_func_index()
Args = [tagged_value()]
RetVal = tagged_value()
使用给定的参数列表调用 C 函数 CFunc
,使用端口 Port
。如果该函数使用 ffi:load_library/3
预加载,则在执行调用之前,所有类型标签都将与预加载的签名进行匹配。
返回 C 函数的返回值,带有正确的类型标签。
注意:如果 CFunc
不是 tagged_func_name()
类型,则仅当它使用 erl_ddll:load_library/3
预加载时才会调用 C 函数(为了确定其返回类型,这是必需的)。
例如,以下 malloc()
调用在执行 erl_ddll:load_library/3
中显示的代码示例后都是有效且等效的
%% Use function name, but require preloads for return type
{nonnull, Ptr1} = ffi:call(Port, "malloc", [{size_t, 1024}]),
{nonnull, Ptr2} = ffi:call(Port, malloc, [{size_t, 1024}]),
%% Use function index from preloads list
{nonnull, Ptr3} = ffi:call(Port, 3, [{size_t, 1024}]),
{nonnull, Ptr4} = ffi:call(Port, {nonnull, 3}, [{size_t, 1024}]),
%% These calls do not require any preload information
{nonnull, Ptr5} = ffi:call(Port, {nonnull, "malloc"}, [{size_t, 1024}]),
{nonnull, Ptr6} = ffi:call(Port, {nonnull, malloc}, [{size_t, 1024}]),
警告: 外部 C 函数的错误和/或误用可能会影响 Erlang VM,可能导致其崩溃。请极其小心地使用此 BIF。
ffi:buffer_to_binary/2
#ffi:buffer_to_binary(TaggedNonNull, Size) -> binary()
类型
TaggedNonNull = tuple(nonnull, integer())
Size: integer()
返回一个二进制数据,其中包含从给定 C 指针读取的 Size
个字节的副本。
警告: 将错误的指针传递给此函数可能会导致 Erlang VM 崩溃。请极其小心地使用。
ffi:cstring_to_binary/1
#ffi:cstring_to_binary(TaggedCString) -> binary()
类型
TaggedCString = tuple(cstring, integer())
返回一个二进制数据,其中包含给定以 NULL 结尾的 C 字符串的副本。
警告: 将错误的指针传递给此函数可能会导致 Erlang VM 崩溃。请极其小心地使用。
ffi:sizeof/1
#ffi:sizeof(TypeTag) -> integer()
类型
TypeTag: type_tag()
返回当前平台上给定 FFI 类型的大小(以字节为单位)。
ffi:check/1
#ffi:check(TaggedValue) -> true | false
类型
TaggedValue = tagged_value()
如果给定的类型标记值格式正确且一致(即,它在当前平台上其类型的允许范围内),则返回 ‘true’。否则,返回 ‘false’。
ffi:min/1
#ffi:min(TypeTag) -> integer()
类型
TypeTag = type_tag()
返回当前平台上给定 FFI 类型允许的最小值。
ffi:max/1
#ffi:max(TypeTag) -> integer()
类型
TypeTag = type_tag()
返回当前平台上给定 FFI 类型允许的最大值。
ffi_hardcodes.hrl
#ffi_hardcodes.hrl
文件是 Erlang ffi 库的一部分。它定义了一组用于处理 FFI 类型大小的宏,以及用于在 C 缓冲区和结构上轻松进行二进制匹配的宏
FFI_HARDCODED_<TYPE>
一个 Erlang 位语法代码段 (Size/TypeSpecifier),可用于匹配二进制内部给定的 FFI 类型(可能从 C 缓冲区获得)。例如,以下二进制匹配
<<ULong:?FFI_HARDCODED_ULONG, _Rest/binary>> = Binary
在 x86-64 上将展开为
<<ULong:64/native-unsigned-integer, _Rest/binary>> = Binary
FFI_HARDCODED_SIZEOF_<TYPE>
类型大小(以 *字节* 为单位)
FFI_HARDCODED_<TYPE>_BITS
类型大小(以 *位* 为单位)
正如其名称所暗示的那样,ffi_hardcodes.hrl
的内容是*特定于构建平台的*,当使用它们时,它们将被硬编码到生成的 .beam
文件中。因此,如果开发人员希望他的/她的基于 FFI 的代码*在不重新编译的情况下可移植*,则应避免使用这些宏。以可移植方式获取 FFI 类型大小的推荐方法是 ffi:sizeof/1
函数。
当使用 erl_ddll:load_library/3
加载库时,可以像任何 Erlang 链接的驱动程序一样重新加载或卸载它。如果使用了 ‘preload’ 选项,则会出现两种额外的行为
如果使用相同的库调用 erl_ddll:load_library/3
两次或多次,则必须根据上次调用重建关联的预加载列表。如果没有使用 ‘preload’ 选项,则必须保持上次预加载(如果有)不变;
如果发出 erl_ddll:reload/2
,则必须通过在加载的库中执行新的符号查找来刷新上次预加载。如果一个或多个符号无法再找到,则必须禁用它们(并且当尝试将它们与 ffi:raw_call/2
一起使用时必须引发错误)。
ffi:call/3
和 ffi:raw_call/3
可以通过简单地提供所需数量的参数来调用可变参数 C 函数。
但是,为了利用预加载优化,有必要为每个不同的函数调用签名使用不同的预加载。例如,如果开发人员将使用不同的参数调用 printf()
,则他/她需要使用如下所示的预加载列表
ok = erl_ddll:load_library("/lib", libc,
[{preload,
[{printf, {sint, cstring}},
{printf, {sint, cstring, double}},
{printf, {sint, cstring, uint, sint}},
{printf, {sint, cstring, cstring}}]}]).
如附录中所述,Erlang 二进制数据可以作为 ‘pointer’ 值传递给 C 函数。在这种情况下,C 函数将收到指向二进制数据第一个字节的指针。
该指针*仅*在 C 函数返回之前有效。如果 C 端需要在以后访问指针数据,则应使用 ‘binary’ FFI 类型(请参见下一段),或者将数据本身复制到安全的位置。
如附录中所述,当 ‘binary’ FFI 类型用作参数时,C 函数还将接收二进制数据(以 ErlDrvBinary
指针的形式)。相应地,具有 ‘binary’ FFI 返回类型的 C 函数必须返回一个 ErlDrvBinary
指针。此外,‘erliovec’ 参数类型将导致将 Erlang iolist()
转换为 ErlIOVec
(并且其指针将传递给 C 函数)。
有三个规则可以正确处理通过 FFI 调用传递给 C 端或从 C 端返回的二进制数据的引用计数。
当二进制数据作为参数接收时(直接或在 ErlIOVec
中),并且 C 端需要保留引用,则必须增加引用计数;
当使用 driver_alloc_binary()
创建二进制数据时,其引用计数值为 1。它被认为*仍然*被 C 端引用;
作为前一点的结果,如果 C 端想要返回新创建的二进制数据而*不*保留引用,则必须在返回之前调用 driver_binary_dec_refc()
。
如上所述,高级 FFI API 基于类型标记值。但是,类型标签可能会引入另一种注释/表示 Erlang 函数参数类型的方式 — 这可能会成为一种令人讨厌的冗余,尤其是在类型协定(可能)会在 Erlang 中引入的情况下。
因此,高级 FFI API 应被视为高度实验性的,并且可能会根据类型协定如何允许完成相同的任务(请参见高级 API)而发生更改。如果/当协定在标准 Erlang/OTP 发行版中可用时,将需要探索此问题。
此 EEP 和建议的 FFI 补丁(请参见下文)不会引入与标准 OTP 发行版的不兼容性。但是,需要进行三个(可能)相关的内部更改
必须允许 driver_binary_dec_refc()
函数在没有错误或警告的情况下达到引用计数 0(即使在调试时也是如此)。为了允许 C 函数创建二进制数据,删除其引用并将其返回给 Erlang VM,这是必要的(请参见“关于 Erlang 二进制数据和引用计数的说明”);
作为前一点的结果,必须允许 driver_binary_inc_refc()
在没有错误或警告的情况下达到最小引用计数 1(当前最小值为 2);
io.c
中的 iolist()
-> ErlIOVec
转换代码需要作为独立函数公开,供 FFI 使用。
此 EEP 的实现在 muvara.org 上作为针对 OTP R11B-5 的一组补丁提供。
该代码基于 GCC FFI 库 (libffi)。libffi 是一个多平台库,可以独立于 GCC 源代码进行打包和使用,并以非常宽松的 许可 发布(与 Erlang 公共许可证兼容)。它已被用于实现多个应用程序和语言的 FFI 接口,包括 Python。
当前 EEP 实现会在构建系统中查找 libffi,并将 Erlang 虚拟机链接到它(如果可用,则优先使用 libffi 共享库)。这可能是一种“足够好”的方法,因为 libffi 通常会在 GNU/Linux、BSD 和 Solaris 发行版上预先打包并易于获取。然而,这种方法可能会给从头开始编译所有内容的开发人员带来麻烦,他们可能无法安装预编译的 libffi 包,或者只是想强制 Erlang 虚拟机和 libffi 之间的静态链接。为了解决这些问题,通常将 libffi 的副本与宿主语言一起分发,并可能与上游版本保持同步。这正是 Python 实际的做法,Erlang/OTP 可能会根据开发人员的反馈采用相同的方法。
下表报告了用于将 Erlang 项作为 C 函数调用参数传递的 Erlang 到 C 的转换。
====================== ===============================
C argument type Supported Erlang types
====================== ===============================
uchar integer()
schar integer()
ushort integer()
sshort integer()
uint integer()
sint integer()
ulong integer()
slong integer()
uint8 integer()
sint8 integer()
uint16 integer()
sint16 integer()
uint32 integer()
sint32 integer()
uint64 integer()
sint64 integer()
float float()
double float()
longdouble float()
pointer binary() | integer()
cstring binary() | integer()
nonnull binary() | integer()
size_t integer()
ssize_t integer()
pid_t integer()
off_t integer()
binary binary()
erliovec iolist()
====================== ===============================
下表报告了用于将 C 函数返回值转换为 Erlang 项的 C 到 Erlang 的转换。
====================== ===============================
C return type Resulting Erlang type
====================== ===============================
uchar integer()
schar integer()
ushort integer()
sshort integer()
uint integer()
sint integer()
ulong integer()
slong integer()
uint8 integer()
sint8 integer()
uint16 integer()
sint16 integer()
uint32 integer()
sint32 integer()
uint64 integer()
sint64 integer()
float float()
double float()
longdouble float()
pointer integer()
cstring integer()
nonnull integer()
size_t integer()
ssize_t integer()
off_t integer()
pid_t integer()
binary binary()
====================== ===============================
版权所有 (C) 2007 CRS4(撒丁岛高级研究、研究和开发中心)- http://www.crs4.it/
作者:Alceste Scalas <alceste (at) crs4 (dot) it>
此 EEP 根据知识共享署名 3.0 许可条款发布。 请参阅 http://creativecommons.org/licenses/by/3.0/