你的第一个 C API 扩展模块

本教程将引导你创建一个简单的以 C 或 C++ 编写的 Python 扩展模块。

我们将直接使用低层级的 Python C API。 有关创建扩展模块的更便捷方式,请参阅 推荐的第三方工具。

本教程假定读者具备 Python 基本知识:你应当会在 Python 代码中定义函数才可能用 C 来编写它们。 请参阅 Python 教程 获取 Python 本身的介绍。

本教程对于任何能够编写基础 C 库的人来说都易于理解。虽然我们会提及一些 C 初学者通常不需要掌握的若干概念,例如 static 函数或链接声明,但理解这些内容并非成功完成教程的必要条件。

我们将重点让您对 Python 的 C API 有一个直观的感受;它不会教您诸如错误处理和引用计数等重要概念,这些内容将在后续章节中讲解。

我们假设您使用的是类 Unix 系统(包括 macOS 和 Linux)或 Windows。在其他系统中,您可能需要调整一些细节——例如系统命令的名称。

您需要安装合适的 C 编译器和 Python 开发头文件。在 Linux 上,头文件通常包含在诸如 python3-dev 或 python3-devel 之类的软件包中。

您需要能够安装 Python 软件包。 本教程使用 pip (pip install),但您可以替换为任何能够构建并安装基于 pyproject.toml 的项目的工具,例如 uv (uv pip install)。 最好先启用 虚拟环境。

备注

本教程使用的 API 是在 CPython 3.15 中新增的。若要创建与早期版本 CPython 兼容的扩展,请参考本文档的较早版本。

本教程采用了 C11 和 C++20 中新增的 C 语法。如果您的扩展需要与早期标准保持兼容,请参照 Python 3.14 或更低版本文档中的教程进行操作。

我们将采取的措施

让我们创建一个名为 spam [1] 的扩展模块,该模块将包含一个指向 C 标准库函数 system() 的 Python 接口。该函数定义在 stdlib.h 中;它接受一个 C 字符串作为参数,将该参数作为系统命令执行,并以整数值返回结果。system() 的手动页可能会这样概括其功能:

#include <stdlib.h>
int system(const char *command);

请注意,与 C 标准库中的许多函数一样,此函数已在 Python 中提供接口。在生产环境中,请使用 os.system() 或 subprocess.run(),而不是在此处编写的模块。

我们希望该函数能够以如下方式从 Python 中调用:

>>> import spam
>>> status = spam.system("whoami")
User Name
>>> status
0

备注

系统命令 whoami 会打印出您的用户名。在像本教程这样的指南中,该命令非常实用,因为它在 Unix 和 Windows 上的名称相同。

从头文件开始

首先为本教程创建一个目录,并在命令行中切换到该目录。然后,在您的目录中创建一个名为 spammodule.c 的文件。[2]

在本文件中,我们将包含两个头文件:Python.h,用于引入 Python C API 的所有声明;以及 stdlib.h,用于 system() 函数。[3]

将以下行代码添加到 spammodule.c 中:

#include <Python.h>
#include <stdlib.h>     // for system()

务必将 stdlib.h 以及任何其他标准库包含的内容置于 Python.h 之后。在某些系统中,Python 可能会定义一些预处理器宏,这些宏会影响标准头文件。

运行您的构建工具

如果仅启用 include,你的扩展将无法发挥任何作用。不过,现在正是编译它并尝试导入它的合适时机。这将确保你的构建工具能够正常运行,从而让你能够按照后续内容逐步实现并测试增量变更。

CPython 本身并不提供用于构建扩展模块的工具;建议采用第三方项目来实现此功能。在本教程中,我们将使用 meson-python。(如果您想使用另一个项目,请参阅 附录:其他构建工具。)

meson-python 需要使用两个额外文件来定义一个“项目”。

首先,添加包含以下内容的 pyproject.toml:

[build-system]
build-backend = 'mesonpy'
requires = ['meson-python']

[project]
# Placeholder project information
# (change this before distributing the module)
name = 'sampleproject'
version = '0'

然后,创建 meson.build,其中包含以下内容:

project('sampleproject','c')

py = import('python').find_installation (pure: false)

py.extension_module(
 'spam',# 可导入的 Python 模块名称
 'spammodule.c',# C 源文件
 install: true,
)

备注

有关配置详情,请参阅 meson-python documentation。

现在,通过 pip 构建并安装 当前目录中的项目 (.):

python -m pip -v install .

-v (--verbose) 选项会使 pip 显示编译器的输出结果;这在开发过程中通常非常有用。

小技巧

如果您尚未安装 pip,请运行 python -m ensurepip;最好是在 虚拟环境 中运行。 (或者,如果您更倾向于使用其他能够构建并安装基于 pyproject.toml 的项目的工具,请使用该工具。)

请注意,每次修改扩展后,您都需要再次运行此命令。与 Python 不同,C 语言有一个显式的编译步骤。

当您的扩展编译并安装完成后,启动 Python 并尝试导入该扩展。此时应会抛出以下异常:

>>> import spam
Traceback (most recent call last):
   ...
ImportError: dynamic module does not define module export function (PyModExport_spam or PyInit_spam)

模块导出钩子

您尝试导入该模块时收到的提示表明,Python 正在查找一个“模块导出函数”,也称为 module export hook。让我们定义一个这样的函数。

首先,在 #include 行下方添加一个原型:

PyMODEXPORT_FUNC PyModExport_spam(void);

小技巧

原型并非严格必要,但某些现代编译器即使没有原型也会发出警告。通常,添加原型比禁用警告更为妥当。

PyMODEXPORT_FUNC 宏用于声明函数的返回类型,并添加必要的特殊链接声明,以便 CPython 加载该函数时能够使其可见且可用。

原型完成后,再添加该函数本身。目前,使其返回 NULL:

PyMODEXPORT_FUNC
PyModExport_spam(void)
{
   return NULL;
}

重新编译并加载该模块。这次您应该会收到一个不同的错误信息。

>>> import spam
Traceback (most recent call last):
   ...
SystemError: module export hook for module 'spam' failed without setting an exception

仅仅返回 NULL 对于导出钩子而言*并非*正确的行为,CPython 会对此发出警告。这反而是件好事——说明 CPython 已经找到了这个函数!现在,让我们让它实现一些有用的功能。

槽位表

导出钩子不应返回 NULL,而应返回创建模块所需的信息。让我们从基础内容开始:名称和 docstring。

相关信息应定义在一个由 PySlot 条目组成的数组中,这些条目本质上就是键值对。请在导出钩子之前定义该数组:

PyABIInfo_VAR(abi_info);

static PySlot spam_slots[] = {
   PySlot_STATIC_DATA(Py_mod_abi, &abi_info),
   PySlot_STATIC_DATA(Py_mod_name, "spam"),
   PySlot_STATIC_DATA(Py_mod_doc, "A wonderful module with an example function"),
   PySlot_END
};

当槽位值(此处为 &abi_info、"spam" 及文档字符串)是指向常量且静态分配数据的指针时,会使用 PySlot_STATIC_DATA 宏。

PyABIInfo_VAR(abi_info); 宏和 Py_mod_abi 槽位是一些通用代码,有助于防止为不同 Python 版本编译的扩展导致解释器崩溃。

对于 Py_mod_name 和 Py_mod_doc,其值均为 C 字符串——即以 NUL 终止、采用 UTF-8 编码的字节数组。

注意:在末尾设置 PySlot_END 哨兵条目。这标志着数组的结束。如果忽略这一点,将会引发未定义行为。

该数组被定义为 static——也就是说,它不会在 .c 文件之外可见。这将是一个贯穿始终的主题。CPython 只需访问导出钩子即可;所有全局变量及所有其他函数通常都应设为 static,以避免与其他扩展发生冲突。

请从您的导出钩子中返回此数组,而不是返回 NULL:

PyMODEXPORT_FUNC
PyModExport_spam(void)
{
   return spam_slots;
}

现在请重新编译并尝试运行:

>>> import spam
>>> print(spam)
<module 'spam' from '/home/encukou/dev/cpython/spam.so'>

您有一个扩展模块!请尝试使用 help(spam) 查看 docstring。

下一步将添加一个函数。

暴露函数

若要将 system() C 函数直接暴露给 Python,我们需要编写一层胶水代码,将 Python 对象中的参数转换为 C 值,并将 C 返回值转换回 Python 类型。

编写胶水代码最简单的方法之一,是使用 “METH_O” 函数;该函数接收两个 Python 对象并返回一个对象。所有 Python 对象——无论其 Python 类型为何——在 C 中均以指向 PyObject 结构的指针形式表示。

在 slots 数组上方添加这样一个函数:

static PyObject *
spam_system(PyObject *self, PyObject *arg)
{
   Py_RETURN_NONE;
}

目前,我们忽略这些参数,改为使用 Py_RETURN_NONE 宏;该宏会展开为一条 return 语句,从而正确返回一个 Python None 对象。

请重新编译您的扩展程序,以确保不存在语法错误。我们尚未将 spam_system 添加到模块中,因此您可能会收到一条提示,称 spam_system 未被使用。

方法定义

要将 C 函数暴露给 Python,您需要在一个名为 PyMethodDef [4] 的结构中提供若干信息:

  • ml_name:Python 函数的名称;

  • ml_doc:一个 docstring;

  • ml_meth:待调用的 C 函数;以及

  • ml_flags:一组用于描述相关细节的标志,例如 Python 参数如何传递给 C 函数。这里我们将使用 METH_O——该标志与我们的 spam_system 函数签名相匹配。

由于模块通常会创建多个函数,因此这些定义需要被收集到一个数组中,并在末尾放置一个以零填充的哨兵值。请将此数组添加到 spam_system 函数下方:

static PyMethodDef spam_methods[] = {
    {
        .ml_name="system",
        .ml_meth=spam_system,
        .ml_flags=METH_O,
        .ml_doc="Execute a shell command.",
    },
    {NULL, NULL, 0, NULL}        /* Sentinel */
};

与模块槽位一样,以零填充的哨兵值标记数组的末尾。

接下来,我们将把该方法添加到模块中。在您的 PySlot 数组中添加一个 Py_mod_methods 槽位:

PyABIInfo_VAR(abi_info);

static PySlot spam_slots[] = {
    PySlot_STATIC_DATA(Py_mod_abi, &abi_info),
    PySlot_STATIC_DATA(Py_mod_name, "spam"),
    PySlot_STATIC_DATA(Py_mod_doc, "A wonderful module with an example function"),
    PySlot_STATIC_DATA(Py_mod_methods, spam_methods),
    PySlot_END
};

请再次重新编译您的扩展程序,然后进行测试。务必重启 Python 解释器,以便 import spam 能够加载到该模块的新版本。

现在您应该能够调用该函数了:

>>> import spam
>>> print(spam.system)
<built-in function system>
>>> print(spam.system('whoami'))
None

请注意,我们的 spam.system 目前尚未执行 whoami 命令;它仅返回 None。

请确认该函数仅接受一个参数,且该参数由 METH_O 标志指定:

>>> print(spam.system('too', 'many', 'arguments'))
Traceback (most recent call last):
   ...
TypeError: spam.system() takes exactly one argument (3 given)

返回整数

现在,让我们来看看返回值。与其使用 None,不如让 spam.system 返回一个数字——也就是一个 Python int 对象。最终,这将成为系统命令的退出码;不过,我们先从固定值开始,比如 3。

Python C API 提供了一个函数,用于根据 C 中的 int 值创建 Python int 对象:PyLong_FromLong()。[5]

若要调用该功能,请将 Py_RETURN_NONE 替换为以下 3 行代码:

static PyObject *
spam_system(PyObject *self, PyObject *arg)
{
   int status = 3;
   PyObject *result = PyLong_FromLong(status);
   return result;
}

重新编译,再次重启 Python 解释器,然后检查该函数现在是否返回 3:

>>> import spam
>>> spam.system('whoami')
3

接受字符串

最后,我们来处理函数参数。

我们的 C 函数 spam_system() 接收两个参数。第一个参数 PyObject *self 将被设置为 spam 模块对象。但在我们的用例中,这并无实际用途,因此我们将忽略它。

另一个变量 PyObject *arg 将设置为用户从 Python 传递过来的对象。我们预计该对象应是一个 Python 字符串。为了使用其中的信息,我们需要将其转换为 C 值——在本例中,即一个 C 字符串(const char *)。

这里存在轻微的类型不匹配:Python 的 str 对象存储的是 Unicode 文本,而 C 字符串则是字节数组。因此,我们需要对数据进行 编码,并将使用 UTF-8 编码。(UTF-8 对于系统命令可能并不总是正确,但这是 str.encode() 默认使用的编码,且 C API 对此有特别支持。)

用于将 Python 字符串编码为 UTF-8 缓冲区的函数名为 PyUnicode_AsUTF8AndSize() [6]。调用方式如下:

static PyObject *
spam_system(PyObject *self, PyObject *arg)
{
   const char *command = PyUnicode_AsUTF8AndSize(arg, NULL);
   int status = 3;
   PyObject *result = PyLong_FromLong(status);
   return result;
}

如果 PyUnicode_AsUTF8AndSize() 成功,command 将指向生成的 C 字符串——即一个以零结尾的字节数组 [7]。该缓冲区由 arg 对象管理,因此我们无需手动释放它,但必须遵守某些规则:

  • 我们应当仅使用 spam_system 函数内部的缓冲区。在 spam_system 返回后,arg 及其所管理的缓冲区可能会被垃圾回收。

  • 我们不得对其进行修改。这就是为什么我们使用 const。

如果 PyUnicode_AsUTF8AndSize() 未*成功,则会返回一个 ``NULL`` 指针。在调用 *任何 Python C API 时,我们始终需要处理此类错误情况。通常的处理方法将在本文档的后续章节中介绍。目前可以确定的是,我们已经正确处理了来自 PyLong_FromLong() 的错误。

对于 PyUnicode_AsUTF8AndSize() 调用,正确处理错误的正确做法是从 spam_system 返回 NULL。为此请添加一个 if 块:

static PyObject *
spam_system(PyObject *self, PyObject *arg)
{
   const char *command = PyUnicode_AsUTF8AndSize(arg, NULL);
   if (command == NULL) {
      return NULL;
   }
   int status = 3;
   PyObject *result = PyLong_FromLong(status);
   return result;
}

为验证错误处理是否正常工作,请再次编译、重启 Python,使 import spam 能够加载模块的新版本,然后尝试将非字符串类型的值传递给函数:

>>> import spam
>>> spam.system(3)
Traceback (most recent call last):
   ...
TypeError: bad argument type for built-in operation

现在,只需使用 char * 缓冲区调用 C 库函数 system(),并用其返回值替代 3:

static PyObject *
spam_system(PyObject *self, PyObject *arg)
{
   const char *command = PyUnicode_AsUTF8AndSize(arg, NULL);
   if (command == NULL) {
      return NULL;
   }
   int status = system(command);
   PyObject *result = PyLong_FromLong(status);
   return result;
}

编译您的模块,重新启动 Python,然后进行测试。这一次,您应该能看到自己的用户名——即 whoami 系统命令的输出结果:

>>> import spam
>>> result = spam.system('whoami')
User Name
>>> result
0

您还可以使用其他命令进行测试,例如 ls、dir,或一个不存在的命令:

>>> import spam
>>> result = spam.system('nonexistent-command')
sh: line 1: nonexistent-command: command not found
>>> result
32512

结果

恭喜!您已成功编写一个完整的 Python C API 扩展模块,并完成本教程!

为方便您查阅,以下是完整的源文件:

/// Includes

#include <Python.h>
#include <stdlib.h>     // for system()

/// Implementation of spam.system

static PyObject *
spam_system(PyObject *self, PyObject *arg)
{
   const char *command = PyUnicode_AsUTF8AndSize(arg, NULL);
   if (command == NULL) {
      return NULL;
   }
   int status = system(command);
   PyObject *result = PyLong_FromLong(status);
   return result;
}

/// Module method table

static PyMethodDef spam_methods[] = {
    {
        .ml_name="system",
        .ml_meth=spam_system,
        .ml_flags=METH_O,
        .ml_doc="Execute a shell command.",
    },
    {NULL, NULL, 0, NULL}        /* Sentinel */
};

/// Module slot table

PyABIInfo_VAR(abi_info);

static PySlot spam_slots[] = {
    PySlot_STATIC_DATA(Py_mod_abi, &abi_info),
    PySlot_STATIC_DATA(Py_mod_name, "spam"),
    PySlot_STATIC_DATA(Py_mod_doc, "A wonderful module with an example function"),
    PySlot_STATIC_DATA(Py_mod_methods, spam_methods),
    PySlot_END
};

/// Export hook prototype

PyMODEXPORT_FUNC PyModExport_spam(void);

/// Module export hook

PyMODEXPORT_FUNC
PyModExport_spam(void)
{
   return spam_slots;
}

附录:其他构建工具

您应该能够按照本教程进行操作——除了“运行您的构建工具”章节本身——使用除 meson-python 以外的其他构建工具。

《Python 软件包用户指南》中设有 list of recommended tools;请务必为 C 语言选择其中一个。

PyInit 函数缺失时的临时解决方案

如果您的构建工具输出报告中提到缺少 PyInit_spam,请暂时在您的模块中添加以下函数:

// A workaround
void *PyInit_spam(void) { return NULL; }

这是一个用于旧版 initialization function 的兼容性补丁;在 CPython 3.14 及更早版本的扩展模块中,该补丁是必需的。当前的 CPython 不再需要它,但某些构建工具仍可能假定所有扩展模块都必须定义它。

如果您采用此临时解决方案,将会得到异常 SystemError: initialization of spam failed without raising an exception,而不是 ImportError: dynamic module does not define module export function。

直接编译

强烈建议使用第三方构建工具,因为它会处理平台及 Python 安装相关的各项细节、为生成的扩展命名,以及后续分发您的成果。

如果您是为某个*特定*系统构建扩展,或者仅为自己构建,那么您可能更希望直接运行编译器。具体实现方式取决于目标系统;请做好应对需要自行解决问题的准备。

Linux

在 Linux 上,Python 开发包可能包含一个 python3-config 命令,该命令会打印出所需的编译器标志。如果您使用此命令,请确认其与您将用于加载该模块的 CPython 解释器相匹配。随后,请从以下命令开始:

gcc --shared $(python3-config --cflags --ldflags) spammodule.c -o spam.so

这将生成一个 spam.so 文件,您需要将其放入 sys.path 上的某个目录中。

脚注