CFFI 快速上手#
C Foreign Function Interface for Python。通过基于可以从头文件或文档中复制粘贴的类似 C 语言声明,从 Python 与几乎所有 C 代码进行交互。
CFFI 是基于 LuaJIT FFI 的 C Foreign Function Interface,旨在让用户能够通过 Python 调用几乎所有的 C 代码。它遵循以下几个原则:
目标是在不学习第三门语言的情况下从 Python 调用 C 代码。现有的替代方案需要用户学习领域特定语言(如 Cython、SWIG)或 API(如 ctypes)。CFFI 的设计要求用户只需了解 C 和 Python,减少了需要学习的额外 API 部分。
将所有与 Python 相关的逻辑保留在 Python 中,这样你就不需要编写很多 C 代码(与 CPython 原生 C 扩展不同)。
首选方法是在 API 级别工作:C 编译器会从你编写的声明中调用,以验证并链接到 C 语言构造。或者,也可以在 ABI 级别工作,就像 ctypes 那样。然而,在非 Windows 平台上,C 库通常具有指定的 C API,而不是 ABI(例如,它们可能将“struct”记录为至少具有这些字段,但可能更多)。
尽量做到完整。目前不支持一些 C99 构造,但所有 C89 都应该支持,包括宏(包括宏“滥用”,你可以手动包装成看起来更合理的 C 函数)。
尝试支持 PyPy 和 CPython,并为其他 Python 实现(如 IronPython 和 Jython)提供合理的路径。
需要注意的是,这个项目并不是关于将可执行的 C 代码嵌入到 Python 中,这与 Weave 不同。它是关于从 Python 调用现有的 C 库。
没有 C++ 支持。有时,将 C++ 代码用 C 包装起来然后使用 CFFI 调用这个 C API 是合理的。否则,请查看其他项目。我建议使用 cppyy,它具有一些相似性(并且在 CPython 和 PyPy 上都能高效运行)。
pip install cffi
CFFI 主模式#
使用 CFFI 的主要方式是作为接口来调用一些已经编译好的共享对象,这些共享对象是通过其他方式提供的。想象一下,你有一个系统安装的共享对象,叫做 piapprox.dll
(Windows)或者 libpiapprox.so
(Linux和其他平台)或者 libpiapprox.dylib
(OS X),它导出了一个函数 float pi_approx(int n);
这个函数根据迭代次数计算 \(\pi\) 的近似值。你想从 Python 调用这个函数。注意这种方法同样适用于静态库 piapprox.lib
(Windows)或 libpiapprox.a
。
from cffi import FFI
ffibuilder = FFI()
# cdef() expects a single string declaring the C types, functions and
# globals needed to use the shared object. It must be in valid C syntax.
ffibuilder.cdef("""
float pi_approx(int n);
""")
# set_source() gives the name of the python extension module to
# produce, and some C source code as a string. This C code needs
# to make the declared functions, types and globals available,
# so it is often just the "#include".
ffibuilder.set_source("_pi_cffi",
"""
#include "pi.h" // the C header of the library
""",
libraries=['piapprox']) # library name, for the linker
if __name__ == "__main__":
ffibuilder.compile(verbose=True)
这段代码是使用CFFI库创建一个Python扩展模块,该模块封装了一个名为pi_approx
的C函数。这个函数接受一个整数参数n
,返回一个浮点数,表示π的近似值。
首先,从cffi库中导入FFI类,并创建一个FFI对象。然后,使用cdef()
方法声明C函数和类型。在这个例子中,我们声明了一个名为pi_approx
的函数,它接受一个整数参数并返回一个浮点数。
接下来,使用set_source()
方法设置生成的Python扩展模块的名称(在这里是_pi_cffi
),并提供一些C源代码作为字符串。这里的C源代码通常只包含一个#include
指令,用于包含所需的C头文件。在这个例子中,我们需要包含名为pi.h
的头文件,它包含了pi_approx
函数的声明。
最后,我们还提供了一个库名列表,以便链接器知道需要链接哪个库。在这个例子中,我们只需要链接名为piapprox
的库。
如果这段代码作为主程序运行,它将编译并生成名为_pi_cffi
的Python扩展模块。
执行这个脚本。如果一切正常,它应该生成 _pi_cffi.c
文件,然后调用编译器对其进行编译。生成的 _pi_cffi.c
文件中包含了 set_source()
中给出的字符串,在这个例子中是 #include "pi.h"
。之后,它包含上面 cdef()
中声明的所有函数、类型和全局变量的胶水代码。
在运行时,你可以像这样使用扩展模块:
from _pi_cffi import ffi, lib
print(lib.pi_approx(5000))
其他 CFFI 模式#
CFFI 可以在四种模式中使用:“ABI”与“API”级别,每种都有“内联”(in-line mode)或“外联”(out-of-line mode)准备(或编译)。
ABI 模式在二进制级别访问库,而更快的 API 模式则使用 C 编译器访问它们。
在内联模式下,每次导入 Python 代码时都会设置一切。在外联模式下,你有一个单独的准备步骤(可能包括 C 编译),这会产生一个模块,然后主程序可以导入该模块。
CFFI ABI level, in-line#
from cffi import FFI
ffi = FFI()
ffi.cdef("""
int printf(const char *format, ...); // copy-pasted from the man page
""")
C = ffi.dlopen(None) # loads the entire C namespace
arg = ffi.new("char[]", b"world") # equivalent to C code: char arg[] = "world";
C.printf(b"hi there, %s.\n", arg) # call printf
17
请注意,char *
类型的参数需要一个 bytes
对象。如果你有一个 str
(或者在 Python 2 中的 unicode
),你需要用 somestring.encode(myencoding)
明确地对其进行编码。
Python 3 在 Windows 上:ffi.dlopen(None)
无法正常工作。这个问题复杂且难以修复。如果你尝试从系统中存在的特定 DLL 调用函数,那么问题不会出现:这时你使用 ffi.dlopen("path.dll")
。
这个例子没有调用任何 C 编译器。它工作在所谓的 ABI 模式,这意味着如果你在 cdef()
中稍微误声明了某个函数或结构体的某些字段,程序将会崩溃。
如果使用 C 编译器安装你的模块是一个选项,强烈建议使用 API 模式。(它也更快。)
CFFI 结构体/数组示例(最小化,内联)#
from cffi import FFI
ffi = FFI()
ffi.cdef("""
typedef struct {
unsigned char r, g, b;
} pixel_t;
""")
image = ffi.new("pixel_t[]", 800*600)
f = open('data', 'rb') # binary mode -- important
f.readinto(ffi.buffer(image))
f.close()
image[100].r = 255
image[100].g = 192
image[100].b = 128
f = open('data', 'wb')
f.write(ffi.buffer(image))
f.close()
这可以作为结构体和数组模块的更灵活替代,并取代了 ctypes
。你也可以调用 ffi.new("pixel_t[600][800]")
来获取二维数组。
这个例子没有调用任何 C 编译器。
这个例子也有一个外联等效版本。它与上面的第一个示例“CFFI 主模式”类似,但在 ffibuilder.set_source()
的第二个参数中传递 None
。然后在主程序中你写 from _simple_example import ffi
,然后从 image = ffi.new("pixel_t[]", 800*600)
这行开始,内容与内联示例相同。
CFFI API模式,调用 C 标准库#
# file "example_build.py"
# Note: we instantiate the same 'cffi.FFI' class as in the previous
# example, but call the result 'ffibuilder' now instead of 'ffi';
# this is to avoid confusion with the other 'ffi' object you get below
from cffi import FFI
ffibuilder = FFI()
ffibuilder.set_source("_example",
r""" // passed to the real C compiler,
// contains implementation of things declared in cdef()
#include <sys/types.h>
#include <pwd.h>
// We can also define custom wrappers or other functions
// here (this is an example only):
static struct passwd *get_pw_for_root(void) {
return getpwuid(0);
}
""",
libraries=[]) # or a list of libraries to link with
# (more arguments like setup.py's Extension class:
# include_dirs=[..], extra_objects=[..], and so on)
ffibuilder.cdef("""
// declarations that are shared between Python and C
struct passwd {
char *pw_name;
...; // literally dot-dot-dot
};
struct passwd *getpwuid(int uid); // defined in <pwd.h>
struct passwd *get_pw_for_root(void); // defined in set_source()
""")
if __name__ == "__main__":
ffibuilder.compile(verbose=True)
你需要运行 example_build.py
脚本一次,以将“源代码”生成到 _example.c
文件中,并将其编译为常规 C 扩展模块。(CFFI 根据传递给 set_source()
的第二个参数是否为 None
来选择生成 Python 或 C 模块。)
你需要一个 C 编译器来完成这一步骤。它生成一个文件,例如 _example.so
或 _example.pyd
。如果需要,它可以像其他扩展模块一样以预编译形式分发。
然后,在你的主程序中,你使用:
from _example import ffi, lib
p = lib.getpwuid(0)
assert ffi.string(p.pw_name) == b'root'
p = lib.get_pw_for_root()
assert ffi.string(p.pw_name) == b'root'
请注意,这与 struct passwd
的确切 C 布局无关(它是“API级别”,而不是“ABI级别”)。它需要 C 编译器来运行 example_build.py
,但比尝试准确获取 struct passwd
字段的细节更具可移植性。同样,在 cdef()
中,我们声明 getpwuid()
接受一个 int
参数;在某些平台上,这可能稍有不正确——但这无关紧要。
还要注意,在运行时,API 模式比 ABI 模式更快。
要在 Setuptools
的 setup.py
分发中集成它:
from setuptools import setup
setup(
...
setup_requires=["cffi>=1.0.0"],
cffi_modules=["example_build.py:ffibuilder"],
install_requires=["cffi>=1.0.0"],
)
CFFI API模式,调用C源文件而不是编译后的库#
如果你想调用一个没有预先编译的库,但你有其 C 源文件,那么最简单的解决方案是制作一个扩展模块,该模块从这个库的 C 源文件和额外的 CFFI 包装器一起编译。例如,假设你从 pi.c
和 pi.h
文件开始:
/* filename: pi.c*/
# include <stdlib.h>
# include <math.h>
/* Returns a very crude approximation of Pi
given a int: a number of iteration */
float pi_approx(int n){
double i,x,y,sum=0;
for(i=0;i<n;i++){
x=rand();
y=rand();
if (sqrt(x*x+y*y) < sqrt((double)RAND_MAX*RAND_MAX))
sum++; }
return 4*(float)sum/(float)n; }
/* filename: pi.h*/
float pi_approx(int n);
创建一个名为 pi_extension_build.py
的脚本,用于构建 C 扩展模块:
from cffi import FFI
ffibuilder = FFI()
ffibuilder.cdef("float pi_approx(int n);")
ffibuilder.set_source("_pi", # name of the output C extension
"""
#include "../src/pi.h"
""",
sources=['../src/pi.c'], # includes pi.c as additional sources
libraries=['m']) # on Unix, link with the math library
if __name__ == "__main__":
ffibuilder.compile(tmpdir="./test/.temp", verbose=True)
generating ./test/.temp/_pi.c
setting the current directory to '/home/runner/work/pybook/pybook/doc/libs/cffi/test/.temp'
将工作目录加入环境变量:
import sys
sys.path.append("./test")
在工作目录中,观察生成的输出文件:_pi.c
、_pi.o
和编译后的 C 扩展模块(例如在 Linux 上称为 _pi.so
)。它可以通过 Python 调用:
from _pi.lib import pi_approx
approx = pi_approx(10)
assert str(approx).startswith("3.")
approx = pi_approx(10000)
assert str(approx).startswith("3.1")
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
Cell In[4], line 1
----> 1 from _pi.lib import pi_approx
3 approx = pi_approx(10)
4 assert str(approx).startswith("3.")
ModuleNotFoundError: No module named '_pi'
完全为了性能(API级别,外联)#
CFFI主模式部分 的一个变体,目标不是调用现有的 C 库,而是在构建脚本中直接编写并编译调用一些 C 函数:
# file "example_build.py"
from cffi import FFI
ffibuilder = FFI()
ffibuilder.cdef("int foo(int *, int *, int);")
ffibuilder.set_source("_example",
r"""
static int foo(int *buffer_in, int *buffer_out, int x)
{
/* some algorithm that is seriously faster in C than in Python */
}
""")
if __name__ == "__main__":
ffibuilder.compile(tmpdir="test", verbose=True)
# file "example.py"
from _example import ffi, lib
buffer_in = ffi.new("int[]", 1000)
# initialize buffer_in here...
# easier to do all buffer allocations in Python and pass them to C,
# even for output-only arguments
buffer_out = ffi.new("int[]", 1000)
result = lib.foo(buffer_in, buffer_out, 1000)
你需要一个C编译器来运行 example_build.py
,一次。它生成一个文件,例如 _example.so
或 _example.pyd
。如果需要,它可以像其他扩展模块一样以预编译形式分发。
外联,ABI 级别#
外联 ABI 模式是常规(API)外联模式和内联 ABI 模式的混合。它让你使用 ABI 模式及其优势(不需要 C 编译器)和问题(更容易崩溃)。
这种混合模式可以大幅减少导入时间,因为解析大型 C 头文件速度较慢。它还允许你在构建期间进行更详细的检查,而不用担心性能问题(例如,基于系统上检测到的库版本,多次调用 cdef()
并传入小段声明)。
# file "simple_example_build.py"
from cffi import FFI
ffibuilder = FFI()
# Note that the actual source is None
ffibuilder.set_source("_simple_example", None)
ffibuilder.cdef("""
int printf(const char *format, ...);
""")
if __name__ == "__main__":
ffibuilder.compile(verbose=True)
运行一次生成 _simple_example.py
。你的主程序只需导入这个生成的模块,不再需要 simple_example_build.py
:
from _simple_example import ffi
lib = ffi.dlopen(None) # Unix: open the standard C library
#import ctypes.util # or, try this on Windows:
#lib = ffi.dlopen(ctypes.util.find_library("c"))
lib.printf(b"hi there, number %d\n", ffi.cast("int", 2))
请注意,这个 ffi.dlopen()
与内联模式中的不同,它不会调用任何额外的魔法来定位库:它必须是一个路径名(带有或不带有目录),这是 C dlopen()
或 LoadLibrary()
函数所需的。这意味着 ffi.dlopen("libfoo.so")
是可以的,但 ffi.dlopen("foo")
则不行。在后者的情况下,你可以将其替换为 ffi.dlopen(ctypes.util.find_library("foo"))
。此外,只有在 Unix 上才能识别 None
以打开标准 C 库。
对于分发目的,请记住生成了一个新的 _simple_example.py
文件。您可以将其静态地包含在项目源代码文件中,或者使用 Setuptools,在 setup.py
中可以这样写:
from setuptools import setup
setup(
...
setup_requires=["cffi>=1.0.0"],
cffi_modules=["simple_example_build.py:ffibuilder"],
install_requires=["cffi>=1.0.0"],
)
总之,当你希望声明许多 C 结构体但不需要与共享对象快速交互时,这种模式很有用。例如,它对解析二进制文件很有用。
内联,API级别#
“API级别+内联”模式组合是存在的,但已经被长期弃用。过去它通过 lib = ffi.verify("C header")
来实现。使用 set_source("modname", "C header")
的外联变体更为推荐,并且当项目规模增大时能避免许多问题。
CFFI 嵌入#
新版本1.5中的新增功能。
CFFI 可以用于嵌入:创建一个标准的动态链接库(在 Windows 下为 .dll
,在其他系统下为 .so
),可以从 C 应用程序中使用。
import cffi
ffibuilder = cffi.FFI()
ffibuilder.embedding_api("""
int do_stuff(int, int);
""")
ffibuilder.set_source("my_plugin", "")
ffibuilder.embedding_init_code("""
from my_plugin import ffi
@ffi.def_extern()
def do_stuff(x, y):
print("adding %d and %d" % (x, y))
return x + y
""")
ffibuilder.compile(tmpdir="test/.temp", target="plugin-1.5.*", verbose=True)
generating test/.temp/my_plugin.c
setting the current directory to '/home/runner/work/pybook/pybook/doc/libs/cffi/test/.temp'
'/home/runner/work/pybook/pybook/doc/libs/cffi/test/.temp/plugin-1.5.so'
这个简单的示例创建了名为 plugin-1.5.dll
或 plugin-1.5.so
的 DLL,其中包含导出的函数 do_stuff()
。你只需执行上面的脚本一次,使用你想要在内部使用的解析器;它可以是 CPython 2.x、3.x或 PyPy。然后,这个 DLL 可以像通常一样从应用程序中使用;应用程序不需要知道它是与用 Python 和 CFFI 制作的库进行通信。运行时,当应用程序调用 int do_stuff(int, int)
时,Python 解释器会自动初始化,并调用 def do_stuff(x, y):
。
CFFI 实际发生了什么?#
CFFI 接口的操作级别与 C 相同——你使用与在 C 中定义它们时相同的语法来声明类型和函数。这意味着大多数文档或示例可以直接从手册页中复制。
声明可以包含类型、函数、常量和全局变量。你传递给 cdef()
的内容不能超过这些;特别是,不支持 #ifdef
或 #include
指令。上面示例中的 cdef
只是声明了“在 C 级别有一个具有给定签名的函数”,或者“有一个具有这种形状的结构体类型”。
在 ABI 示例中,dlopen()
调用手动加载库。在二进制级别上,一个程序被拆分成多个命名空间——一个全局的(在某些平台上),加上每个库一个命名空间。因此,dlopen()
返回一个 <FFILibrary>
对象,这个对象作为属性包含了来自该库的所有函数、常量和变量符号,并且这些符号已经在 cdef()
中声明了。如果你有多个相互依赖的库需要加载,你只需调用一次 cdef()
,但需要多次调用 dlopen()
。
相比之下,API 模式的工作方式更接近于 C 程序:C 链接器(静态或动态)负责查找使用的任何符号。你在 set_source()
的 libraries 关键字参数中命名库,但永远不需要指明哪个符号来自哪个库。 set_source()
的其他常见参数包括 library_dirs
和 include_dirs
;所有这些参数都传递给标准的 distutils/setuptools
。
ffi.new()
行分配 C 对象。它们最初是用零填充的,除非使用可选的第二个参数。如果指定了该参数,它将提供一个“初始化器”,就像你可以在 C 代码中使用它来初始化全局变量一样。
实际的 lib.*()
函数调用应该很明显:就像 C 一样。
CFFI ABI 与 API 的对比#
在二进制级别上访问C库(“ABI”)存在很多问题,特别是在非 Windows 平台上。
ABI 级别的最直接缺点是调用函数需要通过非常通用的 libffi
库,这会很慢(而且在非标准平台上不总是经过完美测试)。API 模式则编译 CPython C 包装器,直接调用目标函数。它可以快得多(并且比 libffi
工作得更好)。
更根本的原因是,C 库通常意味着要与 C 编译器一起使用。你不应该像猜测结构中的字段在哪里这样的事情。上面的“真实示例”展示了 CFFI 如何在后台使用 C 编译器:这个例子使用了 set_source(…, "C source…")
并且从不调用 dlopen()
。使用这种方法,我们有一个优势,即我们可以在 cdef()
的各个地方实际上使用“…”,缺失的信息将借助 C 编译器的帮助完成。CFFI 会将其转换为一个单独的 C 源文件,其中包含未修改的“C source”部分,后面跟着一些由 cdef()
导出的“魔法” C 代码和声明。当这个 C 文件被编译时,生成的 C 扩展模块将包含我们需要的所有信息——或者 C 编译器会像往常一样给出警告或错误,例如如果我们误声明了某个函数的签名。
注意,来自 set_source()
的“C source”部分可以包含任意 C 代码。你可以使用它来声明一些用 C 编写的更多辅助函数。要将这些助手导出到 Python,请将它们的签名也放在 cdef()
中。(你可以在“C source”部分中使用 static C
关键字,就像 static int myhelper(int x) { return x * 42; }
,因为这些助手只在同一 C 文件中生成的“魔法”C代码之后被引用。)
这可以用来例如将“疯狂”的宏包装成更标准的C函数。额外的C层对其他原因也有用,比如调用期望某些复杂参数结构的函数,你更喜欢在 C 中构建而不是在 Python 中。(另一方面,如果你只需要调用“函数式”宏,那么你可以直接在 cdef()
中声明它们,就好像它们是函数一样。)
生成的 C 代码应该在其上运行的平台(或 Python 版本)上是相同的,因此在简单的情况下,你可以直接分发预生成的 C 代码,并将其视为常规的C扩展模块(取决于 CPython 上的 _cffi_backend
模块)。上面示例中的特别 Setuptools 行用于更复杂的情况,我们需要重新生成 C 源文件——例如,因为重新生成此文件的 Python 脚本本身将查看系统以了解应该包含什么或不应该包含什么。