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的列表和字典。