最近有個專案需要跨 Team 合作,我負責開發一個 DLL 讓他們 import。說起來有點汗顏,目前的工作大部分都在維護現有架構下的 code,沒太多經驗從頭建造一個給外部使用的模組,在這次的專案中採了一些坑、也學到一些經驗,這篇文章就紀錄一下當時開發 DLL 時學到的小技巧或是該注意的小地方囉!
開頭不免俗提一下,Dynamic-Link Library (DLL) 是一種 PE 格式的 binary file, 是 windows 開發用來加強模組化設計的可共享物件,透過程式碼重複使用能有效的提高記憶體使用率,模組間彼此能夠鬆散的組合、重用與升級,簡化部屬流程。
這本篇文章都是以 Visual C++ 的角度討論,其他編譯器的行為沒深入探討,如有錯誤歡迎大家底下留言指正,接下來就直接進入本篇的主題囉~
Name Mangling
C 語言不像 C++ 的函式有 overload 這種高階語言的概念,所以 C 語言的函式匯出時不會對函式名稱做任何修飾,也就是說你使用的函式名稱在符號表上是一致的。
然而 C++ 為了實現上面提到的特性,允許相同名稱的函式根據不同參數提供不同功能,compiler 為了能區隔出這些函式,勢必得在產生函式名稱的符號上動手腳。
Compiler 會根據 calling convention, namespace, parameter 等等資訊去修飾匯出的函式名稱,以我常用的 MSVC 的 compiler 舉例:
__declspec(dllexport) double Add(double a, double b)
上面的 function 輸出時會被修飾成:
由於不同編譯器如何去修飾函式名稱並沒有一致的規範,既使是相同的編譯器,不同版本間也會有差異,使得 dll 跟 compiler 存在嚴重的 dependency!
最簡單解決 name mangling 的方式就是使用 extern c
,明確的告訴 compiler 被 extern c 所標註的 function 匯出時使用 C 語言的方式產生符號,只要 caller 的編譯器可以相容於 c 語言就可以正常的運作。
剛剛的範例宣告 extern c 之後,輸出的符號會變成:
extern "C" __declspec(dllexport) double Add(double a, double b)
Calling convention
Calling convention 是用來規定函式呼叫時 caller 和 callee 的互動方式,例如 stack 是要由 caller 或 callee 清除,參數是如何被傳遞的等等。
DLL 匯出函式最簡單的方法是在函式前加上 __declspec(dllexport) 關鍵字:
__declspec(dllexport) double Add(double a, double b)
但上面的宣告隱藏了 calling convention 的細節,對於 C 和 C++ 的程式,MSVC default 的 calling convention 是採用 __cdecl ,如果你的 dll 只給相同語言的程式使用,預設情況下是沒有問題的!但假設今天你的 dll 要給 C# 或VB (default calling convention 採用 __stdcall ) 就會發生錯誤。
真實情況下沒辦法保證所有人的 calling convention 是一致的,如果希望 dll 能夠被廣泛使用在不同的環境下,可以替每個 export 的 function 明確加上 calling convention 的規範避免 caller 跟 callee 不一致的情況。
__declspec(dllexport) double __cdecl Add(double a, double b)
解決這個問題的方式有兩種,第一種是使用 .def 檔的方式把匯出的函式重新命名,避免了函式名稱被修飾得面目全非。
另外一個常見的解決方式就是使用 extern c
。上面提到 C 是採用 __cdecl 的 calling convention,編譯器不會對函式名稱進行任何修飾,這也是為什麼很多 dll 都會使用 extern c 這個關鍵字把所有 export 的 function 包起來,因為他同時解決了 name mangling 跟 calling convention 兩大問題!
Use Pure C Interface
之前接觸過的 dll 都是給內部使用,整個專案的編譯環境設定都是一致的,那時候使用起來一切正常,一直以來都沒特別去注意 interface 的細節。
直到這次開發時同事貼心體醒我,interface 要全部用 pure C 的方式輸出喔!我才突然驚覺原來有這樣的限制嗎?身為 std container 的重度使用者來說,一時還真有點驚慌失措啊!
會有這樣的限制是因為 c++ 的 ABI (application binary interface) 並沒有一致的標準,使用不同版本的 visual studio 編譯出來的 lib 也會碰到 ABI 不相容的狀況。
而 c 作為一個相當老牌的程式語言,ABI 則相對明確且穩定,相容性也比較高,對於跨平台、跨語言比較不容易出問題。
Data Alignment
有使用過 Windows API 的人,可能有看過不少 structure 裡會包含一個 cbsize 的參數用來表示此結構的大小,通常這個參數都是做為驗證的作用,用來確保傳入的結構符合預期。
而程式為了優化記憶體的存取效能,通常會對結構內的某些型態做 alignment ,然而如果被呼叫端 ( dll ) 與呼叫端 (exe)使用了不同的記憶體對齊方式時,驗證這個 structure 的大小就會有不同的差異,甚至嚴重點,會因為存取到非法的記憶體位址導致程式出錯。
為了避免這樣的問題,我們可以在 header 檔使用 pragma pack 的關鍵字去指定編譯器該使用多少 byte 去對齊 header 檔的資料結構
#pragma pack(push)
#pragma pack(1)// some user defined structure#pramga pack(pop)
DisableThreadLibraryCalls
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}
// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
DllMain 裡包含四種 case,如果你的 DLL 對當前程式創建新的 thread 不感興趣時,可以使用 DisableThreadLibraryCalls 關閉 DLL_THREAD_ATTACH 和 DLL_PROCESS_DETACH 避免掉不必要的操作。
[補充] DisableThreadLibraryCalls 有一些限制情境,例如不適用於 dll 使用 static CRT,使用前請仔細閱讀文件 Remarks 的地方!
Heap Allocation
在程式設計師的自我修養這本書裡有一個例子,有一個程式設計師開發了一個 api 介面是 char* SubString(int pos, int len)
這個 function 會傳回指向子字串的指標,但是 DLL 本身並不負責該指標的記憶體釋放工作,使用者須自行呼叫 delete/free 對他進行釋放。
當今天使用 DLL 的應用程式使用的 CRT Library 跟 DLL 本身不一致,就可能會導致程式 crash ,當你的使用者越廣泛,一定免不了會碰上 。
由於每個 CRT 會有獨立的 heap,會導致這兩個 module 使用到的 heap 空間是不同的,如果 dll 某個 export API 在內部 new 一塊記憶體空間並把指標傳給外部使用,當外部嘗試把這塊空間釋放掉時,就很有可能會產生 heap corrupt。
比較好的作法是當 DLL有 allocate 的 API 時,也得產生相對應的 de-allocate 的 API 給外部呼叫。
Reserve Parameter For Future Use
通常 API 開出去後定義好的介面就很難修改了,假設突然有想要額外補充的參數只能靠新增 API 取代, 為了避免之後因為參數的改變,導致需要頻繁的增加類似功能 API ,可以在每個 API 的加上 pReserved ( pointer to void ) 當作保留參數。
__declspec(dllexport) double Add(double a, double b, PVOID pReserved)
最近有看到留言有關於之前股票文章的問題,還有提醒我最近網站有改版的狀況,之後會花時間把之前的股票文章重新 review 一下是否有過時需要更新的部分,也會把大家的問題整理好後回覆大家~