现在的位置: 首页 > 综合 > 正文

驱动开发1:介绍驱动

2013年12月03日 ⁄ 综合 ⁄ 共 13576字 ⁄ 字号 评论关闭

 

介绍
这篇文章尝试去描述如何为windows nt 写一个简单的驱动。关于写驱动的文章和资源在网上有很多,但是,都有各种不足,使得入门有些困难。你可能会想:如果已经有了一篇文章,为什么还要别的?答案是如果有更多的信息则会使你开始入门更加容易。
这篇文章将会描述如何创建一个简单的设备驱动,动态装载和卸载,和用户模式的交互。
创建一个简单的驱动
什么是一个子系统
在解释如何些一个设备驱动之前,我需要先解释一个概念。编译器和链接器产生一个操作系统能理解的二进制文件,在windows中,这种格式是PE(对于可执行程序)。这就有了一个概念:子系统。一个子系统,根据PE文件头信息,描述如何加载包含入口点的可执行程序信息到二进制文件中。
在vc++中,你可以创建控制台程序和GUI应用程序,这是不同的子系统,他们会根据自己对应的子系统信息生成不同的PE二进制信息。这就是为什么在控制台程序中使用main 而在windows程序中使用WinMain的原因。当你创建工程时,有这样的选项:/SUBSYSTEM: CONSOLE or /SUBSYSTEM:WINDOWS.
驱动使用另外不同的子系统,叫做"NATIVE"。
驱动程序的"main"
“NATIVE”也能运行定义了入口点为“NtProcessStartup”的用户模式的应用程序。这种应用程序默认也是可执行的。你可以覆盖入口点函数名字,比如:使用编译选项-entry:<functionname>。如果我们想写一个驱动,我们需要写一个入口点,其参数列表和返回类型符合驱动程序的模式。如果我们装载这个驱动,系统将会安装它并且告诉系统这是一个驱动。
我们可以使用任意名字,但是一般驱动开发者使用"DriverEntry"作为入口点。这意味者我们需要添加“-entryriverEntry” 到编译器选项。如果我们使用DDK,并选择驱动为这一类型,DDK会自动加入这一项。DDK包含了预设选项的开发环境,能够比较容易创建程序。实际上,开发者可以更改makefile文件来改变这些选项。这就是为什么“DriverEntry”成为了官方驱动入口的原因。
Dlls 实际上也指定了windows子系统去编译的,但是它还有一个开关:/DLL。驱动程序也有这样的开关,/DRIVER:WDM 和/DRIVER:UP(指定了这个驱动不能被装载在多处理器系统中)。
编译器创建二进制文件还要根据PE头选项和装载器如何装载。装载器会在执行时做一些验证,并根据文件类型以假定的方式被装载。比如:在进入程序入口点之前,一些启动代码会先被执行(比如 WinMainCRTStartup 调用 WinMain,初始化 CRT)。你的工作只是简单的写应用程序。
设置的选项如下:
/SUBSYSTEM:NATIVE /DRIVER:WDM –entryriverEntry
创建“DriverEntry”之前
在我们坐下来写“DriverEntry”之前.  这有一些要注意的事情。许多人想直接写一个驱动,然后看它如何工作,这是一般编程的习惯。但是你的程序可能会崩溃,或者消失。如果是一个驱动程序,可能会造成严重的后果,系统崩溃。
第一个规则是在你还没有理解一个驱动如何工作,如何正确运行之前,最好不要随便改动,然后编译运行。举个例子:关于虚拟内存的例子(省略)。
任何事情背后都隐藏很多概念,我试图给出一个基本的摘要并告诉你从哪里查找这些信息。在写驱动之前,理解以下的概念很重要。
什么是IRQL?
IRQL是Interrupt ReQuest Level,中断请求级别。处理器在一个IRQL上执行线程代码。IRQL是帮助决定线程如何被中断的。在同一处理器上,线程只能被更高级别IRQL的线程能中断。每个处理器都有自己的中断IRQL。
我们经常遇见的有四种IRQL级别。“Passive”, “APC”, “Dispatch” and “DIRQL”. “DriverEntry”将会在PASSIVE_LEVEL被调用。
PASSIVE_LEVEL
IRQL最低级别,没有被屏蔽的中断,在这个级别上,线程执行用户模式,可以访问分页内存。
APC_LEVEL
在这个级别上,只有APC级别的中断被屏蔽,可以访问分页内存。当有APC发生时,处理器提升到APC级别,这样,就屏蔽掉其它APC,为了和APC执行一些同步,驱动程序可以手动提升到这个级别。比如,如果提升到这个级别,APC就不能调用。在这个级别,APC被禁止了,导致禁止一些I/O完成APC,所以有一些API不能调用。
DISPATCH_LEVEL
这个级别,DPC 和更低的中断被屏蔽,不能访问分页内存,所有的被访问的内存不能分页。因为只能处理分页内存,所以在这个级别,能够访问的Api大大减少。
DIRQL (Device IRQL)
一般的,更高级的驱动在这个级别上不处理IRQL,但是几乎所有的中断被屏蔽,这实际上是IRQL的一个范围,这是一个决定某个驱动有更高的优先级的方法。
在这个驱动中,我们只会工作在PASSIVE_LEVEL级别,所以不用担心API不能调用的问题。我们需要知道IRQL的概念。
什么是IRP
IRP被称作I/O Request Packet(IO请求包),在驱动堆栈中,它从驱动传给驱动。它是一个数据结构,使得驱动之间能够交互。I/O管理器和其他驱动可能会创建一个IRP传递给你的驱动。IRP包含了被请求的操作信息。
IRP包含一个sub-requests(子请求,或者叫IRP 堆栈位置)的列表,每个在驱动栈中的驱动关于如何中断IRP都有自己的sub-request。
我们下面创建的驱动没有这么复杂,在堆栈中只有它一个。
创建驱动程序
下面是DriverEntry的原型
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath);
DRIVER_OBJECT 是代表驱动的数据结构。 The DriverEntry routine will use it to populate it with other entry points to the driver for handling specific I/O requests.  这个对象还包含了一个代表一个特定驱动的数据结构的指针。一个驱动实际上可以处理多个设备。DRIVER_OBJECT 包含了这个驱动能够处理的所有设备的链表。在这里,我们只是创建一个设备。“Registry Path”是一个指向注册表中该驱动存在位置的字符串指针。驱动能够使用注册表这个位置存储一些信息。
函数的下一部分:首先创建一个设备。设备有不同类型的设备,工作在不同级别,并不是所有的设备都同硬件打交道。每一个特定的工作有一个设备堆栈去做,堆栈中高级别的驱动可能同用户模式下的程序通信,低级别的驱动可能同更低级别的驱动或者硬件通信。有网络驱动,显示驱动,文件驱动,每一个都有自己的驱动堆栈。 堆栈中的驱动为更低级的驱动服务。最高级别的驱动同用户模式通信。 
让我们看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工具来查看这些信息。
注意函数RtlInitUnicodeString,它初始化一个UNICODE_STRING 数据结构,这个数据结构包含三个值。第一:字符串长度,第二:字符串最大长度,第三:字符串指针。在驱动开发中,一般使用这个结构。需要注意的是,在这个字符串结尾并不是以0结尾,因为结构中已经有字符串的长度。这个对于初学者要特别注意的。
驱动有自己的名字,一般命名为:/Device/<somename> ,这个字符串作为参数传递给IoCreateDevice,第二个字符串”/DosDevices/Example”在这个程序中不使用。对于IoCreateDevice,pDriverObject(驱动对象指针), 0(指定设备扩展名的字节数),FILE_DEVICE_UNKNOWN,(设备类型),pDeviceObjec(新创建设备对象的指针)。
现在我们已经成功创建了/Device/Example 设备的驱动。我们需要安装这个设备驱动。下面是IRP主要的调用请求:
      for(uiIndex = 0; uiIndex < IRP_MJ_MAXIMUM_FUNCTION; uiIndex++)
             pDriverObject->MajorFunction[uiIndex] = Example_UnSupportedFunction;
    
        pDriverObject->MajorFunction[IRP_MJ_CLOSE]             = Example_Close;
        pDriverObject->MajorFunction[IRP_MJ_CREATE]            = Example_Create;
        pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL]    = Example_IoControl;
        pDriverObject->MajorFunction[IRP_MJ_READ]              = Example_Read;
        pDriverObject->MajorFunction[IRP_MJ_WRITE]             = USE_WRITE_FUNCTION;
我们列举了Create, Close, IoControl, Read 和 Write函数。当我们和用户模式应用程序通讯的时候,某些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 
需要注意的是,IRP_MJ_CLOSE在进程创建句柄的时候没有被调用,如果你需要进行进程相关的清除操作,你最好处理IRP_MJ_CLEANUP 。
用户模式的程序能够通过调用驱动使用这些功能,你可能奇怪,为什么叫做文件,但实际上又不是文件。这些API不仅仅能够访问文件,还能够和暴露给用户模式的任何驱动通讯。在最好,我们将会写一个和我们的驱动通信的简单的用户模式下的应用程序。关于USE_WRITE_FUNCTION,我将会稍后解释。
下一段代码很简单,是驱动卸载的功能。
pDriverObject->DriverUnload =  Example_Unload;
如果使用这个功能,驱动能够动态卸载;否则不能卸载。
这之后的代码实际上使用了设备对象DEVICE_OBJECT而不是驱动对象DRIVER_OBJECT,这两种数据结构容易搞混。
pDeviceObject->Flags |= IO_TYPE;
        pDeviceObject->Flags &= (~DO_DEVICE_INITIALIZING);
IO_TYPE实际上是我们想要处理的IO类型,在example.h文件中定义。
DO_DEVICE_INITIALIZING告诉IO管理器,该设备初始化的时候,不要给驱动发送任何IO请求。对于在DriverEntry中创建的设备,不需要这些代码,因为IO管理器已经清除了这些标志,但是,如果你在其他函数中使用IoCreateDevice创建的设备,必须手动清除标志。这些标志是被IoCreateDevice函数设置的。
IoCreateSymbolicLink(&usDosDeviceName, &usDriverName);
IoCreateSymbolicLink在对象管理器中创建符号链接。如果要浏览对象,可以下载QuickView工具,或者去 www.sysinternals.com下载winboj。符号链接只是简单的映射Dos 设备名字到NT设备名字。在这个例子中,Example是 Dos设备名字,/Device/Example是NT驱动名字。
不同的驱动有自己的名字。不能设置两个驱动为相同的NT设备名字。例如,你有一个记忆棒,在系统中可能显示为E:,如果你移除该设备,可以将E:映射为网络驱动器,应用程序以相同的方式与之通信,他们并不关心E:是CDRom,还是软盘,还是网络驱动器。驱动需要去解释和处理这些请求,例如进行网络重定向,或者传递给相应的硬件驱动。这是通过符号链接完成的。E:是一个符号链接。网络可能映射E:给/Device/NetworkRedirector,记忆棒可能映射E:给/Device/FujiMemoryStick。
创建可以卸载的程序
下面的这个函数是卸载的函数,为了能够支持动态卸载驱动。
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);
}
卸载函数只是删除符号链接和创建的 /Device/Example设备。
创建IRP_MJ_WRITE
如果你使用WriteFile 或者 ReadFile,你应该知道,写文件的时候只是简单的传递一个缓冲区数据给设备,读文件的时候从设备读出数据,发送给设备的参数是在IRP中。IO管理器组织数据发送IRP包给驱动有三种方法:“Direct I/O”, “Buffered I/O”和  “Neither。
#ifdef __USE_DIRECT__
#define IO_TYPE DO_DIRECT_IO
#define USE_WRITE_FUNCTION  Example_WriteDirectIO
#endif
 
#ifdef __USE_BUFFERED__
#define IO_TYPE DO_BUFFERED_IO
#define USE_WRITE_FUNCTION  Example_WriteBufferedIO
#endif
#ifndef IO_TYPE
#define IO_TYPE 0
#define USE_WRITE_FUNCTION  Example_WriteNeither
#endif
Direct I/O
NTSTATUS 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。在这个例子中,我们需要的唯一的参数是buffer的长度。
bufferedIO 工作的方法是提供给你一个MdlAddress(内存描述列表)。这个描述了用户模式的地址和如何映射到物理地址。然后我们使用Irp-> MdlAddress调用MmGetSystemAddressForMdlSafe去完成这个功能。这个操作会给我们一个可读的内存的虚拟地址。
这么做的原因是因为驱动并不总是在线程中处理用户模式的请求。如果你处理了不同进程的请求,你不能跨进程边界读取用户模式下的内存。如果没有操作系统的支持,两个应用程序不能互相读写。
所以简单的将用户模式进程的物理页映射到系统内存。我们能够使用从用户模式返回的地址。
这种方法一般用在大缓冲中使用,因为它不需要内存拷贝。内存中用户模式的内存被锁定,直到使用direct IO的底层的IRP完成。这种方法一般对于大缓冲更加有用。
Buffered I/O
NTSTATUS 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;
}
正如上边提到的,传递给驱动的数据能够被其它的线程或进程访问。映射内存到非分页内存使得在IRQL 级别的驱动页能够读它。
需要在当前的进程外部访问内存的原因是某些驱动在系统进程内创建了线程。
实际上,使用Buffered I/O分配了非分页内存并执行了copy,这是在执行读写操作前就已经进行了的。当使用小缓冲时,这个方法比较好。在使用Direct IO时,用户模式的分页内存不需要被锁定,如果使用大缓冲,需要分配大块的连续的非分页内存。
Neither Buffered nor Direct
NTSTATUS 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;
}
在这种方法中,驱动直接访问用户模式的地址。IO管理器并不拷贝数据,页不锁定内存,只是简单的给出用户模式的内存地址。这种方法的优点是不需要拷贝数据,不需要分配内存,不需要锁定内存。缺点是你必须处理在被调用线程中处理这个请求,并且能够用户模式的相应进程的地址。在这个进程其他的线程中可能尝试改变或者释放内存。所以需要使用ProbeForRead和ProbeForWrite并且添加异常处理。在任何时间,访问的内存都可能无效。在读写之前,只能简单的尝试。缓冲存储在Irp->UserBuffer中。
#pragma 
你看到的这些指示只是告诉链接器如将代码放在那个段和进行一些设置。
家庭作业
你的家庭作业是写一个读的程序实现对每种类型的IO进行处理。你可以参考写的程序。
动态装载和卸载这个驱动
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表示驱动必须手工启动,当机器重启的时候不会自动启动。这样当系统蓝屏的时候,我们就不需要启动到保护模式下。你可以在其他的程序中同这个服务通信。以上的代码很容易理解。如果你要使用它可以拷贝这个驱动到C:/ example.sys。如果服务创建失败,可能你已经创建并打开了它。
同设备驱动的通信
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中看到。你可以使用DOS驱动名字打开驱动,也可以使用使用/Device/<Nt Device Name> 打开它。一旦你获得设备句柄,你就可以调用WriteFile,  ReadFile, CloseHandle, DeviceIoControl函数。
结论
这篇文章只是写了一个驱动简单例子,并告诉你如何创建,安装,和在用户模式访问驱动。如果你想写驱动,最好熟读驱动的基本概念,特别是这篇文章提到的。

 

抱歉!评论已关闭.