Python ctypes 使用笔记

  2022 年 02 月 22 日   Python  

Python 标准库中的 ctypes 模块可以用来从 Python 中调用 C/C++ 写好的、编译到动态链接库(.so.dylib.dll)中的函数。也就是大家常说的 Foreign Function Interface(简称 FFI,即不同语言之间相互的函数调用)。

ctypes 基于 libffi 实现,它主要提供了 Python → C/C++(或反过来)之间数据类型的转换

据我所示,著名的深度学习开源项目 MXNet 和 TVM 就是使用 ctypes 来帮助他们实现了 FFI 系统,使得用户可以从 Python 中调用 C++ 代码编译出来的函数。

一、先要了解的概念:调用约定(Calling Conventions)

先设想这样一个问题:给你一个编译好的共享库文件(比如 .so 文件),你怎么调用其中的某个函数,比如 printf

这就牵涉到以下这些问题:

这些问题属于应用二进制接口(ABI)的一部分。只有把这些规定好,我们才能正确调用已编译机器代码中的函数。这就是调用约定

在 x86 架构的微处理器上,有两种常用的调用约定,包括:

  1. cdecl (C declaration),源于 C 语言,通常作为 x86 C/C++ 编译器的默认调用规则。
  2. stdcall,由微软创建的调用约定,是 Windows API 的标准调用约定。

和 ctypes 有什么关系?

后面要讲的 ctypes 模块中的 cdll 对象就是能够解析 cdecl 调用约定,用来打开 Linux 中的 .so 文件和 Windows 上的一些“现代的” DLL 文件(如 C 标准库 msvcrt.dll),而 windll 对象则能够解析 stdcall 调用约定,用来打开 Windows 上的一些传统的 .dll 文件(如 kernel32.dll)。

二、加载动态链接库

Windows 上,可以直接通过 ctypes 模块中的 cdll 对象或 windll 对象的属性来访问动态链接库,如:

>>> from ctypes import *
>>> print(windll.kernel32)
<WinDLL 'kernel32', handle ... at ...>
>>> print(cdll.msvcrt)
<CDLL 'msvcrt', handle ... at ...>
>>> libc = cdll.msvcrt

而在 Linux 上,则需要提供动态链接库的文件名,使用 cdllLoadLibrary() 方法或 CDLL() 函数来手动加载:

>>> cdll.LoadLibrary("libc.so.6")
<CDLL 'libc.so.6', handle ... at ...>
>>> libc = CDLL("libc.so.6")
>>> libc
<CDLL 'libc.so.6', handle ... at ...>

快速找到某个库

如果你不知道某个库具体的文件名,但知道库的名字(如 C 标准库,libc,名字就是 c),可以使用 ctypes.utils 中的 find_library() 函数来搜索某个库在此电脑上的名字:

>>> from ctypes.util import find_library
>>> find_library("c")
'/usr/lib/libc.dylib'

上面的演示说明在我的 Macbook 上,C 标准库是 /usr/lib/libc.dylib 这个文件。可以直接把这个字符串作为参数传给 CDLL(),用来打开 C 标准库:

>>> libc = CDLL(find_library("c"))

三、调用动态链接库中的函数

可以通过已加载的库的属性来调用其中的函数,比如调用 C 标准库中的 time() 函数:

>>> libc.time()
1645494603

传入参数的类型

这里没有传参数进去。如果要传参数,ctypes 默认支持传入以下类型的参数:

如果要传入其它类型,如浮点数,则需要借助 ctypes 定义的一些 C 兼容的数据类型

一般 ctypes 中定义的 c_xxx 类型就是 C 语言中的 xxx 类型,前缀 u 表示 unsigned,后缀 p 表示指针。如:

ctypes 数据类型 对应的 C 中的数据类型
c_bool _Bool
c_char char
c_short short
c_float float
c_double double
c_char_p char*
c_void_p void*

返回值的类型

同时需要注意的是,刚才的 time() 例子也没有设置函数的返回类型。默认情况下,ctypes 认为这个函数返回整型。如果不是,就需要手动为这个函数设置一下返回值的类型,否则会报错。

如:数学库 libm 中的 exp() 函数接受一个 double 类型的参数,返回值也是 double 类型,就需要这样:

>>> libm = ctypes.CDLL(find_library("m"))  # 加载数学库 libm
>>> libm.exp.restype = ctypes.c_double     # 通过设置函数的 restype 属性来设置返回值类型
>>> libm.exp(ctypes.c_double(1.0))         # 调用 exp,传入一个 double 类型的 1.0
2.718281828459045

指针类型:pointer(), POINTER()byref()

这里专门说一下指针类型。前面提到 ctypes 中定义了一些指针类型,如 c_char_p 代表 char*,但如果要表示 int* 呢?ctypes 并没有定义 c_int_p 类型,但我们可以使用 ctypes 中的 pointer() 函数来构造指向某个对象的指针,类似 C 语言中的 & 取地址操作

>>> from ctypes import *
>>> i = c_int(42)
>>> pi = pointer(i)  # pi 就是指向 i 的指针

pointer() 函数底层其实是使用了 ctypes 中的 POINTER() 函数,先构造了一个指向某种类型的指针类型

>>> PI = POINTER(c_int)
>>> PI
<class 'ctypes.LP_c_long'>

然后用这个 PI 类型去构建指针变量:

>>> pi = PI(c_int(42))  # pi 指针指向整数 42

如果你传参的时候本来就是要传入某个对象的地址,那推荐使用 byref() 而非 pointer(),因为 byref() 不需要创建中间的临时类型 PI,而是直接取某个对象的地址,效率更高:

>>> i = c_int()
>>> f = c_float()
>>> libc.scanf(b"%d %f", byref(i), byref(f))
# 上面等价于 C 语言中的 scanf("%d %f", &i, &f);

四、高级功能与特性

结构体

TODO