转 C++ 及其对象特性似乎与 Microsoft Windows Driver Model (WDM) 和 Windows Driver Foundation (WDF) 驱动程序的语义非常吻合。但是,对于内核模式驱动程序,C++ 语言的一些特性可能导致难以发现和解决的问题。为了帮助您进行合理选择,本文将与您分享来自 Microsoft 关于使用 C++ 为 Windows 家族操作系统编写内核模式驱动程序的调查的见解和建议。 此信息适用于以下操作系统: 借助其对象特性,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 文档中的设备特定信息。 内核模式代码注意事项内核模式代码必须考虑以下因素,以避免损坏数据、系统不稳定和操作系统冲突。 内核管理其自己的内存页:
并不是随时都可以使用所有的处理器资源。
资源(尤其是堆栈)具有严格的限制。用户空间中“廉价”的资源在内核模式中可能非常昂贵,或者要求采取不同的方法来获取。具体来讲,内核堆栈的大小是 3 页。 内核模式中没有提供所有的标准库(C 或 C++)。
将 C++ 编译器用于内核模式代码请务必牢记,编译器生成的正确的目标代码未必是您期望的代码,其组织方式也未必是您所期望的。事实总是如此,但是 C++ 比 C 更可能发生这种问题。您必须检查目标代码,以确保与您的期望一致,或者至少能在内核环境中正确工作。 目前可用的 C++ 编译器的输出不能保证在所有平台和版本的内核模式都能工作。代码使用的 C++“高级”特性越多,就越可能出现互操作性问题。 内核模式代码的关键区域
安全和不安全的 C++ 构造 一个出色的经验法则是,如果有一种明显的方式可以将 C++ 构造重新整理为合法的 C 代码,那么它可能是安全的。一个示例就是声明的松散排序,包括在 for 语句中声明变量。 C++ 中更严格的类型检查可能不允许技术上合法但是语义上错误的构造。这种更严格的类型检查是一种提高驱动程序可靠性的有用方式。 涉及类层次结构或模板、异常,或各种形式的动态类型的任何内容都可能不安全。使用这些构造需要对生成的目标代码进行非常仔细的分析。将类的使用限制到 POD 类能够显著降低风险。 检查生成的代码 要使用 C++ 编写驱动程序,必须理解编译器生成的代码,确保目标代码满足内核模式要求,并确保其不会出现本文讨论的问题。开发人员应该做好阅读目标代码、浏览链接图的准备,以确保数据和代码都位于合适的位置并且仅使用了内核安全的库。检查代码的可分页性、内联函数和正确的程序顺序。 我们强烈建议您立即阅读和测试这方面的代码,而不是等到编写完源代码再进行阅读和测试。检查早期的原型并测试潜在的疑难用法,这样如果遇到了难以克服的 C++ 问题,您还有机会找到和实现替代解决方案。 内核模式驱动程序的 C++ 问题Microsoft 开发人员已经发现 C++ 中容易出现特定的内核模式驱动程序问题的一些区域。 内存中的代码 C++ 编译器为非 POD 类和模板生成代码的方式使得很难确定执行一个函数所需的所有代码的去向,因此很难将代码安全地分页。编译器能够为至少下列对象自动生成代码。如果这些对象不一致,开发人员无法直接控制插入这些代码的节,这意味着当需要这些代码时,它们却可能已经被分页出去。
C++ 编译器没有提供机制来直接控制这些实体在内存中的位置。C++ 的设计并没有考虑控制内存位置的必要性。#pragma alloc_text 不能用于控制成员函数的位置,因为无法命名该成员函数(有多种原因)。编译器生成的函数、扩展模板正文和编译器生成 thunk 的 #pragma code_seg 的作用域比较模糊。没有控制虚函数表的位置的机制,因为从编译器的角度看,这种表既不是代码也不是数据(虚函数表独占了一节)。 如果头文件中的函数声明为内联,但是编译器没有生成该函数的内联代码,那么根据使用该函数的位置,它可能被插入多个代码段中。实例化一个类模板时,它会在首次使用它的节中生成,并且通常不会立即发现是哪一节生成了它。这两个问题会造成不应该分页的代码变得可以分页,或者应该分页的代码却无法分页。 如果使用了一种类层次结构,那么是否需要在访问派生类时将基类代码放入内存中完全取决于从派生类调用的基类函数(和编译器是否能够内联这些函数),以及在哪些节插入这些函数。例如,如果派生类提供了一种不需要使用基类方法的方法,那么基类代码就无需驻留在内存中。但是,难以确定何时属于这种情形。另外,该层次结构及其类使用的任何 thunk 也可能需要驻留在内存中。 堆栈 异常也会影响到堆栈。请参阅本文稍后的“异常与 RTTI”。 动态内存 在用户空间中,operator new 和 operator delete 非常方便,但是如果驱动程序使用了多个内存池或带标记的内存,那么这两个运算符会变得很麻烦。因为 "placement new" 带有额外的操作数,所以将选择内存池或生成标记所需的信息传入到重载的 operator new 中,但是这并不比直接使用内存函数容易多少。因为没有带有额外的参数的 "placement delete" 可以传入标记或池类型,所以使用 operatordelete 时无法传入标记(或内存控制,如果需要),也就不可能检查位于释放位置的标记是否是预期的标记,这极大地影响了使用标记内存的好处。不用提供标记就可以对内存进行 delete 操作,但是您需要确定不在驱动程序代码中使用标记的风险和缺点是否大于其便利性。 内存跟踪工具通常记录进行分配的函数的返回地址。一些 C++ 编译器将 operator new 实现为函数,这使得所有内存分配似乎都来自同一个位置,从而影响了内存跟踪工具在这方面的功能。虽然这个问题可以解决,但是您必须确定这样做的好处是否大于直接使用内存分配的好处。 库
C++ 函数的导出基于它们的完整签名,而不是(像 C 函数那样)只基于其名称。C++ 函数的名称被改编为包含类型信息,该信息是其签名的一部分。尽管名称改编的规则相当稳定,但是无法保证改编的名称不随编译器版本的变化而改变。因此,无法将 C++ 函数可靠地导出到不同版本的库中,但是可以表示为 extern "C" 的 C++ 函数能够做到。另外,使用 .def 文件能够帮助减轻这个问题的风险。注意:extern "C" 函数的独特性仅基于函数名称,而不是像 C++ 中那样基于整个签名。 不是所有的库函数都可以在内核模式下使用,尤其是与“高级” C++ 语言特性相关的函数。标准模板库是实现许多 C++ 概念(例如大小可变的数组)的“常用”方法。但是,简单地假定标准模板库存在且可用是不安全的。尽管标准模板库的大部分内容都实现为头文件中的源代码,但是这个库也会偶尔使用内核环境中不可用或没有用处的库函数或其他特性。 标准模板库还假设其使用的每个数据对象都存在于单个 DLL 中。尽管在大多数情况下,可以跨越 DLL 边界传递 POD 对象的引用,但是传递比较复杂的结构(比如列表)的引用可能导致运行时错误并且难以诊断。已知问题包括:如果没有为一个 DDL 分配内存,那么释放它的内存就会导致失败(至少在进行调试模式编译时是这样);各个 DDL 的 "end of list" 标记各不相同,这会导致意外的超越列表搜索。您必须清楚这些问题并采取步骤来预防它们。 我们不建议在内核模式驱动程序中使用标准模板库函数,因为无法假定标准模板库已经存在并且能正常工作。对于内核模式代码,准确理解特定数据结构的实现方法有助于确保该数据结构不会违背内核空间的要求。专门的实现也可能比更常用的标准模板库函数更小,但是库通常能够更好地满足内核空间的要求。 异常与 RTTI 运行时类型信息 (RTTI) 还需要一个库,内核模式的C++ 中目前还没有这个库。迄今为止,内核模式代码中就这个库的请求(如果有)非常少。现在还无法确定这种需求的缺乏是因为其他问题的掩盖,还是因为它对内核模式无用。 编译器版本 您应该谨慎控制两个驱动程序之间或一个驱动程序和操作系统之间的任何接口,通常使用 C 而不用 C++ 编写这些结构。否则,C++ 实现的版本间不兼容性可能导致互操作失败。 静态变量与全局范围变量和初始化 C++ 标准允许在局部范围内声明 static 变量,以在首次使用时(首次进入该范围时)对其初始化。这种实现方式不但会造成初始化期间的竞争条件,还会带来与意外的线程间数据共享相关的高风险,因为声明为 static 的变量是全局静态,而不是基于每个线程。最好在全局范围内显式地处理(在线程间共享的)全局静态数据,以确保访问保护适合于所应用的条件。 如果 C++ 全局对象要求声明初始化(全局构造函数),则无法调用这个构造函数。不应该使用需要构造函数的全局对象,或者必须开发一种机制来确保可以调用该构造函数。网络上有一些消息来源声称已经解决这个问题,其中可能有适合您的解决方案。 C++ 标准没有指定全局对象的初始化顺序,所以即使存在一种调用全局对象构造函数的机制,初始化顺序也必须由驱动程序明确地控制,或者该顺序无关紧要。 结束语Microsoft 既不认可也不反对使用 C++ 编写内核模式驱动程序。这种保守态度一部分源于本文所述问题,也有一部分源于支持所有平台的需要。在尝试使用 C++ 进行任何内核模式开发之前,您必须清楚本文讲述的已知问题和风险,也应该警惕其他的未知问题。 Microsoft 一直在调查研究在内核中更有效地使用 C++ 的方法。目前还不知道适用于用户模式代码的所有 C++ 特性是否都可用于内核模式代码。
细心编码和仔细检查生成的代码可以避免许多问题。也有一些问题非常难以克服。所有这些问题都需要开发人员格外小心和仔细分析。 |