CPython 扩展模块定义指南:PyModExport 导出钩子、PyInit 初始化函数与多阶段初始化
本文基于 CPython 官方 C API 文档 extension-modules.rst 编写,完整覆盖 CPython 扩展模块的两种定义方式——3.15 新增的 PyModExport_<name> 导出钩子(export hook)与旧式的 PyInit_<name> 初始化函数,并深入讲解多阶段初始化(multi-phase initialization)的各阶段语义、多个模块实例的隔离要求,以及旧式单阶段初始化的行为差异与已知设计缺陷。读完后,你将能够按当前 CPython 源码的标准写出可被 import 机制正确加载的 C 扩展模块,并理解每种初始化方式在 ABI、GIL、子解释器场景下的适用边界。
1. 什么是 CPython 扩展模块
CPython 扩展模块本质上是一个共享库(Linux 上的 .so、Windows 上的 .pyd DLL),它需要满足两个条件才能被导入:
- 可加载:编译配置与 Python 解释器兼容,能装入 Python 进程;
- 可发现:共享库位于
sys.path上,且文件名是「模块名 +importlib.machinery.EXTENSION_SUFFIXES中列出的某个扩展名」,这样默认导入器importlib.machinery.ExtensionFileLoader才能找到它。
文档明确建议:扩展模块的构建、打包与分发最好交给第三方工具(如 Setuptools)完成,这属于本文档之外的范畴。本文聚焦于「模块本身的定义与初始化」这一核心问题。
2. 新式定义:PyModExport 导出钩子(3.15 起)
Python 3.15 引入了 PyModExport_<name> 导出钩子。计划支持更早版本 Python 的模块仍可使用旧式 PyInit 方式(见第 4 节),两者在本仓库中均受支持。
导出钩子是一个导出的 C 函数,签名如下:
PySlot *PyModExport_modulename(void);
2.1 函数命名规则:ASCII 与非 ASCII 模块名
- 仅含 ASCII 字符的模块名:钩子函数命名为
PyModExport_{<name>},<name>直接替换为模块名。 - 非 ASCII 模块名:钩子函数改为
PyModExportU_{<name>}(注意多了一个U),其中<name>使用 Python 的 punycode 编码,并将连字符替换为下划线。文档给出的换算逻辑为:
def hook_name(name):
try:
suffix = b'_' + name.encode('ascii')
except UnicodeEncodeError:
suffix = b'U_' + name.encode('punycode').replace(b'-', b'_')
return b'PyModExport' + suffix
这一命名逻辑在 CPython 源码中有对应实现:Python/importdl.c 中定义了四组前缀常量——ASCII 前缀 {"PyInit", "PyModExport"} 与非 ASCII 前缀 {"PyInitU", "PyModExportU"},导入器先用 ASCII 编码模块名,失败并捕获 PyUnicodeEncodeError 后回退到 PyUnicode_AsEncodedString(name, "punycode", NULL),再据此在共享库中查找对应符号。导入器优先查找导出钩子:只有当 PyModExport 符号不存在时才会退回 PyInit 符号。
2.2 返回值:PySlot 槽位数组
导出钩子返回一个 PySlot 条目数组,以 slot ID 为 0 的条目(即 PySlot_END)结尾。这些槽位描述模块应当如何被创建和初始化。关键约束:
- 该数组必须保持有效且恒定,直到解释器关闭,通常应使用
static存储; - 任何动态行为都应放到
Py_mod_create与Py_mod_exec槽位中,而不是让槽位数组本身变化。
PySlot 结构体与配套的填充宏定义在 Include/slots.h 中。从源码看,每个槽位是固定 16 字节的布局:sl_id(槽位标识)、sl_flags(标志位)加一个联合体载荷(指针 / 函数指针 / 整数):
struct PySlot {
uint16_t sl_id;
uint16_t sl_flags;
uint32_t sl_reserved; // must be 0
union {
void *sl_ptr;
void (*sl_func)(void);
Py_ssize_t sl_size;
int64_t sl_int64;
uint64_t sl_uint64;
};
};
常用构造宏(同样位于 Include/slots.h):
| 宏 | 用途 |
|---|---|
PySlot_STATIC_DATA(NAME, VALUE) |
静态数据槽(打上 PySlot_STATIC 标志),用于 Py_mod_name、Py_mod_doc、Py_mod_methods、Py_mod_abi 等 |
PySlot_FUNC(NAME, VALUE) |
函数槽,用于 Py_mod_create、Py_mod_exec、Py_mod_init 等需要函数指针的槽位 |
PySlot_DATA(NAME, VALUE) / PySlot_SIZE / PySlot_INT64 / PySlot_UINT64 |
其他载荷类型的构造方式 |
PySlot_END |
数组终止符,展开为 {0} |
PySlot_PTR / PySlot_PTR_STATIC |
面向不支持 designated initializer 的旧版 C++(C++11 及以下)的等价写法 |
钩子可以返回 NULL 并设置异常来表示加载失败。
2.3 用 PyMODEXPORT_FUNC 宏声明钩子
文档推荐用辅助宏 PyMODEXPORT_FUNC 声明导出钩子。从 Include/exports.h 可以看到它的定义:
#ifndef PyMODEXPORT_FUNC
#define PyMODEXPORT_FUNC _PyINIT_FUNC_DECLSPEC PySlot*
#endif
该宏完成三件事:
- 指定返回类型
PySlot*; - 添加平台所需的外部链接声明(Windows/Cygwin 等平台依赖
__declspec机制,见exports.h顶部的HAVE_DECLSPEC_DLL处理); - 对 C++ 将函数声明为
extern "C"。
完整示例——模块 spam 的导出钩子(对应 Doc/c-api/extension-modules.rst 中的示例):
PyABIInfo_VAR(abi_info);
static PySlot spam_slots[] = {
PySlot_STATIC_DATA(Py_mod_abi, &abi_info),
PySlot_STATIC_DATA(Py_mod_name, "spam"),
PySlot_FUNC(Py_mod_init, spam_init_function),
...
PySlot_END
};
PyMODEXPORT_FUNC
PyModExport_spam(void)
{
return spam_slots;
}
文档强调:导出钩子通常应该是模块 C 源码中唯一非 static 的项。
仓库中有一个更完整的可编译示例,即 C API 文档内嵌教程用的 Doc/includes/capi-extension/spammodule-01.c。它实现了一个带 spam.system(command) 函数的模块:
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 */
};
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
};
PyMODEXPORT_FUNC PyModExport_spam(void);
PyMODEXPORT_FUNC
PyModExport_spam(void)
{
return spam_slots;
}
2.4 Py_mod_abi 槽位与 ABI 检查
上述示例中的 PyABIInfo_VAR(abi_info) 是 3.15 引入的 ABI 信息机制,定义在 Include/modsupport.h:
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_FREETHREADING_AGNOSTIC (PyABIInfo_GIL|PyABIInfo_FREETHREADED)
PyABIInfo_VAR(NAME) 宏以 _PyABIInfo_DEFAULT 展开为一个 static PyABIInfo 变量(默认值包含 PY_VERSION_HEX 构建版本与按 Py_LIMITED_API 计算的 ABI 版本);PyABIInfo_Check(info, module_name) 则用于在 ABI 不匹配时抛异常而不是崩溃。
2.5 钩子内的三条注意事项
导出钩子应当保持简短。如果钩子除了 return 一个静态数组之外还做了别的事,则有以下约束:
- 若需要调用任何 Python C API,推荐先调用
PyABIInfo_Check,在常见的 ABI 不匹配场景下抛异常而非崩溃; - 钩子中的代码绝不能依赖 GIL——free-threaded 构建的 Python 只有在钩子返回之后才能检查
Py_mod_gil槽位(或缺失); - 同理,钩子可能在任意子解释器中被调用,因为
Py_mod_multiple_interpreters槽位(或缺失)也是在钩子返回后才被检查。
文档给出的带检查的完整写法:
PyMODEXPORT_FUNC
PyModExport_modulename(void)
{
if (PyABIInfo_Check(&abi_info, "modulename") < 0) {
/* ABI mismatch. It's not safe to examine the raised exception. */
return NULL;
}
/* use Python API (as little as possible); don't rely on GIL */
return modulename_slots;
}
2.6 单个共享库中导出多个模块
通过定义多个导出钩子,一个共享库可以导出多个模块。但由于 Python 导入机制只会查找与文件名对应的那个符号,导入这些模块需要自定义导入器,或者提供适当命名的扩展文件副本/链接。这一行为对应 PEP 489 中「Multiple modules in one library」一节描述的规则。
3. 多阶段初始化(Multi-phase initialization)
创建扩展模块的过程分为若干阶段,这就是「多阶段初始化」名称的由来:
- Python 找到并调用导出钩子,获取创建模块所需的信息;
- 在任何实质代码执行之前,Python 即可判断模块支持哪些能力,并据此调整环境或拒绝加载不兼容的扩展。
Py_mod_abi、Py_mod_gil、Py_mod_multiple_interpreters等槽位影响这一步; - 默认情况下由 Python 自己创建模块对象——等价于创建对象时调用
object.__new__。可用Py_mod_create槽位覆盖此步骤; - Python 设置
__package__、__loader__等初始模块属性,并把模块对象插入sys.modules; - 之后模块以扩展特有的方式完成初始化——等价于
object.__init__或执行 Python 模块的顶层代码,行为由Py_mod_exec槽位指定。
多阶段初始化由 PEP 489 定义,自 Python 3.5 起被支持;它与「初始化函数一次性返回构建完毕的模块」的旧式单阶段初始化相对(见第 5 节)。
4. 旧式定义:PyInit 初始化函数(3.15 起 soft-deprecated)
作为 PyModExport_<name> 的替代,扩展模块也可以定义旧式初始化函数:
PyObject* PyInit_modulename(void);
- 函数名为
PyInit_{<name>};非 ASCII 模块名改用PyInitU_{<name>},编码方式与导出钩子相同(punycode + 下划线)。 - 若同一模块同时导出
PyInit_<name>与PyModExport_<name>,PyInit_<name>会被忽略——这与第 2.1 节提到的 importdl.c 查找优先级一致。
4.1 PyMODINIT_FUNC 声明宏
与 PyMODEXPORT_FUNC 类似,文档推荐用 PyMODINIT_FUNC 宏声明初始化函数。从 Include/exports.h 看,其定义是:
#ifndef PyMODINIT_FUNC
#define PyMODINIT_FUNC _PyINIT_FUNC_DECLSPEC PyObject*
#endif
即:指定 PyObject* 返回类型、添加平台链接声明、对 C++ 声明 extern "C"。
4.2 PyModuleDef 与 PyModuleDef_Init
通常 PyInit_modulename 返回一个 m_slots 非 NULL 的 PyModuleDef 实例,让 Python 走多阶段初始化路径。返回前必须用如下函数初始化该实例:
PyObject* PyModuleDef_Init(PyModuleDef *def);
该函数确保模块定义是一个正确报告类型与引用计数的 Python 对象,成功返回 def 转型为 PyObject*,出错返回 NULL。约束包括:它是从模块初始化函数返回 PyModuleDef 之前的必需调用,不应用于其他上下文;Python 假定 PyModuleDef 结构体是静态分配的;函数返回的引用可能是新引用也可能是借用引用,不得释放。
示例——模块 spam 的旧式定义:
static struct PyModuleDef spam_module = {
.m_base = PyModuleDef_HEAD_INIT,
.m_name = "spam",
...
};
PyMODINIT_FUNC
PyInit_spam(void)
{
return PyModuleDef_Init(&spam_module);
}
需要说明的是,PyInit 方式在 3.15 被标记为 soft-deprecated:不会再获得新特性,但也没有移除计划,因此兼容旧版本的模块仍可继续使用。
5. 旧式单阶段初始化的行为差异与缺陷
单阶段初始化(legacy single-phase initialization)同样被标记为 soft-deprecated:它是有已知缺点和设计缺陷的旧机制,扩展作者被鼓励改用多阶段初始化,但官方没有移除计划。
在单阶段初始化中,PyInit_modulename 应当创建、填充并返回模块对象,通常借助 PyModule_Create 与 PyModule_AddObjectRef 一类函数完成。它与默认的多阶段初始化的关键差异如下:
(1)单阶段模块实际上是「单例」。 首次初始化时,Python 会保存模块 __dict__ 的内容(即通常的函数与类型对象);后续导入时不再调用初始化函数,而是创建带新 __dict__ 的新模块对象,并把保存的内容拷贝进去。用 CPython 自测套件中的内部模块 _testsinglephase(源码见 Modules/_testsinglephase.c)演示:
>>> import sys
>>> import _testsinglephase as one
>>> del sys.modules['_testsinglephase']
>>> import _testsinglephase as two
>>> one is two
False
>>> one.__dict__ is two.__dict__
False
>>> one.sum is two.sum
True
>>> one.error is two.error
True
模块对象 one 与 two 不是同一个,__dict__ 也不是同一个,但模块内的函数 sum 与异常类 error 是同一对象。文档同时声明:该精确行为应视为 CPython 的实现细节。
(2)spec 参数的替代机制。 由于 PyInit_modulename 不接受 spec 参数,导入器把部分导入机制状态保存起来并应用到该调用期间创建的第一个合适模块上——具体来说,导入子模块时会把父包名前缀拼接到模块名上。因此单阶段的 PyInit_modulename 应当在可能创建任何其他模块对象之前尽快创建「属于自己」的模块对象。
(3)不支持非 ASCII 模块名(即 PyInitU_modulename 形式不可用于单阶段初始化)。
(4)单阶段模块支持 PyState_FindModule 之类的模块查找函数(多阶段模块不支持该旧式查找)。
(5)PyModuleDef.m_slots 必须为 NULL——这是区分单阶段与多阶段路径的标志字段。
6. 多个模块实例:隔离要求
与旧式单阶段模块不同,默认情况下扩展模块不是单例:如果从 sys.modules 删除条目后重新导入,会创建一个新模块对象,并通常装入全新的方法与类型对象;旧模块按正常垃圾回收处理——这与纯 Python 模块的行为一致。
在子解释器(sub-interpreter)或 Python 运行时重新初始化(Py_Finalize + Py_Initialize)之后,也可能存在额外的模块实例。在这些场景下,在模块实例之间共享 Python 对象很可能导致崩溃或未定义行为。为此:
- 每个扩展模块实例都应当是隔离的(isolated):对某个实例的修改不应隐式影响其他实例;模块拥有的所有状态(包括对 Python 对象的引用)都应当属于特定模块实例。官方 how-to 文档 Doc/howto/free-threading-extensions.rst 及相关的隔离实践指南可作进一步参考;
- 一种更简单的规避方式是:在重复初始化时直接抛错,彻底放弃多实例;
- 所有模块都应预期支持子解释器,或者显式声明不支持——通常通过上述隔离或阻止重复初始化实现;模块也可以借助
Py_mod_multiple_interpreters槽位把自己限定在主解释器内。
7. 在仓库中验证与延伸阅读
- 导出钩子 + 槽位表的真实用例可参考 Modules/_testmultiphase.c(多阶段初始化测试模块)与 Modules/xxlimited.c(受限于 stable ABI 的 xx 模块);测试数据位于 Lib/test/test_cext/extension.c。
- 符号查找与钩子解析的完整实现见 Python/importdl.c 与 Python/import.c。
- 想动手写第一个扩展模块,官方教程 Doc/extending/first-extension-module.rst 与本文引用的 Doc/includes/capi-extension/spammodule-01.c 保持了内容同步。
- ABI 版本与导出符号的约定见 Doc/c-api/apiabiversion.rst;3.15 的变化说明见 Doc/whatsnew/3.15.rst。
选型小结:面向 3.15+ 的新代码应直接使用 PyModExport_<name> 导出钩子 + 静态 PySlot 数组 + Py_mod_abi/Py_mod_create/Py_mod_exec 槽位;需要兼容旧版本 Python 时使用 PyMODINIT_FUNC PyInit_<name> 返回经 PyModuleDef_Init 处理的 PyModuleDef(m_slots 非空即走多阶段路径);单阶段初始化仅作为遗留机制存在,新扩展不建议采用。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00