MIT-6-004-数字设计与计算机系统结构笔记-全-

MIT 6.004 数字设计与计算机系统结构笔记(全)

001:什么是信息?💡

在本节中,我们将学习信息的基本概念,以及如何从工程角度量化信息。我们将通过一个简单的例子来理解信息如何消除不确定性,并引入衡量信息量的数学方法。

为了构建能够处理、传输或存储信息的电路,我们需要一些工程工具来帮助我们判断所选的信息表示方式是否良好。这正是本章的主题。我们将研究将信息编码为比特的不同方法,并学习帮助我们判断编码是否有效的数学知识。我们还将探讨,当我们的表示方式因错误而损坏时,我们可以做些什么。能够检测到问题发生,甚至可能纠正问题,这将是很有用的。

让我们从提问开始:什么是信息?从工程的角度来看,我们将信息定义为传达或接收的数据,这些数据消除了关于特定因素或情况的不确定性。换句话说,在接收到数据后,我们将更了解那个特定的因素或情况。数据所消除的不确定性越大,数据所传达的信息就越多。

让我们看一个例子。从一副标准的52张扑克牌中随机抽取了一张牌。在没有任何关于所选牌的数据的情况下,这张牌的类型有52种可能性。

现在,假设你收到了以下关于选择的一条数据:

以下是四种可能的数据情况:

  • A. 你得知这张牌的花色是红心。这将选择范围缩小到13张牌中的一张。
  • B. 你得知这张牌不是黑桃A。这仍然留下了51张可能的牌。
  • C. 你得知这张牌是一张人头牌(即J、Q或K)。所以选择是12张牌中的一张。
  • D. 你得知这张牌是“自杀国王”。他那位拿着小蓝剑的朋友向我们表明,这实际上是一张特定的牌——红心K,国王正把剑刺进自己的头。这里没有不确定性。我们确切地知道选择是什么。

哪一条可能的数据传达了最多的信息?换句话说,哪条数据最大程度地消除了关于所选牌的不确定性?同样地,哪条数据传达的信息量最少?

在我们讨论这些问题正确答案背后的数学原理之前,请先在下面的投票中选出你的答案。

在本节中,我们一起学习了信息的工程定义:信息是消除不确定性的数据。我们通过扑克牌的例子,直观地理解了不同数据所消除的不确定性程度不同,从而传达的信息量也不同。接下来,我们将引入数学工具来精确地量化信息。

002:1.2.2 量化信息

在本节课中,我们将学习如何量化信息。我们将引入随机变量的概念来描述不确定性,并学习克劳德·香农提出的信息量计算公式。通过纸牌游戏的例子,我们将理解信息量与事件概率之间的关系,并学会计算不同情境下接收到的信息量。

引入随机变量

数学家喜欢通过引入随机变量的概念来模拟特定情况下的不确定性。在我们的应用中,总是处理具有有限数量 n 个不同选择的情况。

因此,我们将使用一个离散随机变量 X,它可以取 n 个可能值中的一个:x₁, x₂, ..., xₙ

X 取值为 x₁ 的概率由概率 P₁ 给出,取值为 x₂ 的概率由 P₂ 给出,以此类推。概率越小,X 取该特定值的不确定性就越大。

信息量的定义

克劳德·香农在其关于信息论的开创性工作中,定义了当我们得知 X 取值为 xᵢ 时所接收到的信息量。

信息量 I 的计算公式为:

I = log₂(1 / Pᵢ)

请注意,一个选择的不确定性与它的概率成反比。因此,对数内的项 (1 / Pᵢ) 本质上代表了该特定选择的不确定性大小。

我们使用以2为底的对数来以比特(bit)为单位衡量不确定性的大小,其中1比特是一个可以取值为0或1的量。

可以将信息内容视为编码这个选择所需的比特数。

处理部分信息

上一节我们介绍了在完全确定结果时如何计算信息量。本节中我们来看看,如果接收到的数据没有解决所有的不确定性,该如何计算。

例如,之前我们收到数据说抽到的牌是红心。部分不确定性已被消除,因为我们比收到数据前更了解这张牌,但我们还不知道具体是哪张牌。因此,仍然存在一些不确定性。

我们仍然可以使用上一张幻灯片中的信息量公式,利用我们收到的数据的概率来计算信息量。

在我们的例子中,从一副52张牌中随机抽取一张牌,得知它是红心的概率是 13/52(红心的数量除以总选择数)。

所以 P_data = 13/521/4,信息量计算为 log₂(1 / (1/4)),结果是2比特。

等概率选择的通用情况

这是一个我们经常遇到的例子。我们收到关于 n 个等概率选择的部分信息,每个选择的概率为 1/n。这使选择范围缩小到 M 个。

收到此类信息的概率是 M * (1/n),因此接收到的信息量为:

I = log₂(n / M) 比特

实例分析

让我们看一些例子。

  • 抛一枚公平的硬币:如果我们得知抛一枚公平硬币的结果(正面或反面),我们从2个选择变为1个选择,因此接收到的信息是 log₂(2/1),即1比特。这很合理:我们需要1比特来编码两种可能性中实际发生的那一种,例如,1代表正面,0代表反面。

  • 回顾纸牌花色的例子:得知从一副新牌中抽出的牌是红心,我们得到 log₂(52/13),即2比特信息。这同样合理:我们需要2比特来编码四种可能的牌色中出现的哪一种。

  • 掷两个骰子:考虑掷两个骰子(一个红色,一个绿色)得到的信息。每个骰子有六个面,因此有36种可能的组合。一旦我们得知确切的掷出结果,我们就收到了 log₂(36/1),即大约5.17比特的信息。

那些分数比特是什么意思?我们的电路只能处理整比特。所以,要编码单个结果,我们需要使用6比特。但假设我们想记录连续10次掷骰的结果。按每次6比特计算,总共需要60比特。而这个公式告诉我们,我们不需要60比特,只需要52比特就能明确无误地编码结果。我们是否能提出一种达到这个下限的编码方式,是一个有趣的问题,我们将在本章后面讨论。

总结与回顾

最后,让我们回到最初的例子。下表显示了接收到的数据的不同选择,以及该事件的概率和计算出的信息量。

以下是不同事件及其信息量的总结:

  • 得知牌是红心:此事件的概率为 13/52,信息量为2比特。
  • 得知牌不是黑桃A:此事件可能性很高,因为它是黑桃A的机会只有 1/52,因此我们从此事件中获得的信息量很小,约为0.028比特。
  • 得知牌是花牌(J,Q,K):一副牌中有12张花牌,所以此事件的概率为 12/52,我们收到约2.115比特。比得知花色获得的信息稍多,因为剩余的不确定性略少。
  • 得知具体是哪张牌:当所有不确定性都被消除时,我们获得的信息最多,略多于5.7比特。

这些结果与蓝先生的直觉很好地吻合。解决的不确定性越多,我们接收到的信息就越多。

现在,请尝试在下面的练习中计算更多示例的信息量。


本节课中我们一起学习了如何量化信息。我们引入了离散随机变量来描述不确定性,并掌握了香农信息量的计算公式 I = log₂(1/P)。通过分析从完全确定到部分确定的各种情况,我们理解了信息量与事件概率之间的反比关系,并能够计算在等概率或非等概率选择下接收到的信息量(以比特为单位)。这些概念是理解信息编码和压缩的基础。

003:信息熵 📊

在本节中,我们将学习如何评估编码方案的有效性。我们将介绍信息熵的概念,它衡量了随机变量所包含的平均信息量。理解熵是设计高效编码方案的基础。

熵的定义与计算

上一节我们讨论了信息量的概念。本节中,我们来看看如何计算一个随机变量的平均信息量,即信息熵。

熵的数学定义如下:

公式:
[
H(X) = E[I(X)] = \sum_{i} p_i \cdot \log_2\left(\frac{1}{p_i}\right)
]

其中:

  • H(X) 表示随机变量 X 的熵。
  • 大写字母 E 表示数学期望,即平均值。
  • p_i 是随机变量取第 i 个值的概率。
  • log_2(1/p_i) 是得知该值时所获得的信息量。

计算方法是:将每个可能结果的信息量,乘以其发生的概率,然后将所有结果加权求和。

熵的计算示例

为了更清楚地理解,我们来看一个具体的例子。

假设有一个随机变量,它可以取四个值:A, B, C 或 D。其概率分布及相关信息量如下表所示:

符号 概率 (p_i) 信息量 (log₂(1/p_i))
A 1/3 ≈ 1.58 bits
B 1/3 ≈ 1.58 bits
C 1/6 ≈ 2.58 bits
D 1/6 ≈ 2.58 bits

以下是计算该随机变量熵的步骤:

计算过程:
[
H(X) = \left(\frac{1}{3} \times 1.58\right) + \left(\frac{1}{3} \times 1.58\right) + \left(\frac{1}{6} \times 2.58\right) + \left(\frac{1}{6} \times 2.58\right) \approx 1.626 \text{ bits}
]

计算结果表明,该随机变量的熵约为 1.626 比特。这个数字告诉我们,一个聪明的编码方案应该能做得比简单地用 2 比特(因为 2²=4)来编码每个符号更好。这留给我们一个思考:如何设计这样的编码?我们将在本章的第三部分进一步讨论。

熵的意义与解释

那么,熵究竟告诉了我们什么?假设我们有一段数据,它描述了一系列随机变量 X 的值。

以下是基于平均比特使用量与熵 H(X) 的关系得出的三种情况:

  • 平均使用比特数 < H(X):如果我们平均使用少于 H(X) 比特来传输序列中的每个信息,那么我们发送的信息量不足以消除关于数值的不确定性。换句话说,熵是我们需要传输的比特数的下限。如果目标是明确无误地描述数值序列,那么使用少于这个数量的比特意味着任务失败。
  • 平均使用比特数 > H(X):另一方面,如果我们平均使用多于 H(X) 比特来描述数值序列,那么我们就没有最有效地利用资源,因为同样的信息本可以用更少的比特来表示。
  • 平均使用比特数 = H(X):最后,如果我们平均恰好使用 H(X) 比特,那么我们就得到了完美的编码。然而,完美总是一个艰难的目标,因此大多数时候,我们只能满足于接近这个值。

在本节最后的练习中,请尝试计算不同场景下的熵,以加深理解。

总结

本节课中,我们一起学习了信息熵的核心概念。我们了解到熵定义了一个随机变量所包含的平均信息量,其计算公式为概率加权信息量的总和。更重要的是,熵为无损编码的效率设定了一个理论下限:任何编码方案平均使用的比特数都无法低于熵值。理解这一点,是我们在后续课程中设计高效电路编码方案的基石。

004:1.2.4 编码 📝

在本节课中,我们将要学习如何将数据编码为由0和1组成的序列,即比特串。编码是比特串与待编码数据集合成员之间的一种明确映射关系。

接下来,我们把注意力转向将数据编码为0和1的序列,即比特串。

编码是比特串与待编码数据集合成员之间的一种明确映射。例如,我们有一个包含四个符号的集合S:A、B、C和D。

我们希望使用比特串来编码由这些符号构成的消息,例如“ABBA”。


固定长度编码

如果我们选择一次编码一个字符,我们的编码将为每个符号分配一个唯一的比特串。由于我们有四个符号,我们可以为每个符号选择一个唯一的2位比特串。

以下是可能的编码方案:

  • A 编码为 00
  • B 编码为 01
  • C 编码为 10
  • D 编码为 11

这被称为固定长度编码,因为用于表示符号的比特串都具有相同的长度。消息“ABBA”的编码将是 00-01-01-00

我们可以反向运行这个过程。给定一个比特串和编码表,我们可以查找比特串中的下几位,使用编码表来确定它们代表的符号:00 解码为 A,01 解码为 B,依此类推。

可变长度编码

我们也可以使用不同长度的比特串来编码符号,这被称为可变长度编码

以下是可变长度编码的一个例子:

  • A 编码为 01
  • B 编码为 1
  • C 编码为 000
  • D 编码为 001

“ABBA”将被编码为 01-1-1-01。我们将看到,精心构建的可变长度编码对于高效编码符号出现概率不同的消息非常有用。

编码的无歧义性

我们必须确保编码是无歧义的。假设我们决定这样编码:

  • A 编码为 0
  • B 编码为 1
  • C 编码为 10
  • D 编码为 11

消息“ABBA”的编码将是 0-1-1-0。看起来不错,因为这个编码比前两种编码都短。

现在,让我们尝试解码这个比特串。使用编码表,我们不幸地得到了几种解码结果:当然是“ABBA”,但也可能是“AA”或“ABC”,这取决于我们如何对位进行分组。这个编码方案失败了,因为消息无法被明确地解释。

使用二叉树表示编码

幸运的是,我们可以用一个二叉树来表示一个无歧义的编码。具体做法是:用0和1标记从每个树节点出发的分支,并将要编码的符号作为树的叶子节点放置。



如果你为一个提议的编码构建了二叉树,并且发现没有符号标记在内部节点上,且每个叶子节点上恰好只有一个符号,那么你的编码就是可行的。


解码过程示例

例如,考虑左侧所示的编码。绘制相应的二叉树只需要一秒钟。符号B沿着标记为0的弧,距离树根的距离为1。A距离为2,C和D距离为3。

如果我们收到一个编码消息,例如 0-1-1-1-1,我们可以使用编码的连续位来识别从树根到叶子的路径,一步一步进行,直到到达一个叶子节点。


然后重复这个过程,再次从根开始,直到编码消息中的所有位都被消耗完。

所以,消息中的第一个位 0 将我们从根带到叶子B,这是我们解码出的第一个符号。接下来的 1-1 将我们带到A,再下一个 1-1 得到第二个A。

最终的解码消息“BAA”并非完全出乎意料,至少对于一只美国羊来说是这样。

总结

本节课中我们一起学习了数据编码的基本概念。我们了解了固定长度编码可变长度编码的区别,并认识到编码必须是无歧义的,以确保消息能被正确解码。我们学习了如何使用二叉树来直观地表示和验证一个编码方案的无歧义性,并通过示例演示了编码和解码的过程。理解这些原理是后续学习更复杂编码方案(如哈夫曼编码)的基础。

005:定长编码 📏

在本节课中,我们将要学习定长编码。这是一种在符号出现概率相等或没有先验信息时使用的编码方式。我们将探讨其原理、优势,并通过二进制编码十进制、ASCII码和二进制数表示等具体例子来加深理解。

定长编码的原理

如果待编码的符号出现概率相等,或者我们没有理由认为它们不相等,那么我们将使用定长编码。在这种编码的二叉树中,所有叶子节点到根节点的距离都相同。

定长编码的优势在于支持随机访问。我们可以通过简单地跳过特定数量的比特,来确定消息中的第n个符号。例如,在使用此处所示的定长编码的消息中,如果我们想确定编码消息中的第三个符号,我们会跳过用于编码前两个符号的四个比特,然后从消息的第五个比特开始解码。

熵与等概率符号

Blue先生正在为我们讲解具有n个等可能结果的随机变量的熵。

在这种情况下,熵公式中求和序列的每个元素都等于 (1/n) * log₂(n)。由于序列中有n个元素,最终熵值就是 log₂(n)

定长编码实例

以下是几个使用定长编码的常见例子。

二进制编码十进制

在二进制编码十进制中,十进制数的每个数位被单独编码。由于有10个不同的十进制数字,我们需要使用一个4比特的代码来表示这10种可能的选择。

其关联熵为 log₂(10),约等于3.322比特。我们可以看到,我们选择的编码效率不高,因为我们使用的比特数超过了编码所需的最小数量。例如,编码一个1000位的十进制数,我们的编码会使用4000比特,尽管熵值表明我们或许能找到更短的编码,比如3400比特。

ASCII码

另一个常见的编码是ASCII码,它用于在计算和通信中表示英文文本。ASCII码有94个打印字符,因此其关联熵为 log₂(94),约等于6.555比特。所以,在我们的定长编码中,每个字符会使用7比特。

无符号整数的二进制表示

最重要的编码之一是我们用来表示数字的编码。让我们从思考无符号整数(从零开始向上计数)的表示法开始。

借鉴我们表示十进制数(即使用10个十进制数字的以10为基数的表示法)的经验,我们的二进制数字表示将使用两个二进制数字的以2为基数的表示法。

将一个数值的n位二进制表示转换为相应整数的公式如下所示。只需将每个二进制数字乘以其在基数2表示中的对应权重即可。例如,这里有一个12位的二进制数,每个二进制数字的权重如上所示。我们可以将其值计算为 0*2¹¹ + 1*2¹⁰ + 1*2⁹ + ...。只保留非零项并展开2的幂次,我们得到和 1024 + 512 + 256 + 128 + 64 + 16。以10为基数表示,总和为数字2000。

使用这种n位表示法,可以表示的最小数字是0(当所有二进制数字都为0时),最大数字是 2ⁿ - 1(当所有二进制数字都为1时)。许多数字系统被设计为支持对相同固定大小的二进制编码数字进行操作,例如选择32位或64位表示法。这意味着在处理太大而无法表示为单个32位或64位二进制字符串的数字时,它们将需要多次操作。

十六进制表示法

转录一长串二进制数字既繁琐又容易出错,因此让我们寻找一种更方便的记法。理想情况下,这种记法应该能让我们无需太多计算就能轻松恢复原始的比特串。

一个好的选择是使用基于某个2的更高次幂的基数的表示法,这样表示法中的每个数字都对应一小段连续的二进制比特串。如今一个流行的选择是称为十六进制的基数16表示法,简称Hex。在这种表示法中,每四个二进制数字用一个十六进制数字表示。

由于四个二进制比特有16种可能的组合,我们将需要16个十六进制数字。我们将从十进制表示法中借用数字0到9,然后简单地使用字母表的前六个字母A到F来表示剩余的数字。四比特二进制与十六进制之间的转换关系如下方左侧的表格所示。

要将一个二进制数转换为十六进制,请将二进制数字按四个一组进行分组,从最低有效位(即权重为2⁰的比特)开始。然后使用表格将每个四比特模式转换为相应的十六进制数字。例如,0000是十六进制数字0,1101是十六进制数字D,0111是十六进制数字7。得到的十六进制表示是7D0。为了避免混淆,我们将使用特殊的前缀0x来表示一个数字是以十六进制显示的,因此我们将0x7D0写作二进制数0111-1101-0000的十六进制表示。这种记法约定被许多编程语言用于输入二进制字符串。

总结

本节课中我们一起学习了定长编码。我们了解了其基本原理,即当符号概率相等时,使用所有叶子节点深度相同的编码树。我们探讨了其支持随机访问的优势。通过二进制编码十进制、ASCII码和无符号整数二进制表示等具体实例,我们看到了定长编码的应用及其与熵的关系。最后,我们介绍了十六进制表示法,作为一种便于读写长二进制串的实用工具。理解这些基础编码是深入学习计算机如何表示和处理信息的关键第一步。

006:有符号整数与二进制补码 🧮

在本节课中,我们将学习如何表示有符号整数,特别是二进制补码表示法。这是一种在现代数字系统中广泛使用的、能够简化算术电路设计的编码方式。

概述

我们的最终挑战是弄清楚如何表示有符号整数。例如,数字 -2000 应该如何表示?在十进制记数法中,惯例是在数字前加上加号或减号来表示其正负,通常省略正数的加号以简化记法。

符号-数值表示法

我们可以采用一种类似的记法,称为符号-数值表示法。在二进制中,通过在二进制串的前端分配一个单独的位来表示符号,例如用 0 表示正数,用 1 表示负数。

因此,-2000 的符号-数值表示将是一个初始的 1(表示负数),后跟之前两页幻灯片中描述的 2000 的表示。

然而,使用符号-数值表示法存在一些复杂之处。0 有两种可能的二进制表示:+0 和 -0。这使得编码效率略有降低。但更重要的是,执行符号-数值数加法的电路与执行减法的电路是不同的。当然,我们在小学就习惯了这一点,我们学习了一种加法技巧和另一种减法技巧。

二进制补码表示法

为了保持电路简单,大多数现代数字系统使用二进制补码来表示有符号数。

在这种表示法中,一个 n 位二进制补码数的最高位具有负权重,如图所示。因此,所有负数在最高位都是 1。从这个意义上说,最高位充当了符号位。如果它是 1,则表示的数字是负数。

数值范围

最负的 n 位数在最高位有一个 1,表示值 -2^(n-1)。最正的 n 位数在具有负权重的最高位是 0,在所有具有正权重的位上是 1,表示值 2^(n-1) - 1。这给出了可能的取值范围。

例如,在 8 位二进制补码表示中,最负的数是 -2^7,即 -128;最正的数是 2^7 - 1,即 127。

如果所有 n 位都是 1,可以将其视为最负数与最正数之和。换句话说,-2^(n-1) + (2^(n-1) - 1) = -1。当然,如果所有 n 位都是 0,那就是 0 的唯一表示。

二进制补码的运算

让我们看看当我们将 -1 和 1 的 n 位值相加,并保留 n 位结果时会发生什么。

在最右边的列,1 + 1 是 0,进位 1。在第二列,进位的 1 加上 1 再加上 0 是 0,进位 1,依此类推。结果是全零,即 0 的表示,完美。请注意,我们只使用了普通的二进制加法,即使其中一个或两个操作数是负数。

二进制补码非常适合 n 位算术。要计算 B - A,我们可以直接使用加法来计算 B + (-A)

所以现在我们只需要知道如何根据 A 的二进制补码表示来求 -A 的二进制补码表示。

我们知道 A + (-A) = 0,并且使用上面的例子,我们可以将 0 重写为 1 + (-1)。重组项后,我们看到 -A = 1 + (-1 - A)

求补运算

如上所述,-1 的二进制补码表示是全 1 位,因此我们可以将该减法写为:全 1 减去 A 的各个位(a₀, a₁, ..., aₙ₋₁)。

如果特定位 aᵢ 是 0,那么 1 - aᵢ 是 1。如果 aᵢ 是 1,那么 1 - aᵢ 是 0。因此,在每一列中,结果都是 aᵢ 的按位取反,我们使用 C 语言的按位取反运算符 ~ 来表示。

所以我们看到 -A = ~A + 1。就是这样。

练习

为了练习你的二进制补码技能,请尝试以下练习。你需要记住的就是如何进行二进制加法以及二进制补码取反(即按位取反再加一)。


总结

本节课中,我们一起学习了有符号整数的表示方法。我们首先了解了符号-数值表示法及其缺点,然后重点学习了二进制补码表示法。我们明白了其最高位具有负权重,定义了数值范围,并学习了其核心运算规则:使用普通二进制加法进行加减法,以及通过 -A = ~A + 1 来求一个数的相反数。这种表示法因其电路设计的简洁性而成为现代计算机系统的标准。

007:变长编码 📊

在本节课中,我们将要学习变长编码。当所有可能选项的信息量不同时,固定长度编码的效率不高。我们将探讨如何通过为不同概率的符号分配不同长度的编码,来获得更短的期望编码长度,并了解其工作原理。

上一节我们介绍了固定长度编码,它适用于所有选项概率均等的情况。本节中我们来看看当选项出现的概率不同时,如何设计更高效的编码方案。

期望编码长度

如果各个选项的信息量不同,我们可以做得更好。衡量编码效率的一种方法是计算其期望长度。期望长度的计算方法是:将每个符号 x_i 的编码长度 L_i,乘以其出现的概率 P_i,然后对所有符号求和。

公式如下:
期望长度 = Σ (P_i * L_i)

我们的目标是找到一种编码,其期望长度比固定长度编码更短。理想情况下,我们希望编码的期望长度尽可能接近信息源 XH(X),熵代表了信息的平均信息量。

变长编码的设计原则

我们知道,如果一个符号 x_i 出现的概率 P_i 较高,其信息量就较小,因此我们希望为其分配较短的编码。

反之,如果一个符号 x_i 出现的概率 P_i 较低,其信息量就较大,我们可以为其分配较长的编码。

我们将构建这种编码长度可变的方案,称之为变长编码

一个变长编码的例子

以下是一个我们之前见过的例子。有四个待编码的符号 A、B、C、D,每个都有指定的出现概率。

以下是建议的编码方案,它遵循了上一张幻灯片的原则:为信息量小的高概率符号(例如 B)分配短编码,为信息量大的低概率符号(例如 C 或 D)分配长编码。

符号 概率 编码
A 1/4 10
B 1/2 0
C 1/12 110
D 1/12 111

我们可以将这个编码表示为一棵二叉树。由于所有符号都出现在树的叶节点上,可以证明这个编码是无歧义的

让我们尝试解码一段编码数据:0 10 0 10 111

以下是解码步骤:

  1. 从树根开始,第一个编码位是 0,沿左分支到达叶节点 B。所以解码的第一个符号是 B。
  2. 再次从树根开始,下一个位 1 沿右分支,接着 0 沿左分支,再 0 沿左分支,到达叶节点 C。第二个符号是 C。
  3. 继续,0 解码为 B
  4. 10 解码为 A
  5. 111 解码为 D

因此,整个解码后的消息是:B, C, B, A, D

计算编码效率

现在计算这个变长编码的期望长度:

  • A 的编码长度(2 位) × 其概率(1/4) = 0.5
  • B 的编码长度(1 位) × 其概率(1/2) = 0.5
  • C 的编码长度(3 位) × 其概率(1/12)≈ 0.25
  • D 的编码长度(3 位) × 其概率(1/12)≈ 0.25

期望长度总和 ≈ 1.5 位

我们做得怎么样?如果对这四个符号使用固定长度编码,每个符号需要 2 位。那么编码 1000 个符号需要 2000 位

使用我们的变长编码,编码 1000 个符号的期望长度约为 1500 位

编码所需位数的理论下限是 1000 乘以信息源 X 的熵 H(X),计算后约为 1626 位。可见,变长编码让我们更接近理论最优值,但尚未完全达到。

寻找更优的编码

那么,是否存在另一种变长编码方案能获得更好的效果呢?

通常,我们需要一种系统性的方法来生成最优的变长编码,这将是下一个视频的主题。


本节课中我们一起学习了变长编码。我们了解到,当符号出现概率不同时,可以通过为高概率符号分配短编码、为低概率符号分配长编码来减少平均编码长度。我们通过一个具体例子演示了变长编码的解码过程,并计算了其期望长度,发现它比固定长度编码更高效,且更接近信息熵所设定的理论极限。

008:霍夫曼算法 🧮

在本节课中,我们将学习霍夫曼算法。这是一种用于构造最优变长编码的方法。我们将了解其工作原理,并通过一个具体例子来演示如何构建编码树。

概述

给定一组符号及其出现的概率,霍夫曼算法能够指导我们如何构造一个最优的变长编码。

所谓“最优”,是指在每次编码一个符号的假设下,没有其他变长编码能拥有更短的期望长度

算法原理:自底向上构建

该算法从底向上构建编码的二叉树。

首先,选择概率最小的两个符号。这意味着它们的信息含量最高,因此应该拥有最长的编码。

如果在过程中遇到两个符号概率相同的情况,可以任意选择其中一个。

算法步骤详解

以下是霍夫曼算法的具体构建步骤。

第一步:选择并合并最小概率符号

在我们的运行示例中,概率最低的两个符号是 CD

将这两个符号合并为一个二叉子树,其中一个分支标记为 0,另一个标记为 1。哪个标签对应哪个分支并不重要。

从符号列表中移除 C 和 D,并用一个新构建的子树替换它们。这个新子树的根节点具有关联概率 1.6,即其两个分支概率之和。

第二步:重复合并过程

现在继续,在每一步都选择两个概率最低的符号(和/或子树),将选择的对象合并成一个新的子树。

在我们的例子中,此时符号 A 的概率是 1/3,符号 B 的概率是 1/2,而 CD 子树 的概率是 1/6。因此,我们将 ACD 子树 合并。

第三步:完成最终合并

在最后一步,我们只剩下两个选择:BACD 子树

我们将它们合并成一个新的子树,其根节点最终成为代表最优变长编码的树的根节点。

令人高兴的是,这棵树生成的编码正是我们一直使用的那个。

编码的等价性与效率

如上所述,我们可以通过交换任何子树分支上的 01 标签,来生成多种不同的变长编码。

但是,所有这些编码都将具有相同的期望长度。期望长度由每个符号到树根的距离决定,而不是由从根到叶路径上的标签决定。

因此,所有这些不同的编码在效率上是等价的。

动手实践

现在,请尝试运用霍夫曼算法来构造最优的变长编码。

总结

本节课中,我们一起学习了霍夫曼算法。我们了解到,该算法通过自底向上、反复合并概率最小的两个节点来构建最优编码树。最终生成的变长编码能最小化信息的期望编码长度,并且通过交换分支标签可以得到一系列等价的、效率相同的最优编码。

009:哈夫曼编码进阶 🚀

在本节课中,我们将探讨如何通过编码符号序列(而非单个符号)来进一步优化哈夫曼编码的平均长度,并简要介绍现代自适应压缩算法的概念。


上一节我们介绍了哈夫曼编码是最优的变长编码。那么,这是否意味着我们无法做得更好了呢?

对于一次编码一个符号的情况,哈夫曼编码确实已经是最优的,无法再改进。

但是,如果我们希望编码长的符号序列,可以通过处理符号对(或更长的序列)来降低编码的期望长度,而不是仅处理单个符号。

以下是使用符号对进行编码的原理说明:

下面的表格展示了从我们例子中得出的符号对的概率。

如果我们使用哈夫曼算法,基于这些概率构建最优的变长编码,结果发现,当编码符号对时,每个符号的期望长度是 1.646 比特

这比单独编码每个符号时的 1.667 比特/符号 有微小的改进。

如果我们编码长度为3的序列,效果会更好,依此类推。


了解了固定序列长度的优化后,我们来看看更强大的现代方法。

现代文件压缩算法使用一种自适应算法,来动态确定哪些序列频繁出现,从而应该被赋予较短的编码。

当数据中包含许多重复序列时,这些算法效果非常好。例如,在自然语言数据中,某些字母组合甚至整个单词会反复出现。

压缩可以实现相对于原始文件大小的显著缩减。


如果你想了解更多,可以在维基百科上查找 LZW,阅读关于 Lempel-Ziv-Welch (LZW) 数据压缩算法的内容。


本节课中我们一起学习了如何通过编码符号序列来优化哈夫曼编码,以及现代自适应压缩算法的基本思想。核心在于,利用数据中的统计规律和重复模式,可以有效地减少表示信息所需的平均比特数。

010:错误检测与纠正 🔍

在本节课中,我们将要学习当数据在编码和传输过程中发生错误时,如何检测并纠正这些错误。我们将从简单的单比特错误开始,逐步引入汉明距离和奇偶校验等核心概念,并探讨如何设计编码方案以应对不同程度的错误。


现在,让我们思考一下,如果发生错误,并且我们编码数据中的一个或多个比特被破坏,会发生什么情况。我们将重点关注单比特错误,但所讨论的大部分内容可以推广到多比特错误。

例如,考虑对某个不可预测事件的结果进行编码。例如,抛掷一枚公平的硬币。有两种结果,正面编码为0,反面编码为1。

现在,假设在处理过程中发生了一些错误。例如,数据在从鲍勃传输给爱丽丝的过程中被破坏。鲍勃本意是发送消息“正面”,但传输过程中0被破坏变成了1。因此爱丽丝收到了1,并将其解释为“反面”。

所以,如果存在单比特错误的可能性,这种简单的编码方式效果不佳。

为了帮助讨论,我们将引入汉明距离的概念,其定义为两个相同长度的编码中,对应数字不同的位置数量。

例如,这里有两个七比特编码,它们在第三和第五个位置上不同,因此这两个编码之间的汉明距离是2。

如果有人告诉我们两个编码的汉明距离是零,那么这两个编码是相同的。汉明距离是衡量编码差异的便捷工具。

这如何帮助我们思考单比特错误?一个单比特错误恰好改变编码中的一个比特,因此一个有效的二进制码字与发生单比特错误的同一码字之间的汉明距离是1。

我们简单编码的困难在于,两个有效码字0和1之间的汉明距离也是1,因此一个单比特错误会将一个有效码字变成另一个有效码字。

我们将用图形方式展示这一点,使用箭头表示两个编码相差一个比特。换句话说,编码之间的汉明距离是1。

这里的真正问题是,当爱丽丝收到1时,她无法区分这是未损坏的“反面”编码,还是损坏的“正面”编码。她无法检测到错误已经发生。让我们找出解决她问题的方法。

关键在于想出一组有效码字,使得单比特错误不会产生另一个有效码字。我们需要的是至少相差两个比特的码字。换句话说,我们希望任意两个码字之间的最小汉明距离至少为2。

如果我们有一组最小汉明距离为1的码字,我们可以通过为每个原始码字添加一个奇偶校验位来生成我们想要的集合。

有偶校验和奇校验。使用偶校验时,选择额外的奇偶校验位,使得新码字中1比特的总数为偶数。

例如,我们最初对“正面”的编码是0,添加一个偶校验位得到00。对我们最初“反面”的编码添加一个偶校验位得到11。

码字之间的最小汉明距离从1增加到了2。这有什么帮助?

考虑发生单比特错误时的情况:00会被破坏为01或10。这两者都不是有效码字。啊哈,我们可以检测到发生了单比特错误。类似地,11的单比特错误也会被检测到。

请注意,有效码字00和11都有偶数个1比特,但损坏的码字01或10有奇数个1比特。我们说损坏的码字具有奇偶性错误

执行奇偶校验很容易。只需计算码字中1的数量。如果是偶数,则未发生单比特错误。如果是奇数,则发生了单比特错误。我们将在后续章节中看到,可以使用布尔函数异或来执行奇偶校验。

请注意,如果发生偶数个比特错误,奇偶校验将无法帮助我们,因为损坏的码字将具有偶数个1比特,因此看起来是正常的。

奇偶校验对于检测单比特错误很有用,但需要更复杂的编码来检测更多错误。

一般来说,为了检测E个错误,我们需要码字之间的最小汉明距离为 E + 1

我们可以在下面的图表中看到这一点,该图显示了错误如何破坏有效码字000和111,它们之间的汉明距离为3。理论上,这意味着我们应该能够检测到最多2个比特错误。

每个箭头代表一个单比特错误,从图中我们可以看到,从000或111出发,沿着任何长度为2的路径,都无法到达另一个有效码字。换句话说,假设我们从000或111开始,我们可以检测到最多2个错误的发生。

基本上,我们的错误检测方案依赖于选择汉明距离足够远的码字,使得E个错误无法将一个有效码字破坏成看起来像另一个有效码字的样子。


本节课中我们一起学习了错误检测的基本原理。我们引入了汉明距离作为衡量编码差异的工具,并解释了如何通过添加奇偶校验位来增加码字间的最小距离,从而检测单比特错误。我们还了解到,为了检测E个错误,需要的最小汉明距离为 E + 1。这些概念是构建更健壮的错误检测与纠正系统的基础。

011:纠错码 🔧

在本节课中,我们将要学习如何不仅检测单个比特错误,还能纠正错误以恢复原始数据。我们将探讨通过增加有效码字之间的汉明距离来实现纠错的基本原理。

增加汉明距离以实现纠错 💡

上一节我们介绍了通过汉明距离来检测错误。本节中我们来看看,如何通过进一步增加汉明距离来实现纠错。

通过将有效码字之间的最小汉明距离增加到 3,我们可以保证由单个比特错误产生的码字集合不会重叠。

码字集合与纠错原理 🧩

以下是理解纠错的关键:当两个有效码字的汉明距离至少为3时,任何一个码字发生单个比特错误后,变成的新码字都不会与另一个有效码字发生单个比特错误后产生的码字相同。

例如,考虑两个有效码字 000111(汉明距离为3):

  • 码字 000 发生单个比特错误可能变成:100010001
  • 码字 111 发生单个比特错误可能变成:110101011

可以看到,这两个集合没有任何共同的码字。

错误推断与纠正 🛠️

基于上述原理,并假设最多发生一个错误,我们可以从接收到的任何码字推断出原始的码字。

例如,如果我们接收到码字 001,我们可以推断原始的码字是 000,并且发生了一个比特错误。因为 001 只出现在 000 的错误集合中,而不在 111 的错误集合里。

通用纠错条件 📐

我们可以将这一见解推广:如果我们想要纠正最多 E 个错误,那么有效码字之间的最小汉明距离必须至少为 2E + 1

用公式表示即:
最小汉明距离 ≥ 2E + 1

例如,要纠正单个比特错误(E=1),我们需要有效码字的最小汉明距离为 3

编码理论与总结 📚

编码理论是一个专门的研究领域,致力于开发算法来生成具有必要检错和纠错特性的码字。你可以选修关于这个主题的完整课程。

本节课中我们一起学习了纠错码的基本原理。通过选择汉明距离足够远的码字,我们可以确保能够检测甚至纠正已损坏编码数据的错误。这种方法非常巧妙。

012:1.2.12 信息量化例题解析 📊

在本节课中,我们将通过两个具体的例题,学习如何量化信息。我们将运用一个核心公式:当得知一个概率为 P 的事件发生时,所获得的信息量是 log₂(1/P)


例题一:从帽子中选名字

上一节我们介绍了信息量的计算公式,本节中我们来看看如何应用它。

假设有人从一个装有5个女性名字和3个男性名字的帽子中随机抽取一个名字,并告诉你“抽到的是男性”。这个信息给了你多少信息量?

解题思路:

  1. 首先,计算事件“抽到男性”的概率 P
  2. 然后,将概率 P 代入信息量公式 I = log₂(1/P) 进行计算。

以下是详细的计算步骤:

  • 计算概率 P:帽子中共有 5 + 3 = 8 个名字。男性名字有 3 个。因此,抽到男性的概率是 P = 3/8
  • 计算信息量 I:将概率代入公式,得到 I = log₂(1 / (3/8)) = log₂(8/3)

另一种思考方式:
你也可以这样理解:如果你最初有 n 种可能的选择,而获得的信息将你的选择范围缩小到 M 种,那么你获得的信息量就是 log₂(n/M)

在这个例子中:

  • 初始选择数 n = 8(总共有8个名字)。
  • 被告知“抽到男性”后,选择数减少到 M = 3(只有3个男性名字)。
  • 因此,获得的信息量同样是 I = log₂(8/3)


例题二:猜一个二进制补码数

现在,我们来看一个与计算机数字表示相关的例子。

你需要猜一个随机的4位二进制补码数。然后我告诉你:“这个数大于0”。这个信息给了你多少信息量?

解题思路:

  1. 确定所有可能的4位二进制补码数的总数,即初始选择数 n
  2. 确定在“大于0”这个条件下,还剩下多少种可能的数,即缩小后的选择数 M
  3. 最后,使用公式 I = log₂(n/M) 计算信息量。

以下是详细的计算步骤:

  • 计算初始选择数 n:一个4位二进制补码可以表示 2⁴ = 16 个不同的数字。因此,n = 16
  • 计算缩小后的选择数 M:在4位二进制补码中,大于0的数是从 0001(十进制1)到 0111(十进制7),共有 7 个。因此,M = 7
  • 计算信息量 I:将 nM 代入公式,得到 I = log₂(16/7)


总结

本节课中我们一起学习了如何量化信息。我们通过两个例题实践了核心公式 I = log₂(1/P) 或等价的 I = log₂(n/M)。关键在于:

  1. 确定事件发生的概率 P,或者确定信息前后的选择数量变化(从 nM)。
  2. 将数值代入公式进行计算。
    这种方法将抽象的信息概念转化为具体的数值,是理解信息论的基础。

013:二进制补码表示法示例详解 💻

在本节课中,我们将学习二进制补码表示法,这是一种用于在二进制中表示正数和负数的方法。我们将通过具体的例子来理解其工作原理、数值范围以及正负数之间的转换技巧。


理解二进制补码表示法

二进制补码表示法允许我们使用二进制位来表示正数和负数。其核心在于,最高有效位(最左边的位)被赋予一个负的权重,而其他位则保持正的权重。

对于一个N位的二进制数,其值可以通过以下公式计算:
值 = -bN-1 × 2N-1 + Σ (bi × 2i),其中 i 从 0 到 N-2,bi 是第 i 位的值(0 或 1)。

上一节我们介绍了二进制补码的基本概念,本节中我们来看看具体的例子。


正数示例

让我们从一个正数的例子开始,以理解这种表示法如何工作。

考虑一个6位的二进制补码数:001000

要确定这个数的值,我们按位计算:

  • 最右边的位(第0位)代表 20
  • 向左依次是 21, 22, 23, 24
  • 最左边的位(第5位)代表 -25

以下是计算过程:

  • 如果某一位是 0,则其对应的权重不参与计算。
  • 如果某一位是 1,则其对应的权重是数值的一部分。

对于 001000,我们看到只有一个 1,位于 23 的位置。因此,这个数的值是 23,等于 8

注意:如果想用更多二进制位来表示同一个正数(如8),只需在正数前添加前导零。例如,用8位二进制补码表示数字8,就是在6位表示 001000 前加两个零,得到 00001000


负数示例

现在让我们尝试一个负数。在二进制补码表示中,负数的最髙有效位总是 1

数字 101100 的值是多少?

我们应用相同的逻辑。现在,我们在第2、3和5位上有 1。这意味着这个数等于:
-25 + 23 + 22 = -32 + 8 + 4 = -20

注意:对于负数,如果想用更多位表示同一个值(如-20),只需在数字前添加前导 1。例如,用8位表示-20是 11101100。我们可以验证:-27 + 26 + 25 + 23 + 22 = -128 + 64 + 32 + 8 + 4 = -20。


数值表示范围

了解了单个数字的表示后,我们来看看使用N位二进制补码可以表示的数字范围。

以6位为例:

  • 最大的正数011111。其值为:20 + 21 + 22 + 23 + 24 = 1 + 2 + 4 + 8 + 16 = 31。这也等于 25 - 1。
  • 最小的负数100000。其值为:-25 = -32,没有正数部分相加。

因此,对于N位二进制补码,其可表示的范围是:
从 -2N-1 到 2N-1 - 1


正负数转换技巧

在二进制补码中,有一个简单的方法可以在正数和其对应的负数之间进行转换:将所有位取反,然后加1

例如,我们之前计算出 101100 等于 -20

  1. 取反所有位:101100 -> 010011
  2. 加1:010011 + 1 = 010100
  3. 计算 010100 的值:22 + 24 = 4 + 16 = 20

这证实了我们原来的数字 101100 是 -20。

同样的方法可以反向应用。如果你从正数20 (010100) 开始:

  1. 取反所有位:010100 -> 101011
  2. 加1:101011 + 1 = 101100

我们又回到了原来的数字 101100,即 -20。

因此,要快速确定一个负的二进制补码数的值,可以翻转所有位并加1,找到对应的正数 x,这意味着你原来的负数具有 -x 的值。


总结

本节课中我们一起学习了二进制补码表示法。我们了解到:

  1. 二进制补码使用最高有效位作为负权重(-2N-1)来表示负数。
  2. 正数的表示与无符号二进制类似,但位数可扩展(添加前导0)。
  3. 负数的表示需要扩展时,添加的是前导1。
  4. N位二进制补码的表示范围是 -2N-1 到 2N-1-1
  5. 一个核心的转换技巧是:取反加一,这可以用于求一个数的相反数或验证负数的值。

掌握二进制补码是理解计算机如何存储和处理整数的关键基础。

014:补码加法运算示例 🧮

在本节课中,我们将学习如何使用二进制补码表示法进行加法运算。我们将通过具体的例子,演示如何将减法问题转化为加法问题,并理解运算过程中可能出现的溢出情况。


二进制补码表示法的一个优点是,它允许你将加法和减法都作为加法问题来处理。具体方法是将减法问题 A - B 转化为加法问题 A + (-B)。由于我们已经知道如何轻松地对补码数值进行取反操作,这个过程变得非常直接。

上一节我们介绍了补码的取反操作,本节中我们来看看如何利用它进行实际的加减法运算。

示例一:计算 15 - 18

让我们使用6位二进制补码表示法来计算 15 - 18

首先,将减法转化为加法:
15 - 18 = 15 + (-18)

步骤1:获取操作数的补码表示

  • 15 的二进制表示:001111
  • 18 的二进制表示:010010

为了得到 -18,我们对 18 的表示执行“按位取反并加1”的操作:

  1. 按位取反 010010 得到 101101
  2. 1101101 + 1 = 101110
    因此,-18 的补码表示为 101110

步骤2:执行加法运算

现在,我们将 15-18 的补码相加:

   001111  (15)
 + 101110  (-18)
 ------------
   111101

在二进制加法中,1 + 1 会产生一个 0 和一个进位 1。按照这个规则,我们得到最终的和为 111101

步骤3:解读结果

为了解读 111101 代表什么数值,我们再次对其执行“按位取反并加1”的操作(即求其相反数):

  1. 按位取反 111101 得到 000010
  2. 1000010 + 1 = 000011
    000011 等于十进制数 3

这意味着 111101 等于 -3。这正是我们执行 15 - 18 时期望得到的结果。


示例二:计算 27 - 6

让我们尝试另一个例子:27 - 6

同样,转化为加法:27 - 6 = 27 + (-6)

步骤1:获取操作数的补码表示

  • 27 可以分解为 16 + 8 + 2 + 1,即 2^4 + 2^3 + 2^1 + 2^0。其6位二进制表示为 011011
  • 6 可以分解为 4 + 2,即 2^2 + 2^1。其6位二进制表示为 000110

为了得到 -6,我们对 6 的表示执行取反操作:

  1. 按位取反 000110 得到 111001
  2. 1111001 + 1 = 111010
    因此,-6 的补码表示为 111010

步骤2:执行加法运算

27-6 的补码相加:

   011011  (27)
 + 111010  (-6)
 ------------
 1 010101

注意,这里产生了一个额外的进位(第7位)。因为我们使用的是6位表示法,这个最高位的进位会被丢弃。

步骤3:解读结果

所以,最终结果是 010101。这个二进制数等于 2^4 + 2^2 + 2^0 = 16 + 4 + 1 = 21。这正是 27 - 6 的正确结果。


上一节我们完成了两个成功的运算示例,本节中我们来看看运算中一个重要的异常情况:溢出。

溢出检测 ⚠️

如果你尝试相加的两个数,其结果超出了你的N位表示法所能表示的数字范围,就意味着发生了溢出。

溢出可以通过观察被加数的符号和结果的符号来检测:

以下是判断溢出的两条规则:

  1. 正数相加得负数:如果你尝试将两个最高位为 0 的正数相加,得到的结果最高位却是 1(即结果为负数),那么发生了溢出。
  2. 负数相加得正数:如果你尝试将两个最高位为 1 的负数相加,得到的结果最高位却是 0(即结果为正数),那么同样发生了溢出。

当一个正数和一个在表示范围内的负数相加时,不会发生溢出。

溢出示例

让我们看一个例子。假设我们尝试使用6位补码计算 31 + 12

  • 31 的补码:011111
  • 12 的补码:001100

执行加法:

   011111  (31)
 + 001100  (12)
 ------------
   101011

结果 101011 的最高位是 1,这意味着结果被解释为一个负数。然而,我们相加的是两个正数。这表明溢出发生了

溢出发生的原因是,这两个数的和是 43,它大于 2^(5) - 1 = 31(这是6位补码能表示的最大正数)。


本节课中我们一起学习了二进制补码的加法运算。我们掌握了将减法转化为加法的方法,并通过逐步示例练习了计算过程。更重要的是,我们理解了溢出的概念,并学会了如何通过检查操作数和结果的符号位来检测溢出。记住,在进行固定位宽的补码运算时,始终要注意结果是否在可表示的范围内。

015:霍夫曼编码实例详解 📚

在本节课中,我们将学习霍夫曼编码,这是一种根据每个选项出现的概率来生成可变长度编码的方法。概率更高的选项将获得更短的编码。

概述

霍夫曼编码的目标是生成一种编码方案,用于表示学生选择的不同专业。本实例中共有四个专业,每个专业都有其对应的出现概率。

构建霍夫曼树

上一节我们介绍了霍夫曼编码的基本概念,本节中我们来看看如何通过具体步骤构建霍夫曼树。

第一步:合并概率最低的两个节点

构建霍夫曼编码时,首先从概率最低的两个选择开始。

在本例中,专业67的概率为0.06,专业61的概率为0.09。由于这是两个最低的概率,因此选择它们作为构建编码树的起点。

合并这两个选择后,生成的根节点概率等于两个叶节点概率之和,即0.15。然后,我们将这棵树的一侧标记为0,另一侧标记为1。

第二步:继续合并剩余的最小概率节点

接下来,在剩余的节点集合中(其中专业61和67已被概率为0.15的节点A替代),找出两个概率最小的节点。

此时,最小的概率是节点A的0.15和专业63的0.41。因此,我们创建一个合并节点A和63的新节点B。

新节点B的概率为0.56。同样,我们标记它的两个分支,一个为0,另一个为1。

第三步:完成树的构建

现在,我们重复这个过程最后一次。此时仅剩的两个选择是节点B和专业62。

这意味着我们应该创建一个合并节点B和专业62的新节点C。

注意,此节点的概率为1.0,因为我们已经到达树的顶端。

最后一步是标记这最后两个分支。

生成编码

现在所有分支都已标记,我们可以从根节点遍历到每个叶节点,以确定分配给该叶节点对应专业的编码。

以下是遍历结果:

  • 专业61的编码是1,0,1。
  • 专业62的编码是0(一个比特)。
  • 专业63的编码是1,1。
  • 专业67的编码是1,0,0。

这些编码是合理的,因为我们期望概率最高的专业(本例中的专业62)获得最短的编码。概率次高的专业63获得第二短的编码,依此类推。

霍夫曼编码的灵活性

我们刚刚看到,从这个霍夫曼编码树产生的编码是:专业61为1,0,1,专业62为0,专业63为1,1,专业67为1,0,0。

需要注意的是,这个问题的霍夫曼编码树也可以画成另一种形式。这两种树在结构上是相同的,并为四个专业产生相同的编码。

此外,霍夫曼树可以产生不止一种有效的编码。标记边的唯一约束是,从每个节点出发,必须有一个0分支和一个1分支,但没有规定哪一侧必须标记为0或1。

例如,我们本可以选择将B节点的左侧标记为1,右侧标记为0,而不是我们最初的标记方式。然而,请注意,这将导致一个不同但同样有效的霍夫曼编码。在这种情况下,专业61的编码变为1,1,1,专业62保持为0,专业63变为1,0,专业67变为1,1,0。

只要在选定的树中保持一致,就能产生有效的霍夫曼编码。

计算熵与平均比特数

现在,我们在表格中再添加一列,给出每个专业的 P * log₂(1/P)。利用这个信息,我们可以计算熵,即每条消息中包含的平均信息量。

熵的计算公式为所有专业选择的 P * log₂(1/P) 之和。对于这个问题,熵是1.6。

我们现在也可以计算已识别编码的每个专业的平均比特数。

计算方法是:将每个编码的比特数乘以该专业的概率。

以下是具体计算过程:

  • 专业61的编码为1,1,1(3比特),概率0.09。
  • 专业62的编码为0(1比特),概率0.44。
  • 专业63的编码为1,0(2比特),概率0.41。
  • 专业67的编码为1,1,0(3比特),概率0.06。

因此,每个专业的平均比特数为:
3 * 0.09 + 1 * 0.44 + 2 * 0.41 + 3 * 0.06 = 0.27 + 0.44 + 0.82 + 0.18 = 1.71

注意,这个值(1.71)略大于熵(1.6)。这是因为虽然霍夫曼编码是一种高效的编码,使我们非常接近理论极限,但由于它每次只编码一个专业,而没有考虑在一条传达大量学生所选专业的消息中看到特定专业序列的概率,因此仍然存在一些低效之处。

总结

本节课中我们一起学习了霍夫曼编码的完整构建过程。我们从概率最低的节点开始,逐步合并构建出霍夫曼树,然后通过遍历树得到每个选项的可变长度编码。我们还了解到霍夫曼编码具有灵活性,只要保证每个节点的分支标记一致,就可以产生不同的有效编码。最后,我们计算了信息熵和编码的平均比特数,理解了霍夫曼编码虽然高效,但并非绝对最优。

016:纠错实例分析 🛠️

在本节课中,我们将通过一个具体的实例来学习纠错与检错。我们将分析一种特定的编码方案,理解其如何帮助我们在数据传输过程中检测并纠正错误。

概述

我们将要学习一种包含9个数据位和7个校验位的编码方案。通过计算其汉明距离,并分析几个包含错误的示例消息,我们将掌握如何利用奇偶校验来定位和修复单比特错误,以及为何该方案无法纠正双比特错误。

编码方案介绍

消息由9个数据位和7个校验位组成。

  • 数据位:每个数据位表示为 D_IJ,其中 I 代表行号,J 代表列号。
  • 校验位:每个 P_IJ 校验位用于确保其所在行和列的奇偶性为奇数
  • 全局校验位:位于右下角的 P_XX 位用于确保整个消息的奇偶性为奇数。换句话说,它使消息中“1”的总数为奇数。

计算最小汉明距离

上一节我们介绍了编码的构成,本节中我们来看看如何计算其最小汉明距离。我们需要思考:如果改变一个数据位,编码中需要改变多少比特?

理解这一点最简单的方式是通过一个例子。假设我们想将 D_01 从0翻转为1。

  1. 首先,数据位 D_01 本身发生改变。
  2. 接着,为了保持该行(行0)的奇偶性为奇数,校验位 P_0X 需要翻转。
  3. 然后,为了保持该列(列1)的奇偶性为奇数,校验位 P_X1 需要翻转。
  4. 至此,已有三个条目被改变,这意味着整个消息的奇偶性发生了翻转。为了保持整个消息的奇偶性为奇数,全局校验位 P_XX 也需要翻转。

因此,总共需要改变4个条目。这意味着该编码的最小汉明距离等于 4,因为任何时候翻转一个数据位,都需要改变4个条目来维持编码规则。

纠错与检错能力分析

根据编码理论,我们可以推导出该编码的纠错和检错能力:

  • 检错:要检测 E 比特错误,汉明距离必须大于 E,即 汉明距离 >= E + 1
    • 要检测1比特错误,需要汉明距离 >= 2
  • 纠错:要纠正 E 比特错误,汉明距离必须大于 2E,即 汉明距离 >= 2E + 1
    • 要纠正1比特错误,需要汉明距离 >= 3

对于本编码方案,由于汉明距离为 4,这意味着它应该能够检测并纠正单比特错误

实例分析:无错误消息

让我们看一个具体的消息示例。以下是检查奇偶性的结果:

  • 所有行的奇偶性均为奇数。
  • 所有列的奇偶性均为奇数。
  • 整个消息的奇偶性为奇数。

由于奇数奇偶性表示一个有效消息,因此该消息中没有错误

实例分析:单比特错误纠正

现在,我们来看几个包含单比特错误的例子,并学习如何纠正它们。

示例一:校验位错误

以下是检查奇偶性的结果:

  • 所有行的奇偶性均为奇数。
  • 列1的奇偶性为偶数。

这意味着错误发生在列1。由于所有行的奇偶性都正确,这表明出错的比特就是列1的校验位本身。将 P_X1 从0翻转为1后,我们便再次得到了一个有效消息。

示例二:数据位错误

以下是检查奇偶性的结果:

  • 行0的奇偶性为偶数。
  • 列0的奇偶性为偶数。

这意味着位于行0、列0的数据位 D_00 是错误的。翻转这个比特后,我们看到所有行和列的奇偶性都正确了,整个消息的奇偶性也正确。这样,我们就通过定位错误比特恢复了消息。

示例三:全局校验位错误

以下是检查奇偶性的结果:

  • 所有行的奇偶性均为奇数。
  • 所有列的奇偶性均为奇数。
  • 整个消息的奇偶性为偶数。

这意味着此消息中的错误比特是全局校验位 P_XX 本身。翻转右下角的这个比特将再次得到一个有效消息。

实例分析:双比特错误检测

上一节我们成功纠正了单比特错误,本节中我们来看看当出现双比特错误时会发生什么。让我们回到最初的有效消息,并尝试纠正一个双比特错误。

假设我们翻转 D_11 为0,并翻转 D_22 为1,从而创建一个双比特错误。现在检查奇偶性:

  • 行1和行2出现奇偶性错误。
  • 列1和列2出现奇偶性错误。
  • 整个消息的奇偶性保持正确。

根据这些信息,我们知道消息传输中发生了错误,但我们无法精确识别是哪两个比特出错了。因为存在不止一种改变两个比特的方式,能得到一个具有相同奇偶校验模式的有效消息。

这证明我们可以检测双比特错误,但无法纠正它。回顾我们之前关于汉明距离的结论,这与我们的预期相符:

  • 要检测 E=2 比特错误,汉明距离必须 >= E+1 = 3
  • 要纠正 E=2 比特错误,汉明距离必须 >= 2E+1 = 5

由于本问题中的汉明距离是4,因此我们只能检测双比特错误,而无法纠正它们。

总结

本节课中我们一起学习了如何通过奇偶校验矩阵进行纠错与检错的实例分析。我们首先计算了编码的最小汉明距离为4,这决定了其能够检测并纠正单比特错误。随后,我们通过多个例子实践了如何定位和修复校验位、数据位及全局校验位的单比特错误。最后,我们分析了双比特错误的案例,验证了该编码能够检测但无法纠正此类错误,这与汉明距离理论完全一致。理解这些原理对于设计可靠的数字通信和存储系统至关重要。

017:2.2.1 信息的具体编码 🧬

在本节课中,我们将要学习如何为比特寻找一个有用的物理表示方法,这是构建能够处理信息的设备的第一步。

上一章我们讨论了如何将信息编码为比特序列。本节中,我们来看看什么构成了一个好的比特表示,换句话说,我们希望比特的物理表示具备哪些特性。

我们希望比特数量庞大。例如,我们期望随身携带数十亿比特(如音乐文件),并能在网络上访问数万亿比特以获取新闻、娱乐、社交互动和商业信息等。因此,我们希望比特体积小且成本低廉。

自然界提供了一个建议:DNA中体现的化学编码。腺嘌呤、胸腺嘧啶、鸟嘌呤和胞嘧啶等核苷酸序列形成的密码子,编码了作为生命体蓝图的遗传信息。分子尺度满足了我们对尺寸的要求。目前正在进行如何利用生命化学进行大规模有趣计算的积极研究。

我们当然希望比特在长时间内保持稳定,一旦是0,就一直是0。

罗塞塔石碑(如图所示,是其原始石碑的一部分,包含埃及国王托勒密五世的法令)制作于公元前196年,它编码的信息使得考古学家在近2000年后能够开始可靠地破译埃及象形文字。

但是,正是使石刻成为稳定信息表示的特性,使得操作这些信息变得困难。这引出了我们清单上的最后一项:我们希望比特的表示方式能让我们轻松、快速地访问、转换、组合、传输和存储它们所编码的信息。

假设我们不想随身携带黏糊糊的DNA桶或石凿,我们应该如何表示比特呢?

通过一些工程手段,我们可以利用与带电粒子相关的电现象来表示信息。带电粒子的存在会产生电势能差,我们可以将其测量为电压;带电粒子的流动可以测量为电流。我们还可以利用与带电粒子相关的电磁场的相位和频率来编码信息,后两种选择构成了无线通信的基础。

哪种电现象是最佳选择取决于预期的应用。在本课程中,我们将使用电压来表示比特。例如,我们可以选择0伏特表示比特0,1伏特表示比特1。

为了表示比特序列,我们可以使用多个电压测量值,可以来自许多不同的导线,也可以是单根导线上随时间变化的一系列电压。

使用电压表示有许多优点。电源插座提供了廉价且基本可靠的电力来源;对于移动应用,我们可以使用电池供电。一个多世纪以来,我们已经积累了关于电压和电流的大量工程知识。我们现在知道如何构建非常小的电路来存储、检测和操作电压,并且可以使这些电路在非常小的电力下运行。事实上,如果编码的信息没有变化,我们可以设计在稳态下功耗接近零的电路。

然而,基于电压的表示也存在一些挑战。电压很容易受到周围环境中变化的电磁场影响。如果我想向您传输电压编码的信息,我们需要通过导线连接。并且改变导线上的电压需要一些时间,因为带电粒子必要流动的时序由导线的电阻电容决定。在现代集成电路中,这些RC时间常数很小,但遗憾的是不为零。

对于这些挑战,我们有很好的工程解决方案,所以让我们开始吧。

本节课中,我们一起学习了比特的理想物理特性(数量多、体积小、成本低、稳定且易于操作),并重点介绍了使用电压作为比特表示方法的原因、优势(易于获取、工程知识丰富、功耗低)以及面临的挑战(易受干扰、需要物理连接、存在传输延迟)。

018:2.2.2 模拟信号 📡

在本节课中,我们将要学习如何使用电压来表示信息,特别是黑白图像中的信息。我们将探讨模拟信号表示的基本原理、其在实际系统中的局限性,以及为什么我们需要一种更可靠的信息处理方式。


考虑使用电压来表示黑白图像信息的问题。

图像中的每个 (X, Y) 点都有一个关联的强度。黑色是最弱的强度,白色是最强的强度。一个基于电压的明显表示方法是将强度编码为电压,例如,0伏代表黑色,1伏代表白色,中间强度则用介于两者之间的电压表示。

首先,图像中每个点包含多少信息?

答案取决于我们区分强度(或电压)的能力。如果我们能区分任意微小的差异,那么图像中的每个点都可能包含无限量的信息。但作为工程师,我们怀疑我们能够检测到的差异存在一个下限。

为了表示与 N 位二进制数相同的信息量,我们需要能够在 0 伏到 1 伏的范围内区分总共 2^N 个不同的电压。例如,当 N=2 时,我们需要能够区分四种可能的电压:0伏、1/3伏、2/3伏和1伏。这似乎并不困难。

理论上,N 可以任意大。但在实践中,如果我们想要达到百万分之一伏甚至十亿分之一伏的测量精度,这将极具挑战性,甚至几乎不可能。不仅设备会变得非常昂贵,测量耗时,而且热噪声等现象也会干扰我们在特定时刻对瞬时电压的定义。

因此,我们使用电压编码信息的能力,显然会受到我们可靠且快速地区分特定时刻电压的能力的限制。


上一节我们讨论了用电压表示图像信息的理论,本节中我们来看看如何将整幅图像转换为一个随时间变化的电压序列。

为了完成表示整幅图像的项目,我们将按照某种预定的光栅顺序(从左到右,从上到下)扫描图像,并将强度转换为电压。通过这种方式,我们可以将图像转换为一个随时间变化的电压序列。

这就是早期电视机的工作原理。图像被编码为一个在代表黑色和白色的电压之间变化的电压波形。实际上,电压范围被扩展,以允许信号指定水平扫描的结束和一幅图像的结束,即所谓的同步信号。我们称之为连续波形,表示它在特定时间点可以取指定范围内的任何值。

现在,让我们看看当我们尝试构建一个系统来处理这个信号时会发生什么。


我们使用两个简单的处理模块来创建一个系统。复制模块在其输出端重现其输入端的任何电压。复制模块的输出看起来与原始图像相同。反相模块在输入电压为 V 时,产生一个 1 - V 的电压。换句话说,白色被转换为黑色,反之亦然。图像通过反相模块后,我们得到输入图像的负片。

为什么要使用处理模块?使用预封装模块是构建大型电路的常用方法。我们可以通过将一个模块连接到另一个模块来组装系统,并在无需理解每个模块内部细节的情况下,推理出最终系统的行为。模块提供的预封装功能使其易于使用,无需成为模拟电路专家。

此外,我们期望能够在构建不同系统时以不同配置连接模块,并能够根据每个模块的行为预测每个系统的行为。这将允许我们像搭积木一样,通过将一个模块连接到另一个模块来简单地构建系统。即使是不懂电路细节的程序员,也可以期望构建出执行特定处理任务的系统。整个理念的核心在于可预测行为的保证。如果组件正常工作,并且我们按照连接模块的规则进行连接,我们期望系统能按预期工作。


所以,让我们用复制和反相模块构建一个系统。下图是一个使用多个这两种模块实例的图像处理系统。我们期望输出图像看起来是什么样子?理论上,复制模块不会改变图像,而反相模块的数量是偶数个,因此输出图像应该与输入图像完全相同。

但在现实中,输出图像并非输入的完美副本,它略显模糊。强度略有偏差,并且看起来强度的急剧变化被平滑了,产生了原始图像的模糊再现。哪里出了问题?


为什么理论与现实不符?也许复制和反相模块不能正确工作。从它们不完全遵守其行为的数学描述这个意义上说,这几乎肯定是正确的。微小的制造差异和不同的环境条件会导致每个复制模块实例在输入 V 伏时,输出不是 V 伏,而是 V + ε 伏,其中 ε 代表处理过程中引入的误差量。反相模块也是如此。

困难在于,在我们这种强度的连续值表示中,V + ε 本身就是一个完全正确的输出值,只是它不对应于 V 伏的输入。换句话说,我们无法区分一个轻微损坏的信号和一个对应于略微不同图像的完全有效信号。

更重要的是,这也是真正致命的问题:误差会随着编码图像通过由复制和反相模块组成的系统而累积。系统越大,累积的处理误差就越大。这似乎不太理想。如果我们不得不规定在编码信息上可以执行多少次计算,然后结果才会因损坏过多而无法使用,这至少会非常尴尬。

如果你认为这意味着我们用来描述系统操作的理论是不完美的,那么你是正确的。我们确实需要一个非常复杂的理论来捕捉输出信号可能偏离其预期值的所有可能方式。那些具有数学思维的人可能会抱怨现实是不完美的。但这有点过了。现实就是现实。作为工程师,我们需要构建能够在现实世界中可靠运行的系统。

因此,真正的问题可能在于我们如何选择设计系统。事实上,以上所有情况都是真实的:噪声和不精确是不可避免的。我们无法可靠地再现无限的信息。如果我们的系统要可靠地处理信息,我们必须设计它能够容忍一定量的误差。基本上,我们需要找到一种方法,来注意到处理步骤引入了误差,并在误差有机会累积之前恢复正确的值

如何做到这一点,将是我们下一个主题的内容。


本节课中,我们一起学习了如何使用模拟电压信号表示图像信息。我们探讨了模拟表示的无限信息潜力及其在实际中受到测量精度和噪声限制的现实。通过构建一个简单的图像处理系统,我们发现了模拟信号处理中误差累积的根本问题,这引出了对更可靠信息表示和处理方法的需求。下一节,我们将探讨解决这一问题的关键思路。

019:使用电压进行数字表示 📊

在本节课中,我们将学习如何利用连续的电压世界来表示离散的数字信息,这是构建数字系统的核心工程思想。我们将探讨几种电压表示方案,并最终找到一个既实用又可靠的解决方案。

数字抽象的概念

为了解决我们的工程问题,我们将引入所谓的“数字抽象”。

关键思路是利用连续的电压世界来表示一个小的、有限的数值集合。在我们的案例中,就是两个二进制值:01

需要记住的是,世界本身并非天生就是数字化的。我们只是希望通过工程手段,让它表现得像数字世界一样,即利用连续的物理现象来实现数字设计。

从连续到离散的挑战

作为一个简短的补充,需要提及的是,存在一些物理现象本质上是数字化的。换句话说,它们被观察到具有几个量子化的值之一。例如,电子的自旋。

这对经典物理学家来说是一个意外,他们曾认为物理量的测量是连续的。量子理论的发展,用于描述某些原子粒子所经历的有限自由度,彻底改变了经典物理学的世界。我们现在才开始研究如何将量子物理学应用于计算,并且在构建量子计算机方面有了一些有趣的进展报告。

但对于本课程,我们将专注于如何利用经典的连续现象来创建数字系统。

第一次尝试:单一阈值方案

使用电压进行数字表示的关键思想是建立一个信号约定,每次只编码一位信息。换句话说,两个值(0或1)中的任何一个,都将使用我们数字系统中每个组件和导线的统一表示方式。

我们将通过三次尝试来得出一个能解决所有问题的电压表示方案。

我们的第一次尝试是显而易见的方案:简单地将电压范围划分为两个子范围,一个范围代表0,另一个代表1。

选取某个阈值电压 V_TH 将范围一分为二。

当电压 v 小于阈值电压时,我们将其视为代表比特值 0

当电压 v 大于或等于阈值电压时,它将代表比特值 1

这种表示法为所有可能的电压分配了一个数字值。

这个定义的问题部分在于难以解释接近阈值的电压。

给定一个特定电压的数值,很容易应用规则并得出相应的数字值。但是,随着电压越来越接近阈值,准确确定正确的数值会变得更加耗时且昂贵。所涉及的电路必须由精密元件制成,并在精确控制的物理环境中运行。考虑到我们想要构建的系统所处的多种环境以及适中的成本预期,这很难实现。

因此,尽管这个定义具有吸引人的数学简洁性,但在实际应用上是不可行的。这个方案得到了一个大大的红色叉号。

第二次尝试:引入禁止区

在第二次尝试中,我们将引入两个阈值电压:V_LV_H

电压小于或等于 V_L 将被解释为 0,电压大于或等于 V_H 将被解释为 1

V_LV_H 之间的电压范围被称为“禁止区”,在这个区域内,我们被禁止要求数字系统有任何特定的行为。

一个特定的系统可以将禁止区内的电压解释为 01,甚至不需要在其解释上保持一致。事实上,系统甚至不需要对这个范围内的电压产生任何解释。

这有什么帮助呢?现在,我们可以构建一个快速且不那么精确的电压到比特转换器,例如,使用一个高增益运算放大器和一个位于禁止区某处的参考电压,来判断给定电压是高于还是低于阈值电压。

这个参考电压不需要超级精确,因此可以用低成本、精度为10%的电阻构建的分压器来生成。参考电压可能会随着工作温度变化或电源电压变化等而略有改变。我们只需要保证转换器对于低于 V_L 或高于 V_H 的电压有正确的行为。

这种表示法非常有前景,目前我们暂且给它一个绿色的对勾。经过更多讨论后,在我们达到目标之前,还需要再做一个小小的调整。

总结

本节课中,我们一起学习了“数字抽象”的核心思想,即如何利用连续的电压来表示离散的二进制值。我们分析了使用单一阈值的简单方案在实际中的缺陷,并引入了带有“禁止区”的双阈值方案,该方案通过放宽对中间电压的精确要求,为构建低成本、高可靠性的数字系统提供了可行的工程路径。

020:组合逻辑器件 🧩

在本节课中,我们将学习组合逻辑器件的精确定义,以及如何通过组合规则将多个小型组合器件连接起来,构建出更大、更复杂的数字系统。


组合逻辑器件的定义

上一节我们讨论了数字信号,现在我们可以定义什么是数字处理元件。

我们说一个器件是组合逻辑器件,当且仅当它满足以下四个标准:

  1. 数字输入:器件使用我们的信号约定,将低于 ViL 的输入电压解释为数字值 0,将高于 ViH 的输入电压解释为数字值 1
  2. 数字输出:器件通过产生小于等于 VL 的电压来输出 0,通过产生大于等于 VH 的电压来输出 1
  3. 功能规范:器件必须有一个详细的功能规范,说明对于输入数字值的每一种可能组合,每个输出对应的值。
  4. 时序规范:器件必须有一个时序规范,告诉我们器件的输出反映其输入值变化需要多长时间。至少必须指定一个称为 TPD(传播延迟)的上限时间。

我们将这四个标准统称为静态规范,所有组合逻辑器件都必须满足它。


组合规则

为了用组合逻辑元件构建更大的组合系统,我们需要遵循以下组合规则:

以下是构建组合系统时必须遵守的三条核心规则:

  1. 每个系统组件本身必须是组合逻辑器件
  2. 每个组件的每个输入必须连接到:系统输入、或另一个器件的恰好一个输出、或代表值 0 或值 1 的恒定电压。
  3. 互连的组件不能包含任何有向环。换句话说,从系统输入到输出的任何路径中,一个特定的组件最多被访问一次。

我们的主张是:使用这些组合规则构建的系统本身也将是组合逻辑器件。我们可以用组合逻辑组件构建任意大小的组合逻辑器件,并且可以预期它仍然遵守静态规范。


为什么组合规则有效?

为了理解为什么这个主张成立,让我们考虑一个由组合逻辑器件 A、B 和 C 构建的系统。我们将通过证明整个系统确实遵守静态规范,来证明它本身是组合逻辑的。

  1. 系统有数字输入吗? 是的。系统的输入是某些组件器件的输入。由于组件是组合逻辑的,因此具有数字输入,所以整个系统继承了其组件的属性,也具有数字输入。
  2. 系统有数字输出吗? 是的。同理,系统的所有输出都连接到某个组件,而组件是组合逻辑的,因此输出是数字的。
  3. 我们能推导出系统的功能规范吗? 是的。我们可以通过组件模块逐步传播当前输入值的信息。由于电路中没有环路,我们可以根据电路拓扑确定的顺序,通过评估组合组件的行为来确定每个内部信号的值。
  4. 我们能推导出系统的传播延迟 TPD 吗? 是的。由于没有环路,我们可以枚举从系统输入到系统输出的所有有限长度路径。然后,我们可以通过累加路径上各组件的 TPD 来计算特定路径的 TPD。整个系统的 TPD 将是所有可能输入到输出路径中 TPD 的最大值,即最长路径的 TPD

因此,整个系统确实遵守静态规范,它本身就是一个组合逻辑器件。这非常巧妙,意味着我们可以使用组合规则构建任意复杂度的组合逻辑器件。


总结

本节课中,我们一起学习了组合逻辑器件的精确定义,它必须满足数字输入、数字输出、功能规范和时序规范这四项静态规范。我们还学习了构建更大组合系统的三条组合规则,并通过逻辑推导证明了遵循这些规则构建的系统本身也是组合逻辑器件,从而确保了数字系统设计的可靠性和可扩展性。

021:处理噪声 🛡️

在本节课中,我们将要学习数字系统中一个关键概念:如何处理无处不在的电气噪声。我们将看到,通过精心设计信号规范,可以为系统提供“噪声容限”,从而确保数字信号的可靠传输。

在上一节我们介绍了数字信号的基本规范,本节中我们来看看如何增强其鲁棒性以应对噪声。

噪声带来的问题

考虑一个组合系统,左侧的上游组合器件试图向右侧的下游组合器件发送一个数字0。

上游器件产生的输出电压略低于 VL。根据我们之前提出的信号规范,这符合数字0的表示。

现在,假设一些电气噪声轻微改变了导线上的电压,导致下游器件输入端检测到的电压略高于 VL。换句话说,接收到的信号不再符合有效的数字输入规范。

下游器件的组合行为因此无法得到保证。系统由于微小的电气噪声而产生了错误行为。这正是我们希望通过采用数字系统架构来避免的那种不稳定行为。

解决方案:调整信号规范

解决此问题的一种方法是调整信号规范,使输出信号必须遵守比输入信号更严格的界限。

其核心思想是确保有效的输出信号在受到噪声影响后,不会变成无效的输入信号。

我们能否通过完全避免噪声来彻底解决这个问题?这是一个美好的想法,但如果我们计划使用电子元件,这是无法实现的目标。

噪声的来源

电压噪声(我们将其定义为偏离标称电压值的变化)有多种来源。

以下是噪声的主要来源:

  • 电气效应:例如,根据欧姆定律(V = I × R),导体上的IR压降;导体之间的电容耦合。
  • 制造差异:元件参数与其标称值之间的制造差异,导致器件间电气行为的微小不同。
  • 环境因素:例如热噪声,或外部电磁场引起的电压效应。

这个列表还可以继续。请注意,在许多情况下,噪声是由电路的正常操作引起的,或者是制造电路所用材料和工艺的固有特性,因此是不可避免的。

然而,我们可以预测噪声的幅度,并相应地调整我们的信号规范。

实施:分离的输入/输出规范

让我们看看如何实施这个方案。

我们对噪声问题的修正方案是为数字输入和数字输出提供独立的信号规范。

  • 要发送数字0,数字输出必须产生小于或等于 VOL 的电压。
  • 要发送数字1,数字输出必须产生大于或等于 VOH 的电压。

到目前为止,这似乎与我们之前的信号规范没有太大不同。区别在于,数字输入必须遵守不同的信号规范。

  • 小于或等于 VIL 的输入电压必须被解释为数字0。
  • 大于或等于 VIH 的输入电压必须被解释为数字1。

这四个信号阈值的取值需满足以下约束条件:

V_OL < V_IL
V_IH < V_OH

请注意,VIL 严格大于 VOL,而 VIH 严格小于 VOH

噪声容限

输入和输出电压阈值之间的间隙称为噪声容限

噪声容限告诉我们,一个有效的0或1输出信号可以承受多大的噪声,而其连接到的输入端仍能正确解读该信号。

两个噪声容限中较小的那个,称为该信号规范的抗噪声度

作为数字工程师,我们的目标是设计信号规范,以提供尽可能高的抗噪声度。

遵守此信号规范的组合器件,会在噪声有机会累积并最终导致信号错误之前,消除其输入端的噪声。

总结

本节课中我们一起学习了数字系统如何处理噪声。关键在于设计具有噪声容限的信号规范,即输出信号的电压范围(VOL, VOH)比输入信号的识别范围(VIL, VIH)更严格。这样,即使信号在传输中受到一定干扰,只要干扰幅度不超过噪声容限,接收端仍能正确识别。这确保了数字信号传输的可靠性,避免了早期模拟信号示例中出现的问题。

022:2.2.6 电压传输特性 📈

在本节课中,我们将学习如何通过测量一个简单的组合器件——缓冲器——来理解其电压传输特性。我们将探讨静态纪律如何约束其行为,并了解为什么构建组合逻辑电路需要非线性器件。


让我们使用一个最简单的组合器件——缓冲器——来进行一些测量。

缓冲器有一个输入和一个输出。在经过一个很小的传播延迟后,其输出将驱动为与输入相同的数字值。

这个缓冲器遵守静态纪律。这是组合器件的核心要求。

它使用了一个修订后的信号规范,该规范包含了低噪声容限和高噪声容限。

测量将通过将输入电压设置为从零伏到电源电压的一系列值来完成。

在将输入电压设置为特定值后,我们会等待输出电压稳定下来。换句话说,我们会等待缓冲器的传播延迟。

我们将结果绘制在一张图表上,横轴为输入电压,纵轴为测得的输出电压。得到的曲线被称为缓冲器的电压传输特性。

为了方便起见,我们在两个坐标轴上标记了信号阈值。

在开始绘制点之前,请注意,静态纪律约束了任何组合器件的电压传输特性必须呈现的样子。

如果我们等待器件的传播延迟,那么当输入电压是有效的数字值时,测得的输出电压也必须是一个有效的数字值。有效输入,有效输出。

我们可以用图表上的阴影禁止区域来图形化地展示这一点。这些区域中的点对应着有效的数字输入电压,但却是无效的数字输出电压。因此,对于一个合法的组合器件,其电压传输特性上的点都不会落在这两个区域内。

好的,回到我们的缓冲器。

将输入电压设置为低于低输入阈值 V_IL 的值,会产生一个低于 V_OL 的输出电压,正如预期的那样。数字零输入产生数字零输出。

尝试一个稍高但仍有效的零输入,会得到类似的结果。

请注意,这些测量并没有告诉我们任何关于缓冲器速度的信息。它们只是测量器件的静态行为,而不是动态行为。

如果我们继续进行所有额外的测量,就会得到缓冲器的电压传输特性,如图中黑色曲线所示。

请注意,该曲线没有穿过阴影区域,符合我们上面设定的合法组合器件行为的预期。

关于电压传输特性,有两个有趣的观察结果。

让我们更仔细地观察图表中心的白色区域,该区域对应输入电压在 V_ILVIH 的范围内。

首先,请注意这些输入电压处于我们信号规范的禁止区内,因此组合器件可以产生任何它想要的输出电压,并且仍然遵守静态纪律,因为静态纪律只约束器件在有效输入下的行为。

其次,请注意由四个电压阈值限定的中心白色区域,其高度大于宽度。这是因为我们的信号规范具有正的噪声容限。所以,V_OH - V_OL 严格大于 VIH - V_IL

任何穿过此区域的电压传输特性曲线,其某部分的斜率绝对值必须大于1。

在电压传输特性曲线斜率绝对值大于1的点上,请注意输入电压的微小变化会导致输出电压发生更大的变化。这就是斜率绝对值大于1的含义。

在电气术语中,我们会说该器件的增益大于1或小于-1,其中我们将增益定义为给定输入电压变化下的输出电压变化。

如果我们考虑用组合元件构建更大的电路,任何输出都可能连接到其他输入。这意味着横轴的范围 V_in 必须与纵轴的范围 V_out 相同。换句话说,电压传输特性图必须是正方形的,并且电压传输特性曲线必须位于这个正方形内。

为了适应正方形的边界,电压传输特性曲线必须在某处改变斜率,因为我们从上面知道,必须存在斜率绝对值大于1的区域,并且它不可能在整个输入范围内都大于1。

在其工作范围内表现出增益变化的器件被称为非线性器件。

综上所述,这些观察告诉我们,不能仅使用线性器件(如电阻、电容和电感)来构建组合器件。我们需要增益大于1的非线性器件。

寻找这样的器件是下一章的主题。


本节课中,我们一起学习了如何通过测量缓冲器来绘制和理解电压传输特性。我们明确了静态纪律对组合器件输入输出关系的约束,并认识到构建可靠的数字电路需要具有增益的非线性器件,因为线性器件无法满足信号规范中关于噪声容限和电压摆幅的要求。

023:2.2.7 VTC 实例分析 📈

在本节课中,我们将通过一个具体的实例,学习如何分析一个器件的电压传输特性,并判断它是否可以作为组合逻辑反相器使用。

概述

我们将分析一个特定器件的电压传输特性图,并尝试为其选择合适的电压阈值,以使其满足组合逻辑器件的要求。


让我们来看一个具体的例子。下图展示了一个特定器件的电压传输特性。

我们想知道,是否可以将这个器件用作组合逻辑反相器。


换句话说,我们能否为电压阈值 VOLVILVIHVOH 选择合适的值,使得图中所示的电压传输特性满足组合逻辑器件的约束条件?


一个反相器在其输入为数字0时输出数字1,反之亦然。

实际上,当这个器件的输入电压较低时,它确实能产生一个高输出电压,因此它有可能满足要求。


该器件产生的最低输出电压是 0.5 伏特。因此,如果该器件要产生一个合法的数字输出 0,我们必须选择 VOL 至少为 0.5 伏特。

我们希望当反相器的输入是有效的数字1时,它能产生一个有效的数字0。


观察电压传输特性图,我们发现如果输入电压高于 3 伏特,输出电压将小于或等于 VOL。因此,让我们将 VIH 设定为 3 伏特。


我们可以将其设定为高于 3 伏特的值,但我们会将其设定得尽可能低,以便为较大的高电平噪声容限留出空间。


这样,我们就确定了四个信号阈值中的两个:VOLVIH

另外两个阈值通过噪声容限 N 与这两个阈值相关联,如下面两个公式所示:

VIL = VOL + N
VOH = VIH + N

我们能否找到一个 N 值,使得当 Vin 小于或等于 VIL 时,Vout 大于或等于 VOH

如果我们选择 N 为 0.5 伏特,那么根据公式可以得出 VIL 为 1 伏特,VOH 为 3.5 伏特。

将这些阈值绘制在图上,并添加上禁止区域,我们高兴地发现,该电压传输特性实际上是合法的。


因此,如果我们使用以下信号规范:VOL 为 0.5 伏特,VIL 为 1 伏特,VIH 为 3 伏特,VOH 为 3.5 伏特,那么我们就可以将这个器件用作组合逻辑反相器。


一切准备就绪。


总结

本节课中,我们一起学习了如何通过分析电压传输特性图,为一个器件选择合适的电压阈值。我们确定了 VOLVILVIHVOH 的值,并验证了它们满足组合逻辑反相器的要求,最终得出结论:该器件可以在指定的信号规范下作为反相器使用。

024:2.2.8 静态约束实例分析

在本节课中,我们将学习如何判断一个电路的电压规格是否满足“静态约束”。静态约束是确保数字电路能够可靠级联工作的关键规则。

静态约束的核心要求

为了满足静态约束,一个电路产生的输出信号质量必须优于可接受的输入信号质量。这确保了当多个门电路(例如,一个缓冲器后接另一个缓冲器)级联时,即使前一级门电路引入了少量噪声,后一级门的输入也始终是有效的。

更具体地说,要满足静态约束,必须满足以下条件:

  • 一个有效的低电平输出必须比有效的低电平输入“更低”。即:V_OL < V_IL
  • 一个有效的高电平输出必须比有效的高电平输入“更高”。即:V_OH > V_IH

综合起来,我们得到:V_OL < V_IL <= V_IH < V_OH。另一种理解方式是观察有效输入的范围(橙色和绿色箭头所示),它比有效输出的范围更宽。图中还显示了噪声容限,它对应着有效输入但非有效输出的区域。

有效输入与输出的定义

正如之前所述:

  • 一个有效输入要么是低电平(V_in < V_IL),要么是高电平(V_in > V_IH)。
  • 一个有效输出要么是低电平(V_out < V_OL),要么是高电平(V_out > V_OH)。

问题:判断规格是否满足静态约束

在这个问题中,我们希望判断规格1、2和3(它们都提供0.3伏的噪声容限)在给定的电压传输曲线下是否满足静态约束。

对于每个规格,我们需要检查以下两个约束条件:

  1. 是否满足 V_OL < V_IL <= V_IH < V_OH?满足此约束保证了输出信号的质量优于输入。
  2. 一个有效输入是否总是产生一个有效输出?由于此曲线显示了一个反相功能,这具体转化为:
    • A. 一个有效的低输入(V_in < V_IL)是否总是产生一个有效的高输出(V_out > V_OH)?
    • B. 一个有效的高输入(V_in > V_IH)是否总是产生一个有效的低输出(V_out < V_OL)?

如果所有约束都满足,则该规格遵守静态约束;否则,不遵守。

对于所有三个规格,我们都可以看到确实满足 V_OL < V_IL <= V_IH < V_OH。因此,第一个约束对三个规格都成立。

现在,让我们检查第二个约束。

规格一分析

  • 如果 V_in < V_IL (即 0.4V),则 V_out = 5V,它大于 V_OH (4.9V)。因此,有效的低输入产生了有效的高输出。
  • 如果 V_in > V_IH (即 4.6V),则 V_out = 0V,它小于 V_OL (0.1V)。因此,有效的高输入产生了有效的低输出。

由于所有约束都满足,规格一满足静态约束

规格二分析

  • 如果 V_in < 0.9V,则 V_out >= 4V,它并不大于 V_OH (4.4V)。因此,有效的低输入未能产生有效的高输出。

所以,规格二不满足静态约束

规格三分析

  • 如果 V_in < 1.4V,则 V_out >= 4V,在这种情况下它大于 V_OH (3.9V)。因此,约束的第一部分成立。
  • 现在检查有效高输入的情况:如果 V_in > 3.6V,则 V_out <= 1V,它小于 V_OL (1.1V)。因此,约束的这一部分也成立。

由于所有约束都满足,这意味着规格三也满足静态约束

总结

本节课中,我们一起学习了静态约束的具体应用。我们通过分析三个具体规格的实例,掌握了判断电路是否满足静态约束的两步法:首先检查电压阈值是否满足 V_OL < V_IL <= V_IH < V_OH 的关系,然后验证在给定的电压传输曲线下,所有有效输入是否都能产生对应的有效输出。只有同时满足这两个条件,电路才能可靠地级联工作。

025:3.2.1 MOSFET物理结构 🔬

在本节课中,我们将学习组合逻辑器件的理想特性,并深入探讨一种能实现这些特性的关键技术——金属氧化物半导体场效应晶体管(MOSFET)的物理结构。

上一节我们介绍了组合逻辑器件的电压传输特性。本节中,我们来看看实现这些特性所需的具体电路技术。

组合逻辑器件的理想特性回顾

首先,让我们回顾一下对组合逻辑器件特性的期望清单。

在之前的课程中,我们努力开发了一种基于电压的信息表示方法,这种方法能够在信息流经处理元件系统时容忍一定程度的误差。

我们指定了四个信号阈值:

  • VOLVOH 分别设定了组合器件输出端用于表示逻辑0和逻辑1的电压上限和下限。
  • VILVIH 则用于类似地解释组合器件输入端的电压。

我们规定 VOL 必须严格小于 VIL,并将这两个低电平阈值之间的差值称为低电平噪声容限。这是指可以添加到输出信号中,但仍能在任何连接的输入端被正确解读的噪声量。

出于同样的原因,我们规定 VIH 必须严格小于 VOH

当我们查看电压传输特性(VTC)时,我们看到了包含噪声容限的影响。VTC是组合器件输出电压(V_out)相对于输入电压(V_in)的曲线图。

由于组合器件在稳态下,必须在给定有效输入电压时产生有效的输出电压,我们可以在VTC中识别出禁止区域。这些区域对应有效的输入电压,但标识出无效的输出电压范围。一个合法的组合器件的VTC不能有任何点落入这些区域。

由四个阈值电压界定的中心区域,其宽度小于高度。因此,任何合法的VTC都必须有一个增益大于1的区域,并且整个VTC必须是非线性的。这里展示的VTC是一个作为反相器的组合器件的特性。

如果我们有幸使用一种提供高增益、且输出电压接近地电位和电源电压的电路技术,我们就可以将VOL和VOH向外推向电源轨,同时将VIL和VIH向内推,从而带来增加噪声容限的好结果——这总是一件好事。

回想第2讲的开头,我们的数字系统将需要数十亿个器件,因此每个器件都必须非常便宜且小巧。在当今的移动世界中,系统能够依靠电池长时间运行,这意味着我们希望系统的功耗尽可能低。当然,处理信息必然需要改变系统内的电压,这会消耗一定的功率。但如果系统空闲且内部电压没有变化,我们希望系统的功耗为零。最后,我们希望实现具有有用功能的系统,因此需要开发我们想要执行的逻辑运算的目录。

非常了不起的是,有一种电路技术将让我们的愿望成真。这项技术就是本讲的主题。

核心器件:MOSFET

我们讨论的主角是金属氧化物半导体场效应晶体管,简称MOSFET。

下图是一个MOSFET的3D剖面图,它由复杂的电气材料夹层构成,是集成电路的一部分。之所以称为集成电路,是因为其中的各个器件是在一系列制造步骤中全部批量制造的。

在现代工艺中,图中所示模块的边长仅为几十纳米。这大约是人类一根细头发丝厚度的十分之一。这个尺寸非常小,以至于无法使用普通光学显微镜观察,因为其分辨率受限于可见光的波长(400至750纳米)。多年来,工程师们能够大约每24个月将器件尺寸缩小一半,这一观察结果被称为摩尔定律,以英特尔创始人之一戈登·摩尔的名字命名,他于1965年首次指出了这一趋势。

每次尺寸缩小50%,就使得集成电路制造商能够在相同面积上制造出四倍于以前的器件。正如我们将看到的,器件本身也会变得更快。

1975年的集成电路可能只有2500个器件。今天,我们能够制造包含20到30亿个器件的IC。

以下是图中内容的快速导览:

  • 构建IC的基底是一片薄的硅晶体晶圆,其中添加了杂质以使其导电。在本例中,杂质是像硼这样的受主原子。我们将这种掺杂硅表征为P型半导体
  • IC将包括一个与P型基底的电接触点,称为体端,以便在需要时控制其电压。
  • 为了在导电材料之间提供电绝缘,我们使用一层二氧化硅。通常,绝缘层的厚度并不特别重要,除非它用于隔离晶体管的栅极(图中红色部分)与基底。该区域的绝缘层非常薄,以便栅极导体上的电荷产生的电场能够轻易地影响基底。
  • 晶体管的栅极端是一个导体。在本例中是多晶硅。栅极、薄氧化层绝缘层和P型基底形成了一个电容器,改变栅极上的电压将导致栅极正下方的P型基底发生电学变化。
  • 在早期的制造工艺中,栅极端由金属制成,术语“金属氧化物半导体”(MOS)指的就是这种特定结构。
  • 栅极端就位后,像磷这样的施主原子被注入到栅极两侧的两个矩形区域的P型基底中。这将那些区域改变为N型半导体,成为MOSFET的另外两个终端,称为源极漏极

请注意,源极和漏极在物理上是相同的,它们通过器件工作期间所扮演的角色来区分。正如我们将在下一张幻灯片中看到的,MOSFET的功能是一个电压控制开关,连接器件的源极和漏极端。当开关导通时,电流将从漏极流向源极,流经作为栅极电容器第二极板形成的导电沟道。

MOSFET的关键参数

MOSFET有两个关键尺寸:

  • 长度 L,测量电流从漏极流向源极必须跨越的距离。
  • 宽度 W,决定了可用于传导电流的沟道量。

流过开关的电流(称为 I_DS)与沟道宽度与其长度的比值成正比。

通常,IC设计者使长度尽可能短。当新闻报道提到14纳米工艺时,14纳米指的是允许的最小沟道长度值。设计者选择沟道宽度来设定所需的电流量。如果I_DS很大,源极和漏极节点上的电压转换将很快,代价是器件物理尺寸更大。

以下是MOSFET特性的总结:

  • MOSFET有四个电极端:体端、栅极、源极和漏极
  • 器件的两个尺寸由设计者控制:沟道长度(通常选择尽可能小)和沟道宽度(选择以将电流设定为所需值)。
  • 它是一个固态开关,没有活动部件,开关操作由四个端子的相对电压决定的电场控制。

本节课中我们一起学习了组合逻辑器件的理想特性,并详细介绍了MOSFET的物理结构、制造工艺及其关键尺寸参数。MOSFET作为一种电压控制的固态开关,是实现现代数字系统高集成度、低功耗和高性能目标的核心基础元件。

026:电气视角

在本节课程中,我们将学习金属氧化物半导体场效应晶体管(MOSFET)的电气工作原理。我们将了解其四个端子的电压如何控制开关的导通与关断,并学习如何解读其电流-电压特性曲线。

概述

上一节我们从物理结构的角度认识了MOSFET。本节中,我们将从电气视角来分析MOSFET,理解其作为电压控制开关的具体行为。我们将重点关注其阈值电压、导通条件以及电流-电压特性。

电气工作原理

MOSFET的操作由其四个端子的电压决定。

首先,我们标记栅极两侧的两个扩散区端子。按照惯例,我们将电位较高的扩散区端子称为漏极,电位较低的称为源极。在这种标记下,如果有电流流过MOSFET开关,电流将从漏极流向源极。

MOSFET在制造时被设计为具有一个特定的阈值电压VTH,它决定了开关从关断(开路)状态转变为导通(闭合)状态的时刻。对于图中所示的N沟道MOSFET,在现代工艺中,VTH大约为0.5伏特。

图中左侧的P+端子是连接到P型衬底的。为了使MOSFET正常工作,衬底的电压必须始终小于或等于源极和漏极的电压。后续会有关于如何连接此端子的具体规则。

MOSFET由栅极电压Vg与源极电压Vs之间的差值控制,我们称之为Vgs(即Vg - Vs)。

关断状态(Vgs < VTH)

当Vgs小于MOSFET的阈值电压时,开关处于开路或非导通状态。换句话说,源极和漏极之间没有电气连接。

当N型和P型材料物理接触时,在它们的结处会形成一个耗尽区(图中深红色区域)。这是一个载流子从结处迁移走的衬底区域。耗尽区充当了衬底与源/漏极之间的绝缘层。当源/漏极相对于衬底的电压增大时,这个绝缘层的宽度会增加。如图所示,这个绝缘层填充了源极和漏极端子之间的衬底区域,使它们保持电气隔离。

导通状态(Vgs > VTH)

随着Vgs增大,正电荷在栅极导体上积累并产生电场,吸引衬底原子中的电子。当电场强度达到阈值电压VTH时,电场足够强,能将衬底电子从价带拉到导带。这些新获得移动能力的电子会向栅极导体移动,聚集在作为栅极电容器绝缘体的薄氧化物下方。

当积累足够多的电子时,该区域的类型就从P型转变为N型。现在,一个N型材料的沟道在源极和漏极端子之间形成了一条导电通路。这层N型材料被称为反型层,因为它的类型已从原始的P型材料反转。

此时,MOSFET开关闭合或导通。电流将从漏极流向源极,其大小与Vds(漏极和源极端子之间的电压差)成正比。此时,导电的反型层就像一个遵循欧姆定律的电阻,因此Ids = Vds / R,其中R是沟道的有效电阻。

这个过程是可逆的。如果Vgs下降到阈值电压以下,衬底电子会落回价带,反型层消失,开关不再导通。

大Vds情况(Vds > Vgs)

当Vds大于Vgs时(如底部图所示),情况会变得更复杂。大的Vds改变了沟道中电场的几何形状,导致反型层在靠近漏极的沟道末端被夹断。但在大Vds下,电子会隧穿通过夹断点,到达仍然存在于源极端子附近的导电反型层。

为了了解夹断如何影响从漏极流向源极的电流Ids,让我们看看下一张幻灯片上的Ids曲线图。

电流-电压特性曲线分析

这张图包含大量信息,我们来解读一下。

每条曲线都是在特定Vgs值下,Ids随Vds变化的函数图。

首先,请注意当Vgs小于或等于阈值电压时,Ids为0。前六条曲线都重叠绘制在X轴上。

一旦Vgs超过阈值电压,Ids变为非零,并随着Vgs的增加而增加。这很合理:Vgs越大,被吸引到栅极电容器下极板的衬底电子就越多,反型层就越厚,从而允许通过更大的电流。

当Vds小于Vgs时,我们看到MOSFET表现得像一个遵循欧姆定律的电阻。这体现在曲线左侧的线性部分。曲线线性部分的斜率基本上与导电MOSFET沟道的电阻成反比。随着Vgs增加导致沟道变厚,电流增大,直线斜率变得更陡,表明沟道电阻变小。

但是,当Vds变得大于Vgs时,沟道在漏极端被夹断。正如我们在Ids曲线右侧所看到的,电流不再随Vds的增加而增加。相反,Ids近似恒定,曲线变成一条水平线。我们说MOSFET达到了饱和,此时Ids达到了某个最大值。

请注意,Ids曲线的饱和部分并非完全平坦,随着Vds增大,Ids会继续轻微增加。这种效应称为沟道长度调制,反映了沟道夹断的增加与更大Vds引起的电流增加并不完全匹配的事实。

N沟道与P沟道MOSFET

到目前为止,我们讨论的都是如图左所示、在P型衬底中具有N型源漏扩散区的MOSFET。这被称为N沟道MOSFET,因为形成的反型层是N型半导体。N沟道MOSFET的电路符号如图所示,四个端子按此方式排列。在我们的MOSFET电路中,我们会将MOSFET的体端连接到地,这将确保P型衬底的电压始终小于或等于源漏扩散区的电压。

我们也可以通过翻转所有材料类型来构建MOSFET,即在N型衬底中创建P型源漏扩散区。这被称为P沟道MOSFET,它同样表现为一个电压控制开关,只是所有电压极性都相反。正如我们将看到的,导致N沟道开关导通的控 制电压会导致P沟道开关关断,反之亦然。

使用两种类型的MOSFET将为我们提供行为互补的开关。因此,对于同时使用两种类型MOSFET的电路,我们称之为互补金属氧化物半导体,简称CMOS

总结

本节课中,我们一起学习了MOSFET的电气视角。我们了解到MOSFET是一个由栅源电压Vgs控制的开关:当Vgs低于阈值电压VTH时,开关关断;当Vgs高于VTH时,开关导通,形成反型层沟道。我们还分析了其电流-电压特性曲线,区分了线性区(电阻特性)和饱和区(恒流特性)。最后,我们介绍了N沟道和P沟道MOSFET的区别,以及它们将如何以互补的方式用于构建CMOS电路。

现在我们已经有了两种类型的电压控制开关,我们接下来的任务是弄清楚如何使用它们来构建用于处理以电压编码的信息的有用电路。

027:3.2.3 CMOS 构建方法 🧱

在本节课程中,我们将学习如何使用 MOSFET 来构建处理数字信息的电路。我们将介绍构建 CMOS 电路的两条核心规则,并以此为基础,分析一个基本的 CMOS 反相器是如何工作的。


上一节我们了解了 MOSFET 的基本功能,本节中我们来看看如何利用它们来构建数字逻辑电路。

我们遵循两条简单的规则来构建电路。遵循这些规则,我们就可以将 MOSFET 抽象为一个简单的电压控制开关。

第一条规则是,我们只使用 N 沟道 MOSFET(简称 NFET)。

NFET 仅用于构建下拉电路,即将信号节点连接到电源的地线(GND)。当下拉电路导通时,信号节点电压为 0 伏,代表数字值 0

如果遵守这条规则,NFET 的行为就像一个由 V_GS(栅极电压与源极电压之差)控制的开关。

  • V_GS 低于 MOSFET 的阈值电压 V_th 时,开关断开(不导通),源极和漏极之间没有连接。
  • V_GS 高于阈值电压 V_th 时,开关闭合(导通),源极和漏极之间形成通路。该通路的电阻由 V_GS 的大小决定。V_GS 越大,开关的有效电阻越低,从漏极流向源极的电流就越大。

在设计由 NFET 开关组成的下拉电路时,我们可以为每个 NFET 开关使用以下简单的思维模型:

  • 如果栅极电压是数字 0,开关将断开
  • 如果栅极电压是数字 1,开关将闭合


接下来,我们看看 P 沟道 MOSFET 的情况。

我们的第二条规则是,P 沟道 MOSFET(简称 PFET)只能用于上拉电路

PFET 用于将信号节点连接到电源电压,我们称之为 V_DD。当上拉电路导通时,信号节点电压为 V_DD,代表数字值 1

PFET 具有负的阈值电压,V_GS 必须小于该阈值电压,PFET 才会导通。这些负号可能有点令人困惑,但幸运的是,我们可以为上拉电路中的每个 PFET 开关使用一个简单的思维模型。

以下是 PFET 开关的思维模型:

  • 如果栅极电压是数字 0,开关将闭合
  • 如果栅极电压是数字 1,开关将断开

基本上,这与 NFET 开关的行为相反


你可能会想,为什么我们不能在上拉电路中使用 NFET,或者在下拉电路中使用 PFET呢?

你将在第二次作业的第一个实验中探索这个问题的答案。简而言之,如果违反规则,信号节点将经历信号电平的衰减,并失去我们努力构建的噪声容限


现在,让我们考虑一个组合逻辑反相器的 CMOS 实现。如果反相器的输入是数字 0,其输出是数字 1,反之亦然。

该反相器电路由一个 NFET 开关和一个 PFET 开关组成。

  • NFET 作为下拉电路,将输出节点连接到地。
  • PFET 作为上拉电路,将输出连接到 V_DD
    两个开关的栅极端都连接到反相器的输入节点。

图中显示了反相器的电压传输特性。当 V_in 是数字 0 输入时,我们看到 V_out 大于或等于 V_OH,代表数字 1 输出。

让我们看看当输入为数字 0 时,上拉和下拉开关的状态。

回顾 NFET 和 PFET 开关的简单思维模型:

  • 输入为 0 意味着 NFET 开关断开,因此输出节点与地之间没有连接。
  • 同时,PFET 开关闭合,在输出节点和 V_DD 之间建立连接。

电流将流经上拉开关,对输出节点充电,直到其电压达到 V_DD。一旦源极和漏极端都处于 V_DD,开关两端就没有电压差,因此不再有电流流过开关。

类似地,当 V_in 是数字 1 时,NFET 开关闭合PFET 开关断开,因此输出连接到地并最终达到 0 伏电压。同样,一旦输出节点达到 0 伏,流经下拉开关的电流就会停止。


当输入电压处于其范围的中间时,根据所使用的特定电源电压和 MOSFET 的阈值电压,上拉和下拉电路有可能在短时间内同时导通

这没有问题。事实上,当两个 MOSFET 开关都导通时,输入电压的微小变化会产生输出电压的巨大变化,从而带来 CMOS 器件所表现出的极高增益

这反过来意味着我们可以选择包含充裕噪声容限的信号阈值,使 CMOS 器件能够在许多不同的操作环境中可靠工作。

这就是我们的第一个 CMOS 组合逻辑门


在本节课中,我们一起学习了构建 CMOS 数字电路的两条核心规则:NFET 仅用于下拉电路,PFET 仅用于上拉电路。我们利用简单的开关模型,详细分析了 CMOS 反相器的工作原理,看到了它如何通过互补的开关动作实现逻辑反相功能,并获得了高增益和良好的噪声容限。

在下一个视频中,我们将探索如何构建其他更有趣的逻辑功能。

028:3.2.4 超越反相器 🚀

在本节课中,我们将要学习如何利用互补的“上拉”和“下拉”电路来构建除反相器之外的其他逻辑门。我们将探讨这些电路的设计原则、工作原理,并最终构建出“与非门”(NAND Gate)。

互补电路的工作原理

上一节我们介绍了反相器,本节中我们来看看如何构建更复杂的逻辑门。其核心是设计互补的“上拉”和“下拉”电路,其连接方式如下图所示,用于控制输出节点的电压。

“互补”指的是一个电路导通时,另一个电路必定不导通的特性。

  • 当上拉电路导通而下拉电路不导通时,输出节点连接到 VDD,其输出电压会迅速上升,成为一个有效的数字“1”输出。
  • 当下拉电路导通而上拉电路不导通时,输出节点连接到 GND,其输出电压会迅速下降,成为一个有效的数字“0”输出。


非互补设计的后果

如果电路设计错误,导致上拉和下拉电路在较长时间内同时导通,就会在 VDDGND 之间形成通路,产生大量短路电流,这是非常糟糕的情况。



由于我们的简单开关模型无法确定这种情况下的输出电压,我们将其输出值称为 X(未知)。另一种非互补设计的可能性是两者都不导通,此时输出节点与任一电源电压都没有连接。

这时,输出节点处于电气“浮空”状态,存储在节点电容上的电荷会保留在那里(至少一段时间)。这是一种存储形式,我们将在后续课程中讨论。现在,我们将专注于具有互补上拉和下拉电路的器件行为。

互补电路的设计对称性

由于上拉和下拉电路是互补的,我们会在其设计中看到一种优美的对称性。

我们已经见过最简单的互补电路:一个 NFET 下拉和一个 PFET 上拉。如果同一个信号控制两个开关,很容易看出当一个开关导通时,另一个开关就关断。

现在,考虑一个由两个串联的 NFET 开关构成的下拉电路。只有当 A=1B=1 时,电流才能通过两个开关;对于 AB 值的任何其他组合,其中一个或两个开关都会关断。

与串联 NFET 互补的电路是并联的 PFET。当任意一个 PFET 开关导通(即 A=0B=0)时,电路顶部和底部节点之间就存在连接。

我们可以通过一个思维实验来验证:考虑 AB 所有可能的取值组合 (0,0), (0,1), (1,0), (1,1)

  • 当一个或两个输入为 0 时,串联的 NFET 电路不导通,而并联的 PFET 电路导通。
  • 当两个输入都为 1 时,串联的 NFET 电路导通,而并联的 PFET 电路不导通。

最后,考虑由并联 NFET 和串联 PFET 构成的电路。进行同样的思维实验,可以确信当一个电路导通时,另一个电路不导通。

构建与非门(NAND Gate)

让我们将这些观察应用到构建下一个 CMOS 组合器件中。

在这个器件中,我们使用串联的 NFET 作为下拉电路,并联的 PFET 作为上拉电路,这正是我们在上一张幻灯片中确认的互补电路。

我们可以构建一个称为“真值表”的表格来表示,它描述了对于 AB 所有可能的输入组合,输出 Z 的值。

以下是该器件的真值表:

A B Z
0 0 1
0 1 1
1 0 1
1 1 0
  • AB 都为 0 时,PFET 导通,NFET 关断,所以 Z 连接到 VDD,器件输出为数字 1
  • 事实上,只要 AB 中有一个为 0,情况依然如此,Z 的值仍然是 1
  • 只有当 AB 同时为 1 时,两个 NFET 才会都导通,Z 的值变为 0

这个特定的器件被称为 与非门(NAND Gate),是“NOT AND”(非与)的缩写,其功能是与(AND)函数的反相。

物理布局与制造成本

回到物理视图,左图是从集成电路表面俯视的鸟瞰图,展示了 MOS 晶体管在二维平面上的布局。

  • 蓝色材料代表金属导线,顶部和底部的大型金属走线连接到 VDDGND
  • 红色材料形成多晶硅栅极节点。
  • 绿色材料是 NFET 的 N 型源极和漏极扩散区。
  • 棕色材料是 PFET 的 P 型源极和漏极扩散区。

你能看出 NFET 是串联的,而 PFET 是并联的吗?

为了让你对制造一个与非门的成本有个概念,黄色框内是一个粗略估算。

  • 在一块直径为 300 毫米(对我们非公制使用者来说是 12 英寸)的硅晶圆上,我们可以制造大约 260 亿个 与非门。
  • 对于这里展示的较旧的 IC 制造工艺,购买材料并执行所有制造步骤以形成这些与非门的电路,成本约为 3500 美元



因此,每个与非门的最终成本略高于 100 纳美元。我认为这完全符合既便宜又小巧的标准。

总结

本节课中我们一起学习了 CMOS 逻辑门设计的核心思想:互补的上拉和下拉电路。我们分析了串联 NFET 与并联 PFET 的互补特性,并利用这种结构成功构建了 与非门(NAND Gate)。我们还从物理布局和制造成本的角度,了解了集成电路是如何实现高密度和低成本的。

029:3.2.5 CMOS门电路设计 🧠

在本节课中,我们将要学习如何使用更复杂的开关串并联网络来构建实现复杂逻辑功能的CMOS门电路。我们将重点介绍设计互补CMOS电路的方法,并探讨其固有的逻辑特性限制。

设计复杂CMOS门电路

上一节我们介绍了基本的CMOS反相器。本节中我们来看看如何设计实现更复杂逻辑功能的CMOS门电路。

使用更复杂的开关串并联网络,我们可以构建实现更复杂逻辑功能的器件。

要设计一个更复杂的逻辑门,首先需要确定PFET开关的串并联连接方式,以便在正确的输入组合下将输出连接到VDD。

以下是设计上拉电路的基本思路:

  • 首先,根据期望的逻辑功能,确定使输出为1的输入条件。
  • 然后,将这些逻辑关系(AND和OR)转换为开关的串联和并联连接。

一个设计示例

在这个例子中,当A为0,或者当B为0且C为0时,输出F将为1。

逻辑表达式可以写作:F = A' + (B' * C')

OR运算转换为并联连接,AND运算转换为串联连接,从而得到图中右侧所示的上拉电路。

构建互补的下拉电路

为了构建互补的下拉电路,需要系统地遍历上拉电路的连接层次结构。

以下是构建下拉电路的转换规则:

  • 将PFET替换为NFET。
  • 将串联子电路替换为并联子电路。
  • 将并联子电路替换为串联子电路。

在所示的例子中,上拉电路有一个由A控制的开关与一个由B和C控制的开关组成的串联子电路并联。

互补的下拉电路使用NFET,其中一个由A控制的开关与一个由B和C控制的开关组成的并联子电路串联。

最后,将上拉电路和下拉电路组合起来,形成一个完全互补的CMOS实现。

这个过程可能进展得很快,但通过练习,你会对CMOS设计过程感到得心应手。

CMOS门的限制

Mr. Blue提出了一个好问题:这个设计方法适用于任何和所有的逻辑函数吗?

答案是否定的,让我们看看原因。

使用CMOS技术,单个门(即一个上拉网络和一个下拉网络的电路)只能实现所谓的反相函数,即输入上升导致输出下降,反之亦然。

为了理解原因,考虑当门的一个输入从0变为1时会发生什么。

以下是输入变化时开关状态的变化:

  • 任何由上升输入控制的NFET开关将从关闭变为开启。
  • 任何由上升输入控制的PFET开关将从开启变为关闭。

这可能会启用门输出与地之间的一个或多个路径,并可能禁用门输出与VDD之间的一个或多个路径。

因此,如果门的输出因输入上升而改变,那必然是因为某些下拉路径被启用,同时某些上拉路径被禁用。

换句话说,由于输入上升导致的输出电压的任何变化,必然是从1到0的下降跳变。类似的推理告诉我们,输入下降必然导致输出上升。

事实上,对于任何非常量的CMOS门,我们知道当所有输入为0时,其输出必须为1,因为所有NFET都关闭,所有PFET都开启。反之,如果所有输入为1,门的输出必须为0。

这意味着所谓的正逻辑无法用单个CMOS门实现。

看看这个AND函数的真值表。当两个输入都为0,或两个输入都为1时,其值与我们对CMOS门在这些输入组合下输出的推论不一致。

此外,我们可以看到,当A为1且B从0上升到1时,输出是上升而不是下降。

这个故事的寓意是:当你成为一名CMOS设计师时,你将非常擅长用反相逻辑来实现功能。

总结

本节课中我们一起学习了如何设计实现复杂逻辑功能的CMOS门电路。我们掌握了从上拉网络逻辑表达式出发,通过系统性的规则转换来构建互补下拉网络的方法。更重要的是,我们理解了单个CMOS门电路固有的限制:它只能实现反相逻辑功能,无法直接实现如AND、OR这样的正逻辑。这是CMOS电路设计中的一个基本特性,需要在未来的设计中时刻牢记。

030:3.2.6 CMOS时序分析 ⏱️

在本节课程中,我们将学习如何分析和定义CMOS逻辑门的时序特性。我们将理解传播延迟和污染延迟这两个核心概念,并学习如何计算复杂组合电路的总体延迟。


上一节我们介绍了如何使用CMOS构建组合逻辑。本节中,我们来看看如何描述这些逻辑门的时序特性。

这里是一个由两个CMOS反相器串联组成的简单电路,我们将用它来理解如何描述左侧反相器的时序。

建立一个关于输入电压VN变化的电气模型会很有帮助。当VN从数字0跳变为数字1时,上拉PFET开关关闭,下拉NFET开关导通,将左侧反相器的输出节点连接到地。

该节点的电气模型包括连接左右反相器的物理导线的分布电阻和电容,以及右侧反相器中MOSFET栅极的电容。

当输出节点连接到地时,该电容上的电荷将通过导线的电阻和NFET下拉开关导通沟道的电阻流向地连接。最终,导线上的电压将达到地电位,即零伏特。

VN的下降沿转换过程非常相似,会导致输出节点充电至VDD。

现在让我们看看电压随时间变化的波形。

顶部的图显示了VN的一个上升沿和随后的一个下降沿转换。我们看到输出波形具有电容器通过电阻放电或充电时电压的典型指数形状。该指数由其相关的RC时间常数表征,其中R是导线和MOSFET沟道的总电阻,C是导线和MOSFET栅极端子的总电容。

由于输入和输出的转换都不是瞬时的,我们需要选择如何测量反相器的传播延迟。幸运的是,我们的信令阈值正好提供了所需的指导。

组合逻辑门的传播延迟被定义为从有效输入到有效输出的延迟的上限。有效输入电压由VIL和VIH信令阈值定义,有效输出电压由VOL和VOH信令阈值定义。我们已在波形图上标出了这些阈值。

为了测量与VN上升沿相关的延迟,首先确定输入变为有效数字1的时间,即VN越过VIH阈值的时间。接下来,确定输出变为有效数字0的时间,即Vout越过VOL阈值的时间。这两个时间点之间的间隔就是这组特定输入和输出转换的延迟。

我们可以通过相同的过程来测量与输入下降沿相关的延迟。首先,确定VN越过VIL阈值的时间。然后找到Vout越过VOH阈值的时间。得到的间隔就是我们想要测量的延迟。

由于传播延迟TPD是任何输入转换相关延迟的上限,我们将选择一个大于或等于我们刚刚测量值的TPD值。当制造商为门电路选择TPD规格时,必须考虑制造差异、不同环境条件(如温度和电源电压)的影响等。它应该选择一个TPD,该TPD将是其客户在实际器件上可能进行的任何延迟测量的上限。

从设计者的角度来看,我们可以依赖大型数字系统中每个组件的这个上限,并用它来计算系统的TPD,而无需重复制造商的所有测量。

如果我们的目标是最小化系统的传播延迟,那么我们希望尽可能减小电容和电阻。这里存在一个有趣的权衡。为了使MOSFET开关的有效电阻更小,我们会增加其宽度。但这会增加开关栅极端子的额外电容,从而减慢连接到栅极的输入节点上的转换速度。找出能最小化整体传播延迟的晶体管尺寸是一个有趣的优化问题。

虽然静态规范并不严格要求,但定义另一个称为污染延迟的时序规格将很有用。它衡量的是在门的输入开始变化并变为无效之后,门的先前输出保持有效的时间长度。从技术上讲,污染延迟是从无效输入到无效输出的延迟的下限。

我们将像测量传播延迟一样进行延迟测量。在输入上升沿转换中,延迟从输入不再是有效数字0时开始,即VN越过VIL阈值时。延迟在输出变为无效时结束,即Vout越过VOH阈值时。我们可以对输入下降沿转换进行类似的延迟测量。

由于污染延迟TCD是任何输入转换相关延迟的下限,我们将选择一个小于或等于我们刚刚测量值的TCD值。

我们真的需要污染延迟规格吗?通常不需要。如果未指定,设计者应假设组合器件的TCD为零。换句话说,一个保守的假设是输出在输入变为无效的同时也变为无效。

顺便说一下,制造商经常使用术语“最小传播延迟”来指代器件的污染延迟。这个术语有点令人困惑,但现在你知道他们想告诉你什么了。

以下是组合逻辑时序规格的快速总结。这些规格告诉我们输出波形(在此示例中标记为B)变化的时序如何与输入波形(标记为A)变化的时序相关。

组合器件可能在输入转换后的某个时间间隔内保留其先前的输出值。器件的污染延迟是对该间隔最小尺寸的保证。换句话说,TCD是旧输出值保持有效的时间长度的下限。如注释2所述,一个保守的假设是器件的污染延迟为零,这意味着器件的输出可能在输入转换后立即改变。因此,TCD为我们提供了B何时开始变化的信息。

同样,了解在输入转换后B何时保证完成变化会很有帮助。换句话说,我们需要等待多长时间,输入的变化才能反映在输出的更新值上?这就是TPD告诉我们的,因为它是输入转换后B变为有效和稳定所需时间的上限。

如注释1所指出的,一般来说,在从输入转换开始测量的TCD之后和TPD之前的时间间隔内,无法保证输出的行为。在该间隔内,B输出多次改变,甚至在该间隔的任何部分具有非数字电压,都是合法的。正如我们将在本章最后一个视频中看到的,我们将能够为组合电路的一个子类提供关于B在此间隔内行为的更多见解。但一般来说,设计者不应假设B在TCD和TPD之间的间隔内的值。

我们如何根据组件的时序规格计算更大组合电路的传播延迟和污染延迟?

我们的示例是一个由四个与非门组成的电路,其中每个与非门的TPD为4纳秒,TCD为1纳秒。

为了找到较大电路的传播延迟,我们需要找到从节点A、B或C上的输入转换到输出Y上有效且稳定的值之间的最大延迟。为此,考虑从某个输入到Y的每条可能路径,并通过累加路径上组件的TPD来计算路径延迟。选择最大的路径作为整个电路的TPD。在我们的示例中,最大延迟是一条包含三个与非门的路径,其累积传播延迟为12纳秒。换句话说,保证输出Y在A、B或C发生转换后的12纳秒内稳定且有效。

为了找到较大电路的污染延迟,我们再次研究从输入到输出的所有路径,但这次我们寻找从无效输入到无效输出的最短路径。因此,我们累加每条路径上组件的TCD,并选择最小的路径延迟作为整个电路的TCD。在我们的示例中,最小延迟是一条包含两个与非门的路径,其累积污染延迟为2纳秒。换句话说,在某个输入变为无效后,输出Y将至少保持其先前值2纳秒。


本节课中我们一起学习了CMOS逻辑门的时序分析。我们定义了传播延迟(TPD)作为输出变为有效所需时间的上限,以及污染延迟(TCD)作为旧输出值在输入变化后保持有效时间的下限。我们还学习了如何通过累加路径上各器件的延迟来计算复杂组合电路的总体TPD和TCD。理解这些时序参数对于设计和分析高速数字系统至关重要。

031:3.2.7 宽容门电路

在本节中,我们将探讨CMOS逻辑门输出信号变化的时序特性,并引入一个重要的概念——宽容门电路。我们将了解其工作原理、与传统门电路的区别,以及它在数字系统中的意义。

概述

上一节我们介绍了门电路的传播延迟和污染延迟。本节中,我们来看看一种特殊的门电路行为——宽容性。我们将分析CMOS与非门的具体工作方式,理解为何在某些输入条件下,其他输入的信号变化不会影响输出的有效性和稳定性。

非CMOS与非门的时序分析

首先,考虑一个实现“或非”功能的非CMOS组合逻辑器件的行为。

观察波形图,初始时输入A和B均为0,输出Z为1,这与真值表的规定一致。现在,输入B发生从0到1的跳变,输出Z最终将通过从1到0的跳变来反映这个变化。

正如我们在上一个视频中学到的,Z跳变的时序由或非门的污染延迟和传播延迟决定。

需要注意的是,在输入跳变后的TCD到TPD时间间隔内,我们无法确定输出Z的值。我们在波形图上用红色阴影区域来标示这个区间。

不同初始条件下的行为

现在,考虑另一种初始设置:A和B均为1,相应地输出Z为0。

检查真值表可知,如果A为1,则无论B的值是什么,输出Z都将是0。

那么,当B发生从1到0的跳变时会发生什么?在跳变之前,Z为0,我们预期在B跳变后的TPD时间后,Z仍为0。但一般来说,在TCD到TPD的时间间隔内,我们不能对Z的值做任何假设。

Z在该区间内可能出现任何行为,而这个器件仍然是一个合法的组合逻辑器件。许多门电路技术,例如CMOS,遵循着更严格的限制。

CMOS或非门的内部机制

让我们详细看一下当两个输入均为数字1时,CMOS或非门实现中的开关配置情况。

高栅极电压将开启两个N型开关(如红色箭头所示),并关闭两个P型开关(如红色X所示)。由于上拉电路不导通,而下拉电路导通,输出Z连接到地,即数字0输出的电压。

现在,当输入B从1跳变到0时会发生什么?

由B控制的开关改变了它们的配置。P型开关现在开启,而N型开关现在关闭。但总体而言,上拉电路仍然不导通,并且仍然存在一条从Z到地的下拉路径。

因此,虽然过去有两条从Z到地的路径,而现在只有一条,但Z始终连接到地,其值在整个B跳变过程中保持有效和稳定。

对于CMOS或非门,当一个输入为数字1时,输出将不受另一个输入跳变的影响。

定义宽容组合逻辑器件

一个宽容的组合逻辑器件就是表现出这种行为的器件,即当任何足以确定输出值的输入组合已经有效至少TPD时间时,保证输出是有效的。

当某些输入处于触发这种宽容行为的配置时,其他输入的跳变将不会影响输出值的有效性。令人高兴的是,大多数CMOS逻辑门实现天生就是宽容的。

我们可以扩展真值表表示法来指示宽容行为,在某些行中使用“x”表示输入值,以表明在确定正确输出值时该输入值是无关的。

一个宽容或非门的真值表指出了两种这样的情况:当A为1时,B的值无关;当B为1时,A的值无关。

无关输入上的跳变不会触发通常与输入跳变相关的TCD和TPD输出时序。

宽容性的重要性

宽容性何时重要?在构建存储组件时,我们将需要宽容的组件,这是我们将在后面几章中涉及的主题。

你已经准备好尝试构建一些自己的CMOS门电路了。可以看看作业中的第一个实验练习,我相信你会觉得它很有趣。

总结

本节课中,我们一起学习了宽容门电路的概念。我们了解到,在CMOS门电路中,当某些输入处于特定状态(如或非门中任一输入为1)时,输出值已被确定,且不受其他输入跳变的影响,从而保证了输出的稳定性和有效性。这种特性通过扩展的真值表(使用“x”表示无关项)来描述,并且对于构建可靠的数字系统(尤其是存储部件)至关重要。

032:3.2.8 CMOS门电路功能分析实例 🧠

在本节课中,我们将通过一个具体实例,学习如何分析CMOS门电路的逻辑功能。我们将理解CMOS门的基本结构,并运用其特性来推导不同输入组合下的输出值。

CMOS门电路结构回顾 🔌

上一节我们介绍了CMOS门的基本概念,本节中我们来看看一个具体的分析实例。首先,我们需要回顾CMOS门的关键结构。

CMOS门由一个输出节点构成,该节点连接到一个上拉电路和一个下拉电路

  • 上拉电路:仅包含PFET(P型场效应晶体管)。
  • 下拉电路:仅包含NFET(N型场效应晶体管)。

实例问题与已知条件 📝

在我们的例子中,门电路计算一个四输入函数 F(A, B, C, D)

我们已知一个特定输入组合下的输出值:
F(1, 0, 1, 1) = 1

基于这个信息,我们需要判断另一个输入组合下的输出值:
F(1, 0, 0, 0) = ?

CMOS门的反相特性 ⚡️

要解决这个问题,必须理解CMOS门的一个核心特性:所有CMOS门都是反相的。这意味着:

  1. 所有输入为0时,只有上拉电路中的PFET导通,下拉电路中的NFET全部关闭。因此,输出等于1
    • 公式表示:F(0, 0, ..., 0) = 1
  2. 所有输入为1时,只有下拉电路中的NFET导通,上拉电路中的PFET全部关闭。因此,输出等于0
    • 公式表示:F(1, 1, ..., 1) = 0

逐步推理与分析过程 🔍

现在,我们开始分析从已知条件 F(1,0,1,1)=1 推导 F(1,0,0,0) 的过程。

我们注意到,第一组输入 (1,0,1,1) 与第二组输入 (1,0,0,0) 之间的区别在于:第三位和第四位输入从 1 变成了 0,而第一位和第二位输入保持不变。

在CMOS门中,将一个输入从 1 变为 0 会产生以下影响:

  • 在上拉电路中,更多的PFET被打开(因为PFET在输入为0时导通)。
  • 在下拉电路中,更多的NFET被关闭(因为NFET在输入为0时关闭)。

这导致:

  • 上拉电路将输出拉高到高电平(逻辑1)的可能性增加
  • 下拉电路将输出拉低到低电平(逻辑0)的可能性减少

得出结论 ✅

由于在输入为 (1,0,1,1) 时,即使有较少的PFET导通,我们已经得到了一个高电平输出(1)。

那么,当我们通过将输入从1变为0来打开更多PFET关闭一些NFET时,唯一可能的结果就是维持高电平输出

因此,我们可以确定:
F(1, 0, 0, 0) = 1


本节课中我们一起学习了如何利用CMOS门的结构特点和反相特性,通过已知输入输出关系来推理未知输入下的输出值。关键点在于理解输入信号变化如何影响上拉和下拉电路中的晶体管开关状态,从而决定最终的输出逻辑电平。

033:CMOS逻辑门 🧠

在本节课中,我们将学习如何为一个给定的逻辑函数设计CMOS电路。我们将以函数 Z = ¬(A ∨ (B ∧ C)) 为例,逐步构建其下拉(Pull-down)和上拉(Pull-up)网络。


概述

CMOS逻辑门由两个互补的晶体管网络构成:一个由NMOS晶体管组成的下拉网络,以及一个由PMOS晶体管组成的上拉网络。下拉网络导通时,将输出连接到地(GND),产生低电平(0)。上拉网络导通时,将输出连接到电源(VDD),产生高电平(1)。我们的目标是设计这两个网络来实现给定的逻辑函数。

分析目标函数

给定的逻辑函数是:
Z = ¬(A ∨ (B ∧ C))

这意味着,当括号内的表达式 (A ∨ (B ∧ C)) 为真(1)时,输出Z为假(0);反之,当括号内表达式为假(0)时,输出Z为真(1)。

上一节我们介绍了CMOS电路的基本结构,本节中我们来看看如何为这个具体函数设计电路。

设计下拉网络(Pull-down Network)

下拉网络负责在输出应为0时导通。根据函数 Z = ¬(A ∨ (B ∧ C)),当 Z=0 时,意味着:
A ∨ (B ∧ C) = 1

这个条件在以下任一情况成立时满足:

  1. A = 1
  2. B = 1C = 1

因此,下拉网络需要在A为1,或者(B和C同时为1)时,提供一条从输出Z到地的通路。

以下是构建下拉网络的步骤:

  • 由于条件是“A (B C)”,我们首先构建一个代表“B与C”的串联结构。
  • 然后,将这个串联结构与A并联,以实现“A或(B与C)”的逻辑。

所以,下拉网络由A与(B、C串联)并联构成。当A=1,或者B=1且C=1时,这条通路导通,将输出拉低至0。

设计上拉网络(Pull-up Network)

上拉网络负责在输出应为1时导通。根据函数,当 Z=1 时,意味着:
A ∨ (B ∧ C) = 0

这个条件在以下情况成立时满足:

  1. A = 0B = 0 (这使得B∧C=0)
  2. C = 0 (这使得B∧C=0,无论B为何值)

因此,上拉网络需要在(A和B同时为0)或者C为0时,提供一条从电源VDD到输出Z的通路。

CMOS设计有一个关键技巧:上拉网络是下拉网络的对偶形式。具体规则是:将下拉网络中的并联结构改为串联,串联结构改为并联,并使用PMOS晶体管(其在输入为0时导通)。

以下是应用此规则的过程:

  • 下拉网络是:A 并联 (B与C串联)。
  • 其对偶(上拉网络)是:A与B串联,再与C并联。

我们来验证这个电路:当A=0且B=0时,A与B的串联通路导通;或者当C=0时,C所在的并联支路导通。这两种情况都能将输出上拉至1,符合逻辑要求。

完整电路图

综合以上设计,实现函数 Z = ¬(A ∨ (B ∧ C)) 的完整CMOS电路如下:

  • 下拉网络(NMOS):晶体管A与(晶体管B和C的串联)并联。
  • 上拉网络(PMOS):(晶体管A和B的串联)与晶体管C并联。

注意:在CMOS中,PMOS晶体管的栅极输入与NMOS对应,但导通条件相反(低电平导通)。

总结

本节课中我们一起学习了CMOS逻辑门的设计流程:

  1. 分析函数:明确输出为0和1的条件。
  2. 设计下拉网络:根据输出为0的条件,用NMOS晶体管构建导通通路。串联对应“与”逻辑,并联对应“或”逻辑。
  3. 设计上拉网络:应用对偶规则,将下拉网络的串联/并联关系互换,并使用PMOS晶体管,即可得到上拉网络。

通过这个实例,我们掌握了将布尔逻辑函数转化为具体、可实现的CMOS晶体管电路的方法。

034:组合逻辑电路设计入门 🧠

在本节课中,我们将学习创建组合逻辑电路的各种技术,以实现特定的功能规范。功能规范是我们用来创建电路组合逻辑抽象静态规则的一部分。

功能规范:自然语言 📝

一种方法是使用自然语言来描述设备的操作。这种方法有其优缺点。其优点是,自然语言能以惊人的简洁形式传达复杂的概念,并且它是一种我们大多数人都知道如何阅读和理解的符号。但除非措辞非常谨慎,否则可能会因词语的多重解释或描述不完整而引入歧义。通常并不总是能明显看出是否处理了所有可能性。

功能规范:真值表 📊

有一些很好的替代方案可以解决上述缺点。真值表是一种直接的表格表示法,它为数字输入的每种可能组合指定输出值。

如果一个设备有 n 个数字输入,其真值表将有 2^n 行。在下面展示的例子中,设备有三个输入,每个输入的值可以是 01

三个输入值的组合有 2 × 2 × 2 = 2^3,即八种,因此真值表有八行。系统地枚举这八种组合很简单,这使得在构建规范时很容易确保没有遗漏任何组合。并且由于输出值是明确指定的,因此没有误解所需功能的空间。

真值表对于输入和输出数量较少的设备来说是绝佳的选择。遗憾的是,当设备有许多输入时,它们并不真正实用。例如,如果我们要描述一个将两个 32 位数字相加的电路的功能,总共将有 64 个输入,真值表将有 2^64 行。这并不实用。如果我们每秒为一行输入正确的输出值,填满这个表格将需要 5840 亿年。

功能规范:布尔方程 ➕

另一种规范是使用布尔方程来描述如何使用布尔代数从输入值计算输出值。我们使用的运算是逻辑运算 异或,每个运算接受两个布尔操作数;以及 ,它接受一个布尔操作数。

使用描述这些逻辑运算的真值表,按照方程中列出的操作序列,从特定的输入值组合计算输出值是很直接的。

让我简单说一下布尔方程中使用的符号。输入值由输入的名称表示,在此示例中为 ABC。数字输入值 0 等价于布尔值 false。数字输入值 1 等价于布尔值 true

布尔运算 通过在布尔表达式上方画一条水平线来表示。在此示例中,等号后的第一个符号是上方有一条线的 C,表示在用于计算表达式其余部分之前,应反转 C 的值。

布尔运算 使用标准数学符号中的乘法运算表示。有时我们会使用显式的乘法运算符,通常写作两个布尔表达式之间的点,如示例方程的第一项所示。有时 运算符是隐式的,如示例方程其余三项所示。

布尔运算 由加法运算表示,始终显示为加号。

当设备有许多输入时,布尔方程很有用,并且正如我们将看到的,很容易将布尔方程转换为电路原理图。

真值表和布尔方程是可以互换的。如果我们为每个输出都有一个布尔方程,我们可以通过评估布尔方程来填充真值表某一行的输出列,使用该行特定的输入值组合。例如,要确定真值表第一行中 Y 的值,我们将用布尔值 false 替换方程中的符号 ABC,然后使用布尔代数计算结果。

我们也可以反向操作。我们总是可以将真值表转换为一种特定形式的布尔方程,称为 积之和。让我们看看如何操作。

从真值表到积之和方程 🔄

首先查看真值表并回答问题:Y 何时具有值 1,或者用布尔代数的语言来说,Y 何时为 trueY 在输入对应于真值表的第 2 行、或第 4 行、或第 7 行、或第 8 行时为 true。总共有四种输入组合使 Ytrue

因此,相应的布尔方程是四个项的 ,其中每个项是一个布尔表达式,该表达式对特定的输入组合求值为 true

真值表的第 2 行对应于 C=0B=0A=1。相应的布尔表达式是 ¬C ∧ ¬B ∧ A,这个表达式当且仅当 C0B0A1 时求值为 true。第 4 行对应的布尔表达式是 ¬C ∧ B ∧ A。第 7 行和第 8 行依此类推。

这种方法总是会给我们一个 积之和 形式的表达式。“和”指的是 运算,“积”指的是 运算组。在这个例子中,我们有四个乘积项的和。

我们的下一步是使用布尔表达式作为使用组合逻辑门构建电路实现的配方。

组合逻辑门库 🧱

作为电路设计者,我们将使用一个组合逻辑门库,这个库要么由集成电路制造商提供,要么是我们自己使用 NFET 和 PFET 开关设计的 CMOS 门。

最简单的门之一是 反相器,其原理图符号如下所示。输出线上的小圆圈表示反相,这是原理图中常用的约定。从其真值表可以看出,反相器实现了布尔 功能。

与门 当且仅当 A 输入为 1B 输入为 1 时输出 1,因此得名 。库中通常包括具有三个输入、四个输入等的 与门,当且仅当其所有输入都为 1 时,它们才产生 1 输出。

或门 如果 A 输入为 1B 输入为 1,则输出 1,因此得名 。同样,库中通常包括具有三个输入、四个输入等的 或门,当至少有一个输入为 1 时,它们产生 1 输出。

这些是 与门或门 的标准原理图符号。请注意,与门 符号的输入侧是直的,而 或门 符号是弯曲的。稍加练习,你会发现很容易记住哪个原理图符号是哪个。

现在,让我们使用这些构建块来构建一个实现积之和方程的电路。

构建积之和电路 ⚙️

电路的结构完全遵循布尔方程的结构。我们使用反相器来执行必要的布尔 运算。在积之和方程中,反相器对特定的输入值(本例中为 ABC)进行操作。为了使这个原理图易于阅读,我们为方程中的四个 操作分别使用了单独的反相器。但在实际中,我们可能会将 C 输入反相一次以产生一个 ¬C 信号,然后在需要 ¬C 值时使用该信号。

四个乘积项中的每一个都是使用一个三输入 与门 构建的。然后使用一个四输入 或门 将这些乘积项 在一起。最终的电路有一层反相器、一层 与门 和一个最终的 或门

在下一节中,我们将讨论如何使用输入较少的库组件来构建具有多个输入的 与门或门

积之和电路的传播延迟看起来相当短。从输入到输出的最长路径包括一个反相器、一个 与门 和一个 或门。我们真的能在传播延迟为三个门延迟的电路中实现任何布尔方程吗?实际上不能,因为构建具有多个输入的 与门或门 将需要额外的组件层,这会增加传播延迟。我们将在下一节中学习这一点。

好消息是,我们现在有了将真值表转换为其对应的积之和布尔方程,并构建实现该方程的电路的直接技术。


本节课中,我们一起学习了组合逻辑电路设计的入门知识。我们探讨了使用自然语言、真值表和布尔方程作为功能规范的方法,并重点掌握了将真值表转换为积之和布尔方程,进而使用基本逻辑门(反相器、与门、或门)构建对应电路的具体流程。这为理解和实现更复杂的数字逻辑功能奠定了基础。

035:4.2.2 实用逻辑门

在本节课程中,我们将学习如何构建具有多个输入的逻辑门,并探讨不同电路结构(链式与树状)在成本和性能上的权衡。我们还将介绍“与非门”和“或非门”作为通用门的重要性,以及它们在CMOS电路设计中的优势。

上一节我们介绍了逻辑门的基本概念,本节中我们来看看如何构建具有多个输入的“与门”和“或门”。这在利用“积之和”方程作为模板创建电路实现时是必需的。

假设我们的门库中只有双输入门,我们需要研究如何使用这些双输入门作为构建模块来搭建更宽的门电路。我们将以创建三输入和四输入门为例,但所使用的方法可以推广到构建任意所需宽度的“与门”和“或门”。

构建多输入门的方法

这里展示的方法依赖于“与”运算符的结合律。这意味着我们可以通过以任何方便的顺序进行成对的“与”运算来完成一个N路“与”操作。“或”和“异或”运算同样具有结合律,因此相同的方法也适用于从对应的双输入门设计宽“或”电路和宽“异或”电路。只需将下图中的双输入“与门”替换为双输入“或门”或双输入“异或门”即可。

让我们从设计一个计算三个输入A、B和C的“与”的电路开始。在如下所示的电路中,我们首先计算A与B,然后将结果与C进行“与”运算。

使用相同的策略,我们可以用三个双输入“与门”构建一个四输入“与门”。本质上,我们正在构建一个“与门”链,它使用N-1个双输入“与门”来实现一个N路“与”操作。

我们也可以用不同的方式关联这四个输入,并行计算A与B以及C与D,然后使用第三个“与门”合并这两个结果。使用这种方法,我们正在构建一个“与门”树。

链式与树状:哪种方法更优?

首先,我们必须明确“更优”的含义。在设计电路时,我们关心成本(取决于组件数量)和性能(我们用电路的传播延迟来表征)。

两种策略需要相同数量的组件,因为两种情况下成对“与”运算的总数是相同的。因此在考虑成本时,两者打平。

现在考虑传播延迟。中间的链式电路的传播延迟是3个门延迟,并且我们可以看到,一个N输入链的传播延迟将是N-1个门延迟。链式电路的传播延迟随输入数量线性增长。

底部的树状电路的传播延迟是2个门延迟,小于链式电路。树状电路的传播延迟随输入数量对数增长,具体来说,使用双输入门构建的树状电路的传播延迟增长为log₂(N)。当N很大时,树状电路的传播延迟可以显著优于链式电路。

传播延迟是从输入到输出的最坏情况延迟的上限,并且假设所有输入同时到达,它是一个很好的性能衡量指标。但在大型电路中,A、B、C和D可能根据生成每个信号的电路的传播延迟在不同时间到达。

假设输入D比其他输入晚很多到达。如果我们使用树状电路来计算所有四个输入的“与”,计算Z的额外延迟是在D到达后的2个门延迟。然而,如果我们使用链式电路,计算Z的额外延迟可能只有1个门延迟。

这个故事的寓意是:除非我们知道计算输入信号值的电路的传播延迟,否则很难知道一个子电路(例如这里所示的四输入“与门”)的哪种实现会产生最小的整体传播延迟。

与非门和或非门的优势

在设计CMOS电路时,单个门本质上是反相的,因此为了获得最佳性能,我们想使用这里所示的“与非门”和“或非门”,而不是“与门”和“或门”。“与非门”和“或非门”可以实现为单个CMOS门,涉及一个上拉电路和一个下拉电路。而“与门”和“或门”在其实现中需要两个CMOS门,例如,一个“与非门”后接一个反相器。我们将在下一节讨论如何使用“与非门”构建“积之和”电路。

请注意,“与非”和“或非”操作不具有结合律。A、B、C的“与非”不等于C与(A和B的“与非”)的“与非”。因此,我们不能通过构建双输入“与非门”的树来构建具有多个输入的“与非门”。我们也会在下一节讨论这一点。

异或门及其应用

我们多次提到了“异或”操作,有时称为Xor。这个逻辑函数在构建用于算术或奇偶校验计算的电路时非常有用。正如你将在Lab 2中看到的,实现一个双输入“异或门”将比实现一个双输入“与非门”或“或非门”需要多得多的NFET和PFET。

通用门:与非门和或非门

我们知道,我们可以为任何真值表提出一个“积之和”表达式,从而使用反相器、“与门”和“或门”构建电路实现。事实证明,我们可以仅使用双输入“与非门”构建具有相同功能的电路,我们说双输入“与非门”是一个通用门。这里我们展示了如何仅使用双输入“与非门”来实现“积之和”的构建模块。稍后我们将展示一个仅使用“与非门”的更直接的“积之和”实现,但这些小原理图是一个概念证明,表明仅使用“与非门”的等效电路是存在的。

如下这些小原理图所示,双输入“或非门”也是通用的。反相逻辑需要一点时间来适应,但它是设计低成本、高性能CMOS电路的关键。


本节课中我们一起学习了如何从双输入门构建多输入逻辑门,比较了链式和树状结构的性能差异,认识了“与非门”和“或非门”在CMOS设计中的核心优势及其作为通用门的能力,并了解了“异或门”的特殊用途。这些知识是理解和设计更复杂数字电路的基础。

036:4.2.3 反相逻辑门 🧠

在本节课中,我们将要学习逻辑门库的设计考量,特别是反相逻辑门(如与非门、或非门)与非反相逻辑门(如与门、或门)之间的权衡。我们将探讨如何利用德摩根定律来构建具有大量输入的逻辑门,并分析不同电路实现方式在速度和面积上的差异。

现在,是时候花点时间查看我们将用于设计的逻辑门库的文档了。

请在 CourseW 标签页旁边的 handouts 标签页中,查找 Standard Cell Library 讲义及其更新。本幻灯片的信息即取自那里。

该库包含反相门(如反相器、与非门、或非门)和非反相门(如缓冲器、与门、或门)。

为什么需要同时包含这两种类型的门?我们不是刚学过只用与非门或或非门就能构建任何电路吗?这是个好问题。

如果我们看看下面这个四输入与函数的三种实现方式,就能对答案有所了解。

上方的电路是直接使用库中提供的四输入与门实现的。该门的传输延迟(TPD)为 160 皮秒,面积为 20 平方微米。不必过于担心具体数字,本幻灯片的关键在于不同设计之间数字的比较。

中间的电路使用一个四输入与非门连接一个反相器来实现相同的功能。该电路的 TPD 为 90 皮秒,比上方的单门电路快得多。代价是面积稍大一些。

这怎么可能呢?尤其是我们知道,与门在库中的实现其实就是中间电路所示的与非门加反相器对。

答案在于库的创建者决定将非反相门设计得面积小但速度慢。他们使用了比反相逻辑门(设计目标是速度快)宽度小得多的 MOSFET 晶体管。

我们为什么会想使用慢速门呢?请记住,电路的传播延迟是由从输入到输出的最长延迟路径决定的。在一个复杂电路中,有许多输入输出路径,但只有最长路径上的组件需要快速,以实现最佳的整体 TPD。其他较短路径上的组件可以慢一些,而短输入输出路径上的组件甚至可以非常慢。

因此,对于电路中不敏感于速度的部分,使用更慢但更小的门是一个很好的权衡。整体性能不受影响,但总面积得到了优化。所以,为了追求更快的性能,我们将使用反相门进行设计;为了追求更小的面积,我们将使用非反相门进行设计。门库的创建者在设计可用门时就考虑到了这种权衡。

四输入反相门的设计也考虑了这种权衡。为了追求极致性能,我们希望使用两输入门的树形电路,如下方电路所示。这种实现将 TPD 再减少了 10 皮秒,但代价是面积稍大一些。

仔细观察下方的电路。这个树形电路使用了两个与非门,其输出通过一个或非门组合。这真的能计算 A、B、C、D 的与吗?是的,你可以通过使用与非门和或非门的真值表构建这个组合系统的真值表来验证。

这个电路是应用一个特定布尔恒等式——德摩根定律——的好例子。

德摩根定律有两种形式,都展示在这里。上方的形式是我们分析下方电路时感兴趣的形式。它告诉我们,A 与 B 的或非等价于(非 A)与(非 B)的与。所以,两输入或非门可以被视为具有反相输入的两输入与门。

这有什么帮助?我们现在可以看到,下方的电路实际上是一个与门树,其中第一层的反相输出与第二层的反相输入相匹配。第一次看到时可能有点困惑,但通过练习,你会习惯在构建反相逻辑的树或链时使用德摩根定律。

利用德摩根定律,我们可以回答如何构建具有大量输入的与非门和或非门的问题。

我们的门库包含最多四输入的反相门,为什么停在那里?因为一个四输入与非门的下拉链有四个串联的 nFET,导通沟道的电阻开始累加。我们可以通过加宽 nFET 来补偿,但那样门会变得大得多,并且更宽的 nFET 会给输入信号带来更高的容性负载。随着输入数量的增加,面积和速度之间可能的权衡数量迅速增长,因此通常最好的做法是让库设计者停在四输入门,然后让电路设计者在此基础上继续构建。

幸运的是,德摩根定律向我们展示了如何构建交替的与非门和或非门树,以实现具有大量输入的反相逻辑。

这里我们看到了一个八输入与非门和一个八输入或非门的示意图。将左侧电路中间层的或非门视为具有反相输入的与门,就很容易看出该电路是一个带有反相输出的与门树。类似地,将右侧电路中间层的与非门视为具有反相输入的或门,可以看出我们实际上是一个带有反相输出的或门树。

现在,让我们看看如何使用反相逻辑构建积之和电路。

这里显示的两个电路实现了相同的积之和逻辑函数。顶部的电路使用了两层与非门。底部的电路使用了两层或非门。

让我们可视化德摩根定律在顶部电路中的应用。输出为 Y 的与非门可以通过德摩根定律转换为具有反相输入的或门。

因此,我们可以将左上方的电路重新绘制为右上方的电路。现在,请注意第一层的反相输出被第二层的反相输入所抵消,我们可以通过移除匹配的反相来直观地展示这一步。

瞧,我们看到了积之和形式的与非门电路:一层反相器,一层与门,以及一个组合乘积项的或门。

我们可以使用类似的可视化方法来转换底部电路的输出门,得到右下角所示的电路。

匹配“气泡”(反相符号),我们看到我们得到了与上面相同的逻辑函数。

观察左下方的或非门电路,它有四个反相器,而与非门电路只有一个。我们为什么会使用或非门实现呢?这与输入负载有关。在顶部电路中,输入 A 连接到总共四个 MOSFET 开关;在底部电路中,它只连接到反相器中的两个 MOSFET 开关。因此,底部电路对 A 信号施加的电容负载是顶部电路的一半。如果信号 A 连接到许多这样的电路,这可能很重要。

总而言之,当你发现自己需要为积之和表达式实现快速与或电路时,尝试使用与非门到与非门的实现方式,它会比使用与门和或门明显更快。

在本节课中,我们一起学习了逻辑门库中反相与非反相门的设计权衡,理解了如何利用德摩根定律构建多输入逻辑门和优化电路结构,并分析了不同实现方式在速度、面积和输入负载上的差异,为后续的电路设计打下了基础。

037:逻辑化简 🔧

在本节中,我们将学习如何对逻辑表达式进行化简,以构建更小、更快、成本更低的电路。我们将探讨使用布尔代数恒等式和真值表分析两种方法,并讨论化简可能带来的潜在问题。

概述

上一节我们介绍了如何根据给定的积之和表达式构建电路。一个有趣的问题是:我们能否使用更少或更小的门电路来实现相同的功能?换句话说,是否存在一个涉及更少运算的等价布尔表达式?布尔代数提供了许多恒等式,可用于将表达式转换为等价且更简洁的形式。

使用布尔代数恒等式化简

化简恒等式尤其有用,它可以将涉及两个变量和四个运算的表达式简化为单个变量且无需运算。让我们看看如何使用这个恒等式来简化一个积之和表达式。

以下是本章开始时涉及四个乘积项的方程:
Y = A·B·C + A·B·C' + A'·B·C + A'·B·C'

我们将使用化简恒等式的一个变体,它涉及一个表达式 α 和一个单变量 A。观察这些乘积项,如果我们令 α 为表达式 C·B,那么中间两项就提供了应用化简恒等式的机会。

因此,我们将中间两个乘积项简化为 α,即 C·B,从而从表达式的这一部分中消除了变量 A。

现在考虑剩下的三个乘积项,我们发现第一项和最后一项也可以化简,这次令 α 为表达式 C'·A

经过化简,我们得到了一个等价但更小的方程:
Y = A·C' + B·C

计算成对运算中的反相操作,原方程有 14 个运算,而简化后的方程只有 4 个运算。简化后的电路构建成本将低得多,并且传播延迟也更小。

使用真值表与无关项分析

另一种思考化简的方式是在真值表中搜索“无关”情况。

例如,观察左侧原始真值表的第一行和第三行。在这两种情况下,A=0,C=0,输出 Y=0。唯一的区别是 B 的值,这表明当 A 和 C 都为 0 时,B 的值是无关紧要的。

这给出了右侧真值表的第一行,其中我们使用 X 来表示当 A 和 C 都为 0 时 B 的值无关紧要。通过比较具有相同 Y 值的行,我们可以找到其他无关情况。

包含无关项的真值表只有三行输出为 1。实际上,最后一行是冗余的,因为它匹配的输入组合 011 和 111 已经被第二行和第四行覆盖了。

从第 2 行和第 4 行导出的乘积项,正是我们通过应用化简恒等式找到的乘积项。

化简的权衡:毛刺问题

我们是否总是希望使用最简化的方程作为电路模板?这似乎可以最小化电路成本并最大化性能,是一件好事。

简化后的电路如图所示。让我们看看当 A=1,B=1,且 C 从 1 转换到 0 时,它的性能如何。

在转换之前,C=1,从标注的节点值可以看出,是底部的与门导致输出 Y 为 1。当 C 转换到 0 时,底部的与门关闭,顶部的与门开启,最终 Y 输出再次变为 1。但是,顶部与门的开启被反相器的传播延迟所延迟。

因此,会有一个短暂的时间段,两个与门都没有开启,输出瞬间变为 0。这种短暂的错误值脉冲被称为“毛刺”,当它在电路的其他部分传播时,可能会导致许多节点值发生短暂变化。所有这些变化都会消耗功率,因此如果可能,最好避免这类毛刺。

如果我们在实现中包含第三个乘积项 B·A,电路计算出的长期答案与之前相同。但现在,当 A 和 B 都为高电平时,输出 Y 将独立于输入 C 的值而为 1。因此,C 输入从 1 到 0 的转换不会在 Y 输出上引起毛刺。

如果你还记得上一章的最后一节,我们用来描述这类电路的术语是“宽松的”。

总结

本节课中,我们一起学习了逻辑化简的两种主要方法:利用布尔代数恒等式和通过真值表分析无关项。化简可以显著降低电路的成本和延迟。然而,我们也看到,过度化简有时会引入不必要的“毛刺”,增加功耗。因此,在实际电路设计中,需要在简洁性、性能和可靠性之间做出权衡。对于复杂的表达式,通常会借助计算机辅助设计工具,它们使用启发式算法来寻找良好的(但不一定是最优的)简化方案。

038:4.2.5 卡诺图

在本节课中,我们将学习一种称为卡诺图的图形化工具,它可以帮助我们更直观地找到逻辑函数的最小积之和表达式。我们将了解卡诺图的结构、如何识别蕴含项,以及如何利用它来化简布尔表达式。

卡诺图的结构与原理

上一节我们介绍了使用布尔代数恒等式来化简积之和表达式。本节中我们来看看一种更直观的图形化方法——卡诺图。

当尝试使用化简恒等式来最小化积之和表达式时,我们的目标是找到两个可以合并为一个更小乘积项的乘积项,从而消除无关变量。当这两个乘积项来自真值表的相邻行时,这很容易做到。

例如,观察这个真值表的最后两行。由于在这两种情况下输出y都是1,这两行都将出现在该函数的积之和表达式中。当C和B都为1时,很容易发现A是无关变量。因此,真值表的最后两行可以用单个乘积项 B AND C 来表示。

如果我们重新组织真值表,使适当的乘积项位于相邻行,那么发现这些合并机会就会更容易。这就是我们在右边的卡诺图中所做的。

卡诺图将真值表组织成一个二维表格,其行和列用输入变量的可能值来标记。在这个卡诺图中,第一行包含c为0时的条目,第二行包含c为1时的条目。同样,第一列包含A为0且B为0时的条目,依此类推。

卡诺图中的条目与真值表中的条目完全相同,只是格式不同。请注意,列的排列顺序是一种特殊的序列,不同于通常的二进制计数序列。这种序列称为格雷码,相邻标签的位恰好有一位不同。换句话说,对于任意两个相邻的列,要么A标签的值改变了,要么B标签的值改变了。从这个意义上说,最左列和最右列也是相邻的。我们将表格写成一个二维矩阵,但你应该把它想象成一个左右边缘相连的圆柱体。

为了帮助你可视化哪些条目是相邻的,立方体的边缘显示了哪些三位输入值仅相差一位。如红色箭头所示,如果两个条目在立方体中是相邻的,那么它们在表格中也是相邻的。

扩展到更多变量

我们可以轻松地将卡诺图符号扩展到具有四个输入变量的函数的真值表,如图所示。我们对行和列都使用了格雷码序列。和之前一样,最左列和最右列是相邻的,顶行和底行也是相邻的。同样,当我们移动到相邻的列或行时,四个输入标签中只有一个会发生变化。

要为六个变量的函数构建卡诺图,我们需要一个4x4x4的值矩阵。这在二维页面上很难绘制,并且很难判断三维矩阵中的哪些单元格是相邻的。对于超过六个变量,我们需要额外的维度。这是计算机可以处理的事情,但对于我们这些生活在三维空间中的人来说很难。实际上,卡诺图对于最多四个变量效果很好,我们将坚持这一点。但请记住,你可以将卡诺图技术推广到更高的维度。

蕴含项与质蕴含项

那么为什么要讨论卡诺图呢?因为卡诺图中包含1的相邻条目模式,将揭示在我们的积之和表达式中使用更简单乘积项的机会。

让我们引入蕴含项的概念,这是卡诺图中一个矩形区域的奇特名称,该区域内的条目都是1。请记住,当一个条目是1时,我们希望积之和表达式对于该特定的输入值组合求值为真。我们要求蕴含项的宽度和长度是2的幂;换句话说,该区域应有一行、两行或四行,以及一列、两列或四列。蕴含项之间可以重叠。如果一个蕴含项没有完全包含在任何其他蕴含项中,我们称其为质蕴含项。

我们最终最小化积和表达式中的每个乘积项,都将与卡诺图中的某个质蕴含项相关。

让我们看看这些规则在实践中如何使用这两个示例卡诺图。当我们识别质蕴含项时,会用红色圆圈圈出它们。

从左侧的卡诺图开始,第一个蕴含项包含那个单独的、不与其他任何包含1的单元格相邻的单元格。第二个质蕴含项是卡诺图右上角的一对相邻的1。这个蕴含项有一行两列,符合我们对蕴含项维度的约束。

在右侧的卡诺图中寻找质蕴含项有点棘手。回想一下,最左列和最右列是相邻的,我们可以发现一个2x2的质蕴含项。请注意,这个质蕴含项包含许多更小的1x2、2x1和1x1蕴含项,但它们都不是质蕴含项,因为它们完全包含在这个2x2蕴含项中。很容易想围绕剩下的那个1画一个1x1的蕴含项,但实际上我们希望找到包含这个特定单元格的最大蕴含项。在这种情况下,就是这里显示的1x2质蕴含项。

为什么我们希望找到尽可能大的质蕴含项?我们稍后会回答这个问题。

每个蕴含项都可以用一个乘积项唯一标识,这是一个布尔表达式,对于蕴含项内的每个单元格求值为真,对于所有其他单元格求值为假。正如我们在本章开头对真值表行所做的那样,我们可以使用行和列标签来帮助我们构建正确的乘积项。

我们圈出的第一个蕴含项对应于乘积项 NOT A AND NOT B AND C。这是一个当A为0、B为0且C为1时求值为真的表达式。

右上角的1x2蕴含项呢?我们不希望包含在蕴含项内移动时会变化的输入变量。在这种情况下,保持恒定的两个输入值是c(值为0)和A(值为1),因此对应的乘积项是 A AND NOT C

以下是右侧卡诺图中两个质蕴含项的乘积项。请注意,质蕴含项越大,乘积项越小。这是有道理的,因为当我们在一个大的蕴含项内移动时,在整个蕴含项中保持恒定的输入数量更少。现在我们明白为什么我们希望找到尽可能大的质蕴含项了——它们能给我们最小的乘积项。

实践:寻找质蕴含项

让我们尝试另一个例子。请记住,我们正在寻找尽可能大的质蕴含项。一个好的方法是找到一些未被圈出的1,然后确定可以找到的包含该单元格的最大蕴含项。

有一个2x4的蕴含项覆盖了表格的中间两行。查看顶行的1,我们可以识别出包含这些单元格的2x2蕴含项。有一个4x1的蕴含项覆盖了右列,剩下表格左下角那个孤零零的1。寻找相邻的1并记住表格是循环的,我们可以找到一个包含这最后一个未被圈出1的2x2蕴含项。

请注意,我们总是在寻找尽可能大的蕴含项,但要受限于每个维度必须是1、2或4的约束。正是这些最大的蕴含项将成为质蕴含项。现在我们已经识别出了质蕴含项,准备构建最小积之和表达式。

构建最小表达式

以下是两个示例卡诺图,其中我们只显示了覆盖图中所有1所需的质蕴含项。这意味着,例如,在四变量图中,我们没有包括覆盖右列的4x1蕴含项。那个蕴含项是一个质蕴含项,因为它没有完全被任何其他蕴含项包含,但覆盖表中所有1并不需要它。

查看顶部的表格,我们将通过包含每个所示蕴含项的乘积项来组装最小积之和表达式。顶部的蕴含项对应的乘积项是 A AND NOT C。底部的蕴含项对应的乘积项是 B AND C。这样就完成了。

为什么得到的方程是最小的?如果存在某种进一步的化简可以产生更小的乘积项,那将意味着在卡诺图中存在一个可以圈出的更大的质蕴含项。

查看底部的表格,我们可以逐项组装积之和表达式。有四个质蕴含项,所以表达式中有四个乘积项。这样就完成了。

在卡诺图中寻找质蕴含项比摆弄布尔代数恒等式更快且更不容易出错。请注意,最小积之和表达式不一定是唯一的。如果我们在构建覆盖时使用了不同的质蕴含项组合,我们就会得到一个不同的积之和表达式。当然,这两个表达式在对于任何特定输入值组合产生相同y值的意义上是等价的。毕竟,它们是从同一个真值表构建的。并且这两个表达式将具有相同数量的操作。

因此,当你需要为最多四个变量的函数找出最小积之和表达式时,卡诺图是首选方法。

应用:消除毛刺

我们也可以使用卡诺图来帮助我们从输出信号中消除毛刺。在本章早些时候,我们看到了这个电路,并观察到当A为1且B为1时,C上从1到0的转换可能会在Y输出上产生一个毛刺,因为底部的乘积项关闭而顶部的乘积项开启。

这种情况显示在卡诺图上的黄色箭头处,我们正在从11列的底行单元格转换到顶行单元格。很容易看出,我们正在离开一个蕴含项并移动到另一个蕴含项。正是这两个蕴含项之间的间隙导致了Y上潜在的毛刺。

事实证明,存在一个覆盖此转换所涉及单元格的质蕴含项,如红色虚线轮廓所示。我们在构建原始的积之和实现时没有包含它,因为其他两个乘积项提供了必要的功能。但如果我们在积之和中包含该蕴含项作为第三个乘积项,Y输出上就不会发生毛刺。

要使一个实现具有容错性,只需在积之和表达式中包含所有能弥合导致潜在输出毛刺的乘积项之间间隙的质蕴含项。

总结

本节课中我们一起学习了卡诺图,这是一种用于化简布尔表达式的强大图形工具。我们了解了如何构建卡诺图,如何识别蕴含项和质蕴含项,以及如何利用它们来推导最小积之和表达式。我们还看到了卡诺图在识别和消除组合电路中的静态冒险(毛刺)方面的应用。对于处理最多四个变量的逻辑函数,卡诺图提供了一种比代数化简更直观、更系统的方法。

数字系统与计算机架构P1:4.2.6:多路复用器 🚦

在本节课中,我们将要学习一种非常重要的组合逻辑器件——多路复用器。我们将了解它的工作原理、如何构建不同规模的复用器,以及它在实现逻辑函数和可编程逻辑中的关键作用。


我们之前用作示例的真值表描述了一种非常有用的组合器件,称为 2选1多路复用器

多路复用器(简称Mux)会从其两个输入值中选择一个作为输出值。当图中标记为 S 的选择输入为 0 时,数据输入 D0 的值成为输出 Y 的值。当 S1 时,数据输入 D1 的值被选为输出 Y 的值。

多路复用器有多种规模,这取决于选择输入的数量。一个具有 K 个选择输入的复用器,将在 2^K 个数据输入的值中进行选择。例如,下图展示了一个 4选1 复用器,它有四个数据输入和两个选择输入。

更大的复用器可以由多个2选1复用器以树状结构构建而成,如下图所示。


上一节我们介绍了多路复用器的基本结构,本节中我们来看看为什么多路复用器如此有趣。一个答案是,它们为实现逻辑函数提供了一种非常优雅且通用的方法。

考虑右侧所示的 8选1 复用器。三个输入 ABC 被用作该复用器的三个选择信号。我们可以将这三个输入看作一个3位二进制数。例如,当它们全为 0 时,复用器将选择数据输入 0;当它们全为 1 时,复用器将选择数据输入 7,依此类推。

这如何使实现真值表中所示的逻辑函数变得容易呢?我们只需将复用器的数据输入端连接到真值表输出列中所示的常数值。ABC 输入端的值将导致复用器选择数据输入端上相应的常数值,作为 C_out 输出的值。

如果之后我们更改了真值表,我们无需重新设计复杂的“与或”电路,只需更改数据输入端的常数值即可。我们可以将复用器视为一个 查表设备,在此例中,它可以被重新编程以实现任何3输入的逻辑方程。

这种电路可用于创建各种形式的 可编程逻辑,其中集成电路的功能不是在制造时确定的,而是在用户稍后执行的编程步骤中设置的。现代可编程逻辑电路可以被编程以替代数百万个逻辑门,这对于在投入定制集成电路实现的高昂成本之前,对数字系统进行原型设计非常方便。


因此,具有 N 个选择线的复用器,实际上可以替代具有 N 个输入的逻辑电路。这样的复用器将拥有 2^N 个数据输入。这对于 N 值在5或6以内的情况非常有用。但对于输入更多的函数,电路规模的指数级增长会使其变得不切实际。


毫不奇怪,多路复用器是 通用 的,如下图所示,这些基于复用器的实现可以构建“与或”基本模块。有推测认为,在分子尺度的逻辑技术中,复用器可能是天然的“门”,因此了解它们可以用来实现任何逻辑函数是很有益的。


即使是 异或 门,也可以仅用两个2选1复用器简单地实现。


本节课中我们一起学习了多路复用器。我们了解了其作为数据选择器的基本功能,如何通过树状结构构建更大规模的复用器,以及它作为通用逻辑模块和可编程逻辑核心部件的强大能力。多路复用器是数字系统中连接数据与控制的关键桥梁。

040:4.2.7 只读存储器 📖

在本节课中,我们将要学习一种使用只读存储器来实现逻辑功能的最终策略。这种策略在需要从同一组输入生成多个不同输出时非常有用,我们将在后续课程中学习有限状态机时经常遇到这种情况。

上一节我们介绍了多路复用器,它适用于实现只有一个输出列的真值表。本节中我们来看看只读存储器,它则擅长实现具有多个输出列的真值表。

解码器:ROM的核心组件

只读存储器的一个关键组件是解码器。解码器有 K 个选择输入和 2^K 个数据输出。在任何给定时间,只有一个数据输出会是 1(高电平),具体是哪一个则由选择输入的值决定。

以下是解码器的工作原理:

  • 当选择线设置为 J 的二进制表示时,第 J 个输出将为 1

ROM的实现原理

这里展示了一个用于实现左侧所示双输出真值表的只读存储器电路。这个特定的双输出器件是一个全加器,是加法电路中的基本构建模块。

该电路的三个输入 ABC 连接到一个 3-8 解码器 的选择线上。解码器的八个输出在原理图中水平排列,每个输出都标明了使其为高电平的输入值组合。

解码器的输出控制着一个由 NFET 下拉开关组成的矩阵。该矩阵为真值表的每个输出都设有一个垂直列。每个开关将一个特定的垂直列连接到地,当开关导通时,会强制该列输出为低电平。列电路的设计使得如果没有下拉开关强制其值为 0,则其输出值将为 1。每个垂直列上的值经过反相后,产生最终的输出值。

那么,我们如何使用所有这些电路来实现真值表描述的功能呢?

对于任何特定的输入值组合,解码器中恰好有一个输出为高电平,所有其他输出为低电平。可以将解码器输出视为指示了输入值选择了真值表的哪一行。由高电平解码器输出控制的所有下拉开关都将被打开,强制它们所连接的垂直列变为低电平。

例如,如果输入是 001,则标记为 001 的解码器输出将为高电平。这将打开连接到 S 垂直列的下拉开关,强制 S 列变为低电平。C_out 垂直列没有被下拉,因此它将保持高电平。经过输出反相器后,S 将为 1C_out 将为 0,这正是期望的输出值。

通过改变下拉开关的位置,这个只读存储器可以被编程以实现任何三输入、双输出的函数。

处理多输入ROM

对于具有许多输入的只读存储器,解码器会有很多输出,开关矩阵中的垂直列可能会变得很长且速度变慢。我们可以稍微重新配置电路,让一部分输入控制解码器,而其他输入则用于在多个更短、更快的垂直列中进行选择。这种较小解码器和输出多路复用器的组合在这类存储电路中非常常见。

ROM的特性与权衡

只读存储器(简称 ROM)是一种实现策略,它忽略了待实现特定表达式的结构。ROM 的尺寸和整体布局仅由输入和输出的数量决定。通常,开关矩阵会完全填充,所有可能的开关位置都放置了一个 NFET 下拉开关。一个独立的物理或电气编程操作决定了哪些开关实际由解码器线路控制,其他开关则被配置为永久关闭状态。

如果 ROM 有 n 个输入和 m 个输出,那么开关矩阵将恰好有 2^n 行和 m 个输出列,与真值表的大小完全对应。

当 ROM 的输入发生变化时,各个解码器输出会关闭和开启,但时间上略有不同。随着解码器线路的切换,输出值可能会变化几次,直到下拉开关的最终配置稳定下来。因此,ROM 不是宽容的,其输出可能会出现之前讨论过的毛刺行为。

总结

本节课中我们一起学习了各种可用于实现逻辑功能的电路。

积之和 方法非常适合用反相逻辑来实现。每个电路都是为实现特定功能而定制的,因此可以做得既快速又小巧。当需要高性能或生产数百万个设备时,设计和制造此类电路的费用是值得的。

多路复用器ROM 电路实现则基本独立于要实现的特定功能,其功能由一个单独的编程步骤决定,该步骤可以在设备制造完成后进行。它们特别适用于原型设计、小批量生产或功能在设备投入使用后可能需要更新的场景。

041:4.2.8 真值表示例 📝

在本节课中,我们将学习如何根据CMOS电路推导其逻辑函数,以及如何根据真值表判断一个函数能否用单个CMOS门实现。我们将通过一个具体示例,展示从电路到真值表,再到逻辑表达式化简的完整过程。


从CMOS电路到逻辑函数

上一节我们介绍了CMOS电路的基本结构,本节中我们来看看如何分析一个给定的CMOS下拉网络,并写出它对应的逻辑函数。

给定如下所示的CMOS下拉网络电路图,我们可以推导出该电路所代表的逻辑函数。

该函数为 F = NOT( (A OR B) AND (C OR D) )

用逻辑表达式表示为:

F = ¬((A + B) · (C + D))

这个函数可以用真值表来描述,真值表列出了所有可能的输入组合及其对应的输出值。


构建真值表

真值表枚举了所有可能的输入组合,并为每一种组合指定了输出F的值。

以下是构建该函数真值表的过程:

  • 当输入 A=0, B=0, C=0, D=0 时:

    • (A OR B) = 0, (C OR D) = 0。
    • (0 AND 0) = 0。
    • 最后取反,得到 F = 1
  • 当输入 A=0, B=0, C=0, D=1 时:

    • (A OR B) = 0, (C OR D) = 1。
    • (0 AND 1) = 0。
    • 最后取反,得到 F = 1

按照同样的方法,我们可以完成整个真值表。

最终得到的输出序列为:1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0


从真值表到逻辑表达式

给定一个真值表,我们可能会被问到:这个真值表定义的函数F能否用单个CMOS门实现?要解决这个问题,我们首先需要将真值表转换为F的逻辑方程。

根据此真值表,函数F可以表示为积之和形式,即列出所有使F等于1的项,然后将它们用OR运算连接起来。

对于这个真值表,F的表达式为:

F = (¬A·¬B·¬C) + (¬A·B·¬C) + (A·¬B·¬C)


逻辑化简与CMOS实现判断

为了判断该函数能否作为单个CMOS电路实现,我们需要对函数F进行逻辑化简,目标是看 NOT F 能否被写成仅由输入变量(A, B, C)通过AND和OR运算组成的函数,而不包含这些输入变量的取反。

我们从F的表达式开始化简:

F = ¬A·¬B·¬C + ¬A·B·¬C + A·¬B·¬C

第一步:提取公因子¬C

F = ¬C · (¬A·¬B + ¬A·B + A·¬B)

第二步:观察并重组括号内的项
我们可以重写括号内的表达式,并重复第一项:

F = ¬C · (¬A·¬B + A·¬B + ¬A·B + ¬A·¬B)

第三步:应用逻辑恒等式
注意到对于任意变量X,有 X + ¬X = 1。但这里我们应用吸收律和合并律。观察发现,¬A·¬B + A·¬B = ¬B¬A·¬B + ¬A·B = ¬A。实际上,更直接的化简是:
括号内 (¬A·¬B + ¬A·B) 可以提取 ¬A,得到 ¬A·(¬B+B) = ¬A·1 = ¬A
A·¬B 保持不变。但根据真值表,更精确的化简路径是合并最小项。
最终化简结果为:

F = ¬C · (¬A + ¬B)

第四步:求NOT F并应用德摩根定律
现在,我们对F取反以获得NOT F:

NOT F = ¬( ¬C · (¬A + ¬B) )

应用德摩根定律:

  • ¬(A · B) = ¬A + ¬B
  • ¬(A + B) = ¬A · ¬B
    因此:
NOT F = ¬(¬C) + ¬(¬A + ¬B) = C + (A · B)

所以:

NOT F = C + (A · B)

现在我们得到了 NOT F = C OR (A AND B)。这表明F可以用单个CMOS门实现,因为我们成功地将NOT F表达为一个仅由未取反的输入(A, B, C)通过AND和OR运算组成的函数。


绘制对应的CMOS电路

根据化简结果,我们可以绘制出对应的CMOS电路。

  • 下拉网络:实现 NOT F 的函数,即 C + (A·B)。这对应着C(A和B串联) 两部分并联。
  • 上拉网络:是下拉网络的对偶(即AND和OR互换,变量取反),应为 (A和B并联)C 串联。


总结

本节课中我们一起学习了CMOS电路分析的完整流程:

  1. 从电路到函数:根据CMOS下拉网络写出逻辑函数。
  2. 从函数到真值表:通过枚举输入组合计算输出,构建真值表。
  3. 从真值表到表达式:使用积之和(SOP)方法,根据真值表写出逻辑表达式。
  4. 逻辑化简与实现判断:通过代数化简(如提取公因子、应用德摩根定律)将表达式转化为 NOT F 的形式,并据此判断能否用单个CMOS门实现以及设计对应的电路。

掌握这一流程对于理解和设计数字逻辑电路至关重要。

042:门电路与布尔逻辑 🔌

在本节课中,我们将学习布尔门电路的基本概念,并通过一个具体实例,学习如何分析由多个逻辑门组成的复杂电路的功能。


概述

直接使用晶体管构建所有逻辑功能较为复杂。因此,我们引入一个更高级的抽象概念——布尔门,它代表了晶体管门电路。每个门被赋予一个符号,可用于组合多个逻辑门的原理图。

为了理解任意逻辑门组合所实现的功能,我们将首先回顾基本门电路及其定义逻辑的真值表。


基本逻辑门回顾

以下是几种基本的逻辑门及其功能定义。

非门(Inverter)

非门是一个单输入、单输出的门电路。其输出是输入的逻辑反相。

  • 当输入 A = 0 时,输出 Y = 1
  • 当输入 A = 1 时,输出 Y = 0

与门(AND Gate)和或门(OR Gate)

与门要求其所有输入都为真(1),输出才为真(1)。其真值表如下:

  • 输入 AB = 00,输出 Y = 0
  • 输入 AB = 01,输出 Y = 0
  • 输入 AB = 10,输出 Y = 0
  • 输入 AB = 11,输出 Y = 1

或门要求其至少有一个输入为真(1),输出即为真(1)。其真值表如下:

  • 输入 AB = 00,输出 Y = 0
  • 输入 AB = 01,输出 Y = 1
  • 输入 AB = 10,输出 Y = 1
  • 输入 AB = 11,输出 Y = 1

与非门(NAND Gate)和或非门(NOR Gate)

与非门和或非门分别是与门和或门的反相输出。
我们倾向于使用与非门和或非门,因为它们是反相逻辑门,可以用单个CMOS晶体管实现,而与门和或门则不能。

异或门(Exclusive OR, XOR)

异或门在其两个输入中恰好有一个为1时,输出为1,否则输出为0。其真值表如下:

  • 输入 AB = 00,输出 Y = 0
  • 输入 AB = 01,输出 Y = 1
  • 输入 AB = 10,输出 Y = 1
  • 输入 AB = 11,输出 Y = 0


组合逻辑电路分析实例

上一节我们回顾了基本门电路,本节中我们来看一个由多个门组合而成的电路实例,并学习如何逐步分析其功能。

一个布尔门的输出可以作为另一个布尔门的输入,因此多个门可以用来生成更复杂的函数。例如,下图是一个由两个输入(A和B)和六个门(一个非门、一个与门、一个或门、两个或非门和一个与非门)组成的电路。

为了弄清楚这个门组合的输出是什么,我们可以逐步分析电路。

逐步推导真值表

首先,我们枚举输入A和B的所有可能组合(00, 01, 10, 11)。

  1. 非门输出:非门的输出是B的补码,记为 B'
  2. 与门和或门输出:与门和或门直接使用A和B作为输入。根据其定义,我们可以直接填写这两列。
  3. 第一个或非门输出:它的输入是A和 B'。我们遍历每种输入组合来确定该门的输出。
    • 输入 A, B' = 0, 1,输出为 0
    • 输入 A, B' = 0, 0,输出为 1
    • 输入 A, B' = 1, 1,输出为 0
    • 输入 A, B' = 1, 0,输出为 0
  4. 第二个或非门输出:它的输入是第一个或非门的输出和与门的输出。同样,我们遍历每种输入组合来确定输出。
    • 当两个输入都是 0 时,输出为 1
    • 当一个输入是 0,另一个是 1 时,输出为 0
  5. 最终输出(与非门):最后,我们将第二个或非门的输出和或门的输出作为与非门的输入,生成电路的最终输出H。
    • 当两个输入都是 1 时,输出为 0
    • 其他情况下,输出为 1

通过以上步骤,我们得到了完整的真值表:

用布尔表达式表示功能

现在我们已经评估出对于每种A和B的组合,输出H等于什么。我们可以将电路表示为一个输入为A和B、输出为H的单一真值表。

此时,我们可以将函数H表示为所有使H等于1的情况的组合。这发生在以下三种情况:

  1. A和B都等于0
  2. A等于0且B等于1
  3. A和B都等于1

这可以用以下布尔逻辑表达式表示:

H = (A' * B') + (A' * B) + (A * B)

这种表示法称为积之和形式。


电路的等效实现

任何积之和表达式都可以转换为仅使用非门、与门和或门的简单门级表示。具体方法是使用一个大型的或门,其每个输入连接一个与门(每个与门对应一个乘积项),并根据需要使用非门对输入取反。

关于这个门组合,一个有趣的现象是它可以很容易地转换为一个完全由与非门组成的电路。

转换为全与非门电路

转换方法基于以下原理:

  1. 将一个门的输出反相两次,会得到原始输出。这意味着我们可以在每个与门输出和或门输入之间添加两个反相器。
  2. 我们可以将这些反相器画成代表反相操作的“气泡”:一个气泡在与门的输出端,另一个气泡在或门的输入端。
  3. 我们知道,一个与门后接一个反相器,就是一个与非门。
  4. 我们知道,一个反相器等效于一个将两个输入连接在一起的与非门。
  5. 此外,利用德摩根定律,我们知道 (A' + B') 等价于 (A * B)'。这意味着气泡后接的或门也可以被一个与非门替代。

通过以上步骤,我们得到了一个功能等效、但完全由与非门组成的电路表示。

通用门的概念

使用与非门实现电路的优势在于,与非门是反相逻辑,每个门都可以用单个CMOS晶体管实现。

所有函数都可以表示为与非门的组合,因此与非门被认为是通用门。或非门也是通用的,可以用来表示任何函数。

进一步说,任何可以用来实现其他所有函数的电路也被认为是通用的。要确定一个门G是否是通用的,需要检查是否可以通过简单地使用一个或多个G的副本,以及低电平和高电平常量输入,将G转换为与非门或或非门。


总结

本节课中我们一起学习了:

  1. 回顾了非门、与门、或门、与非门、或非门和异或门等基本逻辑门及其真值表。
  2. 通过一个具体实例,学习了如何逐步分析由多个逻辑门组成的组合电路,并推导出其完整的真值表和布尔表达式(积之和形式)。
  3. 探讨了如何将基于与门和或门的电路转换为完全由与非门构成的等效电路,并理解了与非门作为“通用门”的重要性。

掌握这些分析方法是理解和设计更复杂数字逻辑系统的基础。

043:4.2.8 组合逻辑时序分析实例 🧮

在本节课中,我们将学习如何分析一个组合逻辑电路的时序特性,具体计算其传播延迟和污染延迟。我们将通过一个具体的电路实例,逐步演示计算过程。

电路与组件介绍

我们被给定一个组合逻辑电路,该电路包含以下组件:一个反相器、一个与门、一个异或门和一个多路复用器。

同时,我们获得了电路中每个组件的污染延迟和传播延迟参数。

我们的任务是确定整个电路的污染延迟和传播延迟。

传播延迟的计算

传播延迟的定义是:从输入改变到输出最终稳定下来,可能发生的最长延迟时间。

为了计算电路的传播延迟,我们需要找出从输入到输出的所有路径中,各组件传播延迟之和最大的那条路径。

对于本电路,延迟最长的路径是经过异或门和多路复用器的路径。

这两个门的传播延迟之和为 2.1 + 1.5 = 3.6 纳秒

因此,整个电路的传播延迟 t_pd 为:

t_pd = 3.6 ns

污染延迟的计算

上一节我们介绍了传播延迟,本节我们来看看污染延迟。污染延迟的定义是:从输入改变到输出开始发生变化(或不再保证保持先前稳定值)的最短延迟时间。

为了计算电路的污染延迟,我们需要找出从输入到输出的所有路径中,各组件污染延迟之和最小的那条路径。

对于本电路,最短的路径是从输入 A 直接连接到多路复用器的选择端。

因此,电路的污染延迟等于多路复用器的污染延迟,即 0.2 纳秒

整个电路的污染延迟 t_cd 为:

t_cd = 0.2 ns

总结

本节课中,我们一起学习了组合逻辑电路的时序分析。我们通过一个具体实例,演示了如何根据各个逻辑门的延迟参数,计算整个电路的传播延迟和污染延迟。关键步骤如下:

  • 传播延迟:寻找从输入到输出的最长路径,计算该路径上所有组件传播延迟之和。
  • 污染延迟:寻找从输入到输出的最短路径,计算该路径上所有组件污染延迟之和。

掌握这些基本概念和计算方法,是理解和设计高速数字系统的基础。

044:4.2.8 卡诺图实战示例 🔍

在本节课中,我们将学习如何使用卡诺图来简化布尔逻辑方程,从而得到函数的最简与或表达式。掌握这一方法可以帮助我们用最少数量的逻辑门来实现电路功能。

概述

布尔逻辑可用于将逻辑方程简化到最简形式。这个最简形式被称为函数的最小项之和表示法。拥有一个简化后的方程,使我们能够使用最少数量的逻辑门来实现该函数。

从真值表到与或表达式

一种常用的、将任意布尔表达式转换为其等价最小项之和的方法,就是使用卡诺图。

卡诺图的工作原理如下:首先,从函数的原始真值表规范开始。然后,将每一个使函数输出为1的输入组合,表示为一个基本的乘积项。

将所有乘积项进行“或”运算,就得到了该函数的与或表达式。需要注意的是,这个表达式还不是最简形式。

对于我们的示例,与或表达式为:
F = (¬A ∧ ¬B ∧ ¬C) ∨ (¬A ∧ B ∧ C) ∨ (A ∧ ¬B ∧ ¬C) ∨ (A ∧ ¬B ∧ C) ∨ (A ∧ B ∧ C)

构建卡诺图

接下来,我们为函数创建卡诺图。方法是构建一个二维网格,代表所有可能的输入组合,并确保在网格中从一个列或行到下一个列或行时,只有一个输入变量发生变化。这种编码方式被称为格雷码

一旦我们标记好卡诺图,就可以根据输出结果填充它:对于输出为1的组合填入1,对于输出为0的组合填入0。

在卡诺图中分组

下一步,我们尝试将尽可能多的相邻“1”合并成组,每个组的大小必须是2的幂次方。

需要注意的是,相邻的“1”可以跨行、跨列合并,甚至可以像圆环一样跨越卡诺图的边缘进行合并。

覆盖所有“1”所必需的、最大的“1”分组,将成为函数最小项之和表示法中的一项。

确定乘积项

为了确定每个分组对应的乘积项是什么,需要查看该“1”分组所覆盖的所有列和行的标签,然后消除那些同时以“1”和“0”形式出现的输入变量。

因为如果某个输入变量在分组中同时以原变量和反变量的形式出现,意味着该输入不影响该项的结果,因此可以被消除。

对所有“1”分组执行此操作,然后将结果进行“或”运算,就得到了该函数的最小项之和表示法。

请注意,如果能使乘积项更简单,允许并期望在分组中出现重叠的“1”。

示例分析

在我们的示例中,我们圈出了底部中间的两个“1”,它们代表了项 B ∧ C,因为在该分组中,变量A同时以低电平和高电平形式出现。

下一组“1”是最右边一列的两个“1”。这些“1”代表了项 A ∧ ¬B

最后,我们的最后一个分组环绕了第一行,创建了项 ¬B ∧ ¬C

这样就得到了我们函数的一个最小项之和表示法。

然而,请注意,除了将最右边一列的两个“1”分组,我们也可以将底行最右边的两个“1”分组。那样将产生项 A ∧ C,而不是 A ∧ ¬B

这两种项的任意组合,都是该函数的有效最小项之和表示法。

总结

本节课中,我们一起学习了卡诺图的使用方法。我们了解到,卡诺图是一种通过图形化方式合并相邻最小项,来简化布尔函数并得到其最简与或表达式的有效工具。关键在于正确构建格雷码排列的网格,识别并合并相邻的“1”分组,然后根据分组消除冗余变量,最终得到最简的逻辑表达式。

045:数字状态 🧠

在本节课中,我们将学习数字系统中的“状态”概念。我们将了解为什么组合逻辑电路无法实现某些功能,并探索如何通过引入存储元件来构建具有记忆能力的电路,即“时序逻辑”电路。

从组合逻辑到状态记忆

上一节我们介绍了如何根据功能规格构建组合逻辑电路,其输出值仅取决于输入的当前值。

但这里有一个简单的设备无法用组合逻辑构建。该设备有一个作为输出的灯和一个作为输入的按钮。如果灯是关的,按下按钮,灯会亮起。如果灯是亮的,按下按钮,灯会熄灭。

这个电路与我们之前讨论的组合电路有何不同?

最大的区别在于,该设备的输出并非其当前输入值的函数。按下按钮时的行为取决于过去发生的事件。奇数次按下会打开灯,偶数次按下会关闭灯。该设备记住了上一次按下是奇数次还是偶数次,以便在下一次按钮按下时根据规格做出反应。

能够记住其输入历史的设备被称为具有“状态”。

第二个区别更为微妙。按钮的按下标记了一个时间点的事件。我们谈论的是按下前的状态(灯亮)和按下后的状态(灯灭)。我们感兴趣的是按钮从“未按下”到“按下”的转变,而不是按钮当前是否被按下。

设备的内部状态使其即使在接收相同输入时也能产生不同的输出。组合设备无法表现出这种行为,因为其输出仅取决于输入的当前值。

接下来,我们看看如何将设备状态的概念融入我们的电路设计中。

时序逻辑:引入存储元件

我们将引入一个新的抽象概念——存储元件,用于存储我们想要构建的数字系统的当前状态。

存储元件存储一个或多个比特,用于编码系统的当前状态。这些比特作为数字值出现在存储元件的输出端(图中标记为“当前状态”的导线)。当前状态与当前输入值一起,作为组合逻辑块的输入,该逻辑块产生两组输出。

一组输出是设备的下一个状态,使用与当前状态相同数量的比特进行编码。另一组输出是作为数字系统输出的信号。

组合逻辑的功能规格(可能是真值表或一组布尔方程)规定了下一个状态和系统输出如何与当前状态和当前输入相关联。

存储元件有两个输入:一个指示何时用下一个状态替换当前状态的“加载”控制信号,以及一个指定下一个状态应该是什么的数据输入。

我们的计划是定期触发加载控制,从而为当前状态产生一系列值。序列中的每个状态都由前一个状态以及触发加载时的输入决定。

包含组合逻辑和存储元件的电路被称为“时序逻辑”。

存储元件具有以比特为单位的特定容量。如果存储元件存储K比特,由于设备状态使用K比特内存进行编码,因此可能状态数量的上限为 2^K

因此,我们需要弄清楚如何构建一个可以不时加载新值的存储元件。这是本章的主题。我们还需要一种系统的方法来设计时序逻辑,以实现期望的动作序列,这将是下一章的主题。

存储技术:从电容到双稳态元件

我们一直用电压表示比特,因此可能会考虑使用电容器来存储特定电压。

电容器是一个被动的双端器件。两个端子连接到由绝缘体隔开的两个平行导电板。向电容器的一个板添加电荷Q会在两个板端子之间产生电压差V。Q和V通过电容器的电容C相关:Q = C * V

当我们通过将板端子连接到较高电压来向电容器添加电荷时,这称为给电容器充电;当我们通过将板端子连接到较低电压来取走电荷时,这称为给电容器放电。

以下是基于电容器的存储设备可能的工作方式。电容器的一个端子连接到某个稳定的参考电压。我们使用一个NFET开关将电容器的另一个板连接到一根称为“位线”的导线。NFET开关的栅极连接到一根称为“字线”的导线。

要将一个比特信息写入我们的存储设备,首先将位线驱动到所需的电压(即数字0或数字1)。然后将字线设置为高电平,打开NFET开关。电容器将充电或放电,直到其电压与位线相同。此时,将字线设置为低电平,关闭NFET开关,从而将电荷隔离在电容器的内部板上。

在理想情况下,电荷将无限期地保留在电容器的板上。在之后的某个时间访问存储的信息时,我们首先将位线充电到某个中间电压。然后将字线设置为高电平,打开NFET开关,将位线上的电荷与电容器的电荷连接起来。位线和电容器之间的电荷共享会对位线上的电荷及其电压产生微小影响。

如果电容器存储的是数字1(因此处于较高电压),电荷将从电容器流入位线,提高位线的电压。如果电容器存储的是数字0(因此处于较低电压),电荷将从位线流入电容器,降低位线的电压。位线电压的变化取决于位线电容与存储电容的比值,但存储电容通常非常小。

一个非常灵敏的放大器(称为“读出放大器”)用于检测这种微小变化,并产生一个合法的数字电压作为从存储单元读取的值。

读写操作需要一整套操作序列以及精心设计的模拟电子电路。好消息是,单个存储电容器非常小。在现代集成电路中,我们可以在相对便宜的芯片上容纳数十亿比特的存储,这种芯片称为“动态随机存取存储器”,简称DRAM。DRAM的每比特存储成本非常低。

坏消息是,读写所需的复杂操作序列需要一些时间,因此访问时间相对较慢。并且我们必须担心在外部电噪声的影响下,如何仔细维持存储电容器上的电荷。

更糟糕的消息是,NFET开关并不完美。即使在其正式关闭时,开关上也会有微量的泄漏电流。随着时间的推移,这种泄漏电流会对存储电荷产生明显影响。因此,我们必须在泄漏损坏存储信息之前,通过读取和重写存储值来定期刷新存储器。在当前技术下,这大约需要每10毫秒进行一次。

也许我们可以通过设计一个利用反馈来持续刷新存储信息的电路,来规避电容存储的缺点。

双稳态存储元件:利用反馈

这是一个使用组合反相器以正反馈环路连接的电路。如果我们将其中一个反相器的输入设置为数字0,它将在其输出端产生数字1。第二个反相器随后在其输出端产生数字0,该输出又连接回原始输入。这是一个稳定的系统,只要电路连接到电源和地,即使存在噪声,这些数字值也将保持不变。当然,如果我们翻转两条导线上的数字值,系统也是稳定的。

其结果是一个具有两种稳定配置的系统,称为“双稳态存储元件”。

下图是电压传输特性图,显示了两个反相器系统的V_out和V_in之间的关系。将系统输出连接到其输入的效果由添加的约束条件 V_in = V_out 表示。然后,我们可以通过图形方式求解同时满足两个约束条件的V_in和V_out值。

两条曲线相交处有三个可能的解。VTC两端两个交点处的点是稳定的,这意味着V_in的微小变化(例如由电噪声引起)对V_out没有影响。因此,尽管存在微小扰动,系统仍将返回其稳定状态。

中间的相交点是我们所说的“亚稳态”。理论上,系统可以永远保持在这个特定的V_in和V_out电压上。但最小的扰动将导致电压快速转换到其中一个稳定解。

由于我们计划使用这个双稳态存储元件作为我们的存储组件,我们需要弄清楚如何避免系统进入这种亚稳态。更多内容将在下一章讨论。

现在,让我们弄清楚如何将新值加载到我们的双稳态存储元件中。


本节课中我们一起学习了数字状态的核心概念。我们了解到,组合逻辑缺乏记忆过去输入的能力,因此无法实现依赖于历史状态的功能。通过引入存储元件来保存“状态”,我们构建了时序逻辑电路。我们探讨了使用电容器存储信息的DRAM技术及其优缺点,并介绍了利用正反馈实现稳定存储的双稳态元件原理,为后续学习如何加载和控制状态奠定了基础。

046:5.2.2 D锁存器 🔒

在本节课中,我们将学习如何使用一个2选1多路复用器来构建一个可设置的存储元件,即D锁存器。我们将详细探讨其工作原理、内部结构以及确保其正确工作的时序要求。


上一节我们讨论了双稳态电路,本节中我们来看看如何利用多路复用器来构建一个实用的存储单元。

我们可以使用一个2选1多路复用器来构建一个可设置的存储元件。回想一下,多路复用器会选择其两个数据输入中的一个值作为其输出值。多路复用器的输出将作为存储元件的状态输出。

在存储元件内部,我们还将多路复用器的输出连接到其自身的D0数据输入端。多路复用器的D1数据输入端将成为存储元件的数据输入端。

多路复用器的选择线将成为存储元件的加载信号,这里称为门控信号。

当门控输入为低电平时,多路复用器的输出通过D0数据输入端反馈回多路复用器,形成了上一节讨论的双稳态正反馈环路。

现在,这个电路有了一个环路,因此它不再是一个组合逻辑电路。

当门控输入为高电平时,多路复用器的输出由D1输入端的值决定。换句话说,就是存储元件的数据输入端的值。

为了将新数据加载到存储元件中,我们需要将门控输入设置为高电平,并保持足够长的时间,以使Q输出变得有效且稳定。查看真值表,我们可以看到当G为1时,Q输出跟随D输入。当G输入为高电平时,D输入的任何变化都会反映为Q输出的变化,其时序由多路复用器的TPD决定。

然后,我们可以将门控输入设置为低电平,以将存储元件切换到存储模式。

在这种模式下,稳定的Q值通过正反馈环路无限期地保持,如真值表的前两行所示。

我们的存储器件被称为D锁存器,或简称为锁存器,其原理图符号如下所示。当锁存器的门控信号为高电平时,锁存器打开,信息从D输入端流向Q输出端。

当锁存器的门控信号为低电平时,锁存器关闭并进入存储模式,记住门控信号从高电平跳变到低电平时D输入端上的任何值。这在右侧的时序图中显示。波形显示了信号何时稳定(即恒定的低电平或高电平信号)以及信号何时变化(显示为低电平和高电平之间的一个或多个跳变)。当G为高电平时,我们可以看到Q在D达到新的稳定值后不晚于TPD的时间内改变为新的稳定输出值。理论上,在G跳变到低电平后,Q将稳定保持在G发生高到低跳变时D所具有的值上。

但我们知道,一般来说,在输入跳变后的TPD时间之前,我们不能对组合逻辑器件的输出做任何假设。

器件被允许在输入跳变后的TCDTPD之间的间隔内做任何它想做的事情。但是,如果G上的1到0跳变导致Q输出在短暂间隔内变得无效,我们的存储器将如何工作?毕竟,我们试图记住的是Q输出上的值。我们必须确保G上的1到0跳变不会影响Q输出。

这就是为什么我们为存储元件指定使用宽容型多路复用器

宽容型多路复用器的真值表如下所示。在以下三种条件中的任何一种下,宽容型多路复用器的输出即使在输入跳变后也能保持有效和稳定。

以下是宽容型多路复用器保持输出稳定的条件:

  1. 当通过将G设置为高电平来加载锁存器时,一旦D输入有效且稳定了TPD时间,我们就能保证Q输出将有效且稳定,其值与D输入相同,与Q的初始值无关。

  2. 如果Q和D都有效且稳定了TPD时间,那么Q输出将不受G输入后续跳变的影响。

  3. 如果G为低电平且Q已稳定了至少TPD时间,那么输出将不受D输入后续跳变的影响。

宽容性是否保证了锁存器能正常工作?只有在我们在正确的时间确保信号稳定,从而能够利用多路复用器的宽容行为时,才能保证。

为了确保锁存器按我们期望的方式工作,我们需要遵循以下步骤。

以下是确保D锁存器正确工作的操作步骤:

  1. 首先,在G输入为高电平期间,将D输入设置为我们希望存储在锁存器中的值。

  1. 然后,经过TPD时间后,我们保证该值将在Q输出端稳定且有效。这是上一张幻灯片中的条件1。

  1. 现在,我们再等待一个TPD时间,以便关于Q‘输入端新值的信息通过锁存器的内部电路传播。此时,D和Q‘都已稳定了至少TPD时间,这为我们提供了上一张幻灯片中的条件2。

  2. 因此,如果D稳定了2 * TPD时间,G上的跳变将不会影响Q输出。D的这个要求称为锁存器的建立时间。它是在G发生高到低跳变之前,D必须保持稳定和有效的时间长度。

  3. 现在,我们可以将G设置为低电平,同时仍保持D稳定且有效。再经过一个TPD时间,让新的G值通过锁存器的内部电路传播后,我们就满足了上一张幻灯片中的条件3。

  1. 此后,Q输出将不受D后续跳变的影响。对D稳定性的这个进一步要求称为锁存器的保持时间。它是在G跳变之后,D必须保持稳定和有效的时间长度。

建立时间和保持时间要求合称为动态约束。如果这个锁存器要正确运行,就必须遵循这个约束。

总之,动态约束要求D输入在G发生跳变之前和之后都保持稳定和有效。

如果我们的电路设计遵循动态约束,我们就可以保证当门控信号发生高到低跳变时,这个存储元件能够可靠地存储D上的信息。



本节课中我们一起学习了D锁存器的构建和工作原理。我们了解到,通过使用一个2选1多路复用器并引入反馈环路,可以创建一个基本的存储单元。锁存器有两种模式:当门控信号G为高时,它处于透明模式,输出Q跟随输入D;当G为低时,它处于存储模式,锁存并保持G下降沿时刻的D值。为了确保锁存器可靠工作,必须遵守动态约束,即输入信号D在门控信号G的下降沿前后需要满足特定的建立时间和保持时间要求。

047:5.2.3 D寄存器

在本节课中,我们将学习如何设计一个更可靠的存储元件——D寄存器,以解决在时序逻辑系统中使用简单锁存器时可能出现的时序问题。

上一节我们讨论了使用锁存器作为存储元件时,由于门控信号开启时间过长而可能产生的信号环路问题。本节中,我们来看看如何通过一种巧妙的双门结构来解决这个问题。

锁存器的问题与双门方案

让我们尝试使用锁存器作为时序逻辑系统中的存储元件。为了将新状态的编码加载到锁存器中,我们需要将锁存器的门控输入设置为高电平以打开它,让新值传播到代表当前状态的锁存器Q输出端。这个更新后的值会通过组合逻辑传播,从而更新新状态信息。

但是,如果门控信号保持高电平的时间过长,我们就在系统中创建了一个环路。当信息在环路中反复传播时,新状态值开始快速变化,我们加载新状态到锁存器的计划就会出错。

因此,为了使系统正常工作,我们需要精确控制门控信号G为高电平的时间间隔。这个间隔必须足够长以满足动态约束的要求,但又必须足够短,以便在新状态信息有机会绕环路传播一圈之前,锁存器能够再次关闭。这种对时序的精确要求在实际中很难保证,因为信号的确切时序几乎无法保证。我们只有信号转换时间的上下限,而没有精确间隔的保证。我们真正需要的,是一个标记时间点而非时间间隔的加载信号。

以下是一个类比,帮助我们理解正在发生的情况以及我们可以采取的措施。

想象一排汽车在收费站闸门前等待。汽车序列代表了我们时序逻辑中的状态序列,而带闸门的收费站则代表了锁存器。最初,闸门关闭,汽车耐心等待通过收费站。当闸门打开时,第一辆车驶出收费站。但你可以看到,关闭闸门的时机将非常棘手。它必须打开足够长的时间让第一辆车通过,但又不能太长,以免其他车辆也通过。这正是我们在时序逻辑中使用锁存器作为存储元件时所面临的问题。

那么,如何确保只有一辆车通过闸门呢?

一种解决方案是使用两道闸门。计划如下:最初,闸门1打开,允许恰好一辆车进入收费站,同时闸门2关闭。然后在某个特定时间点,我们关闭闸门1,同时打开闸门2。这让收费站里的那辆车得以继续前进,但阻止了任何其他车辆通过。我们可以重复这个两步过程,每次处理一辆车。关键在于,任何时候都不存在同时穿过两道闸门的路径。这与机械钟表中的擒纵机构是相同的原理。擒纵机构确保连接到发条的齿轮一次只前进一个齿,防止发条使齿轮疯狂旋转,导致一天的时间瞬间流逝。

如果我们观察收费站的输出,会看到一辆车在闸门2打开的那个时间点之后不久出现。下一辆车将在闸门2下一次打开之后不久出现,依此类推。车辆通过收费站的速率由闸门2打开的间隔时间决定。

D寄存器的结构与原理

让我们应用这个解决方案来为时序逻辑设计一个存储元件。

借鉴双闸门收费站的设计思路,我们将使用两个背靠背的锁存器设计一个新元件,称为D寄存器。D寄存器的加载信号通常被称为寄存器的时钟,但寄存器的D输入和Q输出所扮演的角色与锁存器中的相同。首先,我们将描述D寄存器的内部结构,然后描述它的功能,并详细研究其工作原理。

D输入连接到我们称为主锁存器的部分,而Q输出连接到从锁存器。请注意,时钟信号在连接到主锁存器的门控输入之前被反相了。因此,当主锁存器打开时,从锁存器关闭,反之亦然。这实现了我们在上一张幻灯片中看到的擒纵行为:任何时候都不存在从寄存器D输入到寄存器Q输出的有效路径。

时钟信号上的反相器引入的延迟可能会引起我们的担忧。当时钟信号发生从0到1的上升沿转换时,是否可能存在一个短暂的间隔,使两个锁存器的门控信号同时为高?因为反相器输出从1转换到0之前会有一个小的延迟。实际上,反相器并非必需。我们来看一个稍有不同的锁存器示意图,其中当G为低电平时锁存器打开,当G为高电平时关闭。这正是我们主锁存器所需要的。顺便提一下,你有时会听到寄存器被称为触发器,这是因为锁存器中正反馈环路的双稳态特性。

这就是D寄存器的内部结构。在下一节中,我们将逐步了解寄存器的工作原理。

D寄存器的工作步骤

以下是D寄存器在一个时钟周期内的工作步骤:

  1. 时钟为低电平(加载阶段):当时钟信号为低电平时,主锁存器的门控输入为高电平(经过反相后),因此主锁存器关闭,其输出保持稳定。同时,从锁存器的门控输入直接为低电平,因此从锁存器打开。此时,从锁存器的输出Q反映了之前存储在寄存器中的值,并对外部电路可见。D输入端的新数据可以进入主锁存器,但由于主锁存器关闭,数据被阻挡在其输入端,不会影响当前输出Q。

  2. 时钟上升沿(捕获时刻):当时钟信号从低电平跳变到高电平的瞬间,这是一个关键的时间点。主锁存器的门控输入变为低电平(反相后),因此主锁存器打开,D输入端的数据被快速捕获并传送到主锁存器的输出端。与此同时,从锁存器的门控输入变为高电平,因此从锁存器关闭,将其输出Q与主锁存器刚刚变化的新输出隔离开来。这个短暂的过渡期由电路设计保证两个锁存器不会同时导通。

  3. 时钟为高电平(保持阶段):当时钟信号稳定在高电平时,主锁存器保持打开,但其输出已经稳定为在上升沿捕获的D值。从锁存器保持关闭,因此寄存器的输出Q保持不变,仍然是上升沿之前的值。此时,即使D输入端的数据发生变化,也不会影响输出Q,因为从锁存器是关闭的,且主锁存器的输出已经锁定。

  4. 时钟下降沿(传输时刻):当时钟信号从高电平跳变回低电平时,主锁存器再次关闭,锁定当前D值。从锁存器则打开,将主锁存器中锁定的新值传输到输出端Q。此时,外部电路看到输出Q更新为在之前上升沿捕获的D值。

这个过程可以总结为:在时钟上升沿,D寄存器捕获输入D的当前值;在时钟下降沿(或下一个上升沿,取决于设计),它将捕获的值传输到输出Q。这种“捕获-传输”机制,通过主从锁存器的交替开关实现,确保了在一个时钟周期内,输入的变化不会直接导致输出的不稳定变化,从而避免了时序竞争问题。寄存器的输出Q只在每个时钟边沿(通常是上升沿)之后更新一次,其值等于上一个时钟边沿时D输入端的数据。这可以用一个简单的时序行为来描述:
Q(t+1) = D(t),其中t代表第t个时钟边沿。

总结

本节课中我们一起学习了D寄存器的设计原理。我们首先指出了简单锁存器在时序逻辑中作为存储元件时,因门控信号时序难以精确控制而可能产生信号环路的问题。接着,通过收费站和擒纵机构的类比,我们引入了使用两个锁存器背靠背工作的双门解决方案。最后,我们详细剖析了D寄存器的内部结构,它由一个主锁存器和一个从锁存器构成,通过反相的时钟信号控制,实现了输入数据的稳定捕获与传输。D寄存器在时钟上升沿采样输入D,并在之后将值稳定输出到Q,从而为时序逻辑电路提供了可靠且易于管理的存储功能。

048:5.2.4 D寄存器时序分析 ⏱️

在本节课中,我们将要学习D寄存器(D Register)的内部工作原理和关键时序参数。我们将通过分析信号在电路中的传播,来深入理解寄存器如何在时钟边沿采样和存储数据。

概述

D寄存器的整体操作很简单:在时钟输入从0到1的上升沿,寄存器对D输入端的值进行采样,并将该值存储起来,直到下一个上升沿到来。Q输出端就是寄存器中存储的值。我们将看到寄存器如何通过主从锁存器结构实现这一功能。

主锁存器操作

上一节我们介绍了D寄存器的基本概念,本节中我们来看看其内部主锁存器(Master Latch)是如何工作的。

时钟信号连接到主锁存器和从锁存器(Slave Latch)的门控输入端。所有关键动作都发生在时钟发生跳变时。时钟从低到高的跳变称为时钟上升沿,从高到低的跳变称为时钟下降沿

让我们首先关注主锁存器及其输出信号(在图中标记为星号*)的操作。

  • 在时钟上升沿,主锁存器从“打开”状态变为“关闭”状态,对其输入端的值进行采样,并进入“记忆”模式。
  • 只要锁存器保持关闭,这个采样值就会成为锁存器的输出。
  • 因此,只要时钟信号为高电平,星号信号就保持稳定。
  • 在时钟下降沿,主锁存器打开,其输出将反映D输入端的任何变化(会有一个锁存器传播延迟 TPD)。

从锁存器操作

现在,让我们分析从锁存器在做什么。从锁存器的输出信号也是整个D寄存器的Q输出。

  • 在时钟上升沿,从锁存器打开,其输出将跟随星号信号的值。
  • 但请记住,当时钟为高电平时,由于主锁存器关闭,星号信号是稳定的。因此,如果从锁存器中保存的值发生变化,Q信号在初始跳变后也会保持稳定。
  • 在时钟下降沿,从锁存器从“打开”状态变为“关闭”状态,对其输入端的值进行采样,并进入“记忆”模式。只要锁存器保持关闭,这个采样值就会成为从锁存器的输出。
  • 可以看到,只要时钟信号为低电平,Q输出就保持稳定。

边沿触发与设计挑战

现在,让我们单独观察一下Q信号本身。它只在从锁存器于时钟上升沿打开时发生变化。其余时间,要么从锁存器的输入是稳定的,要么从锁存器是关闭的。Q输出的变化是由时钟上升沿触发的,因此得名上升沿触发D寄存器

在边沿触发器件的原理图符号中,约定在时钟输入端使用一个小三角形来表示,正如D寄存器的符号所示。

设计寄存器电路时,有一个棘手的问题需要解决。

在时钟下降沿,从锁存器从打开变为关闭。因此,其输入(星号信号)必须满足从锁存器的建立时间和保持时间,以确保正确操作。复杂之处在于,主锁存器在同一时间打开,所以星号信号可能在时钟边沿后不久就发生变化。

  • 主锁存器的污染延迟 TCD 告诉我们,在时钟下降沿之后,旧值能保持稳定多长时间。
  • 从锁存器的保持时间 T_hold 告诉我们,在时钟下降沿之后,其输入需要保持稳定多长时间。

为了确保从锁存器的正确操作,主锁存器的污染延迟必须大于或等于从锁存器的保持时间。进行必要的时序分析可能有点棘手,因为我们必须考虑制造差异以及温度和电源电压等环境因素。

如有必要,可以在主锁存器和从锁存器之间添加额外的门延迟(例如,成对的反相器),以增加相对于时钟下降沿的、从锁存器输入端的污染延迟。请注意,我们只能通过改变电路设计来解决从锁存器的保持时间问题。

D寄存器时序规格总结

以下是D寄存器时序规格的总结:

  • Q信号的变化由时钟输入的上升沿触发。
  • 寄存器的传播延迟 TPD 是时钟上升沿之后,Q输出变得有效和稳定所需时间的上限。
  • 寄存器的污染延迟 TCD 是时钟上升沿之后,Q的先前值保持有效时间的下限。

请注意,TCDTPD 都是相对于时钟上升沿来测量的。寄存器的设计具有一定的“宽容性”:如果Q的旧值和新值相同,则保证Q信号在时钟上升沿期间是稳定的。换句话说,TCDTPD 规格仅在Q输出实际发生变化时才适用。

为了确保主锁存器的正确操作,寄存器的D输入必须满足主锁存器的建立时间和保持时间约束。以下两个规格由主锁存器的时序决定:

  • 建立时间 T_setup:在时钟上升沿之前,D输入必须有效且稳定的时间量。
  • 保持时间 T_hold:在时钟上升沿之后,D输入必须有效且稳定的时间量。

时钟边沿周围的这个稳定区域确保了我们遵守主锁存器的动态规则。

因此,当你使用制造商门库中的D寄存器组件时,需要在寄存器的数据手册中查找这四个时序规格,以便分析整个电路的时序。

总结

本节课中我们一起学习了D寄存器的内部时序行为。我们分析了主从锁存器如何在时钟的上升沿和下降沿协同工作,实现对D输入端数据的采样和稳定输出。我们明确了关键的时序参数:传播延迟 TPD、污染延迟 TCD、建立时间 T_setup 和保持时间 T_hold,并理解了它们在确保电路正确运行中的重要性。在下一节中,我们将看到如何利用这些规格进行实际的电路时序分析。

049:5.2.5 时序电路时序分析

在本节课中,我们将要学习时序电路设计中的核心原则——单时钟同步设计规范,并详细分析如何计算和保证电路的正确时序。

上一节我们介绍了时序电路的基本概念,本节中我们来看看如何确保电路在时钟控制下稳定工作。

单时钟同步设计规范

在6.004课程中,我们采用一种特定的设计方法来使用寄存器,称之为单时钟同步设计规范

观察左侧的电路示意图,它由寄存器(带有边沿触发符号的矩形图标)和组合逻辑电路(此处表示为带有输入和输出的小云朵)组成。由于寄存器的输入和输出之间没有组合逻辑路径,整个电路不存在组合逻辑环路。换句话说,从系统输入和寄存器输出到寄存器输入的路径,不会两次经过同一个组合逻辑模块。

所有时钟器件共享一个单一的周期性时钟信号。使用多个时钟信号是可能的,但分析跨越不同时钟域的信号时序非常复杂,因此当所有寄存器使用同一时钟时,设计会简单得多。

数据信号何时变化的细节在很大程度上并不重要。重要的是连接到寄存器输入端的信号必须稳定有效足够长的时间,以满足寄存器的建立时间要求,并且当然,也要保持稳定足够长的时间以满足寄存器的保持时间要求。

我们可以通过选择时钟周期大于从寄存器输出到寄存器输入的每条路径的传播延迟,再加上寄存器的建立时间,来保证动态规范得到遵守。以这种方式选择时钟周期的一个好处是,在时钟上升沿到来的时刻,电路中任何地方都没有其他可能引起噪声的逻辑转换发生,这意味着在更新每个寄存器的存储状态时,应该没有噪声问题。

我们的下一个任务是学习如何分析单时钟同步系统的时序。

路径时序分析模型

以下是同步系统中一条特定路径的模型。一个大型数字系统将有许多这样的路径,我们必须对每一条路径进行下述分析,以找到决定最小可行时钟周期的关键路径。正如你所料,有计算机辅助设计程序可以为我们完成这些计算。

一个上游寄存器的输出连接到一个组合逻辑电路,该电路生成标记为星号的信号,作为下游寄存器的输入。让我们绘制一个精确的时序图,显示系统中每个信号何时变化以及何时稳定。

时钟的上升沿触发上游寄存器,其输出标记为QR1的变化由寄存器的污染延迟传播延迟决定。QR1至少保持其旧值一个污染延迟的时间,然后在传播延迟时间内达到其最终稳定值。此后,QR1将保持稳定直到下一个时钟上升沿。

现在让我们计算组合逻辑电路输出(图中用红星标记)的波形。逻辑的污染延迟决定了星号信号最早何时变为无效(从QR1变为无效时开始测量)。逻辑的传播延迟决定了星号信号最晚何时稳定(从QR1变为稳定时开始测量)。既然我们知道了星号信号的时序,我们就可以确定它是否满足下游寄存器Reg2的建立时间和保持时间。

以下是关键时序参数的计算:

  • T1:测量时钟上升沿后,星号信号保持有效的时间长度。T1是Reg1的污染延迟与逻辑的污染延迟之和。这段时间必须大于或等于Reg2的保持时间,以确保正确操作。
  • T2:测量Reg1的传播延迟、逻辑的传播延迟与Reg2的建立时间之和。这告诉我们下一个时钟上升沿可以发生的最早时间,同时仍能确保满足Reg2的建立时间要求。因此,T2必须小于或等于时钟上升沿之间的时间,即时钟周期T_clock

如果下一个时钟上升沿在T2之前发生,我们将违反Reg2的动态规范。因此,我们的数字系统中每条寄存器到寄存器的路径都必须满足这两个不等式。如果任何一个不等式被违反,电路将无法保证正确工作。

观察涉及T_clock的不等式,我们看到上游寄存器的传播延迟和下游寄存器的建立时间,占用了本可用于组合逻辑执行有用工作的时间。因此,设计者会尝试使用能最小化这两个时间的寄存器。

如果上游和下游寄存器之间没有组合逻辑会怎样?这在设计触发器、数字延迟线等电路时会发生。那么,第一个不等式告诉我们,上游寄存器的污染延迟最好大于或等于下游寄存器的保持时间。实际上,污染延迟通常小于保持时间,所以通常情况并非如此。因此,设计者通常需要插入虚拟逻辑(例如,两个串联的反相器)来产生必要的污染延迟。

最后,我们还必须担心时钟偏移现象,即时钟信号到达一个寄存器的时间早于到达另一个寄存器的时间。我们在此不深入分析,但其净效应是增加了下游寄存器表现上的建立时间和保持时间(假设我们无法预测偏移的方向)。

时钟周期与系统性能

时钟周期T_clock表征了我们系统的性能。你可能已经注意到,英特尔愿意出售运行在不同时钟频率下的处理器芯片,例如1.7 GHz处理器与2.0 GHz处理器。你有没有想过这些芯片有何不同?事实上,它们并无不同。制造工艺的差异意味着有些芯片的传播延迟比其他的更好。在快速芯片上,逻辑的更小传播延迟意味着它们可以采用更小的T_clock,从而获得更高的时钟频率。因此,英特尔制造了许多相同设计的芯片,测量它们的传播延迟,并选择快速的芯片作为更高性能的部件出售。这就是芯片行业的盈利之道。

本节课中我们一起学习了单时钟同步设计规范,掌握了通过分析寄存器间路径的污染延迟、传播延迟、建立时间和保持时间来确定最小可行时钟周期的方法,并理解了时钟周期如何决定系统性能以及芯片分级背后的原理。

050:5.2.6 时序分析示例 ⏱️

在本节中,我们将学习如何对一个时序逻辑系统进行时序分析。我们将应用之前学到的时序约束不等式,通过一个具体示例来计算关键参数,如最小时钟周期和输入信号的建立与保持时间要求。


使用D寄存器作为存储元件

在时序逻辑系统中,使用D寄存器作为存储元件效果很好。在每个时钟的上升沿,寄存器会加载新的状态值。这个新状态值将在寄存器输出端呈现,并在当前时钟周期的剩余时间内作为“当前状态”使用。

组合逻辑电路利用这个“当前状态”和输入信号的值,来计算“下一个状态”以及输出信号的值。一系列时钟上升沿和输入信号的变化,将产生一系列的状态变化,进而产生一系列的输出。

在下一章,我们将引入一个新的抽象概念——有限状态机,它将使设计时序逻辑系统变得更加容易。


时序分析示例

现在,让我们利用已学的时序分析技术,来分析这里展示的时序逻辑系统。寄存器和组合逻辑的时序规格参数如图所示。

以下是需要回答的问题。

组合逻辑的污染延迟要求

组合逻辑的污染延迟并未指定。为了使系统正常工作,它必须满足什么条件?

我们知道,根据动态准则,寄存器污染延迟逻辑污染延迟之和必须大于或等于寄存器的保持时间。用公式表示即:

t_cd(reg) + t_cd(logic) ≥ t_hold(reg)

使用已知参数并进行简单计算可知,逻辑电路的污染延迟必须至少为 1纳秒

最小时钟周期计算

第二个时序不等式告诉我们,时钟周期 T_clock 必须大于寄存器传播延迟逻辑传播延迟寄存器建立时间三者之和。用公式表示即:

T_clock > t_pd(reg) + t_pd(logic) + t_setup(reg)

代入已知参数值,我们计算出最小时钟周期为 10纳秒

输入信号的时序约束

接下来,我们需要确定输入信号相对于时钟上升沿的时序约束。为此,我们需要借助时序图进行分析。

“下一个状态”信号是寄存器的输入,因此它必须满足寄存器指定的建立时间和保持时间,如图所示。


输入信号约束推导

现在,我们引入输入信号。它的跳变时序如何影响“下一个状态”信号的时序呢?

输入信号的建立时间很容易计算。它等于逻辑电路的传播延迟加上寄存器的建立时间。我们之前计算过,这个值是 7纳秒

换句话说,如果输入信号在时钟上升沿到来之前至少稳定 7纳秒,那么“下一个状态”信号就能在时钟上升沿到来之前至少稳定 2纳秒,从而满足寄存器指定的建立时间要求。

输入信号的保持时间计算如下:它等于寄存器的保持时间减去逻辑电路的污染延迟。我们计算得出这个值为 1纳秒

换句话说,如果输入信号在时钟上升沿到来之后至少保持稳定 1纳秒,那么“下一个状态”信号就能在时钟上升沿之后额外保持稳定 1纳秒,总共达到 2纳秒 的稳定时间,从而满足寄存器指定的保持时间要求。


总结

本节完成了我们对时序逻辑的初步介绍。几乎所有的数字系统都是时序逻辑系统,因此都必须遵守动态准则所施加的时序约束。

所以,下次当你看到一款1.7GHz处理器芯片的广告时,你就会明白这个“1.7”是从何而来的了——它正是由这些基本的时序参数和约束所决定的。

051:5.2.7 实例分析1 🔧

在本节课中,我们将学习如何设计一个可靠的锁存器。我们将分析一个使用多路复用器实现锁存器的方案,并评估三种不同的电路设计,判断它们是否可靠、高效。

锁存器是一种能够存储一位二进制数据的电路。其核心功能是:当控制信号允许时,它可以加载新的数据值;当控制信号禁止时,它将保持原有的数据值不变。

锁存器的工作原理

锁存器可以使用一个多路复用器来设计。其中,控制信号 G 用于决定是否将新的数据值 D 加载到锁存器的输出 Q 中。

G = 0 时,多路复用器选择将 Q 的旧值反馈回输入端,因此输出 Q 保持其原有值不变。

G = 1 时,多路复用器选择数据输入 D 作为其输入,因此输出 Q 被更新为 D 的值。

描述该锁存器操作的逻辑函数是:
Q = (¬G ∧ Q) ∨ (G ∧ D)

我们的目标是仅使用与门、或门和非门来构建这个锁存器。

设计方案评估

我们获得了三种锁存器的设计方案。对于每一种方案,我们需要判断它是“坏的”、“好的”还是“臃肿的”。

  • 坏的:意味着锁存器无法可靠工作。
  • 好的:意味着锁存器能够可靠工作。
  • 臃肿的:意味着锁存器能够工作,但使用了比必要数量更多的门电路。

接下来,让我们逐一分析这些方案。

方案A分析

以下是方案A的电路图。

仔细观察这个电路,我们发现它实现的逻辑函数是:
Q = (G ∧ Q) ∨ D

这并非我们之前指定的锁存器正确逻辑方程。因此,这个设计是坏的

方案B与方案C分析

上一节我们分析了有缺陷的方案A,本节中我们来看看方案B和方案C。

方案B的逻辑方程是:
Q = (¬G ∧ Q) ∨ (G ∧ D)

这恰好与我们为锁存器指定的逻辑方程相同。然而,这个实现方式不是宽容的,因为它无法保证当控制信号 G 的值发生变化时,输出信号 Q 上不会出现毛刺(短暂的错误信号)。


方案C则包含了相同的逻辑函数 Q = (¬G ∧ Q) ∨ (G ∧ D),但额外增加了一个项:Q ∨ D

如果你为这两个逻辑函数分别创建卡诺图,你会发现 Q ∨ D 这一项是冗余的,因为它没有在卡诺图中增加任何新的“1”单元。




这意味着从逻辑功能上看,两个函数是等价的。但额外项的效果是使电路的实现变得宽容,从而消除了输出毛刺。因此,方案C是好的,而方案B是坏的

总结

本节课中我们一起学习了可靠锁存器的设计。我们首先回顾了使用多路复用器实现锁存器的基本原理和逻辑方程 Q = (¬G ∧ Q) ∨ (G ∧ D)。然后,我们分析了三个具体的设计方案:方案A因逻辑方程错误而被判定为“坏的”;方案B虽然逻辑正确,但因非宽容设计可能导致毛刺而被判定为“坏的”;方案C通过增加冗余的逻辑项实现了宽容设计,从而被判定为“好的”。这个实例强调了在数字电路设计中,逻辑正确性、可靠性和电路优化都是需要综合考虑的重要因素。

052:时序约束分析实例 🧮

在本节中,我们将学习如何分析时序逻辑电路中的时序约束。我们将通过一个具体实例,计算寄存器保持时间、时钟周期以及外部输入信号的建立与保持时间要求。

我们被给定一个通用的状态机框图,它包含两个状态位。同时,我们还获得了组合逻辑和状态寄存器的时序参数。

利用这些数据,我们需要确定满足所有时序规范时,寄存器保持时间(T_hold)的最大允许值。

确定最大保持时间 ⏱️

为了满足寄存器的保持时间要求,在时钟沿之后,寄存器的输入必须在T_hold时间内保持稳定。

新变化传播到寄存器输入端的最快路径,是通过计算到达该输入的最短路径上所有污染延迟(T_CD)的总和来确定的。


这个总和是寄存器的T_CD加上逻辑的T_CD。因此,寄存器的T_hold必须小于或等于这个总和。

公式T_hold(register) ≤ T_CD(register) + T_CD(logic)

代入给定的污染延迟值,我们得到:
T_hold ≤ 0.1 ns + 0.2 ns = 0.3 ns

因此,寄存器保持时间的最大允许值为 0.3 纳秒

上一节我们计算了保持时间的约束,接下来我们看看如何确定最小的时钟周期。

确定最小时钟周期 ⏰

时钟周期必须足够长,以确保数据能在下一个时钟周期开始前,穿过整个电路并稳定下来,满足寄存器的建立时间(T_setup)要求。



在这个电路中,数据需要穿过寄存器和组合逻辑。因此,对时钟周期的约束是:T_clock必须大于或等于寄存器的传播延迟(T_PD)、逻辑的传播延迟以及寄存器的建立时间三者之和。

公式T_clock ≥ T_PD(register) + T_PD(logic) + T_setup(register)



代入给定的时序参数:
T_clock ≥ 5 ns + 3 ns + 2 ns = 10 ns


因此,满足时序规范的最小时钟周期为 10 纳秒

了解了系统内部的时序要求后,我们接下来需要确定外部输入信号“in”相对于时钟有效沿的建立和保持时间规范。

确定外部输入的时序要求 🔌

我们希望确定输入信号“in”相对于时钟有效沿的最小建立时间(T_setup)和保持时间(T_hold)规范,以确保整个系统能满足必要的时序要求。

输入信号“in”必须在时钟上升沿之前足够长的时间内保持稳定有效,以便其能通过组合逻辑传播,并准时到达寄存器以满足其建立时间。

因此,“in”的建立时间必须大于或等于逻辑的传播延迟加上寄存器的建立时间。

公式T_setup(in) ≥ T_PD(logic) + T_setup(register)

计算得出:T_setup(in) ≥ 3 ns + 2 ns = 5 ns

一旦输入信号“in”变为无效,寄存器的输入将在逻辑的污染延迟(T_CD)之后变为无效。

“in”必须保持有效足够长的时间,以确保寄存器的输入不会在寄存器的保持时间结束之前就变得无效。

因此,“in”的保持时间加上逻辑的污染延迟,必须大于或等于寄存器的保持时间。

公式T_hold(in) + T_CD(logic) ≥ T_hold(register)

这可以改写为:“in”的保持时间必须大于或等于寄存器的保持时间减去逻辑的污染延迟。

计算得出:T_hold(in) ≥ 0.3 ns - 0.2 ns = 0.1 ns

以下是外部输入“in”的时序要求总结:

  • 最小建立时间(T_setup):5 纳秒。输入信号必须在时钟上升沿之前至少 5 纳秒保持稳定。
  • 最小保持时间(T_hold):0.1 纳秒。输入信号必须在时钟上升沿之后至少保持 0.1 纳秒不变。

总结 📝

本节课中,我们一起学习了如何分析一个时序逻辑电路的时序约束。我们通过一个具体实例,逐步推导并计算出了:

  1. 寄存器保持时间的最大允许值(0.3 ns)。
  2. 系统能够正常工作的最小时钟周期(10 ns)。
  3. 外部输入信号所需满足的最小建立时间(5 ns)和保持时间(0.1 ns)。

理解这些时序约束对于设计稳定可靠的数字系统至关重要。

053:有限状态机 🧠

在本节课中,我们将要学习有限状态机(FSM)这一核心概念。FSM是一种用于描述和设计时序逻辑系统的抽象模型,它能够清晰地定义系统如何根据当前状态和输入来决定下一个状态和输出。我们将通过一个简单的“数字密码锁”例子来理解FSM的设计过程。


在上一章中,我们介绍了时序逻辑,它包含了组合逻辑和存储元件。

组合逻辑是一个由遵循静态规则的元件构成的无环图。静态规则保证,如果我们提供有效且稳定的输入,那么在最后一个输入跳变之后的特定时间间隔内,我们将得到有效且稳定的数字输出。同时,还存在一个功能规范,它告诉我们对于每一种可能的输入值组合,其对应的输出值是什么。

在这个图示中,有 K+M 个输入和 K+N 个输出。因此,组合逻辑的真值表将有 2^(K+M) 行和 K+N 个输出列。

状态寄存器的任务是记住时序逻辑的当前状态。状态被编码为 K 个比特,这允许我们表示 2^K 个唯一的状态。需要记住的是,状态以一种恰当的方式捕获了输入序列的相关历史信息,即先前输入值对时序逻辑操作的影响是通过存储的状态比特来体现的。

通常,状态寄存器的加载输入由一个周期性信号的上升沿触发,该信号用组合逻辑计算出的新状态来更新存储的状态。

作为设计者,我们有几个任务。首先,我们必须确定,针对预期的输入序列,需要生成什么样的输出序列。实际上,一个特定的输入可能会产生一长串输出值,在处理输入序列的逐步过程中,输出可能保持不变,而FSM通过更新其内部状态来记住相关信息。然后,我们必须为逻辑开发功能规范,使其能计算出正确的输出和下一个状态值。最后,我们需要为时序逻辑系统设计出实际的电路图。所有这些任务都相当有趣,让我们开始吧。


作为一个时序系统的例子,让我们来制作一个数字密码锁。

这个锁有一个单比特的输入信号,用户通过它输入一个比特序列作为密码。它有一个输出信号 Unlock,当且仅当输入了正确的密码时,该信号为 1。在这个例子中,我们希望当最后四个输入值是序列 0, 1, 1, 0 时,断言 Unlock(即,将 Unlock 设置为 1)。

这里有一个好问题:我们需要多少个状态比特?我们必须记住最后四个输入比特吗?那样的话我们需要四个状态比特。或者,我们是否可以记住更少的信息,仍然能完成工作?我们不需要最后四个输入的完整历史记录,我们只需要知道最近的输入是否代表了部分输入的正确密码的一部分。换句话说,如果输入序列不代表正确的密码,我们不需要精确追踪它是如何不正确的,我们只需要知道它是不正确的。带着这个观察,让我们来弄清楚如何表示我们数字系统的期望行为。


我们可以使用一种称为有限状态机(FSM)的新抽象来描述时序系统的行为。FSM抽象的目标是独立于实际实现来描述时序逻辑的输入输出行为。

一个有限状态机有一个周期性的时钟输入。时钟的上升沿将触发从当前状态到下一个状态的转换。FSM有一定数量的状态,其中有一个特定的状态被指定为初始状态或起始状态,当FSM首次启动时即处于此状态。

设计FSM时一个有趣的挑战是确定所需的状态数量,因为状态比特的数量与计算下一个状态和输出所需的内部组合逻辑的复杂性之间通常存在权衡。

FSM有一定数量的输入,用于传递FSM完成其工作所需的所有外部信息。这里同样存在有趣的设计权衡:假设FSM需要100比特的输入信息,我们应该设置100个输入并一次性传递所有信息,还是应该设置一个输入,将信息作为一个100个周期的序列来传递?在许多现实场景中,当时序逻辑比我们试图控制的物理过程快得多时,我们经常会看到使用比特串行输入,信息以序列形式到达,一次1比特。这允许我们使用更少的信号硬件,代价是需要按顺序传输信息所需的时间。

FSM有一定数量的输出来传递时序逻辑计算的结果。关于串行与并行输入的上述评论同样适用于选择信息应如何在输出上编码。

有一组转换规则,指定了如何根据当前状态 S 和输入 I 来确定下一个状态 S'。该规范必须是完整的,枚举出每一种可能的 SI 组合所对应的 S'。最后,还有一个关于如何确定输出值的规范。

如果输出严格是当前状态 S 的函数,FSM的设计通常会简单一些。但一般来说,输出可以是 S 和当前输入两者的函数。

现在我们已经建立了抽象模型,让我们看看如何使用它来设计我们的数字密码锁。


在本节课中,我们一起学习了有限状态机(FSM)的基本概念。我们了解到FSM是一种描述时序逻辑行为的强大抽象工具,它由状态、输入、输出、转换规则和输出规范构成。通过“数字密码锁”的例子,我们初步探讨了如何确定状态、设计状态转换以及权衡硬件复杂度与性能。在接下来的课程中,我们将深入探讨FSM的具体设计和实现。

054:状态转移图 📊

在本节课中,我们将学习如何使用状态转移图来描述一个有限状态机的操作,特别是针对一个门锁控制器的例子。我们将从基本概念开始,逐步构建完整的图,并探讨其实现方式。

概述

状态转移图是描述有限状态机行为的一种图形化工具。它使用圆圈表示状态,箭头表示状态之间的转移,并标注触发转移的输入条件和产生的输出。通过这种方式,我们可以清晰地展示FSM如何根据输入序列改变其内部状态并产生相应的输出。

状态转移图的构建

上一节我们介绍了有限状态机的基本概念,本节中我们来看看如何为一个具体的门锁控制器绘制状态转移图。

最初,FSM尚未接收到任何组合密码位,我们称此状态为SX。

在状态转移图中,状态用圆圈表示。每个圆圈暂时用一个符号名称标记,以提醒我们它所代表的历史信息。

对于这个FSM,解锁输出U将是当前状态的函数,因此我们将在圆圈内标明U的值。由于在状态SX中,我们对过去的输入位一无所知,门锁应保持锁定状态,所以U等于0。

我们将用一个加粗边框的圆圈来指示初始状态。

我们将利用状态序列来记住到目前为止看到的输入组合。因此,如果FSM处于状态SX并且接收到输入0,它应该转移到状态S0,以提醒我们已经看到了组合密码0110的第一位。

我们使用箭头来表示状态之间的转移。每个箭头都有一个标签,告诉我们该转移应在何时发生。

所以这个特定的箭头告诉我们,当FSM处于状态SX且下一个输入是0时,FSM应转移到状态S0。转移由FSM时钟输入信号的上升沿触发。

让我们为指定的组合密码的剩余部分添加状态。最右边的状态S0110表示FSM已检测到指定输入序列的时刻,因此在此状态下解锁信号为1。查看状态转移图,我们看到如果FSM从状态SX开始,输入序列0110将使FSM停留在状态S0110。

到目前为止一切顺利。如果输入位不是组合密码中的下一位,FSM应该怎么做?例如,如果FSM处于状态SX且输入位是1。

它仍然没有接收到任何正确的组合密码位,因此下一个状态仍然是SX。

以下是其他状态对应的非正确组合密码输入的转移。

请注意,不正确的组合密码输入不一定使FSM回到状态SX。例如,如果FSM处于状态S0110,最后四个输入位是0110。如果下一个输入是1,那么最后四个输入现在是1101。这不会导致开锁。

但最后两位可能是有效组合序列的前两位。因此,FSM转移到S01,表示在过去两位中已输入了01序列。

摩尔机与米利机

我们一直在处理输出是当前状态函数的FSM,这称为摩尔机。在这里,输出写在状态圆圈内部。

如果输出是当前状态和当前输入的函数,则称为米利机。由于转移也是当前状态和当前输入的函数,我们将使用斜杠分隔输入值和输出值,在每个转移箭头上标注适当的输出值。

因此,查看右侧的状态转移图,假设FSM处于状态S3。如果输入是0,则寻找离开S3且标记为0的箭头。斜杠后的值告诉我们输出值,在这种情况下是1。如果输入是1,输出值将是0。

状态转移图的规则

有一些简单的规则可以用来检查状态转移图是否格式良好。

从一个特定状态出发的转移必须是互斥的。换句话说,对于每个状态,不能有多个具有相同输入标签的转移。如果FSM要一致地运行,这是有道理的;对于给定的当前状态和输入,关于下一个状态不能有任何歧义。

所谓一致,我们指的是如果FSM在相同的起始状态重新启动并给予相同的输入序列,它应该进行相同的转移。

此外,离开每个状态的转移应该是集体完备的。换句话说,应为每个可能的输入值指定一个转移。如果我们希望FSM对于该特定输入值保持其当前状态,我们需要显示一个从当前状态回到自身的转移。

有了这些规则,对于每个当前状态和输入值的组合,将恰好有一个转移被选中。

从图到真值表

状态转移图中的所有信息都可以用表格形式表示为真值表。

真值表的行列出了当前状态和输入的所有可能组合。真值表的输出列告诉我们与每一行相关的下一个状态和输出值。

如果我们用二进制值替换符号状态名称,最终会得到一个真值表,就像我们在第4章中看到的那样。

如果我们的状态转移图中有K个状态,我们将需要 ceil(log₂(K)) 个状态位,因为状态位不能是分数。在我们的例子中,我们有一个5状态的FSM,所以我们需要3个状态位。

我们可以以任何方便的方式分配状态编码,例如,第一个状态为000,第二个状态为001,依此类推。

但是状态编码的选择会对实现真值表所需的逻辑产生很大影响。找出能产生最简单逻辑的状态编码实际上很有趣。

有了真值表,我们可以使用第4章的技术来设计实现FSM组合逻辑的逻辑电路。

当然,我们可以选择简单的方法,直接使用ROM来完成这项工作。😊

使用ROM的实现

在这个电路中,使用只读存储器根据当前状态和输入计算下一个状态和输出。

我们使用3位二进制值对FSM的五个状态进行编码,因此我们有一个3位状态寄存器。带有边沿触发输入的矩形是多位寄存器的示意图简写。

如果图中的电线代表多比特信号,我们会在电线上画一条斜线并标上数字,以指示信号中有多少位。在这个例子中,当前状态和下一个状态都是3位信号。

只读存储器总共有四个输入信号:3个用于当前状态,1个用于输入值。因此,ROM有 2⁴ = 16 个位置,对应真值表的16行。

ROM中的每个位置提供真值表特定行的输出值。由于我们有四个输出信号(3个用于下一个状态,1个用于输出值),每个位置提供4位信息。存储器通常用其位置数量和每个位置的位数来标注。

所以我们的存储器是一个 16 x 4 的ROM,即16个位置,每个位置4位。当然,为了使状态寄存器正常工作,我们需要确保遵守动态规则。

我们可以使用第5章末尾描述的时序分析技术来检查这一点。目前,我们假设输入上的时序转换与时钟的上升沿正确同步。

设计选择与总结

现在,在设计时序逻辑系统的功能时,我们有了FSM抽象可以使用。

以下是使用ROM和多比特状态寄存器的FSM通用电路实现的设计选择总结。

输出位可以严格是当前状态的函数;这样的FSM称为摩尔机。或者,它们可以是当前状态和当前输入的函数,在这种情况下,FSM称为米利机。

我们可以选择状态位的数量。S个状态位将使我们能够编码 2^S 个可能的状态。请注意,每增加一个状态位,ROM中的位置数量就会翻倍。因此,当使用ROM来实现必要的逻辑时,我们对最小化状态位的数量非常感兴趣。

我们电路的波形相当简单。时钟的上升沿触发状态寄存器输出的转换。然后ROM进行计算,得出下一个状态,该状态在时钟周期的某个时刻变为有效。这个值将在下一个时钟上升沿被加载到状态寄存器中。

随着FSM遵循状态转移图规定的状态转移,这个过程会一遍又一遍地重复。

其他注意事项

还有一些细节需要注意。

启动时,我们需要某种方法将状态寄存器的初始内容设置为初始状态的正确编码。许多设计使用复位信号,将其设置为1以强制进入某个初始状态,然后设置为0以开始执行。我们可以在这里采用这种方法,使用复位信号来选择要加载到状态寄存器中的初始值。

在我们的例子中,我们使用了3位状态编码,这将允许我们实现一个最多有 2³ = 8 个状态的FSM。我们只使用了这些编码中的五个,这意味着ROM中有一些位置我们永远不会访问。如果这是一个问题,我们总是可以使用逻辑门来实现必要的组合逻辑,而不是ROM。

假设状态寄存器不知何故被加载了一个未使用的编码。那么,这就像处于我们的状态转移图中未列出的状态。防御此问题的一种方法是设计ROM内容,使未使用的状态始终指向初始状态。理论上,这个问题不应该出现,但有了这个修复,至少它不会导致未知行为。

我们之前提到了寻找最小化组合逻辑的状态编码这个有趣的问题。有计算机辅助设计工具可以帮助我们做到这一点,作为寻找布尔函数最小逻辑实现这一更大问题的一部分。

Mr. Blue向我们展示了构建门锁控制器状态寄存器的另一种方法:使用移位寄存器捕获最后四个输入位,然后简单地查看记录的历史以确定是否与组合密码匹配。这里没有花哨的下一个状态逻辑。

最后,我们仍然需要解决确保输入转换不违反状态寄存器动态规则的问题。我们将在本章最后一节讨论这个问题。

总结

本节课中我们一起学习了如何绘制和分析状态转移图,这是描述有限状态机行为的核心工具。我们了解了摩尔机和米利机的区别,探讨了如何将状态转移图转换为真值表,并最终用逻辑电路或ROM实现。我们还讨论了状态编码的重要性以及一些实际实现中的细节问题,如初始化和未使用状态的处理。

055:6.2.3 FSM状态 🧠

在本节课中,我们将要学习有限状态机(FSM)抽象概念中关于状态数量、系统组合以及状态机等价性的核心知识。

关于FSM状态的进一步思考

上一节我们介绍了FSM的基本概念,本节中我们来看看关于FSM状态数量的一些推论。

如果一个FSM使用了 K 个状态位,那么它的状态转移图中最多可以有多少个状态?我们知道,FSM最多可以有 2^K 个状态,因为这是 K 位二进制数所能表示的独特组合的数量。

组合FSM的状态数量

现在,假设我们将两个FSM串联起来,第一个FSM的输出作为第二个FSM的输入。这个更大的系统本身也是一个FSM。那么它有多少个状态呢?

如果我们不知道这两个组件FSM的具体细节,那么这个更大系统的状态数量上限是 M × N。这是因为第一个FSM可能处于其 M 个状态中的任何一个,而同时第二个FSM可能处于其 N 个状态中的任何一个。需要注意的是,这个答案并不依赖于 XY(即每个组件FSM的输入信号数量)。更多的输入信号仅仅意味着状态转移图上的转换标签更长,但并不能告诉我们关于内部状态数量的任何信息。

探索未知FSM的行为

最后,这里有一个看似简单实则棘手的问题。我给你一个带有两个输入(标记为0和1)和一个输出(实现为一个灯)的FSM。然后我要求你发现它的状态转移图。你能做到吗?

为了更具体一些,假设你实验了一个小时,按下了各种顺序的按钮。每次按下0按钮,如果灯亮着,它就会熄灭。当你按下1按钮时,如果灯是熄灭的,它就会亮起。否则,似乎什么也不会发生。根据我们的实验,我们可以画出什么样的状态转移图?

考虑以下两个状态转移图。上面的图描述了我们实验中观察到的行为:按0关灯,按1开灯。第二个图似乎做了同样的事情,除非你恰好连续按了四次1按钮。

如果我们不知道FSM中状态数量的上限,我们就永远无法确定我们已经探索了所有可能的行为。但是,如果我们确实有一个上限,比如状态数量上限为 K,并且我们可以将FSM重置到其初始状态,那么我们就能发现它的行为。这是因为在一个 K 状态的FSM中,从初始状态开始,每个可达状态都可以在少于 K 次转换内到达。

因此,如果我们一个接一个地尝试所有可能的 K 步输入序列,并且每次试验都从初始状态开始,那么我们就能保证访问到机器中的每一个状态。

FSM的等价性

我们的答案还因为一个观察而变得复杂:具有不同状态数量的FSM可能是等价的。

这里有两个FSM,一个有2个状态,一个有5个状态。它们不同吗?从任何实际意义上讲,它们并不不同。由于这两个FSM在外部是无法区分的,我们可以互换使用它们。

我们说两个FSM是等价的,当且仅当每个输入序列从两个FSM产生相同的输出序列。因此,作为工程师,如果我们有一个FSM,我们希望找到最简单、从而成本最低的等价FSM。我们将在下一个示例的背景下讨论如何找到更小的等价FSM。

总结

本节课中我们一起学习了FSM状态数量的上限(2^K),组合FSM的状态数量上限(M × N),以及如何通过有限步骤探索未知FSM的行为。我们还了解了FSM等价性的概念,即两个外部行为完全相同的FSM可以互换,而工程师的目标通常是找到状态数最少、最简单的等价实现。

056:6.2.4 机器人蚂蚁示例 🐜

在本节课中,我们将学习如何为一个机器人蚂蚁设计一个有限状态机大脑,使其能够遵循“右手法则”走出一个简单的迷宫。我们将从理解蚂蚁的输入输出开始,逐步构建状态机,并通过模拟器验证其行为。

概述

我们获得了一个机器人蚂蚁,它的大脑是一个有限状态机。FSM的输入来自蚂蚁的两根触角,标记为L和R。当触角碰到物体时,输入为1,否则为0。FSM的输出控制蚂蚁的运动:设置F输出为1使其前进,分别设置TL或TR输出为1使其左转或右转。如果蚂蚁同时尝试转向和前进,转向会优先发生。需要注意的是,蚂蚁在触角碰到物体时可以转向,但不能前进。

我们的挑战是设计一个FSM大脑,让蚂蚁能够走出一个如图所示的简单迷宫。我们记得,如果迷宫没有孤立的墙,我们可以使用“右手法则”逃脱:将右手放在墙上,然后沿着墙行走,始终保持手在墙上。

设计策略

我们假设蚂蚁最初迷失在空间中。唯一合理的策略是向前走,直到找到迷宫的墙。

因此,我们的初始状态标记为“Lost”,它断言F输出,使蚂蚁向前移动,直到至少一根触角碰到东西;换句话说,直到L或R输入中至少有一个为1。

现在,蚂蚁会发现自己处于以下三种情况之一。为了实施右手法则,蚂蚁应该向左(逆时针)旋转,直到它的触角刚好离开墙壁。

为了实现这一点,我们将添加一个“Rotate CCW”状态,它断言左转输出,直到L和R都为0。

现在,蚂蚁的右侧有一堵墙,我们可以开始用它的右触角沿着墙行走的过程。

我们让蚂蚁向前和向右移动,假设它会立即再次碰到墙。

“Wall1”状态同时断言右转和前进输出,然后检查右触角以决定下一步该做什么。

如果右触角如预期般碰到了墙,蚂蚁就向左转以释放触角,然后向前走。

“Wall2”状态同时断言左转和前进输出,然后检查触角。

如果右触角仍然碰到墙,它需要继续转向。如果左触角碰到墙,说明它遇到了一个内角,需要重新调整方向,使新的墙位于其右侧。这种情况我们已经在“Rotate CCW”状态中处理过了。

最后,如果两根触角都自由了,蚂蚁应该处于上一张幻灯片描述的状态,即与墙平行站立。因此,我们返回到“Wall1”状态。

我们期望FSM会在蚂蚁沿墙移动时,在“Wall1”和“Wall2”状态之间交替切换。如果它到达一个内角,它会旋转以使新的墙位于右侧,然后继续前进。

处理外角

当蚂蚁处于“Wall1”状态时,它向前移动并右转,然后检查其右触角,期望找到它正在沿着走的墙。但如果是一个外角,就没有墙可碰。

在这种情况下,正确的策略是继续右转并前进,直到右触角碰到拐角处的墙。

“Corner”状态实现了这一策略,当蚂蚁再次到达墙时,它会转换到“Wall2”状态。

模拟验证

让我们在模拟器中尝试一下。左边是FSM大脑的转移表文本表示。每一行指定一个输入模式,如果匹配,将设置指定的下一个状态和输出信号。这个特定版本的机器人蚂蚁允许蚂蚁放置或拾取面包屑,并感知它遇到的面包屑,但本演示不需要这些输入和输出。

输入模式指定了当前状态和触角输入的值。模拟器会高亮显示与当前输入匹配的表中的行。如你所见,最初蚂蚁处于“Lost”状态,两根触角都没有碰到东西。

右边是一张地图,显示了我们绿色的蚂蚁站在一个有蓝色墙壁的迷宫中。我们可以选择几个不同的迷宫进行尝试。要看到蚂蚁的行动,让我们多次点击步进按钮。

经过几步后,蚂蚁碰到了墙,然后逆时针旋转以释放其触角。现在它开始沿着墙走,直到到达一个拐角,此时它继续右转和前进,直到再次与墙接触。

现在,我们让它运行,观察FSM如何耐心地执行程序策略,响应输入并生成适当的输出响应。通过更多的传感器和执行器,你可以看到相当复杂的行为和响应是可能的。本质上,这正是现代机器人所做的。它们也拥有充满预编程行为的FSM大脑,使它们能够执行指定的任务。

总结

本节课中,我们一起学习了如何为一个简单的机器人蚂蚁设计有限状态机控制器。我们从定义输入(L, R)和输出(F, TL, TR)开始,根据“右手法则”制定了走出迷宫的策略,并据此设计了包含“Lost”、“Rotate CCW”、“Wall1”、“Wall2”和“Corner”等状态的状态机。最后,我们通过模拟器验证了状态机的行为,看到它能够成功地引导蚂蚁走出迷宫。这个例子展示了FSM如何作为机器人的“大脑”,通过预定义的状态和转移来响应环境并完成特定任务。

057:6.2.5 等价状态与实现 🧠

在本节课中,我们将要学习如何通过寻找和合并等价状态来简化有限状态机,并了解如何使用逻辑门来实现FSM。

概述

之前我们讨论了如何寻找具有更少状态的等价FSM。现在,我们将开发一种方法来寻找这样的FSM。其核心思路是寻找两个可以合并为一个状态,且不会以任何外部可区分的方式改变FSM行为的等价状态。

等价状态的定义

两个状态被认为是等价的,当且仅当它们满足以下两个标准:

  1. 状态必须具有相同的输出。这很合理,因为输出对外部可见。如果两个状态的输出值不同,这种差异显然是外部可区分的。
  2. 对于每一种输入值的组合,这两个状态都必须转换到等价的状态

我们的策略是从原始FSM开始,寻找成对的等价状态,然后合并这些状态。我们将不断重复这个过程,直到找不到更多的等价状态为止。

应用实例:蚂蚁FSM

让我们在蚂蚁FSM上尝试这个方法。首先,我们需要找到一对具有相同输出的状态。

实际上,只有一对这样的状态:wall1corner,它们都断言“右转”和“前进”输出。

接下来,我们假设 wall1corner 是等价的,并检查对于每种适用的输入值组合,它们是否都转换到等价的状态。

对于这两个状态,所有的转换都只取决于输入 R 的值。因此,我们只需要检查两种情况:

  • 如果 R0,两个状态都转换到 corner
  • 如果 R1,两个状态都转换到 wall2

因此,两个等价标准都得到了满足。我们可以得出结论:wall1corner 状态是等价的,可以合并。

这为我们提供了上图所示的四状态FSM,我们将合并后的单一状态称为 wall1。这个更小的等价FSM的行为与之前的五状态FSM完全相同。

五状态机的实现需要三个状态位,而四状态机的实现只需要两个状态位。减少一个状态位是巨大的改进,因为它将所需ROM的尺寸减少了一半。

正如我们通过最小化布尔方程可以实现相当大的硬件节省一样,我们也可以通过合并等价状态在时序逻辑中实现同样的效果。蚂蚁客户们正期待着降价呢!

使用逻辑门实现FSM

上一节我们介绍了如何通过合并状态来简化FSM。本节中我们来看看,如果想使用逻辑门(而不是ROM)来实现组合逻辑,我们需要做什么。

首先,我们必须构建真值表,录入状态转换图中的所有转换。

我们从 lost 状态开始。如果FSM处于此状态,输出 F 应为 1。如果两个天线输入都是零,下一个状态也是 lost。为 lost 状态分配编码 00,我们已经在表格的第一行捕获了此信息。

如果任一触角被触碰,FSM应从 lost 状态转换到 rotate counterclockwise 状态。我们给这个状态分配编码 01。有三种 LR 值的组合与此转换匹配,因此我们在真值表中添加了三行。这样就处理了从 lost 状态出发的所有转换。

现在我们可以处理从 rotate counterclockwise 状态出发的转换。如果任一触角被触碰,下一个状态再次是 rotate counterclockwise。因此,我们识别了匹配的输入值,并将适当的三行添加到转换表中。

我们可以以类似的方式继续,逐个编码所有的转换。

上图是最终的表格,我们使用了“无关项”来减少用于演示的行数。

接下来,我们希望为组合逻辑的每个输出(即两个下一个状态位和三个运动控制输出)推导出布尔方程。

以下是两个下一个状态位的卡诺图。利用我们在第4章学到的KMap技能,我们将为 S1' 找到一个质蕴含项的覆盖,并以最小积之和方程的形式写下对应的乘积项。然后对另一个下一个状态位进行同样的操作。

我们可以遵循类似的过程,为运动控制输出推导出最小积之和表达式。

以直接的方式用与门和或门实现每个积之和表达式,我们得到了蚂蚁大脑的如下原理图。很简洁。

谁能想到蚂蚁的跟随行为可以用几个D寄存器和少量逻辑门来实现呢?

FSM的广泛应用

有许多复杂的行为可以用出人意料的简单FSM来创建。早期,计算机图形学领域的研究者就发现,像集群、鸟群和鱼群这样的群体行为,可以通过为每个参与者配备一个简单的FSM来建模。

所以,下次你看到《指环王》电影中的大规模战斗场景时,可以想象成许多FSM在并行运行。

由组成分子之间的简单相互作用产生的物理行为,有时使用元胞自动机(一种相互通信的FSM网络)来建模,比试图求解描述分子行为约束的偏微分方程更容易。

这里还有一个想法:如果我们允许FSM修改它自己的转换表呢?嗯,也许这是一个合理的进化模型。

FSM无处不在。在你的余生中,你都会看到FSM。

总结

本节课中我们一起学习了如何定义和寻找有限状态机中的等价状态,并通过合并它们来简化电路设计,从而减少硬件开销。我们还探讨了使用逻辑门(而非ROM)来实现FSM组合逻辑的具体步骤,包括构建真值表和推导最小化布尔方程。最后,我们了解了FSM在模拟复杂行为(如群体行为和物理现象)中的广泛应用。

058:6.2.6 同步与亚稳态

在本节课中,我们将要学习当异步输入信号进入时序逻辑电路时可能引发的问题。我们将探讨亚稳态现象的本质、它带来的风险,以及如何通过同步器设计来有效管理这些风险,确保数字系统的可靠运行。

概述

当输入信号的时序与系统时钟完全独立时,我们称其为异步输入。这种情况常见于来自外部世界的信号,其事件发生的时间不受我们控制。为了确保状态寄存器的可靠运行,时序逻辑系统的输入必须遵守相对于系统时钟上升沿的建立时间和保持时间约束。然而,异步输入可能在任何时间变化,包括违反这些约束的时间点。因此,我们需要设计同步器电路来处理这些异步信号。

同步器规范与挑战

上一节我们介绍了异步输入的问题,本节中我们来看看一个理想的同步器应满足的规范,以及为什么这是一个难以完全解决的问题。

同步器有两个输入:InClock,其跳变分别发生在时间 T_NT_C

  • 如果 In 的跳变足够早于 Clock 的跳变,我们希望同步器在时钟跳变后的某个有界时间 T_D 内输出 1
  • 如果 Clock 的跳变足够早于 In 的跳变,我们希望同步器在时钟跳变后的时间 T_D 内输出 0
  • 最后,如果两个跳变之间的间隔小于某个指定间隔 T_E,同步器可以在时钟跳变后的时间 T_D 内输出 01。只要在指定截止时间前输出稳定的数字 01,两种答案均可接受。

事实证明,对于任何有限的 T_ET_D 值,都无法构建一个保证满足此规范的同步器,即使使用100%可靠的元件。

D寄存器作为同步器的局限性

既然直接满足规范有困难,我们能否简单地使用一个D寄存器来解决问题呢?让我们来分析一下。

我们将 In 连接到寄存器的数据输入 D,将 Clock 连接到寄存器的时钟输入 CLK。我们设定决策时间 T_D 为寄存器的传播延迟,允许的误差间隔 T_E 为寄存器建立时间和保持时间中较大的那个。

我们的理论是:

  • 如果 In 的上升沿至少发生在 Clock 上升沿的 T_setup 时间之前,寄存器保证输出 1
  • 如果 InClock 上升沿的 T_hold 时间之后发生跳变,寄存器保证输出 0

到目前为止,一切顺利。如果 In 在相对于 Clock 上升沿的建立和保持时间内跳变,我们知道我们违反了动态规则,无法确定寄存器将存储 0 还是 1。但根据我们的规范,在这种情况下我们可以输出任意值,所以问题似乎解决了,对吗?

遗憾的是,并非如此。我们被数字抽象所误导,假设即使违反了动态规则,在传播延迟后 Q 也必须是 10。但这不是一个有效的假设。当我们更仔细地观察寄存器主锁存器的操作时,会发现当 DCLK 大约同时变化时,情况会变得复杂。

亚稳态的本质

为了理解问题的根源,我们需要深入探究当输入变化违反时序约束时,寄存器内部发生了什么。

主锁存器本质上是一个可配置为双稳态存储元件的锁存器,它利用正反馈环路工作。当锁存器处于存储模式时,它基本上是一个双门循环电路。其行为由两个约束决定:图中绿色所示的双门电路的电压传输特性,以及图中红色所示的 V_in = V_out 约束。这两条曲线在三个点相交。我们关注的是中间的相交点。

如果 InClock 同时变化,在锁存器关闭并启用正反馈环路时,Q 上的电压可能正在过渡。因此,反馈环路中的初始电压可能恰好处于或非常接近中间相交点的电压。当 Q 处于亚稳态电压时,存储环路处于一种称为亚稳态的不稳定平衡状态。理论上,系统可以永远平衡在这个点,但环路电压的微小变化将使系统远离亚稳态平衡点,并不可逆转地使其向稳定的平衡点移动。

我们面临的问题是:无法界定系统停留在亚稳态的时间长度。

亚稳态的影响与特性

了解了亚稳态的产生机制后,我们来看看它对系统的影响以及它的一些关键特性。

以下是关于亚稳态我们所知的信息:

  • 它处于数字信号规范的禁止区域,因此对应于无效的逻辑电平。
  • 违反动态规则意味着我们的寄存器不再保证在有界时间内产生数字输出。
  • 持续的无效逻辑电平可能对我们的时序逻辑电路造成逻辑和电气上的破坏。

从逻辑层面看,由于具有无效输入的组合逻辑 H 会产生不可预测的输出,无效信号可能破坏我们时序系统中的状态和输出值。从电气层面看,如果CMOS门的输入处于亚稳态电压,由该输入控制的PFET和NFET开关都会导通,从而在 V_DD 和地之间形成通路,导致系统功耗激增。

亚稳态是一种不稳定平衡,最终会通过过渡到两个稳定平衡点之一而得到解决。从图中可以看出,亚稳态电压处于VTC的高增益区域,因此 V_in 的微小变化会导致 V_out 的巨大变化。一旦远离亚稳态点,环路电压将向 0V_DD 移动。

系统演化到稳定平衡所需的时间与正反馈环路启用时 Q 的电压距离亚稳态点的接近程度有关。Q 的初始电压越接近亚稳态电压,系统解决亚稳态所需的时间就越长。但由于 Q 距离亚稳态电压的接近程度没有下限,因此解决时间也没有上限。换句话说,如果你指定一个可用的解决时间 T_D,总会存在一个初始 Q 电压范围,无法在该时间内得到解决。

如果系统在某个时间点进入亚稳态,那么对于任何有限的时间间隔 T,系统在间隔 T 后仍然处于亚稳态的概率是非零的。好消息是,在间隔结束时仍处于亚稳态的概率随着 T 的增加呈指数级下降。

请注意,每个双稳态系统至少有一个亚稳态,因此亚稳态是我们使用正反馈环路构建存储元件所付出的代价。

应对策略:同步器与隔离

既然亚稳态无法完全避免,我们应该如何设计系统来应对它呢?本节介绍一种实用的隔离策略。

我们处理异步输入的方法是通过在第一个寄存器的输出端添加第二个寄存器,对来自D寄存器同步器的潜在亚稳态值进行“隔离”。如果输入上的跳变违反了动态规则并导致第一个寄存器进入亚稳态,这不会立即成为问题,因为亚稳态值被第二个寄存器阻止进入系统。

实际上,在时钟周期的前半段,第二个寄存器中的主锁存器是关闭的,因此亚稳态值被完全忽略。直到下一个时钟边沿,即整整一个时钟周期之后,第二个D寄存器才需要一个有效且稳定的输入。第一个寄存器在整整一个时钟周期后仍处于亚稳态的概率仍然存在,但我们可以通过选择足够长的时钟周期,使这个概率低到我们期望的水平。

换句话说,第二个寄存器的输出(为内部组合逻辑提供信号)将以我们选择的概率保持稳定和有效。有效性并非100%保证,但故障时间间隔可能长达数年甚至数十年,因此在实践中这不是问题。如果没有第二个寄存器,系统可能每隔几小时就会遇到一次亚稳态故障。确切的故障率取决于电路中的跳变频率和增益。

如果我们的时钟周期很短,但又希望有较长的隔离时间怎么办?我们可以使用多个串联的隔离寄存器。决定故障概率的是从第一个寄存器进入亚稳态到内部逻辑使用同步输入之间的总延迟。

核心结论:我们可以使用同步寄存器将潜在的亚稳态信号隔离一段时间。由于保持亚稳态的概率随隔离时间呈指数下降,我们可以将故障概率降低到任何期望的水平。虽然不是100%保证,但如果使用我们的隔离策略,亚稳态在实践中将不再是一个问题。

总结

本节课中我们一起学习了异步输入给时序逻辑电路带来的挑战。我们深入探讨了亚稳态现象,理解了它产生的原因、无法完全避免的本质以及对系统可靠性的潜在威胁。最重要的是,我们掌握了一种有效的应对策略:使用两级或多级寄存器构成的同步器对异步信号进行隔离。通过合理设计隔离时间,我们可以将亚稳态导致的系统故障概率降低到工程上可接受的水平,从而确保数字系统在面对不可控的异步输入时仍能稳定运行。

059:FSM状态与转移 🧮

在本节课中,我们将通过一个具体的实例,学习如何分析一个有限状态机(FSM)的状态转移图、计算输出序列、寻找通用输入序列,并判断状态是否等价以简化FSM。

概述

我们有一个五状态的米利型有限状态机(FSM),它具有一个1位输入 in 和一个2位输出 out。其初始状态为 B。我们将从一个部分填充的状态转移图和一个真值表出发,完成以下任务:

  1. 补全状态转移图中缺失的状态和转移标签。
  2. 计算给定输入序列下的输出序列。
  3. 寻找一个能从任意状态都到达指定状态 E 的通用输入序列。
  4. 判断是否存在一个等价的、状态数更少的FSM。

补全状态转移图

上一节我们介绍了FSM的基本概念,本节中我们来看看如何利用真值表来补全状态转移图。

给定的真值表定义了FSM的行为:对于每个当前状态和输入组合,它指明了下一个状态和输出。我们的起始状态是 B

以下是补全状态转移图的步骤:

我们从起始状态 B 开始。根据真值表,当输入 in = 0 时,下一个状态是 C。由于图中从状态 B 出发、输入为 0 的转移箭头已经标出,我们可以确定:

  • S2 对应的状态就是 C
  • 同时,这意味着 T2 对应的是输入 in = 1 的情况。

根据真值表,从状态 Bin = 1 时,会转移到状态 D 并输出 00。因此:

  • S4 对应的状态是 D
  • T2 对应的转移标签是 1/00

现在,我们来看状态 D。图中已经标出了 in = 1 的转移,所以 T3 对应 in = 0。查真值表可知,从状态 Din = 0 时,会转移到状态 E 并输出 01。因此:

  • S3 对应的状态是 E
  • T3 对应的转移标签是 0/01

以此类推,我们可以完成整个图的填充。最终结果如下:

  • S1 = A, S2 = C, S3 = E, S4 = D
  • T1 = 1/11, T2 = 1/00, T3 = 0/01, T4 = 1/01, T5 = 0/10, T6 = 1/01, T7 = 0/10

计算输出序列

现在我们已经有了完整的状态转移图,接下来我们计算当FSM从状态 B 开始,并接收输入序列 100 时,会产生什么输出序列。

以下是状态转移过程:

  1. 起始状态为 B。输入第一个位 1,根据转移 T2 (1/00),FSM转移到状态 D,输出 00
  2. 当前状态为 D。输入第二个位 0,根据转移 T3 (0/01),FSM转移到状态 E,输出 01
  3. 当前状态为 E。输入第三个位 0,根据转移 T5 (0/10),FSM转移到状态 A,输出 10

因此,对于输入序列 100,对应的输出序列是 00 01 10

寻找通用输入序列

接下来,我们尝试寻找一个输入序列,无论FSM初始处于哪个状态(A, B, C, D, E),在该序列处理完毕后,都能保证FSM最终停留在状态 E

我们需要为每个可能的起始状态,找到一条通往状态 E 的路径,并尝试找到一个公共的输入序列。

以下是分析过程:

我们从状态 A 开始。观察状态图,从 AE 的最短路径是输入序列 110A -> B -> D -> E)。

对于状态 B,序列 10 即可到达 EB -> D -> E)。但我们需要一个通用序列,所以测试 110 是否也适用。对于状态 B110 的路径是 B -> D -> D -> E(第一个 1 进入 D,第二个 1 使状态保持在 D,最后的 0 进入 E),同样成功。

检查其他状态:

  • 状态 D: 输入 110,路径为 D -> D -> D -> E,成功。
  • 状态 E: 输入 110,路径为 E -> B -> D -> E,成功。
  • 状态 C: 输入 110,路径为 C -> B -> D -> E,成功。

综上所述,输入序列 110 是一个通用序列,无论初始状态如何,都能使FSM最终进入状态 E

状态等价与FSM简化

最后,我们来探讨这个五状态FSM是否可以简化成一个四状态的等价FSM。这涉及到“状态等价”的概念。

在米利型FSM中,如果两个状态对于所有可能的输入,都产生相同的输出并转移到相同的下一个状态,那么这两个状态是等价的,可以合并为一个状态。

让我们检查当前FSM中的状态对。通过对比状态转移表(或观察完整的状态图),我们发现状态 C 和状态 E 的行为完全一致:

  • 当输入 in = 0 时,两者都转移到状态 A,并输出 10
  • 当输入 in = 1 时,两者都转移到状态 B,并输出 01

用公式化的方式表达,对于任意输入 i

NextState(C, i) == NextState(E, i) 且 Output(C, i) == Output(E, i)

因此,状态 C 和状态 E 是等价的。我们可以将它们合并为一个状态(例如,称为状态 CE)。合并后,所有指向原状态 CE 的转移,现在都指向新状态 CE;从新状态 CE 出发的转移,则继承原来 CE 的转移关系。这样,我们就得到了一个功能完全等价但只有四个状态的FSM。

总结

本节课中我们一起学习了有限状态机的综合分析。

  1. 我们利用真值表补全了状态转移图的缺失信息。
  2. 我们通过模拟状态转移过程,计算出了给定输入序列对应的输出序列。
  3. 我们通过分析从各状态到目标状态的路径,寻找到了一个通用的输入序列 110,它能从任意初始状态将FSM驱动到指定状态 E
  4. 最后,我们应用状态等价的原则,识别出状态 CE 是等价的,从而可以将原五状态FSM简化为一个四状态的等价FSM。这个过程体现了优化数字逻辑设计的基本思想。

060:FSM实现 🧩

在本节课中,我们将学习如何根据给定的状态转移图,完成一个摩尔型有限状态机(FSM)的真值表,并分析其中是否存在等价状态。

我们被给定了一个包含四个状态(A、B、C、D)的状态转移图。这是一个摩尔机,因为其输出仅取决于当前状态。同时,我们还获得了一个部分填充的真值表,我们的首要任务是补全真值表中的缺失项。

确定状态编码 🔍

为了完成真值表,我们需要找出状态A到D与它们的编码(S1, S0)之间的对应关系。

我们首先查看输出列。我们知道只有状态A的输出为1,因此状态A的编码必须是(0,0),并且无论输入是什么,输出都为1,因为它是一个摩尔机。

根据状态图,从状态A出发,当输入为1时,FSM转移到状态B。查看真值表,从状态(0,0)即A出发,在输入为1时,我们转移到状态(0,1)。这意味着状态B的编码是(0,1)。

我们继续以这种方式进行,现在追踪从状态B出发的转移。

补全真值表 📝

根据状态图,当输入n等于0时,我们从状态B转移到状态D。在真值表中查找这个转移,可以得知状态D的编码是(1,0)。

同时,从状态图中我们看到,从状态B出发,当n等于1时,我们回到状态A(我们现在知道是00)。因此,我们可以填充真值表中的下一个缺失项。

至此,我们已经确定了状态A、B和D的编码,剩下的编码(1,1)自然对应状态C。

现在,我们拥有完成真值表所需的所有信息。

查看当前状态为(1,0)(即状态D)且输入in等于1的行,并对照状态图中的相应转移,显示在这种情况下,我们回到状态D。由于状态D的编码是(1,0),所以S0‘(次态S0)项是0。

因为这是一个摩尔机,我们还知道与当前状态D相关联的输出,无论输入是什么,都是0。所以缺失的输出项是0。

最后,从状态(1,1)(即状态C)出发,当n等于1时,我们从状态图看到我们转移到状态A,即状态(0,0)。因此,剩下的缺失的S0‘值是0。

识别等价状态 ⚖️

现在,我们想确定这个FSM中是否存在任何等价状态。

在摩尔机中,等价状态具有相同的输出和相同的输入转移行为。

这排除了状态A,因为它是唯一输出为1的状态。

仔细观察状态B、C和D,我们可以看到状态B和C在输入为0时都转移到状态D,在输入为1时都转移到状态A。此外,它们的输出值都是0。这意味着状态B和C是等价的,可以合并为一个状态,从而将这个FSM变成一个三状态FSM。

总结 📚

本节课中,我们一起学习了如何根据状态图补全摩尔型有限状态机的真值表。我们通过分析输出和状态转移,逐步推导出了每个状态的二进制编码。最后,我们应用等价状态的概念,发现状态B和C可以合并,从而简化了状态机的设计。这个过程是数字逻辑设计中实现和优化有限状态机的基础步骤。

061:7.2.1 延迟与吞吐量 🚀

在本章中,我们的目标是介绍一些用于衡量电路性能的指标,并探讨提升性能的方法。

我们将暂时把电路放在一边,先看一个日常生活中的例子,这有助于我们理解将要介绍的几个性能指标。

从洗衣系统看性能指标 👕

洗衣是我们都必须在某个时候面对的处理任务。我们洗衣系统的输入是若干批脏衣物,输出则是相同批次的、经过洗涤、烘干和折叠的干净衣物。系统包含两个组件:一台洗涤一批衣物需要30分钟的洗衣机,和一台烘干一批衣物需要60分钟的烘干机。你可能习惯使用不同处理时间的洗衣组件,但在这个例子中,我们使用这些时间设定。

我们的衣物在系统中遵循一条简单的路径:每批衣物首先在洗衣机中洗涤,之后转移到烘干机中进行烘干。当然,在装载洗衣机、将湿衣物转移到烘干机或从烘干机取出干衣物的步骤之间可能存在延迟。我们假设我们尽可能快地在系统中移动衣物,一旦可以就将其转移到下一个处理步骤。

处理单批衣物

要处理一批衣物,我们首先让它通过洗衣机,这需要30分钟。然后让它通过烘干机,这需要60分钟。因此,从系统输入到系统输出的总时间是90分钟。如果这是一个组合逻辑电路,我们会说该电路从有效输入到有效输出的传播延迟是90分钟。

处理多批衣物

现在让我们思考处理n批衣物的情况。

在麻省理工学院,我们喜欢温和地调侃一下河上游那所著名学府的同行们。所以,以下是我们想象中哈佛大学学生处理n批衣物的方式。

他们遵循组合逻辑的“配方”:在系统根据前一组输入产生正确输出后,才提供新的系统输入。因此,在步骤1中,第一批衣物被洗涤;在步骤2中,第一批衣物被烘干,总共耗时90分钟。一旦这些步骤完成,哈佛学生才进入步骤3,开始处理第二批衣物,依此类推。

系统处理n批衣物的总时间,仅仅是处理单批衣物所需时间的n倍。所以总时间是 n * 90 分钟。当然,我们在这里有点开玩笑,哈佛学生实际上并不自己洗衣服。妈妈会在周三早上派管家来收集他们的脏衣物,并在下午茶时间前将熨烫平整的衣物送回。

但我们希望你能看到哈佛洗衣方法与组合电路之间的类比。我们都能看到,在烘干机运行时,洗衣机处于闲置状态。这种低效率影响了衣物通过系统的最终速率。

引入流水线处理

作为6.004课程的工科学生,我们看到重叠洗涤和烘干过程是有意义的。因此,在步骤1中,我们洗涤第一批衣物;在步骤2中,我们像之前一样烘干第一批衣物。但除此之外,我们开始洗涤第二批衣物。我们必须为步骤2分配60分钟,以便给烘干机足够的时间完成工作。

这里存在轻微的低效率,因为洗衣机提前完成了工作。但由于只有一台烘干机,是烘干机决定了衣物通过系统的速度。这种重叠处理一系列输入的系统被称为流水线系统,每个处理步骤被称为流水线的一个阶段。

输入通过流水线的速率由最慢的流水线阶段决定。我们的洗衣系统是一个两阶段流水线,每个阶段的处理时间为60分钟。

我们重复这个重叠的洗涤-烘干步骤,直到所有n批衣物都被处理完毕。我们每60分钟开始一批新的洗涤,并且每60分钟从烘干机得到一批新的干衣物。换句话说,我们重叠洗衣系统的有效处理速率是每60分钟一批衣物。

因此,一旦流程开始,n批衣物需要 n * 60 分钟。而对于特定的一批衣物,它需要两个阶段的处理时间,总共需要120分钟。

第一批衣物的时间安排略有不同,因为步骤1的时间可以更短,无需等待烘干机。但在流水线系统的性能分析中,我们感兴趣的是稳态,即假设我们有无限的输入供应。

核心性能指标:延迟与吞吐量 ⚖️

我们看到有两个有趣的性能指标。

第一个是系统的延迟,即系统处理一个特定输入所需的时间。

  • 在哈佛洗衣系统中,洗涤和烘干一批衣物需要90分钟。
  • 在6.004洗衣系统中,洗涤和烘干一批衣物需要120分钟(假设它不是第一批)。

第二个性能指标是吞吐量,即系统产生输出的速率。在许多系统中,每输入一组数据就得到一组输出,在这样的系统中,吞吐量也告诉我们系统消耗输入的速率。

  • 在哈佛洗衣系统中,吞吐量是每90分钟一批衣物。
  • 在6.004洗衣系统中,吞吐量是每60分钟一批衣物。

虽然哈佛洗衣系统的延迟更低,但6.004洗衣系统的吞吐量更高。哪个系统更好?这取决于你的目标。

  • 如果你需要清洗100批衣物,你会更喜欢吞吐量更高的系统。
  • 另一方面,如果你需要在90分钟内为约会准备好干净的内衣,你会更关心延迟。

洗衣的例子也说明了延迟和吞吐量之间常见的权衡关系。如果我们通过使用流水线处理来提高吞吐量,延迟通常会随之增加,因为所有流水线阶段必须同步运行,处理速率由最慢的阶段决定。

总结 📝

在本节中,我们一起学习了衡量系统性能的两个关键指标:延迟吞吐量。我们通过洗衣的例子,对比了顺序处理(类似组合电路)和流水线处理的不同。顺序处理虽然单次延迟可能较低,但整体吞吐量受限于总处理时间;而流水线处理通过重叠操作提高了吞吐量,但通常会增加单个任务的延迟。理解这种权衡对于设计和优化数字系统至关重要。

062:流水线电路 🚀

在本节课中,我们将学习如何通过流水线技术来提升电路的性能。我们将分析组合逻辑电路的延迟与吞吐量,并探讨如何通过插入寄存器将其改造为流水线电路,从而在增加少量延迟的代价下,显著提高吞吐量。


组合电路的性能分析

上一节我们讨论了电路性能的基本概念。本节中,我们来看看如何分析一个组合电路的性能。

一个大型组合电路的延迟就是其传播延迟 D。其吞吐量则是 1 / D,因为只有在完成当前输入的计算后,才能开始处理下一个输入。

考虑一个由三个组件 FGH 组成的组合系统,其中 FG 并行工作,为 H 提供输入。

通过时序图,我们可以追踪特定输入值 x 的处理过程。在 x 有效且稳定一段时间后,FG 模块产生输出 F(x)G(x)。当 H 的输入有效且稳定后,H 模块将在其传播延迟所决定的时间后,产生系统输出 P(x)

从有效输入到有效输出的总耗时由各组件模块的传播延迟决定。如果我们直接使用这些模块,就无法改进这个延迟。


提升吞吐量的思路

那么,如何提升系统的吞吐量呢?观察发现,在产生输出后,FG 模块处于空闲状态,只是保持输出稳定,而 H 在进行计算。

我们能否找到一种方法,让 FG 在处理下一个输入的同时,仍允许 H 处理第一个输入?换句话说,我们能否将组合电路的处理过程分为两个阶段:第一阶段计算 F(x)G(x),第二阶段计算 H(x)?如果可以,我们就能提高系统的吞吐量。

一个灵感是使用寄存器来保存 F(x)G(x) 的值供 H 使用,同时让 FG 模块开始处理下一个输入值。

为了简化时序分析,我们假设流水线寄存器的传播延迟和建立时间为零。时序电路的合适时钟周期由最慢处理阶段的传播延迟决定。

在这个例子中,包含 FG 的阶段需要至少 20 纳秒的时钟周期才能正常工作,而包含 H 的阶段需要至少 25 纳秒。因此,第二阶段最慢,将系统时钟周期设定为 25 纳秒。


流水线系统的工作原理

这是我们提高组合逻辑吞吐量的通用方案:使用寄存器将处理过程划分为一系列阶段。寄存器捕获一个处理阶段的输出,并将其作为下一个处理阶段的输入保存。特定的输入将以每个时钟周期一个阶段的速度在系统中推进。

在这个例子中,处理流水线有两个阶段,时钟周期为 25 纳秒,因此流水线系统的延迟是 50 纳秒。换句话说,延迟等于 阶段数 × 系统时钟周期

流水线系统的延迟比非流水线系统稍长。然而,流水线系统每个时钟周期(25 纳秒)就能产生一个输出。流水线系统以少量增加延迟为代价,获得了显著更好的吞吐量。

流水线图帮助我们可视化流水线系统的操作。流水线图的行代表流水线阶段,列代表连续的时钟周期。

在时钟周期 I 开始时,输入 X_i 变得稳定有效。然后在时钟周期 I 期间,FG 模块处理该输入,并在周期结束时,结果 F(X_i)G(X_i) 被第一和第二阶段之间的流水线寄存器捕获。

接着在周期 I+1 中,H 使用捕获的值来处理 X_i。同时,FG 模块正在处理 X_{i+1}。可以看到,特定输入值的处理在图中沿对角线移动,每个时钟周期前进一个流水线阶段。

在周期 I+1 结束时,H 的输出被最终的流水线寄存器捕获,并可在周期 I+2 中使用。从输入到达到输出可用的总时间是两个周期。

处理过程周期复始,每个时钟周期产生一个新的输出。使用流水线图,我们可以追踪特定输入在系统中的进展,或者查看任何特定周期中所有阶段正在做什么。


流水线电路的形式化定义

我们将一个 K 级流水线(简称 K-流水线)定义为一个非循环电路,在其每条从输入到输出的路径上恰好有 K 个寄存器。

因此,一个非流水线的组合电路就是零级流水线。为了便于用流水线组件构建更大的流水线系统,我们约定每个流水线阶段(也就是每个 K 级流水线)在其输出端都有一个寄存器。

我们将使用分析时序电路时序的技术,来确保所有流水线寄存器共用的时钟信号具有足够的周期,以保证每个阶段的正确操作。

因此,对于每条从寄存器到寄存器、或从输入到寄存器的路径,我们需要计算输入寄存器的传播延迟、组合逻辑的传播延迟以及输出寄存器的建立时间之和。

然后,我们将选择系统时钟周期,使其大于或等于所有这些和中的最大值。在正确的时钟周期下,并且每条从系统输入到系统输出的路径上恰好有 K 个寄存器,我们就能保证 K-流水线计算出的输出与原始非流水线组合电路相同。

一个 K-流水线的延迟是系统时钟周期的 K 倍:
延迟 = K × T_clk

而一个 K-流水线的吞吐量是系统时钟的频率:
吞吐量 = 1 / T_clk


总结

本节课中我们一起学习了流水线电路的核心概念。我们了解到,通过将组合逻辑划分为多个阶段并用寄存器分隔,可以构建流水线。虽然这会略微增加系统的整体延迟(延迟 = 阶段数 × 时钟周期),但能显著提高吞吐量(吞吐量 = 时钟频率)。流水线图是分析和可视化其操作的有效工具。正确设计流水线的关键在于确保时钟周期满足最慢阶段的要求,以保证数据在各级之间正确传递。

063:流水线设计方法论 🚀

在本节课中,我们将学习流水线电路设计的方法论。我们将了解如何将一个组合逻辑电路转换为一个结构良好、功能正确的流水线电路,并分析其性能指标,如吞吐量和延迟。


流水线结构的重要性

上一节我们介绍了流水线的基本概念,本节中我们来看看一个失败的流水线设计案例,并理解结构良好的流水线为何如此重要。

这是一个流水线设计的失败尝试。对于K值是多少时,这个电路才是一个K级流水线?我们来数一下从系统输入到系统输出的每条路径上的寄存器数量。

  • 穿过A和C模块的顶部路径有两个寄存器。
  • 穿过B和C模块的底部路径也有两个寄存器。
  • 但是,穿过所有三个模块(A、B、C)的中间路径只有一个寄存器。

不是一个结构良好的K级流水线。我们为什么要在意这一点?因为这种流水线电路计算出的结果,与原始的非流水线电路不同。问题的根源在于,在处理过程中,连续几代的输入值被混合在了一起。

例如,在周期 I+1 期间,B模块正在使用X输入的当前值,但使用的却是Y输入的前一个值。这种情况在结构良好的流水线中也可能发生,因此我们需要开发一种流水线设计技术,以确保结果结构良好。


确保流水线结构良好的策略

为了解决上述问题,我们需要一个策略来确保:如果我们沿着一条从输入到输出的路径添加了流水线寄存器,那么我们必须沿着每一条这样的路径都添加寄存器。

以下是我们的分步策略:

步骤一:画一条轮廓线,使其穿过电路的所有输出。将这条线的端点标记为“终点”,后续所有轮廓线都将在这两个终点之间绘制。

步骤二:在两个终点之间,沿着模块间的信号连接继续绘制轮廓线。确保每条信号连接都以相同的方向穿过新的轮廓线。这意味着系统输入将位于轮廓线的一侧,而系统输出位于另一侧。

这些轮廓线划分了流水线阶段。在信号连接与轮廓线相交的位置放置一个流水线寄存器。在下图中,我们用大黑点标记了流水线寄存器的位置。

通过从终点到终点绘制轮廓线,我们保证了会穿过每一条输入-输出路径,从而确保我们的流水线结构良好。

现在,我们可以通过寻找具有最长“寄存器到寄存器”或“输入到寄存器”传播延迟的流水线阶段,来计算系统时钟周期。

根据这些轮廓线,并假设流水线寄存器是零延迟的理想寄存器,系统时钟周期必须为 8纳秒,以适应C模块的操作。这给出了系统的吞吐量:每8纳秒产生一个输出。

由于我们绘制了三条轮廓线,这是一个三级流水线,系统的总延迟是 3 × 8纳秒 = 24纳秒


流水线设计的目标与权衡

我们流水线设计的通常目标是:使用尽可能少的寄存器实现最大的吞吐量。

因此,我们的策略是找到系统中最慢的组件(在我们的例子中是C组件),并在其输入和输出端放置流水线寄存器。所以我们绘制了穿过C模块两侧的轮廓线。

将时钟周期设定为8纳秒。我们这样定位轮廓线,使得任意两个流水线寄存器之间的最长路径最多为8纳秒。

在保持相同吞吐量和延迟的前提下,如何绘制轮廓线通常有多种选择。例如,我们可以将E模块和F模块放在同一个流水线阶段中。

让我们回顾一下流水线策略。

  1. 首先,我们画一条穿过所有输出的轮廓线。这创建了一个一级流水线,其吞吐量和延迟始终与原始组合电路相同。
  2. 然后,我们绘制下一条轮廓线,试图隔离系统中最慢的组件。这创建了一个二级流水线,其时钟周期为2纳秒,因此吞吐量为1/2(即比一级流水线快一倍)。
  3. 我们可以添加额外的轮廓线,但请注意,二级流水线已经具有了可能的最小时钟周期。因此,在此之后,添加更多轮廓线只会增加流水线级数,从而增加系统延迟,但不会提高吞吐量。这并非错误,只是对硬件投资而言不值得。

注意,A和C模块之间的信号连接现在有两个背靠背的流水线寄存器。这没有问题,当我们将一个输入-输出路径长度不同的电路流水线化时,这种情况经常发生。

所以,我们的流水线策略是:设计吞吐量递增的流水线实现,通常以增加延迟为代价。有时我们很幸运,每个流水线阶段的延迟完全平衡,在这种情况下延迟不会增加。请注意,流水线电路的延迟永远不会小于非流水线电路。


突破性能瓶颈:使用流水线化组件

一旦我们隔离了最慢的组件,就无法再进一步提高吞吐量了。面对这种性能瓶颈,我们如何继续提升电路性能呢?

一个解决方案是:使用流水线化的组件(如果可用的话)。

假设我们能够用两级的流水线版本 A‘ 替换原来的A组件。我们可以重新绘制流水线和轮廓线,确保考虑到A‘组件内部的流水线寄存器。这意味着我们的两条轮廓线必须穿过A‘组件,从而保证我们会在系统的其他地方添加流水线寄存器,以匹配A‘引入的两周期延迟。

现在,任何阶段的最大传播延迟是1纳秒,吞吐量从1/2提升到了1/1(即每纳秒一个输出)。这是一个四级流水线,因此延迟将是4纳秒。这很棒。

但是,如果我们的瓶颈组件没有可用的流水线替代品,我们该怎么办?

我们将在下一节中解决这个问题。


总结

本节课中我们一起学习了流水线电路设计的方法论。我们了解到,结构良好的流水线要求所有输入到输出的路径具有相同数量的寄存器。通过绘制轮廓线的策略,可以系统地实现这一点。流水线设计的目标是提高吞吐量,但通常会以增加延迟为代价,并且性能提升受限于系统中最慢的组件。为了突破此限制,可以使用内部已流水线化的组件。

064:7.2.4 电路交错

在本节课中,我们将学习如何通过“交错”技术来提升数字系统的吞吐量。我们将从一个洗衣店的类比开始,理解交错的基本概念,然后将其应用到电路设计中,以解决流水线中慢速模块带来的瓶颈问题。

概述:通过交错提升吞吐量

上一节我们讨论了流水线设计,但遇到了一个瓶颈:系统中存在一个无法被流水线化的慢速模块(C模块),其延迟决定了整个系统的最小时钟周期。本节中,我们来看看如何通过“交错”技术来克服这个限制。交错的核心思想是使用多个相同的慢速组件,让它们交替工作,从而模拟出一个多级流水线的效果,最终提升系统的整体吞吐量。

从洗衣店到电路:交错的概念

为了理解交错,我们先看一个洗衣店的例子。学生们找到了一个拥有两台烘干机但只有一台洗衣机的洗衣店来解决烘干瓶颈。

以下是这个系统的运作计划,时间线以30分钟为步长进行划分:

  • 洗衣机在每个时间步长都被使用,每30分钟产出一批新洗好的衣物。
  • 烘干机的使用是交错的:一号烘干机用于烘干奇数批次的衣物,二号烘干机用于烘干偶数批次的衣物。
  • 一旦启动,一台烘干机需要运行两个步长,总计60分钟。

由于两台烘干机错开时间运行,整个系统每30分钟就能产出一批干净、干燥的衣物。系统的稳定吞吐量是每30分钟一批衣物,而特定一批衣物的延迟是90分钟

这个例子的关键结论是:考虑这个双烘干机系统的操作。即使烘干机组件本身不是流水线化的,但通过两台烘干机交错工作的系统,其行为就像一个两级流水线,时钟周期为30分钟,延迟为60分钟。 换句话说,通过交错使用两个非流水线组件,我们可以实现一个两级流水线的效果。

电路实现:通用双路交错器

回到上一节的电路例子,由于C模块8纳秒的延迟决定了最小时钟周期,我们无法将流水线系统的吞吐量提升到超过每8纳秒一个结果。为了进一步提升吞吐量,我们需要找到C组件的流水线版本,或者使用交错策略,用两个非流水线C组件的实例来模拟两级流水线的效果。我们来尝试后者。

上图展示了一个通用的双路交错器电路,这里使用了两个非流水线C组件的副本:C0和C1。

  • 每个C组件的输入来自一个D锁存器,其任务是捕获并保持输入值。
  • 还有一个多路复用器,用于选择哪个C组件的输出将被输出寄存器捕获。
  • 电路的左下角是一个非常简单的两状态有限状态机(FSM),只有一个状态位。其下一个状态逻辑是一个单独的反相器,这使得状态在连续的时钟周期内在0和1之间交替。

下面的时序图显示了状态位在每个时钟上升沿之后如何变化。为了帮助我们理解电路,我们将查看一些信号波形来说明其操作。

首先,这是来自上一张幻灯片的时钟信号和FSM状态位的波形。一个新的X输入在时钟上升沿之后从上一级到达。

接下来,我们跟踪C0组件的操作。当FSM Q为低电平时,其输入锁存器打开。因此,新到达的X1输入通过锁存器,C0可以开始其计算,在第二个时钟周期结束时产生结果。注意,C0的输入锁存器在第二个时钟周期开始时关闭,即使X输入开始变化,它也能保持X1输入值稳定。其效果是C0在两个时钟周期的大部分时间内拥有有效且稳定的输入,从而有足够的时间计算结果。

C1的波形类似,只是偏移了一个时钟周期。当FSM Q为高电平时,C1的输入锁存器打开,因此新到达的X2输入通过锁存器,C1可以开始其计算,在第三个时钟周期结束时产生结果。

现在检查多路复用器的输出。当FSM Q为高时,它选择来自C0的值;当FSM Q为低时,它选择来自C1的值。我们可以在所示的波形中看到这一点。

最后,在时钟上升沿,输出寄存器捕获其输入上的值,并在该时钟周期的剩余时间内保持其稳定。

交错电路的行为就像一个两级流水线。 在周期I到达的输入值经过两个时钟周期处理,结果在周期I+2变得可用。

那么交错系统的时钟周期是多少?由于上游流水线寄存器(提供X输入)、内部锁存器、多路复用器以及输出寄存器的建立时间所带来的传播延迟,会损失一些时间。因此,时钟周期必须比C模块传播延迟的一半稍长一些。

在流水线图中处理交错组件

我们可以将交错电路视为一个两级流水线,每个时钟周期消耗一个输入,并在两个周期后产生一个结果。当在我们的流水线图中加入一个N路交错组件时,我们将其视为一个N级流水线。因此,必须有N条我们的流水线等高线穿过该组件。

在这里,我们用双路交错的C‘组件替换了流水线中的C组件。我们可以遵循绘制流水线等高线的流程:

  1. 首先,我们画一条穿过所有输出的等高线。
  2. 然后我们添加等高线,确保有两条穿过C‘组件。
  3. 接着,我们在等高线与信号连接的交叉点添加流水线寄存器。

我们看到,穿过C‘的等高线导致在F模块的其他输入上添加了额外的流水线寄存器,以适应通过C‘的两周期延迟。我们乐观地将C‘的最小T_clock指定为4纳秒,这意味着现在决定系统时钟周期的慢速组件是传播延迟为5纳秒的F模块。

因此,我们新流水线电路的吞吐量是每5纳秒一个输出。由于有五条等高线,它是一个五级流水线,所以延迟是时钟周期的五倍,即25纳秒。通过并行运行流水线系统,我们可以继续增加吞吐量。

并行与交错的结合:进一步提升

这里展示了一个拥有两台洗衣机和四台烘干机的洗衣店,本质上是之前所示的一台洗衣机、两台烘干机系统的两个副本。操作如前所述,只是系统在每个步长生产和消耗两批衣物。因此,吞吐量是每30分钟两批衣物,有效速率为每15分钟一批。单批衣物的延迟没有改变,仍然是每批90分钟。

我们已经看到,即使有慢速组件,我们也可以使用交错和并行性来持续提高吞吐量。

那么我们能达到的吞吐量有上限吗?答案是肯定的。流水线寄存器和交错组件的时序开销将为可实现的时钟周期设定一个下限,从而为可实现的吞吐量设定一个上限。很遗憾,在现实世界中,无限加速是不可能的。

总结

本节课中我们一起学习了“电路交错”这一重要技术。我们从洗衣店的类比入手,理解了如何通过让多个相同组件交替工作来模拟流水线行为。接着,我们深入探讨了双路交错器的电路实现,包括其状态机控制、输入锁存和输出选择机制。然后,我们学习了如何在流水线分析图中处理交错组件,并将其视为多级流水线阶段。最后,我们看到了结合并行与交错可以进一步提升吞吐量,但也认识到由于电路本身的时序开销,吞吐量的提升存在理论上限。交错是优化数字系统性能、突破慢速组件瓶颈的一个非常实用的方法。

065:7.2.5 自定时电路 🕰️

在本节课中,我们将要学习一种不同于传统同步时钟系统的电路设计方法——自定时电路。我们将探讨如何通过握手协议,让电路中的各个模块根据自身处理速度来协调数据传输,从而提高系统吞吐量。

同步系统的局限性

上一节我们介绍了同步流水线设计,其所有阶段都在一个全局时钟的同步下步调一致地工作。时钟周期必须根据所有处理阶段中最长的处理时间(最坏情况)来设定。我们称这种系统为同步全局定时系统

但是,如果处理时间存在数据依赖性呢?换句话说,对于某些数据输入,某个特定的处理阶段可能能够在更短的时间内产生输出。我们能否设计一种系统来利用这种机会,从而提高吞吐量?

握手协议:一种替代方案

一种替代方案是继续使用单一系统时钟,但让每个阶段通过信号来指示它何时准备好接收新输入,以及何时有新输出可供下一阶段使用。设计一个简单的双信号握手协议来可靠地在阶段间传输数据,这很有趣。

上游阶段产生一个名为 here_is_X 的信号,表示它有新数据要提供给下游阶段。下游阶段产生一个名为 got_X 的信号,表示它何时愿意接收数据。这是一个同步系统,因此信号值仅在时钟上升沿被检查。

握手协议工作流程如下:

  • 上游阶段在下一个时钟上升沿将会有新输出值时,则断言 here_is_X
  • 下游阶段将在时钟上升沿获取下一个输出时,则断言 got_X
  • 两个阶段都在时钟上升沿检查这些信号,以决定下一步做什么。
  • 如果两个阶段在同一时钟沿看到 here_is_Xgot_X 都被断言,则握手完成,数据传输在该时钟沿发生。

任何阶段都可以延迟一次传输,例如它们仍在忙于产生下一个输出或消费前一个输入。

异步自定时系统

虽然构建起来要困难得多,但也可以构建一个无时钟的异步自定时系统,它使用类似的握手协议。这种握手的时序基于握手信号的跳变,可以在任何满足协议条件的时候发生,无需全局时钟。

以下是异步握手协议的四个阶段:

  1. 请求阶段:当上游阶段有新输出且 got_X 未断言时,它断言其 here_is_X 信号,然后等待下游阶段在 got_X 信号上的回复。
  2. 确认阶段:下游阶段看到 here_is_X 被断言后,在它消费完可用输入时断言 got_X
  3. 撤销请求阶段:下游阶段等待 here_is_X 变为低电平,这表明上游阶段已成功接收到 got_X 信号。
  4. 撤销确认阶段:一旦 here_is_X 被撤销,下游阶段就撤销 got_X,传输握手准备再次开始。

注意,上游阶段会等待直到看到 got_X 被撤销后,才开始下一次握手。

处理多个下游模块

思考一下当存在多个具有各自内部时序的下游模块时,这个自定时协议如何工作会很有趣。在下图示例中,模块A的输出同时被B和C阶段消费。

我们需要一个特殊的电路(图中黄色方框)来合并来自B和C阶段的 got_X 信号,并为A阶段生成一个汇总信号。

让我们快速看一下这里的时序图:

  • 在A断言 here_is_X 之后,黄色方框中的电路会等待,直到B和C阶段都断言了它们的 got_X 信号,然后才向A阶段断言 got_X
  • 此时,A阶段撤销 here_is_X,然后黄色方框等待,直到B和C阶段都撤销了它们的 got_X 信号,然后才向A阶段撤销 got_X

系统运行示例

让我们观察系统运行。当信号被断言时,我们将用红色显示,否则为黑色。

  1. A阶段的新数据值到达其数据输入端,提供该值的模块随后断言其 here_is_A 信号,让A知道它有新输入。
  2. 一段时间后,A向上游发送 got_A 信号,表示它已消费该值,然后上游阶段撤销 here_is_A,接着A撤销其 got_A 信号。这完成了数据向A阶段的传输。
  3. 当A准备好向B和C阶段发送新输出时,它检查其 got_X 信号是否被撤销(确实是),于是它断言新的输出值并向黄色方框发送 here_is_X 信号,该方框将此信号转发给下游阶段。
  4. B准备好消费新输入,因此断言其 got_X 输出。注意,C仍在等待其第二个输入,尚未断言其 got_X 输出。
  5. B完成计算后,向C提供一个新值,并断言其 here_is_X 信号输出,让C知道它的第二个输入已就绪。
  6. 现在C满足了条件,并向上游两个阶段发出信号,表示它已消费了两个输入。
  7. 由于 got_X 的两个输入都被断言,黄色方框断言A的 got_X 输入,让它知道数据已被传输。
  8. 同时,B完成其握手部分,C完成与B的事务。
  9. A撤销 here_is_X,表示它已看到 got_X 输入。
  10. 当B和C阶段看到它们的 here_is_X 信号变为低电平时,它们通过撤销其 got_X 输出来完成握手。
  11. 当B和C的 got_X 都变为低电平时,黄色方框通过撤销A的 got_X 输入来让A知道握手完成。

此时,系统已返回到初始状态,A现在准备好接受未来的某个输入值。

总结

本节课中我们一起学习了自定时电路的设计思想。这是一种基于完全跳变信号传输的优雅设计。每个模块完全控制自己何时消费输入和产生输出,因此系统能够以尽可能快的速度处理数据,而无需等待最坏情况下的处理延迟。这为处理时间可变或数据依赖性强的高性能系统设计提供了一种强大的替代方案。

数字系统与计算机架构:P1:6.4 控制结构总结

在本节中,我们将总结关于流水线系统控制方法的学习内容。

上一节我们介绍了不同的流水线控制策略,本节中我们来回顾并总结其核心概念与性能衡量。


流水线控制方法概述

控制流水线系统最直接的方法是使用一个系统时钟,其周期需适应最坏情况下的处理时间。

这种系统易于设计,但当某些数据值能更快处理时,无法产生更高的吞吐量。

我们了解到,可以使用简单的握手协议在系统中移动数据。

所有通信仍然发生在系统时钟的上升沿,但用于传输数据的具体时钟边沿由各阶段自身决定。


全局时钟调整的局限性

人们可能会考虑是否可以通过调整全局时钟来利用数据依赖的处理加速。

但在大型系统中,必要的时序生成器会非常复杂。

通常,使用模块间的本地通信来确定系统时序,比在系统层面解决所有约束要容易得多。

因此,这种方法通常不是好的选择。


局部定时异步系统

那么,像我们刚才看到的例子那样的局部定时异步系统呢?

每一代工程师都曾受到异步逻辑的吸引。

遗憾的是,对于大型系统(例如现代计算机),要设计出可靠的设计通常被证明过于困难。

但在某些特殊情况下,例如整数除法逻辑,数据依赖的加速使得额外的工作是值得的。


系统性能表征

我们通过测量系统的延迟吞吐量来表征其性能。

对于组合电路,延迟就是电路的传播延迟,其吞吐量是延迟的倒数。

我们引入了一种系统性的策略来设计K级流水线。

其中,每个阶段的输出端都有一个寄存器,并且从输入到输出的每条路径上恰好有K个寄存器。

系统时钟周期 T_clock 由最慢流水线级的传播延迟决定。

流水线系统的吞吐量是 1 / T_clock,其延迟是 K × T_clock

流水线化是提高大多数高性能数字系统吞吐量的关键。


总结

本节课中,我们一起学习了流水线系统的不同控制方法,包括固定时钟周期、握手协议以及异步逻辑的局限性。我们明确了使用系统时钟和握手协议的优缺点,并理解了为何全局时钟调整在大型系统中不实用。最后,我们回顾了如何用延迟和吞吐量来量化系统性能,以及流水线化作为提升吞吐量核心技术的原理。

067:流水线设计实例分析 🚀

在本节课中,我们将学习如何分析一个组合逻辑电路,并对其进行流水线化改造,以提升其吞吐量。我们将通过一个具体的“最大值比较器”电路实例,逐步理解传播延迟、吞吐量、流水线阶段划分等核心概念。


电路模块介绍

我们有一个名为 CBbit 的模块。该模块接收两个无符号二进制数 AB 的对应位,以及来自高位 CBbit 模块的两个进位输入位 C_in。它的输出位 RAB 中较大者的对应位。它还有两个额外的输出位 C_out,分别指示到目前为止比较的位中,A 是否大于 B,或 B 是否大于 A

每个 CBbit 模块的传播延迟为 4 纳秒


构建4位最大值比较器

CBbit 模块被用来创建一个名为 Max4 的组合逻辑器件,该器件用于确定其两个4位无符号二进制输入中的最大值。它由四个 CBbit 模块构成,结构如下图所示。

传播延迟与吞吐量分析

组合逻辑电路的传播延迟是其从输入到输出的最长路径上的延迟。在本电路中,最长路径需要穿过四个 CBbit 模块。由于每个模块的延迟为4纳秒,因此该组合电路的总传播延迟为:

延迟 L = 4 × 4 ns = 16 ns

组合逻辑电路的吞吐量是其延迟的倒数,因此该电路的吞吐量为:

吞吐量 = 1 / 16 ns


特殊情况分析:输入相等

接下来我们考虑一个问题:如果输入 A[3:0]B[3:0] 是相同的数字,那么从最低位 CBbit 模块未使用的 C_out 输出端,我们预期会看到哪两个比特?

C_out[1] 位指示 A > BC_out[0] 位指示 B > A。由于两个数字相同,这两个不等式均不成立,因此两个 C_out 输出都应为 0


流水线化改造以最大化吞吐量

现在,我们希望对上述电路进行流水线化改造,以获得最大吞吐量。我们为此步骤提供了理想的流水线寄存器(传播延迟和建立时间均为0)。

为了最大化吞吐量,我们希望添加流水线寄存器,将每个独立的 CBbit 模块隔离到其自己的流水线阶段中。流水线化时,我们需要在所有输出端添加寄存器。

我们首先在所有四个输出端(R3, R2, R1, R0)画一条分割线,表示在此处添加流水线寄存器。

接着,为了隔离最低位的 CBbit 模块,我们画另一条分割线。这条线穿过输出 R3、R2、R1,经过两个低位 CBbit 模块之间,最后穿过输入 A0 和 B0。

记住,分割线每穿过一条线,就意味着我们在那里添加一个流水线寄存器。这一步总共添加了六个寄存器:分别为 R3、R2、R1 各一个,两个模块之间一个,以及 A0 和 B0 输入各一个。

此时,无论观察哪条从输入到输出的路径,路径上的流水线寄存器数量都是2个。

我们继续以这种方式进行,将每个 CBbit 模块都隔离到其自己的流水线阶段中。


流水线电路性能计算

现在每个 CBbit 模块都位于自己的流水线阶段,我们可以为此电路计时以获得最大吞吐量。

时钟周期必须为流水线寄存器的传播延迟、CBbit 模块的传播延迟以及下一级寄存器的建立时间留出足够时间。由于我们的寄存器是理想的(延迟和建立时间均为0),因此时钟周期等于一个 CBbit 模块的传播延迟:

时钟周期 T = 4 ns

流水线电路的延迟等于流水线阶段数乘以时钟周期。本例中有4个阶段:

延迟 L_pipeline = 4 × 4 ns = 16 ns

流水线电路的吞吐量是时钟周期的倒数:

吞吐量 = 1 / 4 ns


构建4输入最大值比较器

我们的 CBbit 模块还可以用来创建 Max4x4 电路,这是一个能确定四个4位二进制输入中最大值的组合电路。

新电路的延迟与吞吐量

仔细观察电路,最长路径从左上的 CBbit 模块开始,到右下的 CBbit 模块结束。沿着这条路径数一数,需要穿过 6CBbit 模块。

因此,该组合电路的延迟为:

延迟 L = 6 × 4 ns = 24 ns

其吞吐量为延迟的倒数:

吞吐量 = 1 / 24 ns


对新电路进行流水线化改造

我们的最终任务是流水线化这个新电路,以获得最大吞吐量。

和之前一样,我们首先在所有输出端画一条分割线。

接下来,我们需要规划如何添加剩余的分割线,以使流水线电路的时钟周期最小化。时钟周期必须为寄存器延迟、组合逻辑延迟和建立时间留出时间。由于寄存器是理想的,周期仅等于相邻寄存器间组合逻辑的传播延迟。

为了最小化周期,我们希望每对流水线寄存器之间最多只有一个 CBbit 模块。这样可以使周期 T = 4 ns

为了实现这一点,我们可以在电路中画对角线形式的分割线。

注意,这些分割线确保了任何一对流水线寄存器之间最多只有一个 CBbit 模块,同时,在单个流水线阶段内尽可能多地包含了可以并行执行的 CBbit 模块。后一个约束在最大化吞吐量的同时,也有助于最小化整体延迟。

此时,无论遵循哪条从输入到输出的路径,都会穿过三个流水线寄存器。

我们继续以这种方式添加流水线阶段,直到每个阶段只包含一个 CBbit 模块。

现在,任何从输入到输出的路径都需要穿过六个流水线寄存器,因为我们将电路拆分成了六个流水线阶段,从而分解了最长路径。

我们可以用 T = 4 ns 的周期来驱动这个电路。

因此,流水线化后的延迟为:

延迟 L_pipeline = 6 × 4 ns = 24 ns

吞吐量为:

吞吐量 = 1 / 4 ns


总结 📝

本节课中,我们一起学习了:

  1. 分析组合逻辑电路的传播延迟吞吐量
  2. 通过添加流水线寄存器对电路进行流水线化改造
  3. 计算流水线化后的时钟周期延迟吞吐量
  4. 掌握了通过画分割线来规划流水线阶段,以在最小化时钟周期(最大化吞吐量)和控制总延迟之间取得平衡的方法。

关键要点是:流水线化通过将长组合路径拆分为多个较短的、由寄存器分隔的阶段,允许电路以更高的频率(更短的周期)运行,从而显著提升吞吐量,尽管总延迟可能因寄存器开销而略有增加或保持不变。

068:流水线设计实例分析 🚀

在本节课中,我们将通过一个具体的电路实例,学习如何分析组合电路的性能,并运用流水线技术对其进行优化,以实现最大吞吐量。我们将计算原始电路的延迟和吞吐量,然后通过添加流水线寄存器来划分流水线阶段,最终评估优化后的性能。


概述 📋

我们被给定一个由九个组合逻辑模块连接而成的电路。每个模块上的数字代表其传播延迟(单位:微秒)。我们的目标是首先分析这个原始组合电路的延迟和吞吐量,然后通过流水线化来最大化其吞吐量。


原始电路性能分析 ⚙️

上一节我们介绍了电路的基本结构,本节中我们来看看如何计算其关键性能指标。

电路中最长的路径经过两个3微秒模块、一个2微秒模块和两个1微秒模块。因此,电路的延迟(Latency, L)等于这些延迟之和。

延迟计算公式:
L = (2 × 3μs) + (1 × 2μs) + (2 × 1μs) = 10μs

吞吐量(Throughput)是延迟的倒数,表示单位时间内电路能处理的任务数量。

吞吐量计算公式:
吞吐量 = 1 / L = 1 / 10μs


流水线化以实现最大吞吐量 🔄

现在,我们希望对这个电路进行流水线化以最大化吞吐量。回忆一下,时钟周期必须为流水线寄存器的传播延迟、流水线寄存器之间任何组合逻辑的传播延迟以及流水线寄存器的建立时间留出足够时间。

由于我们假设流水线寄存器是理想的(传播延迟和建立时间为0),因此时钟周期(T)将等于任意一对流水线寄存器之间最长的组合逻辑延迟。

为了最小化时钟周期,我们希望添加流水线分割线(contours),使得每个流水线阶段都能以最慢组件(3微秒)的速率被时钟驱动。这意味着在一个流水线阶段内,所有组合路径的传播延迟最多为3微秒。

以下是绘制流水线分割线的关键步骤:

  • 首先,必须在所有输出端添加流水线寄存器。因此,第一条分割线穿过模块X的单个输出C。
  • 每个流水线阶段的延迟应最多为3微秒。这意味着右下角的1微秒模块和它上方的3微秒模块必须位于不同的流水线阶段。因此,下一条分割线在它们之间穿过。
  • 然后,通过将这条分割线的两端连接到原始输出分割线的两端来完成轮廓绘制。

每次导线被分割线穿过,都表示我们在此处添加了一个流水线寄存器。划分流水线阶段的方式不止一种,我们将尝试两种不同的方法。


流水线方案一 🧩

对于第一种实现方案,我们进行如下划分:

我们将右下角的1微秒模块单独放在一个流水线阶段。
接下来,一个流水线阶段包含第二行的3微秒模块。
我们可以将中间底部的1微秒模块包含在同一个流水线阶段,因为这两个模块彼此独立,可以并行执行。
此时,我们看到剩余的一组2微秒和1微秒模块。我们希望将它们与左上角的3微秒模块隔离开。因此,我们绘制最后一条分割线来隔离那个3微秒模块。
注意,此时在第二个流水线阶段中,所有剩余的2微秒和1微秒模块沿任何路径相加最多为3微秒。这意味着划分完成,无需添加更多流水线阶段。

最终,我们的流水线电路共有4个流水线阶段,时钟周期等于3微秒。

流水线延迟计算公式:
L_pipeline = 阶段数 × T = 4 × 3μs = 12μs

流水线吞吐量计算公式:
吞吐量_pipeline = 1 / T = 1 / 3μs

需要注意的是,流水线电路的延迟实际上比原始组合电路更慢。这是因为我们的流水线实现在最后一个流水线阶段有一些未使用的周期(气泡)。然而,由于现在时钟周期可以等于最慢组件的延迟,我们的吞吐量得到了显著提升。


流水线方案二 🔀

我们之前提到,划分流水线阶段的方式不止一种。现在让我们尝试另一种解决方案。

对于第二种方案,我们尝试将底部的三个1微秒模块合并到一个流水线阶段。
接下来,我们需要隔离中间的3微秒模块。剩余的两个和另一个1微秒模块可以合并到另一个流水线阶段。
最后,左上角的3微秒模块位于其自己的流水线阶段。

与之前一样,我们最终得到了4个流水线阶段,可以用周期T=3微秒来驱动时钟。这表明两种组件划分方式最终都实现了12微秒的延迟和1/(3微秒)的吞吐量。


使用改进组件进行深度流水线化 ⚡

现在,假设我们为标记为3和2的组件找到了流水线替代品。这些替代品分别具有3级和2级流水线,并且可以在1微秒的周期下工作。

使用这些替换组件并为实现最大吞吐量进行流水线化,可达到的最佳性能是什么?

由于这些新组件每个都可以在1微秒的周期下工作,我们的时钟周期T降低到1微秒。
然而,我们流水线中的阶段数现在上升到10级,因为这些新组件各自内部都有多个流水线阶段。

新的延迟计算公式:
L_new = 10 × T = 10 × 1μs = 10μs

新的吞吐量计算公式:
吞吐量_new = 1 / T = 1 / 1μs


总结 🎯

本节课中我们一起学习了流水线设计的完整分析过程。

我们首先分析了原始组合电路的延迟(10μs)和吞吐量(1/10μs)。
然后,我们通过添加流水线寄存器将其划分为多个阶段,以实现最大吞吐量。在时钟周期受限于最慢组件(3μs)的情况下,我们得到了两种可行的4阶段流水线方案,其延迟为12μs,吞吐量提升至1/3μs。
最后,我们探讨了使用内部已流水线化的更快组件(周期1μs)进行深度流水线的效果。虽然总阶段数增加到10级,但延迟回归到10μs,同时吞吐量大幅提升至1/1μs。

这个实例清晰地展示了流水线技术如何在略微增加延迟的代价下,显著提升系统的吞吐量,以及组件本身的速度对整体性能上限的决定性作用。

069:6.2.1 功耗优化 🔋

在本节课中,我们将探讨如何优化数字系统,使其变得更小、更快、性能更高、能效更佳等。我们将首先了解功耗的来源,然后讨论几种降低功耗的设计策略。

概述:设计权衡与目标

在最后一章,我们将研究如何优化数字系统,使其变得更小、更快、性能更高、能效更佳等。

如果能同时实现所有这些目标,那将非常理想,对于某些电路,我们确实可以做到。但一般来说,在一个维度上进行优化通常意味着在另一个维度上表现不佳。

换句话说,设计需要做出权衡。

正确做出权衡要求我们对系统的设计目标有清晰的理解。

考虑两个不同的设计团队,一个负责为游戏构建高端显卡,另一个负责构建苹果手表。构建显卡的团队主要关注性能,并且在限度内愿意牺牲成本和功耗来实现其性能目标。显卡有固定的尺寸,因此使系统足够小以满足所需尺寸是高度优先的,但使其更小则收益甚微。

构建手表的团队则有非常不同的目标。尺寸和功耗至关重要,因为它必须适合手腕佩戴,并且能全天运行,而不会在佩戴者的手腕上留下灼痕。

假设两个团队都在考虑对其部分逻辑进行流水线化以提高性能。流水线寄存器显然是额外的成本。流水线化带来的重叠执行和更高的时钟频率会增加功耗,并需要以某种方式散发热量。你可以想象,两个团队可能会对正确的行动方案得出非常不同的结论。

本章将探讨一些可能的权衡。但作为设计师,您必须挑选并选择哪些权衡适合您的设计。这正是优秀工程师所擅长的设计挑战。在指定的约束条件下,交付超出所有人预期的成果是最令人满意的。

静态功耗

我们的第一个优化主题是功耗,通常的目标是满足特定的功耗预算,或者在满足所有其他设计目标的同时最小化功耗。

在CMOS电路中,功耗有几个来源,有些在我们的控制范围内,有些则不是。

静态功耗是指即使电路空闲时(即没有节点改变值)也会消耗的功率。使用我们简单的MOSFET开关操作模型,我们期望CMOS电路具有零静态功耗。在CMOS的早期,我们非常接近实现这一理想。但随着MOSFET物理尺寸的缩小和工作电压的降低,MOSFET中两种静态功耗来源开始变得显著。

我们将讨论在N沟道MOSFET中出现的效应,但请记住,它们在P沟道MOSFET中也会出现。

第一个效应取决于MOSFET栅极氧化层的厚度,如左侧MOSFET示意图中的黄色薄层所示。在每一代新的集成电路技术中,该层的厚度都会随着所有物理尺寸的普遍减小而缩小。更薄的绝缘层意味着更强的电场,导致更深的耗尽层,从而产生载流能力更强的N沟道,实现更快的栅极速度。不幸的是,这些层现在薄到足以让电子隧穿绝缘体,产生从栅极到衬底的小电流。在单个电路中有数十亿个MOSFET的情况下,即使微小的电流加起来也会导致不可忽视的功耗。

第二个效应是由理论上不导电的N沟道MOSFET(因为VGS小于阈值电压)的漏极和源极之间流动的电流引起的。这个效应恰当地被称为亚阈值导通,并且与VGS减去VTH(当N沟道MOSFET关闭时为负值)呈指数关系。因此,随着每一代新技术中VTH的降低,VGS减去VTH的负值变小,亚阈值导通增加。

一种解决方案是改变N沟道MOSFET的几何形状,使导电沟道成为一个高而窄的鳍片,栅极端子包裹其三面,有时称为三栅极结构。这已将亚阈值导通降低了一个数量级或更多,暂时解决了这个特定问题。

除了可以选择较旧的制造工艺外,系统设计师无法控制这些效应。我们在此提及它们,是为了让您意识到新技术通常会带来额外的成本,这些成本随后会成为权衡过程的一部分。

动态功耗

设计师确实可以控制电路的动态功耗,即在计算序列中导致节点改变值所消耗的功率。每次节点从0变为1或从1变为0时,电流流过MOSFET的上拉和下拉网络,对输出节点的电容进行充电或放电,从而改变其电压。

考虑反相器在输入电压变化时的操作:上拉和下拉网络导通和关断,将反相器的输出节点连接到VDD或地,对输出节点的电容进行放电或充电,改变其电压。

我们可以通过积分与流入和流出电容的电流相关的瞬时功率乘以电容两端的电压在输出转换所需时间内的值,来计算所需的能量。

MOSFET沟道电阻上耗散的瞬时功率简单地是IS乘以VDS。这是使用能量积分计算输出节点从1到0转换的功率,其中我们使用流出输出节点电容的电流方程I = C dV/dt来测量IS。

假设输入信号是周期为T_clock的时钟信号,并且每次转换占用半个时钟周期。我们可以通过数学计算确定,通过下拉网络耗散的功率是(1/2) * F * C * VDD²,其中频率F告诉我们每秒此类转换的次数,C是节点电容,电源电压VDD是节点电容的起始电压。

当电容充电时,上拉网络耗散的电流有一个类似的积分,它产生相同的结果。因此,一个完整的充电然后放电循环耗散F * C * VDD²瓦特的功率。

请注意,所有这些功率都来自电源。前半部分在输出节点充电时耗散,另一半作为能量存储在电容器中。然后,电容器的能量在放电时耗散。

这些结果总结在左下角。我们添加了整个电路功耗的计算,假设电路的N个节点每个时钟周期都发生变化。

现代集成电路可能消耗多少功率?这是对当前一代CPU芯片的快速粗略估计。假设它以1千兆赫兹运行,并且有1亿个内部节点可能每个时钟周期都发生变化。每个节点电容约为1飞法拉,电源电压约为1伏。根据这些数字,估计的功耗为100瓦。我们都知道100瓦的灯泡有多热。你可以看到很难防止CPU过热。

对于许多应用来说,这功耗太高了,而现代CPU(例如用于笔记本电脑的CPU)只耗散这部分能量的一小部分,因此CPU设计师肯定有一些技巧,我们稍后会看到一些。

但首先,请注意在现代集成电路中能够降低电源电压是多么重要。如果我们能够将电源电压从3.3伏降低到1伏,仅此一项就使功耗降低了10倍以上。因此,在相同的功耗预算下,较新的电路可以更大(例如五倍)且更快(例如两倍)。

这里显示了新技术趋势。净效应是,如果我们负担得起,较新的芯片自然会耗散更多功率。我们必须非常聪明地使用更多、更快的MOSFET,以避免触及我们面临的功耗限制。

降低功耗的策略

为了了解我们可以采取哪些措施来降低功耗,请考虑以下算术逻辑单元(ALU)的示意图,类似于您将在本课程最后实验部分设计的ALU。

有四个独立的组件模块,执行通常在ALU中发现的独立算术、布尔、移位和比较操作。一些ALU控制信号用于在特定时钟周期选择所需的结果,基本上忽略其他模块产生的答案。当然,仅仅因为其他答案未被选中,并不意味着我们在计算它们时没有耗散能量。

这暗示了一个节省功耗的机会。假设我们可以以某种方式关闭不需要其输出的模块。防止它们耗散功率的一种方法是防止模块的输入发生变化,从而确保没有内部节点会改变,从而将关闭模块的动态功耗降至零。

一种想法是在每个模块的输入端放置锁存器,仅当当前周期需要该模块的答案时才打开模块的输入锁存器。如果模块的锁存器保持关闭,其内部节点将保持不变,从而消除模块的动态功耗。这可以节省大量功率。例如,移位器电路有许多内部节点,因此具有较大的动态功耗,但在大多数程序中移位操作相对较少。因此,通过我们提出的修复方案,大多数时候不会产生这些能量成本。

一种更严格的节能方法是,通过切断电源来实际关闭电路中未使用的部分。这实现起来更复杂,因此这种技术通常保留用于特殊的节能操作模式,在这种模式下,我们可以承受可靠地为电路重新供电所需的时间。

另一个想法是在电路无事可做时降低时钟频率,减少节点转换的频率。这对于与现实世界交互的设备特别有效,其中重大外部事件的时间尺度以毫秒计。设备可以缓慢运行,直到需要处理外部事件,然后在处理事件时加快时钟频率。

所有这些技术以及更多技术都用于现代移动设备中,以在不限制提供突发性能能力的情况下节省电池电量。在这个领域还有更多的创新有待完成,作为设计师,您可能会被要求解决这些问题。

最后一个问题是,计算是否必须消耗能量。关于这个问题有一些有趣的理论推测,请参阅课程笔记的第6.5节以了解更多。

总结

本节课中,我们一起学习了数字系统中的功耗优化。我们首先了解了静态功耗和动态功耗的来源,其中静态功耗主要与制造工艺相关,而动态功耗则与节点切换频率、电容和电压的平方成正比。接着,我们探讨了几种降低功耗的设计策略,包括通过门控时钟或输入锁存来关闭未使用的模块、在空闲时降低时钟频率,以及更激进地切断部分电路的电源。理解这些权衡和优化技术,对于设计高性能、低功耗的数字系统至关重要。

070:进位选择加法器 🧮

在本节课中,我们将要学习如何通过改进电路设计来提升加法器的性能。我们将从分析一个性能瓶颈——行波进位加法器开始,然后介绍一种名为“进位选择加法器”的优化方案,它能显著降低加法操作的延迟。

性能瓶颈分析

提升性能最直接的方法是减少电路的传播延迟。让我们来看一个常见的性能瓶颈:行波进位加法器。

为了改进它,我们首先需要找出从输入到输出具有最大传播延迟的路径,也就是决定整体 TPD 的路径。在这个例子中,这条路径就是贯穿每个全加器模块的、从进位输入到进位输出的长进位链。

为了触发这条路径,我们可以设置 A 输入全为 1,B 输入除最低位为 1 外全为 0,从而计算 -1 + 1。最终结果是 0,但请注意,每个全加器都必须等待前一级的进位输入,才能在其和输出端产生 0,并为下一个全加器生成进位输出。进位确实像波浪一样在电路中传播,每个全加器依次完成自己的工作。

这条路径上的总传播延迟是 (n-1) 乘以每个全加器从进位输入到进位输出的延迟,再加上产生最终和值的延迟。

大 O 表示法 📈

如果我们把操作数的大小(即 N)增加一倍,整体延迟会如何变化?使用大 O 表示法来总结延迟对 N 的依赖关系,有助于我们把握宏观趋势。显然,随着 N 增大,末尾异或门的延迟变得不那么重要,因此大 O 表示法会忽略随着 N 增长而相对不重要的项。

在这个例子中,延迟是 O(n),这告诉我们,如果 N 增大一倍,延迟预期也会大致翻倍。

大 O 表示法(理论家称之为渐近分析)告诉我们随着 N 增长,哪个项将主导结果。黄色方框包含了正式定义,但一个例子可能更容易理解。

假设我们想描述方程 n² + 2n + 3 的值随着 n 增大的增长情况。主导项显然是 ,并且除了有限个 n 值外,我们方程的值被 的简单倍数上下限所界定。对于 n ≥ 0,下限总是成立。而在本例中,上限仅在 n = 0, 1, 2, 3 时不成立,对于所有其他正 n 值,上限不等式都成立。因此,我们说这个方程是 O(n²)

实际上大 O 表示法有两种变体:我们使用 Θ 符号表示 G(n)F(n) 的倍数上下界所限定;使用 O 符号表示 G(n) 仅被 F(n) 的倍数上界所限定。

进位选择加法器设计思路 💡

上一节我们分析了行波进位加法器的延迟问题,本节中我们来看看如何改进。行波进位加法器的问题在于,高位必须等待来自低位的进位输入。我们能否让加法器的高半部分与低半部分并行工作呢?

假设我们要构建一个 32 位加法器。让我们制作两个高 16 位加法器的副本:一个假设来自低位的进位输入是 0,另一个假设进位输入是 1。这样,我们现在有三个 16 位加法器,它们都可以在新到达的 A 和 B 输入上并行操作。

一旦 16 位加法完成,我们就可以使用低半部分产生的实际进位输出来选择那个使用了匹配进位输入值的高半部分加法器的答案。这种类型的加法器被恰当地命名为“进位选择加法器”。

这个进位选择加法器的延迟仅比一个 16 位行波进位加法器的延迟多一点。这大约是原始 32 位行波进位加法器延迟的一半。因此,以增加约 50% 的电路为代价,我们将延迟减半。

作为下一步,我们可以应用相同的策略来降低 16 位加法器的延迟,然后再降低上一步中使用的 8 位加法器的延迟。每一步,我们都将加法器延迟减半,并增加一个多路选择器的延迟。经过 log₂(N) 步后,N 将为 1,我们就完成了。此时,延迟将是一个执行 1 位加法的常数开销,加上 log₂(n) 乘以选择正确答案的多路选择器延迟。

因此,进位选择加法器的整体延迟是 O(log n)。注意,log₂(n)log n 仅相差一个常数因子,因此在大 O 表示法中我们忽略对数的底数。进位选择加法器展示了设计者可用的一个清晰的性能与面积权衡。

优化设计实例 🛠️

由于加法器在许多数字系统中扮演重要角色,这里展示一个经过更精心设计的 32 位进位选择加法器版本。你可以在你的 ALU 设计中尝试它。

加法器模块的大小经过选择,使得试算和与前一级的进位输入大约同时到达进位选择多路选择器。请注意,由于多路选择器的选择信号负载较重,我们加入了一个缓冲器来加快选择信号的转换速度。

这个进位选择加法器比 32 位行波进位加法器快约 2.5 倍,代价是电路规模大约增加一倍。当你想让你的 ALU 速度翻倍时,这是一个值得记住的优秀设计。


本节课中我们一起学习了如何通过分析关键路径来识别性能瓶颈,并引入了大 O 表示法来量化延迟与输入规模的关系。我们重点介绍了进位选择加法器的设计原理,它通过并行计算不同进位假设下的结果,并用实际进位进行选择,从而将加法延迟从 O(n) 降低到 O(log n),实现了显著的性能提升,同时展示了数字设计中典型的性能与面积权衡。

071:6.2.3 超前进位加法器 🚀

在本节课中,我们将学习一种全新的加法器设计方法——超前进位加法器。这种方法通过重构进位逻辑,将加法器的延迟从线性降低到对数级别,从而显著提升运算速度。


概述

之前我们学习了行波进位加法器,其延迟与位数成正比。本节我们将探索一种更快的方案,其核心思想是将进位逻辑从链式结构转变为树状结构,利用“生成”和“传播”信号并行计算所有进位。


重构全加器进位逻辑

首先,我们从全加器模块的进位输出方程开始。通过改写方程,我们可以将其分解为两个核心部分:

  • 生成项 (G):当模块的输入自身就能产生进位输出时,该信号为真。其逻辑为:G = A AND B
  • 传播项 (P):当模块的进位输出依赖于进位输入时,该信号为真。其逻辑通常定义为:P = A XOR B

注意:传播项的逻辑从 A OR B 改为 A XOR B,这并不改变进位输出的真值表,但能让我们用 Sum = P XOR C_in 来计算和输出。

基于此,进位输出 C_out 可以简洁地表示为:
C_out = G OR (P AND C_in)

这意味着产生进位只有两种方式:要么由当前模块生成,要么由前一级的进位传播过来。

以下是重组后的全加器模块示意图。进位输出的小型与或电路可以用三个2输入与非门实现,比实验2中建议的三乘积项实现更为紧凑。


构建生成-传播模块

上一节我们定义了单个全加器的生成与传播信号。现在,让我们看看如何将两个相邻的加法器模块组合成一个更大的“块”。

考虑一个由高位模块(H)和低位模块(L)组成的块。我们可以利用每个模块的G和P信息,推导出整个块的生成(G_block)和传播(P_block)方程:

  • 块生成 (G_block):当高位模块自身生成进位,或者低位模块生成进位且被高位模块传播时,整个块会生成进位。
    G_block = G_H OR (P_H AND G_L)
  • 块传播 (P_block):只有当低位模块传播其进位输入到中间进位,并且高位模块将该中间进位传播到最终输出时,整个块才传播进位输入。
    P_block = P_H AND P_L

这两个方程非常简单,只需几个逻辑门即可实现。我们可以据此构建一个生成-传播模块

下图展示了如何将GP模块与H、L模块连接。GP模块的G和P输出告诉我们,在什么条件下这个由两个独立模块组成的更大块会产生进位输出。



构建树状结构

我们可以使用更多层的GP模块来构建一个逻辑树,用于计算任意数量输入加法器的生成和传播逻辑。

对于一个有n个输入的加法器,这棵树总共包含 n-1 个GP模块,并且具有 O(log n) 的延迟。这意味着延迟随位数增长的速度大大减缓。

下一步,我们将看到如何利用这些生成和传播信息来快速计算每个原始全加器模块的进位输入。


计算各级进位输入

一旦我们获得了最低位的进位输入C0,就可以分层计算每个全加器模块的进位输入。

给定一个加法器块的进位输入,我们只需将其作为该块低半部分的进位输入传递下去。而高半部分的进位输入,则需要利用低半部分的生成和传播信息来计算:

  • C_in_high = G_low OR (P_low AND C_in_block)

我们可以用这些方程构建一个进位计算模块,并按如下图所示的方式将C模块排列成树。这样,就可以利用C0进位输入,分层计算越来越小的块的进位输入,直到最终到达每个全加器模块。

例如,以下方程展示了如何从C0计算C4,以及如何从C4计算C6:
C4 = G[0:3] OR (P[0:3] AND C0)
C6 = G[4:5] OR (P[4:5] AND C4)

同样,从C0输入到达,到计算出每个全加器的进位输入,其总传播延迟也是 O(log n)

请注意,特定C模块的GL和PL输入,与GP树中相同位置的GP模块的两个输入是相同的。


整合:超前进位模块

我们可以将GP模块和C模块组合成一个单一的超前进位模块。这个模块在树中向上传递生成和传播信息,同时向下传递进位输入信息。

顶部的示意图展示了如何连接超前进位模块树。

现在,我们得到了所有这些努力工作的回报:分层计算向上路径的生成/传播信息以及向下路径的进位输入信息,其组合传播延迟为O(log N)。由于计算和输出仅需要一个额外的异或门延迟,因此这也就是整个加法器的总延迟。

与行波进位加法器的O(n)延迟相比,这是一个相当大的改进。

最终设计说明:我们不再需要全加器模块中的进位输出电路,因此可以将其移除。这种生成-传播策略的变体构成了已知最快加法器电路的基础。如果你想了解更多,可以在维基百科上查找“Kogge-Stone加法器”。


总结

本节课中,我们一起学习了超前进位加法器的设计原理。我们通过引入生成(G)传播(P)信号,将进位计算从串行链改为并行树状结构,从而将加法器的关键路径延迟从线性降低到对数级别。这种设计是高性能算术逻辑单元的核心技术之一。

072:8.2.4 二进制乘法 🧮

在本节课中,我们将要学习二进制乘法的硬件实现原理。我们将从最直观的实现方式开始,然后探讨如何通过权衡来优化其面积或速度。

算术逻辑单元中最大、最慢的电路之一就是乘法器。我们将首先开发一个直接的实现方案,然后在下一节中,探讨如何通过权衡来使其变得更小或更快。

乘法运算的原理

以下是两个无符号二进制操作数的乘法运算,被分解为其基本操作步骤。这和我们小学时学习乘法的方式完全一样。

我们取乘数(B操作数)的每一位,利用我们记忆中的乘法表,将其与被乘数(A操作数)的每一位相乘。在从右向左处理被乘数的过程中,处理任何可能产生的进位。这一步的输出被称为部分积

然后,我们对乘数的剩余位重复此步骤。每个部分积都向左移动一位,这反映了乘数各位权重的增加。

在我们的例子中,数字是单个比特,即0或1,因此乘法表非常简单。事实上,一位二进制乘法电路就是一个两输入的与门。并且,这里没有进位。由于没有进位,部分积的宽度为N位。如果乘数有M位,就会有M个部分积。当我们把这些部分积相加时,如果考虑到最高位可能产生的进位,我们将得到一个N+M位的结果。

公式: 部分积 = A × B[i] (其中i是乘数的第i位)

乘法器的电路实现

乘法运算中简单的部分是生成部分积,这只需要一些与门。更昂贵的操作是将M个N位的部分积相加。

以下是实现4位乘4位乘法所需的组合逻辑电路图。这个设计很容易扩展到更大的乘法器:对于更大的乘数,我们需要更多的行;对于更大的被乘数,我们需要更多的列。

  • M × N 个两输入与门用于计算M个部分积的各个比特。
  • 加法器模块将当前行的部分积与之前所有行的部分积之和相加。

实际上,这里有两种类型的加法器模块:当模块需要三个输入时使用全加器;当只需要两个输入时使用更简单的半加器

这个电路中最长的路径需要仔细分析。信息总是向下移动一行,或者向左移动到相邻的列。由于有M行,并且在任何特定的行和列中,从输入到输出的任何路径上最多有N+M个模块,因此电路的延迟是O(N)量级的(因为M和N只相差一个常数因子)。由于这是一个组合电路,其吞吐量就是延迟的倒数。而硬件总量是O(N²)量级的。

在下一节中,我们将研究如何降低硬件成本,或者如何提高吞吐量。

有符号数的乘法

但在我们继续之前,让我们花点时间看看,如果操作数是二进制补码整数而不是无符号整数,电路将如何改变。

对于二进制补码的乘数和被乘数,它们各自的最高位具有负的权重。因此,在将部分积相加时,我们需要将每个M位的部分积进行符号扩展到加法所需的完整N+M位宽度,以确保在进行加法时,负的部分积能被正确处理。

当然,由于乘数的最高位具有负权重,我们需要减去而不是加上最后一个部分积。现在,我们进行一些巧妙的变换:我们会在某些列上先加上一些值,然后再减去它们,目的是消除所有由符号扩展引起的额外加法。我们还将最后一个部分积的减法重写为:先对该部分积取反,然后再加1。

这听起来有些神秘,但经过一系列代数变换后,我们得到了最终需要完成的工作表。令人惊讶的是,这与原始的无符号乘法表没有太大不同。只有少数部分积的比特需要取反,并且有两个“1”需要加到特定的列中。

由此产生的电路如图所示。我们将一些与门改为了与非门以执行必要的取反操作,并修改了逻辑来处理需要加入的两个“1”。彩色元素显示了相对于原始无符号乘法器电路所做的更改。

基本上,用于乘法二进制补码操作数的电路,其延迟、吞吐量和硬件成本与原始电路相同。

总结

本节课中,我们一起学习了二进制乘法的硬件实现。我们从无符号数的乘法原理出发,构建了对应的组合逻辑电路,并分析了其延迟和硬件复杂度。接着,我们探讨了如何将该电路适配用于有符号的二进制补码乘法,通过巧妙的代数变换,最终发现其电路复杂度与无符号乘法器基本相同。在下一节中,我们将探讨如何优化这个乘法器。

073:6.004 2017 第73讲 8.2.5 乘法器设计权衡

在本节课中,我们将学习如何改进原始组合逻辑乘法器的设计,重点关注如何通过流水线技术和进位保留加法器来权衡吞吐量、延迟和硬件成本。

概述

我们将分析原始组合乘法器的性能瓶颈,并探索两种优化策略:一种是使用流水线技术来显著提高吞吐量,另一种是采用顺序逻辑设计来大幅降低硬件成本。核心在于理解进位传播是限制性能的关键,并学习如何利用进位保留加法技术来克服它。

原始设计的瓶颈

原始的组合逻辑乘法器设计,其关键路径延迟与操作数的位数 N 成正比。这是因为每一行的加法操作中,进位信号需要从最低有效位串行传播到最高有效位。因此,其吞吐量(即每单位时间能完成的乘法运算次数)约为 1/(2N)

上一节我们介绍了原始设计的性能限制,本节中我们来看看如何通过流水线技术来改进它。

流水线优化:提高吞吐量

我们的目标是使用流水线规划流程,将计算过程划分为多个阶段,以期获得更小的时钟周期和更高的吞吐量。

以下是实施流水线优化的步骤:

  1. 初始划分:首先在所有输出端画一条轮廓线,这创建了一个单级流水线。但这并未改善吞吐量。
  2. 进一步划分:添加另一条轮廓线,将计算大致分成两半。如果方向正确,我们期望看到吞吐量提升。确实,吞吐量实现了翻倍。
  3. 根本性瓶颈:然而,优化前后的吞吐量都仍然是 O(1/N) 量级。只要一整行加法器位于同一个流水线阶段内,该阶段的延迟就仍然是 O(N),因为必须为进位信号的逐位传播留出时间。

为了获得突破性的改进,我们需要一个关键洞察:必须打破长进位链。

打破进位链的技术

有几种方法可以解决这个问题。这里演示的技术在我们接下来的任务中会很有用。

在这个示意图中,我们重新组织了进位链。进位输出仍然连接到左侧一列的模块,但在这个设计中,是连接到下方一行的模块。因此,所有需要在特定列进行的加法仍然在该列完成,我们只是重组了由哪一行来执行加法。

让我们对这个修订后的示意图进行流水线划分,创建延迟大约相当于两个模块传播延迟的阶段。

  • 水平轮廓线现在切断了长的进位链。
  • 阶段延迟现在变为常数,与 N 无关。

需要注意的是,我们必须增加大约 O(N) 个额外的行来处理进位一直传播到最后的问题,这些额外的电路在图中以灰色框显示。

为了在每个阶段实现与 N 无关的延迟,我们需要大约 O(N) 条轮廓线(即 O(N) 个流水线阶段)。这意味着:

  • 时钟周期现在是常数,记为 O(1)
  • 吞吐量也因此是 O(1),与 N 无关。
  • 系统总延迟因为有 O(N) 个阶段,所以是 O(N)
  • 硬件成本仍然是 O(N²)

因此,流水线进位保留乘法器相比原始电路,吞吐量得到了显著提升。这是我们未来可以记住的另一种设计权衡。我们将在下一个优化中使用进位保留技术。

顺序乘法器:降低硬件成本

现在,我们来看看另一种优化方向:如何仅使用 O(N) 的硬件来实现乘法器。

这个顺序乘法器设计在每个时钟周期计算一个部分积,并将其累加到当前的和中。完成整个乘法运算需要 O(N) 个步骤。

在每个步骤中:

  1. B 寄存器的最低有效位取出乘数的下一个比特。
  2. 该比特与被乘数相与,形成下一个部分积。
  3. 该部分积被送入一个 N 位的进位保留加法器,与 P 寄存器中累积的和相加。
  4. P 寄存器的值和加法器的输出都是进位保留格式。这意味着除了 N 个数据位,还有 N-1 个“保留的进位”需要在下一个周期加到相应的列上。
  5. 加法器的输出保存回 P 寄存器。
  6. 为下一步做准备,PB 寄存器都右移一位。这样,累积和的一个比特就被“退休”到 B 寄存器的高位部分,因为它不会再被剩余的部分积影响。

可以这样理解:我们不是将部分积左移来匹配当前乘数比特的权重,而是将累积和右移。

这种设计的关键优势在于:

  • 时钟周期可以非常小,更重要的是,它与 N 无关。由于没有进位传播,进位保留加法器的延迟极小,仅相当于一个全加器模块的操作时间。
  • 总延迟:经过大约 N 步生成所有部分积后,还需要大约 N 步来完成进位保留加法器中进位的最终传播。因此,总延迟仍然是 O(N)
  • 吞吐量:在 2N 步结束后,答案组合在 PB 寄存器中产生,所以吞吐量是 O(1/N)
  • 硬件成本:最大的变化在于硬件成本降至 O(N),这相比原始组合乘法器的 O(N²) 成本是一个巨大的改进。

总结

本节课中我们一起学习了乘法器设计的几种权衡方案。我们看到,通过一些巧妙的设计,我们可以创造出具有 O(1) 吞吐量的设计,也可以创造出仅需 O(N) 硬件成本的设计。

进位保留加法技术在许多场景下都很有用。它的应用可以在硬件成本不变的情况下提高吞吐量,或者在吞吐量不变的情况下节省硬件成本。理解这些基本权衡对于计算机架构设计至关重要。

数字系统与计算机架构:P1:第一部分总结 🎯

在本节课中,我们将回顾课程第一部分所涵盖的核心内容。从信息编码的基础理论到数字系统的性能权衡,我们已经走过了相当长的学习旅程。


对设计权衡的讨论标志着课程第一部分的结束。

在过去的八次讲座中,我们覆盖了广泛的知识领域。

我们首先探讨了信息论背后的数学原理,并利用它来评估各种有效使用比特序列编码信息内容的替代方案。

接下来,我们将注意力转向如何在编码中精心添加冗余,以确保我们能够检测甚至纠正破坏比特级编码的错误。

随后,我们学习了模拟信号在系统中添加处理元件时如何累积误差。我们通过数字方式使用电压解决了这个问题,选择两个电压范围来编码比特值 01。我们为输出和输入制定了不同的信号规范,增加了噪声容限以使信号传输更加稳健。

然后,我们为组合器件制定了静态规则,并得出结论:我们的器件必须是非线性的,并且增益必须大于 1

在研究组合逻辑时,我们首先认识了 MOSFET,一种电压控制开关。我们开发了一种使用 MOSFET 构建 CMOS 组合逻辑门的技术,该技术满足了静态规则的所有标准。😊

接着,我们讨论了系统化合成更大组合电路的方法,这些电路能够实现任何我们可以用真值表形式表达的功能。

为了能够执行一系列操作,我们首先基于正反馈环路开发了一个可靠的双稳态存储元件。为确保存储元件正常工作,我们引入了动态规则,要求存储元件的输入在切换到存储模式之前和之后的一段时间内保持稳定。

我们引入了有限状态机作为设计时序逻辑的有用抽象。然后,我们找到了处理异步输入的方法,以最小化因亚稳态导致错误操作的可能性。

在最后两次讲座中,我们提出了延迟和吞吐量作为数字系统的性能衡量指标,并讨论了在各种约束下实现最大吞吐量的方法。我们探讨了如何通过权衡来达成目标,例如通过降低延迟来最小化功耗并提高性能,或者增加吞吐量。

在短时间内,我们掌握了大量信息。Blue 先生和 6.004 课程团队的其他成员希望您发现本课程对提高您设计数字系统和分析其操作的技能有所帮助。

您已经完成了几个实际设计,并且正顺利迈向使用我们的标准单元库设计一台完整的计算机。这是相当了不起的成就。

如果您希望继续这段旅程,请加入我们课程的第二部分,在那里我们将讨论可编程架构,并完成一个现代 32 位处理器的设计。

届时再见。

075:数据通路与有限状态机

欢迎来到6.004课程的第二部分。在本部分课程中,我们将注意力转向数字系统的设计与实现,这些系统能够对不同类型的二进制数据执行有用的计算。我们将为这些系统提出一个通用的设计,我们称之为计算机,以便它们能够在许多不同的应用领域中作为有用的工具。

计算机最初用于科学与工程领域的数值计算。但如今,它们被用作任何需要复杂行为的系统中的中央控制元件。

在本章中,我们有很多内容要学习。让我们开始吧。

设计目标:阶乘计算系统

假设我们想设计一个系统来计算某个数值参数N的阶乘函数。N的阶乘定义为N乘以N-1乘以N-2,依此类推直到1。

我们可以使用像C这样的编程语言来描述执行阶乘计算所需的一系列操作顺序。在这个程序中,有两个变量:A和B。A用于在我们逐步计算答案时累积结果。B用于保存下一个需要相乘的值。B从数值参数N的值开始。do循环是完成工作的地方,在每次循环迭代中,我们执行阶乘公式中的一个乘法,用结果更新累加器A的值,然后递减B的值,为下一次循环迭代做准备。

高级有限状态机设计

如果我们想实现一个执行该操作序列的数字系统,使用时序逻辑是合理的。下图是一个高级有限状态机的状态转移图,它被设计为以期望的顺序执行必要的计算。

我们称之为高级FSM,因为每个状态的输出不仅仅是简单的逻辑电平,而是指示要对源变量执行的操作的公式,并将结果存储在目标变量中。FSM运行时访问的状态序列反映了C程序执行所执行的步骤。

FSM重复循环状态,直到要存入B的新值等于0,此时FSM转换到最终的完成状态。

构建数据通路

高级FSM在设计使用数字逻辑构建块实现所需计算的电路时非常有用。我们将使用32位D寄存器来保存A和B的值。我们还需要一个2位D寄存器来保存当前状态的2位编码,即起始、循环或完成状态的编码。我们将包含逻辑来计算实现正确状态转换所需的输入。在本例中,我们需要知道B的新值是否为0。最后,我们需要逻辑来执行乘法和递减操作,并选择在每个FSM周期结束时应加载到A和B寄存器中的值。

让我们从设计实现所需计算的逻辑开始。我们称这部分逻辑为数据通路。

首先,我们需要两个32位D寄存器来保存A和B的值。然后,我们将绘制计算要存储在这些寄存器中的值所需的组合逻辑块。

在起始状态,我们需要将常量1加载到A寄存器,将常量N加载到B寄存器。

在循环状态,我们需要为A寄存器计算A乘以B,为B寄存器计算B减1。

最后,在完成状态,我们需要能够用其当前值重新加载每个寄存器。

我们将使用多路复用器来选择要加载到每个数据寄存器中的适当值。这些多路复用器由2位选择信号控制,这些信号选择三个32位输入值中的哪一个将成为要加载到寄存器中的32位值。

因此,通过为WA_sel和WB_sel选择适当的值,我们可以使数据通路在FSM操作的每一步计算所需的值。

添加控制逻辑

接下来,我们将添加控制FSM状态转换所需的组合逻辑。在本例中,我们需要测试要加载到B寄存器的新值是否为0。数据通路的Z信号在这种情况下为1,否则为0。

现在,我们可以添加控制FSM的硬件了,它有一个来自数据通路的输入Z,并生成两个2位输出WA_sel和WB_sel来控制数据通路。

有限状态机真值表与实现

这是FSM组合逻辑的真值表。S是编码为2位值的当前状态,S_prime是下一个状态。

利用我们在课程第一部分学到的技能,我们准备好绘制系统的原理图。我们知道如何设计适当的乘法器和递减电路,并且可以使用标准的寄存器和ROM实现来控制FSM。来自数据通路的Z信号与2位当前状态组合,形成组合逻辑的三个输入。在本例中,组合逻辑由一个具有2^3(即8)个位置的只读存储器实现。

每个ROM位置都有六个输出位的适当值,WA_sel、WB_sel和下一个状态各占2位。右侧的表格显示了ROM内容,这些内容很容易从前一页的表格中确定。

总结

在本节课中,我们一起学习了如何设计一个计算阶乘的数字系统。我们从高级C程序描述开始,将其转换为高级有限状态机。然后,我们详细设计了系统的两个主要部分:执行算术运算的数据通路,以及控制操作顺序和选择正确数据路径的有限状态机控制器。最后,我们展示了如何将整个系统实现为使用寄存器、组合逻辑块和ROM的数字电路。这种数据通路加控制器FSM的设计模式是构建复杂数字系统(包括计算机)的基础。

076:可编程数据通路

在本节课中,我们将学习如何设计一个通用的硬件系统,使其能够通过编程来解决多种不同的问题。我们将从专用计算电路的设计思路出发,探讨如何构建一个可编程的数据通路,并理解其背后的控制逻辑。

概述

我们之前已经找到了一种设计硬件来执行特定计算的方法。首先,绘制一个有限状态机(FSM)的状态转移图,以描述完成计算所需的一系列操作序列。接着,使用寄存器存储数值,并结合组合逻辑来实现所需操作,从而构建出合适的数据通路。最后,构建一个FSM来生成数据通路所需的控制信号。

那么,数据通路加上控制逻辑本身是否构成一个FSS?答案是肯定的。它包含寄存器和一些组合逻辑,因此它本身就是一个FSM。理论上,我们可以为其绘制真值表,但在实践中,由于存在大量寄存器状态(例如66位),真值表的行数将高达2的66次方,这在实际中是不可行的。

这种困难源于将寄存器和数据通路视为一个“超级FSM”状态的一部分。因此,我们通常将数据通路与控制FSM分开考虑。

构建通用数据通路

上一节我们介绍了专用电路的设计方法,本节中我们来看看如何将这种方法通用化,以便使用一个计算机电路来解决许多不同的问题。

大多数问题可能需要更多的操作数和结果存储空间,同时,一个更大的允许操作列表也会非常有用。这实际上有些棘手,我们需要确定一个最小操作集合。正如我们稍后将看到的,令人惊讶的是,非常简单的硬件就足以执行任何可实现的运算。

在另一个极端,许多复杂操作(例如快速傅里叶变换)最好通过一系列更简单的操作(例如加法和乘法)来实现,而不是作为一个单一的大型组合电路。这类设计权衡正是计算机架构的乐趣所在。

我们将更大的存储空间与我们选定操作集合的逻辑结合起来,形成一个通用数据通路,该通路可以重复用于解决许多不同问题。

以下是其工作原理。这里展示了一个包含四个数据寄存器的数据通路,用于保存结果。A选路器和B选路器允许选择任意数据寄存器作为算术和布尔运算的操作数。运算结果由Op选路器选择,并且可以通过将正确的写使能控制信号设置为1,并使用2位W选路信号来选择在下一个时钟上升沿加载哪个数据寄存器,从而写回任意数据寄存器。

请注意,数据寄存器有一个加载使能控制输入。当此信号为1时,寄存器将从其D输入端加载新值;否则,它将忽略D输入并简单地重新加载其先前值。

当然,我们还会添加一个控制FSM,为数据通路生成适当的控制信号序列。来自数据通路的Z输入允许系统执行数据相关的操作,即操作序列可以受到数据寄存器中实际值的影响。

编程通用硬件

如果我们想使用这个数据通路来计算阶乘(假设数据寄存器的初始内容如图所示),我们将使用以下控制FSM的状态转移图。

与最初的实现相比,我们需要更多的状态,因为这个数据通路在每个步骤只能执行一个操作。因此,每次迭代需要三个步骤:一个用于乘法,一个用于递减,一个用于测试是否完成。

正如这里所见,通用计算机硬件通常比优化的专用电路需要更多的周期,并且可能涉及更多的硬件。只要不需要超过四个数据寄存器来保存输入数据、中间结果和最终答案,你就可以用这个系统解决许多不同的问题,例如幂运算、除法、平方根等。

通过设计控制FSM,我们实际上是在对我们的数字系统进行编程,指定它将执行的操作序列。这正是早期数字计算机的工作方式。

上图是1943年在宾夕法尼亚大学建造的ENIAC计算机。维基百科关于ENIAC的文章告诉我们,ENIAC可以被编程来执行复杂的操作序列,包括循环、分支和子程序。将一个问题映射到机器上的任务非常复杂,通常需要数周时间。在纸上设计出程序后,通过操作其开关和电缆将程序输入ENIAC的过程可能需要数天。随后是一个验证和调试阶段,得益于能够逐步执行程序的能力。

显然,我们需要一种不那么繁琐的方式来为我们的计算机编程。

总结

本节课中,我们一起学习了从专用计算硬件到通用可编程数据通路的设计思路演变。我们了解到,通过将数据存储、运算单元和控制逻辑分离,并引入一个有限状态机来生成控制序列,可以构建一个能够解决多种问题的通用硬件框架。同时,我们也看到了早期编程方式的局限性,这为后续更高级的编程模型和计算机架构发展奠定了基础。

077:冯·诺依曼模型 🖥️

在本节课中,我们将学习构建通用计算机的核心模型——冯·诺依曼模型。我们将了解其基本组成部分、工作原理,以及指令集架构(ISA)的重要性。


构建易于针对新问题进行重新编程的通用计算机有多种方法。几乎所有现代计算机都基于约翰·冯·诺依曼于1945年提出的存储程序计算机架构,该架构现在通常被称为冯·诺依曼模型。

冯·诺依曼模型包含三个主要组件。

中央处理器(CPU)🧠

中央处理器(CPU)包含一个数据通路和一个控制有限状态机(FSM)。CPU是执行计算和控制的核心。

主存储器 💾

CPU连接到一个读写存储器,该存储器容纳一定数量(W)的字,每个字有n位。如今,即使是小型存储器也拥有数十亿个字,每个存储位置的宽度至少为32位,通常更多。该存储器通常被称为主存储器,以区别于系统中的其他存储器。

你可以将其视为一个数组。当CPU希望对存储器中的值进行操作时,它会向存储器发送一个数组索引(我们称之为地址)。经过短暂的延迟(目前是数十纳秒),存储器将返回存储在该地址的n位值。

对主存储器的写入遵循相同的协议,当然,数据流方向相反。我们将在后续课程中讨论存储器技术。

输入/输出设备 🔌

最后,还有输入/输出设备,使计算机系统能够与外部世界通信,或访问数据存储。与主存储器不同,这些存储设备即使在断电时也能记住数据。


核心思想:存储程序 💡

关键思想是使用主存储器来存储CPU的指令和数据。当然,指令和数据都只是存储在主存储器中的二进制值。

当被解释为指令时,存储器中的一个值可以被视为一组字段,包含一个或多个比特,用于编码有关CPU要执行的操作的信息。

  • 操作码字段:指示要执行的操作,例如 add(加)、xor(异或)、compare(比较)。
  • 后续字段:指定哪些寄存器提供源操作数,以及存储结果的目标寄存器。

CPU解释指令字段中的信息并执行请求的操作。然后,它会继续执行存储器中的下一条指令,逐步执行存储的程序。

本章的目标是讨论我们希望CPU执行哪些操作的细节,例如应该有多少个寄存器等。

当然,存储器中的一些值不是指令。它们可能是表示数值、字符串等的二进制数据。当CPU需要对它们进行操作时,会将这些值读入其临时寄存器,并将新计算的值写回存储器。

这里有一个很好的问题:我们如何知道存储器中的哪些字是指令,哪些是数据?毕竟,它们都是二进制值。答案是,仅通过查看值本身无法区分。是CPU如何使用它们来区分指令和数据。如果一个值被加载到数据通路中,它被用作数据。如果一个值被控制逻辑加载,它被用作指令。


系统构建:数据通路与控制单元 ⚙️

因此,这是我们为执行计算而构建的数字系统。

我们将从一个包含若干寄存器(用于保存数据值)的数据通路开始。我们将能够选择哪些寄存器为算术逻辑单元(ALU)提供操作数,ALU将执行操作。ALU产生一个结果和其他状态信号。ALU的结果可以写回其中一个寄存器以供后续使用。

我们将为数据通路提供与主存储器之间传输数据的手段。

将有一个控制单元,为数据通路提供必要的控制信号。在所示的示例数据通路中,控制单元将提供 A_selB_sel 来选择两个寄存器值作为操作数,并提供 Dest 来选择写入ALU结果的寄存器。如果数据通路有32个内部寄存器,A_selB_selDest 将是5位值,每个值指定一个0到31范围内的特定寄存器编号。

控制单元还提供 FN 功能码,用于控制ALU执行的操作。我们在课程第一部分设计的ALU需要一个六位功能码来在各种算术、布尔和移位操作之间进行选择。

控制单元从主存储器加载值,并将其解释为指令。控制单元包含一个称为程序计数器(PC)的寄存器,用于跟踪主存储器中要执行的下一条指令的地址。控制单元还包含(希望是)少量的逻辑电路,用于将指令字段转换为必要的控制信号。

请注意,控制单元接收来自数据通路的状态信号,这将使程序能够执行不同的指令序列,例如,当某个特定数据值为0时。

  • 数据通路 是我们数字系统的“肌肉”,负责存储和操作数据值。
  • 控制单元 是我们系统的“大脑”,解释存储在主存储器中的程序,并为数据通路生成必要的控制信号序列。

指令执行循环 🔄

指令是工作的基本单位。它们由控制单元获取,并按照获取的顺序一个接一个地执行。

每条指令指定要执行的操作,以及提供源操作数的寄存器和存储结果的目标寄存器。

在冯·诺依曼机器中,指令执行涉及以下步骤:

  1. 取指:从程序计数器(PC)指定的地址对应的存储器位置加载指令。
  2. 译码与执行:当存储器返回请求的数据时,指令字段被转换为数据通路的适当控制信号。这包括从指定寄存器中选择源操作数,指示ALU执行指定操作,并将结果存储在指定的目标寄存器中。
  3. 更新PC:执行指令的最后一步是更新程序计数器的值,使其成为下一条指令的地址。

这个执行循环一遍又一遍地执行。现代机器每秒可以执行超过十亿条指令。


指令集架构(ISA)📜

到目前为止的讨论有些抽象。现在,是时候深入研究并确定我们希望系统支持哪些指令了。

指令字段及其含义的规范,以及数据通路设计的细节,统称为系统的指令集架构(ISA)

ISA是操作和存储机制的详细规范,并作为数字硬件设计者和编写程序的程序员之间的契约。

由于程序存储在主存储器中,因此可以更改,我们将它们称为软件,以区别于一旦实现就不会改变的数字逻辑(硬件)。硬件和软件的结合决定了我们系统的行为。

ISA是一个新的抽象层。我们可以为系统编写程序,而无需了解硬件的实现细节。随着硬件技术的进步,我们可以构建更快的系统,而无需更改软件。

你可以看到,在15年的时间跨度内,执行Intel x86指令集的硬件从每秒执行30万条指令发展到每秒执行50亿条指令。软件保持不变,我们只是利用了更小更快的MOSFET来构建更复杂的电路和更快的执行引擎。

但需要提醒一句。人们很容易在ISA中做出反映当前技术限制的选择,例如内部寄存器的数量、操作数的宽度或主存储器的最大大小。但当技术改进时,很难改变ISA,因为存在强大的经济动机来确保旧软件可以在新机器上运行。这意味着一个特定的ISA可以存在数十年,并跨越许多代技术。如果你的ISA成功了,你将不得不长期忍受你所做的任何糟糕选择。


设计ISA的挑战与定量方法 📊

设计一个ISA是困难的。应该支持哪些操作?需要多少个内部寄存器?我们应该如何设计指令编码以最小化程序大小,或保持控制单元的逻辑尽可能简单?展望未来,我们对未来技术的计算和存储能力能说些什么?

我们将通过定量方法来回答这些问题。

首先,我们将选择一组基准程序,这些程序被选为我们期望在系统中运行的多种类型程序的代表。因此,一些基准程序将执行科学和工程计算,一些将操作大型数据集或执行数据库操作,一些将需要用于图形或通信的专门计算等。幸运的是,经过数十年的计算机使用,有几个标准化的基准测试套件可供我们使用。

然后,我们将使用我们的指令集实现这些基准程序,并在我们提议的数据通路上模拟它们的执行。我们将评估结果以衡量性能表现。但“表现好”是什么意思呢?这就变得有趣了,“好”可以指执行速度、能耗、电路大小、系统成本等。如果你正在设计智能手表,你的选择会与设计高性能GPU卡或数据中心服务器不同。

无论你选择什么指标来评估你提议的系统,都有一个重要的设计原则可以遵循:识别常见操作,并在优化设计时专注于它们。例如,在通用计算中,几乎所有程序都花费大量时间在简单的算术操作和访问主存储器中的值上。因此,应该使这些操作尽可能快速和节能。

现在,让我们开始设计我们自己的指令集和执行引擎,这个系统我们将称之为 Beta


总结 ✨

在本节课中,我们一起学习了冯·诺依曼模型的基本原理,它是现代计算机的基石。我们了解了CPU、主存储器和I/O设备如何协同工作,通过存储程序的概念执行计算。我们还探讨了指令集架构(ISA)作为硬件和软件之间关键接口的重要性,以及设计ISA时面临的挑战和采用的定量方法。接下来,我们将深入设计我们自己的Beta指令集架构。

078:6.004 2017 第78讲 9.2.4 存储

📚 概述

在本节课程中,我们将学习计算机架构中的存储系统,特别是以Beta处理器为例,探讨精简指令集计算机(RISC)架构中寄存器与主内存的角色、区别以及它们如何协同工作来执行程序。


🏗️ 精简指令集架构(RISC)

Beta处理器是精简指令集计算机架构的一个例子。“精简”指的是在Beta的指令集架构中,大多数指令只访问内部寄存器来获取操作数和存储结果。内存值的加载和存储需要使用独立的内存访问指令,这些指令仅实现简单的地址计算。这些精简措施使得硬件实现更小、性能更高,并且在软件方面编译器也更简单。ARM和MIPS指令集是其他RISC架构的例子,而英特尔的x86指令集则更为复杂。


💾 CPU内部存储:寄存器

CPU内部有有限数量的存储空间,用顺序逻辑的语言来说,这被称为CPU状态。其中包括一个32位的程序计数器(简称PC),它保存着主内存中当前指令的地址。此外,还有32个编号从0到31的寄存器,每个寄存器都保存一个32位的值。我们在指令中使用5位的字段来指定用作操作数或目标的寄存器编号。

作为简写,我们使用前缀“R”加编号来指代一个寄存器。例如,R0指的是由5位字段00000选择的寄存器。寄存器31(即R31)是特殊的,它的值读取时总是0,并且向R31写入不会影响其值。

每个寄存器的位数,也就是ALU操作所支持的位数,是指令集架构的一个基本参数。Beta是一个32位架构。许多现代计算机是64位架构,这意味着它们有64位寄存器和64位数据通路。


🗄️ 主内存

主内存是一个32位字的数组,每个字包含4个8位的字节。字节编号为0到3,其中字节0对应32位值的低7位,依此类推。Beta指令集架构只支持字访问,即加载或存储完整的32位字。大多数真实的计算机还支持对字节和半字的访问。

尽管Beta只寻址完整的字,但遵循许多指令集架构的惯例,它使用字节地址。由于每个字有4个字节,内存中连续字的地址相差4。因此,内存中的第一个字地址为0,第二个字地址为4,依此类推。在下图所示的图表中,您可以看到每个内存位置左侧的地址。

请注意,在指定地址和其他二进制值时,我们通常使用十六进制表示法。前缀0x表示一个数字是十六进制的。在绘制内存图时,我们遵循一个惯例:地址从上到下阅读时递增。

Beta支持32位寻址,因此一个地址正好可以放入一个32位寄存器或内存位置。最大内存大小是232字节或230字,即4千兆字节或10亿字的主内存。一些Beta的实现可能实际上有更小的主内存,换句话说,位置少于10亿个。


⚖️ 寄存器与主内存:权衡

为什么要有独立的寄存器和主内存呢?现代程序和数据集非常庞大,因此我们需要一个大容量的内存来容纳所有内容。但是,大容量内存速度慢,并且通常一次只能访问一个位置,因此它们不适合用作每条指令的存储,因为指令需要访问多个操作数并存储一个结果。如果我们只使用一个大的存储阵列,那么一条指令将需要3个32位地址来指定两个源操作数和目标。每条指令的编码会非常庞大,并且所需的内存访问必须一个接一个地进行,这会大大减慢指令执行速度。

另一方面,如果我们使用寄存器来保存操作数和存储结果,我们可以设计寄存器硬件以实现并行访问,并使其非常快速。为了保持高速,我们不能有太多的寄存器,这是数字系统中常见的规模与速度的性能权衡。最终,带来最佳性能的权衡是:拥有少量非常快速的寄存器供大多数指令使用,以及一个容量大但速度慢的主内存。这正是Beta指令集架构所做的。


🔄 程序执行模板

通常,所有程序数据都驻留在主内存中。程序使用的每个变量都位于特定的主内存位置,因此具有特定的内存地址。例如,在下图中,变量x的值存储在内存位置0x1008,变量y的值存储在内存位置0x1000,依此类推。

要执行一个计算,例如计算x * 37并将结果存储在y中,我们必须首先将x的值加载到一个寄存器中,比如R0。然后,数据通路将R0中的值乘以37,将结果存回R0。这里我们假设常数37以某种方式对数据通路可用,本身不需要从内存加载。最后,我们将R0中更新后的值写回y所在的内存位置。步骤很多。

当然,如果我们选择将x和y的值保存在寄存器中,就可以避免所有的加载和存储操作。由于只有32个寄存器,我们无法对所有变量都这样做,但也许我们可以安排将x和y加载到寄存器中,通过引用这些寄存器来执行所有涉及x和y的计算,然后在完成后,将x和y的更改存储回内存以备后用。通过将常用值保存在寄存器中来优化性能,是程序员和编译器编写者常用的技巧。

因此,基本的程序模板是:一些将值加载到寄存器中的指令,接着是计算指令,然后是任何必要的存储指令。使用这种模板的指令集架构通常被称为加载-存储架构


📝 总结

在本节课中,我们一起学习了计算机存储系统的关键组成部分。我们了解了Beta作为RISC架构如何通过精简指令和分离的加载/存储操作来提升效率。我们探讨了CPU内部的快速寄存器与容量大但速度较慢的主内存之间的根本区别与权衡。最后,我们掌握了程序执行的基本模板——加载-存储架构,它通过将常用数据暂存在寄存器中来优化计算性能。理解这些存储层次和访问模式是掌握计算机如何工作的基础。

079:9.2.5 ALU指令

在本节课中,我们将要学习Beta指令集架构中的算术逻辑单元指令。我们将了解其指令格式、编码方式以及如何在数据通路中执行。

上一节我们介绍了Beta ISA提供的存储资源,本节中我们来看看如何设计Beta指令本身。Beta指令分为三类:执行寄存器值算术与逻辑运算的计算指令、访问主存值的加载与存储指令,以及改变程序计数器值的分支指令。我们将依次讨论每一类指令。

在Beta ISA中,所有指令编码的长度相同。每条指令编码为32位,因此恰好占据主存中的一个32位字。这种固定长度的指令编码使得控制单元解码指令的逻辑更简单。对于大多数指令,下一条指令位于当前指令之后的内存位置,计算下一条指令的地址非常简单,只需将程序计数器的当前值加4即可。

虽然固定长度编码在信息密度上可能不如变长编码高效,但解码变长指令的硬件非常复杂。如今,内存技术的进步使得代码大小不再是主要问题,设计的重点转向了满足现代应用所需的高性能。因此,我们选择固定长度编码,虽然代码体积稍大,但能保持硬件执行引擎的小巧与快速。

计算指令格式

Beta的计算指令在算术逻辑单元中执行。我们将使用课程第一部分设计的ALU。

Beta ALU指令包含四个字段:

  • 一个6位字段,指定要执行的ALU操作,称为操作码
  • 两个5位字段RARB,指定源操作数来自的寄存器编号(R0到R31)。
  • 一个5位字段RC,指定结果写入的目标寄存器。

这种指令格式使用了32位字中的21位,剩余位未使用,应设置为0。操作码字段始终位于指令的第31至26位。

以下是ADD指令的二进制编码示例。ADD的操作码是二进制值100000RC字段指定结果写入R3,RARB字段指定源操作数来自R1和R2。因此,这条指令将R1和R2中的32位值相加,并将32位和写入R3。

我们通常使用十六进制表示指令的二进制编码,例如0x80611000。但更简便的方式是使用功能符号来描述指令,例如ADD(R1, R2, R3)。这里使用了一个称为助记符的符号名称(ADD),后跟括号内的操作数列表(源操作数R1、R2,目标操作数R3)。ADD(RA, RB, RC)是请求Beta计算寄存器RA和RB中值之和,并将结果作为寄存器RC新值的简写。

支持的ALU操作

以下是Beta支持的所有ALU操作的助记符列表。每一条指令的详细功能描述可在Beta文档手册中找到。所有这些指令都使用相同的四字段模板,仅在操作码字段的值上有所不同。

这一步相当直接,我们简单地为ALU提供的所有基本操作提供了指令编码。

数据通路实现草图

现在我们有了第一组指令,可以创建一个更具体的实现草图。

在我们的数据通路中:

  • 指令中的5位RARB字段用于选择32个寄存器中的哪两个作为源操作数。注意,R31并非真正的读写寄存器,它只是常量0。因此,选择R31作为操作数意味着使用值0。
  • 指令中的5位RC字段选择哪个寄存器将接收ALU的输出结果。
  • 未显示的是将指令操作码转换为相应ALU功能码所需的硬件,例如可以使用一个64位置的ROM通过查表来执行转换。
  • 程序计数器逻辑支持指令的顺序执行。它是一个32位寄存器,在每条指令结束时通过将其当前值加4来更新。这意味着下一条指令将来自存放当前指令的内存位置之后。

在此图中,我们可以看到RISC架构的一个优势:解码指令以产生控制数据通路所需信号所需的逻辑并不多。事实上,许多指令字段都是直接使用的。

总结

本节课中我们一起学习了Beta ISA的ALU指令。我们了解了其采用32位固定长度编码,包含操作码、两个源寄存器和一个目标寄存器字段。这种设计简化了硬件控制逻辑。我们还查看了所有支持的ALU操作助记符,并初步探讨了指令在数据通路中的执行过程,看到了RISC架构在解码效率上的优势。下一节,我们将继续探讨加载与存储指令。

080:9.2.6 常量操作数

在本节课中,我们将学习如何处理指令集架构设计中的一项常见功能请求:允许在算术逻辑单元指令中使用小常量作为第二个操作数。我们将通过定量分析来权衡其成本与收益,并了解其具体实现方式。

概述

指令集架构设计师经常会收到增加新功能的请求。这些功能在理论上能使指令集架构在某些方面变得更好。处理这类请求时,需要运用定量分析方法,以判断成本与收益之间的权衡。

功能请求:常量操作数

上一节我们讨论了功能请求的一般处理原则,本节中我们来看看一个具体的请求:允许在ALU指令中使用小常量作为第二个操作数。

如果我们将指令中原本用于指定第二个源寄存器(RB)的5位字段替换掉,就可以在指令中腾出空间,容纳一个16位的常量,该常量占据指令的第15至0位。

支持此请求的论据是,小常量在许多程序中频繁出现。如果无需使用加载操作从主内存读取常量值,程序将变得更短。

反对此请求的论据是,实现此功能需要额外的控制和数据通路逻辑,这会增加硬件成本,并可能降低性能。

因此,我们的策略是修改基准测试程序,使其使用增强此功能后的指令集架构,并在模拟执行中测量其影响。

定量分析结果

以下是分析基准测试程序执行动态(而非静态代码)后得到的结果:

  • 超过半数的算术指令使用小常量作为第二个操作数。
  • 80%的比较指令涉及小常量。这可能反映了在执行过程中,比较指令常用于判断循环是否结束。
  • 加载和存储操作进行的地址计算中也经常发现小常量。

涉及常量操作数的操作显然是一个常见情况,非常值得优化。在指令集架构中添加对小常量操作数的支持,使得程序显著变得更小、更快。

因此,功能请求获得批准

常量指令格式

现在我们已经确认了该功能的价值,接下来看看其具体实现格式。

这里我们看到Beta指令的第二种格式。它是第一种格式的修改版,我们用存放常量的16位字段(采用二进制补码格式)替换了原来的5位RB字段。

这允许我们表示范围在 0x8000(十进制-32,768)到 0x7FFF(十进制32,767)之间的常量操作数。

以下是一个加法常量指令的例子,它将R1的内容与常量-3相加,结果写入R3:

ADDC(R1, -3, R3)

在符号表示中,第二个操作数现在是一个常量,或者更一般地说,是一个可以求值为常量值的表达式。

技术细节:符号扩展

一个技术细节需要讨论:指令包含一个16位常量,但数据通路需要一个32位的操作数。数据通路硬件如何将16位的-3表示转换为32位的-3表示?

比较各种常量的16位和32位表示,我们发现:

  • 如果16位二进制补码常量为负数(即最高位为1),则等效32位常量的高16位全为1。
  • 如果16位常量为非负数(即最高位为0),则32位常量的高16位全为0。

因此,硬件需要执行的操作是符号扩展:将16位常量的符号位复制16次,以形成32位常量的高半部分。32位常量的低半部分直接来自指令中的16位常量。

实现符号扩展无需额外的逻辑硬件,仅通过布线即可完成。

支持的指令

以下是14条支持常量形式的ALU指令。它们使用相同的指令助记符,但添加了后缀“C”以指示第二个操作数是常量。

ADDC, SUBC, MULC, DIVC, CMPEQ, CMPLT, CMPLE
ANDC, ORC, XORC, SHLC, SHRC, SARC

由于这些是新增指令,它们拥有与原始ALU指令不同的操作码。

最后请注意,如果我们需要一个无法用16位表示的常量操作数,则必须将该常量作为32位值存储在主内存的某个位置,并像处理任何变量值一样,将其加载到寄存器中使用。

硬件实现概览

为了让大家对所需增加的硬件有所了解,让我们更新一下实现草图,以添加对常量作为第二个ALU操作数的支持。

我们不需要添加太多硬件,只需一个多路复用器。该复用器选择来自RB寄存器的值,或选择来自指令中16位字段并经过符号扩展后的常量。

控制该复用器的B_sel控制信号,在ALU-with-constant指令时为1,在常规ALU指令时为0。

目前,我们将硬件实现细节暂且搁置,将在后续几讲中重新讨论。

总结

本节课中,我们一起学习了如何处理指令集架构的功能增强请求。我们通过定量分析发现,在ALU指令中使用小常量作为第二个操作数是一个高频出现的场景,优化它能显著提升程序性能和减小代码体积。我们介绍了支持该功能的指令格式、符号扩展的原理以及大致的硬件实现思路。当所需常量超出16位表示范围时,仍需通过加载指令从内存获取。

081:内存访问指令详解 🧠

在本节课中,我们将学习Beta ISA中第二类重要指令:加载(Load)和存储(Store)指令。这些指令是CPU访问内存中数据的唯一途径,因为Beta采用的是“加载-存储架构”。我们将详细探讨它们的工作原理、格式以及在编程中的应用。

加载与存储指令概述

上一节我们介绍了算术逻辑指令,本节中我们来看看专门用于内存访问的加载和存储指令。这两条指令与带常量的ALU指令使用相同的指令模板。

指令格式为:OPCODE RA, CONSTANT(RC)。其中OPCODE指定操作类型,RA是基址寄存器,CONSTANT是一个16位有符号常量,RC在加载指令中作为目标寄存器,在存储指令中作为源数据寄存器。

加载指令的工作原理

加载指令(LD)用于从内存读取数据到CPU寄存器。其核心操作是计算内存地址。

以下是加载指令的执行步骤:

  1. 地址计算:将RA寄存器的值与指令低16位符号扩展后的常量相加。此计算与ADC指令完全相同,因此硬件可以复用。
    公式:Memory_Address = RA + SignExtend(CONSTANT)
  2. 内存访问:将计算出的和作为地址发送给主内存。
  3. 数据写入:从主内存返回的数据被写入RC寄存器。

存储指令的工作原理

存储指令(ST)用于将CPU寄存器中的数据写入内存。其地址计算过程与加载指令相同。

存储指令在几个方面比较特殊:

  • 它是唯一需要读取RC寄存器值的指令,因此数据通路硬件需要稍作调整以适应此需求。
  • 由于RC在此充当源操作数,它在指令的符号形式中作为第一个操作数出现,格式为:ST(RC, CONSTANT, RA)CONSTANTRA共同指定目标地址。
  • 它是唯一在指令执行结束后向寄存器文件写入结果的指令。

编程实例解析

让我们通过一个具体例子来理解这些指令的应用。假设我们需要从内存中加载变量x的值,乘以37,然后将结果写回存储变量y的内存位置。

使用Beta指令,我们可以用三条指令序列来表达这个计算:

LD(R31, 0x1008, R1)  // 从地址 (R31=0 + 0x1008) 加载x的值到R1
MULC(R1, 37, R2)     // R2 = R1 * 37
ST(R2, 0x1010, R31)  // 将R2的值存储到地址 (0 + 0x1010)
  • 第一条LD指令将R31(其值恒为0)与常量0x1008相加,得到地址0x1008,这正是变量x的存储位置。
  • 第二条MULC指令执行乘法运算。
  • 第三条ST指令执行类似的地址计算,将结果写入变量y的地址0x1010`。

处理大地址与LDR指令

加载和存储指令的地址计算方式在需要访问的地址能放入16位常量字段时(即地址 <= 0x7FFF)工作良好。

那么,当我们需要访问地址高于0x7FFF的内存位置时该怎么办?
这时,我们需要像处理任何大常量一样对待这些地址,将它们存储在内存中,以便后续通过加载指令读入寄存器供加载/存储指令使用。

但是,如果需要存储的大常量数量超过了低地址区(即我们可以直接访问的地址范围)的容量呢?
为了解决这个问题,Beta指令集包含了一条加载相对地址指令(LDR)。我们将在后续关于Beta实现的课程中详细讲解这条指令。

总结

本节课中我们一起学习了Beta ISA中的加载(LD)和存储(ST)指令。我们了解到它们是CPU与内存交互的唯一桥梁,采用RA + 符号扩展(CONSTANT)的方式计算有效地址。存储指令在操作数顺序和不对寄存器进行写回方面具有特殊性。通过实例,我们看到了如何用这些指令完成从内存读数据、运算、再写回内存的完整流程。最后,我们探讨了处理大地址的挑战,并引出了将在后续课程介绍的LDR指令作为解决方案。掌握这些指令是理解程序如何操作数据的关键一步。

082:分支指令 🧭

在本节课中,我们将要学习第三类指令——分支指令。这类指令允许我们改变程序计数器(PC)的值,从而控制程序的执行流程,实现循环、条件判断等功能。

到目前为止,程序计数器在每个指令结束时都简单地增加4,以便下一条指令来自紧接当前指令之后的内存位置。换句话说,Beta处理器一直在按顺序执行内存中的指令。但在许多程序中,例如计算阶乘的程序,我们需要打破这种顺序执行。要么需要跳转回去重复执行之前的指令,要么因为某些条件依赖而跳过一些指令。我们需要一种方法,能够根据程序执行过程中产生的数据值来改变程序计数器。

分支指令概述

上一节我们介绍了顺序执行,本节中我们来看看如何通过分支指令改变执行流程。改变PC值,使其依赖于某些条件,是通过分支指令实现的,这种操作被称为条件分支。当分支被“采纳”时,PC值被改变,执行在新的位置(称为分支目标)重新开始。如果分支未被采纳,PC值增加4,执行在分支指令之后的指令处继续。顾名思义,分支指令代表了执行序列中的一个潜在分叉。我们将使用分支指令来实现许多不同类型的控制结构,如循环、条件语句、过程调用等。

分支指令也使用带有16位有符号常量的指令格式。分支指令的操作有点复杂,让我们逐步分析它们的操作。

BEQ指令详解

让我们从BEQ指令的操作开始。首先,执行常规的PC+4计算,得到BEQ指令之后那条指令的地址。无论分支是否被采纳,这个值都会被写入RC寄存器。分支指令的这个特性非常方便,我们将在后续课程中用它来实现过程调用。注意,如果我们不需要记住PC+4的值,可以将R31指定为RC寄存器。

接下来,BEQ测试RA寄存器的值,看它是否等于0。如果等于0,则采纳分支,PC值增加指令常量字段指定的量。实际上,这个常量被称为偏移量,因为我们用它来偏移PC。它被视为字偏移量,并乘以4以转换为字节偏移量,因为PC使用字节寻址。

如果RA寄存器的内容不等于0,则PC值增加4,执行在BEQ之后的指令处继续。

关于偏移量,我再多说几句。分支指令使用的是所谓的PC相对寻址。这意味着分支目标的地址是相对于分支指令的地址(实际上是相对于分支之后那条指令的地址)来指定的。因此,偏移量为0指的是分支之后的指令,偏移量为-1指的是分支指令本身。

负偏移量被称为向后分支,通常出现在循环末尾测试循环条件的分支中,如果需要再次迭代,则向后跳转到循环开始处。正偏移量被称为向前分支,通常出现在if语句的代码中,如果条件不成立,我们可能会跳过程序的某一部分。

我们可以使用BEQ来实现所谓的无条件分支,即总是被采纳的分支。如果我们测试R31是否等于0,这总是成立的,所以BEQ(R31, ...)总是会分支到指定的目标。

BNE指令与其他条件

还有一个BNE指令,其操作与BEQ相同,只是条件判断的逻辑相反。当寄存器RA的值不等于0时,采纳分支。

可能看起来只测试等于0或不等于0并不能满足所有需求。例如,我们如何判断A是否小于B?这就是比较指令的用武之地。它们执行更复杂的比较,如果比较结果为真则产生一个非零值,如果为假则产生0值。然后,我们可以使用BEQ和BNE来测试比较结果并进行相应的分支。

以下是BEQ和BNE指令的核心操作逻辑:

BEQ(RA, offset, RC) 操作逻辑:

if (RA == 0) {
    PC = PC + 4 + (offset * 4);
} else {
    PC = PC + 4;
}
RC = PC + 4; // 无论分支是否采纳,都存储下一条指令地址

BNE(RA, offset, RC) 操作逻辑:

if (RA != 0) {
    PC = PC + 4 + (offset * 4);
} else {
    PC = PC + 4;
}
RC = PC + 4;

阶乘计算示例

终于,我们现在可以编写Beta汇编代码,使用左侧C代码所示的迭代算法来计算阶乘了。

在Beta代码中,循环从第二条指令开始,并用标签L:标记。循环体包括所需的乘法运算和B的递减。然后在第四条指令中,测试B的值。如果B不等于0,BNE指令将分支回标签为L的指令。

请注意,在我们用于BEQ和BNE指令的符号表示法中,我们不直接写入偏移量,因为手动计算会很麻烦,并且如果我们在循环中添加或删除指令,偏移量也会改变。相反,我们引用我们希望分支到的指令,而将符号代码转换为二进制指令字段的程序会为我们计算偏移量。

Beta代码与我们之前在本讲座中讨论的简单可编程数据路径中为计算阶乘创建的高级有限状态机(FSM)所指定的操作之间,存在着令人满意的相似性。在这个例子中,高级FSM中的每个状态都与一个特定的Beta指令很好地对应起来。我们通常不会期望有这么高的对应度,但由于我们的Beta数据路径和示例数据路径非常相似,所以状态和指令匹配得相当好。

以下是计算阶乘的Beta汇编代码示例:

// 假设初始值:R1 = n (要计算阶乘的数), R2 = 1 (结果累加器)
    CMOVE(1, R2)        // 初始化结果为1
L:  BEQ(R1, done, R31)  // 如果 R1 == 0,跳转到‘done’
    MUL(R2, R1, R2)     // R2 = R2 * R1
    SUB(R1, 1, R1)      // R1 = R1 - 1
    BNE(R1, L, R31)     // 如果 R1 != 0,跳转回‘L’
done:                   // 循环结束,R2中为n的阶乘结果

总结

本节课中我们一起学习了分支指令,这是控制程序执行流程的关键。我们了解了条件分支(BEQ, BNE)如何通过测试寄存器值来决定是否跳转,掌握了PC相对寻址和偏移量的概念,并通过阶乘计算的例子看到了如何使用分支指令来实现循环。分支指令使我们能够构建复杂的控制结构,是编程中实现条件逻辑和循环的基础。

083:9.2.9 跳转指令

在本节课中,我们将要学习Beta指令集中的最后一种指令类型——跳转指令。我们将了解它如何与分支指令配合,共同实现程序中的过程调用与返回机制。

上一节我们介绍了条件分支指令,它能够根据条件改变程序的执行顺序。本节中我们来看看无条件跳转指令,它提供了计算目标指令地址的能力。

跳转指令的功能是简单地将程序计数器设置为某个寄存器中的值。与分支指令类似,跳转指令也会将PC+4的值写入指定的目标寄存器。

以下是跳转指令的格式:

JMP(Rc)

其操作可以描述为:

PC <- R[c]
R[31] <- PC + 4

这种能力对于在Beta代码中实现过程调用非常有用。假设我们有一个计算参数平方根的过程sqrt。我们不展示sqrt的具体代码,只关注其最后一条指令——一条跳转指令。在调用方程序中,程序员可能从两个不同的地方调用这个sqrt过程。

让我们观察一下执行过程。第一次调用sqrt过程是通过主存中地址X100处的一条无条件分支指令实现的。该分支的目标地址是sqrt过程的第一条指令,因此执行流会跳转到那里继续执行。

BEQ指令同时会将下一条指令的地址(Hex 104)写入其目标寄存器R28

当我们执行到过程调用的末尾时,跳转指令会将R28中的值(即Hex104)加载到PC中。这样,执行就会在第一次BEQ指令之后继续。我们成功地从过程返回,并恢复了主程序中断点的执行。

当我们第二次调用sqrt过程时,事件序列与之前相同,只是这次R28中保存的是Hex67C,即第二条BEQ指令之后那条指令的地址。

因此,当我们第二次到达sqrt过程的末尾时,跳转指令会将PC设置为67C,执行将在第二次过程调用之后恢复。BEQJMP指令协同工作,完美地实现了过程的调用与返回。我们将在后续课程中详细讨论过程的实现细节。

至此,Beta指令集架构的设计就介绍完毕了。我们来做一个总结。

  • Beta拥有32个寄存器,用于保存可作为ALU操作数的值。
  • 所有其他值,连同程序本身的二进制表示,都存储在主存中。
  • Beta支持32位内存地址,可以访问2^32(即4GB)的主存空间。
  • 所有的Beta内存访问都针对32位字,因此所有地址必须是4的倍数(因为每个字有4个字节)。
  • 指令有两种格式:第一种指定操作码、两个源寄存器和一个目标寄存器;第二种格式用指令本身存储的16位常量经符号扩展得到的32位常量,替换了第二个源寄存器。
  • 指令分为三类:ALU运算指令、用于访问主存的加载/存储指令,以及改变执行顺序的分支与跳转指令。

以上就是全部内容。正如我们将在下一讲中看到的,我们将能够利用这套相对简单的指令集,构建出一个可以执行任何我们所能指定的计算的系统。

本节课中我们一起学习了跳转指令的格式与功能,并了解了它如何与分支指令配合实现过程调用。我们还回顾并总结了整个Beta指令集架构的核心特性。

084:6.2.10 可编程架构实例分析 🧮

在本节课中,我们将学习如何为一个简单的数据通路设计控制逻辑,以执行一个特定的计算任务。我们将通过一个具体的例子,即计算函数 3 * n - 2 并将结果存入寄存器 R3,来理解控制有限状态机(FSM)的工作原理。

数据通路概述

我们使用一个简单的数据通路,它包含以下核心组件:

  • 寄存器文件:一个包含四个寄存器(R0, R1, R2, R3)的存储单元。
  • 算术逻辑单元(ALU):能够执行加法、减法、乘法和NAND操作。它还能比较两个输入是否相等,并输出一个标志位 Z
  • 控制信号:由控制FSM生成,用于协调数据通路中各个部件的操作。

以下是数据通路的主要控制信号及其功能:

  • A_selB_sel:这两个信号用于选择哪个寄存器的值作为ALU的A输入和B输入。
    • A_sel 选中的寄存器值通过红色线路送至ALU。
    • B_sel 选中的寄存器值通过蓝色线路送至ALU。
  • op_sel:这个信号通过一个多路选择器,决定ALU执行四种运算(加、减、乘、NAND)中的哪一种,并将结果通过紫色线路送回寄存器文件。
  • W_selW_en:这两个信号控制运算结果是否写回寄存器文件,以及写入哪个寄存器。
    • W_en 是写使能信号。如果设为 0,则无论 W_sel 为何值,都不会写入任何寄存器。
    • W_sel 指定结果应写入四个寄存器中的哪一个。

控制FSM(图中黄色方框)根据要执行的操作序列,生成上述所有控制信号。

问题设定与初始状态

假设四个寄存器的初始值为:

  • R0 = 1
  • R1 = 0
  • R2 = -1
  • R3 = n

我们的目标是计算函数 3 * n - 2,并将最终结果存入 R3

我们获得了一段不完整的代码清单,它描述了实现目标所需的操作序列。每个状态(S0S5)对应一条指令。

S0: ADD R2, R2, Rx -> R2
S1: ADD R1, R0, Ry -> R1
S2: ADD R1, R0, R1 -> R1
S3: MUL R3, Rz, R3 -> R3
S4: ADD R3, R3, R2 -> R3
S5: HALT

我们的第一个任务是确定代码中 Rx, Ry, Rz 的值,使程序按预期运行。

步骤一:解析程序逻辑

上一节我们介绍了数据通路和问题目标,本节我们来分析每一条指令,以确定未知的寄存器选择。

以下是每条指令的分析:

状态 S0ADD R2, R2, Rx -> R2

  • 目标:通过将 R2(当前值为 -1)与另一个寄存器相加,使 R2 最终值为 -2
  • 分析:-1 + (-1) = -2。因此,Rx 必须是 R2 本身。
  • 结论:Rx = R2

状态 S1ADD R1, R0, Ry -> R1

  • 目标:通过将 R0(当前值为 1)与另一个寄存器相加,使 R1 最终值为 2
  • 分析:1 + 1 = 2。因此,Ry 必须是 R0 本身。
  • 结论:Ry = R0

状态 S2ADD R1, R0, R1 -> R1

  • 目标:将 R0(值为 1)与 R1(上一步后值为 2)相加,结果存回 R1
  • 执行:1 + 2 = 3。执行后 R1 = 3

状态 S3MUL R3, Rz, R3 -> R3

  • 目标:计算 3 * n 并存入 R3。已知 R3 初始值为 n
  • 分析:需要将 R3 乘以 3R1 当前值为 3
  • 结论:Rz = R1

状态 S4ADD R3, R3, R2 -> R3

  • 目标:计算 3 * n - 2。此时 R3 已为 3 * nR2-2
  • 执行:(3 * n) + (-2) = 3 * n - 2。结果存回 R3

状态 S5HALT

  • 执行停机指令,表示程序结束。

至此,我们得到了完整的、可执行的程序代码。

步骤二:设计控制FSM真值表

现在我们已经有了可工作的程序代码,下一个目标是确定控制FSM的设置,使其能为我们的数据通路生成正确的控制信号。

由于我们有6个状态(S0S5),需要用3位二进制数来编码当前状态和下一个状态。

我们开始为每个状态填充控制信号的值。控制信号宽度如下:

  • A_sel, B_sel, W_sel:各2位(用于选择4个寄存器之一)。
  • op_sel:2位(用于选择4种运算之一)。为简化,我们用运算名(如 add, mul)表示其编码。
  • W_en:1位(写使能)。
  • Z:比较结果,本例中不关心,记为 X(无关项)。
  • statenext_state:各3位。

以下是每个状态的控制信号设置:

状态 S0 (当前状态: 000)

  • next_state = 001 (指向S1)
  • A_sel = 10 (选择 R2)
  • B_sel = 10 (选择 R2)
  • op_sel = add
  • W_sel = 10 (写入 R2)
  • W_en = 1 (允许写入)
  • Z = X

状态 S1 (当前状态: 001)

  • next_state = 010 (指向S2)
  • A_sel = 00 (选择 R0)
  • B_sel = 00 (选择 R0)
  • op_sel = add
  • W_sel = 01 (写入 R1)
  • W_en = 1
  • Z = X

状态 S2 (当前状态: 010)

  • next_state = 011 (指向S3)
  • A_sel = 00 (选择 R0)
  • B_sel = 01 (选择 R1)
  • op_sel = add
  • W_sel = 01 (写入 R1)
  • W_en = 1
  • Z = X

状态 S3 (当前状态: 011)

  • next_state = 100 (指向S4)
  • A_sel = 01 (选择 R1)
  • B_sel = 11 (选择 R3)
  • op_sel = mul
  • W_sel = 11 (写入 R3)
  • W_en = 1
  • Z = X

状态 S4 (当前状态: 100)

  • next_state = 101 (指向S5)
  • A_sel = 11 (选择 R3)
  • B_sel = 10 (选择 R2)
  • op_sel = add
  • W_sel = 11 (写入 R3)
  • W_en = 1
  • Z = X

状态 S5 (当前状态: 101) - 停机状态

  • next_state = 101 (停留在S5)
  • 此时,我们不再关心数据通路的操作,因此 A_sel, B_sel, op_sel, W_sel 均可设为无关项 X
  • 但是,必须确保不会将任何结果写回寄存器。因此,W_en 必须设为 0
  • Z = X

完整的控制ROM

综合以上分析,我们得到了执行函数 3 * n - 2 并将结果存入 R3 所需的完整控制ROM(只读存储器)内容。这张表定义了控制FSM在每个状态下应输出的所有信号。

当前状态 (s2:0) 下一状态 (s’2:0) Z A_sel B_sel op_sel W_sel W_en
000 001 X 10 10 add 10 1
001 010 X 00 00 add 01 1
010 011 X 00 01 add 01 1
011 100 X 01 11 mul 11 1
100 101 X 11 10 add 11 1
101 101 X X X X X 0

总结

本节课中,我们一起学习了如何为给定的数据通路和计算任务设计控制逻辑。我们首先分析了程序指令,确定了正确的数据流向。然后,我们为控制有限状态机(FSM)的每一个状态详细设定了所有控制信号的值,包括寄存器选择、运算选择、写使能以及状态转换。最终,我们得到了一张完整的控制ROM表,它能够指导数据通路正确地计算 3 * n - 2 并将结果存储到指定位置。这个过程清晰地展示了软件指令如何通过硬件的控制信号得以执行。

085:10.2.1 汇编语言简介 🧠

在本节课中,我们将要学习如何为 Beta 处理器编写程序。我们将从指令集架构的抽象层面,过渡到使用汇编语言这一更易于人类理解和编写的符号化编程方式。

回顾 Beta 指令集架构

上一节我们介绍了本课程将构建的计算机系统——Beta 的指令集架构。

Beta 包含两种类型的存储或内存。在 CPU 数据通路中,有 32 个通用寄存器,它们可以被读取以向 ALU 提供源操作数,或者被写入 ALU 的计算结果。在 CPU 的控制逻辑中,有一个称为程序计数器的专用寄存器,它包含内存中下一条待执行指令的地址。

数据通路和控制逻辑连接到一个容量最大为 2³² 字节(组织为 2³⁰ 个 32 位字)的大型主存。该内存同时存储数据和指令。

Beta 指令是 32 位的值,由多个字段组成。6 位的操作码字段指定要执行的操作。5 位的 RA、RB 和 RC 字段包含寄存器编号,用于指定 32 个通用寄存器中的一个。

指令有两种格式:一种指定操作码和三个寄存器;另一种指定操作码、两个寄存器和一个 16 位有符号常量。

指令分为三类:

  • ALU 指令:对两个操作数执行算术或逻辑运算,并将结果存储在目标寄存器中。操作数可以是两个来自通用寄存器的值,或者一个寄存器值和一个常量。黄色高亮表示使用第二种指令格式的指令。
  • 加载/存储指令:访问主存,将值从主存加载到寄存器,或将寄存器值存储到主存。
  • 分支和跳转指令:其执行可能会改变程序计数器,从而改变下一条要执行指令的地址。

从机器码到汇编语言

为了对 Beta 进行编程,我们需要将二进制指令加载到主存中。显然,确定每条指令的编码是计算机的工作。

因此,我们将创建一种简单的编程语言,让我们能够为每条指令指定操作码和操作数。这样,我们就不必像幻灯片顶部那样编写二进制代码,而是可以编写汇编语言语句,以符号形式指定指令。

当然,我们仍然需要考虑为哪些值使用哪些寄存器,并为更复杂的操作编写指令序列。通过使用高级语言,我们可以再提升一个抽象层次,用变量和数学运算来描述我们想要的计算,而不是用寄存器和 ALU 函数。

在本节中,我们将描述用于 Beta 编程的汇编语言。在下一节中,我们将探讨如何将 C 语言等高级语言翻译成汇编语言。

抽象层次可以继续堆叠。我们可以在 C 语言中编写一个 Python 解释器,然后用 Python 编写应用程序。如今,程序员通常会选择最适合表达其计算的编程语言,然后经过可能多层的翻译,最终生成 Beta 实际可以执行的指令序列。

汇编语言与汇编器

回到汇编语言,我们将用它来屏蔽指令的比特级表示,也无需知道变量和指令在内存中的确切位置。

一个称为汇编器的程序会读取包含汇编语言程序的文本文件,并生成一个 32 位字的数组,该数组可用于初始化主存。

我们将学习 UASM 汇编语言,它内置于 Bim(我们的 Beta 指令集架构模拟器)中。UASM 本质上是一个高级计算器。它读取算术表达式并求值以产生 8 位值,然后按顺序将这些值添加到字节数组中,该数组最终将被加载到 Beta 的内存中。

UASM 支持几种有用的语言特性,使编写汇编语言程序更容易:

  • 符号和标签:让我们可以为特定的值和地址命名。
  • :让我们可以为表达式序列创建简写符号,当求值时,这些表达式将生成指令和数据的二进制表示。

UASM 源文件示例

以下是一个 UASM 源文件示例。通常,我们在每一行写一条 UASM 语句,并使用空格、制表符和换行符使源代码尽可能易读。我们添加了一些颜色编码以帮助解释。

注释(绿色显示)允许我们向程序添加文本注解。好的注释有助于提醒你程序的工作原理。你肯定不希望每次需要修改或调试代码时,都从头开始理解一段代码的功能。

有两种添加注释的方法:

  • // 开始一个注释,该注释占据源行的其余部分。汇编器会忽略 // 之后的任何字符,并在源文件下一行的开头重新开始处理语句。
  • 你也可以使用分隔符 /**/ 来包围注释文本,汇编器将忽略其中的所有内容。使用第二种注释,你可以在代码开头放置 /*,在多行之后用 */ 结束注释部分,从而注释掉多行代码。

符号(红色显示)是常量值的符号名称。符号使代码更易于理解。例如,我们可以用 N 作为某个计算的初始值的名称。在本例中,值为 12。后续语句可以使用符号 N 来引用这个值,而不是直接输入值 12。阅读程序时,我们会知道 N 代表这个特定的初始值。因此,如果以后我们想更改初始值,只需更改符号 N 的定义,而不是查找并更改程序中所有的 12。实际上,其他出现的 12 可能并不指代这个初始值,因此为了确保只更改那些确实指代初始值的 12,我们必须阅读并理解整个程序。你可以想象这有多容易出错。因此,使用符号是一个你应该遵循的好习惯。

请注意,所有寄存器名称都以红色显示。我们将定义符号 R0R31 的值为 0 到 31。然后,我们将使用这些符号来帮助我们理解哪些指令操作数意图是寄存器(例如,通过写 R1),哪些是数值(例如,通过写数字 1)。我们本可以到处都使用数字,但代码将更难阅读和理解。

标签(黄色显示)是符号,其值是程序中特定位置的地址。这里,标签 loop 将是内存中 MUL 指令位置的名字。在代码末尾的 BEQ 指令中,我们使用标签 loop 来指定 MUL 指令作为分支目标。因此,如果 R1 不为零,我们希望分支回 MUL 指令并开始下一次迭代。

对于大多数 UASM 语句,我们使用缩进,以便于发现程序定义的标签。缩进不是必需的,这只是汇编语言程序员用来保持程序可读性的另一个习惯。

当我们想要编写 Beta 指令时,我们使用宏调用(蓝色显示)。当汇编器遇到宏时,它会展开宏,用宏定义中提供的文本字符串替换它。在展开过程中,提供的参数会以文本形式插入到宏定义中指定的位置的展开文本中。可以把宏看作是我们本可以输入的一个更长文本字符串的简写。

我们将在下一个视频片段中展示这一切是如何工作的。

总结

本节课中我们一起学习了汇编语言的基础知识。我们了解到,汇编语言是一种符号化的低级编程语言,它屏蔽了机器码的二进制细节,使我们能够使用助记符、符号和标签来编写程序。通过 UASM 汇编器的示例,我们看到了注释、符号、标签和宏如何共同作用,使汇编语言程序更易于编写、阅读和维护。这为我们接下来学习如何将高级语言翻译成汇编语言奠定了重要基础。

086:符号与标签 🏷️

在本节课中,我们将学习汇编器如何处理源文件,重点关注符号和标签的定义与使用。我们将了解汇编器如何维护符号表,以及如何通过两遍扫描来解析和生成最终的机器码。


汇编器的工作流程 🔄

汇编器逐行读取源文件,定义符号和标签,展开宏或计算表达式,以生成输出数组中的字节。每当汇编器遇到符号或标签的使用时,它会用符号表中找到的对应数值进行替换。

符号表的初始化

汇编器维护一个符号表,用于将符号名称映射到其数值。最初,符号表会加载所有寄存器符号的映射关系。

处理过程示例

第一行 n = 12 定义了符号 n 的值为12,因此在符号表中创建了相应的条目。

前进到下一行,汇编器遇到了带有参数 R31nR1addC 宏调用。正如我们将在后续幻灯片中看到的,这会触发一系列嵌套的宏展开,最终生成一个要放置在内存位置0的32位值。

这个32位值在这里被格式化以显示指令字段,目标地址显示在方括号中。

下一条指令以相同的方式处理,生成第二个32位字。

在第四行,标签 loop 被定义为即将被填充的内存位置的值,在本例中是位置8。因此,在符号表中创建了适当的条目,并且 Ma 宏被展开为要放置在位置8的32位字。

汇编器逐行处理文件,直到文件结束。

两遍扫描

实际上,汇编器会对文件进行两遍扫描。在第一遍中,它用所有符号和标签定义的值加载符号表。然后在第二遍中,它生成二进制输出。

这种两遍扫描的方法允许语句引用文件中稍后定义的符号或标签。例如,一个前向分支指令可以引用程序中稍后指令的标签。


符号的本质与潜在陷阱 ⚠️

正如我们在上一节看到的,寄存器符号并没有什么神奇之处。它们只是数值0到31的符号名称。

因此,在处理 addC R31 n R1 时,UASM 会用它们的值替换符号,实际上展开为 addC 31 12 1

UASM 非常简单。它只是用值替换符号、展开宏和计算表达式。因此,如果您在需要数值的地方使用了寄存器符号,则该符号的值将被用作数值常量。这可能不是程序员的本意。

类似地,如果您在需要寄存器编号的地方使用了符号或表达式,则该值的低5位将被用作寄存器编号。在这个例子中,作为 RB 寄存器编号,同样可能不是程序员的本意。

这个故事的寓意是:在编写 UASM 汇编程序时,您必须保持清醒,认识到操作数的解释是由操作码宏决定的,而不是由您编写操作数的方式决定的。


分支指令与偏移量计算 🧮

回顾第9讲,分支指令使用指令的16位常量字段,将分支目标的地址编码为从分支指令位置开始的字偏移量。

实际上,偏移量是从分支指令之后的那条指令开始计算的,因此偏移量-1将指向分支指令本身。

手动计算偏移量有点繁琐,并且如果我们在分支指令和分支目标之间添加或删除指令,偏移量当然会改变。

幸运的是,分支指令的宏包含了必要的公式,用于根据分支地址和分支目标地址计算偏移量。

因此,我们只需指定分支目标的地址(通常使用标签),并让 UASM 完成繁重的计算工作。

在这里,我们看到 BE 分支向后跳转了三条指令。

记住要从分支指令之后的指令开始计数。 所以偏移量是-3。

-3的16位二进制补码表示形式就是放置在 bieni 指令常量字段中的值。


总结 📝

在本节课中,我们一起学习了汇编器如何处理符号和标签。我们了解了符号表的初始化和两遍扫描的工作流程,认识到符号本质上是数值的别名。我们还探讨了在编写汇编代码时需要注意的潜在陷阱,即操作数的解释权在于操作码宏。最后,我们学习了分支指令中偏移量的自动计算机制,这极大地简化了汇编程序的编写。掌握这些概念对于理解汇编语言和计算机底层架构至关重要。

087:宏指令详解 🧩

在本节课中,我们将要学习宏在UASM汇编器中的工作原理,以及它们如何被用来简化Beta ISA指令的编写。我们将从宏的基本定义和展开过程开始,逐步深入到指令格式的组装、字节序的概念,以及如何通过宏创建更易读的伪指令。

宏的工作原理

上一节我们介绍了汇编器的基本概念,本节中我们来看看宏是如何工作的。宏允许我们定义可重用的代码模板。

让我们仔细看看宏在UASM中是如何工作的。这里我们看到宏cons的定义,它有一个参数N。宏的主体是四个表达式序列。

当在这个例子中调用cons宏,并传入参数37时,宏的主体被展开,所有出现的N都被替换为参数37。生成的文本随后被处理,就像它原本就出现在宏调用的位置一样。

在这个例子中,四个表达式被求值,产生一个由四个值组成的序列,这些值将被放入输出数组接下来的四个字节中。

; 宏定义示例
cons(N) {
    N & 0xFF
    (N >> 8) & 0xFF
    (N >> 16) & 0xFF
    (N >> 24) & 0xFF
}

宏展开可能包含其他宏调用,这些宏调用自身也会被展开,这个过程持续进行,直到只剩下需要求值的表达式。

多字节值与字节序

了解了宏的基本展开后,我们来看看如何处理多字节数据,这涉及到字节序的概念。

这里我们看到宏word的定义,它将参数汇编成两个连续的字节。以及宏long的定义,它使用word宏来处理值的低16位和高16位,从而将参数汇编成四个连续的字节。

这两个UASM语句导致常量0xDEADBEEF被转换为4个字节,然后从索引0x100开始存入输出数组。

需要注意的是,Beta架构期望多字节值的最低有效字节存储在最低的字节地址。因此,最低有效字节0xEF被放置在地址0x100,而最高有效字节0xDE被放置在地址0x103

这是多字节值的小端序约定。最低有效字节在前。英特尔的X86架构也是小端序。存在一个对称的大端序约定,最高有效字节在前。

两种约定都在积极使用,事实上,一些ISA可以被配置为使用任何一种约定。使用哪种约定没有标准答案,但存在两种约定的事实意味着,当在不同ISA之间移动多字节值时,我们必须警惕需要转换其表示形式。例如,当我们向另一个用户发送数据文件时。

可以想象,两种方案都有强烈的支持者,他们乐于长篇大论地捍卫自己的观点。鉴于讨论的热烈程度,这两种约定的名称取自乔纳森·斯威夫特的《格列佛游记》是恰当的,书中一场内战就是围绕从鸡蛋的大头还是小头打开一个溏心蛋而展开的。

Beta指令的汇编

现在,让我们将注意力转向如何用宏来组装具体的机器指令。

让我们看看用于汇编Beta指令的宏。Beta_Op_Helper宏支持三寄存器指令格式,其参数是要放入操作码、Ra、Rb和Rc字段的值。

.align 4指令是一点管理性的簿记工作,用于确保指令的字节地址是4的倍数。换句话说,确保它们在内存中恰好占据一个32位字。其后跟着一个long宏的调用,以生成表示此处所示表达式值的四个字节的二进制数据。

表达式是实际组装字段发生的地方。每个字段使用模运算符限制在所需的位数内,然后左移到32位字中的正确位置。

; Beta_Op_Helper 宏示例
Beta_Op_Helper(op, ra, rb, rc) {
    .align 4
    long( (op << 26) | (ra << 21) | (rb << 16) | (rc) )
}

以下是使用16位常量作为第二个操作数的指令的辅助宏。

让我们跟踪一条ADDC指令的汇编过程,看看这是如何工作的。ADDC宏展开为对Beta_Op_C_Helper宏的调用,传递正确的ADDC操作码值以及三个操作数。

Beta_Op_C_Helper宏执行以下算术运算:操作码参数(在本例中为值0x30)左移以占据指令的高6位。然后,Ra参数(在本例中为15)被放置在其正确位置。16位常量-32768被定位在指令的低16位中,最后,Rc参数(在本例中为0)被定位在指令的Rc字段中。

你可以明白为什么我们称这个过程为“组装”一条指令。一条指令的二进制表示是由每个指令字段的二进制值组装而成的。这不是一个复杂的过程,但它需要大量的移位和掩码操作,我们很乐意让计算机来处理。

这是将这条ADDC指令汇编成主存中适当的32位二进制值的整个宏展开序列。你可以看到,关于Beta指令格式和操作码值的知识被内置在宏定义的主体中。UASM处理实际上非常通用,使用不同的宏定义集,它可以处理几乎任何ISA的汇编语言程序。

便利宏与伪指令

最后,我们将学习如何利用宏来创建更简洁、更易读的伪指令,从而提升编程体验。

所有针对Beta ISA的宏定义都在beta.uasm文件中提供,该文件包含在每个汇编语言实验作业中。请注意,我们包含了一些便利宏来定义速记表示法,为某些操作数提供常见的默认值。

例如,除了过程调用,我们并不关心分支指令保存在目标寄存器中的PC+4值。因此,几乎总是,我们会指定R31作为Rc寄存器,从而有效地丢弃分支保存的PC+4值。所以我们定义了两个参数的分支宏,自动提供R31作为目标寄存器。这节省了一些输入,更重要的是,它使得汇编语言程序更容易理解。

以下是一整套旨在使程序更具可读性的便利宏。例如,无条件分支可以使用BR宏来编写,而不是更繁琐的BEQ(R31, ...)

当测试比较指令的结果时,使用BF(分支假)和BT(分支真)宏更具可读性。

请注意页面底部的PUSHPOP宏。它们展开为多指令序列,在本例中,用于从由SP寄存器指向的栈数据结构中添加和移除值。

我们称这些宏为“伪指令”,因为它们让程序员看起来拥有一个更大的指令集,尽管在底层,我们只是使用了在第九讲中开发的相同的小型指令集。

在这个例子中,我们使用伪指令重写了最初用于阶乘计算的代码。例如,CMOVE是一个将小常量移入寄存器的伪指令。对我们来说,阅读和理解常量移动操作的意图,比通过CMOVE展开的ADDC提供的“将值与0相加”的操作更容易。任何我们能做的、以减少认知混乱的事情,从长远来看都将非常有益。


本节课中我们一起学习了UASM宏的定义、展开和工作原理,理解了小端序和大端序在多字节数据存储中的区别,掌握了Beta指令如何通过宏被组装成二进制机器码,并认识了通过宏定义伪指令来简化编程、提升代码可读性的方法。宏是连接高级汇编语言表达与底层机器指令的有力工具。

088:6.004 2017 汇编总结 🧩

在本节课中,我们将要学习汇编语言中关于数据处理的最后一部分内容。我们已经讨论了如何汇编指令,本节将重点探讨如何分配和初始化数据存储,以及如何将这些数据值加载到寄存器中供程序使用。

数据分配与初始化 📊

上一节我们介绍了指令的汇编,本节中我们来看看如何处理数据。程序需要存储和初始化数据,并能够访问这些数据。

以下是一个使用 .long 宏来分配和初始化两个内存位置的程序示例。我们使用标签来记录这些位置的地址,以便后续引用。

N:      .long 5
fact:   .long 1

当程序被汇编时,标签 Nfact 的值(即它们所代表的内存地址)分别为 0 和 4。这是存放两个数据值的内存位置的地址。

访问数据值 🔄

为了访问第一个数据值,程序使用了一条加载指令。在这个例子中,使用了一个便利宏,它默认将 R31 作为 RA 字段的值。

LD(R1, N)

汇编器会用符号表中标签 N 的值(即 0)来替换对标签 N 的引用。当加载指令执行时,它会将常量 0 与 RA 寄存器(即 R31,其值为 0)的值相加,计算出内存地址 0,然后从这个地址获取值并放入 R1 中。

汇编器表达式计算 🧮

数据字和指令字段所需的值可以写成表达式。这些表达式由汇编器在汇编程序时进行计算,并使用其结果值。

核心概念:表达式是在汇编器运行时(即程序在 Beta 计算机上运行之前)被计算的。汇编器不会生成 ADDMUL 指令来在程序执行期间计算这些值。如果指令字段或初始数据需要一个值,汇编器必须能够自己执行算术运算。如果你需要程序在运行时计算一个值,则必须在程序中编写必要的指令。

特殊符号:点(.)📍

UASM 汇编器有一个特殊的符号,称为点(.)。它的值是汇编器在生成二进制数据时,下一个将要填充的主内存位置的地址。

  • 初始时,点(.)的值为 0。
  • 每次生成一个新的字节值时,点(.)的值就会递增。

我们可以设置点(.)的值,以告诉汇编器我们希望将值放置在内存中的哪个位置。

.org 0x100
    .long 0xdeadbeef

在这个例子中,常量 0xdeadbeef 被放置到了主内存的 0x100 位置。

我们也可以在表达式中使用点(.)来计算其他符号的值,如下所示,当定义符号 K 的值时:

K: .long 27

实际上,标签定义 K: 完全等同于 UASM 语句 K = .

我们甚至可以递增点(.)的值来跳过某些位置。例如,如果我们想为一个未初始化的数组预留空间:

array: . = . + 100*4

汇编语言总结 📝

以上就是汇编语言。我们使用汇编语言作为一种方便的符号,来生成指令和数据的二进制编码。我们让汇编器来构建我们所需的比特级表示,并跟踪这些值在主内存中的存储地址。

UASM 本身提供了对符号标签的支持。

  • 可以写成常量或涉及常量的表达式。
  • 我们使用符号为值赋予有意义的名称,使我们的程序更具可读性且更易于修改。
  • 类似地,我们使用标签为主内存中的地址赋予有意义的名称,然后在加载/存储指令中引用数据位置,或在分支指令中引用指令位置。
  • 隐藏了指令从其组成字段汇编而来的细节。
  • 我们可以使用点(. 来控制汇编器将值放置在主内存的何处。

一个有趣的问题:先有鸡还是先有蛋? 🥚

汇编器本身是一个在我们的计算机上运行的程序。这就引出了一个有趣的“先有鸡还是先有蛋”的问题:第一个汇编器程序是如何被汇编成二进制代码,从而能在计算机上运行的呢?

答案是,它是手工汇编成二进制的。我猜想它最初处理的是一种非常简单的语言,只有在程序能够汇编基本指令之后,才逐渐添加了符号、标签、宏、表达式计算等高级功能。而且我确信他们当时非常小心,没有丢失那个二进制文件,这样他们就不必进行第二次手工汇编了。


本节课中我们一起学习了汇编语言中数据处理的完整流程,包括数据的分配、初始化、访问,以及汇编器如何通过表达式计算和特殊符号来简化这些工作。我们还了解了汇编语言作为机器码与程序员之间桥梁的核心作用,并探讨了汇编器自身是如何诞生的。

089:计算模型 🧠

在本节课中,我们将探讨计算机架构中的一个核心问题:指令集架构(ISA)需要包含哪些能力才能实现通用计算?我们将从布尔逻辑的通用性出发,分析有限状态机(FSM)的计算局限性,并最终引入图灵机作为通用计算模型,理解其如何为现代计算机的设计奠定理论基础。

计算模型的通用性问题

计算机架构师面临一个有趣的问题:指令集架构(ISA)必须包含哪些能力?

在第一部分课程中学习布尔门时,我们证明了与非门(Nand gate)是通用的。换句话说,我们可以仅使用由与非门构成的电路来实现任何布尔函数。

我们可以对ISA提出相应的问题:它是否通用?换句话说,它能被用来执行任何计算吗?

有限状态机(FSM)的能力与局限

我们可以用β计算机解决哪些问题?它能解决FSM能解决的所有问题吗?是否存在FSM无法解决的问题?如果能,β计算机能解决那些问题吗?这些问题的答案是否取决于特定的ISA?

为了提供答案,我们需要一个计算数学模型。通过对模型进行推理,我们应该能够证明什么可以计算,什么不能计算。我们希望确保β ISA具备执行任何计算所需的功能。

计算机科学的根源在于评估许多替代的计算数学模型,以确定每个模型所能代表的计算类别。一个难以捉摸的目标是找到一个能够代表所有可实现计算的通用模型。换句话说,如果某个计算可以用其他定义良好的模型来描述,我们也应该能够使用这个通用模型来描述相同的计算。

一个候选模型可能是有限状态机(FSM),它可以使用时序逻辑构建。利用布尔逻辑和状态转移图,我们可以推理FSM在任何给定输入下的操作,以100%的确定性预测输出。

FSM是通用的数字计算设备吗? 换句话说,我们能否设计出FSM实现,来执行任何数字设备都能解决的所有计算?

尽管FSM有用且灵活,但存在一些常见问题是任何FSM都无法解决的。

例如,我们能否构建一个FSM来判断一串已编码为二进制序列的括号是否格式正确?一个括号串是格式正确的,如果括号是平衡的。换句话说,对于每一个开括号,在字符串后面都有一个匹配的闭括号。

在所示的例子中,顶部的输入字符串格式正确,但底部的输入字符串则不正确。处理完输入字符串后,如果字符串格式正确,FSM将输出1;否则输出0。

这个问题能用FSM解决吗? 不能。困难在于FSM使用其内部状态来编码它对输入历史的了解。对于括号问题,FSM需要计算到目前为止看到的未匹配开括号的数量,以便判断未来的输入是否包含所需数量的闭括号。但在有限状态机中,只有固定数量的状态,因此特定的FSM有一个它能计数的最大值。如果我们给FSM的输入包含的开括号数量超过了它用于计数的状态数,它将无法检查输入字符串是否格式正确。

FSM的有限性限制了它们解决需要无限计数的问题的能力。

图灵机:迈向通用计算

我们还可以考虑哪些其他计算模型?在这种情况下,数学家们提供了帮助,以英国数学家艾伦·图灵为代表。在20世纪30年代初,艾伦·图灵是许多研究证明和计算极限的数学家之一。

他提出了一个概念模型,由一个有限状态机与一个无限的数字磁带组合而成,该磁带可以在FSM的控制下进行读写。某些计算的输入将被编码为磁带上的符号。然后,FSM将读取磁带,在执行计算时改变其状态,接着将答案写入磁带,最后停机。如今,这个模型被称为图灵机

图灵机解决了FSM的有限性问题。

图灵机与整数函数

那么,这一切与计算有何关系?假设磁带上非空的输入占据有限数量的相邻单元,它可以表示为一个大整数。只需使用磁带上符号的比特编码来构造一个二进制数,交替地从磁头左侧的符号和磁头右侧的符号中提取。最终,所有符号都将被纳入这个非常大的整数表示中。

因此,图灵机的输入和输出都可以被视为大整数,而图灵机本身正在实现一个将输入整数映射到输出整数的整数函数

图灵机的FSM“大脑”可以通过其真值表来描述,我们可以系统地枚举所有可能的FSM真值表,为枚举中出现的每个真值表分配一个索引。请注意,索引增长得非常快,因为它们本质上包含了真值表中的所有信息。幸运的是,我们有非常大量的整数供应。

我们将使用图灵机FSM的索引来标识该图灵机,因此我们可以谈论“图灵机347,在输入51上运行,产生答案42”。

总结

本节课中,我们一起学习了计算模型的核心概念。我们从布尔逻辑的通用性出发,探讨了有限状态机(FSM)在计算能力上的根本限制,特别是其无法解决需要无限状态(如括号匹配)的问题。接着,我们引入了艾伦·图灵提出的图灵机模型,它通过结合有限状态机和无限存储磁带,克服了FSM的局限性,为通用计算提供了理论基础。最后,我们了解到图灵机的计算可以抽象为整数函数,并且所有可能的图灵机都可以被系统枚举。这为理解现代计算机指令集架构(ISA)所需的基本能力奠定了重要基础。

090:可计算性与通用性 🧮

在本节课中,我们将学习计算理论中的核心概念:可计算性与通用性。我们将探讨不同的计算模型,理解丘奇-图灵论题,并最终揭示通用图灵机如何为现代存储程序计算机奠定基础。


计算模型与可计算性

上一节我们介绍了图灵机作为计算模型。实际上,存在许多其他的计算模型。每个模型都描述了一类整数函数,这些函数通过对整数输入执行计算来产生整数答案。

在20世纪30年代中期,克林尼、波斯特和图灵都是普林斯顿大学阿隆佐·丘奇的学生。他们探索了许多其他用于建模计算的表述方式,例如递归函数、基于规则的字符串重写系统和λ演算。

他们都对证明“存在可由可实现机器解决”的问题特别感兴趣。这自然意味着需要刻画那些可由可实现机器解决的问题。

结果发现,每个模型都能够计算完全相同的整数函数集合。这是通过构建在不同模型之间转换计算步骤的构造来证明的。可以证明,如果一个计算能被一个模型描述,那么在另一个模型中就存在等价的描述。

这引出了一个独立于所选计算方案的可计算性概念。这个概念由丘奇论题形式化。该论题指出:任何可实现机器可计算的离散函数,都可由某个图灵机计算。

因此,如果我们说函数 F(x) 是可计算的,那就等价于说存在一个图灵机。当给定 x 作为其纸带上的输入时,该图灵机会将 F(x) 作为输出写在纸带上并停机。

目前,丘奇论题还没有被证明,但它被普遍认为是正确的。通常,“可计算”即意味着可由某个图灵机计算。

如果你对不可计算函数的存在性感到好奇,请参阅本讲座末尾的可选视频。


从专用到通用:通用图灵机

我们已经确定图灵机可以模拟任何可实现的计算。换句话说,对于我们想要执行的每个计算,都有一个不同的图灵机来完成这项工作。

但这如何帮助我们设计一台通用计算机呢?还是说,有些计算无论如何都需要专用的机器?

我们想要寻找的是一个通用函数 U。它接受两个参数 KJ,然后计算图灵机 K 在输入 J 上运行的结果。U 是可计算的吗?换句话说,是否存在一个通用图灵机 TU

如果存在,那么我们就不需要许多特定的图灵机,而可以直接使用 TU 来计算任何可计算函数的结果。

令人惊喜的是,U 是可计算的,并且 TU 确实存在。事实上,存在无限多个通用图灵机,有些还相当简单。已知最小的通用图灵机只有四个状态,使用六个纸带符号。

一台通用机器能够执行任何图灵机所能执行的任何计算。这里的原理是什么?

  • K 编码了一个程序,即执行特定计算的某个任意图灵机的描述。
  • J 编码了执行该计算所需的输入数据
  • TU 解释这个程序,模拟 TK 处理输入并写出答案所需的步骤。

解释计算的编码表示这一概念是一个关键思想,并构成了我们存储程序计算机的基础。通用图灵机是现代通用计算机的范式。


指令集架构与通用性

给定一个指令集架构(ISA),我们想知道它是否等价于一台通用图灵机。如果是,它就可以模拟其他任何图灵机,从而计算任何可计算函数。

我们如何证明我们的计算机是图灵通用的呢?只需证明它能模拟某个已知的通用图灵机即可。

实际计算机的有限内存意味着我们只能模拟通用图灵机对一定大小以内的输入的操作。但在此限制内,我们可以证明我们的计算机能够执行任何能装入内存的计算。

事实证明,这个门槛并不高。只要ISA具备条件分支和一些简单的算术运算,它就会是图灵通用的。


程序作为数据:编译与软件工程

将程序编码成可以被其他程序作为数据处理的这一概念,是计算机科学中的一个关键思想。

我们经常将一个用某种抽象高级机器语言(例如C或Java)编写的程序 X翻译成例如汇编语言程序 Y,以便由我们的CPU解释执行。这种翻译被称为编译

软件工程的很大一部分都基于这样一个思想:获取一个程序,并将其用作某个更大程序中的一个组件。有了编译程序的策略,就为设计新的编程语言打开了大门。这些新语言让我们能够使用特别适合手头任务的数据结构和操作来表达我们期望的计算。


总结

本节课中我们一起学习了数学家们在计算模型研究上的成果。

首先,我们了解到不同的计算模型在能力上是等价的,并由丘奇论题统一为图灵可计算性。接着,我们探讨了通用图灵机的概念,它能够通过解释编码的程序来模拟任何其他图灵机,这为现代存储程序计算机铺平了道路。最后,我们认识到,只要一个指令集架构具备基本的分支和算术能力,它就能实现图灵通用性,并且“程序即数据”的思想是编译和软件工程的基础。

总而言之,知道我们计划构建的计算引擎能够执行任何可实现机器所能执行的任何计算,这令人欣慰。基于Beta ISA,我们已经具备了构建通用计算机的理论基础。

091:不可计算函数 🧮

在本节课中,我们将探讨一个计算机科学中的深刻问题:是否存在不可计算的函数?我们将了解什么是不可计算函数,并重点分析最著名的例子——停机问题。

概述

本节视频内容是可选的,但它旨在回答一个根本性问题:是否存在不可计算的函数?答案是肯定的。确实存在一些定义明确的离散函数,它们无法被任何图灵机计算。换句话说,不存在任何算法,能够在有限步骤内为任意有限的输入 x 计算出 F(x) 的值。这并非因为我们不知道算法,而是可以严格证明这样的算法根本不存在。这表明,有限状态机的内存限制并非我们能否解决问题的唯一障碍。

最著名的不可计算函数:停机函数 ⏸️

上一节我们确认了不可计算函数的存在,本节中我们来看看其中最著名的一个例子:停机函数。

当图灵机执行计算时,可能产生两种结果:要么图灵机将答案写入纸带并停机;要么图灵机永远循环下去。停机函数的作用就是告诉我们将会得到哪种结果。

给定两个整数参数 KJ,停机函数判断编号为 K 的图灵机在输入纸带内容为 J 时是否会停机。

为什么停机函数不可计算? 🤔

我们已经了解了停机函数的定义,现在我们来快速勾勒一个论证,说明为什么停机函数是不可计算的。

假设停机函数是可计算的,那么它就等价于某个图灵机,我们称之为 T_H

我们可以利用 T_H 来构建另一个图灵机 T_N(N 代表“棘手的”)。T_N 对单个参数 x 进行预处理,其结果要么循环,要么停机。

T_N(x) 的设计逻辑是:如果图灵机 x 在输入 x 时停机,则 T_N(x) 循环;反之,如果图灵机 x 在输入 x 时循环,则 T_N(x) 停机。其核心思想是,T_N(x) 的行为总是与 T_x(x) 的行为相反。

在假设我们拥有 T_H 来回答“停机还是循环”这个问题的前提下,T_N 是容易实现的。

现在,考虑当我们把 n(即 T_N 自身的编号)作为参数输入给 T_N 时会发生什么。根据 T_N 的定义:

  • 如果停机函数告诉我们 T_N(n) 停机,那么 T_N(n) 将循环。
  • 如果停机函数告诉我们 T_N(n) 循环,那么 T_N(n) 将停机。

显然,T_N(n) 不可能同时既循环又停机。因此,如果停机函数是可计算的且 T_H 存在,我们将推导出 T_N 应用于参数 n 时这种不可能的行为。这告诉我们 T_H 不可能存在,从而证明停机函数是不可计算的。

总结

本节课中我们一起学习了不可计算函数的概念。我们了解到,确实存在一些定义良好的函数(如停机函数),没有任何图灵机或算法能够计算它们。我们通过反证法分析了停机函数不可计算的原因:假设其可计算会导致逻辑矛盾。这个结论揭示了计算理论的一个基本极限,即并非所有数学上定义明确的问题都能通过算法解决。

092:Beta汇编语言实例解析 🧮

在本节课中,我们将通过几个具体的例子,学习如何分析Beta处理器的汇编代码。我们将重点关注指令执行顺序、内存寻址方式以及程序流程控制,以理解代码最终在寄存器中产生的结果。


Beta处理器基础

上一节我们介绍了Beta处理器的基础概念,本节中我们来看看其内存寻址的具体细节。

本课程使用一个名为Beta的32位处理器。Beta处理器处理32位的指令和数据字。然而,内存地址是以字节为单位指定的。

一个字节由8位组成,因此每条32位指令由四个字节构成。

这意味着,如果两条指令A和B存储在连续的内存位置,且A的地址是0x100,那么B的地址就是0x104


实例一:加载与加法运算

现在,假设你看到以下这段代码。

. = 0
LD(R31, C, R0)
ADDC(R0, B, R0)
HALT()
A: . = 0x200
B: . = 0x204
C: . = 0x208

. = 0这个符号表示你的程序从地址0开始。你可以假设执行从这个位置0开始,并在即将执行HALT指令时停止。

我们的目标是确定这段指令序列执行后,寄存器R0中的最终值。

请注意,这段代码中使用的是十六进制数,我们也希望答案以十六进制表示。

这段代码以一条加载指令到寄存器R0开始。加载指令使用R31的值加上C作为加载的源地址。由于R31等于0,这意味着存储在地址C的值被加载到R0中。

所以,加载指令执行后,R0 = 0x300。接下来,执行一条ADDC指令,将R0与常量B相加,结果存回R0

紧接在标签A前面的. = 0x200符号告诉我们,地址A = 0x200

这意味着地址B = 0x204,地址C = 0x208

因此,如果将常量B加到R0上,R0现在变为0x300 + 0x204 = 0x504


实例二:分支指令

现在让我们看看这段简短的代码。我们的目标是以十六进制确定R0中剩余的值。

. = 0
BEQ(R31, loop, R0)
HALT()
loop: . = 4

. = 0符号再次告诉我们,第一条指令(分支指令)位于地址0。

分支指令然后跳转到位置. + 4,即0 + 4 = 4。这是HALT指令的地址。除了跳转到HALT指令,分支指令还会将紧随其后的指令地址存储到目标寄存器中(本例中是R0)。

下一条指令的地址是4,所以R0 = 0x4


实例三:循环与位计数

让我们看看这段代码在做什么。它首先将地址X的内容加载到R0中,所以R0 = 0x0FACE0FF,简写为0xFACEOFF

. = 0
LD(R31, X, R0)
CMOVE(0, R1)
loop: ANDC(R0, 1, R3)
ADDC(R1, R3, R1)
SHIFTRC(R0, 1, R0)
BNE(R0, loop, R31)
HALT()
X: LONG(0xFACEOFF)

然后将常量0移入R1。所以R1 = 0

现在它进入循环,ANDC指令将R0的最低有效位放入R3ADDC指令在R3等于1时递增R1。这意味着如果R0的最低有效位是1,那么R1就加1。否则,R1保持不变。

接着,SHIFTRC指令将R0右移一位。这使得R0的最高有效位变为0,并且R0原来的高31位向右移动一位。注意,这意味着旧的R0的最低有效位现在完全消失了。不过没关系,因为我们已经根据R0原始的最低有效位递增了R1

BNE(如果不等于则分支)指令会在R0不等于0时跳转回loop标签。

这意味着这个循环所做的是:检查R0的当前最低有效位,如果该位是1则递增R1,然后将该位移出,直到所有位都被移出。换句话说,它是在计算从地址X加载的原始值中“1”的总数。

当所有的“1”都被计数后,循环结束,此时R0中只剩下0,因为所有的“1”都已被移出。

R1中剩下的是数据中“1”的数量。0xFACEOFF的二进制表示为0000 1111 1010 1100 1110 0000 1111 11110xFACEOFF中有19个“1”,所以R1 = 19,即16 + 3,用十六进制表示为0x13


实例四:栈操作

在这段代码中,CMOVE指令首先将栈指针设置为0x1000

CMOVE(0x1000, SP)
PUSH(SP)
HALT()

然后执行一个PUSH(SP)操作。让我们首先理解PUSH指令的作用。PUSH指令实际上是一个由两条Beta指令组成的宏,用于将一个值压入栈中。栈指针首先增加4,以指向栈上的下一个空位置。

这使SP = 0x1004

然后,将被压入栈的寄存器RA的内容,存储到地址为SP - 4(即地址0x1000)的内存位置。

现在,看这里实际执行的PUSH操作,我们正在执行一个对栈指针自身的压栈操作,所以RA寄存器也是栈指针。

这意味着存储在位置0x1000的值实际上是SP的值,即0x1004。所以被压入栈的值是0x1004


总结

本节课中我们一起学习了如何分析Beta汇编语言的几个典型实例。我们通过跟踪指令执行、理解内存地址计算(特别是字节与字的关系)、分析分支与循环逻辑,以及解析栈操作宏,掌握了从代码片段推导最终寄存器状态的方法。这些练习是理解处理器如何执行低级指令的关键步骤。

093:克里斯托弗·特曼访谈 - 关于教学计算结构

概述

在本节中,我们将通过克里斯托弗·特曼教授的访谈,了解他如何对计算结构产生兴趣、他的教学理念,以及他如何设计课程以满足不同背景学生的学习需求。访谈内容涵盖了课程结构、教学工具、学生互动以及未来展望等多个方面。


对计算结构产生兴趣

在20世纪70年代早期,我是一名大学生,为了支付学费,我在校园计算机上担任第三班操作员。在那个时代,学校只能负担得起一台计算机。由于观看闪烁的指示灯很无聊,我拿出了大学正在运行的计算机的电路图,开始尝试理解计算机的工作原理。从那时起,理解如何将这些组件组合成一台能够进行计算的机器,就成了我终生的兴趣。

教学与在线教育的兴趣

我一直被优秀的教师所吸引和激励。每个人在青少年或成年初期都会选择一个榜样,而我选择的榜样正是我遇到的那些优秀教师。因此,我立志要尽我所能成为最好的教师。看到学生点头并突然理解某个概念时,那种满足感非常强烈。教学是一种非常有成就感的体验,它形成了一个良性循环:你从教学中获得积极的反馈,然后在来年做得更好,从而获得更积极的反馈。四十年来,这感觉一直很棒。

学生背景的多样性

学生的背景差异很大。有些学生有很长的编程经验,甚至可能对计算机内部结构有所了解。而另一些学生则没有任何相关经验,他们可能只是使用过浏览器、笔记本电脑、电子邮件等,但对操作系统或内部硬件一无所知。他们带着兴趣而来,但完全没有背景知识。

满足多样化需求的课程结构

为了应对如此多样化的背景,课程必须提供大量丰富的材料。你需要为那些需要从零开始的学生提供起点,同时也需要为那些已经掌握前半部分内容的学生提供能激发他们兴趣的后半部分内容。我把这看作是一个自助餐:有很多菜品,你可以从头开始挑选,也可以跳过前几道菜,直接参与到对话的中间部分。关键在于拥有大量可供选择的材料。这也是6.004课程的一个标志性特点:我们提供了所有可能的学习材料方式,不仅针对不同背景,也针对不同的学习风格。有些学生喜欢交谈或倾听,有些喜欢阅读,有些则只想做习题集并进行即时学习。

MITx平台如何支持这种“自助餐”模式

MITx平台首先是一个汇集所有不同类型材料的“一站式商店”。其次,它遵循了一种新兴的最佳实践,即如何向初学者解释材料:采用短小精悍的视频片段,每个片段介绍一个单一技能或概念,然后通过一些自测问题让学生检查理解。理论上,你只需观看视频,然后回答一些简单的问题(不是难题),如果你理解了视频内容,就能回答出来。这让学生有机会开始检索学习的过程,你会反复提问,学生逐渐将知识从短期记忆转移到中长期记忆。MITx平台非常适合构建这种学习序列,学生也很欣赏这种方式,一切都被分解得更易于消化。想象一下播放一个50分钟的视频,大约到第6分钟,你可能就会想同时查看电子邮件。因此,保持内容简短精悍很重要。现在,我们拥有大量短视频片段,MITx平台允许你组织这些片段并穿插问题,持续测试学习效果,这成为了一种组织有序的学习之旅。学生可以随时开始、停止并返回学习,因为它是异步的,他们可以自己选择时间和地点。我们教师总是幻想:学生没来上课,但凌晨三点他们清醒时,肯定在看视频。实际上,查看观看统计数据,在测验周确实有很多观看行为,很多人将其用作一种深入沉浸式的材料介绍方式。

教学与学习研究的激动时刻

对于真正关心教学学术研究的人来说,数字工具的出现使得通过最佳实践实现学习成为可能,这确实是一个激动人心的时代。在大学层面教学的我们,通常只是被递上一支粉笔并被要求去教学。而小学教师则经过培训学习如何教学。我们的教学方式多是基于轶事和经验,试图记住别人是如何教我们的。现在,在线课程提供了一个真正的教育实验室,我们可以尝试不同的技术,能够相当准确地评估某项练习、视频片段或设计问题的效果如何。我们可以在同一批学生中进行A/B测试。作为一名科学家和工程师,能够提出假设并通过一系列实验进行测试,这非常棒。借助MITx平台,我们真的可以进行这些实验,这太棒了,确实令人兴奋。

大课堂的教学策略

保持学生参与度很有趣,因为我们有如此多的不同材料,真正来听课的学生是那些通过听课方式学习效果最好的人。我本人就是这样的学生。所以,到场的学生不是被强制的,他们是自愿选择的,因此他们准备好接受口头讲授的某种程度的参与。我有一套精心准备并在课堂上展示的材料,这些材料经过调试,内容进度适中,大多数人能跟上。教学一段时间后,你会变得更加放松,所以这是一种非常轻松的体验。我会讲笑话,分享我职业生涯中的故事。有趣的是,学期末学生评价时,很多人说他们很喜欢这些故事。在讲完枯燥的技术细节后,说说“当我尝试使用那个东西时,发生了以下情况”,这很有趣。突然间,他们开始集中注意力,心想“哦,对”。我认为这有助于他们记忆相关内容。确实如此,你从讲座中记住的几乎从来不是技术要点,而是他们讲的笑话、发生的事故或犯的错误。这涉及一个叫做“流畅性”的概念,即事情进展得有多顺利。当每个人都点头,一切都很顺利时,你的思维反而可能开始游离。因此,在讲座中适当加入一些“不流畅”实际上是有好处的,比如停下来讲个笑话、犯个错误、把粉笔掉在地上说声“该死”并看看地板,或者我喜欢做的是:从讲台后面走出来,接近听众。你可以看到他们有点反应:“等等,他‘逃出来’了。”任何能打破“我只是在顺流而下”状态的事情,都能在保持人们参与度方面产生很大影响。这是一个很好的建议。

教学团队的角色

我们有一个由讲师组成的小团队。我长期以来一直是这个团队的一员,但近年来也有其他来自外部的人员加入。系里增加了讲师资源,所以还有另一位讲师参与,也有其他教员加入。这为人才库增加了一些深度。然后,我们有研究生助教负责辅导课,有本科生助教,还有实验助理。我们有一个完整的层级结构。他们几乎都上过这门课,并且都喜欢它。这门材料让人们觉得“这真的很棒,我等不及要告诉下一个人这是如何工作的”。当他们坐在那里与学生一起工作时,就像我一样,有一种热情会自然流露出来。学生实际上更喜欢另一种方式:向实验助理提问不那么令人生畏,他们可能上学期刚修完这门课,所以对需要做什么来达成目标记忆犹新。然后,你可以沿着层级向上寻求答案。这样,只有当你相当确定其他人无法解答时,才会向那些更令人生畏的人提问。所以,当问题传到我这里时,大多已经没人担心那是“愚蠢的问题”了。我并不真的相信有愚蠢的问题,我认为所有问题都很有趣。但学生会想:我问了10个人,没人知道,现在可以问你了,而且我相当确信这不是那种只要读了作业就能知道的明显问题。这种不同经验水平和年龄的搭配很好:在经验水平的高端,你可以得到任何问题的答案;在经验水平的低端,你是在和刚刚在几个月前做过你现在正在做的事情的人交谈,所以你可以问他们而不会感到尴尬。

在线论坛的作用与管理

在线论坛是课程中一个极好的资源。我第一次能够对一个问题进行深思熟虑的回答,并让180人看到答案,而不是只让一个人看到。当下一个人有同样的问题时,我就不必再花10分钟重复解答。对于大班教学来说,你无法为300人中的每一个都花费10分钟。所以,论坛是提问的好地方。我尽量对每个问题都给予深思熟虑且尊重的回答。即使问题类似于“哇,如果你读了材料就会知道”,我也会说:“如果你回头看材料,你会发现它解释了以下内容……”或者试着给出一点提示,暗示可能需要更多准备。但很多问题是:“我读了材料,但还是不明白,我需要一个例子。”我努力让学生感到非常自在地提问,这没有任何成本,他们可以匿名提问,这消除了一些障碍。在2017年秋季,论坛大约有2500条贡献,平均响应时间约为20分钟。学生可能会在凌晨3点提问,这怎么可能呢?原来,我们有一些助教,特别是在凌晨3点正是他们精力充沛的时候。我们许多参与课程的人都设置了实时发布通知,所以我们会立即收到电子邮件,通常可以马上输入答案。我认为快速的响应时间确实降低了学生的挫败感。没有什么比卡在某个问题上想说“我希望我能问别人”更糟的了。现在,即使在凌晨3点12分,你也可以说“等等,我可以提问并且能得到答案”,学生们非常非常感激这一点。论坛确实改变了学生遇到困难时的挫败感水平。遇到困难只是一个10分钟的过程,而不是一个持续两天的过程——“天哪,下次答疑时间在周末之后,我该怎么办?”当然,很多学生在非朝九晚五的时间学习。所以,这是让教学团队成为24/7全天候团队,而不仅仅是每周五天朝九晚五团队的一种方式。教学团队成员的工作时间也和论坛用户差不多,所以很匹配。

课程中的实验体验

学生可以获得动手进行数字设计的实践经验。这些实验可以在任何地方进行,因为它是基于浏览器的,无需下载软件,只需上网即可。我喜欢做的事情之一就是构建基于浏览器的计算机辅助设计工具。事实证明,它们确实运行得很好。现代浏览器环境作为编程环境相当强大,需要学习一些技巧,但一旦掌握,效果就很好。这些工具性能相当高,学生可以坐下来完成大部分设计工作。我们的学习很多是以设计驱动的,我们实际上试图让他们构建我们描述的东西,我们甚至可能详细地告诉他们如何组合。但“肌肉记忆”这个概念很重要:如果你亲自构建它,拖动组件并连接线路,你会记得更牢。或者你会问自己:“这个应该放在这里还是那里?”然后你回头看,开始真正仔细地阅读说明,并意识到“哦,我必须这样放才能工作”。我们让手和眼都参与进来,而不仅仅是听。如果你组装了某个东西,我们会构建测试来检查它是否功能正确。所以你可以立刻知道是否搞砸了。这不像交上去一周后,在我已经不再关心的时候拿回来一个红叉,然后说“好吧,该死”。在这里,他们必须做对才能完成,但我们会立刻告诉他们不对,然后他们继续努力,或者在论坛上发帖说“我的电路不工作”,工作人员可以从服务器远程调出他们的设计,说“哦,这里,这是你的错误”。这样,学生实际上是在扮演工程师的角色。这可能是他们第一次真正的“构建”体验,这些学生是大二学生。所以,说“哇,这很酷,我实际上组装并调试了电路”是有点趣味的。这意味着我必须找出哪里错了,我做错了什么,然后修复它。这是一次非常宝贵的经历。这就像深夜电视购物广告:看他们使用时觉得如此简单,但你把小工具买回家后它却不工作。他们在讲座中看过,在视频中看过,在示例中看过,一切都极其明显,认为这很简单,直到你自己动手做,然后你才填补了所有缺失的环节,意识到“哦,原来这么难,现在我明白了”。他们努力尝试修复,但我认为这些虚拟实验台的整体概念很棒。正如我所说,浏览器上的执行环境和图形环境是一流的,很容易构建使用相当复杂后台计算并拥有出色用户界面的复杂工具。而且浏览器是可移植的。回想20年前,我给你软件下载到电脑上做这些事,那简直是雷区,每个人的环境都略有不同。“哦,你没有那个库的最新版本?那你不能运行这个。”“但如果你更新了库,你又不能运行那个了。”那真是一场噩梦。因此,将这些实验体验打包成世界各地的人都能使用的方式,意义重大。有一次,我在香港地铁上,一个年轻人走过来对我说:“我上了你们的MITx课程,我真的很喜欢做电路实验,而且我不用下载任何东西。”我当时想,哇,这太有趣了。被人拦住并开始谈论这不仅仅是一种倾听体验,这很有趣。这些虚拟实验室实际上将课程从一种可能附带一些书面习题的倾听体验,转变为让你的手也动起来的体验。手动起来,脑就动起来,当人们的大脑开始运转时,他们记住的东西是惊人的。

学生首次尝试工程师角色时遇到的挑战

我认为很多挑战与信心有关。有时学生来找我说:“我想让这个工作。”我说:“好的,让我看看你的设计,我们来修复它。”我尽量把手放在口袋里,让他们自己修复,但我会说:“你有没有想过,如果这个工作而那个不工作,那说明了什么?”很多学生不善于利用已知信息来减少下一步尝试或测试的范围,从而缩小问题所在的位置。这是一种需要学习的技能:如何有条理地处理一个不工作的复杂事物(其中部分工作,但有些部分不工作),并尝试从两端向中间推进,找到不工作的地方。这是一种需要练习一段时间的技能,我们试图帮助培养这种技能。我很有信心这会奏效,但大多数学生相当确信要么模拟器坏了,要么没希望了。所以,当他们意识到有一种系统性的方法可以让东西工作时,他们会有些惊讶。一旦他们确信这是真的,他们就会变得更加自信,认为“它现在不工作,但我只需要再花10分钟,我相信它会工作的”,而不是说“哦,我唯一能做的就是举手求助,因为它不工作”。我们努力让学生摆脱那种“你的工作就是让它工作,而不仅仅是让我们过来看着我们让它工作”的心态。学生们会完成这个转变。所以,这似乎是课程的一个学习目标:不仅是学习数字系统的架构,还要培养工程师所具备的专业能力态度。有一系列过程你必须经历。同样,学习如何学习也是大二学生仍在做的事情。这可能是他们第一次被“扔进深水区”。我们尝试安排很多救生员在旁边,但我们准备做的不仅仅是拿着记录板检查他们是沉是浮。我们准备好跳进去说:“试试这个,试试那个。”在大一(有很多辅助轮)之后、高年级课程(帮助相对较少)之前,有这样的经历实际上非常有帮助。6.004课程的一大优点是,你宿舍楼里有一半的人都上过这门课。所以,即使我们有课程团队,你也可以去宿舍楼里问。正如我所说,每隔一个人就会说:“哦,我上过那门课,是的,我上过。”所以实际上有大量的关于材料和类似事情的知识。没有多少课程有这种机会,但我们充分利用了它。因此,在课堂结构化学习之外,发生了很多“走廊学习”和同伴学习。这很有趣,它说明了在本科阶段拥有共同学习经历以促进那种“走廊学习”的重要性,真的很有趣。此外,学生通常在白天很忙,容易分心。他们来上课时脑子里有很多事情。所以,很可能是在他们房间的安静环境中,他们才能真正在智力上钻研材料。首先,给他们一些值得钻研的东西是好的,但也要确保在他们离开你提供的结构化帮助环境时,能通过同伴或论坛获得支持。我认为这确实改变了学生消化课程的方式。讲座出勤率一般,但很多人在评估中对材料的学习掌握得很好。我认为这印证了你之前谈到的:你提供了多种学习方式的“自助餐”,所以来听课的人是那些通过这种形式学习效果最好的人。有些人我除了偶尔在实验答疑时间过来聊聊天外,几乎从未见过。我们有一项活动是在设计完成后进行的“检查”,你必须来解释你的设计。部分原因是为了确保也许是你自己做的设计,而不是你宿舍楼的朋友做的。至少,我们希望他们理解他们声称已经完成的设计。对于那些努力完成设计的人来说,他们喜欢过来展示,他们为自己的“成果”感到自豪。特别是在课程后期,我们有一个相当复杂的设计项目,要获得高分确实很难,你必须非常出色。令人惊讶的是,有很多人尝试这个项目,他们进来说:“好吧,我卡在这里了,给我一些下一步尝试的思路。”如果你敢说“嗯,你知道,只要这样做”,他们会说:“不,我不想要答案,我想……我在这里玩得很开心,所以不要拿走有趣的部分,我想自己琢磨出来,我只需要一些提示,告诉我应该把注意力集中在哪个谜题上。”乐趣就在于解谜和动手做的过程。我认为这很棒,因为当学生刚来时,他们往往专注于答案,比如“你给了我一份满是问题的工作表,所以我看了答案,我觉得我准备好了”。我说:“不,工作表不是为了让你知道那些问题的答案,工作表是帮助你诊断是否理解内容。所以,工作表能发生的最好事情是‘我不知道怎么做这个问题’,因此我应该去弄清楚怎么做。”所以,如果你在进行这种自我主导的掌握式学习(这也是我们试图帮助学生按照自己的节奏掌握材料的方式),我们必须提供大量的自我评估,他们必须将其用作评估工具。如果他们只是说“哦,这个问题的答案是3,我希望测验会考这个”,那他们就没有真正掌握。让学生不再只关注答案,而是真正专注于“我如何解决这类问题?在我知道的所有事情中,我首先必须选择适当的概念或技能,然后我必须知道如何应用它”,这很关键。看着他们从刚进来时专注于答案,到离开时能够说“好吧,你可以问我任何问题,因为我实际上知道如何从头开始做事情,而不仅仅是因为我能认出正确答案,我实际上能创造出正确答案”,这种转变很美妙。当人们能做到这一点时,他们会感到被赋能。

课程的未来展望

我即将退休,所以课程正在移交给一个新的团队,一些早期教学团队的成员将接手。他们当然有自己关于更好方法的强烈意见。我认为课程的基本结构、要教授的基本主题列表将保持不变,但他们心中有不同的设计体验构想,所以他们会找到自己的方式。这对我来说很有趣,因为我认为除非你教过300人的大课,否则你可能无法体会到那些在办公室面对两个学生或在10人的辅导课上效果很好的方法,对于300人的大课并不真正适用。你会突然意识到:“嗯,如果他们有问题会问的,所以我不需要太具体。”但有趣的是,“哇,300个问题,那是很多问题,每个学生3个问题,我这周就有900个问题。”于是你开始明白材料需要准备得多么仔细。在你发给我的一个问题中,你谈到了“设计”材料。大班教学在“设计”材料方面确实有一个真正的问题:这些材料要能帮助学生做你想让他们做的事,但你不能让他们无所适从,因为你只有有限的能力把他们全部拉回岸边。所以,你真的必须把大部分他们需要的东西都放进材料里。

关于“设计”材料以保持所有人跟上进度的更多说明

这是一个迭代的过程。人们说:“哇,6.004运行得像时钟一样精确,这是我上过的最有条理的课程。”我说:“嗯,你知道,20年前你不会这么说。”我们也有过一些不幸的作业、无法完成的作业,或者对某些学生太难、对所有人太简单的作业。是的,我们这样做,我们尝试获取学生反馈,论坛在这方面实际上很棒,你能即时得到“这个很糟糕”的反馈,然后你会说“哦,好吧”。如果你做得好,你会做笔记。我想我教这门课大概有30个学期了,这给了你很多机会在每个学期结束后反思什么做对了、什么做错了。有一个好的团队,他们通常能立刻发现问题:“哦,我们必须改这个”或者“我花了太多时间帮助学生解决这个,所以应该把它放进作业里”。甚至在实时教学中,我们也会在作业中添加一段话,提供更多解释或提示。所以,愿意将构建材料视为一个持续的过程很重要。过一段时间,大多数坑洼都会被填平,道路就会变得相当平坦。有时,意想不到的问题实际上是一扇通往全新事物的大门。有些学生说:“我思考了这个,并尝试用这种方式做,然后我想‘哇,这是多么了不起的见解’。”我们希望所有学生都有机会获得那种“啊哈”的顿悟时刻。所以现在我们要想办法把它融入到设计问题中,让每个人都说“有机会恍然大悟”。学生能够自己提出那个见解真的很棒,但这给了你一个视角,让你有机会理解学生是如何看待你所提出的问题的。他们误解了,所以回答了一个不同的问题,结果那个不同的问题至少很有趣,甚至可能比你问的更好。然后你开始进入另一个良性循环,慢慢地你积累了很多东西,最终得到那些看起来没什么,但背后经过了大量演化才以这种方式、这个顺序提出的问题。经历这个过程很有趣,也常常令人惊讶,你会说:“哦,我以为这已经很清楚了。”


总结

本节课中,我们一起学习了克里斯托弗·特曼教授关于计算结构教学的理念与实践。我们了解到他对计算结构的兴趣源于早年操作计算机的经历,他的教学热情则受到优秀教师的激励。课程设计采用“自助餐”模式,通过MITx平台提供丰富的材料以适应不同背景和学习风格的学生。教学策略包括保持课堂互动、利用在线论坛提供即时支持,以及通过基于浏览器的虚拟实验室提供动手设计体验。课程不仅传授技术知识,还注重培养学生解决问题的能力和工程思维。教学团队结构多元,支持系统完善。课程材料经过多年迭代优化,未来将由新团队在保持核心结构的基础上继续发展创新。

001:高级语言的执行策略 🚀

在本节课中,我们将要学习如何将高级语言翻译成计算机可以执行的代码。我们将探讨两种主要的执行策略:解释和编译,并分析它们各自的优缺点。

到目前为止,我们已经了解了Beta指令集架构(ISA),它包含了控制数据通路操作的指令,这些操作处理存储在寄存器中的32位数据。此外,还有访问主存和改变程序计数器的指令。这些指令被格式化为操作码、源和目的字段,在主存中构成32位的值。

为了简化工作,我们开发了汇编语言来指定指令序列。每条汇编语言语句对应一条指令。作为汇编语言程序员,我们需要负责管理哪些值在寄存器中,哪些在主存中,并且需要弄清楚如何将复杂的操作(例如访问数组元素)分解成正确的Beta指令序列。

我们可以更进一步,使用高级语言来描述我们想要执行的计算。这些语言使用变量和其他数据结构,抽象掉了存储分配以及数据进出主存的细节。我们可以直接通过名称引用数据对象,让语言处理器处理细节。同样地,我们可以编写表达式和其他操作符(如赋值)来高效地描述在汇编语言中需要许多语句才能完成的操作。

今天,我们将深入探讨如何将高级语言程序翻译成可以在Beta上运行的代码。


高级语言的优势 ✨

高级语言(如C语言)使我们能够在不涉及任何Beta ISA细节(如寄存器、特定Beta指令等)的情况下描述计算。这种抽象意味着创建程序所需的工作量更少,并且使其他人更容易阅读和理解程序实现的算法。

使用高级语言有许多优点:

  • 提高生产力:程序简洁且可读性强,使程序员效率更高。
  • 易于维护:代码的可读性使其易于维护。
  • 减少错误:语言本身可以帮助检查某些类型的错误,例如将字符串值存储到数值变量中。
  • 自动化复杂任务:动态分配和释放存储等复杂任务可以完全自动化。

因此,使用高级语言创建正确程序所需的时间可能远少于编写汇编语言程序。由于高级语言抽象了特定ISA的细节,程序具有可移植性,我们可以在不同的ISA上运行相同的代码,而无需重写代码。

那么,使用高级语言会失去什么呢?我们是否应该担心,由于无法手工精心设计每条指令,我们会在效率和性能方面付出代价?答案取决于我们选择如何运行高级语言程序。两种基本的执行策略是解释编译


解释执行策略 🔍

解释高级语言程序时,我们需要编写一个特殊的程序,称为解释器,它在实际的计算机M1上运行。解释器模拟某个抽象的、易于编程的机器M2的行为,并为每个M2操作执行一系列M1指令以达到预期效果。我们可以将解释器与M1一起视为M2的一个实现。换句话说,给定一个为M2编写的程序,解释器将逐步模拟M2指令的效果。

在处理计算任务时,我们经常使用多层解释。例如,一位工程师可能使用她的带Intel CPU的笔记本电脑来运行Python解释器。在Python中,她加载SciPy工具包,该工具包为矩阵和数据的数值分析提供了一个类似计算器的接口。对于每个SciPy命令(例如,查找数据集的最大值),SciPy工具包会执行许多Python语句(例如,循环遍历数组的每个元素,记住最大值)。对于每个Python语句,Python解释器又会执行许多x86指令(例如,递增循环索引并检查循环终止条件)。执行单个SciPy命令可能需要执行数十条Python语句,而这又可能需要执行数百条x86指令。工程师很高兴她不必自己编写每一条指令。

当计算只需执行一次,或者在投入更多精力创建更高效的实现之前探索哪种计算方法最有效时,解释是一种有效的实现策略。


编译执行策略 ⚙️

当我们有需要重复执行的计算任务,并因此愿意预先投入更多时间以获得长期更高的效率时,我们会使用编译实现策略。

在编译中,我们同样从实际的计算机M1开始。然后,我们将高级语言程序P2逐句翻译成M1的程序P1。请注意,我们实际上并没有运行P2程序。相反,我们将其用作模板来创建一个可以在M1上直接执行的等效P1程序。这个翻译过程称为编译,执行翻译的程序称为编译器。我们编译P2程序一次以获得翻译后的P1,然后每当想要执行P2时,就在M1上运行P1。

运行P1避免了处理P2源代码的开销,也避免了执行任何中间解释层的成本。它不是在遇到每个P2语句时动态地找出必要的机器指令,而是预先捕获了那串机器指令并将其保存为P1程序以供后续执行。如果我们愿意支付编译的前期成本,我们将获得更高效的执行。通过不同的编译器,我们可以在许多不同的机器(M2、M3等)上运行P2,而无需重写P2。

因此,现在我们有两种执行高级语言程序的方法:解释和编译。两者都允许我们修改原始源代码,都允许我们抽象掉用于运行程序的实际计算机的细节,并且这两种策略在现代计算机系统中都得到了广泛使用。


解释与编译的对比 📊

上一节我们介绍了两种执行策略,本节中我们来看看它们的具体区别。

假设高级程序中出现语句 x + 2

  • 解释器处理该语句时,它会立即获取变量x的值并为其加2。
  • 另一方面,编译器会生成Beta指令,这些指令将变量x加载到寄存器中,然后为该值加2。

解释器在处理每条语句时立即执行它,实际上,如果语句在循环中,它可能会多次处理和执行同一条语句。编译器则只是生成指令,以便在稍后某个时间执行。

解释器在执行过程中有处理高级源代码的开销,并且这种开销在循环中可能会发生多次。编译器只承担一次处理开销,使得最终执行更高效。但在开发过程中,程序员可能必须多次编译和运行程序,常常只为程序的单次执行而承担编译成本,这使得“编译-运行-调试”循环可能花费更多时间。

解释器在运行时(即程序运行时)决定x的数据类型和必要的操作类型。编译器则在编译过程中做出这些决定。

哪种方法更好?通常,执行编译后的代码比以解释方式运行代码要快得多。但由于解释器在运行时做决定,它可以根据数据(例如变量x的类型)改变其行为,从而在处理不同类型数据时使用相同算法,提供了相当大的灵活性。编译器则为了快速执行而牺牲了这种灵活性。


总结 📝

本节课中我们一起学习了高级语言程序执行的两种核心策略:解释和编译。解释通过一个在真实机器上运行的解释器程序,动态地模拟并执行高级语言语句,适合快速开发和探索。编译则通过编译器将高级语言程序预先翻译成目标机器的指令,生成可独立执行的程序,适合需要重复运行以获得高性能的场景。两者各有优劣,共同构成了现代计算生态的基础。

002:编译表达式 🧠

在本节课中,我们将要学习编译器如何将高级语言程序(如C语言)翻译成功能等效的机器指令序列。我们将从一个简单的编译技术入手,重点探讨如何编译表达式和几种基本语句。

编译器是一个将高级语言程序翻译成功能等效的机器指令序列的程序。换句话说,它生成汇编语言程序。

编译器首先检查高级程序是否正确。这意味着语句结构良好,程序员没有要求进行无意义的计算。例如,将字符串值与整数相加,或试图在变量被正确初始化之前使用其值。

编译器在操作可能不会产生预期结果时,也可能提供警告。例如,当从浮点数转换为整数时,浮点值可能太大,无法放入整数提供的位数中。

如果程序通过了审查,编译器就会继续生成高效的指令序列,通常会找到重新安排计算的方法,使生成的序列更短、更快。

现代优化编译器在生成汇编语言方面很难被超越,因为编译器会耐心地探索替代方案,并推断出即使是勤奋的汇编语言程序员也可能不明显的程序特性。

上一节我们介绍了编译器的基本概念,本节中我们来看看一个将C程序编译成汇编的简单技术。

编译器的两个主要例程

在我们的简单编译器中,有两个主要例程:compile_statementcompile_expression

compile_statement 的任务是编译源程序中的单个语句。由于源程序是一系列语句,我们将反复调用 compile_statement

我们将重点介绍四种类型语句的编译技术。

以下是四种基本语句类型:

  • 无条件语句:只是一个被求值一次的表达式。
  • 复合语句:只是一系列按顺序执行的语句。
  • 条件语句(有时称为if语句):计算测试表达式的值(例如,A < B 这样的比较)。如果测试为真,则执行语句1;否则执行语句2。
  • 迭代语句:每次迭代也包含一个测试表达式。如果测试为真,则执行该语句并重复该过程。如果测试为假,则终止迭代。

另一个主要例程是 compile_expression,其任务是生成计算表达式值的代码,并将结果留在某个寄存器中。

表达式的形式

表达式有多种形式。

以下是表达式的几种主要形式:

  • 简单的常量值
  • 来自标量或数组变量的值
  • 赋值表达式:计算值,然后将结果存储到某个变量中。
  • 一元或二元操作:使用指定的运算符组合其操作数的值。
  • 复杂的算术表达式:可以分解为一元和二元操作的序列。
  • 过程调用:将执行一个命名的语句序列,并将提供的参数值赋给该过程的形式参数。

编译过程和过程调用是我们将在下一讲中讨论的主题,因为其中有一些需要理解和处理的复杂性。

幸运的是,编译其他类型的表达式和语句是直接的。让我们开始吧。

编译常量、变量和数组

我们需要什么代码来将常量的值放入寄存器?

如果常量能放入指令的16位常量字段中,我们可以使用 CMOVE 将符号扩展的常量加载到寄存器中。这种方法适用于 -32,768 到 +32,767 之间的常量。如果常量太大,它被存储在主存位置中,我们使用 LOAD 指令将其值获取到寄存器中。

加载变量的值与加载大常量的值非常相似。我们使用 LOAD 指令访问保存变量值的内存位置。

执行数组访问稍微复杂一些。数组作为连续位置存储在主存中,从索引0开始。数组的每个元素占据固定数量的字节。因此,我们需要代码将数组索引转换为指定数组元素的实际主存地址。

我们首先调用 compile_expression 来生成计算索引表达式的代码,并将结果留在 RX 中。这将是一个介于0和数组大小减1之间的值。我们将使用 LOAD 指令来访问适当的数组条目,但这意味着我们需要将索引转换为字节偏移量,我们通过将索引乘以 B_SIZE(一个元素的字节数)来实现。如果 B 是一个整数数组,B_SIZE 将是4。现在我们有了寄存器中的字节偏移量,我们可以使用 LEA 将偏移量加到数组的基地址上,计算出所需数组元素的地址,然后将该地址处的内存值加载到寄存器中。

编译赋值和算术操作

赋值表达式很容易。调用 compile_expression 生成将表达式值加载到寄存器的代码,然后生成 STORE 指令将该值存储到指定的变量中。

算术操作也很容易。使用 compile_expression 为每个操作数表达式生成代码,将结果留在寄存器中。然后生成适当的 ALU 指令来组合操作数,并将答案留在寄存器中。

让我们看一个例子,看看这一切是如何工作的。

这里有一个赋值表达式,需要一个减法、一个乘法和一个加法来计算所需的值。让我们从头到尾跟随编译过程,因为我们调用 compile_expression 来生成必要的代码。

按照上一页中赋值表达式的模板,我们递归调用 compile_expression 来计算赋值右侧的值。这是一个乘法操作,所以按照操作模板,我们需要编译乘法的左操作数。这是一个减法操作,所以我们再次调用 compile_expression 来编译减法的左操作数。哈,我们知道如何将变量的值放入寄存器。因此,我们生成一条 LOAD 指令,将 x 的值加载到 R1 中。

我们遵循的这个过程称为递归下降。我们使用对 compile_expression 的递归调用来处理表达式树的每一层。在每次递归调用中,表达式变得更简单,直到我们到达一个变量或常量,在那里我们可以生成适当的指令而无需进一步下降。此时,我们已经到达了表达式树的叶子,并且完成了递归的这个分支。

现在我们需要将减法右操作数的值放入寄存器。在这种情况下,它是一个小常量,因此我们生成一条 CMOVE 指令。现在两个操作数值都在寄存器中,我们返回到减法模板并生成一条减法指令来执行减法。现在我们在 R1 中有了乘法左操作数的值。我们对乘法的右操作数遵循相同的过程,递归调用 compile_expression 来处理表达式的每一层,直到我们到达一个变量或常量。然后我们沿着表达式树向上返回,按照上一张幻灯片中适当模板的指示,在返回过程中生成适当的指令。

生成的代码显示在幻灯片的左侧。递归下降技术使得即使是最复杂的表达式,生成代码也变得轻而易举。

甚至有机会通过查看相邻指令来找到一些简单的优化。例如,一条 CMOVE 指令后跟一条算术操作,通常可以缩短为一条以常量作为其第二操作数的单条算术指令。

这些局部转换称为窥孔优化,因为我们一次只考虑一两条指令。

本节课中我们一起学习了编译器的基础知识,特别是如何使用递归下降技术来编译表达式和基本语句。我们探讨了如何处理常量、变量、数组访问、赋值和算术操作,并通过一个例子演示了编译过程。最后,我们提到了窥孔优化作为提升生成代码效率的一种简单方法。

003:编译语句 🧩

在本节课中,我们将学习如何将高级编程语言中的各种语句(如赋值、复合语句、条件语句和循环语句)编译成底层的机器指令。我们将通过一系列简单的代码生成模板来理解这个过程。


无条件语句与复合语句

上一节我们介绍了表达式编译,本节中我们来看看语句的编译。首先从最简单的两种语句类型开始。

无条件语句通常是赋值表达式或过程调用。编译这类语句时,只需调用编译表达式的功能来生成相应的代码。

复合语句的编译同样简单。以下是其处理方式:

  • 递归调用 compile_statement 函数。
  • 依次为复合语句中的每一条子语句生成代码。
  • 为语句2生成的代码会紧跟在为语句1生成的代码之后。
  • 执行过程将按顺序遍历每条语句的代码。

条件语句(If-Then-Else)

现在,让我们转向更复杂的条件语句。对于最简单的 if-then 形式,我们需要生成代码来评估测试表达式。如果寄存器中的值为假(false),则跳过执行 then 子句中语句的代码。

简单的汇编语言模板通过递归调用 compile_expressioncompile_statement 来为 if 语句的各个部分生成代码。

完整的 if 语句包含一个 else 子句,当测试表达式的值为假时应执行该子句。该模板使用了一些分支指令和标签来确保执行流程符合预期。

你可以看到,编译过程本质上就是应用许多小型模板,将代码生成任务逐步分解为更小的任务,并以适当的方式生成必要的代码将所有部分粘合在一起。

循环语句(While 与 For)

接下来,我们看看循环语句。while 语句的模板与 if 语句的模板非常相似,只是在末尾有一个分支指令,导致生成的代码被重复执行,直到测试表达式的值变为假。

经过一些思考,我们可以对这个模板稍作改进。我们重新组织了代码,使得每次迭代只执行一条分支指令 BT,而不是原始模板中每次迭代的两条分支指令 BFBR。这虽然不是重大改进,但对循环内部代码的小优化可以在长时间运行的程序中累积成显著的性能提升。

关于另一种常见的迭代语句 for 循环,这里做一个简要说明。for 循环是表达迭代的一种简写方式,其中循环索引(例如示例中的 I)会遍历一系列值,并且 for 循环体针对循环索引的每个值执行一次。for 循环可以转换为这里所示的 while 语句,然后使用上面展示的模板进行编译。

实例分析:阶乘函数

在这个例子中,我们应用模板为之前见过的阶乘函数的迭代实现生成代码。浏览生成的代码,你将能够将代码片段与前几张幻灯片中的模板对应起来。这不是最高效的代码,但考虑到递归下降方法在编译高级程序时的简洁性,这已经相当不错了。

优化:使用寄存器

修改递归下降过程以容纳存储在专用寄存器而非主存中的变量值是一件简单的事情。优化编译器非常擅长识别将值保留在寄存器中的机会,从而避免访问主存值所需的加载和存储操作。

使用这个简单的优化,循环中的指令数量从 10 条减少到了 4 条。现在,生成的代码看起来相当不错了。但与其继续调整递归下降方法,我们在此暂停。在下一部分,我们将看到现代编译器如何采用更通用的方法来生成代码。

尽管如此,当我第一次了解递归下降时,我跑回家写了一个简单的实现,并惊叹于自己在一个下午就编写了自己的编译器。😊


总结

本节课中,我们一起学习了如何使用递归下降编译方法为高级语言中的基本语句结构生成机器代码。我们探讨了无条件语句、复合语句、条件语句(if-then-else)以及循环语句(whilefor)的编译模板,并通过阶乘函数的例子观察了实际代码生成过程。最后,我们简要了解了通过将变量值存储在寄存器中进行优化的概念。虽然递归下降方法简单直观,但它为理解现代编译器更复杂的代码生成技术奠定了基础。

004:编译器前端

概述

在本节课中,我们将要学习现代编译器如何将高级语言编写的源代码转换为计算机可以执行的指令。我们将重点关注编译器的前端部分,即分析和理解源代码的阶段。


编译器结构概览

现代编译器首先分析源程序文本,生成一个等价的、用与语言和机器无关的中间表示来表达的操作序列。

编译过程通常分为两个主要阶段:分析阶段(前端)和综合阶段(后端)。前端负责理解程序,后端负责生成高效代码。


分析阶段(前端)

分析或前端阶段检查程序是否格式良好。换句话说,它检查每个高级语言语句的语法是否正确。

它理解每个语句的语义,即其含义。

许多高级语言包含变量类型的声明,例如整数、浮点数、字符串等。前端会验证所有操作是否正确应用。

它确保数值操作的操作数是数值类型,字符串操作的操作数是字符串类型,依此类推。

本质上,分析阶段将源程序的文本转换为一个内部数据结构,该结构指定了要执行的操作序列和类型。

通常存在一系列前端程序,它们将各种高级语言(例如 C、C++、Java)翻译成一种公共的中间表示


综合阶段(后端)

综合或后端阶段随后优化中间表示,以减少最终代码运行时执行的操作数量。

例如,它可能发现循环内部有一些与循环索引无关的操作,这些操作可以被移到循环外部,从而只执行一次,而不是在循环内重复执行。


一旦中间表示达到最终优化形式,后端就会为目标指令集架构生成代码序列,并寻找可以利用该 ISA 特定特性的进一步优化。

例如,对于 Beta ISA,我们曾看到如何将一个 C 语言中的移动操作,后跟一个算术操作,缩短为带有一个常量操作数的单一操作。




词法分析(扫描)

分析阶段从扫描源文本开始,生成一系列词法单元对象,这些对象标识源文本中每一部分的类型。

源文本中用于分隔词法单元的空格、制表符、换行符等,在扫描过程中都已被移除。

为了支持有用的错误报告,词法单元对象还包含关于每个词法单元在源文本中位置的信息,例如文件名、行号和列号。

扫描阶段会报告非法的词法单元。例如,词法单元 3x 会导致错误,因为在 C 语言中,它既不是合法的数字,也不是合法的变量名。


语法分析(解析)

解析阶段处理词法单元序列以构建语法树,该树以一种方便的数据结构捕获原始程序的结构。

每个一元和二元操作的操作数已被组织好。每个语句的组成部分已被找到并标记。源文本中每个词法单元的角色已被确定,并且这些信息被捕获在语法树中。

将树中节点的标签与我们上一节讨论的模板进行比较,我们可以看到,编写一个程序来进行深度优先的树遍历是很容易的,该程序使用每个树节点的标签来选择适当的代码生成模板。

我们暂时还不会这样做,因为分析和转换树还有一些工作要做。


语义分析

语法树使得验证程序的语义正确性变得容易,例如,检查操作数的类型是否与请求的操作兼容。

例如,考虑语句 x = "bananas"

赋值操作的语法是正确的:左边有一个变量,右边有一个表达式。但至少在 C 语言中,其语义是不正确的。

通过查找其符号表来检查变量 x 的声明类型(例如 int),并将其与表达式(字符串)的类型进行比较,op= 树节点的语义检查器将检测到类型不兼容。

换句话说,我们不能将字符串值存储到整数变量中。

当语义分析完成时,我们知道语法树代表了一个语法正确且语义有效的程序,并且我们完成了将源程序转换为等价的、与语言无关的操作序列的过程。



总结

本节课中我们一起学习了编译器前端的工作流程。我们了解到,编译器前端通过词法分析将源代码分解为词法单元,通过语法分析构建语法树以理解程序结构,最后通过语义分析确保程序的类型和操作是正确且兼容的。这个过程将高级语言源代码转换为一个清晰、结构化的中间表示,为后端的优化和代码生成做好准备。

005:优化与代码生成 🚀

在本节课中,我们将要学习编译器后端处理的两个核心阶段:中间表示的优化目标指令集的代码生成。我们将了解如何通过一系列简单的优化步骤,将语法树转换为高效的机器代码。

语法树与控制流图

上一节我们介绍了语法树作为中间表示的作用。本节中我们来看看如何将其进一步组织成控制流图

语法树是一种有用的中间表示,它独立于源语言和目标指令集架构。它包含了关于操作顺序和分组的信息,这些信息在单个机器语言指令中并不明显。它允许不同源语言的前端共享一个针对特定ISA的后端。

后端处理可以分为两个子阶段。第一个阶段对中间表示执行与机器无关的优化。然后,优化后的中间表示由代码生成阶段翻译成目标ISA的指令序列。

一种常见的中间表示是将语法树重组为所谓的控制流图。图中的每个节点都是一个赋值和表达式求值的序列,并以一个分支结束。这些节点被称为基本块,代表作为一个单元执行的连续操作序列。一旦开始执行一个基本块中的第一个操作,该块中的其余操作也将被连续执行,不会被其他操作中断。

这个特性让我们可以考虑许多优化,例如,将变量值临时存储在寄存器中。如果存在在本块执行过程中,块外的其他操作也可能需要访问这些变量值的可能性,那么优化就会变得复杂。图的边指示了将我们带到另一个基本块的分支。

例如,这是求最大公约数(GCD)程序的控制流图。如果一个基本块以条件分支结束,则有两条标记为T和F的边离开该块,指示根据测试结果将要执行的下一个块。其他块只有一个离开箭头,表示该块总是将控制权转移到箭头指示的块。

请注意,如果我们只能从单个前驱块到达一个块,那么从前驱块获得的关于操作和变量的任何知识都可以延续到目标块。例如,如果“if X > y”块已经生成了将x和y值加载到寄存器的代码,那么两个目标块都可以利用该信息并使用相应的寄存器,而无需生成自己的加载指令。

但是,如果一个块有多个前驱,此类优化就会受到更多限制。我们只能使用所有前驱块共有的知识。控制流图看起来很像高级有限状态机的状态转换图。

优化过程

现在我们已经了解了控制流图,本节中我们来看看如何通过多次遍历它来优化中间表示。

我们将通过对控制流图执行多次遍历来优化中间表示。每次遍历执行一个特定的、简单的优化。我们将反复应用这些简单优化,直到找不到更多可执行的优化为止。总的来说,这些简单的优化可以组合起来实现非常复杂的优化。

以下是几个优化示例:

  • 死代码消除:我们可以消除从未使用的变量的赋值和从未到达的基本块。
  • 常量传播:我们识别具有常量值的变量,并在引用该变量的地方用该常量替代。
  • 常量折叠:我们可以计算具有常量操作数的表达式的值。

为了说明这些优化如何工作,考虑一个稍显简单的源程序及其控制流图。注意,我们已经使用临时变量名(例如,_T1)将复杂表达式分解为简单的二元操作。让我们开始优化。

死代码消除遍历可以移除第一个基本块中对Z的赋值,因为Z在后续块中被重新赋值,并且中间的代码没有引用Z。接下来,我们寻找具有常量值的变量。这里我们发现x被赋值为3且从未被重新赋值,因此我们可以将所有对x的引用替换为常量3。现在执行常量折叠,计算任何常量表达式的值。这是更新后的控制流图,准备进行下一轮优化。

首先,再次进行死代码消除。然后,常量传播。最后,常量折叠。经过两轮这些简单操作后,我们精简了许多赋值。进入第三轮:死代码消除。在这里,我们可以确定条件分支的结果,从而从中间表示中消除整个基本块,因为它们现在为空或无法再被到达。


现在,中间表示已经显著变小。接下来是另一轮常量传播。然后,常量折叠。接着是更多的死代码消除。这些遍历持续进行,直到我们发现没有进一步的优化可执行。至此,优化完成。这些简单转换的重复应用已将原始程序转换为计算Z相同最终值的等效程序。

我们可以通过添加更多遍历来做更多优化,例如:

  • 消除公共子表达式的冗余计算。
  • 将循环无关的计算移出循环。
  • 展开短循环,以便在一次循环执行中完成(例如)两次迭代的效果,节省一些增量和测试指令的成本。

优化编译器拥有一套复杂的优化策略,用于生成更小、更高效的代码。

代码生成

优化完成后,现在是为目标ISA生成指令的时候了。

首先,代码生成器为每个变量分配一个专用寄存器。如果变量数量多于寄存器数量,一些变量将被存储在内存中,并在需要时使用加载和存储指令来访问它们。但频繁使用的变量将尽可能多地驻留在寄存器中。

使用之前的模板,将每个赋值和操作翻译成一条或多条指令。为每个基本块生成代码,添加适当的标签和分支。重新排列基本块的代码顺序,尽可能消除无条件分支。最后,执行任何特定于目标的后端优化。

这是GCD代码的原始控制流图,以及一个经过轻微优化的控制流图。GCD不像前面的例子那么简单,所以我们只能进行一些常量传播和常量折叠。注意,我们不能将关于变量值的知识从顶部基本块传播到后续的if块,因为if块有多个前驱。

以下是代码生成器处理优化后控制流图的过程。首先,它分配寄存器来保存X和Y的值。然后,它为每个基本块生成代码。接下来,重新组织基本块的顺序,尽可能消除无条件分支。生成的代码相当不错。人类程序员可能不会做出明显的更改来使代码更快或更小。编译器干得漂亮。

这里按顺序展示了所有的编译步骤及其输入和输出数据结构。它们共同将原始源代码转换为高质量的汇编代码。优化遍历的耐心应用通常能产生比手工编写汇编语言更高效的代码。如今,程序员能够专注于让源代码实现所需功能,而将翻译成指令的细节留给编译器处理。

总结

本节课中我们一起学习了编译器后端的关键流程。我们了解到,控制流图是组织中间表示的有效方式,它由基本块和连接它们的边构成。优化过程通过多次遍历控制流图,应用死代码消除常量传播常量折叠等简单但强大的技术,逐步精简和加速代码。最后,代码生成器将优化后的中间表示映射到目标机器的寄存器和指令上,生成高效的汇编代码。整个过程展示了如何通过系统化的步骤,将高级语言自动转换为高质量的底层机器指令。

006:6.004 编译器工作示例 🧩

在本节课中,我们将学习编译器如何将高级语言代码片段翻译成汇编语言。我们将通过几个具体的例子,分析编译器如何确定程序中的依赖关系,并生成有效的机器代码。

算术表达式编译示例

首先,我们来看一个简单的算术表达式 A = B + 3 * C。假设变量A、B、C存储在内存中,编译器可以使用寄存器来存储中间结果。

我们有一段部分完成的汇编代码,需要确定编译器必须填写的缺失值。

XXX C, R1
YYY 1, R1, R0
ADD R0, R1, R0
XXX B, R1
ADD R0, R1, R0
ST R0, ZZZ

确定第一条指令

第一条指令 XXX C, R1 试图将变量C的值放入寄存器R1。由于C来自内存,因此指令 XXX 必须是一个加载操作,其中C是要加载的变量地址。

核心概念LOAD C, R1 是一个宏指令,等价于 LOAD(R31, C, R1)。LOAD操作会将常量C与寄存器R31(其值恒为0)相加,从而得到加载操作的源地址C。

因此,XXX 应为 LOAD。R1将作为临时寄存器,保存变量C的值。

实现乘法运算

接下来需要将C乘以3。乘法运算通常开销较大,因此编译器的工作是将其转换为更简单、更快的操作。注释提示,编译器首先尝试计算 2 * C 并将结果存入R0。

由于R1等于C,且操作中的常量是1,编译器会使用逻辑左移一位的操作来实现乘以2。

核心概念:在二进制中,将一个数左移一位等同于乘以2。因此,YYY 应为 SHL(左移)指令的常量版本,即 SHLC

所以,YYY 应为 SHLC。执行 SHLC 1, R1, R0 后,R0的值变为 2 * C

完成表达式计算

下一条指令 ADD R0, R1, R0 将R0(2*C)与R1(C)相加,得到 3*C 并存储回R0。

接下来,我们需要再次从内存加载变量B的值。因此,第二个 XXX 也应为 LOAD 指令,即 LOAD B, R1

最后,我们需要将R1(B)与R0(3*C)相加,并将结果存回内存变量A。ADD R0, R1, R0 指令完成了加法。由于存储指令 ST R0, ZZZ 使用R0作为源,因此 ZZZ 必须是变量A的地址,即 A

至此,完整的汇编代码如下:

LOAD C, R1
SHLC 1, R1, R0
ADD R0, R1, R0
LOAD B, R1
ADD R0, R1, R0
ST R0, A

条件语句编译示例 🔀

上一节我们介绍了算术表达式的编译,本节中我们来看看条件语句 if (A > B) C = 17; 如何被编译。

我们同样有一段部分完成的Beta汇编代码:

LOAD A, R0
LOAD B, R1
XXX R0, R1, R0
BNE R0, YYY, L2
CMOVE(17, R0)
ST R0, C
L2:

加载变量与比较操作

首先,代码将变量A和B的值分别加载到临时寄存器R0和R1中。

接下来,XXX 必须是某种Beta比较操作。但Beta指令集不直接提供“大于比较”操作,因此我们需要利用“小于比较”或“小于等于比较”操作来实现。

观察代码,当程序分支到标签L2时,会跳过对C的赋值存储。因此,我们需要确保在 A > B进行分支跳转。

这等价于在 A <= B进行分支跳转。所以,如果我们让 XXXCMPLE(比较小于等于)操作:

核心概念CMPLE R0, R1, R0 会比较R0(A)和R1(B)。如果 A <= B 为真,则将结果1存入R0;否则存入0。

设置分支条件

然后,我们需要设置 YYY,以确保当 A <= B(即比较结果为1)时,执行分支跳转。BNE 指令在操作数不相等时跳转。因此,我们需要将 YYY 设置为一个与比较结果(R0)进行比较的值。为了在R0为1时跳转,我们可以将 YYY 设为 R31(其值恒为0),因为 1 != 0 会触发 BNE 跳转。

所以,YYY 应为 R31

完成条件赋值

最后,如果分支未跳转(即 A > B),则会执行 CMOVE(17, R0),将17移入R0,然后存储到地址C。因此,代码中已给出的 CMOVE(17, R0) 是正确的。

完整的条件语句汇编翻译如下:

LOAD A, R0
LOAD B, R1
CMPLE R0, R1, R0    ; 如果 A <= B,R0=1,否则R0=0
BNE R0, R31, L2     ; 如果 R0 != 0 (即 A <= B),跳转到L2
CMOVE(17, R0)       ; 否则 (A > B),将17存入R0
ST R0, C            ; 将R0的值存入C
L2:

数组操作编译示例 📊

现在,让我们看看编译器如何处理数组操作 A[I] = A[I-1]

给出的部分汇编代码如下:

LOAD I, R0
SHLC XXX, R0, R0
LOAD YYY(R0), R1
ST R1, ZZZ(R0)

计算数组索引地址

首先,代码将存储在地址I的值加载到寄存器R0。I是我们的数组索引。由于Beta是字节寻址,但处理的是32位(4字节)值,这意味着每个数组元素需要4字节的存储空间。

因此,为了指向内存中的正确位置,我们需要将索引I乘以4。

核心概念:左移一位等价于乘以2。所以,这里我们左移两位来实现乘以4。因此,XXX 等于 2

执行 SHLC 2, R0, R0 后,R0的值变为 4 * I

加载数组元素

为了加载 A[I],我们需要加载地址 A + 4*I。但这里我们要加载的是 A[I-1],所以需要加载比上述地址早4字节的位置,即 A + 4*I - 4

因此,在加载操作 LOAD YYY(R0), R1 中,基址寄存器R0已经是 4*I,所以偏移量 YYY 应为 A - 4。这样,实际加载的地址就是 (A - 4) + (4*I) = A + 4*(I-1),即 A[I-1] 的地址。

所以,YYY 应为 A-4。此加载操作将数组元素 A[I-1] 放入R1。

存储数组元素

现在,我们想将R1的内容存储到数组元素 A[I] 中,其位于地址 A + 4*I。由于R0已经等于 4*I,那么 A + R0 就给出了我们存储操作的目标地址。

存储指令是 ST R1, ZZZ(R0)。这意味着目标地址是 ZZZ + R0。为了得到 A + R0,我们需要设置 ZZZA

同时,源寄存器是R1,这正是我们想要存入 A[I] 的值。

因此,完整的数组操作汇编代码如下:

LOAD I, R0
SHLC 2, R0, R0      ; R0 = 4 * I
LOAD A-4(R0), R1    ; R1 = A[I-1]
ST R1, A(R0)        ; A[I] = R1

循环结构编译示例 🔄

最后,我们分析一个简单的循环:sum 初始化为0,然后循环将 I 的值累加到 sum 中,I 从0递增到9。

部分完成的编译代码如下:

ST R31, sum
ST R31, I
_L7:
LOAD sum, R0
LOAD I, R1
ADD R1, R0, XXX
ST XXX, sum
ADDC(R1, 1, YYY)
ST YYY, I
CMPLTC(ZZZ, YYY, R0)
BNE R0, R31, _L7

初始化变量

编译器首先将两个变量 sumI 初始化为0。这是通过将寄存器R31(在Beta中值恒为0)的值存储到 sumI 的地址来实现的。

标签 _L7 指示我们循环的开始。

循环体:累加与递增

在循环中,首先需要从内存加载 sumI 的当前值,分别放入R0和R1。

接下来,sum 需要增加 I。由于结果会被存回 sum,我们希望 ADD 指令的目的寄存器 XXX 就是R0。即执行 ADD R1, R0, R0,将R1(I)加到R0(sum)上,结果存回R0。

因此,XXX 应为 R0

然后,循环索引 I 需要递增。由于R1等于I,这意味着我们希望将R1加1。指令 ADDC(R1, 1, YYY) 将R1加1,结果存入 YYY。所以 YYY 应为 R1

循环条件判断

最后,我们需要判断循环是否需要重复,即检查 I 是否小于10。Beta提供了 CMPLTC(与常量比较小于)操作来做到这一点。

由于R1保存了I的最新值,将R1与常量 ZZZ 比较,结果会存入R0。为了检查 I < 10ZZZ 应为 10

如果比较结果为真(R0 != 0),则我们需要重复循环,分支回 _L7BNE R0, R31, _L7 指令实现了这一点(R31恒为0)。

完整的循环汇编代码如下:

ST R31, sum         ; sum = 0
ST R31, I           ; I = 0
_L7:
LOAD sum, R0        ; R0 = sum
LOAD I, R1          ; R1 = I
ADD R1, R0, R0      ; R0 = sum + I
ST R0, sum          ; sum = R0
ADDC(R1, 1, R1)     ; R1 = I + 1
ST R1, I            ; I = R1
CMPLTC(10, R1, R0)  ; R0 = (10 < R1)? 即 (I < 10)? 注意:CMPLTC是 b < a
BNE R0, R31, _L7    ; 如果 R0 != 0 (即 I < 10),跳转到 _L7

注意CMPLTC(10, R1, R0) 计算的是 10 < R1(即 10 < I)的结果。这与检查 I < 10 在逻辑上是相反的。为了正确循环,我们需要的是 I < 10 为真时继续。因此,更准确的逻辑可能需要调整。但根据题目给出的框架和 BNE 跳转,它隐含了当 I 递增到不小于10时,比较结果会使R0为0,从而退出循环。

总结 📝

本节课中我们一起学习了编译器工作的几个核心示例:

  1. 算术表达式:编译器将高级运算分解为加载、移位、加法等基本指令,并利用寄存器暂存中间结果。
  2. 条件语句:通过组合比较指令(如CMPLE)和条件分支指令(如BNE)来实现高级语言中的if逻辑。
  3. 数组访问:需要考虑字节寻址与数据大小的关系,通过地址计算(索引乘以元素大小)来正确访问数组元素。
  4. 循环结构:循环涉及初始化、循环体执行、索引更新和条件判断,编译器将其翻译为包含标签、加载/存储、算术运算和条件分支的指令序列。

通过这些例子,我们看到了编译器如何分析代码的依赖关系,并利用目标机器(如Beta)的指令集,生成高效且功能等价的汇编代码。理解这一过程是学习计算机体系结构和编译原理的重要基础。

007:过程(Procedures) 🧩

在本节课中,我们将要学习高级语言提供的最有用的抽象之一:过程(或子程序)。我们将探讨过程的概念、其重要性,并深入了解如何使用Beta指令集架构(ISA)来实现过程调用。

概述

过程是一系列执行特定任务的指令。它提供了一个单一的命名入口点,允许程序的其他部分调用它。过程抽象将复杂的计算封装为一个“黑盒”,使用者只需知道其输入输出行为,而无需了解其内部实现细节。这种抽象是构建模块化、可重用代码的基础,也是面向对象编程的核心思想。

过程的概念

上一节我们介绍了过程的基本定义,本节中我们来看看过程的具体组成部分。

一个过程包含以下关键元素:

  • 入口点:一个单一的名称,用于在程序中引用该过程。
  • 形式参数:过程内部代码用来指代调用时传入值的名称。
  • 过程体:执行特定任务的一系列语句。
  • 局部变量:在过程执行期间存在且只能被过程体内语句访问的变量。
  • 返回值:过程计算的结果。有些过程可能不返回值,仅通过其副作用(如修改共享数据)产生影响。

以下是过程定义的伪代码示例:

function GCD(a: integer, b: integer): integer
begin
    // 过程体:计算最大公约数
    while b != 0 do
        temp := b
        b := a mod b
        a := temp
    end while
    return a // 返回值
end

过程抽象的力量在于封装。例如,一个名为coprime的过程可以调用GCD过程来判断两个数是否互质。coprime的程序员只需要知道GCD的输入(两个整数)和输出(一个整数),而无需关心GCD内部是如何计算的。

几乎所有高级语言都提供预构建的过程集合,称为。这些库极大地增强了语言的表达能力和易用性。

过程的实现策略

理解了过程的概念后,我们接下来探讨如何在实际的机器指令层面实现它。我们将重点分析两种策略。

内联展开

一种可能的实现方式是内联展开。在这种方法中,我们将过程调用替换为过程体语句的一个副本,并用实际参数值替换对形式参数的引用。

这种方法将过程视为类似宏的简单记法缩写。然而,它存在明显问题:

  1. 代码膨胀:如果一个冗长的过程被多次调用,最终展开的代码会非常庞大。
  2. 无法处理递归:对于递归过程(即过程调用自身),在编译时内联过程将永不终止,导致方案失败。

因此,内联通常只被优化编译器用于非常短小的过程。

链接调用

第二种,也是更通用的选项是链接调用。在这种方法中,过程的代码只有一份副本,所有对该过程的调用都“链接”到这份代码上执行。

以下是链接调用的实现步骤:

  1. 过程体被翻译成一次Beta指令。
  2. 第一条指令被标识为过程的入口点
  3. 过程调用被编译成一组指令,用于计算参数表达式并将其值存入约定位置。
  4. 使用分支指令将控制权转移到过程的入口点。分支指令会将下一条指令的地址(返回地址)保存在指定的寄存器中。
  5. 过程代码运行,将结果存入约定位置。
  6. 过程通过跳转指令跳转到提供的返回地址,从而恢复调用者程序的执行。

为了使这个方案可行,我们需要一个调用约定来规定参数值、返回地址和返回值存储在哪里。

Beta ISA 的调用约定

上一节我们介绍了链接调用的概念,本节中我们来看看如何为Beta ISA设计一个具体的调用约定。

一个简单的想法是使用寄存器:

  • R1:用于传递第一个(或唯一一个)参数值。
  • R28:用于保存返回地址。这个寄存器通常被称为链接指针
  • R0:用于保存过程的返回值。

让我们以计算阶乘的递归过程 fact(3) 为例,看看这个约定如何工作。我们的目标是建立一个统一的调用约定,使得所有过程调用和过程体都使用相同的规则。

以下是fact函数的部分伪代码和对应Beta指令的示意:

function fact(n: integer): integer
begin
    if n <= 0 then
        return 1
    else
        return n * fact(n-1) // 递归调用
end

对应的Beta指令需要处理参数传递、递归调用和返回值。

递归调用的问题与栈帧

在按照上述约定编译fact的代码并模拟执行递归调用fact(3)时,我们会发现一个严重问题:寄存器会被覆盖。

具体来说:

  1. 初始调用将返回地址(HALT指令的地址)存入R28。
  2. 进入fact后,递归调用fact(2)需要再次使用R28来保存它自己的返回地址(MUL指令的地址),这覆盖了原来的返回地址。
  3. 同样,用于保存当前参数n值的寄存器(例如R2)也会在递归调用中被覆盖。

问题的核心在于,每个活跃的递归调用都需要记住它自己的参数值和返回地址。在执行fact(3)并最终调用fact(0)时,存在四个嵌套的活跃调用,因此需要 4 * 2 = 8 个存储位置。显然,固定的两个寄存器无法满足这种随着递归深度变化而动态增长的存储需求。

早期的一些语言(如Fortran)的解决方案是禁止递归。但我们需要一个更通用的方法。

总结

本节课中我们一起学习了过程这一核心编程抽象。我们首先了解了过程的定义、组成部分及其作为“黑盒”封装的重要性。然后,我们探讨了实现过程的两种策略:内联展开和链接调用,并指出了内联在代码膨胀和处理递归方面的局限性。接着,我们为Beta ISA设计了一个使用寄存器的简单调用约定。最后,我们通过阶乘递归的例子,发现了该约定在应对递归调用时寄存器值被覆盖的根本问题,这引出了对动态存储机制的需求,为下一节课学习“栈帧”这一关键概念做好了铺垫。

008:激活记录与栈 📚

在本节课中,我们将要学习过程调用中一个关键概念:激活记录。我们将探讨为什么需要它,以及如何使用这种数据结构来高效地管理激活记录的生命周期。理解这些概念对于掌握程序如何在计算机内存中组织和管理数据至关重要。

激活记录的必要性

我们需要解决的问题是,在哪里存储过程所需的值,包括其参数返回地址返回值。过程可能还需要存储其局部变量的空间,以及在过程覆盖调用者的寄存器之前,保存这些寄存器值的空间。

我们希望避免对参数数量、局部变量数量等设置任何限制。因此,我们需要为每个活跃的过程调用分配一块存储区域,我们称之为激活记录

正如我们在阶乘例子中看到的,我们不能为特定过程静态地分配单个存储块,因为递归调用意味着在执行过程中的某些时刻,会有多个对该过程的活跃调用。

我们需要一种方法,在过程被调用时动态分配激活记录的存储空间,并在过程返回后回收这些空间。

激活记录的生命周期

让我们看看随着执行的进行,激活记录是如何创建和销毁的。

第一个激活记录用于调用 fact(3)。它在过程开始时创建,并保存了参数 N 的值以及 fact(3) 计算完成后应恢复执行的返回地址

在执行 fact(3) 期间,我们需要进行递归调用来计算 fact(2)。因此,该过程调用也会获得一个激活记录,其中包含适当的参数值和返回地址。

请注意,原始的激活记录被保留下来,因为它包含了在 fact(2) 调用返回后完成 fact(3) 计算所需的信息。所以现在,我们有两个活跃的过程调用,因此有两个激活记录。

fact(2) 需要计算 fact(1),而 fact(1) 又需要计算 fact(0)。此时,有四个活跃的过程调用,因此有四个激活记录。

递归在 fact(0) 处终止,它将值 1 返回给其调用者。此时,fact(0) 的执行完成,因此其激活记录不再需要,可以被丢弃。

fact(1) 现在完成其计算,将 1 返回给其调用者。我们不再需要它的激活记录。接着 fact(2) 完成,将 2 返回给其调用者,其激活记录可以被丢弃,依此类推。

需要注意的是,嵌套过程调用的激活记录总是在其调用者的激活记录之前被丢弃。这是合理的,因为调用者的执行在嵌套过程调用返回之前无法完成。

栈数据结构

我们需要一种存储方案,能够高效地支持激活记录的分配和回收,如上所示。

早期的编译器编写者认识到,激活记录是按照后进先出的顺序进行分配和回收的。因此他们发明了这种数据结构,它实现了 push 操作(将记录添加到栈顶)和 pop 操作(移除栈顶元素)。

新的激活记录在过程调用期间被压入栈中,并在过程调用返回时从栈中弹出。请注意,栈操作只影响栈顶,即栈上最近添加的记录。

C 语言的过程通常只需要访问栈顶的激活记录。其他编程语言,例如 Java,支持访问其他激活记录,栈也支持这两种操作模式。

最后一个技术说明:一些编程语言支持闭包(例如 JavaScript)或协程(例如 Python 的 yield 语句),在这些情况下,激活记录即使在过程返回后也需要被保留。此时,栈简单的后进先出行为就不再足够,需要另一种方案来分配和回收激活记录。但这属于另一门课程的主题。

Beta 架构上的栈实现

以下是我们将在 Beta 架构上实现栈的方法。我们将指定 Beta 的一个寄存器 R29 作为栈指针,用于管理栈操作。

当我们向栈中压入一个字时,我们将递增栈指针。因此,随着字被压入栈中,栈会向更高的地址方向增长。

我们将采用一个约定:栈指针 SP 指向第一个未使用的栈位置,即其值是下一个 push 操作将要填充的位置的地址。因此,地址低于 SP 值的那些位置对应着之前已分配的字。

字可以在执行过程中的任何时刻被压入或弹出栈,但我们将强加一个规则:将字压入栈的代码序列必须在执行结束时弹出这些字。因此,当一个代码序列执行完毕时,SP 的值应与该序列开始前相同。这被称为栈纪律,它确保了对栈的中间使用不会影响后续的栈引用。

我们将分配一大块内存区域来存放栈,并确保栈可以增长而不会覆盖其他程序存储。大多数系统要求你在运行程序时指定最大栈大小,如果程序试图向栈中压入过多项,则会发出执行错误信号。

对于我们的 Beta 栈实现,我们将使用现有指令来实现栈操作。因此,对我们来说,栈严格来说是一套软件约定。其他指令集架构可能提供专门用于栈操作的指令。

还有许多其他合理的栈约定,因此你需要查阅你将要使用的特定 ISA 或编程语言所采用的约定。

UASM 中的栈支持宏

我们向 UASM 添加了一些便利宏来支持栈操作。

PUSH 宏展开为两条指令。ADDC 指令递增栈指针,在栈顶分配一个新字,然后通过一条 ST 指令,用指定寄存器的值初始化这个新的栈顶字。

POP 宏将栈顶的值加载到指定寄存器中,然后使用一条 SUBC 指令递减栈指针,从栈中回收该字。

请注意,PUSHPOP 宏中指令的顺序非常重要。正如我们将在下一讲中看到的,中断可能导致 Beta 硬件在任何两条指令之间停止执行当前程序,因此我们必须小心操作顺序。

对于 PUSH,我们首先在栈上分配字,然后初始化它。如果我们反过来做,并且在初始化和分配代码之间发生中断,那么中断期间运行的、使用栈的代码可能会无意中覆盖已初始化的值。但假设所有代码都遵循栈纪律,先分配后初始化的方案总是安全的。

同样的推理也适用于 POP 指令的顺序。我们首先访问栈顶一次以检索其值,然后我们回收该位置。

我们可以使用 ALLOCATE 宏为后续使用预留多个栈位置,有点像 PUSH,但不进行初始化。DEALLOCATE 执行相反的操作,从栈中移除 N 个字。

通常,如果我们在汇编语言程序中看到一个 PUSHALLOCATE,我们应该能找到对应的 POPDEALLOCATE,这表明栈纪律得到了遵守。

栈的典型用法

我们将使用栈来保存我们稍后需要的值。例如,如果我们需要使用一些寄存器进行计算,但不知道这些寄存器的当前值是否在程序后面还需要,我们可以将它们当前的值压入栈中,然后就可以自由地在代码中使用这些寄存器。在我们完成后,我们可以使用 POP 来恢复保存的值。

请注意,我们弹出数据的顺序与数据被压入的顺序相反。换句话说,我们需要遵循栈操作所强加的后进先出纪律。

现在,我们有了栈这种数据结构,可以用来解决在过程调用期间分配和回收激活记录的问题。

总结

本节课中我们一起学习了激活记录的概念,它是存储过程调用相关信息(如参数、返回地址、局部变量)的动态内存块。为了解决其动态分配和回收的需求,我们引入了这种后进先出的数据结构。我们探讨了在 Beta 架构上如何利用栈指针和特定宏指令(PUSH/POP)来实现栈操作,并强调了遵守栈纪律的重要性。最后,我们看到了栈在保存和恢复寄存器值等场景中的典型应用。掌握这些知识是理解程序执行时内存管理机制的基础。

009:栈帧组织 🧱

在本节课中,我们将学习过程调用中栈帧的组织方式。栈帧,也称为活动记录,用于存储过程调用所需的信息,包括参数、局部变量和返回地址。我们将详细讲解调用者和被调用者如何协作来构建和销毁栈帧,并解释为何采用特定的参数压栈顺序。


栈帧的用途

我们使用栈来保存过程的活动记录,该记录包含了过程调用时参数的值。

我们会在栈上分配字(words)来保存过程的局部变量的值,这是假设我们没有将它们保存在寄存器中的情况。

我们还将使用栈来保存返回地址,以便过程可以进行嵌套的过程调用,而不会覆盖其自身的返回地址。


调用者与被调用者的责任

分配和释放活动记录的责任将由调用过程(调用者)和被调用过程(被调用者)共同承担。

调用者负责计算参数表达式的值,并将这些值保存在栈上正在构建的活动记录中。

我们将采用一个约定:参数以逆序压入栈中。换句话说,第一个参数将是最后一个被压入栈的。我们将在几页幻灯片后解释为何做出这个选择。

为过程编译的代码涉及一系列表达式求值,每个求值之后都跟着一个压栈操作,以将计算出的值保存在栈上。因此,当被调用过程开始执行时,栈顶包含第一个参数的值,下一个字包含第二个参数的值,依此类推。

在所有参数值(如果有的话)被压入栈后,会有一条分支指令将控制权转移到过程的入口点,同时将分支指令后的下一条指令地址保存在链接指针寄存器(R28,我们将其专用于此目的)中。

当被调用者返回,执行在调用者中恢复时,会使用一个解分配器(deallocator)从栈中移除所有参数值,以维持栈的纪律。

以上就是编译器为调用过程生成的代码。其余的工作发生在被调用过程中。


被调用过程的初始化代码

被调用过程开始处的代码负责完成活动记录的分配。由于完成后,活动记录将占据栈上连续的一批字,我们有时会称活动记录为栈帧,以提醒我们它的存储位置。

第一个动作是保存链接指针寄存器(LP)中的返回地址。这释放了LP,使其可以被被调用者主体内的任何嵌套过程调用使用。

为了便于访问存储在活动记录中的值,我们将指定另一个称为基址指针(BP或R27)的寄存器,它将指向我们正在构建的栈帧。因此,在进入过程时,代码会保存指向调用者栈帧的指针,然后使用栈指针的当前值使BP指向当前的栈帧。我们稍后将看到如何使用BP。

接下来,代码将在栈帧中分配字来保存被调用者的局部变量的值(如果有的话)。

最后,被调用者需要保存它在执行其余代码时将使用的任何寄存器的值。这些保存的值可用于在返回调用者之前恢复寄存器的值。这被称为被调用者保存约定,即被调用者保证所有寄存器值在过程调用前后保持不变。

有了这个约定,调用者中的代码可以假设,在嵌套过程调用之前放置在寄存器中的任何值,在嵌套调用返回后仍然存在。

请注意,专门指定一个寄存器作为基址指针并非绝对必要。所有对栈上值的访问都可以相对于栈指针进行,但从SP的偏移量会随着值被压入和弹出栈而改变(例如,在过程调用期间)。如果我们使用BP进行所有栈帧引用,将更容易理解生成的代码。


参数顺序的考量

现在让我们回到关于栈帧中参数值顺序的问题。我们采用了以逆序压入值的约定。换句话说,第一个参数的值是最后一个被压入的。

那么,为什么要以逆序压入参数值呢?

当参数以逆序压入时,标记为arg0的第一个参数将位于相对于基址指针的固定偏移处,而不管压入栈的参数值有多少个。

编译器可以使用一个简单的公式来确定任何特定参数的正确BP偏移值。因此,第一个参数在偏移量-12处,第二个在-16处,依此类推。为什么这很重要?

一些语言(如C)支持具有可变数量参数的过程调用。通常,过程可以从(例如)第一个参数确定期望有多少个额外参数。典型的例子是C的printf函数,其第一个参数是一个格式字符串,指定应如何打印一系列值。

因此,对printf的调用包括格式字符串参数,加上数量不定的额外参数。根据我们的调用约定,格式字符串将始终位于相对于BP的相同位置,因此printf代码可以找到它,而无需知道当前调用中有多少个额外参数。

局部变量也位于相对于BP的固定偏移处。第一个局部变量在偏移量0处,第二个在偏移量4处,依此类推。因此,我们看到,拥有一个基址指针使得使用在编译时可确定的固定偏移量来访问参数和局部变量的值变得容易。

局部变量上方的栈空间可用于其他用途,例如,为嵌套过程调用构建活动记录。


总结

本节课中,我们一起学习了栈帧的组织结构。我们了解到栈帧用于存储过程调用的上下文信息,其构建由调用者和被调用者共同完成。调用者负责按逆序压入参数并处理返回地址,而被调用者则负责保存返回地址、设置基址指针、分配局部变量空间并遵循被调用者保存约定。采用逆序压参和基址指针的设计,特别是为了支持像C语言中printf这样的可变参数函数,使得参数访问更简单、更灵活。理解栈帧的组织是理解过程调用机制和编译器生成代码的关键。

010:6.004 过程调用约定与编译

在本节课中,我们将要学习过程(函数)调用的具体约定,以及编译器如何根据这些约定生成对应的汇编代码。我们将重点关注调用栈的管理、寄存器的保存与恢复,以及递归过程的实现。


过程调用约定 📝

上一节我们介绍了过程调用的基本概念,本节中我们来看看其具体的工作约定。

调用过程(caller)与被调用过程(callee)必须遵循以下约定以确保程序正确执行:

以下是调用者(caller)的职责:

  • 以逆序将参数压入栈中。
  • 跳转到被调用过程的入口点,同时将返回地址存入链接指针(LP)。
  • 在被调用者返回后,从栈中移除参数值。

以下是被调用者(callee)的职责:

  • 执行约定的计算,并将结果留在寄存器 R0 中。
  • 计算完成后,跳转至返回地址。
  • 移除自身放置在栈上的任何数据,使栈恢复到过程被调用时的状态。请注意,参数是由调用者压栈的,因此也应由调用者负责移除。
  • 保存除 R0(用于存放返回值)外所有寄存器的值。因此,调用者可以假设在嵌套调用前存入寄存器的值,在嵌套调用返回后依然存在。

过程入口与出口代码模板 💻

了解了约定后,我们来看看编译器为过程调用生成的固定代码模式。

过程 F 的入口点代码模板如下:

F:  PUSH(LP)        // 保存调用者的LP
    PUSH(BP)        // 保存调用者的BP
    MOVE(SP, BP)    // 初始化当前栈帧的BP
    // 为局部变量在栈上分配空间
    PUSH(R1)        // 保存过程代码中将用到的寄存器(R0除外)
    PUSH(R2)
    ...

过程的出口序列代码模板与入口序列的动作相对应,以相反的顺序恢复所有保存的值:

    ...
    POP(R2)         // 恢复寄存器
    POP(R1)
    MOVE(BP, SP)    // 重置SP到入口点状态,隐式释放局部变量空间
    POP(BP)         // 恢复调用者的BP
    POP(LP)         // 恢复调用者的LP
    JMP(LP)         // 跳转回调用过程

请注意,通过 MOVE(BP, SP) 将栈指针重置到入口点 MOVE(SP, BP) 时的状态,这隐式地撤销了入口序列中分配语句的效果,因此出口序列中不需要显式的“释放”操作。出口序列的最后一条指令将控制权交还给调用过程。

通过练习,你会熟悉这些代码模板。同时,在需要为过程生成代码时,可以随时参考本节内容。


实例分析:阶乘函数 🔢

现在,我们通过一个具体例子来看编译器如何应用这些模板。左侧是C语言实现的阶乘函数,右侧是对应的汇编代码。

入口序列保存了调用者的 LP 和 BP,然后为当前栈帧初始化 BP。由于 R1 的值是安全的,我们可以在后续代码中使用 R1。

出口序列恢复了所有保存的值,包括 R1 的值。过程体的代码已经确保在执行到达出口序列时,R0 寄存器包含了返回值。

嵌套的过程调用将参数值压栈,并在嵌套调用返回后将其移除。其余代码的生成使用了我们在之前课程中见过的模板。

除了计算和压入参数值,实现一个过程调用链接大约需要10条指令。对于任何规模的过程来说这都不算多,但对于一个非常简单的过程可能就显多了。正如之前提到的,一些优化编译器会权衡并内联小的非递归过程,以节省这部分开销。


递归与栈追踪 🧱

那么,我们是否解决了递归过程的激活记录存储问题?是的。

每次过程调用都会分配一个新的栈帧。在每个帧中,我们可以看到为参数和返回地址分配的存储空间。随着嵌套调用的返回,栈帧将以相反的顺序被释放。

有趣的是,通过查看活动的栈帧,我们可以了解到大量关于当前执行状态的信息。当前 BP 的值,连同保存在激活记录中的旧值,使我们能够识别活动的过程调用、确定它们的参数、活动调用的局部变量值等等。

如果在任何给定时间打印出所有这些信息,我们将得到一个显示计算进度的栈追踪。事实上,当程序出现问题时,许多语言的运行时环境都会打印出栈追踪来帮助程序员确定发生了什么。当然,如果你能解读栈帧中的信息,就证明你理解了我们的调用与返回约定。


总结 ✨

本节课中我们一起学习了过程调用的详细约定,包括调用者和被调用者各自的责任。我们分析了过程入口和出口的标准代码模板,并通过阶乘函数的实例观察了编译器如何生成汇编代码。最后,我们探讨了栈帧机制如何优雅地支持递归调用,并提供了进行栈追踪的基础。掌握这些约定对于理解程序执行和调试至关重要。

011:栈帧侦探 🕵️

在本节中,我们将练习新掌握的技能,看看如何分析一个在运行中被暂停的程序。我们将通过检查其栈内存转储,推断出程序执行的状态和上下文。


我们被告知一个 fact(阶乘)函数的计算正在进行中,并且下一条待执行指令的程序计数器(PC)值为十六进制 40。同时,我们获得了右侧所示的栈内存转储。由于我们正处于 fact 计算过程中,我们知道当前的栈帧(可能还有其他栈帧)是 fact 函数的活动记录

利用上一页幻灯片中的代码,我们可以确定栈帧的布局,并在栈转储的右侧生成相应的标注。

通过标注,可以很容易地看出,当前活动记录中 fact 调用的参数值是 3


上一节我们确定了当前调用的参数,本节中我们来看看如何找到最初调用 fact 时的参数。

我们必须利用每个栈帧中保存的 BP 值来标记其他栈帧。查看每个栈帧中保存的 LP 值(它总是位于该帧 BP 值偏移量为 -8 的位置),我们发现许多保存的值都是 0x40,这必然是递归调用 fact 时的返回地址。

遍历栈帧,我们找到第一个返回值不是 0x40 的帧,这一定是指向 fact 过程外部代码的返回地址。这意味着我们找到了由最初调用 fact 所创建的栈帧,并可以看到最初调用的参数是 6


最初调用 fact 的分支指令位置在哪里?在最初调用 fact 的栈帧中,保存的 LP 值是 0x80。这是最初调用指令之后那条指令的地址,因此,发起最初调用的分支指令地址是 0x7C(即 0x80 的前一条指令)。回答这个问题需要熟练掌握十六进制运算。


下一条即将执行的指令是什么?我们被告知 PC 地址是 0x40。我们注意到,所有递归 fact 调用保存的 LP 值都是 0x40,因此 0x40 必然是 fact 代码中 BRANCH(fact, LP) 指令之后那条指令的地址。回顾之前幻灯片中的 fact 代码,可以确定这条指令是 DEALLOCATE(1)


BP 寄存器的值是多少?我们知道,BP 指向当前栈帧中包含保存的 R1 值的那个栈位置地址。因此,当前栈帧中保存的 BP 值,就是前一个栈帧中保存的 R1 值的地址。通过这个保存的 BP 值,我们可以推导出所有其他栈位置的地址。向前计数,我们发现 BP 的值必须是 0x13C


SP 寄存器的值是多少?由于我们即将执行 DEALLOCATE 指令来从栈中移除嵌套调用的参数,该参数目前仍应位于栈上,紧跟在保存的 R1 值之后。由于 SP 指向第一个未使用的栈位置,它指向那个参数字之后的位置,因此其值为 0x144


最后,R0 寄存器的值是多少?由于我们刚刚从 fact(2) 的递归调用中返回,R0 中的值必须是该递归调用的结果,即 2


通过栈上的活动记录和一些推理,我们可以了解到程序的许多信息。由于计算状态由 PC、寄存器和主内存的值表示,一旦我们获得了这些信息,就能准确推断出程序一直在做什么。

这非常巧妙。


总结与软件约定回顾 📝

本节课中我们一起学习了如何分析栈帧来理解程序状态。现在,让我们总结一下为实现过程调用而采用的软件约定。

我们专门指定了一些寄存器来支持这些约定。首先,R31 被定义为 R0,这是指令集架构(ISA)的一部分。

此外,R30 在 ISA 中被赋予特定功能,我们将在下一讲讨论 Beta 处理器的实现时详细说明。在此之前,请不要在代码中使用 R30

其余专用的寄存器与我们的软件约定相关:

  • R29 作为栈指针(SP)。
  • R28 作为链接指针(LP)。
  • R27 作为基址指针(BP)。

随着你不断练习阅读和编写代码,你会逐渐熟悉这些专用寄存器。


在思考如何实现过程(函数)时,我们发现了对活动记录的需求,它用于存储任何活动过程调用所需的信息。

活动记录在过程调用开始时由调用者和被调用者共同创建,并在过程完成后被丢弃。

活动记录中保存了以下内容:

  • 参数值
  • 保存的 LPBP
  • 调用者保存在其他任何寄存器中的值
  • 过程局部变量的存储空间也分配在活动记录中

我们使用 BP 来指向当前的活动记录,从而可以方便地访问参数和局部变量的值。

我们采用了被调用者保存的约定,即被调用的过程有义务保存除 R0 之外所有寄存器的值。

这些约定共同作用,使我们能够编写具有任意数量参数和局部变量的过程,并支持嵌套和递归的过程调用。

现在,我们已经准备好编译和执行任何 C 语言程序了。


本节课中,我们一起学习了栈帧的结构、如何通过栈内存转储进行“侦探”工作以推断程序状态,并回顾了支撑过程调用的关键软件约定和专用寄存器。这些知识是理解函数调用机制和程序运行时行为的基础。

012:过程与栈的实例分析 🧩

在本节课中,我们将学习过程(函数)在Beta架构上是如何实现的。我们将通过分析一个名为F的“神秘”函数及其对应的Beta汇编代码,来深入理解过程调用、栈帧结构以及递归的实现机制。


过程调用与栈帧结构

为了理解过程在Beta上的实现,我们先来看一个C语言函数及其翻译成的Beta汇编代码。

这个神秘的函数F接收一个参数X。它首先对输入X和常数5进行逻辑与操作,结果存入变量a。然后,它检查x是否等于0,如果是,则返回0;否则,返回一个我们需要确定的未知值。

我们得到了该C代码对应的Beta汇编翻译。接下来,我们将仔细分析代码的各个部分,以理解这个函数以及Beta上过程的一般工作原理。

调用者代码

调用过程的代码负责将所有参数压入栈中。这在代码和栈图中以粉色显示。如果有多个参数,它们会以逆序压栈,这样第一个参数相对于基指针寄存器BP的位置总是固定的。

BR指令在将返回地址(即B)存入链接指针寄存器LP后,跳转到标签F

过程入口序列

在黄色部分,我们看到过程的入口序列。这个入口序列的结构对所有过程都是相同的。

以下是入口序列的步骤:

  1. 压入LP:将LP寄存器压入栈中,紧接在调用者压入的参数之后。
  2. 压入BP:在更新BP之前,将当前的BP寄存器值保存到栈上。
  3. 设置BP:执行MOVE(SP, BP)SP是栈指针,总是指向栈上的下一个空闲位置。执行此指令时,SP指向已保存的BP之后的位置。这条指令使BP指向SP当前指向的同一位置。
  4. 分配局部变量空间:在栈上为局部变量分配空间。此过程为一个局部变量分配了空间。
  5. 保存寄存器:将所有将被过程修改的寄存器压入栈中。这样做可以在过程执行完毕后恢复寄存器的原始值。在本例中,寄存器R1被保存到栈上。

一旦入口序列完成,BP寄存器仍然指向已保存的BP之后的位置。而SP现在则指向已保存的R1寄存器之后的位置。

对于这个过程,在执行入口序列后,栈被修改为如下图所示。

过程返回(出口)序列

所有Beta过程的返回序列都遵循相同的结构。

以下是出口序列的步骤:

  1. 恢复寄存器:恢复过程中使用的所有寄存器的原始值。
  2. 释放局部变量:从栈上释放所有局部变量占用的空间。
  3. 恢复BP:恢复BP寄存器的值。
  4. 恢复LP:恢复LP寄存器的值。
  5. 返回:跳转到LP,其中包含过程的返回地址。

在我们的例子中,LP包含地址B,这是在执行过程F之后应该执行的下一条指令的地址。

具体到我们的例子,出口序列以POP(R1)开始,以恢复寄存器R1的原始值。这也释放了栈上用于存储R1值的空间。

接着,我们通过MOVE(BP, SP)指令释放局部变量空间,这使栈指针指向与基指针相同的位置。

然后,我们恢复BP寄存器。这对于嵌套过程调用尤为重要,如果不恢复,调用过程将无法再依赖其第一个参数位于BP-12位置的事实。

最后,我们恢复LP寄存器并跳转到其指向的地址,即返回地址。


分析“神秘”函数

现在,让我们回到原始过程及其Beta汇编翻译。我们将通过检查汇编代码中剩余的高亮部分,来理解这个神秘函数实际上在做什么。

让我们放大查看高亮代码。

LD指令将第一个参数加载到寄存器R0中。第一个参数总是可以在位置BP-12(即当前BP寄存器前三个字)找到。这意味着值X被加载到R0中。

接着,我们在R0和常数5之间执行二进制与操作,并将结果存储到寄存器R1中。覆盖R1是可以的,因为入口序列已经将原始的R1副本保存到了栈上。覆盖R0也是可以的,因为我们最终期望结果返回到R0中。

回顾我们函数的C代码,我们看到X5的按位与结果被存储到一个名为A的局部变量中。

在我们的入口序列中,我们在栈上为一个局部变量分配了一个字的空间。这就是我们想要存储这个中间结果的地方。这个位置的地址等于BP寄存器的内容。

由于存储操作的目标地址是通过将指令中最后一个寄存器的内容与常数相加来确定的,因此此存储操作的目标地址是BP + 0。所以,正如预期的那样,变量A存储在BP寄存器指向的位置。

现在我们检查x是否等于0,如果是,我们想返回值0。这在Beta汇编中是通过检查R0是否等于0来实现的,因为R0已通过加载操作获得了x的值。BEQ操作检查此条件是否成立,如果成立,则跳转到标签B,即我们的出口序列。在这种情况下,R0已经等于0,所以R0已经包含了正确的返回值,我们准备执行返回序列。

如果x不等于0,那么我们执行标签XX之后的指令。通过弄清楚这些指令的作用,我们可以确定标记为多个问号的神秘函数的值。

我们首先将R01。这意味着R0将被更新为x - 1

然后我们将这个值压入栈中,并对过程F进行递归调用。换句话说,我们再次调用F,新参数等于x - 1

到目前为止,我们知道我们的神秘函数将包含项F(x - 1)

我们还看到LP被更新为一个新的返回地址,即YY + 4

因此,在我们使用新参数x - 1递归调用F之前,我们的栈看起来像这样。

在第一次递归调用中执行过程入口序列后,我们的栈看起来像这样。注意,这次保存的LPYY + 4,因为这是我们递归过程调用的返回地址。之前的BP指向最初调用FBP所指向的位置。

这组栈元素的另一个术语是活动记录。在这个例子中,每个活动记录由五个元素组成:F的参数、保存的LP、保存的BP、局部变量和保存的R1。每次递归调用F时,另一个活动记录将被添加到栈中。

当我们最终从所有这些递归调用返回时,栈上只剩下一个活动记录,以及进行递归调用时的第一个参数。

DEALLOCATE(1)指令然后从栈中移除这个参数。所以栈指针现在指向我们之前压入参数x - 1的位置。

R0保存着递归调用F的返回值,即F(x - 1)的值。

现在我们执行一条加载指令,将地址为BP + 0(即a)的值加载到寄存器R1中。然后将R1加到R0上,在R0中产生最终结果。

R0现在等于a + F(x - 1),所以我们发现我们的神秘函数是a + F(x - 1)



栈跟踪分析

在继续分析这个问题的栈跟踪之前,我们先回答几个更简单的问题。

第一个问题:语句A = X & 5中的变量a是否存储在栈上?如果是,它相对于BP寄存器存储在哪里?

之前我们看到,我们的汇编程序在栈上为一个局部变量分配了空间。然后将R1(保存着x和常数5进行二进制与操作的结果)存储到BP寄存器指向的位置,如图所示。

下一个问题:将标签YY处的指令翻译成二进制表示。

标签YY处的指令是BR(F, LP)。这条指令实际上是一个宏,翻译为BEQ(R31, F, LP)。因为R31总是等于0,所以这个分支总是会被执行。

该指令的二进制表示格式是:6位操作码,后跟5位RC标识符,再后跟5位RA,最后是16位字面量。

  • BEQ的操作码是011100
  • RC等于LP,即寄存器R2828的5位编码是11100
  • RAR31,其编码是11111

现在我们需要确定该指令中字面量的值。

分支指令中的字面量存储的是从分支指令之后的指令到目标地址的偏移量(以字为单位)。

查看我们函数的汇编代码,我们需要计算从DEALLOCATE(1)指令回到标签F的指令数量。注意,PUSHPOP宏实际上各由两条指令组成,因此每个宏算作两个字。计算回去并考虑每个PUSHPOP的两条指令,我们发现需要回溯16条指令。所以我们的字面量是-16,表示为一个16位二进制数。+160000 0000 0001 0000,所以-161111 1111 1111 0000

现在,假设函数F是从一个外部主程序调用的,并且当对F的递归调用即将执行标记为XXBEQ指令时,机器被暂停。被暂停机器的BP寄存器包含0x174,并且内存区域的十六进制内容如下所示。栈左侧的值是栈上每个位置的地址。

我们首先想确定SP(栈指针)寄存器的当前值。

我们被告知,机器在对F的递归调用即将执行标记为XXBEQ指令时被暂停,并且当时BP寄存器是0x174

我们看到,在MOVE操作将BP更新为等于SP之后,栈上又添加了两个条目。第一个是ALLOCATE指令,它为一个局部变量分配了空间,从而使SP指向位置0x178。然后是PUSH(R1),它在栈上保存了R1的副本,从而将栈指针寄存器进一步向下移动到0x17C

我们现在想回答一些关于栈跟踪本身的问题,以帮助我们更好地理解其结构。

第一个问题:确定当前栈帧中局部变量A的值。

我们知道A存储在位置BP + 0。所以A是存储在地址0x174的变量,从图中可知该值是5

从这里开始,我们可以如下标记栈跟踪中的所有条目。我们之前看到,每个活动记录由五个字组成:参数X,后跟保存的LP、保存的BP、局部变量和保存的寄存器R1。我们可以应用这个结构来标记我们的栈跟踪。

现在我们的栈跟踪已完全标记,我们可以更仔细地查看在Beta上实现过程的细节。

我们首先查看栈上存储的多个LP值。注意,地址0x144处的第一个值是0x5C,而接下来的两个值是0xA4。这是因为地址0x144处存储的LP值是最初调用过程F的主过程的返回地址,而接下来的两个LP值是在F函数内部对F进行递归调用的返回地址。

利用这些信息,你现在可以回答这个问题:从外部主程序最初调用F的分支指令的地址是什么?

回想一下,存储在LP寄存器中的值实际上是返回地址,即分支指令之后那条指令的地址。所以如果原始的LP值是0x5C,这意味着分支指令的地址是0x58

我们还可以回答:程序暂停时PC的值是多少?

我们知道程序在即将执行标签XX处的指令时被暂停。我们还知道标签YY处的指令对F进行递归调用。我们知道递归调用的LP值是0xA4。这意味着DEALLOCATE(1)指令的地址是0xA4。向后数4个字节,并考虑到PUSH操作由两条指令组成,我们看到标签XX等于0x90。这就是程序暂停时PC的值。


代码优化分析

作为最后一个问题,我们考虑以下情况:假设你被告知可以从程序中删除四条指令而不影响程序的行为。要删除的四条指令是一条LOAD、一条STORE、一条ALLOCATE和一条MOVE指令。删除这些指令将使我们的程序更短、更快。所以我们的目标是确定这是否可能而不影响程序的行为。

让我们首先考虑删除ALLOCATE指令。如果删除这条指令,意味着我们将不会在栈上为局部变量A保存空间。然而,如果我们仔细观察黄色高亮的三行代码,我们会发现a的实际值首先在R1中计算,然后才存储到局部变量A中。

由于R1将在每次递归调用期间被保存到栈上,我们可以避免将a保存在栈上,因为我们可以从下一个活动记录的已保存R1中找到它的值,如栈上高亮的AR1对所示。

这意味着我们可以安全地删除ALLOCATE指令。因此,这也意味着我们不需要将A存储到栈上的STORE操作,也不需要将A重新加载到寄存器R1LOAD操作。

最后,因为栈上不再存储任何局部变量,那么通常用于释放所有局部变量的指令MOVE(BP, SP)可以被跳过,因为在弹出R1之后,BPSP寄存器已经指向同一位置。

所以,结论是:这四条操作可以从程序中删除,而不会改变代码的行为。


总结

在本节课中,我们一起学习了Beta架构上过程调用的实现细节。我们通过一个具体的“神秘”函数实例,深入剖析了:

  1. 过程调用约定:包括调用者如何传递参数,以及被调用者如何设置栈帧。
  2. 栈帧(活动记录)的结构:它包含了参数、返回地址、旧的基指针、局部变量和保存的寄存器。
  3. 递归的实现:通过栈来保存每一层调用的状态,使得函数能够调用自身。
  4. 汇编代码分析:我们逐条解读了Beta汇编指令,并将其映射回高级语言(C语言)的逻辑。
  5. 栈跟踪分析:通过检查内存快照,我们学会了如何解读运行时栈的内容,并从中提取关键信息,如返回地址、局部变量值等。
  6. 代码优化:我们分析了在特定情况下,如何通过移除不必要的栈操作来优化代码,同时保持程序功能不变。

理解过程与栈的交互是理解计算机如何执行复杂程序的关键一步,它为学习更高级的主题如操作系统和编译器奠定了基础。

013:构建模块 🧱

在本节课中,我们将描述执行 Beta 指令所需的数据通路和控制逻辑。通过理解这些基本构建模块,我们为后续设计和实现一个完整的处理器打下基础。

设计目标 🎯

在开始设计任务之前,理解设计目标非常有用。首要目标当然是实现 Beta 指令集架构所定义的功能,即正确执行指令。

但我们还需要考虑其他目标。一个显而易见的目标是最大化性能,通常以每秒执行的指令数来衡量。性能通常用 MIPS(每秒百万条指令)来表示。例如,1974年推出的 Intel 880 处理器在 Dr. Stone 基准测试中达到了 0.29 MIPS。而现代多核处理器的性能则在 10,000 到 100,000 MIPS 之间。

另一个目标可能是最小化制造成本。在集成电路制造中,成本与电路尺寸成正比。或者,我们可能希望在给定价格下获得最佳性能。在我们日益移动化的世界中,每瓦特的最佳性能可能是一个重要目标。

计算机工程中一个有趣的挑战就是如何平衡性能、成本和能效。显然,Apple Watch 的设计者与高端台式机的设计者有着不同的设计目标。

性能分析 ⚙️

处理器的性能与运行程序所需的时间成反比。执行时间越短,性能越高。

执行时间由三个因素决定:

  1. 程序中的指令数量
  2. 我们的时序电路执行特定指令所需的时钟周期数。复杂的指令(例如,从主存中取两个值相加)可能使程序变短,但也可能需要多个时钟周期来完成必要的内存和数据通路操作。
  3. 每个时钟周期所需的时间,这由我们数据通路中数字逻辑的传播延迟决定。

因此,为了提高性能,我们可以减少要执行的指令数量,或者尝试最小化执行指令平均所需的时钟周期数。显然,前两个选项之间存在权衡:每条指令执行更多计算通常意味着执行该指令需要更多时间。或者,我们可以尝试保持逻辑简单,最小化其传播延迟,以期获得较短的时钟周期。

本节课,我们将专注于实现一个每个时钟周期执行一条指令的 Beta 指令集架构。我们电路中的组合路径会相当长,但正如我们在课程第一部分学到的,这为我们提供了使用流水线来提高实现吞吐量的机会。我们将在后续课程中讨论流水线处理器的实现。

Beta ISA 回顾 📚

以下是 Beta ISA 的快速回顾。Beta 有 32 个 32 位寄存器,用于保存数据通路使用的值。

第一类 ALU 指令,其操作码字段的高两位为 10,对两个寄存器操作数 RARB 执行操作,并将结果存回指定的目标寄存器 RC。指令格式包含一个 6 位的操作码字段来指定操作,以及三个 5 位的寄存器字段来指定用作源和目标的寄存器。

第二类 ALU 立即数指令,其操作码字段的高两位为 11,执行相同的操作集,但第二个操作数是一个在 -32,768+32,767 范围内的常数。操作包括算术运算、比较、布尔运算和移位。在汇编语言中,我们在助记符后添加后缀 C 来表示第二个操作数是常数。

第二种指令格式也用于访问内存和改变正常顺序执行流程的指令。仅使用两种指令格式,将使得构建负责将编码指令转换为控制数据通路操作所需信号的逻辑变得非常容易。事实上,我们将能够直接使用许多指令位。

构建模块 🧩

我们将逐步构建数据通路,从执行 ALU 指令所需的逻辑开始,然后添加额外的逻辑来执行内存和分支指令。最后,我们需要添加逻辑来处理发生异常以及由于当前指令无法正确执行而必须暂停执行的情况。

我们将使用课程第一部分学到的数字逻辑门。特别是,我们需要多比特寄存器来保存指令之间的状态信息。回想一下,这些存储元件在时钟信号的上升沿加载新值,然后存储该值直到下一个上升沿。

我们将在设计中使用大量多路复用器,以在数据通路中的备选值之间进行选择。实际计算将由我们在第一部分末尾设计的算术逻辑单元执行。它包含执行上一张幻灯片所列的算术、比较、布尔和移位操作的逻辑。它接收两个 32 位操作数并产生一个 32 位结果。

最后,我们将使用几种不同的存储组件来实现数据通路中的寄存器存储,以及存储指令和数据的主存。您可能会发现复习课程第一部分关于组合逻辑和时序逻辑的章节很有用。

寄存器文件 📁

Beta ISA 将 32 个 32 位寄存器指定为数据通路的一部分。这些在下图中显示为洋红色矩形。它们被实现为带加载使能的寄存器,具有一个 EN 信号来控制何时用新值加载寄存器。如果 EN1,寄存器将在下一个上升时钟沿从 D 输入加载。如果 EN0,寄存器将重新加载其当前值,因此其值保持不变。

在时钟信号上添加使能逻辑可能看起来更容易,但这几乎从来不是一个好主意,因为该逻辑中的任何毛刺都可能产生错误的边沿,导致寄存器在错误的时间加载新值。请始终记住这个原则:不要使用门控时钟

寄存器下方显示的多路复用器让我们可以从 32 个寄存器中的任何一个选择值。由于数据通路逻辑需要两个操作数,因此有两个这样的多路复用器。它们的选择输入 RA1RA2 作为地址,决定哪些寄存器值将被选为操作数。

最后,有一个解码器,根据 5 位的 WA 输入决定 32 个寄存器加载使能中的哪一个将为 1。为了方便起见,我们将所有这些功能打包到一个名为 寄存器文件 的单一组件中。

寄存器文件有两个读端口,给定一个 5 位地址输入,在读数据端口上传递所选寄存器的值。两个读端口独立工作,它们可以从不同的寄存器读取,或者如果地址相同,则从同一个寄存器读取。

寄存器文件左侧的信号包括一个 5 位值 WA,用于选择要写入指定 32 位写数据 WD 的寄存器。如果写使能信号 WE 在时钟信号上升沿时为 1,则所选寄存器将加载提供的写数据。请注意,在 Beta ISA 中,从寄存器地址 31 读取应始终产生零值。寄存器文件具有内部逻辑来确保这一点。

寄存器文件时序 ⏱️

下图显示了寄存器文件的操作时序。要从寄存器文件读取一个值,需在其中一个读端口上提供一个稳定的地址输入 RA。经过寄存器文件的传播延迟后,所选寄存器的值将出现在相应的读数据端口 RD 上。

寄存器文件的写操作与写入普通 D 寄存器非常相似。写地址 WA、写数据 WD 和写使能 WE 信号都必须在时钟上升沿之前的指定建立时间内有效且稳定,并且必须在时钟上升沿之后的指定保持时间内保持稳定和有效。如果满足这些时间约束,寄存器文件将在时钟上升沿可靠地更新所选寄存器的值。

当寄存器值在时钟上升沿被写入时,如果该值被读地址选中,新数据将在传播延迟后出现在相应的数据端口上。换句话说,如果读地址改变或所选寄存器的值改变,读数据值就会改变。

我们能在单个时钟周期内读写同一个寄存器吗?可以。如果读地址在时钟周期开始时有效,寄存器的旧值将在该周期的剩余时间出现在数据端口上。然后,写操作在周期结束时发生,新的寄存器值将在下一个时钟周期可用。

好的,这就是我们将要使用的组件的简要介绍,让我们开始设计吧。


在本节课中,我们一起学习了构建 Beta 处理器数据通路的基本模块。我们明确了设计目标,分析了影响性能的因素,回顾了 Beta ISA 的指令格式,并详细介绍了寄存器文件这一核心组件的结构、功能和时序特性。这些知识是后续构建完整处理器控制逻辑和数据通路的基础。

014:6.4 ALU指令执行数据通路

在本节课中,我们将学习如何为执行ALU(算术逻辑单元)指令构建数据通路。我们将重点关注处理两个寄存器操作数的指令,并理解从取指到写回的完整执行流程。

概述

我们的首要任务是构建执行ALU指令所需的数据通路逻辑。每条指令都遵循相同的处理步骤。我们将详细探讨这些步骤,并了解如何用硬件实现它们。

取指与译码步骤

上一节我们介绍了指令执行的基本概念,本节中我们来看看取指和译码的具体硬件实现。

当前程序计数器(PC)寄存器的值被送往主存储器,作为要获取指令的地址。对于ALU指令,下一条指令的地址就是当前指令地址加4。我们使用一个专用的加法器来计算PC+4,并将该值送回,作为PC的下一个值。图中还包含了一个多路复用器(Mux),用于在复位信号为1时选择PC的初始值。

经过存储器的传播延迟后,指令位(ID 31 至 0)准备就绪,处理步骤可以开始。部分指令字段可以直接使用,而其他控制信号的值则需要通过操作码(Opcode)字段的位,由一些逻辑电路计算得出。

执行双寄存器操作数ALU指令

现在,让我们填充执行带有两个寄存器操作数的ALU指令所需的数据通路逻辑。

以下是实现该数据通路的关键组件:

  1. 寄存器文件连接:指令中5位的RA、RB和RC字段可以直接连接到寄存器文件的相应地址输入端口。RA和RB字段提供两个读端口的地址,RC字段提供写端口的地址。
  2. ALU操作数:两个读数据端口的输出被路由到ALU的输入,作为两个操作数。
  3. 控制信号生成:ALUFN控制信号告诉ALU执行何种操作。这些信号由控制逻辑根据6位操作码字段确定。具体来说,我们可以假设控制逻辑使用只读存储器(ROM)实现,操作码位作为ROM的地址,ROM的输出就是控制信号。由于有6位操作码,我们需要一个2^6=64个位置的ROM。我们将对ROM的内容进行编程,为64种可能的操作码提供正确的控制信号值。
  4. 结果写回:ALU的输出被路由回寄存器文件的写数据端口,以便在周期结束时写入RC寄存器。我们还需要另一个控制信号Wr(写寄存器文件),当我们想写入RC寄存器时,该信号应设为1。

让我向你介绍Wr,6.004的吉祥物,她当然是以她最喜欢的控制信号命名的,并且她经常提到它。

让我们跟踪执行ADD指令时的数据流。取指后,提供了RA和RB指令字段,RA和RB寄存器的值出现在寄存器文件的读数据端口上。控制逻辑已解码操作码位,并提供了相应的ALU功能码。你可以在Beta图表的右上角找到可能的功能码列表。ALU计算的结果被送回寄存器文件,准备写入RC寄存器。当然,我们需要将Wr设为1以启用写入。

这里我们看到了精简指令集计算机(RISC)架构的主要优势之一:执行所需的数据通路非常直接明了。

执行常量操作数ALU指令

另一种形式的ALU指令使用常量作为第二个ALU操作数。这个32位操作数是通过对指令中字面量字段(位15至0)存储的16位二进制补码常量进行符号扩展而形成的。

为了选择符号扩展后的常量作为第二个操作数,我们在数据通路中添加了一个多路复用器(Mux)。

以下是该数据通路的工作原理:

  1. 操作数选择:当多路复用器的BSel控制信号为0时,选择寄存器文件的输出作为操作数。当BSel为1时,选择符号扩展后的常量作为操作数。
  2. 符号扩展实现:数据通路的其余部分与之前相同。请注意,执行符号扩展不需要逻辑门,全部通过布线完成。要对一个二进制补码数进行符号扩展,我们只需要根据需要复制其高位(即符号位)多次。你可能需要回顾课程第一部分第1讲中关于二进制补码的讨论。
  3. 常量形成:要从一个16位常量形成一个32位操作数,我们只需将其高位(位15)复制16次,然后连接到BSel多路复用器。

在执行带有常量的ALU指令期间,数据流与之前大致相同。唯一的区别是控制逻辑将BSel控制信号设置为1,从而选择符号扩展后的常量作为第二个ALU操作数。和之前一样,控制逻辑生成适当的ALU功能码,ALU的输出被路由到寄存器文件,准备写回RC寄存器。

时钟与执行时序

系统的时钟信号连接到寄存器文件和PC寄存器。在时钟上升沿,执行阶段计算出的新值被写入这些寄存器。因此,时钟上升沿标志着当前指令执行的结束和下一条指令执行的开始。

时钟周期,即两个时钟上升沿之间的时间,需要足够长,以适应实现上述五个步骤的逻辑电路的累积传播延迟。

由于每个时钟周期执行一条指令,时钟频率就告诉了我们指令的执行速率。如果时钟周期是10纳秒,那么时钟频率就是100兆赫,我们的Beta处理器将以100 MIPS(每秒百万条指令)的速度执行指令。

总结

本节课中,我们一起学习了为Beta ISA执行ALU指令构建数据通路。我们详细探讨了取指、译码、读取操作数、执行运算和写回结果这五个步骤的硬件实现。我们看到了如何处理两种类型的ALU操作数:寄存器-寄存器和寄存器-常量。令人惊讶的是,这个数据通路足以执行Beta指令集架构中的大部分指令。我们只剩下存储器和分支指令需要实现,这将是我们的下一个任务。

015:6.2.3 加载与存储指令 🧠

在本节课中,我们将要学习计算机架构中两种访问主内存的关键指令:加载(Load)和存储(Store)。我们将详细探讨它们如何在数据通路中执行,包括地址计算、数据流动以及相关的控制信号设置。


主内存访问概述

加载和存储指令用于访问主内存。需要注意的是,指令和数据存储在同一个主内存中,尽管在数据通路图中,为了方便绘制,我们将其表示为两个独立的方框。

在我们展示的形式中,主内存有三个端口:两个读端口(用于取指令和读取加载数据)和一个写端口(供存储指令用于将数据写入主内存)。

地址计算

地址计算与加法指令(Add C)执行的计算完全相同。RA寄存器的内容会与指令低16位进行符号扩展后的16位字面量相加。

因此,我们将直接复用现有的数据通路硬件来计算地址。

加载指令的执行流程

对于加载指令,算术逻辑单元(ALU)的输出被路由到主内存,作为我们希望访问的内存位置的地址。

经过内存的传播延迟后,该地址位置的内容由内存返回,我们需要将该值路由回寄存器文件,以便写入RC寄存器。

以下是加载指令执行过程中的关键步骤:

  1. 选择操作数:ALU的操作数选择方式与ADC指令相同。
  2. 执行加法:ALU被请求执行加法操作。
  3. 提供地址:ALU的结果连接到主内存的地址端口。
  4. 设置读操作:主内存的控制信号被设置为读操作。
  5. 路由返回数据:WD_sel控制信号被设置为2,以将返回的数据路由到寄存器文件。

内存控制信号

主内存有两个控制信号:

  • MOE(内存输出使能):当我们想从内存读取一个值时,将其设置为1。
  • MWE(内存写使能):当我们希望主内存将写数据(WD)端口上的值存储到寻址的内存位置时,将其设置为1。

写回数据的选择

我们需要添加一个多路复用器(MUX)来选择写回寄存器文件的值:是来自逻辑单元(LU)的输出,还是从主内存返回的数据。

我们使用了一个3选1的多路复用器,当我们实现分支和跳转指令时,会看到另一个MUX输入的用途。

2位的WD_sel信号用于选择写回值的来源。

存储指令的执行流程

存储指令的执行与加载指令非常相似,但多了一个额外的复杂性。

要写入内存的值来自RC寄存器,但RC指令字段并未连接到寄存器文件的读地址。幸运的是,存储指令并未使用RB寄存器地址,因为第二个ALU操作数来自字面量字段。

因此,我们将使用一个MUX,使得RC字段可以被选为寄存器文件第二个读端口的地址。

  • 当RA2_sel控制信号为0时,选择RB字段作为地址。
  • 当RA2_sel为1时,选择RC字段作为地址。

第二个读数据端口的输出连接到主内存的写数据端口。

存储指令是唯一不向寄存器文件写入结果的指令。因此,在执行存储指令时,W_en控制信号将为0。

以下是存储指令执行过程中的数据流动:

  1. 选择操作数:操作数的选择方式与加载指令相同。
  2. 执行地址计算:ALU执行地址计算,结果作为地址发送给主内存。
  3. 提供写数据:同时,RC字段被选为寄存器文件第二个读端口的地址,来自RC寄存器的值成为主内存的写数据。
  4. 触发写入:通过将MWE控制信号设置为1,主内存将在周期结束时将WD数据写入选定的内存位置。

控制信号的“无关”项

在执行存储指令时,W_en控制信号被设置为0,因为我们不会向寄存器文件写入值。既然我们不写入寄存器文件,我们就不关心WD_sel信号的值。

当然,逻辑电路仍需为WD_sel提供一个值。“无关”标注告诉逻辑设计师,她可以提供任何最方便的值。这在用卡诺图优化控制逻辑时特别有用,该值可以选择为0或1,以哪种能最好地简化逻辑方程为准则。


总结

本节课中,我们一起学习了加载(Load)和存储(Store)指令的执行机制。我们了解到:

  • 它们共享相同的地址计算硬件。
  • 加载指令从内存读取数据并写回寄存器文件,涉及MOE信号和WD_sel信号的选择。
  • 存储指令将寄存器数据写入内存,需要额外的多路复用器来选择RC寄存器作为数据源,并激活MWE信号,同时它不写回寄存器文件(W_en=0)。
  • 控制逻辑中存在“无关”项,为逻辑优化提供了空间。

理解这些数据通路细节是掌握CPU如何与内存交互的关键一步。

016:6.4 跳转与分支指令 🚀

在本节课中,我们将学习如何通过跳转和分支指令改变程序的执行顺序。这些指令通过修改程序计数器(PC)的值,使处理器能够执行循环、条件判断和函数调用等复杂操作。


程序计数器与顺序执行

到目前为止,我们介绍的所有指令都是顺序执行的。下一条指令的地址来自当前指令地址之后的位置,这由 PC+4 逻辑实现。

公式:

下一条指令地址 = 当前PC值 + 4

跳转指令

跳转指令通过改变程序计数器(PC)的值来打破顺序执行。它从RA寄存器中取出一个值,并将其设置为下一个PC值。

在数据通路的左上角,PC单元多路选择器 允许控制逻辑选择下一个PC值的来源。

  • PC_Sel 信号为 0 时,选择递增后的PC值(PC+4)。
  • PC_Sel 信号为 2 时,选择RA寄存器的值。

跳转指令还需要将下一条指令的地址(即PC+4值)保存到RC寄存器中。这是通过将 WD_Sel 信号设置为 0,从而选择PC+4值作为写入寄存器文件的数据来实现的。

以下是跳转指令的数据通路工作流程:

  1. PC+4加法器的输出被路由到寄存器文件。
  2. WE 信号被设置为 1,以允许在周期结束时将该值写入RC寄存器。
  3. 同时,从寄存器文件输出的RA寄存器值连接到PC单元多路选择器的“2”号输入。
  4. 设置 PC_Sel2,将选择RA寄存器中的值作为PC的下一个值。
  5. 其余控制信号为“无关”状态,但内存写使能信号 MemW 除外,它必须保持为 0,以防止意外写入内存。


分支指令

上一节我们介绍了无条件跳转,本节中我们来看看条件分支。分支指令需要一个额外的加法器来计算目标地址,方法是将指令字面量字段中的缩放偏移量加到当前的PC+4值上。

公式:

分支目标地址 = (PC + 4) + (符号扩展(指令[15:0]) << 2)
  • 偏移量乘以4(左移2位)是为了将指令中存储的字偏移转换为PC所需的字节偏移
  • 符号扩展通过复制指令第15位的值来实现。

偏移加法器的输出成为PC单元多路选择器的“1”号输入。如果分支条件成立,它将成为PC的下一个值。

我们还需要逻辑来判断是否应该进行分支。连接到寄存器文件第一个读数据端口的32位“或非”门用于测试RA寄存器的值。如果RA寄存器的所有位都为0,则输出 Z 为1,否则为0。

控制逻辑使用 Z 值来确定 PC_Sel 的正确值:

  • 如果 Z 指示分支成立,PC_Sel 将为 1,偏移加法器的输出成为PC的下一个值。
  • 如果分支不成立,PC_Sel 将为 0,执行将在PC+4处的下一条指令继续。

以下是分支指令的数据通路工作流程:

  1. 与跳转指令类似,PC+4值被路由到寄存器文件,以便在周期结束时写入RC寄存器。
  2. 同时,根据RA寄存器的值计算 Z 信号。
  3. 分支偏移加法器计算分支目标地址。
  4. 偏移加法器的输出被路由到PC单元多路选择器。
  5. 控制逻辑根据 Z 值计算出的3位 PC_Sel 控制信号,决定下一个PC值是分支目标还是PC+4。
  6. 其余未使用的控制信号设置为默认的“无关”值。


相对加载指令

我们最后要介绍的一条指令是 LDR(相对加载)指令。LDR的行为类似于普通的加载指令,不同之处在于内存地址取自分支偏移加法器。

为什么从LDR指令附近的位置加载值会有用?通常,这样的地址指的是相邻的指令,那么我们为什么要把指令的二进制编码作为数据加载到寄存器中呢?

LDR的用例是访问那些必须存储在内存中的大常量,因为它们太大,无法放入指令的16位字面量字段。

在所示的例子中,编译后的代码需要加载常量 123456。因此,它使用一条LDR指令,该指令引用一个已用所需值初始化的附近位置 C1。由于这个只读常量是程序的一部分,将其与程序的指令存储在一起(通常在过程代码之后)是合理的。但需要注意,必须小心放置存储位置,以免其被当作指令执行。

为了将偏移加法器的输出路由到主内存地址端口,我们添加了 A_Sel 多路选择器。这样,当 A_Sel 等于 0 时,可以选择RA寄存器值作为ALU的第一个操作数;当 A_Sel 等于 1 时,可以选择偏移加法器的输出。

对于LDR指令,A_Sel 将被设置为 1,然后要求ALU执行布尔运算 A(即输出等于第一个操作数值的函数)。这个值随后出现在ALU输出端,并连接到主内存地址端口。执行的其余部分与普通加载指令完全相同。

这里有一个优化问题:为什么不直接将多路选择器放在通往主内存地址端口的线上,完全绕过ALU?答案与计算内存地址所需的时间有关。如果我们将多路选择器移到那里,加载和存储地址的数据通路将需要经过两个多路选择器(B_Sel 和 A_Sel),这会稍微延迟地址的到达。虽然看似影响不大,但额外的时间必须加到时钟周期中,从而略微减慢每条指令的速度。当执行数十亿条指令时,每条指令上增加的一点时间会严重影响处理器的整体性能。通过将 A_Sel 多路选择器放在我们设计的位置,其传播延迟与 B_Sel 多路选择器重叠,因此它提供的增强功能没有性能代价。

以下是LDR指令的数据通路工作流程:

  1. 偏移加法器的输出通过 A_Sel 多路选择器路由到ALU。
  2. ALU执行布尔运算 A,结果成为主内存的地址。
  3. 返回的数据通过 WD_Sel 多路选择器路由,以便在周期结束时写入RC寄存器。
  4. 其余控制值被赋予其通常的默认值。


总结

本节课中我们一起学习了三种改变程序执行流的指令:

  1. 跳转指令:无条件地将RA寄存器中的值加载到PC中,用于实现函数调用和返回。
  2. 分支指令:根据条件(RA寄存器是否为0)决定是跳转到目标地址还是继续顺序执行,用于实现条件判断和循环。
  3. 相对加载指令:从PC相对地址加载数据,主要用于将存储在代码段附近的大常量加载到寄存器中。

这些指令通过巧妙地复用分支偏移加法器和控制逻辑,扩展了Beta处理器的功能,使其能够支持更复杂的编程结构。

017:异常处理机制 🚨

在本节课中,我们将学习计算机硬件如何处理程序执行过程中出现的意外情况,即“异常”。我们将探讨异常的类型、硬件实现机制以及它们如何帮助程序与操作系统进行交互。


异常与中断概述

上一节我们介绍了指令的正常执行流程。本节中我们来看看当指令无法正常执行时,硬件应如何处理。

例如,如果编程错误导致尝试将某段数据作为指令执行,并且操作码字段不对应任何有效的Beta指令,就会发生所谓的“非法操作”(ILO)。或者,访问的地址可能超出了实际主内存的大小。又或者,某个操作数的值不可接受,例如除法指令的除数B为0。

在现代计算机中,普遍接受的策略是停止当前运行程序的执行,并将控制权转移给特定的错误处理代码。错误处理程序可能会将程序状态保存到磁盘以供后续调试。对于未实现但合法的操作码,它也可能通过软件模拟缺失的指令,然后恢复执行,就像该指令已在硬件中实现一样。

此外,还需要处理与输入/输出相关的外部事件。在这种情况下,我们希望中断当前程序的执行,运行一些代码来处理外部事件,然后恢复执行,就像中断从未发生过一样。

为了处理这些情况,我们将添加硬件,将异常视为对特殊处理代码的“伪造”过程调用。这样安排可以保存被中断程序的PC+4值,以便处理程序在需要时能够恢复执行。

这是一个非常强大的功能,因为它允许我们将控制权转移给软件,以处理我们有限的硬件能力之外的几乎所有情况。正如我们将在课程第三部分看到的,异常硬件将是我们连接运行中的程序与操作系统的关键,并允许操作系统处理外部事件,而运行中的程序对此毫无察觉。


同步异常与异步中断

我们的计划是中断正在运行的程序,其行为就像当前指令实际上是对处理程序代码的一次过程调用。当处理程序执行完毕时,如果合适,它可以使用正常的过程返回序列来恢复用户程序的执行。

我们将使用术语 异常 来指代由执行当前程序引起的异常。这类异常是同步的,因为它们是由执行特定指令触发的。换句话说,如果程序使用相同的数据重新运行,相同的异常会再次发生。

我们将使用术语 中断 来指代由外部事件引起的异步异常,其发生时机与当前运行的程序无关。


硬件实现方案

两种类型异常的实现方式是相同的。当检测到异常时,Beta硬件的行为将如同当前指令是一次跳转:对于同步异常跳转到地址4,对于异步中断则跳转到地址8。可以假定这些地址中的指令会跳转到相应处理程序的入口点。

我们将被中断程序的PC+4值保存到R30,这是一个专用于此目的的寄存器。我们称该寄存器为XP(异常指针),以提醒我们自己它的用途。由于中断尤其可能在程序执行过程中的任何时刻发生,从而随时覆盖XP的内容,因此用户程序不能使用XP寄存器来保存值,因为这些值可能在任何时刻消失。

以下是该方案的工作原理。假设我们的硬件没有实现除法指令,因此它被视为非法操作码。异常硬件会强制进行一次到地址4的过程调用,然后跳转到此处所示的IO处理程序。除法指令的PC+4值已保存在XP寄存器中,因此处理程序可以获取非法指令,并在可能的情况下,通过软件模拟其操作。当处理程序完成后,它可以通过执行 JMP(XP) 来恢复原始程序在除法指令之后继续执行。


数据通路的修改

为了处理异常,我们只需要对数据通路进行一些简单的修改。

我们添加了一个由 WA_SEL 信号控制的多路选择器,用于为寄存器文件选择正确的写回地址。当 WA_SEL 为1时,写回将发生在XP寄存器,即寄存器30。当 WA_SEL 为0时,写回将正常进行,即写入当前指令RC字段指定的寄存器。

PC单元多路选择器的剩余两个输入被设置为异常处理程序的固定地址,在我们的例子中,4用于非法操作,8用于中断。

以下是异常发生时的控制流。被中断指令的PC+4值通过 WD_SEL0 路径被写入XP寄存器。同时,控制逻辑选择3或4作为 PC_SEL 的值,以选择将启动异常处理的适当的下一条指令。其余的控制信号被强制设置为“无关”值,因为我们不再关心完成在本周期开始时已从内存取出的指令的执行。

请注意,被中断的指令并未被执行。因此,如果异常处理程序希望执行被中断的指令,它必须在执行 JMP(XP) 以恢复被中断程序的执行之前,从XP寄存器中的值减去4。


总结

本节课中,我们一起学习了计算机架构中的异常处理机制。我们了解了同步异常(如非法操作)和异步中断(如外部I/O事件)的区别。核心实现方案是通过硬件将控制权强制转移到固定的处理程序地址(如地址4或8),并将返回地址(PC+4)保存到专用的XP寄存器(R30)中。这只需要在数据通路中添加一个多路选择器来控制写回地址,并扩展PC选择逻辑即可实现。这种机制是连接用户程序与操作系统、实现强大系统功能的基础。

018:6.004 2017 课程总结 🎯

在本节课中,我们将回顾并总结用于执行指令和处理异常的最终数据通路设计。我们将分析其组件、控制信号,并将其与现代处理器进行比较。

数据通路回顾 🧩

上一节我们介绍了异常处理的机制,本节中我们来看看完整的、集成了所有功能的数据通路最终设计。

下图展示了执行指令和处理异常的最终数据通路。请花点时间回顾每个数据通路组件的作用,即它为何被添加到数据通路中。同样,您应该理解控制信号如何影响数据通路的操作。

以我的眼光来看,为实现所有这些功能,所需的硬件量似乎非常适中。事实上,它如此精简,以至于我们将在接下来的实验作业中要求您实际完成 Beta 处理器的逻辑设计。

与现代处理器的对比 ⚙️

我们的设计与您用来在线观看本课程的处理器相比如何?

现代处理器拥有许多额外的复杂性以提高性能,例如:

  • 流水线执行
  • 每个周期执行多条指令的能力
  • 复杂的存储系统以降低平均内存访问时间等。

我们将在后续课程中介绍其中一些增强功能。关键在于,Beta 硬件在现代集成电路上可能只占据 1 或 2 平方毫米,而现代英特尔处理器则占据 300 到 600 平方毫米。显然,所有这些额外的电路都有其存在的理由。如果您对此感到好奇,我建议您学习一门关于高级处理器架构的课程。

控制信号汇总 📋

以下是每类指令(包括异常期间和复位期间所需设置)的所有控制信号设置汇总。在可能的情况下,对于不影响特定指令所需数据通路操作的控制信号,我们将其值指定为“无关”。

请注意,内存写使能信号始终有定义值,确保我们只在存储指令期间写入内存。同样,寄存器文件的写使能也有明确定义,除了在复位期间(此时我们假定正在重启处理器,不关心保留任何寄存器值)。

控制逻辑实现 💡

如前所述,通过六位操作码字段索引的只读存储器是生成当前指令相应控制信号的最简单方法。控制逻辑的 ZIRQ 输入会影响控制信号,这可以通过少量逻辑电路来处理 ROM 的输出即可实现。

人们总是可以使用卡诺图来获得乐趣,生成使用普通逻辑门的最小化实现。其结果在面积和传播延迟方面都会小得多,但需要更多的设计工作。我的建议是:从一个 ROM 实现开始,让所有其他部分先工作起来,然后当您想要优化逻辑门时再回过头来改进。

总结与展望 🏁

本节课中我们一起学习了设计一台简单的 32 位计算机硬件所需的内容。当然,我们通过选择简单的二进制指令编码,并将硬件功能限制为高效执行最常见的操作,使这项工作变得容易。不太常见和更复杂的功能可以留给软件处理。

异常机制为我们提供了一个强大的工具,当硬件无法处理任务时,可以将控制权转移给软件。

祝您愉快地完成 Beta 处理器的硬件设计。成千上万的麻省理工学院学生都曾享受过他们的设计首次成功运行时的“是的!”时刻。为了表彰他们的努力,我们奖励他们您在这里看到的“内有 Beta”贴纸,您在校园里走动时可以在笔记本电脑上看到它。

祝您好运。

019:一个更好的Beta处理器 🧠

在本节课程中,我们将学习如何为Beta处理器添加新的指令。我们将分析几种候选指令,并判断实现它们所需的最小硬件或软件改动。具体来说,我们将探讨三种实现方式:使用宏指令、修改控制ROM信号,或者必须进行硬件改动。


指令一:SWAP指令 🔄

上一节我们介绍了评估新指令的基本框架,本节中我们首先来看一个SWAP指令。该指令的目标是在一个时钟周期内交换寄存器RXRY的内容。

核心约束:Beta处理器的硬件无法在同一个时钟周期内向两个不同的寄存器写入数据。因此,仅通过宏指令或修改控制信号无法实现此指令。

结论:要实现SWAP指令,必须对Beta处理器进行硬件改动。


指令二:NEG指令 ➖

接下来,我们考虑添加一个NEG指令。该指令的功能是计算寄存器RX的二进制补码负数,并将结果存入寄存器RY

我们首先需要判断是否能用宏指令实现它。

核心思路:计算一个值的负数,可以通过从0减去该值来实现。在Beta指令集中,我们可以用R31(其值恒为0)减去RX

以下是实现该功能的宏指令代码:

NEG(RX, RY) -> SUB(R31, RX, RY)

注意事项:此宏指令对于最大的可表示负数的特殊情况无效,因为其负数无法用32位二进制补码表示。但对于所有其他情况,该宏指令都能正常工作。


指令三:PC相对存储指令 💾

现在,我们分析一个更复杂的指令:PC相对存储指令(STR)。该指令将寄存器RX的内容写入内存,其目标地址由公式 PC + 4 + 4 * SEXT(C) 计算得出。

由于Beta处理器现有的存储指令使用 RY + SEXT(C) 计算地址,行为不同,因此无法用宏指令实现。

接下来,我们检查是否能在现有数据通路上,通过修改控制ROM信号来实现它。

以下是实现STR指令所需的数据通路和控制信号设置:

  1. 地址计算:指令内存下方的额外加法器用于计算有效地址 PC + 4 + 4 * SEXT(C)。设置 ASEL = 1,将此地址值送入ALU的A操作数端。
  2. ALU功能:设置 ALUFN = A,使ALU直接将A操作数(即计算出的地址)传递到输出端,作为数据内存的地址(MA)。
  3. 写入数据:存储操作中,第一个操作数对应寄存器RC(即RX)。设置 RA2SEL = 1,以选择RC。其值通过寄存器文件的RD2端口输出,成为内存写入数据(MWD)。
  4. 内存控制信号
    • MWR = 1:使能数据内存的写入功能。
    • MOE = 0:禁用内存输出。这允许读写数据共享同一总线(图中未明确显示,但需如此设置)。
  5. 寄存器文件控制:设置 WE = 0,确保不写回寄存器文件。因此,WDSELWASEL可设为无关项(X)。
  6. 其他信号BSEL为无关项,因为此指令中ALU忽略B操作数。PCSEL = 0,使PC正常加4,以获取下一条指令。

最终,STR指令完整的控制ROM信号配置如下:


指令四:位清除指令 🧹

最后,我们考虑添加位清除指令 BITCLR(RX, RY, RZ)。其功能是计算 RY & ~RX 的结果(即RYRX的反码进行按位与),并存入RZ

没有现有的Beta指令能直接完成此功能,因此宏指令不可行。我们需要判断能否通过修改控制ROM,在现有数据通路上实现它。

核心概念:此操作是一个布尔运算。回顾ALU中的逻辑单元(LU),其功能由ALUFN[5:0]控制。当ALUFN[5:4] = 10时,ALU对每对输入位AiBi执行由ALUFN[3:0](即A, B, C, D)定义的布尔函数。

以下是位清除操作的真值表推导:

RXi ~RXi RYi RZi = RYi & ~RXi
0 1 0 0
0 1 1 1
1 0 0 0
1 0 1 0

对比标准LU功能表可知,实现 F = B & ~A 需要设置 A=0, B=1, C=0, D=0。因此,ALUFN[3:0] = 0100

所以,位清除指令的完整ALUFN代码为:ALUFN = 10_0100

以下是实现位清除指令所需的其他控制信号设置:

  1. 操作数选择:指令字指定了寄存器RX(RA), RY(RB), RZ(RC)。设置 RA2SEL=0,从RB(RY)读取第二个操作数。设置 ASEL=0, BSEL=0,将RXRY的值送入ALU。
  2. ALU功能:如上所述,设置 ALUFN = 10_0100
  3. 结果写回:设置 WDSEL=1,将ALU结果送回寄存器文件。RC(RZ)是目标寄存器,因此设置 WASEL=0(选择RC)和 WE=1(使能写入)。
  4. 内存控制:设置 MWR=0,避免写入数据内存。MOE为无关项,WDSEL=1也使得内存读取数据(MRD)被忽略。
  5. PC更新:设置 PCSEL=0,使PC加4。

最终,位清除指令完整的控制ROM信号配置如下:


总结 📚

本节课中我们一起学习了为处理器添加新指令的系统性分析方法。我们通过四个实例了解到:

  1. SWAP指令:需要硬件改动,因为单周期双写寄存器超出当前数据通路能力。
  2. NEG指令:可通过宏指令 SUB(R31, RX, RY) 实现(除边界情况外)。
  3. PC相对存储指令(STR):可通过合理配置现有数据通路(利用额外加法器、设置ASEL=1ALUFN=A等)并修改控制ROM信号来实现,无需硬件改动。
  4. 位清除指令(BITCLR):通过深入理解ALU逻辑单元的功能表,找到对应的ALUFN代码(10_0100),并配置其他控制信号,即可在现有数据通路上实现。

这种方法强调了计算机架构中硬件与软件(微指令)协同设计的核心思想。

020:Beta控制信号实例分析 🧠

在本节课中,我们将通过一个具体的例子,深入理解Beta处理器中各个控制信号的作用。我们将分析一个部分填充的控制信号表,其中包含两条已知的Beta指令和三条需要添加的新指令。我们的任务是推断出已知指令,并为新指令设置正确的控制信号,以实现其预期行为。

实例背景与目标

我们被提供了一个部分填充的控制信号表。表中有五行,对应五条不同的指令。其中两条是现有的Beta指令,我们需要根据已提供的控制信号推断出它们。另外三条是我们计划通过修改控制信号来添加到Beta处理器中的新指令。

以下是我们要添加的三条新指令:

  1. LDX指令:双索引加载指令。其有效地址由两个寄存器(RA和RB)的内容相加得到,而不是像普通加载指令那样将一个寄存器内容与一个常数相加。计算出的内存地址处的数据将被加载到寄存器RC中。最后,程序计数器(PC)加4以指向下一条指令。

    • 公式表示:EA = [RA] + [RB][RC] ← Mem[EA]PC ← PC + 4
  2. MOVZc指令:为零则移动常数指令。如果寄存器RA的内容等于零,则将符号扩展后的字面常量加载到寄存器RC中。随后PC加4。

    • 伪代码描述:if ([RA] == 0) then [RC] ← SignExtend(constant)
  3. STR指令:相对存储指令。其有效地址通过将常数C符号扩展、乘以4,然后与PC+4相加得到。寄存器RC的内容将被存储到这个计算出的有效地址指向的内存位置。最后,PC加4。

    • 公式表示:EA = PC + 4 + 4 * SignExtend(C)Mem[EA] ← [RC]PC ← PC + 4

我们的目标是补全控制信号表中所有标有问号的黄色单元格。

分析已知指令行

首先,我们来看控制表的第一行。这一行中,一个与众不同的值是PCSel,它等于2。对于大多数指令,PCSel等于0;对于分支指令,它等于1;对于跳转指令,它等于2。因此,这一行描述的指令必定是一条跳转指令。

回顾Beta处理器的PCSel控制逻辑:

  • PCSel = 0:顺序执行下一条指令(PC ← PC + 4)。
  • PCSel = 1:执行分支操作(条件跳转)。
  • PCSel = 2:执行跳转操作(无条件跳转),目标地址由JT(跳转目标)指定。

跳转指令(如JMP)的行为如下:

  1. 有效地址通过取寄存器RA的内容并清除最低两位(使其字对齐)得到。
  2. 下一条指令的地址(PC+4)被存入寄存器RC,以备需要返回时使用。
  3. PC被更新为新的有效地址,从而跳转到目标地址继续执行。

为了实现这个数据流,需要设置以下控制信号:

  • WDSel(写数据选择)必须设为0,以选择PC+4作为写入寄存器文件的数据。
  • WE(寄存器文件写使能)必须设为1,以允许写入。
  • WASel(写地址选择)必须设为0,以写入RC寄存器,而非XP寄存器。
  • ASel, BSel, ALUFN 对于跳转指令是无关项(don‘t care),因为ALU未被使用。
  • MOE(内存输出使能)是无关项,因为该指令不使用内存数据。
  • MWR(内存写/读)必须设为0,以确保不会向内存写入任何值。

根据这些分析,我们可以补全第一行中WE等信号的值,它们与跳转指令的数据流图完全吻合。


上一节我们分析了跳转指令对应的控制信号。现在,让我们来看控制表的第二行。

在这一行中,我们看到PCSel等于1。这强烈暗示该行对应的指令是某种分支指令。在Beta的两条分支指令(BEQ和BNE)中,PCSel=1是它们的共同特征。结合上下文,我们可以推断这一行对应的是BEQ(相等则分支)操作。

分支指令与跳转指令类似,同样不使用ALU和内存(仅进行地址计算和比较),并且也会将返回地址(PC+4)存入RC寄存器。因此,与寄存器文件相关的控制信号(WDSel, WE, WASel)设置应与跳转指令相同。同样,必须将MWR设为0以防止写入内存。其他与ALU和内存读取相关的信号(ASel, BSel, ALUFN, MOE)均为无关项。

为新指令设置控制信号

接下来,我们将重点分析需要添加的三条新指令,并推导出它们所需的控制信号设置。

分析LDX指令(双索引加载)

第三行明确告诉我们,它对应的是新添加的LDX指令。我们需要确定缺失的控制信号,以实现其“将RA和RB相加作为地址进行加载”的行为。

LDX指令所需的数据流如下:

  1. 地址计算:需要将寄存器RA和RB的内容相加。因此,ALUFN应设置为加法(ADD)。ASelBSel都应设为0,以将寄存器RA和RB的值送入ALU。
  2. 寄存器读取:为了读取寄存器RB(而非RC)作为第二个操作数,需要将RA2Sel设为0。
  3. 内存读取:ALU的加法结果将作为内存地址(MA)。为了从内存读取数据,需设置MWR=0(读模式),MOE=1(使能内存输出)。
  4. 数据写回:从内存读出的数据(MD)需要写回寄存器文件。因此,WDSel应设为2,以选择内存数据作为写入值。WE应设为1以启用写入,WASel设为0以写入RC寄存器。
  5. PC更新PCSel设为0,使PC加4,指向下一条指令。

根据以上分析,我们可以补全LDX指令行的所有控制信号。


在成功为LDX指令配置了控制信号后,我们来看第四条指令。

分析MOVZc指令(为零则移动常数)

观察第四行,我们发现ALUFN被设置为“B”(即直接输出B操作数),并且WE(寄存器文件写使能)依赖于Z标志位的值。这正是MOVZc指令的特征:当RA等于0时(Z=1),才将一个常数写入RC。

该指令的数据流如下:

  1. 常数输入BSel应设为1,以将符号扩展后的常数字面值作为B操作数送入ALU。
  2. ALU直通ALUFN设为“B”,使常数直接通过ALU到达输出端。
  3. 数据选择与写入WDSel设为1,选择ALU的输出作为写入寄存器文件的数据。WASel设为0,目标寄存器是RC。WE的逻辑由指令语义决定:当Z=1时写入。
  4. 内存与寄存器:由于WDSel不是2,内存数据被忽略,因此MOE是无关项。MWR必须为0,防止内存写入。RA2SelASel也是无关项,因为B操作数来自常数,A操作数被ALU忽略。
  5. PC更新PCSel设为0,顺序执行。

根据这些规则,我们可以填写该行的剩余控制信号。


最后,我们来到控制表的最后一行,它对应我们的第三条新指令。

分析STR指令(相对存储)

最后一行对应的是STR指令(相对存储)。其功能是将寄存器RC的内容存入内存,地址由公式 EA = PC + 4 + 4 * SignExtend(C) 计算得出。

实现此指令的数据流较为特殊:

  1. 地址计算:有效地址由指令存储器下方的专用加法器计算(PC+4+4*C)。这个值需要通过ASel=1的选择器送入ALU的A输入端。
  2. ALU直通地址ALUFN设为“A”,将计算出的有效地址直接作为ALU输出,送至内存地址线(MA)。
  3. 准备存储数据:要存储的数据来自寄存器RC。设置RA2Sel=1,使寄存器文件的第二读端口输出RC的值(RD2)。这个值直接成为内存写数据(MWD)。
  4. 执行存储:设置MWR=1,启用内存写入操作,将MWD的数据存入MA指定的地址。
  5. 寄存器文件:该指令不写寄存器,所以WE=0。因此,WASelWDSel都成为无关项。
  6. PC更新PCSel设为0,PC加4。

依据这个流程,我们可以补全STR指令的所有控制信号。

总结 🎯

本节课中,我们一起完成了一个Beta处理器控制信号的实例分析。我们首先通过PCSel等关键信号推断出了控制表中的两条已知指令(JMP和BEQ)。随后,我们深入分析了三条新指令(LDX, MOVZc, STR)的预期行为,并逐步推导出实现这些行为所必需的控制信号设置。

这个过程清晰地展示了:

  • 控制信号(如ASel, BSel, ALUFN, WDSel, MWR等)如何精确地引导数据在处理器数据通路(寄存器文件、ALU、内存)中流动。
  • 如何根据指令的语义(如“相加”、“判断为零”、“计算相对地址”)来反推所需的控制逻辑。
  • “无关项”的存在,说明了处理器设计中的优化空间,某些信号在不影响指令正确执行时可以忽略。

通过这个练习,我们不仅加深了对Beta单周期数据通路工作原理的理解,也掌握了修改和扩展指令集的基本方法。

021:6.4 内存技术 🧠

在本节课中,我们将要学习计算机系统中使用的不同内存技术。我们将探讨它们各自的特性、优缺点,并理解为什么现代计算机需要构建一个混合的内存层次结构来平衡速度、容量和成本。

在上一讲中,我们完成了Beta精简指令集计算机的设计。Beta ISA的简单组织意味着实现指令所需的电路存在大量共性。最终设计包含几个主要构建模块,以及用于适当选择输入值的多路选择逻辑。

如果我们计算MOSFET数量并考虑传播延迟,会很快发现这里显示为两个黄色组件的三端口主内存是最昂贵的组件,无论是在空间占用还是内存访问所需周期时间的百分比方面。因此,从许多方面来看,我们拥有的其实是一台内存机器,而非计算机器。每条指令的执行都始于从主内存中取指令。最终,CPU处理的所有数据都是从主内存加载或存储到主内存的。只有极少数频繁使用的变量值可以保存在CPU的寄存器文件中。但大多数有趣的程序所操作的数据量,都远超CPU数据路径中可用存储空间所能容纳的范围。事实上,大多数现代计算机的性能都受限于CPU与主内存之间连接的带宽,即所谓的“内存瓶颈”。

本讲的目标是理解这个瓶颈的本质,并探讨我们是否可以通过架构上的改进来尽可能地最小化这个问题。

内存技术概览 📊

我们拥有多种内存技术可供使用,它们在容量、延迟、带宽、能效和成本方面差异巨大。毫不奇怪,我们发现每种技术在我们整个系统架构的不同应用中都有其用武之地。

以下是四种主要内存技术的简要介绍:

  • 寄存器:由时序逻辑构建,提供极低的访问延迟(约20皮秒),但最多只能存储几千位数据。
  • 静态随机存取存储器:旨在为多达数千个存储位置提供较低的访问延迟(几纳秒)。我们已经看到,更多的存储位置意味着更长的访问延迟,这是我们当前内存架构中容量与性能权衡的一个根本体现。这种权衡之所以存在,是因为增加位数会增加内存电路所需的面积,进而导致信号线更长,并且由于电容负载增加而导致电路性能变慢。
  • 动态随机存取存储器:针对容量和低成本进行了优化,但牺牲了访问延迟。在本讲中,我们将看到如何使用SRAM和DRAM来构建一个混合的内存层次结构,以提供较低的平均延迟和高容量,试图兼得两者之长。请注意,“平均”一词已经悄悄进入了性能声明中。这意味着我们将依赖内存访问的统计特性来实现低延迟和高容量的目标。在最坏的情况下,我们仍然会受到SRAM的容量限制和DRAM的长延迟困扰,但我们将努力确保最坏情况不常发生。
  • 闪存和硬盘驱动器:提供非易失性存储。“非易失”意味着即使关闭电源,内存内容也会保留。硬盘位于内存层次结构的底部,以极低的成本提供海量的长期存储。访问延迟有百倍改进的闪存,通常与硬盘驱动器结合使用,其方式类似于SRAM与DRAM的结合使用,换句话说,是为了构建一个具有改进延迟和高容量的非易失性存储混合系统。

接下来,让我们更详细地了解这四种内存技术,然后我们将回到构建内存系统的任务上来。

本节课中我们一起学习了计算机系统中的四种主要内存技术:寄存器、SRAM、DRAM以及以闪存和硬盘为代表的非易失性存储。我们理解了它们之间在速度、容量和成本上的根本权衡,并认识到单一技术无法满足所有需求。因此,现代计算机系统采用混合的内存层次结构,利用不同技术的优势,并依赖程序访问的局部性原理,力求在平均情况下获得接近最快内存的速度和接近最大内存的容量。在接下来的章节中,我们将深入探讨如何具体构建这个层次结构。

022:SRAM 详解 🧠

在本节课中,我们将学习静态随机存取存储器(SRAM)的内部结构与工作原理。SRAM是计算机中用于高速缓存和寄存器文件的关键组件。我们将从SRAM的整体阵列组织开始,深入到其核心存储单元——位元胞的电路设计,并详细解释其读写操作过程。最后,我们会探讨如何为SRAM增加读写端口以及其设计上的权衡。

SRAM 的组织结构 🏗️

SRAM被组织成一个内存位置的阵列。一次内存访问要么读取,要么写入单个位置中的所有比特位。

下图展示了一个包含8个位置、每个位置存储6比特数据的SRAM阵列的组件布局。可以看到,单个位元胞被组织成8行(每行对应一个存储位置)和6列(每列对应存储字中的一个比特)。阵列外围的电路用于地址解码和支持读写操作。

为了访问SRAM,我们需要提供足够的地址位来唯一指定一个位置。在这个例子中,需要3条地址线来选择8个内存位置中的一个。地址解码器逻辑会将8条字线(阵列中的水平线)中的一条置为高电平,以启用特定行位置进行即将到来的访问。其余的字线则被置为低电平,禁用它们控制的元胞。

被激活的字线会启用选中行上的每一个SRAM位元胞,将每个元胞连接到一对位线(阵列中的垂直线)。在读取操作期间,位线将来自启用元胞的模拟信号传送到感测放大器,感测放大器将这些模拟信号转换为数字数据。在写入操作期间,输入的数据被驱动到位线上,以便存储到启用的位元胞中。

更大的SRAM阵列会采用更复杂的组织结构,以最小化位线的长度,从而降低其电容。

核心存储单元:位元胞 ⚡

SRAM的核心是位元胞。一个典型的元胞包含两个CMOS反相器,它们以正反馈回路连接,形成一个双稳态存储元件。

下图展示了两种稳定的配置。在上方的配置中,元胞存储的是比特“1”。在下方的配置中,存储的是比特“0”。只要保持供电,反相器的抗噪声能力就能确保逻辑值得以维持,即使任一反相器的输入端存在电气噪声。

反馈回路的两端通过存取晶体管连接到两条垂直的位线上。当连接到存取晶体管栅极的字线为高电平时,晶体管导通,即在元胞的内部电路和位线之间建立电气连接。当字线为低电平时,存取晶体管关闭,双稳态反馈回路与位线隔离,只要供电,它就能稳定地保持存储的值。

读取操作详解 📖

在读取操作期间,驱动器首先将所有位线预充电至VDD(即逻辑“1”值),然后断开连接,让位线保持在“1”的浮动状态。

接着,地址解码器将其中一条字线置为高电平,将一行元胞连接到各自的位线。选中行中的每个元胞随后会将其两条位线中的一条拉至地(GND)。在这个例子中,是右边的位线被拉低。

由于位线具有很大的总电容,而两个反相器中的MOSFET尺寸很小(以尽可能缩小元胞面积),因此位线上的电压变化很慢。大电容部分来自位线的长度,部分来自同一列中其他元胞的存取晶体管的扩散电容。

SRAM不会等待位线达到有效的逻辑电平,而是使用感测放大器来快速检测两条位线之间产生的微小电压差,并生成相应的数字输出。由于检测电压的微小变化对电气噪声非常敏感,SRAM为每个比特使用一对位线和一个差分感测放大器,以提供更强的抗噪声能力。

可以看到,设计低延迟的SRAM需要大量关于MOSFET模拟行为的专业知识,并需要一些巧思来确保电气噪声不会干扰电路的正确运行。

写入操作详解 ✍️

写入操作首先将位线驱动到适当的值。在所示的例子中,我们想向元胞写入一个比特“0”。因此,左位线被设置为地(GND),右位线被设置为VDD。和之前一样,地址解码器随后将一条字线置为高电平,选中特定行的所有元胞进行写入操作。

驱动器的MOSFET比元胞内反相器的MOSFET大得多。因此,启用元胞的内部信号被强制为位线上的值,双稳态电路翻转到新的稳定配置。这本质上是将驱动器的输出和内部反相器的输出“短路”在一起,所以这是另一个模拟操作。

由于NMOS晶体管通常比相同宽度的PMOS晶体管能承载更高的源漏电流,并且考虑到NMOS存取晶体管的阈值压降,写入工作几乎全部由连接到零值位线的大型NMOS下拉晶体管完成,它能轻松压倒元胞内反相器的小型PMOS上拉晶体管。同样,SRAM设计师需要大量专业知识来正确平衡MOSFET的尺寸,以确保快速可靠的写入操作。

多端口SRAM设计 🔄

为SRAM增加多个读写端口并不困难,这对于寄存器文件电路是一个方便的补充。我们可以通过增加额外的字线组、位线组、驱动器和感测放大器来实现。这将为我们提供多条路径,以独立访问内存阵列中不同行的双稳态存储元件。

对于一个具有N个端口的SRAM,每个比特将需要N条字线、2N条位线和2N个存取晶体管。额外的字线会增加元胞的有效高度,额外的位线会增加元胞的有效宽度,因此所有这些导线所需的面积会迅速主导SRAM的尺寸。

由于在增加端口时,元胞的高度和宽度都会增加,因此总面积随读写端口数量的平方而增长。所以必须注意不要随意增加端口,以免SRAM的成本失控。

总结 📝

本节课中,我们一起学习了SRAM的详细工作原理。

我们了解到,SRAM的电路被组织成一个位元胞阵列,每个内存位置对应一行,每个位置的每个比特对应一列。每个比特由两个连接成双稳态存储元件的反相器存储。读写本质上是通过位线和存取晶体管执行的模拟操作。

SRAM为每个位元胞使用六个MOS晶体管。我们能否做得更好?存储单比特信息所需的最少MOS晶体管数量是多少?这将是后续课程可能探讨的有趣问题。

通过本课的学习,你应该对SRAM的内部结构、读写机制以及设计上的考量有了清晰的认识。

023:6.4.2.3 DRAM

在本节课中,我们将学习动态随机存取存储器(DRAM)的基本原理、内部结构、读写操作及其与SRAM的对比。

概述

上一节我们介绍了SRAM,本节中我们来看看另一种主流的随机存取存储器——DRAM。DRAM以其高存储密度和低成本而闻名,是现代计算机主存的核心技术。我们将从它的基本存储单元开始,逐步了解其工作原理和面临的挑战。

DRAM存储单元

我们需要至少一个MOSFET作为访问晶体管,以便选择哪些位会受到读写操作的影响。

我们可以使用一个简单的电容器进行存储,其中存储位的值由电容器极板间的电压表示。

由此产生的电路被称为动态随机存取存储器,或DRAM单元。

如果电容器电压超过某个阈值,我们存储的是1。否则,我们存储的是0。

电容器的电荷量决定了读取存储值的速度和可靠性,它与电容量成正比。

我们可以通过以下方式增加电容量:增加电容器两极板间绝缘层的介电常数、增加极板面积,或减小极板间的距离。所有这些技术都在不断改进。

上图展示了一个现代DRAM单元的横截面。电容器在一个深沟槽中形成,这个沟槽被蚀刻在集成电路的衬底材料中。增加沟槽深度可以在不增加单元面积的情况下增加电容器极板的面积。

字线构成了N型场效应晶体管(NFET)访问晶体管的栅极,它将电容器的外极板连接到位线。一层非常薄的绝缘层将外极板与内极板隔开,内极板连接到某个参考电压(图中显示为地线)。

你可以搜索“沟槽电容器”以获取有关电容器构造中使用的尺寸和材料的最新信息。

由此产生的电路非常紧凑,每个比特的面积比SRAM比特单元小约20倍。

DRAM的挑战

然而,DRAM也面临一些挑战。没有电路来维持电容器的静态电荷,因此存储的电荷会从电容器的外极板泄漏,故而得名“动态”存储器。

泄漏是由通过PN结流向周围衬底的微小皮安级电流,或访问晶体管即使关闭时也可能存在的亚阈值导通引起的。这限制了我们能让电容器无人照看但仍能读取存储值的时间。

这意味着我们必须安排读取然后重写每个比特单元(称为刷新周期),大约每10毫秒一次,这增加了DRAM接口电路的复杂性。

DRAM读写操作

DRAM的写操作很直接:只需用字线打开访问晶体管,然后通过位线对存储电容器进行充电或放电。

读操作则稍微复杂一些。以下是读操作的步骤:

  1. 首先,位线被预充电到某个中间电压,例如VDD/2,然后预充电电路被断开。
  2. 激活字线,将所选单元的存储电容器连接到位线,导致电容器的电荷与位线电容存储的电荷共享。
  3. 如果单元电容器中存储的值是1,位线电压会略微增加(例如,几十毫伏)。如果存储的值是0,位线电压会略微下降。
  4. 灵敏放大器用于检测这种微小的电压变化,以产生数字输出值。

这意味着读操作会擦除比特单元中存储的信息,因此在读操作结束时,必须用检测到的值重写该单元。

DRAM的组织结构

DRAM电路通常组织成宽行的形式。换句话说,一次访问可以读取多个连续的位置。

这个特定的位置块由DRAM行地址选择。然后,DRAM列地址用于从该块中选择要返回的特定位置。

如果我们想读取同一行中的多个位置,那么我们只需要发送一个新的列地址,DRAM就会返回该位置的数据,而无需再次访问比特单元。

对一行的首次访问具有较长的延迟,但对同一行的后续访问延迟非常低。正如我们将看到的,我们将能够利用快速的列访问来获得优势。

总结

本节课中我们一起学习了DRAM的核心知识。

总结来说,DRAM比特单元由一个访问晶体管连接到一个存储电容器组成,该电容器经过巧妙构造以占用尽可能小的面积。

DRAM在读取后必须重写比特单元的内容,并且每个单元必须定期读取和写入,以确保存储的电荷在泄漏电流破坏之前得到刷新。

由于DRAM比特单元尺寸小,DRAM的容量比SRAM大得多,但DRAM接口电路的复杂性意味着对一行位置的初始访问比SRAM访问慢得多。然而,对同一行的后续访问速度接近SRAM访问速度。

只要电路有电,SRAM和DRAM都会存储数值。但如果电路断电,存储的比特就会丢失。对于长期存储,我们需要使用非易失性存储技术,这将是下一讲的主题。

024:6.4 非易失性存储与存储层次结构的使用

在本节课中,我们将要学习非易失性存储技术,特别是闪存和硬盘驱动器的工作原理与特性。我们还将探讨如何利用由不同速度和容量的存储器组成的层次结构,来构建一个既大又快的存储系统。

非易失性存储器

上一节我们介绍了易失性存储器,本节中我们来看看非易失性存储器。非易失性存储器用于在系统断电时维持系统状态。

在闪存中,长期存储是通过在一个被称为浮栅的绝缘良好的导体上存储电荷来实现的,这些电荷可以稳定保持数年。浮栅被集成在一个标准的MOSFET中,位于MOSFET的栅极和沟道之间。

以下是其工作原理:

  • 如果浮栅上没有存储电荷,MOSFET可以被开启。换句话说,通过在栅极端施加电压 V1,可以形成一个连接MOSFET源极和漏极的反型层,使其导通。
  • 如果浮栅上存储了电荷,则需要一个更高的电压 V2 才能开启MOSFET。通过将栅极电压设置在 V1V2 之间,我们可以测试MOSFET是否导通,从而判断浮栅是否带电。
  • 实际上,如果我们能测量流过MOSFET的电流,就可以确定浮栅上存储了多少电荷。这使得通过改变浮栅上的电荷量,在一个闪存单元中存储多个比特的信息成为可能。

闪存单元可以并联或串联连接,形成类似于CMOS或非门或与非门的电路,从而允许设计出适用于随机访问或顺序访问的各种访问架构。

闪存的密度非常高,接近DRAM的面积密度,尤其是在每个单元存储多位信息时。NOR闪存的读取访问时间与DRAM相似,为几十纳秒。NAND闪存的读取时间则要长得多,大约为10微秒。

所有类型闪存的写入时间都相当长,因为必须使用高电压迫使电子穿过浮栅周围的绝缘屏障。闪存只能写入有限次数,超过后绝缘层会受损,导致浮栅无法可靠存储电荷。目前,保证的写入次数在10万到100万次之间。

为了克服这一限制,闪存芯片内部包含巧妙的地址映射算法,使得对同一地址的连续写入实际上被映射到不同的闪存单元上。总而言之,闪存是一种高性能但高成本的替代方案,用于取代作为长期非易失性存储选择的硬盘驱动器。

硬盘驱动器

了解了基于半导体的闪存后,我们再来看看传统的机械式存储设备。硬盘驱动器包含一个或多个旋转的盘片,盘片表面涂有磁性材料。盘片以每分钟5400到15000转的速度旋转。

一个位于盘片表面上方的读写磁头可以检测或改变下方磁性材料的磁化方向。读写磁头安装在一个致动器上,使其能够定位到不同的圆形磁道上。

以下是读取数据的过程:

  • 要读取特定的数据扇区,磁头必须径向移动到正确的磁道上。
  • 然后等待盘片旋转,直到磁头位于目标扇区上方。

正确放置磁头所需的平均总时间大约为10毫秒,因此硬盘访问时间相当长。然而,一旦读写磁头定位正确,数据可以以每秒100兆字节的可观速率传输。如果每次访问之间都需要重新定位磁头,有效的传输速率会因磁头重新定位的时间而下降1000倍。

硬盘驱动器为太字节级的数据提供了经济高效的非易失性存储,代价是访问速度较慢。

存储技术总结与挑战

至此,我们完成了对存储技术的快速浏览。如果你想了解更多,维基百科上有关于每种设备的有用文章。

SRAM的容量和访问时间一直与集成电路规模和速度的改进保持同步。有趣的是,尽管DRAM和硬盘驱动器的容量和传输速率有所提高,但它们的初始访问时间并没有得到同等快速的改善。值得庆幸的是,在过去十年中,闪存帮助填补了处理器速度和硬盘驱动器之间的性能差距。

但是,处理器周期时间和DRAM访问时间之间的差距继续扩大,这增加了设计低延迟、高容量存储系统的挑战。可用存储技术的容量差异超过10个数量级,延迟差异超过8个数量级。这在如何权衡速度与容量方面构成了相当大的挑战。

存储层次结构中的每一次转换都体现了相同的基本设计选择:我们可以选择更小更快,或者更大更慢。这有点尴尬。实际上,我们能想办法两全其美吗?

构建理想的存储系统

我们希望系统表现得好像拥有一个大、快、便宜的主存储器。显然,使用任何单一的存储技术都无法实现这一目标。那么,我们有一个想法:能否使用一个具有不同权衡特性的层次化存储系统,来达到接近大、快、便宜内存的效果?

以下是可能的思路:

  • 能否将我们经常使用的内存位置存储在SRAM中,使这些访问具有低延迟?
  • 其余的数据能否存储在更大更慢的存储组件中,并在必要时在各级之间移动?

让我们顺着这个思路,看看它会将我们引向何处。

管理存储层次结构的两种方法

有两种方法可以管理存储层次结构。

第一种方法是暴露层次结构,提供一定数量的每种存储类型,让程序员根据特定计算的需求,决定如何最佳地分配各种内存资源。程序将编写代码,在适当时将数据移入快速存储,当不再需要低延迟访问时再移回更大更慢的内存。最快的存储器数量很少,因此随着计算焦点的变化,数据需要不断移动。这种方法有著名的倡导者,其中最有影响力的或许是超级计算机领域的“史蒂夫·乔布斯”——西摩·克雷。他的见解是将数据组织为向量,并在程序控制下将向量移入和移出快速内存。这对于某些类型的科学计算是一个很好的数据抽象。

第二种方法是隐藏层次结构,简单地告诉程序员他们有一个大的、统一的地址空间可以随意使用。存储系统在幕后根据检测到的使用模式,在存储层次结构的各个级别之间移动数据。这需要电路来检查CPU发出的每个内存访问,以确定在层次结构中的何处找到请求的位置。然后,如果某个地址区域被频繁访问(例如在循环中取指令),内存系统会安排将这些访问映射到最快的存储组件,并自动将循环指令移到那里。所有这些机制对程序员都是透明的,程序只需取指令和访问数据,内存系统会处理其余的事情。

克雷对这种自动管理的方法深表怀疑,他有一句名言:“你无法伪装你没有的东西。”难道了解特定程序如何使用数据的程序员,不能通过显式管理存储层次结构来做得更好吗?事实证明,在运行通用程序时,有可能构建一个自动管理的、低延迟、高容量的层次化存储系统,它看起来就像一个大的统一内存。是什么见解使这成为可能?这就是下一节的主题。

本节课中我们一起学习了非易失性存储技术(闪存和硬盘)的特性与局限,理解了不同存储技术在速度、容量和成本上的巨大差异。我们探讨了通过构建存储层次结构来逼近“大、快、便宜”理想内存的目标,并介绍了管理这种层次结构的两种基本方法:由程序员显式管理和由硬件自动管理,为下一节深入探讨自动管理的原理做好了准备。

025:6.4.2.5 局部性原理 🧠

在本节课中,我们将要学习计算机内存系统如何利用局部性原理来高效地组织数据,从而在有限的快速存储资源中,让程序在正确的时间访问到正确的数据。我们将探讨程序访问内存的模式,并理解缓存如何作为内存层次结构的关键部分来提升系统性能。

内存系统的目标 🎯

上一节我们介绍了内存速度与容量之间的矛盾。本节中我们来看看内存系统如何安排,才能在正确的时间将正确的数据放在正确的位置。

我们的目标是将频繁使用的数据存放在快速的SRAM中。这意味着内存系统必须能够预测哪些内存位置将被访问,并且管理数据移入和移出SRAM的开销必须可控。我们希望将数据移动的成本分摊到多次访问中。换句话说,我们希望移入SRAM的任何数据块都能被多次访问。当数据不在SRAM中时,它们会存放在更大、更慢的DRAM中,作为主内存。如果系统按计划工作,DRAM的访问将很少发生,例如,仅在需要将另一个数据块移入SRAM时。

程序访问内存的模式 📊

如果我们观察程序如何访问内存,会发现我们可以准确预测哪些内存位置将被访问。指导原则是访问的局部性,它告诉我们:如果在时间T访问了地址X,那么程序在不久的将来很可能会访问附近的位置。

为了理解程序为何表现出访问局部性,让我们看看一个运行中的程序如何访问内存。

指令获取的局部性

指令获取是相当可预测的。执行通常是顺序进行的,因为大多数情况下,下一条指令是从当前指令之后的位置获取的。循环代码会重复获取相同的指令序列,如时间线左侧所示。当然,分支和子程序调用会中断顺序执行,但之后我们又会从连续的位置获取指令。一些编程结构,例如面向对象语言中的方法分派,可能会产生对非常短的代码序列的分散引用,如时间线右侧所示,但顺序很快会恢复。

这与我们对程序执行的直觉相符。例如,一旦我们执行了某个过程的第一条指令,几乎肯定会执行该过程中的其余指令。因此,如果我们安排在获取过程的第一条指令时,将该过程的所有代码都移动到SRAM中,那么可以预期许多后续的指令获取都可以由SRAM来满足。尽管从DRAM获取一个数据块的首个字有相对较长的延迟,但DRAM快速的列访问将迅速从连续地址流式传输剩余的字。这将把初始访问的成本分摊到整个传输序列中。

数据访问的局部性

过程对其当前栈帧中的参数和局部变量的访问情况类似。同样,在执行过程代码的时间跨度内,会对一小块内存区域进行多次访问。

由加载和存储指令生成的数据访问也表现出局部性。程序可能正在访问对象或结构的组件,或者可能正在遍历数组的元素。有时信息会从一个数组或数据对象移动到另一个,如时间线右侧的数据访问所示。

工作集概念 📈

通过模拟,我们可以估计在特定时间跨度内将被访问的不同位置的数量。当我们这样做时,会发现一个工作集的概念,即被重复访问的位置集合。

如果我们绘制工作集大小作为时间间隔大小的函数,会发现工作集的大小趋于平稳。换句话说,一旦时间间隔达到一定大小,被访问的位置数量大致相同,与间隔发生在何时无关。正如我们在左侧图表中看到的,实际访问的地址会变化,但在时间间隔内不同地址的数量,平均而言,将保持相对恒定,并且出乎意料地,并不那么大。

这意味着,如果我们能安排SRAM足够大以容纳程序的工作集,那么大多数访问都可以由SRAM来满足。我们偶尔需要将新数据移入SRAM,并将旧数据移回DRAM。但DRAM访问的发生频率将低于SRAM访问。我们稍后会进行数学计算,但你可以看到,由于访问局部性,我们有望构建一个由SRAM和DRAM组合而成的内存,其性能像SRAM,但容量像DRAM。

缓存:内存层次结构的关键 🗃️

我们分层内存系统中的SRAM组件称为缓存。它提供对最近访问的数据块的低延迟访问

以下是缓存工作的核心机制:

  • 缓存命中:如果请求的数据在缓存中,则发生缓存命中,数据由SRAM提供。
  • 缓存未命中:如果请求的数据不在缓存中,则发生缓存未命中,包含请求位置的数据块必须从DRAM移入缓存。

局部性原理告诉我们,应该预期缓存命中的发生频率远高于缓存未命中。

现代计算机系统通常使用多级SRAM缓存。最靠近CPU的级别更小,但速度非常快;而离CPU较远的级别更大,因此也更慢。某一级缓存的未命中会触发对下一级的访问,依此类推,直到需要DRAM访问来满足初始请求。

缓存技术在许多应用中被用来加速对频繁访问数据的访问。例如,你的浏览器维护一个常用网页的缓存,如果它确定数据仍然有效,就会使用网页的本地副本,从而避免了通过互联网传输数据的延迟。

现代内存层次结构示例 💻

以下是一个可能出现在现代计算机上的内存层次结构示例:

  • 三级片上SRAM缓存
  • DRAM主内存
  • 用于硬盘驱动器的闪存缓存

编译器负责决定哪些数据值保存在CPU寄存器中,哪些值需要使用加载和存储指令。三级缓存和对DRAM的访问由内存系统中的电路管理。在此之后,访问时间足够长(数百条指令时间),以至于管理层次结构较低级别之间数据移动的工作就交给了软件。

今天我们讨论的是片上缓存如何工作。在本课程的第三部分,我们将讨论软件如何管理主内存和非易失性存储设备。

硬件与软件管理 🔧

无论是硬件管理还是软件管理,内存系统的每一层都旨在为下一较慢层中频繁访问的位置提供更低延迟的访问。正如我们将看到的,在层次结构较慢的层中,实现策略应该有很大不同。

总结 ✨

本节课中我们一起学习了局部性原理及其在计算机内存系统中的关键作用。我们了解到程序对指令和数据的访问在时间和空间上都具有局部性,这形成了工作集的概念。利用这一特性,我们引入了缓存作为快速SRAM,用于存放频繁访问的数据块,从而通过高频率的缓存命中来显著提升内存访问效率,同时将访问较慢DRAM(缓存未命中)的成本分摊。现代系统采用多级缓存构成内存层次结构,共同实现了接近SRAM速度、拥有DRAM容量的高性能内存系统。

026:缓存基础

在本节课中,我们将要学习缓存的基本工作原理、性能指标以及其内部结构。我们将了解处理器如何通过缓存来加速内存访问,并学习如何计算平均内存访问时间。

缓存的工作原理与性能指标

处理器在访问内存时,首先会向缓存发送一个地址。如果请求地址的数据保存在缓存中,数据会被快速返回给CPU。如果请求的数据不在缓存中,则发生缓存缺失。此时,缓存需要向主内存发起请求以获取数据,然后将其返回给处理器。通常,缓存会记住新获取的数据,并可能替换掉缓存中一些较旧的数据。

假设一次缓存访问需要4纳秒,一次主内存访问需要40纳秒。那么,一次缓存命中的访问延迟是4纳秒。而一次缓存缺失的访问延迟则是44纳秒。处理器必须处理这种可变的存储器访问时间,可能的方式是简单地等待访问完成,或者在支持超线程的现代处理器中,可能会执行来自另一个程序线程的一两条指令。

命中率与缺失率

命中率和缺失率分别表示访问中缓存命中和缓存缺失的比例。这两个比率之和为1。利用这些指标,我们可以计算平均内存访问时间。由于我们总是先检查缓存,因此每次访问都包含缓存访问时间,即命中时间。如果缓存缺失,我们还需要额外的主内存访问时间,即缺失惩罚。但主内存访问只发生在部分访问中,缺失率告诉我们这种情况发生的频率。

因此,平均内存访问时间可以使用以下公式计算:

平均访问时间 = 命中时间 + 缺失率 × 缺失惩罚

缺失率越低(或等价地,命中率越高),平均访问时间就越小。我们设计缓存的目标就是实现高命中率。

如果存在多级缓存,我们可以递归地应用该公式来计算内存层次结构中每一级的平均内存访问时间。每一级连续的缓存速度都更慢,即具有更长的命中时间,但由于其容量增大带来的更低缺失率,可以抵消这一劣势。

性能计算示例

让我们尝试一些数字。假设缓存需要4个处理器周期来响应,主内存需要100个周期。在没有缓存的情况下,每次内存访问需要100个周期。在有缓存的情况下,一次缓存命中需要4个周期,一次缓存缺失需要104个周期。

为了使带有缓存的平均内存访问时间达到100个周期(即盈亏平衡点),需要多高的命中率?使用上一张幻灯片的平均内存访问时间公式,我们发现只需要4%的命中率,就能使“缓存+主内存”的内存系统性能与单独使用主内存时一样好。当然,我们的目标是做得比这好得多。

假设我们希望平均内存访问时间为5个周期。显然,大多数访问必须是缓存命中。我们可以使用平均内存访问时间公式来计算所需的命中率。通过计算,我们发现为了实现5个周期的平均访问时间,必须有99%的访问是缓存命中。

在实际运行程序时,我们能期望达到这么好的效果吗?幸运的是,我们可以接近。在对SPEC CPU 2000基准测试的模拟中,一个标准大小的L1缓存在超过10万亿次访问中,测得的命中率为97.5%。

缓存的内部结构

以下是构建缓存的一个起点。缓存将保存许多不同的数据块。目前,我们假设每个数据块是一个单独的内存位置。每个数据块都用一个地址标签来标记。一个数据块及其关联的地址标签的组合被称为一个缓存行。

当从CPU接收到一个地址时,我们将在缓存中搜索具有匹配地址标签的数据块。如果找到匹配的地址标签,则发生缓存命中。对于读访问,我们将从匹配的缓存行返回数据;对于写访问,我们将更新存储在缓存行中的数据,并在某个时刻更新主内存中的对应位置。

如果没有找到匹配的标签,则发生缓存缺失。因此,我们必须选择一个缓存行来保存请求的数据,这意味着之前缓存的某个位置将不再存在于缓存中。对于读操作,我们将从主内存获取请求的数据,将其添加到缓存中,更新缓存行的标签和数据字段,当然,还要将数据返回给CPU。对于写操作,我们将更新所选缓存行中的标签和数据,并在某个时刻更新主内存中的对应位置。

因此,缓存的内容由CPU发出的内存请求决定。如果CPU请求一个最近使用过的地址,那么数据很可能由于之前对同一位置的访问而仍然在缓存中。随着工作集的缓慢变化,缓存内容将根据需要更新。如果整个工作集都能放入缓存,那么大多数请求都会命中,平均内存访问时间将接近缓存访问时间。到目前为止,一切顺利。

当然,我们需要弄清楚如何快速搜索缓存。换句话说,我们需要一种快速的方法来回答“特定的地址标签是否能在某个缓存行中找到”这个问题。这将是我们下一个主题的内容。

总结

本节课中我们一起学习了缓存的基础知识。我们了解了处理器如何通过缓存来减少内存访问延迟,学习了如何使用命中率、缺失率、命中时间和缺失惩罚来计算平均内存访问时间。我们还探讨了缓存的基本内部结构,包括缓存行和地址标签的概念。最后,我们通过计算示例看到了高命中率对于提升系统整体性能的关键作用。

027:6.4.2.7 直接映射缓存 🧠

在本节中,我们将学习计算机缓存中最简单的一种硬件实现——直接映射缓存。我们将了解其基本结构、工作原理以及地址如何映射到特定的缓存行。

概述

最简单的缓存硬件由一个SRAM和一些额外的逻辑电路组成。缓存硬件被设计成使CPU地址空间中的每个内存位置都映射到一个特定的缓存行,因此得名“直接映射缓存”。

当然,内存位置的数量远多于缓存行的数量,因此许多地址会被映射到同一个缓存行,缓存一次只能保存这些地址中一个的数据。

直接映射缓存的工作原理

直接映射缓存的操作是直观的。我们将使用传入地址的一部分作为索引,来选择要搜索的单个缓存行。搜索过程包括将地址的其余部分与所选缓存行的地址标签进行比较。

如果标签与地址匹配,则发生缓存命中,我们可以立即使用缓存行中的数据来满足请求。在这个设计中,我们包含了一个额外的有效位,当标签和数据字段包含有效信息时,该位为1。

当缓存通电时,每个缓存行的有效位被初始化为0,表示所有缓存行为空。当数据被载入缓存,缓存行的标签和数据字段被填充时,有效位被设置为1。CPU可以请求清除特定缓存行的有效位,这被称为刷新缓存。例如,如果CPU发起从磁盘读取的操作,磁盘硬件会将其数据读入主内存的一个块中,那么该块的任何缓存值都将过时。因此,CPU将通过将任何匹配的缓存行标记为无效来从缓存中刷新这些位置。

一个具体示例

让我们通过一个包含8行、每行包含一个单字(4字节数据)的小型直接映射缓存来看看这是如何工作的。假设有一个CPU请求地址为十六进制E8的位置。

由于每个缓存行有4字节数据,地址的最低两位表示缓存字内的适当字节偏移。因为缓存只处理字访问,所以字节偏移位不被使用。

接下来,我们需要使用3位地址位来选择要搜索的8个缓存行中的哪一个。我们从地址的低位中选择这些缓存索引位。为什么?这是因为局部性原理。局部性原理告诉我们,CPU很可能请求附近的地址。为了让缓存表现良好,我们希望安排附近的地址能够同时保存在缓存中。这意味着附近的位置必须映射到不同的缓存行。附近位置的地址在其低位地址位上有所不同,因此我们将使用这些位作为缓存索引位。这样,附近的位置将映射到不同的缓存行。

由缓存行选择的数据、标签和有效位从SRAM中读取。为了完成搜索,我们将地址的剩余部分与缓存的标签字段进行比较。如果它们相等且有效位为1,我们就有了缓存命中,数据字段可以用来满足请求。

为什么标签字段不是32位,既然我们有32位地址?我们本可以那样做,但由于存储在缓存行2中的所有值都具有相同的索引位(010),我们节省了一些SRAM位,并选择不在标签中保存这些位。换句话说,使用SRAM来保存我们可以从传入地址生成的位是没有意义的。

因此,本例中的缓存硬件是一个8行 x 60位的SRAM,外加一个27位比较器和一个与门。缓存访问时间是SRAM的访问时间,加上比较器和与门的传播延迟。这几乎是我们能期望的最简单和最快的了。这种简单性的缺点是,对于每个CPU请求,我们只查看一个缓存位置,以查看缓存是否持有所需的数据。这算不上什么搜索,对吧?但地址到缓存行的映射在这里对我们有帮助。使用低位地址位作为缓存索引,我们安排了附近的位置映射到不同的缓存行。例如,如果CPU正在执行一个8条指令的循环,所有8条指令都可以同时保存在缓存中。更复杂的搜索机制也无法改进这一点。归根结底,这种极其简单的搜索足以在我们关心的情况下获得良好的缓存命中率。

更多示例

现在让我们尝试更多示例,这次使用一个包含64行的直接映射缓存。

假设缓存收到一个对位置400C的读取请求。为了了解请求是如何处理的,我们首先将地址写成二进制,以便轻松地将其划分为偏移、索引和标签字段。

对于这个地址,偏移位的值为0。缓存行索引位的值为3,标签位的值为40。因此,将缓存行3的标签字段与地址的标签字段进行比较,由于匹配,我们发生了缓存命中,缓存行数据字段中的值可以用来满足请求。

访问位置4008会是缓存命中吗?这个地址与第一个例子类似,只是缓存行索引现在是2而不是3。查看缓存行2,我们发现其标签字段58与地址中的标签字段40不匹配,因此此访问将是缓存未命中

那么,保存在缓存行0、1和2中的字的地址是什么?它们都有相同的标签字段。我们可以反向运行地址匹配过程。对于一个要与这三个缓存行匹配的地址,它必须看起来像这里显示的二进制数,我们使用缓存标签字段中的信息来填充高位地址位,低位地址位将来自索引值。如果我们填入索引0、1和2,然后将得到的二进制数转换为十六进制,我们得到5800、5804和5808,分别作为缓存行0、1和2中数据的地址。

请注意,缓存位置的完整地址是通过将缓存行的标签字段与缓存行的索引组合而成的。当然,我们需要能够从缓存中保存的信息中恢复完整地址,以便可以正确地与来自CPU的地址请求进行比较。

总结

在本节中,我们一起学习了直接映射缓存的基本概念。我们了解到,直接映射缓存通过将内存地址划分为标签、索引和偏移字段,将每个地址唯一映射到一个特定的缓存行。其硬件实现简单高效,利用地址低位作为索引,有助于利用空间局部性,使附近的地址映射到不同的缓存行,从而在循环等常见场景中获得良好的性能。虽然每次只搜索一个缓存位置,但这种简单的设计对于许多实际应用来说已经足够有效。

028:6.4 块大小与缓存冲突 🧠

在本节课中,我们将要学习如何通过调整直接映射缓存的块大小来利用程序的局部性,并理解块大小如何影响缓存性能。我们还将探讨直接映射缓存的一个关键弱点——冲突未命中,以及它为何会导致性能问题。

块大小:利用空间局部性

上一节我们介绍了直接映射缓存的基本结构。本节中我们来看看如何通过调整块大小来优化缓存设计。

我们可以对直接映射缓存的设计进行微调,以利用空间局部性,并节省标签字段和有效位带来的开销。具体方法是增加缓存中数据字段的大小,从单个字增加到两个字、四个字等。每个缓存行中的数据字数被称为块大小,它通常是2的幂次方。

使用更大的块大小是有意义的。如果访问相邻字(例如数组中的连续元素)的概率很高,那么在发生缓存未命中时,为什么不一次性获取一个更大的数据块呢?这实际上是在用增加一次未命中的代价,来换取未来更高的命中概率。

以下是两种不同块大小的16字直接映射缓存的比较:

  • 块大小为4的缓存:对于每128位数据,有27位的标签和有效位开销,因此SRAM位的开销占比为 17%
  • 块大小为1的缓存:对于每32位数据,有27位的标签和有效位开销,因此SRAM位的开销占比高达 46%

由此可见,更大的块大小意味着我们能更高效地使用SRAM芯片,因为用于存储数据的比例更高了。

块大小与地址字段划分

当块大小增加时,CPU地址的划分方式也会随之改变。以一个块大小为4(即16字节)的缓存为例:

  • 偏移量:由于每个缓存行有16字节数据,现在需要 4个偏移位。缓存使用偏移量的高2位来选择在缓存命中时,将四个字中的哪一个返回给CPU。
  • 索引:假设缓存有4行,则需要从传入地址中取出 2个缓存行索引位
  • 标签:剩下的 26个地址位 被用作标签字段。

需要注意的是,每个缓存行只有一个有效位。这意味着要么整个数据块都在缓存中,要么都不在。支持缓存部分数据块会带来额外的复杂性,这通常不值得。因为局部性原理告诉我们,在不久的将来很可能需要该块中的其他字,所以将它们预取到缓存中很可能会提高命中率。

块大小与性能的权衡

我们已经论证了将块大小从1增加是一个好主意。那么,块大小是否越大越好呢?让我们来看看增加块大小的成本和收益。

  • 成本(未命中惩罚增加):块越大,发生缓存未命中时需要从主存获取的字就越多。因此,未命中惩罚随块大小线性增长。不过,由于从DRAM获取第一个字的访问时间本身就很长,增加的惩罚可能没有想象中那么痛苦。
  • 收益(未命中率降低):将块大小增加到1以上可以降低未命中率,因为我们把后续访问可能会命中的字提前取到了缓存中。
  • 潜在风险(缓存行数减少):假设不增加缓存的总容量,增加块大小就意味着要相应减少缓存行的数量。减少行数会影响缓存能够容纳的不同地址块的数量。正如在讨论程序工作集大小时所看到的,我们需要容纳一定数量的独立区域(如代码、栈、数据等)才能实现高命中率。因此,我们需要确保有足够数量的块来容纳工作集中的不同地址。

结论是,存在一个最佳的块大小,能够最小化平均内存访问时间。超过这个点再增加块大小可能会适得其反。结合未命中率和未命中惩罚随块大小变化的曲线,我们可以使用平均内存访问时间(AMAT)公式来选择能提供最佳性能的块大小。

平均内存访问时间公式AMAT = 命中时间 + 未命中率 × 未命中惩罚

在现代处理器中,常见的块大小是 64字节(或16个字)。

直接映射缓存的弱点:冲突未命中

直接映射缓存有一个致命的弱点。考虑运行一个包含三条指令的循环代码,指令从字地址1024开始,数据从字地址37开始。程序交替访问指令和数据(例如,一个加载指令循环)。

假设有一个1024行、块大小为1的直接映射缓存。一旦所有六个位置(三条指令和三个数据)都被加载到缓存中,稳态命中率将是 100%,因为每个地址都被映射到不同的缓存行。

现在考虑执行相同的程序,但这次数据被重新定位到从字地址2048开始。问题出现了:现在指令和数据开始竞争使用相同的缓存行。例如,地址1024处的第一条指令和地址2048处的第一个数据字都映射到缓存行0,因此同一时间只能有一个在缓存中。

  • 获取第一条指令会用地址1024的内容填充缓存行0。
  • 但随后的第一次数据访问会未命中,然后用地址2048的内容重新填充缓存行0,覆盖掉之前的指令。
  • 我们说数据地址与指令地址发生了冲突
  • 下一次循环时,第一条指令已不在缓存中,对其的访问将导致一次缓存未命中,这被称为冲突未命中

在稳态下,缓存中将永远不会包含CPU当前请求的那个字。这非常糟糕。我们希望设计一个能提供平坦、统一地址空间抽象的内存系统,但在这个例子中,仅仅改变几个地址就导致缓存命中率从 100% 暴跌至 0%。程序员肯定会注意到她的程序运行速度慢了十倍。

因此,虽然我们喜欢直接映射缓存的简单性,但需要做出一些架构上的改变,以避免由冲突未命中引起的性能问题。

总结

本节课中我们一起学习了缓存设计中块大小的概念及其影响。我们了解到,增加块大小可以更好地利用空间局部性,降低SRAM开销,并可能降低未命中率,但也会增加未命中惩罚并减少缓存行数,因此存在一个最佳平衡点。此外,我们还探讨了直接映射缓存固有的冲突未命中问题,当多个频繁访问的数据项被映射到同一个缓存行时,会导致性能急剧下降,这是直接映射缓存架构需要解决的关键挑战。

029:6.4 关联缓存 🧠

在本节课中,我们将要学习关联缓存的工作原理,包括全关联缓存和组相联缓存。我们将了解它们如何解决直接映射缓存中的地址冲突问题,并探讨其性能与成本之间的权衡。


全关联缓存

上一节我们介绍了直接映射缓存,本节中我们来看看全关联缓存。

全关联缓存为每个缓存行配备一个标签比较器。因此,传入地址的标签字段会与全关联缓存中每一个缓存行的标签字段进行比较。

由于会搜索所有缓存行,特定的内存位置可以存放在任何缓存行中。这消除了因地址冲突导致冲突未命中的问题。

这里展示的缓存行可以存放四个不同的内存块,无论它们的地址是什么。上一节末尾的例子需要一个能存放两个三字块的缓存,一个用于循环指令,一个用于数据字。这个全关联缓存将使用其两个缓存行来完成此任务,并实现100%的命中率,无论指令块和数据块的地址如何。

全关联缓存非常灵活,对于大多数应用具有很高的命中率。其唯一的缺点是成本。为每个缓存行配备标签比较器以实现标签匹配的并行搜索,当缓存行数量很多时,会显著增加所需的电路数量。即使使用称为内容可寻址存储器的混合存储比较电路,也无法大幅降低全关联缓存的总体成本。


组相联缓存

直接映射缓存只搜索单个缓存行。全关联缓存搜索所有缓存行。是否存在一个折中的方案,可以并行搜索少量缓存行呢?

答案是肯定的。如果你仔细观察这里展示的全关联缓存图,会发现它看起来像四个单行直接映射缓存在并行工作。如果我们设计一个由四个多行直接映射缓存并行工作的缓存,结果会怎样?

结果将是我们所称的四路组相联缓存

一个 N路组相联缓存 本质上就是 N个直接映射缓存(我们称之为子缓存)并行工作。每个子缓存将传入地址的标签字段与由地址索引位选中的缓存行的标签字段进行比较。在一次特定请求中被搜索的N个缓存行构成了一个搜索组,所需的位置可能存放在该组中的任何一个成员里。

这里展示的四路组相联缓存,每个子缓存有8个缓存行。因此,每个组包含4个缓存行(每个子缓存一个),总共有8个组(每个子缓存行对应一个组)。一个N路组相联缓存最多可以容纳N个地址映射到相同缓存索引的内存块。因此,最多N个具有冲突地址的块访问仍可在此缓存中得到满足而不会产生未命中。

与直接映射缓存相比,这是一个巨大的改进,因为在直接映射缓存中,地址冲突将导致当前缓存行的内容被驱逐,以容纳新的请求。同时,一个N路组相联缓存可以拥有非常多的缓存行,但只需承担N个标签比较器的成本。与全关联缓存相比,这也是一个巨大的改进,因为全关联缓存中大量的缓存行需要大量的比较器。

因此,N路组相联缓存是易于冲突的直接映射缓存与灵活但非常昂贵的全关联缓存之间的一个良好折中方案。

这是一个稍微更详细的示意图,展示的是一个三路八组缓存。请注意,路的数量并不要求是2的幂次,因为我们没有使用任何地址位来选择特定的路。这意味着缓存设计者可以微调缓存容量以满足其空间预算。

回顾一下术语:对于特定缓存索引将被搜索的N个缓存行称为一个,而每个N个子缓存称为一。每一路的命中逻辑与其他路的逻辑并行运行。

一个特定的地址是否可能被多于一路匹配?硬件并不排除这种可能性,但组相联缓存的管理方式确保了这种情况不会发生。假设我们从DRAM获取的数据在缓存未命中时只写入一个子缓存(我们稍后会讨论如何选择这一路),那么就不可能出现多个子缓存同时匹配一个传入地址的情况。


我们需要多少路?

我们希望有足够的路数来避免直接映射缓存中遇到的缓存行冲突。观察我们之前看到的内存访问与时间关系图,我们会发现在任何时间间隔内,只有一定数量的潜在地址冲突需要我们担心。从地址到缓存行的映射设计旨在避免相邻位置之间的冲突,因此我们只需要担心不同区域(代码、堆栈和数据)之间的冲突。

在所示的例子中,有三个这样的区域,或者如果你需要支持从一个数据区域复制到另一个数据区域,则可能需要两个数据区域。如果时间间隔特别大,我们可能需要两倍于这个数量的路数,以避免时间间隔早期访问与晚期访问之间的冲突。关键是,少量的路数就足以避免缓存中的大多数缓存行冲突。


路数的权衡

与块大小一样,过犹不及。存在一个最佳路数,可以最小化平均内存访问时间。超过这个点,组合大量路数的命中信号所需的额外电路将开始产生显著的传播延迟,这会直接增加缓存命中时间和平均内存访问时间。更重要的是,左边的图表显示,超过4到8路后,对未命中率的影响微乎其微。

对于大多数程序,具有大量组的八路组相联缓存的性能,与昂贵得多的同等容量全关联缓存相当。


替换策略

对于组相联和全关联缓存,还有一个最终问题需要解决:当发生缓存未命中时,应该选择哪个缓存行来存放将从主存获取的数据?这对于直接映射缓存不是问题,因为每个数据块只能存放在由其地址决定的特定缓存行中。但在N路组相联缓存中,有N个可能的缓存行可供选择(每路一个)。在全关联缓存中,可以选择任何缓存行。那么,如何选择?

我们的目标是选择替换那个对未来命中率影响最小的缓存行内容。

最优的选择是替换在未来最久不会被访问(或者可能永远不会再被访问)的块,但这需要预知未来。这里有一个思路:通过观察最近的访问并应用局部性原理来预测未来的访问。如果一个块最近没有被访问过,那么它在不久的将来被访问的可能性就更小。

这引出了最近最少使用替换策略,通常称为 LRU。我们替换在过去最久未被访问的块。LRU在实践中效果很好,但要求我们为每组缓存行维护一个按最后使用时间排序的列表,并且需要在每次缓存访问时更新这个列表。

当我们需要选择替换组中的哪个成员时,我们会选择这个列表上最后一个缓存行。对于一个八路组相联缓存,有 8!(8的阶乘)种可能的排序,因此我们需要 log₂(8!)16个状态位 来编码当前的排序。在每次访问时更新这些状态位的逻辑并不便宜。基本上,你需要一个查找表来将当前的16位值映射到下一个16位值。

因此,大多数缓存实现的是LRU的近似算法,其更新函数计算起来要简单得多。

还有其他可能的替换策略:

  • 先进先出:替换最旧的缓存行,无论它最后一次被访问是什么时候。
  • 随机:使用某种伪随机数生成器来选择替换对象。

除了随机策略外,所有替换策略都可能被“击败”。如果你知道缓存的替换策略,你可以设计一个程序,通过访问你知道缓存刚刚替换掉的地址,从而获得极差的命中率。虽然我们可能不关心一个旨在获得糟糕性能的程序在系统上运行得如何,但重点是,大多数替换策略偶尔会导致特定程序的执行速度比预期慢得多。

总而言之,LRU替换策略或其近似方案是一个合理的选择。


总结

本节课中我们一起学习了关联缓存的核心概念。我们了解到:

  • 全关联缓存 高度灵活,命中率高,但硬件成本(比较器数量)也最高。
  • 组相联缓存(特别是 N路组相联)是直接映射缓存和全关联缓存之间的一个优秀折中。它通过并行搜索N个(路)缓存行来减少冲突未命中,同时将硬件成本控制在N个比较器。
  • 对于大多数程序,4到8路的组相联缓存性能已接近全关联缓存,是实践中的常见选择。
  • 当缓存未命中需要替换数据时,LRU(最近最少使用) 或其近似算法是常用的替换策略,旨在根据访问的局部性原理保留最可能被再次访问的数据。

通过理解这些缓存组织方式及其权衡,我们可以更好地理解计算机系统如何高效地管理内存访问。

030:6.4.2.10 写策略 🖊️

在本节课中,我们将探讨缓存设计的最后一个关键决策:如何处理内存写操作。我们将介绍几种不同的写策略,并分析它们如何影响系统性能和内存带宽的利用。


概述

缓存的设计目标是减少平均内存访问时间。除了处理读操作(缓存命中与缺失),系统还必须高效地处理来自CPU的写操作。写策略决定了缓存何时以及如何将更新后的数据同步回主内存。选择正确的策略对于平衡性能和内存带宽至关重要。

上一节我们讨论了缓存的替换策略,本节中我们来看看如何处理写操作。

写策略的类型

当CPU向缓存发出写请求时,缓存需要决定何时将数据更新到主内存。以下是三种主要的策略。

写直达

最直接的选择是立即执行写操作。每当CPU向缓存发送写请求时,缓存会同时向主内存执行相同的写操作。这被称为写直达

  • 优点:主内存始终拥有所有位置的最新值,数据一致性简单。
  • 缺点:如果CPU必须等待缓慢的DRAM写访问完成,这会成为性能瓶颈。如果程序频繁写入某个特定内存位置(例如更新栈帧中的局部变量),反复写入主内存会浪费内存带宽。

写回

更好的策略是写回。在这种策略下,缓存内容会立即更新,CPU无需等待即可继续执行。更新后的缓存值仅当该缓存行因缓存缺失而被选中替换时,才会被写回主内存。

  • 优点:最大限度地减少了对主内存的访问次数,为其他操作保留了内存带宽。这是大多数现代处理器采用的策略。
  • 挑战:替换缓存行变得更为复杂,因为如果该行曾被修改过,在重用前必须将其内容写回主内存。

为了高效实现写回策略,我们需要引入一个额外的状态位。

脏位

我们可以通过为每个缓存行添加一个脏位来避免不必要的写回操作。

  • 当缓存行因缺失而被填充时,其脏位设置为 0
  • 如果后续的写操作更改了缓存行中的数据,脏位被设置为 1,表明缓存中的值已与主内存中的值不同。
  • 当选择一个缓存行进行替换时,仅当其脏位为 1 时,才需要将其数据写回主内存。

因此,带脏位的写回策略提供了一个优雅的解决方案:它最小化了写入主内存的次数,并且仅在需要将脏缓存行写回内存时,才会因缓存缺失而延迟CPU。


缓存设计策略总结

我们对缓存的讨论至此结束。我们构建分层内存系统的动机是为了通过兼具低延迟和高容量的系统来最小化平均内存访问时间。

我们采用了多种策略来实现这一目标,以下是核心策略回顾:

  1. 增加缓存行数:通过降低缺失率来减少平均内存访问时间。
    • 公式平均访问时间 = 命中时间 + 缺失率 × 缺失代价
  2. 增加块大小:利用DRAM的快速列访问,在缓存缺失时高效加载整块数据。期望未来访问附近位置时能提高命中率。
  3. 增加路数:减少缓存行冲突的可能性,从而降低缺失率。
  4. 使用LRU替换策略:选择最近最少使用的缓存行进行替换,以最小化替换操作对命中率的影响。
  5. 采用带脏位的写回策略:处理写操作,最小化对主内存的写入。

我们如何在所有这些架构选择之间进行权衡?与往常一样,我们将模拟不同的缓存组织结构,并选择在基准测试程序上能提供最佳性能的架构组合。


总结

本节课中我们一起学习了缓存设计的核心写策略。我们比较了写直达写回以及通过引入脏位来优化写回策略的方法。理解这些策略有助于我们设计出能够有效平衡速度、容量和带宽利用的内存层次结构,这是构建高效计算机系统的关键一环。

031:6.4.11 缓存性能分析实例 🧠

在本节课程中,我们将通过一个基准测试程序,比较三种不同缓存配置的行为,以深入理解缓存配置对程序性能的影响。我们将分析直接映射缓存、两路组相联缓存和四路组相联缓存在不同访问模式下的命中率表现。

缓存配置概述

我们将要比较的三种缓存配置如下:

  • 第一种:一个具有64行的直接映射缓存。
  • 第二种:一个使用LRU(最近最少使用)替换策略的两路组相联缓存,总容量同样为64行。
  • 第三种:一个使用LRU替换策略的四路组相联缓存,总容量也是64行。

请注意,这三种缓存的总容量相同,都能存储64个字的数据。

缓存寻址机制详解

上一节我们介绍了三种缓存类型,本节中我们来看看它们具体的寻址方式。

直接映射缓存

在直接映射缓存中,任何一个特定的内存地址都精确地映射到缓存中的一行。

假设我们的数据宽度是32位(4字节)。这意味着连续的地址之间相隔4字节。因此,我们将地址的最低两位视为总是00,以确保地址位于数据字边界上。

接下来,我们需要确定这个特定地址映射到哪一行缓存。由于缓存有64行,我们需要6位地址来选择其中一行。这6位被称为索引。在这个例子中,索引是000011,所以这个地址映射到缓存的第3行。

存储在缓存中的数据是地址的标签部分加上32位数据。标签用于在检查特定地址是否在缓存中时进行比较,它唯一地标识了特定的内存地址。此外,每一行缓存都有一个有效位,用于指示该行中的数据当前是否有效。这在系统启动时很重要,因为如果没有有效位,就无法区分缓存中的数据是垃圾数据还是真实数据。

两路组相联缓存

在两路组相联缓存中,缓存被分为两组,每组包含一半数量的行。因此,我们有两组,每组32行。

由于现在只有32行,我们只需要一个5位的索引来选择行。然而,任何给定的索引可以映射到两个不同的位置,每组中各一个。这也意味着在进行标签比较时,需要进行两次比较,每组一次。

四路组相联缓存

在四路组相联缓存中,缓存被分为四组,每组16行。索引的宽度现在是4位,用于选择缓存行。在这里,选择一个行标识了四个可能的位置之一,用于读取或写入相关数据。这也意味着,当尝试从缓存读取以判断所需地址是否在缓存中时,需要比较四个标签。

基准测试程序分析

现在我们已经了解了三种缓存的配置,接下来看看我们将要运行的基准测试程序的行为。

测试程序首先定义了几个常量:JABN

  • J 指定了程序代码所在的地址。
  • A 是数据区域1的起始地址。
  • B 是数据区域2的起始地址。
  • N 指定了数据区域的大小。

由于一个字由4字节组成,N=16意味着每个数据区域有4个数据元素。

接着,汇编器被告知程序的起始地址是0x1000。绿色矩形标识了代码的外层循环,黄色矩形标识了内层循环。

在进入外层循环之前,存储在寄存器R6中的循环计数器被初始化为1000。然后,每次执行外层循环时,R6减1,只要R6不等于0,循环就重复。外层循环每次还会将R0重置为NR0用于保存所需的数组偏移量。由于数组的最后一个元素存储在地址N-4处,内层循环的第一步是将R0减4。

然后,R1被加载地址A + R0处的值,即A[3](因为数组索引从0开始)。R2被加载B[3]的值。只要R0不等于0,循环就会重复,每次访问每个数组的前一个元素,直到加载第一个元素A[0]。然后外层循环递减R6,并将整个过程重复1000次。

场景一:不同内存区域的访问

理解了三种缓存的配置和测试程序的行为后,我们现在可以开始比较该程序在三种缓存上的行为了。

我们首先要问自己的是,这三种缓存配置中,哪一种能获得最高的命中率?这里我们不需要计算实际的命中率,而是需要认识到在这个基准测试中存在三个不同的数据区域:第一个区域存放指令,第二个区域存放数组A,第三个区域存放数组B。

如果我们考虑这些区域在内存中的地址,会发现第一条指令在地址0x1000。无论考虑哪种缓存,这都会产生索引0。因此,对于所有三种缓存,第一条指令都会映射到缓存的第一行。类似地,数组A和B的第一个元素分别在地址0x20000x3000。无论考虑哪种缓存,这些地址也会产生索引0

因此,我们看到第一条C.MOV指令、A[0]B[0]都会映射到缓存的第0行。类似地,地址为0x1004的第二条C.MOV指令,以及数组元素A[1]B[1],都会映射到缓存的第1行。

这告诉我们,如果使用直接映射缓存或两路组相联缓存,那么指令和数组元素之间会发生缓存冲突。缓存中的冲突意味着缓存缺失,因为我们会用另一个数据替换缓存中的一个数据。然而,如果我们使用四路组相联缓存,那么内存的每个区域都可以进入缓存中不同的组,从而避免冲突,并在第一次循环后实现100%的命中率。注意,第一次循环时,每条指令和数据访问都会导致缓存缺失,因为数据需要被初次载入缓存;但当循环重复时,数据已经存在,从而产生缓存命中。

场景二:修改后的基准测试

现在,假设我们对测试程序做一个微小的修改,将B0x3000改为0x2000。这意味着数组A和数组B现在指向内存中的相同位置。

我们想确定,由于这个改变,哪种缓存的命中率会有显著提升。修改后的基准测试与原版的区别在于,我们现在只有两个不同的内存区域需要访问:一个用于指令,一个用于数据。这意味着两路组相联缓存中将不再发生冲突,因此其命中率将比原版基准测试显著提高。

现在假设我们再次修改基准测试,这次使JAB都等于0,并将N改为64。这意味着我们的数组现在有16个元素,而不是4个。这也意味着我们为数组A和B加载的值实际上与程序的指令相同。另一种理解方式是,我们现在只访问一个内存区域。

我们现在想要确定的是,对于每种缓存配置,将会发生的缓存缺失总数。

场景三:指令与数据重叠的分析

让我们从直接映射缓存开始考虑。在直接映射缓存中,我们首先会访问第一条C.MOV指令。由于该指令尚未在缓存中,我们的第一次访问是一次缓存缺失。现在我们将该指令的二进制表示载入到缓存的第0行。

接下来,我们想访问第二条C.MOV指令。同样,该指令不在缓存中,所以我们得到另一个缓存缺失。这导致我们将第二条C.MOV指令加载到缓存的第1行。我们以同样的方式继续处理SUBEQ指令和第一条LOAD指令。同样,这些指令每次都会产生缓存缺失,并导致我们将这些指令加载到缓存中。

现在我们准备执行第一次加载操作。这个操作想要将A[15]加载到R1。因为数组A的起始地址是0,所以A[15]映射到我们缓存的第15行。由于我们尚未向缓存的第15行加载任何内容,这意味着我们的第一次数据访问是一次缺失。我们继续执行第二条LOAD指令。该指令尚未在缓存中,所以我们得到一次缓存缺失,然后将其加载到缓存的第4行。

接着,我们尝试访问B[15]B[15]A[15]对应的是同一块数据。因此,这次数据访问已经在缓存中,从而对B[15]产生一次数据命中。

到目前为止,我们有5次指令缺失,1次数据缺失和1次数据命中。接下来,我们需要访问BNE指令。再一次,我们得到一次缓存缺失,导致将BNE指令加载到缓存的第5行。

现在内层循环以R0等于60(对应数组的第14个元素)重复。这次循环中,所有指令都已经在缓存中,并产生指令命中。A[14]映射到我们缓存的第14行,由于尚未存在于缓存中,导致一次数据缺失。因此我们将A[14]载入缓存。然后,和之前一样,当我们尝试访问B[14]时,我们得到一次数据命中,因为它与A[14]是同一块数据。

所以总的来说,我们现在看到了6次指令缺失和2次数据缺失,其余访问都是命中。这个过程对每个数组元素A[i]产生一次数据缺失,对B[i]产生一次数据命中,直到我们到达A[5]A[5]实际上会导致一次命中,因为它对应内存中存放BNE R0, R指令的位置,而该指令已经存在于缓存的第5行。从那时起,剩余的数据访问都产生命中。

此时,我们完成了内层循环,并继续执行外层循环中的剩余指令。这些指令是第二条SUBEQ和第二条BNE指令。它们对应缓存第6行和第7行中的数据,因此产生命中。然后循环本身重复1000次,但每次循环时,所有指令和数据都在缓存中,所以它们都产生命中。

因此,在直接映射缓存上执行该基准测试时,我们得到的总缺失次数是16次。这些被称为强制性缺失,即首次将数据加载到缓存时发生的缺失。

场景三:组相联缓存分析

回顾一下,直接映射缓存在缓存中有一组64行。两路组相联缓存有两组,每组32行。四路组相联缓存有四组,每组16行。

由于只需要16行就能容纳该基准测试相关的所有指令和数据,这意味着在组相联缓存中,实际上只会用到一组。而且,即使在四路组相联缓存中也有16行,这意味着一旦数据被加载到缓存中,就不需要被其他数据替换。因此,在每行第一次缺失之后,整个基准测试中剩余的访问都将是命中。

所以,在两路和四路组相联缓存中的总缺失次数也是16次。

总结

本节课中我们一起学习了如何通过具体的基准测试程序分析不同缓存配置(直接映射、两路组相联、四路组相联)的性能差异。我们了解到:

  1. 当程序访问多个独立的内存区域时,更高相联度的缓存(如四路)可以通过将不同区域的数据映射到不同的组来避免冲突,从而提高命中率。
  2. 当程序的数据访问模式导致地址映射到相同的缓存行时(如指令和数据区域重叠),直接映射和低相联度缓存会发生冲突缺失。
  3. 在指令和数据完全重叠的特殊场景下,三种缓存都表现出相同的强制性缺失次数(16次),因为所有必要数据最终都能被容纳而不被替换。
  4. 缓存命中率不仅取决于容量,还极大地依赖于程序的访问模式和缓存的映射策略

理解这些关系对于计算机架构师和程序员优化程序性能至关重要。

032:缓存工作示例详解 🧠

在本节课中,我们将通过一个具体的工作示例,深入探讨缓存(Cache)的工作原理。我们将分析一个与Beta处理器配合使用的两路组相联缓存,学习如何确定地址位的用途、计算缓存命中率,并理解程序执行后缓存的内容状态。

缓存地址映射分析 🔍

上一节我们介绍了缓存的基本概念,本节中我们来看看如何为一个具体的缓存设计地址映射。

考虑一个与我们的Beta处理器配合使用的两路组相联缓存。每个缓存行(Cache Line)存储一个32位(4字节)的数据字,并附带一个有效位(Valid Bit)和一个标记(Tag)。Beta处理器使用32位字节地址。我们需要确定哪些地址位应用作缓存索引(Index),哪些应用作标记(Tag),以确保最佳的缓存性能。

由于我们的地址以字节为单位,但数据以32位(4字节)字为单位,因此地址的最低2位始终被假定为 0, 0,以实现字对齐(Word Alignment)。

我们的缓存有8行,这意味着索引位必须是3位宽。我们希望用作索引的位是紧接着的次重要位,即地址的位 [4:2]。我们希望将这些位作为索引的一部分而非标记,是出于局部性(Locality)的考虑。其思想是,内存中彼此靠近的指令或数据比位于内存不同部分的指令或数据更有可能在同一时间段内被访问。

例如,如果我们的指令来自地址 0x1000,那么我们也很可能访问下一条位于地址 0x1004 的指令。在这种映射方案下,第一条指令的索引将映射到缓存的第0行,而下一条指令将映射到缓存的第1行,因此它们不会在缓存中引起冲突或缺失。

剩下的高位用作标记。为了能够唯一标识每个不同的地址,我们需要使用所有剩余的位作为标记。由于许多地址会映射到缓存中的同一行,我们必须比较数据的标记和缓存行的标记,以确认是否找到了我们正在寻找的数据。因此,我们使用地址位 [31:5] 作为标记。

缓存访问过程解析 🛠️

理解了地址映射后,我们来看看一个具体的访问过程。

假设我们的Beta处理器执行一次对地址 0x5678 的读取操作。我们希望确定需要检查缓存中的哪些位置,以判断数据是否已在缓存中。

为了确定这一点,我们需要识别地址中对应于索引的部分。索引位是位 [4:2],对于地址 0x5678,这部分对应的二进制是 1, 1, 0。这意味着该地址将映射到我们缓存的第6行。

由于这是一个两路组相联缓存,我们的数据可能位于两个可能的位置:第6行的A组或B组。因此,我们需要比较这两个位置的标记,以确定我们试图读取的数据是否已经在缓存中。

缓存性能计算 📊

在分析了单次访问后,我们来评估缓存的整体性能。

假设在读取时检查缓存需要1个周期,而在缺失时重新填充缓存需要额外的8个周期。这意味着发生缺失时的总时间为9个周期(1个周期用于首次检查值是否在缓存中,加上8个周期用于将值带入缓存)。

现在,假设我们希望实现平均读取访问时间为1.1个周期。要达到这个在许多次读取上的平均访问时间,所需的最低命中率是多少?

我们知道:
平均访问时间 = 命中时间 × 命中率 + 缺失时间 × 缺失率

如果我们称命中率为 A,那么缺失率就是 1 - A。因此,我们期望的平均访问时间1.1必须等于:
1 × A + 9 × (1 - A)

这简化为:
1.1 = 9 - 8A

这意味着:
8A = 7.9

A = 7.9 / 8

因此,要实现1.1个周期的平均访问时间,我们的命中率必须至少为 7.9/8

基准程序缓存行为分析 📈

接下来,我们通过一个基准程序来具体分析缓存的行为。

我们获得了一个用于测试两路组相联缓存的基准程序。在执行开始前,缓存初始为空,即所有缓存行的有效位均为0。假设使用LRU(最近最少使用)替换策略,我们希望确定该程序的大致缓存命中率。

首先理解这个基准程序的功能。程序从地址0开始。它使用三条 CMOVE 操作初始化寄存器:

  1. 第一条将 R0 初始化为 source,这是数据在内存中的存储地址。
  2. 第二条将 R1 初始化为 0
  3. 第三条将 R2 初始化为 0x1000,这是基准程序将要处理的数据字数。

然后程序进入循环(图中黄色矩形部分)。循环从地址 source + 0 加载数据的第一个元素到寄存器 R3。接着,它将 R0 递增以指向下一个数据块。由于数据是32位宽,这需要加上常数 4(表示连续数据字之间的字节数)。然后,它将刚刚加载的值加到 R1 中,R1 保存了迄今为止所有数据的运行总和。最后,R2 减1,表示还剩更少的数据需要处理。只要 R2 不等于 0,循环就会重复。在基准程序的最后,最终的总和被存储在地址 source,程序停止。

在确定近似命中率时,可以基本忽略只执行一次(位于循环外)的指令。因此,只关注循环中反复发生的情况:每次循环,我们有5次指令取指和1次数据取指。

以下是循环中缓存行为的逐步分析:

  • 第一次循环:指令取指全部缺失,然后将它们载入缓存。从地址 0x100 的数据加载也会缺失。当这些数据被载入缓存时,它不会替换最近加载的指令,而是被加载到缓存的B组中。
  • 后续循环:所有指令取指都命中。然而,我们现在需要的数据是一个新的数据块,这将导致缓存缺失,并再次将新的数据字加载到缓存的B组中。
  • 稳定状态:由于循环执行多次,我们也可以忽略第一次循环迭代时的初始指令取指缺失。因此,在稳定状态下,每次循环我们得到5次指令缓存命中和1次数据缓存缺失。

这意味着我们近似的命中率是 5/6

缓存最终状态确定 🗂️

最后,我们考虑在这个基准程序执行完成后,缓存中存储了什么内容。

正如我们之前看到的,由于我们有一个两路组相联缓存,指令和数据不会相互冲突,因为它们可以各自进入不同的组。我们希望确定在执行完成后,缓存的第4行中最终会存储哪条指令和哪个数据块。

首先确定指令到缓存行的映射。由于程序从地址0开始,第一条 CMOVE 指令在地址0,其索引等于二进制 0b000(即0)。这意味着它将映射到缓存第0行。由于此时缓存为空,它将被加载到A组的第0行。以类似的方式,接下来的两条 CMOVE 指令和 LOAD 指令将被加载到A组的第1至3行。

此时,我们开始加载数据。由于缓存是两路组相联,数据将被加载到B组,而不是移除加载到A组的指令。循环外的指令最终会被从A组中替换出来,以便为映射到相同行的数据项腾出空间,但构成循环的指令不会被替换,因为每次有东西映射到缓存第3至7行时,最近最少使用的位置将对应于一个数据值,而不是被反复使用的指令。

这意味着在基准程序执行结束时,位于缓存第4行的指令是程序中地址 0x10 处的 ADD 指令。

现在,考虑这个基准程序中使用的数据会发生什么。我们期望循环指令保留在缓存A组的第3至7行。数据将根据需要占用整个B组以及A组的第0至2行。

数据从地址 0x100 开始。地址 0x100 的索引部分是 0b000,因此这个数据元素映射到缓存第0行。由于第0行最近最少使用的组是B组,它将进入B组,从而保持A组的指令完整。下一个数据元素在地址 0x104。由于最低两位用于字对齐,该地址的索引部分是 0b001。因此,这个数据元素映射到B组的第1行,依此类推。

数据元素 0x8 位于地址 0x120。该地址的索引部分再次是 0b000,因此这个元素像元素 0x0 一样映射到缓存第0行。然而,此时,B组第0行比A组第0行最近被访问过,因此只执行一次的 CMOVE 指令将被一个映射到第0行的数据元素替换。

如前所述,循环中的所有指令都不会被替换,因为它们被反复访问,所以它们永远不会成为缓存行中最近最少使用的项。

在执行循环 0x1000 次(即访问了数据元素 00xFFF)之后,缓存的状态将如描述所示。循环指令继续保留在缓存中的原始位置。缓存状态持续如此,最近访问的数据元素替换更早的数据元素。

当基准程序执行完毕时,最终位于缓存第4行的数据元素是来自地址 0x40F0 的数据元素 0x0FFC


本节课总结:在本节课中,我们一起学习了如何为一个两路组相联缓存设计地址映射(索引位与标记位),如何根据访问时间和缺失惩罚计算所需的缓存命中率,以及如何通过分析一个具体程序的执行流程来预测其缓存行为(命中/缺失)和最终缓存状态。这些是理解和优化计算机内存系统性能的核心技能。

033:6.4 提升Beta性能 🚀

在本节课中,我们将运用课程第一部分学到的流水线技术,来提升我们在课程第二部分开发的32位Beta CPU设计的性能。

概述

我们之前设计的Beta CPU每个时钟周期执行一条指令。其时钟周期由指令执行路径中所有组件的累积延迟决定。为了提升性能,我们需要减少每条指令的平均时钟周期数(CPI)或缩短时钟周期本身。本节课我们将重点探讨如何通过流水线技术来缩短时钟周期,从而提高指令吞吐量。

性能瓶颈分析

上一节我们回顾了Beta CPU的基本设计。本节中我们来看看其性能瓶颈。

执行程序所花费的时间可以表示为三个项的乘积:

  1. 执行指令的总数。
  2. 执行单条指令所需的平均时钟周期数(CPI)。
  3. 单个时钟周期的持续时间(T_clock)。

作为CPU设计者,我们可以控制后两项:CPI和时钟周期。在我们的Beta设计中,每条指令在一个时钟周期内完成,因此CPI为1。时钟周期T_clock则由Beta电路中最长路径的延迟决定。

例如,考虑执行一条OP类指令(涉及两个寄存器操作数和ALU运算)的路径。除了几个多路选择器,主存储器、寄存器文件和ALU都必须有时间完成其工作。

最坏情况的执行时间是LOAD指令。在一个时钟周期内,我们需要:

  1. 从主存取指令。
  2. 从寄存器文件读操作数。
  3. 在ALU中执行地址计算。
  4. 从主存读取请求的数据。
  5. 将数据写入目标寄存器。

这些组件的延迟累加起来,导致了一个相当长的时钟周期,从而程序运行时间也较长。

我们的两个示例执行路径还说明了另一个问题:我们被迫选择能适应最坏情况执行时间的时钟周期,即使某些指令(其执行路径更短)可以执行得更快。我们让所有指令都变慢,仅仅因为有一条指令具有很长的关键路径。

引入流水线技术

那么,为什么不简单的指令用一个时钟周期执行,而更复杂的指令用多个周期执行,而不是强迫所有指令都在一个长时钟周期内执行呢?在接下来的几张幻灯片中,我们将看到一个很好的答案,它将允许我们以更短的时钟周期执行所有指令。

我们将使用流水线技术来解决这些问题。我们将指令的执行划分为一系列步骤,每个步骤在流水线的连续阶段中执行。

因此,一条指令在流经执行流水线的各个阶段时,需要多个时钟周期才能完成。但由于流水线的每个阶段只有一两个组件,时钟周期可以大大缩短,CPU的吞吐量可以大大提高。

吞吐量的提高是重叠执行连续指令的结果。在任何给定时间,CPU中都会有多个指令,每个指令都处于其执行的不同阶段。执行特定指令所有步骤的时间(即指令延迟)可能比我们非流水线实现中的要高一些,但我们将在每个时钟周期完成执行某条指令的最后一步,因此指令吞吐量是每个时钟周期一条。由于流水线CPU的时钟周期要短得多,指令吞吐量也就高得多。

经典五级流水线

这听起来很棒,但不出所料,我们还需要处理一些问题。流水线化指令执行的方法有很多。我们将研究经典的五级指令执行流水线的设计,该设计在20世纪80年代的集成电路CPU设计中得到广泛应用。

这五个流水线阶段对应于冯·诺依曼存储程序架构中执行指令的步骤:

  1. 取指阶段:负责从程序计数器指示的主存位置获取二进制编码的指令。
  2. 寄存器读取/译码阶段:从寄存器文件中读取所需的寄存器操作数。
  3. 执行阶段:执行请求的操作(如ALU运算)。
  4. 访存阶段:对主存进行第二次访问,以读取或写入LOAD、LDR或STORE指令的数据,使用ALU阶段的值作为内存地址。
  5. 写回阶段:将前面阶段的结果写入寄存器文件中的目标寄存器。


观察之前幻灯片中的执行路径,我们看到非流水线设计的每个主要组件现在都在自己的流水线阶段中。因此,时钟周期现在将由这些组件中最慢的一个决定。

将指令执行分为五个阶段后,我们是否期望时钟周期变为原始值的五分之一?这只有在我们将执行划分得使每个阶段恰好完成总工作量的五分之一时才会发生。实际上,主要组件的延迟略有不同,因此指令吞吐量的提升将略低于理想五级流水线所能达到的5倍因子。如果我们有一个慢速组件(例如ALU),我们可以选择将该组件进一步流水线化,或交错使用多个ALU以达到相同的效果。但在本讲座中,我们将采用上述的五级流水线。

流水线的挑战

那么,为什么这不是一个20分钟的讲座呢?毕竟,我们知道如何流水线化组合电路。我们可以通过在电路图上画K条等高线,并在等高线与信号相交的任何地方添加流水线寄存器,来构建一个有效的K级流水线。这里有什么大问题吗?

问题是,这个电路是组合电路吗?不是,寄存器文件和内存中都有状态。这意味着执行给定指令的结果可能依赖于先前指令的结果。

电路中存在环路,来自后面流水线阶段的数据会影响前面流水线阶段的执行。例如,在WB阶段结束时对寄存器文件的写入,将改变在RF阶段从寄存器文件读取的值。换句话说,指令之间存在执行依赖关系,当我们尝试流水线化指令执行时,需要考虑这些依赖关系。

数据冒险与控制冒险

我们将通过检查执行流水线的操作来解决这些问题。

有时,给定指令的执行将依赖于执行先前指令的结果。有两种类型的问题依赖:

  1. 数据冒险:当当前指令的执行依赖于先前指令产生的数据时发生。例如,一条读取寄存器R0的指令将依赖于先前写入R0的指令的执行。
  2. 控制冒险:当分支、跳转或异常改变执行顺序时发生。例如,在BEQ指令之后执行哪条指令的选择取决于分支是否被采纳。

当指令所依赖的指令也在流水线中时(换句话说,前面的指令尚未完成执行),指令执行就会触发冒险。我们需要调整流水线中的执行以避免这些冒险。

行动计划

以下是我们的行动计划:

  1. 首先设计一个五级流水线,使其能够处理不触发冒险的指令序列(即指令执行不依赖于仍在流水线中的先前指令)。
  2. 然后修复我们的流水线,以正确处理数据冒险
  3. 最后,解决控制冒险

总结

本节课中我们一起学习了如何通过引入流水线技术来提升CPU性能。我们分析了非流水线设计的性能瓶颈,介绍了经典的五级流水线结构(IF、RF、EX、MEM、WB),并指出了在流水线设计中需要解决的关键挑战:数据冒险和控制冒险。在接下来的课程中,我们将按照行动计划,逐步构建并完善能够高效处理这些冒险的流水线CPU。

034:15.2.2 基础五级流水线 🚀

在本节课中,我们将要学习如何为Beta数据通路添加流水线,以提升处理器的性能。我们将从一个简化的数据通路模型开始,逐步引入五级流水线结构,并观察指令在其中如何重叠执行。


简化数据通路

为了便于理解流水线的工作原理,我们首先需要重新绘制并简化Beta数据通路。

上一节我们介绍了流水线的概念,本节中我们来看看如何构建一个基础的流水线结构。首先,我们进行两项简化。

第一项简化是专注于顺序执行。因此,我们先移除程序计数器(PC)逻辑中的分支寻址部分。在我们的简化模型中,处理器总是顺序执行PC+4处的下一条指令。当我们讨论控制冒险时,会再将分支和跳转逻辑添加回来。

第二项简化是在图中将寄存器文件绘制两次。这样做的目的是为了区分指令执行不同阶段发生的读操作和写操作。

  • 顶部的寄存器文件展示了在RF(取寄存器)阶段使用的组合逻辑读端口。
  • 底部的寄存器文件展示了在WB(写回)阶段末尾,用于将结果写入目标寄存器的时钟控制写端口。

实际上,物理上只有一组32个寄存器。我们只是在图中将读写电路绘制成了独立的组件。


引入五级流水线

如果我们向简化后的数据通路添加流水线寄存器,就可以看到指令执行从上到下依次经过五个阶段。

如果我们考虑执行没有数据冒险的指令序列,信息会沿着流水线向下流动,流水线将正确地重叠执行其中的所有指令。

下图展示了实现这五个阶段所需的组件:

以下是各阶段的主要功能:

  • IF(取指)阶段:包含程序计数器(PC)和用于获取指令的主存接口。
  • RF(取寄存器)阶段:包含寄存器文件以及操作数多路选择器。
  • ALU(算术逻辑单元)阶段:使用操作数并计算结果。
  • MEM(访存)阶段:处理加载(Load)和存储(Store)操作的内存访问。
  • WB(写回)阶段:将结果写入目标寄存器。

在每个时钟周期,每个阶段都在执行特定指令的相应部分。在给定的时钟周期内,流水线中同时存在五条指令。

需要注意的是,对主存的数据访问几乎跨越两个时钟周期。数据访问在MEM阶段开始时发起,而返回的数据仅在WB阶段结束前才被需要。内存本身也是流水线化的,可以同时完成前一条指令的访问并开始下一条指令的访问。


流水线中的控制逻辑

简化图还展示了控制逻辑是如何分布在流水线各阶段的。这是如何工作的呢?

我们已将指令寄存器作为每个流水线阶段的一部分包含在内,这样每个阶段都可以从其指令寄存器中计算出所需的控制信号。

编码后的指令会随着指令在流水线中流动,简单地从一个阶段传递到下一个阶段。

每个阶段从其指令寄存器的操作码(opcode)字段计算出自己的控制信号。RF阶段需要从其指令寄存器中获取RA、RB和字面值(literal)字段,而WB阶段则需要其指令寄存器中的RC字段。所需的逻辑与非流水线实现非常相似,只是被拆分并移到了相应的流水线阶段。

我们将会看到,为了正确处理流水线冒险,必须添加一些额外的控制逻辑。


流水线执行示例

我们的简化图现在看起来不那么简单了。为了理解流水线如何工作,让我们跟随它执行以下六条指令序列。

请注意,这些指令读写的是不同的寄存器,因此不存在潜在的数据冒险。同时,也没有分支和跳转指令,因此也不存在潜在的控制冒险。

由于没有潜在的冒险,指令执行可以重叠,并且它们在流水线中的重叠执行将正确无误。

好的,我们开始。

周期1:IF阶段将程序计数器的值发送给主存,以获取第一条指令(绿色的LOAD指令)。该指令将在周期结束时存储在RF阶段的指令寄存器中。同时,IF阶段还在计算PC+4,这将是程序计数器的下一个值。我们将下一个值标记为蓝色,表示它是序列中蓝色指令的地址。我们将在每个流水线阶段的右侧添加相应颜色的标签,以指示该阶段正在处理哪条指令。

周期2:此时,程序计数器和RF阶段指令寄存器中的值对应于绿色指令。在本周期内,寄存器文件将读取寄存器操作数(本例中是R1),这是绿色指令所需要的。由于绿色指令是加载指令,A路选择0,B路选择1,从而选择适当的值在周期结束时写入A和B操作数寄存器。同时,IF阶段正在从主存获取蓝色指令,并为下一个周期计算更新后的PC值。

周期3:绿色指令现在位于ALU阶段,ALU将其操作数寄存器中的值相加(本例中是R1的值和常数4)。结果将在周期结束时存储在Y/MEM寄存器中。

周期4:我们现在重叠执行四条指令。MEM阶段为绿色的LOAD指令发起一次内存读取。请注意,读取的数据将首先在WB阶段可用,在当前时钟周期内对CPU不可用。

周期5:在周期4中发起的主存读取结果现在可用于写入寄存器文件。因此,当内存数据在周期5结束时写入R2时,绿色LOAD指令的执行就完成了。同时,MEM阶段正在为蓝色的LOAD指令发起一次内存读取。

流水线继续在连续的时钟周期中完成连续的指令。


性能与数据冒险

单条指令的延迟是五个时钟周期。流水线CPU的吞吐量是每个周期一条指令。这与非流水线实现相同,但流水线的时钟周期更短,因为每个流水线阶段包含的组件更少。

需要注意的是,绿色LOAD指令的效果(即用新值填充R2)直到周期5结束时的时钟上升沿才发生。换句话说,绿色LOAD指令的结果在周期6之前对其他指令不可用。

如果流水线中有指令在周期6之前读取R2,它们将得到旧值。这就是一个数据冒险的例子。这对我们来说不是问题,因为我们的指令序列没有触发这个数据冒险。

处理数据冒险是我们的下一个任务。


在本节课中,我们一起学习了如何构建一个基础的五级流水线CPU。我们从简化数据通路开始,引入了IF、RF、ALU、MEM和WB五个阶段,并通过一个无冒险的指令序列示例,观察了指令在流水线中如何重叠执行以实现更高的吞吐量。我们还了解了控制信号在流水线中的分布方式,并初步认识了数据冒险的产生条件,为后续解决冒险问题奠定了基础。

035:数据冒险

在本节课中,我们将要学习流水线CPU设计中的一个核心挑战:数据冒险。我们将了解数据冒险产生的原因,并探讨两种主要的解决策略:停顿旁路。通过本课,你将理解如何确保流水线CPU与无流水线CPU产生相同的程序结果。

流水线图

上一节我们介绍了数据通路图,但它在描述指令序列的流水线执行时并不十分方便,因为每个时钟周期都需要一张新的图。

课程第一部分介绍的流水线图提供了更紧凑、更易读的流水线执行表示方法。图中每一行代表一个流水线阶段,每一列代表一个执行周期。表格中的条目显示了在正常操作下,每个周期每个流水线阶段正在处理的指令。一条特定指令在经历五个流水线阶段的过程中,会沿着对角线在图中移动。

寄存器读写时机

为了理解数据冒险,我们首先需要回顾特定指令在何时读写寄存器文件。

寄存器读取发生在指令处于RF(取寄存器)阶段时,也就是我们读取指令操作数寄存器的时候。

寄存器写入发生在指令处于WB(写回)阶段结束的那个周期。例如,对于第一条加载指令,我们在周期2读取R1,并在周期5结束时写入R2。

考虑周期6中的寄存器文件操作:我们为处于RF阶段的MUL指令读取R12和R13,同时为处于WB阶段的加载指令在周期结束时写入R4。

数据冒险的产生

现在,让我们看看当指令序列中存在数据冒险时会发生什么。ADD指令将其结果写入R2,而紧随其后的SUB指令会立即读取R2。

SUB指令的执行显然依赖于ADD指令的结果。我们称之为写后读依赖

流水线图展示了周期性的执行过程,我们圈出了ADD写入R2和SUB读取R2的周期。问题在于:ADD直到周期5结束时才写入R2,但SUB试图在周期3读取R2值。周期3时寄存器文件中的R2值尚未反映ADD指令的执行结果。因此,按照当前情况,流水线将无法正确执行该指令序列。这个指令序列触发了一个数据冒险

我们希望流水线CPU能产生与无流水线CPU相同的程序结果,因此需要找到解决方案。

解决数据冒险的策略

有三种通用策略可用于解决流水线冒险。任何技术都有效,但正如我们将看到的,它们在指令吞吐量和电路复杂性方面有不同的权衡。

第一种策略是让指令在RF阶段停顿,直到它们所需的结果被写入寄存器文件。“停顿”意味着我们在周期结束时不重新加载指令寄存器,因此下个周期将尝试执行同一条指令。如果我们停顿一个流水线阶段,所有更早的阶段也必须停顿,因为它们被停顿的指令阻塞了。如果一条指令在RF阶段停顿,那么IF阶段也会停顿。停顿总是有效,但会对指令吞吐量产生负面影响。停顿太多周期会丧失流水线执行的性能优势。

第二种策略旁路转发,即在结果计算出来后立即将其路由到更早的流水线阶段。事实证明,我们需要的值通常存在于流水线数据通路的某个地方,只是尚未写入寄存器文件。如果该值存在并能转发到需要的地方,我们就不需要停顿。我们将能够使用此策略来避免大多数类型的数据冒险导致的停顿。

第三种策略推测,即对所需值进行智能猜测并继续执行。一旦确定了实际值,如果我们猜对了,就万事大吉;如果猜错了,就必须回退执行并用正确的值重新开始。显然,只有在能够做出准确猜测的情况下,推测才有意义。我们将能够使用此策略来避免控制冒险导致的停顿。

接下来,让我们看看前两种策略在处理数据冒险时如何工作。

策略一:停顿

将停顿策略应用于我们的数据冒险,我们需要让SUB指令在RF阶段停顿,直到ADD指令将其结果写入R2。

在流水线图中,SUB在RF阶段停顿了三次,直到它最终能在周期6从寄存器文件访问R2值。每当RF阶段停顿时,IF阶段也会停顿。但当RF停顿时,AOU阶段在下个周期应该做什么?RF阶段尚未完成工作,因此无法将其指令传递下去。

解决方案是让RF阶段为AOU阶段生成一条无害的指令,称为空操作指令。空操作指令对CPU状态没有影响,即不改变寄存器文件或主存储器的内容。例如,任何以R31作为目标寄存器的OP类或OPSY类指令都是空操作。由停顿的RF阶段引入流水线的空操作在图中以红色显示。由于SUB在RF阶段停顿了三个周期,因此有三个空操作被引入流水线,我们有时将这些空操作称为流水线中的“气泡”。

流水线如何知道何时停顿?它可以比较RF阶段指令的RA和RB字段中的寄存器编号,与AOU、MEM、WB阶段指令的RC字段中的寄存器编号。如果匹配,则存在数据冒险,RF阶段应停顿。停顿将持续到检测不到冒险为止。这里有一些细节需要注意:有些指令不读取两个寄存器;存储指令不使用其RC字段;我们不希望R31匹配,因为从寄存器文件读取R31总是安全的。

停顿将确保正确的流水线执行,但确实会增加有效CPI。如果CPI的增加大于流水线化带来的周期时间减少,将导致更长的执行时间。

为了实现停顿,我们只需要对流水线数据通路进行两处简单的修改:

  1. 生成一个新的控制信号STALL,当它有效时,禁止加载IF和RF阶段输入端的三个流水线寄存器,这意味着它们下个周期的值将与本周期的值相同。
  2. 引入一个多路选择器来选择发送给AOU阶段的指令。如果STALL为1,我们选择一条空操作指令(例如,目标寄存器为R31的ADD指令)。如果STALL为0,则RF阶段未停顿,因此它将当前指令传递给AOU。

以下是计算STALL信号的方法(如前一张幻灯片所述):

// 简化逻辑示例:检测RAW冒险
stall = (RA == RC_AOU || RB == RC_AOU || RA == RC_MEM || RB == RC_MEM) && (RA != 31 && RB != 31);

实现停顿所需的额外逻辑相当简单,因此真正的设计权衡在于:因停顿而增加的CPI与因流水线化而减少的周期时间之间的权衡。这样我们有了一个解决方案,尽管它可能带来一些性能成本。

策略二:旁路

现在考虑我们的第二种策略:旁路。如果RF阶段需要的数据存在于流水线数据通路中的某个地方,则此策略适用。在我们的例子中,尽管ADD直到周期5结束时才写入R2,但将要写入的值是在周期3ADD处于AOU阶段时计算出来的。在周期3,AOU的输出正是同一周期处于RF阶段的SUB所需的值。

因此,如果我们检测到R阶段指令的RA字段与ALU阶段指令的RC字段相同,我们就可以使用ALU的输出,来代替从寄存器文件读取的过时RA值。无需停顿。在我们的例子中,在周期3,我们希望将ALU的输出路由到RF阶段,作为R2的值。我们用一条红色的旁路箭头表示数据从AOU阶段路由到RF阶段。

为了实现旁路,我们将在寄存器文件的读端口添加一个多输入多路选择器,以便我们可以从其他流水线阶段选择适当的值。这里我们展示了来自AOU、MEM和WB阶段的组合旁路路径。对于之前幻灯片中的旁路示例,我们在周期3使用蓝色旁路路径来获取R2的正确值。

旁路多路选择器由匹配源寄存器编号与AOU、MEM、WB阶段目标寄存器编号的逻辑控制,并需处理R31的常见复杂情况。如果存在多个匹配怎么办?换句话说,如果RF阶段试图读取的寄存器同时是AOU和MEM阶段指令的目标寄存器。没问题,我们希望选择来自最近指令的结果。因此,如果有AOU匹配,则选择它,然后是MEM匹配,接着是WB匹配,最后才是寄存器文件的输出。

下图展示了所有需要的旁路路径。

请注意,分支和跳转指令将其PC+4值写入寄存器文件,因此我们也需要从它们各自阶段的PC+4值以及AOU值进行旁路。

旁路发生在周期结束时,例如,在ALU计算出结果之后。为了适应旁路多路选择器的额外传输延迟,我们必须将时钟周期稍微延长。因此,这里再次存在设计权衡:停顿带来的CPI增加与旁路带来的周期时间略微增加。当然,在旁路的情况下,还需要额外的布线面积和多路选择器。

我们可以通过减少旁路的数量来降低成本,例如,只旁路来自AOU阶段的AOU结果,并使用停顿来处理所有其他数据冒险。

旁路无法完全解决的问题:加载-使用冒险

如果我们实现了完全旁路,是否还需要停顿逻辑?事实证明,需要。有一种数据冒险是旁路无法完全解决的。

考虑试图立即使用加载指令结果的情况。在下面展示的例子中,SUB试图使用紧接其前的加载指令写入R2的值。这被称为加载-使用冒险

回想一下,加载数据直到加载指令到达WB阶段的那个周期才在数据通路中可用。因此,即使有完全旁路,我们也需要让SUB在RF阶段停顿直到周期5,从而在流水线中引入两个空操作。如果没有来自WB阶段的旁路,我们需要停顿直到周期6。

总结与扩展

本节课中我们一起学习了处理数据冒险的两种主要策略。

  1. 停顿:我们可以让IF和RF阶段停顿,直到RF阶段指令所需的寄存器值在寄存器文件中可用。所需硬件简单,但引入流水线的空操作浪费了CPU周期,导致更高的有效CPI。
  2. 旁路:我们可以使用旁路路径将所需值路由到RF阶段,前提是它们存在于流水线数据通路中的某个地方。这种方法比停顿需要更多的硬件,但不会降低有效CPI。

即使实现了旁路,我们仍然需要停顿来处理加载-使用冒险。

我们能否通过添加更多流水线阶段来进一步减少时钟周期?更多的流水线阶段意味着同时有更多的指令在流水线中,这反过来增加了数据冒险的机会和停顿的必要性,从而增加了CPI。

编译器可以通过重组它们生成的汇编代码来帮助减少依赖。这是我们之前看到的加载-使用冒险示例。即使有完全旁路,我们也需要停顿两个周期。但如果编译器或汇编语言程序员注意到MULXOR指令独立于SUB指令,因此可以移到SUB之前,那么依赖关系就变成了:当SUB处于RF阶段时,加载指令自然处于WB阶段,因此不需要停顿。这种优化仅在编译器能够找到可以移动的独立指令时才有效。不幸的是,在许多程序中很难找到这样的指令。

还有一种最终方法:改变指令集架构,使数据冒险成为ISA的一部分。换句话说,直接规定对目标寄存器的写入有三条指令的延迟。如果需要空操作,就让程序员将它们添加到程序中。以让编译器工作更复杂为微小代价,来简化硬件。你可以想象编译器编写者会有多喜欢这个建议,更不用说汇编语言程序员了。而且当你添加更多流水线阶段时,你还可以再次更改ISA。这就是编译器编写者如何看待那些单方面更改ISA以节省几个逻辑门的CPU架构师。

成功的ISA具有非常长的生命周期,因此不应包含由短期实现考虑驱动的权衡。最好不要走那条路。

036:6.4 控制冒险

在本节课中,我们将要学习流水线处理器中的“控制冒险”问题。当处理器遇到跳转或分支指令时,它无法立即确定下一条要执行的指令,这会导致流水线停顿,降低性能。我们将探讨控制冒险的成因、影响以及几种主要的解决策略。

控制冒险的成因

上一节我们介绍了数据冒险,本节中我们来看看控制冒险。控制冒险由跳转和分支指令引发,如下面的代码片段所示:

BEQ(R3, loop_top)  // 如果R3不等于0,则跳转到loop_top
SUB(R4, R5, R6)    // 下一条指令

执行完 BEQ 指令后,应该执行哪条指令?如果 R3 的值非零,则应执行 loop_top 处的指令(例如 ADC)。如果 R3 的值为零,则下一条指令应为 SUB

当当前指令是显式的控制流转移指令(即跳转或分支)时,下一条指令的选择取决于当前指令的执行结果。这种依赖性对我们的执行流水线有何影响?

确定下一条指令

在非流水线实现中,如何确定下一条指令?

  • 对于分支指令(BEQBNE),要加载到程序计数器(PC)的值取决于:
    1. 操作码(Opcode),即指令是 BEQ 还是 BNE
    2. 程序计数器的当前值,因为它用于偏移量计算。
    3. 指令 RA 字段指定寄存器中存储的值,因为这是分支测试的值。
  • 对于跳转指令,下一条 PC 值同样取决于操作码字段和 RA 寄存器的值。
  • 对于所有其他指令,下一条 PC 值仅取决于指令的操作码和 PC+4 的值。

控制冒险由跳转和分支指令触发,因为它们的执行依赖于 RA 寄存器的值。换句话说,它们需要从寄存器文件中读取数据,而这发生在 RF(寄存器读取)阶段。

流水线停顿策略

我们的旁路机制确保即使 RA 寄存器的值尚未写回寄存器文件,我们也能使用其正确值。我们关心的是,当跳转或分支指令处于 RF 阶段时,其后续指令的地址将在该周期结束时被加载到程序计数器中。但在 RF 阶段进行这些计算时,IF(指令取指)阶段应该做什么?

答案是,在跳转和分支被采纳的情况下,在那些指令能够在 RF 阶段访问 RA 寄存器的值之前,我们不知道 IF 阶段应该做什么。

一种解决方案是让 IF 阶段停顿,直到 RF 阶段计算出必要的结果。这是我们处理冒险的第一个通用策略。这是如何工作的?

如果 RF 阶段中的操作码是跳转、BEQBNE,则让 IF 阶段停顿一个周期。在示例代码中,假设执行 BNER3 的值非零,即 BNE 之后的指令应该是循环顶部的 ADC

流水线图显示了我们要实现的效果。在周期 4 和 8 中,一个空操作(no-op)被插入流水线。然后,在 RF 阶段确定下一条指令是什么之后的下一个周期,恢复执行。

请注意,我们依赖旁路逻辑从 MEM 阶段为 R3 传递正确的值,因为写入 R3ADC 指令仍在流水线中。换句话说,我们还需要处理一个数据冒险。

观察流水线图右侧的表格,我们看到执行三指令循环的一次迭代需要四个周期。因此,有效 CPI 为 4/3,增加了 33%。使用停顿来处理控制冒险影响了我们执行流水线的指令吞吐量。

实现指令作废

我们已经看到了将空操作引入流水线所需的逻辑。在这种情况下,我们在 IF 阶段的指令路径中添加一个由 IR_source_IF 信号控制的多路复用器。我们使用控制信号的上标来指示哪个流水线状态持有它们所控制的逻辑。

如果 RF 阶段中的操作码是跳转、BEQBNE,我们将 IR_source_IF 设置为 1,这会导致一个空操作替换正在从主存储器读取的指令。当然,我们将设置 PC 控制信号以选择正确的下一个 PC 值,以便 IF 阶段在下一个周期获取所需的后续指令。

如果我们用空操作替换一条指令,我们称之为作废该指令。

更复杂的分支与流水线刷新

Beta ISA 中的分支指令在 RF 阶段做出分支决策,因为它们只需要寄存器 RA 中的值。但假设 ISA 有一个在 ALU 阶段做出分支决策的分支指令。

当分支决策在 ALU 阶段做出时,我们需要向流水线中插入两个空操作,以替换 RF 和 IF 阶段中现在不需要的指令。这会进一步增加有效 CPI。

但权衡之处在于,更复杂的分支可能会减少程序中的指令数量。如果我们作废所有更早流水线阶段中的指令,这称为刷新流水线。由于刷新流水线对有效 CPI 影响很大,我们只在这是确保执行流水线正确行为的唯一方法时才这样做。

投机执行

在执行分支时,我们可以更智能地选择何时刷新流水线。如果分支未被采纳,事实证明流水线通过获取分支后的指令一直在做正确的事情。

即使我们不确定是否真的希望执行某条指令,也开始执行它,这称为投机执行。如果我们在指令对 CPU 状态产生影响(例如,写入寄存器文件或主存储器)之前能够将其作废,那么投机执行是可以接受的。由于这些称为副作用的状态更改发生在较晚的流水线阶段,因此指令可以在我们必须最终决定是否应将其作废之前,通过 IF、RF 和 ALU 阶段。

投机执行如何帮助处理控制冒险?猜测程序计数器的下一个值是 PC+4,这对于除跳转和被采纳的分支之外的所有情况都是正确的。

再次看我们的例子,但这次假设 BNE 未被采纳,即 R3 中的值为 0。SUB 指令在周期 4 开始时进入流水线。在周期 4 结束时,我们知道是否要作废 SUB。如果分支未被采纳,我们希望执行 SUB 指令,因此我们只是让它继续在流水线中前进。换句话说,我们不是总是作废分支后的指令,而只在分支被采纳时才作废它。如果分支未被采纳,则流水线投机正确,无需作废任何指令。

然而,如果 BNE 被采纳,则 SUB 在周期 4 结束时被作废,并且在周期 5 中执行一个空操作。因此,我们只在发生被采纳的分支时才在流水线中引入一个气泡。更少的气泡将减少作废对有效 CPI 的影响。

我们将使用与之前相同的数据通路电路,只是在设置 IR_source_IF 控制信号的值为 1 的时机上会更聪明一些。我们不是对所有分支都将其设置为 1,而只在分支被采纳时才将其设置为 1。

分支预测

我们总是猜测下一条指令来自 PC+4 的简单策略对于跳转和被采纳的分支是错误的。查看模拟执行轨迹,我们会发现这种投机错误会导致有效 CPI 增加约 10%。我们能做得更好吗?这对于具有深度流水线的 CPU 来说是一个重要问题。例如,英特尔 2009 年的 Nehalem 处理器在流水线相当靠后的阶段解析更复杂的 X86 分支指令。

由于 Nehalem 每个周期能够执行多条指令,在 Nehalem 中刷新流水线实际上会作废许多指令的执行,从而对 CPI 造成相当大的影响。与许多现代处理器实现一样,Nehalem 有一个更复杂的投机机制。

它不是总是猜测下一条指令在 PC+4,而是只对非分支指令这样做。对于分支,它基于该分支上次执行时的行为,以及对该分支使用方式的一些了解,来预测每个单独分支的行为。例如,循环末尾的后向分支(在除最后一次迭代外的所有迭代中都被采纳)可以通过其负的分支偏移值来识别。

Nehalem 甚至可以确定分支指令之间是否存在相关性,使用另一个更早分支的结果来推测当前分支的分支决策。通过这些复杂的策略,Nehalem 的投机正确率达到 95% 到 99%,大大降低了分支对有效 CPI 的影响。

分支延迟槽

还有一种“偷懒”的选择是改变 ISA 来处理控制冒险。例如,我们可以改变 ISA,规定跳转或分支后的指令总是被执行。换句话说,控制转移发生在下一条指令之后。这一更改确保将 PC+4 作为下一条指令地址的猜测总是正确的。

在所示的示例中,假设我们更改了 ISA,我们可以重新组织循环的执行顺序,将 MUL 指令放在 BNE 指令之后的所谓分支延迟槽中。由于分支延迟槽中的指令总是被执行,因此 MUL 指令将在循环的每次迭代中执行。假设我们能找到合适的指令放入延迟槽,由此产生的执行(如流水线图所示)将使分支对有效 CPI 的影响为零。

分支延迟槽是个好主意吗?似乎它们减少了分支可能对指令吞吐量产生的负面影响。缺点是,只有一半的时间我们能找到可以移动到分支延迟槽的指令。另一半时间,我们必须用显式的空操作指令填充它,从而增加了代码大小。而且,如果我们在流水线更靠后的阶段做出分支决策,就会有更多的分支延迟槽,这将更难填充。实际上,事实证明在减少分支影响方面,分支预测比延迟槽效果更好。

总结

本节课中我们一起学习了控制冒险。我们再次看到,为了改善流水线执行的吞吐量而改变 ISA 是有问题的。ISA 的生命周期比具体实现更长,因此最好不要改变执行语义来处理特定实现产生的性能问题。现代处理器更倾向于使用复杂的投机执行和分支预测机制,而非修改 ISA,来高效地处理控制冒险。

037:异常与中断处理 🚨

在本节课中,我们将要学习异常和中断如何影响流水线执行。我们将探讨当发生非法指令或外部中断时,处理器如何保存现场、跳转到处理程序,并确保程序的正确执行状态。

上一节我们介绍了流水线的基本原理,本节中我们来看看当程序执行遇到意外情况时,流水线应如何应对。

异常对流水线的影响

当发生非法指令或外部中断时,我们需要将 PC+4 的值存入 XP 寄存器,并将程序计数器加载到相应异常处理程序的地址。异常会导致控制流冒险,因为它们本质上是隐式的分支。

在非流水线实现中,异常只影响当前指令的执行。我们希望在我们的流水线实现中达到完全相同的效果。

因此,我们首先需要识别流水线中哪条指令受到了影响,然后确保该指令之前的代码正确完成,同时作废受影响的指令及其之后所有在流水线中的指令。由于流水线中存在多条指令,我们需要进行一些梳理工作。

异常的检测时机

在流水线执行过程中,我们何时确定一条指令将引发异常?

一个明显的例子是在 RF 阶段解码指令时检测到非法操作码。但异常也可能在其他流水线阶段产生。例如:

  • ALU 阶段可能在除法指令的第二操作数为 0 时产生异常。
  • MEM 阶段可能检测到指令试图访问非法地址。
  • 类似地,IF 阶段在获取下一条指令时可能产生内存异常。

在每种情况下,引发异常的指令之后的指令可能已经进入流水线,需要被作废。

好消息是,由于寄存器值只在写回阶段更新,作废一条指令只需要将其替换为 NOP 操作。我们无需恢复寄存器文件或主存中任何已更改的值。

异常处理方案

以下是我们的处理方案。如果一条指令在阶段 I 引发异常:

  1. 将该指令替换为特殊的 B anE 指令,其唯一副作用是将 PC+4 值写入 XP 寄存器。
  2. 通过作废更早流水线阶段中的指令来清空流水线。
  3. 最后,将程序计数器加载到异常处理程序的地址。

在这个例子中,假设 load 指令将在 MEM 阶段(发生在周期 4)产生内存异常。箭头显示了周期 5 时流水线中指令的改写情况,此时 IF 阶段正在获取异常处理程序的第一条指令。

流水线的必要修改

我们需要修改指令路径中的多路选择器,使其能够将实际指令替换为 NOP(如果指令被作废)或 B anE(如果指令引发异常)。

多异常处理

由于流水线同时执行多条指令,我们必须考虑如果在执行期间检测到多个异常会发生什么。

在这个例子中,假设 load 指令将在 MEM 阶段引起内存异常,并且请注意它后面跟着一条具有非法操作码的指令。

观察流水线图。非法操作码在周期 3 的 RF 阶段被检测到,导致非法指令异常处理在周期 4 开始。但在该周期,MEM 阶段检测到来自 load 指令的非法内存访问,因此导致内存异常处理在周期 5 开始。

请注意,由较早指令 load 引起的异常覆盖了由较晚非法操作码引起的异常,即使非法操作码异常被先检测到。这是正确的行为,因为一旦 load 的执行被放弃,流水线的行为应如同 load 之后的所有指令都未被执行一样。

如果在同一周期检测到多个异常,应优先处理流水线中最靠后指令引发的异常。

外部中断的处理

外部中断也表现为隐式分支,但事实证明它们在流水线中处理起来稍微容易一些。

我们将把外部中断视为影响 IF 阶段的异常。假设外部中断发生在周期 2。这意味着 sub 指令将被替换为我们的特殊 B& E 指令以捕获 PC+4 值,并强制下一条 PC 为中断处理程序的地址。

中断处理程序完成后,我们希望在被中断的 sub 指令处恢复执行。因此,我们将编写处理程序来更正已保存的 XP 寄存器中的值,使其指向 sub 指令。

这一切都显示在流水线图中。请注意,程序中位于 sub 之前的 addload 和其他指令不受中断影响。

我们可以使用现有的指令路径多路选择器来处理中断,因为我们将其视为 IF 阶段异常。我们只需要调整 IF 阶段 IR 源的逻辑,使其在请求中断时也输出 B anE 指令。

总结

本节课中我们一起学习了异常和中断在流水线处理器中的处理机制。我们了解到,异常通过在流水线特定阶段检测并替换为特殊指令来处理,需要保存 PC+4 并跳转到处理程序。当多个异常发生时,流水线中最靠后指令引发的异常具有优先权。外部中断则被当作影响取指阶段的异常来处理,确保了程序状态的正确保存与恢复。

038:15.2.6 流水线总结 🚀

在本节课中,我们将学习五级流水线数据通路的最终版本,并回顾我们为处理数据冒险、控制冒险以及异常和中断所添加的硬件逻辑。我们还将总结用于提升流水线性能的核心策略。

五级流水线数据通路最终版

上一节我们讨论了流水线中的各类冒险,本节中我们来看看为应对这些挑战而设计的完整数据通路。

下图展示了我们最终的五级流水线数据通路设计。

处理数据冒险

为了处理数据冒险,我们在数据通路中增加了两处关键设计。

以下是具体的硬件修改:

  • 我们在 IF(取指)和 RF(寄存器读取)阶段的输入寄存器处添加了暂停逻辑
  • 我们在寄存器文件读端口的输出处添加了旁路多路选择器。这样,如果需要访问一个已计算但尚未写回寄存器文件的值,我们可以从数据通路中更靠后的阶段(如 ALU 输出或内存读取结果)直接获取该值。
  • 我们还规定,当 IF 和 RF 阶段被暂停时,可以在 RF 阶段之后向流水线中插入空操作指令

处理控制冒险

为了处理控制冒险,我们采用了推测执行策略。

我们默认推测下一条指令的地址是 PC + 4。然而,对于跳转指令和条件分支指令(当分支被采纳时),这个猜测是错误的。

因此,我们增加了相应的机制,用于作废 IF 阶段中取出的错误指令。下图说明了这一过程。

处理异常与中断

为了处理异常和中断,我们在除最后一级流水线外的所有阶段都添加了指令作废逻辑。

具体的处理流程如下:

  • 一条引发异常的指令会被替换为一条特殊的 trap 指令,以捕获其 PC + 4 的值。
  • 所有位于该异常指令之前、尚在流水线更早阶段中的指令都会被作废。

性能与策略总结

所有增加的这些额外电路,都是为了确保流水线执行的结果与非流水线执行的结果完全相同。

通过使用旁路分支预测,我们确保了数据和控制冒险对有效 CPI 只产生很小的负面影响。这意味着,虽然每条指令的周期数可能略有增加,但大幅缩短的时钟周期最终带来了指令吞吐量的巨大提升。

下图概括了性能提升的关键。

值得牢记我们用来处理冒险的三大策略:暂停旁路推测。大多数执行问题都可以通过其中一种策略来解决。如果你未来需要设计高性能流水线系统,请务必记住这些策略。

课程总结

本节课中我们一起学习了五级流水线设计的最终形态,回顾了应对数据冒险、控制冒险及异常中断的硬件机制,并总结了提升流水线性能的核心策略。

关于流水线的讨论到此结束。在最后一讲中,我们将探索提升处理器性能的其他途径,即讨论并行处理

039:流水线Beta处理器工作示例解析 🧠

在本节课中,我们将学习如何分析一个在五级流水线Beta处理器上运行的程序。我们将通过绘制流水线图,来理解指令如何流经各个阶段,以及如何处理数据冒险、流水线停顿和分支延迟槽的取消。

概述

我们假设有一个功能完整的五级流水线Beta处理器,它具备完整的旁路(Bypassing)和分支延迟槽取消(Annulment)机制,正如课程中所介绍的那样。这个处理器已经运行了下面展示的程序一段时间。虽然程序的具体功能对本问题并不重要,但我们还是快速回顾一下。

程序开始时,先将寄存器R1初始化为0,然后进入循环。R1代表当前正在访问的数组元素的索引。在循环内部,该数组元素的值被加载到寄存器R0中。接着,R1的值增加4,以指向数组中的下一个元素。然后,程序将刚刚加载到R0的数组元素值与更新后的R1索引值进行比较。如果它们相等,则重复循环;如果不相等,则将当前R1的值存储到名为index的内存位置,以记录满足比较条件的索引值。

我们的目标是理解这个程序在Beta处理器上的运行情况。为此,我们将创建一个流水线图来展示程序的执行过程。

流水线图解析

流水线图展示了在五个流水线阶段中,每个阶段当前正在执行哪条指令。行代表指令所处的流水线阶段,共有五个阶段:

  1. IF(取指阶段):从内存中获取下一条指令。
  2. RF(寄存器文件阶段):读取指令的源操作数。
  3. ALU(算术逻辑单元阶段):执行所有需要的算术和逻辑单元操作。
  4. MEM(内存访问阶段):可以开始为加载或存储操作访问内存,因为内存地址已在ALU阶段计算出来。
  5. WB(写回阶段):将结果写回寄存器文件。

流水线图的列代表执行周期。

我们的循环以一条加载(LOAD)指令开始。因此,我们在周期1001的IF阶段看到了这条LOAD指令。接着,LOAD指令依次通过流水线的五个阶段。

下一条是ADDC指令。由于LOAD和ADDC指令之间没有数据依赖,ADDC指令在周期1002开始,并同样通过Beta流水线的所有五个阶段。

接下来是CMPEQ(比较相等)指令。当我们到达CMPEQ指令时,遇到了第一个数据冒险。这是因为LOAD指令正在更新R0,而CMPEQ指令需要读取R0的这个新值。

回忆一下,LOAD指令直到流水线的写回(WB)阶段才会产生其值。这意味着,即使有完整的旁路逻辑,CMPEQ指令也无法读取寄存器R0,除非LOAD指令处于写回阶段。因此,我们必须在周期1004启动流水线停顿。

在我们的流水线图中,可以在周期1005看到这个停顿:CMPEQ指令仍停留在RF阶段,并且我们在原本早一个周期进入流水线的CMPEQ指令的位置插入了一个空操作(NOP)。

CMPEQ之后的下一条指令是BNE(若不相等则分支)。注意,它在周期1004进入了IF阶段,但它也同样被CMPEQ指令的停顿所阻塞。因此,当CMPEQ卡在RF阶段时,BNE停留在IF阶段。

在周期1005,CMPEQ能够通过使用从写回阶段到RF阶段的旁路路径来读取R0的更新值,以及使用从MEM阶段到RF阶段的旁路路径来读取ADDC指令产生的R1更新值,从而完成对其操作数的读取。

在周期1006,CMPEQ指令进入ALU阶段,而BNE指令可以进入RF阶段。由于CMPEQ将要更新R2的值(这是BNE试图读取的寄存器),我们需要利用从ALU阶段到RF阶段的旁路路径,以便在周期1006为BNE提供CMPEQ指令的结果。

RF阶段也是生成Z信号的阶段。Z信号告诉Beta处理器一个寄存器是否等于0。这意味着,到周期1006的RF阶段结束时,BNE将知道是否应该重复循环。

我们现在说明如果循环在周期1006重复,流水线图会发生什么。在周期1006,存储(STORE)指令进入了流水线的IF阶段,因为在确定分支是否被采纳之前,我们假设应该继续取下一条指令。如果BNE确定应该分支回循环起点,那么这条刚刚取出的STORE指令必须被取消,方法是在其位置插入一个空操作(NOP)。这个取消操作在周期1006启动,并在周期1007的RF阶段显示为一个NOP。

在周期1007,我们还看到现在取出了循环的第一条指令,即LOAD指令,以便我们可以重复循环。

下图是一个完整的流水线图,展示了我们的示例代码中循环的重复执行,以及所使用的旁路路径、流水线停顿的启动和分支延迟槽的取消。

问题与解答

现在,我们准备回答几个关于该循环在Beta处理器上执行的问题。

问题一:寄存器读取方式

第一个问题是:寄存器R0、R1和/或R2中,哪些至少有一次是直接从寄存器文件读取的,而不是通过旁路路径?

回顾我们完整的流水线图,我们看到LOAD和ADDC指令没有通过旁路路径读取它们的操作数。由于这两条指令都读取了R1,这意味着寄存器R1至少有一次是直接从寄存器文件读取的。R0仅被CMPEQ读取,且总是来自旁路路径。同样,R2仅被BNE读取,也总是来自旁路路径。

答案:只有寄存器 R1 至少有一次是直接从寄存器文件读取的。

问题二:控制信号周期分析

接下来,我们想确定流水线Beta硬件中,控制信号在哪些周期被设置为特定值。

以下是具体问题与答案:

  1. 在哪个周期 stall 信号被设置为1?
    这发生在停顿启动的周期,即周期1004。在该周期结束时,通过不允许新的值加载到该流水线阶段的指令寄存器中,当前处于IF和RF阶段的指令被停顿。

  2. 在哪个周期 annul_IF 不等于0?
    annul_stage 控制信号指定在特定阶段何时启动取消操作。为了启动取消,当前在IF阶段的指令会被一个NOP替换。当我们需要取消一个分支延迟槽时,这发生在IF阶段。在我们的例子中,这发生在周期1006

  3. 在哪个周期 annul_RF 不等于0?
    这个问题是问在RF阶段何时启动了取消操作。这发生在CMPEQ指令需要被停顿在RF阶段以填充流水线气泡时。在周期1004,一个NOP被插入流水线,取代了当时在RF阶段的CMPEQ指令。因此,停顿以及 annul_RF 不等于0的设置,发生在周期1004

  4. 在哪个周期 annul_ALU 不等于0?
    换句话说,在哪个周期我们启动了用NOP替换ALU阶段指令的操作?在我们的示例中,这没有发生

问题三:旁路路径使用分析

现在,我们考虑旁路路径的使用情况。

  1. 在哪个周期使用了来自ALU阶段的任一旁路路径?
    在周期1006,BNE通过来自ALU阶段的旁路路径读取了CMPEQ指令的结果。

  2. 在哪个周期使用了来自MEM阶段的任一旁路路径?
    在周期1005,CMPEQ通过来自MEM阶段的旁路路径读取了ADDC指令的结果。

  3. 在哪个周期使用了来自写回(WB)阶段的任一旁路路径?
    在周期1005,CMPEQ通过来自写回阶段的旁路路径读取了LOAD指令的结果。

总结

在本节课中,我们一起学习了如何为运行在五级流水线Beta处理器上的程序绘制和分析流水线图。我们看到了指令如何流经IF、RF、ALU、MEM和WB阶段,并重点分析了数据冒险的处理:通过使用来自ALU、MEM和WB阶段的旁路路径来避免停顿,以及在无法避免时如何插入停顿周期(Stall)。我们还了解了分支预测错误时的处理机制,即通过将错误取入流水线的指令替换为空操作(NOP)来取消分支延迟槽。通过回答具体问题,我们巩固了对寄存器读取方式、控制信号生效时机以及旁路路径使用场景的理解。掌握这些知识对于深入理解处理器流水线的工作原理和性能分析至关重要。

040:Beta芯片分类测试教程 🧩

在本教程中,我们将学习如何通过一个特定的测试程序,来识别和分类四种不同类型的五级流水线Beta处理器芯片。我们将分析每种芯片在执行该程序时的行为差异,并最终根据程序跳转的目标地址来确定芯片的类别。


芯片类别概述

假设我们发现了一个房间,里面堆满了废弃的五级流水线Beta处理器。这些芯片分为四类:

以下是四种芯片类别的描述:

  1. C1:功能完整的Beta芯片。具备完整的数据旁路和分支延迟槽取消逻辑。
  2. C2:寄存器文件损坏的Beta芯片。所有直接从寄存器文件读取的数据都是0。注意:如果数据是通过旁路路径读取的,则能获得正确的值。
  3. C3:无旁路路径的Beta芯片
  4. C4:无分支延迟槽取消功能的Beta芯片

问题是这些芯片没有标签,我们不知道每个芯片属于哪一类。


测试程序介绍

为了解决这个问题,我们设计了一个测试程序。计划是使用每块Beta芯片单步执行该程序,并仔细记录最后一条JMP指令加载到程序计数器(PC)中的地址。我们的目标就是通过这个跳转地址来确定每块芯片属于上述四类中的哪一类。

注意:在一块功能完整的芯片上,这段代码会顺序执行指令,并跳过MUL指令。


C1:功能完整芯片的执行分析

上一节我们介绍了测试程序和目标,本节中我们来看看功能完整的C1类芯片是如何执行这段程序的。

下图展示了该程序在C1类芯片上的流水线执行图。

图中显示,尽管在第2个周期取指了MUL指令,但当BEQ指令处于RF(寄存器读取)阶段并确定将跳转到标签X(即SUBC指令)时,MUL指令被取消了。取消的方式是在其位置插入一个空操作(NOP)。

ADDCBEQ指令从寄存器文件中读取R31(其值恒为0)。然而,SUBC指令通过旁路路径从处于MEM(内存访问)阶段的BEQ指令获取了R2的值。

接着,ADD指令读取R0R2R0的值在周期4结束时已经通过ADDC指令写回寄存器文件,因此可以直接读取。而R2的值则是通过旁路路径从处于ALU(算术逻辑单元)阶段的SUBC指令读取的。

最后,JMP指令通过旁路路径从处于周期6ALU阶段的ADD指令读取R3的值。

程序行为总结

当在具备完整旁路路径和分支延迟槽取消功能的C1芯片上运行时,代码行为如下:

以下是每条指令执行后的寄存器状态变化:

  1. ADDC 设置 R0 = 4
  2. BEQPC+4 存入 R2。由于ADDC在地址0,BEQ在地址4,所以 PC+4 = 8 被存入 R2,程序跳转到标签X
  3. SUBCR2 的最新值(8)中减去4,将结果(4)存回 R2
  4. ADDR0 (4) 和 R2 (4) 相加,将结果(8)存入 R3
  5. JMP 跳转到 R3 中的地址,即 8

因此,C1芯片的最终跳转地址是 8


C2:寄存器文件损坏芯片的执行分析

了解了功能完整芯片的行为后,我们接下来分析C2类芯片,其寄存器文件损坏,总是输出0。

程序行为发生了一些变化。使用R31(其值本来就是0)的ADDCBEQ指令行为与之前相同。

SUBC指令通过旁路路径获取R2的值,因此读取到了正确的R2值(8)。核心概念是:只有直接从寄存器文件读取才会返回0,如果数据来自旁路路径,则能得到正确值。 所以SUBC计算出结果4。

ADD指令从寄存器文件读取R0,从旁路路径读取R2。结果是R0被当作0读取,而R2的值是正确的(4)。所以R3被赋值为4。

JMP指令也通过旁路路径读取其寄存器,因此跳转到地址 4

因此,C2芯片的最终跳转地址是 4


C3:无旁路路径芯片的执行分析

现在,我们来看C3类芯片,它没有任何旁路路径。这意味着一些指令会读取其源操作数的陈旧(过时)值。

让我们详细分析这个例子。ADDCBEQ指令从寄存器文件读取R31(值为0),所以它们最终产生的结果没有变化。但必须记住,目标寄存器的更新值要等到该指令完成流水线的回写(WB)阶段后才会生效。

SUBC读取R2时,它得到了R2的陈旧值,因为BEQ指令尚未完成,所以它认为R2 = 0。然后它从中减去4,试图将-4写入R2

接下来ADD运行。正常情况下,ADD会从寄存器文件读取R0,因为当ADD处于RF阶段时,写入R0ADDC指令已经完成了所有流水线阶段。ADD通常也会从旁路路径读取R2。然而,由于C3没有旁路路径,它从寄存器文件读取R2的陈旧值。

为了确定它读取的是哪个陈旧值,我们需要查看流水线图,看看在ADD读取其源操作数时,BEQSUBC(两者最终都会更新R2)是否已经完成。

观察流水线图,我们发现当ADD处于RF阶段时,BEQSUBC都尚未完成。因此,为R2读取的值是R2的初始值,即 0

我们现在可以确定ADD指令的行为:它认为R0 = 4R2 = 0,并最终将4写入R3

最后,JMP指令希望读取ADD的结果,但由于没有旁路路径,它读取了R3的原始值(0),并跳转到地址 0

简单推断:如果我们更仔细地看代码,可以不经过中间步骤就得出这个结论,因为ADD是唯一试图更新R3的指令。而JMP通常通过旁路路径获取R3,但旁路不可用。因此,它必须读取R3的原始值,即0。

因此,C3芯片的最终跳转地址是 0


C4:无分支延迟槽取消芯片的执行分析

最后,我们分析C4类芯片,这类Beta处理器不会取消在分支指令之后取指但本不应执行的指令。这意味着在BEQ之后取指的MUL指令实际上会在流水线中执行,并影响R2的值。

让我们仔细看看每条指令发生了什么。同样,ADDC设置R0 = 4BEQ设置R2 = 8。由于旁路路径正常工作,我们可以假设能立即获得更新后的值。

接下来,执行MUL指令,它通过旁路路径获取R2的最新值并将其乘以2,所以它设置R2 = 16

SUBC现在使用这个R2值(16),从中减去4,得到R2 = 12

ADD然后读取R0 = 4,并加上R2 = 12,得到R3 = 16

最后,JMP跳转到地址 16

因此,C4芯片的最终跳转地址是 16


总结与分类表

本节课中,我们一起学习了如何利用一个精心设计的测试程序来区分四类有缺陷的Beta处理器芯片。通过单步执行并观察最终的跳转地址,我们可以明确地将每块芯片归类。

由于四个类别各自产生一个唯一的跳转地址,该程序可用于将所有Beta芯片分类到这四个类别中。

以下是四类芯片对应的最终跳转地址:

  • C1(功能完整):跳转地址 = 8
  • C2(寄存器文件损坏):跳转地址 = 4
  • C3(无旁路路径):跳转地址 = 0
  • C4(无分支延迟槽取消):跳转地址 = 16

041:16.2.1 更深入的内存层次结构 🧠

在本节课中,我们将回到在第二部分第14讲中讨论过的内存系统。我们将探讨如何将内存层次结构扩展到主存之外,并引入虚拟内存系统的概念。

概述:从缓存到虚拟内存

上一节我们介绍了缓存如何利用局部性原理,通过硬件自动管理,为CPU提供对少量内存位置的快速访问。本节中,我们来看看如何将主存视为一个更大的“缓存”,用于访问容量巨大但速度极慢的二级存储(如硬盘),从而构建一个虚拟内存系统。

回顾:缓存与主存

我们之前了解到,现代内存技术存在一个根本性的权衡:随着内存容量的增加,其访问时间也会增加。缓存通过关联寻址等技术,自动管理CPU最常访问的内存位置内容,从而构建出兼具大容量和短平均访问时间的内存系统。缓存的有效性基于局部性原理:如果CPU在时间T访问了位置X,那么它在不久的将来很可能访问附近的位置。

缓存的组织方式使得附近的位置可以同时驻留在缓存中。如果CPU请求的地址存在于缓存中,则访问速度非常快。为了提高请求地址存在于缓存中的概率,我们引入了关联性的概念,增加了每次访问时检查的缓存位置数量,并解决了指令和数据竞争同一缓存位置的问题。我们还讨论了块大小(缓存行中的字数)、替换策略(在缓存未命中时选择重用哪条缓存行)和写策略(决定何时将更改的数据写回主存)的适当选择。

引入二级存储

我们从未讨论过主存中的数据来自哪里,以及填充主存的过程是如何管理的。这就是本节课的主题。

闪存驱动器和硬盘等二级存储设备提供了比主存更大的容量,并且具有非易失性的额外好处,即断电后数据依然保存。这些新设备的通用名称是二级存储,数据将驻留在这里,直到被移动到主存(即一级存储)以供使用。

因此,当我们首次启动计算机系统时,所有数据都位于二级存储中,我们可以将其视为内存层次结构的最终层。在设计正确的内存架构时,我们将基于之前讨论缓存时的思路,将主存视为永久性、大容量二级存储的另一个缓存层。

我们将构建一个虚拟内存系统。与缓存类似,该系统将根据需要自动将数据从二级存储移动到主存。虚拟内存系统还将允许我们控制程序可以访问哪些数据,为构建能够在单个CPU上安全运行多个程序的系统奠定基础。

二级存储的特性

让我们开始深入探讨。下图展示了我们第14讲中开发的内存系统的两个组件:缓存和主存。以及我们新的二级存储层。

好消息是,二级存储的容量非常巨大。😊 即使是最普通的现代计算机系统也有数百GB的二级存储,中型台式机上拥有1-2TB也很常见。云端的二级存储容量可以增长到许多PB(1 PB = 10^15 字节,即一百万GB)。

坏消息是,磁盘的访问时间比DRAM长100,000倍。因此,从DRAM到磁盘的访问时间变化,远比从缓存到DRAM的变化大得多。在研究DRAM时序时,我们发现,与访问第一个字的时间相比,检索连续字块的额外访问时间很小。因此,假设我们最终会访问额外的字,获取一个块是正确的计划。

对于磁盘,第一个字和后续字的访问时间差异更加显著,因此毫不奇怪,我们将从磁盘读取相当大的数据块。二级存储访问时间极长的后果是,如果我们需要的数据不在主存中,访问磁盘将非常耗时。因此,我们需要设计虚拟内存系统,以最小化访问主存时的未命中率。一次未命中及随后的磁盘访问将对平均内存访问时间产生巨大影响。因此,未命中率需要非常非常低,例如,相对于指令执行速率而言。

虚拟内存系统的设计考量

考虑到二级存储巨大的未命中代价,这告诉我们应该如何将其用作内存层次结构的一部分?

以下是关键的设计考量:

  • 高关联性:我们需要极大的灵活性来确定磁盘数据如何放置在主存中。换句话说,如果我们的内存访问工作集能够放入主存,虚拟内存系统应该能够实现这一点,避免不同数据块访问之间不必要的冲突。
  • 大块大小:为了利用从磁盘读取连续字时增量成本低的优势,我们希望使用大的块大小。根据局部性原理,我们预期会访问该块的其他字,从而将未命中的成本分摊到未来的多次命中上。
  • 写回策略:我们希望采用一种写回策略,即只有当主存中已更改的数据需要被二级存储中其他块的数据替换时,才需要更新磁盘内容。

未命中具有如此长的延迟也有一个好处:我们可以用软件来管理主存的组织和二级存储的访问。即使处理一次未命中的后果需要执行数千条指令,与磁盘的访问时间相比,执行这些指令也很快。因此,我们的策略将是:用硬件处理命中,用软件处理未命中。这将导致简单的内存管理硬件,并有可能使用软件实现的非常聪明的策略来决定在未命中时该怎么做。

总结

本节课中,我们一起学习了如何将内存层次结构扩展到主存之外。我们引入了二级存储(如硬盘)作为大容量、非易失性但速度慢的存储层。为了有效利用它,我们提出了虚拟内存系统的概念,将主存视为二级存储的缓存。鉴于二级存储极长的访问延迟,虚拟内存系统的设计需要高关联性大块大小写回策略。一个关键的策略转变是,利用软件来处理代价高昂的未命中事件,而让硬件高效处理常见的命中情况。这为后续讨论内存管理和多程序安全运行奠定了基础。

042:虚拟内存基础 🧠

在本节课中,我们将要学习虚拟内存系统的基本工作原理。虚拟内存是现代计算机系统中的一项关键技术,它允许程序使用比物理内存更大的地址空间,并提供了内存隔离和按需加载等强大功能。

概述

虚拟内存系统通过在CPU和主存之间引入一个称为内存管理单元(MMU)的硬件来工作。CPU生成的内存地址被称为虚拟地址,以区别于主存使用的物理地址。MMU的核心任务是将虚拟地址转换为物理地址。

上一节我们介绍了地址空间的概念,本节中我们来看看虚拟内存是如何具体实现的。

MMU与地址转换

MMU硬件通过一个简单的表查找来完成虚拟地址到物理地址的转换。这个表被称为页表。

从概念上讲,MMU使用虚拟地址作为索引来选择表中的条目,该条目会告诉我们对应的物理地址。这个表允许特定的虚拟地址被映射到主存中的任何位置。

在正常操作中,我们需要确保两个虚拟地址不会映射到同一个物理地址。但是,如果某些虚拟地址没有对应的物理地址转换,这是可以接受的。这表明所请求的虚拟地址的内容尚未加载到主存中,因此MMU会向CPU发出一个内存管理异常信号。CPU可以分配一个物理内存位置,并执行所需的I/O操作,从二级存储(如硬盘)中初始化该位置。

MMU表为系统提供了对CPU上运行的程序如何访问物理内存的大量控制。例如,我们可以安排快速连续地运行多个程序,这种技术称为分时。通过在不同程序间切换时更改页表,并妥善管理各自的页表,可以使一个程序可访问的主存位置对另一个程序不可访问。

我们还可以利用内存管理异常,按需将程序内容加载到主存中,而不是在程序开始执行前就必须加载整个程序。实际上,我们只需要确保程序的当前工作集实际驻留在主存中即可。当前未使用的位置可以留在二级存储中,直到需要时再加载。

在本节课和下一节课中,我们将看到MMU如何在现代分时计算机系统的设计中扮演核心角色。

分页机制

当然,我们需要一个不可能巨大的表来将每个虚拟地址单独映射到物理地址。因此,我们将虚拟和物理地址空间都划分为固定大小的块,称为页。

以下是关于分页的关键细节:

  • 页大小:页大小总是2的幂字节,例如 2^P 字节。因此,P 是选择页上特定位置所需的地址位数。
  • 地址划分:我们使用虚拟或物理地址的低 P 位作为页内偏移量。剩余的地址位告诉我们正在访问哪个页,称为页号。
  • 典型值:典型的页大小是4到16千字节,分别对应 P=12P=14

假设 P=12。如果CPU产生一个32位的虚拟地址,那么虚拟地址的低12位是页内偏移量,高20位是虚拟页号。同样,物理地址的低 P 位是页内偏移量,剩余的物理地址位是物理页号。

关键思想是MMU将管理页,而不是单个内存位置。它将整个页从二级存储移动到主存中。根据局部性原理,如果一个程序访问了页上的一个位置,我们预计它很快就会访问附近的其他位置。通过从低地址位选择页内偏移量,我们可以确保附近的位置位于同一页上(除非我们靠近页的某一端)。因此,页自然地捕捉了局部性的概念。并且由于页很大,在处理二级存储访问时,读写许多位置只比访问第一个位置稍微耗时一点,这让我们能利用这个优势。

MMU将虚拟页号映射到物理页号。它通过使用虚拟页号作为页表的索引来实现这一点。页表中的每个条目指示该页是否驻留在主存中,如果是,则提供相应的物理页号。物理页号与页内偏移量结合,形成主存的物理地址。

如果请求的虚拟页未驻留在主存中,MMU会向CPU发出一个称为页错误的内存管理异常信号,以便CPU可以从二级存储加载适当的页,并在MMU中建立相应的映射。

我们计划使用主存作为页缓存,这被称为分页,有时也称为按需分页,因为页在二级存储和主存之间的移动是由程序的需求决定的。

按需分页流程

以下是按需分页的具体计划。

最初,程序的所有虚拟页都驻留在二级存储中,MMU是空的。换句话说,没有页驻留在物理内存中。

CPU开始运行程序,它生成的每个虚拟地址(无论是用于取指令还是数据访问)都会传递给MMU,以映射到主存中的物理地址。

如果虚拟地址驻留在物理内存中,主存硬件可以完成访问。如果虚拟地址未驻留在物理内存中,MMU会发出页错误异常,迫使CPU切换到称为页错误处理程序的特殊代码执行。

处理程序分配一个物理页来保存请求的虚拟页,并将虚拟页从二级存储加载到主存中。然后,它调整请求的虚拟页的页表条目,以显示其现在已驻留,并指示新分配和初始化的物理页的物理页号。

在尝试分配物理页时,处理程序可能会发现所有物理页当前都在使用中。在这种情况下,它会选择一个现有的页进行替换,例如,选择一个最近未被访问的驻留虚拟页。它将所选虚拟页的内容交换到二级存储,并更新被替换虚拟页的页表条目,以指示其不再驻留。现在,就有了一个空闲的物理页可以重新使用,来保存缺失的虚拟页的内容。

程序的工作集,即程序当前正在访问的页集合,通过一系列页错误被加载到主存中。在程序开始运行时经历一阵页错误之后,工作集变化缓慢,因此页错误的频率会急剧下降,可能接近于零(如果程序规模小且行为良好)。但是,也有可能编写出持续产生页错误的程序,这种现象称为颠簸。考虑到二级存储的长访问时间,发生颠簸的程序运行得非常慢,通常慢到用户放弃并重写程序以使其行为更合理。

页表设计

页表的设计是直观的。页表中为每个虚拟页设置一个条目。

以下是页表条目的构成:

  • 条目数量:例如,如果CPU生成32位虚拟地址,页大小为 2^12 字节,则虚拟页号有 32-12=20 位,页表将有 2^20 个条目。
  • 驻留位(R):页表中的每个条目包含一个驻留位 R,当虚拟页驻留在物理内存中时,该位设置为1。如果 R 为0,访问该虚拟页将导致页错误。如果 R 为1,该条目还包含物理页号,指示在主存中何处可以找到该虚拟页。
  • 脏位(D):还有一个额外的状态位称为脏位 D。当一个页刚从二级存储加载时,它是干净的。换句话说,物理内存的内容与二级存储中页的内容匹配。因此,脏位被设置为零。如果随后CPU向该页上的某个位置进行存储操作,则该页的脏位被设置为1,表示该页是脏的。换句话说,内存的内容现在与二级存储的内容不同。如果一个脏页被选择进行替换,在页被重用之前,必须将其内容写入二级存储以保存更改。

一些MMU在每个页表条目中还有额外的状态位。例如,可以有一个只读位,当设置时,如果程序试图向该页存储数据,将生成异常。这对于保护代码页免受错误数据访问的意外损坏非常有用,这是一个非常方便的调试功能。

MMU工作示例

这里有一个MMU实际工作的例子。为了简化,假设虚拟地址是12位,由一个8位的页内偏移量和一个4位的虚拟页号组成,因此有 2^4=16 个虚拟页。物理地址是11位,分为相同的8位页内偏移量和一个3位的物理页号,因此有 2^3=8 个物理页。

在左侧,我们看到一个显示16条目页表内容的图表,即每个虚拟页一个条目。每个页表条目包括一个脏位 D、一个驻留位 R 和一个3位的物理页号,总共5位。因此,页表有16个条目,每个5位,总共 16 * 5 = 80 位。表中的第一个条目对应虚拟页0,第二个条目对应虚拟页1,依此类推。

在幻灯片中间,有一个显示八个物理页的物理内存图。每个物理页的注释显示了其内容的虚拟页号。请注意,虚拟页存储在物理内存中没有特定的顺序。哪个页保存什么内容取决于页错误发生时哪些页是空闲的。一般来说,在程序运行一段时间后,我们会期望看到这里显示的这种混乱排序。

让我们跟随MMU处理虚拟地址 0x2C8 的请求,该地址由这里显示的加载指令执行生成。

将虚拟地址拆分为页号和偏移量,我们看到虚拟页号是2,偏移量是 0xC8。查看索引为2的页表条目,我们看到 R 是1,表示虚拟页2驻留在物理内存中。该条目的 PPN 字段告诉我们,虚拟页2可以在物理页4中找到。将 PPN 与8位偏移量结合,我们发现虚拟地址 0x2C8 的内容可以在主存位置 0x4C8 找到。

请注意,偏移量在转换过程中保持不变,物理页内的偏移量始终与虚拟页内的偏移量相同。

总结

本节课中我们一起学习了虚拟内存的基础知识。我们了解到,虚拟内存系统通过内存管理单元(MMU)和页表,将程序使用的虚拟地址空间映射到实际的物理内存地址空间。核心机制是分页,它将内存划分为固定大小的页,并按需在物理内存和二级存储之间移动这些页。这允许程序使用比物理内存更大的地址空间,并支持内存隔离、按需加载等高级功能,是现代操作系统实现多任务和高效内存管理的基石。

043:16.2.3 缺页异常 📄

在本节课中,我们将要学习当CPU访问一个不在物理内存中的虚拟页时会发生什么,这个过程被称为“缺页异常”。我们将详细拆解缺页处理的全过程,并通过一个具体例子来加深理解。

缺页异常处理流程 🔄

上一节我们介绍了虚拟内存的基本概念,本节中我们来看看当CPU试图访问一个驻留位(R位)为0的非驻留虚拟页时,系统如何响应。

以下是缺页异常处理的标准步骤:

  1. 触发异常:CPU访问一个R位为0的虚拟页,内存管理单元(MMU)会发出一个缺页异常信号。
  2. 切换处理器:CPU暂停当前程序的执行,转而运行操作系统中的缺页异常处理程序。
  3. 选择替换页:处理程序首先需要找到一个可用的物理页。它可能直接找到一个空闲页,或者通过选择一个正在使用的页并将其内容移出来“创造”一个空闲页。
  4. 写回脏页:如果被选中的替换页是“脏的”(即其D位为1,表示自上次从二级存储读入后内容已被修改),则必须将其内容写回二级存储(如硬盘)。
  5. 更新页表:将选中的虚拟页标记为非驻留(将其R位设为0)。
  6. 加载目标页:将目标虚拟页的内容从二级存储读入到刚刚腾出的物理页中。
  7. 更新目标页表项:更新目标虚拟页的页表项,将其R位设为1,并填入正确的物理页号(PPN)。
  8. 恢复执行:处理程序结束,CPU恢复执行原程序,并重新执行那条引发缺页异常的指令。此时,由于页表已更新,内存访问将成功。

页面替换策略 🤔

在步骤3中,我们需要选择一个页面进行替换。这里存在一个关键问题:我们应该选择哪个页面?

  • 限制:有些页面不能被选择,例如存放缺页处理程序代码本身的页面(称为“有线”页面)。选择刚刚触发访问的页面也非常低效。
  • 理想策略:最优策略是选择那个在未来最长时间内都不会被再次使用的页面。但这需要预知未来的执行路径,因此无法实现。
  • 实际策略:存在多种权衡实现难度与缺页率的替换策略。幻灯片底部提供的维基百科链接对此有详细描述。其中描述的“老化算法”因其能以适中的实现成本提供接近最优的性能而被频繁使用。

实例分析:深入理解缺页 📝

为了双重确认我们对缺页的理解,让我们通过一个具体例子来演练一遍。

假设当前状态如上图所示。现在,考虑一条存储指令正在访问虚拟地址 0x600,该地址位于虚拟页6(VPN 6)上。

  1. 检查页表:查看VPN 6的页表项,发现其R位为0,表明它不在主存中。这触发了缺页异常。
  2. 选择替换页:根据题目设定,缺页处理程序选择最近最少使用的VPN 4作为替换页。
  3. 处理脏页:VPN 4的页表项中D=1,因此处理程序将物理页5中的内容(即VPN 4的内容)写回二级存储。
  4. 更新替换页状态:更新页表,将VPN 4标记为非驻留(R位设为0)。
  5. 加载目标页:将VPN 6的内容从二级存储读入现在可用的物理页5。
  6. 更新目标页状态:更新VPN 6的页表项,指明它现在驻留在物理页5中(R=1,PPN=5)。
  7. 恢复与执行:缺页处理程序完成工作,程序恢复执行,重新执行那条存储指令。这次,MMU成功将虚拟地址 0x600 转换为物理地址 0x500。由于存储指令修改了VPN 6的内容,其D位被设置为1。

硬件与软件的分工 ⚙️

我们可以将MMU的工作分为两个任务,用计算机科学的术语来说,就是两个过程。

  • V2P 过程(地址转换):每次内存访问时都会调用,负责将虚拟地址转换为物理地址。它使用页表信息(驻留位数组、脏位数组、物理页号数组)进行查找。如果请求的虚拟页未驻留,则调用 page_fault 过程。
  • page_fault 过程(缺页处理):当 V2P 发现缺页时被调用。它负责选择替换页、必要时写回脏页、从二级存储加载目标页,并更新页表信息。

在实现上,我们采用了一个良好的策略:

  • 硬件实现 V2P:因为每次内存访问都需要它,必须追求速度。
  • 软件实现 page_fault:通过缺页异常机制,引导CPU去执行包含 page_fault 过程的处理程序软件。缺页是希望不常发生的异常情况,用软件处理更为灵活。

这本质上是在专用硬件(如MMU)和通用硬件(如CPU)之间进行权衡。我们通常对使用专用硬件的提议持怀疑态度,只将其留给那些确实非常频繁且对系统整体性能至关重要的操作。

总结 📚

本节课中我们一起学习了缺页异常的处理机制。我们了解到,当CPU访问一个不在物理内存中的页面时,会触发缺页异常,由操作系统的处理程序接管。该处理程序负责选择一个物理页进行替换、必要时保存其内容、从硬盘加载所需页面,并更新页表。最后,原程序得以恢复执行。整个系统通过硬件实现快速的常规地址转换,而通过软件异常处理不常见的缺页情况,实现了效率与灵活性的平衡。

044:6.2.4 构建内存管理单元(MMU)🚀

在本节中,我们将学习如何构建一个内存管理单元(MMU)。我们将了解定义虚拟内存系统的三个关键架构参数,并探讨如何利用页表、转换后备缓冲器(TLB)和主存来实现高效的地址转换。


虚拟内存系统由三个架构参数定义,它们也决定了MMU的架构。

  • P:用于虚拟地址和物理地址中页内偏移量的地址位数。
  • V:用于虚拟页号的地址位数。
  • M:用于物理页号的地址位数。

右侧列出的所有其他参数都源自这三个参数。


上一节我们介绍了MMU的基本参数,本节中我们来看看这些参数的实际应用场景。

典型的页大小在4KB到16KB之间。这个大小的选择是在权衡利弊后找到的平衡点:一方面,使用物理内存存放不常用的页会带来浪费;另一方面,从二级存储(如硬盘)读取数据时,我们希望尽可能多地读取连续数据,以分摊访问初始字的高昂成本。

虚拟地址的大小由指令集架构决定。目前,我们正从支持4GB虚拟地址空间的32位架构,过渡到支持16EB虚拟地址空间的64位架构。Exa是国际单位制前缀,代表10的18次方。

一个64位地址可以访问海量内存。虚拟地址空间过小曾是许多指令集架构扩展的主要原因。当然,每一代工程师都认为他们所做的过渡将是最终版本。我记得我们都曾认为32位是一个难以想象的大地址空间。那时我们按兆字节购买内存,只有在幻想中才认为系统能有几千兆字节的内存。如今,CPU架构师对64位感到相当满意。我们将在几十年后看看他们的感受。

物理地址的大小目前介于30位(用于内存需求适中的嵌入式处理器)和40位以上(用于处理大型数据集的服务器)之间。由于CPU实现预计每几年就会更新,物理内存大小的选择可以调整以适应当前技术。由于程序员使用虚拟地址,他们与这个实现选择是隔离的。MMU确保现有软件在不同大小的物理内存下都能继续正常运行。程序员可能会注意到性能差异,但基本功能不会改变。


为了更具体地理解这些参数,让我们来看一个例子。

假设我们的系统支持32位虚拟地址、30位物理地址和4KB页大小。这意味着:

  • P = 12
  • V = 32 - 12 = 20
  • M = 30 - 12 = 18

根据这些参数,我们可以推导出:

  • 物理页数量 = 2^M = 2^18(在本例中)。
  • 虚拟页数量 = 2^V = 2^20(在本例中)。
  • 由于页表中每个虚拟页都有一个条目,因此页表条目数 = 2^20,约100万个。
  • 每个页表条目包含一个物理页号、一个R位和一个D位,总共 M + 2 位,在本例中是20位。
  • 因此,页表总大小约为2000万位。

如果我们考虑使用一个大型专用静态RAM来存放页表,这将非常昂贵。


既然使用专用内存存放页表成本高昂,我们自然会想到替代方案。为什么不使用主存的一部分呢?我们拥有大量主存并且已经为其付费。

我们可以使用一个称为页表指针的寄存器来存放主存中页表数组的地址。换句话说,页表将占用一些专用的物理页。硬件可以使用所需的虚拟页号作为索引,执行常规的数组访问计算,从主存中获取所需的页表条目。

这种实现方案的缺点是,现在执行一次虚拟访问需要进行两次物理内存访问:第一次是获取虚拟到物理地址转换所需的页表条目,第二次才是实际访问请求的位置。


再次,缓存来拯救我们。大多数系统包含一个称为转换后备缓冲器的特殊用途缓存,它映射虚拟页号到物理页号。TLB通常很小且速度很快。它通常是全相联的,以避免冲突,从而确保尽可能高的命中率。

如果使用TLB找到了物理页号,就可以避免为获取页表条目而访问主存,这样我们又回到了每次虚拟访问只需一次物理访问的状态。TLB的命中率非常高,通常超过99%。这并不奇怪,因为局部性和工作集的概念表明,在短时间内只有少量页面处于活跃使用状态。正如我们将在后面几张幻灯片中看到的,这个简单的TLB、页表和主存架构有一些有趣的变体,但基本策略保持不变。


现在,让我们把所有部分整合起来,看看完整的地址转换流程。

CPU生成的虚拟地址首先由TLB处理,以查看是否已缓存了从VPN到PPN的适当转换。如果是,则可以直接进行主存访问。如果所需的映射不在TLB中,则访问主存中的页表相应条目。如果该页是常驻的,则使用页表条目的PPN字段来完成地址转换。当然,这个转换会被缓存在TLB中,以便后续对该页的访问可以避免访问页表。如果所需的页面非常驻,MMU会触发一个缺页异常,缺页异常处理程序代码将处理此问题。


最后,我们通过一个完整的例子来展示所有组件如何协同工作。

在这个例子中,P = 10,V = 22,M = 14。

  • 物理内存中一次可以存放多少页?
    有 2^M 个物理页,所以是 2^14 页。
  • 页表中有多少个条目?
    每个虚拟页有一个条目,有 2^V 个虚拟页,所以页表中有 2^22 个条目。
  • 页表中每个条目有多少位?
    假设每个条目包含PPN、常驻位和脏位。由于PPN是M位,每个条目有 M + 2 位,所以是16位。
  • 页表占用了多少页?
    有 2^V 个页表条目,每个占用 (M+2)/8 字节,所以本例中页表总大小为 2^23 字节。每页容纳 2^P 或 2^10 字节,因此页表占用 2^23 / 2^10 = 2^13 页。
  • 在任何给定时间,可以访问的虚拟内存比例是多少?
    有 2^V 个虚拟页,其中 2^M 个可以是常驻的,所以常驻页的比例是 2^M / 2^V = 2^14 / 2^22 = 1 / 2^8。


让我们进行一些具体的地址转换,并指出涉及的MMU组件。

虚拟地址 0x1804 对应的物理地址是什么?
首先,我们必须将虚拟地址分解为VPN和偏移量。偏移量是低10位,因此在本例中是 0x4。VPN是剩余的地址位,所以VPN是6。首先查看TLB,我们发现VPN 6 到 PPN 2 的映射已被缓存,因此我们可以通过将PPN 2 与10位偏移量 0x4 拼接起来,得到物理地址 0x804。

虚拟地址 0x1080 呢?
对于这个地址,VPN是4,偏移量是 0x80。VPN 4 的转换未在TLB中缓存,因此我们必须检查页表,页表告诉我们该页常驻在物理页5。拼接PPN和偏移量,我们得到物理地址 0x1480。

最后,虚拟地址 0xFC 呢?
这里VPN是0,偏移量是 0xFC。在TLB中未找到VPN 0 的映射,检查页表显示VPN 0 非常驻在主存中,因此触发缺页异常。

关于示例中的TLB和页表内容,有几点需要注意:TLB条目可能无效(其R位为0)。当虚拟页被替换时会发生这种情况。因此,当我们在页表中将R改为0时,我们也必须在TLB中做同样的事情。我们应该担心PPN 5 在页表中出现两次吗?请注意,VPN 3 的条目无关紧要,因为它的R位是0。通常,当标记一个页面非常驻时,我们不会费心清除条目中的其他字段,因为当R=0时它们不会被使用。所以,实际上只有一个有效的映射指向PPN 5。


本节课中我们一起学习了构建MMU的核心概念。我们定义了关键参数P、V、M,并理解了它们如何决定系统规模。我们探讨了使用主存存放页表的经济性方案,以及引入TLB缓存来避免性能下降的巧妙方法。最后,我们通过一个完整示例,演练了从虚拟地址到物理地址的转换全过程,包括TLB命中、TLB未命中访问页表以及触发缺页异常的情况。理解这些机制是掌握现代计算机内存管理的基础。

045:6.2.5 上下文

在本节课中,我们将要学习计算机系统中的“上下文”概念。上下文是理解虚拟地址的关键,它使得多个程序能够安全、独立地共享同一物理内存。我们将探讨上下文如何定义、如何切换,以及操作系统如何利用它来管理用户程序。

页表提供上下文

页表为解释虚拟地址提供了上下文。换句话说,它提供了正确确定虚拟地址在主存或辅助存储器中位置所需的信息。

多个程序可以同时加载到主存中,每个程序都有自己的上下文。独立的上下文确保了程序之间不会相互干扰。例如,一个程序中虚拟地址0对应的物理位置,与另一个程序中虚拟地址0对应的物理位置是不同的。

每个程序都在自己的虚拟地址空间中独立运行。正是页表提供的上下文,使得它们能够共存并共享一个公共的物理内存。

切换程序时切换上下文

因此,在切换程序时,我们需要切换上下文。这是通过重新加载页表来实现的。

在分时系统中,系统会周期性地从一个运行程序切换到另一个,从而制造出多个程序各自在自己的虚拟机上运行的假象。这是通过在切换CPU状态到下一个程序时切换上下文来实现的。

操作系统与内核模式

存在一组被称为操作系统的特权代码,它管理着一个物理处理器和主存在许多程序之间的共享,每个程序都有自己的CPU状态和虚拟地址空间。操作系统有效地创建了许多虚拟机,并使用一组共享的物理资源来编排它们的执行。

操作系统运行在一个特殊的操作系统上下文中,我们称之为内核。操作系统包含必要的异常处理程序和分时支持。由于它需要管理物理内存,在处理缺页错误等事务时,它被允许访问任何物理位置。

运行程序中的异常会导致硬件切换到内核上下文,我们称之为进入内核模式。异常处理完成后,程序在我们称之为用户模式的上下文中恢复执行。由于操作系统运行在内核模式,它拥有对许多在用户模式下无法访问的硬件寄存器的特权访问权限。这些寄存器包括MMU状态、IO设备等。

需要访问磁盘等资源的用户模式程序,必须向操作系统内核发出请求以执行操作,这给了操作系统审查请求权限等的机会。我们将在后续课程中看到这一切是如何运作的。

用户程序的虚拟地址空间布局

用户模式程序,也称为应用程序,其编写方式就好像它们可以访问整个虚拟地址空间。它们通常遵循相同的约定,例如程序第一条指令的地址、栈指针的初始值等。由于所有这些虚拟地址都是使用当前上下文来解释的,通过控制上下文,操作系统可以确保程序能够无冲突地共存。

右侧的图表展示了一个组织应用程序虚拟地址空间的标准方案。

以下是典型的虚拟地址空间布局:

  • 不可访问页:通常,第一个虚拟页被设置为不可访问。这有助于捕获涉及引用未初始化(即值为零)指针的错误。
  • 只读代码页:接下来是一些只读页,用于存放应用程序的代码以及它可能使用的任何共享库的代码。将代码页标记为只读可以避免因错误的数据访问无意中更改程序而导致的难以发现的错误。
  • 读写数据页:然后是读写页,用于存放应用程序静态分配的数据结构。
  • 栈区:虚拟地址空间的其余部分被划分为两个可以随时间增长的数据区域。第一个是应用程序栈,用于存放过程激活记录。图中我们将其置于虚拟地址空间的低端,因为我们的约定是栈向高地址方向增长。
  • 堆区:另一个全局区域是堆,用于为长期存在的数据结构动态分配存储空间。“动态”意味着对象的分配和释放是在应用程序运行时通过显式的过程调用完成的。换句话说,在程序实际执行之前,我们不知道会创建哪些对象。如图所示,随着堆的扩展,它向低地址方向增长。

缺页处理程序知道在这些区域增长时分配新的页面。当然,如果它们在某处相遇并且需要更多空间,应用程序就不走运了——它耗尽了虚拟内存。

总结

本节课中,我们一起学习了计算机系统中的上下文概念。我们了解到,上下文由页表定义,它使得每个程序拥有独立的虚拟地址空间,从而安全地共享物理内存。操作系统通过在内核模式下管理上下文切换和资源分配,实现了多程序的并发执行。我们还探讨了用户程序虚拟地址空间的典型布局,包括代码区、数据区、栈和堆,理解了操作系统如何通过控制上下文来组织和管理这些区域。

046:MMU的改进 🚀

在本节课中,我们将探讨内存管理单元(MMU)的一些实现细节优化,这些优化旨在提升效率或增加功能。我们将了解分层页表、上下文切换的性能影响,以及如何将缓存与MMU高效集成。

分层页表结构 🗂️

上一节我们介绍了简单的页表映射。在那种实现中,完整的页表会占用一定数量的物理页。例如,如果每个页表项占用主存的一个字,那么一个包含220个项的页表就需要210个物理页来存储。当系统中有多个进程上下文时,每个进程都需要自己的页表,这对物理内存资源的需求会变得非常大。

为了应对这个问题,我们可以采用一种分层页表的实现。以下是其工作原理:

  • 页目录:虚拟地址的最高10位用于访问一个页目录。页目录本身也是一个存储在物理内存中的数据结构。
  • 页表段:页目录中的每一项指向一个物理页,该物理页存放着对应那部分虚拟地址空间的页表段
  • 按需驻留:关键在于,这些页表段本身也位于虚拟内存中。换句话说,它们并不需要在任何时刻都全部驻留在物理内存里。
  • 节省资源:如果正在运行的应用程序只活跃地使用其虚拟地址空间的一小部分,那么我们可能只需要少数几个页来存放页目录和必要的页表段。当系统中有许多应用程序时,这种节省效果会非常显著。

在这个例子中,页目录中对应于堆和栈之间尚未分配的虚拟内存的条目都被标记为“未驻留”。因此,我们无需为海量的、标记为“未驻留”的页表项分配任何页表资源。

访问页表现在需要两次内存访问:首先访问页目录,然后访问相应的页表段。但由于转换后备缓冲器(TLB) 的存在,这次额外访问的影响可以忽略不计。

优化上下文切换 🔄

通常,在切换上下文时,操作系统会重新加载页表指针,使其指向新进程的页表(或页目录)。由于这次切换实际上改变了页表中的所有条目,操作系统还必须使TLB中的所有条目失效。这自然会对TLB命中率产生巨大影响,并且在TLB被重新填满之前,大量的页表访问会导致平均内存访问时间急剧增加。

为了减少上下文切换的影响,一些MMU包含一个上下文编号寄存器。其内容会与虚拟页号(VPN)拼接起来,共同作为查询TLB的键值。本质上,这意味着TLB缓存条目中的标签字段被扩展,包含了填充该TLB条目时提供的上下文编号。

以下是其工作方式:

  • 切换上下文:现在,操作系统在切换上下文时,会同时加载新的上下文编号到上下文编号寄存器,以及新的页表指针。
  • 无需刷新TLB:由于TLB中属于其他上下文的条目将不再匹配新的查询键值,因此在上下文切换时无需刷新整个TLB
  • 性能提升:如果TLB有足够的容量来缓存多个上下文的VPN到物理页号(PPN)映射,那么上下文切换对平均内存访问时间的影响将大大降低。

集成缓存与MMU ⚙️

最后,让我们回到如何将缓存和MMU集成到内存系统中的问题。这里有两种主要选择。

第一种选择是将缓存放在CPU和MMU之间。换句话说,缓存基于虚拟地址工作。这看起来不错,因为VPN到PPN的转换成本只发生在缓存未命中时。但困难在于上下文切换时,虚拟内存的有效内容会改变。这意味着操作系统在执行上下文切换时,必须使缓存中的所有条目失效,这会导致在缓存被重新填满之前,缓存未命中率非常高,从而再次对性能产生重大影响。

我们可以通过缓存物理地址来解决这个问题,即将缓存放在MMU和主存之间。这样,缓存的内容不受上下文切换的影响(请求的物理地址会不同,但缓存会正常处理)。这种方法的缺点是,在开始缓存访问之前,我们必须先承担MMU转换的成本,这可能会增加平均内存访问时间。

但是,如果我们足够聪明,就不必等到MMU完成转换再开始访问缓存。缓存需要虚拟地址中的行号来获取相应的缓存行。如果用于行号的地址位完全包含在虚拟地址的页偏移量中,那么这些位不受MMU转换的影响。因此,缓存查找可以与MMU操作并行进行

一旦缓存查找完成,就可以将缓存行的标签字段与MMU产生的物理地址的相应位进行比较。如果MMU中的TLB命中,物理地址大约会在缓存查找产生标签字段的同时就绪。通过并行执行MMU转换和缓存查找,通常不会对平均内存访问时间产生影响。这样,我们就实现了两全其美:一个物理寻址的缓存,且不因MMU转换而产生时间惩罚。

还有一个细节:增加缓存容量的一种方法是增加缓存行的数量,从而增加用作行号的地址位数。由于我们希望行号能放入虚拟地址的页偏移量字段,我们能拥有的缓存行数量是有限的。同样的道理也适用于增加块大小。因此,要增加缓存容量,我们唯一的选择是增加缓存的相联度,这可以在不影响用于行号的地址位的情况下增加容量。

总结 📝

本节课中我们一起学习了虚拟内存相关的MMU改进技术。

我们使用MMU来提供将虚拟地址映射到物理地址的上下文。通过切换上下文,我们可以创造出许多虚拟地址空间的假象,从而使多个程序可以共享单个CPU和物理内存而互不干扰。

我们讨论了使用页表将虚拟页号转换为物理页号。为了节省成本,我们将页表放在物理内存中,并使用TLB来消除大多数虚拟内存访问中访问页表的成本。访问未驻留的页面会导致页错误异常,允许操作系统管理跨多个应用程序公平共享物理内存的复杂性。

我们还看到,提供上下文是创建虚拟机的第一步,这将是我们下一讲的主题。

047:虚拟内存工作示例 🧠

在本节课中,我们将学习虚拟内存的工作原理,并通过一个具体示例来理解虚拟地址到物理地址的转换过程,以及处理缺页中断的步骤。

概述

虚拟内存允许程序表现得好像拥有比实际物理内存更大的内存空间。其工作原理是程序使用虚拟地址,这些地址通过页表映射到物理内存或磁盘上的实际位置。

虚拟内存基础

虚拟地址通过页表转换为物理地址。页表是一个查找表,每个虚拟页对应一个条目。页表记录了虚拟页是否在物理内存中。如果在,则立即返回物理页号。如果不在,则会发生缺页中断,此时必须将所需的虚拟页从磁盘调入物理内存。

为了给新页面腾出空间,物理内存中最近最少使用的页面会被移除。页表也会随之更新,建立新的虚拟页到物理页的映射。

由于磁盘读写操作成本高昂,数据以块为单位进行传输。这基于局部性原理,即靠近当前地址的指令或数据很可能也会被访问,因此一次性获取多个数据字是合理的。

数据以页为单位在磁盘和内存之间移动。虚拟内存和物理内存中的页大小相同。

工作示例分析

让我们通过一个示例来具体了解虚拟内存的使用。

系统参数设定

通常虚拟地址空间大于物理地址空间,但这不是必须的。在本问题中,虚拟地址空间恰好小于物理地址空间。

具体参数如下:

  • 虚拟地址长度为 16位,可寻址 2^16 字节
  • 物理地址长度为 20位,物理内存大小为 2^20 字节
  • 页大小为 2^8 字节,即每页 256 字节

这意味着:

  • 16位虚拟地址8位页内偏移8位虚拟页号 组成。
    • 公式:虚拟地址 = VPN (8位) | 页内偏移 (8位)
  • 20位物理地址 由相同的 8位页内偏移12位物理页号 组成。
    • 公式:物理地址 = PPN (12位) | 页内偏移 (8位)

页表大小计算

我们首先考虑页表的大小。页表为每个虚拟页设置一个条目,用于映射到物理页。

因此,页表条目数等于虚拟页的数量,即 2^8 个条目(因为VPN是8位)。每个页表条目的大小为 14位:12位用于存储物理页号,1位脏位,1位驻留位。

页大小变化的影响

假设页大小加倍至 2^9 字节/页,但虚拟和物理地址长度保持不变。

以下是页表属性的变化:

1. 页表条目大小的变化
由于物理地址长度仍为20位,页内偏移从8位增至9位,意味着物理页号减少1位,从12位变为11位。因此,每个页表条目的大小也减少1位。

2. 页表条目数量的变化
页表条目数等于虚拟页数。如果每页大小加倍,则虚拟页数量减半。这体现在虚拟页号从8位减少到7位。因此,页表条目数从 2^8 个减少到 2^7 个。

3. 地址转换所需的页表访问次数
此参数不随页大小变化而改变。

代码执行与地址转换

现在我们回到原始的256字节/页的设置,并执行以下两行代码:

LOAD  R1, 0x34C(R0)  // PC = 0x1FC
STORE R1, 0x604(R0)  // PC = 0x200

注释显示了每条指令执行时的程序计数器值,即LOAD指令位于地址0x1FC,STORE指令位于地址0x200

执行这两行代码需要先取指令,然后执行指令要求的数据访问。由于页大小为2^8字节,地址的低8位是页内偏移。注意地址以十六进制表示,8位对应最低两位十六进制字符。

因此,访问的虚拟地址及其对应的虚拟页号如下:

  1. 取LOAD指令:地址 0x1FC -> VPN = 1
  2. 执行LOAD数据访问:地址 0x34C -> VPN = 3
  3. 取STORE指令:地址 0x200 -> VPN = 2
  4. 执行STORE数据访问:地址 0x604 -> VPN = 6

确定访问的物理地址

给定如下页表,我们需要确定这段代码访问的唯一物理地址以及访问顺序。假设处理缺页中断的代码和数据位于物理页0。

VPN 驻留位 脏位 PPN
0 1 0 0x004
1 1 0 0x007
2 1 0 0x602
3 0 0 -
4 1 1 0x033
5 1 1 0x097
6 1 1 0x790
7 1 0 0x220

以下是访问过程分析:

第一步:取LOAD指令 (VPN 1)

  • 查页表,VPN 1的驻留位为1,在物理内存中,PPN为0x007
  • 访问的第一个物理页是 0x007
  • 物理地址 = PPN + 页内偏移 = 0x007 << 8 | 0xFC = 0x7FC

第二步:加载数据 (VPN 3)

  • 查页表,VPN 3的驻留位为0,不在物理内存中,触发缺页中断。
  • 需要移除物理内存中最近最少使用的页以腾出空间。LRU页是VPN 2(映射到PPN 0x602),其脏位为0,说明在内存期间未被修改,内存与磁盘内容一致。
  • 因此,将VPN 2的驻留位置0,腾出物理页0x602
  • 处理缺页中断的代码在物理页0,因此访问的第二个物理页是 0x000
  • 将VPN 3调入物理页0x602,更新页表:VPN 3的驻留位置1,PPN设为0x602,脏位置0(因为是加载操作)。
  • 此时,虚拟地址0x34C对应的物理地址为 0x602 << 8 | 0x4C = 0x6024C
  • 访问的第三个物理页是 0x602

第三步:取STORE指令 (VPN 2)

  • 查页表,VPN 2的驻留位已在上一步被置0,再次触发缺页中断。
  • 移除下一个LRU页(VPN 5,映射到PPN 0x097)。其脏位为1,说明该页被修改过。
  • 缺页处理程序需要先将物理页0x097的内容写回磁盘上的虚拟页5,然后才能将物理页0x097用于VPN 2。
  • 更新页表:VPN 5驻留位置0;VPN 2驻留位置1,PPN设为0x097,脏位置0(尚未修改)。
  • 虚拟地址0x200对应的物理地址为 0x097 << 8 | 0x00 = 0x09700
  • 访问的第四个物理页是 0x097

第四步:执行STORE操作 (VPN 6)

  • 查页表,VPN 6的驻留位为1,在物理内存中,PPN为0x790
  • 虚拟地址0x604对应的物理地址为 0x790 << 8 | 0x04 = 0x79004
  • 由于VPN 6的脏位已经是1,执行存储操作后无需修改页表。如果脏位原是0,则需将其置1。
  • 访问的第五个物理页是 0x790

总结

本节课中,我们一起学习了虚拟内存的工作机制。通过一个具体示例,我们逐步分析了虚拟地址到物理地址的转换过程,包括查页表、处理缺页中断、选择替换页面以及更新页表映射。这段代码最终访问了五个不同的物理页,按顺序分别是:0x007, 0x000, 0x602, 0x097, 0x790。理解这个过程对于掌握计算机如何管理内存和高效运行程序至关重要。

048:虚拟内存回顾 🧠

在本节课中,我们将回顾虚拟内存的核心概念。我们将了解内存管理单元(MMU)如何工作,以及它如何通过地址翻译让多个程序共享物理内存,同时为每个程序提供拥有独立大地址空间的假象。


虚拟内存与地址翻译

上一节我们介绍了虚拟内存的概念并引入了内存管理单元(MMU)。本节中,我们来看看MMU如何具体实现虚拟地址到物理地址的翻译。

虚拟地址空间和物理地址空间都被划分为一系列。每个页包含固定数量的存储位置。例如,如果每页包含 2^12 字节,那么一个32位地址空间将包含 2^32 / 2^12 = 2^20 个页。

在这个例子中,32位地址可以被视为包含两个字段:

  • 一个由高位地址位组成的20位虚拟页号
  • 一个由低位地址位组成的12位页内偏移量

这种安排确保了相邻的数据会位于同一个页上。

MMU使用一个页表将虚拟页号翻译成物理页号。从概念上讲,页表是一个数组,数组中的每个条目包含一个物理页号以及几个指示页面状态的位。翻译过程很简单:虚拟页号被用作数组的索引,以获取对应的物理页号。然后,物理页号与页内偏移量结合,形成完整的物理地址。

在实际实现中,页表通常被组织成多级结构,这允许我们只将正在使用的部分页表驻留在内存中。为了避免每次地址翻译都访问页表的开销,我们使用一个名为翻译后备缓冲器的缓存来记住最近的VPN到PPN的翻译结果。

每个虚拟地址空间的所有已分配位置都可以在二级存储(如硬盘)上找到。请注意,它们不一定驻留在主内存中。如果CPU试图访问一个未驻留在主内存中的虚拟地址,就会发出一个缺页异常信号,操作系统将安排将所需的页面从二级存储移入主内存。实际上,在任何给定时间,只有每个程序的活动页面才驻留在主内存中。

以下是翻译过程的示意图:

以下是翻译过程的步骤:

  1. 首先检查所需的VPN到PPN映射是否缓存在TLB中。
  2. 如果没有,则必须访问多级页表,以查看该页是否驻留,如果是,则查找其物理页号。
  3. 如果发现该页未驻留,则向CPU发出缺页异常信号,以便CPU可以运行处理程序从二级存储加载该页。


多上下文与上下文切换

页表创建了将虚拟地址翻译为物理地址所需的上下文。在一个同时处理多个任务的计算机系统中,我们希望支持多个上下文,并能够快速从一个上下文切换到另一个上下文。

多上下文将允许我们在多个程序之间共享物理内存。每个程序都将拥有独立的虚拟地址空间。例如,两个程序都可以将虚拟地址0作为其第一条指令的地址,但最终会访问主内存中不同的物理位置。在程序之间切换时,我们将执行上下文切换以切换到适当的MMU上下文。

对特定映射上下文的访问由两个寄存器控制:

  • 上下文编号寄存器:控制TLB中可访问哪些映射。
  • 页目录寄存器:指示哪个物理页保存多级页表的顶层。

通过简单地重新加载这两个寄存器,我们就可以切换到另一个上下文。

为了有效地容纳多个上下文,我们需要足够的TLB容量来同时缓存所有进程最频繁使用的映射,并且需要一定数量的物理页来保存所需的页目录和页表段。例如,对于一个特定的进程,3个页就足以容纳虚拟地址空间两端各1024个页的驻留两级页表,从而提供对高达8 MB的代码、堆栈和堆的访问。这对于许多简单程序来说绰绰有余。


总结与展望

本节课中我们一起学习了虚拟内存系统的核心机制。我们回顾了MMU如何通过页表进行地址翻译,引入了TLB来加速这一过程,并解释了缺页异常的处理。我们还探讨了如何通过多上下文支持来实现多个程序间的内存共享与快速切换。

在多个程序之间共享CPU的能力似乎是一个很棒的想法。接下来,让我们探讨一下这可能如何工作的细节。

049:进程

概述

在本节课中,我们将要学习一个核心的抽象概念——进程。我们将了解进程如何封装一个正在运行的程序的所有资源,以及操作系统如何通过管理多个进程,让它们看起来像是在独立的虚拟机上运行,从而高效地共享一台物理计算机。


进程:一个运行程序的抽象

我们创建一个名为“进程”的新抽象,用以捕捉“正在运行的程序”这一概念。

一个进程囊括了运行一个程序所需的所有资源,包括CPU、MMU、输入输出设备等资源。

每个进程都有一个状态,该状态捕获了我们所知的关于其执行的一切信息。

进程数据包括CPU的硬件状态,换句话说,就是寄存器和程序计数器中的值。

进程状态还包括进程虚拟地址空间的内容,包括代码、数据值、栈以及从堆中动态分配的数据对象。

在MMU的管理下,这部分状态可以驻留在主存中,也可以驻留在辅助存储中。

进程状态还包括MMU的硬件状态,正如我们之前所见,这取决于页目录寄存器中的上下文编号。

此外,还包括为分层页表分配的页面。

进程状态还包含关于进程输入输出活动的额外信息,例如它在文件系统中读写文件的位置、与已打开网络连接相关的状态和缓冲区、来自用户界面的待处理事件(例如键盘字符和鼠标点击)等等。

正如你将看到的,有一个特殊的特权进程称为操作系统,运行在其自己的内核模式上下文中。操作系统管理每个进程的所有簿记工作,安排进程定期运行。操作系统将为进程提供各种服务,例如访问数据和文件、建立网络连接、管理窗口系统和用户界面等。

为了从一个用户模式进程切换到另一个,操作系统需要捕获并保存当前用户模式进程的完整状态。其中一部分状态已经存在于主存中,所以这部分已经就绪。另一部分可以在各种内核数据结构中找到。还有一部分需要能够从CPU和MMU的各种硬件资源中保存和恢复。

为了成功实现进程,操作系统必须能够使每个进程看起来像是在其自己的虚拟机上运行,该虚拟机独立于其他进程的其他虚拟机工作。我们的目标是在所有虚拟机之间高效地共享一台物理机器。


虚拟机的组织架构

上一节我们介绍了进程的概念,本节中我们来看看操作系统如何组织资源来支持多个进程。

下图展示了我们提议的组织架构示意图。

物理机器提供的资源显示在幻灯片的底部。CPU和主存构成了系统核心的计算引擎。连接到CPU的是各种外围设备,这是一个从英语单词“periphery”衍生出来的集合名词,表示围绕CPU的资源。

一个定时器产生周期性的CPU中断,可用于触发周期性操作。辅助存储为系统提供大容量的非易失性存储器。与外部世界的连接也很重要,许多计算机包括用于可移动设备的USB连接,大多数提供有线或无线网络连接。最后,通常还有服务于用户界面的视频显示器、键盘和鼠标。摄像头和麦克风作为下一代用户界面正变得越来越重要。

物理机器由运行在特权内核上下文中的操作系统管理。操作系统处理与外围设备的低级接口,初始化和管理MMU上下文等。正是操作系统为每个进程创建了它们所看到的虚拟机。

用户模式程序直接在物理处理器上运行,但它们的执行可以被定时器中断,从而给操作系统一个机会来保存当前进程的状态,并切换到运行下一个进程。通过MMU,操作系统为每个进程提供了一个独立的虚拟地址空间,该空间与其他进程的操作隔离。操作系统提供的虚拟外围设备使进程无需关心与其他进程共享资源的所有细节。

窗口的概念允许进程访问一个矩形像素阵列,而无需担心窗口中的某些像素是否被其他窗口遮挡,也无需担心如何确保鼠标光标始终显示在任何内容之上等等。

每个进程不是直接访问IO设备,而是可以访问一个IO事件流,这些事件在键入字符、点击鼠标等时生成。例如,操作系统负责确定哪些键入的字符属于哪个进程。在大多数窗口系统中,用户点击一个窗口来表示拥有该窗口的进程现在拥有键盘焦点,并应接收任何后续键入的字符。点击时鼠标的位置可能决定哪个进程接收点击事件。所有这些都意味着,共享的细节已被抽象出虚拟外围设备提供的简单接口之外。

访问磁盘上的文件也是如此。操作系统提供了一个有用的抽象,使每个文件看起来像一个连续的、可滚动的字节数组,支持读写操作。操作系统知道文件如何映射到磁盘上的扇区池,并处理坏扇区、减少碎片化以及通过预读和后写来提高吞吐量。

对于网络,操作系统提供对某个远程套接字的有序字节流的访问。它实现了适当的网络协议来对数据流进行分组、寻址数据包以及处理丢失、损坏或乱序的数据包。

为了配置和控制这些虚拟服务,进程使用管理程序调用SVC与操作系统通信,这是一种调用操作系统内核代码的控制转移访问过程调用。

每个虚拟服务的设计和实现细节超出了本课程的范围,如果你感兴趣,操作系统课程将详细探讨这些主题。

操作系统为每个进程提供一个独立的虚拟机,并定期从一个进程的运行切换到下一个进程的运行。


进程切换:从进程0到进程1

上一节我们了解了虚拟机的组织,本节中我们具体看看操作系统如何在进程之间进行切换。

让我们跟随从运行进程0切换到运行进程1的过程。最初,CPU正在执行进程0中的用户模式代码。

该执行要么被程序中的显式yield中断,要么更可能被定时器中断中断。无论哪种方式,最终都会将控制权转移到操作系统代码,同时将当前的PC+4值保存在XP寄存器中,操作系统在内核模式下运行。我们稍后将更详细地讨论中断机制。

操作系统将进程0的状态保存在内核存储的适当表中。然后,它从进程1的内核表中重新加载状态。请注意,进程1的状态是在进程1在更早的某个时间点被中断时保存的。接着,操作系统使用跳转指令,利用新恢复的进程1状态来恢复用户模式执行。

执行在进程1中恢复,正好是它之前被中断的地方。现在,我们正在运行进程1中的用户模式程序。

我们已经中断了一个进程,并恢复了另一个进程的执行。我们将以循环轮转的方式持续这样做,在每个进程开始新一轮执行之前,给每个进程一个运行的机会。

黑色箭头给出了时间感知的感觉。对于每个进程,虚拟时间随着执行的指令序列展开。除非查看实时时钟,否则进程不会意识到其执行偶尔会暂停一段时间。暂停和恢复对于一个正在运行的进程是完全透明的。

当然,从外部看,我们可以看到在实时中,执行路径从一个进程移动到另一个进程,在切换期间访问操作系统,从而产生我们在此处看到的交错执行路径。CPU的时间复用被称为分时,我们将在下一节更详细地研究其实现。


总结

本节课中我们一起学习了进程这一核心抽象。我们了解到进程封装了运行程序所需的所有资源状态,操作系统通过为每个进程创建独立的虚拟机来管理它们。我们还探讨了操作系统如何通过保存和恢复进程状态,在多个进程之间进行切换,实现CPU的分时共享,从而让每个进程都感觉自己独占计算机资源。

050:17.2.3 分时技术 ⏱️

在本节课中,我们将要学习分时技术的关键实现机制——定时器中断。我们将了解硬件如何触发中断,操作系统软件如何保存和恢复进程状态,并最终实现多个进程在单个CPU上轮流执行。

概述

分时技术允许多个用户程序共享一个处理器。其核心是定时器中断,它周期性地打断当前运行的用户程序,将控制权交给操作系统。操作系统借此机会可以保存当前进程的状态,并选择另一个进程恢复执行。

定时器中断硬件机制

上一节我们介绍了分时的概念,本节中我们来看看实现它的关键技术——定时器中断。让我们回顾一下Beta处理器中的中断硬件是如何工作的。

外部设备通过置位Beta处理器的中断请求输入来请求中断。如果Beta处理器正运行在用户模式(即程序计数器PC中存储的超级用户位为0),那么置位IRQ将在识别到中断的时钟周期触发以下动作。

其目标是:将当前的PC+4值保存到XP寄存器中,并强制程序计数器跳转到一个特定的内核模式指令,该指令是中断处理程序的起始点。

基于当前指令生成控制信号的正常流程被覆盖,部分控制信号被强制设置为特定值。

  • PC单元被设置为4,这选择了一个指定的内核模式地址作为程序计数器的下一个值。
  • 所选地址取决于外部中断的类型。对于定时器中断,地址是十六进制数 0x80000008。注意,PC31(超级用户位)被置为1,CPU将在开始执行中断处理程序代码时处于内核模式。
  • WA单元、WD单元和Wth控制信号被设置,以便将PC+4写入XP寄存器(即寄存器文件中的R30)。
  • 最后,MWR被设置为0,以确保如果我们中断的是一条存储指令,该指令的执行能被正确中止。

因此,在下一个周期,CPU将从内核模式中断处理程序的第一条指令开始执行,该处理程序可以在CPU的XP寄存器中找到被中断指令的PC+4值。

正如我们所见,中断硬件机制非常精简:它保存了被中断用户模式程序的PC+4值到XP寄存器,并将程序计数器设置为一个取决于发生何种外部中断的预定值。处理中断请求的其余工作由软件完成。

中断处理的软件流程

上一节我们看到了硬件如何启动中断,本节中我们来看看操作系统软件如何接管并完成中断处理。

被中断进程的状态(例如,CPU寄存器R0到R30中的值)被存储在主内存中一个名为 user_m_state 的操作系统数据结构里。然后,调用适当的处理程序代码(通常是一个用C语言编写的过程)来完成主要工作。当该过程返回时,进程数据从 user_m_state 重新加载。操作系统将XP中的值减去4,使其指向被中断的指令,然后通过 JUMP(XP) 恢复用户模式执行。

需要注意的是,在我们简单的Beta实现中,各种中断处理程序的第一条指令占据着内存中连续的位置。由于中断处理程序长度超过一条指令,这第一条指令总是一条跳转到实际中断代码的分支指令。

  • 复位中断(CPU首次启动时触发)将PC设置为 0x80000000
  • 非法指令中断将PC设置为 0x80000004
  • 定时器中断将PC设置为 0x80000008,依此类推。

在所有情况下,新PC值的第31位都被置为1,以便处理程序在超级用户或内核模式下执行,从而访问内核上下文。另一种常见的替代方案是在一个已知位置提供一个新PC值表,让中断硬件访问该表以获取适当处理程序的PC。这提供了与我们简单的Beta实现相同的功能。

由于进程数据在中断期间被保存和恢复,因此中断对正在运行的用户模式程序是透明的。本质上,我们借用几个CPU周期来处理中断,然后恢复正常程序执行。

定时器中断处理程序示例

理解了基本流程后,我们来看一个具体的定时器中断处理程序是如何工作的。我们的初始目标是使用定时器中断来更新操作系统中记录当前时间的数据值。假设定时器每六分之一秒触发一次中断。

用户模式程序正常执行,无需做任何特殊处理来应对定时器中断。周期性地,定时器中断用户模式程序,运行操作系统中的时钟中断处理程序代码,然后恢复用户模式程序的执行。该程序继续执行,就像中断从未发生过一样。如果程序需要访问当前时间,它会向操作系统发出相应的系统调用请求。

操作系统中的时钟处理程序代码以一小段汇编语言代码开始和结束,用于保存和恢复状态。在中间,汇编代码调用一个C过程来实际处理中断。

以下是处理程序代码在C语言中可能的样子。我们找到了时间数据值的声明,以及一个名为 user_m_state 的结构,它临时保存进程状态。还有一个用于递增时间值的C过程。

定时器中断执行位置8处的 BR 指令,该指令跳转到 clock_handler 处的实际中断处理程序代码。代码首先将所有CPU寄存器的值保存到 user_m_state 数据结构中。注意,我们不保存R31的值,因为它的值总是0。在设置好内核模式堆栈后,汇编语言存根调用上面的C过程来完成繁重的工作。当该过程返回时,CPU寄存器从保存的进程数据中重新加载,并且XP寄存器值减4,使其指向被中断的指令。然后,JUMP(XP) 恢复用户模式执行。

从定时中断到进程切换

哦,这看起来足够简单。但这与分时有什么关系呢?我们的目标不是安排定期切换正在运行的进程吗?既然我们有一段代码在每次定时器中断时运行,那么就让我们修改它,以便每隔一段时间就安排调用操作系统的调度器例程。

在这个例子中,如果我们希望每两次定时器中断调用一次调度器,我们会将常量 QUANTUM 设置为2。调度器例程正是分时魔法发生的地方。

进程状态管理数据结构

为了实现进程切换,我们需要管理多个进程的状态。以下是相关的数据结构。

我们看到上一张幻灯片中的 user_m_state 数据结构,用户模式进程数据在中断期间存储于此。

这里是一个进程控制块数组,每个数据结构对应系统中的一个进程。进程控制块保存着当我们进程当前未执行时的完整状态,它是处理器状态的长期存储。如你所见,它包括一个包含进程寄存器值的 m_state 副本、MMU状态以及与进程输入输出活动相关的各种状态(这里用一个数字表示连接到该进程的虚拟用户界面控制台)。总共有N个进程。变量 curpid 给出了当前运行进程在进程表中的索引。

分时调度的实现代码

以下是实现分时调度的、出奇简单的代码。

每当调度器例程被调用时,它首先将临时保存的状态移动到当前进程的进程控制块中。然后递增 curpid 以移动到下一个进程,确保当我们刚运行完最后一个进程时,它能回绕到0。接着,它从新进程的进程控制块中重新加载临时状态,并相应地设置MMU。此时,调度器返回,时钟中断处理程序从更新后的临时安全状态重新加载CPU寄存器并恢复执行。瞧,我们现在正在运行一个新进程了!

分时工作流程总览

让我们用这张图再次梳理分时是如何工作的。在图的顶部,你会看到用户模式进程的代码,下方是操作系统代码及其数据结构。

定时器中断当前正在运行的用户模式程序,并开始执行操作系统的C处理程序代码。处理程序做的第一件事是将所有寄存器保存到 user_m_state 数据结构中。如果调用了调度器例程,它会将临时保存的状态移动到进程控制块中,后者为进程状态提供长期存储。接着,调度器将下一个进程的保存状态复制到临时保存区,然后时钟处理程序将更新后的状态重新加载到CPU寄存器中,并恢复执行——这次运行的是新进程中的代码。

当我们查看操作系统时,请注意,由于其代码运行时超级用户模式位被置为1,因此在操作系统内中断是被禁止的。这防止了在处理第一个中断的过程中又收到第二个中断的尴尬问题,这种情况可能会意外地覆盖 user_m_state 中的状态。但这意味着在编写操作系统代码时必须非常小心。任何类型的无限循环都无法被中断。你可能经历过这种情况:你的机器似乎冻结了,不接受任何输入,只是像块木头一样呆在那里。此时,你唯一的选择是循环上电硬件(终极中断)并重新开始。

在用户模式程序执行期间是允许中断的,因此,如果它们陷入死循环需要被中断,这总是可能的,因为操作系统仍然在响应(例如键盘中断)。每个操作系统都有一个神奇的按键组合,保证可以挂起当前进程的执行,有时还会安排复制进程数据以供后续调试。这非常方便。

总结

本节课中我们一起学习了分时技术的核心实现。我们了解到,定时器中断是周期性打断CPU执行的关键硬件机制。中断发生后,硬件自动保存返回地址并跳转到内核处理程序。操作系统软件则负责保存完整的进程状态(到 user_m_state进程控制块),并通过调度器选择下一个要运行的进程,恢复其状态,从而实现多个进程在单个CPU上的轮流执行。整个机制对用户程序是透明的,并且通过在内核中禁用中断来保证状态切换的原子性。

051:6.4 处理非法指令 🛠️

在本节课中,我们将学习操作系统如何妥善处理程序试图执行非法操作码指令的情况。我们将看到,硬件可以通过软件模拟来扩展其功能。

概述

操作系统提供的另一项服务,是妥善处理执行非法操作码指令的尝试。“非法”之所以加引号,是因为它仅指那些未由硬件直接实现其操作的指令。我们将看到,通过软件模拟来扩展硬件功能是可能的。

非法指令的处理机制

上一节我们介绍了中断的处理流程。本节中我们来看看CPU遇到非法指令(有时也称为未实现的用户操作,简称UUO)时的行为。这与处理中断的方式非常相似。可以将非法指令视为由CPU直接引发的中断。

与中断类似,当前指令的执行会被挂起,控制信号被设置为特定值,以将PC+4捕获到XP寄存器中,并将PC设置为一个特定值(本例中为十六进制800004)。请注意,新PC的第31位(也称为超级用户位)被设置为1,这意味着操作系统处理程序将能够访问内核模式上下文。

操作系统代码结构

以下是一些与微型操作系统中类似的代码,你将在最后的实验作业中对其进行实验。让我们快速浏览一下当执行非法指令时,代码的执行流程。

从地址0开始,我们看到跳转到各种中断和异常处理程序的分支指令。对于非法指令,将执行地址4处的分支指令 B _illop

紧接着是为操作系统数据结构分配的空间。这包括操作系统栈的空间、用户模式状态(用于在中断期间存储用户模式寄存器值)以及进程表(为每个进程的完整状态提供长期存储,当另一个进程正在执行时)。

宏定义的使用

在编写汇编语言时,为重复使用的操作定义宏非常方便。每当我们需要执行该操作时,就可以使用宏调用,汇编器会将宏的主体插入到宏调用的位置,并对宏的参数进行文本替换。

以下是一个宏,用于从32位值中提取特定字段的两条指令序列。M 是最左边位的位号,N 是最右边位的位号。位从0到31编号,其中第31位是最高有效位,即32位二进制值最左端的那一位。

以下是另一些宏,它们展开为将CPU寄存器保存到用户模式状态临时存储区,或从该区域恢复CPU寄存器的指令序列。

有了这些宏,让我们看看如何处理非法操作码。

非法操作码处理流程

与所有中断处理程序一样,第一步操作是将用户模式寄存器保存到临时存储区,并初始化操作系统栈。

接下来,我们从用户模式程序中获取非法指令。请注意,保存的PC+4值是中断程序上下文中的一个虚拟地址,因此我们需要使用内存管理单元(MMU)例程来计算正确的物理地址。关于这一点,下一张幻灯片会有更多说明。

然后,我们将使用非法指令的操作码作为索引,查找一个子程序地址表,该表对应64种可能的操作码。一旦我们获得了针对这个特定非法操作码的处理程序地址,我们就跳转到那里处理情况。从一个地址表中选择目标地址的过程称为调度,该表称为调度表。如果调度表包含许多不同的条目,调度在时间和空间上比一长串的比较分支指令要高效得多。

在本例中,该表表明,大多数非法操作码的处理程序是 _UUO_error 例程。因此,简单地测试操作系统将要模拟的那两个非法操作码,可能更小更快。

非法操作码1将用于实现从用户模式到操作系统的过程调用,我们称之为超级用户调用。下一节将详细介绍这一点。

作为操作系统模拟指令的一个例子,我们将使用非法操作码2作为 swapreg 指令的操作码,我们现在就来讨论它。

但首先,快速了解一下操作系统如何将用户模式虚拟地址转换为它可以使用的物理地址。

我们将基于上一讲描述的MMU虚拟到物理地址转换过程来构建。该过程期望的参数是虚拟地址的虚拟页号和偏移量字段,因此按照我们向C过程传递参数的约定,这些参数以相反的顺序被压入栈中。相应的物理地址在R0寄存器中返回。

然后,我们可以使用计算出的物理地址从物理内存中读取所需的位置。

好的,回到处理非法操作码。以下是处理真正非法操作码的处理程序。在这种情况下,操作系统使用各种内核例程在用户控制台上打印出有用的错误信息,然后使系统崩溃。如果你运行充满神秘十六进制数字的Windows操作系统,可能见过这些“蓝屏死机”。

实际上,这并不是处理用户程序中非法操作码的最佳方法。在真正的操作系统中,更好的做法是将进程状态保存到一个特殊的调试文件中(历史上称为核心转储),然后终止这个特定进程,或许在用户控制台上打印一条简短的错误信息,让他们知道发生了什么。之后,用户可以启动调试程序来检查转储文件,以找出他们的程序错误所在。

最后,这是将模拟 swapreg 指令操作的处理程序。在此之后,程序将继续执行,就好像该指令已在硬件中实现一样。

swapreg 是一条交换两个指定寄存器中值的指令。

要定义一条新指令,我们首先必须让汇编器知道如何将 swapreg Ra, Rc 汇编语言语句转换为二进制。在本例中,我们将使用类似于 addc 指令的二进制格式,但将未使用的文字字段设置为0。RA和RC寄存器的编码出现在它们通常的字段中,操作码字段设置为2。

模拟过程出奇地简单。首先,我们从 swapreg 指令的二进制编码中提取RA和RC字段,并将这些值转换为访问已保存寄存器值临时数组的适当字节偏移量。然后,我们使用RA和RC偏移量来访问已保存在 userMState 中的用户模式寄存器值。我们将进行适当的交换,将更新后的寄存器值留在 userMState 中,当从非法指令中断处理程序返回时,这些值将被加载到CPU寄存器中。最后,我们将分支到恢复进程状态并恢复执行的内核代码。

我们将在下一节看到这段代码。

总结

本节课中,我们一起学习了操作系统如何处理非法指令。我们了解到,非法指令可以触发类似中断的机制,使CPU进入内核模式。操作系统通过调度表来查找并执行相应的模拟处理程序,从而在软件层面实现硬件未直接支持的功能。我们还以 swapreg 指令为例,详细了解了软件模拟指令的具体步骤。这展示了操作系统通过软件扩展硬件能力的强大灵活性。

052:6.004 2017 第52讲 17.2.5 管理程序调用

在本节课中,我们将要学习用户模式程序如何通过一种称为“管理程序调用”的机制,安全地与操作系统进行通信以请求服务或获取数据。

概述

用户模式程序需要与操作系统通信,以请求服务或获取有用的操作系统数据,例如当前时间。但是,由于它们在不同于操作系统的内存管理单元上下文中运行,因此无法直接访问操作系统的代码和数据。无论如何,这都不是一个好主意,因为操作系统通常负责实现安全和访问策略。如果任何随机的用户程序都能绕过这些保护,系统的其他用户会感到不满。

因此,需要一种机制,允许用户模式程序在特定的入口点调用操作系统代码,并使用寄存器或用户模式虚拟内存来发送或接收信息。我们将使用这些管理程序调用来访问一个文档齐全且安全的操作系统应用程序编程接口。此类接口的一个例子是POSIX,这是许多类Unix操作系统实现的标准接口。

管理程序调用机制

事实证明,我们有一种方法可以将控制权从用户模式程序转移到特定的操作系统处理程序:只需执行一条非法指令。我们将采用一种约定,使用操作码字段为1的非法指令来作为管理程序调用。这些SVC指令的低位将包含一个索引,指示我们试图访问哪项SVC服务。

以下是该机制的工作原理。再次查看我们的用户模式-内核模式示意图。请注意,用户模式程序中包含具有不同索引的管理程序调用,当执行这些调用时,它们旨在请求不同的操作系统服务。

当执行一条SVC指令时,硬件会检测到操作码字段为1,将其视为非法指令,并触发一个异常,该异常会运行操作系统的非法指令处理程序,正如我们在上一节中看到的那样。处理程序将处理器状态保存在临时存储区中,然后根据操作码字段分派到相应的处理程序。

此处理程序可以访问临时存储区中的用户寄存器,或者使用适当的操作系统子程序,可以访问任何用户模式虚拟地址的内容。如果要将信息返回给用户,可以将返回值存储在临时存储区中,或者覆盖用户R0寄存器的内容。然后,当处理程序完成时,可能已更新的已保存寄存器值将被重新加载到CPU寄存器中,用户模式程序将在管理程序调用之后的指令处恢复执行。

调度与处理程序

在上一节中,我们看到了非法指令处理程序如何使用调度表,根据非法指令的操作码字段选择相应的子处理程序。

在本幻灯片中,我们看到的是SVC指令(即操作码字段为1的指令)的子处理程序。这段代码使用指令的低位访问另一个调度表,为八个可能的管理程序调用中的每一个选择相应的代码。我们的小型操作系统只提供了一组简单的服务。一个真正的操作系统将拥有用于访问文件、处理网络连接、管理虚拟内存、生成新进程等的管理程序调用。

以下是当管理程序调用处理程序完成后,恢复用户模式进程执行的代码。它简单地恢复寄存器的保存值,并跳转到SVC指令之后的指令处恢复执行。

特殊情况处理

有时,由于某些原因,SVC请求无法完成,需要在未来重试该请求。例如,READ_CHAR管理程序调用返回用户输入的下一个字符。但如果尚未输入任何字符,操作系统此时无法完成请求。在这种情况下,SVC处理程序应分支到yield,该分支会安排在下一次此进程运行时重新执行SVC指令,然后调用Scheduler来运行下一个进程。这给了所有其他进程在SVC再次尝试之前运行的机会,希望这次能成功。

您可以看到,这段代码也作为两个不同SVC的实现。一个进程可以通过调用yield管理程序调用来放弃其当前剩余的执行时间片。这只会导致操作系统调用调度器,暂停当前进程的执行,直到其在轮转调度过程中下一次轮到为止。

要停止执行,进程可以调用halt SVC。查看其实现,我们发现halt这个名称有点用词不当。实际发生的情况是,系统安排每次调度该进程时都重新执行halt SVC,这会导致操作系统调度下一个进程执行。该进程看起来像是停止了,因为halt SVC之后的指令永远不会被执行。

添加新的SVC处理程序

添加新的SVC处理程序很简单。首先,我们需要为用户模式程序定义新的SVC宏。在这个例子中,我们正在定义用于获取和设置时间的SVC。由于这些是第8个和第9个SVC,我们需要对SVC调度代码进行小的调整,然后将适当的条目添加到调度表的末尾。新处理程序的代码同样简单直接。

处理程序可以通过查看用户状态临时保存区中的正确条目来访问程序的R0值。只需几条指令即可实现所需的操作。

SVC机制的优势

SVC机制提供了对操作系统服务和数据的受控访问。正如我们将在后续课程中看到的,SVC处理程序不能被中断这一点很有用,因为它们运行在中断被禁用的管理模式下。因此,例如,如果我们需要使用加载-加-存储序列来递增主内存中的一个值,但我们希望确保在加载和存储之间没有其他进程执行介入,我们可以将所需的功能封装为一个SVC,它保证是不可中断的。

总结

本节课中,我们一起探索了实现一个简单分时操作系统的良好开端。我们详细了解了管理程序调用机制,它如何允许用户程序安全地请求内核服务,包括其工作原理、调度方式、特殊情况处理以及如何扩展新的调用。在下一讲中,我们将继续探索操作系统如何处理外部输入/输出设备。

053:6.004 2017 操作系统工作示例 🖥️

在本节课中,我们将学习操作系统如何管理多个进程在共享地址空间上运行。我们将通过一个具体的例子,深入探讨Beta计算机操作系统的核心机制,包括进程状态管理、调度以及一个名为samplePC的监控调用是如何工作的。


为了让多个进程能够在同一台计算机上使用共享地址空间运行,我们需要一个操作系统。该操作系统负责控制在任何给定时间点哪个进程可以运行,并确保当前加载到系统中的状态是当前进程的状态。

通过以下示例,我们将更仔细地了解Beta操作系统的运作方式。我们首先从负责维护当前处理器状态以及调度在任意时间点应运行哪个进程的代码开始分析。

进程状态管理 📊

操作系统使用一个名为Mstate的结构来跟踪每个运行进程中32个寄存器的值。变量userMstate保存着当前正在运行的进程的状态。

进程表(proc table)保存了机器上运行的每个进程所需的所有状态。对于每个进程,它将其所有寄存器的值存储在state变量中。每个进程还可以存储额外的状态,例如,如果我们想使用虚拟内存,每个进程的页表;另一个例子是键盘标识符,它将硬件与特定进程关联起来。

变量current保存着当前运行进程的索引。

进程调度 🔄

当我们想将控制权从一个进程切换到另一个进程时,会调用调度器(scheduler)。

调度器首先将当前运行进程的状态存储到进程表中。接着,它将current进程索引加1。如果当前进程是第n-1个进程,则它会回到进程0。最后,它将userMstate变量重新加载为新当前进程的状态。

示例:samplePC 监控调用 🔍

为了能够在一个进程上运行诊断程序,以采样另一个进程程序计数器(PC)中的值,系统提供了一个名为samplePC的监控调用。

以下是为你提供的监控调用处理程序的C语言部分。它是不完整的,因此我们的第一个目标是确定应该用什么来替换代码中的问号(???)。

我们被告知samplePC监控调用的工作方式是:它从R0寄存器获取一个进程号P,并在R1寄存器中返回进程P当前程序计数器的值。

此处显示的处理程序从userMstate数据结构中读取寄存器R0的值,并将其存储到变量P中。这是要监控的进程编号。

为了确定进程P的PC值,可以查找为进程P保存的XP寄存器的值(即上次进程P运行时保存的)。XP寄存器保存着下一条PC地址的值,因此从进程表procTable[P]中读取XP寄存器就能告诉我们进程P的下一个PC值。

PC值需要返回到当前程序的寄存器R1中。这意味着缺失的代码是 userMstate.regs[1] = PC;

避免重复采样 🚫

假设你有一个计算密集型进程,它由一个包含10,000条指令的单一循环构成。在运行此循环时,你使用samplePC监控调用来采样PC值。你注意到samplePC代码的结果中有许多重复值。

你意识到发生这种情况的原因是,每次你的性能分析进程被调度运行时,它会进行多次samplePC调用,但其他进程并未运行,因此你多次获得了相同的采样PC值。

如何避免重复采样?为了避免重复采样,我们在samplePC处理程序中添加一个对调度器Scheduler的调用。这确保了每次性能分析进程被调度时,它只采样一个PC值,然后就让另一个进程运行。

分析I/O密集型代码 📝

假设你继续使用原始版本的samplePC处理程序(即不调用调度器的版本),并用它来测试以下代码:该代码反复调用getKey监控调用来从键盘读取一个字符,然后调用writeChar监控调用来写入刚刚读取的字符。

我们想回答的问题是:哪个PC值会被报告得最频繁?地址0x100(即getKey调用的地址)被报告得最频繁,因为大多数时候当调用getKey时,并没有待处理的按键。这意味着getKey调用会被反复处理,直到最终有一个按键需要处理。结果是,出现最频繁的PC值是0x100

进程自我监控的挑战 🤔

我们考虑的最后一个问题是:当运行在进程0中的性能分析器(profiler)监控进程0自身时,会观察到什么行为?

假设性能分析器代码主要由一个大循环构成,该循环在指令0x1000处包含一个samplePC监控调用。循环的其余部分处理由samplePC调用收集的数据。我们想回答的问题是:在samplePC结果中观察到了什么?我们有四个选项可供选择。

需要考虑的第一个选项是:是否所有采样的PC值都指向内核OS代码?这个选项是错误的,因为如果成立,将意味着你设法中断了内核代码,而这是Beta系统不允许的。

下一个要考虑的选项是:采样的PC是否总是0x1004?这看起来可能是正确的,因为samplePC监控调用位于地址0x1000,所以存储PC+4将导致0x1004被存储到userMstate.regs[XP]中。

然而,如果你仔细观察samplePC处理程序,会发现XP寄存器是从进程表(procTable)中读取的,但userMstate.regs仅在调用调度器Scheduler时才被写入进程表。因此,从进程表读取的值将是进程0上次被中断时的最后一个PC值。要获得正确的值,你需要读取userMstate.regs[XP]

第三个选项是:samplePC调用永不返回。没有理由认为这是真的。

最后,最后一个选项是:以上都不是。由于其他选项都不正确,因此这是正确答案。


总结 📚

本节课中,我们一起学习了操作系统管理多进程的核心机制。我们探讨了如何使用Mstate和进程表来维护进程状态,以及调度器如何实现进程间的切换。通过分析samplePC监控调用的工作示例,我们理解了如何采样其他进程的PC值,并解决了在采样过程中可能遇到的重复采样问题。最后,我们分析了进程自我监控时的特殊行为,认识到正确读取状态信息的重要性。这些概念是理解现代操作系统进程管理和调度的基础。

054:操作系统设备处理程序 📟

在本节课中,我们将学习操作系统如何处理输入输出设备。我们将探讨操作系统与设备本身的交互机制,以及用户模式程序如何通过系统调用访问内核缓冲区。理解这些概念对于掌握计算机如何高效管理I/O操作至关重要。


操作系统与设备的交互

上一节我们介绍了操作系统的基本角色,本节中我们来看看操作系统如何与物理设备进行交互。这种交互主要涉及中断处理程序和内核缓冲区的协同工作。

当用户在键盘上按下一个键时,键盘会向CPU触发一个中断请求。该中断会挂起当前正在运行的进程,转而执行专门处理此I/O事件的中断处理程序。

以下是键盘输入处理的基本流程:

  1. 键盘触发中断。
  2. CPU执行键盘中断处理程序。
  3. 处理程序从键盘读取字符。
  4. 字符被保存到与“拥有键盘焦点”的进程相关联的内核缓冲区中。
  5. 处理程序退出,恢复运行被中断的进程。

这个过程仅需少量指令即可完成。只要中断请求能被及时处理,CPU的速度足以跟上人类打字的速度。


内核缓冲区与溢出处理

内核缓冲区的大小是有限的。当缓冲区被填满后,操作系统需要决定如何处理新到达的字符。

以下是两种主要的处理策略:

  • 覆盖旧字符:这种方法通常没有意义,因为丢弃先前的字符会导致数据丢失。
  • 丢弃新字符:更好的做法是CPU丢弃缓冲区满后接收到的任何字符,并给用户一些提示。事实上,许多系统会发出“哔”声,提示用户刚刚键入的字符被忽略了。

系统调用与阻塞I/O

在之后的某个时间,用户模式程序会执行一个 ReadKey 系统调用,请求操作系统将下一个字符返回到寄存器R0中。

操作系统中的 ReadKey 系统调用会从缓冲区“抓取”下一个字符,将其放入用户的R0寄存器,然后在SVC指令之后恢复执行。

这里有一个关键点需要理解:ReadKey 系统调用是一种阻塞式I/O请求。这意味着程序假设当SVC返回时,R0中已经存有下一个字符。如果此时没有字符可返回,程序的执行应该被阻塞,即挂起,直到有字符可用为止。

许多操作系统也支持非阻塞式I/O请求,这种请求会立即返回,同时提供一个状态标志和一个结果。程序可以检查状态标志来判断是否有字符可用,如果没有,则可以采取适当措施,例如稍后重新发起请求。


事件驱动架构的优势

请注意,用户模式程序并没有与键盘进行任何直接交互。它不需要不断地轮询设备来检查是否有待处理的击键。

相反,我们采用了一种事件驱动的方法:当设备需要关注时,它通过中断向操作系统发出信号。这是一种优雅的职责分离。试想一下,如果每个程序都必须不断检查是否有待处理的I/O操作,那将是多么繁琐。

我们的事件驱动组织架构实现了设备的按需服务,只有在有实际工作需要完成时,才会为I/O子系统分配CPU资源。这种由中断驱动的、操作系统与I/O设备的交互对用户程序是完全透明的。


中断处理程序代码示例

以下是操作系统键盘中断处理程序代码的一个大致框架。根据硬件不同,CPU可能使用ISA中的特殊I/O指令来访问设备状态和数据。另一种常见的方法是使用内存映射I/O,即将内核地址空间的一部分专门用于服务I/O设备。在这种方案中,使用普通的加载和存储指令访问特定地址,CPU会将这些地址识别为对键盘或鼠标设备接口的访问。

这里展示的代码采用了内存映射I/O方案。C语言数据结构代表了分配给键盘的两个I/O位置:一个用于状态,一个用于实际的键盘数据。

// 假设的内存映射I/O地址结构
struct keyboard_io {
    volatile uint32_t status; // 状态寄存器
    volatile uint32_t data;   // 数据寄存器
};

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6004-ddca/img/fb3ccb8c6fd9dc3e775c76ee8026a88b_5.png)

// 定义一个指向键盘设备的指针
#define KEYBOARD_BASE ((struct keyboard_io *)0xFFFF0000)

// 键盘中断处理程序(简化版)
void keyboard_interrupt_handler(void) {
    // 1. 从键盘数据寄存器读取键值(可能是扫描码)
    uint32_t key_code = KEYBOARD_BASE->data;
    // 2. 将字符放入内核的环形字符缓冲区
    // (假设有 buffer_put_char 函数)
    buffer_put_char(kernel_keyboard_buffer, translate_keycode(key_code));
}

在实际应用中,键盘处理通常更复杂。从键盘读取的实际上是一个键码和一个标志(指示是按键按下还是释放事件)。操作系统根据键盘布局将键码转换为相应的ASCII字符,并处理诸如按住Shift键或Control键表示大写字符或控制字符等复杂情况。某些击键组合(例如Windows系统中的Ctrl+Alt+Del)会被解释为启动特定应用程序(如任务管理器)的特殊用户命令。

许多操作系统允许用户指定他们需要原始的键盘输入(即键码和状态),还是经过处理的输入(即ASCII字符)。


总结

本节课中,我们一起学习了操作系统处理I/O设备的核心机制。我们了解到:

  1. 操作系统通过中断处理程序内核缓冲区与设备交互,采用事件驱动模型,高效且透明。
  2. 用户程序通过系统调用(如 ReadKey)访问缓冲区数据,这些调用可以是阻塞式非阻塞式的。
  3. 实际设备交互可能通过特殊I/O指令内存映射I/O实现,后者使用普通的内存访问指令。
  4. 简单的键盘输入背后涉及复杂的处理流程,包括键码转换和组合键解释。

下一节,我们将探讨如何编写相关的系统调用代码,以使用户程序能够读取这些字符。

055:SVCs用于输入输出 🖥️➡️⌨️

在本节课中,我们将学习用户程序如何通过监督程序调用来请求输入/输出服务,特别是读取键盘输入。我们将探讨SVC处理程序的基本实现、遇到的挑战(如无限循环问题)以及如何通过进程调度和状态管理来优雅地解决这些问题。


用户程序的读取请求

当用户模式程序想要读取一个键入的字符时,它会执行一个“读取按键”监督程序调用。

SVC的二进制表示在其操作码字段中包含一个非法值。因此,CPU硬件会引发一个异常,开始执行操作系统中的非法操作码处理程序。

操作系统处理程序识别出该非法操作码值是一个SVC,并使用SVC指令的低位比特来确定调用哪个子处理程序。


读取按键处理程序初版

以下是“读取按键”子处理程序的第一个草案,这次用C语言编写。

处理程序首先查看当前进程的进程表条目,以确定哪个键盘缓冲区持有该进程的字符。

我们暂时假设缓冲区不为空,因此跳过最后一行代码。该行代码从缓冲区读取字符,并用它来替换保存寄存器值数组中用户R0的已保存值。

当处理程序退出时,操作系统将重新加载保存的寄存器,并恢复用户模式程序的执行,此时R0中就是刚刚读取的字符。


处理空缓冲区的情况

现在,让我们弄清楚当键盘缓冲区为空时该怎么做。

这里显示的代码只是简单地循环,直到缓冲区不再为空。其理论是,最终用户会键入一个字符,从而引发一个中断。该中断将运行上一节讨论的键盘中断处理程序,该处理程序会将一个新字符存储到缓冲区中。

这一切听起来不错,直到我们想起SVC处理程序运行时,监督位(PC31)被设置为1,从而禁用了中断。糟糕!😊 由于键盘中断永远不会发生,这里显示的while循环实际上是一个无限循环。

因此,如果用户模式程序试图从空缓冲区读取字符,系统将看起来像是挂起,由于中断被禁用,它不会响应任何外部输入。


修复循环问题

是时候采取行动了。我们将通过添加代码,在返回前从已保存的XP寄存器值中减去4,来修复这个循环问题。

这个修复是如何起作用的?回想一下,当SVC非法指令异常发生时,CPU将非法指令的PC+4值存储在了用户的XP寄存器中。

当处理程序退出时,操作系统将通过重新加载寄存器然后执行跳转 XP来恢复用户模式程序的执行,这通常会执行SVC指令之后的那条指令。

通过从已保存的XP值中减去4,将要重新执行的将是SVC指令本身。这当然意味着我们将再次经历相同的步骤集,重复这个循环,直到键盘缓冲区不再为空。这只是一个更复杂的循环。

但有一个关键区别:其中一条指令,即“读取按键”SVC,是在用户模式下执行的,此时PC31等于0。因此,在那个周期中,如果有一个来自键盘的待处理中断,设备中断将取代“读取按键”的执行,键盘缓冲区将被填充。

当键盘处理程序结束时,“读取按键”SVC将再次执行,这次会发现缓冲区不再为空。太好了!


效率优化与进程调度

所以这个版本的处理程序实际上可以工作,但有一个小注意事项。如果缓冲区为空,用户模式程序将不断重新执行复杂的用户模式-内核模式循环,直到定时器中断最终将控制权转移给下一个进程。

这似乎效率很低。一旦我们检查并发现缓冲区为空,最好在再次尝试之前给其他进程一个运行的机会。

这个问题很容易修复:在安排好重新执行“读取按键”监督程序调用之后,我们只需添加一个对调度程序Sched的调用。

Sched的调用会挂起当前进程的执行,并安排在处理程序退出时运行下一个进程。最终,轮询调度会回到当前进程,“读取按键”SVC将再次尝试。通过这个简单的一行修复,系统将大大减少浪费周期检查空缓冲区的时间,而是将这些周期用于运行其他(希望是)更有生产力的进程。

代价是在键入字符后重新启动程序会有一小段延迟,但通常每个进程的时间片足够小,以至于一轮进程执行发生得比两次键入字符之间的时间更快,所以额外的延迟并不明显。


关于分时系统的讨论

现在我们有了对传统反对分时系统论点的一些见解。

论点如下:假设我们有10个进程,每个进程需要一秒钟来完成其计算。在没有分时的情况下,第一个进程将在一秒后完成,第二个在两秒后,依此类推。

使用分时,比如说1/10秒的时间片,所有进程将在10秒后的某个时间完成,因为在完成之前发生的约100次进程切换需要一点额外时间。

因此,在分时系统中,所有进程的完成时间与没有分时情况下的最坏完成时间一样长。那么,为什么要费心使用分时呢?

我们在前面的幻灯片中看到了这个问题的答案之一:如果一个进程不能有效利用其时间片,它可以将这些周期捐赠给完成其他任务。

因此,在大多数进程都在等待某种I/O的系统中,分时实际上是将周期用在最能发挥作用的地方的好方法。

如果你现在打开你正在使用的系统的任务管理器或活动监视器,你会看到有数百个进程,几乎所有这些进程都处于某种I/O等待状态。

所以,分时在运行计算密集型任务时确实会带来成本,但在一个I/O和计算任务混合的实际系统中,分时是正确的方式。


进程状态管理:Sleep与Wakeup

我们实际上可以更进一步,确保不运行那些正在等待尚未发生的I/O事件的进程。

我们将向进程状态添加一个状态字段,指示进程是活动的(例如,状态为0)还是等待的(例如,状态非0)。我们将使用不同的非零值来指示进程正在等待什么事件。

然后,我们将修改调度程序,使其只运行活动进程。

要了解其工作原理,最容易的方法是使用一个具体例子。Unix操作系统有两个内核子程序:sleepwakeup,两者都需要一个非零参数。该参数将用作状态字段的值。

让我们看看它的实际运作。当“读取按键”监督程序调用检测到缓冲区为空时,它调用sleep,并带有一个唯一标识它正在等待的I/O事件的参数(在本例中,是特定缓冲区中字符的到来)。

sleep将此进程的状态设置为这个唯一标识符,然后调用Sched。调度程序已被修改为跳过状态非零的进程,不给它们运行的机会。

同时,键盘中断将导致中断处理程序向键盘缓冲区添加一个字符,并调用wakeup来通知任何正在等待该缓冲区的进程。

当键盘中断处理程序中的缓冲区标识符与“读取按键”处理程序中的匹配时,wakeup会遍历所有进程,寻找正在等待这个特定I/O事件的进程。当它找到一个时,它将该进程的状态设置为0,将其标记为活动状态。

这个零状态将导致该进程在调度程序下一次在轮询搜索中轮到它时再次运行。

效果是,一旦一个进程进入睡眠状态等待一个事件,在事件发生且wakeup将该进程标记为活动之前,它不会再被考虑执行。

非常巧妙。这是确保没有CPU周期浪费在无用活动上的另一个优雅修复。我记得多年前在Unix代码的一个非常早期的版本中第一次看到这个时,印象是多么深刻。


总结

本节课中,我们一起学习了用户程序如何通过SVC请求I/O服务。我们探讨了处理程序的基本逻辑、因禁用中断导致的无限循环问题,以及通过修改返回地址来巧妙重启SVC的解决方案。我们还讨论了分时系统的效率权衡,并深入了解了通过sleepwakeup机制进行进程状态管理的高级技术,这确保了CPU资源只被分配给可以取得进展的进程,从而显著提高了系统在I/O密集型环境中的整体效率。

056:操作系统中的匹配处理程序 🧩

在本节中,我们将通过一个具体的示例来测试对“读取按键”SVC(超级用户调用)代码最终设计的理解。我们将分析三个不同版本的处理程序(R1、R2、R3)与三种不同系统模型(A、B、C)的匹配关系,并解读用户反馈信息以推断实际部署情况。

处理程序与系统模型匹配

上一节我们探讨了“读取按键”SVC的几种设计尝试。本节中,我们来看看如何将它们与不同的系统模型相匹配。

以下是三种系统模型和处理程序的简要说明:

  • 系统模型A:标准系统,内核不可中断。
  • 系统模型B:允许设备中断,即使CPU正在执行SVC调用。
  • 系统模型C:单进程系统,不支持分时共享。
  • 处理程序R1:类似于上一节的尝试2,但始终从同一个键盘缓冲区读取,无视发起请求的进程。
  • 处理程序R2:类似于上一节的尝试1,如果尝试从空缓冲区读取,可能导致无限循环。
  • 处理程序R3:类似于上一节的尝试3,专为内核不可中断的标准系统设计。

根据设计逻辑,我们可以进行初步匹配:

  1. R1 总是从同一键盘读取,这在分时共享系统中没有意义,会导致所有进程共享同一个输入流。因此,它必须用于模型C(单进程)系统。
  2. R2 存在潜在无限循环的风险。只有在模型B系统中,键盘中断可以打断这个循环,填充缓冲区,代码才能成功运行。
  3. 通过排除法,R3 应与模型A系统配对,因为它专为不可中断的内核设计。

解读用户反馈信息

一位实习生错误地将未知版本的处理程序磁盘分发给了运行三种不同模型的用户。现在,我们需要根据用户反馈的信息,推断出他们各自使用的是哪种处理程序和系统模型。

以下是所有可能的组合表格,其中已排除了上一节中正确的匹配(即新旧处理程序相同,用户不会发送消息的情况):

处理程序 模型A 模型B 模型C
R1 ✅ (已匹配)
R2 ✅ (已匹配)
R3 ✅ (已匹配)

反馈一:编译时错误

第一条用户消息是:“我遇到了编译时错误,调度器(scheduler)和进程表(proc table)未定义。”

  • 分析:短语“调度器和进程表未定义”不适用于分时共享系统,因为这类系统会包含这些符号。因此,可以排除前两列(模型A和B)。
  • 进一步排除:也可以排除第二行(处理程序R2),因为R2代码中不包含对调度器(Sched)的调用。
  • 结论:这条消息来自一个尝试在模型C系统上运行处理程序R3的用户。因为模型C不支持分时共享,其操作系统代码中既没有调度器也没有进程表。

反馈二:共享输入与性能下降

第二条消息是:“嘿,现在系统总是从 keyboard0 读取所有人的输入。除此之外,它似乎比原来浪费了更多的CPU周期。”

  • 分析:R1是唯一始终从 keyboard0 读取的处理程序,因此可以排除第2行和第3行(R2和R3)。
  • 区分模型:R1处理程序会在等待字符到达时进行循环,浪费大量CPU周期。用户抱怨性能下降,这意味着与他们之前使用的处理程序相比,这是一个显著变化。
    • 如果用户之前在模型B系统上运行R2,他们早已习惯了循环等待的性能,切换到R1时不会注意到明显的性能差异。
    • 因此,可以排除模型B。
  • 结论:这条消息来自一个在模型A系统上运行处理程序R1的用户。

反馈三:运行正常且性能提升

最后一条消息是:“不错,新系统似乎运行良好,甚至比原来浪费的CPU时间更少了。”

  • 分析:既然系统与新处理程序一起能按预期工作,我们可以排除许多可能性。
    • 排除R1:R1在分时共享系统上无法“运行良好”,因为用户会察觉到所有进程现在都从同一个键盘缓冲区读取。因此排除模型A和B下的R1。
    • 排除模型C下的R2/R3:模型C不包含进程表或调度功能,因此排除最右侧一列(模型C)下的R2和R3。
    • 排除模型A下的R2:在模型A(内核不可中断)系统上,处理程序R2在尝试从空缓冲区读取时会导致无限循环,无法工作。
  • 结论:这条消息一定是由一个现在运行处理程序R3模型B用户发送的。

总结

本节课中,我们一起学习了如何通过逻辑推理,将特定的SVC处理程序代码与不同的操作系统模型进行匹配。我们首先基于设计原理进行了理论匹配,然后通过分析用户反馈的具体问题(如编译错误、功能异常和性能变化),在排除所有不可能选项后,确定了每个用户实际使用的处理程序和系统模型组合。这个过程就像解决逻辑谜题,巩固了我们对操作系统内核中断处理、进程管理和系统调用交互的理解。

057:实时系统 🚨

在本节课中,我们将要学习实时系统的基本概念。我们将探讨传统分时系统的局限性,理解实时系统为何需要保证任务在特定截止时间前完成,并分析影响系统响应时间的关键因素。


在构建分时系统的过程中,我们努力创建了一个执行环境,让每个进程仿佛运行在各自独立的虚拟机上。

进程看起来是并发运行的,尽管实际上我们是在单个硬件系统上快速切换运行的进程。

这通常能带来更好的整体利用率,因为如果某个特定进程正在等待一个I/O事件,我们可以将不需要的CPU周期用于运行其他进程。

分时系统的缺点是,很难精确预测一个进程需要多长时间才能完成,因为它将获得的CPU时间取决于其他进程使用了多少时间。

因此,我们需要知道有多少其他进程,它们是否在等待I/O事件等等。

在我们的分时系统中,无法对完成时间做出任何保证。

我们选择让操作系统扮演外部世界触发的中断事件与进行事件处理的用户模式程序之间的中介。

换句话说,我们将事件处理(数据由操作系统存储)与事件处理(数据通过管理程序调用传递给用户模式程序)分离开来。

这意味着,使用传统的分时系统,很难确保事件处理能在指定的事件截止时间前完成。换句话说,很难确保在事件触发后的指定时间段结束前完成处理。

由于现代CPU芯片提供了廉价、高性能的通用计算能力,它们常被用作控制系统的“大脑”,而这些系统对截止时间有严格要求。例如,考虑现代汽车的电子稳定控制系统。

该系统通过保持汽车朝向驾驶员预期的方向,帮助驾驶员在转向和制动操作中保持对车辆的控制。

系统核心的计算机测量汽车所受的力、转向方向和车轮旋转,以判断是否因失去牵引力而失控。换句话说,判断汽车是否打滑。

如果是,电子稳定控制系统会快速自动地对单个车轮进行制动,以防止汽车航向偏离驾驶员的预期航向。

有了电子稳定控制,你可以猛踩刹车或急转弯避开障碍物,而不用担心汽车会突然甩尾失控。

你可以通过制动器发出的“咔嗒”声感觉到系统在工作。为了有效,电子稳定控制系统必须保证在接收到危险的传感器信号后的一定时间内,对每个车轮采取正确的制动动作。

这意味着它必须能够保证某些子程序在传感器事件发生后的预定时间内运行完成。

为了能够做出这些保证,我们必须想出一种更好的方法来调度进程执行。轮询调度无法完成这项工作。能够做出此类保证的系统被称为实时系统。

实时系统的一个性能衡量指标是中断延迟 L,即从请求运行某些代码到该代码实际开始执行之间经过的时间量。

如果处理该请求有一个截止时间 D 相关联,我们可以计算出仍允许服务程序在截止时间前完成的最大允许延迟。

换句话说,满足 L_max + S = D 的最大 L 是多少?

如果错过了某些截止时间,可能会发生不好的事情。也许这就是我们称之为“截止时间”的原因。

在这些情况下,我们希望我们的实时系统能保证实际延迟始终小于最大允许延迟。

这些关键的截止时间催生了我们所谓的硬实时约束

哪些因素会导致中断延迟?在处理中断时,保存进程状态、切换到内核上下文以及分派到正确的中断处理程序都需要时间。

在编写操作系统时,我们可以努力最小化中断处理程序设置阶段所涉及的代码量。

我们还必须避免处理器长时间无法被中断的情况。

一些指令集架构有复杂的多周期指令。例如,块移动指令,单条指令在进行数据块从一个位置移动到另一个位置时,会进行多次内存访问。在设计指令集架构时,我们需要避免此类指令,或者将其设计为可中断和可重启的。

最大的问题出现在我们在内核模式下执行另一个中断处理程序时。😊

在内核模式下,中断是被禁用的,因此实际延迟将由完成当前中断处理程序所需的时间加上上述其他开销共同决定。

这种延迟不受CPU设计者的控制,将取决于特定的应用程序。

编写具有硬实时约束的程序可能会变得复杂。

我们的目标是限制并最小化中断延迟。我们将通过优化接收中断并分派到正确处理程序代码的成本来实现这一点。

我们将避免执行时间依赖于数据的指令。

并且我们将努力最小化在内核模式下花费的时间。但即使采取了所有这些措施,我们也会看到在某些情况下,我们必须修改我们的系统以允许中断,即使在内核模式下也是如此。

接下来,我们将看一些具体的例子,了解需要哪些机制来保证硬实时约束。


本节课中,我们一起学习了实时系统的基本原理。我们了解到,与无法保证完成时间的传统分时系统不同,实时系统(尤其是硬实时系统)必须保证任务在严格的截止时间前完成。我们探讨了中断延迟 L 的概念及其与截止时间 D 的关系(L_max + S = D),并分析了导致延迟的因素,如上下文切换、不可中断的内核代码等。最后,我们认识到为了实现硬实时约束,需要在系统设计上做出调整,例如优化中断处理路径和允许内核模式下的有限中断。

058:弱优先级调度 🧩

在本节课中,我们将学习实时系统中的中断处理与调度策略。我们将探讨“先到先服务”调度的问题,并引入“弱优先级”调度算法来优化最坏情况延迟。课程将使用一个包含键盘、磁盘和打印机的系统作为示例,分析不同调度策略对设备响应时间的影响。


先到先服务调度的延迟问题

假设我们有一个实时系统,支持三个设备:

  • 一个键盘,其中断处理程序的服务时间为800微秒。
  • 一个磁盘,服务时间为500微秒。
  • 一个打印机,服务时间为400微秒。

我们首先需要计算每个设备在最坏情况下可能经历的延迟。目前,我们假设请求并不频繁,即每个场景下每个设备只发出一次请求。请求可以在任何时间以任何顺序到达。

如果我们采用先到先服务的顺序处理请求,每个设备都可能被所有其他设备的服务所延迟。

以下是具体分析:

  • 键盘处理程序的开始可能被磁盘和打印机处理程序的执行所延迟,最坏情况延迟为 500 + 400 = 900 微秒。
  • 磁盘处理程序的开始可能被键盘和打印机处理程序的执行所延迟,最坏情况延迟为 800 + 400 = 1200 微秒。
  • 打印机处理程序的开始可能被键盘和磁盘处理程序的执行所延迟,最坏情况延迟为 800 + 500 = 1300 微秒。

在这个场景中,我们发现运行时间长的处理程序(如键盘)对其他设备的最坏情况延迟产生了巨大影响。

那么,有哪些可能性可以减少最坏情况延迟呢?是否存在比先到先服务更好的调度算法?


引入弱优先级调度

一种策略是为待处理的请求分配优先级,并按照优先级顺序提供服务。如果处理程序是不可中断的,优先级将用于在当前任务完成时选择下一个要运行的任务。

请注意,在此策略下,当前任务总是会运行到完成,即使有更高优先级的请求在其执行期间到达。这被称为非抢占式弱优先级系统。

在弱优先级系统中,每个设备的最坏情况延迟计算如下:

  • 所有其他设备的最坏情况服务时间(因为新请求到达时,那个处理程序可能刚刚开始运行)。
  • 加上所有更高优先级设备的服务时间(因为它们会被优先运行)。

在我们的示例中,假设我们分配最高优先级给磁盘,次优先级给打印机,最低优先级给键盘。

以下是各设备的最坏情况延迟分析:

  • 键盘:由于优先级最低,其延迟不变,仍可能被更高优先级的磁盘和打印机处理程序延迟,最坏延迟为 500 + 400 = 900 微秒。
  • 磁盘:拥有最高优先级,因此总是在当前处理程序完成后被选中执行。其最坏情况延迟就是当前运行处理程序的最坏情况服务时间,在本例中是键盘的800微秒。相比先到先服务的1200微秒,这是一个显著的改进。
  • 打印机:其最坏情况是,可能必须等待键盘处理程序完成(最多800微秒),然后等待一个更高优先级的磁盘请求被服务(500微秒),总延迟为 800 + 500 = 1300 微秒。

如何分配优先级:最早截止时间优先策略

在给定严格的实时约束下,应该如何分配优先级?我们假设每个设备在其服务请求到达后,都有一个服务截止时间 D。如果没有特别说明,通常假设 D 是同一设备下一个请求到达之前的时间。这是一个相当保守的假设,可以防止系统越来越落后。例如,键盘处理程序理应在下一个字符到达前完成对当前字符的处理。

最早截止时间优先 是一种分配优先级的策略,其原则是:如果存在任何优先级分配方案能够满足所有截止时间,那么该策略就能保证满足截止时间。它非常简单:按照请求的截止时间排序,将最高优先级分配给截止时间最早的请求,次优先级给下一个截止时间,依此类推。弱优先级系统将选择优先级最高(即截止时间最早)的待处理请求。

最早截止时间优先策略具有直观的吸引力。想象一下在机场排长队过安检,优先处理航班最早的乘客是合理的,前提是在他们的航班起飞前有足够的时间处理所有人。如果先处理10个航班在30分钟后起飞的乘客,而让一个航班在5分钟后起飞的乘客等待,将导致后者错过航班。但如果先处理后者,其他乘客可能稍有延迟,但每个人都能赶上航班。这正是弱优先级系统中的最早截止时间调度可以解决的问题。

虽然超出了我们讨论的范围,但思考一下如果某些航班注定要错过该怎么办也很有趣。如果系统过载,按最早截止时间优先可能意味着所有人都会错过航班。在这种情况下,更好的策略可能是分配优先级以最小化错过航班的总数。这使问题变得复杂,因为优先级的分配现在取决于具体有哪些待处理请求以及服务它们需要多长时间,这是一个值得深思的有趣问题。


总结

本节课我们一起学习了实时系统中的调度策略。我们首先分析了先到先服务调度算法导致的长处理程序严重拖累系统响应时间的问题。接着,我们引入了弱优先级(非抢占式) 调度,通过为中断请求分配优先级来优化高优先级设备的延迟。最后,我们探讨了在严格实时约束下,如何采用最早截止时间优先策略来分配优先级,以确保系统能够满足所有关键任务的截止时间要求。理解这些基础调度概念对于设计高效、可靠的实时系统至关重要。

059:18.2.6 强优先级系统 🚦

在本节课中,我们将要学习强优先级系统。我们将了解弱优先级系统的局限性,并探讨如何通过引入可抢占的强优先级机制来满足设备的严格时限要求。我们还将分析在周期性中断请求下,如何计算CPU负载并确保所有实时约束都能被满足。

弱优先级系统的局限性

上一节我们介绍了弱优先级系统,本节中我们来看看它的一个主要问题。在弱优先级系统中,当前正在运行的任务总是会执行完毕,然后才考虑接下来运行哪个任务。

这意味着,一个设备的最坏情况延迟总是包含了所有其他设备的最坏情况服务时间。换句话说,就是我们必须等待当前运行任务完成的最大时间。

如果存在一个长时间运行的任务,通常意味着无法满足其他任务的严格截止时间。

以下是具体示例:

  • 假设磁盘请求有一个800微秒的截止时间,以保证磁盘子系统的最佳吞吐量。
  • 由于磁盘处理程序的服务时间是500微秒。
  • 因此,从磁盘请求发出到开始执行磁盘服务例程之间的最大允许延迟是300微秒。
  • 然而,弱优先级方案只能保证最大800微秒的延迟。
  • 这远不足以满足磁盘的截止时间要求。使用弱优先级无法满足磁盘的截止时间。

引入强优先级系统

我们需要引入一种可抢占的优先级系统,允许低优先级处理程序被高优先级请求中断。我们称之为强优先级系统。

假设我们像之前一样,赋予磁盘最高优先级,打印机次高优先级,键盘最低优先级。现在,当磁盘请求到达时,它将立即开始执行,而无需等待低优先级的打印机或键盘处理程序完成。

以下是各设备的最坏情况延迟变化:

  • 磁盘的最坏情况延迟降至0。
  • 打印机只会被磁盘抢占,因此其最坏情况延迟是500微秒。
  • 由于键盘优先级最低,其最坏情况延迟仍为900微秒,因为它可能仍需等待磁盘和打印机。

好消息是,通过正确分配优先级,强优先级系统可以保证该请求在800微秒的截止时间前得到服务。

硬件实现调整

我们需要对Beta硬件做一个小调整来实现强优先级系统。我们将用PC[31:29]中的一个3位字段p,替换PC[31]中的单个管理模式位,该字段指示处理器当前运行的8个优先级级别。

接下来,我们将按如下方式修改中断机制。除了请求中断外,请求设备还指定了系统架构师分配的3位优先级P_dev。我们将在中断硬件中添加一个优先级编码器电路,以选择最高优先级的请求,并将该请求的优先级P_dev与PC中的3位优先级值p进行比较。

系统仅在P_dev > p时接受中断请求。换句话说,仅当请求的优先级高于系统当前运行的优先级时,才会发生中断。

当中断被接受时,旧的PC和p信息被保存在XP中,新的PC由中断类型决定,并且新的p字段被设置为P_dev,因此处理器现在将以设备指定的更高优先级运行。

强优先级系统允许低优先级处理程序被高优先级请求中断,因此高优先级设备所经历的最坏情况延迟不受低优先级处理程序服务时间的影响。

周期性中断的影响

使用强优先级允许我们将高优先级分配给具有严格截止时间的设备,从而保证满足它们的截止时间。

现在让我们考虑周期性中断的影响,换句话说,每个设备发出的多个中断请求。我们在表格中添加了一个“最大频率”列,它给出了每个设备生成请求的最大速率。

表格下方展示了强优先级系统的执行时序图。这里我们看到每个设备都有多个请求,在本例中,以它们可能的最大请求速率显示。时间线上的每个刻度代表100微秒的实时。

以下是各设备的请求周期:

  • 打印机请求每1毫秒(10个刻度)发生一次。
  • 磁盘请求每2毫秒(20个刻度)发生一次。
  • 键盘请求每10毫秒(100个刻度)发生一次。

在图中,你可以看到高优先级的磁盘请求在收到后立即得到服务。中优先级的打印机请求会抢占低优先级的键盘处理程序执行。打印机请求会被磁盘请求抢占,但根据它们的请求模式,磁盘请求到达时从未有打印机请求正在处理,因此我们在这里没有看到这种情况发生。

键盘请求开始前的最大延迟确实是900微秒。但这并没有说明全部情况,正如你所见,可怜的键盘处理程序持续被更高优先级的磁盘和打印机请求抢占。因此,键盘处理程序直到其请求被接收后3毫秒才完成。

这说明了为什么实时约束最好用截止时间而不是延迟来表达。如果键盘的截止时间小于3毫秒,即使是强优先级系统也无法满足硬实时约束。原因在于,面对严格的截止时间,根本没有足够的CPU周期来满足设备的周期性需求。

CPU负载与截止时间分析

说到拥有足够的CPU周期,在考虑周期性中断时,我们需要进行几项计算。

第一项是考虑每个周期性请求给系统带来了多少负载。键盘每10毫秒有一个请求,服务每个请求需要800微秒,这消耗了800微秒 / 10毫秒 = 8%的CPU。

类似的计算表明,服务磁盘消耗25%的CPU,服务打印机消耗40%的CPU。总体而言,服务所有设备消耗了73%的CPU周期,剩下27%用于运行用户模式程序。显然,如果服务设备所需的周期超过可用周期的100%,我们就会遇到麻烦。

另一种陷入麻烦的方式是没有足够的CPU周期来满足每个截止时间。我们需要在磁盘请求和磁盘截止时间之间的时间内,使用500 / 800 = 62.5%的周期来服务磁盘。

如果我们假设希望在收到下一个打印机请求之前完成对当前请求的服务,那么打印机的有效截止时间是1000微秒。在1000微秒内,我们需要能够服务一个更高优先级的磁盘请求(500微秒)以及打印机请求本身(400微秒)。因此,我们需要在该1000微秒间隔内使用900微秒的CPU时间,刚好满足。

假设我们尝试将键盘截止时间设置为2000微秒。在那个时间间隔内,我们还需要服务一个磁盘请求和两个打印机请求。因此所需的总服务时间为500 + 2*400 + 800 = 2100微秒,这超出了给定的2000微秒窗口,所以我们无法用可用的CPU资源满足2000微秒的截止时间。

但如果键盘截止时间是3000微秒,让我们看看会发生什么。在3000微秒间隔内,我们需要服务两个磁盘请求、三个打印机请求以及一个键盘请求,总服务时间为2*500 + 3*400 + 800 = 3000微秒,刚好满足。

总结

本节课中我们一起学习了强优先级系统。我们首先指出了弱优先级系统在满足严格实时截止时间方面的不足。然后,我们引入了可抢占的强优先级机制,它允许高优先级中断立即抢占低优先级任务,从而显著降低了高优先级设备的响应延迟。接着,我们探讨了硬件上如何通过扩展优先级字段和修改中断逻辑来实现这一机制。最后,我们深入分析了在周期性中断场景下,如何计算系统的CPU总负载以及如何验证给定的优先级分配和截止时间是否可行。强优先级系统是构建可靠实时系统的关键,它确保了关键任务总能及时得到响应。

060:中断处理与优先级实例分析 🚀

在本节课中,我们将通过两个扩展实例,深入分析弱优先级和强优先级系统在实时任务调度中的应用。我们将以国际空间站的控制系统为例,探讨如何满足不同任务的实时性约束。

概述

我们将分析一个包含三个周期性任务的控制系统:补给飞船引导(SSG)、陀螺仪控制(G)和舱内压力控制(CP)。每个任务都有其请求周期、服务时间和截止期限。我们将首先在弱优先级系统下分析,然后在强优先级系统下重新分析,比较两种策略的差异。


实例一:弱优先级系统分析

上一节我们介绍了中断优先级的基本概念,本节中我们来看看在弱优先级系统下,如何为任务分配优先级并计算关键参数。

以下是任务参数表:

任务 周期 (ms) 服务时间 (ms) 截止期限 (ms)
SSG 30 5 25
G 40 10 20
CP 100 10 100

1. CP任务的最大服务时间

在弱优先级系统中,低优先级任务的服务时间不能过长,以免阻塞高优先级任务。SSG任务的最大允许延迟为20毫秒(25ms - 5ms),G任务的最大允许延迟为10毫秒(20ms - 10ms)。因此,任何其他处理程序(包括CP)的运行时间都不能超过10毫秒,否则G任务将错过其截止期限。

结论:CP任务的最大服务时间为 10毫秒

2. 满足约束的弱优先级排序

根据之前讨论的“最早截止期限优先”策略,截止期限越短,优先级越高。

以下是优先级排序(从高到低):

  1. G任务(截止期限20ms)
  2. SSG任务(截止期限25ms)
  3. CP任务(截止期限100ms)

3. 处理器空闲时间比例

我们需要计算CPU用于服务周期性请求的周期比例。

  • SSG任务:5 / 30 = 16.67%
  • G任务:10 / 40 = 25%
  • CP任务:10 / 100 = 10%

总服务负载为 16.67% + 25% + 10% = 51.67%
因此,处理器空闲时间比例为 100% - 51.67% = 48.33%

这意味着宇航员在空闲时间可以玩《我的世界》。

4. 各任务的最坏情况完成时间

在弱优先级系统中,一个任务可能被更高优先级的任务抢占,也可能需要等待正在运行的低优先级任务完成。

以下是各任务的最坏情况完成时间计算:

  • SSG(最低优先级):可能需等待CP(10ms)和G(10ms)完成,再加上自身服务时间(5ms)。最坏完成时间为 10 + 10 + 5 = 25ms
  • G(中等优先级):可能需等待CP(10ms)完成,再加上自身服务时间(10ms)。最坏完成时间为 10 + 10 = 20ms
  • CP(最高优先级):不会被抢占。但可能需等待当前正在运行的SSG(最长5ms)或G(最长10ms)完成。最坏情况是,一个SSG(5ms)刚启动,紧接着一个G请求(10ms)到达。CP需等待 5 + 10 = 15ms,再加上自身服务时间10ms,总完成时间为 15 + 10 = 25ms


实例二:强优先级系统分析

在分析了弱优先级系统后,我们现在切换到强优先级系统。在强优先级下,高优先级任务可以抢占低优先级任务,这将改变我们的计算方式。

我们假设优先级顺序不变:G最高,SSG次之,CP最低。

1. CP任务的最大服务时间

在强优先级系统中,CP任务的服务时间不再受限于高优先级任务的最大延迟,因为它会被抢占。我们需要考虑在CP请求与其截止期限之间的100毫秒间隔内,SSG和G任务会占用多少CPU时间。

在100毫秒内:

  • SSG可能有4次请求(时间点:0, 30, 60, 90),总服务时间为 4 * 5ms = 20ms
  • G可能有3次请求(时间点:0, 40, 80),总服务时间为 3 * 10ms = 30ms

高优先级任务总共需要 20ms + 30ms = 50ms 的服务时间。
因此,CP任务的服务时间最多可达 100ms - 50ms = 50ms,仍能满足100毫秒的截止期限。

2. 处理器空闲时间比例

假设CP服务时间为50毫秒。

  • SSG任务:5 / 30 = 16.67%
  • G任务:10 / 40 = 25%
  • CP任务:50 / 100 = 50%

总服务负载为 16.67% + 25% + 50% = 91.67%
因此,处理器空闲时间比例为 100% - 91.67% = 8.33%

3. 各任务的最坏情况完成时间

在强优先级系统中,计算方式有所不同。

以下是各任务的最坏情况完成时间:

  • G(最高优先级):服务程序在收到请求后立即运行,最坏完成时间就是其服务时间 10ms
  • SSG(中等优先级):在SSG请求与其25ms截止期限之间,最多可能有1次G请求(10ms)抢占其执行。因此,最坏完成时间为 10ms (G) + 5ms (SSG) = 15ms
  • CP(最低优先级):根据问题1的计算,我们选择了50ms的服务时间,考虑到多个高优先级请求的服务时间,它将在其100ms的截止期限那一刻完成。


总结

本节课中我们一起学习了中断处理中优先级系统的实际应用。我们通过国际空间站控制系统的两个实例,对比分析了弱优先级和强优先级系统:

  1. 弱优先级系统中,低优先级任务的服务时间受限于高优先级任务的延迟要求,计算相对直接,但低优先级任务可能被“饿死”。
  2. 强优先级系统中,高优先级任务可以抢占低优先级任务,因此计算最大服务时间时,需考虑在截止期限窗口内所有高优先级任务的总服务时间。这通常允许低优先级任务有更长的服务时间,但调度更复杂。

在实际的计算机系统中,通常采用强优先级系统,并支持有限数量的优先级级别。在同一强优先级级别内,则使用弱优先级系统来处理多个设备。这种混合方法在实践中效果很好,使系统能够满足其I/O设备提出的各种实时约束。

061:中断处理实例分析 🧠

在本节课中,我们将学习如何分析一个包含多个中断设备的计算机系统。我们将探讨在弱优先级和强优先级两种中断处理机制下,如何计算程序执行时间,以及如何确定能否满足所有设备的中断处理时限要求。


系统设备与中断特性

假设我们有一个计算机系统,包含三个设备:D1、D2 和 D3。每个设备都可能引发中断。

下表总结了每个设备的中断特性:

设备 服务时间 中断频率 截止时间
D1 400 微秒 每 800 微秒 500 微秒
D2 250 微秒 每 1000 微秒 300 微秒
D3 100 微秒 每 800 微秒 400 微秒

服务时间是指处理该设备中断所需的时间。
中断频率是指该设备中断到达的频率。
截止时间是指从中断请求发出到中断处理程序完成所允许的最长时间。


计算程序执行时间

假设有一个程序 P,在禁用中断的情况下执行需要 100 秒。我们想计算在启用中断的情况下,执行该程序需要多长时间。

要回答这个问题,我们需要确定 CPU 用于处理每个设备中断的时间比例。

以下是计算过程:

  • D1 占用 CPU 时间比例:400 / 800 = 50%
  • D2 占用 CPU 时间比例:250 / 1000 = 25%
  • D3 占用 CPU 时间比例:100 / 800 = 12.5%

这意味着用户程序可用的剩余 CPU 时间为:
100% - 50% - 25% - 12.5% = 12.5%

如果用户程序只能占用八分之一的 CPU 时间,那么一个在无中断时需要 100 秒的程序,在启用中断后将需要:
100 秒 / (12.5%) = 800 秒


弱优先级排序分析

上一节我们计算了程序的总执行时间,本节中我们来看看在弱优先级排序下,系统能否满足所有设备的截止时间要求。

在弱优先级系统中,没有抢占机制。一旦一个中断处理程序开始运行,它将运行到完成,即使有更高优先级的中断在其完成前到达。处理程序完成后,系统会按优先级顺序处理所有已到达的中断。

我们需要判断是否存在一种弱优先级排序能满足所有系统约束。如果存在,则确定该排序;如果不存在,则指出哪些设备的约束无法被保证满足。

观察设备特性表,比较截止时间与服务时间。在弱优先级系统中,如果服务时间为 400 微秒的 D1 处理程序正在运行,此时 D2 或 D3 中断到达,那么 D2 或 D3 可能会错过其截止时间。因为 D1 的服务时间加上它们自身的服务时间,超过了它们的截止时间。

具体来说:

  • 如果 D2 必须等待 400 微秒才开始被服务,那么它的完成时间将是 400 + 250 = 650 微秒,这超过了其 300 微秒的截止时间。
  • 如果 D3 必须等待 400 微秒才开始被服务,那么它的完成时间将是 400 + 100 = 500 微秒,这超过了其 400 微秒的截止时间。

因此,不存在一种弱优先级排序能保证满足我们系统的所有约束。


强优先级排序分析

现在,让我们在强优先级排序的假设下重新考虑同样的问题。

在强优先级系统中,高优先级设备的中断处理程序可以抢占正在运行的低优先级设备的处理程序。换句话说,如果设备 A 的优先级高于设备 B,并且一个 A 中断在 B 中断处理过程中到达,那么 B 中断处理程序将被中断。系统会先运行 A 的处理程序,待其完成后,再恢复执行 B 的处理程序。

我们需要判断是否存在一种强优先级排序能满足所有系统约束。如果存在,则确定该排序;如果不存在,则指出哪些设备的约束无法被保证满足。

由于我们现在允许抢占低优先级设备的处理程序以满足高优先级设备的要求,因此不再面临“如果 D1 先运行,则 D2 和 D3 无法满足截止时间”的问题。

此外,在本问题开始时,我们已经确定,根据各设备的服务时间和中断频率,系统有足够的时间服务所有中断。这意味着必定存在一种强优先级排序可以满足系统的所有约束。

我们可以采用“截止时间越短的设备,优先级越高”的原则来得出一个有效的强优先级排序。

一个有效的强优先级排序是:D2 优先级最高,其次是 D3,最后是 D1。用符号表示为:D2 > D3 > D1

对于本例,这个排序是唯一能满足强优先级系统所有约束的有效排序。为了验证这一点,让我们简要分析其他排序的可能性:

  • 如果 D1 的优先级高于 D2 或 D3,那么 D2 和 D3 的截止时间将无法得到保证。因此,D1 必须具有最低优先级。
  • 在 D2 和 D3 之间,如果 D3 的优先级高于 D2,那么当 D3 正在被服务时 D2 中断到达,D2 中断可能要到 350 微秒后才能完成(D3服务时间100微秒 + D2服务时间250微秒),这超出了其 300 微秒的截止时间。因此,D2 必须具有最高优先级。

所以,最终的排序是 D2 > D3 > D1。


总结

本节课中我们一起学习了中断处理系统的实例分析。我们首先根据设备的中断频率和服务时间,计算了在中断启用环境下用户程序的执行时间。接着,我们分析了在弱优先级(非抢占式)系统中,由于低优先级的长服务时间中断可能阻塞高优先级中断,导致无法保证所有设备满足截止时间要求。最后,我们探讨了在强优先级(抢占式)系统中,通过为截止时间短的设备分配高优先级(即 D2 > D3 > D1),可以确保所有中断的时限约束得到满足。这个例子清晰地展示了不同中断处理策略对系统实时性的影响。

062:进程间通信

在本节课中,我们将要学习进程间通信的基本概念。我们将探讨为何应用程序需要被组织为多个相互通信的进程,以及这些进程之间如何有效地共享信息和协调工作。我们将以经典的生产者-消费者问题为例,详细分析进程间的通信与同步机制。

多进程应用的优势

上一节我们介绍了进程间通信的主题,本节中我们来看看为何要使用多进程。

一个应用程序被组织为多个相互通信的进程,这种情况并不少见。使用多个进程而非单一进程的优势在于,许多应用程序天然具有并发性。换句话说,部分所需的计算可以并行执行。

以下是几个具体的例子:

  • 视频压缩:视频压缩算法将每一帧视频表示为8x8像素的宏块数组。每个宏块通过将64个强度和颜色值从空间域转换到频域,然后对频率系数进行量化和霍夫曼编码来单独压缩。如果使用多核处理器进行压缩,就可以并发地执行宏块压缩。
  • 视频游戏:像视频游戏这样的应用程序,自然地分为前端用户界面和后端模拟与渲染引擎。用户的输入相对于模拟过程是异步到达的。将用户事件的处理与后端处理分开组织是最简单的方式。

进程是一种有效的方式,用于封装应用程序中逻辑上独立组件的状态和计算,这些组件在需要共享信息时相互通信。这类应用程序通常是数据驱动或事件驱动的,也就是说,所需的处理取决于待处理的数据或外部事件的到达。

进程间通信方式

了解了多进程的优势后,我们来看看进程间应如何通信。

如果进程运行在相同的物理内存中,通过将相同的物理页映射到两个进程的上下文中,可以轻松地安排共享内存数据。一个进程写入该页面的任何数据都能被另一个进程读取。

为了更便于协调通过共享内存进行通信的进程,提供同步原语会非常方便。一些指令集架构包含了便于进行所需同步的指令。

另一种方法是添加操作系统管理程序调用,以从一个进程向另一个进程传递消息。消息传递比共享内存涉及更多开销,但它使得应用程序的编程环境独立于通信进程是否运行在同一个物理处理器上。

生产者-消费者问题

在本次讲座中,我们将使用经典的生产者-消费者问题作为需要通信和同步的并发进程的示例。

有两个进程:一个生产者和一个消费者。生产者在一个循环中运行,执行某些计算 Xxx 来生成信息,在本例中是一个字符 C。消费者也在一个循环中运行,等待来自生产者的下一个字符到达,然后执行某些计算 Yyy

在生产者和消费者之间传递的信息显然可以比单个字符复杂得多。例如,编译器可能生成一系列汇编语言语句,传递给汇编器以转换为适当的二进制数组表示。视频游戏的用户界面前端可能会将一系列玩家操作传递给模拟和渲染后端。

实际上,在处理流水线中连接多个进程的概念非常有用,以至于Unix和Linux操作系统在操作系统中提供了一个管道原语,将上游进程的输出通道连接到下游进程的输入通道。

执行顺序与约束

让我们看看这个简单生产者-消费者示例中动作的时序图。我们将用箭头表示一个动作发生在另一个动作之前。在单个进程内部,例如生产者,执行顺序意味着特定的时间顺序。第一次执行 Xxx 之后是发送第一个字符,然后是第二次执行 Xxx,接着是发送第二个字符,依此类推。在后面的例子中,我们将省略同一程序中连续语句之间的时序箭头。

我们在消费者中看到了类似的执行顺序:第一个字符被接收,然后第一次执行计算 Yyy,等等。在每个进程内部,程序计数器决定了计算执行的顺序。

到目前为止一切顺利,每个进程都按预期运行。然而,为了使生产者-消费者系统作为一个整体正确运行,我们需要对执行顺序引入一些额外的约束。

这些被称为优先约束,我们将使用风格化的“小于”符号来表示计算A必须在计算B之前发生。在生产者-消费者系统中,我们不能在数据被生产之前就消费它,这个约束可以形式化地要求第 i 次发送操作必须先于第 i 次接收操作。这个时序约束在时序图中显示为实心红色箭头。

假设我们使用一个共享内存位置来保存从生产者传输到消费者的字符,我们需要确保生产者不会在消费者读取前一个字符之前就覆盖它。换句话说,我们要求第 i 次接收必须先于第 i+1 次发送。这些时序约束在时序图中显示为虚线红色箭头。

这些优先约束共同意味着生产者和消费者是紧密耦合的,即一个字符必须被消费者读取后,生产者才能发送下一个字符。如果 XxxYyy 计算花费的时间是可变的,这可能不是最优的。

使用缓冲区解耦进程

那么,让我们看看如何放宽约束,以允许生产者和消费者之间有更多的独立性。

我们可以通过让生产者和消费者通过一个先进先出缓冲区进行通信来放宽对它们的执行约束。当生产者产生字符时,它将它们插入缓冲区。消费者按照字符被生产的相同顺序从缓冲区读取字符。缓冲区可以容纳0到 N 个字符。如果缓冲区持有零个字符,则为空;如果持有 N 个字符,则为满。如果缓冲区已满,生产者应该等待;如果缓冲区为空,消费者应该等待。

使用 N 字符缓冲区将我们的第二个覆盖约束放宽为:第 i 次接收必须发生在第 i+N 次发送之前。换句话说,生产者可以比消费者提前最多 N 个字符。

缓冲区通常实现为一个具有两个索引的 N 元素字符数组。读索引指示下一个要读取的字符,写索引指示下一个要写入的字符。我们还需要一个计数器来跟踪缓冲区持有的字符数量,但图中省略了这一点。索引按模 N 递增。换句话说,在访问第 N-1 个元素之后,下一个要访问的元素是第0个元素,因此得名循环缓冲区

其工作原理如下:生产者运行,使用写索引将第一个字符添加到缓冲区。生产者可以产生额外的字符,但一旦缓冲区满就必须等待。消费者可以在缓冲区不为空的任何时候接收一个字符,使用读索引来跟踪下一个要读取的字符。只要生产者不写入已满的缓冲区,且消费者不从空缓冲区读取,生产者和消费者的执行可以按任何顺序进行。

代码实现与同步需求

以下是生产者和消费者的代码可能的样子。循环缓冲区的数组和索引位于共享内存中,两个进程都可以访问它们。生产者中的发送例程使用写索引 in 来跟踪下一个字符的写入位置。同样,消费者中的接收例程使用读索引 out 来跟踪下一个要读取的字符。每次使用后,每个索引按模 N 递增。

这段代码的问题在于,按照目前的写法,两个优先约束都没有被强制执行。消费者可以从空缓冲区读取,而生产者可以在缓冲区满时覆盖条目。我们需要修改这段代码来强制执行约束,为此,我们将引入一种新的编程结构,用于提供适当的进程间同步。


本节课中我们一起学习了进程间通信的基本原理。我们探讨了使用多进程的优势,分析了共享内存和消息传递两种通信方式,并以生产者-消费者问题为例,深入讨论了进程间的执行约束、使用缓冲区进行解耦的方法,以及实现时对同步机制的需求。理解这些概念是构建正确、高效并发应用程序的基础。

063:6.4 信号量 🚦

在本节课中,我们将学习一种强大的同步抽象工具——信号量。我们将了解它的定义、基本操作,并学习如何使用它来解决进程间的同步问题,特别是生产者-消费者问题。

概述

信号量是一种由荷兰计算机科学家 Edsger Dijkstra 提出的抽象数据类型,用于解决多进程环境下的同步需求。它本质上是一个共享的整数值(≥0),通过两个原子操作 waitsignal 来管理,从而协调多个进程对共享资源的访问顺序。

信号量的定义与操作

信号量是一个整数值大于或等于0的抽象数据类型。程序员可以声明一个信号量,并指定其初始值。这个信号量存储在所有需要同步操作的进程共享的内存位置中。

对信号量的访问通过两个操作进行:waitsignal

  • wait 操作:此操作会等待,直到指定的信号量值大于0。然后,它会将信号量值减1并返回调用程序。如果调用 wait 时信号量值为0,则概念上会暂停执行,直到信号量值变为非0。一个简单但低效的实现是让 wait 例程循环,定期测试信号量的值,直到其值非0才继续执行。
  • signal 操作:此操作将指定信号量的值加1。如果有任何进程正在等待该信号量,那么其中恰好有一个进程现在可以继续执行。我们必须小心实现 signalwait,以确保“恰好一个”这个约束得到满足。换句话说,要防止两个等待同一信号量的进程在收到一个 signal 后,都认为自己可以递减信号量并继续执行。

一个初始值为 K 的信号量保证:第 Isignal 调用会先于第 I+Kwait 调用完成。稍后我们将通过具体例子来阐明这一点。

注意:在6.004课程中,我们排除了信号量值为负的情况。在文献中,你可能会看到用 P(S) 代替 wait(S),用 V(S) 代替 signal(S)。这些操作名称来源于荷兰语的“测试”和“增加”。

使用信号量实现执行顺序约束

上一节我们介绍了信号量的基本概念,本节中我们来看看如何使用它来实现进程间的执行顺序约束。

假设有两个进程A和B,每个进程运行一个包含五个语句的程序。在每个进程内部,执行是顺序的(A1在A2之前执行,依此类推)。但进程之间没有执行顺序约束,因此进程B的语句B1可能在进程A的任何语句之前或之后执行。

如果我们希望施加一个约束:语句A2的执行必须在语句B4开始执行之前完成(如下图所示)。

以下是使用信号量实现这种简单前驱约束的步骤:

  1. 声明并初始化信号量:首先,声明一个信号量(本例中称为 S)并将其初始值设为 0
  2. 放置 signal 调用:在约束箭头开始的位置放置一个 signal(S) 调用。在本例中,signal(S) 被放在进程A的语句A2之后。
  3. 放置 wait 调用:在约束箭头结束的位置放置一个 wait(S) 调用。在本例中,wait(S) 被放在进程B的语句B4之前。

进行这些修改后,进程A照常执行,signal(S) 在语句A2执行后发生。进程B的语句B1到B3也照常执行,但当执行到 wait(S) 时,进程B的执行会被挂起,直到 signal(S) 语句执行完毕。这保证了B4只有在A2完成后才会开始执行。

通过将信号量 S 初始化为0,我们强制了第一个 signal(S) 调用必须在第一个 wait(S) 调用成功之前完成。

将信号量视为资源管理器

理解信号量的另一种方式是将其视为共享资源池的管理工具。其中,资源池的大小 K 就是信号量的初始值。

  • 使用 signal 操作向共享池添加归还资源。
  • 使用 wait 操作从共享池分配一个资源供你独占使用。

在任何给定时间,信号量的值表示共享池中尚未分配的可用资源数量。请注意,waitsignal 操作可以在同一个进程中,也可以在不同的进程中,这取决于资源何时被分配和归还。

应用:生产者-消费者问题(第一部分)

现在,让我们将信号量应用到经典的生产者-消费者问题中。假设我们有一个能容纳 N 个字符的缓冲区。

首先,我们定义一个信号量 chars 并将其初始化为 0chars 的值将告诉我们缓冲区中有多少个字符。

  • 生产者(发送方) 在向缓冲区添加一个字符后,执行 signal(chars),表示缓冲区现在多了一个字符。
  • 消费者(接收方) 在从缓冲区读取之前,执行 wait(chars) 以确保缓冲区中至少有一个字符。

由于 chars 被初始化为0,这强制了第 Isignal(chars) 调用必须先于第 Iwait(chars) 调用完成。换句话说,消费者在字符被生产者放入缓冲区之前,无法消费它。

我们使用 chars 信号量实现了一个必要的同步约束:消费者不能从空缓冲区读取。但这足够了吗?

识别缺失的约束

上一节我们使用信号量防止了消费者读取空缓冲区,但生产者这边还存在问题。

是什么阻止生产者向 N 字符的缓冲区中放入超过 N 个字符?什么也没有。生产者可能会开始覆盖之前放入缓冲区、但尚未被消费者读取的字符。这被称为缓冲区溢出,从生产者传输到消费者的字符序列会遭到严重破坏。

到目前为止,我们保证的是:消费者只有在生产者将字符放入缓冲区后才能读取它。但我们仍然需要保证:生产者不能领先消费者太多。由于缓冲区最多容纳 N 个字符,生产者必须在消费者读取了第 I 个字符之后,才能发送第 I+N 个字符。

应用:生产者-消费者问题(完整方案)

为了解决上述问题,我们需要第二个信号量来管理缓冲区中的空位数量。

我们添加第二个信号量 spaces 来管理缓冲区中的空位数量。初始时,缓冲区是空的,所以它有 N 个空位。

  • 生产者 必须等待有空位可用。当 spaces 非0时,wait(spaces) 成功,将可用空位数减1,然后生产者用下一个字符填充该空位。
  • 消费者 在从缓冲区读取一个字符后,执行 signal(spaces),表示又多了一个可用空位。

这里存在一种美妙的对称性:

  • 生产者消耗空位,并生产字符。
  • 消费者消耗字符,并生产空位。

信号量被用来跟踪这两种资源(即字符和空位)的可用性,从而同步生产者和消费者的执行。

这个方案在单个生产者进程和单个消费者进程的情况下工作得很好。接下来,我们将思考如果存在多个生产者和多个消费者会发生什么。😊

总结

本节课中,我们一起学习了信号量这一核心同步机制。我们了解了信号量作为共享整数和资源管理器的双重视角,掌握了 waitsignal 操作的含义。通过具体的例子,我们学习了如何使用信号量来强制进程间的执行顺序,并完整地解决了经典的单生产者-单消费者问题,通过 charsspaces 两个信号量实现了对缓冲区访问的安全同步。这为理解更复杂的并发编程模式奠定了坚实基础。

064:原子事务 🔒

在本节课中,我们将学习并发编程中的一个核心概念:原子事务。我们将通过一个银行取款的例子,理解当多个进程同时访问和修改共享数据时可能出现的问题,并学习如何使用信号量来确保关键代码段的原子执行,从而实现互斥访问。


概述

当多个进程或线程同时操作共享数据时,如果不加以控制,可能会导致数据不一致。本节我们将探讨这个问题,并引入临界区互斥事务的概念,学习如何使用信号量来保护共享数据。


银行取款示例:并发问题 💳

让我们看一个不同的例子。自动取款机允许银行客户执行多种交易,如存款、取款、转账等。我们考虑当两个客户同时尝试从同一个账户中取出50美元时会发生什么。

银行取款交易的部分代码显示在右上角。这段代码负责调整账户余额以反映取款金额。假设检查账户是否有足够资金的操作已经完成。

预期行为

假设银行使用独立的进程来处理每笔交易。因此,两个取款交易会创建两个不同的进程,每个进程都将运行扣款代码。如果每个对debit的调用都能不受干扰地完成,我们会得到预期的结果。第一笔交易从账户中扣除50美元,然后第二笔交易做同样的事情。最终结果是,你和你的朋友总共得到100美元,而账户余额减少了100美元。

到目前为止,一切正常。

并发冲突

但是,如果第一个交易的进程在刚读取余额后就被中断,会发生什么?第二个进程从余额中减去50美元,完成了它的交易。现在,第一个进程恢复运行,使用的是它在被中断前加载的、现在已经过时的余额。最终结果是,你和你的朋友总共得到100美元,但账户余额只被扣除了50美元。

这个故事的寓意是,在编写读取和写入共享数据的代码时需要小心,因为其他进程可能在我们执行的过程中修改数据。


临界区与互斥 🔐

当我们说更新一个共享内存位置时,通常需要加载当前值、修改它,然后存储更新后的值。我们希望确保在加载开始到存储完成之间,没有其他进程访问该共享位置。

这个“加载-修改-存储”的代码序列就是我们所说的临界区。我们需要安排其他试图执行同一临界区的进程被延迟,直到我们的执行完成。这个约束被称为互斥。换句话说,同一时间只能有一个进程执行同一临界区中的代码。

一旦我们识别出临界区,我们将使用信号量来保证它们的原子执行。也就是说,一旦临界区的执行开始,在它完成之前,没有其他进程能够进入该临界区。

信号量(用于强制执行互斥约束)和临界区代码的组合,实现了一个事务。事务可以执行共享数据的多次读写,并保证在事务进行期间,这些数据不会被其他进程读取或写入。


使用信号量保护临界区 🛡️

以下是原始的debit代码,我将通过添加一个锁信号量来修改它。在这个例子中,信号量控制的资源是运行临界区代码的权利。

通过将lock初始化为1,我们表示最多只能有一个进程可以同时执行临界区。

运行debit代码的进程在锁信号量上执行wait操作。如果lock的值为1wait操作会将lock的值减为0,并允许该进程进入临界区。这被称为获取锁

如果lock的值为0,则表示其他进程已经获取了锁并正在执行临界区,我们的执行将被挂起,直到lock值变为非0

当进程完成临界区的执行后,它通过调用signal释放锁,这将允许其他进程进入临界区。如果有多个进程在等待,只有一个能够获取锁,其他进程仍需等待轮到它们。

以这种方式使用,信号量实现了互斥约束。换句话说,它保证了两次临界区的执行不会重叠。

需要注意的是,如果多个进程需要执行临界区,它们可以以任何顺序运行,唯一的保证是它们的执行不会重叠。


锁的粒度与设计考量 ⚙️

这里有一些有趣的工程问题需要考虑。

首先是锁的粒度问题,即锁控制哪些共享数据。在我们的银行例子中,应该用一个锁来控制所有账户余额的访问吗?那将意味着在进行一笔交易时,任何人都无法访问任何余额。这意味着访问不同账户的交易也必须一个接一个地运行,即使它们访问的是不同的数据。因此,为所有余额使用一个锁会引入不必要的顺序约束,大大降低交易处理的速度。

由于我们需要的保证是,不应该允许对同一账户进行多个同时的交易,因此为每个账户设置一个单独的锁,并修改debit代码在继续之前获取该账户的锁,会更有意义。这将只延迟真正重叠的交易,这对于一个每秒处理数千笔大多不重叠交易的大型系统来说,是一个重要的效率考量。

当然,为每个账户设置锁意味着需要很多锁。如果这是一个问题,我们可以采用一种折中策略,即使用保护一组账户的锁,例如,账户号码后三位相同的账户。这意味着我们只需要1000个锁,就可以允许最多1000笔交易同时发生。

共享数据上的事务概念非常有用,以至于我们经常使用一个称为数据库的独立系统来提供所需的功能。数据库经过精心设计,以提供对共享数据的低延迟访问,并提供适当的事务语义。数据库和事务的设计与实现非常有趣,建议有兴趣的读者在网上阅读更多相关资料。


回到生产者-消费者示例 🔄

回到我们的生产者-消费者示例,我们看到如果多个生产者试图同时向缓冲区插入字符,它们的执行可能会以导致字符被覆盖和/或索引被错误递增的方式重叠。我们刚刚在银行示例中看到了这个错误。

生产者代码包含一个访问PIO缓冲区的临界区代码,我们需要确保该临界区被原子地执行。

在这里,我们添加了第三个名为lock的信号量,为将字符插入PIO缓冲区的代码临界区实现必要的互斥约束。经过这个修改,当存在多个生产者进程时,系统现在可以正确工作。

多个消费者也存在类似的问题。因此,我们使用相同的锁来保护从缓冲区读取的临界区和receive代码。为生产者和消费者使用相同的锁可以工作,但确实引入了不必要的顺序约束,因为生产者和消费者使用不同的索引(即,生产者用in,消费者用out)。为了解决这个问题,我们可以使用两个锁,一个给生产者,一个给消费者。


信号量:同步问题的瑞士军刀 🛠️

在处理同步问题时,信号量是一个非常方便的工具。

waitsignal出现在不同的进程中时,它们确保进程间正确的执行时序。在我们的例子中,我们使用两个信号量来确保消费者不能从空缓冲区读取,生产者不能向满缓冲区写入。

我们还使用信号量来确保临界区的执行(在我们的例子中是索引inout的更新)被保证是原子的。换句话说,递增共享索引所需的读写序列,不会在初始读取索引和最终写入之间被另一个进程中断。


总结

在本节课中,我们一起学习了:

  1. 并发冲突:当多个进程无控制地访问共享数据时,可能导致数据不一致,如银行取款示例所示。
  2. 临界区与互斥:识别出需要原子执行的代码段(临界区),并通过互斥约束确保同一时间只有一个进程执行它。
  3. 信号量的应用:使用信号量作为锁,来实现互斥,保护临界区。
  4. 锁的粒度:根据实际需求(如按账户加锁)设计锁的粒度,以平衡正确性与性能。
  5. 生产者-消费者模型的完善:将互斥锁应用到生产者-消费者模型中,解决多生产者/消费者场景下的数据竞争问题。

通过理解和使用原子事务与信号量,我们可以编写出正确、高效的并发程序。

065:信号量的实现 🔧

在本节课中,我们将要学习如何从零开始实现信号量。信号量本身是共享数据,其 waitsignal 操作需要以临界区执行的读-修改-写序列。我们将探讨两种主要的实现方法:利用操作系统内核的不可中断特性,以及使用特殊的原子硬件指令。

实现挑战与引导问题

信号量是实现互斥约束的常用工具。然而,我们无法使用信号量自身来实现信号量,这被称为“引导问题”。因此,我们需要从头构建所需的功能。

幸运的是,在运行有时分共享处理器和不可中断操作系统内核的系统上,我们可以利用系统调用机制来实现所需功能。

我们也可以扩展指令集架构,加入特殊的测试并设置指令,这允许我们实现一个简单的锁信号量。然后,这个锁可以用来保护实现更复杂信号量语义的临界区。

单个指令本质上是原子的。在多核处理器中,如果共享主存支持将读取旧值和写入新值到特定内存位置作为一个单一的内存访问操作,那么这种指令就能满足我们的需求。

此外,还存在其他更复杂的纯软件解决方案,它们依赖于单个读写的原子性来实现简单锁,例如维基百科上的C. Decker算法。本节课我们将更详细地探讨前两种方法。

方法一:基于系统调用的实现

以下是 waitsignal 系统调用的操作系统处理程序。由于系统调用在内核模式下运行且不可中断,因此处理程序代码自然作为临界区执行。

两个处理程序都期望用户程序将信号量内存地址作为参数通过寄存器 R0 传递。

Wait 处理程序

wait 处理程序检查信号量的值。如果值非零,则将其减一,然后处理程序恢复用户程序在 wait 系统调用之后指令的执行。

如果信号量为 0,代码会安排当用户程序恢复执行时重新执行 wait 系统调用。接着,它调用 sleep 将进程标记为非活动状态,直到收到对应的 wakeup 调用。

Signal 处理程序

signal 处理程序更简单:它将信号量值加一,并调用 wakeup 来标记任何正在等待此特定信号量的进程为活动状态。最终,轮转调度器会选中一个正在等待的进程,该进程将能够减少信号量并继续执行。

需要注意的是,此代码没有提供公平性保证。换句话说,不能保证一个等待的进程最终一定能成功找到非零的信号量。调度器有特定的进程运行顺序,因此序列中的下一个等待进程总是会获得信号量,即使序列中后面有等待时间更长的进程。

如果希望实现公平性,wait 可以维护一个等待进程队列,并使用该队列(独立于调度顺序)来决定哪个进程是下一个。

方法二:基于原子指令的实现

许多指令集架构支持类似此处所示的测试并清零指令。TCLR 指令读取内存位置的当前值,然后将其设置为 0,所有操作作为一个单一操作。它类似于加载指令,区别在于它在读取值之后会将内存位置清零。

要实现 TCLR,内存需要支持读-清零操作以及正常的读写操作。

幻灯片底部的汇编代码展示了如何使用 TCLR 来实现一个简单的锁。

以下是实现步骤:

  1. 程序使用 TCLR 指令访问锁信号量的值。
  2. 如果返回给寄存器 RC 的值是 0,则表示其他进程已持有锁,程序循环以再次尝试 TCLR
  3. 如果返回值非零,则表示已获得锁,可以开始执行临界区代码。在这种情况下,TCLR 也已将锁设置为 0,从而阻止其他进程进入临界区。
  4. 当临界区执行完毕后,使用一条 store 指令将信号量设置为非零值,释放锁。

总结

本节课中,我们一起学习了信号量的两种底层实现方法。首先,我们看到了如何利用操作系统内核的不可中断特性,通过系统调用处理程序来实现 waitsignal 操作。其次,我们探讨了如何借助硬件提供的原子指令(如测试并清零)来构建一个简单的锁,进而保护更复杂的信号量操作。理解这些实现原理对于深入掌握并发编程和操作系统内核设计至关重要。

066:6.4 死锁 🚫

在本节课中,我们将要学习多进程同步中的一个关键问题:死锁。当多个进程需要同时获取多个共享资源时,如果处理不当,就可能陷入一种所有进程都无法继续执行的僵局。我们将通过银行转账和哲学家就餐的例子来理解死锁的成因、必要条件以及如何避免和解决它。

多锁同步的挑战

上一节我们介绍了使用信号量进行进程同步。当必要的同步操作需要获取多个锁时,就需要特别考虑一些问题。

例如,下面的代码实现了从一个银行账户向另一个账户转账的功能。代码假设每个账户都有一个独立的信号量锁,由于需要调整两个账户的余额,因此需要获取每个账户的锁。

// 伪代码示例:转账操作
transfer(from_account, to_account, amount) {
    acquire(lock[from_account]); // 获取源账户锁
    acquire(lock[to_account]);   // 获取目标账户锁
    balance[from_account] -= amount;
    balance[to_account] += amount;
    release(lock[to_account]);   // 释放目标账户锁
    release(lock[from_account]); // 释放源账户锁
}

考虑如果两个客户同时尝试在他们各自的账户之间进行转账会发生什么。客户A尝试先获取账户605的锁,再获取账户604的锁。客户B尝试先获取账户604的锁,再获取账户605的锁。

如果客户A成功获取了账户605的锁,同时客户B成功获取了账户604的锁,那么接下来,每个客户都无法成功获取他们的第二个锁,因为这些锁已经被对方持有。这种情况被称为死锁致命拥抱,因为没有任何一个进程能够恢复执行,它们都将无限期地等待一个永远不会变得可用的锁。

显然,涉及多个资源的同步需要更多的思考。

哲学家就餐问题 🍽️

死锁问题可以通过“哲学家就餐问题”得到优雅的阐释。假设有五位哲学家等待就餐,每位需要两根筷子才能开始吃饭,而桌上共有五根筷子。

哲学家遵循一个简单的算法:

  1. 拿起左边的筷子。
  2. 拿起右边的筷子。
  3. 当拥有两根筷子时,开始吃饭直到结束。
  4. 将两根筷子放回桌上。

我们再次看到了任务完成前需要两个或更多资源的基本设置。

你可能会发现这里出现的问题。如果所有哲学家同时拿起了他们左边的筷子,那么所有筷子都被占用,但没有一位哲学家能够拿到第二根筷子并开始吃饭——这又是一个死锁。

以下是发生死锁的四个必要条件:

  1. 互斥:一个资源一次只能被一个进程获取。
  2. 持有并等待:进程在等待获取下一个资源时,继续持有已分配的资源。
  3. 不可抢占:资源不能被强制从持有它的进程中移除,只能在进程完成事务后释放。
  4. 循环等待:一个进程需要的资源被另一个进程持有,反之亦然,形成一个循环等待链。

死锁的解决方案

当需要获取多个资源时,我们如何解决死锁问题?要么从一开始就避免它,要么检测到死锁已经发生并实施恢复策略。这两种技术在实践中都有应用。

避免死锁:全局排序法

在哲学家就餐问题中,可以通过对算法进行一个小修改来避免死锁。

首先,为每根筷子分配一个唯一的编号,建立所有资源的全局排序。然后重写代码,让哲学家按照全局排序来决定先获取哪根资源(筷子),后获取哪根。

以下是修改后的算法步骤:

  1. 哲学家拿起他左右两边筷子中编号较小的那一根。
  2. 然后拿起编号较大的那一根。
  3. 开始吃饭。
  4. 吃完后将两根筷子放回桌上。

这个修改如何避免死锁?死锁发生在所有筷子都被拿起,但没有哲学家能吃饭的时候。如果所有筷子都被拿起,意味着某位哲学家拿起了编号最大的筷子,那么他之前一定已经拿起了他另一侧编号更小的筷子。因此,这位哲学家可以吃饭,然后放回筷子,从而打破“持有并等待”的循环。

所以,如果系统中的所有进程都能就它们要获取的资源达成一个全局排序,并按顺序获取它们,那么就不会出现由“持有并等待”循环引起的死锁。

在我们的银行转账代码中,安排全局排序也很容易。我们将修改代码,使其先获取编号较小的账户的锁,再获取编号较大的账户的锁。

transfer(from_account, to_account, amount) {
    // 确定获取锁的顺序:先小号,后大号
    int first = min(from_account, to_account);
    int second = max(from_account, to_account);

    acquire(lock[first]);  // 先获取编号小的账户锁
    acquire(lock[second]); // 再获取编号大的账户锁

    balance[from_account] -= amount;
    balance[to_account] += amount;

    release(lock[second]);
    release(lock[first]);
}

现在,两个客户都会首先尝试获取账户604的锁。成功的那个客户可以接着获取账户605的锁并完成交易。死锁避免的关键在于,客户们会竞争他们共同需要的第一个资源的锁。获取到这个锁确保了它们能够获取剩余的共享资源,而不用担心这些资源会以导致“持有并等待”循环的方式被分配给另一个进程。

当我们能够修改所有进程以进行协作时,为共享资源建立并使用全局顺序是可行的。

检测与恢复策略

在不改变进程的情况下预防死锁是一个更困难的问题。例如,在操作系统层面,可以修改等待(wait)系统调用来检测循环等待,并终止其中一个等待进程,释放其资源以打破死锁。

我们提到的另一种策略是检测与恢复。数据库系统会在检测到对特定事务使用的共享数据进行外部访问时(这可能导致数据不一致),中止该事务。当向数据库提交事务时,程序员可以指定事务被中止时应采取的措施,例如可以指定重试该事务。数据库会记住事务期间对共享数据所做的所有更改,并且只在确定事务不会被中止时,才更改共享数据的主副本,此时更改被提交到数据库。

总结

本节课中我们一起学习了将应用程序组织为通信进程的便利性,以及使用信号量来同步不同进程的执行,保证特定约束得到满足。我们引入了临界代码段互斥约束的概念,以确保一段代码序列能被无中断地执行,并看到信号量也可用于实现这些互斥约束。

最后,我们重点讨论了死锁问题,它发生在多个进程必须获取多个共享资源时。我们提出了几种基于资源全局排序事务重启能力的解决方案。

同步原语在大数据领域和云计算中协调成千上万个进程时扮演着关键角色。理解同步问题及其解决方案,是编写大多数现代应用程序时的一项关键技能。

067:6.004 信号量实战示例 🚦

在本节课中,我们将学习如何使用信号量来确保程序中不同的先后顺序约束得到满足。

在深入探讨如何使用信号量来强制执行先后顺序要求之前,我们先回顾一下我们可用的工具。

信号量基础回顾

你可以将信号量视为一种数量有限的共享资源。

如果我们将一个信号量 S 初始化为 0,那么它表示当前资源 S 不可用。

如果 S 等于 1,则意味着恰好有一个 S 资源可供使用。如果 S 等于 2,则表示有两个 S 资源可用,依此类推。

为了使用共享资源,一个进程必须首先获取该资源。这通过在需要该资源的代码前添加一个 wait(S) 调用来实现。

只要 S 的值等于 0,等待此资源的代码就会被阻塞,意味着它无法通过 wait(S) 命令。

要通过 wait(S) 调用,信号量 S 的值必须大于 0,表示资源可用。获取资源是通过将信号量的值减 1 来实现的。

wait(S) 命令类似,我们还有一个 signal(S) 命令。

对信号量 S 执行 signal 操作表示一个额外的 S 资源变得可用。signal(S) 命令将 S 的值增加 1

这样做的结果是,一个正在等待 S 的进程现在将能够获取它并继续执行下一行代码。

应用场景:两个并发进程

现在让我们考虑两个并发运行的进程 P1P2

P1 有两段代码,其中 A 段在 B 段之前。类似地,P2 也有两段代码,C 段在 D 段之前。

在每个进程内部,执行是顺序进行的,因此我们保证 A 总是在 B 之前执行,C 总是在 D 之前执行。我们还假设没有循环,每个进程只运行一次。

我们想考虑如何利用不同的信号量来确保代码中任何必要的先后顺序约束。

场景一:确保 B 在 C 之前完成

假设我们需要满足的约束是:B 段代码必须在 C 段代码开始执行之前完成。

我们可以使用信号量 S 来实现这一点,首先在共享内存中将信号量初始化为 0

接下来,为了确保 C 段代码不会过早开始运行,我们在 C 段代码前添加一个 wait(S) 调用。只要 S 等于 0,进程 P2 中的代码就无法运行。

另一方面,P1 则不受此约束。因此 A 段代码可以立即开始运行。由于 B 段在 A 段之后,它将在 A 段之后执行。

一旦 B 段完成,进程 P1 需要发出信号通知我们的信号量,以表明现在进程 P2 可以开始执行了。signal(S) 调用将把 S 设置为 1,这将允许 P2 最终越过 wait(S) 命令。

以下是实现此约束的代码结构:

// 共享信号量
semaphore S = 0;

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6004-ddca/img/c8f2ffd57812531a9101f24d242bc6d8_47.png)

// 进程 P1
A();
B();
signal(S);

// 进程 P2
wait(S);
C();
D();

场景二:确保进程互斥(不重叠)

接下来,让我们考虑一个稍微复杂一点的约束:DA 之前,或者 BC 之前。

换句话说,P1P2 不能重叠。一个必须先运行,然后是另一个,但任何一个都可以是首先运行的。

为了实现这一点,我们希望将信号量 S 用作一个互斥信号量。互斥信号量或互斥锁确保一次只能有一个完整的代码块运行而不会被中断。

这可以通过将信号量 S 初始化为 1,并在两个进程的开头都放置一个 wait(S) 语句来实现。由于 S 初始化为 1,两个进程中只有一个能够获取 S 信号量。无论哪个进程碰巧先获取到它,它就会先运行。

我们需要添加的最后一段代码是,在两个进程代码的末尾,我们必须执行 signal(S)。如果我们不执行 signal(S),那么只有碰巧先获取到 S 信号量的进程能够运行,而另一个进程则会卡在等待 S 信号量的状态。

如果在进程结束时我们执行 signal(S),那么 S 会递增回 1,从而允许下一个进程执行。

请注意,因为这段代码没有循环,所以不用担心第一个进程再次获取 S 信号量。

以下是实现互斥的代码结构:

// 共享信号量(互斥锁)
semaphore S = 1;

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6004-ddca/img/c8f2ffd57812531a9101f24d242bc6d8_67.png)

// 进程 P1
wait(S);
A();
B();
signal(S);

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6004-ddca/img/c8f2ffd57812531a9101f24d242bc6d8_68.png)

// 进程 P2
wait(S);
C();
D();
signal(S);

场景三:确保两个进程的第一段代码先于第二段代码运行

最后,让我们考虑最后一组约束。

在这种情况下,我们希望确保进程 P1P2 的第一段代码都在进程 P1P2 的第二段代码之前运行。换句话说,A 必须在 BD 之前,C 也必须在 BD 之前。

A 必须在 B 之前,以及 C 必须在 D 之前的约束是默认满足的,因为代码总是按顺序执行。这意味着我们的约束简化为 AD 之前,以及 CB 之前。

为了实现这一点,我们需要使用两个信号量,比如 ST,并将它们初始化为 0

在一个进程的第一段代码完成后,它应该向另一个进程发出信号,表明如果另一个进程已经完成了其第一段代码,那么它可以开始其第二段代码。

为了确保另一个进程已经完成了其第一段代码,我们将 signal 调用放在每个进程的两段代码之间。

除了向另一个进程发出可以继续的信号外,每个进程还需要等待另一个进程正在发出信号的信号量。

这种 signalwait 的组合确保了代码的 AC 段将在 BD 段之前运行。

由于信号量初始化为 0wait(S)P1 调用 signal(S) 之前不会完成,而那时它已经完成了 A 段。

类似地,wait(T)P2 调用 signal(T) 之前不会完成,而那时它已经完成了 C 段。

因此,一旦进程能够通过它们的 wait 命令,我们就保证两个进程的第一段代码都已经运行了。

我们也没有通过要求 AC 之前运行或 CA 之前运行等来强加任何额外的约束。

当然,我们可以交换使用 ST 信号量,最终得到完全相同的行为。但请注意,我们不能交换 signalwait 命令的顺序。如果我们试图在 signal 之前调用 wait,那么两个进程都会陷入死锁,等待一个永远不会被发出信号的信号量。

这突出了一个事实:在使用信号量时,你必须始终非常小心,不仅要考虑满足所需的要求,还要确保没有可能陷入死锁情况,即一个或多个进程永远无法运行完成。

总结

本节课中,我们一起学习了如何使用信号量来协调并发进程的执行顺序。我们回顾了信号量的基本操作 waitsignal,并通过三个具体示例演示了如何应用它们来实现不同的先后顺序约束:确保一段代码在另一段之前执行、实现进程间的互斥访问,以及确保多个进程的第一阶段都在第二阶段之前完成。最重要的是,我们认识到在使用信号量时必须谨慎设计,以避免死锁的发生。

068:系统级接口

概述

在本节课中,我们将要学习计算机系统如何通过定义良好的接口将各种技术组件集成在一起。我们将探讨接口设计的重要性、历史经验教训,以及现代系统如何通过通用通信通道实现组件间的互联。

计算机系统汇集了许多技术,并利用它们来快速执行程序。其中一些技术相对较新,另一些则已伴随我们数十年。

每个系统组件都附带其功能和接口的详细规范。系统设计师的期望是,他们可以根据组件规范来设计系统,而无需了解每个组件的实现细节。这非常有益,因为许多底层技术会发生变化,通常这些变化使得组件变得更小、更快、更便宜、更节能等等。假设新组件仍然实现相同的接口,那么它们就可以几乎不费力地被集成到系统中。

这个故事的寓意是,系统架构中最重要的部分是接口。

我们的目标是设计能够经历多代技术变革而依然有效的接口规范。实现长期生存的一种方法是,将规范建立在一个有用的抽象之上,这个抽象隐藏了大部分(如果不是全部)底层实现细节。

例如,操作系统提供了许多多年来保持稳定的接口。网络接口可靠地将字节流传递到指定的目标,隐藏了数据包、套接字、错误检测和恢复等细节。或者,窗口和图形系统渲染复杂图像,使应用程序免受底层图形引擎细节的影响。又或者,日志文件系统在后台防止二级存储阵列的损坏。

基本上,我们早已过了每次集成电路专家能够将芯片上的晶体管数量翻倍、通信专家想出如何从1 GHz网络升级到10 GHz网络、或者内存专家能够将主存容量增加4倍时,就从头开始构建系统的阶段。

保护我们免受技术变革影响的接口对于确保技术进步不会成为持续性的破坏来源至关重要。

接口设计的经验与教训

上一节我们介绍了接口的重要性,本节中我们来看看一些历史上接口设计的经验教训。

有一些著名的例子表明,一个表面上看似方便的接口选择,却带来了令人尴尬的长期后果。例如,在独立计算时代,不同的指令集架构对于如何在主存中存储多字节数值做出了不同的选择。

IBM架构将最高有效字节存储在最低地址,即所谓的大端序。而Intel的X86架构则首先存储最低有效字节,即所谓的小端序。但在网络世界中,数值数据经常在系统间传输,这导致了各种复杂问题。这是一个局部最优选择对全局产生不幸影响的典型例子。正如俗话所说:一时的方便,一生的遗憾

另一个例子是第一台IBM PC(基于Intel CPU芯片的原始个人计算机)选择的系统级通信策略。IBM通过简单地使用当时X86 CPU提供的接口信号来构建其用于添加IO外设、内存卡等的扩展总线。因此,数据总线宽度、地址引脚数量、数据传输协议等,都是为该特定CPU的接口而设计的。这是一个合乎逻辑的选择,因为它完成了任务,同时将成本保持在尽可能低的水平。

但随着更新、性能更高的CPU被引入,能够寻址更多内存或提供32位而非16位外部数据通路,这个选择很快被证明是不幸的。系统架构师被迫向客户提供霍布森选择:要么为了向后兼容性而牺牲系统性能,要么丢弃去年购买的网络卡,因为它与今年的系统不兼容。

但也有成功的故事。IBM在20世纪60年代初选择的System/360接口延续到了70年代和80年代的System/370,以及90年代的Enterprise System Architecture/390。客户期望为早期机器编写的软件能在新系统上继续运行,而IBM能够满足这一期望。也许最引人注目的长期接口成功案例是70年代初设计的TCP和IP网络协议,它们构成了大多数基于数据包的网络通信的基础。最近的一次更新将网络地址从32位扩展到128位,但这对于使用网络的应用程序来说基本上是透明的。这是一套极具远见的工程选择,经受住了网络连接指数级增长超过四十年的考验。

现代系统组件互联

上一节我们回顾了接口设计的历史,本节中我们来看看现代系统组件互联的接口选择。

今天讲座的主题是找出连接系统组件的适当接口选择。在最早的系统中,这些连接是非常临时的,因为协议和物理实现是为每个必须建立的连接独立选择的。连接CPU机箱和内存机箱的电缆(是的,在那个年代它们分别放在不同的19英寸机架中)与连接CPU和磁盘的电缆是不同的。

改进的电路技术使系统组件从机柜大小缩小到电路板大小,系统工程师设计了一种模块化封装方案,允许用户混合搭配可插入通信背板的电路板类型。背板上的协议和信号反映了每个供应商做出的不同选择。IBM的电路板不能插入Digital Equipment的背板,反之亦然。

这演变成了一些标准化的通信背板,允许用户进行自己的系统集成,为CPU、内存、网络等选择不同的供应商。健康的竞争迅速压低了价格并推动了创新。

然而,这一有希望的发展被快速提高的性能需求所超越,这需要通信带宽,而多板背板根本无法支持这种带宽。对更高性能的需求以及将许多不同通信通道集成到单个芯片中的能力,导致了不同通道的激增。在许多方面,系统架构让人想起了原始系统:临时的、为特定任务专门构建的通信通道。

正如我们将看到的,工程上的考虑导致了通用单向点对点通信通道的广泛采用。根据所需的性能、传输距离等,仍然存在几种类型的通道,但异步点对点通道已经基本取代了早期系统的同步多信号通道。

大多数系统级通信都涉及通过电线进行信号传输。因此,接下来,我们将研究随着通信速度从千赫兹增加到千兆赫兹,我们必须处理的一些工程问题。

总结

本节课中我们一起学习了计算机系统架构中接口的核心作用。我们了解到,良好的接口设计能够抽象底层技术细节,确保系统的长期稳定性和兼容性。通过回顾大端序/小端序、IBM PC扩展总线等历史案例,我们认识到接口选择的长远影响。最后,我们探讨了现代系统如何从临时、专用的连接方式,发展到采用通用异步点对点通信通道来连接组件,以适应高性能计算的需求。理解这些接口原理是设计和集成复杂计算机系统的基础。

069:Wires(导线)🚀

在本节课中,我们将要学习数字系统中一个看似简单但至关重要的组成部分:导线。我们将探讨为什么在高速电路中,导线不能仅仅被视为理想的连接点,以及如何正确地建模和分析它们的行为,以确保系统可靠运行。


导线:不仅仅是连接点

构建一个通信通道有多难?它们不就是从一个组件连接到另一个组件的长导线构成的逻辑电路吗?

电路理论家会告诉你,原理图中的导线旨在表示电路的等电位节点,用于连接组件端子。在这个简单的模型中,导线上所有点的电压都相同,并且一个组件端子上的任何电压变化都会瞬间传播到连接在同一导线上的其他组件端子。

距离的概念在我们的电路模型中被抽象掉了。端子要么通过导线连接,要么没有。如果需要考虑电阻、电容和电感,可以在电路模型中添加必要的组件。导线本身是无时间概念的。它们用于显示组件如何连接,但它们本身不是组件。

事实上,当导线上的电压变化率与电磁波沿导线传播所需的时间相比很慢时,将导线视为等电位节点是一个非常可行的模型。


高速信号与传输线理论

随着集成电路技术的进步,电路速度不断提高,这个经验法则开始在逻辑电路中失效,因为组件之间的距离最多只有几十英寸。

实际上,自19世纪末以来,人们就知道电压电平的变化需要有限的时间才能沿导线传播。奥利弗·亥维赛是一位自学成才的英国电气工程师,他在19世纪80年代描述了一组电报方程,用于解释信号如何沿导线传播。利用这些方程,他能够将当时新的大西洋电报电缆的传输速率提高10倍。

我们现在知道,对于高速信号传输,我们必须将导线视为传输线,我们将在接下来的几张幻灯片中详细讨论。在这个领域,如果我们想正确预测电路的性能,组件之间的距离以及导线的长度就至关重要。距离和信号传播很重要。


真实导线的电气模型

现实世界中的导线实际上是相当复杂的组件。

上图是一个真实导线无限小段的电气模型。实际的导线可以通过想象许多个这样的模型首尾相连来正确建模。信号,即导线上的电压,是相对于参考节点测量的,参考节点也在模型中显示。

有四个参数表征导线的行为:

  • R 表示导体的电阻。对于印刷电路板上的布线,它通常可以忽略不计,但对于长导线和集成电路,它可能很重要。
  • L 表示导体的自感,它表征当流过导线的电流变化时,导线的磁场会吸收多少能量。
  • 导体和参考节点之间由某种绝缘体隔开,这可能只是空气,从而形成一个电容为 C 的电容器。
  • 最后,电导 G 表示通过绝缘体泄漏的电流,通常这个值非常小。

表格显示了我们在集成电路内部和印刷电路板上可能测量到的导线参数值。


传输线的特性

当我们分析沿导线发送信号时会发生什么,我们可以使用一个称为传输线的单一组件来描述导线的行为,该组件具有一个特征性的复值阻抗 Z

在高速信号频率下,以及在芯片或电路板上(如现代数字系统中可能找到的)的距离上,传输线是无损耗的,电压变化(阶跃)以 1 / sqrt(L*C) 米每秒的速率沿导线传播。

使用此处给出的印刷电路板数值,特征阻抗约为 50 欧姆,传播速度约为 18 厘米/纳秒7 英寸/纳秒

为了将数字信息从一个组件发送到另一个组件,我们改变连接导线上的电压,该电压阶跃从发送方传播到接收方。我们必须注意该能量前沿到达导线末端时会发生什么。如果我们不做任何吸收该能量的措施,守恒定律告诉我们,它会作为回波从导线末端反射回来,很快我们的导线上就会充满先前电压阶跃的回波。

为了防止这些回波,我们必须用一个与传输线特征阻抗匹配的接地电阻来端接导线。如果信号可以双向传播,我们将在两端都需要端接。

这个模型告诉我们的是,将信息从一个组件传输到另一个组件需要时间,并且我们必须小心地在信息到达目的地时吸收与传输相关的能量。


不良布线设计的后果

有了这些理论背景,我们现在可以描述信号导线设计不良所带来的现实后果。

关键观察是,除非我们小心,否则先前传输的能量可能残留下来,从而破坏当前的传输。解决这个问题的通用方法是时间,即给予传输值更长的时间来稳定到无干扰的值。但在高性能系统中,减慢速度通常不可接受,因此我们必须尽力最小化这些能量存储效应。

以下是几种常见问题:

  • 反射:如果端接不完全正确,任何到达导线末端的电压阶跃都会产生一些反射,这些回波需要一段时间才能消失。实际上,能量会从任何阻抗不连续处反射,这意味着我们希望尽量减少此类不连续点的数量。

  • 传播延迟与信号完整性:我们需要小心地留出足够的时间让信号达到有效的逻辑电平。下图中的阴影区域显示了导线A从1到0再到1的转换。第一个反相器试图从初始输入转换到0产生1输出,但在输入再次改变之前,没有足够的时间在Y或B上完成转换。这导致导线C(第二个反相器的输出)上出现一个窄脉冲。我们看到,当信号到达C时,A上的比特序列已经被破坏。这个问题是由反相器之间导线电容中的能量存储引起的,这将限制我们运行逻辑的速度。

  • 振铃:下图显示了当大的电压阶跃触发导线振荡(称为振铃)时会发生什么,这是由于导线的电感和电容造成的。图表显示,需要一些时间振铃才会衰减到我们拥有可靠数字信号的程度。可以通过将电压阶跃分散在更长的时间上来减小振铃。

这里的核心思想是,通过密切关注我们的布线设计和将信息放到导线上的驱动器,我们可以最小化这些能量存储效应对性能的影响。


从理论到系统设计

好了,电气工程知识就讲这么多。假设我们的系统中有一些信息。如果我们随着时间的推移保存该信息,我们称之为存储。如果我们将该信息发送到另一个组件,我们称之为通信

在现实世界中,我们已经看到通信需要时间,我们必须在系统时序中为此预算时间。我们的工程设计必须适应传播速度、组件之间的距离以及我们可以在不触发上一张幻灯片中看到的效果的情况下改变导线电压的速度的基本限制。

结果是,我们的时序模型必须考虑导线延迟

在本课程的第一部分,我们有一个简单的时序模型,为逻辑门输出反映门输入变化所需的时间分配了一个固定的传播延迟 TPD。我们需要改变我们的时序模型,以考虑将逻辑门输出传输到下一个组件的延迟。

时序将是负载相关的,因此连接到许多其他逻辑门输出的信号将比仅连接到一个其他门的信号更慢。Jde 模拟器在计算门的有效传播延迟时,会考虑门输出信号的负载。

我们可以通过减少输出信号上的负载数量,或者使用专门设计的称为缓冲器的门(图中红色显示的组件)来驱动具有非常大负载的信号,从而改善传播延迟。优化电路性能时的一项常见任务是追踪负载重(因此速度慢)的导线,并重新设计电路以使它们更快。

今天,我们关注的是用于在系统级别连接组件的导线。因此,接下来我们将把注意力转向系统级互连的可能设计以及可能出现的问题。


总结

本节课中,我们一起学习了导线在数字系统中的关键作用。我们了解到,在高速设计中,导线不能被视为理想的零延迟连接,而必须建模为具有电阻、电感和电容的传输线。信号沿导线传播需要时间,其速度由 1 / sqrt(L*C) 决定。不良的布线设计会导致反射传播延迟振铃等问题,从而破坏信号完整性。为了确保可靠通信,我们需要使用匹配电阻进行端接,并考虑负载相关的延迟。在系统设计中,优化导线性能(例如使用缓冲器)对于实现高性能至关重要。

070:总线

在本节课中,我们将要学习计算机系统中的总线概念。总线是连接系统内不同组件(如CPU、内存和输入/输出设备)的通信通道。我们将探讨总线的基本工作原理、其设计如何支持系统模块化,以及随着系统速度提升,总线设计所面临的挑战。

系统模块化与扩展性 🧩

上一节我们介绍了计算机系统的基本组成,本节中我们来看看如何设计系统以支持模块化和扩展性。

如果我们希望系统是模块化且可扩展的,其设计应如何适应用户未来可能想要添加的组件?

多年来,通用的方法是在承载CPU、内存和初始I/O组件集合的主板上,提供一种插入额外印刷电路板(即扩展卡)的方式。主板上的插槽将扩展卡上的电路连接到主板上的信号,这些信号允许CPU与扩展卡通信。

什么是总线? 🚌

上一节我们提到了主板上的信号集合,本节中我们正式介绍“总线”这一核心概念。

“总线”是系统架构领域的术语,指代一组使用预定通信协议传输数据的导线集合。

以下是构成系统总线的关键信号线:

  • 地址线:用于选择扩展卡上不同的通信端点(如内存位置、控制寄存器、诊断端口等)。
  • 数据线:用于在CPU和扩展卡之间双向传输数据。在旧系统中,会有多条数据线以支持字节或字宽度的数据传输。
  • 控制线:用于告知扩展卡特定传输何时开始,并允许扩展卡指示其何时已做出响应。

如果主板上有多个插槽用于插入多张扩展卡,相同的信号线可能会连接到所有卡上,此时地址线将用于区分哪些传输是针对哪张卡的。

总线事务如何工作? ⏱️

了解了总线的构成后,本节中我们来看看一次典型的总线事务是如何进行的。

时钟信号用于同步通信。信号在时钟的断言边沿被放置到总线导线上,并在时钟的采样边沿被接收方读取。时钟波形的时序设计旨在为信号沿总线传播并在所有接收器处达到有效逻辑电平留出足够时间。

以下是总线事务的基本步骤:

  1. 发起事务:发起事务的组件称为总线主设备,它拥有总线控制权。大多数总线提供一种机制,用于将所有权从一个组件转移到另一个。主设备设置总线线路以指示期望的操作(读、写、块传输等)、接收方的地址,以及在写操作中要发送给接收方的数据。
  2. 响应事务:预期的接收方称为从设备,它在每个采样边沿监视总线线路,寻找自己的地址。当从设备看到针对自己的事务时,它执行请求的操作,并使用总线信号指示操作何时完成。完成后,它可能使用数据线向主设备返回信息。
  3. 错误处理:总线本身可能包含电路,用于检测从设备未响应的事务,并在适当的间隔后生成错误响应,以便主设备可以采取相应措施。

总线设计的挑战与局限 🚧

上一节我们介绍了总线在低速下的工作方式,本节中我们来看看当系统速度提升时,总线设计会遇到哪些问题。

只要事务速率不太快(例如低于50 MHz),这种总线架构被证明是一种非常可行的、用于容纳扩展卡的设计。

但随着系统速度的提高,事务速率也必须提高以保持系统性能在可接受的水平。因此,每次事务的时间变得更短。随着总线导线上信号传输时间的减少,各种效应开始凸显。

以下是高速总线面临的主要问题:

  • 时序问题:如果时钟周期过短,可能没有足够的时间让主设备看到断言边沿、启用其驱动器、让信号沿长总线传播到目标接收器,并在采样边沿之前在所有接收器处稳定足够长的时间。
  • 时钟偏移:时钟信号在不同扩展卡上到达的时间不同。一个接收到较早时钟信号的卡可能认为轮到自己开始驱动总线信号了,而一个接收到较晚时钟信号的卡可能仍在驱动上一个周期的总线信号。驱动器之间的这些瞬时冲突会给系统增加巨大的电气噪声。
  • 信号反射:能量会在总线连接器造成的所有微小阻抗不连续点发生反射。如果连接器很多,就会产生许多小的回波,这些回波可能破坏各个接收器看到的信号。右上角的公式显示了在每个不连续点有多少信号能量被传输,有多少被反射。其净效应就像对着大峡谷快速喊话,除非在词语之间留出足够的时间让回波消失,否则回波可能会使信息失真到无法识别。

最终,总线被降级用于相对低速的通信任务,对于高速通信,必须开发不同的方法。

总结 📝

本节课中我们一起学习了计算机系统中的总线。我们了解到总线是一组用于数据传输的导线,它通过地址线、数据线和控制线,使CPU能够与扩展卡等组件通信。我们探讨了基于时钟同步的总线事务基本流程,涉及主设备和从设备的交互。最后,我们认识到随着系统速度的提升,总线在时序、时钟偏移和信号反射等方面面临根本性挑战,这导致了其在高速通信场景下的局限性。理解总线是理解计算机内部组件如何协同工作的基础。

071:6.2.4 点对点通信 📡

在本节课中,我们将学习计算机网络中一种关键的通信方式——点对点通信。我们将了解网络技术如何发展以连接远距离的组件,以及点对点链路如何通过解决共享通信介质的问题,成为现代高性能系统的基础。

概述

网络技术被开发出来以连接组件,在本例中,是连接相隔较远距离的独立计算机系统。这里的“较远距离”指的是以米而非厘米来衡量的距离。在这些更长的距离上进行通信,导致了不同的设计权衡。

早期网络与协议栈

上一节我们讨论了共享总线上的电气问题,本节中我们来看看网络技术是如何组织起来解决这些问题的。

在早期网络中,信息以比特序列的形式通过共享通信介质发送。比特被组织成数据包,每个数据包包含目的地址。数据包还包含用于检测传输错误的校验和,并且协议支持请求重传出错数据包的能力。

控制网络的软件被划分为一个模块栈,每个模块实现不同的通信抽象。

以下是网络协议栈的主要层次:

  • 物理层:这是最底层,负责发送和接收单个比特数据包。比特错误在此层被检测和纠正,无法纠正错误的数据包会被丢弃。针对不同类型的物理网络,有不同的物理层模块可用。
  • 网络层:该层处理数据包的寻址和路由。复杂的路由算法通过多跳网络找到最短的通信路径,并处理特定网络链路的瞬时或长期中断。
  • 传输层:该层负责提供可靠的数据流通信,处理数据包被丢弃或乱序的问题。为了优化网络使用并限制因网络拥塞导致的数据包丢失,传输层还处理流量控制,即控制数据包的发送速率。

网络领域的一个关键思想是:在“尽力而为”的数据包网络之上构建可靠的通信信道。协议的高层被设计成能够从低层的错误中恢复。事实证明,这比试图在每一层都实现100%的可靠性更具成本效益和鲁棒性。

点对点链路的优势

正如我们在上一节所见,当尝试在具有多个驱动器和接收器的共享导线上通信时,会存在许多电气问题。降低通信速率有助于解决问题,但“慢”不符合当今高性能系统的要求。

网络世界的经验表明,最快且问题最少的通信信道是单个驱动器与单个接收器进行通信,这被称为点对点链路

差分信号与时钟恢复

使用差分信号特别具有鲁棒性。在差分信号中,接收器测量两条信号线之间的电压差。可能在某条信号线上感应电压噪声的电气效应,会以相同的程度影响另一条线。因此,电压差在很大程度上不受大多数噪声的影响。几乎所有高性能通信链路都使用差分信号。

如果我们发送的是数字数据,这是否意味着我们还必须发送一个单独的时钟信号,以便接收器知道何时对信号进行采样以确定下一个比特?事实证明,通过一些巧妙的设计,我们可以从接收到的信号中恢复定时信息,前提是我们知道发射端的标称时钟周期。

如果发射器在自身时钟的上升沿改变发送的比特,那么接收器就可以利用接收波形中的跳变来推断部分时钟边沿的定时。然后,接收器可以利用对发射器标称时钟周期的了解,来推断其余时钟边沿的位置。这是通过使用锁相环来生成发射器时钟的本地副本,并利用任何接收到的跳变来校正本地时钟的相位和周期。

训练序列与编码

发射器在数据包前部添加一个比特训练序列,以确保在传输数据包数据本身之前,接收器的锁相环已正确同步。使用一个独特的比特序列来分隔训练信号和数据包数据,这样即使接收器在时钟正确同步过程中错过了几个训练比特,它也能准确知道数据包的起始位置。一旦接收器知道了时钟边沿的定时,它就可以在每个时钟周期接近结束时对输入波形进行采样,以确定发送的比特。

为了让本地时钟与发射器时钟保持同步,输入波形需要有足够频繁的跳变。但是,如果发射器发送的(比如说)全是零,我们如何保证足够频繁的时钟边沿呢?

IBM发明的技巧是:发射器将消息比特流重新编码成一个保证有跳变的比特流,无论消息比特是什么。最常用的编码是8B10B,其中8个消息比特被编码成10个发送比特,这种编码保证至少每6个比特时间就有一次跳变。当然,接收器必须反转8B10B编码以恢复实际的消息比特。这个技巧非常巧妙。

这样做的好处是,我们确实只需要发送单一的比特流。接收器将能够恢复定时信息和数据,而无需同时传输单独的时钟信号。

现代网络架构

利用这些经验教训,网络已经从使用共享通信信道发展到使用点对点链路。

如今,局域网使用10、100或1000兆比特的以太网布线,其中包含用于发送和接收的独立差分对。换句话说,每个发送或接收信道都是单向的,具有单个驱动器和单个接收器。网络使用独立的交换机和路由器从发送方接收数据包,然后通过点对点链路将数据包转发到下一个交换机,如此经过多个点对点链路,直到数据包到达其目的地。

系统级连接也已发展到使用相同的通信策略:点对点链路配合交换机,将数据包路由到其预定目的地。

请注意,沿每条链路的通信是独立的,因此具有许多链路的网络实际上可以支持大量的通信带宽。通过在交换机中进行少量的数据包缓冲来处理对特定链路的瞬时争用,这是一种非常有效的策略,用于将大量信息从一个组件移动到下一个组件。

总结

本节课中,我们一起学习了点对点通信的原理。我们回顾了网络协议栈的分层结构,探讨了点对点链路相较于共享介质的优势,深入了解了差分信号和时钟恢复技术(如锁相环和8B10B编码)如何实现高速可靠的单一比特流传输。最后,我们看到现代网络和系统级连接如何普遍采用点对点链路与交换机结合的架构,以实现高带宽和可靠的通信。在下一节中,我们将探讨一些更有趣的细节。

072:系统级互连技术

在本节课中,我们将要学习现代计算机系统中,组件之间如何进行高速、可靠的通信。我们将重点了解串行点对点链路如何取代传统的并行总线,并探讨其工作原理与优势。

串行点对点链路:并行总线的现代替代方案

上一节我们介绍了传统并行总线面临的挑战。本节中我们来看看它的现代替代方案:串行点对点链路。

串行点对点链路是现代计算机系统中,用于替代存在诸多电气和时序问题的并行通信总线的技术。

每条链路都是单向的,并且只有一个驱动器。

接收器从数据流中恢复时钟信号。

因此,不存在信道共享、时钟偏移和电气问题带来的复杂性。

这种高度受控的电气环境使得信号传输速率可以达到非常高的水平,利用当今技术,速率可达千兆赫兹范围。

如果需要更高的吞吐量,可以并行使用多条串行链路。需要额外的逻辑来从通过多条链路并行发送的多个数据包中重新组装原始数据。

但在当前技术下,所需逻辑门的成本非常低廉。

需要注意的是,现代系统的扩展策略仍然使用插入主板的扩展卡概念。

但扩展卡不是连接到并行总线,而是连接到一个或多个点对点通信链路。

现代系统通信架构示例:基于Intel Core i7的系统

以下是基于英特尔酷睿i7 CPU芯片的近期系统的系统级通信示意图。

CPU直接连接到内存,以实现最高的内存带宽。

但通过快速通道互连(QPI)与所有其他组件通信。

QPI在每个方向上有20条差分信号路径,每秒每个方向支持高达64亿次20位传输。

所有其他通信通道,如USB、PCI Express、网络、串行ATA、音频等,也都是串行链路,根据应用需求提供不同的通信带宽。

PCI Express:主板组件间的通信链路

PCI Express常被用作系统主板上组件之间的通信链路。

单个PCIe 2.0通道使用低电压差分信号,在特性阻抗为100欧姆的线路上,以每秒5千兆比特的速率传输数据。

PCIe通道受控于与之前描述的网络协议栈类似的协议栈。

以下是PCIe协议栈各层的主要功能:

  • 物理层:通过线路传输数据包。每个数据包以一个训练序列开始,用于同步接收器的时钟恢复电路,随后是一个唯一的起始序列,然后是数据包的有效载荷,最后以一个唯一的结束序列结尾。
  • 数据链路层:物理层有效载荷被组织为序列号、事务层有效载荷和用于验证数据的循环冗余校验序列。利用序列号,数据链路层可以判断数据包是否丢失,并请求发送方从丢失的数据包开始重新传输。它还处理流量控制问题。
  • 事务层:从所有通道的事务层有效载荷中重新组装消息,并使用消息头来识别接收端的预期接收者。

总的来说,在多个PCIe通道上发送和接收消息需要大量的逻辑电路。

但在使用当今集成电路技术时,其成本是完全可以接受的。

使用八条通道时,最大传输速率可达每秒4千兆字节,能够满足高性能外设(如图形卡)的需求。

总结与展望

本节课中我们一起学习了网络领域的知识如何重塑了主板上组件间的通信方式,推动了从并行总线到少数串行点对点链路的转变。

因此,当今的系统比以往更快、更可靠、更节能、体积也更小。

073:6.4 通信拓扑结构 🧠

在本节课中,我们将学习如何为需要相互通信的终端组件(例如多核芯片上的CPU)设计最佳的系统级互连网络。我们将分析不同网络拓扑结构在吞吐量、延迟和硬件成本方面的表现。


上一节我们讨论了系统级互连的基础,本节中我们来看看几种具体的通信网络拓扑结构。我们将使用点对点链路构建网络,并假设每条链路的硬件成本为1个单位,传输一条消息需要1个单位时间,且不同链路可以并行工作。

总线拓扑

总线是我们讨论的基线方案,所有组件共享一条通信通道。

  • 吞吐量:由于只有单一通道,吞吐量为 1条消息/单位时间
  • 延迟:任意两个组件间传递消息需要 1个单位时间
  • 硬件成本:每个组件都需要一个到共享通道的接口,总成本为 O(N)

环形拓扑

在环形网络中,每个组件只向其一个邻居发送消息,链路首尾相连构成环。

  • 总链路数N 条。
  • 吞吐量与成本:均为 O(N)
  • 最坏情况延迟:消息可能需要穿越 N-1 条链路才能到达紧邻的上游邻居,因此延迟为 O(N)

环形拓扑适用于延迟不重要,或大多数消息都发送给紧邻下游组件(即组件构成处理流水线)的场景。

全连接拓扑

最通用的网络拓扑是每个组件都与其他所有组件有直接链路。

  • 总链路数O(N²) 条。
  • 吞吐量与成本:均为 O(N²)
  • 延迟:每个目的地都可直接访问,延迟为 1个单位时间

尽管成本高昂,但全连接图能提供极高的吞吐量和极低的延迟。

交叉开关拓扑

交叉开关是全连接图的一个变体,特定的行和列可以连接起来,在特定的A、B组件间形成链路,但限制是每行每列在每个时间单位只能传输一条消息。

假设第一行和第一列连接到同一个组件,以此类推。也就是说,示例中的交叉开关用于连接四个组件。

  • 每单位时间传递的消息数O(N) 条。
  • 延迟1个单位时间
  • 成本:交叉开关中有 个开关,因此成本为 O(N²),尽管实际链路只有 O(N) 条。

网格拓扑

在网格网络中,组件连接到固定数量的相邻组件,形成二维或三维阵列。

  • 总链路数:与组件数量成正比,为 O(N)
  • 吞吐量与成本:均为 O(N)
  • 最坏情况延迟:与网格边长成正比。对于二维网格,延迟为 O(√N);对于三维网格,延迟为 O(³√N)

有序的布局、恒定的单节点硬件成本以及适中的最坏情况延迟,使得二维四邻接网格成为当前实验性多核处理器的热门选择。

超立方体与树形拓扑

超立方体和树形网络提供了对数级延迟 O(log N),对于大规模N值,这可能比网格网络更快。

以下是各种拓扑结构的理论延迟总结:

作为一个现实检验,必须认识到在我们这个三维世界中,组件间最坏情况距离的理论下限是 O(³√N)。在二维布局中,最坏情况距离是 O(√N)。由于我们知道消息传输时间与传输距离成正比,因此应该修改我们的延迟计算以反映这一物理约束。

注意,总线和交叉开关涉及N个连接到单一链路的连接。因此,这里的延迟下限需要反映每个连接增加的电容负载。

胜出者:网格网络避免了随着连接组件数量增长而需要更长导线的问题,对于连接数千个处理器的高容量通信网络来说,似乎是一个有吸引力的替代方案。


总结

本节课中我们一起学习了系统级互连的各种通信拓扑结构。

总结我们的讨论:点对点链路如今已普遍用于系统级互连,因此我们的系统比以往更快、更可靠、更节能、更小巧。多信号并行总线仍用于与存储器之间的极高带宽连接,并通过大量精心的工程设计来避免早期总线实现中出现的问题。无线连接普遍用于将移动设备连接到附近的组件,并且在如何让移动设备发现附近的外设并自动连接方面,已有许多有趣的研究工作。

即将到来的多核芯片时代将拥有数十到数百个处理核心。目前有大量正在进行的研究,旨在确定哪种通信拓扑结构能提供高通信带宽和低延迟的最佳组合。未来十年,对于片上网络工程师来说,将是一个激动人心的时期。

074:指令级并行性 🚀

在本节课中,我们将要学习如何通过指令级并行性来提升程序的运行速度。我们将探讨程序运行时间的构成,分析流水线设计的限制,并了解现代处理器如何通过更深的流水线、更宽的流水线以及乱序执行等技术来挖掘指令间的并行性,从而提升性能。


现代世界对计算能力有着永不满足的需求,因此系统架构师一直在思考如何让程序运行得更快。

程序的运行时间是三个项的乘积:程序中的指令数,乘以执行每条指令所需的平均处理器周期数(CPI),再乘以每个处理器周期所需的时间(T_clock)。为了减少运行时间,我们需要减少其中一个或多个项。

程序中的指令数由指令集架构和编译器决定。这两者都可以优化,但在本次讨论中,我们将重点放在如何减少另外两项上。

流水线与时钟周期 ⏱️

正如我们所见,流水线通过将指令执行划分为一系列步骤来减少 T_clock,每个步骤可以在更短的 T_clock 内完成其任务。

上一节我们介绍了流水线如何减少时钟周期,本节中我们来看看如何减少 CPI。

理解 CPI 与流水线停顿 🛑

在我们 Beta 处理器的五级流水线实现中,我们设计的硬件目标是每个时钟周期完成一条指令的执行,因此理想的 CPI 是 1。但有时,如果所需操作尚未完成,硬件必须向流水线中引入停顿气泡来延迟流水线阶段的执行。

以下是导致停顿的几种情况:

  • 执行分支指令时。
  • 试图立即使用由加载指令从内存加载的值时。
  • 等待缓存未命中从主内存得到满足时。

CPI_stall 代表了因流水线中引入停顿而损失的周期数。它的值取决于分支执行频率和立即使用加载结果的频率。通常,它是一个小于 1 的分数。例如,如果一个包含加载指令的六指令循环需要 8 个周期完成,则该循环的 CPI_stall 为 2/6,换句话说,每六条指令额外需要两个周期。

我们经典的五级流水线是一个有效的折衷方案,它允许 T_clock 大幅降低,同时将 CPI_stall 保持在合理的较低水平。

提升性能的潜力 💡

仍有改进空间,因为每个流水线阶段一次只处理一条指令,理想的 CPI 是 1。

缓慢的操作,例如在 ALU 阶段完成乘法,或在 IF 或 MEM 阶段访问大型缓存,会迫使 T_clock 变大,以适应一个周期内必须完成的所有工作。

流水线中指令的顺序是固定的。例如,如果一条加载指令因缓存未命中而在 MEM 阶段被延迟,那么所有处于更早阶段的指令也会被延迟,即使它们的执行可能不依赖于该加载指令产生的值。

流水线中指令的顺序始终反映了 IF 阶段获取它们的顺序。让我们探讨一下,如果放宽这些限制,需要什么条件,并有望改善程序运行时间。

增加流水线深度 📏

增加流水线阶段的数量应该能让我们减少时钟周期时间。

我们通过增加阶段来打破性能瓶颈。例如,增加额外的流水线阶段 MEM1 和 MEM2,以便为内存操作完成提供更长的时间。

但这会以增加 CPI_stall 为代价,因为每个额外的 MEM 阶段意味着在存在数据冒险时需要引入更多的停顿气泡。更深的流水线意味着处理器将并行执行更多指令。

在继续列举性能优化清单之前,让我们先思考一下流水线深度的限制。

每个额外的流水线阶段都会给时间预算带来一些额外的开销成本。

我们必须考虑流水线寄存器的传播、建立和保持时间,并且通常需要留出一点额外时间来应对时钟偏移,即时钟边沿到达每个寄存器的时间差。最后,由于我们无法总是将工作完全均匀地分配到各个流水线阶段,工作量较少的阶段将会有一些时间被浪费。

我们将所有这些影响汇总为每个阶段的额外时间开销 O。如果原始时钟周期为 T,那么对于 n 级流水线,时钟周期将为 T/n + O

在极限情况下,当 n 变得很大时,加速比趋近于 T/O。换句话说,随着每个阶段用于工作的时间变得越来越小,开销开始占主导地位。

在某个点上,增加额外的流水线阶段对时钟周期几乎没有影响。

作为一个数据点,英特尔酷睿 2 X86 芯片(代号 Nahalem)拥有一个 14 级的执行流水线。

增加流水线宽度与乱序执行 🔄

好的,回到我们的性能优化清单。有时,我们可以安排并行执行两条或更多指令,前提是它们的执行彼此独立。这将提高理想的 CPI,代价是增加每个流水线阶段的复杂性以处理多条指令的并发执行。

如果流水线中的一条指令因数据冒险而停顿,可能仍有后续指令可以继续执行。允许指令在流水线中相互超越被称为乱序执行。我们必须小心确保改变执行顺序不会影响程序产生的值。

更多的流水线阶段和更宽的流水线阶段增加了控制冒险时需要丢弃的工作量,可能会增加 CPI_stall。因此,通过预测分支结果(即执行或不执行)来最小化控制冒险的数量非常重要,这样我们就能增加流水线中的指令是我们想要执行的可能性。

我们利用更宽的流水线和乱序执行的能力,取决于能否找到可以并行或以不同顺序执行的指令。

这些特性统称为指令级并行性

指令级并行性示例 🔍

这里有一个例子,让我们可以探索可能存在的指令级并行性数量。左边是一个未优化的循环,用于计算前 n 个整数的乘积。右边,我们重写了代码,将可以并发执行的指令放在同一行。

首先注意 BF 指令后的红线。红线以下的指令只有在 BF 未执行时才应执行。这并不意味着我们不能在分支结果已知之前就开始执行它们,但如果我们在分支之前执行它们,则必须准备好在分支执行时丢弃它们的结果。

可能的执行顺序受到红色箭头所示的写后读依赖关系的约束。我们认识到这些是潜在的数据冒险,当一条指令的操作数值依赖于前一条指令的结果时就会发生。

在我们的五级流水线中,我们能够通过旁路将 ALU、MEM 和写回阶段的值传回 RF 阶段(操作数在此确定)来解决许多此类冒险。

当然,旁路只有在指令已执行、其结果可用于旁路时才有效。因此,在这种情况下,箭头向我们展示了保证旁路可行的执行顺序约束。

执行顺序还有其他约束。绿色箭头标识了两条具有相同目标寄存器的指令之间的写后写约束。

为了确保循环结束时 R2 中的值是正确的,LD(R31, R2) 指令必须在 CMPLT 指令的结果存入寄存器文件之后,才能将其结果存入寄存器文件。

类似地,蓝色箭头显示了一个写后读约束,确保在访问寄存器时使用正确的值。

在这种情况下,LD(R31, R2) 必须在 BF 的 RA 操作数从 R2 读取之后,才能存入 R2。

事实证明,如果我们为每条指令的结果分配一个唯一的寄存器名,就可以消除写后写和写后读约束。硬件通过使用大量的临时寄存器可以相对容易地做到这一点,但我们不会在这里深入讨论重命名的细节。

使用临时寄存器也使得在知道分支结果之前,可以轻松丢弃已执行指令的结果。

在这个例子中,我们发现对于 BF 之后的指令,潜在的并发性实际上相当好。

利用并行性:超标量处理器 🚀

为了利用这种潜在的并发性,我们需要修改流水线,使其能够并行执行一定数量(n)的指令。

如果我们能维持这种执行速率,那么理想的 CPI 将是 1/n,因为当指令退出最后的流水线阶段时,我们将在每个时钟周期完成 n 条指令的执行。

那么我们应该为 n 选择什么值呢?由不同 ALU 硬件执行的指令很容易并行执行,例如加法和移位,或整数和浮点运算。当然,如果我们提供多个加法器,就可以并发执行多个整数算术指令。

为地址算术提供单独的硬件(称为加载/存储单元)将支持加载/存储指令与整数算术指令的并发执行。

杜克大学的这组讲义很好地概述了在每个流水线阶段用于支持并发执行的技术。基本上,通过增加 ALU 中功能单元的数量,以及寄存器文件和主内存上的内存端口数量,我们就具备了支持多条指令并发执行的条件。

因此,需要在增加的电路成本和增加的并发性之间做出权衡。

作为一个数据点,英特尔 Nahalem 核心每个周期最多可以完成四个微操作,其中每个微操作对应我们的一条简单 RISC 指令。

现代乱序超标量处理器架构 🏗️

这是一个简化的现代乱序超标量处理器示意图。

指令获取和解码阶段一次处理多条指令。维持这种执行速率的能力在很大程度上取决于预测分支指令结果的能力,以确保宽流水线中大部分填充的是我们实际想要执行的指令。

良好的分支预测需要使用先前分支的历史记录,并且人们投入了大量巧思,试图用最少的硬件获得良好的预测。如果你对细节感兴趣,可以在维基百科上搜索“分支预测器”。

寄存器重命名发生在指令解码期间,之后指令就可以被分派到功能单元。如果一条指令需要前一条指令的结果作为操作数,分派器已经识别出哪个功能单元将产生该结果。该指令在队列中等待,直到指定的功能单元产生结果,当所有操作数值都已知时,该指令最终从队列中取出并执行。

由于指令在其操作数可用时由不同的功能单元执行,因此执行顺序可能与原始程序中的顺序不同。执行后,功能单元广播它们的结果,以便等待的指令知道何时可以继续。结果也被收集在一个大的重排序缓冲区中,以便它们可以退休,换句话说,以正确的顺序将其结果写入寄存器文件。

需要大量电路来确保功能单元有指令可执行,知道指令何时拥有所有操作数,并将执行结果组织成正确的顺序。

性能提升与未来展望 📈

那么,我们应该期望从所有这些机制中获得多少加速呢?CPI 的影响非常依赖于具体程序,因为它取决于缓存命中率、成功的分支预测、可用的指令级并行性等。

鉴于这里描述的架构,我们能期望的最佳加速是四倍。搜索一下似乎现实情况是平均加速两倍,可能略低于两倍,相对于顺序单发射处理器所能达到的性能。

对于乱序超标量流水线未来的性能改进,我们能期待什么?

流水线深度的增加会导致 CPI_stall 和时序开销上升。在当前流水线深度下,CPI_stall 的增加超过了 T_clock 减少带来的收益,因此进一步增加深度不太可能。

在使用更多乱序执行来增加 ILP 与因分支预测错误影响和内存速度无法更快运行而导致的 CPI_stall 增加之间,也存在类似的权衡。功耗的增加速度超过了降低 T_clock 和增加乱序执行逻辑所带来的性能增益。实现分支预测和并发执行进一步改进所需的额外复杂性似乎非常艰巨。

所有这些因素都表明,未来乱序超标量流水线处理器的性能不太可能出现实质性的重大改进。

因此,系统架构师已将注意力转向利用数据级并行性线程级并行性,这将是我们接下来的两个主题。


本节课中,我们一起学习了如何通过指令级并行性来优化处理器性能。我们分析了程序运行时间的构成,探讨了通过增加流水线深度和宽度、引入乱序执行来提升性能的方法,并了解了现代超标量处理器的基本架构。最后,我们也看到了这些技术在性能提升上遇到的瓶颈,从而引出了数据级并行和线程级并行等更高级的优化方向。

075:数据级并行性 🚀

在本节课中,我们将要学习数据级并行性。这是一种通过设计专门的硬件,对多个数据元素同时执行相同操作,从而显著提升计算性能的技术。我们将探讨其工作原理、优势,以及它在现代处理器(如向量处理器和GPU)中的应用。


数据级并行性的概念 💡

在某些应用中,数据天然以向量或矩阵的形式存在。例如,表示音频波形的数字化采样向量,或来自摄像头的二维图像中的像素颜色矩阵。

处理这类数据时,通常需要对每个数据元素执行相同的操作序列。以下示例代码计算向量和,其中一个向量的每个分量与另一个向量的对应分量相加。

for (int i = 0; i < N; i++) {
    C[i] = A[i] + B[i];
}

向量处理器设计 🛠️

通过复制CPU的数据通路部分,我们可以设计出能够在多个数据元素上并行执行相同操作的专用向量处理器

如上图所示,寄存器文件和算术逻辑单元(ALU)被复制,来自当前指令解码的控制信号由所有数据通路共享。

数据存取机制 📥

数据以大块形式获取,类似于获取缓存行。数据块中的每个字被加载到每个数据通路的指定寄存器中。

类似地,每个数据通路可以贡献一个字,以连续块的形式存储到主存中。

在这类机器中,进出主存的数据总线宽度为多个字宽,因此一次内存访问可以并行地为所有数据通路提供数据。

性能优势 ⚡

在拥有N条数据通路的机器上执行一条指令,相当于在只有单条数据通路的传统机器上执行N条指令。这实现了高度的并行性,而无需乱序执行或超标量执行等复杂机制。

假设我们有一个拥有16条数据通路的向量处理器,让我们比较它在向量求和操作上与传统的流水线Beta处理器的性能。

以下是精心组织的Beta代码,以避免执行过程中的任何数据冒险。

LOOP:
    LD(R1, 0, R2)    ; Load A[i]
    LD(R1, 4, R3)    ; Load B[i]
    ADD(R2, R3, R4)  ; C[i] = A[i] + B[i]
    ST(R4, 8, R1)    ; Store C[i]
    ADDC(R1, 12, R1) ; i++
    CMPLT(R1, R5, R6); Check loop condition
    BNE(R6, LOOP)    ; Branch if not done

循环包含9条指令,如果计入循环末尾BNE指令引起的流水线停顿(no-op),需要10个周期执行。假设没有缓存未命中导致的额外周期,求和所有16个元素需要160个周期。

而向量处理器的对应代码如下(假设向量大小为16个元素):

VLD(V0, R1)      ; Load vector A into V0
VLD(V1, R1+64)   ; Load vector B into V1
VADD(V0, V1, V2) ; Vector add: V2 = V0 + V1
VST(V2, R1+128)  ; Store result vector C

向量处理器完成所需操作仅需4个周期,速度提升了40倍。这个例子展示了最佳可能的速度提升。良好加速的关键在于我们能够向量化代码,并利用所有并行操作的数据通路。

条件操作的并行化 🔄

你可能会想,向量处理器能否高效执行数据相关的操作?数据相关操作在传统机器上表现为条件语句,即如果条件为真则执行语句体。

如果测试和分支由单一指令执行引擎控制,我们如何利用并行数据通路?技巧在于为每个数据通路提供一个本地谓词标志

使用向量化的比较指令并行执行所有 A[i] < B[i] 的比较,并将结果记录在每个数据通路的本地谓词标志中。

然后,扩展向量指令集架构以包含谓词化指令,这些指令检查本地谓词标志以决定是执行还是什么都不做。

在这个例子中,ADD.V.P 指令仅在本地谓词标志为真时,才对本地数据执行加法操作。指令谓词化也用于许多非向量架构,以避免因条件分支预测错误而导致的执行时间损失。

它们对于简单的算术和布尔操作(即非常短的指令序列)特别有用,这些操作仅在满足条件时才应执行。X86指令集架构包含条件移动指令。在32位ARM指令集架构中,几乎所有指令都可以有条件地执行。

现代CPU中的向量扩展 🖥️

向量处理器的强大之处在于,一条指令可以启动对N对操作数进行N次并行操作。

大多数现代CPU都集成了向量扩展,可以对8、16、32或64位的操作数进行并行操作,这些操作数被组织成128、256或512位的数据块。

通常,只需要在原本设计用于处理全宽度操作数的ALU上增加一些简单的附加逻辑即可。并行性被内置于向量程序中,而不是由指令分发和执行机制动态发现。

GPU:大规模数据并行性的典范 🎮

也许拥有许多并行操作数据通路的架构的最佳例子是图形处理单元,它几乎存在于所有计算机图形系统中。

GPU数据通路通常专用于实时显示3D场景(表示为数十亿个三角形面片)到计算机屏幕2D图像所需算法中的32位和64位浮点运算。坐标变换、像素着色、抗锯齿、纹理映射等,都是易并行计算的例子,其并行性来自于必须对数百万个不同的数据对象独立执行相同的计算。

类似的问题也存在于生物信息学、大数据处理、深度学习中的神经网络仿真等领域。如今,GPU越来越多地用于许多有趣的科学和工程计算,而不仅仅是作为图形引擎。


总结 📚

本节课中,我们一起学习了数据级并行性。我们了解到,通过设计向量处理器和GPU等架构,可以对大量数据元素同时执行相同操作,从而获得显著的性能提升。关键在于能够将代码向量化,并利用硬件的并行数据通路。这种技术对于处理图像、音频、视频以及各种科学计算任务至关重要。因此,当前和未来的指令集架构几乎都必然包含对向量操作的支持。

076:6.4 线程级并行 🧵

在本节课中,我们将要学习线程级并行(TLP)的概念。我们将探讨如何通过使用多个更小、更高效的核心来提升计算性能,并理解其背后的理论限制和实际应用场景。

概述

在讨论乱序超标量流水线CPU时,我们曾指出其成本相对于性能提升增长得非常快,形成了下图所示的成本-性能曲线。

沿着这条曲线向下移动,我们可以找到更高效的架构,例如,以四分之一的成本获得一半的性能。当我们的应用程序包含可以并行执行的独立计算时,我们或许能够使用两个核心来提供与原来昂贵核心相同的性能,但成本却低得多。如果可用的并行性允许我们使用更多核心,我们将看到性能提升与成本增加之间呈线性关系。当然,关键在于所需计算能够被划分为多个任务,这些任务可以独立运行,几乎或完全不需要任务间的通信或协调。

核心成本与数量的权衡

核心成本与核心数量之间的最佳权衡点是什么?

如果我们的计算可以任意划分而不产生额外开销,那么我们将继续沿着曲线向下移动,直到找到以最低成本提供所需性能的成本-性能点。

实际上,将计算分配到多个核心确实会涉及一些开销。例如,分发数据和代码,然后收集和汇总结果。因此,找到最佳权衡点更为困难。尽管如此,使用大量更小、更高效核心的想法似乎很有吸引力。许多应用程序既包含可以并行执行的计算,也包含无法从并行中受益的计算。

阿姆达尔定律

为了理解我们可能从并行化中获得的加速,计算机科学家吉恩·阿姆达尔在1967年提出的计算模型非常有用,现在被称为阿姆达尔定律。

假设我们正在考虑一项增强措施,它可以将手头任务的某个比例 F 加速 S 倍。

如图所示,任务的灰色部分现在所需的时间是原来的 F / S

通过一些简单的算术,我们可以计算出使用该增强措施后获得的总体加速。

我们可以得出的一个结论是,对所需计算中占比较大部分进行加速的增强措施将带来最大的收益。换句话说,我们希望使 F 尽可能大。

如果我们有许多核心可以用来加速任务的并行部分,我们能期望的最佳加速是多少?

以下是基于 FS 的加速公式,其中 F 是任务的并行部分比例。

如果我们假设任务的并行部分可以通过使用越来越多的核心而无限加速,我们会发现可能的最佳加速是 1 / (1 - F)

例如,你编写了一个程序,其90%的工作可以并行完成,但另外10%必须顺序执行。那么,无论你拥有多少核心,可以实现的最佳总体加速是10倍。

反过来思考这个问题,假设你有一台1000核心的机器,你希望用它来在目标应用上实现500倍的加速。为了达到这个目标,你需要能够将99.8%的计算并行化。

显然,当目标任务具有大量天然的并行性时,多核机器最为有用。

线程级并行架构

使用多个独立的核心来执行并行任务被称为线程级并行,其中每个核心执行一个单独的计算线程。线程是独立的程序,因此其执行模型可能比向量机提供的锁步执行更为灵活。

当线程数量较少时,你通常会看到核心共享一个公共的主存储器,允许线程通过共享公共地址空间进行通信和同步。我们将在下一节进一步讨论这一点。这是当前多核处理器(通常有2到12个核心)所采用的方法。

当核心数量达到数十或数百个时,共享内存会成为一个真正的瓶颈,因为它们会迅速耗尽可用的内存带宽。在这些架构中,线程使用通信网络来回传递消息进行通信。我们在之前的讲座中讨论过可能的网络拓扑结构。

一种经济高效的片上方法是使用最近邻网状网络,它支持许多并行的点对点通信,同时仍然允许任意两个核心之间的多跳通信。

消息传递也用于计算集群中,许多普通CPU协作处理大型任务。有标准化的消息传递接口(MPI),以及专门的、具有极高吞吐量和低延迟的消息传递通信网络(例如InfiniBand),使得构建高性能计算集群变得容易。

在接下来的几节中,我们将更仔细地研究构建共享内存多核处理器所涉及的一些问题。

总结

本节课中,我们一起学习了线程级并行的基本原理。我们探讨了通过多核设计提升性能与成本效益的权衡,理解了阿姆达尔定律对并行加速的理论限制,并介绍了共享内存与消息传递这两种主要的线程级并行架构。核心要点是,并行化的有效性高度依赖于任务本身可被并行化的比例,而架构的选择则取决于核心数量和应用对通信的需求。

077:共享内存与缓存 🧠

在本节课中,我们将学习多核处理器中共享内存与缓存的基本概念。我们将探讨缓存如何提升性能,以及它们如何导致内存一致性问题。最后,我们将介绍顺序一致性和弱一致性这两种内存语义模型,并讨论它们对程序员的意义。

多核处理器概念示意图

下图展示了一个多核处理器的概念示意图。

为了减少平均内存访问时间,四个核心中的每一个都拥有自己的缓存。大多数内存请求将由这些缓存来满足。如果发生缓存未命中,请求会被发送到共享主存。在核心数量适中且缓存命中率良好的情况下,正常操作期间必须访问主存的内存请求数量应该非常少。

为了将内存请求数量降至最低,缓存采用了写回策略。在这种策略中,存储指令会更新缓存,但主存只有在脏缓存行被替换时才会被更新。

我们的目标是每个核心都应该共享主存的内容。换句话说,任何一个核心所做的更改都应该对所有其他核心可见。

一个共享内存的例子

在下面这个例子中,核心0运行线程A,核心1运行线程B。两个线程都引用了两个共享内存位置,分别存放变量X和Y的值。X和Y的当前值分别是1和2。这些值既保存在主存中,也被每个核心缓存。

当线程被执行时会发生什么?每个线程独立执行,在存储X和Y时更新自己的缓存。

对于任何可能的执行顺序,无论是并发还是顺序执行,结果都是相同的:线程A打印2,线程B打印1。硬件工程师可能会指出这种一致的结果并宣布成功。

但是,仔细检查最终的系统状态会发现一些问题。执行完成后,两个核心对X和Y的值存在分歧。在核心0上运行的线程会看到 X=3Y=2,而在核心1上运行的线程会看到 X=1Y=4

由于缓存的存在,系统的行为并不像存在一个单一的共享内存。另一方面,我们不能消除缓存,因为那将导致平均内存访问时间急剧上升,从而抵消使用多核带来的预期性能提升。

我们期望什么结果?

一个合理的正确性标准是线程在单个分时共享核心上运行时的结果。其论点是,多核实现应该产生相同的结果,但通过并行执行取代分时共享来更快地完成。

下表显示了分时共享实验的可能结果,其结果取决于语句的执行顺序。程序员会理解存在多种可能的结果,并且知道如果他们想要一个特定的结果,就必须对执行顺序施加额外的约束,例如使用信号量。

请注意,多核执行的结果 (2, 1) 并没有出现在顺序分时共享执行的可能结果列表中。

顺序一致性

并行执行N个线程应该对应于这些线程在单个核心上的某种交错执行,这一概念被称为顺序一致性。如果多核系统实现了顺序一致性,那么程序员就可以将系统视为提供了硬件加速的分时共享。

因此,我们简单的多核系统在两个方面失败了。首先,它没有正确实现共享内存,因为正如我们所看到的,两个核心可能对一个共享变量的当前值存在分歧。其次,作为第一个问题的结果,该系统没有实现顺序一致性。显然,我们需要找到一个解决方案。

一个可能的解决方案:弱一致性

一个可能的解决方案是放弃顺序一致性。另一种内存语义是弱一致性,它只要求每个线程的内存操作看起来是按照该线程发出的顺序执行的。

换句话说,在一个弱一致性系统中,如果一个特定的线程先写X然后写Y,那么任何线程读取X和Y的可能结果将是:(X未变, Y未变),或(X已变, Y未变),或(X已变, Y已变)。但不会有线程看到Y已变而X未变的情况。

在弱一致性系统中,来自其他线程的内存操作可能以任意方式重叠,不一定与任何顺序交错一致。

请注意,我们的多核缓存本身甚至不能保证弱一致性。一个执行 写X写Y 的线程会更新其本地缓存,但随后的缓存替换可能导致更新后的Y值在更新后的X值之前被写入主存。

为了实现弱一致性,线程应该被修改为:写X,将更改通知所有其他处理器,然后写Y。在下一节中,我们将讨论如何修改缓存以自动执行所需的通信。

乱序执行核心带来了额外的复杂性,因为无法保证连续的存储指令会按照它们在程序中出现的顺序完成。这些架构提供了一种屏障指令,可以保证屏障之前的内存操作在屏障之后的内存操作执行之前完成。

内存一致性的多样性

存在许多类型的内存一致性。每个市售的多核系统都有其特定的关于何时发生何种情况的保证。因此,谨慎的程序员需要仔细阅读架构手册,以确保她的程序会按照她的意图执行。

关于内存语义和多核系统的非常易读的讨论,请参阅参考PDF文件。

总结

本节课中,我们一起学习了多核处理器中共享内存与缓存的基础知识。我们了解到,虽然缓存对于提升性能至关重要,但它们也引入了内存一致性问题。我们探讨了顺序一致性的理想模型,以及现实中更常见的弱一致性模型。理解这些内存语义对于在多核系统上编写正确的并发程序至关重要。

078:缓存一致性 🧠

在本节课中,我们将要学习多核系统中的缓存一致性问题。我们将探讨当多个处理器共享数据时,如何确保它们看到的数据是最新的,并介绍一种名为MESI的缓存一致性协议来解决此问题。

概述

我们的简单多核系统存在一个问题:当一个共享变量的值被更改时,系统内部没有通信机制来通知其他处理器。

解决方案是通过一个所有缓存都能监听的共享总线来提供必要的通信。这样,一个缓存可以“窥探”其他缓存中发生的变化,并更新其本地状态以保持一致。这种必需的通信协议被称为缓存一致性协议

在设计协议时,我们希望只在真正发生共享和进展时产生通信开销。换句话说,只有当多个缓存拥有共享变量的本地副本时,才需要进行通信。

缓存行的状态

为了实现缓存一致性协议,我们需要改变为每个缓存行维护的状态。

所有缓存行的初始状态都是无效,这表示标签和数据字段不包含最新的信息。这对应于我们原始缓存实现中将有效位设置为零。

当缓存行数据处于独占状态时,表示该缓存拥有这些内存位置的唯一副本,并且本地数据与主内存中的数据相同。这对应于我们原始缓存实现中将有效位设置为一。

如果缓存行状态是已修改,则意味着该缓存行数据是数据的唯一有效副本。这对应于我们原始缓存实现中将脏位和有效位都设置为一。

为了处理共享问题,还有第四个状态,称为共享。它表示其他缓存也可能拥有相同且未修改的内存数据的副本。

读取操作与状态转换

当从主内存填充缓存时,其他缓存可以窥探其读取请求并参与完成该请求。

如果没有其他缓存拥有请求的数据,则从主内存获取数据,并且请求缓存将该缓存行的状态设置为独占

如果其他某个缓存拥有处于独占共享状态的请求缓存行,它会提供数据,并在窥探总线上断言共享信号,以指示现在有多个缓存拥有该数据的副本。所有缓存都会将该缓存行的状态标记为共享

如果另一个缓存拥有该缓存行的已修改副本,它会提供更改后的数据,为请求缓存提供正确的值,同时更新主内存中的值。同样,共享信号被断言,读取和响应的缓存都会将该缓存行的状态设置为共享

因此,在读取请求结束时,如果存在该缓存行的多个副本,它们都将处于共享状态。如果只有一个副本,它将处于独占状态。

写入操作与状态转换

写入缓存行时,才会发生共享匹配。

如果发生缓存未命中,缓存首先执行如上所述的缓存行读取操作。

如果缓存行现在处于共享状态,写入操作将导致缓存向窥探总线发送一个无效化消息,告诉所有其他缓存使它们的缓存行副本无效,从而保证本地缓存现在拥有对该缓存行的独占访问权。

如果写入发生时缓存行处于独占状态,则无需通信。现在,缓存数据可以在缓存行中被更改,并将状态设置为已修改,完成写入。

这个协议根据其可能状态的首字母缩写,被称为 MESI 协议。

硬件支持与请求流

请注意,我们原始缓存实现中的有效位和脏位状态已被重新用于编码四种MESI状态之一。成功的关键在于,每个缓存现在都知道一个缓存行何时可能被另一个缓存共享,从而在共享位置的值被更改时触发必要的通信。

协议不会尝试更新共享值,而是简单地将其无效化。如果其他缓存在未来某个时间需要共享变量的值,它们将发出新的请求。

为了支持缓存一致性,缓存硬件必须被修改以支持两个请求流:一个来自CPU,一个来自窥探总线。

CPU端包括一个存储请求队列,用于存储因缓存未命中而延迟的请求。这允许CPU继续执行,而无需等待缓存重新填充操作完成。请注意,CPU读取请求在检查缓存之前需要检查存储队列,以确保向CPU提供最新的值。通常有一个存储屏障指令,它会暂停CPU,直到存储队列为空,从而保证在执行恢复之前,所有处理器都已看到写入操作的效果。

在窥探端,缓存必须窥探来自其他缓存的事务,根据需要使缓存行数据无效或提供数据,然后更新本地缓存行状态。如果缓存正忙于(例如,重新填充操作),无效化请求可能会排队,直到可以被处理。通常有一个读取屏障指令,它会暂停CPU,直到无效化队列为空,从而保证在执行恢复之前,来自其他处理器的更新已应用到本地缓存数据。

请注意,这里显示的“带修改意图的读取”事务只是协议简写,表示一个读取操作后立即跟一个无效化操作,表明请求者将更改缓存行的内容。

协议应用示例

让我们将MESI缓存一致性协议应用到我们之前的例子中。

以下是我们的两个线程,以及它们的本地缓存状态,表明位置X和Y的值由两个缓存共享。让我们看看当操作按此处所示的顺序1到4发生时的情况。

  1. 线程A将x更改为3。由于此位置在本地缓存中标记为共享,核心0的缓存向其他缓存发出针对位置X的无效化事务,从而获得对位置X的独占访问权,并将其值更改为3。在此步骤结束时,核心1的缓存不再拥有位置X值的副本。
  2. 线程B将y更改为4。由于此位置在本地缓存中标记为共享,缓存1向其他缓存发出针对位置Y的无效化事务,从而获得对位置Y的独占访问权,并将其值更改为4。
  3. 执行在TB中继续,它需要位置X的值。这是一个缓存未命中,因此它在窥探总线上发出读取请求,缓存0用其更新后的值响应,并且两个缓存都将位置X标记为共享。也在监听窥探总线的主内存也更新其X值的副本。
  4. 最后,在步骤4中,线程A需要y的值,这导致在窥探总线上发生类似的事务。

请注意,结果与在分时核心上执行相同序列所产生的结果完全一致,因为一致性协议保证了没有缓存拥有过时的共享内存位置副本。并且两个缓存对共享变量X和Y的最终值达成一致。

总结

本节课中我们一起学习了缓存一致性协议MESI。该协议通过引入四种状态(无效、独占、共享、已修改)和基于共享总线的窥探机制,解决了多核系统中共享数据的一致性问题。它只在必要时进行通信,确保了所有处理器看到的内存视图是一致的,从而维护了顺序一致性。

目前,单核架构似乎已达到一个稳定点。至少在当前的指令集架构下,流水线深度不太可能增加,而乱序超标量指令执行已达到性能回报递减点。因此,由于CPU核心内部架构变化而带来的性能大幅提升似乎不太可能。

GPU架构在不断演变以适应特定应用领域的新用途,但它们不太可能影响通用计算。在系统层面,趋势是增加核心数量,并找出如何用新算法最好地利用并行性。

展望未来,请注意大脑能够使用相当慢的机制完成非凡的成果。将信息传递到大脑需要百分之一秒,而突触每秒只激发0.3到1.8次。是海量的并行性赋予了大脑计算能力,还是大脑使用了不同的计算模型(例如,神经网络)来根据新输入决定新动作?至少在涉及认知的应用中,有新的架构和技术前沿需要探索。你们面前有一些有趣的挑战。

079:课程总结与展望 🎓

在本节课中,我们将回顾6.004课程的核心内容,总结我们所学到的工程抽象与设计原则,并展望计算技术未来的可能发展方向。

课程回顾:从器件到系统 🏗️

现在我们已经到达了6.004课程的终点。回顾过去,我们可以从两个角度来思考我们讨论过的材料、练习过的技能以及完成的设计。

从器件出发,我们沿着设计层次结构向上推进,每一层都作为下一层的构建模块。在此过程中,我们思考了设计权衡,选择了那些能使我们的系统可靠、高效、易于理解,从而易于维护的方案。

工程抽象的力量:黑盒与规范 📦

从另一个角度看6.004,我们创建并随后使用了一套工程抽象的层次结构。这些抽象合理地独立于它们所封装的技术。尽管技术日新月异,但这些抽象及其体现的原则是永恒的。

例如,乔治·布尔在1847年描述的符号逻辑,至今仍被用来推理你我今天设计的数字电路的操作。

工程抽象的力量在于,它允许我们基于组件的行为来推理系统的行为,而无需理解每个组件的实现细节。将组件视为实现某些特定功能的黑盒的优势在于,只要满足相同的规范,实现方式可以改变。

在我的一生中,一个两输入与非门的尺寸缩小了10个数量级,然而一个50年前的逻辑设计如果用今天的技术实现,仍然可以按预期工作。想象一下,如果你必须推理掺杂硅和导电金属的电学特性,来构建一个将两个二进制数相加的电路,那将是多么困难。

使用抽象让我们能够限制每一层的设计复杂度,缩短设计时间,并更容易验证规范是否得到满足。一旦我们创建了一套有用的构建模块库,我们就可以反复使用它们来组装许多不同的系统。

课程目标与期望 🎯

我们在6.004中的目标是揭开计算机工作原理的神秘面纱,从MOSFET开始,一直上升到操作系统。我们希望你已经理解了我们所介绍的工程抽象,并有机会在完成实验中的设计问题时练习使用它们。

我们也希望你能理解它们的局限性,并有信心在面对新的工程挑战时创造新的抽象。好的工程师使用抽象,而伟大的工程师创造抽象。

6.004是一门入门课程,仅触及设计层次结构每一层所使用的基本原理。如果某个特定主题让你觉得特别有趣,我们希望你能寻找更高级的课程,让你更深入地钻研那个工程学科。

成千上万的工程师共同努力,创造了作为当今信息社会引擎的数字系统。正如你可以想象的,有无穷无尽的有趣工程等待探索和掌握,所以卷起袖子,加入这场乐趣吧。

未来的计算挑战:超越经典 🚀

明天的工程挑战会是什么?以下是关于计算的未来可能与现在截然不同的几点思考。

我们今天构建的系统具有明确定义的状态概念,即存储在内存中、由其逻辑组件产生并沿着互连传输的精确数字值。但基于量子力学原理的计算可能让我们能够解决目前棘手的问题,使用的状态不是描述为一和零的集合,而是描述为许多状态叠加的相互关联的概率。

我们使用电压编码信息,并使用电压控制开关,通过基于硅的电子器件进行计算。但生命的化学过程已经进行了数千年的详细制造操作,使用编码为氨基酸序列的信息。我们DNA中编码的一些信息已经存在了数百万年,这是一个真正长寿的信息系统。今天,生物学家正开始用生物材料构建计算组件。也许50年后,你不再需要插上笔记本电脑的电源,而是需要“喂养”它。

除了使用真值表和逻辑函数,一些计算最好由神经网络来执行,它们对模拟输入进行适当加权的组合,其中的权重是系统在使用应产生已知输出的示例输入进行训练时学习到的。人工神经网络被认为模拟了我们大脑中突触和神经元的运作。随着我们对大脑运作方式的了解更多,我们可能会获得许多关于如何实现擅长识别和推理的系统的新见解。

再次以生物体为有用模型,编程可能会被学习所取代,其中刺激和反馈被用来演化系统行为。换句话说,系统将使用适应机制来演化出所需的功能,而不是通过显式编程来实现。

这一切似乎都是科幻小说的内容,但我怀疑我们的父母对于能和Siri谈论明天的天气,也会有同样的感觉。

结束语与致谢 🙏

感谢你加入6.004课程。我们很享受呈现这些材料,并用设计任务来挑战你,以锻炼你的新技能和理解。数字系统的世界前方充满了有趣的时光,我们当然需要你的帮助来发明未来。

我们欢迎你对课程提出任何反馈,所以请随时在论坛中留言。

暂时告别,并祝你在未来的学习中好运。


本节课总结:我们一起回顾了6.004课程的核心,理解了工程抽象(如将组件视为黑盒)在构建复杂数字系统中的关键作用。我们学习了从底层器件到高层系统的设计层次结构,并展望了量子计算、生物计算和神经网络等未来计算技术的可能性。课程鼓励我们不仅要学会使用现有抽象,更要勇于创造新的抽象来解决未来的工程挑战。

080:关于教学计算结构

概述

在本节中,我们将通过 Christopher Terman 教授的访谈,了解他如何教授“计算结构”这门课程。我们将探讨他的教学理念、课程设计、学生背景多样性、在线平台的应用、课堂互动策略、教学团队角色、在线论坛的作用、虚拟实验室体验以及课程的未来发展。

教学兴趣的起源

Christopher Terman 教授对计算结构的兴趣始于20世纪70年代。当时,他是一名大学生,为了支付学费,他在校园计算机上担任第三班操作员。在那个时代,学校只能负担得起一台计算机。由于观看闪烁的指示灯很无聊,他拿出了大学计算机的电路图,开始尝试理解计算机的工作原理。自那时起,理解如何将这些组件组合成能够进行计算的机器,就成了他终生的兴趣。

对教学与在线教育的热情

Terman 教授对教学和在线教育的热情源于他学生时代遇到的优秀教师。他深受这些教师的启发,并立志成为最好的教师。他认为,教学并看到学生点头表示理解的过程非常有满足感。这种积极的反馈形成了一个良性循环:从教学中获得良好感受,促使他在下一年做得更好,从而获得更好的感受。四十年来,这对他来说一直是一段非常充实的经历。

学生背景的多样性

学生的背景差异很大。有些学生有很长的编程经验,甚至对计算机内部结构有所了解。而另一些学生则没有任何相关经验,他们可能只是使用过浏览器、笔记本电脑和电子邮件,但对操作系统或内部硬件一无所知。他们带着兴趣而来,但完全没有背景知识。

满足多样化需求的课程结构

为了满足如此多样化的背景需求,课程必须提供丰富的材料。对于需要从零开始的学生,需要有入门材料;对于已经掌握部分内容的学生,需要有能够激发他们兴趣的进阶材料。Terman 教授将课程设计成一个“自助餐”,提供了大量可供选择的材料。这是6.004课程的一个标志性特点:我们提供了学习材料的每一种可能方式,不仅适应不同的背景,也适应不同的学习风格。

以下是适应不同学习风格的方式:

  • 听觉/口语型学习者:可以通过听课或讨论来学习。
  • 阅读型学习者:可以通过阅读文本来学习。
  • 实践型学习者:可以通过完成习题集进行“即时学习”,他们可能先看例题,如果不理解,再回头阅读解释性材料。

MITx 在线平台的作用

MITx 在线平台为提供这种“自助餐”式学习体验提供了两大助力。首先,它是一个集成了所有不同类型材料的“一站式商店”。其次,它支持当前关于如何向初学者解释材料的最佳实践:采用短小精悍的片段化教学。

平台允许教师构建包含简短视频片段和自测问题的学习序列。这种设计促进了“检索练习”学习法,即通过反复回答类似问题,帮助学生将知识从短期记忆转化为长期记忆。学生可以随时随地异步学习,平台统计数据也显示,在测验周期间观看量很大,说明很多学生将其用作沉浸式学习工具。

数字工具与教学研究

对于像 Terman 教授这样热衷于教学研究的人来说,这是一个激动人心的时代。在线课程提供了一个真正的教育实验室,教师可以尝试不同的教学技巧,并相对准确地评估其效果。借助 MITx 平台,可以进行 A/B 测试,这就像拥有了一个实验室,可以提出假设并通过实验进行验证。

大课堂的互动策略

由于课程提供了丰富的材料,真正来听课的学生是那些通过听课学习效果最好的人。Terman 教授为课堂准备了一套经过“调试”的材料,确保内容量适中、进度合理。他在课堂上保持轻松的氛围,会讲笑话、分享职业生涯故事。他提到,学生期末评价中很多人喜欢这些故事,这有助于他们将技术与实际经历联系起来,从而更好地记忆。

他引入了“流畅性中断”的概念。当课堂进行得过于顺畅时,学生的思维可能会开始游离。因此,适当制造一些“不流畅”是有益的,比如讲个笑话、故意犯个错误、或者走近学生。任何能打破原有节奏的小变化,都能有效保持学生的参与度。

教学团队的构成与作用

课程有一个庞大的教学团队。核心是一小群讲师,Terman 教授是其中长期的一员,此外还有系里增加的讲师资源和其他参与教学的教师。团队还包括负责辅导课的研究生助教、本科生助教以及实验室助理。

这个分层体系非常有效。几乎所有团队成员都曾修读过并热爱这门课程,因此他们充满热情。学生往往更喜欢从底层(如刚修完课的本科生助教)开始提问,这样压力较小。只有当问题在底层无法解决时,才会向上级求助。这种机制确保了向更“令人生畏”的教师提出的问题,通常都是经过筛选的、非显而易见的问题。

在线论坛的管理与价值

在线论坛(如 Piazza)是课程的宝贵资产。它允许教师对一个问题给出深思熟虑的答案,然后让180名学生看到,而不是只回答一个人。这极大地提高了效率。

Terman 教授努力对每个问题都给予周到且尊重的回答。学生可以匿名提问,这降低了障碍。在2017年秋季,论坛约有2500条贡献,平均响应时间约为20分钟。快速的响应时间显著降低了学生的挫败感,使他们感到支持是全天候的。论坛改变了学生遇到困难时的体验,从可能持续数天的困境变成了一个大约10分钟就能解决的过程。

虚拟实验室体验

课程中的实验体验是浏览器化的数字设计,无需下载任何软件。Terman 教授喜欢构建基于浏览器的计算机辅助设计工具,现代浏览器环境完全能够胜任。

这是一种“设计驱动”的学习。学生需要根据描述(有时是相当详细的)来构建某个东西。亲自动手搭建、连接组件的过程,能带来更好的记忆和理解。学生会问自己:“这个应该放在这里还是那里?”从而更仔细地审视设计说明。

虚拟实验室配备了测试功能,学生可以立即知道设计是否正确。如果不正确,他们可以继续调试或在论坛求助,工作人员可以远程查看并指出错误。这让学生真正体验了工程师的工作。从“看别人做很简单”到“自己动手却遇到问题”,这个过程填补了认知空白,是非常宝贵的经历。

基于浏览器的虚拟实验平台具有便携性,使得世界各地的学生都能无障碍地使用这些复杂的工具,这是本地软件难以比拟的优势。

学生初次实践的挑战

学生初次尝试扮演工程师角色时,面临的主要挑战之一是信心系统性调试能力的缺乏。许多学生不善于利用已知信息来缩小问题范围,或者从两端向中间排查故障。

教学团队的目标是帮助学生建立信心,并掌握系统化的调试方法。课程的一个学习目标就是让学生完成从“只关注答案”到“掌握解决问题方法”的转变。我们希望学生最终能感到“赋能”——他们不仅能识别正确答案,还能从头开始创造出正确答案。

课程的未来发展

虽然课程目前已经相当完善,但未来仍有调整空间。Terman 教授即将退休,课程将交由新的教学团队接管。新团队自然会有关于如何改进的不同想法。

课程的基本结构和主题列表可能会保持不变,但设计体验可能会有所不同。教授大型课程(如300人)需要精心设计材料,因为教师没有能力单独回答每个学生的每个问题。因此,必须将学生所需的大部分内容都整合到材料中,这个过程是迭代的。

材料的“工程化”设计

所谓“工程化”材料,是一个持续的迭代过程。通过多年的教学(Terman 教授教了大约30个学期),结合学生反馈(论坛能提供即时反馈),不断反思什么做得好、什么需要改进。教学团队会实时调整,例如在作业中添加解释段落或提示。

有时,学生的“误解”或意外发现会带来全新的视角,甚至可能比原问题更有趣。这为改进课程设计提供了宝贵的机会。经过长期演化,最终的问题可能看起来简单,但其背后蕴含着精心的设计和排序。

总结

在本节中,我们一起探讨了 Christopher Terman 教授关于“计算结构”课程的教学哲学与实践。我们了解到,面对背景多样的学生,提供丰富的“自助餐”式学习材料至关重要。MITx 在线平台和虚拟实验室工具极大地促进了碎片化学习和动手实践。有效的教学团队、活跃的在线论坛以及鼓励“流畅性中断”的课堂策略,共同营造了支持性的学习环境。课程不仅传授数字系统架构知识,也致力于培养学生像工程师一样思考、调试和解决问题的专业能力。最后,课程材料的优化是一个持续迭代、“工程化”的过程,始终以学生的学习体验和效果为核心。

posted @ 2026-03-29 09:20  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报