2022-02-21
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 架构的微处理器上,有两种常用的调用约定,包括:
- cdecl (C declaration),源于 C 语言,通常作为 x86 C/C++ 编译器的默认调用规则。
- 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 上,则需要提供动态链接库的文件名,使用 cdll
的 LoadLibrary()
方法或 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 默认支持传入以下类型的参数:
None
,传入的时候被解释成 C 语言中的 NULL;bytes
和str
,传入的时候被解释为指向该内存空间的指针;int
,传入的时候被解释为 C 语言中的整型;
如果要传入其它类型,如浮点数,则需要借助 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