因為聽到了兩聲雷,所以要發兩篇博客。

——魯迅

 

這篇博客來聊聊LuaJIT FFI裏面ctypes的實現。

FFI全稱是Foreign Function Interface即異世界語言接口,LuaJIT中使用FFI可以調用其他語言編譯的庫。

一個示例如下:

以上示例會輸出

991970
0
tv_sec in timeval offset:0
tv_usec in timeval offset:4

有關FFI的具體語法不想寫了,感興趣的朋友可以看LuaJIT關於FFI的四篇文檔。看完之後可以再看看同事的一個講座

在上面這個示例中,我們定義了time_t和suseconds_t作為int_t的別名,timeval和timezone兩個結構體,和gettimeofday這個函數的簽名。簡單地說,FFI首先會在當前進程映像中找到gettimeofday的偏移,由於這個C函數在libc中實現,所以一定會在當前映像中找到。根據函數簽名,將輸入值按定義的數據類型長度壓入棧中;然後,當取值時,FFI根據當前平台和架構計算結構體中每個成員的偏移量,然後直接取出內存中對應偏移下對應字長的數據。

雖然ffi.cdef定義時的語法和C一樣,但是FFI並沒有真正編譯它,而只是將它們按規則轉換成偏移量,並且記錄下來。

不論是enum,typedef還是function,LuaJIT FFI都用一種ctypes來表示它。為了方便用戶不用重新定義像uint32_t這樣的類型,LuaJIT自帶了95種初始的ctypes。在這裡推薦一個工具parseback,可以方便查看各個已定義的ctypes的類型。這個工具依賴的是一個沒有文檔的方法ffi.typeinfo:

輸出:

可以看到許多關鍵詞也被加入了初始列表中,這是為了防止熊孩子亂用關鍵詞做類型名稱。

parseback工具還能生成一個dot圖來描述ctype,我們來看看之前的gettimeofday是怎麼表示的:

因為OpenResty也定義了一堆FFI類型,所以我們用一個循環來尋找ctypes表裏面的最後一個類型,然後用以下命令

resty test.lua | dot -Tpng > 1.png

得到這樣的圖:

 

這個圖好像有點複雜,我們先看看其中id為460的timeval結構體。

首先它的類型是CT_STRUCT,代表它是一個結構體的開始。

它的size是8,FFI通過這個屬性知道當ffi.new(“timeval”)被調用時,需要分配多少內存。

它的cid(child ID)是0,這裡先跳過。

它有一個sid(sibling ID)屬性,值為461,它代表單鏈表的下一個成員。timeval由兩個成員構成,分別是tv_sec和tv_usec;兩個成員都有指為10的cid,對CT_FIELD來說,cid代表這個成員的類型,在這裡它們都是size為4的CT_NUM。LuaJIT不會在類型上區分int,uint,long,它們都是CT_NUM,區別在於size和unsigned標記。如id為10的類型就是一個unsigned int。tv_usec的offset是4,代表它在結構體里的偏移量;當取timeval的tv_usec成員時,FFI跳過4位元組,取它的類型長度也就是4位元組內存。

 

我們再回到前一張複雜的圖。

gettimeofday是個CT_FUNC類型的ctype,也就是function;當一個ctype是CT_FUNC時,它的cid表示返回值的類型,這個例子里是9,也就是signed int。

對一個CT_FUNC來說,sid代表參數列表的類型。sid值是469,指向了名為tv的CT_FIELD,它代表一個成員名稱;LuaJIT在存儲函數類型時,其實和存儲一個結構體是類似的。id為469的CT_FIELD的cid為468,表示它的類型是468;468是一個指針CT_PTR,指向id是460的timeval結構體CT_STRUCT。連起來就是說,它是一個*timeval。

 

理解了這個之後,我們來看下面的故事。LuaJIT FFI中,你可以把一個lua函數作為回調函數傳回FFI中,例如:

當然在C里,你也可以:

這樣在FFI里可以寫成:

看起來沒問題對吧,但是我們如果我們多次循環ffi.cast:

最終會報錯”table overflow”,這個錯誤來自https://github.com/LuaJIT/LuaJIT/blob/1e66d0f9e6698fdee672c40a9a5b4159c9254091/src/lj_ctype.c#L158,因為某種原因存儲ctypes的表被填滿了。它的上限是65535。我們來看看它被什麼填滿了:

是一個匿名指針,指向一個匿名函數,它的返回值是void。毫無疑問我們的ffi.cast(“callback2*”, …)會在每次調用時創建兩個新的ctype。

在LuaJIT的代碼里,ffi.cdef,ffi.new,ffi.cast都會經過同一段C parser的代碼段lj_cparse的cp_decl_intern函數里,因為除了在cdef中定義類型外,我們也可以在new和cast里定義匿名結構體,所以這些函數都能產生新的ctype。

這個函數里,當一個符號被認定為function,它就會無條件地新建一個新的ctype。在我們的寫法里,typedef void (callback2)(int param);定義了一個名為callback2的「函數類型」,然後在ffi.cast里,cparser解析了callback2,為它創建了一個新的CT_FUNC類型,然後解析了*,新建了一個指針指向新的CT_FUNC。

實際上,LuaJIT確實是可以通過一些額外的標誌位來使ffi.cast不會創建新的函數類型的。CType這個類型可以增加一個新的成員,來記錄自己的id,通過這種辦法,當解析為CT_FUNC時,如果這個id已賦值,則可以直接使用已有的ctype。我嘗試了一個簡單的patch來驗證我的想法,證明了它的可行性;但是沒有跑完整的測試集。

我在issues里提問了這個問題,Mike Pall小哥熱心地回復了我。當然作者選擇目前這種做法確實是更簡潔的;而且本身對「函數類型」的typedef,可以有不同角度的理解。所以以後記得寫回調函數的時候,定義一個正常的「函數指針」就好了。