代码 锁主页 驱动开发

2016-01-26 20:18:15 zengxianyang 阅读数 14249

前言

         GPIO驱动是Linux驱动开发中最基础、但却是很常用、很重要的驱动。比如你要点亮一个LED灯、键盘扫描、输出高低电平等等。而Linux内核的强大之处在于对最底层的GPIO硬件操作层的基础上封装了一些统一的GPIO操作接口,也就是所谓的GPIO驱动框架。这样开发人员可以调用这些接口去操作设备的IO口,不需要担心硬件平台的不同导致IO口的不同。

         今天,我主要讲的就是如何使用Linux内核封装好的GPIO接口函数在驱动开发中需要操作GPIO时候的使用。

          文章部分内容参考他人博客,特此声明!

概述

       GPIO是与硬件体系密切相关的,linux提供一个模型来让驱动统一处理GPIO,即各个板卡都有实现自己的gpio_chip控制模块:request, free, input,output, get,set,irq...然后把控制模块注册到内核中,这时会改变全局gpio数组:gpio_desc[]. 当用户请求gpio时,就会到这个数组中找到,并调用这个GPIO对应的gpio_chip的处理函数。gpio实现为一组可用的 gpio_chip, 由驱动传入对应 gpio的全局序号去 request, dataout ,datain, free. 这时会调用gpio_chip中具体的实现。

        GPIO是一组可控件的脚,由多个寄存器同时控制。通过设置对应的寄存器可以达到设置GPIO口对应状态与功能。数据状态,输入输出方向,清零,中断(哪个边沿触发), 一般是一组(bank)一组的。寄存器读写函数: __raw_writel() __raw_writeb() __raw_readl() __raw_readb()。

1. Linux内核中GPIO模型的结构

1.1 struct gpio_desc

//表示一个gpio口,含对应的gpio_chip.
//对于每一个gpio,都有一个gpio描述符,这个描述符包含了这个gpio所属的控制器即chip和一些标志,label等。

struct gpio_desc {
    struct gpio_chip    *chip;
    unsigned long       flags;
/* flag symbols are bit numbers */
#define FLAG_REQUESTED  0
#define FLAG_IS_OUT 1
#define FLAG_EXPORT 2   /* protected by sysfs_lock */
#define FLAG_SYSFS  3   /* exported via /sys/class/gpio/control */
#define FLAG_TRIG_FALL  4   /* trigger on falling edge */
#define FLAG_TRIG_RISE  5   /* trigger on rising edge */
#define FLAG_ACTIVE_LOW 6   /* value has active low */
#define FLAG_OPEN_DRAIN 7   /* Gpio is open drain type */
#define FLAG_OPEN_SOURCE 8  /* Gpio is open source type */
#define FLAG_USED_AS_IRQ 9  /* GPIO is connected to an IRQ */

#define ID_SHIFT    16  /* add new flags before this one */

#define GPIO_FLAGS_MASK     ((1 << ID_SHIFT) - 1)
#define GPIO_TRIGGER_MASK   (BIT(FLAG_TRIG_FALL) | BIT(FLAG_TRIG_RISE))

#ifdef CONFIG_DEBUG_FS
    const char      *label;
#endif
};
//采用了一个具有ARCH_NR_GPIOS大小的gpio描述符数组。这个描述符数组便代表了系统所有的gpio。
static struct gpio_desc gpio_desc[ARCH_NR_GPIOS];

1.2  struct davinci_gpio_controller

//一组GPIO控制器结构,例如GPIO0和GPIO1是一组(共32个GPIO口),共用一组寄存器,所以GPIO0和GPIO1荷载一起用chips[0]来控制
//假如有144个GPIO,分为4组(GPIO0~GPIO8),每组有2个banks(即GPIO0和GPIO1为1组),每组最多可以有32个GPIO,每组的控制寄存器空间有10个

struct davinci_gpio_controller {

    struct gpio_chip    chip;//每组对应的gpio_chip

    int            irq_base;//每组对应的中断

    spinlock_t        lock;//自旋锁

    void __iomem        *regs;//每组的寄存器地址

    void __iomem        *set_data;//设置数据寄存器地址

    void __iomem        *clr_data;//清除数据寄存器地址

    void __iomem        *in_data;//输入数据寄存器地址

}; 


1.3 struct gpio_chip

//每一个davinci_gpio_controller结构都对应于一个gpio_chip结构,gpio_chip既可看成是davinci_gpio_controller结构的补充
//表示一个gpio controller.通过这个结构抽象化所有的GPIO源,而让板上其它的模块可以用相同的接口调用使用这些GPIO。

struct gpio_chip {

    const char    *label;

    struct device    *dev;

    struct module    *owner; 

    int    (*request)(struct gpio_chip *chip,unsigned offset);//请求gpio

    void    *free)(struct gpio_chip *chip,unsigned offset);//释放gpio

    int    (*get_direction)(struct gpio_chip *chip,unsigned offset);

    int    (*direction_input)(struct gpio_chip *chip,unsigned offset);//配置gpio为输入,返回当前gpio状态

    int    (*get)(struct gpio_chip *chip,unsigned offset);//获取gpio的状态

    int    (*direction_output)(struct gpio_chip *chip,unsigned offset, int value);//配置gpio为输出,并设置为value

    int    (*set_debounce)(struct gpio_chip *chip,unsigned offset, unsigned debounce);//设置消抖动时间,尤其是gpio按键时有用

    void    (*set)(struct gpio_chip *chip,unsigned offset, int value);//设置gpio为value值

    int    (*to_irq)(struct gpio_chip *chip,unsigned offset);//把gpio号转换为中断号

    void    (*dbg_show)(struct seq_file *s,struct gpio_chip *chip);

    int    base;// 这个gpio控制器的gpio开始编号

    u16    ngpio;//这个gpio控制器说控制的gpio数

    const char    *const *names;

    unsigned    can_sleep:1;

    unsigned    exported:1;

 

#if defined(CONFIG_OF_GPIO)

    struct device_node *of_node;

    int of_gpio_n_cells;

    int (*of_xlate)(struct gpio_chip *gc,const struct of_phandle_args *gpiospec, u32 *flags);

#endif

#ifdef CONFIG_PINCTRL

    struct list_head pin_ranges;

#endif

};


1.4 struct davinci_gpio_regs

//GPIO寄存器结构

struct davinci_gpio_regs {

    u32 dir; // gpio方向设置寄存器 

    u32 out_data; // gpio设置为输出时,表示输出状态(0或1) 

    u32 set_data; // gpio设置为输出时,用于输出高电平 

    u32 clr_data; // gpio设置为输出时,用于输出低电平 

    u32 in_data; // gpio设置为输入时,用于读取输入值 

    u32 set_rising; // gpio中断上升沿触发设置 

    u32 clr_rising; // gpio中断上升沿触发清除 

    u32 set_falling; // gpio中断下降沿触发设置 

    u32 clr_falling; // gpio中断下降沿触发清除 

    u32 intstat; // gpio中断状态位,由硬件设置,可读取,写1时清除。 

};


1.5 struct gpio

struct gpio {

    unsigned gpio;//gpio号

    unsigned long flags;//gpio标志

    const char *label;//gpio名

};

2.  驱动开发中GPIO初始化操作

在实际的驱动开发中,根据板级资源和CPU手册,GPIO初始化一般需要以下三个步骤:

       1.设置IO口的复用模式,如果某个IO当作GPIO使用,那么就需要根据CPU手册去配置iomux(IO复用寄存器)为GPIO模式;

        2.设置IO口的输入输出方向,根据实际开发需求,将相应的GPIO配置为相应的输入输出方向;

        3.GPIO初始化赋值(输出高低电平)、拉高拉低操作;

2.1 GPIO申请

#########################################

#description:申请一个GPIO资源

#unsigned gpio:要申请的GPIO管脚号,为一个正整数

# const char *label:为申请的GPIO管脚取个名字                 

#########################################           

int gpio_request(unsigned gpio, const char *label);

int gpio_request_one(unsigned gpio, unsigned long flags, const char *label);

2.2 GPIO输入输出设置

#########################################

#description:设置某个GPIO的输入输出方向

#unsigned gpio:要设置的GPIO管脚号,为一个正整数

# int value:设置的值                

#########################################           

int gpio_direction_input(unsigned gpio);
 int gpio_direction_output(unsigned gpio, int value);

2.3 获取GPIO管脚的值和设置GPIO管脚的值

#########################################

#description:获取、设置某个GPIO的值

#unsigned gpio:要获取、设置的GPIO管脚号

# int value:设置的值                

#########################################

int gpio_get_value(unsigned gpio);
void gpio_set_value(unsigned gpio, int value);

2.4 GPIO当作中断口使用

#########################################

#description:设置某个GPIO为中断口

#unsigned gpio:要设置中断的GPIO管脚号            

#########################################

int gpio_to_irq(unsigned gpio);

返回的值即中断编号可以传给request_irq()和free_irq(),内核通过调用该函数将gpio端口转换为中断,在用户空间也有类似方法。

3. GPIO驱动实例

3.1 以下GPIO驱动例子为矩阵键盘中对GPIO的操作

//设置某个管脚为输入

int set_key_input(unsigned int gpio)
{
    char name[32];
    sprintf(name, "GPIO%d", gpio);
    
    if(gpio_request(gpio,NULL) != 0)
    {
        printk("gpio request error!\n");
        return -1;
    }
    gpio_direction_input(gpio);
    gpio_free(gpio);
    return 0;
}

//设置某个管脚为输出

int set_key_output(unsigned int gpio,int value)
{
    char name[32];
    sprintf(name, "GPIO%d", gpio);

    if(gpio_request(gpio,NULL) != 0)
    {
        printk("gpio request error!\n");
        return -1;
    }
    gpio_direction_output(gpio,value);
    gpio_free(gpio);
    return 0;
}


//获取某个GPIO管脚的值

int get_key_value(unsigned int gpio)
{
    int value= -1;
    if (gpio_request(gpio, NULL) != 0)
    {
        printk("get_key_value err\n");        
        return -1;
    }
    value = gpio_get_value(gpio);
    gpio_free(gpio);

    return value;
}


//设置某个GPIO输出为低电平

int set_key_low(unsigned int gpio)
{

	if (gpio_request(gpio, NULL) != 0) {
		//printk("set_key_low request err\n");
		return -1;
	}
	gpio_direction_output(gpio, 0);
	__gpio_set_value(gpio, 0);
	gpio_free(gpio);

	return 0;
}

//拉高、拉低某个GPIO

//GPIO的拉高拉低操作内核没有提供通用的接口函数,这个需要驱动开发人员根据CPU手册的寄存器配置去封装拉高拉低函数,以下给出一个伪代码的例子:

//假设拉高GPIO1_IO01这个IO:

<span style="font-size:12px;">#define SET_PULL_UP 0x01
#define SET_PULL_DOWN 0x00
#define REG_GPIO_BASE 0x8e000000
#define GPIO1_IO01_OFFSET 0x400

int set_pull_up(unsigned int reg_base,unsigned int offset,int up)
{
     unsigned int gpio_base;
     unsigned int gpio;

     gpio_base = ioreamap(reg_base,SIZE_4K);//调用ioreamap映射GPIO空间到内存,映射大小根据实际需求而定
     gpio = gpio_base + offset; 
     gpio |= up;		//将某个GPIO拉高,根据具体的寄存器操作而定 
     __raw_writel(gpio,gpio_base + offset);
     return 0;  
}

set_pull_up(REG_GPIO_BASE,GPIO1_IO01_OFFSET,SET_PULL_UP);</span>


//拉低某个GPIO

#define SET_PULL_UP 0x01
#define SET_PULL_DOWN 0x00
#define REG_GPIO_BASE 0x8e000000
#define GPIO1_IO01_OFFSET 0x400

int set_pull_down(unsigned int reg_base,unsigned int offset,int down)
{
     unsigned int gpio_base;
     unsigned int gpio;

     gpio_base = ioreamap(reg_base,SIZE_4K);//调用ioreamap映射GPIO空间到内存,映射大小根据实际需求而定
     gpio = gpio_base + offset; 
     gpio &= down;		//将某个GPIO拉低,根据具体的寄存器操作而定 
     __raw_writel(gpio,gpio_base + offset);
     return 0;  
}

set_pull_up(REG_GPIO_BASE,GPIO1_IO01_OFFSET,SET_PULL_DOWN);

 

3.2 总结

         3.1中展示了基本的GPIO操作函数的编写,在实际的驱动开发中,比如对某个连接到CPU的GPIO管脚的外设模块需要初始化的时候,一般都是调用GPIO接口函数进行输入输出、拉高、拉低设置,读者可以参考3.1的例子根据实际开发需求进行修改。

2016-11-20 18:19:56 viewsky11 阅读数 6349

基础性总结

1, linux驱动一般分为3大类:

* 字符设备
* 块设备
* 网络设备

2, 开发环境构建:

* 交叉工具链构建
* NFS和tftp服务器安装

3, 驱动开发中设计到的硬件:

* 数字电路知识
* ARM硬件知识
* 熟练使用万用表和示波器
* 看懂芯片手册和原理图

4, linux内核源代码目录结构:

这里写图片描述
* arch/: arch子目录包括了所有和体系结构相关的核心代码。它的每一个子目录都代表一种支持的体系结构,例如i386就是关于intel cpu及与之相兼容体系结构的子目录。
* block/: 部分块设备驱动程序;
* crypto: 常用加密和散列算法(如AES、SHA等),还有一些压缩和CRC校验算法;
* documentation/: 文档目录,没有内核代码,只是一套有用的文档;
* drivers/: 放置系统所有的设备驱动程序;每种驱动程序又各占用一个子目录:如,/block 下为块设备驱动程序,比如ide(ide.c)。如果你希望查看所有可能包含文件系统的设备是如何初始化的,你可以看 drivers/block/genhd.c中的device_setup()。
* fs/: 所有的文件系统代码和各种类型的文件操作代码,它的每一个子目录支持一个文件系统, 例如fat和ext2;
* include/: include子目录包括编译核心所需要的大部分头文件。与平台无关的头文件在 include/linux子目录下,与 intel cpu相关的头文件在include/asm-i386子目录下,而include/scsi目录则是有关scsi设备的头文件目录;
* init/: 这个目录包含核心的初始化代码(注:不是系统的引导代码),包含两个文件main.c和Version.c,这是研究核心如何工作的好的起点之一;
* ipc/: 这个目录包含核心的进程间通讯的代码;
* kernel/: 主要的核心代码,此目录下的文件实现了大多数linux系统的内核函数,其中最重要的文件当属sched.c;同样,和体系结构相关的代码在arch/i386/kernel下;
* lib/: 放置核心的库代码;
* mm/:这个目录包括所有独立于 cpu 体系结构的内存管理代码,如页式存储管理内存的分配和释放等;而和体系结构相关的内存管理代码则位于arch/i386/mm/下;
* net/: 核心与网络相关的代码;
* scripts/: 描述文件,脚本,用于对核心的配置;
* security: 主要是一个SELinux的模块;
* sound: 常用音频设备的驱动程序等;
* usr: 实现了用于打包和压缩的cpio;

5, 内核的五个子系统:

* 进程调试(SCHED)
* 内存管理(MM)
* 虚拟文件系统(VFS)
* 网络接口(NET)
* 进程间通信(IPC)

6, linux内核的编译:

* 配置内核:make menuconfig,使用后会生成一个.confiig配置文件,记录哪些部分被编译入内核,哪些部分被编译成内核模块。
* 编译内核和模块的方法:make zImage
Make modules
* 执行完上述命令后,在arch/arm/boot/目录下得到压缩的内核映像zImage,在内核各对应目录得到选中的内核模块。

7, 在linux内核中增加程序

(直接编译进内核)要完成以下3项工作:
* 将编写的源代码拷入linux内核源代码相应目录
* 在目录的Kconifg文件中增加关于新源代码对应项目的编译配置选项
* 在目录的Makefile文件中增加对新源代码的编译条目

8, linux下C编程的特点:

内核下的Documentation/CodingStyle描述了linux内核对编码风格的要求。具体要求不一一列举,以下是要注意的:
* 代码中空格的应用
* 当前函数名:
GNU C预定义了两个标志符保存当前函数的名字,__FUNCTION__保存函数在源码中的名字,__PRETTY_FUNCTION__保存带语言特色的名字。
由于C99已经支持__func__宏,在linux编程中应该不要使用__FUNCTION__,应该使用__func__
*内建函数:不属于库函数的其他内建函数的命名通常以__builtin开始。

9,内核模块

内核模块主要由如下几部分组成:
(1) 模块加载函数
(2) 模块卸载函数
(3) 模块许可证声明(常用的有Dual BSD/GPL,GPL,等)
(4) 模块参数(可选)它指的是模块被加载的时候可以传递给它的值,它本身对应模块内部的全局变量。例如P88页中讲到的一个带模块参数的例子:
insmod book.ko book_name=”GOOD BOOK” num=5000
(5) 模块导出符号(可选)导出的符号可以被其他模块使用,在使用之前只需声明一下。
(6) 模块作者等声明信息(可选)
以下是一个典型的内核模块:

/*
 * A kernel module: book
 * This example is to introduce module params
 *
 * The initial developer of the original code is Baohua Song
 * <author@linuxdriver.cn>. All Rights Reserved.
 */

#include <linux/init.h>
#include <linux/module.h>

static char *book_name = “dissecting Linux Device Driver”;
static int num = 4000;

static int book_init(void)
{
        printk(KERN_INFO “ book name:%s\n”,book_name);
        printk(KERN_INFO “ book num:%d\n”,num);
        return 0;
}

static void book_exit(void)
{
        printk(KERN_INFO “ Book module exit\n “);
}

module_init(book_init);
module_exit(book_exit);
module_param(num, int, S_IRUGO);
module_param(book_name, charp, S_IRUGO);
MODULE_AUTHOR(“Song Baohua, author@linuxdriver.cn”);
MODULE_LICENSE(“Dual BSD/GPL”);
MODULE_DESCRIPTION(“A simple Module for testing module params”);
MODULE_VERSION(“V1.0”);

注意:标有__init的函数在链接的时候都放在.init.text段,在.initcall.init中还保存了一份函数指针,初始化的时候内核会通过这些函数指针调用__init函数,在初始化完成后释放init区段。
模块编译常用模版:

KVERS = $(shell uname -r)
# Kernel modules
obj-m += book.o
# Specify flags for the module compilation.
#EXTRA_CFLAGS=-g -O0
build: kernel_modules
kernel_modules:
        make -C /lib/modules/$(KVERS)/build M=$(CURDIR) modules

clean:
        make -C /lib/modules/$(KVERS)/build M=$(CURDIR) clean

注意要指明内核版本,并且内核版本要匹配——编译模块使用的内核版本要和模块欲加载到的那个内核版本要一致。
模块中经常使用的命令:

insmod,lsmod,rmmod

系统调用:

int open(const char *pathname,int flags,mode_t mode);

flag表示文件打开标志,如:O_RDONLY
mode表示文件访问权限,如:S_IRUSR(用户可读),S_IRWXG(组可以读、写、执行)

10,linux文件系统与设备驱动的关系

这里写图片描述
应用程序和VFS之间的接口是系统调用,而VFS与磁盘文件系统以及普通设备之间的接口是file_operation结构体成员函数。
两个重要的函数:
(1)struct file结构体定义在/linux/include/linux/fs.h(Linux 2.6.11内核)中定义。文件结构体代表一个打开的文件,系统中每个打开的文件在内核空间都有一个关联的struct file。它由内核在打开文件时创建,并传递给在文件上进行操作的任何函数。在文件的所有实例都关闭后,内核释放这个数据结构。在内核创建和驱动源码中,struct file的指针通常被命名为file或filp。
在驱动开发中,文件读/写模式mode、标志f_flags都是设备驱动关心的内容,而私有数据指针private_data在驱动中被广泛使用,大多被指向设备驱动自定义的用于描述设备的结构体。驱动程序中常用如下类似的代码来检测用户打开文件的读写方式:

if (file->f_mode & FMODE_WRITE) //用户要求可写
{
}
if (file->f_mode & FMODE_READ) //用户要求可读
{
}

下面的代码可用于判断以阻塞还是非阻塞方式打开设备文件:

if (file->f_flags & O_NONBLOCK) //非阻塞
pr_debug("open:non-blocking\n");
else //阻塞
pr_debug("open:blocking\n");

(2)struct inode结构体定义在linux/fs.h中

11,devfs、sysfs、udev三者的关系:

(1)devfs
linux下有专门的文件系统用来对设备进行管理,devfs和sysfs就是其中两种。在2.4内核4一直使用的是devfs,devfs挂载于/dev目录下,提供了一种类似于文件的方法来管理位于/dev目录下的所有设备,我们知道/dev目录下的每一个文件都对应的是一个设备,至于当前该设备存在与否先且不论,而且这些特殊文件是位于根文件系统上的,在制作文件系统的时候我们就已经建立了这些设备文件,因此通过操作这些特殊文件,可以实现与内核进行交互。但是devfs文件系统有一些缺点,例如:不确定的设备映射,有时一个设备映射的设备文件可能不同,例如我的U盘可能对应sda有可能对应sdb;没有足够的主/次设备号,当设备过多的时候,显然这会成为一个问题;/dev目录下文件太多而且不能表示当前系统上的实际设备;命名不够灵活,不能任意指定等等。
(2)sysfs
正因为上述这些问题的存在,在linux2.6内核以后,引入了一个新的文件系统sysfs,它挂载于/sys目录下,跟devfs一样它也是一个虚拟文件系统,也是用来对系统的设备进行管理的,它把实际连接到系统上的设备和总线组织成一个分级的文件,用户空间的程序同样可以利用这些信息以实现和内核的交互,该文件系统是当前系统上实际设备树的一个直观反应,它是通过kobject子系统来建立这个信息的,当一个kobject被创建的时候,对应的文件和目录也就被创建了,位于/sys下的相关目录下,既然每个设备在sysfs中都有唯一对应的目录,那么也就可以被用户空间读写了。用户空间的工具udev就是利用了sysfs提供的信息来实现所有devfs的功能的,但不同的是udev运行在用户空间中,而devfs却运行在内核空间,而且udev不存在devfs那些先天的缺陷。
(3)udev
udev是一种工具,它能够根据系统中的硬件设备的状况动态更新设备文件,包括设备文件的创建,删除等。设备文件通常放在/dev目录下,使用udev后,在/dev下面只包含系统中真实存在的设备。它于硬件平台无关的,位于用户空间,需要内核sysfs和tmpfs的支持,sysfs为udev提供设备入口和uevent通道,tmpfs为udev设备文件提供存放空间。

12,linux设备模型:

这里写图片描述

在linux内核中,分别使用bus_type,device_driver,device来描述总线、驱动和设备,这3个结构体定义于include/linux/device.h头文件中。驱动和设备正是通过bus_type中的match()函数来配对的。

13, 重要结构体解析

(1)cdev结构体
在linux2.6内核中,使用cdev结构体描述一个字符设备,定义如下:

struct cdev{
    struct kobject kobj;//内嵌的kobject对象
    struct module *owner;//所属模块
    struct file_operations *ops;//文件操作结构体
    struct list_head list;
    dev_t dev;//设备号,长度为32位,其中高12为主设备号,低20位为此设备号
    unsigned int count;
};

(2)file_operations结构体
结构体file_operations在头文件linux/fs.h中定义,用来存储驱动内核模块提供的对设备进行各种操作的函数的指针。这些函数实际会在应用程序进行linux的open(),write(),read(),close()等系统调用时最终被调用。该结构体的每个域都对应着驱动内核模块用来处理某个被请求的事务的函数地址。源代码(2.6.28.7)如下:

struct file_operations{
    struct module*owner;
    loff_t (*llseek)(struct file*,loff_t,int);
    ssize_t (*read)(struct file*,char__user*,size_t,loff_t*);
    ssize_t (*write)(struct file*,constchar__user*,size_t,loff_t*);
    ssize_t (*aio_read)(struct kiocb*,cons tstruct iovec*,unsigned long,loff_t);
    ssize_t (*aio_write)(struct kiocb*,const struct iovec*,unsigned long,loff_t);
    int (*readdir)(struct file*,void*,filldir_t);
    unsigned int (*poll)(struct file*,struct poll_table_struct*);
    int (*ioctl)(struc inode*,struct file*,unsigned int,unsigned long);
    long (*unlocked_ioctl)(struct file*,unsigned int,unsigned long);
    long (*compat_ioctl)(struct file*,unsigned int,unsigned long);
    int (*mmap)(struct file*,struct vm_area_struct*);
    int (*open)(struct inode*,struct file*);
    int (*flush)(struct file*,fl_owner_t id);
    int (*release)(struct inode*,struct file*);
    int (*fsync)(struct file*,struct dentry*,int datasync);
    int (*aio_fsync)(struct kiocb*,int datasync);
    in (*fasync)(int,struct file*,int);
    int (*lock)(struct file*,int,struct file_lock*);
    ssize_t (*sendpage)(struct file*,struct page*,int,size_t,loff_t*,int);
    unsigned long (*get_unmapped_area)(struct file*,unsigned long,unsigned long,unsigned long,unsigned long);
    in t(*check_flags)(int);
    int (*dir_notify)(structfile*filp,unsignedlongarg);
    int (*flock)(structfile*,int,structfile_lock*);
    ssize_t (*splice_write)(struct pipe_inode_info*,struct file*,loff_t*,size_t,unsig ned int);
    ssize_t (*splice_read)(struct file*,loff_t*,struct pipe_inode_info*,size_t,unsigned int);
    int(*setlease)(struct file*,long,struct file_lock**);
}; 

解析:

struct module*owner;
/*第一个file_operations成员根本不是一个操作;它是一个指向拥有这个结构的模块的指针.
这个成员用来在它的操作还在被使用时阻止模块被卸载.几乎所有时间中,它被简单初始化为
THIS_MODULE,一个在中定义的宏.这个宏比较复杂,在进行简单学习操作的时候,一般初始化为THIS_MODULE。*/
loff_t (*llseek)(struct file*filp,loff_tp,int orig);
/*(指针参数filp为进行读取信息的目标文件结构体指针;参数p为文件定位的目标偏移量;参数orig为对文件定位
的起始地址,这个值可以为文件开头(SEEK_SET,0,当前位置(SEEK_CUR,1),文件末尾(SEEK_END,2))
llseek方法用作改变文件中的当前读/写位置,并且新位置作为(正的)返回值.
loff_t参数是一个"longoffset",并且就算在32位平台上也至少64位宽.错误由一个负返回值指示.
如果这个函数指针是NULL,seek调用会以潜在地无法预知的方式修改file结构中的位置计数器(在"file结构"一节中描述).*/
ssize_t (*read)(struct file *filp,char__user *buffer,size_t size,loff_t *p);
/*(指针参数filp为进行读取信息的目标文件,指针参数buffer为对应放置信息的缓冲区(即用户空间内存地址),
参数size为要读取的信息长度,参数p为读的位置相对于文件开头的偏移,在读取信息后,这个指针一般都会移动,移动的值为要读取信息的长度值)
这个函数用来从设备中获取数据.在这个位置的一个空指针导致read系统调用以-EINVAL("Invalidargument")失败.
一个非负返回值代表了成功读取的字节数(返回值是一个"signedsize"类型,常常是目标平台本地的整数类型).*/
ssize_t (*aio_read)(struct kiocb*,char__user *buffer,size_t size,loff_t p);
/*可以看出,这个函数的第一、三个参数和本结构体中的read()函数的第一、三个参数是不同的,
异步读写的第三个参数直接传递值,而同步读写的第三个参数传递的是指针,因为AIO从来不需要改变文件的位置。
异步读写的第一个参数为指向kiocb结构体的指针,而同步读写的第一参数为指向file结构体的指针,每一个I/O请求都对应一个kiocb结构体);
初始化一个异步读--可能在函数返回前不结束的读操作.如果这个方法是NULL,所有的操作会由read代替进行(同步地).
(有关linux异步I/O,可以参考有关的资料,《linux设备驱动开发详解》中给出了详细的解答)*/
ssize_t (*write)(struct file*filp,const char__user *buffer,size_t count,loff_t *ppos);
/*(参数filp为目标文件结构体指针,buffer为要写入文件的信息缓冲区,count为要写入信息的长度,
ppos为当前的偏移位置,这个值通常是用来判断写文件是否越界)
发送数据给设备.如果NULL,-EINVAL返回给调用write系统调用的程序.如果非负,返回值代表成功写的字节数.
(注:这个操作和上面的对文件进行读的操作均为阻塞操作)*/
ssize_t (*aio_write)(struct kiocb*,const char__user *buffer,size_t count,loff_t *ppos);
/*初始化设备上的一个异步写.参数类型同aio_read()函数;*/
int (*readdir)(struct file*filp,void*,filldir_t);
/*对于设备文件这个成员应当为NULL;它用来读取目录,并且仅对文件系统有用.*/
unsigned int(*poll)(struct file*,struct poll_table_struct*);
/*(这是一个设备驱动中的轮询函数,第一个参数为file结构指针,第二个为轮询表指针)
这个函数返回设备资源的可获取状态,即POLLIN,POLLOUT,POLLPRI,POLLERR,POLLNVAL等宏的位“或”结果。
每个宏都表明设备的一种状态,如:POLLIN(定义为0x0001)意味着设备可以无阻塞的读,POLLOUT(定义为0x0004)意味着设备可以无阻塞的写。
(poll方法是3个系统调用的后端:poll,epoll,和select,都用作查询对一个或多个文件描述符的读或写是否会阻塞.
poll方法应当返回一个位掩码指示是否非阻塞的读或写是可能的,并且,可能地,提供给内核信息用来使调用进程睡眠直到I/O变为可能.
如果一个驱动的poll方法为NULL,设备假定为不阻塞地可读可写.
(这里通常将设备看作一个文件进行相关的操作,而轮询操作的取值直接关系到设备的响应情况,可以是阻塞操作结果,同时也可以是非阻塞操作结果)*/
int (*ioctl)(struct inode*inode,struct file*filp,unsigned int cmd,unsigned long arg);
/*(inode和filp指针是对应应用程序传递的文件描述符fd的值,和传递给open方法的相同参数.
cmd参数从用户那里不改变地传下来,并且可选的参数arg参数以一个unsignedlong的形式传递,不管它是否由用户给定为一个整数或一个指针.
如果调用程序不传递第3个参数,被驱动操作收到的arg值是无定义的.
因为类型检查在这个额外参数上被关闭,编译器不能警告你如果一个无效的参数被传递给ioctl,并且任何关联的错误将难以查找.)
ioctl系统调用提供了发出设备特定命令的方法(例如格式化软盘的一个磁道,这不是读也不是写).另外,几个ioctl命令被内核识别而不必引用fops表.
如果设备不提供ioctl方法,对于任何未事先定义的请求(-ENOTTY,"设备无这样的ioctl"),系统调用返回一个错误.*/
int(*mmap)(struct file*,struct vm_area_struct*);
/*mmap用来请求将设备内存映射到进程的地址空间.如果这个方法是NULL,mmap系统调用返回-ENODEV.
(如果想对这个函数有个彻底的了解,那么请看有关“进程地址空间”介绍的书籍)*/
int(*open)(struct inode *inode,struct file *filp);
/*(inode为文件节点,这个节点只有一个,无论用户打开多少个文件,都只是对应着一个inode结构;
但是filp就不同,只要打开一个文件,就对应着一个file结构体,file结构体通常用来追踪文件在运行时的状态信息)
尽管这常常是对设备文件进行的第一个操作,不要求驱动声明一个对应的方法.如果这个项是NULL,设备打开一直成功,但是你的驱动不会得到通知.
与open()函数对应的是release()函数。*/
int(*flush)(struct file*);
/*flush操作在进程关闭它的设备文件描述符的拷贝时调用;它应当执行(并且等待)设备的任何未完成的操作.
这个必须不要和用户查询请求的fsync操作混淆了.当前,flush在很少驱动中使用;
SCSI磁带驱动使用它,例如,为确保所有写的数据在设备关闭前写到磁带上.如果flush为NULL,内核简单地忽略用户应用程序的请求.*/
int(*release)(struct inode*,struct file*);
/*release()函数当最后一个打开设备的用户进程执行close()系统调用的时候,内核将调用驱动程序release()函数:
void release(struct inode inode,struct file *file),release函数的主要任务是清理未结束的输入输出操作,释放资源,用户自定义排他标志的复位等。
在文件结构被释放时引用这个操作.如同open,release可以为NULL.*/
int (*synch)(struct file*,struct dentry*,intdatasync);
//刷新待处理的数据,允许进程把所有的脏缓冲区刷新到磁盘。
int(*aio_fsync)(struct kiocb*,int);
/*这是fsync方法的异步版本.所谓的fsync方法是一个系统调用函数。系统调用fsync
把文件所指定的文件的所有脏缓冲区写到磁盘中(如果需要,还包括存有索引节点的缓冲区)。
相应的服务例程获得文件对象的地址,并随后调用fsync方法。通常这个方法以调用函数__writeback_single_inode()结束,
这个函数把与被选中的索引节点相关的脏页和索引节点本身都写回磁盘。*/
int(*fasync)(int,struct file*,int);
//这个函数是系统支持异步通知的设备驱动,下面是这个函数的模板:
static int***_fasync(intfd,structfile*filp,intmode)
{
struct***_dev*dev=filp->private_data;
returnfasync_helper(fd,filp,mode,&dev->async_queue);//第四个参数为fasync_struct结构体指针的指针。
//这个函数是用来处理FASYNC标志的函数。(FASYNC:表示兼容BSD的fcntl同步操作)当这个标志改变时,驱动程序中的fasync()函数将得到执行。
}
/*此操作用来通知设备它的FASYNC标志的改变.异步通知是一个高级的主题,在第6章中描述.
这个成员可以是NULL如果驱动不支持异步通知.*/
int (*lock)(struct file*,int,struct file_lock*);
//lock方法用来实现文件加锁;加锁对常规文件是必不可少的特性,但是设备驱动几乎从不实现它.
ssize_t (*readv)(structfile*,const struct iovec*,unsigned long,loff_t*);
ssize_t (*writev)(struct file*,const struct iovec*,unsigned long,loff_t*);
/*这些方法实现发散/汇聚读和写操作.应用程序偶尔需要做一个包含多个内存区的单个读或写操作;
这些系统调用允许它们这样做而不必对数据进行额外拷贝.如果这些函数指针为NULL,read和write方法被调用(可能多于一次).*/
ssize_t (*sendfile)(struct file*,loff_t*,size_t,read_actor_t,void*);
/*这个方法实现sendfile系统调用的读,使用最少的拷贝从一个文件描述符搬移数据到另一个.
例如,它被一个需要发送文件内容到一个网络连接的web服务器使用.设备驱动常常使sendfile为NULL.*/
ssize_t (*sendpage)(structfile*,structpage*,int,size_t,loff_t*,int);
/*sendpage是sendfile的另一半;它由内核调用来发送数据,一次一页,到对应的文件.设备驱动实际上不实现sendpage.*/
unsigned long(*get_unmapped_area)(struct file*,unsigned long,unsignedlong,unsigned long,unsigned long);
/*这个方法的目的是在进程的地址空间找一个合适的位置来映射在底层设备上的内存段中.
这个任务通常由内存管理代码进行;这个方法存在为了使驱动能强制特殊设备可能有的任何的对齐请求.大部分驱动可以置这个方法为NULL.[10]*/
int (*check_flags)(int)
//这个方法允许模块检查传递给fnctl(F_SETFL...)调用的标志.
int (*dir_notify)(struct file*,unsigned long);
//这个方法在应用程序使用fcntl来请求目录改变通知时调用.只对文件系统有用;驱动不需要实现dir_notify.

14, 字符设备驱动程序设计基础

主设备号和次设备号(二者一起为设备号):
一个字符设备或块设备都有一个主设备号和一个次设备号。主设备号用来标识与设备文件相连的驱动程序,用来反映设备类型。次设备号被驱动程序用来辨别操作的是哪个设备,用来区分同类型的设备。
linux内核中,设备号用dev_t来描述,2.6.28中定义如下:

typedef u_long dev_t;

在32位机中是4个字节,高12位表示主设备号,低12位表示次设备号。

可以使用下列宏从dev_t中获得主次设备号:也可以使用下列宏通过主次设备号生成dev_t:

MAJOR(dev_tdev);
MKDEV(intmajor,intminor);
MINOR(dev_tdev);

分配设备号(两种方法):
(1)静态申请:

int register_chrdev_region(dev_t from,unsigned count,const char *name);

(2)动态分配:

int alloc_chrdev_region(dev_t *dev,unsigned baseminor,unsigned count,const char *name);

注销设备号:

void unregister_chrdev_region(dev_t from,unsigned count);

创建设备文件:
利用cat/proc/devices查看申请到的设备名,设备号。
(1)使用mknod手工创建:mknod filename type major minor
(2)自动创建;
利用udev(mdev)来实现设备文件的自动创建,首先应保证支持udev(mdev),由busybox配置。在驱动初始化代码里调用class_create为该设备创建一个class,再为每个设备调用device_create创建对应的设备。

15, 字符设备驱动程序设计

设备注册:
字符设备的注册分为三个步骤:
(1)分配

cdev:struct cdev *cdev_alloc(void);

(2)初始化

cdev:void cdev_init(struct cdev *cdev,const struct file_operations *fops);

(3)添加

cdev:int cdev_add(struct cdev *p,dev_t dev,unsigned count)

设备操作的实现:
file_operations函数集的实现。

struct file_operations xxx_ops={
.owner=THIS_MODULE,
.llseek=xxx_llseek,
.read=xxx_read,
.write=xxx_write,
.ioctl=xxx_ioctl,
.open=xxx_open,
.release=xxx_release,
…
};

特别注意:驱动程序应用程序的数据交换:
驱动程序和应用程序的数据交换是非常重要的。file_operations中的read()和write()函数,就是用来在驱动程序和应用程序间交换数据的。通过数据交换,驱动程序和应用程序可以彼此了解对方的情况。但是驱动程序和应用程序属于不同的地址空间。驱动程序不能直接访问应用程序的地址空间;同样应用程序也不能直接访问驱动程序的地址空间,否则会破坏彼此空间中的数据,从而造成系统崩溃,或者数据损坏。安全的方法是使用内核提供的专用函数,完成数据在应用程序空间和驱动程序空间的交换。这些函数对用户程序传过来的指针进行了严格的检查和必要的转换,从而保证用户程序与驱动程序交换数据的安全性。这些函数有:

unsigned long copy_to_user(void__user *to,const void *from,unsigned long n);
unsigned long copy_from_user(void *to,constvoid __user *from,unsigned long n);
put_user(local,user);
get_user(local,user);

设备注销:

void cdev_del(struct cdev *p);

16,ioctl函数说明

ioctl是设备驱动程序中对设备的I/O通道进行管理的函数。所谓对I/O通道进行管理,就是对设备的一些特性进行控制,例如串口的传输波特率、马达的转速等等。它的调用个数如下:

int ioctl(int fd,ind cmd,…);

其中fd就是用户程序打开设备时使用open函数返回的文件标示符,cmd就是用户程序对设备的控制命令,后面的省略号是一些补充参数,有或没有是和cmd的意义相关的。
ioctl函数是文件结构中的一个属性分量,就是说如果你的驱动程序提供了对ioctl的支持,用户就可以在用户程序中使用ioctl函数控制设备的I/O通道。

命令的组织是有一些讲究的,因为我们一定要做到命令和设备是一一对应的,这样才不会将正确的命令发给错误的设备,或者是把错误的命令发给正确的设备,或者是把错误的命令发给错误的设备。
所以在Linux核心中是这样定义一个命令码的:

设备类型 序列号 方向 数据尺寸
8bit 8bit 2bit 13~14bit

这样一来,一个命令就变成了一个整数形式的命令码。但是命令码非常的不直观,所以LinuxKernel中提供了一些宏,这些宏可根据便于理解的字符串生成命令码,或者是从命令码得到一些用户可以理解的字符串以标明这个命令对应的设备类型、设备序列号、数据传送方向和数据传输尺寸。
点击(此处)折叠或打开

/*used to create numbers*/
#define _IO(type,nr)        _IOC(_IOC_NONE,(type),(nr),0)
#define _IOR(type,nr,size)    _IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOW(type,nr,size)    _IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOWR(type,nr,size)    _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
#defin e_IOR_BAD(type,nr,size)    _IOC(_IOC_READ,(type),(nr),sizeof(size))
#define _IOW_BAD(type,nr,size)    _IOC(_IOC_WRITE,(type),(nr),sizeof(size))
#define _IOWR_BAD(type,nr,size)_IOC(_IOC_READ|_IOC_WRITE,(type),(nr),sizeof(size))

#define _IOC(dir,type,nr,size)\
    (((dir)<<_IOC_DIRSHIFT)|\
    ((type)<<_IOC_TYPESHIFT)|\
    ((nr)<<_IOC_NRSHIFT)|\
    ((size)<<_IOC_SIZESHIFT))

17,文件私有数据

大多数linux的驱动工程师都将文件私有数据private_data指向设备结构体,read等个函数通过调用private_data来访问设备结构体。这样做的目的是为了区分子设备,如果一个驱动有两个子设备(次设备号分别为0和1),那么使用private_data就很方便。
这里有一个函数要提出来:

container_of(ptr,type,member)//通过结构体成员的指针找到对应结构体的的指针

其定义如下:

/**
*container_of-castamemberofastructureouttothecontainingstructure
*@ptr:    thepointertothemember.
*@type:    thetypeofthecontainerstructthisisembeddedin.
*@member:    thenameofthememberwithinthestruct.
*
*/
#define container_of(ptr,type,member)({            \
    const typeof(((type*)0)->member)*__mptr=(ptr);    \
    (type*)((char*)__mptr-offsetof(type,member));})

18,字符设备驱动的结构

可以概括如下图:
这里写图片描述
字符设备是3大类设备(字符设备、块设备、网络设备)中较简单的一类设备,其驱动程序中完成的主要工作是初始化、添加和删除cdev结构体,申请和释放设备号,以及填充file_operation结构体中操作函数,并实现file_operations结构体中的read()write()ioctl()等重要函数。如图所示为cdev结构体、file_operations和用户空间调用驱动的关系。

19, 自旋锁与信号量

为了避免并发,防止竞争。内核提供了一组同步方法来提供对共享数据的保护。我们的重点不是介绍这些方法的详细用法,而是强调为什么使用这些方法和它们之间的差别。
Linux使用的同步机制可以说从2.0到2.6以来不断发展完善。从最初的原子操作,到后来的信号量,从大内核锁到今天的自旋锁。这些同步机制的发展伴随Linux从单处理器到对称多处理器的过度;伴随着从非抢占内核到抢占内核的过度。锁机制越来越有效,也越来越复杂。目前来说内核中原子操作多用来做计数使用,其它情况最常用的是两种锁以及它们的变种:一个是自旋锁,另一个是信号量。

自旋锁
自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,不需要自旋锁)。
自旋锁最多只能被一个内核任务持有,如果一个内核任务试图请求一个已被争用(已经被持有)的自旋锁,那么这个任务就会一直进行忙循环——旋转——等待锁重新可用。要是锁未被争用,请求它的内核任务便能立刻得到它并且继续进行。自旋锁可以在任何时刻防止多于一个的内核任务同时进入临界区,因此这种锁可有效地避免多处理器上并发运行的内核任务竞争共享资源。

自旋锁的基本形式如下:
spin_lock(&mr_lock);
//临界区
spin_unlock(&mr_lock);

信号量
Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个已被持有的信号量时,信号量会将其推入等待队列,然后让其睡眠。这时处理器获得自由去执行其它代码。当持有信号量的进程将信号量释放后,在等待队列中的一个任务将被唤醒,从而便可以获得这个信号量。
信号量的睡眠特性,使得信号量适用于锁会被长时间持有的情况;只能在进程上下文中使用,因为中断上下文中是不能被调度的;另外当代码持有信号量时,不可以再持有自旋锁。
信号量基本使用形式为:

static DECLARE_MUTEX(mr_sem);//声明互斥信号量
if(down_interruptible(&mr_sem))
//可被中断的睡眠,当信号来到,睡眠的任务被唤醒
//临界区
up(&mr_sem);

信号量和自旋锁区别
从严格意义上说,信号量和自旋锁属于不同层次的互斥手段,前者的实现有赖于后者,在信号量本身的实现上,为了保证信号量结构存取的原子性,在多CPU中需要自旋锁来互斥。
信号量是进程级的。用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺进程。鉴于进程上下文切换的开销也很大,因此,只有当进程占用资源时间比较长时,用信号量才是较好的选择。
当所要保护的临界区访问时间比较短时,用自旋锁是非常方便的,因为它节省上下文切换的时间,但是CPU得不到自旋锁会在那里空转直到执行单元锁为止,所以要求锁不能在临界区里长时间停留,否则会降低系统的效率
由此,可以总结出自旋锁和信号量选用的3个原则:
1:当锁不能获取到时,使用信号量的开销就是进程上线文切换的时间Tc,使用自旋锁的开销就是等待自旋锁(由临界区执行的时间决定)Ts,如果Ts比较小时,应使用自旋锁比较好,如果Ts比较大,应使用信号量。
2:信号量所保护的临界区可包含可能引起阻塞的代码,而自旋锁绝对要避免用来保护包含这样的代码的临界区,因为阻塞意味着要进行进程间的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
3:信号量存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在信号量和自旋锁之间只能选择自旋锁,当然,如果一定要是要那个信号量,则只能通过down_trylock()方式进行,不能获得就立即返回以避免阻塞
自旋锁VS信号量
需求建议的加锁方法
低开销加锁优先使用自旋锁
短期锁定优先使用自旋锁
长期加锁优先使用信号量
中断上下文中加锁使用自旋锁
持有锁是需要睡眠、调度使用信号量

20, 阻塞与非阻塞I/O

一个驱动当它无法立刻满足请求应当如何响应?一个对 read 的调用可能当没有数据时到来,而以后会期待更多的数据;或者一个进程可能试图写,但是你的设备没有准备好接受数据,因为你的输出缓冲满了。调用进程往往不关心这种问题,程序员只希望调用 read 或 write 并且使调用返回,在必要的工作已完成后,你的驱动应当(缺省地)阻塞进程,使它进入睡眠直到请求可继续。
阻塞操作是指在执行设备操作时若不能获得资源则挂起进程,直到满足可操作的条件后再进行操作。
一个典型的能同时处理阻塞与非阻塞的globalfifo读函数如下:

/*globalfifo读函数*/
static ssize_t globalfifo_read(struct file *filp, char __user *buf, size_t count,
    loff_t *ppos)
{
    int ret;
    struct globalfifo_dev *dev = filp->private_data;
    DECLARE_WAITQUEUE(wait, current);

    down(&dev->sem); /* 获得信号量 */
    add_wait_queue(&dev->r_wait, &wait); /* 进入读等待队列头 */

    /* 等待FIFO非空 */
    if (dev->current_len == 0) {
        if (filp->f_flags &O_NONBLOCK) {
            ret = - EAGAIN;
            goto out;
        }
        __set_current_state(TASK_INTERRUPTIBLE); /* 改变进程状态为睡眠 */
        up(&dev->sem);

        schedule(); /* 调度其他进程执行 */
        if (signal_pending(current)) {
            /* 如果是因为信号唤醒 */
            ret = - ERESTARTSYS;
            goto out2;
        }

        down(&dev->sem);
    }

    /* 拷贝到用户空间 */
    if (count > dev->current_len)
        count = dev->current_len;

    if (copy_to_user(buf, dev->mem, count)) {
        ret = - EFAULT;
        goto out;
    } else {
        memcpy(dev->mem, dev->mem + count, dev->current_len - count); /* fifo数据前移 */
        dev->current_len -= count; /* 有效数据长度减少 */
        printk(KERN_INFO "read %d bytes(s),current_len:%d\n", count, dev->current_len);

        wake_up_interruptible(&dev->w_wait); /* 唤醒写等待队列 */

        ret = count;
    }
out:
    up(&dev->sem); /* 释放信号量 */
out2:
    remove_wait_queue(&dev->w_wait, &wait); /* 从附属的等待队列头移除 */
    set_current_state(TASK_RUNNING);
    return ret;
}

21, poll方法

使用非阻塞I/O的应用程序通常会使用select()poll()系统调用查询是否可对设备进行无阻塞的访问。select()poll()系统调用最终会引发设备驱动中的poll()函数被执行。
这个方法由下列的原型:

unsigned int (*poll) (struct file *filp, poll_table *wait);

这个驱动方法被调用, 无论何时用户空间程序进行一个 poll, select, 或者 epoll 系统调用, 涉及一个和驱动相关的文件描述符. 这个设备方法负责这 2 步:

  1. 对可能引起设备文件状态变化的等待队列,调用poll_wait()函数,将对应的等待队列头添加到poll_table.
  2. 返回一个位掩码, 描述可能不必阻塞就立刻进行的操作.

poll_table结构, 给 poll 方法的第 2 个参数, 在内核中用来实现 poll, select, 和 epoll 调用; 它在 中声明, 这个文件必须被驱动源码包含. 驱动编写者不必要知道所有它内容并且必须作为一个不透明的对象使用它; 它被传递给驱动方法以便驱动可用每个能唤醒进程的等待队列来加载它, 并且可改变 poll 操作状态. 驱动增加一个等待队列到poll_table结构通过调用函数 poll_wait:

void poll_wait (struct file *, wait_queue_head_t *, poll_table *);

poll 方法的第 2 个任务是返回位掩码, 它描述哪个操作可马上被实现; 这也是直接的. 例如, 如果设备有数据可用, 一个读可能不必睡眠而完成; poll 方法应当指示这个时间状态. 几个标志(通过 定义)用来指示可能的操作:
POLLIN:如果设备可被不阻塞地读, 这个位必须设置.
POLLRDNORM:这个位必须设置, 如果”正常”数据可用来读. 一个可读的设备返回( POLLIN|POLLRDNORM ).
POLLOUT:这个位在返回值中设置, 如果设备可被写入而不阻塞.
……
poll的一个典型模板如下:

static unsigned int globalfifo_poll(struct file *filp, poll_table *wait)
{
    unsigned int mask = 0;
    struct globalfifo_dev *dev = filp->private_data; /*获得设备结构体指针*/

    down(&dev->sem);

    poll_wait(filp, &dev->r_wait, wait);
    poll_wait(filp, &dev->w_wait, wait);
    /*fifo非空*/
    if (dev->current_len != 0) {
        mask |= POLLIN | POLLRDNORM; /*标示数据可获得*/
    }
    /*fifo非满*/
    if (dev->current_len != GLOBALFIFO_SIZE) {
        mask |= POLLOUT | POLLWRNORM; /*标示数据可写入*/
    }

    up(&dev->sem);
    return mask;
}

应用程序如何去使用这个poll呢?一般用select()来实现,其原型为:

int select(int numfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

其中,readfds, writefds, exceptfds,分别是被select()监视的读、写和异常处理的文件描述符集合。numfds是需要检查的号码最高的文件描述符加1。
以下是一个具体的例子:

/*======================================================================
    A test program in userspace 
    This example is to introduce the ways to use "select"
     and driver poll 

    The initial developer of the original code is Baohua Song
    <author@linuxdriver.cn>. All Rights Reserved.
======================================================================*/
#include <sys/types.h>
#include <sys/stat.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/time.h>

#define FIFO_CLEAR 0x1
#define BUFFER_LEN 20
main()
{
  int fd, num;
  char rd_ch[BUFFER_LEN];
  fd_set rfds,wfds;

  /*以非阻塞方式打开/dev/globalmem设备文件*/
  fd = open("/dev/globalfifo", O_RDONLY | O_NONBLOCK);
  if (fd != - 1)
  {
    /*FIFO清0*/
    if (ioctl(fd, FIFO_CLEAR, 0) < 0)
    {
      printf("ioctl command failed\n");
    }
    while (1)
    {
      FD_ZERO(&rfds);// 清除一个文件描述符集rfds
      FD_ZERO(&wfds); 
      FD_SET(fd, &rfds);// 将一个文件描述符fd,加入到文件描述符集rfds中
      FD_SET(fd, &wfds);

      select(fd + 1, &rfds, &wfds, NULL, NULL);
      /*数据可获得*/
      if (FD_ISSET(fd, &rfds)) //判断文件描述符fd是否被置位
      {
          printf("Poll monitor:can be read\n");
      }
      /*数据可写入*/
      if (FD_ISSET(fd, &wfds))
      {
          printf("Poll monitor:can be written\n");
      } 
    }
  }
  else
  {
    printf("Device open failure\n");
  }
}

其中:
FD_ZERO(fd_set *set); //清除一个文件描述符集set
FD_SET(int fd, fd_set *set); //将一个文件描述符fd,加入到文件描述符集set中
FD_CLEAR(int fd, fd_set *set); //将一个文件描述符fd,从文件描述符集set中清除
FD_ISSET(int fd, fd_set *set); //判断文件描述符fd是否被置位。

22,并发与竞态介绍

Linux设备驱动中必须解决一个问题是多个进程对共享资源的并发访问,并发的访问会导致竞态,在当今的Linux内核中,支持SMP与内核抢占的环境下,更是充满了并发与竞态。幸运的是,Linux 提供了多钟解决竞态问题的方式,这些方式适合不同的应用场景。例如:中断屏蔽、原子操作、自旋锁、信号量等等并发控制机制。
并发与竞态的概念
并发是指多个执行单元同时、并发被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态。
临界区概念是为解决竞态条件问题而产生的,一个临界区是一个不允许多路访问的受保护的代码,这段代码可以操纵共享数据或共享服务。临界区操纵坚持互斥锁原则(当一个线程处于临界区中,其他所有线程都不能进入临界区)。然而,临界区中需要解决的一个问题是死锁。

23, 中断屏蔽

在单CPU 范围内避免竞态的一种简单而省事的方法是进入临界区之前屏蔽系统的中断。CPU 一般都具有屏蔽中断和打开中断的功能,这个功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,有效的防止了某些竞态条件的发送,总之,中断屏蔽将使得中断与进程之间的并发不再发生。
中断屏蔽的使用方法:

local_irq_disable() /屏蔽本地CPU 中断/ 
….. 
critical section /临界区受保护的数据/ 
….. 
local_irq_enable() /打开本地CPU 中断/ 

由于Linux 的异步I/O、进程调度等很多重要操作都依赖于中断,中断对内核的运行非常重要,在屏蔽中断期间的所有中断都无法得到处理,因此长时间屏蔽中断是非常危险的,有可能造成数据的丢失,甚至系统崩溃的后果。这就要求在屏蔽了中断后,当前的内核执行路径要尽快地执行完临界区代码。
local_irq_disable()不同的是,local_irq_save(flags)除了进行禁止中断的操作外,还保存当前CPU 的中断状态位信息;与local_irq_enable()不同的是,local_irq_restore(flags) 除了打开中断的操作外,还恢复了CPU 被打断前的中断状态位信息。

24, 原子操作

原子操作指的是在执行过程中不会被别的代码路径所中断的操作,Linux 内核提供了两类原子操作——位原子操作和整型原子操作。它们的共同点是在任何情况下都是原子的,内核代码可以安全地调用它们而不被打断。然而,位和整型变量原子操作都依赖于底层CPU 的原子操作来实现,因此这些函数的实现都与 CPU 架构密切相关。
1 整型原子操作
1)、设置原子变量的值

void atomic_set(atomic v,int i); /设置原子变量的值为 i */ 
atomic_t v = ATOMIC_INIT(0); /定义原子变量 v 并初始化为 0 / 

2)、获取原子变量的值

int atomic_read(atomic_t v) /返回原子变量 v 的当前值*/ 

3)、原子变量加/减

void atomic_add(int i,atomic_t v) /原子变量增加 i */ 
void atomic_sub(int i,atomic_t v) /原子变量减少 i */ 

4)、原子变量自增/自减

void atomic_inc(atomic_t v) /原子变量增加 1 */ 
void atomic_dec(atomic_t v) /原子变量减少 1 */ 

5)、操作并测试

int atomic_inc_and_test(atomic_t *v); 
int atomic_dec_and_test(atomic_t *v); 
int atomic_sub_and_test(int i, atomic_t *v); 

上述操作对原子变量执行自增、自减和减操作后测试其是否为 0 ,若为 0 返回true,否则返回false。注意:没有atomic_add_and_test(int i, atomic_t *v)
6)、操作并返回

int atomic_add_return(int i, atomic_t *v); 
int atomic_sub_return(int i, atomic_t *v); 
int atomic_inc_return(atomic_t *v); 
int atomic_dec_return(atomic_t *v); 

上述操作对原子变量进行加/减和自增/自减操作,并返回新的值。
2 位原子操作
1)、设置位

void set_bit(nr,void addr);/设置addr 指向的数据项的第 nr 位为1 */ 

2)、清除位

void clear_bit(nr,void addr)/设置addr 指向的数据项的第 nr 位为0 */ 

3)、取反位

void change_bit(nr,void addr); /对addr 指向的数据项的第 nr 位取反操作*/ 

4)、测试位

test_bit(nr,void addr);/返回addr 指向的数据项的第 nr位*/ 

5)、测试并操作位

int test_and_set_bit(nr, void *addr); 
int test_and_clear_bit(nr,void *addr); 
int test_amd_change_bit(nr,void *addr); 

25, 自旋锁

自旋锁(spin lock)是一种典型的对临界资源进行互斥访问的手段。为了获得一个自旋锁,在某CPU 上运行的代码需先执行一个原子操作,该操作测试并设置某个内存变量,由于它是原子操作,所以在该操作完成之前其他执行单元不能访问这个内存变量。如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行;如果测试结果表明锁仍被占用,则程序将在一个小的循环里面重复这个“测试并设置” 操作,即进行所谓的“自旋”。
理解自旋锁最简单的方法是把它当做一个变量看待,该变量把一个临界区标记为“我在这运行了,你们都稍等一会”,或者标记为“我当前不在运行,可以被使用”。
Linux中与自旋锁相关操作有:
1)、定义自旋锁

spinlock_t my_lock; 

2)、初始化自旋锁

spinlock_t my_lock = SPIN_LOCK_UNLOCKED; /静态初始化自旋锁/ 
void spin_lock_init(spinlock_t lock); /动态初始化自旋锁*/ 

3)、获取自旋锁

/若获得锁立刻返回真,否则自旋在那里直到该锁保持者释放/ 
void spin_lock(spinlock_t *lock); 
/若获得锁立刻返回真,否则立刻返回假,并不会自旋等待/ 
void spin_trylock(spinlock_t *lock) 

4)、释放自旋锁

void spin_unlock(spinlock_t *lock) 

自旋锁的一般用法:

spinlock_t lock; /定义一个自旋锁/ 
spin_lock_init(&lock); /动态初始化一个自旋锁/ 
…… 
spin_lock(&lock); /获取自旋锁,保护临界区/ 
……./临界区/ 
spin_unlock(&lock); /解锁/ 

自旋锁主要针对SMP 或单CPU 但内核可抢占的情况,对于单CPU 且内核不支持抢占的系统,自旋锁退化为空操作。尽管用了自旋锁可以保证临界区不受别的CPU和本地CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部(BH)的影响,为了防止这种影响,就需要用到自旋锁的衍生。
获取自旋锁的衍生函数:

void spin_lock_irq(spinlock_t lock); /获取自旋锁之前禁止中断*/ 
void spin_lock_irqsave(spinlock_t lock, unsigned long flags);/获取自旋锁之前禁止中断,并且将先前的中断状态保存在flags 中*/ 
void spin_lock_bh(spinlock_t lock); /在获取锁之前禁止软中断,但不禁止硬件中断*/ 

释放自旋锁的衍生函数:

void spin_unlock_irq(spinlock_t *lock) 
void spin_unlock_irqrestore(spinlock_t *lock,unsigned long flags); 
void spin_unlock_bh(spinlock_t *lock); 

解锁的时候注意要一一对应去解锁。
自旋锁注意点:
(1)自旋锁实际上是忙等待,因此,只有占用锁的时间极短的情况下,使用自旋锁才是合理的。
(2)自旋锁可能导致系统死锁。
(3)自旋锁锁定期间不能调用可能引起调度的函数。如:copy_from_user()、copy_to_user()、kmalloc()、msleep()等函数。
(4)拥有自旋锁的代码是不能休眠的。

26, 读写自旋锁

它允许多个读进程并发执行,但是只允许一个写进程执行临界区代码,而且读写也是不能同时进行的。
1)、定义和初始化读写自旋锁

rwlock_t my_rwlock = RW_LOCK_UNLOCKED; /* 静态初始化 */ 
rwlock_t my_rwlock; 
rwlock_init(&my_rwlock); /* 动态初始化 */ 

2)、读锁定

void read_lock(rwlock_t *lock); 
void read_lock_irqsave(rwlock_t *lock, unsigned long flags); 
void read_lock_irq(rwlock_t *lock); 
void read_lock_bh(rwlock_t *lock); 

3)、读解锁

void read_unlock(rwlock_t *lock); 
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags); 
void read_unlock_irq(rwlock_t *lock); 
void read_unlock_bh(rwlock_t *lock); 

在对共享资源进行读取之前,应该先调用读锁定函数,完成之后调用读解锁函数。
4)、写锁定

void write_lock(rwlock_t *lock); 
void write_lock_irqsave(rwlock_t *lock, unsigned long flags); 
void write_lock_irq(rwlock_t *lock); 
void write_lock_bh(rwlock_t *lock); 
void write_trylock(rwlock_t *lock); 

5)、写解锁

void write_unlock(rwlock_t *lock); 
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags); 
void write_unlock_irq(rwlock_t *lock); 
void write_unlock_bh(rwlock_t *lock); 

在对共享资源进行写之前,应该先调用写锁定函数,完成之后应调用写解锁函数。

读写自旋锁的一般用法:

rwlock_t lock; /定义一个读写自旋锁 rwlock/ 
rwlock_init(&lock); /初始化/ 
read_lock(&lock); /读取前先获取锁/ 
…../临界区资源/ 
read_unlock(&lock); /读完后解锁/ 
write_lock_irqsave(&lock, flags); /写前先获取锁/ 
…../临界区资源/ 
write_unlock_irqrestore(&lock,flags); /写完后解锁/ 

27, 顺序锁(sequence lock)

顺序锁是对读写锁的一种优化,读执行单元在写执行单元对被顺序锁保护的资源进行写操作时仍然可以继续读,而不必等地写执行单元完成写操作,写执行单元也不必等待所有读执行单元完成读操作才进去写操作。但是,写执行单元与写执行单元依然是互斥的。并且,在读执行单元读操作期间,写执行单元已经发生了写操作,那么读执行单元必须进行重读操作,以便确保读取的数据是完整的,这种锁对于读写同时进行概率比较小的情况,性能是非常好的。
顺序锁有个限制,它必须要求被保护的共享资源不包含有指针,因为写执行单元可能使得指针失效,但读执行单元如果正要访问该指针,就会导致oops。
1)、初始化顺序锁

seqlock_t lock1 = SEQLOCK_UNLOCKED; /静态初始化/ 
seqlock lock2; /动态初始化/ 
seqlock_init(&lock2) 

2)、获取顺序锁

void write_seqlock(seqlock_t *s1); 
void write_seqlock_irqsave(seqlock_t *lock, unsigned long flags) 
void write_seqlock_irq(seqlock_t *lock); 
void write_seqlock_bh(seqlock_t *lock); 
int write_tryseqlock(seqlock_t *s1); 

3)、释放顺序锁

void write_sequnlock(seqlock_t *s1); 
void write_sequnlock_irqsave(seqlock_t *lock, unsigned long flags) 
void write_sequnlock_irq(seqlock_t *lock); 
void write_sequnlock_bh(seqlock_t *lock); 

写执行单元使用顺序锁的模式如下:

write_seqlock(&seqlock_a); 
/写操作代码/ 
…….. 
write_sequnlock(&seqlock_a); 

4)、读开始

unsigned read_seqbegin(const seqlock_t *s1); 
unsigned read_seqbegin_irqsave(seqlock_t *lock, unsigned long flags); 

5)、重读

int read_seqretry(const seqlock_t *s1, unsigned iv); 
int read_seqretry_irqrestore(seqlock_t *lock,unsigned int seq,unsigned long flags); 

读执行单元使用顺序锁的模式如下:

unsigned int seq; 
do{ 
seq = read_seqbegin(&seqlock_a); 
/读操作代码/ 
……. 
}while (read_seqretry(&seqlock_a, seq)); 

28, 信号量

信号量的使用
信号量(semaphore)是用于保护临界区的一种最常用的办法,它的使用方法与自旋锁是类似的,但是,与自旋锁不同的是,当获取不到信号量的时候,进程不会自旋而是进入睡眠的等待状态。
1)、定义信号量

struct semaphore sem; 

2)、初始化信号量

void sema_init(struct semaphore sem, int val); /初始化信号量的值为 val */ 

更常用的是下面这二个宏:

#define init_MUTEX(sem) sema_init(sem, 1) 
#define init_MUTEX_LOCKED(sem) sem_init(sem, 0) 

然而,下面这两个宏是定义并初始化信号量的“快捷方式”

DECLARE_MUTEX(name) /一个称为name信号量变量被初始化为 1 / 
DECLARE_MUTEX_LOCKED(name) /一个称为name信号量变量被初始化为 0 / 

3)、获得信号量

/该函数用于获取信号量,若获取不成功则进入不可中断的睡眠状态/ 
void down(struct semaphore *sem); 
/该函数用于获取信号量,若获取不成功则进入可中断的睡眠状态/ 
void down_interruptible(struct semaphore *sem); 
/该函数用于获取信号量,若获取不成功立刻返回 -EBUSY/ 
int down_trylock(struct sempahore *sem); 

4)、释放信号量

void up(struct semaphore sem); /释放信号量 sem ,并唤醒等待者*/ 

信号量的一般用法:

DECLARE_MUTEX(mount_sem); /定义一个信号量mount_sem,并初始化为 1 / 
down(&mount_sem); /* 获取信号量,保护临界区*/ 
….. 
critical section /临界区/ 
….. 
up(&mount_sem); /释放信号量/ 

29, 读写信号量

读写信号量可能引起进程阻塞,但是它允许多个读执行单元同时访问共享资源,但最多只能有一个写执行单元。
1)、定义和初始化读写信号量

struct rw_semaphore my_rws; /定义读写信号量/ 
void init_rwsem(struct rw_semaphore sem); /初始化读写信号量*/ 

2)、读信号量获取

void down_read(struct rw_semaphore *sem); 
int down_read_trylock(struct rw_semaphore *sem); 

3)、读信号量释放

void up_read(struct rw_semaphore *sem); 

4)、写信号量获取

void down_write(struct rw_semaphore *sem); 
int down_write_trylock(struct rw_semaphore *sem); 

5)、写信号量释放

void up_write(struct rw_semaphore *sem); 

30, completion

完成量(completion)用于一个执行单元等待另外一个执行单元执行完某事。
1)、定义完成量

struct completion my_completion; 

2)、初始化完成量

init_completion(&my_completion); 

3)、定义并初始化的“快捷方式”

DECLARE_COMPLETION(my_completion) 

4)、等待完成量

void wait_for_completion(struct completion c); /等待一个 completion 被唤醒*/ 

5)、唤醒完成量

void complete(struct completion c); /只唤醒一个等待执行单元*/ 
void complete(struct completion c); /唤醒全部等待执行单元*/ 

31, 自旋锁VS信号量

信号量是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发送进程上下文切换,当前进程进入睡眠状态,CPU 将运行其他进程。鉴于开销比较大,只有当进程资源时间较长时,选用信号量才是比较合适的选择。然而,当所要保护的临界区访问时间比较短时,用自旋锁是比较方便的。
总结:
解决并发与竞态的方法有(按本文顺序):
(1)中断屏蔽
(2)原子操作(包括位和整型原子)
(3)自旋锁
(4)读写自旋锁
(5)顺序锁(读写自旋锁的进化)
(6)信号量
(7)读写信号量
(8)完成量
其中,中断屏蔽很少单独被使用,原子操作只能针对整数进行,因此自旋锁和信号量应用最为广泛。自旋锁会导致死循环,锁定期间内不允许阻塞,因此要求锁定的临界区小;信号量允许临界区阻塞,可以适用于临界区大的情况。读写自旋锁和读写信号量分别是放宽了条件的自旋锁 信号量,它们允许多个执行单元对共享资源的并发读。

2017-05-29 20:29:57 pdfebook 阅读数 1363

本书介绍了Linux设备驱动开发理论、框架与实例,详细说明了自旋锁、信号量、完成量、中断顶/底半部、定时器、内存和I/O映射以及异步通知、阻塞I/O、非阻塞I/O等Linux设备驱动理论,以及字符设备、块设备、tty设备、I2c设备、LCD设备、音频设备、USB设备、网络设备、PCI设备等Linux设备驱动架构中各个复杂数据结构和函数的关系,并讲解了Linux驱动开发的大量实例,使读者能够独立开发各类Linux设备驱动。

 

【备注:本书第11页,第六行链接已经失效,替换为:http://pan.baidu.com/s/1cFrl2e 密码:ezs2】

作者简介

  宋宝华,Linux布道者,知名嵌入式系统专家,《Essential Linux Device Drivers》译者。作为较早从事Linux内核与设备驱动研究的专家之一,他在众多国内外知名企业开展Linux技术培训。他也是一位活跃的Linux开发者和深度实践者,为Linux官方内核贡献了大量的Linux源码并承担代码审核工作。至今已向Linux官方内核提交逾数万行代码和几百个补丁。他的《Linux设备驱动开发详解》系列书在嵌入式Linux开发者中有口皆碑,是众多Linux书籍中为数不多的畅销书。

 
 
限个人学习使用,不得用于商业用途,请在下载后24小时内删除。
备注:资源来自网络,如有不合理可私信我,秒删。
电子书 Linux设备驱动开发详解:基于最新的Linux 4.0内核.pdf 宋宝华 免费下载
https://page55.ctfile.com/fs/14299555-204231232
 
 

2014-07-02 20:24:50 dayenglish 阅读数 3022

表格中描述的是常用的同步机制,这些机制的相关描述以及他们在windows系统下面的实现。

同步方法

描述

Windows下的机制

Interlocked operations

提供原子的算术,逻辑,和列表操作,不仅是多线程安全的同时也是多处理器安全的

InterlockedXxx and ExInterlockedXxx routines

Mutexes

提供内存的互斥访问权限

Spin locks, fast mutexes, kernel mutexes, synchronization events

Shared/exclusive lock

运行一个线程专享写多个线程共享读

Executive resources

Counted semaphore

允许固定大小的获取权限

Semaphores

原子锁的实现需要处理器汇编的支持,因而可以在所有的IRQL级别上面使用。InterlockedXxx例程能够运行在可换页数据当中。

一个互斥体保证共享资源的专有访问权限,任意能够保证互斥专有权限的锁都能被认为是互斥体。比如,自旋锁和同步事件都可以被认为是互斥体,因为当一个同步事件被唤醒的时候,仅有一个线程能够获取访问权限,同样自旋锁在并发条件下最多只能有一个线程能够被选中执行。互斥体的类型依赖于互斥体的使用环境,选择互斥体需要遵循下列规则:

1 互斥体可以运行的IRQL级别,也就是互斥体可以在哪一级IRQL当中获取和释放

获取互斥体是否会提升当前的IRQL?如果IRQL得到提升,那么原来的IRQL被保存在哪里?

3 是否互斥体的释放和获取必须在同一个线程环境上下文当中?

互斥体是否能够被递归获取,也就是说一个线程能否在不释放互斥体的条件下,再次获取该互斥体?

5 当一个持有互斥体的线程被终止而没有释放互斥体会出现什么情况?

互斥体类型

IRQL限制

递归和线程方面的细节

Interrupt spin lock

获取的时候提升IRQLDIRQL并且返回之前的IRQL给调用者

不能递归获取,获取和释放在同一个线程上下文

Spin lock

获取的时候提升IRQLDISPATCH_LEVEL并且返回之前的IRQL给调用者

不能递归获取,获取和释放在同一个线程上下文

Queued spin lock

获取的时候提升IRQLDISPATCH_LEVEL并且保存之前的IRQL在一个锁持有者句柄当中

不能递归获取,获取和释放在同一个线程上下文

Fast mutex

获取的时候提升IRQLAPC_LEVEL并且保存之前的IRQL到锁当中

不能递归获取,获取和释放在同一个线程上下文

Kernel mutex (a kernel dispatcher object)

在获取的时候进入代码互斥区域,在释放的时候离开代码互斥区域

可以递归获取,获取和释放在同一个线程上下文

Synchronization event (a kernel dispatcher object)

获取并不会改变IRQL,在小于等于APC_LEVEL级别等待,而在小于等于DISPATCH_LEVEL级别激发

不能递归获取,获取和释放不需要在同一个线程上下文

Unsafe fast mutex

获取的时候不改变IRQL并且在小于等于APC_LEVEL级别下面释放

不能递归获取,释放和获取在同一个线程上下文

共享互斥锁也被称为读写锁,允许一个线程进行专享的写操作,而其他的线程进行共享的读操作。计数信号量和互斥体一样,只不过可以出现多个线程同时获取信号量。

Windows同步机制

描述

IRQL限制

InterlockedXxx routines

在换页内存上执行原子的算术和逻辑运算

能够在任何IRQL当中获取

Spin locks

在非换页内存上提供专享的内存访问

在IRQL <= DISPATCH_LEVEL级别上获取

ExInterlockedXxx routines

执行算术、逻辑和列表控制的原子操作,不仅是多线程安全的同样也是多处理器安全的

在IRQL <= DISPATCH_LEVEL级别上访问 SList 历程; 其他历程可以在任意IRQL上面访问

Fast mutexes

在APC_LEVEL级别保护数据,同时防止线程被打断

在IRQL <= APC_LEVEL级别上获取

Executive resources

允许一个线程专享写而其他的线程共享读

在IRQL<=APC_LEVEL级别上获取

Kernel dispatcher objects (events, kernel mutexes, semaphores, timers, files, threads, processes)

在IRQL<=APC_LEVEL级别上提供不同形式的同步,能够和用户模式下的应用程序同步

在 IRQL <= APC_LEVEL下等待; 在IRQL <= DISPATCH_LEVEL下激发

Callback objects

在IRQL<=DISPATCH_LEVEL内核模式下面提供代码同步,能够用于驱动之间同步

在 IRQL <= DISPATCH_LEVEL被通知; 回调例程运行在激发线程的上下文当中,和激发线程的IRQL一样

自旋锁和它的名称所暗示的一样:当一个线程拥有自旋锁的时候,其他的线程在内存的某一个位置上通过忙等待自旋,直到锁是可用的。也就是说线程并不会阻塞,而是继续保持CPU的控制权,以防止执行相同或者更低IRQL中的代码。自旋锁是一个没有完全公开的KSPIN_LOCK结构体,他们必须从非分页内存当中分配,比如设备扩展结构体,或者由用户调用的非分页内存分配操作获得的内存。

自旋锁类型

描述

Ordinary spin lock

保护DISPATCH_LEVEL级别或者更高级的共享数据

Queued spin lock

保护DISPATCH_LEVEL级别或者更高级的共享数据,排队自旋锁可以在XP或者以后的系统版本上面使用

Interrupt spin lock

保护DIRQL级别的共享数据,在InterruptServiceSynchCritSection例程当中使用

所有的自旋锁提升IRQL到DISPATCH_LEVEL或者更高,自旋锁是唯一一个能够被用于高于DISPATCH_LEVEL的同步机制。也就是说系统线程不能够切换,并且当前线程也不能被打断。所有持有自旋锁的代码都需要遵从高于DISPATCH_LEVEL级别的IRQL的执行规则。一个自旋锁的单处理器实现很简单,只需要提升IRQL就可以了,但是多处理器的实现则需要两步,第一步提升IRQL,第二步通过一个原子操作测试并设置相应的数值。

普通自旋锁工作在DISPATCH_LEVEL,为了创建一个普通自旋锁,驱动程序在非换页内存当中分配一个KSPIN_LOCK结构体,并且调用KeInitializeSpinLock进行初始化。运行在低于DISPATCH_LEVEL的代码必须通过KeAcquireSpinLock和KeReleaseSpinLock进行自旋锁获取和释放。而已经运行在DISPATCH_LEVEL的代码应该调用KeAcquireSpinLockAtDpcLevel和KeReleaseSpinLockFromDpcLevel进行自旋锁的获取和释放,这两个例程不会引起IRQL的改变。

不论何时多个线程请求队列自旋锁,都需要在自旋锁的队列上面排队。另外,队列自旋锁仅仅测试和设置本地CPU,因此总线开销更小,对NUMA体系架构尤其高效。一个排队自旋锁需要一个KLOCK_QUEUE_HANDLE结构体来辅助KSPIN_LOCK——前者为后者提供一个队列存储的句柄。这个结构体能够在堆栈当中分配,为了初始化排队自旋锁需要调用KeInitializeSpinLock例程。同样的,运行在低于DISPATCH_LEVEL的驱动历程应该调用KeAcquireInStackQueuedSpinLock和KeReleaseInStackQueuedSpinLock来获取和释放自旋锁。而运行在DISPATCH_LEVEL大的驱动历程则需要调用KeAcquireInStackQueuedSpinLocktDpcLevel和KeReleaseInStackQueuedSpinLockFromDpcLevel来获取和释放自旋锁。

一个中断自旋锁保护设备寄存器和驱动的InterruptService例程以及可以在DIRQL访问的SynchCritSection例程。当一个设备驱动与中断对象关联的时候,操作系统为中断对象创建一个中断自旋锁。当InterruptService例程运行在DIRQL,并且持有相关的中断自旋锁。当InterruptService退出的时候,操作系统释放自旋锁并且降低IRQL。

当一个驱动调用KeSynchronizeExecution来运行一个SynchCritSection例程的时候,同样会申请默认的中断自旋锁。操作系统提升IRQL到DIRQL,获取自旋锁,并且调用SynchCritSection例程。其他的驱动需要访问中断自旋锁应该调用KeAcquireInterruptSpinLock来对共享数据进行访问。然而,一个设备可能在不同的IRQL上面产生多个中断。在这种情况下,驱动必须创建一个中断能够到达的最高的IRQL的自旋锁。当驱动连接中断对象的时候,传递一个KSPIN_LOCK结构体指针。这个结构体和一个中断能够达到的最高的DIRQL关联。系统会将这个自旋锁与中断对象关联起来。

ExInterLockedXxx例程由汇编代码进行编码并且通常禁止相关的处理器的中断。实际上ExInterLockedXxx例程的代码运行在HIGH_LEVEL。为了保护SMP系统上的数据,在操作之前,操作系统提升IRQL并且获取自旋锁。当例程完成操作的时候,系统释放自旋锁并且返回到原来的IRQL。

另外,ExInterLockedXxx例程能够管理下列三种类型的列表:

1 单链表

2 双链表

3 S链表

目的

非InterLocked例程

Interlocked例程

Insert entry at front of singly linked list.

PushEntryList

ExInterlockedPushEntryList

Remove entry from front of singly linked list.

PopEntryList

ExInterlockedPopEntryList

Insert entry at front of doubly linked list.

InsertHeadList

ExInterlockedInsertHeadList

Remove entry from front of doubly linked list.

RemoveHeadList

ExInterlockedRemoveHeadList

Insert entry at end of doubly linked list.

InsertTailList

ExInterlockedInsertTailList

Remove entry from end of doubly linked list.

RemoveTailList

None.

Initialize doubly linked list.

InitializeListHead

None.

Check whether list has entries.

IsListEmpty

None.

Remove entry from doubly linked list.

RemoveListEntry

None.

Initialize S-list.

None.

ExInitializeSListHead

Insert entry at front of Slist.

None.

ExInterlockedPushEntrySList

Remove entry from end of S-list.

None.

ExInterlockedPopEntrySList

Remove all entries from an S-list.

None.

ExInterlockedFlushSList

ExInterLockedXxxList例程使用了一个为驱动分配的自旋锁,这些例程能够在任何IRQL进行调用,而S-链表只能在低于DISPATCH_LEVEL运行。windows内部使用S链表实现lookaside链表。

一个快速互斥体是一个不完全公开的FAST_MUTEX结构体,这个结构体必须在非换页内存当中分配。FAST_MUTEX运行在APC_LEVEL级别,因此可以阻止所有的APC提交,也不会被其他的线程打断。

例程

描述

ExAcquireFastMutex

在获取快速互斥体之前提升IRQL到APC_LEVEL ,阻塞直到快速互斥体可用

ExAcquireFastMutexUnsafe

在当前的IRQL获取快速互斥体,阻塞直到互斥体可用

ExTryToAcquireFastMutex

在获取快速互斥体之前提升IRQL到APC_LEVEL ,不可用时不阻塞

ExAcquireFastMutex和ExAcquireFastMutexUnsafe引起线程阻塞直到互斥体可用。而ExTryToAcquireFastMutex如果互斥体不可用立刻返回FALSE。当出现下面两种情况之一的时候,使用ExAcquireFastMutexUnsafe获取互斥体:

· 线程已经运行在APC_LEVEL

· 线程在获取互斥体之前通过调用KeEnterCriticalRegion和FsRtlEnterFileSysytem进入了关键代码段

在上面两种情况下的任意一种,用户模式和内核模式的APC提交已经被禁止了。

一个驱动程序应该按照下面的步骤使用快速互斥体:

1 从非换页内存当中分配一个结构体FAST_MUTEX

2 初始化这个快速互斥体的结构体

3 在进入保护区域之前,调用ExAcquireFastMutex,ExAcquireFastUnsafe或者ExTryToAcquireFastMutex

 

4 执行受保护的操作

5 通过调用ExReleaseFastMutex或者ExReleaseFastMutexUnsafe释放快速互斥体

快速互斥体具有下列局限性:

1 快速互斥体不能够递归获取,这样做的结果是导致一个死锁

2 持有快速互斥体的驱动代码运行在APC_LEVEL级别。因此,在持有快速互斥体的代码中不能调用只能在PASSIVE_LEVEL级别可调用的例程,比如IoBuildDeviceIoControlRequest

3 快速互斥体不是系统内核分发对象。因此,一个驱动不能够利用KeWaitForMultipleObjects例程来等待快速互斥体。

操纵系统定义了一些内核分发对象,这些对象提供不同类型的同步机制。内核调度机制相对简单,并且能够在PASSIVE_LEVEL级别进行申请。 

对象类型

描述

IRQL限制

Kernel mutex

提供在PASSIVE_LEVEL或者APC_LEVEL专享访问

在 IRQL <=APC_LEVEL级别等待

Event

提供驱动程序下的同步,能够用于和用户模式的应用程序同步can be used to synchronize with user-mode applications.

在IRQL<=APC_LEVEL级别等待,在IRQL<=DISPATCH_LEVEL设置

Semaphore

保护一组同类的数据

在IRQL<=APC_LEVEL等待,在IRQL<=DISPATCH_LEVEL.

Timer

在一个绝对或者绝对的定时时间间隔内提供通知或者同步定时器

在IRQL <=APC_LEVEL等待,在IRQL <=DISPATCH_LEVEL.

Threads, processes, and files

在文件、线程和进程创建或者文件IO结束,线程和进程终止的时候进行同步

在IRQL <=APC_LEVEL等待

一个驱动程序将内核调度对象作为参数传递给KeWaitForSingleObject或者KeWaiForMultipleObject例程。通这两个例程,驱动程序能够等待超时或者直到内核分发对象被设置。

内核分发数据库通过内核分发对象的名称管理所有内核调度对象。为了访问这些对象,系统必须提升IRQL到DISPATCH_LEVEL,并且获取系统范围内的分发数据库的自旋锁。分发锁的获取很频繁,大多数情况下系统需要等待锁就绪,因此,驱动代码应该优先考虑使用快速互斥体而不是分发对象。

内核分发对象有一个公有头部(DISPATCHER_HEADER),但是每一种类型的分发对象都有自己特有的初始化和释放例程。内核分发对象必须在非换页内存当中分配。驱动代码能够通过句柄或者指针管理内核分发对象。如果驱动通过句柄管理内核分发对象,但是这个驱动程序在任何线程上下文运行,那么驱动必须设置OBJ_KERNEL_HANDLE属性来防止用户模式下的访问。驱动代码通常利用内核分发对象来等待一个同步IO操作的结果。最顶层的驱动创建并且发送IRP给底层的驱动,然后在发出IO请求的线程上下文等待。更底层的驱动有可能需要在PASSIVE_LEVEL和APC_LEVEL级别的任意线程上下文等待同步执行。

内核分发对象有两种状态,激发状态和非激发状态。激发状态表明当前分发对象能够被获取,一个激发状态的对象还没有被任何线程锁获取,而一个非激发状态的对象则被一个或多个线程所获取。分发对象的初始激发状态和分发对象的类型有关,比如内核互斥体在初始化的时候立刻就被激发,而事件对象则需要手动调用KeSetEvent例程来激发。

一个线程能够在低于等于DISPATCH_LEVEL级别激发内核分发对象,但是仅仅能够在低于等于APC_LEVEL级别的情况下等待这个内核分发对象。也就是说驱动程序不能在IoCompletion,StartIo或者其他的延迟调用例程当中等待分发对象。最高层的驱动程序在他的读写例程当中等待分发对象,而底层的驱动程序不能在读写例程当中等待分发对象,因为他们的读写例程能够在DISPATCH_LEVEL被调用。

然而,如果一个线程不要等待分发对象的话,能够在DISPATCH_LEVEL级别获取一个内核分发对象。这一功能通过给KeWaitForSingleObject或者KeWaitMultipleObjects传递超时值为0。这种特性可用于测试一个对象是否被激发。比如,一个DPC例程和其他例程同步的时候,可能需要测试对象是否被激发。如果对象被激发则DPC处理这个任务,否则做其他的任务,然后将这个原始任务进行排队。因为work item是在PASSIVE_LEVEL当中执行,可以在work item当中等待相应的分发对象

KeWaitForSingleObject和KeWaitForMultipleObjects函数的参数Alterts和WaitMode决定在线程等待的时候系统怎样处理用户模式下的APC。

Value of Alertable and WaitMode parameters

Special 
kernel-mode APC

Normal 
kernel-mode APC


User-mode APC

Terminate wait?

Deliver and run APC?

Terminate wait?

Deliver and run APC?

Terminate wait?

Deliver and run APC?

Alertable = TRUE

WaitMode = UserMode

No

If (A*), then Yes

No

If (B**), then Yes

Yes

Yes, after thread returns to user mode

Alertable = TRUE

WaitMode = KernelMode

No

 

If (A), then Yes

No

If (B), then Yes

No

No

Alertable = FALSE

WaitMode = UserMode

No

If (A), then Yes

No

If (B), then Yes

No

No (with exceptions, such as CTRL+C to terminate)

Alertable = FALSE

WaitMode = KernelMode

No

If (A), then Yes

No

If (B), then Yes

No

No

 

  *A: IRQL < APC_LEVEL.

**B: IRQL < APC_LEVEL,线程既不在APC_LEVEL也不在关键代码段

当一个线程从系统模式的切换到用户模式的时候,系统会提交大多数用户模式的APC。用户模式APC不会中断用户模式下的代码,在一个应用程序为一个线程排队用户模式的APC之后,这个应用程序通过调用等待函数并传递参数Alterable为TRUE会引起系统提交APC。

当驱动程序调用KeWaitForSingleObject或者KeWaitForMultipleObjects,并且设置传参数Alterable为TRUE同时WaitMode为UserMode。那么等待将会返回STATUS_USER_APC或者STATUS_ALTERTED,只要存在悬挂的用户模式APC。

驱动程序调用KeWaitForXxx例程不应该传递Alterable参数为TRUE并同时设置WaitMode为UserMode。除非应用程序明确要求驱动程序在等待期间提交用户模式下的APC。

当一个驱动程序调用KeWaitForSingleObject或者KeWaitForMultipleObjects,参数WaitMode被设为UserMode,而Alterable设为FALSE。那么等待将会在线程被终止的时候返回STATUS_USER_APC。然而驱动程序必须在PASSIVE_LEVEL级别而不能在关键代码区域等待。

WaitMode同样也可以决定线程的内核堆栈在等待的时候是否能够被换页出去。当等待模式是UserMode的时候,系统将内核模式的堆栈换页出去。当正在等待的驱动是堆栈上的唯一的驱动,在UserMode下等待才是安全的。如果一个或者多个驱动在堆栈上,这些驱动当中的更新堆栈变量操作可能导致缺页错误。

windows在\\kernelObject对象目录当中定义了一些标准的事件对象。KeSetEvent函数有三个参数:第一个参数是一个将要被激发的事件指针,第二个是事件激发之后想要获取的优先级提升,第三个参数是一个Wait布尔值。当Wait布尔值为TRUE的时候表示线程在KeSetEvenet之后立刻就会调用KeWaitXxx例程。

一般情况下,驱动程序调用KeSetEvent——设置Wait为FALSE,当Wait设置为FALSE的时候,KeSetEvent提升IRQL到DISPATCH_LEVEL,获取分发锁,修改事件对象的激发状态,激活任意等待的线程,解锁分发对象数据库,降低IRQL到原来的数值,然后返回。然而,当Wait为TRUE的时候,KeSetEvnet不释放分发锁或者降低IRQL。这个优化能够避免不必要的上下文切换,因为调用者已经在一个原子操作当中激发了这个事件。如果一个驱动程序使用了这个特性,那么它必须在小于DISPATCH_LEVEL级别下调用KeSetEvent,并且保证不在任意线程上下文当中。一个类似生产者和消费者场景下的驱动历程可能使用这个特性。这种驱动通常以下列方式和事件进行交互,生产者驱动历程激发第一个事件通知应可以发送数据。然后立刻等待第二个事件被另一个线程激发,第二个线程设置第二个事件通知数据已经收到,并且做好接收更多数据的准备。驱动应该使用这些特性仅仅在请求IO的线程的上下文当中,一个驱动应该避免阻塞一个不相关的线程。

一个通知事件唤醒任何在等待的线程,并且保持激发状态,直到显式调用KeResetEvent。在win32 API当中通知事件被称作手工重置事件。驱动程序通常用通知事件等待IRP的完成。比如,一个驱动可能通过IoBuildDeviceIoControlRequest发送IO控制代码给设备对战当中的更底层的驱动。这个例程的一个参数就是一个事件对象的指针。在驱动例程创建并且发送IRP之后,驱动在这个事件对象上面等待。当IRP完成,IO管理器激发这个事件,这个事件保持激发状态直到调用KeResetEvent。

同步事件也被称为自动重置事件,在唤醒线之后立刻返回到非激发状态,驱动程序较少使用同步事件。一个需要长时间初始化的设备驱动程序可能在StartDevice例程上等待同步事件来确保设备被完整初始化。在设备被中断并且任何在DISPATCH_LEVEL级别的处理都结束时,驱动的DpcForIsr例程激发这个事件。控制权然后转移到StartDevice例程,在这里可能继续初始化驱动和设备。同样的,一个驱动能够在同步事件上等待DispatchPnp例程来确保IO已经在停止或者移除设备之前完成。

一种在内核驱动和用户模式应用程序下协作的方式。在驱动当中:

1 定义一个私有的IO控制代码,应用程序可以通过这个IO控制代码传递一个事件

2 提供一个DispatchDeviceControl例程来处理私有的IRP_MJ_DEVICE_CONTROL下面的IOCTL请求

3 通过调用ObReferenceObjectByHandle来获取有效的事件指针,在DesiredAccess参数当中,指定SYNCHRONIZE权限,而在ObjectType参数当中,指定*ExEventObjectType

4 通过KeSetEvent激发事件,通过调用KeResetEvent来重置通知事件

5 调用ObDereferenceObject来释放句柄,当句柄值不再需要的时候

在应用程序当中:

1 通过调用CreateEvent函数创建一个有名事件

2 通过调用DeviceIoControl函数,将句柄传递给驱动程序

3 通过调用调用WaitForSingleObject或者WaitForMultipleObjects等待内核模式下的驱动程序激发这个事件

4 在退出之前调用CloseHandle删除这个事件句柄

内核互斥体是一种可以用于分页内存的同步技术,同时它还可以用于需要相对较长的执行时间的情况下。驱动程序能够在小于等于APC_LEVEL的情况下使用。

内核互斥体依赖于线程上下文,通常是运行在请求线程上下文的最高层次的驱动例程使用内核互斥体。一个获取互斥体的线程应该在同一个线程上下文当中释放互斥体。

内核互斥体和快速互斥体的不同之处表现在以下几个方面:

1 内核互斥体能够被递归获取,而快速互斥体不行

2 内核互斥体通过调用KeWaitForSingleObject,KeWaitForMultipleObjects以及KeWaitForMutexObject。而快速互斥体通过ExAcquireFastMutex、ExTryToAcquireFastMutex,和ExAcquireFastMutexUnsafe

3 内核互斥体的获取需要使用系统范围内的锁。因此,他们效率相对快速互斥体低

为了使用内核互斥体,驱动程序必须按照下列步骤来执行:

1 从一个非换页内存当中分配一个KMUTEX数据结构

2 传递之前分配的数据结构指针给KeInitializeMutex函数进行互斥体初始化

3 通过KeWaitForSingleObject、KeWaitForMultipleObjects或者KeWaitForMutexObject等待互斥体

4 执行保护的操作

5 通过KeReleaseMutex释放互斥体

系统在初始化内核互斥体的时候已经让这个互斥体处于激发状态,第一个等待互斥体的线程将获取互斥体。驱动历程应该总是定义KernelMode,当他们等待内核互斥体的时候。在内核模式下等待将阻止内核模式堆栈被换页出去,并且禁止用户模式下的APC和普通内核模式的APC提交,因此可以阻止线程被终止和被打断。而内核模式下的特殊APC仍然可以提交。在北部,获取一个内核模式的互斥体需要调用KeEnterCriticalRegion。如果这个获取互斥体的线程运行在PASSIVE_LEVEL,会禁止普通内核模式的APC提交直到内核释放互斥体。当线程已经在APC_LEVEL的时候,进入关键代码段不起作用,因为此时普通内核模式APC已经禁止了。

一个持有互斥体的线程在转入到用户模式之前必须释放互斥体。如果再切换到用户模式过程当中线程持有互斥体,那么系统将崩溃。

KeReleaseMutex和KeSetEvent有同样意义的Wait参数。一个线程递归获取互斥体必须释放互斥体同样的次数,操作系统不会使得互斥体处于激发状态或者调用KeLeaveCriticalRegion直到所有的申请都释放。

信号量的处理和互斥体类似,但是当释放次数超过信号量的上限的时候,系统会跳出异常,这一点和事件不同——激发状态的事件可以进行设置。另外信号量的释放参数Wait和互斥体的处理一样,线程能够通过KeReadStateSemphore来测试信号量是否被激发。

定时器有通知和同步两种,一个驱动程序通过KeInitializeTimer创建一个通知定时器,或者通过调用KeInitializeTimer创建两种定时器之一。相对时间通过计算机器运行时间并且不受系统时钟的影响。相对时间包括系统休眠的时间,当系统被唤醒,系统调整机器时间来包含计算机休眠的时间。结果是一旦系统唤醒许多定时器同时超时。

当一个通知定时器超时的时候,所有的等待线程都被激发。定时器保持激发状态直到一个线程调用KeSetTimer显式重置定时器。当一个同步定时器超时,仅仅一个线程被激发。同时系统立刻重置定时器到非激发状态。

驱动能够在小于等于APC_LEVEL级别上等待IRQL,或定义一个CustomTimerDpc例程被调用当超时的时候。这个历程能够取代驱动程序创建的协助线程来执行相应的操作。这个历程同样可以用于超时来自DISPATCH_LEVEL级别的请求。

为了使用定时器,驱动应该遵从下面的步骤:

从非换页内存当中分配一个KTIMER结构体

调用KeIntializeTimer或者KeInitializeTimerEx创建并且初始化这个定时器

为了绑定一个CustomTimerDpc到定时器上,还需要额外的调用KeInitializeDpc初始化一个DPC对象,并注册CustomTimerDpc

通过KeSetTimer或者KeSetTimerEx设置定时器,指定超时时限。超时的时候排队CustomTimerDpc例程,包括可选的DPC参数

调用KeWaitForSingleObject或者KeWaitForMultipleObjects来等待定时器

如果需要在定时器超时之前取消定时器,可以调用KeCancelTimer

为了在超时之后重置定时器,可以调用KeSetTimer

线程、进程以及文件也是内核调度对象。驱动程序能够用KeWaitXxx来同步进程、线程或者文件。另外,驱动程序能够监听到新线程或者进程的创建。为了在线程、进程或者文件对象上面等待,内核模式的驱动必须定义KernelMode等待模式。当线程或者进程终止、文件操作完成的时候,等待会被唤醒。

一个文件IO操作完成的时候系统会自动激发一个内置在文件对象当中的事件。这个事件是同步事件,也就是说这个事件会在等待线程被通知之后自动重置。特定线程的同步只有在驱动创建一个辅助线程的时候才非常有用。大多数驱动例程,除了最高层次的驱动分发例程,都是在任意线程的环境上下文当中调用。因此,与当前线程环境上下文的同步是没有意义的。,当一个系统范围的线程或者进程被创建或者删除的时候,驱动能够获得通知。为了得到通知,驱动可以通过函数PsSetCreateProcessNotifyRoutine或者PsSetCreateThreadNotifyRoutine设置一个回调例程。设置回调例程的驱动在系统关闭之前不能退出。

通过使用可执行资源,驱动能够实现读写锁。可执行资源设计用于专有写而可以共享读。执行资源不需要获取系统分发数据库的自旋锁,因此速度比较快。一个运行在小雨等于APC_LEVEL级别的线程代码可以使用执行资源。一个执行资源是一个ERESOURCE结构体,这个结构体必须从非分页内存当中分配。一个ERESOURCE必须是自动对齐的,不过ERESOURCE结构体是不完全公开的。

Table 9. Executive Resource Acquisition Routines

例程

访问权限

条件

ExAcquireResourceSharedLite

共享

如果资源没有被专享访问并且没有任何线程在等待专享访问,或者请求的线程已经包含共享或者独享访问权限

ExAcquireResourceExclusiveLite

独享

如果资源没有被独享或者共享访问

ExAcquireSharedStarveExclusive

共享

如果资源没有被专享访问并且没有任何线程在等待专享访问,或者请求的线程已经包含共享或者独享访问权限,等待独享权限的线程将继续等待

ExAcquireSharedWaitForExclusive

共享

如果资源没有被专享访问并且没有任何线程在等待专享访问,如果请求线程有权限访问这个资源但是其他线程正在等待的时候,递归获取权限需要先释放权限然后和其他的线程竞争

线程可以将独享特权变换到共享特权,而不能将共享特权变换为专有特权。ExConvertExclusiveToSharedLite例程可以将线程的专享特权转换为共享特权。一个线程可以代替其他线程来释放资源通过调用ExReleaseResourceForThread。文件系统驱动使用这个例程,当一个线程获取了资源然后发送这个IO请求给其他线程的时候。在这种情况下,线程完成IO请求可以调用这个例程代替第一个线程来释放资源。

驱动程序能够通过ExIsResourceAcquiredLiteExIsResourceAcquiredSharedLiteExIsResourceAcquiredExclusiveLite来判断资源是否被任何线程占用。另外,驱动还能够通过ExGetSharedWaiterCount或者ExGetExclusiveWaiterCount来获取等待的线程数目。中断一个拥有互斥资源所的线程可能引起死锁。

使用执行资源的要点:

从非换页内存当中分配一个ERESOURCE结构体

DriverEntry或者AddDevice例程当中调用ExInitializeResourceLite初始化资源

在获取资源之前禁止普通内核模式的APC,驱动程序通过调用KeEnterCriticalRegion,文件系统调用FsRtlEnterFileSystem。如果驱动例程运行在系统线程,可以不必要禁止APC,因为系统线程不会被中断。

通过调用表9当中的线程来获取资源

执行代码

通过调用ExReleaseResourceLite释放资源

重新使能普通内核模式的APC提交,通过调用KeLeaveCriticalRegion或者FsRtlLeaveFileSystem

所有的资源获取例程返回一个指示获取成功与否的布尔值。执行资源能够被递归获取。比如,一个文件系统可能通过映射文件到虚拟内存当中的保留区域来实现cache。如果这个过程引起页错误,那么操作系统将产生额外的IO请求。这些IO请求同样会送到这个文件系统驱动并且打断驱动处理cache IO的过程。为了处理额外的IO请求,文件系统必须递归的获取在cache IO过程当中使用的锁。

回调对象仅仅能够在内核模式下使用,他们不能与用户模式下的应用程序共享。驱动程序通过调用ExCreateCallBack来创建一个回调对象。用户通过ExRegisterCallback来注册一个回调例程。当驱动程序指定的回调条件发生的时候,驱动程序调用ExNotifyCallback来通知回调例程运行。ExNotifyCallback能够在小于等于DISPATCH_LEVEL级别上被调用。而回调例程在在通知线程的环境上下文当中和ExNotifyCallback例程相同的IRQL上面运行。如果驱动注册一个回调例程,确定你知道通知发生的IRQL,并且适合回调例程运行。

当使用用户自定义的锁的时候,需要牢记编译器可能会对指令进行位置调整,这就需要我们使用内存栅栏来防止这种优化。内存栅栏是一个处理器指令防止读写操作的指令顺序调整。ExInterLockedXxxInterlockedXxx例程和KeMemoryBarrierKeMemoryBarrierWithoutFence例程都可以插入内存栅栏防止重排序。

多种同步机制的使用

比如,一个驱动程序可能包含两个需要在DISPATCH_LEVEL级别保护的列表,当驱动例程需要讲一个列表当中的数据转移到另一个列表的时候,仅仅只是用一个自旋锁是不够的。但代码需要访问两个链表的时候,就需要两个锁。获取锁的顺序可以通过简历锁层次得到。锁的层次通过他们的IRQL递增形式排列。首先列出需要最低IRQL的锁,然后列出第二低的锁…当代码需要一次获取多个锁的时候,它应该以IRQL递增的方式获取。当存在IRQL相同的时候,优先获取访问次数比较频繁的锁。

一下步骤可以防止死锁:

绝对不要在任何大于等于DISPATCH_LEVEL级别被调用的驱动例程当中等待一个内核分发对象,在大于小于DISPATCH_LEVEL级别调用的例程包括IoCompletetion例程和存储驱动的IO分发例程以及USBhub驱动

禁止正常内核模式的APC调用,在任何可执行资源获取之前,或者调用KeWaitXxx等待事件、信号量、定时器、线程、文件对象或者进程之前

使用驱动验证程序的死锁检测选项来发现潜在的死锁

总是通过锁层次来编码

PASSIVE_LEVEL级别的关键代码段外使用锁有可能导致DOS攻击,当一个持有锁的驱动被中断,这是因为windows排队一个普通内核模式的APC来中断线程,即使驱动定义了KernelMode等待,普通内核模式APC仍然会提交,当下面所有条件成立的时候:

目标线程运行在APC_LEVEL

目标线程没有运行APC

目标线程不在关键代码段


2019-02-13 17:34:19 Sagittarius_Warrior 阅读数 7873

Abstract

  如果推荐 Windows 驱动开发的入门书,我强烈推荐《Windows驱动开发技术详解》。但是由于成书的时间较早,该书中提到的很多工具和环境都已不可用或找不到,而本文搜集了大部分的工具,并在 win10X64 上安装开发环境,在 win7x86 上进行实验,趟过了不少实际编译和测试中遇到的坑。此外,本文也对相关章节的重点进行了总结,全文目录如下:

全书导读

  《Windows驱动开发技术详解》全书由浅入深分为四个部分:入门篇、进阶篇、实用篇和提高篇,可以参考 目录结构
  本人之前从事 Windows PCIe 设备驱动开发,目前从事网络安全方面的工作,本文是我利用春节假期复习《Windows驱动开发技术详解》所写。由于现在的工作不涉及具体的设备驱动,所以书中“实用篇”被我跳过了,从事安全方面开发的人员只需要掌握驱动模型即可。
  全书阅读的建议是:基础篇快速阅读,把每个实验做一遍即可。进阶篇认真阅读和实验,而提高篇实际上就是前面知识的总结,看以当作复习和综合练习。
  此外,在我的资源里有相关的全书源码,也有我自己实验的 DDK build 版源码。鉴于原书代码大部分是 VC 版,而且使用的是老版本的VC ,我在实验时,全部将其驱动代码改为了 DDK build 版本。应用程序部分则采用 VS2017 新建 win32 console 工程进行编译。

开发和调试

驱动开发工具

《Windows驱动开发技术详解》和《Windows内核安全编程》配套的 Driver Build 工具都是 WDK7600。它可以从 MSDN 上下载,也可以直接从如下链接下载,选择“Full Development Environment” ,默认路径安装即可(无须设置环境变量)。

WinDDK 下载安装

DriverStudio 安装配置

驱动日志工具

最经典的驱动日志工具是 sysinternals - DbgView.exe,通过 C 风格的 KdPrint() 函数输出日志。需要注意的是:KdPrint() 的日志只在 Checked Build 版本中才可见,在 Free Build 版本不可见。

此外,Microsoft 也提供了一套 Driver 日志机制—— WPP + TraceView.exe,适合有 PDB 文件的日志分析。

驱动运行状态观察工具

Procexp.exe 可以观察驱动的运行状态,此外,还有 livekd.exe 和 kd.exe。

DriverView.exe 用于观察系统已安装的全部驱动程序,也可以使用 CMD - systeminfo 命令查看驱动信息。

驱动安装工具

KmdManager.exe 是驱动加载和测试工具,Link。如下图所示,它提供了驱动的注册、启动服务(net start ***)和发送相关 IOControl 的功能。
在这里插入图片描述

srvinstw.exe 是《Windows内核安全编程》推荐的一款驱动加载工具,并配有详细的操作步骤。

需要注意的是:KmdManager.exe 和 srvinstw.exe 都需要使用“管理员权限”运行。

除了 Driver Studio,Windows 驱动安装首选 devcon.exe。它是附在 Windows WDK 中的一个工具。

WinDbg

驱动相关的操作系统知识

在这里插入图片描述
其中,native api 对应的 PE 是 ntdll.dll,其 API 一般都以 Nt开头;而“系统服务函数”对应的 PE 是 ntosknrl.exe,其 API 一般都以 Zw 开头。此外,可以通过 Dependency walker.exe 查看其导出函数。

城里城外看SSDT

驱动程序的编译和调试

这一章主要介绍 Windows 驱动程序的开发环境搭建,驱动程序安装和驱动程序调试,主要参考《Windows驱动开发技术详解》的第三章和《Windows内核安全编程》第一章。其中,examples 用的是前者的,而开发环境和工具都使用的是后者的。

HelloDDK

HelloDDK 是一个 NT驱动,也就是说,它是一个非 PnP 驱动,仅以系统服务的形式存在,并不与设备相关。

  1. 安装好 WDK7600后,启动 “Windows Driver Kits - x86 Checked Build Environment” 命令窗口,导航到源码目录。
  2. 输入 “build” 指令,生成 sys 文件。
  3. 在目标机器运行 srvinstw.exe ,安装该服务驱动。
  4. cmd - net start/stop helloddk ,开启/停止该服务。

需要注意:

  1. 安装该服务后,可以在如下注册表中看到
HKLM\SYSTEM\CurrentControlSet\services\HelloDDK

从《Windows驱动开发技术详解》第三章可知,服务安装的过程,实际上就是写注册表的过程。
2. 用 DbgView.exe 观察的时候,需要“管理员权限运行”和开启“Capture Kernel + Enable Verbose Kernel Output”。
3. 需要重启才能在 DeviceManager 中看到该新安装的“非即插即用设备”。
4.

LoadNTDriver

LoadNTDriver 程序演示了如何调用 SCM 加载/卸载 NT 驱动程序,该程序源码(main.cpp)可疑放到一个 VS2017 的 win32 console 应用程序中 build,仅需修改以下几处:

  1. Unicode 改为 “多字节字符集”
  2. LoadNTDriver 和 UnloadNTDriver 形成增加 const 修饰
  3. getch -> _getch
  4. 可以将运行时改为 MT

HelloWDM

SubKey

HelloWDM 是一个 PnP 驱动,它注册的时候会创建三个子健:

Hardware子健

HKLM\SYSTEM\CurrentControlSet\Enum

Class子健

HKLM\SYSTEM\CurrentControlSet\Control\Class

service子健

HKLM\SYSTEM\CurrentControlSet\services\
INF

与 NT 驱动不同,WDM 驱动的注册表信息和设备信息都是在 INF 文件中定义的。《Windows驱动开发技术详解》CH1&CH3 都有对 INF 文件很详细的介绍。

MSDN - INF

INF 文件的节

安装HelloWDM

通过 DeviceMgr - root 选择“安装过时硬件” - 手动安装 - 从磁盘安装。

安装过程的日志会记录在 C:\Windows\INF\Setupapi.dev.log 文件中。

在这里插入图片描述

WDM驱动程序的结构

《Windows驱动开发技术详解》CH1简洁地解析了 HelloWDM 驱动程序每一行的意义,后面各章节更深入展开具体的技术细节。

驱动程序的基本结构

驱动程序的数据结构

Windows驱动程序虽然是 C 语言实现的,但是它还是采用了 OOP 的编程模式,其中数据结构就是 OOP 中重要的基石。通俗点说,数据结构(struct)就是Windows驱动程序的对象的载体。

驱动对象

OOP 编程模式中,一个对象代表一类实例。很明显,一个驱动对象就代表一个驱动程序。

在这里插入图片描述

设备对象

设备对象是驱动对象的一个子对象,它对应一个设备。

Introduction to device object

typedef struct _DEVICE_OBJECT {
  ...
  struct _DRIVER_OBJECT  *DriverObject;
  struct _DEVICE_OBJECT  *NextDevice;
  struct _DEVICE_OBJECT  *AttachedDevice;
  struct _IRP  *CurrentIrp;
  PIO_TIMER                   Timer;
  ULONG                       Flags;
  ULONG                       Characteristics;
  __volatile PVPB             Vpb;
  PVOID                       DeviceExtension;
  DEVICE_TYPE                 DeviceType;
  CCHAR                       StackSize;
  ...
} DEVICE_OBJECT, *PDEVICE_OBJECT;

需要特别注意的是 NextDeviceAttachedDevice ,NextDevice 是水平串联的设备的驱动对象,AttachedDevice则是分层驱动中垂直挂载的设备(虚拟设备)的驱动对象。

设备扩展

在驱动编程中,应该尽量避免使用全局变量。设备对象记录“通用”设备的信息,其他“特殊”的信息则放在设备扩展(结构体)中。很明显,程序员自定义的一些变量尽量放在设备扩展(结构体)中。

驱动程序的加载过程

这一节描述了驱动程序有 SYSTEM 进程加载的过程,驱动对象的创建,设备对象的创建等知识,非常重要。

设备名与符号链接

前者只在内核模式下可以设备,后者相当于前者的别名。这一节对这两个知识点有很详细的介绍,非常清晰。

WinObj观察驱动对象和设备对象

这一节最后还介绍了如何用 sysinternals - winobj.exe 查看设备对象和驱动对象。主要包括:驱动对象、设备对象和符号链接。

此外,也可以用 DeviceTree.exe 查看这些信息。

WDM驱动的基本结构

WDM 驱动完成一个设备操作需要至少两个设备对象(垂直结构)—— PDO 和 FDO。

PDO_and_FDO

当检测到设备时,PDO 有总线驱动程序创建。FDO 由自定义的驱动程序创建并附加到 PDO 上。

注意:在驱动的层次结构中,越靠近硬件的层,越“下”。

major_irp_and_minor_irp

IRP 的编号由主编号和辅助编号一起指定,表明这个 IRP 的作用。

驱动的层次结构

所谓“驱动的层次结构”,实际上是指设备对象的层次结构,包括 PDO 和 FDO。借助 DeviceTree.exe 可以很清晰地观察设备对象的层次结构。

设备对象堆栈

底层设备对象依靠 DEVICE_OBJECT 中的 AttachedDevice 查找上层设备对象,如果 AttachedDevice 为空,则到达顶层。而高层设备对象则依靠 DEVICE_EXTENSION 记录下层的设备对象。也就是,双向查询的链路都是通畅的。

在这里插入图片描述

水平串联

同一个驱动对象创建出来的设备对象之间的关系描述为水平结构。

在这里插入图片描述

实验

第一个实验是在原有的 HelloDDK 驱动程序的基础上,增加一个水平的设备对象,可以复用原有的 HelloDDK 中的 makefile和Sources文件,也可以使用原先的 LoadNTDriver.exe 程序进行测试。

第二个实验是在原有的 HelloWDM 驱动程序的基础上,增加垂直设备对象和水平设备对象的打印信息,可以复用原有的 HelloWDM 中的 makefile,Sources 和 INF 文件。

Windows内存管理

这一章主要介绍内存管理方面的基本知识和在内核编程中需要注意的内存操作方法。

内存管理的基本概念

内存管理的基本概念包括:物理内存管理(分段和分页)、虚拟内存管理(地址空间映射)、内核态地址空间和用户态地址空间、驱动程序与进程的关系、分页内存和非分页内存。
这些知识在“操作系统”课程中讲解的更加详细,《Windows驱动开发技术详解》仅做了简单介绍。其中特别需要注意的是:

  1. Windows驱动程序的不同例程(routines)运行在不同的进程。DriverEntry和AddDevice运行在SYSTEM进程中。而其他如 IRP_MJ_READ 和 IPR_MJ_WRITE 的派遣函数运行在应用程序“上下文”中,只能访问对应的进程的虚拟内存地址。
  2. 通过打印 log,可以查看当前例程所在的进程(PsGetCurrentProcess)。
  3. 当程序的中断请求级别在 DISPATCH_LEVEL 之上时(包括DISPATCH_LEVEL),程序只能使用非分页内存,否则将 BSOD。

通用链表

“操作系统”课程中的 ucore 也大量使用了“通用链表”,它是一种双向链表,一般作为子结构体嵌入到其他对象(结构体)中,从而实现跨不同对象的串联。

Windows 内核中的链表结构如下,Blink指向前一个对象(Before),Flink指向后一个对象(Follow)。

在这里插入图片描述

《Windows驱动开发技术详解》对 Windows 驱动中的链表有详细的操作(增删改查)介绍和相关的实验,适合编程时参考。

Lookaside

Lookaside 结构主要是为了避免内核内存碎片化而设计的,可以将它看作为内核内存池(容器)。

#pragma INITCODE
VOID LookasideTest() 
{
	//初始化Lookaside对象
	PAGED_LOOKASIDE_LIST pageList;
	ExInitializePagedLookasideList(&pageList,NULL,NULL,0,sizeof(MYDATASTRUCT),'1234',0);

#define ARRAY_NUMBER 50
	PMYDATASTRUCT MyObjectArray[ARRAY_NUMBER];
	//模拟频繁申请内存
	for (int i=0;i<ARRAY_NUMBER;i++)
	{
		MyObjectArray[i] = (PMYDATASTRUCT)ExAllocateFromPagedLookasideList(&pageList);
	}

	//模拟频繁回收内存
	for (i=0;i<ARRAY_NUMBER;i++)
	{
		ExFreeToPagedLookasideList(&pageList,MyObjectArray[i]);
		MyObjectArray[i] = NULL;
	}

	ExDeletePagedLookasideList(&pageList);
	//删除Lookaside对象
}

内核内存操作函数

《Windows驱动开发技术详解》介绍了 RtlCopyMemory 等内核内存操作函数的用法,这方面可以参考 MSDN 的文档。

其中,P136 演示了如何用 Dependency Walker.exe 查看驱动依赖的PE - ntosknrl.exe 的导出函数,包括用到的导出函数和全部的导出函数。

Windows内核函数

这一章主要介绍内核中常用的函数,包括:

  • 字符串处理函数
  • 文件读写函数
  • 注册表读写函数

当然,这些都可以查 DDK 文档和 MSDN 文档。这些函数大部分都在 ntosknrl.exe 中,可以通过 Dependency Walker.exe 进行查看。

派遣函数

CH2.3 “从应用程序到驱动程序”一节已经介绍了这一连串过程:

graph LR
App --> win32子系统 
win32子系统 --> NativeAPI
NativeAPI --> 系统服务函数
系统服务函数 --> IO管理器
IO管理器 --> 驱动程序派遣函数
  1. 从 NativeAPI 到 “系统服务函数” 通过“软中断”实现了从用户模式内核模式的切换。
  2. IRP 是由 IO 管理器创建并发送给驱动程序的。
  3. 驱动程序的主要职责就是处理 IO 请求。
  4. 派遣函数实际上就是“请求-响应”模式中的响应函数

IRP与派遣函数

IRP

IRP - I/O Request Package,实际上是 Windows 内核中的一种数据结构(对象)。上层应用程序向操作系统发送 I/O 请求,操作系统(主要是 I/O 管理器)将 I/O 请求转换为 IRP 发送给对应的驱动程序,驱动程序根据 IRP 的类型分配给不同的派遣函数进行处理。

IRP 是一个很复杂的数据结构,其中 MajorFunction 和 MinoreFunction 是它的两个最基本的属性。

参考 HelloWDM 的 DriverEntry 的源码,可以发现 DriverObject 有一个函数指针数组 MajorFunction。通过设置这个数组,可以将 IRP 的类型与派遣函数管理起来。注意,MajorFunction 是函数指针数组

不同的 I/O 请求对应不同的 IRP 类型,本节通过一个表格列出了全部的 IRP 类型。
IRP 类型

派遣函数

P189 通过 ReadFile 函数的调用过程,详解了派遣函数是如何处理和返回应用程序发起的 I/O 请求的。

IRP垂直转发

IRP先发送到顶层 DeviceObject,如果它没有结束 IRP ,则会继续往下层设备对象转发。其中 IO_STACK_LOCATION 结构体会记录对应设备(本层)中的操作。显然,IRP 维护了一个 IO_STACK_LOCATION 结构体数组。

本节通过一个实验演示了如何打印 IRP 的信息,见源码 DispatchTest - NTDriver。

此外,也可以通过 IRPTrace.exe 跟踪 IRP 。

缓存区方式读写与直接读写

驱动程序创建的设备一般有三种读写方式:缓存区方式读写、直接读写和其他。其中,常用的是“缓存区方式读写”和“直接读写”,它们在创建设备(IoCreateDevice)后,由设备对象的 Flags 属性指定。

缓存区方式读写时,内核会复制用户态的 buffer 数据到 IRP 的子域 AssociatedIrp.SystemBuffer 中;而直接读写则不复制数据,而是直接锁定用户态的 buffer,防止进程切换,然后通过 MDL 记录这段 buffer,最后进行虚拟地址重映射。注意:这个 buffer 指的是 output buffer 。

MDL

直接方式读写设备时,操作系统会先重映射将用户模式下的缓存区锁住,并用 MDL 记录这段内存,然后到内核模式后,再进行虚拟地址重映射。

MDL 主要记录三个值:这段 buffer 的大小(mdl->ByteCount)、buffer 的首页地址(mdl->StartVa)和这段 buffer 在首页的偏移量(mdl->ByteOffset)。它们可以通过以下几个宏获得:

  • MmGetMdlByteCount
  • MmGetMdlVirtualAddress
  • MmGetMdlByteOffset
NTSTATUS HelloDDKRead(IN PDEVICE_OBJECT pDevObj,
								 IN PIRP pIrp) 
{
	KdPrint(("Enter HelloDDKRead\n"));

	PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
	NTSTATUS status = STATUS_SUCCESS;

 	PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);

 	ULONG ulReadLength = stack->Parameters.Read.Length;
	KdPrint(("ulReadLength:%d\n",ulReadLength));

	ULONG mdl_length = MmGetMdlByteCount(pIrp->MdlAddress);
	PVOID mdl_address = MmGetMdlVirtualAddress(pIrp->MdlAddress);
	ULONG mdl_offset = MmGetMdlByteOffset(pIrp->MdlAddress);
	
	KdPrint(("mdl_address:0X%08X\n",mdl_address));
	KdPrint(("mdl_length:%d\n",mdl_length));
	KdPrint(("mdl_offset:%d\n",mdl_offset));

	if (mdl_length!=ulReadLength)
	{
		//MDL的长度应该和读长度相等,否则该操作应该设为不成功
		pIrp->IoStatus.Information = 0;
		status = STATUS_UNSUCCESSFUL;
	}else
	{
		//用MmGetSystemAddressForMdlSafe得到MDL在内核模式下的映射
		PVOID kernel_address = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,NormalPagePriority);
		KdPrint(("kernel_address:0X%08X\n",kernel_address));
		memset(kernel_address,0XAA,ulReadLength);
		pIrp->IoStatus.Information = ulReadLength;	// bytes xfered
	}
	
	pIrp->IoStatus.Status = status;
	
	IoCompleteRequest( pIrp, IO_NO_INCREMENT );
	KdPrint(("Leave HelloDDKRead\n"));

	return status;
}

实验 MDL_Test 分别打印了应用模式下的 buffer 地址和内核模式下重映射后的 buffer 地址。要深入了解 MDL ,可以阅读 CH12.3 IRP 分解。

DeviceIoControl

DeviceIoControl 是一类特殊的 I/O 请求,它可以在一次 I/O 请求中同时完成设备的读写操作。同样地,它也有三种控制方式。

驱动程序的同步处理

本章介绍了驱动程序中常用的同步处理办法,并且将内核模式下的同步处理方法和用户模式下的同步处理方法做了比较。另外,本章还介绍了中断请求级、自旋锁等同步处理机制。

同步与异步

可重入与不可重入

函数的执行结果是否与执行顺序相关。

关于“同步和异步”,最好参考《Windows核心编程》CH10 异步设备I/O,它讲解的更加详细。

中断请求级别

IRQL

Windows 的中断分为“软件中断”和“硬件中断”,并统一映射了“中断请求级别”(IRQL)。同步机制很大程度上依赖于 IRQL 。

软件中断有 “int n” 汇编指令产生,而硬件中断,又称为“外部中断”,由外部设备发起,经“中断控制器”(PIC),发送给 CPU 。

通过“DeviceMgr” - 查看 - 依连接排序资源,可以查看系统中的各个中断。

以《我是歌手》这个综艺节目来类比“中断”和“中断请求级别”:
如果说舞台是CPU,主持人就是调度进程。首先是主持人上舞台发言,然后安排各位歌手(进程)上台演唱。
突然,(舞台大屏幕)插播一条广告,这个“插播”实际上就是“软件中断”,它实际上也是写在剧目中的,换句话说,“软件中断”(int n)也是写在程序中的。
当节目进行中,突然舞台大屏幕不亮了,这个时候,就需要暂停节目,请工作人员上舞台来维修大屏幕。“大屏幕坏了”实际上就是“硬件中断”。
如果在维修大屏幕的时候,不小心电路短路,舞台起火了,这个时候,就得暂停维修,然保安同志上来灭火。很显然,“舞台起火了”比“大屏幕坏了”的中断请求级别高。

在这里插入图片描述

用户模式的代码运行在最低的 PASSIVE_LEVEL 级别,而驱动程序的 DriverEntry, DispatchFunction, AddDevice 等一般也运行在 PASSIVE_LEVEL 级别,必要时它们可以提升到 DISPATCH_LEVEL 。

Windwos 负责线程调度的组件是运行在 DISPATCH_LEVEL 。

在内核模式下,可以通过 KeGetCurrentIrql 函数获取当前的 IRQL 。

IRQL与线程优先级

线程优先级是相对应用程序而言,而应用程序都运行在 PASSIVE_LEVEL 级别。

IRQL与内存分页

对于等于或高于 DISPATCH_LEVEL 级别的程序不能使用分页内存。驱动程序的 StartIO 例程、DPC 例程、中断服务例程都运行在 DISPATCH_LEVEL 或者更高的 IRQL 级别,它们都不能使用分页内存。

IRQL的提升与降低
header 1 header 2
KeGetCurrentIrql 获取当前 IRQL
KeRaiseIrql 提升当前 IRQL
KeLowerIrql 降低当前 IRQL

自旋锁

Spin Lock 用于内核同步,主要是各派遣函数之间的同步。

内核模式下的同步对象

这一节讲解了如何在内核模式下开线程和进行线程同步的。

用户模式同步对象

用户模式下的同步对象包括:Event(事件), Mutex(互斥体)和 Semaphone(信号灯)等,它们实际上是内核模式下的同步对象的封装。

关于用户模式下的同步,《Windows核心编程》一书讲解的更详细,其中还包括“关键区”(Critical Section)等非内核对象的同步方法。

内核模式下的同步对象

用户模式下,各个函数都是以句柄(handle,一个 32 位整数)操作同步对象的;而内核模式下,程序员可以获得真实的同步对象指针。每种同步对象在内核中都会对应一种结构体。

内核模式下的等待函数:

  • KeWaitForSingleObject
  • KeWaitForMultipleObjects
系统线程和用户线程

内核模式下用 PsCreateSystemThread 创建新线程,它既可以创建用户线程,也可以创建系统线程。

实验

这个实验并没有给出源码,但是可以直接按照书中代码,手敲,并加入 HelloDDK 工程(CH5)。

#pragma PAGEDCODE
VOID SystemThread(IN PVOID pContext)
{
	KdPrint(("Enter SystemThread\n"));
	PEPROCESS pEProcess = IoGetCurrentProcess();
	PTSTR ProcessName = (PTSTR)((ULONG)pEProcess + 0x174);
	KdPrint(("This thread run in %s process !\n", ProcessName));
	
	// 结束线程
	KdPrint(("Leave SystemThread\n"));
	PsTerminateSystemThread(STATUS_SUCCESS);
}

#pragma PAGEDCODE
VOID MyProcessThread(IN PVOID pContext)
{
	KdPrint(("Enter MyProcessThread\n"));
	PEPROCESS pEProcess = IoGetCurrentProcess();
	PTSTR ProcessName = (PTSTR)((ULONG)pEProcess + 0x174);
	KdPrint(("This thread run in %s process !\n", ProcessName));
	
	// 结束线程
	KdPrint(("Leave MyProcessThread\n"));
	PsTerminateSystemThread(STATUS_SUCCESS);
}

#pragma PAGEDCODE
VOID CreateThread_Test()
{
	HANDLE hSystemThread, hMyThread;
	
	// 创建进程线程
	NTSTATUS status = PsCreateSystemThread(&hSystemThread, 0, NULL, NULL, NULL, SystemThread, NULL);
	
	// 创建系统线程
	status = PsCreateSystemThread(&hMyThread, 0, NULL, NtCurrentProcess(), NULL, MyProcessThread, NULL);
}

需要注意的是,在 win7 32bit 系统中,并没有打印出正确的进程名。

应用程序传递事件给驱动程序
  • DeviceIoControl
  • ObReferenceObjectByHandle
  • ObDereferenceObjcet

它实际是通过 DeviceIoControl 将用户模式的句柄(索引)传到内核,然后通过 ObReferenceObjectByHandle 将句柄转换为内核对象(结构体)的指针。

需要指出的是,句柄是与进程相关的,也就意味着一个进程中的句柄只能在这个进程中有效。

驱动程序之间传递同步对象

创建命名同步对象

其他同步方法

主要是“自旋锁”及其变种。

IRP的同步

同步操作与异步操作的原理

这一块的内容,《Windows核心编程》CH10 讲解的更详细。

CreateFile 的第六个参数(属性)可以指定用“同步”或“异步”的方式访问设备。

同步方式访问时,ReadFile, WriteFile and DeviceIoControl 中的 OVERLAP 数据结构设为 NULL,而异步方式访问,需要设置这个结构体,在其中嵌入 Event 。

OVERLAP 又称为“重叠结构”,这个结构体中包含一个 event 句柄。这个 Event 由用户创建,并等待内核返回。

此外,使用 ReadFileEx 或 WriteFileEx 进行异步读写时,不需要设置 OVERLAP 的 Event 子域,但是要设置“完成例程”(也称“回调例程”Callback Function)。

APC

APC - Asynchronous Procedure Call (异步过程调用)。它需要设置 OVERLAP 的 Event 来实现异步操作,而是通过回调例程来实异步,类似于软件中断。

  • ReadFileEx
  • WriteFileEx

AsyncOperate2 的源码可以直接在 VS2017 win32 console 工程中运行。

IRP的同步完成与异步完成

用户模式对设备的同步和异步操作需要得到驱动程序的的支持。一般同步模式,是在 IRP 的派遣函数中直接处理相关请求,然后返回。而异步模式,则先在派遣函数中直接返回,后面再处理。

同步模式:

graph LR
ReadFile-->NtReadFile
NtReadFile-->ntoskrnl.exe
ntoskrnl.exe-->IRP
IRP-->DispatchFunction
DispatchFunction-->IoCompleteRequest

这一节非常详细地列出了“同步完成”和两种方式的“异步完成”的具体步骤,极其重要!

需要注意的是:APC 异步方式,IoCompleteRequest 会将完成函数(回调例程)插入 APC 队列。应用程序进入 Alert 模式,APC 队列会自动出队,并执行完成函数。

IRP 的同步完成就是在派遣函数中,调用 IoCompleteRequest 将 IRP 处理完毕。

IRP 被“异步完成”指的就是不在派遣函数中调用 IoCompleteRequest 内核函数。调用 IoCompleteRequest 函数意味着 IRP 请求的结束,也标志着本次对设备操作的结束。

这一节的实验“PendingIrpTest”非常重要,它演示了“异步操作”和“队列”,自定义设备扩展项,内核链表的使用等多项内核编程技术。

PMY_IRP_ENTRY my_irp_entry;
	while(!IsListEmpty(pDevExt->pIRPLinkListHead))
	{
		PLIST_ENTRY pEntry = RemoveHeadList(pDevExt->pIRPLinkListHead);
		my_irp_entry = CONTAINING_RECORD(pEntry,
                              MY_IRP_ENTRY,
                              ListEntry);
 		my_irp_entry->pIRP->IoStatus.Status = STATUS_SUCCESS;
 		my_irp_entry->pIRP->IoStatus.Information = 0;	// bytes xfered
 		IoCompleteRequest( my_irp_entry->pIRP, IO_NO_INCREMENT );
 
		ExFreePool(my_irp_entry);
	}

这个是链表释放的代码,其中最重要的有两步:首先从链表中取出 PLIST_ENTRY 指针,然后通过 CONTAINING_RECORD 推算出链表单元 MY_IRP_ENTRY 的首地址(指针)。

此外,我们可以扩展一下,在派遣函数中不仅是挂起 IRP,还可以开启一个新线程,在这个线程函数中,执行完一系列任务后,再调用 IoCompleteRequest 完成 IO 请求,最后切换到用户模式执行回调例程。

需要注意的是,回调例程还是和应用程序IO请求在一个线程中。

这个实验的驱动部分同前面的 NTDriver,拷贝 makefile 和 sources 后,使用 DDK build。而应用程序可以使用 VS 2017 build 。

CancelIRP

驱动程序可以在派遣函数中为当前 IRP 设置 CancelRoutine ,当应用程序调用 CancelIO 时,将触发 CancelRoutine。更详细的步骤是:应用程序 CancelIO 将调用内核的 IoCancelIrp,而 IoCancelIrp 将调用之前注册(设置)的 CancelRoutine,在 CancelRoutine 里需要设置 STATUS_CANCELLED 并调用 IoCompleteRequest,最后还要释放全局 cancel 自旋锁。

很明显,IoCancelIrp 会获取一个自旋锁,它将运行在 DISPATCH_LEVEL 级别。应该尽可能将 CancelRoutine 设计的简单。

有了 cancel 功能,一个异步队列的功能就完整了。

实验 CancelIRP 演示了如何取消 IO 请求,但是 CancelIO 函数的参数是设备句柄,而不是具体某次 IO 请求。

StartIO例程

StartIO 例程能够保证各个并发的 IRP 顺序执行,即串行化。

所谓的“串行化”,我们可以想象一下“开车进长江隧道”的情形。假设过江隧道只允许单向一条车道通行,而隧道口是四股车流,此时就需要竞争,将四股车流合成一股,这就是“串行化”。

StartIO 是 DDK 提供的一个内部队列,并支持 IRP 取消函数、超时、同步插入队列等操作。

typedef struct _KDEVICE_QUEUE {
    CSHORT Type;
    CSHORT Size;
    LIST_ENTRY DeviceListHead;
    KSPIN_LOCK Lock;
    BOOLEAN Busy;
} KDIVECE_QUEUE, *PKDEVICE_QUEUE;

实验 StartIOTest 演示 向 DriverObject 注册 StartIO 例程和派遣函数通过 IoStartPacket 将 IRP 加入队列。

从“生产者”和“消费者”模型来看,StartIO 就是消费者,派遣函数是生产者。但是,具体的“入队”和“出队”操作是操作系统完成的。

需要注意的是,已出队的 IRP (进入 StartIO)不能取消。也就是说,只能取消队列中的 IRP 。

参考书中的 IoStartPacket 和 IoStartNextPacket 的伪代码

// 获取自旋锁
...

device->CurrentIrp = Irp;

// 释放自旋锁
...

device->DriverObject->DriverStartIo(device, Irp);

也就是说,IoStartPacket 和 IoStartNextPacket 会重新指定 device->CurrentIrp,并调用 StartIo 例程。

需要注意的是,这段伪代码仅展示了其中的一条逻辑分支,即“设备空闲”,可以调用 StartIo 例程,而没有展示另一个分支“设备忙”,需要插入队列。

因为 StartIo 和 CancelIrp 都是允许在 DISPATCH_LEVEL,在执行具体逻辑前,需要先加锁(全局锁),并判断对方是否已执行同一个 IRP。

学习后面的“自定义StartIO”,可以更加深入地了解内核队列的使用细节。“自定义StartIO”与内置的 StartIO 不同的是,它不是回调函数,而是一个普通函数,直接被派遣函数调用。

KeInsertDeviceQueue 函数的返回值表明当前设备是否“空闲”。也就是说,在生产者这一端就会判断是否需要入队。

另外,在 StartIo 例程中,开启了一个循环执行出队操作。一定要在多线程环境下去理解“自定义StartIO”,派遣函数和“自定义StartIO”都在多线程环境下并发。“入队”和“出队”的内核 API 应该也内置了互斥机制。

中断服务例程

中断服务例程(ISR - Interupt Service Routine)

DPC例程

DPC例程一般和 ISR 配合使用,它运行于相对 ISR 较低的 DISPATCH_LEVEL 级别。因此,一般将不需要紧急处理的代码放在DPC例程,而将需要紧急处理的代码放在ISR中。

定时器

本章总结了在内核模式下的四种等待方法,读者可以利用这些方法灵活地用在自己的驱动程序中。最后本章还介绍了如何对irp的超时情况进行处理。

在驱动程序中有两种使用定时器(Timer)的方法,一种方法是使用IO定时器例程,另一种方法是使用DPC例程。

定时器的实现方法一

利用 DDK 内置的 IO Timer,实现 ms 级的定时器。

IRP超时处理

首先初始一个定时器对象和一个DPC对象,并将DPC例程与定时器对象进行关联。在每次对IRP操作前,开启定时器,并设置好一定的超时。如果在指定的时间内对IRP的处理没有结束,那么操作系统就会进入DPC例程。
在DPC例程中取消还在继续处理的IRP。如果驱动程序在超时前结束IRP的操作,则应该取消定时器,从而保证不会再次取消IRP。

实验“IRPTimeout”展示了 DPC例程和定时器的联合应用。这个实验比较简单,可以扩展“正常完成IRP则取消定时器”的功能。

驱动程序调驱动程序

本章主要介绍了分层驱动的概念。分层驱动可以将功能复杂的驱动程序分解为多个功能简单的驱动程序。多个分层的驱动程序形成一个设备堆栈,irp请求首先发送到设备堆栈的顶层,然后依次穿越每层的设备堆栈,最终完成irp请求。

本章将要介绍的是纯内核模式的编程,它可以很自由地跨越进程的边界。它也是前面所有章节知识的大揭秘,包括:同步与异步IRP 的实现原理、句柄与内核对象的转换方法、IRP 的实现原理等。

IRP介绍

以文件句柄形式调用

在内核模式下以文件句柄形式调用其他驱动程序,实际上就是将用户模式下发起 I/O 的相关 win32 API (NTDLL.dll) 替换为其对应的内核 API (ntoskrnl.exe)即可,包括:

win32 API kernel api IRP
CreateFile ZwCreateFile IRP_MJ_CREATE
CloseHandle ZwCloseFile IRP_MJ_CLOSE
ReadFile ZwReadFile IRP_MJ_READ
WriteFile ZwWriteFile IRP_MJ_WRITE

需要注意的是:CreateFile 需要被操作设备的“符号链接”,而 ZwCreateFile 则可以直接通过设备名拿到 ObjectAttributes 对象的指针。其他几个函数都是操作设备句柄。
此外,内核函数会直接发起相关的 IRP ,而用户模式是通过 I/O 管理器调用对应的内核函数发起相关的 IRP 。

实验1

源码是以 VC 工程给出的,查看相关的 *.dsp 文件可以发现,在链接器 link.exe 中指明了它的输出文件:

/out:"MyDriver_Check/HelloDDKA.sys"

我们在改为 DDK build 时,需要修改 sources 文件中的 TARGETNAME。

TARGETNAME=HelloDDKA
TARGETTYPE=DRIVER
TARGETPATH=OBJ

INCLUDES=$(BASEDIR)\inc;\
         $(BASEDIR)\inc\ddk;\

SOURCES=Driver.cpp\
异步调用
  1. 调用 ZwReadFile 前,先为 IRP 设置一个完成例程(APC)。
  2. 设置一个事件
  3. 在完成例程中激发事件。

参考实验2的代码,与用户模式的异步不同的是,这里即设置了“回调例程”,也设置了同步事件,而用户模式APC不需要设置事件,它需要的是调用 SleepEx,让当前线程处于 Alert 状态即可。

异步调用方法二

不另外再设置同步事件,而是直接使用文件对象的子域事件作为同步事件。

每打开一个设备,都会伴随存在一个关联的文件对象(FILE_OBJECT)。利用内核函数 ObReferenceObjectByHandle 可以获得和设备相关的文件对象指针。当 IRP_MJ_READ 请求被结束后,文件对象的子域 Event 会被设置,因此用文件对象的 Event 子域可以当做同步点。

通过符号链接打开设备

内核中一般通过设备名来打开设备,但是,也可以通过符号链接来打开设备。

利用 ZwOpenSymbolicLinkObject 内核函数先得到(符号链接)设备的句柄,然后使用 ZwQuerySymbolicLinkObject 内核函数查找到设备名。

参考 Test4 源码

	UNICODE_STRING DeviceSymbolicLinkName;
	RtlInitUnicodeString( &DeviceSymbolicLinkName, L"\\??\\HelloDDKA" );

	//初始化objectAttributes
	OBJECT_ATTRIBUTES objectAttributes;
	InitializeObjectAttributes(&objectAttributes, 
							&DeviceSymbolicLinkName,
							OBJ_CASE_INSENSITIVE|OBJ_KERNEL_HANDLE, 
							NULL, 
							NULL );

	HANDLE hSymbolic;
	//设定了FILE_SYNCHRONOUS_IO_NONALERT或者FILE_SYNCHRONOUS_IO_ALERT为同步打开设备
	ntStatus = ZwOpenSymbolicLinkObject(&hSymbolic,FILE_ALL_ACCESS,&objectAttributes);
#define UNICODE_SIZE 50
	UNICODE_STRING LinkTarget;
	LinkTarget.Buffer = (PWSTR)ExAllocatePool(PagedPool,UNICODE_SIZE);
	LinkTarget.Length = 0;
	LinkTarget.MaximumLength = UNICODE_SIZE;

	ULONG unicode_length;
	ntStatus = ZwQuerySymbolicLinkObject(hSymbolic,&LinkTarget,&unicode_length);

	KdPrint(("DriverB:The device name is %wZ\n",&LinkTarget));

	InitializeObjectAttributes(&objectAttributes, 
							&LinkTarget,
							OBJ_CASE_INSENSITIVE, 
							NULL, 
							NULL );
	
	HANDLE hDevice;
	IO_STATUS_BLOCK status_block;
	//设定了FILE_SYNCHRONOUS_IO_NONALERT或者FILE_SYNCHRONOUS_IO_ALERT为同步打开设备
	ntStatus = ZwCreateFile(&hDevice,
		FILE_READ_ATTRIBUTES|SYNCHRONIZE,
		&objectAttributes,
		&status_block,
		NULL,FILE_ATTRIBUTE_NORMAL,FILE_SHARE_READ,
		FILE_OPEN_IF,FILE_SYNCHRONOUS_IO_NONALERT,NULL,0);

	if (NT_SUCCESS(ntStatus))
	{
		ZwReadFile(hDevice,NULL,NULL,NULL,&status_block,NULL,0,NULL,NULL);
	}
	
	ZwClose(hDevice);
	ZwClose(hSymbolic);
	ExFreePool(LinkTarget.Buffer);

通过设备指针调用

以文件句柄形式调用驱动程序的方法与用户模式下的操作类似,而本节将介绍内核模式特有的直接通过“设备指针”调用的方法。

不借用 ZwCreateFile 和 ZwReadFile 等内核函数,而是手动构造各个 IRP,然后将 IRP 传递到相应驱动函数的派遣函数里。

IoGetDeviceObjectPointer

每个内核中的句柄都会和一个内核对象的指针联系起来。

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

IoGetDeviceObjectPointer 内核函数可以通过设备名直接获得“文件对象”指针和“设备对象”指针。

当调用 IoGetDeviceObjectPointer 内核函数后,设备对象的引用计数会加 1, 当用完这个设备对象后,应该调用 ObDereferenceObject 内核函数,使其引用计数减 1。

创建IRP

参考 创建IRP的四种方法

手动创建IRP的几个步骤:

  1. 先得到设备指针。一种方法是调用 IoGetDeviceObjectPointer;另一种方法是先调 ZwCreateFile 获得设备句柄,再调 ObReferenceObjectByHandle 内核函数通过设备句柄获得设备指针。
  2. 使用上面提到的4个内核函数创建IRP。
  3. 构造IRP的I/O堆栈
  4. 调用 IoCallDriver 内核函数,它内部会调用设备对象的派遣函数。

实验5

	UNICODE_STRING DeviceName;
	RtlInitUnicodeString( &DeviceName, L"\\Device\\MyDDKDeviceA" );

	PDEVICE_OBJECT DeviceObject = NULL;
	PFILE_OBJECT FileObject = NULL;
	//得到设备对象句柄,计数器加1
	//如果是第一次调用IoGetDeviceObjectPointer,会打开设备,相当于调用ZwCreateFile
	ntStatus = IoGetDeviceObjectPointer(&DeviceName,FILE_ALL_ACCESS,&FileObject,&DeviceObject);

	KdPrint(("DriverB:FileObject:%x\n",FileObject));
	KdPrint(("DriverB:DeviceObject:%x\n",DeviceObject));

	if (!NT_SUCCESS(ntStatus))
	{
		KdPrint(("DriverB:IoGetDeviceObjectPointer() 0x%x\n", ntStatus ));

		ntStatus = STATUS_UNSUCCESSFUL;
		// 完成IRP
		pIrp->IoStatus.Status = ntStatus;
		pIrp->IoStatus.Information = 0;	// bytes xfered
		IoCompleteRequest( pIrp, IO_NO_INCREMENT );
		KdPrint(("DriverB:Leave B HelloDDKRead\n"));

		return ntStatus;
	}

	KEVENT event;
	KeInitializeEvent(&event,NotificationEvent,FALSE);
	IO_STATUS_BLOCK status_block;
	LARGE_INTEGER offsert = RtlConvertLongToLargeInteger(0);

	//创建同步IRP
	PIRP pNewIrp = IoBuildSynchronousFsdRequest(IRP_MJ_READ,
												DeviceObject,
												NULL,0,
												&offsert,&event,&status_block);
 	KdPrint(("DriverB:pNewIrp:%x\n",pNewIrp));

	PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(pNewIrp);
	stack->FileObject = FileObject;

	//调用DriverA,会一直调用到DriverA的派遣函数
	NTSTATUS status = IoCallDriver(DeviceObject,pNewIrp);

    if (status == STATUS_PENDING) {

		//如果DriverA的派遣函数没有完成IRP,则等待IRP完成
       status = KeWaitForSingleObject(
                            &event,
                            Executive,
                            KernelMode,
                            FALSE, // Not alertable
                            NULL);
        status = status_block.Status;
    }

	//将引用计数减1,如果此时计数器减为0,
	//则将关闭设备,相当于调用ZwClose
 	ObDereferenceObject( FileObject );

实验七演示了最原始的 IoAllocateIrp 内核函数的使用方法和手动构造 IRP 的方法,原书代码会BSOD,可以参考CSDN博客

所有对设备的操作都会转化为一个 IRP ,而所有的 IRP 最终都是由 IoAllocateIrp 内核函数创建的。

PIRP IoAllocateIrp(
  CCHAR   StackSize,
  BOOLEAN ChargeQuota
);

使用 IoAllocateIrp 创建了 IRP 后,需要使用 IoFreeIrp 释放相关的数据结构

	PDEVICE_OBJECT DeviceObject = NULL;
	PFILE_OBJECT FileObject = NULL;
	//通过设备名得到设备对象指针
	ntStatus = IoGetDeviceObjectPointer(&DeviceName,FILE_ALL_ACCESS,&FileObject,&DeviceObject);

	KdPrint(("DriverB:FileObject:%x\n",FileObject));
	KdPrint(("DriverB:DeviceObject:%x\n",DeviceObject));

	if (!NT_SUCCESS(ntStatus))
	{
		KdPrint(("DriverB:IoGetDeviceObjectPointer() 0x%x\n", ntStatus ));
		ntStatus = STATUS_UNSUCCESSFUL;
		// 完成IRP
		pIrp->IoStatus.Status = ntStatus;
		pIrp->IoStatus.Information = 0;	// bytes xfered
		IoCompleteRequest( pIrp, IO_NO_INCREMENT );
		KdPrint(("DriverB:Leave B HelloDDKRead\n"));

		return ntStatus;
	}

	KEVENT event;
	KeInitializeEvent(&event,NotificationEvent,FALSE);

	PIRP pNewIrp = IoAllocateIrp(DeviceObject->StackSize,FALSE);
	KdPrint(("pNewIrp->UserEvent :%x\n",pNewIrp->UserEvent));
	pNewIrp->UserEvent = &event;

	IO_STATUS_BLOCK status_block;
    pNewIrp->UserIosb = &status_block;
    pNewIrp->Tail.Overlay.Thread = PsGetCurrentThread();

	//因为DriverA是BUFFER IO设备
	pNewIrp->AssociatedIrp.SystemBuffer = NULL;
	
 	KdPrint(("DriverB:pNewIrp:%x\n",pNewIrp));

    // 构造I/O堆栈
	PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(pNewIrp);
	stack->MajorFunction = IRP_MJ_READ;
	stack->MinorFunction=IRP_MN_NORMAL;//0
	stack->FileObject = FileObject;

	//调用DriverA驱动
	NTSTATUS status = IoCallDriver(DeviceObject,pNewIrp);

    if (status == STATUS_PENDING) {
       status = KeWaitForSingleObject(
                            &event,
                            Executive,
                            KernelMode,
                            FALSE, // Not alertable
							NULL);
	   KdPrint(("STATUS_PENDING\n"));
    }

 	ObDereferenceObject( FileObject );
	IoFreeIrp(pNewIrp);

上述代码在实验中,发现会导致 BSOD,注释 IoFreeIrp 这一句后才解决这个问题。在学习完“分层驱动”之后,我们知道,一当调用了 IoCallDriver 后,IRP 的控制权就转交给了被调驱动程序,此后,只有在完成例程中才能拿回控制权。否则,在调用 IoCallDriver 后再设置 IRP,则会引起奔溃。参考 12.1 完成例程。

获取设备指针的方法

这一节深入介绍了 Windows 内部如何获得设备对象指针,它实现了自定义的 IoGetDeviceObjectPointer 函数。

//模拟IoGetDeviceObjectPointer实现
NTSTATUS
MyIoGetDeviceObjectPointer(
    IN PUNICODE_STRING ObjectName,
    IN ACCESS_MASK DesiredAccess,
    OUT PFILE_OBJECT *FileObject,
    OUT PDEVICE_OBJECT *DeviceObject
    )
{
    PFILE_OBJECT fileObject;
    OBJECT_ATTRIBUTES objectAttributes;
    HANDLE fileHandle;
    IO_STATUS_BLOCK ioStatus;
    NTSTATUS status;

	//设置要打开的设备的设备名
    InitializeObjectAttributes( &objectAttributes,
                                ObjectName,
                                OBJ_KERNEL_HANDLE,
                                (HANDLE) NULL,
                                (PSECURITY_DESCRIPTOR) NULL );

	//ZwOpenFile打开设备,获得文件对象句柄
    status = ZwOpenFile( &fileHandle,
                        DesiredAccess,
                        &objectAttributes,
                        &ioStatus,
                        0,
                        FILE_NON_DIRECTORY_FILE );

    if (NT_SUCCESS( status )) 
	{
		//通过文件对象句柄,得到文件对象指针
        status = ObReferenceObjectByHandle( fileHandle,
                                            0,
                                            *IoFileObjectType,
                                            KernelMode,
                                            (PVOID *) &fileObject,
                                            NULL );
        if (NT_SUCCESS( status )) 
		{
            *FileObject = fileObject;
			//通过文件对象指针,得到设备对象指针
            *DeviceObject = IoGetBaseFileSystemDeviceObject( fileObject );
        }
        ZwClose( fileHandle );
    }
    return status;
}

Troubleshooting: MULTIPLE_IRP_COMPLETE_REQUESTS

分层驱动程序

本章主要介绍了分层驱动的概念。分层驱动可以将功能复杂的驱动程序分解为多个功能简单的驱动程序。多个分层的驱动程序形成一个设备堆栈,irp请求首先发送到设备堆栈的顶层,然后依次穿越每层的设备堆栈,最终完成irp请求。

分层驱动程序概念

分层驱动程序对应多个驱动程序,每个驱动程序创建一个设备对象,然后设备对象会一层一层地“挂载”在其他设备对象之上。

设备对象

DEVICE_OBJECT

从设备对象的数据结构从设计上支持分层驱动程序。

Introduction to device objects

设备堆栈与挂载
PDEVICE_OBJECT IoAttachDeviceToDeviceStack(
  PDEVICE_OBJECT SourceDevice,
  PDEVICE_OBJECT TargetDevice
);

注意:IoDetachDevice 只有一个参数——TargetDevice,它是被删除设备的下层设备。

IO堆栈

在 Windows 驱动模型中,还有一个概念叫“I/O堆栈”,用 IO_STACK_LOCATION 数据结构表示。它和设备堆栈紧密结合。

在 IRP 的数据结构中,存储着一个 IO_STACK_LOCATION 数组的指针。调用 IoAllocateIrp 内核函数创建 IRP 时,有一个 StackSize 参数,该参数就是 IO_STACK_LOCATION 数组的大小。

关于 IO_STACK_LOCATION 数组,还可以参考 CH7.1 节。

IRP 每穿越一次设备堆栈,就会用 IO_STACK_LOCATION 记录下本次操作的某些属性。

在这里插入图片描述

向下转发IRP

顶层驱动的设备对象收到 IRP,进入派遣函数后有三种处理方式:

  1. 调用 IoCompleteRequest 直接结束 IRP
  2. 调用 StartIo 串行化 IRP,除当前 IRP 外,其他的进入 IRP 队列
  3. 向下转发 IRP

向下转发 IRP 的时候,需要注意 IO_STACK_LOCATION 的变化

跳过当前层

PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;

// 调用下层驱动
IoSkipCurrentIrpStackLocation(pIrp);

ntStatus = IoCallDriver(pdx->TargetDevice, pIrp);

当前层参与操作

PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;

// 调用下层驱动
IoCopyCurrentIrpStackLocationToNext(pIrp);

ntStatus = IoCallDriver(pdx->TargetDevice, pIrp);
实验一

实验1 演示了如何将 DriverA 的设备对象挂载到 DriverB 上,同时转发对应的 IRP 。

在这里插入图片描述

从日志中可以看出,DriverB hook 了 DriverA 的 CreateFile, ReadFile and CloseFile 三个 I/O 接口。此外,需要注意 DriverA 是异步完成,而 DriverB 也继承了这种异步特性,但实际应用层测试函数采用的是同步读。

可以通过 DeviceTree.exe 查看它们之间的挂载关系。

在这里插入图片描述

实验二

实验2 剖析了DeviceTree.exe枚举设备对象的原理。

完成例程

关于“完成例程”,可以参考 CH9 IRP的同步,其中“异步操作设备方式二”使用的就是“完成例程”。本节主要学习分层驱动程序中,“完成例程”是如何在各层中传递的。

本节主要介绍 IRP 的控制权流转。

完成例程概念

当驱动程序通过 IoCallDriver 调用自己的下层驱动或其他驱动时,会将 IRP 的控制权交给被调的驱动。
有两种情况:一是被调用的设备是同步完成这个 IRP,那么,在 IoCallDriver 返回时,IRP 即完成。二是被调用的设备是异步完成这个 IRP,那么,IoCallDriver 会立即返回,但 IRP 并没有真正完成。
对于第二种情况,可以在调用 IoCallDriver 前,向 IRP 注册一个“完成例程”。当 IRP 真正完成的时,这个“完成例程”即会被调用。其实注册 IRP 完成例程就是在当前堆栈(IO_STACK_LOCATION)中的 CompletionRoutine 子域。IRP 完成后,一层层堆栈向上弹出,它会同步检查这个子域,如果非空则会调用完成例程。另外,传进完成例程的就是 IO_STACK_LOCATION 的另一个子域 Context 。

IoSetCompletionRoutine

当前驱动层设置下层驱动的完成例程

完成例程可以作为通知 IRP 完成的一个标志。在完成例程中可以很清楚地知道 IRP 的完成状况。或者说,在完成例程中,提供了一个机会重新获得 IRP 的控制权。

传播 Pending 位

设置了完成例程会,传播 Pending 位的工作需要程序员在代码中指定。

实验3演示了完成例程和传播 Pending 位。

实验4演示了完成例程返回 STATUS_MORE_PROCESSING_REQUIRED,让本层驱动重新获得 IRP 控制权。

将irp分解成多个irp

利用前面所说的完成例程返回 STATUS_MORE_PROCESSING_REQUIRED,重新获取 IRP 的控制权,继续向下转发 IRP。或者说,它改变了 IRP 沿着栈直下直上的流动方式。

IRP 分解可以想象一下少林寺挑水和尚去古井取水的情形:
和尚挑水用的是大牌桶(外层 IRP),比井口大,不能放到井里去直接取水。于是,可以用一个小桶(中间层 IRP)去井里取水,夺取几次,一起存在大牌桶挑回寺庙。

实验5演示了 IRP 的分解和借助 MDL 实现直接读写。这个实验的应用模式测试程序、DriverA 和 DriverB 都需要重新编译。其中 DriverA 的 MDL 不是典型用法,典型用法可以参考 CH7 的 MDL_Test 实验。

MDL实现内核模式下大缓存分片

初步认识MDL

The IoBuildPartialMdl routine builds a new memory descriptor list (MDL) that represents part of a buffer that is described by an existing MDL.

在这里插入图片描述

上面这份日志的执行流不再是直下直上。

wdm驱动程序架构

WDM 驱动与 NT 驱动最大的不同是引入了 PNP 机制。

IRP_MJ_PNP 与其他的 IRP 不同的是,它不是来自于应用程序,而是当设备被插入、拔出或被系统加载时,由 I/O 管理器向驱动程序发送 IRP_MJ_PNP。随后,它将被转发给底层设备对象,由底层总线驱动去处理。这样的设计,使得底层的修改不影响上层的逻辑驱动。

PDO

PDO - Physical Device Object,是由微软提供的总线驱动程序所创建,它完成了 PnP 和电源管理等功能。在 Windows 中有多个总线驱动程序,分别是 PCI 总线驱动程序,USB 总线驱动程序,ISA 总线驱动程序和虚拟总线驱动程序等。其中,PCI 总线是 ROOT 总线。

当系统启动时,根总线驱动被加载,然后寻找挂载于根总线上的驱动设备。如果发现有 PCI 设备,就会加载 PCI 设备的 PDO,然后寻找合适的 FDO 加载。PCI-ISA 桥、USB 适配器会当作是 PCI 设备,被加载 PDO 和 FDO 等。

PDO and PnP

在 PnP 之前的的驱动模型中,某个外设的系统资源都是固定的,包括:I/O 地址空间、物理地址空间、中断号等资源。而 PnP 实现了这些资源的动态分配。

即插即用

Plug and Play功能指的是通过操作系统协调自动分配设备上的资源,例如:中断号、I/O 地址、DMA通道、设备物理内存等。

PnP相关组件

在这里插入图片描述

注意:注册表维护已安装的硬件和即插即用设备软件的数据库。注册表的内容帮助驱动程序和其他组件识别和定位设备使用的资源。

即插即用IRP

转发给底层驱动,处理各种 PnP IRP 子功能请求。表13-1 列出了各个子功能代码。

WDM 驱动与 NT 驱动最大的不同是引入了 PNP 机制。

IRP_MJ_PNP 与其他的 IRP 不同的是,它不是来自于应用程序,而是当设备被插入、拔出或被系统加载时,由 I/O 管理器向驱动程序发送 IRP_MJ_PNP。随后,它将被转发给底层设备对象,由底层总线驱动去处理。这样的设计,使得底层的修改不影响上层的逻辑驱动。

通过设备接口寻找设备

在 WDM 驱动程序中,一般都是通过设备接口来定位一个驱动程序。同时,为了兼容 NT 驱动程序,也可以使用设备名和符号链接来定位设备。

设备接口

设备接口是一组全局标识(GUID),由 128 位的数字组成,并能保证在全球范围内不冲突。

引入设备接口主要是避免设备名冲突。

IoRegisterDeviceInterface 内核函数负责注册设备接口。

应用程序寻找设备接口

在应用程序寻找设备,是通过设备接口和设备号决定的。这里的设备号是指具有相同驱动程序的设备的编号。

HANDLE GetDeviceViaInterface( GUID* pGuid, DWORD instance)
{
	// Get handle to relevant device information set
	HDEVINFO info = SetupDiGetClassDevs(pGuid, NULL, NULL, DIGCF_PRESENT | DIGCF_INTERFACEDEVICE);
	if(info==INVALID_HANDLE_VALUE)
	{
		printf("No HDEVINFO available for this GUID\n");
		return NULL;
	}

	// Get interface data for the requested instance
	SP_INTERFACE_DEVICE_DATA ifdata;
	ifdata.cbSize = sizeof(ifdata);
	if(!SetupDiEnumDeviceInterfaces(info, NULL, pGuid, instance, &ifdata))
	{
		printf("No SP_INTERFACE_DEVICE_DATA available for this GUID instance\n");
		SetupDiDestroyDeviceInfoList(info);
		return NULL;
	}

	// Get size of symbolic link name
	DWORD ReqLen;
	SetupDiGetDeviceInterfaceDetail(info, &ifdata, NULL, 0, &ReqLen, NULL);
	PSP_INTERFACE_DEVICE_DETAIL_DATA ifDetail = (PSP_INTERFACE_DEVICE_DETAIL_DATA)(new char[ReqLen]);
	if( ifDetail==NULL)
	{
		SetupDiDestroyDeviceInfoList(info);
		return NULL;
	}

	// Get symbolic link name
	ifDetail->cbSize = sizeof(SP_INTERFACE_DEVICE_DETAIL_DATA);
	if( !SetupDiGetDeviceInterfaceDetail(info, &ifdata, ifDetail, ReqLen, NULL, NULL))
	{
		SetupDiDestroyDeviceInfoList(info);
		delete ifDetail;
		return NULL;
	}

	printf("Symbolic link is %s\n",ifDetail->DevicePath);
	// Open file
	HANDLE rv = CreateFile( ifDetail->DevicePath, 
		GENERIC_READ | GENERIC_WRITE,
		FILE_SHARE_READ | FILE_SHARE_WRITE,
		NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
	if( rv==INVALID_HANDLE_VALUE) rv = NULL;

	delete ifDetail;
	SetupDiDestroyDeviceInfoList(info);
	return rv;
}
查看设备接口

通过 WinObj 或 DeviceTree 可以查看设备接口。

实验一

本章的实验一演示了 WDM 驱动程序的 PnP 功能,其中包含了一个测试程序和一个驱动程序。

测试程序

可以新建 WIN32 CONSOLE 工程,将这个测试程序嵌入,需要注意的是,guid.h 和 Ioctls.h 两个文件都位于驱动程序中,也就是说,为了统一维护,复用了驱动程序的声明文件。前者定义的是设备接口 GUID,后者是 I/O 控制码。

这个新建的 WIN32 CONSOLE 工程一开始编译的时候会报链接错误:LINK2001 和 LINK1120:无法解析的外部符号。

参考网上博客可知,LINK1120 表示:

首先说这是一个链接错误而不是编译错误,造成这种问题的根本原因就是找得到函数的声明,但是找不到函数的实现,这是最根本的,具体的表现形式有很多.

在看具体报错的几个导出 API ,都是驱动相关的函数。很明显,这些函数的头文件可以找到,但是没有包含他们的库文件。

setupdigetclassdevs link error 这个帖子告诉我们,需要将 setupapi.lib 这个库文件添加到附加依赖项中。

驱动程序

驱动程序可以复用 CH1 或 CH3 中的 HelloWDM 工程的 makefile 和 Sources 文件。

比较本章的驱动程序和 CH1 中的驱动程序可以发现:

  1. 它们的 INF 文件相同,都包含 GUID
  2. 头文件中的设备扩展不同,本章没有符号链接和设备名,而代替为设备接口(interfaceName)
  3. cpp 中主要是增加了 GUID 和 PnP 子 IRP 号。

可以观察 GUID 到 interfaceName 的注册过程。

在这里插入图片描述

日志中高亮的部分就是 interfaceName 。它的各个部分以井号分割,含义大概是:

header 1 header 2
\?\ROOT 设备挂载路径,直接挂载 ROOT 总线上
UNKNOWN DeviceName,未设(NULL)
0000 设备号
{…} GUID

另外,在应用程序中打印显示“符号链接”为“\”.

再论IRP

本章将相关irp的操作做了进一步的总结。首先是转发irp,归纳了几种不同的方式。其次总结了创建irp的几种不同方法。创建irp总的来说分为创建同步irp和创建异步irp。对于创建同步irp,操作比较简单,i/o管理器会负责回收irp的相关内存,但是使用不够灵活。对于创建异步irp,操作比较复杂,程序员需要自己负责对irp及相关内存回收,但使用十分灵活。

本章是对 CH11 “驱动程序调用驱动程序” 和 CH12 “分层驱动程序” 两章的 IRP 相关内容的总结。其中,创建 IRP 部分可以结合 CH11 来看,而转发 IRP 部分需要结合 CH12 来看。

转发IRP

转发 IRP 分为几种情况:直接转发、转发并等待、转发并设置“完成例程”。此外,还可以挂起 IRP ,并设置 StartIo 例程和直接完成 IRP。

这些在前面的章节中都已讲解过了,这一章把它们归集到一起来回顾。其中,转发和完成例程,可以参考 CH12 的几个实验,而 StartIo 可以参考 CH9 。

创建IRP

创建 IRP 分为创建同步 IRP 和创建异步 IRP。

同步 IRP 在调用 IoCallDriver 后,会一直阻塞直到 IRP 完成。异步 IRP 可以设置完成例程,在完成例程中获取 IRP 的完成情况。

创建同步IRP

IoBuildDeviceIoControlRequest 创建同步 IRP 。

参考 CH11 实验5

创建有超时的 IOCTL IRP

这个的设计机制比较巧妙,在前面的章节并没有相关的内容,主要用于同步IRP。因为同步IRP 需要等到 IoCallDriver 返回才继续执行后面的代码,设计超时机制,就非常重要了。

NTSTATUS MakeSynchronousIoctlWithTimeout(
    IN PDEVICE_OBJECT TopOfDeviceStack,
	IN ULONG IoctlControlCode,
	PVOID InputBuffer,
	ULONG InputBufferLength,
	PVOID OutputBuffer,
	ULONG OutputBufferLength,
	IN ULONG Milliseconds	
	) 
{
	NTSTATUS status;
	PIRP irp;
	KEVENT event;
	IO_STATUS_BLOCK ioStatus;
	LARGE_INTEGER dueTime;
	IRPLOCK lock;
	
	// 初始化同步事件
	KeInitializeEvent(&event,NotificationEvent,FALSE);
	
	//创建同步IRP
	irp = IoBuildDeviceIoControlRequest(
	    IoctlControlCode,
		TopOfDeviceStack,
		InputBuffer,
		InputBufferLength,
		OutputBuffer,
		OutputBufferLength,
		FALSE,    // External ioctl
	    &event,
		&ioStatus);
		
	// 判断 IRP 是否为空
	
	if ( irp == NULL) {
	    return STATUS_INSUFFICIENT_RESOURCES;
	}
	
	lock = IRPLOCK_CANCELABLE;
	
	// 设置完成例程
	IoSetCompletionRoutine(
	    irp,
		MakeSynchronousIoctlWithTimeoutCompletion,
		&lock,
		TRUE,
		TRUE,
		TRUE
		);
		
	// 调用底层驱动
	status = IoCallDriver(TopOfDeviceStack,irp);
	
	// 判断IRP是否被挂起
	if (status == STATUS_PENDING) {
	    // 定义延时 1 ms
		dueTime.QuadPart = -10000 * Milliseconds;

		//如果DriverA的派遣函数没有完成IRP,则等待IRP完成
       status = KeWaitForSingleObject(
                            &event,
                            Executive,
                            KernelMode,
                            FALSE, // Not alertable
                            &dueTime
							);
							
		// 如果是超时
		if (status == STATUS_TIMEOUT) {
		    if (InterlockedExchange((PVOID)&lock, IRPLOCK_CANCEL_STARTED)
			    == IRPLOCK_CANCELABLE) {
				// 取消 IRP
				IoCancelIrp(irp);
				
				if (InterlockedExchange((PVOID)&lock, IRPLOCK_CANCEL_COMPLETE)
			    == IRPLOCK_COMPLETED) {
				    // 结束 IRP
				    IoCompleteRequest(irp, IO_NO_INCREMENT);
				}
			}
			
			// 等待同步事件
			KeWaitForSingleObject(
                            &event,
                            Executive,
                            KernelMode,
                            FALSE, // Not alertable
                            NULL
							);
							
			// 设置IRP 完成状态
			ioStatus.Status = status;    // return STATUS_TIMEOUT
		} else {
		    status = ioStatus.Status;
		}
		
	}
	
	return status;
}

NTSTATUS MakeSynchronousIoctlWithTimeoutCompletion(
    IN PDEVICE_OBJECT DeviceObject,
    IN PIRP Irp,
	IN PVOID Context
	) 
{
    PLONG lock;
	lock = (PLONG)Context;
	if (InterlockedExchange((PVOID)&lock, IRPLOCK_CANCEL_COMPLETE)
	    == IRPLOCK_STARTED) {
		return STATUR_MORE_PROCESSING_REQUIRED;
	}
	
	return STATUS_CONTINUE_COMPLETION;
}

IoBuildAsynchronousFsdRequest 创建异步 IRP ,并在完成例程中释放内存资源。

IoAllocateIrp 也是创建异步 IRP 。

过滤驱动程序

本章主要介绍wdm和nt式过滤驱动程序开发。过滤驱动程序开发十分灵活,可以修改已有驱动程序的功能,也可以对数据进行过滤加密。另外,利用过滤驱动程序还能编写出很多具有相当功能强大的程序来。

过滤驱动程序分为两类:

header 1 header 2
高层过滤驱动程序 High FiDo,挂载在 FDO 之上
低层过滤驱动程序 Low FiDO,挂载在 PDO 之上,介于 FDO 与 PDO 之间

文件过滤驱动程序

文件过滤驱动程序将自己挂载在磁盘驱动之上,拦截全部发往磁盘驱动的IRP,并有选择地过滤这些IRP。

过滤驱动程序的模型

在这里插入图片描述

为了让编写的过滤驱动能让 U 盘变为只读状态,可以在 DISK.sys 和 USBSTOR.sys 之间建立一个过滤驱动。

过滤驱动程序的入口函数

需要在入口函数中指定需要过滤的IRP 的派遣函数。

U盘过滤驱动程序

在这里插入图片描述

FileFilter实验

复用 CH1 HelloWDM 的 makefile 和 sources 文件,修改 TargetName,如下,参考 CH3 ,Sources 文件新行前用的是 tab,不是空格

TARGETNAME=MyFilter
TARGETTYPE=DRIVER
DRIVERTYPE=WDM
TARGETPATH=OBJ

INCLUDES=$(BASEDIR)\inc;\
         $(BASEDIR)\inc\ddk;\

SOURCES=DriverEntry.cpp	\
	stddcls.cpp

一开始会报编译错误,C4335,参考compiling error c4335 修改对应的文件格式,即可。

然后会报一个编译宏的错误,主要是文件格式转换导致宏定义换行时,增加了空行,使得换行符失效,删除空行即可。

后续还有一些相关的编译错误,于是重新编辑 stddcls.h 文件如下:

// stddcls.h -- Precompiled headers for WDM drivers
// Copyright (C) 1999 by Walter Oney
// All rights reserved

#ifdef __cplusplus
extern "C"
{
#endif

#pragma warning(disable:4201)	// nameless struct/union
#define DEPRECATE_DDK_FUNCTIONS 1

#include <wdm.h>
#include <stdio.h>
#ifdef __cplusplus
}
#endif 


#define PAGEDCODE code_seg("PAGE")
#define LOCKEDCODE code_seg()
#define INITCODE code_seg("INIT")

#define PAGEDDATA data_seg("PAGE")
#define LOCKEDDATA data_seg()
#define INITDATA data_seg("INIT")

#define arraysize(p) (sizeof(p)/sizeof((p)[0]))
磁盘命令过滤

磁盘命令过滤的关键是拦截 IRP_MJ_SCSI,它是 IRP_INTERNAL_DEVICE_CONTROL 的一个别名。在 DISK.sys 和 USBSTOR.sys 之间传递的是标准的 SCSI 指令。

在 IRP_MJ_SCSI 的派遣函数中,在将该 IRP 发给底层前,先设置完成例程,在完成例程中修改底层驱动的所做的处理。

nt式过滤驱动程序

上一节介绍的是 WDM 过滤驱动,安装它时需要修改注册表。本机介绍的 NT 式过滤驱动,无须修改注册表。它通过驱动名直接寻找到需要过滤的驱动设备的指针,然后将自己挂载在上面。

键盘驱动设备对象

通过 WinObj.exe 可以查看键盘驱动的设备对象为 Device\KeyboardClass0

ctrl2cap

实验 ctrl2cap 是资深 Windows 内核专家 Mark Russionovich 编写的一个键盘过滤驱动程序。它也是在完成例程中记录 IRP_MJ_READ 获取的值,也可以修改这个值。

需要注意的是,在进行这个实验的时候,我又踩了一次坑,大致过程如下:

我一开始是通过“远程桌面”连接到测试机,可以观察到 ctrl2cap 驱动已挂载成功,但是,在 txt 中测试打字,debugview 未捕获到任何键盘记录。
然后,我在每个“派遣函数”中增加入口打印信息,debugview 还是没有捕获任何派遣函数调用的记录。
通过 winobj.exe 观察发现,键盘设备对象除了 KeyboardClass0 ,还有一个类似的 KeyboardClass1,于是切换了 device name,再次实验,依然没有记录。
这个问题卡了一个多星期,某天突然想到了,是不是 Remote Desktop 并没有真正通过键盘与 OS 交互?
于是,重新实验,不使用 Remote Desktop,而是使用 VMWare 客户端直接登录。这次,能够看到键盘的敲击记录啦。

在这里插入图片描述

高级调试技巧

本章将介绍一些windows开发驱动的高级调试技巧。主要是使用 WinDbg.exe 进行 dump 文件调试和双机内核调试。这方面的资料网上比较多,建议遇到问题直接 google 。