項目中可能會經常用到第三方庫,主要是出於程式效率考慮和節約開發時間避免重覆造輪子。無論第三方庫開源與否,編程語言是否與當前項目一致,我們最終的目的是在當前編程環境中調用庫中的方法並得到結果或者藉助庫中的模塊實現某種功能。這個過程會牽涉到很多東西,本篇文章將簡要的介紹一下該過程的一些問題。 1.背景 ...
項目中可能會經常用到第三方庫,主要是出於程式效率考慮和節約開發時間避免重覆造輪子。無論第三方庫開源與否,編程語言是否與當前項目一致,我們最終的目的是在當前編程環境中調用庫中的方法並得到結果或者藉助庫中的模塊實現某種功能。這個過程會牽涉到很多東西,本篇文章將簡要的介紹一下該過程的一些問題。
1.背景
多語言混合編程可以彌補某一種編程語言在性能表現或者是功能等方面的不足。雖然所有的高級語言都會最終轉換成彙編指令或者最底層的機器指令,但是語言本身之間的千差萬別很難一言以蔽之,這對不同語言之間相互通信造成很大的障礙。
工作中需要用python完成一項功能,但是所有現有的python庫都不滿足需求。最終找到了一個開源的C++庫,編譯得到動態庫被python調用才完成工作需求。雖然整個過程耗時不多,但是期間碰到很多的問題,而且這些問題都很有思考價值。
除了這篇博文外,後續還將有一到兩篇文章通過具體的實例講解一下跨語言調用。
2.問題思考
在進行具體的介紹之前,先來思考一下調用外部庫或者自己實現庫所牽涉的一些一般性的問題。這樣或許實際中操作使用時會理解的更加深刻,遇到問題也能夠逐項的排查。
如果用C語言寫的庫調用了Linux的system call,縱使C本身是跨平臺的,那麼該庫也不可能在Window上被使用,即便我們能拿到源碼。這裡有兩個核心問題:
- 是否開源
- 是否跨平臺
如果庫的實現不依賴平臺,且開源,那就意味著很大可能能在當前項目中使用。為什麼是可能,因為即使庫的實現語言和當前項目語言一致,也可能因為語言版本差異或者標準迭代導致不相容。
最差的情況就是只能拿到編譯後的庫文件,且需在特定的平臺運行。
作為庫的開發者,最好是能夠開源且庫的實現不依賴於特定的平臺,這樣才能最大限度的被使用。
作為庫的使用者,最不理想的情況是庫可以在當前平臺使用,但是只能拿到靜態庫或者動態庫,且庫的實現語言和當前項目語言不一致。
多數情況是第三方庫是跨平臺的且能夠拿到源代碼。這樣的話如果兩者的實現語言一致,我們可以直接將第三方庫的代碼移植到當前的項目中;如果實現語言不一致,需要在當前平臺上將庫的源碼編譯出當前平臺上可用的庫文件,然後在當前項目中引用編譯生成的庫文件。
本文將先簡單的介紹在window平臺上,使用python 2.7 自帶的ctypes庫引用標準的C動態庫msvcrt.dll。這裡可以先思考以下幾個問題:
- python可不可以引用靜態庫?
- python中怎麼拿到DLL導出的函數?
- python和C/C++之間的變數的類型怎樣轉換,如果是自定義的類型呢?
- 怎麼處理函數調用約定(calling convention,eg:__cdecl,__stdcall,__thiscall,__fastcall)可能不同的問題?
- 如果調用DLL庫的過程中出現問題,是我們調用的問題還是庫本身的問題?應該怎樣快速排查和定位問題?
- 有沒有什麼現有的框架能夠幫我們處理python中引用第三方庫的問題呢?
- 對於自定義的類型(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的官方解釋。註意最後一句話,表示對於函數調用,在平臺不支持的情況下,語言中指定關鍵字或者編譯器轉換均可能無效。
接下的介紹中來我們將一一回答上面的問題。
3.導入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
接下來我們一一分析上面的這段代碼。
3.1 載入庫的方式
根據當前平臺分別載入Windows和Linux上的C的標準動態庫msvcrt.dll和libc.so.6。
註意這裡我們使用的ctypes.cdll來load動態庫,實際上ctypes中總共有以下四種方式載入動態庫:
- class
ctypes.
CDLL
(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False) - class
ctypes.
OleDLL
(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False) - class
ctypes.
WinDLL
(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False) - 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。
3.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的函數。
3.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類型。
3.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]
3.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,實際上不會這麼用。
3.6 小節
這裡簡單的介紹了一下ctypes如何和動態庫打交道。限於篇幅還有指針,引用類型和數組等的傳遞,以及自定義類型等沒有介紹。但是這一小結應該能對python引用動態庫過程有一個大致的認識。
更加詳細信息可以參考官網:ctypes
4. 自定義DLL文件導入
為了更好的理解python調用DLL的過程,有必要瞭解一下DLL的定義文件。
4.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文件。有以下幾點需要註意:
- 上面#pragma comment(lib, "../Debug/Project2.lib")中引用的是生成Project2.dll過程中產生的導出庫,並非靜態庫。
- __declspec聲明只在Windows平臺用,若是引用靜態庫,則不需要__declspec聲明。
- 不管動態庫還是靜態庫,除了用#pragma comment引用lib文件外,還可以在Conguration Properties>Linker>Input>Additional Dependencies中添加lib文件。
- 上面例子中我們導出和引用均聲明瞭extern "C",表示讓編譯器以C的方式編譯和鏈接文件。意味著導出的函數不支持重載,且函數調用約定為C和C++的預設調用約定__cdecl。
- DLL_EXPORT void __stdcall hello_world(void)指定了函數使用__stdcall的Calling Convention,該方式聲明優先於編譯器預設的__cdecl方式。
- 不同的調用約定不僅會影響實際的函數調用過程,還會影響編譯輸出函數的命名。比如函數hello_world以__cdecl方式和__stdcall方式輸出到DLL中的函數分別為hello_world和_hello_world@0。
4.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。
5 總結
先來看一下開始提問的問題,部分問題已經在文中說明。
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調用動態庫的過程。其他的庫原理是一樣的,只不過進行了更高級的封裝而已。