Windows驱动程序框架

司寇季
2023-12-01

一、驱动程序框架介绍

    很多人都用过VC++等图形集成开发环境(IDE)开发过Windows应用程序,当用集成开发环境生成一个工程时,会自动生成一个预先定义好的命令行,这个命令行包含了编译器(compiler)和连接器(linker)某些缺省的配置。很多习惯于图形集成开发环境的人可能对此并不了解。你可能用IDE生成过GUI应用程序,也可能生成过console应用程序,这是两种不同的子系统(subsystem)应用程序,如果你注意观察,可能会发现,console应用程序中是以main函数为入口函数,而GUI应用程序是以WinMain函数为入口函数。在工程设置上,console应用程序的设置是/SUBSYSTEM:CONSOLE,而GUI应用程序的设置是/SUBSYSTEM:WINDOWS。而驱动程序的设置是/SUBSYSTEM:NATIVE。

    虽然在工程设置里,你可以通过选项“-entry:DriverEntry”来设定驱动程序的入口函数名字,但驱动程序的入口函数一般都命名为DriverEntry,DriverEntry已经成为官方缺省的驱动函数入口名称。

    连接器(linker)根据windows可执行文件PE头的设置,生成最后的二进制文件,PE文件头的设置还决定了这个可执行文件如何被加载的,例如是作为一个可执行文件被加载,还是作为一个动态链接库被加载,还是作为一个驱动程序被加载。加载器(loader)会根据这些设置来验证是否支持所设定的加载模式。我们只需设置好加载模式,加载器就会根据这个设置来加载我们的程序。
 
    一般的驱动程序设置如下:
       /SUBSYSTEM:NATIVE  /DRIVER:WDM -entry:DriverEntry

    在开始写DriverEntry之前,我们先说一下驱动程序的一些特殊之处。我知道,很多人都想能够尽快写一个驱动程序,想看看到底驱动程序是如何工作的。这在写windows应用程序时,经常是这样的,拿一个例子来,改动一下,编译通过后,运行测试。如果运行不正确,应用程序崩溃了,或者消失了,这对系统不会造成多大影响,但是在编写驱动程序时,出现错误会导致蓝屏,当面对蓝屏时,往往会不知所措,如果驱动程序是在系统启动时加载的,情况会更糟糕。这时只有重新启动系统,进入到安全模式,恢复到先前的硬件配置。

    首先应该知道的是,驱动程序是加载到系统内核中的,如果驱动程序编写不当,会影响到系统的完整性,驱动程序中的BUG可能会导致整个系统的崩溃。Windows采用虚拟内存机制,系统会将内存中某些页面交换到外部磁盘上来,这对应用程序是透明的,影响不大,但是有时候驱动程序要求访问的内存是不能被交换到外部磁盘的,必须在内存中,否则可能会引起系统蓝屏。

    驱动程序中使用内存必须小心,在某些情况下,如果一个驱动占用了可交换的内存页面,系统会尽可能的将这些页面保持在内存中。如果关闭了应用程序,驱动仍旧占用内存,这bug是很难发现的,除非进行驱动验证(driver verify)。(需深入理解)

    关于IRQL和IRP,微软的MSDN有很长的篇幅来描述,这里只是尽可能用比较简单的描述来解释它。

    IRQL(Inerrupt Request Level),中断请求级别,任何一个进程都是在线程中执行的,而任何一个线程都运行于一定的IRQL,进程的IRQL决定了线程允许如何被中断。同一个处理器上线程只能被具有更高IRQL级别的线程所中断,低优先级或同等优先级的中断会被屏蔽,只有高级别的IRQL才会中断。在多处理器系统中,每个处理器都有自己独立的IRQL。

    系统共有四种级别的IRQL,分别是“Passive”“APC”“Dispatch”“DIRL”。IRQL级别越高,可调用的API函数就越少。MSDN的内核函数API文档中都会注明在哪个中断请求级别上调用。例如DriverEntry函数就是运行在PASSIVE_LEVEL。

    PASSIVE_LEVEL是最低级的IRQL,不会屏蔽任何中断。用户态应用程序的线程就运行在这个级别上,可以使用可交换的内存。
    APC_LEVEL,异步调用就运行在这个级别,这时会屏蔽APC级别的中断。在这个级别仍可访问可交换内存。当一个APC中断发生时,处理器提升到APC级别,这时,就禁止了其他的APC中断。驱动程序自己提升到APC级别,以便处理同步操作。
    DISPATCH_LEVEL,运行于这个级别的处理器会屏蔽除DPC以外的中断,不能访问可交换内存,所以这个级别能调用的API函数大大减少。
    DIRQ,设备级中断,这是硬件设备的中断,一般高层的驱动程序不需要处理这个中断,只有底层的驱动程序才处理这个中断。

    刚开始学习编写驱动程序,可以先集中精力学习驱动程序的框架,但是,对中断级别要有一定的理解。

    IRP(I/O Request Packet),中断请求包,它会沿着驱动程序栈在驱动程序间传递。IRP包是由I/O管理器或者是另一个驱动程序产生,并传递到你的驱动程序中来,驱动程序利用IRP包来传递信息并完成请求任务。IRP包中包含所请求的操作信息。

    IRP包在MSDN中的解释很详细,大约有二十多页。IRP包包含一个“子请求”的列表,也称为IRP栈。为了形象的解释IRP栈是如何工作的,我们做一个比喻。假设你有三个人,一个是木匠,一个是管钳工,一个是焊工,他们三个共同建筑一个房子,他们有自己的工具箱,每个人都要完成自己的工作。而IRP包就是发起建造房子的总命令。一旦建造房子的IRP总命令下达以后,每个人开始自己的工作,每个人都完成自己的工作以后,建造房子的总命令也就完成了。

    现在我们开始讨论DriverEntry例程,声明如下:
       NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath);
参数DRIVER_OBJECT是一个数据结构,表示这个驱动。RegistryPath是一个字符串,在注册表中描述这个驱动的一些信息,驱动程序也可以在注册表的这个位置添加一些特殊的信息。在这个例程中,一般要用到一个有用的数据结构,那就是DEVICE_OBJECT,它表示一个特定的设备,一个驱动程序有可能操纵多个设备,可以用这个数据结构来区分不同的设备。
 
    下面我们来编写DriverEntry例程,第一件事情就是创建一个设备,也许你会感到困惑,没有实际的物理设备,我们如何创建设备,虽然驱动程序往往是和具体的硬件联系在一起,但是驱动程序也可以不和特定的硬件设备相绑定。驱动程序也有很多类型,驱动程序也分不同的层次,并不是所有的驱动程序都和硬件打交道。最高层的驱动程序一般要和用户层的应用程序相交互,最底层的驱动程序一般和具体硬件或者其他驱动打交道。系统中有网卡驱动,显卡驱动,文件驱动等等,每种驱动都有自己的驱动栈。驱动栈或者把一个IRP请求分成几个请求传给其他驱动栈,或者只是简单的把这个请求转发给底层的驱动。

    我们以磁盘操作为例,根用户态应用程序交互的驱动程序并不直接和底层的硬件打交道,高层的驱动只是管理文件系统本身,当要进行读写时,它会和位于它下面的中间层驱动交互,中间层驱动和底层的驱动交互,底层的驱动才进行实际的物理操作。

    下面分析一下DriverEntry的前一部分

NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
NTSTATUS NtStatus = STATUS_SUCCESS;
UINT uiIndex = 0;
PDEVICE_OBJECT pDeviceObject = NULL;
UNICODE_STRING usDriverName, usDosDeviceName;

DbgPrint("DriverEntry Called \r\n");

RtlInitUnicodeString(&usDriverName, L"\\Device\\Example");
RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example");

NtStatus = IoCreateDevice(pDriverObject, 0,
&usDriverName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE, &pDeviceObject);
}

    第一个函数时DbgPrint函数,这相当于应用层的printf函数,它会将调试信息发送给调试器,你可以用软件“DBGVIEW”来查看打印信息,这个软件可以在www.sysinternals.com下载。
    第二个函数是RtlInitUnicodString,这个函数初始化一个UNICODE_STRING数据结构,这个数据结构包含三个域,第一个域是这个UNICODE字符串的长度,第二个域是UNICODE字符串最大长度,第三个域是一个指向这个字符串的指针。在驱动程序中很多地方都会使用这个UNICODE字符串结构,记住,这个字符串结构不是以NULL结尾的,因为它有字符长度这个信息,所以无需以NULL结尾。新手有时会以为这种字符串是以NULL结尾的,结果往往会造成蓝屏。
    设备有自己的名字,设备的命名往往如下所示:\Device\<somename>,这个名字是调用IoCreateDevice时的参数。IoCreateDevice函数的第一个参数一个指向设备的指针,第二个参数是我们设置为0,这个参数的意思是指设备扩展数据结构的大小,可以通过这个数据结构传递驱动的所需的信息,这个参数比较重要,在这个例子中我们没有用到,所以暂时设置为0。
    现在我们已经成功的创建了\Device\Example设备驱动,现在需要设置相应IRP包的函数指针。

for(uiIndex = 0; uiIndex < IRP_MJ_MAXIMUM_; uiIndex++)
pDriverObject->Major[uiIndex] = Example_UnSupported;

pDriverObject->Major[IRP_MJ_CLOSE] = Example_Close;
pDriverObject->Major[IRP_MJ_CREATE] = Example_Create;
pDriverObject->Major[IRP_MJ_DEVICE_CONTROL] = Example_IoControl;
pDriverObject->Major[IRP_MJ_READ] = Example_Read;
pDriverObject->Major[IRP_MJ_WRITE] = USE_WRITE_;


   
    我们设置好Create,Close,IoControl,Read,Write等函数指针,当应用层程序调用一定的API函数时,驱动程序就会调用这些设置好的函数。IRP包和API函数的对应关系如下,

CreateFile -> IRP_MJ_CREATE
CloseHandle -> IRP_MJ_CLEANUP & IRP_MJ_CLOSE
WriteFile -> IRP_MJ_WRITE
ReadFile-> IRP_MJ_READ
DeviceIoControl -> IRP_MJ_DEVICE_CONTROL

    下一段代码比较简单:
pDriverObject->DriverUnload = Example_Unload;
    如果想动态的卸载驱动,必须设置这个函数指针,如果不指定这个函数指针那么你的驱动一旦被装载,系统就不会卸载掉它。

    下面的代码使用的是DEVICE_OBJECT,不是DRIVER_OBJECT,这两个数据结构可能有些相似,容易引起混扰,但是它们代表不同的对象。

pDeviceObject->Flags |= IO_TYPE;
pDeviceObject->Flags &= (DO_DEVICE_INITIALIZING);

    这里设置设备标志,IO_TYPE这个标志在后面详细描述。
    DO_DEVICE_INITIALIZING告诉I/O管理器,设备正在初始化,不要发送I/O请求包给这个驱动。在DriverEntry函数中,这个设置并不需要,因为I/O管理器会自动设置这个标志,并且退出DriverEntry时,I/O管理器会自动清除这个标志。但是如果你在别的函数里调用IoCreateDevice,就必须自己去清除这个标志,其实这个标志是在IoCreateDevice函数里设置的。   

    最后的代码片断是建立一个DOS设备名称\DosDevice\Example,函数IoCreateSymbolicLink只是创建了一个符号连接,在NT设备名字和DOS设备名字之间建立一个关联,他们是指同一个设备的。

    不同的设备生产厂商编写自己的驱动程序,这些驱动有自己的名字。在系统中不能有两个名字相同的驱动。比如说,你有一个记忆棒,在系统中的映射到E:盘,如果你拔掉记忆棒之后,把一个网络共享映射到E:盘,应用程序可以跟E:盘交互,应用程序并不关心这个E:盘是光盘、软盘、记忆棒还是网络共享。它们都是通过E:盘这个符号连接与其交互。

    现在我们把“Example”作为一个DOS设备名字,并与“Device\Example”相关联,在与用户态通信部分,我们将继续讨论如何使用这种映射。

    下面是卸载函数例程,为了能动态卸载这个驱动,必须提供卸载例程。这个卸载例程比较简单,删除我们建立的符号连接名字以及设备名字。

VOID Example_Unload(PDRIVER_OBJECT DriverObject)
{

UNICODE_STRING usDosDeviceName;

DbgPrint("Example_Unload Called \r\n");

RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example");
IoDeleteSymbolicLink(&usDosDeviceName);

IoDeleteDevice(DriverObject->DeviceObject);
}

很多人都用过WriteFile和ReadFile,在这两个函数里,传递一个缓冲区参数,读操作会把读到的信息填充到这个缓冲区中,写操作会把缓冲区里的数据写到磁盘上去。这样简单的操作,在驱动层有着比较复杂的机制。驱动程序里有三种读写模式,分别是“Direct I/O”“Buffered I/O”“Neither”。在例子中,我们定义

#ifdef __USE_DIRECT__
#define IO_TYPE DO_DIRECT_IO
#define USE_WRITE_  Example_WriteDirectIO
#endif

#ifdef __USE_BUFFERED__
#define IO_TYPE DO_BUFFERED_IO
#define USE_WRITE_  Example_WriteBufferedIO
#endif

#ifndef IO_TYPE
#define IO_TYPE 0
#define USE_WRITE_  Example_WriteNeither
#endif


   
    先来介绍一下 Direct I/O,代码如下

Example_WriteDirectIO(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS NtStatus = STATUS_SUCCESS;
    PIO_STACK_LOCATION pIoStackIrp = NULL;
    PCHAR pWriteDataBuffer;

    DbgPrint("Example_WriteDirectIO Called \r\n");
   
    /*
     * Each time the IRP is passed down
     * the driver stack a new stack location is added
     * specifying certain parameters for the IRP to the driver.
     */
    pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
   
    if(pIoStackIrp)
    {
        pWriteDataBuffer =
          MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
   
        if(pWriteDataBuffer)
        {                            
            /*
             * We need to verify that the string
             * is NULL terminated. Bad things can happen
             * if we access memory not valid while in the Kernel.
             */
           if(Example_IsStringTerminated(pWriteDataBuffer,
              pIoStackIrp->Parameters.Write.Length))
           {
                DbgPrint(pWriteDataBuffer);
           }
        }
    }

    return NtStatus;
}

函数的入口参数是设备对象,就是请求发送到的设备对象,一个驱动程序可以创建多个设备对象,第一个参数就是即将处理请求的设备对象。第二个参数就是IRP,中断请求包。
函数里做的第一件事情就是调用IoGetCurrentIrpStackLocation,我们会得到属于这个设备的IO_STACK_LOCATION,在我们这个例子里,我们只需要得到这个缓冲区的长度。
通过“MdlAddress”(Memory Deion List),我们可以得到缓冲区的地址,这个地址是用户态地址,我们通过函数“MmGetSystemAddressForMdlSafe”转换成内核可以访问的地址,这样就可以读取缓冲区了。
       这种方法一般用于缓冲区较大的情况,因为这种方法不需要内存拷贝。用户态的缓冲区锁定于内存中一直到这个IRP包完成为止,这也是这种方法的缺点。

    下面介绍一下Buffered I/O,代码如下

Example_WriteBufferedIO(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS NtStatus = STATUS_SUCCESS;
    PIO_STACK_LOCATION pIoStackIrp = NULL;
    PCHAR pWriteDataBuffer;

    DbgPrint("Example_WriteBufferedIO Called \r\n");
   
    /*
     * Each time the IRP is passed down
     * the driver stack a new stack location is added
     * specifying certain parameters for the IRP to the driver.
     */
    pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
   
    if(pIoStackIrp)
    {
        pWriteDataBuffer = (PCHAR)Irp->AssociatedIrp.SystemBuffer;
   
        if(pWriteDataBuffer)
        {                            
            /*
             * We need to verify that the string
             * is NULL terminated. Bad things can happen
             * if we access memory not valid while in the Kernel.
             */
           if(Example_IsStringTerminated(pWriteDataBuffer,
                   pIoStackIrp->Parameters.Write.Length))
           {
                DbgPrint(pWriteDataBuffer);
           }
        }
    }

    return NtStatus;
}


    这种方法就是把数据传递到驱动,系统会把缓冲区分配在不可交换的内存页里,这种方法的优点是别的线程也可以访问这个缓冲区,甚至一些系统进程也可以访问它。缺点是需要分配不可交换内存,并进行数据拷贝,这样在进行读写操作时,会加重系统负担。所以这种方法往往应用于缓冲区较小的情况下。这种方法不象Direct I/O那样,应用程序被锁定在内存中。
   
       下面是Neither方法,代码如下

Example_WriteNeither(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS NtStatus = STATUS_SUCCESS;
    PIO_STACK_LOCATION pIoStackIrp = NULL;
    PCHAR pWriteDataBuffer;

    DbgPrint("Example_WriteNeither Called \r\n");
   
    /*
     * Each time the IRP is passed down
     * the driver stack a new stack location is added
     * specifying certain parameters for the IRP to the driver.
     */
    pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
   
    if(pIoStackIrp)
    {
        /*
         * We need this in an exception handler or else we could trap.
         */
        __try {
       
                ProbeForRead(Irp->UserBuffer,
                  pIoStackIrp->Parameters.Write.Length,
                  TYPE_ALIGNMENT(char));
                pWriteDataBuffer = Irp->UserBuffer;
           
                if(pWriteDataBuffer)
                {                            
                    /*
                     * We need to verify that the string
                     * is NULL terminated. Bad things can happen
                     * if we access memory not valid while in the Kernel.
                     */
                   if(Example_IsStringTerminated(pWriteDataBuffer,
                          pIoStackIrp->Parameters.Write.Length))
                   {
                        DbgPrint(pWriteDataBuffer);
                   }
                }

        } __except( EXCEPTION_EXECUTE_HANDLER ) {

              NtStatus = GetExceptionCode();    
        }

    }

    return NtStatus;
}

这种方法直接读取应用程序的地址,它不需要拷贝数据,也不需要把应用程序锁定在内存中。这种方法的缺点是你必须处理这个请求在调用线程的上下文中。

动态加载和卸载驱动,在这里我们只用一些简单的用户态API来加载或者卸载驱动。代码如下

int _cdecl main(void)
{
    HANDLE hSCManager;
    HANDLE hService;
    SERVICE_STATUS ss;

    hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CREATE_SERVICE);
   
    printf("Load Driver\n");

    if(hSCManager)
    {
        printf("Create Service\n");

        hService = CreateService(hSCManager, "Example",
                                 "Example Driver",
                                  SERVICE_START | DELETE | SERVICE_STOP,
                                  SERVICE_KERNEL_DRIVER,
                                  SERVICE_DEMAND_START,
                                  SERVICE_ERROR_IGNORE,
                                  "C:\\example.sys",
                                  NULL, NULL, NULL, NULL, NULL);

        if(!hService)
        {
            hService = OpenService(hSCManager, "Example",
                       SERVICE_START | DELETE | SERVICE_STOP);
        }

        if(hService)
        {
            printf("Start Service\n");

            StartService(hService, 0, NULL);
            printf("Press Enter to close service\r\n");
            getchar();
            ControlService(hService, SERVICE_CONTROL_STOP, &ss);

            DeleteService(hService);

            CloseServiceHandle(hService);
           
        }

        CloseServiceHandle(hSCManager);
    }
   
    return 0;
}

这段代码加载驱动并启动它,启动类型为SERVICE_DEMAND_START,意思是需要的时候才启动它,它不会随着系统启动就加载。要运行这个程序,把驱动文件example.sys放到c:盘下面。服务启动以后,如果键入回车,就会停止服务,从服务列表中删除并退出。

与驱动通信,下面的代码演示了如何与驱动通信

int _cdecl main(void)
{
    HANDLE hFile;
    DWORD dwReturn;

    hFile = CreateFile("\\\\.\\Example",
            GENERIC_READ | GENERIC_WRITE, 0, NULL,
            OPEN_EXISTING, 0, NULL);

    if(hFile)
    {
        WriteFile(hFile, "Hello from user mode!",
                  sizeof("Hello from user mode!"), &dwReturn, NULL);
        CloseHandle(hFile);
    }
   
    return 0;
}

    可以通过DBGVIEW查看调试打印信息,可以看到,只需简单的打开设备名称,获得句柄,就可以对驱动进行读写操作了。
 

 类似资料: