Python使用Ctypes与C/C++ DLL文件通信过程介绍及实例分析

项目中可能会经常用到第三方库,主要是出于程序效率考虑和节约开发时间避免重复造轮子。无论第三方库开源与否,编程语言是否与当前项目一致,我们最终的目的是在当前编程环境中调用库中的方法并得到结果或者借助库中的模块实现某种功能。这个过程会牵涉到很多东西,本篇文章将简要的介绍一下该过程的一些问题。

1.背景

多语言混合编程可以弥补某一种编程语言在性能表现或者是功能等方面的不足。虽然所有的高级语言都会最终转换成汇编指令或者最底层的机器指令,但是语言本身之间的千差万别很难一言以蔽之,这对不同语言之间相互通信造成很大的障碍。

工作中需要用python完成一项功能,但是所有现有的python库都不满足需求。最终找到了一个开源的C++库,编译得到动态库被python调用才完成工作需求。虽然整个过程耗时不多,但是期间碰到很多的问题,而且这些问题都很有思考价值。

除了这篇博文外,后续还将有一到两篇文章通过具体的实例讲解一下跨语言调用。

2.问题思考

在进行具体的介绍之前,先来思考一下调用外部库或者自己实现库所牵涉的一些一般性的问题。这样或许实际中操作使用时会理解的更加深刻,遇到问题也能够逐项的排查。

如果用C语言写的库调用了Linux的system call,纵使C本身是跨平台的,那么该库也不可能在Window上被使用,即便我们能拿到源码。这里有两个核心问题:

  • 是否开源
  • 是否跨平台

如果库的实现不依赖平台,且开源,那就意味着很大可能能在当前项目中使用。为什么是可能,因为即使库的实现语言和当前项目语言一致,也可能因为语言版本差异或者标准迭代导致不兼容。

 最差的情况就是只能拿到编译后的库文件,且需在特定的平台运行。

作为库的开发者,最好是能够开源且库的实现不依赖于特定的平台,这样才能最大限度的被使用。

作为库的使用者,最不理想的情况是库可以在当前平台使用,但是只能拿到静态库或者动态库,且库的实现语言和当前项目语言不一致。

多数情况是第三方库是跨平台的且能够拿到源代码。这样的话如果两者的实现语言一致,我们可以直接将第三方库的代码移植到当前的项目中;如果实现语言不一致,需要在当前平台上将库的源码编译出当前平台上可用的库文件,然后在当前项目中引用编译生成的库文件。

本文将先简单的介绍在window平台上,使用python 2.7 自带的ctypes库引用标准的C动态库msvcrt.dll。这里可以先思考以下几个问题:

  1. python可不可以引用静态库?
  2. python中怎么拿到DLL导出的函数?
  3. python和C/C++之间的变量的类型怎样转换,如果是自定义的类型呢?
  4. 怎么处理函数调用约定(calling convention,eg:__cdecl,__stdcall,__thiscall,__fastcall)可能不同的问题?
  5. 如果调用DLL库的过程中出现问题,是我们调用的问题还是库本身的问题?应该怎样快速排查和定位问题?
  6. 有没有什么现有的框架能够帮我们处理python中引用第三方库的问题呢?
  7. 对于自定义的类型(class 和 struct)是否能在python中被引用。

关于函数调用约定,有必要简单的提一下:

Calling Convention和具体的编程语言无关,是由编译器、连接器和操作系统平台这些因素共同决定的。

The Visual C++ compilers allow you to specify conventions for passing arguments and return values between functions and callers. Not all conventions are available on all supported platforms, and some conventions use platform-specific implementations. In most cases, keywords or compiler switches that specify an unsupported convention on a particular platform are ignored, and the platform default convention is used.

这是MS的官方解释。注意最后一句话,表示对于函数调用,在平台不支持的情况下,语言中指定关键字或者编译器转换均可能无效。

接下的介绍中来我们将一一回答上面的问题。

2.导入C标准动态库

先来简单看一下python中如何引用C的标准动态库。

 1 import ctypes, platform, time
 2 if platform.system() == 'Windows':
 3     libc = ctypes.cdll.LoadLibrary('msvcrt.dll')
 4 elif platform.system() == 'Linux':
 5     libc = ctypes.cdll.LoadLibrary('libc.so.6')
 6 print libc
 7 # Example 1
 8 libc.printf('%s\n', 'lib c printf function')
 9 libc.printf('%s\n', ctypes.c_char_p('lib c printf function with c_char_p'))
10 libc.printf('%ls\n', ctypes.c_wchar_p(u'lib c printf function with c_wchar_p'))
11 libc.printf('%d\n', 12)
12 libc.printf('%f\n', ctypes.c_double(1.2))
13 # Example 2
14 libc.sin.restype = ctypes.c_double
15 print libc.sin(ctypes.c_double(30 * 3.14 / 180))
16 # Example 3
17 libc.pow.restype = ctypes.c_double
18 print libc.pow(ctypes.c_double(2), ctypes.c_double(10))
19 # Example 4
20 print libc.time(), time.time()
21 # Example 5
22 libc.strcpy.restype = ctypes.c_char_p
23 res = 'Hello'
24 print libc.strcpy(ctypes.c_char_p(res), ctypes.c_char_p('World'))
25 print res

接下来我们一一分析上面的这段代码。

2.1 加载库的方式

根据当前平台分别加载Windows和Linux上的C的标准动态库msvcrt.dll和libc.so.6。

 注意这里我们使用的ctypes.cdll来load动态库,实际上ctypes中总共有以下四种方式加载动态库:

  1. class ctypes.CDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False)
  2. class ctypes.OleDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False)
  3. class ctypes.WinDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False)
  4. class ctypes.PyDLL(name, mode=DEFAULT_MODE, handle=None)

关于这几个加载动态库的方式区别细节可以参考一下官网的说明,这里仅简要说明一下。

 除了PyDll用于直接调用Python C api函数之外,其他的三个主要区别在于

  • 使用的平台;
  • 被加载动态库中函数的调用约定(calling convention);
  • 库中函数假定的默认返回值。

 也就是平台和被加载动态库中函数的调用约定决定了我们应该使用哪种方式加载动态库。

本例中我们在windows平台上使用的是CDLL而不是WinDll,原因是msvcrt.dll中函数调用约定是C/C++默认的调用约定__cdecl。

而WinDll虽然是可以应用于windows平台上,但是其只能加载标准函数调用约定为__stdcall的动态库。因此这里只能使用CDLL方式。

可以将上面的CDLL换成WinDll看一下会不会有问题。这里应该能够对函数调用理解的更加深刻一些了,同时也回答了上面第一小节中我们提问的问题4。

2.2 跨语言类型转换

 这里主要针对第一节提出的问题3。

我们是在python中调用C的函数,函数实参是python类型的变量,函数形参则是C类型的变量,显然我们将python类型的变量直接赋值给C类型的变量肯定会有问题的。

因此这里需要两种语言变量类型之间有一一转换的必要。这里仅仅列出部分对应关系(由于博客园的表格显示会有问题,因此这样列出,请见谅):

Python type        Ctypes type          C type

int/long             c_int             int

float             c_double           double

string or None        c_char_p           char * (NUL terminated)

unicode or None       c_wchar_p          wchar_t * (NUL terminated)

 通过Ctypes type中提供类型,我们建立了一种python类型到c类型的一种转换关系。

在看一下上面的例子Example 1。在调用C的函数时,我们传给C函数的实参需要经过Ctypes转换成C类型之后才能正确的调用C的函数。

2.3 设定C函数的返回类型

看一下上面的例子Example 2.

libc.sin.restype = ctypes.c_double

我们通过restype的方式指定了C(math 模块)函数sin的返回类型为double,对应到python即为float。显然函数的返回类型在DLL中是无法获取的。

开发人员也只能从库的说明文档或者头文件中获取到函数的声明,进而指定函数返回值的类型。

double sin (double x);
float sin (float x);
long double sin (long double x);
double sin (T x);           // additional overloads for integral types

上面是C++11中cmath中sin函数的声明。这里几个sin函数是C++中的函数重载。

libc.sin(ctypes.c_double(30 * 3.14 / 180))

由于调用之前指定了sin函数的返回类型ctypes.c_double,因此sin的调用结果在python中最终会转换为float类型。

2.4 假定的函数返回类型

由于我们在动态库中获取的函数并不知道其返回类型,因为我们只得到了函数的实现,并没有函数的声明。

在没有指定库函数返回类型的情况下,ctypes.CDLL和ctyps.WinDll均假定函数返回类型是int,而ctypes.oleDll则假定函数返回值是Windows HRESULT。

那如果函数实际的返回值不是int,便会按照int返回值处理。如果返回类型能转为int类型是可以的,如果不支持那函数调用的结果会是一个莫名其妙的数字。

time_t time (time_t* timer);

  上面的例子Example 4则默认将C类型time_t转为了python 的int类型,结果是正确的。

对于Example 3中我们不仅要指定函数pow的返回类型,还要转换函数的实参(这里很容易疏忽)。

因此在调用动态库之前一定要看下函数声明,指定函数返回类型。

到这里很容易想到可以指定函数的返回值类型,那能不能指定函数形参的类型呢?答案是肯定的,argtypes 。

printf.argtypes = [c_char_p, c_char_p, c_int, c_double]

2.5 可变string buffer

 上面的例子Exapmle 5中我们调用了C中的一个字符串拷贝函数strcpy,这里函数的返回值和被拷贝的对象均为正确的。

但是这里是故意这样写的,因为这里会有一个问题。

如果res = ‘Hello’改为res = ‘He’和res = ‘HelloWorld’,那么实际上res的结果会是‘Wo’和’World\x00orld’。

str_buf = ctypes.create_string_buffer(10)
print ctypes.sizeof(str_buf)                       # 10
print repr(str_buf.raw)                            # '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
str_buf.raw = 'Cnblogs'
print repr(str_buf.raw)                            # 'Cnblogs\x00\x00\x00'
print repr(str_buf.value)                          # 'Cnblogs'

这里我们可以通过ctypes.create_string_buffer来指定一个字符串缓存区。

使用string buffer改写Example 5:

libc.strcpy.restype = ctypes.c_char_p
res = ctypes.create_string_buffer(len('World') + 1)
print libc.strcpy(res, ctypes.c_char_p('World'))
print repr(res.raw), res.value                     # 'World\x00' 'World'

注意上面的res的类型是c_char_Array_xxx。这里只是为了介绍string buffer,实际上不会这么用。

2.6 小节

这里简单的介绍了一下ctypes如何和动态库打交道。限于篇幅还有指针,引用类型和数组等的传递,以及自定义类型等没有介绍。但是这一小结应该能对python引用动态库过程有一个大致的认识。

更加详细信息可以参考官网:ctypes

3. 自定义DLL文件导入

为了更好的理解python调用DLL的过程,有必要了解一下DLL的定义文件。

3.1 C/C++引用DLL

首先,作为对比我们看一下C/C++如何引用DLL文件的。下面的文件是 ./Project2/Source2.cpp

工程配置为:Conguration Properties>General>Configuration Types: Dynamic Library (.dll)

输出路径:./Debug/Project2.dll

 1 #include <stdio.h>
 2 #include <math.h>
 3 #include <string.h>
 4 
 5 #ifdef _MSC_VER
 6 #define DLL_EXPORT extern "C" __declspec( dllexport )
 7 #else
 8 #define DLL_EXPORT
 9 #endif
10 
11 __declspec(dllexport) char* gl = "gl_str";
12 
13 DLL_EXPORT void __stdcall hello_world(void) {
14     printf("%s Hello world!\n", gl);
15 }
16 
17 DLL_EXPORT int my_add(int a, int b) {
18     printf("calling my_add@int func\n");
19     return a + b;
20 }
21 
22 //DLL_EXPORT double my_add(double a, double b) {
23 //    printf("calling my_add@double func\n");
24 //    return a + b;
25 //}
26 
27 DLL_EXPORT int my_mod(int m, int n) {
28     return m % n;
29 }
30 
31 DLL_EXPORT bool is_equal(double a, double b) {
32     return fabs(a - b) < 1e-3;
33 }
34 
35 DLL_EXPORT void my_swap(int *p, int *q) {
36     int tmp = *p;
37     *p = *q;
38     *q = tmp;
39 }
40 
41 inline void swap_char(char *p, char *q) {
42     char tmp = *p;
43     *p = *q;
44     *q = tmp;
45 }
46 
47 DLL_EXPORT void reverse_string(char *const p) {
48     if (p != nullptr) {
49         for (int i = 0, j = strlen(p) - 1; i < j; ++i, --j)
50             swap_char(p + i, p + j);
51             //swap_char(&p[i], &p[j]);
52     }
53 }

 下面的文件是 ./Project1/Source1.cpp

工程配置为:Conguration Properties>General>Configuration Types: Application (.exe)

输出路径:./Debug/Project1.exe

 1 #include "stdio.h"
 2 #include "cstdlib"
 3 #pragma comment(lib, "../Debug/Project2.lib")
 4 
 5 #ifdef _MSC_VER
 6 #define DLL_IMPORT extern "C" __declspec( dllimport )
 7 #else
 8 #define DLL_IMPORT
 9 #endif
10 
11 DLL_IMPORT void __stdcall hello_world(void);
12 DLL_IMPORT int my_add(int, int);
13 DLL_IMPORT int my_mod(int, int);
14 DLL_IMPORT bool is_equal(double, double);
15 DLL_IMPORT void my_swap(int*, int*); 
16 DLL_IMPORT void reverse_string(char* const);
17 
18 __declspec(dllimport) char* gl;
19 
20 int main() {
21     int a = 0, b = 1;
22     char s[] = "123456";
23     hello_world();
24     my_swap(&a, &b);
25     reverse_string(s);
26     printf("DLL str gl: %s \n", gl);
27     printf("DLL func my_add: %d\n", my_add(1,2));
28     printf("DLL func my_mod: %d\n", my_mod(9, 8));
29     printf("DLL func my_comp: %s\n", is_equal(1, 1.0001) ? "true":"false");
30     printf("DLL func my_swap: (%d, %d)\n", a, b);
31     printf("DLL func reverse_string: %s\n", s);
32     system("pause");
33 }

 上面的这个例子已经清楚的展示了C/C++如何导出和引用DLL文件。有以下几点需要注意:

  1. 上面#pragma comment(lib, “../Debug/Project2.lib”)中引用的是生成Project2.dll过程中产生的导出库,并非静态库。
  2. __declspec声明只在Windows平台用,若是引用静态库,则不需要__declspec声明。
  3. 不管动态库还是静态库,除了用#pragma comment引用lib文件外,还可以在Conguration Properties>Linker>Input>Additional Dependencies中添加lib文件。
  4. 上面例子中我们导出和引用均声明了extern “C”,表示让编译器以C的方式编译和链接文件。意味着导出的函数不支持重载,且函数调用约定为C和C++的默认调用约定__cdecl。
  5. DLL_EXPORT void __stdcall hello_world(void)指定了函数使用__stdcall的Calling Convention,该方式声明优先于编译器默认的__cdecl方式。
  6. 不同的调用约定不仅会影响实际的函数调用过程,还会影响编译输出函数的命名。比如函数hello_world以__cdecl方式和__stdcall方式输出到DLL中的函数分别为hello_world和_hello_world@0。

3.2 python引用DLL

先使用VS自带的dumpbin工具看一下Project2.dll文件部分内容:

dumpbin -exports “./Debug/project2.dll”

ordinal hint RVA      name

1    0 00018000 ?gl@@3PADA
2    1 00011217 _hello_world@0
3    2 00011046 is_equal
4    3 0001109B my_add
5    4 000112D0 my_mod
6    5 00011005 my_swap
7    6 0001118B reverse_string

 话不多说,先上代码:

 1 import ctypes, platform, time
 2 if platform.system() == 'Windows':
 3     my_lib = ctypes.cdll.LoadLibrary(r'.\Debug\Project2.dll')
 4     # my_lib = ctypes.CDLL(r'.\Debug\Project2.dll')
 5 elif platform.system() == 'Linux':
 6     my_lib = ctypes.cdll.LoadLibrary('libc.so.6')
 7 
 8 # [C++] __declspec(dllexport) char* gl = "gl_str";
 9 print ctypes.c_char_p.in_dll(my_lib, '?gl@@3PADA').value    # result: gl_str 10 
11 # [C++] DLL_IMPORT void __stdcall hello_world(void);
12 getattr(my_lib, '_hello_world@0')()    # result: gl_str Hello world! 13 
14 # [C++] DLL_IMPORT int my_add(int, int);
15 print my_lib.my_add(1, 2)         # result: 3                 
16 
17 # [C++] DLL_IMPORT int my_mod(int, int);
18 print my_lib.my_mod(123, 200)    # result: 123 19 
20 # [C++] DLL_IMPORT void my_swap(int*, int*); 
21 a, b = 111, 222
22 pa, pb = ctypes.pointer(ctypes.c_int(a)), ctypes.pointer(ctypes.c_int(b))
23 my_lib.my_swap(pa, pb)
24 print pa.contents.value, pb.contents.value  # result: 222, 111 25 print a, b    # result: 111, 222 26 
27 # [C++] DLL_IMPORT bool is_equal(double, double);
28 my_lib.is_equal.restype = ctypes.c_bool
29 my_lib.is_equal.argtypes = [ctypes.c_double, ctypes.c_double]
30 # print my_lib.is_equal(ctypes.c_double(1.0), ctypes.c_double(1.0001))
31 print my_lib.is_equal(1.0, 1.0001)    # result: True 32 print my_lib.is_equal(1.0, 1.0100)    # result: False 33 
34 # [C++] DLL_IMPORT void reverse_string(char *const);
35 s = "123456"
36 ps = ctypes.pointer(ctypes.c_char_p(s))
37 print ps.contents    # result: c_char_p('123456') 38 my_lib.reverse_string(ctypes.c_char_p(s))
39 print ps.contents, s  # result: c_char_p('654321') 654321

 上面的代码加上注释和结果已经很详细的说明了python引用DLL的过程,限于篇幅,这里就不在赘述。

有一点需要强调,我们使用__stdcall方式声明函数hello_world方式,并且用CDLL方式引入。导致无法直接用lib.func_name的方式访问函数hello_world。

如果想要使用my_lib.hello_world的方式调用该函数,只需要使用windll的方式引入DLL,或者使用默认的__cdecl方式声明hello_world。

4 总结

先来看一下开始提问的问题,部分问题已经在文中说明。

1.python可不可以引用静态库?

首先,静态库是会在链接的过程组装到可执行文件中的,静态库是C/C++代码。

其次,python是一种解释性语言,非静态语言,不需要编译链接。

最后,官网好像没有提供对应的对接模块。

5.如果调用DLL库的过程中出现问题,是我们调用的问题还是库本身的问题?应该怎样快速排查和定位问题?

python中怎么定位问题这个不多说。

DLL中的问题可以使用VS的attach to process功能,将VS Attach 到当前运行的python程序,然后调用到DLL,加断点。

6.有没有什么现有的框架能够帮我们处理python中引用第三方库的问题呢?

常用的有ctypes,swig, cython, boost.python等

7.对于自定义的类型(class 和 struct)是否能在python中被引用。

至少ctypes中没有相关的操作。

其实也没必要,因为不仅python中没有对应的类型,而且完全可以通过将自定义的类或者结构体封装在DLL输出的函数接口中进行访问等操作。

总结:

本文使用python自带的库ctypes介绍了如果引用动态库DLL文件,相对于其他的第三方库,这是一个相对比较低级的DLL包装库。但正是因为这样我们才能看清楚调用DLL过程的一些细节。

使用ctypes过程遇到的每一个错误都可能是一个我们未知的知识点,因此建议先熟悉该库,尽可能深入的了解一下python调用动态库的过程。其他的库原理是一样的,只不过进行了更高级的封装而已。