内核模式的 DLL

Tim Roberts


版权所有 (C) 2003,Tim Roberts。保留所有权利


Win32 用户模式程序员已经习惯于使用和创建动态链接库,或者叫 DLL,来划分应用或者达到有效的代码重用。典型的应用程序包括许多 DLL,仔细的设计可以使得这些 DLL 能被多次重用。


内核驱动程序作者常常不知道也可以在内核模式中正确地使用这一概念。标准的 DDK 甚至还带有好几个示例(例如,storage/changers/class)。在本文中,我将演示一个可以工作的(尽管微不足道)内核 DLL 的例子。


基础


从 C 语言源代码看来,内核 DLL 实质上等同于用户模式 DLL。主要的不同在于不能在内核 DLL 中调用任何用户模式 API。这并不奇怪。


使用内核 DLL 就像用户模式 DLL 一样:链接器在构建 DLL 时生成一个导入库,然后将此库包含到将要使用此 DLL
的任何驱动程序的目标库列表中。既不需要注册表技巧,也不需要任何特别的动作来起停该 DLL。内核 DLL
将随任何引用之的其他驱动程序自动加载,而随最后一个引用之的驱动程序自动卸载(注 1)。


也可以从正常的 WDM 驱动程序导出入口点。操作系统中有许多驱动程序为其他驱动程序的使用导出了入口点。例如,无所不在的
NTOSKRNL.EXE —— 其中包含了所有的 Ex、Fs、Io、Ke、Mm、Nt 以及 Zw 入口点,事实上会被每个驱动程序用到 ——
也只不过就是一个标准的带有导出的内核驱动程序,正像我们在这儿讨论的 DLL 一样。


深入


好,现在让我们进入一些细节里来。本工程的所有源文件均可从 http://www.wd-3.com/downloads/kdll.zip 得到。


当创建一个导出的驱动程序时,要进行的最重要的步骤是在 sources 文件中指定 TARGETTYPE 宏:





 
   

这个类型告诉创建系统我们的工程将构建一个要导出函数的内核模式驱动程序。如果你像普通内核模式驱动程序那样保持 TARGETTYPE 设置为 DRIVER,则其他驱动程序将不能使用你的导出。


你的 DLL 必须包含标准的 DriverEntry
入口点,不过实际上系统不会调用它。这个需求是创建系统的人为限制,因为它会为每个内核驱动程序把 /ENTRY:DriverEntry
添加到链接器选项中。EXPORT_DRIVER
类型的驱动程序也需要像普通驱动程序那样工作,而且创建系统并不能说明我们会不会调用入口点,因此我们为只导出的 DLL 也必须提供一个伪入口点。


如果你确实需要在加载和卸载时执行一次性操作,那你就应该到处两个特殊的入口点,叫做 DllInitialize 和 DllUnload:





 
   

传入 DllInitialize 的 RegistryPath 字符串具有如下形式:





 
   

只需要在只导出类型的 DLL 中包含 DllInitialize 例程 —— 就是说,在一个仅作为 DLL
来使用并且不是真实的硬件驱动程序的驱动程序中。不需要为这种驱动程序定义服务相关的注册表键。因而,RegistryPath
字符串不可能也不希望对你有用,因为它很可能是一个不存在的注册表键的名字。


兼容警告:在 Windows 98 Gold 中有一个缺陷,如果你包含了 DllInitialize 入口点,你的 DLL
将无法加载。另外,Windows 98 第二版以及 Windows Me 从不会调用 DllUnload 入口点。在这些系统中,一个内核 DLL
一旦加载,将永远存在。


声明导出


除去这两个特殊的入口点,你可以创建任何你觉得方便的入口点名称。你只需向链接器标明这些入口点名称。做这件事有两个方法。出于示例目的,我将从我们的 DLL 导出一个功能性入口点:





 
   

有两个方法来告诉链接器你想导出一个函数。第一个方法是在 .DEF 文件中列举名称。.DEF 文件对任何一个做过 Win16 或者
Win32 编程的人来说很常见。这是一个特殊的文件,用于向编译器给出那些不便在命令行上包括的指令。就目前而言,它要罗列我们想从 DLL
中导出的例程的名称。编译器使用这一列表在 DLL 中创建符号表,并且创建一个可以在其他工程中使用以使之可以调用我们的 DLL 的导入库。我们的
.DEF 文件看起来像这样:





 
   

DllInitialize 和 DllUnload 必须全部标示为 PRIVATE。这将告诉链接器从 DLL 可执行文件中导出此符号,但不要将其置入创建的导入库中。如果它们没有被标示为 PRIVATE 创建系统会标记一个错误。


导入库是将函数名字映射到包含该函数的 DLL 中去的基础机制。你在 Win32 程序中使用的几乎所有的库都是导入库,包括诸如
ntdll.lib 和 ntoskrnl.lib 等内核库以及像 kernel32.lib、user32.lib 和 gdi32.lib
等用户模式的库。这些库实际上并不包含任何代码。相反,它们包含一组链接器表,表中包含一些有点像这种意思的信息,“将名字
MySampleFunction 映射到 MY.DLL 中的 _MySampleFunction@4”。


链接器把这些信息嵌入到可执行文件中,于是操作系统在 EXE 或者 DLL 最终加载到内存中以后可以把所有这些零碎串起来。


我们必须在 sources 文件中使用特殊的 DLLDEF 宏来指明 .DEF 文件的名字:





 
   

标示到处入口点的第二个方法是在源代码中使用 declspec 属性:





 
   

这和在 .DEF
文件中列出名字的作用一样。通常,我乐于减少工程中文件的数目,因为这可以自动减少出错的机会。但是眼下,这儿有一个问题:DllInitialize 和
DllUnload 必须被标志为 PRIVATE 导出,并且就我所知,除了使用 .DEF 文件还没有别的办法可以指定一个导出为
PRIVATE。因此,你将必须使用 .DEF 文件,至少是为这两个名字。其他的导出究竟是包括到 .DEF 文件中还是使用
__declspec(dllexport) 标示它们,完全随你。


本文的示例源代码使用 C 语言。如果你希望从一个用 C++ 写就的 DLL 中导出函数,你还有另外的一个因素需要考虑。因为 C++
允许多个具有不同参数列表的函数同名,C++ 编译器会“修饰”它们的符号名字,使用附加的、用于特别标示返回类型和参数列表的字符。例如,当把
SampleDouble 函数编译到一个 C++ 模块中时,其实际名字是 ?SampleDouble@@YGJPAH@Z。如果你在其他 C++
驱动程序中尝试调用此函数,它可以工作,但如果你试图从一个 C 语言驱动程序中调用它,则外部的名字不能匹配。


解决此问题的方法是基于 extern 声明使用一个特殊的语言修饰符,就像这样:





 
   

有了这些了解,我们现在可以打开 DDK 命令环境来构建了。对本示例来讲,我们要构建一个叫 sample.sys
的文件。我们将此文件复制到驱动程序的习惯位置,%WINDIR%/SYSTEM32/DRIVERS,则已经准备好使用我们的 DLL
了。可以使用“dumpbin”命令来验证导出,就像对一个用户模式 DLL 那样:





 
   

我们还可以选择使用 Platform SDK 中附带的 Dependency Walker 小程序(DEPENDS.EXE)来查看此文件:


图从略 —— 译者注。


注意,底部窗格中显示出所有模块的子系统为“Native”。如果在对一个你创建的驱动程序或者内核 DLL 查看依赖时看到了“Win32”子系统,那就意味着你调用了用户模式的 API 函数。


调用 DLL


为了方便调用 DLL 中的入口点,我们可能想创建一个头文件,包含到我们的调用驱动程序中。就此例来说,我们可以使用如下简单的 sample.h:





 
   

我们在这儿使用了若干个使文件更灵活而且更易于阅读的宏。这些宏定义于 中,该头文件在大多数驱动程序中经由 被自动包含。


EXTERN_C 在 C++ 源文件中展开为 extern "C",而在 C 源文件中展开为简单的老式的 extern。这保证了在调用程序中不会产生不需要的修饰。


DECLSPEC_IMPORT 展开为 Visual C++ 的指定符 __declspec(dllimport)。这是上面用到过的
__declspec(dllexport) 的对照物,用以告诉编译器对该函数的调用将在运行时从一个 DLL
处得到满足,而不是在链接时被加载。这允许编译器和链接器优化对 SampleDouble 的运行时联接(注 2)。


当你定义了这样一个头文件,其中包含了一个 DLL 的函数原型,则把此头文件也包含到 DLL
工程中是个好主意。这样做将使你在编译时可以检查函数的原型是否正确。所有的 DECLSPEC_IMPORT
指示会导致编译器的警告。你可以通过往头文件中放置一些条件编译来消除这个小问题:





 
   

在你的 DLL 工程中定义符号 SAMPLE_INTERNAL,会导致头文件中没有任何 __declspec 指示。其他包含此头文件的工程中没有定义这一符号,就意味着头文件里的确包含有这些指示。


测试


为了测试我们的例子,我把以下代码加入到了一个我正在手上的内核驱动程序的 DriverEntry 中:





 
   

我把 sample.lib 从示例的构建目录复制到了测试程序的构建目录,并且将“sample.lib”加入到了 sources 里的 TARGETLIBS 宏中。实际上,因为我们的测试驱动程序实在是太简单了,整个 sources 文件都在这儿了:





 
   

然后我创建了我的驱动程序并且把二进制文件复制到了 SYSTEM32/DRIVERS。这是一个老式的 NT 4 驱动程序,所以我用“net start”来启动它,用“net stop”来停止。生成的调试日志看起来像这样:





 
   

注意我们的 DLL 先于调用驱动程序开始执行,而在调用驱动程序关闭后卸载。这,再一次,类似于 Win32 用户模式的 DLL 操作:系统不知道我们是否计划在 DriverEntry 中调用 DLL,因此它在启动调用驱动程序之前确保所有的 DLL 均已就绪。


你可以看一下传递到我的内核 DLL 的 DllInitialize 入口点中的注册表路径。当我告诉你在我的注册表里根本没有这么个路径时,你将不得不相信我,那个字符串确实是个装饰品。


结论


对于仅仅是倍增一个整数来讲,这工作太多了点,但它演示了一个强大的、鲜为人知的概念。使用少许的规划,你可以为你所有感兴趣的例程构建一个集中的知识库,把有时令人生畏的内核 API 的复杂性隐藏到一个简单的封装里,你可以一再地去使用它。


关于作者:

Tim Roberts,一个不可救药的软件工程师,写程序既为娱乐又为金钱。Tim 对计算机编程已经超过了三分之一个世纪,在任何东西上编程,从微控制器到大型主机。


Tim 是 Providenza & Boekelheide 公司的股东,那是个位于硅谷的技术咨询公司,正好在俄勒冈波特兰之外。P&B 提供所有类型的硬件和软件的咨询,尤其是图像、视频和多媒体。


注 1:不过,在 Windows 98 第二版或者 Windows Me 中内核 DLL 永远也不会卸载。我将在后文的讲述关于平台兼容时提及更多。

注 2:如果你告知编译器一个给定的函数将从另一个 DLL
中导入,它将会生成一个使用间接地址表的间接调用。反之,生成一个对外部函数的调用。然后链接器会包含一个转换函数(取自导入库),该转换函数包含一个使
用间接地址表的间接调用。所以,使用 __declspec(dllimport) 会在运行时消除中间的转换并节省少量的机器时钟周期。

你可能感兴趣的:(内核模式的 DLL)