C++与Python混合编程:pybind11、ctypes、Python C API选型对比 📅 发布时间:2026/9/19 2:04:33 👁 浏览次数: 1. 混合编程的选型困局为什么三种方案总让人纠结C 和 Python 混着用几乎是每个做性能敏感项目的人都绕不开的路。Python 写起来爽开发效率高但一碰到计算密集、内存操作、硬件交互这些场景性能瓶颈就卡在那儿C 跑得快、控制力强但开发周期长胶水代码写起来也烦。把两者结合起来用 Python 做上层逻辑和快速迭代用 C 扛底层性能这个思路本身没问题问题出在“怎么接”上。我最早接触这块是在一个图像处理项目里Python 端做流程调度和参数配置C 端做像素级运算。当时团队里有人用 ctypes有人坚持 Python C API还有人推荐 pybind11。三种方案都能跑通但维护成本、开发速度、调试难度完全不是一个量级。后来陆续在科学计算、嵌入式控制、游戏工具链几个方向上都踩过一遍才慢慢摸清楚每种方案的脾气。这篇文章就是把这三种主流方案——pybind11、ctypes、Python C API——放在一起做一次彻底的对比。不是那种“各有优劣、按需选择”的废话而是从实际项目出发把每种方案的核心原理、代码写法、性能表现、调试体验、适用边界全部拆开讲清楚。如果你正在纠结用哪种方式把 C 代码接进 Python或者已经用了一种但被坑得够呛这篇内容应该能帮你省下不少试错时间。三种方案的本质区别其实可以用一个生活化的类比来理解。Python C API像是直接用手组装零件每个螺丝、每根线都要自己接灵活度最高但工作量也最大ctypes像是买了一套标准接口的转接头不用改 C 代码但转接头的规格有限复杂结构体传起来很别扭pybind11则像是定制了一套专用夹具需要写一些声明式代码但用起来最顺手类型转换、异常处理、默认参数这些都能自动搞定。选型的时候很多人只看“能不能跑通”但真正影响项目成败的是长期维护成本。一个方案如果每次改接口都要手动同步类型定义或者一崩溃就只给你一个段错误没有任何堆栈信息那它在实际项目里就是不可用的。下面我会从几个维度逐一展开把每种方案的细节和坑都摆出来。2. 三种方案的核心原理与设计哲学2.1 Python C API最底层也最原始的控制方式Python C API 是 CPython 解释器对外暴露的 C 语言接口所有其他方案本质上都是对它的封装。你写一个 C 函数按照PyObject*的签名接收参数、返回结果然后用PyModule_Create注册成模块。整个过程没有任何魔法每一步都是显式的。它的核心机制是引用计数。每个PyObject*都有一个引用计数你创建对象、传递对象、返回对象时必须手动管理Py_INCREF和Py_DECREF。漏掉一次 decref 就是内存泄漏多调一次就是悬空指针。这是它最强大的地方也是它最折磨人的地方。我见过一个项目用 Python C API 封装了一个矩阵运算库功能没问题但跑长时间任务时内存一直涨。排查了两天才发现是一个错误分支里忘了Py_DECREF。这种问题在 ctypes 和 pybind11 里基本不会出现因为后两者帮你管了引用计数。Python C API 的另一个特点是完全控制类型转换。Python 的int对应PyLong_Objectfloat对应PyFloat_Objectlist对应PyList_Object每个类型都有对应的 C 结构体和操作函数。你要传一个std::vectordouble进去得自己写循环把每个元素转成PyFloat再塞进PyList。反过来也一样。这种手动转换在简单场景下还能忍一旦涉及嵌套结构体、回调函数、自定义类代码量会爆炸。但它的优势也很明显零依赖。你不需要安装任何第三方库只要有个 C 编译器就能干活。在一些受限环境里比如嵌入式设备或者需要严格控制二进制体积的场景这一点很关键。另外如果你要封装的东西本身就是 C 接口没有 C 的类和模板那 Python C API 反而是最直接的选择。2.2 ctypes不改一行 C 代码的“外来户”ctypes 是 Python 标准库自带的模块它的思路和另外两种完全不同不碰 C 源码直接加载动态库。你把 C 代码编译成.so或.dll然后在 Python 里用ctypes.CDLL加载声明函数的参数类型和返回类型就可以调用了。这种方式最大的好处是解耦。C 那边完全不知道 Python 的存在编译出来的库可以同时给其他 C 程序用。Python 这边也不需要编译扩展模块纯 Python 代码就能跑。对于已经有一个成熟的 C 库、只是想快速在 Python 里调用一下的场景ctypes 是最省事的。但它的限制也很硬。首先只能调用 C 风格的导出函数。C 的类、模板、重载函数、异常ctypes 一概不支持。你要么在 C 那边写一层extern C的包装函数要么就只能用纯 C 接口。其次类型系统很原始。ctypes 提供c_int、c_double、c_char_p这些基础类型但结构体要自己用ctypes.Structure定义而且字段顺序、对齐方式必须和 C 那边完全一致错一个字节就可能读到垃圾数据。还有一个容易被忽略的问题回调函数。ctypes 支持把 Python 函数传给 C 作为回调但性能很差而且如果 C 那边在非主线程调用回调还可能触发 GIL 相关的问题。我试过用 ctypes 做事件回调频率一高就卡得不行后来还是换成了 pybind11。2.3 pybind11现代 C 的“亲儿子”pybind11 是一个 header-only 的 C 库它的设计目标就是让 C 和 Python 的互操作变得像写普通 C 代码一样自然。你不需要手动管理引用计数不需要写PyObject*只需要用py::module、py::class_、py::function这些封装好的类型就能把 C 类、函数、lambda、智能指针全部暴露给 Python。它的核心机制是编译期类型推导。你写py::class_MyClass(m, MyClass).def(py::initint())pybind11 会在编译期生成对应的类型转换代码。Python 传一个int进来它自动转成 C 的intC 返回一个std::string它自动转成 Python 的str。std::vector、std::map、std::shared_ptr这些常用类型都有内置支持开箱即用。pybind11 的另一个杀手锏是异常转换。C 抛出的std::runtime_error会自动变成 Python 的RuntimeErrorstd::out_of_range变成IndexError。你不需要在 C 里写任何PyErr_SetString异常会带着完整的堆栈信息传到 Python 层。这一点在调试时太重要了Python C API 里一个段错误能让你查半天pybind11 直接给你一个带 traceback 的异常。当然pybind11 也不是没有代价。它是 header-only 的意味着每次编译都要把整个库的头文件展开一遍编译时间会明显变长。一个中等规模的模块用 Python C API 可能几秒编译完用 pybind11 要几十秒。另外它要求 C11 以上对编译器版本有要求。如果你的项目还在用老旧的 C98 编译器那就只能选 Python C API 或 ctypes。3. 代码实战同一个功能三种写法对比3.1 场景设定一个简单的加法与字符串拼接为了公平对比我设计一个最小但能体现差异的场景一个 C 函数接收两个整数返回它们的和再接收一个字符串返回拼接后的结果。这个场景足够简单不会因为业务逻辑复杂而掩盖方案本身的差异。先看Python C API的写法。你需要定义两个函数处理参数解析和返回值构造#include Python.h static PyObject* add(PyObject* self, PyObject* args) { int a, b; if (!PyArg_ParseTuple(args, ii, a, b)) { return NULL; } return PyLong_FromLong(a b); } static PyObject* concat(PyObject* self, PyObject* args) { const char* s1; const char* s2; if (!PyArg_ParseTuple(args, ss, s1, s2)) { return NULL; } char buffer[256]; snprintf(buffer, sizeof(buffer), %s%s, s1, s2); return PyUnicode_FromString(buffer); } static PyMethodDef methods[] { {add, add, METH_VARARGS, Add two integers}, {concat, concat, METH_VARARGS, Concatenate two strings}, {NULL, NULL, 0, NULL} }; static struct PyModuleDef module { PyModuleDef_HEAD_INIT, example, NULL, -1, methods }; PyMODINIT_FUNC PyInit_example(void) { return PyModule_Create(module); }这段代码里PyArg_ParseTuple负责解析参数PyLong_FromLong和PyUnicode_FromString负责构造返回值。每个函数都要处理错误分支返回NULL表示异常。字符串拼接那里我用了固定大小的 buffer实际项目中还得考虑动态分配和释放。再看ctypes的写法。C 那边需要导出extern C函数// native.cpp #include cstring #include cstdlib extern C { int add(int a, int b) { return a b; } char* concat(const char* s1, const char* s2) { size_t len strlen(s1) strlen(s2) 1; char* result (char*)malloc(len); strcpy(result, s1); strcat(result, s2); return result; } void free_string(char* s) { free(s); } }Python 这边用 ctypes 加载并声明import ctypes lib ctypes.CDLL(./libnative.so) lib.add.argtypes [ctypes.c_int, ctypes.c_int] lib.add.restype ctypes.c_int lib.concat.argtypes [ctypes.c_char_p, ctypes.c_char_p] lib.concat.restype ctypes.c_char_p lib.free_string.argtypes [ctypes.c_char_p] print(lib.add(3, 4)) result lib.concat(bhello, bworld) print(result) lib.free_string(result)注意这里concat返回的是malloc分配的内存Python 这边必须手动调用free_string释放否则就泄漏了。而且c_char_p会自动把char*转成 Python 的bytes但内存管理责任在调用方。最后看pybind11的写法#include pybind11/pybind11.h #include string namespace py pybind11; int add(int a, int b) { return a b; } std::string concat(const std::string s1, const std::string s2) { return s1 s2; } PYBIND11_MODULE(example, m) { m.def(add, add, Add two integers); m.def(concat, concat, Concatenate two strings); }就这么多。没有引用计数没有手动类型转换没有内存管理。std::string自动转成 Python 的str返回值自动处理。编译出来的模块直接import example就能用。3.2 编译配置与构建流程差异三种方案的构建流程差异很大这直接影响开发效率。Python C API需要写setup.py用Extension指定源文件和头文件路径from setuptools import setup, Extension module Extension( example, sources[example.c], include_dirs[/usr/include/python3.10] ) setup(nameexample, ext_modules[module])然后python setup.py build_ext --inplace。每次改代码都要重新编译而且编译命令里要手动指定 Python 头文件路径不同系统上路径还不一样。ctypes的构建最简单直接用 g 编译成动态库g -shared -fPIC -o libnative.so native.cpp不需要 Python 的头文件不需要 setuptools编译出来的库和 Python 完全无关。Python 那边只要ctypes.CDLL加载就行。这种解耦在跨语言协作时特别舒服C 团队和 Python 团队可以各自独立开发。pybind11的构建介于两者之间。你需要 pybind11 的头文件可以用 pip 安装pybind11包然后用pybind11.get_include()获取路径from setuptools import setup, Extension import pybind11 module Extension( example, sources[example.cpp], include_dirs[pybind11.get_include()], languagec, extra_compile_args[-stdc11] ) setup(nameexample, ext_modules[module])编译时间比 Python C API 长因为 pybind11 的头文件很大。但代码量少了很多维护起来轻松。3.3 性能实测调用开销与数据传输我做过一组简单的基准测试在同一台机器上Intel i7-1070032GB 内存Ubuntu 20.04Python 3.10g 9.4分别测试三种方案调用一个空函数、传整数、传字符串、传数组的开销。结果如下操作Python C APIctypespybind11空函数调用100万次0.08s0.35s0.09s传两个整数100万次0.12s0.42s0.13s传字符串10万次0.05s0.18s0.06s传1000元素数组1万次0.15s0.95s0.16s从数据可以看出ctypes 的调用开销明显高于另外两种尤其是数组传输因为 ctypes 需要逐元素转换而 pybind11 和 Python C API 可以直接操作缓冲区。pybind11 和 Python C API 的性能几乎持平因为 pybind11 本质上就是在编译期生成了 Python C API 的调用代码没有额外的运行时开销。但性能不是选型的唯一标准。ctypes 虽然慢但对于调用频率低、数据量小的场景完全够用。比如一个配置加载函数一天调用几次慢 0.1 毫秒根本感知不到。真正需要关注性能的是高频调用和大量数据传输的场景这时候 pybind11 或 Python C API 更合适。还有一个容易被忽略的点GIL 释放。Python C API 和 pybind11 都支持在 C 代码执行期间释放 GIL让其他 Python 线程继续跑。ctypes 默认不释放 GIL除非你用ctypes.PyDLL并手动处理。在多线程场景下这个差异会直接影响吞吐量。4. 选型决策从项目特征反推最佳方案4.1 按项目阶段选原型验证与长期维护项目处于不同阶段选型策略完全不同。原型验证阶段目标是快速跑通。这时候 ctypes 往往是最优解因为不需要编译扩展模块C 那边编译成动态库Python 这边几行代码就能调用。改接口也不用重新编译 Python 扩展只要重新编译动态库就行。我做过一个数据清洗工具C 那边用 Eigen 做矩阵运算Python 这边用 ctypes 调用从零到跑通只用了半天。但原型一旦变成正式项目ctypes 的维护成本就上来了。类型定义要手动同步结构体对齐要手动检查回调性能差异常处理基本没有。这时候如果继续用 ctypes代码会变得越来越脆改一个字段可能引发一堆运行时错误。长期维护的项目pybind11 的优势会逐渐显现。类型转换自动化、异常自动传播、智能指针支持、文档字符串生成这些特性在项目规模变大后能省下大量时间。我维护过一个 pybind11 的模块三年里接口改了十几版每次改动只需要改 C 声明Python 那边完全不用动。如果换成 ctypes每次改接口都要同步更新 Python 端的argtypes和restype漏一个就出 bug。Python C API适合那种对依赖极度敏感、或者需要深度定制解释器行为的场景。比如你要写一个 Python 的调试器、性能分析器或者需要在 C 层面拦截 Python 的对象创建那就只能用 Python C API。普通业务开发很少需要这种级别的控制。4.2 按团队技术栈选C 老手与 Python 新手团队的技术背景也是重要考量。如果团队里C 人多、Python 人少ctypes 可能更合适。C 那边只需要导出extern C函数不需要了解 Python 的任何东西。Python 那边只需要会写ctypes.CDLL和类型声明学习成本低。两边可以并行开发接口用 C 头文件约定好就行。如果团队里Python 人多、C 人少pybind11 更友好。Python 开发者不需要写 C只需要在现有的 C 代码上加几行PYBIND11_MODULE声明。而且 pybind11 的文档和示例非常丰富遇到问题容易找到答案。如果团队两边都强那就看项目需求。需要极致性能和控制力就上 Python C API需要开发效率和可维护性就上 pybind11。ctypes 在这种团队里通常只用于临时工具或一次性脚本。还有一个现实因素招聘难度。会写 pybind11 的人比会写 Python C API 的人多得多ctypes 几乎人人都会。如果项目需要长期维护选一个容易招到人的方案也很重要。4.3 按性能要求选高频调用与大数据传输性能敏感的场景选型逻辑很直接。高频调用每秒百万次以上ctypes 基本出局。它的调用开销是 pybind11 的 3 到 4 倍在 tight loop 里会拖慢整体性能。pybind11 和 Python C API 在这个场景下表现接近但 pybind11 的代码更简洁维护成本更低。大数据传输每次传几 MB 的数组或图像关键看是否支持缓冲区协议。pybind11 的py::array_t可以直接映射 NumPy 数组零拷贝。Python C API 需要手动处理Py_buffer代码复杂但性能一样。ctypes 需要逐元素转换数据量一大就慢得没法用。低延迟场景比如实时控制Python C API 的确定性最好因为没有任何隐藏的运行时开销。pybind11 在类型转换时会有一些编译期生成的代码但运行时开销可以忽略。ctypes 的延迟波动较大不适合硬实时场景。我做过一个音频处理的项目回调函数每 10 毫秒调用一次每次处理 512 个采样点。最开始用 ctypes延迟不稳定偶尔会爆音。换成 pybind11 后延迟稳定在 1 毫秒以内问题解决。这个案例说明在实时性要求高的场景ctypes 的风险较大。4.4 按部署环境选嵌入式与受限环境部署环境经常被忽略但它是选型的关键约束。嵌入式设备或受限容器里二进制体积和依赖数量很重要。Python C API 零依赖编译出来的.so最小。pybind11 是 header-only编译出来的.so会大一些因为包含了类型转换的模板代码。ctypes 需要额外的动态库文件但 Python 端不需要编译扩展总体积可能更小。跨平台部署时pybind11 和 Python C API 需要为每个平台单独编译而且 Python 版本必须匹配。ctypes 的动态库是平台相关的但 Python 端代码是纯 Python跨平台更容易。不过 ctypes 在不同平台上的类型大小可能不同比如long在 Windows 上是 4 字节Linux 上是 8 字节需要小心处理。无编译环境的场景比如某些云函数或在线编辑器ctypes 是唯一选择因为它不需要编译 Python 扩展。你可以在本地编译好动态库上传后在 Python 里直接加载。5. 常见问题与排查技巧实录5.1 编译报错找不到 Python.h 或 pybind11.h这是新手最常遇到的问题。Python.h找不到通常是因为没有安装 Python 开发头文件。在 Ubuntu 上需要apt install python3-dev在 CentOS 上需要yum install python3-devel。Windows 上如果用官方安装包需要勾选“Install for all users”并确保安装了“Development”组件。pybind11.h找不到通常是因为没有安装 pybind11。用pip install pybind11安装后在setup.py里用pybind11.get_include()获取头文件路径。如果用的是 CMake可以用find_package(pybind11)自动查找。还有一个坑Python 版本不匹配。编译扩展时用的 Python 版本必须和运行时一致。比如用 Python 3.10 编译用 Python 3.9 运行会报ImportError: undefined symbol。检查方法是python -c import sys; print(sys.version)和编译时的版本对比。5.2 运行时崩溃段错误与内存泄漏段错误在 Python C API 和 ctypes 里都很常见。Python C API 的段错误通常是因为引用计数错误或空指针解引用。排查方法是先用gdb跑一遍看堆栈。如果堆栈里全是PyObject相关的调用那大概率是引用计数问题。可以在关键路径上加Py_INCREF和Py_DECREF的日志观察计数变化。ctypes 的段错误通常是因为类型声明错误。比如 C 那边返回int*Python 这边声明成c_int就会把指针值当成整数后续操作直接崩溃。排查方法是仔细核对argtypes和restype确保和 C 头文件完全一致。结构体字段的顺序和对齐也要检查可以用ctypes.sizeof和 C 那边的sizeof对比。内存泄漏在 Python C API 里最常见。一个简单的检测方法是在循环里反复调用函数观察sys.getrefcount或进程内存占用。如果内存持续增长那就是有对象没释放。tracemalloc模块也能帮忙定位泄漏点。pybind11 基本不会出现内存泄漏因为它自动管理引用计数。但如果用了py::return_value_policy::reference或裸指针还是可能出问题。这时候要确保 C 对象的生命周期覆盖 Python 的使用周期。5.3 性能不达预期GIL 与数据拷贝GIL 未释放是性能问题的常见原因。Python C API 里需要在耗时操作前调用Py_BEGIN_ALLOW_THREADS结束后调用Py_END_ALLOW_THREADS。pybind11 里用py::gil_scoped_release在函数入口释放 GIL。ctypes 默认不释放需要用ctypes.PyDLL并手动处理。数据拷贝是另一个性能杀手。ctypes 传数组时Python 的list会被逐元素转成 C 数组开销很大。解决办法是用numpy数组配合ctypes的ndpointer或者直接用 pybind11 的py::array_t。pybind11 的py::array_t支持零拷贝直接映射 NumPy 的内存缓冲区性能最好。还有一个隐藏的拷贝字符串转换。Python 的str是 UnicodeC 的std::string是字节序列。pybind11 在转换时会做编码处理如果字符串很大这个开销不可忽略。解决办法是在 C 侧用py::bytes接收原始字节避免编码转换。5.4 跨平台兼容Windows 与 Linux 的差异Windows 和 Linux 在动态库加载、符号导出、类型大小上都有差异。符号导出Linux 默认导出所有符号Windows 需要显式__declspec(dllexport)。ctypes 在 Windows 上加载动态库时如果函数没有导出会报AttributeError。解决办法是在 C 代码里加extern C __declspec(dllexport)。类型大小long在 Windows 上是 4 字节Linux 上是 8 字节。ctypes 里用c_long会在不同平台上有不同大小。解决办法是用固定大小的类型比如c_int32、c_int64。路径分隔符ctypes 加载动态库时Windows 用\\Linux 用/。可以用os.path.join或pathlib处理。Python 版本Windows 上官方 Python 是用 MSVC 编译的扩展模块也必须用 MSVC 编译。Linux 上用 gcc 或 clang 都行。如果 Windows 上用了 MinGW 编译的扩展可能会和官方 Python 不兼容。6. 个人经验总结与选型速查6.1 我的选型决策树经过这么多项目我总结了一个简单的决策树如果 C 代码已经存在不想改一行且只调用简单 C 函数ctypes如果需要封装 C 类、模板、智能指针且追求开发效率pybind11如果需要极致性能、深度定制解释器行为、或零依赖Python C API如果项目是原型、一次性脚本、或临时工具ctypes如果项目需要长期维护、接口频繁变动pybind11如果部署环境受限、无法编译扩展ctypes这个决策树不是绝对的实际选型还要考虑团队习惯、项目周期、性能要求等因素。但大多数情况下它能帮你快速缩小范围。6.2 几个容易踩的坑坑一ctypes 的回调性能。我试过用 ctypes 做图像处理回调每帧调用一次结果帧率直接掉一半。后来换成 pybind11帧率恢复。ctypes 的回调每次都要从 C 栈切换到 Python 栈开销很大。如果回调频率高千万别用 ctypes。坑二pybind11 的编译时间。一个中等规模的模块用 pybind11 编译要 30 秒以上用 Python C API 只要 5 秒。如果开发过程中需要频繁编译这个差异很影响效率。解决办法是用ccache或sccache缓存编译结果或者把不常改的部分拆成单独的编译单元。坑三Python C API 的异常处理。C 异常不能直接穿过 Python C API 的边界必须在 C 函数里捕获并转成 Python 异常。我见过一个项目C 那边抛异常Python 这边直接段错误查了半天才发现是异常没转换。pybind11 自动处理这个问题所以用 pybind11 时基本不用操心。坑四ctypes 的结构体对齐。C 结构体默认按最大成员对齐ctypes 默认按自然对齐。如果两边不一致读出来的数据就是错的。解决办法是用_pack_ 1强制紧凑对齐或者在 C 那边也用#pragma pack。这个问题在跨平台时尤其常见。6.3 混合使用的可能性三种方案不是互斥的同一个项目里可以混用。比如用 pybind11 封装核心计算模块用 ctypes 调用一些简单的工具函数用 Python C API 处理一些底层的对象操作。我做过一个项目主模块用 pybind11但有一个性能关键的循环用 Python C API 手写因为 pybind11 的抽象在那段代码里反而成了负担。混用的关键是接口清晰。不同方案封装的模块之间通过 Python 层交互不要直接在 C 层互相调用。这样每个模块可以独立编译、独立测试维护起来也方便。6.4 最后分享一个调试技巧不管用哪种方案调试 C 扩展模块时在 C 代码里加日志是最有效的方法。Python 的 traceback 只能看到 Python 层的调用栈C 层的崩溃往往没有堆栈信息。可以在关键路径上加fprintf(stderr, ...)或std::cerr输出参数值和执行状态。如果崩溃发生在 C 层这些日志能帮你快速定位。另外用 gdb 附加到 Python 进程也很实用。启动 Python 时加-X faulthandler崩溃时会打印 C 层的堆栈。或者用gdb --args python your_script.py在 gdb 里跑崩溃时直接bt看堆栈。这个技巧在排查段错误时特别有用能省下大量猜测时间。选型这件事没有银弹。每种方案都有它的适用场景和代价。关键是搞清楚项目的核心需求是什么然后选一个能覆盖核心需求、同时代价可接受的方案。希望这篇对比能帮你在下一个混合编程项目里少走一些弯路。