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

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

更新紀錄(僅初版,2026年08月22日 發布)
初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22176725)

以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。

Go Komura(2026)。〈DllMain 與載入器鎖定 ── 「DLL 初始化時什麼都別做」真正的理由〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/dllmain-loader-lock/

DOI(已登錄存檔)
10.5281/zenodo.22176725
DOI(上次登錄版本)
10.5281/zenodo.22176726

「載入自己寫的 DLL 之後,LoadLibrary 就不再返回」「只有在特定電腦上,或以服務啟動時才沒有回應」。碰到這類問題時,最先該檢查的地方之一,就是 DLL 的初始化程式碼,也就是 DllMain 以及從它呼叫出去的處理。

DllMain 和應用程式一般的初始化函式不同。它是作業系統在持有載入器鎖定的狀態下呼叫的函式,因此能做的處理受到很強的限制。Microsoft 之所以說「理想的 DllMain 是空的虛設函式」,是因為這項限制不只影響自己的 DLL,也會波及處理程序內其他的 DLL 與執行緒。1

本文以在 Windows 上撰寫 DLL、外掛程式與 C++/CLI 包裝的開發者為對象,依為什麼會停住 → 把初始化移到哪裡 → 該怎麼結束 → 無回應要怎麼調查的順序整理。

1. 先講結論 ── 把 DllMain 縮小,並改變執行的時點

與其在 DllMain 裡尋找安全的寫法,不如減少在那裡執行的工作,這才是基本原則。 要不要留下來,依下列順序判斷。1

判斷項目 基本方針 詳細說明之處
何時進行初始化 編譯期就能決定的做成靜態初始化,其餘延遲到載入完成後的第一次使用 第 5 章
從 DllMain 呼叫什麼 避免 LoadLibrary / FreeLibrary、與其他執行緒同步,以及使用 User、Shell、COM 等。間接呼叫也要確認 第 3 章
結束時做什麼 把只卸載 DLL 的情況,和整個處理程序結束的情況分開 第 6 章

容易漏掉的是,全域與靜態 C++ 物件的建構函式和解構函式也受到相同的限制。在 C++/CLI 中,還要一併消除在載入器鎖定下執行 MSIL 的路徑。光看 DllMain 本體是不是空的,並不足以判斷。23

如果現在正在調查無回應,可從第 7 章開始讀;如果是要重新檢視設計,依第 2 到第 6 章的順序閱讀,就能把各項禁令與對策對應起來。

2. 機制 ── DllMain 是在載入器鎖定的內側被呼叫的

2.1 不只載入時,執行緒啟動與結束時也會被呼叫

DllMain 是在 DLL 進出處理程序或執行緒的時點,由作業系統載入器呼叫的進入點。通知有下列四種。2

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

執行緒啟動時的通知,不只送到建立該執行緒的 DLL,也會送到已載入這個處理程序的所有 DLL。DllMain 不是「載入自己的 DLL 時只跑一次的程式碼」。在頻繁建立執行緒的處理程序裡,每次通知都會執行它。2

若不需要這些通知,可以選擇在 DLL_PROCESS_ATTACH 裡呼叫 DisableThreadLibraryCalls。不過與靜態 CRT 連結的 DLL 不能使用,靜態 TLS 也有條件,因此在 5.3 分開判斷。4

2.2 「持著鎖被呼叫」是所有限制的出發點

作業系統的載入器為了維持 DLL 載入、卸載與通知的一致性,會用每個處理程序一把的載入器鎖定把處理序列化。重要的是,它在呼叫 DllMain 之前就取得這把鎖,並且在 DllMain 執行期間持續持有。1

在這段期間,同一處理程序的其他執行緒若要載入 DLL,或要推進執行緒啟動與結束的通知,就只能等這把鎖被釋放。這裡不是可以只顧自己 DLL 的方便、放進長時間等待的地方。

DllMain 的處理會影響整個處理程序 DLL 通知的理由載入器會先取得處理程序共用的載入器鎖定再呼叫 DllMain,其他執行緒的 DLL 載入與執行緒通知都要等它釋放,因此 DllMain 的處理也會影響其他 DLL 與執行緒載入器取得共用鎖定執行 DllMainDllMain 返回釋放載入器鎖定其他執行緒的 DLL 載入與通知等待同一把鎖被釋放等待中的處理得以繼續

圖 1: DllMain 執行期間會持續持有共用鎖定,因此在那裡等待,就會停住其他 DLL 與執行緒的進行。

接下來的各項禁令,只要用「這個處理會不會直接或間接需要載入器鎖定,或需要其他 DLL 的初始化」來思考,就能理解。

3. 為什麼會停住 ── 用四條路徑理解禁令

3.1 呼叫會載入其他 DLL 的處理

要避免從 DllMain 呼叫 LoadLibrary / FreeLibrary。LoadLibrary 會在載入順序上製造循環相依,導致初始化程式碼尚未執行的 DLL 被使用。在結束這一側,同樣有使用到已處理完畢、已釋放的 DLL 的危險。2

並不是自己沒有寫 LoadLibrary 就安全。User、Shell、COM 的函式中有些會在內部載入其他系統元件,碰到尚未初始化或已釋放的元件時就會發生存取違規。2

可以安全呼叫的範圍,基本上是在 DllMain 執行的時點保證已載入的 Kernel32.dll 之中,那些不會載入其他 DLL 的函式。例如建立臨界區段或 Mutex 這類同步物件、使用 TLS 都可以。不過官方明確寫著安全函式並不存在詳盡的清單。請不要認為「因為是 Kernel32 所以什麼都行」「既然能建立同步物件,那等其他執行緒也沒關係」。2

3.2 在 DllMain 裡等待其他執行緒結束

典型的死結發生在 DLL 卸載時。形態是 DllMain 要求工作者執行緒結束,然後等待它結束。

工作者即使做完自己的工作,仍必須通過執行緒結束時的 DLL_THREAD_DETACH 通知。而這個通知需要正在等待的 DllMain 那一側所持有的載入器鎖定。結果是 DllMain 等工作者,工作者等 DllMain 返回。5

在 DllMain 裡等執行緒會死結的結構持有載入器鎖定的 DllMain 等待工作者執行緒結束,但正要結束的工作者為了 DLL_THREAD_DETACH 通知而等待載入器鎖定被釋放,於是互相等待形成死結工作者執行緒DllMain載入器(持有鎖定)工作者執行緒DllMain載入器(持有鎖定)結束通知需要載入器鎖定DllMain 持鎖等待,W 等待鎖定通知 DLL_PROCESS_DETACH要求結束並等待完成做完處理後準備結束執行緒

圖 2: 工作者即使做完工作,也無法推進執行緒結束通知,因此 DllMain 裡的結束等待不會解除。

這種形態不是「運氣不好才會停住」,而是互相等待在結構上必然成立。卸載時必要的清理,在第 6 章與處理程序結束時的處理分開討論。

3.3 自己的鎖定與載入器鎖定的取得順序反轉

不只執行緒的啟動與結束,像 GetModuleHandle 這樣的 API 內部也需要載入器鎖定。當下面兩條路徑重疊時,鎖定的取得順序就會反轉。6

  • DllMain 這一側是先持有載入器鎖定,再去取得私有鎖 G。
  • 工作者這一側是先持有私有鎖 G,再呼叫 API 去取得載入器鎖定。
載入器鎖定與私有鎖的順序反轉DllMain 在持有載入器鎖定的狀態下去取私有鎖,工作者執行緒則在持有私有鎖的狀態下為了 GetModuleHandle 等去取載入器鎖定,取得順序反轉而死結DllMain:持有載入器鎖定中去取私有鎖 G工作者:持有私有鎖 G 中去取載入器鎖定取得順序反轉造成死結GetModuleHandle 等在內部需要它

圖 3: 一邊依載入器鎖定 → G 的順序取得,另一邊依 G → 載入器鎖定的順序取得,兩者就會互相等待對方釋放。

官方要求把載入器鎖定視為應用程式鎖定階層的最上層,也就是最先被取得的那一把鎖。進入 DllMain 的時點,那把鎖已經被取得了。不只要看呼叫的函式名稱,還要確認是在持有哪一把鎖的狀態下呼叫的。6

3.4 光是建立執行緒,也還留著等待啟動與生命週期的問題

在 DllMain 裡呼叫 CreateThread 同樣不建議。新執行緒在 DLL_THREAD_ATTACH 通知處理完成之前,無法開始執行執行緒函式。由於目前的 DllMain 正持有載入器鎖定,只要在那個 DllMain 裡等待它啟動或完成就會死結。5

不等待也不代表一切都解決了。DllMain 返回之後,若在建立出來的執行緒還沒開始跑之前 DLL 就被卸載,執行緒的起始位址會指向已釋放的程式碼,仍留有當機的危險。5

DllMain 內建立執行緒仍留下的兩個問題在 DllMain 裡建立的執行緒會為了啟動通知而等待載入器鎖定,因此 DllMain 這一側若等待它啟動或完成就會死結;就算不等待就返回,只要執行緒開始執行前 DLL 被卸載,程式碼的生命週期就已結束而當機是否在 DllMain 內建立執行緒新執行緒等待啟動通知在 DllMain 內等待啟動或完成?持著鎖互相等待從 DllMain 返回開始執行前就卸載 DLL起始位址的程式碼已不存在

圖 4: 「不在 DllMain 裡等待」與「守住所建立執行緒要用的 DLL 生命週期」,是兩件各自都必要的事。

4. 即使 DllMain 是空的也危險的兩個地方

4.1 全域與靜態物件的動態初始化

與 CRT(C/C++ 執行階段)連結的 DLL 中,全域與靜態 C++ 物件的建構函式和解構函式會經由 CRT 的進入點執行。它們實質上就是 DllMain 的一部分,受到相同的限制。2

就算把設定檔的讀取、記錄機制的啟動、COM 初始化、執行緒啟動交給建構函式,那也只是把呼叫的地方藏起來,執行的時點仍然在載入器鎖定的內側。只要複雜的處理中含有其他 DLL 的載入或執行緒同步,危險程度就和直接寫在 DllMain 本體裡一樣。

全域物件的初始化變成地雷的路徑DLL 載入時取得載入器鎖定,全域物件的建構函式經由 CRT 執行,因此其中的 LoadLibrary、執行緒同步與 COM 初始化都等於在執行 DllMain 的禁止事項DLL 載入(取得載入器鎖定)CRT 的進入點全域物件的建構函式等同 LoadLibrary 的處理啟動執行緒並等待完成使用 COM 或 User32全部落在 DllMain 的禁止事項上

圖 5: 不只 DllMain 本體,由 CRT 呼叫的靜態物件初始化與結束處理,也要列入審查對象。

編譯期就決定的常數初始化,例如能做成 constexpr 的部分,要和執行期的複雜初始化分開看待。伴隨函式呼叫的動態初始化則要延遲,並讓第一次存取也發生在 DllMain 之外。

4.2 在 C++/CLI 的呼叫對象裡執行了 MSIL

用 C++/CLI 包裝原生 DLL 的組態,在用 C++/CLI 包裝原生 DLL 的實務中討論過。在這種組態下,必須注意在載入器鎖定下執行 MSIL(受管理程式碼)的路徑。一旦執行 MSIL 需要 CLR 初始化或載入其他組件,就可能死結。3

編譯器會對 DllMain 直接試圖執行 MSIL 的程式碼發出警告 C4747。但是經由其他模組函式的間接執行無法偵測。因此不能只憑「沒有警告」就判斷安全。靜態物件的動態初始化子也是確認對象。3

載入器鎖定下的 MSIL 執行能否被偵測DllMain 直接執行 MSIL 的程式碼可由編譯器以警告 C4747 偵測,但經由其他模組函式的間接執行無法偵測,因此必須靠審查呼叫樹與徹底的原生編譯來防止從 DllMain 發出的呼叫直接執行 MSIL經由其他模組執行可用警告 C4747 偵測編譯器偵測不到用審查與 #pragma unmanaged 防止

圖 6: 除了 C4747 能偵測的直接路徑,經由其他模組的呼叫也要一併審查。

對策是用 #pragma unmanaged 把 DllMain 以及從它可到達的函式編譯成原生程式碼,或是採用根本不帶 DllMain 的組態。即使選後者,也不要漏掉靜態初始化子之類的間接路徑。3

5. 初始化的設計 ── 做成靜態、延遲,只留最小限度

5.1 在留給 DllMain 之前,先想想能不能改變執行時點

官方的基本方針是能做的初始化在編譯期做完,其餘盡可能延遲。只有必須作為載入時的失敗及早偵測的處理,才作為例外留下最小限度。1

例如,可能會有「因為相依的設定檔損毀,所以要讓 DLL 載入本身失敗」這種需求。即使如此,也不是先推進其他初始化再失敗,而是收斂成「試做必要的處理,然後立刻失敗」的形式。這並不是「載入時就可以做複雜初始化」的例外。1

DLL 初始化的設計指引初始化先檢討能不能做成編譯期的靜態初始化,不行的話以延遲到第一次使用為基本原則,只有必須作為載入失敗及早偵測的部分才最小限度留在 DllMain能不能不必必須能在編譯期決定嗎?做成靜態初始化失敗必須在載入時偵測嗎?延遲到第一次使用(基本)只在 DllMain 做最小限度用 INIT_ONCE 或函式區域 static 互斥

圖 7: 先檢討靜態初始化與延遲初始化,DllMain 裡只留下需要及早偵測的最小限度處理。

5.2 延遲初始化要連「最初從哪裡呼叫」都一起設計

第一次使用時的互斥,可以用 INIT_ONCE 的一次性初始化,或 C++ 的函式區域 static(magic static)。作法是把複雜的全域物件,改成在第一次存取時才建立的指標,或移到函式區域 static。

不過,光是延遲並不等於已經離開載入器鎖定。只有當第一次存取是從「DLL 載入完成後才被呼叫的一般 API」發生時,才真正擺脫這項限制。在那種情況下,就能把它設計成幾乎可以使用整個 Windows API 的一般初始化處理。1

反過來說,如果從 DllMain 或靜態初始化子裡進行那次第一次存取,初始化處理終究還是在載入器鎖定下執行。除了把它抽成初始化函式之外,請確認是誰、在什麼時候第一次呼叫它。

延遲初始化能離開載入器鎖定的條件延遲初始化的第一次存取若發生在載入完成後的一般 API,就能在載入器鎖定之外初始化,但若發生在 DllMain 或靜態初始化子,就仍在相同的限制下執行載入完成後的一般 APIDllMain・靜態初始化子第一次存取來自哪裡?在載入器鎖定之外初始化在相同限制下初始化用 INIT_ONCE・函式區域 static 互斥連第一次存取的時點也要移走

圖 8: INIT_ONCE 與函式區域 static 負責初始化的互斥,但不保證它會在載入器鎖定之外被呼叫。

5.3 DisableThreadLibraryCalls 用三個條件判斷

不依賴執行緒通知的 DLL,可以在 DLL_PROCESS_ATTACH 呼叫 DisableThreadLibraryCalls,停掉 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 的通知。在頻繁建立執行緒的處理程序中,可以減少通知造成的額外負擔。4

套用之前,要確認靜態 CRT、靜態 TLS,以及有沒有使用通知的處理。與靜態 CRT 連結的 DLL 不可呼叫,因為 CRT 本身需要執行緒通知。若 thread_local 或 __declspec(thread) 的靜態 TLS 生效,呼叫本身會失敗並傳回 FALSE。4

是否要呼叫 DisableThreadLibraryCalls 的判斷與靜態 CRT 連結的 DLL 不可呼叫,靜態 TLS 生效時呼叫本身會失敗因此不呼叫,兩者都不適用且不使用執行緒通知的 DLL 則可在 DLL_PROCESS_ATTACH 一邊確認傳回值一邊呼叫以削減通知成本是否是否不需要需要與靜態 CRT 連結?不可呼叫使用靜態 TLS?呼叫也會失敗(FALSE)需要執行緒通知?在 ATTACH 呼叫(確認傳回值)不呼叫,改為處理通知

圖 9: 區分「靜態 CRT 時不使用」與「靜態 TLS 時會失敗」,在不需要通知的情況下也要確認傳回值。

這是在使用動態連結 CRT、且滿足這些條件的一般 DLL 上才會考慮的最佳化。DisableThreadLibraryCalls 是用來減少通知的,不是用來在 DllMain 裡做複雜初始化的手段。

6. 結束處理 ── 把 DLL 卸載與處理程序結束分開

6.1 同樣是 DLL_PROCESS_DETACH,之後留下來的東西並不相同

DLL_PROCESS_DETACH 會在只有 DLL 被卸載時,以及整個處理程序結束時被呼叫。通知名稱雖然相同,清理的前提卻不一樣。5

經由 FreeLibrary 卸載時,處理程序之後仍會繼續運作。 因此必須確實清理執行緒的停止、開啟的控制代碼、取得的資源,以及需要持久化的狀態等。不過就像 3.2 所看到的,若為此在 DllMain 裡等待執行緒自然結束,就會死結。5

說到底,最安全的是避免讓可能被卸載的 DLL 自己持有執行緒,把執行緒的所有權移到 EXE 那一側。在既有設計已讓 DLL 持有執行緒的情況下,就要把下面的停止協定與這些限制放在一起考慮,不要拆開來看。

6.2 DLL 持有工作者時,官方記載的停止協定

Microsoft 的最佳做法中,記載了卸載時的停止步驟:等的不是「執行緒自然結束」,而是「已進入一致狀態的訊號」。5

  1. DllMain 這一側用事件向工作者發出結束訊號。
  2. 工作者把目前的工作收攏到一致狀態,發出完成訊號後進入無限等待。
  3. DllMain 這一側確認一致狀態後,用 TerminateThread 結束該執行緒。

這個步驟看起來粗暴,但它是在「等自然結束就會讓結束通知與載入器鎖定相撞」這個限制下被文件化的做法。前提是把狀態收攏到一致的那段處理,也要遵守和 DllMain 相同的限制。若那段處理去載入其他 DLL 或進入載入器鎖定的等待,就無法避免與等待訊號的一側形成死結。5

卸載時等待的不是自然結束而是一致完成的步驟DllMain 向工作者發出停止訊號,工作者遵守與 DllMain 相同的限制進入一致狀態,回傳訊號後進入無限等待,DllMain 確認一致狀態後再結束該執行緒工作者執行緒DllMain(卸載時)工作者執行緒DllMain(卸載時)等的是一致完成的訊號,不是自然結束用事件發出結束訊號遵守相同限制進入一致狀態發出一致完成的訊號進入無限等待確認一致後 TerminateThread

圖 10: 即使採用這個協定,也仍然需要讓建立一致狀態的處理不會去等待載入器鎖定。

這並不是說可以把任何執行中的執行緒直接砍掉。首先要檢討的是,能不能把執行緒的所有權放在 DLL 之外。

6.3 處理程序結束時,什麼都不做就返回才是理想

處理程序結束而呼叫 DLL_PROCESS_DETACH 的時候,其他執行緒都已經結束或被強制終止,位址空間的一致性也靠不住。相依的 DLL 與執行階段的狀態同樣不可信賴,像釋放記憶體那樣的清理反而變得危險。官方也表示,這種情況下理想的處理常式是空的。5

需要持久化的資料,要在應用程式本來的結束處理中先寫出。 請不要把 DLL_PROCESS_DETACH 當成最後能收拾一切的地方。

DLL 卸載與處理程序結束的清理前提不同只卸載 DLL 時處理程序仍會繼續運作因此需要清理資源但要避免在 DllMain 內等待自然結束,處理程序結束時則不能指望其他執行緒與資源的一致性所以基本上什麼都不做就返回,該保存的狀態要先在應用程式的結束處理中寫出只卸載 DLL處理程序結束DLL_PROCESS_DETACH 的理由是?處理程序之後仍會運作正確清理殘留的資源不在 DllMain 內等待自然結束其他執行緒與資源的狀態不確定基本上什麼都不做就返回保存先在應用程式的結束處理中進行

圖 11: 不只判斷「是否需要清理」,還要分開判斷處理程序是否繼續存在,以及清理時所用的資源是否可以信賴。

7. 無回應的調查 ── 找出等待載入器鎖定的一方與持有它的一方

7.1 用傾印確認互相等待的執行緒配對

取得凍結當下的傾印,檢查每條執行緒的堆疊。典型的指紋是這樣一組:在 ntdll.dll 中以 Ldr 開頭的載入器函式內等待鎖定的執行緒,以及在 DllMain 或靜態初始化子(dynamic initializer)裡等待別的東西的執行緒。

停在 LoadLibrary 呼叫過程中的執行緒,也是典型的登場角色。只要能把「等待鎖定的一方」和「持有它並在等別的東西的一方」串起來,幾乎就能判斷這是與載入器鎖定有關的死結。

從無回應的傾印確認載入器鎖定的互相等待在各執行緒的堆疊中尋找 Ldr 系列的鎖定等待與 DllMain 或靜態初始化子內的等待,確認兩者是否形成互相等待的關係,看不到典型組合時也要調查其他系統的無回應能看不出來取得無回應中的傾印確認各執行緒的堆疊在 Ldr 系列等待鎖定在 DllMain・靜態初始化子內等待互相等待的關係能串起來?載入器鎖定的死結也調查其他系統的無回應

圖 12: 不要只憑一條堆疊下判斷,要看等待載入器鎖定的一方,與持有它並等待的一方成對出現。

「啟動時偶爾發生」「只有某台機器」「只有以服務執行時」這類條件也是線索。互相等待是在 DLL 載入與執行緒啟動、結束等時機重疊時才成立,因此浮現的方式會隨環境差異與執行時機而改變。

7.2 用 Application Verifier 與呼叫對象的審查來預防

啟用 Application Verifier 並執行測試,就能在執行期偵測 DllMain 裡的典型錯誤。1 在 C++/CLI 中不要忽略警告 C4747,警告偵測不到的間接路徑也要用 4.2 的觀點確認。

審查時,要在從 DllMain 可到達的處理中,找出有沒有間接呼叫 LoadLibrary 的函式。COM 初始化、部分 CRT 功能、延遲載入(delay-load)匯入等都是確認對象。特別是延遲載入的匯入函式,其第一次呼叫在內部就會變成 LoadLibrary,這一點很容易被漏掉。即使函式名稱出現在 DllMain 之外,只要呼叫來源是 DllMain,那項限制就依然存在。

8. 總結 ── 看的不是函式名稱,而是「何時、在哪一把鎖之下執行」

DllMain 的限制,都可以從它是在持有處理程序共用的載入器鎖定的狀態下被呼叫這一點來理解。範圍不只自己的 DllMain,還包含 CRT 的靜態初始化與結束處理,以及 C++/CLI 的間接呼叫。

重新檢視時,先從「初始化能不能做成靜態」「其餘能不能延遲到載入完成之後」開始。連延遲之後的第一次存取也要確認;若不需要通知,就看靜態 CRT 與靜態 TLS 的條件,考慮使用 DisableThreadLibraryCalls。

在結束這一側,要把只卸載 DLL 與整個處理程序結束分開。DLL 持有工作者時,遵守「發出停止訊號、確認一致、結束」的協定,可能的話把執行緒的所有權移到 EXE 那一側。處理程序結束時的 DllMain 要盡量接近空的,資料的保存則放到應用程式本來的結束處理。

重新檢視 DllMain 與其呼叫對象的順序不只 DllMain,連靜態初始化與間接呼叫都納入範圍調查,把初始化移到靜態或載入完成之後,依結束的理由設計停止與清理,再用 Application Verifier 與傾印確認DllMain・靜態初始化・呼叫對象把初始化移到靜態・載入完成後也確認延遲之後的第一次存取依結束理由設計清理用 Verifier・警告・傾印確認

圖 13: 不只追初始化的處理內容,連它的執行時點與結束時的生命週期都追下去,就能把這些禁令當成同一套設計方針來處理。

遇到邊界案例時要問的是:「這是可以在持有載入器鎖定的狀態下執行的工作嗎?」 不把拿不定主意的初始化塞進 DllMain,而是改變它的執行時點,這就是安全 DLL 設計的起點。

相關文章

相關諮詢領域

小村軟體有限公司承接啟動時與 DLL 載入時的無回應、死結原因調查(傾印分析),DllMain 與靜態初始化周邊的設計審查,以及把 C++/CLI 包裝與外掛 DLL 改造成安全初始化設計的工作。即使還在「只有特定環境啟動時會卡住」這種難以重現的階段,也歡迎與我們討論。

參考連結

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

  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, Initialization of Mixed Assemblies. 關於不可在載入器鎖定下執行 MSIL;不可把 DllMain 及其呼叫樹編譯成 MSIL,應以 #pragma unmanaged 處理;DllMain 直接試圖執行 MSIL 時會發出警告 C4747,但經由其他模組的間接執行無法偵測;以及靜態物件的動態初始化子也可能造成相同的問題。 ↩ ↩2 ↩3 ↩4

  4. Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). 關於停用 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知可減少執行緒建立與銷毀時的額外負擔;不可從與靜態 CRT 連結的 DLL 呼叫;以及靜態 TLS(thread_local 或 __declspec(thread))生效時不會進行這項最佳化。 ↩ ↩2 ↩3

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

  6. Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. 關於應定義鎖定階層並總是以相同順序取得;載入器會在呼叫 DllMain 前取得載入器鎖定,因此載入器鎖定應置於鎖定階層的最上層;要注意像 GetModuleFileName 這類間接取得載入器鎖定的 API 與私有鎖之間的取得順序;以及鎖定順序反轉造成死結的實例。 ↩ ↩2

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

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

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

常見問題

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

DllMain 裡真的什麼都做不了嗎?
「什麼都別做」不是誇飾,而是官方的設計方針,Microsoft 自己也說理想的 DllMain 是接近空殼的虛設函式。可以安全使用的,只有在 DllMain 執行的時點保證已載入的 Kernel32.dll 之中,那些不會載入其他 DLL 的函式。例如建立臨界區段或 Mutex、使用 TLS 都沒問題。反過來說,LoadLibrary/FreeLibrary、與其他執行緒同步,以及呼叫 User32、Shell、COM 等函式,會造成死結或存取違規,因此禁止。拿不定主意的初始化,正確做法是「不要在 DllMain 做,延遲到第一次被使用的時候」。
C++ 的全域變數(靜態物件)建構函式也受 DllMain 的限制嗎?
會受限制。DLL 與 CRT(C++ 執行階段)連結時,全域與靜態物件的建構函式和解構函式會經由 CRT 提供的進入點,實質上作為 DllMain 的一部分執行。也就是說,在建構函式裡呼叫 LoadLibrary、啟動其他執行緒並等它完成、初始化 COM 等,危險程度都和直接在 DllMain 裡做一樣。帶有複雜初始化的全域物件,請改成保留指標、在第一次存取時才建立,或改用函式區域 static,把執行時機移到 DllMain 之外。
應該呼叫 DisableThreadLibraryCalls 嗎?
有條件地有效。若 DLL 不需要 DLL_THREAD_ATTACH/DETACH 通知,在 DLL_PROCESS_ATTACH 呼叫 DisableThreadLibraryCalls,就能停掉每次執行緒建立與銷毀時的通知,減少頻繁建立執行緒的處理程序上的額外負擔。不過有兩個例外。與靜態 CRT 連結的 DLL 不可呼叫(靜態 CRT 需要執行緒通知)。另外,若 thread_local 或 __declspec(thread) 的靜態 TLS 生效,呼叫本身會失敗並傳回 FALSE,因此請養成檢查傳回值的習慣。請在使用動態連結 CRT 的一般 DLL 上,確認沒有任何處理依賴執行緒通知之後再使用。
為什麼 C++/CLI(Managed 混合)的 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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽