保护模式 3讲-段寄存器GDT表与个人代码总结
一丶段描述符
1.1 GDT与LDT
1.1.1 段描述符之GDT表 与 LDT表的概述
-
GDT表
查询inter手册可以得知. 当我们在保护模式下. 进行内存访问的时候 所有的内存访问. 要么通过
全局描述符表(GDT) 要么就通过局部描述符表(LDT) 进行访问的. 而 这些描述符表中.记录的都是
段描述符 段描述符里面包含了 段的基地址 访问特权 类型 和用法信息. 每个段描述符都会有一个与之相关的段选择子,也叫做段选择符 段选择子 包含的是 GDT 与LDT(与它相关的的段描述符)里面的一个索引. 一个全局或者局部标志 决定了 段选择子 是指向GDT 还是 LDT. 想要访问GDT 或者LDT 要提供 段选择子以及偏移地址. 段选择子只是提供了一个索引.来进行访问GDT 或者LDT中的段描述符的. 而段描述符 包含了线性地址 空间的基地址 , 偏移量 确定了相对与基地址的字节地址.
GDT表或者LDT表的线性地址都存储在 GDTR 寄存器与 LDTR寄存器 中.
通过上面我们可以得出几点概要
-
- 保护模式下.我们的内存访问其实都是查表. 查的是GDT 或者 LDT. 如何确定查询的是GDT 还是LDT 取决于段选择子的 全局或者局部的标志位 而查表其实就是 段选择当索引去GDT表中查询. 查到哪一项. 这一项保存的是 段描述符结构
- GDT或者LDT表中.保存的是段描述符结构 段描述符里面才真正的 描述了 段的基地址 访问特权 类型 和用法信息
- 访问GDT或者LDT 就要提供段选择子以及偏移地址.
以上就是对GDT表或者 LDT表的描述 总结来说 GDT或者LDT 就是一块内存. 也可以看成一个数组. 数组的每一项其实保存的都是段描述符 段选择子就是下标
3.1.2 GDTR寄存器与GDT表了解.
根据Inter手册所属. GDTR寄存器 保存了 GDT的 32位基地址 和16位表界限
基地址指的就是GDT从0字节开始的线性地址.可以理解为就是数组首地址.
表界限.可以理解为就是数组的大小. 所以说GDTR 寄存器是一个48位寄存器
按照C语言结构来来表是就如下
struct GDTR { DWORD *GdtBase, SHORT limit; }
LGDT 与SGDT 汇编指令 分别是用来获取和保存 GDTR寄存器的.
电脑开机之后,通电之后.GDT就开始初始化了.
总结:
-
1.GDTR是一个寄存器.保存的就是GDT的首地址以及一个长度. 根据长度可以确定一个GDT表示的内存有多大
-
2.GDT是一个数组.数组里面保存的是段描述符结构 请不要搞混概念
-
在这里我们就可以用驱动程序来读取 GDT了.并且我们进行打印输出即可.
3.1.3 LDTR寄存器与LDT
LDTR寄存器 保存了 16位的段选择子 32位的基地址 16位的段界限(长度) LDT描述符的属性.
LLDT 和 SLDT 指令分别是用来 获取 和设置 LDTR寄存器的.
结构如下:
struct LDTR
{
WORD select,
DWORD base,
WORD Limit,
WORD Attribute,
}
LDTR没有使用.所以简单了解下.
3.2 段选择子
3.2.1 段选择子介绍
上面了解了LDT表. GDT表. 以及分段的概念. 那么就该说一下如何查表. 段选择子是什么.
其实我们说了怎么多. 都是在了解 当汇编指令访问内存的时候 是怎么走的.
比如:
mov ebx,30h
mov fs,bx
官方的说法如下:
段选择子 是一个 16位 的段标识符. 他不是直接指向该段.而是作为一个索引.指向该
段的 段描述符 其实就是下标. 去GDT表查询. 查到段描述符 段描述符才描述了信息.
30就是段选择子. 这里的30赋值给了ebx中的bx位. bx赋值给fs. 可见部分是段选择子.所以会查表
看图了解
分为两个域 一个是 index(索引 3 - 15位表示) 一个是 标记以及特权位
根据官方手册所说. 索引位. 来表示 GDT表中的8192个描述符的某一项. 由此可以得知.GDT表不会超过 8192项 索引值 * 8 来索引 段描述符 由此可以得知. 段描述符是8个字节大小 且GDT表中的每一项是8个字节
手册还是 GDIT 第一项是不可用的. 也就是段选择子为0. TI 位设置为0 则被视为空选择子
当我们查询GDT表的时候如果为索引为0 那么CPU不会产生异常的. 但是如果我们以空选择子来查询的时候得出的段描述符来进行内存访问的时候.就会产生异常. 就会产生一个 通用保护异常.(GP)
还说了 对CS 以及SS赋予一个空选择副是的时候也会产生GP异常.
-
TI标记位
进行内存访问的时候,为 0 表示查询GDT. 为1则访问LDT表.
-
RPL 位
特权级别位. 总共三个等级 0 - 3 0为最高特权级. 描述了 RPL 与 CPL 之间的关系. 以及
段选择子所指向的段描述符
看到这里我们可以进行总结了
- 1.段段选择子其实也是一个结构.表达了 GDT表的索引. 一个标志.查询GDT还是LDT. 一个特权级别.
所以我们可以进行手工解析了.
- 2.GDT表中的第一项为不可用的.也就是空选择子
比如我电脑上的段选择子. 也就是段的可见部分. 以DS段为例子 段选择子为 0x0023
拆分成二进制
0000 0000 0010 0011 第一次拆分
0000 0000 0000 0 索引 = 0
0 查询的GDT表
11 RPL 特权级别 3
总结一下, 查询的索引 = 0; 查询的是GDT表 . RPL 特权级别 = 3;
2. 段描述符
2.1 段描述符介绍
段描述符是一个结构. 占用了8个字节大小 是GDT表或者LDT表中的一个数据结构
其实上面也说了当进行内存访问的时候,段选择子 当索引 查询GDT表.来得出段描述符表.
段描述符表示了 段基地址 段的大小 访问权限. 以及状态等信息.
看一下这个结构体信息
2.2 段描述符属性详解.
2.2.1 段寄存器与段描述符 一一的对应关系
-
段寄存器中的段属性 与 段描述符中的段属性的对应关系
段寄存器我们知道其结构为
struct set { WORD Selector, //段选择子 16位 WORD Attribute,//段属性 16位 DWORD Base, //段基地址 32位 DWORD Limit, //段限长 32位 }
那么段属性与段描述符 是一种怎么样的对应关系?
请不要把 段寄存器结构 与 段描述符搞混
看下图
对应着段描述符的 高4个字节中的 第八位 到 第23位
-
段寄存器中的 32位的基地址 与 段描述符中的基地址对应关系
段描述符的基地址由三部分组成. 原因是CPU实在16位上扩展的.要兼容16位.32位 64位.所以只能不断扩展
看下图:
分别是由 高4字节的 24 - 31位 4-7位 低四个字节的 16 - 31位来组成的.
所以我们通过位运算.可以拼接处地址. 并且赋值给段寄存器的基地址域
-
Limit对应
这个就不用说了.看上图就知道. 第四个字节的 0-15位来做成的limit
高地址的 16 - 19位 也是. 合计20个字节
-
2.2.2 段属性中的位详解
2.2.2.1P位 高四个字节的第15位
p = 1 代表这个段描述符是有效的
p = 0 代表这个段描述符是无效的
2.2.2.2G位 粒度位 高四个字节的第23位
G位 根据inter手册所说如下
G = 0: 代表段limit(限长)是以字节为单位的. 根据段描述符我们知道段限长 为20位组成.
也就是0xFFFFF 大小. 如果G = 0; 那么是以字节为单位. 在 0xFFFFF最大表示了0xFFFFF个大小
意思就是说以字节为单位. 字节 * limit 来表示一个 一个段界限值.
G = 1: 段限长就是表示4kb大小. 4096-1 = 4095 也就是0xFFF 大小. 也就是一个页大小
段限长可以表示为 4kb * limit 来表示一个limit有多大.
2.2.2.3 S位
在系统中段描述符有很多形式.一种是系统段描述符 一种是数据或者代码段描述符
S = 1 表示代码段或者数据段描述符
s = 0 表示系统段描述符
根据G位 DPL位 S位 可以枚举出什么是代码段.什么是数据段.
DPL要么都为1 要么都为0
所以如果是代码段 G DPL S 组合起来 十六进制要么为9 要么为F
- S = 1 代码段与数据段中的Type域讲解
TYPE 域
TYPE域 跟S位相关联. 看下TYPE域的4个位表示
S = 1的时候Type如下.是针对 代码或者数据段表示的. S= 0那么则是针对内核.
TYpe 域中的第 11位 = 0 确定了这是数据段
11位 = 1 确定了这是代码段
意思就是说. S位只是确定了你是代码段还是数据段,但是肯定不会是系统段描述符.
而Type域则真正决定了.你是代码段还是数据段.
所以我们可以在确定代码段或者数据段的前提下.精确的遍历出那些是代码段.那些是数据段.
在段描述符的第6个十六进制位 可以看. 入如果 > 9 就是代码段. 否则就是数据段
对于数段而言 控制位如下:
控制位了. E W A
W 表示可写
A 访问位
E 扩展位
A 位表示了你这个段描述符是否访问过.如果访问过.那么就会置1 否则就是0
W 表示可写位. 如果针对这个段描述符写过.则W位置1. 否则如果为0.那么表示这个数据段是不可写的.
E扩展位
扩展位 分为向下扩展 和向上扩展. 具体意思是啥.
首先了解下堆栈段. 堆栈比如是可读写的数据段. 而且大小需要动态变化. 所以我们使用向下扩展.扩展方向 = 1 就是向下扩展的意思. 意思就是ss.base + limit 这块表示的空间. 其余的空间 是有效的. 比如我们读取堆栈.那么除了ss.base可以读.其余空间也是有效的.也是可以读的.
E = 0 向上扩展: 那么意思就是有效空间只有 ss.base + limt空间大小.
如下图:
图是以fs为原型. 第一个表示向上扩展. 有效空间只有 fs.base + limit E = 0
E = 1 向下扩展. 表示有效空间除了fs.base + limit 之外都是可以访问的.
对于代码段而言 控制位如下:
控制位: C R A
A = 访问位. 同数据段一样.
R = 可读位 表示当前这个段是否是可读的.
C = 一致位
C = 1 一致代码段
C = 0 非一致代码段
-
S = 0 Type域讲解
上面我们说过S = 1 表示代码段域数据段.而且Type域也不同. 那么S = 0则表示是系统段描述符.那么看下如果是系统段描述符
根据Inter手册所属 当S位 = 0; 这个描述符位系统描述符 处理可以识别以下类型的系统描述符
局部描述符 LDT
任务段描述符 TSS
调用们描述符
中断门描述符
陷阱门描述
任务们描述
这些描述又可以分为两大类 一类是系统段描述符 另一个是门描述符.
系统段(LDT TSS) 门就是本身
看下Type解析
如果是系统段.那么Type域就进行组合解析了. 比如序号为11的一项 二进制为 1011 那么它则表示这个描述符为 32位的TSS 并且处于繁忙状态(Busy)
其实也是第五个字节.可以直接看第五个字节的数据表示形式.图片如下
-
db位
db位主要是影响段寄存器的操作
-
CS代码段的影响
D = 1 那么我们的汇编就采用32位的寻址方式
D = 0 那么我们的汇编就采用16位的寻址方式.
D = 0 汇编伪代码
```
mov esp,dword ptr [di - 18] 操作的di 也就是16位寻址 可以用硬编码0x67进行修改来实现这种寻址
```D = 1
mov esp,dword ptr [ebp - 18] 操作的都是32位的寄存器
SS段的影响
堆栈段影响也是分为16位与32位. 32位下.我们 push pop call 都会影响ESP. 也就是D = 1的情况下
D = 0的话. 那么影响的就是SP 而不是ESP
向下扩展的数据段
如果D = 1 那么数据段的limit寻址可以到达4GB.
如果D = 0; 那么就只能到达64kb
其实就是 2^32 与 2^16影响. d = 1 那么都按照32位来算. 否则就是按照16位寻址来算.
-
总结与实验
总结1 GDT的操作
1.1 获取GDT数组表.输出所有段描述符
遍历获取GDT表 只是获取GDT表的首地址. 地址里面的内容则是段描述符
#include <ntddk.h>
//读取GDT表并且遍历解析,
void DriverUnLoad(PDRIVER_OBJECT pDriverObj)
{
UNREFERENCED_PARAMETER(pDriverObj);
}
NTSTATUS DriverEntry(
PDRIVER_OBJECT pDriverObj,
PUNICODE_STRING pReg)
{
UNREFERENCED_PARAMETER(pDriverObj);
UNREFERENCED_PARAMETER(pReg);
pDriverObj->DriverUnload = DriverUnLoad;
char gdt[6];
short index = 0;
int* pBase = NULL;
short limit = 0;
__asm
{
sgdt gdt;
}
//遍历GDT表
limit = *(short*)gdt; //获取limit
//获取base
pBase = (int *)*(int *)((char *)gdt + 2);
/*DbgPrint("base = %p\r\n", index, gdt.BaseAddress);*/
for (index = 0; index < limit; index++)
{
//每个GDT表存储的是 8个字节的段描述符.这里不解析段描述符.只是遍历内存
if (index == 5)
{
break;
}
DbgPrint("GDT Array %d base = %x\r\n", index,(char *)pBase + index * 8);
//输出结果 GDT Array 0 base = 80b95000
//输出结果 GDT Array 1 base = 80b95008
}
return STATUS_SUCCESS;
}
封装成函数如下
PVOID GetGdtBaseByIndex(ULONG uIndex)
{
/*
利用汇编 sgdt 读取GDTR内容. 并且进行解析
*/
char gdt[6];
short index = 0;
int* pBase = NULL;
short limit = 0;
__asm
{
sgdt gdt;
}
limit = *(short*)gdt; //获取limit
//获取base
pBase = (int*)*(int*)((char*)gdt + 2);
for (index = 0; index < limit; index++)
{
//每个GDT表存储的是 8个字节的段描述符.这里不解析段描述符.只是遍历内存
if (index == uIndex)
{
return (PVOID)((char*)pBase + (index * 8));
}
}
return NULL;
}
1.2解析GDT表中的段描述符
总结2
2.1输出所有有效的段描述符 (P位)
原理: 根据P位来判断段描述符表是否是有效还是无效.解析P位进行输出即可.
伪代码
//我们要获取P位.就要建立段描述符结构体.来表示GDT中的内容.这样就很方便获取值了.用结构体位域表示
typedef struct _Segmentdescriptor
{
ULONG limit0_15 : 16;
ULONG LowBase16_31 : 16;
ULONG LowHighBase0_7 : 8;
ULONG Type : 4;
ULONG sBit : 1;
ULONG DplBit : 2;
ULONG pBit : 1;
ULONG HightLimit16_19 : 4;
ULONG Avl : 1;
ULONG Resever : 1;
ULONG DbBit : 1;
ULONG Gbit : 1;
ULONG HightBase : 8;
}SEGMENTDESCRIPTOR, * PSEGMENTDESCRIPTOR;
BOOLEAN CheckSegmentdescriptorEffective(PVOID GDTValue)
{
/*
根据段描述符来判断P位是否= 1 = 1则是有效位
*/
KdBreakPoint();
PSEGMENTDESCRIPTOR pSecDescr = NULL;
if (GDTValue == NULL)
{
return 0;
}
//解析GDTVALUE 进行解析
pSecDescr = (PSEGMENTDESCRIPTOR)GDTValue;
ULONG isEffective = 0;
isEffective = pSecDescr->pBit;
if (isEffective&0x00000001)
{
return TRUE;
}
return FALSE;
}
2.2 获取所有数据段描述符 并且输出其Type域表示 (S = 1,Type 1 = 0)
原理: 根据S位. 来解析对应的Type位. S= 1代表可能是数据段或者代码段 解析其Type域的高第一位.来肯定是代码段还是数据段 并且根据不同段.对type域不同的解释.来解释type域
这里只是获取特定的一个段来判断是否是数据段.其实可以进行遍历. 这里逻辑就不写了.贴出简单代码
总共实现三步
1.获取是s 判断是系统段. 还是代码以及数据段
2.获取Type值
3.解析Type值 确定是代码 还是数据段
4.输出
//获取s位
INT CheckSegmentdescriptorIsSystem(PVOID GDTValue)
{
/*
根据段描述符来判断S位是否= 1 = 1则是代码数据段 0就是系统段
*/
KdBreakPoint();
PSEGMENTDESCRIPTOR pSecDescr = NULL;
if (GDTValue == NULL)
{
return -1;
}
//解析GDTVALUE 进行解析
pSecDescr = (PSEGMENTDESCRIPTOR)GDTValue;
ULONG isSystemSegmentdescriptor = 0;
isSystemSegmentdescriptor = pSecDescr->sBit;
if (isSystemSegmentdescriptor & 0x00000001)
{
return TRUE;
}
return FALSE;
}
//根据GDT数组的内容(也就是段描述符) 来获取对应的Type域
INT GetSegmentdescriptorTypeValue(PVOID GDTValue)
{
/*
根据段描述符来获取Type域的值即可.
*/
KdBreakPoint();
PSEGMENTDESCRIPTOR pSecDescr = NULL;
if (GDTValue == NULL)
{
return -1;
}
//解析GDTVALUE 进行解析
pSecDescr = (PSEGMENTDESCRIPTOR)GDTValue;
int TypeValue = -1;
TypeValue = pSecDescr->Type;
return TypeValue;
}
//根据Type值进行校验 是数据段还是代码段
INT CheckCodeAnDataByTypeValue(INT TypeValue)
{
//获取TypeValue的高位.来判断高位是否是1
if (TypeValue & 0x00000008)
{
// 高位位1 是代码段
return TRUE;
}
else if (TypeValue & 0x00000007)
{
return FALSE;
}
else
{
return -1;
}
}
//输出
NTSTATUS PrintTypeBlock(INT isSystem, INT uTypeValue)
{
if (isSystem == 0)
{
//Type直接按照系统段解析
switch (uTypeValue)
{
case 0x0:
DbgPrint("Inter公司保留未定义\r\n");
break;
case 0x1:
DbgPrint("有效的286TSS\r\n");
break;
case 0x2:
DbgPrint("LDT \r\n");
break;
case 0x3:
DbgPrint("286TSS忙碌\r\n");
break;
case 0x4:
DbgPrint("286调用门\r\n");
break;
case 0x5:
DbgPrint("任务门\r\n");
break;
case 0x6:
DbgPrint("286中断门\r\n");
break;
case 0x7:
DbgPrint("286陷阱门\r\n");
break;
case 0x8:
DbgPrint("未定义(Inter保留)\r\n");
break;
case 0x9:
DbgPrint("有效的386TSS\r\n");
break;
case 0xA:
DbgPrint("有效的386TSS忙\r\n");
break;
case 0xB:
DbgPrint("未定义(Inter保留)\r\n");
break;
case 0xC:
DbgPrint("386调用门\r\n");
break;
case 0xD:
DbgPrint("未定义(Inter保留)\r\n");
break;
case 0xE:
DbgPrint("386中断门\r\n");
break;
case 0xF:
DbgPrint("386陷阱门\r\n");
break;
default:
DbgPrint("解析Type未找到相关定义\r\n");
break;
}
}
else
{
//Type按照数据段 以及代码段进行解析
switch (uTypeValue)
{
case 0x0:
DbgPrint("Index = 0 DType = 0 Data Seg E = 0 W = 0 A = 0 Read-Only\r\n");
break;
case 0x1:
DbgPrint("Index = 1 DType = 0 Data Seg E = 0 W = 0 A = 1 Read-Only,accessed\r\n");
break;
case 0x2:
DbgPrint("Index = 2 DType = 0 Data Seg E = 0 W = 1 A = 0 Read/Write\r\n");
break;
case 0x3:
DbgPrint("Index = 3 DType = 0 Data Seg E = 0 W = 1 A = 1 Read/Write,accessed\r\n");
break;
case 0x4:
DbgPrint("Index = 4 DType = 0 Data Seg E = 1 W = 0 A = 0 Read-Only,expand-down\r\n");
break;
case 0x5:
DbgPrint("Index = 5 DType = 0 Data Seg E = 1 W = 0 A = 1 Read-Only,expand-down,accessed\r\n");
break;
case 0x6:
DbgPrint("Index = 6 DType = 0 Data Seg E = 1 W = 1 A = 0 Read/Write,expand-down\r\n");
break;
case 0x7:
DbgPrint("Index = 7 DType = 0 Data Seg E = 1 W = 1 A = 1 Read/Write,expand-down,accessed\r\n");
break;
case 0x8:
DbgPrint("Index = 8 CType = 1 Code Seg C = 0 R = 0 A = 0 Execute-Only\r\n");
break;
case 0x9:
DbgPrint("Index = 9 CType = 1 Code Seg C = 0 R = 0 A = 1 Execute-Only,accessed\r\n");
break;
case 0xA:
DbgPrint("Index = A CType = 1 Code Seg C = 0 R = 1 A = 0 Execute/Read\r\n");
break;
case 0xB:
DbgPrint("Index = B CType = 1 Code Seg C = 0 R = 1 A = 1 Execute/Read,accessed\r\n");
break;
case 0xC:
DbgPrint("Index = C CType = 1 Code Seg C = 1 R = 0 A = 0 Execute-Only,conforming\r\n");
break;
case 0xD:
DbgPrint("Index = D CType = 1 Code Seg C = 1 R = 0 A = 1 Execute-Only,conforming,accessed\r\n");
break;
case 0xE:
DbgPrint("Index = E CType = 1 Code Seg C = 1 R = 1 A = 0 Execute/Read-Only,conforming\r\n");
break;
case 0xF:
DbgPrint("Index = F CType = 1 Code Seg C = 1 R = 1 A = 1 Execute/Read-Only,conforming,accessed\r\n");
break;
default:
DbgPrint("解析Type未找到相关定义\r\n");
break;
}
}
return STATUS_SUCCESS;
}
//调用代码
GetGdtBaseByIndex 函数就是获取GDT表数组中记录的地址.6就是下标.表示要获取 GDT表中哪一项
uIsSytem = CheckSegmentdescriptorIsSystem(GetGdtBaseByIndex(6));
if (uIsSytem != -1)
{
if (uIsSytem == FALSE)
{
DbgPrint("当前段描述符是系统段\r\n");
}
else
{
INT TypeVaue = -1;
INT IsCodeAnDataSeg = -1;
TypeVaue = GetSegmentdescriptorTypeValue(GetGdtBaseByIndex(6));
if (TypeVaue == -1)
{
DbgPrint("Type类型数据获取错误");
return STATUS_SUCCESS;
}
IsCodeAnDataSeg = CheckCodeAnDataByTypeValue(TypeVaue);
if (IsCodeAnDataSeg != -1)
{
if (IsCodeAnDataSeg == TRUE)
{
DbgPrint("当前段描述符是代码段\r\n");
PrintTypeBlock(uIsSytem, TypeVaue);
}
else
{
DbgPrint("当前段描述符是数据段\r\n");
PrintTypeBlock(uIsSytem, TypeVaue);
}
}
}
}
2.3 获取所有代码段描述符 并且输出其Type域表示(S = 1,Type 1 = 1)
原理: 根据S位. 来解析对应的Type位. S= 1代表可能是数据段或者代码段 解析其Type域的高第一位.来肯定是代码段还是数据段 并且根据不同段.对type域不同的解释.来解释type域
同上.只要进行遍历即可.
2.4 获取所有系统段描述符,并且输出其Type域表示(S = 0)
原理: 根据S位. 来解析对应的Type位. S= 0代表是系统段. 然后直接解析Type域来看看表示的是什么.
同上.只要进行遍历即可.
2.5 代码工程下载
代码工程放到百度盘里面.也不大.可以直接下载下来学习.是针对32位的.
链接:https://pan.baidu.com/s/1tHLBuus91fgYkjKTjiZcsw
提取码:rfbv
2.6 总结
1.总结GDT 以及如何用代码获取GDT
2.根据段描述符建立对应结构体.来进行解析GDT中数组的内容. 其实定义好了怎么解析都可以了.就很简单了.
坚持两字,简单,轻便,但是真正的执行起来确实需要很长很长时间.当你把坚持两字当做你要走的路,那么你总会成功. 想学习,有问题请加群.群号:725864912(收费)群名称: 逆向学习小分队 群里有大量学习资源. 以及定期直播答疑.有一个良好的学习氛围. 涉及到外挂反外挂病毒 司法取证加解密 驱动过保护 VT 等技术,期待你的进入。
详情请点击链接查看置顶博客 https://www.cnblogs.com/iBinary/p/7572603.html
本文来自博客园,作者:iBinary,未经允许禁止转载 转载前可联系本人.对于爬虫人员来说如果发现保留起诉权力.https://www.cnblogs.com/iBinary/p/13198073.html
欢迎大家关注我的微信公众号.不定期的更新文章.更新技术. 关注公众号后请大家养成 不白嫖的习惯.欢迎大家赞赏. 也希望在看完公众号文章之后 不忘 点击 收藏 转发 以及点击在看功能. QQ群: