Windows NT 设备驱动程序开发基础
一、背景介绍
1.1 Windows NT操作系统的组成
1.1.1 用户模式(User Mode)与内核模式(Kernel Mode)
从Intel 80386开始,出于安全性和稳定性的考虑,该系列的CPU可以运行于ring0
~ring3从高到低四个不同的权限级,对数据也提供相应的四个保护级别。运行于较
低级别的代码不能随意调用高级别的代码和访问较高级别的数据,而且也只有ring0
层的代码可以直接进行对物理硬件的访问。由于Windows NT是一个支持多平台的操作
系统,为了与其他平台兼容,它只利用了CPU的两个运行级别。一个被称为内核模式,
对应80x86的ring0层,操作系统的核心部分,包括设备驱动程序都运行在该模式;另
一个被称为用户模式,对应80x86的ring3层,操作系统的用户接口部分以及所有的用
户应用程序都运行在该级别。
1.1.2 Windows NT操作系统的结构
图1简要地描述了Windows NT的系统组成。
图一
从图中可以看到,在物理硬件(Hardware)与系统核心(Kernel)之间有一个硬件抽象
层(Hardware Abstraction Layer),它屏蔽了不同平台硬件的差异,向操作系统的
上层提供了一套统一的接口。从图中我们还可以看到,设备驱动程序(Device Driver)
是被I/O管理器(I/O Manager)包围起来的,即驱动程序与操作系统上层的通信全部都
要通过I/O管理器。这给驱动程序的编写带来了很大的便利,因为很多诸如接收用户的请
求 、与用户程序交换数据、内存映射、挂接中断、同步等等麻烦的工作都由I/O管理器代
劳了。
1.1.3 Windows NT设备驱动程序的分类
根据是否直接操作硬件,可以把驱动程序分成两大类:内核模式的驱动程序和专用驱
动程序。
内核模式的驱动程序根据硬件的通信协议,直接对硬件进行端口访问、中断响应、DM
A传输。它包括:串、并行口,键盘,文件系统,SCSI,网络等驱动程序;专用驱动程序
包括视频,打印,多媒体,虚拟DOS等驱动程序,他们在实现上与前者有很大区别。我在
实习期间所做的工作以及本文以下的讨论都局限于内核模式的驱动程序。
1.2 Windows NT下内核模式设备驱动程序的结构和运行
一般来说,设备驱动程序的任务主要有二:第一,接受来自用户程序的读写请求,把
用户的数据传送给设备,或把从设备接收到的数据传送给用户;第二,轮询设备或处理
来自设备的中断请求,完成数据传输。
1.2.1 驱动程序与用户程序的通信
I/O管理器把每一个设备对上层都抽象成了文件,所以在Win32用户程序中只要通过以
下几条简单的文件操作API函数就可以实现与驱动程序中的某个设备通信(请注意,一个
驱动程序可以驱动多个设备):
函数名 功能
CreateFile 打开一个设备,准备进行数据传输。返回一个与设备相关的句柄。
CloseHandle 关闭一个由CreateFile打开的设备。
ReadFile 从设备读取数据。
WriteFile 向设备写数据。
DeviceIoControl 对设备进行一些自定义的操作,比如更改设置等。
表一
1.2.2 DriverEntry过程
这是每一个设备驱动程序的入口,每次该程序启动时被系统自动调用。大部分的设备
初始化的工作都在这个过程中完成。包括设置响应各种用户请求的过程的入口,使I/O管
理器能知道当用户的打开、关闭、读写等请求到来时各应调用那些过程来处理。驱动程
序中只有本过程的名字\"DriverEntry\"是固定的,以下列出的所有过程都要由本过程向系
统注册。
如果该驱动程序不响应任何请求的话,只要一个DriverEntry过程就可以构成一个能运
行的驱动程序。
1.2.3 Unload和ShutDown过程
Unload过程负责在驱动程序被停止前做一些必要的处理。比如释放资源,记录最终状
态等。ShutDown过程在系统即将关闭时被调用,与前者的区别在于不用释放任何资源。
1.2.4 DispatchOpen和DispatchClose过程
这两个过程在用户调用CreateFile和CloseHandle时被调用,为即将到来的读写操作做
备,或做一些读写完成后的必要处理。
1.2.5 DispatchRead, DispatchWrite与StartIo过程
这前两个过程在用户调用ReadFile和WriteFile时被调用。它们先做一些检验用户请求
合法性的工作,然后启动一个被称为StartIo的过程开始实际的与硬件间的数据传输。I
/O管理器还通过IRP为它们提供了一个指向用户缓冲区的指针,用于与用户程序交换数据
。详情请见1.3.2
1.2.6 接受自定义的其他请求
这两个过程在用户调用DeviceIoControl时被调用。它通过IRP获得用户的请求号,以
及一个指向用户缓冲区的指针,可以与用户程序进行通信。
1.2.7 中断处理过程(ISR)
这些过程在中断发生时被系统调用。
1.2.8 推迟过程(Deferred Procedure)
这些过程用来在较低的运行级别完成较高运行级别过程(如中断处理过程)的一
些任务。详情请见1.3.3
1.3 实现细节
1.3.1 内核代码运行级别
Windows NT为它的内核模式的代码分配了不同的级别。在同一个CPU上,级别低的过程
可以被任何级别更大的过程中断。级别由低到高排列如下:
级别名称 运行于该级别的过程
PASSIVE_LEVEL DriverEntry, Unload, ShutDown, DispatchXxx。
APC_LEVEL 在某些特殊情况下,大存储量设备的驱动程序运行于该级别。
DISPATCH_LEVEL StartIo, AdapterControl, ControllerControl, IoTimer,Dpc。
DIRQLs 各种中断处理程序。
表二
1.3.2 几个对象
i) I/O请求包(IRP)
I/O管理器每收到一个来自用户的请求就创建一个该结构,并将其作为参数传给驱
动程序的DispatchXxx、StartIo过程。该结构中存放有请求的类型,用户缓冲区的首地
址,用户请求数据的长度等信息。驱动程序处理完这个请求后,也在该结构中添入处理
结果的有关信息,调用IoCompleteRequest将其返回给I/O管理器,用户程序的请求随即
返回。
ii) DPC
当驱动程序中要用到Dpc过程时,需要创建该对象。具体作用请见1.3.3。
iii) 驱动程序对象(DriverObject)
该对象在驱动程序被启动时由I/O管理器创建,保存有该程序处理各种请求的过程
入口、该程序所驱动的全部设备对象的链表等。
iv) 设备对象(DeviceObject)
每发现一个可以驱动的设备,驱动程序调用IoCreateDevice创建一个该对象。该
对象有一个指针DeviceExtension指向一块由驱动程序定义的结构,其中保存有关此设备
的如端口号,中断向量等全部信息。
v) 中断对象(Interrupt)
该对象在驱动程序调用IoConnectInterrupt时创建,存有中断及处理的过程的信息。
当一个中断发生时,I/O管理器用它寻找对应的处理过程。
1.3.3 推迟过程调用(Deferred Procedure Call)
由于中断处理过程运行于较高的DIRQL级,它们能屏蔽许多级别小于或等于它们的过程
的执行,如果它们占用CPU时间过长,很容易使系统性能下降。因此中断处理过程应将一
些不是很紧急的任务放在被称为Dpc的过程中,在完成数据传输等紧急任务后将一个DPC
对象放在系统DPC队列的末尾,然后退出,尽量早地让出CPU。系统将在完成所有DIRQL级
的任务后处理DPC队列,在DISPATCH_LEVEL执行每一个DPC 对象指定的Dpc过程,完成中
处理断过程未尽的任务。
1.3.4 查找硬件信息
i) 系统自动搜索到的设备
在系统启动时,组件NTDETECT会自动地搜索计算机上已有的硬件,包括串、并行
口,键盘,鼠标,以及大多数PCI和EISA设备。并将它们的信息,包括总线类型,总线号
,用到的端口号及数量、中断向量号、DMA通道号、占用内存等按一定格式添入注册表的
\\HKEY_LOCAL_MACHINE\\Hardware\\description\\System\\ 键之下。在驱动程序中可以用I
oQueryDeviceDescription以及一个回调函数ConfigCallback来查找符合要求的设备,并
获取它的配置信息。
ii) 系统不能自动搜索到的设备
一些ISA的设备无法被系统自动检测到,只有在安装驱动程序时在注册表中人工添入它
们的配置信息。驱动程序启动时可以用RtlQueryRegistryvaluess等函数查询注册表获得
这些信息。
1.3.5 有关内存
80386以上的32位CPU可以管理多达4GB的物理内存。它将这些内存分为许多大小为64K
B的段和4KB的页来管理,并通过段描述符和页表将物理地址映射成系统地址供程序访问
。由于Windows NT使用虚拟内存技术,可能某些系统地址对应的物理地址处于硬盘上,
每当程序读写这些地址时会产生一个缺页异常,使CPU将这些内存调入物理存储器中。这
部分内存被称为分页内存(Paged)。与之对应的是非分页内存(Nonpaged),这部分内
存保证是物理驻留的。驱动程序中运行级别大于等于DISPATCH_LEVEL的过程不能访问分
页内存,否则引起系统崩溃。
1.3.6 缓冲的I/O与直接I/O
在驱动程序创建了一个设备后,可以通过设置DeviceObject的Flags域的值来将设备设
置成缓冲的I/O或直接的I/O。
如果该值被设为DO_BUFFERED_IO,每当I/O管理器收到一个读写请求,就在内存的非分
页区分配一块与用户区大小相同的区域,并将首指针存放于Irp对象的AssociatedIrp.S
ystemBuffer中,驱动程序就通过这个缓冲区与用户交换数据。每当一个读请求被完成时
I/O管理器自动将该缓冲区中的内容复制到用户区,并释放该区域。
如果用户区大于一页(在80x86上为4096字节),一般将该值设为DO_DIRECT_IO。
这时每当I/O管理器收到一个读写请求,先锁定用户区的物理内存,然后为其创建一个内
存描述表(MDL),并将该表的首指针存放于Irp对象的MdlAddress中,驱动程序可以通过
调用MmGetSystemAddressformdl获得用户区在系统空间中的地址。每当一个读请求被完
成时I/O管理器自动将该区域解锁。
1.3.7 定时
为了防止当设备出现某种故障时导致读写请求超时,或需要定时轮询某些设备的状态
,驱动程序需要设置一些定时器。驱动程序中有两种方法可以设置定时器。一种是调用
IoInitializeTimer将一个定时器过程IoTimer与一个设备对象联系起来。在调用IoStar
tTimer后,系统将每一秒钟调用一次IoTimer,直至驱动程序调用IoStopTimer。如果需
要设置更小间隔的定时器,需要用到被称为CustomTimerDpc的一种推迟过程调用机制。
它可以设置系统每隔一定时间将一个设置好的DPC对象放到DPC队列的末尾,执行一个指
定的定时器Dpc过程。这个时间间隔可以精确到100ns。
1.3.8 同步
如果驱动程序有可能在某时刻有多个部分在同时运行,比如有中断处理过程,或
存在多个设备等,对公共数据或代码的访问就需要同步。方法有
i) 自旋锁(SpinLock)
驱动程序可以在初始化时调用KeInitializeSpinLock创建该对象。在任何代码段
访问被保护的数据之前,先调用KeAcquireSpinLock试图获得该对象的所有权,如果成功
,该段代码被系统提升至DISPATCH_LEVEL,进行数据访问。访问完毕后须调用KeRelease
SpinLock释放所有权,运行级别也被恢复。此方法只适用于同步运行级别小于等于DISP
ATCH_LEVEL的代码,主要用于多CPU的情形。此外,还有一种中断自旋锁用于与中断处理
过程同步,可以将较低级别的代码提升到需要与之同步的中断DIRQL。
ii) 控制器(Controller)
该对象主要用于同步一个驱动程序中的多个设备,保证它们能顺序地访问特定的
代码或数据。该对象在驱动程序初始化调用IoCreateController被创建。设备在StartI
o过程中调用IoAllocateController请求获得Controller对象的独占权。使用完后调用I
oFreeController释放。驱动程序停止时调用IoDeleteController从内存删除该对象。该
对象有一个指针ControllerExtension指向一块由驱动程序定义的结构,其中保存有此驱
动程序的公共数据。
iii) 适配器(Adapter)
该对象用于同步多个设备(不一定在一个驱动程序中)对DMA通道的使用。该对象
在系统启动侦测硬件时自动被创建。驱动程序在初始化时调用HalGetAdapter获得该对象
的指针。设备在StartIo过程中调用IoAllocateAdapterChannel请求获得DMA通道的独占
权,然后开始传输数据。使用完后调用IoFreeControllerChannel释放DMA通道。
iv) DPC
由于DPC队列中的对象总是被系统顺序地处理,所以也可以将需要同步的代码做成
Dpc过程,需要调用时将相应的DPC对象放到队列的末尾即可。
v) 其他
同用户模式的应用程序类似,驱动程序也可以使用多线程,也提供了一套用来同步的
对象,如Event, Mutex, Semaphore, Timer, Thread。其中Event对象可以被命名,不同
的驱动程序可以利用同名的Event对象同步对公共数据的访问。
1.3.9 分层
I/O管理器一个有用的功能是允许把一个驱动程序堆在另一个驱动程序之上。这样在分
编写如网络驱动等有协议栈程序时,可以为各层编写相对独立的代码。当驱动程序需要
在不同的平台上移植时,只需重新编写最下层的硬件驱动程序即可。高层驱动程序的另
一个功能是可以对用户请求进行予处理,比如把较大的请求分割成较小的请求分多次传
给给下层的程序。
1.3.10 设备名及其符号连接
Windows NT系统维护着一个对象名字空间,把所有在系统内注册过的对象的名字分类
存在一个树状空间里,用Win32 SDK提供的WinObj工具可以浏览这个空间。如果希望设备
能被用户的CreateFile函数打开,就需要在调用IoCreateDevice创建该设备对象时赋予
它一个名字,位于\\Device\\下,并调用IoCreateSymbolicLink在\\DosDevices\\下创建一
个符号连接。这样,用户程序就能用CreateFile(\"\\\\\\\\.\\\\符号连接名\",……)打开该设
备,并获得其句柄。
1.4 驱动程序的编译链接,调试、安装和启动。
Windows NT下编写驱动程序的环境被称为为DDK(Device Driver Kit) For Micro
soft Windows NT,这是一个命令行下的工作环境。但是在安装DDK之前需要安装Win32
SDK(Software Development Kit)以及 Microsoft Visual C++。
编译链接器为Build.exe,他从配置文件Sources中读出待编译的程序的配置,包
括源文件、目标文件等,从环境变量Include中得到引用文件的地址,然后调用Visual
C++的编译链接器Nmake.exe进行实际的编译链接工作。日志文件build.log,build.wrn
,build.err 中分别记录了编译链接中执行的命令行,遇到的错误,遇到的警告。编译
完成后的文件后缀为.sys
安装过程分两步:第一,将编译成的.sys文件拷贝到Windows NT的System32\\Dri
vers\\下;第二,在注册表的HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Service
s\\下创建与.sys文件同名的键,然后在之下创建名为Start,Type, ErrorControl的三
个REG_DWORD类型的数值键。其中Start的键值控制该驱动程序在系统启动的哪个阶段被
启动。小于3的数设定该驱动程序在系统启动的某个阶段被自动启动;3表示需要管理员
手动启动;4表示该程序被禁用。设置完毕后需要重新启动系统。
手动启动和停止一个驱动程序需要使用控制面板(ControlPanel)中的设备(Device
)图标。
由于驱动程序的结构比较复杂,而且调试内核模式的代码需要两台安装有Windows NT
的计算机,比较麻烦,所以在编写一个较复杂的驱动程序的过程中应分步来进行测试。
在完成任何一部分工作后都应进行测试,以便及早地发现错误。根据本人的经验,驱动
程序中的大多数错误都是由于不正确地访问内存造成的。比如使用未被初始化的指针,
释放已经被释放的内存,在DISPATCH_LEVEL或以上的运行级别引用分页的内存。