CFFI 快速上手#

C Foreign Function Interface for Python。通过基于可以从头文件或文档中复制粘贴的类似 C 语言声明,从 Python 与几乎所有 C 代码进行交互。

CFFI 是基于 LuaJIT FFI 的 C Foreign Function Interface,旨在让用户能够通过 Python 调用几乎所有的 C 代码。它遵循以下几个原则:

  1. 目标是在不学习第三门语言的情况下从 Python 调用 C 代码。现有的替代方案需要用户学习领域特定语言(如 CythonSWIG)或 API(如 ctypes)。CFFI 的设计要求用户只需了解 C 和 Python,减少了需要学习的额外 API 部分。

  2. 将所有与 Python 相关的逻辑保留在 Python 中,这样你就不需要编写很多 C 代码(与 CPython 原生 C 扩展不同)。

  3. 首选方法是在 API 级别工作:C 编译器会从你编写的声明中调用,以验证并链接到 C 语言构造。或者,也可以在 ABI 级别工作,就像 ctypes 那样。然而,在非 Windows 平台上,C 库通常具有指定的 C API,而不是 ABI(例如,它们可能将“struct”记录为至少具有这些字段,但可能更多)。

  4. 尽量做到完整。目前不支持一些 C99 构造,但所有 C89 都应该支持,包括宏(包括宏“滥用”,你可以手动包装成看起来更合理的 C 函数)。

  5. 尝试支持 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 模式更快。

要在 Setuptoolssetup.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.cpi.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.dllplugin-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_dirsinclude_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 脚本本身将查看系统以了解应该包含什么或不应该包含什么。