4、C扩展模块编写:Python C API入门、PyObject与引用计数、编写第一个C扩展模块
说实话,很多做Jetson的朋友都问过我一个问题:Python跑起来挺爽的,为什么非要跟C扯上关系?
我的回答很简单——你想想看,在Jetson上做实时图像处理,Python的GIL锁一卡,帧率直接掉一半。这时候你就知道C扩展有多香了。今天我们就来聊聊怎么在Python里调用C代码,写出真正高性能的模块。
4.1 Python C API到底是什么?
Python C API,说白了就是Python官方提供的一套C语言接口。它让你能用C语言写一个动态链接库,然后像导入普通Python模块一样导入它。
我在项目中遇到过最典型的场景:一个YOLO的后处理函数,Python版本跑一次要15ms,用C重写后直接降到2ms。嗯,这就是C扩展的价值所在。
核心概念其实就三个:
- PyObject:Python中所有对象的C语言表示
- 引用计数:Python内存管理的核心机制
- 模块初始化函数:让Python认识你的C模块
重要提醒:Python C API不是让你从头写一个Python解释器,而是让你在C语言层面操作Python对象。你写的C函数,最终会被Python虚拟机调用。
4.2 PyObject:一切皆对象的真相
Python里「一切皆对象」这句话,在C层面是怎么体现的?答案就是PyObject。
每个Python对象,在C语言里都是一个PyObject结构体指针。这个结构体只有两个字段:
typedef struct _object {
Py_ssize_t ob_refcnt; // 引用计数
PyTypeObject *ob_type; // 类型信息
} PyObject;
你看,就这么简单。一个记录有多少个变量引用它,一个记录它是什么类型。
我刚开始学的时候犯过一个低级错误:直接操作PyObject内部的字段。后来发现,官方提供了大量宏和函数来安全操作,比如Py_INCREF()、Py_DECREF()。千万别手贱去改ob_refcnt,血的教训。
| 常用宏/函数 | 作用 |
|---|---|
Py_INCREF(obj) |
增加引用计数 |
Py_DECREF(obj) |
减少引用计数,减到0时自动释放 |
Py_XINCREF(obj) |
安全版,允许obj为NULL |
Py_XDECREF(obj) |
安全版,允许obj为NULL |
Py_NewRef(obj) |
创建新引用(Python 3.10+) |
4.3 引用计数:别让你的内存泄漏
引用计数是Python内存管理的基石。每个对象都有一个计数器,记录当前有多少个地方引用了它。当计数器归零时,对象就被回收。
在C扩展里,你必须手动管理引用计数。为什么?因为C代码不受Python的垃圾回收器管理。
我曾经在Jetson上写过一个视频流处理模块,因为忘记释放一个临时创建的PyList对象,导致内存以每秒10MB的速度增长。跑了半小时,系统直接OOM。嗯,从那以后我再也不敢忽视引用计数了。
个人习惯:每次创建新对象后,我都在注释里写清楚「这个引用是借用的还是新创建的」。这样三个月后回来看代码,自己还能看懂。
引用计数的两条黄金法则:
- 谁创建,谁释放:你用
Py_BuildValue()或PyList_New()创建的对象,用完要Py_DECREF() - 借用引用不释放:函数参数传进来的
PyObject*,通常不需要你释放
4.4 编写第一个C扩展模块
好了,理论说完了,我们来写点真东西。目标:写一个C扩展模块,提供一个加法函数和一个打印函数。
先看目录结构:
my_extension/
├── mymodule.c # C源码
├── setup.py # 构建脚本
└── test.py # 测试脚本
第一步:写C代码
// mymodule.c
#include <Python.h>
// 加法函数:接收两个整数,返回它们的和
static PyObject* my_add(PyObject* self, PyObject* args) {
int a, b;
// 解析Python传入的参数
if (!PyArg_ParseTuple(args, "ii", &a, &b)) {
return NULL; // 解析失败,Python会自动抛出异常
}
int result = a + b;
// 将C的int转换成Python的PyObject
return PyLong_FromLong(result);
}
// 打印函数:接收一个字符串,打印出来
static PyObject* my_print(PyObject* self, PyObject* args) {
const char* msg;
if (!PyArg_ParseTuple(args, "s", &msg)) {
return NULL;
}
printf("[C扩展] %s\n", msg);
// 没有返回值时,返回Py_None(Python的None)
Py_RETURN_NONE;
}
// 方法表:告诉Python这个模块有哪些函数
static PyMethodDef MyMethods[] = {
{"add", my_add, METH_VARARGS, "两个整数相加"},
{"print_msg", my_print, METH_VARARGS, "打印一条消息"},
{NULL, NULL, 0, NULL} // 结束标记
};
// 模块定义结构体
static struct PyModuleDef mymodule = {
PyModuleDef_HEAD_INIT,
"mymodule", // 模块名
NULL, // 模块文档
-1, // 模块状态(-1表示全局)
MyMethods // 方法表
};
// 模块初始化函数
PyMODINIT_FUNC PyInit_mymodule(void) {
return PyModule_Create(&mymodule);
}
第二步:写构建脚本
# setup.py
from setuptools import setup, Extension
module = Extension('mymodule', sources=['mymodule.c'])
setup(
name='mymodule',
version='1.0',
description='我的第一个C扩展模块',
ext_modules=[module]
)
第三步:编译安装
# 在终端执行
python setup.py build_ext --inplace
编译成功后,你会看到当前目录多了一个mymodule.cpython-*.so文件。这就是你的C扩展模块。
第四步:测试
# test.py
import mymodule
result = mymodule.add(3, 5)
print(f"3 + 5 = {result}")
mymodule.print_msg("Hello from C!")
运行结果:
3 + 5 = 8
[C扩展] Hello from C!
注意:在Jetson上编译时,记得确认你的Python版本。Jetson默认的Python3.8和手动安装的Python3.10,头文件路径不一样。我曾经因为路径搞错,编译报错折腾了一下午。
4.5 常见坑与避坑指南
写C扩展模块,有几个坑我踩过,分享给你:
- 段错误(Segmentation Fault):90%是因为引用计数搞错了。多了一次
Py_DECREF(),对象被提前释放,下次访问就崩了。 - 内存泄漏:创建了对象忘记释放。用
valgrind或者Python的gc模块可以检测。 - 类型不匹配:
PyArg_ParseTuple的格式字符串写错了,比如传了浮点数却用"i"解析。Python会报TypeError,但错误信息可能不够直观。
调试技巧:在C代码里加printf打印日志,是调试C扩展最直接的方法。别怕麻烦,我每次写新函数都会先加几行日志,确认参数传进来了再往下写。
4.6 在Jetson上的特殊注意事项
Jetson是ARM架构,跟x86有些区别:
- 编译选项:加上
-march=armv8-a可以优化性能 - CUDA集成:如果要在C扩展里调用CUDA,需要链接
-lcuda和-lcudart - 交叉编译:如果你在PC上交叉编译给Jetson用,记得指定正确的Python头文件路径
我个人习惯在Jetson上直接编译,省去交叉编译的麻烦。虽然慢一点,但不容易出错。
好了,第一个C扩展模块就写完了。你可能会觉得步骤有点多,但相信我,一旦你尝到了性能提升的甜头,就会觉得这一切都值得。下一章我们会深入更复杂的类型处理,比如在C里操作Python的列表和字典。