哈维穆德数字设计与计算机体系结构-RISC-版笔记-全-
哈维穆德数字设计与计算机体系结构 RISC 版笔记(全)
001:从0到1 🚀
在本章中,我们将学习如何仅使用0和1来表示和处理信息。我们将探讨数字系统的基础,包括二进制和十六进制数制、逻辑门以及构成现代计算机核心的晶体管。
概述 📋

数字设计的世界建立在两个简单的状态之上:0和1。本章的目标是理解如何用这两个数字构建复杂的系统。我们将从数字表示开始,逐步深入到实现这些表示的物理组件。
数字表示:二进制与十六进制 🔢
上一节我们介绍了数字设计的基础是0和1。本节中,我们来看看如何用它们来表示更大的数字。
二进制是基数为2的数制系统。这意味着每一位(称为一个比特)只能是0或1。一个二进制数的值是其各位乘以2的幂次的总和。例如,二进制数 1011 表示:
1*2³ + 0*2² + 1*2¹ + 1*2⁰ = 8 + 0 + 2 + 1 = 11
然而,书写长串的二进制数非常繁琐。为了简化,我们引入了十六进制(基数为16)作为一种简写方式。十六进制使用数字0-9和字母A-F(代表10-15)来表示数值。每4位二进制数可以直接转换为1位十六进制数。
以下是相关术语:
- 比特:一个二进制位,是信息的最小单位。
- 半字节:4个比特的组合。
- 字节:8个比特的组合,是计算机中常见的数据单位。
二进制运算与有符号数 ➕➖
现在我们已经能用0和1表示数字,本节中我们来看看如何对它们进行运算。
二进制加法遵循与十进制加法类似的规则,但逢2进1。例如:
1011 (11)
+ 0110 (6)
= 10001 (17)
为了表示负数,计算机使用特定的编码方式。最常见的是二进制补码。在这种表示法中:
- 正数的表示与普通二进制相同。
- 负数的表示方法是:取其绝对值的二进制形式,按位取反,然后加1。
- 最高位(最左边的位)是符号位:0表示正数,1表示负数。
有时我们需要将一个数扩展到更多比特(例如从8位扩展到16位)。对于有符号数(补码),我们进行符号扩展:将符号位复制到所有新的高位。对于无符号数,我们进行零扩展:在所有新的高位填充0。
逻辑门:处理0和1的组件 ⚙️
数字系统不仅需要表示0和1,还需要对它们进行操作。逻辑门就是实现这些基本逻辑功能的电子组件。
逻辑门将输入的0和1(作为电信号)进行组合,根据其内部功能产生一个输出的0或1。最基本的逻辑门包括:
- 与门:仅当所有输入都为1时,输出才为1。公式:
Y = A · B - 或门:只要有一个输入为1,输出就为1。公式:
Y = A + B - 非门:将输入取反。如果输入是1,输出是0,反之亦然。公式:
Y = A'
逻辑电平定义了电压范围到逻辑值(0和1)的映射。例如,在一个系统中,0V到0.8V可能被解释为逻辑0,2V到5V被解释为逻辑1。中间的电压区域是不确定状态,应避免。
晶体管:逻辑门的基石 🔌
逻辑门是抽象的构建块。在物理层面,它们是由晶体管构成的。本节我们将深入一层,看看这些基础开关是如何工作的。
最常用的数字晶体管类型是CMOS晶体管,特别是MOSFET。我们可以将其理想化为受电压控制的数字开关:
- NMOS晶体管:当栅极电压为高(1)时,开关闭合(导通);为低(0)时,开关断开。
- PMOS晶体管:与NMOS行为相反,当栅极电压为低(0)时导通。
通过将NMOS和PMOS晶体管以互补的方式组合,我们可以构建出高效且功耗极低的逻辑门电路,例如CMOS反相器(非门)、与非门、或非门等。
数字系统的功耗 ⚡
构建数字系统时,功耗是一个关键考量因素。CMOS电路的主要功耗来源包括:
- 动态功耗:在逻辑状态切换(0->1或1->0)时,对电路中的电容进行充放电所消耗的能量。功耗与切换频率和电压的平方成正比。
- 静态功耗:即使电路处于空闲状态,由于晶体管微小的漏电流而产生的功耗。
降低功耗的技术包括降低供电电压、优化电路设计以减少不必要的切换活动,以及使用电源门控在模块空闲时完全关闭其电源。
总结 🎯

在本章中,我们一起学习了数字设计的基石。我们从最简单的0和1出发,探索了用二进制和十六进制表示数字的方法,学习了二进制运算和有符号数的补码表示。接着,我们了解了处理这些比特的逻辑门及其功能,并深入到物理层面,看到了CMOS晶体管如何作为开关来构建这些逻辑门。最后,我们讨论了数字系统中功耗的来源。理解这些从抽象到物理的基础概念,是掌握复杂数字系统设计的第一步。在后续课程中,我们将反复运用这些概念来构建更大型、更功能强大的系统。
002:管理复杂性的设计原则 🧩

在本节课中,我们将学习如何设计那些过于庞大、无法一次性装入人脑的系统。工程师们通过一系列核心原则来应对这种复杂性,包括抽象、设计约束以及由层次化、模块化和规律性构成的“三Y”原则。
抽象:隐藏不重要的细节 🎭
上一节我们提出了管理复杂性的核心问题,本节中我们来看看第一个关键工具:抽象。抽象意味着在细节不重要时将其隐藏。
例如,要理解计算机的工作原理,我们可以从量子力学开始。最终,所有现象都归结为原子晶格中电子的运动。然而,为海量组件同时求解波动方程是完全不可行的。
因此,我们向上移动到更高的抽象层次:
- 器件层:我们定义晶体管,它由掺杂原子构成,但我们可以将其视为具有特定电压-电流关系的多端器件。
- 模拟电路层:例如运算放大器或滤波器,由器件构成,但我们无需关心内部晶体管的精确排列,只需将其视为一个放大器。
- 数字电路层:我们定义逻辑门,它输入和输出0与1。
- 逻辑层:例如加法器或存储器,由许多逻辑门构成,但我们可以将其视为一个具有输入输出关系的黑盒。
- 微架构层:组合加法器和存储器等,构成数据通路和控制器。
- 架构层:这是程序员看到的系统视图,包括计算机可执行的指令和内部寄存器。
- 操作系统层:例如设备驱动程序,可以打开它以访问键盘并读取键入的字符,而无需关心总线上传输的0和1的具体模式。
- 应用软件层:例如编写程序在屏幕上打印内容、运行视频游戏或进行计算。
这些程序最终依赖于所有底层抽象,但程序员在编写“Hello World”时,通常不会思考导致这一切发生的电子波动方程。这就是抽象的力量。
在本课程中,我们将主要关注从数字电路到架构的中间抽象层次。
设计约束:有意识地限制选择 🔧
上一节我们介绍了通过抽象来简化理解,本节中我们来看看另一个重要概念:设计约束。设计约束是指有意识地限制设计选择,这初看似乎违反直觉,但实则能极大简化设计。
一个绝佳的例子是数字约束。我们使用离散的电压值,而非连续的电压值。这使得设计变得更简单,并能构建更复杂的系统。
例如,在模拟电视中,连续的电压信号决定屏幕上像素的颜色,任何电压噪声都会导致视觉上的雪花干扰。而在数字系统中,每个像素由一组比特(bits)表示,并且可以引入纠错机制。即使存在噪声,比特信息仍可恢复,从而获得清晰的图像。
因此,数字系统已在众多领域取代了其模拟前身。
三Y原则:层次化、模块化与规律性 🏗️
上一节我们探讨了通过约束来简化设计,本节中我们来看看管理复杂性的三个具体技术:层次化、模块化和规律性,合称“三Y原则”。
以下是三Y原则的具体含义:
- 层次化:递归地将复杂系统分解为更小的子系统。
- 模块化:用定义明确的功能和接口来构建系统。模块化意味着没有副作用。
- 规律性:与可互换部件的概念相关。
以福特T型车为例,看三Y原则如何应用:
- 层次化:将汽车整体分解为底盘、车轮、座椅、发动机等组件。发动机又可进一步分解为气缸、火花塞、排气系统、化油器等。化油器再分解为进气口、进油针、供油管和连接螺母。
- 模块化:以连接螺母为例。其功能明确:将油管固定在进气歧管上,防止泄漏,且易于拆卸以便维修。其接口标准化:具有标准直径、标准螺纹间距和标准扭矩。
- 规律性:标准化的螺母可以从众多供应商处购买,这降低了成本并提高了可用性。亨利·福特的名言“任何顾客可以将这辆车漆成任何他想要的颜色,只要它是黑色”就是规律性的体现,它使得所有部件可互换,简化了生产和维修。
数字抽象:从连续到离散 🔢
上一节我们通过T型车了解了三Y原则,本节中我们将聚焦于本课程的一个关键抽象:数字抽象。
大多数物理变量是连续的,例如导线上的电压、振荡频率或物体的位置。但在数字抽象中,我们只考虑这些值的一个离散子集来定义我们的0和1。
数字抽象的一个早期例子是分析机,由查尔斯·巴贝奇在19世纪中期设计。它被认为是第一台数字计算机,但并非使用电信号,而是使用机械齿轮。每个齿轮有10个不同位置(0到9),通过摇动曲柄,齿轮相互啮合并运动,使系统能够执行加法和计算数学用表等操作。
巴贝奇因数学用表中的大量错误而感到沮丧,萌生了用机器计算的想法。然而,他不断追求更优雅的设计,导致项目一再推倒重来。加之当时的机械加工精度有限,以及他本人难以相处的性格,最终项目在耗尽巨资后未能完成,巴贝奇抱憾而终。直到20世纪90年代,工程师们利用现代CNC技术才根据他的图纸成功建造出可运行的分析机。
回到电子系统,拥有10个状态的机械系统非常精密且易出错。相比之下,仅区分两种状态(如两种电压)要容易得多。如今,我们可以制造出极其微小、快速且廉价的晶体管(成本低至纳美分级别),这使得构建数字系统变得非常容易。
我们将这两个离散值称为0和1。0可能代表“假”或低电压,1可能代表“真”或高电压。这些值可以用各种不同的物理量表示,但我们通常使用电压。我们还有一个术语叫比特(bit),是二进制数字的缩写,其值要么是0,要么是1。
总结 📚

本节课中,我们一起学习了管理复杂性的核心设计原则。我们了解了抽象如何通过隐藏不必要细节来帮助我们理解庞大系统;认识了设计约束(如数字约束)如何通过限制选择来简化设计并实现更强大的功能;最后,我们探讨了层次化、模块化和规律性这“三Y原则”如何通过递归分解、明确定义和标准化来构建复杂而可靠的系统。这些原则是数字设计和计算机架构的基石,将在后续课程中反复应用。
003:无符号二进制数 🔢

在本节中,我们将学习数字系统的基础,特别是无符号二进制数。我们将了解二进制数的表示方法、如何与十进制数相互转换,以及二进制数的表示范围。
我们习惯于使用十进制数,即基数为10的数制。在十进制中,每一位的权重是前一位的10倍。例如,数字 5374₁₀ 表示:
- 千位:5 × 1000
- 百位:3 × 100
- 十位:7 × 10
- 个位:4 × 1
因此,其值为 5×1000 + 3×100 + 7×10 + 4×1 = 5374。
理解了十进制后,我们来看看二进制。二进制只使用两个数字:0和1。在二进制中,每一位的权重是前一位的2倍。例如,数字 1101₂ 表示:
- 八位(2³):1 × 8
- 四位(2²):1 × 4
- 二位(2¹):0 × 2
- 个位(2⁰):1 × 1
因此,其值为 8 + 4 + 0 + 1 = 13₁₀。
在二进制运算中,2的幂次方非常常用,熟记它们会很有帮助:
- 2⁰ = 1
- 2¹ = 2
- 2² = 4
- 2³ = 8
- 2⁴ = 16
- 2⁵ = 32
- 2⁶ = 64
- 2⁷ = 128
- 2⁸ = 256
- 2⁹ = 512
- 2¹⁰ = 1024
上一节我们介绍了二进制的基本概念,本节中我们来看看如何进行二进制与十进制之间的转换。
从二进制转换到十进制
转换方法是将每一位的值乘以其权重(2的幂次方),然后求和。
例如,将 10011₂ 转换为十进制:
- 从右至左,权重分别为:1(2⁰), 2(2¹), 4(2²), 8(2³), 16(2⁴)。
- 计算:1×16 + 0×8 + 0×4 + 1×2 + 1×1 = 16 + 0 + 0 + 2 + 1 = 19₁₀
从十进制转换到二进制
有两种常用方法。
方法一:减法法(适合人工计算)
此方法是从最大的、小于目标数的2的幂次方开始,依次减去。
例如,将 47₁₀ 转换为二进制:
- 找到小于47的最大2的幂:32(2⁵)。置该位为1,剩余 47 - 32 = 15。
- 下一个幂是16,15 < 16,所以该位置0。
- 下一个幂是8,15 ≥ 8,置该位为1,剩余 15 - 8 = 7。
- 下一个幂是4,7 ≥ 4,置该位为1,剩余 7 - 4 = 3。
- 下一个幂是2,3 ≥ 2,置该位为1,剩余 3 - 2 = 1。
- 最后一个幂是1,1 ≥ 1,置该位为1,剩余 0。
因此,47₁₀ = 101111₂(即 32 + 8 + 4 + 2 + 1)。
方法二:除2取余法(适合计算机算法)
此方法是不断将十进制数除以2,记录余数,直到商为0。余数序列从后往前读就是二进制结果。
例如,将 53₁₀ 转换为二进制:
- 53 ÷ 2 = 26 ... 余数 1 (最低位)
- 26 ÷ 2 = 13 ... 余数 0
- 13 ÷ 2 = 6 ... 余数 1
- 6 ÷ 2 = 3 ... 余数 0
- 3 ÷ 2 = 1 ... 余数 1
- 1 ÷ 2 = 0 ... 余数 1 (最高位)
将余数从最后一个到第一个排列,得到 110101₂。验证:32 + 16 + 4 + 1 = 53。
了解了转换方法后,最后我们探讨一下二进制数的表示范围。
对于一个 n 位的十进制数,它可以表示 10ⁿ 个不同的数,范围是从 0 到 10ⁿ - 1。
例如,一个3位十进制数可以表示 10³ = 1000 个数,范围是 0 到 999。
类似地,对于一个 n 位的二进制数,它可以表示 2ⁿ 个不同的数,范围是从 0 到 2ⁿ - 1。
例如,一个3位二进制数可以表示 2³ = 8 个数,范围是 000₂ (0₁₀) 到 111₂ (7₁₀)。
用公式表示,n位无符号二进制数的范围是:
范围:0 ≤ N ≤ 2ⁿ - 1

本节课中我们一起学习了无符号二进制数。我们首先回顾了十进制系统作为对比,然后引入了二进制系统,解释了其每一位的权重是2的幂次方。接着,我们详细讲解了二进制与十进制相互转换的两种主要方法:减法法和除2取余法。最后,我们定义了n位二进制数所能表示的数值范围,即从0到2ⁿ - 1。掌握这些基础知识是理解后续计算机中数据表示和运算的关键。
004:十六进制数 🔢

在本节中,我们将学习十六进制数系统。十六进制在数字系统中非常常见,因为它是一种高效表示二进制数的方式。

概述
上一节我们讨论了二进制数。本节中,我们将探讨十六进制数系统,了解其构成、与二进制和十进制的转换方法,以及它在计算机系统中的常见表示方式。
十六进制数基础
十六进制是一种基数为16的数制。这意味着它使用16个不同的符号来表示数值。
以下是十六进制数的构成:
- 数字 0-9:与十进制中的含义相同。
- 字母 A-F:用于表示十进制中的10到15。
| 十六进制数字 | 十进制等价 | 二进制等价 (4位) |
|---|---|---|
| 0 | 0 | 0000 |
| 1 | 1 | 0001 |
| 2 | 2 | 0010 |
| 3 | 3 | 0011 |
| 4 | 4 | 0100 |
| 5 | 5 | 0101 |
| 6 | 6 | 0110 |
| 7 | 7 | 0111 |
| 8 | 8 | 1000 |
| 9 | 9 | 1001 |
| A | 10 | 1010 |
| B | 11 | 1011 |
| C | 12 | 1100 |
| D | 13 | 1101 |
| E | 14 | 1110 |
| F | 15 | 1111 |
十六进制数的优势在于,它能用一个数字方便地表示四位二进制数。例如,二进制数 1010(基数为2)等于十进制数10,在十六进制中则表示为 A。这使得十六进制成为表示长二进制数的一种更紧凑的方式。
进制转换
理解了十六进制的基础后,我们来看看如何进行不同进制间的转换。
十六进制转二进制
从十六进制转换到二进制非常简单。以下是具体步骤:
- 将十六进制数的每一位单独取出。
- 将每一位转换为其对应的四位二进制数。
- 将所有四位二进制数按顺序拼接起来。
示例:将十六进制数 0x4AF 转换为二进制。
4(十六进制) ->0100(二进制)A(十六进制) ->1010(二进制)F(十六进制) ->1111(二进制)- 拼接结果:
0100 1010 1111(二进制)
因此,0x4AF 的二进制表示为 010010101111。
二进制转十六进制
从二进制转换到十六进制是上述过程的逆过程。以下是具体步骤:
- 从二进制数的最右侧开始,向左每四位分成一组。如果最左侧一组不足四位,则在前面补零。
- 将每一组四位二进制数转换为其对应的十六进制数字。
- 将所有十六进制数字按顺序拼接起来。
十六进制转十进制
要将十六进制数转换为更直观的十进制数,可以遵循与二进制转十进制类似的位权展开法。
示例:将 0x4AF 转换为十进制。
- 确定每一位的位权(从右向左,以16为底):
- 最右侧
F的位权是 (16^0) - 中间
A的位权是 (16^1) - 最左侧
4的位权是 (16^2)
- 最右侧
- 将每一位的值乘以其位权,然后求和:
4(十进制值4) × (16^2) = 4 × 256 = 1024A(十进制值10) × (16^1) = 10 × 16 = 160F(十进制值15) × (16^0) = 15 × 1 = 15
- 总和:1024 + 160 + 15 = 1199
因此,0x4AF 的十进制表示为 1199。
编程中的表示法
在文本文件和编程语言中,通常难以使用下标来表示数字的基数。因此,形成了以下约定俗成的前缀表示法:
- 十进制数:通常没有前缀。例如,
123默认为十进制。 - 十六进制数:使用前缀
0x或0X。例如,0x4AF、0X1F。 - 二进制数:使用前缀
0b或0B。例如,0b1010、0B1100。
这种表示法在计算机编程中非常常见,用于明确指示数字的基数。
总结

本节课中,我们一起学习了十六进制数系统。我们了解到十六进制使用0-9和A-F共16个符号,它能高效地表示二进制数(每四位对应一个十六进制数字)。我们掌握了十六进制与二进制、十进制之间相互转换的方法。最后,我们还学习了在编程中区分不同进制数的前缀表示法(0x 代表十六进制,0b 代表二进制)。掌握十六进制对于理解和操作数字系统至关重要。
005:字节、半字节与相关概念 🎵


在本节课程中,我们将继续探讨数字系统,重点介绍比特、字节、半字节等基本单位,以及如何估算二进制数的大小。这些概念是理解计算机如何存储和处理数据的基础。
上一节我们讨论了二进制数系统,本节中我们来看看构成这些数字的基本单位。
比特与字节
首先讨论比特。我们在二进制数中已经见过比特。有两个特定的比特位需要特别注意:最高有效位和最低有效位。最高有效位是二进制数中代表最大2的幂次的那一位。最低有效位则是代表最小2的幂次的那一位。
接下来是字节和半字节。字节由8个比特组成,是另一种对二进制数进行分组的方式。当我们用十六进制表示时,每个字节可以用两个十六进制数字表示,因为每个十六进制数字代表4个比特。半字节是一个不太常用但确实存在的术语,它代表4个比特,恰好可以方便地用一个十六进制数字表示。
与最高/最低有效位的概念类似,字节也有最高有效字节和最低有效字节的区分。最高有效字节代表数值中最重要的部分,而最低有效字节则代表我们所讨论数值的最小部分。
2的幂次估算
对2的幂次及其对应的十进制近似值有一个大致的了解会很有帮助。
以下是常见的2的幂次及其近似十进制值:
2^10 ≈ 10^3 = 1,024 ≈ 1千 (Kilo)2^20 ≈ 10^6 = 1,048,576 ≈ 1兆 (Mega)2^30 ≈ 10^9 = 1,073,741,824 ≈ 1吉 (Giga)
这些近似值在你讨论二进制数的大小时非常有用。你可能在查看电脑文件大小(如多少兆字节或吉字节)或测试网络速度(如每秒多少兆比特)时见过这些单位。这为你提供了一种估算这些数字对应十进制近似大小的方法。
估算练习
记住这些值非常有用,你会经常用到它们,而不必每次都去查表。让我们通过练习来巩固一下。
假设我们想估算 2^24 的值。我们可以将其拆分为已知的部分:
2^24 = 2^4 * 2^20
我们知道 2^4 = 16,并且从上一张幻灯片可知 2^20 ≈ 10^6。
因此,2^24 ≈ 16 * 10^6 = 1千6百万。
类似地,如果我们想知道一个32位整数能表示的最大值,我们可以这样做:
一个32位整数能表示的最大值约为 2^32。
使用类似的策略:
2^32 = 2^2 * 2^30
2^2 = 4,并且 2^30 ≈ 10^9。
因此,2^32 ≈ 4 * 10^9 = 40亿。
再次强调,了解这些值可以让你快速估算并感知一个数字的大致规模。这在试图理解问题时,是一个非常实用的经验法则。

本节课中我们一起学习了比特、字节和半字节的概念,掌握了如何利用2的幂次的近似值来快速估算二进制数的大小。这些知识是后续学习计算机数据表示和内存寻址的重要基础。
数字设计与计算机架构:1.5:二进制加法 🔢

在本节课中,我们将要学习二进制数的加法运算。我们将从熟悉的十进制加法开始,逐步过渡到二进制加法,并理解其工作原理、进位规则以及一个重要的概念——溢出。
上一节我们介绍了数字系统的基础,本节中我们来看看二进制数的加法是如何进行的。二进制加法的规则与十进制类似,但只涉及两个数字:0和1。
以下是二进制加法的基本规则:
- 0 + 0 = 0
- 0 + 1 = 1
- 1 + 0 = 1
- 1 + 1 = 0,并产生一个进位 1 到更高位
让我们通过一个例子来演示这个过程。假设我们要计算二进制数 1011 和 0011 的和。
就像小学学的竖式加法一样,我们从最低位(最右边)开始:
- 第一列:
1 + 1 = 10(二进制)。我们写下 0,并将 1 进位到下一列。 - 第二列:
1(进位)+ 1 + 1 = 11(二进制)。我们写下 1,并将 1 进位到下一列。 - 第三列:
1(进位)+ 0 + 0 = 1。我们写下 1,没有进位。 - 第四列:
1 + 0 = 1。我们写下 **1`。
因此,1011 + 0011 = 1110。我们可以验证:1011 是十进制的 11,0011 是十进制的 3,它们的和 14 在二进制中正是 1110。
然而,当加法的结果超过了我们用来表示数字的固定位数时,就会出现一个关键问题。数字系统通常使用固定数量的比特(位)进行操作。
以下是溢出的定义:
- 溢出 发生在运算结果太大,无法用给定的比特位数表示时。
例如,如果我们用4位二进制数(范围是0到15)计算 1011(11)加 0110(6),结果是 10001(17)。这个结果需要5位来表示。如果我们只保留低4位 0001(1),就会得到一个错误的结果。这个被丢弃的额外高位就是溢出位。
溢出在现实世界中可能导致严重后果。一个著名的例子是1996年阿丽亚娜5号火箭的首次发射失败。
火箭的惯性参考系统软件将一个64位浮点数转换为16位有符号整数时,发生了溢出。这个数值代表了火箭的水平速度,但由于新引擎更强大,速度值超出了16位整数能表示的范围。溢出导致控制系统接收到错误数据,进而发出错误的转向指令,最终使火箭在发射后约40秒偏离轨道并自毁。
这个案例表明,在设计数字系统时,充分考虑数值范围并预防溢出至关重要。

本节课中我们一起学习了二进制加法。我们从回顾十进制加法入手,掌握了二进制加法的基本规则和进位机制。接着,我们探讨了“溢出”的概念,即当运算结果超出指定比特位数表示范围时发生的情况,并通过阿丽亚娜5号火箭的例子了解了溢出可能带来的实际风险。理解这些基础是进行可靠数字系统设计的关键。
007:有符号数 🔢
在本节课中,我们将要学习二进制数如何表示负值。我们将介绍两种主要的方法:原码表示法和补码表示法,并重点讲解补码的工作原理及其优势。

概述
在之前的章节中,我们学习了无符号二进制数。本节中,我们来看看如何表示有符号数,即包含正负值的数字。理解有符号数的表示是进行算术运算的基础。
原码表示法
原码是一种直观的表示有符号数的方法。在这种表示法中,最高有效位(MSB)被称为符号位,其余位是数值位。
- 符号位为 0 表示正数。
- 符号位为 1 表示负数。
- 数值位按常规的二进制方式解释。
例如,用一个6位的原码表示数字 6 和 -6:
- +6:符号位为
0,数值6的二进制是110,所以表示为0 00110(假设为6位)。 - -6:符号位为
1,数值6的二进制是110,所以表示为1 00110。
一个 n 位原码数的表示范围是:
范围 = [ -(2^(n-1) - 1), +(2^(n-1) - 1) ]
例如,一个3位原码数的范围是 -7 到 7。
然而,原码表示法存在两个主要问题:
- 存在两个零:
0 000表示 +0,1 000表示 -0。这会造成混淆。 - 加法运算复杂:不能直接使用简单的二进制加法器进行运算,因为符号位需要特殊处理。
因此,原码并不是最常用的系统。
补码表示法
补码表示法解决了原码的问题。它只有一个零的表示,并且加法运算可以直接进行。
在补码中,最高有效位的权重是 -2^(n-1),而不是正权重。
对于一个4位补码数,各位的权重从左到右依次是:-8, 4, 2, 1。
- 最正数:没有 -8 权重,其他位全为1。即
0 111= 0 + 4 + 2 + 1 = 7。 - 最负数:只有 -8 权重,其他位为0。即
1 000= -8 + 0 + 0 + 0 = -8。
最高有效位仍然指示符号:0 为正,1 为负。
一个 n 位补码数的表示范围是:
范围 = [ -2^(n-1), +(2^(n-1) - 1) ]
对于4位数,范围是 -8 到 +7。这个范围是不对称的,但消除了负零。
求一个数的补码(取负)
有一个标准方法来改变一个补码数的符号,称为“求补码”或“取负”。操作步骤如下:
- 按位取反(将0变1,1变0)。
- 将结果加1。
让我们通过例子来理解:
例1:求 +3 的补码(即求 -3)
+3的4位二进制是0011。- 步骤1:按位取反得到
1100。 - 步骤2:加1得到
1101。 - 验证:
1101= -8 + 4 + 0 + 1 = -3。
例2:求 +6 的补码(即求 -6)
+6的4位二进制是0110。- 步骤1:按位取反得到
1001。 - 步骤2:加1得到
1010。 - 验证:
1010= -8 + 0 + 2 + 0 = -6。
例3:已知补码 1001,求其十进制值
- 方法A(直接计算):
1001= -8 + 0 + 0 + 1 = -7。 - 方法B(通过取负验证):对
1001求补码。- 取反:
0110 - 加1:
0111= +7 - 因此原数
1001是 -7。
- 取反:
补码的运算
补码最大的优势是加法和减法可以使用相同的硬件电路。
加法示例1:6 + (-6) = 0
0110 (6)
+ 1010 (-6,即6的补码)
------
10000
丢弃溢出的最高位(1),得到 0000,即 0。结果正确。
加法示例2:(-2) + 3 = 1
-2:+2是0010,求补码得1110。+3是0011。
1110 (-2)
+ 0011 (3)
------
10001
丢弃溢出的最高位(1),得到 0001,即 1。结果正确。
减法运算:减法可以通过“取减数的补码,然后与被减数相加”来实现。
示例:3 - 5 = 3 + (-5)
+3:0011-5:+5是0101,求补码得1011
0011 (3)
+ 1011 (-5)
------
1110
1110 = -8 + 4 + 2 + 0 = -2。结果正确。
表示范围对比
让我们总结并对比4位二进制数在不同表示法下的范围:
以下是三种表示法的范围:
- 无符号数:
0000到1111-> 0 到 15 - 补码:
1000到0111-> -8 到 +7 - 原码:
1111到0111(不包括两个零) -> -7 到 +7,并包含 +0 (0000) 和 -0 (1000)。
总结
本节课中我们一起学习了有符号二进制数的表示方法。
- 我们首先介绍了原码,它用单独的符号位表示正负,但存在双零和加法复杂的问题。
- 然后我们深入学习了补码表示法,其中最高位具有负权重(-2^(n-1))。
- 我们掌握了通过“取反加一”来求一个数补码(取负)的方法。
- 最后,我们验证了补码可以直接用于加法和减法运算,这使得它在计算机系统中被广泛采用。

理解补码是理解计算机如何执行算术运算的关键一步。
008:扩展
在本节课中,我们将要学习如何将一个M位长的数字扩展为更宽的N位数字。我们将重点介绍两种主要的扩展方法:有符号数的符号扩展和无符号数的零扩展。

上一节我们介绍了二进制数的表示方法,本节中我们来看看如何改变数字的位宽。
符号扩展
对于二进制补码表示的有符号数,我们使用符号扩展。其核心操作是复制符号位(最高有效位)到新的高位中。
公式:对于一个M位的二进制补码数 a[M-1:0],将其符号扩展为N位(N > M)的结果是 { {N-M{a[M-1]}}, a[M-1:0] }。
假设我们有一个4位的数字3,其二进制表示为 0011。符号位是0(正数)。
以下是将其扩展为8位的步骤:
- 原始4位数字:
0011 - 复制符号位
0到新的高4位。 - 得到8位结果:
0000 0011,其值仍然是3。
再举一个负数的例子,数字-5的4位二进制补码表示为 1011。符号位是1(负数)。
以下是将其扩展为8位的步骤:
- 原始4位数字:
1011 - 复制符号位
1到新的高4位。 - 得到8位结果:
1111 1011,其值仍然是-5。
通过复制符号位,无论位数如何增加,数字的值都保持不变。
零扩展
对于无符号数,我们使用零扩展。其核心操作是在高位填充0。
公式:对于一个M位的无符号数 a[M-1:0],将其零扩展为N位(N > M)的结果是 { {N-M{1‘b0}}, a[M-1:0] }。
假设我们有一个无符号数3,其4位二进制表示为 0011。
以下是将其零扩展为8位的步骤:
- 原始4位数字:
0011 - 在高4位填充0。
- 得到8位结果:
0000 0011,其值仍然是3。
再看另一个例子,二进制序列 1011。如果将其视为无符号数,其值为11(8+2+1)。
以下是将其零扩展为8位的步骤:
- 原始4位数字:
1011 - 在高4位填充0。
- 得到8位结果:
0000 1011,其值仍然是11。
重要提示:零扩展仅对解释为无符号数的二进制序列保持值不变。如果同一个二进制序列(如1011)被解释为有符号的二进制补码数(值为-5),那么零扩展后的结果(00001011,值为11)将不再是原来的-5。因此,选择正确的扩展方式取决于你对数据的解释。

本节课中我们一起学习了数字的扩展。我们了解到,对于有符号的二进制补码数,应使用符号扩展,即复制符号位到高位;对于无符号数,应使用零扩展,即在高位填充0。理解并正确应用这两种方法,是进行不同位宽数据间操作的基础。
009:逻辑门 🧠

在本节课中,我们将要学习数字电路的基本构建模块——逻辑门。我们将了解不同类型的逻辑门,它们的功能、符号表示以及如何用布尔方程和硬件描述语言来描述它们。
逻辑门概述
逻辑门是接收0和1作为输入,并输出0和1的电路元件。它们可以实现各种不同的逻辑功能。
最简单的逻辑门只接收单个输入并产生单个输出。
单输入逻辑门
以下是两种基本的单输入逻辑门。
非门
非门有一个输入A和一个输出Y。其符号是一个三角形加一个圆圈,圆圈表示取反操作。
其布尔方程为:
Y = A' (通常读作“Y等于非A”)
非门的输出与输入相反。如果输入是0,则输出是1。如果输入是1,则输出是0。
缓冲器
缓冲器的输出直接跟随输入。如果输入是0,则输出是0。如果输入是1,则输出是1。
其布尔方程为:
Y = A
从纯逻辑的角度看,缓冲器似乎没有作用。但从电气角度看,缓冲器可能很有用。例如,它们可以输出相同的逻辑值,但提供不同的电压电平,或者以更大的功率驱动电机或灯泡等负载。
双输入逻辑门
上一节我们介绍了单输入逻辑门,本节中我们来看看更常见的双输入逻辑门,它们的功能更加丰富。
以下是几种主要的双输入逻辑门。
-
与门:仅当两个输入都为真(1)时,输出才为真(1)。
- 布尔方程:Y = A · B (写作乘法形式)
- 真值表:0 AND 0 = 0;0 AND 1 = 0;1 AND 0 = 0;1 AND 1 = 1。
-
或门:如果任意一个或两个输入为真(1),则输出为真(1)。
- 布尔方程:Y = A + B (写作加法形式,但注意:1 OR 1 = 1)
- 真值表:0 OR 0 = 0;0 OR 1 = 1;1 OR 0 = 1;1 OR 1 = 1。
-
异或门:当且仅当一个输入为真(1)而另一个为假(0)时,输出为真(1)。如果两个输入相同,则输出为假(0)。
- 布尔方程:Y = A ⊕ B
- 真值表:0 XOR 0 = 0;0 XOR 1 = 1;1 XOR 0 = 1;1 XOR 1 = 0。
-
与非门:这是与门的取反。其输出与与门相反。
- 布尔方程:Y = (A · B)'
- 真值表:0 NAND 0 = 1;0 NAND 1 = 1;1 NAND 0 = 1;1 NAND 1 = 0。
-
或非门:这是或门的取反。其输出与或门相反。
- 布尔方程:Y = (A + B)'
- 真值表:0 NOR 0 = 1;0 NOR 1 = 0;1 NOR 0 = 0;1 NOR 1 = 0。
-
同或门:这是异或门的取反。其输出与异或门相反。
- 布尔方程:Y = (A ⊕ B)'
- 真值表:0 XNOR 0 = 1;0 XNOR 1 = 0;1 XNOR 0 = 0;1 XNOR 1 = 1。
多输入逻辑门
逻辑门可以扩展到两个以上的输入。以下是多输入逻辑门的定义。
- 多输入与门:仅当所有输入都为真(1)时,输出才为真(1)。
- 多输入或门:如果任意一个或多个输入为真(1),则输出为真(1)。
- 多输入或非门:仅当所有输入都为假(0)时,输出才为真(1)。
- 多输入异或门:其定义与双输入略有不同。多输入异或门的输出为真(1),当且仅当输入中为真(1)的数量是奇数。例如,对于三输入异或门,当恰好有一个输入为真,或所有三个输入都为真时,输出为真。
用硬件描述语言描述逻辑门
绘制原理图很有帮助,但在键盘上操作不便。因此,我们经常需要用文本来描述逻辑门。
在本课程中,我们将使用一种称为SystemVerilog的硬件描述语言。这是一种行业标准,广泛用于描述硬件。
以下是一些描述逻辑门的示例。假设我们想定义一些逻辑门,有输入A、B、C和来自五个不同门的五个输出。
module example_gates (
input logic A, B, C,
output logic Y1, Y2, Y3, Y4, Y5
);
// 非门,名为g1,输入A,输出Y1
not g1(Y1, A);
// 与门,名为g2,输入A和B,输出Y2
and g2(Y2, A, B);
// 或门,名为g3,输入A、B和C,输出Y3
or g3(Y3, A, B, C);
// 与非门,名为g4,输入A和B,输出Y4
nand g4(Y4, A, B);
// 异或门,名为g5,输入A和B,输出Y5
xor g5(Y5, A, B);
endmodule

这段代码是对之前讨论的逻辑门原理图的文本描述。
历史背景:乔治·布尔
与、或、非这些操作可以追溯到英国数学家乔治·布尔。他生活在19世纪上半叶。布尔出身于工人阶级家庭,父母无力送他上学。但令人瞩目的是,他自学了数学,并且学得非常好,以至于后来在爱尔兰女王学院获得了教职。
布尔撰写了一篇名为《思维规律的研究》的论文。他认为,人类思维运作的方式是,我们考虑那些非真即假的命题,并通过组合这些命题来得结论。事后看来,人脑的运作方式可能并不像布尔认为的那样理性。但他为二进制变量、真与假的概念奠定了基础,并引入了与、或、非这三种基本的逻辑运算。
本节课中我们一起学习了逻辑门,它们是数字设计的基石。我们了解了各种单输入和双输入逻辑门的功能与表示方法,探讨了多输入逻辑门的扩展,并初步接触了用SystemVerilog硬件描述语言来描述这些门电路。最后,我们还回顾了逻辑运算的历史起源。掌握这些基本概念是理解更复杂数字系统设计的第一步。
010:晶体管 🔌

在本节课中,我们将学习CMOS晶体管。逻辑门是由晶体管构建的,而晶体管可以被视为一种三端口、电压控制的开关。
晶体管概述
一个普通的开关(如电灯开关)是一个两端设备,其两侧根据开关的拨动状态连接或断开。晶体管则拥有第三个端口,它接收一个电压,用于控制开关的导通或关断。
这种晶体管被称为MOS晶体管。它有两个称为源极和漏极的端口,它们是否连接取决于栅极的控制电压。
- 当栅极为低电平时,源极和漏极断开,晶体管关断。
- 当栅极为高电平时,源极和漏极连接,晶体管导通。
集成电路与硅的化学性质
集成电路由罗伯特·诺伊斯等人共同发明。诺伊斯被称为“硅谷市长”,他于1957年共同创立了仙童半导体公司。许多早期仙童的工程师后来创立了硅谷其他重要的公司,其中最著名的是英特尔。诺伊斯和他的同事于1968年离开仙童,创立了后来成为英特尔的公司。
晶体管由硅制成,硅是一种半导体。纯硅本身导电性很差。它形成金刚石晶格结构,每个硅原子在其价电子层有四个电子,并与四个相邻原子成键。由于所有键都被占用,晶格中没有自由电子可以轻易移动。
但是,如果我们引入一些杂质,这个过程称为掺杂,硅就可以变成良导体。
以下是两种主要的掺杂类型:
- N型硅:如果添加第五族元素(如砷),砷有五个价电子,会有一个电子变得松散,可以在晶格中自由移动,留下带正电的砷离子。这种由带负电的电子导电的半导体称为N型硅。
- P型硅:如果添加第三族元素(如硼),硼只有三个价电子,它会从邻近原子“借”一个电子,产生一个带负电的硼离子和一个带正电的“空穴”。这个空穴可以像正电荷一样在晶格中移动。这种半导体称为P型硅。
MOS晶体管的工作原理
当今最常见的晶体管类型是金属氧化物半导体场效应晶体管。它由多层结构堆叠而成。
- 栅极:顶部的终端,历史上由金属制成,后来使用多晶硅,现在又开始使用金属。
- 绝缘层:中间是二氧化硅层,它是一种优良的绝缘体。
- 衬底:底部是硅衬底,通常为P型。
- 源极和漏极:在栅极两侧的衬底中,通过掺杂形成N型区域。
当栅极电压为0时,源极、衬底和漏极之间没有导电路径,晶体管关断。
当在栅极施加正电压时,会在栅极上产生正电荷。由于栅极和衬底被绝缘层隔开,形成了一个电容器。正电压会吸引电子到栅极下方的沟道中。这样,源极(N型)、沟道(电子聚集)和漏极(N型)之间就形成了导电路径,电流可以流动,晶体管导通。
这种晶体管称为N型MOS晶体管,因为其导电是通过N型硅中的电子实现的。
P型MOS晶体管
P型MOS晶体管则相反。它拥有P型源极和漏极,以及N型衬底。
- 当栅极为低电压时,晶体管导通。
- 当栅极为高电压时,晶体管关断。
其符号与NMOS类似,但在栅极上有一个小圆圈,表示它在栅极为0时导通。
晶体管特性总结
我们可以将晶体管视为一个三端开关,栅极控制开关状态,源极和漏极根据开关状态连接或断开。
以下是两种晶体管的特性总结:
- NMOS晶体管:
- 栅极 = 0:晶体管关断,源极和漏极断开。
- 栅极 = 1:晶体管导通,源极和漏极连接。
- PMOS晶体管:
- 栅极 = 0:晶体管导通,源极和漏极连接。
- 栅极 = 1:晶体管关断,源极和漏极断开。
从物理特性来看,NMOS晶体管擅长传递低电平(0),能很好地将输出拉低至地电位。PMOS晶体管则擅长传递高电平(1),能很好地将输出拉高至电源电压。
构建逻辑门
基于以上特性,我们可以构建逻辑门。逻辑门有输入和输出。
- 我们会在输出端和地之间构建一个NMOS晶体管网络。NMOS晶体管擅长将输出拉低至0。
- 我们会在输出端和电源之间构建一个PMOS晶体管网络。PMOS晶体管擅长将输出拉高至1。
通过组合这两种网络,我们可以实现各种逻辑功能。

本节课中,我们一起学习了晶体管的基础知识。我们了解了晶体管作为电压控制开关的基本概念,探讨了硅的半导体特性以及N型和P型掺杂的原理。我们详细分析了NMOS和PMOS晶体管的结构、工作原理和电气特性,并理解了它们各自在传递高、低电平方面的优势。最后,我们知道了如何利用这两种晶体管来构建实现逻辑功能的网络。这些知识是理解后续数字电路设计的基础。
011:从晶体管构建逻辑门 🧠

在本节课中,我们将学习如何利用晶体管来构建基本的逻辑门电路。我们将从非门开始,逐步构建与非门、或非门,并了解如何组合它们来实现更复杂的逻辑功能,如与门。最后,我们将探讨传输门的概念,并回顾半导体领域著名的摩尔定律。
上一节我们介绍了晶体管的基本工作原理,本节中我们来看看如何用它们搭建逻辑门。
构建非门
非门的逻辑是:当输入A为0时,输出Y应为1;当输入A为1时,输出Y应为0。
我们可以使用一个NMOS晶体管和一个PMOS晶体管来构建非门。NMOS连接在输出端Y和地之间,PMOS连接在输出端Y和电源之间。
- 当 A = 0 时,PMOS导通,NMOS截止。电流路径从电源经PMOS到达输出Y,没有路径连接到地。因此,Y = 1。
- 当 A = 1 时,PMOS截止,NMOS导通。电流路径从输出Y经NMOS到达地,没有路径连接到电源。因此,Y = 0。
这样,我们就用两个晶体管成功构建了一个非门。
构建与非门
接下来,我们构建一个两输入与非门。与非门的逻辑是:仅当所有输入都为1时,输出才为0。
以下是构建方法:
- 在输出Y和地之间,串联两个NMOS晶体管。
- 在输出Y和电源之间,并联两个PMOS晶体管。
让我们分析所有输入组合下的情况:
- 当 A = 0, B = 0 时,两个PMOS都导通,两个NMOS都截止。存在从电源到Y的路径(实际上有两条并联路径),没有路径到地。因此,Y = 1。
- 当 A = 0, B = 1 时,PMOS P1导通,NMOS N1截止,N2导通。存在从电源经P1到Y的路径。虽然N2导通,但由于N1截止,无法形成到地的完整路径。因此,Y = 1。
- 当 A = 1, B = 0 时,情况类似。PMOS P2导通,NMOS N2截止。存在从电源到Y的路径,没有到地的完整路径。因此,Y = 1。
- 当 A = 1, B = 1 时,两个PMOS都截止,两个NMOS都导通。没有从电源到Y的路径,但存在从Y经串联的N1和N2到地的完整路径。因此,Y = 0。
这样,我们就成功构建了一个与非门。
构建或非门与组合逻辑门
现在,我们来看看如何构建一个三输入或非门。或非门的逻辑是:仅当所有输入都为0时,输出才为1。
构建思路如下:
- 为了仅在所有输入为0时输出高电平,我们需要三个PMOS晶体管串联在电源和输出之间。
- 为了在任何输入为1时输出低电平,我们需要三个NMOS晶体管并联在输出和地之间。
然而,我们无法直接用这种“上拉-下拉”结构直接构建一个像与门这样的同相逻辑门。因为在这种结构中,输入为1会使输出下拉(变低),输入为0会使输出上拉(变高),这与与门的逻辑不符。
解决方法是组合我们已经构建好的门。例如,一个两输入与门可以通过一个与非门后接一个非门来实现。
公式: AND = NOT(NAND)
构建传输门
之前提到,NMOS传输高电平效果差,PMOS传输低电平效果差。为了构建一个能有效传输0和1的开关,我们需要将两种晶体管结合使用,这称为传输门。
构建方法如下:
- 将一个NMOS和一个PMOS并联,连接在输入A和输出B之间。
- 使能信号
EN连接到NMOS的栅极。 - 使能信号的反相
EN'连接到PMOS的栅极。
工作原理:
- 当 EN = 1 时,
EN' = 0。NMOS和PMOS同时导通,输入A与输出B连通。 - 当 EN = 0 时,
EN' = 1。NMOS和PMOS同时截止,输入A与输出B之间断开。
摩尔定律
半导体领域一个非常重要的人物是戈登·摩尔。他在1968年与罗伯特·诺伊斯共同创立了英特尔公司。早在1965年,摩尔观察了芯片上的晶体管数量,发现它大约每年翻一番。他在半对数坐标纸上绘制了这一趋势,并预测如果持续下去,芯片上的晶体管数量将呈指数级增长,这将带来惊人的变化。
这种增长后来略有放缓,但自1975年以来,芯片上的晶体管数量大约每两年翻一番。因此,我们从20世纪60年代芯片上的几个晶体管,发展到70年代的数百个,再到如今(2020年)的数百亿个。这就是著名的摩尔定律:芯片上可容纳的晶体管数量呈指数级增长。
该定律的一些推论是:晶体管变得更小、更快、功耗更低且更便宜。这形成了一个良性循环,使得芯片性能不断提升。
下图展示了一些英特尔处理器的历史数据:
- 4004是第一款微处理器,于1971年左右推出,集成了约2000个晶体管。
- 到2006年,处理器已拥有超过10万个晶体管。
- 到2020年,单个芯片上的晶体管数量已接近100亿。
在过去的五个十年里,这一指数增长一直非常稳定,这在技术史上是独一无二的。罗伯特·科利曾有一个著名的比喻:如果汽车行业遵循与摩尔定律相同的发展周期,那么今天的劳斯莱斯售价将仅为100美元,每加仑汽油可行驶一百万英里,而汽车每年会爆炸一次。

本节课中我们一起学习了如何用晶体管构建基本逻辑门(非门、与非门、或非门),了解了通过组合简单门(如与非门+非门)可以实现更复杂的逻辑功能(如与门)。我们还认识了能有效传输0和1的传输门,并回顾了描述集成电路发展速度的摩尔定律及其深远影响。
012:数字电路的功耗 💡

在本节课程中,我们将学习数字电路功耗的基本概念。功耗是衡量电路能量消耗速率的关键指标,理解它对于设计高效、节能的电子系统至关重要。
概述
功耗由两部分组成:动态功耗和静态功耗。动态功耗源于电路开关时电容的充放电过程,而静态功耗则是在电路不进行任何操作时持续存在的能量消耗。
动态功耗
上一节我们介绍了功耗的基本定义,本节中我们来看看动态功耗的具体计算。在电子学中,晶体管栅极可以等效为电容。这个电容由金属栅极、绝缘的二氧化硅层和导电沟道构成。
为电容从地电平充电到电源电压 VDD 所需的能量为 C * V^2。假设电路以频率 F 运行,即每秒 F 个周期,并且电容平均每秒充电 α 次(因为从1放电到0是免费的),那么消耗的功率就是每次充电的能量 C * V^2 乘以每秒充电的次数 α * F。
因此,动态功耗的公式为:
P_dynamic = α * C * V^2 * F
活动因子详解
以下是关于活动因子 α 的进一步说明。活动因子是电容被充电的周期所占的比例。
- 时钟信号:时钟在每个周期内上升和下降一次。因此,每个周期电容都会充电和放电各一次,活动因子为 1。
- 周期性切换的数据信号:如果一个数据信号在每个周期都从0变到1或从1变到0,那么一半的周期在充电,另一半在放电,活动因子为 0.5。
- 随机数据:对于真正的随机数据,大约每两个周期发生一次切换事件,且其中一半是充电事件。因此,只有四分之一的周期在为电容充电,活动因子为 0.25。
在实际的数字系统中,连续周期的数据值之间通常存在相关性,它们更可能保持不变。在这种情况下,活动因子会更低。对于数字逻辑电路,10% 的活动因子相当常见。
静态功耗
静态功耗是在没有门电路切换时也持续消耗的功率。这由电源的静态电流(也称为漏电流)引起。在现代芯片中,静态功耗主要源于晶体管尺寸变得非常小,即使在试图关闭时也无法完全关断,仍会有纳安或皮安级的漏电流。当乘以芯片上数十亿个晶体管时,其总量就变得相当可观。
静态功耗的计算公式为:
P_static = I_static * V
单位回顾
了解相关单位对计算很有帮助,因为芯片的参数通常数值很小、频率很高。
- 电容:通常在飞法(
10^-15F)到皮法(10^-12F)量级。 - 电流:通常在微安(
10^-6A)到纳安(10^-9A)量级。 - 频率:通常在兆赫兹(百万次/秒)到吉赫兹(十亿次/秒)量级。
功耗估算实例
假设我们有一部正在运行“愤怒的小鸟”游戏的手机,我们来估算其功耗。
给定参数:
- 电源电压
V = 0.8 V - 平均开关电容
C = 5 nF(5 * 10^-9 F) - 工作频率
F = 2 GHz(2 * 10^9 Hz) - 活动因子
α = 10%(0.1) - 静态电流
I_static = 100 mA(0.1 A)
计算动态功耗:
P_dynamic = 0.1 * (5e-9) * (0.8)^2 * (2e9) = 0.64 W
计算静态功耗:
P_static = 0.1 * 0.8 = 0.08 W
总功耗:
P_total = P_dynamic + P_static = 0.64 + 0.08 = 0.72 W
通常,静态功耗远小于动态功耗。手机在运行时消耗约 0.72 W 的功率。
电池续航估算
大多数时候,手机处于口袋中的空闲状态,动态功耗很低。假设手机电池容量为 8 Wh(瓦时),我们想估算其待机时间。
首先计算空闲时的功耗(主要是静态功耗):
P_idle ≈ P_static = 0.08 W
然后计算电池续航时间:
电池寿命 = 电池容量 / 功耗 = 8 Wh / 0.08 W = 100 小时
如果手机完全处于空闲状态且不被使用,电池续航时间约为100小时,即超过4天。
总结

本节课中我们一起学习了数字电路功耗的核心概念。我们了解到总功耗由 动态功耗(P_dynamic = α * C * V^2 * F)和 静态功耗(P_static = I_static * V)两部分组成。动态功耗与电容、电压平方、频率及活动因子成正比;静态功耗则由漏电流引起。通过一个手机功耗的实例,我们演示了如何应用这些公式进行估算,这对于设计低功耗电子系统具有重要意义。
013:组合逻辑设计简介 🧠
在本章中,我们将学习组合逻辑设计。组合逻辑是数字系统的基础,其输出仅取决于当前输入的值。我们将从第1章介绍的逻辑门知识出发,逐步构建更复杂的逻辑功能。
概述 📋
本节课我们将要学习组合逻辑电路。这些电路的特点是,其输出完全由当前的输入值决定。我们将从布尔方程开始,学习如何描述和简化逻辑关系,然后探讨如何将这些方程转化为实际的逻辑门电路。此外,我们还会介绍一些用于简化和分析逻辑的工具与基本构件。

从布尔方程开始 ➕
上一节我们介绍了组合逻辑的基本概念,本节中我们来看看其数学描述的基础——布尔方程。
组合逻辑可以用布尔方程来描述。这些方程使用变量(代表输入和输出)以及逻辑运算符(如与、或、非)来定义0和1之间的关系。
例如,一个简单的与门功能可以表示为:
Y = A · B
其中 Y 是输出,A 和 B 是输入,· 代表逻辑与操作。
为了有效地处理这些方程,我们需要一套规则来简化它们。
布尔代数的公理与定理 📐
为了简化布尔方程,我们需要借助布尔代数的公理和定理。这些规则允许我们以数学方式操作逻辑表达式,从而得到更简单、更高效的电路实现。
以下是布尔代数的一些基本定律:
- 恒等律:
A + 0 = A,A · 1 = A - 零元律:
A + 1 = 1,A · 0 = 0 - 互补律:
A + A' = 1,A · A' = 0 - 交换律:
A + B = B + A,A · B = B · A - 结合律:
A + (B + C) = (A + B) + C,A · (B · C) = (A · B) · C - 分配律:
A · (B + C) = (A · B) + (A · C),A + (B · C) = (A + B) · (A + C) - 吸收律:
A + (A · B) = A,A · (A + B) = A - 德摩根定理:
(A + B)' = A' · B',(A · B)' = A' + B'
从方程到逻辑门 ⚙️
在掌握了如何用方程描述逻辑后,下一步就是将其转化为由物理逻辑门组成的实际电路。除了表示真(1)和假(0)的信号,在实际电路中还会遇到其他两种重要的信号状态。
在数字电路设计中,我们除了处理0和1,还需要理解另外两种信号状态:
- X(未知/冲突):表示信号值未知或存在驱动冲突。
- Z(高阻态):表示输出端处于断开或“浮动”状态,常见于三态门。
卡诺图:图形化简化工具 🗺️
对于包含多个变量的复杂布尔方程,手动应用定理进行简化可能很繁琐。卡诺图提供了一种直观的图形化方法。
卡诺图是一种将真值表信息重新排列成网格的工具,通过识别相邻的“1”格(或“0”格)来直观地找到可以合并的乘积项,从而系统地简化布尔方程。
组合逻辑基本构件 🧱
除了基本门电路,设计者通常会使用一些预先定义好的、功能更复杂的组合逻辑模块作为“构件”。其中两个最常用的是多路选择器和译码器。
以下是两个关键组合逻辑构件的介绍:
- 多路选择器:根据选择信号的值,从多个输入中选择一个传送到输出。其功能类似于一个单刀多掷开关。
- 译码器:将输入的n位二进制代码,转换为2^n个输出线中的某一个有效(通常为1)。例如,一个2-4译码器可以将2位输入(00, 01, 10, 11)分别使能对应的4个输出。
时序分析 ⏱️
设计一个正确的电路还不够,我们通常还希望它运行得足够快。因此,理解信号通过逻辑门所需的传播时间至关重要。

最后,我们将关注时序问题。我们需要设计不仅能正确工作,还能快速工作的电路。这涉及到分析信号从输入传播到输出所需的时间(传播延迟),以及确保电路在时钟信号下能稳定工作。
总结 🎯
本节课中我们一起学习了组合逻辑设计的基础。我们从描述逻辑关系的布尔方程出发,学习了用于简化方程的布尔代数定理。接着,我们探讨了如何将方程映射到实际的逻辑门电路,并认识了X和Z两种特殊信号状态。我们介绍了用于简化逻辑的图形化工具——卡诺图,以及多路选择器和译码器这两个重要的组合逻辑构件。最后,我们指出了时序分析对于设计高速电路的重要性。这些知识为我们设计和分析更复杂的数字系统奠定了坚实的基础。
014:组合电路

在本节课中,我们将要学习组合电路的基本概念、定义及其必须遵守的规则。组合逻辑是数字系统的基础,它没有记忆功能,输出完全由当前的输入决定。
电路与组合电路的定义
上一节我们介绍了课程主题,本节中我们来看看电路和组合电路的正式定义。
一个逻辑电路是一个由输入、输出、功能规范和时序规范定义的设备。功能规范定义了输出与输入之间的关系。例如,当所有输入都为低电平时,输出可能为高电平。时序规范则说明了输出响应输入变化所需的时间。
我们可以将电路视为一个由节点和元件组成的图。例如,一个电路可能包含三个名为A、B、C的输入节点,两个名为Wanzi的输出节点,以及一个名为N1的内部节点。该电路包含三个元件:E1、E2和E3,每个元件本身也是一个电路。它们可以是一个逻辑门,也可以是由逻辑门网络构成的更复杂的电路。因此,电路是一个递归定义,其元件本身也是电路。
逻辑电路主要有两种类型:组合逻辑和时序逻辑。组合逻辑没有记忆功能,其输出完全由输入的当前值决定,这也是本章的重点。时序逻辑则包含其他所有情况,其输出依赖于输入的先前值和当前值,因此具有记忆功能。
组合电路的规则
为了使一个电路成为组合电路,它必须遵守以下规则。
以下是组合电路必须满足的三条核心规则:
- 每个元件本身必须是组合的。如果电路由逻辑门构成,那么它们是组合的。但如果由触发器、静态RAM单元等构成,则不是组合元件,因此该电路也不是组合的。
- 电路中的每个节点必须是输入节点,或者必须恰好连接到一个输出。输入节点连接到外部输入源。内部节点和输出节点都必须恰好连接到一个元件的输出端。
- 电路不能包含循环路径。即不能存在从输出端返回到输入端的路径。
如果一个电路满足所有这些规则,那么它就是组合电路。
规则应用示例
让我们通过一个例子来理解这些规则。假设我们有一个由三个组合元件(如逻辑门)构成的电路,其输入、内部节点和输出节点的连接都符合上述规则,那么该电路是组合的。
反之,如果我们将两个元件的输出端短路连接在一起(形成一个共享节点),那么这个节点就连接到了两个输出,违反了第二条规则,因此电路不再是组合的。
同样,如果我们将一个输出信号反馈连接到一个输入,这就创建了一个从输出回到输入的循环路径,违反了第三条规则,该电路也不是组合电路。
总结

本节课中我们一起学习了组合电路的核心概念。我们明确了组合逻辑的定义,即输出仅取决于当前输入的无记忆逻辑。我们详细阐述了构成组合电路必须满足的三条规则:所有元件本身必须是组合的;每个节点必须是输入或仅连接到一个输出;以及电路中不能存在循环路径。理解这些规则是设计和分析更复杂数字系统的基础。
015:布尔方程与SOP/POS形式 📚

在本节课中,我们将学习布尔方程,以及如何用“积之和”与“和之积”这两种标准形式来描述逻辑功能。布尔方程是定义数字逻辑模块功能的核心工具。
布尔方程简介
布尔方程是一种非常有用的方式,用于为逻辑模块提供功能规范。它们描述了输出如何依赖于输入项。
例如,在这个模块中,我们有三个输入,分别命名为 A、B 和 Cin,以及两个输出,命名为 S 和 Cout。每个输出都是三个输入变量的布尔函数。
我们可以这样描述:
- S = A ⊕ B ⊕ Cin
- Cout = (A AND B) OR (A AND Cin) OR (B AND Cin)
这就是用布尔方程描述的功能规范。
核心概念定义
在深入之前,我们先定义一些核心概念,这些概念将有助于后续的理解。
- 补码:一个变量上方带有一条横线。如果 A、B、C 是我们的变量,那么
A̅、B̅、C̅就是它们的补码。 - 文字:一个变量或其补码。例如,对于变量 A、B、C,有 6 个文字:
A、A̅、B、B̅、C、C̅。 - 蕴含项:文字的乘积,即文字的“与”运算。例如,
A B C̅、A̅ C、B C、B̅都是蕴含项。 - 最小项:一个包含所有输入变量(以原变量或补码形式出现)的乘积项。例如,
A B C̅是一个最小项,因为它包含了 A、B 和 C̅。而B C不是最小项,因为它没有包含 A 或 A̅。 - 最大项:文字的“或”运算,且包含所有输入变量。例如,
A OR B̅ OR C是一个最大项。而B̅ OR C不是最大项,因为它没有包含 A 或 A̅。
积之和形式
任何布尔方程都可以写成积之和形式。在这种形式中,我们使用最小项的和。
在真值表中,我们可以将每一行与一个最小项关联起来。例如,在这个真值表中:
- 第一行 (A=0, B=0) 对应最小项
A̅ B̅。 - 第二行 (A=0, B=1) 对应最小项
A̅ B。 - 第三行 (A=1, B=0) 对应最小项
A B̅。 - 第四行 (A=1, B=1) 对应最小项
A B。
假设我们有一个真值表,其输出 Y 具有特定的模式。为了找到其积之和形式的布尔方程,我们只需圈出输出 Y 为真的行,并读出这些行对应的最小项。
例如,如果 Y 在第一行和第三行为真,那么对应的最小项是 A̅ B̅ 和 A B̅。因此,这个布尔函数的积之和方程为:
Y = (A̅ B̅) OR (A B̅)
观察这个方程,你可能会发现它可以简化为 B̅。我们稍后将讨论系统化的简化方法。
和之积形式
任何布尔方程也可以写成和之积形式。在这种形式中,我们使用最大项的积。
对于真值表的每一行,我们可以给出一个最大项,来描述“不在这行”的条件。例如:
- 对于行 (0, 0),对应的最大项是
A OR B。因为如果 A 为真或 B 为真,我们就不在这一行。 - 对于行 (0, 1),对应的最大项是
A OR B̅。 - 对于行 (1, 0),对应的最大项是
A̅ OR B。 - 对于行 (1, 1),对应的最大项是
A̅ OR B̅。
接下来,我们圈出所有输出为 0 的行。我们可以说,输出 Y 为真,当且仅当我们不在任何这些被圈出的行上。
因此,如果第一行和第三行输出为 0,对应的最大项是 (A OR B) 和 (A̅ OR B)。那么 Y 的和之积方程为:
Y = (A OR B) AND (A̅ OR B)
和之积形式通常不如积之和形式直观,也不是描述电路的常用方式,因此我们通常使用积之和形式。
实例分析:午餐决策电路
让我们通过一个例子来实践。假设我们想去食堂吃午餐,并需要一个电路来帮助我们做决定。规则是:如果食堂不干净,或者他们只供应肉饼,我们就不吃午餐。
我们构建真值表,输入是“干净”和“有肉饼”,输出是“吃午餐”。
根据规则:
- 不干净,无肉饼:不吃(因为不干净)。
- 不干净,有肉饼:不吃。
- 干净,无肉饼:吃。
- 干净,有肉饼:不吃(因为只有肉饼)。
积之和形式:这很简单。我们圈出唯一输出为 1 的行(第三行)。对应的最小项是:C AND M̅。因此方程为:
Lunch = C AND (NOT M)
(如果干净且没有肉饼,我们就吃午餐。)
和之积形式:我们需要圈出所有输出为 0 的行(第1、2、4行)。对应的最大项分别是:
- 行1:
C OR M - 行2:
C OR M̅ - 行4:
C̅ OR M̅
方程为:
Lunch = (C OR M) AND (C OR M̅) AND (C̅ OR M̅)
(如果这三个条件都满足,我们就吃午餐。)可以看到,这种形式不那么直观。
从文字描述到布尔方程
通常,你可以直接将文字描述翻译成布尔方程。以下是一些例子:
-
去公园:如果不下雨并且我们有三明治,我们就去公园。
Park = (NOT Raining) AND Sandwiches
-
成为赢家:如果我们送你一百万美元或一个记事本,你就是赢家。
Winner = MillionDollars OR Notepad
-
享用美食:如果你自己制作,或者你有一位有才华但不贵的私人厨师,你就能享用美食。
DeliciousFood = MakeItYourself OR (PersonalChef AND Talented AND (NOT Expensive))
-
进入大楼:如果你戴着帽子并穿着鞋,或者你只戴着帽子,就可以进入。
Enter = (Hat AND Shoes) OR Hat(注意:这可以简化为Enter = Hat)
-
进入大楼(变体):如果你戴着帽子并穿着鞋,或者你戴着帽子但没穿鞋,就可以进入。
Enter = (Hat AND Shoes) OR (Hat AND (NOT Shoes))(注意:这同样可以简化为Enter = Hat)
总结

在本节课中,我们一起学习了布尔方程的核心概念及其两种标准形式。我们定义了补码、文字、蕴含项、最小项和最大项。我们重点掌握了如何从真值表推导出积之和方程,这是最直观和常用的形式。我们也了解了和之积形式作为另一种等价的描述方式。最后,我们通过实例练习了如何将日常的文字问题直接转化为布尔逻辑方程。掌握这些是设计和分析数字逻辑电路的基础。
016:布尔公理 📚
在本节中,我们将学习布尔代数的基本公理。布尔代数与常规代数类似,但只对0和1进行操作。它同样有一套公理和定理,可以帮助我们简化布尔方程。布尔代数的公理和定理具有一种迷人的对偶性:如果将“与”和“或”互换,同时将0和1互换,公理或定理依然成立。这被称为对偶性。

接下来,让我们从布尔代数的公理开始。
公理一:二进制域
布尔代数定义在一个二进制域中,这意味着变量只能取两个值:0或1。其数学表达为:
公式: 如果 B ≠ 1,则 B = 0。
这个公理的对偶形式是:如果 B ≠ 0,则 B = 1。这两个公理共同定义了布尔代数的基本取值集合。
公理二:非运算
非运算(取反)的定义如下:
公式: NOT 0 = 1,记作 0̅ = 1。
这个公理的对偶形式是:NOT 1 = 0,记作 1̅ = 0。非运算将一个布尔值转换为其相反值。
公理三:与运算
与运算(AND)的定义需要三个部分。以下是其真值表:
代码:
0 AND 0 = 0
1 AND 1 = 1
0 AND 1 = 1 AND 0 = 0
这个公理的对偶形式定义了或运算(OR),我们将在下一部分看到。
公理四:或运算
或运算(OR)是“与”运算的对偶。其定义如下:
代码:
1 OR 1 = 1
0 OR 0 = 0
1 OR 0 = 0 OR 1 = 1
请注意,这正是将“与”运算公理中的0与1互换、AND与OR互换后得到的结果,完美体现了对偶性。

在本节课中,我们一起学习了布尔代数的四个基本公理:二进制域、非运算、与运算和或运算。我们了解了布尔代数中独特的对偶性原理,它使得公理和定理在交换运算符和常量后依然成立。这些公理是构建更复杂布尔表达式和进行逻辑简化的基石。
017:单变量布尔定理 📚

在本节中,我们将学习布尔代数,特别是关于单变量的定理。这些定理是简化逻辑表达式和设计数字电路的基础。
上一节我们介绍了布尔代数的公理,本节中我们来看看基于这些公理推导出的一系列定理。这些定理描述了单个布尔变量与常量(0和1)及其自身进行逻辑运算时的简化规则。
以下是单变量的五个核心布尔定理。
定理1:同一性定理
- 公式:
B · 1 = B和B + 0 = B - 描述: 任何变量与1进行“与”运算,结果等于变量本身;任何变量与0进行“或”运算,结果也等于变量本身。1是“与”运算的同一性元素,0是“或”运算的同一性元素。
定理2:零元素定理
- 公式:
B · 0 = 0和B + 1 = 1 - 描述: 任何变量与0进行“与”运算,结果恒为0;任何变量与1进行“或”运算,结果恒为1。0是“与”运算的零元素,1是“或”运算的零元素。
定理3:幂等性定理
- 公式:
B · B = B和B + B = B - 描述: 任何变量与其自身进行“与”或“或”运算,结果仍然是该变量本身。
定理4:双重否定定理
- 公式:
!(!B) = B - 描述: 对同一个变量进行两次“非”运算,结果会恢复为原变量。在数字系统中,可以理解为“负负得正”。
定理5:互补性定理
- 公式:
B · !B = 0和B + !B = 1 - 描述: 任何变量与其自身的反变量进行“与”运算,结果恒为0;进行“或”运算,结果恒为1。
为了更直观地理解,我们可以将这些定理对应到逻辑门电路上。
- 同一性定理:一个“与”门,一端输入变量B,另一端接逻辑1(高电平),其功能等效于一根直接输出B的导线。同理,一个“或”门,一端输入B,另一端接逻辑0(低电平),也等效于一根导线。
- 零元素定理:一个“与”门,一端输入B,另一端接逻辑0,其输出恒为0。一个“或”门,一端输入B,另一端接逻辑1,其输出恒为1(相当于直接连接到电源)。
- 幂等性定理:一个两输入端都接同一变量B的“与”门,等效于一根导线。两输入端都接B的“或”门亦然。
- 双重否定定理:两个串联的“非”门(反相器),其功能等效于一根直通导线。
- 互补性定理:一个“与”门,输入端分别接B和!B,输出恒为0。一个“或”门,输入端分别接B和!B,输出恒为1。

本节课中我们一起学习了五个关于单变量的布尔定理:同一性定理、零元素定理、幂等性定理、双重否定定理和互补性定理。这些定理是使用布尔代数进行逻辑简化的基本工具,理解它们对于后续分析更复杂的逻辑电路至关重要。记住,所有这些定理都遵循对偶原理。
018:多变量布尔定理 📚

在本节课程中,我们将学习涉及多个变量的布尔代数定理。这些定理对于简化布尔方程至关重要。我们将介绍这些定理的内容,并学习两种证明它们的方法。
上一节我们介绍了单变量的布尔定理,本节中我们来看看涉及多个变量的、更有趣的定理。
多变量定理
以下是几个重要的多变量布尔定理。
交换律
布尔代数具有交换律,即运算顺序不影响结果。
B AND C = C AND BB OR C = C OR B
结合律
布尔代数具有结合律,即运算分组方式不影响结果。
(B AND C) AND D = B AND (C AND D)(B OR C) OR D = B OR (C OR D)
分配律
布尔代数具有分配律,并且与常规代数不同,它对“与”和“或”运算都适用。
B AND (C OR D) = (B AND C) OR (B AND D)B OR (C AND D) = (B OR C) AND (B OR D)
吸收律
吸收律允许我们消除不必要的变量以简化表达式。
B AND (B OR C) = BB OR (B AND C) = B
合并律
合并律允许我们将两个具有互补变量的项合并为一项。
(B AND C) OR (B AND NOT C) = B(B OR C) AND (B OR NOT C) = B
一致律
一致律指出,如果一个表达式包含 B AND C、NOT B AND D 和 C AND D 三项,那么最后一项是冗余的。
(B AND C) OR (NOT B AND D) OR (C AND D) = (B AND C) OR (NOT B AND D)
定理的证明方法
要证明这些定理,有两种通用方法。
完美归纳法
完美归纳法意味着尝试所有可能的变量组合(0和1)。由于变量数量有限,这种方法简单直接。
代数推导法
这种方法更传统,通过应用其他已知的定理和公理来逐步简化方程,直到证明两边相等。
证明示例
让我们通过一个例子来实践这两种证明方法。
证明吸收律:B AND (B OR C) = B
使用完美归纳法证明:
我们可以列出所有可能的 B 和 C 组合,并计算两边的值。通过对比真值表,可以发现两边的结果在所有情况下都完全相同,从而证明定理成立。
使用代数推导法证明:
我们可以通过一系列已知定理进行推导:
- 从
B AND (B OR C)开始。 - 应用分配律:
(B AND B) OR (B AND C)。 - 应用幂等律:
B OR (B AND C)。 - 应用同一律(
B = B AND 1):(B AND 1) OR (B AND C)。 - 应用分配律(逆用):
B AND (1 OR C)。 - 应用零一律:
B AND 1。 - 应用同一律:
B。
推导完成,B AND (B OR C)简化为B。
证明合并律:(B AND C) OR (B AND NOT C) = B
使用代数推导法证明:
- 从
(B AND C) OR (B AND NOT C)开始。 - 应用分配律:
B AND (C OR NOT C)。 - 应用互补律:
B AND 1。 - 应用同一律:
B。
推导完成。
德摩根定律
这是最有趣且非常有用的定理,尤其是在处理与非门和或非门时。
德摩根定律指出:
- 多个变量“与”运算后再取“非”,等于各变量取“非”后再进行“或”运算。
NOT (B AND C AND D) = (NOT B) OR (NOT C) OR (NOT D)
- 多个变量“或”运算后再取“非”,等于各变量取“非”后再进行“与”运算。
NOT (B OR C OR D) = (NOT B) AND (NOT C) AND (NOT D)
简而言之:
- “与”之非 等于 “非”之或。
- “或”之非 等于 “非”之与。

有一个关于德摩根定律的趣闻:一位数字设计专业的学生学习这些内容时感到头晕和头痛。他去看医生,说:“医生,医生,这些‘与’、‘或’、‘非’搞得我头好痛。” 医生说:“好吧,不要吃这片药,也不要吃那片药。” 或者等价地说,“既不吃这片药,也不吃那片药,然后早上给我打电话。”
本节课中我们一起学习了多变量的布尔定理。我们了解到布尔代数像常规代数一样,具有交换律、结合律和分配律。吸收律、合并律和一致律可以帮助我们简化表达式。最后,德摩根定律为我们提供了在“与”、“或”、“非”运算之间进行转换的强大工具,这在电路设计中尤其重要。
019:简化方程与证明定理 🔧
在本节中,我们将学习如何使用布尔代数来简化逻辑方程,并证明一些定理。简化方程是数字设计中的一项核心技能,它可以帮助我们构建更高效、成本更低的电路。

概述
布尔代数简化可以有多重含义。一种常见的定义是生成最小积之和表达式。这意味着得到一个包含最少数量蕴涵项的积之和方程,且每个蕴涵项包含最少的文字。蕴涵项是文字的乘积,而文字是变量或其补码。
然而,简化也可以有其他目标,例如使用最少的逻辑门、实现最低成本或最低功耗。不同的实现技术可能对“最优”有不同的定义。例如,一个最小积之和表达式 Y = A·¬B + ¬A·B 也可以表示为 Y = A ⊕ B(异或)。在大多数实现技术中,异或门可能更简单,但这取决于具体的技术细节。
简化方程示例
上一节我们介绍了简化的概念,本节中我们通过具体例子来看看如何操作。
示例一
假设我们有一个真值表:
| A | B | Y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
通过圈出输出为1的行,我们可以得到积之和表达式:
Y = ¬A·B + A·B
这个表达式显然不是最简单的。通过观察真值表,我们可以发现,只要B为真,输出Y就为真。因此,我们可以直接写出:
Y = B
我们也可以使用结合定理(T10)来证明。表达式 ¬A·B + A·B 中,B是公因子,而 ¬A 和 A 是互补的。根据结合定理,这可以简化为 B。
如果不记得结合定理,也可以使用分配律:
Y = B·(¬A + A)
然后利用互补律 ¬A + A = 1,再根据同一律 B·1 = B,最终得到 Y = B。
示例二
让我们看一个更复杂的例子。假设真值表如下,输出Y在以下行(A, B, C)为1:(0,0,1), (0,1,1), (1,1,0), (1,1,1)。
由此得到的积之和表达式为:
Y = ¬A·¬B·C + ¬A·B·C + A·B·¬C + A·B·C
这个表达式不是最简形式。我们可以运用布尔代数进行简化。以下是一个巧妙的步骤,可能初看并不明显:
首先,利用幂等律 X = X + X,我们可以复制项 A·B·C:
Y = ¬A·¬B·C + ¬A·B·C + A·B·¬C + A·B·C + A·B·C
现在,我们可以对项进行分组和合并:
以下是分组和合并的步骤:
- 将前两项
¬A·¬B·C和¬A·B·C组合。它们只在B上不同(¬B与B),因此可以合并,消去B,得到¬A·C。 - 将后三项
A·B·¬C和两个A·B·C组合。注意A·B·C出现了两次,我们可以将其与A·B·¬C合并。它们只在C上不同(¬C与C),因此可以合并,消去C,得到A·B。
于是,表达式简化为:
Y = ¬A·C + A·B
我们还可以进一步观察,看是否能继续简化。在这个例子中,¬A·C 和 A·B 没有明显的公因子,因此这可能是最简形式之一。
让我们验证一下简化后的逻辑:
Y = ¬A·C:当A为假且C为真时输出为真。对应真值表中的行 (0,0,1) 和 (0,1,1)。Y = A·B:当A为真且B为真时输出为真。对应真值表中的行 (1,1,0) 和 (1,1,1)。
验证结果与原始真值表描述一致。
总结

本节课中我们一起学习了如何使用布尔代数简化逻辑方程。我们了解到,“简化”可以根据不同目标(如最少的蕴涵项、最少的逻辑门)有不同的定义。我们通过两个例子演示了简化的过程:在简单例子中通过观察或应用结合定理直接得到结果;在复杂例子中,通过复制项并分组合并来消去变量。掌握这些技巧对于设计高效的数字电路至关重要。
020:从逻辑到门电路 🧠➡️🔌
在本节中,我们将学习如何将布尔方程转换为实际的逻辑门电路。我们将从简单的例子开始,逐步介绍绘制可读电路图的规则,并探讨多级逻辑和多输出电路的设计。

从布尔方程到门电路
假设我们有一个布尔方程,我们希望在暗巷中实现它,并用逻辑门来构建。我们可以直接解读这个方程。例如,我们有 A and B bar。
所以,这里有一个与门。然后我们将其反转,得到 A and B bar。接下来,我们需要 C and C bar and D, and E。因此,将 C 反转,得到 C bar。然后与 D 和 E 进行与运算。最后,将所有这些结果进行或运算。
绘制可读电路图的规则
在绘制原理图时,有几个规则需要考虑,以确保图纸的可读性。
- 让数据流沿着合理的方向流动。通常,输入在左侧或顶部,流向右侧或底部的输出。
- 门电路通常从左向右流动。
- 尽可能保持导线笔直。在原理图中追踪一团乱麻般的导线非常困难。
以下是关于导线连接和交叉的约定:
- 当两条导线在 T 型节点处汇合时,它们总是连接的。不需要用点来表示连接,因为不连接是没有意义的。
- 当导线交叉时,如果没有点,表示它们不连接。
- 如果交叉处有点,则表示存在连接。
- 通过这种方式,我们可以明确地判断是否存在连接,并且只使用必要的连接点。
两级逻辑示例
让我们看一个两级逻辑的例子。这里有一个积之和表达式,我们来构建一个电路。
我们有输入 A、B 和 C。我喜欢将它们垂直排列,并同时列出它们的反相值:A bar、B bar 和 C bar。
现在,我们需要三个三输入与门。
- 第一个与门接收 A bar、B bar 和 C bar。
- 第二个与门接收 A、B bar 和 C bar。
- 第三个与门接收 A、B bar 和 C。
这样就得到了我们的最小项。然后,我们可以将它们进行或运算。
将输出汇集起来。通过这种方式,我们可以系统地绘制任何积之和表达式。
我们的输出是 Y。输入从左上角开始,向下延伸。输入及其反相值向下排列。然后,我们有一组与门来形成基于这些字面量的乘积项,并产生横向的输出。最后,我们有一组或门,每个输出对应一个,接收这些最小项。
多级逻辑
有时,使用多级更简单的门来构建多级逻辑更为方便。
例如,如果我们想构建一个八输入的异或门,我们可以使用 128 个不同的最小项,每个涉及八个不同的字面量,形成一个巨大的两级积之和电路。但更好的方法是使用仅需 7 个两输入异或门构成的树形结构。
另一个例子是一个由三级与非门和或非门构建的电路。这在芯片上很常见,因为当我们使用 CMOS 技术时,与非门和或非门是自然可用的。
多输出电路示例:优先权电路
现在,让我们看一些多输出电路的例子。以优先权电路为例。
优先权电路是一个盒子,有输入和输出。假设输入 3 具有最高优先级,输入 0 具有最低优先级。我们希望最多只激活一个输出,对应于具有最高优先级的输入。
- 如果 A3 为真,则我们希望 Y3 为真,其他所有输出为 0。
- 如果 A3 为假,但 A2 为真,则 Y2 应为真,Y3 为假,其他输出为假。
- 如果 A2 和 A3 都为假,但 A1 为真,则这是最高优先级,我们激活 Y1。
- 如果 A0 为真,且其他输入都为假,则 Y0 应为真。
- 如果所有输入都不为真,则所有输出都不为真。
这被称为优先权电路,在数字系统中有许多有用的应用。
我们可以为此设计硬件。可以通过一个巨大的积之和表达式,然后应用布尔代数进行简化来推导布尔表达式。但通常,通过观察就可以直接设计。
观察这个逻辑:
- Y3 为真,当 A3 为真时。
- Y2 为真,当 A3 为假且 A2 为真时。
- Y1 为真,当 A3 和 A2 都为假,但 A1 为真时。
- Y0 为真,当 A3、A2 和 A1 都为假,且 A0 为真时。
编写这样的大真值表相当繁琐。因此,另一个选择是利用无关项来简化它。
我们可以这样描述:
- 如果 A3 为真,那么我们不在乎 A2、A1 或 A0 是什么,我们使 Y3 为真,其他为假。这里的 X 表示“无关”。
- 如果 A3 为假,但 A2 为真,那么我们不在乎 A1 或 A0 是什么。Y3 将为假,但 Y2 将为真。
- 如果 A3 和 A2 都为假,A1 为真,那么 A0 是什么无关紧要。我们激活 Y1。
- 如果 A0 是最高优先级,那么我们激活 Y0。
- 如果所有输入都为 0,则输出全为 0。
这就是利用无关项来简化真值表的方法。
总结

在本节中,我们一起学习了如何将布尔方程转换为逻辑门电路。我们介绍了绘制清晰电路图的规则,通过实例练习了两级逻辑的构建,了解了多级逻辑的优势,并探讨了多输出电路(如优先权电路)的设计方法,包括利用“无关项”来简化设计。掌握这些从抽象逻辑到具体门电路的转换技能,是数字硬件设计的基础。
021:气泡推演法 💭
在本节中,我们将学习如何运用德摩根定律,通过“气泡推演”的图形化方法来简化包含与非门及或非门的逻辑表达式。这种方法能帮助我们更直观地理解和分析电路。

概述
德摩根定理指出:一组变量的与非运算,等价于这些变量取反后的或运算。其对偶形式是:一组变量的或非运算,等价于这些变量取反后的与运算。
公式:
(A NAND B) = NOT (A AND B) = (NOT A) OR (NOT B)(A NOR B) = NOT (A OR B) = (NOT A) AND (NOT B)
我们可以应用这一定理来简化包含取反的表达式。通常,我喜欢从最外层的取反开始,逐步向内处理。每当遇到双重取反时,就可以利用双重否定律将其消去。
代数法应用示例
上一节我们回顾了德摩根定律,本节中我们来看看如何具体应用它来化简表达式。
示例一
假设我们有表达式:Y = NOT (A OR (NOT (B AND C)))
- 应用德摩根定律于最外层的取反:
Y = (NOT A) AND (NOT (NOT (B AND C))) - 利用双重否定律消去双重取反:
Y = (NOT A) AND (B AND C) - 合并项,得到最终结果:
Y = (NOT A) AND B AND C
示例二
现在来看一个稍复杂的例子:Y = NOT (A AND (NOT (B AND C)) AND (NOT (NOT A AND NOT B)))
- 首先,对整体应用德摩根定律:
Y = (NOT A) OR (NOT (NOT (B AND C))) OR (NOT (NOT (NOT A AND NOT B))) - 消去第一处双重取反:
Y = (NOT A) OR (B AND C) OR (NOT (NOT (NOT A AND NOT B))) - 对最内层的
(NOT (NOT A AND NOT B))再次应用德摩根定律:Y = (NOT A) OR (B AND C) OR ((NOT (NOT A)) OR (NOT (NOT B))) - 消去所有双重取反:
Y = (NOT A) OR (B AND C) OR (A OR B) - 应用分配律并化简:
(NOT A) AND A结果为0,(NOT A) AND B与B AND C中的B合并,最终得到:Y = (NOT A) AND B AND C
注意:当对 A AND B 应用德摩根定律时,必须写成 (NOT A) OR (NOT B) 并用括号括起来,以遵循运算顺序。
图形化气泡推演法
相较于代数运算,我更喜欢在可能时使用图形化的德摩根定律,即“气泡推演法”。
从图形角度看,一个与非门等价于一个输入端带有气泡(取反)的或门。以下是推演步骤:
- 将输出端的气泡“推”向输入端。
- 气泡出现在所有输入端。
- 门的类型从“与” flavour 变为“或” flavour。
因此,A NAND B 在图形上等价于 (NOT A) OR (NOT B)。为简洁起见,我们通常将气泡直接画在门电路的输入/输出端,而不必处处画出独立的反相器。
同理,一个或非门等价于一个输入端带有气泡的与门。
气泡推演的核心优势在于:当两个气泡在一条线上首尾相连时,它们会相互抵消(依据双重否定律)。这使得我们能够轻松地从电路图直接读出逻辑表达式。
例如,如果一个电路最终简化为 Y = ((A AND B) OR (C AND D)) 的形式,我们可以通过推演气泡使它们对齐并抵消,从而清晰地看出这一点。
复杂电路分析
让我们分析一个更复杂的、包含与非门和或非门的电路。这类电路在设计CMOS电路时非常常见,因为CMOS门本质上产生的是反相信号。
我们的策略是:从输出端开始,逆向朝输入端推演气泡,目标是让气泡成对抵消,从而简化电路。
假设我们有一个初始电路,其输出 Y 没有气泡。我们检查内部,发现一些门有输出气泡,一些没有。
- 首先处理输出级的与非门。将其输出气泡向后推演,这会改变前级门的类型和输入状态。
- 接着,将新出现的气泡继续向前级门推演。
- 在这个过程中,寻找气泡首尾相连的路径。一旦发现,就将它们抵消。
- 重复此过程,直到无法继续推演或电路变得清晰。
通过这样的步骤,原本复杂的电路可能被简化为:Y = (NOT A) AND (NOT B) AND C。气泡推演法使我们能轻松确定一个复杂电路的逻辑功能。
总结

本节课中我们一起学习了“气泡推演法”。我们首先回顾了德摩根定律的代数形式,然后通过示例学习了如何运用它化简表达式。更重要的是,我们掌握了气泡推演这一图形化技巧,它能直观地帮助我们通过移动和抵消逻辑门上的“气泡”(取反符号),来分析和简化包含与非门、或非门的电路,从而直接读出电路的布尔表达式。这种方法在数字电路设计和分析中非常实用。
022:X与Z状态详解 🔌

在本节课程中,我们将学习数字电路中除0和1之外的两种特殊逻辑状态:X(未知/争用)和Z(高阻态/浮空)。理解这些状态对于电路设计、调试和避免潜在问题至关重要。
X状态:未知与争用
上一节我们讨论了正常的逻辑电平0和1。本节中,我们来看看X状态。X通常表示电路输出端存在争用。
想象一个场景:两个反相器的输出端被短接到同一个节点上。这并非一个组合逻辑电路,因为两个输出被短路在了一起。
假设输入A为1,B为0。那么,上方的反相器试图将输出Y驱动为0,而下方的反相器则试图将Y驱动为1。根据这两个反相器的相对驱动能力,最终节点电压可能停留在高电平、低电平,或者处于两者之间的禁止区。
此外,这个最终电压可能随电源电压、温度、器件老化、电源噪声而变化,并且在不同芯片之间也可能不同,这取决于反相器的相对强度。更重要的是,这种争用通常会导致很大的功耗,因为两个器件在相互“对抗”。
我们称这种状态为X。如果在电路仿真中看到X出现,这通常是存在争用的标志,往往意味着设计存在错误。请严肃对待这些X状态并修复它们。
电路仿真中使用X的另一个场景是未初始化的信号。例如,我们稍后会讲到的触发器具有记忆功能。在仿真开始时,我们不知道触发器里存储了什么值,就会用X来表示该值未知。同样,你需要修复这些未初始化的问题。
请注意,在之前的幻灯片中,我们讨论过在输入端使用X作为“无关项”。因此,必须根据上下文来理解X的含义:在输出端,X通常意味着争用;在输入端,X可能意味着“无关”。
争用可能导致功耗问题,如果争用发生在驱动能力很强的输出端之间,甚至可能烧毁引脚。为了纪念这一点,我今天戴了一条特别的领带,上面印有数字设计和本·比特德尔(Ben Bitdiddle)的图案,图中本正在因为电路争用而炸毁他的芯片。
Z状态:高阻态与浮空
现在,让我们转向Z状态。如果一个节点既没有被驱动到高电平,也没有被驱动到低电平,我们就说它处于浮空状态。描述此状态的其他术语还包括高阻抗、开路或高Z。在电路仿真中,我们通常使用Z来表示浮空。
一个浮空的节点可能偶然处于低电压、高电压,或者处于两者之间的禁止区,并且其电压可能不断变化。例如,它可能因为荧光灯的辐射而每秒变化60次,或者在你触摸它时发生变化。
浮空节点的问题是,万用表可能无法指示出该节点处于浮空状态。实际上,万用表可能会干扰该节点,使其达到一个明确的电压值,导致电路在你用表笔接触时工作正常,而一旦移开表笔就停止工作。同样,在实验室测试时可能一切正常,但当指导老师走过来检查时,电路可能就失灵了。因此,无意的浮空节点会带来极大的调试困扰,它们通常在你忘记将导线连接到某个输入端时无意中产生。
浮空状态也可以是有意为之的。我们有一个叫做三态缓冲器的元件。
以下是一个常规反相器和一个三态缓冲器的对比:
- 常规反相器:
Y = NOT(A) - 三态缓冲器:具有输入A、输出Y,以及一个使能信号(EN)。
当使能信号有效(例如EN=1)时,它作为一个常规缓冲器工作:若A=0,则Y=0;若A=1,则Y=1。但当使能信号关闭(例如EN=0)时,无论输入A是什么,输出Y都会进入浮空(高阻态)。这使得我们能够构建一个电路,在其被禁用时让输出端保持断开连接。
三态缓冲器的一个典型应用是构建三态总线。例如,在计算机中有一个共享总线,我们希望处理器、视频系统、以太网控制器或内存都能在某一时刻与总线通信。每个设备都可以连接一个三态缓冲器,由独立的使能信号控制。
只要我们确保在任何给定时刻只有一个使能信号为真,那么总线就由需要通信的那个设备驱动。其他系统的输出则处于浮空状态,因此它们不会与当前驱动总线的设备发生争用或冲突。
总结
本节课中,我们一起学习了数字电路中的两种特殊状态:
- X状态:通常表示输出端存在争用(多个驱动源冲突)或信号未初始化。这通常是设计错误,需要修复。
- Z状态:表示高阻态或浮空,即节点未被任何源驱动。这可能是无意错误(如未连接),也可能是有意设计(如使用三态缓冲器实现总线共享)。

理解并正确管理X和Z状态,对于设计可靠、高效的数字系统至关重要。
023:卡诺图 🗺️

在本节课中,我们将要学习一种用于简化布尔方程的图形化工具——卡诺图。我们将了解其基本概念、如何构建以及如何用它来高效地化简逻辑表达式。
概述
上一节我们讨论了通过积之和或直接观察法来简化真值表。然而,对于复杂的真值表,这些方法可能变得繁琐或困难。本节中,我们来看看卡诺图,它提供了一种直观的视觉方法来简化布尔方程。
卡诺图基于一个核心的布尔代数概念:对于任何表达式 P,P·A + P·A' = P。这意味着变量 A 的真值在此情况下无关紧要。卡诺图通过将变量以特定方式排列,使得相邻的方格仅在一个变量上不同(真或补),从而帮助我们识别并合并这些可以简化的项。
构建三变量卡诺图
让我们从一个三输入的真值表示例开始,学习如何构建卡诺图。
我们将画出八个方格,对应三输入真值表的八行。我们将变量 A 和 B 放在水平轴,变量 C 放在垂直轴。
- C 的值很简单,只有 0 或 1。
- A 和 B 的组合有四种:00, 01, 11, 10。
注意,A 和 B 的排列顺序是 00, 01, 11, 10,而不是自然的二进制顺序。这样排列是为了确保相邻的列(或行)之间只有一个变量的值发生变化。例如,从 00 到 01,只有 B 变化;从 01 到 11,只有 A 变化;从 11 到 10,只有 B 变化;并且从 10 绕回 00,只有 A 变化。
现在,我们可以将真值表中的输出值填入对应的方格中。例如,输入 A=0, B=0, C=0 对应左上角的方格,A=0, B=0, C=1 对应其正下方的方格,依此类推。
填好值后,我们可以圈出输出为 1 的方格。如果相邻的 1 可以组成一个矩形块,并且这个块的大小是 2 的幂(如 1, 2, 4 个方格),那么这些项就可以合并。合并时,在这个块内值保持不变的变量保留,值发生变化的变量则被消去。
在第一个简单例子中,两个为 1 的方格(A'B'C' 和 A'B'C)组成了一个 1x2 的块。在这个块中,A 和 B 始终为 0,而 C 的值在变化。因此,合并后的简化表达式为 Y = A' B'。
一个更复杂的三变量例子
现在,我们来看一个稍复杂的三变量卡诺图例子,以巩固理解。
同样,我们首先根据真值表将输出值填入卡诺图。填好后,我们观察哪些为 1 的方格可以合并。
在这个例子中,我们需要画两个圈来覆盖所有为 1 的方格。
- 第一个圈覆盖了两个垂直相邻的 1。在这个圈里,B=1,C=1,而 A 的值在变化。因此,这个圈对应的项是 B C。
- 第二个圈覆盖了两个水平相邻的 1。在这个圈里,A=0,B=1,而 C 的值在变化。因此,这个圈对应的项是 A' B。
最终的简化表达式是这两个质蕴涵项的逻辑或:Y = B C + A' B。
质蕴涵项与四变量卡诺图
我们引入两个重要概念:
- 蕴涵项:是文字(变量或其反变量)的乘积项。
- 质蕴涵项:是卡诺图中可以画出的最大可能圈所对应的蕴涵项。使用质蕴涵项能实现最大程度的简化。
现在,让我们将概念扩展到四变量卡诺图。四变量卡诺图有 16 个方格,行和列各代表两个变量。构建和简化的核心原则不变:相邻性(包括上下、左右以及四边循环相邻),圈出大小为 2 的幂的矩形块,并且每个圈应尽可能大。
以下是使用卡诺图进行简化的步骤:
- 填图:根据真值表,将所有输出值填入对应方格。
- 画圈:圈出所有包含“1”的方格,每个圈必须是矩形,且包含 1、2、4、8 或 16 个方格。
- 规则:
- 每个“1”至少被一个圈覆盖。
- 每个圈应尽可能大(即寻找质蕴涵项)。
- 圈可以跨越图的边界(循环相邻性)。
- 允许重叠覆盖。
- 读图:对于每个圈,写出对应的乘积项。在圈范围内值保持不变的变量保留(为真则取原变量,为假则取反变量),值发生变化的变量则省略。
让我们分析一个四变量卡诺图的例子。按照上述步骤填图后,我们开始画圈寻找质蕴涵项。
以下是可能画出的圈及其对应的布尔项:
- 一个 2x2 的绿色块:在这个块中,A 始终为 0,C 始终为 1,而 B 和 D 在变化。因此,该项为 A' C。
- 一个 2x1 的蓝色块:在这个块中,A=0,B=1,D=1,而 C 在变化。因此,该项为 A' B D。
- 一个 2x1 的红色块:在这个块中,A=1,B=0,C=0,而 D 在变化。因此,该项为 A B' C'。
- 一个覆盖四个角的特殊圈(利用循环相邻):在这个圈中,B=0,D=0,而 A 和 C 在变化。因此,该项为 B' D'。
最终,将这些所有质蕴涵项相加(逻辑或),就得到了最简的积之和表达式。
总结

本节课中,我们一起学习了卡诺图这一强大的图形化工具。我们了解了它基于 P·A + P·A' = P 的简化原理,掌握了如何为三变量和四变量逻辑函数构建卡诺图,并学习了通过画圈(寻找质蕴涵项)来合并相邻最小项,从而导出最简布尔表达式的系统化步骤。卡诺图通过其视觉直观性,使得逻辑化简过程变得清晰且高效,是数字逻辑设计中的一项基础且重要的技能。
024:带无关项的卡诺图 🗺️
在本节中,我们将学习如何处理带有“无关项”的逻辑函数,并利用卡诺图来找到最简化的积之和表达式。无关项是指在某些输入组合下,输出值可以是0也可以是1,我们对此“不关心”。通过巧妙地利用这些无关项,我们可以合并更大的卡诺图圈组,从而得到更简单的逻辑表达式。

概述
卡诺图是化简逻辑函数的有力工具。当逻辑函数的真值表中存在无关项时,我们可以选择性地将这些无关项视为1或0,以便在卡诺图中形成更大的矩形圈组,从而得到更简化的逻辑表达式。本节课将通过一个具体例子,演示如何操作。
真值表与卡诺图填充
首先,我们有一个四输入变量的真值表,其中部分输出被标记为“无关项”(用X表示)。我们的目标是将此真值表的信息填入卡诺图。
以下是真值表对应的输出值(Y):
- 当输入 A, B, C, D = 0000 时,Y = 1。
- 当输入为 0001 时,Y = 0。
- 当输入为 0010 时,Y = 1。
- 当输入为 0011 时,Y = 0。
- 当输入为 0100 时,Y = 0。
- 当输入为 0101 时,Y = X(无关项)。
- 当输入为 0110 时,Y = 1。
- 当输入为 0111 时,Y = 0。
- 当输入为 1000 时,Y = 1。
- 当输入为 1001 时,Y = 1。
- 当输入为 1010 时,Y = X(无关项)。
- 当输入为 1011 时,Y = X(无关项)。
- 其余输入组合(1100, 1101, 1110, 1111)对应的 Y = 0。
根据以上信息,我们将其填入四变量卡诺图。填图时需要特别注意行列顺序,避免出错。
圈组策略与无关项利用
上一节我们填充了卡诺图,本节中我们来看看如何圈组。我们的原则是:圈出最大的矩形块以覆盖所有标为1的格子,并且可以选择性地将无关项(X)当作1来圈入,如果这有助于形成更大的圈组。我们不必圈入所有的无关项。
观察填充好的卡诺图,我们可以识别出三个主要的圈组机会:
- 一个覆盖了图中部两行的4x2巨型块(蓝色)。
- 一个覆盖了右侧两列的4x2巨型块(红色)。
- 一个覆盖了四个角的2x2块(绿色)。
图中还有一个孤立的无关项(X),圈入它并不会让任何圈组变得更大或更简单,因此我们选择不圈它。
从圈组推导逻辑项
现在,我们将每个圈组转换为对应的乘积项(蕴含项)。
以下是每个圈组对应的变量取值分析:
- 蓝色圈组:在此区域内,A和B取遍了所有可能值(00, 01, 11, 10),因此A和B是“无关的”。C的值始终为1。D的值有0也有1,因此也是“无关的”。所以,这个蓝色块对应的乘积项就是 C。
- 红色圈组:在此区域内,A的值始终为1。B的值有0也有1,是“无关的”。C和D取遍了所有可能值,都是“无关的”。所以,这个红色块对应的乘积项就是 A。
- 绿色圈组(四角):四个角对应的A值有0也有1,是“无关的”。B的值始终为0(即 B')。C的值有0也有1,是“无关的”。D的值始终为0(即 D')。所以,这个绿色块对应的乘积项就是 B' D'。
总结
本节课中,我们一起学习了如何利用卡诺图中的“无关项”来简化逻辑表达式。关键步骤是:将真值表填入卡诺图,然后通过将无关项视为1来帮助形成尽可能大的圈组,最后将每个圈组翻译成对应的乘积项。对于本例,最终得到的最简积之和表达式为:
Y = C + A + B' D'

通过这种方法,我们可以有效地处理具有不完全定义特性的逻辑函数,并得到成本最低的实现方案。
025:多路复用器 🧩
在本节中,我们将学习组合逻辑中的一个重要构建模块:多路复用器。多路复用器是一种能从多个输入信号中选择一个并传递到输出的电路。我们将探讨其工作原理、符号表示以及多种实现方式。

多路复用器简介
多路复用器,常简称为 Mux,其功能是从 n 个不同的输入中选择一个连接到输出端。
例如,一个 2选1多路复用器 有两个输入(D0 和 D1)、一个输出(Y)和一个选择信号(S)。其功能如下:
- 当选择信号 S = 0 时,输出 Y = D0。
- 当选择信号 S = 1 时,输出 Y = D1。
其简化的真值表如下:
| S | Y |
|---|---|
| 0 | D0 |
| 1 | D1 |
对于一个 n选1多路复用器,需要 log₂(n) 个选择位来进行选择。例如,一个4选1多路复用器需要2个选择位,一个8选1多路复用器则需要3个选择位。
多路复用器的实现
既然我们已经了解了多路复用器的功能,接下来我们看看如何用逻辑门来构建它。
方法一:积之和(SOP)实现
我们可以根据真值表,通过卡诺图化简或直接推导出逻辑表达式。对于2选1多路复用器,其输出逻辑为:
Y = (S' · D0) + (S · D1)
这个表达式可以直接转换为门级电路:一个反相器、两个与门和一个或门。
方法二:三态门实现
另一种有趣的实现方式是使用一对三态缓冲器。每个三态缓冲器连接一个输入,并由选择信号控制其使能端。当 S=0 时,连接 D0 的三态门导通;当 S=1 时,连接 D1 的三态门导通。两个三态门的输出端被连接在一起。
注意:这种将两个输出端直接连接的做法,在组合逻辑的常规规则下是违规的。但由于在任何时刻只有一个三态门处于有效输出状态,所以这种设计是可行的,并且在实际中很常见。
构建更大的多路复用器
上一节我们介绍了基础的2选1多路复用器,本节中我们来看看如何构建输入更多的多路复用器,例如4选1多路复用器。
一个4选1多路复用器的符号有四个输入(D0, D1, D2, D3),一个2位的选择信号(S1, S0),以及一个输出(Y)。其功能如下:
- 当 S1S0 = 00 时,选择 D0。
- 当 S1S0 = 01 时,选择 D1。
- 当 S1S0 = 10 时,选择 D2。
- 当 S1S0 = 11 时,选择 D3。
以下是几种实现4选1多路复用器的方法:
- 两级逻辑实现:使用积之和表达式,需要四个与项(每个对应一个输入被选中的条件)和一个或门。
- 三态门实现:使用四个三态门,其输出端连接在一起,通过一个2-4译码器根据选择信号使能其中一个。
- 层次化实现:使用三个2选1多路复用器分两级构建。第一级用两个2选1 Mux分别处理奇数位(D1, D3)和偶数位(D0, D2)输入,由最低位选择信号(S0)控制;第二级用一个2选1 Mux从第一级的两个输出中选一个,由最高位选择信号(S1)控制。
多路复用器作为查找表
多路复用器的一个巧妙用途是实现查找表,从而构建任意的真值表。
如果我们想实现一个具有四行的真值表(对应两个输入变量A和B),我们可以使用一个4选1多路复用器。具体做法是:
- 将输入变量 A 和 B 连接到多路复用器的选择端。
- 根据目标真值表每一行的输出值(0或1),将多路复用器的数据输入端相应地连接到地(GND,逻辑0) 或电源(VDD,逻辑1)。
例如,要实现函数 Y = A' · B,其真值表为:
| A | B | Y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 0 |
| 1 | 1 | 0 |
连接方式如下:
- 当 AB=00(选择00)时,Y应为0,所以 D0 接 GND。
- 当 AB=01(选择01)时,Y应为1,所以 D1 接 VDD。
- 当 AB=10(选择10)时,Y应为0,所以 D2 接 GND。
- 当 AB=11(选择11)时,Y应为0,所以 D3 接 GND。
通过这种方式,只需改变输入端的0/1连接,就可以重新编程该多路复用器以实现任何两变量的逻辑函数。同理,要实现一个三变量表达式的真值表,则需要一个8选1多路复用器。
总结

本节课中我们一起学习了多路复用器。我们首先了解了多路复用器的基本功能:根据选择信号从多个输入中选通一个到输出。然后,我们探讨了用积之和逻辑以及三态门来实现2选1多路复用器。接着,我们学习了如何构建更大的多路复用器(如4选1),并介绍了层次化设计方法。最后,我们发现了多路复用器的一个强大应用:通过将其数据输入端固定为常量0或1,选择端接入输入变量,可以将其配置为一个可编程的查找表,用于实现任意的组合逻辑函数。多路复用器是数字系统中用于数据选择和路由的基础且关键的组件。
数字设计与计算机架构:2.14:译码器 🔌
在本节中,我们将学习组合逻辑电路中的另一个重要构建模块:译码器。译码器是一种能将一组输入编码转换为特定输出信号的电路,在内存寻址和逻辑函数实现中非常有用。

译码器是一种实用的组合电路。它包含 n 个输入和 2^n 个输出。在任何时刻,它都会使这 2^n 个输出中恰好一个变为高电平。因此,我们根据输入从 2^n 种可能性中选择一个输出。
例如,一个 2-4 译码器 有两个输入(通常称为地址位,因为常用于内存寻址),我们称它们为 A0 和 A1。它有四个输出,我们称之为 Y0 到 Y3。
以下是其功能描述:
- 当输入 A 为
00时,输出 Y0 有效(高电平)。 - 当输入 A 为
01时,输出 Y1 有效。 - 当输入 A 为
10时,输出 Y2 有效。 - 当输入 A 为
11时,输出 Y3 有效。
本质上,我们根据两位地址码来选择四个输出中的一个。
上一节我们了解了译码器的基本功能,现在来看看它的一个具体实现。译码器可以通过一组与门来实现,每个输出由输入变量及其反变量的逻辑与运算决定。
具体实现如下:
- Y0 = NOT(A1) AND NOT(A0)
- Y1 = NOT(A1) AND A0
- Y2 = A1 AND NOT(A0)
- Y3 = A1 AND A0
这种结构确保了在任何给定时刻,只有一个输出条件为真。
译码器的一个强大应用是实现任意逻辑函数。我们可以利用译码器生成一个函数的所有最小项,然后通过或门组合这些项来得到输出。这是实现逻辑功能的另一种方法。
假设我们有一个两变量函数:Y = (A AND B) OR (NOT(A) AND NOT(B))。请注意,这个函数等价于 A XNOR B(同或)。
以下是实现步骤:
- 将输入 A 和 B 接入一个 2-4 译码器。
- 译码器会输出四个最小项:m0 (A'B'), m1 (A'B), m2 (AB'), m3 (AB)。
- 我们的函数 Y 在 A 和 B 同时为真(对应 m3)或 A 和 B 同时为假(对应 m0)时为真。
- 因此,我们只需将译码器的输出 Y0 (m0) 和 Y3 (m3) 通过一个或门连接起来,即可得到最终输出 Y。
通过这种方式,我们使用译码器和或门构建了所需的逻辑功能。

本节课中,我们一起学习了译码器。我们首先定义了译码器,它是一个具有 n 个输入和 2^n 个输出的组合电路,每次仅激活一个输出。接着,我们以 2-4 译码器为例,查看了其真值表和基于与门的电路实现。最后,我们探讨了译码器的一个关键应用:通过生成所有最小项并与或门结合,来实现任意逻辑函数。掌握译码器是理解更复杂数字系统(如内存和可编程逻辑器件)的基础。
027:组合逻辑时序分析 ⏱️

在本节课中,我们将学习如何分析组合逻辑电路的时序性能。我们将了解电路速度的关键指标,包括传播延迟和污染延迟,并探讨影响这些延迟的因素。
上一节我们讨论了如何构建功能正确的电路。本节中,我们来看看如何评估和构建快速的电路。我们需要分析电路的速度。
假设我们有一个电路元件,例如一个缓冲器。当输入信号 A 在某个时刻发生变化时,输出信号 Y 会在一定的延迟后响应并发生变化。
我们通常用横轴表示时间,纵轴表示信号电平,来绘制信号变化的时序图。一般来说,我们可以绘制这样的图:输入 A 在某个时刻从0变为1(或反之),输出 Y 最初与 A 保持一致,但经过一段时间后,Y 开始变化,并在另一段时间后稳定到新的值。
我们定义两个关键的延迟参数:
- 污染延迟:指从输入发生变化开始,到输出可能开始改变为止的时间。它是旧输出值不再得到保证的最短时间。
- 传播延迟:指从输入发生变化开始,到输出肯定已经稳定到新值为止的时间。
污染延迟和传播延迟分别代表了从输入变化到输出达到新值所需的最短和最长时间。
电路中的延迟来源于多种原因。首先,每个电路都有固有的电容和电阻。为电路中的节点充电需要能量,而有限的电流(由电阻建模)导致了 RC 延迟。其次,信息传播速度不能超过光速,因此即使电路优化到电阻和电容极低,信号在物理距离上的传输也需要时间。
传播延迟和污染延迟可能因多种原因而不同:
- 门的上升延迟和下降延迟可能不同,例如,上升信号通过PMOS网络,而下降信号通过NMOS网络。
- 对于多输入/输出的门,某些输入路径可能比其他路径更快。
- 延迟还依赖于环境条件:电路在高温下通常更慢,在低温下更快;在高电压下更快,在低电压下更慢。
接下来,我们引入关键长路径和短路径的概念。考虑一个由多个门组成的完整电路,输入为 A 到 D,输出为 Y。我们需要找出从任何输入变化到输出变化的最长和最短时间。
以下是分析路径延迟的关键步骤:
- 最长路径:从输入 A 或 B 到输出 Y 的路径似乎经过了所有门。这条路径的总传播延迟是各个门传播延迟之和。
- 公式:
t_pd(circuit) = t_pd(AND1) + t_pd(OR) + t_pd(AND2)
- 公式:
- 最短路径:从输入 D 到输出 Y 的路径只经过一个与门。因此,整个电路的污染延迟就是这个与门的污染延迟。
- 公式:
t_cd(circuit) = t_cd(AND2)
- 公式:

本节课中我们一起学习了组合逻辑电路的时序分析。我们定义了污染延迟和传播延迟这两个核心概念,理解了延迟产生的物理原因(如RC延迟和光速限制),并掌握了如何通过识别电路中的关键长路径和短路径来计算整个电路的总延迟。这些知识是设计和优化高速数字系统的基础。
028:时序电路简介

在本章中,我们将学习时序电路,即具有记忆功能的电路。我们将从状态元件开始讨论,然后解释同步时序逻辑的一般定义。接着,我们将探讨两种类型的同步时序逻辑:有限状态机和并行电路(或并行性)。最后,我们还将学习如何计算这些时序电路的时序。
概述
时序电路的输出不仅取决于当前输入,还取决于先前的输入值。因此,时序电路具有记忆功能,因为它能记住过去的输入。例如,在智能手机上输入解锁密码时,手机不仅需要知道当前输入的数字,还需要记住之前输入的数字序列。这就是为什么我们需要时序电路。
状态的定义
首先,我们定义几个术语。状态是指解释电路未来行为所需的所有信息。例如,在输入密码“3-1-5”解锁手机时,电路的状态记录了输入进度:是否已输入“3”?如果已输入,电路就处于“已输入3”的状态,等待下一个正确输入“1”。一旦输入“1”,电路状态更新为“已输入3和1”,并等待输入“5”。因此,状态是决定电路下一步行为的关键信息。
我们将使用锁存器和触发器来存储这种状态,并用二进制值(比特)对状态进行编码。在本课程中,我们主要使用触发器,但锁存器同样可以用于此目的。
同步时序电路
同步时序电路是一种使用触发器且所有触发器共享一个公共时钟的时序电路。这个时钟类似于个人电脑中的时钟,例如2 GHz时钟。所有电路元件都响应这个时钟信号,因此称为“同步”。本章将详细讨论时钟在电路中的作用。
时序电路的作用
时序电路为事件提供序列,这正是其名称的由来。例如,在检测密码序列“3-1-5”时,电路需要按顺序识别这些输入事件。这些电路具有短期记忆,能够记住先前的输入。它们通过从输出到输入的反馈来存储信息,所存储的信息就是比特。
再次强调,状态是关于先前输入的所有信息,电路需要这些信息来预测其未来行为。状态通常只用1比特表示,并且是最后捕获的值或状态。
状态元件的类型
我们将使用特定的电路元件来存储状态。接下来介绍四种类型的状态元件:双稳态电路、SR锁存器、D锁存器和D触发器。最终我们将主要使用D触发器。
上一节我们介绍了时序电路的基本概念和状态的定义。本节中,我们来看看用于存储状态的具体元件类型。
以下是四种主要的状态存储元件:
- 双稳态电路:具有两个稳定状态的电路。
- SR锁存器:一种基本的锁存器,通过Set和Reset输入控制状态。
- D锁存器:在时钟信号有效期间,输出跟随输入(Data)变化的锁存器。
- D触发器:在时钟边沿(上升沿或下降沿)瞬间捕获并存储输入值(Data)的触发器,是我们将主要使用的元件。
总结

本节课中,我们一起学习了时序电路的核心概念。我们了解到时序电路具有记忆功能,其输出依赖于当前和历史的输入。我们定义了“状态”的概念,它是决定电路未来行为的信息集合。我们还介绍了同步时序电路,其特点是所有触发器共用一个时钟信号。最后,我们列举了四种存储状态的基本元件,并指出在本课程后续内容中将重点使用D触发器。
029:双稳态电路

在本节中,我们将学习双稳态电路。这是构成其他状态元件的基本构建块,它能够存储一个比特的信息。
双稳态电路概述
双稳态电路之所以被称为“双稳态”,是因为它拥有两个稳定的状态。该电路没有输入,但有两个输出:Q 和 Q_bar(Q的非)。这两个输出始终保持相反的逻辑值。
电路工作原理
以下是双稳态电路的两种等效表示方式。其核心是两个交叉耦合的反相器。

从功能上看,两个首尾相连的反相器等效于一个缓冲器,其输出被反馈到输入,从而形成一个存储单元。
稳定状态分析
该电路能够稳定地存储一个比特值(0或1)。让我们来分析它的两种稳定状态。
状态一:存储逻辑0
假设初始时 Q = 0。
Q = 0作为输入进入反相器2(I2)。- I2 将其反相,输出
Q_bar = 1。 Q_bar = 1作为输入进入反相器1(I1)。- I1 将其反相,输出
Q = 0。
这个过程形成一个稳定的闭环,电路将保持 Q = 0, Q_bar = 1 的状态。
状态二:存储逻辑1
假设初始时 Q = 1。
Q = 1作为输入进入反相器2(I2)。- I2 将其反相,输出
Q_bar = 0。 Q_bar = 0作为输入进入反相器1(I1)。- I1 将其反相,输出
Q = 1。
同样,电路将稳定地保持 Q = 1, Q_bar = 0 的状态。
电路的局限性
虽然双稳态电路能够存储信息,但它存在一个明显的缺陷:没有控制输入。这意味着一旦电路处于某个稳定状态,我们无法从外部改变其存储的值。它只能“记住”最初被设置的状态(可能是上电时的随机值),而无法被写入新的数据。
总结与过渡
本节课中,我们一起学习了双稳态电路。我们了解到它通过两个交叉耦合的反相器,能够稳定地存储一个比特(0或1),并分析了其两种稳定状态的工作原理。然而,我们也指出了它的关键限制——缺乏控制输入,导致我们无法主动设置其存储的值。

正因为这个缺陷,我们需要一个更强大的电路。在下一节中,我们将探讨如何改进这个设计,引入控制输入,从而创造出我们可以实际写入和读取数据的状态元件。
030:SR锁存器 🔒

在本节中,我们将学习一种基本的存储元件——SR锁存器。我们将分析其电路结构,理解其工作原理,并掌握其在不同输入组合下的行为。
SR锁存器,也称为置位复位锁存器。S代表置位,R代表复位。它有两个输入:S(置位)和R(复位)。我们通过分析电路,探究在四种可能的输入组合下,输出Q会发生什么变化。
电路分析
SR锁存器由两个交叉耦合的或非门构成,具有反馈路径,这使得其分析比组合逻辑电路稍复杂。以下是四种输入组合的分析。
情况一:S=1, R=0(置位)
当S为1,R为0时,我们来分析电路。
以下是分析步骤:
- 观察底部的或非门N2。其一个输入R为0,另一个输入来自Q的反馈(目前未知)。
- 然而,顶部的或非门N1的输入S为1。根据或逻辑的特性,1或任何值都等于1。
- 因此,N1的输出(在气泡前)为1,经过气泡(取反)后,Q变为0。
- 现在,Q(为0)反馈到N2的输入。N2的输入变为0或0,结果为0(气泡前)。
- 该结果经过气泡取反,Q非变为1。
- 最终,Q=1,Q非=0。这符合布尔公理,并且将输出Q置位为1。
情况二:S=0, R=1(复位)
当S为0,R为1时,电路是对称的。
以下是分析步骤:
- 这次我们从底部的或非门N2开始分析。其输入R为1。根据或逻辑,1或任何值等于1。
- 因此,N2的输出(气泡前)为1,经过气泡取反后,Q非变为0。
- Q非(为0)反馈到N1的输入。N1的输入变为0或0,结果为0(气泡前)。
- 该结果经过气泡取反,Q变为1。
- 最终,Q=0,Q非=1。这符合布尔公理,并且将输出Q复位为0。
情况三:S=0, R=0(保持/记忆)
当S和R均为0时,电路进入记忆状态。我们将展示两种分析方法。
方法一:假设前值
我们不知道Q之前的值(Q_prev),但可以分别假设它为0或1进行验证。
假设 Q_prev = 0:
- Q(0)反馈到N2,输入为0或0,输出(气泡前)为0,取反后Q非变为1。
- Q非(1)反馈到N1,输入为0或1,输出(气泡前)为1,取反后Q变为0。
- 结果稳定,Q保持为0。
假设 Q_prev = 1:
- Q(1)反馈到N2,输入为1或0,输出(气泡前)为1,取反后Q非变为0。
- Q非(0)反馈到N1,输入为0或0,输出(气泡前)为0,取反后Q变为1。
- 结果稳定,Q保持为1。
方法二:代数分析
我们也可以使用布尔代数,将反馈值视为变量Q。
- 对于底部或非门N2:输出 = NOT(R OR Q) = NOT(0 OR Q) = NOT(Q) = Q非
- 对于顶部或非门N1:输出 = NOT(S OR Q非) = NOT(0 OR Q非) = NOT(Q非) = Q
- 推导结果是 Q = Q,这意味着输出保持了之前的值。
因此,当S和R均为0时,锁存器保持其原有状态,实现了记忆功能。
情况四:S=1, R=1(无效状态)
当S和R同时为1时,我们分析电路。
- 对于N1:输入为1或(来自Q非的反馈),输出(气泡前)为1,取反后Q=0。
- 对于N2:输入为1或(来自Q的反馈,现为0),输出(气泡前)为1,取反后Q非=0。
- 最终得到 Q=0 且 Q非=0。
这违反了Q和Q非必须互为反码的布尔公理,因此这是一个无效状态,在实际电路中应避免。
总结与符号
上一节我们介绍了存储元件的概念,本节中我们详细分析了SR锁存器。
以下是SR锁存器功能总结:
- 置位 (Set):当 S=1, R=0 时,输出被设置为1(Q=1)。
- 复位 (Reset):当 S=0, R=1 时,输出被复位为0(Q=0)。
- 保持 (Hold):当 S=0, R=0 时,输出保持之前的状态(Q = Q_prev),这是其记忆功能的核心。
- 无效 (Invalid):当 S=1, R=1 时,输出处于未定义(Q=0, Q非=0)的无效状态,必须避免。
其电路符号通常如下图所示,包含S、R输入和Q、Q非输出。


本节课中,我们一起学习了SR锁存器的工作原理。我们分析了其四种输入模式,理解了其如何通过交叉耦合的或非门实现置位、复位和记忆功能,并认识了需要避免的无效输入状态。这是构建更复杂时序逻辑电路的基础。
031:D锁存器 🧠

在本节中,我们将学习第三种状态元件——D锁存器。我们将了解它的结构、工作原理以及它在时钟信号控制下的行为。
概述
D锁存器是一种基本的状态元件,用于存储一位数据。它有两个输入:时钟信号(clock)和数据输入(D),以及一个输出(Q)。其核心功能是:在时钟信号为高电平时,输出Q跟随输入D的变化;在时钟信号为低电平时,输出Q保持之前的状态不变。
D锁存器的构成
D锁存器是在SR锁存器的基础上构建而成的。它通过增加两个与门,将数据输入D和时钟信号clock结合起来,控制SR锁存器的S和R输入。
以下是其内部电路的核心逻辑:
- 数据路径:数据输入
D直接连接到一个与门,该与门的输出连接到SR锁存器的S端。 - 反相路径:数据输入
D经过一个反相器变为D_bar,然后连接到另一个与门,该与门的输出连接到SR锁存器的R端。 - 时钟控制:时钟信号
clock同时连接到上述两个与门的另一个输入端。
用逻辑表达式可以描述为:
S = D AND clock
R = (NOT D) AND clock
Q = 状态(由S和R根据SR锁存器规则决定)
D锁存器的工作原理
上一节我们介绍了D锁存器的构成,本节中我们来看看它在不同时钟信号下的具体行为。其工作模式完全由时钟信号clock决定。
当 Clock = 0(低电平)
当时钟信号为低电平时,无论数据输入D是什么值,两个与门的输出(即S和R)都为0。
根据SR锁存器的特性,当S=0且R=0时,锁存器处于“保持”状态,输出Q将维持其之前的值。
总结:Clock=0时,D锁存器不透明,输出Q保持不变。
当 Clock = 1(高电平)
当时钟信号为高电平时,与门的输出将完全由另一个输入(即D或D_bar)决定。此时,D锁存器变得“透明”。
以下是两种具体情况:
-
情况一:D = 1
S输入收到1 AND 1 = 1。R输入收到0 AND 1 = 0(因为D_bar = 0)。- 根据SR锁存器规则(
S=1, R=0),输出Q被置位为1。
-
情况二:D = 0
S输入收到0 AND 1 = 0。R输入收到1 AND 1 = 1(因为D_bar = 1)。- 根据SR锁存器规则(
S=0, R=1),输出Q被复位为0。
总结:Clock=1时,D锁存器透明,输出Q实时跟随输入D的变化。
D锁存器的符号与特性
理解了工作原理后,我们来看它的标准符号和关键特性。D锁存器的符号通常如下图所示,时钟输入常置于顶部以强调其控制作用。

其核心特性可以归纳为:
- 透明模式:当
clock=1时,Q跟随D。输入数据的任何变化都会直接反映到输出。 - 不透明(保持)模式:当
clock=0时,Q保持clock从1跳变为0瞬间所捕获的D值。在此期间,无论D如何变化,Q都保持不变。 - 避免无效状态:通过内部电路设计(
S和R由D及其反相信号生成),确保了S和R不会同时为1,从而避免了SR锁存器中可能出现的无效或不确定状态。
总结

本节课中我们一起学习了D锁存器。它是一种受时钟控制的状态元件,其输出行为分为两个阶段:在时钟高电平时透明传输数据,在时钟低电平时锁存并保持数据。这种特性使其成为构建更复杂时序电路(如寄存器)的基础模块。理解D锁存器如何由SR锁存器构建而成,以及时钟信号如何控制其透明与保持状态,是掌握数字系统中数据存储与同步的关键一步。
032:D触发器 🧠

在本节中,我们将学习本章的最后一种状态元件:D触发器。我们将了解其工作原理、内部电路结构,并与之前介绍的D锁存器进行比较。
概述
D触发器是一种边沿触发的状态元件,它仅在时钟信号的上升沿对输入D进行采样,并将该值存储到输出Q。与电平敏感的D锁存器不同,D触发器提供了更精确的时序控制,是现代同步时序电路的核心构建模块。
D触发器的符号与功能
D触发器的符号与D锁存器相似,但有一个关键区别:时钟输入端有一个三角形标记。这个三角形表示该电路是边沿触发的。
符号表示:
- D: 数据输入
- CLK (带三角形): 时钟输入 (上升沿触发)
- Q: 数据输出
为了简洁,我们通常使用一个简化符号,不标注D和Q,也不显示Q的非输出。
功能定义:
D触发器在时钟信号的上升沿(从0变为1的时刻)对输入D进行采样,并立即(或经过一个极短的传播延迟后)使输出Q等于采样到的D值。在其他所有时间,Q都保持其之前的值,即具有记忆功能。
公式描述:
在时钟上升沿:Q(t+1) = D(t)
其他时间:Q(t+1) = Q(t)
内部电路结构
D触发器的内部电路由两个D锁存器背靠背连接而成。
电路构成:
- 主锁存器 (Master Latch): 接收输入D。
- 从锁存器 (Slave Latch): 接收主锁存器的输出。
两个锁存器的时钟信号是相反的。
工作原理时序分析:
以下是D值传递到Q的时序过程。
-
时钟为低电平 (CLK=0):
- 主锁存器的时钟输入(CLK的反相信号)为高,因此主锁存器透明,输入D的值传递到其内部节点N1。
- 从锁存器的时钟输入为低,因此从锁存器不透明,隔离了N1和最终输出Q。
-
时钟上升沿 (CLK从0→1):
- 在上升沿即将发生前,D的值已被捕获在主锁存器的内部节点N1。
- 时钟变为高电平时,主锁存器变为不透明,锁存住N1的值。
- 同时,从锁存器变为透明,将N1的值传递到输出Q。
-
时钟为高电平 (CLK=1):
- 主锁存器保持不透明,输入D的变化无法影响内部节点N1。
- 从锁存器保持透明,但因其输入N1已稳定,所以输出Q也保持稳定。
这个过程在每一个时钟上升沿重复。两个锁存器交替工作(一个透明时另一个不透明),因此得名“触发器”(Flip-Flop)。
D锁存器与D触发器的对比
上一节我们介绍了D锁存器,本节中我们来看看D触发器。理解两者的区别对设计时序电路至关重要。
以下是D锁存器与D触发器工作波形的对比。
D锁存器 (电平敏感):
- 透明期: 当CLK为高电平时,输出Q跟随输入D的变化。
- 保持期: 当CLK为低电平时,Q保持CLK下降沿前一刻的D值,忽略之后D的变化。
D触发器 (边沿触发):
- 采样点: 仅在CLK的上升沿对D进行采样,并更新Q。
- 保持期: 在两次上升沿之间的所有时间,Q都保持上一次采样到的值,完全不受D变化的影响。
关键区别总结:
- D锁存器在CLK高电平期间是“透明”的。
- D触发器只在CLK上升沿的瞬间“眨眼”看一下D的值,其他时间都是“闭眼”记忆状态。
初始状态问题
一个重要的实际问题是:在第一个时钟上升沿到来之前,或者电路刚上电时,触发器的输出Q是什么?
答案是:不确定。它可能是0,也可能是1。这个初始状态通常是未知的,在波形图中我们用双线或“X”来表示。在实际系统中,需要通过复位电路来将触发器置为一个已知的初始状态。
总结
本节课中我们一起学习了D触发器。
- D触发器是一种边沿触发的状态元件,使用带三角形的时钟符号表示。
- 其核心功能是:在时钟上升沿采样输入D,并更新输出Q;在其他所有时间保持Q不变。
- 内部通常由两个反相的D锁存器(主从结构)构成,实现了精确的边沿采样。
- 与D锁存器相比,D触发器提供了更严格、更清晰的时序控制,是构建复杂同步数字系统的基石。

理解D触发器是学习寄存器、计数器、状态机等更复杂时序逻辑电路的第一步。
数字设计和计算机架构:3.6:触发器变体 🔄

在本节中,我们将探讨几种触发器的变体。触发器是本课程中将使用的核心状态元件。理解其他状态元件很重要,因为触发器本质上是由它们构建而成的。
寄存器
首先介绍寄存器。寄存器是一组触发器。例如,一个4位寄存器就是由四个触发器组成的。当我们提到“触发器”时,通常指的就是D触发器,有时也简称为“flop”。
这些触发器共享同一个时钟信号。时钟信号连接到所有触发器的时钟输入端。
为了绘图简洁,我们通常使用总线符号来表示多位寄存器。例如,一个4位寄存器可以画成一条带斜杠的线,旁边标注数字4,这等价于四根独立的线。当寄存器位数很多时(例如32位),使用总线符号绘图会方便得多。
以下是两种表示4位寄存器的方式,它们是等价的。
带使能端的触发器
接下来,我们看看带使能端的触发器。除了常规的时钟和D输入外,它还有一个额外的使能输入,通常标记为 EN 或 E。
使能信号控制着状态元件何时存储新数据或保持原有状态。
- 当
EN = 1时,触发器表现得像一个普通的D触发器,在时钟边沿,输出Q获取输入D的值。 - 当
EN = 0时,触发器保持其原有状态,忽略D输入的值。
那么,如何构建这样一个带使能的触发器呢?我们可以从一个普通的D触发器开始,并在其D输入端前添加一些组合逻辑。
我们可以通过真值表来推导所需的逻辑。目标是控制输入到D触发器内部(记为 D_in)的信号。
以下是推导过程:
- 当
EN = 0时,我们希望D_in = Q_prev(前一个Q值),以保持状态。 - 当
EN = 1时,我们希望D_in = D,以采样新数据。
由此,我们可以写出 D_in 的逻辑方程:
D_in = (EN' * Q_prev) + (EN * D)
根据这个方程,我们可以用与门和或门搭建电路。另一种更直观的实现方式是使用一个2选1多路选择器:使能信号 EN 作为选择端,当 EN=1 时选择 D,当 EN=0 时选择 Q_prev,然后将选择器的输出连接到D触发器的 D_in。
可复位触发器
另一种常见的变体是可复位触发器。它在普通触发器的基础上增加了一个复位输入,通常标记为 R 或 CLR(清除)。
当复位信号有效时(R = 1),触发器存储的状态位被强制清零(Q = 0)。当 R = 0 时,它表现得像一个普通的D触发器。
可复位触发器分为两种类型:
- 同步复位:复位操作仅在时钟边沿生效。即使复位信号变高,也必须等到下一个时钟上升沿到来时,Q才会被清零。
- 异步复位:复位操作立即生效。一旦复位信号变高,Q会立刻被清零,无需等待时钟边沿。
异步复位触发器需要修改触发器内部锁存器的电路结构。而同步复位触发器则可以用我们为“使能”触发器设计的类似方法来构建。
构建同步复位触发器的方法如下:
我们同样在D触发器前添加组合逻辑。目标是:当 R = 0 时,D_in = D;当 R = 1 时,D_in = 0。
其逻辑方程非常简单:
D_in = R' * D
这个方程意味着,只有当复位无效(R=0)时,D输入才能通过;一旦复位有效(R=1),D_in 被强制为0,从而在下一个时钟边沿将Q清零。
可复位触发器非常重要,因为在系统上电时,触发器的初始状态是未知的。通过复位操作,我们可以将电路置于一个已知的确定状态,这是系统可靠工作的基础。
可置位触发器
最后,我们简要介绍可置位触发器。它有一个置位输入,通常标记为 S。
其功能是:当置位信号有效(S = 1)时,输出Q被强制置为1(Q = 1);当 S = 0 时,它表现得像一个普通触发器。
与可复位触发器类似,可置位触发器也分为同步置位和异步置位两种版本。同步版本的构建思路与同步复位触发器相似,而异步版本同样需要修改触发器内部电路。
总结

本节课我们一起学习了触发器的几种重要变体。我们首先了解了寄存器是多位触发器的集合,并用总线符号简化表示。然后,我们深入探讨了带使能端的触发器,它通过一个使能信号控制数据的采样与保持,并学习了其逻辑方程 D_in = (EN' * Q_prev) + (EN * D) 和实现方法。接着,我们介绍了可复位触发器,它能够将状态强制清零,并区分了同步复位(D_in = R' * D)与异步复位的不同。最后,我们提到了可置位触发器的基本概念。理解这些变体对于设计和分析复杂的时序逻辑电路至关重要。
034:同步时序逻辑设计 🧠

在本节中,我们将学习如何设计同步时序逻辑电路。我们将了解时序电路与组合电路的区别,并探讨如何通过引入寄存器和时钟信号来解决简单反馈电路中的时序不确定性问题。
什么是时序电路?
上一节我们介绍了组合逻辑电路,本节中我们来看看时序电路。时序电路是指所有非组合逻辑的电路。这意味着电路的输出不仅取决于当前的输入,还取决于电路过去的状态。
让我们考虑一个简单的例子:三个首尾相连的反相器。
// 三个反相器构成的环形振荡器
wire X, Y, Z;
assign Y = ~X;
assign Z = ~Y;
assign X = ~Z; // 输出反馈到输入
这个电路没有外部输入。假设初始时刻 X=0,在时序图中,X 从 0 跳变到 1。以下是信号传播过程:
X从 0 变为 1。- 经过第一个反相器延迟后,
Y变为 0。 - 经过第二个反相器延迟后,
Z变为 1。 Z驱动X,使其变回 0。- 这个过程不断重复,导致
X、Y、Z三个节点在 0 和 1 之间持续振荡。
该电路的振荡周期取决于反相器的延迟。然而,这种延迟在实际操作中会发生变化,导致电路行为难以预测和控制。
同步时序逻辑设计
为了解决上述电路时序不确定和难以控制的问题,我们采用同步时序逻辑设计。
同步时序逻辑的核心思想是:通过插入寄存器(通常是触发器)来打破电路中的循环路径。寄存器中存储着系统的状态,并且状态的改变只发生在时钟边沿。正因为状态的改变与时钟同步,所以我们称之为“同步”时序逻辑。
以下是同步时序逻辑电路的构成规则:
- 每个电路元件要么是寄存器,要么是组合逻辑电路。
- 系统中必须至少有一个寄存器。
- 所有寄存器接收同一个时钟信号。
- 每一条循环路径中必须至少包含一个寄存器。
这意味着,如果我们有一个形成闭环的电路,我们不能让信号直接通过组合逻辑反馈,而必须在反馈路径中插入一个寄存器。
两种常见的同步时序电路是有限状态机和流水线,我们将在后续章节详细讨论。
总结

本节课中我们一起学习了同步时序逻辑设计的基础。我们了解到,简单的反馈电路(如环形振荡器)会产生不可控的振荡。通过引入寄存器和统一的时钟,我们可以将状态变化同步到时钟边沿,从而设计出行为确定、易于控制的时序电路。这为后续学习更复杂的有限状态机和处理器流水线打下了坚实的基础。
035:有限状态机简介 🧠

在本节课中,我们将要学习有限状态机的基本概念。有限状态机是数字系统中用于控制逻辑的核心组件,它能够根据当前状态和输入来决定系统的行为。我们将了解其构成、工作原理以及两种主要类型。
有限状态机的构成
有限状态机由一个状态寄存器和组合逻辑构成。
状态寄存器存储着系统的状态。系统的状态 S,我们也可以称之为当前状态,就是该寄存器输出的值,即寄存器中保持的比特位。
在下一个时钟边沿,下一个状态就会成为当前状态。我们使用带撇号的符号 S' 来表示下一个状态,而 S 本身表示当前状态。
我们同时使用组合逻辑来计算下一个状态,以及计算输出。这被称为输出逻辑。
因此,我们通过两个组合逻辑块和一个状态寄存器来定义有限状态机的功能。
状态转换逻辑
在有限状态机中,下一个状态由当前状态和输入共同决定。
例如,假设我们正在智能手机上输入解锁密码“5,7,3”。如果我们已经输入了“5”,那么系统就处于“已输入5”的状态。此时,下一个状态就取决于这个当前状态(已输入5)和下一个输入。如果下一个输入是“7”,状态就会变为“已输入5和7”。如果接下来输入的是“4”,系统则不会解锁。由此可见,系统的后续行为完全取决于当前状态和输入信号。
有限状态机的类型
有限状态机有两种主要类型,它们的区别仅在于输出逻辑,即输出是如何生成的。
摩尔型有限状态机
在摩尔型有限状态机中,输出仅取决于当前状态。
以下是摩尔型FSM的结构示意图。中间是状态寄存器,输出当前状态,左侧是下一个状态。我们有一个“下一个状态逻辑”组合逻辑块,它利用系统的当前状态和输入来计算下一个状态。而在摩尔型FSM中,输出仅由系统的状态位,即当前状态来决定。
摩尔型FSM是实际应用中最常见的有限状态机类型。
米利型有限状态机
第二种有限状态机称为米利型FSM。
其状态计算,即下一个状态的计算,与摩尔型FSM一样,由输入和当前状态决定。但输出逻辑不同,因为其输出由输入和当前状态共同决定。
而在摩尔型FSM中,输出仅由当前状态决定。因此,这两种FSM的区别仅在于输出逻辑的计算方式。我们将在后续课程中给出这两种状态机的具体例子。
总结

本节课中我们一起学习了有限状态机的基础知识。我们了解到FSM由状态寄存器和组合逻辑构成,其下一个状态由当前状态和输入决定。我们重点区分了两种主要的FSM类型:输出仅取决于当前状态的摩尔型FSM,以及输出同时取决于当前状态和输入的米利型FSM。理解这些核心概念是设计复杂数字控制逻辑的第一步。
036:摩尔型有限状态机示例1 🧮

在本节中,我们将学习如何设计一个摩尔型有限状态机。我们将从一个简单的日常场景出发,逐步完成从状态图到逻辑电路的设计过程。
概述
我们将设计一个帮助遵守日程的有限状态机。该状态机有四个状态:醒来、早餐、淋浴和上课。我们将学习如何将状态图转化为状态转移表,为状态分配编码,推导出次态方程,并最终构建出对应的数字逻辑电路。
状态转移图
我们首先绘制状态转移图。状态用圆圈表示,状态之间的转移用带条件的箭头表示。
以下是状态转移图的描述:
- 起始状态是醒来状态。
- 从醒来状态出发,如果时间充足,则转移到早餐状态;如果时间不足,则直接转移到淋浴状态。
- 从早餐状态出发,无条件转移到淋浴状态。
- 从淋浴状态出发,无条件转移到上课状态。
- 从上完课状态出发,无条件转移回醒来状态。
这个图表展示了状态以及状态之间的转移条件,我们称之为状态转移图。
状态转移表
上一节我们介绍了状态转移图,本节中我们来看看如何将其转化为更结构化的表格形式,即状态转移表。
状态转移表(也称为次态表)用于定义在给定当前状态和输入时,下一个状态是什么。表格的左侧是当前状态和输入,右侧是下一个状态。
以下是构建状态转移表的步骤:
- 第一行:当前状态为“醒来”,输入T为0(无时间),则次态为“淋浴”。
- 第二行:当前状态为“醒来”,输入T为1(有时间),则次态为“早餐”。
- 第三行:当前状态为“早餐”,输入为任意值(无关项),则次态为“淋浴”。
- 第四行:当前状态为“淋浴”,输入为任意值,则次态为“上课”。
- 第五行:当前状态为“上课”,输入为任意值,则次态为“醒来”。
通过此表,我们完整地规定了有限状态机的行为。
状态编码
数字逻辑电路处理的是1和0,而我们的状态目前是字母。因此,我们需要为状态分配二进制编码。
由于有4个状态,我们至少需要2位二进制数来唯一表示它们(因为 log₂(4) = 2)。如果有5个状态,则需要3位,依此类推。
我们为状态分配如下编码:
- 醒来 (W):
00 - 早餐 (B):
01 - 淋浴 (S):
10 - 上课 (C):
11
现在,我们可以用这些编码重写状态转移表,得到一个编码后的状态转移表。
编码状态转移表与次态方程
上一节我们为状态分配了编码,本节中我们利用这些编码生成可用于逻辑设计的真值表,并推导次态方程。
我们将编码代入原始的状态转移表。例如,“醒来”状态00,输入T=0时,次态为“淋浴”10。以此类推,完成整个表格的转换。
这个编码表看起来就像一个真值表。我们可以用它来写出次态位的逻辑方程,就像处理普通真值表一样。
以下是推导次态方程 S1' 和 S0' 的过程:
- 对于
S1'(次态高位),我们圈出输出为1的最小项,得到表达式。通过布尔代数化简(例如使用吸收律),可以得到简化后的方程:S1' = S1'·T' + S1'·S0。进一步观察,这等价于 S1' = S1' ⊕ S0(异或运算)。 - 对于
S0'(次态低位),同样圈出输出为1的最小项,得到表达式。化简后得到:S0' = S0'·T + S1·S0'。
电路实现
现在我们有了次态方程,可以开始构建电路了。一个典型的有限状态机电路包含三部分:状态寄存器、次态逻辑和输出逻辑。
以下是构建电路的步骤:
- 状态寄存器:这是一个由触发器组成的寄存器,用于存储当前状态位
S1和S0。它有时钟CLK和复位Reset输入。复位时,状态寄存器应被置为初始状态(00,即醒来状态)。 - 次态逻辑:这是一个组合逻辑电路,根据当前状态
S1、S0和输入T,计算下一个状态S1'和S0'。我们根据推导出的方程 S1' = S1'·T' + S1'·S0 和 S0' = S0'·T + S1·S0' 来搭建这个逻辑网络(使用与门、或门、非门等)。 - 连接:将状态寄存器的输出(当前状态)反馈到次态逻辑的输入。次态逻辑的输出连接到状态寄存器的输入。这样,每个时钟上升沿到来时,下一个状态就会被载入寄存器,成为新的当前状态。
通过这个电路,我们就在硬件上实现了之前设计的状态机。
输出逻辑与波形验证
我们的状态机还可以产生输出。例如,我们定义当处于“上课”状态时,点亮一个“快乐”指示灯H。
输出仅依赖于当前状态,这是摩尔型状态机的特点。我们可以创建一个输出表,列出每个状态对应的H值。根据编码,只有当状态为11(上课)时,H=1。因此,输出方程非常简单:H = S1 · S0。
我们可以在电路中添加一个与门来实现这个输出逻辑,其输入直接来自状态寄存器。
为了验证电路功能,我们可以绘制时序图。假设初始复位后状态为00(醒来),输入T在一段时间内为1。当时钟边沿到来时,观察状态位S1、S0和输出H的变化。它们应该按照“醒来(00) -> 早餐(01) -> 淋浴(10) -> 上课(11, H=1) -> 醒来(00)”的顺序正确跳变,从而验证设计的正确性。
设计流程总结
本节课中我们一起学习了完整的摩尔型有限状态机设计流程:
- 明确输入输出:确定除了时钟和复位外的输入信号(如
T)和输出信号(如H)。 - 绘制状态转移图:用图形化方式描述状态、转移条件和输出。
- 创建状态表:分别建立状态转移表(定义次态)和输出表(定义输出)。
- 状态编码:为每个状态分配唯一的二进制码。
- 生成编码表:用编码重写状态转移表和输出表。
- 推导逻辑方程:从编码表中写出次态逻辑和输出逻辑的布尔方程。
- 电路实现:根据方程搭建包含状态寄存器、次态逻辑和输出逻辑的数字电路。

通过这个例子,你将掌握设计简单同步时序逻辑系统的基本方法。
037:摩尔型有限状态机示例2 🚦

在本节中,我们将通过一个交通灯控制器的具体例子,深入学习如何设计一个摩尔型有限状态机。我们将从状态图开始,逐步完成状态编码、状态转移表和输出表的推导,并最终实现其电路。
概述
我们将设计一个控制两条道路交叉口交通灯的有限状态机。该系统有两个输入传感器,用于检测两条道路上是否有车辆。根据交通状况,控制器将改变交通灯的状态,以高效地管理交通流。
状态转移图
首先,我们定义系统的行为,并用状态转移图来描述它。
系统有两个输入:
- TA:学术大道上的交通传感器。当有车辆时,TA = 1。
- TB:布拉瓦达大道上的交通传感器。当有车辆时,TB = 1。
系统有两个输出,分别控制两条道路的交通灯:
- LA:学术大道的交通灯。
- LB:布拉瓦达大道的交通灯。
每个交通灯可以有三种状态:绿色(G)、黄色(Y)、红色(R)。因此,每个输出(LA 和 LB)需要多个比特位来表示。
以下是状态转移图的描述:
- 状态 S0:LA 为绿色,LB 为红色。只要 TA 为 1(学术大道有车),就保持在 S0 状态。当 TA 变为 0 时,转移到状态 S1。
- 状态 S1:LA 为黄色,LB 为红色。在下一个时钟边沿,无条件转移到状态 S2。
- 状态 S2:LA 为红色,LB 为绿色。只要 TB 为 1(布拉瓦达大道有车),就保持在 S2 状态。当 TB 变为 0 时,转移到状态 S3。
- 状态 S3:LA 为红色,LB 为黄色。在下一个时钟边沿,无条件转移回状态 S0。
状态转移表
根据状态转移图,我们可以创建状态转移表(也称为次态表)。该表使用当前状态和输入来决定下一个状态。
以下是状态转移表的内容:
| 当前状态 | 输入 TA | 输入 TB | 次态 |
|---|---|---|---|
| S0 | 0 | X | S1 |
| S0 | 1 | X | S0 |
| S1 | X | X | S2 |
| S2 | X | 0 | S3 |
| S2 | X | 1 | S2 |
| S3 | X | X | S0 |
注意:表中的
S代表状态变量,不要与状态名 S0, S1, S2, S3 混淆。X表示“无关项”。
状态编码
状态名 S0, S1, S2, S3 是符号化的,我们需要为它们分配具体的二进制编码。首先,我们使用二进制编码,即使用最少的比特数来表示所有状态。
我们有 4 个状态,所以至少需要 2 个比特(log2(4) = 2)。我们选择如下编码:
- S0 ->
00 - S1 ->
01 - S2 ->
10 - S3 ->
11
我们用这些编码替换状态转移表中的状态名。我们用两个状态位 S1 和 S0(S1 是高位,S0 是低位)来表示状态。
以下是编码后的状态转移表:
| 当前状态 S1 S0 | 输入 TA | 输入 TB | 次态 S1‘ S0’ |
|---|---|---|---|
| 0 0 | 0 | X | 0 1 |
| 0 0 | 1 | X | 0 0 |
| 0 1 | X | X | 1 0 |
| 1 0 | X | 0 | 1 1 |
| 1 0 | X | 1 | 1 0 |
| 1 1 | X | X | 0 0 |
推导次态方程
现在,我们可以根据编码后的状态转移表,为每个次态位(S1‘ 和 S0’)写出逻辑方程。我们使用积之和(SOP)的形式。
以下是次态位 S1‘ 的推导过程:
- 观察 S1‘ 为 1 的行:第 3 行 (01 -> 10),第 4 行 (10 -> 11),第 5 行 (10 -> 10)。
- 对应的乘积项为:
(~S1 & S0)(来自第3行,输入为无关项)(S1 & ~S0 & ~TB)(来自第4行)(S1 & ~S0 & TB)(来自第5行)
- 合并第 2 和第 3 项:
(S1 & ~S0 & ~TB) + (S1 & ~S0 & TB) = (S1 & ~S0)。 - 因此,S1‘ 的方程为:
S1‘ = (~S1 & S0) + (S1 & ~S0) = S1 ^ S0(异或)。
以下是次态位 S0‘ 的推导过程:
- 观察 S0‘ 为 1 的行:第 1 行 (00 -> 01),第 4 行 (10 -> 11)。
- 对应的乘积项为:
(~S1 & ~S0 & ~TA)(来自第1行)(S1 & ~S0 & ~TB)(来自第4行)
- 因此,S0‘ 的方程为:
S0‘ = (~S1 & ~S0 & ~TA) + (S1 & ~S0 & ~TB)。
输出表与输出编码
在摩尔型有限状态机中,输出完全由当前状态决定。因此,我们创建输出表。
以下是输出表的内容:
| 当前状态 | 输出 LA | 输出 LB |
|---|---|---|
| S0 | 绿色(G) | 红色(R) |
| S1 | 黄色(Y) | 红色(R) |
| S2 | 红色(R) | 绿色(G) |
| S3 | 红色(R) | 黄色(Y) |
输出值(绿、黄、红)也需要编码。每个灯有3种状态,至少需要2个比特。我们选择如下编码:
- 绿色(G) ->
00 - 黄色(Y) ->
01 - 红色(R) ->
10
我们用状态编码和输出编码替换输出表中的符号。
以下是编码后的输出表:
| 当前状态 S1 S0 | LA1 LA0 | LB1 LB0 |
|---|---|---|
| 0 0 | 0 0 | 1 0 |
| 0 1 | 0 1 | 1 0 |
| 1 0 | 1 0 | 0 0 |
| 1 1 | 1 0 | 0 1 |
推导输出方程
根据编码后的输出表,我们可以为每个输出位写出逻辑方程。
以下是各输出位的方程:
LA1 = S1LA0 = ~S1 & S0LB1 = ~S1LB0 = S1 & S0
电路实现
有了次态方程和输出方程,我们就可以画出有限状态机的电路图。电路主要由三部分组成:状态寄存器、次态逻辑和输出逻辑。
以下是电路实现的描述:
- 状态寄存器:由两个 D 触发器构成,存储当前状态位 S1 和 S0。其输入(D端)连接次态逻辑的输出(S1‘ 和 S0’),在时钟边沿更新。
- 次态逻辑:一组组合逻辑电路,根据当前状态(S1, S0)和输入(TA, TB)计算下一个状态(S1‘, S0’)。其实现基于我们推导出的方程:
S1‘ = S1 ^ S0S0‘ = (~S1 & ~S0 & ~TA) + (S1 & ~S0 & ~TB)
- 输出逻辑:另一组组合逻辑电路,根据当前状态(S1, S0)计算输出(LA1, LA0, LB1, LB0)。其实现基于我们推导出的方程:
LA1 = S1LA0 = ~S1 & S0LB1 = ~S1LB0 = S1 & S0
独热编码
上一节我们使用了二进制编码。现在,我们来看看另一种常用的编码方式:独热编码。
在独热编码中,状态位的数量等于状态的数量。每个状态对应一个唯一的状态位,并且在任何时候,只有一个状态位为高(1)。
对于我们的4状态系统,独热编码如下:
- S0 ->
0001 - S1 ->
0010 - S2 ->
0100 - S3 ->
1000
使用独热编码时,次态方程的推导可以更直接地从状态转移图得到,并且通常更简单,但需要更多的触发器(4个 vs 2个)。
以下是基于独热编码的次态方程(注意:方程中只包含当前为高的那个状态位,因为其他位根据编码规则必然为0):
S3‘ = S2 & ~TB(从S2转移到S3的条件)S2‘ = S1 + (S2 & TB)(从S1无条件转移到S2,或在S2且TB=1时保持)S1‘ = S0 & ~TA(从S0转移到S1的条件)S0‘ = (S0 & TA) + S3(在S0且TA=1时保持,或从S3无条件转移到S0)
输出方程也需要用独热编码的状态位重写:
LA1 = S2 + S3LA0 = S1LB1 = S0 + S1LB0 = S3

一个重要注意事项:当使用独热编码并带有复位信号时,必须确保复位后能进入正确的初始状态(例如 S0=0001)。这通常需要将对应 S0 的触发器设置为可置位(SET),而其他触发器设置为可复位(RESET)。这样,当复位信号有效时,电路会初始化为 0001,而不是 0000。
总结
本节课我们一起学习了如何设计一个完整的摩尔型有限状态机。我们从交通灯控制器的状态图出发,逐步完成了状态转移表、状态编码(包括二进制编码和独热编码)、次态方程和输出方程的推导,并理解了其对应的电路实现结构。关键点在于:摩尔机的输出仅取决于当前状态;状态编码的选择(二进制或独热)会影响电路的复杂度和使用的触发器数量。
038:米利型有限状态机示例 🐌

在本节中,我们将学习另一种有限状态机——米利型有限状态机。我们将通过一个具体的例子,对比它与摩尔型有限状态机的区别,并学习如何设计、编码和实现一个米利型状态机。
概述
上一节我们介绍了摩尔型有限状态机,其输出仅取决于当前状态。本节中,我们来看看米利型有限状态机。在米利型状态机中,输出由当前状态和当前输入共同决定。我们将通过一个“蜗牛爬纸带”的例子,详细讲解如何设计米利型状态机,并将其与摩尔型方案进行对比。
问题描述:蜗牛微笑检测器
假设有一只蜗牛在一条印有0和1的纸带上爬行。我们希望设计一个状态机作为蜗牛的大脑,使得当它爬过的最后两位是“01”序列时,蜗牛会微笑。
我们的目标是设计两种方案:一个摩尔型状态机和一个米利型状态机。
摩尔型状态机设计
首先,我们为摩尔型状态机绘制状态转移图。我们需要检测序列“01”。
我们从复位状态 S0 开始。状态转移图如下:
- 如果输入是
0,我们转移到状态S1(表示已检测到第一个0)。 - 如果输入是
1,我们保持在状态S0(因为序列未开始)。 - 在状态
S1:- 如果输入是
0,我们保持在S1(因为最新的有效起始位仍是这个0)。 - 如果输入是
1,我们转移到状态S2并输出1(即微笑),因为此时我们完整检测到了“01”。
- 如果输入是
- 在状态
S2:- 如果输入是
0,我们回到状态S1(这个0可能是新序列的开始)。 - 如果输入是
1,我们回到状态S0(连续两个1破坏了模式)。
- 如果输入是
在摩尔型状态机中,输出写在状态圆圈内。因此,只有状态 S2 的输出为 1(微笑),状态 S0 和 S1 的输出均为 0。
米利型状态机设计
现在,我们为同一个问题设计米利型状态机。在米利型状态机中,输出写在状态转移箭头上,格式为 输入/输出。
我们同样从复位状态 S0 开始。状态转移图如下:
- 在状态
S0:- 如果输入是
0,转移到状态S1,输出0。 - 如果输入是
1,保持在状态S0,输出0。
- 如果输入是
- 在状态
S1:- 如果输入是
0,保持在状态S1,输出0。 - 如果输入是
1,转移到状态S0,输出1。注意,一旦在状态S1下收到输入1,我们立即输出1,然后回到S0。
- 如果输入是
观察可知,米利型状态机只需要两个状态(S0, S1),而摩尔型需要三个状态(S0, S1, S2)。通常,摩尔型状态机需要更多的状态。
状态编码与逻辑推导
接下来,我们将状态图转化为状态转移表,并推导出下一状态和输出的逻辑方程。
摩尔型状态机
我们使用二进制编码状态:S0=00, S1=01, S2=10。根据状态转移图,我们可以列出下一状态表。
对于输出 Y(微笑),其真值表仅取决于当前状态:
S0 (00): Y = 0S1 (01): Y = 0S2 (10): Y = 1
通过卡诺图优化,我们可以得到简化的逻辑方程。假设当前状态位为 S1, S0,输入为 A,那么下一状态和输出的方程可能如下:
S1' = S1 & S0 & AS0' = A'Y = S1
米利型状态机
我们使用二进制编码状态:S0=0, S1=1。米利型状态机的下一状态和输出由当前状态和输入共同决定,因此我们使用组合的状态转移与输出表。
根据状态转移图,我们可以列出表格,并推导出方程:
S0' = A'(下一状态方程)Y = S0 & A(输出方程)
电路实现与对比
根据推导出的逻辑方程,我们可以绘制出两种状态机的电路图。
摩尔型状态机电路包含状态寄存器、下一状态逻辑(输入为当前状态 S1, S0 和输入 A)和输出逻辑(输入仅为当前状态 S1, S0)。
米利型状态机电路也包含状态寄存器、下一状态逻辑(输入为当前状态 S0 和输入 A)。关键区别在于输出逻辑:它的输入同时包括当前状态 S0 和当前输入 A。
时序行为对比
两种状态机的时序行为有显著差异,这体现在它们的输出响应速度上。
在米利型状态机中,一旦在状态 S1 下检测到输入 A=1,输出 Y 会立即变为高电平(微笑),无需等待时钟沿。
在摩尔型状态机中,即使在状态 S1 下检测到 A=1,系统也需要等到下一个时钟沿到来,将状态转移到 S2 后,输出 Y 才会变为高电平。
因此,当输出需要立即响应输入变化时,米利型状态机是更合适的选择。
总结

本节课中我们一起学习了米利型有限状态机。我们通过一个“检测01序列”的例子,逐步完成了从问题定义、状态图绘制、状态编码、逻辑方程推导到电路实现的全过程。关键点在于理解米利型状态机(输出 = F(当前状态, 当前输入))与摩尔型状态机(输出 = F(当前状态))的核心区别。这种区别导致了米利型状态机通常需要更少的状态,并且其输出能对输入变化做出即时响应,这在某些时序要求严格的应用中至关重要。
039:分解式有限状态机 🧩

在本节中,我们将学习如何通过“分解”来简化复杂的有限状态机设计。我们将以交通灯控制器为例,为其添加一个“游行模式”,并对比分解前后的设计差异。
分解式有限状态机将复杂的有限状态机拆分为多个更小、相互作用的FSM。这有助于降低FSM的复杂度。当复杂度降低时,设计、测试和调试过程都能得到简化,因此具有诸多优势。
举例来说,假设我们想修改之前构建的交通灯控制器,为其增加一个游行模式。为此,我们将引入两个新的输入信号:P 和 R。当 P 等于1时,系统进入游行模式,Bravada大道将保持绿灯,以便游行队伍通过。当 R 等于1时,系统退出游行模式,恢复正常运行模式。
我们可以设计一个未分解的FSM,将所有功能都集成在一个更新后的、更复杂的FSM中。或者,我们可以采用分解的方法:保留原有的交通灯控制FSM,并新增一个模式控制FSM,由它来决定何时让交通灯FSM保持Bravada大道为绿灯。
为了实现这种交互,我们需要一个内部信号 M(模式信号),由模式FSM输出,用于告知交通灯FSM当前处于何种模式。这样,原有的交通灯FSM只需稍作修改,增加一个输入端口 M,而新增的模式FSM则负责处理两个新输入信号,并决定当前应处于的模式。
下图展示了一个未分解版本的FSM,它将所有功能都包含在一个有限状态机中。

然而,分解后的FSM设计则清晰得多。这里我们有模式FSM。
只要游行模式输入 P 为低电平,它就保持在 S0 状态,输出模式信号 M 为0。当游行输入 P 有效(变为1)时,FSM则转移到 S1 状态,同时输出 M 变为1。
现在,对于交通灯FSM,当它处于 S2 状态(Bravada大道绿灯)时,其转移条件发生了变化。原先的逻辑是:当 TB(Bravada大道计时器)为真时保持 S2,为假时转移到 S3。现在的新逻辑是:当 TB 为真 或 从模式FSM接收到的输出 M 为真时,都保持在 S2 状态。这意味着,如果 M 等于1,系统将停留在 S2 状态,保持Bravada大道绿灯,让游行队伍通过。
相应地,另一个转移条件也需要包含模式信号。我们需要的条件是 M 或 TB 的“非”,即 (M OR TB) 的取反,这在逻辑上等价于 (NOT M) AND (NOT TB)。我们可以更清晰地将其表述为:当 M 和 TB 均为0时,表达式 (M OR TB) 的结果为0,此时我们希望离开 S2 状态,开始将LA大道变为绿灯。
通过这种方式,我们得到了两个更简单的FSM,使得设计、测试和调试都变得更加容易。


在本节中,我们一起学习了分解式有限状态机的概念。通过将复杂的单一FSM拆分为多个协同工作的简单FSM,我们有效地降低了系统复杂度,从而简化了整个设计流程。这种方法在需要为现有系统添加新功能时尤为有用。
040:时序分析 🕒

在本节中,我们将学习同步时序电路中的关键时序概念。我们将重点讨论D触发器的时序约束,包括建立时间、保持时间和传播延迟,并学习如何分析一个电路以确保其能够可靠工作。
时序简介
上一节我们介绍了组合逻辑的时序。对于时序电路,其时序与D触发器的时序紧密相关。
D触发器的一个主要规定是,其输入D在采样时必须保持稳定。否则,我们可能会得到一个无效的值或更长的延迟。这类似于拍照:如果我们在时钟边沿采样时,输入D不稳定,我们就会得到一个模糊或错误的输出。
这些对触发器的约束可以分为输入时序约束和输出时序约束。
输入时序约束
输入时序约束是指,输入D必须在时钟边沿附近保持稳定。
具体来说,它必须在时钟边沿之前的一个建立时间内保持稳定。这是在时钟边沿之前D必须保持稳定的时间。它必须稳定,可以是1或0,但不能改变。
在时钟边沿之后,它还必须至少在一个保持时间内保持稳定。在整个时钟边沿附近的这个时间段内,D都必须保持稳定。
这些虚线表示D可以在其他时间改变,但必须在这个时钟边沿附近保持稳定。
建立时间和保持时间的总和,我们称之为孔径时间,类似于相机快门打开和关闭以拍照的时间。
让我们思考一下这些时间从何而来。回想一下,触发器是由两个背靠背的锁存器L1和L2组成的。
- 建立时间:当时钟从0变为1时,我们需要确保内部节点N1有一个稳定的值。因此,建立时间实质上是D信号传播到内部节点N1并稳定下来所需的时间。
- 保持时间:当时钟从0变为1时,时钟的反相信号
clock_bar驱动L1。clock_bar要经过一个反相器的延迟后才变为0。这个反相器的延迟就是保持时间,它意味着D在时钟边沿之后的一小段时间内仍然不能改变,直到那个锁存器在保持时间后被关闭。
通过观察触发器的内部结构,我们可以看到这些值的来源:T_hold是关闭L1所需的时间,而T_setup是D信号进入内部节点N1所需的时间。
输出时序约束
输出时序约束描述了时钟边沿之后,输出Q需要多长时间才会改变。
请记住,我们的输出Q是根据时钟边沿改变的,而不是根据D的改变。D的改变不会改变Q的输出,直到我们收到一个时钟边沿。
我们称之为时钟到Q的传播延迟。这是从时钟边沿到输出Q改变的时间。
- 传播时钟到Q:类似于组合逻辑中的最大传播延迟,
T_pcq是时钟边沿之后,Q可能发生改变的最长时间。 - 污染时钟到Q:
T_ccq是时钟边沿之后,Q可能发生改变的最短时间。
再次深入我们的触发器内部电路:当时钟边沿到来(时钟从0变为1),Q的改变仍然需要一些时间。这就是这里的延迟。传播时钟到Q是L2的最大延迟,而污染时钟到Q是L2的最小延迟。
在下图中,时钟从0变为1,Q将在时钟边沿之后的某个时间改变。最早可能改变的时间是T_ccq,最晚可能改变的时间是T_pcq。虚线表示它可能在这个时间段内的任何时间改变,但在这个时间段之外,Q是稳定的。
动态规则
我们定义动态规则,以确保信号在采样时有效。
对于一个同步时序电路,为了获得触发器有效的输出,其输入必须在时钟边沿附近的孔径时间(即建立时间和保持时间)内保持稳定。
具体来说,输入必须在时钟边沿之前至少一个建立时间内保持稳定,并且在时钟边沿之后至少一个保持时间内保持稳定。这样,触发器才能采样到一个有效且稳定的值。
寄存器对之间的延迟
当我们考虑动态规则时,我们总是在考虑一对寄存器。这些寄存器之间的延迟有一个最小延迟和一个最大延迟,这取决于电路元件的延迟。
假设我们有一个时钟边沿。信号从寄存器1传出,经过组合逻辑,最终到达寄存器2的输入。只有到那时,我们才能接收下一个上升时钟边沿。这个延迟的最小时间就是我们所说的周期时间或时钟周期,即时钟边沿之间的时间。
我们可以有电路延迟的最小值和最大值:
- 信号从寄存器1输出:最小延迟
T_ccq,最大延迟T_pcq。 - 信号经过组合逻辑:最小延迟(污染延迟)
T_cd,最大延迟(传播延迟)T_pd。 - 信号进入寄存器2:需要建立时间
T_setup。
建立时间约束
我们有两个约束来确定电路是否能可靠运行:建立时间约束和保持时间约束。首先讨论建立时间约束。
建立时间约束取决于从寄存器1到寄存器2的最大延迟。
寄存器2的输入必须在时钟边沿之前至少一个建立时间内保持稳定。当时钟边沿到来,驱动Q1改变,这可能需要的最长时间是T_pcq。然后Q1的改变驱动D2改变,这可能需要的最长时间是T_pd。最后,这个信号必须在下一个时钟边沿之前至少T_setup时间就到达D2并稳定下来。
因此,我们可以将其相加并得出结论:周期时间必须大于或等于从寄存器1传播到寄存器2所需的最长时间。
公式表示为:
T_c >= T_pcq + T_pd + T_setup
我们可以将其改写为关于传播延迟T_pd的表达式,因为作为数字设计师,我们可以控制组合逻辑的设计:
T_pd <= T_c - (T_pcq + T_setup)
理想情况下,如果进出触发器的时间为零,我们将有整个周期时间来完成所需的计算(T_pd)。但现实中,我们有将信号移出寄存器1和移入寄存器2的开销。因此,T_pcq + T_setup被称为时序开销。
记住,T_c的这个计算被称为建立时间约束,也称为周期时间约束。
保持时间约束
接下来,我们讨论进行时序分析、判断电路是否可靠工作时需要考虑的第二个约束:保持时间约束。
保持时间约束取决于从寄存器1经过组合逻辑到寄存器2的最小延迟。
寄存器2的输入信号D2必须在时钟边沿之后至少一个保持时间内保持稳定。在这种情况下,我们看的是R1和R2的同一个时钟边沿。我们需要确保信号不会“跑得太快”,在保持时间结束之前就开始改变D2。
我们需要计算信号通过这个电路的最快速度。最早从寄存器1出来的时间是T_ccq,加上经过组合逻辑的最短路径(污染延迟)T_cd,然后它可能开始改变D2的值。
只要D2在保持时间之后才开始改变,就是可以的。因此,约束条件是:信号开始改变D2的最早时间必须晚于保持时间。
公式表示为:
T_ccq + T_cd > T_hold
或者另一种写法:
T_hold < T_ccq + T_cd
同样,我们可以将其改写为关于T_cd的表达式:
T_cd > T_hold - T_ccq
时序分析实例
当我们进行时序分析以查看电路是否正确运行时,我们需要计算这两个约束。
如果保持时间约束不满足,电路在任何频率下都无法可靠运行。
考虑以下电路:我们有一些组合逻辑和两个寄存器。假设我们具有如下所示的时序特性:触发器具有特定的时序特性,各个门电路具有特定的传播和污染延迟。
首先,计算电路的传播延迟和污染延迟:
T_pd(最长路径):通过A或B,经过三个门。T_pd = 3 * 35 = 105 ps。T_cd(最短路径):通过D到Y'或C到X',只有一个门延迟。T_cd = 25 ps。
现在计算周期时间约束:
T_c >= T_pcq + T_pd + T_setup
T_c >= 50 + 105 + 60 = 215 ps
周期时间必须大于或等于215皮秒。如果以后我们发现门延迟实际上是40皮秒(比预想的慢),我们可以在制造后轻松调整时钟周期。
接下来考虑保持时间约束:
T_hold < T_ccq + T_cd
70 ps < 30 + 25 = 55 ps?
70皮秒小于55皮秒吗?不,70 > 55。这违反了约束!数据可能过快通过,并在保持时间结束前就开始改变寄存器2的输入。这是一个问题。
55皮秒不大于70皮秒。保持时间约束未满足,这是一个更棘手的问题,因为我们不能通过增加周期时间来修复它。电路变得不可靠,有时工作,有时不工作,这是最糟糕的bug类型。
我们需要使这些短路径变长。它们不够长,可能在保持时间结束前就开始改变。
解决方案:在每个短路径上添加一个缓冲器。现在我们的短路径有两个门延迟:T_cd = 2 * 25 = 50 ps。
重新检查约束:
T_hold < T_ccq + T_cd
70 ps < 30 + 50 = 80 ps
现在80 > 70,满足保持时间约束。添加缓冲器后(需注意不影响关键长路径),我们的电路可以可靠运行了。
总结
本节课中,我们一起学习了同步时序电路的核心时序概念。
我们定义了触发器的输入约束(建立时间和保持时间)和输出约束(传播和污染时钟到Q延迟)。我们引入了动态规则,以确保信号在采样时稳定。
我们推导并详细分析了两个关键的时序约束公式:
- 建立时间约束:
T_c >= T_pcq + T_pd + T_setup,它决定了电路的最小工作时钟周期。 - 保持时间约束:
T_hold < T_ccq + T_cd,它确保信号不会过快传播导致采样错误。
最后,通过一个实例,我们演练了如何计算这些约束,并发现当保持时间约束不满足时,必须通过增加短路径延迟(例如添加缓冲器)来修复,而无法通过降低时钟频率解决。

记住,完整的时序分析必须同时检查这两个约束,缺一不可。
041:时钟偏移 ⏰

在本节中,我们将学习时钟偏移的概念。时钟偏移是指时钟信号到达电路中不同寄存器的时间差异。我们将探讨这种差异如何影响电路的时序约束,并学习如何计算考虑时钟偏移后的最小周期时间和最小传播延迟。
概述
在理想情况下,时钟信号会同时到达电路中的所有寄存器。然而,由于物理布局和导线延迟,时钟信号到达不同寄存器的时间实际上存在差异,这种现象称为时钟偏移。本节课中,我们将分析时钟偏移对建立时间约束和保持时间约束的影响,并推导出相应的计算公式。
时钟偏移的定义
上一节我们介绍了理想的同步时序电路模型。本节中我们来看看一个现实因素:时钟偏移。
时钟信号无法同时到达所有寄存器。这是因为寄存器在物理空间上是分离的。在一个大型芯片上,一些寄存器可能位于芯片的顶部,而另一些位于底部。如果时钟信号从芯片的某个点输入,那么到达远处寄存器就会产生较长的延迟。
这种时钟边沿到达时间的差异称为时钟偏移。以下是一个示例,展示了两个时钟信号 Clock1 和 Clock2。它们的频率相同,都源自同一个主时钟,但边沿存在时间差。例如,Clock2 的边沿比 Clock1 的边沿更早到达。
Clock1: __|‾‾‾|___|‾‾‾|___|‾‾‾|___
Clock2: _|‾‾‾|___|‾‾‾|___|‾‾‾|____
^ ^
早期边沿 延迟边沿
这两个边沿之间的最大可能时间差记为 T_skew。在分析时钟偏移时,我们将进行最坏情况分析,以确保动态约束在任何寄存器对之间都不会被违反。系统中存在许多寄存器,我们会找到最大的延迟差,并确保设计能满足此最坏情况。其他寄存器对之间的时钟偏移可能较小,但只要设计能满足最大偏移,也就能满足那些较小偏移的情况。
考虑时钟偏移的建立时间约束
现在,让我们讨论在考虑时钟偏移 T_skew 时,如何建立时间约束或周期时间约束。
在最坏情况下,假设 Clock2 的边沿比 Clock1 的边沿早。我们重新绘制时钟波形图。Clock1 驱动第一个寄存器 R1,Clock2 驱动第二个寄存器 R2。Clock2 的边沿较早,Clock1 的边沿相对延迟。它们的周期时间 T_c 仍然相同。
数据在 Clock1 的边沿被锁存进 R1。之后,数据最早在 T_ccq 后从 R1 输出,然后经过组合逻辑的传播延迟 T_pd,最后必须在 Clock2 的下一个边沿到来之前,满足寄存器 R2 的建立时间 T_setup。
关键点在于:数据从 R1 输出到被 R2 采样,可用的时间不再是整个周期 T_c,而是 T_c 减去两个时钟边沿之间的差值 T_skew。
因此,新的建立时间约束不等式为:
T_c - T_skew >= T_ccq + T_pd + T_setup
将 T_skew 移到不等式右边,我们得到计算最小周期时间的公式:
T_c >= T_ccq + T_pd + T_setup + T_skew
与不考虑偏移的公式 T_c >= T_ccq + T_pd + T_setup 相比,周期时间 T_c 的要求增加了 T_skew。由于时钟频率 f = 1 / T_c,这意味着时钟偏移会迫使最大工作频率降低。
我们也可以将其改写为对组合逻辑最大传播延迟 T_pd 的约束:
T_pd <= T_c - T_ccq - T_setup - T_skew
考虑时钟偏移的保持时间约束
接下来,我们分析时钟偏移对保持时间约束的影响。
对于保持时间约束,我们需要考虑的最坏情况是 Clock2 的边沿比 Clock1 的边沿晚。此时,我们关注的是同一个时钟边沿(例如上升沿)的行为。Clock1 的边沿触发 R1 输出新数据,而 Clock2 较晚的边沿仍在对 R2 中前一个周期的数据进行采样。
问题在于:R1 输出的新数据可能过快通过组合逻辑,在 Clock2 的保持时间窗口结束之前就到达并改变了 R2 的输入 D2,从而破坏了 R2 对旧数据的正确保持。
因此,为了防止这种“竞争”,数据从 R1 输出后,其最早可能到达 R2 的时间(即 T_ccq + T_cd)必须大于 R2 所需的保持时间 T_hold,再加上时钟偏移量 T_skew。因为 Clock2 的采样边沿来得更晚,保持时间窗口也结束得更晚。
新的保持时间约束不等式为:
T_ccq + T_cd >= T_hold + T_skew
将其改写为对组合逻辑最小污染延迟 T_cd 的约束:
T_cd >= T_hold + T_skew - T_ccq
与不考虑偏移的公式 T_cd >= T_hold - T_ccq 相比,对 T_cd 的要求增加了 T_skew。这意味着时钟偏移加大了对电路最短路径延迟的下限要求,使得满足保持时间约束变得更困难。
总结
本节课中我们一起学习了时钟偏移的概念及其对电路时序的关键影响。
以下是核心要点:
- 时钟偏移是时钟信号到达不同寄存器的实际时间差,记为
T_skew。 - 对建立时间的影响:时钟偏移减少了可用于数据传播的有效周期时间,从而提高了对最小周期时间的要求,限制了最大时钟频率。其约束公式为:T_c >= T_ccq + T_pd + T_setup + T_skew。
- 对保持时间的影响:时钟偏移延长了接收寄存器采样边沿的保持时间窗口,从而提高了对组合逻辑最小污染延迟的要求。其约束公式为:T_cd >= T_hold + T_skew - T_ccq。

因此,在物理设计时,必须尽量减小时钟偏移,并在此基础进行严格的时序验证,以确保电路在指定频率下能可靠工作。
042:亚稳态 📊

在本节中,我们将探讨数字电路中的一个重要概念——亚稳态。我们将了解它何时发生、为何会发生,以及它对电路行为的影响。理解亚稳态对于设计可靠、稳定的数字系统至关重要。
亚稳态的发生条件
上一节我们讨论了动态约束,本节中我们来看看当这个约束被违反时会发生什么。
亚稳态发生在输入信号在触发器的建立-保持时间窗口内发生变化时。这个时间窗口也被称为孔径时间。

异步输入与亚稳态
那么,亚稳态具体在什么情况下发生呢?它主要发生在处理异步输入时。例如,用户输入(如按下键盘按键)就是典型的异步输入。我们没有一个时钟信号来控制用户何时按下按键,而且要求用户在纳秒级时间内做出反应也是不现实的。
以下是异步输入进入触发器时可能发生的几种情况:
- 情况1:输入信号在时钟沿的建立-保持时间窗口之前发生变化。没有问题。
- 情况2:输入信号在时钟沿的建立-保持时间窗口之后发生变化。没有问题。
- 情况3:输入信号正好在建立-保持时间窗口内发生变化。这就是问题所在。
亚稳态的物理表现
当问题发生时,触发器内部会发生什么?让我们回想一下触发器的内部结构(基于SR锁存器和交叉耦合的或非门)。在时钟采样时刻,如果输入D正好在变化,内部节点(如L1)可能没有足够的时间稳定到逻辑高电平(1)或低电平(0)。
结果可能是,本应采样到的电压值(例如1V或0V)变成了一个中间值,比如0.5V。这个电压既不是明确的1,也不是明确的0。
双稳态器件与亚稳态
亚稳态实际上发生在任何双稳态器件中。对于触发器,它有两个稳定状态:Q=1 和 Q=0。但在这两个状态之间,还存在一个亚稳态。
如果触发器进入这个亚稳态,它可能会在那里停留一段不确定的时间——可能很短,也可能很长。在此期间,其输出是一个无效的中间电压值。
亚稳态的危害
这个中间电压值被送到后续的组合逻辑电路时,问题就出现了。部分逻辑门可能将其解释为1,另一部分可能将其解释为0。这会导致整个电路功能紊乱,行为完全不符合预期。
后果的严重性取决于应用场景:
- 在医疗设备(如X光机)中,可能导致剂量错误。
- 在汽车控制系统中,可能导致故障。
- 在儿童玩具中,可能只是需要重启。
亚稳态的电路模型分析
为了更好地理解,我们可以将处于保持状态的触发器内部(两个交叉耦合的反相器)简化为一个带有反馈的缓冲器模型。
一个普通缓冲器的直流传输特性是:低输入产生低输出,高输入产生高输出,中间有一个过渡区。

当我们加上反馈后:
- 如果初始输出是稳定的高电平(1),反馈回来仍是高输入,系统保持稳定。
- 如果初始输出是稳定的低电平(0),反馈回来仍是低输入,系统保持稳定。
- 如果初始输出是中间值(如0.6V),反馈回来作为输入,输出可能仍是0.6V。系统就会卡在这个亚稳态点附近,无法自行恢复到1或0。
亚稳态的概率与解决时间
如果输入在一个随机时间变化,特别是在孔径时间内,输出Q进入亚稳态的概率是存在的。输出从亚稳态分辨为一个确定的1或0所需的时间,称为分辨时间。
输出分辨时间超过某个等待时间t的概率可以用以下公式描述:
P(t_resolution > t) = (T0 / Tc) * e^(-t / τ)
其中:
T0和τ是电路固有的时间常数。Tc是时钟周期。T0/Tc直观地表示了输入信号在“坏时间”(孔径时间内)变化的概率。τ表示电路从亚稳态点被驱赶到稳定电源轨(1或0)的速度。
这个公式表明,如果一个触发器采样到了亚稳态输入,只要你等待足够长的时间(t 很大),输出以很高的概率(尽管不是100%)会最终分辨为一个有效的1或0。

本节课中我们一起学习了亚稳态的概念。我们了解到,当异步输入违反触发器的动态约束时,电路可能进入一个既非1也非0的中间状态,并可能持续不确定的时间。这会导致系统功能错误。我们通过电路模型分析了其原理,并介绍了描述亚稳态发生概率和分辨时间的数学模型。在设计数字系统时,必须通过同步器等技术来管理亚稳态风险,以确保可靠性。
043:同步器 🔄

在本节中,我们将学习同步器的基本原理。同步器用于处理异步信号,确保它们能够安全地被时钟系统读取,从而降低系统因亚稳态而失效的概率。我们将探讨其工作原理、关键参数以及如何评估其可靠性。
我们基于等待信号脱离亚稳态的时间来构建同步器。
每个用户界面都会产生一系列输入。用户按下按钮,无论是在智能手机、电脑还是键盘上,这些动作的发生时间与系统时钟边沿是不同步的。同步器的目标就是降低系统因此失效的概率。
同步器永远无法将失效概率降为零。如图所示,我们有一个输入信号 RD,经过一个同步器后,输出信号 Q 被送入系统。
一个同步器由两个首尾相连的触发器构成。第一个触发器是采样触发器,用于采样这个异步输入 D。然后,我们允许它利用触发器内部的反馈,在一段时间内进行信号再生。这段时间是时钟周期 TC 减去建立时间 T_setup。在这两个触发器之间,信号有足够的时间在时钟周期内再生,稳定到高电平或低电平。
因此,我们读取信号的时间 T 不再是随机的,而是我们允许其再生的等待时间,即 TC - T_setup。代入失效概率公式 P(failure) = (T0 / T) * e^(-T / τ),我们可以计算出同步器的失效概率。
我们还有另外两个衡量指标:每秒失效概率和平均无故障时间。如果我们每秒按下按钮 n 次,那么每秒失效概率就是单次失效概率乘以 n。平均无故障时间则是每秒失效概率的倒数。
例如,假设有一个飞镖盘,我击中某个特定区域的概率是0.1。如果我每秒投掷5次,那么每秒失效概率就是 0.1 * 5 = 0.5。平均无故障时间就是 1 / 0.5 = 2 秒,即平均每2秒会发生一次“失效”(未击中目标)。另一个例子是抛硬币,得到正面的概率是0.5。如果每秒抛一次,那么每秒失效概率是0.5,平均无故障时间也是2秒。
以下是一个同步器示例,它包含两个触发器:
- 第一个是采样触发器。
- 第二个触发器允许信号在两个采样点之间,借助触发器内部的反馈,再生到高电平或低电平。
其参数如下:
- 时钟周期
TC = 2纳秒(对应频率500MHz) - 时间常数
T0 - 建立时间
T_setup - 每秒事件数
n
我们可以利用公式计算失效概率、每秒失效概率和平均无故障时间。平均无故障时间就是每秒失效概率的倒数。
代入数字计算后,我们得到单次失效概率为 5.6 × 10^-6。乘以 n 得到每秒失效概率,再取倒数得到平均无故障时间,结果是5小时。这意味着系统平均每5小时会失效一次。当然,实际失效可能发生在下一秒,也可能在一年后,但平均而言是5小时。
这个结果是否可以接受取决于具体应用。如果是我的电脑平均每5小时死机一次,我可能会选择退货。但如果是一个给孩子玩的、没有安全隐患的玩具,即使每5小时出一次问题,可能也勉强可以接受。
那么,如何进一步降低失效概率呢?我们可以在输出端再增加一个触发器。现在,输出 Q 在送入系统前,会经过第三个触发器。这样,信号再生的等待时间就从原来的 TC - T_setup 增加了一倍。你可以自行计算数值,其效果是指数级改善的。
通过增加触发器,我们延长了信号再生到稳定高电平或低电平的时间。只有在信号极大概率稳定为1或0之后,它才会被送入系统。

本节课中,我们一起学习了同步器的工作原理。我们了解到,同步器通过串联触发器为异步信号提供再生时间,从而降低亚稳态导致的系统失效概率。我们学习了如何计算失效概率、每秒失效概率和平均无故障时间这三个关键指标,并通过实例分析了其可靠性。最后,我们探讨了通过增加触发器级数来指数级提高系统稳定性的方法。理解这些概念对于设计可靠的数字系统至关重要。
044:并行性 🚀

在本节中,我们将学习数字电路设计中两种关键的并行性概念:空间并行性和时间并行性。我们将通过简单的比喻和电路示例来理解它们如何影响系统的延迟和吞吐量。
概述
并行性是提高系统性能的核心技术。它主要分为两种类型:空间并行性和时间并行性。空间并行性通过复制硬件来同时执行多个任务,而时间并行性则通过将任务分解为多个阶段,像流水线一样在不同时间处理不同部分。理解这两种并行性对于设计高效的数字系统至关重要。
空间并行性与时间并行性
上一节我们介绍了并行性的重要性,本节中我们来看看它的两种具体形式。
空间并行性本质上是复制硬件。例如,如果我们有一个加法器或某个计算电路,我们可以不使用一个,而是使用两个、三个或四个。这意味着我们将同时进行大量操作。这类似于个人电脑中的四核处理器,我们复制了硬件以允许在完全相同的时刻进行并行计算。
时间并行性则类似于装配线。我们将一个任务分解为多个阶段,并同时执行任务的不同部分。例如,在汽车装配线上,第一阶段组装底盘,第二阶段安装轮胎,第三阶段安装内饰。当第一辆汽车完成第一阶段进入第二阶段时,第二辆汽车就可以开始第一阶段的工作。这样,随着时间的推移,我们就有多辆汽车同时处于不同的制造阶段。“Temporal”一词意为“时间”,因此时间并行性是在时间维度上创造并行操作。
性能分析:令牌、延迟与吞吐量
在理解了两种并行性的基本概念后,我们需要一套方法来量化分析系统在并行性方面的表现。
以下是几个核心概念:
- 令牌:指一组输入,经过系统处理后产生一组输出。
- 延迟:指一个令牌从系统开始到结束所需的时间,即完成整个计算的时间。
- 吞吐量:指单位时间内系统产生的令牌数量。
并行性可以增加吞吐量,但通常无助于改善延迟(完成任何给定任务所需的时间)。事实上,它通常会使延迟略微恶化。
饼干烘焙示例
让我们通过一个生动的例子来具体理解延迟和吞吐量。假设本·比特尔想烤一些饼干来庆祝他的交通灯控制系统安装成功。
无并行性的情况
烤饼干需要5分钟揉面,15分钟烘烤。
- 延迟 = 5分钟 + 15分钟 = 20分钟。
- 吞吐量 = 每20分钟1盘饼干。因为20分钟是1/3小时,所以吞吐量是 每小时3盘。
应用空间并行性
本请来艾丽莎·黑客帮忙,使用她自己的烤箱。
- 延迟:等待饼干烤好仍需20分钟。
- 吞吐量:现在每20分钟可以产出2盘饼干,即 每小时6盘。
使用空间并行性,吞吐量翻倍,但延迟保持不变。
应用时间并行性
本自己工作,但将流程分为两个阶段(揉面/装盘 和 烘烤),并准备两个烤盘。
- 延迟:获得第一盘饼干仍需20分钟。
- 吞吐量:在第一盘烘烤时,他可以准备第二盘。因此,从第一盘之后,他每15分钟就能产出一盘饼干。15分钟是1/4小时,所以吞吐量是 每小时4盘。
结合两种并行性
如果本和艾丽莎都采用时间并行性(流水线)工作。
- 吞吐量:将变为每15分钟产出2盘饼干,即 每小时8盘。
电路中的并行性
现在,让我们看看这些概念如何对应到我们的数字电路设计中。
假设我们有一个计算任务,例如一个四输入异或函数:Y = A ⊕ B ⊕ C ⊕ D。假设每个异或门的传播延迟 T_{pd-XOR} 是 50 皮秒,那么整个组合逻辑的 T_{pd} 为 100 皮秒。
原始电路(无并行性)
为简化分析,我们假设一些时序参数:
- 寄存器时钟到输出时间
T_{pcq} = 60 ps - 寄存器建立时间
T_{setup} = 40 ps - 组合逻辑传播延迟
T_{pd} = 900 ps(对应约450 ps每级异或门)
那么,系统时钟周期 T_c 必须满足:
T_c >= T_{pcq} + T_{pd} + T_{setup} = 60 + 900 + 40 = 1000 ps = 1 ns
- 延迟 ≈ 1个周期 = 1 ns
- 吞吐量 = 每周期1次计算 = 每秒10亿次计算(1 GHz)
应用空间并行性
我们复制两份相同的计算电路。
- 延迟:单次计算延迟仍约为1 ns。
- 吞吐量:现在每个周期可以完成两次计算,吞吐量翻倍。
应用时间并行性(流水线)
我们在组合逻辑中间插入一个寄存器,将计算分为两个阶段。

现在,每个阶段的组合逻辑延迟减半。最长路径延迟变为单级异或门的 T_{pd}(450 ps)。
新的时钟周期为:
T_c >= T_{pcq} + T_{pd-stage} + T_{setup} = 60 + 450 + 40 = 550 ps
- 延迟:现在一个计算需要2个周期完成,总延迟 = 2 * 550 ps = 1100 ps。延迟略有增加。
- 吞吐量 = 1 / T_c = 1 / 550 ps ≈ 每秒18.2亿次计算。相比原始的1 GHz,吞吐量提升了约82%。
通过流水线,我们以略微增加延迟为代价,显著提高了吞吐量。理论上,如果组合逻辑能被分成更多均衡的阶段(K级),则时钟周期可以进一步缩短至约 T_c ≈ T_{pd-original}/K + 时序开销,从而获得更高的吞吐量。当然,系统启动时需要时间“填充”流水线,结束时需要“排空”流水线,这会带来一些初始和结束的开销。
总结

本节课中我们一起学习了数字电路中的两种核心并行技术。
- 空间并行性通过复制硬件资源来同时处理多个任务,能有效提升吞吐量,但不减少单个任务的延迟。
- 时间并行性(流水线)通过将任务分解为多个阶段并重叠执行,也能大幅提升吞吐量,但可能会略微增加单个任务的延迟,并引入流水线填充和排空的开销。
在实际的处理器设计(如RISC-V架构)中,这两种技术被广泛应用,是提升计算机性能的关键。
045:硬件描述语言简介 🧠

在本章中,我们将学习硬件描述语言,简称 HDL。
硬件描述语言允许我们使用一种语言来描述逻辑,包括组合逻辑和时序逻辑。我们将讨论如何描述组合逻辑、如何描述延迟(仅在仿真中),以及如何使用所谓的“always块”来描述时序逻辑。此外,我们还将涵盖阻塞与非阻塞赋值、有限状态机、参数化模块和测试平台。


概述
在本节中,我们将介绍硬件描述语言的基本概念、它们的作用以及两种主流语言。我们将学习如何用 HDL 描述电路的行为和结构,并理解从 HDL 代码到实际硬件门电路的转换过程。
HDL 的作用
硬件描述语言允许我们指定逻辑功能。它是一种计算机辅助设计工具,能够根据我们使用的描述语言生成综合后的门电路。如今,大多数商业设计都使用 HDL 而非原理图来完成。
以下是两种主流的 HDL:
- SystemVerilog: 于 1984 年开发,最初称为 Verilog,后成为 IEEE 标准。在 2005 年扩展后,现称为 SystemVerilog。
- VHDL: 于 1981 年由美国国防部开发,同样是 IEEE 标准,并于 2008 年更新。
设计流程
首先,我们使用选择的 HDL 描述电路。我们将展示如何在 SystemVerilog 中完成。接着,我们可以对电路进行仿真,即施加输入并检查输出是否正确。这种仿真能在电路投入硬件制造前进行调试,从而节省数百万美元的成本。
然后,我们将对电路进行综合。综合过程将 HDL 代码转换为一种称为“网表”的格式,该格式描述了硬件中的门电路及其连接关系。
重要概念:HDL 不是编程语言
这一点非常重要,因此用红色强调。硬件描述语言不是编程语言。因此,当你用 HDL 描述电路时,必须清楚你期望从所写的 HDL 代码中综合出什么样的硬件。
如果你仅仅将其视为软件语言,将会遇到麻烦,因为你可能会产生无法工作或规模远超所需的硬件。所以,再次强调:编写 HDL 时,要思考你期望它产生的硬件。
模块类型
模块分为两种类型:行为模块和结构模块。模块都有输入(图中左侧的 A、B、C)和输出(Y),即它们都有接口。但这两种模块的区别在于:
- 行为模块: 描述模块的功能,即电路的行为,但不说明内部组件是如何组合在一起的。
- 结构模块: 描述模块是如何组合而成的,即由子模块构建而成。
通常,底层模块是行为模块,而高层模块则以结构化的方式将这些底层行为模块组合在一起,即实例化并连接一系列子模块。
模块声明示例
以下是一个 SystemVerilog 中的模块声明示例:
module example (input A, B, C, output Y);
// 模块体将放在这里
endmodule
我们可以看到这里有关键字 module,后面跟着模块名 example,这定义了模块的名称。接着是输入(A, B, C)和输出(Y)。如果我们要绘制系统的黑盒图,它看起来就是这样的。模块声明的最后需要有关键字 endmodule,表示模块结束。在这之间,我们将放置描述模块功能的模块体。
行为模块示例
这是一个行为型 SystemVerilog 模块。它包含了我们之前提到的接口、module 关键字、模块名 example 和 endmodule 关键字。现在,我们来看模块体,即模块的功能部分。
在这个例子中,它是一个积之和表达式:
Y = (~A & ~B & ~C) | (A & ~B & ~C) | (A & ~B & C) | (A & B & C)
我们使用 SystemVerilog 语言描述了电路的行为,而没有具体说明“实例化一个与门和一个或门”。我们实际上让综合工具来决定如何用门电路来实现这些功能。
编写完 SystemVerilog 模块后,我们可以对其进行仿真。图中显示了输入 A、B、C 和输出 Y。例如,开始时 A、B、C 为 0,0,0,我们看到输出 Y 为 1,这符合 ~A & ~B & ~C 项的结果。当输入为 0,0,1 时,输出为 0,因为没有哪个乘积项会迫使输出为 1。我们可以继续观察其他输入组合下的输出,通过仿真来验证。如果输出不符合预期,我们可以返回修改 SystemVerilog 代码以纠正错误。
下一步是综合 SystemVerilog 模块。综合工具将我们的模块转换为门电路。我们可以看到它被转换成了 Y = (~B & ~C) | (A & ~B)。综合工具并不总是最小化方程,但在这个简单案例中它做到了。我们注意到电路与我们描述的形式略有不同,但功能是相同的,并且它使用了最少数量的门电路来实现该功能。
SystemVerilog 语法规则
以下是 SystemVerilog 的一些基本语法规则:
- 区分大小写: 这是许多人遇到难以排查错误的地方。例如,信号
reset(小写 r)与Reset(大写 R)是两个不同的信号。 - 命名规则: 名称不能以数字开头。例如,
2mux是无效的名称,而mux2是有效的。 - 空白符: 空白符(空格、制表符、换行)会被忽略。
- 注释: 我们可以在代码中添加注释。单行注释以
//开头。多行注释以/*开始,以*/结束。
结构模块示例
这里有一个结构型 SystemVerilog 模块的例子,模块名为 Nand3。
它仍然具有接口 A、B、C 和 Y。现在我们注意到两件事。首先,有一个名为 n1 的信号,它是一个内部信号。其次,我们在这个模块内部实例化了一个三输入与门 and3,并将其输出连接到内部信号 n1。然后,我们实例化了一个反相器模块 inv,将 n1 作为其输入,并将其输出连接到 Y。
这个模块是结构化的,因为我们实例化了子模块(三输入与门和反相器),并使用内部信号 n1 将它们连接起来。n1 不是输入或输出,只是一个内部连接信号。
module Nand3 (input A, B, C, output Y);
wire n1; // 内部连线
and3 and_gate (.A(A), .B(B), .C(C), .Y(n1)); // 实例化三输入与门
inv inverter (.A(n1), .Y(Y)); // 实例化反相器
endmodule
总结

本节课我们一起学习了硬件描述语言的基础知识。我们了解了 HDL 的作用、两种主流语言(SystemVerilog 和 VHDL)以及从设计描述到仿真和综合的完整流程。我们重点区分了行为描述与结构描述,并通过示例学习了如何在 SystemVerilog 中声明模块、编写行为代码和结构代码。最后,我们记住了一个核心原则:HDL 描述的是硬件结构,而非软件流程,编写时必须时刻考虑其对应的硬件实现。
046:组合逻辑的SystemVerilog描述 🧠

在本节课中,我们将学习如何使用硬件描述语言(HDL)来描述组合逻辑电路。我们将重点介绍SystemVerilog中的位运算符、条件赋值、内部信号、运算符优先级以及位操作等核心概念。
位运算符与总线操作
上一节我们介绍了模块的基本结构,本节中我们来看看如何对多比特总线进行位运算。
module gates (
input logic [3:0] A, B,
output logic [3:0] Y1, Y2, Y3, Y4, Y5
);
assign Y1 = A & B; // 与门
assign Y2 = A | B; // 或门
assign Y3 = A ^ B; // 异或门
assign Y4 = ~(A & B); // 与非门
assign Y5 = ~(A | B); // 或非门
endmodule
在上述代码中,A和B是4比特总线(位3到0),输出Y1到Y5也是4比特总线。每个逻辑门操作都独立应用于总线的每一位。例如,Y1[0] = A[0] & B[0],Y1[1] = A[1] & B[1],依此类推。
需要注意的是,对于与非门(NAND)的写法。不能直接写~A & B,因为取反运算符~的优先级高于与运算符&,这会导致先对A取反再与B相与,结果并非我们想要的~(A & B)。因此,必须使用括号:~(A & B)。
缩减运算符
为了简化对总线所有位进行相同操作(如所有位相与)的代码,我们可以使用缩减运算符。
// 不使用缩减运算符的冗长写法
assign y = A[7] & A[6] & A[5] & A[4] & A[3] & A[2] & A[1] & A[0];
// 使用缩减运算符的简洁写法
assign y = &A; // 对8比特总线A的所有位进行与操作
缩减运算符将运算符(如&, |, ^)放在信号名前,即可对该信号的所有位执行相应的操作。
条件赋值(三目运算符)
接下来,我们学习如何使用条件赋值来实现一个多路选择器(MUX)。
module mux2 (
input logic [3:0] D0, D1,
input logic S,
output logic [3:0] Y
);
assign Y = (S == 1‘b1) ? D1 : D0;
endmodule
这是一个2选1多路选择器,输入D0和D1以及输出Y都是4比特总线。S是1比特的选择信号。三目运算符? :的语法是:条件 ? 表达式1 : 表达式2。如果条件为真(S为1),则Y等于D1;否则,Y等于D0。
内部信号
在构建复杂逻辑时,使用内部信号可以使代码更清晰、更易于维护。以下是一个使用内部信号P(传播)和G(生成)构建的1比特全加器示例。
module fulladder (
input logic A, B, Cin,
output logic Sum, Cout
);
logic P, G; // 内部信号声明
assign P = A ^ B; // 传播信号
assign G = A & B; // 生成信号
assign Sum = P ^ Cin; // 和输出
assign Cout = G | (P & Cin); // 进位输出
endmodule
内部信号P和G在模块内部定义和使用,不直接作为模块的输入或输出。这种结构清晰地表达了全加器的逻辑:和(Sum)是A、B、Cin三者的异或,而进位(Cout)在A和B都为1(生成)或其中一位为1且进位输入为1(传播)时产生。
运算符优先级
在编写表达式时,了解运算符的优先级至关重要,它决定了运算的执行顺序。以下是SystemVerilog运算符从高到低的优先级列表:
~(按位取反、逻辑非)*,/,%(乘、除、取模)+,-(加、减)&(与)^,^~(异或、同或)|(或)? :(三目条件运算符)
根据优先级,我们可以判断何时需要括号。例如:
assign w = A & B | ~B & C;是正确的,因为&的优先级高于|,~的优先级最高。- 如果想实现
Y = (A | B) & (C | D),则必须使用括号,否则会先计算B & C,违背原意。
位操作与拼接
SystemVerilog提供了强大的位选择和拼接功能,用于构建和操作总线。
以下是位操作和拼接运算符的示例:
assign Y = {A[2:1], {3{B[0]}}, 6‘b100010};
这条语句将多个部分拼接成一个12比特的信号Y:
A[2:1]:取A总线的第2位和第1位。{3{B[0]}}:将B[0]的值重复3次。6‘b100010:一个6比特的二进制常量。
下划线_可用于数字常量中以提高可读性(如16‘b0001_0101_1001_0010),它会被综合工具忽略。
结构描述:实例化模块
我们可以通过实例化已有的模块来构建更复杂的电路,这称为结构描述。
module mux2_8bit (
input logic [7:0] D0, D1,
input logic S,
output logic [7:0] Y
);
// 实例化两个4比特的2选1MUX
mux2 lsb_mux (.D0(D0[3:0]), .D1(D1[3:0]), .S(S), .Y(Y[3:0]));
mux2 msb_mux (.D0(D0[7:4]), .D1(D1[7:4]), .S(S), .Y(Y[7:4]));
endmodule
这里,我们通过实例化两个之前定义的4比特mux2模块,构建了一个8比特的2选1多路选择器。一个处理低4位([3:0]),另一个处理高4位([7:4]),选择信号S同时控制两者。
三态缓冲器
最后,我们看看如何描述具有高阻态(‘z)输出的三态缓冲器。
module tristate (
input logic [3:0] A,
input logic enable,
output tri [3:0] Y // 注意输出类型为‘tri‘(三态)
);
assign Y = (enable == 1‘b1) ? A : 4‘bz;
endmodule
当使能信号enable为1时,输出Y等于输入A;当enable为0时,输出Y变为高阻态(4‘bz),这意味着该输出线 effectively 断开连接,允许其他驱动源控制该线路。这在总线共享的场景中非常有用。
总结

本节课中我们一起学习了使用SystemVerilog描述组合逻辑的多种方法。我们从基本的位运算符和总线操作开始,然后探讨了用于简化代码的缩减运算符和用于实现条件逻辑的三目运算符。我们还学习了通过定义内部信号来构建清晰、模块化的设计(如全加器),并深入理解了运算符优先级以避免逻辑错误。此外,我们掌握了强大的位选择和拼接技术,以及通过实例化现有模块进行结构描述的方法。最后,我们了解了如何创建具有高阻态输出的三态缓冲器。掌握这些基础是进行更复杂数字电路设计的关键。
047:SystemVerilog仿真中的延迟 ⏱️
在本节中,我们将学习SystemVerilog硬件描述语言中延迟的概念。我们将了解如何指定延迟,更重要的是,理解这些延迟仅用于仿真目的,而非描述硬件的实际物理延迟。
概述

在硬件设计中,信号传播需要时间。为了在仿真中更真实地观察信号间的因果关系和时序行为,SystemVerilog允许我们为赋值语句添加延迟。然而,核心要点是:这些延迟仅用于仿真,不代表硬件的实际物理延迟。它们帮助我们在仿真波形中清晰地看到信号变化的先后顺序和逻辑关系。
上一节我们介绍了组合逻辑的建模,本节中我们来看看如何在仿真中引入时间维度。
延迟的语法与含义
在SystemVerilog中,延迟通过在赋值语句中使用 # 符号后跟一个数字来指定。这个数字代表仿真器在计算右侧表达式后,等待多少个“仿真时间单位”再将结果赋值给左侧信号。
代码示例:
assign #1 Abar = ~A; // 延迟1个时间单位
这条语句意味着:每当输入信号 A 发生变化,仿真器会计算 ~A 的值,但会等待1个时间单位后,才将这个新值赋给输出信号 Abar。
需要再次强调,这里的“1个时间单位”是在仿真环境中定义的(例如1ps),它没有直接的物理意义,只是为了在仿真波形中可视化信号变化的时序。
深入分析一个带延迟的模块
让我们通过一个具体的模块例子来详细理解延迟在仿真中的行为。
模块定义:
module example (input logic A, B, C, output logic Y);
logic Abar, Bbar, Cbar;
logic N1, N2, N3;
assign #1 Abar = ~A;
assign #1 Bbar = ~B;
assign #1 Cbar = ~C;
assign #2 N2 = A & Bbar;
assign #2 N3 = B & Cbar;
assign #3 N1 = (Abar & B) | (Bbar & Cbar);
assign #4 Y = N1 | N2 | N3;
endmodule
仿真波形分析
假设在仿真时间 t=0 时,输入 A、B、C 同时从高电平变为低电平。
以下是信号变化的逐步推演:
-
第一级延迟(
#1):- 输入
A,B,C在t=0变化。 - 经过1个时间单位(
t=1),反相信号Abar,Bbar,Cbar被更新为各自输入的反相值。
- 输入
-
第二级延迟(
#2):- 信号
A和Bbar是N2的输入。A在t=0变为0,Bbar在t=1变为1。 - 根据
assign #2 N2 = A & Bbar;,任何右侧输入变化后,等待2个单位再更新N2。 A的最后一次变化在t=0,因此t=2时,仿真器会用A=0和Bbar的当前值计算N2。由于是“与”操作,0 & Bbar的结果恒为0,因此N2在t=2被赋值为0。- 同理,
N3也在t=2被更新为0(因为B=0)。
- 信号
-
第三级延迟(
#3):- 信号
Abar,B,Bbar,Cbar是N1的输入。它们分别在t=1(Abar,Bbar,Cbar)和t=0(B)发生变化。 - 根据
assign #3 N1 = (Abar & B) | (Bbar & Cbar);,任何右侧输入变化后,等待3个单位再更新N1。 - 最后一个变化的输入是
t=1的Abar,Bbar,Cbar。因此,在t=4时,仿真器用所有输入的当前值计算N1。代入数值(1 & 0) | (1 & 1),计算结果为1。因此N1在t=4被赋值为1。
- 信号
-
第四级延迟(
#4):- 信号
N1,N2,N3是输出Y的输入。N1在t=4变为1。 - 根据
assign #4 Y = N1 | N2 | N3;,任何右侧输入变化后,等待4个单位再更新Y。 - 最后一个变化的输入是
t=4的N1。因此,在t=8时,仿真器计算Y。由于是“或”操作,1 | N2 | N3的结果恒为1,因此Y在t=8被赋值为1。
- 信号
通过这个分析,我们可以看到延迟如何像涟漪一样在逻辑电路中传播,最终决定了输出 Y 在 t=8 时刻发生变化。
延迟仿真的关键点总结
以下是关于SystemVerilog仿真延迟的几个核心要点:
- 仿真专用:
#延迟仅用于仿真,综合工具会忽略它们。它们不定义芯片的实际工作速度。 - 建模因果关系:延迟的主要作用是帮助设计者在仿真波形中清晰地观察信号变化的因果关系和路径。
- 不影响逻辑功能:在理想的零延迟仿真中,只要输入稳定,输出会立即得到正确值。添加延迟后,输出结果在逻辑上保持不变,只是变化的时间点被推迟了。
- 工具优化:如示例所示,仿真器会进行逻辑优化。即使某个信号的延迟未到期,如果其值已能确定输出(如
0 & X = 0,1 | X = 1),仿真器可能会提前得出结果。
总结

本节课中我们一起学习了SystemVerilog中延迟的用法和意义。我们了解到,通过 # 符号可以为赋值语句添加仿真延迟,这使我们能够在波形图中直观地追踪信号在逻辑路径中的传播时序。必须牢记,这些延迟是纯粹的仿真工具,用于调试和验证,并不代表最终硬件电路的物理时序特性。硬件的实际速度由晶体管开关速度、导线长度等物理因素决定,需要通过静态时序分析等其它工具来评估。掌握仿真延迟,能帮助我们更好地理解和调试数字电路的行为。
048:SystemVerilog中的时序逻辑 🧠

在本节课中,我们将要学习如何使用SystemVerilog来描述时序逻辑电路,包括锁存器、触发器和有限状态机。我们将重点介绍描述这些电路的标准“惯用语”,并解释为何使用正确的编码风格至关重要。
上一节我们介绍了如何使用赋值语句来指定组合逻辑,本节中我们来看看如何指定时序逻辑。
SystemVerilog使用特定的“惯用语”来描述锁存器、触发器和有限状态机。这些惯用语是我们在SystemVerilog中用来明确指定“这应该是一个锁存器”或“这应该是一个触发器”的固定格式。其他编码风格可能看似正确,但会产生错误的硬件,因此使用我们将要讨论的这些惯用语风格非常重要。
指定这些锁存器和触发器的一般结构总是围绕一个称为“敏感列表”的部分。我们使用 always 关键字,后跟一个敏感列表,然后执行一条语句。每当敏感列表中的任何信号发生变化(即事件发生时),就应该执行该语句。
以下是描述一个D触发器的示例。
module flop (
input logic clk,
input logic [3:0] d,
output logic [3:0] q
);
always_ff @(posedge clk)
q <= d;
endmodule
我们有关键字 module 和模块名(这里我们选择了 flop,这取决于电路设计者的选择)。我们有输入 clk 和 d(这是一个4位输入),以及一个4位输出 q。这是我们使用的惯用语:always_ff 表示这应该是一个触发器。@(posedge clk) 中的 posedge 关键字表示在时钟的上升沿(从0变为1时)执行语句。因此,在时钟的上升沿,执行 q <= d 这条语句,将 d 的值传输给 q。当我们综合这段硬件描述时,会得到我们期望的D触发器。
我们也可以指定一个可复位的D触发器。
module flopr (
input logic clk,
input logic reset,
input logic [3:0] d,
output logic [3:0] q
);
always_ff @(posedge clk)
if (reset) q <= 4‘b0;
else q <= d;
endmodule
现在,除了时钟输入 clk,我们还添加了复位输入 reset。always 语句的这部分是相同的:always_ff @(posedge clk)。这意味着当时钟上升沿事件发生时,将执行语句。但它执行的语句现在不同了:如果 reset 为真,则 q 变为0;否则,q 正常地获取 d 的值。当 reset 为0时,它就像一个普通的D触发器。那么,这是一个异步还是同步可复位的触发器呢?复位操作仅在响应时钟边沿时发生,因此这是一个同步复位的D触发器。
我们也可以构建一个异步复位的触发器。
module flopr_async (
input logic clk,
input logic reset,
input logic [3:0] d,
output logic [3:0] q
);
always_ff @(posedge clk or posedge reset)
if (reset) q <= 4‘b0;
else q <= d;
endmodule
为此,我们需要将希望触发语句评估的事件放入 always 语句的敏感列表中。always_ff @(posedge clk or posedge reset) 表示整个 always 块中的语句将在时钟上升沿 或 复位信号上升沿发生时被评估。如果 reset 变为高电平(上升沿),它会触发评估。如果 reset 为1,q 会立即复位,而无需等待时钟上升。因此,这是一个异步复位的D触发器。从综合后的符号来看,你无法区分异步和同步复位,需要查看SystemVerilog代码。
我们还可以指定一个带使能端的D触发器。
module flopr_en (
input logic clk,
input logic reset,
input logic en,
input logic [3:0] d,
output logic [3:0] q
);
always_ff @(posedge clk or posedge reset)
if (reset) q <= 4‘b0;
else if (en) q <= d;
endmodule
这里有时钟 clk 和复位 reset,现在我们添加了使能输入 en。我们修改代码:如果 reset 为真,q 变为0;否则,如果 enable 为真,则 q 获取 d 的值。我们没有添加 else 语句,因为如果上述条件都不满足,触发器将保持它之前拥有的值(即保持 q 的前一个值)。这仍然是一个带使能端的异步复位触发器。你也可以将其改为同步复位并带使能端。
接下来,让我们讨论另一种我们实际上不会使用、并且通常在你的设计中也不应该使用的时序逻辑电路:锁存器。在本课程中,如果你产生了锁存器,那是一个错误。锁存器是一种当时钟为高电平时,q 跟随 d 变化的元件。
module latch (
input logic clk,
input logic [3:0] d,
output logic [3:0] q
);
always_latch
if (clk) q <= d;
endmodule
我们使用关键字 always_latch。如果 clk 为高,则 q 获取 d 的值;否则(隐含地),q 保持其值。这里的问题是,如果你没有完全指定组合逻辑(例如,在 if 语句中缺少 else 分支),则可能产生锁存器。在本课程中,你不应该期望出现锁存器。你可能会在综合工具的输出中看到一些警告,例如“警告:产生了锁存器”。你需要查看这些警告并修复问题,通常问题在于没有完全指定组合逻辑。再次强调,如果你的SystemVerilog代码中产生了锁存器,那就是一个错误。

本节课中我们一起学习了在SystemVerilog中描述时序逻辑的标准方法,包括D触发器、同步/异步复位触发器、带使能端的触发器,并强调了避免无意中生成锁存器的重要性。正确使用这些惯用语对于生成预期的硬件电路至关重要。
049:使用always语句的组合逻辑

在本节中,我们将学习如何使用Verilog中的always语句块来描述组合逻辑电路。我们将重点介绍if-else、case和casez语句的用法,并理解它们在组合逻辑设计中的关键作用。
使用always语句描述组合逻辑
上一节我们介绍了组合逻辑的基本描述方式。本节中我们来看看如何使用always语句块来实现组合逻辑。
某些语句必须放在always语句或always块中才能使用,例如if-else语句、case语句以及一种包含无关项的特殊case语句——casez。
以下是一个使用always语句块的组合逻辑模块示例。
always_comb begin
y1 = A & B;
y2 = A | B;
y3 = A ^ B;
y4 = ~(A & B);
y5 = ~(A | B);
end
这里我们使用always_comb来指示这是一个组合逻辑块。注意,在always块内部,我们直接使用y1 = A & B;这样的赋值语句,而不是assign y1 = A & B;。这个模块的功能与我们之前使用assign语句编写的模块几乎完全相同。
对于这个简单的例子,使用assign语句可能更简洁,代码行数更少,没有必要使用always块。但这个例子展示了如何使用always_comb语句块来生成组合逻辑。
七段数码管译码器示例
现在,我们来看一个更复杂的例子:一个七段数码管译码器。这是一个使用always块和case语句描述的组合电路。
以下是该译码器的核心代码结构:
always_comb begin
case (data)
4'b0000: segments = 7'b1111110; // 显示 0
4'b0001: segments = 7'b0110000; // 显示 1
4'b0010: segments = 7'b1101101; // 显示 2
// ... 其他数字 3-9 的编码
default: segments = 7'b0000000; // 默认情况,不显示
endcase
end
七段数码管的段编号通常为a, b, c, d, e, f, g。例如,要显示数字0,需要点亮a, b, c, d, e, f段(g段熄灭),对应的编码就是7'b1111110。
这是一个十进制译码器,只处理输入0到9。如果输入是10到15(即十六进制的A到F),则所有段都不点亮,输出全零。
这里有一个非常重要的点: default语句是必需的。对于组合逻辑,我们必须为所有可能的输入组合确定性地指定输出值。case语句类似于真值表,它直接列出了每种输入对应的输出。
如果你不包含default语句,综合工具可能会不确定在某些未列出的输入情况下该做什么,从而推断出一个锁存器(latch)来保持之前的值。这可能导致仿真正确,但实际硬件无法正常工作。因此,务必在case语句中包含default语句。
使用casez语句处理优先级电路
最后,我们介绍casez语句。这是我们在之前视频中讨论过的优先级电路的一个例子。
以下是优先级电路的casez实现:
always_comb begin
casez (a)
4'b1???: y = 4'b1000; // a[3]优先级最高
4'b01??: y = 4'b0100; // 其次a[2]
4'b001?: y = 4'b0010; // 其次a[1]
4'b0001: y = 4'b0001; // 最后a[0]
default: y = 4'b0000; // 无请求
endcase
end
注意代码中的问号?,它们表示“无关项”(don't cares)。例如,4'b1???意味着只要最高位a[3]为高,我们就不关心其他位的值是什么,因为此时优先级将给予a[3],输出y[3]为1。
casez语句允许我们在输入条件中包含这些无关项,从而更简洁地描述具有优先级逻辑的电路。
同样,必须包含default语句。在这个例子中,如果输入a是4'b0000(没有任何请求),这个组合没有被前面的分支覆盖,default语句会将其输出指定为4'b0000。
总结
本节课中我们一起学习了如何使用Verilog的always语句块来描述组合逻辑。关键要点包括:
if-else、case和casez语句必须放在always块内使用。- 使用
always_comb来明确指示一个组合逻辑块。 - 在
always块内对信号直接赋值,无需assign关键字。 - 使用
case语句可以像真值表一样直接描述逻辑功能。 - 至关重要:在
case或casez语句中,必须包含default分支来指定所有未覆盖输入情况的输出,否则综合工具可能推断出锁存器,导致非组合逻辑行为。 casez语句中的?表示无关项,常用于描述优先级逻辑。

通过掌握这些语句,你可以更灵活、更清晰地用HDL描述各种组合逻辑电路。
050:信号赋值 🧠
在本节中,我们将学习Verilog中信号赋值的两种关键方式:阻塞赋值与非阻塞赋值。理解它们的区别对于正确设计同步时序逻辑和组合逻辑至关重要。

上一节我们介绍了时序逻辑的基本结构,本节中我们来看看如何具体地给信号赋值。
阻塞赋值与非阻塞赋值
在Verilog中,= 是阻塞赋值运算符,而 <= 是非阻塞赋值运算符。它们的核心区别在于赋值发生的时机。
- 非阻塞赋值 (
<=):在同一时钟边沿,所有使用<=的赋值是同时进行的。它模拟了寄存器在同一时刻更新其状态的行为。 - 阻塞赋值 (
=):赋值按照代码在文件中出现的顺序依次执行。一个赋值语句会“阻塞”后续语句的执行,直到它完成。
以下是这两种赋值方式的一个关键示例。
同步器示例:两种赋值的对比
假设我们需要设计一个由两个背靠背触发器构成的同步器。正确的写法应使用非阻塞赋值。
// 正确的同步器:使用非阻塞赋值
always @(posedge clk) begin
n1 <= d; // 语句1
q <= n1; // 语句2
end
在时钟上升沿,语句1和语句2同时求值。d 的当前值被安排赋给 n1,而 n1 的旧值(即上一个时钟周期的值)被安排赋给 q。这综合出的正是我们想要的两个触发器。
现在,我们看看使用阻塞赋值会发生什么。
// 错误的同步器:使用阻塞赋值
always @(posedge clk) begin
n1 = d; // 语句1
q = n1; // 语句2
end
在时钟上升沿,语句1立即执行,n1 获得了 d 的当前值。接着,语句2执行,此时 n1 已经是新的值(即 d 的值),因此 q 直接被赋值为 d。这导致综合工具只生成一个触发器,而不是我们想要的两个。
信号赋值的一般准则
理解了核心区别后,我们可以总结出在数字设计中使用信号赋值的一般规则。
以下是不同类型逻辑所推荐的赋值方式:
-
同步时序逻辑
- 使用:
always @(posedge clk)块 和 非阻塞赋值 (<=)。 - 示例:
always @(posedge clk) q <= d;这是一个D触发器的标准描述。
- 使用:
-
简单组合逻辑
- 使用:连续赋值语句 (
assign)。 - 示例:
assign y = a & b;这综合成一个与门。当a或b变化时,y会立即更新。
- 使用:连续赋值语句 (
-
复杂组合逻辑
- 使用:
always @(*)块 和 阻塞赋值 (=), 例如在case或if-else语句内部。 - 示例:
always @(*) begin if (sel) y = a; else y = b; end
- 使用:
一个至关重要的硬件概念
必须牢记,HDL描述的是硬件,而不是纯粹的软件程序。一个核心原则是:
一个信号(网络)只能由一个逻辑源驱动。
这意味着你不能在多个 always 块或多个 assign 语句中对同一个信号进行赋值。以下代码是错误的,因为它试图用两个不同的逻辑块驱动 q,会导致冲突。
// 错误示例:对同一信号的多重驱动
always @(posedge clk) begin
q <= d;
end
assign q = 1‘b0; // 错误!q 已经在上面的 always 块中被驱动了。
初学者常犯的错误是将HDL当作普通编程语言,认为可以随时给变量重新赋值。在硬件中,你需要预先定义好每个信号的驱动源。

本节课中我们一起学习了Verilog中阻塞赋值与非阻塞赋值的根本区别,掌握了在时序逻辑和组合逻辑中正确使用它们的方法,并理解了“单一驱动源”这一硬件描述的基本限制。遵循这些准则,是写出可综合、行为正确的硬件描述代码的关键。
051:有限状态机 (FSMs) 🧠

在本节中,我们将学习如何在硬件描述语言(HDL)中描述有限状态机(FSM)。我们将探讨摩尔型和米利型FSM的区别,并通过两个具体示例——一个三分频计数器和一个序列检测器——来演示如何将状态转移图直接转换为SystemVerilog代码。
从状态图到硬件描述
上一节我们介绍了有限状态机的基本概念。本节中我们来看看如何在SystemVerilog中描述它们。
与硬件设计中的FSM一样,我们在SystemVerilog中描述的有限状态机也由三个逻辑块组成:次态逻辑、输出逻辑和状态寄存器。
以下是我们的摩尔型FSM和米利型FSM的示意图。两者都包含次态逻辑、输出逻辑和状态寄存器。米利型FSM的区别在于,其输出逻辑由当前状态和输入共同驱动,而在摩尔型FSM中,输出逻辑仅取决于当前状态。
三分频计数器示例
假设我们有一个三分频计数器。其复位状态为S0,由双圆圈表示。在每个时钟边沿,状态发生转移。它被称为“三分频”是因为输出信号的频率是输入时钟频率的三分之一。
以下是状态转移过程:
- 状态从S0开始。
- 在第一个时钟边沿,转移到S1。
- 在第二个时钟边沿,转移到S2。
- 在第三个时钟边沿,回到S0,并重复此循环。
输出仅在S0状态为高。如果时钟周期为1纳秒(频率1 GHz),则输出周期为3纳秒(频率1/3 GHz),因此实现了三分频。
现在,我们看看如何将这个状态转移图转换为SystemVerilog中的FSM模块。
module divide_by_three_fsm (
input logic clk, reset,
output logic q
);
这个FSM在S0状态时断言输出q。因此,在S0时q为高,在S1和S2时q为低,如此循环。
我们首先定义一个状态类型,以便使用符号S0、S1、S2,而不是具体的二进制编码。
typedef enum logic [1:0] {S0, S1, S2} statetype;
statetype state, nextstate;
接着,我们定义三个逻辑块,就像在硬件中一样:状态寄存器、次态逻辑和输出逻辑。
状态寄存器就是一个存储当前状态的寄存器。
// 状态寄存器
always_ff @(posedge clk, posedge reset)
if (reset) state <= S0;
else state <= nextstate;
次态逻辑根据当前状态决定下一个状态。
// 次态逻辑
always_comb
case (state)
S0: nextstate = S1;
S1: nextstate = S2;
S2: nextstate = S0;
default: nextstate = S0; // 重要:包含默认情况
endcase
输出逻辑根据当前状态产生输出。
// 输出逻辑
assign q = (state == S0);
在这个FSM中,我们希望输出仅在S0状态断言。如果我们希望它在S0和S1状态都断言,代码应为 assign q = (state == S0) | (state == S1);。一个常见错误是写成 S0 | S1,这是对状态编码值进行按位或操作,而不是逻辑或,会导致错误。
序列检测器示例(摩尔型)
接下来是另一个摩尔型FSM的例子:一个检测序列“01”的序列检测器。在复位状态S0,如果检测到输入a为0,则转移到S1。在S1状态,如果检测到a为1,则转移到S2并断言输出。否则,根据输入进行其他状态转移。
例如,输入a随时间变化,每当出现“0”后紧跟“1”的序列时,输出就应该断言。
现在我们在HDL中描述它。我们可以直接从状态转移图编写SystemVerilog代码,无需设计具体电路。
我们同样有三个状态,因此需要2位状态变量。如果有更多状态(例如5个),则需要3位状态变量([2:0])。
状态寄存器与之前相同。次态逻辑根据当前状态和输入a决定下一个状态。
// 次态逻辑
always_comb
case (state)
S0: if (a) nextstate = S0;
else nextstate = S1;
S1: if (a) nextstate = S2;
else nextstate = S1;
S2: if (a) nextstate = S0;
else nextstate = S1;
default: nextstate = S0; // 重要:包含默认情况
endcase
输出逻辑中,输出仅在S2状态为真。
// 输出逻辑
assign smile = (state == S2); // 注意:使用双等号进行比较操作
序列检测器示例(米利型)
现在考虑同一个序列检测器,但用米利型FSM实现。我们仍然寻找“0”后跟“1”的序列。在米利型中,当处于S0状态且输入a为0时,转移到S1。当处于S1状态且输入a为1时,立即断言输出并转移到S0。米利型FSM的输出时序与摩尔型略有不同。
将其转换为SystemVerilog模块。我们仍然直接从状态转移图编写代码。
这里我们只有两个状态,因此只需要1位状态变量(logic 类型)。在米利型FSM中,次态逻辑和输出逻辑可以合并到一个组合逻辑块中描述。
// 次态与输出组合逻辑
always_comb begin
smile = 1‘b0; // 默认输出为0
case (state)
S0: if (a) begin
nextstate = S0;
end else begin
nextstate = S1;
end
S1: if (a) begin
nextstate = S0;
smile = 1‘b1; // 检测到序列,断言输出
end else begin
nextstate = S1;
end
default: nextstate = S0;
endcase
end
注意,我们在case语句之前将smile默认赋值为0。这样,除非在case语句的特定分支(如S1且a为1时)被显式赋值,否则输出保持为0。这确保了组合逻辑对所有可能的输入(状态和a)都是确定性的,同时避免了在每个分支都重复书写smile = 1‘b0;,使代码更简洁。
总结

本节课中我们一起学习了如何在SystemVerilog中描述有限状态机。我们回顾了摩尔型与米利型FSM的核心区别:摩尔型的输出仅依赖于当前状态,而米利型的输出依赖于当前状态和输入。我们通过三分频计数器和“01”序列检测器两个例子,详细演示了如何将状态转移图转换为包含状态寄存器、次态逻辑和输出逻辑三个部分的HDL代码。关键要点包括:使用enum定义状态类型以提高代码可读性;在组合逻辑的case语句中务必包含default分支;正确使用比较运算符(==)和赋值语句;对于米利型机,可以在逻辑块开始处为输出设置默认值以简化代码。掌握这些方法,你就能直接使用HDL来描述复杂的时序逻辑行为了。
052:参数化模块 📚
在本节中,我们将学习参数化模块的概念。参数化模块通过“规则性”原则帮助我们复用模块,从而更高效地设计不同规格的电路。
概述

参数化模块允许我们定义一个通用的模块模板,通过参数来调整其具体规格(如数据宽度),而无需为每种规格都重写一个模块。本节我们将通过一个2选1多路选择器的例子来理解其工作原理。
参数化模块示例
上一节我们介绍了普通的2选1多路选择器。本节中,我们来看看如何将其改造为参数化模块。
以下是参数化2选1多路选择器的Verilog代码描述:
module mux2 #(parameter WIDTH = 8) (
input [WIDTH-1:0] d0, d1,
input s,
output [WIDTH-1:0] y
);
assign y = s ? d1 : d0;
endmodule
这段代码与之前的非参数化模块非常相似。关键区别在于,我们使用 #(parameter WIDTH = 8) 定义了一个名为 WIDTH 的参数,其默认值为8。在声明输入 d0、d1 和输出 y 的位宽时,我们不再使用固定数字(如 [3:0]),而是使用参数表达式 [WIDTH-1:0]。电路的功能逻辑 assign y = s ? d1 : d0; 则保持不变。
模块实例化
定义好参数化模块后,我们可以在其他模块中实例化它。实例化时,我们可以选择使用默认参数值,也可以指定新的参数值。
使用默认参数实例化
如果我们不指定参数值,模块将使用其定义时设置的默认值。
mux2 myMux (.d0(a), .d1(b), .s(sel), .y(out));
在这个例子中,我们实例化了一个名为 myMux 的 mux2 模块。由于没有指定 WIDTH 参数,它将采用默认值8,因此这是一个8位宽度的2选1多路选择器。
指定参数值实例化
如果我们需要一个不同位宽的模块,可以在实例化时通过 #( ) 语法指定参数值。
mux2 #(12) myMux12 (.d0(a), .d1(b), .s(sel), .y(out));
这里,我们通过 #(12) 将 WIDTH 参数设置为12,从而实例化了一个12位宽度的多路选择器。请注意,# 符号在Verilog中被重载了:在 always 块等上下文中它表示延迟,而在模块实例化时则用于指定参数值。
总结

本节课中我们一起学习了参数化模块。我们了解到,参数化模块通过引入参数(如 WIDTH)使模块定义变得通用和灵活。这允许我们使用同一段模块代码来生成不同规格的电路实例,极大地提高了代码的复用性和设计效率。关键点包括:使用 parameter 关键字定义参数及其默认值,在端口声明中使用参数表达式,以及在实例化时选择使用默认参数或通过 #(value) 指定新参数。
053:测试平台 🧪

在本节中,我们将学习测试平台的概念。测试平台是用于自动化仿真测试的HDL模块,它本身不可综合成硬件,但能极大地帮助我们验证设计的正确性。
什么是测试平台?
上一节我们介绍了模块设计,本节中我们来看看如何验证这些模块。测试平台是一个用于测试另一个模块的HDL模块。被测试的模块称为被测设备或DUT。测试平台仅用于仿真,不可综合。
测试平台的类型
我们将介绍三种测试平台,它们的功能依次增强。
简单测试平台
首先,我们为被测设备编写一个简单的模块。假设其功能为:Y = ~B & ~C | A & ~B。
以下是该模块的代码:
module sillyfunction(input logic A, B, C,
output logic Y);
assign Y = ~B & ~C | A & ~B;
endmodule
接下来,我们编写第一个简单测试平台。它没有输入输出端口,其内部实例化DUT,并手动设置输入信号A、B、C的值,然后通过延时观察输出波形。
以下是简单测试平台的代码结构:
module testbench1();
logic A, B, C;
logic Y;
// 实例化被测设备
sillyfunction dut(A, B, C, Y);
initial begin
A=0; B=0; C=0; #10; // 设置输入并延时
A=0; B=0; C=1; #10;
// ... 遍历所有输入组合
A=1; B=1; C=1; #10;
end
endmodule
这种方法的缺点是,我们需要手动查看波形来判断输出Y是否正确。
自检测试平台
为了自动化检查过程,我们引入自检测试平台。它在设置输入值后,立即检查输出是否符合预期,并在仿真监视器中打印错误信息。
以下是自检测试平台的核心逻辑:
initial begin
A=0; B=0; C=0; #10;
if (Y !== 1) $display("000 failed.");
A=0; B=0; C=1; #10;
if (Y !== 0) $display("001 failed.");
// ... 检查所有组合
end
这种方法节省了手动查看波形的时间,但编写所有输入组合和预期值仍然繁琐。
带测试向量的自检测试平台
最常用且高效的方法是使用带测试向量的自检测试平台。测试向量是一个文本文件,其中每一行代表一组输入和对应的预期输出,类似于真值表。
例如,一个名为 example.tv 的测试向量文件内容如下:
// A B C Y_expected
000 1
001 0
010 0
// ... 其他行
111 0
测试平台会读取该文件,在时钟的上升沿设置输入,在下降沿比较实际输出与预期值,并自动报告所有错误。
以下是这种测试平台的关键部分:
- 时钟生成:用于控制输入设置和输出检查的时序。
always begin clk = 1; #5; clk = 0; #5; end - 读取测试向量:在仿真开始时将文件读入数组。
$readmemb("example.tv", testvectors); - 应用与检查:在时钟边沿应用输入并检查输出。
注意,我们使用always @(posedge clk) #1; {A, B, C, Yexpected} = testvectors[vectornum]; always @(negedge clk) if (!reset) begin if (Y !== Yexpected) begin $display("Error: inputs=%b", {A,B,C}); errors = errors + 1; end vectornum = vectornum + 1; if (testvectors[vectornum] === 4‘bx) begin // 检查是否结束 $display("%d tests completed with %d errors", vectornum, errors); $finish; end end!==和===进行比较,因为它们可以正确处理x(未知)和z(高阻)状态。
总结
本节课中我们一起学习了测试平台的三种类型:
- 简单测试平台:手动设置输入,需查看波形验证。
- 自检测试平台:自动比较输出与预期值并报告错误。
- 带测试向量的自检测试平台:从文件读取测试用例,自动化程度最高,是最常用的方法。

使用测试平台可以高效、自动地验证HDL设计的正确性,是数字设计流程中不可或缺的一环。
054:引言 🧱
在本章中,我们将探讨数字构建模块,特别是那些在许多数字电路(包括处理器)中广泛使用的基本模块。

我们将要讨论的主要主题包括算术电路、数字系统、时序构建模块,以及最后两种类型的阵列:存储器阵列和逻辑阵列。
概述

在之前的章节中,我们已经介绍了一些数字构建模块,如逻辑门、多路复用器、解码器和寄存器。本章我们将重点关注算术电路、计数器、存储器阵列和逻辑阵列。所有这些构建模块都体现了我们已经讨论过的设计原则,包括层次化、模块化和规整性。这些模块由更简单的组件构成,展示了层次化;它们具有定义明确的接口和功能,体现了模块化;而其规整的结构使得扩展至不同规模或位宽变得容易。我们将在第7章中使用这些构建模块来构建一个微处理器,它们是许多数字电路的基础。
章节主要内容
以下是本章将要涵盖的核心内容:
- 算术电路:用于执行加法、减法等数学运算的电路。
- 数字系统:理解计算机如何表示和处理数字的基础。
- 时序构建模块:具有状态记忆功能的电路模块,如计数器。
- 存储器阵列:用于存储数据的规整结构。
- 逻辑阵列:用于实现复杂逻辑功能的规整结构。
总结

本节课我们一起学习了第5章的引言部分,明确了本章将深入探讨算术电路、数字系统、时序模块以及存储器与逻辑阵列这几类关键的数字构建模块。这些模块是构成复杂数字系统(如处理器)的基石,其设计充分运用了层次化、模块化和规整性的原则。在接下来的小节中,我们将逐一详细学习它们。
055:加法器介绍 🧮

在本节中,我们将学习加法器的基本原理。我们将从最简单的单位加法器开始,了解其工作原理和逻辑实现,然后探讨如何将它们组合起来构成多位加法器。理解加法器是理解计算机算术运算的基础。
单位加法器
上一节我们介绍了算术逻辑单元(ALU)的概念,本节中我们来看看构成ALU核心组件之一的加法器。首先,我们考虑两种类型的单位加法器。这些加法器用于将单个比特相加。
输入是A和B。我们有半加器,半加器不包含进位输入位。但全加器包含进位输入。
半加器
让我们先看看半加器。我们相加0和0。我们只是将这两个比特A和B相加。0加0等于0,进位输出和和位都是0。
以下是所有可能的输入组合及其输出:
- 0加1等于1,所以和位为1,进位输出位为0。
- 1加0也等于1。
- 1加1等于2,这给我们一个进位输出1,和位为0。
我们可以更简洁地表示这些逻辑关系。使用积之和形式或卡诺图最容易,但在这个例子中卡诺图有些大材小用。
对于进位输出C_out,我们得到A与B的逻辑与。我们可以用积之和形式写出和位S的逻辑表达式,或者我们直接将其识别为一个异或函数。这将是 (A' AND B) OR (A AND B'),我们将其识别为 A XOR B。
因此,半加器的逻辑公式为:
- 和位 S = A ⊕ B
- 进位 C_out = A · B
现在我们有了公式,可以用门电路为我们的半加器构建这些逻辑。
全加器
接下来,我们考虑包含进位输入的全加器。在全加器中,我们将三个比特相加,因为我们可能有一个进位输入位,以及A和B。
我们将它们全部相加。进位输入C_in加A加B。以下是所有可能的输入组合:
- 0加0加0等于0。
- 0加0加1等于1。
- 0加1加0等于1。
- 0加1加1等于2,所以进位输出为1,和为0。
- 1加0加0等于1。
- 1加0加1等于2。
- 1加1加0等于2。
- 1加1加1等于3,这给我们一个进位输出1,和位也为1。
现在我们可以做同样的事情,最简单的方法可能是将其画在卡诺图中,或者将其识别为三输入异或函数。我们也可以用积之和形式写出,然后那样处理。
因此,全加器的逻辑公式为:
- 和位 S = A ⊕ B ⊕ C_in
- 进位 C_out = (A · B) + (B · C_in) + (A · C_in)
现在我们有了全加器的两个公式。它需要更多的逻辑电路。因此,如果我们能使用半加器而不是全加器,就有理由使用它,因为硬件更少、功耗更低、成本更低。但我们有两种选择:一位全加器或一位半加器。
多位加法器(进位传播加法器)
现在让我们讨论多位加法器或进位传播加法器。这些加法器将进位从一列传播到下一列。
就像我们在上一个例子中看到的,我们将一些数字相加,在这个例子中是4位数字。例如,1111加0001等于10000。然后那个1被进位。所以这是我们的进位,这个进位将从一列传播或可能传播到下一列。1加1加1等于3,写1进1。2是0,进1。2又是0,进1。所以这个进位从一列传播到下一列,直到输出。这就是我们这个4位加法器的C_out。
我们将讨论传播这个进位的不同策略。这种传播,就像我们在第二章讨论传播延迟时一样,这个进位的传播将是我们最慢的路径,所以如果我们能加快这条路径,我们就能加快整个加法运算。
我们将讨论三种不同的加法器:
- 行波进位加法器:我们基本上使用单位加法器,并让进位从一列传播到下一列。
- 超前进位加法器
- 前缀加法器
后两种加法器我们将加快进位传播这条慢速路径。因此,对于较大的位宽,这些加法器通常比行波进位加法器更快,但它们需要更多的硬件,所以我们将付出更多硬件、更多功耗和更多实际建造成本的代价。
这里有一个我们的进位传播加法器的符号。请记住,所有这些都属于进位传播加法器。它看起来与我们刚刚增加输入A和B以及和输出位宽的1位全加器非常相似。
超前进位加法器和前缀加法器对于大型加法器(即较大的N)更快,但它们确实需要更多的硬件。
总结

本节课中我们一起学习了加法器的基本构建模块。我们从半加器和全加器的逻辑设计与公式开始,理解了它们如何处理单位加法。然后,我们探讨了如何将这些单位加法器连接起来构成多位加法器,并引入了进位传播的概念。最后,我们简要介绍了行波进位、超前进位和前缀加法器这三种主要的多位加法器架构,知道了在速度与硬件成本之间存在权衡。理解这些内容是学习更复杂算术电路和计算机架构的关键一步。
056:行波进位加法器 🧮

在本节中,我们将学习一种基本的加法器设计——行波进位加法器。我们将了解其工作原理、结构特点以及性能分析。
行波进位加法器通过将多个1位全加器串联而成。其核心思想是,低位的进位输出作为高位的进位输入,进位信号像波浪一样从最低有效位“传播”到最高有效位。
基本结构
首先,我们从最低有效位(第0位)开始。虽然可以使用半加器,但为了保持结构一致性并允许一个全局的进位输入,我们通常在第0位也使用全加器。
以下是构建一个n位行波进位加法器的步骤:
- 第0位加法:输入为
A[0]、B[0]和进位输入C_in(通常为0)。全加器计算和S[0]与进位输出C[0]。- 公式:
{C[0], S[0]} = A[0] + B[0] + C_in
- 公式:
- 后续位加法:对于第
i位(i从1到n-1),输入为A[i]、B[i]和前一位的进位输出C[i-1]。全加器计算和S[i]与进位输出C[i]。- 公式:
{C[i], S[i]} = A[i] + B[i] + C[i-1]
- 公式:
- 最终输出:最高位的进位输出
C[n-1]即为整个加法器的最终进位输出C_out。所有位的和S[0]到S[n-1]组成最终的和。
工作原理示例
让我们通过一个具体例子来理解进位是如何“行波”传递的。假设我们要计算 1011 + 0111。
- 第0位(最低位):
A[0]=1,B[0]=1,C_in=0。计算得S[0]=0,C[0]=1。 - 第1位:
A[1]=1,B[1]=1,C[1]=C[0]=1。计算得S[1]=1,C[1]=1(因为1+1+1=3,二进制为11)。 - 第2位:
A[2]=0,B[2]=1,C[2]=C[1]=1。计算得S[2]=0,C[2]=1。 - 第3位(最高位):
A[3]=1,B[3]=0,C[3]=C[2]=1。计算得S[3]=0,C_out=C[3]=1。
最终结果为:和 S = 0010,进位 C_out = 1,即 1011 + 0111 = 10010(十进制 11+7=18)。可以看到,进位 C[0] 的产生影响了 C[1] 的计算,C[1] 又影响了 C[2],以此类推。
电路布局与关键路径
在电路图中,数据流通常从左向右。但对于表示数字的加法器,我们习惯将最高有效位(MSB)放在左边,最低有效位(LSB)放在右边。因此,行波进位加法器的进位信号是从右(LSB)向左(MSB)传递的,这与我们书写数字的习惯一致。
行波进位加法器的主要缺点是速度较慢。其延迟由最长的信号路径决定,即进位传播链。
- 关键路径:从最低位的输入
A[0]/B[0]开始,到最高位的进位输出C_out为止,进位信号需要依次通过每一个全加器。 - 延迟计算:对于一个n位行波进位加法器,其总延迟
T_ripple大约是单个全加器延迟T_FA的n倍。- 公式:
T_ripple ≈ n * T_FA
- 公式:
例如,一个32位的行波进位加法器,其延迟大约是32个全加器的延迟。这使得它在需要高速运算的场景下效率不高。
特点总结
以下是行波进位加法器的主要优缺点:
- 优点:
- 结构非常简单,易于理解和实现。
- 所需的逻辑门数量较少,硬件成本低。
- 缺点:
- 延迟与位数n成正比,当位数增加时,速度会显著下降。
- 进位传播路径长,是限制其速度的主要瓶颈。
因此,行波进位加法器适用于对速度要求不高,但追求电路简单和低成本的场合。
本节总结

本节课我们一起学习了行波进位加法器。我们了解了其通过串联全加器来构建多位数加法的基本原理,并通过示例观察了进位信号的传播过程。我们重点分析了其数据流方向以及最关键的进位传播延迟问题,认识到其延迟公式为 n * T_FA。最后,我们总结了其结构简单但速度较慢的特点,为后续学习更高效的高速加法器(如超前进位加法器)奠定了基础。
057:进位前瞻加法器 (CLA) 🚀

在本节中,我们将学习如何加速加法器中进位信号的传播过程。我们将介绍一种名为“进位前瞻加法器”的结构,它通过提前计算进位来显著减少加法运算的延迟。
概述
在上一节中,我们介绍了行波进位加法器,其缺点是进位信号必须从最低有效位逐级传递到最高有效位,这成为了加法运算的“关键路径”,限制了速度。本节中,我们将探讨进位前瞻加法器,它通过计算“生成”和“传播”信号来并行预测进位,从而加速这一过程。
生成与传播信号
首先,我们需要理解两个核心概念:生成和传播。它们描述了加法器中某一列(或某一位)对进位的处理方式。
- 生成:指该列自身会产生一个进位输出,无论是否有进位输入。这发生在两个加数位都为1时。
- 公式:
G_i = A_i · B_i
- 公式:
- 传播:指该列会将输入的进位原样传递到输出。这发生在至少一个加数位为1时。
- 公式:
P_i = A_i + B_i
- 公式:
基于这两个信号,某一列的进位输出 C_i 可以表示为:
公式:C_i = G_i + (P_i · C_{i-1})
这意味着,该列的进位输出要么由自身生成,要么是将输入的进位传播出去。
列级生成与传播计算
以下是计算一个具体加法示例中每一列的生成和传播信号的步骤:
- 对于每一列
i,计算P_i = A_i + B_i。 - 对于每一列
i,计算G_i = A_i · B_i。
通过这种方式,我们可以用逻辑门快速计算出所有列的 P 和 G 信号。
块级生成与传播信号
仅计算列级信号并未带来速度优势,因为进位公式依然依赖前一级的进位。真正的加速来自于将多个位(例如4位)组合成一个“块”,并计算整个块的生成和传播信号。
- 块传播信号:一个
k位块(如4位)会将输入进位传播到输出进位的条件是,块内的每一列都能传播进位。- 公式:
P_{i:j} = P_i · P_{i-1} · ... · P_j(对于从i到j的块)
- 公式:
- 块生成信号:一个
k位块会自行生成一个输出进位的条件是,块内某一列生成了进位,并且该进位能被其右侧的所有列传播出去。- 逻辑描述:生成信号
G_{3:0}(对于4位块)为真,如果:- 第3列生成进位 (
G_3),或 - 第2列生成进位 (
G_2) 且第3列传播进位 (P_3),或 - 第1列生成进位 (
G_1) 且第2、3列传播进位 (P_2·P_3),或 - 第0列生成进位 (
G_0) 且第1、2、3列传播进位 (P_1·P_2·P_3)。
- 第3列生成进位 (
- 这个逻辑表达式可以优化为便于电路实现的形式。
- 逻辑描述:生成信号
有了块级的 P_{block} 和 G_{block},该块的进位输出 C_{out} 可以快速计算:
公式:C_{out} = G_{block} + (P_{block} · C_{in})
进位前瞻加法器结构
现在,我们来看一个32位进位前瞻加法器的结构,它由多个4位CLA块组成。
- 第一步:计算列信号。所有位的
P_i和G_i通过一级与门/或门并行产生。 - 第二步:计算块信号。每个4位块利用其内部的
P_i和G_i,通过多级逻辑门(图中显示为6级门延迟)并行计算出该块的P_{block}和G_{block}。 - 第三步:快速计算块间进位。利用公式
C_{out} = G_{block} + (P_{block} · C_{in}),进位信号可以通过两级门延迟(一个与门和一个或门)从一个块传递到下一个块。由于所有块的P_{block}和G_{block}已经提前算好,进位链得以快速通过。 - 第四步:计算和位。每个CLA块内部实际上包含一个小的行波进位加法器,用于计算该块4位的和。它使用外部快速计算得到的进位输入
C_{in}来工作。对于最后一块,得到最终进位后,仍需经过该块内部的全加器延迟才能产生最终的和位。
延迟分析
进位前瞻加法器的关键路径延迟由以下几部分组成:
T_{PG}:计算列级P和G信号的延迟(1级门延迟)。T_{PG块}:计算块级P和G信号的延迟(对于4位块,约为6级门延迟)。(N/K - 1) * T_{AND-OR}:进位信号通过块间进位逻辑链的延迟。N是总位数,K是每块位数,T_{AND-OR}是每级块间进位逻辑的延迟(2级门延迟)。K * T_{FA}:最后一块内部行波进位产生和位的延迟。T_{FA}是全加器的延迟。
总延迟公式:T_{CLA} = T_{PG} + T_{PG块} + (N/K - 1) * T_{AND-OR} + K * T_{FA}
对于位数较多(如 N > 16)的情况,进位前瞻加法器通常比行波进位加法器快得多。
总结

本节课中,我们一起学习了进位前瞻加法器。我们首先定义了“生成”和“传播”这两个核心概念,并展示了如何用它们描述进位。然后,我们将这些概念从单列扩展到多位列块,引入了块级生成和传播信号,从而能够并行预测进位。最后,我们分析了CLA的整体结构和工作原理,并对其性能延迟进行了量化分析。通过将进位计算从串行改为部分并行,CLA有效地解决了行波进位加法器的速度瓶颈问题。
058:前缀加法器

概述
在本节中,我们将学习本章要讨论的最后一种进位传播加法器——前缀加法器。前缀加法器与先行进位加法器目标相同,都旨在尽可能快地计算进位,但其计算方式不同,它通过分而治之的策略,高效地计算出每一位的进位输入。
前缀加法器的基本原理
上一节我们介绍了先行进位加法器,本节中我们来看看前缀加法器。前缀加法器的核心目标是快速计算每一位的进位输入 C_i-1,然后利用这些进位来计算最终的和 S_i = A_i XOR B_i XOR C_i-1。
其计算过程分为两步:
- 首先计算每一位的生成
G_i和传播P_i信号。 - 然后通过合并相邻位的生成和传播信号,以对数级的速度计算出所有需要的进位。
一个块的进位输出 C_out 可能由两种情况产生:要么在该块内部生成进位,要么该块传播了一个来自其右侧块的进位。
我们可以用左组和右组的概念来思考。对于一个由左组和右组结合而成的块,其生成信号 G 的计算公式为:
G_combined = G_left OR (G_right AND P_left)
这意味着,整个块会生成一个进位,如果左组自己生成了进位,或者右组生成了进位并且左组能够传播它。
关于前缀加法器,一个重要细节是列 -1 代表进位输入 C_in。因此,G_-1 就等于 C_in。由于没有列在 -1 的右侧,其传播信号 P_-1 是无关项。
前缀计算示例
让我们通过一个简短的例子来理解前缀加法器的计算过程。
假设我们计算两个4位数(加上进位输入列)的加法。我们设定进位输入 C_in = 1。
首先,我们计算每一列(位)的生成 G_i 和传播 P_i 信号:
- 列 -1:
G_-1 = C_in = 1,P_-1 = X(无关) - 列 0:
A=0, B=0->G_0 = 0,P_0 = 1 - 列 1:
A=0, B=1->G_1 = 1,P_1 = 1 - 列 2:
A=0, B=0->G_2 = 0,P_2 = 1 - 列 3:
A=0, B=0->G_3 = 0,P_3 = 1
现在,我们开始合并计算跨度更广的生成信号。
第一步:合并为2位块
我们计算从列0到列-1这个2位块的生成信号 G_{0:-1}:
G_{0:-1} = G_0 OR (G_{-1} AND P_0) = 0 OR (1 AND 1) = 1
这实际上就是进位 C_0。
同样,计算列2到列1的块:
G_{2:1} = G_2 OR (G_1 AND P_2) = 0 OR (1 AND 1) = 1
P_{2:1} = P_2 AND P_1 = 1 AND 1 = 1
第二步:合并为4位块
现在,我们利用上一步的结果计算列2到列-1这个4位块的生成信号 G_{2:-1}:
G_{2:-1} = G_{2:1} OR (G_{0:-1} AND P_{2:1}) = 1 OR (1 AND 1) = 1
这实际上就是进位 C_2。
第三步:计算中间进位
我们还需要计算进位 C_1,即 G_{1:-1}。我们可以通过合并一个1位列(列1)和一个2位块(列0到-1)来得到:
G_{1:-1} = G_1 OR (G_{0:-1} AND P_1) = 1 OR (1 AND 1) = 1
至此,我们得到了所有需要的进位前缀:C_0 = 1, C_1 = 1, C_2 = 1。
前缀加法器电路结构
让我们观察一个16位前缀加法器的电路结构图。电路主要由三种模块构成:
- 黄色模块(预计算层):计算每一位的
P_{i:i}和G_{i:i}(即P_i和G_i),以及G_{-1}(即C_in)。 - 黑色模块(前缀合并层):这是核心计算层。它接收两个相邻块的
(P, G)信号,并输出合并后更大块的(P, G)信号。其逻辑遵循公式:P_{out} = P_left AND P_rightG_{out} = G_left OR (G_right AND P_left)
电路通过分层(2位、4位、8位、16位)合并,以对数级速度计算出所有跨度直到-1的生成信号G_{i:-1}(即C_i)。
- 蓝色模块(求和层):一旦得到进位
C_i-1(即G_{i-1:-1}),便可通过异或门计算最终的和位:
注意,S_i = A_i XOR B_i XOR C_i-1A_i XOR B_i在预计算层早已完成,因此求和层只增加一个门延迟。
电路的关键路径在于黑色前缀合并层。对于N位加法器,合并层有 log2(N) 级,每一级包含一个与门和一个或门(共2个门延迟)。
性能分析与比较
现在我们来分析并比较几种进位传播加法器(CPA)的性能。假设使用32位加法器,一个两输入门延迟为100皮秒,一个全加器延迟为300皮秒。
以下是三种加法器的延迟计算:
- 行波进位加法器:延迟为
N * T_FA = 32 * 300ps = 9.6 ns。 - 先行进位加法器(4位块):延迟公式为
T_PG + (N/k - 1) * T_AND_OR + k * T_FA。计算得600ps + 7*200ps + 4*300ps = 3.2 ns(注:与视频中3.3ns略有差异,系计算舍入导致)。 - 前缀加法器:延迟公式为
T_PG + log2(N) * 2 * T_GATE + T_XOR。计算得100ps + 5 * 200ps + 100ps = 1.2 ns。
从比较中可以看出:
- 先行进位加法器比行波进位加法器快约3倍。
- 前缀加法器比行波进位加法器快约8倍,比先行进位加法器快约2.7倍。
尽管前缀加法器需要更多的硬件逻辑(与门和或门),但它极大地缩短了关键路径(进位传递路径)的延迟,在性能上获得了显著的回报。

总结
本节课中我们一起学习了前缀加法器。我们了解到,前缀加法器通过先计算每位的生成和传播信号,然后采用分治策略,以对数时间复杂度 O(log N) 高效计算出所有进位。我们通过示例逐步演算了其计算过程,分析了其电路结构,并最终通过性能对比,认识到前缀加法器在速度上的显著优势,尽管其硬件复杂度更高。这种设计体现了数字系统中在速度与面积/复杂度之间进行权衡的经典思想。
059:减法器与比较器 🔢

在本节课中,我们将要学习减法器和比较器的基本概念与实现方法。减法器用于计算两个数字的差,而比较器则用于判断两个数字的大小关系。理解这些组件是构建更复杂算术逻辑单元的基础。
减法器
上一节我们介绍了加法器,本节中我们来看看减法器。减法器与加法器类似,其功能是计算 A - B。实际上,减法可以看作是 A 加上 B 的负数,即 A + (-B)。
那么,如何对一个二进制补码数进行取反操作呢?我们通过计算其二进制补码来翻转符号。因此,-B 实际上等于 对 B 取反再加 1。
所以,A - B 的运算可以表示为:
A - B = A + (~B + 1)
以下是减法器的符号,它看起来像一个加法器,只是将加号替换为减号。

如何实现一个减法器呢?我们可以利用已有的加法器设计作为指导。我们已经知道如何设计多种类型的加法器。
实现减法器的步骤如下:
- 将输入 A 连接到加法器的一端。
- 将输入 B 取反后,连接到加法器的另一端。
- 为了完成 +1 的操作,我们可以利用加法器的进位输入(Carry In)端。将其固定设置为高电平(1)。
- 这样,加法器就计算了 A + (~B + 1),从而输出了 A - B 的结果。
比较器
接下来,我们讨论如何比较数字。一个常见的比较是判断两个数是否相等。
相等比较器
这是一个四位数相等比较器的符号,用于检查 A 是否等于 B。

如何实现这个功能呢?实际上有多种方法。一种常见的方式是使用异或非门(XNOR),它也被称为同或门或相等门。
异或非门的真值表如下:
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
从真值表可以看出,当两个输入位相等(同为0或同为1)时,输出为真。因此,对于单个比特的比较,我们可以使用一个异或非门。
对于多位数的比较,我们需要将每一位分别进行比较。以下是实现多位相等比较器的步骤:
- 对 A 和 B 的每一位分别使用异或非门进行比较,得到一系列表示该位是否相等的信号。
- 只有当所有位都相等时,两个数才完全相等。因此,我们将所有异或非门的输出通过一个与门(AND gate)连接起来。
- 与门的最终输出即为 A == B 的结果。这种方法可以轻松扩展到任意数量的比特位。
有符号数比较器(小于比较)
我们可能还需要执行有符号数的比较,例如判断 A < B 是否成立。
让我们通过一些例子来思考如何实现。假设我们计算 A - B:
- 若 A < B(例如 5 - 7 = -2),结果为负数。
- 若 A > B(例如 7 - 5 = 2),结果为正数。
- 若 A = B(例如 5 - 5 = 0),结果为零(非负)。
观察发现,当 A < B 时,减法结果 A - B 为负数。在二进制补码表示中,负数的符号位(最高有效位)为 1。
因此,实现 A < B 比较器的电路如下:
- 将 A 和 B 输入到一个减法器中。
- 取出减法器输出结果的符号位(即最高位)。
- 该符号位的值直接指示了 A < B 是否为真:若为 1,则 A < B;若为 0,则 A >= B。
注意:在实际设计中需要考虑溢出的情况,但本基础教程中暂不深入讨论溢出处理。

本节课中我们一起学习了减法器和比较器的原理与实现。减法器通过将减法转化为加法(A + ~B + 1)来实现。比较器则用于判断数字间的关系:相等比较器通过逐位异或非再相与来实现;小于比较器则巧妙地利用减法器结果的符号位来判断大小。这些组件是构成计算机算术逻辑单元的核心部分。
060:算术逻辑单元 (ALU) 🧠

在本节课中,我们将要学习算术逻辑单元 (ALU) 的核心概念。ALU 是处理器的大脑,负责执行基本的算术和逻辑运算,如加法、减法、与 (AND) 和或 (OR) 操作。我们将探讨如何构建一个基本的 ALU,如何为其添加状态标志,以及如何利用这些标志进行数值比较。
ALU 概述
ALU 是处理器的核心部件。它接收两个输入 A 和 B,以及一个控制信号 ALUControl,根据控制信号执行指定的运算,并输出结果 Result。在我们的设计中,ALUControl 是一个 2 位信号,用于选择四种运算之一:加法、减法、与运算和或运算。
其符号表示类似于一个加法器,但标注为 “ALU”。
构建一个基本的 ALU
上一节我们介绍了 ALU 的基本概念,本节中我们来看看如何从零开始构建一个支持四种运算的 ALU。
实现或 (OR) 运算
首先,我们实现或运算。这需要一个或门。
以下是实现或运算的电路逻辑:
// 或运算:Result = A | B
assign Result = A | B;
实现与 (AND) 运算
接下来,我们实现与运算。这需要一个与门。
以下是实现与运算的电路逻辑:
// 与运算:Result = A & B
assign Result = A & B;
实现加法 (ADD) 运算
然后,我们实现加法运算。这需要一个加法器。
以下是实现加法运算的电路逻辑:
// 加法运算:Result = A + B
assign {Cout, Result} = A + B;
实现减法 (SUB) 运算
最后,我们实现减法运算。与其使用一个独立的减法器,我们可以复用加法器。我们知道,A - B 等价于 A + (~B) + 1。因此,我们可以通过一个多路选择器来选择输入 B 或其反码,并将 ALUControl[0] 作为进位输入。
以下是实现减法运算的电路逻辑:
// 减法运算:Result = A - B
// 当 ALUControl[0] = 1 时,选择 B 的反码,并设置进位输入 Cin = 1
assign B_input = ALUControl[0] ? ~B : B;
assign Cin = ALUControl[0];
assign {Cout, Result} = A + B_input + Cin;
整合运算:使用多路选择器
现在,我们已经有了四个运算单元。我们需要一个 4 选 1 的多路选择器,由 ALUControl 信号控制,来选择最终的输出结果。
以下是整合所有运算的电路逻辑:
// 根据 ALUControl 选择运算结果
always @(*) begin
case (ALUControl)
2'b00: Result = A + B; // 加法
2'b01: Result = A - B; // 减法
2'b10: Result = A & B; // 与
2'b11: Result = A | B; // 或
endcase
end
为 ALU 添加状态标志
上一节我们构建了一个基本的 ALU,本节中我们来看看如何为其添加状态标志。这些标志提供了关于运算结果的额外信息,对于条件判断和比较操作至关重要。
我们将添加四个标志:
- N (Negative):结果为负。
- Z (Zero):结果为零。
- C (Carry):加法或减法产生了进位/借位。
- V (Overflow):有符号数运算发生了溢出。
负标志 (N)
负标志 N 的设置最为简单。如果结果的最高有效位(对于 32 位 ALU 是第 31 位)为 1,则结果为负,N 置 1。
以下是负标志的逻辑:
assign N = Result[31]; // 假设为 32 位 ALU
零标志 (Z)
零标志 Z 在结果的所有位都为 0 时置 1。这可以通过一个多输入的或非门来实现。
以下是零标志的逻辑:
assign Z = (Result == 32‘b0);
进位标志 (C)
进位标志 C 在 ALU 执行加法或减法且最高位产生进位/借位时置 1。我们只在 ALUControl[1] 为 0(表示加法或减法)时才关心进位输出。
以下是进位标志的逻辑:
wire is_add_sub = ~ALUControl[1]; // 判断是否为加法或减法
assign C = is_add_sub & Cout; // 如果是加/减运算且有进位,则 C=1
溢出标志 (V)
溢出标志 V 用于有符号数运算。当两个同号数相加得到异号结果,或两个异号数相减得到同号结果时,发生溢出。其检测逻辑相对复杂,需要结合操作数符号、结果符号以及运算类型。
以下是溢出标志的逻辑:
wire sign_A = A[31];
wire sign_B = B[31];
wire sign_Result = Result[31];
wire op_add = (ALUControl == 2‘b00); // 加法
wire op_sub = (ALUControl == 2’b01); // 减法
// 溢出条件:
// 1. 加法:A和B同号,但结果与A异号。
// 2. 减法:A和B异号,但结果与A同号。
assign V = (op_add & (sign_A == sign_B) & (sign_Result != sign_A)) |
(op_sub & (sign_A != sign_B) & (sign_Result == sign_A));
利用 ALU 标志进行比较操作
上一节我们为 ALU 添加了状态标志,本节中我们来看看如何利用这些标志进行数值比较。比较操作通常通过先执行减法 A - B,然后检查标志位来实现。
以下是各种比较操作的实现方法:
相等 (EQ) 与不相等 (NEQ)
- 相等:如果
A - B的结果为零(Z 标志为 1),则A == B。 - 不相等:
A != B是A == B的反,即~Z。
以下是相等与不相等比较的逻辑:
assign EQ = Z;
assign NEQ = ~Z;
有符号数小于 (SLT)
对于有符号数,如果 A < B,则 A - B 的结果应为负数(N 标志为 1)。但必须考虑溢出的情况。正确的判断逻辑是:A < B 为真,当且仅当 (N != V)。即,结果的负标志与溢出标志异或。
以下是有符号数小于比较的逻辑:
assign SLT_signed = (N ^ V); // 有符号数 A < B
无符号数小于 (SLTU)
对于无符号数,如果 A < B,则 A - B 的减法操作会产生借位,即进位标志 C 为 0(注意:在减法中,Cout = 0 表示有借位)。因此,A < B 为真,当且仅当 C == 0。
以下是无符号数小于比较的逻辑:
assign SLT_unsigned = ~C; // 无符号数 A < B
其他比较操作
其他比较操作(如大于、小于等于、大于等于)可以通过上述基本比较组合或取反得到。
以下是其他比较操作的逻辑:
// 有符号比较
assign SLE_signed = Z | (N ^ V); // 小于或等于
assign SGT_signed = ~(Z | (N ^ V)); // 大于
assign SGE_signed = ~(N ^ V); // 大于或等于
// 无符号比较
assign SLE_unsigned = Z | (~C); // 小于或等于
assign SGT_unsigned = ~(Z | (~C)); // 大于
assign SGE_unsigned = C; // 大于或等于 (即,无借位)
扩展 ALU 功能
基本的 ALU 可以进一步扩展以支持更多操作,例如:
- SLT (Set Less Than):如果
A < B,则将结果的最低位置 1,否则置 0。这可以通过上述比较逻辑实现。 - 逻辑非 (NOT) 和 异或 (XOR):添加相应的逻辑门电路即可。
总结

本节课中我们一起学习了算术逻辑单元 (ALU) 的核心原理。我们从构建一个支持加、减、与、或运算的基本 ALU 开始,然后为其添加了负 (N)、零 (Z)、进位 (C) 和溢出 (V) 状态标志。最后,我们探讨了如何利用这些标志来实现有符号和无符号数的各种比较操作(等于、不等于、小于、大于等)。ALU 及其标志系统是处理器执行算术运算和做出决策的基础。
061:移位器、乘法器和除法器 🧮

在本节课中,我们将要学习移位器、乘法器和除法器的基本概念、工作原理以及如何构建它们。这些组件是计算机算术运算的核心,理解它们对于掌握计算机架构至关重要。
移位器
移位器有两种形式:逻辑移位和算术移位。
逻辑移位器将数值向左或向右移动,并在空出的位置上填充零。例如,这是一个逻辑右移两位的操作。我们从这个数字开始,将这两位向右移出。这是一个一位右移,但我们希望移出两位。从左侧移入的是零。因此我们得到两个零,这两个零被移入,而这两位被移出到右侧。我们得到 0,0,1,1,0。
逻辑左移类似。但我们是向左移动,在这个例子中移动相同的位数。我们移出左边的两位。因为执行的是逻辑左移,所以在右侧移入零。我们得到 001,这两位被移出,在右侧移入两个零。所以,左移时,我们从左侧移出位。对于逻辑移位器,我们在空位上移入零。
算术移位器仅在右移时有所不同。在右移时,我们不是移入零,而是移入最高有效位的值。在这个例子中,最高有效位是 1,所以算术右移两位仍然会将两位移出到右侧,但我们移入的是 1,而不是零。我们得到 1,1,1,1,0。现在我们移入 1,或者说是最高有效位的值,不总是 1。如果最高有效位是 0,我们也会移入 0。在这个例子中,最高有效位是 1,我们移入 1。而在逻辑右移中,无论最高有效位是什么,都只移入零。
对于左移,逻辑左移和算术左移是相同的。我们移出左边的两位,在右侧移入零。我们得到 0,0,1,0,0。可以看到,它们是相同的。所以逻辑和算术左移相同,但右移可能不同。算术右移中,我们移入符号位;逻辑右移中,我们总是移入零。因此,我们必须查看符号位或最高有效位是什么,才能知道要移入什么。
循环移位器
循环移位器与移位器类似,但不是简单地将位移出边缘,而是将它们循环回来。这是一个循环右移。我们循环右移两位。这一位会移出并向右移动,实际上是循环并回到前端。所以我们在这里得到那个 1。然后 0 循环回来,而不是直接掉出,它循环到前端。我们得到 0,1,它们不再在末端,而是循环到了前端,得到 0,1,1,1,0。
左循环移位类似。循环左移类似,这一位将循环回来。所以它不会从左端掉出,而是循环并落在右侧。然后这一位循环并落在右侧。我们得到 0,0,1,1,1。它们不再在左侧,而是循环到了右侧。
如果我们循环右移三位,那么会得到这个 0。它不会从左端掉出,而是循环并来到右侧,这将是循环左移三位。我们将得到 0,1,1,1,0,从左端掉出并循环到右侧。
移位器的设计
现在我们来讨论如何设计这些移位器。让我们以一个四位左移位器为例。记住算术和逻辑左移是相同的,所以它被称为左移位器。我们从一个四位数字开始,输入是 A3, A2, A1, A0,这是一个四位输入,我们将有四位输出。
我们可以移动多少位呢?我们可以不移位(0位),可以移动 1 位、2 位或 3 位。如果移动 4 位,我们就得到 0,这没有意义。这看起来像是使用多路复用器的好地方。
我们有一个选择线,我们希望它有四个选项,所以需要两位。我们设置一个移位量信号来选择移动多少位。如果我们移动 0 位,我们将这个 4 位信号馈送到 Y,这就是我们想要的,没有移位。
如果我们想左移一位呢?我们的输入 A3, A2, A1, A0,这个 A3 会移出。它会向左移动,然后我在这里得到零。所以我们可以取我们的 A 输入,我们想要 A2 到 Y3,A1 到 Y2,A0 到 Y1,并且在最右边的位上得到零。
如果我们移动两位呢?现在这两位都会移出,我们在右侧得到两个零。所以如果我们移动两位,我们会想要 A1 到 Y3,A0 到 Y2,并且在右侧得到两位零。
最后一个选项是移动三位。那么所有三个最高有效位都会向左移出,我们在右侧得到三个零。所以我们得到 A0 到 Y3,然后在左侧得到三位零。
移位器不要与稍后会谈到的移位寄存器混淆。移位器是纯组合逻辑的。我们使用移位量来指定要移动多少位。例如,如果我们想左移三位,那么我们选择那个输入并将其馈送到输出 Y。
我们可以为逻辑右移和算术右移考虑相同的事情。这里我用不同的位 A3, A2, A1, A0 画出来了。例如,你可以看到 Y3。如果我们移动零位,这个顶部输入是我们多路复用器的零输入。如果我们的移位量是 00,我们直接得到 A3, A2, A1, A0,我们根本不移动,直接发送到输出 Y。但如果我们移动一位,现在不是 A3,所有东西都向右移动(实际上是左移一位?这里上下文是右移,可能描述有混淆,按电路逻辑理解)。所以我们得到 A2, A1, A0,然后在 Y0 得到零。以此类推。当我们移动三位时,例如,现在我们只剩下这个位,我们向左移动了 A3, A2, A1, A0。所有这些位都向左移出,我们有一个零,后面跟着三个零。所以我们得到 A0,后面跟着 0,0,0。
逻辑右移非常相似,但现在我们有 A3, A2, A1, A0,当我们右移时,位从右侧掉出,我们得到零。我们看到唯一在 Y 的最高位不是零的情况是当没有右移时,即移位量为 0。否则,那些零会从左侧移入。所以在所有这些情况下,无论是右移一位、两位还是三位,我们都会在那个最高有效位得到零。以此类推,只有那个最低位,首先是 A0,然后右移一位后得到 A1,右移两位后得到 A2,右移三位后得到 A3。
算术移位和逻辑移位的区别在于,我们不是将零移入左侧,而是移入最高有效位。所以不是移入这些零,它们被连接到输入的最高有效位 A3。对于这些选项也是如此。
移位器作为乘除法器
我们可以使用移位器作为乘法和除法器。就像在十进制中一样,如果我们左移一个值,比如说我们有数字 53,我们将其左移两位,我们将得到 53,0,0。这相当于将 53 乘以 10 的(移动位数)次方。在这个例子中,53 乘以 100,是的,这就是我们得到的结果。
在二进制中也是如此,如果我们将一个数字左移 n 位(二进制数字),就相当于将该数字乘以 2 的 n 次方。让我们举个例子,假设我们有一个数字 3,我们将其左移两位。这两位将掉出,零将从右侧移入。所以我们得到 0,0,0,0,1,1,0,0。我们期望这个结果是,原始数字是 3,左移两位应该是 3 乘以 2 的 2 次方(这是我们移动的位数)。所以 3 乘以 4 应该是 12。检查一下,我们得到了 12。
这是另一个例子,假设值为 1。我们将其左移 3 位。我们期望结果是原始数字 1 乘以 2 的(移动位数 3)次方。事实上,这是正确的。我们将那个 1 左移三位,那些掉出,我们在前面放入零,得到 8。
这对正数和负数都有效。假设我们从 -3 开始,我们将其左移两位。我们期望那是 -3 乘以 2 的 2 次方,即 -3 乘以 4,得到 -12。将这个左移两位,那些掉出,在右侧放入零,我们得到 1,0,1,0,0。事实上,这是 -12。我们可以取二进制补码来确认大小是 12,并且它是负数,所以我们确实得到了 -12。
右移则相反。当我们右移时,具体使用算术右移,符号表示算术右移 n 位,这相当于除以 2 的 n 次方。我们可以再次使用十进制类比,假设我们有一个数字 37,000,我们将其右移三位。那将是相同的。我们将其右移三位,那三位掉出,我们得到 37,000 变成 37。这相当于原始数字 37,000 除以 10 的(移动位数)次方,我们右移了,所以除以 10 的 3 次方。37,000 除以 1000,是的,是 37。
这里原理相同。假设我们从某个数字开始,在这个例子中是 8。我们将其算术右移一位。在这种情况下,算术或逻辑移位结果相同,但对有符号数很重要。算术右移一位,我们期望它将 8 除以 2 的 1 次方。事实上,我们将这个右移一位,算术右移这位,我们取这个符号位,这就是被移入的,我们得到 0,0,1,0,0。事实上,我们得到 4。8 除以 2 的(移动位数)次方,算术右移,我们得到 8 除以 2 确实是 4。
这对我们的负数也有效。这里我们有 -16,我们想将其算术右移两位,所以这两位掉出,记住我们移入最高有效位或符号位。所以我们在这里移入 1,得到 1,1,1,0,0。事实上,那是 -4。所以得到 -16 算术右移 2 位,相当于将其除以 2 的(右移位数)次方,即 2 的 2 次方。所以 -16 除以 4,是的,我们得到 -4,检查无误。
因此,移位器比通用乘法器/除法器便宜得多。如果我们能用移位器代替乘法器/除法器来进行乘法或除法运算,那将节省大量硬件,降低功耗等。所以这是进行 2 的幂次乘除运算的一种简单方法。
乘法器
现在我们来讨论乘法器和除法器,看看如何构建它们。乘法器,我们知道,以前在十进制中做过手算乘法。在二进制中过程类似,但实际上更简单一些。我们将通过创建部分积然后将它们相加来执行乘法。
所以,2 乘以整个被乘数。2 乘以 0 得 0,2 乘以 3 得 6,2 乘以 2 得 4。然后我们要移位,因为这个 4 在十位,不是个位,然后我们做 4 乘以 0,再写 4 乘以整个被乘数,4 乘以 0 得 0,4 乘以 3 得 12,进位 1,4 乘以 2 得 8 加 1 得 9。然后我们把它们加在一起。我们将这些部分积相加得到结果。
在二进制中,除了乘法运算更容易之外,其他都相同。乘数乘以被乘数,下面的这些位。我仍然要将那个 1 乘以整个被乘数,或者如果最高有效位是 0,结果就全是零。所以我的部分积要么是被乘数(如果乘数位是 1),要么全是零(如果乘数位是 0)。我们可以看到这些部分积是被乘数、被乘数,最后是零。这样更容易,因为乘法很简单,基本上是与门:被乘数与乘数位进行与运算。如果该位是 0,我们希望它是零;如果是 1,我们希望它是被乘数本身。然后我们将这些部分积相加得到结果。在这个例子中,5 乘以 7,我们得到 35,事实上我们得到 32 加 3。
我们如何构建它呢?我们通过观察如何计算那些部分积来构建。这是一个 4 位乘 4 位的乘法器。我们有 4 位操作数,我们可以得到最多 8 位作为乘积结果。与加法不同,如果我们有 4 位操作数,我们得到 4 位结果;而乘法器,4 位操作数给我们 8 位结果;16 位操作数,我们得到 32 位结果。
如前所述,这些部分积将是乘数位与被乘数各位的与。所以 B0 与 A0,B0 与 A1,B0 与 A2,B0 与 A3。然后我们应该移动一个位置,然后得到 B1 与所有这些位:B1 与 A0,B1 与 A1,B1 与 A2,B1 与 A3。同样,B2 与所有那些被乘数位。最后,B3 与所有那些被乘数位。然后我们将它们相加以得到最终乘积。
我们可以在电路上看到。我们从这些加法器开始,进位为 0。我们执行 A0 与... 我们开始时,B0 与所有 A 位进行与运算:B0 与 A0,B0 与 A1... 然后我们将其与 B1 相加... 这一个直接进入乘积位 0。然后我们将这两个相加,这在这里执行。然后,我们将这三个相加。一个,两个,加到第三个上。以此类推。
我们注意到这个电路是一个 4 位乘 4 位的乘法器。它用了相当多的逻辑:一堆加法器和一些与门来执行乘法,所以乘法实际上相当昂贵。一些非常简单的处理器、微处理器没有乘法器或除法器,就是因为硬件成本高。因此,这些乘法或除法运算将在软件中完成,而不是由硬件执行。
除法器
现在我们来讨论除法器。这里有一个我们做过的例子,小除法,2584 除以 15 结果是 172 余 4。所以我们有要运算的操作数 A 除以 B,得到商加上余数除以 B。
这是手算过程,我们以前都做过,15 进 25,嗯,如果能进一次。减去 15,得到 10。带下一位数字,得到 108,15 能进多少次?进 7 次。7 乘以 15 是 105。减去 105,得到 3。带下最后一位数字,34,15 能进多少次?2 次。2 乘以 15 是 30。减去那个,得到 4,这就是我们的余数。所以我们得到 172,余数为 4。
我将展示一种稍微不同的方法,因为这是我们在硬件中进行二进制除法时要做的。我们可以这样想:我们试图看看这些数字... 基本上是从右边移入,我们进行左移,看看那个数字在什么时候能被 15 整除?
我们从 2 开始,尝试看 15 能进 2 吗?不能。我们在心里跳过了那部分,因为我们显然知道 15 不能进 2。但我们没有进到 258,我们在得到 25 时停下了,因为我们说,哦,是的,15 能进 25。但通常,我们从最高有效数字开始,说,好吧,能进吗?2 减 15 是负数,不能进,所以再次移位,将那个数字再次左移。现在我们得到 25。我们说,能进吗?25 减 15,是的,能进。所以我们可以看到它进了,并且进了一次。现在我们说,好的,很好,这是我们剩下的,25 减 15 是 10。所以现在,我们不是使用这个 25,而是说我们将使用这里剩下的任何数量作为我们现在要移位并带下一位数字的数量。我们说,嗯,15 能进那个多少次?进 7 次。现在我们再次看到 3,而不是 105,因为这是正数而不是负数,它确实能进。我们将那个 3 带下来,这就是我们移位并带入最后一位数字 4 的数量。
这是另一种思考方式,因为这就是我们在二进制中要做的。我们将以类似的方式执行二进制除法。这是无符号二进制除法。我们的 A 这里是 13 除以 2。我们期望得到 6,余数 1。6 乘以 2 是 12,然后我们有余数 1。
让我们看看。我们有我们的 A 这里:A3, A2, A1, A0。我们要做的第一件事是取 A 的最高有效位并将其移入。我们从右边移入。所以我们有 A3。我们将看看我们的除数 B 是否能进那个数。我们执行 1 减 2,不能,是负数。所以我们说,好吧。负的结果意味着 2 不能进那个数。这意味着我们在那个商位中放入 0。好的,这没问题。所以我们取 A3,这个值,整个东西,我们将其左移一位,所以 A3 仍然在这里。然后我们取下一位 A2 并将其从右边移入。现在我们再次检查,我们说,嘿,我们的除数能进那个数吗?3 减 2。是的,能进。所以结果是正数。3-2 是 1。因为它是正数,我们在商位那里放入 1,并说,是的,能进。二进制的一个好处是,它要么能进,要么不能。它不会是它的倍数。所以它要么是 1(能进),要么是负数结果,或者是 0(不能进)。
好的,所以能进,3 减 2 是 1,我们取这个值 1。这就是余数,是我们要移位的数量。现在,左移,然后带下下一个最高有效位 A1 和最低有效位 A0。现在我们说,好吧。让我们看看 2 能进 2 吗?2 减 2 是正数。我们得到正数结果。2 减 2 是 0,0 被认为是正数。所以 2 减 2 是 0,所以是正数,不是负数。所以我们得到 1,在商的下一个最高有效位。然后我们取那个结果,嗯,全是零。所以几乎看不到,但将其左移 1 位,最后带入 A 的最后一位 A0。我们检查并说,好吧,2 能进那个数吗?1 减 2 是 -1,哦,是负数。所以最后一位是 0。这是我们的余数。它不能进。所以我们的商是 6。这实际上是除法过程的最后一步,我们的余数是 1。
我们可以用算法形式写出来。我们有这个 R‘,余数值,我们想执行 A 除以 B 等于 Q 加 R 除以 B,其中 R 是余数。
我们将对 i 从 n-1 到 0 执行此操作,从被除数 A 的最高有效位开始。我们将初始化余数为零,所以我们从 0,0,0,0 开始。然后我们移入,将这个左移一位,所以那位掉出,我们移入 A_i,在第一次迭代中是 A 的最高有效位 A3。现在我们得到 0,0,0, A3,那是最高有效位。这就是这一步。
现在我们将执行这个差值 R 减 B。我们将检查 B 是否能进那里。这是我们的 R 减 B。我们说,嗯,如果那个差值小于 0,所以如果是负数,那么它不能进。我们将保持那个余数,并说,嘿,它不能进。所以那个商位是 0。好的,这是我们的第一步或第一次迭代。在这种情况下,我们没有执行 else 步骤。我们将在下一步看到会发生什么,现在它将重复,因为我们在这个 for 循环中,i 从 n-1 到 0。
现在我们在第二次迭代中。所以现在 i 等于 n-2。我们的 R‘,在这种情况下是 0,0,0, A3,也就是 1,我们就保持这样。现在我们将这个 R’ 左移一位。现在我们将得到 0,0, A3,零掉出,我们移入下一个最高有效位 A2。这就是这里发生的情况。现在我们将说,好吧,我们将再次执行那个差值,并说 R 减 B,B 能进那个值吗?事实上,这次能进。它说 D 小于 0 吗?不,不是这种情况。在这种情况下,它大于或等于 0,是 1。所以现在我们使商 Q_i(在这种情况下 Q2)等于 1,现在,我们不是传递这个 R 向前,而是传递差值向前,即我们的 0001,到我们的 R‘ 或余数 R’ 中。
好的,现在我们说,好吧,R‘ 等于 D,即 0001,将其左移一位,所以 0001 左移,丢掉那位,下一个最高有效位 A1 移入。在我们的例子中 A1 是 0,所以现在我们到达这一步。现在我们检查并说,B 能进那里吗?我们再次执行那个差值,R 减 B。是的,能进。D 小于 0 吗?不,不小于 0,所以我们在 else 情况下。我们说,好吧,Q 是 1,i 是这个 1。我们使 R‘ 等于那个差值。
好的,现在我们进入最后一次迭代。R‘ 是 0,0,0,0,那个差值。我们将其左移一位。我们丢掉那位,并移入 A 的最后一位,即 A0。在这个例子中,它是 1。我们得到 0,0,0,1。再次,我们现在在这个阶段 0001,我们执行那个差值。1 减 2,小于 0 吗?是的。我们最后的商位是 0。Q0 是 0,然后 R‘ 等于 R(保持不变)。我们只是等于那个。
现在我们在迭代结束时,我们已经从 i 等于 n-1 运行到 0。现在我们完成了那个 for 循环。我们说 R 等于 R‘。所以余数等于 R’ 值。我们得到我们的结果,即 6(0,1,1,0),这个值星号商,以及我们的余数 1。
除法器的构建
我们如何构建这个呢?这是构建方法。我们这里有一个盒子,每个盒子在这里显示,因为空间原因,我们没有在这个大图中写出所有小盒子的名称。但这个盒子里面是这个电路。这个电路基本上在执行减法,正如你所看到的,我们馈入 B 的反码加 1 到进位中。所以那是在做这个,0,0,0, A3。这是第一次迭代,0,0,0, A_i 在第一次迭代中是 A3。所以每个盒子都在执行一次迭代。这是第 n-1 次迭代。这是第 n-2 次迭代。这是第 n-3 次迭代。这是第 n-4 次迭代,在这个例子中 n 是 4。所以我们有 A 的位 3、位 2、位 1 和位 0 被移入。
好的,所以这正在执行那个差值 R 减 B,因为我们有 B 的反码输入和进位上的 1。所以这正在执行减法,然后我们得到差值。我们想知道,嗯,我们要么将那个差值作为 R‘ 向前馈送(在 else 情况下),要么如果结果是负数,即 B 不能进,我们就馈送这个余数 R。
进位输出,这只是一个行波进位加法器,它服从这些其他盒子。这里的关键是,我们想看看那个差值的结果是什么。所以我们想看看差值的最高有效位 P3。这是差值的最高有效位,它将决定我们选择什么。如果它是负数,那么 Q_i,在这种情况下 Q3,如果这是负数,这是 1,所以 Q3 是它的反码,将是 0。如果它是负数,R‘ 等于 R。所以如果这里是负数,我们希望将 R 馈送到 R‘,它不能进。这是一个输出。这是差值的最高有效位,它告诉我们结果是否为负数。然后我们将其馈送到我们的... 这些是输入。在每个那些 N 输入选择的多路复用器中。
好的,所以每个盒子都在执行算法的一次迭代,每一行都在执行算法的一次迭代。然后你可以看到,我们取要么... 所以无论 R‘ 是什么,我们取它,左移一位,然后移入 A 的下一个最高有效位。同样的事情,下一行,取那个 R‘,然后移入下一个最高有效位。对于最后一行也是如此。
正如你所看到的,这个除法器也相当昂贵,就像乘法器一样,有加法器、多路复用器,构建这个 4 位除 4 位的除法器将花费我们不少成本。
总结

在本节课中,我们一起学习了移位器、乘法器和除法器。我们了解了逻辑移位和算术移位的区别,以及循环移位的工作原理。我们探讨了如何使用移位器高效地进行 2 的幂次的乘法和除法运算。我们还深入研究了乘法器和除法器的构建方式,理解了它们如何通过部分积相加或迭代减法来工作,并认识到这些硬件组件在成本和复杂性方面的考量。这些知识是理解计算机如何执行基本算术运算的基础。
062:定点数表示法 🧮

在本节中,我们将学习如何使用二进制位来表示小数。我们将重点介绍两种主要表示法中的第一种:定点数表示法。这种表示法使用固定数量的位来表示整数部分和小数部分。
概述
之前我们已经讨论了无符号二进制数(仅表示正数)以及有符号数的表示方法(如符号-幅度和二进制补码)。现在,我们将探讨如何表示分数。本节将详细介绍定点数表示法,其特点是二进制小数点的位置是固定的。
定点数表示法
定点数使用固定数量的位来表示整数部分,同时使用固定数量的位来表示小数部分。
例如,考虑一个具有4个整数位和4个小数位的格式。在无符号二进制中,整数位的权重是2的幂次:20(个位)、21(二位)、22(四位)和23(八位)。对于小数部分,我们扩展这个模式,使用负指数幂:2-1(二分之一)、2-2(四分之一)、2-3(八分之一)和2-4(十六分之一)。
二进制小数点的位置是隐含的,我们需要事先约定整数位和小数位各有多少位。
以下是一个具体的例子。假设我们有一个二进制数 0110.1100(其中小数点是为了清晰而标出,实际存储中并不存在)。其值为:
- 整数部分
0110= 0×8 + 1×4 + 1×2 + 0×1 = 6 - 小数部分
.1100= 1×(1/2) + 1×(1/4) + 0×(1/8) + 0×(1/16) = 0.5 + 0.25 = 0.75
因此,这个定点数表示的十进制值是 6.75。
无符号定点数格式
无符号定点数格式通常表示为 Ua.b,其中 a 是整数位的数量,b 是小数位的数量。
例如,我们刚才的例子(6.75,4个整数位,4个小数位)可以表示为 U4.4。同一个数 6.75 也可以用其他格式表示,如 U3.5 或 U6.2,只要分配的位数足够容纳该数值即可。
常见的定点数格式有8位、16位和32位。例如:
- U.8.8 常用于表示传感器数据、音频和像素数据。
- U.16.16 用于需要更高精度的信号处理。
有符号定点数格式
有符号定点数(使用二进制补码)格式通常表示为 Qa.b,其中 a 是包括符号位在内的整数位的数量,b 是小数位的数量。
对一个Q格式的定点数进行取负操作(求其相反数),方法与标准的二进制补码相同:将所有位取反,然后在最低有效位上加1。这里的关键是,这个“最低有效位”指的是整个数(包括小数部分)的最低位。
例如,要用 Q4.4 格式表示 -6.75,步骤如下:
- 首先得到 +6.75 的表示:
0110.1100 - 将所有位取反:
1001.0011 - 在最低位(小数部分最后一位)加1:
1001.0011 + 0.0001 = 1001.0100
因此,1001.0100就是 -6.75 在 Q4.4 格式下的表示。
一个特别常见的格式是 Q1.15(常简写为 Q15),它使用1个符号位和15个小数位。其表示范围约为 +1 到 -1。
饱和运算
在讨论算术运算时,我们必须考虑溢出问题。在音频或视频处理中,如果发生溢出,一个很大的正数突然变成负数,会产生可闻的“爆裂”声或视频中的“黑像素”。
为了避免这个问题,我们常常使用饱和运算。饱和运算在发生溢出时,不会让数值“环绕”到另一端,而是将其限制在可表示的最大或最小值上。
例如,在 U4.4 格式下计算 12 + 7.5:
- 12 表示为
1100.0000 - 7.5 表示为
0111.1000 - 直接相加结果为
10011.1000,这需要5个整数位,在U4.4格式下发生溢出,结果会错误地变小。 - 使用饱和运算,结果将被限制在U4.4能表示的最大值,即
1111.1111(15.9375)。
总结

本节课我们一起学习了定点数表示法。我们了解到,定点数通过预先固定整数位和小数位的数量来表示小数。我们介绍了无符号格式(Ua.b)和有符号格式(Qa.b)的表示方法,以及如何对Q格式数进行取负操作。最后,我们探讨了在信号处理中至关重要的饱和运算概念,它可以防止溢出导致的质量劣化,是处理音频、视频等数据时的常用技术。
063:浮点数 🧮

在本节课中,我们将学习浮点数的概念、表示方法及其与定点数的区别。浮点数类似于科学计数法,能够表示更大范围的数值,是通用计算中的重要组成部分。
从定点数到浮点数
上一节我们介绍了定点数,本节中我们来看看浮点数。与定点数不同,浮点数允许二进制小数点的位置“浮动”,使其始终位于最高有效位1的右侧。这类似于十进制的科学计数法。
例如,十进制数 243 可以表示为:
2.43 × 10²
在二进制浮点数中,我们采用类似的方法。一个浮点数通常由以下部分组成:
(-1)^S × M × B^E
其中:
- S 是符号位(0 表示正,1 表示负)。
- M 是尾数(Mantissa)。
- B 是基数(Base),在二进制浮点数中固定为 2,因此无需编码。
- E 是指数(Exponent)。
与定点数相比,浮点数提供了更大的动态范围(能表示更小和更大的数),但这是以牺牲一定精度和增加算术运算复杂度为代价的。浮点数在加法前需要对齐尾数,这会消耗更多的性能和时间。
以下是浮点数与定点数的主要对比:
- 浮点数:编程更容易,动态范围大,但硬件实现更复杂,功耗和性能开销大。适用于通用计算。
- 定点数:编程更复杂(需警惕溢出),动态范围小,但硬件实现简单高效。适用于对功耗和成本敏感的信号处理、机器学习等领域。
IEEE 754 32位浮点数标准
现在,我们来看看如何将浮点数的各个部分编码到具体的位格式中。最常用的标准是 IEEE 754 单精度(32位)浮点数格式。
一个32位浮点数包含以下字段:
- 1位 符号位(S)
- 8位 指数位(E)
- 23位 尾数位(M)
我们将通过一个例子,分三步构建出最终的 IEEE 754 表示形式。
示例:表示十进制数 228
第一步:转换为二进制并隐含“×2⁰”
首先,将十进制数 228 转换为二进制:
228₁₀ = 11100100₂ (可理解为 11100100 × 2⁰)
第二步:规范化(移动二进制小数点)
将二进制小数点向左移动,直到它位于最高有效位1的右侧。这里需要移动7位。
1.1100100 × 2⁷
现在,我们得到了:
- 符号 S = 0(正数)
- 尾数 M =
1.1100100 - 指数 E = 7
第三步:编码为IEEE 754格式
- 符号位:直接填入
0。 - 指数位:采用偏置指数。对于32位浮点数,偏置量是127。因此,存储的指数值是
7 + 127 = 134。
134₁₀ = 10000110₂ - 尾数位:只存储小数部分。因为规范化的尾数总是
1.xxxxx的形式,所以开头的“1”是隐含的,无需存储。我们只存储小数点后的部分1100100,并在右侧用0补足23位。
最终,228 的 IEEE 754 单精度表示为:
0 10000110 11001000000000000000000
可以将其转换为十六进制以便阅读:
0x43640000
常见错误:不要试图直接从十进制的科学计数法(如 2.28 × 10²)转换到二进制浮点。必须遵循上述“先转二进制,再规范化”的步骤。
另一个示例:表示 -58.25
让我们用完整的流程处理一个负数和小数。
-
转换绝对值 58.25 为二进制:
- 整数部分 58:
58₁₀ = 111010₂ - 小数部分 0.25:
0.25₁₀ = 0.01₂(因为 0.25 = 2⁻²) - 合并:
58.25₁₀ = 111010.01₂(即111010.01 × 2⁰)
- 整数部分 58:
-
规范化:将二进制小数点左移5位。
1.1101001 × 2⁵- 符号 S = 1(负数)
- 尾数 M =
1.1101001 - 指数 E = 5
-
编码:
- 符号位:
1 - 偏置指数:
5 + 127 = 132,即10000100₂ - 尾数小数部分:
1101001(后补0至23位)
- 符号位:
最终32位表示为:
1 10000100 11010010000000000000000
十六进制表示为:
0xC2690000
特殊值、双精度与舍入
浮点数格式需要定义一些特殊值。
特殊编码:
- 零:由于零没有最高有效位1,因此用特殊编码表示。指数和尾数部分全为0。符号位可为0或1,因此存在
+0和-0。 - 无穷大:指数全为1,尾数全为0。符号位决定正负无穷。
- 非数:指数全为1,尾数非0。用于表示无效操作的结果,如
0 ÷ 0。
单精度与双精度:
- 单精度:即我们讨论的32位格式(1位符号,8位指数,23位尾数)。偏置量 = 127。
- 双精度:使用64位(1位符号,11位指数,52位尾数)。偏置量 = 1023。双精度极大地增加了尾数位数,从而提供了更高的精度,适用于科学计算等需要极高精度的场景。
溢出、下溢与舍入:
- 当数字的绝对值过大而无法表示时,发生溢出。
- 当数字的绝对值过小(过于接近0)而无法表示时,发生下溢。
- 由于尾数位数固定,实数经常无法精确表示,需要进行舍入。常见的舍入模式有:
- 向零舍入
- 向下舍入
- 向上舍入
- 向最近值舍入(最常用)
例如,假设我们只能用3个小数位表示二进制数 1.11001(十进制约1.78125)。
- 向下舍入得到:
1.110(1.75) - 向上舍入得到:
1.111(1.875) - 向零舍入(正数时同向下舍入):
1.110(1.75) - 向最近值舍入:由于
1.11001更接近1.110而非1.111,因此结果为1.110(1.75)。

本节课中我们一起学习了浮点数的核心思想、IEEE 754单精度浮点数的编码方式(包括规范化、偏置指数和隐含位),并了解了双精度格式、特殊值以及舍入等关键概念。浮点数通过牺牲一定的精度和硬件效率,换取了表示极大动态范围数值的能力,是现代计算机处理实数运算的基石。
064:浮点数加法 🧮

在本节中,我们将学习如何对浮点数进行加法运算。这个过程比补码或无符号二进制数的加法要复杂得多,因为它涉及提取字段、对齐指数、尾数相加、结果规范化以及可能的舍入操作。
概述
浮点数加法需要将数字从其紧凑的存储格式中“拆解”出来,执行实际的算术运算,然后再将结果“组装”回标准格式。接下来,我们将详细介绍每个步骤。
加法步骤详解
以下是执行浮点数加法的具体步骤。我们将逐一拆解,并通过一个例子来演示。
第一步:提取字段
首先,我们需要从浮点数的二进制表示中提取出符号位、指数位和尾数位(分数位)。对于单精度浮点数(32位),其格式为:1位符号位(S),8位指数位(E),23位尾数位(F)。
公式:(-1)^S × 1.F × 2^(E-127)
第二步:添加前导1
在IEEE 754标准中,规格化数的尾数部分隐含了一个前导的“1”。在进行计算前,我们必须将这个“1”显式地添加到提取出的尾数(F)前面,以构成完整的尾数(Mantissa)。
代码表示:Mantissa = 1 + Fraction
第三步:比较并对齐指数
两个加数的指数可能不同。为了使尾数能够直接相加,我们需要将指数较小的那个数的尾数向右移位,直到两个数的指数相等。移位时,移出的低位可能会丢失,这将在后续的舍入步骤中处理。
第四步:尾数相加
当两个数的指数对齐后,我们就可以将它们的完整尾数(包括前导1)进行二进制加法。
第五步:规范化结果
相加后的尾数可能不再是1.xxx的形式(例如,可能得到10.xxx或0.1xxx)。我们需要通过左移或右移尾数,并相应地调整指数,使结果重新变为规格化形式(即尾数部分为1.xxx)。
第六步:舍入
由于尾数的位数有限(如23位),规范化后的结果可能位数过多。我们需要根据IEEE 754的舍入规则(如向最近偶数舍入)将结果截断到指定的精度。
第七步:组装结果
最后,我们将结果的符号位、调整后的指数(加上偏置值127)以及舍入后的尾数(去掉前导1)重新组合成一个32位的浮点数。
示例演示
现在,让我们通过一个具体的例子来实践上述步骤。假设我们要计算 3.0 + 0.5 的浮点数表示。
第一步:提取字段
- 数字
3.0(十六进制0x40400000):- 符号位 S = 0
- 指数 E = 10000000 (二进制) = 128
- 尾数 F = 100 0000 0000 0000 0000 0000
- 数字
0.5(十六进制0x3F000000):- 符号位 S = 0
- 指数 E = 01111110 (二进制) = 126
- 尾数 F = 000 0000 0000 0000 0000 0000
第二步:添加前导1,构成完整尾数
3.0的尾数 M1 =1.100 0000 0000 0000 0000 00000.5的尾数 M2 =1.000 0000 0000 0000 0000 0000
第三步:比较并对齐指数
- E1 = 128, E2 = 126。E2 较小。
- 将 M2 的指数对齐到 128,需要将其尾数右移 2 位:M2' =
0.010 0000 0000 0000 0000 0000
第四步:尾数相加
- M1 + M2' =
1.1000000...+0.0100000...=1.1100000...
第五步:规范化
- 结果
1.1100000...已经是规格化形式(1.xxx),指数保持为 128。
第六步:舍入
- 尾数
1.1100000...恰好符合23位精度,无需舍入。
第七步:组装结果
- 符号位 S = 0
- 指数 E = 128 (二进制
10000000) - 舍去前导1后的尾数 F =
110 0000 0000 0000 0000 0000 - 最终32位结果:
0 10000000 11000000000000000000000(二进制) =0x40600000(十六进制) =3.5
总结

本节课我们一起学习了浮点数加法的完整流程。可以看到,与整数加法相比,浮点数加法需要多个额外的步骤来处理指数对齐、结果规范化和舍入。理解这个过程对于深入理解计算机如何执行浮点运算至关重要,也有助于在编程中预判和处理可能的精度问题。
065:计数器和移位寄存器 🧮

在本节课中,我们将要学习两种重要的时序逻辑电路:计数器和移位寄存器。计数器用于按顺序生成数字,而移位寄存器则用于在时钟边沿移动数据位。我们将探讨它们的工作原理、构建方法以及在实际系统中的应用。
计数器
上一节我们介绍了寄存器,本节中我们来看看计数器。计数器在每个时钟边沿递增。例如,手表就是一个计数器,它从0计数到59,然后回到0,这是一个模60计数器。计数器用于循环遍历数字序列。
一个3位计数器的例子是:从0开始计数,依次经过1、2、3、4、5、6、7,然后回到0。计数器的应用包括数字时钟显示和程序计数器。程序计数器(我们将在下一章讨论)用于跟踪当前正在执行的指令,例如,指令0正在执行,然后是指令1,接着是指令2。
以下是计数器的符号表示。它没有输入,但有一个复位端(Reset)用于将其重置为零,还有一个N位的输出端。其符号看起来像一个没有输入端的触发器。
如何构建计数器?
我们如何构建这个计数器?我们可以使用一个加法器。我们想让计数器每次加1,即加上二进制表示的1。但直接连接加法器会产生竞争问题。解决方案是加入一个触发器。
具体构建方法如下:使用一个可复位的触发器,其输出Q反馈到加法器的一个输入端。加法器的另一个输入端是常数1(N位表示)。加法器的输出连接到触发器的D输入端。在下一个时钟边沿,递增后的值成为新的Q值,如此循环。
以下是构建一个N位计数器的示意图,它每次递增1。我们也可以构建递增不同数值(如4)的计数器。
Verilog 实现
以下是计数器在SystemVerilog中的实现。这是一个8位计数器的模块。
module counter (
input logic clk,
input logic reset,
output logic [7:0] q
);
always_ff @(posedge clk) begin
if (reset) begin
q <= 8‘b0;
end else begin
q <= q + 8‘b1;
end
end
endmodule
另一种更详细的实现方式会显式地展示加法器:
module counter_verbose (
input logic clk,
input logic reset,
output logic [7:0] q
);
logic [7:0] next_q;
// 显式加法器
assign next_q = q + 8‘b1;
always_ff @(posedge clk) begin
if (reset) begin
q <= 8‘b0;
end else begin
q <= next_q;
end
end
endmodule
使用计数器进行时钟分频
计数器也可用于对时钟进行分频。例如,如果我们想控制一个LED闪烁,而板载时钟通常至少为50MHz,人眼无法察觉如此快速的闪烁。因此我们需要降低时钟频率。
假设我们有一个时钟信号,周期为Tc。一个1位计数器在每个时钟边沿从0翻转到1。其输出信号的周期是2Tc,频率是时钟频率的一半。
对于N位计数器,其最高有效位(MSB)的输出频率是时钟频率除以2^N。例如,一个3位计数器,其最高位的周期是8Tc,频率是时钟频率的1/8。
因此,计数器可用于分频。例如,一个50MHz的时钟,经过一个24位计数器后,其最高位的输出频率是50MHz / 2^24 ≈ 2.98Hz。
数字控制振荡器
我们还可以使用计数器产生数字控制振荡器。我们使用刚才讨论的N位计数器,但每次循环不是加1,而是加一个值P。
现在,该计数器最高有效位的输出将以(时钟频率 × P / 2^N)的频率翻转,而不是之前的1 / 2^N。
例如,假设时钟频率为50MHz,我们想产生一个200Hz的信号。我们需要使 P / 2^N = 200 / 50,000,000。
我们可以尝试N=24(24位计数器),P=67。输出频率 = 50MHz × 67 / 2^24 ≈ 199.676Hz,非常接近200Hz。
我们也可以使用32位计数器,设置P=17,179,可以得到更接近200Hz的频率。
移位寄存器
现在,让我们转向另一种有用的时序电路:移位寄存器。移位寄存器可以在每个时钟边沿移入一个新的位。它看起来像是一系列首尾相连的寄存器。
移位寄存器有一个串行输入(S_in或Serial In)和一个串行输出(S_out或Serial Out)。内部还有并行输出Q0, Q1, Q2, ..., Q_{n-1}。
例如,我们可以随时间移入一个值,如0, 1, 1, 0, 1。最初移入的0会随着新位的移入而向右移动。最终,这些位会出现在内部并行输出线上。
因此,移位寄存器可以将串行输入转换为并行输出,我们称之为串并转换器。这在芯片引脚数量有限时非常有用。我们可以通过单根线(串行)随时间移入多位数据,然后在芯片内部以并行方式使用这些数据。
例如,如果要设置计数器的值,可以通过串行方式移入该值,然后在计数器内部并行使用。
带并行加载的移位寄存器
移位寄存器的另一个版本是带并行加载功能的移位寄存器。让我们看看它的结构。
如果没有多路选择器和加载(Load)信号,这部分电路就是一个常规的移位寄存器。但现在,我们增加了一个多路选择器。
以下是其工作原理:
- 当 Load = 1 时,多路选择器将数据输入D馈送到寄存器中。此时,电路就像一个普通的N位寄存器。
- 当 Load = 0 时,多路选择器将串行输入S_in馈送到第一个寄存器,同时每个寄存器的输出连接到下一个寄存器的输入。此时,电路就像一个移位寄存器。
这种电路非常有用。例如,在计数器电路中,通常我们希望它像普通寄存器/计数器一样工作(Load=1)。但有时我们想通过串行输入预设一个值,这时我们可以设置Load=0,通过S_in串行移入数据。
带并行加载的移位寄存器也常用于芯片测试。我们可以将芯片中所有或大多数寄存器串联起来,通过串行输入为所有寄存器加载一个已知值,然后将Load设回1,使其忽略串行输入并从该状态开始计算。
Verilog 实现
以下是带并行加载的移位寄存器的SystemVerilog实现。
module shift_register_parallel_load (
input logic clk,
input logic reset,
input logic load,
input logic ser_in,
input logic [N-1:0] d, // 假设N已定义
output logic [N-1:0] q,
output logic ser_out
);
// 异步复位,同步逻辑
always_ff @(posedge clk, posedge reset) begin
if (reset) begin
q <= {N{1‘b0}}; // 复位为全零
end else if (load) begin
q <= d; // 并行加载模式
end else begin
// 移位模式:左移一位,移入ser_in
q <= {q[N-2:0], ser_in};
// 最高位移出到ser_out
ser_out <= q[N-1];
end
end
endmodule
代码说明:
- 当
reset有效时,寄存器q被清零。 - 当
load为1时,q从数据输入d加载,表现为普通寄存器。 - 当
load为0时,q向左移位(低位向高位移动),最低位由ser_in填充,最高位被移出到ser_out。
总结
本节课中我们一起学习了计数器和移位寄存器。
- 计数器 在每个时钟边沿递增,可用于计数、程序跟踪和时钟分频。其核心是一个加法器加一个触发器的反馈环路。
- 移位寄存器 可以将数据位在寄存器链中移动,实现串行到并行的转换,在接口引脚受限时非常有用。
- 带并行加载的移位寄存器 结合了普通寄存器和移位寄存器的功能,通过一个加载信号进行控制,增加了灵活性,常用于初始化和测试。

理解这些基本时序逻辑模块是设计更复杂数字系统(如后续章节将介绍的处理器)的重要基础。
066:存储器简介 💾

在本节中,我们将学习数字系统中一个至关重要的组成部分——存储器。我们将了解什么是存储器阵列,它们如何工作,以及不同类型的存储器(如RAM和ROM)之间的核心区别。
存储器阵列概述
存储器是数字系统的另一个重要组成部分。我们使用存储器阵列来存储数据。
这些阵列能高效地存储大量数据,右侧的图片展示了一个存储器阵列。我们有一个输入或地址,以及一个位地址。我们可以向该阵列读取或写入数据。这些箭头表示我们可以写入数据,也可以从该阵列中读取数据。
因此,每个唯一的N位地址处都会读取或写入一个M位的数据值。例如,一个2位地址将有4个(即2的2次方)唯一地址;一个10位地址将有1024个(即2的10次方)唯一地址。
常见的存储器类型
有三种常见的存储器类型:DRAM(动态随机存取存储器)、SRAM(静态随机存取存储器)和ROM(只读存储器)。我们将逐一讨论它们。
存储器阵列的结构
这是一个非常小的存储器示例,但其原理适用于更大的尺寸。存储器阵列是位单元组成的二维数组。这是存储器阵列的最高层视图。在这个例子中,我们有一个2位地址和3位数据。
因此,对于2个地址位,我们有2的2次方(即4个)可能的唯一地址。地址分别是00、01、10、11。我们可以在每个地址存储3位数据。例如,在地址00处,存储了位011。在地址11(即地址3)处,存储了值010。
每个位单元存储一个比特。我们再次强调,有N个地址位和M个数据位。我们也可以将其称为2的N次方行和M列。因此,行数就是深度,即我们的存储器阵列可以容纳的字数;宽度就是字的大小。在这个例子中,我们有3位,宽度是3位,所以字的大小是3位。
这个阵列是一个2的N次方乘以M位的阵列,即一个存储器阵列。因此,我们有一个2的2次方乘以3位,即4乘以3位的存储器阵列。换句话说,我们可以存储总共4个字,每个字大小为3位,所以总共可以存储12位数据。
例如,在这个2的2次方乘以3位的存储器阵列中,如果我们查看地址10,那里存储的字是100。字的大小是3位,字数(即深度)是4,因为我们有2个地址位。
更大的存储器阵列示例
这是另一个存储器阵列,我们有10个地址位,所以阵列中存储了2的10次方(即1024)个字,字的大小是32位。因此,我们有2的10次方个条目,每个条目32位。每个行包含32位,即我们阵列的宽度是32。我们可以存储的总位数是2的10次方乘以32位,即2的15次方位,或32千位数据。
位单元的内部结构
现在,让我们深入了解位单元内部是什么。每个位单元可以存储一个比特,并且每个位单元都有一条字线,本质上是一条使能线。当这个位单元被使能时,它允许存储在该单元中的1或0输出到数据线上。
我们将重点讨论读取操作,写入操作类似,只是数据线上的内容会被存储到那个位单元中。例如,在左侧的情况下,字线等于1,这意味着那些位单元被使能。如果存储在该位单元中的是0,那么由于字线为1,那个0可以输出到数据线上,并使数据线变为0。类似地,如果存储的是1,只要字线为1,那个1就会输出到数据线上,数据线变为1。
相反,如果我们的字线是0,那个位单元就没有被使能。因此,基本上没有连接。当这条字线为0时,它表示“哦,不,未被使能,不能输出到数据线上”。那条数据线就处于高阻抗状态。
完整的存储器阵列
现在让我们看看整个存储器阵列。我们有字线,它再次类似于使能线。存储器阵列中的单一行可以在任何给定时间被读取或写入。这条字线对应一个唯一的地址。
如果我们看看如何构建这个阵列,左侧有一个解码器。我们的地址输入到一个解码器中。无论地址是什么,对于2位地址,我们的解码器会有一个独热输出。例如,如果地址是00,那么字线0为高电平,并允许第0行的比特输出到数据线上。数据线连接到数据输出。
另一方面,如果地址是10,那么现在字线2将为高电平。但请记住,解码器是独热输出。只有字线2是高电平,其余都是0。因此,只有那一行的位单元被使能来驱动数据线。然后,我们可以在数据输出上读取该数据。
在这个例子中,当地址是10时,我们在数据线上读取出的值是100。
存储器类型:RAM与ROM
接下来,让我们讨论这些位单元中包含什么,这取决于我们使用的存储器类型。总的来说,有两种类型的存储器:随机存取存储器(RAM)和只读存储器(ROM)。RAM和ROM的名称实际上是历史遗留的。最重要的部分是RAM是易失性的,而ROM是非易失性的。
易失性意味着对于随机存取存储器(RAM),当我切断该电路的电源时,存储器内容会丢失。因此,易失性意味着如果断电,存储在那里的内容也会丢失。
非易失性,或者说ROM,意味着当我关闭电源时,存储器内容仍然存储在那里。例如,我的智能手机,我在上面拍了很多照片,当它没电关机时,我的照片不会全部消失,因为存储使用的是ROM而不是RAM。
所以,再次强调,RAM是易失性的,意味着断电时会丢失数据。但它的读写速度很快,这是使用RAM相对于ROM的一个优势。我们计算机的内存就是RAM。当你谈论你的计算机有多少内存时,那就是DRAM。
历史上它被称为随机存取存储器,因为任何数据字都可以像其他任何数据字一样容易地被访问。这与磁带录音机等形成对比,磁带不是随机存取的,如果你靠近磁带的那部分,访问就快,但如果是在磁带两小时后的位置,访问就会困难。RAM或随机存取,意味着任何数据地址的访问都与其他地址一样容易或快速。
只读存储器(ROM)的定义性特征再次是它是非易失性的。因此,当断电时,它能保留数据。ROM读取速度快,但写入有时是不可能的,或者像在你的手机或U盘上,写入速度很慢。
闪存、相机、U盘和数码相机都使用ROM,这样当断电时,你把U盘从电脑上拔下来,数据不会消失。历史上,它被称为只读存储器,因为它们过去是在工厂里写入的,通过烧断保险丝来实现,并且只能写入一次,不能再次写入,所以被称为只读存储器。但同样,这显然不再正确了,我使用的闪存盘既可以读取也可以写入。因此,ROM现在指的是其关键特征——非易失性,即断电时保留数据。
总结

在本节课中,我们一起学习了存储器的基础知识。我们了解了存储器阵列是二维的位单元阵列,通过地址访问特定位置的数据。我们探讨了位单元如何通过字线和数据线工作,以及完整的存储器阵列如何通过地址解码器选择特定行。最后,我们区分了两种主要存储器类型:易失性的随机存取存储器(RAM),其读写速度快但断电数据丢失;以及非易失性的只读存储器(ROM),其数据在断电后仍能保留,但写入可能较慢或受限。理解这些概念是构建和理解复杂数字系统的基础。
067:RAM详解 💾

在本节中,我们将学习计算机中两种关键的内存类型:动态随机存取存储器(DRAM)和静态随机存取存储器(SRAM)。我们将探讨它们的基本工作原理、内部结构以及主要区别。

上一节我们介绍了内存阵列的基本概念,本节中我们来看看构成这些阵列的核心存储单元是如何工作的。

RAM的构建方式主要有两种:DRAM和SRAM。DRAM代表动态随机存取存储器,SRAM代表静态随机存取存储器。它们的核心区别在于存储数据的方式。
DRAM:基于电容的存储
DRAM使用一个电容来存储数据位。
公式: DRAM存储单元 = 1个晶体管 + 1个电容
罗伯特·登纳德于1966年在IBM发明了DRAM,当时许多人甚至怀疑它能否正常工作。但不久之后,到20世纪70年代中期,DRAM已广泛应用于几乎所有计算机中。
动态随机存取存储器之所以被称为“动态”,是因为存储在电容上的值需要定期刷新或重写,每次读取后也需要刷新。这是因为电容并不完美,其存储的电荷会随时间泄漏,导致存储的值逐渐衰减。读取一个位总是会破坏其存储的电荷,因此需要刷新数据。
以下是DRAM单元的工作原理:
- 当字线为高电平(1)时,NMOS晶体管导通。
- 如果电容上存储了电荷(代表逻辑1),电荷会流向位线并为其充电。
- 如果电容上没有电荷(代表逻辑0),位线则保持其原有状态,不会接收到额外电荷。
SRAM:基于锁存器的存储
SRAM与DRAM不同,它虽然也存储数据位,但存储方式使其无需刷新数值。SRAM通过交叉耦合的反相器来存储数据。
代码/结构描述:
SRAM存储单元 = 2个交叉耦合的反相器构成锁存器 + 2个访问晶体管
如果存储的位是0,当字线变为高电平时,这个0会被驱动到位线上,或将位线拉低。如果存储的位是1,字线变高则允许电荷流动。反相器会主动地将这个1驱动到位线上。
由于使用了交叉耦合的反相器,我们同时拥有数据位及其反相值。因此,SRAM单元通常包含位线(Bit Line)和反相位线(Bit Line Bar)。我们可以读取这两条线之间的电压差,这有助于加速读取过程,使其更快。
这种存储被称为静态随机存取存储器,因为读取时位的值不会被破坏(我们不会丢失所有电荷,数据由反相器主动驱动),并且它也不会像DRAM电容上的电荷那样随时间泄漏。
以下是两种存储单元的总结对比:
- DRAM单元:数据位存储在电容上。
- SRAM单元:数据位存储在交叉耦合的反相器上。

本节课中我们一起学习了DRAM和SRAM的基本原理。DRAM利用电容存储电荷来代表数据,需要定期刷新;而SRAM利用交叉耦合反相器构成的锁存器来存储数据,无需刷新,速度更快。理解这两种基本存储单元是学习更复杂内存架构的基础。在绘制内存阵列时,可以用一个简化的方框代表存储单元,并注明其内部是DRAM结构还是SRAM结构,这利用了抽象原则,使示意图更清晰易懂。
068:只读存储器(ROM)与逻辑实现

在本节中,我们将学习只读存储器(ROM)的基本结构,并探索如何利用ROM阵列来实现逻辑功能。我们将看到,通过存储特定的数据模式,ROM可以充当一个可编程的查找表,执行与门、或门等逻辑运算。
ROM的存储原理
上一节我们介绍了存储器的基本概念,本节中我们来看看ROM的具体实现方式。ROM使用点阵图表示法,其中圆点表示存储的值为1。
例如,在地址10处,存储的值为1、0、0。在地址11处,存储的值为0、1、0。
存储单元中,值为1的单元基本上是空的,而值为0的单元则有一个晶体管连接到地线。因此,当字线被激活(变为高电平)时,它会将对应的位线(例如数据位1)下拉至0。
如果我们将它画在这里,当字线变为高电平时,它会将该位线拉低至0。而在存储1的地方,由于没有连接,当字线变为高电平时,该位线将保持为1,而不会被拉低至0。因此,这里存储的是010。
在地址1、0处,我们存储了1、0、0,以此类推。和所有存储器一样,ROM也有深度(本例中为2^2=4个字)和宽度(字长为3位)。
闪存的发明
藤尾增冈在东芝公司从事存储器和高速电路研发。他在未经授权的项目中发明了闪存,这个项目是他在夜晚和周末完成的。擦除存储器的过程让他联想到了闪光灯相机,因此他将其命名为“闪存”。
然而,东芝在商业化这个想法上行动迟缓,英特尔在1988年率先将其推向市场。如今,闪存的应用已经极其广泛,我们每个人都至少拥有数个,甚至数十个日常使用的闪存设备。
使用存储器阵列实现逻辑
我们可以用存储器阵列来执行逻辑运算。事实上,可编程逻辑门阵列(如FPGA)的工作原理就是使用存储器来实现逻辑。
例如,我们不再横向查看存储的值(如0、1、0),而是可以纵向查看。对于地址位的任何组合,查看存储的数据。
如果我们称地址位为A1和A0,那么数据位D2执行的是异或(XOR)运算。因为对于输入11和00,输出是0;对于输入10和01,输出是1。因此,D2 = A1 XOR A0。
类似地,我们可以查看数据位D0,它执行的是与(AND)运算:D0 = A1 AND A0。我们还可以得到D1,它等于A1 AND (NOT A0),根据德摩根定律,这等价于(NOT A1) OR A0。
因此,我们可以将存储器视为在执行逻辑运算。这非常有用,因为根据我们存储的位模式,可以实现不同的逻辑功能。
实例:用ROM实现逻辑函数
现在,让我们使用一个2位地址、3位数据宽度的ROM来实现左侧列出的逻辑函数。我们将X、Y、Z连接到输出位线,将A和B连接到地址线。
我们希望实现:
- X = A AND B
- Y = A OR B
- Z = A AND (NOT B)
为了实现这些功能,我们需要在ROM中存储对应的真值表。我们仍然可以将其视为在不同地址存储不同的值:
- 地址0存储值 0, 0, 0
- 地址1存储值 0, 1, 0
- 地址2存储值 0, 1, 1
- 地址3存储值 1, 1, 0
但现在,我们正在使用这个存储器来执行我们不同输出(X, Y, Z)的逻辑运算。
我们可以分析这个存储器阵列,看看它实现了什么功能:
- D2 执行的是 A1 XOR A0。
- D1 执行的是 A1 OR (NOT A0)。
- D0 执行的是 (NOT A1) AND (NOT A0)(因为唯一的1出现在A1和A0都为低电平时)。
实际上,我们可以使用任何类型的存储器阵列(ROM、DRAM、SRAM或RAM)来实现以下逻辑函数,只需存储相应的位模式即可。
以下是实现特定函数所需的存储内容(注意,这里的书写顺序可能与常规真值表上下颠倒):
- 实现 X = A AND B, 存储模式为:
1, 0, 0, 0 - 实现 Y = A OR B, 存储模式为:
1, 1, 1, 0 - 实现 Z = A AND (NOT B), 存储模式为:
0, 1, 0, 0
查找表(LUT)的概念
这里的关键点在于,我们可以用存储器阵列来执行逻辑。这些用于实现逻辑的存储器阵列有一个特殊的名称,叫做查找表。我们可以为每个输入组合“查找”对应的输出。
它本质上就是一个存储在存储器中的真值表。在本例中,我们执行的是与操作。我们将输入变量A和B连接到地址输入端,然后存储器就充当了该真值表的查找表。
假设我们最初实现的是A和B的与操作,但后来发现我本意是想实现或操作。那么,我只需要改变存储在存储器中的值,现在它就成了一个或门,而不是与门。
这种可重构性正是我们使用查找表(或用于逻辑的存储器阵列)时所依赖的重要特性。
总结

本节课中我们一起学习了只读存储器(ROM)如何通过内部的晶体管连接来存储固定数据,以及闪存发明的有趣历史。更重要的是,我们深入探讨了存储器阵列的一个强大应用:将其作为查找表来实现组合逻辑功能。通过将输入变量连接到地址线,并将期望的真值表输出存储到数据位中,同一个物理存储器结构可以灵活地配置成与门、或门、异或门等多种逻辑门。这种基于查找表的可重构逻辑是实现现代可编程逻辑器件(如FPGA)的核心基础。
069:SystemVerilog中的存储器阵列 🧠

在本节中,我们将学习如何使用SystemVerilog来描述和实现存储器阵列,包括RAM和ROM。我们将探讨单端口和多端口存储器的结构,并了解寄存器文件的基本实现原理。
单端口RAM示例
上一节我们介绍了存储器阵列的基本概念,本节中我们来看看如何在SystemVerilog中具体描述一个RAM。
这是一个256x3位的RAM。字大小为3位,地址大小为8位。深度为2的8次方,即256个字。
以下是该RAM的输入和操作描述:
- 输入信号:时钟
clk、写使能writeEnable、地址A、写数据WD。 - 这是一个单端口存储器,但该端口既可读也可写。
- 读操作是组合逻辑:读数据
RD等于存储在RAM阵列中地址A处的值。 - 写操作是时序逻辑:仅在时钟上升沿且写使能有效时,才会将写数据
WD写入到地址A指向的RAM单元。
其核心行为可以用以下代码描述:
// 读操作(组合逻辑)
assign RD = ram[A];
// 写操作(时序逻辑)
always_ff @(posedge clk) begin
if (writeEnable) begin
ram[A] <= WD;
end
end
单端口ROM示例
接下来,我们看一个只读存储器(ROM)的例子。
这是一个128x32位的ROM。字大小为32位,地址大小为7位,深度为128(2的7次方)。
以下是该ROM的关键特性:
- 它只有输入和输出:地址输入
A和读数据输出RD。 - ROM的内容通过文件初始化:在这个例子中,ROM从名为
memfile.dat的文件中加载初始数据。 - 读操作是组合逻辑:给定一个地址,它组合逻辑地输出该地址对应的数据。
ROM的初始化方式如下:
logic [31:0] rom [0:127];
initial begin
$readmemh("memfile.dat", rom);
end
assign RD = rom[A];
数据文件memfile.dat可以包含多达128行,每行是一个32位的十六进制数。地址0对应第一行的值,地址1对应第二行,依此类推。
多端口存储器
存储器也可以拥有多个端口,一个端口即一个地址-数据对。下面我们来看一个多端口存储器的例子。
这个例子展示了一个3端口存储器,包含2个读端口和1个写端口。存储器阵列本身仍然是单一的,深度为2的N次方,宽度为M位。
以下是其端口定义:
- 两个读端口:地址
address1和address2,对应读数据readData1和readData2。 - 一个写端口:地址
address3、写数据WD3和写使能writeEnable3。
该存储器的行为是:可以同时从两个地址读取数据,并在时钟边沿条件满足时向第三个地址写入数据。我们将在第七章使用这种多端口存储器来实现寄存器文件,用于存储多个值并通过读写操作访问它们。
寄存器文件示例
最后,我们来看一个在SystemVerilog中表示的多端口存储器阵列的具体实例,即一个寄存器文件。
这是一个32x32位的寄存器文件,深度为32,字大小为32位,因此需要一个5位地址来访问。
以下是该寄存器文件的结构:
- 两个读端口:读地址
RA1和RA2,对应读数据RD1和RD2。 - 一个写端口:写地址
WA3、写数据WD3和写使能WE3。 - 一个单一的存储器阵列存储所有数据。
其操作逻辑如下:
- 写操作是时序逻辑:在时钟上升沿,如果写使能
WE3有效,则将WD3写入到地址WA3指向的寄存器单元。 - 读操作是组合逻辑:
RD1等于读地址RA1指向的寄存器值;RD2等于读地址RA2指向的寄存器值。
寄存器文件有一个特殊之处,如第六、七章将详述的,寄存器0的值恒为0。因此,如果读地址是0,则无论寄存器阵列中存储了什么,读操作都返回0;否则,返回对应地址存储的值。此规则对RD1和RD2均适用。
其核心部分代码描述如下:
logic [31:0] rf [0:31]; // 寄存器文件阵列
// 写操作
always_ff @(posedge clk) begin
if (WE3 && WA3 != 0) begin // 通常约定寄存器0不可写
rf[WA3] <= WD3;
end
end
// 读操作(组合逻辑,寄存器0恒为0)
assign RD1 = (RA1 == 0) ? 32'b0 : rf[RA1];
assign RD2 = (RA2 == 0) ? 32'b0 : rf[RA2];
总结

本节课中我们一起学习了SystemVerilog中描述存储器阵列的方法。我们首先分析了单端口RAM和ROM的结构与行为描述,理解了读操作(组合逻辑)与写操作(时序逻辑)的区别。接着,我们探讨了多端口存储器的概念,它允许同时进行多个读写访问。最后,我们以一个具体的32位寄存器文件为例,详细说明了其多端口(两读一写)的实现方式,并特别指出了寄存器0值恒为0这一重要设计约定。这些存储器模块是构成复杂数字系统,特别是处理器数据通路的基础组件。
070:逻辑阵列 🧩

在本节课中,我们将学习本章的最后一个主题:逻辑阵列。我们将介绍两种主要的逻辑阵列类型,并了解它们如何用于实现数字逻辑电路。
逻辑阵列概述
逻辑阵列是用于实现数字逻辑电路的集成电路。主要有两种类型:可编程逻辑阵列和现场可编程门阵列。
上一节我们介绍了各种逻辑实现方式,本节中我们来看看这两种可配置的逻辑阵列结构。
可编程逻辑阵列
可编程逻辑阵列是一种只能实现组合逻辑的器件。其内部结构固定,由与阵列后接或阵列组成。这种结构与我们之前学过的两级逻辑(先与后或)类似,可以轻松实现积之和形式的逻辑方程。
以下是PLA的基本结构:
- 与阵列:生成乘积项(即蕴含项)。
- 或阵列:将选定的乘积项进行或运算,得到最终输出。
由于PLA内部连接固定且只能实现组合逻辑,其灵活性有限。但它结构简单,因此成本通常低于FPGA。
现场可编程门阵列
现场可编程门阵列则更为灵活。它不仅可以实现组合逻辑,还可以实现时序逻辑。其“可编程”部分既包括逻辑功能,也包括内部连接。
FPGA主要由以下部分构成:
- 逻辑单元:执行逻辑运算的基本单元,包含组合逻辑和时序逻辑部件。
- 输入/输出单元:位于芯片外围,用于与外部引脚接口。
- 可编程互连:以可编程的方式连接各个逻辑单元和I/O单元。
- 常用模块:许多FPGA还集成了乘法器、RAM等常用模块,以方便设计。
以下是FPGA的一般布局示意图:

逻辑单元内部结构
让我们深入了解逻辑单元的内部。以Altera Cyclone IV FPGA的逻辑单元为例,其核心部件包括:
- 查找表:这是一个小型存储器(例如4输入LUT相当于一个16x1位的存储器),用于实现组合逻辑功能。它可以实现任意四输入变量的函数。
- 代码表示:
LUT[addr] = output_value
- 代码表示:
- 触发器:一个D触发器,用于实现时序逻辑和存储状态。
- 多路选择器:用于灵活地连接查找表和触发器,可以选择将查找表的组合输出或触发器的寄存器输出作为LE的输出,并支持内部反馈。

Cyclone IV LE最重要的部分是:一个四输入查找表、一个寄存器输出、一个组合输出,并且可以在两者之间进行选择。
逻辑单元配置示例
了解结构后,我们来看看如何配置逻辑单元来实现特定功能。
示例1:实现三输入函数
假设要实现函数 X = A'B'C + AB'C'。
- 我们将输入A、B、C连接到查找表的
data1,data2,data3。 - 第四个输入
data4必须连接到固定值(0或1),不能悬空。 - 然后,我们需要根据真值表对查找表进行编程。对于此函数,当地址为
001(A’B’C)和110(AB’C’)时,输出为1,其余为0。 - 最后,配置多路选择器,选择查找表的组合输出作为LE的输出。
示例2:实现多输入函数
对于六输入异或函数 Y = A1 ⊕ A2 ⊕ A3 ⊕ A4 ⊕ A5 ⊕ A6,单个4输入LUT无法实现。我们需要使用多个LE进行级联:
- 第一个LE:用其LUT计算
W1 = A1 ⊕ A2 ⊕ A3 ⊕ A4,输出W1。 - 第二个LE:将其LUT的输入连接到W1、A5、A6,并将第四个输入接地。该LUT实现
Y = W1 ⊕ A5 ⊕ A6。 - 因此,这个六输入异或函数需要2个逻辑单元。
电路所需逻辑单元数量估算
我们可以估算实现一个电路所需LE的数量。
示例3:32位2选1多路选择器
一个1位的2选1MUX有3个输入(S, D0, D1)。一个4输入LUT足以实现它(剩余1输入接地)。因此,1位需要1个LE。32位则需要 32个LE。
示例4:有限状态机
假设一个FSM有:2位状态、2个输入、3个输出。
- 状态存储:每个LE只有一个触发器,存储2位状态需要 2个LE。
- 次态逻辑:计算每个次态位需要当前状态(2位)和输入(2位),共4个输入。这可以由一个4输入LUT完成。因此,计算2个次态位需要 2个LE(但注意,这2个LE可以与存储状态的LE是同一个,因为每个LE既有LUT又有触发器)。
- 输出逻辑:输出仅由当前状态(2位)决定。每个输出位可由一个LUT实现(剩余2输入接地)。3个输出需要 3个LE。
- 总计:最简情况下,至少需要 5个LE(2个用于存储状态并计算次态,另外3个用于计算输出)。
FPGA设计流程
最后,我们简要了解使用FPGA的设计流程:
- 设计输入:使用CAD工具(如Quartus),通过原理图或硬件描述语言输入设计。
- 仿真:对设计进行仿真,验证逻辑功能。
- 综合与映射:工具将HDL代码综合成门级网表,并映射到FPGA的具体资源(LE、互连等)。
- 下载配置:将生成的配置文件下载到FPGA芯片中。
- 硬件测试:在实际硬件上测试设计功能。
总结

本节课中我们一起学习了两种主要的逻辑阵列:
- PLA:结构简单、成本低,但只能实现组合逻辑,内部连接固定。
- FPGA:功能强大且灵活,通过可编程的逻辑单元和互连,能够实现复杂的组合与时序逻辑电路。我们深入了解了其核心部件逻辑单元的结构,并学习了如何估算实现给定电路所需的资源数量。最后,我们概述了标准的FPGA设计流程。
071:引言 🖥️

在本章中,我们将学习计算机架构。架构是程序员视角下的计算机,它定义了程序员需要了解的指令集和内存。如果两台计算机具有相同的架构,即使它们的实现完全不同,也能运行相同的程序。我们还将稍微向上看一层,了解运行在架构之上的软件和操作系统。

什么是计算机架构?🔍
上一节我们介绍了本章的主题。本节中,我们来看看架构的具体定义。
架构是程序员对计算机的视图。它是一组指令和内存,程序员需要了解这些才能定义计算机。如果你用相同的架构构建两台不同的计算机,它们可以有完全不同的实现,但仍然可以运行相同的程序。
我们也将关注更高一点的层次,即运行在架构上的软件和操作系统。
本章学习路径 📚
上一节我们定义了架构。本节中,我们将概述本章的学习路径。
我将首先关注汇编语言编程,这是机器的原生语言,以及如何将像C语言这样的程序翻译成汇编语言。
然后我们将进入机器语言,即实际在硬件上运行的1和0。
我们将讨论寻址模式,以及编译、汇编和加载程序的过程及其各种细节。
从底层到高层:抽象层次的跃升 ⬆️
之前,我们一直在较低的抽象层次上工作。我们从器件开始,逐步上升到数字电路和逻辑,现在我们跃升到另一端——架构,即程序员视角下的计算机。
然后在第7章,我们将向下回到微架构,在中间汇合,看看如何构建实际实现架构的硬件。
指令与语言:汇编与机器码 💬
上一节我们提到了抽象层次的跃升。本节中,我们来具体看看计算机的语言。
指令和汇编语言是计算机语言中的命令。它们有两种格式。汇编语言是这些指令的人类可读格式。而机器语言是一串1和0,这是计算机可读的。
RISC-V 架构简介 🎯
世界上有许多不同的架构,本书专注于 RISC-V 架构。
RISC-V架构由伯克利的研究人员于2010年开始开发,它是第一个被广泛接受的开源计算机架构,因此任何人都可以自由使用和增强它。目前它在工业界获得了很大的关注,是一个热门事物。所以在本课程中我们专注于它。
但一旦你学会了一种架构,学习其他所有架构就变得非常容易。
以下是RISC-V发展中的关键人物:
- Krste Asanović 来自伯克利,是开发RISC-V的先驱,目前是RISC-V基金会董事会主席,也是SiFive公司的联合创始人,该公司正在将RISC-V商业化。
- 他的同事 David Patterson 是伯克利长期的计算机架构师,他在20世纪80年代共同提出了精简指令集计算机(RISC)的概念,也是RISC-V的创始成员之一。
- David Patterson与斯坦福大学的 John Hennessy 共同获得了图灵奖,以表彰他们在计算机架构设计和评估的定量方法方面的开创性贡献。John Hennessy也是经典教科书《计算机体系结构》的合著者。
RISC设计原则 🧱
在Hennessy和Patterson之前,计算机架构通常根据其优雅程度来评估。例如,一个可能直接运行Pascal或Lisp程序的架构。
Hennessy和Patterson在1990年左右大胆地提出,计算机架构应该根据其运行程序的速度来评估。这导致了精简指令集计算机(RISC)的概念,即不追求最大或最灵活的指令集,而是简化为一个能够运行任何程序的最小指令集。由于简化,它们可以用非常快速、高效的硬件构建,并且可以以更高的频率运行,提供更高的性能。
因此,20世纪80年代的RISC计算机彻底改变了计算机架构领域。Hennessy和Patterson的书已成为经典,并经历了多个版本。他们还合著了教科书《计算机组成与设计》,以一种与本书非常相似的方式呈现微架构,并启发了本书。
Hennessy和Patterson阐述了四个主要设计原则:
以下是四个核心设计原则:
- 简单源于规整:保持简单,并尽可能减少特殊情况。
- 加速常见情况:观察你的架构大部分时间在做什么,并让这些情况运行得非常快。
- 越小越快:如果你能用更少的指令、更少的寄存器构建一个架构,那么它运行这些指令的速度就会更快。
- 好的设计需要好的折衷:与其刻板地坚持前三个原则,偶尔为了效率,你确实需要做出妥协。
总结 📝

本节课中,我们一起学习了计算机架构的基本概念,它是程序员与计算机硬件之间的接口。我们介绍了从底层硬件到高层架构的抽象层次,明确了汇编语言与机器语言的区别。本章重点聚焦于RISC-V这一开源架构,并了解了其背后的发展历程与核心的RISC设计原则,即追求简单、规整和高效。在接下来的章节中,我们将深入汇编语言编程,开始探索如何用计算机的“母语”进行交流。
072:指令集详解 🧠
在本节中,我们将学习汇编语言编程中使用的指令。我们将从简单的算术运算开始,了解RISC-V指令的格式和设计原则,并探讨如何将复杂操作分解为一系列简单指令。

汇编语言中的基本指令
上一节我们介绍了汇编语言的基础概念,本节中我们来看看具体的指令是如何工作的。
假设我们有一个简单的程序:a = b + c。我们想用RISC-V汇编语言来编写它。
我们会写成:add a, b, c。你也可以这样理解它:add a 得到 b + c 的结果。
add被称为助记符,它指示了要执行哪条指令。- 这条指令接受两个源操作数和一个目标操作数。
- 目标操作数是
a,这是存放结果的地方。 - 两个源操作数是
b和c。
减法指令与此类似,只是助记符不同。如果我们想执行 a = b - c,我们可以使用 sub 指令:sub a, b, c。同样,助记符现在是 sub,操作数保持不变:目标操作数是 a,两个源操作数是 b 和 c。
设计原则:简单性促进规律性
这里引出了一个重要的设计原则:简单性促进规律性。
请注意,add 和 sub 指令的格式是一致的。它们都有相同数量的操作数:两个源操作数和一个目标操作数。它们只在助记符上有所不同。这种规律性使得在硬件中编码和处理指令变得更加容易。
处理复杂操作:分解为简单指令
假设我们想编写一个稍复杂的程序:a = b + c - d。
你可能会设想一种架构,它拥有一条指令能一次性完成所有这些操作。但在RISC-V中,我们将其分解为两个步骤。
- 首先,我们执行
add t, b, c。这里我们引入了一个临时寄存器t来存放b + c的中间结果。 - 然后,我们执行
sub a, t, d,这将得到最终结果b + c - d。

因此,我们使用了一个临时寄存器 t 和两条连续的指令来运行这个程序。
设计原则:加速常见情况
这引出了另一个设计原则:加速常见情况。
RISC-V只包含简单、常用的指令。因此,用于解码和执行这些指令的硬件可以做得简单、快速且小巧(我们将在第7章详细探讨这一点)。如果你想执行那些不常见的、更复杂的操作,我们就把它们分解成一系列更简单的指令。
所以,RISC-V是精简指令集计算机的一个绝佳例子,它拥有数量较少、设计简单的指令集。这与复杂指令集计算机(如Intel的x86架构)形成鲜明对比,后者拥有大量不同的指令,甚至包括像“将整个字符串从一个内存位置复制到另一个位置”这样的单一复杂指令。
本节总结
本节课中我们一起学习了RISC-V汇编语言的基本指令格式。我们了解了 add 和 sub 指令的用法,认识了“简单性促进规律性”和“加速常见情况”这两个关键的设计原则,并掌握了如何通过将复杂操作分解为多条简单指令来解决问题。理解这些基础是后续学习更复杂指令和计算机架构工作原理的关键。
073:操作数 💾

在本节中,我们将学习计算机指令所使用的操作数,并重点聚焦于寄存器操作数。
概述
指令执行计算时需要数据,这些数据被称为操作数。操作数必须物理存储在计算机的某个位置。本节课我们将探讨操作数的三种存储位置:寄存器、内存和常量,并深入理解RISC-V架构中32个寄存器的设计、命名约定及其在编程中的具体应用。
操作数的存储位置
回想我们之前的例子 A = B + C。其中的 A、B 和 C 都是操作数。该指令有两个源操作数和一个目的操作数。这些操作数必须物理存储在计算机中。
操作数主要有三种存储位置选择:
- 寄存器:通常由触发器或寄存器文件构成的小型高速存储单元,访问速度极快。
- 内存:通常由SRAM或DRAM构成,容量更大,但访问时间也更长。
- 常量:也称为立即数,它们直接编码在指令本身中,是硬连线的。
聚焦寄存器
让我们重点关注寄存器。RISC-V架构拥有 32个寄存器,每个寄存器的宽度为 32位。访问寄存器的速度远快于访问内存。RISC-V被称为32位架构,正是因为它主要操作32位宽的数据。
设计原则3:更小意味着更快
RISC-V仅有32个寄存器,数量较少。计算机架构师非常谨慎地选择寄存器文件的大小,以确保系统的时钟周期不受其限制。我们选择一个足够小的寄存器文件,使其不会成为计算机性能的瓶颈,从而能够构建快速的计算机。
RISC-V寄存器命名与约定
RISC-V的32个寄存器被命名为 x0 到 x31。虽然可以用这些“x”名称来调用它们,但程序员更常用一些具有特定约定的别名,这使代码更易读。
- 寄存器零:名为
zero或x0,它被硬连线为常数值 0。在程序中,值0出现的频率非常高,因此拥有一个始终为0的寄存器非常有用。
其余31个寄存器用于处理数据。原则上,你可以将任何信息存储在任何寄存器中,但程序员们约定俗成,将特定寄存器用于特定目的,这便于不同程序员编写的函数能够轻松交互。
以下是关键的寄存器及其约定用途:
x1:称为返回地址寄存器。用于存储函数调用后应返回的地址。x2:称为栈指针寄存器。指向内存中栈的顶部,用于在函数调用时保存变量。x3和x4:分别是全局指针和线程指针寄存器,本章暂不深入讨论。
其余寄存器主要分为三组:保存寄存器、临时寄存器和参数寄存器。
以下是这些寄存器的分组详情:
- 保存寄存器:由程序员约定用于存储需要长期保存的变量。在函数调用返回后,这些寄存器中的值需要保持不变。包括
s0、s1以及s2到s11,对应x8、x9和x18到x27,共12个。 - 临时寄存器:用于保存临时或中间计算结果。例如,在计算
A = B + C - D时,需要一个临时寄存器来存放B+C的中间结果。包括t0、t1、t2以及t3到t6。 - 参数寄存器:用于向函数传递参数值,以及从函数调用返回值。包括
a0到a7。
作为程序员,你可以使用 x 系列名称或别名(如 ra、s0)。使用别名能使代码对阅读者更清晰。
使用寄存器的指令示例
现在,让我们使用真实的寄存器来重写之前的指令。
示例1:寄存器加法
我们之前的指令是 A = B + C。现在假设我们将变量 A 保存在寄存器 s0 中,B 在 s1 中,C 在 s2 中。那么程序将重写为:
add s0, s1, s2 # s0 = s1 + s2
在汇编语言中,井号 # 表示单行注释。在代码中添加注释来说明寄存器用于存储哪个变量,是一个好习惯,能使代码更易理解。
示例2:使用立即数的加法
考虑指令 A = B + 6。这里引入了一个新指令:addi。i 代表立即数。该指令接受一个目的寄存器、一个源寄存器和一个常量(立即数)。
同样,假设 A 在 s0,B 在 s1,那么指令为:
addi s0, s1, 6 # s0 = s1 + 6
总结

本节课我们一起学习了计算机指令的操作数。我们了解到操作数可以存储在寄存器、内存或作为立即数编码在指令中。我们重点探讨了RISC-V架构的32个寄存器,理解了“更小更快”的设计原则,并掌握了寄存器的命名约定(如 zero、s0、t0、a0)及其在编程中的典型用途。最后,我们通过 add 和 addi 指令的示例,实践了如何使用寄存器来执行加法运算。理解这些是编写高效汇编程序的基础。
074:内存指令 💾

在本节中,我们将学习处理器如何与内存进行交互。我们将探讨为什么需要内存指令,以及RISC-V架构中用于读写内存的具体指令。
概述
大多数程序的数据量都超过了32个寄存器所能容纳的范围,因此需要将额外的数据存储在内存中。内存容量远大于寄存器,但访问速度较慢。我们将最常用的变量保存在寄存器中,而将不常用的数据存储在更大但更慢的内存中。首先,我们将讨论字寻址内存,然后介绍更常见的字节寻址内存,并解释RISC-V处理器如何处理这两种模式。
字寻址内存
在字寻址内存模型中,每个内存地址存储一个32位的字。例如,地址0可能存储字ABCDEF78,地址1可能存储字F2F1AC07。
常规的算术指令(如add和sub)不能直接操作内存中的数据。因此,我们需要一种新的指令类型,称为加载指令,用于将数据从内存读入寄存器。
加载字指令
加载字指令lw(load word)从一个内存地址读取一个值,并将其放入指定的寄存器中。其语法格式为:
lw rd, offset(rs1)
其中,rd是目标寄存器,offset是一个偏移量,rs1是基址寄存器。计算出的内存地址为 rs1 + offset。
例如,指令 lw t1, 5(s0) 表示:将内存地址 s0 + 5 处的字读取出来,存入寄存器 t1。
假设我们想将内存地址1处的字读入寄存器s3。我们可以使用指令:
lw s3, 1(zero)
这里,基址寄存器zero的值恒为0,加上偏移量1,得到内存地址1。执行后,内存地址1处的值F2F1AC07将被存入寄存器s3。
存储字指令
与加载指令相对应的是存储字指令sw(store word),它将一个寄存器的值写入内存。其语法格式为:
sw rs2, offset(rs1)
其中,rs2是要存储的源寄存器,offset是偏移量,rs1是基址寄存器。计算出的内存地址同样为 rs1 + offset。
例如,指令 sw t4, 3(zero) 表示:将寄存器t4的值存储到内存地址 0 + 3(即地址3)处。假设t4的值为FEEDCAB,执行后,内存地址3处的内容将变为FEEDCAB。
字节寻址内存
实际上,包括RISC-V在内的大多数现代微处理器都使用字节寻址内存。这意味着每个字节(8位)都有自己唯一的地址。
由于我们的处理器字长为32位(4个字节),因此每个字地址都是4的倍数。具体对应关系如下:
- 字0:字节地址 0, 1, 2, 3
- 字1:字节地址 4, 5, 6, 7
- 字2:字节地址 8, 9, A, B
- 字3:字节地址 C, D, E, F
- 字4:字节地址 10, 11, 12, 13
因此,在字节寻址系统中,要访问第N个字,其内存地址需要乘以4。例如:
- 访问第2个字(字索引为2),其内存地址为
2 * 4 = 8。 - 访问第10个字(字索引为10),其内存地址为
10 * 4 = 40(十六进制为0x28)。
重要提示:RISC-V是字节寻址的,而非字寻址。
字节寻址下的指令示例
在字节寻址模式下,要读取第二个字(位于字节地址8),正确的RISC-V指令是:
lw s3, 8(zero)
这条指令会将内存地址8处的字(即字节地址8、9、A、B四个字节组成的数据)读入寄存器s3。

同样,要将一个值写入第四个字的地址(字节地址0x10),可以使用指令:
sw t7, 0x10(zero)
这条指令会将寄存器t7的值存储到从字节地址0x10开始的连续四个字节中。
总结
本节课中,我们一起学习了处理器与内存交互的核心机制。我们了解到,由于寄存器数量有限,程序需要将大量数据存储在内存中。我们首先介绍了字寻址内存的概念,然后重点讲解了RISC-V架构实际使用的字节寻址内存模型。我们学习了两种关键的内存指令:lw(加载字)用于从内存读取数据到寄存器,sw(存储字)用于将寄存器数据写回内存。理解字节寻址是正确计算内存地址的关键,因为字地址需要乘以4才能得到对应的字节地址。
075:立即数(常量)🔢

在本节中,我们将学习RISC-V指令集中如何处理常量,也称为立即数。我们将了解如何将12位立即数嵌入到指令中,以及如何通过组合指令来加载更长的32位常量。
立即数简介
上一节我们讨论了寄存器和内存中的操作数。本节中,我们来看看常量操作数,它们被称为“立即数”,因为其值作为指令的一部分立即可用。
例如,像 addi 这样的指令可以接受一个12位的立即数作为指令的一部分。
使用12位立即数
假设我们有这样一个程序:A = -372 和 B = A + 6。我们想将 A 存储在 s0 中,B 存储在 s1 中。
以下是实现此功能的指令:
addi s0, zero, -372 # s0 = 0 + (-372), 将 -372 存入 A
addi s1, s0, 6 # s1 = s0 + 6, 计算 B = A + 6
由于指令长度有限,我们只能存储有限长度的立即数。在RISC-V指令集中,我们最多只能在指令中直接存储12位的立即数。这些立即数以二进制补码形式存储,并符号扩展为32位。因此,我们可以存储从 -2048 到 +2047 范围内的正数和负数。
加载32位常量
假设我们需要一个更长的常量,比如一个32位的数。RISC-V为此提供了一条特殊指令,称为“高位立即数加载”(lui)。
lui 指令将一个20位的立即数放入目标寄存器的高20位,并将低12位清零。然后,我们可以使用一条 addi 指令来添加低位的值。
例如,我们想将32位常量 0xFEDC8765 加载到寄存器 s0 中。
以下是加载此常量的步骤:
lui s0, 0xFEDC8 # 将 0xFEDC8 加载到 s0 的高20位,低12位为0
addi s0, s0, 0x765 # 将 0x765 加到 s0 的低12位,完成 0xFEDC8765
lui 指令只需要一个寄存器和一个立即数参数,因此指令中有空间容纳20位的立即数。尽管我们追求“简单性倾向于规律性”,但这是一个为了能处理32位指令而做出的良好折衷。
处理符号扩展的注意事项
重要的是要记住,addi 指令会对12位立即数进行符号扩展。如果12位立即数的最高位(第11位)是1,它将被符号扩展,在所有高位填充1,这实际上会使其看起来像一个负数。
因此,如果我们想加载一个32位常量,并且其低12位的最高位是1,我们需要将 lui 指令中的高20位加1。
例如,要加载常量 0xFEDC8EAB:
- 低12位
0xEAB的最高位是1,其作为有符号数是-341。 - 因此,我们不能直接
lui 0xFEDC8然后addi 0xEAB,因为addi会将0xEAB符号扩展为0xFFFFFEAB。
正确的做法是:
lui s0, 0xFEDC9 # 高位部分加1:0xFEDC8 + 1 = 0xFEDC9
addi s0, s0, -341 # 添加有符号立即数 -341 (即 0xFFFFFEAB)
# 结果:0xFEDC9000 + 0xFFFFFEAB = 0xFEDC8EAB (忽略进位)
当你尝试创建这样的长常量时,必须注意这一位并妥善处理。
本节总结
本节课中,我们一起学习了RISC-V中立即数的处理。我们了解到:
- 基本的
addi等指令可以处理12位有符号立即数。 - 通过
lui和addi指令的组合,可以加载任意的32位常量。 - 在组合加载32位常量时,必须注意低12位立即数的符号扩展问题,并在必要时对高位部分进行调整。

理解立即数的编码和加载方式是编写高效汇编代码的基础。
076:逻辑与移位指令

在本节课中,我们将开始学习RISC-V汇编语言编程,首先介绍逻辑运算和移位指令。
概述
想象我们有一个用C、Java或Python等高级语言编写的程序,它处于更高的抽象层次。我们希望将其翻译成汇编语言。这个高级程序将包含循环、条件语句、数组和函数调用等结构。我们将从介绍支持这些结构的RISC-V指令集开始。本视频将讨论逻辑运算和移位运算,下一个视频讨论乘法和除法,再下一个视频讨论分支和跳转。之后,我们将开始将它们组合起来编写高级代码。
在讨论编程时,值得提一下世界上最早的程序员之一——阿达·洛夫莱斯。她是拜伦勋爵的女儿,出身贵族,也是查尔斯·巴贝奇的朋友。当巴贝奇建造分析机时,她实际上编写了第一个有趣的计算机程序,用于在该机器上计算伯努利数。
逻辑指令
RISC-V中有三种逻辑指令:AND(与)、OR(或)和XOR(异或)。它们执行按位运算。每条指令接收两个32位的源操作数,并产生一个32位的结果。结果中的每一位都是对两个源操作数对应位执行操作的结果。
- AND 指令可用于屏蔽位。例如,如果第一个源操作数是某个值,第二个源操作数是
0x000000FF(即低8位为1,高24位为0),那么任何数与1进行AND运算结果为其本身,与0进行AND运算结果为0。因此,将一个数与这个掩码进行AND运算,会保留其低8位的值,并将其他所有位强制设为0。这被称为屏蔽。 - OR 指令可用于组合位。例如,如果我们有一个寄存器的高16位存有数据,另一个寄存器的低16位存有数据,并且我们想将它们组合起来,就可以对它们进行OR运算。OR指令也可用于将某些位设置为1。
- XOR 指令适用于反转位。例如,一个数与
0xFFFFFFFF(即-1)进行XOR运算,会得到该数的按位取反。这为我们提供了NOT(非)操作。如果我们与一个仅在特定位置为1的源操作数进行XOR运算,则只会反转那些特定的位。
让我们看一个逻辑运算的例子。假设寄存器 s1 中有一堆随机数据,寄存器 s2 的高16位为1,低16位为0。
- 如果我们执行
s1 AND s2:任何位与0运算结果为0,因此低16位被清零为0;任何位与1运算结果为其本身,因此高16位与s1中的相同。 - 如果我们执行
s1 OR s2:任何位与0运算结果为其本身,因此低16位保持不变;任何位与1运算结果为1,因此高16位被强制设为1。 - 如果我们执行
s1 XOR s2:任何位与0运算结果为其本身,因此低16位与s1中的相同;任何位与1运算结果为该位的反转,因此高16位是s1高16位的反转。
立即数逻辑指令
这些逻辑指令也支持立即数版本。这些立即数是12位长,就像 addi 指令中一样,可以是正数或负数,并且会进行符号扩展。例如,数字 -1484 会被符号扩展为高20位全为1的特定模式。
以下是使用立即数的逻辑运算示例:
- ANDI:当第二个源操作数(立即数)的某位为1时,结果保留第一个源操作数的对应位;为0时,结果对应位为0。
- ORI:当第二个源操作数的某位为0时,结果保留第一个源操作数的对应位;为1时,结果对应位强制为1。
- XORI:当第二个源操作数的某位为0时,结果保留第一个源操作数的对应位;为1时,结果对应位是第一个源操作数对应位的反转。
移位指令
移位指令接收两个源操作数,将第一个源操作数按第二个源操作数指定的量进行移位。
有三种类型的移位:
- SLL:逻辑左移
- SRL:逻辑右移
- SRA:算术右移(有时写作
>>>)
逻辑右移和算术右移的区别在于:逻辑右移用0填充高位,而算术右移用原始数字最高位(符号位)的副本填充高位。
- 逻辑左移等价于乘以2的幂。
- 逻辑右移等价于除以2的幂(用于无符号数)。
- 算术右移也等价于除以2的幂,但用于有符号数以保留符号。
这些移位指令也支持立即数版本。实际上,需要指出的是,移位量只使用寄存器源操作数的低5位。因为当我们移位32位数时,只对0到31的移位量有意义,其他移位量会导致结果无意义。因此,只考虑第二个源操作数的低5位。
立即数移位指令也接受一个5位的立即数来指定移位量。所以有:
- SLLI:逻辑左移立即数
- SRLI:逻辑右移立即数
- SRAI:算术右移立即数
它们可以移位一个0到31范围内的常量。
总结

本节课我们一起学习了RISC-V汇编语言编程的基础——逻辑指令和移位指令。我们了解了AND、OR、XOR指令的按位操作及其在屏蔽、组合和反转位中的应用,并学习了它们的立即数版本。接着,我们探讨了逻辑左移、逻辑右移和算术右移指令,理解了它们与乘除法的关系以及有符号与无符号移位的区别,同时也介绍了这些指令的立即数形式。这些指令是构建更复杂控制结构和算法的基础。
077:乘法与除法指令 🔢
在本节中,我们将学习RISC-V架构中用于执行乘法和除法运算的指令。我们将了解如何计算32位整数的乘积与商,并处理相关的细节。

概述
在计算机中,对两个32位数进行乘法运算会产生一个64位的结果。为了处理这种情况,RISC-V指令集提供了专门的乘法指令。同样,除法运算会产生一个商和一个余数,RISC-V也提供了相应的指令来处理这些运算。
乘法指令
上一节我们介绍了RISC-V的基本算术指令,本节中我们来看看用于乘法的指令。
RISC-V使用两条指令来处理32位整数的乘法:mul 和 mulh。
mul rd, rs1, rs2指令计算rs1和rs2的乘积,并将结果的低32位存入目标寄存器rd。mulh rd, rs1, rs2指令计算rs1和rs2的乘积,并将结果的高32位存入目标寄存器rd。
因此,要获得完整的64位乘积结果,需要组合使用这两条指令。
假设我们想计算 s1 和 s2 的完整64位乘积,并将结果存入寄存器对 s4(高32位)和 s3(低32位)。我们可以执行以下指令序列:
mulh s4, s1, s2 # s4 = (s1 * s2)的高32位
mul s3, s1, s2 # s3 = (s1 * s2)的低32位
示例:
假设寄存器 s1 中的值是十六进制数 0x4000_0000(即 2³⁰),寄存器 s2 中的值是 0x8000_0000(在二进制补码表示中,这代表 -2³¹)。它们的乘积应为 2³⁰ × (-2³¹) = -2⁶¹。
-2⁶¹ 用十六进制表示为 0xE000_0000_0000_0000。因此,执行上述指令后:
s4将得到值0xE000_0000(高32位)。s3将得到值0x0000_0000(低32位)。
除法指令
了解了乘法之后,我们接下来看看除法运算。除法指令会同时产生商和余数。
RISC-V同样使用两条指令来处理32位整数的除法:
div rd, rs1, rs2指令计算rs1除以rs2的商,并将结果存入目标寄存器rd。rem rd, rs1, rs2指令计算rs1除以rs2的余数(即rs1 mod rs2),并将结果存入目标寄存器rd。
示例:
假设寄存器 s1 中的值是 0x11(十进制17),寄存器 s2 中的值是 3。
- 执行
div s3, s1, s2后,s3将得到商5(因为 17 ÷ 3 = 5)。 - 执行
rem s4, s1, s2后,s4将得到余数2(因为 17 ÷ 3 余 2)。
处理负数
除法指令也适用于负数,但在处理负数时,需要特别注意商和余数的定义。RISC-V的除法指令遵循向零取整的规则,这意味着商会被截断为最接近零的整数。余数的符号与被除数 (rs1) 的符号相同。
例如,-7 ÷ 3 的商是 -2(向零取整),余数是 -1(因为 -7 = 3 × (-2) + (-1))。
总结
本节课中我们一起学习了RISC-V架构中的乘法和除法指令。我们了解到:
- 使用
mul和mulh指令组合可以获得两个32位整数相乘的完整64位结果。 - 使用
div和rem指令可以分别获得两个32位整数相除的商和余数。 - 在处理涉及负数的除法时,指令遵循特定的规则来确定商和余数的值。

掌握这些指令对于在RISC-V汇编语言中执行基本的数学运算是至关重要的。
078:分支与跳转指令 🧭

在本节中,我们将学习控制流指令,包括条件分支和无条件跳转。这些指令允许程序改变指令的执行顺序,是实现循环、条件判断和函数调用的基础。
到目前为止,我们所学的指令都是按照程序编写的顺序依次执行的。但分支和跳转指令使我们能够控制这个顺序,改变指令的执行路径。
分支指令的类型
分支指令主要分为两种类型:条件分支和无条件跳转。在RISC-V架构中,条件分支指令通常称为“分支”,而无条件分支则称为“跳转”。
条件分支指令有四种主要形式:
- BEQ:在两个源操作数相等时跳转。
- BNE:在两个源操作数不相等时跳转。
- BLT:在第一个源操作数小于第二个时跳转。
- BGT:在第一个源操作数大于第二个时跳转。
所有这些条件分支指令都接受两个源操作数,对它们进行比较,然后根据比较结果决定是否跳转到目标地址。无条件跳转指令 J 则总是会执行跳转,不受任何条件影响。此外,还有一些用于函数调用的跳转指令变体,我们将在后续课程中讨论。
条件分支示例
现在,让我们通过一些具体的例子来理解条件分支是如何工作的。
假设我们想执行下面这段程序:
addi s0, zero, 4 # s0 = 0 + 4, 所以 s0 的值为 4
addi s1, zero, 1 # s1 = 0 + 1, 所以 s1 的值为 1
slli s1, s1, 2 # s1 = s1 逻辑左移 2 位, 1 << 2 = 4, 所以现在 s1 的值也为 4
此时,寄存器 s0 和 s1 的值都等于 4。
如果我们执行一条分支指令:
beq s0, s1, target # 比较 s0 和 s1, 如果相等则跳转到标签 ‘target‘ 处
由于 s0 和 s1 的值都是 4,它们相等,因此条件满足,分支被采纳。程序将跳转到 target 标签处继续执行:
target:
add s1, s1, s0 # s1 = 4 + 4 = 8
而 beq 指令之后、target 标签之前的两条指令(如果存在)则不会被执行。
请注意,target 被称为标签。在汇编语言中,标签后面通常跟着一个冒号(:)来标识。标签不能是任何保留字,例如,它不能是指令的名称。我们在分支指令中引用这个标签,以指明跳转的目标地址。
条件分支的另一种情况
下面是同一个程序,但将 BEQ 替换成了 BNE:
addi s0, zero, 4
addi s1, zero, 1
slli s1, s1, 2 # 此时 s0 = 4, s1 = 4
bne s0, s1, target # 比较 s0 和 s1, 如果不相等则跳转
addi s1, s1, 1 # 由于 s0 等于 s1, 分支未被采纳,继续执行此条指令:s1 = 4 + 1 = 5
sub s1, s1, s0 # s1 = 5 - 4 = 1
target:
addi s1, s1, 4 # s1 = 1 + 4 = 5
此时,s0 和 s1 的值再次都是 4。对于“不相等则跳转”指令,由于它们相等,因此条件不满足,分支未被采纳。程序会顺序执行 bne 之后的下一条指令,最终得到与之前不同的结果。
无条件跳转示例
最后,我们来看一个无条件跳转的例子。无条件跳转指令总是会改变程序执行流。
考虑以下指令序列:
j target # 无条件跳转到标签 ‘target‘
addi s0, s0, 1 # 这条指令将被跳过
sub s1, s1, s0 # 这条指令也将被跳过
target:
add s2, s0, s1 # 程序直接跳转到这里开始执行
当执行 j target 指令时,程序会直接跳转到 target 标签处,中间的两条指令(addi 和 sub)会被完全跳过,不会被执行。
总结

本节课我们一起学习了RISC-V架构中的控制流指令。我们了解到:
- 条件分支指令(如
BEQ,BNE)通过比较两个操作数的值来决定是否跳转,是实现程序判断逻辑的关键。 - 无条件跳转指令(
J)则总是执行跳转,用于实现固定的流程转移。 - 标签用于在汇编代码中标记跳转目标地址。
- 分支和跳转指令通过改变程序计数器(PC)的值,打破了指令的顺序执行,赋予了程序灵活的控制能力,这是构建复杂软件(如循环和条件语句)的基石。
079:条件语句与循环 🔄

在本节中,我们将学习如何将高级编程语言(如C语言)中的条件语句(if/else)和循环(while/for)翻译成RISC-V汇编语言。我们将通过具体的例子,一步步展示翻译过程,并解释其背后的逻辑。
条件语句的翻译
在高级语言中,条件语句用于根据特定条件执行不同的代码块。在汇编层面,这通常通过比较和分支指令来实现。
if 语句
考虑以下C语言if语句:
if (i == j) {
f = g + h;
}
f = f - i;
假设变量存储在以下寄存器中:
f在s0g在s1h在s2i在s3j在s4
翻译成RISC-V汇编的步骤如下。首先,我们需要比较i和j是否相等。如果条件为假(即i != j),我们希望跳过if语句的主体部分。
以下是翻译后的汇编代码:
bne s3, s4, L1 # 如果 i != j,则跳转到标签 L1
add s0, s1, s2 # if 主体:f = g + h
L1:
sub s0, s0, s3 # if 语句后的代码:f = f - i
在这个例子中,我们使用bne(分支如果不相等)指令来检查条件。如果条件不满足,程序跳转到标签L1,从而跳过if主体。无论条件如何,f = f - i这行代码都会执行。
if-else 语句
现在,让我们看一个包含else分支的例子:
if (i == j) {
f = g + h;
} else {
f = f - i;
}
翻译这个结构需要处理两个分支。以下是汇编代码:
bne s3, s4, Else # 如果 i != j,跳转到 Else 标签
add s0, s1, s2 # if 主体:f = g + h
j Done # 跳过 else 部分
Else:
sub s0, s0, s3 # else 主体:f = f - i
Done:
# 后续代码...
这里,如果i != j,程序跳转到Else标签执行else分支。在if分支执行后,使用j(无条件跳转)指令跳过else分支,直接到Done标签。
循环的翻译
循环允许我们重复执行一段代码,直到满足某个条件。我们将探讨while和for循环的翻译。
while 循环
假设我们想编写一个程序,计算满足 2^x = 128 的 x 值。我们使用while循环来实现:
int power = 1;
int x = 0;
while (power != 128) {
power = power * 2;
x = x + 1;
}
以下是翻译成RISC-V汇编的代码。我们首先初始化变量,然后在循环开始处检查条件。
addi s0, zero, 1 # power = 1
addi s1, zero, 0 # x = 0
addi t0, zero, 128 # 将常数128加载到临时寄存器 t0
While:
beq s0, t0, Done # 如果 power == 128,跳转到 Done
slli s0, s0, 1 # power = power * 2 (左移一位)
addi s1, s1, 1 # x = x + 1
j While # 跳回循环开始处检查条件
Done:
# 循环结束后的代码...
在while循环中,我们在循环开始处检查条件。如果条件不满足(power == 128),则跳出循环。否则,执行循环体,然后无条件跳回循环开始处重新检查条件。
for 循环
for循环通常包含初始化、条件检查和迭代操作。考虑一个求0到9之和的例子:
int sum = 0;
for (int i = 0; i != 10; i++) {
sum = sum + i;
}
翻译成RISC-V汇编如下:
addi s1, zero, 0 # sum = 0
addi s0, zero, 0 # i = 0 (初始化)
addi t0, zero, 10 # 将常数10加载到临时寄存器 t0
For:
beq s0, t0, Done # 如果 i == 10,跳转到 Done
add s1, s1, s0 # sum = sum + i (循环体)
addi s0, s0, 1 # i = i + 1 (迭代操作)
j For # 跳回循环开始处检查条件
Done:
# 循环结束后的代码...
for循环的翻译与while循环类似,但显式包含了初始化步骤。循环开始处检查条件,如果满足则执行循环体和迭代操作,然后跳回检查条件。
使用 slt 指令的循环
有时,我们可能需要使用“小于”比较。RISC-V提供了set less than(slt)指令,它比较两个值,并将结果(0或1)存入目标寄存器。
例如,计算2的幂直到和超过100:
int sum = 0;
for (int i = 1; i < 101; i = i * 2) {
sum = sum + i;
}
使用slt指令的翻译如下:
addi s1, zero, 0 # sum = 0
addi s0, zero, 1 # i = 1
addi t0, zero, 101 # 将常数101加载到临时寄存器 t0
For:
slt t2, s0, t0 # 设置 t2 = 1 如果 i < 101,否则 t2 = 0
beq t2, zero, Done # 如果 t2 == 0 (即 i >= 101),跳转到 Done
add s1, s1, s0 # sum = sum + i
slli s0, s0, 1 # i = i * 2 (左移一位)
j For # 跳回循环开始处检查条件
Done:
# 循环结束后的代码...
这里,slt指令将比较结果存入t2,然后beq指令根据t2的值决定是否跳出循环。
总结
在本节中,我们一起学习了如何将高级语言中的条件语句和循环结构翻译成RISC-V汇编语言。关键点包括:
- 条件语句:通过比较指令(如
beq,bne)和跳转指令来实现分支逻辑。 - 循环:通过组合初始化、条件检查和跳转指令来构建
while和for循环。 - 比较指令:除了直接的分支指令,
slt指令可用于进行“小于”比较并将结果存入寄存器,为复杂的条件判断提供了灵活性。

掌握这些翻译模式是理解高级语言如何底层执行,以及编写高效汇编代码的基础。
080:数组访问教程 📚

在本节课中,我们将学习如何在汇编语言中访问数组。我们将了解数组的基本概念、如何计算元素地址,以及如何通过循环遍历数组。课程内容涵盖字(word)数组和字节(byte)数组(如字符串)的访问方法。
数组基础概念
数组用于存储大量相似的数据。数组的索引(index)表示我们想要访问哪个元素,而大小(size)表示元素的总数。
假设我们有一个包含5个元素的数组,每个元素的大小为一个字(32位)。该数组的基地址(起始地址)是 0x123B478。

访问数组的第一步是加载数组的基地址。
访问数组元素
假设我们想访问这个数组,取出两个元素并将它们的值加倍。同时,假设寄存器 s0 将用于存放数组的基地址。
如果基地址尚未加载,我们可以通过以下两步完成:
- 使用
lui(加载高位立即数)指令将地址的高20位加载到s0。 - 使用
addi(加立即数)指令将地址的低12位加到s0上。
具体代码如下:
lui s0, 0x123B4 # 加载地址的高20位 (0x123B4)
addi s0, s0, 0x780 # 加上地址的低12位 (0x780)
现在,s0 中保存了数组的基地址 0x123B478。
访问和操作特定元素
如果我们想加载数组的第0个元素(array[0]),将其加倍,然后存回原处,可以这样做:
lw t0, 0(s0) # 加载 array[0] 到 t0
slli t0, t0, 1 # t0 = t0 << 1 (值加倍)
sw t0, 0(s0) # 将结果存回 array[0]
要访问第1个元素(array[1]),需要注意它在内存中位于基地址之后4个字节的位置。因此,偏移量是4。
lw t0, 4(s0) # 加载 array[1] 到 t0
slli t0, t0, 1 # t0 = t0 << 1 (值加倍)
sw t0, 4(s0) # 将结果存回 array[1]
使用循环遍历数组
上一节我们介绍了如何访问单个数组元素,本节中我们来看看如何使用 for 循环来遍历整个数组。
假设我们想将数组中的每个元素乘以8。寄存器 s0 已包含数组的基地址,我们将使用寄存器 s1 作为循环变量 i。
以下是实现该功能的步骤:
- 初始化:将循环索引
i设置为0。 - 循环条件:检查
i是否小于数组大小(例如1000)。 - 循环体:计算当前元素的地址,加载其值,乘以8,然后存回。
- 更新与跳转:将
i加1,然后跳回步骤2。
具体汇编代码如下。我们假设数组有1000个元素(size = 1000):
addi s1, zero, 0 # i = 0 (初始化)
loop:
li t2, 1000 # 加载循环上限 1000
bge s1, t2, done # 如果 i >= 1000,跳转到 done
# 计算 array[i] 的地址
slli t0, s1, 2 # t0 = i * 4 (将字索引转换为字节偏移量)
add t0, s0, t0 # t0 = 基地址 + 字节偏移量 (array[i]的地址)
# 加载、操作、存储
lw t1, 0(t0) # 加载 array[i] 的值到 t1
slli t1, t1, 3 # t1 = t1 << 3 (乘以8)
sw t1, 0(t0) # 将结果存回 array[i]
# 更新循环变量
addi s1, s1, 1 # i = i + 1
j loop # 跳回循环开始
done:
字符串:字符数组
另一种常见的数据结构是字符串,它本质上是字符数组。字符通常使用美国信息交换标准代码(ASCII)进行编码,每个键盘按键对应一个字节的值。
例如:
- 大写字母
A的ASCII码是0x41(十进制65)。 B是0x42,C是0x43,依此类推。- 小写字母的ASCII码值比对应的大写字母大
0x20。
在C语言中,字符串以值为 0 的字节(称为空终止符)结尾。请注意,这不是字符 ‘0‘(其ASCII码是 0x30),而是真正的字节值 0。
假设我们有一个字符串 "cat" 存储在内存中:
- 地址
1000:‘C‘(0x43) - 地址
1001:‘a‘(0x61) - 地址
1002:‘t‘(0x74) - 地址
1003:‘\0‘(0x00) (空终止符)
计算字符串长度
以下是一个计算字符串长度的程序逻辑(伪代码):
- 将长度
len初始化为0。 - 检查
str[len]处的字符。 - 如果该字符不是
0,则将len加1,并重复步骤2。 - 如果字符是
0,则循环结束,此时的len就是字符串长度。
以字符串 "cat" 为例:
- 初始
len=0,str[0]=‘C‘(非零) ->len=1 len=1,str[1]=‘a‘(非零) ->len=2len=2,str[2]=‘t‘(非零) ->len=3len=3,str[3]=‘\0‘(为零) -> 循环结束,得到长度3。

以下是实现该逻辑的汇编代码。假设 s0 已包含字符串的基地址,s1 用于存储长度:
addi s1, zero, 0 # len = 0 (初始化长度)
while:
add t0, s0, s1 # t0 = 基地址 + len (计算当前字符地址)
lb t1, 0(t0) # 加载一个字节: t1 = str[len]
beq t1, zero, done # 如果 t1 == 0,跳转到 done (遇到终止符)
addi s1, s1, 1 # 否则,len = len + 1
j while # 跳回循环开始
done:
# 循环结束后,s1 中即为字符串长度
请注意,我们使用 lb(加载字节)指令,因为字符是单字节的。访问字节数组时,偏移量不需要乘以4,因为内存是按字节寻址的。
总结 🎯
本节课中我们一起学习了在RISC-V汇编语言中访问数组的核心方法:
- 基础访问:通过基地址加偏移量来访问数组元素。对于字数组,偏移量需要乘以4(
索引 * 4)来转换为字节地址。 - 循环遍历:使用循环变量作为索引,可以高效地遍历和操作整个数组。
- 字符串处理:字符串是特殊的字节数组,以空字符(
0)结尾。使用lb指令按字节加载,偏移量直接相加,无需转换。
关键要点在于:计算元素地址时,必须根据元素大小(字或字节)对索引进行相应的缩放。
081:函数调用 🧩

在本节课中,我们将学习如何在汇编语言中进行函数调用。我们将从最简单的函数调用开始,逐步深入到带有参数和返回值的函数,并了解调用过程中寄存器的使用约定。
概述
函数调用是程序设计中组织代码和实现复用的核心机制。在汇编层面,这涉及到如何跳转到目标函数、如何传递参数、如何保存返回地址以及如何返回结果。本节我们将通过具体的例子,解析RISC-V架构下函数调用的基本步骤和约定。
简单的函数调用
让我们从一个最简单的函数调用开始。假设在主函数 main 中,我们调用一个名为 simple 的函数,该函数不接收任何输入,也不返回任何输出。
以下是实现此调用的汇编代码示例:
main:
jal simple # 跳转并链接到 simple 函数
add s0, s1, s2 # 函数返回后继续执行
simple:
jr ra # 跳转回返回地址寄存器 ra 中保存的地址
工作原理分析:
- 跳转并链接 (
jal):这条指令执行两个操作。首先,它将下一条指令的地址(即程序计数器PC + 4)保存到返回地址寄存器ra中。然后,它跳转到标签simple所指示的地址。 - 函数返回 (
jr ra):在simple函数中,jr ra指令使程序跳转回ra寄存器中保存的地址,从而返回到main函数中jal指令之后的位置。
通过这个简单的例子,我们看到了函数调用和返回的基本框架:调用者使用 jal 跳转并保存返回点,被调用者使用 jr ra 返回。
带参数和返回值的函数调用
上一节我们介绍了最简单的函数调用。本节中我们来看看更常见的情况:一个接收参数并返回结果的函数。
假设我们需要一个计算 (F + G) - (H + I) 的函数 diff_of_sums。在 main 函数中,我们调用 diff_of_sums(2, 3, 4, 5) 并将结果赋值给变量 y。
以下是实现此功能的汇编代码思路:
main:
# 准备参数:将 2, 3, 4, 5 分别放入寄存器 a0, a1, a2, a3
li a0, 2
li a1, 3
li a2, 4
li a3, 5
# 调用函数
jal diff_of_sums
# 将返回值 (a0) 存入变量 y (假设 y 在寄存器 s7 中)
add s7, zero, a0
diff_of_sums:
# 计算 F + G (a0 + a1),结果存入临时寄存器 t0
add t0, a0, a1
# 计算 H + I (a2 + a3),结果存入临时寄存器 t1
add t1, a2, a3
# 计算 (F+G) - (H+I) (t0 - t1),结果存入 s3
sub s3, t0, t1
# 将最终结果从 s3 移动到返回值寄存器 a0
add a0, zero, s3
# 返回调用者
jr ra
关键约定:
- 参数传递:前8个参数通过寄存器
a0到a7传递。 - 返回值:函数通过寄存器
a0返回结果。 - 寄存器保护:被调用函数(
diff_of_sums)使用了t0,t1,s3寄存器。调用者(main)可能原本也在使用这些寄存器。为了避免冲突,被调用函数有责任在修改某些寄存器(如s系列保存寄存器)前保存其原始值,并在返回前恢复。这通常通过“栈”来实现,我们将在后续课程中详细讨论。
总结
本节课中我们一起学习了RISC-V汇编语言中的函数调用机制。
- 我们首先通过一个无参数无返回值的函数,理解了
jal(跳转并链接)和jr ra(跳转返回)这一对核心指令的作用。 - 接着,我们探讨了带有参数和返回值的函数调用,掌握了使用
a0-a7寄存器传递参数、使用a0寄存器返回结果的编程约定。 - 最后,我们指出了函数调用中一个至关重要的问题:寄存器保护。被调用函数不应破坏调用者依赖的寄存器值,这引出了对“栈”这一内存区域的需求,为下一节内容做好了铺垫。

通过遵循这些约定,不同的程序员可以独立编写能够正确协作的函数模块。
082:栈(The Stack)📚

在本节中,我们将学习计算机架构中的一个重要概念——栈。栈是主内存的一部分,用于临时存储变量。我们将了解它的工作原理、如何通过栈指针进行管理,以及它在函数调用过程中如何保存和恢复寄存器状态。
概述
栈是一种遵循“后进先出”原则的数据结构,类似于一摞盘子。在计算机架构中,栈通常从高内存地址向低地址方向“向下”增长。一个名为栈指针(SP)的特殊寄存器始终指向栈的顶部。
栈的基本操作
上一节我们介绍了内存和寄存器的基本概念,本节中我们来看看栈的具体操作。
栈可以扩展和收缩。当需要更多内存空间时,栈可以向下扩展;当不再需要这些空间时,栈可以向上收缩。
在计算机架构中,栈是“倒置”的。它通常从一个较高的内存位置开始,向较低的地址方向增长。有一个名为栈指针(SP)的寄存器,它是32个寄存器之一,用于指向栈的顶部。
想象我们有一段内存。在栈的顶部,栈指针指向一些数据,即最顶部的元素。
现在,假设我们想在栈上再放入两个字。由于栈是向下增长的,因此栈指针需要向下移动两个字的位置。旧的值保持不变,但我们可以放入两个新值。此时,栈指针指向内存中低8字节的位置。
栈在函数调用中的应用
在函数调用中,我们必须确保没有意外的副作用。回想一下 diff of sums 函数,它会覆盖 t0、t1 和 s3 这三个寄存器。因此,我们可能希望在修改这些寄存器之前保存它们,然后在函数返回之前恢复它们。
以下是保存三个寄存器的示例。每个寄存器是32位(4字节),因此我们需要将栈指针向下移动12字节,为这三个数据腾出空间。
然后,我们可以使用 sw(存储字)指令,将 s3、t0 和 t1 依次存储在栈顶上方偏移量为0、4和8字节的位置。
现在,我们已经保存了这些值,可以自由地执行会修改 t0、t1 和 s3 的 diff of sums 函数了。函数最终将结果放在 a0 中。
接下来,我们需要通过恢复这些值来进行清理。我们使用 lw(加载字)指令,从栈顶偏移量为8、4和0的位置将值加载回 t1、t0 和 s3。
最后,我们将栈指针移回最初的位置。至此,除了本应存放返回结果的 a0 外,所有寄存器都未改变,栈指针也回到了开始时的位置。然后,我们通过 jr ra 指令返回。
寄存器的保存约定
现在让我们思考一下哪些寄存器需要被保存。
s 寄存器用于表示变量。因此,当一个函数调用另一个函数并返回时,合理地期望局部变量没有被破坏,所以我们需要在函数调用期间保存这些寄存器。同样,栈指针在函数调用结束后应该回到原来的位置,返回地址寄存器(ra)也应保持不变,以便我们知道返回到哪里。栈指针上方的栈空间也不应被破坏。
如果一个被调用的函数(即被调用者)想要使用任何 s 寄存器、栈指针或 ra,它需要在使用前保存它们。
临时寄存器(t 寄存器)和参数寄存器(a 寄存器)被称为“非保留”寄存器。临时寄存器本来就是临时的,你不能指望它们在函数调用期间保持其值。因此,如果调用者希望它们保持值,它应该自己保存这些寄存器。同样,调用者将通过 a0 到 a7 传递参数,所以这些寄存器的值本质上会被改变。如果调用者在进行函数调用前关心 a 寄存器的旧值,调用者有责任保存它们。
优化 diff of sums 函数
让我们重新审视 diff of sums 函数。但这次,只需要保存 s3,因为它是被保留的寄存器之一。diff of sums 必须承诺不会破坏任何 s 寄存器,但它可以自由地使用 t 寄存器。
在这个版本的 diff of sums 中,我们只将栈指针向下移动一个字(4字节),保存 s3。然后执行计算,将答案放入 s3,再复制到 a0。最后,我们通过 lw 指令从栈上恢复 s3,并将栈指针移回。
我们甚至可以做得更好。既然结果最终要放入 a0,那么中途没有特别的理由将其放入 s3。如果我们不使用 s3,那么我们就不需要保存或恢复任何东西。因此,这里有一个优化后的 diff of sums 版本,它直接将答案放入 a0,无需保存或恢复任何内容。
非叶子函数与栈帧
假设我们有一个非叶子函数,即一个会调用其他函数的函数。
例如,我们在函数 F1 中,想要调用函数 F2。在此之前,F1 需要知道返回地址。因此,它需要移动栈指针,并将其返回地址保存到栈上。然后它可以调用 F2。F2 的执行会改变 ra。所以当我们返回时,结果在 a0 中,我们需要从栈上加载 ra 回来,并将栈指针移回原处。
任何会调用其他函数的非叶子函数都有责任首先保存返回地址。
以下是一个稍微复杂一点的函数对示例:F1 和 F2。假设 F1 是一个非叶子函数,因为它调用了 F2。F2 被称为叶子函数,因为它不调用任何其他函数。假设 F1 使用 s4 和 s5,并且在调用 F2 返回后还需要 a0 和 a1。
F1 首先在栈上为五个数据腾出空间,将栈指针向下移动20字节。它会保存它需要的 a0 和 a1,保存它的返回地址(以便 F1 知道最后返回到哪里),并保存它想要使用的 s4 和 s5。然后,我们可以使用 jal(跳转并链接)指令跳转到函数 F2。我们可以在 F1 中做一堆其他事情,包括可能在使用 s4 和 s5 作为内部变量时改变它们。当我们全部完成后,我们需要从栈上加载返回地址,将栈指针移回原处,恢复我们关心的其他变量,然后跳转回返回地址。
F2 更简单一些。假设它只使用 s4(这是它唯一需要的被保留寄存器),并且不调用任何其他函数。因此,它只需要在栈上保存 s4:将栈指针向下移动4字节,将 s4 存储到栈上,执行其操作,然后从栈上恢复 s4,将栈指针移回原处(这应该是加4),然后跳转回返回地址。
这是一个函数调用期间栈的示例:假设栈指针最初在栈顶的某个位置。然后我们调用 F1,将 a0、a1、ra、s4 和 s5 放入栈中,这被称为函数 F1 的“栈帧”。接着,当我们调用 F2 时,栈指针会再次向下移动一个字,F2 会将其 s4 寄存器保存在 F2 的栈帧中。一旦 F2 完成,我们会释放 F2 的栈帧,栈指针移回这里。一旦 F1 完成,栈指针最终回到最初的位置。
总结
本节课中我们一起学习了栈的概念及其在RISC-V架构函数调用中的关键作用。

总结如下:
- 当调用者想要调用另一个函数时,它将任何参数放入
a0到a7。 - 它保存任何可能需要的寄存器,这包括
ra,以及可能的临时寄存器或a寄存器(最好在将参数放入它们之前保存)。 - 然后,它使用
jal指令调用被调用者。 - 当返回时,它将恢复它保存且仍然需要的任何寄存器,并在
a0中查找结果。 - 被调用者将保存它可能想要扰动的任何被保留寄存器(例如
s寄存器)。 - 它执行其功能,然后将结果放入
a0,恢复它接触过的那些寄存器,并通过jr ra指令返回。
083:递归函数 🌀
在本节课中,我们将学习递归函数调用,即函数调用自身的过程。我们将重点探讨在汇编语言中如何处理递归调用,特别是如何通过栈来保存和恢复寄存器的状态,以确保程序正确运行。

概述
递归函数是编程中的一个核心概念,它通过函数调用自身来解决问题。在汇编层面实现递归时,我们需要特别注意寄存器状态的保存与恢复,因为每次递归调用都可能覆盖前一次调用的数据。本节课将以经典的阶乘函数为例,详细讲解如何将递归逻辑转换为正确的RISC-V汇编代码。
递归函数定义
递归函数是指直接或间接调用自身的函数。在将其转换为汇编代码时,一个有效的方法是先将其视为调用另一个普通函数,忽略寄存器被覆盖的问题,然后再回过头来考虑如何通过栈来保存和恢复必要的寄存器。
一个经典的递归函数例子是阶乘函数,记作 n!,其定义为:
[
n! = n \times (n-1) \times (n-2) \times \ldots \times 2 \times 1
]
例如,6! = 6 × 5 × 4 × 3 × 2 × 1 = 720。
阶乘的递归定义如下:
- 如果
n <= 1,则返回1(基本情况)。 - 否则,返回
n × factorial(n-1)。
假设主函数调用 factorial(3),其执行过程为:
factorial(3)返回3 × factorial(2)factorial(2)返回2 × factorial(1)factorial(1)返回1- 然后逐层返回:
factorial(2) = 2 × 1 = 2,factorial(3) = 3 × 2 = 6
初步汇编代码转换
首先,我们忽略栈和寄存器保存问题,将递归逻辑直接转换为汇编代码。以下是第一遍转换得到的“黑盒”代码框架:
factorial:
li t0, 1 # 将1加载到t0寄存器,用于比较
bgt a0, t0, ELSE # 如果 n > 1,跳转到ELSE标签
li a0, 1 # 基本情况:将返回值设为1
jr ra # 返回调用者
ELSE:
addi a0, a0, -1 # 计算 n-1,并作为新参数
jal factorial # 递归调用 factorial(n-1)
# 假设返回后,a0中保存着 factorial(n-1) 的结果
# 此处需要将原n值乘以该结果,但原n值已被覆盖
# 我们稍后会处理这个问题
mul a0, t1, a0 # 用临时寄存器t1(应保存原n值)乘以结果
jr ra # 返回
这段代码实现了基本逻辑,但存在一个问题:在递归调用 jal factorial 后,参数寄存器 a0 的值(原 n)被新的参数 n-1 覆盖了,导致后续无法计算 n * factorial(n-1)。
保存与恢复寄存器
为了解决上述问题,我们需要在递归调用前保存关键寄存器的状态,调用后再恢复。对于阶乘函数,需要保存的两项是:
- 返回地址寄存器
ra:因为jal指令会修改ra,我们需要知道每次调用后应返回到哪里。 - 参数
n的值:在计算n * factorial(n-1)时,我们需要用到原始的n值。
以下是修改后的完整汇编代码,包含了栈操作:
factorial:
# 为两个寄存器(a0和ra)在栈上分配空间
addi sp, sp, -8
sw a0, 0(sp) # 将当前的n值保存到栈上
sw ra, 4(sp) # 将返回地址保存到栈上
# 检查基本情况:n <= 1
li t0, 1
bgt a0, t0, RECURSE
# 基本情况:返回1
li a0, 1
# 恢复栈指针并返回
addi sp, sp, 8
jr ra
RECURSE:
# 准备递归调用:计算 n-1 作为新参数
addi a0, a0, -1
jal factorial # 递归调用 factorial(n-1)
# 递归调用返回后,a0中保存着 factorial(n-1) 的结果
# 现在从栈中恢复原n值到临时寄存器t1,并恢复ra
lw t1, 0(sp) # 将原n值加载到t1
lw ra, 4(sp) # 恢复返回地址
addi sp, sp, 8 # 释放栈空间
# 计算最终结果:n * factorial(n-1)
mul a0, t1, a0
jr ra # 返回
栈帧变化过程分析
为了更好地理解递归调用时栈的变化,我们假设 factorial 函数从内存地址 0x8500 开始,并且初始栈指针 sp 指向 0xFF0。以下是调用 factorial(3) 时栈帧的演变过程:
-
第一次调用
factorial(3):sp下移8字节至0xFE8。- 将
a0=3和ra=[主函数返回地址]保存到栈帧[0xFE8, 0xFF0)。
-
第二次调用
factorial(2):sp再次下移8字节至0xFE0。- 将
a0=2和ra=0x8528(factorial(3)中jal后的地址)保存到新栈帧。
-
第三次调用
factorial(1):sp下移至0xFD8。- 将
a0=1和ra=0x8528保存到栈帧。
-
开始返回:
factorial(1)遇到基本情况,返回a0=1,并将sp恢复至0xFE0。factorial(2)从栈中恢复t1=2和ra=0x8528,计算2 * 1 = 2,存入a0,返回并将sp恢复至0xFE8。factorial(3)从栈中恢复t1=3和ra=[主函数返回地址],计算3 * 2 = 6,存入a0,返回并将sp最终恢复至初始的0xFF0。
通过栈帧的压入和弹出,每个递归调用实例都能独立地访问其自身的参数和返回地址,从而保证了程序的正确执行。
总结
本节课我们一起学习了递归函数在汇编层面的实现。关键要点如下:
- 递归函数通过调用自身来解决问题,在汇编中需要仔细管理寄存器的状态。
- 栈是保存和恢复寄存器(如返回地址
ra和参数)的关键数据结构。 - 实现递归的通用模式是:在递归调用前将必要数据压栈,调用后弹栈恢复,最后进行计算并返回。
- 通过分析阶乘函数的栈帧变化,我们直观地看到了递归调用与返回过程中栈的动态调整。

理解递归的汇编实现,有助于我们深入认识函数调用机制和计算机系统如何管理程序状态。
084:深入理解跳转与伪指令 🧠

在本节中,我们将深入探讨RISC-V汇编语言,了解跳转指令和伪指令的工作原理。我们将看到,处理器实际支持的跳转指令种类有限,但通过伪指令,程序员可以更方便地编写代码。
处理器支持的跳转指令
上一节我们介绍了多种跳转指令,但实际上,RISC-V处理器只直接支持两种核心的跳转指令:JAL(跳转并链接)和JALR(跳转并链接寄存器)。
JAL(跳转并链接):该指令接受一个21位的立即数偏移量(以补码形式表示,可向前或向后跳转约100万字节),并将其与程序计数器(PC)相加作为目标地址。同时,它还会将PC + 4(返回地址)存入指定的目标寄存器(rd)。- 公式:
PC <- PC + sign_extend(offset);rd <- PC + 4
- 公式:
JALR(跳转并链接寄存器):该指令与JAL类似,但它不是将立即数加到PC上,而是将一个源寄存器(rs1)的值与一个12位的立即数相加作为目标地址。同样,它也会将PC + 4存入目标寄存器(rd)。- 公式:
PC <- rs1 + sign_extend(imm);rd <- PC + 4
- 公式:
这两种跳转指令足以实现所有跳转功能。然而,从程序员的角度来看,并非总是需要用到全部功能。
伪指令的概念
为了编程的便利性,RISC-V定义了伪指令。伪指令并非真实的处理器指令,但汇编器(Assembler)会自动将它们转换为一条或多条真实的RISC-V指令。
以下是关于跳转的常见伪指令及其等效的真实指令:
J label:无条件跳转到标签label。- 等效指令:
JAL x0, label - 解释:
x0是硬连线为0的寄存器,向其写入数据会被忽略。因此,JAL x0, label执行跳转,但丢弃了返回地址,实现了简单的跳转。
- 等效指令:
JAL label:用于函数调用,跳转到标签label并将返回地址存入ra(x1)寄存器。- 等效指令:
JAL ra, label(默认目标寄存器就是ra)
- 等效指令:
JR rs:跳转到寄存器rs指定的地址。- 等效指令:
JALR x0, rs, 0 - 解释:将返回地址丢弃(存入
x0),并将目标地址设置为rs + 0,即直接跳转到rs的值。
- 等效指令:
RET:从函数返回。- 等效指令:
JALR x0, ra, 0 - 解释:这是
JR ra的另一种更清晰的写法,专门用于函数返回。
- 等效指令:
标签与跳转偏移量
标签(Label)用于标记跳转的目标位置。在机器码中,它被编码为相对于当前PC的字节偏移量。
例如,如果当前指令地址是0x300,我们想跳转到标签simple(地址为0x51C),那么偏移量就是 0x51C - 0x300 = 0x21C。汇编器会将JAL simple编码为JAL ra, 0x21C。
然而,JAL的偏移量被限制在20位(约±1MB),JALR的立即数偏移量被限制在12位。为了支持更远距离的跳转(例如,调用一个距离很远的函数),需要使用特殊指令组合。
长距离跳转:CALL 伪指令
CALL伪指令允许使用32位的偏移量进行函数调用,突破了JAL的20位限制。
它的实现原理是将其分解为两条真实指令:
AUIPC ra, offset_hi:将当前PC与偏移量的高20位相加,结果临时存入ra寄存器。JALR ra, ra, offset_lo:再将ra的值与偏移量的低12位相加,作为最终目标地址进行跳转,同时将真正的返回地址存入ra。
- 代码示例:
# 伪指令:CALL far_away_function # 等效的真实指令序列: AUIPC ra, offset_upper_20_bits JALR ra, ra, offset_lower_12_bits
其他常用伪指令
除了跳转,汇编器还提供了其他方便的伪指令来简化代码编写。
以下是几个例子:
MV rd, rs(数据移动):将寄存器rs的值复制到rd。- 等效指令:
ADDI rd, rs, 0
- 等效指令:
NOT rd, rs(按位取反):将寄存器rs的所有位取反后存入rd。RISC-V没有专门的NOT指令。- 等效指令:
XORI rd, rs, -1(因为-1的补码表示是全1,与1进行异或会翻转该位)
- 等效指令:
NOP(空操作):一条不执行任何操作的指令,常用于延时或占位。- 等效指令:
ADDI x0, x0, 0
- 等效指令:
LI rd, immediate(加载大立即数):将一个32位的立即数加载到寄存器rd中。- 实现方式:通常分解为
LUI(加载高20位)和ADDI(加上低12位)两条指令。
- 实现方式:通常分解为
- 分支比较伪指令:RISC-V只直接提供
BLT(小于则分支)和BGE(大于等于则分支)。其他比较可以通过交换操作数或与零比较来实现。BGT rs1, rs2, label(大于则分支)等效于BLT rs2, rs1, label。BGEZ rs, label(大于等于零则分支)等效于BGE rs, x0, label。
总结
本节课中,我们一起深入学习了RISC-V的跳转机制和伪指令。
- 我们了解到处理器核心只支持
JAL和JALR两种跳转指令。 - 伪指令是汇编器提供的语法糖,它们会被转换为真实的指令,使代码更易读、易写。
- 我们学习了
J、JAL、JR、RET、CALL等跳转相关伪指令的等效实现。 - 标签在汇编中代表地址,跳转偏移量有其范围限制,长距离跳转需要
AUIPC和JALR指令组合完成。 - 此外,我们还认识了
MV、NOT、NOP、LI等常用伪指令,它们极大地简化了数据操作、逻辑运算和常量加载等常见任务。

理解伪指令及其背后的真实指令转换,对于阅读汇编代码和深入理解计算机架构至关重要。
085:机器语言R型指令格式 🖥️

在本节中,我们将学习机器语言。到目前为止,我们一直关注汇编语言,它是计算机原生语言的人类可读版本。但计算机本质上是数字系统,它们只理解0和1。因此,每条汇编语言指令实际上都表示为一个由0和1组成的模式。
在RISC-V架构中,每条指令的长度是32位。这意味着每条汇编语言指令都有一个对应的机器语言指令,它是一个由32个0和1组成的模式。我们将所有指令设为相同大小的原因是:简单性倾向于规律性。通过让指令与数据字大小相同,每条指令也恰好占用一个字,便于规整地存储。
理想情况下,我们希望所有指令都使用同一种格式。但我们知道,有些指令需要两个源寄存器和一个目标寄存器;有些指令需要较少的寄存器,但需要一个立即数;还有一些指令需要一个较长的立即数,例如20位的立即数。因此,作为一种折中方案,RISC-V定义了四种不同的指令格式。我们将在本节和下一节中介绍它们。
R型指令格式用于需要两个源寄存器和一个目标寄存器的寄存器类型指令。I型指令格式包含一个源寄存器、一个立即数和一个目标寄存器。S型或B型指令格式用于存储和分支指令。U型和J型指令格式用于加载上立即数以及需要20位立即数的跳转指令。
现在,让我们首先关注R型指令。这些指令需要三个寄存器操作数:两个源寄存器Rs1和Rs2,以及一个目标寄存器Rd。指令还需要一些位来指明它是什么指令,我们将把这些信息存储在三个不同的字段中:一个是op(也称为操作码),另外两个是funct7和funct3,它们是7位和3位的字段,用于告知执行何种类型的操作。通过拥有这么多不同的位,我们可以在指令集中编码大量不同的指令,甚至包括设计者尚未构想出的指令。
R型指令的格式是一个32位的字。最低的7位是操作码op。接下来的5位用于指定目标寄存器,因为有32个寄存器,log₂(32)=5,所以我们需要5位来指定是哪一个。再往上,我们有源寄存器1和源寄存器2,它们也是5位的字段,用于指定32个寄存器中的一个。然后我们还剩下3位和7位,我们将它们用作功能字段funct3和funct7,以提供更多关于执行何种操作的信息。
假设我们想执行一条加法指令:add s2, s3, s4。我们需要查阅一个列出所有指令操作码的表格,课本的附录B中有这样一个表格。对于add指令,op是51,funct3是0,funct7是0。我们还需要知道使用了哪些寄存器。在本章开头,我们讨论过寄存器名称与其编号的对应关系:寄存器s2是18号,s3是19号,s4是20号。因此,目标寄存器是18,源寄存器1是19,源寄存器2是20。必须注意将它们按正确的顺序放入指令格式中。
以下是这些值的十进制表示,让我们将它们转换为二进制:
op= 51(十进制) =0110011(二进制)rd= 18(十进制) =10010(二进制)funct3= 0(十进制) =000(二进制)rs1= 19(十进制) =10011(二进制)rs2= 20(十进制) =10100(二进制)funct7= 0(十进制) =0000000(二进制)
现在,如果我们把这堆比特位按四位一组进行划分,并转换为十六进制,会更容易阅读和表达。这条add s2, s3, s4指令的机器语言编码是十六进制的0x01498933。将这个值告诉RISC-V处理器,它就能准确地知道要做什么。
减法指令sub与此类似。它的op码也是51,funct3也是0,但我们通过funct7字段为32(而不是0)来将其与加法区分开。例如,指令sub t0, t1, t2中,t0是5号寄存器,t1是6号寄存器,t2是7号寄存器。按照相同的过程编码并转换为十六进制,结果是0x407302b3。
让我们再看几个例子。以下是几个R型指令及其编码的总结:
以下是几个R型指令的编码示例:
sll s7, t0, s1(逻辑左移)op= 51,funct3= 1,funct7= 0- 寄存器:
s7(23),t0(5),s1(9)
xor t1, t2, s3(异或)op= 51,funct3= 4,funct7= 0- 寄存器:
t1(6),t2(7),s3(19)
srai s3, s4, 2(算术右移立即数)op= 19,funct3= 5,funct7= 32- 注意:这是一个I型指令(涉及立即数),此处列出是为了对比。其寄存器字段为:
s3(19),s4(20),立即数为2。
对于每一条指令,我们都可以查阅附录B中的表格找到其操作码和功能码,查找寄存器的编号,将所有值转换为二进制,然后最终转换为十六进制,从而得到对应的机器语言指令。

在本节课中,我们一起学习了机器语言的基本概念,特别是RISC-V架构中R型指令的格式。我们了解到,每条32位的汇编指令都对应一个由操作码、寄存器字段和功能字段组成的特定比特模式。通过将指令各部分的值转换为二进制并组合,最终可以得到十六进制表示的机器码。理解这种编码方式是理解计算机如何执行底层操作的关键一步。在接下来的章节中,我们将继续探讨其他类型的指令格式。
数字设计和计算机架构:6.16:RISC-V I、S、B、U、J型指令格式 🖥️

在本节中,我们将学习RISC-V指令集架构中除R型指令外的其他几种机器语言格式:I型、S型、B型、U型和J型。理解这些格式对于掌握指令编码和解码至关重要。
上一节我们介绍了R型指令格式,本节中我们来看看其他几种指令格式。
I型指令格式
I型指令代表“立即数”类型。与R型指令类似,I型指令也包含三个操作数:一个源寄存器、一个目标寄存器,以及一个12位的立即数。其格式如下:
- Opcode:位于指令的低7位,标识指令类型。
- Rd:目标寄存器,占5位。
- Funct3:3位功能码,用于细化指令操作。
- Rs1:第一个源寄存器,占5位。
- Immediate:12位立即数,填充在指令的高12位。
以下是I型指令的一个例子:
addi x5, x3, 42
这条指令将寄存器x3的值加上立即数42,结果存入寄存器x5。在编码时,Opcode代表addi操作,Funct3为0,Rs1对应x3,Rd对应x5,Immediate字段则编码数值42。
另一个I型指令的例子是加载指令,例如lw(加载字)。其Opcode为3,Funct3为2。目标寄存器是t2(寄存器编号7),基地址寄存器是s3(编号19),偏移量是-6。
S型和B型指令格式
S型和B型指令分别用于存储操作和分支操作。它们的格式非常相似,主要区别在于立即数的编码方式。
这两种指令都包含以下部分:
- Opcode:低7位。
- Rs1:第一个源寄存器。
- Rs2:第二个源寄存器(S型用于存储的数据,B型用于比较的第二个寄存器)。
- Funct3:3位功能码。
以下是S型和B型指令中立即数的编码方式:
- S型:存储一个12位的立即数(偏移量)。这个立即数被拆分为两部分:低5位放在指令的一个位置,高7位放在另一个位置。这样设计是为了给
Rs2字段留出空间。 - B型:存储一个13位的分支偏移量。由于分支目标地址总是2字节对齐的(即最低位总是0),因此编码时省略了最低有效位,实际存储偏移量的第12位到第1位。这些位同样以特定顺序分散在指令中。
例如,一条存储字指令sw的Opcode是35,Funct3为2。假设要将寄存器s5的值存储到以s3为基地址、偏移-6的位置,我们需要将-6这个12位立即数拆开并编码到指令的指定位置。
对于B型指令,如beq s0, s5, label1,需要计算从当前指令地址到目标标签label1的偏移量(以字节为单位)。假设偏移量为16(十进制),在编码时,我们取这个偏移量的第12位到第1位,并按特定规则分散填入指令的立即数字段。
U型和J型指令格式
U型和J型指令格式更为简洁,主要用于处理大立即数和长跳转。
-
U型:用于加载大立即数到寄存器的高位(如
lui指令)。它包含一个20位的立即数,这个立即数直接放在指令的高20位,低12位用0填充。格式为:Opcode+Rd+20位立即数。- 例如,
lui s5, 0xhcdef将立即数0xhcdef左移12位后加载到寄存器s5的高20位。
- 例如,
-
J型:用于无条件跳转指令(如
jal)。它存储一个20位的跳转偏移量。和B型指令一样,由于跳转地址是2字节对齐的,编码时省略最低有效位。这20位偏移量(实际是21位偏移量的第20位到第1位)以一种看似不连续的顺序分布在指令中。- 例如,执行
jal ra, function1时,汇编器会计算从当前指令地址到function1地址的偏移量,然后将该偏移量按J型格式的特殊规则进行编码。
- 例如,执行
总结与设计原则
本节课中我们一起学习了RISC-V的五种核心指令格式:R、I、S、B、U、J型。所有指令的Opcode都位于相同位置(低7位),这是解码器识别指令类型的第一步。需要Funct3的指令也将其放在中间固定位置。需要目标寄存器Rd或源寄存器Rs1/Rs2的指令,其字段位置也是统一的。

这种设计体现了计算机架构的一个重要原则:好的设计需要恰当的折衷。通过提供多种指令格式(如R型支持两个寄存器操作,I型支持寄存器-立即数操作),RISC-V获得了编程的灵活性。同时,将格式数量保持得较少,遵循了“简单源于规整”和“更小更快”的原则,从而允许实现快速且简单的指令解码器。
数字设计和计算机架构:6.17:立即数编码 🧩

在本节中,我们将探讨RISC-V指令中立即数的编码方式。理解这些编码对于掌握指令格式和硬件实现至关重要。
概述
RISC-V指令集使用多种类型的指令,如I型、S型、B型、U型和J型,它们都需要处理立即数。这些立即数在指令中的编码位置看似复杂,但从硬件设计的角度看,这种安排是为了实现高效解码和操作。
立即数的用途与表示
上一节我们介绍了不同类型的指令。本节中,我们来看看这些指令如何编码立即数。
I型指令(如lw、addi)使用一个12位的二进制补码立即数。例如,addi指令将一个12位立即数与寄存器值相加。值得注意的是,没有专门的subi(立即数减法)指令,因为可以通过使用负的立即数来实现减法。例如:
- 执行
a + 4:addi s0, s0, 4 - 执行
a - 4:addi s1, s1, -4
从硬件角度看编码

如果仅看指令字中的位分布,立即数的编码顺序可能显得不规则。但从硬件实现的角度分析,这种设计就变得合理了。
以下是各类型指令的立即数构成方式:
- I型或S型指令:取指令中的12位立即数,并将其符号扩展至高位。
- B型指令:取立即数位
imm[11:1](最低有效位imm[0]固定为0),并将第12位(imm[12])符号扩展至高位。 - U型指令:低12位为0,立即数放置在高20位。
- J型指令:立即数首位为0,随后是11位,然后是剩余的立即数位,最后符号扩展至高位。
编码一致性与硬件优化
指令解码的难点在于快速识别关键字段:操作码(opcode)、功能码(funct)和源寄存器。RISC-V的设计致力于最大化这些字段在不同指令类型间的一致性。
以下是关键字段的位置规律:
- 操作码(opcode):始终位于指令字的第6至第0位。
- 寄存器字段:目标寄存器
rd、源寄存器rs1和rs2在所有指令类型中的位置都固定不变。 - 功能码(funct3):位置也始终固定。
唯一变化较大的是立即数字段。在硬件中,从指令字的不同位置选取位来组装立即数是一项相对简单的操作,它不涉及访问寄存器文件、内存或算术逻辑单元(ALU),因此速度很快。设计者可以承受这部分复杂度。
尽管如此,指令集仍尽可能系统化地打包立即数。例如,在S型和B型指令中,位imm[4:1]出现在相同位置。S型需要imm[0],而B型需要imm[11],因此将imm[11]安排在了另一个特定位置,而不是移动整个B型指令的位域。通过这种方式,在可能的情况下,我们保持了比特位位置的一致性。
总结
本节课中,我们一起学习了RISC-V指令集中立即数的编码方式。我们了解到,尽管立即数在指令字中的分布看似分散,但这种设计是为了保证操作码和寄存器字段位置的一致性,从而简化硬件解码。将复杂性集中在立即数组装上是合理的,因为这是一个快速且简单的硬件操作。通过分析I、S、B、U、J型指令的立即数格式,我们看到了指令集设计在效率与规整性之间的权衡。
088:指令解码与寻址模式 🔍

在本节中,我们将学习如何解读机器语言指令,并理解操作数的寻址方式。我们将从指令格式的识别开始,逐步深入到不同寻址模式的具体应用。

指令格式回顾
上一节我们介绍了RISC-V指令集的基本概念。以下是目前我们已学习的一些指令在汇编语言和机器语言中的对应关系。
以下是部分指令及其编码的总结:
- 每条指令都有其汇编语言助记符和对应的操作码。
- R型指令(如
add,sub,and,or)的操作码都是51。它们通过funct3和funct7字段来区分。 - 例如,
add和sub的funct3字段相同,但funct7字段不同。and和or的funct3编码则不同。 addi是I型指令,操作码为19。- 分支指令是B型指令,操作码为
99,并通过funct3字段区分不同类型。 lw(加载字)的操作码是3,funct3为2。sw(存储字)的操作码是35,funct3为2。jal(跳转并链接)是J型指令,操作码为111。jalr(寄存器跳转并链接)的操作码是103。lui(加载高位立即数)是U型指令,操作码为55。- 值得注意的是,
jalr实际上属于I型指令,因为它只包含一个12位的偏移量,并需要一个目标寄存器。
解码机器指令
当需要解读一段机器语言代码时,最佳起点是将十六进制数转换为二进制,然后查看 op 和 funct3 字段,以确定指令类型并解析其余部分。接着,根据指令类型提取各个字段。
假设我们遇到以下两条机器语言指令:
0x41f383b3
0xfff48493
对于第一条指令 0x41f383b3:
- 其低7位(操作码)是
0110011,即十进制的51。这告诉我们这是一条R型指令。 - 我们需要查看
funct3和funct7字段。funct3字段(第12-14位)是000。 - 现在我们知道,
funct3为000时,可能是add或sub。 - 最后,我们查看高7位(
funct7字段):0100000,即十进制的32,这对应sub指令。 - 因此,这条指令是
sub。
对于第二条指令 0xfff48493:
- 其操作码(低7位)是
0010011,即十进制的19。这看起来像是一条I型指令,例如addi。 - 同样,我们查找
funct3字段,发现是000。 - 可以确定,这是一条
addi指令。
提取指令字段
一旦确定了指令的格式,我们就可以将其余位解包到对应的字段中。
对于第一个例子(sub 指令,R型):
- 十六进制数
0x41f383b3对应的二进制位如下:funct7:0100000rs2:11111(x31)rs1:11101(x29)funct3:000rd:00111(x7)opcode:0110011
- 我们已经通过
op、funct3和funct7知道它是sub指令。其他字段告诉我们源寄存器和目标寄存器。 - 目标寄存器
rd是x7,查表可知是寄存器t2。 - 源寄存器
rs1是x29(t4),rs2是x31(t6)。 - 因此,这条指令是:
sub t2, t4, t6
对于第二个例子(addi 指令,I型):
- 十六进制数
0xfff48493对应的二进制位如下:imm[11:0]:111111111111(这是一个负数,因为最高位是1,作为二进制补码表示-1)rs1:01001(x9)funct3:000rd:01001(x9)opcode:0010011
- 我们看到立即数
imm由于前导的1,是一个负数,作为12位二进制补码表示-1。 rs1是x9(s1),rd是x9(s1)。- 因此,这条指令是:
addi s1, s1, -1
寻址模式
理解机器和汇编语言的另一个部分是寻址模式,即指令中如何寻址操作数。这里讨论几种选择。
以下是RISC-V中常见的几种寻址模式:
- 寄存器寻址:R型指令只使用寄存器寻址模式,它们从寄存器中取两个源操作数,并将结果写入一个目标寄存器。例如:
add x1, x2, x3 - 立即数寻址:I型指令采用一个源寄存器、一个目标寄存器和一个立即数。对于I型指令,立即数是一个12位的有符号值。例如:
addi x1, x2, 100 - 基址寻址:用于加载和存储指令。要访问的内存地址通过将一个寄存器中的基地址加上一个立即数偏移来计算。例如:
lw x1, 4(x2)计算地址为x2 + 4。 - 另一个存储指令的例子:
sw x3, -25(x6),其地址计算为x6 - 25。 - PC相对寻址:用于分支指令和
jal指令。在这种模式下,我们需要计算目标地址相对于当前程序计数器(PC)的偏移量。
PC相对寻址示例
假设我们有一条分支指令:bne x24, x25, L1。
- 这条
bne指令本身位于内存地址0xEB0。 - 标签
L1位于地址0x354。 - 分支的距离是向后的
0xEB0 - 0x354 = 0xB5C(十六进制),即十进制的2908个字节。因此,偏移量应为-2908。 - 以下是如何将
-2908表示为一个13位的二进制补码数(分支指令的立即数字段是13位,但编码时最低有效位总是0,因此被省略)。 - 我们有操作码
branch,funct3字段为001表示bne。 - 源寄存器是
x24(s8) 和x25(s9)。 - 分支立即数字段编码如下:
- 最低4位(
imm[3:1])是0010。 - 第5到10位(
imm[10:5])是110101。 - 第11位(
imm[11])是1。 - 第12位(
imm[12])是1(因为是向后跳转,符号位为1)。
- 最低4位(

总结

本节课中,我们一起学习了如何解码RISC-V机器语言指令。我们从识别操作码和功能字段开始,以确定指令类型,然后提取出寄存器、立即数等具体字段。接着,我们探讨了不同的寻址模式,包括寄存器寻址、立即数寻址、基址寻址和PC相对寻址,并通过具体示例理解了它们在指令中的编码和应用。掌握这些知识是理解计算机如何执行底层指令的关键一步。
089:程序的编译、汇编与加载 🖥️

在本节课中,我们将学习程序从高级语言代码到最终在内存中运行的全过程。我们将探讨编译、汇编和加载的步骤,并了解程序和数据在内存中的布局。
存储程序的概念 💾
数字计算机的一项伟大创新是存储程序的概念。可以想象一个硬连线的系统,例如一个计算器,其电路负责检测按键,然后将数据送入算术逻辑单元执行运算,最后通过硬件将结果显示在屏幕上。这个计算器只能是计算器,无法执行其他任务。
但有了存储程序,程序本身是一组存储在内存中的指令。如果你想改变硬件执行的任务,无需重新布线整个系统,只需重新编程处理器,将新程序放入内存即可。
在我们的处理器中,32位的指令和数据都存储在内存中。处理器上运行的不同应用程序之间的唯一区别,就是指令序列的不同。执行程序时,处理器按适当顺序从内存中获取指令,并为每条指令执行指定的操作。
程序在内存中的布局 📊
上一节我们介绍了存储程序的概念,本节中我们来看看程序和数据在内存中是如何组织的。
程序的指令需要存储在内存中,这部分也称为程序的文本段。数据也需要存储在内存中。
我们已经了解过栈,它用于保存一些临时数据。此外,还有一部分内存用于存储全局或静态变量,它们在程序开始前就已分配。另一部分内存则用于存放动态分配的变量,例如通过 malloc 请求的变量。
那么,内存有多大呢?在我们的32位处理器上,地址是32位长,因此最多有 2^32 字节的内存,即4GB。地址范围从 0x00000000 到 0xFFFFFFFF。
计算机架构中一个几乎无法挽回的错误,就是地址空间不够大。4GB在很长一段时间内是足够的,但现代计算机甚至移动设备通常拥有超过这个容量的内存,因此需要更大的地址空间,这也推动了向64位微处理器的发展。
每个系统都有一个内存映射,它显示了内存的不同部分用于何种目的。内存映射取决于整个系统和操作系统,不一定由处理器本身定义。
以下是一个RISC-V处理器可能使用的内存映射示例:
- 底部32KB用作异常处理程序。例如,复位时程序计数器被设置为地址0,该地址可能有一个ROM,指示系统从启动闪存中获取指令并加载到内存,然后跳转到这些指令。
- 接着是文本段,用于存放程序指令。
- 然后是全局数据段,为全局变量提供空间。这部分可能较小,因为程序中全局变量的数量是确定的。
- 之后是动态数据的空间。堆从全局数据的顶部开始,当你调用
malloc请求内存时,堆会向上增长。 - 栈从内存顶部开始,随着函数调用和变量存储,栈会向下增长。
如果堆和栈发生碰撞,就意味着内存耗尽,会出现问题。内存的上半部分可能预留给操作系统使用。
编译与链接工具链 🔗
我们已经了解了程序在内存中的布局,现在来看看将高级语言代码转换为可执行文件的工具链过程。
如果我们想运行一个程序,需要经历以下步骤:
- 从用C或类似语言编写的高级代码开始。
- 将其输入编译器。编译器会生成汇编代码。
- 汇编代码通过汇编器处理,得到称为目标文件的机器语言代码。
- 需要将我们代码的目标文件与来自库或其他开发者编写的程序部分的其他目标文件结合起来。
- 链接器将所有不同的目标文件放在一起,创建可执行文件。
- 加载器将可执行文件加载到内存中,将机器语言指令放入内存的正确位置。
在计算早期,格蕾丝·霍珀是一位关键人物。她获得了耶鲁大学数学博士学位,是开发出第一个编译器的人。她还开发了COBOL编程语言。在此之前,所有程序都是用汇编语言或直接用机器语言编写并手工翻译的。她构建了这个从高级语言到汇编语言的编译器。
从代码到内存:一个具体示例 📝
上一节我们介绍了工具链,本节我们通过一个具体示例,看看程序如何从源代码一步步映射到内存。
假设我们有以下程序:
- 有一些全局变量
f,g,y。 - 有一个函数
func,它接收两个输入,执行一些操作(可能是递归的)。 - 有一个
main函数,它将全局变量f设为2,g设为3,用这些变量调用func函数,将结果放回y,然后返回。
以下是 func 程序翻译成汇编语言和机器语言的示例。func 需要在栈上保存一些内容,因此将栈指针下移,存储返回地址和一些局部变量,执行一些操作(包括可能对其自身的递归调用),然后返回。
我们还会注意到,这个程序使用了一些伪指令,例如 move 指令等价于 addi,return 指令等价于 jalr ra。
每条指令都对应一组机器码,所有这些机器码都存储在不同的地址。注意,每一行都是4字节。
main 函数也存储在这里,每条指令也是4字节。我们需要在栈上分配空间来存储一些变量,还需要将一些值加载到 f 和 g 中。f 和 g 是全局变量,因此它们的访问是相对于全局指针(全局变量的基地址)的。
当通过工具链运行此程序时,其中一个中间结果称为符号表。它记录了:
- 文本段从地址
0x1074开始。 - 具体来说,
func和main分别从0x10144和0x10180开始。 - 数据段从
0x115e0开始。 - 全局变量
f,g,y分别位于0x11a38,0x11a34,0x11a30。 - 每个
f,g,y长4字节。 func函数长0x3c字节,main函数长0x34字节。
现在我们可以看到所有这些是如何放入内存的:
- 文本段从
0x10144开始,包含func和main。 - 开始运行程序时,程序计数器被设置为
main的开头,我们开始执行代码。 - 全局指针指向全局段的起始位置,
y,g,f位于该全局段中。 - 栈指针设置在内存顶部,随着我们存入内容而向下增长。
- 在这个程序中,没有动态分配内存,因此没有内容向上增长。
总结 ✨

本节课中,我们一起学习了程序从编译、汇编到加载的完整过程。我们了解了存储程序的核心概念,认识了程序在内存中的布局(包括文本段、数据段、堆和栈),并回顾了将高级语言代码转换为可执行文件的工具链步骤。最后,通过一个具体示例,我们看到了源代码如何最终映射到内存地址中并准备执行。理解这些步骤对于掌握计算机如何运行软件至关重要。
090:大端序与小端序内存 🧠💾

在本节中,我们将学习计算机内存中一个重要的概念:字节序。具体来说,我们将探讨大端序和小端序这两种不同的字节组织方式,理解它们的区别、工作原理以及在实际应用中的影响。
概述
内存是按字节寻址的。一个关键问题是:在一个字(例如32位字)内部,我们如何为各个字节编号?这引出了两种不同的字节顺序约定:小端序和大端序。
上一节我们介绍了内存寻址的基本概念,本节中我们来看看字节在字内部的排列顺序。
字节序的定义
字节序定义了多字节数据(如一个字)在内存中存储时,其各个字节的排列顺序。
以下是两种主要的字节序:
- 小端序:一个字中,字节0存储的是最低有效字节,而字节3存储的是最高有效字节。
- 大端序:一个字中,字节0存储的是最高有效字节,而字节3存储的是最低有效字节。
在两种情况下,字的地址是相同的。例如,字0在地址0,字1在地址4,字2在地址8,字3在地址C。但是,字内部各个字节的地址顺序是不同的。
字节序的对比与影响
字节序本身的选择通常是任意的,没有绝对的优劣之分。历史上,计算机架构师们曾为此争论不休,一些人认为大端序更自然,另一些人则认为小端序更自然。
然而,当两个使用不同字节序的系统需要共享数据时,问题就出现了。如果一个系统是大端序,另一个是小端序,并且它们需要查看字内部的字节,那么数据的解读将是相反的。
如何判断字节序
有一种方法可以判断你计算机的字节序。假设内存中有一个字,其十六进制值为 0x01234567。
如果你在一个小端序或大端序的机器上加载这个完整的字,你得到的结果是相同的,都是 0x01234567。
但是,如果你将这个字存储到内存中(起始地址为字节0,地址0),然后尝试单独加载某个字节,例如字节1,结果就会因字节序而异:
- 在大端序系统中,字节1(地址偏移1)包含的是值
0x23。 - 在小端序系统中,字节1(地址偏移1)包含的是值
0x45。
这正是不同字节序系统间可能出错的地方。
总结

本节课中我们一起学习了内存字节序的核心概念。我们了解到,小端序将最低有效字节放在最低地址,而大端序将最高有效字节放在最低地址。虽然对于整字的操作,两种方式结果一致,但在进行字节级数据访问或不同字节序系统间通信时,必须注意和处理字节序的差异,否则会导致数据解读错误。现代许多计算机已经具备了根据需要切换字节序的能力。
091:有符号与无符号指令 🔢

在本节中,我们将学习RISC-V指令集中处理有符号数和无符号数的指令,以及如何检测运算中的溢出。
概述
RISC-V为某些运算提供了处理有符号数和无符号数两种版本的指令,同时也提供了检测溢出的方法。理解这些指令的区别对于编写正确的程序至关重要。
乘法指令
上一节我们介绍了乘法运算,本节中我们来看看不同符号处理方式对乘法结果的影响。当两个32位数相乘时,会得到一个64位的结果。我们之前看过的 mul 指令执行的是有符号乘法。
RISC-V也提供了无符号版本的乘法指令:
mulhu:将两个操作数都视为无符号数进行乘法。mulhsu:将第一个操作数视为有符号数,第二个操作数视为无符号数进行乘法。
在执行乘法时,结果的低32位无论操作数被视为有符号还是无符号都是相同的,因此我们可以直接使用 mul 指令来获取低32位。然而,结果的高32位取决于我们如何解释符号。
例如,假设寄存器 s0 的值为 0x80000000,s2 的值为 0xC0000000。
- 如果将它们视为有符号数:
s0是 -2³¹s2是 -2³⁰- 乘积应为 2⁶¹,其二进制表示为
0x2000000000000000。
- 如果将它们视为无符号数:
s0是 2³¹s2是 3 × 2³⁰- 乘积应为 3 × 2⁶¹,其二进制表示为
0x6000000000000000。
- 如果第一个数视为有符号,第二个视为无符号:
s0是 -2³¹s2是 3 × 2³⁰- 乘积应为 -3 × 2⁶¹,其二进制表示为
0xA000000000000000。
可以看到,根据将数字解释为有符号还是无符号,乘法的结果是不同的,因此需要使用相应的指令。
除法、余数、分支与比较指令
除法和取余运算通常也针对有符号数,但同样有无符号版本(如 divu, remu)。
分支指令通常将数字解释为有符号的二进制补码,但也有无符号版本。例如,假设 s1 为 0x80000000,s2 为 0x40000000。
- 执行
blt s1, s2(有符号小于则分支):s1解释为 -2³¹s2解释为 2³⁰s1小于s2,因此分支被采取。
- 执行
bltu s1, s2(无符号小于则分支):s1解释为正的 2³¹s2解释为 2³⁰s1不小于s2,因此分支不被采取。
类似地,置小于指令 slt 也有有符号 (slt) 和无符号 (sltu) 版本。一个需要注意的细节是,立即数版本的指令(如 slti, sltiu)总是对立即数进行符号扩展,即使是在执行无符号比较时也是如此。
例如,使用相同的 s1 和 s2:
slt t0, s1, s2(有符号比较):s1是 -2³¹,s2是 2³⁰。s1小于s2,结果t0被置为 1。
sltu t1, s1, s2(无符号比较):s1被视为一个很大的正数 (2³¹)。- 它不小于
s2(2³⁰),因此结果t1为 0。
slti t2, s1, -1(有符号立即数比较):s1是 -2³¹。- 立即数 -1 被符号扩展为
0xFFFFFFFF。 s1小于 -1,因此t2被置为 1。
sltiu t3, s1, -1(无符号立即数比较):s1被视为无符号数 2³¹。- 立即数 -1 同样被符号扩展为
0xFFFFFFFF,但此时它被解释为无符号数,其值为 2³² - 1(一个非常大的正数)。 - 2³¹ 小于 2³² - 1,因此
t3同样被置为 1。这里的关键在于,指令中的立即数-1在编码时已经被转换成了0xFFFFFFFF。
加载指令
加载指令会对值进行符号扩展。当我们使用 lh(加载半字)或 lb(加载字节)指令将8位或16位数据加载到32位寄存器时,寄存器的高位会用所读取值的第16位或第8位进行符号扩展。
RISC-V也提供了无符号版本的加载指令,它们进行零扩展:
lhu(无符号加载半字):将半字放入寄存器的低16位,高位用0填充。lbu(无符号加载字节):将字节放入寄存器的低8位,高位用0填充。

溢出检测
RISC-V不提供专门的无符号加法指令,因为常规的 add 指令对无符号加法同样有效。它也不直接提供溢出检测指令,因为可以利用现有指令计算溢出。
检测无符号溢出:
假设我们将两个数相加 add t2, t0, t1。对于无符号数,结果应该大于或等于任一加数。如果结果 t2 小于第一个加数 t0,则发生了溢出,结果是错误的。判断逻辑可以用以下伪代码表示:
if (t2 < t0) {
// 发生无符号溢出
}
检测有符号溢出:
检测有符号加法溢出稍微复杂一些。假设我们执行 add t2, t0, t1。我们可以通过以下步骤检测:
- 判断结果
t2是否为负数:slt t3, t2, zero(如果t2 < 0,则t3 = 1)。 - 判断加数
t0是否小于另一个加数t1:slt t4, t0, t1(如果t0 < t1,则t4 = 1)。 - 分析溢出条件:当结果
t2为负数,但实际加法(两个正数或一正一负)本应产生非负结果时,或者结果为正数但本应产生负数时,发生溢出。可以证明,当t3(结果符号)与t4(操作数比较)不相等时,发生了溢出。 - 因此,可以通过判断
t3和t4是否相等来检测溢出:bne t3, t4, overflow_handler。
总结
本节课中我们一起学习了RISC-V指令集中处理有符号和无符号操作的关键指令。我们了解了乘法、除法、分支、比较和加载指令的有符号与无符号版本之间的区别,特别是立即数在无符号比较指令中仍会被符号扩展这一细节。最后,我们掌握了如何利用现有的比较和算术指令来检测加法的无符号溢出和有符号溢出。正确理解和使用这些指令是编写可靠、高效底层代码的基础。
092:压缩指令 💾

在本节中,我们将学习RISC-V指令集架构中的压缩指令。压缩指令是一种16位版本的指令,旨在减少程序代码的存储空间,这对于成本敏感且存储资源有限的微控制器应用尤为重要。
概述
到目前为止,我们讨论的都是RISC-V架构的32位指令版本。然而,RISC-V也需要与市场上的微控制器竞争。许多16位微控制器将指令打包在仅16位中,因此所需的代码存储空间大约只有一半。由于代码存储通常是微控制器成本中最大的部分,16位处理器在这方面具有优势。因此,RISC-V以及其他指令集架构(如ARM)都定义了16位的压缩指令版本。这是RISC-V中的一个可选特性,但大多数RISC-V编译器在可能的情况下都会生成32位和16位指令的混合代码,以节省代码空间。
压缩指令的基本概念
核心思想是用16位版本替换常见的整数和浮点指令。这些压缩指令的助记符以字母“C”开头。例如,add指令的压缩版本是c.add,load指令的压缩版本是c.lw。
为了将指令压缩到仅16位(而不是32位),一些压缩指令被限制为仅使用3位寄存器标识符(而不是5位),因此只能选择X8到X15之间的8个寄存器。此外,立即数编码也相当不规则,其范围通常在6到11位之间,具体取决于指令格式中能塞进多少位。操作码部分则只有2位。
压缩指令格式示例
以下是几种不同类型的压缩指令格式:
- R类型指令:使用一个寄存器同时作为源操作数和目的操作数,第二个源操作数来自另一个寄存器,操作由
funct字段指定。- 格式:
c.[op] rd, rs2
- 格式:
- I类型指令:使用一个寄存器同时作为第一个源操作数和目的操作数,并包含一个打包的立即数。立即数由指令中的5位加上额外的2位组成,共7位,并由
funct3字段指定指令类型。- 格式:
c.[op] rd, imm
- 格式:
- 存储、分支、跳转指令:格式类似,例如跳转指令可以携带一个看起来像12位(实际为11位长)的立即数。
- 格式示例:
c.j imm
- 格式示例:
程序示例分析
让我们通过一个示例程序来理解压缩指令的混合使用。
c.li s1, 0 # 将0加载到寄存器s1(i=0),这是压缩指令。
addi t2, zero, 200 # 将200放入t2用于比较。200这个立即数太大,无法放入压缩指令,因此使用常规的addi指令。
loop:
bge s1, t2, done # 如果i (s1) >= 200 (t2),则跳转到done。这个分支目标地址需要较多位,因此使用非压缩指令。
c.lw a1, 0(t0) # 从地址(t0)加载数据到a1。假设t0指向数组起始地址,这是压缩指令。
c.addi a1, a1, 10 # 给a1加10,这是压缩指令。
c.sw a1, 0(t0) # 将a1存回地址(t0),这是压缩指令。
c.addi t0, t0, 4 # 将基地址指针t0增加4,指向下一个字,这是压缩指令。
c.addi s1, s1, 1 # i加1,这是压缩指令。
c.j loop # 跳转回循环开始,这是压缩指令。
done:
在这个程序中,许多指令(如c.li, c.lw, c.addi, c.sw, c.j)都使用了压缩格式,从而有效减少了整体代码大小。而处理大立即数(200)和长距离分支的指令则保留了标准的32位格式。

总结

本节课我们一起学习了RISC-V的压缩指令。我们了解到压缩指令是16位版本的常用指令,旨在减少代码存储空间,这对微控制器应用至关重要。我们探讨了其基本设计思路,例如限制寄存器寻址范围和采用不规则的立即数编码。最后,通过分析一个混合使用压缩与非压缩指令的程序示例,我们直观地看到了压缩指令如何在实际中节省代码空间。理解压缩指令有助于我们编写更高效的代码,并深入理解RISC-V架构为适应不同应用场景所做的设计权衡。
093:浮点指令 🧮

在本节课中,我们将学习RISC-V架构中的浮点指令。这些指令用于处理单精度、双精度和四精度浮点数,是进行科学计算和信号处理等任务的基础。
浮点扩展与寄存器
RISC-V架构提供了三个可选的浮点扩展:F、D和Q模式,分别用于处理单精度(32位)、双精度(64位)和四精度(128位)浮点数。这些扩展定义了32个浮点寄存器。
这些寄存器的宽度取决于所实现的最高精度。例如,如果实现了四精度扩展,那么寄存器宽度就是128位。单精度数字则存储在该128位寄存器的低32位中。
浮点寄存器的命名和用途与常规的整数寄存器类似,也包含临时寄存器、保存寄存器和参数寄存器等类别。
浮点指令格式
浮点指令通常在其助记符后附加一个字母(S、D或Q)来指示操作数的精度。
例如,指令 FADD.S 表示对两个单精度浮点数进行加法运算。
以下是主要的浮点算术运算类别:
- 基本运算:包括加法、减法、除法、平方根、最小值和最大值。
- 融合乘加运算:这是一类非常重要的指令,包括乘加、乘减、负乘加和负乘减。我们稍后会详细讨论。
- 数据移动与转换:用于在不同精度之间移动和转换浮点数,例如将双精度数转换为单精度数。
- 比较与分类:用于比较浮点数的值,以及对数字进行分类和符号注入操作。
融合乘加指令
融合乘加指令(Fused Multiply-Add)是信号处理程序中最关键的指令之一。这类程序通常需要计算一系列乘积的累加和,即先进行乘法运算,再将结果加到累加和中。
指令格式示例如下:
FMADD.S F1, F2, F3, F4
这条指令执行的操作是 F1 = F2 * F3 + F4。
由于这类指令需要指定四个寄存器(两个乘数、一个加数和一个目标寄存器),因此需要一种新的指令格式,称为R4类型。
浮点程序示例
现在,我们通过一个具体的程序示例来理解浮点指令的应用。这个程序的目标是:将一个包含200个元素的浮点数数组 scores 中的每个元素都加上10。
程序的核心逻辑如下:
- 初始化循环计数器
i为0,设置循环上限为200。 - 将整数10转换为单精度浮点数。
- 进入循环:计算数组元素
scores[i]的地址。 - 从内存中加载
scores[i]到浮点寄存器。 - 执行浮点加法:
scores[i] + 10.0。 - 将结果存回内存中原
scores[i]的位置。 - 递增计数器
i,并跳回循环开始处判断是否继续。
以下是关键步骤的伪代码示意:
# 初始化
li s1, 0 # i = 0
li t2, 200 # 循环上限
li t0, 10 # 整数10
fcvt.s.w ft0, t0 # 将整数10转换为单精度浮点数,存入 ft0
loop:
# 检查循环条件 (i >= 200?)
bge s1, t2, done
# 计算 &scores[i] 地址
slli t1, s1, 2 # i * 4 (单精度浮点数占4字节)
add t1, t1, a0 # a0 是数组基地址
# 加载 scores[i]
flw ft1, 0(t1)
# 浮点加法
fadd.s ft1, ft1, ft0
# 存回结果
fsw ft1, 0(t1)
# i++
addi s1, s1, 1
j loop
done:
# 循环结束
指令格式详解
上一节我们介绍了需要四个操作数的融合乘加指令。对于大多数其他浮点指令,它们可以使用我们已经熟悉的R型、I型和S型格式。
但对于融合乘加指令,我们需要新的R4格式。在这种格式中:
opcode和funct3字段标识指令类型。rs1、rs2和rd字段分别指定源寄存器1、源寄存器2和目标寄存器。- 指令编码中高位的一部分被用来存放第三个源寄存器
rs3。 - 还有一个2位的
funct2字段,用于指定具体执行哪一种乘加操作(例如,是乘加还是乘减)。

本节课中,我们一起学习了RISC-V的浮点指令集。我们了解了支持不同精度的F、D、Q扩展,32个浮点寄存器的组织方式,以及包括基本运算、融合乘加、数据转换和比较在内的各类浮点指令。通过一个给数组元素加10的示例程序,我们看到了浮点加载、存储、转换和算术指令在实际代码中的应用。最后,我们特别说明了为支持四操作数指令而引入的R4指令格式。掌握这些知识是进行浮点密集型计算编程的基础。
094:微架构导论 🚀

在本章中,我们将把课程的前后两部分结合起来。在第一部分,我们从0和1开始,逐步学习了逻辑设计,掌握了设计ALU、存储器和多路复用器等组件的方法。随后,我们跳转到高级层面,从软件入手,向下探索了计算机架构,即计算机运行的原生指令。现在,我们将在中间汇合,通过微架构这个主题将这两条线索连接起来。我们将学习如何将硬件模块组合起来,实际构建一个微处理器。
我们将利用在逻辑设计中开发的所有组件,并以一种能够运行机器语言指令的方式将它们连接起来,从而构建我们的RISC微处理器。
性能分析 ⚡
在考察处理器时,一个关键问题是它的速度,因此我们将讨论性能分析。
三种实现方案 🔄
我们将探讨RISC-V处理器的三种不同实现方案,即三种不同的微架构。
- 单周期处理器:所有工作在一个时钟周期内完成。因此,时钟周期必须足够长,以容纳最复杂的指令。
- 多周期处理器:我们将指令分解为多个更简单的步骤。这允许每个步骤运行得更快,并且能让我们复用一些硬件。我们将比较其性能与单周期处理器的差异。
- 流水线处理器:之前我们讨论过洗衣的流水线示例,同样地,我们可以重叠执行多条指令,从而大幅提高运行速度。所有注重性能的现代处理器都采用流水线技术。
最后,我们将概述当前处理器中使用的一些高级微架构技术。
微架构与架构 🧠
如前所述,微架构是在硬件中实现架构的方式。架构是程序员看到的机器视图,而微架构是硬件设计师的领域。
我们将把处理器划分为数据通路和控制器。数据通路包含对数据字进行操作的功能模块。控制器则产生控制信号,在正确的时间指示数据通路做正确的事情。
我们将考察同一架构的三种不同实现(微架构)。它们都将执行相同的功能,都是RISC-V处理器,但在运行速度、硬件成本等方面有不同的权衡。
衡量速度:程序执行时间 ⏱️
衡量处理器速度的最终标准是运行我们感兴趣的程序所需的时间。
程序执行时间由以下公式决定:
执行时间 = 程序指令数 × 每条指令平均所需时钟周期数 × 每个时钟周期的秒数
我们定义:
- CPI:每条指令的周期数。
- 时钟周期:也称为
Tc,是一个时钟周期的秒数。 - IPC:每周期指令数,是CPI的倒数。
我们的挑战是在成本、功耗和性能的约束下,或在成本或功耗约束下获得最佳性能。
指令子集 🎯
为了使构建过程易于处理,我们考虑一个最有趣的RISC-V指令子集:
- R型指令:
add,sub,and,or,slt - 存储器指令:
lw(加载字),sw(存储字) - 分支指令:
beq
我们将构建一个能处理这些指令的处理器。之后,我们会探讨如何添加其他指令,如addi或jal,但一旦掌握了这些基本指令,其余的都非常相似。
架构状态 💾
微架构中的下一个重要概念是架构状态。架构状态决定了理解处理器正在做什么所需知道的一切。
想象一下,如果我们记录了处理器的架构状态,就可以像科幻电影中冷冻大脑一样“冷冻”处理器。即使关闭计算机电源,之后当我们恢复架构状态时,处理器也能像之前一样继续运行。
对于RISC-V处理器,我们需要记录的架构状态包括:
- 32个寄存器的内容
- 程序计数器的值
- 存储器的内容
如果我们恢复这些寄存器、内存内容,并将程序计数器设置回原处,程序就会继续运行。因此,任何RISC-V处理器的实现都必须包含这些架构状态:一个程序计数器、一个包含32个寄存器的寄存器文件,以及一个存储器(我们可能会将其分为指令存储器和数据存储器,以分别存放程序和数据)。
后续内容预告 🛠️
在接下来的章节中,我们将把这些架构状态与算术逻辑单元等组件连接起来,对寄存器进行操作;使用多路复用器来选择所需的结果;并将它们组合起来构建我们的数据通路。然后,我们将创建一个控制器,在正确的时间向数据通路发出控制信号。

总结:本节课我们一起学习了微架构的基本概念,它是连接底层硬件设计与上层指令集的桥梁。我们明确了性能的衡量标准(程序执行时间),并预告了将学习的三种处理器实现方案:单周期、多周期和流水线。我们还定义了构建处理器所需的RISC-V指令子集,并理解了关键的“架构状态”概念,它是处理器能够暂停和恢复运行的依据。最后,我们概述了后续将如何通过组合数据通路和控制器来构建一个完整的微处理器。
095:单周期处理器数据通路之lw指令 🖥️

在本节课中,我们将学习RISC-V单周期处理器的基本构成,并重点讲解如何为lw(加载字)指令实现其数据通路。
概述
处理器通常分为两个主要部分:数据通路和控制器。数据通路主要对32位字(我们计算机的机器字长)进行操作。控制器接收指令,并根据指令内容向数据通路发送控制信号,以指导其执行具体操作。我们将从设计数据通路开始,并逐步构建其处理各种指令的能力。
为了直观地理解数据通路中的操作,我们以一个示例程序作为参考。假设程序从内存地址1000开始,第一条指令是lw(加载字)指令。
示例程序与指令
以下是示例程序中的几条指令:
- 加载字指令:
lw x6, -4(x9)- 这是一条I型指令。
- 操作码
op和功能码funct3共同表明这是一条lw指令。 - 源寄存器是
x9。 - 立即数偏移量是
-4。 - 目标寄存器是
x6。 - 其机器码值为
FFC4A303。
- 后续指令还包括S型的
sw(存储字)指令、R型的or(或)指令以及B型的beq(相等时分支)指令。
一旦数据通路能处理所有这些指令,我们就能运行一个基础程序中的所有关键操作。本节课,我们将从第一条lw指令开始。
数据通路构建步骤
上一节我们介绍了处理器的基本框架和示例指令。本节中,我们来看看如何为lw指令一步步构建数据通路。我们将从处理器的架构状态开始:程序计数器(PC)、指令存储器、寄存器文件和数据存储器。
以下是构建lw指令数据通路的关键步骤:
-
获取指令
首先,将程序计数器(PC)的当前值(例如1000)连接到指令存储器的地址输入端。指令存储器根据该地址读出当前指令的机器码(FFC4A303),并将其置于指令总线上。 -
读取源寄存器
lw指令需要一个源寄存器(x9)。我们从指令的rs1字段(位[19:15])提取寄存器编号9,并将其送入寄存器文件的读地址端口1。寄存器文件输出x9中存储的值(假设为2004)。 -
扩展立即数
lw指令还需要一个立即数偏移量(-4),它编码在指令的位[31:20]中(值为0xFFC)。我们需要一个符号扩展器将这个12位的值扩展为32位。扩展后的32位值ImmExt为0xFFFFFFFC(即-4的32位补码表示)。 -
计算内存地址
接下来,需要将基地址(x9的值)与偏移量相加,得到要访问的内存地址。我们使用算术逻辑单元(ALU)来完成这个加法操作。将寄存器读出的值(2004)作为ALU的A输入,将符号扩展后的立即数(-4)作为B输入。通过设置ALU控制线为010(代表加法操作),ALU输出结果2000(即2004 + (-4))。 -
读取数据内存
将ALU计算出的地址结果(2000)连接到数据存储器的地址输入端。数据存储器从该地址读取数据,并输出到读数据总线(假设该地址存储的值为10)。 -
写回寄存器文件
最后,需要将从内存读取的值(10)写回到目标寄存器x6。我们将数据存储器的输出连接到寄存器文件的“写数据”端口。同时,从指令的rd字段(位[11:7])提取目标寄存器编号6,连接到寄存器文件的“写地址”端口。并激活寄存器写使能信号(RegWrite),这样在时钟周期结束时,值10就会被写入寄存器x6。 -
更新程序计数器
至此,lw指令的执行几乎完成。但还需要为下一条指令做准备:程序计数器(PC)当前仍指向1000。我们需要一个专用的加法器来计算PC + 4(即1004),并将这个值作为PC_next,在下一个时钟上升沿将其载入程序计数器,从而指向下一条指令。
总结

本节课中,我们一起学习了为RISC-V单周期处理器实现lw(加载字)指令数据通路的完整过程。我们逐步构建了从指令获取、寄存器读取、立即数扩展、地址计算、内存访问到结果写回的数据流,并理解了程序计数器如何更新以指向下一条指令。这个过程清晰地展示了数据在处理器各组件间的流动路径,是理解处理器如何工作的基础。在后续课程中,我们将以此为基础,扩展数据通路以支持其他类型的指令。
096:单周期处理器数据通路 - 其他指令实现 🧠

在本节中,我们将继续构建单周期处理器的数据通路,重点实现除加载字指令外的其他核心指令,包括存储字、R型运算和分支指令。我们将看到如何通过复用现有硬件并添加少量控制信号来支持这些功能。
概述
到目前为止,我们已经实现了加载字指令。本节我们将依次实现存储字指令、R型指令(如OR运算)和分支指令(如BEQ)。我们将看到,数据通路的大部分硬件可以复用,关键在于添加正确的控制信号来指导数据流向和运算操作。
存储字指令的实现 💾
上一节我们介绍了加载字指令,本节我们来看看如何实现存储字指令。
在我们的示例程序中,地址1004处有一条存储字指令 sw x6, 8(x9)。这是一条S型指令,其结构与I型指令类似,但立即数字段位于指令的不同位置:低5位在指令的底部,高7位在指令的顶部。操作码表明这是一条存储指令,源寄存器x9作为基地址,源寄存器x6包含要存储的值。
机器码为 0x0064a423。硬件操作流程如下:
- 程序计数器指向1004,从指令存储器中读取该指令。
- 指令被送入寄存器文件和符号扩展器。
- 寄存器文件根据指令中的
rs1字段(值为9)读取寄存器x9的内容(假设为2004),并根据rs2字段(值为6)读取寄存器x6的内容(假设为10)。 - 符号扩展器需要从指令的正确位置提取立即数(值为8)。我们引入一个名为
ImmSrc的控制信号来指示符号扩展器应提取哪种类型的立即数。对于S型指令,ImmSrc设为1。 - 源A(寄存器x9的值)和源B(符号扩展后的立即数)被送入ALU,ALU执行加法运算:
2004 + 8 = 0x200C。 - 计算结果作为地址送入数据存储器。从寄存器文件读取的值(10)作为写入数据送入数据存储器。
- 我们引入一个新的控制信号
MemWrite。当其为真时,数据存储器将在时钟上升沿将数据(10)写入指定地址(0x200C)。 - 数据存储器也会输出读取的数据,但由于当前是写操作,我们忽略该输出。通过将寄存器文件的写使能信号
RegWrite设为0,我们确保不会将无用数据写回寄存器。
以下是实现存储字指令所需的新增硬件和控制信号:
- 修改符号扩展器,使其能根据指令类型从不同位置提取立即数。
- 新增控制信号
ImmSrc,用于指导符号扩展器。 - 新增控制信号
MemWrite,用于控制数据存储器的写入操作。 - 需要从寄存器文件读取第二个源操作数的值。
与此同时,程序计数器加4的逻辑照常工作,因此下一条指令地址变为1008。
立即数格式详解 🔢
在深入下一条指令前,让我们进一步了解立即数格式。目前我们已涉及两种立即数类型:
- I型立即数:当
ImmSrc = 00时,从指令的[31:20]位提取12位立即数,并进行符号扩展。 - S型立即数:当
ImmSrc = 01时,立即数由指令的[11:7](低5位)和[31:25](高7位)两部分拼接而成,然后进行符号扩展。
R型指令的实现 ⚙️
现在程序计数器指向1008,该地址的指令是 or x4, x5, x6。这是一条R型指令,其操作码标识R型指令,funct3 和 funct7 字段共同指定具体的OR操作。源寄存器是x5和x6,目的寄存器是x4。
所有R型指令的硬件需求相同,区别仅在于 funct3 和 funct7 字段,这些字段将决定ALU执行何种运算。
硬件操作流程如下:
- 从指令存储器读取指令
0x0062e233(OR指令)。 - 指令被送入寄存器文件,读取寄存器x5(假设值为6)和寄存器x6(假设值为10)的内容。
- 对于R型指令,ALU的第二个操作数应来自寄存器文件,而非立即数。因此,我们在ALU的B输入前添加一个多路选择器,由控制信号
ALUSrc控制。ALUSrc=0时选择寄存器文件输出,ALUSrc=1时选择立即数。此处设为0。 - ALU根据
ALUControl信号(对于OR操作,设为01)执行运算:6 OR 10 = 14(二进制1110)。 - 运算结果需要写回寄存器文件。在加载字指令中,写入数据来自数据存储器。现在,我们需要将ALU的结果写入寄存器。因此,我们在寄存器文件的写入数据输入前添加另一个多路选择器,由控制信号
ResultSrc控制。ResultSrc=0时选择ALU结果,ResultSrc=1时选择数据存储器输出。此处设为0。 - 将
RegWrite信号置为真,将值14写入目的寄存器x4。
程序计数器同样加4,准备执行下一条指令。
分支指令的实现 🔀
最后,我们考虑实现相等则分支指令。为此,我们需要计算分支的目标地址:PC目标地址 = 当前PC值 + 偏移量(立即数)。
假设在地址 0x100C 处有一条指令 beq x4, x4, label7(label7指向程序开头)。这是一条B型指令,rs1 和 rs2 都是x4,操作码和 funct3 表明是BEQ指令。立即数字段编码了分支目标偏移量,其位分布较为特殊。
硬件操作流程如下:
- 从指令存储器读取BEQ指令。
- 从寄存器文件读取寄存器x4的值两次(假设均为14)。
- 通过减法比较这两个值是否相等。设置
ALUSrc=0使两个操作数均来自寄存器文件,设置ALUControl=110(减法)。14 - 14 = 0。 - ALU会生成一个名为
Zero的标志位。当减法结果为0时,Zero=1,表示两个寄存器值相等,应进行分支。 - 接下来计算分支目标地址。B型指令的立即数字段经过特定编码(例如,可能代表-12)。符号扩展器需要支持这种新格式。我们扩展
ImmSrc信号为2位,并定义ImmSrc=10对应B型立即数。符号扩展器需对立即数位进行重组和符号扩展。 - 我们添加一个专用的加法器来计算目标地址:
PC + 符号扩展后的立即数。例如,0x100C + (-12) = 0x1000。 - 现在,下一条PC地址有两种可能:常规的
PC+4或分支目标地址PC target。我们添加一个由PCSrc信号控制的多路选择器。当需要分支时(Zero=1),PCSrc=1,选择PC target作为下一个PC值。本例中,选择0x1000,程序跳转回开始处。
总结
本节课中,我们一起学习了如何扩展单周期处理器的数据通路以支持存储字、R型运算和分支指令。关键点在于:
- 通过复用ALU、寄存器文件等核心部件,并添加关键的多路选择器(如
ALUSrc,ResultSrc,PCSrc)来控制数据流向。 - 引入并扩展控制信号(如
ImmSrc,MemWrite,ALUControl)来精确指导不同指令的执行。 - 符号扩展器需要支持I型、S型和B型三种立即数格式。
- 通过
Zero标志位和PCSrc信号共同实现条件分支逻辑。

现在,我们的数据通路已能处理加载字、存储字、R型运算和分支指令这些基本指令类型,构成了一个功能完整的处理器核心。
097:单周期处理器控制器设计 🎛️

在本节中,我们将为单周期处理器设计控制器。控制器负责解析当前执行的指令,并生成正确的控制信号来驱动数据通路中的各个组件。
数据通路回顾
上一节我们介绍了处理器的数据通路。现在,我们来看看如何控制它。
数据通路始于架构状态:程序计数器(PC)、寄存器文件和指令/数据存储器。我们添加了ALU进行运算、符号扩展器处理立即数、几个加法器(用于PC+4和PC+分支偏移量),以及一些多路选择器来选择不同的输入。这就构成了黑色的数据通路部分。
数据通路接收来自控制器的蓝色控制信号,以告知其执行何种操作。例如,数据通路需要存储器的写使能信号、ALU的控制信号,以及符号扩展器和各个多路选择器的选择信号。控制器的工作就是基于当前执行的指令,产生相应的控制信号。
控制器概览
从高层次看,控制器接收指令作为输入。具体来说,它需要指令的字段:操作码(opcode)、funct3字段,以及funct7字段的至少第5位(用于区分加法或减法)。它还需要来自ALU的零标志(Zero Flag),以判断分支是否应该执行。

控制器的输出包括:两个存储器的写使能信号(MemWrite 和 RegWrite)、ALU控制信号(ALUControl),以及多个多路选择器的选择信号(PCSrc、RegSrc、ALUSrc、ImmSrc),用于指导数据通路的其他部分。

控制器分解
我们将这个控制器分解为三个更底层的模块:
- 主解码器:仅查看操作码,判断指令类型(R型、I型、S型等),并生成大部分控制信号。
- ALU解码器:查看ALU操作码(
ALUOp)和R型指令的功能字段,生成适当的ALUControl信号。 - PC源逻辑:在分支指令且比较结果为真(相等)时,告知处理器应执行分支。
接下来,我们将分别设计这三个模块。
主解码器设计
主解码器的输入是指令的操作码字段(op),输出是所有不同的控制信号。
以下是不同类型指令对应的控制信号值:
-
取字指令(Load Word)
RegWrite= 1 (写寄存器文件)ImmSrc= 00 (I型立即数)ALUSrc= 1 (ALU第二个操作数来自立即数)MemWrite= 0 (不写数据存储器)ResultSrc= 1 (结果来自数据存储器读取的值)Branch= 0 (非分支指令)ALUOp= 00 (指示ALU执行加法)
-
存字指令(Store Word)
RegWrite= 0 (不写寄存器文件)ImmSrc= 01 (S型立即数)ALUSrc= 1 (ALU第二个操作数来自立即数)MemWrite= 1 (写数据存储器)ResultSrc= X (不关心,因为结果被丢弃)Branch= 0 (非分支指令)ALUOp= 00 (指示ALU执行加法)
-
R型指令(R-Type)
RegWrite= 1 (写寄存器文件)ImmSrc= X (不使用立即数)ALUSrc= 0 (ALU第二个操作数来自寄存器文件)MemWrite= 0 (不写数据存储器)ResultSrc= 0 (结果来自ALU)Branch= 0 (非分支指令)ALUOp= 10 (指示ALU解码器查看功能字段)
-
分支指令(Branch)
RegWrite= 0 (不写寄存器文件)ImmSrc= 10 (B型立即数)ALUSrc= 0 (ALU第二个操作数来自寄存器文件)MemWrite= 0 (不写数据存储器)ResultSrc= X (不关心)Branch= 1 (是分支指令)ALUOp= 01 (指示ALU执行减法以进行比较)
ALU解码器设计
在深入ALU解码器之前,我们先回顾一下ALU的功能。ALU控制信号(ALUControl)与操作的对应关系如下:
000-> 加法(ADD)001-> 减法(SUB)010-> 与(AND)011-> 或(OR)101-> 小于则置位(SLT)
ALU内部包含一个加法器、一个与门和一个或门。通过控制信号选择不同的运算路径。例如,减法通过将操作数B取反并设置进位输入为1来实现(A + ~B + 1)。小于则置位操作通过执行减法,然后检查结果的符号位(最高位)来实现,若为负则输出1,否则输出0。
现在来看ALU解码器。它接收来自主解码器的ALUOp信号(指示应执行加法、减法还是查看功能字段)以及指令的功能字段(funct3和funct7[5]),并产生正确的ALUControl信号。
其逻辑如下:
- 当
ALUOp= 00 (来自加载/存储指令):总是执行加法,ALUControl= 000。 - 当
ALUOp= 01 (来自分支指令):总是执行减法,ALUControl= 001。 - 当
ALUOp= 10 (来自R型指令):需要查看功能字段。- 如果
funct3= 000 且funct7[5]= 1,则为减法,ALUControl= 001。 - 否则(对于加法或其他情况),
ALUControl= 000。 - 如果
funct3= 010,则为小于则置位,ALUControl= 101。 - 如果
funct3= 110,则为或,ALUControl= 011。 - 如果
funct3= 111,则为与,ALUControl= 010。
- 如果
ALU解码器本质上是一个组合逻辑块,根据输入(ALUOp, funct3, funct7[5])计算输出(ALUControl)。可以使用任何组合逻辑设计技术来实现它。
运行示例:AND指令
让我们以AND指令 and x5, x6, x7 为例,看看控制器和数据通路如何协同工作。
- 取指:程序计数器(PC)指向指令地址,从指令存储器中取出AND指令(操作码为0x51,表示R型指令)。
- 解码:指令被送到控制单元。主解码器根据操作码产生控制信号:
RegWrite=1,ALUSrc=0,MemWrite=0,ResultSrc=0,Branch=0,ALUOp=10。 - 读寄存器:同时,指令中的寄存器地址x6和x7被送到寄存器文件,读出这两个寄存器的值。
- 执行:
- x6的值直接送到ALU的A输入端。
- x7的值送到“源B多路选择器”。由于
ALUSrc=0,该多路选择器选择来自寄存器文件的x7值(而非立即数)作为ALU的B输入。 - ALU解码器收到
ALUOp=10和指令的funct3字段(表示AND),产生ALUControl=010。 - ALU执行x6 AND x7的运算,得到结果。
- 写回:
- 结果被送到“结果多路选择器”。由于
ResultSrc=0,该多路选择器选择ALU的结果。 - 该结果被送回寄存器文件。由于
RegWrite=1,结果被写入目标寄存器x5。
- 结果被送到“结果多路选择器”。由于
- 更新PC:
MemWrite=0,所以不写数据存储器。Branch=0,所以不执行分支。PC源逻辑选择PC+4作为下一个PC值。

总结

本节课中,我们一起学习了单周期处理器控制器的设计。我们首先回顾了数据通路对控制信号的需求,然后将控制器分解为主解码器、ALU解码器和PC源逻辑三个模块。我们详细设计了主解码器针对不同指令类型的控制信号真值表,解释了ALU解码器根据ALUOp和功能字段生成ALUControl信号的逻辑,并通过AND指令的示例完整演示了控制器与数据通路在单周期内的协同工作流程。控制器是处理器的“大脑”,它确保每一条指令都能正确地在数据通路上执行。
098:单周期处理器扩展 🚀

在本节课中,我们将扩展单周期处理器,使其能够处理更多的RISC-V指令。我们将重点学习如何添加“立即数加法”和“跳转并链接”这两条新指令。通过这个过程,你将理解扩展处理器功能的核心方法:分析新指令的功能,找出与现有数据通路和控制器的差异,并进行相应的微小修改。
立即数加法指令
上一节我们介绍了处理器如何处理基本的R、I、S、B型指令。本节中,我们来看看如何添加“立即数加法”指令。
“立即数加法”指令类似于R型加法指令,但其第二个操作数来自指令中的立即数字段,而非寄存器文件。因此,我们可以复用大部分现有的数据通路。
主要区别在于,ALU的第二个输入源需要选择来自立即数扩展器,而非寄存器文件。这要求我们修改控制信号 ALUSrc 和 ImmSrc。
以下是主译码器真值表的更新。黑色部分是我们已为现有指令构建的逻辑,现在需要为“立即数加法”指令添加新的一行。
- Opcode:
0010011(I型指令) - RegWrite:
1(需要将结果写回寄存器文件) - MemWrite:
0(不访问内存) - ResultSrc:
0(结果来自ALU) - Branch:
0(非分支指令) - ALUOp:
10(需要查看funct3字段以确定具体操作) - ALUSrc:
1(关键区别:ALU第二个操作数选择立即数) - ImmSrc:
00(关键区别:将立即数按I型指令进行符号扩展)
现在,让我们在数据通路中追踪“立即数加法”指令的执行流程:
- 程序计数器指向指令。
- 控制单元根据指令生成控制信号。
- 寄存器文件读取源操作数1 (
rs1)。 - 立即数字段被送至符号扩展器,按I型格式扩展。
- 由于
ALUSrc = 1,多路选择器选择扩展后的立即数作为ALU的第二个输入。 - 由于
ALUOp = 10,ALU控制单元根据指令的funct3字段生成控制信号000(代表加法)。 - ALU执行
rs1 + 立即数运算。 - 由于
ResultSrc = 0,结果多路选择器选择ALU的输出。 - 由于
RegWrite = 1,运算结果被写回目标寄存器 (rd)。 MemWrite和Branch信号均为0,确保不进行内存写入或分支跳转。
总结“立即数加法”指令的添加过程:我们只需在主译码器中添加一行,其控制信号与R型指令行基本相同,仅需修改 ALUSrc 和 ImmSrc 两个信号,以选择立即数作为第二个操作数。
跳转并链接指令

处理完相对简单的“立即数加法”后,我们来看看一个更具挑战性的指令:“跳转并链接”。这条指令与我们目前处理的指令差异较大,但仍有相似之处可循。
它与“不相等则分支”指令类似,但存在三个关键区别:
- 跳转总是发生,而非条件跳转。因此,我们需要确保
PCSrc信号在跳转指令时为1。 - 立即数的格式不同。
jal是J型指令,我们需要增强符号扩展器以处理这种新的立即数格式。 jal指令需要计算PC + 4(返回地址),并将其存入目标寄存器 (rd),以实现链接功能。我们已有硬件计算PC + 4,但需要将其作为新的输入提供给结果多路选择器。
以下是实现“跳转并链接”指令所需的修改:
1. 修改PC源逻辑
我们需要确保在执行跳转指令时 PCSrc = 1。在原分支逻辑(Branch & Zero)的基础上,增加一个或门,当指令为跳转时,也输出 PCSrc = 1。
公式表示为:PCSrc = (Branch & Zero) | Jump
2. 扩展立即数生成单元
为符号扩展器添加处理J型立即数的功能。当 ImmSrc = 11 时,扩展器需按J型指令格式重组立即数字段:
- 将指令中的
imm[20|10:1|11|19:12]位重新排列。 - 最低位补0(因为指令地址总是字对齐的)。
- 进行符号扩展。
3. 更新主译码器
为操作码 1101111 (对应 jal) 添加新的一行。
- RegWrite:
1(需要将返回地址PC+4写入rd) - ImmSrc:
11(按J型指令处理立即数) - ALUSrc:
X(无关项,因为ALU不参与此指令的核心操作) - MemWrite:
0 - ResultSrc:
10(关键区别:结果选择PC + 4) - Branch:
0 - ALUOp:
XX(无关项) - Jump:
1(新增信号,用于控制PC源逻辑中的或门)
4. 修改结果多路选择器
为结果多路选择器增加一个新的输入端口,其输入为 PC + 4。当 ResultSrc = 10 时,选择此值作为结果写回寄存器文件。
将这些修改整合到数据通路中后,jal 指令的执行流程如下:
- 控制单元识别出
jal指令,生成相应的控制信号,特别是Jump = 1和ResultSrc = 10。 Jump信号使PCSrc = 1,下一条指令的地址被更新为跳转目标地址(由扩展后的J型立即数与当前PC计算得出)。- 同时,
PC + 4的值被结果多路选择器选中。 - 由于
RegWrite = 1,PC + 4这个返回地址被写入目标寄存器rd。
总结“跳转并链接”指令的添加,我们主要做了三处修改:
- 在
PCSrc生成逻辑中增加了一个或门,以支持无条件跳转。 - 扩展了立即数生成单元,支持J型立即数格式。
- 为结果多路选择器增加了
PC + 4输入,用于提供返回地址。
课程总结 🎯
本节课中,我们一起学习了如何扩展单周期处理器的指令集。我们通过添加“立即数加法”和“跳转并链接”两条指令,演示了处理器扩展的通用方法:
- 理解指令:明确新指令要完成的功能。
- 对比现有通路:找出新指令与处理器已有功能之间的异同。
- 最小化修改:通常只需在控制逻辑(真值表)中添加新行,并可能对数据通路进行微小调整(如增加多路选择器输入、扩展功能单元)。

核心在于,许多新指令可以共享大部分已有的硬件资源,只需通过控制信号进行不同的配置。这种模块化设计思想是计算机架构的核心之一。
099:单周期处理器性能分析 🚀

在本节中,我们将分析单周期处理器的性能。我们已经构建了一个单周期处理器,现在来看看它能以多快的速度运行程序。
概述
程序执行时间取决于三个因素:程序中的指令数量、执行每条指令所需的平均周期数,以及每个周期的时长。其关系可以用以下公式表示:
程序执行时间 = 指令数 × CPI × 周期时间
对于单周期处理器,每条指令恰好需要一个周期,因此CPI(每条指令的周期数)为1。本节的核心任务是确定处理器的周期时间,即完成最复杂指令所需的最长时间路径。
确定关键路径
周期时间由处理器中最长的数据路径决定,这条路径被称为关键路径。在单周期处理器中,lw(加载字)指令通常涉及最多的组件,因此其路径可能是最长的。
以下是lw指令执行过程中的两个主要潜在关键路径:
路径一:寄存器文件读取路径(蓝色路径)
此路径从程序计数器(PC)开始,依次经过以下组件:
- 从指令存储器中读取指令。
- 从指令中解析出寄存器地址,并从寄存器文件中读取源操作数。
- 将读取的数据送入ALU进行计算。
- 将ALU结果作为地址,访问数据存储器以读取数据。
- 将读取的数据通过结果选择器(Result Mux)送回。
- 在时钟周期结束前,将数据写回寄存器文件。
路径二:立即数生成路径(灰色路径)
此路径与路径一的前半部分不同:
- 同样从指令存储器中读取指令。
- 指令送入控制器,解码出立即数类型(IMM Src)。
- 根据类型,由立即数扩展器(Immediate Extender)生成立即数。
- 该立即数通过源B选择器(SrcB Mux)送入ALU。
- 后续步骤(ALU计算、访问数据存储器、结果选择、写回寄存器)与路径一相同。
关键路径是这两条路径中较长的那一条。它决定了处理器的最小周期时间。
关键路径延时计算
综合来看,单周期处理器的关键路径延时是以下各部分延时之和:
- 程序计数器(PC)的传播时间。
- 访问指令存储器的时间。
- (取寄存器文件读取时间)与(指令解码、立即数扩展及通过源B选择器的时间)两者中的较大值。
- ALU的计算时间。
- 访问数据存储器的时间。
- 结果选择器(Result Mux)的传播时间。
- 寄存器文件的写入建立时间。
通常,存储器访问、ALU操作和寄存器文件访问是延时最大的部分。
假设路径一(寄存器文件读取)更长,那么关键路径可以概括为:从程序计数器开始,依次经过指令存储器、寄存器文件、ALU、数据存储器、结果选择器,最后准备写回寄存器文件。
性能实例分析
假设我们使用以下组件延时(单位:皮秒,ps)进行分析:
| 组件 | 延时 (ps) |
|---|---|
| 程序计数器 (PC) | 40 |
| 指令存储器 (IMem) | 200 |
| 数据存储器 (DMem) | 200 |
| 寄存器文件读取 (Reg File Read) | 100 |
| 算术逻辑单元 (ALU) | 120 |
| 结果选择器 (Result Mux) | 30 |
| 寄存器文件写入建立 (Reg File Setup) | 60 |
根据关键路径,总周期时间计算如下:
周期时间 = PC + IMem + RegRead + ALU + DMem + ResultMux + RegSetup
周期时间 = 40 + 200 + 100 + 120 + 200 + 30 + 60 = 750 ps
现在,假设运行一个包含1000亿条指令的程序(即 \(10^{11}\) 条指令)。对于单周期处理器,CPI = 1。
程序总执行时间为:
执行时间 = 指令数 × CPI × 周期时间
执行时间 = $10^{11}$ × 1 × (750 × $10^{-12}$ 秒) = 75 秒
因此,我们的单周期处理器需要75秒来完成这个程序。
总结
本节课中,我们一起学习了如何分析单周期处理器的性能。我们了解到:
- 程序执行时间由指令数、CPI和周期时间共同决定。
- 对于单周期处理器,CPI恒为1,因此周期时间是决定性能的关键。
- 周期时间由处理器的关键路径决定,通常是执行
lw这类复杂指令所需的最长数据通路。 - 通过累加关键路径上各逻辑元件的延时,我们可以估算出处理器的周期时间和整体性能。在给定的例子中,周期时间为750皮秒,执行一个千亿条指令的程序需要75秒。

理解关键路径和延时分析是评估和优化处理器设计的基础。
100:单周期处理器测试平台 🧪
在本节中,我们将学习单周期RISC-V处理器的顶层模块设计,并了解如何通过一个测试程序来验证其功能正确性。
上一节我们介绍了处理器的核心组件,本节中我们来看看如何将它们集成并测试。
系统总体设计

我们的系统由一个RISC-V处理器构成,该处理器包含数据通路和控制器。通常,存储器位于处理器核心外部,因此我们将指令存储器和数据存储器独立出来。
数据通路将程序计数器发送给指令存储器,以获取指令。指令存储器通过其读数据端口返回指令,该指令会同时发送给数据通路和控制器。
为了访问数据存储器,数据通路会将ALU结果信号作为数据地址发送到数据存储器的地址端口,并将正确的数据值发送到数据存储器的写数据端口。数据通路将从存储器的读数据端口接收读数据信号。数据存储器还有一个写使能信号,称为 MemWrite,由控制器产生。
控制器接收指令,并基于指令产生一系列控制信号。当ALU结果为零时,数据通路会向控制器回复 Zero 信号。数据通路和控制器都接收时钟信号和复位信号,复位信号用于将程序计数器初始化为起始地址,以便逐条执行指令。
测试策略
现在,让我们切换到测试平台。测试处理器有几种策略:一种是系统性的方法,应用大量测试来单独检查每种指令;另一种是更整体但临时的测试方法,即运行一个能执行所有指令的程序,然后检查程序最终是否产生正确的结果。
对于这个简单的处理器,我们将采用第二种方法,即运行一个测试程序。
以下是该测试程序的流程和预期结果:
addi x2, x0, 5:将值5存入寄存器x2。addi x3, x0, 12:将值12存入寄存器x3。addi x7, x3, -9:计算12 - 9,将结果3存入寄存器x7。这测试了负数的加法。or x4, x7, x2:计算3 OR 5(二进制0011 OR 0101),将结果7存入寄存器x4。and x5, x3, x4:计算12 AND 7(二进制1100 AND 0111),将结果4存入寄存器x5。add x5, x5, x4:计算4 + 7,将结果11存入寄存器x5。beq x5, x7, end:比较x5(11)和x7(3),它们不相等,因此分支不执行。这测试了条件不满足时的分支。slt x4, x3, x4:判断x3(12)是否小于x4(7),结果为假(0),将0存入寄存器x4。beq x4, x0, around:判断x4(0)是否等于x0(0),结果为真,因此分支执行,跳转到around标签处。这测试了条件满足时的分支,并跳过了下一条可能破坏x5值的addi指令。slt x4, x7, x2:在around标签处,判断x7(3)是否小于x2(5),结果为真(1),将1存入寄存器x4。这测试了slt指令能产生0和1两种结果。add x7, x4, x5:计算1 + 11,将结果12存入寄存器x7。sub x7, x7, x2:计算12 - 5,将结果7存入寄存器x7。sw x7, 68(x3):将x7的值(7)存储到内存地址x3 + 68处。x3为12,所以地址是80。这测试了向内存写入数据。lw x2, 80(x0):从内存地址x0 + 80(即地址80)加载数据到x2。x2现在应该得到值7。这测试了从内存读取数据。jal x3, end:执行跳转并链接指令,跳转到end标签。这测试了跳转指令,并跳过了下一条可能破坏x2的指令。同时,返回地址(下一条指令的地址,假设为十进制64)应被存入x3。add x2, x2, x3:在end标签处,计算x2(7)加上x3(返回地址64),将结果71存入x2。sw x2, 84(x0):最后,将x2的值(71)存储到内存地址84处。
如果处理器设计有任何错误,程序执行过程中就可能出错,最终导致向地址 84 写入的值不是 71。因此,通过监控内存地址 84 的最终值,我们可以判断处理器是否工作正常。
生成机器码
接下来,我们需要将这个RISC-V汇编程序翻译成机器语言。这是一个繁琐的过程,最好使用汇编器来完成。最终,我们得到了与18条汇编指令对应的18条机器语言指令。

本节课中我们一起学习了单周期RISC-V处理器的顶层模块连接方式,并通过一个精心设计的测试程序来验证其功能。测试程序涵盖了算术、逻辑、访存、分支和跳转等关键指令,通过检查程序最终向特定内存地址写入的预期值(71),可以有效地判断处理器设计的正确性。
101:单周期处理器SystemVerilog实现 🖥️

在本节中,我们将学习如何用SystemVerilog硬件描述语言实现一个RISC-V单周期处理器。我们将从顶层模块开始,逐步深入到数据通路和控制器的各个子模块,并了解如何编写测试平台来验证设计的正确性。
概述
本教程将详细讲解一个RISC-V单周期处理器的SystemVerilog实现。该处理器实现了包括加载、存储、算术运算、逻辑运算、分支和跳转在内的基本指令集。我们将遵循自顶向下的方法,首先介绍测试平台和顶层模块,然后深入到处理器、控制器和数据通路的内部结构。

测试平台


测试平台用于实例化被测设备,提供时钟和复位信号,并检查运行结果是否正确。
以下是测试平台的核心部分:


// 实例化被测设备(整个处理器)
riscv_singlecycle dut (.clk(clk), .reset(reset), ...);


// 生成周期为10个时间单位的时钟
always #5 clk = ~clk;

// 应用持续22个时间单位的复位信号
initial begin
reset = 1;
#22;
reset = 0;
end
为了验证结果,测试平台在每个时钟的下降沿检查内存写操作。如果处理器将数值71写入地址84,则表明仿真成功。如果发生其他内存写操作,则忽略或报告仿真失败。

always @(negedge clk) begin
if (memwrite && dataadr === 32'h54 && writedata === 32'h47) begin
$display("Simulation succeeded");
$stop;
end else if (memwrite && dataadr !== 32'h50) begin
$display("Simulation failed");
$stop;
end
end
顶层模块
上一节我们介绍了测试平台,本节中我们来看看处理器的顶层模块。顶层模块包含处理器、指令存储器和数据存储器。

这些模块通过以下信号连接:
- 程序计数器(PC)从处理器输出到指令存储器。
- 数据存储器接收来自处理器的内存写使能、数据地址和写入数据信号,并向处理器返回读取数据。
module top(input logic clk, reset, output logic [31:0] writedata, dataadr, output logic memwrite);
logic [31:0] pc, instr, readdata;
// 实例化处理器
riscv_singlecycle cpu(clk, reset, pc, instr, memwrite, dataadr, writedata, readdata);
// 实例化指令存储器
imem imem(pc[7:2], instr);
// 实例化数据存储器
dmem dmem(clk, memwrite, dataadr, writedata, readdata);
endmodule

数据存储器

数据存储器是一个同步读写存储器,包含64个32位字。


以下是其读写操作:
- 读操作:
assign readdata = ram[address]; - 写操作:在时钟上升沿,如果写使能信号为真,则执行写入。
if (we) ram[address] <= writedata;

指令存储器

指令存储器包含64个32位字,并在初始化时载入一个由18条机器语言指令组成的程序。
其读操作使用相同的惯用语法:assign instr = ram[address];
处理器模块


现在让我们进入处理器模块内部。处理器由数据通路和控制器两部分组成。
数据通路接收时钟、复位信号以及来自控制器的一系列控制信号,并产生零标志位输出。控制器接收指令和零标志位,并产生控制数据通路所需的控制信号。

在Verilog中,必须声明连接模块之间的所有内部信号。如果未声明,Verilog会默认其为单比特信号,这可能导致多比特信号(如立即数源或ALU控制信号)行为错误。

module riscv_singlecycle(input logic clk, reset,
output logic [31:0] pc,
input logic [31:0] instr,
output logic memwrite,
output logic [31:0] aluout, writedata,
input logic [31:0] readdata);
// 控制器产生的控制信号
logic [1:0] immsrc, resultsrc;
logic alusrc, regwrite, zero;
logic [1:0] pcsrc;
logic [2:0] alucontrol;
// 数据通路产生的零标志位
// ... 控制器和数据通路实例化 ...
endmodule
控制器
控制器由主译码器、ALU译码器和用于计算PC源(程序计数器下一个值来源)的逻辑组成。
以下是控制器的主要组成部分:
- 主译码器:接收7位操作码(op)字段,并产生大部分控制信号。
- ALU译码器:接收ALU操作码(ALUOp)和指令中的几位字段,产生3位ALU控制信号。
- PC源逻辑:在发生分支(且条件为零)或执行跳转指令时,决定如何更新程序计数器。
主译码器

主译码器采用查找表(真值表)的方式实现。在SystemVerilog中,通常使用case语句来描述。
为了提高代码可读性和可维护性,我们定义一个11位的总线controls来集中存放所有控制信号。在case语句中为controls赋值,然后再将其拆分到各个独立的控制信号输出。

例如,对于R型指令(操作码0110011),控制信号组合为:
regwrite = 1(寄存器写使能)immsrc = xx(立即数源,不关心)alusrc = 0(ALU源B选择寄存器文件)branch = 0(非分支指令)memwrite = 0(不写内存)resultsrc = 00(结果来自ALU)jump = 0(非跳转指令)aluop = 10(需查看指令功能字段)


always_comb begin
case(op)
7'b0110011: controls = 11'b1_xx_0_0_0_00_0_10; // R-type
// ... 其他指令 ...
default: controls = 11'bxxxxxxxxxxx; // 非法指令
endcase
end
// 拆分控制信号
assign {regwrite, immsrc, alusrc, branch, memwrite, resultsrc, jump, aluop} = controls;


ALU译码器
ALU译码器也以真值表形式描述。它根据ALUOp信号和指令中的功能字段(funct3和funct7),产生具体的ALUControl信号。


数据通路
数据通路包含实现处理器功能所需的所有硬件组件。
以下是数据通路中的关键组件及其连接:
- 程序计数器(PC)逻辑:包含一个PC寄存器、一个计算PC+4的加法器、一个计算分支目标地址的加法器,以及一个多路选择器用于在PC+4、分支目标或跳转目标之间选择下一个PC值。选择由
pcsrc控制信号控制。 - 寄存器文件:包含32个32位寄存器。它有两个读端口(根据地址
ra1和ra2输出数据rd1和rd2)和一个写端口(在时钟上升沿,若写使能we3为真,则将数据wd3写入地址wa3)。寄存器x0被硬连线为零。 - 立即数扩展器:根据
immsrc控制信号,将指令中的立即数字段符号扩展为32位。支持I型、S型、B型和U型指令的立即数格式。 - 算术逻辑单元(ALU):接收两个32位输入
srcA和srcB,根据alucontrol信号执行指定操作,输出32位结果aluresult和一个zero标志位(当结果为0时置位)。输入srcB由一个多路选择器提供,该选择器在寄存器文件读出的数据rd2和扩展后的立即数之间选择,由alusrc信号控制。 - 结果选择多路器:根据
resultsrc信号,选择将ALU结果、数据存储器读取的数据或PC+4作为最终结果写回寄存器文件。

立即数扩展逻辑
立即数扩展逻辑根据immsrc信号,将指令中的不同位段组合并符号扩展为32位立即数。

always_comb begin
case(immsrc)
2'b00: immext = {{20{instr[31]}}, instr[31:20]}; // I-type
2'b01: immext = {{20{instr[31]}}, instr[31:25], instr[11:7]}; // S-type
2'b10: immext = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0}; // B-type
2'b11: immext = {instr[31:12], 12'b0}; // U-type
endcase
end


寄存器文件


寄存器文件是一个标准的三端口(两读一写)同步存储器。

其关键操作如下:
- 写操作:在时钟上升沿,如果写使能
we3为真且目标地址wa3非零,则将数据wd3写入wa3地址对应的寄存器。向x0(地址0)的写入被忽略。 - 读操作:组合逻辑输出。如果读地址为0,则输出零;否则输出对应寄存器的值。
// 寄存器数组
logic [31:0] rf [31:0];
// 写操作
always_ff @(posedge clk) begin
if (we3 && wa3 != 0) rf[wa3] <= wd3;
end
// 读操作
assign rd1 = (ra1 != 0) ? rf[ra1] : 0;
assign rd2 = (ra2 != 0) ? rf[ra2] : 0;
其他基本组件

最后,我们回顾一下数据通路中使用的基本组件的Verilog实现惯用法。


以下是常用组件的代码示例:
- 加法器:
assign y = a + b; - 可复位的触发器:
always_ff @(posedge clk, posedge reset) begin if (reset) q <= 0; else q <= d; end - 2输入多路选择器:
assign y = s ? d1 : d0;
总结

本节课中我们一起学习了如何用SystemVerilog实现一个RISC-V单周期处理器。我们从测试平台和顶层模块开始,逐步深入到处理器内部的控制器和数据通路。我们看到,处理器的硬件结构图可以直接对应到SystemVerilog代码中的各个模块和连接。实现的关键在于理解每个硬件组件的功能,并使用正确的Verilog惯用法来描述它们,例如使用case语句实现译码器,使用always_ff块描述同步寄存器,以及使用连续赋值语句assign描述组合逻辑。通过这种自顶向下、模块化的设计方法,我们可以清晰地构建出复杂的数字系统。
102:单周期处理器领带庆祝 🎉
在本节中,我们将通过一个特别的庆祝活动,回顾单周期和多周期处理器的核心数据通路设计。

上一节我们详细探讨了处理器的数据通路设计。为了庆祝这一重要里程碑,本节将展示一个为此特别设计的领带图案。
以下是领带图案的展示和说明。


为庆祝我们的单周期和多周期处理器设计完成,我为此场合制作了一条特别的领带。



领带上的图案主题是计算机架构。图案中展示了我们的单周期和多周期数据通路设计。


遗憾的是,印刷时图案被略微裁剪了,但整体效果依然清晰。让我们为此欢呼。




本节课中,我们一起通过一个有趣的庆祝方式,回顾并形象化地展示了单周期与多周期处理器的数据通路,这是计算机架构学习中的一个重要里程碑。
103:多周期处理器数据通路(lw指令)

在本节课中,我们将学习多周期RISC-V处理器的设计。我们将重点关注lw(加载字)指令,并了解如何将指令执行分解为多个步骤,以复用硬件并提高时钟频率。
上一节我们介绍了单周期处理器的优缺点。本节中我们来看看多周期处理器如何通过将指令执行分解为更小的步骤来解决这些问题。
多周期处理器概述
多周期处理器将指令执行分解为多个较短的步骤。这样,简单指令可以用更少的步骤完成,从而可能更快结束。硬件可以被复用,例如,一个ALU可以在多个不同的步骤中使用,从而无需额外的加法器。由于每个步骤更小,时钟周期可以更快,从而获得更高的时钟频率。其缺点是,每个周期都需要支付寄存器建立时间和时钟到Q延迟等时序开销,这会降低性能。
我们将遵循与设计单周期处理器相同的过程:首先设计数据通路,然后设计控制器。在本讲中,我们将专注于lw指令的数据通路设计。
状态元件
与单周期处理器类似,我们从状态元件开始。但这次我们将使用一个统一的存储器,它同时包含指令和数据,而不是分开的指令存储器和数据存储器。
- 存储器:有一个地址端口用于指定要读写的单元,一个数据输出端口用于读取数据,一个数据输入端口用于写入数据。当写使能信号
MemWrite为真时,在时钟上升沿将数据写入指定地址。 - 寄存器文件:与之前相同。
- 程序计数器:与之前相同。
我们将继续连接这些元件,并像单周期处理器那样添加ALU和多路选择器。
分解lw指令的步骤
以下是lw指令的六个执行步骤。
步骤1:取指
无论执行什么指令,第一步都是相同的。程序计数器指向当前指令。我们将其连接到存储器地址端口,从该地址读取当前指令。然后,我们将该指令存储在一个寄存器中,称为指令寄存器。这样,即使在后续步骤中,我们也能记住这条指令。我们只希望在取指步骤中写入这个寄存器,因此需要一个使能信号 IRWrite,它在第一个周期有效,表示我们希望用新指令写入此寄存器。
步骤2:译码与读寄存器
在第二步中,我们将从寄存器文件和符号扩展模块中读取源操作数和立即数。我们从指令寄存器中取出指令,并将其分解为若干字段。rs1字段(指令的第19至15位)将送到寄存器文件,读取该寄存器的内容,并将其存储在一个我们称为A寄存器的寄存器中。同时,指令中的立即数字段被送到符号扩展器,得到扩展后的立即数。我们需要一个控制信号 ImmSrc 来告知立即数的格式。
步骤3:计算存储器地址
对于加载指令,下一步是计算存储器地址。我们将来自A寄存器的源操作数 rs1 和扩展后的立即数送入ALU。设置ALU控制信号以指示其执行加法操作。计算出的结果称为ALU结果,我们将在该步骤结束时将其存储在一个寄存器中,称为ALU输出寄存器。
步骤4:访问存储器
在第四步中,我们使用ALU输出寄存器中的地址从存储器中读取数据。我们的统一存储器地址输入需要一个多路选择器,以便从多个来源中选择地址信号。该多路选择器需要一个控制信号 AdrSrc。当 AdrSrc 为0时,从PC获取指令地址;当 AdrSrc 为1时,从ALU输出寄存器获取数据存储器地址。存储器读取后,数据输出端口的值将被存储在一个我们称为数据寄存器的寄存器中。
步骤5:写回寄存器文件
第五步,我们将结果写回寄存器文件。寄存器文件的结果输入需要一个多路选择器,以便从ALU输出寄存器或数据寄存器等来源中选择结果。实际上,我们很快会需要另一个来源,因此我们将预留一个额外的输入端,并需要一个2位控制信号 ResultSrc[1:0]。在此步骤中,我们从数据寄存器获取结果,将其送回寄存器文件,并置位写使能信号 RegWrite,将该值写入寄存器文件。要写入的寄存器地址来自指令的 rd 字段(第11至7位)。
步骤6:更新程序计数器
最后需要做的是将PC递增,指向下一条指令。程序计数器的值可以作为ALU的另一个输入源。因此,源A SrcA 除了来自寄存器文件,也可以来自PC;源B SrcB 除了来自立即数,也可以选择常数4。我们指示ALU执行加法,计算 PC + 4,并将结果放在ALU结果线上。我们需要在本周期内立即将其送到PC。因此,我们在结果多路选择器上添加一个输入来选择ALU结果,并将该结果送回PC。我们还需要一个使能信号 PCWrite 来指示我们希望在此周期更改程序计数器。同时,我们需要控制信号 ALUSrcA 和 ALUSrcB 来选择源A和源B的来源。

本节课中我们一起学习了多周期RISC-V处理器数据通路的设计,并详细分解了lw指令执行的六个步骤:取指、译码/读寄存器、计算地址、访问存储器、写回寄存器和更新PC。通过复用ALU等硬件和分步执行,多周期设计为优化性能和硬件成本提供了基础。下一节我们将探讨如何设计控制这个数据通路的有限状态机。
104:多周期处理器数据通路(其他指令)🚀

在本节中,我们将继续构建多周期处理器的数据通路,并实现除加载(Load)和立即数运算(I-type)指令之外的其他指令。我们将重点讲解存储(Store)、寄存器-寄存器运算(R-type)和分支(Branch)指令的实现方式。
上一节我们介绍了加载和立即数运算指令的数据通路实现。本节中,我们来看看如何扩展数据通路以支持存储、R-type和分支指令。
存储指令(Store)的实现
存储指令(如 sw rs2, offset(rs1))的功能是将第二个源寄存器(rs2)中的数据写入内存。我们已经具备了大部分能力:可以取指、可以读取寄存器。但现在我们需要读取两个寄存器,而不仅仅是一个。
以下是实现存储指令所需的步骤:
- 从指令的
rs2字段引出一条线连接到寄存器文件,以读取第二个源操作数。 - 扩展寄存器文件输出端的寄存器(
A和B),以同时保存两个源操作数的值。 - 将第二个源操作数的值(称为写数据
WriteData)连接到内存的写数据端口。 - 在第三个步骤中,计算内存地址(
rs1的值加上符号扩展后的立即数)。 - 在第四个步骤中,将计算出的地址提供给内存,并激活内存写控制信号(
MemWrite),将WriteData写入内存。
R-type指令的实现
对于R-type指令(如 add rd, rs1, rs2),我们实际上已经具备了所有必需的硬件。
以下是R-type指令的执行步骤:
- 第一步:从内存中取出指令,存入指令寄存器。
- 第二步:从寄存器文件中读取两个源操作数
rs1和rs2。 - 第三步:将这两个源操作数送入ALU执行指定的运算操作。
- 第四步:将ALU的运算结果写回寄存器文件的目标寄存器
rd中。
分支指令的实现
分支指令(如 beq rs1, rs2, offset)需要完成两件事:计算分支目标地址,以及决定是否进行分支跳转。
让我们将其绘制在数据通路上。实现分支指令需要注意,程序计数器(PC)在取指阶段已经更新为 PC+4。由于我们需要计算的是 PC + offset(而不是 PC+4 + offset),因此必须保留一份旧的PC值副本用于与立即数相加。
以下是分支指令的执行步骤:
- 第一步:读取指令。同时,我们需要捕获当前旧的程序计数器(PC)值。
- 第二步:将旧的PC值与符号扩展后的立即数(
offset)相加,得到分支目标地址。 - 第三步:比较两个源寄存器(
rs1和rs2)的值。将A和B送入ALU执行减法操作,并通过零标志位判断它们是否相等。 - 第四步:如果比较结果为相等(即分支条件成立),则通过激活
PCWrite控制信号,将第二步计算出的分支目标地址写入程序计数器,从而完成分支跳转。
多周期处理器数据通路总结
下图展示了我们构建的完整多周期RISC-V处理器数据通路:

让我们总结一下这个数据通路的关键组成部分:
- 黑色部分:代表数据通路本身。粗黑线通常是32位宽的总线,细线则是更小的总线(如5位宽的寄存器标识符)。
- 蓝色部分:代表控制信号,由控制单元产生,用于协调各部件在正确步骤中执行操作。
- 核心状态组件:包括程序计数器(PC)、内存和寄存器文件,构成了处理器的架构状态。
- 功能单元:我们添加了符号扩展器(Sign Extender)来处理立即数,以及ALU来执行算术逻辑运算。
- 流水线寄存器:我们在每个步骤之后添加了寄存器(如指令寄存器IR、寄存器A/B、ALU结果寄存器等),用于暂存该步骤产生的值,从而将指令执行分解为多个时钟周期。
- 多路选择器:我们增加了额外的多路选择器,用于在多个数据源中选择其一(例如,选择写入寄存器的数据是来自ALU结果还是内存加载的数据)。

本节课中,我们一起学习了如何扩展多周期处理器的数据通路以支持存储(Store)、R-type运算和分支(Branch)指令。我们理解了每条指令所需的执行步骤,并看到了如何通过添加控制信号、多路选择器和中间寄存器来协调这些步骤。至此,一个支持基本指令集的完整多周期处理器数据通路已呈现出来。
105:多周期处理器LW指令控制FSM设计 🧠

在本节课程中,我们将为多周期处理器设计控制器。我们将遵循与单周期处理器类似的设计思路,但会考虑控制信号在不同时钟周期内的变化。我们将把控制器分解为几个子模块,并详细讲解用于执行lw(加载字)指令的有限状态机设计。
概述
与单周期处理器类似,多周期处理器的控制器接收指令和来自ALU的零标志位,并产生相应的控制信号。关键区别在于,这些控制信号在不同的执行步骤中会取不同的值。我们将采用分层设计,将控制器分解为主有限状态机、ALU解码器、PC逻辑和指令解码器。
控制器子模块分解
上一节我们介绍了控制器的整体框架,本节中我们来看看其具体的子模块构成。这是一种分层设计的实例。
- 主有限状态机:取代单周期中的组合逻辑主解码器。它根据操作码,在适当的时钟周期产生大部分控制信号。
- ALU解码器:与单周期处理器完全相同。它接收来自主FSM的
ALUOp信号和指令的funct字段,以确定所需的ALU控制信号。 - PC逻辑:与单周期类似。基于
Branch和Zero信号,以及另一个称为PCUpdate的信号,决定是否写入程序计数器。当需要获取下一条指令或执行无条件跳转时,PCUpdate为真。 - 指令解码器:我们将指令解码逻辑从主FSM中分离出来,因为立即数的来源仅取决于操作码,与执行步骤无关。这将是一个组合逻辑模块,用于简化设计。
指令解码器设计
以下是指令解码器的功能。它是一个组合逻辑电路,仅查看7位操作码,并产生2位输出ImmSrc。
| 指令类型 | 操作码 (Op) | 立即数来源 (ImmSrc) |
|---|---|---|
| 加载字 (lw) | 0000011 |
00 (I-type) |
| 存储字 (sw) | 0100011 |
01 (S-type) |
| R-type指令 | 0110011 |
-- (无关) |
| 相等分支 (beq) | 1100011 |
10 (B-type) |
多周期主FSM设计
现在让我们设计多周期主有限状态机。它接收7位操作码,并输出正确的使能信号(如MemWrite、RegWrite)和多路选择器控制信号(如ALUSrcA、ResultSrc)。为了简化状态图表示,我们采用以下约定:
- 使能信号:仅当信号被置为1(断言)时,才在状态中列出其名称;若为0,则省略。
- 多路选择器信号:仅当需要关心其具体值时,才在状态中列出;若为无关项,则省略。
状态0:取指 (Fetch)
任何指令的第一步都是取指。在复位时,主FSM进入取指状态(状态0)。
- 控制信号:
AdrSrc = 0:选择PC作为指令内存的地址。IRWrite:断言,将读出的指令写入指令寄存器。
- 数据路径操作:在此状态下,ALU同时计算
PC+4,为后续更新PC做准备。ALUSrcA = 00:选择PC作为ALU的第一个操作数。ALUSrcB = 10:选择常数4作为ALU的第二个操作数。ALUOp = 00:指示ALU执行加法。ResultSrc = 10:选择ALU结果输出。PCUpdate:断言,将PC+4的结果写回程序计数器。
状态1:解码 (Decode)
在下一个周期,我们对指令进行解码并进行符号扩展。
- 控制信号:此状态无需设置任何控制信号。
- 数据路径操作:指令已就位。寄存器文件根据
rs1字段读出数据,符号扩展器根据ImmSrc(由指令解码器根据操作码确定)对立即数进行扩展。这为下一步操作准备好操作数。
状态2:执行/地址计算 (Execute)
根据指令类型,我们进入第三步。对于lw指令,我们需要计算内存地址。
- 控制信号:
ALUSrcA = 10:选择从寄存器文件读出的rs1数据作为ALU的第一个操作数。ALUSrcB = 01:选择符号扩展后的立即数作为ALU的第二个操作数。ALUOp = 00:指示ALU执行加法。
- 数据路径操作:ALU计算
rs1 + imm,得到的内存地址在周期结束时被存入ALU输出寄存器。
状态3:内存访问 (Memory)
现在我们需要实际从内存中读取数据。
- 控制信号:
ResultSrc = 00:选择ALU输出寄存器的值。AdrSrc = 01:选择上述结果作为数据内存的地址。
- 数据路径操作:根据计算出的地址从数据内存中读取数据,并在周期结束时将读出的数据存入数据寄存器。
状态4:写回 (Writeback)
最后一步是将结果写回寄存器文件。
- 控制信号:
ResultSrc = 01:选择数据寄存器中的值(即从内存读出的数据)。RegWrite:断言,将该值写入寄存器文件中由指令rd字段指定的位置。
- 数据路径操作:完成数据从内存到寄存器文件的写入。
完成此状态后,lw指令执行完毕,状态机将返回状态0,开始取下一条指令。
总结

本节课中我们一起学习了多周期处理器控制器的设计,重点分析了lw指令的执行过程。我们通过一个五状态的有限状态机(取指、解码、执行、内存访问、写回)来顺序产生控制信号,并在取指阶段巧妙地并行完成了PC+4的计算。这种多周期设计通过分时复用硬件资源,相较于单周期设计,可以在不同的指令步骤中执行不同的操作,从而优化了硬件利用。
106:多周期处理器其他指令的控制设计 🎛️

在本节中,我们将学习如何为除 lw(加载字)指令之外的其他指令设计多周期处理器的控制器。我们将依次分析 sw(存储字)、R型指令和 beq(分支等于)指令的控制流程。
概述
上一节我们介绍了 lw 指令的控制流程。本节中,我们将扩展控制器设计,使其能够处理 sw、R型算术逻辑指令以及 beq 分支指令。我们将看到,这些指令共享部分执行步骤,但在关键步骤上有所不同。
存储字指令的控制流程
sw 指令的前三个步骤与 lw 指令完全相同。
- 取指:从内存中获取指令,并将程序计数器加4。
- 译码:读取寄存器文件,并计算立即数。
- 地址计算:计算基地址与偏移量之和,得到内存地址。
以下是 sw 指令的第四个步骤,也是其独有的步骤:
在第四步中,我们需要将数据写入内存。此时,计算出的地址位于 ALUOut 寄存器中。我们需要将此地址提供给内存,并将要写入的数据(来自寄存器文件 ReadData2 端口)也提供给内存。
- 控制信号设置:
ResultSrc设为00,选择ALUOut作为结果。AdrSrc设为1,将ALUOut提供的地址传递给内存。MemWrite信号需要置为有效,以执行写操作。- 要写入的数据来自寄存器文件的
ReadData2端口,通过数据路径传递到内存的数据输入端口。
完成写入后,与 lw 指令一样,控制流会通过一个箭头返回到取指状态,开始执行下一条指令。
R型指令的控制流程
R型指令包括 add、sub、and、or 和 slt。它们的前两个步骤(取指和译码)与其他指令相同。其独特之处在于第三和第四步。
在第三步(执行阶段),我们需要执行ALU运算。
- 控制信号设置:
ALUSrcA设为10,选择寄存器文件ReadData1端口作为ALU的第一个操作数。ALUSrcB设为00,选择寄存器文件ReadData2端口作为ALU的第二个操作数。ALUOp设为10,指示控制器根据指令的funct字段([14:12]和[30])来生成具体的ALU控制信号,以决定执行加法、减法等操作。- ALU的计算结果存储在
ALUOut寄存器中。
接下来是第四步(写回阶段),我们需要将ALU的结果写回寄存器文件。
- 控制信号设置:
ResultSrc设为00,选择ALUOut中的值作为写回数据。RegWrite信号需要置为有效,将数据写入由指令中rd字段指定的目标寄存器。
分支等于指令的控制流程
beq 指令需要计算两样东西:分支目标地址(PC + 偏移量)和两个源寄存器值的比较结果(是否相等)。我们可以优化设计,在译码阶段就提前计算分支目标地址。
在第二步(译码阶段),当我们读取寄存器时,ALU此时是空闲的。我们可以利用它来计算分支目标地址。
- 控制信号设置:
ALUSrcA设为01,选择当前的PC值。ALUSrcB设为01,选择经过符号扩展的立即数(偏移量)。ALUOp设为00,指示ALU执行加法操作。- 计算结果
PC + offset被存入ALUOut寄存器,供后续可能的分支跳转使用。
接下来,对于 beq 指令,我们需要进入第三个状态进行比较。
在第三步(分支执行阶段),我们比较两个寄存器的值。
- 控制信号设置:
ALUSrcA设为10,选择ReadData1。ALUSrcB设为00,选择ReadData2。ALUOp设为01,指示ALU执行减法操作。ALU会生成一个Zero标志位,如果结果为0,则表示两个寄存器值相等。- 控制器需要置位
Branch信号。如果Branch和Zero同时为真,则PCWrite信号有效。
当需要跳转时,之前计算并存放在 ALUOut 中的分支目标地址将被写入程序计数器。
- 控制信号设置:
ResultSrc设为00,选择ALUOut中的分支目标地址。- 该地址通过多路选择器反馈到
PCNext的输入端。当PCWrite有效时,它被载入程序计数器。
总结
本节课中,我们一起学习了多周期处理器控制器的完整设计。我们分析了四种基本指令的控制序列:
lw/sw指令:共享取指、译码和地址计算阶段。lw在第四步读内存并写回寄存器;sw在第四步向内存写入数据。- R型指令:在第三步使用ALU执行运算,在第四步将结果写回寄存器文件。
beq指令:在译码阶段提前计算分支目标地址,在第三步比较寄存器并决定是否跳转,跳转时用之前计算的目标地址更新PC。

通过将这些步骤整合到一个统一的状态机中,并设置相应的控制信号,我们就构建出了一个能够执行多条指令的多周期处理器控制器。理解数据路径与控制信号之间的协作是掌握处理器设计的关键。
107:多周期处理器扩展至其他指令 🧠

在本节中,我们将学习如何扩展RISC-V多周期处理器,以支持新的指令。我们将重点分析如何处理立即数加法指令(如 addi)以及跳转并链接指令(如 jal)。通过理解这些指令的执行流程,你将掌握多周期处理器控制逻辑的扩展方法。
上一节我们介绍了多周期处理器处理基本指令(如 lw, sw, beq)的有限状态机。本节中,我们来看看如何扩展这个状态机,以支持 I 型算术指令和 J 型跳转指令。
处理 I 型算术指令(如 addi)
假设我们需要处理一个立即数运算指令,或者更广泛地说,任何 I 型算术逻辑单元指令(例如 andi, ori 等)。我们首先检查指令的 op 字段。如果其值对应 I 型ALU指令,我们就需要执行它。
I 型指令的执行过程与 R 型指令非常相似,关键区别在于第二个操作数来源不同:它来自指令中的立即数字段,而不是第二个寄存器。
以下是执行 I 型指令的步骤:
- 第一个操作数来自寄存器文件。因此,
ALUSrcA信号需要设置为10,以选择寄存器rs1。 - 第二个操作数来自立即数字段。因此,
ALUSrcB信号需要设置为01,以选择符号扩展后的立即数。 - 这两个源操作数送入ALU。
ALUOp信号需要设置为10,表示需要根据指令的funct3字段来决定具体的ALU操作。 - ALU计算出结果。
完成这个“执行立即数”阶段后,后续步骤与执行 R 型指令完全相同:进入常规的“ALU写回”阶段,将结果写回寄存器文件。
通过这种方式,我们不仅处理了 addi,也一并处理了所有立即数形式的ALU指令(如 andi, ori 等)。与 R 型指令的主要区别仅在于执行状态中 ALUSrcB 信号的选择。
处理跳转并链接指令(jal)
接下来,我们看看如何添加对 jal 指令的支持。这是一个相对较大的改动,就像在单周期处理器中一样。
jal 指令需要完成几项任务:
- 获取
J型立即数。 - 计算跳转目标地址。
- 将返回地址(
PC+4)写入寄存器文件。 - 更新程序计数器(
PC)为跳转目标地址。
以下是 jal 指令在多周期处理器中的执行步骤:
- 计算返回地址(
PC+4):这一步与处理分支指令时计算目标地址类似,但目的不同。我们需要PC的当前值,因此ALUSrcA设置为01。同时,ALUSrcB设置为10(选择常数4),ALUOp设置为00(执行加法)。这样,ALU就计算出了PC+4。 - 更新程序计数器:与此同时,在解码阶段(第二步)我们已经计算出了跳转目标地址(
PC + offset),并暂存在ALUOut寄存器中。在此步骤中,我们通过设置ResultSrc为0来选择这个地址,并断言PCUpdate信号,将程序计数器更新为这个跳转目标地址。 - 写回返回地址:最后,我们需要将
PC+4(即返回地址)写入寄存器文件。现有的“ALU写回”步骤正好可以将ALU的输出写入寄存器文件,我们可以直接复用这一步。只需将ResultSrc设置为00,以选择来自ALU(此时ALU输出是PC+4)的值,并设置RegWrite为1,即可将PC+4写入目标寄存器。
多周期处理器状态机总结 🏁
本节课中我们一起学习了如何扩展多周期处理器的控制逻辑。至此,我们完成了多周期处理器的主要有限状态机设计。
这个状态机执行一条指令需要3到5个不等的时钟周期。所有指令都从取指和译码开始。之后,根据指令类型进入不同的执行路径:
- 加载指令(
lw):需要5个周期。第三步计算内存地址,第四步从内存读取数据,第五步将数据写回寄存器文件。 - 存储指令(
sw):需要4个周期。在第四步,根据第三步计算出的地址,将数据写入内存。 R型和I型算术指令:需要4个周期。第三步执行ALU运算,第四步将结果写回寄存器文件。- 分支指令(
beq):仅需3个周期。因为在第二步已经计算了分支目标地址,第三步比较寄存器值,若相等则更新程序计数器。 - 跳转并链接指令(
jal):需要额外的步骤来计算返回地址和更新PC,其流程已整合在上述状态机中。

通过这种多周期设计,处理器可以更高效地利用硬件资源,以不同的周期数灵活执行各类指令。
108:多周期处理器性能分析 🧮
在本节中,我们将分析多周期处理器的性能。我们将了解不同指令所需的周期数,计算平均指令周期数(CPI),并探讨处理器的时钟周期时间。最后,我们将比较多周期处理器与单周期处理器的整体执行时间。

指令周期数分析
上一节我们介绍了多周期处理器的基本概念,本节中我们来看看不同指令执行所需的周期数。
在多周期处理器中,不同的指令需要不同数量的时钟周期。以下是各类指令的周期数:
- 分支指令(Branch on equal):仅需3个周期。
- R型指令(如add)、I型指令、存储指令(store word)、跳转链接指令(jump and link):需要4个周期。
- 加载指令(load word):需要5个周期。
因此,每条指令的平均周期数(CPI)是这些数值的加权平均值。
平均CPI计算
为了计算实际的平均CPI,我们需要一个指令混合比例的基准。以SPECint 2000基准测试为例,这是一个衡量计算机性能的基准程序集。
以下是该基准测试中各类指令的大致比例:
- 加载指令(Loads):占25%。
- 存储指令(Stores):占10%。
- 分支指令(Branches):占13%。
- R型数据处理指令:占剩余的52%。
根据这些比例,我们可以计算平均CPI:
- 13%的分支指令,每指令3个周期。
- (52% + 10%) = 62%的指令,每指令4个周期。
- 25%的加载指令,每指令5个周期。
平均CPI计算公式为:
平均CPI = (13% × 3) + (62% × 4) + (25% × 5)
计算结果是每条指令平均略高于4个周期。
处理器时钟周期分析
接下来,我们需要分析处理器的时钟周期时间。在多周期设计中,不同的数据路径可能限制时钟速度。我们来看两条可能成为瓶颈的关键路径。
以下是两条关键延迟路径的分析:
- 蓝色路径(PC更新路径):该路径从程序计数器(PC)开始,经过ALU计算PC+4,再通过结果选择器(Result Mux)返回。其延迟包括:寄存器输出、控制解码器、两个多路选择器、ALU运算以及下一个寄存器的建立时间。
- 红色路径(内存读取路径):该路径涉及从内存读取数据。其延迟包括:生成内存地址、通过地址选择器(Address Mux)、访问数据内存、读取数据并存入数据寄存器。
我们做两个合理的假设:
- 寄存器文件的访问速度比访问内存快,因为它更小。
- 写入内存通常比读取内存快。
如果这些假设成立,那么存储指令的写入路径和访问寄存器文件的路径就不会成为限制因素。
因此,多周期处理器的最坏情况时钟周期时间(T_c)由以下部分构成:
T_c = T_pcq + T_dec + 2 × T_mux + max(T_ALU, T_mem) + T_setup
代入典型值(单位:皮秒 ps):
- 寄存器输出时间(
T_pcq):40 ps - 解码器延迟(
T_dec):25 ps - 两个多路选择器延迟(
2 × T_mux):60 ps - ALU或内存访问的最大延迟(
max(T_ALU, T_mem)):200 ps - 寄存器建立时间(
T_setup):50 ps
计算总周期时间:
T_c = 40 + 25 + 60 + 200 + 50 = 375 ps
这个时钟周期时间(375 ps)恰好是之前单周期处理器周期时间(约750 ps)的一半。因此,多周期处理器的时钟速度确实更快。
整体性能比较
然而,我们需要谨慎:虽然时钟频率提高了一倍,但现在每条指令需要3到5个周期才能完成,因此整体性能可能并未提升。
让我们计算一个包含1000亿条指令(10^11条指令)的程序的执行时间。
总执行时间公式为:
总时间 = 指令数 × 平均CPI × 时钟周期时间
代入数值:
总时间 = 10^11 × 4.12 × 375 × 10^{-12} 秒
计算结果略高于150秒。相比之下,单周期处理器执行同一程序大约需要75秒。
本节总结

本节课中我们一起学习了多周期处理器的性能分析。我们了解到:
- 不同指令需要不同的执行周期数,平均CPI是它们的加权平均值。
- 处理器的时钟周期由最长的关键路径决定,在本例中为375皮秒。
- 尽管多周期处理器的时钟频率是单周期处理器的两倍,但由于每条指令需要更多时钟周期,其整体执行时间可能反而更长。在本例的假设和基准下,多周期处理器的执行时间是单周期处理器的两倍多。这说明了在计算机架构设计中需要在时钟速度(频率)和每条指令所需周期数(CPI)之间进行权衡。
109:流水线处理器介绍 🚀

在本节中,我们将学习流水线RISC-V微处理器的基本概念和工作原理。我们将看到如何通过将指令执行过程划分为多个阶段并重叠执行,来显著提升处理器的吞吐量。
流水线概念回顾
上一节我们介绍了多周期处理器,但发现其性能提升有限。本节中,我们来看看流水线技术,这是一种更有效的性能提升方法。
流水线的核心思想是时间并行性。我们可以通过重叠执行不同指令的不同阶段,来提高整体吞吐量。一个经典的类比是洗衣流程:
- 顺序执行:洗一桶衣服 -> 晾干 -> 折叠 -> 收好 -> 再开始洗下一桶。
- 流水线执行:当第一桶衣服在晾干时,第二桶衣服可以开始洗;当第一桶在折叠时,第二桶在晾干,第三桶可以开始洗。
这样,虽然完成单件任务的总时间不变,但单位时间内完成的任务数量(吞吐量)大大增加。
单周期与流水线处理器对比
为了理解流水线的优势,我们先对比单周期处理器和流水线处理器的执行时间。
在单周期处理器中,每条指令必须顺序完成所有五个阶段:
- 取指:从指令存储器读取指令。
- 译码:从寄存器文件读取操作数。
- 执行:在算术逻辑单元进行计算。
- 访存:访问数据存储器(如果需要)。
- 写回:将结果写回寄存器文件。
假设各阶段最长耗时如下:
- 取指/访存:200皮秒
- 译码:100皮秒
- 执行:120皮秒
- 写回:50皮秒
那么,单条指令的总执行时间接近 700皮秒。第二条指令必须等待第一条完全结束后才能开始。
在流水线处理器中,我们将这五个阶段用流水线寄存器分隔开。每个阶段完成后,其结果被锁存到寄存器中,供下一阶段使用。关键点在于,只要一个阶段完成了当前指令的处理,它就可以立即开始处理下一条指令。
以下是流水线执行的时间线示意图:
- 周期1:取指指令1。
- 周期2:取指指令2,同时译码指令1。
- 周期3:取指指令3,译码指令2,执行指令1。
- 周期4:取指指令4,译码指令3,执行指令2,访存指令1。
- 周期5:取指指令5,译码指令4,执行指令3,访存指令2,写回指令1。
此时,处理器的吞吐量变为每200皮秒完成一条指令(由最慢的阶段决定),相比单周期的700皮秒,获得了显著的性能提升。理想情况下,如果五个阶段完全均衡,可以获得5倍的加速比。
流水线数据通路构建
现在,我们来看看如何构建一个流水线处理器的数据通路。我们从熟悉的单周期数据通路开始,并在关键位置插入流水线寄存器,将其划分为五个独立的阶段。
以下是构建流水线数据通路的核心步骤:
- 划分阶段:在取指、译码、执行、访存、写回五个阶段之间插入四个流水线寄存器。这些寄存器在时钟边沿锁存数据,确保每个阶段在一个周期内处理一条指令的特定部分。
- 信号传递:所有在阶段间传递的数据和控制信号都必须通过流水线寄存器。为了清晰区分,我们为信号名添加后缀(如
_F,_D,_E,_M,_W),以表明它属于哪个流水线阶段。 - 解决数据冲突:一个关键问题是,写回阶段需要知道将结果写入哪个寄存器(由指令的
rd字段指定)。这个信息在译码阶段被解析,但必须伴随指令数据一起穿过流水线,直到写回阶段才被使用。因此,我们需要将rd字段从译码阶段开始,通过流水线寄存器一直传递到写回阶段。 - 寄存器文件读写:为了在同一周期内既能读取(译码阶段)又能写入(写回阶段)寄存器文件,我们采用一种时序设计:在时钟上升沿进行读操作,在时钟下降沿进行写操作。这样避免了读写冲突。
流水线控制逻辑
控制逻辑也需要进行流水化。控制单元仍然根据当前处于译码阶段的指令来生成所有控制信号。
以下是控制信号在流水线中的传递过程:
- 立即数类型等少数信号在译码阶段就需要使用。
- 大部分控制信号(如
ALUOp,ALUSrc,MemWrite,RegWrite等)在生成后,会跟随对应的指令数据一起进入流水线寄存器。 - 这些信号在后续的流水阶段中被“激活”:
ALUSrc和ALUOp在执行阶段被使用。Branch和Jump信号也在执行阶段用于解析分支/跳转,并可能影响下游的取指阶段(这引入了控制冒险,后续会讨论)。MemWrite在访存阶段被用作数据存储器的写使能。RegWrite和结果选择信号在写回阶段被使用,以决定是否以及如何写回寄存器文件。
通过这种方式,控制信号就像指令的“行李”一样,在流水线中流动,并在需要的时候被取出使用。
总结

本节课中我们一起学习了流水线处理器的基本原理。我们了解到,通过将指令执行过程划分为多个阶段(取指、译码、执行、访存、写回),并在阶段间插入寄存器,可以实现多条指令的重叠执行,从而大幅提高处理器的吞吐量。我们构建了流水线数据通路的框架,并说明了数据和控-制信号如何沿流水线传递。关键点包括:使用流水线寄存器隔离阶段、传递目标寄存器地址 rd 以解决写回问题、以及将控制信号与指令数据同步流水化。流水线是几乎所有现代高性能处理器的基石技术。
110:流水线处理器的数据冒险 🚧

在本节中,我们将学习流水线处理器中数据冒险的概念、成因以及几种主要的解决方法。数据冒险发生在一条指令需要依赖前一条尚未完成指令的结果时。我们将重点探讨通过编译器插入空操作、代码重排、硬件前递以及流水线停顿这几种策略来应对数据冒险。
数据冒险的成因与示例
上一节我们介绍了流水线的基本概念,本节中我们来看看流水线中可能出现的“冒险”问题。流水线冒险发生在一条指令的执行依赖于前一条尚未完成指令的结果时。
数据冒险是其中一种类型,它发生在我们需要使用一个寄存器的值,但这个值尚未被前一条指令写回寄存器文件时。
让我们来看一个数据冒险的例子。假设我们执行一条加法指令 add s8, s4, s5,随后四条指令都将 s8 作为源操作数使用。
add s8, s4, s5
sub s9, s8, s10
or s11, s8, s12
and s13, s8, s14
在流水线中,add 指令在第五个周期的前半段才将结果写回 s8 寄存器。然而,紧随其后的 sub 指令在第三个周期就需要从寄存器文件中读取 s8 的值,此时它读到的是旧的、错误的 s8 值。同样,or 指令在第四个周期读取 s8 时也会遇到同样的问题。只有 and 指令在第五个周期的后半段读取时,才能获得正确的 s8 值。因此,sub 和 or 指令会得到错误的结果,这就是一个典型的数据冒险。
解决数据冒险的方法
为了解决数据冒险,有几种可能的方案。
编译器插入空操作
一种方法是在编译时,在一条指令和需要其结果的指令之间插入不做任何操作的 nop 指令。由于从写入寄存器到可以安全读取之间通常间隔两个周期,我们可能需要插入两个 nop。
例如,在 add 指令后插入两个 nop:
add s8, s4, s5
nop
nop
sub s9, s8, s10
...
这样,sub 指令在第五个周期才尝试读取 s8,此时值已就绪。然而,这种方法效率低下,因为它会降低程序速度并增加代码体积。
编译器代码重排
另一种编译时优化是重排代码顺序。如果程序中存在与当前数据流无关的指令,编译器可以尝试将这些指令提前执行,以填充等待数据就绪的空闲周期。
例如,原始代码:
add s8, s4, s5
sub s9, s8, s10 // 依赖于 s8
// 一些与 s8 无关的指令
重排后:
add s8, s4, s5
// 一些与 s8 无关的指令(被提前)
sub s9, s8, s10 // 此时 s8 已就绪
这种方法的缺点是,对于不同的处理器微架构,指令结果就绪的延迟可能不同,编译器可能无法做出最优的重排决策。
硬件前递
硬件前递(或称旁路)是一种在运行时由硬件动态解决数据冒险的方法。其核心思想是:当一条指令在流水线的执行阶段产生结果后,如果后续指令需要这个结果,我们可以绕过寄存器文件,直接将这个结果“前递”给需要它的指令。
回顾之前的例子,add 指令的结果在第三个周期(执行阶段)就已经计算出来了(s4 + s5)。而 sub 指令在第四个周期才需要这个值进行减法运算。通过硬件前递,我们可以将执行阶段计算出的 s8 值直接传递给 sub 指令的ALU输入,而不是等待它写回寄存器文件后再读取。
实现前递需要额外的硬件逻辑。我们需要检查执行阶段的源寄存器是否与处于访存阶段或写回阶段指令的目的寄存器相匹配。如果匹配,且该指令确实要写入寄存器,则触发前递。
以下是前递逻辑的部分公式描述:
-
检查是否从访存阶段前递:
ForwardAE = (Rs1E == RdM) && RegWriteM && (RdM != 0)Rs1E: 执行阶段的源寄存器1编号。RdM: 访存阶段指令的目的寄存器编号。RegWriteM: 访存阶段指令是否要写寄存器。(RdM != 0): 确保目的寄存器不是x0(恒为0的寄存器)。
-
检查是否从写回阶段前递:
ForwardAE = (Rs1E == RdW) && RegWriteW && (RdW != 0)(当不满足访存阶段前递条件时)RdW: 写回阶段指令的目的寄存器编号。RegWriteW: 写回阶段指令是否要写寄存器。
对于源寄存器2(Rs2E)的前递逻辑(ForwardBE)与上述公式类似。
流水线停顿
然而,并非所有情况都能通过前递解决。一个典型的例子是加载指令(lw)后紧跟使用其结果的指令。
lw s7, 40(s5)
and s10, s7, s11
lw 指令直到第四个周期(访存阶段)结束时才从内存中读出数据。而紧随其后的 and 指令在第三个周期(执行阶段)就需要 s7 的值。此时,数据尚未就绪,无法前递。
这种情况下,处理器必须采用停顿策略。硬件会检测到这种“加载-使用”冒险,并暂停流水线中相关指令的推进,直到数据可用为止。
具体操作是:将 and 指令阻塞在译码阶段,不让它进入执行阶段。同时,and 之后的所有指令也相应地被阻塞在更早的阶段(例如取指阶段)。当 lw 指令在第四个周期结束时获得数据后,可以通过前递机制将数据送给 and 指令,然后解除停顿,流水线继续执行。
以下是加载停顿的检测逻辑公式:
Stall = ((Rs1D == RdE) || (Rs2D == RdE)) && MemReadE
Rs1D,Rs2D: 译码阶段的源寄存器编号。RdE: 执行阶段指令的目的寄存器编号。MemReadE: 执行阶段指令是否为加载指令(lw)。为真时表示需要停顿。
当检测到停顿时,控制逻辑会:
- 关闭取指和译码阶段流水线寄存器的使能信号,使其内容保持不变(停顿)。
- 对执行阶段的流水线寄存器发出清空信号,使其输出为零(相当于在执行阶段插入一个
nop),防止错误操作。
总结
本节课中我们一起学习了流水线处理器中的数据冒险问题。我们了解到,数据冒险源于指令间的数据依赖与流水线并行性的冲突。我们探讨了四种主要的解决策略:
- 编译器插入空操作:简单但低效,会增加延迟和代码大小。
- 编译器代码重排:一种软件优化,通过调整指令顺序来隐藏延迟,但其效果受限于架构知识。
- 硬件前递:一种高效且常见的硬件解决方案,通过旁路网络将已计算但未写回的结果直接传递给需要它的指令,解决了大部分数据冒险。
- 流水线停顿:当前递无法解决问题时(如加载-使用冒险)的必要手段,通过暂停流水线来等待数据就绪,会引入性能开销。

现代处理器通常结合使用硬件前递和流水线停顿机制,并辅以编译器的优化,来高效地处理数据冒险,从而在保持流水线高效运行的同时确保程序执行的正确性。
111:流水线处理器中的控制冒险 🚧

在本节中,我们将继续探讨流水线处理器中的冒险问题,这次的重点是控制冒险。
概述
控制冒险发生在我们需要取指时,却还不知道应该取哪条指令的情况下。这在诸如beq(相等时分支)这类指令中尤为明显。分支指令的判断结果要到执行阶段才能确定,因为此时才会比较数值并判断结果是否为零。然而,在分支指令之后,流水线已经取入了后续指令。如果分支被采纳,这些后续指令就必须被冲刷掉。
控制冒险详解
让我们在流水线中具体观察这个过程。假设我们有一条beq指令。如果寄存器s1等于s2,程序将跳转到下方的label1处。紧接着beq之后还有两条指令(例如sub和or)。
- 周期1:取指
beq。 - 周期2:取指
sub。 - 周期3:取指
or。同时,在执行阶段,我们发现分支应该被采纳。
此时,我们实际上应该去取label1处的指令(例如add)。但sub和or指令本不该执行,因此我们需要将它们从流水线中冲刷掉。这种因错误预测分支而丢弃工作的过程,被称为分支误预测惩罚,其大小等于分支被采纳时需要冲刷的指令数量。
冲刷逻辑的实现
为了实现冲刷,我们需要在分支于执行阶段被采纳时,冲刷掉当时处于取指和解码阶段的指令。我们通过引入FlushD和FlushE信号来实现这一点,这两个信号将作为对应流水线寄存器的清零输入。
具体的逻辑是:
- 如果在执行阶段,
PCSrcE信号(该信号指示程序计数器应从分支目标地址而非PC+4取值)被置位,则冲刷执行阶段。 - 同时,如果
PCSrcE信号为真(意味着我们正在采纳一个分支),或者存在一个lw指令导致的数据冒险(我们之前已经知道在lw冒险时需要冲刷执行阶段),我们也需要冲刷解码阶段。
这引入了一些额外的硬件。我们需要监控PCSrcE信号,如果它有效,我们不仅要断言FlushE(冲刷执行阶段),还需要断言FlushD(冲刷解码寄存器)。
完整的处理器架构
将所有这些部分整合起来,我们就得到了一个完整的流水线处理器,它包含三个主要部分:
- 数据通路:本质上与单周期处理器相同,但为了提升性能,我们增加了一些前递多路选择器。
- 控制单元:与单周期处理器相同,但我们将控制信号流水化,将它们传递到需要它们的相应阶段。
- 冒险单元:负责侦测冒险。如果冒险可以通过前递解决,则启用对应的前递多路选择器。否则,可能需要采取冲刷或暂停操作。具体来说,我们可以暂停流水线前段,冲刷执行阶段。如果遇到分支误预测,我们还需要冲刷分支之后的两条指令。

总结
本节课我们一起学习了流水线处理器中的控制冒险。我们了解到,控制冒险源于分支指令结果确定较晚,导致后续错误取入的指令需要被冲刷,从而产生性能惩罚。我们探讨了通过FlushD和FlushE信号来实现指令冲刷的逻辑。最后,我们回顾了包含数据通路、控制单元和冒险单元的完整流水线处理器架构,其中冒险单元负责处理包括控制冒险在内的各种冒险情况。
112:流水线处理器性能分析 📊

在本节中,我们将分析流水线处理器的性能。我们将通过一个具体的基准测试程序,计算其平均每条指令周期数,并识别处理器的关键路径以确定其最大时钟频率。最后,我们将对比流水线处理器与单周期、多周期处理器的性能差异。
性能分析概述
我们再次考虑一个包含多种指令类型的基准测试程序。假设其中40%的加载指令被下一条指令使用,导致一个周期的停顿;同时,50%的分支指令预测错误,导致两条指令被冲刷。我们的目标是计算该程序在流水线处理器中的平均每条指令周期数。
理想情况下,流水线处理器应达到 CPI = 1。但在现实中,停顿会增加CPI。
计算平均CPI
以下是不同指令类型的CPI计算:
- 加载指令:60%的情况下需要1个周期,40%的情况下(由于数据冒险)需要2个周期。
- 平均CPI =
0.6 * 1 + 0.4 * 2 = 1.4
- 平均CPI =
- 分支指令:50%的情况下需要1个周期,50%的情况下(由于预测错误)需要3个周期。
- 平均CPI =
0.5 * 1 + 0.5 * 3 = 2.0
- 平均CPI =
- 其他指令:始终需要1个周期。
假设指令混合比例为:加载指令占25%,分支指令占13%,其余指令占62%。则整体平均CPI计算如下:
平均CPI = (0.25 * 1.4) + (0.13 * 2.0) + (0.62 * 1) = 1.23
因此,该程序的平均每条指令周期数为 1.23。
识别关键路径
上一节我们计算了平均CPI,本节我们来看看决定处理器最大时钟频率的关键路径。我们需要分析流水线每个阶段的最长延迟。
- 取指阶段:延迟包括PC寄存器传播延迟、指令存储器读取延迟和下一级寄存器的建立时间。
- 译码阶段:需要在半个周期内完成寄存器文件的读取和下一级寄存器的建立,因此整个周期时间至少是此延迟的两倍。
- 执行阶段:这是最复杂的阶段。考虑一个需要旁路数据的场景:数据从流水线寄存器出发,经过一个多路选择器,再通过旁路多路选择器,进入ALU的源操作数B多路选择器,最终ALU进行计算。对于分支指令,ALU产生的零标志位还需经过与/或逻辑门,然后通过下一个PC多路选择器,最终为PC寄存器准备好新值。这是最长的路径。
- 访存阶段:延迟包括寄存器传播延迟、数据存储器加载延迟和下一级寄存器的建立时间。
- 写回阶段:需要在半个周期内完成从寄存器出发、经过一个多路选择器、写入寄存器文件的操作,因此周期时间至少是此延迟的两倍。
通过代入具体数值(例如:T_pcq = 40ps, 多路选择器延迟 30ps, ALU延迟 120ps, 逻辑门延迟 20ps, 建立时间 50ps),我们可以计算执行阶段关键路径的总延迟:
关键路径延迟 = 40 + (4 * 30) + 120 + 20 + 50 = 350 ps
因此,处理器的最小时钟周期应为 350皮秒。这虽然不如我们期望的仅由存储器读取决定的200皮秒理想,但相比单周期处理器的750皮秒已有巨大提升。
性能对比
现在,假设有一个包含1000亿条指令的程序。
- 流水线处理器执行时间:
总时间 = 指令数 × 平均CPI × 时钟周期 = 100e9 × 1.23 × 350e-12 秒 = 43.05 秒

让我们与其他架构对比:
- 单周期处理器:执行相同程序需要 75秒。
- 多周期处理器:执行时间约为 155秒(比单周期更慢,其主要优势在于节省硬件)。
流水线处理器虽然增加了流水线寄存器等硬件成本,但将程序执行时间缩短至 43秒,速度达到了单周期处理器的 1.7倍(即获得了70%的性能提升)。
总结

本节课中我们一起学习了流水线处理器的性能分析方法。我们通过计算平均CPI来量化流水线停顿带来的影响,并通过分析关键路径确定了处理器的最大时钟频率。最终对比表明,流水线技术能以相对较小的硬件开销(主要是额外的寄存器),换取显著的性能提升(本例中为70%),这正是现代处理器普遍采用流水线设计的重要原因。
113:高级微架构 🚀

在本节中,我们将探讨现代处理器设计中用于提升性能的一系列高级技术。我们已经学习了单周期、多周期和流水线这三种基础的RISC处理器微架构。本节将介绍更复杂的设计理念,如深度流水线、微操作、分支预测等,这些技术共同造就了当今高速的PC、笔记本电脑、手机和服务器。
深度流水线
上一节我们介绍了五级流水线处理器。本节中我们来看看深度流水线技术。其核心思想是:如果五级流水线是好的,那么更多级数可能会更好。
许多现代高性能处理器拥有10到20个甚至更多的流水线级。然而,流水线级数的增加受到以下因素限制:
- 分支预测错误惩罚:级数越多,一旦分支预测错误,需要清空(flush)的指令就越多,性能损失越大。
- 时序开销:每一级流水线都会引入寄存器建立时间(setup time)和时钟到输出延迟(clock-to-Q delay),这些开销累积起来会限制时钟频率的提升。
- 功耗与硬件成本:更多的流水线寄存器意味着更高的功耗和硬件成本。
随着流水线级数增加,时钟周期时间(Cycle Time)会下降。但指令执行总时间取决于 执行时间 = 指令数 × CPI × 时钟周期时间。如果级数过多,由于冒险(Hazard)导致的CPI上升可能会超过周期时间减少带来的收益,反而使性能下降。
一个极端的例子是奔腾4处理器,在其主要依靠时钟频率竞争的年代,曾达到28级流水线。虽然更高的频率带来了更高的售价,但实际性能并未同比提升,且功耗高达130瓦以上,最终难以为继。英特尔随后转向了级数更少、效率更高的酷睿(Core)微架构。
微操作
接下来,我们看看如何处理复杂指令。微操作(Micro-ops)技术将复杂的指令分解为一系列更简单的指令(即微操作)。在运行时,这些复杂指令被解码成微操作序列。
这种技术广泛应用于复杂指令集计算机(CISC),因为其指令无法在我们之前讨论的那种简洁规整的数据通路上高效执行。
例如,假设有一条复杂指令:从寄存器s2的值加0偏移量的地址加载数据到s1,并且对s2进行后自增4(即s2 = s2 + 4)。这是一种在数组中遍历元素的常见指令。
我们可以将其分解为两个更简单的微操作:
lw s1, 0(s2)// 加载数据addi s2, s2, 4// 地址自增
如果不进行分解,就需要寄存器文件具备两个写端口,以便在同一周期写入两个结果,这会使寄存器文件更大、更慢。因此,将其分解为微操作通常是更好的选择。
分支预测
在流水线处理器中,分支冒险会显著影响性能。理想流水线的CPI是1,但分支预测错误会增加CPI。我们的目标是尽可能减少预测错误,以最小化性能损失。分支预测技术就是用来猜测分支是否会被执行。
以下是两种主要的分支预测方法:
静态分支预测是最简单的技术,仅依据分支方向进行预测:
- 向后分支(通常对应循环结束条件)预测为“执行”(Taken)。
- 向前分支预测为“不执行”(Not Taken)。
动态分支预测则通过硬件记录分支历史来做出更智能的预测。一个称为分支目标缓冲区(Branch Target Buffer, BTB) 的硬件结构可以记录最近数百或数千条分支的目的地址和执行情况。当再次遇到分支时,用程序计数器(PC)索引BTB,查看历史记录并进行预测。
动态预测器可以使用状态机来记录更精细的历史信息。以下是两种常见的动态预测器:
假设我们有一个对1到10求和的循环程序,其循环结束分支(bge)仅在最后一次迭代时执行。以下是不同预测器在该循环中的表现分析:
- 1位分支预测器:只记录上一次该分支是否执行。在循环中,它会错误预测循环的第一次和最后一次迭代。对于10次迭代,错误率为20%。
- 2位分支预测器:使用一个2位状态机(如“强不执行”、“弱不执行”、“弱执行”、“强执行”),能容忍一次非常规结果而不改变预测。这样,它只会错误预测循环的最后一次迭代,错误率降至10%,从而获得90%的准确率。
通过良好的分支预测,可以减少需要清空流水线的分支比例。现代分支预测器的准确率通常能超过90%。
其他高级技术
除了上述技术,现代处理器还采用了许多其他方法来提升性能:
- 超标量处理器:每个时钟周期可以发射(issue)并执行多条指令。
- 乱序执行处理器:允许指令不按程序顺序执行,以减少数据冒险带来的停顿。
- 寄存器重命名:通过动态分配物理寄存器来消除假数据依赖(写后写、读后写冒险),是乱序执行的关键技术之一。
- 单指令多数据:一条指令可以同时对多个数据执行相同操作,用于加速多媒体、科学计算等向量化任务。
- 多线程:一个处理器核心能够在多个正在运行的程序(线程)之间快速切换,提高硬件利用率。
- 多处理器(多核):将多个处理器核心集成在一个芯片上,实现真正的并行处理。
总结

本节课中我们一起学习了现代处理器高级微架构的多种关键技术。我们从深度流水线的利弊开始,探讨了通过微操作分解复杂指令的方法。然后,深入研究了静态和动态分支预测技术,特别是1位和2位预测器的工作原理及其对性能的影响。最后,我们简要概述了超标量、乱序执行、寄存器重命名、SIMD、多线程和多核等其他重要技术。这些技术相互结合,共同推动了处理器性能的持续提升。
114:超标量与乱序处理器 🚀

在本节课中,我们将学习两种提升处理器性能的关键技术:超标量处理器和乱序处理器。我们将探讨它们如何通过同时执行多条指令来加速程序运行,并了解处理指令间依赖关系的各种方法。
概述
超标量处理器通过复制数据通路,使其能够在每个时钟周期内执行多条指令。然而,指令间的依赖关系会限制这种并行性。乱序处理器则通过前瞻性地分析指令流,并允许没有依赖关系的指令“乱序”执行,来克服这一限制。我们还将介绍寄存器重命名和单指令多数据这两种进一步优化性能的技术。
什么是超标量处理器?⚙️
上一节我们介绍了基本的流水线处理器。本节中,我们来看看超标量处理器。
一个超标量处理器拥有多个数据通路的副本,以便能够同时执行多条指令。
以我们的流水线RISC-V处理器为例。假设我们不是读取一条指令,而是同时读取两条指令。然后,假设我们的寄存器文件不是3个端口,而是有6个端口。这样,我们可以在每个步骤中获取两条指令的操作数,并写入两条指令的结果。接着,假设我们不是只有一个ALU,而是有两个,这样我们可以在每个步骤中执行两条指令。最后,假设我们有一个双端口存储器,每个周期可以读写两个值,以及两个结果总线。
理想情况下,我们的超标量处理器可以在每个周期发射和执行两条指令。公式可以表示为:
理想IPC = 发射宽度
其中IPC(Instructions Per Cycle)是衡量性能的关键指标。
依赖关系与限制 ⛓️
不幸的是,指令间存在依赖关系,这有时会限制我们同时发射多条指令的能力,因为一条指令的执行结果可能依赖于前一条指令。
以下是理解依赖关系及其影响的一些例子。
无依赖关系的程序
假设我们有一个将结果放入S7的加载字指令,然后是一个将结果放入S8的加法指令,一个减法指令放入S9,一个与指令放入S10,一个或指令放入S11,以及一个存储指令存入S5。这些指令的输入都不依赖于之前的指令。
在这种情况下,处理器可以高效地并行执行。
- 周期1:我们可以从指令存储器同时取指加载和加法指令。
- 周期2:我们为加载指令读取S0和40,为加法指令读取T1和T2。同时,我们可以取指减法指令和与指令。
- 周期3:加载和加法指令都可以执行加法操作。同时,寄存器文件读取减法指令和与指令的源操作数,并取指或指令和存储指令。
- 周期4:加载指令使用数据存储器,加法指令不需要。ALU执行减法操作和与操作。寄存器文件读取或指令和存储指令的操作数。
- 周期5:寄存器文件将加载和加法指令的结果写回S7和S8。减法指令和与指令完全不使用数据存储器。ALU执行或指令和存储指令的加法操作。
这里我们在每个周期都发射两条指令,因此指令每周期数(IPC)为2。
存在依赖关系的程序
现在考虑一个存在依赖关系的程序。我们加载数据到S8,但假设加法指令需要使用S8。然后假设减法指令的目的地也是S8,而与指令依赖于减法指令产生的S8。最后,假设或指令的目的地是S11,而存储指令依赖于S11。
在这种情况下,并行执行受到严重限制。
- 周期1:我们可以发射加载指令,但不能发射加法指令,因为它需要尚未从加载指令获得的S8。
- 周期2:我们可以发射加法指令,但它会停顿,因为它需要等到周期5S8才可用。减法指令独立于加法指令,所以我们也可以发射它。它们都在步骤7完成。
- 周期3:我们可以发射与指令和或指令。它们也会停顿,因为加法指令和减法指令已经停顿。
- 周期5:我们可以发射存储指令。
现在,发射六条指令需要五个周期,因此实际IPC为6/5 = 1.2。这比1好,但并不理想。
乱序执行处理器 🔄
为了尝试解决这些问题,我们可以设计一个乱序微处理器。这种处理器可以前瞻多条指令,并尽可能多地同时发射指令,只要它们之间没有依赖关系。它允许指令不按程序顺序发射。
我们需要关注的依赖关系有三种:写后读、读后写和写后写。
- 写后读依赖:一条指令写入一个寄存器,后面的指令要读取该寄存器。读操作必须等待写操作完成,或者至少需要旁路机制。
- 读后写依赖:一条指令读取一个寄存器,后面的指令要写入该寄存器。写操作不能乱序到读操作之前,否则可能得到错误结果。
- 写后写依赖:一条指令写入一个寄存器,随后的指令也写入同一个寄存器。需要保持这些指令的顺序,以确保最终寄存器中的值是第二条指令的结果。
乱序处理器通常使用一个称为记分板的设备来跟踪等待发射的指令、可用的功能单元以及指令间的依赖关系。它会在记分板中查找下一个准备就绪、有可用功能单元且所有依赖都已满足的指令。
让我们看看这个乱序处理器如何处理之前的例子。
- 周期1:我们可以执行加载字指令。加法指令依赖于它,所以不能执行。但我们可以向前查看程序,找到或指令,并将其与加载指令同时发射。
- 周期2:或指令产生S11。存储指令需要S11,所以我们可以在本周期发射存储指令,并将S11的结果从或指令旁路给存储指令。我们也想发射加法指令,但它依赖于尚未从加载指令准备好的S8,所以加法指令必须停顿,不能与存储指令同时发射。
- 周期3:我们在本周期发射加法指令和减法指令。
- 周期4:与指令依赖于减法指令的结果,我们在本周期发射它。
现在,我们在四个周期内发射了六条指令,得到IPC为1.5。这比之前好,但仍不理想。
寄存器重命名技术 🏷️
另一个关键技术是寄存器重命名。在上一个例子中,减法指令直到加载字指令之后才能发射,因为它们都写入S8,存在读后写依赖。而与指令必须等到减法指令之后。
如果我们愿意重命名寄存器,不让减法指令写入S8,而是引入一个新的临时寄存器R0,情况就会改变。
- 我们可以同时发射加载指令到S8,以及减法指令到寄存器R0。
- 依赖于减法结果的与指令,在重命名硬件的作用下,其源操作数S8变成了R0,因此我们可以将R0的结果从减法指令旁路给与指令。
- 或指令访问S11,因此它可以与与指令同时发射。
- 最后,加法指令和存储指令可以同时发射。
当所有指令执行完毕后,处理器需要确保S8的最终值实际上在这个重命名后的寄存器R0中,而不是原始的S8。
通过寄存器重命名,我们可以在两个周期内发射所有六条指令,获得IPC为2,这非常出色。
单指令多数据技术 🧮
另一种同时执行更多操作的技术是单指令多数据。
在这种技术中,一条指令同时对多个数据片段进行操作。这在图形处理和机器学习中非常常见,也适用于任何类型的短算术运算,有时被称为打包算术。
例如,假设我们有一条加法指令,用于对8个8位元素进行加法。假设我们有64位寄存器D0和D1。我们执行一条打包加法指令。寄存器将被视为8个8位值,并得到8个8位的和。列之间的任何溢出都会被丢弃,而不会影响下一列。
如果这些值是像素,我们就可以同时对多个像素进行算术运算,获得八倍的性能。代码示例如下:
// 假设 D0 = [A7, A6, A5, A4, A3, A2, A1, A0] (每个A为8位)
// 假设 D1 = [B7, B6, B5, B4, B3, B2, B1, B0]
PADD8 D2, D0, D1 // D2 = [A7+B7, A6+B6, ..., A0+B0] (每个结果取低8位)
总结
本节课中,我们一起学习了提升处理器性能的几种高级技术。
- 超标量处理器通过复制硬件资源来支持指令级并行,但受限于指令间的数据依赖。
- 乱序执行通过动态调度指令,允许无依赖的指令先执行,从而更充分地利用硬件资源。
- 寄存器重命名通过消除假依赖(如写后写、读后写),进一步提高了乱序执行的效率。
- 单指令多数据则通过一条指令处理多个数据元素,实现了数据级并行,特别适用于多媒体和科学计算等场景。

这些技术共同构成了现代高性能处理器设计的核心。
115:多线程与多处理器 🧵💻

在本节中,我们将学习多线程与多处理器的基本概念。我们将探讨什么是线程,以及如何通过多线程和多处理器技术来提高计算机系统的性能和效率。
概述
线程是程序的一部分,可以同时执行。在典型的计算机中,许多线程同时运行。例如,运行文字处理器时,可能有一个线程用于检测按键输入,另一个线程在后台检查拼写或语法,同时可能还有一个线程在打印文档,而所有这些操作都不会妨碍你继续打字。在一个应用程序中,可能有多个线程同时运行。此外,当你使用文字处理器时,可能还有视频在后台播放,或者正在下载电影或音乐。用户希望所有这些线程看起来是同时执行的。如果我们有足够的硬件,实际上可以让它们同时执行。
线程与进程
线程是程序的一部分,可以同时执行。进程是计算机上运行的程序,一个应用程序可能包含多个进程。一个进程可能由多个线程组成。
在传统的单核处理器中,一次只能运行一个线程。当一个线程停滞时,例如,当线程访问主内存并因缓存未命中而需要100个周期时,该线程可能会停滞。此时,该线程的架构状态可以被保存起来,另一个线程的架构状态可以被加载到寄存器文件和程序计数器中,以便新线程开始运行。这称为上下文切换。如果频繁进行上下文切换,用户会觉得所有线程都在同时运行,尽管它们实际上只是在非常快速地来回切换。
多线程技术
通过多线程技术,我们可以拥有多个架构状态的副本,例如多个寄存器文件副本和多个程序计数器副本。这样,我们可以有多个线程在同一时间处于活动状态。当一个线程停滞时,另一个线程可以立即开始执行。实际上,如果一个线程无法充分利用所有执行单元,我们可能会同时从另一个线程发出指令,以保持执行硬件的完全占用。
多线程并不会增加单个线程的指令级并行性,但它确实提高了系统的吞吐量。英特尔将这种技术称为超线程。
多处理器技术
多处理器是另一种技术,其中我们有许多处理器核心,它们之间通过某种方式进行通信。多处理器的一些例子包括同构多处理器、异构多处理器或集群。
在同构多处理器中,有许多相同的核心,通常共享一个共同的主内存,并通过读写该内存进行通信。异构多处理器中,核心类型不同。例如,在你的手机中,可能有一个四核CPU用于运行应用程序,一个数字信号处理器用于处理无线电信号,一个图形处理器用于视频加速,以及一个小处理器用于控制其他处理器的电源开关。集群是另一种类型,其中每个核心或核心组都有自己的内存系统,集群之间通过网络(如以太网)进行通信。集群之间的通信速度比集群内部的通信速度慢。
总结

在本节中,我们一起学习了多线程与多处理器的基本概念。我们了解了线程和进程的区别,探讨了多线程技术如何通过上下文切换提高系统吞吐量,以及多处理器技术如何通过同构、异构和集群等不同架构实现并行计算。希望这些知识能帮助你更深入地理解现代计算机架构的先进技术,并为未来的学习和实践打下基础。
116:内存系统简介 🧠

在本章中,我们将学习计算机内存系统。我们将首先介绍内存系统的基本概念,然后探讨其性能指标,最后分析影响性能的因素以及如何构建高效的内存系统,特别是缓存和虚拟内存。
概述
计算机系统的性能不仅取决于处理器的速度,也取决于内存系统的效率。处理器与内存之间的交互是计算过程的核心。在之前的章节中,我们假设内存访问可以在一个处理器时钟周期内完成,但自20世纪80年代以来,这已不再是现实。处理器性能的增长速度远超内存系统,导致了所谓的“内存墙”或“内存性能差距”。因此,现代计算机系统采用内存层次结构来弥合这一差距,使内存看起来像处理器一样快。
处理器与内存接口
处理器通过发送内存读写信号、地址和写入数据与内存通信,内存则返回读取的数据给处理器。下图展示了这一接口:

内存性能差距
下图以对数刻度展示了处理器与内存性能的增长差异。自20世纪80年代以来,处理器性能的提升速度远快于内存,导致访问内存需要多个处理器时钟周期。

挑战在于使内存表现得像处理器一样快。为了解决这个问题,我们采用了内存层次结构。
内存层次结构
理想的内存应具备快速、廉价和大容量三个特性,但这三者难以同时实现。因此,我们构建一个由多种类型内存组成的层次结构,以模拟出快速、廉价且大容量的内存。
内存层次结构通常包括:
- 缓存:位于处理器芯片上或附近,访问速度极快(通常为一个处理器时钟周期)。
- 主内存:位于芯片外,速度较慢。
- 硬盘/固态硬盘:用于虚拟内存,速度最慢但成本极低。
以下是不同类型内存的典型参数:
| 内存类型 | 成本(每GB) | 访问时间 |
|---|---|---|
| SRAM(缓存) | $100 | 0.2 - 3 纳秒 |
| DRAM(主内存) | $3 | 10 - 50 纳秒 |
| SSD/HDD(虚拟内存) | $0.03 - $0.10 | 20,000 - 5,000,000 纳秒 |
局部性原理
为了使内存层次结构高效工作,系统需要利用两种局部性原理。
时间局部性
时间局部性是指,如果某个数据最近被使用过,那么它很可能在不久的将来再次被使用。
我们通过将最近访问的数据保存在内存层次结构的较高层(特别是缓存)中来利用时间局部性。
举例:就像你最近使用过的教科书,你很可能会再次用到它。因此,你不会把它放回五英里外的仓库,而是放在背包里以便随时取用。
空间局部性
空间局部性是指,如果某个数据被使用过,那么其附近的数据很可能很快也会被使用。
处理器在访问某个数据时,会将其附近的数据也一并取入缓存,从而利用空间局部性。
举例:就像在图书馆找你最喜欢的作者的书,当你找到那本书时,你很可能会顺便查看旁边书架上的书,可能会发现该作者的新作。
总结

在本节课中,我们一起学习了内存系统的基础知识。我们了解到处理器与内存之间存在性能差距,因此现代计算机采用由缓存、主内存和硬盘构成的内存层次结构来提升整体性能。这一结构的高效运行依赖于对时间局部性和空间局部性原理的利用。在接下来的章节中,我们将深入探讨缓存和虚拟内存的具体设计与工作原理。
117:存储器系统性能 📊

在本节中,我们将学习如何衡量存储器系统的性能。我们将介绍命中与未命中、命中率与未命中率,以及平均存储器访问时间等核心概念。
概述
存储器系统的性能主要通过其命中率、未命中率以及存储器访问时间(或平均存储器访问时间)来衡量。
命中与未命中
首先,我们来定义“命中”和“未命中”。
- 命中:当所需数据在存储器层次结构的某一级(如高速缓存)中被找到时,称为一次命中。
- 未命中:当所需数据未在当前层级中找到,需要到下一级存储器(如主存)中寻找时,称为一次未命中。
命中率与未命中率
接下来,我们基于命中与未命中的次数来计算比率。
命中率是命中次数占总访问次数的比例。未命中率是未命中次数占总访问次数的比例。它们的和总是为1。
以下是它们的定义公式:
- 命中率公式:
命中率 = 命中次数 / 总访问次数 - 未命中率公式:
未命中率 = 未命中次数 / 总访问次数 - 关系:
命中率 + 未命中率 = 1或命中率 = 1 - 未命中率,未命中率 = 1 - 命中率
平均存储器访问时间
平均存储器访问时间是处理器访问数据所需的平均时间。它是衡量存储器系统性能的关键指标。
假设一个系统只有高速缓存和主存两级。平均存储器访问时间的计算公式为:
平均访问时间 = 访问高速缓存的时间 + 未命中率 × 访问主存的时间
其中,访问高速缓存的时间通常为1个处理器时钟周期。如果系统有更多级的存储器层次结构,公式可以相应扩展。
实例分析
现在,我们通过一个具体例子来应用以上概念。
假设一个程序执行了2000次加载和存储操作(即2000次存储器访问)。其中,有1250次所需数据在高速缓存中找到(命中),其余750次需要从其他存储器层级获取(未命中)。
根据公式计算:
- 命中率 = 1250 / 2000 = 0.625
- 未命中率 = 750 / 2000 = 0.375
- 验证:0.625 + 0.375 = 1
现在,进一步假设该系统的存储器层次结构有两级:高速缓存和主存。访问高速缓存需要1个处理器时钟周期,访问主存需要100个处理器时钟周期。那么,该程序的平均存储器访问时间是多少?
应用公式:
平均访问时间 = 1周期 + 0.375 × 100周期 = 1 + 37.5 = **38.5周期**
因此,该程序访问任意数据的平均时间为38.5个处理器时钟周期。
总结

本节课我们一起学习了存储器系统性能的衡量方法。我们明确了命中与未命中的定义,学会了计算命中率和未命中率,并掌握了计算平均存储器访问时间的核心公式。理解这些概念是分析和优化计算机存储器系统的基础。
118:缓存介绍 🧠

在本节中,我们将学习计算机系统中一个至关重要的组件——缓存。缓存是内存层次结构中的最高层,它的设计目标是让处理器感觉访问内存的速度非常快。我们将探讨缓存的基本概念、工作原理以及设计时需要考虑的关键问题。
缓存概述与设计问题
上一节我们介绍了内存层次结构的概念,本节中我们来看看其中的核心——缓存。
缓存位于内存层次结构的最高层,其访问速度通常非常快,大约为一个处理器周期。理想情况下,它能向处理器提供所需的大部分数据。因此,对于处理器而言,内存的访问时间看起来就像是一个周期。缓存通常保存最近被访问过的数据。
当我们设计一个缓存时,主要讨论三个核心问题:
- 缓存中存放什么数据?
- 如何在缓存中找到数据?
- 如果缓存已满,但需要载入新数据,如何替换旧数据?
在本次讨论中,我们将重点关注加载操作,但存储操作遵循相同的原则。
缓存存放的数据
那么,缓存中存放什么数据呢?本质上,缓存会预测处理器需要的数据并将其放入缓存。虽然无法准确预测未来,但我们可以利用过去来预测未来。
我们利用之前讨论过的局部性原理:时间局部性和空间局部性。
- 我们利用时间局部性,将新访问的数据复制到缓存中。这样,如果数据最近被访问过,它就已经在缓存中,为下一次访问做好准备。
- 空间局部性意味着,当我们访问某个数据时,也会将相邻地址(内存地址)的数据一并载入缓存。
缓存术语
以下是缓存相关的术语,我们将在后续讨论中使用它们。
- 缓存容量(C):指缓存中存储的数据字节的数量。需要特别注意,这个数字仅指存储的数据量,缓存实际还会存储一些其他信息。
- 块大小(B):指一次被载入缓存的数据字节数。这也被称为行大小。
- 块数量(B):指缓存的总容量除以块大小(b)的结果,即
B = C / b。 - 关联度(N):我们稍后会详细讨论。它指的是一个组(set)中包含的块(block)数量。
- 组数量(S):每个内存地址会精确映射到其中一个组。组数量等于总块数除以关联度,即
S = B / N。
我们稍后将更详细地讨论最后两个术语。
数据的查找方式
缓存中的数据被组织成多个组(set),组数为 S。每个内存地址会精确映射到这些组中的一个。
缓存根据每个组中包含的块数进行分类:
- 直接映射缓存:每个组只有一个块。
- N路组相联缓存(也简称为N路相联缓存):每个组有N个块。
- 全相联缓存:所有缓存块都在一个单独的组中。
接下来,我们将逐一分析这些缓存组织结构。这里使用一个简化的示例来演示原理,实际缓存不会只有8个字(word)这么小,但这有助于阐明概念。
对于这些示例缓存,其参数如下:
- 容量(C):8个字
- 块大小(b):1个字(32位)
- 块数量(B):8
用项目符号总结如下:
- 容量:8个字
- 块大小:1个字
- 块数量:8


本节课中我们一起学习了缓存的基本介绍。我们了解了缓存的设计目标、三个核心设计问题(存什么、怎么找、怎么换),以及利用局部性原理来预测数据需求。我们还定义了缓存的关键术语,如容量、块大小、关联度等,并简要介绍了三种主要的缓存组织结构:直接映射、组相联和全相联。在接下来的课程中,我们将深入探讨这些组织结构的具体工作原理。
119:直接映射缓存 🧠

在本节中,我们将学习直接映射缓存的基本工作原理。我们将了解内存地址如何映射到缓存中的特定位置,以及缓存如何通过标签和有效位来识别数据。理解这些概念是掌握缓存设计的关键。
系统地址空间
首先,我们讨论一个直接映射缓存系统。该系统使用32位地址。
地址范围从0一直到全1(即0x00000000到0xFFFFFFFF)。图中显示的是字地址,因此地址的低两位始终为零。地址空间从十六进制的0x00000000一直到0xFFFFFFFC。
但只有这些地址数据的一个子集可以保存在缓存中。
缓存映射原理
我们采取的做法是:将前8个字(块)映射到缓存中。这8个字分别是字0到字7。如果只访问这前8个字,字0会进入缓存的位置0,字1进入位置1,依此类推,直到字7。
当缓存被填满后,接下来的8个字(字8到字15)也会映射到相同的缓存位置。例如,字8会重新映射到缓存底部的集合0,字9映射到集合1,以此类推。这意味着主存中的多个字可以映射到缓存的同一个位置。
如果访问了字0并将其载入缓存,随后再访问字8,那么字8的数据会“驱逐”字0的数据并替换它。这种设计被称为直接映射缓存。特定的字地址直接映射到给定的缓存集合。
缓存硬件结构
上图展示了一个直接映射缓存的硬件结构。我们从32位内存地址开始。
由于我们一次载入整个字,因此忽略地址的最低两位(字节偏移)。接下来的3位用于确定数据存放在哪个集合。因为有8个缓存条目(2^3 = 8),所以需要3位索引。
例如,地址0(字地址)的索引位是000,它映射到集合0。地址32(字地址8,因为8 * 4 = 32)的索引位也是000,同样映射到集合0。
地址中剩余的位被称为标签。标签用于标识具体是哪个地址的数据。例如,地址0的标签是0,而地址32的标签是1。我们必须将标签与数据一起存储在缓存中,以便区分映射到同一集合的不同数据。
此外,每个缓存条目还有一个有效位。当缓存是“冷”的(即未载入任何数据)时,所有有效位都是0。只有当数据被载入某个缓存条目时,其有效位才被设置为1。
这个缓存的尺寸可以看作是一个小型SRAM(静态随机存取存储器),每个条目的宽度是:1位(有效位)+ 27位(标签位)+ 32位(数据位)。因此,这是一个8行 x (1+27+32)位的SRAM。
缓存访问示例
假设我们执行指令 lw t2, 0(x0),访问地址0。这是第一次访问,缓存未命中。我们从主存中取出数据(例如 0x01234567)载入缓存。同时,我们将标签设置为0,并将该条目的有效位设为1。
接着,执行指令 lw t2, 32(x0),访问字地址32(内存地址32)。其索引位指向集合0。此时,缓存中集合0已有数据。由于标签不匹配(请求的标签是1,缓存中的标签是0),发生未命中。我们需要将地址0的数据“驱逐”出缓存,然后将地址32的数据(例如 0xABCD1234)和其标签(1)载入,并保持有效位为1。
如果下一条指令再次访问地址32,处理器会检查集合0。此时标签匹配(都是1),且有效位为1,因此发生缓存命中,数据可以直接从缓存中读取并发送给处理器。
反之,如果下一条指令访问地址0,处理器检查集合0,发现标签不匹配(请求0,缓存中是1),尽管有效位为1,但仍为缓存未命中。此时无法使用缓存中的数据,必须访问下一级存储层次(如主存)来获取数据,并将其载入缓存。
时间局部性示例
以下是一段RISC-V汇编代码示例,展示了时间局部性:
addi s1, zero, 0
addi s0, zero, 5
loop:
lw t2, 4(s1) # 访问地址 4
lw t2, 12(s1) # 访问地址 12
lw t2, 8(s1) # 访问地址 8
addi s0, s0, -1
bne s0, zero, loop
假设初始缓存为空(冷缓存)。
- 第一次循环迭代:三次
lw指令都会未命中(强制性未命中),并将数据分别载入到集合1(地址4)、集合3(地址12)和集合2(地址8)。 - 后续四次循环迭代:所有对地址4、12、8的访问都会命中,因为数据已经在缓存中。
总访问次数:循环5次 * 3次加载 = 15次。
未命中次数:3次(仅第一次迭代)。
未命中率:3/15 = 20%。
此例体现了时间局部性:最近访问过的数据很可能再次被访问。最初的未命中属于强制性未命中,因为缓存初始为空。
冲突未命中示例
以下代码展示了冲突未命中:
addi s1, zero, 0
addi s0, zero, 5
loop:
lw t2, 4(s1) # 访问地址 4 (映射到集合1)
lw t2, 0x24(s1)# 访问地址 0x24 (也映射到集合1)
addi s0, s0, -1
bne s0, zero, loop
地址4(二进制...00100)和地址0x24(二进制...00100100)的索引位相同,都映射到集合1。
假设初始缓存为空。
- 第一次迭代:访问地址4,未命中,数据载入集合1。访问地址0x24,未命中,它驱逐地址4的数据,将自己载入集合1。
- 第二次迭代:访问地址4,未命中(因为数据已被驱逐),它驱逐地址0x24的数据,将自己载入。接着访问地址0x24,再次未命中,又驱逐地址4的数据。
- 此模式在每次循环中重复。
总访问次数:循环5次 * 2次加载 = 10次。
未命中次数:10次(每次访问都未命中)。
未命中率:10/10 = 100%。
这种因为不同数据映射到同一缓存集合而相互驱逐导致的未命中,称为冲突未命中。
总结

本节课我们一起学习了直接映射缓存的核心机制。我们了解了内存地址如何通过索引位映射到特定的缓存集合,并利用标签来唯一标识数据。通过示例,我们分析了缓存命中、未命中、强制性未命中和冲突未命中的情况。直接映射缓存结构简单,但可能因冲突未命中而导致性能下降,这是其设计上的一个权衡。理解这些基础是学习更复杂缓存组织方式(如组相联缓存)的重要前提。
120:关联缓存 🧠

在本节中,我们将学习关联缓存。关联缓存是一种用于减少缓存冲突失效的技术。我们将了解其工作原理、不同类型以及它们如何提升缓存性能。
概述
上一节我们介绍了直接映射缓存及其可能遇到的冲突失效问题。本节中,我们来看看如何通过引入关联缓存来缓解这个问题。
关联缓存,有时也称为N路组关联缓存,为每个缓存组提供了多个存储位置(称为“路”)。这就像一条高速公路,从单车道变成了多车道,减少了因前方车辆(数据块)过慢而导致的堵塞(冲突失效)。
组关联缓存的工作原理
我们使用相同大小的缓存,仍存储8个字。但组数减少了。这里我们有4个组(0, 1, 2, 3)。因此,我们使用2位地址来指示内存地址将映射到哪个组。
内存地址可以映射到该组中的任意一个“路”。例如,假设我们有一个地址映射到组2。如果该组完全为空,我们可以选择将数据放入任意一个路。但如果其中一个路已经存有数据(带有某个随机标签),而另一个路无效,那么新数据将被存入那个无效的路。
以下是缓存命中的判断过程:
- 处理器提供地址。
- 地址被拆分为标签位和组索引位。
- 根据组索引找到对应的缓存组。
- 将该组的每一路的标签与地址标签进行比较。
- 同时检查每一路的有效位。
- 如果某一路的标签匹配且有效位为1,则发生命中,通过多路选择器选择该路的数据。
- 如果所有路都不匹配或无效,则发生失效,需要从主存加载数据。
例如,首次访问地址 0x8 时,假设它映射到组2且该组为空,数据被存入组2的某一路(如way1)。下次再访问 0x8 时,缓存控制器会比较组2中所有路的标签,发现way1的标签匹配且有效,于是命中,从way1读取数据。
关联性如何减少冲突失效
让我们回顾直接映射缓存中导致冲突失效的例子:循环访问地址 0x4 和 0x24。在直接映射缓存中,由于 0x4 和 0x24 的低位地址相同,它们会映射到同一个缓存块,导致频繁的冲突失效。
在两路组关联缓存中,情况得到改善:
- 地址
0x4首次访问(强制性失效),数据被载入其映射组的way0。 - 地址
0x24首次访问(强制性失效),由于同一组还有way1可用,数据被载入way1。 - 后续的循环访问中,
0x4会在way0命中,0x24会在way1命中。
假设循环运行5次,共10次内存访问:
- 只有最初的2次访问是失效(强制性失效)。
- 后续8次访问全部命中。
- 总失效率为 2/10 = 20%。
这比直接映射缓存下的高失效率要好得多。因此,关联性通过为映射到同一组的数据提供多个存放位置,有效减少了冲突失效。
全关联缓存
组关联缓存的一个极端形式是全关联缓存。在全关联缓存中,整个缓存被视为一个单一的组。这意味着任何内存块可以存放在缓存中的任何一个块位置。
- 对于一个有8个块的缓存,全关联就意味着它是一个8路关联缓存(只有1个组,包含8路)。
- 地址中不再需要组索引位,整个地址(除了字内偏移)都作为标签。
- 缓存控制器必须将输入地址的标签与缓存中所有块的标签进行比较,以判断是否命中。
全关联缓存几乎能消除所有冲突失效,因为数据块之间不再因映射规则而竞争同一个位置。然而,它的实现成本很高:
- 需要大量的比较器(与缓存块数量相同)进行并行标签比较。
- 用于选择输出数据的多路选择器规模更大(例如,8路需要8选1多路器)。
- 缓存替换策略(当缓存满时决定替换哪一块)变得更加关键和复杂。
因此,全关联缓存虽然性能理想,但由于其硬件复杂性和成本,通常只用于容量非常小的缓存(如TLB)。
总结
本节课中我们一起学习了关联缓存。
- 组关联缓存在直接映射缓存的基础上,为每个组增加了多个“路”,从而降低了冲突失效的概率。它是性能和硬件成本之间的一个良好折衷。
- 全关联缓存允许数据块存放在任意位置,能最大程度减少冲突失效,但硬件实现复杂、成本高昂。
- 关联度的选择(如2路、4路、8路)是计算机架构设计中一个重要的权衡点,需要在失效率改善和增加的硬件开销(比较器、多路选择器复杂度)之间取得平衡。

通过理解关联缓存,我们掌握了提升缓存系统性能的关键机制之一。下一节,我们将探讨另一个重要主题:缓存替换策略。
121:空间局部性 🧠

在本节中,我们将学习缓存设计中的另一个重要概念——空间局部性。上一节我们介绍了时间局部性,本节中我们来看看空间局部性如何通过一次读取多个连续的字来提升缓存效率。
空间局部性是指,当我们访问内存中的一个字时,很可能在不久的将来也会访问其附近地址的字。为了利用这一点,我们可以增加缓存的块大小,使得一次内存访问能加载一个包含多个字的块,而不仅仅是单个字。
增加块大小
在之前的示例中,我们的块大小只有一个字。现在,我们将块大小增加到四个字。缓存总容量仍然是八个字,但由于每个块包含四个字,我们现在只能存储两个块。因此,缓存中的组数减少为两组(组0和组1)。
内存地址的格式也随之改变。我们引入了一个新的字段,称为块偏移。由于块大小为4(即2²个字),块偏移需要2位。如果块大小为8,则需要3位(log₂8=3)。块偏移用于指定我们想要访问块内的哪一个字。
以下是地址字段的划分:
- 字节偏移:最低几位,指定字内的字节(通常忽略)。
- 块偏移:接下来的几位,指定块内的字。
- 组索引:再接下来的位,指定数据应存储在哪个组。
- 标签:剩余的位,用于唯一标识内存块。
空间局部性示例
假设我们访问地址 0x8。在直接映射缓存中,处理器不仅会将地址 0x8 处的字加载到缓存中,还会将整个包含 0x0、0x4、0x8 和 0xC 的块加载进来。
随后,如果我们访问地址 0xC,处理器会检查缓存。由于块偏移不同,它需要的是块内的另一个字。但由于空间局部性,整个块已经在第一次访问 0x8 时被加载进来了,因此对 0xC 的访问将是一次命中,无需再次访问主存。
性能优势分析
考虑一个访问序列:在一个循环中依次访问地址 0x4、0x8 和 0xC,循环执行5次。
- 总访问次数:3次访问/循环 × 5次循环 = 15次。
- 缺失次数:只有第一次访问
0x4时会发生缺失(冷启动缺失)。后续对0x8和0xC的访问,由于它们与0x4属于同一个块且已被加载,都会命中。 - 缺失率:1 / 15 ≈ 6.67%。
与块大小为1个字的情况相比,缺失率显著降低,这清晰地展示了利用空间局部性的优势。
缓存缺失类型回顾
以下是三种主要的缓存缺失类型:
- 强制性缺失:数据第一次被访问时必然发生的缺失。
- 容量缺失:由于缓存容量有限,无法容纳所有活跃数据而导致的缺失。
- 冲突缺失:在组相联或直接映射缓存中,多个活跃数据块映射到同一个缓存组,相互冲突而被替换出去导致的缺失。
增加块大小主要有助于减少强制性缺失。
缓存组织参数总结
以下是描述缓存组织方式的关键参数及其关系:
- 容量:
C(总字节数或字数) - 块大小:
b(每块的字节数或字数) - 块数:
B = C / b - 相联度:
N(每组中的块数,也称为路数) - 组数:
S = B / N

根据这些参数,我们可以定义不同类型的缓存:
- 直接映射缓存:
N = 1,S = B - N路组相联缓存:
1 < N < B,S = B / N - 全相联缓存:
N = B,S = 1(即整个缓存只有一个组)
总结

本节课中我们一起学习了空间局部性的概念及其在缓存设计中的应用。通过增加缓存块的大小,处理器可以一次性预取多个连续的字。当程序展现出良好的空间局部性(即顺序访问内存)时,这种方法能有效减少强制性缺失,从而显著降低缺失率并提升整体性能。同时,我们也回顾了缓存的三种缺失类型和描述缓存组织的关键参数公式。理解这些概念对于分析和优化计算机系统的内存层次结构至关重要。
122:LRU(最近最少使用)替换策略 🧠

在本节中,我们将学习当缓存已满时,如何选择要替换的数据块。核心目标是选择一个未来最不可能被再次访问的块,以最小化缓存未命中率。
上一节我们介绍了缓存未命中的概念,本节中我们来看看一种具体的替换策略。
概述
当缓存容量太小,无法一次性容纳所有感兴趣的数据时,程序访问一个不在缓存中的数据X,就必须驱逐(替换)缓存中已有的某个数据Y。如果之后程序再次访问Y,就会发生缓存未命中。因此,我们需要一种策略来选择被替换的数据Y,以最小化未来需要它的可能性。
LRU替换策略
最常用的方法是最近最少使用(LRU)替换策略。其基本思想是:选择那个距离上次使用时间最久的数据块进行替换。
以下是LRU替换的一个示例。假设我们依次访问以下地址(十六进制):0x4, 0x24, 0x4, 0x54。我们使用块大小为1个字(word)的缓存,并且有4个组(set),因此使用2位作为组索引(set index)。
首先,我们分析这些地址的映射关系。地址位分解如下(假设字寻址):
- 块偏移(block offset): 0位(因为块大小=1字)
- 组索引(set index): 低2位
- 标签(tag): 剩余高位
可以验证,地址0x4(二进制0100)、0x24(二进制100100)、0x54(二进制1010100)的组索引(低2位)都是00,因此它们都映射到组0。
现在,我们一步步跟踪LRU策略下的缓存状态变化。为了跟踪“最近使用”情况,我们为组内的每个“路”(way)维护一个使用位(use bit),标记哪一路是最近最少使用的。
以下是访问序列的详细过程:
-
首次访问
0x4:组0为空。我们将地址0x4的数据载入组0的某个空闲路(例如路0),标签设为高位部分,并将该路标记为有效(Valid=1)。此时,路0是最近使用过的。 -
访问
0x24:0x24也映射到组0,且其标签与路0中的标签不同,发生缓存未命中。由于组0中路1空闲,我们将0x24的数据载入路1。现在,路0和路1都存有数据。我们需要更新使用位,记录路1是最近刚被使用的。 -
再次访问
0x4:此次访问命中缓存(数据已在路0中)。访问后,我们需要更新使用位,表明路0刚刚被使用过,因此它不再是“最近最少使用”的。 -
访问
0x54:0x54映射到组0,但其标签与路0和路1中的标签均不匹配,发生缓存未命中。此时组0已满(路0和路1都有效)。根据LRU策略,我们检查使用位,找出自上次访问以来最久未被使用的路。假设我们的记录显示路1是最近最少使用的。因此,我们驱逐路1中0x24的数据,并将0x54的数据载入路1。之后,更新使用位,标记路1为最近使用过的。
关键逻辑可以用伪代码描述:
if (缓存访问未命中) {
找到目标组;
if (该组中有空闲路) {
将数据载入空闲路;
} else {
根据LRU策略(检查使用位)选择该组中一个数据块进行替换;
驱逐旧数据,载入新数据;
}
}
更新被访问路(无论是命中还是新载入)的使用位,标记其为“最近使用”;
总结

本节课中我们一起学习了LRU(最近最少使用)缓存替换策略。该策略基于程序访问的时间局部性原理,认为最近被访问过的数据在短期内更可能再次被访问。因此,当需要替换时,它选择驱逐那个最久未被访问的数据块。实现LRU通常需要为缓存中的每个块维护额外的元数据(如使用位或时间戳)来跟踪访问顺序。这是一种有效且广泛应用的缓存管理策略。
123:缓存总结

在本节中,我们将对缓存的核心概念进行总结,回顾其工作原理、性能影响因素以及多级缓存结构。
缓存数据与寻址 🧠
上一节我们介绍了缓存的基本思想,本节中我们来看看缓存具体存储什么数据以及如何找到它们。
缓存中存储的是处理器最近使用过的数据(利用时间局部性)以及附近的数据(利用空间局部性)。
数据如何被找到?首先,由处理器提供的地址决定数据位于哪个组。接着,同一个地址也决定了数据在块内的具体字位置。在相联缓存中,数据可能位于组内的多个路中的任何一个。
当需要替换数据时,通常采用最近最少使用策略来替换组内的一路数据。
缓存性能趋势图 📊
以下是展示缓存缺失率趋势的图表。图表底部是缓存容量(单位:千字节),向右递增;纵轴是总体缺失率。
可以看到,典型的缓存缺失率范围很广。例如,10%的缺失率对缓存而言相对较高,而低于1%的缺失率则表现优异。
容量、相联度与块大小的影响 ⚙️
现在,我们来分析影响缓存性能的几个关键参数。
相联度的影响
随着相联路数的增加,我们可以减少冲突缺失。例如,对于一个8KB的缓存,从直接映射(1路)改为2路相联,能显著减少冲突缺失。增加到4路能带来小幅提升,但增加到8路则收效甚微。
容量的影响
图表下方的容量缺失会随着缓存容量的增大而减少。而强制性缺失所占比例通常很小,在图中几乎看不到。
核心结论是:更大的缓存减少容量缺失,更高的相联度减少冲突缺失。
块大小的影响
为了利用空间局部性,我们会增加块大小。图表展示了从16字节到256字节不同块大小下,多种缓存容量(4KB到256KB)的缺失率变化。
对于较小的缓存(如4KB),增加块大小起初有助于降低缺失率,但超过某个点(例如64字节后)反而可能因加剧冲突而导致缺失率上升。
对于较大的缓存(如64KB或256KB),增加块大小(直至64字节)能有效降低缺失率,但超过此点后收益甚微。
多级缓存 🏗️
我们可以构建多级缓存系统。我们之前讨论的通常是第一级缓存,但现代系统普遍包含第二级缓存,第三级缓存也很常见。
较低级别的缓存容量更大,缺失率更低,但访问时间也更长。我们不希望将第一级缓存做得太大,以免其访问时间超过一个处理器时钟周期。
因此,为了获得更大的有效缓存容量,我们采用多级结构:
- 第一级缓存:容量小、速度快。典型大小为16KB,目标是在1个时钟周期内完成访问。
- 第二级缓存:容量更大。典型大小为256KB,访问需要数个时钟周期。
- 第三级缓存:容量最大,访问时间也最长。
下图展示了Pentium 3处理器的芯片布局,可以清晰地看到规整排列的一级缓存和二级缓存存储阵列。


总结 📝
本节课中我们一起学习了缓存系统的核心总结。我们回顾了缓存存储的数据类型(基于时间与空间局部性)和寻址方式。通过分析图表,我们理解了缓存容量主要影响容量缺失,相联度主要影响冲突缺失,而块大小的优化需要权衡空间局部性收益与潜在的冲突增加。最后,我们介绍了多级缓存的设计理念,即通过分层结构在访问速度与容量/低缺失率之间取得平衡。
124:虚拟内存简介 💾

在本节中,我们将学习计算机内存层次结构中的最低一层——虚拟内存。我们将了解它如何利用硬盘来模拟更大的内存空间,以及它如何提供内存保护功能。

上一节我们介绍了内存层次结构中的缓存。本节中,我们来看看层次结构中的最后一级,即虚拟内存。
虚拟内存存储在硬盘上。它给程序制造了一种拥有更大内存的假象,而主内存(DRAM)则充当了硬盘的缓存。硬盘速度很慢,但容量非常大且成本低廉。

以下是两种常见的硬盘类型:
- 硬盘驱动器:包含可旋转的磁性盘片和读写磁头,访问时间在毫秒级。
- 固态硬盘:目前更常用,使用闪存芯片,没有机械部件,速度更快。
程序运行时使用的是虚拟地址,整个虚拟地址空间都存储在硬盘上。其中一部分数据子集被加载到主内存(DRAM)中。CPU负责将虚拟地址翻译成物理地址(即DRAM地址)。如果所需数据不在DRAM中,则需要从硬盘中获取。
除了能有效扩展可访问的内存容量,虚拟内存还提供了内存保护功能。每个程序都有自己独立的虚拟地址到物理地址的映射关系。因此,两个程序可以使用相同的虚拟地址来指向不同的物理数据。程序无需知道其他程序的存在,虚拟内存系统会处理这一切。这防止了一个程序(或病毒)错误地访问或修改另一个程序正在使用的内存。
虚拟内存与缓存系统有许多相似之处,我们可以进行类比:
以下是缓存与虚拟内存的关键概念对比:
- 缓存块 ↔ 页:缓存中一次传输的数据单位称为“块”;虚拟内存中,从硬盘加载到物理内存的数据单位称为“页”。
- 块内偏移 ↔ 页内偏移:用于定位块或页内的具体字。
- 未命中 ↔ 缺页:缓存中找不到数据称为“未命中”;虚拟内存中找不到对应页称为“缺页”。
- 标记 ↔ 虚拟页号:缓存中用于匹配的标识;虚拟内存中用于查找物理页的标识。
因此,物理内存(主存)实质上是虚拟内存的一个缓存。
以下是虚拟内存系统的几个核心概念:
- 页大小:指一次从硬盘传输到DRAM的内存容量。
- 地址翻译:将处理器生成的虚拟地址转换为物理地址的过程。
- 页表:一个用于实现从虚拟地址到物理地址转换的查找表。
我们可以通过一个示意图来理解:左侧是虚拟地址空间,其中一部分页(蓝色)仅存在于硬盘上。另一部分页则被加载到物理内存(主存)中,处理器可以直接访问它们,尤其是可以将其调入更快的缓存。大多数内存访问都能在物理内存中命中,但程序可以访问的虚拟地址空间容量要大得多。虚拟内存系统之所以能提供“容量大且速度快”的假象,正是得益于硬盘(虚拟内存)和DRAM(物理内存)之间的这种层次化协作。

本节课中我们一起学习了虚拟内存的基本原理。我们了解到虚拟内存如何利用硬盘扩展内存容量,并通过地址翻译和页表机制提供内存保护。同时,我们也看到了虚拟内存与缓存系统在概念上的相似性,这有助于我们理解整个内存层次结构是如何协同工作的。
125:地址转换 🧠

在本节中,我们将学习虚拟内存系统中的核心机制——地址转换。我们将了解处理器如何生成虚拟地址,以及系统如何将其转换为物理地址以访问主内存。
上一节我们介绍了虚拟内存的基本概念,本节中我们来看看地址转换的具体过程。
处理器在虚拟内存系统中生成的是虚拟地址,而非物理地址。物理地址用于寻址主内存。因此,我们需要将处理器产生的虚拟地址转换为物理地址,才能访问主内存中的数据。
以本系统为例,虚拟地址为31位(位0至位30),物理地址为27位(位0至位26)。这意味着虚拟地址空间大小为 2^31 字节 = 2 GB,而物理内存大小为 2^27 字节 = 128 MB。
地址转换的关键在于:页内偏移量(即字或字节在页内的位置)保持不变,无需转换。需要转换的是虚拟页号到物理页号的映射。
例如,虚拟页号 9 可能映射到物理页号 1。一个虚拟页可以映射到物理内存中的任意位置。
以下是本示例系统的具体参数:
- 虚拟内存大小:
2^31 B = 2 GB - 物理内存大小:
2^27 B = 128 MB - 页大小:
2^12 B = 4 KB
因此,虚拟页的数量为 2^31 / 2^12 = 2^19 个,物理页的数量为 2^27 / 2^12 = 2^15 个。物理页的数量少于虚拟页。
为了更直观地理解,我们来看一个映射示例。
下图展示了物理内存(主内存)和虚拟内存(通常位于硬盘等存储设备上)的映射关系。在本例中,我们有19位的虚拟页号和15位的物理页号。

例如,虚拟页 5 映射到物理页 1。虚拟页 0x7FFD 映射到物理页 0。图中白色的虚拟页表示当前未映射到物理内存中,因为物理内存容量小于虚拟地址空间,无法一次性容纳所有虚拟页。
接下来,我们通过一个具体例子来实践地址转换过程。
问题是:虚拟地址 0xE2E247C 对应的物理地址是什么?
以下是转换步骤:
- 分离页内偏移:虚拟地址的低12位(
0x47C)是页内偏移,这部分直接保留,无需转换。 - 提取虚拟页号:剩余的高位部分(
0xE2E24>> 12 =0xE2E)是虚拟页号。在本例中,虚拟页号为2(即0xE2E对应的页索引,图中示例简化表示)。 - 查询页表映射:根据映射图(或页表),查找虚拟页号
2对应的物理页号。图中显示,虚拟页2映射到物理页0x7FFF。 - 组合物理地址:将得到的物理页号(
0x7FFF)与保留的页内偏移(0x47C)组合,形成完整的物理地址:0x7FFF47C。
因此,虚拟地址 0xE2E247C 对应的物理地址是 0x7FFF47C。


本节课中我们一起学习了地址转换的核心流程。我们了解到处理器使用虚拟地址,通过页表机制将虚拟页号转换为物理页号,同时保持页内偏移不变,从而生成访问主内存所需的物理地址。这个过程使得程序可以使用远大于物理内存的地址空间,是虚拟内存系统得以实现的基础。
126:页表

在本节中,我们将学习虚拟地址如何通过页表转换为物理地址。页表是内存管理单元(MMU)中的核心数据结构,负责记录虚拟页与物理页之间的映射关系。
页表概述
为了完成虚拟地址到物理地址的转换,我们使用一种称为页表的结构。页表为每个虚拟页提供一个条目,因此条目的数量等于虚拟页号的数量。
页表条目结构
页表中的每个条目都包含一个有效位和该页所在的物理页号。
- 有效位:指示该虚拟页是否已映射到物理内存(主存)。
- 物理页号:如果有效位为真,则此字段存储该虚拟页对应的物理页号。
如果条目无效,则意味着该虚拟页当前未映射到物理内存。
页表工作示例
以下是一个页表示例。在本例中,虚拟页号有19位,因此我们的页表条目总数(未全部显示)是 2^19 个。
我们使用虚拟页号来索引页表。例如,处理器产生虚拟地址 0x247C。我们不转换页内偏移量,只关注虚拟页号,即高19位。这里是 2,因此我们查看页表的条目2。条目2是有效的,表示该页已映射到物理内存。我们从该条目中读取物理页号(即上一张幻灯片中使用的转换结果)。我们检查有效位,如果命中(即有效),则使用该物理页号,然后附加页内偏移量,从而得到物理地址。
实践练习
给定这个页表,虚拟地址 0x5F20 对应的物理地址是什么?
请先自行尝试,然后我们一起解答。
解答过程:
页内偏移量仍然是低12位,因为我们的页大小是 2^12 字节。这部分我们不转换。我们只转换虚拟页号。
我们查看页表,查找条目 5(因为虚拟页号是5)。该条目有效,意味着该页已映射到物理内存。其转换后的物理页号是十六进制数 0x1。
现在,我们将这个物理页号与我们的页内偏移量拼接起来,就得到了物理地址 0x1F20。这就是虚拟地址 0x5F20 对应的物理地址。
页表未命中示例
第二个例子:虚拟地址 0x73E4 的物理地址是什么?
同样,我们只看高位数。低12位是页内偏移量,我们不看这12位,因为它们保持不变(这基本上表示我们在该页内查找哪个字)。我们使用高19位的虚拟页号。
我们查看页表中的条目7,发现它是无效的。这意味着该页没有映射到物理内存。因此,系统需要将该页调入物理内存,或者选择物理内存中的一个页来写入这个虚拟页。这在系统中称为未命中。
性能考量与术语
将页面调入物理内存的术语称为分页,有时当需要换出另一个页面时,也称为交换。
页表通常非常大,并且因为它很大,所以通常位于物理内存中。因此,一次加载或存储操作不仅需要一次内存访问来读取页表以完成虚拟到物理地址的转换,还需要在获得物理内存地址后,再进行一次内存访问来实际存取数据。这会使内存性能减半,除非我们采用一些更巧妙的设计。
本节总结

本节课中,我们一起学习了页表的基本原理。我们了解到页表通过虚拟页号索引,每个条目包含有效位和物理页号,共同完成地址转换。通过实例,我们练习了如何使用页表将虚拟地址转换为物理地址,并理解了当页表条目无效(未命中)时,系统需要进行分页操作。最后,我们指出了基础的页表设计会带来额外的内存访问开销,影响性能。
127:TLB(转换后备缓冲器)🚀

在本节中,我们将学习如何通过使用一种称为转换后备缓冲器(TLB)的机制,来加速虚拟地址到物理地址的转换过程。
上一节我们介绍了虚拟内存和页表的基本概念。虽然页表提供了灵活的地址转换,但每次内存访问都需要先查询页表,这导致了一次内存访问变成了两次,降低了效率。本节中我们来看看如何解决这个问题。
TLB 概述
为了更高效地进行虚拟地址到物理地址的转换,我们采用了一种称为转换后备缓冲器(TLB)的机制。TLB 本质上是一个小型缓存,用于存储最近使用过的地址转换条目。
我们利用程序访问的时间局部性原理:如果一个地址转换最近被使用过,那么它很可能在不久的将来再次被使用。因此,TLB 可以显著减少大多数加载和存储操作所需的内存访问次数,从两次降回一次。
以下是 TLB 的关键特性:
- 目的:缓存最近使用过的页表条目(即虚拟页号到物理页号的映射)。
- 工作原理:基于时间局部性。由于页表访问具有很高的时间局部性,且页面尺寸很大(例如 4KB),连续的加载和存储操作很可能访问同一个页面。
- 结构:它是一个小型缓存,通常包含 16 到 512 个条目。
- 性能:访问速度极快,通常在一个处理器时钟周期内完成。它通常是全相联的,命中率通常高于 99%。
- 效果:将大多数加载和存储操作的内存访问次数从两次减少到一次。
TLB 工作示例
让我们通过一个具体的例子来理解 TLB 是如何工作的。下图展示了一个包含两个条目的 TLB:

这个结构看起来很像我们之前学过的缓存图。但区别在于,TLB 中存储的不是数据,而是地址转换条目(物理页号)。其标签部分存储的是对应的虚拟页号。由于它是全相联的,任何转换条目可以存放在任何一路(Way)中。
假设处理器要访问虚拟地址 0x247C。地址转换过程如下:
- 分离地址:首先,将虚拟地址拆分为虚拟页号和页内偏移。页内偏移(低12位)不参与转换,将直接成为物理地址的低12位。
- TLB 查找:使用虚拟页号作为标签,在 TLB 的所有条目中进行并行查找(因为是全相联)。我们需要检查每个条目的标签是否匹配。
- 查看条目1(左侧):其虚拟页号(
0x2)与请求的虚拟页号不匹配。因此,条目1的比较器输出为0(未命中)。 - 查看条目0(右侧):其虚拟页号(
0x2)与请求的虚拟页号匹配。同时,该条目是有效的(Valid 位为1)。
- 查看条目1(左侧):其虚拟页号(
- 生成命中信号:条目0的比较器输出为1(命中),条目1的输出为0。通过一个选择逻辑(例如,一个多路选择器),我们确定命中发生在条目0。
- 组合物理地址:从命中的 TLB 条目(条目0)中取出存储的物理页号(
0x7FF)。将这个物理页号与第一步得到的页内偏移组合起来,就形成了完整的物理地址。 - 完成访问:处理器获知 TLB 命中,并得到了转换后的物理地址,随后便可以使用这个物理地址进行实际的内存访问。

至此,一次借助 TLB 的快速地址转换就完成了。
总结

本节课中我们一起学习了转换后备缓冲器。TLB 是一个用于缓存虚拟页号到物理页号映射关系的小型专用高速缓存。它利用程序访问的时间局部性,将最常见的地址转换结果保存在处理器附近,从而在绝大多数情况下避免了耗时的页表内存访问,将有效内存访问时间恢复到了接近单次访问的水平。这是现代计算机内存管理系统实现高效虚拟内存的关键组件之一。
128:虚拟内存总结 🧠

在本节中,我们将对虚拟内存的核心概念进行总结。虚拟内存是现代计算机系统中管理内存的关键技术,它允许多个程序高效、安全地共享物理内存资源。
上一节我们介绍了虚拟内存的具体机制,本节中我们来看看它的核心优势与工作原理总结。
虚拟内存提供了内存保护功能。多个同时运行的程序或进程无法访问同一块物理内存,因为每个进程都拥有自己独立的页表。
每个进程都可以使用整个虚拟地址空间。例如,在我们的案例中,虚拟地址空间可达 2^31 字节(即 2 GB)的数据。进程不会因为其他进程也需要内存而被限制只能使用少量数据。
但是,一个进程在物理内存中只能访问其整个虚拟地址空间的一个子集。这些子集通过其自身的页表进行映射。因此,在任何给定时刻,只有该虚拟内存的一个子集可以被调入主内存。
虚拟内存增加了系统的有效容量。虽然只有虚拟页的一个子集在物理内存中,但系统给人的感觉是,你可以访问所有内存,只是不能同时访问全部。
以下是虚拟内存系统的核心组件:
- 页表:负责将虚拟页映射到物理页,并执行地址转换。
- TLB:即翻译后备缓冲器,用于加速地址转换过程。
不同的程序拥有不同的页表,每个程序都有自己的页表。这为进程之间提供了内存保护,防止一个进程覆盖另一个正在运行的程序所使用的数据。

本节课中我们一起学习了虚拟内存的总结。我们了解到,虚拟内存通过为每个进程提供独立的虚拟地址空间和页表,实现了内存保护和多程序并发运行。它利用页表进行地址映射,并通过TLB来提升性能,使得有限的物理内存能够高效地支持更大的虚拟地址空间。
129:嵌入式系统与输入输出简介 🚀

在本章中,我们将学习嵌入式系统以及计算机的输入输出。我们将深入探讨微控制器,这是一种集成了微处理器和各种输入输出外设的芯片,用于控制现实世界中的设备。
什么是微控制器?💡
上一节我们介绍了本章的主题。本节中,我们来看看微控制器的具体定义。
一个微处理器是单芯片上的计算机。而一个微控制器则是在同一芯片上集成了微处理器和灵活的输入输出外设,旨在控制现实世界中的事物。
以下是微控制器中常见的一些外设示例:
- 通用输入输出:能够将引脚驱动为高或低逻辑电平。
- 串行端口:通过少量电线(一次一位)控制设备。
- 定时器:用于测量或生成精确的时间间隔。
- 模数转换器和数模转换器:在现实世界的模拟域和处理器的数字域之间进行转换。
- 脉宽调制:与定时器相关,可以生成精确宽度的脉冲,并用这些脉冲序列驱动现实世界中的设备(如电机)。
- 通用串行总线:一些微控制器能够通过USB或以太网等标准与其他设备通信。
嵌入式系统与应用领域 🌍
微控制器通常用于我们所说的嵌入式系统中。嵌入式系统是一个你不认为它是计算机的系统,尽管它内部有一个微处理器。
以下是一些嵌入式系统的例子:
- 微波炉:内部有微处理器运行时钟,控制加热时间,并可能包含热过载传感器。
- 时钟和收音机:如今内部都包含微处理器。
- 汽车:电子燃油点火系统、娱乐系统、甚至电动车窗都包含微控制器。
- 儿童玩具:任何使用电池并发出声音的玩具,都是一个由微处理器生成声音的嵌入式系统。
- 医疗设备:例如植入体内帮助治疗糖尿病的血糖监测仪。
微控制器是半导体行业的一个重要领域。在2019年,大约售出了260亿个微控制器,相当于地球上每人约3个。微控制器的平均价格约为60美分。例如,2019年一辆普通的新车内部大约有70个微控制器。汽车是微控制器的最大市场,此外还广泛应用于消费电子、工业应用和军事领域。
微控制器的特点与分类 🔧
许多微控制器的成本低于40美分,每一分钱都至关重要。如今,在系统中加入一个处理器的成本可能比加入一根电缆或推杆来驱动某物更便宜。如果可以通过电气方式实现,通常会更便宜、更灵活、更智能、可重新编程,并且可能更节能。
微控制器通常根据其内部总线的大小(即单次操作能访问多少内存)分为8位、16位或32位。
- 8位微控制器在20世纪70年代开始流行,至今仍能满足大量应用需求。
- 16位微处理器在2019年占据了最大的收入份额。
- 32位微处理器正在迅速普及,其相对于16位处理器的增量成本目前仅为5到10美分,速度更快、更灵活,但缺点是往往使用更多的代码内存。
微处理器按其架构分类,架构是微处理器理解的本地机器语言。本课程中我们关注的是RISC-V架构。其他商业架构示例包括在移动设备中非常广泛的ARM架构,以及仍在广泛使用的经典PIC和8051微架构。
本章学习路线图 🗺️
在接下来的小节中,我们将具体探索以下内容:
- 内存映射I/O:了解如何通过程序读写特定的内存地址来控制物理引脚。
- 设备驱动程序:学习控制与设备通信的软件,它向程序员隐藏了设备的某些细节。
- 生成延迟:特别是如何使用定时器来产生精确的延迟。
- 实践应用:通过一个在LED上播放摩尔斯电码的程序示例来应用以上知识。
- 芯片间接口:重点介绍SPI(串行外设接口),这是一种常见的串行接口方式。
- 深入案例:最后,我们将深入分析一个SPI加速度计接口,详细了解如何将处理器真正连接到芯片。
本章大部分内容涉及应用软件和操作系统层面,设备驱动程序可以被视为操作系统的一部分。

本节课中,我们一起学习了嵌入式系统和微控制器的基本概念,了解了它们广泛的应用领域、成本优势以及分类方式,并预览了本章后续将深入探讨的具体技术主题。
130:RISC-V微控制器 🖥️

在本节中,我们将学习RISC-V微控制器,特别是以FE310芯片为例,了解其架构、特性以及可用的开发板。
RISC-V是一种相对较新的架构。它是一个开放标准架构,这使其与大多数其他架构区分开来。RISC-V最初于2010年在伯克利开始开发。它没有授权费用,因此任何人都可以按照自己的方式构建RISC-V微处理器,而无需支付费用或涉及专利。正因如此,RISC-V变得越来越流行,尤其是在嵌入式片上系统中,即处理器被集成到更大的芯片中。
该架构相对较新,目前市面上广泛可用的RISC-V微控制器只有一款,即SiFive Freedom E310第二代芯片。它于2019年发布,并搭载在几款成本较低的开发板上。
FE310微处理器核心
Freedom E310微处理器是一个集成了多种功能的微控制器。首先是E31微处理器核心,它采用五级流水线设计,我们将在本课程的第7章讨论。它遵循RV32IMAC架构。RV32I是32位RISC-V整数指令集,即标准指令集。除此之外,还有几个扩展:M代表乘法和除法,即硬件乘法器和除法器;A代表原子内存操作,允许执行不可分割的访问操作;C代表压缩指令,这些指令被压缩为16位而非32位,以提高代码效率。
该处理器性能尚可,基准测试结果为每兆赫兹2.73 CoreMark。虽然高端处理器可以做得更好,但这个成绩并不差。芯片最高运行频率为320 MHz,对于微控制器来说相对较快,尽管它采用的是较旧的180纳米工艺。芯片核心工作电压为1.8伏,与外部世界通信的IO电压为3.3伏。
片上存储器
Freedom E310拥有多种片上存储器。它包括一个16 KB的静态RAM用于存储数据,存放最常用的变量。实际上,所有变量都存储在这里,这是芯片内置的全部RAM。它还有一个16 KB的指令缓存,用于存放最近访问的指令。
它有两种ROM:一个8 KB的掩膜ROM,在工厂编程;另一个8 KB的一次性可编程ROM用于启动。这些ROM的作用是,当处理器复位时,引导处理器到正确的位置开始获取指令。之后,大部分指令来自外部闪存芯片。
外设接口
处理器拥有多种IO外设。它有19个引脚可用于通用输入输出,可以驱动为高电平或低电平。它有几个串行端口:两个可供用户使用的串行外设接口,外加一个用于与外部闪存通信的接口;一个I²C接口;以及两个UART串行端口。它还有三个用于生成脉宽调制输出的模块、一个定时器以及一个通过JTAG连接的调试接口。
以下是处理器的引脚图。它采用48引脚QFN四方扁平无引线封装。需要注意的是,该芯片的焊盘在底部,因此焊接时需要在电路板底部的焊盘上涂上焊锡,然后仔细对齐芯片并加热。这对于手工操作来说并不容易。
在48个引脚中,其用途列在右侧:其中12个是电源和地引脚,例如VDD、IO VDD、模拟VDD等。19个引脚可用于通用IO,例如这里是GPIO 9号、10号、11号。其中6个用于JTAG编程器,JTAG代表联合测试行动组,是编程嵌入式系统的标准。6个用于通过SPI从闪存获取指令。两个连接时钟晶体,六个用于其他控制。
开发板选项
这些处理器被安装在开发板上,以下是三款市售的FE310开发板。每块板都搭载了FE310处理器。
以下是可用的开发板选项:
- SparkFun Red-V Thing Plus:售价30美元。它可以插入面包板,尺寸小巧,大约相当于一包口香糖的大小。一个缺点是默认情况下引脚没有焊接,因此需要使用电烙铁自行焊接引脚。但这是我们本课程中将使用的板子。
- SparkFun Red-V RedBoard:售价40美元。它与前者类似,尺寸稍大,并且引脚已经为我们焊接好了。一个奇怪之处是它使用了双重引脚编号,板上的引脚编号并不直接对应处理器的引脚编号。因此,每当你想使用某个引脚时,都需要查表,看板上标注的引脚编号如何映射到处理器的实际引脚编号。
- SiFive的HiFive1板:价格稍高,它内置了Wi-Fi和蓝牙功能。
这三块板在功能上相似。它们都通过USB连接供电,并允许你编程和调试开发板。它们也都有外部电源连接器,因此可以在没有USB连接的情况下使用设备。它们都将19个IO引脚引出到板载的引脚上,并且软件在所有板上都是兼容的。
在本课程中,我们将使用Red-V Thing Plus开发板进行实验,因为它成本最低,并且可以轻松插入面包板。
技术文档
在研究开发板上的内容时,我们需要参考两份数据手册。第一份是FE310-G002数据手册,其中包含引脚定义、电气规格、功能、使用的电压等信息,这些对于设计电路板的人员是相关的。在本章中,我们将主要参考的是FE310手册。它讨论了微处理器核心的规格、内存映射和外设,并包含了固件工程师或程序员所需的所有信息。
总结

本节课我们一起学习了RISC-V微控制器。我们介绍了RISC-V架构的开放特性,详细探讨了FE310微控制器的核心架构、存储器配置、丰富的外设接口以及引脚定义。我们还比较了几种基于FE310的流行开发板,并指出了本课程将使用的硬件平台。最后,我们明确了在开发过程中需要参考的关键技术文档。这些知识为我们后续进行RISC-V微控制器的实际编程和系统设计奠定了基础。
131:内存映射I/O 🧠💾

在本节中,我们将学习内存映射输入/输出的概念。这是一种通过读写特定内存地址(称为寄存器)来控制外部设备的方法。我们将了解其工作原理,并通过C语言代码示例来演示如何读取和写入这些寄存器。
内存映射I/O概述
在内存映射输入/输出机制下,我们通过读写被称为寄存器的特定内存位置来控制外部设备。这个术语可能有些误导,这些寄存器并不一定是一组触发器,而是内存中的一些特殊位置。通过向这些位置写入数据,我们可以引发现实世界中的物理动作;通过读取这些位置,我们可以测量现实世界的状态。
因此,处理器地址空间的一部分被保留用于I/O寄存器,而不是用于程序或数据。在C语言中,我们使用指针来指定这些地址进行读写操作。
FE310处理器的内存映射示例
以下是FE310处理器的内存映射示例:
- 地址
0x1000到0x1FFF是引导ROM。处理器启动时,从这里获取指令,然后跳转到一次性可编程ROM,最终跳转到代码闪存。 - 程序存储在闪存中,地址范围大约从
0x200000到0x400000。 - 静态RAM起始于
0x800000。 - 此外,还有许多外设分布在内存的不同地址区域,本章我们将探讨其中的几个。
C语言中的内存映射I/O示例
上一节我们介绍了内存映射的基本概念,本节中我们来看看如何在C语言中具体操作这些I/O寄存器。
首先,我们需要包含 stdint.h 头文件,以便使用各种大小的整数类型。由于寄存器是32位的,我们将使用 uint32_t 类型来表示一个32位的无符号整数。
假设我们想使用两个寄存器:一个用于通用输入,一个用于通用输出。我们稍后会详细解释其具体含义。这些寄存器中的32位可以用来读取I/O引脚的值,或者向引脚输出一个值。
根据内存映射,GPIO(通用输入输出)外设的起始地址是 0x10012000。输入寄存器正好位于这个地址,而输出寄存器则在其基础上偏移12个字节。
以下是声明指向这些地址的指针的方法:
volatile uint32_t *gpio_input = (volatile uint32_t*) 0x10012000;
volatile uint32_t *gpio_output = (volatile uint32_t*) 0x1001200C;
我们将它们声明为 volatile。这告诉C编译器,该值可能会在程序控制之外发生变化。因此,每次我们想查看这个值时,都需要实际去内存映射中获取,而不能假设它和上次获取时相同。
读取寄存器值
如果我们想读取所有32个GPIO引脚的值,可以解引用指向输入寄存器的指针:
uint32_t all_pins = *gpio_input;
这将得到一个32位的数字,我们可以将其存入一个变量。
假设我们特别想知道GPIO第19位(Bit 19)的值。我们需要从那个32位寄存器中提取出第19位。可以这样做:
uint32_t bit19_value = (*gpio_input >> 19) & 1;
首先获取整个32位的值,然后将其右移19位,最后与数字1进行“与”操作。这将留下一个0或1,即GPIO第19位的值。
如果我们想等待该位变为0,可以使用一个while循环:
while ( ((*gpio_input >> 19) & 1) == 1 ) {
// 等待
}
只要该位是1,循环条件就为真,程序就会等待,直到它不再是1。
类似地,如果我们想等待第19位变为1,可以在条件前加上逻辑非:
while ( ((*gpio_input >> 19) & 1) == 0 ) {
// 等待
}
写入寄存器值
如果我们想将某个特定位(例如第5位)写为1,可以这样做:
*gpio_output = *gpio_output | (1 << 5);
首先,将数字1左移5位,这样就在第5列有了一个1,其他位都是0。然后,将其与寄存器的当前值进行“或”操作。这将把1写入第5位,而其他所有位保持不变。
如果我们想强制将第5位写为0,可以这样做:
*gpio_output = *gpio_output & ~(1 << 5);
首先,将数字1左移5位,然后取反。这样我们就得到了一个在所有位都是1、唯独第5位是0的数字。将其与GPIO寄存器的值进行“与”操作。任何位与1相与保持不变,与0相与则变为0。因此,除了被强制设为0的第5位,其他所有位都保持不变。
操作详解
为了更清晰地理解,让我们详细看看几个核心操作。
读取特定位
假设我们想读取某个寄存器第3位的值。
- 我们有一个指向该寄存器的指针,解引用后得到一个32位的值
B。 - 将其右移3位:
B >> 3。这样,原来的第3位(B3)就移到了最低有效位(LSB)的位置。 - 将结果与数字1进行“与”操作:
(B >> 3) & 1。由于1只在LSB是1,其他位是0,所以“与”操作的结果就是B3的值(0或1),其他位均为0。
将特定位写为1
假设我们想将某个寄存器的第3位写为1。
- 从数字1开始:
1。 - 将其左移3位:
1 << 3。现在第3列是1,其他列是0。 - 读取寄存器的当前值,得到
B。 - 将两者进行“或”操作:
B | (1 << 3)。0或任何数等于该数本身,所以其他位不变;1或任何数等于1,因此第3位被强制设为1。
将特定位写为0
假设我们想将某个寄存器的第3位写为0。
- 从数字1开始:
1。 - 将其左移3位:
1 << 3。 - 将其取反:
~(1 << 3)。现在所有位都是1,唯独第3位是0。 - 读取寄存器的当前值,得到
B。 - 将两者进行“与”操作:
B & ~(1 << 3)。任何位与1相与保持不变,与0相与则变为0。因此,除了第3位被强制设为0,其他所有位都保持不变。
总结

本节课中,我们一起学习了内存映射输入/输出的核心概念。我们了解到,处理器通过将外设寄存器映射到内存地址空间来实现I/O操作。在C语言中,我们可以使用 volatile 指针来访问这些地址,并通过位操作(如移位、与、或、非)来读取或写入寄存器中的特定位,从而控制硬件或读取其状态。这是底层硬件编程和嵌入式系统开发中的一项基础且重要的技能。
132:通用输入输出(GPIO)🚀

在本节课中,我们将要学习通用输入输出(GPIO)的基本概念和操作方法。GPIO是微控制器与外部世界交互的重要接口,通过它我们可以控制LED、读取开关状态或连接其他数字逻辑设备。
GPIO概述
通用输入输出引脚是我们可以写入逻辑0或1,或从引脚读取0或1的引脚。由GPIO控制的设备示例包括可以打开或关闭的LED、可以读取高电平或低电平值的开关,以及与其他数字逻辑或芯片的连接。
GPIO引脚配置
在开始使用新微控制器的GPIO时,首先要查看微控制器有多少个GPIO引脚以及它们的命名方式。每个引脚通常可以配置为输入、输出或某些特殊功能,例如串行端口或数据转换器。因此,你需要弄清楚如何进行这种配置,然后重新配置引脚。
在FE310开发板上,有一个GPIO端口GPIO0,它关联了32位。然而,芯片没有足够的引脚引出所有32个GPIO,因此只有19个是可访问的。下图显示了映射关系:引脚0、引脚1、引脚2、引脚3、引脚4、引脚5等等。
一些GPIO引脚具有多种功能。例如,引脚3到5还与串行外设接口(SPI)相关联,我们可以将它们用作常规GPIO或SPI。
板上有一个蓝色LED连接到GPIO5。当你将该引脚驱动为高电平时,LED亮起;驱动为低电平时,LED熄灭。如果因为正在进行串行传输而导致串行时钟(SCLK)脉冲,你也会看到LED闪烁。
这些引脚上的编号非常小,除非你有非常好的视力或放大镜,否则可能无法从电路板的丝印上清楚地读取。因此,参考显示引脚对应关系的页面非常方便,它们也在电路板背面有标注,但如果你将电路板插入面包板,这不会有太大帮助。
内存映射寄存器控制
GPIO通过内存映射I/O进行控制。你可能还记得GPIO的基地址是0x10012000。从该地址开始有一系列寄存器,每个寄存器宽32位,并具有32个独立的位字段来控制32个不同的GPIO。32位等于4字节,因此每个寄存器比前一个寄存器偏移4字节。
以下是主要的寄存器及其偏移量:
- 输入值寄存器:偏移量0x0
- 输入使能寄存器:偏移量0x4
- 输出使能寄存器:偏移量0x8
- 输出值寄存器:偏移量0xC
此外,还有一些与控制内部上拉电阻、设置引脚驱动强度、中断相关的寄存器,以及两个用于选择特殊功能的IO功能选择寄存器。
复位时,其中三个寄存器(输入使能、输出使能、输出值)被初始化为零,以禁用所有GPIO引脚,使它们不作为输入/输出或具有内部上拉电阻,这样芯片在复位后就不会出现意外行为。
代码示例:配置与读写
假设我们想编写一些代码。在之前的例子中,我们还没有讨论使能寄存器,现在让我们引入相关的代码行来提供使能。
首先,我们定义指向四个主要寄存器的指针:
volatile uint32_t *input_val = (uint32_t*)(0x10012000 + 0x0);
volatile uint32_t *input_en = (uint32_t*)(0x10012000 + 0x4);
volatile uint32_t *output_en = (uint32_t*)(0x10012000 + 0x8);
volatile uint32_t *output_val = (uint32_t*)(0x10012000 + 0xC);
接着,我们配置引脚。例如,将GPIO19配置为输入,GPIO5配置为输出:
// 使能GPIO19为输入
*input_en |= (1 << 19);
// 使能GPIO5为输出
*output_en |= (1 << 5);
然后,我们可以读写这些位。读取GPIO19的值可以通过查看输入值寄存器的所有32位值,右移19位,然后与1进行与操作来获得仅第19位:
uint32_t gpio19_val = (*input_val >> 19) & 0x1;
等待该值变为1的循环可以这样写:
while (((*input_val >> 19) & 0x1) == 0) {
// 等待
}
要向GPIO5写入1(这也会点亮蓝色LED),我们可以将输出值寄存器的第5位置1:
*output_val |= (1 << 5);
要关闭LED,我们可以将第5位清零:
*output_val &= ~(1 << 5);
需要注意的是,如果你使用Red-V RedBoard而不是HiFive1 Plus,电路板上的编号与GPIO编号不同,它是Arduino风格。如果你根据GPIO编号编写程序,这可能会造成混淆。例如,名为“0”的引脚实际上是GPIO16,名为“1”的引脚是GPIO17,以此类推,名为“8”的引脚是GPIO0。
综合应用示例
假设我们有一个开关和一个LED,我们想编写一个程序来读取连接到GPIO19的开关值,并将该值写入连接到GPIO5的LED。这有点小题大做,因为我们可以用一根导线而不是一个30美元的微控制器板来完成整个事情,但这只是一个示例。

以下是实现该功能的代码:
// 声明寄存器指针(同上,此处省略)
// 配置引脚
*input_en |= (1 << 19); // GPIO19 作为输入
*output_en |= (1 << 5); // GPIO5 作为输出
while (1) {
// 读取GPIO19的值
uint32_t switch_val = (*input_val >> 19) & 0x1;
// 根据开关值控制LED
if (switch_val) {
*output_val |= (1 << 5); // 打开LED
} else {
*output_val &= ~(1 << 5); // 关闭LED
}
}
特殊功能选择
大多数GPIO引脚也与其他特殊功能相关联。例如,我们的微控制器有串行端口(几种类型)和脉宽调制(PWM)信号。
以下是引脚特殊功能的例子:
- GPIO引脚2也可以用作串行外设接口(SPI)的片选(CS)或PWM输出。
- GPIO引脚3是SPI的数据信号(MOSI)或另一个PWM输出。
- GPIO引脚5是SPI的时钟(SCLK),没有其他选项。
如果我们想将引脚用作特殊功能,我们需要使用IO功能使能寄存器(IOF_EN)将其设置为1,以使用特殊功能代替GPIO。然后,我们使用IO功能选择寄存器(IOF_SEL)来选择我们想要的功能。
例如,如果我们想让引脚13作为脉宽调制(PWM)输出:
- 将IOF_EN寄存器的第13位写为1,以启用特殊功能。
- 将IOF_SEL寄存器的对应位设置为0(如果PWM对应0)或1(如果I²C对应1),以选择PWM功能。
具体操作代码如下:
// 假设已定义IOF_EN和IOF_SEL寄存器的指针
*iof_en |= (1 << 13); // 启用引脚13的特殊功能
*iof_sel &= ~(1 << 13); // 选择功能0(例如PWM)
// 或 *iof_sel |= (1 << 13); // 选择功能1(例如I²C)
总结
本节课中我们一起学习了通用输入输出(GPIO)的核心概念。我们了解了GPIO引脚的基本功能、如何通过内存映射寄存器对其进行配置(包括设置为输入或输出),并学习了读取输入值和写入输出值的具体代码实现。我们还探讨了GPIO引脚的多功能特性,以及如何通过特定寄存器将其配置为串口、PWM等特殊功能。掌握GPIO是进行嵌入式系统开发和硬件交互的基础。
133:RISC-V设备驱动库 🧩

在本节课程中,我们将学习如何为RISC-V开发一个简单的GPIO设备驱动库。我们将了解直接使用内存映射指针的局限性,并学习如何通过结构体和函数来抽象硬件操作,从而编写更清晰、更易维护的代码。
直接内存映射I/O的局限性
上一节我们介绍了通过指针直接操作内存映射I/O的方法。虽然这种方法很直接,但在大型系统中存在一些缺点。
以下是直接使用指针的几个主要问题:
- 难以把握全局:如果只是向特定的内存地址写入数据,开发者必须不断查阅手册来确认每个地址的功能,这容易造成混淆。
- 容易出错:GPIO的地址通常很长(例如8位十六进制数),输入时一个字符的错误就会导致写入错误的内存位置,程序将无法正常工作。
- 对初学者不友好:许多C语言初学者对指针操作的理解不够深入,使用晦涩的指针赋值语句会增加学习难度。
因此,我们将编写一个设备驱动库来隐藏底层细节,让我们能以更抽象的方式访问GPIO。这个库将提供一个类似Arduino风格的接口。
使用结构体定义内存映射
本节中,我们将使用结构体来代替简单的指针定义内存映射I/O。你可能还记得,通过结构体,我们可以指向内存中结构体的起始位置,然后将后续的内存位置作为结构体的成员来访问。
这意味着我们无需像使用指针那样手动输入所有地址,从而得到更清晰、更易读的C代码。
以下是我们 easyrv5io 库中GPIO的定义。首先,我们定义引脚模式:
#define INPUT 0
#define OUTPUT 1
接下来,声明一个用于GPIO寄存器的结构体:
typedef struct {
volatile uint32_t input_val;
volatile uint32_t input_en;
volatile uint32_t output_en;
volatile uint32_t output_val;
// ... 可能还有其他寄存器
} gpio_t;
这个结构体包含多个32位无符号整数,它们对应GPIO的各个寄存器。偏移量0是输入值寄存器,偏移量4是输入使能寄存器,偏移量8是输出使能寄存器,偏移量C(12)是输出值寄存器。这些是内存映射I/O中GPIO部分的前四个寄存器。
然后,我们定义GPIO的基地址:
#define GPIO0_BASE 0x10012000U
这个地址是GPIO在内存映射中的起始位置。末尾的 U 表示这是一个无符号十六进制数。我们称其为 GPIO0_BASE。我们的芯片只实现了最多32个GPIO,因此只需要一个GPIO内存区域,但芯片的后续版本可能会实现更多。
最后,我们声明一个指向该基地址处GPIO结构体的指针:
#define GPIO0 ((gpio_t *) GPIO0_BASE)
现在,我们有了一个指向结构体的指针,该结构体的成员对应着硬件所在的内存位置。
实现用户友好的GPIO函数
现在,让我们看看如何使用这个结构体来访问GPIO,以便编写友好的函数。
digitalRead 函数:该函数接收我们想要读取的引脚编号,并返回该引脚上的值(0或1)。
int digitalRead(int pin) {
return (GPIO0->input_val >> pin) & 1;
}
它通过我们便捷的 GPIO0 结构体指针(实际上指向内存映射GPIO的起始位置)来工作。读取结构体的 input_val 字段,这让我们从地址 0x10012000 获取全部32位输入值。然后,通过右移操作将目标GPIO引脚的值移到最低位,并与1进行与操作,以屏蔽掉其他引脚的值。最终,我们得到对应引脚值的0或1。
digitalWrite 函数:该函数接收我们想要写入的引脚编号和要写入的值。
void digitalWrite(int pin, int val) {
if (val == 1) {
GPIO0->output_val |= (1 << pin); // 将对应位置1
} else {
GPIO0->output_val &= ~(1 << pin); // 将对应位置0
}
}
如果值为1,我们通过或操作将GPIO输出值寄存器的对应位置1。否则,我们将除了目标位之外的所有位都置为1,而目标位置0,以关闭该引脚。
pinMode 函数:该函数设置引脚的模式,可以是输入或输出。
void pinMode(int pin, int function) {
if (function == INPUT) {
// 设置为输入:使能输入,禁用输出和IO功能
GPIO0->input_en |= (1 << pin);
GPIO0->output_en &= ~(1 << pin);
// 假设有IO功能寄存器,也需禁用
// GPIO0->iof_en &= ~(1 << pin);
} else if (function == OUTPUT) {
// 设置为输出:使能输出,禁用输入和IO功能
GPIO0->output_en |= (1 << pin);
GPIO0->input_en &= ~(1 << pin);
// GPIO0->iof_en &= ~(1 << pin);
}
}
如果功能是输入,我们通过或操作将输入使能寄存器的对应位置1,并关闭输出使能和IO功能使能寄存器,使其成为输入而非输出或特殊功能。类似地,如果要将引脚设置为输出,我们则将输出使能寄存器的对应位置1,并关闭输入和IO功能寄存器。
应用驱动库编写程序
最后,让我们应用这个驱动库来编写一个更易读的程序。该程序读取三个开关的状态,并相应地控制三个LED。
我们首先包含头文件:
#include "easyrv5io.h"
使用引号而非尖括号,是告诉编译器在我们的项目文件夹中寻找该文件,而不是在系统目录中。
现在,我们的主程序如下:
int main() {
// 假设开关连接到GPIO2到GPIO4(在Red5开发板上对应D18到D20)
pinMode(2, INPUT);
pinMode(3, INPUT);
pinMode(4, INPUT);
// 假设LED连接到GPIO8到GPIO10
pinMode(8, OUTPUT);
pinMode(9, OUTPUT);
pinMode(10, OUTPUT);
while (1) {
// 读取GPIO2(第一个开关)的值,并写入GPIO8(第一个LED)
digitalWrite(8, digitalRead(2));
// 对第二对和第三对开关/LED执行相同操作
digitalWrite(9, digitalRead(3));
digitalWrite(10, digitalRead(4));
}
return 0;
}
这样,我们就完成了我们的程序。

总结
本节课中,我们一起学习了如何为RISC-V处理器构建一个简单的GPIO设备驱动库。我们首先分析了直接使用内存映射指针的缺点,然后引入了使用结构体来更清晰、更安全地定义硬件寄存器的方法。接着,我们实现了 digitalRead、digitalWrite 和 pinMode 等用户友好的函数,将底层硬件操作封装起来。最后,我们应用这个库编写了一个控制开关和LED的程序,演示了如何使代码更易读和维护。通过创建这样的抽象层,我们可以让I/O操作对开发者更加友好,尤其是对于初学者而言。
134:定时器 ⏱️
在本节中,我们将学习如何在微控制器上实现延时和使用定时器。我们将从简单的软件延时循环开始,然后介绍更精确、更可靠的硬件定时器使用方法。

概述
与真实世界交互时,最常见需求之一是测量时间或生成特定的时间间隔。本节将介绍两种实现方法:基于循环的软件延时和使用硬件定时器的精确延时。
软件延时循环
首先,我们来看一种简单的延时方法:在程序中编写一个循环。
假设我们需要延时特定的毫秒数。可以声明一个整数变量,将其设置为某个初始值,然后递减计数到零。通过校准每毫秒所需的循环次数,可以实现大致的时间延迟。
以下是实现此功能的核心代码逻辑:
int i = desired_milliseconds * counts_per_millisecond;
while (i-- > 0) {
// 空循环,消耗时间
}
通过实验校准,发现大约需要1600次计数才能延时1毫秒,即每秒约160万次计数。
基于此延时函数,我们可以编写一个让LED闪烁的程序。例如,将引脚5设置为输出(连接蓝色LED),然后在循环中先点亮LED,延时500毫秒,再熄灭LED,再延时500毫秒,如此反复。
// 伪代码示例
set_pin_as_output(5);
while (1) {
turn_on_pin(5);
delay_ms(500);
turn_off_pin(5);
delay_ms(500);
}
然而,这种延时循环方法存在几个问题。首先,手动校准每毫秒的计数次数非常繁琐。其次,校准结果可能不精确(例如,是1600还是1670?)。最后,如果编译器优化策略改变,生成了运行更快的代码,那么实际的延时长度就会变得不准确。
硬件定时器
为了解决软件延时的问题,微控制器通常配备了一种称为“定时器”的外设。这是一种更可靠的延时方法。
我们使用的微控制器拥有一个64位的定时器,它通过一个外部的32.768 kHz振荡器进行计数。这意味着定时器每秒钟会计数32768次。
根据手册,我们可以通过访问内存映射外设中的特定寄存器来使用这个定时器。该寄存器位于核心本地中断器的内存映射区域,地址为 0x200BFF8。它是一个64位的寄存器,名为 MTIME。读取这个寄存器,就能获得系统启动以来经过的、以32kHz时钟周期为单位的时间。
以下是使用硬件定时器实现延时的步骤:
- 声明一个指向该寄存器的指针。由于寄存器是64位的,指针类型应为
uint64_t*。 - 声明一个
uint64_t类型的变量来存储目标完成时间。 - 计算目标时间:
完成时间 = 当前时间 + 所需延时毫秒数 * (32768 / 1000)。 - 循环读取当前时间,直到当前时间达到或超过目标完成时间。
核心计算公式如下:
目标完成时间 = MTIME + (延时毫秒数 × 32.768)
以下是代码实现的示意:
volatile uint64_t *mtime = (uint64_t*)0x200BFF8; // 指向MTIME寄存器的指针
void delay_ms(uint64_t milliseconds) {
uint64_t current_time = *mtime;
uint64_t done_time = current_time + (milliseconds * 32768ULL / 1000ULL);
while (*mtime < done_time) {
// 等待,直到定时器值达到目标时间
}
}
使用这种方法,延时精度由硬件振荡器保证,不受编译器优化或CPU速度变化的影响,因此更加精确和可靠。
总结
本节课我们一起学习了在微控制器上实现延时的两种方法。
我们首先介绍了简单的软件延时循环,它易于实现但存在校准繁琐、精度不高和受编译器影响等问题。
接着,我们探讨了更优的解决方案——使用硬件定时器。通过访问 MTIME 寄存器,我们可以基于稳定的外部振荡器进行精确计时。这种方法避免了软件延时的缺陷,是生成精确时间间隔的推荐方式。

理解这两种方法的区别和适用场景,对于进行嵌入式系统和硬件交互编程至关重要。
135:摩尔斯电码示例 📡

在本节中,我们将综合运用之前学到的GPIO、定时器和设备驱动程序知识,构建一个能够播放摩尔斯电码信息的系统。这个系统将控制蓝色LED灯的闪烁,演示EasyRed5 IO设备驱动库的使用,并且与实验7的内容有相似之处。
概述

我们将编写一个程序,通过LED灯的闪烁来发送“SOS”的摩尔斯电码信号。程序的核心包括定义摩尔斯电码表、编写播放单个字符和字符串的函数,以及使用EasyRed5库来控制GPIO引脚。
程序结构
首先,我们定义程序的基本框架,包括头文件引用和常量定义。
#include "easyred5io.h"
#define DURATION 100 // 定义最短时间间隔为100毫秒
接下来,我们需要定义摩尔斯电码表。摩尔斯电码由点和划组成,点代表短脉冲,划代表长脉冲。每个字母对应一串点和划的组合。
const char* morse_codes[26] = {
".-", // A
"-...", // B
"-.-.", // C
// ... 其他字母的定义
"...", // S
// ... 继续定义直到Z
};
播放单个字符
现在,我们编写一个函数来播放单个字符。该函数接收一个字符作为输入,并根据摩尔斯电码表控制LED灯的闪烁。
void play_char(char c) {
const char* code = morse_codes[c - 'a']; // 获取字符对应的摩尔斯电码
int i = 0;
while (code[i] != '\0') { // 遍历电码字符串直到结束符
digital_write(5, HIGH); // 打开LED
if (code[i] == '.') {
delay(DURATION); // 点:等待100毫秒
} else if (code[i] == '-') {
delay(DURATION * 3); // 划:等待300毫秒
}
digital_write(5, LOW); // 关闭LED
delay(DURATION); // 字符内间隔:等待100毫秒
i++; // 处理下一个点或划
}
delay(DURATION * 2); // 字符间间隔:等待200毫秒
}
播放字符串
为了播放完整的消息,我们需要一个函数来遍历字符串中的每个字符,并依次播放它们。
void play_string(const char* str) {
int i = 0;
while (str[i] != '\0') { // 遍历字符串直到结束符
play_char(str[i]); // 播放当前字符
i++; // 移动到下一个字符
}
}
主程序
最后,我们在主函数中初始化GPIO引脚,并设置一个无限循环来持续播放“SOS”信号。
int main() {
pin_mode(5, OUTPUT); // 设置引脚5为输出模式
while (1) {
play_string("sos"); // 播放“SOS”
delay(DURATION * 4); // 单词间间隔:等待400毫秒
}
return 0;
}
总结

在本节中,我们一起学习了如何构建一个摩尔斯电码播放系统。我们定义了摩尔斯电码表,编写了播放字符和字符串的函数,并利用EasyRed5 IO库的pin_mode和digital_write函数来控制LED灯。通过这个示例,你将GPIO操作、定时延迟和字符串处理综合应用到了一个完整的项目中。
136:接口技术 🖥️➡️🌍

在本节课程中,我们将学习接口技术,即如何在数字系统中将不同的组件连接在一起。我们将探讨与外部世界(如传感器、执行器或其他处理器)通信的几种主要方法。
概述
数字系统(如微控制器)需要与现实世界交互。这通常涉及连接到传感器以监测环境,连接到执行器以触发动作,或连接到其他处理器以交换信息。例如,在一个化工厂中,可能需要传感器来测量温度、pH值和湿度,同时需要执行器来启动电机或打开阀门。此外,系统中可能还有其他微控制器需要相互通信。为了实现这些连接,主要有三种通用的接口方法:并行接口、串行接口和模拟接口。
并行接口
上一节我们介绍了接口的基本概念,本节中我们首先来看看并行接口。在并行接口中,我们使用多根导线同时发送包含多个比特的完整信息。
公式/概念:发送N比特信息需要至少N根数据线。
如果信息量很大,需要大量导线,这种方法的成本会很高。除了数据线,通常还需要一根时钟线或某种握手信号(如请求和应答信号)来指示数据何时准备就绪。由于线缆数量多,并行总线通常笨重、不易弯曲且成本较高。因此,它主要用于高性能、短距离的通信,例如将动态RAM芯片连接到计算机主板上的处理器。
串行接口
由于并行接口在远距离或需要灵活布线的场景下存在不足,本节我们来看看串行接口。串行接口使用单根数据线,通过多次使用这根线来发送多位信息,从而减少了所需的物理连线。
以下是三种常见的串行接口标准:
- SPI (Serial Peripheral Interface):它使用一根时钟线、一根数据输出线和一根数据输入线。主设备通过多个时钟周期向从设备发送信息。例如,发送一个字节需要8个时钟周期,每个周期在数据输出线上发送一个比特,同时在数据输入线上接收一个比特。
- UART (Universal Asynchronous Receiver and Transmitter):它与SPI类似,但没有专用的时钟线,属于异步通信。通信双方需要预先约定数据传输速率,并自行检测数据跳变。
- I²C (Inter-Integrated Circuit):它使用一根时钟引脚和一根双向数据引脚。由于数据线是双向的,其使用稍微复杂一些。
所有这些标准都广泛应用。本课程将重点介绍SPI。此外,还有更复杂但功能强大的接口,如USB,它虽然也是串行传输(每周期一位),但可以通过极高的时钟频率实现每秒吉比特的数据传输速率。
模拟接口
数字系统处理的是离散的比特,而现实世界的许多信号(如电压、温度)是连续变化的模拟量。因此,我们需要模拟接口来进行转换。
主要有三种方式实现数字与模拟世界的交互:

- 模数转换器 (ADC):用于将连续的模拟电压转换为一组与该电压成比例的数字比特。
- 公式/概念:
数字值 ∝ 输入电压
- 公式/概念:
- 数模转换器 (DAC):与ADC相反,它接收一个数字值,并产生一个与该数值成比例的模拟电压。
- 公式/概念:
输出电压 ∝ 数字输入值
- 公式/概念:
- 脉宽调制 (PWM):这种方法通过以足够高的频率在低电压和高电压之间快速切换一个数字信号来模拟模拟输出。当这个信号被平滑(滤波)后,其波形的平均值与期望的输出值成比例。
- 概念:通过改变一个周期内高电平所占的时间比例(占空比)来控制平均输出电压。
总结
本节课中我们一起学习了数字系统中连接不同组件的接口技术。我们介绍了三种主要方法:并行接口(多线同时传输,适合高速短距通信)、串行接口(单线分时传输,常见标准有SPI、UART、I²C,节省连线)以及模拟接口(用于连接真实的模拟世界,涉及ADC、DAC和PWM技术)。理解这些接口是设计能够与现实世界有效交互的数字系统的关键。
137:SPI接口 🧩

在本节课中,我们将学习串行外设接口(SPI)。这是一种使用少量连线连接设备的简单通信协议。我们将了解其工作原理、信号定义、配置方法,并学习如何编写代码来使用SPI进行通信。
SPI概述
SPI是一种简单的通信方式,仅需几根线即可连接设备。在一个SPI系统中,有一个主设备与一个或多个从设备通信。主设备发出时钟信号和数据输出信号,从设备则用数据输入信号响应。主设备还可以选择性地发送从设备选择信号,以指定与多个从设备中的哪一个通信。
SPI信号
以下是SPI通信中涉及的主要信号:
- SCK/SCLK(串行时钟):由主设备生成,每个传输的位对应一个脉冲。
- MOSI(主出从入):从主设备发送到从设备的串行数据。
- MISO(主入从出):从从设备发送回主设备的串行数据。
- SS/CS(从设备选择/片选):由主设备发出,用于选择特定的从设备。该信号通常是低电平有效。
SPI的一个优点是,从设备的硬件可以只是一个移位寄存器。主设备生成时钟,并通过MOSI线发送数据。在时钟的每个边沿,从设备捕获一位数据,并可能通过MISO线发回自己的数据。
连接方式
SPI的连接方式灵活。以下是几种常见配置:
- 单主单从:这是最简单的配置。由于只有一个从设备,从设备选择信号可以始终拉低(使能),或者在某些情况下可以省略。
- 单主多从:主设备与多个从设备通信。SCK和MOSI信号连接到所有从设备,MISO信号从所有从设备连回主设备(但同一时刻只能有一个驱动)。主设备为每个从设备提供独立的从设备选择信号。
- 重要提示:必须将所有设备的地(GND) 连接在一起,以确保电压参考一致,否则通信可能不可靠。
从设备选择信号通常低电平有效。禁用从设备可以降低功耗。连接方式有多种选择:如果只有一个从设备且不关心功耗,可以将其片选引脚永久拉低;对于多个从设备,可以使用GPIO引脚在SPI事务前后控制片选,或者使用支持自动生成片选脉冲的SPI控制器。
配置SPI通信
上一节我们介绍了SPI的基本连接,本节中我们来看看如何配置SPI控制器以启动通信。通常需要遵循以下步骤:
- 选择SPI端口:确定使用哪个SPI硬件模块。
- 配置引脚模式:将用于SCK、MOSI、MISO以及可选的SS的GPIO引脚设置为SPI功能模式(而非通用输入/输出)。
- 设置波特率:决定通信速度。在面包板等实验环境中,建议将时钟频率设置为1MHz或更低,以确保信号完整性。
- 设置时钟极性和相位:这两个参数定义了数据采样相对于时钟边沿的关系。对于大多数设备,将两者都设置为0即可正常工作。具体含义如下:
- 时钟极性(CPOL):0表示时钟空闲时为低电平;1表示时钟空闲时为高电平。
- 时钟相位(CPHA):0表示在第一个时钟边沿采样数据;1表示在第二个时钟边沿采样数据。
- 其他配置:通常使用默认设置即可,但某些设备可能需要配置其他控制寄存器。
数据传输过程
配置好SPI后,就可以进行数据传输了。以下是发送和接收数据的基本流程:
- 发送数据:
- 检查发送数据寄存器中的“满”标志位。必须等待该标志为0(表示有空闲位置)才能发送新数据。
- 将待发送的字节写入发送数据寄存器的数据字段。SPI硬件会自动开始工作,生成8个时钟脉冲,并逐位发送数据。
- 接收数据:
- 在发送数据的同时,SPI也会通过MISO线接收来自从设备的8位数据。
- 读取接收数据寄存器,并检查其“空”标志位(通常是最高位)。如果为1,表示接收数据仍为空,需要持续读取直到该标志变为0。
- 当“空”标志为0时,读取接收数据寄存器的数据字段,即可获得从设备返回的数据。注意:读取操作会同时将数据从接收队列中取出,因此每个有效数据只能读取一次。
SPI寄存器映射
为了编程控制SPI,我们需要了解其内存映射的寄存器。以下是一些关键寄存器(以某个具体芯片为例):
- 波特率寄存器:低12位用于设置时钟分频值。SPI时钟频率计算公式为:
F_sck = F_ahb / (2 * (div + 1))
例如,若系统时钟F_ahb为16MHz,分频值div设为15,则SPI时钟频率为 16MHz / (2 * 16) = 0.5MHz。 - 时钟模式寄存器:最低两位分别控制时钟相位和极性。
- 发送数据寄存器:
- 位[31]:“满”标志。
- 位[7:0]:待发送的数据字段。
- 接收数据寄存器:
- 位[31]:“空”标志。
- 位[7:0]:接收到的数据字段。
我们可以使用C语言的结构体和位域来方便地访问这些寄存器。例如,定义波特率寄存器的结构体:
typedef struct {
uint32_t div : 12; // 低12位为分频值
uint32_t : 20; // 高20位保留未用
} sck_div_bits;
然后,定义一个完整的SPI外设结构体,包含这些寄存器在内存中的偏移地址。
编写SPI驱动程序
现在,让我们将理论知识付诸实践,编写一个简单的SPI设备驱动库。核心是初始化和收发函数。
首先,我们需要一个初始化函数来配置SPI。该函数需要设置引脚模式、波特率、时钟极性和相位。
void spi_init(uint16_t clock_div, uint8_t phase, uint8_t polarity) {
// 1. 配置SCK, MOSI, MISO引脚为SPI功能模式(IO Function 0)
pin_mode(SCK_PIN, IO_MODE_FUNC0);
pin_mode(MOSI_PIN, IO_MODE_FUNC0);
pin_mode(MISO_PIN, IO_MODE_FUNC0);
// 2. 配置波特率
SPI1->sck_div.div = clock_div;
// 3. 配置时钟极性和相位
SPI1->sck_mode.phase = phase;
SPI1->sck_mode.polarity = polarity;
}
其次,我们需要一个收发函数。该函数发送一个字节,并接收从设备返回的一个字节。
uint8_t spi_transfer(uint8_t data_to_send) {
// 等待发送寄存器非满
while (SPI1->tx_data.full) {
// 空循环等待
}
// 写入要发送的数据
SPI1->tx_data.data = data_to_send;
// 等待接收寄存器非空,并读取数据
uint8_t received_data;
while (1) {
received_data = SPI1->rx_data.data; // 读取整个寄存器(包含空标志位)
if (!SPI1->rx_data.empty) { // 检查空标志位
break; // 数据有效,跳出循环
}
// 若为空,则继续等待
}
return received_data;
}
注意:上面的spi_transfer函数示例中,SPI1->rx_data是一个结构体,其empty成员代表“空”标志位。读取data字段和检查empty标志是原子操作,实际实现需参考具体硬件的数据手册。
引脚模式函数扩展
为了支持SPI,我们需要扩展之前课程中提到的pin_mode函数,使其能够将GPIO引脚设置为特殊的“IO功能”模式(如SPI),而不仅仅是输入或输出模式。这通常涉及设置GPIO功能使能位和功能选择位。
typedef enum {
GPIO_MODE_INPUT,
GPIO_MODE_OUTPUT,
GPIO_MODE_FUNC0, // 例如SPI
GPIO_MODE_FUNC1 // 其他外设功能
} gpio_mode_t;
void pin_mode(uint8_t pin, gpio_mode_t mode) {
// ... 根据mode参数配置相应的GPIO控制寄存器位 ...
}
示例程序
最后,让我们看一个使用上述驱动进行SPI通信的简单示例程序。
#include <stdint.h>
#include "io_red5.h" // 包含我们的GPIO和SPI驱动头文件
int main() {
uint8_t sampled_value;
// 初始化SPI:500kHz时钟,相位0,极性0
spi_init(15, 0, 0); // 假设分频值15对应500kHz
while (1) {
// 发送数据 0x60 (二进制 0110 0000) 并接收返回数据
sampled_value = spi_transfer(0x60);
// 可以在这里处理或打印接收到的数据 sampled_value
// print_value(sampled_value);
// 短暂延时
delay_ms(100);
}
return 0;
}
这个程序会持续向从设备发送数值0x60,并读取从设备返回的响应。
总结

本节课中我们一起学习了串行外设接口(SPI)。我们从SPI的基本概念和信号定义开始,了解了其主从架构和连接方式。接着,我们深入探讨了如何配置SPI的波特率、时钟极性和相位。然后,我们学习了SPI数据传输的流程,包括如何检查状态标志、发送和接收数据。最后,我们通过分析寄存器映射、编写初始化函数、数据收发函数以及一个简单的示例程序,将理论知识转化为实际的编程技能。SPI是一种高效、简单的同步串行通信协议,广泛应用于传感器、存储器、显示器等外设与微控制器的连接中。
138:SPI加速度计示例 🎯

在本节中,我们将学习如何通过SPI接口将加速度计连接到微控制器。我们将以LIS3DH三轴加速度计为例,介绍其工作原理、硬件连接、寄存器配置以及数据读取方法,并最终将其应用于一个数字水平仪项目中。
概述
LIS3DH是一款三轴加速度计,可测量X、Y和Z轴上的加速度,满量程最高可达±8G。它通过SPI接口以16位数据格式传输每个轴的测量值,灵敏度可低至1毫克。该芯片采用16引脚LGA封装,结构坚固。为了方便使用,Adafruit和SparkFun等公司提供了包含该芯片、支持电容电阻以及连接引脚的整体模块。
工作原理
加速度计内部采用微机电系统技术。它包含硅悬臂梁,这些梁被蚀刻在芯片上小于一平方毫米的区域中。梁上有一个导体,下方有另一个导体,两者之间形成电容。当施加加速度时,梁会移动,改变与下方导体的距离,从而改变电容。一个高精度的模数转换器感知此电容变化,并读出加速度值。此外,设备还包含温度传感器,用于补偿温度变化引起的热膨胀效应。
从我们的角度来看,它只是一个带有SPI接口的“黑盒”。我们可以发送命令来配置其工作模式,然后发送更多命令来读取各轴的加速度值。该设备同时支持SPI和I²C接口,本节我们专注于SPI。
硬件连接
以下是将LIS3DH连接到RISC-V开发板的步骤:
- 连接共地,确保共同的参考电平。
- 将开发板的3.3V输出连接到加速度计的VCC引脚。注意:切勿使用5V,否则会损坏设备。
- 连接SPI接口所需的四根信号线:
- SCK:从主设备(RISC-V板)到从设备(加速度计)的串行时钟。
- MOSI:主设备输出,从设备输入。
- MISO:主设备输入,从设备输出。
- CS:片选信号(例如使用引脚2)。当该信号为低电平时,表示正在与加速度计通信。
SPI通信协议
加速度计内部有一组寄存器,微控制器通过读写这些寄存器与之通信。每个寄存器有一个6位地址和8位数据。
访问寄存器需要使用16位的SPI事务。具体格式如下:
- 第1位:读写控制位(
RW)。1表示读,0表示写。 - 第2位:单/多寄存器访问位(
MS)。0表示访问单个寄存器。 - 第3-8位:6位寄存器地址。
- 后续8位:在写操作时,是待写入的8位数据;在读操作时,是用于填充时钟周期的哑元数据,同时从设备会在此阶段返回寄存器的8位内容。
整个通信过程需要将片选信号(CS)拉低,并在16个时钟周期内完成两次8位的SPI传输。
写操作流程
- 拉低
CS。 - 发送第一个8位数据:
RW=0,MS=0, 以及6位地址。 - 发送第二个8位数据:待写入的8位数值。
- 拉高
CS。此时返回的8位数据无意义,可丢弃。
读操作流程
- 拉低
CS。 - 发送第一个8位数据:
RW=1,MS=0, 以及6位地址。 - 发送第二个8位数据(哑元数据,如全0)。
- 拉高
CS。在第二个8位时钟周期内,从设备返回的8位数据即为目标寄存器的内容。
关键寄存器


以下是几个重要的寄存器:
“我是谁”寄存器(WHO_AM_I)
- 地址:
0x0F - 功能:用于验证SPI通信是否正常。读取该寄存器应返回固定值
0x33(十六进制)或51(十进制)。
控制寄存器1(CTRL_REG1)
- 地址:
0x20 - 功能:用于开启各轴并设置采样率。
- 配置示例:写入
0x77可开启X、Y、Z轴,并设置输出数据率为400Hz。
控制寄存器4(CTRL_REG4)
- 地址:
0x23 - 功能:设置分辨率、量程等。
- 配置示例:写入
0x88可启用高分辨率模式(16位输出)、设置量程为±2G,并启用块数据更新。
数据输出寄存器
每个轴(X, Y, Z)的加速度值由两个8位寄存器(低字节OUT_X_L和高字节OUT_X_H)共同组成一个16位的补码数。例如:
- X轴低字节地址:
0x28 - X轴高字节地址:
0x29
读取时,需要将高字节左移8位后与低字节组合。
数据校准
加速度计读取的原始值需要经过换算才能得到以G为单位的加速度。换算公式大致为:
加速度(G) = (原始值 - 偏移量) * 比例系数
为了获得精确的偏移量和比例系数,需要进行校准:
- 将设备水平放置,读取各轴输出值,此值即为零加速度时的偏移量。
- 将设备沿某一轴旋转±90度,读取输出值。结合水平时的读数,即可计算出该轴的比例系数。
代码实现
以下是如何用代码实现SPI读写和加速度计数据读取的示例框架:
#include <stdint.h>
#include "spi.h" // 假设包含SPI操作函数
// 引脚定义
#define CS_PIN 2
// 向加速度计寄存器写入数据
void accel_write(uint8_t addr, uint8_t data) {
digitalWrite(CS_PIN, LOW); // 选中设备
spi_transfer((addr & 0x3F) | 0x00); // RW=0, 发送地址
spi_transfer(data); // 发送数据
digitalWrite(CS_PIN, HIGH); // 取消选中
}
// 从加速度计寄存器读取数据
uint8_t accel_read(uint8_t addr) {
uint8_t value;
digitalWrite(CS_PIN, LOW); // 选中设备
spi_transfer((addr & 0x3F) | 0x80); // RW=1, 发送地址
value = spi_transfer(0x00); // 发送哑元数据并接收返回值
digitalWrite(CS_PIN, HIGH); // 取消选中
return value;
}
void setup() {
// 初始化SPI,设置速率、相位等
spi_init(SPI_MODE0, 500000);
// 配置CS引脚为GPIO输出模式
pinMode(CS_PIN, OUTPUT);
digitalWrite(CS_PIN, HIGH); // 初始化为不选中状态
// 1. 验证通信:读取WHO_AM_I寄存器
uint8_t id = accel_read(0x0F);
if(id != 0x33) {
// 处理错误:通信失败
}
// 2. 配置加速度计
accel_write(0x20, 0x77); // 开启所有轴,400Hz
accel_write(0x23, 0x88); // 高分辨率模式,±2G,块更新
}
void loop() {
// 3. 读取加速度数据
int16_t accel_x;
uint8_t x_low = accel_read(0x28);
uint8_t x_high = accel_read(0x29);
accel_x = (x_high << 8) | x_low; // 组合成16位有符号数
// 同样的方法读取Y轴和Z轴
// int16_t accel_y = ...;
// int16_t accel_z = ...;
// 此处可进行数据校准和后续处理
delay(100); // 以10Hz频率采样
}
项目应用:数字水平仪
在实验8中,你将基于上述加速度计代码构建一个数字水平仪。目标是:
- 校准加速度计,确定水平(0G)和倾斜(±1G)状态对应的原始值。
- 连接一个8x8 LED点阵(实际使用其中7x7部分),用于显示一个光点。
- 当开发板水平时,光点位于矩阵中心。
- 当开发板向不同方向倾斜时,光点会向相应方向移动,从而直观显示倾斜状态。

这需要你使用开发板上的14个GPIO引脚以并行接口方式驱动LED点阵,并注意串联限流电阻以防止LED过流损坏。
总结
本节课我们一起学习了如何通过SPI接口连接和操作LIS3DH加速度计。我们了解了其硬件连接方法、SPI通信协议、关键寄存器的配置,以及如何读取和校准加速度数据。最后,我们探讨了如何将这些知识应用到一个具体的数字水平仪项目中。掌握这些内容,你就能在嵌入式系统中集成并使用类似的SPI传感器了。

浙公网安备 33010602011771号