寒江独钓(2):串口的过滤

烈酒焚心 提交于 2020-01-09 17:01:31

一、过滤的概念

  “过滤”(filter)是极其重要的一个概念。过滤是在不影响上层和下层接口的情况下,在Windows系统内核中加入新的层,从而

不需要修改上层的软件或者下层的真实驱动程序,就加入了新的功能。

二、绑定设备的内核Api之一

  通过编程可以生成一个虚拟的设备对象,并“绑定”(Attach)在一个真实的设备上。一旦绑定,则本来操作系统发送给真实设备

的请求,就会首先发送到这个虚拟设备。

  在WDK中,有多个内核API能实现绑定功能。下面是其中一个函数的原型:

NTSTATUSIoAttachDevice(IN PDEVICE_OBJECT SourceDevice,IN PUNICODE_STRING TargetDevice,OUT PDEVICE_OBJECT *AttachedDevice);

  IoAttachDevice参数如下:

    SourceDevice是调用者生成的用来过滤的虚拟设备;

    TargetDevice是要被绑定的目标设备。请注意这里的 TargetDevice并不是一个PDEVICE_OBJECT(DEVICE_OBJECT

  是设备对象的数据结构,以P开头的是其指针),而是一个字 符串(在驱动开发中字符串用UNICODE_STRING来表示)。实际上,

  这个字符串是要被绑定的设备的名字。Windows中许多设备对象是有名字的,但是并不是所有的设备对象都有名字。必须是有名字

  的设备,才能用这个内核API进行绑定。在Windows中,串口设备是有固定名字的。

  这里有一个疑问:假设这个函数绑定一个名字所对应的设备,那么如果这个设备已经被其他的设备绑定了,会怎么样呢?

  如果一个设备被其他设备绑定,它们在一起的一组设备,被称为设备栈(之所以称为栈,是由于和请求的传递方式有关)。实际上,

  IoAttachDevice总是会绑定设备栈上最顶层的那个设备。

    AttachedDevice是一个用来返回的指针的指针。绑定成功后,被绑定的设备指针被返回到这个地址。

前面已经提到了并不是所有的设备都有设备名字,所以依靠IoAttachDevice无法绑定没有名字的设备。另外还有两个API:一个是

IoAttachDeviceToDeviceStack,另一个是IoAttachDeviceToDeviceStackSafe。这两个函数功能一 样,都是根据设备对象的

指针(而不是名字)进行绑定;区别是IoAttachDeviceToDeviceStackSafe更加安全,而且只有在 Windows 2000SP4和Windows XP

以上的系统中才有。一般都使用IoAttachDeviceToDeviceStackSafe,但当试图兼容较低版本的Windows 2000时,

应该使用IoAttachDeviceToDeviceStack。

NTSTATUSIoAttachDeviceToDeviceStackSafe( IN PDEVICE_OBJECT  SourceDevice,  // 过滤设备 IN PDEVICE_OBJECT  TargetDevice,  // 要被绑定的设备栈中的设备 IN OUT PDEVICE_OBJECT  *AttachedToDeviceObject// 返回最终被绑定的设备 );

在Window 2000下应该使用另外一个函数IoAttachDeviceToDeviceStack,这个函数除了缺少最后一个参数之外(实际上放到返回值里了),

其他的和IoAttachDeviceToDeviceStackSafe函数相同。

PDEVICE_OBJECT  IoAttachDeviceToDeviceStack( IN PDEVICE_OBJECT  SourceDevice, IN PDEVICE_OBJECT  TargetDevice);

这个函数返回了最终被绑定的设备指针,这也就导致了它不能返回一个明确的错误码。但是如果为NULL,则表示绑定失败了。

三、绑定设备的内核Api之二

函数IoCreateDevice被用于生成设备

NTSTATUS IoCreateDevice(IN PDRIVER_OBJECT  DriverObject, IN ULONG  DeviceExtensionSize,IN PUNICODE_STRING  DeviceName  OPTIONAL,IN DEVICE_TYPE  DeviceType,IN ULONG  DeviceCharacteristics,IN BOOLEAN  Exclusive,OUT PDEVICE_OBJECT  *DeviceObject);

  这个函数看上去很复杂,但是目前使用时,还无须了解太多。DriverObject是本驱动的驱动对象。这个指针是系统提供,从

DriverEntry中传入,在最后完整的例子中再解释。DeviceExtensionSize是设备扩展,读者请先简单地传入0。 DeviceName是

设备名称。一个规则是:过滤设备一般不需要名称,所以传入NULL即可。DeviceType是设备类型,保持和被绑定的设备类型 一致

即可。DeviceCharacteristics是设备特征,在生成设备对象时笔者总是凭经验直接填0,然后看是否排斥,选择FALSE。

  值得注意的是,在绑定一个设备之前,应该把这个设备对象的多个子域设置成和要绑定的目标对象一致,包括标志和特征。

面是一个示例的函数,这个函数可以生成一个设备,然后绑定在另一个设备上。

NTSTATUSccpAttachDevice(PDRIVER_OBJECT driver, PDEVICE_OBJECT oldobj,PDEVICE_OBJECT *fltobj, PDEVICE_OBJECT *next){NTSTATUS status;PDEVICE_OBJECT topdev = NULL;
 // 生成设备,然后绑定status = IoCreateDevice(driver,0,NULL,oldobj->DeviceType,0,FALSE,fltobj);
 if (status != STATUS_SUCCESS)return status;
 // 拷贝重要标志位if(oldobj->Flags & DO_BUFFERED_IO)(*fltobj)->Flags |= DO_BUFFERED_IO;if(oldobj->Flags & DO_DIRECT_IO)(*fltobj)->Flags |= DO_DIRECT_IO;if(oldobj->Flags & DO_BUFFERED_IO)(*fltobj)->Flags |= DO_BUFFERED_IO;if(oldobj->Characteristics & FILE_DEVICE_SECURE_OPEN)(*fltobj)->Characteristics |= FILE_DEVICE_SECURE_OPEN;(*fltobj)->Flags |=  DO_POWER_PAGABLE;// 将一个设备绑定到另一个设备上topdev = IoAttachDeviceToDeviceStack(*fltobj,oldobj);if (topdev == NULL){// 如果绑定失败了,销毁设备,返回错误。IoDeleteDevice(*fltobj);*fltobj = NULL;status = STATUS_UNSUCCESSFUL;return status;}*next = topdev;
 // 设置这个设备已经启动(*fltobj)->Flags = (*fltobj)->Flags & ~DO_DEVICE_INITIALIZING;return STATUS_SUCCESS;}

四、绑定设备的内核Api之三

函数IoGetDeviceObjectPointer可以获得这个设备对象的指针。这个函数的原型如下:

NTSTATUS IoGetDeviceObjectPointer(IN PUNICODE_STRING  ObjectName,IN ACCESS_MASK  DesiredAccess,OUT PFILE_OBJECT  *FileObject,OUT PDEVICE_OBJECT  *DeviceObject);

  其中的ObjectName就是设备名字。

  DesiredAccess是期望访问的权 限。实际使用时可以不要顾忌那么多,直接填写FILE_ALL_ACCESS即可。

  FileObject是一个返回参数,即获得这个设备对象的同时会得到 的一个文件对象(File Object)。就打开串口设备这件事而言,

  这个文件对象并没有什么用处。但是必须注意:在使用这个函数之后必须把这个文件对象“解除引用”,否则会引起 内存泄漏。

要得到的设备对象就返回在参数DeviceObject中了。示例如下:

#include <ntddk.h>// 因为用到了RtlStringCchPrintfW,所以必须使用头文件ntstrsafe.h// 这里定义NTSTRSAFE_LIB是为了使用静态的ntstrsafe静态库。这样才能// 兼容Windows2000。#define NTSTRSAFE_LIB#include <ntstrsafe.h>……// 打开一个端口设备PDEVICE_OBJECT ccpOpenCom(ULONG id,NTSTATUS *status){// 外面输入的是串口的id,这里会改写成字符串的形式UNICODE_STRING name_str;static WCHAR name[32] = { 0 };PFILE_OBJECT fileobj = NULL;PDEVICE_OBJECT devobj = NULL;
 // 根据id转换成串口的名字memset(name,0,sizeof(WCHAR)*32);RtlStringCchPrintfW(name,32,L"\\Device\\Serial%d",id);RtlInitUnicodeString(&name_str,name);
 // 打开设备对象*status = IoGetDeviceObjectPointer(&name_str, FILE_ALL_ACCESS, &fileobj, &devobj);// 如果打开成功了,记得一定要把文件对象解除引用// 总之,这一句不要忽视if (*status == STATUS_SUCCESS)ObDereferenceObject(fileobj);
 // 返回设备对象return devobj;}

五、绑定所有串口

  下面是一个简单的函数,实现了绑定本机上所有串口的功能。这个函数用到了前面提供的ccpOpenCom和ccpAttachDevice这两个函数。

为了后面的过滤,这里必须把过滤设备和被绑定的设备(后面暂且称为真实设备吧,虽然这些设备未必真实)的设备对象指针都保存起来。

下面的代码用两个 数组保存。数组应该多大?这取决于一台计算机上最多能有多少个串口。读者应该去查阅IBM PC标准,这里笔者随意

地写一个自认为足够大的数字。

// 计算机上最多只有32个串口,这是笔者的假定#define CCP_MAX_COM_ID 32// 保存所有过滤设备指针static PDEVICE_OBJECT s_fltobj[CCP_MAX_COM_ID] = { 0 };// 保存所有真实设备指针static PDEVICE_OBJECT s_nextobj[CCP_MAX_COM_ID] = { 0 };
// 这个函数绑定所有的串口void ccpAttachAllComs(PDRIVER_OBJECT driver){ULONG i;PDEVICE_OBJECT com_ob;NTSTATUS status;for(i = 0;i<CCP_MAX_COM_ID;i++){// 获得object引用com_ob = ccpOpenCom(i,&status);if(com_ob == NULL)continue;// 在这里绑定,并不管绑定是否成功ccpAttachDevice(driver,com_ob,&s_fltobj[i],&s_nextobj[i]);}}

  没有必要关心这个绑定是否成功,就算失败了,看一下s_fltobj即可。这个数组中不为NULL的成员表示已经绑定了,为NULL的成员

则是没有绑定或者 绑定失败的。这个函数需要一个DRIVER_OBJECT的指针,这是本驱动的驱动对象,是系统在DriverEntry中传入的。

六、获得实际数据(请求IRP)

1、请求的区分

(1)每个驱动程序只有一个驱动对象

(2)每个驱动程序可以生成若干个设备对象,这些设备对象从属于一个驱动对象。在一个驱动中可否生成从属于其他驱动的驱动对象的

  设备对象呢?从IoCreateDevice的参数来看,这样做是可以的,但是笔者没有尝试过这样的应用。

(3)若干个设备(它们可以属于不同的驱动)依次绑定形成一个设备栈,总是最顶端的设备先接收到请求

  请注意:IRP是上层设备之间传递请求的常见数据结构,但是绝对不是唯一的数据结构。传递请求还有很多其他的方法,不同的设备也可

能使用不同的结构来传递请求。

  请求可以通过IRP的主功能号进行区分。IRP的主功能号是保存在IRP栈空间中的一个字节,用来标识这个IRP的功能大类。相应的,还有

一个次功能号来标识这个IRP的功能细分小类。读请求的主功能号为IRP_MJ_READ,而写请求的主功能号为IRP_MJ_WRITE。

下面的方法用于从一个IRP指针得到主功能号(这里的变量irp是一个PIRP,也就是IRP的指针)。

// 这里的irpsp称为IRP的栈空间,IoGetCurrentIrpStackLocation获得当前栈空间// 栈空间是非常重要的数据结构PIO_STACK_LOCATION irpsp = IoGetCurrentIrpStackLocation(irp);if(irpsp->MajorFunction == IRP_MJ_WRITE){// 如果是写…}else if(irpsp->MajorFunction == IRP_MJ_READ){// 如果是读…}

2、请求的结局

最终的结局有3种:

(1)请求被允许通过了。过滤不做任何事情,或者简单地获取请求的一些信息。但是请求本身不受干扰,这样系统行为不会有变化。

(2)请求直接被否决了。过滤禁止这个请求通过,这个请求被返回错误了,下层驱动程序根本收不到这个请求。这样系统行为就变了,

  后果是常常看见上层应用程序弹出错误框提示权限错误或者读取文件失败之类信息。

(3)过滤完成了这个请求。有时有这样的需求,比如一个读请求,我们想记录读到了什么。如果读请求还没有完成,那么如何知道到

  底会读到什么呢?只有让这个请求先完成再去记录。过滤完成这个请求时不一定要原封不动地完成,这个请求的参数可以被修改

  (比如把数据都加密一番)。

调用IoSkipCurrentIrpStackLocation跳过当前栈空间;然后调用IoCallDriver把这个请求发送给真实的设备。请注意:因为真实的设备已经被过滤设备绑定,所以首先接收到IRP的是过滤设备的对象。代码如下(irp是过滤到的请求):

// 跳过当前栈空间IoSkipCurrentIrpStackLocation(irp);
// 将请求发送到对应的真实设备。记得我们前面把真实设备都保存在s_nextobj// 数组中。那么这里i应该是多少?这取决于现在的IRP发到了哪个// 过滤设备上。后面讲解分发函数时读者将了解到这一点status = IoCallDriver(s_nextobj[i],irp);

3、写请求的数据

  一个写请求(也就是串口一次发送出的数据)保存在哪里呢?IRP里面一共有3个地方可以描述缓冲 区:

  一个是irp->MDLAddress,一个是irp->UserBuffer,一个是 irp->AssociatedIrp.SystemBuffer。

  不同的IO类别,IRP的缓冲区不同。

  SystemBuffer是一般用于比较 简单且不追求效率情况下的解决方案:把应用层(R3层)中内存空间中的缓冲数据拷贝到内核空间

  UserBuffer则是最追求效率的解决方案。应用层的缓冲区地址直接放在UserBuffer里(数据在用户空间),在内核空间中访问。在

当前进程和发送请求进程 一致的情况下,内核访问应用层的内存空间当然是没错的。但是一旦内核进程已经切换,这个访问就结束了,

访问UserBuffer当然是跳到其他进程空间去 了。因为在Windows中,内核空间是所有进程共用的,而应用层空间则是各个进程隔离的。

  MDLAddress当然一个更简单的解决方案是把应用层的地址空间映射到内核空间,这需要在页表中增加一个映射。当然这不需要编程

者手工去修改页表,通过构造MDL就 能实现这个功能。MDL可以翻译为“内存描述符链”,但是本书按业界传统习惯一律称之为MDL。

IRP中的MDLAddress域是一个MDL的指针,从 这个MDL中可以读出一个内核空间的虚拟地址。这就弥补了UserBuffer的不足,同时比

SystemBuffer的完全拷贝方法要轻量,因为这个内 存实际上还是在老地方,没有拷贝。

  串口写请求到底用的是哪种方式呢?老实点说,笔者并不清楚也没有去调查到底是哪种方式。但是如果用下面的编码方式,无论采用

哪种方式,都可以把数据正确地读出来。

PBYTE buffer = NULL;if(irp->MdlAddress != NULL)buffer = (PBYTE)MmGetSystemAddressForMdlSafe(irp->MdlAddress);elsebuffer = (PBYTE)irp->UserBuffer;if(buffer == NULL)buffer = (PBYTE)irp->AssociatedIrp.SystemBuffer;

  这其中涉及一个函数MmGetSystemAddressForMdlSafe,有兴趣的读者可以在WDK的帮助中查阅一下这个函数的含义。同时也

可以深入了解一下MDL,但是对阅读本书重要性不是很明显。本书的后面涉及从MDL得到系统空间虚拟地址的情况下,都简单地调用

MmGetSystemAddressForMdlSafe。

  此外是缓冲区有多长的问题。对一个写操作而言,长度可以如下获得:

ULONG length = irpsp->Parameters.Write.Length;

3、完整的分发函数

  我们再尝试编写一个分发函数。这个函数处理所有串口的写请求,所有从串口输出的数据都用DbgPrint打印出来。也就是说,

读者打开 DbgView.exe,就可以看到串口的输出数据了。这当然不如一些比较专业的串口嗅探软件好,但是读者可以以这个例子

为基础开发更专业的工具。

NTSTATUS ccpDispatch(PDEVICE_OBJECT device,PIRP irp){PIO_STACK_LOCATION irpsp = IoGetCurrentIrpStackLocation(irp);NTSTATUS status;ULONG i,j;
    // 首先要知道发送给了哪个设备。设备最多一共有CCP_MAX_COM_ID// 个,是前面的代码保存好的,都在s_fltobj中for(i=0;i<CCP_MAX_COM_ID;i++){if(s_fltobj[i] == device){   // 所有电源操作,全部直接放过if(irpsp->MajorFunction == IRP_MJ_POWER){// 直接发送,然后返回说已经被处理了PoStartNextPowerIrp(irp);IoSkipCurrentIrpStackLocation(irp);return PoCallDriver(s_nextobj[i],irp);}// 此外我们只过滤写请求。写请求,获得缓冲区及其长度// 然后打印if(irpsp->MajorFunction == IRP_MJ_WRITE){// 如果是写,先获得长度ULONG len = irpsp->Parameters.Write.Length;// 然后获得缓冲区PUCHAR buf = NULL;if(irp->MdlAddress != NULL)buf = (PUCHAR)MmGetSystemAddressForMdlSafe(irp->MdlAddress,NormalPagePriority);elsebuf = (PUCHAR)irp->UserBuffer;if(buf == NULL)buf = (PUCHAR)irp->AssociatedIrp.SystemBuffer;
                // 打印内容for(j=0;j<len;++j){DbgPrint("comcap: Send Data: %2x\r\n",buf[j]);}}
            // 这些请求直接下发执行即可,我们并不禁止或者改变它IoSkipCurrentIrpStackLocation(irp);return IoCallDriver(s_nextobj[i],irp);}}
    // 如果根本就不在被绑定的设备中,那是有问题的,直接返回参数错误irp->IoStatus.Information = 0;irp->IoStatus.Status = STATUS_INVALID_PARAMETER;IoCompleteRequest(irp,IO_NO_INCREMENT);return STATUS_SUCCESS; }

4、如何动态卸载

  解除绑定。如果要把这个模块做成可以动态卸载的模块,则必须提供一个卸载函数。我们应该在卸载函数中完成解除绑定的功能;

否则,一旦卸载一定会蓝屏。

  这里涉及到3个内核API:一个是IoDetachDevice,负责将绑定的设备解除绑定;另一个是IoDeleteDevice,负责把我们前 面用

IoCreateDevice生成的设备删除掉以回收内存;还有一个是KeDelayExecutionThread,纯粹负责延时。这三个函数的参 数相对简单,

这里就不详细介绍了,需要的读者请查阅WDK的帮助。

  卸载过滤驱动有一个关键的问题:我们要终止这个过滤程序,但是一些IRP可能还在这个过滤程序的处理过程中。要取消这些请求

非常的麻烦,而且不一定 能成功。所以解决方案是等待5秒来保证安全地卸载掉。只能确信这些请求会在5秒内完成,同时等待之前我们

已经解除了绑定,所以这5秒内不会有新请求发送过 来处理。这对于串口而言是没问题的,但是并非所有的设备都如此。所以读者在后面

的章节会看到不同的处理方案。

#define  DELAY_ONE_MICROSECOND  (-10)#define  DELAY_ONE_MILLISECOND (DELAY_ONE_MICROSECOND*1000)#define  DELAY_ONE_SECOND (DELAY_ONE_MILLISECOND*1000)
void ccpUnload(PDRIVER_OBJECT drv){ULONG i;LARGE_INTEGER interval;
 // 首先解除绑定for(i=0;i<CCP_MAX_COM_ID;i++){if(s_nextobj[i] != NULL)IoDetachDevice(s_nextobj[i]);}
 // 睡眠5秒。等待所有IRP处理结束interval.QuadPart = (5*1000 * DELAY_ONE_MILLISECOND);KeDelayExecutionThread(KernelMode,FALSE,&interval);
 // 删除这些设备for(i=0;i<CCP_MAX_COM_ID;i++){if(s_fltobj[i] != NULL)IoDeleteDevice(s_fltobj[i]);}}

5、完整的代码

  这个驱动的完整代码比较简单。前面已经介绍了一些函数,请把这些函数都拷贝下来集中到comcap.c文件里。再建立一个目录,名为

comcap来容纳这个文件。这个文件的内容大致如下:

NTSTATUS DriverEntry(PDRIVER_OBJECT driver, PUNICODE_STRING reg_path){ size_t i; // 所有的分发函数都设置成一样的 for(i=0;i<IRP_MJ_MAXIMUM_FUNCTION;i++) { driver->MajorFunction[i] = ccpDispatch; }

 // 支持动态卸载
 driver->DriverUnload = ccpUnload;

 // 绑定所有的串口
 ccpAttachAllComs(driver);

 // 直接返回成功即可
 return STATUS_SUCCESS;
}

然后编写一个SOURCE文件,内容如下:

TARGETNAME=comcapTARGETPATH=objTARGETTYPE=DRIVERSOURCES =comcap.c

  将这个文件也放在comcap目录下。参考1.1.3节中的方法编译,然后加载执行这个驱动。设法通过串口传输数据,打开DbgView.exe就能

看到输出信息了。

这个例子的代码在随书附带光盘源代码的comcap目录下。

本章的示例代码

本章的例子在源代码目录comcap下,编译结果为comcap,可以动态加载和卸载。编译的方法请参考本书的附录“如何使用本书的源码光盘”。

加载后,如果有数据从串口输出,打开DbgView.exe就会看到输出信息了。

一般的读者可能没有使用串口的打印机,但是可以用如下的方法简单地使用串口,以便让这个程序起作用:

打开“开始”菜单→“所有程序”→“附件”→“通讯”→“超级终端”,然后任意建立一个连接,如图3-1所示。

 
(点击查看大图)图3-1  打开“超级终端”用串口拨号

注意连接时使用的COM1就是第一个串口。这样单击“确定”按钮之后,在上面的文本框中任意输入字符串就会被发送到串口。

此时如果加载了comapp.sys,那么在DbgView.exe中就应该可以看到输出信息如图3-2所示。

 
(点击查看大图)图3-2  用comcap捕获的串口数据

 

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!