第6章 内核驱动C++编程
6.1 驱动中的类
很少有专题讲内核中的C++编程,中文资料恐怕更是罕见。由于C++的普及性、与C的亲密关系,以及大部分情况下程序员都使用C++编译器编译C程序的事实,当初学者听说内核中“不容易”(笔者也听说过“无法”二字)用C++进行编程时,会大吃一惊。不管是说者无意,还是听者有心,Windows内核的现状,决定了C语言是内核编程的首选。
其实内核驱动中也能使用C++,也能使用类,但和用户程序中的用法有一些区别,一些特殊的地方需要特别注意。从笔者的经验来看,WDK给出的AVStream小端口驱动示例工程,就都是C++代码,这是由于AVStream的模块性非常强,在实现较大功能模块时,非得用类封装,否则难以表述清楚。
本章专门讲述如何在内核中编写C++驱动程序。笔者先写一个简单的例子,显示类的一些基本特性,并由此交代出几项关键点;然后改造《WDF USB设备驱动开发》一章中的WDFCY001驱动的例子,将它全部改造成一个驱动类,并最终实现C++的最大优点:多态。
6.1.1 一个简单的例子
首先我们尝试把用户程序中最简单的类拷贝到内核中,编译链接,看看行不行。下面就是笔者定义的整数类,它封装一个整数,对象能够被当成整数使用。
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信息
6.1.2 new/delete
查看上面的代码,会发现一个不同于以往的new操作符。这是怎么回事呢?我们这一节就讲讲它。在用户程序中,创建和释放一个对象使用 new/delete方法,其底层乃是调用HeapAllocate/HeapFree 堆API从线程堆栈中申请空间。但问题是,内核CRT没有提供new/delete操作符,所以需要自己定义。自定义的new/delete操作符,自然也是能够从堆栈中分配内存的,内核中有RtlAllocateHeap/RtlFreeHeap堆栈服务函数。但在内核中,我们一般使用内存池来获取内存,实际上内存池和堆栈使用了同一套实现机制。使用ExAllocatePool/ExFreePool函数对从内存池申请/释放内存,下面是一个例子。
__forceinline
void* __cdecl operator new(size_t size,
POOL_TYPE pool_type,
ULONG pool_tag)
{
ASSERT((pool_type < MaxPoolType) && (0 != size));
if(size == 0)return NULL;
// 中断级检查。分发级别和以上的级别只能分配非分页内存
ASSERT(pool_type == NonPagedPool ||
(KeGetCurrentIrql() < DISPATCH_LEVEL));
return ExAllocatePoolWithTag(pool_type,
static_cast<ULONG>(size),
pool_tag);
}
上面的函数定义有几个细节的地方应当注意一下。首先注意new操作符重载,它的第一个参数一定是size_t,用来表示将分配缓冲区的长度;其次注意分页内存和非分页内存的区别,即pool_type所表示者,在DISPATCH_LEVEL及以上的级别是不能分配分页内存的。
下面是使用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;
6.1.3 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 _DriverEntry@8
referenced in function _GsDriverEntry@8
error LNK1120: 1 unresolved externals
很奇怪,这是一个链接错误,说明编译过程是通过的。怎么回事呢?认真看一下错误内容,原来是系统在链接时找不到入口函数_DriverEntry@8。这个奇怪的函数名,很显然是C编译器对DriverEntry进行编译后的结果,前缀“_”是C编译器特有的,后缀“@8”是所有参数的长度。原来我们现在使用的是C++编译器,一定是它把DriverEntry编译成了系统无法认识的另一副模样了(实际上,C++编译器会把它编译成以“?DriverEntry@@”开头的一串很长的符号)。
一旦加上extern "C"修饰符,上述问题即立刻消失了。extern "C"提醒编译器要使用C编译格式编译DriverEntry函数,这样编译生成的函数名称为“_DriverEntry@8”,链接器即可正确地识别出符号了。
6.1.4 全局/静态变量
首先列出规则如下:
不能定义类的全局或者静态对象,除非这个类没有构造函数;否则全局对象将因初始化过程中含有无法解决的符号,而导致链接失败。
读者可能难以理解这个规定,所以要用实例进行更深的挖掘才行。以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)" (??__EgA@@YAXXZ)
上面的链接错误,是由于函数??__EgA@@YAXXZ中找不到符号_atexit。这两个名字都怪得不得了!理解它们要从C++标准说起,C++标准规定对于全局对象的处理,编译器要保证全局对象在main()函数运行之前已经被初始化,并且保证main()函数在退出前被删除(析构)。变量的初始化与删除,需要编译器专门为它们各自创建一个函数,并在合适的时机进行调用。函数名称根据不同的编译器会有所不同,在这里看到,用于对gA进行初始化的是函数??__EgA@@YAXXZ,笔者通过IAD反汇编后看到,用于删除(析构)的是函数??__FgA@@YAXXZ。后者一点问题都没有,但前者遇到了问题,无法解析_atexit符号。笔者将其汇编代码拷贝如下:
// 函数名,注释很明白地告诉我们,此函数是gA的初始化函数
??__EgA@@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
// 初始化已经完毕了,问题出在这里
//初始化完毕后,把??__FgA@@YAXXZ地址作为参数,调用_atexit以注册终止函数
00000345 push offset ??__FgA@@YAXXZ
0000034A call _atexit
0000034A
// 恢复堆栈
0000034F add esp, 4
00000352 pop ebp
00000353 retn
00000353
00000353 _text$yc ends
上面的汇编代码,大部分都是正确的,只是到了最后调用_atexit函数时才出了错(_atexit是导入符号,实际函数名应去掉前面的“_”,即atexit)。atexit是一个C标准函数,其作用是向系统注册终止函数,即主程序在终止之前需调用的处理函数。上面我们看到,atexit将??__FgA@@YAXXZ作为参数进行了调用以析构gA。在逻辑上是没有问题的,但atexit函数在内核中未实现。实际上,它有下面的一行调用:
atexit(??__FgA@@YAXXZ);
现在的问题就归结为:内核中没有C运行时函数atexit。请问:它可以有吗?它难道不可以有吗?
上面笔者也说过,内核代码和用户程序是非常不一样的。用户程序的生命周期由main()调用开始,main()调用结束,整个程序也即完结。而驱动程序却不一样,虽然我们有时候把DriverEntry比作main(),但二者在本质上不同,DriverEntry的生命周期非常短,其作用仅是将内核文件镜像加载到系统中时进行驱动初始化,调用结束后驱动程序的其他部分依旧存在,并不随它而终止。所以我们一般可把DriverEntry称为“入口函数”,而不可称为“主函数”。因此作为内核驱动来说,它没有一个明确的退出点,这应该是atexit无法在内核中实现的原因吧。
从图6-2我们看到,用户程序是一个独立运行单位,main()函数是主线程,它的生命周期也就是程序的生命周期。而内核驱动呢?它的生命周期其实只是镜像文件的生命周期,即加载与卸载,并没有固定的主线程与之匹配甚至支配其生命周期;相反,驱动代码可以出现在任何线程环境中,被任何线程调用。
话说回来,其实驱动程序也是有明显的生命周期的,即从DriverEntry开始到DriverUnload结束的镜像文件的生命周期,如图6-3所示。这也并非不可利用,笔者觉得,如果在DriverEntry调用前执行全局对象的初始化函数,而同时把终止函数注册到DriverUnload中,或许能够解决问题,但前提是要求系统要做相应的改动了。因为DriverUnload是可选的,所以若采用这种方法,应采取措施为未提供DriverUnload函数的驱动设置默认的卸载函数。但随着微软对这方面研究的深入,笔者相信,这个问题一定是他们的问题列表中必须解决的一项。
图6-2 用户程序
图6-3 内核假想实现
本节内容代码,请参看本书simClass示例工程。
内核中使用C++还有一点需要注意,就是C++编译器会在不提醒的情况下,使用堆栈生成临时变量若干,而内核堆栈是非常有限的,所以常常需要对此保持一份警惕。
6.1.5 栈的忧虑
普通的Win32线程有两个栈:一个是用户栈,另一个是内核栈;而如果是内核中创建的系统工作线程,则只有内核栈。只要代码在内核中运行,线程就一定是使用其内核栈的。栈的主要作用是维护函数调用帧,以及为局部变量提供空间。
用户栈可以指定其大小,默认是1MB,通过编译指令/stack可改设其他值。
普通内核栈的大小是固定的,由系统根据CPU架构而定,x86系统上为12KB,x64系统上为24KB,安腾系统上为32KB。对于GUI线程,普通内核栈空间可能不够,所以系统又定义了“大内核栈”概念,可以在需要的时候增长栈空间。只有GUI线程才能使用大内核栈,这也是系统规定的。
关于GUI线程,笔者多说几句。Windows的发明,将GDI和USER模块,即“窗口与图形模块”的实现移到了内核中,称为Windows子系统内核服务,并形成一个win32k.sys内核文件。而用户层仅留调用接口,由User32.dll和GDI32.dll两个文件暴露出来。判断一个线程是不是GUI线程的依据,竟非常的简单:线程初建时,都是普通线程,第一次调用Windows子系统内核服务(只要用户程序调用了User32.dll和GDI32.dll中的函数,并导致相关内核服务在内核中被执行),系统即立刻将之转变为GUI线程,并从而切换到“大内核栈”;倘若至线程结束,并未有任何一个子系统内核服务被调用,那么它一直都是普通线程,一直使用普通内核栈。
正是由于窗口与图形模块的内移,才导致了相关服务必须在内核中执行,从而不得不引入“大内核栈”概念。笔者知道UNIX系列的操作系统,包括Linux、Mac,都是在用户层实现窗口与图形子系统的,这类操作系统甚至可以毫不影响地在多个图形子系统间进行切换。回忆Windows NT 4以前的操作系统,其设计也和UNIX一样,相关模块放在用户层实现。在这种情况下,对于上述操作系统,按照笔者的理解,它们就没必要使用大内核栈的概念了——笔者仔细查过Linux和UNIX相关书籍,确实未找到“大内核栈”的说明。
和C编译器相比,C++编译器更善于为目标代码做较多优化,并因为创建数量不等的临时变量而占用一定的栈空间。对于用户栈和大内核栈,临时变量带来的栈空间支出一般不足以构成问题。但对于普通内核栈,C++编译器并不知道自己正在多么奢侈地挥霍着有限而珍贵的资源,几十K甚至十几K的内存很容易被耗尽,内核栈溢出因此成为一个非常大的威胁。
下面给读者举一个语言上的例子。对于表达式:
A = b + c
如果a、b、c三个变量的类型为:
int a, b, c;
虽然不同的编译器间各有不同的实现,但一般来说编译后的结果是这样的:先把b的值存入一个寄存器中(如eax),将寄存器和c相加,再把寄存器值传入变量a。这里面不涉及临时变量。
但如果a、b、c三个变量的类型为一个类,如ClsSome:
ClsSome a, b, c;
则编译后的结果就不像表面上那么简单了,编译器会创建一个ClsSome类型的临时变量tmp,并将b与c相加后的结果存入tmp中,最后用赋值操作将临时对象tmp赋值给a。临时变量tmp是编译器神不知鬼不觉创建的,程序员很难预知这一背后动作。
对于上面的对象例子,如果有更多的对象参与并实现了更复杂的操作,则编译器创建的临时变量数将更多,可能超乎你的想象。Lippman在其《Inside The C++ Object Model》一书中举了一例,是三个对象之间的四则运算:
a = b + c - b*c; // 见其书6.3节,原是a[i] = b[i]+c[i] – b[i]*c[i],是一个对象数组
Lippman举此例后,称这里面将会导致创建5个临时变量,岂不令人惊讶!
对于因为内核栈空间的瓶颈而引起的忧虑,目前并没有好的解决方法。可能读者会疑惑一个问题,即为什么不能把内核栈也设计得和用户栈一样呢?比如把内核栈默认大小设置为1MB,用户栈这么做并没有带来任何问题啊。
提出这个问题的读者很会动脑子,但他忽略了一个问题,就是用户空间和内核空间的不同之处。用户空间是进程独立的,以x86系统为例,在正常情况下,每个进程都有独立的2GB用户空间,所以用户栈的1MB并不起眼。
而内核空间是全局共享的,所有内核栈都在同一个内核空间中申请内存资源。如果内核栈也像用户栈一样,将大小设到1MB,我们来算一笔账吧。系统中的线程成百上千,就算平均500个线程吧,每个线程一个1MB大小的内核栈,一共占了500MB。这还了得吗?岌岌可危。500个线程太保守了,笔者在写作的当下系统中有967个线程(见图6-4),那就用掉将近1半的内核空间了!再倘若用户开启了/3G开关,那么内核空间就只有1GB——系统要喊救命了,可了不得!
在任务管理器中,在选择列对话框中勾上“线程数”,能看到各进程含有的线程数,将所得数相加能得到一个大概的系统线程总数;但更好的办法是查看系统的性能计数,可使用perfmon来查看,如图6-4所示。
图6-4 查看系统线程数
内核栈的问题,正是内核中使用C++的一个最大障碍。在实际编程时,为了尽量避免发生栈溢出错误,需要经常对栈剩余空间保持一份警惕,尤其在可能形成很深的调用栈(如递归调用)的情况下。内核函数IoGetStackLimits与IoGetRemainingStackSize分别用来获取当前内核栈的边界与剩余空间,可使用这两个函数实时控制栈状况。可在函数入口处包含下列代码。
// 如果当前内核栈空间小于150字节,就让函数返回
if(IoGetRemainingStackSize() < 150)
return; // 如有可能,可指定一个特殊的错误值
6.2 类封装的驱动程序
上面的clsInt太过简单了,无法回答这样的问题:在内核中使用类能带来什么好处?simClass工程无法回答上述问题,笔者只是借助它引出并解决一些基本问题。下面我们思考这样一个问题:就驱动本身而言,如何把内核驱动封装成一个类?
内核驱动,无外乎就是一些数据结构:驱动对象、设备对象、文件对象、IRP等;而对这些数据结构的处理就是内核函数:WDM驱动乃是分发函数(Dispatch Function),WDF乃是事件(Event)。
这不正好吗?上述二者恰好是类封装的基本要素!类者,数据加方法。笔者将把诸如驱动对象、设备对象等一切用到的数据结构,作为成员数据;把分发函数或者事件、回调,作为成员函数。一个“驱动类”就此初露峥嵘了。
想法是不错的,但遇到两个问题,下面一一说明。
6.2.1 寻找合适的存储所
定义类之前要解决的第一个问题是,一旦类对象被创建后,它的生命周期基本上要和驱动程序的生命周期相当,在哪里保存类对象呢?创建全局变量当然是一种方法,但存在多个驱动实例时就会发生冲突。在WDM驱动中,有设备扩展可以保存自己的变量。KMDF则更丰富,笔者最终决定在WDFDRIVER对象中保存类对象。达成的效果如图6-5所示。
驱动对象和设备对象是驱动程序的核心,而回调函数又是核心的核心。在图6-5中,驱动对象和设备对象的回调函数,都在DrvClass类中实现。而为了让C++类对象的生命周期和驱动对象保持一致,用一个WDMMEMORY对象将它封装起来,并作为驱动对象的子对象,由框架自动维护,在驱动对象存在时,C++类对象将一直是有效的。
首先看看怎么把一个自定义的内容保存到驱动对象中,这又要用到框架对象的“环境变量”概念了,前面我们学过给设备对象设置环境变量,现在轮到驱动对象了。让我们重新来做一遍。
图6-5 对象模块图
第1步,定义一个获取环境块指针的函数。
WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DRIVER_CONTEXT,
GetDriverContext);
上面的宏将定义一个名称为GetDriverContext的函数,这个函数的伪代码如下:
*DRIVER_CONTEXT GetDriverContext(WDFOBJECT Object)
{
// XXX是一个固定的地址,由于未文档化,无法知道其具体定义
return (DRIVER_CONTEXT*)Object->XXX;
}
以后只需要进行如下调用,即能取得驱动对象的环境块指针(前提是传入正确的对象句柄)。
// 获取环境变量
DRIVER_CONTEXT *pContext = GetDriverContext(WdfDriver);
第2步,在WdfDriverCreate创建框架驱动对象的同时,设置环境变量的结构,通过WDF_DRIVER_CONFIG完成。下面代码的前面部分,实现了此步。
第3步,调用GetDriverContext获取环境变量,并将其封装到一个WDFMEMORY对象中,并指定第2步中创建的驱动对象为其父对象,以令框架自动维护其生命周期。下面代码的后面部分,实现了此步。
NTSTATUS DrvClass::DriverEntry(
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath)
{
KDBG(DPFLTR_INFO_LEVEL, "DrvClass::DriverEntry");
WDFMEMORY hDriver;
WDF_OBJECT_ATTRIBUTES attributes;
WDF_DRIVER_CONFIG config;
NTSTATUS status = STATUS_SUCCESS;
WDFDRIVER WdfDriver;
// 设定驱动环境块长度
// 宏内部会调用sizeof(…)求结构体长度,并用粘连符(##)获得其名称
WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, DRIVER_CONTEXT);
WDF_DRIVER_CONFIG_INIT(&config, DrvClass::PnpAdd_sta);
status = WdfDriverCreate(DriverObject, // WDF驱动对象
RegistryPath,
&attributes,
&config, // 配置参数
&WdfDriver);
// 取得驱动环境块
PDRIVEDR_CONTEXT pContext = GetDriverContext(WdfDriver);
ASSERT(pContext);
pContext->par1 = (PVOID)this;
// 把类对象用WDFMEMORY对象封装后,作为WDFDRIVER对象的子对象
WDF_OBJECT_ATTRIBUTES_INIT(&attributes);
attributes.ParentObject = WdfDriver;
attributes.EvtDestroyCallback = DrvClassDestroy;
WdfMemoryCreatePreallocated(&attributes, (PVOID)this,
sizeof(DrvClass), &hDriver);
KDBG(DPFLTR_INFO_LEVEL, "this = %p", this);
return status;
}
驱动程序将在入口函数DriverEntry中动态创建一个类对象,并即刻调用方法DrvClass::DriverEntry,以创建驱动对象并将其作为对象的存储所。
以这种方法实现的妙处是,对象的维护是自动化的,我们不用操心太切。一切看上去,很是完美。下面是DrvClassDestroy函数的实现,WDF框架会在销毁内存对象时自动调用它,我们在其中销毁类对象。
VOID DrvClassDestroy(IN WDFOBJECT Object)
{
PVOID pBuf = WdfMemoryGetBuffer((WDFMEMORY)Object, NULL);
delete pBuf;
}
6.2.2 类方法与事件函数
KMDF中的事件函数,分开来说:驱动对象有EvtDriverDeviceAdd和EvtDriverUnload,我们将实现前者;设备对象有一系列PNP/Power事件;还有其他对象的事件函数,且忽略之,详见代码。
事件函数说到底是一种回调函数。类普通成员函数,由于编译后会增加this参数,所以无法成为回调函数。只能使用类静态函数,并通过静态函数再回调成员函数。这是一种很通用的实现手段。以EvtDriverDeviceAdd事件函数为例,我们要在类中为它定义两个相关函数。
Class DrvClass
{
// 定义类静态函数,它是全局的,可以作为回调函数
static NTSTATUS PnpAdd_sta(
IN WDFDRIVER Driver,
IN PWDFDEVICE_INIT DeviceInit);
// 再定义类成员函数,将由静态函数内部调用
virtual NTSTATUS PnpAdd(
IN WDFDRIVER Driver,
IN PWDFDEVICE_INIT DeviceInit,
DrvClass* pThis);
// 其他接口函数
// ……
}
要能够通过静态函数回调成员函数,即通过PnpAdd_sta回调PnpAdd函数。前提是要能够获得对象指针,因为我们已经把对象指针保存在驱动对象的环境块中了,所以达到此目的不是难事。代码如下:
NTSTATUS DrvClass::PnpAdd_sta(IN WDFDRIVER Driver,
IN PWDFDEVICE_INIT DeviceInit)
{
// 取得环境块
PDRIVEDR_CONTEXT pContext = GetDriverContext(Driver);
// 环境块中存有对象指针
DrvClass* pThis = (DrvClass*)pContext->par1;
// 再调用成员函数
return pThis->PnpAdd(Driver, DeviceInit);
}
所有其他的事件函数,都必须采用相同的方法实现。
6.2.3 KMDF驱动实现
其实上面的内容,一直是围绕KMDF进行讲解的。DrvClass内部的DriverEntry成员函数已经讲解过了,现在看看真正的入口函数该如何定义吧。
extern "C" NTSTATUS DriverEntry(
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath
)
{
// 动态创建对象,此步在后面将被修改
DrvClass* myDriver = new(NonPagedPool, 'CY01')DrvClass();
if(myDriver == NULL)return STATUS_UNSUCCESSFUL;
return myDriver->DriverEntry(DriverObject, RegistryPath);
}
干净得不得了,驱动程序在加载之初就以快捷无比的速度向我们定义的类靠拢了。至于第1行代码动态创建对象的操作,当前这样实现已经完全可以了,但在后面将被修改,以支持多态。
6.2.4 WDM驱动实现
如果使用WDM方式进行类封装,对于非PNP类驱动,可以在入口函数中创建控制设备对象,并把类对象保存在设备对象的设备扩展中;对于PNP类驱动,应当在AddDevice函数中建立设备栈时创建类对象,并将其保存在功能设备对象的设备扩展中。笔者会以前者为例,简单讲一下实现。WDMClass示例工程,读者参照代码,在它的基础上很容易扩展出功能更为完善的驱动程序。
这里列出具体的封装过程。首先是类定义,定义一个通用的分发函数如下:
class WDMDrvClass{
public:
static NTSTATUS DispatchFunc_sta(
DEVICE_OBJECT Device,
PIRP Irp);
virtual NTSTATUS DispatchFunc(
DEVICE_OBJECT Device,
PIRP Irp);
// 其他……
};
同理,定义一个静态函数和一个类成员函数,静态函数将通过对象指针调用成员函数。入口函数中要这样定义:
typedef struct{
WDMDrvClass pThis;
//……
}DEVICE_EXTENSION;
NTSTATUS DriverEntry( PDRIVER_OBJECT Driver,
PUNICODE_STRING Register)
{
// 创建动态对象
WDMDrvClass* pDrv = new(NonPagedPool, 'SAMP') WDMDrvClass();
// 设置分发函数,全部指向DispatchFunc_sta
for(int i = 0; i < IRP_MJ_MAXIMUM_FUNCTION; i++)
Driver->DispatchFunction[i] = pDrv->DispatchFunc_sta;
}
// 创建控制设备对象,并同时创建设备扩展区
IoCreateDeviceObject(..., sizeof(DEVICE_EXTENSION));
// 把对象指针保存到设备扩展中
DEVICE_EXTENSION* pContext = (DEVICE_EXTENSION*)DeviceObject->DeviceExtension;
pContext->pThis = pDrv;
return STATUS_SUCCESS;
}
这一切就绪之后,我们还是来看看DispatchFunc_sta该如何实现吧。诚如我们所知,所有的驱动分发函数的第一个参数总是设备对象,正是我们所创建的那个。通过它,我们总是能够在静态函数中得到对象指针。下面是DispatchFunc_sta函数的实现。
NTSTATUS WDMDrvClass::DispatchFunc_sta(
DEVICE_OBJECT Device, PIRP Irp)
{
PDEVICE_EXTENSION pContext = Device->DeviceExtension;
WDMDrv pThis = pContext->pThis;
return pThis-> DispatchFunc(Device, Irp);
}
与上述KMDF的实现类似,其他更详细的实现内容,请参阅工程代码。
6.3 多态
如果纯粹是为了尝鲜,在驱动中加入一个类,内部却只是一团硬板,那就完全多此一举了。所以本节笔者将带领大家在内核中实现类的多态。以CY001 USB设备驱动为例进行讲解,代码请参考本书工程UsbBaseClass和CY001UsbClass,前者以基类实现设备驱动,后者以子类实现设备驱动。
6.3.1 基类、子类
笔者对基类的要求是能够实现USB设备的最基本要素,使得设备能够在系统中显现,能够正常运行和移除。所以设备栈一定要成功建立,基本的Pnp/Power接口也必须要提供,但用户层接口可以暂不考虑。最终的结果是PnpAdd函数实现得非常完整,因为必须要将设备栈建立起来;EvtDevicePrepareHardware和EvtDeviceReleaseHardware函数也得以完整实现,这样设备能够正确运行和移除,但细节方面的设置如休眠等则以接口留出。
子类必须实现更完善的功能,如休眠、唤醒设置。下面的例子分别对应着基类和子类的实现。
// 配置设备驱动的电源管理功能
NTSTATUS DrvClass::InitPowerManagement()
{
return STATUS_SUCCESS;
}
这是基类的实现,空空如也,子类则要复杂许多倍。
// 配置设备驱动的电源管理功能
NTSTATUS CY001Drv::InitPowerManagement()
{
NTSTATUS status = STATUS_SUCCESS;
WDF_USB_DEVICE_INFORMATION usbInfo;
KDBG(DPFLTR_INFO_LEVEL, "[InitPowerManagement]");
// 获取设备信息
WDF_USB_DEVICE_INFORMATION_INIT(&usbInfo);
WdfUsbTargetDeviceRetrieveInformation(m_hUsbDevice, &usbInfo);
// 设置设备的休眠和远程唤醒功能
// …… 详见代码
return status;
}
6.3.2 实现多态
怎么能够实现多态呢?当前,动态对象是在入口函数中创建的,而按照现有逻辑,入口函数是不允许修改的。笔者要提供一个机会,让库使用者可以创建动态对象。为此笔者特地有一个规定,所有库使用者必须定义一个宏,以注册自己的驱动类。
REGISTER_DRV_CLASS(DriverName)
如果不使用子类,则需要定义下面的宏而直接使用基类。
REGISTER_DRV_CLASS_NULL()
那么这两个宏到底有什么作用呢?要看宏定义了:
// 注册子类
#define REGISTER_DRV_CLASS(DriverName) DrvClass* GetDrvClass(){ return (DrvClass*)new(NonPagedPool, '10YC') DriverName();}
// 注册基类
#define REGISTER_DRV_CLASS_NULL()DrvClass* GetDrvClass(){ return new(NonPagedPool, 'ESAB') UsbBaseClass();}
两个宏都是为了定义一个名称为GetDrvClass的函数。前者注册驱动类的子类,并在GetDrvClass的实现中动态创建子类对象,在返回时将子类对象的指针转换为基类对象指针;后者则声明直接使用基类,并在GetDrvClass的实现中动态创建一个基类对象,并返回其指针。
调用者不必关心被创建的对象到底是来自基类还是子类,他只要使用在基类中定义的接口就可以了,而借助虚拟函数的运行时绑定策略,即可实现多态。
需要注意的是宏REGISTER_DRV_CLASS(DrvClass),其作用和REGISTER_DRV_ CLASS_NULL()是一样的,都将定义一个GetDrvClass函数。
好戏还要看DriverEntry()函数中的实现,重新修改后的函数代码如下:
NTSTATUS Driver(DRIVER_OBJECT Driver,
UNICODE_STRING Register)
{
DrvClass* pDriver = GetDrvClass();
return pDriver->DriverEntry(Driver, Register);
}
真是无与伦比的简洁,它通过GetDrvClass函数实现了多态,并立刻将驱动的实现交付到了pDriver对象的手中,而pDriver可以是基类,也可以是任意一个从基类继承的子类。
实现多态的核心是两个类注册宏,以及在入口函数中对GetDrvClass函数的调用。需要注意的是,如果用户同时定义了两个宏,那么系统就会因为发现两个完全一样的GetDrvClass函数而使编译失败;反之,如果上述两个宏一个都没有定义,那么在链接时,将因为无法找到函数定义而链接失败。
驱动工程UsbBaseClass使用驱动基类直接驱动CY001 USB设备,从SOURCE文件中可以看到,它含有的编译文件为DrvClass.cpp和GetDrvClass.cpp两个文件,前者是基类的定义文件,后者只有一行代码,即REGISTER_DRV_CLASS_NULL()。这是最简单的驱动工程。
驱动工程CY001USBClass使用驱动子类CY001DrvClass驱动CY001 USB设备,从SOURCE文件中可以看到,它依旧包含了DrvClass.cpp文件,此外还包含了若干个子类的实现文件。
所以,读者只要在DrvClass甚至CY001DrvClass类的基础上实现子类化,并注册新的子类,就能够实现功能扩展。
如图6-6所示是本节所讲的多态实现原理图。
图6-6 多态关系图
6.3.3 测试
编译UsbBaseClass工程的代码,用得到的CY001.sys文件替代system32\drivers目录下的同名文件,以驱动CY001 USB设备。尝试使用本书中的UsbKitApp程序,会发现能够正确枚举到USB设备,但软件的具体功能如获取描述符等,无法正常使用。
以同样的方法测试编译CY001UsbClass工程后得到的CY001.sys文件,并运行UsbKitApp程序以测试,会发现和WDFCY001工程的测试结果完全一样。
6.4 小结
使用本章中介绍的方法,可以轻松实现驱动的类封装。特别是本章介绍的实现多态的方法,可以使得驱动代码的复用性得到很大增强。建议读者在CY001UsbClass的基础上,再子类化一个MyCY001Ex(或其他你喜欢的名字)类,在父类的基础上添加自己的功能,并尝试使用编译这个新工程生成的sys文件,看新类能否发挥作用。
c编程示例代码
ReplyDeletec示例代码使用系统快速排序