[C++] 開發 DLL 的那些坑

Rainie
9 min readMay 11, 2022

--

最近有個專案需要跨 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 給外部呼叫。

Ref : https://docs.microsoft.com/zh-tw/cpp/c-runtime-library/potential-errors-passing-crt-objects-across-dll-boundaries?view=msvc-170

Reserve Parameter For Future Use

通常 API 開出去後定義好的介面就很難修改了,假設突然有想要額外補充的參數只能靠新增 API 取代, 為了避免之後因為參數的改變,導致需要頻繁的增加類似功能 API ,可以在每個 API 的加上 pReserved ( pointer to void ) 當作保留參數。

__declspec(dllexport) double Add(double a, double b, PVOID pReserved)

最近有看到留言有關於之前股票文章的問題,還有提醒我最近網站有改版的狀況,之後會花時間把之前的股票文章重新 review 一下是否有過時需要更新的部分,也會把大家的問題整理好後回覆大家~

--

--