Reverse engineering the 386 processor's prefetch queue circuitry
386处理器预取队列电路逆向工程分析
Ken Shirriff's blog
计算机历史,复原老式计算机,IC 逆向工程,以及其他内容
386处理器预取队列电路的逆向工程
1985 年,Intel 推出了具有开创性的 386 处理器,这是 x86 架构中的第一个 32 位处理器。为了提高性能,386 具有 16 字节的指令预取队列。预取队列的目的是在需要指令之前从内存中获取指令,因此处理器通常在执行指令时不需要等待内存。指令预取利用了处理器“思考”的时间,否则内存总线将处于未使用状态。
在本文中,我将详细研究 386 的预取队列电路。一个有趣的电路是增量器,它将指针加 1 以步进遍历内存。这听起来很简单,但增量器使用复杂的电路来实现高性能。预取队列使用大型网络来移动字节,以便它们正确对齐。它还具有一个紧凑的电路,用于将有符号的 8 位和 16 位数字扩展为 32 位。这篇文章中没有任何重大发现,但如果您对底层电路和动态逻辑感兴趣,请继续阅读。
下图显示了在显微镜下观察到的 386 闪亮、指甲大小的硅芯片。虽然它看起来像是对一个以奇怪方式分区的城市进行的航空拍摄,但芯片照片揭示了芯片的功能块。左上角的预取单元是相关的块。在本文中,我将讨论预取队列电路(红色突出显示),跳过右侧的预取控制电路。预取单元从总线接口单元(右上角)接收数据,该单元与内存通信。指令解码单元从预取单元逐字节接收预取的指令,并解码操作码以供执行。
此 386 芯片的照片显示了寄存器的位置。点击此图像(或任何其他图像)以获得更大的版本。
芯片的左侧四分之一由电路条组成,这些电路条看起来比芯片的其余部分更有序。这种网格状外观的出现是因为每个功能块(在很大程度上)是通过重复相同的电路 32 次(每个位一次)并排构建的。垂直数据线上下延伸,以 32 位为一组,连接功能块。为了使这项工作能够进行,每个电路必须适合芯片上相同的宽度;这种布局约束迫使电路设计人员开发一种能够有效利用该宽度而不超过允许宽度的电路。预取队列的电路使用相同的方法:每个电路宽 66 µm1 并重复 32 次。可以看出,将预取电路装入这个固定宽度需要一些布局技巧。
预取器做什么
预取单元的目的是通过在需要指令之前从内存中读取指令来提高性能,这样处理器就不需要等待从内存中获取指令。预取利用了内存总线空闲的时间,从而最大限度地减少了与其他正在读取或写入数据的指令的冲突。在 386 中,预取的指令存储在 16 字节的队列中,该队列由四个 32 位块组成。2
下图放大了预取器并显示了其主要组件。您可以看到(在大多数情况下)如何将相同的电路重复 32 次,形成垂直条带。顶部是来自总线接口单元的 32 条总线线路。这些线路通过总线接口单元提供数据路径和外部存储器之间的连接。当右侧的 32 条水平线路分支并形成 32 条垂直线路(每位一条)时,这些线路形成三角形图案。接下来是提取指针和限制寄存器,以及用于检查提取指针是否已达到限制的电路。请注意,增量器和限制检查电路的两个低位(在右侧)缺失。在增量器的底部,您可以看到一些位位置缺少其他电路中的电路块,从而打破了重复块的模式。16 字节的预取队列位于增量器的下方。虽然这个存储器是预取器的核心,但它的电路占用的面积相对较小。
预取器的底部移动数据以根据需要进行对齐。32 位值可以跨越预取缓冲区的两个 32 位行进行拆分。为了处理这个问题,预取器包括一个数据移动网络来移动和对齐其数据。这个网络占用了很多空间,但这里没有有源电路:只有一个水平和垂直线的网格。
最后,符号扩展电路根据需要将有符号的 8 位或 16 位值转换为有符号的 16 位或 32 位值。您可以看到符号扩展电路非常不规则,尤其是在中间。锁存器存储预取队列的输出,以供数据路径的其余部分使用。
限制检查
如果您编写过 x86 程序,您可能了解处理器的指令指针(EIP),它保存着要执行的下一条指令的地址。当程序执行时,指令指针从一条指令移动到另一条指令。然而,事实证明,指令指针实际上并不存在!相反,386 有一个“预取指令推进指针”,它保存着要提取到预取队列中的下一条指令的地址。但有时处理器需要知道指令指针值,例如,在调用子例程时确定返回地址,或者计算相对跳转的目标地址。那么会发生什么呢?处理器从预取队列电路获取预取指令推进指针地址,并减去预取队列的当前长度。结果是要执行的下一条指令的地址,即所需的指令指针值。
预取指令推进指针(要预取的下一条指令的地址)存储在预取队列电路顶部的寄存器中。当预取指令时,预取电路会递增该指针。(由于指令一次提取 32 位,因此该指针以 4 为步长递增,并且底部两位始终为 0。)
但是,是什么阻止预取器预取太远并超出有效内存范围?众所周知,x86 架构使用分段来定义有效的内存区域。段具有起始地址和结束地址(称为基址和限制),并且通过阻止对段外部的访问来保护内存。386 有六个活动段;相关的一个是代码段,其中包含程序指令。因此,代码段的限制地址控制着预取器何时必须停止预取。3 当提取指针达到代码段的限制时,预取队列包含一个电路来停止预取。在本节中,我将描述该电路。
比较两个值可能看起来很简单,但 386 使用了一些技巧来使这项工作更快。基本思想是使用 30 个 XOR 门来比较两个寄存器的位。(为什么是 30 位而不是 32 位?由于一次提取 32 位,因此地址的低位为 00,可以忽略不计。)如果两个寄存器匹配,则所有 XOR 值都将为 0,但如果它们不匹配,则 XOR 值将为 1。从概念上讲,将 XOR 连接到 32 输入 OR 门将产生所需的结果:如果所有位都匹配,则为 0,如果存在不匹配,则为 1。不幸的是,出于电气原因,使用标准 CMOS 逻辑构建 32 输入 OR 门是不切实际的,并且不方便地太大而无法装入电路。相反,386 使用动态逻辑来实现分布式的 NOR 门,该门在预取器的每一列中都有一个晶体管。
下面的示意图显示了相等比较的一位的实现。其机制是,如果两个寄存器不同,则右侧的晶体管会打开,从而将等式总线拉低。此电路重复 30 次,比较所有位:如果存在任何不匹配,则等式总线将被拉低,但如果所有位都匹配,则总线将保持高电平。左侧的三个门实现 XNOR;此电路可能看起来过于复杂,但它是实现 XNOR 的标准方法。右侧的 NOR 门会阻止比较,除非在时钟阶段 2 期间。(这一点的重要性将在下面解释。)
如果任何位不匹配,则等式总线会水平穿过预取器,被拉低。但是什么将总线拉高?那是下面动态电路的工作。与常规静态门不同,动态逻辑由处理器的时钟信号控制,并依赖于电路中的电容来保存数据。386 由两相时钟信号控制。4 在第一个时钟阶段,下面的预充电晶体管打开,将等式总线拉高。在第二个时钟阶段,上面的 XOR 电路被启用,如果两个寄存器不匹配,则将等式总线拉低。同时,CMOS 开关在时钟阶段 2 中打开,将等式总线的值传递给锁存器。“保持器”电路保持等式总线保持高电平,除非它被显式拉低,以避免等式总线上的电压缓慢耗散的风险。保持器使用弱晶体管来保持总线在不活动时保持高电平。但是,如果总线被拉低,则保持器晶体管将被压倒并关闭。
这种动态逻辑降低了功耗和电路尺寸。由于总线在相反的时钟阶段被充电和放电,因此您可以避免晶体管中出现稳定电流。(相反,像 8086 这样的 NMOS 处理器可能会在总线上使用上拉电阻。当总线被拉低时,您最终会使电流流过上拉和下拉晶体管。这将增加功耗,使芯片运行更热,并限制您的时钟速度。)
增量器
在每次预取之后,必须递增预取指令推进指针,以保存要预取的下一条指令的地址。递增此指针是增量器的工作。(因为每次提取 32 位,所以指针每次递增 4。但是在芯片照片中,您可以看到增量器和限制检查电路中有一个缺口,其中省略了底部两位的电路。因此,增量器的电路将其值递增 1,因此指针(附加了两个零位)以 4 为步长增加。)
构建增量器电路非常简单,例如,您可以使用 30 个半加器链。问题在于高速递增 30 位值很困难,因为从一个位置到下一个位置都有进位。这类似于以十进制计算 99999999 + 1;您需要单调乏味地进 1,进 1,进 1,依此类推,通过所有数字,从而导致缓慢的顺序过程。
增量器使用更快的方法。首先,它高速地几乎并行地计算所有进位。然后,它从进位并行地计算每个输出位 - 如果有一个进入某个位置的进位,它会切换该位。
从概念上讲,计算进位很简单:如果该值末尾有一个 1 位块,则所有这些位将产生进位,但进位被最右边的 0 位停止。例如,递增二进制 11011 会导致 11100;最后两位有进位,但零会阻止进位。早在 1959 年,英国曼彻斯特大学就开发了一种电路来实现这一点,称为曼彻斯特进位链。
在曼彻斯特进位链中,您构建一个开关链,每个数据位一个开关,如下所示。对于 1 位,您关闭开关,但对于 0 位,您打开开关。(开关由晶体管实现。)为了计算进位,您首先在右侧输入一个进位信号。该信号将通过闭合的开关,直到遇到一个断开的开关,然后它将被阻止。5 沿链的输出为我们提供了每个位置所需的进位值。
由于曼彻斯特进位链中的开关都可以并行设置,并且进位信号高速通过开关,因此该电路会快速计算我们需要的进位。然后,进位会翻转关联的位(并行),从而使我们比直接加法器更快地获得结果。
当然,在实际实现中存在一些复杂性。进位链中的进位信号被反转,因此低信号通过进位链传播以指示进位。(将信号拉低比拉高更快。)但是_某些东西_需要在必要时使线路升高。与等式电路一样,解决方案是动态逻辑。也就是说,进位线在一个时钟阶段被预充电为高电平,然后在第二个时钟阶段发生处理,可能会将线路拉低。
下一个问题是,进位信号在通过多个晶体管和长导线时会减弱。解决方案是每个段都有一个电路来放大信号,使用一个时钟控制反相器和一个非对称反相器。重要的是,此放大器不在进位链路径中,因此它不会降低信号通过链的速度。
上面的示意图显示了典型位的曼彻斯特进位链的实现。链本身位于底部,具有与以前相同的晶体管开关。在时钟阶段 1 期间,预充电晶体管将进位链的该段拉高。在时钟阶段 2 期间,链上的信号通过右侧的“时钟控制反相器”以产生局部进位信号。如果存在进位,则 XOR 门会翻转下一位,从而产生递增的输出。6 “保持器/放大器”是一个非对称反相器,可产生强大的低输出,但弱的高输出。当没有进位时,其弱输出会保持进位链拉高。但是,一旦检测到进位,它就会强烈地将进位链拉低以增强进位信号。
但是,此电路仍然不足以实现所需的性能。增量器并行使用第二种进位技术:进位跳跃。这个概念是查看位块并允许进位跳过整个块。下图显示了进位跳跃电路的简化实现。每个块由 3 到 6 位组成。如果块中的所有位都是 1,则 AND 门会打开进位跳跃线中的关联晶体管。这允许进位跳跃信号(从左到右)一次传播一个块。当它到达带有 0 位的块时,相应的晶体管将关闭,从而像在曼彻斯特进位链中一样停止进位。AND 门全部并行运行,因此晶体管会并行快速打开或关闭。然后,进位跳跃信号会通过少量晶体管,而无需通过任何逻辑。(进位跳跃信号就像一列特快列车,跳过大多数车站,而曼彻斯特进位链是通往所有车站的本地列车。)与曼彻斯特进位链一样,进位跳跃的实现需要在线路上的预充电电路、保持器/放大器和时钟控制逻辑,但我将跳过细节。
一个抽象和简化的进位跳跃电路。块大小与 386 的电路不匹配。
一个有趣的特征是大型 AND 门的布局。一个 6 输入 AND 门是一个大型设备,很难装入增量器的一个单元中。解决方案是将门分布在多个单元中。具体来说,该门使用一个标准的 CMOS NAND 门电路,其中 NMOS 晶体管串联,PMOS 晶体管并联。每个单元都有一个 NMOS 晶体管和一个 PMOS 晶体管,并且这些链在末端连接以形成所需的 NAND 门。(反转输出会产生所需的 AND 函数。)这种分布式的布局技术是不寻常的,但可以使每个位的电路大小大致相同。
由于这些技术,增量器电路很难进行逆向工程。特别是,大多数预取器由一个电路块组成,该电路块重复 32 次,每个位一次。另一方面,增量器由_四个_不同的电路块组成,这些电路块以不规则的模式重复。具体来说,一个块启动一个进位链,第二个块继续进位链,第三个块结束进位链。结束块之前的块是不同的(一个大型晶体管来驱动最后一个块),总共形成四个变体。这种不规则的模式在之前预取器的照片中可见。
对齐网络
预取器的底部旋转数据以根据需要进行对齐。与某些处理器不同,x86 不强制执行对齐的内存访问。也就是说,32 位值不需要从内存中的 4 字节边界开始。因此,32 位值可能会跨越预取队列的两个 32 位行进行拆分。此外,当指令解码器提取指令的一个字节时,该字节可能位于预取队列中的任何位置。
为了解决这些问题,预取器包括一个对齐网络,该网络可以旋转字节以输出字节、字或四个字节,并具有处理器其余部分所需的对齐方式。
下图显示了该对齐网络的一部分。每个退出预取队列的位(顶部)都有四根导线,分别用于旋转 24、16、8 或 0 位。每个旋转导线都连接到 32 条水平位线中的一条。最后,每条水平位线都有一个输出抽头,连接到下面的数据路径。(垂直线位于芯片的下层 M1 金属层中,而水平线位于上层 M2 金属层中。对于这张照片,我移除了 M2 层以显示底层。原始水平线的阴影仍然可见。)
这个想法是,通过选择一组垂直旋转线,来自预取队列的 32 位输出将向左旋转该量。例如,要旋转 8,位会沿“旋转 8”线发送。来自预取队列的位 0 将激励水平线 8,位 1 将激励水平线 9,依此类推,位 31 将环绕到水平线 7。由于水平位线 8 连接到输出 8,因此结果是位 0 输出为位 8,位 1 输出为位 9,依此类推。
对齐 32 位值的四种可能性。上面的四个字节按指定的方式移动以产生下面的所需输出。
对于对齐过程,一个 32 位输出可能会跨越预取队列中的两个 32 位条目以四种不同的方式拆分,如上所示。这些组合由多路复用器和驱动器实现。两个 32 位多路复用器选择预取队列中的两个相关行(上面的蓝色和绿色)。四个 32 位驱动器连接到四组垂直线,激活一组驱动器以产生所需的移动。每个驱动器的每个字节都经过布线以实现如上所示的对齐。例如,旋转 8 驱动器从“绿色”多路复用器获取其顶部字节,并从“蓝色”多路复用器获取其他三个字节。结果是跨越两个队列行拆分的四个字节被旋转以形成对齐的 32 位值。
符号扩展
最后一个电路是符号扩展。假设您想将一个 8 位值添加到一个 32 位值。可以通过简单地用零填充高位来将无符号的 8 位值扩展为 32 位。但是对于有符号的值,这会比较棘手。例如,-1 是八位值 0xFF,但 32 位值是 0xFFFFFFFF。要将 8 位有符号值转换为 32 位,必须用原始值的高位(指示符号)填充高 24 位。换句话说,对于正值,额外位用 0 填充,但对于负值,额外位用 1 填充。此过程称为符号扩展。9
在 386 中,位于预取器底部的电路对指令中的值执行符号扩展。该电路支持将 8 位值扩展为 16 位或 32 位,以及将 16 位值扩展为 32 位。该电路将根据指令用零或符号扩展值。
下面的示意图显示了该符号扩展电路的一位。它由左右两侧的锁存器和中间的多路复用器组成。锁存器使用使用 CMOS 开关的标准 386 电路构建(请参阅脚注)。7 多路复用器选择三个值之一:来自交换网络的位值、0(用于符号扩展)或 1(用于符号扩展)。如果选择了位值,则多路复用器由 CMOS 开关构建,而 0 或 1 值使用两个晶体管。此电路重复 32 次,尽管底部字节只有锁存器,没有多路复用器,因为符号扩展不会修改底部字节。
符号扩展电路的第二部分确定是否应该用 0 或 1 填充这些位,并将控制信号发送到上面的电路。左侧的门确定符号扩展位应该是 0 还是 1。对于 16 位符号扩展,该位来自数据的位 15,而对于 8 位符号扩展,该位来自位 7。右侧的四个门生成用于符号扩展每个位的信号,从而为位范围 31-16 和范围 15-8 生成单独的信号。
该电路在芯片上的布局有些不寻常。大多数预取器电路由 32 个相同的列组成,每个位一个列。8 上面的电路实现一次,使用大约 16 个门(上面未显示缓冲器和反相器)。尽管如此,上面的电路还是被塞入了位位置 17 到 7 中,从而在布局中造成了不规则性。此外,与 386 的其余部分相比,电路在硅中的实现是不寻常的。386 的大多数电路使用两个金属层进行互连,从而最大限度地减少了多晶硅布线的使用。但是,上面的电路也使用长段多晶硅来连接这些门。
上图显示了符号扩展电路在规则数据路径电路中不规则的布局,该数据路径电路的宽度为 32 位。符号扩展电路以绿色显示;这是本节顶部描述的电路,针对每个位 31-8 重复。位 15-8 的电路已向上移动,可能是为了为以红色指示的符号扩展控制电路腾出空间。请注意,控制电路的布局是完全不规则的,因为该电路只有一个副本,并且没有内部结构。这种布局的一个结果是该电路块的左侧和右侧浪费了空间,即没有电路(除了通过的垂直金属线)的棕褐色区域。在最右侧,用于控制锁存器的电路块已楔入位 0 下方。Intel 的设计人员付出了巨大的努力来最大限度地减少处理器芯片的尺寸,因为较小的芯片可以节省大量资金。这种布局一定是他们能够管理的最有效的布局,但我发现与数据路径其余部分的规则性相比,这在美学上令人不悦。
指令如何在芯片中流动
指令在 386 芯片中遵循一条曲折的路径。首先,右上角的总线接口单元从内存中读取指令,并通过 32 位总线(蓝色)将它们发送到预取单元。预取单元将指令存储在 16 字节的预取队列中。
如何从预取队列中执行指令?事实证明,存在两条不同的路径。假设您要执行一条指令,将 12345678 添加到 EAX 寄存器。预取队列将保存五个字节 05(操作码)、78、56、34 和 12。预取队列通过红色显示的 8 位总线一次将一个操作码提供给解码器。总线从预取队列的对齐网络获取最低的 8 位,并将此字节发送到缓冲区(红色箭头头部的小方块)。从那里,操作码传送到指令解码器。10 反过来,指令解码器使用大型表 (PLA) 将 x86 指令转换为具有 19 个不同字段的 111 位内部格式。11
另一方面,指令的数据字节通过 32 位数据总线(橙色)从预取队列传递到 ALU(算术逻辑单元)。与之前的总线不同,此数据总线是分布式的,每条导线都穿过数据路径的每一列。该总线延伸穿过整个数据路径,因此值也可以存储到寄存器中。例如,MOV
(移动)指令可以将来自指令的值(一个“立即”值)存储到寄存器中。
结论
386 的预取队列包含大约 7400 个晶体管,比 Intel 8080 处理器还要多。(这仅仅是队列本身;我忽略了预取控制逻辑。)这说明了处理器技术的快速发展:386 中一个功能单元的一部分包含的晶体管比 11 年前的整个 8080 处理器还要多。并且该单元小于整个 386 处理器的 3%。
每次我查看 x86 电路时,我都会看到支持向后兼容性所需的复杂性,并且我对 RISC 为何变得流行有了更多的理解。预取器也不例外。大部分复杂性是由于 386 对未对齐内存访问的支持,这需要一个字节移动网络来将字节移动到 32 位对齐。此外,指令总线的另一端是复杂的指令解码器,它可以解码复杂的 x86 指令。解码 RISC 指令要容易得多。
无论如何,我希望您发现对预取电路的这种观察很有趣。我计划撰写更多关于 386 的文章,因此请在 Bluesky (@righto.com) 或 RSS 上关注我以获取更新。我之前已经撰写了多篇关于 386 的文章;一个好的起点可能是我对 368 个芯片的调查。
脚注和参考文献
- 一个位的电路宽度会变化几次:当预取队列和段描述符缓存使用 66 µm 宽的电路时,数据路径电路略微紧凑,为 60 µm。桶形移位器甚至更窄,每个位 54.5 µm。连接具有不同宽度的电路会浪费空间,因为连接这些位的布线需要水平段来调整间距。但是,使用比需要的宽度更宽的宽度也会浪费空间。因此,权衡值得时,间距的变化很少发生。↩
- Intel 8086 处理器有一个 6 字节的预取队列,而 Intel 8088(用于原始 IBM PC)的预取队列只有 4 个字节。相比之下,386 的 16 字节队列似乎很豪华。(然而,一些 386 处理器据说由于一个错误只使用了 12 个字节。) 预取队列假定指令按线性顺序执行,因此它对分支或循环没有帮助。如果处理器遇到分支,则预取队列将被丢弃。(相比之下,即使执行跳转到别处,现代缓存也可以工作。)此外,预取队列不处理自修改代码。(以前,代码在执行时更改自身以挤出额外的性能是很常见的。)通过将代码加载到预取队列中然后修改指令,您可以确定预取队列的大小:如果执行了旧指令,则它必须在预取队列中,但如果执行了修改后的指令,则它必须在预取队列之外。从 Pentium Pro 开始,如果写入修改了预取的指令,x86 处理器会刷新预取队列。↩
- 预取单元生成“线性”地址,这些地址必须由分页单元转换为物理地址(参考)。↩
- 我不知道时钟的哪个阶段是阶段 1,哪个阶段是阶段 2,因此我随意分配了这些数字。386 从以处理器时钟速度两倍运行的时钟输入
CLK2
在内部创建四个时钟信号。386 生成一个具有非重叠相位的两相时钟。也就是说,在第一阶段为高电平和第二阶段为高电平之间存在一个小间隙。386 的电路受时钟控制,交替的块由交替的相位控制。由于时钟相位不重叠,这确保了逻辑块按顺序激活,从而允许数据的有序流动。但是由于 386 使用 CMOS,因此它也需要用于 PMOS 晶体管的低有效时钟。您可能会认为您可以简单地将相位 1 时钟用作低有效相位 2 时钟,反之亦然。问题是,当用作低有效时,这些时钟相位会重叠;有时两个时钟信号都为低电平。因此,必须显式反转两个时钟相位以产生两个低有效时钟相位。我在 [这篇文章](http