Skip to content

第四章 - 处理器体系结构

【概览】本章从软硬件交界面的视角出发,系统阐释处理器的微架构设计。为便于理解指令集体系结构(ISA)及其底层硬件电路(组合逻辑与时序电路)的对应关系,本章引入了基于 Intel x86-64 简化的教学版指令集 Y86-64。

本章的学习路径从解析指令集规范开始,引入硬件控制语言(HCL),进而设计出一个顺序执行的处理器基础模型(SEQ),以实现常规的指令处理功能。在 SEQ 模型建立后,本章探讨了顺序执行架构的性能瓶颈,并通过引入流水线技术对其进行重构与优化,最终构建出流水线处理器模型(PIPE)。

在模型演进的过程中,本章要求以宏观的系统视角,分析指令流转中的异常状态处理机制,设计控制逻辑以解决流水线执行中的数据冒险与控制冒险,并对处理器的运行性能进行评估。

经过本章的理论学习与模型推演,我们能够建立对现代处理器底层逻辑的客观认知,掌握代码在硬件层面执行的具体机制,从而为后续的系统级代码开发与体系结构研究提供理论支撑。


指令集体系结构在计算机体系结构中的地位

从1946年2月14日美国宾夕法尼亚大学诞生第一台通用电子计算机ENIAC至今,在无数研究者的打磨与完善下,计算机领域沉淀出了一套严密的体系结构。在这套体系结构的支持下,现代计算机得以巧妙利用不同物理硬件的性能特点,构建紧密繁荣的软件生态,为整个行业的蓬勃发展提供坚实支撑。而在这套重要的体系结构中,指令集体系结构处于核心位置,它打通了处理器硬件与软件之间数据和控制流交互的通道,是一切计算机活动的基石。作为连接软件抽象与硬件物理实现的一份标准规范,指令集体系结构不仅规范了程序对底层资源的访问边界,更直接指导着处理器微架构的具体设计逻辑。正是因为它的存在,上层的软件开发者得以脱离繁杂的电路细节进行高效编程,而底层的硬件工程师也能在统一的规范下持续提升芯片的执行效能

image.png

为了深入理解这一规范是如何在处理器上真正运作的,本章将剥离现代处理器中冗余的工程细节,引入一套精简而经典的教学版指令集——Y86-64 作为核心学习对象。我们将从解析该架构的底层规范起步,逐步探讨并推演从基础的顺序执行处理器(SEQ)到流水线处理器(PIPE)的完整硬件设计与演进过程,由此揭开现代计算机底层运转的核心逻辑。

什么是指令集体系结构

指令集体系结构(ISA,Instruction Set Architecture)作为计算机体系结构的核心,处于软件与硬件的交界层。它为处理器的行为模式提供了标准规范。通俗而言,ISA 定义了处理器具备哪些操作能力、遵循何种执行逻辑、如何响应异常,并为上层软件提供了底层硬件的编程模型。

具体而言,一份完整的 ISA 规范通常包含:程序员可见状态、指令集及其编码格式、一组编程规范,以及异常与中断处理机制。

程序员可见状态

程序员可见状态(Programmer-Visible State)中的“程序员”,主要指代编写汇编代码的开发者或生成底层机器码的编译器。我们可以将该概念理解为:由 ISA 明确规定,能够被指令直接读取、操作或修改,并能反映处理器当前执行环境的所有硬件状态集合。

程序员可见状态包括:

  1. 程序计数器(PC,Program Counter):存储下一条要执行指令的内存地址。软件可以通过跳转指令(如 jmp, call)来间接修改它。

  2. 通用寄存器: 供指令直接操作的高速存储单元

  3. 条件码/状态寄存器:记录最近一次算数运算特征,用来支持条件跳转与判断

  4. 虚拟地址空间

为了方便界定程序员可见状态,我们可以将之与**程序员不可见状态(微架构状态)**进行对比。以下状态由硬件自动维护,软件无法通过常规指令直接干预:

  1. 流水线寄存器:在流水线F、D、E、M、W各个阶段用来暂存中间状态的寄存器(在后续内容中会进行讲解)

  2. 缓存的 Tag 和状态位: 硬件自动管理 L1/L2/L3 Cache 的命中与替换,程序员无法用常规指令直接读写这些控制位。

  3. 分支预测缓冲表: 硬件用来预测跳转分支的内部数据结构。

  4. 页表快速跳转表(TLB,Translation Lookaside Buffer):CPU内部的高速缓存,用来存放刚被查过的页表项

指令集及其编码

指令集及其编码规定了处理器能够执行的操作集合,以及程序员可以用来指挥 CPU 实现指定计算的具体指令形式。

指令集架构(ISA)为硬件设计者提供了一套必须满足的功能契约与输入输出规范,但并不限制底层物理电路的具体设计与实现方式。底层硬件的逻辑组织方案和物理实施细节,分别由微架构和物理电路设计所决定。

在具体的指令集设计中,汇编指令的助记符通常遵循一套标准的规范。一般而言,指令名称由三部分组成:操作数的来源与去向、操作类型以及操作数大小。以数据传送指令为例,rmi(register、memory、immediate)分别代表寄存器、内存位置以及立即数;mov(move)表示数据的传送操作;q(quad)表示四字(64位)。根据这些组合规则,我们可以清晰地解析指令的含义,例如在 Y86-64 中:

rmmovq:将指定寄存器中的值写入到指定的内存位置,移动的数据大小为 8 字节。

irmovq:将一个给定的立即数赋值给指定的寄存器,移动的数据大小同样为 8 字节。

上述由字母组成的指令形式是供程序员阅读和编写的文本表示。然而对于 CPU 而言,内存中的内容仅是一连串的二进制数值。为了让硬件能够准确解析并执行这些动作,必须设计一套严密的指令编码规范。这套规范通过特定的二进制操作码,界定了每条指令的长度与格式,从而在微观层面上实现了指令与指令边界的分离,以及操作逻辑与随附立即数的分离。基于这一逻辑,下面将引入 Y86-64 的具体指令编码形式:

image.png

我们可以看到在 Y86-64 规范中,指令编码通常被划分为以下几个结构单元:指令指示符、寄存器指示符以及可选的 8 字节常数字。

其中,单字节的指令指示符被均分为两个部分:高四位为指令代码(icode),低四位为指令功能(ifun)。指令代码用于标识当前指令所属的独立操作类别,指令功能则用于指明该类别下的特定子操作。

以十六进制字节 0x70 为例,它在物理上被解析为 70 两个部分。其中,icode 值为 7,代表该指令属于分支跳转类别;ifun 值为 0,代表该类别中的无条件直接跳转操作,即 jmp

image.png

编程规范

编程规范是指一套标准化的规则,规定了软件(包括编译器、汇编器以及底层汇编程序员)应当如何统筹使用 ISA 提供的硬件资源。其核心目的在于确保在统一的 ISA 框架下,由不同开发者编写或不同编译器生成的独立代码模块,能够在同一处理器上正确对接与协同运行。具体而言,该规范主要包含寄存器使用约定(如规定哪些寄存器用于传递参数或返回值,以及明确调用者与被调用者的寄存器保存责任)、栈帧的内存布局要求,以及数据访问与对齐规范等内容。

x86-64 ISA的编程规范在 Chapter 3 中已有介绍

异常与中断处理机制

异常机制涵盖了中断(外部硬件触发)、陷阱(系统调用)以及故障(如缺页、除零错误)等具体的事件类型。ISA 规范不仅定义了这些异常事件的分类,还规定了异常向量表等底层机制,以确保当程序遇到错误或需要向操作系统请求高权限服务时,硬件能够安全地保存当前的“执行现场”,并将控制权平滑移交给内核代码。

在异常事件的管理上,ISA 通常采用分类响应的思想。以 Y86-64 架构为例,它通过引入一个状态寄存器(Stat)来描述程序执行的总体状态。除了状态值 1(AOK)表示程序正常执行外,其他状态值分别对应细分的异常情况:例如执行了 halt 指令(HLT)、非法的内存地址访问(ADR)以及非法的指令执行(INS)。

image.png

在通用的处理器架构中,一旦硬件检测到状态异常,便会强制中止当前程序的常规控制流,通过查阅异常向量表跳转至对应的异常处理程序。此时,操作系统内核接管控制权,并根据异常的类型采取不同的处理策略。例如,对于致命错误,内核可能会直接中止该程序的运行;而在某些情况下,内核也会向用户进程发送特定的信号,交由用户自定义的信号处理程序来进行善后。

逻辑设计和硬件控制语言

Y86-64 微架构

ISA 作为计算机系统中连接软硬件的中间层,为底层硬件设计提供逻辑规范,同时为上层软件提供硬件调用接口。其中,ISA仅为底层确定了大致的框架(输入输出规范),具体的逻辑设计由微架构决定。

ISA 作为计算机系统中连接软件与硬件的中间层,为底层硬件设计提供了严格的逻辑规范,同时为上层软件提供了统一的底层编程模型。其中,ISA 仅为底层硬件界定了功能边界与输入输出规范,底层组织方式与内部逻辑设计由微架构来决定。

目前,我们已经掌握了部分 Y86-64 指令集规范以及逻辑电路设计的基础知识。接下来,我们将从基础的顺序执行处理器(SEQ)模型起步,逐步探索与优化微架构设计,最终构建出一个高效的流水线处理器(PIPE)——这也正是现代高性能处理器的核心。

指令执行的阶段划分

一般而言,处理一条指令包含多个底层操作。为了设计一个通用的处理器,我们需要对指令的计算过程进行抽象,提取出公有的子计算阶段,从而搭建出一个能够充分利用硬件资源的通用处理框架。现代处理器对指令的处理通常被划分为以下六个标准阶段:

取指(fetch):取指阶段根据 PC 寄存器中的地址,从内存中读取指令字节。指令码首字节的高四位被称为指令代码(icode),指明该指令所属的操作大类;低四位被称为指令功能(ifun),指明该类别下的具体功能。随后,硬件还可能取出一个寄存器指示符字节,用于指明指令所需的一个或两个寄存器操作数(rArB)。此外,它还可能取出一个四字节的常数字(valC)。在取指阶段末尾,硬件会根据当前的 PC 值以及所取指令的长度,计算出下一条相邻指令的起始地址(valP)。

译码(decode):译码阶段从寄存器文件中读取最多两个操作数,得到值ValA和/或ValB。通常,其读入指令rA和rB字段指明的寄存器。

执行(execute):在执行阶段,算术/逻辑单元(ALU)负责完成指令指定包含的计算任务。具体而言,ALU 会根据指令功能码(ifun)执行特定的算术或逻辑运算,或者为访存指令计算内存的有效地址,亦或在栈操作中负责增减栈指针。这个过程得到的值被称为ValE。在执行特定算术/逻辑指令时,该阶段还会依据计算结果,同步更新条件码寄存器。此外,该阶段也负责处理条件判定逻辑:对于条件传输指令(cmovXX),硬件会结合当前条件码评估传送条件,从而动态决定是否撤销对目标寄存器的写入(即在条件失败时将目标指向 RNONE);同样,对于条件跳转指令(jXX),该阶段会计算分支条件是否成立,为后续的程序控制流转移提供信号。

访存(memory):访存阶段可以将数据写入内存,或者从内存读取数据。读出的值为ValM

写回(write back):写回阶段最懂想寄存器文件中写入两个结果

更新(PC update):将PC设置为下一处理指令的地址

现代复杂架构中的指令执行,均可由上述标准阶段来解释。例如,包含内存间接寻址的算术指令 addq 8(%rbx), %rax,在现代处理器底层会被译码器分解为两个更基础的微操作(Micro-operations):先执行类似于 mrmovq 8(%rbx), %rcx 的数据加载操作,随后执行 addq %rcx, %rax 的寄存器加法操作。而每一个微操作的执行流程,都严格遵循上述的六阶段序列逻辑。

接下来,我们会通过解析常见指令在每个阶段的状态改变的细节,来为后续逻辑电路的设计做好准备。

阶段OPqrA,rBrrmovq rA, rBirmovqV, rB
取指$icode: ifun \leftarrow {\mathrm{M}}_{1}\left\lbrack \mathrm\right\rbrack
$\mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack
$

valPPC+2 | icode:ifunM1[PC] \mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack $

valPPC+2 | icode:ifunM1[PC] \mathrm : \mathrm \leftarrow {\mathrm{M}}{1}\left\lbrack {\mathrm + 1}\right\rbrack $valC \leftarrow {\mathrm{M}}\left\lbrack {\mathrm + 2}\right\rbrack valP \leftarrow PC+10 ||||||||||||| valA \leftarrow R[rA] valB \leftarrow R[rB] | valA \leftarrow R[rA] |||| valE \leftarrow valB\ OP\ valA Set CC | valE \leftarrow 0 + valA | valE \leftarrow 0 + valC ||访|||||| R[rB]← valE | R[rB]← valE | R[rB]← valE ||PC| PC \leftarrow valP | PC \leftarrow valP | PC \leftarrow valP$|

subq 指令执行的细节

阶段OPq rA, rBsubq %rdx, %rbx
取指$icode: ifun \leftarrow {\mathrm{M}}_{1}\left\lbrack \mathrm\right\rbrack
$\mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack
$

valPPC+2 | icode:ifunM1[PC] \mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack $

valPPC+2 | |||| |||| |译码|$valA \leftarrow R[rA] valB \leftarrow R[rB] | valA \leftarrow R[rA] valB \leftarrow R[rB] ||| valE \leftarrow valB\ OP\ valA Set\ CC | valE \leftarrow valB -valA$ Set ZFSFOF | |访存||| |写回| R[rB]valE | R[rB]valE | |更新 PC| PCvalP | PCvalP |

irmovq 指令执行细节

阶段OPq rA, rBirmovq V, %rsp
取指$icode: ifun \leftarrow {\mathrm{M}}_{1}\left\lbrack \mathrm\right\rbrack
$\mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack
$

valPPC+2 | icode:ifunM1[PC] \mathrm : \mathrm \leftarrow {\mathrm{M}}{1}\left\lbrack {\mathrm + 1}\right\rbrack ValC = {\mathrm{M}}\left\lbrack {\mathrm + 2}\right\rbrack valPPC+10 | |||| |||| |译码|$valA \leftarrow R[rA] valB \leftarrow R[rB] |||| valE \leftarrow valB\ OP\ valA Set\ CC | valE \leftarrow 0 + ValC ||访||||| R[rB]← valE | R[rB]← valE ||PC| PC \leftarrow valP | PC \leftarrow valP$|

rmmovq 指令执行细节

阶段OPq rA, rBrmmovq rA, D(rB)
取指$icode: ifun \leftarrow {\mathrm{M}}_{1}\left\lbrack \mathrm\right\rbrack
$\mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack
$

valPPC+2 | icode:ifunM1[PC] \mathrm : \mathrm \leftarrow {\mathrm{M}}{1}\left\lbrack {\mathrm + 1}\right\rbrack ValC = {\mathrm{M}}\left\lbrack {\mathrm + 2}\right\rbrack valPPC+10 | |||| |||| |译码|$valA \leftarrow R[rA] valB \leftarrow R[rB] | valA \leftarrow R[rA] valB \leftarrow R[rB] ||| valE \leftarrow valB\ OP\ valA Set\ CC | valE \leftarrow ValB + ValC$| |访存|| M8[ValE]valA | |写回| R[rB]valE || |更新 PC| PCvalP | PCvalP |

mrmovq 指令执行细节

阶段OPq rA, rBmrmovq D(rB), rA
取指$icode: ifun \leftarrow {\mathrm{M}}_{1}\left\lbrack \mathrm\right\rbrack
$\mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack
$

valPPC+2 | icode:ifunM1[PC] \mathrm : \mathrm \leftarrow {\mathrm{M}}{1}\left\lbrack {\mathrm + 1}\right\rbrack ValC = {\mathrm{M}}\left\lbrack {\mathrm + 2}\right\rbrack valPPC+10 | |||| |||| |译码|$valA \leftarrow R[rA] valB \leftarrow R[rB] | valB \leftarrow R[rB] ||| valE \leftarrow valB\ OP\ valA Set\ CC | valE \leftarrow ValB + ValC ||访|| valM \leftarrow M_8[ValE] ||| R[rB]← valE$| M8[rA]valM | |更新 PC| PCvalP | PCvalP |

pushq 与 popq指令

阶段pushq rApopq rA
取指$icode: ifun \leftarrow {\mathrm{M}}_{1}\left\lbrack \mathrm\right\rbrack
$\mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack
$

valPPC+2 | icode:ifunM1[PC] \mathrm : \mathrm \leftarrow {\mathrm{M}}_{1}\left\lbrack {\mathrm + 1}\right\rbrack$

valPPC+10 | |||| |||| |译码|$valA \leftarrow R[rA] valB \leftarrow R[%rsp] | valA \leftarrow R[%rsp] valB \leftarrow R[%rsp] ||| valE \leftarrow valB - 8 | valE \leftarrow ValB + 8 ||访| M_8[ValE] \leftarrow ValA | valM \leftarrow M_8[ValE] ||| R[% rsp]← valE | R[% rsp]← valE R[rA]← valM ||PC| PC \leftarrow valP | PC \leftarrow valP$|

pushq 指令会把栈指针减 8,并且将一个寄存器值写入内存中。因此,当执行 pushq %rsp 指令时,处理器的行为是不确定的,因为要入栈的寄存器会被同一条指令修改。通常有两种不同的约定:

  1. 压入 %rsp 的原始值,对应下图 Way 1,

  2. 压入减去 8 的 %rsp 的值,对应下图 Way 2。

image.png

对于Y86-64处理器来说,我们采用和x86-64一样的做法,即压入 %rsp 的原始值

对 popq %rsp 指令也有类似的歧义。可以将 %rsp 置为从内存中读出的值,也可以置为加了增量后的栈指针。对于Y86-64处理器来说,我们采用和x86-64一样的做法,即弹出 %rsp 从内存中读出的原始值。

jxx、call与ret

阶段jxx Destcall Destret Dest
取指$icode: ifun \leftarrow {\mathrm{M}}_{1}\left\lbrack \mathrm\right\rbrack
$

ValCM8[PC+1]valPPC+9 | icode:ifunM1[PC]

ValCM8[PC+1]valPPC+9 | icode:ifunM1[PC]

valPPC+1 | ||| icode:ifunM1[PC]ValCM8[PC+1]valPPC+9 | icode:ifunM1[PC]ValCM8[PC+1]valPPC+9 | ||||| |译码|| ValBR[%rsp] | ValAR[%rsp]ValBR[%rsp] | |执行| CndCond(CC,ifun) | valEvalB8 | valEValB+8 | |访存|| M8[ValE]ValP | valMM8[ValA] | |写回| R[%rsp]valE | R[%rsp]valE | R[%rsp]valE | |更新 PC| PCCnd? valC:ValP | PCvalC | PCvalM |

观察这些指令在每个子阶段的执行细节,我们可以发现一些容易被忽视的设计巧思。例如,在进行过程调用(call)时,处理器会自动将返回地址压入栈中。这是因为处理器在取指(Fetch)阶段,会先行计算出顺序下一条指令的地址(即 valP)。这种早期计算临时地址并将其送入访存阶段压栈,最终再于更新阶段统一修改 PC 寄存器的机制,不仅确保了函数调用完成后能够高效、精准地返回主调者的下一句指令,也为后续流水线架构中的指令预取提供了基础。

此外,深入观察过程分析表可以发现,表格中部分单元格存在内容空置以及变量使用反常的现象。例如,在 call Dest 指令的译码阶段,读取栈指针并赋值的对象是 valB,而非通常认知中顺序更靠前的 valA;同时,在该赋值操作的上方刻意留出了一行空白。

对于卡内基梅隆的教材而言,肯定不会出现排版性质的低级错误,这种看似反常规的排版往往隐藏着作者的深意。当我们着手设计 SEQ 微架构时便会理解,底层物理电路的连线和端口分配是固定死板的。表格中的“留白”,一方面是在提示硬件设计者在此阶段需要保持某些控制信号的静默(例如将特定寄存器或内存的写使能信号拉低至 0),防止电路设计出现问题;另一方面,强制使用 valB 则是为了严格对齐特定的物理读端口,以便后续能在执行阶段完美复用已有的算术逻辑单元数据通路,减少重复电路设计带来的空间损失。这样的特定的排版设计,反映了工程设计中的严谨细节。

SEQ 微架构

在经过前面对指令执行的六个最小阶段的了解以及常见指令的执行过程拆分与分析后,我们脑海中对微架构的设计要求已经有了大概的认知(如下图),即:一个可以拆分成六个互联部分、顺着指令执行过程传递数据以及控制信息的系统。那么基于指令串行执行特点以及产品开发中的MVP理论,我们不难设计出第一版微架构 SEQ。

MVP(Minimum Viable Product,最小可行性产品)是产品开发和精益创业理论中的一个核心概念。其宗旨是用最低的成本、最少的时间,做出来一个刚刚好能跑通核心业务逻辑的“早期版本”,然后迅速推向市场,通过真实用户的反馈来验证你的产品想法是否合理。

image.png

SEQ大概的数据通路以及逻辑通路如上图所示(部分细节未给出,留待后面进行完善)

完善设想可获得基本的SEQ模型:

image.png

在这个 SEQ 模型中,处理器首先取出 PC 寄存器指向地址(Instruction Memory)中存放的指令代码,并解析其内容,从而提取出 icodeifun、寄存器指示符 rA 和/或 rB,以及可能存在的常数 valC。在进行 Fetch 操作的同时,处理器还会根据当前 PC 的值以及取出的指令长度,计算出紧邻的下一条指令地址并写入 valP 中。

进入 Decode 阶段后,处理器会从寄存器文件中读取最多两个寄存器的值,具体的读取对象由 Fetch 阶段取得的 rArB 决定。

在 Execute 阶段,处理器接收前两个阶段传递来的参数。如果是执行算术运算,ALU 会根据 ifun 指示的功能进行相应计算,输出结果(valE),并同步更新条件码(CC)寄存器。而如果当前执行的是条件传输或跳转指令,处理器则会评估当前的 CC 状态来生成 Cnd 信号,以此决定后续是否要将写入目标寄存器撤销(置为 RNONE),或是决定是否进行分支跳转。

到了 Memory 阶段,硬件的访存控制逻辑会直接依据指令的类别(icode)来判断是否需要读写内存。处理器结合 Fetch 阶段产生的 valP(常用于 call 压栈)、Decode 阶段取得的 valA(常作为存入内存的数据),以及 Execute 阶段算出的 valE(常作为内存地址),完成对数据内存的完整操作。Execute 或 Memory 阶段读出的数据(valE/M),在 Write Back 阶段可能会被写回寄存器文件。

在最后的 PC Update 阶段,处理器会综合考量 icode、条件信号 Cnd、以及可能的目标地址(valPvalCvalM),选出正确的地址来更新 PC 寄存器。至此,处理器便完成了当前这条指令执行的完整生命周期,并为下一条指令的取指做好了准备。

在现代计算机体系结构(如现代改良型哈佛架构)中,程序的指令和数据在主存中通常共享统一的物理地址空间,但在靠近处理器核心的底层高速缓存层面,它们被严格分离为独立的指令缓存(Instruction Cache)与数据缓存(Data Cache)。这种物理通路的分离,消除了流水线执行过程中的结构冲突。通过设置双重的独立缓存,处理器的取指阶段和访存阶段能够并行运作、互不干扰,保障底层指令流水线的高效流转。

Fetch 阶段设计细节

image.png

Fetch阶段的细节图展现了指令切分、中间量以及信号量的产生具体方式

Need ValC 表示指令是否包括一个常数字 Need regids 表示指令是否包括一个寄存器指示符字节 Instr valid 表示指令是否合法 imem_error 表示指令地址是否合法

如图所示,在尝试读取指令时,硬件会首先检查内存地址的合法性,从而产生 imem_error 信号。若 imem_error 为真(即地址非法),硬件会强制将提取出的 icode 置为 nop 的代码,从而异常发生后保护处理器的内部状态不被污染;同时,硬件会将地址异常(ADR)的信号传递给后续阶段,最终更新至 Stat 寄存器以中止程序。

若指令地址正常,硬件会对取出的字节进行切分,获取 icode,并据此生成 Instr validNeed regids 以及 Need valC 三个控制信号。其中,Instr valid 用于向系统反馈指令本身的合法性(若非法则抛出 INS 异常)。而 Need valCNeed regids 则会与原 PC 值一起,被共同输入至 PC increment 模块,用于计算紧邻的下一条指令地址。其物理电路逻辑等价于以下公式:

valP=PC+1+Need_regids+8×Need_valC

特别值得注意的是,Need regids 信号还会被同步传递给 Align(对齐)模块。对齐模块在这里充当了一个“滑动窗口控制开关”,指示硬件应当从哪个字节开始截取那段长达 8 字节的常数(valC)。

Decode 阶段细节

image.png

Decode(译码)阶段的核心职责有两点:一是读取寄存器文件获取操作数,二是为后续的写回(Write-back)阶段提前配置好目标寄存器的地址(ID)。

从物理结构来看,寄存器文件通常配备四个独立的端口,支持同时读取两个操作数(通过读端口 A 和 B)以及写入两个操作数(通过写端口 E 和 M)。每个端口均包含一个“地址输入端”与一组“数据总线”:地址输入端接收 4 位的寄存器 ID 以精确定位操作目标;数据总线则是一组 64 位的物理线路,作为寄存器文件数据流入或流出的通道。

在具体的执行流中,Decode 阶段会接收上一阶段(Fetch)提取的 rArB。此时,硬件的控制逻辑会根据指令代码(icode),动态选择究竟是将 rArB 还是隐式寄存器(如 %rsp)的 ID 馈入读端口,从而成功读出所需的 valAvalB

同时,Decode 阶段也会根据 icode 输出默认的写入目标地址(dstEdstM)。需要说明的是,对于条件传输指令,虽然最终决定 valE 是否写入的是 Execute 阶段生成的 Cnd 信号,但动态修改目标地址(将 dstE 截断为 RNONE)的逻辑是在 Execute 阶段的硬件区块中发生的,Decode 阶段仅负责提供最初的基础路由。

Execute 阶段细节

image.png

Execute 阶段的核心是对指令中的算术控制逻辑进行运算与判断。icode 在此阶段依旧扮演者 controller 的角色,通过控制 ALU 输入端前置的多路选择器,动态决定将哪些数据源(如 valAvalB 或常数 valC)输入运算单元。此外,icodeifun 会协同决定 ALU 具体执行的算术或逻辑功能。完成计算后,ALU 输出数据结果 valE。当遇到分支或条件传输指令时,硬件会综合当前的 CC 状态与 ifun 指定的触发条件产生 Cnd 信号,为后续的数据写回或 PC 更新提供控制依据。

Memory 阶段细节

image.png

Memory 和 Execute 阶段数据控制流有些相似,icode 依旧扮演控制数据读取的角色。在这个阶段,处理器会根据前阶段产生的ValE、ValA以及ValP以及icode选择传入内存操作的数据,并根据icode判断是内存读还是内存写。在从内存中取出ValM的同时,处理器会产生 dmem_error 的信号,和前面阶段产生的Instr_valid、imem_error一起传入Stat处理单元,生成反馈程序运行整体状态的 Stat 信号

Memory 与 Execute 阶段的控制逻辑存在相似之处,icode 在此依然扮演着路由控制器的角色。在这个阶段,处理器首先会根据 icode 判断当前指令需要执行内存读、内存写,还是保持闲置。随后,硬件会结合 icode,从前阶段传递来的变量中选出所需的物理地址与写入数据:通常以 Execute 阶段算出的 valE 作为内存访问地址,以 Decode 阶段读出的 valA 作为存入内存的数据,而在执行函数调用(call)时,则会将 Fetch 阶段算出的返回地址 valP 压入内存。

在执行读写操作的过程中(如向内存写入数据,或从内存中读取出 valM),硬件会实时校验目标物理地址的合法性。一旦探测到地址越界或非法,处理器便会立即触发 dmem_error(数据内存异常)信号。最终,这个异常信号会与前阶段产生的 Instr valid(非法指令异常)以及 imem_error(指令内存异常)一起被汇聚至状态(Stat)处理单元,由其生成反映程序当前整体运行状态的 Stat 信号。

PC Update 阶段细节

image.png

PC Update 阶段的逻辑相对简单,根据前阶段产生的值和信号选择下一个将要执行的指令地址

SEQ+ 微架构

SEQ+ 是SEQ转向PIPE模型的一个中间模型,保留SEQ主体的同时对PC Upate进行了关键修改,为PIPE模型转变提供基础。在讲解SEQ+模型的细节之前,我们需要对流水线模型进行一定的了解。

流水线

预想你是一家生意火爆的餐厅老板,目前后厨只有一位厨师。你观察到,在为顾客上菜之前,大厨需要独自完成接单、备菜、炒菜、装盘、摇铃这五个标准化流程,且每个流程都有专属的工作区。

由于全场只有这一名大厨,他必须把一道菜的所有流程从头到尾做完,才能转身去接下一道菜。这就意味着顾客的订单必须被串行处理。你敏锐地发现了这种低效的根源:大厨在处理当前子任务时,只会占用对应的工作区,而厨房里另外四个工位的资源都处于完全闲置的状态。

如果能让每个时间点的闲置资源都被充分利用,整体出餐效率也将获得提升。于是,你果断招聘了四位专职助理,让他们和大厨分别负责这五个工位。现在的运转模式变成了:**一旦某道菜完成了当前工序并被推向下一个工位,该工位的厨师立刻“无缝衔接”处理下一道菜的同类工序。**这样一来,确保了厨房的每个区域、每个时刻都在满负荷运转。

恭喜你,成功发明了“流水线”工作模型。使你的出餐效率提高5倍(假设上菜一共分为五个阶段,每个阶段用时相同)。

image.png

流水线模型相对于串行执行模型而言一个最大的特点就是将任务拆分成多个子阶段,每个子阶段的资源都被充分利用,降低资源闲置的比例,提高系统吞吐量(Throughput)。不过需要注意的是,系统的运行时钟由最慢的阶段所决定,如果子阶段存在不均匀划分,执行流程中就会有气泡(Bubble)存在,任务的执行延迟(Latency)就会增加,整体收益也会降低。

一般而言,流水线模型切分的子阶段越细,系统的吞吐量越高;子阶段切分越均匀,任务执行的延迟增加的越少,在引入流水线模型时,需要在 throughput 以及 latency 之前进行 trade-off 。然而,流水线模型也不能无限度增加系统的吞吐量,系统最大吞吐量取决于最大耗时的子阶段。

image.png

SEQ + 的改变

为了实现流水线化设计,我们需要调整下SEQ中五个阶段的执行顺序,让PC在第一个时钟周期开始时执行,从而解开每条指令执行所隐含的串行关系,使得指令能源源不断进入处理流程。

image.png

SEQ 模型中,在PC Update 后的 Fetch 阶段,硬件会取出可能代表跳转地址的常数字ValC,同时通过计算获得紧邻下一项的指令地址 ValP;在Execute以及Memory阶段,硬件会分别产生条件信号Cnd以及内存值ValM。普通常规指令并不会影响指令跳转逻辑,下一条执行的往往是ValP指向的指令,只有条件、跳转存在地址跃变的情况。此时,下一条待执行指令地址由ValC、Cnd以及ValM共同决定。

因此,我们可以创建状态寄存器来保存在上一条指令执行过程中计算出来的新信号,然后在下一个时钟周期开始时再根据状态寄存器中的值计算新的PC值。我们将更改后的执行模型称为SEQ+。

一般情况PC等于上一条指令Fetch阶段中计算出的ValP值,此时直接执行ValP指向的指令即可。若遇到跳转指令,通过icode 以及 Cnd 信号,我们可以通过暂停或者插入气泡的机制等待正确地址计算完成并更新进状态寄存器后,再跳转执行下一条指令(具体处理方案见后面的流水线异常处理部分)

image.pngimage.png

PIPE- 微架构

修改后的SEQ+已经具备了简单的并发性质:每个阶段可以独立处理输入的数据或信号且具有源源不断的输入。不过,由于缺乏暂存中间结果的寄存器,目前处理模型每个子阶段依旧与前面的阶段存在数据依赖。为了解决这个问题,我们引入了流水线寄存器,暂存每个阶段输出的结果,消除阶段间的数据和逻辑依赖,使每个子阶段能够做到真正并发。引入流水线寄存器后的模型具备了流水线的基本形式,被称为PIPE-模型。

image.pngimage.png

PIPE-模型并不是在SEQ+的基础上简单增加流水线寄存器完成,它同时设置、合并以及更新了一些信号量以及中间量。例如,icode 与 指令读取状态量在Fetch阶段合并在一起,让F_stat就携带指令读取状态以及指令内容的信息;在所有指令中,只有call在Memory阶段需要ValP,jxx指令会在Execute阶段(不需要跳转的时候)会需要ValP,而这些指令又不需要从寄存器文件中读出的值,因此我们将ValA和ValP合并成新的ValA值,减少流水线寄存器的状态数量。

为了区分每一阶段独自的状态值和信号量,我们通常在变量名前添加前缀以作区分。例如,D_stat表示Decode阶段完成后产stat状态的值,而d_stat表示Decode运行过程中产生的stat值。

PIPE 微架构

PIPE- 模型已经具备了流水线模型数据处理系统的完整功能,但还缺少信号控制系统,对异常行为进行处理。(CSAPP中的)PIPE 模型的完整结构则是在PIPE-的基础上添加了一些信号回馈线路以及异常处理控制单元。在对PIPE-模型进行完善之前,我们首先需要知道PIPE-模型中存在的异常问题,并探究如何进行处理。

流水线冒险

在构建PIPE-模型时,我们假象的处理数据流是理想的串行独立数据,但实际数据流往往没有这样理想,数据之间往往存在一定的关联,而这些关联会使简单的并行逻辑(PIPE-)失效。一般而言,这些关联可以分为:

  1. 数据相关:下一条指令的运算与当前指令的运算结果相关

  2. 控制相关:未来处理的指令地址与当前跳转指令的目标地址相关

这些相关会导致流水线计算出现错误,在计算机术语中称之为冒险(hazard)。与关联相应,冒险可分为数据冒险与控制冒险。我们先从最简单的数据冒险开始,探究异常产生的原因以及对应可能的处理方案。

数据冒险

假定PIPE-模型处理下列指令序列:

Shell
# assume that %rax = x; %rdx = y
0x000: irmovq $10,%rdx 
0x00a: irmovq $3,%rax 
0x014: addq %rdx,%rax 
0x016: halt

如果没有进行特殊的流水线控制,该指令流在PIPE-模型中的执行细节如下:

image.png

我们很容易发现,第三条指令 addq %rdx,%rax 所需要的两个操作数(译码阶段)依赖于前两条指令 irmovq 执行的结果(写回阶段)。在当前模型执行到第5个周期时,我们发现,第一个操作数正在写入Register File,而第二个操作数还未进行写操作。于是,add指令会使用未更新的操作数进行算数操作,导致错误结果的产生。

image.png

这个错误产生的原因是后续指令在某一阶段会使用前面指令某一阶段产生的结果,即指令序列具有潜在的时序依赖。为了解决这个问题,我们选择使用nop指令推后后续指令执行的时间,满足潜在的时序依赖。

image.png

引入:nop指令 nop(全称 No Operation,即“无操作”)指令功能是让处理器“发呆”一个时钟周期,除了自增 PC指向下一条指令外,不对任何寄存器、内存或状态码产生任何影响。

从数据冒险的例子中我们不难看出,当一条指令更新后面指令会读到的那些程序状态时,就有可能出现冒险。对于Y86-64来说,程序状态包括程序寄存器、程序计数器、内存、条件码寄存器和状态寄存器。我们可以看看每类状态出现冒险的可能性。

程序寄存器:我们已经在引入的例子中认识了这种冒险。出现这种冒险是因为寄存器文件的读写是在不同的阶段进行的,导致不同指令之间可能会存在隐藏的时序依赖。

程序计数器:更新和读取程序计数器之间的冲突导致了控制冒险。当我们的取指阶段逻辑在取下一条指令之前,正确预测了程序计数器的新值时,就不会产生冒险。相反,预测错误的分支和 ret 指令会产生冒险,需要进行特殊处理。

内存:Memory 阶段发生对内存的读和写。对于传统冯诺伊曼结构,指令和数据共享一片内存空间,CPU是否将一串数据看成指令,取决于PC指针是否指向它。因此对于能够自我修改代码的程序(例如JIT类程序),访存阶段写数据的指令和在取指阶段中读指令之间可能存在冲突。这类冲突的解决方案较为复杂,本文中暂不讨论。

条件码寄存器:在执行阶段中,整数操作会更新条件寄存器。条件传送/转移指令会分别在执行/访存阶段读这些寄存器。 由于条件传输/转移指令都是接收自己在Execute阶段更新的CC值,因此不会发生冒险。

状态寄存器:指令流经流水线的时候,会影响程序状态。状态寄存器上不存在数据冒险,但是可能发生控制冒险,即连续指令同时异常时,最终报错靠后指令的问题。

通过上述分析,我们不难看出异常发生的一个必要条件,即:若指令流中存在由于控制条件或者数据读取而产生的隐藏时序依赖,则将其并行化执行时,隐藏的时序依赖会被显化,造成冒险。这个条件的发现为后续解决方案的设计提供思路。

除此之外,这些分析还表明,我们只需要处理寄存器数据冒险、控制冒险,以及确保能够正确处理异常,就能让系统正确运行。当设计一个复杂系统时,这样的分类分析是很重要的。这样做可以确认出系统实现中可能的困难,还可以指导生成用于检查系统正确性的测试程序。

数据冒险的解决方案

经过分析,我们知道冒险发生的原因是原本串行执行的隐式时序依赖在并发的PIPE-执行模型中显化出来,解决冒险即是解决显化的时序依赖。对于时序依赖,我们可以选择将依赖项(后执行的指令)后置到时序依赖的范围外,或者将被依赖项结果“前置”,消除时序依赖。对于前者,我们对应使用暂停的方式;对于后者,我们可以采用数据转发。

用暂停来避免数据冒险

暂停(stalling)是避免冒险的一种常用技术,暂停时,处理器会停止流水线中一条或多条指令,直到冒险条件不再满足。具体而言,在检测到某一指令对正在执行的前序指令存在依赖时,处理器在冲突发生的阶段前向其插入bubble,直到被依赖项完成数据更新(即消除时序依赖)。bubble和 nop 指令的功能很像——它不会改变寄存器、内存、条件码或程序状态,只是占用了一个时钟周期。

以前面举出的程序为例,如果CPU具有暂停控制机制,则原来的指令流在addq执行到D阶段时,会插入三个bubble,直到时序依赖消除。

image.png

用转发来避免数据冒险

除了stall之外,由于流水线模型中存在阶段寄存器,存储每一阶段产生的中间结果,我们可以通过把被依赖项的结果提前转发给依赖项指定阶段的寄存器,在存在冒险的阶段执行前选择正确的数据,来达到减少甚至消除stall的次数。

这里可以插一句,解释为什么还要研究转发的方法:stall 的硬件开销比较大

对于之前的例子,我们可以看到,两个irmovq指令都和后续的addq指令间存在相同类型的时序依赖,对于PIPE模型而言,我们只需要解决后一个irmovq指令和addq指令之间的依赖即可。对两条指令进行分析,irmovq在W阶段确立数据写回的目的寄存器编号以及写回值、addq在D阶段读取目的寄存器编号,因此我们可以通过将irmovq指令W阶段产生的中间结果(W阶段寄存器中的W_valE)提前传入addq指令D阶段的阶段寄存器,经过某种处理后选择更新后的结果,解决原本的冒险。

image.png

除此之外,D阶段需要的数据还可能由E以及W阶段产生,因此我们需要在D阶段后的三个阶段都与D阶段建立一个回馈电路进行数据转发。其中,E阶段会产生ALU计算出的结果ValE;M阶段存在写入/回内存的目标寄存器DestM、ValM;W阶段同M阶段存在DestW以及ValW。这就是PIPE模型D阶段额外建立的五条数据通路的原因。

image.pngimage.png

流水线异常处理

流水线异常处理主要围绕着信号量Stat的传递以及处理展开,即如何传递各阶段产生的状态信号、如何处理接收异常状态。对于流水线模型而言,异常处理大致存在为三个细节。

第一类是多异常存在情况的处理。由于流水线并行计算的特点,一条流水线上可能同时存在多种不同阶段产生的异常。对于这种情况,我们选择优先处理较深指令产生的异常(即越先执行的指令)

第二类是分支预测失败的问题。我们知道处理器执行分支指令时会对跳转的目标进行预测,以此充分利用硬件。而这样的设计会导致一种情况,即预测分支内执行的指令产生了异常,而最终发现分支预测错误,需要跳转到正确的分支。这时候我们需要暂停状态信号量的传递,收回预测执行的指令,重新装填正确分支指令并对忽视之前指令产生的状态信号

第三类

流水线性能评价指标

分支预测机制

指令重排技术

【未完待续】

最近更新: