
CPython C API 扩展与嵌入指南编写、构建并深入你的第一个原生扩展模块【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython本文基于 CPython 仓库 Extending and Embedding the Python Interpreter 文档编写系统讲解如何用 C/C 扩展 Python 解释器从零开始编写第一个 C API 扩展模块、配置构建工具meson-python、理解模块导出钩子PyModExport_*与槽表机制并深入源码验证 CPython 动态加载扩展模块的底层流程。读完后你将掌握完整的扩展模块开发流程并能结合仓库源码理解错误处理约定、符号导出机制与嵌入Embedding场景。1. 文档定位扩展与嵌入的完整知识地图CPython 的 C APIApplication Programmers Interface定义了一组函数、宏和变量提供对 Python 运行时系统绝大部分方面的访问能力。文档开篇明确了三大主题用 C 或 C 编写模块扩展 Python 解释器。这些模块能做的事情和 Python 代码一样——定义函数、对象类型和方法——此外还能与原生库交互或通过避免解释器开销获得更好的性能将 Python 解释器嵌入到另一个应用程序中把 Python 当作扩展语言使用如何编译和链接扩展模块使其能在运行时被解释器动态加载前提是操作系统支持该特性。文档假设读者具备 C 与 Python 的基础知识非正式的 Python 入门见 教程语言的形式化定义见 语言参考现有对象类型、函数和模块的完整文档见 库参考而完整的 Python/C API 描述则单独整理在 C API 文档。在 C 源码文件中引入 Python API 的方式很简单——包含头文件Python.h。但文档特别强调了一个重要的可移植性提示C 扩展接口是 CPython 特有的扩展模块不能在其他 Python 实现如 PyPy、Jython上工作。在很多情况下可以避免编写 C 扩展以保留可移植性。例如如果用途只是调用 C 库函数或系统调用应考虑使用ctypes模块或 cffi 库而不是编写自定义 C 代码。这些工具让你在 Python 中编写与 C 代码对接的代码比编译 C 扩展模块更可移植。CPython 本身并不附带构建扩展模块的工具官方推荐使用第三方构建后端见 C API 工具列表。文档还指出教程模块可以作为构建工具的简单测试用例或作为代码生成器的预期输出样例——这也说明了该文档面向的读者既包括扩展作者也包括扩展开发工具的作者。知识地图文档目录结构Doc/extending/目录下的文档按学习路径组织章节文档内容定位first-extension-module.rst入门教程创建第一个 C API 扩展模块extending.rst中级主题C API assorted topics错误与异常等newtypes_tutorial.rst教程定义新的对象类型newtypes.rst新类型相关进阶话题building.rst构建扩展模块的一般指南windows.rstWindows 平台构建指南embedding.rst将 CPython 运行时嵌入更大的应用程序其中“中级主题”部分错误与异常、新类型、构建主要面向那些开发扩展工具本身的人而非推荐普通用户用它来写扩展“嵌入”部分则讨论相反的场景——不是创建运行在 Python 解释器内部的主应用程序的扩展而是把 CPython 运行时嵌入到一个更大的应用里。2. 前置条件与版本约束进入教程之前需要明确环境要求源自 first-extension-module.rstC 编译器与Python 开发头文件。在 Linux 上头文件通常在python3-devDebian/Ubuntu 系或python3-develRHEL 系包中能安装 Python 包的能力。教程使用pippip install也可以替换为任何能构建并安装基于pyproject.toml项目的工具如uv pip install建议在虚拟环境中进行目标系统教程假设 Unix 类系统包括 macOS 与 Linux或 Windows其他系统可能需要调整部分细节例如系统命令名版本注意本仓库当前版本为 3.16.0a0见 patchlevel.h教程使用了CPython 3.15 新增的 API如模块导出钩子PyModExport_*以及C11/C20 语法。如果要创建兼容更早版本 CPython 的扩展应查阅对应版本文档——例如 CPython 3.14 及以下要求扩展模块定义PyInit_*初始化函数。教程选择实现一个名为spam的模块作为 C 标准库函数system的 Python 接口spam是 Monty Python 粉丝的最爱食物这是教程的命名彩蛋#include stdlib.h int system(const char *command);目标调用形式 import spam status spam.system(whoami) User Name status 0文档同时提醒system这样的 C 标准库函数在 Python 中已经有现成暴露生产环境中请使用os.system或subprocess.run而不是自己写的模块。选择whoami作为演示命令是因为它在 Unix 和 Windows 上同名方便跨平台演示。3. 教程实战从零构建spam模块3.1 从头部文件开始创建目录并切换进去然后创建spammodule.c文件文件名随意但传统上扩展模块用*module.c后缀Python 非主语言的项目可能用py_spam.c之类。文件开头包含两个头文件#include Python.h #include stdlib.h // for system()关键规则stdlib.h等标准库头文件必须放在Python.h之后。因为在某些系统上Python 会定义一些影响标准头文件行为的预处理宏。虽然技术上包含stdlib.h并非必需——Python.h本身就会为自身使用或向后兼容而包含它和若干标准头——但显式包含自己需要的头文件是好习惯。3.2 配置构建工具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 0meson.buildproject(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, )构建并安装当前目录.中的项目python -m pip -v install .-v--verbose选项让 pip 显示编译器输出开发期间经常有用。两个实用提示如果系统没有 pip先运行python -m ensurepip最好在虚拟环境中每次修改扩展后都要重新运行安装命令——不像 PythonC 有显式的编译步骤。3.3 第一次导入观察报错以验证加载机制编译安装后启动 Python 尝试导入此时应该失败并抛出 import spam Traceback (most recent call last): ... ImportError: dynamic module does not define module export function (PyModExport_spam or PyInit_spam)这个报错本身就是一条宝贵的诊断信息它证明动态加载机制已经生效CPython 正在 .so 文件中按符号名查找模块导出函数。这条错误消息正是 CPython 源码 Python/importdl.c 中_PyImport_GetModuleExportHooks()函数生成的if (!PyErr_Occurred()) { PyObject *msg; msg PyUnicode_FromFormat( dynamic module does not define module export function (%s_%s or %s_%s), info-hook_prefixes-export_prefix, name_buf, info-hook_prefixes-init_prefix, name_buf); ... PyErr_SetImportError(msg, info-name, info-filename); }从源码结构看CPython 在加载动态模块时优先查找新式导出钩子符号PyModExport_name找不到再回退到旧式初始化函数符号PyInit_name。两种前缀常量定义在 Python/importdl.cstatic const struct hook_prefixes ascii_only_prefixes { PyInit, PyModExport}; static const struct hook_prefixes nonascii_prefixes { PyInitU, PyModExportU};对非 ASCII 模块名符号名按 PEP 489 规则使用 Punycode 编码前缀相应变为PyInitU/PyModExportU。加载流程先尝试 export 前缀找到则返回 2再尝试 init 前缀找到则返回 1都找不到才抛出上述ImportError见 Python/importdl.c。3.4 定义模块导出钩子错误信息告诉我们 CPython 在寻找“模块导出函数”module export function亦称模块导出钩子。定义方式分两步。第一步添加函数原型放在#include行下方PyMODEXPORT_FUNC PyModExport_spam(void);原型并非严格必需但某些现代编译器没有它会发出警告——通常添加原型比禁用警告更好。PyMODEXPORT_FUNC宏声明函数的返回类型并添加使函数在 CPython 加载时可见、可用的特殊链接声明。该宏的定义见 Include/exports.h#ifndef PyMODEXPORT_FUNC #define PyMODEXPORT_FUNC _PyINIT_FUNC_DECLSPEC PySlot* #endif也就是说导出钩子的返回类型是PySlot*槽表数组。而_PyINIT_FUNC_DECLSPEC根据平台展开为extern CC 时加上导出符号修饰在 Windows/Cygwin 下是__declspec(dllexport)见 Include/exports.h在其他平台是__attribute__((visibility(default)))。这些宏还负责区分核心模块与扩展模块的符号可见性——扩展模块的导出钩子必须具有外部链接CPython 才能通过动态符号查找dlsym等定位到它。第二步实现函数。先让它返回NULLPyMODEXPORT_FUNC PyModExport_spam(void) { return NULL; }重新编译并再次导入会得到不同的错误 import spam SystemError: module export hook for module spam failed without setting an exception仅返回NULL并不是导出钩子的正确行为CPython 会抱怨。但这恰恰是好消息——它意味着 CPython 已经找到了你的函数3.5 槽表Slot Table模块身份的声明导出钩子应该返回创建模块所需的信息。最基础的信息是模块名和 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 };逐条说明PySlot_STATIC_DATA宏用于槽值是“指向常量、静态分配数据的指针”的场景这里分别是abi_info、spam和 docstring。Py_mod_name与Py_mod_doc的取值都是 C 字符串——NUL 结尾、UTF-8 编码的字节数组PyABIInfo_VAR(abi_info)宏与Py_mod_abi槽是样板代码boilerplate用于防止为不同 Python 版本编译的扩展加载后导致解释器崩溃PySlot_END是哨兵条目标记数组结束。忘记它会导致未定义行为数组声明为static——即在此.c文件外不可见。这是常见主题CPython 只需要访问导出钩子所有全局变量和其他函数通常都应该是static的以免与其他扩展冲突对比PyMODEXPORT_FUNC展开出的导出可见性修饰——整个.c文件中只有导出钩子需要外部链接。让导出钩子返回该数组PyMODEXPORT_FUNC PyModExport_spam(void) { return spam_slots; }重新编译测试 import spam print(spam) module spam from /home/encukou/dev/cpython/spam.so你已经拥有了一个扩展模块用help(spam)可以看到 docstring。3.6 暴露函数胶水代码与PyMethodDef要把 C 函数system直接暴露给 Python需要写一层胶水代码glue code把参数从 Python 对象转换成 C 值再把 C 返回值转回 Python。最简单的方式之一是METH_O函数——接收两个 Python 对象、返回一个对象。所有 Python 对象无论类型在 C 中都表示为指向PyObject结构的指针。在槽数组上方添加这样的函数static PyObject * spam_system(PyObject *self, PyObject *arg) { Py_RETURN_NONE; }暂时忽略参数用Py_RETURN_NONE宏返回 Python 的None对象它展开为一个正确返回None的return语句。重新编译后可能收到spam_system未使用的警告——这是正常的因为它还没有被加到模块里。方法定义表Method Definitions要把 C 函数暴露给 Python需要提供PyMethodDef结构中的若干信息ml_namePython 函数名ml_docdocstringml_meth被调用的 C 函数ml_flags描述细节的标记集例如 Python 参数如何传递给 C 函数。这里用METH_O——匹配spam_system函数签名的标记。PyMethodDef结构同时用于创建类的方法因此不存在单独的“PyFunctionDef”。由于模块通常要创建多个函数这些定义要收集在一个数组中末尾放一个零填充的哨兵static PyMethodDef spam_methods[] { { .ml_namesystem, .ml_methspam_system, .ml_flagsMETH_O, .ml_docExecute a shell command., }, {NULL, NULL, 0, NULL} /* Sentinel */ };然后向槽表添加Py_mod_methods槽指向该PyMethodDef数组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)) TypeError: spam.system() takes exactly one argument (3 given)3.7 返回整数PyLong_FromLong接下来处理返回值。要让spam.system返回一个数字——Python 的int对象。C API 提供了从 C 的int值创建 Pythonint对象的函数PyLong_FromLong。函数名可能不太直观PyLong指 Python 的int类——它最初叫longFromLong则指 C 的long即long int类型。替换Py_RETURN_NONEstatic PyObject * spam_system(PyObject *self, PyObject *arg) { int status 3; PyObject *result PyLong_FromLong(status); return result; }重新编译、重启解释器确认函数现在返回 3 import spam spam.system(whoami) 33.8 接受字符串参数PyUnicode_AsUTF8AndSize与错误处理最后处理函数参数。C 函数spam_system接收两个参数第一个PyObject *self会被设为spam模块对象本例无用忽略第二个PyObject *arg是用户从 Python 传入的对象期望是 Python 字符串。这里存在一个微妙的类型不匹配Python 的str对象存储 Unicode 文本而 C 字符串是字节数组。所以需要把数据编码本例使用 UTF-8。UTF-8 未必总是适合系统命令但它是str.encode的默认编码且 C API 对它有专门支持。把 Python 字符串编码为 UTF-8 缓冲区的函数是PyUnicode_AsUTF8AndSizestatic 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指向str类的最初名称unicodeAndSize部分指该函数还能通过输出参数获取缓冲区大小——本例不需要所以第二个参数传NULL。调用成功时command指向结果 C 字符串——零结尾的字节数组。这个缓冲区由arg对象管理无需释放但必须遵守规则只应在spam_system函数内部使用该缓冲区。函数返回后arg及其管理的缓冲区可能已被垃圾回收不得修改它因此使用const。若调用不成功PyUnicode_AsUTF8AndSize返回NULL。调用任何 Python C API 时都必须处理这类错误情况。本例中正确的处理方式是spam_system直接返回NULLstatic PyObject * spam_system(PyObject *self, PyObject *arg) { const char *command PyUnicode_AsUTF8AndSize(arg); if (command NULL) { return NULL; } int status 3; PyObject *result PyLong_FromLong(status); return result; }这个“失败时返回NULL且不重复设置异常”的模式是整个 C API 错误传播约定的缩影详见 4.1 节。测试错误处理——传入非字符串值 import spam spam.system(3) TypeError: bad argument type for built-in operation3.9 最终形态调用system并返回真实结果剩下就是把system库函数用char *缓冲区调用起来并用其结果替换3static PyObject * spam_system(PyObject *self, PyObject *arg) { const char *command PyUnicode_AsUTF8AndSize(arg); 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完整的spammodule.c源码收录在 Doc/includes/capi-extension/spammodule-01.c该文件头部注释明确说明需与教程文档保持同步。一个值得注意的脚注我们忽略了 Python 字符串可以包含 NUL 字节会截断 C 字符串这一事实即spam.system(foo\0bar)会被当作spam.system(foo)。这可能带来安全问题所以真正的os.system会检查这种情况并报错。4. 进阶主题C API 的核心约定教程刻意避开了错误处理与引用计数等“重要概念”它们由 extending.rstUsing the C API: Assorted topics覆盖。理解这些约定是写出健壮扩展的关键。4.1 错误与异常Errors and ExceptionsPython 解释器中一个重要的约定是函数失败时应设置一个异常条件并返回错误值通常是-1或NULL指针。异常信息存储于解释器线程状态的三个成员中异常类型、异常实例、traceback 对象——无异常时它们为NULL否则等价于sys.exc_info()返回的元组的 C 版本。常用的设置异常的 APIPyErr_SetString最常用的一个参数是异常对象和一个 C 字符串。异常对象通常是预定义对象如PyExc_ZeroDivisionErrorC 字符串表示错误原因会被转换成 Python 字符串对象存为异常的“关联值”PyErr_SetFromErrno只接收异常参数通过检查全局变量errno构造关联值PyErr_SetObject最通用的接收两个对象参数——异常与其关联值。传给这些函数的对象不需要Py_INCREF。错误传播规则与教程中command NULL → return NULL的做法呼应调用另一个函数g的函数f在检测到g失败时f应自己返回错误值通常NULL或-1而不应再调用PyErr_*函数——g已经调用了。f的调用者同样应向它的调用者返回错误指示而不调用PyErr_*如此一路向上传播直到解释器主循环那里会中止当前执行的 Python 代码并尝试查找程序员指定的异常处理器唯一需要调用PyErr_Clear清除异常的场景你不想把错误交给解释器而是想完全自己处理比如重试或假装什么都没发生。模块可以在某些情况下用另一个PyErr_*函数给出更详细的错误消息但一般规则是不要这样做否则会丢失错误原因的信息每个失败的malloc调用都必须转换为异常——malloc或realloc的直接调用者必须调用PyErr_NoMemory并自行返回失败指示对象创建函数如PyLong_FromLong已经这样做了所以该注意事项只针对直接调用malloc的代码注意除PyArg_ParseTuple及其伙伴外返回整数状态的函数通常成功返回正值或零、失败返回-1类似 Unix 系统调用返回错误指示时务必清理垃圾——对已创建的对象调用Py_XDECREF或Py_DECREF。异常类型选择完全由你决定但应明智选择所有内置 Python 异常都有对应的预声明 C 对象如PyExc_ZeroDivisionError可直接使用。不要用PyExc_TypeError表示“文件打不开”那应该是PyExc_OSError参数列表有问题时PyArg_ParseTuple通常抛PyExc_TypeError参数值超出范围或必须满足其他条件时用PyExc_ValueError合适。也可以为模块定义独有的新异常最简单的方式是在文件开头声明一个 static 全局对象变量并在模块初始化时用PyErr_NewException初始化它。4.2 模块导出机制的源码级印证教程中的每个报错都能在 CPython 加载器源码中找到对应逻辑这一印证帮助我们把“文档行为”上升为“实现事实”符号查找顺序_PyImport_GetModuleExportHooks先用export_prefixPyModExport查符号成功后返回 2再退回init_prefixPyInit成功后返回 1两者皆无则抛出含两个候选符号名的ImportError。因此旧式PyInit_*模块在新版本上仍可加载而新式钩子返回的PySlot*槽表才是 3.15 的推荐路径非 ASCII 模块名模块短名最后一个.之后的部分先尝试 ASCII 编码失败则按 PEP 489 转 Punycode符号前缀切换为PyInitU/PyModExportU见 Python/importdl.c钩子返回NULL的处理若导出钩子返回NULL且未设置异常加载器判定为“failed without setting an exception”并抛SystemError——这正是教程中观察到的第二个报错符号可见性PyMODEXPORT_FUNC经由 Include/exports.h 展开保证钩子在所有目标平台上都以外部链接可见Windows 用__declspec(dllexport)GCC/Clang 平台用visibility(default)与教程中“其他全局变量和函数都应为static”的告诫形成对照。5. 其他构建工具与直接编译教程正文使用 meson-python但附录提供了替代路径源自 first-extension-module.rst 的 “Appendix: Other build tools”5.1 缺失PyInit函数的临时对策如果你的构建工具输出抱怨缺少PyInit_spam可以临时添加// A workaround void *PyInit_spam(void) { return NULL; }这是旧式初始化函数initialization functionCPython 3.14 及以下的扩展模块要求定义的垫片shim。当前 CPython 不需要它但某些构建工具可能仍然假设所有扩展模块都要定义它。使用这个对策后你会得到SystemError: initialization of spam failed without raising an exception而不是ImportError: dynamic module does not define module export function。5.2 直接调用编译器仅限特定系统自用场景使用第三方构建工具被强烈推荐因为它会处理平台与 Python 安装的诸多细节、生成扩展的命名以及日后的分发。但如果你只为特定系统或自己构建扩展也可以直接运行编译器——方式是系统相关的需自行解决可能出现的问题。以 Linux 为例Python 开发包可能附带python3-config命令可打印所需的编译旗标。使用它时确认它对应于你要用来加载模块的 CPython 解释器然后gcc --shared $(python3-config --cflags --ldflags) spammodule.c -o spam.so这会生成spam.so文件需要把它放到sys.path上的某个目录中。6. 嵌入Embedding与后续学习路径Doc/extending/的最后一个主题方向是反向场景不是把 C 代码塞进 Python 解释器而是把 CPython 运行时嵌入到一个更大的应用程序中让 Python 作为该应用的脚本/扩展语言。embedding.rst 覆盖成功完成此事所需的一些细节。综合学习路径建议如下先完成 first-extension-module.rst 教程跑通spam模块全流程精读 extending.rst 的错误与异常等中级主题——它们决定扩展在生产环境下的健壮性需要自定义对象类型时学习 newtypes_tutorial.rst 与 newtypes.rst跨平台分发前参考 building.rst 的一般构建指南与 windows.rst 的 Windows 专项指南应用形态为宿主程序时转向 embedding.rstAPI 细节随时查 Doc/c-api/ 中的完整 C API 文档。7. 关键 API 速查表API用途失败行为/要点PyMODEXPORT_FUNC声明模块导出钩子返回PySlot*定义见 Include/exports.hPyModExport_name()3.15 新式模块导出钩子返回NULL且不设异常会触发SystemErrorPyInit_name()旧式初始化函数3.14 及以下要求新式钩子存在时优先被查找的是PyModExport_*PySlot/PySlot_STATIC_DATA模块槽表键值对数组必须以PySlot_END结尾Py_mod_abiPyABIInfo_VARABI 版本校验样板代码防止版本不匹配的扩展崩溃解释器Py_mod_name/Py_mod_doc模块名与 docstring取值为 NUL 结尾 UTF-8 C 字符串Py_mod_methods挂载PyMethodDef数组数组以{NULL, NULL, 0, NULL}哨兵结尾PyMethodDef描述一个绑定方法/函数ml_name/ml_doc/ml_meth/ml_flags四要素METH_O函数标记恰好接收一个参数多余参数抛TypeErrorPyLong_FromLongClong→ Pythonint失败返回NULL并设PyErr_NoMemory等异常PyUnicode_AsUTF8AndSizestr→ UTF-8 只读 C 缓冲区失败返回NULL缓冲区生命周期绑定源对象不得修改Py_RETURN_NONE返回None的宏展开为正确的return语句PyErr_SetString/PyErr_SetFromErrno/PyErr_SetObject设置异常参数对象无需Py_INCREFPyErr_Clear清除异常仅在模块要自行完全处理错误时调用PyErr_NoMemorymalloc失败时设置内存错误malloc/realloc直接调用者必须处理8. 小结Doc/extending/文档给出了 CPython 扩展开发的完整坐标系C API 经Python.h暴露运行时能力但这是 CPython 专属接口、不跨实现可移植构建交由第三方后端教程选定 meson-python3.15 起模块通过PyModExport_*导出钩子返回PySlot槽表来声明模块身份与方法源码验证见Python/importdl.c的查找顺序与错误消息生成逻辑错误处理遵循“设置一次、逐层返回错误值”的约定。教程中的spam模块完整源码在 Doc/includes/capi-extension/spammodule-01.c恰好覆盖了“头文件 → 构建配置 → 导出钩子 → 槽表 → 方法表 → 参数编码 → 返回值转换 → 错误传播”的全链路是任何构建工具与代码生成器的天然基准测试用例。掌握这条主线后新类型定义、平台化构建与解释器嵌入便都是在此骨架上的自然延伸。【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考