使用 C++ 编写内核模式驱动程序的优点与缺点
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++ 构造,以确保这些构造不会导致本文所述的问题,或者违背内核模式驱动程序的编写原则。
• 本文讨论的一些问题可能直到开发结束时才会变得明朗,而且解决这些问题可能需要完全重写代码。
• 一些非常严重的问题很难在测试驱动程序时根据需要重现,所以,具有内在的不可靠性的驱动程序也许能够在预期的时间段运行良好,但是会随机地出现失败。这进一步增加了仔细分析的要求。
细心编码和仔细检查生成的代码可以避免许多问题。也有一些问题非常难以克服。所有这些问题都需要开发人员格外小心和仔细分析。