现在的位置: 首页 > 综合 > 正文

使用 C++ 编写内核模式驱动程序的优点与缺点

2013年08月27日 ⁄ 综合 ⁄ 共 5529字 ⁄ 字号 评论关闭

使用 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++ 函数的名称被改编为包含类型信息,该信息是其签名的一部分。尽管名称改

抱歉!评论已关闭.