本文根据徐文浩老师的计算机组成原理记录:计算机组成原理
CSDN base64 图片显示有问题, 想要个人笔记的可以私我
![指令周期][指令周期]
指令是放在存储器里的,实际上,通过 PC 寄存器和指令寄存器取出指令的过程,是由控制器(Control Unit)操作的。指令的解码过程,也是由控制器进行的。一旦到了执行指令阶段,无论是进行算术操作、逻辑操作的 R 型指令,还是进行数据传输、条件分支的 I 型指令,都是由算术逻辑单元(ALU)操作的,也就是由运算器处理的。不过,如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里面完成,不需要用到运算器。
![获取指令][获取指令]
Machine Cycle,机器周期或者CPU 周期。CPU 内部的操作速度很快,但是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来,所以一般把从内存里面读取一条指令的最短时间,称为 CPU 周期。
![周期关系][周期关系]
一般来说,可以认为,数据通路就是我们的处理器单元。它通常由两类原件组成。
第一类叫操作元件,也叫组合逻辑元件(Combinational Element),其实就是 ALU。它们的功能就是在特定的输入下,根据下面的组合电路的逻辑,生成特定的输出。
第二类叫存储元件,也有叫状态元件(State Element)的。比如我们在计算过程中需要用到的寄存器,无论是通用寄存器还是状态寄存器,其实都是存储元件。
控制器的逻辑很简单。只是机械地重复“Fetch - Decode - Execute“循环中的前两个步骤,然后把最后一个步骤,通过控制器产生的控制信号,交给 ALU 去处理。
所有 CPU 支持的指令,都会在控制器里面,被解析成不同的输出信号。现在的 Intel CPU 支持 2000 个以上的指令。这意味着,控制器输出的控制信号,至少有 2000 种不同的组合。控制器“翻译”出来的,就是不同的控制信号。这些控制信号,告诉 ALU 去做不同的计算。
在最简单的情况下,要让每一条指令,从程序计数,到获取指令、执行指令,都在一个时钟周期内完成。如果 PC 寄存器自增地太快,程序就会出错。因为前一次的运算结果还没有写回到对应的寄存器里面的时候,后面一条指令已经开始读取里面的数据来做下一次计算了。这个时候,指令使用同样的寄存器,前一条指令的计算就会没有效果,计算结果就错了。在这种设计下,要在一个时钟周期里,确保执行完一条最复杂的 CPU 指令,也就是耗时最长的一条 CPU 指令。这样的 CPU 设计,称之为单指令周期处理器(Single Cycle Processor)。
![CPU实现的抽象逻辑图][CPU实现的抽象逻辑图]
CPU在空闲状态就会停止执行,具体来说就是切断时钟信号,CPU的主频就会瞬间降低为0,功耗也会瞬间降低为0。由于这个空闲状态是十分短暂的,所以在任务管理器里面也只会看到CPU频率下降,不会看到降低为0。当CPU从空闲状态中恢复时,就会接通时钟信号,这样CPU频率就会上升。所以会在任务管理器里面看到CPU的频率起伏变化。
组合逻辑电路(Combinational Logic Circuit):只需要给定输入,就能得到固定的输出
时序逻辑电路(Sequential Logic Circuit):
![时钟][时钟]
开关 A,一开始是断开的,手工控制;
合上开关 A,磁性线圈就会通电,产生磁性,开关 B 就会从合上变成断开。一旦这个开关断开了,电路就中断了,磁性线圈就失去了磁性。于是,开关 B 又会弹回到合上的状态。
一个 D 型触发器,只能控制 1 个比特的读写,同时拿出多个 D 型触发器并列在一起,并且把用同一个 CLK 信号控制作为所有 D 型触发器的开关,这就变成了一个 N 位的 D 型触发器,也就可以同时控制 N 位的读写。
CPU 里面的寄存器可以直接通过 D 型触发器来构造。我们可以在 D 型触发器的基础上,加上更多的开关,来实现清 0 或者全部置为 1 这样的快捷操作。
不同指令的执行时间不同,但是需要让所有指令都在一个时钟周期内完成,那就只好把时钟周期和执行时间最长的那个指令设成一样。这就好比学校体育课 1000 米考试,要给这场考试预留的时间,肯定得和跑得最慢的那个同学一样。因为就算其他同学先跑完,也要等最慢的同学跑完间,才能进行下一项活动。
快速执行完成的指令,需要等待满一个时钟周期,才能执行下一条指令。所以,在单指令周期处理器里面,无论是执行一条用不到 ALU 的无条件跳转指令,还是一条计算起来电路特别复杂的浮点数乘法运算,都等要等满一个时钟周期。在这个情况下,虽然 CPI 能够保持在 1,但是时钟频率却没法太高。因为太高的话,有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来,开始执行下一条指令的时候,前一条指令的执行结果可能还没有写入到寄存器里面。那下一条指令读取的数据就是不准确的,就会出现错误。
![单指令周期处理器][单指令周期处理器]
将复杂的指令拆解成简单的指令, 这样的协作模式被称为指令流水线,每一个独立的指令被称为流水线阶段或者流水线级(Pipeline Stage)。而现代的处理器不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。
如果某一个操作步骤的时间太长,就可以考虑把这个步骤,拆分成更多的步骤,让所有步骤需要执行的时间尽量都差不多长。这样,也就可以解决我们在单指令周期处理器中遇到的,性能瓶颈来自于最复杂的指令的问题。现代的 ARM 或者 Intel 的 CPU,流水线级数都已经到了 14 级。
流水线可以增加吞吐率,为什么我们不把流水线级数做得更深呢?为什么不做成 20 级,乃至 40 级呢?一个最基本的原因,就是增加流水线深度,其实是有性能成本的。用来同步时钟周期的,不再是指令级别的,而是流水线阶段级别的。每一级流水线对应的输出,都要放到流水线寄存器(Pipeline Register)里面,然后在下一个时钟周期,交给下一个流水线级去处理。所以,每增加一级的流水线,就要多一级写入到流水线寄存器的操作。但是,不断加深流水线,这些操作占整个指令的执行时间的比例就会不断增加。最后,性能瓶颈就会出现在这些 overhead 上。
![流水线的性能瓶颈][流水线的性能瓶颈]
流水线技术带来的性能提升,是一个理想情况; 在世纪的程序执行中,并不一定能够做得到; 如果一个复杂指令被分为 3 个流水线阶段, 但是要执行 3 需要 2 的结果 ,执行 2 则需要 1 的结果, 这样的依赖关系会导致指令完成时间和单指令周期 CPU 花费的时间一致
流水线设计需要解决的三大冒险,分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。
本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。同一个时钟周期,两个不同指令访问同一个资源类似的资源冲突。
![结构冒险][结构冒险]
在第 1 条指令执行到访存(MEM)阶段的时候,流水线里的第 4 条指令,在执行取指令(Fetch)的操作。访存和取指令,都要进行内存数据的读取。我们的内存,只有一个地址译码器的作为地址输入,那就只能在一个时钟周期里面读取一条数据,没办法同时执行第 1 条指令的读取内存数据和第 4 条指令的读取指令代码。
资源冲突解决方案,其实本质就是增加资源。对于访问内存数据和取指令的冲突,一个直观的解决方案就是把我们的内存分成两部分,让它们各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存。
把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture),来自哈佛大学设计Mark I 型计算机时候的设计。
冯·诺依曼体系结构,又叫作普林斯顿架构(Princeton Architecture)。
但是, 今天使用的 CPU,仍然是冯·诺依曼体系结构的,并没有把内存拆成程序内存和数据内存这两部分。因为如果那样拆的话,对程序指令和数据需要的内存空间,我们就没有办法根据实际的应用去动态分配了。虽然解决了资源冲突的问题,但是也失去了灵活性。
不过,借鉴了哈佛结构的思路,现代的 CPU 虽然没有在内存层面进行对应的拆分,却在 CPU 内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
![CPU体系结构][CPU体系结构]
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW)。
先写后读的依赖关系,我们一般被称之为数据依赖,也就是 Data Dependency。
int main() {
int a = 1;
int b = 2;
a = a + 2;
b = a + 3;
}
先读后写的依赖,一般被叫作反依赖,也就是 Anti-Dependency。
int main() {
int a = 1;
int b = 2;
a = a + b;
b = a + b;
}
写后再写的依赖,一般被叫作输出依赖,也就是 Output Dependency。
int main() {
int a = 1;
a = 2;
}
解决这些数据冒险。其中最简单的一个办法,不过也是最笨的一个办法,就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。时钟信号会不停地在 0 和 1 之前自动切换。其实,并没有办法真的停顿下来。流水线的每一个操作步骤必须要干点儿事情。所以,在实践过程中,并不是让流水线停下来,而是在执行后面的操作步骤前面,插入一个 NOP 操作,也就是执行一个其实什么都不干的操作。
![流水线停顿解决数据冒险][流水线停顿解决数据冒险]
一个数据冒险:
![停顿][停顿]
其实第二条指令的执行,未必要等待第一条指令写回完成,才能进行。如果第一条指令的执行结果,能够直接传输给第二条指令的执行阶段,作为输入,第二条指令,就不用再从寄存器里面,把数据再单独读出来一次,才来执行代码。
完全可以在第一条指令的执行阶段完成之后,直接将结果数据传输给到下一条指令的 ALU。然后,下一条指令不需要再插入两个 NOP 阶段,就可以继续正常走到执行阶段。
![操作数转发][操作数转发]
这样的解决方案,就叫作操作数前推(Operand Forwarding),或者操作数旁路(Operand Bypassing)。其实,更合适的名字应该叫操作数转发。这里的 Forward,其实就是写 Email 时的“转发”(Forward)的意思。不过现有的经典教材的中文翻译一般都叫“前推”。
操作数前推的解决方案不但可以单独使用,还可以和流水线冒泡一起使用。有的时候,虽然可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。
比如说,先去执行一条 LOAD 指令,再去执行 ADD 指令。LOAD 指令在访存阶段才能把数据读取出来,所以下一条指令的执行阶段,需要在访存阶段完成之后,才能进行。
a = b + c
d = a * e
x = y * z
计算里面的 x ,却要等待 a 和 d 都计算完成,实在没啥必要。所以完全可以在 d 的计算等待 a 的计算的过程中,先把 x 的结果给算出来。
![乱序执行][乱序执行]
当循环或者条件分子执行的时候, CPU 会跳去执别的指令, 这种为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是控制冒险(Control Harzard)。
条件跳转指令其实进行了两种电路操作:
都是在指令译码(ID)的阶段就能获得的。而对应的条件码比较的电路,只要是简单的逻辑门电路就可以了,并不需要一个完整而复杂的 ALU。
所以,可以将条件判断、地址跳转,都提前到指令译码阶段进行,而不需要放在指令执行阶段。对应的,也要在 CPU 里面设计对应的旁路,在指令译码阶段,就提供对应的判断比较的电路。
让 CPU 来猜一猜,条件跳转后执行的指令,应该是哪一条; 分支预测失败了,就把后面已经取出指令已经执行的部分丢弃掉。这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。
假装分支不发生; 继续执行后面的指令;
一级分支预测(One Level Branch Prediction),或者叫1 比特饱和计数(1-bit saturating counter)。这个方法,其实就是用一个比特,去记录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况。这个方法还是有些“草率”,需要更多的资料来判断下次分支的指令, 于是引入一个状态机(State Machine)来做这个事情。这个时候就需要 2 个比特来记录对应的状态。这样这整个策略,就可以叫作2 比特饱和计数,或者叫双模态预测器(Bimodal Predictor)。
写代码的时候养成良好习惯,按事件概率高低在分支中升序或降序安排,争取让状态机少判断
程序的 CPU 执行时间 = 指令数 × CPI × Clock Cycle Time
CPI 的倒数,又叫作 IPC(Instruction Per Clock),也就是一个时钟周期里面能够执行的指令数,代表了 CPU 的吞吐率。最佳情况下,IPC 也只能到 1。因为无论做了哪些流水线层面的优化,即使做到了指令执行层面的乱序执行,CPU 仍然只能在一个时钟周期里面,取一条指令。
现在用的 CPU 芯片中, 虽然浮点数计算已经变成 CPU 里的一部分,但并不是所有计算功能都在一个 ALU 里面,真实的情况是,CPU 会有多个 ALU。所以在指令执行层面可以并行, 如此, 为了提升性能, 可以通过增加物理硬件的方式将取指令和指令译码部分同样增加即可。这样可以一次性从内存里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。在一个时钟周期里,能够完成的指令就不只一条了。
这种 CPU 设计,叫作多发射(Mulitple Issue)和超标量(Superscalar)。
无论是乱序执行,还是超标量技术,在实际的硬件层面,其实实施起来都挺麻烦的。这是因为,在乱序执行和超标量的体系里面,CPU 要解决依赖冲突(冒险)的问题。CPU 需要在指令执行之前,去判断指令之间是否有依赖关系。如果有对应的依赖关系,指令就不能分发到执行阶段。这些对于依赖关系的检测,都会使得 CPU 电路变得更加复杂。
于是,计算机科学家和工程师们就又有了一个大胆的想法。能不能不把分析和解决依赖关系的事情,放在硬件里面,而是放到软件里面来干呢?
可以通过改进编译器来优化指令数这个指标。有一个非常大胆的 CPU 设计想法,叫作超长指令字设计(Very Long Instruction Word,VLIW)。这个设计,不仅想让编译器来优化指令数,还想直接通过编译器,来优化 CPI。围绕着这个设计的,是 Intel 一个著名的“史诗级”失败,也就是著名的 IA-64 架构的安腾(Itanium)处理器。只不过,这一次,责任不全在 Intel,还要拉上可以称之为硅谷起源的另一家公司,也就是惠普。之所以称为“史诗”级失败,这个说法来源于惠普最早给这个架构取的名字,显式并发指令运算(Explicitly Parallel Instruction Computer),这个名字的缩写EPIC,正好是“史诗”的意思。
在乱序执行和超标量的 CPU 架构里,指令的前后依赖关系,是由 CPU 内部的硬件电路来检测的。而到了超长指令字的架构里面,这个工作交给了编译器这个软件。编译器在这个过程中,其实也能够知道前后数据的依赖。于是, 让编译器把没有依赖关系的代码位置进行交换。然后,再把多条连续的指令打包成一个指令包。安腾的 CPU 就是把 3 条指令变成一个指令包。
CPU 在运行的时候,不再是取一条指令,而是取出一个指令包。然后,译码解析整个指令包,解析出 3 条指令直接并行运行。可以看到,使用超长指令字架构的 CPU,同样是采用流水线架构的。也就是说,一组(Group)指令,仍然要经历多个时钟周期。同样的,下一组指令并不是等上一组指令执行完成之后再执行,而是在上一组指令的指令译码阶段,就开始取指令了。
值得注意的一点是,流水线停顿这件事情在超长指令字里面,很多时候也是由编译器来做的。除了停下整个处理器流水线,超长指令字的 CPU 不能在某个时钟周期停顿一下,等待前面依赖的操作执行完成。编译器需要在适当的位置插入 NOP 操作,直接在编译出来的机器码里面,就把流水线停顿这个事情在软件层面就安排妥当。
安腾失败的原因有很多,其中有一个重要的原因就是“向前兼容”。
一方面,安腾处理器的指令集和 x86 是不同的。这就意味着,原来 x86 上的所有程序是没有办法在安腾上运行的,而需要通过编译器重新编译才行。
另一方面,安腾处理器的 VLIW 架构决定了,如果安腾需要提升并行度,就需要增加一个指令包里包含的指令数量,比方说从 3 个变成 6 个。一旦这么做了,虽然同样是 VLIW 架构,同样指令集的安腾 CPU,程序也需要重新编译。因为原来编译器判断的依赖关系是在 3 个指令以及由 3 个指令组成的指令包之间,现在要变成 6 个指令和 6 个指令组成的指令包。编译器需要重新编译,交换指令顺序以及 NOP 操作,才能满足条件。甚至,需要重新来写编译器,才能让程序在新的 CPU 上跑起来。
于是,安腾就变成了一个既不容易向前兼容,又不容易向后兼容的 CPU。那么,它的失败也就不足为奇了。
与超标量不同, 超线程的 CPU,其实是把一个物理层面 CPU 核心,“伪装”成两个逻辑层面的 CPU 核心。这个 CPU,会在硬件层面增加很多电路,使其可以在一个 CPU 核心内部,维护两个不同线程的指令的状态信息。
![超线程][超线程]
比如,在一个物理 CPU 核心内部,会有双份的 PC 寄存器、指令寄存器乃至条件码寄存器。这样,这个 CPU 核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的 CPU 在同时运行。所以,超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称 SMT)技术。
不过,在 CPU 的其他功能组件上,Intel 并没有提供双份。无论是指令译码器还是 ALU,一个 CPU 核心仍然只有一份。因为超线程并不是真的去同时运行两个指令,那就真的变成物理多核了。超线程的目的,是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程 B,就可以拿来干自己需要的事情。这个线程 B 可没有对于线程 A 里面指令的关联和依赖。这样,CPU 通过很小的代价,就能实现“同时”运行多个线程的效果。通常只要在 CPU 核心的添加 10% 左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。
不过,由于并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好。一般是在那些各个线程“等待”时间比较长的应用场景下。比如,需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。于是,CPU 计算并没有跑满,但是往往当前的指令要停顿在流水线上,等待内存里面的数据返回。这个时候,让 CPU 里的各个功能单元,去处理另外一个数据库连接的查询请求就是一个很好的应用案例。
基于 SIMD 的向量计算指令,也正是在 Intel 发布 Pentium 处理器的时候,被引入的指令集。当时的指令集叫作MMX,也就是 Matrix Math eXtensions 的缩写,中文名字就是矩阵数学扩展。
$ python
>>> import numpy as np
>>> import timeit
>>> a = list(range(1000))
>>> b = np.array(range(1000))
>>> timeit.timeit("[i + 1 for i in a]", setup="from __main__ import a", number=1000000)
32.82800309999993
>>> timeit.timeit("np.add(1, b)", setup="from __main__ import np, b", number=1000000)
0.9787889999997788
>>>
两个功能相同的代码性能有着巨大的差异,足足差出了 30 多倍。所有用 Python 讲解数据科学的教程里,往往在一开始就告诉我们不要使用循环,而要把所有的计算都向量化(Vectorize)。直接用 C 语言实现一下 1000 个元素的数组里面的每个数加 1, 就会发现, 即使是 C 语言编译出来的代码,还是远远低于 NumPy。原因就是,NumPy 直接用到了 SIMD 指令,能够并行进行向量的操作。
前面使用循环来一步一步计算的算法呢,一般被称为SISD,也就是单指令单数据(Single Instruction Single Data)的处理方式。如果使用的是一个多核 CPU ,那么它同时处理多个指令的方式可以叫作MIMD,也就是多指令多数据(Multiple Instruction Multiple Data)。
![SIMD][SIMD]
SIMD 在获取数据和执行指令的时候,都做到了并行。在从内存里面读取数据的时候,SIMD 是一次性读取多个数据。数组里面的每一项都是一个 integer,也就是需要 4 Bytes 的内存空间。Intel 在引入 SSE 指令集的时候,在 CPU 里面添上了 8 个 128 Bits 的寄存器。128 Bits 也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。比起循环分别读取 4 次对应的数据,时间就省下来了。
数据读取到了之后,在指令的执行层面,SIMD 也是可以并行进行的。4 个整数各自加 1,互相之前完全没有依赖,也就没有冒险问题需要处理。只要 CPU 里有足够多的功能单元,能够同时进行这些计算,这个加法就是 4 路同时并行的,自然也省下了时间。
异常其实是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,异常的处理,其实是由软件来完成的。计算机会为每一种可能会发生的异常,分配一个异常代码(Exception Number)。有些教科书会把异常代码叫作中断向量(Interrupt Vector)。这些异常代码里,I/O 发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由 CPU 预先分配好的,也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。
中断(Interrupt)。程序在执行到一半的时候,被打断了。这个打断执行的信号,一般来自于 CPU 外部的 I/O 设备。
陷阱(Trap),其实是程序员“故意“主动触发的异常。就好像你在程序里面打了一个断点,这个断点就是设下的一个"陷阱"。当程序的指令执行到这个位置的时候,就掉到了这个陷阱当中。然后,对应的异常处理程序就会来处理这个"陷阱"当中的猎物。应用程序通过系统调用去读取文件、创建进程,其实也是通过触发一次陷阱来进行的。这是因为,用户态的应用程序没有权限来做这些事情,需要把对应的流程转交给有权限的异常处理程序来进行。
故障(Fault)。它和陷阱的区别在于,陷阱是开发程序的时候刻意触发的异常,而故障通常不是。故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令。因为当前的指令因为故障的原因并没有成功执行完成。
中止(Abort)。这是故障的一种特殊情况。当 CPU 遇到了故障,但是恢复不过来的时候,程序就不得不中止了。
编号 | 类型 | 原因 | 示例 | 触发时机 | 处理后操作 |
---|---|---|---|---|---|
1 | 中断 | I/O设备信号 | 用户键盘输入 | 异步 | 下一条指令 |
2 | 陷阱 | 程序刻意触发 | 程序进行系统调用 | 同步 | 下一条指令 |
3 | 故障 | 程序执行出错 | 程序加载的缺页错误 | 同步 | 当前指令 |
4 | 中止 | 故障无法恢复 | ECC内存校验失败 | 同步 | 退出程序 |
在实际的异常处理程序执行之前,CPU 需要去做一次“保存现场”的操作。这个保存现场的操作,和函数调用的过程非常相似。
切换到异常处理程序的时候,其实就好像是去调用一个异常处理函数。指令的控制权被切换到了另外一个"函数"里面,所以自然要把当前正在执行的指令去压栈。这样才能在异常处理程序执行完成之后,重新回到当前的指令继续往下执行。不过,切换到异常处理程序,比起函数调用,还是要更复杂一些。原因有下面几点:
linux内核中有软中断和硬中断的说法。比如网卡收包时,硬中断对应的概念是中断,即网卡利用信号“告知”CPU有包到来,CPU执行中断向量对应的处理程序,即收到的包拷贝到计算机的内存,然后“通知”软中断有任务需要处理,中断处理程序返回;软中断是一个内核级别的进程(线程),没有对应到本次课程的概念,用于处理硬中断余下的工作,比如网卡收的包需要向上送给协议栈处理
软中断是由软件来触发,它属于同步的中断。一般用来完成一些特定任务:int 3 调试断点,以及之前 Linux 的 int 80h 系统调用
硬件中断是硬件组件触发的,可能是CPU内部异常,也可能是io外设的。外设的中断属于异步,它可能会在CPU指令执行期间触发。
软中断对应陷阱, 硬中断对应中断, 有时候也包含故障, 也有的把故障单独归类为异常
CPU 的指令集里的机器码是固定长度还是可变长度,也就是复杂指令集(Complex Instruction Set Computing,简称 CISC)和精简指令集(Reduced Instruction Set Computing,简称 RISC)
编号 | CISC | RISC |
---|---|---|
1 | 以硬件为中心的指令集设计 | 以软件为中心的指令集设计 |
2 | 通过硬件实现各类程序指令 | 通过编译器实现简单指令的组合, 完成复杂功能 |
3 | 更高效的使用内存和寄存器 | 需要更大的内存和寄存器, 并更频繁的使用 |
4 | 可变的指令长度, 支持更复杂的指令长度 | 简单, 定长的指令 |
5 | 大量指令数 | 少量指令数 |
MIPS 机器码的长度都是固定的 32 位
Intel x86 的机器码的长度是可变的
在计算机历史的早期,所有的 CPU 其实都是 CISC
虽然冯·诺依曼高屋建瓴地提出了存储程序型计算机的基础架构,但是实际的计算机设计和制造还是严格受硬件层面的限制。当时的计算机很慢,存储空间也很小。为了让计算机能够做尽量多的工作,每一个字节乃至每一个比特都特别重要。所以,CPU 指令集的设计,需要仔细考虑硬件限制。为了性能考虑,很多功能都直接通过硬件电路来完成。为了少用内存,指令的长度也是可变的。就像算法和数据结构里的赫夫曼编码(Huffman coding)一样,常用的指令要短一些,不常用的指令可以长一些。那个时候的计算机,想要用尽可能少的内存空间,存储尽量多的指令。
随着计算机性能越来越好, 存储空间也越来越大。UC Berkeley 的大卫·帕特森(David Patterson)教授发现,实际在 CPU 运行的程序里,80% 的时间都是在使用 20% 的简单指令。
在硬件层面,要想支持更多的复杂指令,CPU 里面的电路就要更复杂,设计起来也就更困难。更复杂的电路,在散热和功耗层面,也会带来更大的挑战。在软件层面,支持更多的复杂指令,编译器的优化就变得更困难。于是,在 RISC 架构里面,CPU 把指令“精简”到 20% 的简单指令。而原先的复杂指令,则通过用简单指令组合起来来实现,让软件来实现硬件的功能。这样,CPU 的整个硬件设计就会变得更简单了,在硬件层面提升性能也会变得更容易了。
核心问题是要始终向前兼容 x86 的指令集,那么能不能不修改指令集,但是让 CISC 风格的指令集,用 RISC 的形式在 CPU 里面运行呢?在微指令架构的 CPU 里面,编译器编译出来的机器码和汇编代码并没有发生什么变化。但在指令译码的阶段,指令译码器“翻译”出来的,不再是某一条 CPU 指令。译码器会把一条机器码,“翻译”成好几条“微指令”。这些 RISC 风格的微指令,会被放到一个微指令缓冲区里面,然后再从缓冲区里面,分发给到后面的超标量,并且是乱序执行的流水线架构里面。不过这个流水线架构里面接受的,就不是复杂的指令,而是精简的指令了。在这个架构里,指令译码器相当于变成了设计模式里的一个“适配器”(Adaptor)。这个适配器,填平了 CISC 和 RISC 之间的指令差异。
凡事有好处就有坏处。这样一个能够把 CISC 的指令译码成 RISC 指令的指令译码器,比原来的指令译码器要复杂。这也就意味着更复杂的电路和更长的译码时间:本来以为可以通过 RISC 提升的性能,结果又有一部分浪费在了指令译码上。之所以大家认为 RISC 优于 CISC,来自于一个数字统计,那就是在实际的程序运行过程中,有 80% 运行的代码用着 20% 的常用指令。这意味着,CPU 里执行的代码有很强的局部性。而对于有着很强局部性的问题,常见的一个解决方案就是使用缓存。
所以,Intel 就在 CPU 里面加了一层 L0 Cache。这个 Cache 保存的就是指令译码器把 CISC 的指令“翻译”成 RISC 的微指令的结果。于是,在大部分情况下,CPU 都可以从 Cache 里面拿到译码结果,而不需要让译码器去进行实际的译码操作。这样不仅优化了性能,因为译码器的晶体管开关动作变少了,还减少了功耗。
因为“微指令”架构的存在,从 Pentium Pro 开始,Intel 处理器已经不是一个纯粹的 CISC 处理器了。它同样融合了大量 RISC 类型的处理器设计。不过,由于 Intel 本身在 CPU 层面做的大量优化,比如乱序执行、分支预测等相关工作,x86 的 CPU 始终在功耗上还是要远远超过 RISC 架构的 ARM,所以最终在智能手机崛起替代 PC 的时代,落在了 ARM 后面。
到了 21 世纪的今天,CISC 和 RISC 架构的分界已经没有那么明显了。Intel 和 AMD 的 CPU 也都是采用译码成 RISC 风格的微指令来运行。而 ARM 的芯片,一条指令同样需要多个时钟周期,有乱序执行和多发射。甚至有这样的评价,“ARM 和 RISC 的关系,只有在名字上”。
ARM 真正能够战胜 Intel,主要是因为下面这两点原因: