Windows内核 WDM驱动程序的基本结构和实例
WDM驱动的基本结构:
WDM驱动模型是建立在NT式驱动程序模型基础之上的。对于WDM驱动程序来说,一般都是基于分层的,即完成一个设备的操作,至少要由两个驱动设备共同完成。
1)物理设备对象和功能设备对象
物理设备对象(Physical Device Object,PDO)和功能设备对象(Function Device Object,FDO)的关系是“附加”与“被附加”的关系。
当PC插入某个设备时,PDO会自动创建。确切的说,是由总线驱动创建的。PDO不能单独操作设备,需要配合FDO一起使用。系统会提示检测到新设备,要求安装驱动程序。需要安装的驱动程序就是WDM程序,此驱动程序负责创建FDO,并且附加到PDO上。
当一个FDO附加到PDO上时,PDO设备对象的子域AttachedDevice会记录FDO的位置。PDO被称作底层驱动或下层驱动,而FDO被称作高层驱动或上层驱动。
复杂一点的情况是,在FDO和PDO之间还会存在过滤驱动。在FDO上面的过滤驱动被称作上层过滤驱动,在FDO下层的驱动被称作下层过滤驱动。每个设备对象中,有个StackSize子域,表明操作这个设备对象需要几层才能到达最下层的物理设备。
过滤驱动可以嵌套,即可以有很多个高层过滤驱动或多个底层过滤驱动。过滤驱动不是必须的,在WDM模型中PDO和FDO是必须的。
NT设备是被动装入的,例如当有设备插入PC时,系统不会有提示,用户需要自己指定加载何种驱动;而WDM驱动则不然,当插入设备时,系统会自动创建出PDO,并提示请求用户安装FDO。
2)WDM驱动的入口程序
和NT驱动一样也是DriverEntry,但是初始化作用被分散到其他例程中。如创建设备对象的工作被放在了AddService例程中,同时,在DriverEntry中,需要设置对IRP_MJ_PNP处理的派遣函数。
WDM驱动的DriverEntry和NT式驱动的DriverEntry有以下几点不同:
(1)增加了对AddService函数的设置;AddService例程负责创建FDO,并附加到PDO上。
(2)创建设备对象的操作不在这个函数中了,而转到了AddService例程中。
(3)必须加入IRP_MJ_PNP的派遣回调函数。IRP_MJ_PNP主要是负责计算机中即插即用的处理。
3)WDM驱动的AddService例程
AddService例程是WDM独有的。在DriverEntry中需设置AddService例程的函数地址,设置的方式是驱动对象中有个DriverExtension子域,DriverExtension中有个AddService子域,将该子域指向AddService例程的函数地址即可。
和DriverEntry不同,AddService例程的名字可以任意命名:
DRIVER_ADD_DEVICE AddDevice;
NTSTATUS AddDevice(
__in struct _DRIVER_OBJECT *DriverObject, //驱动对象(I/O管理器创建的)
__in struct _DEVICE_OBJECT *PhysicalDeviceObject //设备对象(底层总线驱动创建的PDO设备对象)
//传入该参数的目的是将FDO附加在PDO上
)
{ ... }
AddService函数的功能可分为:
(1)通过IoCreateDevice等函数,创建设备对象,该设备对象就是FDO。
(2)创建FDO后,需要将FDO的地址保存下来,保存的位置是在设备扩展中。
(3)驱动程序将创建的FDO附加到PDO上,附加这个动作是利用函数IoAttachDeviceToDeviceStack实现的:
PDEVICE_OBJECT IoAttachDeviceToDeviceStack(
__in PDEVICE_OBJECT SourceDevice, //要附加到其他设备上的设备,
//将FDO附加在PDO之上时,此处即填FDO的地址
__in PDEVICE_OBJECT TargetDevice //被附加的设备,将FDO直接附加在PDO上时
//(不考虑过滤驱动),此处即时PDO的地址
);
返回值:附加后,返回附加设备的下层设备。如果中间没有过滤驱动,返回值就是PDO;如果中间有过滤驱动,返回的是过滤驱动。
当FDO附加到PDO上时,PDO会通过AttachedDevice子域知道它上层的设备是FDO(或过滤驱动);而FDO无法知道下层是什么设备,因此,需要通过设备扩展设置FDO下层的设备,例如:
//设备扩展结构体
typedef struct _DEVICE_EXTENSION
{
PDEVICE_OBJECT fdo; //功能设备对象FDO
PDEVICE_OBJECT NextStackDevice; //FDO的下层驱动设备
UNICODE_STRING ustrDeviceName; //设备名
UNICODE_STRING ustrSymLinkName; //符号链接
}DEVICE_EXTENSION, *PDEVICE_EXTENSION;
一般根据自己的需要定制自己的设备扩展。子域fdo是为了保存FDO的地址以备后用;子域NextStackDevice是为了定位设备的下一层设备。在附加操作完成后,需要设定符号链接,以便用户应用程序访问该设备。
4)DriverUnload例程
在NT式驱动中,DriverUnload例程主要负责做删除设备和取消符号链接。而在WDM驱动中,这部分操作被IRP_MN_REMOVE_DEVICE的处理函数所负责。因此,如果在DriverEntry中有申请内存的操作,可以在DriverUnload例程中回收这些内存,DriverUnload例程变得相对简单了。
5)对IRP_MN_REMOVE_DEVICE IRP的处理
驱动程序内部是由IRP驱动的。创建IRP的原因很多,IRP_MN_REMOVE_DEVICE这个IRP是当设备被卸载时,由即插即用管理器创建,并发送到驱动程序中的。IRP一般由两个号码指定该IRP的具体意义:一是主IRP号(Major IRP);一是辅IRP号(Minor IRP)。
每个IRP都由对应的派遣函数所处理,派遣函数是在DriverEntry中指定的。
在WDM驱动程序中,对设备的卸载一般是在对IRP_MN_REMOVE_DEVICE的处理函数中进行的。除了需要删除设备,取消符号链接外,在卸载函数中还需要将FDO从PDO上的堆栈中移除掉,使用函数IoDetachDevice:
VOID IoDetachDevice(
__inout PDEVICE_OBJECT TargetDevice //下层堆栈上的设备对象
);
调用此函数后,可将FDO从设备链上删除,但PDO还是存在的,PDO的删除是由操作系统负责的。
WDM驱动程序的编写实例如下,先定义头文件HelloWDM.h:
#pragma once
#ifdef __cplusplus
extern "C"
{
#endif
#include <wdm.h>
#ifdef __cplusplus
}
#endif
//设备扩展结构体
typedef struct _DEVICE_EXTENSION
{
PDEVICE_OBJECT fdo;
PDEVICE_OBJECT NextStackDevice;
UNICODE_STRING ustrDeviceName;
UNICODE_STRING ustrSymLinkName;
}DEVICE_EXTENSION, *PDEVICE_EXTENSION;
//定义分页内存、非分页内存和INIT段内存标志
#define PAGEDCODE code_seg("PAGE")
#define LOCKEDCODE code_seg()
#define INITCODE code_seg("INIT")
#define PAGEDDATA data_seg("PAGE")
#define LOCKEDCODE code_seg()
#define INITDATA data_seg("INIT")
#define ARRAYSIZE(p) (sizeof(p)/sizeof((p)[0]))
NTSTATUS HelloWDMAddDevice(IN PDRIVER_OBJECT DriverObject,
IN PDEVICE_OBJECT PhysicalDeviceObject);
NTSTATUS HelloWDMPnp(IN PDEVICE_OBJECT fdo, IN PIRP Irp);
NTSTATUS HelloWDMDispatchRoutine(IN PDEVICE_OBJECT fdo, IN PIRP Irp);
NTSTATUS DefaultPnpHandler(PDEVICE_EXTENSION pdx, PRIP Irp);
NTSTATUS HandleRemoveDevice(PDEVICE_EXTENSION pdx, PRIP Irp);
void HelloWDMUnload(IN PDRIVER_OBJECT DriverObject);
extern "C"
NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject,
IN PUNICODE_STRING pRegistryPath);
和NT式驱动程序一样,WDM的入口函数地址也是DriverEntry,且在C++编译时需要使用extern “C”修饰:
/**
* 函数名称:DriverEntry
* 功能描述:初始化驱动程序,定位和申请硬件资源,创建内核对象
* 参数列表:
* pDriverObject:从I/O管理器中传进来的驱动对象
* pRegistryPath:驱动程序在注册表中的路径
* 返回值:返回初始化驱动状态
*/
#pragma INITCODE //此函数放在INIT段中,当驱动加载结束后,此函数就可以从内存中卸载了
extern "C" NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject,
IN PUNICODE_STRING pRegistryPath)
{
KdPrint(("Enter DriverEntry/n"));
pDriverObject->DriverExtension->AddDevice = HelloWDMAddDevice;
pDriverObject->MajorFunction[IRP_MJ_PNP] = HelloWDMPnp;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = HelloWDMDispatchRoutine;
pDriverObject->MajorFunction[IRP_MJ_CREATE] = HelloWDMDispatchRoutine;
pDriverObject->MajorFunction[IRP_MJ_READ] = HelloWDMDispatchRoutine;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = HelloWDMDispatchRoutine;
pDriverObject->DriverUnload = HelloWDMUnload;
KdPrint(("Leave DriverEntry/n"));
return STATUS_SUCCESS;
}
在WDM驱动程序中,创建设备对象的任务不再由DriverEntry承担,而需要驱动程序向系统注册一个称为AddDevice的例程。此例程由PNP管理器负责调用,其函数主要职责是创建设备。
/**
* 函数名称:HelloWDMAddDevice
* 功能描述:添加新设备
* 参数列表:
* DriverObject:从I/O管理器中传进来的驱动对象
* PhysicalDeviceObject:从I/O管理器中传进来的物理设备对象
* 返回值:返回新添加设备的状态
*/
#pragma PAGEDCODE
NTSTATUS HelloWDMAddDevice(IN PDRIVER_OBJECT DriverObject,
IN PDEVICE_OBJECT PhysicalDeviceObject)
{
PAGED_CODE(); //DDK提供的宏,只在check版中有效,
//用于确保该例程运行在低于APC_LEVEL的中断优先级别上
KdPrint(("Enter HelloWDMAddDevice/n"));
NTSTATUS status;
PDEVICE_OBJECT fdo;
UNICODE_STRING devName;
RtlInitUnicodeString(&devName, L"//Device//ASCEWDMDevice");
status = IoCreateDevice(DriverObject,sizeof(DEVICE_EXTENSION),
&(UNICODE_STRING)devName,
FILE_DEVICE_UNKNOWN,
0, FALSE, &fdo);
if(!NT_SUCCESS(status))
{
return status;
}
PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION)fdo->DeviceExtension;
pdx->fdo = fdo;
//将fdo(功能设备对象)挂接到设备堆栈上,将返回值(下层堆栈的位置)
//记录在设备扩展结构中
pdx->NextStackDevice = IoAttachDeviceToDeviceStack(
fdo, PhysicalDeviceObject);
UNICODE_STRING symLinkName;
RtlInitUnicodeString(&symLinkName, L"//DosDevices//HelloWDM");
pdx->ustrDeviceName = devName;
pdx->ustrSymLinkName = symLinkName;
status = IoCreateSymbolicLink(&(UNICODE_STRING)symLinkName,
&(UNICODE_STRING)devName);
if(!NT_SUCCESS(status))
{
IoDeleteSymbolicLink(&pdx->ustrSymLinkName);
status = IoCreateSymbolicLink(&symLinkName, &devName);
if(!NT_SUCCESS(status))
{
return status;
}
}
fdo->Flags |= DO_BUFFERED_IO | DO_POWER_PAGABLE;
fdo->Flags &= ~DO_DEVICE_INITIALIZING;
KdPrint(("Leaving HelloWDMAddDevice/n"));
}
WDM式驱动程序主要区别在于对IRP_MJ_PNP的IRP的处理。其中,IRP_MJ_PNP会细分为若干个子类。下面代码除了对IRP_MN_REMOVE_DEVICE做特殊处理外,其他IRP则做相同处理:
/**
* 函数名称:HelloWDMPnp
* 功能描述:对即插即用IRP进行处理
* 参数列表:
* fdo:功能设备对象
* Irp:从I/O请求包
* 返回值:返回状态
*/
#pragma PAGEDCODE
NTSTATUS HelloWDMPnp(IN PDEVICE_OBJECT fdo, IN PIRP Irp)
{
PAGED_CODE();
KdPrint(("Enter HelloWDMPnp/n"));
NTSTATUS status = STATUS_SUCCESS;
PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION)fdo->DeviceExtension;
//得到当前IRP的堆栈
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
static NTSTATUS (*fcntab[])(PDEVICE_EXTENSION pdx, PRIP Irp) =
{
DefaultPnpHandler, //IRP_MN_START_DEVICE
DefaultPnpHandler, //IRP_MN_QUERY_REMOVE_DEVICE
HandleRemoveDevice,//IRP_MN_REMOVE_DEVICE
DefaultPnpHandler, //IRP_MN_CANCEL_REMOVE_DEVICE
DefaultPnpHandler, //IRP_MN_STOP_DEVICE
DefaultPnpHandler, //IRP_MN_QUERY_STOP_DEVICE
DefaultPnpHandler, //IRP_MN_CANCEL_STOP_DEVICE
DefaultPnpHandler, //IRP_MN_QUERY_DEVICE_RELATIONS
DefaultPnpHandler, //IRP_MN_QUERY_INTERFACE
DefaultPnpHandler, //IRP_MN_QUERY_CAPABILITIES
DefaultPnpHandler, //IRP_MN_QUERY_RESOURCES
DefaultPnpHandler, //IRP_MN_QUERY_RESOURCE_REQUIREMENTS
DefaultPnpHandler, //IRP_MN_QUERY_DEVICE_TEXT
DefaultPnpHandler, //IRP_MN_FILTER_RESOURCE_REQUIREMENTS
DefaultPnpHandler, //
DefaultPnpHandler, //IRP_MN_READ_CONFIG
DefaultPnpHandler, //IPR_MN_WRITEZ_CONFIG
DefaultPnpHandler, //IRP_MN_EJECT
DefaultPnpHandler, //IRP_MN_SET_LOCK
DefaultPnpHandler, //IRP_MN_QUERY_ID
DefaultPnpHandler, //IRP_MN_QUERY_PNP_DEVICE_STATE
DefaultPnpHandler, //IRP_MN_QUERY_BUS_INFORMATION
DefaultPnpHandler, //IRP_MN_DEVICE_USAGE_NOTIFICATION
DefaultPnpHandler, //IRP_MN_SURPRISE_REMOVAL
};
ULONG fcn = stack->MinorFunction;
if(fcn >= ARRAYSIZE(fcntab))
{
//未知的设备类型
status = DefaultPnpHandler(pdx, Irp); //对于未知的IRP类型,我们让
//DefaultPnpHandler函数处理
return status;
}
#if DBG
static char* fcnname[] =
{
"IRP_MN_START_DEVICE",
"IRP_MN_QUERY_REMOVE_DEVICE",
"IRP_MN_REMOVE_DEVICE",
"IRP_MN_CANCEL_REMOVE_DEVICE",
"IRP_MN_STOP_DEVICE",
"IRP_MN_QUERY_STOP_DEVICE",
"IRP_MN_CANCEL_STOP_DEVICE",
"IRP_MN_QUERY_DEVICE_RELATIONS",
"IRP_MN_QUERY_INTERFACE",
"IRP_MN_QUERY_CAPABILITIES",
"IRP_MN_QUERY_RESOURCES",
"IRP_MN_QUERY_RESOURCE_REQUIREMENTS",
"IRP_MN_QUERY_DEVICE_TEXT",
"IRP_MN_FILTER_RESOURCE_REQUIREMENTS",
"",
"IRP_MN_READ_CONFIG",
"IRP_MN_WRITE_CONFIG",
"IRP_MN_EJECT",
"IRP_MN_SET_LOCK",
"IRP_MN_QUERY_ID",
"IRP_MN_QUERY_PNP_DEVICE_STATE",
"IRP_MN_QUERY_BUS_INFORMATION",
"IRP_MN_DEVICE_USAGE_NOTIFICATION",
"IRP_MN_SURPRISE_REMOVAL"
};
Kdprint(("PNP request (%s)/n", fcnname[fcn]));
#endif //DBG
status = (*fcntab[fcn])(pdx, Irp);
KdPrint(("Leave HelloWDMPnp/n"));
return status;
}
除了IRP_MN_STOP_DEVICE之外,HelloWDM对其他PNP的IRP做同样处理,即直接传递到底层驱动,并将底层驱动的结果返回:
/**
* 函数名称:DefaultPnpHandler
* 功能描述:对PNP IRP进行默认处理
* 参数列表:
* pdx:设备对象的扩展
* Irp:从I/O请求包
* 返回值:返回状态
*/
#pragma PAGEDCODE
NTSTATUS DefaultPnpHandler(PDEVICE_EXTENSION pdx, PRIP Irp)
{
PAGED_CODE();//确保该例程处于APC_LEVEL之下
KdPrint(("Enter DefaultPnpHandler/n"));
IoSkipCurrentIrpStackLocation(Irp); //略过当前堆栈
KdPrint(("Leave DefaultPnpHandler/n"));
return IoCallDriver(pdx->NextStackDevice, Irp); //用下层堆栈的驱动设备对象处理此IRP
}
对IRP_MN_REMOVE_DEVICE的处理类似于在NT式驱动中的卸载例程,而在WDM驱动中,卸载例程几乎不用做处理:
/**
* 函数名称: HandleRemoveDevice
* 功能描述:对IRP_MN_REMOVE_DEVICE进行处理
* 参数列表
* fdo:功能设备对象
* Irp:从I/O请求包
* 返回值:返回状态
*/
#pragma PAGEDCODE
NTSTATUS HandleRemoveDevice(PDEVICE_EXTENSION pdx, PIRP Irp)
{
PAGED_CODE();
KdPrint(("Enter HandleRemoveDevice/n"));
Irp->IoStatus.Status = STATUS_SUCCESS;
NTSTATUS status = DefaultPnpHandler(pdx, Irp);
IoDeleteSymbolicLink(&(UNICODE_STRING)pdx->ustrSymLinkName);
//调用IoDetachDevice()把fdo从设备栈中脱开
if(pdx->NextStackDevice)
IoDetachDevice(pdx->NextStackDevice);
//删除fdo
IoDeleteDevice(pdx->fdo);
KdPrint(("Leave HandleRemoveDevice/n"));
return status;
}
/**
* 函数名称:HelloWDMDispatchRoutine
* 功能描述:对默认IRP进行处理
* 参数列表:
* fdo:功能设备对象
* Irp:从I/O请求包
* 返回值:返回状态
*/
#pragma PAGEDCODE
NTSTATUS HelloWDMDispatchRoutine(IN PDEVICE_OBJECT fdo, IN PIRP Irp)
{
PAGED_CODE();
KdPrint(("Enter HelloWDMDispatchRoutine/n"));
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
KdPrint(("Leave HelloWDMDispatchRoutine/n"));
return STATUS_SUCCESS;
}
由于WDM式的驱动程序将主要的卸载任务放在了对IRP_MN_REMOVE_DEVICE的处理函数中,在标准的卸载例程中几乎没有什么需要做的:
/**
* 函数名称:HelloWDMUnload
* 功能描述:负责驱动程序的卸载操作
* 参数列表:
* DriverObject:驱动对象
* 返回值:返回状态
*/
#pragma PAGEDCODE
void HelloWDMUnload(IN PDRIVER_OBJECT DriverObject)
{
PAGED_CODE();
KdPrint(("Enter HelloWDMUnload/n"));
KdPrint(("Leave HelloWDMUnload/n"));
}
Windows内核 WDM驱动程序的基本结构和实例的更多相关文章
- Windows NT驱动程序的基本结构和实例
Windows驱动程序分为两类:一类是不支持即插即用功能的NT式驱动程序:另一类是支持即插即用功能的WDM驱动程序. NT式驱动的基本结构: 1)驱动加载过程与驱动入口函数DriverEntry: 驱 ...
- 从0开始:Windows内核利用的另一种方式
https://www.anquanke.com/post/id/91063 从0开始:Windows内核利用的另一种方式 阅读量 9168 | 稿费 200 分享到: 发布时间:201 ...
- msf利用- windows内核提权漏洞
windows内核提权漏洞 环境: Kali Linux(攻击机) 192.168.190.141 Windows2003SP2(靶机) 192.168.190.147 0x01寻找可利用的exp 实 ...
- 《天书夜读:从汇编语言到windows内核编程》五 WDM驱动开发环境搭建
(原书)所有内核空间共享,DriverEntery是内核程序入口,在内核程序被加载时,这个函数被调用,加载入的进程为system进程,xp下它的pid是4.内核程序的编写有一定的规则: 不能调用win ...
- [Windows内核分析]KPCR结构体介绍 (CPU控制区 Processor Control Region)
Windows内核分析索引目录:https://www.cnblogs.com/onetrainee/p/11675224.html 逆向分析操作系统内核代码至少需要具备两项技能: 段页汇编代码非常懂 ...
- Windows内核 基本数据结构
驱动对象: 每个驱动程序都会有唯一的驱动对象与之对应,并且这个驱动对象是在驱动加载时被内核中的对象管理程序所创建的.驱动对象用DRIVER_OBJECT数据结构表示,它作为驱动的一个实例被内核加载,并 ...
- windows内核编程之常用数据结构
1.返回状态 绝大部分的内核api返回值都是一个返回状态,也就是一个错误代码.这个类型为NTSTATUS.我们自己写的函数也大部分这样做. NTSTATUS MyFunction() { NTSTAT ...
- [11]Windows内核情景分析---设备驱动
设备驱动 设备栈:从上层到下层的顺序依次是:过滤设备.类设备.过滤设备.小端口设备[过.类.过滤.小端口] 驱动栈:因设备堆栈原因而建立起来的一种堆栈 老式驱动:指不提供AddDevice的驱动,又叫 ...
- [1]windows 内核情景分析---说明
本文说明:这一系列文章(笔记)是在看雪里面下载word文档,现转帖出来,希望更多的人能看到并分享,感谢原作者的分享精神. 说明 本文结合<Windows内核情景分析>(毛德操著).< ...
随机推荐
- 豆制品厂开车超市送货智能手持PDA POS打票机-手持票据打印机
豆制品厂开车拉着豆腐到某一个超市送货,到达后秤出斤数后就用票据打印机开单 能直接开单,单子一式两份,一张给客户一张留底,到月底时客户要根据客户的量返点的,单子统计.能现场开单,单子上显示哪个超市,豆制 ...
- 我的c++学习(3)字符的输入输出
#include "stdafx.h" #include<iostream> using namespace std; int main(void) { /* char ...
- 【Oralce】时间操作
加法 select sysdate,add_months(sysdate,12) from dual; --加1年 select sysdate,add_months(sysdate,1 ...
- datanode启动不了
报如下异常:*org.apache.hadoop.ipc.RemoteException(org.apache.hadoop.hdfs.server.protocol.DisallowedDatano ...
- hive Java API
Java连接hive进行操作的方式有多种,接触到了两种: 首先,hive要起动远程服务接口,命令: hive --service hiveserver -p 50000 & 1. 通过jdbc ...
- Codeforces Round #235 (Div. 2) B. Sereja and Contests
#include <iostream> #include <vector> #include <algorithm> using namespace std; in ...
- NOIp 2012 #2 借教室 Label:区间修改线段树
题目描述 在大学期间,经常需要租借教室.大到院系举办活动,小到学习小组自习讨论,都需要向学校申请借教室.教室的大小功能不同,借教室人的身份不同,借教室的手续也不一样. 面对海量租借教室的信息,我们自然 ...
- 【BZOJ】3240: [Noi2013]矩阵游戏
题意 给出\(n, m(1 \le n, m \le 10^{1000000})\),求\(f(n, m) \ \mod \ 10^9+7\) $$\begin{cases}f(1, 1) = 1 \ ...
- UITableView常见 UI 问题总结
一,经历 1.让 group 形式的UITableView的单元格也可以修改separatorStyle属性来设置. 2.修改group形式的UITableView的 cell 之间的间距,可以更改s ...
- qt播放器
播放器 http://blog.csdn.net/foruok/article/details/39005703 图片 http://blog.csdn.net/liyinhuicc/article/ ...