资讯动态

C++与Python混合编程:从pybind11到ctypes的实战指南

发布时间:2026/10/6 4:44:57 来源:尧图企业网站定制
1. 为什么需要混合编程两种语言的优势互补你有没有遇到过这种情况Python写起来舒服几十行代码就能跑通逻辑但一旦数据量上来或者某个循环要执行几百万次运行时间就慢得让人抓狂。而C正好相反性能是强项可开发速度让人头秃改一行代码都要重新编译半天。于是很多人开始琢磨能不能让Python负责业务逻辑、快速原型的部分把真正的计算压力交给C这其实就是C与Python混合编程的核心思路让两种语言各干各擅长的事Python做“指挥官”C做“打手”。混合编程并不是什么高不可攀的技术。早些年大家用CPython C API手写绑定那体验确实痛苦又要管引用计数又要处理异常稍不留神就段错误。现在好了有了pybind11、Cython、ctypes这些工具C与Python之间的“翻译层”已经被大大简化。我自己在项目中实际使用下来一个计算密集型的模块从纯Python迁移到C扩展后性能提升十几倍甚至几十倍都很常见而且代码可维护性并不差。这篇文章不是教科书式地罗列理论而是把我实际跑通的经验和你分享。你会看到C与Python混合编程为什么有用四种主流方案到底怎么选然后我会带着你用pybind11从零搭建一个可用的扩展模块再聊聊ctypes和Cython这两条轻量路线最后把编译、链接、运行时常见的坑和排查方法一次性说清楚。不管你是刚入门Python不久、想优化性能瓶颈的新手还是已经在维护大型工程、需要嵌入C/C模块的资深开发者这篇文章都能提供一份可以直接照着操作的参考。2. 主流方案对比到底选pybind11、Cython、ctypes还是C API2.1 四条技术路线的底层逻辑C与Python混合编程说白了就是解决“怎么让Python调用C代码”和“怎么在C代码里操作Python对象”这两件事。围绕这个需求社区发展出了几种不同思路的方案。先说ctypes。它是Python标准库的一部分不需要任何编译步骤你只需要有一个编译好的动态链接库Linux下是.soWindows下是.dllmacOS下是.dylib然后在Python里用ctypes.CDLL加载它再声明函数参数类型和返回类型就能直接调用。它是“运行时绑定”底层通过libffi实现所以写起来最快适合快速调用已有的C函数或C接口的库。然后是Cython。Cython有点特殊它不完全是“绑定工具”更像是一门“扩展语言”。你写一个.pyx文件语法上很像Python但可以加C/C类型声明。Cython会把这个文件编译成C语言代码再编译成Python扩展模块。它的优点是你不需要用C的语法去写绑定逻辑可以用Python的思考方式来描述然后手动标注哪些变量是C类型从而大幅度提速。很多科学计算项目的核心算法就是先用Python写再用Cython逐步优化最终得到接近C的性能。接着是pybind11。这是我在新项目中优先推荐的工具它是纯头文件的C库基于C11标准设计通过模板元编程在编译期完成类型映射。你用C写被绑定的代码然后在同一个文件里写PYBIND11_MODULE宏把C函数、类、枚举、重载运算符逐一“暴露”给Python。它支持的C特性非常丰富包括lambda、STL容器、自定义类型转换器而且运行性能很高模板展开后几乎没有额外包装开销。最后是CPython C API。这是最底层的方案直接操作Python解释器提供的C接口。你需要手写PyObject*相关的代码管理引用计数处理异常甚至要了解GIL。它的缺点是开发量大、错误率高但好处是控制力最强Python生态里很多重量级扩展比如NumPy就是这样实现的。除非你对Python解释器内部机制特别熟悉否则我不建议新项目直接从C API开始。2.2 决策因素开发效率、性能、部署复杂度我见过不少团队在选择混合编程方案时犹豫不决其实把它拆成几个维度来看就清晰了。开发效率是指从写好C代码到Python能调用中间要花多少精力性能是调用开销和运行效率毕竟我们做混合编程本来就是为了性能部署复杂度则是最终交付时需要处理的环境依赖、编译产物、打包问题。方案开发效率性能表现部署复杂度适合场景pybind11高模板自动映射非常高几乎无额外开销中等需要编译.whl或.pyd新写的C模块需要绑定复杂类型Cython中高Python风格高但依赖类型声明质量中等需要编译C文件已有Python代码渐进优化ctypes极高无需编译中有动态调用开销低只需携带动态库已有C动态库快速调用CPython C API低手写细节多非常高高需维护引用计数开发重量级扩展深度定制解释器交互我个人的选择逻辑是这样的如果手头已经有一个C/C动态库只是想在自己的Python脚本里调用它而且场景也不复杂直接用ctypes别折腾。如果我要写一个新的性能敏感模块而且这个模块有一定规模我首选pybind11它的可维护性和性能表现在所有方案里是最均衡的。如果项目里的大量算法本来就是Python代码只是个别函数太慢我可以把那些函数用Cython重写保留Python层面的调用方式不变。至于C API只有在实现深度的Python对象协议、写Python解释器插件时才会专门碰它。2.3 VSCode配置C环境和工具链准备在做任何混合编程之前环境必须是对的。我自己花了很多时间在环境配置上尤其是Windows坑不少。先说说最基础的工具链。Python安装看起来是小事但很多人第一次翻车就翻在这里。去Python官网下载安装包时一定要勾选“Add Python to PATH”否则后续在命令行用python时会提示找不到命令。安装完成后在终端里执行python --version确认一下。另外如果项目里要用多版本Python建议用py命令或虚拟环境工具管理别硬改环境变量。C编译器这步最麻烦。Windows上主要有两条路一条是安装Visual Studio Build Tools或Visual Studio用MSVC编译器这也是官方推荐的路线另一条是安装MinGW-w64用g编译器。两条路我都试过总结下来如果你要用CMake配合pybind11而且项目可能接触比较复杂的Windows SDK接口优先选MSVC。MinGW-w64在很多场景够用但遇到依赖Windows系统头文件的库时兼容性问题会让你很头疼。macOS用户直接用Xcode命令行工具Clang就够了。Linux用户更简单sudo apt install build-essential就把g和make都装好了。VSCode配置C/C环境也需要专门说一句。你需要在扩展市场里装“C/C”扩展微软出的和“Python”扩展。第一步先让编辑器识别头文件路径在.vscode/c_cpp_properties.json里配置compilerPath和includePath。第二步配置构建任务比如写一个tasks.json调用CMake或g命令。第三步配置调试器Windows上MSVC用DebuggerLinux/macOS用GDB。这个过程中最容易出错的是路径里有中文或空格导致编译和调试命令解析异常。我后来统一的原则是所有项目路径只用英文和数字能省掉90%的环境类问题。CMake也是必须掌握的因为跨语言绑定最终要编译成共享库用CMake管理比直接手写一长串g命令强太多。CMake下载安装后在终端里执行cmake --version看是否成功。Windows上用CMake生成项目时需要通过-G指定生成器例如-G Visual Studio 17 2022或-G MinGW Makefiles不指定的话默认可能生成你并不想要的Build System。3. pybind11实操从零编写一个C扩展模块3.1 安装pybind11并初始化项目结构我先用pybind11带大家走一遍完整的流程因为它是目前最顺手的一条路线。首先安装pybind11库最简单的方式是pip install pybind11这个命令不仅会装好Python包还会把所需的.h头文件放到Python的site-packages目录下。如果你想让CMake找到pybind11常规做法是在CMakeLists.txt里写find_package(pybind11 CONFIG REQUIRED)此时需要指定pybind11的cmake目录比如cmake -Dpybind11_DIR$(python -m pybind11 --cmakedir) ..其实更简单的做法是在项目目录下直接按源码方式引入。把pybind11的仓库克隆下来或者复制include/pybind11和include/Python.h相关头文件放到自己的third_party/pybind11目录然后CMake里add_subdirectory(third_party/pybind11)。这样可以避免不同环境下的查找失败。建议的项目目录结构如下my_mixed_project/ ├── src/ │ └── core.cpp ├── python/ │ └── test_core.py ├── CMakeLists.txt └── README.md这个结构很清爽C源码放srcPython测试脚本放python构建规则放根目录。如果你的项目复杂还可以在src下面再分子模块但初学阶段没必要过度设计。3.2 编写C核心代码并绑定函数与类我们写一个现实一点的例子把一组科学计算的耗时操作放到C里。我先实现一个点积计算函数输入是Python的NumPy数组输出是单个浮点数。C源文件如下#include pybind11/pybind11.h #include pybind11/numpy.h namespace py pybind11; double dot_product(py::array_tdouble a, py::array_tdouble b) { auto a_buf a.request(); auto b_buf b.request(); if (a_buf.size ! b_buf.size) { throw std::runtime_error(Size mismatch); } double* a_ptr static_castdouble*(a_buf.ptr); double* b_ptr static_castdouble*(b_buf.ptr); double sum 0.0; for (ssize_t i 0; i a_buf.size; i) { sum a_ptr[i] * b_ptr[i]; } return sum; } PYBIND11_MODULE(core, m) { m.doc() C core module; m.def(dot_product, dot_product, Compute dot product); }注意PYBIND11_MODULE(core, m)里的core必须和最终编译出的动态库文件名一致。Windows上产出core.pydLinux上产出core.soPython导入时用的就是这个名字不一致会报ImportError或ModuleNotFoundError。如果你要绑定更复杂的类型比如一个C类pybind11用起来也非常直观class Calculator { public: Calculator(double scale) : scale_(scale) {} double add(double x, double y) { return (x y) * scale_; } double scale_; }; PYBIND11_MODULE(core, m) { py::class_Calculator(m, Calculator) .def(py::initdouble()) .def(add, Calculator::add) .def_readwrite(scale, Calculator::scale_); }绑定类的原理是pybind11通过模板为每个类生成一个“类型胶囊”保存C对象的指针及其析构函数。默认情况下Python拿到的是对象的拷贝还是引用取决于你绑定的返回值策略。后面我会讲到py::return_value_policy那是一个常见的性能和安全陷阱。3.3 使用CMake完成跨语言构建写好了源码下一步是配置CMake。一个可直接使用的CMakeLists.txt如下cmake_minimum_required(VERSION 3.15) project(my_mixed_project) set(CMAKE_CXX_STANDARD 17) find_package(Python COMPONENTS Interpreter Development REQUIRED) find_package(pybind11 CONFIG REQUIRED) pybind11_add_module(core src/core.cpp) # 如果还想给模块添加更严格的编译优化 target_compile_options(core PRIVATE $$CXX_COMPILER_ID:MSVC:/O2 /EHsc $$NOT:$CXX_COMPILER_ID:MSVC:-O3 -Wall -shared -fPIC )find_package(Python COMPONENTS Interpreter Development REQUIRED)的作用是找到Python解释器和动态库位置pybind11会据此自动配置头文件路径和链接参数。pybind11_add_module是一个简化函数相当于add_library加上一系列针对Python扩展的默认设置比如Windows上会生成.pyd而不是.dll。构建过程比较简单mkdir build cd build cmake .. cmake --build . --config ReleaseWindows下如果使用Visual Studio生成器--config Release很重要Debug版本和Release版本的Python运行库是不同的混用会碰到一些莫名其妙的运行时错误。构建完成后在build/Release目录下会生成core.pyd把它复制到项目根目录或Python测试脚本目录下然后就可以直接import core了。如果你的CMake找不到pybind11通常是pybind11的cmake目录没被正确找到。这时用前面提到的-Dpybind11_DIR...参数手动指定即可。3.4 在Python中调用并确认性能收益模块生成之后写一个Python脚本验证import numpy as np from core import dot_product import time n 10_000_000 a np.random.rand(n) b np.random.rand(n) # 纯 NumPy 计算 start time.perf_counter() res_numpy np.dot(a, b) time_numpy time.perf_counter() - start # 调用 C 扩展 start time.perf_counter() res_cpp dot_product(a, b) time_cpp time.perf_counter() - start print(NumPy:, res_numpy, time:, time_numpy) print(C:, res_cpp, time:, time_cpp) print(Speedup:, time_numpy / time_cpp)这里要注意我的dot_product在C里做的是朴素循环理论上不一定比NumPy的快因为NumPy底层已经做过高度优化。但如果你把更复杂的算法比如需要遍历大量数据、做复杂判断的逻辑放在C中差异就会非常明显。我实际遇到过一个StructuredGrid插值算法用Python写200毫秒改用C扩展后只需要20毫秒而且这是把Python层循环搬进C的结果不是只改了一个点积。性能调优的心得是多做测试不要猜。在不同数组大小下对比调用时间如果差得不多说明瓶颈可能在Python层的反复解析、数据拷贝上那就需要调整设计尽量把更多的逻辑放进C里。4. 轻量方案实务ctypes和Cython的快速落地4.1 ctypes加载已有动态库的最快路径如果你电脑上已经有一个用C或C写的.dll或.so而且你没有心思去写pybind11的绑定代码那ctypes几乎是最省事的方案。它最大的价值在于“不编译扩展”只需要在Python侧声明函数的信息就能调用动态库。我先拿一个纯粹的C动态库做例子。源文件myadd.cppextern C { double add_c(double a, double b) { return a b; } double sum_array(double* arr, int n) { double s 0; for (int i 0; i n; i) s arr[i]; return s; } }在Linux下编译g -shared -fPIC -o libmyadd.so myadd.cpp在Windows下用MinGW可以g -shared -o myadd.dll myadd.cpp用MSVC的话需要cl /LD myadd.cpp。注意函数要加extern C否则C编译器会对符号做名字修饰导致ctypes按名字找不到函数。Python侧使用import ctypes import numpy as np lib ctypes.CDLL(./libmyadd.so) # Windows 下用 ctypes.CDLL(./myadd.dll) lib.add_c.argtypes [ctypes.c_double, ctypes.c_double] lib.add_c.restype ctypes.c_double print(lib.add_c(3.0, 4.0)) # 传递数组 lib.sum_array.argtypes [ctypes.POINTER(ctypes.c_double), ctypes.c_int] lib.sum_array.restype ctypes.c_double arr np.array([1.0, 2.0, 3.0, 4.0], dtypenp.float64) ptr arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) print(lib.sum_array(ptr, len(arr)))关键细节是argtypes和restype必须设置如果不设置ctypes会默认把Python的int转换成C的int把Python的float转换成C的double但指针、结构体、字符串等复杂类型可能会传错导致段错误。这个坑我踩过不止一次。arr.ctypes.data_as是NumPy提供的高效零拷贝方式把数组内存的首地址传递给C函数。这样C函数可以直接读写NumPy数组的内存但如果C函数保存了这个指针并在Python释放数组之后继续使用就会产生悬空指针这是崩溃的元凶之一。4.2 Cython从Python代码渐进式优化Cython与ctypes不同它需要你写.pyx文件并编译成扩展模块。但好处是你可以只优化部分代码而且写法和Python非常接近。比如我们有一个纯Python累加函数def py_sum(int n): cdef double s 0.0 cdef int i for i in range(n): s i * 0.5 return s实际上在这个函数里函数名和参数类型我都用Python语法写然后通过cdef声明C变量的类型。把文件保存为math_helper.pyx然后写setup.pyfrom setuptools import setup from Cython.Build import cythonize setup( ext_modulescythonize(math_helper.pyx), )执行pip install cython python setup.py build_ext --inplace生成math_helper.cpython-3xx-x86_64-linux-gnu.so后Python里直接import math_helper调用math_helper.py_sum即可。功能上和原Python函数一致但循环部分已经被编译成C代码性能能提升数倍甚至一个数量级。如果你的函数需要接收NumPy数组Cython有一个很好用的“内存视图”语法import numpy as np cimport numpy as cnp def sum_2d(double[:, :] arr): cdef int i, j cdef double total 0 for i in range(arr.shape[0]): for j in range(arr.shape[1]): total arr[i, j] return totalCython编译后直接使用内存视图访问NumPy数组效率很高也不需要手动处理GIL和引用计数。但要注意传入数组必须是连续内存或者符合内存视图的步长规则否则可能出现数据访问错误。我建议Cython的定位是“渐进优化”先用Python实现逻辑性能不够时再迁移到Cython而不是一开始就上Cython。因为Cython虽然比C API简单但调试和构建还是有一定成本。4.3 数据交换和内存管理的几个关键点无论用哪种方案C和Python之间交换数据时最容易出问题的是“谁拥有这块内存”。Python对象由引用计数管理超出作用域就会析构C对象的生命周期由new/delete或智能指针管理。如果你在C里返回了一个指向局部变量的指针Python拿到后当有效数据用最后结果就是崩溃。在pybind11中函数返回值的默认策略是return_value_policy::automatic对于返回的类对象会拷贝一份对于简单类型则按值传递。如果你返回的是成员变量指针或容器的引用需要显式使用py::return_value_policy::reference或reference_internal并确保Python对象持有C对象的引用防止C对象在Python侧还在使用时就被销毁。数据拷贝也是性能的关键。很多时候我们以为C扩展很快但实际慢在数据从Python转换到C的过程中。尤其是多维数组如果无法避免复制那就要评估是否值得。最佳实践是尽量让C模块直接读写Python的原始缓冲区比如pybind11的py::array_t默认会做一次检查如果数组内存连续且类型匹配就直接用原指针不拷贝如果不匹配则可能触发一个临时拷贝。我建议在C函数里先检查a_buf.strides和a_buf.shape必要时才创建一个临时数组。另外跨语言异常处理也要留意。C抛出的异常默认情况下在C API边界处会被忽略或导致未定义行为。pybind11会把std::runtime_error自动翻译成Python的RuntimeError其他异常也能通过py::register_exception注册为Python自定义异常。但是如果你用的不是这些绑定框架而是手写C API一定要在函数入口捕获异常转换成Python的异常对象再返回。5. 常见问题与排查技巧实录5.1 编译链接阶段的高频报错混合编程第一个大头就在编译和链接环节我下面总结几个我实际遇到多次的问题。“找不到 python39.lib”或“cannot open file python39.lib”。这通常发生在Windows下原因是CMake没有正确找到Python的库文件。解决办法是检查find_package(Python COMPONENTS Interpreter Development REQUIRED)是否正常执行如果Python是通过Windows商店或Anaconda安装的CMake可能会找到错误版本的库。最直接的办法是设置Python_ROOT_DIR或Python_LIBRARY、Python_EXECUTABLE变量强制指定路径。“undefined symbol: PyInit_ ”。这是pybind11开发中非常经典的问题。报错信息会提示Python加载模块时找不到初始化入口。原因一般是PYBIND11_MODULE宏里写的模块名和编译生成的动态库文件名不一致。比如宏写的是PYBIND11_MODULE(core_internal, m)但生成的文件被改成core.pydPython导入core就会去寻找PyInit_core找不到就报undefined symbol。所以修改文件名时一定要同步修改宏里的模块名或者反过来。“ImportError: DLL load failed”。Windows下跑已编译的.pyd文件时系统提示找不到某个dll或者加载失败。常见原因是缺少对应版本的Visual C Redistributable。MSVC编译的扩展一般依赖vcruntime140.dll、msvcp140.dll等运行库。最省事的解决办法是安装“Microsoft Visual C Redistributable”最新版。另一个原因是Python位数和C编译位数不一致比如Python是64位但编译时用了32位工具链。这个检查起来很简单在Python里看platform.architecture()编译时选对应架构即可。5.2 运行期崩溃与性能问题排查运行期崩溃尤其是段错误是所有混合编程者最怕遇到的。常见原因有三类数组越界、悬空指针、GIL问题。数组越界往往是因为Python传入的数组尺寸和C里假设的不一致。排查时我会在C函数入口打印形状和步长确保传入数据是连续的。悬空指针多数发生在返回引用到局部对象或保存指针的缓存场景。如果是通过ctypes调用特别要注意argtypes必须设置正确否则ctypes可能把64位指针截断成32位整数导致导致访问非法地址。GIL问题是另一个隐蔽的雷区。Python的全局解释器锁不允许两个线程同时执行Python字节码但如果你的C扩展运行一个耗时操作时不释放GIL那么其他Python线程就会被卡住。反过来如果C线程尝试在持有GIL的线程中调用Python对象也可能导致死锁。在pybind11中如果你的C函数是纯计算且不需要操作Python对象可以在函数体内使用py::gil_scoped_release释放GIL让Python解释器能够调度其他线程。比如m.def(heavy_compute, [](py::array_tdouble data) { py::gil_scoped_release release; // 此处可以执行不接触 Python API 的耗时逻辑 });关于性能调优先用profiler确认瓶颈位置。我常用的工具包括perfLinux、Very Sleepy或Visual Studio ProfilerWindows。有时你会看到耗时根本没进入C扩展而是花在Python侧的重复解析上那就说明需要把更多的循环体搬进C。如果耗时进入C但依旧慢就要检查是否做了隐式拷贝。在pybind11里py::array和py::array_t本身拥有底层内存引用但如果你把数组强制转换或调用某些方法触发了复制性能损失会很大。可以临时打印sizeof和指针位置来确认是不是同一个地址。5.3 VSCode调试混合工程的经验VSCode已经成了我调试C与Python混合项目的主力。要调试C扩展模块第一步是把CMake产生的符号文件保留下来。在CMakeLists里如果用的是pybind11_add_module默认Release配置下会生成.pdb文件或.dSYM调试时需要把它们和.pyd或者.so放在同一目录。VSCode的launch.json里Python调试配置和C调试配置是不同的。我通常先运行Python脚本让它停在某个断点上然后使用“C”调试配置附加到PID上或者反过来用Python集成调试器跳入C扩展时编译器需要生成调试信息。我在.vscode/launch.json里会放两个配置一个用于调试Python入口一个用于调试C扩展。Python端常规的request: launchC端则使用request: attach或通过cpptools插件启动。实操中最稳的做法是先用gdbLinux或Visual Studio调试器Windows直接针对.so/.pyd加载Python解释器运行然后在C源码里打断点。VSCode也有“C/C Extension”和“Python Extension”协同的方案但配置起来会有一些繁琐。这里分享一个调试小技巧如果你的C扩展崩了先别急着开IDE调试试试在Python中运行最小调用脚本并用faulthandler定位是哪一个栈帧出的问题import faulthandler faulthandler.enable() import your_module your_module.run_something()它能直接告诉你段错误发生在哪个C或Python栈上能省去一大把瞎猜的时间。6. 从零搭建混合工程时我的一些体会最后说点实在的。很多初学者一上来就选最复杂的C API方案或者把整个项目一股脑全部改成C其实都不明智。我的建议是先评估性能瓶颈究竟在哪。用cProfile跑一遍现有Python代码找到占比最高的函数只看那部分是否需要优化。如果确实要走到C这步先用ctypes打一个最小可行验证确认性能达标后在决定是否引入pybind11做结构化绑定。这样做的好处是前期的技术探索成本很低不会因为选错方案而返工很多。我自己实际使用的项目里pybind11占了多数因为它对现代C特性的支持非常完整。但我也保留了一个ctypes模块用来快速对接一个比较老旧的C动态库。两种方案共存完全没问题只要在项目里约定好分层底层算法模块统一用C核心库然后通过pybind11封装成Python接口个别不稳定、还在快速迭代的算法可以先放在C库中用导出C接口的方式交给ctypes调等稳定后再迁移到pybind11正式绑定。还有一点是文档和测试不能省。混合编程的接口是跨语言边界一旦出错问题可能来自Python侧的类型声明也可能来自C侧的边界条件。我认为最有效的做法是在C层加大量断言同时在Python侧写一套针对输入尺寸、类型、异常情况的测试用例。pybind11可以把C的assert转成Python异常但默认的assert在Release模式会被禁用所以建议用自定义的检查函数或者干脆在Python测试里多做边界测试。跨语言开发比单纯写Python或C更考验工程思维因为你同时要关心两种语言的特性、两套构建工具链、两套调试手段。但一旦你熟悉了流程收益是非常明显的“写Python时不用再因为性能问题强迫自己把所有代码都改成C遇到性能热点时用C补上既保证了开发速度又保住了运行时效率“。”这两种语言配合起来的威力在数值计算、游戏服务器逻辑、图像渲染管线、实时控制系统里都有非常大的发挥空间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑