Chinaunix首页 | 论坛 | 博客
  • 博客访问: 84847
  • 博文数量: 28
  • 博客积分: 1430
  • 博客等级: 上尉
  • 技术积分: 240
  • 用 户 组: 普通用户
  • 注册时间: 2006-06-30 23:34
文章分类

全部博文(28)

文章存档

2011年(2)

2006年(26)

我的朋友

分类: C/C++

2006-09-28 17:01:05

 开发驱动程序的过程
编写内核模式的代码于应用程序的代码是不同的,因为驱动程序是操作系统的信任的部分。它的代码必须遵循特殊的规则,小心的编写。
驱动程序开发策略 

  像大多数的软件,驱动程序也使用有组织的开发方法。这个部分将介绍一些技巧来尽量减少开发时间。 

使用规范的开发方法 

  一般的,如果编写一个设备驱动程序没有一个开发策略。而是去匆忙的测试全新的硬件,往往测试代码会成为驱动程序的主要部分。也许设备驱动程序是被一些对操作系统不熟悉的程序员编写的,第一个驱动程序的编写会是一个驱动程序作者全面了解系统构架的旅程。 

  幸运的是,疯狂的软件编写的日子过去了。在复杂的硬件和操作系统的环境下,想怎么编写就怎么编写的代码是不会运行的。 

下面是一个简短的开发技术(它们中的一些是来自实时设计技术)列表。 

1. 数据流图可以将一个驱动程序分成独立的功能部分。通过这个图可以容易的看出各个部分的联系,和数据的流向。 

2. 状态机模型是一个描述驱动过程控制流的好的方法,特别是管理复杂的硬件和软件协议的驱动程序。在验证状态机的过程中,可能发现驱动程序中的同步问题。 

3. 分析期待的数据的重复率或者输入输出的响应将得到一系列定量的时序条件。对于确定驱动程序的总性能是非常重要的。 

4. 另一个有用的工具是外部事件和驱动程序响应动作的列表。这个列表应该包括设备的硬件事件和用户通过I/O管理器的软件事件。 

使用这些技术分解一个驱动程序成为一个个定义好的功能部分。有时,这些意味着分解一个简单的,单片的驱动程序成为一些硬件相关的端口驱动程序和硬件无关的类驱动程序。在任何时候,在工程的开始,分析一个驱动程序的设计所花费的时间多于设计工程它自己,这样可以减少调试和维护的时间。 

使用渐进的开发方法 

   一旦最初的分析和设计完成,就要开始编写代码了。按照以下的步骤进行可以减少调试的时间: 

1. 确定驱动程序需要哪些内核模式对象。 

2. 确定驱动程序需要哪些上下文环境或者状态信息和这些信息的存储位置。 

3.首先编写DriverEntry和Unload例程,最初不要增加即插即用支持,这样允许通过控制面板手动的测试驱动程序的装载和卸载。 

4. 添加处理IRP_MJ_CREATE和IRP_MJ_CLOSE的操作和一些不需要进行设备访问的例程。然后可以使用一个简单的WIN32程序调用CreateFile和CloseHandle来测试。 

5. 添加寻找和分配驱动程序的硬件的代码,还有在驱动程序被卸载后的重新分配硬件的代码。如果硬件支持即插即用,这一步测试硬件和驱动程序的自动加载能力。 

6. 添加处理IRP_MJ_XXX函数的派遣例程,最初的例程应该没有使用物理设备,后来新的代码应该使用简单的WIN32程序进行测试,例如ReadFile和WriteFile调用,或者其它支持的函数。 

7. 最后完成Start I/O例程,ISR和DPC例程。现在可以使用真实的数据和硬件进行测试。 

一个有用的提示: 当硬件的确切行为是不能肯定的时候,增加一个DeviceIoControl函数,这个例程直接访问设备的寄存器,这时可用一个简单的WIN32程序直接控制设备寄存器。记得在发布最后的驱动程序版本的时候删除这个功能。 

使用驱动程序实例 

Windows 2000驱动程序开发包(DDK)里面有相当多的驱动程序实例。有许多方法使用者些代码来使驱动程序的开发变的更容易,微软鼓励从这些代码实例中剪切和粘贴。 

编码技术与习惯 

  编写一个信任的内核模式部分和编写应用程序是不同的。下面提供一些编码技术与习惯,使编写代码更加容易一些。 

一般性的建议 

首先,在编写驱动程序的时候应当遵循一些一般性的指导方针: 

1.  尽量避免使用汇编语言。因为它使代码难于阅读,没有移植性,维护困难。HAL宏提供一个安全的机制去访问I/O设备寄存器。因此,在驱动程序中极少使用汇编语言。 

2. 对于特定平台的代码,提供一个单独的模块,至少要用#ifdef/#endif语句将它们括起来。 

3.  不要使用标准的C运行时库连接驱动程序。它除了浪费存储器空间之外,一些库的例程的状态或者上下文环境不是线程安全或者驱动程序安全的方式。 

4.  这一条可能不适合编写设备驱动程序。天天使用运行库环境的C语言程序员,常常不清楚C程序和C运行库的差别。C运行库需要初始化,它尝试初始化一个堆区域和调用全局对象的构造器(使用C++时)。所有的这些任务防碍驱动程序的操作。 

5.  Windows 2000提供它自己的环境支持内核模式代码。包括RtlXxx函数(运行库)等多个公用的C语言运行库服务支持。 

6. 用某种源代码控制管理驱动程序工程。微软的Visual Source Safe是一个好的选择。对于大的跨平台的工程Rational公司的ClearCase也值得考虑。WinMerge也是必备。 

命名规范 

  所有的大的软件工程应该为例程和变量定义制定一些标准的命名规范,设备驱动程序也不例外。好的命名规范提高开发,调试,除错和维护的效率。 

  微软为DDK提供一个命名规范,NTDDK.H定义所有的数据类型,结构,常数,宏。按照DDK规范,所有这些类型的名字都是大写的。甚至是C语言数据类型也提供一个相应的DDK名字。例如,C语言的数据类型void*在NTDDK.H中是PVOID。这些定义可以很容易的扩展到未来的64Bits的平台。 

微软推荐将每个驱动程序例程的名字加上一个特殊的前缀。例如,编写一个鼠标类驱动程序,Start I/O例程的名字可能是MouseClassStartIo。同样的缩写成为两个或者三个字符通常用在内部数据的命名。这样一个例程的名字可能是MouConfiguration。 

头文件 

  除了包含NTDDK.h或者WDM.h,一个驱动程序应当使用私有的头文件去隐藏硬件和平台依赖的变量。例如,寄存器访问宏应该存储在一个私有的头文件,这些宏应该被#ifdef包括。这个技术解决在不同的I/O空间和存储器空间访问寄存器的问题。 

甚至可解决移植性,寄存器访问宏使驱动程序容易阅读和维护。下列代码片段是一个并行端口设备访问的宏,这个实例假定驱动程序的一些初始化代码已经把第一个设备寄存器的地址放到设备Extension的 PortBase中。 

// 定义设备计存器为偏移地址 

#define PAR_DATA      0       

#define PAR_STATUS    1       

#define PAR_CONTROL   2 

//为寄存器定义访问宏,每个宏使用指向设备Extension的指针作为参数 

#deinfe ParWriteData( pDevExt, bData ) \ 

(WRITE_PORT_UCHAR( pDevExt->PortBase + PAR_DATA, bData ) ) 

#define ParReadStatus( pDevExt )    \ 

(READ_PORT_UCHAR( pDevExt->>PortBase + PAR_STATUS )) 

#define ParWriteControl( pDevExt, bData )  \ 

(WRITE_PORT_UCHAR( pDevExt->PortBase + PAR_CONTROL, bData ) ) 

状态返回值 

  WIN2000的内核模式部分使用32Bits的状态值去描述一个特殊操作的结果。这些代码的数据类型是NTSTATUS,在三种情况下使用这个状态代码。 

  1. 使用任何WIN2000内部函数的时候,NTSTATUS值汇报调用成功或者失败。 

  2. I/O管理器调用一个驱动程序支持的回调例程,例程通常返回一个NTSTATUS值给系统。 

  3. 在完成I/O请求的处理之后,驱动程序必须用一个NTSTATUS值标记IRP,这个值最终被映 射到一个Win32 ERROR_XXX代码。NTSTATUS代码于Win32 error代码不是同一回事。I/O管理器提供它两个之间的映 射。DDK的帮助描述它们的映 射关系,它值得一读。 

NTSTATUS.h中描述大量的NTSTATUS值的符号名。所有的这些名字是STATUS_XXX的形式,XXX描述真正的状态信息。STATUS_SUCCESS,STATUS_NAME_EXISTS都是这些名字的实例。 

当一个系统例程返回一个NTSTATUS值,DDK头文件提供一个方便的宏去测试调用的成功于失败信息。下面的代码片段解释了这个技术: 

    NTSTATUS status; 

status = IoCreateDevice ( ... ); 

if ( !NT_SUCCESS( status )) {      // 产生错误,清除和退出 

                                       : } 

    一定要检查系统例程调用的返回值,否则错误将传送给驱动程序代码的其它部分,也可能是系统代码。尽早的捕捉到错误是软件工程师的主要的规则。 


 

Windows 2000驱动程序支持的例程 

I/O管理器和WIN2000其它的内核模式部分提供大量的供驱动程序调用的函数。DDK文文件描述了这些函数。现在,指出WIN2000支持的例程种类已经足够。如表5.1所示: 

分类 
 支持 
 函数名 
 
Executive 
 存储器分配,互锁队列区域,监控列表,系统工作者线程 
 ExXxx() 
 
HAL 
 设备寄存器,总线访问 
 HalXxx() 
 
I/O Manager 
 普通驱动程序支持 
 IoXxx() 
 
Kernel 
 DPC,同步 
 KeXxx() 
 
Memory Manager 
 虚拟存储器到物理存储器的映 射,存储器分配 
 MmXxx() 
 
Object Manager 
 句柄管理 
 ObXxx() 
 
Process Manager 
 系统线程管理 
 PsXxx() 
 
Runtime library 
 字符串操作,大整数运算,寄存器访问,安全性函数,时间和日期函数,队列和列表支持 
 RtlXxx() (大部分的例程) 
 
Security Monitor 
 特权检查,安全描述符函数 
 SeXxx() 
 
(Miscellaneous) 
 内部系统服务 
 ZwXxx() 
 

表5.1WIN2000支持的例程种类 

  ZwXxx函数需要更多的说明。它们实际上是为所有的NtXxx用户模式系统服务的内部调用接口。用户模式和内核模式接口时不同的,ZwXxx函数不执行任何参数校验。虽然这样的函数有很多,DDK文件仅仅描述很少一部分。使用无正式文件的函数是一个冒险,因为微软在未来可能改变或者删除这些函数。 

  I/O管理器提供几个方便的函数,它们包装了一个或者多个对其它内核模块的低级的调用,应该尽量使用它们。 

丢弃初始化例程 

  一些编译器支持声明某些函数函数可以丢弃的选项。被选中的函数在驱动程序完成加载后被移出存储器,这样使驱动程序更小。最好使用这种开发环境。 

可以丢弃的函数是DriverEntry和那些只是被DriverEntry调用的子程序。以下的代码片段描写怎样利用可以丢弃的代码(使用微软的C程序开发环境): 

#ifdef ALLOC_PRAGMA 

#pragma alloc_text( init, DriverEntry ) 

#pragma alloc_text( init, FuncCalledByDriverEntry ) 

#pragma alloc_text( init, OtherFuncCalledByDriverEntry ) 

#endif 

alloc_text pragma必须出现在函数名声明之后,定义之前。在pragma声明中的函数必须在同一个编辑单元。 

控制驱动程序分页 

  没分页的系统存储器是宝贵的资源。驱动程序可以定义适当的例程到分页的存储器来减少非分页存储器的负担,任何IRQL是PASSIVE_LEVEL的函数可以在分页的存储器中运行,包括Reinitialize例程,Unload例程,Shutdown例程,Dispatch例程,线程函数以及任何运行在PASSIVE_LEVEL IRQL的帮助函数。再一次,是alloc_text pragma执行这个声明,例子如下: 

#ifdef ALLOC_PRAGMA 

#pragma alloc_text( page, Unload ) 

#pragma alloc_text( page, Shutdown ) 

#pragma alloc_text( page, DispatchRead ) 

#pragma alloc_text( page, DispatchHelper ) 

#endif 

最后,如果整个驱动程序很少使用,它可以被暂时的被翻出存储器。MmPageEntireDriver例程不管驱动程序声明的存储器管理属性,就将整个模块暂时的翻页。这个函数应该在DriverEntry例程的末尾调用和当不再打开任何它的设备句柄时从IRP_MJ_CLOSE的派遣例程,当使用这个驱动程序的时候,就在IRP_MJ_CREATE的派遣例程中调用MmResetDriverPaging来使驱动程序的页属性恢复正常。 

当使用者个技术的时候,注意两件事: 

1. 调用MmPageEntireDriver时,确定没有IRP被驱动程序的高级IRQL部分处理。 

2. 当ISR被分页的时候,确定没有设备中断到达。 

驱动器存储器分配 

  程序设计涉及的一个重大的方面是分配存储单元。不幸的是,驱动程序不能简单的调用malloc和 free,或者new和delete。要确定分配正确类型的存储器,使用完毕后必须释放分配的存储器,因为内核模式代码没有自动清除机制。 

驱动程序可用的存储器 

  驱动程序有三种分配存储器的选择。标准的分配存储器的选择依靠持续时间,大小,IRQL来确定。可用的选择是: 

1. 内核堆栈:内核模式堆栈在驱动程序例程执行期间给局部变量提供有限数量的非分页存储空间。 

2. 分页池: 运行在DISPATCH_LEVEL IRQL以下的例程可以有一个称作分页池的堆。这个区域的存储器是可分页的,当它访问的时候可能产生缺页故障。 

3. 非分页池:运行在提高IRQL的例程需要从一个称作非分页池的堆空间中分配暂时的存储空间。系统保证非分页池中的虚空间总是在物理存储器空间中。I/O管理器创建的设备和控制器Extension就存储在这个区域。 

因为驱动程序必须是可重入的,所以除了只读数据之外从不分配全局变量。否则,一个线程尝试存储数据到的全局变量,与另一个线程的读写将可能是同一个数据。 

当然,驱动程序的局部静态变量同样糟糕。驱动程序的状态信息必须存储在其它的地方,例如像以前介绍的设备Extension。 

使用内核堆栈 

  在80X86的平台上,内核堆栈的大小仅仅是12Kbyte,在其它的平台上,内核堆栈的大小是16Kbyte。因此,内核堆栈是宝贵的资源,内核堆栈溢出将导致异常。遵循以下指导方针可以避免内核堆栈溢出: 

1. 不要设计有很深的嵌套的内部例程,保持调用树尽量平坦。 

2. 应尽量避免递归,必须的时候应当限制递归的深度。驱动程序不是进行Fibonacci级数运算的地方。 

3. 不要使用内核栈来创建大的数据结构,大的数据结构应该在堆中创建。 

另一个内核堆栈的特性是它存在于高速缓冲存储器中,所以它不能用作DMA操作,DMA的缓冲区应该在非分页池中。 

使用缓冲池 

  使用内核例程ExAllocatePool和ExFreePool在缓冲池中分配存储空间。 

  这些函数允许分配下列存储空间: 

1. NonPagedPool:IRQL高于或者等于DISPATCH_LEVEL的驱动程序例程可用的存储器。 

2. NonPagedPoolMustSucceed:驱动程序继续操作的重要的临时存储空间。在紧急的情况下使用这种存储空间,使用后要尽快释放。实际上,如果分配失败将会产生异常。 

3. NonPagedPoolCacheAligned:保证CPU高速缓存线的自然边界对齐的存储器,驱动程序使用这中存储器作为永久的I/O缓冲区。

 4. NonPagedPoolCacheAlignedMustS:对驱动程序重要的操作的临时缓冲区。末尾的S表示成功。像之前的MustSucceed操作这个请求大概从没有被使用过。 

5. PagedPool:  有被IRQL低于DISPATCH_LEVEL的例程使用。通常,这些包括驱动程序的初始化,清除,派遣例程和任何内核模式的线程。 

6. PagedPoolCacheAligned:它是文件系统使用的I/O缓冲区存储空间, 

使用系统存储器的时候,记得下列几点: 

1. 缓冲池是珍贵的系统资源,不要太奢侈,尤其是非分页区域。 

2. 当分配或者释放非分页存储器驱动程序必须在或者高于DISPATCH_LEVEL的IRQL上执行。驱动程序必须执行在或者低于below APC_LEVEL的IRQL上分配或者释放分页存储器。 

3. 尽快释放不再使用的存储器,否则系统在缺少存储器的情况下运行效率会变低。特别的,要确认在卸载驱动程序的时候归还空间给缓冲池。 

存储器再分配 

  一般地,驱动程序应当避免常常分配和释放小于PAGE_SIZE bytes的缓冲池,这样使缓冲池中产生碎片而不能被其它内核模式代码使用。如果必须这样的话,就分配一个大块的存储区域和提供一个再分配子程序去分配它们。 

实际上,一个C程序员可能编写自己的在一个大的缓冲池中分配和释放存储空间的子程序,一个C++程序员可能重载new和delete操作。 

一些驱动程序需要管理一些小的固定尺寸的存储块。例如,SCSI驱动程序必须提供一个SCSI请求块(SRBs),它被用来发送命令给SCSI设备。内核提供两个不同的处理在分配的机制: 

区域缓冲区 

  区域缓冲区是一块驱动程序分配的缓冲池。Executive例程提供管理分页或者非分页存储器中的固定尺寸的存储块。 

使用区域缓冲区时要注意同步,特别的,如果一个中断服务,DPC,派遣例程都需要访问统一个区域缓冲区,这时要使用一个Executive自旋锁来同步。如果所有的访问操作在统一个IRQL水平,可以使用一个互斥体代替。 

在安装区域缓冲区之前,必须了解ZONE_HEADER数据结构。区域缓冲区或者快速互斥体对象必须声明和初始化。下面是管理区域缓冲区的步骤: 

        1. 调用ExAllocatePool请求区域缓冲区,然后使用ExInitializeZone初始化它,这个步骤常常在DriverEntry例程中执行。 

        2. 调用ExAllocateFromZone或者ExInterlockedAllocateFromZone从区域缓冲区分配一个块,后者使用一个自旋锁去同步访问区域缓冲区。前者的同步工作留给了驱动程序代码。 

3. 调用ExFreeToZone或者InterlockedFreeToZone释放分配的块。 

        4. 在驱动程序的Unload例程,使用ExFreePool释放整个区域缓冲区的存储空间。在释放区域缓冲区的时候,必须确定区域缓冲区中的块都被释放。 

一个区域缓冲区应该不大于必要的空间,MmQuerySystemSize可以得到可用的系统存储空间的总数,另一个Executive函数MmIsThisAnNTAsSystem是用来检查当前的平台是否是WIN2000的服务器版本,运行在服务器版本的驱动程序可以分配稍微大一点的空间。 

如果在区域缓冲区中分配存储块失败,驱动程序将使用标准的缓冲池去得到请求的块,这个策略需要一个清楚的结构去指出分配是来自区域缓冲区还是缓冲池。这样才能调用合适的例程释放这个块。 

通过ExExtendZone或者ExInterlockedExtendZone可以使存在的区域缓冲区的更大,但是这些函数很少被使用,系统好象不能正确的分配额外的区域缓冲区,实际上,微软有考虑过废除整个区域缓冲区的抽象。WIN2000提供一个更加有效的监视列表构架。 

监视列表 

  监视列表(Lookaside List)是一个固定尺寸存储块的一个连接列表,不像区域缓冲区,根据系统状态的不同监视列表可以动态的增大或者减小。因此合适大小的监视列表可能较少的浪废存储空间。 

与区域缓冲区相比,在监视列表上使用同步机制更加有效,如果CPU构建中有一个8-byte比较交换指令,Executive使用它去使访问监视列表的操作连续。在没有这个指令的平台上,对于非分页池就使用一个自旋锁,对于分页池就使用一个互斥体。 

在使用监视列表之前,必须声明一个NPAGED_LOOKASIDE_LIST或者PAGED_LOOKASIDE_LIST(依赖于存储空间是否分页)结构,下面介绍监视列表的管理过程: 

        1. 使用ExInitializeNPagedLookasideList或者ExInitializePagedLookasideList函数初始化列表的头结构,通常DriverEntry或者AddDevice例程执行这个任务。 

        2. 调用ExAllocateNPagedLookasideList或者ExAllocatePagedLookasideList从监控列表分配一个块,可以在驱动程序的任何地方调用这两个例程。 

3. 调用ExFreeToNPageLookasideList或者ExFreeToPageLookasideList释放块。 

       4. 调用ExDeleteNPagedLookasideList或者ExDeletePagedLookasideList释放任何于监控列表相关联的资源,这个函数通常在驱动程序的Unload或者RemoveDevice例程中执行。 

监控列表初始化函数简单的设置列表头,它们不实际的分配存储器给列表。初始化函数请求列表可以拥有的最大数量(称作列表的深度)的块。 

当使用分配函数的时候,系统才分配需要数量的存储空间,当块被释放,他们被添加到监控列表,直到最大允许深度。任何块的释放将导致存储空间被释放到系统,这样一段时间之后,在监控列表中的块数量将趋近于列表深度附近。 

要小心控制监控列表的深度,如果太浅,系统会常常执行昂贵的分配和释放的动作,如果太深,会造成存储空间的浪费。列表头结构的统计数字可以帮助决定列表深度。 

阅读(1419) | 评论(0) | 转发(0) |
0

上一篇:A simple WDM driver

下一篇:echo on off

给主人留下些什么吧!~~