Python与C/C++混合编程:ctypes、C API与pybind11选型指南
2026/9/19 3:11:39 网站建设 项目流程

早几年我接过一个活儿:把一个跑了好多年的C++算法库接给Python团队用。当时组里有人推荐ctypes,说不用编译省事;有人觉得手写Python C API更底层可控;也有人提了一句pybind11,但大家都拿不准它和另外两条路差别到底在哪。后来三条路我各试了一遍,才真正理解它们不是同一个层面的东西——ctypes是运行时桥接,Python C API是写解释器级原生模块,pybind11则是建立在C API之上的C++模板库。如果你手头也有一个C/C++库要暴露给Python,或者正准备把一个Python热点函数用C++重写,这篇应该能帮你少走不少弯路。三种方案我都会给出能直接跑的代码,并把每一条路背后的原理和坑讲清楚。

1. 三种方案的核心定位与底层逻辑

1.1 为什么Python需要与C/C++混合编程

Python写业务逻辑确实快,但遇到计算密集、内存布局敏感或者必须直接操作硬件数据的场景,纯Python很难满足性能要求。而C/C++在编译期就能完成大量优化,配合指针和底层内存模型,能把热点函数压缩到毫秒甚至微秒级。现实里更常见的情况是:团队手里已经有一套成熟C++库,重写一遍不现实,最合理的路径就是把现有代码桥接给Python调用。混合编程本质上是拿开发成本换性能,关键就是怎么桥接效率最高、代价最小。

我自己接触到的项目大体分为三类:一是已有的C++算法库(如优化求解器、图像处理)要开放给Python做原型验证;二是Python服务里某几个函数是性能瓶颈,需要把热点重写成C++;三是需要绕开Python限制直接操作系统接口或第三方本地库。这三类场景对工具的需求不太一样,但都逃不开同一个问题:用哪种方式把两层语言接起来。

1.2 三条技术路线的本质区别

先说ctypes。ctypes是Python标准库里的动态库加载工具,它不会编译任何东西,只是在运行时把.so或.dll加载进进程,然后按C调用约定去调里面的函数。这一层主要工作是把Python对象翻译成C语言对应的类型(整数、浮点、指针、结构体),再根据函数签名把参数塞进寄存器或栈上。它不具备理解C++类型的能力,因此只适合调用按C ABI导出的接口,C++的类和重载必须由你自己在外面包一层extern "C"函数。

Python C API走的是完全相反的路线。它要求你直接用C语言写一个扩展模块,这个模块会在Python解释器加载时被识别为原生模块。因为代码直接和解释器内部结构打交道,所以你能访问Python对象内部、定义新类型、控制垃圾回收甚至修改字节码。能力最强,负担也最重。

pybind11则是在Python C API上又包了一层C++模板库。你按C++的习惯写绑定代码,编译器通过模板推导帮你自动生成那些繁琐的C API调用。它保留了C++的类、重载、异常和标准库类型,生成出来的产物仍然是原生扩展模块。打个不太严谨的比方:ctypes像用电话远程指挥一台机器,C API像直接焊接电路板,pybind11则是一个帮你自动生成电路图设计的高层工具链。

1.3 选型前先想清楚的五个问题

我没有办法直接告诉你“XXX方案最好”,因为选型依赖具体语境。但可以先问自己五个问题。

第一,现有代码是C还是C++?纯C接口用ctypes最方便,甚至根本不需要编译扩展;如果是C++类库,ctypes很难直接接入,而pybind11几乎是为这个场景设计的。

第二,Python侧期待的是函数调用还是要复用类?只有几个函数暴露出去,三条路都能做;要暴露多个类、继承关系、运算符重载,ctypes基本劝退,C API手写非常痛苦,pybind11舒服很多。

第三,数据量多大、调用频率多高?如果每个函数内计算都很重,那调用层多出来的微秒级开销可以忽略;如果是百万次短调用,调用开销和数据转换开销就需要认真考虑。

第四,团队里谁会长期维护这段桥接代码?如果只有你一个人懂底层,建议选维护成本低的方案,别给自己留坑。

第五,发布环境是否可控?构建扩展模块需要编译工具链,而ctypes只需要动态库在目标机器上能被加载,这两者在分发流程上是完全不同的考量。

2. ctypes:最轻量的C库桥接方案

2.1 ctypes的适用边界与底层原理

ctypes是Python标准库自带的,在绝大多数场景下不需要额外安装。它通过操作系统的动态库加载接口(Linux下的dlopen,Windows下的LoadLibrary)把共享库映射进进程地址空间,再通过ctypes模块定义好的类型映射,将函数调用转换成C ABI调用。因为是运行时处理,所以没有编译步骤,非常适合快速验证、原型开发和需要加载第三方闭源库的场景。

但它的边界非常清晰:你只能调C接口。如果库是C++写的,要么在库端提供extern "C"导出函数,要么在Python侧用其他方案。就算强行用ctypes读取C++类对象,也只能拿到一段不稳定的内存布局,随时会因成员变量顺序、虚表指针、对齐规则变化而崩溃,这显然不可维护。所以在用ctypes之前,先确认自己能控制的接口边界是不是C ABI。

除了函数,ctypes还有一个强大但容易被忽略的能力:回调。你可以用CFUNCTYPE把Python函数传给C库,让C代码反向调用Python。这在处理C库的事件回调、排序比较器、遍历器等场景非常实用,也是很多项目选ctypes的重要原因之一。

2.2 快速跑通第一个ctypes绑定

用一个最简单的例子演示完整过程。先写一个C文件,声明几个要导出的函数和结构体:

// demo.c #include <stdint.h> int add(int a, int b) { return a + b; } double avg(double* arr, int n) { double sum = 0.0; for (int i = 0; i < n; i++) { sum += arr[i]; } return sum / n; } typedef struct { double x; double y; } Point; Point make_point(double x, double y) { Point p = {x, y}; return p; }

然后编译成动态库。Linux和macOS下用gcc:

gcc -shared -fPIC -o libdemo.so demo.c

Windows下如果装了gcc(如MinGW)可以类似处理;如果用的是MSVC,需要用cl编译或直接用CMake。这里我以最常见的Linux环境为例。

接着在Python里加载并调用:

import ctypes lib = ctypes.CDLL("./libdemo.so") # 如果不声明argtypes和restype,参数默认会被当作int处理 lib.add.argtypes = [ctypes.c_int, ctypes.c_int] lib.add.restype = ctypes.c_int print(lib.add(3, 5)) # 8 # 结构体的定义 class Point(ctypes.Structure): _fields_ = [ ("x", ctypes.c_double), ("y", ctypes.c_double), ] lib.make_point.argtypes = [ctypes.c_double, ctypes.c_double] lib.make_point.restype = Point p = lib.make_point(1.5, 2.5) print(p.x, p.y) # 数组与指针 lib.avg.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_int] lib.avg.restype = ctypes.c_double arr = (ctypes.c_double * 4)(1.0, 2.0, 3.0, 4.0) print(lib.avg(arr, 4))

这里最关键的一步是声明argtypes和restype。如果漏掉restype,ctypes默认会把函数返回值当作int处理;在64位系统上返回指针时,高位会被截断,轻则得到错误地址,重则直接段错误。别问我是怎么知道的——这个问题我在刚接触ctypes时踩过不止一次。

2.3 ctypes在真实项目里的三个坑

第一个坑就是restype截断。返回值只要是指针、结构体或较大的整数类型,都必须显式声明restype。同理,参数也要尽量用argtypes写清楚,否则ctypes会把Python int统一转成C int,遇到需要long long或指针的场景就会出错。

第二个坑是内存所有权。C库里malloc出来的内存,Python侧用完需要调用对应的释放函数(通常是free),否则会泄漏。但要注意,如果释放函数也是C库导出的,需要在Python侧绑定到libc或该库自身,不要随便用ctypes.CDLL(None)去猜符号来源。用错了libc版本,轻则崩掉,重则出现内存损坏。

第三个坑是GIL。ctypes.CDLL加载的库函数在调用时,默认会释放Python的全局解释器锁。对耗时较长的C函数来说,这可以避免Python线程被完全卡住,是好事。但如果你的C函数回调Python代码,或者依赖解释器状态进行操作,就要小心锁的重新获取和一致性。如果需要在调用期间始终保持GIL不被释放,可以考虑PyDLL方式,但日常用得很少。

还有一个很容易踩的操作细节:结构体内存对齐。ctypes允许在结构体定义里使用_pack_,默认为0表示按平台规则对齐。如果C结构体设置了#pragma pack,Python侧必须用_pack_对应匹配,否则读出的字段要么错位、要么顺序颠倒。这类问题表现非常隐蔽,调试时会浪费大量时间。

3. Python C API:底层的原生扩展通道

3.1 C扩展的设计思路

Python官方推荐的扩展方式就是C API。写出来的模块是真正的Python原生模块,加载后和C实现的内置模块没有本质区别。一个最简单的扩展模块由三部分组成:方法表、模块定义和初始化函数。方法表告诉解释器这个模块导出了哪些函数,模块定义描述模块名、docstring和生命周期,初始化函数在import时被解释器调用并返回模块对象。

一旦跨过这层基础,C API的能力远超ctypes。你可以直接在C里操作Python对象,可以定义新的Python类型,可以让对象参与垃圾回收,可以访问和修改解释器内部状态。代价是任何一次对象操作都要自己处理引用计数,任何错误都要通过设置异常并返回NULL来传递。写起来相当繁琐,而且每行都是在和解释器底层打交道。

不过对于一个只需暴露几个函数的项目来说,C API并没有那么可怕。只要按固定模板写,代码结构是高度重复的。难的是你想做复杂类型或精细控制的时候,要学的细节会指数级增加。

3.2 手写一个最小C扩展

还是用add这个例子。先写C代码:

#define PY_SSIZE_T_CLEAN #include <Python.h> static PyObject* demo_add(PyObject* self, PyObject* args) { int a, b; if (!PyArg_ParseTuple(args, "ii", &a, &b)) { return NULL; // 解析失败时,PyArg_ParseTuple已设置异常 } return PyLong_FromLong(a + b); } static PyMethodDef DemoMethods[] = { {"add", demo_add, METH_VARARGS, "Add two integers"}, {NULL, NULL, 0, NULL} }; static struct PyModuleDef demomodule = { PyModuleDef_HEAD_INIT, "demo", "A minimal C extension module.", -1, DemoMethods }; PyMODINIT_FUNC PyInit_demo(void) { return PyModule_Create(&demomodule); }

然后写setup.py:

from setuptools import setup, Extension setup( name="demo", ext_modules=[ Extension("demo", sources=["demo.c"]), ], )

在命令行执行:

pip install .

或调试时执行:

python setup.py build_ext --inplace

之后import demo; demo.add(3, 5)就能用了。这段代码里,PyInit_demo的函数名必须与模块名严格对应:模块名demo,初始化函数就必须叫PyInit_demo。如果名字对不上,import时会报“dynamic module does not define init function”之类的错误。

3.3 引用计数、GIL与C++细节

C API最核心也是最容易出错的点是引用计数。规则说起来也很简单:凡是Py_NewRef、Py_INCREF或返回新引用的函数,持有者负责Py_DECREF;从PyArg_ParseTuple取出的参数是借用的引用,生命周期至少持续到函数返回,可以放心使用但不该长期保存。一旦在函数返回后还想继续持有某个Python对象,必须主动增加引用计数。这个错误通常是潜伏型Bug,首次崩溃往往发生在完全不相干的内存操作时。

GIL方面,C API默认持有解释器的GIL执行代码。对于耗时长的纯计算,建议在合适位置用Py_BEGIN_ALLOW_THREADS释放GIL,计算完成后再用Py_END_ALLOW_THREADS重新获取。注意,释放GIL期间绝对不能访问任何Python对象,否则轻则数据竞争,重则解释器崩溃。只用Python C API写过扩展的开发者,很少会主动想到这一点,直到线上出现诡异的卡顿。

如果你是在C++工程里用C API,还有两个额外细节。第一,不要从C++代码里直接让异常穿越C调用边界返回给Python,必须在Python C API函数内部用catch捕获,再通过PyErr_SetString把错误信息翻译成Python异常,否则会直接std::terminate。第二,包含Python.h时,如果在C++环境下编译,一些编译器可能需要extern "C"包裹,不过多数情况下Python.h自身已做了处理,不需要手动干预。

4. pybind11:现代C++混合编程的舒适区

4.1 pybind11的设计哲学

pybind11本质上是C++写的一个头文件库,核心思路是用模板元编程在编译期生成Python C API的绑定代码。你不需要手动写PyMethodDef,不需要管引用计数,不需要把C++类型手工翻译成PyObject*,只需要像平常写C++一样定义好函数和类,然后用py::class_m.def这些接口把结构描述出来,剩下的繁琐工作全由编译器完成。

它跟C API的关系不是并列,而是建立在C API之上的一层封装。所以它保留了C API的底层能力和性能特征,同时把开发体验提升到了接近写C++接口本身的程度。相比ctypes,它没有运行时的类型翻译层,绑定代码在编译期生成,因此可以支持C++类继承、虚函数重载、默认参数、函数重载、运算符重载,还能通过stl.hnumpy.h自动转换STL容器与NumPy数组。这些特性都是ctypes很难安全实现的。

pybind11对编译器有要求,C++11起步,官方建议使用更新标准;这也意味着它不适合“完全没有现代C++经验”的团队。但只要工程本身能过编译,后续维护的舒适度会显著好于另外两条路。

4.2 常用绑定写法速览

看一段相对完整的pybind11绑定代码:

#include <pybind11/pybind11.h> #include <pybind11/stl.h> #include <pybind11/numpy.h> #include <vector> #include <string> namespace py = pybind11; int add(int a, int b) { return a + b; } class Counter { public: Counter(int start) : count_(start) {} void increment(int step) { count_ += step; } int get() const { return count_; } private: int count_; }; std::vector<int> double_all(const std::vector<int>& vals) { std::vector<int> out; out.reserve(vals.size()); for (int v : vals) { out.push_back(v * 2); } return out; } py::array_t<double> scale_array(py::array_t<double> input, double factor) { auto buf = input.request(); double* ptr = static_cast<double*>(buf.ptr); auto result = py::array_t<double>(buf.size); double* out_ptr = static_cast<double*>(result.request().ptr); for (ssize_t i = 0; i < buf.size; ++i) { out_ptr[i] = ptr[i] * factor; } return result; } PYBIND11_MODULE(demo, m) { m.doc() = "pybind11 demo module"; m.def("add", &add, "Add two integers", py::arg("a"), py::arg("b")); m.def("double_all", &double_all, "Double all elements in a vector"); m.def("scale_array", &scale_array, "Scale a numpy array by factor", py::arg("input"), py::arg("factor")); py::class_<Counter>(m, "Counter") .def(py::init<int>(), py::arg("start")) .def("increment", &Counter::increment, py::arg("step")) .def("get", &Counter::get); }

这里有几个点值得单独说。include<pybind11/stl.h>之后,std::vector才能与Python list自动转换;如果没有这个include,模板匹配会失败,编译时会看到一长串错误。py::arg("a")用于给参数命名,这样Python侧可以用关键字参数调用,还能自动生成函数签名。py::class_的链式调用是典型的pybind11风格,每增加一个def就添加一个方法。py::array_t配合request()拿到缓冲区指针,可以直接操作NumPy数组内存,避免中间拷贝。

4.3 把pybind11接进现有C++工程

接入方式主要看你的构建体系。如果Python侧是一个独立的包,推荐用setuptools的Pybind11Extension:

from pybind11.setup_helpers import Pybind11Extension, build_ext from setuptools import setup ext_modules = [ Pybind11Extension( "demo", ["src/demo.cpp"], cxx_std=17, ), ] setup( name="demo", ext_modules=ext_modules, cmdclass={"build_ext": build_ext}, )

如果项目已经用CMake管理,直接在CMakeLists.txt里:

cmake_minimum_required(VERSION 3.15) project(demo LANGUAGES CXX) find_package(pybind11 REQUIRED) pybind11_add_module(demo src/demo.cpp)

pybind11的头文件既可以通过pip安装到Python环境中,也可以直接作为子模块放到源码树。优先用pip安装的好处是版本和Python环境天然对应,find_package时更容易找到正确的路径。需要注意Windows下编译时必须选对架构:Python是64位的,编译器也要用x64模式,否则会出现无法解析外部符号或ABI不匹配的问题。

4.4 pybind11不是银弹,它的隐藏成本

pybind11让开发舒服,但代价也很现实。第一,编译时间明显变长,甚至一个不大的绑定文件可能都要编译几十秒到几分钟,模板实例化大量消耗CPU和内存。第二,生成的扩展模块二进制体积相对较大,因为包含了完整的C++运行时和模板生成的代码。第三,一旦绑定代码里出现模板编译错误,报错信息极其吓人,一屏根本看不完,对新手很不友好。第四,如果你要绑定的C++代码本身就很复杂,比如涉及多继承、复杂模板元编程、自定义智能指针,pybind11虽然能处理,但绑定的复杂度也会随之上升。

另外,pybind11运行时的异常映射很方便,但反过来也意味着C++端的每类异常都需要在边界处翻译。默认它会把std::runtime_error翻译成RuntimeError,std::invalid_argument翻译成ValueError,如果自定义异常没有注册,就会被笼统地转成RuntimeError。记得在发布前测试一下具体异常类型是否正确,别让用户只看到一个笼统的错误。

5. 三项全对比与选型决策表

5.1 性能与开销画像

技术选型必须看数据。我从自己的基准测试和社区反馈中总结了一张性能画像表,注意不同场景下绝对数值会变,趋势是稳定的:

维度ctypesPython C APIpybind11
单次调用开销较高(动态查找、类型翻译)
数据类型转换成本手动且样式固定,结构体/数组转换有明显开销手动可控自动转换,STL/numpy转换有一定开销
编译时间无(库单独编译即可)
开发效率
可直接调用的语言CCC++/C
对C++类的原生支持需要自行实现完整支持

从实际曲线看,只要函数内计算量达到几十微秒以上,三种方案的调用开销差距就不明显了。真正拉开差距的是高频小函数调用,或者需要在Python和C之间频繁传递复杂结构体的场景。这时候,ctypes每次都要构造结构体对象并翻译成C布局,pybind11则可以通过自定义类型转换或直接操作缓冲区把成本压到很低。

5.2 功能与工程化能力对比

从功能和工程化角度再拉一张表:

能力ctypesPython C APIpybind11
C++类绑定基本不支持需手写大量代码内置支持
函数重载不支持手动区分overload_cast
默认参数/关键字参数需在Python侧处理手动解析py::arg自动支持
STL容器转换手动手动stl.h自动转换
NumPy数组零拷贝需手动管理需手动管理numpy.h支持
异常映射PyErr_SetString自动映射
自动生成docstring手动自动
与setuptools/CMake集成不需要直接都很方便
ABI稳定性依赖库自身需要跟随Python版本跟随C++编译链与Python版本

这张表说明一个明显规律:功能越丰富,ctypes越吃力;代码量越大,C API越痛苦;而pybind11几乎在每个功能点上都省心,代价是编译期和二进制体积。

5.3 各场景的推荐选型

结合我的经验,给几条比较坚决的建议。

如果只是紧急调用一个已有的C库,并且这个库本身就是C接口,直接用ctypes。安装零额外依赖、写起来快,而且如果库已经独立编译,Python侧不需要任何编译工具链。

如果要对一个中型以上、类结构复杂的C++工程提供Python接口,首选pybind11。它能让你把精力集中在接口设计上,而不是埋没在引用计数和类型翻译里。维护成本也低得多。

如果追求极致性能且代码量很小,例如只绑定三五个热函数、完全清楚内存布局,手写Python C API是可接受的,但你需要有很强的自控力,确保每行代码都正确处理引用计数与错误路径。

如果团队里既有C++接口又有C接口,且统一构建体系,pybind11可以同时覆盖:纯C函数用m.def直接绑定,C++类用py::class_绑定,一套工具链解决全部问题。

6. 常见问题与排查技巧实录

6.1 构建环节的典型错误

先列最常见的构建期问题。

Windows下安装或构建扩展时,经常看到“Microsoft Visual C++ 14.0 is required。Get it with Microsoft C++ Build Tools”一类的报错。这句话的意思是Python构建扩展需要一个可用的MSVC编译器,不是说你缺少某个Python包。解决方法是安装Visual Studio Build Tools,安装时勾选“使用C++的桌面开发”工作负载。装完如果还提示,可以把已安装的构建工具版本更新到与报错要求一致的版本。某些情况下也需要注意机器上是x86还是x64架构,Python 3.10以上基本都是x64。

Linux下找不到Python.h时,先确认是否安装了开发包。Ubuntu/Debian下通常需要python3-dev或python3.x-dev。如果是pybind11找不到头文件,先执行pip show pybind11查看包路径,再把include路径配置正确,或者干脆用Pybind11Extension/CMake find_package,就不用手动处理路径问题。

macOS下用clang编译扩展时,如果出现Undefined symbols for ... Python相关符号,通常需要在链接参数里加上-undefined dynamic_lookup。很多独立编译的扩展都会遇到这个,Python官方提供了sysconfig能查到对应链接参数,别手动抄网上的旧命令。

6.2 运行与崩溃问题

编译通过不代表万事大吉,运行时崩溃往往更难定位。这里记录几个典型:

段错误是最常见的。ctypes场景下,多半是restype声明错误、结构体字段与C端布局不一致、或者调用了已被释放的内存。pybind11场景下,常见于返回了局部变量的引用、或者lambda捕获了失效对象。C API场景下则多半是引用计数不平衡导致对象被提前回收。

导入时报undefined symbol,通常是扩展模块依赖的某个符号没有链接进动态库。Linux下可以用ldd -r,macOS下用otool -L,Windows下可用Dependencies工具查看缺失的DLL或符号。还有一种隐蔽情况:两个动态库都导出了同名符号,运行时按加载顺序解析到了错误的版本,这叫符号抢占,在混合C/C++库时尤其值得警惕。

另一个高频问题是GIL死锁。长耗时函数里释放了GIL,但回调到Python时没有重新获取;或者A线程持有GIL等待B线程结果,B线程又在等待GIL。这类问题表现起来就是程序卡死,没有堆栈,只能靠代码审查和faulthandler逐步排查。

6.3 调试技巧实录

调试混合编程代码,我的习惯是分三步。

第一步,先在纯C++层把功能单测过一遍,确认算法本身没有问题,再去接Python绑定。这一步能排除掉大量“其实是C++代码本身就有bug”的情况。pybind11绑定之前建议先跑一轮C++测试,ctypes绑定的C库同理。

第二步,用Python自带的faulthandler快速定位崩溃位置。在程序最前面调用import faulthandler; faulthandler.enable(),崩溃时能打印出Python侧的线程栈,再配合gdb/lldb把进程attach起来,通常能看到是哪个扩展函数在调用链上出了问题。

第三步,针对扩展代码本身用编译期内存检测。Linux下可以用AddressSanitizer编译扩展,启用后很多越界和use-after-free会直接报出来,信息量远大于裸段错误。pybind11项目在编译时也可以打开调试宏,输出更详细的转换和类型信息。用这些工具跑一轮,大多数隐藏Bug都会暴露。

最后再分享一个我自己的习惯。现在接到混合编程任务,我会先问现有代码是C还是C++。如果只是几个C函数、不想引入构建链,ctypes十分钟搞定。如果是要把一个正经C++工程暴露出去,或者在Python里复用类层次结构,我基本直接上pybind11,不为别的,后续维护的人不用在参数签名和引用计数里挣扎。手写Python C API这条路,只有需要深度定制解释器行为时我才会走,日常主动选它的情况几乎没有。你可以把这三条路都跑一遍,让实际工程告诉你答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询