1、一个最简单cpu的数据通路
可以看到,cpu内部一直重复执行着 Fetch(取指令)–> decode(指令译码)–> execute(执行指令),这个循环叫做指令周期。pc寄存器中存储的地址,需要地址译码器来寻址,在偌大的内存中找到对应地址存储的指令后,存入指令寄存器,再通过指令译码器把指令翻译成各个线路的控制信号给到运算器(运算器ALU是没有状态的,只能根据输入计算并输出结果),运算器输出结果,再结果写入到存储器中。
2、cpu是如何做到自动重复执行,如何实现存储,如何让pc寄存器自动增加
我们先来看一下左边这个简单的电路,开关B原本是闭合的,我们把开关A合上后,开关B则会不停的断开和闭合。对于下游电路来说就是不断产生0和1这样的信号,这就是我们的时钟信号。我们只需要在每次电路接通时对pc寄存器加1,即可以实现每间隔一个时钟周期 pc寄存器就会加1。
有了震荡电路,我们还需要解决数据的存储问题。请看图1,开关全部断开输出为0,合上开关R输出为1,断开开关R输出仍为1,再合上开关S输出则变为0。 可以看到这个电路开关都断开时的输出结果与上一次的操作有关。这就是记忆功能,该电路能存储一个bit的信息。(如图2,对电路再做一些完善,加上时钟信号,并只提供一个输入端,实现对一个bit的读写,我们叫做D型控制器)
有了时钟信号,D型控制器,再加上加法器即可以实现pc寄存器的自增了。
3、cpu分级设计提升性能
有了时钟信号,cpu就能实现自动重复执行: Fetch(取指令)–> decode(指令译码)–> execute(执行指令)。每一个时钟周期,程序计数器就会加1,对于单指令周期处理的方式, 显然在这个时钟周期内我们必须完成处理一个指令的所有步骤。因为下一个时钟周期来临,我们必须要处理下一条指令了。这种处理指令的方式很简单,但是有一个最大的弊端,一个时钟周期必须保证处理完一条最复杂的指令,那么当处理简单的指令时就会浪费很多的时间。
我们知道在处理指令时,不同的操作阶段使用到cpu中不同的组件,我们把这一整套处理操作比作成工厂里的流水线。我们只需要保证一个时钟周期内执行完一个最复杂的操作即可,这就是流水线分级处理,如下图所示。这样我们就充分的利用了每个组件,提升处理指令的效率。
4、cpu分级后的挑战
虽然分级后明显提升了cpu的效率,但是缺也带来了很多的挑战。
- 功耗:分级了后则线路变得复杂,数据的存储变得更多,功耗资源消耗更大
- 结构冒险:可能存在多个步骤之间同时执行时争抢资源
- 数据冒险:多个指令之间的数据存在依赖关系,先读后写,先写后读,先写再写
- 出现if else时:指令并非是顺序执行的,那么分级后默认的顺序执行就可能出错
针对这些问题,我们提出了一些解决方案
- 针对功耗的问题,我们只需要控制好分级的层数即可,目前流水线级数已经达到了14级
- 针对资源冲突,我们可以增加资源
- 针对数据冒险,最简单的办法就是在指令中插入等待操作NOP。但是单纯的停顿等待太过于浪费时间,我们可以使用操作数前推的方式,把上一个计算的结果,直接转发到下一个指令的执行阶段。这样我们需要多拉出一根信号传输线路。但是这样可以省去把结果写入寄存器,再读取出来的步骤的时间。提前执行下一条指令。但是也没法完全避免等待,毕竟还是需要等待上一个指令把结果计算出来。
-
乱序执行,在等待阶段,某个部件其实是空闲的,那么后面的指令若没有依赖关系,则可以不用等前面的指令,自己先执行。乱序执行实现比较复杂,大致情况如下:
-
取指令和指令译码还是顺序执行的,但是译码完成后CPU 不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站的地方。
-
保留站中的这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元,其实就是 ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。
-
指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区的地方。在重排序缓冲区里,我们的 CPU 会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果。
-
实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer 面,最终才会写入到高速缓存和内存里。
-
-
如果存在 if else 这种代码,那么取指令和译码就不能没有停顿一直执行下去了。因为我们无法得知下一条指令存储的地址。if else 的逻辑对应到指令 cmp(比较), jmp(跳转)。只有等到 jmp 执行完后去更新了pc寄存器,我们才能去取下一条指令。此处则用到了分支预测方案进行处理:
-
静态预测,假装分支不发生,直接往下执行,成功的概率百分之五十,命中率太低。
-
动态预测,根据前面的结果来判断后面的分支跳转,成功的概率大大提高。 例如for 循环,第一次不跳转,则预测下一次也不跳转。
-
预测错误处理,当上一条指令真正的分支判断结果出来后,发现预测错误,则需要清除掉已经执行了一半的操作,重新取指令并译码执行。只要去除指令代价不大就是很划算的。
-
分支预测的例子,代码如下,同样循环了十亿次,第二段程序比第一段程序多花费的好几倍时间。这个差异就来自我们上面说的分支预测。看下图可以发现第一段程序预测错误10万次,而第二段程序预测错误了1000万次。
-
public class BranchPrediction {
public static void main(String args[]) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
for (int j = 0; j <1000; j ++) {
for (int k = 0; k < 10000; k++) {
}
}
}
long end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start));
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
for (int j = 0; j <1000; j ++) {
for (int k = 0; k < 100; k++) {
}
}
}
end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start) + "ms");
}
}