1. 程式人生 > >Lua C API 的正確用法【written by 雲風】

Lua C API 的正確用法【written by 雲風】

當 lua 發生了未捕獲的異常時,會呼叫 panic 函式,然後呼叫 abort() 退出程序。一個補救的方法是在框架的最外層設定一個恢復點:C 語言用 setjmp ,C++ 用 try catch 。在 lua_atpanic 設定的 panic 方法中,直接跳轉到恢復點( C 語言用 longjmp ,C++ 用 throw )讓 panic 函式永不返回。但這並非推薦的手法,按 Lua 作者 Roberto 的說法,“The panic mode is only for ill-structured Lua programs.”。

當你只用 C 編寫 Lua 的庫時,即用一個現成的,考慮完備的宿主程式(比如 Lua 官方的直譯器)時,這個問題通常不必考慮。因為你呼叫 Lua C API 的 C 程式碼塊都是直接或間接被 Lua 呼叫的。但把 Lua C API 遍佈在宿主程式中時卻很容易忽視。完善的做法是,你應該把你的業務邏輯寫到一個 lua_CFunction

 中,然後用 lua_pcall 去呼叫它。而這個程式碼塊的引數則應該用 void * 通過 lua_pushlightuserdata 來傳遞。

這就是為什麼,之前版本的 Lua 都提供了一個叫 lua_cpcall 的 C API 的緣故。而在 Lua 5.2 支援了 light c function 後,對於無 upvalue 的 C function 都可以無額外成本的通過 lua_pushcfunction 傳入 lua vm ,所以就不再需要一個單獨的 lua_cpcall 了。

最好的範例是 Lua 官方的直譯器 的實現:你現在應該明白,為何主邏輯被寫在一個叫 pmain 的函式中,而不是直接在 main 裡實現了吧。

前面提到 lua_pushlightuserdata 不會丟擲異常,同樣的其它簡單值型別,lua_pushboolean lua_pushinteger 等都不會。這是因為這些 api 是不檢查 lua 的堆疊容量的,也不會主動按需擴充套件 Lua 棧。不過 lua_pushstring 這種需要構造新物件的 API 則可能引發 OOM (out of memory)異常,需要留意。lua 只保證在從 Lua 進入 C 的邊界上提供額外的 LUA_MINSTACK 個 slot 。這個值預設為 20 ,一般是夠用的。正因為一般夠用,反而容易被編寫 C 擴充套件的同學忽視。尤其是在 C 擴充套件的程式碼裡有 C 層次上的遞迴時,非常容易在邊界情況下棧溢位。因為 Lua 的 stack 實際上又經常留出超過 LUA_MINSTACK

 的空間,這種 bug 不易察覺。記住:如果你在 C 擴充套件中做複雜的事情,一定要記得在使用 lua stack 前,用 luaL_checkstack 留夠你需要的空間。

在用 C 編寫 Lua 的 C 擴充套件庫時,由於 C API 有丟擲異常的可能,你還需要考慮,每次當你呼叫 Lua API 時,下一行程式都有可能執行不到。所以一旦你想臨時申請一些堆記憶體使用,要充分考慮你在同一函式內編寫的釋放臨時物件的程式碼很可能執行不到。正確的方法是使用 lua_newuserdata 來申請臨時記憶體,如果被異常打斷,後續的 gc 流程會清理它們。luaL_Buffer 相關的庫就是基於這個做的。或者你自己有辦法通過池回收也可以,總之需要考慮這點。

基於同樣的理由,如果你構造了一個 C 物件,那麼在呼叫其它 Lua C API 之前,應該把物件中的所有欄位都清零(設定成合法的初始值),避免通過 Lua C API 一個個欄位設定。比如:

struct foobar {
  const char *a;
  const char *b;
}

...

struct foobar * f = lua_newuserdata(L, sizeof(*f));

... // 一些其它工作

f->a = lua_tostring(L, 1);
f->b = lua_tostring(L, 2);

這樣寫就是有風險的。因為,第一次呼叫 lua_tostring 時有可能因為異常而執行不到下一行,導致 f->b 沒有被初始化。正確的做法是:

struct foobar * f = lua_newuserdata(L, sizeof(*f));
f->a = NULL;
f->b = NULL;

... // 一些其它工作

f->a = lua_tostring(L, 1);
f->b = lua_tostring(L, 2);

如果你仔細閱讀過 lua 的原始碼,會發現 Lua 內部實現中也經常用這種慣例寫法。這裡使用 newuserdata 可以迴避大多數初始化失敗的問題,但你要確信在 c 物件正確初始化之後才能給將 f 或對應的 lua 物件傳遞到別處,以及給 userdata 增加 metatable 。

當宿主語言本身支援異常時,讓宿主語言的異常機制和 Lua 自身的異常機制協同工作是一個難題。想不侵入 Lua 自身的實現而靠庫自身協調兩種異常機制是幾乎不可能的。為了解決這個問題,Lua 允許你在構建庫的時候定義一系列的巨集來用宿主語言的異常機制來實現 Lua 的異常傳播。

看 ldo.c 前面的 LUAI_THROW LUAI_TRY 等巨集就是做的這個事情。所以,如果你用 C++ 做宿主語言,就應該用 C++ 編譯器編譯 Lua 庫。如果你直接用 C 編譯出來的庫,連結到 C++ 程式中(或共用已編譯好的 lua 動態庫),那麼表面上工作會一切正常。可一旦涉及異常處理,就會有很多未知的問題。

這個問題是這樣造成的:

Lua 在內部發生異常時,VM 會在 C 的 stack frame 上直接跳至之前設定的恢復點,然後 unwind lua vm 層次上的 lua stack 。lua stack (CallInfo 結構)在捕獲異常後是正確的,但 C 的 stack frame 的處理未必如你的宿主程式所願。也就是 RAII 機制很可能沒有被觸發。

btw ,Lua 的 stack frame 並不一一對應 C 的 stack frame ,即並不是一次 Lua 層的函式呼叫就對應一層 C 函式呼叫,當你在 Lua 層上 pcall 一個 lua 函式中再 pcall 一個 lua 函式,也不是直覺上的做兩層 try catch 。Lua 的這種實現和 Lua 的語言特性,尾遞迴以及 coroutine 有關。如果想在 pcall 的內部 coroutine.yield 回 C 層,就絕對不能讓 Lua 的函式呼叫對應到 C 函式呼叫上,否則 coroutine 就無法 resume (因為在 C 層上跳回恢復點,就破壞了 C 層的 stack frame ,無法重建)。這也是為什麼不能簡單的讓 Lua 內部實現的異常機制簡單相容宿主語言的緣故。

換句話說,即使你用 try catch 重新編譯了 lua 庫。當你在 lua_pushstring 這種可能丟擲異常的 lua api 外主動 try catch ,這個異常你可以捕獲到(因為指定 lua vm 的實現也使用它),但卻會破壞 lua vm 本身的工作。

強調:你不能用 throw 代替 lua_error 來丟擲異常,也不能用 try catch 來取代 lua_pcall 。在 Lua VM 實現層面換成 C++ 的異常機制,並不等於 lua 和 C++ 擁有了等價的異常傳播系統。當你明白有些 lua api 會丟擲異常,並且這個異常是以 C++ 的 throw 丟擲的;你同時也應該明白,自行用 C++ 的異常捕獲機制來包起這些 lua api 的呼叫,試圖捕獲異常是錯誤的做法。

在 C++ 中嵌入 Lua 後,讓 C++ 編寫的擴充套件庫正確運作的問題很好解決(單獨構建一個 C++ 版的庫即可),但當你在多種語言中互動,以 C/C++ 中媒介時,這個問題就複雜的多。比如說,近年來流行用 Unity3D 開發遊戲,並在 mono 虛擬機器中嵌入 Lua 來編寫遊戲邏輯,就涉及 lua mono C 三者之間的溝通。mono 本身也有自身的虛擬機器,恐怕你很難將 lua 自身實現中用到的 LUAI_THROW LUAI_TRY 替換為 mono 的異常實現。所以,當你用 C# 編寫程式碼轉換為 Lua 可以呼叫的函式時,應該避免 C# 的異常漏到 Lua 的 VM 中。反過來,lua 異常也一定要在 lua 層面或 C 層面截獲住。這些要實現的非常小心,所以不建議直接把 lua C api 一一對映成 C# 函式,用 C# 來直接操作 lua state ,那樣是很難寫的完備的。

考慮到 mono 本身就是 C 實現的,Lua API 的異常傳播在大部分情況下都可以在 mono vm 里正常工作(如果你把 mono 也看成是 C 編寫的模組的話),但當異常發生時(Lua 程式和 C 程式不一樣,很多情況下依賴異常傳播),即使在 Lua 層捕獲,只要中間穿越了 C# 程式碼,那麼一些副作用卻是很難察覺的。這是因為 lua 的 VM 實現是直接用 longjmp 做 C 的 stack frame unwind 的,mono vm 並不能感知。危險正在於 99% 的情況下都工作正常,偶爾不正常卻很難發覺。

如果完全用 C# 來重新實現一遍 Lua 可以完備的解決這個問題,UniLua 就是這樣一個專案。這樣做的缺點是效能堪憂。畢竟同樣的事情,C# 比原生程式碼要慢的多。

如果你在意效能,那麼還是可以把 Lua 編譯成原生庫,然後匯出介面給 C# 使用的,這樣的專案也很多,就不一一列舉了。但使用時應該注意,應該避免在低層次去操作 Lua_State ,而應該封裝出簡單的幾個高層介面。直接讓 C# 程式碼去讀寫 Lua State 中的資料結構就是不可取的。幾乎所有的對 Lua State 的 C API 都有異常處理的問題。簡單封裝這些 C API 成 C# 函式,要麼考慮不完備要麼效率低下(在過低層次上編碼造成的問題)。

讓我們把 Lua VM 和 mono VM 互動看成是兩個黑盒間的互動,其實這和不同程序,不同機器,不同服務間的互動本質上並沒有什麼區別。問題是不是變得熟悉起來?其實就是相互發送訊息的過程。我們要做的僅僅是講訊息編碼,訊息傳遞,讓對方處理。不要過於考慮訊息傳遞過程中的效能開銷,承認一定的開銷,可以提供更大的彈性,和設計介面上的簡潔。真正要考慮的其實是怎麼儘量減少互動的頻率。

其實我們要做的僅僅是把 C# 函式按統一的規格註冊到 Lua VM 中供其呼叫(甚至只有一個單一介面讓 Lua 傳送訊息出來),給 C# 提供一個方法可以呼叫 Lua 中的函式(或是向 Lua 傳送訊息,由 Lua 側將訊息轉換為函式呼叫)就可以了。考慮到這個過程其實是在同一程序(甚至同一執行緒)中進行的。訊息的編碼不一定是一個連續的字串,只要是雙方都可以編碼解碼的記憶體地址即可。

因為寫這篇 blog 正是我們自己的專案遇到了此類需求,所以我在寫文字的同時也為公司的同事編寫了一組示範程式碼。程式碼在 github 上 。它只完成了基本的功能,並只是一個 C 庫,但通過一些簡單的封裝就可以包裝成 C# 模組在 unity3d 的 mono 環境中使用。

ps. 本文提到的問題並不僅僅出現在 lua 的初學者,一些使用者眾多的將 lua api binding 到C 之外語言的庫在實現的時候都或多或少的有這裡談及的問題。

以 C++ 使用者使用較多的 luabind 為例,它所提供的 "Lua functions in C++" 特性就是不完備的。只是這個 C++ 庫實現的極其繁雜,看出並瞭解其中的問題(設計的侷限性)很不容易,而隱患又不容易出現,對使用者來說是個很大的威脅。(當然你非常清楚問題後,是可以從使用上規避容易出問題的用法的)

具體是這樣:想讓一個 lua 函式從 C++ 中被呼叫。luabind 提供了一個叫做 call_function 的方法,用起來倒是簡單,參看其文件 的 7.3 節。

一般說來,我們會從 host 程式中直接呼叫它,也就是呼叫 lua 函式並不在 lua 保護模式中。luabind 的實現考慮了這一點,所以 call_function 只會用 lua_pcall 而不會使用可能產生異常的 lua_call 。

問題出在獲得函式物件,處理引數,以及將返回值轉換為 C++ 物件上面。

抽絲剝繭理解其實現非常困難,所以我們只看其中明顯問題:

如果你提供了一個字串去定位全域性函式, 在 445 行可以看到:

lua_pushstring(L, name);
lua_gettable(L, LUA_GLOBALSINDEX);

return proxy_type(L, 1, &detail::pcall, args);

這裡的 lua_pushstring 和 lua_gettable 都是有可能丟擲異常的,但沒有在 pcall 的保護中(pcall 是在後面觸發的)。

當然,如果你不考慮 oom 錯誤,也不考慮全域性表有可能被人過載了 index 元方法而可能出錯。那麼這看起來還是個小問題。

ps. 在 pcall 前將引數壓入 lua stack 可能引發的 OOM 屬於同類問題,也暫時不考慮。

再來看一個更為嚴重的:

call_function 的返回值是等到 pcall 返回了以後,由 template 指定的 Ret 類來轉換為 C++ 物件的。

我們在 198 行 可以看到這個過程,是在呼叫 m_fun 也就是 pcall 之後的。即,從 lua 值到 C++ 物件的轉換並沒有被 pcall 保護起來。

為什麼說這個過程隱患很大?因為當你從 lua string 轉換為 C++ string 時,其實呼叫的是 lua_tostring (具體見luabind/detail/policy.capp )

這個 api 除了 oom 異常外,還有很多可能出錯。因為 lua 中所有物件都可以附加 tostring 元方法,在轉換為 string 時,會執行一段 lua 程式碼。這在 lua 程式中非常常見。

而正確的封裝方法應該是從 C++ 中呼叫 lua 函式時,引數的傳遞和返回值的接收和向 host 語言轉換都應該包含在一個lua_pcall 下,那個真正的 lua 函式呼叫使用 lua_call 即可。你便可以正確捕獲整個過程中 lua 程式碼裡的錯誤。