轉
C++ 及其對象特性似乎與 Microsoft Windows Driver Model (WDM) 和 Windows Driver Foundation (WDF) 驅動程序的語義非常吻合。但是,對于內核模式驅動程序,C++ 語言的一些特性可能導致難以發現和解決的問題。為了幫助您進行合理選擇,本文將與您分享來自 Microsoft 關于使用 C++ 為 Windows 家族操作系統編寫內核模式驅動程序的調查的見解和建議。
此信息適用于以下操作系統:
Microsoft Windows 2000
Microsoft Windows XP
Microsoft Windows Server 2003
Microsoft Windows Vista
Microsoft Windows Server 2008
簡介
借助其對象特性,C++ 似乎與 Microsoft Windows Driver Model (WDM) 和 Windows Driver Foundation (WDF) 驅動程序的語義非常吻合,而且它為開發人員帶來的便利性和極富表現性的功能確實很有吸引力。但是,使用目前可用的 Microsoft 編譯器在 C++ 中編寫內核模式代碼涉及到一些技術問題,這些問題可能引起驅動程序代碼中的其他問題。
許多開發人員將 C++ 編譯器當作“超級 C”來使用,而沒有完全使用 C++ 的功能,因為 C++ 編譯器執行的某些規則比標準 C 編譯器更加嚴格,而且提供一些能夠在驅動程序上下文中安全使用的附加特性。通常認為 C++ 編譯器的這種使用方式適合于內核模式代碼。正是一些“高級的”C++ 特性引起了內核模式代碼中的問題,例如非 POD("plain old data",如 C++ 標準所定義)類和繼承、模板和異常。這些問題主要是由 C++ 實現和內核環境引起,而不是 C++ 語言的內在屬性。
Microsoft 正在調查與使用 C++ 為 Microsoft Windows 家族操作系統編寫內核模式驅動程序相關的問題。本文將與您分享 Micorsoft 開發人員關于如何權衡使用 C++ 編寫驅動程程序的利弊的最新見解。
本文內容適用于創建內核模式驅動程序的標準 Windows Driver Development Kit (DDK) 構建環境(從 Windows Server 2003 Service Pack 1 (SP1) DDK 開始)。如果您使用的構建環境或編譯器不是由 DDK 或 Windows Driver Kit (WDK) 提供的,那么您應該確定本文討論的各個問題是否適用于您的開發環境,以及是否存在其他問題。確定該問題的信息可以通過文檔的形式從編譯器提供者獲得,但是正如下面所描述的,您可能更有必要檢查生成的代碼和鏈接圖。
本文不打算討論如何使用 C++ 編寫內核模式驅動程序,而是假設您了解編寫內核模式驅動程序的基本原理。有關編寫內核模式驅動程序的一般信息,請參閱內核模式體系結構指南和 Windows DDK 文檔中的設備特定信息。
內核模式代碼注意事項
內核模式代碼必須考慮以下因素,以避免損壞數據、系統不穩定和操作系統沖突。
內核管理其自己的內存頁:
您必須處理好兩個相互矛盾的要求,即操作正確和最小內存占用。
• |
在不允許分頁時,如果要執行代碼,那么代碼和數據必須位于內存中。也就是說,當系統以 IRQL DISPATCH_LEVEL 或更高級別運行時,包含當前執行例程及其調用的任何例程或訪問的數據(以及在此函數調用鏈上的所有信息)等的頁面都必須鎖定到內存中,直到 IRQL 級別降低到 DISPATCH_LEVEL 以下。否則,就會發生頁面錯誤和系統沖突。
|
• |
要增加用戶應用程序可用的內存量,驅動程序應該使其代碼和數據片段能夠在合適的情形下分頁。這可以提升系統性能。
|
并不是隨時都可以使用所有的處理器資源。
• |
在 x86 系統上,浮點和多媒體單元就無法在內核模式中使用,除非特意請求。嘗試不恰當地使用它們不一定會導致提升的 IRQL 上的浮點錯誤(這將造成系統沖突),但是可能導致隨機進程中的數據不知不覺地損壞。不恰當地使用也可能造成其他進程中的數據損壞;這類問題通常難以調試。
|
• |
在 Intel Itanium 系統上,不是所有的浮點寄存器都可用。
|
資源(尤其是堆棧)具有嚴格的限制。用戶空間中“廉價”的資源在內核模式中可能非常昂貴,或者要求采取不同的方法來獲取。具體來講,內核堆棧的大小是 3 頁。
內核模式中沒有提供所有的標準庫(C 或 C++)。
• |
構建環境為內核模式提供的標準庫不必與用戶模式相同,因為內核模式的標準庫不依賴于 Win32 API,而且這些庫的編寫必須符合內核模式要求。標準庫的內核模式實現可能僅有有限的功能,或者受到其他內核模式屬性的制約。
|
• |
庫例程的用戶模式實現可能不能在內核模式下工作。有些例程不能鏈接,有些不能運行,還有些例程看似可以運行,但具有負面影響。
|
將 C++ 編譯器用于內核模式代碼
請務必牢記,編譯器生成的正確的目標代碼未必是您期望的代碼,其組織方式也未必是您所期望的。事實總是如此,但是 C++ 比 C 更可能發生這種問題。您必須檢查目標代碼,以確保與您的期望一致,或者至少能在內核環境中正確工作。
目前可用的 C++ 編譯器的輸出不能保證在所有平臺和版本的內核模式都能工作。代碼使用的 C++“高級”特性越多,就越可能出現互操作性問題。
內核模式代碼的關鍵區域
需要特別注意內核模式驅動程序中的以下區域。對于那些適合兩種語言(C 和 C++)的區域,C++ 代碼可能更容易出問題,因為 C++ 編譯器做了更多的自動化工作,而且您可能不會意識到它導致了一個問題。
• |
必須使用 KeSaveFloatingPointState 和 KeRestoreFloatingPointState 或 Windows DDK 文檔描述的其他機制恰當地保護浮點指令。
|
• |
InterlockedXxx 函數應當在生成的代碼中插入內存屏障指令。檢查輸出以確保您需要的屏障已經存在。
|
• |
必須仔細理解 volatile 關鍵字的語義,確保其指向一個“易變”級別的對象。可變項有時是指針,有時是對象本身,有時指針和對象都是可變的。將 volatile 應用到錯誤的對象上是常見的錯誤,因此應該仔細檢查該關鍵字的使用。例如,如果打算將一個穩定的指針指向可變的位置,那么應該(通過仔細閱讀代碼)確保代碼實現的不是一個指向穩定位置的可變指針。
|
• |
堆棧幀嚴格受限。例如,在 x86 系統上,每個線程可用的堆棧總量是 12K。
|
• |
函數源代碼中隱含的跳轉或內存使用會帶來發生意外的頁面錯誤的風險。特別地,編譯器生成的一些函數和數據對象可能不會馬上顯露出來。關于可能發生意外的對象的詳細信息,請參閱本文稍后的“內存中的代碼”。
|
• |
對于內聯函數(和 __forceinline)的使用,如果要確保代碼駐留在內存中,則應該與編譯器的優化規則交互。
• |
您期望其內聯的函數可能并不是內聯的。結果,使用這樣的函數可能造成頁面錯誤。
|
• |
編譯器可能在您不期望的情況下生成函數的內聯代碼。
|
|
安全和不安全的 C++ 構造
盡管目前還沒有嚴格的和可測試的“完全安全的” C++ 子集可用于內核模式代碼,但是一些有用的指南可用于區分安全與不安全的構造。
一個出色的經驗法則是,如果有一種明顯的方式可以將 C++ 構造重新整理為合法的 C 代碼,那么它可能是安全的。一個示例就是聲明的松散排序,包括在 for 語句中聲明變量。
C++ 中更嚴格的類型檢查可能不允許技術上合法但是語義上錯誤的構造。這種更嚴格的類型檢查是一種提高驅動程序可靠性的有用方式。
涉及類層次結構或模板、異常,或各種形式的動態類型的任何內容都可能不安全。使用這些構造需要對生成的目標代碼進行非常仔細的分析。將類的使用限制到 POD 類能夠顯著降低風險。
檢查生成的代碼
C 語言的一個最初的設計目標是能夠輕松確定生成的目標代碼的用途,因此 C 語言非常適合處理內核模式。而 C++ 是一種復雜得多的語言,這使得將其用于內核環境要困難得多。
要使用 C++ 編寫驅動程序,必須理解編譯器生成的代碼,確保目標代碼滿足內核模式要求,并確保其不會出現本文討論的問題。開發人員應該做好閱讀目標代碼、瀏覽鏈接圖的準備,以確保數據和代碼都位于合適的位置并且僅使用了內核安全的庫。檢查代碼的可分頁性、內聯函數和正確的程序順序。
我們強烈建議您立即閱讀和測試這方面的代碼,而不是等到編寫完源代碼再進行閱讀和測試。檢查早期的原型并測試潛在的疑難用法,這樣如果遇到了難以克服的 C++ 問題,您還有機會找到和實現替代解決方案。
內核模式驅動程序的 C++ 問題
Microsoft 開發人員已經發現 C++ 中容易出現特定的內核模式驅動程序問題的一些區域。
內存中的代碼
使用 C++ 編寫內核模式驅動程序面臨的最嚴重的問題是內存頁面的管理,尤其是內存中的代碼,而不是數據。大型驅動程序的可分頁性非常重要,而且分頁代碼并不總是在內存中。在系統進入無法進行分頁的狀態之前,所有將要用到的代碼都必須駐留在內存中。
C++ 編譯器為非 POD 類和模板生成代碼的方式使得很難確定執行一個函數所需的所有代碼的去向,因此很難將代碼安全地分頁。編譯器能夠為至少下列對象自動生成代碼。如果這些對象不一致,開發人員無法直接控制插入這些代碼的節,這意味著當需要這些代碼時,它們卻可能已經被分頁出去。
• |
編譯器生成的代碼,比方構造函數、析構函數、類型轉換和賦值運算符。(雖然可以明確地提供這些代碼,但是需要仔細確認是否需要提供它們。)
|
• |
Ajdustor thunk,用于在層次結構中的類之間進行轉換。
|
• |
虛函數 thunk,用于實現虛函數調用。
|
• |
虛函數表 thunk,用于管理基類和多態。
|
• |
模板代碼正文,在首次使用時插入,除非對其進行了顯式實例化。
|
• |
虛函數表本身。
|
C++ 編譯器沒有提供機制來直接控制這些實體在內存中的位置。C++ 的設計并沒有考慮控制內存位置的必要性。#pragma alloc_text 不能用于控制成員函數的位置,因為無法命名該成員函數(有多種原因)。編譯器生成的函數、擴展模板正文和編譯器生成 thunk 的 #pragma code_seg 的作用域比較模糊。沒有控制虛函數表的位置的機制,因為從編譯器的角度看,這種表既不是代碼也不是數據(虛函數表獨占了一節)。
如果頭文件中的函數聲明為內聯,但是編譯器沒有生成該函數的內聯代碼,那么根據使用該函數的位置,它可能被插入多個代碼段中。實例化一個類模板時,它會在首次使用它的節中生成,并且通常不會立即發現是哪一節生成了它。這兩個問題會造成不應該分頁的代碼變得可以分頁,或者應該分頁的代碼卻無法分頁。
如果使用了一種類層次結構,那么是否需要在訪問派生類時將基類代碼放入內存中完全取決于從派生類調用的基類函數(和編譯器是否能夠內聯這些函數),以及在哪些節插入這些函數。例如,如果派生類提供了一種不需要使用基類方法的方法,那么基類代碼就無需駐留在內存中。但是,難以確定何時屬于這種情形。另外,該層次結構及其類使用的任何 thunk 也可能需要駐留在內存中。
堆棧
編譯器始終能夠在堆棧上自由生成額外數據,比如創建臨時對象、延遲調用清除和其他以隱蔽方式使用堆棧的操作。有關單個函數使用堆棧的方式,C 和 C++ 幾乎沒有區別,但是由于額外的機制通常會導致更多的函數調用,所以 C++ 使用的堆棧總數常常會更多。應當牢記堆棧大小,在任何編程語言中,當堆棧空間受限時都應如此。
異常也會影響到堆棧。請參閱本文稍后的“異常與 RTTI”。
動態內存
驅動程序開發工具(比如 Driver Verifier)依靠帶有標記的內存來驗證驅動程序中內存使用。使用 operator new 和 operator delete 分配和釋放內存會削弱這些工具檢測驅動程序代碼中的內存泄漏和其他問題的能力。
在用戶空間中,operator new 和 operator delete 非常方便,但是如果驅動程序使用了多個內存池或帶標記的內存,那么這兩個運算符會變得很麻煩。因為 "placement new" 帶有額外的操作數,所以將選擇內存池或生成標記所需的信息傳入到重載的 operator new 中,但是這并不比直接使用內存函數容易多少。因為沒有帶有額外的參數的 "placement delete" 可以傳入標記或池類型,所以使用 operatordelete 時無法傳入標記(或內存控制,如果需要),也就不可能檢查位于釋放位置的標記是否是預期的標記,這極大地影響了使用標記內存的好處。不用提供標記就可以對內存進行 delete 操作,但是您需要確定不在驅動程序代碼中使用標記的風險和缺點是否大于其便利性。
內存跟蹤工具通常記錄進行分配的函數的返回地址。一些 C++ 編譯器將 operator new 實現為函數,這使得所有內存分配似乎都來自同一個位置,從而影響了內存跟蹤工具在這方面的功能。雖然這個問題可以解決,但是您必須確定這樣做的好處是否大于直接使用內存分配的好處。
庫
創建和使用庫時需要考慮許多明顯因素:
• |
導出的 C++ 函數的名稱可能因版本不同而異。
|
• |
不是用戶模式中所有可用的函數都能夠在內核模式庫中使用。
|
• |
標準模板庫設計用于處理來自單個 DLL 的數據對象。
|
C++ 函數的導出基于它們的完整簽名,而不是(像 C 函數那樣)只基于其名稱。C++ 函數的名稱被改編為包含類型信息,該信息是其簽名的一部分。盡管名稱改編的規則相當穩定,但是無法保證改編的名稱不隨編譯器版本的變化而改變。因此,無法將 C++ 函數可靠地導出到不同版本的庫中,但是可以表示為 extern "C" 的 C++ 函數能夠做到。另外,使用 .def 文件能夠幫助減輕這個問題的風險。注意:extern "C" 函數的獨特性僅基于函數名稱,而不是像 C++ 中那樣基于整個簽名。
不是所有的庫函數都可以在內核模式下使用,尤其是與“高級” C++ 語言特性相關的函數。標準模板庫是實現許多 C++ 概念(例如大小可變的數組)的“常用”方法。但是,簡單地假定標準模板庫存在且可用是不安全的。盡管標準模板庫的大部分內容都實現為頭文件中的源代碼,但是這個庫也會偶爾使用內核環境中不可用或沒有用處的庫函數或其他特性。
標準模板庫還假設其使用的每個數據對象都存在于單個 DLL 中。盡管在大多數情況下,可以跨越 DLL 邊界傳遞 POD 對象的引用,但是傳遞比較復雜的結構(比如列表)的引用可能導致運行時錯誤并且難以診斷。已知問題包括:如果沒有為一個 DDL 分配內存,那么釋放它的內存就會導致失敗(至少在進行調試模式編譯時是這樣);各個 DDL 的 "end of list" 標記各不相同,這會導致意外的超越列表搜索。您必須清楚這些問題并采取步驟來預防它們。
我們不建議在內核模式驅動程序中使用標準模板庫函數,因為無法假定標準模板庫已經存在并且能正常工作。對于內核模式代碼,準確理解特定數據結構的實現方法有助于確保該數據結構不會違背內核空間的要求。專門的實現也可能比更常用的標準模板庫函數更小,但是庫通常能夠更好地滿足內核空間的要求。
異常與 RTTI
C++ 異常很有吸引力,但是它們很難在內核模式中實現。C++ 異常需要一個內核模式安全的庫,目前還不存在這種庫。異常還會帶來無法回避的運行時問題,因為對于大小受限的堆棧來說,拋出異常時生成的異常記錄是很大的對象。在 x86 系統上,異常記錄不是特別大(但是比許多一般的堆棧幀更大),但是 Intel Itanium 系統上的異常記錄非常大:3K 到 4K,或者可用的 24K 堆棧空間的 1/8 到 1/6。為了保持驅動程序到 64 位平臺的可移植性,應該盡量避免使用異常,即使在 x86 體系結構上也是如此。rethrow 運算符會在堆棧上生成多個異常記錄。注意:結構化異常處理 (__try /__except/__finally) 可以在內核模式下使用,但是空間問題仍然存在。C++ 異常在語義上有許多微妙之處,可以防止將其簡單地映射到結構化異常處理上。
運行時類型信息 (RTTI) 還需要一個庫,內核模式的C++ 中目前還沒有這個庫。迄今為止,內核模式代碼中就這個庫的請求(如果有)非常少。現在還無法確定這種需求的缺乏是因為其他問題的掩蓋,還是因為它對內核模式無用。
編譯器版本
盡管 C++ 語言標準很穩定,但其實現技術一直在發展。因此,編譯器版本可能會更改生成代碼的操作方式。這類修改不太可能影響到用戶模式代碼,但是會影響到內核模式代碼。在內核模式代碼中,更多的底層實現被曝露給(有時來自)驅動程序開發人員;無法保證內核模式代碼在版本之間的互操作性。
您應該謹慎控制兩個驅動程序之間或一個驅動程序和操作系統之間的任何接口,通常使用 C 而不用 C++ 編寫這些結構。否則,C++ 實現的版本間不兼容性可能導致互操作失敗。
靜態變量與全局范圍變量和初始化
C++ 靜態變量(在全局或局部范圍內聲明)為驅動程序帶來了許多問題。
C++ 標準允許在局部范圍內聲明 static 變量,以在首次使用時(首次進入該范圍時)對其初始化。這種實現方式不但會造成初始化期間的競爭條件,還會帶來與意外的線程間數據共享相關的高風險,因為聲明為 static 的變量是全局靜態,而不是基于每個線程。最好在全局范圍內顯式地處理(在線程間共享的)全局靜態數據,以確保訪問保護適合于所應用的條件。
如果 C++ 全局對象要求聲明初始化(全局構造函數),則無法調用這個構造函數。不應該使用需要構造函數的全局對象,或者必須開發一種機制來確保可以調用該構造函數。網絡上有一些消息來源聲稱已經解決這個問題,其中可能有適合您的解決方案。
C++ 標準沒有指定全局對象的初始化順序,所以即使存在一種調用全局對象構造函數的機制,初始化順序也必須由驅動程序明確地控制,或者該順序無關緊要。
結束語
Microsoft 既不認可也不反對使用 C++ 編寫內核模式驅動程序。這種保守態度一部分源于本文所述問題,也有一部分源于支持所有平臺的需要。在嘗試使用 C++ 進行任何內核模式開發之前,您必須清楚本文講述的已知問題和風險,也應該警惕其他的未知問題。
Microsoft 一直在調查研究在內核中更有效地使用 C++ 的方法。目前還不知道適用于用戶模式代碼的所有 C++ 特性是否都可用于內核模式代碼。
• |
將 C++ 編譯器用作“超級 C”通常是可行的,但是編譯器的這種用法會給開發人員帶來一定的風險。
|
• |
自動識別存在問題的 C++ 構造目前還不實際,因此開發人員必須仔細分析編譯器輸出,以確保生成的代碼適合于內核模式。
|
• |
在使用 C++ 之前,應該認真評估 C++ 是否適合您。具體來講,您應該在開發過程的早期測試 C++ 構造,以確保這些構造不會導致本文所述的問題,或者違背內核模式驅動程序的編寫原則。
|
• |
本文討論的一些問題可能直到開發結束時才會變得明朗,而且解決這些問題可能需要完全重寫代碼。
|
• |
一些非常嚴重的問題很難在測試驅動程序時根據需要重現,所以,具有內在的不可靠性的驅動程序也許能夠在預期的時間段運行良好,但是會隨機地出現失敗。這進一步增加了仔細分析的要求。
|
細心編碼和仔細檢查生成的代碼可以避免許多問題。也有一些問題非常難以克服。所有這些問題都需要開發人員格外小心和仔細分析。