一、什么是CPU缓存
1.1 CPU缓存的来历
众所周知,CPU是计算机的大脑,它负责执行程序的指令,而内存负责存数据, 包括程序自身的数据。在很多年前,CPU的频率与内存总线的频率在同一层面上。内存的访问速度仅比寄存器慢一些。但是,这一局面在上世纪90年代被打破了。CPU的频率大大提升,但内存总线的频率与内存芯片的性能却没有得到成比例的提升。并不是因为造不出更快的内存,只是因为太贵了。内存如果要达到目前CPU那样的速度,那么它的造价恐怕要贵上好几个数量级。所以,CPU的运算速度要比内存读写速度快很多,这样会使CPU花费很长的时间等待数据的到来或把数据写入到内存中。所以,为了解决CPU运算速度与内存读写速度不匹配的矛盾,就出现了CPU缓存。
2. CPU缓存的概念
CPU缓存是位于CPU与内存之间的临时数据交换器,它的容量比内存小的多但是交换速度却比内存要快得多。CPU缓存一般直接跟CPU芯片集成或位于主板总线互连的独立芯片上。
为了简化与内存之间的通信,高速缓存控制器是针对数据块,而不是字节进行操作的。高速缓存其实就是一组称之为缓存行(Cache Line)的固定大小的数据块组成的,典型的一行是64
字节。
3. CPU缓存的意义
CPU往往需要重复处理相同的数据、重复执行相同的指令,如果这部分数据、指令CPU能在CPU缓存中找到,CPU就不需要从内存或硬盘中再读取数据、指令,从而减少了整机的响应时间。在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。所以,缓存的意义满足以下两种局部性原理:
- 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
- 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
二、CPU的三级缓存
2.1 CPU的三级缓存
随着多核CPU的发展,CPU缓存通常分成了三个级别:L1
,L2
,L3
。级别越小越接近CPU,所以速度也更快,同时也代表着容量越小。L1 是最接近CPU的, 它容量最小(例如:32K
),速度最快,每个核上有两个 L1 缓存, 一个用于存数据的 L1d Cache(Data Cache),一个用于存指令的 L1i Cache(Instruction Cache)。L2 缓存 更大一些(例如:256K
),速度要慢一些, 一般情况下每个核上都有一个独立的L2 缓存; L3 缓存是三级缓存中最大的一级(例如3MB),同时也是最慢的一级, 在同一个CPU插槽之间的核共享一个 L3 缓存。结构如图2.1:
2.1 CPU-缓存-主内存图示
下面是三级缓存的处理速度参考表:
从CPU到 | 大约需要的CPU周期 | 大约需要的时间(单位ns) |
---|---|---|
寄存器 | 1 cycle | |
L1 Cache | ~3-4 cycles | ~0.5-1 ns |
L2 Cache | ~10-20 cycles | ~3-7 ns |
L3 Cache | ~40-45 cycles | ~15 ns |
跨槽传输 | ~20 ns | |
内存 | ~120-240 cycles | ~60-120ns |
就像数据库缓存一样,获取数据时首先会在最快的缓存中找数据,如果缓存没有命中(Cache miss) 则往下一级找, 直到三级缓存都找不到时,那只有向内存要数据了。一次次地未命中,代表取数据消耗的时间越长。
2.2 带有高速缓存的CPU执行计算的流程
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU的高速缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
三、多核CPU多级缓存一致性协议MESI
多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。
3.1 MESI协议缓存状态
缓存行(Cache line):缓存存储数据的单元。
MESI 是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
状态 | 描述 | 监听任务 |
---|---|---|
M 修改 (Modified) | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 |
E 独享、互斥 (Exclusive) | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
S 共享 (Shared) | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
I 无效 (Invalid) | 该Cache line无效。 | 无 |
注意:
对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
3.2 MESI状态转换
3.2.1 触发事件
触发事件 | 描述 |
---|---|
本地读取(Local read) | 本地cache读取本地cache数据 |
本地写入(Local write) | 本地cache写入本地cache数据 |
远端读取(Remote read) | 其他cache读取本地cache数据 |
远端写入(Remote write) | 其他cache写入本地cache数据 |
3.2.2 cache分类
前提:所有的cache共同缓存了主内存中的某一条数据。
- 本地cache:指当前cpu的cache。
- 触发cache:触发读写事件的cache。
- 其他cache:指既除了以上两种之外的cache。
注意:本地的事件触发本地cache和触发cache为相同。
3.2.3 上图的切换解释
状态 | 触发本地读取 | 触发本地写入 | 触发远端读取 | 触发远端写入 |
---|---|---|---|---|
M状态(修改) | 本地cache:M 触发cache:M 其他cache:I |
本地cache:M 触发cache:M 其他cache:I |
本地cache:M→E→S 触发cache:I→S 其他cache:I→S 同步主内存后修改为E独享,同步触发、其他cache后本地、触发、其他cache修改为S共享 |
本地cache:M→E→S→I 触发cache:I→S→E→M 其他cache:I→S→I 同步和读取一样,同步完成后触发cache改为M,本地、其他cache改为I |
E状态(独享) | 本地cache:E 触发cache:E 其他cache:I |
本地cache:E→M 触发cache:E→M 其他cache:I 本地cache变更为M,其他cache状态应当是I(无效) |
本地cache:E→S 触发cache:I→S 其他cache:I→S 当其他cache要读取该数据时,其他、触发、本地cache都被设置为S(共享) |
本地cache:E→S→I 触发cache:I→S→E→M 其他cache:I→S→I 当触发cache修改本地cache独享数据时时,将本地、触发、其他cache修改为S共享.然后触发cache修改为独享,其他、本地cache修改为I(无效),触发cache再修改为M |
S状态(共享) | 本地cache:S 触发cache:S 其他cache:S |
本地cache:S→E→M 触发cache:S→E→M 其他cache:S→I 当本地cache修改时,将本地cache修改为E,其他cache修改为I,然后再将本地cache为M状态 |
本地cache:S 触发cache:S 其他cache:S |
本地cache:S→I 触发cache:S→E→M 其他cache:S→I 当触发cache要修改本地共享数据时,触发cache修改为E(独享),本地、其他cache修改为I(无效),触发cache再次修改为M(修改) |
I状态(无效) | 本地cache:I→S或者I→E 触发cache:I→S或者I →E 其他cache:E、M、I→S、I 本地、触发cache将从I无效修改为S共享或者E独享,其他cache将从E、M、I 变为S或者I |
本地cache:I→S→E→M 触发cache:I→S→E→M 其他cache:M、E、S→S→I |
既然是本cache是I,其他cache操作与它无关 | 既然是本cache是I,其他cache操作与它无关 |
下图示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。
M | E | S | I | |
---|---|---|---|---|
M | × | × | × | √ |
E | × | × | × | √ |
S | × | × | √ | √ |
I | √ | √ | √ | √ |
举个例子来说:
假设cache 1 中有一个变量x = 0的cache line 处于S状态(共享)。
那么其他拥有x变量的cache 2、cache 3等x的cache line调整为S状态(共享)或者调整为 I 状态(无效)。
3.3 多核缓存协同操作
假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。
3.3.1 单核读取
那么执行流程是:
CPU A发出了一条指令,从主内存中读取x。
从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享)。
3.3.2 双核读取
那么执行流程是:
CPU A发出了一条指令,从主内存中读取x。
CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。
CPU B发出了一条指令,从主内存中读取x。
CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。
3.3.3 修改数据
那么执行流程是:
CPU A 计算完成后发指令需要修改x.
CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
CPU A 对x进行赋值。
3.3.4 同步数据
那么执行流程是:
CPU B 发出了要读取x的指令。
CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)
CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。
3.3.5 一个CPU要修改一个处于shared状态的变量
1.本地缓存行将会通过寄存器控制器向远程拥有相同缓存行的寄存器发送一个RFO请求(Request For Owner),告诉其他CPU里面的缓存把缓存里面的值为valid状态,然后待收到各个缓存的(valid ack)已经完成无效状态修改的回应之后,
2.再把自己的状态改为Exclusive,之后再进行修改。
3.修改后再改为Modified状态,数据写入缓存行。
上面这几步大家可以看到第一步的时候,CPU需要在等待所有的valid ack之后才会进行下面的操作。这部分就会让CPU产生一定的阻塞,无法充分利用CPU。这个时候就印出来了存储缓冲区 storeBuffer。
四、MESI优化和他们引入的问题
缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。
4.1 CPU切换状态阻塞解决-存储缓存(Store Bufferes)
比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多。
4.2 Store Bufferes
为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。存储缓冲区的作用就是修改一个变量的时候,直接执行修改的操作不直接镇对缓存行,而是针对一个叫制作storeBuffer的位置来操作的。这样CPU在执行修改操作的时候,直接把数据写入到storeBuffer里面,并发出广播告知其他CPU,你们的缓存里面需要变为validate状态,然后去执行其他的操作,等接受到validate ack的时候才会回来把缓冲区里面的值写入到缓存行里面。但是这么做有两个风险。
4.3 Store Bufferes(写缓存区或存储缓存器)的风险
写操作:
- 如果相应的缓存条目状态为 E、M,则直接写入
- 如果相应的缓存条目状态为 S, 处理器会将写操作相关信息存入写缓冲器,并发送Invalidate消息。(不再等待响应消息)
- 如果相应的缓存条目状态为 I,将写操作相关信息存入写缓冲器,并发送Read Invalidate消息。(不再等待响应消息)
当处理器将写操作写入写缓冲器后,则认为写操作已经完成。而实际上,当处理器收到其他所有处理器回应的Read Response、Invalidate Acknowledge消息后,处理器才会将写缓冲器中对应的写操作写入相应的缓存行,这个时候,写操作才算真正完成。
写缓冲器让处理器在执行写操作时不需要再额外的等待,减少了写操作的等待,提高了处理器的指令执行效率。
读操作:
引入写缓存器后,处理器读取数据时,由于该数据的更新结果可能仍然停留在写缓冲器中,所以处理器会先从写缓冲器中找寻数据,没有找到时,才从高速缓存中找。这种处理器直接从写缓冲器中读取数据的技术被称为:存储转发。
1 value = 3;
2
3 void exeToCPUA(){
4 value = 10;
5 isFinsh = true;
6 }
7 void exeToCPUB(){
8 if(isFinsh){
9 //value一定等于10?!
10 assert value == 10;
11 }
12 }
试想一下开始执行时,CPU A保存着isfinish在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比isfinish更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。也会就是存储缓存虽然可以解决等待阻塞导致CPU性能浪费的问题,但是同样引入了新的问题---重排序,也就是isFinsh的赋值在value赋值之前。
4.4 重排序
这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。
4.5 失效队列
存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:
- 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送
- Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
- 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。
也就是失效队列的作用是CPU接收到validate广播的时候马上返回给对方validate ack响应。等当前的操作执行完再回来真正的把缓存里面的值标识为validate状态。
但是失效队列造成的问题就是在CPU已经给对方应答的时候自己本身还没有去把这个值validate掉,也就是可见性问题(乱序执行),明明写了 其他线程看不到。
4.6重排序问题和重排序的解决
通过volatile标记,可以解决编译器层面的可见性与重排序问题。而内存屏障则解决了硬件层面的可见性与重排序问题
4.6.1 volatile
4.6.1.1 volatile介绍
volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。下面举例说明。在DSP开发中,经常需要等待某个事件的触发,所以经常会写出这样的程序:
这段程序等待内存变量flag的值变为1(怀疑此处是0,有点疑问,)之后才运行do2()。变量flag的值由别的程序更改,这个程序可能是某个硬件中断服务程序。例如:如果某个按钮按下的话,就会对DSP产生中断,在按键中断程序中修改flag为1,这样上面的程序就能够得以继续运行。但是,编译器并不知道flag的值会被别的程序修改,因此在它进行优化的时候,可能会把flag的值先读入某个寄存器,然后等待那个寄存器变为1。如果不幸进行了这样的优化,那么while循环就变成了死循环,因为寄存器的内容不可能被中断服务程序修改。为了让程序每次都读取真正flag变量的值,就需要定义为如下形式:
需要注意的是,没有volatile也可能能正常运行,但是可能修改了编译器的优化级别之后就又不能正常运行了。因此经常会出现debug版本正常,但是release版本却不能正常的问题。所以为了安全起见,只要是等待别的程序修改某个变量的话,就加上volatile关键字。
volatile的本意是“易变的”,由于访问寄存器的速度要快过RAM,所以编译器一般都会作减少存取外部RAM的优化。比如:
程序的本意是希望ISR_2中断产生时,在main当中调用do_something函数,但是,由于编译器判断在main函数里面没有修改过i,因此可能只执行一次对从i到某寄存器的读操作,然后每次if判断都只使用这个寄存器里面的“i副本”,导致do_something永远也不会被调用。如果变量加上volatile修饰,则编译器保证对此变量的读写操作都不会被优化(肯定执行)。此例中i也应该如此说明。
一般说来,volatile用在如下的几个地方:
中断服务程序中修改的供其它程序检测的变量需要加volatile;
2、多任务环境下各任务间共享的标志应该加volatile;
3、存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;
另外,以上这几种情况经常还要同时考虑数据的完整性(相互关联的几个标志读了一半被打断了重写),在1中可以通过关中断来实现,2中可以禁止任务调度,3中则只能依靠硬件的良好设计了。
4.6.1.2 volatile 的含义
volatile总是与优化有关,编译器有一种技术叫做数据流分析,分析程序中的变量在哪里赋值、在哪里使用、在哪里失效,分析结果可以用于常量合并,常量传播等优化,进一步可以死代码消除。但有时这些优化不是程序所需要的,这时可以用volatile关键字禁止做这些优化,volatile的字面含义是易变的,它有下面的作用:
1、不会在两个操作之间把volatile变量缓存在寄存器中。在多任务、中断、甚至setjmp环境下,变量可能被其他的程序改变,编译器自己无法知道,volatile就是告诉编译器这种情况。
2、不做常量合并、常量传播等优化,所以像下面的代码:
if的条件不会当作无条件真。
3、对volatile变量的读写不会被优化掉。如果你对一个变量赋值但后面没用到,编译器常常可以省略那个赋值操作,然而对Memory Mapped IO的处理是不能这样优化的。
前面有人说volatile可以保证对内存操作的原子性,这种说法不大准确,其一,x86需要LOCK前缀才能在SMP下保证原子性,其二,RISC根本不能对内存直接运算,要保证原子性得用别的方法,如atomic_inc。
对于jiffies,它已经声明为volatile变量,我认为直接用jiffies++就可以了,没必要用那种复杂的形式,因为那样也不能保证原子性。
你可能不知道在Pentium及后续CPU中,下面两组指令作用相同,但一条指令反而不如三条指令快。
4.6.1.3 编译器优化 → C关键字volatile → memory破坏描述符
memory比较特殊,可能是内嵌汇编中最难懂部分。为解释清楚它,先介绍一下编译器的优化知识,再看C关键字volatile。最后去看该描述符。
1、编译器优化介绍
内存访问速度远不及CPU处理速度,为提高机器整体性能,在硬件上引入硬件高速缓存Cache,加速对内存的访问。另外在现代CPU中指令的执行并不一定严格按照顺序执行,没有相关性的指令可以乱序执行,以充分利用CPU的指令流水线,提高执行速度。以上是硬件级别的优化。再看软件一级的优化:一种是在编写代码时由程序员优化,另一种是由编译器进行优化。编译器优化常用的方法有:将内存变量缓存到寄存器;调整指令顺序充分利用CPU指令流水线,常见的是重新排序读写指令。对常规内存进行优化的时候,这些优化是透明的,而且效率很好。由编译器优化或者硬件重新排序引起的问题的解决办法是在从硬件(或者其他处理器)的角度看必须以特定顺序执行的操作之间设置内存屏障(memory barrier),linux 提供了一个宏解决编译器的执行顺序问题。
这个函数通知编译器插入一个内存屏障,但对硬件无效,编译后的代码会把当前CPU寄存器中的所有修改过的数值存入内存,需要这些数据的时候再重新从内存中读出。
2、C语言关键字volatile
C语言关键字volatile(注意它是用来修饰变量而不是上面介绍的volatile)表明某个变量的值可能在外部被改变,因此对这些变量的存取不能缓存到寄存器,每次使用时需要重新存取。该关键字在多线程环境下经常使用,因为在编写多线程的程序时,同一个变量可能被多个线程修改,而程序通过该变量同步各个线程,例如:
该线程启动时将intSignal置为2,然后循环等待直到intSignal为1时退出。显然intSignal的值必须在外部被改变,否则该线程不会退出。但是实际运行的时候该线程却不会退出,即使在外部将它的值改为1,看一下对应的伪汇编代码就明白了:
对于C编译器来说,它并不知道这个值会被其他线程修改。自然就把它cache在寄存器里面。记住,C 编译器是没有线程概念的!这时候就需要用到volatile。volatile 的本意是指:这个值可能会在当前线程外部被改变。也就是说,我们要在threadFunc中的intSignal前面加上volatile关键字,这时候,编译器知道该变量的值会在外部改变,因此每次访问该变量时会重新读取,所作的循环变为如下面伪码所示:
3、Memory
有了上面的知识就不难理解Memory修改描述符了,Memory描述符告知GCC:
1)不要将该段内嵌汇编指令与前面的指令重新排序;也就是在执行内嵌汇编代码之前,它前面的指令都执行完毕。
2)不要将变量缓存到寄存器,因为这段代码可能会用到内存变量,而这些内存变量会以不可预知的方式发生改变,因此GCC插入必要的代码先将缓存到寄存器的变量值写回内存,如果后面又访问这些变量,需要重新访问内存。
如果汇编指令修改了内存,但是GCC 本身却察觉不到,因为在输出部分没有描述,此时就需要在修改描述部分增加“memory”,告诉GCC 内存已经被修改,GCC 得知这个信息后,就会在这段指令之前,插入必要的指令将前面因为优化Cache 到寄存器中的变量值先写回内存,如果以后又要使用这些变量再重新读取。
使用“volatile”也可以达到这个目的,但是我们在每个变量前增加该关键字,不如使用“memory”方便。
4.6.2 内存屏障
内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念,不同的叫法。通过volatile标记,可以解决编译器层面的可见性与重排序问题。而内存屏障则解决了硬件层面的可见性与重排序问题。
4.6.2.1 内存屏障
mb(), wmb(), mb(),可以防止硬件上的指令重排。除了编译器,有的CPU也支持对指令进行重排来优化程序执行效率,这几个函数就是去防止CPU去做这些事情。rmb()是读访问内存屏障,它保证在屏障(调用rmb()的位置处)之后的任何读操作在执行之前,屏障之前的所有读操作都已经完成。wmb()对应写操作,保证在屏障(调用wmb()的位置处)之后的任何写操作之前,屏障之前的所有写操作都已经完成。mb()就同时包含读和写操作,保证在屏障(调用wmb()的位置处)之后的任何读或写操作之前,屏障之前的所有读和写操作都已经完成。
4.6.2.2 优化屏障
barrier(),防止编译器对内存访问的优化,类似volatile关键字对于访问变量的作用。它告诉编译器,在插入barrier()的位置处,内存中的内容都被更新了,你想读变量、映射到内存的寄存器等内容都需要真正到内存里去读,这样就能保证barrier之后的读指令不会被优化掉。
4.6.2.3 内存屏障和优化屏障
内存屏障是和硬件即CPU特性相关的,那么,如果你的CPU没有指令重排的能力,也就没有必要防止指令重排了。例如,一款CPU不支持写指令重排,那么系统中的wmb()就直接被定义成了barrier()。还有SMP系统中使用的smp_rmb(), smp_wmb(), smp_mb(),它们只用于SMP系统。在单处理器上它们被定义成barrier()。
当然,防止优化后,受影响的代码执行效率会降低,但为了保证正确性,牺牲一点性能是值得的。优化屏障的一个特定应用是内核的抢占机制。
五、参考文章
https://www.cnblogs.com/xuanbjut/p/11608991.html
https://www.cnblogs.com/yanlong300/p/8986041.html
https://blog.csdn.net/weixin_44363885/article/details/92838607
https://blog.csdn.net/jasonchen_gbd/article/details/80151265
https://blog.csdn.net/qq_30055391/article/details/84892936
https://blog.csdn.net/qq_32680371/article/details/107848269