不写一行 C 扩展,ctypes 就能让 Python 直接调用动态库里的函数。但它是「信任你」的:你告诉它函数接受什么、返回什么,它就照做,说错了也不报错。这篇用 libc 和一个自己编译的小 C 库,把它的能力和陷阱都真实跑一遍。
libc.getpid() = 83732 | os.getpid() = 83732
libc.abs(-7) = 7
libc.strlen(b'hello') = 5 | libc.strlen('你好'.encode()) = 6 (UTF-8 字节数)ctypes.CDLL(ctypes.util.find_library("c")) 打开 C 标准库,函数就成了属性。getpid 与 os.getpid() 结果相同——它们真是同一个系统调用。传参要用 C 能理解的类型:bytes 会被当作 char*,直接传 str 就报 ArgumentError;strlen 数的是 UTF-8 字节数(6)而不是字符数。
argtypes 与 restypelibm.sqrt(2.0) → ArgumentError: argument 1: TypeError: Don't know how to convert parameter 1 只声明 argtypes、不声明 restype: libm.sqrt(2.0) = 0 ← 返回值被当成 int 读, 是一个无意义的数 再声明 restype=c_double: libm.sqrt(2.0) = 1.4142135623730951 malloc 不声明 restype 时返回 79558736 (被当成 32 位 int); 声明 c_void_p 时返回 0x104bdf860 (超过 2**32 = 0x100000000: True)
这是 ctypes 最容易踩的坑。sqrt 接受 double 返回 double:①不声明 argtypes,传 Python float 会 ArgumentError;②声明了 argtypes 但不声明 restype,默认按 int 读返回值,结果得到 0,没有任何报错;③两个都声明才对。返回指针也一样:malloc 默认返回值被当成 32 位整数,而真实指针超过 2**32,高位丢失——用这个值当地址,程序会崩或者改到不该改的内存。要声明 restype = c_void_p。
(第二个 malloc 的两个返回值来自两次不同的调用,不是同一块内存,这里只想展示位数:错误的读法只剩 32 位。)
cc -shared -O2 -fPIC demo.c -o libdemo.dylib → 返回码 0 sum_array((c_int32 * 5)(10..50), 5) = 150 fill_rec 填写结构体: tag = b'R' value = 2.5 count = 7 结构体布局 C 与 ctypes 对照: sizeof C=24 / ctypes=24; offset(value) C=8 / ctypes=8; offset(count) C=16 / ctypes=16 (字段总大小 1+8+4=13, 中间有对齐填充)
用 cc -shared 编出动态库,(c_int32 * 5)(...) 造一个 C 数组交给 sum_array;继承 ctypes.Structure 声明一个和 C 结构体对应的类,用 byref 传指针让 C 函数填写。最后一行是关键的对照:C 里 sizeof(Rec) 是 24,value 的偏移是 8,ctypes 算出来也完全一致——13 字节的字段被对齐填充到 24 字节。字段顺序或类型写得和 C 不一样,读到的就是错位的数据。
排序前: [5, 2, 9, 1, 7, 3, 8, 4] 排序后: [1, 2, 3, 4, 5, 7, 8, 9] | C 的 qsort 一共回调了 Python 比较函数 17 次; 前 3 次: [(5, 7), (7, 4), (5, 4)]
用 CFUNCTYPE 声明回调的类型,把 Python 函数包装后传给 C 的 qsort。8 个整数的排序里,C 一共回调了 Python 的比较函数 17 次,最后数组原地有序。每次回调都在 C 和 Python 之间来回切换,所以回调密集的场景会慢。
Python 内置 abs(-5) 64 ns / 次 (含 lambda 调用) ctypes libc.abs(-5) 326 ns / 次 (含 lambda 调用) 对 100 万个整数求和: C 函数 0.30 ms | Python sum() 2.99 ms | Python for 循环 52.94 ms (结果相同: True)
调用一次 libc.abs 要 326 ns(含外层 lambda),而 Python 自带的 abs 只要 64 ns——ctypes 每次调用要做参数转换,不适合频繁调用「很小」的函数。但把整块数据一次交给 C:100 万个整数求和,C 函数 0.30 ms,sum() 3.0 ms,for 循环 53 ms。调用次数少、每次工作量大才是 ctypes 的用法(注意 (c_int32 * N) 数组这里是事先造好的,构造它本身不算在内)。
子进程 stdout: 将要读地址 0 | 返回码: -11 (负数 = 被信号杀死, 11 = SIGSEGV) | stderr 最后一行: (无)
读地址 0:Python 层没有 Exception 可以捕获,进程直接被 SIGSEGV(信号 11)杀死。ctypes 绕过了 Python 的类型和内存安全,任何一处 argtypes、结构体布局、指针生命周期写错,都可能表现为随机崩溃。上线用之前,请一定给每个函数声明 argtypes 和 restype。
ctypes,Apple clang 21 编译 C 库cffi/Cython 与 ctypes 的性能对比、回调对象被垃圾回收后 C 仍持有指针会怎样