第一次把五级流水的时空图铺在纸上我盯着那几条交错推进的指令看了很久——明明每条指令都要走完取指、译码、执行、访存、写回这五步可它们偏偏能像流水线上的工件一样错开身位往前跑在一块并不算快的芯片上把吞吐率抬到单周期实现的几倍。这件事里藏着计算机体系结构最迷人的一处设计哲学不追求让单条指令跑得更快而是想办法让每一拍都有活干。而把这条哲学落到硬件上的核心手段就是流水线。这篇内容想做的事情很具体把体系结构基础和流水线原理这两块看起来抽象、考试里又全是计算题的知识拆成一套能自己推、自己算、自己动手搭出来的东西。如果你正在上这门课、正准备做 MIPS 五级流水的实验、或者工作几年后想回头补一补底层的性能直觉下面的内容应该都能对上号。我不会只给你结论每条结论从哪来、哪些地方教科书讲得含糊、实验中哪几个信号最容易接错都会说清楚。1. 一条指令的完整生命周期体系结构到底在权衡什么把这个问题想明白后面流水线的所有设计动机都会变得顺理成章。一条add r1, r2, r3从进入处理器到改完寄存器堆中间经历了取指、译码、取操作数、运算、写回这一串动作每一步都要占用硬件资源、消耗时间。体系结构这门学问本质上就是在回答这些步骤该怎么组织、资源该怎么分配、时间该怎么切。1.1 ISA 与微架构的分界线同一份指令集能长出完全不同的芯片很多人一开始会把指令集和处理器混为一谈这个混淆会直接导致后面学乱序执行时彻底晕掉。指令集架构ISA是软件和硬件之间的契约它规定了有哪些指令、指令怎么编码、有多少个通用寄存器、寻址方式有哪几种、异常怎么处理。微架构Microarchitecture则是这份契约的具体实现方式它决定流水线有几级、有没有乱序、cache 多大、几个发射口。这条分界线一旦划清楚很多反常识的现象就正常了同一份 ARM 或者 RISC-V 的二进制程序可以跑在一个只有三级流水的低功耗小核上也可以跑在一个十几级流水、四发射、乱序执行的桌面核上功能完全一致性能差十倍。程序不需要重新编译因为 ISA 没变变的只是底下那套组织方式。从流水线的角度看ISA 的设计会直接决定流水线好不好做这里最有代表性的就是 RISC 风格的选择ISA 层面的设计选择对流水线的直接影响定长指令如 32 位译码阶段不用先算指令长度IF 阶段一拍就能确定下一条指令地址Load/Store 架构只有访存指令会碰内存EX 阶段的功能划分干净冒险判定简单寄存器堆三端口两读一写大多数 ALU 指令能在 ID 阶段同时取到两个源操作数规整的指令格式源寄存器和目的寄存器字段位置固定译码可以并行做反过来说如果指令长度可变、一条指令能直接对内存做运算典型 CISC 风格译码这一级就会变得非常重可能要拆成好几拍流水线的节奏立刻被打乱。这也解释了为什么早期复杂指令集的处理器流水线做不深而 RISC 一出来流水线就变得顺手。1.2 性能公式的三个乘数以及它们为什么互相拉扯所有体系结构的性能讨论都绕不开这个式子CPU 时间 指令数 × CPI × 时钟周期 指令数 × CPI ÷ 主频三个乘数分别对应三个不同的层面指令数由编译器优化和 ISA 本身决定**CPI每指令周期数**由微架构和程序行为共同决定时钟周期由电路实现和工艺决定。关键在于这三个量不是独立的动一个往往牵动另外两个这是体系结构里最需要建立起来的直觉。我举个最常见的例子。为了让指令数变少ISA 里加一条把内存里的数加上寄存器再写回内存的复合指令指令数确实少了编译器也确实少发了指令但这条指令的译码和执行都变复杂了CPI 会上升时钟周期可能还得拉长。省下的指令数能不能抵掉 CPI 和周期的损失是要具体算的没有天然答案。再举个例子。为了把主频拉高把流水线从 5 级加深到 15 级每一级的组合逻辑变短了理论上级数上去了频率就能上去。代价是分支预测失败时要冲刷的指令从 2 条变成 12 条每次失败的代价暴涨同时流水线寄存器的数量翻了几倍面积和功耗都上去了。历史上著名的超深流水线设计后来被放弃转向更浅的流水加多核核心原因就在这里。这里还得插一句关于性能指标的提醒MIPS每秒百万条指令作为性能指标是有误导性的。它等于 主频 ÷ (CPI × 10^6)把指令数量完全忽略了。一个用 100 条指令干完的活和一个用 200 条指令干完的活MIPS 高不代表跑得快。真正靠谱的指标是执行时间或者相对某个基准程序的加速比。1.3 Amdahl 定律为什么只优化一部分经常白忙这条定律看着简单但它是所有性能优化的第一道过滤器做题和做实际优化都躲不开加速比 1 / ((1 - f) f / s)其中 f 是可优化部分占总时间的比例s 是这部分被优化后的加速倍数。就算你把某个模块优化到无穷快整体加速比的上限也只有 1/(1-f)。我代入一组数字你能立刻感受到它的冷水效果。假设某程序 40% 的时间花在某个可优化模块上你把它优化了 10 倍整体加速比是 1/(0.6 0.04) 1.5625。就算这个模块彻彻底底优化到零耗时上限也只有 1/0.6 ≈ 1.667。花大力气做了十倍优化最终只拿到 1.56 倍这个落差就是 Amdahl 定律想让人记住的东西。这条定律在流水线里的一次典型应用是冒险停顿到底值不值得费力消除。如果实测下来各条指令因为数据冒险平均要停 0.3 拍CPI 从 1 变成 1.3那这部分占总周期的比例是 23% 左右。你加了一堆前递通路和预测逻辑即使把停顿完全消除理论加速比上限也就 1.3 倍而实际中往往会引入额外的组合逻辑延迟让时钟周期稍微变长把收益又吃掉一部分。这就解释了一个工程上的常见判断停顿比例低于某个阈值时硬件的复杂度往往不划算不如把资源投到别的地方。2. 五级流水线的骨架把一个周期切五刀是怎么切出来的如果说第 1 节讲的是该权衡什么这一节讲的就是教科书里最经典的那套方案长什么样。五级流水是所有体系结构课程的起点也是理解现代处理器的必备底图。我会把每一级为什么这么切、流水寄存器到底起什么作用、加速比怎么手算都过一遍。2.1 为什么切在 IF/ID/EX/MEM/WB 这五个位置五级的划分不是随便挑的它是按照功能边界 延迟大致均衡这两个约束切出来的IF取指用 PC 访问指令存储器取出指令同时算出 PC4。ID译码/取寄存器解析指令字段从寄存器堆读出两个源操作数同时判断这条指令到底要干什么。EX执行/地址计算ALU 干活做算术运算或者算访存地址分支指令的比较也在这里做。MEM访存只有 load/store 真正用到这一级访问数据存储器。WB写回把结果写进寄存器堆。划分的核心依据是每一级的延迟要尽量接近因为流水线的时钟周期由最慢的那一级决定。切得不均就会出现某一级拖后腿、其他级空等的情况——流水线里最快的级也只能和最慢的级跑一样快这是硬性约束没有商量余地。同时还有一条隐含约束功能之间不能有冲突。比如读寄存器必须在 ID 级完成因为后面 EX 级要用写寄存器必须在 WB 级完成因为要等结果算出来。这两个动作虽然都碰寄存器堆但发生在不同时刻所以能共用一套寄存器堆硬件而不打架。如果 ISA 允许一条指令在 EX 阶段写回结果又在同一级被下一条指令读到硬件就得加额外的通路复杂度立刻上去。还有个实际工程上的细节值得提一下时钟周期并不等于最慢那一级的逻辑延迟还要加上流水寄存器的建立时间、保持时间和时钟偏移。教科书里画图时流水寄存器是个理想的竖线实际芯片里它要占几十到一百多皮秒。当流水线级数很深、每级逻辑只有两三百皮秒时流水寄存器本身的开销占比就相当可观了这也是流水线不能无限加深的物理原因之一。2.2 流水寄存器的角色它不是缓冲是对齐时刻的证据初学者最容易忽略的就是流水寄存器画时空图时直接跳过它结果实验里波形对不上就完全找不到问题。流水寄存器是在每一级之间插的一组触发器用来把这一级的输出锁住供下一级在下一个时钟边沿使用。以 ID/EX 寄存器为例它要保存的东西相当多两个源操作数的值、两个源寄存器的编号、目的寄存器编号、ALU 的控制信号、是不是访存指令、是不是写寄存器、是不是分支……这个宽度通常在一两百位。为什么连寄存器编号都要往后传因为 EX 之后的 MEM 和 WB 阶段要根据这些编号去做前递比较、决定往哪个寄存器写回。编号跟信号一样也得逐级传递。提示做实验时ID/EX 寄存器里少存一个控制信号症状往往不是功能完全错而是某类指令偶尔出错特别是在测试用例覆盖不到的时候完全看不出来。最稳的做法是画一张完整的信号流向表把每个控制信号从哪一级产生、经过哪几级、在哪一级被消费列清楚。流水寄存器还有个容易踩的坑是复位和使能。当流水线要插入气泡停顿时通常的做法是把某个流水寄存器的写使能拉低让它保持原值同时把前一级的输出挡住等效于往后插了一个空周期。如果忘了给寄存器加使能端停顿就变成了重复执行同一条指令波形上看起来一切正常但结果完全是错的。2.3 理想流水的加速比手算从 nk/(kn-1) 看流水线的上限理想流水的计算有固定套路我把公式和推导思路都写一下这样你就不需要背了。假设流水线有 k 级要连续执行 n 条互不相关的指令。第一条指令需要 k 个周期才能走完全程之后每个周期都能完成一条所以总的周期数是总周期数 k (n - 1) n k - 1如果不流水每条指令都要 k 个周期总周期数是 n×k。所以加速比是加速比 n×k / (k n - 1)代入具体数字感受一下。k 5n 1000 条指令加速比 5000 / 1004 ≈ 4.98。已经非常接近 5 了。当 n 趋于无穷时加速比趋近 k。这就是流水线的理论上限级数。但这里有个必须纠正的误解加速比趋近 k指的是吞吐率提升 k 倍不是单条指令的延迟降低 k 倍。恰恰相反单条指令走完全程的时间从原来的 1 个长周期变成了 k 个短周期如果每级逻辑延迟不均衡、流水寄存器有开销单条指令的实际延迟反而是变长的。流水线是拿延迟换吞吐这个 trade-off 必须记牢。顺着这个思路还能推出一个常被忽略的结论流水线对连续大量相同操作最友好对单次零散操作毫无帮助。如果一个程序里指令之间依赖极重、根本凑不出可以交错执行的指令流流水线就只能靠停顿维持正确性实际加速比会远低于 k。这也是为什么后面的章节要花大量篇幅讲冒险——冒险处理的质量直接决定你能不能拿到接近 k 的那部分理论收益。2.4 单周期、多周期、流水线三种实现的正面对比把三种实现方式放一起对照流水的定位会清楚很多实现方式时钟周期由谁决定CPI关键问题单周期最慢的那条指令通常是 load1周期极长快的指令白白等硬件不能复用多周期最慢的那一级大于 13 到 5 不等每条指令的 CPI 不一样控制复杂度上升流水线最慢的那一级 流水寄存器开销理想为 1实际大于 1冒险处理是全部难点单周期实现的致命问题是木桶效应被放大到极致周期必须按最慢指令访存来定而一条 add 明明只需要很短的时间却要占用同样长的周期硬件资源还不能复用比如指令存储器和数据存储器必须分开因为同一个周期既要取指又要访存。多周期把一条指令拆到多个短周期里执行快的指令少用几拍慢的指令多用几拍硬件开始复用这是进步。但它的 CPI 平均下来是 3 到 5而且每条指令之间必须串行前一条没做完后一条开不了工。流水线继承了多周期的短周期和硬件复用同时用重叠执行把 CPI 压到理想值 1。代价就是引入了冒险本来串行执行时天然不存在的冲突一旦重叠就全冒出来了。流水线的全部复杂度都是为了在不破坏正确性的前提下维持重叠执行。3. 三类冒险流水线的所有麻烦都从这三条缝里漏出来这一节是整个知识体系里最需要动手算的部分。冒险Hazard指的是下一条指令在它预定的时钟周期内无法执行的情况。教科书一般分成三类结构冒险、数据冒险、控制冒险。我按成因—判定—解决方案—代价这个顺序逐个说中间所有停顿拍数都给出推导过程。3.1 结构冒险资源只有一份两条指令同时伸手结构冒险的根源是硬件资源不够用两条指令在同一拍想用同一个部件。典型的三处第一处是存储器。如果只有一个存储器IF 阶段要取指MEM 阶段要访存两条指令撞在同一拍就没法办。解决办法是分离指令存储器和数据存储器也就是我们常说的 I-Cache 和 D-Cache 分开。这个设计不只是为了消除冲突它同时提高了访存带宽是现代处理器的标配。第二处是寄存器堆。一个典型的寄存器堆只有两个读口一个写口。正常情况下ID 级两个读口给当前指令取源操作数WB 级占用写口写结果读写发生在同一拍的不同端口上不冲突。但如果 ISA 允许一条指令有两个以上的源操作数或者需要读一个写一个这种组合端口就不够用了。有些设计会在寄存器堆里做写优先或者加内部前递让同一拍写入的值能被读出省掉一次停顿。第三处是非全流水的功能单元最典型的是乘法和除法器。加法器可以做成全流水一拍出一个结果但除法器通常要跑十几拍甚至几十拍这期间它无法接收新指令后续指令只能排队等待。解决思路是把它做成部分流水比如 3 拍一段或者用迭代式的软件实现替代硬件除法。注意结构冒险和另外两类冒险有个重要区别——它是硬件资源决定的跟程序里指令的依赖关系无关。也就是说不管你写什么程序只要资源冲突存在它就一定会出现。所以这类冒险主要靠硬件设计解决软件层面基本无能为力。3.2 数据冒险RAW 的三种典型间隔和停顿拍数推导数据冒险也叫数据相关分三种写后读RAW、读后写WAR、写后写WAW。在顺序发射的流水线里只有 RAW 是真问题因为 WAR 和 WAW 需要后面的指令先执行才能发生而顺序发射天然保证了这一点。WAR 和 WAW 要到乱序执行那一章才会跳出来捣乱。RAW 指的是后一条指令要读的寄存器正是前一条指令要写的。我们先把五级流水里结果的产生时刻标清楚LW r1, 0(r2) IF(1) ID(2) EX(3) MEM(4) WB(5)r1 的新值在 WB 阶段的开始才写进寄存器堆即第 5 拍。如果下一条指令紧跟其后ADD r3, r1, r4 IF(2) ID(3) EX(4) ...它在第 3 拍的 ID 阶段读 r1但 r1 要到第 5 拍才被写入读到的必然是旧值。这就是典型的数据冒险。根据结果能在哪一级被拿到停顿的拍数是可以精确推导的依赖情况结果产生阶段无前递需要停顿有前递需要停顿ALU 指令紧接 ALU 指令第 3 拍末EX 结束2 拍0 拍ALU 指令紧接 store存数据第 3 拍末2 拍0 拍WB→MEM 前递load 紧接使用其结果的指令第 4 拍末MEM 结束3 拍1 拍相隔两条及以上已写完0 拍0 拍为什么无前递时 ALU 紧接 ALU 要停 2 拍因为下一条指令的 ID 阶段必须等到前一条的 WB 阶段完成写入之后才能读到正确值。前一条在第 5 拍写所以下一条的 ID 必须落到第 5 拍而它原本应该在第 3 拍差了 2 拍就得插 2 个气泡。3.3 前递能救什么、救不了什么load-use 为什么必须硬停一拍前递Forwarding也叫旁路 Bypassing的核心思想很朴素既然结果算出来之后会躺在流水寄存器里那为什么要等到 WB 才用直接从流水寄存器抓过来送给需要的 ALU 输入就行了。实现上前递通路一般有两条到 EX 级的路径从 EX/MEM 流水寄存器来上一拍刚算出来的结果从 MEM/WB 流水寄存器来再上一拍的结果。判定逻辑大致是这样// EX 阶段操作数前递优先级EX/MEM更新 MEM/WB较旧 always (*) begin fwd_a 2b00; if (ex_mem_regwrite (ex_mem_rd ! 5d0) (ex_mem_rd id_ex_rs)) fwd_a 2b10; else if (mem_wb_regwrite (mem_wb_rd ! 5d0) (mem_wb_rd id_ex_rs)) fwd_a 2b01; end这里有三处非常容易错的地方我踩过不止一次优先级必须写对。两条通路同时命中同一个寄存器时说明有连续两条指令写了同一个寄存器此时必须用更新的那个值也就是 EX/MEM 那条。顺序写反了功能会时对时错而且只在特定依赖距离下出错极难调。必须排除零号寄存器。如果目标架构规定 r0 恒为 0那么目的寄存器是 r0意味着这条指令根本不写寄存器不能参与前递判定。少了这个判断add r0, ...这类被丢弃结果的指令会污染正常的前递路径。前递只能救 ALU 结果救不了 load 结果。这是最关键的认知。load 的数据要到 MEM 阶段结束才从存储器里出来而依赖它的指令在同一拍就要在 EX 阶段用这个数据。时间上根本来不及前递通路也无能为力必须硬停一拍。这个停一拍是 load-use 冒险的标志性代价。它的判定可以在 ID 阶段完成// load-use 冒险判定前一条是 load且其结果正是当前指令的源操作数 assign load_use id_ex_memread ((id_ex_rt if_id_rs) || (id_ex_rt if_id_rt));一旦判定成立就把 PC 和 IF/ID 寄存器冻住保持原值同时把 ID/EX 的控制信号全部清零插入一个气泡。下一条指令在 ID 阶段多待一拍等 load 的数据从存储器出来之后再靠 MEM→EX 的前递拿到正确值。提示load-use 停顿的拍数可以通过编译器优化减少。最常用的办法是指令调度把不相关指令插到 load 和使用者之间。如果中间能塞进一条独立的指令这次停顿就完全省掉了。这个思路在后面的循环展开里还会用到。3.4 控制冒险分支在哪一级判定决定你要冲刷几条指令控制冒险来自分支和跳转。流水线每拍都在顺序取指但分支一旦成立接下来取到的几条指令就是错的必须作废这个操作叫冲刷Flush也就是往流水线里灌气泡。冲刷的代价跟分支结果在哪一级确定直接相关。我把几种情况列出来分支判定阶段需冲刷的指令数说明ID 阶段提前比较目标地址也提前算1 条需要额外的比较器和加法器可能拉长 ID 级延迟EX 阶段2 条教科书五级流水最常见的位置MEM 阶段3 条早期简化实现代价大以 EX 阶段判定为例分支指令进到 EX 才知道跳不跳而这时 IF 和 ID 两级已经各取了一条指令进来了这两条都是错的必须一起清掉。硬件上的做法是// 分支在 EX 阶段判定成立冲刷 IF/ID 与 ID/EX 两级 if (branch_taken) begin if_id 32d0; if_id_valid 1b0; id_ex 64d0; id_ex_valid 1b0; end要注意 PC 也必须同时更新成跳转目标地址而且这个更新和冲刷是同一拍发生的。如果 PC 更新晚了一拍会多取一条错指令如果冲刷信号晚了一拍错指令会溜进 EX 级产生不可预料的副作用。3.5 预测与延宕把等待换成猜只靠冲刷控制冒险的代价太贵。业界的思路是与其等结果不如先猜猜对了零代价猜错了再冲刷。这就有了分支预测。静态预测最简单规则固定比如向后跳的分支预测成立向前跳的预测不成立。它的依据是循环循环体末尾的分支通常是向后跳的且绝大多数时候成立而错误处理这类分支通常向前跳且很少成立。这个规则听起来简陋但对循环密集的程序命中率能到七八成而且零硬件成本。动态预测用运行时的历史信息。最经典的是两位饱和计数器每个分支项用两位状态记录强烈成立/弱成立/弱不成立/强烈不成立只有连续两次猜错才翻转预测方向。相比一位计数器它多了容错能力——循环结束那一次预测错误不会立刻把状态翻过去避免下一次进入循环时开头两遍都猜错。再往上是相关预测很多分支的行为跟它前面的分支有关。比如if (a 0) ...; if (a 0) ...这种第一个分支成立则第二个也成立。用全局历史寄存器记录最近若干条分支的结果作为索引就构成了两级自适应预测器。更进一步还有把局部历史和全局历史结合起来的选择预测器。分支目标缓冲BTB解决的是另一个问题即使预测成立了跳转目标地址也要等到译码甚至执行才算得出来。BTB 用分支指令的地址作为索引缓存上次跳到了哪里在 IF 阶段就能拿到目标地址实现零延迟跳转。配上返回地址栈RAS专门处理函数返回这两样东西是现代前端取指的基础设施。还有个已经基本退出历史的方案值得一提分支延宕Delayed Branch。思路是把分支后面的一个指令槽定义为延宕槽无论分支跳不跳槽里的指令都会执行编译器负责往槽里填一条有用的指令。这样流水线完全不需要冲刷。它是 MIPS 的经典设计但问题是编译器要找到合适的指令填槽并不容易而且填进去的指令如果跟分支有依赖还得插 nop收益不稳定。现代处理器基本都转向硬件预测了。4. 突破教科书流水线动态调度、超标量与乱序教科书里的五级流水是理想化的模型真实处理器早就不是这样了。这一节讲清楚几件事顺序流水为什么会被一条长延迟指令卡死、记分牌和 Tomasulo 分别解决了什么矛盾、寄存器重命名和 ROB 是怎么在乱序的同时保住精确异常的。这部分理解透了你再回头看现代处理器的微架构图就不会发怵。4.1 顺序流水的连锁停顿一条 load 拖住整条队伍顺序流水线顺序发射、顺序执行有一个致命的性能问题一条指令卡住后面的全部卡住即使后面那条指令跟它毫无关系。举个典型的例子。假设某条指令要访问的内容不在 cache 里需要几十拍甚至上百拍才能从主存取回。在顺序流水里这条 load 之后的每一条指令哪怕只是两条互不相干的算术运算都得乖乖排在后面。这时候处理器里绝大部分功能单元都闲着纯属浪费。更隐蔽的问题是假依赖。看这段代码ADD r1, r2, r3 MUL r4, r5, r6 ADD r7, r1, r8三条指令里第 1 条和第 3 条有真依赖RAW必须按顺序。但第 2 条 MUL 跟谁都不相关如果 MUL 是个多周期单元要跑 5 拍顺序流水里第 3 条也只能等它做完才能进 EX。这就是典型的资源浪费——明明有可以并行的工作却因为顺序发射的限制做不了。4.2 记分牌与 Tomasulo从停顿等待到换人执行**记分牌Scoreboard**是第一个系统性解决这个问题的方案。它的核心思想是把发射和执行解耦指令按顺序发射保证不会因为乱序发射产生无法处理的情况但一旦进入执行单元就可以乱序完成。记分牌内部维护三张表指令状态表记录每条已发射指令处在哪个阶段。功能单元状态表记录每个执行单元忙不忙、在算什么、目的寄存器是哪个。寄存器结果状态表记录每个寄存器正在被哪条指令写用来判断当前指令能不能发射。记分牌允许乱序执行但由于它没做寄存器重命名WAR 和 WAW 就成了真障碍。它的处理办法是指令要写某个寄存器之前检查有没有更早发射的指令还没读这个寄存器WAR要写之前检查有没有更早的指令还没写它WAW。不满足条件就得等。所以记分牌的并行度提升是有限的。Tomasulo 算法是更进一步。它用保留站Reservation Station把操作数在哪这件事从寄存器堆里解耦出来又用公共数据总线CDB把结果广播给所有等待的单元。关键机制是寄存器重命名指令进入保留站时如果源操作数还没算出来就记下生产者的标签而不是寄存器编号等结果通过 CDB 广播出来用标签匹配的方式直接抓取。这样做的效果是彻底消除了 WAR 和 WAW 冒险因为物理上不同的生产者可以同时写同一个逻辑寄存器寄存器状态表会自动指向最新的那个标签。两个方案的对照维度记分牌Tomasulo发射顺序顺序顺序执行顺序乱序乱序WAR/WAW 处理靠等待限制并行度靠重命名直接消除结果传递写寄存器堆后由后续指令读取CDB 广播 保留站捕获硬件复杂度较低高保留站、CDB 仲裁循环展开需求编译器需要展开循环提高并行度支持寄存器重命名后硬件自动利用循环级并行注意Tomasulo 解决了并行度问题但带来一个新麻烦——指令乱序完成异常和分支预测失败时就无法恢复到精确状态。这正是后面要引入重排序缓冲ROB的原因。4.3 寄存器重命名与 ROB精确异常是怎么保住的精确异常的意思是当某条指令发生异常时处理器要保证它之前的指令全部完成、它之后的指令一条都没生效然后跳到异常处理程序。乱序执行天然破坏这个性质——后面一条指令可能已经算完并改了寄存器前面那条才刚要报异常。**重排序缓冲ROB**的解法是执行可以乱序但提交必须按序。指令按顺序发射时就分配一个 ROB 表项执行完成后把结果写进 ROB 而不是直接写寄存器堆。只有当一条指令成为 ROB 里最老的那条时才把它的结果真正提交到寄存器堆或者存储器并释放表项。异常发生时只要清掉 ROB 里这条指令以及之后的所有表项就能回到精确状态。寄存器重命名本身有两种实现一种是用 ROB 表项号直接当物理寄存器号另一种是单独的物理寄存器堆加映射表。前者的好处是重命名和 ROB 天然合二为一缺点是 ROB 太大时表项号位宽可观后者需要一个映射表在提交时更新硬件更复杂但更灵活。4.4 超标量、VLIW 与多发射的取舍前面讲的都是每拍发射一条指令。超标量要每拍发射多条通常 2 到 6 条。这带来一系列新问题取指单元每拍必须能取出多条指令并且正确处理跨 cache 行的边界译码单元要能并行译码多条发射逻辑要能做多路仲裁而且要保证多条指令之间没有依赖冲突——这是发射窗口存在的意义在窗口内的指令可以自由选择执行顺序窗口外的必须遵守顺序。超标量分两种静态调度的超标量由编译器在指令里标注可以并行发射的组合硬件按标注发射动态调度的超标量由硬件在运行时决定发射哪些指令。前者硬件简单但需要编译器配合指令格式受限后者硬件复杂但是主流。**VLIW超长指令字**走的是另一条路把并行性完全交给编译器硬件只负责按固定格式执行。一条超长指令里打包了多个操作槽位硬件不需要冒险检测、不需要乱序调度功耗和复杂度都低。代价是代码膨胀严重比如 4 个槽位但程序里只有 1 个操作剩下 3 个槽位全填 nop而且一旦硬件槽位数变化二进制就不兼容了。它在 DSP 和某些专用领域活得不错通用计算领域基本被超标量挤掉了。这里还得提一下取指带宽这个常被忽略的瓶颈多发射的前提是每拍能取到足够多的指令而分支的存在让取指目标不确定。所以超标量处理器的分支预测通常做得特别重前端取指队列也特别深。我见过一个很反直觉的现象某些程序在四发射的机器上性能反而不如双发射原因就是分支密集导致前端供给跟不上后端功能单元再宽也吃不满。5. 动手做一条 MIPS 五级流水实验里最容易翻车的几处理论看完真正的理解都发生在把波形调对的那一刻。这一节我按改动清单—关键陷阱—调试套路的顺序把做 MIPS 五级流水实验的实战经验过一遍。不管你是用 HDL 写还是用仿真软件搭思路是通用的。5.1 从单周期改成五级流水的最小改动清单很多课程的设计是从单周期版本改到五级流水这样过渡最自然。我的建议是不要一上来就加前递和预测先把没有冒险处理的裸流水跑通再逐个加机制。裸流水虽然性能差但它的正确性边界最清晰出了问题容易定位。从单周期到裸流水需要做的事情有这么几件插入四级流水寄存器IF/ID、ID/EX、EX/MEM、MEM/WB。每一级要传递的信号必须完整列出包括数据、控制信号、寄存器编号、PC 相关信息。给 PC 加写使能默认每拍自增停顿时要保持不变。把寄存器堆的读写分离到不同阶段ID 读WB 写。单周期版本里读写在同一拍完成改成流水后必须拆开。控制信号跟着指令走单周期里控制信号是当拍产生的流水里它必须在 ID 级产生然后随流水寄存器逐级传递到 EX、MEM、WB 使用。加冲刷信号分支成立时清掉 IF/ID 和 ID/EX 两级。注意 PC 也要同拍更新。我强烈建议在第一次跑通之前先编一份信号对照表把每个控制信号在 ID 级产生时的取值和它需要传到哪一级列出来。这张表看起来枯燥但它是后面 debug 的唯一依据。我吃过这个亏漏传了一个 memwrite 信号结果 store 指令在访存阶段什么都没写功能测试里因为没人读那块内存居然全通过了。5.2 load-use 停顿与 forward 通路的组合逻辑陷阱前递和停顿这两个机制放一起时最容易出的问题就是判定条件的边界。我用一个具体场景说明。假设序列是LW r1, 0(r2) ADD r3, r1, r4 ADD r5, r1, r6第一条是 load后两条都用 r1。正确的行为是ADD r3 需要停顿一拍之后它从 MEM/WB 前递拿到 r1 的值ADD r5 不再需要停顿因为它跟 load 之间隔了两条指令。问题出在停顿那一拍期间ID/EX 里的内容被清成气泡了。如果前递判定逻辑没有考虑气泡的情况它可能会拿气泡里的残留寄存器编号去匹配产生错误的前递。解决办法有两条路一是给流水寄存器加一个 valid 位前递判定必须检查 valid二是气泡时把相关字段清成 r0 或者一个不会命中的值。两条路都行但必须显式处理不能指望清成 0 就不会命中——如果 r0 是可写的这个假设就不成立。第二个陷阱是前递到 store 的数据通路。store 指令要写内存它的数据可能来自前一条 ALU 指令的 WB 阶段也可能来自更早。所以 store 的数据前递通路是 WB→MEM而不是 WB→EX。忘了这条通路add r1, r2, r3紧跟sw r1, 0(r4)就会往内存写旧值。第三个陷阱是跳转目标地址的计算时机。如果分支在 EX 判定那么目标地址也在 EX 算出此时 PC 已经取了两条错指令。但如果是 jal 这类需要写返回地址的指令返回地址的计算要在 IF 阶段就完成PC4不能等到 EX。这两个计算路径必须在设计时分开处理混在一起就会出现返回地址错位。5.3 分支冲刷与时序flush 信号到底该覆盖哪些寄存器冲刷的常见错误是覆盖范围不全或者覆盖范围过大。覆盖不全的典型症状是大部分分支测试能过但某些分支密集的用例会算错。原因通常是忘了清某个流水寄存器或者忘了清 valid 位。我建议的做法是把所有需要在冲刷时清零的寄存器列一个清单一一对照。在 EX 阶段判定分支的情况下清单通常是IF/ID 全部字段、IF/ID 的 valid、ID/EX 全部字段、ID/EX 的 valid。PC 要更新成目标地址而不是清零。覆盖过大的典型症状是程序功能完全错乱连最简单的顺序执行都不对。原因通常是冲刷信号跟停顿信号、或者跟正常流水推进逻辑产生了冲突。比如同一拍既要求 PC 自增又要求 PC 跳到目标地址两个赋值写在一起综合出来的结果是随机的。解决办法是把所有对 PC 的赋值写在一个 always 块里用优先级从高到低排列分支冲刷优先于停顿停顿优先于正常推进。这样时序上就不会有歧义。还有一个容易忽略的点冲刷后的第一条指令其地址必须来自正确的来源。分支成立时是分支目标跳转指令成立时是跳转目标如果这两条路径共用一个多路选择器而没有正确的选择信号就会出现分支跳对了但跳转跳错了这种诡异现象。5.4 波形调试与自测用例的设计套路调试流水线有个基本原则先用最简用例定位阶段再用复杂用例验证组合。我的套路是这样第一步用几条互不相关的算术指令跑一遍只看波形里指令是不是整齐地错开一拍推进。这一步只验证流水寄存器和时钟逻辑不看功能。第二步加一组有 RAW 依赖的算术指令比如add r1,r2,r3紧跟add r4,r1,r5看前递通路是不是生效EX 阶段的 ALU 输入是不是拿到了最新值。这一步是前递逻辑的专项验证。第三步加 load-use 序列看停顿是不是准确插入了一拍以及停顿之后的前递是不是拿到了 load 出来的数据。这一步是停顿逻辑的专项验证。第四步加分支序列看冲刷的指令数对不对跳转目标是不是正确。第五步跑一个循环比如求数组和把所有机制综合起来。这一步最容易暴露单机制正确但组合起来错的问题。提示循环用例一定要检查最后一次迭代的行为。很多设计在循环正常进行时都对只在退出循环的那一次分支预测或冲刷上出错。这是经典陷阱专门设计一个循环次数为 1、2、3 的用例能立刻把它逼出来。另外波形里要盯的信号建议固定几个PC、IF/ID 里的指令、ID/EX 的 valid、EX 阶段的两个 ALU 输入、分支成立信号、冲刷信号。把这几个信号作为一组模板保存下来每次调试直接调用比每次重新挑信号高效得多。6. 把知识变成分数习题里高频出现的四类计算题最后这一节聊应试层面的东西。体系结构这门课的考试计算题的题型其实非常集中把这几类练熟基本不会失分。我不按知识模块分按题目长什么样来分这样你复习时可以直接对照。6.1 流水线时空图画对一次胜过背十遍时空图是这类题的基础工具横轴是时钟周期纵轴是指令每条指令画一段标出它在每一拍处于哪一级。画图有三个必须遵守的规矩第一指令的起始周期要错开。第一条从第 1 拍开始第二条从第 2 拍开始以此类推除非中间有停顿。第二前递不会改变指令的顺序只改变数据来源。很多同学一看到有前递就把指令的时间条往前挪这是错的。前递只影响操作数的取值路径指令本身还是按原来的节拍推进。第三结构性停顿和 load-use 停顿要在图上明确标出气泡。气泡的表示方法通常是空一个格子或者画一个斜线格。题目问总周期数时气泡必须算进去。画完之后题目通常会问三件事总执行周期数、实际加速比、实际 CPI。总周期数直接从图上数实际加速比 不流水的总周期数 ÷ 流水总周期数实际 CPI 总周期数 ÷ 指令数。这三个量的计算没有任何技巧唯一的要求是图画对。6.2 CPI 与加速比题型分清理想和实际这类题的坑全在理想和实际的混淆上。我把判断标准写清楚题目说理想流水线无冒险不考虑停顿那么 CPI 1加速比 kk 为级数。题目给出了冒险发生的频率和每次的停顿拍数那么实际 CPI 1 平均停顿拍数。平均停顿拍数的计算是这类题的核心公式是各类冒险的发生率 × 每次的停顿拍数求和。我举个完整的例子假设某五级流水程序load 指令占 20%其中一半紧跟使用其结果的指令每次停 1 拍分支指令占 15%在 EX 阶段判定每次冲刷 2 拍其余指令无冒险。那么平均停顿 0.20 × 0.5 × 1 0.15 × 2 0.1 0.3 0.4 实际 CPI 1 0.4 1.4如果题目还给了时钟频率和指令总数就能算出实际执行时间执行时间 指令数 × CPI ÷ 主频这类题唯一容易错的地方是把百分比算错基数。比如load 占 20%其中一半有依赖这里的一半是相对 load 的不是相对全部指令的所以最终系数是 0.2×0.5不是 0.5。读题时把每一层百分比的对象标清楚这个坑就避开了。6.3 循环展开与指令调度的手算流程这类题给一段循环代码要求你做循环展开、重排指令、消除停顿然后算展开前后的周期数差。步骤是固定的第一步列出原始循环体的指令标出每条之间的依赖关系。特别注意 load 和使用者之间、以及跨迭代的依赖。第二步确定展开次数。通常题目会指定或者你可以按展开后刚好能填满一个整数倍的调度模式来选。展开 4 次是常见值因为它能配合流水线深度做完整调度。第三步做寄存器重命名。展开后不同迭代用到的临时寄存器不能冲突否则会引入假依赖WAW/WAR。这一步是必须的因为原始循环里同一个寄存器在每次迭代都被复用。第四步重排指令把不相关的指令插到 load 和使用者之间。这一步是核心目标是让每个 load 之后都有足够的独立指令填充延迟。第五步算总周期数并对比。注意展开会引入额外的开销指令循环控制、寄存器复制以及可能需要处理余数迭代次数不是展开次数整数倍时。我举个最小例子说明这个流程的效果。假设延迟是load 结果需要 2 拍才能用原始代码Loop: LW r1, 0(r2) ADD r3, r1, r4 SW r3, 0(r2) ADDI r2, r2, 4 BNE r2, r5, LoopLW 和 ADD 之间有 load-use 停顿 1 拍ADD 和 SW 之间有 1 拍前递停顿假设 store 数据前递需要那么每条迭代至少要停 2 拍。展开两次之后可以把第二次迭代的 LW 插到第一次迭代的停顿位置很多停顿就被填掉了。具体能省多少取决于延迟拍数和展开次数做题时按流水线时空图老老实实数比套公式靠谱。6.4 一份自测清单什么样算真的学懂了复习到最后判断自己是不是真懂了可以用下面这份清单来自查。每一条都是能自己推导出来才算过检查项合格标准流水线加速比公式能不看资料推导出 nk/(kn-1)并说清 n 趋于无穷时的含义停顿拍数推导能画出时空图从结果产生阶段反推出需要停几拍前递优先级能说清为什么 EX/MEM 优先于 MEM/WB以及为什么要排除 r0load-use 判定能用一条组合逻辑表达式写出判定条件冲刷范围能列出分支在 EX 判定时需要清零的所有流水寄存器CPI 计算能根据冒险频率算出实际 CPI并说清每个百分比的基数记分牌与 Tomasulo能说清两者在 WAR/WAW 处理上的根本差异ROB 的作用能解释为什么乱序执行需要按序提交我自己的经验是这份清单里最容易以为自己懂了的是第三条和第五条。前递优先级很多人能背出来但问如果两条通路同时命中会怎样就答不上来冲刷范围大家记得要清 IF/ID但常常忘了 valid 位和 ID/EX。这两处恰恰是实验中出错最多的地方。另外补一句关于学习路径的体会。这门课里性能公式和流水线时空图这两块看书的时候会觉得很简单但真正动笔算才会发现细节很多。我建议的做法是每看完一章找三道计算题手写一遍完整过程包括时空图和中间推导不要只在脑子里过。手写能暴露的问题脑子里过是暴露不出来的。还有一个小技巧把课本上每个机制都问一句它解决的是哪一类冒险。前递解决 RAW重命名解决 WAR/WAW预测和冲刷解决控制冒险停顿是最后的兜底手段。把这四条对应关系刻在脑子里遇到任何新机制都能快速定位它在整个体系里的位置不用死记硬背。