故事是這樣子的,最近小弟接觸一項工作,主要是開發一套C 的API,實作大程式底層的介面,
以前只有改過別人的介面,這次自己從頭到尾把介面建起來,git repository 提交100多個commit,說實在蠻有成就感的。
寫Project的過程中也發現自己對測試的經驗實在不夠,本來想說該把unit test set 建起來然後做個 regression test,
結果unit test 寫一寫最後都變成behavior test 了,啊啊啊啊我根本不會寫測試啊,測試好難QQQQ
寫測試時也發現一個問題,用C 寫測試實在有點痛苦,陸續看了幾個例如 CMocka
的testing framework,實作起來還是挺麻煩的;
有些要測試的功能,例如檔案parser,都需要另外找library來支援,而C 的library 卻未必合用,要放入project 的Makefile 系統也很麻煩;
C 需要編譯也讓有彈性的測試較難達成。
這時我們的救星:Python 又出現了,是否能利用python上面豐富的模組跟套件,還有簡單易用的 testing framework,可以彈性修改的直譯執行來幫助測試呢?
一開始我看了一下Python C/C++ extension,這是將C的程式包成函式庫,並對它增加一層 Python 呼叫的介面,變成 Python 可以 import 的module。
但是這個方法有點太殺雞用牛刀了,這是要用在python 極需性能的部分,可以寫C 進行加速,而不是單純想 call C function 來測試。
比較好的解法是利用python 的 ctypes
,
可以直接載入編譯好的 shared library,直接操作C 函式。
引用參考資料程式設計遇上小提琴
的話:
與其做出pyd來給python使用這種多此一舉的事情,東西就在那裡,dll就在那裡,為何不能直接使用呢?
所以我們的救星:ctypes 登場了。
首先是要引用的對象,使用ctypes 前要先把要引用的project 編成單一的動態函式庫,下面以 libcoffee.so 為例, 如此一來,我們可以用下列的方式,將 .so 檔載入python 中:
filepath = os.path.dirname(os.path.abspath(__file__))
libname = "libcoffee.so"
libcoffee = ctypes.cdll.LoadLibrary(os.path.join(filepath, libname))
ctypes 有數種不同的載入方式,差別在於不同的 call convention
- cdll 用的是cdecl
- windll 跟 oledll 則是 stdcall
載入之後就可以直接call 了,有沒有這麼猛(yay)
參數處理:
ctypes 中定義了幾乎所有 C 中出現的基本型別,可參考說明文件表格
,
None 對應 C 的NULL
所有產生出來的值都是可變的,可以透過 .value 去修改。例外是利用Python string 來初始化 c_char_p()
,再用 value 修改其值,
原本的Python string 也不會被修改,這是因為Python string 本身就是不可修改的。
如果要初始化一塊記憶體,丟進C 函式中修改的話,上面的 c_char_p
就不能使用,要改用 create_string_buffer
來產生一塊記憶體,
可以代入字串做初始化,並指定預留的大小,爾後可以用 .value 來取用 NULL terminated string,.raw 來取用 memory block。
如果要傳入 reference ,除了 create_string_buffer
產生的資料型態本身就是 pointer 之外,可以用 byref() 函式將資料轉成 pointer。
使用 ctypes 呼叫C 函式,如果參數處理不好,會導致 Python 直接crash,所以這點上要格外小心。
我們可以為函式加一層 argtypes 的保護,任何不是正確參數的型態想進入都會被擋下,例如:
libcoffee.foo.argtypes = [c_int]
# 之前這樣會過,C function 也很高興的call 下去
libcoffee.foo(ctypes.c_float(0))
# 設定之後就會出現錯誤
ctypes.ArgumentError: argument 1: <type 'exceptions.TypeError'>: wrong type
這部分採 strict type check ,甚至比C 本身嚴格,如果設定argtypes 為:
POINTER(c_int)
那用 c_char_p
這種在C 中可以轉型過去的型態也無法被接受。
回傳值:
restype 則是定義 C 函式的 return type,因為我的函式都回傳預設的 c_int ,所以這部分不需要特別設定。
比較令我驚豔的是,ctypes 另外有 errcheck 的屬性,這部分同restype 屬性,只是在檢查上比較建議使用errcheck。
errcheck 可以設定為 Python callable (可呼叫物件,無論class 或function ),這個callable 會接受以下參數:
callable(result, func, args)
- result 會是從C 函式傳回來的結果
- func 為 callable 函式本身
- args 則是本來呼叫C 函式時代進去的參數
在這個callable 中我們就能進行進一步的檢查,在必要的時候發出例外事件。
def errcheck(result, func, args):
if result != 0:
raise Exception #最好自己定義 exception 別都用預設的
return 0
libcoffee.errcheck = errcheck
衍生型別:
ctypes 另外提供四種衍生型別
- Structure: C struct
- Union: C union
- Array: C array
- Pointer: C pointer
Struct, Union
每個繼承 Structure 跟Union 的 subclass 都要定義 _filed_
,型態為 2-tuples 的 list,
2-tuple 定義 field name 跟 field type,型態當然要是 ctypes 的基本型別或是衍生的 Structure, Union, Pointer 。
Struct 的align 跟byte order 請參考文件描述 , 一般是不需要特別設定:
Array
Array 就簡單多了,直接某個 ctypes 型態加上 * n 就是Array 型態,然後就能如class 般直接初始化:
TenInt = ctypes.c_int * 10
arr = TenInt()
Pointer
Pointer 就如上面所說,利用 pointer() 將 ctypes 型別直接變成 pointer,它實際上是先呼叫 POINTER(c_int) 產生一個型別,然後代入參數值。
爾後可以用 .contents 來取用內容的副本(注意是副本,每次呼叫 .contents 的回傳值都不一樣)和 C 一樣,
pointer 可以用 python 取值的 [n]
slice,並且修改 [n]
的內容即會修改原本指向的內容,
取用 slice 的時候也要注意out of range 的問題,任何會把C 炸掉的錯誤,通常也都會在執行時把 python 虛擬機炸了。
同樣惡名昭彰的Null pointer dereference:
null_ptr = POINTER(c_int)()
產生NULL pointer,對它 index 也會導致 Python crash。
Callback
ctypes 也可以產生一個 callback function,這裡一樣有兩種不同的型式:
- CFUNCTYPE:cdecl
- WINFUNCTYPE:stdcall
以第一個參數為 callback 的return type,其餘參數為 callback 參數。
這部分我這裡沒有用到先跳過,不過下面文件連結
有示範怎麼用ctypes 寫一個可被 qsort 接受的 callback 函式,真的 sort 一個buffer 給你看。
實際案例
上面我們把ctypes 的文件整個看過了,現在我們來看看實際的使用案例。
這裡示範三個 C function,示範用ctypes 接上它們,前情提要一下project 的狀況,
因為寫project 的時候消耗了太多咖啡了,所以project 的範例名稱就稱為 coffee,我們會實作下面這幾個函式的介面:
// 由int pointer回傳一個隨機的數字
int coffee_genNum(int *randnum, int numtype);
// 在buf 中填充API version string
int coffee_getAPIVersion (char *buf)
// 對src1, src2 作些處理之後,結果塞回到dest 裡面
int coffee_processBuf (char *src1, char *src2, char *dest, int len)
針對我要測試的 c header,就對它寫一個 class 把該包的函式都包進去,init 的部分先將 shared library 載入:
import ctypes
import os.path
class Coffee(object):
"""libcoffee function wrapper"""
def __init__(self):
filepath = os.path.dirname(os.path.abspath(__file__))
libname = "libcoffeeapi.so"
self.lib = ctypes.cdll.LoadLibrary(os.path.join(filepath, libname))
所有 header 檔裡面的自訂型別,都能很容易直接寫成 ctypes Structure,舉個例本來有個叫counter 的 Union,比對一下C 跟Python 版本:
C 版本:
typedef union Counter {
unsigned char counter[16];
unsigned int counter32[4];
} Counter;
Python ctypes 版本:
class Counter(Union):
_fileds_ = [
("counter", c_uint8 * 16),
("counter32", c_uint32 * 4)]
針對 C 裡面的函式,我們把它們寫成各別的 Python 函式對應,同時為了保險起見,每個函式都設定 argstype, 這樣在參數錯誤時就會直接丟出 exception;又因為函式都依照回傳非零為錯誤的規則,所以可以對它們設定 errcheck 函式, 在return 非零時也會拋出例外事件。
這裡的作法是在 class __init__
裡面把該設定的都寫成一個dict,這樣有必要修改的時候只要改這裡就好了:
def errcheck(result, func, args):
if result != 0:
raise Exception
return 0
# in __init__ function
argstable = [
(self.lib.coffee_genNum, [POINTER(c_int), c_int]),
(self.lib.coffee_getAPIVersion, [c_char_p]),
(self.lib.coffee_processBuf, [c_char_p, c_char_p, c_char_p, c_int])]
for (foo, larg) in argstable:
foo.argtypes = larg
foo.errcheck = errcheck
然後實作各函式,這部分就是苦力,想要測的函式都拉出來,介面的參數我就用Python 的物件,在內部轉成ctypes 的物件然後往下呼叫, 如 getNum 的轉化型式就會長這個樣子,pointer 的參數,可以使用 ctypes 的byref 。
# int coffee_genNum(int *randnum, int numtype);
def genNum(self, numtype):
_arg = c_int(numtype)
_ret = c_int(0)
self.lib.coffee_genNum(byref(_ret), _arg)
return _ret.value
getAPIVersion 是類似的,這次我們用 create_string_buffer
產生一個 char pointer,然後直接代入函式,就可以用value 取值了。
# int coffee_getAPIVersion (char *buf)
def getAPIVersion(self):
buf = create_string_buffer(16)
self.lib.coffee_getAPIVersion(buf)
return buf.value
最後這個的概念其實是一樣的,我把它寫進來只是要火力展示XD,上面提到的processBuf,實際上可以把它們跟Python unittest 結合在一起, 利用python os.urandom來產生完全隨機的string buffer,答案也可以從python 的函式中產生, 再用unittest 的assertEqual 來比較buffer 內容:
l = 4096
src1 = create_string_buffer(os.urandom(l-1))
src2 = create_string_buffer(os.urandom(l-1))
dest = create_string_buffer(l)
ans = generateAns(src1, src2)
self.lib.coffee_processBuf(src1, src2, dest, l)
self.assertEqual(dest.raw, ans)
一個本來在C 裡面要花一堆本事產生的測試函式就完成啦owo
如能將這個class 加以完善,等於要測哪些函式都能拉出來測試,搭配python unittest 更能顯得火力強大。
後記:
趁這個機會把ctype的文件整個看過一遍,覺得Python 的 ctypes 真的滿完整的,完全可以把 C 函式用 ctypes 開一個完整的 Python 介面, 然後動態的用 Python 執行,真的是生命苦短,請用python。