# C.4 流水线为何难以实现？
<!-- source: p0832 -->

了解如何检测与解决冒险后，可以处理前面暂时避开的一些复杂问题。本节前半讨论异常情况：指令执行顺序以非预期方式改变；后半讨论不同指令集带来的挑战。

## 处理异常

流水线处理器更难处理异常，因为指令重叠执行，使得判断一条指令能否安全地修改处理器状态变得更困难。指令分成若干部分执行，经过几个周期才完成；与此同时，流水线中的其他指令可能触发异常，迫使处理器在某些指令完成前中止它们。在深入讨论问题与解法之前，需要了解可能发生的情况，以及体系结构对异常支持的要求。

### 异常类型与要求

不同处理器对正常指令顺序被改变的情况采用不同术语，如中断（interrupt）、故障（fault）、异常（exception），但用法不统一。本书用“异常”统称这些机制，包括：

- I/O 设备请求。
- 用户程序调用操作系统服务。
- 跟踪指令执行。
- 断点，即程序员请求的中断。
- 整数运算溢出。
- 浮点运算异常。
- 缺页，即页面不在主存。
- 未对齐的访存，如果体系结构要求对齐。
- 违反存储器保护。
- 使用未定义或尚未实现的指令。
- 硬件故障。
- 电源故障。

特指某一类时，使用更完整的名称，如 I/O 中断、浮点异常或缺页。

<!-- source: p0833 -->

虽然统称异常，各类事件的重要特征决定了硬件应采取什么措施。异常要求可沿五个部分独立的维度分类：

1. **同步或异步：** 若每次使用相同数据和内存分配运行程序，事件总在相同位置发生，就是同步事件。除硬件故障外，异步事件来自处理器与存储器之外的设备。异步事件通常可等当前指令完成再处理，因此较容易处理。
2. **用户请求或强制发生：** 用户任务直接要求发生的，是用户请求事件。从某种意义上说，它们可预测，算不上真正的“意外”；之所以作为异常处理，是因为仍使用同一套保存和恢复状态的机制。触发此类事件的指令，其唯一功能就是产生异常，所以总能在该指令完成后处理。强制发生的异常由用户程序无法控制的硬件事件引起，因不可预测而较难实现。
3. **用户可屏蔽或不可屏蔽：** 用户任务能屏蔽或禁用的事件，称为用户可屏蔽。屏蔽位仅控制硬件是否响应异常。
4. **指令内部或指令之间：** 事件是在执行中途发生、阻止指令完成——无论这段执行多短——还是在指令之间被识别？指令内部异常通常是同步的，因为由指令触发。它们需要停止并重新启动指令，比指令间异常更难实现。指令内部的异步异常来自灾难性情况，如硬件故障，总会使程序终止。
5. **恢复或终止：** 若中断后程序总是停止，则为终止事件；若继续执行，则为恢复事件。终止异常较容易实现，因为处理器不必在处理后重新启动同一个程序。

图 C.26 按这五个维度对前面的例子分类。最难实现的，是发生在指令内部、又必须恢复执行的中断。处理它时，必须调用另一个程序，保存正在执行的程序状态、纠正异常原因、恢复状态，然后<!-- source: p0834 -->重试触发异常的指令。这一过程实际上必须对原程序不可见。若流水线允许处理器处理异常、保存状态并重新启动，同时不影响程序执行，就称该流水线或处理器**可重新启动**（restartable）。早期超级计算机与微处理器常缺少这一属性，但如今几乎所有处理器至少对整数流水线都提供它，因为实现虚拟内存需要这一能力（见第 2 章）。

| 异常类型 | 同步／异步 | 用户请求／强制发生 | 用户是否可屏蔽 | 指令内部／之间 | 恢复／终止 |
| --- | --- | --- | --- | --- | --- |
| I/O 设备请求 | 异步 | 强制发生 | 不可屏蔽 | 之间 | 恢复 |
| 调用操作系统 | 同步 | 用户请求 | 不可屏蔽 | 之间 | 恢复 |
| 跟踪指令执行 | 同步 | 用户请求 | 可屏蔽 | 之间 | 恢复 |
| 断点 | 同步 | 用户请求 | 可屏蔽 | 之间 | 恢复 |
| 整数运算溢出 | 同步 | 强制发生 | 可屏蔽 | 内部 | 恢复 |
| 浮点运算上溢或下溢 | 同步 | 强制发生 | 可屏蔽 | 内部 | 恢复 |
| 缺页 | 同步 | 强制发生 | 不可屏蔽 | 内部 | 恢复 |
| 未对齐访存 | 同步 | 强制发生 | 可屏蔽 | 内部 | 恢复 |
| 违反存储器保护 | 同步 | 强制发生 | 不可屏蔽 | 内部 | 恢复 |
| 使用未定义指令 | 同步 | 强制发生 | 不可屏蔽 | 内部 | 终止 |
| 硬件故障 | 异步 | 强制发生 | 不可屏蔽 | 内部 | 终止 |
| 电源故障 | 异步 | 强制发生 | 不可屏蔽 | 内部 | 终止 |
{#fig-C.26 .wide .expand num="C.26" caption="五个分类维度决定不同异常需要采取的动作。必须允许恢复的异常标为“恢复”，但软件仍可能选择终止程序。发生在指令内部、同步、强制且可恢复的异常最难实现。存储器保护违规似乎应总是导致终止，但现代操作系统也利用保护机制检测首次访问或首次写入页面等事件，所以处理器应能在这些异常后恢复。"}

### 停止并重新开始执行

与非流水线实现一样，最难的异常具有两个特征：发生在指令内部，即对应 EX 或 MEM 的执行中途；并且必须允许重新启动。例如，这条 RISC-V 流水线中，取数据引起的虚拟内存缺页要到 MEM 才会发生。<!-- source: p0835 -->发现缺页时，已有几条其他指令正在执行。缺页必须可重启，并需要操作系统等另一执行实体介入。因此，要安全地停止流水线、保存状态，以便在正确状态下重启指令。

通常通过保存重启位置的 PC 实现重启。若重启指令不是分支，就正常取顺序后继并执行；若是分支，则重新判断条件，再从目标或顺序后继取指。异常发生后，流水线控制可按以下步骤安全保存状态：

1. 在下一个 IF 强行注入一条陷阱指令。
2. 在陷阱真正发生前，禁止故障指令及其后的所有指令写入状态。做法是从异常指令开始，将它及后继指令的流水线锁存器控制置零，但不影响先于它的指令。这样，不能在异常处理前完成的指令就不会改变状态。
3. 操作系统异常处理程序得到控制后，立即保存故障指令的 PC，供以后返回使用。

异常处理结束后，用专门指令重新加载 PC 并启动指令流；RISC-V 使用异常返回指令。若流水线可以停止在这样一个边界：故障指令之前的指令全部完成，之后的指令都能从头执行，就称它提供**精确异常**。

理想情况下，故障指令尚未改变状态；某些异常要正确处理，也确实要求这一点。但对浮点异常等其他异常，某些处理器会在异常得到处理前写回故障指令的结果。此时，即使目的寄存器与某个源寄存器相同，硬件也必须能找回原源操作数。浮点操作可能执行许多周期，其他指令很可能已经改写这些源操作数；下一节将看到，浮点操作常乱序完成。为解决此问题，许多高性能处理器引入两种模式：精确异常模式，以及不保证精确异常的快速／性能模式。精确模式允许的浮点指令重叠较少，因此更慢。

许多系统要求精确异常；另一些系统则因为它能简化操作系统接口而认为它很有价值。最低限度，任何带请求分页或 IEEE 算术陷阱处理程序的处理器，<!-- source: p0836 -->都必须用硬件或适当软件支持提供精确异常。整数流水线较容易实现精确异常，虚拟内存需求也强烈推动了对访存精确异常的支持。实际中，这些原因使设计者与体系结构设计者总为整数流水线提供精确异常。本节介绍 RISC-V 整数流水线的实现，浮点的更复杂问题留到 C.5 节。

### RISC-V 中的异常

图 C.27 列出各流水级可能产生的异常。由于同时执行多条指令，同一周期可能发生多个异常。例如：

| 指令／周期 | 1 | 2 | 3 | 4 | 5 | 6 |
| --- | --- | --- | --- | --- | --- | --- |
| `ld` | IF | ID | EX | MEM | WB | |
| `add` | | IF | ID | EX | MEM | WB |

当 `ld` 位于 MEM、`add` 位于 EX 时，可能同时发生数据缺页和算术异常。可以先只处理数据缺页，再重启执行。只要软件正确，第一次异常不会再发生，第二次会重新出现，并可单独处理。[^arithmetic-example]

| 流水级 | 可能发生的异常 |
| --- | --- |
| IF | 取指缺页；未对齐访存；违反存储器保护 |
| ID | 未定义或非法操作码 |
| EX | 算术异常 |
| MEM | 取数据缺页；未对齐访存；违反存储器保护 |
| WB | 无 |
{#fig-C.27 .wide .expand num="C.27" caption="RISC-V 教学流水线中可能发生的异常。八种情况中，有六种来自指令或数据存储器访问。原页在本表上方再次印出前面的 ld/add 时序。"}

[^arithmetic-example]: **译注：** 本节沿用可产生整数算术异常的教学流水线例子。不能据此认为标准 RISC-V 的普通 `add` 会因整数溢出自动陷阱。

<!-- source: p0837 -->

实际情况没有这个例子这么简单。异常可能乱序发生：后一条指令的异常，可能早于前一条指令的异常出现。仍考虑 `ld` 后跟 `add`：`ld` 在 MEM 发现数据缺页，`add` 在 IF 就可能发现取指缺页。虽然由后面的指令引起，取指缺页却会先出现。

既然要实现精确异常，就必须先处理 `ld` 的异常。将 `ld` 所在位置的指令称为 \(i\)，将 `add` 所在位置称为 \(i+1\)。不能按异常实际出现的时间立即处理，否则就违反非流水线的顺序。硬件应把每条指令引起的所有异常，记录到与它关联的**异常状态向量**中，并随指令沿流水线传递。一旦设置异常标志，就关闭所有可能写入数据的控制信号，包括写寄存器和写内存。存储指令可在 MEM 产生异常，因此硬件必须能在它发生异常时阻止写入完成。

当指令进入 WB，或即将离开 MEM 时，检查异常状态向量。若有异常，就按非流水线处理器中应发生的时间顺序处理：先处理最早指令的异常，通常也先处理该指令最早流水级的异常。这样，\(i\) 的全部异常都会先于 \(i+1\) 被处理。先前各级为 \(i\) 所做的某些操作可能无效，但由于寄存器与内存写入已禁用，状态不会改变。C.5 节将看到，为浮点操作维持这种精确模型难得多。

下一小节讨论拥有更强大、执行时间更长指令的处理器，其流水线异常处理会遇到什么问题。

## 指令集带来的复杂性

RISC-V 指令没有多于一个结果，我们的整数流水线也只在执行结束时写入该结果。当指令保证能够完成时，称其已**提交**（committed）。在这里，全部指令在 MEM 结束、WB 开始时提交，此前不更新状态，所以精确异常较容易实现。

有些处理器的指令在自身及前驱尚未保证完成前，就在执行中途改变状态。例如，原文指出 IA-32 的自增寻址方式会在执行中途更新寄存器。<!-- source: p0838 -->如果此时因异常中止，处理器状态已经改变。虽然知道哪条指令引起异常，若无额外硬件支持，指令只完成一半，异常仍是不精确的，之后很难重启指令流。也可以把状态更新推迟到提交，但这可能困难或昂贵，因为指令自身可能依赖更新后的状态；例如，一条 VAX 指令对同一寄存器多次自增。

因此，具有这类指令的处理器，多数能够撤销指令提交前的一切状态变化。异常发生时，利用这种能力把状态恢复到被中断指令开始之前。下一节将看到，更强的 RISC-V 浮点流水线也会引入类似问题；C.7 节的技术还会显著增加异常处理复杂性。

另一类相关问题，是指令执行过程中更新内存，例如 Intel 或 IBM 360 的字符串复制（见附录 K）。为了能够中断并重启，这些指令规定使用通用寄存器作为工作寄存器。于是，部分完成的指令状态始终保存在寄存器中：异常时保存，处理后恢复，指令就能继续。

某些零散的体系结构状态还可能制造额外冒险，或需要额外的保存／恢复硬件，条件码就是典型例子。许多处理器会在执行指令时隐式设置条件码。这样做有好处：条件判断与实际分支可以分离。但要在设置条件码和分支之间调度指令、填补流水线延迟，就变得困难，因为大多数指令都会设置条件码，不能放在这段间隔中。

此外，采用条件码的处理器必须确定分支条件何时最终有效，即找出分支之前最后一次设置条件码的时刻。大多数隐式设置条件码的处理器，会推迟分支条件判断，直到全部前驱都有机会写条件码。显式设置条件码的体系结构允许调度条件测试与分支之间的间隔，但控制仍须跟踪最后一条设置条件码的指令，以确定分支条件何时已知。实际上，必须把条件码当作一个操作数，检测它与分支之间的 RAW 冒险，正如 RISC-V 对寄存器所做的那样。

另一个棘手领域是多周期操作。设想要把如下 x86 指令序列流水线化：

<!-- source: p0839 -->

~~~asm
mov BX, AX        ; 寄存器之间传送
add 42(BX+SI),BX   ; 将内存内容与寄存器相加，写回同一内存位置
sub BX,AX         ; 寄存器相减
rep movsb         ; 传送字符串，长度由 CX 给出
~~~

这些指令本身都不特别长，x86 最长可达 15 字节，但执行周期数差异极大：从一周期到数百周期。数据存储器访问次数也从零到可能数百次不等。数据冒险非常复杂，既发生在指令之间，也发生在指令内部；`movsb` 的源、目的区域完全可能重叠。

让所有指令执行相同周期数的简单方案不可接受，因为会引入大量冒险与旁路条件，并形成极长的流水线。直接在 x86 指令层面流水线化很难，但人们找到了类似 VAX 所用的巧妙解法：对**微指令**执行进行流水线处理。微指令是用来组成更复杂指令的一系列简单指令。由于它们很简单，看起来很像 RISC-V，流水线控制容易得多。自 1995 年以来，所有 Intel IA-32 微处理器都采用把 IA-32 指令转换成微操作，再对微操作流水线化的策略。ARM 中某些较复杂指令也采用这种方法。

相比之下，加载—存储处理器的操作简单，工作量相近，更容易流水线化。认识到指令集设计与流水线之间的关系，就能设计出更适合高效流水线的体系结构。下一节将介绍 RISC-V 流水线如何处理执行时间长的浮点指令。

许多年来，人们认为指令集与实现之间关系不大，设计指令集时没有把实现问题作为重点。20 世纪 80 年代，人们逐渐认识到，指令集的复杂性会同时增加流水线实现难度并降低效率。90 年代，各家公司都转向更简单的指令集，以降低激进实现的复杂度。
