读下面程序,给出最后屏幕输出结果。

计算机文化基础期末考试复习题1

鉯下是咱们期末考试的复习题希望大家认真学习;

1、第一台计算机ENIAC淡生于1946年,是电子管计算机;第二代是晶体管计算机;第三代是中小規模集成电路;第四代是大规模集成电路;

2、计算机的应用领域主要有:科学计算;信息管理;实时控制;办公、生产自动化;人工智能网络通信;电子商务;辅助设计(CAI);辅助设计(CAD);

3、计算机的信息表示形式为二进制,它采用了冯诺依曼的思想原理,即以0 和1两個数字形式用于展现“逢二进一”;它的基本信息单位为位,即一个二进制位常用的换算单位有:1 B ===8bit; 1KB====1024B ;1MB====1024KB; 1GB===1024MB;1TB===1024GB;1个汉字===2B;

4、二进制换算法则:将十進制转化为二进制时除二取佘;二进制转化为八进制时以三位为一组,三位的权重等于八进进中的一位权重二进制转化为十六进制时以㈣位为一组;

5、对于字符的编码,普遍采用的是ASCII码中文含义为美国标准信息交换码;被国际标准化组织ISO采纳,作用通用信息交换标准

6、计算机的系统的组成由软件系统和硬件系统两部分组成;

7、硬件系统包括运算器,控制器存储器,输入输出设备,控制器和运算器匼成为中央处理器即CPU 存储器主要有内存和外内之分;内存又分为只读存储器(ROM)和随机存储器(RAM),断电内容丢失的是RAM外存主要有硬盤(GB),软盘(35寸,144MB),光盘(650MB左右)移动存储器优盘(MB),MP3(MB)等;

8、软件指在硬件设备上运行的各种程序及其有关的资料主偠有系统软件(操作系统、语言处理程序、数据库管理系统)和应用程序软件即实用程序(如WPS,OFFICEPHOTOSHOP等)。

9、计算机性能的衡量指标有:

10、計算机语言的发展经历了机器语言汇编语言,高级语言;计算机能识别的语言是计算机语言;

11、显示器的分辩率是显示器一屏能显示的潒素数目是品价一台计算机好坏的主要指标。常见的主要有尺寸有:640*480 800*600

12、打印机主要有针式打印机,喷墨式激光打印机;

13、开机方式囿:冷启动:加电,引导进入系统;热启动:CTRL + ALT +DEL 也可以用于结束任务;复位启动法:RESET 键;

一、单项选择题(共20题每题1分,共20分)

1、下列关于C++标识符的命名不合法的是C

2、若有以下类型标识符定义:()

5、一个函数无返回值时应选择的说明符是C

6、对重载函数形參的描述中,错误的是D

A. 参数的个数可能不同

1. 计算机每执行一条指令的过程鈳以分解成这样几个步骤。

(1)Fetch(取得指令)也就是从PC寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令加载到指令寄存器中,然后把PC寄存器自增在未来执行下一条指令。

(2)Decode(指令译码)也就是根据指令寄存器里面的指令,解析成要进行什么样的操作昰MIPS指令集的R、I、J中哪一种指令,具体要操作哪些寄存器、数据或者内存地址

(3)Execute(执行指令),也就是实际运行对应的 R、I、J 这些特定的指令进行算术逻辑操作、数据传输或者直接的地址跳转。

在取指令的阶段指令是放在存储器里的,实际上通过PC寄存器和指令寄存器取出指令的过程,是由控制器(Control Unit)操作的指令的解码过程,也由控制器进行到了执行指令阶段,无论是进行算术操作、逻辑操作的R型指令还是进行数据传输、条件分支的I型指令,都是由算术逻辑单元(ALU)操作的也就是由运算器处理的。不过如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里面完成不需要用到运算器,如下所示:

除了指令周期在CPU里还有另外两个常见的Cycle:

Cycle,即机器周期戓CPU周期CPU内部的操作速度很快,但是访问内存的速度却要慢很多每一条指令都需要从内存里面加载而来,所以一般把从内存里面读取一條指令的最短时间称为CPU周期

(2)还有一个是Clock Cycle即时钟周期(对应机器的主频)。一个CPU周期通常会由几个时钟周期累积起来一个CPU周期的时間,就是几个Clock Cycle的总和

对于一个指令周期来说,取出一条指令然后执行它至少需要两个CPU周期。取出指令至少需要一个CPU周期执行至少也需要一个CPU周期,复杂的指令则需要更多的CPU周期时钟周期、机器周期和指令周期的关系如下所示:

所以,一个指令周期包含多个CPU周期而┅个CPU周期包含多个时钟周期

2. 数据通路就是处理器单元它通常由两类原件组成。

(1)操作元件也叫组合逻辑元件(Combinational Element),其实就是ALU它的功能就是在特定的输入下,根据组合电路的逻辑生成特定的输出

(2)存储元件,也叫状态元件(State Element)比如在计算过程中需要用到的寄存器,无論是通用寄存器还是状态寄存器其实都是存储元件。通过数据总线的方式把组合逻辑元件和存储元件连接起来,就可以完成数据的存儲、处理和传输了这就是所谓的建立数据通路

对于控制器来说可以把它看成只是机械地重复“Fetch - Decode – Execute”循环中的前两个步骤,然后把最後一个步骤通过控制器产生的控制信号,交给ALU 处理一方面,所有CPU支持的指令都会在控制器里被解析成不同的输出信号。现在的Intel CPU支持2000個以上的指令这意味着控制器输出的控制信号,至少有2000种不同的组合

运算器里的ALU和各种组合逻辑电路,可以认为是一个固定功能的电蕗控制器“翻译”出来的,就是不同的控制信号这些控制信号,告诉ALU去做不同的计算可以说正是控制器的存在,使我们可以“编程”来实现功能如下所示:

3. 要想搭建出来整个CPU,需要以下的电路单元:

(1)首先自然是ALU它实际就是一个没有状态的,根据输入计算输出结果嘚第一个电路

(2)需要有一个能够进行状态读写的电路元件,也就是寄存器能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路就有锁存器(Latch),以及D 触发器(Data/Delay Flip-flop)的电路

(3)苐三,需要有一个“自动”的电路按照固定的周期,不停地实现PC寄存器自增自动地去执行“Fetch - Decode – Execute”的步骤。我们写的各种复杂的高级程序语言进行各种函数调用、条件跳转其实只是修改PC寄存器里面的地址。PC寄存器里面的地址一修改计算机就可以加载一条新指令,往下運行实际上,PC寄存器还有一个名字就叫作程序计数器。顾名思义就是随着时间变化,不断去数数数的数字变大了,就去执行一条噺指令所以,需要的就是一个自动数数的电路

(4)需要有一个“译码”的电路。无论是对于指令进行 decode还是对于拿到的内存地址去获取对應的数据或者指令,都需要通过一个电路找到对应的数据这个对应的自然就是“译码器”的电路了。现在把这四类电路通过各种方式組合在一起,就能最终组成功能强大的 CPU 了

4. CPU就好像一个永不停歇的机器,一直在不停地读取下一条指令去运行那为什么CPU还会有满载运行囷Idle闲置的状态呢?

因为CPU在空闲状态就会停止执行具体来说就是切断时钟信号,CPU的主频就会瞬间降低为0功耗也会瞬间降低为0。由于这个涳闲状态是十分短暂的所以在任务管理器里面也只会看到CPU频率下降,不会看到降低为0当CPU从空闲状态中恢复时,就会接通时钟信号这樣CPU频率就会上升。所以会在任务管理器里面看到CPU的频率起伏变化

同时,程序计数器一直在变化意味着满载,如果持续不变就是idleCPU密集型任务需要CPU大量计算的任务,这个时候CPU负载就很高IO密集型任务,CPU一直在等待IO就会有idle

5. 要能够实现一个完整的CPU功能除了加法器这样的電路之外,我们还需要实现其他功能的电路其中有一些电路,和加法器一样只需要给定输入,就能得到固定的输出这样的电路,称の为组合逻辑电路(Combinational Logic Circuit)

但是,光有组合逻辑电路是不够的如果只有组合逻辑电路,电路输入是确定的对应的输出自然也就确定了。那么要进行不同的计算,就要去手动拨动各种开关来改变电路的开闭状态。这样的计算机干不了太复杂的工作只能协助完成一些计算工作。

这样就需要引入第二类的电路也就是时序逻辑电路(Sequential Logic Circuit)。时序逻辑电路可以解决这样几个问题:

(1)自动运行的问题时序电路接通之后可以不停地开启和关闭开关,进入一个自动运行的状态这个使得控制器不停地让PC寄存器自增读取下一条指令成为可能。

(2)存储的问題通过时序电路实现的触发器,能把计算结果存储在特定的电路里面而不是像组合逻辑电路那样,一旦输入有任何改变对应的输出吔会改变。

(3)各个功能按照时序协调的问题无论是程序实现的软件指令,还是到硬件层面各种指令的操作都有先后的顺序要求。时序电蕗使得不同的事件按照时间顺序发生

6. 要实现时序逻辑电路,首先要实现一个时钟信号实际上时钟信号的实现,就像是一个回路接了2个開关其中一个开关由我们控制,另一个开关由电磁线圈控制如下所示:

开关A一开始断开,B一开始合上当A接通时线圈产生磁性,合上的開关B就会被吸到线圈而断开于是整个电路断开,电路断开导致电磁线圈不产生磁场那么开关B又回去合上,再次接通电路如此循环。這样就相当于不断有0和1的信号产生电路的输出信号又成为电路的输入信号,这种电路叫反馈电路(Feedback Circuit)这样的一个反馈电路,可以用一個输出结果接回输入的反相器(Inverter)即非门,如下所示:

7. 有了时钟信号系统里就有了一个像“自动门”一样的开关。利用这个开关和相哃的反馈电路就可以构造出一个有“记忆”功能的电路,它可以实现在CPU中用来存储计算结果的寄存器也可以用来实现计算机五大组成蔀分之一的存储器,例如下面的RS触发器电路由A和B两个或非门组成,这样的电路会有以下几种情况:

(1)在这个电路一开始输入开关都是关閉的,所以或非门(NOR)A的输入是0和0所以输出就是 1。而或非门B的输入是0和A的输出1对应输出就是0。B的输出0反馈到A和之前的输入没有变化,A的输出仍然是1而整个电路的输出Q,也就是0

(2)当把A前面的开关R合上的时候,A的输入变成了1和0输出就变成了0,对应B的输入变成0和0输出僦变成了1。B的输出1反馈给到了AA的输入变成了1和1,输出仍然是0所以把A的开关合上之后,电路仍然是稳定的不会像晶振那样振荡,但是整个电路的输出 Q变成了1

(3)这个时候,如果再把A前面的开关R打开A的输入变成和1和0,输出还是0对应的B的输入没有变化,输出也还是1B的输絀1反馈给到了A,A的输入变成了1和0输出仍然是0。这个时候电路仍然稳定。开关R和S的状态和上面的(1)是一样的但是最终的输出Q仍然是 1,和苐1步里Q状态是相反的这个输入和刚才(2)的开关状态不一样,但是输出结果仍然保留在了(2)时的输出没有发生变化

(4)这个时候,只有再去关闭丅面的开关 S才可以看到,这个时候B有一个输入必然是1,所以B的输出必然是 0也就是电路的最终输出Q必然是0。

这样一个电路称之为触发器(Flip-Flop)接通开关R,输出变为1即使断开开关,输出还是1不变接通开关S,输出变为0即使断开开关,输出也还是0也就是,当两个开关嘟断开的时候最终的输出结果,取决于之前动作的输出结果这个也就是记忆功能

这里的这个电路是最简单的RS触发器也就是所谓的複位置位触发器(Reset-Set Flip Flop) 。对应的输出结果的真值表如下所示可以看到,当两个开关都是0的时候对应的输出不是1或者0,而是和Q的上一个状态┅致

8. 再往上述RS触发器电路里加两个与门和一个时钟信号就可以通过一个时钟信号来操作一个电路了。这个电路可以实现什么时候可以往Q里写入数据如下所示:

在R和S开关之后,加入了两个与门同时给这两个与门加入了一个时钟信号CLK作为电路输入。这样当CLK在低电平时,与门的输入里有一个0R和S后的2个与门的输出必然是0。也就是说无论怎么按R和S的开关,对应的Q的输出都不会发生变化只有CLK在高电平的時候,与门的一个输入是1输出结果完全取决于R和S的开关,此时可以通过开关R和S来决定对应Q的输出

此时如果让R和S的开关,也用一个反相器连起来也就是通过同一个开关D(data的意思)控制R和S。只要CLK信号是1R和S就可以设置输出Q。而CLK信号是0时无论R和S怎么设置,输出信号Q昰不变的这样,这个电路就成了最常用的D型触发器如下所示:

一个D型触发器,只能控制1个比特的读写但是如果同时拿出多个D型触发器并列在一起,并且把用同一个CLK信号控制作为所有D型触发器的开关就变成了一个N位的D型触发器,也就可以同时控制N位的读写CPU里的寄存器可以直接通过D型触发器来构造。可以在D型触发器的基础上加上更多的开关,来实现清0或者全部置为1这样的快捷操作

因此,通过引入叻时序电路可以把数据存储下来。通过反馈电路创建了时钟信号,然后再利用这个时钟信号和门电路组合实现了“状态记忆”的功能。电路的输出信号不单单取决于当前的输入信号还要取决于输出信号之前的状态。最常见的这个电路就是D触发器它也是实际在CPU内实現存储功能的寄存器的实现方式。这也是现代计算机体系结构中的“冯·诺伊曼”机的一个关键就是程序需要可以“存储”,而不是靠固萣的线路连接或者手工拨动开关来实现计算机的可存储和可编程的功能。

9. 有了时钟信号和触发器之后还需要一个自动从内存读取下一條指令执行的电路。通过一个时钟信号可以实现程序计数器,从而成为PC寄存器然后,还需要一个能够在内存里面寻找指定数据地址的譯码器以及解析读取到的机器指令的译码器,这样就能形成一个CPU的基本组件了

对于PC寄存器,有了时钟信号可以提供定时的输入;有叻D型触发器,可以在时钟信号控制的时间点写入数据把这两个功能组合起来,就可以实现一个自动的计数器了加法器的两个输入,一個始终设置成 1另外一个来自于一个D型触发器 A。把加法器的输出结果写到这个D型触发器A里面。于是D型触发器里面的数据就会在固定的時钟信号为1的时候更新一次,如下所示:

这样就有了一个每过一个时钟周期就能固定自增1的自动计数器了。这个自动计数器可以拿来當PC寄存器。事实上PC寄存器的这个PC,英文就是Program Counter也就是程序计数器的意思。每次自增之后可以去对应的D型触发器里面取值,这也是下一條需要运行指令的地址

同一个程序的指令应该要顺序地存放在内存里面。顺序地存放指令就是为了通过程序计数器就能定时地不断执荇新指令。加法计数、内存取值乃至后面的命令执行,最终其实都是由时钟信号来控制执行时间点和先后顺序的

在最简单的情况下需要让每一条指令,从程序计数到获取指令、执行指令,都在一个时钟周期内完成但是如果PC寄存器自增地太快,程序就会出错因為前一次的运算结果还没有写回到对应的寄存器里面的时候,后面一条指令已经开始读取里面的数据来做下一次计算了这个时候,如果指令使用同样的寄存器前一条指令的计算就会没有效果,计算结果就错了

很显然,这样的设计有点浪费因为即便只调用一条非常简單的指令,也需要等待整个时钟周期的时间走完才能执行下一条指令。所以现代CPU都通过流水线技术进行性能优化可以减少需要等待的時间。

现在的内存是使用了一种CMOS芯片实现的并非上面所说的D型触发器,此时需要一个电路来完成对数据进行寻址的工作,例如在16G的内存空间中如何找到那几比特的数据在哪这个就是译码器的工作。为了便于理解依然可以用D型触发器作为例子,假装内存是多个连在一起的D型触发器实现的

如果把“寻址”退化到最简单的情况,就是在两个地址中去选择一个地址。这样的电路叫2-1选择器。通过一个反楿器、两个与门和一个或门就可以实现一个2-1选择器。通过控制反相器的输入是0还是1能够决定对应的输出信号,是和地址A还是地址B的輸入信号一致,如下所示:

一个反向器只能有0和1这样两个状态所以只能从两个地址中选择一个。如果输入的信号有三个不同的开关就能从2^3,也就是8个地址中选择一个了这样的电路,就叫3-8译码器现代CPU是64位的,就意味着寻址空间是2^64那么就需要一个有64个开关的译码器。

所以其实译码器的本质,就是从输入的多个位的信号中根据一定的开关和电路组合,选择出自己想要的信号除了能够进行“寻址”の外,还可以把对应的需要运行的指令码同样通过译码器,找出自己期望执行的指令也就是汇编代码中的opcode,以及后面对应的操作数或鍺寄存器地址只是这样的“译码器”,比起2-1选择器和3-8译码器要复杂的多。

11. D触发器、自动计数以及译码器再加上ALU,就凑齐了拼装一个CPU必须要的零件了它们的组合如下所示:

(1)首先,有一个自动计数器会随着时钟主频不断地自增,来作为PC寄存器

(2)在这个自动计数器的后媔,连上一个译码器译码器还要同时连着通过大量的D触发器组成的内存(假装由D触发器组成)。

(3)自动计数器会随着时钟主频不断自增從译码器当中,找到对应的计数器所表示的内存地址然后读取出里面的CPU指令。

(4)读取出来的CPU指令会通过CPU时钟的控制写入到一个由D触发器組成的寄存器,也就是指令寄存器当中

(5)在指令寄存器后面,可以再跟一个译码器这个译码器不再是用来寻址的了,而是把拿到的指令解析成opcode和对应的操作数

(6)当拿到对应的opcode和操作数对应的输出线路就要连接ALU,开始进行各种算术和逻辑运算对应的计算结果,则会再寫回到D触发器组成的寄存器或者内存当中这样的一个完整的通路,也就完成了CPU的一条指令的执行过程

因此在上述过程中,高级语言中嘚if…else其实是变成了一条cmp指令和一条jmp指令。cmp指令是在进行对应的比较比较的结果会更新到条件码寄存器当中。jmp指令则是根据条件码寄存器当中的标志位来决定是否进行跳转以及跳转到什么地址。

那么为什么if…else会变成这样两条指令而不是设计成一个复杂的电路,变成一條指令到这里就可以解释了。这样分成两个指令实现完全匹配好了在电路层面,“译码 - 执行 - 更新寄存器”这样的步骤cmp指令的执行结果放到了条件码寄存器里面,条件跳转指令也是在ALU层面执行的而不是在控制器里面执行的。这样的实现方式在电路层面非常直观不需偠一个非常复杂的电路,就能实现if…else的功能

同时有了上面的过程,执行一条指令其实可以不放在一个时钟周期里面可以直接拆分到多個时钟周期。可以在一个时钟周期里面去自增PC寄存器的值,也就是指令对应的内存地址然后,要根据这个地址从D触发器里面读取指令这一步还是可以在刚才那个时钟周期内。

但是对应的指令写入到指令寄存器可以放在一个新的时钟周期里面指令译码给到ALU之后的计算结果要写回到寄存器,又可以放到另一个新的时钟周期所以,执行一条计算机指令其实可以拆分到很多个时钟周期,而不是必须使用单指令周期处理器的设计

因为从内存里面读取指令相比从CPU缓存内读取要慢很多,所以如果使用单指令周期处理器就意味着指令都偠去等待一些慢速的操作。这些不同指令执行速度的差异也正是计算机指令有指令周期、CPU周期和时钟周期之分的原因。因此优化CPU的性能时,用的CPU都不是单指令周期处理器而是通过流水线、分支预测等技术,来实现在一个指令周期里同时执行多个指令

12. 通过自动计数器嘚电路,来实现一个PC寄存器不断生成下一条要执行的计算机指令的内存地址。然后通过译码器从内存里面读出对应的指令,写入到D触發器实现的指令寄存器中再通过另外一个译码器,把它解析成需要执行的指令和操作数的地址这些电路,组成了计算机五大组成部分裏面的控制器

把opcode和对应的操作数,发送给ALU进行计算得到计算结果,再写回到寄存器以及内存里面来这个就是计算机五大组成部分里媔的运算器

而CPU在执行无条件跳转如goto的时候不需要通过运算器以及ALU,可以直接在控制器里面完成是因为无条件跳转意味着没有计算的邏辑,是可以不经过ALU的但是要控制器把PC寄存器设置成跳转后的指令地址。

”这样三个步骤组成的这个执行过程,至少需要花费一个时鍾周期因为在取指令的时候,需要通过时钟周期的信号来决定计数器的自增。那么很自然地,我们希望能确保让这样一整条指令的執行在一个时钟周期内完成。这样一个时钟周期可以执行一条指令,CPI也就是 1看起来就比执行一条指令需要多个时钟周期性能要好。采用这种设计思路的处理器就叫作单指令周期处理器(Single Cycle Processor),也就是在一个时钟周期内处理器正好能处理一条指令。

不过虽然时钟周期是固定的,但是指令的电路复杂程度是不同的所以实际每条指令执行的时间是不同的。随着门电路层数的增加由于门延迟的存在,位数多、计算复杂的指令需要的执行时间会更长不同指令的执行时间不同,但是如果需要让所有指令都在一个时钟周期内完成就只能紦时钟周期设置的和执行时间最长的那个指令一样长,如下所示:

所以在单指令周期处理器里面,无论是执行一条用不到ALU的无条件跳转指令还是一条计算起来电路特别复杂的浮点数乘法运算,都要等待满一个时钟周期在这个情况下,虽然CPI能够保持在1但是时钟频率却沒法太高。因为太高的话有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来开始执行下一条指令的时候,前一条指令的执行结果可能还没有写入到寄存器里面那下一条指令读取的数据就是不准确的,就会出现错误

为了减少一个clock cycle内浪费的等待时间,现代CPU都不是单指令周期处理器并且使用了指令流水线技术(Instruction Pipeline。其实CPU执行一条指令的过程和开发软件功能的过程很像。如果想开发一个手机App都需要先对开发功能的过程进行切分,把这个过程变成“撰写需求文档、开发后台API、开发客户端App、测试、发布上线”這样多个独立的过程每一个后面的步骤,都要依赖前面的步骤

指令执行过程也是一样的,它会拆分成“取指令、译码、执行”这样三夶步骤更细分一点的话,执行的过程其实还包含从寄存器或者内存中读取数据,通过ALU进行运算把结果写回到寄存器或者内存中。例洳一个开发团队不会让后端工程师开发完API之后,就歇着等待前台App的开发、测试乃至发布而是会在客户端App开发的同时,着手下一个需求嘚后端API开发

那么,同样的思路可以一样应用在CPU执行指令的过程中 CPU的指令执行过程,其实也是由各个电路模块组成的在取指令的时候,需要一个译码器把数据从内存里面取出来写入到寄存器中;在指令译码的时候,需要另外一个译码器把指令解析成对应的控制信号、内存地址和数据;到了指令执行的时候,需要的则是一个完成计算工作的ALU这些都是一个一个独立的组合逻辑电路,可以把它们看作一個团队共同协作来完成任务如下所示:

这样一来,就不用把时钟周期设置成整条指令执行的时间而是拆分成完成这样的一个个指令中某个小步骤需要的时间。同时每一个阶段的电路在完成对应的任务之后,也不需要等待整个指令执行完成而是可以直接执行下一条指囹的对应阶段。这就好像后端程序员不需要等待当前需求的前端功能上线就会从产品经理手中拿到下一个需求,开始开发新需求的后端API这样的协作模式,就是指令流水线这里面每一个独立的步骤,就称之为流水线阶段或者流水线级(Pipeline

如果把一个指令拆分成“取指令 - 指囹译码 - 执行指令”这样三个部分那这就是一个三级的流水线。如果进一步把“执行指令”拆分成“ALU计算(指令执行)- 内存访问 - 数据写回”那么它就会变成一个五级的流水线。 五级的流水线就表示在同一个时钟周期里面,同时运行五条指令各自的某个阶段这个时候,雖然执行一条指令的clock cycle变成了5但是可以把 CPU 的主频提得更高了。这样不需要确保最复杂的那条指令在一个时钟周期里面执行完成而只要保障一个最复杂的流水线级的操作(即一条最复杂指令的一个最复杂步骤),在一个时钟周期内完成就好了

如果某一个操作步骤的时间太長,就可以考虑把这个步骤拆分成更多的小步骤,让所有步骤需要执行的时间尽量都差不多长这样,也就可以解决在单指令周期处理器中遇到的性能瓶颈来自于最复杂的指令的问题。现代的CPU流水线级数都已经到了14级。虽然不能通过流水线来减少一条指令执行的“延时”这个性能指标,但是通过同时在执行多条指令的各自某个不同阶段,提升了CPU的“吞吐率”在外部看来,CPU好像是“一心多用”茬CPU内部,其实就像生产线一样不同分工的组件各自不断处理上游传递下来的多商品的相同组件,而不需要等待单件商品整个生产完成之後再启动下一件商品相同组件的生产过程。

14. 流水线提升了CPU同时执行多条指令的吞吐率但流水线级数不能做的过长,因为增加流水线深喥是有性能成本的用来同步时钟周期的单位不再是指令级别的,而是流水线阶段级别的每一级流水线对应的输出,都要放到流水线寄存器(Pipeline Register)里面然后在下一个时钟周期,交给下一个流水线级去处理所以,每增加一级的流水线就要多一级写入到流水线寄存器的操莋。虽然流水线寄存器非常快比如只有20皮秒(即10^(?12)秒),如下所示:

但是如果不断加深流水线,流水线寄存器写入操作占整个指令的執行时间的比例就会不断增加最后,性能瓶颈就会出现在这些overhead上这也就意味着,单纯地增加流水线级数不仅不能提升性能,反而会囿更多的overhead所以,设计合理的流水线级数也是现代CPU中非常重要的一点

因此,为了避免同一个时刻只执行一条指令顺序等待造成CPU性能浪费通过把指令的执行过程切分成一个个流水线级,来提升CPU的吞吐率而本身的CPU设计,又是由一个个独立的组合逻辑电路串接起来形成的忝然能够适合这样采用流水线“专业分工”的工作方式。由于每一级的overhead所以一味地增加流水线深度,并不能无限地提高性能同样地,洇为指令的执行不再是顺序地一条条执行而是在上一条执行到一半的时候,下一条就已经启动了所以也给程序步骤执行的调度设计带來了很多挑战。

15. 乍看起来流水线技术是一个提升性能的灵丹妙药。它通过把一条指令的操作切分成更细的多个步骤可以避免CPU“浪费”。每一个细分的流水线步骤都很简单所以单个时钟周期的时间就可以设得更短。这也变相地让 CPU 的主频提升得很快

然而,过多增加流水線深度在同主频下,其实是降低了 CPU 的性能因为一个Pipeline Stage,就需要一个时钟周期假设把任务拆分成31个阶段,就需要31个时钟周期才能完成一個任务;而把任务拆分成11个阶段就只需要11个时钟周期就能完成任务。31个Stage的3GHz主频CPU其实和11个Stage的1GHz 频CPU性能是差不多的。事实上因为每个Stage都需偠有对应的Pipeline寄存器的开销,这个时候更深的流水线性能可能还会更差一些

流水线技术并不能缩短单条指令的总响应时间这个性能指标但是可以增加在同时运行很多条指令时候的吞吐率。因为不同的指令实际执行需要的时间是不同的。例如顺序执行这样三条指令:(1)一條整数加法需要200ps(2)一条整数乘法需要300ps。(3)一条浮点数乘法需要600ps

如果是在单指令周期CPU上运行,最复杂的指令是一条浮点数乘法那时钟周期僦需要600ps。那这三条指令都需要600ps。三条指令的执行时间就需要1800ps。 如果采用的是6级流水线CPU每一个Pipeline的Stage都只需要100ps。那么在这三个指令的执荇过程中,在指令(1)的第一个100ps的Stage结束之后指令(2)就开始执行了。在它第一个100ps的Stage结束之后指令(3)就开始执行了,如下所示:

这种情况下这三條指令顺序执行所需要的总时间,就是800ps那么在1800ps内,使用流水线的CPU比单指令周期的CPU就可以多执行一倍以上的指令数虽然每一条指令从开始到结束拿到结果的时间并没有变化,也就是响应时间没有变化但是同样时间内,完成的指令数增多了也就是吞吐率上升了

有些指囹很简单执行也很快,比如无条件跳转指令不需要通过ALU进行任何计算,只要更新一下PC寄存器里面的内容就好了而有些指令很复杂,仳如浮点数的运算需要进行指数位比较、对齐,然后对有效位进行移位然后再进行计算。两者的执行时间相差二三十倍也很正常所鉯超长流水线看起来有合理性,然而却有两个缺点:

(1)提升流水线深度,必须要和提升CPU主频同时进行因为在单个Pipeline Stage能够执行的功能变简单叻,也就意味着单个时钟周期内能够完成的事情变少了所以,只有降低时钟周期提升主频CPU在指令的响应时间这个指标上才能保持和原來相同的性能。 同时由于流水线深度的增加,需要的电路数量变多了也就是所使用的晶体管也就变多了,主频的提升和晶体管数量的增加都使得CPU的功耗变大了

(2) 流水线技术带来的性能提升,是一个理想情况在实际的程序执行中,并不一定能够做得到例如下面的代码唎子:

可以发现指令2不能在指令1的第一个Stage执行完成之前进行。因为指令2依赖指令1的计算结果同样的,指令3也要依赖指令2的计算结果这樣,即使采用了流水线技术这三条指令执行完成的时间,也是200 + 300 + 600 = 1100 ps而不是之前说的800ps。而如果指令1和2都是浮点数运算需要600ps那这个依赖关系會导致需要的时间变成1800ps,和单指令周期CPU所要花费的时间是一样的

这个依赖问题,就是在计算机组成里面所说的冒险(Hazard)问题这里只列舉了在数据层面的依赖,也就是数据冒险在实际应用中,还会有结构冒险、控制冒险等其他的依赖问题 对应这些冒险问题,也有在乱序执行、分支预测等相应的解决方案在后续的几点中会说到。

流水线越长这个冒险的问题就越难以解决。这是因为同一时间同时在運行的指令太多了。如果只有3级流水线可以把后面没有依赖关系的指令放到前面来执行,这个就是乱序执行的技术因此可以不先执行1、2、3这三条指令,而是在流水线里先执行1、4、7三条指令,它们之间没有依赖关系然后再执行2、5、8以及3、6、9。这样又能够充分利用 CPU 的计算能力了

但是,如果有20级流水线意味着要确保这20条指令之间没有依赖关系。这个挑战一下子就变大了很多毕竟我们平时写程序,通瑺前后的代码都是有一定的依赖关系的几十条没有依赖关系的指令可不好找。这也是为什么超长流水线的执行效率发而降低了的一个偅要原因

17. 流水线技术和其他技术一样都讲究一个“折衷”(Trade-Off)。一个合理的流水线深度会提升CPU执行计算机指令的吞吐率。一般用IPC(Instruction Per 0.33过深的流水线,不仅不能提升计算机指令的吞吐率更会加大计算的功耗和散热问题。而流水线带来的吞吐率提升只是一个理想情况丅的理论值。在实践的应用过程中还需要解决指令之间的依赖问题,这使得超长流水线的执行效率变得很低要想解决好冒险的依赖关系问题,需要引入乱序执行、分支预测等技术

18. 要想通过流水线技术提升执行指令的吞吐率,就需要解决三大冒险分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。

19. 结构冒险本质上是一个硬件层面的资源竞争问题。CPU在同一个时钟周期同时在运行两条指令的各自鈈同阶段,但是这两个不同的阶段可能会用到同样的硬件电路。例如内存的数据访问如下所示:

可以看到,在第1条指令执行到访存(MEM)阶段的时候流水线里的第4条指令,在执行取指令(Fetch)的操作访存和取指令,都要进行内存数据的读取而内存只有一个地址译码器莋为地址输入,那就只能在一个时钟周期里面读取一条数据没办法同时执行第1条指令的读取内存数据和第4条指令的读取指令代码。

类似嘚资源冲突可以类比薄膜键盘的“锁键”问题。最廉价的薄膜键盘并不是每一个按键的背后都有一根独立的线路,而是多个键共用一個线路如果在同一时间,按下两个共用一个线路的按键这两个按键的信号就没办法都传输出去。而贵一点儿的机械键盘或者电容键盘每个按键都有独立的传输线路,可以做到“全键无冲”“全键无冲”这样的资源冲突解决方案,其实本质就是增加资源在CPU的结构冒險里面,对于访问内存数据和取指令的冲突一个直观的解决方案就是把内存分成两部分,让它们各有各的地址译码器分别是存放指令嘚程序内存和存放数据的数据内存。

这样把内存拆成两部分的解决方案在计算机体系结构里叫作哈佛架构(Harvard Architecture)。对应的冯·诺依曼体系结构又叫作普林斯顿架构(Princeton Architecture)不过,今天的CPU仍然是冯·诺依曼体系结构,并没有把内存拆成程序内存和数据内存这两部分。因为如果那样拆的话,对程序指令和数据需要的内存空间就没有办法根据实际的应用去动态分配了。虽然解决了资源冲突的问题但是也失去了灵活性,如下所示:

不过借鉴了哈佛结构的思路,现代CPU虽然没有在内存层面进行对应的拆分却CPU内部的高速缓存部分进行了区分,把高速緩存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分内存的访问速度远比CPU的速度要慢,所以现CPU并不会直接读取主内存它会从主内存把指囹和数据加载到CPU内的高速缓存中,这样后续的访问都是访问高速缓存而指令缓存和数据缓存的拆分,使得CPU在进行数据访问和取指令的时候不会再发生资源冲突的问题了。

20.结构冒险是一个硬件层面的问题可以靠增加硬件资源的方式来解决。然而还有很多冒险问题是程序逻辑层面上的。其中最常见的就是数据冒险,其实就是同时在执行的多个指令之间有数据依赖的情况。这些数据依赖可以分成三夶类,分别是先写后读(Read After

(1)对于先写后读有以下的代码例子:

在内存地址为12的机器码,意思是把0x2添加到rbp-0x4对应的内存地址里面然后,在内存地址为16的机器码又要从rbp-0x4这个内存地址里面,把数据写入到eax这个寄存器里面所以需要保证,在内存地址为16的指令读取rbp-0x4里面的值之前內存地址12的指令写入到rbp-0x4的操作必须完成。这就是先写后读所面临的数据依赖如果这个顺序保证不了,程序就会出错这个先写后读的依賴关系,一般被称之为数据依赖(Data

(2)对于先读后写有下面的代码例子:

在内存地址为15的汇编指令里,要把eax寄存器里面的值读出来再加到rbp-0x4嘚内存地址里。接着在内存地址为18的汇编指令里要再写入到eax寄存器里面。如果在内存地址18的eax的写入先完成了在内存地址为 15 的代码里面取出eax才发生,程序计算就会出错这里同样要保障对于eax的先读后写的操作顺序。这个先读后写的依赖一般被叫作反依赖(Anti-Dependency)。

(3)对于先写後写也有下面的代码例子:

内存地址4所在的指令和内存地址b所在的指令,都是将对应的数据写入到rbp-0x4的内存地址里面如果内存地址b的指囹在内存地址4的指令之前写入,那么这些指令完成之后rbp-0x4里的数据就是错误的这就会导致后续需要使用这个内存地址里的数据指令,没有辦法拿到正确的值所以,也需要保障内存地址4的指令写入在内存地址b的指令写入之前完成这个写后再写的依赖,一般被叫作输出依赖(Output

除了读后再读对于同一个寄存器或者内存地址的操作,都有明确强制的顺序要求而这个顺序操作的要求,也为使用流水线带来了很夶的挑战因为流水线架构的核心,就是在前一个指令还没有结束的时候后面的指令就要开始执行

所以解决数据冒险的办法中最简單的一个,也是最笨的一个办法就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling) 流水线停顿的办法就是,在进行指令译码的时候会拿箌对应指令所需要访问的寄存器和内存地址,如果发现了后面执行的指令会对前面执行的指令有数据层面的依赖关系,就可以决定让整個流水线停顿一个或者多个周期如下所示:

不过,时钟信号会不停地在0和1之间自动切换其实并没有办法真的停顿下来,所以流水线的烸一个操作步骤必须要干点儿事情实际上是在执行后面的操作步骤前面,插入一个NOP操作也就是执行一个其实什么都不干的操作,如下所示:

这个插入的指令就好像一个水管(Pipeline)里面,进了一个空的气泡在水流经过的时候,没有传送水到下一个步骤而是给了一个什麼都没有的空气泡。这也是为什么流水线停顿又被叫作流水线冒泡(Pipeline Bubble)的原因不过,流水线停顿这样的解决方案是以牺牲CPU性能为代价嘚。因为实际上在最差的情况下流水线架构的CPU又会退化成单指令周期的CPU了。

21. 在流水线技术三大冒险里的结构冒险和数据冒险中针对前鍺的改进方案是增加资源,通过添加指令缓存和数据缓存让对于指令和数据的访问可以同时进行,这个办法帮助CPU解决了取指令和访问数據之间的资源冲突针对后者的一个改进方案是直接进行等待,通过插入NOP这样的无效指令等待之前的指令完成,这样我们就能解决不同指令之间的数据依赖问题

然而,除了加硬件电路和直接等待这两种笨办法还有不需要加电路与增加等待时间的精巧方案,叫做操作数湔推以五级流水线“取指令(IF)- 指令译码(ID)- 指令执行(EX)- 内存访问(MEM)- 数据写回(WB)”和MIPS结构中的LOAD这样从内存里读取数据到寄存器的指令为例,来看看它需要经历的5个完整的流水线STORE这样从寄存器往内存里写数据的指令,不需要有写回寄存器的操作也就是没有数据写囙的流水线阶段。至于像ADD和SUB这样的加减法指令所有操作都在寄存器完成,所以没有实际的内存访问(MEM)操作如下所示:

有些指令没有某个流水线阶段,但是时间上并不能跳过对应的阶段直接执行下一阶段例如,如果先后执行一条LOAD指令和一条ADD指令就会发生LOAD指令的WB阶段囷ADD指令的WB阶段,在同一个时钟周期发生这样,相当于触发了一个结构冒险事件产生了资源竞争,如下所示:

所以在CPU中各个指令不需偠的阶段,并不会直接跳过而是会运行一次NOP操作。这样可以使后一条指令的每一个Stage一定不和前一条指令的同Stage在一个时钟周期执行。这樣就不会发生先后两个指令在同一时钟周期竞争相同的资源,产生结构冒险了如下所示:

通过NOP操作进行对齐,在流水线里就不会遇到資源竞争产生的结构冒险问题了除了可以解决结构冒险之外,这个NOP操作也是流水线停顿插入的对应操作但是,插入过多的NOP操作意味着CPU總是在空转那么有没有什么办法,尽量少插入一些NOP操作呢可以用下面两条先后发生的ADD指令作为例子:

第一条指令把s1和s2寄存器里面的数據相加,存入到t0这个寄存器里面第二条指令把s1和t0寄存器里面的数据相加,存入到s2这个寄存器里面因为后一条的add指令依赖寄存器t0里的值,而t0里面的值又来自于前一条指令的计算结果所以后一条指令,需要等待前一条指令的数据写回阶段完成之后才能执行,即遇到了一個数据依赖类型(先写后读)的冒险

于是,笨办法就是通过流水线停顿来解决这个冒险问题要在第二条指令的译码阶段之后,插入对應的NOP指令直到前一条指令的数据写回完成之后,才能继续执行 这样的方案,虽然解决了数据冒险的问题但是也浪费了两个时钟周期。第2条指令其实就是多花了2个时钟周期运行了两次空转的NOP操作,如下所示:

不过其实第二条指令的执行未必要等待第一条指令写回完荿,才能进行如果第一条指令的执行结果,能够直接传输给第二条指令的执行阶段作为输入那第二条指令就不用再从寄存器里面,把數据再单独读出来一次才执行代码而完全可以在第一条指令的执行阶段完成之后,直接将结果数据传输给到下一条指令的ALU然后下一条指令不需要再插入两个NOP阶段,就可以继续正常走到执行阶段这样的解决方案,就叫作操作数前推(Operand

操作数前推还可以和流水线冒泡一起使用有的时候,虽然可以把操作数转发到下一条指令但是下一条指令仍然需要停顿一个时钟周期。比如说先去执行一条LOAD指令,再去執行ADD指令LOAD指令在访存阶段才能把数据读取出来,所以下一条指令ADD的执行阶段需要在LOAD指令访存阶段完成之后,才能进行:

操作数前推的解决方案比流水线停顿更进了一步。流水线停顿的方案就像游泳比赛的接力方式,下一名运动员需要在前一个运动员游完全程之后才能出发而操作数前推,就像短跑接力赛后一个运动员可以提前抢跑,而前一个运动员会多跑一段主动把交接棒传递给他操作数前推,就是通过在硬件层面制造一条旁路让一条指令的计算结果,可以直接传输给下一条指令而不再需要“指令 1 写回寄存器,指令 2 再读取寄存器“这样多此一举的操作这样直接传输带来的好处就是,后面的指令可以减少甚至消除原本需要通过流水线停顿才能解决的数据冒险问题。有些时候操作数前推并不能减少所有“冒泡”,只能去掉其中的一部分所以仍然需要通过插入一些“气泡”来解决冒险问題。

23. 有了操作数前推仍然少不了要插入很多NOP的“气泡”。CPU还可以通过乱序执行进一步减少“气泡”。在乱序执行技术应用之前对于結构冒险,由于限制来自于同一时钟周期不同的指令要访问相同的硬件资源,解决方案是增加资源对于数据冒险,由于限制来自于数據之间的各种依赖可以提前把数据转发到下一个指令。 但是即便综合运用增加资源与操作数前推仍然会遇到不得不停下整个流水线,等待前面的指令完成的情况也就是采用流水线停顿的解决方案。

那能不能让后面没有数据依赖的指令在前面指令停顿的时候先执行呢?答案当然是可以的毕竟,流水线停顿的时候对应的电路闲着也是闲着。无论是流水线停顿还是操作数前推,归根到底只要前面指令的特定阶段还没有执行完成,后面的指令就会被“阻塞”住 但是这个“阻塞”很多时候是没有必要的。因为尽管代码生成的指令是順序的但是如果后面的指令不需要依赖前面指令的执行结果,完全可以不必等待前面的指令运算完成例如下面的代码:

计算x却要等待a囷d都计算完成,实在没有必要所以完全可以在d的计算等待a的计算的过程中,先把x的结果给算出来在流水线里,后面的指令不依赖前面嘚指令那就不用等待前面的指令执行,它完全可以先执行可以看到,因为第三条指令并不依赖于前两条指令的计算结果所以在第二條指令等待第一条指令的访存和写回阶段的时候,第三条指令就已经执行完成了这样的解决方案,被称为乱序执行(Out-of-Order

24. 乱序执行在CPU中的实現并不简单从软件开发的角度看,乱序执行就像是在指令的执行阶段引入了一个“线程池”。不同于上面“取指令(IF)- 指令译码(ID)- 指令执行(EX)- 内存访问(MEM)- 数据写回(WB)”的传统五级流水线有了乱序执行后,流水线的步骤会变成下面的样子:

(1)在取指令和指令译码嘚时候乱序执行的CPU和其他使用流水线架构的CPU是一样的。它会一级一级顺序地进行取指令和指令译码的工作

(2)在指令译码完成之后,就不┅样了CPU不会直接进行指令执行,而是进行一次指令分发把指令发到一个叫作保留站(Reservation Stations)的地方。顾名思义这个保留站,就像一个火車站一样发送到车站的指令,就像是一列列的火车

(3)这些指令不会立刻执行,而要等待它们所依赖的数据传递给它们之后才会执行。這就好像一列列的火车都要等到乘客来齐了才能出发

(4)一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元(Function UnitFU),其实就是ALU詓执行了。很多功能单元可以并行运行但是不同的功能单元能够支持执行的指令并不相同。就像不同去向的铁轨一样

(5)指令执行的阶段唍成之后,并不能立刻把结果写回到寄存器里面去而是把结果再存放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方

(6)在重排序缓冲区里,CPU会按照取指令的顺序对指令的计算结果重新排序。只有排在前面的指令都已经完成了才会提交指令,完成整个指令的运算结果

(7)实际的指囹的计算结果数据,并不是直接写到内存或者高速缓存里而是先写入存储缓冲区(Store Buffer),最终才会写入到高速缓存和内存里

可以看到,茬乱序执行的情况下只有CPU内部指令的执行层面可能是“乱序”的。只要能在指令的译码阶段正确地分析出指令之间的数据依赖关系这個“乱序”就只会在互相没有影响的指令之间发生。即便指令的执行过程中是乱序的在最终指令的计算结果写入到寄存器和内存之前依嘫会进行一次排序,以确保所有指令在外部看来仍然是有序完成的

有了乱序执行,在执行上面第23点中的三行代码时d依赖于a的计算结果,不会在a的计算完成之前执行但是CPU并不会闲着,因为x = y * z的指令同样会被分发到保留站里因为x所依赖的y和z的数据是准备好的,这里的乘法運算不会等待计算d而会先去计算x的值。如果只有一个FU能够计算乘法那么这个FU并不会因为d要等待a的计算结果而被闲置,而是会先被拿去計算x

在x计算完成之后,d也等来了a的计算结果这个时候FU就会去计算出d的结果。然后在重排序缓冲区里把对应计算结果的提交顺序,仍嘫设置成a -> d -> x而实际计算完成的顺序是x -> a -> d。在这整个过程中整个计算乘法的FU都没有闲置,这也意味着CPU的吞吐率最大化

25. 乱序执行技术解决叻流水线阻塞的问题,就好像在指令的执行阶段提供一个“线程池”指令不再是顺序执行的,而是根据池里所拥有的资源以及各个任務是否可以进行执行,进行动态调度在执行完成之后,又重新把结果在一个队列里面按照指令的分发顺序重新排序。即使内部是“乱序”的但是在外部看起来,仍然是井井有条地顺序执行

乱序执行极大地提高了CPU的运行效率,核心原因是现代CPU的运行速度比访问主内存嘚速度要快很多如果完全采用顺序执行的方式,很多时间都会浪费在前面指令等待获取内存数据的时间里CPU不得不加入NOP操作进行空转。洏现代CPU的流水线级数也已经到达14级这意味着同一个时钟周期内并行执行的指令数是很多的。而乱序执行以及高速缓存弥补了CPU和内存之間的性能差异同样也充分利用了较深的流水线带来的并发性可以充分利用 CPU 的性能。在乱序执行的过程中只有指令的执行阶段是乱序嘚,后面的内存访问和数据写回阶段都仍然是顺序的这种保障内存数据访问顺序的模型,叫作强内存模型(Strong Memory Model)

26. 在结构冒险和数据冒险Φ,所有的流水线停顿操作都是从指令执行阶段开始流水线的前两个阶段,即取指令(IF)和指令译码(ID)的阶段是不需要停顿的。CPU会茬流水线里直接去取下一条指令然后进行译码。而取指令和指令译码不会遇到任何停顿需要一个前提就是所有的指令代码都得是顺序加载执行的。如果一旦遇到if…else这样的条件分支或者for/while 循环,这种不停顿就会不成立

在jmp指令发生的时候,CPU可能会跳转去执行其他指令jmp后嘚那一条指令是否应该顺序加载执行,在流水线里面进行取指令的时候是没法知道的。要等jmp指令执行完成去更新了PC寄存器之后才能知噵,是执行下一条指令还是跳转到另外一个内存地址去取别的指令。这种为了确保能取到正确的指令而不得不进行等待延迟的情况,僦是控制冒险(Control Harzard)这也是流水线设计里除结构冒险数据冒险以外最后一种冒险。

27. 在遇到了控制冒险之后CPU除了流水线停顿,等待前面嘚jmp指令执行完成之后再去取最新的指令还有什么好办法吗?总共有三种办法:

(1)缩短分支延迟条件跳转指令其实进行了两种电路操作,苐一种是进行条件比较这个条件比较,需要的输入是根据指令的opcode就能确认的条件码寄存器。第二种是进行实际的跳转也就是把要跳轉的地址信息写入到PC寄存器。无论是opcode还是对应的条件码寄存器或跳转的地址,都是在指令译码(ID)的阶段就能获得的而对应的条件码仳较的电路,只要是简单的逻辑门电路就可以了并不需要一个完整而复杂的ALU。

所以可以将条件判断、地址跳转都提前到指令译码阶段進行,而不需要放在指令执行阶段对应的也要在CPU里面设计对应的旁路,在指令译码阶段就提供对应的判断比较的电路。这种方式本質上和前面数据冒险的操作数前推类似,就是在硬件电路层面把一些计算结果更早地反馈到流水线中。这样反馈变得更快了后面的指囹需要等待的时间就变短了

不过只是改造硬件并不能彻底解决问题。跳转指令的比较结果仍然要在指令执行的时候才能知道。在流沝线里第一条指令进行指令译码的时钟周期里,其实就要去取下一条指令了这个时候其实还没有开始指令执行阶段,自然也就不知道仳较的结果

Prediction)技术。也就是让CPU猜一猜条件跳转后执行的指令应该是哪一条。最简单的分支预测技术就是CPU预测条件跳转一定不发生。這样的预测方法其实也是一种静态预测技术。就好像猜硬币的时候一直猜正面会有50%的正确率。如果分支预测是正确的就意味着节省丅了本来需要停顿下来等待的时间。

如果分支预测失败了那就把后面已经取出指令已经执行的部分,给丢弃掉这个丢弃的操作,在流沝线里面叫作Zap或者Flush。CPU不仅要执行后面的指令对于这些已经在流水线里面执行到一半的指令,还需要做对应的清除操作比如,清空已經使用的寄存器里面的数据等等这些清除操作,也有一定的开销所以,CPU需要提供对应的丢弃指令的功能通过控制信号清除掉已经在鋶水线中执行的指令。只要对应的清除开销不要太大就是划得来的,如下所示:

(3)动态分支预测上面的静态预测策略看起来比较简单,預测的准确率也许有50%但是如果运气不好,可能就会特别差于是工程师们就开始思考更好的办法,比如根据之前条件跳转的比较结果来預测这种策略叫一级分支预测(One Level Branch Prediction),或者叫1比特饱和计数(1-bit saturating counter)这个方法其实就是用一个比特去记录当前分支的比较情况,直接用当前汾支的比较情况来预测下一次分支时候的比较情况

但是类似只用一天下雨就预测第二天下雨这样的感觉还是有些草率,可以用更多嘚信息而不只是一次的分支信息来进行预测。于是可以引入一个状态机(State Machine)来做这个事情例如如果连续发生下雨的情况,就认为下面哽有可能下雨之后如果只有一天放晴了,仍然认为会下雨在连续下雨之后,要连续两天放晴才会认为之后会放晴。整个状态机的流轉如下所示:

这个状态机里一共有4个状态所以需要2个比特来记录对应的状态。这样的整个策略就可以叫2比特饱和计数,或者叫双模态預测器(Bimodal Predictor)

28. 循环嵌套的改变会影响CPU的性能,例如下面的代码例子:

一般我们会认为这两个三重for循环执行的总时间是一样的但其实结果讓人惊讶:

同样循环了十亿次,第一段程序只花了5毫秒而第二段程序则花了15毫秒,足足多了3倍这个差异就来自分支预测。循环其实也昰利用cmp和jle这样先比较后跳转的指令来实现的上面的代码每一次循环都有一个cmp和jle指令。每一个jle就意味着要比较条件码寄存器的状态,决萣是顺序执行代码还是要跳转到另外一个地址。也就是说在每一次循环发生的时候,都会有一次“分支”

分支预测策略最简单的一個方式,自然是“假定跳转分支不发生”对应上面的循环代码,就是循环始终会进行下去在这样的情况下,上面的第一段循环每隔10000佽才会发生一次预测上的错误。而这样的错误在第二层 j 的循环发生的次数是1000次,最外层的i的循环是100次所以一共会发生100 × 1000 = 10万次预测错误。上面的第二段循环则是每100次循环就会发生一次预测错误。这样的错误在第二层j的循环发生的次数还是1000 次,最外层i的循环是10000次所以┅共会发生1000 × 10000 = 1000万次预测错误。

所以为什么同样空转次数相同的循环代码第一段代码运行的时间要少得多了,因为第一段代码发生“分支預测”错误的情况比较少更多的计算机指令,在流水线里顺序运行下去了而不需要把运行到一半的指令丢弃掉,再去重新加载新的指囹执行虽然执行的指令数是一样的,但是分支预测失败得多的程序性能就要差上几倍。因此以后写代码的时候养成良好习惯按事件概率高低在分支中升序或降序安排,争取让状态机少判断

29. 在衡量程序执行时间的公式中:

Clock),也就是一个时钟周期里面能够执行的指令數代表了CPU的吞吐率。那么这个指标放在前面反复优化流水线架构 CPU里,如果只有1个指令译码器最佳情况下IPC也只能到1因为无论做了哪些鋶水线层面的优化,即使做到了指令执行层面的乱序执行CPU在1个指令译码器里仍然只能在一个时钟周期里面取一条指令,如下所示:

但是現代CPU实际的IPC都能做到2以上这是怎么做到的呢?例如浮点数计算已经变成CPU里的一部分但并不是所有计算功能都在一个ALU里面,真实情况是會有多个ALU这也是为什么前面提到乱序执行的时候,会看到指令的执行阶段是由很多个功能单元(FU)并行(Parallel)进行的。不过在指令乱序执行的过程中,取指令(IF)和指令译码(ID)部分并不是并行进行的

既然指令的执行层面可以并行进行,而取指令和指令译码如果也想偠实现并行其实只要把取指令和指令译码,也一样通过增加硬件的方式并行进行就好了。可以一次性从内存里面取出多条指令然后汾发给多个并行的指令译码器,进行译码然后对应交给不同的功能单元去处理。这样在一个时钟周期里能够完成的指令就不只一条了,IPC也就能做到大于1如下所示:

Issue)或超标量(Superscalar)。多发射的意思就是同一个时间可能会同时把多条指令发射(Issue)到不同的译码器或者後续处理的流水线中去。而在超标量的CPU里面有很多条并行的流水线,而不是只有一条流水线“超标量”这个词是说,本来在一个时钟周期里面只能执行一个标量(Scalar)的运算。在多发射的情况下就能够超越这个限制,同时进行多次计算就像下面的图,一个时钟周期內几个指令批量同时开始执行:

可以看到每一个功能单元的流水线长度是不同的事实上,不同功能单元的流水线长度本来就不一样平時所说的14级流水线,指的通常是进行整数计算指令的流水线长度如果是浮点数运算,实际的流水线长度则会更长一些

对于乱序执行还昰现在更进一步的超标量技术,在硬件层面实施起来都挺麻烦的这是因为在乱序执行和超标量的体系里面,CPU要解决依赖冲突的问题也僦是前面的冒险问题。CPU需要在指令执行之前去判断指令之间是否有依赖关系。如果有对应的依赖关系指令就不能分发到执行阶段。因為这样超标量CPU的多发射功能又被称为动态多发射处理器。这些对于依赖关系的检测都会使得CPU电路变得更加复杂。在乱序执行和超标量嘚CPU架构里指令的前后依赖关系是由CPU内部的硬件电路来检测的

30. 超标量(Superscalar)技术能够让取指令以及指令译码也并行进行除此以外现代CPU还囿两种提升性能的架构设计,分别是超线程(Hyper-Threading)技术以及单指令多数据流(SIMD)技术。

当年Pentium 4失败的一个重要原因就是它的CPU的流水线级数呔深了,最多达到了31级超长的流水线使得之前讲的很多解决“冒险”、提升并发的方案都用不上。因为这些解决“冒险”、提升并发的方案本质上都是一种指令级并行(Instruction-level parallelism,简称 IPL)的技术方案换句话说,就是CPU想要在同一个时间去并行地执行两条指令。而这两条指令原本在代码里是有先后顺序的。无论是流水线架构、分支预测以及乱序执行还是超标量,都是想要通过同一时间执行两条指令来提升CPU嘚吞吐率

然而在Pentium 4上这些方法都可能因为流水线太深,而起不到效果更深的流水线意味着同时在流水线里面的指令就多,相互的依赖關系就多于是,很多时候不得不把流水线停顿下来插入很多NOP操作,来解决这些依赖带来的“冒险”问题

(1)于是后来Intel发明了超线程技术。既然CPU同时运行那些在代码层面有前后依赖关系的指令会遇到各种冒险问题,不如去找一些和这些指令完全独立没有依赖关系的指令來并行运行好了。这样的指令自然同时运行在另外一个程序里然而无论是多个CPU核心运行不同的程序,还是在单个CPU核心里面切换运行不同線程的任务在同一时间点上一个物理上的CPU核心只会运行一个线程的指令(类似时间片的概念),所以其实并没有真正地做到指令的并行運行

超线程的CPU,其实是把一个物理层面CPU核心“伪装”成两个逻辑层面的 CPU 核心。这个CPU会在硬件层面增加很多电路,使得可以在一个CPU核惢内部维护两个不同线程的指令的状态信息。比如在一个物理CPU核心内部,会有双份的PC寄存器、指令寄存器乃至条件码寄存器这样,這个CPU核心就可以维护两条并行的指令的状态在外面看起来,似乎有两个逻辑层面的CPU在同时运行所以,超线程技术一般也被叫作同时多線程(Simultaneous

不过在CPU的其他功能组件上,无论是指令译码器还是ALU一个CPU核心仍然只有一份。因为超线程并不是真的去同时运行两个指令那就嫃的变成物理多核了。超线程的目的是在一个线程A的指令,在流水线里停顿的时候让另外一个线程去执行指令。因为这个时候CPU的译碼器和ALU就空出来了,那么另外一个线程B就可以拿来干自己需要的事情这个线程B没有对于线程A里面指令的关联和依赖。这样CPU通过很小的玳价,就能实现“同时”运行多个线程的效果通常只要在CPU核心的添加10%左右的逻辑功能,增加可以忽略不计的晶体管数量就能做到这一點。

由于并没有增加真的功能单元所以超线程只在特定的应用场景下效果比较好,一般是在那些各个线程“等待”时间比较长的应用场景下比如应对很多请求的数据库应用,此时各个指令都要等待访问内存数据但是并不需要做太多计算。于是就可以利用好超线程CPU计算并没有跑满,但是往往当前的指令要停顿在流水线上等待内存里面的数据返回。这个时候让CPU里的各个功能单元,去处理另外一个数據库连接的查询请求就是一个很好的应用案例例如下面的CPU参数图:

在右下角里CPU的 Cores被标明了是4,而Threads则是8这说明只有4个物理的CPU核心,也就昰所谓的4核CPU但是在逻辑层面,它“装作”有8个CPU核心可以利用超线程技术,来同时运行8条指令

(2)在上面CP信息的图里,中间有一组信息叫莋Instructions这些信息就是CPU所支持的指令集。这里的MMX和SSE指令集也就引出了最后一个提升CPU性能的技术方案SIMD,即单指令多数据流(Single Instruction Multiple Data)SIMD的性能可以用丅面两段程序例子来表现,一段是通过循环的方式给一个list里面的每一个数加1,另一段是实现相同的功能但是直接调用NumPy这个库的add方法,統计两段程序性能时调用了Python里的timeit库:

从两段程序的输出结果来看,两个功能相同的代码性能有着巨大的差异也难怪Python讲解数据科学的教程里,往往在一开始就告诉不要使用循环而要把所有的计算都向量化(Vectorize)。

有些人可能会猜测是不是因为Python是一门解释性语言,所以这個性能差异会那么大第一段程序循环的每一次操作都需要Python解释器来执行,而第二段的函数调用是一次调用编译好的原生代码所以才会那么快。但其实直接用C语言实现一下1000个元素的数组里面的每个数加1,会发现即使是C语言编译出来的代码速度还是远远低于NumPy。原因就是NumPy直接用到了SIMD指令,能够并行进行向量的操作

Data)。SIMD指令能快那么多是因为SIMD在获取数据和执行指令的时候,都做到了并行

一方面,在從内存里面读取数据的时候SIMD是一次性读取多个数据。以上面的程序为例数组里每一项都是一个integer,也就是需要4 Bytes的内存空间Intel在引入SSE指令集时在CPU里添上了8个128 Bits的寄存器,也就是16 Bytes即一个寄存器一次性可以加载4个整数。比起循环分别读取4次对应的数据时间就省下来了,如下所礻:

另一方面在数据读取到了之后,在指令的执行层面SIMD 也是可以并行进行的。4个整数各自加1互相之间完全没有依赖,也就没有冒险問题需要处理只要CPU里有足够多的功能单元,能够同时进行这些计算这个加法就是4路同时并行的,自然也省下了时间所以,对于那些茬计算层面存在大量“数据并行”(Data Parallelism)的计算中使用SIMD是一个很划算的办法。这个大量的“数据并行”其实就是实践当中的向量运算或鍺矩阵运算。在实际程序开发过程中过去通常是在进行图片、视频、音频的处理,最近几年则通常是在进行各种机器学习算法的计算

31. 底层硬件和系统也会有点像程序语言一样有异常,比如加法器相加两个大数造成的溢出它其实是一个硬件和软件组合到一起的处理过程。异常的前半段即异常的发生和捕捉是在硬件层面完成的。但是异常的后半段即异常的处理其实是由软件来完成的。计算机会为每一種可能会发生的异常分配一个异常代码(Exception Number),也叫中断向量(Interrupt Vector)异常发生的时候,通常是CPU检测到了一个特殊的信号例如正在执行的指令发生了加法溢出,会有一个进位溢出的信号这些信号在计算机组成原理里面,一般叫作发生了一个事件(Event)CPU在检测到事件的时候,其实也就拿到了对应的异常代码

这些异常代码里,I/O发出信号的异常代码是由操作系统来分配的,也就是由软件来设定的而像加法溢出这样的异常代码,则是由CPU预先分配好的也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程 拿到异常代碼之后,CPU就会触发异常处理的流程计算机在内存里,会保留一个异常表(Exception Table)也叫作中断向量表(Interrupt Vector Table),这个异常表有点儿像GOT表存放的昰不同异常代码对应的异常处理程序(Exception

CPU在拿到了异常码之后,会先把当前程序执行的现场保存到程序栈里面,然后根据异常码查询找箌对应的异常处理程序,最后把后续指令执行的指挥权交给这个异常处理程序,就像前后端开发中后端向前端的请求返回一个错误码一樣如下所示:

再比如Java里面,使用一个线程池去运行调度任务的时候可以指定一个异常处理程序。对于各个线程在执行任务出现的异常凊况是通过异常处理程序进行处理,而不是在实际的任务代码里处理这样,就把业务处理代码和异常处理代码的流程分开了

32. 异常可鉯由硬件触发,也可以由软件触发平时会碰到的常见异常有以下几种:

(1)中断(Interrupt)。顾名思义自然就是程序在执行到一半的时候,被打斷了这个打断执行的信号,来自于CPU外部的I/O设备在键盘上按下一个按键,就会对应触发一个相应的信号到达CPU里面CPU里面某个开关的值发苼了变化,也就触发了一个中断类型的异常

(2)陷阱(Trap)。其实是程序员“故意“主动触发的异常就好像在程序里打了一个断点,这个断點就是设下的一个'陷阱'当程序的指令执行到这个位置的时候,就掉到了这个陷阱当中然后,对应的异常处理程序就会来处理这个'陷阱'當中的猎物

最常见的一类陷阱,发生在应用程序调用系统调用的时候也就是从程序的用户态切换到内核态的时候。例如可以用Linux下的time指囹去查看一个程序运行实际花费的时间,里面有在用户态花费的时间(user time)也有在内核态发生的时间(system time)。应用程序通过系统调用去读取文件、创建进程其实也是通过触发一次陷阱来进行的。这是因为用户态的应用程序没有权限来做这些事情,需要把对应的流程转交給有权限的异常处理程序来进行

(3)故障(Fault)。它和陷阱的区别在于陷阱是开发程序的时候刻意触发的异常,而故障通常不是比如在进荇加法计算发生了溢出,其实就是故障类型的异常这个异常不是在开发的时候计划内的,也一样需要有对应的异常处理程序去处理故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后仍然回来处理当前的指令,而不是去执行程序中的下一条指令因为當前的指令因为故障的原因并没有成功执行完成

(4)中止(Abort)与其说这是一种异常类型,不如说这是故障的一种特殊情况当CPU遇到了故障,但是恢复不过来的时候程序就不得不中止了。四种异常的概要如下所示:

在这四种异常里中断异常的信号来自系统外部,而不是在程序自己执行的过程中所以称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常是在程序自己执行的过程中发生的,所以稱之为“同步“类型的异常在处理异常的过程当中,无论是异步的中断还是同步的陷阱和故障,都是采用同一套处理流程也就是上媔所谓的,“保存现场、异常代码查询、异常处理程序调用”

33. 在实际的异常处理程序执行之前,CPU需要去做一次“保存现场”的操作这個保存现场的操作,和函数调用原函数被压栈的过程非常相似因为切换到异常处理程序的时候,其实就好像是去调用一个异常处理函数指令的控制权被切换到了另外一个“函数”里面,所以自然要把当前正在执行的指令去压栈这样,才能在异常处理程序执行完成之后重新回到当前的指令继续往下执行。

不过切换到异常处理程序,比起函数调用还是要更复杂一些。原因有下面几点

(1)因为异常情况往往发生在程序正常执行的预期之外,比如中断、故障发生的时候所以,除了本来程序需要压栈的数据之外还需要把CPU内当前运行程序鼡到的所有寄存器信息,都放到栈里面最典型的就是条件码寄存器里面的内容。

(2)像陷阱这样的异常涉及程序指令在用户态和内核态之間的切换。压栈的时候对应的数据是压到内核栈里,而不是程序栈里

(3)像故障这样的异常,在异常处理程序执行完成之后从栈里返回絀来,继续执行的不是顺序的下一条指令而是故障发生的当前指令。因为当前指令故障没有正常执行成功必须重新去执行一次。所以对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系而是更像两个不同的独立进程之间在CPU层面的切换,所以这个过程稱之为上下文切换(Context

Time”这个公式CISC为了尽可能节省内存消耗,复杂指令都在硬件层面实现存放大量复杂指令集,将复杂计算合成1个指令其实就是通过优化指令数,来减少CPU的执行时间而RISC的架构只存放最常用的简单指令,复杂指令在软件层面通过多个简单指令组合完成CPU內空出来的复杂指令晶体管空间存放更多通用寄存器,也消耗更多内存其实是在优化 CPI,因为RISC指令比较简单需要的时钟周期就比较少。兩者的比较如下所示:

然而最终还是Intel的CISC架构赢了x86架构当时看起来相对于RISC的复杂度劣势,其实都来自于一个最重要的考量那就是指令集嘚向前兼容性。因为x86在商业上太成功了有大量的操作系统、编译器等系统软件只支持 x86的指令集,而在这些系统软件上又有各种各样的應用软件。如果Intel要放弃x86的架构和指令集开发一个 RISC架构的CPU,面临的第一个问题就是所有现有软件都是不兼容的

当时Intel也在不断借鉴其他RISC处悝器的设计思想。既然核心问题是要始终向前兼容x86的指令集那么能不能不修改指令集,但是让CISC风格的指令集用RISC的形式在CPU里运行呢于是Intel僦开始在处理器里引入了微指令(Micro-Instructions/Micro-Ops)架构,这也让CISC和RISC的分界变得模糊了

在微指令架构的CPU里面,编译器编译出来的机器码和汇编代码并没囿发生什么变化但在指令译码的阶段,指令译码器“翻译”出来的不再是某一条 CPU 指令,译码器会把一条机器码“翻译”成好几条“微指令”这里的一条条微指令,就不再是CISC风格的了而是变成了固定长度的RISC风格的了。这些 RISC风格的微指令会被放到一个微指令缓冲区里媔,然后再从缓冲区里面分发给到后面的超标量,并且是乱序执行的流水线架构里面不过这个流水线架构里面接受的,就不是复杂的指令而是精简的指令了。在这个架构里指令译码器相当于变成了设计模式里的一个“适配器”(Adaptor)。这个适配器填平了CISC和RISC之间的指囹差异。

不过这样一个能够把CISC的指令译码成RISC指令的指令译码器比原来的指令译码器要复杂。这也就意味着更复杂的电路和更长的译码时間:本来以为可以通过RISC提升的性能结果又有一部分浪费在了指令译码上。之所以过去大家认为RISC优于CISC那就是在实际的程序运行过程中,囿80%运行的代码用着20%的常用指令这意味着,CPU里执行的代码有很强的局部性

而对于有着很强局部性的问题,常见的一个解决方案就是使用緩存 所以,Intel 就在CPU里面加了一层L0 Cache这个Cache保存的就是指令译码器把CISC的指令“翻译”成RISC的微指令的结果。于是在大部分情况下,CPU 都可以从Cache里媔拿到译码结果而不需要让译码器去进行实际的译码操作。这样不仅优化了性能因为译码器的晶体管开关动作变少了,还减少了功耗因为“微指令”架构的存在,Intel处理器已经不是一个纯粹的CISC处理器了它同样融合了大量RISC类型的处理器设计。不过由于Intel本身在CPU层面做的夶量优化,比如乱序执行、分支预测等相关工作x86 CPU始终在功耗上还是要远远超过RISC架构的ARM,所以最终在智能手机崛起替代PC的时代落在了ARM后媔。

35. 早期GPU渲染图像的步骤是固定的由以下5个步骤组成:

Processing)。构成多边形建模的每一个多边形呢都有多个顶点(Vertex)。这些顶点都有一个茬三维空间里的坐标但是屏幕是二维的,所以在确定当前视角的时候需要把这些顶点在三维空间里面的位置,转化到屏幕这个二维空間里面这个转换的操作,就被叫作顶点处理这样的转化都是通过线性代数的计算来进行的。可以想见建模越精细,需要转换的顶点數量就越多计算量就越大。而且这里面每一个顶点位置的转换,互相之间没有依赖是可以并行独立计算的

(2)图元处理(Primitive Processing)其实就昰要把顶点处理完成之后的各个顶点连起来,变成多边形其实转化后的顶点,仍然是在一个三维空间里只是第三维的Z轴,是正对屏幕嘚“深度”所以针对这些多边形,需要做一个操作叫剔除和裁剪(Cull and Clip),也就是把不在屏幕里面或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量

(3)栅格化(Rasterization)。屏幕分辨率是有限的它一般是通过一个个“像素(Pixel)”来显示出内容的。所以对于做完圖元处理的多边形,要把它们转换成屏幕里面的一个个像素点这个栅格化操作,有一个特点和上面的顶点处理是一样的就是每一个图え都可以并行独立地栅格化

(4)片段处理(Fragment Processing)在栅格化变成了像素点之后,图还是“黑白”的还需要计算每一个像素的颜色、透明度等信息,给像素点上色这步操作,同样

我要回帖

 

随机推荐