1. 程式人生 > >Python使用Ctypes與C/C++ DLL檔案通訊過程介紹及例項分析

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。這裡可以先思考以下幾個問題:(可以引用靜態庫麼?TODO)

  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 [email protected] func\n");
19     return a + b;
20 }
21 
22 //DLL_EXPORT double my_add(double a, double b) {
23 //    printf("calling [email protected] 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和[email protected]

3.2 python引用DLL

先使用VS自帶的dumpbin工具看一下Project2.dll檔案部分內容:

dumpbin -exports "./Debug/project2.dll"

ordinal hint RVA      name

1    0 00018000 [email protected]@3PADA
2    1 00011217 [email protected]
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, '[email protected]@3PADA').value    # result: gl_str
10 
11 # [C++] DLL_IMPORT void __stdcall hello_world(void);
12 getattr(my_lib, '[email protected]')()    # 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呼叫動態庫的過程。其他的庫原理是一樣的,只不過進行了更高階的封裝而已。