你的第一个 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 上的某个目录中。
脚注