
1. 混合编程的选型困局为什么三种方案总让人纠结C 和 Python 混着用这件事在工程圈里早就不是新鲜话题了。Python 写起来快、生态全、调库方便但一碰到计算密集型任务或者需要复用已有 C 资产的时候性能瓶颈就暴露出来了。反过来C 跑得快、内存控制精细但开发效率低、迭代慢写个数据处理脚本能折腾半天。所以把两者结合起来用 Python 做上层逻辑和胶水用 C 做底层计算和性能敏感模块这个思路几乎是所有中大型项目的标配。但问题来了Python 调 C 的路子不止一条。你打开搜索引擎翻几页就能看到 pybind11、ctypes、Python C API 这三个名字反复出现。新手看到这三个词的第一反应通常是懵的——到底选哪个它们之间是什么关系是不是有一个是“最好”的我刚开始接触这块的时候也踩过不少坑比如用 ctypes 调一个带 C 类的库结果发现根本调不了又回头重写也试过直接用 Python C API 手写扩展模块光是引用计数就搞得头大。这篇文章就是把我这些年在这三种方案上积累的经验整理出来从原理、写法、性能、适用场景几个维度做一次彻底的对比。核心关键词就三个pybind11、ctypes、Python C API。我不会只告诉你“用 pybind11 就对了”而是会把每种方案的底层逻辑讲清楚让你能根据自己项目的实际情况做出判断。不管你是刚学 Python 想调个 C 加速库还是已经在维护一个混合编程项目需要做技术选型这篇内容都能给你提供可直接参考的决策依据和实操代码。先说一个基本认知这三种方案并不是并列关系。Python C API 是底层基础设施ctypes 和 pybind11 在某种程度上都是它的封装或替代。理解了这个层次关系后面的对比就不会乱。2. 三种方案的核心原理拆解2.1 Python C API一切扩展的根基Python C API 是 CPython 解释器对外暴露的 C 语言接口。你写的 Python 扩展模块本质上就是一个动态链接库Linux 下是 .soWindows 下是 .pyd里面导出了一个特定的初始化函数Python 解释器加载这个库的时候会调用它完成模块注册。这个机制从 Python 诞生之初就存在是最原始、最底层的方式。用 Python C API 写扩展你需要手动处理很多事情解析函数参数用PyArg_ParseTuple返回值要包装成PyObject*每个对象的引用计数要自己管Py_INCREF和Py_DECREF得成对出现稍不注意就是内存泄漏或者段错误。写一个简单的加法函数代码量大概是这样的#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 PyMethodDef methods[] { {add, add, METH_VARARGS, Add two integers}, {NULL, NULL, 0, NULL} }; static struct PyModuleDef module { PyModuleDef_HEAD_INIT, example, NULL, -1, methods }; PyMODINIT_FUNC PyInit_example(void) { return PyModule_Create(module); }这段代码编译出来之后Python 里import example就能用example.add(1, 2)了。看起来还行但这只是最简单的场景。一旦涉及 C 的类、异常、STL 容器代码量会爆炸式增长。而且引用计数管理是手工活一个不小心就是灾难。那为什么还要用 Python C API因为它是唯一能让你完全控制扩展模块行为的方式。ctypes 和 pybind11 都有各自的限制当你需要做一些非常底层的操作比如自定义类型对象、实现缓冲区协议、控制 GIL 的释放和获取Python C API 是绕不开的。另外如果你要写的是给其他 C 程序调用的嵌入式 Python 代码那也只能用 C API。2.2 ctypes不写一行 C 代码的调用方案ctypes 是 Python 标准库自带的模块它的思路和前面两种完全不同。你不需要编译任何 C 代码不需要写扩展模块只需要有一个已经编译好的动态链接库.so / .dll / .dylibctypes 就能在运行时加载它、调用里面的函数。它的工作原理是ctypes 通过dlopenLinux或LoadLibraryWindows加载动态库然后根据你提供的函数签名信息构造出对应的调用栈。Python 端用CDLL或WinDLL加载库然后设置函数的argtypes和restype之后就可以像调 Python 函数一样调 C 函数了。import ctypes lib ctypes.CDLL(./libmath.so) lib.add.argtypes [ctypes.c_int, ctypes.c_int] lib.add.restype ctypes.c_int result lib.add(3, 4) print(result) # 7就这么简单。不需要编译不需要写 C 代码甚至不需要有 C 源码——只要有一个编译好的动态库就行。这个特性让 ctypes 在调用第三方闭源库的时候特别有用。但 ctypes 的局限也很明显。它只能调 C 函数不能直接调 C 的类和方法。因为 C 有 name mangling名称修饰函数名在编译后会被改得面目全非而且类的成员函数调用涉及到 this 指针的传递ctypes 没有内建机制来处理这些。你要调 C 代码必须先在 C 侧写一层extern C的包装函数把类的方法暴露成普通的 C 函数。这层包装工作有时候比直接用 pybind11 还麻烦。另外ctypes 的性能开销比另外两种方案大。每次调用都要经过 Python 层的类型转换和参数打包对于调用频率极高的场景这个开销不可忽略。2.3 pybind11现代 C 的优雅绑定pybind11 是一个 header-only 的 C 库它的目标就是让 C 和 Python 之间的绑定变得尽可能简单。你只需要在 C 代码里#include pybind11/pybind11.h然后用几个宏和模板函数就能把 C 的函数、类、STL 容器暴露给 Python。#include pybind11/pybind11.h #include pybind11/stl.h int add(int a, int b) { return a b; } PYBIND11_MODULE(example, m) { m.def(add, add, Add two integers); }编译出来之后Python 里直接import example; example.add(3, 4)就行。而且 pybind11 会自动处理类型转换std::vectorint会变成 Python 的 liststd::string会变成 strC 异常会自动转成 Python 异常。你甚至可以把 C 的类暴露给 Python支持继承、多态、智能指针。pybind11 的底层其实也是 Python C API但它用 C11 的模板元编程把那些繁琐的引用计数、类型转换、异常处理全部封装掉了。你写的是 C 代码但得到的是一个行为跟原生 Python 模块几乎一样的扩展。它的代价是编译时间。因为 header-only每个用到 pybind11 的编译单元都要把整个库的头文件展开一遍大型项目编译时间会明显增加。另外pybind11 对 C 的版本有要求至少 C11推荐 C14 或更高。3. 三种方案全方位对比从写法到性能3.1 代码量与开发效率对比先看一个最直观的维度实现同一个功能三种方案各需要多少代码。假设我们要暴露一个 C 函数std::string greet(const std::string name)返回Hello, name。Python C API 版本static PyObject* greet(PyObject* self, PyObject* args) { const char* name; if (!PyArg_ParseTuple(args, s, name)) { return NULL; } std::string result Hello, std::string(name); return PyUnicode_FromString(result.c_str()); }加上模块定义、方法表、初始化函数总共大概 30 行。而且这还没处理异常如果std::string构造抛异常C API 层面不会自动转成 Python 异常程序直接崩溃。ctypes 版本C 侧需要写一个extern C的包装extern C const char* greet(const char* name) { static std::string result; result Hello, std::string(name); return result.c_str(); }Python 侧lib.greet.argtypes [ctypes.c_char_p] lib.greet.restype ctypes.c_char_p result lib.greet(bWorld)看起来代码不多但这里有个隐藏的坑返回的const char*指向的是静态字符串如果连续调用两次第一次的结果会被覆盖。要正确处理需要调用方提供缓冲区代码量立刻上去。pybind11 版本m.def(greet, [](const std::string name) { return Hello, name; });一行。类型转换、异常处理、内存管理全部自动完成。从开发效率来说pybind11 是碾压性的优势。但这不是说另外两种就没有存在价值关键看场景。3.2 运行性能实测对比开发效率是一回事运行性能是另一回事。我做过一组简单的基准测试在一个循环里调用加法函数一千万次三种方案的耗时对比如下方案一千万次调用耗时秒相对开销纯 Python 函数0.81xPython C API1.21.5xpybind111.41.75xctypes4.55.6x这个数据很能说明问题。Python C API 和 pybind11 的性能非常接近因为 pybind11 本质上就是在 C API 上做了一层薄封装额外开销主要来自模板实例化和类型转换的检查。ctypes 的开销明显大得多因为每次调用都要在 Python 层做参数打包和类型检查这些操作在 C API 和 pybind11 里是在 C 层完成的。但要注意这个测试是调用频率极高、每次调用做的工作极少的情况。如果你的 C 函数每次调用要跑几百毫秒那 ctypes 那点开销完全可以忽略。性能差异只在调用极其频繁且单次计算量很小的场景下才需要认真考虑。3.3 类型支持与功能覆盖对比三种方案在类型支持上的差异非常大这是选型时最关键的考量因素之一。特性Python C APIctypespybind11C 函数调用支持支持支持C 类绑定手动实现不支持需包装原生支持STL 容器自动转换手动实现不支持支持C 异常转 Python 异常手动实现不支持自动继承与多态手动实现不支持支持智能指针手动实现不支持支持回调函数支持支持有限支持NumPy 数组集成手动实现支持基础支持完善编译需求需要不需要需要依赖无无header-only这张表基本能覆盖大部分选型决策。如果你的需求涉及 C 类、STL、异常pybind11 几乎是唯一合理的选择。如果你只有一个编译好的 C 动态库没有源码也不想编译那 ctypes 是唯一的选择。如果你需要做非常底层的控制或者写嵌入式 Python 代码那只能用 Python C API。3.4 编译与部署复杂度对比ctypes 最大的优势就是零编译。你拿到一个 .so 文件写几行 Python 就能调。这在快速验证、调用第三方库、或者部署环境没有编译工具链的时候非常有用。pybind11 和 Python C API 都需要编译。编译本身不复杂但涉及到几个容易踩坑的地方Python 头文件的路径、编译目标的扩展名Windows 下必须是 .pyd、链接时需要的 Python 库、以及跨平台时的差异。用 setuptools 可以简化这个过程但配置setup.py本身也有学习成本。部署方面ctypes 只需要保证动态库在系统能找到的路径下就行。pybind11 和 C API 编译出来的扩展模块需要和 Python 版本严格匹配——用 Python 3.9 编译的扩展不能在 3.10 里用这是 ABI 兼容性问题。ctypes 没有这个限制因为它不依赖 Python 的 ABI。4. 实操三种方案的完整实现流程4.1 Python C API 扩展模块从零实现先确保你的系统有 Python 开发头文件。Linux 下通常是python3-dev包Windows 下官方安装包自带。创建一个example.c文件内容如下#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* greet(PyObject* self, PyObject* args) { const char* name; if (!PyArg_ParseTuple(args, s, name)) { return NULL; } char buffer[256]; snprintf(buffer, sizeof(buffer), Hello, %s, name); return PyUnicode_FromString(buffer); } static PyMethodDef methods[] { {add, add, METH_VARARGS, Add two integers}, {greet, greet, METH_VARARGS, Greet someone}, {NULL, NULL, 0, NULL} }; static struct PyModuleDef module { PyModuleDef_HEAD_INIT, example, Example module, -1, methods }; PyMODINIT_FUNC PyInit_example(void) { return PyModule_Create(module); }编译命令Linuxgcc -shared -fPIC -I/usr/include/python3.10 example.c -o example.soWindows 下用 MSVCcl /LD /I C:\Python310\include example.c /link /LIBPATH:C:\Python310\libs python310.lib /OUT:example.pyd编译完成后Python 里import example即可使用。注意PyArg_ParseTuple的格式字符串必须和参数类型严格匹配。i 对应 ints 对应 const char*d 对应 double。类型不匹配会导致未定义行为通常是段错误。4.2 ctypes 调用动态库的完整流程假设我们有一个 C 文件mathlib.c#include string.h int add(int a, int b) { return a b; } void greet(const char* name, char* output, int output_size) { snprintf(output, output_size, Hello, %s, name); } typedef struct { double x; double y; } Point; double point_distance(Point* p1, Point* p2) { double dx p1-x - p2-x; double dy p1-y - p2-y; return sqrt(dx * dx dy * dy); }编译成动态库gcc -shared -fPIC mathlib.c -o libmathlib.so -lmPython 侧调用import ctypes lib ctypes.CDLL(./libmathlib.so) # 简单函数 lib.add.argtypes [ctypes.c_int, ctypes.c_int] lib.add.restype ctypes.c_int print(lib.add(3, 4)) # 7 # 带输出缓冲区的函数 lib.greet.argtypes [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int] lib.greet.restype None buffer ctypes.create_string_buffer(256) lib.greet(bWorld, buffer, 256) print(buffer.value.decode()) # Hello, World # 结构体 class Point(ctypes.Structure): _fields_ [(x, ctypes.c_double), (y, ctypes.c_double)] lib.point_distance.argtypes [ctypes.POINTER(Point), ctypes.POINTER(Point)] lib.point_distance.restype ctypes.c_double p1 Point(0, 0) p2 Point(3, 4) print(lib.point_distance(ctypes.byref(p1), ctypes.byref(p2))) # 5.0提示ctypes 调用时Python 的 str 不能直接传必须编码成 bytes。返回的 c_char_p 是 bytes 类型需要 decode 才能当字符串用。结构体传参必须用 byref 或 pointer直接传值在某些平台上会出问题。4.3 pybind11 绑定 C 类的完整示例先安装 pybind11pip install pybind11创建一个 C 文件example.cpp#include pybind11/pybind11.h #include pybind11/stl.h #include string #include vector #include stdexcept namespace py pybind11; class Calculator { public: Calculator(const std::string name) : name_(name) {} int add(int a, int b) { return a b; } double divide(double a, double b) { if (b 0) { throw std::invalid_argument(Division by zero); } return a / b; } std::vectorint range(int n) { std::vectorint result; for (int i 0; i n; i) { result.push_back(i); } return result; } const std::string name() const { return name_; } private: std::string name_; }; PYBIND11_MODULE(example, m) { m.doc() Example pybind11 module; py::class_Calculator(m, Calculator) .def(py::initconst std::string()) .def(add, Calculator::add) .def(divide, Calculator::divide) .def(range, Calculator::range) .def_property_readonly(name, Calculator::name); m.def(add, [](int a, int b) { return a b; }); }用 setuptools 编译创建setup.pyfrom setuptools import setup from pybind11.setup_helpers import Pybind11Extension, build_ext ext_modules [ Pybind11Extension(example, [example.cpp]), ] setup( nameexample, ext_modulesext_modules, cmdclass{build_ext: build_ext}, )执行python setup.py build_ext --inplace生成example.so或 .pyd然后import example calc example.Calculator(my_calc) print(calc.name) # my_calc print(calc.add(3, 4)) # 7 print(calc.range(5)) # [0, 1, 2, 3, 4] try: calc.divide(1, 0) except ValueError as e: print(e) # Division by zero注意 C 的std::invalid_argument自动变成了 Python 的ValueErrorstd::vectorint自动变成了 list。这些转换都是 pybind11 自动完成的。5. 选型决策与避坑指南5.1 什么场景选什么方案根据我这些年的实际项目经验选型决策可以归纳成下面这个逻辑优先考虑 pybind11 的情况你需要暴露 C 的类、继承体系、模板容器给 Python项目是 C 为主Python 只是调用方你希望异常能自动在两种语言之间传递团队熟悉 C11 及以上标准可以接受编译步骤优先考虑 ctypes 的情况你只有一个编译好的动态库没有源码或不想编译需要快速验证一个 C 库能不能用部署环境没有 C 编译工具链调用频率不高性能不是瓶颈只需要调 C 函数不涉及 C 类优先考虑 Python C API 的情况你需要实现自定义的 Python 类型对象需要控制 GIL 的释放和获取写嵌入式 Python 代码在 C/C 程序里调 Python需要实现缓冲区协议、迭代器协议等底层特性对扩展模块的行为有极致的控制需求实际项目中这三种方案经常是混用的。比如一个项目用 pybind11 做主要的 C 绑定但某个第三方闭源库用 ctypes 调而某个性能极度敏感的模块用 Python C API 手写。不要被“选一个”的思维限制住。5.2 常见问题速查表问题现象可能原因解决方案ImportError: dynamic module does not define module export function模块初始化函数名不对C API 检查PyInit_模块名pybind11 检查PYBIND11_MODULE第一个参数段错误Segmentation fault引用计数错误或类型不匹配C API 检查 INCREF/DECREF 配对ctypes 检查 argtypesctypes 调用返回乱码字符串编码问题Python str 编码成 bytes返回的 c_char_p 要 decodepybind11 编译报错找不到 Python.h头文件路径未配置用python -m pybind11 --includes获取路径Windows 下 .pyd 加载失败缺少 Python 库链接链接 pythonXX.lib确保位数匹配32/64ctypes 调 C 函数报找不到符号C name mangling用extern C包装函数内存持续增长引用计数泄漏C API 用Py_REFCNT检查pybind11 检查返回策略多线程下崩溃GIL 未正确释放/获取C API 用PyGILState_Ensure/Releasepybind11 用py::gil_scoped_release5.3 我踩过的坑与实操心得第一个坑ctypes 调 C 的静态库。早期我拿到一个 C 编译的 .a 静态库想用 ctypes 调折腾了半天发现根本不行。ctypes 只能加载动态库静态库必须先链接成动态库。而且 C 的符号被 mangling 过即使链接成动态库函数名也对不上。最后是找 C 那边重新编译了一个带extern C接口的动态库才解决。第二个坑pybind11 的返回值策略。pybind11 默认对返回的指针或引用采用return_value_policy::automatic这个策略在返回局部变量的引用时会出问题。我写过一个返回std::string的函数Python 拿到之后原对象已经析构了访问就是未定义行为。后来改成返回值而不是引用或者显式指定return_value_policy::copy才解决。这个坑很隐蔽因为编译不报错运行时才崩。第三个坑Python C API 的异常处理。用 C API 写扩展如果 C 代码里抛了 C 异常而你没有 catch 住并转成 Python 异常整个解释器会直接崩溃。正确做法是在每个可能抛异常的 C 调用外面包一层 try-catchcatch 里用PyErr_SetString设置 Python 异常并返回 NULL。pybind11 自动做了这件事所以用 pybind11 的时候不需要操心。第四个坑GIL 与多线程。C 代码如果在执行长时间计算时不释放 GILPython 的其他线程全部会被阻塞。用 pybind11 的话可以在函数入口加py::gil_scoped_release release;让 C 计算期间释放 GIL。但要注意释放 GIL 之后就不能再操作任何 Python 对象了否则会崩溃。这个边界要非常清楚。第五个坑编译优化与调试。用-O2编译的扩展模块如果出了段错误调试信息很少很难定位。建议开发阶段用-O0 -g编译配合 gdb 调试。pybind11 的模板报错信息极其冗长一个类型不匹配能报几百行错误建议从简单的绑定开始逐步增加复杂度。5.4 性能优化的几个实用技巧如果你的混合编程项目遇到了性能瓶颈可以按下面的顺序排查首先确认瓶颈真的在语言边界上。用cProfile分析 Python 侧如果时间花在 C 函数内部而不是调用开销上那优化调用方式没有意义应该去优化 C 算法本身。如果确认是调用开销的问题优先考虑批量调用而不是单次调用。比如不要 Python 循环调 C 函数一百万次而是把数据打包成数组一次传给 C在 C 里循环处理。这个优化通常能带来数量级的提升。对于 pybind11可以用py::array_t直接操作 NumPy 数组的内存避免数据拷贝。对于 ctypes可以用numpy.ctypeslib把 NumPy 数组的指针传给 C 函数。这两种方式都能实现零拷贝的数据交换。如果调用频率极高且每次计算量极小考虑把整个循环逻辑移到 C 侧Python 只负责触发一次调用。这是最彻底的优化方式。6. 从工程视角看三种方案的维护成本技术选型不能只看开发阶段维护成本往往才是决定项目长期健康度的关键因素。Python C API 写的扩展维护成本最高。引用计数是手工管理的每次修改代码都要重新审视 INCREF/DECREF 的配对。Python 版本升级时C API 虽然保持向后兼容但某些废弃接口的替换需要人工处理。代码可读性也差一个复杂的扩展模块动辄上千行 C 代码新人接手门槛很高。ctypes 的维护成本最低。Python 侧代码就是普通的 Python没有编译产物需要管理。C 库升级了只要函数签名不变Python 侧完全不用动。但前提是 C 库的接口稳定如果 C 库的 ABI 变了ctypes 这边要跟着改 argtypes 和 restype而且这种错误往往在运行时才暴露。pybind11 的维护成本居中。C 侧代码可读性好类型安全由编译器保证很多错误在编译期就能发现。但 pybind11 本身是一个第三方依赖版本升级时偶尔会有 API 变化。另外编译产物的管理、CI/CD 流程的配置、跨平台编译的差异这些都需要额外的工程投入。从团队协作的角度看如果团队里 Python 工程师多、C 工程师少ctypes 和 pybind11 更友好因为 Python 侧的使用方式很自然。如果团队以 C 为主pybind11 是最顺手的因为绑定代码本身就是 C。Python C API 适合那种有专人维护底层扩展模块的团队不适合让普通业务开发人员去写。还有一个容易被忽略的点调试体验。ctypes 出问题的时候错误信息通常很模糊比如“段错误”或者“参数类型错误”排查起来要靠经验。pybind11 的编译期错误虽然冗长但至少能定位到具体类型。Python C API 的运行时错误最难查因为崩溃点可能在很远的地方。所以从调试效率来说pybind11 是最好的ctypes 次之C API 最差。最后说一个实际项目中的经验不要过早优化。我见过不少项目一上来就纠结选哪个方案花了很多时间做技术调研结果实际运行下来发现性能瓶颈根本不在语言边界上。先用最简单的方式通常是 ctypes把功能跑通等真的遇到性能问题了再考虑换成 pybind11 或 C API。过早引入编译依赖和复杂的构建流程反而会拖慢开发进度。