bigywater.tech
Python · 93

ctypes:Python 如何调用 C 动态库

不写一行 C 扩展,ctypes 就能让 Python 直接调用动态库里的函数。但它是「信任你」的:你告诉它函数接受什么、返回什么,它就照做,说错了也不报错。这篇用 libc 和一个自己编译的小 C 库,把它的能力和陷阱都真实跑一遍。

restype 不声明
sqrt(2.0) 返回 0 而不是 1.414…
17 次
C 的 qsort 对 8 个整数回调 Python 比较函数的次数
SIGSEGV
读地址 0:Python 没有异常,进程被信号直接杀掉(返回码 -11)

验证一:直接调用 libc

实测 · 本机执行输出
  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 与 restype
实测 · 本机执行输出
  libm.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 位。)

验证三:编一个 C 库来调用

数组、结构体、指针
实测 · 本机执行输出
  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 不一样,读到的就是错位的数据。

验证四:C 反过来调用 Python

回调函数
实测 · 本机执行输出
  排序前: [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。

分步演示:C 的 qsort 回调 Python 的比较函数

17 次回调的参数取自上面的真实记录
C 调用 Python 回调
此刻被比较的两个元素

    本文用到的工具与边界

    • 本机 macOS 27 / arm64,Python 3.14.7,标准库 ctypes,Apple clang 21 编译 C 库
    • 没有验证:Windows 上的调用约定(WinDLL 与 CDLL 的区别)、cffi/Cython 与 ctypes 的性能对比、回调对象被垃圾回收后 C 仍持有指针会怎样
    京ICP备2026067502号-1京公网安备11010502063487号