CPU流水线

CPU流水线

为什么现代处理器能在同一时间内处理多条指令?为什么说“指令并不是执行得更快了,而是并行了”?

这一切的核心在于:指令流水线(Instruction Pipeline)。

流水线是现代处理器架构中最基本也是最关键的加速机制。它通过将指令执行过程分成多个阶段,实现“并行执行”,从而极大提高指令吞吐率。本文将围绕其基本原理、四种执行时间变化情况、潜在问题及解决方法(如冒险、旁路、乱序执行)进行讲解。

1、什么是流水线?

流水线是一种将一个指令的执行过程分成多个阶段(阶段越多越细),让不同指令可以同时在不同阶段执行的技术。

最经典的五级流水线结构由以下5步骤组成:

取指(IF):将指令从存储器中读取出来。

译码(ID):将取出来的指令进行翻译。经过译码之后得到指令需要的操作数寄存器索引,可以使用此索引从通用寄存器组(Register File,Regfile)中将操作数读出。

执行(EX):指令译码之后所需要进行的计算类型都已得知,并且已经从通用寄存器组中读取出了所需的操作数,那么接下来便进行指令执行

访存(MEM):是指存储器访问指令将数据从存储器中读出,或者写入存储器的过程。

写回(WB):指将指令执行的结果写回通用寄存器组的过程。

虽然流水线不会加快单条指令的完成速度,但通过指令并行化,它极大地提升了处理器的整体吞吐率,从而实现更高的执行效率。以五级流水线结构为例,单周期处理器与流水线处理器进行对比,对比结果如下图所示:

假设我们要执行n条指令,每条指令遵循k级流水线结构,每个阶段执行一个周期,周期时间为T。

单周期处理器:

在单周期处理器中,所有指令必须在一个统一的长周期内完成所有操作(即取指、译码、执行、访存、写回等步骤),那么在单周期处理器的情况下,执行n条指令总耗时为:

\[Time_{总}=n\cdot k\cdot T

\]

吞吐率为:

\[Throughput_{single} = \frac{1}{k \cdot T}

\]

也就是每KT秒执行一条指令。

理想流水线处理器:

在理想的流水线中,假设没有冒险、冲突或流水线停顿,每条指令可以在每个周期进入流水线的下一个阶段。那么针对流水线处理器,执行n条指令所需要耗费的总时长为:

\[Time_{总} = (k + n - 1) \cdot T

\]

吞吐率为:

\[Throughput_{single} = \frac{n}{(k + n - 1) \cdot T}

\]

当n远大于k的时候,吞吐率接近于:

\[Throughput_{single} = \frac{n}{(k + n - 1) \cdot T} \approx \frac{1}{T}

\]

通过对比吞吐率,可以看到在理想情况下,流水线处理器的吞吐率是单周期处理器的k倍。

2、非理想情况下的流水线

虽然我们在上文中通过数学推导证明了理想流水线能够显著提高吞吐率,但现实中,流水线执行并不会完全理想化,主要受到以下几类问题的影响:

2.1 数据冒险

数据冒险指的是指令之间存在数据依赖关系,就像这段代码:

int a = 10;

int b = a + 10;//语句2

int c = b + a;//语句3

语句3的计算依赖于b的值,在语句2对b进行了计算,也就是语句3依赖于语句2,但是每一个语句都会被翻译成很多的指令,也就是其中两个指令存在依赖关系。

比如说指令3-3的执行需要依赖指令2-2完成后的数据,对于这种情况目前主要有两种处理方法:

第一种处理方法就是引入NOP,这个时钟周期啥也不做,等到依赖的数据完成再继续,这种通过流水线停顿解决数据冒险的方案称为流水线冒泡(Pipeline Bubble)。这种处理方法虽然简单,但是会降低整体流水线的效率。

在流水线处理器中,发生数据冒险时,通常的处理方式之一是引入冒泡(bubble),即在流水线上插入一个或多个 NOP 指令,等待前一条指令完成写回操作(WB),再让依赖指令继续执行。

以“指令 3-3 依赖指令 2-2 的执行结果”为例,若采用纯粹冒泡的方式处理,指令 3-3 必须等待指令 2-2 执行到 WB 阶段,将结果写回寄存器后,才能在 EX 阶段取到正确的数据。这样不仅引入了额外的等待时间,也降低了流水线的效率。

实际上,很多情况下,所需的操作数在指令 2-2 的 EX 阶段就已经被计算出来。因此,可以通过一种更高效的方式:操作数前推(Operand Forwarding),也叫旁路(Forwarding)。它的核心思想是:

不等指令结果写回寄存器,而是从执行阶段直接将结果传送给后续依赖的指令。

这样,指令 3-3 就可以在不完全等待的情况下继续前进,从而显著减少流水线空转时间。

然而,需要注意的是:操作数前推虽然可以解决部分数据冒险,但并不能完全消除等待。在某些情况下,数据仍然未及时产生(如负载延迟、结构冲突等),这时仍需插入 NOP 来保证正确性。因此,在实际处理器中,操作数前推与冒泡常常配合使用,以在保证正确执行的前提下最大程度提升流水线性能。

2.2 控制冒险

在流水线中,多个指令是并行执行的,在指令1执行的时候,后续的指令2和指令3可能已经完成了IF和ID两个阶段等待被执行,此时如果指令1一下子跳到了其他地方,那么指令2和指令3的IF和ID就是无用功了。

遇到这种指令转移情况,处理器需要先排空指令2和指令3对应的流水线,然后跳转到指令1的新的目标位置进入新的流水线,这部分称为转移开销。

无条件跳转

B LABEL是 ARM 中最基础的无条件跳转指令,直接跳转到LABEL处,无需判断条件,因此一旦译码确认跳转,后续预取的指令必然无效。

示例场景:

1: ADD R0, R1, R2 ; 普通加法指令

2: B LABEL ; 无条件跳转到LABEL

3: SUB R3, R4, R5 ; 跳转后的指令(无效)

4: AND R6, R7, R8 ; 更靠后的指令(无效)

...

LABEL: ORR R9, R10, R11 ; 跳转目标

流水线的执行时序如下所示:

清空触发:

指令 2 在 ID 阶段(3 级流水线中,译码阶段即可确定跳转)确认跳转后,流水线中已预取的指令 3(处于 IF 阶段)是基于原顺序的无效指令,必须被清空。

结果:

指令 3 被丢弃(IF 阶段清空),指令 1 继续执行(已进入 EX 阶段,不受影响)。

下一个时钟周期,从LABEL处取指令(ORR)进入 IF 阶段,开始新的流水线流程。

带链接的跳转导致的清空

BL LABEL用于子程序调用,跳转的同时会将返回地址(当前BL指令的下一条指令地址)存入LR(链接寄存器),执行完子程序后通过BX LR返回。由于跳转后子程序的指令与原顺序无关,后续预取的指令必然无效。

1: LDR R0, [R1] ; 从内存加载数据到R0

2: BL FUNCTION ; 调用FUNCTION,返回地址存入LR(即指令3的地址)

3: STR R0, [R2] ; 子程序返回后才执行的指令(此时无效)

4: MOV R3, #0 ; 更靠后的指令(无效)

...

FUNCTION: ADD R0, R0, #1 ; 子程序入口

流水线的执行时序如下所示:

清空触发:

指令 2 在 EX 阶段完成跳转确认后,流水线中已存在的指令 3(ID 阶段)和指令 4(IF 阶段)是原顺序下的指令,但程序已跳转到FUNCTION,这些指令无需执行。

结果:

清空指令 3(ID 阶段)和指令 4(IF 阶段),丢弃它们的译码和取指结果。

从FUNCTION处重新取指(ADD R0, R0, #1)进入 IF 阶段,填充新的流水线。

条件跳转预测失败导致的清空

ARM 的条件跳转指令(如BEQ、BNE、BGT等)带有条件码(如相等、不相等、大于等),执行时需根据 CPSR 寄存器的标志位判断是否跳转。现代 ARM 处理器会采用分支预测(如预测 “不跳转”),若预测错误,已预取的指令必须清空。

示例场景:

1: CMP R0, R1 ; 比较R0和R1,设置CPSR标志位(假设结果为不相等)

2: BEQ LABEL ; 若相等则跳转(处理器预测“相等”,即会跳转)

3: MOV R2, #0x10 ; 预测不跳转时预取的指令(实际需要执行)

4: ADD R3, R2, R4 ; 更靠后的预取指令

...

LABEL: SUB R2, R5, R6 ; 跳转目标(实际不跳转,此指令无效)

流水线执行时序(5 级流水线):

指令 1 执行后,CPSR 标志位显示R0≠R1,因此BEQ实际不跳转。

但处理器在指令 2 的 ID 阶段预测 “会跳转”,因此未预取指令 3 和 4,而是预取了LABEL处的指令 5(SUB R2, R5, R6)。

当指令 2 在 EX 阶段确认 “不跳转” 时,预测失败:预取的指令 5(可能已进入 IF 或 ID 阶段)是无效的,而指令 3 和 4 才是需要执行的。

清空触发:

预测错误导致预取的LABEL处指令无效,必须清空。

结果:

清空已预取的指令 5(IF 或 ID 阶段),重新从指令 3(MOV R2, #0x10)开始取指、译码,填充流水线。

此过程浪费了预测错误期间的流水线周期(转移开销)。

间接跳转(BX/BLX指令)导致的清空

BX Rn或BLX Rn通过寄存器Rn中的地址跳转(Rn存放目标地址),目标地址在运行时才能确定(如函数指针),处理器难以提前预测,因此预取的后续指令几乎必然无效,需清空。

示例场景:

1: LDR R0, =func_ptr ; 从内存加载函数指针到R0(func_ptr指向某个函数)

2: BX R0 ; 通过R0间接跳转

3: ORR R1, R2, R3 ; 跳转后的指令(无效)

4: EOR R4, R5, R6 ; 更靠后的指令(无效)

...

func_ptr: .word function ; 函数指针指向function

function: MUL R7, R8, R9 ; 跳转目标函数

流水线执行时序(5 级流水线):

指令 1(LDR)在 MEM 阶段将func_ptr的值(function的地址)写入 R0。

指令 2(BX R0)在 EX 阶段才能通过 R0 获取目标地址(因依赖指令 1 的结果,存在数据相关)。

在此之前,流水线已预取指令 3(IF 阶段)和指令 4(可能尚未进入流水线)。

清空触发:

当指令 2 在 EX 阶段确认跳转目标为function时,指令 3 已进入 IF 阶段,但它属于原顺序的无效指令。

结果:

清空指令 3(IF 阶段),从function处重新取指(MUL R7, R8, R9),填充流水线。

由于目标地址不可预测,几乎必然触发清空,转移开销较高。

2.3 结构冒险

结构冒险本质上是一种硬件冲突,我们以5级流水线为例来说,指令读取IF阶段和取数操作MEM,都需要进行内存数据的读取,然而内存只有一个地址译码器,只能在一个时钟周期里面读取一条数据。

换句话说就像洗车流水线的喷水和刷洗都要用到水管,但是只有一根水管,这样就存在冲突,导致只能满足一个喷水或者刷洗。

对于MEM阶段和IF阶段的冲突,一个解决方案就是把内存分成两部分:存放指令的内存和存放数据的内存,让它们有各自的地址译码器,从而通过增加硬件资源来解决冲突。

没错,这种将指令和数据分开存储就是著名的哈佛结构Harvard Architecture,指令和数据放在一起的就是冯诺依曼结构(也叫普林斯顿结构Princeton Architecture)。

这两种结构都有各自的优缺点,现代的CPU借鉴了两种架构采用一种混合结构,并且引入了高速缓存,来降低CPU和内存的速度不匹配问题,如图:

这种混合结构就很好地解决了流水线结构冒险问题。

3、总结

指令流水线是现代处理器提升效率的核心机制,其核心原理是将单条指令的执行过程拆分为取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)等多个阶段,使不同指令能在不同阶段并行处理,从而大幅提升整体吞吐率 —— 尽管它不会加快单条指令的执行速度,但通过并行化显著提高了单位时间内完成的指令数量。

从性能对比来看,单周期处理器执行 n 条指令需耗时 n・k・T(k 为阶段数,T 为周期时间),而理想流水线在无冲突时仅需 (k+n-1)・T,当 n 远大于 k 时,吞吐率接近单周期处理器的 k 倍。

然而,实际流水线面临三类主要问题:

数据冒险:指令间的数据依赖会导致后序指令等待,可通过 “冒泡”(插入 NOP 等待)或 “旁路”(直接传递执行阶段结果)减少停顿;

控制冒险:跳转指令(如无条件跳转、条件跳转、子程序调用、间接跳转)会使流水线中已预取的后续指令无效,需清空流水线,尤其条件跳转预测失败时会产生额外转移开销;

结构冒险:硬件资源冲突(如访存与取指共用内存)可通过哈佛结构(指令与数据分离存储)等硬件设计缓解。

现代处理器通过分支预测、操作数前推、混合存储结构等技术,在平衡正确性与效率的前提下,最大化流水线的并行优势。

参考博客链接: https://www.cnblogs.com/myseries/p/14458367.html

https://zhuanlan.zhihu.com/p/425235910

更多创意

探索 wd 手表的多样价位
office365个人邮箱

探索 wd 手表的多样价位

📅 07-21 🔥 7334
非洲长鲈
bt365无法登陆

非洲长鲈

📅 07-03 🔥 5755
中国移动欠费紧急开机,移动开通紧急开机服务提醒
365根据什么来封号

中国移动欠费紧急开机,移动开通紧急开机服务提醒

📅 07-16 🔥 5901