首页
/ CPython 扩展模块入门:用 C API 从零写出第一个 Python 扩展模块(spam 案例)

CPython 扩展模块入门:用 C API 从零写出第一个 Python 扩展模块(spam 案例)

2026-09-06 15:20:12作者:伍希望

本文基于 CPython 官方文档 Your first C API extension module 展开,带你直接用底层 Python C API 写出一个名为 spam 的扩展模块,它把 C 标准库的 system() 函数封装成一个 Python 函数。读完本篇,你将掌握扩展模块的完整落地流程:如何组织 pyproject.toml / meson.build 并用 pip 构建、如何用 PyMODEXPORT_FUNC 定义模块导出钩子、如何用 PySlot 槽位表声明模块名与文档串、如何用 PyMethodDef 暴露函数,以及如何用 PyLong_FromLongPyUnicode_AsUTF8AndSize 完成 Python 对象与 C 值之间的转换。同时会结合仓库源码解释这些新 C API 宏在解释器导入机制中的真实行为,帮助你建立"既有实操、又有源码级原理"的完整认知。

说明:本文使用的部分 C API(PyModExport_*PyABIInfo_VAR、槽位表模块导出)是在 CPython 3.15 中引入的,并使用了 C11 / C++20 语法。若你需要编写兼容更早 CPython 版本的扩展模块,应参考更早版本的官方文档(旧版采用 PyInit_* 初始化函数与 PyModuleDef)。本篇的前提是使用 Unix-like 系统(含 macOS、Linux)或 Windows,并安装了合适的 C 编译器与 Python 开发头文件(Linux 上常为 python3-devpython3-devel 包)。

我们要做什么

先建立一个直觉:我们要写一个扩展模块叫 spam,它把 C 标准库函数 system() 暴露给 Python。该函数声明在 stdlib.h 中:

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

它接收一个 C 字符串作为命令,把该命令交给系统 shell 执行,并把结果以整型(状态码)返回。我们的目标是让它可以从 Python 这样调用:

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

两点提醒:

  • 和许多 C 标准库函数一样,system 在 Python 中已有对应物。生产环境请使用 os.systemsubprocess.run,本例只是教学用途。
  • whoami 在 Unix 与 Windows 上命令名相同,所以适合做教学示例;它在标准输出打印你的用户名。

从头部文件开始

先为教程建一个目录并进入它,然后在该目录创建名为 spammodule.c 的源文件。教程采用传统的 *module.c 后缀命名(文件名其实可以自定,只是部分工具对 .c 扩展名比较挑剔)。

在这个文件里,我们引入两个头文件:Python.h 拉入整个 Python C API 的声明,stdlib.h 提供 system() 函数:

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

务必把 stdlib.h 以及其它标准库头文件放在 Python.h 之后。 因为在某些系统上,Python 会在预处理阶段定义一些会影响标准头文件行为的宏。这一点在 Python.h 的注释里也有体现:Python.h 为了自身使用或向后兼容,已经包含了 stdlib.h 等若干标准头文件,因此显式再包含 stdlib.h 严格说并非必需,但这是良好实践——"显式包含你真正用到的东西"。

运行构建工具

此时只有 #include,扩展模块还什么也做不了。但这是一个先编译、先试导入的好时机:一旦构建工具被验证可用,后续每一步增量修改都能快速编译与测试。

CPython 本身不附带构建扩展模块的工具,官方推荐使用第三方项目。本教程选用 meson-python(从源码结构看,它对一个使用 PyModExport 的简单扩展而言开销最小;如果你倾向其它工具,见后文"附录:其它构建工具")。

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',          # name of the importable Python module
   'spammodule.c',  # the C source file
   install: true,
)

详细的 meson-python 配置可查阅其官方文档。

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

python -m pip -v install .

-v--verbose)选项让 pip 显示编译器输出,在开发过程中非常有用。

如果你没装 pip,可在(最好是虚拟环境里的)运行 python -m ensurepip。或者使用任何能构建并安装基于 pyproject.toml 项目的工具(如 uv pip install)。

注意:每次改动扩展模块后都要重新运行这条命令。与 Python 不同,C 有显式的编译步骤。

当扩展模块被编译并安装后,启动 Python 并尝试导入它。此时应该得到如下异常:

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

模块导出钩子(module export hook)

导入时的异常已经告诉你,Python 在找"模块导出函数"(module export function),也即模块导出钩子(module export hook)。来定义一个。

先在 #include 行下方添加一个函数原型:

PyMODEXPORT_FUNC PyModExport_spam(void);

原型并非严格必要,但部分现代编译器在没有它时会发出警告。通常添加原型比关掉警告更好。

PyMODEXPORT_FUNC 声明了该函数的返回类型,并加上让 CPython 在加载时能看到并使用它所需的一切特殊链接声明。从 exports.h 可以看到它的真实定义:

#ifndef PyMODEXPORT_FUNC
    #define PyMODEXPORT_FUNC _PyINIT_FUNC_DECLSPEC PySlot*
#endif

也就是说,它展开为一个返回 PySlot*、带导出链接属性的函数声明。这与旧式 PyMODINIT_FUNC(返回 PyObject*)相对:PyMODINIT_FUNC 面向传统初始化函数,而 PyMODEXPORT_FUNC 面向新的"槽位数组"式导出。_PyINIT_FUNC_DECLSPEC 会在 Windows / Cygwin 上展开为 __declspec(dllexport),在 GCC/Clang 下展开为 __attribute__((visibility("default"))),从而保证符号跨平台可被导入器发现(参见 exports.h 中针对不同平台的分支)。

解释器如何找到这个函数?在 importdl.c 中定义了导入器查找的符号前缀:

static const struct hook_prefixes ascii_only_prefixes = {
    "PyInit", "PyModExport"};
static const struct hook_prefixes nonascii_prefixes = {
    "PyInitU", "PyModExportU"};

对 ASCII 命名的模块,导入器会依次尝试 PyInit_spamPyModExport_spam 两个符号;对含非 ASCII 字符的模块名(PEP 489 编码),则使用 PyInitU_ / PyModExportU_ 前缀。这也解释了报错信息里为何同时出现 PyModExport_spam or PyInit_spam

接着在原型之后添加函数本体。先让它返回 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 已经找到了你的函数!

import.c 可以看到解释器对返回值的具体判定逻辑:

PySlot *slots = ex0();
if (!slots) {
    if (!PyErr_Occurred()) {
        PyErr_Format(
            PyExc_SystemError,
            "module export hook for module %R failed without setting an exception",
            info->name);
    }
    return NULL;
}

即:钩子返回 NULL 且未设置任何异常时,解释器抛出 SystemError;若返回 NULL 的同时已设置了异常,则会走另一分支("raised unreported exception")。只有返回一个非空的 PySlot* 数组时,才会进入 PyModule_FromSlotsAndSpec(slots, spec) 真正创建模块。

让我们现在让它做点有用的事。

槽位表(slot table)

除了 NULL,导出钩子应当返回"创建一个模块所需的信息"。先从最基本的开始:名字与文档串。

这些信息应当定义在一个 PySlot 条目数组中——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
};

PySlot_STATIC_DATA 用于槽位值是指向常量、静态分配数据的指针情形(这里分别是 &abi_info"spam" 与文档串)。在 slots.h 中可以看到 PySlot 结构与这些宏的定义:

struct PySlot {
    uint16_t sl_id;
    uint16_t sl_flags;
    _Py_ANONYMOUS union { uint32_t sl_reserved; };  // must be 0
    _Py_ANONYMOUS union {
        void *sl_ptr;
        _Py_funcptr_t sl_func;
        Py_ssize_t sl_size;
        int64_t sl_int64;
        uint64_t sl_uint64;
    };
};

#define PySlot_STATIC_DATA(NAME, VALUE) \
    {.sl_id=(NAME), .sl_flags=PySlot_STATIC, .sl_ptr=(VALUE)}

#define PySlot_END {0}

可见一个槽位由"槽位 ID(sl_id)+ 标志位(sl_flags)+ 一个联合体值"组成;PySlot_STATIC_DATA 会打上 PySlot_STATIC 标志并填充 sl_ptr,而 PySlot_END {0} 就是全零的哨兵。

关于 PyABIInfo_VAR(abi_info);Py_mod_abi 槽位,它们是一小段样板代码,作用是防止为不同 Python 版本编译的扩展在加载时把解释器搞崩。在 modsupport.h 中可以看到其定义(3.15 新增):

typedef struct PyABIInfo {
    uint8_t abiinfo_major_version;
    uint8_t abiinfo_minor_version;
    uint16_t flags;
    uint32_t build_version;
    uint32_t abi_version;
} PyABIInfo;
#define PyABIInfo_STABLE        0x0001
#define PyABIInfo_GIL           0x0002
#define PyABIInfo_FREETHREADED  0x0004
#define PyABIInfo_INTERNAL      0x0008
...
#define PyABIInfo_VAR(NAME) \
    static PyABIInfo NAME = _PyABIInfo_DEFAULT;

PyABIInfo_VAR 会在编译期填充一份"编译本扩展时的版本/ABI 信息"(含构建版本 PY_VERSION_HEX、ABI 版本、以及 GIL / free-threaded / 稳定 ABI 等标志),加载时由 PyABIInfo_Check() 与目标解释器比对,从而在 ABI 不匹配时尽早失败而不是崩溃。对 CPython 核心内部模块,还存在一个便捷宏 _Py_ABI_SLOT(见 cpython/modsupport.h)直接生成该槽位。

对于 Py_mod_namePy_mod_doc,它们的值都是 C 字符串——即 NUL 结尾、UTF-8 编码的字节数组。这些 Py_mod_* 槽位的数值由 slots_generated.h 统一生成,例如 Py_mod_name 100Py_mod_doc 101Py_mod_methods 103Py_mod_abi 109 等;导入器最终会用这些 ID 把槽位解析成对应的模块属性(参见 pycore_slots_generated.h_PySlot_resolve_mod_slot 的 switch 分支)。

注意末尾的 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) 看看文档串。下一步是添加一个函数。

暴露一个函数

要直接把 C 的 system 函数暴露给 Python,需要写一层"胶水代码",把参数从 Python 对象转换成 C 值、把 C 的返回值转换回 Python。

写胶水代码最简单的方式之一是 METH_O 函数——它接收两个 Python 对象并返回一个。所有 Python 对象——无论其 Python 类型——在 C 中都表示为指向 PyObject 结构的指针。

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

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

先忽略参数,改用宏 Py_RETURN_NONE。它展开为一个正确返回 Python None 对象的 return 语句。在 object.h 中可见其定义:

#if Py_GIL_DISABLED
#  define Py_RETURN_NONE return Py_NewRef(Py_None)
#else
#  define Py_RETURN_NONE return Py_None
#endif

在 free-threaded 构建下它返回 Py_None 的一个新引用,普通构建下则直接返回 Py_None。重新编译扩展以确保没有语法错误。由于我们还没把 spam_system 加进模块,你可能会收到一个"spam_system 未使用"的警告,这属正常。

方法定义(PyMethodDef)

要把 C 函数暴露给 Python,需要在名为 PyMethodDef 的结构体里提供若干信息:

  • ml_name:Python 函数名;
  • ml_doc:文档串;
  • ml_meth:被调用的 C 函数;
  • ml_flags:一组标志,描述诸如"Python 参数如何传给 C 函数"等细节。这里用 METH_O——与 spam_system 函数签名相匹配的标志。

METH_Omethodobject.h 中定义为 0x0008,表示"接收恰好一个参数、且该参数是任意单个 Python 对象"(与 METH_NOARGS0x0004METH_VARARGS0x0001METH_KEYWORDS0x0002 相互独立,且 METH_NOARGS/METH_O 不得与 METH_VARARGS/METH_KEYWORDS 组合)。

由于模块通常会创建多个函数,这些定义需要收集进一个数组,末尾带一个零填充的哨兵。在 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 */
};

与模块槽位一样,零填充哨兵标记数组结束。

PyMethodDef 结构体也用于创建类的方法,因此并不存在单独的"PyFunctionDef"。

接下来,把方法加入模块。向你的 PyMethodDef 数组对应的模块中加一个 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

调用它,用下面 3 行替换 Py_RETURN_NONE

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

名字 PyLong_FromLong 可能不太直观:PyLong 指的是 Python 的 int(它最初叫 long),FromLong 指的是 C 的 long(或 long int)类型。

重新编译,再次重启 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

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 指的是 Python str 类的原始名字 unicodeAndSize 部分指该函数还能通过一个输出参数取回缓冲区长度。我们这里用不到,所以第二个参数传 NULL

PyUnicode_AsUTF8AndSize 成功,command 将指向结果 C 字符串——一个以零结尾的字节数组。该缓冲区由 arg 对象管理,意味着我们无需释放它,但要遵守一些规则:

  • 我们只应在 spam_system 函数内部使用该缓冲区。spam_system 返回后,arg 及其管理的缓冲区可能被垃圾回收。
  • 我们不得修改它。这就是为什么用 const

这里忽略了"Python 字符串也可能包含 NUL 字节"这一事实(NUL 会终止一个 C 字符串)。换言之,我们的函数会把 spam.system("foo\0bar") 当成 spam.system("foo")。这可能导致安全问题,因此真实的 os.system 会做长度检查并对这种情况抛错。

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;
}

(注:PyUnicode_AsUTF8AndSize 的第二个参数是缓冲区大小的输出参数,用不到时传 NULL。)

为测试错误处理是否生效,再次编译,重启 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

你也可以用其它命令测试,如 lsdir,或一个不存在的命令:

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

最终完整源码

恭喜你!你已写好了一个完整的 Python C API 扩展模块,走完了整个教程。下面是整个源文件(与仓库中 spammodule-01.c 完全一致):

/* This file needs to be kept in sync with the tutorial
 * at Doc/extending/first-extension-module.rst
 */

/// 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;
}

整个文件自上而下遵循一条清晰脉络:头文件 → 胶水函数(METH_O 风格)→ 方法表 PyMethodDef → 槽位表 PySlot(含 ABI 信息与 Py_mod_methods)→ 导出钩子原型 → 导出钩子本体。所有非导出符号都保持 static,保证多扩展共存时不冲突。

附录:其它构建工具

除了"运行构建工具"那一节本身,你应该能用 meson-python 以外的构建工具走完本教程。Python 打包用户指南提供了推荐工具列表;请务必为 C 语言选一个。

缺少 PyInit 函数的临时解决方案(workaround)

如果你的构建工具输出抱怨缺少 PyInit_spam,先把下面这个函数加进模块:

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

这是对旧式初始化函数的一种"垫片(shim)"——该初始化函数在 CPython 3.14 及更早版本中是扩展模块所必需的。当前 CPython 不再需要它,但某些构建工具可能仍假设所有扩展模块都必须定义它。

若使用这个 workaround,你会得到异常 SystemError: initialization of spam failed without raising an exception,而不是 ImportError: dynamic module does not define module export function

直接编译

强烈建议使用第三方构建工具,因为它会替你处理平台与 Python 安装的诸多细节、给最终扩展命名,以及(将来)帮你分发成果。

如果你是为特定系统、或仅为自己构建扩展,也可以直接运行编译器。具体方式因系统而异,请做好需要自行解决问题的准备。

Linux:Python 开发包可能附带 python3-config 命令,用于打印所需的编译器标志。若使用它,请确认它对应你将用来加载模块的那个 CPython 解释器。然后从下面这条命令开始:

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

这应生成一个 spam.so 文件,你需要把它放到 sys.path 中的某个目录里。

更多平台相关的构建细节可参阅 building 文档Windows 构建文档

小结

本教程用一个最小的 spam 模块串起了 CPython 3.15 新式 C API 扩展的完整闭环:

  • pyproject.toml + meson.build 定义项目,python -m pip -v install . 增量构建;
  • PyMODEXPORT_FUNC PyModExport_spam(void) 定义模块导出钩子,解释器通过 PyInit/PyModExport 前缀(importdl.c)定位它;
  • PySlot 槽位表声明 Py_mod_abi / Py_mod_name / Py_mod_doc,并以 PySlot_END 收尾;PyABIInfo_VAR 负责跨版本 ABI 校验;
  • PyMethodDefMETH_O)暴露函数,经 Py_mod_methods 槽位挂到模块;
  • PyLong_FromLong 造整型返回值、用 PyUnicode_AsUTF8AndSize 把字符串参数安全地转为 UTF-8 C 串并处理其错误路径。

理解了这条链路,你就掌握了把任意 C 库能力封装成 Python 可调用接口的基本功底;后续的错误处理、引用计数、自定义类型等进阶主题,可在 extending 目录 中继续深入。

登录后查看全文
热门项目推荐
相关项目推荐