「應用程式在啟動時卡住,但只有某個環境會。」「載入自己的 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 裡跑那些工作。
flowchart TB
accTitle: DllMain 被呼叫的四個時機
accDescr: DLL 載入時跑 DLL_PROCESS_ATTACH;處理程序每次啟動與結束執行緒時,每個已載入 DLL 都會跑 DLL_THREAD_ATTACH 與 DETACH;卸載或處理程序結束時跑 DLL_PROCESS_DETACH;靜態物件的建構函式也會經由 CRT 在這裡跑
load["DLL 載入"] --> pa["DLL_PROCESS_ATTACH"]
pa --> ta["DLL_THREAD_ATTACH(每次執行緒啟動)"]
ta --> td["DLL_THREAD_DETACH(每次執行緒結束)"]
td --> pd["DLL_PROCESS_DETACH(卸載或結束時)"]
pa -.-> crt["靜態物件的建構也在這裡跑"]
圖 1: DllMain 不只在載入時被呼叫,每次執行緒啟動與結束也會,而且靜態物件的初始化也作為其中一部分執行。
3. 載入器鎖定 ── 序列化每個通知的那一把鎖
為什麼只有 DllMain 的限制這麼嚴?答案在載入器的結構。
為了讓 DLL 載入、卸載與各種通知這一系列操作保持一致,作業系統載入器用每個處理程序一把載入器鎖定把工作序列化。重要的是:DllMain 是在持有這把載入器鎖定時被呼叫的。1 只要你還在 DllMain 裡,該處理程序裡每一次其他 DLL 載入、以及每一次執行緒啟動通知,都在等這把鎖被釋放。
從這個結構,禁令的理由一個接一個出來。
- 不可呼叫
LoadLibrary,因為它會造成載入器鎖定重入,或循環的載入順序依賴。也可能變成對初始化尚未完成的 DLL 呼叫函式。2 - 和其他執行緒同步很危險,因為你在等的那條執行緒會有需要載入器鎖定的時刻(啟動與結束時的通知、呼叫
GetModuleHandle家族 API 等)。你握著載入器鎖定等對方;對方等載入器鎖定──典型的鎖順序反轉。6 - User、Shell 與 COM 函式很危險,因為它們內部會載入其他系統元件。你在元件初始化前或拆除後碰到它,就會存取違規。2
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 等執行緒結束」是結構性死結,因為執行緒結束本身需要載入器鎖定。
重點是這不是「運氣不好才會發生」的那種事;結構上必定成立。文件要你把載入器鎖定當成應用程式定義的鎖階層的頂端(最先取得的那把)。在 DllMain 裡你已經握著那把頂層鎖,因此從那裡再去等別的東西都很危險──這樣記很有用。6
flowchart TB
accTitle: 載入器鎖定與私有鎖之間的鎖順序反轉
accDescr: 握著載入器鎖定的 DllMain 去取私有鎖,而握著那把私有鎖的工作者為了 GetModuleHandle 等去取載入器鎖定,取得順序反轉而死結
d["DllMain:握著載入器鎖定"] --> dg["去取私有鎖 G"]
w["工作者:握著私有鎖 G"] --> wl["去取載入器鎖定"]
dg -.-> dead["取得順序反轉造成的死結"]
wl -.-> dead
wl -.-> api["GetModuleHandle 等內部需要它"]
圖 3: 連 GetModuleHandle 這種看起來無害的 API 內部也需要載入器鎖定,因此與私有鎖的順序反轉可以成立。
此外,從 DllMain 裡呼叫 CreateThread 本身也不建議。建立出來的執行緒需要載入器鎖定來處理 DLL_THREAD_ATTACH 通知,因此在目前執行中的 DllMain 返回並釋放鎖之前,它無法開始跑。因此在 DllMain 裡等那條執行緒啟動或結束,立刻就是死結。還有生命週期問題──若 DllMain 返回後,DLL 在一條尚未開始跑的執行緒仍留下時被卸載,該執行緒的起始位址仍指向已釋放的程式碼,於是當機。3
4. C++ 開發者容易踩的兩顆地雷
地雷 1:全域物件的動態初始化。如第 2 章所說,靜態物件的建構函式在 DllMain 限制下執行。讀設定檔、架起記錄設施、初始化 COM、啟動執行緒──一旦你在 DLL 裡放一個建構函式做這類工作的全域,就是在執行「DllMain 裡不能做的事」。編譯期就固定的常數初始化(能做成 constexpr 的)是安全的;涉及函式呼叫的初始化應延遲。
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
圖 4: 即使「DllMain 是空的,所以安全」,一旦有複雜初始化的全域,同樣的危險就復活。
地雷 2:C++/CLI(混合組件)。用 C++/CLI 包裝原生 DLL 的組態(包裝文章所談的形狀)裡,有在載入器鎖定下執行 MSIL(受控程式碼)的危險。執行 MSIL 可能觸發 CLR 初始化或載入另一個組件。編譯器對 DllMain 直接執行 MSIL 的程式碼發出警告 C4747,但無法偵測經由另一個模組函式的間接執行。用 #pragma unmanaged 把 DllMain 與從它呼叫的函式編譯成原生,或採用根本沒有 DllMain 的組態。4
flowchart TB
accTitle: 載入器鎖定下的 MSIL 執行能否被偵測
accDescr: DllMain 直接執行 MSIL 的程式碼能被編譯器以警告 C4747 偵測,但經由另一個模組函式的間接執行不能,因此必須靠審查呼叫樹並堅持原生編譯來防止
d2["從 DllMain 的呼叫"] --> dir["直接執行 MSIL"]
d2 --> ind["經由另一個模組執行"]
dir --> c47["可用警告 C4747 偵測"]
ind --> nc["編譯器偵測不到"]
nc -.-> rv["用審查與 pragma unmanaged 防止"]
圖 5: C4747 只保護你免於直接執行。間接路徑只能靠審查抓到。
5. 正確設計 ── 把「延遲」當預設政策
官方最佳實踐的建議很清楚。1
- 能在編譯期(靜態)做完的初始化就做完。先問動態初始化能否換成靜態。
- 其餘延遲到第一次使用。只要第一次使用發生在 DLL 載入完成後呼叫的普通 API,初始化就在載入器鎖定外執行,幾乎整個 Windows API 都能安全使用。第一次存取的互斥可用
INIT_ONCE(一次性初始化)或 C++ magic statics(函式區域靜態)。延遲不是萬靈丹──若那次第一次存取本身是從DllMain或靜態初始化子發出,初始化子仍在載入器鎖定下跑,你又回到同一套限制。 - 只有必須及早偵測的失敗才破例。你可能有「設定檔壞了就讓載入本身失敗」的需求。即便如此,也只做到「嘗試並立刻失敗」的最小程度。
- 在 DLL_PROCESS_ATTACH 考慮
DisableThreadLibraryCalls。若 DLL 不用執行緒通知,就能拿掉通知成本本身(使用靜態 CRT 或靜態 TLS 時除外)。5 - 用 Application Verifier 檢查。
DllMain裡許多危險呼叫,Application Verifier 能在執行期偵測。1
flowchart TB
accTitle: DLL 初始化的設計指引
accDescr: 先考慮初始化能否做成編譯期靜態初始化;若不能,預設是延遲到第一次使用,DllMain 裡只留下必須作為載入失敗及早偵測的最小部分
q1{"能在編譯期決定嗎?"} -->|"能"| s["做成靜態初始化"]
q1 -->|"不能"| q2{"失敗必須在載入時偵測嗎?"}
q2 -->|"否"| lazy["延遲到第一次使用(預設)"]
q2 -->|"是"| min["DllMain 裡只做最小部分"]
lazy -.-> once["用 INIT_ONCE 或函式區域靜態互斥"]
圖 6: 決策順序是「能否靜態 → 能否延遲」,留在 DllMain 的只有必須及早偵測的最小部分。
要不要套用 DisableThreadLibraryCalls,可用下面的分支機械地決定。
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["不呼叫;處理通知"]
圖 7: 靜態 CRT、靜態 TLS、以及是否需要通知這三個條件,唯一決定該不該呼叫。
關於卸載時停止執行緒,官方文件給了具體協定。不要在 DLL_PROCESS_DETACH(經由 FreeLibrary 卸載時)「等待」工作者執行緒結束,形態是 (1) 用事件發結束訊號,(2) 執行緒側把工作收到一致狀態、回發訊號,並進入無限等待,(3) DllMain 側確認一致狀態後用 TerminateThread 收掉執行緒。3 看起來粗暴,但在「不可在 DllMain 裡等執行緒自然結束」這個約束內,它被記載為務實答案。
sequenceDiagram
accTitle: 卸載時停止執行緒的協定
accDescr: DllMain 用事件向工作者執行緒發結束訊號;工作者把工作收到一致狀態、回發訊號並進入無限等待;DllMain 確認一致狀態後終止該執行緒
participant D as DllMain(DETACH 處理)
participant W as 工作者執行緒
D->>W: 用事件發結束訊號
W->>W: 把工作收到一致狀態
W->>D: 發出一致性完成訊號並永遠等待
D->>W: 用 TerminateThread 終止
Note over D,W: 不等自然結束,因此不死結
圖 8: 用「等一致性訊號再切斷」取代「等自然結束」,避免與載入器鎖定相撞。
就第一原則而言,最安全的設計是避免在可卸載的 DLL 裡擁有執行緒,把執行緒所有權留在 EXE 側。
處理程序結束時的 DLL_PROCESS_DETACH 正好相反:什麼都不做就返回是理想。到這個時點,其他每條執行緒都已被強制終止,依賴的 DLL 或執行階段的狀態也不可靠。這裡做複雜工作只會造成死結與當機。必須持久化的資料應在應用程式自己的關閉路徑寫出;不要依賴這個通知。3
6. 碰到時怎麼調查
載入器鎖定造成的無回應有可辨識的指紋。
看無回應傾印裡的堆疊。對凍結當下取傾印,檢查各執行緒的堆疊。若找到一對:一條在 ntdll.dll 載入器函式(名稱以 Ldr 開頭的家族)裡等鎖,另一條在 DllMain 或靜態初始化子(dynamic initializer)裡等別的東西,幾乎就能確定。停在 LoadLibrary 呼叫中途的執行緒是另一個典型角色。
flowchart TB
accTitle: 載入器鎖定無回應的指紋
accDescr: 在無回應傾印裡,若同時找到在 ntdll 載入器函式裡等鎖的執行緒,以及在 DllMain 或靜態初始化子裡等別的東西的執行緒,幾乎可以當成載入器鎖定死結
dump["無回應傾印"] --> t1["在 Ldr 家族函式裡等鎖的執行緒"]
dump --> t2["在 DllMain 或靜態初始化子裡等待的執行緒"]
t1 --> pair{"兩者都在?"}
t2 --> pair
pair -->|"是"| conf["幾乎確定是載入器鎖定死結"]
pair -->|"否"| other["當成另一種無回應來調查"]
圖 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 的限制乍看像不合理的禁令清單。但一旦抓住「它是在持有頂層鎖──載入器鎖定──時被呼叫」這一點,每條禁令都是同一原則的重述。當成原則記住,遇到文件沒寫的邊界情況,你仍該問得出正確的問題:「這是我握著這把鎖時被允許做的工作嗎?」
相關文章
- Windows 的 DLL 名稱解析機制 - 以實務角度整理搜尋順序、Known DLLs、API set、SxS
- 要在 C# 中使用原生 DLL,C++/CLI 包裝是有力選項的理由 - 與 P/Invoke 的比較整理
- 多執行緒實務最佳實踐 C++ 篇 ── 以 RAII 與 jthread 從結構上消除事故
- 虛假喚醒 ── 條件變數為何「沒被通知也會醒來」,以及 Windows 上正確的等待方式
- 用 WinDbg + SOS 解讀當機傾印檔 ── 收集之後的實務分析入門
- COM STA/MTA 基礎 - 執行緒模型與避免 Hang 的思考方式
相關諮詢領域
小村軟體有限公司承接啟動時或 DLL 載入時無回應與死結的根本原因調查(傾印分析)、DllMain 與靜態初始化周邊的設計審查,以及把 C++/CLI 包裝與外掛 DLL 改成安全初始化設計。即使還在「只有某個環境啟動時才卡住」這種難以重現的階段,也可以諮詢。
參考連結
-
Microsoft Learn, Dynamic-Link Library Best Practices. 關於 DllMain 在持有載入器鎖定時被呼叫,因此能呼叫的函式受到嚴格限制;理想的 DllMain 是空的虛設函式,初始化盡可能延遲;建議編譯期靜態初始化;對必須及早偵測的失敗只做最小部分;以及用 Application Verifier 偵測典型的 DllMain 錯誤。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DllMain entry point. 關於在進入點只做簡單初始化與終止;為什麼不可呼叫 LoadLibrary / FreeLibrary(循環載入順序,以及在初始化前或終止後使用 DLL);Kernel32.dll 保證已載入,因此可在不載入其他 DLL 的範圍內呼叫;安全函式沒有窮盡清單;User、Shell 與 COM 函式會造成存取違規;DLL 通知是序列化的,因此與其他執行緒或處理程序通訊會造成死結;以及連結 CRT 時靜態物件的建構函式與解構函式適用相同限制。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. 關於在 DllMain 裡等執行緒結束會死結的結構(執行緒結束的 DLL_THREAD_DETACH 通知需要載入器鎖定);卸載時停止執行緒的協定(用事件發訊號、確認一致狀態後終止);處理程序結束時的 DLL_PROCESS_DETACH 其他執行緒已被強制終止、位址空間一致性沒有保證,因此理想的處理常式是空的;以及在 DllMain 建立執行緒會讓通知在初始化未完成時排隊並造成問題。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Initialization of Mixed Assemblies. 關於不要在載入器鎖定下執行 MSIL;不要把 DllMain 及其呼叫樹編譯成 MSIL,並以 #pragma unmanaged 處理;DllMain 試圖直接執行 MSIL 時會發出警告 C4747,但經由另一個模組的間接執行無法偵測;以及靜態物件的動態初始化子可能造成同樣問題。 ↩ ↩2
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). 關於停用 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知以降低執行緒建立與銷毀時的負擔;不要從與靜態 CRT 連結的 DLL 呼叫;以及靜態 TLS(thread_local 或 __declspec(thread))生效時不會執行此最佳化。 ↩ ↩2
-
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 上正確的等待方式
條件變數的等待即使沒有通知到來也可能返回(虛假喚醒)。本文從 Windows 實作說明規格為何允許它,並展示 Win32、C++ 與 C# 裡用 while 迴圈與述語正確等待的寫法。
具名管道實務 ── 從設計到安全性,整理 Windows 的標準 IPC
以實務角度解說 Windows 標準行程間通訊──具名管道。從一次資訊整理位元組/訊息模式的選擇、同時服務多個用戶端的伺服器設計、ACL 與偽裝的安全性,以及 .NET 的具名管道串流。
從睡眠恢復就壞掉的應用程式 ── Windows 電源事件的機制,以及耐得住恢復的業務應用寫法
打開筆電,業務應用程式的連線卻斷了──原因是設計從未把睡眠算進去。本文依一次資訊整理 WM_POWERBROADCAST 的通知流程、Modern Standby 的行為、斷線/重連設計、睡眠抑制,以及調查指令。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
常見問題
整理諮詢這個主題時常見的問題。
- 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 外做完工作。