DllMain 與載入器鎖定 ── 「DLL 初始化什麼都別做」真正的理由

· · Windows, DLL, Windows 開發, C++, 故障調查, 多執行緒, Win32 API

「應用程式在啟動時卡住,但只有某個環境會。」「載入自己的 DLL 時,LoadLibrary 有時永遠不返回。」「只有服務啟動那個時機才會死結。」── 把這類調查追夠遠,十之八九會走到同一個地方。DLL 的初始化程式碼──也就是 DllMain

Microsoft 文件用異常強烈的語氣警告 DllMain。不要呼叫 LoadLibrary。不要和其他執行緒同步。不要呼叫 User、Shell 或 COM 函式。理想的 DllMain空的虛設函式──為什麼語氣這麼重?理由集中在一個內部機制:載入器鎖定。本文以在 Windows 上撰寫 DLL、外掛與 C++/CLI 包裝的開發者為對象,依一次資訊說明載入器鎖定如何運作、讓死結成立的結構,以及安全的設計與調查程序。

1. 先講結論

  • DllMain 是在持有載入器鎖定時被呼叫的,這是每個處理程序恰好一把的共享鎖。因此從 DllMain 呼叫會(直接或間接)試圖取得載入器鎖定的工作,就可能死結,或因碰到尚未初始化的 DLL 而當機。1
  • 禁止呼叫 LoadLibrary / FreeLibrary它會造成循環的載入順序依賴,也可能讓初始化程式碼在該 DLL 自己的初始化尚未跑完時就執行。2
  • 和其他執行緒同步也禁止。DLL 通知是序列化的,因此在 DllMain 裡等執行緒啟動或結束,那條執行緒自己會停在等載入器鎖定,於是死結。23
  • 能安全呼叫的,實務上只有 Kernel32.dll 的一個子集。官方文件也明白寫道「安全函式的完整清單不存在」。User、Shell 與 COM 函式會載入其他元件並造成存取違規。2
  • 與 CRT 連結的 DLL 裡,全域的建構函式與解構函式適用相同限制。它們實質上作為 DllMain 的一部分執行。2
  • 正確設計是「延遲」。能在編譯期(靜態)做的初始化就做;不能的延遲到第一次使用。這是官方最佳實踐。1
  • C++/CLI 混合 DLL 特別危險。為避免在載入器鎖定下執行 MSIL,DllMain 及其呼叫樹必須編譯成原生。4

2. DllMain 何時、如何被呼叫

DllMain 是作業系統載入器在 DLL 進入或離開處理程序或執行緒時呼叫的進入點。通知有四種。

通知 時機
DLL_PROCESS_ATTACH DLL 載入到處理程序時
DLL_THREAD_ATTACH 處理程序中啟動新執行緒時
DLL_THREAD_DETACH 執行緒正常結束時
DLL_PROCESS_DETACH DLL 被卸載,或處理程序結束時

有兩個事實容易漏掉。第一,每建立一條執行緒,每個已載入 DLL 的 DllMain 都會以 DLL_THREAD_ATTACH 被呼叫。換句話說 DllMain 不是「我的 DLL 載入時跑一次的東西」;它是持續因處理程序的執行緒活動而被呼叫的程式碼。若不需要,可在 DLL_PROCESS_ATTACH 裡呼叫 DisableThreadLibraryCalls 停掉它(不要從與靜態 CRT 連結的 DLL 呼叫)。5

第二,與 CRT(C/C++ 執行階段)連結的 DLL 裡,全域與靜態 C++ 物件的建構函式與解構函式會經由 CRT 的進入點,作為 DllMain 的一部分執行2 即使你覺得「我們的 DllMain 是空的,所以安全」,帶有複雜初始化的全域物件,就等於在 DllMain 裡跑那些工作。

DllMain 被呼叫的四個時機DLL 載入時跑 DLL_PROCESS_ATTACH;處理程序每次啟動與結束執行緒時,每個已載入 DLL 都會跑 DLL_THREAD_ATTACH 與 DETACH;卸載或處理程序結束時跑 DLL_PROCESS_DETACH;靜態物件的建構函式也會經由 CRT 在這裡跑DLL 載入DLL_PROCESS_ATTACHDLL_THREAD_ATTACH(每次執行緒啟動)DLL_THREAD_DETACH(每次執行緒結束)DLL_PROCESS_DETACH(卸載或結束時)靜態物件的建構也在這裡跑

圖 1: DllMain 不只在載入時被呼叫,每次執行緒啟動與結束也會,而且靜態物件的初始化也作為其中一部分執行。

3. 載入器鎖定 ── 序列化每個通知的那一把鎖

為什麼只有 DllMain 的限制這麼嚴?答案在載入器的結構。

為了讓 DLL 載入、卸載與各種通知這一系列操作保持一致,作業系統載入器用每個處理程序一把載入器鎖定把工作序列化。重要的是:DllMain 是在持有這把載入器鎖定時被呼叫的1 只要你還在 DllMain 裡,該處理程序裡每一次其他 DLL 載入、以及每一次執行緒啟動通知,都在等這把鎖被釋放。

從這個結構,禁令的理由一個接一個出來。

  • 不可呼叫 LoadLibrary,因為它會造成載入器鎖定重入,或循環的載入順序依賴。也可能變成對初始化尚未完成的 DLL 呼叫函式。2
  • 和其他執行緒同步很危險,因為你在等的那條執行緒會有需要載入器鎖定的時刻(啟動與結束時的通知、呼叫 GetModuleHandle 家族 API 等)。你握著載入器鎖定等對方;對方等載入器鎖定──典型的鎖順序反轉。6
  • User、Shell 與 COM 函式很危險,因為它們內部會載入其他系統元件。你在元件初始化前或拆除後碰到它,就會存取違規。2
為什麼在 DllMain 裡等執行緒會死結握著載入器鎖定的 DllMain 等工作者執行緒結束,但正要結束的工作者為了收到 DLL_THREAD_DETACH 而等載入器鎖定被釋放,於是互相等待而死結工作者DllMain載入器(持有鎖)工作者DllMain載入器(持有鎖)結束通知需要這把鎖DllMain 持有鎖,W 在等DLL_PROCESS_DETACH要求結束並等待做完工作後結束

圖 2: 「DllMain 等執行緒結束」是結構性死結,因為執行緒結束本身需要載入器鎖定。

重點是這不是「運氣不好才會發生」的那種事;結構上必定成立。文件要你把載入器鎖定當成應用程式定義的鎖階層的頂端(最先取得的那把)。在 DllMain 裡你已經握著那把頂層鎖,因此從那裡再去等別的東西都很危險──這樣記很有用。6

載入器鎖定與私有鎖之間的鎖順序反轉握著載入器鎖定的 DllMain 去取私有鎖,而握著那把私有鎖的工作者為了 GetModuleHandle 等去取載入器鎖定,取得順序反轉而死結DllMain:握著載入器鎖定去取私有鎖 G工作者:握著私有鎖 G去取載入器鎖定取得順序反轉造成的死結GetModuleHandle 等內部需要它

圖 3: 連 GetModuleHandle 這種看起來無害的 API 內部也需要載入器鎖定,因此與私有鎖的順序反轉可以成立。

此外,從 DllMain 裡呼叫 CreateThread 本身也不建議。建立出來的執行緒需要載入器鎖定來處理 DLL_THREAD_ATTACH 通知,因此在目前執行中的 DllMain 返回並釋放鎖之前,它無法開始跑。因此在 DllMain 裡等那條執行緒啟動或結束,立刻就是死結。還有生命週期問題──若 DllMain 返回後,DLL 在一條尚未開始跑的執行緒仍留下時被卸載,該執行緒的起始位址仍指向已釋放的程式碼,於是當機。3

4. C++ 開發者容易踩的兩顆地雷

地雷 1:全域物件的動態初始化。如第 2 章所說,靜態物件的建構函式在 DllMain 限制下執行。讀設定檔、架起記錄設施、初始化 COM、啟動執行緒──一旦你在 DLL 裡放一個建構函式做這類工作的全域,就是在執行「DllMain 裡不能做的事」。編譯期就固定的常數初始化(能做成 constexpr 的)是安全的;涉及函式呼叫的初始化應延遲。

初始化全域物件變成地雷的路徑DLL 載入時取得載入器鎖定,全域物件的建構函式經由 CRT 進入點執行,因此那些建構函式裡的 LoadLibrary、執行緒同步與 COM 初始化都是在執行 DllMain 禁令DLL 載入(取得載入器鎖定)CRT 進入點全域物件的建構函式等同 LoadLibrary 的工作啟動執行緒並等它結束使用 COM 或 User32這些全部落在 DllMain 禁令下

圖 4: 即使「DllMain 是空的,所以安全」,一旦有複雜初始化的全域,同樣的危險就復活。

地雷 2:C++/CLI(混合組件)。用 C++/CLI 包裝原生 DLL 的組態(包裝文章所談的形狀)裡,有在載入器鎖定下執行 MSIL(受控程式碼)的危險。執行 MSIL 可能觸發 CLR 初始化或載入另一個組件。編譯器對 DllMain 直接執行 MSIL 的程式碼發出警告 C4747,但無法偵測經由另一個模組函式的間接執行。用 #pragma unmanagedDllMain 與從它呼叫的函式編譯成原生,或採用根本沒有 DllMain 的組態。4

載入器鎖定下的 MSIL 執行能否被偵測DllMain 直接執行 MSIL 的程式碼能被編譯器以警告 C4747 偵測,但經由另一個模組函式的間接執行不能,因此必須靠審查呼叫樹並堅持原生編譯來防止從 DllMain 的呼叫直接執行 MSIL經由另一個模組執行可用警告 C4747 偵測編譯器偵測不到用審查與 pragma unmanaged 防止

圖 5: C4747 只保護你免於直接執行。間接路徑只能靠審查抓到。

5. 正確設計 ── 把「延遲」當預設政策

官方最佳實踐的建議很清楚。1

  1. 能在編譯期(靜態)做完的初始化就做完。先問動態初始化能否換成靜態。
  2. 其餘延遲到第一次使用。只要第一次使用發生在 DLL 載入完成後呼叫的普通 API,初始化就在載入器鎖定外執行,幾乎整個 Windows API 都能安全使用。第一次存取的互斥可用 INIT_ONCE(一次性初始化)或 C++ magic statics(函式區域靜態)。延遲不是萬靈丹──若那次第一次存取本身是從 DllMain 或靜態初始化子發出,初始化子仍在載入器鎖定下跑,你又回到同一套限制。
  3. 只有必須及早偵測的失敗才破例。你可能有「設定檔壞了就讓載入本身失敗」的需求。即便如此,也只做到「嘗試並立刻失敗」的最小程度。
  4. 在 DLL_PROCESS_ATTACH 考慮 DisableThreadLibraryCalls若 DLL 不用執行緒通知,就能拿掉通知成本本身(使用靜態 CRT 或靜態 TLS 時除外)。5
  5. 用 Application Verifier 檢查。DllMain 裡許多危險呼叫,Application Verifier 能在執行期偵測。1
DLL 初始化的設計指引先考慮初始化能否做成編譯期靜態初始化;若不能,預設是延遲到第一次使用,DllMain 裡只留下必須作為載入失敗及早偵測的最小部分不能能在編譯期決定嗎?做成靜態初始化失敗必須在載入時偵測嗎?延遲到第一次使用(預設)DllMain 裡只做最小部分用 INIT_ONCE 或函式區域靜態互斥

圖 6: 決策順序是「能否靜態 → 能否延遲」,留在 DllMain 的只有必須及早偵測的最小部分。

要不要套用 DisableThreadLibraryCalls,可用下面的分支機械地決定。

是否呼叫 DisableThreadLibraryCalls不要從與靜態 CRT 連結的 DLL 呼叫;若靜態 TLS 生效,呼叫本身會失敗因此不呼叫;兩者都不適用且 DLL 不用執行緒通知時,在 DLL_PROCESS_ATTACH 呼叫並檢查傳回值,以削減通知成本與靜態 CRT 連結?不可呼叫使用靜態 TLS?呼叫反正會失敗(FALSE)需要執行緒通知嗎?在 ATTACH 呼叫(檢查傳回值)不呼叫;處理通知

圖 7: 靜態 CRT、靜態 TLS、以及是否需要通知這三個條件,唯一決定該不該呼叫。

關於卸載時停止執行緒,官方文件給了具體協定。不要在 DLL_PROCESS_DETACH(經由 FreeLibrary 卸載時)「等待」工作者執行緒結束,形態是 (1) 用事件發結束訊號,(2) 執行緒側把工作收到一致狀態、回發訊號,並進入無限等待,(3) DllMain 側確認一致狀態後用 TerminateThread 收掉執行緒。3 看起來粗暴,但在「不可在 DllMain 裡等執行緒自然結束」這個約束內,它被記載為務實答案。

卸載時停止執行緒的協定DllMain 用事件向工作者執行緒發結束訊號;工作者把工作收到一致狀態、回發訊號並進入無限等待;DllMain 確認一致狀態後終止該執行緒工作者執行緒DllMain(DETACH 處理)工作者執行緒DllMain(DETACH 處理)不等自然結束,因此不死結用事件發結束訊號把工作收到一致狀態發出一致性完成訊號並永遠等待用 TerminateThread 終止

圖 8: 用「等一致性訊號再切斷」取代「等自然結束」,避免與載入器鎖定相撞。

就第一原則而言,最安全的設計是避免在可卸載的 DLL 裡擁有執行緒,把執行緒所有權留在 EXE 側。

處理程序結束時的 DLL_PROCESS_DETACH 正好相反:什麼都不做就返回是理想。到這個時點,其他每條執行緒都已被強制終止,依賴的 DLL 或執行階段的狀態也不可靠。這裡做複雜工作只會造成死結與當機。必須持久化的資料應在應用程式自己的關閉路徑寫出;不要依賴這個通知。3

6. 碰到時怎麼調查

載入器鎖定造成的無回應有可辨識的指紋。

看無回應傾印裡的堆疊。對凍結當下取傾印,檢查各執行緒的堆疊。若找到一對:一條在 ntdll.dll 載入器函式(名稱以 Ldr 開頭的家族)裡等鎖,另一條在 DllMain 或靜態初始化子(dynamic initializer)裡等別的東西,幾乎就能確定。停在 LoadLibrary 呼叫中途的執行緒是另一個典型角色。

載入器鎖定無回應的指紋在無回應傾印裡,若同時找到在 ntdll 載入器函式裡等鎖的執行緒,以及在 DllMain 或靜態初始化子裡等別的東西的執行緒,幾乎可以當成載入器鎖定死結無回應傾印在 Ldr 家族函式裡等鎖的執行緒在 DllMain 或靜態初始化子裡等待的執行緒兩者都在?幾乎確定是載入器鎖定死結當成另一種無回應來調查

圖 9: 載入器鎖定無回應有可辨識的指紋:「在 Ldr 裡等+在 DllMain 裡等」。

懷疑「依賴時機」這個性格。載入器鎖定死結只在 DLL 載入與執行緒啟動或結束重合的那一刻成立。「偶爾在啟動時」、「只有某台機器」、「只有當服務跑」這類重現條件,是這類問題的跡象。

做預防性檢查。啟用 Application Verifier 並跑測試,就能在執行期偵測 DllMain 裡的危險呼叫。1 對 C++/CLI,不要忽略警告 C4747;審查從 DllMain 可達的函式時,把「間接呼叫 LoadLibrary 的函式」(COM 初始化、某些 CRT 功能、延遲載入匯入等)加進審查清單,就能在出貨前抓住意外。延遲載入匯入的第一次呼叫內部變成 LoadLibrary,是容易漏掉的點。

7. 總結

  • DllMain 是在持有載入器鎖定(每個處理程序一把、序列化每個 DLL 通知的那把鎖)時被呼叫的。每條限制都由此而來。
  • 禁令的核心是「不要呼叫 LoadLibrary / FreeLibrary」、「不要和其他執行緒同步」、「不要呼叫依賴 Kernel32 以外 DLL 的函式」。經由 CRT 執行的靜態物件建構函式與解構函式適用相同限制。
  • 基本設計政策是延遲。能做成靜態的初始化就做成靜態;其餘延遲到第一次使用。使用 DisableThreadLibraryCalls 與 Application Verifier。
  • 卸載時停止執行緒遵循官方協定(發訊號 → 確認一致性 → 終止)。處理程序結束時的 DLL_PROCESS_DETACH 理想是空的。
  • 在 C++/CLI 裡,載入器鎖定下執行 MSIL 是另一顆地雷。堅持把 DllMain 呼叫樹編譯成原生。

DllMain 的限制乍看像不合理的禁令清單。但一旦抓住「它是在持有頂層鎖──載入器鎖定──時被呼叫」這一點,每條禁令都是同一原則的重述。當成原則記住,遇到文件沒寫的邊界情況,你仍該問得出正確的問題:「這是我握著這把鎖時被允許做的工作嗎?」

相關文章

相關諮詢領域

小村軟體有限公司承接啟動時或 DLL 載入時無回應與死結的根本原因調查(傾印分析)、DllMain 與靜態初始化周邊的設計審查,以及把 C++/CLI 包裝與外掛 DLL 改成安全初始化設計。即使還在「只有某個環境啟動時才卡住」這種難以重現的階段,也可以諮詢。

參考連結

  1. Microsoft Learn, Dynamic-Link Library Best Practices. 關於 DllMain 在持有載入器鎖定時被呼叫,因此能呼叫的函式受到嚴格限制;理想的 DllMain 是空的虛設函式,初始化盡可能延遲;建議編譯期靜態初始化;對必須及早偵測的失敗只做最小部分;以及用 Application Verifier 偵測典型的 DllMain 錯誤。  2 3 4 5 6

  2. Microsoft Learn, DllMain entry point. 關於在進入點只做簡單初始化與終止;為什麼不可呼叫 LoadLibrary / FreeLibrary(循環載入順序,以及在初始化前或終止後使用 DLL);Kernel32.dll 保證已載入,因此可在不載入其他 DLL 的範圍內呼叫;安全函式沒有窮盡清單;User、Shell 與 COM 函式會造成存取違規;DLL 通知是序列化的,因此與其他執行緒或處理程序通訊會造成死結;以及連結 CRT 時靜態物件的建構函式與解構函式適用相同限制。  2 3 4 5 6 7

  3. Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. 關於在 DllMain 裡等執行緒結束會死結的結構(執行緒結束的 DLL_THREAD_DETACH 通知需要載入器鎖定);卸載時停止執行緒的協定(用事件發訊號、確認一致狀態後終止);處理程序結束時的 DLL_PROCESS_DETACH 其他執行緒已被強制終止、位址空間一致性沒有保證,因此理想的處理常式是空的;以及在 DllMain 建立執行緒會讓通知在初始化未完成時排隊並造成問題。  2 3 4

  4. Microsoft Learn, Initialization of Mixed Assemblies. 關於不要在載入器鎖定下執行 MSIL;不要把 DllMain 及其呼叫樹編譯成 MSIL,並以 #pragma unmanaged 處理;DllMain 試圖直接執行 MSIL 時會發出警告 C4747,但經由另一個模組的間接執行無法偵測;以及靜態物件的動態初始化子可能造成同樣問題。  2

  5. Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). 關於停用 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知以降低執行緒建立與銷毀時的負擔;不要從與靜態 CRT 連結的 DLL 呼叫;以及靜態 TLS(thread_local 或 __declspec(thread))生效時不會執行此最佳化。  2

  6. Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. 關於定義鎖階層並總是以相同順序取得;載入器在呼叫 DllMain 前取得載入器鎖定,因此載入器鎖定應位於鎖階層頂端;觀察 GetModuleFileName 等間接取得載入器鎖定的 API 與私有鎖之間的取得順序;以及鎖順序反轉造成死結的具體例子。  2

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

DllMain 裡真的什麼都不能做嗎?
「什麼都別做」不是誇飾,而是官方設計立場,Microsoft 自己也說理想的 DllMain 接近空的虛設函式。安全的是 Kernel32.dll 函式的一個子集──DllMain 執行時 Kernel32 保證已載入──而且限於不會載入其他 DLL 的範圍。建立臨界區段或互斥、使用 TLS,是可以做的例子。反過來說,LoadLibrary/FreeLibrary、和其他執行緒同步,以及呼叫 User32、Shell、COM 等函式,會造成死結與存取違規,因此禁止。不確定的初始化不要在 DllMain 做;延遲到第一次使用時。
C++ 全域(靜態物件)的建構函式也受 DllMain 限制嗎?
會。DLL 與 CRT(C++ 執行階段)連結時,全域與靜態物件的建構函式與解構函式會經由 CRT 提供的進入點,實質上作為 DllMain 的一部分執行。因此從建構函式呼叫 LoadLibrary、啟動另一條執行緒並等它結束、初始化 COM 等,都和在 DllMain 做這些事一樣危險。非平凡初始化的全域物件,請保留指標並在第一次存取時建構,或用函式區域靜態,讓工作在 DllMain 外執行。
該呼叫 DisableThreadLibraryCalls 嗎?
有條件地該。若 DLL 不需要 DLL_THREAD_ATTACH/DETACH 通知,在 DLL_PROCESS_ATTACH 呼叫 DisableThreadLibraryCalls,就能停掉每個執行緒建立與結束的通知,降低頻繁建立執行緒的處理程序負擔。有兩個例外。不要從與靜態 CRT 連結的 DLL 呼叫它(靜態 CRT 需要執行緒通知)。若 thread_local 或 __declspec(thread) 的靜態 TLS 生效,呼叫本身會失敗並傳回 FALSE,因此養成檢查傳回值的習慣。在使用動態連結 CRT 的典型 DLL 上,確認沒有任何東西依賴執行緒通知後再使用。
為什麼 C++/CLI(混合受控)DLL 會在啟動時卡住?
典型原因是在持有載入器鎖定時試圖執行 MSIL(受控程式碼)。在 C++/CLI 混合組件裡,若 DllMain、從它呼叫的函式、或全域的動態初始化子被編譯成 MSIL,就可能在載入器鎖定下需要 CLR 初始化或載入另一個組件,因而死結。DllMain 本身試圖直接執行 MSIL 時,編譯器會發出警告 C4747,但無法偵測經由另一個模組的間接執行。緩解方式是用 #pragma unmanaged 把 DllMain 及其呼叫樹編譯成原生──或者根本不要有 DllMain。
可以在 DLL_PROCESS_DETACH 清理資源嗎?
答案在「處理程序結束」與「經由 FreeLibrary 卸載」之間不同。處理程序結束時的 DLL_PROCESS_DETACH,其他執行緒已被終止,位址空間也不保證仍一致,因此釋放記憶體這類清理其實危險;官方指引是「理想的處理常式是空的」。必須持久化的資料,在應用程式自己的關閉路徑寫出,這裡本質上什麼都不做就返回。經由 FreeLibrary 卸載時,處理程序還在,因此需要完整清理──停止執行緒、關閉控制代碼等。不過在 DllMain 裡等執行緒結束會死結,所以必須遵循官方協定:發訊號、等到一致狀態,並在 DllMain 外做完工作。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽