17 | 建立数据通路(上):指令+运算=CPU
指令: 计算机的“指令”是怎么运行的,也就是我们撰写的代码,是怎么变成一条条的机器能够理解的指令的,以及是按照什么样的顺序运行的。
计算: 计算机的“计算”部分是怎么执行的,数据的二进制表示是怎么样的,我们执行的加法和乘法又是通过什么样的电路来实现的。
指令周期(Instruction Cycle)
计算机每执行一 条指令的过程,可以分解成这样几个步骤。
- Fetch(取得指令),也就是从 PC 寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把 PC 寄存器自增,好在未来执行下一条指令。
- Decode(指令译码),也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是 R、I、J 中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。
- Execute(执行指令),也就是实际运行对应的 R、I、J 这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。
- 重复进行 1~3 的步骤。
这样的步骤,其实就是一个永不停歇的“Fetch - Decode - Execute”的循环,我们把这 个循环称之为指令周期(Instruction Cycle)。
在取指令的阶段,我们的指令是放在存储器里的,实际上,通过 PC 寄存器和指令寄存器取 出指令的过程,是由控制器(Control Unit)操作的。指令的解码过程,也是由控制器进行的。一旦到了执行指令阶段,无论是进行算术操作、逻辑操作的 R 型指令,还是进行数据传输、条件分支的 I 型指令,都是由算术逻辑单元(ALU)操作的,也就是由运算器处理的。不过,如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里面完成,不需要用到运算器。
除了 Instruction Cycle 这个指令周期,在 CPU 里面我们还会提到另外两个常见的 Cycle。一个叫Machine Cycle,机器周期或者CPU 周期。CPU 内部的操作速度很快,但 是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来,所以我们一般把从内存里面读取一条指令的最短时间,称为 CPU 周期。
还有一个是我们之前提过的Clock Cycle,也就是时钟周期以及我们机器的主频。一个CPU周期,通常会由几个时钟周期累积起来。一个 CPU 周期的时间,就是这几个 Clock Cycle 的总和。
对于一个指令周期来说,我们取出一条指令,然后执行它,至少需要两个 CPU 周期。取出 指令至少需要一个 CPU 周期,执行至少也需要一个 CPU 周期,复杂的指令则需要更多的 CPU 周期。
时钟周期:机器的主频
CPU周期(机器周期): 从内存里面读取一条指令的最短时间
指令周期:执行一条完整指令
建立数据通路
ALU、运算器、处理器单元、数据通 路,它们之间到底是什么关系呢?
名字是什么其实并不重要,一般来说,我们可以认为,数据通路就是我们的处理器单元。它通常由两类原件组成。
第一类叫操作元件,也叫组合逻辑元件(Combinational Element),其实就是我们的 ALU。在前面讲 ALU 的过程中可以看到,它们的功能就是在特定的输入下,根据下面的组合电路的逻辑,生成特定的输出。
第二类叫存储元件,也有叫状态元件(State Element)的。比如我们在计算过程中需要用 到的寄存器,无论是通用寄存器还是状态寄存器,其实都是存储元件。
我们通过数据总线的方式,把它们连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的建立数据通路了。
控制器:逻辑就没那么复杂了。我们可以把它看成只是机械地重复“Fetch - Decode - Execute“循环中的前两个步骤,然后把最后一个步骤,通过控制器产生的控制信号,交给 ALU 去处理。
听起来是不是很简单?实际上,控制器的电路特别复杂。下面我给你详细解析一下。
一方面,所有 CPU 支持的指令,都会在控制器里面,被解析成不同的输出信号。我们之前 说过,现在的 Intel CPU 支持 2000 个以上的指令。这意味着,控制器输出的控制信号,至 少有 2000 种不同的组合。
运算器里的 ALU 和各种组合逻辑电路,可以认为是一个固定功能的电路。控制器“翻译”出来的,就是不同的控制信号。这些控制信号,告诉 ALU 去做不同的计算。可以说正是控制器的存在,让我们可以“编程”来实现功能,能让我们的“存储程序型计算机”名副其实。
指令译码器将输入的机器码,解析成不同的操作码和操作数,然后传输给 ALU 进行计算
CPU 所需要的硬件电路
那么,要想搭建出来整个CPU,我们需要在数字电路层面,实现这样一些功能。
- 首先,自然是我们之前已经讲解过的 ALU 了,它实际就是一个没有状态的,根据输入计算 输出结果的第一个电路。
- 第二,我们需要有一个能够进行状态读写的电路元件,也就是我们的寄存器。我们需要有一个电路,能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使 用,但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路,就有锁存器 (Latch),以及我们后面要讲的 D 触发器(Data/Delay Flip-flop)的电路。
- 第三,我们需要有一个“自动”的电路,按照固定的周期,不停地实现 PC 寄存器自增,自动地去执行“Fetch - Decode - Execute“的步骤。我们的程序执行,并不是靠人去拨动开关来执行指令的。我们希望有一个“自动”的电路,不停地去一条条执行指令。
- 第四,我们需要有一个“译码”的电路。无论是对于指令进行 decode,还是对于拿到的内 存地址去获取对应的数据或者指令,我们都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。
需要的 4 种基本电路。它们分别是,ALU 这样的组合逻辑电路、用来存储数据的锁存器和 D 触发器电路、用来实现 PC 寄存器的计数器电路,以及用来解码和寻址的译码器电路。
18 | 建立数据通路(中):指令+运算=CPU
组合逻辑电路(Combinational Logic Circuit):只需要给定输入,就能得到固定的输出。(比如加法器)
时序逻辑电路(Sequential Logic Circuit):
- 自动运行的问题。时序电路接通之后可以不停地开启和关闭开关,进入一个自动运行的状态。这个使得我们上一讲说的,控制器不停地让 PC 寄存器自增读取下一条指令成为可能。
- 存储的问题。通过时序电路实现的触发器,能把计算结果存储在特定的电路里面, 而不是像组合逻辑电路那样,一旦输入有任何改变,对应的输出也会改变。
- 本质上解决了各个功能按照时序协调的问题。无论是程序实现的软件指令,还是到硬件层面,各种指令的操作都有先后的顺序要求。时序电路使得不同的事件按照时间顺序发生。
时钟信号的硬件实现
时钟:CPU 的主频是由一个晶体振荡器来实现的,而这个晶体振荡器生成的电路信号,就是我们的时钟信号。
一个开关 A,一开始是断开的,由我们手工控制;另外一个开关 B,一开始是合上的, 磁性线圈对准一开始就合上的开关 B。
于是,一旦我们合上开关 A,磁性线圈就会通电,产生磁性,开关 B 就会从合上变成断开。一旦这个开关断开了,电路就中断了,磁性线圈就失去了磁性。于是,开关 B 又会弹回到合上的状态。这样一来,电路接通,线圈又有了磁性。我们的电路就会来回不断地在开启、关闭这两个状态中切换。
这种电路,其实就相当于把电路的输出信号作为输入信号,再回到当前电路。这样的电路构 造方式呢,我们叫作反馈电路(Feedback Circuit)。
上面这个反馈电路一般可以用下面这个示意图来表示,其实就是一个输出结果接回输入的反相器(Inverter),也就是我们之前讲过的非门。
通过D触发器实现存储功能
这个 RS 触发器电路。这个电路由两个或非门电路组成。
- 在这个电路一开始,输入开关都是打开的,所以或非门(NOR)A 的输入是 0 和 0。对应到我列的这个真值表,输出就是 1。而或非门 B 的输入是 0 和 A 的输出 1,对应输出就是 0。B 的输出 0 反馈到 A,和之前的输入没有变化,A 的输出仍然是 1。而整个电路的输出 Q,也就是 0。
- 当我们把 A 前面的开关 R 合上的时候,A 的输入变成了 1 和 0,输出就变成了 0,对应 B 的输入变成 0 和 0,输出就变成了 1。B 的输出 1 反馈给到了 A,A 的输入变成了 1 和 1,输出仍然是 0。所以把 A 的开关合上之后,电路仍然是稳定的,不会像晶振那样振荡,但是整个电路的输出 Q变成了 1。
- 这个时候,如果我们再把 A 前面的开关 R 打开,A 的输入变成和 1 和 0,输出还是 0,对应的 B 的输入没有变化,输出也还是 1。B 的输出 1 反馈给到了 A,A 的输入变成了 1 和 0,输出仍然是 0。这个时候,电路仍然稳定。开关 R 和 S 的状态和上面的第一步是一样的,但是最终的输出 Q 仍然是 1, 和第 1 步里 Q 状态是相反的。我们的输入和刚才第二步的开关状态不一样,但是输出结果仍然保留在了第 2 步时的输出没有发生变化。
- 这个时候,只有我们再去关闭下面的开关 S,才可以看到,这个时候,B 有一个输入必然是 1,所以 B 的输出必然是 0,也就是电路的最终输出 Q必然是 0。
这样一个电路,我们称之为触发器(Flip-Flop)。接通开关 R,输出变为 1,即使断开开关,输出还是 1 不变。接通开关 S,输出变为 0,即使断开开关,输出也还是 0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能。
在我们的上面的 R-S 触发器基础之上,在 R 和 S 开关之后,加入了两个与门,同时给这两个与门加入了一个时钟信号 CLK作为电路输入。
这样,当时钟信号 CLK 在低电平的时候,与门的输入里有一个 0,两个实际的 R 和 S 后的与门的输出必然是 0。也就是说,无论我们怎么按 R 和 S 的开关,根据 R-S 触发器的真值表,对应的 Q 的输出都不会发生变化。
只有当时钟信号 CLK 在高电平的时候,与门的一个输入是 1,输出结果完全取决于 R 和 S 的开关。我们可以在这个时候,通过开关 R 和 S,来决定对应 Q 的输出。
如果这个时候,我们让 R 和 S 的开关,也用一个反相器连起来,也就是通过同一个开关控制 R 和 S。只要 CLK 信号是 1,R 和 S 就可以设置输出 Q。而当 CLK 信号是 0 的时候,无论 R 和 S 怎么设置,输出信号 Q 是不变的。这样,这个电路就成了我们最常用的 D 型触发器。用来控制 R 和 S 这两个开关的信号呢,我们视作一个输入的数据信号 D,也就是 Data,这就是 D 型触发器的由来。
把 R 和 S 两个信号通过一个反相器合并,我们可以通过一个数据信号 D 进行 Q 的写入操作
一个 D 型触发器,只能控制 1 个比特的读写,但是如果我们同时拿出多个 D 型触发器并列在一起,并且把用同一个 CLK 信号控制作为所有 D 型触发器的开关,这就变成了一个 N 位的 D 型触发器,也就可以同时控制 N 位的读写。
CPU 里面的寄存器可以直接通过 D 型触发器来构造。我们可以在 D 型触发器的基础上,加上更多的开关,来实现清 0 或者全部置为 1 这样的快捷操作。
19 | 建立数据通路(下):指令+运算=CPU
通过一个时钟信号,我们可以实现计数器,这个会成为我们的 PC 寄存器。然后,我们还需 要一个能够帮我们在内存里面寻找指定数据地址的译码器,以及解析读取到的机器指令的译码器。这样,我们就能把所有学习到的硬件组件串联起来,变成一个 CPU,实现我们在计 算机指令的执行部分的运行步骤。
PC 寄存器所需要的计数器
有了时钟信号,我们可以提供定时的输入;有了 D 型触发器,我们可以在时钟信号控制的时间点写入数据。我们把这两个功能组合起来,就可以实现一个自动的计数器了。
加法器的两个输入,一个始终设置成 1,另外一个来自于一个 D 型触发器 A。我们把加法 器的输出结果,写到这个 D 型触发器 A 里面。于是,D 型触发器里面的数据就会在固定的 时钟信号为 1 的时候更新一次。
单指令周期处理器(Single Cycle Processor):要在一个时钟周期里,确保执行完一条最复杂的 CPU 指令,也就是 耗时最长的一条 CPU 指令。
在最简单的情况下,我们需要让每一条指令,从程序计数,到获取指令、执行指令,都在一个时钟周期内完成。如果 PC 寄存器自增地太快,程序就会出错。因为前一次的运算结果还没有写回到对应的寄存器里面的时候,后面一条指令已经开始读取里面的数据来做下一次计算了。这个时候,如果我们的指令使用同样的寄存器,前一条指令的计算就会没有效果,计算结果就错了。
这样的设计有点儿浪费。因为即便只调用一条非常简单的指令,我们也需要等待整个时钟周期的时间走完,才能执行下一条指令。在后面章节里我们会讲到,通过流水线技术进行性能优化,可以减少需要等待的时间。
读写数据所需要的译码器
现在,我们的数据能够存储在 D 型触发器里了。如果我们把很多个 D 型触发器放在一起, 就可以形成一块很大的存储空间,甚至可以当成一块内存来用。像我现在手头这台电脑,有 16G 内存。那我们怎么才能知道,写入和读取的数据,是在这么大的内存的哪几个比特呢?
于是,我们就需要有一个电路,来完成“寻址”的工作。这个“寻址”电路,就是我们接下来要讲的译码器。
通过控制反相 器的输入是 0 还是 1,能够决定对应的输出信号,是和地址 A,还是地址 B 的输入信号一致。
译码器的本质,就是从输入的多个位的信号中,根据一定的开关和电路组合, 选择出自己想要的信号。除了能够进行“寻址”之外,我们还可以把对应的需要运行的指令 码,同样通过译码器,找出我们期望执行的指令,也就是在之前我们讲到过的 opcode,以 及后面对应的操作数或者寄存器地址。只是,这样的“译码器”,比起 2-1 选择器和 3-8 译码器,要复杂的多。
建立数据通路,构造一个最简单的 CPU
D 触发器、自动计数以及译码器,再加上 ALU,我们就凑齐了一个拼装一个 CPU 必须要的零件了。
- 首先,我们有一个自动计数器。这个自动计数器会随着时钟主频不断地自增,来作为我们的 PC 寄存器。
- 在这个自动计数器的后面,我们连上一个译码器。译码器还要同时连着我们通过大量的 D 触发器组成的内存。
- 自动计数器会随着时钟主频不断自增,从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的 CPU 指令。
- 读取出来的 CPU 指令会通过我们的 CPU 时钟的控制,写入到一个由 D 触发器组成的寄存器,也就是指令寄存器当中。
- 在指令寄存器后面,我们可以再跟一个译码器。这个译码器不再是用来寻址的了,而是把我们拿到的指令,解析成 opcode 和对应的操作数。
- 当我们拿到对应的 opcode 和操作数,对应的输出线路就要连接 ALU,开始进行各种算术和逻辑运算。对应的计算结果,则会再写回到 D 触发器组成的寄存器或者内存当中。
问题1:高级语言中的 if…else,其实是变成了一条 cmp 指令和一条 jmp 指令。cmp 指令是在进行对应的比较,比较的结果会更新到条件码寄存器当中。jmp 指令则是根据条件码寄存器当中的标志位,来决定是否进行跳转以及跳转到什么地址。为什么我们的 if…else 会变成这样 两条指令,而不是设计成一个复杂的电路,变成一条指令?
分成两个指令实现,完全匹配好了我们在电路层面,“译码 - 执行 - 更新寄存器“这样 的步骤。 cmp 指令的执行结果放到了条件码寄存器里面,我们的条件跳转指令也是在 ALU 层面执行的,而不是在控制器里面执行的。这样的实现方式在电路层面非常直观,我们不需要一个非常复杂的电路,就能实现 if…else 的功能。
问题2:我们执行一条指令,其实可以不放在一个时钟周期 里面,可以直接拆分到多个时钟周期。
我们可以在一个时钟周期里面,去自增 PC 寄存器的值,也就是指令对应的内存地址。然后,我们要根据这个地址从 D 触发器里面读取指令,这个还是可以在刚才那个时钟周期 内。但是对应的指令写入到指令寄存器,我们可以放在一个新的时钟周期里面。指令译码给到 ALU 之后的计算结果,要写回到寄存器,又可以放到另一个新的时钟周期。所以,执行一条计算机指令,其实可以拆分到很多个时钟周期,而不是必须使用单指令周期处理器的设计。
因为从内存里面读取指令时间很长,所以如果使用单指令周期处理器,就意味着我们的指令都要去等待一些慢速的操作。这些不同指令执行速度的差异,也正是计算机指令有指令周期、CPU 周期和时钟周期之分的原因。因此,现代我们优化 CPU 的性能时,用的 CPU 都不是单指令周期处理器,而是通过流水线、分支预测等技术,来实现在一个周期里同时执行多个指令。
问题3:CPU 在执行无条件跳转的时候,不需要通过运算器以及 ALU,为什么可以直接在控制器里面完成。
无条件跳转意味着没有计算的逻辑,应该是可以不经过ALU的,但是要控制器把PC设置成跳转后的指令地址。
延伸
我们可以通过自动计数器的电路,来实现一个 PC 寄存器,不断生成下一条要执行的计算机 指令的内存地址。然后通过译码器,从内存里面读出对应的指令,写入到 D 触发器实现的 指令寄存器中。再通过另外一个译码器,把它解析成我们需要执行的指令和操作数的地址。 这些电路,组成了我们计算机五大组成部分里面的控制器。 我们把 opcode 和对应的操作数,发送给 ALU 进行计算,得到计算结果,再写回到寄存器 以及内存里面来,这个就是我们计算机五大组成部分里面的运算器。 我们的时钟信号,则提供了协调这样一条条指令的执行时间和先后顺序的机制。同样的,这 也带来了一个挑战,那就是单指令周期处理器去执行一条指令的时间太长了。
20 | 面向流水线的指令设计(上):一心多用的现代CPU
单指令周期处理器
一条 CPU 指令的执行,是由“取得指令(Fetch)- 指令 译码(Decode)- 执行指令(Execute) ”这样三个步骤组成的。这个执行过程,至少需 要花费一个时钟周期。因为在取指令的时候,我们需要通过时钟周期的信号,来决定计数器的自增。
在单指令周期处理器里面,无论是执行一条用不到 ALU 的无条件跳转指令,还是一 条计算起来电路特别复杂的浮点数乘法运算,我们都等要等满一个时钟周期。在这个情况 下,虽然 CPI 能够保持在 1,但是我们的时钟频率却没法太高。因为太高的话,有些复杂 指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来,开始执行下一条指 令的时候,前一条指令的执行结果可能还没有写入到寄存器里面。那下一条指令读取的数据 就是不准确的,就会出现错误。
单指令周期处理器:一个CPU时钟周期是执行一条最复杂的指令的时间
指令流水线:一个CPU时钟周期是完成一条简单指令的时间。
把一个指令拆分成“取指令 - 指令译码 - 执行指令”这样三个部分,那这就是一 个三级的流水线。如果我们进一步把“执行指令”拆分成“ALU 计算(指令执行)- 内存访问 - 数据写回”,那么它就会变成一个五级的流水线。
五级的流水线,就表示我们在同一个时钟周期里面,同时运行五条指令的不同阶段。这个时候,虽然执行一条指令的 时钟周期变成了 5,但是我们可以把 CPU 的主频提得更高了。我们不需要确保最复杂后那条指令在时钟周期时间执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。
如果某一个操作步骤的时间太长,我们就可以考虑把这个步骤,拆分成更多的步骤,让所有 步骤需要执行的时间尽量都差不多长。这样,也就可以解决我们在单指令周期处理器中遇到的,性能瓶颈来自于最复杂的指令的问题。
虽然我们不能通过流水线,来减少单条指令执行的“延时”这个性能指标,但是,通过同时 在执行多条指令的不同阶段,我们提升了 CPU 的“吞吐率”。在外部看来,我们的 CPU 好像是“一心多用”,在同一时间,同时执行 5 条不同指令的不同阶段。在 CPU 内部,其 实它就像生产线一样,不同分工的组件不断处理上游传递下来的内容,而不需要等待单件商 品生产完成之后,再启动下一件商品的生产过程。(五级在不同的组件)
取指令(pc、内存) - 指令译码(指令寄存器) - ALU 计算(也叫指令执行,ALU运算器)- 内存访问(寄存器) - 数据写回(内存)
超长流水线的性能瓶颈
流水线可以增加我们的吞吐率,但增加流水线深度,是有性能成本的。
同步时钟周期的,不再是指令级别的,而是流水线阶段级别的。每一级流水线对应 的输出,都要放到流水线寄存器(Pipeline Register)里面,然后在下一个时钟周期,交 给下一个流水线级去处理。所以,每增加一级的流水线,就要多一级写入到流水线寄存器的 操作。虽然流水线寄存器非常快,比如只有 20 皮秒(ps, 秒)。
但是,如果我们不断加深流水线,这些操作占整个指令的执行时间的比例就会不断增加。最 后,我们的性能瓶颈就会出现在这些 overhead 上。如果我们指令的执行有 3 纳秒,也就 是 3000 皮秒。我们需要 20 级的流水线,那流水线寄存器的写入就需要花费 400 皮秒, 占了超过 10%。如果我们需要 50 级流水线,就要多花费 1 纳秒在流水线寄存器上,占到 25%。这也就意味着,单纯地增加流水线级数,不仅不能提升性能,反而会有更多的 overhead 的开销。所以,设计合理的流水线级数也是现代 CPU 中非常重要的一点。
问题1: 在前面讲过,一个 CPU 的时钟周期,可以认为是完成一条简单指令的时间。在这一讲之后,你觉得这句话正确吗?
随着流水线设计的引入,一个指令被拆分为14个子流程。一个CPU的时钟时间,应该是14个子流程中最长的那条的耗时时间。 一个 CPU 的时钟周期,可以认为是完成一条简单指令的时间。 这个应该是依据于吞吐量 来理解的吧。 实际指令不能在一个时钟周期完成, 但是流水线的引入使吞吐量更高。
21 | 面向流水线的指令设计(下):奔腾4是怎么失败的?
“主频战争”带来的超长流水线
增加流水线深度,在同主频下,其实是降低了 CPU 的性能。因为一个 Pipeline Stage,就需要一个时钟周期。那么我们把任务拆分成 31 个阶段,就需要 31 个时钟周期 才能完成一个任务;而把任务拆分成 11 个阶段,就只需要 11 个时钟周期就能完成任务。 在这种情况下,31 个 Stage 的 3GHz 主频的 CPU,其实和 11 个 Stage 的 1GHz 主频的 CPU,性能是差不多的。事实上,因为每个 Stage 都需要有对应的 Pipeline 寄存器的开销,这个时候,更深的流水线性能可能还会更差一些。
新的挑战:冒险和分支预测
超长流水线为什么不可行:
- 功耗问题:提升流水线深度,必须要和提升 CPU 主频同时进行。因为在单个 Pipeline Stage 能够执行的功能变简单了,也就意味着单个时钟 周期内能够完成的事情变少了。所以,只有提升时钟周期,CPU 在指令的响应时间这个指 标上才能保持和原来相同的性能。同时,由于流水线深度的增加,我们需要的电路数量变多了,也就是我们所使用的晶体管也就变多了。主频的提升和晶体管数量的增加都使得我们 CPU 的功耗变大了。
- 流水线技术带来的性能提升,是一个理想情况。在实际的程序执行中,并不一定能够做得到。
依赖问题,就是我们在计算机组成里面所说的冒险(Hazard)问题。这里我们只列举 了在数据层面的依赖,也就是数据冒险。在实际应用中,还会有结构冒险、控制冒险等其他 的依赖问题。
对应这些冒险问题,我们也有在乱序执行、分支预测等相应的解决方案。
我们的流水线越长,这个冒险的问题就越难以解决。这是因为,同一时间同时在运行的指令太多了。如果我们只有 3 级流水线,我们可以把后面没有依赖关系的指令放到前面来执行。这个就是我们所说的乱序执行的技术。
总结
CPU 运转需要的数据通路和控制器,时钟信号控制数据读写从而“存储”,计算机如何“自动”运行,单指令周期处理器到流水线技术,超长流水线不可行。