更新紀錄(僅初版,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 的方便、放進長時間等待的地方。
flowchart TB
accTitle: DllMain 的處理會影響整個處理程序 DLL 通知的理由
accDescr: 載入器會先取得處理程序共用的載入器鎖定再呼叫 DllMain,其他執行緒的 DLL 載入與執行緒通知都要等它釋放,因此 DllMain 的處理也會影響其他 DLL 與執行緒
loader["載入器取得共用鎖定"] --> dll["執行 DllMain"]
dll --> ret["DllMain 返回"]
ret --> unlock["釋放載入器鎖定"]
other["其他執行緒的 DLL 載入與通知"] --> wait["等待同一把鎖被釋放"]
unlock --> resume["等待中的處理得以繼續"]
wait --> resume
圖 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
sequenceDiagram
accTitle: 在 DllMain 裡等執行緒會死結的結構
accDescr: 持有載入器鎖定的 DllMain 等待工作者執行緒結束,但正要結束的工作者為了 DLL_THREAD_DETACH 通知而等待載入器鎖定被釋放,於是互相等待形成死結
participant L as 載入器(持有鎖定)
participant D as DllMain
participant W as 工作者執行緒
L->>D: 通知 DLL_PROCESS_DETACH
D->>W: 要求結束並等待完成
W->>W: 做完處理後準備結束執行緒
Note over W: 結束通知需要載入器鎖定
Note over D,W: DllMain 持鎖等待,W 等待鎖定
圖 2: 工作者即使做完工作,也無法推進執行緒結束通知,因此 DllMain 裡的結束等待不會解除。
這種形態不是「運氣不好才會停住」,而是互相等待在結構上必然成立。卸載時必要的清理,在第 6 章與處理程序結束時的處理分開討論。
3.3 自己的鎖定與載入器鎖定的取得順序反轉
不只執行緒的啟動與結束,像 GetModuleHandle 這樣的 API 內部也需要載入器鎖定。當下面兩條路徑重疊時,鎖定的取得順序就會反轉。6
- DllMain 這一側是先持有載入器鎖定,再去取得私有鎖 G。
- 工作者這一側是先持有私有鎖 G,再呼叫 API 去取得載入器鎖定。
flowchart TB
accTitle: 載入器鎖定與私有鎖的順序反轉
accDescr: DllMain 在持有載入器鎖定的狀態下去取私有鎖,工作者執行緒則在持有私有鎖的狀態下為了 GetModuleHandle 等去取載入器鎖定,取得順序反轉而死結
d["DllMain:持有載入器鎖定中"] --> dg["去取私有鎖 G"]
w["工作者:持有私有鎖 G 中"] --> wl["去取載入器鎖定"]
dg -.-> dead["取得順序反轉造成死結"]
wl -.-> dead
wl -.-> api["GetModuleHandle 等在內部需要它"]
圖 3: 一邊依載入器鎖定 → G 的順序取得,另一邊依 G → 載入器鎖定的順序取得,兩者就會互相等待對方釋放。
官方要求把載入器鎖定視為應用程式鎖定階層的最上層,也就是最先被取得的那一把鎖。進入 DllMain 的時點,那把鎖已經被取得了。不只要看呼叫的函式名稱,還要確認是在持有哪一把鎖的狀態下呼叫的。6
3.4 光是建立執行緒,也還留著等待啟動與生命週期的問題
在 DllMain 裡呼叫 CreateThread 同樣不建議。新執行緒在 DLL_THREAD_ATTACH 通知處理完成之前,無法開始執行執行緒函式。由於目前的 DllMain 正持有載入器鎖定,只要在那個 DllMain 裡等待它啟動或完成就會死結。5
不等待也不代表一切都解決了。DllMain 返回之後,若在建立出來的執行緒還沒開始跑之前 DLL 就被卸載,執行緒的起始位址會指向已釋放的程式碼,仍留有當機的危險。5
flowchart TB
accTitle: DllMain 內建立執行緒仍留下的兩個問題
accDescr: 在 DllMain 裡建立的執行緒會為了啟動通知而等待載入器鎖定,因此 DllMain 這一側若等待它啟動或完成就會死結;就算不等待就返回,只要執行緒開始執行前 DLL 被卸載,程式碼的生命週期就已結束而當機
create["在 DllMain 內建立執行緒"] --> pending["新執行緒等待啟動通知"]
pending --> q{"在 DllMain 內等待啟動或完成?"}
q -->|"是"| dead["持著鎖互相等待"]
q -->|"否"| returns["從 DllMain 返回"]
returns --> race["開始執行前就卸載 DLL"]
race --> crash["起始位址的程式碼已不存在"]
圖 4: 「不在 DllMain 裡等待」與「守住所建立執行緒要用的 DLL 生命週期」,是兩件各自都必要的事。
4. 即使 DllMain 是空的也危險的兩個地方
4.1 全域與靜態物件的動態初始化
與 CRT(C/C++ 執行階段)連結的 DLL 中,全域與靜態 C++ 物件的建構函式和解構函式會經由 CRT 的進入點執行。它們實質上就是 DllMain 的一部分,受到相同的限制。2
就算把設定檔的讀取、記錄機制的啟動、COM 初始化、執行緒啟動交給建構函式,那也只是把呼叫的地方藏起來,執行的時點仍然在載入器鎖定的內側。只要複雜的處理中含有其他 DLL 的載入或執行緒同步,危險程度就和直接寫在 DllMain 本體裡一樣。
flowchart TB
accTitle: 全域物件的初始化變成地雷的路徑
accDescr: DLL 載入時取得載入器鎖定,全域物件的建構函式經由 CRT 執行,因此其中的 LoadLibrary、執行緒同步與 COM 初始化都等於在執行 DllMain 的禁止事項
load["DLL 載入(取得載入器鎖定)"] --> crt["CRT 的進入點"]
crt --> ctor["全域物件的建構函式"]
ctor --> ng1["等同 LoadLibrary 的處理"]
ctor --> ng2["啟動執行緒並等待完成"]
ctor --> ng3["使用 COM 或 User32"]
ng1 -.-> risk["全部落在 DllMain 的禁止事項上"]
ng2 -.-> risk
ng3 -.-> risk
圖 5: 不只 DllMain 本體,由 CRT 呼叫的靜態物件初始化與結束處理,也要列入審查對象。
編譯期就決定的常數初始化,例如能做成 constexpr 的部分,要和執行期的複雜初始化分開看待。伴隨函式呼叫的動態初始化則要延遲,並讓第一次存取也發生在 DllMain 之外。
4.2 在 C++/CLI 的呼叫對象裡執行了 MSIL
用 C++/CLI 包裝原生 DLL 的組態,在用 C++/CLI 包裝原生 DLL 的實務中討論過。在這種組態下,必須注意在載入器鎖定下執行 MSIL(受管理程式碼)的路徑。一旦執行 MSIL 需要 CLR 初始化或載入其他組件,就可能死結。3
編譯器會對 DllMain 直接試圖執行 MSIL 的程式碼發出警告 C4747。但是經由其他模組函式的間接執行無法偵測。因此不能只憑「沒有警告」就判斷安全。靜態物件的動態初始化子也是確認對象。3
flowchart TB
accTitle: 載入器鎖定下的 MSIL 執行能否被偵測
accDescr: DllMain 直接執行 MSIL 的程式碼可由編譯器以警告 C4747 偵測,但經由其他模組函式的間接執行無法偵測,因此必須靠審查呼叫樹與徹底的原生編譯來防止
d2["從 DllMain 發出的呼叫"] --> dir["直接執行 MSIL"]
d2 --> ind["經由其他模組執行"]
dir --> c47["可用警告 C4747 偵測"]
ind --> nc["編譯器偵測不到"]
nc -.-> rv["用審查與 #pragma unmanaged 防止"]
圖 6: 除了 C4747 能偵測的直接路徑,經由其他模組的呼叫也要一併審查。
對策是用 #pragma unmanaged 把 DllMain 以及從它可到達的函式編譯成原生程式碼,或是採用根本不帶 DllMain 的組態。即使選後者,也不要漏掉靜態初始化子之類的間接路徑。3
5. 初始化的設計 ── 做成靜態、延遲,只留最小限度
5.1 在留給 DllMain 之前,先想想能不能改變執行時點
官方的基本方針是能做的初始化在編譯期做完,其餘盡可能延遲。只有必須作為載入時的失敗及早偵測的處理,才作為例外留下最小限度。1
例如,可能會有「因為相依的設定檔損毀,所以要讓 DLL 載入本身失敗」這種需求。即使如此,也不是先推進其他初始化再失敗,而是收斂成「試做必要的處理,然後立刻失敗」的形式。這並不是「載入時就可以做複雜初始化」的例外。1
flowchart TB
accTitle: DLL 初始化的設計指引
accDescr: 初始化先檢討能不能做成編譯期的靜態初始化,不行的話以延遲到第一次使用為基本原則,只有必須作為載入失敗及早偵測的部分才最小限度留在 DllMain
q1{"能在編譯期決定嗎?"} -->|"能"| s["做成靜態初始化"]
q1 -->|"不能"| q2{"失敗必須在載入時偵測嗎?"}
q2 -->|"不必"| lazy["延遲到第一次使用(基本)"]
q2 -->|"必須"| min["只在 DllMain 做最小限度"]
lazy -.-> once["用 INIT_ONCE 或函式區域 static 互斥"]
圖 7: 先檢討靜態初始化與延遲初始化,DllMain 裡只留下需要及早偵測的最小限度處理。
5.2 延遲初始化要連「最初從哪裡呼叫」都一起設計
第一次使用時的互斥,可以用 INIT_ONCE 的一次性初始化,或 C++ 的函式區域 static(magic static)。作法是把複雜的全域物件,改成在第一次存取時才建立的指標,或移到函式區域 static。
不過,光是延遲並不等於已經離開載入器鎖定。只有當第一次存取是從「DLL 載入完成後才被呼叫的一般 API」發生時,才真正擺脫這項限制。在那種情況下,就能把它設計成幾乎可以使用整個 Windows API 的一般初始化處理。1
反過來說,如果從 DllMain 或靜態初始化子裡進行那次第一次存取,初始化處理終究還是在載入器鎖定下執行。除了把它抽成初始化函式之外,請確認是誰、在什麼時候第一次呼叫它。
flowchart TB
accTitle: 延遲初始化能離開載入器鎖定的條件
accDescr: 延遲初始化的第一次存取若發生在載入完成後的一般 API,就能在載入器鎖定之外初始化,但若發生在 DllMain 或靜態初始化子,就仍在相同的限制下執行
first{"第一次存取來自哪裡?"}
first -->|"載入完成後的一般 API"| outside["在載入器鎖定之外初始化"]
first -->|"DllMain・靜態初始化子"| inside["在相同限制下初始化"]
outside --> once["用 INIT_ONCE・函式區域 static 互斥"]
inside --> move["連第一次存取的時點也要移走"]
圖 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
flowchart TB
accTitle: 是否要呼叫 DisableThreadLibraryCalls 的判斷
accDescr: 與靜態 CRT 連結的 DLL 不可呼叫,靜態 TLS 生效時呼叫本身會失敗因此不呼叫,兩者都不適用且不使用執行緒通知的 DLL 則可在 DLL_PROCESS_ATTACH 一邊確認傳回值一邊呼叫以削減通知成本
q1{"與靜態 CRT 連結?"} -->|"是"| no2["不可呼叫"]
q1 -->|"否"| q2{"使用靜態 TLS?"}
q2 -->|"是"| eff["呼叫也會失敗(FALSE)"]
q2 -->|"否"| q3{"需要執行緒通知?"}
q3 -->|"不需要"| yes["在 ATTACH 呼叫(確認傳回值)"]
q3 -->|"需要"| keep["不呼叫,改為處理通知"]
圖 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
- DllMain 這一側用事件向工作者發出結束訊號。
- 工作者把目前的工作收攏到一致狀態,發出完成訊號後進入無限等待。
- DllMain 這一側確認一致狀態後,用
TerminateThread結束該執行緒。
這個步驟看起來粗暴,但它是在「等自然結束就會讓結束通知與載入器鎖定相撞」這個限制下被文件化的做法。前提是把狀態收攏到一致的那段處理,也要遵守和 DllMain 相同的限制。若那段處理去載入其他 DLL 或進入載入器鎖定的等待,就無法避免與等待訊號的一側形成死結。5
sequenceDiagram
accTitle: 卸載時等待的不是自然結束而是一致完成的步驟
accDescr: DllMain 向工作者發出停止訊號,工作者遵守與 DllMain 相同的限制進入一致狀態,回傳訊號後進入無限等待,DllMain 確認一致狀態後再結束該執行緒
participant D as DllMain(卸載時)
participant W as 工作者執行緒
D->>W: 用事件發出結束訊號
W->>W: 遵守相同限制進入一致狀態
W->>D: 發出一致完成的訊號
W->>W: 進入無限等待
D->>W: 確認一致後 TerminateThread
Note over D,W: 等的是一致完成的訊號,不是自然結束
圖 10: 即使採用這個協定,也仍然需要讓建立一致狀態的處理不會去等待載入器鎖定。
這並不是說可以把任何執行中的執行緒直接砍掉。首先要檢討的是,能不能把執行緒的所有權放在 DLL 之外。
6.3 處理程序結束時,什麼都不做就返回才是理想
處理程序結束而呼叫 DLL_PROCESS_DETACH 的時候,其他執行緒都已經結束或被強制終止,位址空間的一致性也靠不住。相依的 DLL 與執行階段的狀態同樣不可信賴,像釋放記憶體那樣的清理反而變得危險。官方也表示,這種情況下理想的處理常式是空的。5
需要持久化的資料,要在應用程式本來的結束處理中先寫出。 請不要把 DLL_PROCESS_DETACH 當成最後能收拾一切的地方。
flowchart TB
accTitle: DLL 卸載與處理程序結束的清理前提不同
accDescr: 只卸載 DLL 時處理程序仍會繼續運作因此需要清理資源但要避免在 DllMain 內等待自然結束,處理程序結束時則不能指望其他執行緒與資源的一致性所以基本上什麼都不做就返回,該保存的狀態要先在應用程式的結束處理中寫出
reason{"DLL_PROCESS_DETACH 的理由是?"}
reason -->|"只卸載 DLL"| alive["處理程序之後仍會運作"]
alive --> cleanup["正確清理殘留的資源"]
cleanup -.-> nojoin["不在 DllMain 內等待自然結束"]
reason -->|"處理程序結束"| processEnd["其他執行緒與資源的狀態不確定"]
processEnd --> empty["基本上什麼都不做就返回"]
empty -.-> save["保存先在應用程式的結束處理中進行"]
圖 11: 不只判斷「是否需要清理」,還要分開判斷處理程序是否繼續存在,以及清理時所用的資源是否可以信賴。
7. 無回應的調查 ── 找出等待載入器鎖定的一方與持有它的一方
7.1 用傾印確認互相等待的執行緒配對
取得凍結當下的傾印,檢查每條執行緒的堆疊。典型的指紋是這樣一組:在 ntdll.dll 中以 Ldr 開頭的載入器函式內等待鎖定的執行緒,以及在 DllMain 或靜態初始化子(dynamic initializer)裡等待別的東西的執行緒。
停在 LoadLibrary 呼叫過程中的執行緒,也是典型的登場角色。只要能把「等待鎖定的一方」和「持有它並在等別的東西的一方」串起來,幾乎就能判斷這是與載入器鎖定有關的死結。
flowchart TB
accTitle: 從無回應的傾印確認載入器鎖定的互相等待
accDescr: 在各執行緒的堆疊中尋找 Ldr 系列的鎖定等待與 DllMain 或靜態初始化子內的等待,確認兩者是否形成互相等待的關係,看不到典型組合時也要調查其他系統的無回應
dump["取得無回應中的傾印"] --> stacks["確認各執行緒的堆疊"]
stacks --> ldr["在 Ldr 系列等待鎖定"]
stacks --> init["在 DllMain・靜態初始化子內等待"]
ldr --> pair{"互相等待的關係能串起來?"}
init --> pair
pair -->|"能"| found["載入器鎖定的死結"]
pair -->|"看不出來"| more["也調查其他系統的無回應"]
圖 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 要盡量接近空的,資料的保存則放到應用程式本來的結束處理。
flowchart TB
accTitle: 重新檢視 DllMain 與其呼叫對象的順序
accDescr: 不只 DllMain,連靜態初始化與間接呼叫都納入範圍調查,把初始化移到靜態或載入完成之後,依結束的理由設計停止與清理,再用 Application Verifier 與傾印確認
scope["DllMain・靜態初始化・呼叫對象"] --> timing["把初始化移到靜態・載入完成後"]
timing --> first["也確認延遲之後的第一次存取"]
first --> shutdown["依結束理由設計清理"]
shutdown --> verify["用 Verifier・警告・傾印確認"]
圖 13: 不只追初始化的處理內容,連它的執行時點與結束時的生命週期都追下去,就能把這些禁令當成同一套設計方針來處理。
遇到邊界案例時要問的是:「這是可以在持有載入器鎖定的狀態下執行的工作嗎?」 不把拿不定主意的初始化塞進 DllMain,而是改變它的執行時點,這就是安全 DLL 設計的起點。
相關文章
- Windows 的 DLL 名稱解析機制 - 搜尋順序與 SxS
- 用 C++/CLI 包裝原生 DLL 的實務
- 多執行緒實務最佳做法 C++ 篇
- 虛假喚醒 ── 條件變數為何「沒被通知也會醒來」,以及 Windows 上正確的等待方式
- 用 WinDbg + SOS 解讀當機傾印 ── 收集之後的實務分析入門
- COM STA/MTA 基礎知識 - 執行緒模型與避免無回應的思考方式
相關諮詢領域
小村軟體有限公司承接啟動時與 DLL 載入時的無回應、死結原因調查(傾印分析),DllMain 與靜態初始化周邊的設計審查,以及把 C++/CLI 包裝與外掛 DLL 改造成安全初始化設計的工作。即使還在「只有特定環境啟動時會卡住」這種難以重現的階段,也歡迎與我們討論。
參考連結
-
Microsoft Learn, Dynamic-Link Library Best Practices. 關於 DllMain 是在持有載入器鎖定時被呼叫的,因此可呼叫的函式受到重大限制;理想的 DllMain 是空的虛設函式,初始化應盡可能延遲;建議使用編譯期的靜態初始化;只對必須及早偵測的失敗做最小限度的處理;以及用 Application Verifier 偵測 DllMain 裡的典型錯誤。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, DllMain entry point. 關於在進入點只應做簡單的初始化與結束處理;不可呼叫 LoadLibrary / FreeLibrary 的理由(載入順序的循環,以及在初始化前或結束後使用 DLL);Kernel32.dll 保證已載入,因此可在不載入其他 DLL 的範圍內呼叫;安全函式不存在詳盡的清單;User、Shell、COM 函式會造成存取違規;DLL 通知是序列化的,因此與其他執行緒、其他處理程序通訊會造成死結;以及與 CRT 連結時靜態物件的建構函式與解構函式也適用相同限制。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Initialization of Mixed Assemblies. 關於不可在載入器鎖定下執行 MSIL;不可把 DllMain 及其呼叫樹編譯成 MSIL,應以 #pragma unmanaged 處理;DllMain 直接試圖執行 MSIL 時會發出警告 C4747,但經由其他模組的間接執行無法偵測;以及靜態物件的動態初始化子也可能造成相同的問題。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). 關於停用 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知可減少執行緒建立與銷毀時的額外負擔;不可從與靜態 CRT 連結的 DLL 呼叫;以及靜態 TLS(thread_local 或 __declspec(thread))生效時不會進行這項最佳化。 ↩ ↩2 ↩3
-
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
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. 關於應定義鎖定階層並總是以相同順序取得;載入器會在呼叫 DllMain 前取得載入器鎖定,因此載入器鎖定應置於鎖定階層的最上層;要注意像 GetModuleFileName 這類間接取得載入器鎖定的 API 與私有鎖之間的取得順序;以及鎖定順序反轉造成死結的實例。 ↩ ↩2
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Win32 執行緒集區 API ── 用 CreateThreadpoolWork 做「不自己建立執行緒」的並行
原生程式碼裡是不是到處都在呼叫 CreateThread?本文依據一手資料解說 Vista 全面重新設計的 Win32 執行緒集區 API:work、timer、wait、io 四種物件、清理群組,以及回呼裡禁止做的事。
「沒有回應」的真面目 ── Windows 如何判定應用程式卡住,以及不卡住的設計
Windows 的「沒有回應」,是視窗 5 秒沒有取出訊息時由作業系統判定、並換成幽靈視窗的機制。本文從判定的內部運作,講到卡住的經典原因、把重工作移出 UI 執行緒的設計,以及無回應的調查程序。
虛假喚醒 ── 條件變數為何「沒被通知也會醒來」,以及 Windows 上正確的等待方式
條件變數的 wait 即使沒有通知到來也可能醒來(虛假喚醒)。本文從 Windows 的實作解開規格為何允許它,並用 Win32、C++ 與 C# 的程式碼示範以 while 與述詞撰寫的正確等待方式。
Time Travel Debugging ── 把長期運轉中無法重現的問題「錄下來」再倒回去
一個月才出現一次的問題,當機傾印只拍得到結果。本文說明如何用 WinDbg 的 Time Travel Debugging(TTD) 錄下執行並倒回,涵蓋 TTD.exe 的錄製設計、環形緩衝區、TTD.Calls 查詢,以及和傾印的分工。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
常見問題
整理諮詢這個主題時常見的問題。
- 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 之外完成處理。