在 Python 中调用 C 函数

在 Python 中调用 C 函数

Python 慢在解释执行和动态类型,但慢的部分往往只占代码的一小部分。把性能关键路径交回给 C,是工程上最常用的优化手段之一。ctypes 是 Python 标准库自带的方案:不需要写一行 C 扩展代码,只要把 C 代码编译成动态库,Python 就能直接加载并调用里面的函数。下面我们从选型讲起,覆盖动态库的编译、类型的映射、指针与缓冲区的处理、结构体与数组的绑定,最后用一个完整的例子串起来,再聊聊常见误区和性能边界。看完你就能把存量 C 库或自己写的 C 函数接进 Python。

1. 三条调用路线

需要把 C 代码交给 Python 调用的场景很常见:核心算法用 C 实现过,不想用 Python 重写,逐元素循环太慢,要把整个循环下沉到 C,要对接系统库或第三方 C 库的 API。实现方案主要有三条:

方案原理要不要编译学习成本性能
ctypes加载动态库,Python 侧声明式绑定只编译 C 侧低调用有装箱开销,粗粒度调用够用
Cython类 Python 语法编译成 C需要中接近 C,可写类型化热路径
Python/C API手写 C 扩展模块需要高最彻底,可精细控制

ctypes 是最轻的一条路:C 侧照常编译成动态库,Python 侧不需要任何编译步骤,适合快速把一个现成库用起来。Cython 写起来像 Python,但产物接近 C,适合从 Python 代码渐进式优化出热路径。Python/C API 是写真正扩展模块的路线,控制力最强,但每次改动都要编译安装,工程开销最大。这一篇我们就聚焦 ctypes:它解决的是已经有一份 C 代码或 C 库、怎么最快让 Python 用起来的问题。

2. 编译动态库

ctypes 加载的是操作系统标准的动态库,不是可执行文件。C 源码写好之后,三个平台的编译命令分别是:

macOS:   gcc -dynamiclib -o libmy.dylib test.c
Linux:   gcc -shared -fPIC -o libmy.so test.c
Windows: cl /LD test.c      # MSVC 编译器

macOS 的 -dynamiclib 产出 .dylib,Linux 的 -shared -fPIC 产出 .so,Windows 的 cl /LD 产出 .dll。编译完可以用符号工具验证函数确实导出了:macOS 上 nm -gU libmy.dylib,Linux 上 nm -D libmy.so,Windows 上 dumpbin /EXPORTS libmy.dll,函数名应该出现在列表里。

这里有两个容易翻车的点。第一,ctypes 只能按函数名调用 C 符号,如果用 C++ 编译器编译,函数名会被名字修饰(name mangling)改写,Python 端按原函数名根本找不到。C++ 源码里的函数要包一层 extern “C”,或把整个头文件用 extern “C” 包起来。第二,调用约定:64 位平台默认约定基本统一,但 32 位 Windows 上 cdecl 和 stdcall 有区别,ctypes 分别用 CDLL 和 WinDLL 加载,后面第 7 节再展开。

3. 类型映射

C 的 int、float 这些类型在 Python 里没有一一对应的概念,ctypes 提供了一组构造器,名字直接沿用 C 类型名。构造一个 c_int(20) 就相当于在 C 里声明 int a = 20,通过 .value 属性读回 Python 的值。常用对照如下:

ctypes 构造器对应的 C 类型常见宽度
c_shortshort2 字节
c_intint4 字节
c_longlong平台相关
c_longlonglong long8 字节
c_floatfloat4 字节
c_doubledouble8 字节
c_size_tsize_t与指针同宽
c_char_pchar *指针宽度
c_void_pvoid *指针宽度

下面这段代码把常用类型各造一个对象,打印对象本身和它的 .value:

from ctypes import *

def test():
    a = c_short(10)
    b = c_int(20)
    c = c_long(30)
    d = c_longlong(40)
    e = c_float(3.14)
    f = c_double(3.14)
    print(a, a.value)
    print(b, b.value)
    print(c, c.value)
    print(d, d.value)
    print(e, e.value)
    print(f, f.value)

if __name__ == '__main__':
    test()

运行结果:

c_short(10) 10
c_int(20) 20
c_long(30) 30
c_longlong(40) 40
c_float(3.140000104904175) 3.140000104904175
c_double(3.14) 3.14

值得注意 c_float 那一行:传进去 3.14,读回来变成 3.140000104904175。这不是 bug,是 float32 的精度限制。float 只有约 7 位有效十进制数字,3.14 在 32 位浮点里没有精确表示,存的是最接近的二进制近似值,打印出来就带一长串尾数。c_double 是 64 位浮点,能表示到约 15 位,所以 3.14 看起来还是 3.14。写数值程序时,C 端用 float 还是 double,Python 侧读回的结果精度可能完全不同,这个差异要提前想清楚。

4. 指针与缓冲区

C 函数经常要求调用方传一个地址进来,函数往这块内存写结果,这就是出参。Python 没有指针概念,ctypes 用三个工具补齐:pointer() 创建真正的指针对象,byref() 轻量地把地址传给函数,create_string_buffer() 申请一块可写的字节缓冲区。

pointer() 返回一个 LP_xxx 对象,可以长期持有,用 .contents 读写它指向的值:

from ctypes import *

a = c_int(20)
p = pointer(a)         # 把 a 包成指针对象
print(p)               # LP_c_int 对象,0x 后地址每次运行不同
print(p.contents)      # 指针指向的值:c_int(20)
p.contents.value = 99  # 通过指针改值
print(a.value)         # 99,原变量同步变化

pointer() 包装的是同一块内存,通过指针改值,原变量能看到新值。大多数 C 函数的指针参数只是把地址传进去,并不需要长期保存这个指针对象,用 byref(a) 更轻:

/* add.c */
void add_one(int *p) {
    if (p) *p += 1;
}
from ctypes import *

lib = CDLL('./libadd.dylib')
lib.add_one.argtypes = [POINTER(c_int)]   # 声明参数类型

a = c_int(20)
lib.add_one(byref(a))    # 只传地址,不创建指针对象
print(a.value)           # 21

byref() 不创建指针对象,只传地址,是 C 函数形参的最佳搭档,声明 argtypes 之后,ctypes 会在调用前核对参数类型,传错立即抛异常而不是段错误。

字符串和字节缓冲区是另一个高频场景。C 的 char* 分两种:只读的字符串常量可以用 c_char_p 绑定,要写回数据的缓冲区必须用 create_string_buffer() 申请可写内存:

from ctypes import *

buf = create_string_buffer(6)   # 6 字节可写缓冲区
print(buf.raw)                  # b'\x00\x00\x00\x00\x00\x00',全部原始字节
print(buf.value)                # b'',读到第一个 \x00 就停

buf.value = b'hello'
print(buf.raw)                  # b'hello\x00'
print(buf.value)                # b'hello',按 C 字符串读

.raw 返回缓冲区全部原始字节,.value 按 C 字符串规则读到第一个 \x00 为止。同一个缓冲区两个视角,得到不同结果,这在调试 C 函数写回的数据时非常有用。

5. 结构体与数组

除了基本类型,ctypes 支持把 C 的结构体、数组、联合体完整映射过来。结构体用继承 Structure 的类描述,_fields_ 列出字段:

from ctypes import *

class Point(Structure):
    _fields_ = [('x', c_int), ('y', c_int)]

pt = Point(3, 4)
print(pt.x, pt.y)               # 3 4

arr = (c_int * 5)(1, 2, 3, 4, 5)  # C 数组:5 个 int
print(arr[2])                   # 3
print(list(arr))                # [1, 2, 3, 4, 5]

Point(3, 4) 会在内存里按 C 布局排布字段(默认自然对齐),传给 C 函数时用 byref(pt) 传结构体地址,C 端就能直接读写 x、y。数组用 (c_int * 5) 这种乘法语法创建,下标访问和 Python 列表一致,传给 C 函数时自动退化为指针。

6. 内存拷贝实战

把前面的内容串起来。假设有一个 C 函数,把 src 指向的内存逐字节拷到 dst,带空指针和非法尺寸检查,放在 test.c:

#include <stdlib.h>
#include <string.h>

void memory_copy(const void *src, void *dst, size_t ele_size, size_t ele_num)
{
    if (NULL == src || NULL == dst) {
        return;
    }
    if (ele_size <= 0 || ele_num <= 0) {
        return;
    }
    char *new_dst = (char *)dst;
    const char *new_src = (const char *)src;
    for (size_t i = 0; i < ele_size * ele_num; ++i) {
        *new_dst++ = *new_src++;
    }
}

函数本身是逐字节拷贝,防御写在前面:src、dst 为空直接返回,尺寸不大于 0 也返回,循环里两个指针都强转成 char*,每次 ++ 恰好走一个字节,循环次数等于 ele_size 乘 ele_num。按第 2 节命令编译成动态库后,Python 端加载并调用:

from ctypes import *

lib = CDLL('./libmy.dylib')
# 调用前把函数签名声明清楚
lib.memory_copy.argtypes = [c_void_p, c_void_p, c_size_t, c_size_t]
lib.memory_copy.restype = None

src = b'hello world'                      # C 函数要字节,不要 str
dst = create_string_buffer(len(src) + 1)  # 多留 1 字节给结束符
print(dst.raw)                            # b'\x00' * 12

lib.memory_copy(src, dst, 1, len(src))    # 拷贝 11 个有效字节
print(dst.raw)                            # b'hello world\x00'
print(dst.value)                          # b'hello world',读到结束符

逐个看关键点。CDLL 把动态库加载进当前进程,返回值上的每个属性对应库里导出的一个函数。src 用 b’hello world’ 而不是 str,因为 C 函数看到的是字节缓冲区,Python 3 的 str 是 Unicode,直接传会报错。dst 用 create_string_buffer(len(src) + 1) 申请 12 字节,比内容长 1 是给结束符留位置。调用时 ele_size=1、ele_num=11,正好拷贝 11 个有效字节,dst 末尾初始化时的 \x00 保留为结束符。

运行结果:拷贝前 dst.raw 是 12 个 \x00、dst.value 是空,拷贝后 raw 是 b’hello world\x00’,value 是 b’hello world’。raw 和 value 两个视角,正好对应 C 端看原始字节和按 C 字符串读两种习惯。

7. 声明函数签名

ctypes 默认把 C 函数的返回值当成 int 解析,也允许任何参数传进来而不做检查——这正是段错误的温床。生产代码里,调用任何 C 函数之前都应该把签名声明清楚:

lib.add.argtypes = [c_int, c_int]    # 参数类型
lib.add.restype = c_int              # 返回值类型

lib.get_double.restype = c_double    # 返回 double,不设会按 int 解析
lib.get_ptr.restype = c_void_p       # 返回指针
lib.get_str.restype = c_char_p       # 返回 C 字符串

restype 告诉 ctypes 返回值怎么解释。不设 restype,返回 double 的函数会被当成 int 解析,Python 端拿到一个完全错误的值,返回指针的会被截断,后续解引用直接崩。argtypes 的价值不只是文档:设置之后,ctypes 会在每次调用前按声明做类型检查,传错类型立即抛 TypeError,还会做自动转换,比如 Python 的 int 自动转成 c_int、str 按 UTF-8 编码后转成 c_char_p。把错误拦在调用前,比段错误好排查得多。

Windows 上还有一个调用约定问题:32 位下,cdecl(C 默认)用 CDLL 加载,stdcall(Win32 API 常用)用 WinDLL 加载,64 位平台两者统一,CDLL 足够。

8. 常见误区

误区一:以为 c_int 永远是 4 字节。c_int 对应 C 的 int,主流 64 位平台上是 4 字节,但标准只保证至少 2 字节,某些平台可能不同。要固定宽度,用 c_int32、c_uint64 这类带位宽后缀的类型,跨平台才稳。

误区二:传字符串忘了转 bytes。C 函数要 char*,Python 3 的 str 是 Unicode,ctypes 不会自动编码,直接传 str 会抛 ArgumentError。统一用 b’…’ 或 str.encode(‘utf-8’) 转成字节再传。

误区三:缓冲区没多留一个字节。C 端按字符串处理并写结束符时,缓冲区要比有效内容长 1。结束符写到越界位置,轻则串改相邻数据,重则段错误。create_string_buffer(len(s) + 1) 是标准写法。

误区四:没设 restype 就调用。默认按 int 解析返回值,double 或指针的函数拿到的全是错值。写调用代码的第一件事,就是把 argtypes 和 restype 声明好。

9. 性能与边界

ctypes 调用的开销不能忽略:每次调用要把 Python 参数装箱成 C 表示、调用完再拆箱,固定开销在微秒量级。所以正确用法是粗粒度调用——一次调用完成大量工作,而不是在 Python 循环里逐元素调用 C 函数,后者每次调用都付一遍装箱拆箱,往往比纯 Python 还慢。想加速的是整个循环,不是循环里的每个元素。

另一个特点:ctypes 调用外部 C 函数期间会释放 GIL,耗时较长的 C 函数不会阻塞 Python 的其他线程,多线程并行跑 C 计算天然合适,但要操作 Python 对象(比如回调里访问 list),还是回到 GIL 保护下。

什么时候不该用 ctypes:热路径上高频、细粒度的小调用,需要跨很多 Python 对象做复杂交互,追求极限性能。这些场景换 Cython 或 Python/C API 更划算。ctypes 的定位是把存量 C 库和简单 C 函数快速接进 Python,选对场景,它就是性价比最高的方案。

在 Python 里调用 C 函数,核心就四步:把 C 代码编译成动态库,用构造器把 C 类型映射成 Python 对象,用 pointer、byref、create_string_buffer 处理指针和缓冲区,用 argtypes、restype 把函数签名声明清楚。类型映射、指针语义、调用约定这三块想明白,大多数 ctypes 问题都能解决。调用是有开销的,记得粗粒度调用、把热循环整体下沉到 C,收益才最大。