简单说说驱动程序设计的入门,其实初级驱动设计中也能使用C++,也能使用类,但和用户程序中的用法有一些区别,一些特殊的地方需要特别注意。从笔者的经验来看,WDK给出的AVStream小端口驱动示例工程,就都是C++代码,这是由于AVStream的模块性非常强,在实现较大功能模块时,非得用类封装,否则难以表述清楚。
很少有专题讲内核中的C++编程,中文资料恐怕更是罕见。由于C++的普及性、与C的亲密关系,以及大部分情况下程序员都使用C++编译器编译C程序的事实,当初学者听说内核中“不容易”(笔者也听说过“无法”二字)用C++进行编程时,会大吃一惊。不管是说者无意,还是听者有心,Windows内核的现状,决定了C语言是内核编程的首选。
本章专门讲述如何在内核中编写C++驱动程序。笔者先写一个简单的例子,显示类的一些基本特性,并由此交代出几项关键点;然后改造《WDF USB设备驱动开发》一章中的WDFCY001驱动的例子,将它全部改造成一个驱动类,并最终实现C++的最大优点:多态。
一个简单的例子
首先我们尝试把用户程序中最简单的类拷贝到内核中,编译链接,看看行不行。下面就是笔者定义的整数类,它封装一个整数,对象能够被当成整数使用。
以下是代码片段: class clsInt{ Public: clsInt(){m_nValue = 0;} clsInt(int nValue){m_nValue = nValue;} void print(){KdPrint((“m_nValue:%d/n”, m_nValue));} operator int(){return m_nValue;} private: int m_nValue; };
上例是一个非常简单的类定义,我们将在DriverEntry函数中使用它,分别定义一个局部变量和动态创建一个对象。我们通过Debug信息来观察对象行踪,希望能够得到正确的输出。入口函数中的定义如下:
以下是代码片段: extern "C" NTSTATUS DriverEntry( IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath ) { // 创建两个对象,一个是局部变量,一个是动态创建的 clsInt obj1(1); clsInt* obj2 = new(NonPagedPool, "abcd") clsInt(2); // 打印Log信息 obj1.print(); obj2->print(); delete obj2; // 让模块加载失败 return STATUS_UNSUCCESSFUL; }
上面代码中先后创建了两个clsInt对象,一个是在栈中创建的,初始变量为1;一个是动态创建的,初始变量为2。后者由于是动态创建的,必须手动调用delete函数释放内存,所以其析构函数比前者先调用。我们必须从Log信息中得到类似的脉络,以证明其正确性。代码请参看simClass工程。图6-1是Log信息的截图,我们如愿以偿地得到了想要的结果。
图6-1 对象Log信息
new/delete
查看上面的代码,会发现一个不同于以往的new操作符。这是怎么回事呢?我们这一节就讲讲它。在用户程序中,创建和释放一个对象使用 new/delete方法,其底层乃是调用HeapAllocate/HeapFree 堆API从线程堆栈中申请空间。但问题是,内核CRT没有提供new/delete操作符,所以需要自己定义。自定义的new/delete操作符,自然也是能够从堆栈中分配内存的,内核中有RtlAllocateHeap/RtlFreeHeap堆栈服务函数。但在内核中,我们一般使用内存池来获取内存,实际上内存池和堆栈使用了同一套实现机制。使用ExAllocatePool/ExFreePool函数对从内存池申请/释放内存,下面是一个例子。
下面是使用new进行内存申请的一个例子。
以下是代码片段: // 定义一个32位的TAG值 #define TAG "abcd" // 外部已经定义了一个clsName类 extern class clsName; // 为clsName申请对象空间 clsName* objName = NULL; objName = new(NonPagedPool, TAG)clsName();
上面的new操作和用户程序中的new操作具有同样的功效,但需要注意第一个参数size_t是必须外置的,编译器会自动用sizeof(clsName)求取长度并作为第一个参数。一般地说,对于类似下面的语句:
className objName = new(…) className(…)
其执行过程是,首先由new操作符为新对象动态分配内存,并返回指针;然后再对此新创建的对象,选择与className(…) 相符的构造函数进行初始化。
再来看看delete操作符的重载。
以下是代码片段: __forceinline void __cdecl operator delete(void* pointer) { ASSERT(NULL != pointer); if (NULL != pointer) ExFreePool(pointer); }
删除对象数组,即delete[]操作符重载。
以下是代码片段: __forceinline void __cdecl operator delete[](void* pointer) { ASSERT(NULL != pointer); if (NULL != pointer) ExFreePool(pointer); }
上面两个函数最终都会将指定地址的内存释放,但在释放之前,前者会调用指定对象的析构函数,后者会对数组中每个成员调用析构函数。示例如下:
以下是代码片段: extern clsName *objName; extern clsName *objArray[]; delete objName; delete[] objArray;
extern "C"
对extern "C"编译指令,大家不会感到陌生。它一般这样用:
以下是代码片段: extern "C"{ //…内容 }
既然是编译指令,就一定是作用于编译时刻的。它告诉编译器,对于作用范围内的代码,以C编译器方式编译。一般是针对C++/Java等程序而用的。如果括号内仅有一项,那么括号可以省略。
最早让我们见识到它的作用的是在入口函数DriverEntry中。现在必须这样声明它:
以下是代码片段: extern "C" NTSTATUS DriverEntry( IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath );
初学者未必知道这一点,如果“忘记”做上述改动,将得到如下错误:
以下是代码片段: error LNK2019: unresolved external symbol [email protected] referenced in function [email protected] error LNK1120: 1 unresolved externals
很奇怪,这是一个链接错误,说明编译过程是通过的。怎么回事呢?认真看一下错误内容,原来是系统在链接时找不到入口函数[email protected]。这个奇怪的函数名,很显然是C编译器对DriverEntry进行编译后的结果,前缀“_”是C编译器特有的,后缀“@8”是所有参数的长度。原来我们现在使用的是C++编译器,一定是它把DriverEntry编译成了系统无法认识的另一副模样了(实际上,C++编译器会把它编译成以“[email protected]@”开头的一串很长的符号)。
一旦加上extern "C"修饰符,上述问题即立刻消失了。extern "C"提醒编译器要使用C编译格式编译DriverEntry函数,这样编译生成的函数名称为“[email protected]”,链接器即可正确地识别出符号了。
全局/静态变量
首先列出规则如下:
不能定义类的全局或者静态对象,除非这个类没有构造函数;否则全局对象将因初始化过程中含有无法解决的符号,而导致链接失败。
读者可能难以理解这个规定,所以要用实例进行更深的挖掘才行。以simClass的clsInt类为例,如果定义如下全局变量:
clsInt gA;
对项目进行编译,会毫不留情地得到如下错误(也是链接错误):
errors in directory c:/trunk/simclass
c:/trunk/simclass/main.obj : error LNK2019: unresolved external symbol _atexit referenced in function "void __cdecl "dynamic initializer for "gA""(void)" ([email protected]@YAXXZ)
上面的链接错误,是由于函数[email protected]@YAXXZ中找不到符号_atexit。这两个名字都怪得不得了!理解它们要从C++标准说起,C++标准规定对于全局对象的处理,编译器要保证全局对象在main()函数运行之前已经被初始化,并且保证main()函数在退出前被删除(析构)。变量的初始化与删除,需要编译器专门为它们各自创建一个函数,并在合适的时机进行调用。函数名称根据不同的编译器会有所不同,在这里看到,用于对gA进行初始化的是函数[email protected]@YAXXZ,笔者通过IAD反汇编后看到,用于删除(析构)的是函数[email protected]@YAXXZ。后者一点问题都没有,但前者遇到了问题,无法解析_atexit符号。笔者将其汇编代码拷贝如下:
以下是代码片段: // 函数名,注释很明白地告诉我们,此函数是gA的初始化函数 [email protected]@YAXXZ: ; DATA XREF: .CRT$XCU:_gA$initializer$o 0000031E mov edi, edi 00000320 push ebp 00000321 mov ebp, esp // 下面首先会调用clsInt的默认构造函数 // 第一句是将m_nValue赋值为0 00000323 mov ds:clsInt gA, 0 // 下面是DbgPrint调用 0000032D mov eax, ds:clsInt gA 00000332 push eax 00000333 push offset clsInt gA 00000338 push offset PrintString 0000033D call _DbgPrint 0000033D 00000342 add esp, 0Ch // 初始化已经完毕了,问题出在这里 //初始化完毕后,把地址作为参数,调用_atexit以注册终止函数 00000345 push offset 0000034A call _atexit 0000034A // 恢复堆栈 0000034F add esp, 4 00000352 pop ebp 00000353 retn 00000353 00000353 _text$yc ends
上面的汇编代码,大部分都是正确的,只是到了最后调用_atexit函数时才出了错(_atexit是导入符号,实际函数名应去掉前面的“_”,即atexit)。atexit是一个C标准函数,其作用是向系统注册终止函数,即主程序在终止之前需调用的处理函数。上面我们看到,atexit将作为参数进行了调用以析构gA。在逻辑上是没有问题的,但atexit函数在内核中未实现。实际上,它有下面的一行调用:
atexit();
现在的问题就归结为:内核中没有C运行时函数atexit。请问:它可以有吗?它难道不可以有吗?
上面笔者也说过,内核代码和用户程序是非常不一样的。用户程序的生命周期由main()调用开始,main()调用结束,整个程序也即完结。而驱动程序却不一样,虽然我们有时候把DriverEntry比作main(),但二者在本质上不同,DriverEntry的生命周期非常短,其作用仅是将内核文件镜像加载到系统中时进行驱动初始化,调用结束后驱动程序的其他部分依旧存在,并不随它而终止。所以我们一般可把DriverEntry称为“入口函数”,而不可称为“主函数”。因此作为初级驱动设计来说,它没有一个明确的退出点,这应该是atexit无法在内核中实现的原因吧。
从图6-2我们看到,用户程序是一个独立运行单位,main()函数是主线程,它的生命周期也就是程序的生命周期。而初级驱动设计呢?它的生命周期其实只是镜像文件的生命周期,即加载与卸载,并没有固定的主线程与之匹配甚至支配其生命周期;相反,驱动代码可以出现在任何线程环境中,被任何线程调用。
话说回来,其实驱动程序也是有明显的生命周期的,即从DriverEntry开始到DriverUnload结束的镜像文件的生命周期,如图6-3所示。这也并非不可利用,笔者觉得,如果在DriverEntry调用前执行全局对象的初始化函数,而同时把终止函数注册到DriverUnload中,或许能够解决问题,但前提是要求系统要做相应的改动了。因为DriverUnload是可选的,所以若采用这种方法,应采取措施为未提供DriverUnload函数的驱动设置默认的卸载函数。但随着微软对这方面研究的深入,笔者相信,这个问题一定是他们的问题列表中必须解决的一项。
内核中使用C++还有一点需要注意,就是C++编译器会在不提醒的情况下,使用堆栈生成临时变量若干,而内核堆栈是非常有限的,所以常常需要对此保持一份警惕。