UCB-CS61c-组成原理中文笔记-全-

UCB CS61c 组成原理中文笔记(全)

🖥️ 课程 P1:计算机体系结构中的伟大思想 - 引言

在本节课中,我们将要学习计算机体系结构课程的核心介绍。我们将了解这门课程的目标、涵盖的“伟大思想”,以及课程的基本结构和政策。课程的重点在于理解硬件与软件之间的接口,以及现代计算系统(从个人电脑到数据中心)是如何工作的。


📰 从一则趣闻开始

课程从一个有趣的新闻开始:珍妮特·杰克逊的某首歌曲中,特定的声音频率可能导致某些型号的笔记本电脑崩溃。这个例子生动地说明了软件(音频文件)与硬件(笔记本电脑)之间存在着意想不到的交互。


🎯 课程核心:硬件/软件接口

上一节我们从一个趣闻引入了硬件与软件的交互,本节中我们来看看这门课的核心主题。

这门课的名称是“计算机体系结构中的伟大思想”,也称为“机器结构”。它并非一门单纯的C语言教学课程。

  • 一切的核心在于奇妙的硬件/软件接口
  • 我们研究计算机如何工作,且不限于单台计算机,还包括数据中心的实际工作方式。
  • 我们使用C语言教学,是因为它比Python等语言更接近底层硬件(更接近“硅”)。
  • 课程目标是让你理解从高层应用(如C程序)到底层晶体管运作的完整链条。


🏗️ 全景视角:从单机到并行世界

传统的计算机体系结构课程只关注单线程、单进程的计算机。本课程则采用更宏大的视角。

  • 旧视角(单机):关注单个CPU核心、单一线程的执行过程。
  • 新视角(全景):我们研究仓库规模的计算硬件,包括:
    • 多核服务器
    • 多级缓存(Cache)
    • 输入/输出设备
  • 你将学习如何设计CPU,而不仅仅是编程。从最底层的逻辑门开始,自底向上地理解整个系统。
  • 课程将探索五种不同层次的并行性来提升性能:
    1. 请求级并行
    2. 线程级并行
    3. 指令级并行
    4. 数据级并行
    5. 硬件描述并行


💡 六大“伟大思想”

以下是本课程将贯穿始终的六个核心思想,我们将在第一节课中初步介绍它们。

思想一:抽象

抽象是计算中的基本思想,在硬件中同样存在。我们将频繁使用的一个关键抽象是:一切都可以用数字位(0和1)来表示

思想二:摩尔定律

摩尔定律由戈登·摩尔提出,预测集成电路上可容纳的晶体管数量约每两年增加一倍。这一定律驱动了计算性能的指数级增长和成本下降,但大约在2005年后,这种增长速度已经放缓。

公式:晶体管数量 ~ ( 2^{(年份-1970)/2} )

思想三:局部性原则

程序倾向于重复使用最近使用过的数据和指令。这类似于从大脑回忆(快) versus 去办公室取东西(慢) versus 去另一个城市取东西(更慢)。

  • 在计算机中,访问不同层级存储的速度差异巨大:
    • 寄存器:~1 纳秒
    • 缓存:~2 纳秒
    • 主存:~100 纳秒
    • 磁盘:~10,000,000 纳秒
  • 存储层次结构(金字塔)体现了这一原则:越往上,速度越快、容量越小、成本越高。

思想四:并行

通过同时执行多个操作来提升性能。我们主要学习三种并行:

  1. 指令级并行:CPU同时处理多条指令的不同阶段(取指、解码、执行)。
  2. 线程级并行:多个线程同时执行不同的任务。
  3. 数据级并行:将同一操作应用于大量数据的不同部分(如多个小组成员分工处理数据)。

阿姆达尔定律指出,并行带来的加速受限于程序中必须顺序执行的部分。
公式:( Speedup \leq \frac{1}{(1 - P) + \frac{P}{N}} ),其中P为可并行部分比例,N为处理器数量。

思想五:性能度量与改进

我们聚焦于两个关键的、可衡量的性能指标:

  1. 延迟:从开始任务到完成任务所花费的时间。
  2. 吞吐量:单位时间内完成的工作量。
    课程将围绕这些指标进行权衡和优化。

思想六:通过冗余实现可靠性

系统组件可能随机失效(如宇宙射线导致比特翻转)。通过引入冗余组件,即使部分失效,系统也能保持正确运行。

  • 示例
    • 数据中心冗余:在不同地理位置备份数据。
    • 磁盘冗余:使用RAID技术。
    • 内存冗余:使用纠错码。

🚀 为什么计算机体系结构在今天依然令人兴奋?

主要有三个原因:

  1. 摩尔定律的放缓:晶体管数量指数增长趋缓,推动我们通过增加核心数、利用并行性等新方法来提升性能。
  2. 功耗墙:芯片功耗成为性能提升的主要限制。
  3. 领域特定计算:不同应用(如图形处理、机器学习)需要不同的硬件架构(如GPU、NPU),导致异构计算系统的兴起。

传统智慧是设计通用架构。新趋势是设计针对特定领域优化的架构,例如谷歌的TPU用于神经网络训练。


🏫 伯克利的贡献与课程信息

大卫·帕特森和约翰·轩尼诗因其在精简指令集和并行计算方面的贡献获得图灵奖。他们的思想(如RISC-V架构)深刻影响了现代计算机设计。

以下是关于本课程你需要知道的信息:

课程组成部分

  • 讲座:每周三次,现场参与或观看录像。
  • 讨论与作业:讨论课讲解概念,作业通过Prairie平台提交。
  • 实验与项目:动手进行设计、编程和调试。
  • 考试:两次期中考试(形式新颖,部分带回家完成)和一次非累积性的期末考试。

支持与政策

  • Ed Stem论坛:课程主要交流平台,用于通知、问答。
  • 办公时间:由助教和导师提供,分为概念答疑和作业帮助。
  • 延期申请:通过专用表格申请,课程团队理解并支持学生平衡学习与生活。
  • 评分目标:课程目标是让所有努力的学生都能成功,不设固定比例曲线。
  • 学术诚信:严禁作弊、抄袭、不当合作。违反者将面临严厉处罚。鼓励通过正当渠道(如申请延期、寻求帮助)应对困难。

📚 总结

本节课我们一起学习了计算机体系结构课程的概览。我们了解了课程的核心是探索硬件与软件之间的接口,并初步认识了贯穿本课程的六大“伟大思想”:抽象、摩尔定律、局部性、并行、性能度量和通过冗余实现可靠性。我们还了解了课程的基本结构、支持政策以及学术诚信的重要性。希望你能对这门探索计算机如何从底层运作的精彩课程充满期待!

课程 P10:Lecture 8:RISC-V 数据传输 🚚

在本节课中,我们将要学习 RISC-V 架构中数据如何在 CPU 和内存之间传输。我们将介绍加载和存储指令,理解字节寻址与字对齐的概念,并探讨大端序与小端序的区别。


课程概述

本节课的核心是理解 RISC-V 的数据传输指令。我们将从计算机的基本结构开始,明确 CPU、寄存器和内存的角色。然后,我们将深入学习 lw(加载字)、sw(存储字)等指令的格式与用法,并解释在处理不同数据类型(如字节)时需要注意的细节。


计算机的基本结构 🧠

上一节我们介绍了课程的整体目标,本节中我们来看看计算机的基本组成部分,这对于理解数据传输至关重要。

我们有一个处理器(CPU),它包含控制单元和数据通路。程序计数器(PC)指示当前正在执行哪条指令。我们还有一组寄存器,它们数量有限但访问速度极快。算术逻辑单元(ALU)负责执行加、减等运算。所有这些部件共同构成了 CPU,也就是我们计算机的“大脑”。

与 CPU 紧密相连的是寄存器,它是我们的“家”,数据在这里处理速度最快。而内存则像是“外围”的仓库,容量更大但访问速度较慢。这种将程序和数据存储在同一内存中的思想,被称为“存储程序概念”,是计算机体系结构的一大核心思想。

内存可以被抽象地看作一个很长的字节数组。CPU 作为主动方,可以“加载”(从内存读)数据到寄存器,或“存储”(从寄存器写)数据到内存。


内存中的数据表示 🔢

上一节我们了解了内存的抽象概念,本节中我们来看看数据在内存中是如何具体排列的。

内存是字节寻址的,这意味着每个内存地址对应一个字节(8位)。一个字(word,在 RISC-V 中是 4 字节)的地址是其最低有效字节(最右边字节)的地址,这种约定称为小端序

例如,假设我们有一个字 0xdeadbeef 存储在内存中。在小端序系统中,它在内存中的字节排列(从低地址到高地址)是 ef, be, ad, de。这种表示方式使得当我们按字节查看内存内容时,数值的书写顺序(从左到右是高位到低位)与内存中的物理顺序(从右到左是低位到高位)是相反的。

理解这种表示法对于后续正确使用加载和存储指令非常重要。


大端序与小端序 🥚

上一节我们接触了小端序,本节中我们系统地了解一下字节序的两种方式:大端序与小端序。

  • 小端序:最低有效字节存储在最低的内存地址(“小端”开头)。
  • 大端序:最高有效字节存储在最低的内存地址(“大端”开头)。

RISC-V 采用小端序。这意味着,当我们将一个寄存器中的字存入内存,再按字节加载回来时,数据在小端序机器上的表现是直观的。不同架构可能选择不同的字节序,因此在编写可移植代码时需要特别注意。


RISC-V 数据传输指令 ⚙️

上一节我们讨论了数据表示,本节中我们正式学习 RISC-V 中用于数据传输的具体指令。

RISC-V 指令是固定的 32 位长度。这意味着指令的操作码、寄存器索引和立即数都必须编码在这 32 位中,因此寄存器的数量(5位索引,共32个)和立即数的大小都受到限制。

数据传输指令主要有加载和存储两类。

加载指令

加载指令用于将数据从内存读入寄存器。其基本格式为:

lw rd, offset(rs1)

其中:

  • lw 表示“加载字”(Load Word)。
  • rd 是目标寄存器,用于存放从内存读出的数据。
  • offset 是一个立即数偏移量。
  • rs1 是基址寄存器,其值加上偏移量构成有效内存地址。

数据流方向是:从内存(由 rs1 + offset 计算出的地址)读取数据,放入 rd 寄存器。

示例:假设 x15 寄存器保存了一个数组的起始地址。要访问该数组的第 3 个元素(每个元素占 4 字节),可以使用:

lw x10, 12(x15)

因为第3个元素的字节偏移量是 3 * 4 = 12

存储指令

存储指令用于将寄存器中的数据写入内存。其基本格式为:

sw rs2, offset(rs1)

其中:

  • sw 表示“存储字”(Store Word)。
  • rs2 是源寄存器,其值将被写入内存。
  • offset 是一个立即数偏移量。
  • rs1 是基址寄存器。

数据流方向是:将 rs2 寄存器中的数据,写入到内存(由 rs1 + offset 计算出的地址)。请注意,其语法与加载指令相似,但语义相反。

示例:将寄存器 x10 的值存入数组(基址在 x15)的第 10 个元素位置:

sw x10, 40(x15)

因为第10个元素的字节偏移量是 10 * 4 = 40


字节加载与存储 🔤

上一节我们学习了字(4字节)的传输,本节中我们看看如何处理单个字节。

RISC-V 提供了加载字节和存储字节的指令:

  • lb:加载字节(Load Byte),将内存中的一个字节读入寄存器,并进行符号扩展(用该字节的最高位填充目标寄存器的高24位)。
  • lbu:加载无符号字节(Load Byte Unsigned),将内存中的一个字节读入寄存器,并进行零扩展(用0填充目标寄存器的高24位)。
  • sb:存储字节(Store Byte),将寄存器的最低8位存入内存的指定地址。

示例:考虑内存中有一个字节值为 0x93(二进制 1001 0011)。

  • 执行 lb 后,寄存器会得到 0xffffff93(负数)。
  • 执行 lbu 后,寄存器会得到 0x00000093(正数)。

这对于处理字符数组(char)或有符号/无符号字节数据至关重要。


字对齐要求 📐

使用 lwsw 指令时,访问的内存地址必须是字对齐的,即地址是 4 的倍数。基址寄存器 (rs1) 的值和偏移量 (offset) 之和也应该是 4 的倍数。非对齐访问可能导致性能下降或硬件异常。

然而,lblbusb 指令用于字节访问,没有对齐限制,可以访问任意地址。


总结

本节课中我们一起学习了 RISC-V 架构中数据传输的核心知识。

我们首先回顾了计算机的基本结构,明确了 CPU、寄存器和内存的分工与性能差异。接着,我们深入探讨了内存的字节寻址方式和小端序表示法。课程的重点是 RISC-V 的数据传输指令:lw(加载字)和 sw(存储字)的格式、语义及使用场景。我们还学习了用于处理字节数据的 lblbusb 指令,并理解了符号扩展与零扩展的区别。最后,我们强调了字对齐访问的重要性。

理解这些指令是编写高效汇编程序和控制数据在计算机层次结构中流动的基础。下一节课,我们将继续深入探讨更多指令类型和编程技巧。

课程 P11:讨论课 3 - 浮点数与 C 语言内存管理 🧠💾

在本节课中,我们将学习 C 语言中的内存管理分区,以及浮点数的 IEEE 754 标准表示方法。我们将通过具体示例来理解这些核心概念。

概述 📋

本次讨论课将分为两部分。首先,我们将回顾 C 语言程序运行时内存的四个主要分区:栈、堆、静态区和代码段。其次,我们将深入探讨浮点数的表示,特别是 IEEE 754 标准,学习如何在小数和二进制浮点表示之间进行转换。


C 语言内存管理 💻

上一节我们介绍了课程的主要内容。本节中,我们来看看 C 程序运行时内存是如何组织的。

C 程序的内存主要分为四个分区:

  • :主要用于存储局部变量、函数参数和返回地址。栈内存由编译器自动管理,函数调用时分配,返回时释放。
    • 公式示例:当一个函数 func(int a) 被调用时,参数 a 的值会被复制到栈上的新位置。
  • :用于动态内存分配,通过 malloccalloc 等函数手动申请,使用 free 函数手动释放。
    • 代码示例int *arr = (int*)malloc(10 * sizeof(int));
  • 静态区:存储全局变量、静态变量(static 关键字)和字符串字面量。该区域在程序整个生命周期内存在。
    • 代码示例static int count = 0;char *str = "Hello"; 中的 "Hello"
  • 代码段:存储程序的机器指令(代码)和只读常量(如一些宏定义)。

以下是关于字符串在内存中存储位置的一个重要区别:

  • char arr[] = "Hello";:字符串字面量 "Hello" 存储在静态区,但其内容会被复制到栈上为新数组 arr 分配的空间中。因此,可以修改 arr 的内容。
  • char *ptr = "Hello";:指针 ptr 直接指向存储在静态区中的字符串字面量 "Hello"。尝试通过 ptr 修改内容通常是未定义行为。

关于动态内存分配,malloccalloc 的区别如下:

  • malloc(size):分配指定字节数的内存,不初始化内容(内容随机)。
  • calloc(num, size):分配 num 个大小为 size 的元素空间,并将所有位初始化为 0。

浮点数表示 🌊

理解了内存的基本分区后,本节我们来看看计算机如何表示浮点数。

我们使用 IEEE 754 单精度标准。它将 32 位分为三个字段:

  • 符号位 (1 bit)0 表示正数,1 表示负数。
  • 指数位 (8 bits):采用 偏置表示法。实际指数 = 无符号指数值 - 127。
  • 尾数位/有效数字位 (23 bits):存储规格化后二进制小数的小数部分(即省略掉开头的 1.)。

浮点数主要有两种类型:

  • 规格化数:当指数位不全为 0 且不全为 1 时。这是最常用的形式,能表示非常大和非常小的数。
    • 此时,尾数隐含一个前导的 1.。值为:(-1)^符号位 × 1.尾数(2) × 2^(指数-127)
  • 非规格化数:当指数位全为 0 时。用于表示非常接近 0 的数。
    • 此时,尾数隐含一个前导的 0.,且指数固定为 -126。值为:(-1)^符号位 × 0.尾数(2) × 2^(-126)

转换练习 🔄

上一节我们介绍了浮点数的结构,本节中我们通过一个具体例子来练习从十进制到浮点表示的转换。

问题:将十进制数 39.5625 转换为 IEEE 754 单精度二进制表示。

步骤 1:处理符号
39.5625 是正数,所以 符号位 S = 0

步骤 2:分别转换整数和小数部分为二进制

  • 整数部分 39:39(10) = 32 + 4 + 2 + 1 = 100111(2)
  • 小数部分 0.5625:0.5625(10) = 0.5 + 0.0625 = 0.1001(2)
  • 合并:39.5625(10) = 100111.1001(2)

步骤 3:规格化二进制数
将二进制小数点左移,使其左边只有一位 1
100111.1001(2) = 1.001111001(2) × 2^5

步骤 4:确定指数字段
实际指数 E = 5。
偏置指数 = E + 127 = 5 + 127 = 132。
132(10) = 10000100(2)
所以 指数位 Exp = 10000100

步骤 5:确定尾数字段
从规格化形式 1.001111001 中,取小数点后的部分,并在右侧补零至 23 位。
尾数 M = 00111100100000000000000

步骤 6:组合
最终 32 位表示为:S | Exp | M
0 | 10000100 | 00111100100000000000000
转换为十六进制更紧凑:0x42272000


总结 🎯

本节课中我们一起学习了:

  1. C 语言内存管理:程序内存分为栈、堆、静态区和代码段。我们比较了 malloccalloc,并区分了字符数组与字符指针初始化字符串时的内存差异。
  2. 浮点数表示:深入理解了 IEEE 754 单精度浮点数的标准,包括符号位、偏置指数位和尾数位的含义,以及规格化与非规格化数的区别。
  3. 转换方法:通过逐步演示,掌握了将十进制小数转换为 IEEE 754 二进制格式的方法。

理解这些底层概念对于编写高效、正确的 C 程序至关重要,也是后续学习计算机体系结构的基础。

课程 P12:Lecture 9: RISC-V 决策与逻辑运算 🧠💡

在本节课中,我们将要学习 RISC-V 架构中的决策(条件分支)和逻辑运算。这是编写智能程序(如 if-else 语句和循环)的基础。我们将了解如何将高级语言中的控制流转换为底层的 RISC-V 指令,并学习按位逻辑操作。


概述 📋

上一节我们介绍了 RISC-V 的内存访问指令(如 lwsw)。本节中我们来看看如何让程序“做决定”,即根据条件执行不同的代码路径,这是通过条件分支指令实现的。同时,我们也会学习逻辑运算指令,它们用于对寄存器中的位进行并行操作。


决策:条件分支 🚦

程序智能化的核心是能够做出选择,例如 if 语句。在 RISC-V 中,这是通过分支(Branch) 指令完成的。

分支指令基础

核心指令是 beq(branch if equal)和 bne(branch if not equal)。

  • beq rs1, rs2, label:如果寄存器 rs1 中的值等于寄存器 rs2 中的值,则程序跳转到 label 处执行;否则,顺序执行下一条指令。
  • bne rs1, rs2, label:如果寄存器 rs1 中的值不等于寄存器 rs2 中的值,则程序跳转到 label 处执行。

这里的 label 是代码中的一个标签(如 loop:),用于标记跳转目标地址。

比较与更多分支条件

为了进行大小比较,RISC-V 提供了有符号和无符号版本的分支指令。

  • blt rs1, rs2, label:如果 rs1 < rs2(有符号比较),则跳转。
  • bge rs1, rs2, label:如果 rs1 >= rs2(有符号比较),则跳转。
  • bltu rs1, rs2, label:如果 rs1 < rs2(无符号比较),则跳转。
  • bgeu rs1, rs2, label:如果 rs1 >= rs2(无符号比较),则跳转。

注意:RISC-V 指令操作的是位,本身没有类型信息。因此,你必须根据数据含义选择正确的有符号(blt/bge)或无符号(bltu/bgeu)比较指令。

无条件跳转

除了条件分支,还有无条件跳转指令 j label,它总是跳转到指定的 label,用于实现 goto 或跳出循环。


条件分支应用示例 🔄

让我们通过一个例子来理解如何将 C 语言的 if-else 语句转换为 RISC-V 代码。

考虑以下 C 代码:

if (i == j) {
    f = g + h;
} else {
    f = g - h;
}

转换时的一个常见技巧是“翻转条件”。我们通常使用 bne(如果不相等则跳过 then 块)来组织代码,这样更符合汇编的线性执行流程。

以下是可能的 RISC-V 汇编实现:

    bne x22, x23, Else   # 如果 i != j,跳转到 Else 标签
    add x19, x20, x21    # then 块: f = g + h
    j Exit               # 跳过 else 块
Else:
    sub x19, x20, x21    # else 块: f = g - h
Exit:
    # 后续代码...

关键点:在汇编中,我们通过判断“条件不成立”时跳走来控制流程,然后在条件成立处顺序执行 then 块,并用 j 指令跳过 else 块。


循环的实现 🔁

所有高级语言中的循环(for, while, do-while)最终都会转换为条件分支和跳转指令。以下是一个累加数组元素的 C 语言 for 循环及其 RISC-V 翻译思路。

C 代码(累加模式):

int A[20];
int sum = 0;
for (int i = 0; i < 20; i++) {
    sum += A[i];
}

RISC-V 汇编核心结构:

    # 初始化: x10 (sum)=0, x11 (i)=0, x13=20, x9 指向数组 A 首地址
loop:
    bge x11, x13, done   # 如果 i >= 20,跳出循环
    lw x12, 0(x9)        # 临时加载 A[i]
    add x10, x10, x12    # sum += A[i]
    addi x9, x9, 4       # 移动指针到下一个数组元素(int 占 4 字节)
    addi x11, x11, 1     # i++
    j loop               # 跳回循环开始
done:
    # 循环结束...

逻辑分析:循环条件 i < 20 被转换为它的相反条件 i >= 20 作为分支判断。只要 i < 20 为真(即 bge 条件为假),就继续执行循环体内的指令,并在末尾跳回标签 loop


逻辑运算 🛠️

逻辑运算指令对寄存器中的所有位进行并行操作,常用于位掩码(masking)、位设置和清零。

基本逻辑指令

RISC-V 提供了与 C 语言位操作符对应的指令:

  • and rd, rs1, rs2:按位与,rd = rs1 & rs2
  • or rd, rs1, rs2:按位或,rd = rs1 | rs2
  • xor rd, rs1, rs2:按位异或,rd = rs1 ^ rs2

这些指令也有立即数版本(andi, ori, xori),可以将寄存器与一个常数进行运算。

移位指令

移位操作也属于逻辑运算:

  • slli rd, rs1, shamt:逻辑左移,rd = rs1 << shamt。低位补 0,高位丢弃。对于无符号数,这相当于乘以 2^shamt。
  • srli rd, rs1, shamt:逻辑右移,rd = rs1 >> shamt。高位补 0,低位丢弃。
  • srai rd, rs1, shamt:算术右移,rd = rs1 >> shamt。高位用原来的符号位填充,低位丢弃。这对于保持有符号数的符号非常有用,相当于有符号数除以 2^shamt(向零取整)。

应用:位掩码(Masking)

使用 and 指令和特定掩码(mask)可以保留或清除某些位。例如,要保留一个寄存器的最低 8 位(一个字节),而将其他位清零:

andi x12, x10, 0xFF  # 0xFF 的二进制是低8位为1,其余为0

这里,0xFF 就是掩码。and 运算的特性是:与 1 相与保留原值,与 0 相与结果为 0。

异或(XOR)的妙用

xor 指令有一个有趣特性:当其中一个操作数为 1 时,它对另一位执行“取反”操作;当为 0 时,则保留另一位不变。因此,xori 常被用作条件按位取反的工具。


编译过程回顾与程序计数器 🖥️

我们编写的 RISC-V 汇编代码(人类可读)需要经过以下步骤才能变成机器可执行的程序:

  1. 编译器:将高级语言(如 C)转换为汇编语言(.s 文件)。
  2. 汇编器:将汇编语言转换为机器码(目标文件 .o),并处理标签(label)。在此阶段,像 beq 指令中的 label 会被计算为具体的地址偏移量。
  3. 链接器:将多个目标文件及库文件链接在一起,解析跨文件的函数引用(如 foo),生成最终的可执行文件。

在 CPU 内部,一个名为程序计数器(Program Counter, PC) 的特殊寄存器指向当前正在执行的指令在内存中的地址。通常,每执行完一条指令,PC 会自动加 4(因为 RISC-V 指令是 32 位/4 字节),指向下一条指令。分支跳转指令的作用就是改变 PC 的值,从而改变程序的执行流。


伪指令与寄存器别名 ✨

为了让汇编代码更易读写,RISC-V 定义了一些伪指令寄存器别名

  • 寄存器别名:用有意义的名称代替数字寄存器编号。
    • a0-a7 -> x10-x17:函数参数/返回值寄存器。
    • zero -> x0:恒为零的寄存器。
  • 常用伪指令
    • mv rd, rs:复制寄存器,rd = rs(实际是 addi rd, rs, 0)。
    • li rd, constant:加载立即数到寄存器(实际是 addi rd, zero, constant 或更复杂的序列)。
    • nop:空操作,不执行任何效果(实际是 addi x0, x0, 0)。


总结 🎯

本节课中我们一起学习了 RISC-V 架构中实现程序决策和逻辑处理的核心内容:

  1. 条件分支指令:包括 beqbnebltbge 等,用于实现 if-else 和循环控制流。关键技巧是经常使用条件的“反面”进行分支判断。
  2. 逻辑运算指令:包括 andorxor 以及移位指令 sllisrlisrai。它们对位进行并行操作,用于掩码、位操作和算术辅助。
  3. 编程模式:我们分析了如何将高级语言的 if-else 语句和 for 循环(特别是累加模式)翻译成 RISC-V 汇编代码。
  4. 底层支持:了解了程序计数器(PC)如何控制执行流,以及伪指令如何简化代码编写。

掌握这些指令是理解计算机如何执行条件判断和复杂运算的基础,也是后续学习函数调用和更高级编程概念的必备知识。

课程 P13:Lecture 10: RISC-V 过程(函数)调用 🧩

在本节课中,我们将要学习 RISC-V 架构中过程(或称为函数)调用的机制。你将理解计算机如何跟踪函数调用、传递参数、保存返回地址以及管理寄存器,从而让复杂的程序能够有条不紊地运行。

概述:为什么需要过程调用?

到目前为止,你已经学会了如何使用跳转和分支指令在程序中移动。你可以编写循环和条件判断。但你还没有办法编写一个可以被重复调用的、独立的函数模块。函数调用是结构化编程的核心,它允许代码复用和逻辑封装。本节将揭开函数调用背后的神秘面纱。

上一节我们介绍了基本的控制流指令,本节中我们来看看如何实现更高级的函数调用与控制权转移。

函数调用的六个步骤

为了实现一个函数调用,处理器需要协同完成一系列标准化的步骤。以下是调用函数的六个关键步骤:

  1. 传递参数:将函数需要的参数值放置到约定的位置。
  2. 转移控制权:跳转到被调用函数的代码起始地址。
  3. 分配局部存储:为被调用函数的局部变量分配内存空间(如果需要)。
  4. 执行函数体:运行函数内部的指令,完成计算任务。
  5. 存储返回值:将函数的计算结果存放到调用者能获取的位置。
  6. 恢复控制权并返回:跳转回调用发生位置的下一条指令,继续执行。

我们将具体看到在 RISC-V 中如何实现这些步骤。

RISC-V 的调用约定

为了确保不同函数之间能正确协作,RISC-V 定义了一套严格的“调用约定”,规定了寄存器的用途。

参数寄存器:a0-a7

RISC-V 使用 a0a7 这 8 个寄存器来传递函数参数。这是为了效率,因为访问寄存器远比访问内存快。

  • a0 对应 x10
  • a1 对应 x11
  • ...
  • a7 对应 x17

核心概念

# 假设要调用函数 func(x, y)
# 将参数 x 的值放入 a0,参数 y 的值放入 a1
mv a0, s0  # s0 中存放着 x 的值
mv a1, s1  # s1 中存放着 y 的值

注意:如果函数参数超过 8 个,超出的部分需要通过“栈”来传递。

返回地址寄存器:ra

寄存器 ra (即 x1) 用于存放返回地址。当调用函数时,处理器会自动将当前指令的下一条指令地址(即返回后应继续执行的位置)存入 ra。函数执行完毕后,通过跳转到 ra 中的地址来返回。

保存寄存器:s0-s11

寄存器 s0s11 (对应 x8-x9, x18-x27) 被称为“保存寄存器”。调用约定规定,被调用的函数如果使用了这些寄存器,必须保证在返回前,它们的值恢复到被调用之前的状态。这意味着,如果函数内部要使用 s 寄存器,它必须先把旧值保存到内存(通常是栈上),使用完毕后再恢复。这样,调用者就可以放心地在 s 寄存器中存放重要数据,而不必担心被调用的函数破坏它们。

临时寄存器:t0-t6

寄存器 t0t6 (对应 x5-x7, x28-x31) 被称为“临时寄存器”。与 s 寄存器相反,被调用的函数可以随意使用它们,而无需保存和恢复。因此,调用者如果希望某个值在函数调用后保持不变,就不应该把它放在 t 寄存器中。

关键指令:跳转与链接

简单的 j (jump) 指令可以实现跳转,但无法保存返回地址。RISC-V 提供了专门的函数调用指令。

jal 指令完成两件事:

  1. 链接:将下一条指令的地址(PC + 4)保存到 ra 寄存器中。
  2. 跳转:跳转到目标标签指定的地址。

核心概念

# 调用函数 ‘sum’
jal sum   # 1. 将返回地址 (PC+4) 存入 ra
          # 2. 跳转到标签 ‘sum’ 处执行
# 函数返回后将从此处继续执行

jalr 指令与 jal 类似,但跳转目标地址来自一个寄存器。

  • jalr ra, 0(t1) 表示:将 PC+4 存入 ra,然后跳转到 t1 寄存器中存储的地址。
  • ret 是一个伪指令,等价于 jalr zero, 0(ra),用于从函数返回。

栈:函数调用的内存舞台

当一个函数需要更多的存储空间(例如,保存 s 寄存器的值、存放局部变量、或者传递超过8个的参数)时,它就会使用“栈”。栈是一段按照“后进先出”原则管理的内存区域。

  • 栈指针:寄存器 sp (即 x2) 指向栈的“顶部”。
  • 栈的生长方向:在 RISC-V 中,栈向内存地址减小的方向生长(向下生长)。
  • 分配空间:通过减小 sp 的值来在栈上分配新的空间。
    addi sp, sp, -16  # 将栈指针下移 16 字节,分配空间
    
  • 释放空间:函数返回前,通过增加 sp 的值来释放它分配的空间。
    addi sp, sp, 16   # 将栈指针上移 16 字节,释放空间
    
  • 栈帧:每次函数调用所分配的栈内存块,称为该函数的“栈帧”。

实例分析:一个叶子函数

让我们分析一个简单的“叶子函数”(即不调用其他函数的函数)的例子:leaf(g, h, i, j),它计算 (g+h) - (i+j)

以下是该函数的 C 代码和对应的 RISC-V 汇编代码:

C 代码:

int leaf(int g, int h, int i, int j) {
    int f;
    f = (g + h) - (i + j);
    return f;
}

RISC-V 汇编:

leaf:
    # 1. 在栈上分配空间(16字节:8字节用于保存s0,8字节对齐)
    addi sp, sp, -16
    # 2. 保存需要使用的保存寄存器 s0, s1
    sd s0, 0(sp)   # 将 s0 的旧值存到栈帧
    sd s1, 8(sp)   # 将 s1 的旧值存到栈帧

    # 3. 执行函数计算
    # 参数已在 a0(g), a1(h), a2(i), a3(j) 中
    add t0, a0, a1 # t0 = g + h
    add t1, a2, a3 # t1 = i + j
    sub s0, t0, t1 # f = (g+h) - (i+j) 结果放在 s0

    # 4. 将返回值放入 a0
    mv a0, s0

    # 5. 恢复保存的寄存器
    ld s1, 8(sp)   # 从栈帧恢复 s1
    ld s0, 0(sp)   # 从栈帧恢复 s0
    # 6. 释放栈空间
    addi sp, sp, 16
    # 7. 返回
    ret

代码解读

  1. 函数首先通过调整 sp 在栈上开辟了 16 字节空间。
  2. 因为它要使用 s0s1 寄存器,所以必须先将它们当前的值保存到栈上 (sd 指令)。
  3. 使用 a0-a3 中的参数进行计算,结果暂存于 s0
  4. 将最终结果 f 移动到返回值寄存器 a0
  5. 在返回前,从栈上恢复 s1s0 的原始值 (ld 指令)。
  6. 通过调整 sp 释放栈空间。
  7. 使用 ret 指令跳转回 ra 指向的地址。

嵌套调用与非叶子函数

当一个函数(我们称之为主调函数)内部又调用了其他函数(被调函数)时,情况会复杂一些。主调函数必须注意:

  • 如果它自己的返回地址 ra 在调用其他函数后还需要使用(通常都需要),那么它必须在调用前将 ra 保存到栈上,否则 ra 会被新的调用覆盖。
  • 如果它放在 a0-a7 中的参数在调用其他函数后还需要使用,也需要提前保存,因为被调函数有权覆盖这些寄存器。
  • 临时寄存器 t0-t6 永远不可靠,如果其中的值在调用后还需要,必须提前保存。

保存和恢复这些值的原理与保存 s 寄存器完全一样,都是通过栈操作 (sd/ld) 来完成。

总结

本节课中我们一起学习了 RISC-V 架构中过程调用的完整机制。我们了解了函数调用的六个步骤,掌握了 RISC-V 关键的调用约定,包括参数寄存器 (a0-a7)、返回地址寄存器 (ra)、保存寄存器 (s0-s11) 和临时寄存器 (t0-t6) 的角色。我们学习了 jaljalr 指令,并深入探讨了栈的概念及其在管理函数局部数据和保存现场中的核心作用。通过分析叶子函数的汇编实例,你将函数调用的抽象概念与具体的机器指令联系了起来。理解这些内容是编写和阅读复杂汇编程序的基础。

课程 P14:Lecture 11: RISC-V 指令格式 I 🧩

在本节课中,我们将学习 RISC-V 指令集架构中机器语言的基础知识。我们将从存储程序计算机的历史背景讲起,然后深入探讨 RISC-V 指令如何被编码为 32 位的机器码。我们将重点学习 R、I、S 这三种指令格式,理解它们的设计逻辑和字段布局,并学习如何将汇编指令翻译成对应的二进制位模式。


从汇编语言到机器语言 🖥️

上一节我们介绍了 RISC-V 的汇编语言。本节中,我们来看看软件与硬件之间的接口——指令集架构(ISA)。ISA 不仅定义了汇编指令,还定义了这些指令在硬件层面如何表示为 0 和 1 的位模式,即机器语言。

在 RISC-V 中,每条指令都被编码为一个 32 位的“字”。这个字被划分为多个“字段”,每个字段承载着指令的不同信息(如操作类型、寄存器编号、立即数等)。

存储程序计算机的历史背景 📜

在深入指令格式之前,了解“存储程序计算机”的概念至关重要。这个概念并非与生俱来。

早期的计算机,如 ENIAC(1946年),需要通过物理连接(如插拔线缆)来为每个新任务“编程”,这个过程可能需要数天。随后出现的 EDSAC(1949年)是第一台“存储程序”计算机,它将指令像数据一样以二进制形式存储在内存中。这使得“编程”变成了写入和加载位模式,速度大大加快。

这个概念由冯·诺依曼等人提出并推广。存储程序计算机带来了两个关键影响:

  1. 所有东西(指令和数据)都有一个内存地址。
  2. 程序以二进制机器码的形式分发,并与特定的指令集架构绑定。

RISC-V 指令格式概述 📊

RISC-V 没有为每条指令定义独一无二的位模式,而是将指令归类为几种固定的“格式”。这简化了 CPU 硬件的设计。本节课我们将学习前三种格式:R、I 和 S 格式。

R 格式:寄存器-寄存器操作 🔄

R 格式指令用于在两个寄存器上进行操作,并将结果写入第三个寄存器。例如加法 add x10, x18, x19

以下是 R 格式指令的 32 位字段布局:

位区间 (bits) 字段名 描述
31-25 funct7 功能码字段 7
24-20 rs2 第二个源寄存器编号
19-15 rs1 第一个源寄存器编号
14-12 funct3 功能码字段 3
11-7 rd 目标寄存器编号
6-0 opcode 操作码

核心概念解析

  • 操作码 (opcode):一个 7 位字段,大致标识指令的格式和基本操作类型。对于所有 R 格式算术/逻辑指令,opcode 都是 0110011
  • 寄存器字段 (rd, rs1, rs2):每个都是 5 位,因为 RISC-V 有 32 个寄存器(编号 0-31),5 位恰好可以表示 2^5 = 32 种可能。
  • 功能码 (funct3, funct7):与 opcode 结合,精确指定是哪种操作(如加、减、与、或等)。例如,add 指令的 funct3000funct70000000

示例:编码 add x10, x18, x19

  1. opcode: 0110011
  2. rd (x10): 寄存器编号 10 = 01010
  3. funct3: 000
  4. rs1 (x18): 寄存器编号 18 = 10010
  5. rs2 (x19): 寄存器编号 19 = 10011
  6. funct7: 0000000

按字段顺序组合起来,就得到了该指令的 32 位机器码。

I 格式:立即数操作 🔢

I 格式指令使用一个寄存器和一个立即数(常量)进行操作,结果写入另一个寄存器。例如立即数加法 addi x15, x1, -50

I 格式的字段布局如下:

位区间 (bits) 字段名 描述
31-20 imm[11:0] 12 位立即数
24-20 rs1 源寄存器编号
19-15 funct3 功能码字段 3
11-7 rd 目标寄存器编号
6-0 opcode 操作码

核心概念解析

  • 立即数字段 (imm[11:0]):一个 12 位字段,用于存放常量值。CPU 会将其符号扩展为 32 位后再参与运算。其表示范围为 -2^112^11 - 1(即 -2048 到 2047)。
  • 设计一致性rdrs1funct3opcode 字段的位置与 R 格式保持一致。这简化了 CPU 读取寄存器编号等操作。
  • 移位指令的特殊性:对于移位指令(如 slli),其移位量只需要 0-31,因此只使用立即数的最低 5 位 imm[4:0],高位 imm[11:5] 用作额外的功能码。这在参考卡上有时被标记为 I* 格式。

I 格式也用于加载指令(如 lw)。此时,rs1 是基地址寄存器,imm[11:0] 是偏移量,计算出的内存地址处的数据被加载到目标寄存器 rd 中。

S 格式:存储指令 💾

S 格式指令用于将寄存器中的数据存储到内存中。例如 sw x13, 8(x20)

S 格式的字段布局如下:

位区间 (bits) 字段名 描述
31-25 imm[11:5] 立即数的高 7 位
24-20 rs2 要存储的数据所在的源寄存器
19-15 rs1 基地址寄存器
14-12 funct3 功能码字段 3
11-7 imm[4:0] 立即数的低 5 位
6-0 opcode 操作码

核心概念解析

  • 立即数拆分:12 位立即数被拆分到两个位置。这是为了保持关键字段位置的一致性
  • 设计哲学:CPU 解码指令时,一个关键任务是快速确定需要读取哪些寄存器。在 R 和 I 格式中,rs1rs2 的位位置是固定的。S 格式为了保持 rs1rs2 字段的位置与 R 格式相同,不得不将完整的 12 位立即数拆分开。这对 CPU 硬件设计更友好,尽管对人类阅读不那么直观。

总结 🎯

本节课我们一起学习了 RISC-V 机器语言的基础和三种核心指令格式:

  1. R 格式:用于寄存器间的算术逻辑运算,包含 funct7rs2rs1funct3rdopcode 字段。
  2. I 格式:用于包含立即数的操作或加载指令,包含 imm[11:0]rs1funct3rdopcode 字段。
  3. S 格式:用于存储指令,将 12 位立即数拆分以保持 rs1rs2 字段位置与 R 格式一致。

理解这些格式的关键在于领会 RISC-V 的设计原则简洁性硬件友好性。通过将指令归类为少数几种格式,并保持源/目标寄存器字段位置相对固定,极大地简化了 CPU 控制单元的设计。虽然机器码对人来说难以直接阅读,但通过参考卡和对其设计逻辑的理解,我们可以准确地在汇编指令和二进制位模式之间进行转换。

课程 P15:RISC-V 介绍与控制流 🚀

在本节课中,我们将学习 RISC-V 架构的基础知识,包括其指令集、寄存器以及控制流指令。我们将从 C 语言代码翻译到 RISC-V 汇编,并理解程序如何在底层执行。


概述

RISC-V 是一种精简指令集计算机(RISC)架构,由加州大学伯克利分校发明。本节课将介绍 RISC-V 的基本概念,包括数据路径、寄存器、算术指令和控制流指令。我们还将学习如何将简单的 C 语言代码翻译成 RISC-V 汇编代码。


数据路径简介

在深入了解 RISC-V 指令之前,我们需要理解计算机数据路径的基本组成部分。数据路径是处理器中执行指令的硬件路径。

计算机包含以下几个关键部分:

  • 程序计数器(PC):存储当前正在执行的指令的地址。
  • 寄存器:CPU 中的小块高速存储单元,用于存储当前正在处理的值。
  • 算术逻辑单元(ALU):执行算术(加、减、乘、除)和逻辑(移位、与、或、异或)操作。
  • 主内存:用于长期存储数据。CPU 可以通过特定的加载和存储指令与主内存交互。

什么是 RISC-V?

RISC-V 代表“精简指令集计算机,第五代”。它是一种指令集架构(ISA),定义了由 CPU 和主内存组成的系统的工作方式。RISC-V 使用一种由简单指令组成的汇编语言,每条指令完成一个单一任务。

CPU 负责执行计算。它接受指令并执行必要的操作以产生输出。主内存用于长期存储数据。寄存器是 CPU 中存储二进制值的硬件组件。

在 RISC-V 中,有 32 个通用寄存器,编号为 x0x31。其中有一个特殊的寄存器 x0(零寄存器),它硬连线为值 0,且不可更改。

重要提示:寄存器不是变量。不能假设在指令序列执行后,寄存器中的值会保持不变。


RISC-V 指令格式

RISC-V 汇编指令通常遵循以下格式:
指令名 目标寄存器, 源操作数1, 源操作数2

操作数可以是两个源寄存器,也可以是一个源寄存器和一个立即数(常数)。使用逗号分隔和井号(#)添加注释可以使代码更清晰。

寄存器根据其命名约定有不同的用途:

  • x0:零寄存器。
  • s0-s11:保存寄存器。在函数调用后,其值必须保持不变。
  • t0-t6:临时寄存器。函数可以自由使用,调用者不期望其值被保存。
  • a0-a7:参数寄存器。通常用于向函数传递参数。
  • ra:返回地址寄存器。存储函数调用后应返回的地址。
  • sp:堆栈指针寄存器。存储当前堆栈顶部的地址。

算术指令

上一节我们介绍了指令的基本格式,本节中我们来看看用于计算的算术指令。RISC-V 提供了多种算术指令。

以下是主要的算术指令类型:

加法与减法

  • add rd, rs1, rs2:将寄存器 rs1rs2 中的值相加,结果存入 rd
    • 公式rd = rs1 + rs2
  • sub rd, rs1, rs2:将寄存器 rs1 的值减去 rs2 的值,结果存入 rd
    • 公式rd = rs1 - rs2

移位操作
移位操作对乘法和除法非常有用。有两种主要类型:

  1. 逻辑移位:移位时,空出的位用 0 填充。
  2. 算术移位:右移时,空出的位用最高有效位(MSB) 的值填充,这可以保持有符号数的符号。
  • slli rd, rs1, imm:逻辑左移。
  • srli rd, rs1, imm:逻辑右移。
  • srai rd, rs1, imm:算术右移。

立即数指令
立即数是一个编码在指令中的常数。例如:

  • addi rd, rs1, imm:将寄存器 rs1 的值加上立即数 imm,结果存入 rd
    • 公式rd = rs1 + imm

立即数通常是一个 12 位的二进制补码数。如果需要更大的常数,可以使用伪指令 li(加载立即数)。


控制流指令

到目前为止,我们看到的指令都是顺序执行的。但在编程中,我们经常需要根据条件改变执行流程,这就是控制流指令的作用。

控制流指令主要分为两类:条件分支无条件跳转

条件分支
条件分支类似于高级语言中的 if-else 语句。如果条件满足,程序将跳转到一个新的地址(标签)执行。

以下是常见分支指令:

  • beq rs1, rs2, label:如果 rs1 == rs2,则跳转到 label
  • bne rs1, rs2, label:如果 rs1 != rs2,则跳转到 label
  • blt rs1, rs2, label:如果 rs1 < rs2(有符号比较),则跳转到 label
  • bge rs1, rs2, label:如果 rs1 >= rs2(有符号比较),则跳转到 label
  • bltu rs1, rs2, label:如果 rs1 < rs2(无符号比较),则跳转到 label
  • bgeu rs1, rs2, label:如果 rs1 >= rs2(无符号比较),则跳转到 label

无条件跳转
无条件跳转总是会跳转,不依赖于任何条件。它用于实现函数调用和循环。

  • jal rd, label:跳转并链接。将下一条指令的地址(PC+4)存入 rd,然后跳转到 label
    • 这通常用于函数调用,rd 通常设为 ra(返回地址寄存器)。
  • jalr rd, rs1, imm:跳转并链接寄存器。将 PC+4 存入 rd,然后跳转到地址 rs1 + imm
  • j label:这是一个伪指令,等价于 jal x0, label。它直接跳转到 label,但不保存返回地址(因为 x0 不可写)。
  • jr rs1:这是一个伪指令,等价于 jalr x0, rs1, 0。它跳转到 rs1 寄存器中存储的地址。

关键区别

  • 使用 jaljalr 时,你会存储返回地址,以便之后能返回。
  • 使用 jjr 时,你不关心返回地址,只是单纯地跳走(例如,在循环末尾跳回循环开头)。

代码翻译示例

理解了算术和控制流指令后,让我们来看一个将 C 代码翻译成 RISC-V 汇编的简单例子。

C 代码

int a = 4;
int b = 5;
int c = 6;
int z = a + b + c + 10;

RISC-V 汇编(假设 a->s0, b->s1, c->s2, z->s3):

addi s0, x0, 4   # a = 4
addi s1, x0, 5   # b = 5
addi s2, x0, 6   # c = 6
add  s3, s0, s1  # z = a + b
add  s3, s3, s2  # z = z + c
addi s3, s3, 10  # z = z + 10

注意:我们不需要显式地将 s3 初始化为 0,因为第一条 add 指令会直接覆盖 s3 中原来的值。


总结

在本节课中,我们一起学习了 RISC-V 架构的基础知识。我们从数据路径和寄存器的概念开始,然后学习了 RISC-V 指令的基本格式和命名约定。我们详细探讨了算术指令(如 add, sub, 移位)和立即数的使用。最后,我们深入研究了控制流指令,包括条件分支(如 beq, blt)和无条件跳转(如 jal, j),并理解了它们在实现循环和函数调用中的作用。通过一个简单的代码翻译示例,我们实践了如何将高级语言逻辑映射到底层汇编指令。掌握这些概念是理解计算机如何执行程序的关键一步。

课程 P16:第12讲:RISC-V 指令格式 II 🧩

在本节课中,我们将深入学习 RISC-V 指令格式,特别是 S 格式、B 格式、J 格式和 U 格式。我们将探讨这些格式如何编码分支、跳转和长立即数操作,并理解程序计数器(PC)在这些指令执行过程中的关键作用。


回顾与引入:S 格式指令示例

上一节我们介绍了 RISC-V 的基本指令格式。本节中,我们通过一个 S 格式指令的例子来巩固理解。

S 格式指令用于存储操作,例如 sw(存储字)。这类指令读取两个源寄存器(一个存数据,一个存基地址),并将数据写入内存,因此没有目的寄存器字段。

以下是一个机器代码示例,我们需要将其反汇编为汇编指令。

操作过程如下:

  1. 识别操作码:前7位 1100011 对应 S 格式的 sw 指令。
  2. 解析寄存器
    • rs1(基地址寄存器)字段:对应寄存器 x2
    • rs2(数据寄存器)字段:对应寄存器 x14
  3. 解析立即数:S 格式的12位立即数被拆分存放。我们需要将高位部分(imm[11:5])和低位部分(imm[4:0])拼接起来,得到一个有符号整数 36

最终,这条指令的汇编形式是:sw x14, 36(x2)。其含义是将寄存器 x14 中的数据存储到内存地址 x2 + 36 处。


程序计数器(PC)与指令执行流程 🗓️

在深入其他指令格式前,我们需要理解一个核心概念:程序计数器。

程序计数器是一个特殊的32位寄存器,它存储着下一条将要执行指令的地址。你可以把它想象成一个“日程表”,总是告诉你CPU下一步要做什么。

PC 如何工作?

  • 对于绝大多数指令(如算术、逻辑指令),执行完毕后,PC 会自动增加 4 个字节(即一条指令的长度),指向内存中的下一条指令。
  • 对于分支跳转指令,它们会以不同的方式更新 PC,使其指向一个新的、非顺序的地址,从而实现程序流的改变。


B 格式:条件分支指令 🔀

本节我们来看看如何实现条件分支。B 格式指令用于条件分支,例如 beq(相等时分支)。

B 格式指令的核心思想是 PC 相对寻址。它存储的不是要跳转的绝对地址,而是相对于当前 PC 的偏移量。这样做有两个好处:一是生成的代码是位置无关的;二是可以用较少的比特数表示跳转目标。

一条 B 格式指令(如 beq rs1, rs2, label)需要编码以下信息:

  • 两个要比较的源寄存器(rs1, rs2)。
  • 一个条件码(funct3),指定是 beqbne 等。
  • 一个12位的有符号立即数,表示跳转偏移量。

关键问题:偏移量的单位是什么?
我们有12位来表示偏移量。如果单位是字节,我们只能跳转 ±2^11 字节。但 RISC-V 指令设计得很巧妙:

偏移量的单位是“半指令”(2字节)。因为指令地址总是4字节对齐的,其最低两位总是0。通过以2字节为单位,12位偏移量实际能表示的字节范围是 ±2^12 字节,相当于能跳转 ±2^10 条指令的距离,这更符合常见循环和条件分支的需求。

B 格式指令的立即数字段在指令中的排列方式比较特殊(位[12|10:5|4:1|11]),这是为了在硬件层面与其他格式的立即数字段共享布线,优化电路设计。

示例:解析 beq x19, x10, label
假设 label 在当前指令前方16字节处。

  1. 计算字节偏移量:+16
  2. 转换为半指令单位:16 / 2 = 8
  3. 8 编码到 B 格式指令的立即数字段中。


J 格式与 U 格式:长跳转与加载大立即数 🚀

当需要跳转的距离超过 B 格式的范围时,或者需要操作大的常数时,我们就需要 J 格式和 U 格式。

J 格式:长距离无条件跳转

J 格式指令(如 jal,跳转并链接)用于函数调用或长距离跳转。它的特点是有一个 20 位的有符号立即数字段,单位同样是“半指令”。这使得跳转范围达到了惊人的 ±2^19 条指令(±1 MiB 地址空间)。

jal rd, label 指令做两件事:

  1. 将下一条指令的地址(PC + 4)保存到目标寄存器 rd 中(用于函数返回)。
  2. 将 PC 设置为 PC + 符号扩展(imm20 << 1),实现跳转。

伪指令 j label(无条件跳转)其实就是 jal x0, label,它跳转但不保存返回地址。

U 格式:加载大立即数到寄存器高位

U 格式指令用于构建32位的常数或地址。它包含一个 20 位的立即数,这个立即数会被放置到目标寄存器的高20位。

主要有两条指令:

  1. lui rd, imm20(加载高位立即数):将 imm20 << 12 写入 rd 的高20位,低12位置零。
  2. auipc rd, imm20(PC 加立即数):将 PC + (imm20 << 12) 的结果写入 rd。常用于生成与当前代码位置相关的地址。

如何构建一个32位常数?
结合 luiaddi(I 格式)可以构建任意32位常数。例如,要加载 0xDEADBEEF

  • lui x5, 0xDEADB // 将 0xDEADB000 加载到 x5
  • addi x5, x5, 0xEEF // x5 = 0xDEADB000 + 0xEEF = 0xDEADBEEF
    汇编器提供的伪指令 li rd, imm 会自动生成最优的指令序列来完成这个操作。


I 格式的扩展:跳转并链接寄存器(JALR) 🔗

最后,我们介绍 jalr(跳转并链接寄存器)指令,它属于 I 格式。

jalr rd, offset(rs1) 指令做两件事:

  1. PC + 4 保存到目标寄存器 rd
  2. 将 PC 设置为 rs1 + 符号扩展(offset)

jalrjal 的关键区别在于,它的跳转目标地址是从寄存器计算出来的,而不是相对于 PC 的偏移。这使得它可以实现:

  • 函数返回jalr x0, 0(x1),跳转回调用者保存的返回地址(x1)。
  • 间接跳转(如通过函数指针、虚函数表调用):跳转目标地址在运行时计算并存入寄存器。
  • 绝对地址跳转:结合 luiaddi 在寄存器中构建完整32位地址,然后用 jalr 跳转,理论上可以跳转到任何地址。

总结 📚

本节课我们一起深入学习了 RISC-V 的多种指令格式:

  • S 格式:用于存储指令,理解其立即数的拆分存放方式。
  • 程序计数器(PC):理解了指令顺序执行和分支跳转时 PC 的更新机制。
  • B 格式:用于条件分支,掌握了 PC 相对寻址 的概念和偏移量的编码方式(以2字节为单位)。
  • J 格式:用于长距离无条件跳转(如 jal),具有更大的20位偏移量。
  • U 格式:用于构建大立即数(lui, auipc),是创建32位常数和地址的基石。
  • JALR 指令:实现了基于寄存器的间接跳转,是函数返回和动态调用的核心。

这些格式共同构成了 RISC-V 灵活而高效的指令系统,使我们能够实现复杂的控制流和数据操作。

课程 P17:编译器、汇编器、链接器、加载器 🛠️

在本节课中,我们将学习程序从高级语言(如C语言)到最终在计算机上运行所经历的完整过程。这个过程通常被称为“CALL”,它代表编译(Compile)、汇编(Assemble)、链接(Link)和加载(Load)。我们将逐一拆解这些步骤,了解它们如何协同工作,将人类可读的代码转化为机器可执行的指令。

伪指令与真实指令 🔄

上一节我们介绍了课程概述,本节中我们来看看伪指令的概念。在之前的课程中,你可能已经在RISC-V模拟器中使用过伪指令。伪指令是为了方便人类或编译器编写汇编代码而存在的,它们并不直接对应具有特定操作码和功能的机器指令,而是真实指令的快捷方式。

以下是几个伪指令及其对应的真实指令示例:

  • mv rd, rs: 这条伪指令表示将寄存器rs的内容“移动”到寄存器rd。实际上,它被翻译为一条addi rd, rs, 0指令,即将rs的值加上立即数0,结果存入rd
  • not rd, rs: 这条伪指令表示对寄存器rs的内容进行按位取反。实际上,它被翻译为一条xori rd, rs, -1指令。在二进制补码中,-1的表示是全1,因此与-1进行异或操作(XOR)就实现了按位取反的效果。
  • li rd, immediate: 这条伪指令表示将一个立即数加载到寄存器rd。根据立即数的大小,它可能被翻译为一条addi rd, x0, immediate指令(如果立即数在12位有符号数范围内),或者被翻译为lui rd, upper_20_bits后接addi rd, rd, lower_12_bits两条指令(如果立即数超过12位)。
  • j labeljr rs: 跳转伪指令。j label被翻译为jal x0, label,使用PC相对寻址。jr rs被翻译为jalr x0, rs, 0,使用绝对寻址(通过寄存器中的地址)。
  • la rd, label: 加载地址伪指令。它把标签label的地址加载到目标寄存器rd。汇编器会根据情况将其翻译为类似li的指令序列,可能涉及PC相对计算。
  • call label: 调用伪指令。它通常被翻译为jal ra, label,用于函数调用,同时会将返回地址保存在ra寄存器中。

理解伪指令有助于我们阅读编译器生成的汇编代码,并明白它们最终都会转化为标准的RISC-V机器指令。

程序翻译与运行概述 📁

上一节我们了解了伪指令如何被翻译,本节中我们来看看将程序从源代码转化为可执行文件的整体流程。翻译是指将程序从一种语言(通常是高级语言)转换为另一种语言(通常是更低级的语言)的过程。这可以提高程序的效率和性能。

例如,C语言是一种典型的编译型语言。它需要被编译和链接才能生成可执行文件。作为对比,Python通常是一种解释型语言,代码由解释器直接逐行执行,而无需预先编译成独立的机器码文件。

从C程序到运行该程序,主要经历以下四个步骤,合称为“CALL”:

  1. 编译(Compile): 将C源代码(.c文件)转换为汇编代码(.s文件)。
  2. 汇编(Assemble): 将汇编代码(包含伪指令)转换为机器代码目标文件(.o文件)。
  3. 链接(Link): 将一个或多个目标文件与所需的库文件合并,解析它们之间的引用关系,生成最终的可执行文件(如a.out)。
  4. 加载(Load): 操作系统将可执行文件加载到内存中,并为其创建运行环境,然后开始执行。

我们通常所说的“将C编译成二进制”,指的就是前三个步骤。

编译器(Compile) ➡️

编译器是CALL流程的第一步。它的输入是高级语言(如C)编写的源代码文件(例如foo.c),输出是对应的汇编语言文件(例如foo.s)。

在CS61C课程中,我们经常扮演“人类编译器”的角色,手动将C代码逻辑翻译成RISC-V汇编代码。真正的编译器(如GCC)则自动化了这个过程。编译器不仅进行直译,还会应用各种优化算法来提高生成代码的效率。

编译器生成的汇编代码中可能会包含我们之前提到的伪指令,以及一些给汇编器的指示信息。

汇编器(Assemble) ⚙️

上一节我们介绍了编译器的工作,本节中我们来看看汇编器。汇编器接收编译器生成的汇编代码文件(.s文件)作为输入,输出一个机器语言模块,即目标文件(.o文件)。

这个目标文件包含了二进制形式的机器代码,但它还不是一个完整的、可独立运行的程序。除了将汇编指令(包括伪指令)翻译成机器指令外,汇编器还负责处理汇编文件中的指示符(Directives)

指示符是汇编器指令,它们本身不产生机器指令,而是指导汇编器如何构建目标文件。例如:

  • .text: 后续内容为代码(指令)。
  • .data: 后续内容为静态数据。
  • .global sym: 声明符号sym为全局符号,允许其他文件访问。
  • .string “abc”: 在数据段中存储字符串“abc”。
  • .word 0x1234: 在数据段中存储一个字(4字节)的数据。

一个目标文件(.o)通常包含以下几个部分:

  1. 文件头(Header): 描述文件结构和各段位置的信息。
  2. 文本段(.text): 所有指令的机器码。
  3. 数据段(.data): 所有静态数据的机器表示。
  4. 符号表(Symbol Table): 本文件中定义的标签(如函数名、全局变量名)及其(预计的)地址列表。
  5. 重定位信息(Relocation Information): 一个“待办事项”列表,记录了本文件中那些在汇编阶段无法确定地址的引用(如调用外部函数、引用其他文件的全局变量),需要链接器后续处理。
  6. 调试信息(Debugging Information): 用于调试器的附加信息。

汇编器在翻译时,对于像beq, jal这类使用PC相对寻址的指令,如果目标标签在同一文件内,它可以计算并填充偏移量。这个过程通常需要扫描汇编代码两遍(Two-pass Assembler):第一遍记录所有标签的位置,第二遍利用这些信息来填充指令中的偏移量字段。

然而,对于引用其他文件符号(如printf)或引用静态数据地址的指令,汇编器无法在此时知道最终地址,因此它只能生成一个占位符,并将需要修复的位置和类型记录在“重定位信息”表中,交给链接器处理。

上一节我们了解到汇编器会生成带有“未解之谜”的目标文件,本节中我们来看看链接器如何解决这些问题。链接器接收一个或多个目标文件(.o)以及库文件作为输入,输出一个完整的可执行文件。

链接器的主要工作包括:

  1. 合并段: 将所有输入目标文件的.text段合并到输出文件的.text段,将所有.data段合并到输出文件的.data段。
  2. 符号解析与重定位: 这是链接器的核心工作。它收集所有输入文件的符号表,形成一个全局符号表。然后,它遍历每个文件的重定位信息表,查找每个未解析符号在全局符号表中的最终地址,并回过头去修改目标文件中对应指令的机器码,用正确的地址替换掉占位符。

链接器需要处理几种不同类型的地址引用:

  • PC相对引用(如beq, jal到同一文件内标签): 在汇编阶段已解决,链接器通常无需修改。
  • 外部函数引用(如call printf: 链接器必须解析printf在库中的实际地址,并修改调用它的jal指令中的偏移量字段。
  • 静态数据引用(如加载全局变量地址): 链接器必须确定该变量在最终数据段中的地址,并修改相应的lui/addi指令序列。

链接方式主要有两种:

  • 静态链接: 将库代码直接复制到最终的可执行文件中。优点是生成的文件自包含,不依赖运行环境;缺点是文件体积大,且库更新后需要重新链接所有程序。
  • 动态链接: 可执行文件中仅记录它需要哪些库。在程序被加载或运行时,操作系统才将所需的共享库(如.so.dll文件)映射到进程内存空间。优点是节省磁盘和内存空间,库升级方便;缺点是程序依赖运行环境,且初次运行有链接开销。

加载器(Load) 🚀

链接器为我们生成了可执行文件,最后一环是加载器。加载器是操作系统的一部分,它的职责是将可执行文件加载到内存中,并启动执行。

加载器的工作流程如下:

  1. 创建地址空间: 操作系统为程序创建一个独立的虚拟内存地址空间。
  2. 加载段: 将可执行文件的.text段(代码)和.data段(初始化数据)读入(或映射到)该地址空间的相应位置。
  3. 初始化堆栈: 设置堆栈指针(sp寄存器),并为main函数的参数(argc, argv)在堆栈上分配空间。
  4. 跳转到入口点: 将程序计数器(PC)设置为程序的入口地址(通常是_start或类似的启动例程),开始执行。启动例程会负责将命令行参数从堆栈设置到寄存器,然后调用用户的main函数。当main函数返回后,启动例程会发起系统调用结束进程。

总结 📚

本节课中我们一起学习了程序从C源代码到最终运行的完整旅程——“CALL”流程。

  • 编译将人类可读的C代码转换为汇编代码。
  • 汇编将汇编代码(含伪指令)转换为机器码目标文件,并生成符号表和重定位信息。
  • 链接将多个目标文件和库合并,解析所有符号引用,生成统一的可执行文件。
  • 加载由操作系统将可执行文件装入内存,初始化运行环境,并启动程序执行。

理解这个过程,有助于我们洞察软件底层的运作机制,为学习操作系统、编译原理等更深入的课程打下坚实基础。

CS 61C 第14讲:同步数字系统导论 🚀

在本节课中,我们将要学习同步数字系统的基础知识。我们将从硬件的最底层开始,了解电流、电压如何通过开关和晶体管形成逻辑门,并最终构建出能够执行计算的数字系统。我们会探讨时钟、信号、延迟等核心概念,并理解组合逻辑电路与状态元素(如寄存器)的区别。


概述 📋

所有公共软件存储库的21TB快照,主要用二维码编码,这非常迷人。位于挪威一座山中250米处,这个档案程序旨在通过存储和索引数以百万计的存储库,捕捉现代软件世界的一个有价值的横截面。

这个设计可保存一千年的档案,一张快照储存在一百八十多卷胶卷里。这很迷人。我希望你们都考虑一下自己的个人数据备份计划。我敢肯定你创造了很多东西:手机、电影、你所做的一切、你写的所有代码。

你是怎么备份的?无论你的计划是什么,你都需要有能力对任何移动设备的丢失做出反应。所以,要确保你关心的东西没有一个只存在于移动设备上。消失的瞬间或掉进水里,它就没了。你要确保它已经“沉入云中”。

当然,这里有一些隐私问题。人们知道你有私人照片,可能是健身或其他内容。这些照片你真的想保密。然而,它们进入云端的那一刻,就可能被黑客攻击,这是一个令人担忧的问题。

所以我很感激,但无论你做什么,确保你对失败有弹性。我们学到的一件事——计算机体系结构的大思想——是冗余。所以要确保数据在多个地方。当你想到谷歌、开源软件和备份时,请确保这一点。

我想我有四个不同的地方,我的数据试图同步到那里,以防我弄丢了我孩子的照片和所有东西。所以,这非常重要。

好啦,我们开始吧。女士们先生们,欢迎来到CS 61C。


同步数字系统导论 🔌

我们将看到一个名为“同步数字系统导论”的新讲座,也称为SDS。

如果摄像机能把我放大,我这里有一个小演示。这是我几分钟前编程的Micro:bit,我要给你一个小消息。你能看到这里的信息吗?我们准备好了。走。哦,让我看看,你能看到留言吗?好啦,上面写了什么?“CS 61C SDS”。

今天我们要学一个新题目。我想在那里给你一个小小的庆祝。所以是的。

我应该给你一个期中考试的最新情况。人们已经被询问过了,想知道期中考试的计划是什么。期中考试将在三周后举行,大约两到三周。

交易是这样的。我想我在第一节课上就说过了,但我会再说一遍。期中考试有两部分:

  • 会有一个带回家的、你在家完成的期中考试,未经监考。我们信任你,就像我们把项目托付给你一样。你要单独完成。这将是你的C编程和RISC-V部分。
  • 完成5个编程题。没有时间限制,只要能完成它。我们会给你所有的测试用例。所以你只要工作直到你通过所有测试用例,提交,你就完成了。你知道你在银行里得到了那些积分。
  • 然后你们要进行80分钟的现场考试。就像你在实验室里有90分钟的时间,但是从一个班转到另一个班需要十分钟,所以是80分钟的考试。我们会试着设计题目,让助教可以在20分钟内完成批改。

你会被要求回答问题,但重要的是,你没有学习表。所以这只是字面上的,你冷冰冰地走进来。我们会给你一份“绿表”,也就是我想在这一点上,它被扩展成了五页的东西。这是在网页上,所以这将交给你。所以所有关于二进制、十六进制、RISC-V转换的内容,绿表上都有。所有这些都将给你,所以你可以看着它说好,如果上面写着,我不需要记住它,否则你需要记住它。

你会被要求做什么?你会被要求做这些事。同步数字系统可以被要求做一切。到了考试的时候,这不是在编程方面,C和RISC-V在那边。剩下的,我们已经询问了您关于数字表示或基本转换的问题。你一定能做到。事实上,您需要能够从任何进制转换到任何进制。这不只是我教你的,我能教你什么,然后你如何从中学到其他东西。我教了你如何在二进制、十六进制和十进制之间转换,你应该可以推断如何从任何进制转换,就像三进制到五进制。你应该能做到的。所以练习一下,确保你知道那是为你而来的。

所以这两个基本的转换、浮点,我们还没有在编程方面测试过你。是的,我们还没有测试你。可能是RISC-V,这些指令是什么样子的,因为这不是在编程方面。这就像如何编码一些东西,但如果我能从指令片段,给汇编程序生成机器代码,或者把它汇编成片段,丽莎上周做的那些事。那是在笔试上。我说,SDS与浮点,所以所有那些。所以基本上,这是一个比61C以往任何时候都轻量级的期中考试,因为我们会相信你,单独解码,在没有计时器的情况下进行编程。就像一个真实的面试问题,你知道一些谷歌面试,说“嘿,写点代码”,你想花多少时间就花多少时间。这就是我们要给你的。然后我们就和你在一起了。最好知道剩下的东西是“冷”的,挺干净的。

我希望你们都能把它完成。我们都想有一个所有模特的王牌,所以我们希望你们都完成它。我希望你们所有人都能把这部分做得很好。提前告诉你,会发生什么。这就是我们现在正在做的事情,在后勤方面试图弄清楚。我们有750名学生,你什么时候能赶上那80分钟的时段?大约90分钟的时段,十分钟的过渡,所以这是一个80分钟的例子,你什么时候能来?所以有很多关于这一点的调查。我们在找房间,还有这些细节。你用实验室,用你的笔记本电脑,所有这些事情。你有笔记本电脑,在不插上电源的情况下可以工作80多分钟吗?还是必须插上电源?但我们必须有电源插排和所有这些东西,所以我们正在研究所有的细节。但物流显然仍有待解决,但计划已经准备好了。

所以给你一些东西,一些用C语言编程的东西,然后准备好你的无学习表,离开它走进来。我们给你一点点,你知道的,绿色床单和你的笔。好啦,有问题吗?这就是中期更新,希望你能用。这样做的目标是有一种新的方法,我们在61C中评估,这使得您不必担心编程方面的时间安排,但也要确保你知道这些关于非编程方面的问题。

是的,我们会给你草稿纸。所以我们会给你一些草稿纸,我们会收回来的。所以你可以写你需要的一切,然后我们从你那里拿回来,因为当我做那些小事情的时候,我也用草稿纸。问得好。还有什么关于期中考试的问题吗?

Ed会有所有的细节,当我们离约会越来越近的时候。太棒了。好吧,我们去开关。


什么是开关?💡

所以今天我们学习同步数字系统。什么是开关?所以大局,听我说,你以前见过这个。我要给你们看四五张幻灯片,你以前见过。这是老派的大图景。CS 61C是ISA就在中间。这个讲座是一个新模块的开始。

所以如果你之前很困惑,没关系的。全部重置,从字面上看,这是每个人的重置。我们现在在做什么?我们有点自上而下地接受了ISA。我认为丽莎结束了一天的工作,能够从任何应用程序,你显然可以编码并看到一直到RISC-V,一直到编译器、汇编器、链接器和加载器。所以你现在处于ISA的水平。那很酷。

现在我们来看看这张图片。我们要从最底层开始,一直到建立一个运行ISA的系统。从字面上看,你会从上到下拥有整个东西。当这种情况发生的时候,这是令人惊讶的一天。我们从这幅画的底部开始,我们要努力向上。

新的学校机器结构再次,你以前见过这个。我们在硬件描述或逻辑门的最低水平。再次,中间是ISA的照片。这是我们的抽象层,我们将在这里,在下部远离底部工作,一路向上。每次演讲,我将在前面讲座的基础上再接再厉。所以这一节我想是连续四节课,我们要建造我们的道路,然后我们要继续建设。你要建造的CPU级别越高,那真的很酷。

这是一堂很棒的课,这门课不是我发明的,我继承了这门课。太神奇了。我们继续添加丽莎,我和其他老师都加入了我们的小作品。但这是一门很棒的课。我很荣幸能教这些美妙的东西。我本科时最喜欢的课程之一,可能莉萨在本科时就选修了这门课。我在麻省理工学院上了他们的课。我们喜欢这东西,所以我们很高兴来到这里。

所以我们说的是同步数字系统。那是什么意思?这些话是什么意思?“同步”这个词就像心跳一样。就像我手指上的假心跳。但是一个系统中的时钟有规律地连续运行的想法,有频率或周期,就像心跳砰砰砰砰,说“做点什么”。做某事做某事做某事的意思是运行指令,增量,电脑,继续这样做,字面上保持处理数据。这就是我们的同步系统将以数字方式运行的东西。

说世界是模拟的,但我们要把东西转换成数字世界。我们讨论了如何做到这一点,通过抽样。我们看到如果你有一些数据,然后你量子化了。我们看到了这两个想法。电信号,我们要量子化成,不是伏特,但是1或0,所以我们有两个不同的值。我们将以抽象的方式把它们分组,形成单词,你马上就会看到。它很酷。

所以逻辑设计,顺便说一句,还有其他课程,如果你想了解更多关于这个的信息,CS 150是很好的课程,CS 151,有一些很棒的后续课程。事实上,如果你今天爱上了61C,苹果会很高兴见到你的。如果今天就像,“我的天啊,我做不到,这是最神奇的新材料”,苹果想雇你。有这么多人,他们在制造自己的硅,所以他们不能雇佣足够的人来制造自己的硅,他们想把一大群人。所以我们希望你能爱上这个。苹果,尤其是爱上这种材料,然后说“我想参加后续课程”,151然后是接下来的比赛。你知道的,工作在等着你,有很多钱。这真的很令人兴奋。

好的,所以我们将讨论如何从下到上再构建一个处理器,了解最低的最低水平是如何,电流是如何均匀流动的,电压是多少,然后一路向上。

为什么我们很关心硬件设计?当我们运行软件时,了解硬件是如何工作的实际上很重要。所以他们说“我是个软件人”,但了解硬件级别上发生了什么实际上很重要。事实上,这就是CS 61C的特别之处。假设你决定成为一名软件开发人员,你的余生都要知道它是如何运行的,会让你成为一个更好的程序员。当然,知道你将学会如何优化它,这个词你还没学会。缓存是下面东西的一部分,如何管理自己的内存,所有这些都能让你成为一个更好的程序员。它真的可以让你在那个水平上触摸硅,所以理解硬件是非常重要的。

你在想处理器,他们擅长什么,什么不?他们做得很好,他们什么时候会失速?其中一件事就像放慢速度。因为你不知道引擎盖下发生了什么,一旦你明白了,你避开那些时候,你试图优化时间,在那里你可以做事情非常快,而且非常有效。

还有更多的课程,CS 152也是我们150系列的CPU设计课程。伯克利是我们的硬件系列。你可以用标准处理器做很多事情。所以有一个全新的运动,为非常专业化的东西设计定制硬件,这很令人兴奋,这是一个奇妙的领域。


简单电路与开关 ⚙️

这是一个简单的电路,让我们一起看看这个并理解它。这是一个开关,现在是一个开关,它显示为打开的。这是灯泡或电阻元件,会有点光的。你会看到一些输出。这是一个电源。这是如何工作的?

当A为零时,那个开关是开着的。电流必须流动,如果你从早期的物理课上学到了基本的电学知识,电流必须流动才能发生事情。所以如果没有电流流过电阻元件或光,现在开关一关,灯就会熄灭。

有一个循环,当我们有一个循环,好事是会发生的。从你身上流出的电流,想想当前的电子从电源流过光。他们,这现在是一条直线,没关系。它看起来不像是,就像一个打开的开关,大门已经关上了。循环还在继续,灯亮着。

你能做到的。顺便说一下,我喜欢这个,你可以像今天一样用一块电池和一块锡纸,你和你从手电筒里拿一盏灯,你可以做一盏灯。它实际上是所有的动力源,你的电池,这是你的灯,就这样了。你只是把两者连接起来,做起来很容易,也很有趣。

所以A继续。事实上我有一件小事,但当我推它看看它在那里是否有效时,它就起作用了。当我拉它的时候,它爆炸了。所以把它推上,继续释放它,它爆炸了。所以事情是这样的。所以当它关闭电流时,如果我们称之为“断言”。这是一个有点奇怪的图表,因为它就像一个,当这像它要做的事情。这是我说的,就像一个,什么能做到这一点?这里现在是一个关闭的,现在有光。如果这真的是那张照片,意思是说,如果你打开开关,如果“取消断言”,然后电流停止流动。所以当A变成一个,灯亮了。就像一对一。这就是我通过三个等号所显示的。所以我推,让我看看,让我们关闭一个准备关闭一个,现在我发布了一个,现在开了,什么都没发生。

那是我的小照片。现在如果你有这样的想法,当A关闭时,电流可以流动,当A打开时,或者零电流不流动。让我们看看这个,如果我们有这条线,什么时候会有联系?图片左侧和右侧之间的电气连接,只在逻辑上,当A关闭时,并且B关闭。只有当它们都关闭时,空白是什么?或者他们都被断言,或者两个人都记得这个词。我们试着用所有这些词,所以你变得很流利。“断言”,意思是他们去一个。左边和右边之间有电气连接吗?如果你把电池放在灯里,就会产生联系。

所以这很有趣看看这个,只是把A和B放进去。这个词你以前听说过吗?“与”。它们都必须在那里才能让电流流过那条线。看下图,现在你把它们建在,现在你说呢,那是什么逻辑?那根电线的左边是什么时候连接的?在电线的右边,电流可以通过顶部或底部或两者兼而有之。如果两者都是,也没关系,它有点分裂,不过没关系,两者都有联系,电气,所以电流可以流动,电子可以流动。所以底部的图片是逻辑“或”。还是很酷。

所以我现在向你们展示了一个逻辑“与”和“或”,只是用电线。顺便说一句,你也可以用锡纸做这个,还有你的铝箔,然后你把这个,你也没事,就是这样。让我们用电线把它分开。在那里,哇,你今天就可以这么做,今天用锡纸。这是很有趣的问题,但要确保我不会失去任何人。因为一个新的话题,我想失去任何人,这是有趣的一个。


从艺术到科学:布尔代数与晶体管 🧮

现在有一个历史笔记。人们习惯了,在过去的日子里,构建电路,有点临时的,没有关于他们的规则,没有一本书说过只需要建立一些电路。他们中的一些人做到了。你在建筑圈的最佳实践是什么?非常粘稠的,这是一种做这件事的艺术,而不是一门做事的科学。他们意识到共同的模式,就像我发现了一个叫做“与”的东西,嘿嘿,你也发现了一个“与”,挺酷的嘛。

那么也许这个克劳德·香农有一些基本的东西,信息论之父之一,把晶体管和这个叫乔治的数学家联系起来,布尔在这里。所以我们称它为布尔代数。大写B怎么样,因为它是以某人的名字命名的。多么整洁,这意味着我们现在可以应用数学,多亏了布尔,我们可以把数学应用于电路设计。多么奇妙的联系,感谢香农和布尔对那些场晶体管的贡献。

晶体管的历史。晶体管诞生于1947年,圣诞节前两天。晶体管或半导体,这意味着它们有时会连接电力,但有时不要依赖其他非常强大的信号,你可以指挥也可以不指挥。我们将看到这是背后的基础。它们可以用来,不是开就是关,那就像一个开关。或者你也可以有一个电源,实际上可以放大。如果有人有模拟放大器或收音机,你在家里给扬声器供电,你可以,扬声器是这样供电的,有一个由放大器供电的。现在,你听到我在房间里说的了。我在说话,想把自己喊得上气不接下气,因为没有放大器。现在不是第二天发生的事了,但不久之后,他们意识到你可以用这个作为放大器,不只是一个或零,但实际上小信号,小波长,大信号。太神奇了。现在我可以为大喇叭和体育场供电。你在想你去过的每一场音乐会,你得感谢这些人。谁是乡亲?约翰·巴丁,威廉·肖克利和沃尔特·布拉顿。肖克利是最大的,我说人类历史上最大最糟糕的种族主义者,所以抬起头来,威廉·肖克利和阅读肖克利。仅供参考。

在那之前他们用的是真空管。你还记得真空管是他们如何编程的吗?早期的计算机,ENIAC和其他人。在那之后,我们现在有了微处理器。你有你的电脑。我是说电子产品的小型化,多亏了晶体管,这是一件令人惊奇的事情。这是第一个晶体管,这很神奇。经营这个PBS的艾拉·弗拉托,特别,称为晶体管化,他说晶体管可能是20世纪最重要的发明,相当强大。

现在我们可以设计模型现代数字系统,使用这些转换的数组,全部放在一起。这项技术被称为半导体上的金属氧化物,那是MOS。C代表互补,常开或常闭开关。我们将在下一两张幻灯片中看到这一点。它们被用作电压控制开关。是MOSFET晶体管。


MOSFET晶体管:电压控制开关 🔋

好吧,我们开始吧。我要花些时间在这张幻灯片上,所以你得到了一张照片。这是一张你可能以前见过的照片,但如果不是,你可能不知道它是如何工作的。让我们一起来理解。有三个端子:栅极(G)、漏极(D)和源极(S)。

如果你忘了顺序,我在这里给你助记符,丹·加西亚说,如果你还记得那个DG。就像所有的水流都想排到闸门里,控制大门的人,不管这些事情是否有联系。像你这样的线人,电压漏极和电源。真的是这样。如果现在栅极端子上的电压(电压总是两个引线之间的电压,在栅极和源极之间)高于或低于源电压,在源极和漏极之间,然后在漏极和源之间建立导电路径,就像一扇门。

好啦,这里是漏极,这里是源极,就像水源。我想这样流动,排水口在那里。我是门,它流动吗?它流动吗?还是不流动?我喜欢开关对吧,流还是不流。我如何控制那口井取决于我在这个门上有多少电压。

所以有N通道和P通道。N代表负,栅极呈阳性。但我喜欢记住的方式,它是N是“正常”。什么叫正常?这只是我对自己的记忆。好啦,我想它就像看,如果我不在这里加电压,什么都不应该发生。如果我给栅极加电,栅极才会有作用,门应该打开。所以只有当我给栅极通电时,门才会打开。我说电源在栅极上加一些电压,关门了吗?当它再次关闭时,那我们就开始行动。所以这有点像,你不需要力量来让事情发生,当它发生时,两者之间有联系。记住我不是在放大,我只是在做一个关闭的开关或打开的开关。所以我的意思是,在正常情况下,我必须施加这个电压,所以事情就是这样。

我们开始了。当G较低时,也就是栅极电压。当那很低的时候,他们长什么样?顺便说一下,这个P通道正好相反,真的很好玩。我们将在这个世界上看到很多二元性。二元性就像阴阳,在那里,事物以这种非常美丽的方式联系在一起。N和P就像对偶。像我这样的对偶在两者之间划清界限,就像有N个世界,P个世界是,就像奇怪的事情,就在这上面,然后是相反的世界。

所以N是

📚 课程 P19:RISC-V 过程、ISA 与调用约定

在本节课中,我们将学习程序编译与链接的完整流程,并深入探讨 RISC-V 指令的翻译过程。课程内容分为三个主要部分:编译器、汇编器与链接器的工作流程,RISC-V 指令的二进制翻译,以及函数调用约定。我们将从宏观的软件构建过程开始,逐步深入到具体的指令编码细节。

🛠️ 编译器、汇编器与链接器

上一节我们概述了课程内容,本节中我们来看看程序从源代码到可执行文件的完整构建流程。这个过程涉及多个工具,它们各司其职,协同工作。

一个典型的构建流程包含以下步骤:

  1. 编译器:将高级语言(如 C、C++)源代码转换为汇编语言。
  2. 汇编器:将汇编语言转换为目标文件。
  3. 链接器:将一个或多个目标文件及库文件合并,生成最终的可执行文件。
  4. 加载器:将可执行文件加载到内存中并运行。

以下是每个步骤的详细说明:

  • 编译器
    编译器接收高级语言代码,输出优化的汇编代码。其输出可能包含伪指令和相对寻址(例如跳转指令中的偏移量)。编译器的主要职责是生成高效的汇编代码,而非直接的机器码。

  • 汇编器
    汇编器接收汇编文件(.s.asm),生成目标文件(.o)。这是本部分的核心。目标文件包含多个关键部分:

    • 文本段:存放已翻译成二进制的机器指令。
    • 数据段:存放程序中的静态数据(如全局变量、字符串常量)。
    • 符号表:记录本文件中定义的所有标签(如函数名、循环标签)及其在文件中的位置(地址)。
    • 重定位表:记录那些引用了但尚未知位置的标签(例如,引用了其他文件中的函数)。这些“未解决的引用”将在链接阶段处理。
      汇编器的工作通常需要两趟扫描:第一趟建立符号表,第二趟利用符号表解析指令中的标签引用,并填充重定位信息。
  • 链接器
    链接器接收所有目标文件和库文件。它的工作包括:

    • 合并所有文件的文本段和数据段。
    • 解析所有目标文件的重定位表,将符号引用替换为最终的绝对内存地址。
    • 生成一个完整的可执行文件。链接器处理的是绝对寻址。
  • 加载器
    操作系统的一部分,负责将可执行文件载入内存,分配栈空间,初始化寄存器,并开始执行程序。

🔢 RISC-V 指令翻译

理解了程序如何被构建后,我们来看看构成程序基础的机器指令是如何表示的。本节我们将学习如何将人类可读的 RISC-V 汇编指令翻译成计算机执行的二进制码。

RISC-V 指令是固定长度的(32位),其格式根据类型不同,划分为不同的字段。主要指令类型包括 R型(寄存器-寄存器)、I型(立即数)、S型(存储)、B型(条件分支)、U型(长立即数)和 J型(跳转)。

以下是将汇编指令 addi x9, x0, -24 翻译为二进制的过程:

  1. 确定指令类型和字段addi 是 I 型指令。其字段构成如下:

    • imm[11:0]:12位立即数
    • rs1:5位源寄存器1
    • funct3:3位功能码
    • rd:5位目的寄存器
    • opcode:7位操作码
  2. 查找各字段值

    • opcodeaddi 的操作码是 0010011
    • funct3addi 的功能码是 000
    • rd:目的寄存器是 x9,对应二进制 01001
    • rs1:源寄存器是 x0,对应二进制 00000
    • imm:立即数是 -24。需要将其转换为12位二进制补码。
      • 先求24的二进制:0000 0001 1000
      • 按位取反:1111 1110 0111
      • 加1:1111 1110 1000。这就是 0xFE8
  3. 组合字段:按照 imm[11:0] | rs1 | funct3 | rd | opcode 的顺序组合。

    • 立即数 (-24): 1111 1110 1000
    • rs1 (x0): 00000
    • funct3: 000
    • rd (x9): 01001
    • opcode: 0010011
    • 最终二进制:1111111010000000000010010010011
    • 转换为十六进制:0xFE800493

反向操作(从二进制到汇编)的过程则是:先转换为二进制,根据最后7位 opcode 确定指令类型,再根据 funct3 等字段确定具体指令,最后解析出寄存器编号和立即数字段。

🤝 函数调用约定

掌握了指令的表示方法后,我们需要了解程序运行时,函数之间如何协作。这就是函数调用约定的作用,它定义了函数调用过程中寄存器如何使用、栈如何管理。

RISC-V 调用约定主要涉及以下几类寄存器:

  • a0-a7 (x10-x17):参数寄存器,用于传递前8个整数或指针参数。a0a1 也用于返回值。
  • ra (x1):返回地址寄存器,存放 jal 指令后的下一条指令地址。
  • sp (x2):栈指针寄存器,指向当前栈顶。
  • s0-s11 (x8-x9, x18-x27):保存寄存器,被调用函数必须保证它们的值在函数返回前后不变。
  • t0-t6 (x5-x7, x28-x31):临时寄存器,调用函数不能假设它们在函数调用后保持不变。

以下是函数调用和返回的基本步骤:

  • 调用函数

    1. 将参数放入 a0-a7
    2. 使用 jal ra, target 指令跳转到目标函数,同时将返回地址存入 ra
  • 被调用函数(序言)

    1. 如果函数会使用保存寄存器 (s0-s11),则将它们的旧值压入栈中保存。
    2. 调整栈指针 sp,为局部变量分配空间。
  • 被调用函数(正文与结尾)

    1. 执行函数主体逻辑。
    2. 将返回值(如果有)放入 a0(和 a1)。
    3. 恢复之前保存的寄存器值。
    4. 调整栈指针 sp,释放栈空间。
    5. 使用 jalr x0, 0(ra) 指令跳转回调用者。

关于栈,它不仅用于保存返回地址和寄存器,当函数内的局部变量或临时数据过多,寄存器不够用时,也会使用栈来存储这些数据。因此,栈的操作可能发生在函数内部的任何地方,而不仅仅是序言和结尾。

📝 总结

本节课中我们一起学习了软件构建的核心工具链:编译器负责生成优化汇编代码,汇编器负责生成包含符号和重定位信息的目标文件,链接器负责合并文件并解析地址生成可执行文件。我们还深入学习了 RISC-V 指令的二进制编码格式,并通过实例练习了汇编与二进制之间的翻译。最后,我们探讨了 RISC-V 的函数调用约定,理解了寄存器分类、栈帧管理以及函数调用与返回的规范流程。这些知识是理解程序底层运行机制和进行系统编程的基础。

课程 P2:数字表示法 🔢

在本节课中,我们将学习数字在计算机中是如何被表示的。我们将探讨模拟信号与数字信号的区别,理解比特(bit)如何成为信息的基本单位,并学习二进制、十进制和十六进制数制之间的转换。最后,我们将了解几种不同的整数编码方式,包括无符号数、原码、反码、补码和移码。

概述:从模拟世界到数字世界 🌍

现实世界本质上是模拟的。例如,声音的振幅或电压的变化是一条连续的曲线。为了在计算机中处理这些信息,我们需要将其转换为数字形式。这个过程主要分为两步:采样量化

采样是在固定时间间隔内测量模拟信号的值。量化则是将采样得到的连续数值,映射到一个具有有限个离散值的标尺上。

并非所有数字数据都来自现实世界的采样。计算机程序也可以直接生成数字信息,例如三维图形或文本。关键在于,比特可以代表任何事物

比特:信息的基本单位 ⚙️

一个比特(bit)是二进制数字(binary digit)的缩写,它只有两种状态:0 或 1,开或关。

核心概念N 个比特最多可以表示 2^N 种不同的事物。

例如,5个电灯开关(每个代表一个比特)有 2^5 = 32 种不同的设置,足以存储26个英文字母。为了存储大小写字母和标点符号,我们通常使用8个比特,即一个字节(byte),这对应着早期的ASCII编码标准。如今,更通用的Unicode标准(如UTF-8, UTF-16)可以表示全球各种语言的字符,甚至包括表情符号。

比特还可以表示逻辑值(真/假)、颜色、地址、命令,甚至情感——只要你能为其分配一个位模式。

数字与数码的区别 🔤

在深入数制之前,区分“数字”(number)和“数码”(digit)至关重要。

  • 数字是抽象的概念,是“数轴”上的一个点。
  • 数码是用于表示数字的符号。

例如,数字“四”是一个概念。但它可以用数码“4”(阿拉伯数字)、数码“IV”(罗马数字)或数码“100”(二进制数码)来表示。因此,我们有二进制数码、十进制数码,但没有“二进制数”这种说法——数本身是统一的,只是表示它的数码系统不同。

数制转换:二进制、十进制与十六进制 🔄

上一节我们明确了数码的概念,本节中我们来看看不同进制数码之间的转换。计算机科学中常用的进制有二进制(基数为2)、十进制(基数为10)和十六进制(基数为16)。

通用位置记数法

任何进制的数码都可以按位权展开。一个多位数码的值等于每位数码乘以该位基数的幂次之和。
通用公式为:
(d_n d_{n-1} ... d_1 d_0)_b = d_n * b^n + d_{n-1} * b^{n-1} + ... + d_1 * b^1 + d_0 * b^0
其中,b是基数,d是每位上的数码。

二进制、十六进制转十进制

直接应用上述公式计算即可。

  • 二进制示例(1101)_2 = 1*2^3 + 1*2^2 + 0*2^1 + 1*2^0 = 8 + 4 + 0 + 1 = (13)_10
  • 十六进制示例(A5)_16 = 10*16^1 + 5*16^0 = 160 + 5 = (165)_10
    在代码中,常用0b前缀表示二进制,0x前缀表示十六进制,如0b1101, 0xA5

十进制转二进制/十六进制(除基取余法)

这是一个通用的算法:用目标基数b不断去除十进制数,并记录余数,直到商为0。最后,将余数从后往前排列,即得到目标进制的数码。

形象理解(以转二进制为例):假设你要退回13个鸡蛋,但快递盒只有固定容量(8个、4个、2个、1个)。你希望用最少的盒子。你先看8格盒能否装下13?能,就用一个,剩下5个。再看4格盒能否装下5?能,用一个,剩下1个。2格盒装不下1,用0个。最后用1个1格盒。所以编码为1101

二进制与十六进制间的快速转换

这是最便捷的转换,无需经过十进制。
核心原理:一个十六进制数码恰好对应4个二进制比特(因为 2^4 = 16)。

以下是二进制、十进制、十六进制(0-15)的对应表,建议熟记:

十进制 二进制(4位) 十六进制
0 0000 0
1 0001 1
2 0010 2
3 0011 3
4 0100 4
5 0101 5
6 0110 6
7 0111 7
8 1000 8
9 1001 9
10 1010 A
11 1011 B
12 1100 C
13 1101 D
14 1110 E
15 1111 F

转换方法

  • 二进制 -> 十六进制:从向左,将二进制数每4位分成一组(最左边不足4位则补0),然后查表替换。
    11010111 -> 分组(1101)(0111) -> 查表得D7,即0xD7
  • 十六进制 -> 二进制:将每个十六进制数码直接展开为4位二进制。
    0xE3 -> E1110, 30011 -> 合并得11100011

十六进制表示非常紧凑,效率是二进制的4倍,常用于表示内存地址、颜色代码(如网页颜色#D0376F)等。

计算机中的整数编码 🧮

计算机的存储空间是有限的,这意味着我们只能用固定数量的比特来表示数字。这导致了“天下没有免费的午餐”——增加一种功能(如表示负数)往往需要牺牲另一种功能(如表示的范围)。

无符号编码

最简单的编码。所有比特都用于表示数值大小。

  • 范围:对于w比特,可表示 02^w - 1
  • 溢出:当计算结果超出这个范围时,会发生“环绕”,就像汽车里程表从99999变回00000。

原码

用最高位(最左边)作为符号位:0表示正,1表示负。其余位表示数值的绝对值。

  • 问题:存在+0(0000)和-0(1000)两个零。进行加减运算的电路设计复杂。

反码

正数的表示与原码相同。负数则是将其对应正数的所有比特取反(1变0,0变1)。

  • 问题:同样存在+0(0000)和-0(1111)两个零。

补码(现代计算机标准)

这是现代计算机表示有符号整数的标准方式。

  • 正数:表示与原码相同。
  • 负数:将其对应正数的所有比特取反后加1(即“取反加一”)。
  • 优点
    1. 唯一的零:0000
    2. 加减法运算电路可以统一,非常高效。
    3. 范围对称且连续:对于w比特,范围是 -2^{w-1}2^{w-1}-1。例如8位补码范围是-128到127。

求值公式:对于补码数码 (b_{w-1} b_{w-2} ... b_0),其值为:
-b_{w-1} * 2^{w-1} + Σ_{i=0}^{w-2} b_i * 2^i

移码

主要用于浮点数的指数部分。其思想是将所有数码表示的值整体平移一个固定的偏差

  • 方法移码表示的真值 = 无符号解释 - 偏差。通常偏差取 2^{w-1} - 1
  • 特点:移码表示中,所有比特为0时对应最小的负数,所有比特为1时对应最大的正数。它简化了浮点数比较大小的问题。

总结 📚

本节课中我们一起学习了数字在计算机中的表示方法。

  1. 我们理解了从模拟信号到数字信号的转换过程(采样与量化)。
  2. 我们掌握了比特作为信息基本单位的概念,知道N比特可表示2^N种事物。
  3. 我们区分了“数字”与“数码”,并熟练掌握了二进制、十进制和十六进制数码之间的相互转换。
  4. 我们探讨了五种整数编码方案:
    • 无符号编码:仅表示非负数。
    • 原码:直观但存在双零,运算不便。
    • 反码:过渡方案,仍有双零问题。
    • 补码:现代计算机标准,解决了双零问题,运算高效。
    • 移码:主要用于浮点数指数编码。

记住核心原则:天下没有免费的午餐。在有限的比特内,任何一种编码方案都是在范围、精度、运算复杂度等方面进行权衡的结果。补码因其卓越的平衡性成为了整数表示的事实标准。

课程 P20:组合逻辑 🔌

在本节课中,我们将学习组合逻辑的基本概念。组合逻辑是数字电路设计的基础,它描述了输入信号如何直接决定输出信号,而不涉及任何内部状态或记忆。我们将从真值表开始,逐步理解逻辑门、布尔代数以及如何简化逻辑表达式。


概述 📋

组合逻辑电路的特点是:其输出仅取决于当前的输入组合。这与我们上节课讨论的时序逻辑(包含状态元素)形成对比。本节我们将探讨如何用真值表描述逻辑功能,如何用逻辑门实现这些功能,以及如何使用布尔代数定律来简化和优化电路设计。


真值表与逻辑函数 📊

上一节我们介绍了数字电路的基本概念,本节中我们来看看如何用真值表精确描述一个逻辑函数。

一个具有 n 个输入的逻辑函数,其真值表有 2^n 行。每一行对应一种输入组合,并给出对应的输出值(0 或 1)。

例如,一个四输入(A, B, C, D)一输出(F)的逻辑函数,其真值表有 16 行。输出列 F 是一个 16 位的数字,每一位对应一种输入组合的输出。

核心概念:一个 n 输入逻辑函数的真值表,其输出列可以看作一个 2^n 位的二进制数。每一个可能的 2^n 位二进制数都对应一个唯一的逻辑函数。

输入组合 (A,B,C,D) | 输出 F
-------------------|-------
0 0 0 0            |   ?
0 0 0 1            |   ?
...                |  ...
1 1 1 1            |   ?


逻辑门:构建模块 🧱

了解了如何描述逻辑功能后,我们来看看实现这些功能的基本构建模块——逻辑门。

以下是几种基本逻辑门的符号、名称和功能描述:

  • 与门 (AND Gate):形状像字母 D 的平直部分。仅当所有输入都为 1 时,输出才为 1。
    • 公式:F = A · BF = AB
  • 或门 (OR Gate):形状像字母 D 的弯曲部分。只要有一个输入为 1,输出就为 1。
    • 公式:F = A + B
  • 非门 (NOT Gate):三角形末端带一个小圆圈。输出是输入的反相。
    • 公式:F = ¬AF = A'
  • 异或门 (XOR Gate):类似或门,但输入线在左侧停止,不穿过符号。当输入相异(一个为0,一个为1)时,输出为1。
    • 公式:F = A ⊕ B
  • 与非门 (NAND Gate):与门符号末端带一个小圆圈。仅当所有输入都为 1 时,输出才为 0。
    • 公式:F = ¬(A · B)
  • 或非门 (NOR Gate):或门符号末端带一个小圆圈。仅当所有输入都为 0 时,输出才为 1。
    • 公式:F = ¬(A + B)

注意:在电路图中,两条线交叉时,如果没有实心圆点,则表示它们不连接(如同立交桥)。只有画了实心圆点的地方才表示电气连接。


从真值表到电路图 🛠️

现在,我们将学习如何将一个真值表转化为由逻辑门组成的实际电路图。

一种系统性的方法是使用 规范形式。对于真值表中每一个输出为 1 的行,我们写出其对应的 最小项(即所有输入变量的“与”组合,原变量取1,反变量取0)。然后,将所有最小项进行“或”运算,就得到了该逻辑函数的规范布尔表达式。

例如,对于多数电路(三个输入,输出为多数值)的真值表,其规范形式为:
F = (¬A·B·C) + (A·¬B·C) + (A·B·¬C) + (A·B·C)

这个表达式可以直接用与门和或门来实现。然而,它通常不是最简形式。


布尔代数:简化工具 ✨

直接由规范形式得到的电路可能很复杂。本节我们引入布尔代数,它是简化逻辑表达式、优化电路设计的强大数学工具。

布尔代数使用变量(如 A, B, C)代表逻辑信号,运算符 “·” (与), “+”(或), “¬”(非) 进行运算。其许多定律与普通代数相似,但也有一些独特之处。

以下是关键的布尔代数定律(展示了其对偶性):

  • 恒等律
    • A · 1 = A
    • A + 0 = A
  • 零一律
    • A · 0 = 0
    • A + 1 = 1
  • 互补律
    • A · ¬A = 0
    • A + ¬A = 1
  • 幂等律
    • A · A = A
    • A + A = A
  • 交换律A · B = B · AA + 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
    • 记忆技巧:将整体取反“分配”到每个变量和运算符上,并把“与”换成“或”,“或”换成“与”。


应用:简化逻辑表达式 🔍

让我们运用布尔代数来简化之前多数电路的规范表达式:
F = AB'C + A'BC + ABC' + ABC

简化过程

  1. F = AB'C + A'BC + ABC' + ABC
  2. = AB'C + A'BC + AB(C' + C) // 对后两项提取公因子 AB
  3. = AB'C + A'BC + AB(1) // 应用互补律 C’+C=1
  4. = AB'C + A'BC + AB // 应用恒等律
  5. = A(B'C + B) + A'BC // 对第一项和第三项提取公因子 A
  6. = A((B'C + B)) + A'BC
  7. = A(B + C) + A'BC // 应用吸收律的变体 (B’C + B) = B + C
  8. = AB + AC + A'BC // 分配律展开 A(B+C)
  9. = AB + C(A + A'B) // 对后两项提取公因子 C
  10. = AB + C(A + B) // 再次应用吸收律变体 (A + A’B) = A + B
  11. = AB + AC + BC // 分配律展开 C(A+B),得到最终简化式

简化后的表达式 F = AB + AC + BC 比原始规范形式简洁得多,对应的电路也更简单、更高效。


总结 🎯

本节课中我们一起学习了组合逻辑的核心知识:

  1. 真值表是描述逻辑功能的精确工具,一个 n 输入函数对应一个 2^n 位的输出模式。
  2. 逻辑门(与、或、非、异或等)是实现逻辑功能的基本电路模块。
  3. 规范形式提供了一种从真值表系统推导出布尔表达式的方法。
  4. 布尔代数是一套用于分析和简化逻辑表达式的数学规则,其中德摩根定律等是进行电路变换的关键。
  5. 通过应用布尔代数定律,我们可以将复杂的逻辑表达式简化,从而设计出更小、更快、更省电的数字电路。

理解组合逻辑是设计更复杂的时序逻辑系统和整个处理器的基础。在下一讲中,我们将探讨如何将这些组合逻辑模块连接起来,构建出能够进行算术运算(如加法器)的更大规模电路。

课程 P21:第16讲 SDS状态与有限状态机 🧠

在本节课中,我们将学习数字系统中的状态概念,并深入探讨有限状态机(FSM)的工作原理。我们将从回顾布尔逻辑问题开始,然后逐步构建一个累加器电路,并最终理解如何用时序逻辑和寄存器来实现状态记忆。


回顾布尔逻辑问题 🔍

上一节我们介绍了组合逻辑,本节我们来看看之前留下的三个布尔逻辑问题,以巩固理解。

以下是三个问题的解答思路:

  1. 证明 (A AND (A OR B)) OR B 等于 B

    • 简单的方法是制作真值表。
    • 第二种方法是使用布尔代数进行化简:
      (A AND (A OR B)) OR B = (A AND A) OR (A AND B) OR B = A OR (A AND B) OR B
      应用吸收律:A OR (A AND B) = A, 因此表达式简化为 A OR B
      但注意,原表达式实际等价于 B。更严谨的推导需利用 A OR (A AND B) = A,并结合其他定律,最终可证明其等于 B
  2. 能否将两个两输入门级联,构成一个三输入门?

    • 对于 ANDORXOR 门,可以通过级联两个两输入门来实现三输入功能。例如:(A AND B) AND C 等价于三输入 AND。
    • 但对于 NAND 门,级联两个两输入 NAND (A NAND B) NAND C 并不能等价于一个三输入 NAND 门。可以通过对比真值表来验证。

  1. 能否仅使用 NAND 或 NOR 门构造其他逻辑门?
    • 答案是肯定的。NAND 和 NOR 被称为“通用逻辑门”。例如,将 NAND 门的两个输入连接在一起,就构成了一个反相器(NOT门)。再通过组合,可以构建出 AND、OR 等所有基本门电路。


从累加器模式到状态电路 ➕

在编程中,我们熟悉累加器模式,例如求和:S = S + X[i]。在硬件中实现此功能,需要能够“记住”当前和 S 的值,这就是状态

我们首先尝试一个直接反馈的想法:将加法器的输出直接接回其输入。但这行不通,原因有二:

  1. 无法控制何时读取下一个输入 X[i]
  2. 无法将累加和 S 初始化为零。

为了解决这个问题,我们需要引入能够存储状态的元件。


核心元件:D触发器与寄存器 💾

状态存储的基础是 D触发器

  • 符号与功能:一个 D 触发器有一个数据输入 D,一个时钟输入 CLK,和一个数据输出 Q。在时钟信号 CLK 的上升沿(从0变1的瞬间),它会采样输入 D 的值,并在短暂延迟后,将该值输出到 Q 并保持稳定,直到下一个时钟上升沿。
  • 时序参数(关键概念):
    • 建立时间 (Setup Time):在时钟上升沿之前,输入 D 必须保持稳定的最短时间。
    • 保持时间 (Hold Time):在时钟上升沿之后,输入 D 必须继续保持稳定的最短时间。
    • 时钟到Q延迟 (Clock-to-Q Delay):从时钟上升沿到输出 Q 稳定有效所需的时间。
  • 寄存器:一个多位宽的存储单元,由多个并行的 D 触发器构成,共用同一个时钟和复位信号。

公式/代码描述

行为描述:
在每一个 CLK 上升沿:
    Q <= D // 将 D 的值赋给 Q
时序约束:
    D 必须在时间窗口 [T_setup 之前, T_hold 之后] 内稳定。

构建正确的累加器电路 ⚙️

现在,我们使用一个寄存器来构建可工作的累加器。

电路结构

  1. 一个加法器,有两个输入:X[i]S[i-1]
  2. 一个寄存器,其输入 D 连接加法器的输出 S[i],其输出 Q 反馈回加法器作为 S[i-1]
  3. 寄存器由全局时钟 CLK 控制,并有一个复位信号 RST 用于在开始时将 S 清零。

工作原理

  1. 初始时刻,RST 有效,寄存器输出 S[-1] = 0
  2. 第一个时钟周期:输入 X[0]S[-1] (0) 相加,产生 S[0] = X[0]。在时钟上升沿,S[0] 被存入寄存器。
  3. 第二个时钟周期:寄存器输出变为 S[0],与 X[1] 相加,产生 S[1] = X[0] + X[1],并在下一个时钟沿存入。
  4. 如此反复,寄存器始终保存着之前的累加和,实现了状态记忆。


系统时序与最大时钟频率 ⏱️

电路能跑多快(时钟频率多高)受限于最长的信号路径延迟。

关键路径:寄存器A的时钟上升沿 → 寄存器A的Q端输出(T_cq)→ 经过组合逻辑电路(T_comb)→ 到达寄存器B的D端并满足其建立时间(T_setup)。
最大时钟频率 由该路径的总延迟决定。

最小时钟周期 T_min = T_cq + T_comb + T_setup
最大时钟频率 F_max = 1 / T_min

流水线技术:为了提升频率(吞吐量),可以在长组合逻辑路径中间插入寄存器,将其分割为多个较短的阶段。这样每个阶段处理时间变短,时钟可以更快。代价是单条数据通过整个电路的延迟会增加,但单位时间内处理的数据量(吞吐量)提高了。


有限状态机 🗺️

有限状态机是描述和设计时序逻辑系统的强大工具。

构成要素

  • 状态:系统所处的不同模式,如“初始”、“收到1个1”、“收到2个1”。
  • 转移:状态之间的切换,由输入条件触发。
  • 输出:在某个状态或发生某个转移时产生的信号。

示例:检测连续三个1

  • 状态:S0(初始/未连续收到1),S1(收到1个1),S2(收到连续2个1)。
  • 输入:位信号 in
  • 输出out,仅在连续收到第三个1时输出1。
  • 转移
    • 在S0状态:若 in=0,保持S0;若 in=1,进入S1。
    • 在S1状态:若 in=0,回到S0;若 in=1,进入S2。
    • 在S2状态:若 in=0,回到S0;若 in=1,输出1并回到S0。

硬件实现
FSM可以用我们刚学的结构实现:组合逻辑(根据当前状态和输入,计算下一个状态和输出)+ 状态寄存器(存储当前状态)。


总结 📚

本节课中我们一起学习了:

  1. 状态的概念:数字系统需要寄存器来记忆信息。
  2. D触发器与寄存器:存储状态的基本单元,及其关键的时序参数(建立时间、保持时间、时钟到Q延迟)。
  3. 时序电路设计:如何利用寄存器构建累加器等具有记忆功能的电路。
  4. 时序分析:决定了电路的最大工作时钟频率,并引入了流水线技术来提升性能。
  5. 有限状态机:一种描述复杂时序行为的模型,并可通过“组合逻辑+状态寄存器”的标准结构在硬件中实现。

这些概念是理解现代处理器和数字系统控制逻辑的基础。

课程 P22:Lecture 17:组合逻辑块 🧩

在本节课中,我们将要学习组合逻辑块的核心概念,特别是多路复用器和算术逻辑单元。我们将从回顾基本逻辑门开始,逐步构建更复杂的电路,并理解如何将它们组合起来实现强大的功能。


概述 📋

本节课我们将探讨组合逻辑块的设计与应用。我们将首先回顾如何从真值表构建逻辑电路,然后重点介绍多路复用器这一关键组件。接着,我们将学习如何构建一个简单的算术逻辑单元,并深入理解加法器和减法器的内部工作原理。最后,我们将讨论溢出检测的逻辑。


多路复用器:数据的选择器 🚦

上一节我们介绍了如何从真值表构建任意逻辑门。本节中我们来看看一个特殊的组合逻辑块:多路复用器。

多路复用器是一种数据选择器。它有几个数据输入、一个选择信号和一个输出。其功能是根据选择信号的值,决定哪一个输入信号可以驱动输出。

二选一多路复用器

一个最简单的多路复用器是二选一多路复用器。它有两个数据输入 AB,一个选择信号 S,以及一个输出 C

其工作规则如下:

  • S = 0 时,输出 C 等于输入 A
  • S = 1 时,输出 C 等于输入 B

我们可以用以下逻辑公式来描述:

C = (¬S ∧ A) ∨ (S ∧ B)

以下是其真值表:

S A B C
0 0 0 0
0 0 1 0
0 1 0 1
0 1 1 1
1 0 0 0
1 0 1 1
1 1 0 0
1 1 1 1

构建这个电路需要四个逻辑门(两个非门、两个与门和一个或门)。

构建更宽的多路复用器

多路复用器可以处理多位宽的数据。一个“n位宽的二选一多路复用器”意味着 ABC 都是 n 位信号。其内部是 n 个并行的、相同的一位二选一多路复用器,所有单元共享同一个选择信号 S

四选一多路复用器

如果需要从四个输入(A, B, C, D)中选择一个,我们就需要一个四选一多路复用器。此时,选择信号需要两位(S1, S0)来编码四种选择(00, 01, 10, 11)。

构建四选一多路复用器有两种方法:

  1. 直接法:根据所有输入(6个:S1, S0, A, B, C, D)写出庞大的真值表(2^6 = 64行),然后推导逻辑表达式。这种方法不实用。
  2. 级联法:使用三个二选一多路复用器分层构建。这是一种更优雅和高效的方法。

以下是级联法的思路:

  • 第一层:用 S0 选择 AB 作为胜者;用另一个 S0 选择 CD 作为胜者。
  • 第二层:用 S1 从第一层的两个胜者中选出最终输出。

硬件会并行计算所有路径的值,但只有被选中的路径信号能最终驱动输出。


算术逻辑单元 🧮

上一节我们学会了如何用多路复用器选择数据。本节中我们来看看如何用多路复用器构建一个能执行多种算术与逻辑运算的单元——算术逻辑单元。

ALU 是一个通用的计算盒子,它接收两个操作数 AB,以及一个操作选择信号 S。根据 S 的值,ALU 对 AB 执行不同的运算(如加、减、与、或等),并将结果输出。

一个简单的 ALU 设计

假设我们要构建一个支持四种操作的简单 ALU:加法、减法、按位与、按位或。我们需要两位选择信号 S1 S0

其设计思路如下:

  1. 并行放置四个功能单元:加法器、减法器、与门、或门。它们同时接收 AB
  2. 用一个四选一多路复用器,根据 S1 S0 的值,选择其中一个功能单元的结果作为最终输出。

例如:

  • S1 S0 = 00:选择加法器结果。
  • S1 S0 = 01:选择减法器结果。
  • S1 S0 = 10:选择按位与结果。
  • S1 S0 = 11:选择按位或结果。

在这个设计中,所有功能单元都在持续工作并产生结果,但只有被多路复用器选中的结果会被传递出去。


加法器与减法器的构建 ➕➖

上一节我们看到了 ALU 的顶层结构。本节中我们深入其核心,看看如何构建加法器和减法器。

一位加法器

我们从最简单的单位开始:一位加法器(全加器)。它接收三个一位输入:AB 和来自低位的进位 Cin;输出两个一位:和 S 以及向高位的进位 Cout

其真值表如下(A + B + Cin = {Cout, S}):

A B Cin Cout S
0 0 0 0 0
0 0 1 0 1
0 1 0 0 1
0 1 1 1 0
1 0 0 0 1
1 0 1 1 0
1 1 0 1 0
1 1 1 1 1

观察真值表,我们可以得到优美的逻辑表达式:

  • SABCin 的异或:S = A ⊕ B ⊕ Cin。这本质上是将三个数相加后取最低位(奇偶性)。
  • 进位 CoutABCin 的多数表决:当三个输入中至少有两个为1时,进位为1。Cout = (A ∧ B) ∨ (A ∧ Cin) ∨ (B ∧ Cin)

多位加法器与行波进位

要构建一个 n 位加法器,我们可以将 n 个一位加法器串联起来。低位加法器的 Cout 连接到相邻高位加法器的 Cin

这种结构称为“行波进位加法器”。其缺点是延迟较长:最高位的计算必须等待进位从最低位像波浪一样依次传递上来。最坏情况下的延迟与位数 n 成正比。存在更快的设计(如超前进位加法器),可以提前计算进位以减少延迟。

溢出检测

加法结果可能超出输出位数所能表示的范围,即发生溢出。溢出的判断方式取决于我们对数字的解释(无符号数 vs 有符号补码)。

  • 无符号数溢出:只需检查最高位加法器产生的进位 Cout。如果 Cout = 1,则发生溢出。
  • 有符号数(补码)溢出:规则更复杂。观察最高位加法器的进位输入 Cin 和进位输出 Cout。当 CinCout 不相等时(即一个为0,一个为1),发生有符号溢出。这对应了两种错误情况:两个正数相加得负数,或两个负数相加得正数。

构建减法器

减法可以通过加法来实现。在二进制补码系统中,A - B 等价于 A + (-B),而 -B 等于 按位取反 B 再加 1

因此,一个聪明的减法器设计如下:

  1. 使用一个条件取反器(例如,通过异或门实现):当减法控制信号 Sub 为1时,将 B 的每一位取反;当 Sub 为0时,B 保持不变。
  2. 同时,将减法控制信号 Sub 作为进位 Cin 输入到加法器。
    这样,当执行减法时(Sub=1),我们实际上计算的是 A + (~B) + 1,即 A - B。当执行加法时(Sub=0),我们计算的是 A + B + 0


总结 🎯

本节课中我们一起学习了组合逻辑块的核心知识。

我们首先回顾了从真值表构建逻辑电路的方法。然后,我们深入探讨了多路复用器,它像一个交通指挥员,根据选择信号决定哪个输入数据可以通过。我们学习了如何构建二选一和四选一多路复用器,并理解了级联构建的方法。

接着,我们将多路复用器应用于构建算术逻辑单元。ALU 利用多路复用器来选择不同的运算结果,实现了在一个通用单元内完成加、减、与、或等多种操作。

最后,我们剖析了 ALU 的核心计算部件:加法器减法器。我们学习了一位全加器的设计(利用异或和多数表决电路),了解了多位行波进位加法器的原理及其延迟问题,讨论了无符号数和有符号补码的溢出检测逻辑,并掌握了一个非常巧妙的技巧:通过条件取反和加1,用加法器来实现减法运算。

现在,我们已经拥有了构建复杂数字系统(如 CPU 数据通路)所需的基本模块。在接下来的课程中,我们将学习如何将这些模块组合起来。

课程 P23:同步数字系统与逻辑电路基础 🧠

在本节课中,我们将学习同步数字系统的基本概念、布尔逻辑化简以及时序分析。课程内容将涵盖逻辑代数、SDS核心组件以及如何分析电路中的信号传播与时钟约束。


布尔逻辑化简 🔍

上一节我们介绍了课程概述,本节中我们来看看布尔逻辑化简。布尔代数是数字电路设计的基础,核心操作包括与(AND)、或(OR)、非(NOT)以及它们的组合,如异或(XOR)。

德摩根定律是解决许多逻辑化简问题的关键。其公式如下:

  • NOT (A AND B) = (NOT A) OR (NOT B)
  • NOT (A OR B) = (NOT A) AND (NOT B)

以下是证明等式 NOT (A OR (A AND B)) = NOT A 的步骤:

  1. 从等式左边开始:NOT (A OR (A AND B))
  2. 首先,将 (A AND B) 视为一个整体。根据德摩根定律,NOT (A OR X) 可以转换为 (NOT A) AND (NOT X)。这里 X = (A AND B)
  3. 应用定律后,表达式变为:(NOT A) AND (NOT (A AND B))
  4. 对新的内层项 NOT (A AND B) 再次应用德摩根定律,将其转换为 (NOT A) OR (NOT B)
  5. 现在表达式是:(NOT A) AND ((NOT A) OR (NOT B))
  6. 注意到 (NOT A) 是公共项。利用分配律,可以将其提取出来:(NOT A) AND ( (NOT A) OR (NOT B) )
  7. 根据布尔代数吸收律或通过真值表分析,A AND (A OR B) = A。因此,(NOT A) AND ((NOT A) OR (NOT B)) 简化为 (NOT A)
  8. 至此,左边 NOT (A OR (A AND B)) 被证明等于右边 NOT A

另一种思路是考虑逻辑含义:原表达式意味着“A为假,或者(A为真且B为真)”。如果A为真,则整个括号内为真,取反后为假,即 NOT A 为假。如果A为假,则无论B如何,NOT A 为真。因此两者等价。


同步数字系统核心概念 ⏱️

理解了逻辑化简后,我们进入同步数字系统的核心。SDS 使用时钟信号来协调电路中各部分的动作,这对于构建复杂、稳定的数字系统(如CPU)至关重要。

系统主要包含两类元件:

  1. 组合逻辑元件:如与门、或门、非门。其输出立即响应输入变化,不依赖于时钟。
    // 例如:一个与门
    output = input_a & input_b;
    
  2. 状态元件:如寄存器(Register)。其输出仅在时钟信号的特定边沿(如上升沿)才根据输入更新,具有记忆功能。
    // 例如:一个上升沿触发的寄存器
    always @(posedge clk) begin
        q <= d;
    end
    

一个典型的SDS数据路径遵循“寄存器-组合逻辑-寄存器”的模式:

  • 输入由第一个寄存器在时钟边沿采样并稳定。
  • 采样值经过组合逻辑电路处理。
  • 处理结果在下一个时钟边沿被第二个寄存器捕获。

时序参数与约束 📊

要保证上述流程可靠工作,必须理解并满足时序约束。所有时序都以时钟上升沿为参考点。

以下是关键的时序参数定义:

  • 时钟周期:相邻两个上升沿之间的时间。
  • T_clk-to-q:时钟上升沿后,寄存器输出稳定有效所需的时间。
  • T_setup:在时钟上升沿之前,寄存器输入信号必须保持稳定的最短时间。
  • T_hold:在时钟上升沿之后,寄存器输入信号必须继续保持稳定的最短时间。
  • T_prop:信号通过组合逻辑传播所需的时间。

由此可以推导出两个核心约束公式,它们决定了电路能正常工作的时钟频率:

  1. 最小时钟周期约束:时钟周期必须大于最长的路径延迟,以确保信号有足够时间传播。
    T_clk > T_clk-to-q + T_prop_max + T_setup
  2. 保持时间约束:最短路径的延迟必须足够长,以防止新数据过早到达干扰当前数据。
    T_hold < T_clk-to-q + T_prop_min

其中,T_prop_max 是寄存器间所有路径中最长的组合逻辑延迟,它决定了电路的最高速度(最小时钟周期)。T_prop_min最短的组合逻辑延迟,用于检查保持时间是否被违反。


时序分析实例 🛠️

现在,我们应用这些概念分析一个具体电路。假设已知:

  • 时钟周期 T_clk = 8 ps
  • 寄存器时钟到Q延迟 T_clk-to-q = 2 ps
  • 寄存器建立时间 T_setup = 4 ps
  • 寄存器保持时间 T_hold = 2 ps
  • 反相器(非门)的传播延迟 T_inv = 1 ps

分析信号从输入 A,经过寄存器 R1、反相器,到寄存器 R2 的传播过程:

  1. 初始状态:在第一个时钟上升沿,R1 采样输入 A。由于 T_clk-to-q 延迟,R1 的输出在时钟沿后 2 ps 才更新。
  2. 组合逻辑延迟R1 的新输出经过反相器,产生 1 ps 的延迟。
  3. 到达 R2:信号在时钟沿后 2 ps + 1 ps = 3 ps 到达 R2 的输入。
  4. 检查建立时间R2 的下一个时钟上升沿在 8 ps 处。信号在 3 ps 时已稳定,距离下一个时钟沿有 8 - 3 = 5 ps,大于要求的 T_setup (4 ps),因此建立时间满足
  5. 检查保持时间R2 在当前时钟沿后需要输入保持 T_hold (2 ps) 稳定。新信号在 3 ps 时才到达,这已经超过了 2 ps 的保持时间窗口,因此保持时间也满足(旧数据在要求的时间内未受新数据干扰)。

通过这个例子,我们可以看到如何利用时序参数来验证电路在给定时钟下的工作可靠性。


常见问题澄清 ❓

以下是关于性能与路径延迟的几个关键点澄清:

  1. 逻辑化简影响性能吗?
    是的。化简布尔表达式可以减少实现所需的逻辑门数量或改变其层级结构,从而可能减少关键路径的 T_prop_max,提高电路可运行的最高时钟频率。

  2. 逻辑门越少,电路一定越快吗?
    不一定。速度取决于关键路径的延迟。即使总门数少,但如果它们串联成一条很长的路径,延迟可能很大。反之,门数多但路径并行,关键路径延迟可能更短。

  3. 最短路径决定时钟频率吗?
    不。电路的最高时钟频率(最小时钟周期)由寄存器之间的最长组合逻辑路径延迟决定。最短路径延迟主要用于验证是否违反保持时间约束。


本节课中我们一起学习了布尔逻辑的化简技巧,深入探讨了同步数字系统的基本原理,包括状态元件与组合逻辑元件的区别,并掌握了时序分析的核心参数与约束公式。通过实例分析,我们了解了如何应用这些知识来评估电路的正确性与性能。这些基础是后续设计更复杂数字系统(如数据路径和CPU)的基石。

课程 P24:第18讲 - RISC-V 单周期数据通路 I 🧠

在本节课中,我们将开始构建一个CPU。我们将把过去几周学到的关于电路、门和延迟的概念整合起来,从一个小模块——数据通路——开始构建。本节课和下一节课将专注于理解并设计一个能够执行RISC-V ISA指令的简单单周期处理器。


概述:从抽象到实现

在计算机系统的抽象层次中,我们正从底层的逻辑门向上构建,目标是到达指令集架构(ISA)层。我们之前从高级语言开始向下探索,现在我们将从底层硬件向上构建,最终在ISA层汇合。

处理器可以看作由两个主要部分组成:数据通路控制单元。数据通路包含执行实际操作(如算术运算)的硬件(如寄存器、ALU),而控制单元则是“大脑”,它告诉数据通路该执行什么操作。


核心构建模块回顾 🔧

在开始连接数据通路之前,我们先回顾几个将要用到的基本逻辑模块。我们将以框图的形式来思考它们,而不是具体的门电路。

多路复用器 (Mux)

多路复用器根据一个选择信号,在两个输入之间选择一个作为输出。

  • 功能:在输入A和B之间选择。
  • 控制:选择信号 S
  • 行为
    • 如果 S = 1,则输出 C = A
    • 如果 S = 0,则输出 C = B

加法器 (Adder)

加法器将两个n位数值相加。

  • 功能:计算 A + B
  • 输入:两个n位值 AB
  • 输出:n位和 S,可能还有进位信号。

算术逻辑单元 (ALU)

ALU可以执行多种算术和逻辑操作,具体操作由一个选择信号决定。

  • 功能:对输入 AB 执行指定操作(如加、减、异或)。
  • 控制ALUop 选择信号,决定执行哪种操作。
  • 输出:操作结果 Result

状态元素:寄存器与存储器

CPU将组合逻辑模块(如Mux、ALU)与状态元素(如寄存器、存储器)结合起来。状态元素在时钟的控制下存储信息。

  • 同步系统:在时钟的每个上升沿,状态元素可能会更新。在其他时间,它们输出当前存储的值。
  • 关键概念:组合逻辑块的输出会立即传递,但状态元素只在时钟上升沿“捕获”输入值进行更新。


处理器中的状态元素 🗃️

在我们的CPU执行过程中,每个时钟周期可能会更新以下几个状态元素。

程序计数器 (PC)

PC是一个存储当前指令地址的寄存器。

  • 行为:在时钟上升沿且写使能有效时,用输入数据更新其输出。其他时间,它持续输出当前地址。

寄存器文件 (Register File)

寄存器文件是一组寄存器(如RISC-V的32个寄存器)的集合。

  • 读取:随时可以读取 rs1rs2 指定寄存器的值。
  • 写入:在时钟上升沿且写使能 (RegWEn) 有效时,将数据 dataW 写入 rd 指定的寄存器。

存储器 (Memory)

我们将存储器视为一个“黑盒”。为了简化设计,我们将其分为两个部分:

  • 指令存储器 (IMem):只读,存储所有指令。
  • 数据存储器 (DMem):可读写,存储程序数据。

  • 读取:给定地址 addr,输出该地址的数据 readData
  • 写入:在时钟上升沿且写使能 (MemRW) 有效时,将数据 dataW 写入地址 addr


单周期处理器执行阶段 🔄

将所有功能一次性连接起来非常复杂。因此,我们将指令执行过程分解为几个概念阶段,这使设计、调试和优化变得更容易。

lw (加载字) 指令为例,其执行阶段可概括为:

  1. 取指 (Fetch):根据PC值从指令存储器读取指令。
  2. 译码 (Decode):解析指令,确定操作类型和所需的寄存器。
  3. 执行 (Execute):在ALU中计算内存地址(例如,寄存器值+立即数)。
  4. 访存 (Memory Access):从数据存储器读取数据。
  5. 写回 (Write Back):将读出的数据写回目标寄存器。

同时,PC会更新为下一条指令的地址(PC+4)。所有这些操作都在一个时钟周期内完成,因此称为“单周期”处理器。控制单元负责协调每个阶段数据通路需要完成的工作。


构建第一个简单数据通路 🧱

让我们从最简单的指令开始,逐步构建数据通路。

示例1:仅支持 add 指令的处理器

假设我们的ISA只有一条指令:add rd, rs1, rs2

我们需要更新的状态:

  1. PC: PC = PC + 4
  2. 寄存器文件:目标寄存器 rd 的值更新为 rs1 + rs2

以下是该数据通路的关键连接步骤:

  • PC更新:PC的输出连接到“加4”模块,其结果反馈回PC的输入,以便在下一个时钟沿更新。
  • 取指与译码:PC值也作为地址输入指令存储器(IMem),读取32位指令。指令的特定字段(位[11:7]为rd,[19:15]为rs1,[24:20]为rs2)被拆分出来,连接到寄存器文件。
  • 执行:寄存器文件输出的 data1 (rs1值) 和 data2 (rs2值) 送入加法器。
  • 写回:加法器的结果连接到寄存器文件的写入数据端 dataW。控制单元需产生寄存器写使能信号 RegWEn

通过以上连接,我们得到了一个只能做加法的处理器。

示例2:支持 addsub 指令

addsub 指令格式相似,主要区别在于 funct7 字段(指令位[30])的值不同。我们需要将加法器替换为功能更丰富的ALU。

数据通路的变化:

  • 将加法器块替换为ALU块。
  • ALU需要接收一个 ALUop 选择信号来决定做加法还是减法。
  • 控制单元会检查指令的 funct7 字段(位30),并据此设置 ALUop 信号(例如,0为加,1为减)。

控制与数据通路的类比:可以将数据通路想象成国际空间站(ISS),它能力强大但需要指导;控制单元就像地面任务控制中心,它分析情况(指令),然后向数据通路发送精确的命令(控制信号),告诉它具体做什么操作。

示例3:支持 addi 指令

addi (立即数加法) 是I-type指令,格式与R-type不同。它将寄存器 rs1 的值与一个12位立即数相加。

数据通路需要的新改动:

  • 立即数生成:需要一个模块从指令的特定位(如位[31:20])提取并符号扩展为32位立即数。
  • ALU输入选择:ALU的第二个输入不再总是来自 rs2,对于 addi,它应来自立即数。因此,需要在 data2(来自寄存器文件)和“立即数”之间添加一个多路复用器。
  • 新增控制信号:控制单元需产生一个新的信号(如 BSelect)来控制上述多路复用器,选择立即数作为ALU输入。

立即数生成模块的直觉:为了从指令的12位字段生成32位立即数,需要将高位进行符号扩展。即,将12位立即数的最高位(符号位)复制,填充到32位立即数的高20位。


总结 📚

本节课我们一起学习了构建RISC-V单周期处理器的初步知识。

  • 我们理解了CPU由数据通路(执行部件)和控制单元(指挥部件)构成。
  • 我们回顾了多路复用器、加法器、ALU、寄存器、存储器等核心硬件模块的抽象功能。
  • 我们引入了将指令执行分为多个阶段(取指、译码、执行、访存、写回)的概念,这简化了设计。
  • 我们通过三个渐进示例,动手构建了数据通路:
    1. 仅支持 add 指令的简单通路。
    2. 通过引入ALU和控制,扩展支持 addsub 指令。
    3. 通过添加立即数生成器和输入选择多路复用器,进一步支持 addi 指令。

我们看到了控制信号(如 RegWEnALUopBSelect)如何根据不同的指令,引导数据在通路中正确流动并执行相应操作。下一讲我们将继续完善数据通路,加入对分支、跳转和存储器访问指令的支持。

🧠 课程 P25:第19讲 - RISC-V 单周期数据通路 II

在本节课中,我们将继续学习如何构建一个RISC-V单周期CPU的数据通路。我们将重点关注如何实现加载(Load)、存储(Store)、分支(Branch)和跳转(Jump)等指令,并理解即时数生成(Immediate Generation)模块的工作原理。


📊 数据通路现状回顾

上一节我们介绍了加法(ADD)、减法(SUB)和立即数加法(ADDI)指令的数据通路实现。本节中,我们来看看如何将内存访问和程序控制流指令集成到这个框架中。

目前,我们的数据通路已经包含了程序计数器(PC)、指令存储器、寄存器文件、算术逻辑单元(ALU)以及一个控制单元。然而,我们还没有将数据存储器(Data Memory)连接到系统中。

数据通路的工作流程如下:

  1. PC 存储当前指令地址。
  2. 指令存储器根据PC的地址读取指令。
  3. 指令被解码,控制单元产生相应的控制信号。
  4. 寄存器文件根据指令中的 rs1rs2 字段读取数据。
  5. B选择多路复用器(MUX) 决定ALU的第二个操作数是来自寄存器 rs2 还是来自即时数生成模块。
  6. ALU执行计算。
  7. 结果可能被写回寄存器文件(对于算术指令)。

整个操作是边沿触发的。在时钟上升沿,寄存器文件会根据 RegWrite 控制信号决定是否将数据写入目标寄存器 rd


🧳 实现加载指令(LW)

加载指令(如 lw rd, offset(rs1))用于从内存中读取一个字并存入寄存器。它使用 I-type 格式。

以下是加载指令需要执行的操作:

  1. 计算内存地址:address = Reg[rs1] + sign-extend(offset)
  2. 从该地址读取内存数据。
  3. 将读取到的数据写入目标寄存器 rd
  4. 将PC更新为下一条指令地址(PC+4)。

为了实现加载,我们需要在数据通路中添加两个新组件:

  • 数据存储器(D-Mem):用于读写数据。
  • 写回选择多路复用器(WB Select MUX):用于选择写回寄存器文件的数据是来自ALU的输出还是来自数据存储器的输出。

控制信号的变化:

  • B选择(B-sel):设置为 1,选择即时数作为ALU的第二个操作数。
  • ALU操作(ALU-op):设置为 add,用于计算地址。
  • 内存读/写(MemRW):设置为 read
  • 写回选择(WB-sel):设置为 1,选择从数据存储器读取的数据写回寄存器。
  • 寄存器写使能(RegWrite):设置为 1,允许写回寄存器文件。

注意:RISC-V还有加载字节(LB)、加载半字(LH)等指令。它们的基本流程与 LW 相同,但在从内存读取完整字后,需要额外的逻辑来提取、符号扩展或零扩展相应的字节或半字。这通常通过额外的多路复用器和逻辑门实现。


📤 实现存储指令(SW)

存储指令(如 sw rs2, offset(rs1))用于将寄存器的值存入内存。它使用 S-type 格式。

以下是存储指令需要执行的操作:

  1. 计算内存地址:address = Reg[rs1] + sign-extend(offset)
  2. 将寄存器 rs2 的值写入计算出的内存地址。
  3. 将PC更新为下一条指令地址(PC+4)。

与加载指令的关键区别在于:

  • 需要同时读取 rs1(基地址)和 rs2(要存储的数据)两个寄存器。
  • 向数据存储器执行操作。
  • 不更新寄存器文件。

控制信号的变化:

  • 即时数选择(Imm-sel):设置为 S,表示S-type即时数格式。
  • B选择(B-sel):设置为 1,选择即时数。
  • ALU操作(ALU-op):设置为 add,用于计算地址。
  • 内存读/写(MemRW):设置为 write
  • 寄存器写使能(RegWrite):设置为 0,因为不写回寄存器。

对于存储指令,写回选择(WB-sel) 信号是“无关项”(don‘t care)。因为 RegWrite0,无论WB-sel选择什么,都不会对寄存器文件产生实际影响。


🚦 实现分支指令(BEQ, BNE, BLT等)

分支指令(如 beq rs1, rs2, offset)用于实现条件跳转。它使用 B-type 格式。

分支指令需要执行的操作:

  1. 比较寄存器 rs1rs2 的值。
  2. 根据比较结果和分支类型(如相等、不相等、小于等),决定是否跳转。
  3. 如果跳转(分支被采纳),则 PC = PC + sign-extend(offset)
  4. 如果不跳转(分支未被采纳),则 PC = PC + 4

这引入了新的挑战:我们需要在同一个周期内进行比较计算两个可能的目标地址(PC+4 和 PC+offset)。

解决方案是引入一个分支比较器(Branch Comparator) 模块和额外的地址计算硬件。

分支比较器模块

  • 输入rs1rs2 的数据值,以及一个控制信号 BrUn(表示是无符号比较)。
  • 输出:两个标志位 BrEq(相等)和 BrLt(小于)。
  • 控制逻辑根据指令类型(如 BEQ, BLT, BGE)和比较器输出的 BrEqBrLt 信号,生成最终的 PC-sel 信号,以选择下一个PC值是 PC+4 还是 PC+offset

地址计算

  • PC+4 由已有的加法器计算。
  • PC+offset 由ALU计算。此时,通过 A选择(A-sel) 多路复用器选择PC(而不是rs1)作为ALU的第一个操作数,通过 B选择(B-sel) 选择即时数作为第二个操作数,然后执行加法。


🧩 即时数生成模块详解

不同的指令格式(I-type, S-type, B-type, J-type, U-type)其即时数在32位指令字中的位置和拼接方式都不同。即时数生成模块的作用就是根据即时数选择(Imm-sel) 控制信号,从指令中提取正确的位,并进行符号扩展,生成一个统一的32位即时数。

以下是核心的拼接逻辑(假设指令位从高到低编号为31到0):

  • 公共部分:所有类型的即时数,其最高位(指令位31)都用于符号扩展。
  • I-type (e.g., ADDI, LW)
    • 即时数位[11:0] = 指令位[31:20]
  • S-type (e.g., SW)
    • 即时数位[11:5] = 指令位[31:25]
    • 即时数位[4:0] = 指令位[11:7]
  • B-type (e.g., BEQ)
    • 即时数位[12, 10:5, 4:1, 11] = 指令位[31, 30:25, 11:8, 7]
    • 注意:B-type即时数的最低位总是0(因为地址按半字对齐),这与S-type不同。
  • J-type (e.g., JAL)U-type (e.g., LUI, AUIPC) 也有各自独特的位拼接模式。

在硬件上,这个模块由一系列将指令特定位连接到输出即时数特定位的连线,以及根据 Imm-sel 信号选择不同输入源的多路复用器构成。


🦘 实现跳转指令(JAL, JALR)

跳转指令用于无条件改变程序流。

1. JAL (Jump and Link) - J-type

  • 操作PC = PC + offset; Reg[rd] = PC + 4
  • 用途:实现函数调用,将返回地址(PC+4)保存到 rd(通常为 x1 链接寄存器)。
  • 数据通路:类似于分支,但总是采纳跳转。使用ALU计算 PC + offset,并通过新增的路径将 PC+4 值写入寄存器文件。

2. JALR (Jump and Link Register) - I-type

  • 操作PC = Reg[rs1] + offset; Reg[rd] = PC + 4
  • 用途:用于从函数返回或间接跳转。
  • 数据通路:类似于JAL,但目标地址的计算是 Reg[rs1] + offset 而不是 PC + offset。因此,A选择多路复用器选择 rs1


⬆️ 实现U-type指令(LUI, AUIPC)

U-type指令用于操作大即时数。

1. LUI (Load Upper Immediate)

  • 操作Reg[rd] = immediate << 12
  • 用途:将20位即时数加载到寄存器的高20位,低12位置零。用于构建大的常数。
  • 数据通路:即时数生成模块产生U-type即时数(高20位来自指令,低12位为0)。通过写回选择(WB-sel) 多路复用器的一个新输入(例如,选择“即时数”本身),直接将这个值写回寄存器文件。ALU可能不被使用,或者用于执行“加零”操作。

2. AUIPC (Add Upper Immediate to PC)

  • 操作Reg[rd] = PC + (immediate << 12)
  • 用途:构建相对于PC的大偏移量地址,常用于位置无关代码。
  • 数据通路:与LUI类似,但需要ALU将PC与U-type即时数相加,然后将结果写回寄存器文件。


📝 总结

本节课中我们一起学习了如何扩展RISC-V单周期数据通路以支持更多指令:

  1. 加载/存储指令:引入了数据存储器和写回选择多路复用器,理解了内存读写的控制流程。
  2. 分支指令:引入了分支比较器模块,学习了如何通过硬件并行计算和选择来条件更新PC。
  3. 跳转指令:学习了无条件跳转的实现,以及如何保存返回地址。
  4. U-type指令:了解了如何处理大即时数,并构建相关地址。
  5. 核心模块:深入理解了即时数生成模块如何通过多路复用和位拼接,为不同格式的指令生成统一的32位即时数。

通过将这些部件全部集成,我们完成了一个能够执行RISC-V基础整数指令集(RV32I)中大部分指令的单周期CPU数据通路设计。这是一个重要的里程碑,它为我们理解更复杂的多周期或流水线CPU奠定了基础。

课程 P26:第20讲 - RISC-V 单周期控制 🎛️

在本节课中,我们将要学习RISC-V单周期处理器的控制单元是如何工作的。我们将深入探讨数据路径与控制信号之间的关系,理解如何通过控制线来协调处理器各个阶段的运作,并分析指令执行的关键路径和时序。


概述

上一节我们介绍了RISC-V单周期处理器的数据路径。本节中,我们来看看控制单元,它就像提线木偶的操纵者,负责指挥数据路径中的各个部件协同工作。

控制单元根据当前正在执行的指令,生成一系列控制信号。这些信号决定了数据在处理器中的流向、ALU执行何种操作、是否访问内存以及是否写入寄存器等关键行为。


数据路径回顾

我们之前看到的数据路径包含五个执行阶段:

  1. 指令取指 (IF)
  2. 指令译码 (ID)
  3. 执行 (EX)
  4. 内存访问 (MEM)
  5. 写回 (WB)

这五个阶段在单周期处理器中依次发生,控制单元为每个阶段指定了具体的工作内容。


控制信号详解

控制单元输出的信号位于数据路径的底部。以下是控制信号如何影响处理器操作的例子。

存储字指令 (sw) 示例

对于存储字指令 sw,其目标是将寄存器中的数据写入内存。

以下是控制信号如何设置:

  • 写寄存器使能 (RegWrite): 设置为 0。因为 sw 指令的目标是写入内存,而不是寄存器。
  • 分支 (Branch): 设置为 0sw 指令不涉及分支操作。
  • ALU操作 (ALUOp): 设置为 addsw 指令需要计算内存地址,即基址寄存器值加上偏移量。
  • ALU源B选择 (ALUSrcB): 选择立即数。因为地址计算需要用到指令中的偏移量。
  • 内存写使能 (MemWrite): 设置为 1。这是少数几个真正需要向内存写入数据的指令之一。
  • 结果写回选择 (MemtoReg): 此时不关心,因为 RegWrite0

当时钟上升沿到来时,计算出的地址被用于内存写入,同时程序计数器更新为 PC + 4

分支相等指令 (beq) 示例

对于分支相等指令 beq,其目标是根据比较结果可能改变程序计数器。

以下是控制信号如何设置:

  • 写寄存器使能 (RegWrite): 设置为 0beq 指令不写入寄存器。
  • 分支 (Branch): 设置为 1。这是一条分支指令。
  • ALU操作 (ALUOp): 设置为 add。需要计算分支目标地址,即当前 PC 加上偏移量。
  • ALU源A选择 (ALUSrcA): 选择 PC。因为目标地址是相对于当前 PC 的。
  • ALU源B选择 (ALUSrcB): 选择立即数(偏移量)。
  • 内存写使能 (MemWrite): 设置为 0
  • 关键控制: 分支相等信号 (BranchEQ)PC源选择 (PCSrc)。这两个信号共同决定了下一个 PC 的值是 PC+4 还是计算出的分支目标地址。BranchEQ 由比较器根据两个寄存器值是否相等产生。


指令执行时序分析 ⏱️

理解控制信号后,我们必须考虑时序。在单周期CPU中,最慢的指令决定了时钟周期的最小长度。

以下是五个阶段大致的延迟(示例值):

  • 指令取指 (IF): 200 ps (从内存读取指令)
  • 指令译码 (ID): 100 ps (寄存器文件读取)
  • 执行 (EX): 200 ps (ALU操作)
  • 内存访问 (MEM): 200 ps (从数据内存读取)
  • 写回 (WB): 100 ps (写回寄存器文件)

对于加载字指令 (lw),它需要经历所有五个阶段。因此,其关键路径延迟是这些阶段延迟的总和:200 + 100 + 200 + 200 + 100 = 800 ps

这意味着时钟周期至少需要 800 ps,对应的最大时钟频率约为 1.25 GHz。如果试图以更快的频率运行,lw 指令可能无法在时钟上升沿前完成写回阶段,导致寄存器文件中的数据不稳定,这是时序违规。


控制逻辑的实现

我们如何根据指令位来生成这些控制信号呢?答案是通过一个大的组合逻辑块,可以将其视为一个只读存储器 (ROM) 或一个经过特殊设计的逻辑电路。

控制单元的输入是指令中的特定字段(主要是操作码 opcodefunct3, funct7 等)。输出就是我们之前讨论的所有控制信号。

例如,判断是否进行无符号分支比较 (BranchUnsigned) 的信号,可以通过检查指令的第30位 (inst[30]) 是否为1来实现。对于 bltu (无符号小于则分支) 指令,该位为1;对于 blt (有符号小于则分支),该位为0。硬件设计者巧妙地将这一信息编码在指令位中,使得控制逻辑的实现变得简单直接。

整个控制逻辑的真值表可以转化为与门、或门构成的电路,或者直接编程到一个ROM中。其逻辑可以用类似高级语言的描述来表达:

  • add 指令 sub 指令 lw 指令... PC 的下一个值选择 PC+4
  • beq 指令 比较结果为相等 PC 的下一个值选择分支目标地址。


总结

本节课中我们一起学习了RISC-V单周期处理器的控制单元。

  1. 我们回顾了数据路径的五个阶段:取指、译码、执行、内存访问和写回。
  2. 我们详细分析了 swbeq 指令的控制信号设置,理解了控制线如何像提线木偶一样指挥数据流动。
  3. 我们探讨了指令执行的关键路径和时序,明白了时钟周期由最慢的指令(如 lw)决定。
  4. 最后,我们了解了控制逻辑可以通过查找表(ROM)或组合逻辑电路来实现,其核心是根据输入指令的位模式,产生相应的控制信号输出。

现在,我们已经从高层C语言,到汇编指令,再到机器码,最后到能够执行这些代码的处理器数据路径和控制单元,完成了对计算机如何工作的底层理解。这是一个值得庆祝的时刻!在接下来的课程中,我们将探索如何通过流水线技术来突破单周期处理器的性能限制。

课程 P27:单周期数据通路详解 🧠

在本节课中,我们将学习计算机体系结构中的核心概念——单周期数据通路。我们将详细拆解其工作原理、五个关键阶段,并分析不同指令如何在这个通路上执行。这对于理解CPU如何执行指令至关重要。

概述 📋

单周期数据通路是CPU设计的一种基本模型,它在一个时钟周期内完成一条指令的全部操作。本节课我们将依次学习其五个阶段:指令提取、指令解码、执行、访存和写回。我们将了解每个阶段的功能、涉及的硬件组件以及控制信号的作用。

核心概念与术语 🔑

在深入数据通路之前,我们先明确几个基础概念。

  • 状态元素:连接到时钟的组件,其输出在时钟边沿更新。数据通路中的一个主要例子是寄存器
  • 关键路径:两个状态元素之间最长的组合逻辑延迟路径。其长度决定了最小时钟周期。
    • 公式:关键路径延迟 = 寄存器时钟到Q输出延迟 + 最长组合逻辑延迟 + 寄存器建立时间
  • 最小时钟周期:等于关键路径的长度。
  • 最大时钟频率:最小时钟周期的倒数。
    • 公式:最大频率 = 1 / 最小时钟周期
  • 多路复用器:根据控制信号选择多个输入中的一个作为输出。
    • 代码描述:output = (sel == 0) ? input_a : input_b

数据通路的五个阶段 🛠️

上一节我们介绍了基础术语,本节中我们来看看构成单周期数据通路的五个核心阶段。每个阶段都承担着指令执行过程中的特定任务。

以下是数据通路的五个阶段概述:

  1. IF - 指令提取:从内存中读取下一条指令。
  2. ID - 指令解码:解析指令,读取寄存器值,生成立即数。
  3. EX - 执行:进行算术逻辑运算或计算地址。
  4. MEM - 访存:从数据内存中读取或写入数据。
  5. WB - 写回:将结果写回到寄存器文件。

第一阶段:指令提取 (IF) 🎯

指令提取阶段的目标是获取下一条要执行的指令。

  • 程序计数器:一个特殊的寄存器,存储当前指令的地址。
  • 指令内存:存储所有指令。PC的值作为地址输入,输出对应地址的指令。
  • 地址计算:一个加法器持续计算 PC + 4,以指向下一条顺序指令的地址。
  • 多路复用器:根据控制信号,选择下一条指令的地址是 PC + 4(顺序执行)还是由ALU计算出的地址(跳转或分支)。

关键输出:从指令内存中读出的32位指令,它将进入下一阶段。

第二阶段:指令解码 (ID) 📖

在解码阶段,系统解析刚取出的指令,并准备执行所需的数据。

  • 寄存器文件:包含所有通用寄存器。根据指令中的 rs1rs2 字段,读取两个源寄存器的值。
  • 控制单元:根据指令的操作码部分,生成一系列控制信号,用于控制数据通路后续阶段的多路复用器、ALU等。
  • 立即数生成器:根据指令类型,从指令的不同位中提取并符号扩展为32位的立即数。

关键输出:从寄存器读出的数据、生成的立即数以及一系列控制信号。

第三阶段:执行 (EX) ⚙️

执行阶段是进行计算的核心环节。

  • 算术逻辑单元:执行指令指定的算术或逻辑运算,如加、减、与、或等。
  • 分支比较器:比较从寄存器读出的两个值,根据指令类型判断是否满足分支条件,并输出信号。
  • 多路复用器
    • A多路复用器:选择ALU的A输入是来自PC还是寄存器 rs1
    • B多路复用器:选择ALU的B输入是来自寄存器 rs2 还是立即数。

关键输出:ALU的计算结果,以及用于判断分支是否发生的信号。

第四阶段:访存 (MEM) 💾

访存阶段负责与数据内存交互。

  • 数据内存:存储程序数据的存储器。
  • 读写控制
    • 加载指令:从计算出的内存地址读取数据。
    • 存储指令:将数据写入计算出的内存地址。

关键点:只有加载和存储指令会真正使用数据内存,其他指令在此阶段“穿过”而不进行实际操作。

第五阶段:写回 (WB) ↩️

写回阶段将指令的执行结果保存到寄存器文件中。

  • 写回多路复用器:选择要写回寄存器文件的数据来源。可能的选择包括:
    • ALU的计算结果
    • 从数据内存读取的数据
    • PC + 4(用于 jal 指令保存返回地址)
  • 寄存器写使能:一个控制信号,决定是否将数据写入目标寄存器。

关键操作:将选定的数据写入指令指定的目标寄存器。

指令执行与关键路径分析 📊

了解了五个阶段后,我们来看看不同类型的指令如何流经这个数据通路,并分析其性能瓶颈。

以下是几条典型指令在各阶段的资源使用情况:

  • add (加法指令):使用 IF, ID, EX, WB。不使用 MEM。总延迟为各阶段延迟之和。
  • lw (加载字指令):使用 IF, ID, EX, MEM, WB。这是最复杂的指令之一,因为它使用了所有五个阶段。
  • sw (存储字指令):使用 IF, ID, EX, MEM。不使用 WB,因为它不写回寄存器。
  • beq (条件分支指令):使用 IF, ID, EX。可能根据比较结果更新PC,但不使用 MEM 和 WB。
  • jal (跳转并链接指令):使用 IF, ID, EX, WB。将 PC+4 写回寄存器,用于子程序调用。

关键路径分析
在单周期数据通路中,时钟周期必须足够长,以完成最复杂指令的所有操作。通常,lw 指令的路径最长,因为它依次经过了 IF、ID、EX、MEM、WB 的所有组合逻辑延迟。因此,系统的最大时钟频率由 lw 指令的执行时间决定。

例如,如果 lw 指令总延迟为 800ps,那么最小时钟周期就是 800ps,最大时钟频率为 1 / 800ps = 1.25 GHz。

单周期数据通路的效率问题:由于时钟周期必须适应最慢的指令,在执行简单指令时,数据通路的许多部分处于闲置状态,导致硬件利用率低。这正是引入流水线技术的主要原因。

控制信号示例 🎛️

最后,我们通过一个简表来直观感受控制信号如何指导指令执行。以下是部分指令的控制信号值示例:

指令 PC多路选择 寄存器写使能 ALU操作 内存写使能 写回数据选择
add PC+4 1 0 ALU结果
lw PC+4 1 0 内存数据
sw PC+4 0 1 X
beq 由比较结果决定 0 0 X
jal ALU结果 1 0 PC+4
  • X 表示“不关心”,因为该信号对该指令无效。
  • 此表为高度简化版本,实际控制信号更多。

总结 🎉

本节课中,我们一起学习了单周期数据通路的完整框架。

  • 我们明确了状态元素关键路径等基础概念。
  • 我们逐步剖析了数据通路的五个阶段:指令提取、解码、执行、访存和写回,理解了每个阶段的功能与组件。
  • 我们分析了不同指令如何流经数据通路,并认识到 lw 指令通常决定了系统的最大时钟频率
  • 我们指出了单周期设计的主要缺点:硬件利用率低,为学习流水线技术打下了基础。

掌握单周期数据通路是理解现代处理器流水线、冒险处理等高级概念的基础。请务必结合图表进行练习,以加深理解。

课程 P28:第21讲 - RISC-V 五级流水线 I 🚀

在本节课中,我们将要学习计算机性能的核心概念,并首次探讨如何通过流水线技术来提升处理器的性能。我们将从理解性能的衡量标准开始,逐步深入到流水线的基本思想,为后续构建更高效的RISC-V CPU打下基础。

概述:性能、效率与流水线

上一节我们完成了单周期RISC-V CPU的设计,确保它能正确工作。本节中,我们来看看如何让它运行得更快。我们将学习如何衡量和改进计算机的性能,并引入“流水线”这一关键概念,它就像一条高效的装配线,能显著提升处理器的吞吐量。

性能的衡量标准 🏎️

我们首先需要定义什么是“性能”。对于计算机而言,性能可以从三个不同角度衡量:

  1. 响应时间:完成单个任务所需的时间。例如,执行一条指令的时间。
  2. 吞吐量:单位时间内完成的工作总量。例如,处理器每秒能执行多少条指令。
  3. 能效:完成每单位任务所消耗的能量。这对于移动设备和数据中心至关重要。

这三者之间的关系可以用一个经典公式描述,即 性能“铁律”

程序执行时间 = 指令数 × 每条指令平均时钟周期数 × 时钟周期时间

  • 指令数:由程序算法、编程语言和编译器优化决定。
  • CPI:平均每条指令所需的时钟周期数,由处理器架构(ISA)和具体实现(微架构)决定。
  • 时钟周期时间:由硬件工艺、关键路径延迟和功耗预算决定。

我们的单周期RISC-V CPU的CPI为1,但时钟周期很长(800皮秒),因为它必须在一个周期内完成所有工作。我们的目标是提高吞吐量。

能效的考量 🔋

随着设备小型化和对续航、散热的重视,能效变得和性能同等重要。在CMOS电路中,动态功耗主要来自对电容的充放电:

功耗 ∝ 电容 × 电压² × 频率

因此,降低电压对减少功耗有极其显著的效果(平方关系)。然而,电压降低会减慢晶体管速度,并且存在泄漏功耗等问题。历史上,约2005年出现的“功耗墙”迫使处理器设计从单纯提升主频,转向了增加核心数量(并行化)和提升能效。

流水线的基本思想 🧺

流水线的灵感来源于日常生活中的装配线,例如洗衣房的工作流程。

假设洗衣服有四个阶段(洗涤、烘干、折叠、收纳),每个阶段需要30分钟。

  • 非流水线(单周期)方式:一个人完成所有四个阶段后,下一个人才能开始。4批衣服需要 8小时
  • 流水线方式:当第一个人完成洗涤进入烘干阶段时,第二个人可以立即开始洗涤,以此类推。4批衣服仅需 3.5小时

以下是流水线的关键特点:

  • 提高吞吐量:多个任务在不同阶段同时进行,单位时间内完成的工作量大大增加。
  • 不减少延迟:单个任务完成所需的总时间(延迟)可能不变甚至略有增加。
  • 受限于最慢阶段:整个流水线的速度取决于其中最慢的那个阶段。阶段时长不平衡会降低效率。
  • 存在填充和排空开销:流水线启动时需要时间填满所有阶段,结束时也需要时间排空,这期间效率并非100%。

从洗衣房到处理器 💻

这个类比完美对应到处理器设计。我们的单周期CPU就像非流水线洗衣房,在一条指令执行过程中的每个阶段(取指、译码、执行、访存、写回),大部分硬件在大部分时间里都是闲置的。

通过引入流水线,我们将指令执行过程划分为五个固定的阶段,并在每个阶段之间插入寄存器来隔离它们。这样,就像洗衣房一样,当第一条指令进入“执行”阶段时,第二条指令就可以进入“译码”阶段,第三条指令可以开始“取指”,从而实现多条指令的重叠执行,极大提高了吞吐量。

理论上,一个五级流水线可以获得接近5倍的吞吐量提升(假设阶段平衡且无冲突)。

总结与展望

本节课中我们一起学习了:

  1. 衡量计算机性能的三大指标:响应时间、吞吐量和能效。
  2. 决定程序执行时间的“铁律”公式。
  3. 功耗与电压、电容的密切关系,以及“功耗墙”如何改变了处理器的发展方向。
  4. 流水线的核心思想——通过任务重叠执行来提高吞吐量,并理解了其优势和限制(如延迟不变、阶段平衡、填充/排空开销)。

下一节课,我们将把洗衣房的流水线思想具体应用到RISC-V CPU的设计中,开始构建我们的五级流水线处理器,并深入探讨其中需要解决的技术挑战。

🚀 课程 P29:Lecture 22: RISC-V 五级流水线 II - 冒险

在本节课中,我们将要学习RISC-V五级流水线设计中的核心概念——冒险。我们将深入探讨结构性冒险和数据冒险,理解它们如何影响流水线性能,并学习一些基础的解决方案。课程内容将围绕单周期CPU与流水线CPU的对比展开,帮助你理解流水线如何提升吞吐量以及随之而来的挑战。


📊 单周期与流水线数据路径回顾

上一节我们介绍了RISC-V的基本数据路径。本节中我们来看看其单周期实现与流水线实现的区别。

在单周期数据路径中,一条指令必须顺序经过所有五个阶段(IF, ID, EX, MEM, WB)才能完成。整个时钟周期由最慢的阶段决定,总和为800皮秒。

公式:单周期CPU时钟周期
T_cycle_single = T_IF + T_ID + T_EX + T_MEM + T_WB = 800 ps

而在流水线数据路径中,我们将这五个阶段用流水线寄存器分隔开。不同指令可以同时处于不同的阶段。时钟周期由最慢的单个阶段决定,为200皮秒。

公式:流水线CPU时钟周期
T_cycle_pipeline = max(T_IF, T_ID, T_EX, T_MEM, T_WB) = 200 ps

这种设计极大地提高了吞吐量。


⚙️ 流水线寄存器与阶段划分

为了构建流水线,我们需要在阶段之间插入寄存器来暂存中间结果。这些就是流水线寄存器

以下是各阶段之间的流水线寄存器:

  • IF/ID寄存器:位于指令取指(IF)和指令译码(ID)阶段之间。保存取出的指令和更新后的PC值。
  • ID/EX寄存器:位于指令译码(ID)和执行(EX)阶段之间。保存从寄存器文件读出的数据、立即数、控制信号等。
  • EX/MEM寄存器:位于执行(EX)和内存访问(MEM)阶段之间。保存ALU计算结果、要存储的数据、控制信号等。
  • MEM/WB寄存器:位于内存访问(MEM)和写回(WB)阶段之间。保存从内存读取的数据或ALU结果,以及写回控制信号。

控制信号也在ID阶段生成,并随着指令在流水线中前进,通过这些流水线寄存器传递到后续需要它们的阶段。


🐢 性能对比:延迟与吞吐量

理解单周期与流水线设计的优劣,关键在于两个性能指标:延迟吞吐量

  • 指令延迟:执行一条指令所需的时间。
    • 单周期:Latency_single = T_cycle_single = 800 ps
    • 流水线:Latency_pipeline = N_stages * T_cycle_pipeline = 5 * 200 ps = 1000 ps
    • 结论:流水线增加了单条指令的延迟。

  • 吞吐量:单位时间内完成的指令数。
    • 我们可以使用处理器性能铁律来分析:
      程序执行时间 = 指令数 × CPI × 时钟周期时间
    • 其倒数与吞吐量相关。
    • 单周期CPI为1,流水线在理想状态下CPI也为1(每个时钟周期都有一条指令完成)。
    • 因此,吞吐量的提升主要来自时钟频率的提高:
      吞吐量提升倍数 ≈ T_cycle_single / T_cycle_pipeline = 800 ps / 200 ps = 4倍
    • 结论:流水线显著提高了吞吐量。


⚠️ 流水线冒险概述

当指令在流水线中重叠执行时,可能会发生冲突,导致指令无法按预期执行,这种现象称为冒险。主要有三类:

  1. 结构性冒险:硬件资源冲突。多条指令试图在同一时钟周期使用同一个硬件部件。
  2. 数据冒险:数据依赖冲突。一条指令需要用到前一条指令的结果,但该结果尚未产生或写回。
  3. 控制冒险:指令流改变冲突。由分支、跳转等指令引起,下一条要取的指令地址不确定。

幸运的是,在我们讨论的RISC-V五级流水线基础设计中,通过分离指令存储器和数据存储器(哈佛结构)、使用多端口寄存器文件等方法,已经避免了结构性冒险。本节课我们重点讨论数据冒险。


🔄 数据冒险与解决方案

数据冒险发生在一条指令(I2)需要读取一个寄存器,而前一条指令(I1)需要写入同一个寄存器时。根据I2读取时I1写入的状态,冒险的严重性不同。

以下是三种典型的数据冒险场景及初始解决方案:

场景一:写后读(同一时钟周期)

例如:ADD t0, t1, t2 后紧跟 SW t0, 0(t3)

  • ADD在WB阶段写t0SW在ID阶段读t0。如果发生在同一时钟周期,SW可能读到旧值。
  • 解决方案:通过设计寄存器文件支持先写后读。即在时钟周期前半段完成写操作,后半段完成读操作。这要求时钟周期不能过快,且是我们的基础假设。

场景二:ALU结果冒险(本次课重点)

例如:ADD t0, t1, t2 后紧跟 SUB t4, t0, t5

  • ADD的结果在EX阶段末产生,在WB阶段才写回t0。但紧随其后的SUB在ID阶段就需要读取t0。此时ADD的新值还在流水线中传递,未写回寄存器文件,导致SUB读到旧值。
  • 初始解决方案:流水线停顿(插入气泡)
    1. 当检测到这种数据依赖时,流水线控制逻辑使SUB指令在ID阶段停顿(不向前推进)。
    2. 在停顿期间,向SUB之后的流水线段插入一个无效操作,称为气泡,以防止错误数据传播。
    3. 一直等到ADD指令将结果写回寄存器文件(WB阶段完成)后,才允许SUB继续执行。
    • 缺点:明显降低了流水线效率,引入了性能损失。

场景三:加载数据冒险(更严重的停顿)

例如:LW t0, 0(t1) 后紧跟 ADD t2, t0, t3

  • LW指令的数据直到MEM阶段末尾才从内存中读出,在WB阶段写回。而紧随其后的ADDLW的MEM阶段就需要t0的值。这比ALU结果冒险的“距离”更远。
  • 解决方案:也需要流水线停顿,且需要的停顿周期更多(通常为1个周期),性能损失更大。

提示:解决数据冒险更高效的方法是数据转发,这将在下一节课中详细讨论。其核心思想是将尚未写回寄存器文件的、但已计算出的结果(例如EX/MEM或MEM/WB寄存器中的值),直接“转发”给需要它的ALU输入,从而避免停顿。


📝 本节课总结

本节课中我们一起学习了RISC-V流水线设计的核心挑战——冒险。

  • 我们回顾了单周期与流水线数据路径,理解了流水线通过提高时钟频率来提升吞吐量,但增加了单条指令的延迟
  • 我们认识了流水线的五个阶段和分隔它们的流水线寄存器
  • 我们重点探讨了数据冒险,它源于指令间的数据依赖。当后续指令需要前序指令尚未产生或写回的结果时,就会发生冒险。
  • 我们分析了ALU结果冒险,并学习了基础的流水线停顿(插入气泡)解决方案。虽然这能保证正确性,但会牺牲性能。
  • 我们还了解到,更严重的加载数据冒险需要更长的停顿周期。

通过本节课,你应该对流水线为何能加速、以及加速带来的新问题有了基本认识。下一节课,我们将深入探讨更优的解决方案——数据转发,以及控制冒险的处理。

课程三:C语言入门 - 基础篇 🚀

在本节课中,我们将要学习C语言的基础知识。C语言是一种强大且高效的低级编程语言,广泛应用于系统编程和性能关键型应用。通过学习C语言,你将能够更深入地理解计算机硬件的工作原理,并编写出运行速度更快的代码。

概述:为什么学习C语言?💡

C语言是一种低级语言,它能让你更接近机器,从而编写出比Python等高级语言更高效的代码。许多高性能库实际上都是用C语言构建的。学习C语言能为你提供全新的编程视角,并让你在C、C++和C#等语言家族中游刃有余。

上一节我们介绍了编程语言的抽象层次,本节中我们来看看C语言在这个层次中的位置。

C语言的历史与地位 📜

C语言并非一种非常高级的语言,也不是一种大型或专用于特定领域的语言。它诞生于20世纪80年代,其设计没有过多限制,能够很好地与低级硬件交互,同时又具备足够的通用性,使其对许多任务都有效。C语言的一个重大成就是,它被用于编写第一个非汇编语言实现的操作系统——Unix。

以下是C语言的一些关键特点:

  • 流行且强大:C语言及其衍生语言(如C++、C#)是40多年来最流行的编程语言之一。
  • 现代替代品:对于新项目,可以考虑使用更安全的Rust或更便捷的Go语言,它们都从C语言中汲取了灵感。
  • 学习资源:可以通过实践和参考《C程序设计语言》(K&R)等经典书籍来学习C语言。

编译型语言 vs. 解释型语言 ⚙️

编程语言可以分为编译型和解释型。理解这一点对掌握C语言至关重要。

  • 编译型语言(如C):源代码通过编译器直接转换为特定计算机架构的机器码(可执行文件)。这使得程序运行速度极快,但可执行文件不能在不同架构(如Windows和Mac)间直接移植。
  • 解释型语言(如Python):源代码由解释器逐行读取并执行。代码具有很好的可移植性,但通常运行速度较慢。
  • Java:是一种混合模式。它先被编译成与架构无关的字节码,然后在运行时由Java虚拟机(JVM) 解释执行,实现了“一次编写,到处运行”。

C语言的编译过程通常包含三个步骤:编译(Compile)汇编(Assemble)链接(Link)。你可以通过口诀“Cal”来记忆。

C程序的构建过程 🏗️

一个C项目通常由多个源文件(.c)组成。构建过程大致如下:

  1. 预处理:处理源代码中的#开头的指令(如#include),进行文本替换和文件包含。
  2. 编译:将预处理后的代码转换为汇编代码。
  3. 汇编:将汇编代码转换为目标文件(.o.obj),即机器码片段。
  4. 链接:将一个或多个目标文件与所需的库文件合并,生成最终的可执行文件。

这种分文件编译的方式支持团队协作和模块化开发。如果只修改了某个源文件,只需重新编译该文件并重新链接即可,无需编译整个项目,这大大节省了开发时间。工具make可以自动化管理这些依赖和构建过程。

以下是编译过程的优点和缺点:

  • 优点
    • 合理的编译时间(借助make工具)。
    • 卓越的运行时性能。
    • 可与Python等脚本语言结合,兼顾开发效率和执行速度。
  • 缺点
    • 代码可移植性差,依赖于特定架构和操作系统库。
    • 开发周期中的“编辑-编译-运行”循环较慢。

C预处理器(CPP)🔧

C预处理器在编译之前对源代码进行文本处理。以下是一些常见的预处理指令:

  • #include <file.h>:包含系统头文件。
  • #include "file.h":包含用户自定义头文件。
  • #define PI 3.14159:定义常量或宏。
  • #ifdef, #endif:条件编译,用于编写可移植代码。

是一种强大的文本替换功能。例如,定义一个求最小值的宏:

#define MIN(X, Y) ((X) < (Y) ? (X) : (Y))

需要注意的是,宏是简单的文本替换,如果参数是有副作用的表达式(例如自增函数),可能会导致意料之外的行为,因为参数可能会被求值多次。

C语言与Java的对比 ⚔️

C语言和Java有许多相似之处,因为Java的设计深受C语言影响。以下是它们的一些主要区别:

  • 编程范式:Java是面向对象的语言;C是面向过程(函数式)的语言。
  • 内存管理:Java有自动垃圾回收;C需要程序员手动管理内存(使用mallocfree)。
  • 编译与运行:C全部编译为本地可执行文件;Java编译为字节码,由JVM解释执行。
  • 入口函数:C是int main(int argc, char *argv[]);Java是public static void main(String[] args)
  • 注释:C使用/* */(不支持嵌套)和//(C99后);Java使用/* *///
  • 布尔值:C中0表示假,非0表示真;Java有明确的boolean类型。

C语言基础语法 🧱

变量与类型

C是强类型语言,变量类型在声明后不能改变。基本类型包括:

  • int:整数。
  • unsigned int:无符号整数。
  • float, double:单精度和双精度浮点数。
  • char:字符。

建议使用明确指定宽度的类型,如int32_t, uint64_t,而不是模糊的long, long long

常量可以使用#defineconst关键字定义。枚举(enum) 能创建一组有名字的常量,提高代码可读性。

函数

函数必须声明返回类型和参数类型。没有返回值的函数返回类型为void

int add(int a, int b) {
    return a + b;
}

结构体(struct)

结构体用于创建自定义的复合数据类型,是实现抽象数据类型(ADT)的方式。

typedef struct {
    int feet;
    int inches;
} Height;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/accff86ef85fbe5cdf56d9fe0496ac57_52.png)

Height h;
h.feet = 5;
h.inches = 11;

控制流

C语言的控制流语句(if, else, while, for, switch)与Java非常相似。强烈建议即使只有一行代码,也使用花括号{}包裹,以避免错误。

特别注意switch语句中的每个case分支通常需要以break结尾,否则会继续执行下一个case(这称为“贯穿”)。

避免使用goto语句,它会使程序流程难以跟踪。

第一个C程序示例 🖥️

让我们看一个计算正弦值的简单C程序:

#include <stdio.h>
#include <math.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/accff86ef85fbe5cdf56d9fe0496ac57_62.png)

int main(void) {
    int angle_degrees;
    double angle_radians, pi, value;

    pi = 4.0 * atan(1.0); // 计算π的近似值
    printf("Value of pi = %f\n", pi);

    for (angle_degrees = 0; angle_degrees <= 360; angle_degrees += 10) {
        angle_radians = pi * angle_degrees / 180.0;
        value = sin(angle_radians);
        printf("%3d   %f\n", angle_degrees, value);
    }
    return 0;
}

重要提醒:C语言中的变量不会自动初始化。声明一个变量后,它的值是内存中的“垃圾值”。直接使用未初始化的变量是常见的错误来源,可能导致程序行为不可预测(这种难以复现的错误有时被称为“海森堡Bug”)。

总结 🎯

本节课中我们一起学习了C语言的基础知识。我们了解到:

  1. C语言是一种高效、低级的编译型语言,能让你更好地利用硬件。
  2. C程序需要通过编译、汇编、链接的过程来生成可执行文件。
  3. C预处理器提供了宏和条件编译等强大功能。
  4. C与Java语法相似,但关键区别在于手动内存管理类型系统
  5. 我们学习了C的基本构建块:变量类型函数结构体控制流
  6. 必须注意初始化变量,并理解0在C中代表“假”。

掌握这些基础是深入理解指针、内存管理等C语言核心概念的第一步。下节课,丽莎老师将带领大家进行更深入的探索。

课程 P30:第23讲:RISC-V 五级流水线 III - 冒险处理 🚧

在本节课中,我们将学习RISC-V五级流水线中一个核心概念:冒险。我们将探讨什么是冒险,它们如何影响流水线性能,以及如何通过硬件和软件技术来解决这些冒险。


期中考试与课程支持 📝

本周有期中考试,请务必参加实验课并填写期中考试复习表。这是一个实验,旨在帮助大家建立对RISC-V和RISC-V C的信心。请让我们知道未来需要改进的地方。

关于期中考试的其他注意事项,公司发布了一篇教育帖子。相关幻灯片应该很快就会发布。目前请不要讨论作业,因为我们还没有为所有人发布解决方案。当允许讨论期中考试时,我们会通知大家。如果你有任何与考试相关的紧急问题,请发邮件给我们。

如果你担心跟不上课程进度,特别是在期中考试后内容较多,请务必报名参加学生支持会议。你可以通过扩展表单完成报名。我们期待在这门课上继续支持你。

我们希望你能感受到我们61C大家庭的支持。这只是一个顶层声明,你需要仔细阅读每一个细节。我们有首尔国立大学的李杰教授作为实验室会议的一部分发言。当你读博士或在学术环境中,你有不同的研究实验室,这并不总是意味着人们在做生物学实验,有时他们在设计GPU等不同类型的设备。切片实验室是一个领域特定架构实验室,围绕不同类型的应用程序设计处理器。

李教授将分享关于领域特定硬件的见解。他拥有该领域的博士学位,并在谷歌工作过一段时间。这将是很好的分享,我可能也会在周三的课上分享。

你们中有多少人复习了星期五的课?我看到90%的人复习了。如果你不确定,请举手。我们将做一个快速回顾,因为我知道期中考试后每个人都需要回到这些概念上,然后我们会继续学习剩余的内容。


流水线回顾与冒险引入 🔄

在谈论冒险之前,我想提醒你我们目前的位置。

我们称之为流水线数据路径。流水线数据路径的不同之处在于,它不是只有一个指令可以按顺序执行所有阶段,而是有多个指令共享流水线。

我们思考这个问题的方式是:假设我们有一个加法指令紧跟着一个减法指令。在这个特定的时钟周期中,加法指令当前处于执行阶段(ALU阶段),因此目前使用管道的这一部分。然后减法指令使用ID阶段,即指令解码阶段。

这意味着它正在访问寄存器文件。为什么我们可以让这些指令共享相同的管道?我们可以有称为流水线寄存器的东西来保存所有指令的数据,然后在时钟上升沿将其传递出去,以便让指令使用那个阶段。这就像一个快照点,允许所有这些指令数据共享同一组导线。

但请注意,实际上这里有一些称为“冒险”的东西。


数据冒险详解 ⚠️

特别是在我们的加法(add)和减法(sub)的情况下,现在我有三条指令,我还遵循了一个称为“或”(or)的指令。我们有这些称为“冒险”的东西,当一些指令实际上还不能正确执行时就会发生。

因为它使用的源寄存器(例如 s0)在之前的指令中被写入。我们知道,如果我们把所有的细节都流水线化,s0 寄存器直到第五个周期才会被写入。

如果我们从周期一、二、三、四、五开始数,如果那是第五周期,实际上减法指令在上一个周期就需要它,就在这里。

因为它需要用 t0 减去 s0。它实际上是在ID阶段读取了错误的值——s0 的旧值。在幻灯片的底部,它不会更新到新值(假设是9),直到它被写回WB阶段。

所以这就是这里的冒险。减法指令实际上不能与加法指令并行执行,或者也读取了错误的寄存器值 x0。因此,从技术上讲,那里也不可能发生,因为有一种叫做“数据冒险”的东西,它得到了错误的数据。

那么解决办法是什么呢?我们上次谈到,这就是我们的结局。我们谈到了一个叫做“流水线停顿”的事情。换句话说,让一些指令实际上不执行,等到没有更多的数据危险之后再运行。

这看起来像是当减法指令有一些解码发生时,流水线实现发现:“哎呀,我现在还不能开始执行减法,因为我正在读取的 s0 还没有被更新。”所以它让这些气泡沿着管道流动。对于这个图来说,这意味着流水线阶段只是运行垃圾数据,它实际上并没有写任何东西来更新其他地方的所有状态。

然后在第二个周期也是如此,它试图再次运行减法指令,但发现:“啊,好吧,又来了,因为 s0 还没有被写入,我没有 s0 的正确值。”然后它继续停顿或产生气泡。最后,在倒数第二行,现在是减法指令。在这一点上,当寄存器文件被读取时(在ID阶段),它将从中读取。这个指令解码阶段很好,它发生在同一个周期中,当寄存器文件在WB阶段被写入时。因为上次的寄存器文件是写操作,然后读操作可以发生在同一个周期中,所以这很好。最后一条指令(or)在这一点上,我们肯定知道 s0 没有被写入,所以我们可以像平常一样获取它。

有时讲座结束后会出现一些问题,感觉还行。就像,这是怎么发生的?这些气泡是怎么起作用的?你可以想象在之前的图表中,如果我很快回到上一张幻灯片,只需添加更多的电线和更多的块,并添加更多通过流水线寄存器布线的项。还记得上次我们谈到通过流水线寄存器进行控制转发。大家可以想象一下,也有一些空操作(NOP)指令正在通过寄存器转发,以确保我们不会将任何内容更新到我们的状态文件中,像寄存器和内存。

但是,事实证明,这实际上在时间上是昂贵的。因为这意味着在接下来的两个周期里,你不能实际执行这些指令,你的整个流水线都停止了。没有指令完成执行。所以“转发”是另一种硬件解决方案,它实际上显式地将管道的输出连接到不同阶段的不同部分。

换句话说,在上一个版本中,我们在考虑只能从寄存器中读取值,当它们都正确更新时。但我们知道,由于我们的五级流水线,到了第三个周期(EX阶段结束时),我们确实有结果 f0 的正确值。因为 s0g0t1。然后这个值通过MEM阶段,然后在WB阶段写回。但在最后两个阶段并没有改变。所以,如果有一种方法可以直接连接前一阶段的输出到后一阶段的输入,那么我们就可以知道在下一个周期,当我们执行减法指令的ALU部分时,我们实际上可以用 s0 的正确值执行。

这是一个巨大的进步,但让我给你们看另一个例子。在这一点上,让我们来看看OR指令。这个或指令有效地在执行阶段只需要知道 s0 的正确值,因为这是操作数之一。源操作数很好,我们什么时候知道正确的值?从技术上讲,它是在前一个阶段(MEM阶段)写的,但我们在WB阶段才知道。但我们知道前一条加法指令的EX阶段发生了。所以如果我们有类似的东西,如果我们要把 s0 转发到MEM阶段,因为它最终会被写到WB阶段,我们可以把输出连接在一起。这是 s0 进入OR的执行阶段。

我想,老实说,这可能是所有图表中最具挑战性的图表。因为这里重要的是要知道,这就像是同一条管道。我刚刚重复了三次管道,但这是同样的硬件。那么“转发”是什么意思呢?转发意味着结果及时地转发到某个未来需要的值。所以在这种情况下,我们说在时钟周期三中,加法指令EX阶段的结果被转发到减法指令EX阶段的输入(时钟周期四),而不是等到时钟周期五它被写入寄存器文件。同样地,OR指令从MEM阶段转发到EX阶段的输入(时钟周期四)。所以时间是向前的。

有个问题,问得好。问题是:“我们不能喜欢这里的紫色电线吗?从技术上讲,它来自MEM阶段。那是怎么回事?为什么它会变成X?它不应该从这里一路连接吗?”这是一个很好的观点。其中一些只是基于事物是如何连接在一起的。这里的重点是,如果你把镜头拉近,你会看到我实际上使用了流水线寄存器作为电线的来源。所以正在发生的是,它没有直接连接到ALU的输出或直接连接到MEM的输出,它真的来自我拥有的任何一组管道寄存器。

因此,如果我们回到最初的幻灯片,你可以想象ALU值是在EX/MEM管道寄存器中设置的,以及这个MEM/WB管道寄存器。所以所有这些都有ALU值,电线就是从那里来的。实际上,让我们在下一节中看看这个。所以我要向你们展示转发是如何工作的,对于其中一种情况(add -> sub)。我不会告诉你减法或OR是什么,我让你自己想办法。我尽量保持图表相对简单,因为它们非常复杂。

但让我们再来看看这个。我们在想什么?我们在考虑这个案例(add后接sub)。然后我们从X值(这个特殊的管道寄存器,存储ALU当前结果的东西)试图将它直接连接到ALU的输入。所以即使我们在管道上及时转发了这个数据路径,实际上看起来事情在倒退,因为我们处理的是同一条管道。每个人都在使用相同的管道。如果我们想转发ALU的结果,让我们看看这是如何工作的,尽管该值存储在EX/MEM管道寄存器中。

好的,中间这个。现在让我强调一下,如果是这样的话,我们需要以某种方式连接这个管道寄存器。我要留着,当这两个... 因为它最终会被加入。那么这是怎么发生的呢?只是有些电线。所以我要把我连接在一起的东西,这里是,我说。好啦,在MEM阶段的管道寄存器输出会发生什么?它要接上电线,然后连接到这两个多路选择器,选择ALU输入A和B。这里还有一些关于我当前指令的信息。

那么这个紫色的指令是什么?这是添加指令。这里的蓝色指令是什么?它被转发到控制逻辑,这是第二条指令的子指令。然后转发控制逻辑会做什么?就像是:“好啦,嗯,我知道ALU的值,也就是 f0,它将需要用作源输入之一,事实上,输入ALU的A,为了下一阶段。” 那有点复杂。这个文件是怎么回事?你有什么问题?我认为就电线而言,这可能是最具挑战性的,因为在数据路径中,电线倒过来了。紫色的线在向后,但在梯形图中,箭头是向前的,因为你在时间上向前推进。

我们在这里有什么问题?好的问题。让我回到上一张幻灯片,不幸的是,我的颜色在这里不完全正确,所以我可能会有点困惑。我们在这里做的是,我们把注意力集中在绿盒子上,我们在说:“好啦,我们真正需要的是结果,它位于EX和MEM之间的管道寄存器,这需要转发给输入,EX阶段。” 因为从技术上讲,前一条指令的EX阶段是完全相同的EX阶段,它只是每个周期被执行。发生的事情是,EX/MEM管道寄存器的输出,就像回到EX输入的电线。但随着时间的推移,至少在这个流水线梯形图中,我们看到事情向前进展。所以这就是为什么我认为这是一个挑战。

那么“转发”的是什么?平均转发意味着及时转发,这样你就不用等着写东西了。我们将在下一个例子中看到,也就是加载指令(load)。所以让我们看看会发生什么。就像我说的,当我们试图比只把东西写到寄存器文件更快时,只有在正确的WB阶段才会发生。让我们看这组特殊的指令:从add到load word到or,然后左移逻辑。在这种情况下,我在这里突出显示了一些寄存器,我们实际上可能有数据危险,但我们要通过将硬件逻辑转发到我们的数据路径中来修复。

所以让我们看看转发可以修复的两个危险,这需要更多的硬件和更多的转发控制逻辑。其中一个是在add中写入 s0,所以更新 s0 的值,但在下一条指令中立即读取。所以再一次,如果我们没有转发,这意味着我们必须让整个流水线停顿,直到 s0 被写入。但是因为我们确实有转发,我们会知道到那时,s0t1t2 在前一阶段计算,我们可以直接把这个转发到EX阶段进行load工作。现在为什么加载字(load word)在执行阶段需要 s0?我们把8加到 s0 上,只是记住load word是如何工作的。所以这很好,就像在绿色盒子里一样。

这里的另一个危险是:load word指令中的 s1 是什么?让我强调一下。这里的一个是我们记忆单词的目的地,或者像这样,我们的记忆字将生活在寄存器文件中。所以 s1 会在WB阶段更新,但真的,它将在MEM阶段结束时被知道。在这种情况下,只要有一个or指令,就像 f0 加上8的值,就像你知道在那个地址的单词的价值一样。一旦那个单词从内存中加载,从技术上讲,我们需要在下一阶段把它写到 s1(WB阶段)。但在这个时候,一旦内存字被加载到数据路径中,我们知道它的值是多少,所以我们可以把它作为一个源转发给or指令。所以这就是为什么你可以看到这根电线,上面写着:“好吧,这就是一个值将被知道的地方,我将需要它为ALU的输入在执行阶段。”

好的,所以这就是我们所看到的。这就是我们在这里看到的蓝色盒子。所以转发解决了这两种情况,因为否则我们将不得不等待另一个周期或拖延另一个周期。转发就像是快捷方式。

然而,这里有一个数据危险是转发不能解决的。所以记住,当你看到这种类型的图表时,你的口头禅是:“恰逢其时,转发及时向前。” 现在让我们看看这个特定的数据危险会发生什么。上面写着:“好,load word指令的 s1 将被写在WB阶段,但我们会在MEM阶段结束时知道的。但是,or指令在EX阶段需要 s1。” 哦不,这是做什么的?你记得我说过的话,我说过MEM阶段的输出,就像那种管道寄存器,它将知道 s1 的值。但我们需要这个来输入EX阶段。所以这个箭头是时光倒流,没有进展。

那么这里的问题是什么呢?到底发生了什么?在相同的时钟周期中我们需要输入,它实际上是其他一些数据路径元素的输出。换句话说,这里的MEM数据路径元素需要以某种方式向前向后。或者像你知道的,所以说,如果我们想想向前向后的事情,这个不太有道理。所以这实际上是不可能的。为什么物理上不可能?为了让这种情况发生,我们需要像DM访问这样的MEM首先发生,然后是EX(ALU需要能够执行)。但时钟周期长度仅够ALU或DM访问之一,所以我们的时钟周期不够长,所以我们可以把这些东西连接在一起。我们该怎么办?

答案是:当加载后立即使用(load-use)发生时,我们真的无能为力。没有硬件解决方案来修复这一个停顿。我们有一个额外的停顿。给大家看一下,我来教你这个,这在实践中是如何工作的。就硬件不可避免地要做的事情而言,因为我们不能后退,对呀,因为我们不能把时间倒过来。

取而代之的是,当我们看到这个,或者我们知道我们无法在执行阶段及时得到 s1 的这个值。因此,我们将知道它,好的,所以我们在这里称之为指令,我们称之为“加载延迟槽”,暗示这就是延迟发生的地方。

所以一旦我们看到or指令使用load word的结果(s1),然后它必须知道选择它,因为它知道它不能执行or指令,当字的值仍从内存中加载时,它无法运行其执行阶段。所以它必须再等第二个周期,然后在这一点上,它能够向前做。

你在这里有什么问题?哦,伟大的洞察力。所以问题或洞察力是:“好啦,所以如果加载延迟槽没有使用load word的结果,那就不会耽搁了。” 是呀,我们会在下一张幻灯片上看到。所以至少每个人都明白,如果我们有这种依赖性,在需要使用or指令的地方,作为一个我们不可避免地会有延迟。我不需要做或不做,好啦,我们得到了。

还有一个问题,哎呦,只是为了所有人。这里有一个关于程序计数器的问题:这个空操作(noop)到底会发生什么?所以禁令真正做的是,上面写着:“好的,请不要实际更新程序计数器,因为我需要再次访问该指令。” 是啊,这是个好问题。所以你也会把这看作是讨论的一部分。如果需要重新加载上一条指令,PC加四,你不应该更新到下一条指令,因为那样你就会失去一切。这就是失速逻辑实际上为你实现的东西。但就像它是一种方式。

所以让我们回到那个想法,这没关系。对于加载后立即使用加载结果的任何指令,我们将需要解决,但其他一切都会好起来的。这允许我们做什么?看看我们的RISC-V说明书,试着重新排序指令,这样负载延迟就不会发生。这就是在代码编译阶段完成的。通常,在硬件级别上可能会发生一点重新排序,我们今天下课后再谈。


代码调度与性能优化 ⚙️

让我们假设这是我们的C代码。我们有几个数组访问:a[0], a[1], a[2]。我们把它们加起来,然后我们把它们存储到同一个数组中。这在简单的编译案例中是什么样子的?所以让我们假设我没有考虑很多,像我这样的编译器,我没想太多。我在想把C转换成RISC-V,我可能会做的是:

我会先加载 a[0],把它放在 t1。我会加载 a[1],然后把它放进 t2。这就是为什么那里有绿色价值。然后我会加 a[0]a[1],也就是 t1t2,我会把它存储到 t3。这是那里的前三个指令。然后我们的前四个指令,然后我会把 a[2] 装进 t4,然后把它们相加并存储这个值到 a[3],它需要在哪里。

然而,这会导致延迟,因为我们有一个操作,在这种情况下,紧随加载之后的add操作使用类似的值,使用 t2(使用load word的结果)。因此,在硬件层面上,这不可避免地会造成一个周期的延迟。同样地,同样的事情在7到8号线又发生了。即load word t4 的结果,这就是我们这里的 a[2],需要立即被读取为下一条指令的源。所以这不可避免地会造成延迟。

没什么大不了的,只会降低我们的表现。好啦,这其实是件大事。问题是关于汇编的,以及加载需要什么。这个稍微有点不一样。我很乐意事后再谈。这是说在汇编中,就像中央的柱子一样,此代码可能发生,这就像是完全可行的代码,它会正常执行的,只是执行得很慢。

那么什么是更好的汇编呢?如果编译器知道会有一个循环延迟(一个周期停顿),然后它可以尝试重新排序指令,这样这些停顿就不会发生了。

我在这里做的唯一一件事是:我把第三个load word指令(也就是我在访问 a[2] 的值)提前了。好啦,所以我有,我从数组中加载所有三个元素(数组元素0, 1和2),然后我添加它们并将单词存储在一起。所以这将允许我做什么?现在不再有立即使用load word结果的指令在它们的负载延迟槽里。这让我能做的结果是:这是加载延迟后的一条指令。因此,只要我的管道有转发,这个问题就可以解决。

好啦,有一个问题要回答,因为你必须看起来像。这是个好问题。所以问题是如何编写编译器来使其工作?答案是很多人。但实际上更重要的答案在左边,也就是说,你需要知道CPU正在处理的流水线,因为如果你不知道加载发生在第四阶段(MEM),执行发生在第三阶段(EX),那你就不知道怎么重新排序了。所以这种事情,我要去之后回答几个问题,但我还需要完成一个部分。

谢谢。这又是两个部分。好啦,所以这很低,然后你可以看到我们完全建立了我们对管道情况的了解,处理危险。现在我们有另一种危险。


控制冒险 🎮

我们上次简短地讲到的,这就是所谓的控制冒险。所以我们又到了这里。这里危险的定义是什么?这意味着我们在技术上无法执行该指令,所以我们需要以某种方式解决问题。否则,我们看到管道降低了

课程 P31:流水线与冒险 🚀

在本节课中,我们将学习流水线数据路径的基本概念,以及流水线技术如何通过缩短关键路径来优化CPU性能。我们还将探讨流水线引入的三种主要冒险(Hazard)类型:结构冒险、数据冒险和控制冒险,并了解如何通过数据转发等技术来缓解这些问题。


流水线数据路径概述 🔄

上一节我们介绍了单周期数据路径,本节中我们来看看流水线数据路径。流水线的核心思想是在数据路径中插入额外的寄存器,将指令执行过程划分为多个阶段。这使得每个阶段的关键路径变短,从而允许我们提高时钟频率,实现更高的吞吐量。

在RISC-V架构中,一个典型的五级流水线包括以下阶段:

  1. IF (Instruction Fetch):指令取指
  2. ID (Instruction Decode):指令译码与寄存器读取
  3. EX (Execute):执行
  4. MEM (Memory Access):内存访问
  5. WB (Write Back):写回

关键公式:流水线CPU的时钟周期取决于任意两个流水线寄存器之间最长的组合逻辑延迟,即最长阶段的时间。

流水线时钟周期 = MAX(IF阶段延迟, ID阶段延迟, EX阶段延迟, MEM阶段延迟, WB阶段延迟)

关键路径分析 📊

在非流水线(单周期)数据路径中,时钟周期取决于完成一条指令所需的所有组合逻辑延迟之和。例如,对于一条 lw (load word) 指令,其关键路径可能包括:PC寄存器、指令内存读取、寄存器文件读取、ALU计算、数据内存读取,最后写回寄存器文件。将所有组件的延迟相加,可能得到例如 900ps 的总延迟。

当我们采用流水线设计后,关键路径被分割。每个阶段只包含部分组件。例如,MEM阶段可能只包含数据内存访问,其延迟为 250ps。加上寄存器时钟到Q输出(30ps)和建立时间(20ps),该阶段总延迟为 300ps。如果这是所有阶段中最长的,那么流水线CPU的时钟周期就是 300ps,相比单周期的 900ps,性能提升了3倍。

注意:性能提升并非简单地等于阶段数的倍数(例如5倍),因为最慢的阶段会成为整个系统的瓶颈。


流水线冒险 ⚠️

流水线虽然提升了吞吐量,但也带来了新的问题,即“冒险”。冒险是指下一条指令无法在预期的时钟周期内执行的情况。主要有三种类型:

以下是三种主要冒险类型的介绍:

  • 结构冒险 (Structural Hazard):当两条指令试图同时使用同一个硬件资源时发生。例如,如果指令和数据共享一个内存,那么取指和访存可能发生冲突。RISC-V架构通过分离指令内存和数据内存(哈佛架构)来解决这个问题。
  • 数据冒险 (Data Hazard):当一条指令依赖于前一条指令的结果,但这个结果尚未产生或写回时发生。这是最常见的一种冒险。
  • 控制冒险 (Control Hazard):当处理器需要根据分支或跳转指令的结果来决定下一条执行哪条指令时发生。由于判断结果在流水线后期才产生,导致已经预取进入流水线的指令可能无效。

数据冒险与数据转发 🔄

数据冒险是流水线中需要重点处理的问题。考虑以下指令序列:

addi t0, t0, 4    # 计算 t0 = t0 + 4
lw   t1, 0(t0)    # 从内存地址 (t0+0) 加载数据到 t1

第二条 lw 指令在ID阶段需要读取寄存器 t0 的值来计算内存地址。但此时,第一条 addi 指令的结果还停留在EX阶段或更后面,尚未写回寄存器文件。如果直接读取,lw 将得到 t0 的旧值,导致错误。

解决方案:数据转发 (Data Forwarding / Bypassing)

数据转发的核心思想是:将计算结果从其产生的地方(如ALU输出端)直接“短路”到需要它的地方(如另一条指令的ALU输入端),而无需等待结果写回寄存器文件。

代码示例:在硬件层面,这通过增加额外的内部通路和多路选择器来实现。控制逻辑会检测到这种数据依赖,并选择转发过来的新值,而不是从寄存器文件读取的旧值。

// 简化的转发逻辑概念
if (EX阶段指令的目标寄存器 == ID阶段指令的源寄存器) {
    // 将EX阶段ALU的输出值,转发给ID阶段指令的ALU输入
    选择器选择“转发数据通路”而非“寄存器文件输出”;
}

然而,并非所有数据冒险都能通过转发解决。例如,对于 lw 指令后紧跟着一个使用其结果的指令(称为 load-use hazard):

lw   t0, 0(sp)
add  t1, t0, t2   # 需要刚加载的 t0 值

lw 指令的结果在MEM阶段结束后才可用,而 add 指令在EX阶段就需要这个值。此时,即使立即从MEM阶段转发,时间上也来不及(它们发生在同一个时钟周期)。对于这种情况,硬件除了使用转发,还必须引入一个时钟周期的 流水线停顿 (Pipeline Stall)气泡 (Bubble),让依赖指令等待一个周期。


总结 🎯

本节课中我们一起学习了流水线处理器的基本原理与挑战。

  • 我们首先了解了流水线如何通过划分阶段来缩短关键路径,提升CPU吞吐量。
  • 接着,我们分析了流水线引入的三种主要冒险:结构冒险、数据冒险和控制冒险。
  • 最后,我们深入探讨了最常见的数据冒险,并学习了通过数据转发技术来减少因数据依赖导致的停顿,从而优化流水线性能。

理解流水线冒险及其解决方案,是掌握现代处理器设计和高性能计算的关键一步。

课程 P32:第 24 讲 - 缓存:直接映射 I 💾

在本节课中,我们将要学习计算机体系结构中的一个核心概念——缓存。我们将探讨为什么需要缓存,它是如何工作的,以及它如何帮助弥合快速处理器与慢速主存之间的速度差距。

概述 📋

期中考试已经结束。今天,我们有一位客座讲师卡罗琳,她将为我们介绍缓存。缓存是处理器和主存之间的一个高速存储层,旨在减少访问数据的延迟。

二进制前缀 🔢

在深入缓存之前,我们需要了解一些基础概念。计算机科学中常用两种前缀系统:国际单位制(SI)前缀和二进制(IEC)前缀。

SI前缀基于十进制,例如:

  • 千(K):10³ = 1,000
  • 兆(M):10⁶ = 1,000,000
  • 吉(G):10⁹ = 1,000,000,000

然而,计算机以二进制工作,因此使用基于2的幂次的二进制前缀更为方便:

  • 基比(Ki):2¹⁰ = 1,024
  • 梅比(Mi):2²⁰ = 1,048,576
  • 吉比(Gi):2³⁰ = 1,073,741,824

硬盘制造商通常使用SI前缀,而操作系统则使用二进制前缀,这解释了为什么标称1TB的硬盘在系统中显示为约931GB。了解这一区别对于后续计算很重要。

内存层次结构 🗂️

上一节我们介绍了二进制前缀,本节中我们来看看计算机的存储系统是如何组织的。

计算机的存储系统是一个层次结构,从快到慢、从贵到便宜、从小到大的排列如下:

  1. 寄存器:位于CPU内部,速度最快,容量最小。
  2. 缓存:位于CPU和主存之间。
  3. 主存(DRAM):容量大,但速度慢于缓存。
  4. 磁盘(二级存储):容量最大,速度最慢。

处理器速度的增长远快于内存访问速度的增长,这导致了“内存墙”问题。缓存的目的就是在这个速度鸿沟上架起一座桥梁。

缓存的基本概念 🧠

理解了内存层次结构后,我们来看看缓存是如何工作的。

缓存是主存中最近被使用数据的一个副本,它被放置在离处理器更近的位置(通常在CPU芯片上)。其核心思想基于两个局部性原理

  • 时间局部性:如果一个数据被访问,那么它在不久的将来很可能再次被访问。
  • 空间局部性:如果一个内存地址被访问,那么它附近的内存地址很可能也会被访问。

缓存通过预取数据块(而不仅仅是单个字节)来利用空间局部性。大多数现代处理器有独立的指令缓存(I-Cache)数据缓存(D-Cache)

当CPU需要访问数据时,系统首先检查缓存。如果数据在缓存中(称为缓存命中),则直接使用,速度很快。如果不在缓存中(称为缓存缺失),则需从主存中加载数据块到缓存,然后再提供给CPU,这个过程较慢。

缓存的组织与设计 🏗️

缓存要高效工作,必须有合理的组织方式。主要有三种映射策略:

  1. 直接映射:每个主存地址只能映射到缓存中唯一一个特定位置。
  2. 全相联映射:每个主存地址可以映射到缓存中的任何一个位置。
  3. 组相联映射:折中方案。缓存分成若干组,每个主存地址可以映射到特定组内的任何一个位置。

为了快速定位数据,内存地址通常被划分为几个部分:标签(Tag)、索引(Index)、偏移量(Offset)。我们将在后续课程中详细讨论。

多级缓存与性能考量 ⚡

单一的缓存可能不足以完全解决问题。现代计算机通常采用多级缓存(L1, L2, L3)。

  • L1缓存:最小最快,紧挨着CPU核心。
  • L2缓存:更大稍慢。
  • L3缓存:更大更慢,可能由多个核心共享。

访问数据时,CPU依次查找L1 -> L2 -> L3 -> 主存。

缓存并不总是带来性能提升。在最坏情况下,如果程序访问模式导致持续的缓存缺失,那么总的访问时间将是 缓存访问时间 + 主存访问时间,反而比直接访问主存更慢。缓存性能通常用平均内存访问时间(AMAT)来衡量。

总结 🎯

本节课中我们一起学习了:

  1. 二进制前缀(SI vs IEC)的区别及其重要性。
  2. 计算机的内存层次结构以及“内存墙”问题。
  3. 缓存的基本概念和工作原理,其核心是时间局部性空间局部性
  4. 缓存的三种主要组织方式:直接映射、全相联映射、组相联映射
  5. 多级缓存的概念以及缓存对程序性能的复杂影响。

缓存是硬件设计中对“以空间换时间”策略的经典应用。理解缓存行为对于编写高性能程序至关重要。在接下来的课程中,我们将深入探讨直接映射缓存的具体实现细节。

🧠 课程 P33:第 25 讲 - 直接映射缓存 II

在本节课中,我们将深入学习直接映射缓存的工作原理。我们将通过分析内存地址的构成、缓存的内部结构以及数据查找的流程,来理解这种最简单的缓存设计是如何加速数据访问的。


概述:缓存的基本概念

上一节我们介绍了缓存和内存层次结构的基本思想。本节中,我们来看看直接映射缓存的具体实现。直接映射缓存是最简单的缓存类型,其核心特点是:每一个内存地址在缓存中都有且只有一个确定的位置可以存放。这就像每个学生(内存地址)在教室里都有一个固定的座位(缓存行),我们只需要根据学号就能立刻找到他。


内存与缓存的映射关系

为了理解映射,我们首先需要将内存想象成一个线性的字节数组。当我们有一个缓存时,关键问题是如何知道一个内存地址的数据应该放在缓存的哪个位置。

一个简单的例子:4字节直接映射缓存

假设我们有一个非常小的缓存:总大小为4字节,每个块(缓存行)大小为1字节。这意味着缓存有4行,每行宽1字节。

  • 如何映射? 内存地址会根据其值“模4”的结果(即除以4后的余数)被分配到缓存中对应的行。例如:

    • 地址0, 4, 8...(余数为0)映射到缓存第0行(蓝色)。
    • 地址1, 5, 9...(余数为1)映射到缓存第1行(红色)。
    • 以此类推。
  • 如何计算? 在二进制中,计算“模4”等价于查看地址的最低两位。这两位直接告诉我们该地址属于哪一行。

    • 00 -> 第0行
    • 01 -> 第1行
    • 10 -> 第2行
    • 11 -> 第3行

增加块大小:8字节直接映射缓存

现在,让我们将块大小增加到2字节。缓存总大小仍为8字节,因此现在有 8字节 / 2字节每块 = 4 个块(行)。

  • 变化是什么? 现在,每个缓存行能存放连续的两个内存地址(一个块)。例如,地址0和1的数据会存放在同一个缓存块(第0行)中。
  • 如何映射? 此时,最低位(第0位)用于指示块内的哪个字节(偏移量)。接下来的两位(第1-2位)用于选择缓存行(索引)。地址除以4(看第1-2位)的结果决定了行号。
    • 地址0(...00)和地址1(...01)的最低两位分别是 0001。它们的第1-2位都是 0,因此都映射到第0行。最低位 0 表示块内右字节,1 表示块内左字节。

地址分解:标签、索引、偏移量

通过上面的例子,我们引出了直接映射缓存中地址分解的三个核心字段。一个内存地址(例如32位)被划分为以下部分:

  1. 偏移量:用于定位数据在一个缓存块内部的具体位置。偏移量位数由块大小决定。

    • 公式偏移量位数 = log₂(块大小字节数)
    • 例如,块大小为8字节,则需要 log₂(8) = 3 位偏移量来寻址块内的8个字节。
  2. 索引:用于选择缓存中的哪一行。索引位数由缓存中的总块数决定。

    • 公式索引位数 = log₂(缓存总字节数 / 块大小字节数)
    • 例如,一个1KB(1024字节)的缓存,块大小为32字节,则有 1024/32=32 个块,需要 log₂(32)=5 位索引。

  1. 标签:地址中剩余的最高位部分。当数据被加载到缓存中时,其地址的标签部分会存储在该缓存行的标签位中。用于在索引找到行后,确认该行中的数据是否确实是我们想要的那个内存地址的数据

记忆口诀:可以记住 TIO(Tag-Index-Offset)的顺序,或者联想“Tío Dan”(西班牙语的“丹叔叔”)来帮助记忆:Tag 在左,Index 在中,Offset 在右。


缓存查找流程

以下是处理器读取数据时,缓存控制器的工作步骤:

  1. 解析地址:根据已知的缓存配置(总大小、块大小),将请求的内存地址分解为 偏移量(Offset)索引(Index)标签(Tag)
  2. 索引行:使用 索引(Index) 位找到缓存中对应的那一行。
  3. 读取数据:根据 偏移量(Offset) 位,从该行的数据块中读取相应的字节。
  4. 标签比对:这是最关键的一步。将地址中的 标签(Tag) 位与缓存行中存储的标签进行比较。
    • 如果标签匹配 且该行的有效位为1 -> 缓存命中。数据直接从缓存返回给处理器,速度极快。
    • 如果标签不匹配 或有效位为0 -> 缓存缺失。控制器必须访问更慢的主内存,读取整个数据块(包含目标地址的数据),将其载入当前索引指向的缓存行,并更新标签。然后,再将请求的数据返回给处理器。

这个过程类似于在机场行李转盘认领行李:你先根据航班号(索引)找到大概的转盘区域,然后通过核对行李牌上的详细信息(标签)来确认是不是自己的行李(数据)。


核心概念与示例

有效位

缓存刚启动或清空时,其中的标签和数据都是无意义的“垃圾值”。为了避免标签偶然匹配导致读取错误数据,每个缓存行都有一个 有效位

  • 有效位 = 1:表示该行中的数据是有效的,可以信任其标签。
  • 有效位 = 0:表示该行是空的或无效的,无论标签是什么,都视为缺失。
    初始化缓存时,所有有效位都被置为0。

计算示例

假设一个直接映射缓存配置如下:

  • 总容量:8 字节
  • 块大小:2 字节
  • 内存地址:32 位

我们来计算各字段的位数:

  1. 偏移量(Offset):块大小为2字节,需要1位 (log₂(2)=1) 来区分块内的两个字节。
  2. 索引(Index):缓存总共有 8字节 / 2字节每块 = 4 个块。需要2位 (log₂(4)=2) 来索引这4行。
  3. 标签(Tag):地址总位数为32位。减去偏移量1位和索引2位,剩下 32 - 1 - 2 = 29 位作为标签。

因此,一个32位地址 0x1022 在此缓存中会被这样解读(假设按位展开后):

  • 最低1位 (0):偏移量 -> 块内右字节。
  • 接下来2位 (01):索引 -> 第1行(从0开始计数)。
  • 高29位:标签 -> 与缓存第1行中存储的标签进行比较。

性能术语

在评估缓存时,我们使用以下术语:

  • 命中率:缓存访问中命中的比例。命中率 = 命中次数 / 总访问次数
  • 缺失率:缓存访问中缺失的比例。缺失率 = 1 - 命中率
  • 命中时间:从缓存中成功读取数据所需的时间(包括地址解码、标签比较和数据读取)。
  • 缺失惩罚:发生缺失时,从主内存获取数据并加载到缓存所额外花费的时间。

一个“热”的缓存意味着它已经包含了工作集所需的大部分数据,因此命中率高,性能好。反之,“冷”的缓存则缺失率高。


总结

本节课我们一起学习了直接映射缓存的核心机制。我们了解到:

  1. 直接映射缓存通过将内存地址划分为 标签(Tag)、索引(Index)、偏移量(Offset) 三个部分来定位数据。
  2. 索引直接确定缓存中的唯一行,偏移量确定块内字节,标签用于验证数据身份。
  3. 缓存查找的核心是 标签比对,配合 有效位 确保数据正确性。
  4. 我们掌握了根据缓存容量和块大小计算各字段位数的方法。

直接映射缓存因其简单和快速而成为基础设计。理解它是学习更复杂的组相联或全相联缓存的重要基石。下节课,我们将继续探索缓存的行为和更高级的主题。

课程 P34:第26讲 - 缓存:多级与性能分析 🧠💾

在本节课中,我们将学习缓存(Cache)的核心概念,特别是直接映射缓存的工作原理、性能分析以及影响缓存性能的关键因素。我们将通过具体的示例和公式来理解缓存如何利用时间局部性和空间局部性来加速程序运行。


概述 📋

缓存是位于CPU和主内存之间的小型高速存储器,其目的是存储最近或频繁使用的数据副本,以减少访问较慢主内存的次数。本节课将深入探讨直接映射缓存的组织方式、读写操作流程,并分析块大小、关联度等因素如何影响缓存的命中率和整体性能。


缓存基础与地址划分 🔢

上一节我们介绍了缓存的基本思想,本节中我们来看看如何具体地将一个内存地址划分成缓存查找所需的字段。

一个内存地址通常被划分为三个部分:标记(Tag)索引(Index)块内偏移(Offset)

  • 标记(Tag):用于标识该数据块来自内存的哪个大区域。当缓存行中的数据被载入时,其来源地址的高位部分就作为标记存储起来。
  • 索引(Index):用于确定数据应该被放置在缓存的哪一行(或哪个“集合”)。它直接对应缓存的行号。
  • 块内偏移(Offset):用于在找到的数据块(通常包含多个连续的字或字节)中定位所需的具体字节或字。

对于一个32位地址、总容量为16 KiB、块大小为16字节(4个字,每字4字节)的直接映射缓存,其划分如下:

  • 总容量 16 KiB = 2^14 字节,所以需要 14位 地址来寻址整个缓存。
  • 块大小 16 字节 = 2^4 字节,所以 偏移量(Offset) 需要 4位
  • 缓存行数 = 总容量 / 块大小 = 2^14 / 2^4 = 2^10 = 1024 行。因此,索引(Index) 需要 10位 来寻址这1024行。
  • 标记(Tag) 位数 = 总地址位数 - (索引位数 + 偏移位数) = 32 - (10 + 4) = 18位

地址划分公式可以总结为:
地址长度 = 标记位(Tag) + 索引位(Index) + 偏移位(Offset)


直接映射缓存工作流程 🔄

理解了地址划分后,我们来看看对于一个读请求,缓存是如何工作的。

以下是处理一个内存读取请求的算法步骤:

  1. 根据索引定位行:使用地址中的索引位找到对应的缓存行。
  2. 检查有效位:检查该行是否包含有效数据。如果无效(例如初始状态或已被驱逐),则发生 强制失效(Compulsory Miss)
  3. 比较标记:如果有效位为1,则比较地址中的标记位与该行存储的标记位是否匹配。
  4. 判断命中/失效
    • 如果标记匹配,则为 命中(Hit)。根据偏移量从数据块中取出所需数据返回给CPU。
    • 如果标记不匹配,则为 冲突失效(Conflict Miss)。这意味着所需数据不在缓存中,且其位置被其他数据占用了。
  5. 处理失效:如果失效(无论是强制还是冲突),缓存控制器需要从主内存中读取包含所需地址的整个数据块,将其存入该缓存行,更新标记位,并将有效位置1。然后,再从新载入的数据块中根据偏移量取出数据返回。

缓存命中判断的逻辑可以用以下伪代码描述:

if (cache_line[Index].valid && cache_line[Index].tag == Tag) {
    // 缓存命中
    data = cache_line[Index].data[Offset];
} else {
    // 缓存失效
    // ... 从内存加载数据块 ...
    // ... 更新缓存行 ...
    data = newly_loaded_data[Offset];
}

写操作与一致性策略 ✍️

到目前为止我们讨论的都是读操作。当CPU需要写入数据时,缓存需要处理数据一致性问题:如何确保缓存和主内存中的数据副本是同步的?

主要有两种策略:

  • 直写(Write-through):数据同时写入缓存和主内存。优点是内存始终保有最新数据,一致性简单;缺点是每次写操作都会引发对慢速内存的访问,增加了写延迟。
  • 写回(Write-back):数据只写入缓存,并将该缓存行标记为“脏(Dirty)”。仅当该“脏”行被新的数据块替换出去时,才将其写回主内存。优点是减少了访问内存的次数;缺点是控制更复杂,且内存中的数据可能不是最新的。

“脏”位是一个额外的状态位,用于标识缓存行中的数据是否比主内存中的副本更新。


缓存性能分析 ⚙️

作为缓存设计者,有几个关键的“旋钮”可以调整以优化性能。

以下是影响缓存性能的主要设计参数:

  1. 缓存容量(Cache Size):总存储空间。容量越大,能存放的数据越多,命中率可能越高,但成本、功耗和查找延迟也越高。
  2. 块大小(Block Size):一次从内存载入的数据量。增大块大小能更好地利用空间局部性(程序很可能访问附近的数据),但过大的块会导致:
    • 失效惩罚(Miss Penalty)增加:从内存传输更多数据耗时更长。
    • 可能降低命中率:如果程序访问模式非常随机,载入的额外数据可能用不上,反而挤占了其他可能有用的数据。
  3. 关联度(Associativity):一个内存块可以放入缓存中哪些位置的限制程度。
    • 直接映射(Direct-mapped):每个块只能放入一个特定行。容易冲突。
    • 组相联(Set-associative):每个块可以放入一个特定“组”内的任意行。是直接映射和全相联的折中。
    • 全相联(Fully-associative):每个块可以放入任何一行。冲突最少,但查找电路极其复杂。
  4. 写策略(Write Policy):如前所述,直写或写回。

平均内存访问时间(AMAT) 是一个重要的性能指标,其简化公式为:
AMAT = 命中时间(Hit Time) + 失效率(Miss Rate) × 失效惩罚(Miss Penalty)
设计目标就是降低AMAT。


失效类型分类 🎯

根据产生的原因,缓存失效可以分为三类:

  • 强制失效(Compulsory Miss):也称为冷启动失效。发生在第一次访问某个数据块时,因为该块从未被载入过缓存。这是不可避免的。
  • 容量失效(Capacity Miss):缓存容量有限,当活跃的工作集(程序频繁访问的数据集合)大小超过缓存容量时,即使缓存是全相联的,也会发生数据被被动替换出去导致的失效。
  • 冲突失效(Conflict Miss):在非全相联(如直接映射或组相联)缓存中,即使缓存还有空闲位置,因为多个内存块竞争同一个缓存行或组而导致的失效。

分析一个程序访存轨迹的失效时,可以按以下逻辑层次思考:

  1. 假设一个无限大、全相联的缓存,剩下的失效都是强制失效。
  2. 假设一个与实际缓存同大小、但为全相联的缓存,新增的失效就是容量失效。
  3. 在实际缓存(如直接映射)上运行,再新增的失效就是冲突失效。

总结 📚

本节课中我们一起学习了缓存的核心机制与性能分析。

  • 我们掌握了如何将内存地址划分为标记、索引和偏移量,这是理解缓存查找的基础。
  • 我们详细演练了直接映射缓存处理读请求的完整工作流程,包括命中与失效的判断。
  • 我们了解了写操作的两种主要策略(直写和写回)及其权衡。
  • 我们探讨了缓存设计的多个关键参数:容量、块大小、关联度和写策略,并理解了它们如何影响命中率、失效惩罚和平均访问时间。
  • 最后,我们学习了缓存失效的三种类型:强制失效、容量失效和冲突失效,并掌握了分析它们的方法。

理解这些概念对于编写高性能程序(例如,进行缓存友好的优化)和设计计算机体系结构都至关重要。

课程 P35:缓存详解 🧠

在本节课中,我们将学习计算机体系结构中的两个核心概念:数据与控制冒险,以及缓存的工作原理。我们将从回顾冒险开始,然后深入探讨缓存的类型、结构和替换策略,帮助你理解它们如何提升计算机性能。

数据与控制冒险回顾 🔄

上一节我们介绍了流水线的基本概念,本节中我们来看看流水线执行时可能遇到的两种主要问题:数据冒险和控制冒险。

数据冒险

当两条指令之间存在数据依赖关系时,就会发生数据冒险。例如,第二条指令需要使用第一条指令的计算结果。在简单的单周期处理器中,这不是问题。但在流水线中,由于指令是重叠执行的,可能导致第二条指令在第一条指令将结果写回寄存器之前就去读取该寄存器,从而读到错误(旧)的值。

以下是解决数据冒险的两种主要方法:

  • 数据转发:将执行阶段的结果直接传递到后续指令的解码或执行阶段,绕过写回阶段。这需要额外的硬件(如多路复用器)和控制逻辑。
    • 公式/概念EX/MEM.RegisterRd -> ID/EX.RegisterRs (将前一条指令ALU的结果直接作为后一条指令的输入)
  • 流水线停顿:插入空操作指令,使流水线暂停几个周期,等待前一条指令完成写回。这种方法简单但会降低性能。

控制冒险

当处理器需要根据条件判断(如分支指令)来决定下一条执行哪条指令时,就会发生控制冒险。在流水线中,在分支指令的执行阶段完成并计算出目标地址之前,后续指令可能已经被取指并进入流水线。如果分支被采纳,这些已被取指的指令就是无效的,需要被丢弃。

以下是解决控制冒险的两种主要方法:

  • 流水线停顿:暂停取指,直到分支指令的执行阶段结束,明确知道下一条指令的地址。这同样会带来性能损失。
  • 分支预测:预测分支是否会被采纳,并基于预测继续取指和执行。如果预测错误,则需要“清空”流水线中错误的指令,并转向正确的指令流。虽然预测错误有代价,但总体效率通常高于总是停顿。

过渡:理解了流水线中的冒险及其解决方案后,我们接下来将探讨一个用于解决内存访问速度瓶颈的关键技术:缓存。

为什么需要缓存?⚡

目前,我们的系统模型是处理器直接访问主内存。然而,访问主存(DRAM)的速度很慢,可能需要数十甚至上百个时钟周期。访问磁盘等次级存储则更慢。为了弥补处理器高速与内存低速之间的差距,我们引入了缓存

缓存是一小块高速存储器,位于处理器和主存之间。它存储最近或经常被访问的数据副本,使得处理器在需要这些数据时,可以快速从缓存中获取,而无需访问慢速的主存。

使用缓存主要基于以下两个 locality(局部性)原理:

  • 时间局部性:如果一个数据项被访问,那么它在不久的将来很可能再次被访问。缓存通过保留最近访问过的数据来利用这一点。
  • 空间局部性:如果一个数据项被访问,那么其邻近地址的数据项也可能很快被访问。缓存通过一次加载一个数据块(包含目标地址及其相邻数据)来利用这一点。

过渡:现在,让我们看看缓存是如何集成到计算机系统以及它是如何组织的。

缓存的组织与结构 🏗️

在概念上,我们可以将缓存视为一个表格。缓存由多个缓存行组成,每个缓存行存储一个从内存加载来的数据块。每个缓存行还包含一些管理信息。

以下是描述缓存结构的关键术语:

  • 块/行:缓存中存储数据的基本单位。
  • :一个或多个缓存行的集合。
  • 索引:用于定位缓存中特定组或行的地址部分。
  • 标记:存储在缓存行中,用于唯一标识该行数据来自内存中哪个地址块。与索引一起,用于判断缓存命中或缺失。
  • 偏移:地址中用于定位块内特定字节的部分。
  • 关联度:衡量缓存灵活性的指标,决定了一个索引可以对应多少个缓存行。
  • 替换/驱逐:当缓存已满且需要加载新数据时,选择移除哪个旧数据块的操作。

一个内存地址通常被划分为以下字段用于缓存查找:
[ 标记 (Tag) | 索引 (Index) | 块内偏移 (Block Offset) ]

过渡:根据关联度的不同,缓存主要有三种类型,它们各有特点。

缓存的三种类型 🗂️

1. 直接映射缓存

这是限制最严格的缓存。每个内存块只能被放到缓存中唯一一个特定的位置(由索引决定)。

  • 工作方式:使用地址的索引位找到对应的唯一缓存行。然后比较该行的标记是否与地址的标记位匹配。如果匹配且有效,则为命中;否则为缺失。
  • 优点:硬件简单,查找速度快(只需一次比较)。
  • 缺点:冲突率高。如果两个频繁访问的内存块映射到同一个缓存行,它们会不停地相互驱逐,导致缓存效率低下。

2. 全相联缓存

这是限制最宽松的缓存。任何内存块可以被放置到缓存中的任何一行。

  • 工作方式:没有索引位。需要将地址的标记位与缓存中的所有行的标记进行比较,以确定是否命中。
  • 优点:冲突率最低,缓存空间利用率高。
  • 缺点:硬件成本高,查找速度慢(需要与所有行比较),难以实现大容量。

3. N路组相联缓存

这是直接映射和全相联的折中方案,也是最常用的类型。缓存被分为若干组,每组有N个缓存行(N路)。

  • 工作方式:使用地址的索引位找到对应的组。然后,需要将该地址的标记位与该组内所有N个缓存行的标记进行比较。
  • 类比:可以想象成一个二维数组。索引决定行(组),然后在该行(组)的N个列(路)中查找匹配的标记。
  • 平衡:通过调整路数N,可以在硬件复杂度、查找速度和冲突率之间取得平衡。例如:
    • N=1 时,即为直接映射缓存。
    • N = 缓存总行数 时,即为全相联缓存。

过渡:在组相联或全相联缓存中,当发生缓存缺失且对应组已满时,需要选择一个旧块进行替换。这就引出了替换策略。

缓存替换策略 🔄

当必须从已满的组中驱逐一个块以便为新块腾出空间时,就需要替换策略。以下是两种常见策略:

以下是两种常见替换策略的对比:

  • 先进先出:选择在组内停留时间最长的块进行替换。实现简单,但可能替换掉仍然常用的块。
  • 最近最少使用:选择在组内最长时间未被访问的块进行替换。这更符合时间局部性原理,通常能获得比FIFO更高的命中率,但实现起来更复杂(需要记录访问顺序)。

代码/概念描述:对于LRU,可以为组内的每个块维护一个“年龄”计数器,每次访问某块时将其年龄置为0,其他块年龄加1。需要替换时,选择年龄最大的块。


本节课中我们一起学习了流水线中的数据与控制冒险及其解决方案,并深入探讨了缓存技术。我们了解了引入缓存的原因、缓存的基本组织结构、三种主要的缓存类型以及常见的缓存替换策略。掌握这些概念对于理解现代处理器如何高效工作至关重要。

课程27:缓存 - 组相联与缓存性能 🧠💾

在本节课中,我们将学习缓存设计中的组相联映射方式,并探讨如何评估缓存性能。我们将了解组相联缓存如何工作,它与直接映射和全相联缓存的区别,以及如何通过多级缓存和替换策略来优化系统性能。


概述:什么是组相联缓存?

上一节我们介绍了直接映射和全相联缓存。本节中,我们来看看介于两者之间的组相联缓存。组相联缓存将缓存划分为多个组(Set),每个组内包含多个块(Block)。内存地址首先映射到一个特定的组,然后在该组内可以存放在任意一个块中。这结合了直接映射的简单性和全相联的灵活性。

核心概念:对于一个内存地址,其映射过程可以表示为:

组索引 (Set Index) = (内存地址 / 块大小) % 组数

在找到对应组后,在该组内进行全相联查找。


组相联缓存的工作原理

组相联缓存的设计引入了“组”的概念。以下是其关键组成部分:

  1. 地址划分:内存地址被划分为三个部分:标签(Tag)组索引(Set Index)块内偏移(Block Offset)
  2. 查找过程:使用组索引找到对应的组,然后并行比较该组内所有块的标签位,以确定是否命中。
  3. 数据读取:如果命中,则根据块内偏移从对应的块中读取数据;如果未命中,则需要从主存中调入数据。

核心硬件:组相联缓存需要多个并行的比较器来同时检查一个组内的所有块标签。


组相联缓存的优势

组相联缓存的主要优势在于减少了“冲突未命中”。在直接映射缓存中,如果两个频繁访问的内存块映射到同一个缓存行,就会产生严重的冲突。组相联缓存允许它们共存于同一个组内的不同块中。

示例对比

  • 在直接映射缓存中,两个映射到同一行的内存块会互相驱逐,导致频繁未命中。
  • 在双向组相联缓存中,这两个块可以存放在同一组的两个不同块中,从而可能同时命中。


块替换策略

当缓存未命中且目标组已满时,需要选择一个现有块进行替换。这就是块替换策略。以下是几种常见策略:

  • 最近最少使用(LRU):替换最长时间未被访问的块。这通常能获得较好的命中率,但硬件实现较复杂(尤其是高相联度时)。
  • 先进先出(FIFO):替换最早进入缓存的块。实现简单,但可能替换掉仍要使用的块。
  • 随机替换:随机选择一个块替换。实现非常简单,且性能有时出人意料地好。

对于双向组相联缓存,LRU策略只需一个位来记录两个块中哪个是最近被访问的。


缓存性能分析

缓存设计的最终目标是降低平均内存访问时间(AMAT)。其公式为:

AMAT = 命中时间 + 未命中率 × 未命中惩罚

影响AMAT的因素

  1. 命中时间:访问缓存所需的时间。更小、更简单的缓存(如L1缓存)命中时间更短。
  2. 未命中率:缓存未命中的概率。增大缓存容量、增加相联度、优化块大小可以降低未命中率。
  3. 未命中惩罚:从下级存储器(如主存或下一级缓存)加载数据所需的时间。这个时间通常很长。


多级缓存

为了同时获得低命中时间和低未命中率,现代计算机采用多级缓存层次结构。

  • L1缓存:容量小(几十KB),速度极快(1个时钟周期),集成在CPU核心内。
  • L2缓存:容量较大(几百KB到几MB),速度较慢(几个到十几个时钟周期),可能被多个核心共享。
  • L3缓存:容量更大(几MB到几十MB),速度更慢,通常由所有核心共享。

多级缓存的AMAT计算(以两级缓存为例):

AMAT = L1命中时间 + L1未命中率 × L2命中时间 + L1未命中率 × L2未命中率 × 主存访问时间

通过增加L2缓存,即使L1未命中,也能从较快的L2中获取数据,从而显著降低有效的未命中惩罚。


实际案例与总结

本节课中我们一起学习了组相联缓存的设计与性能评估。我们了解到:

  1. 组相联缓存是直接映射和全相联的折中,通过将缓存分组来减少冲突未命中。
  2. 替换策略(如LRU、FIFO、随机)决定了组满时如何选择被替换的块。
  3. 缓存性能的核心指标是平均内存访问时间(AMAT),它受命中时间、未命中率和未命中惩罚影响。
  4. 多级缓存是平衡速度与容量的关键架构,通过层次化的设计,用较小的快速缓存(L1)覆盖大部分访问,用较大的慢速缓存(L2/L3)捕获更多的未命中,从而在整体上实现接近快速缓存的速度和接近大容量存储的容量。

缓存是计算机体系结构中提升性能的核心技术之一,其设计需要在硬件成本、功耗和性能之间进行精妙的权衡。理解这些基本原理是进行高效系统设计的基础。

课程 P37:第28讲:弗林分类法与SIMD 🧠

在本节课中,我们将学习计算机体系结构中的一个重要概念——并行性。我们将从弗林分类法开始,了解不同类型的并行计算模型,并重点探讨单指令多数据流(SIMD)模型。这是一种允许一条指令同时处理多个数据的强大技术,广泛应用于图形处理和科学计算等领域。


弗林分类法概览 📊

上一节我们介绍了并行性的基本概念。本节中,我们来看看如何系统地分类不同的并行计算模型,即弗林分类法。

弗林分类法根据指令流和数据流的数量,将计算机体系结构分为四类。以下是其核心分类:

  • 单指令流单数据流(SISD):这是传统的串行处理器模型。一次执行一条指令,处理一个数据项。公式表示为:指令流 = 1, 数据流 = 1
  • 单指令流多数据流(SIMD):一条指令同时作用于多个数据项。这是我们本节课的重点。公式表示为:指令流 = 1, 数据流 = N (N>1)
  • 多指令流单数据流(MISD):多条指令同时处理同一个数据。这种模型在实际中很少使用。
  • 多指令流多数据流(MIMD):多个处理器核心同时执行不同的指令,处理不同的数据。这是现代多核处理器的基础。

软件(如操作系统或应用程序)可以是串行的或并行的,硬件也可以是串行的(如老式单核CPU)或并行的(如现代多核CPU)。这四种组合都是可能的。


深入SIMD:单指令多数据流 ⚡

理解了弗林分类法后,我们聚焦于其中的SIMD模型。本节中我们来看看SIMD是如何工作的以及它的优势。

SIMD的核心思想是数据级并行。它利用一组非常宽的寄存器,能够一次性加载多个数据元素(例如4个单精度浮点数),然后通过一条特殊的指令(如向量加法)同时对这些数据进行相同的操作,最后再将结果存回内存。

以下是一个直观的例子。假设我们需要将两个数组 AB 中的每个对应元素相加,结果存入数组 C

  • 传统SISD方式(伪代码)
    for (int i = 0; i < N; i++) {
        C[i] = A[i] + B[i]; // 每次循环执行一次加法
    }
    
  • SIMD方式(概念性描述)
    for (int i = 0; i < N; i += 4) { // 假设SIMD宽度为4
        // 一条指令加载A[i]到A[i+3]四个数到宽寄存器RegA
        // 一条指令加载B[i]到B[i+3]四个数到宽寄存器RegB
        // 一条指令执行 RegC = RegA + RegB // 同时完成4次加法
        // 一条指令将RegC中的4个结果存回C[i]到C[i+3]
    }
    

通过SIMD,理论上可以将此类循环操作的速度提升数倍(取决于SIMD寄存器的宽度)。


SIMD的演进与应用实例 🚀

了解了SIMD的基本原理后,本节中我们来看看它在实际硬件中的发展历程和典型应用。

SIMD并非新概念。早在1957年麻省理工学院的TX-2计算机就包含了类似的思想。然而,它的普及始于个人计算机时代,主要驱动力是多媒体处理(如音频、视频编解码),这些任务通常需要对大量数据执行相同的操作。

英特尔处理器中的SIMD指令集经历了多次演进:

  1. MMX:引入64位宽寄存器,主要处理整数。
  2. SSE:扩展到128位寄存器,支持单精度浮点数运算(一次处理4个float)。
  3. AVX:进一步扩展到256位寄存器(一次处理8个float)和512位寄存器(一次处理16个float)。

在代码中,程序员可以通过编译器提供的内部函数来使用SIMD指令。这些函数看起来像普通的C函数,但会被直接编译为特定的SIMD汇编指令。

例如,使用SSE内部函数进行向量加法的代码片段可能如下所示:

#include <xmmintrin.h> // SSE头文件

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/8909df85cfbf71088110ca0d873a6380_136.png)

__m128 vec_a, vec_b, vec_result; // 声明128位向量寄存器
// ... 将数据从内存加载到 vec_a 和 vec_b ...
vec_result = _mm_add_ps(vec_a, vec_b); // 执行4个float的并行加法
// ... 将结果 vec_result 存回内存 ...

性能提升是显著的。将简单的矩阵乘法从纯Python实现,优化为使用C语言并配合AVX内部函数,可以获得数百倍的加速比。


总结与核心要点 ✅

本节课中我们一起学习了并行计算的基础分类——弗林分类法,并深入探讨了单指令多数据流模型。

核心要点总结:

  • 弗林分类法 从指令流和数据流的角度,将计算机分为SISD、SIMD、MISD、MIMD四类。
  • SIMD 的核心是数据级并行,通过一条指令同时处理多个数据,非常适合处理数组、图像像素、音频采样等尴尬并行的任务。
  • SIMD需要特殊的宽寄存器向量指令支持。在x86架构上,它经历了从MMX、SSE到AVX的演进,寄存器宽度和并行能力不断提升。
  • 在编程中,通常通过编译器内部函数来显式地使用SIMD指令,这要求程序员对数据布局和算法有更细致的控制。
  • 记住唐纳德·克努斯的忠告:“过早优化是万恶之源”。首先保证代码正确性,然后再考虑使用SIMD等并行技术进行优化。

下一节课,我们将探讨弗林分类法中的另一个重要模型——多指令多数据流,即现代多核处理器所采用的线程级并行。

课程 P38:第29讲 - 线程级并行 I 🧵

在本节课中,我们将要学习计算机体系结构中的一个核心概念:线程级并行。我们将探讨为什么需要并行计算、多核处理器的基本工作原理,以及线程的概念和挑战。


概述:为什么需要并行?

上一节我们回顾了性能提升的传统方法。本节中我们来看看为什么并行计算变得至关重要。

过去,我们通过提高时钟频率来提升性能。但这种方法遇到了瓶颈,因为过高的频率会导致发热和效率问题。目前,主流处理器的时钟频率大约在5GHz左右,很难再大幅提升。

另一个提升性能的方法是降低CPI(每条指令的时钟周期数)。我们通过流水线技术和更宽的寄存器(如SIMD指令)来实现这一点。然而,最根本的提升来自于同时执行多个任务,即并行计算。

公式总性能 ≈ 时钟频率 × (1/CPI) × 并行任务数

当单个任务的速度无法再提升时,增加并行任务的数量就成为关键。这适用于处理相关任务(如矩阵计算的不同部分)或不相关任务(如服务器处理多个独立请求)。


并行计算的发展历程

上一节我们提到了性能瓶颈。本节中我们来看看工程师们如何利用日益增多的晶体管来实现并行。

摩尔定律指出,集成电路上的晶体管数量大约每两年翻一番。虽然单核性能停滞,但晶体管数量仍在增长。工程师们利用这些额外的晶体管,不是去制造更快的单核,而是去制造更多的核心

  • 2005年是一个转折点:从追求更高的单核频率,转向在多核处理器上运行并行程序。
  • 多核处理器:将多个独立的CPU核心集成在同一块芯片上。每个核心都有自己的控制单元、数据路径(ALU、寄存器)和缓存(L1、L2)。它们共享最后一级缓存(L3)和主内存。
  • 现代示例:如今的手机芯片(如苹果A系列)可能包含六核CPU、四核GPU和专门的神经处理引擎,共同协作处理复杂任务。


核心概念:线程

上一节我们介绍了多核硬件。本节中我们来看看软件如何利用这些硬件:通过线程。

线程是一个独立的指令执行流。它拥有自己的程序计数器(PC)和处理器状态(寄存器值)。一个程序可以分成多个线程来并行执行不同部分。

需要区分两个关键概念:

  • 软件线程:程序员或操作系统请求创建的线程。数量可以非常多。
  • 硬件线程:在物理核心上实际执行的线程。数量受限于物理核心数。

操作系统的工作是将大量的软件线程复用到有限的硬件线程上。当一个线程因等待内存数据(缓存未命中)而停滞时,操作系统可以快速将其切换出去,让另一个就绪的线程运行,从而保持硬件忙碌,给用户所有线程都在同时运行的错觉。

代码概念

// 伪代码:一个程序创建两个线程来分别处理任务
create_thread(process_task_A); // 软件线程1
create_thread(process_task_B); // 软件线程2
// 操作系统决定这两个软件线程如何映射到物理核心上运行


挑战:非确定性与共享资源

上一节我们了解了线程的基本原理。本节中我们来看看并行编程带来的主要挑战。

当多个线程并行执行时,它们的指令交错顺序是不确定的。这引入了非确定性。如果线程之间需要通信或共享数据,程序员必须小心地协调它们,以确保结果的正确性。正如Edward Lee教授所言:“线程作为计算模型是非常不确定的,程序员的工作变成了修剪这种不确定性。”

最大的挑战来自于共享资源,尤其是共享内存。当多个核心/线程同时访问和修改同一块内存区域时,会引发数据竞争和一致性问题。

以下是多核系统中主要的共享资源冲突点:

  1. 内存(Memory):多个核心通过共享总线或互连访问同一主内存,容易成为瓶颈。
  2. 最后一级缓存(如L3 Cache):被所有核心共享,可能发生争用。
  3. 数据路径(DataPath):在超线程中,单个核心的ALU等资源可能被多个逻辑线程争用。
  4. 控制(Control):涉及线程调度和同步的开销。
  5. 输入/输出(I/O):通常有专门的控制器管理,争用相对较少。

在这些选项中,共享内存是软件和硬件设计师最头疼的问题,因为它是通信和协调的基础,也是最容易引发性能瓶颈和正确性问题的部分。


硬件辅助:同时多线程(超线程)

上一节我们讨论了线程切换的开销。本节中我们来看看硬件如何帮助更高效地运行多个线程。

传统的线程切换(上下文切换)需要保存和恢复大量寄存器状态,开销较大。同时多线程(Simultaneous Multi-Threading, SMT),在英特尔处理器上常被称为超线程(Hyper-Threading),是一种硬件优化。

其核心思想是:在单个物理核心内,复制一份体系结构状态(如PC和寄存器组),使得从软件角度看,好像存在两个“逻辑核心”。这样,当一个线程因内存访问而停顿时,另一个线程可以立刻使用ALU等执行资源,而无需进行完整的上下文切换。

  • 物理CPU核心:硬件上真实存在的处理单元。
  • 逻辑CPU核心:通过SMT技术呈现给操作系统的“虚拟”核心。例如,一个8核CPU支持超线程后,操作系统可能看到16个逻辑核心。

注意:SMT并不能带来成倍的性能提升(通常远低于2倍),因为它共享了核心内部的大部分执行资源。但它能更充分地利用这些资源,提升整体吞吐量,特别是在线程经常停滞等待的情况下。


总结与展望

本节课中我们一起学习了线程级并行的基础知识。

我们了解到,由于时钟频率提升的瓶颈,并行计算成为持续提升性能的必由之路。多核处理器通过在单芯片上集成多个核心来实现硬件并行。在软件层面,线程是并行执行的基本单位,操作系统负责将众多软件线程调度到有限的硬件线程上执行。

我们探讨了并行编程的核心挑战:非确定性共享资源(尤其是内存)的争用。同时,也介绍了同时多线程(超线程) 这种硬件技术,它通过更细粒度的资源共享来提升单个核心的利用率。

记住我们的口头禅:“过早优化是万恶之源”。在编写并行程序时,首先确保程序的正确性,然后再考虑优化其并行性能。

下节课,我们将深入探讨多线程编程中最为棘手的问题:如何管理共享内存,以及如何保持缓存一致性


祝你万圣节快乐!🎃
(附:一个计算机风格的万圣节玩笑:为什么61不能和C学生区分万圣节和圣诞节?因为Oct 31 == Dec 25!(八进制31等于十进制25))

课程 P39:多级缓存与平均内存访问时间 (AMAT) 🧠💾

在本节课中,我们将深入探讨缓存系统的核心概念,特别是多级缓存的组织方式以及如何评估其性能。我们将学习如何计算平均内存访问时间,并理解不同类型的缓存未命中及其影响。

概述 📋

缓存是计算机系统中用于提升数据访问速度的关键组件。它利用了程序访问数据的空间局部性时间局部性。空间局部性指程序倾向于访问彼此邻近的内存地址;时间局部性指程序可能重复访问相同的数据。通过将近期使用的数据存储在更靠近处理器的快速存储器中,可以显著减少访问主内存的漫长等待时间。

上一节我们介绍了缓存的基本思想,本节中我们来看看缓存的具体组织方式、访问流程以及性能评估方法。

缓存基础回顾 🔄

我们需要缓存的主要原因是为了利用空间局部性和时间局部性。如果你知道将要访问内存中彼此靠近的位置,缓存可以让这个过程更快。同样,如果你知道稍后需要再次访问某个数据项,缓存也非常适合时间局部性,因为它可以存储最近使用的项目。

从缓存访问周期时间来看,从L1或L2缓存获取项目的时间比访问物理内存要短得多。

以下是一些核心术语:

  • 缓存行:也称为块,是存储在缓存中的一组连续内存单元。
  • :由多个缓存行组成,可以通过索引来定位。
  • 关联度:组成一个集合所需的缓存行数量。
  • 驱逐:当缓存已满时,用于移除旧缓存行以便为新数据腾出空间的策略。

缓存地址分解 🧩

为了在缓存中定位数据,内存地址被分解为几个部分:标记索引偏移量

  • 标记:是缓存行的唯一标识符,由内存地址的最高有效位组成。
  • 索引:用于指定缓存中集合的编号。对于直接映射缓存,它指向特定的块;对于组相联缓存,它指向特定的组。
  • 偏移量:用于在缓存行(块)内部索引,以获取所需的具体字节。

以下是计算各部分位数的公式:

  • 偏移量位数 = log₂(块大小)
  • 索引位数 = log₂(集合数量) = log₂(缓存大小 / (关联度 × 块大小))
  • 标记位数 = 地址总位数 - 索引位数 - 偏移量位数

例如,给定一个32字节的缓存,块大小为8字节,直接映射(关联度为1):

  • 块数量 = 32 / 8 = 4
  • 索引位数 = log₂(4) = 2
  • 偏移量位数 = log₂(8) = 3
  • 标记位数 = 假设地址空间为8位,则 8 - 2 - 3 = 3

缓存组织方案 🗂️

主要有三种缓存组织方案:

  1. 直接映射缓存:每个内存块只能映射到缓存中的一个特定位置。结构简单,但容易发生冲突。
  2. 全相联缓存:任何内存块可以放入缓存中的任何位置。冲突最少,但查找速度慢(需要比较所有标记)。
  3. N路组相联缓存:缓存被分为多个组,每个组有N个块。一个内存块可以映射到特定组内的任何位置。这是直接映射和全相联之间的折中方案。

缓存替换策略 🔄

当缓存已满且需要放入新数据时,必须决定驱逐哪个旧块。以下是常见的替换策略:

  • 先进先出:驱逐在集合中停留时间最长的块。
  • 最近最少使用:驱逐在集合中最近被访问时间最早的块。

哪种策略最好?这取决于具体的工作负载和设计目标(如实现简单性、效率)。不存在绝对“最佳”的策略。

缓存访问流程与未命中类型 ❌

访问缓存的基本流程是:首先根据索引找到对应的组,然后比较该组内所有块的标记。如果找到匹配的标记且数据有效,则为命中,根据偏移量读取数据。否则,发生未命中,需要从下级存储器(如L2缓存或主存)加载数据块。

缓存未命中主要有三种类型:

  1. 强制性未命中:由于数据从未被加载到缓存中而导致的未命中。这是不可避免的。
  2. 冲突未命中:在组相联或直接映射缓存中,因为多个内存块映射到同一个缓存组/行,导致一个块被驱逐,而后又被访问所造成的未命中。
  3. 容量未命中:缓存容量不足,无法容纳工作集的所有数据块,导致一些块被驱逐后又需要被访问所造成的未命中。

增加缓存容量可以减少容量未命中;增加关联度可以减少冲突未命中。

写策略 ✍️

当处理器需要写入数据时,缓存系统有两种主要策略:

  1. 写直达:数据同时写入缓存和主存。实现简单,保持了一致性,但每次写操作都访问慢速主存,效率低。
  2. 写回:数据只写入缓存,并标记该缓存行为“脏”。仅当该脏块被驱逐时,才将其写回主存。效率高,减少了主存访问,但实现更复杂,需要额外的“脏位”元数据。

此外,对于写操作未命中的情况,也有两种策略:

  • 写分配:将所需数据块从主存加载到缓存,然后在缓存中完成写操作(通常配合写回策略)。
  • 写不分配:直接将数据写入主存,而不将其加载到缓存。

平均内存访问时间 ⏱️

平均内存访问时间 是衡量缓存系统性能的关键指标,它表示平均访问一次内存所需的时间。

其计算公式为:
AMAT = 命中时间 + 未命中率 × 未命中惩罚

  • 命中时间:在缓存中找到数据所需的访问时间。
  • 未命中率:访问缓存时发生未命中的概率。
  • 未命中惩罚:处理一次未命中所需的时间,包括从下级存储器加载数据的时间。

对于多级缓存(如L1和L2),AMAT的计算可以递归进行:
AMAT = L1命中时间 + L1未命中率 × (L2命中时间 + L2未命中率 × L2未命中惩罚)

我们的目标是尽可能降低AMAT。

总结 🎯

本节课中我们一起学习了缓存系统的核心知识。我们回顾了缓存存在的原因——利用局部性原理。我们详细探讨了如何将内存地址分解为标记、索引和偏移量,并介绍了三种主要的缓存组织方案:直接映射、全相联和组相联。我们还了解了当缓存满时如何选择被替换的块,以及写操作的不同处理策略。

最后,我们学习了评估缓存性能的重要指标——平均内存访问时间,并掌握了其计算方法。理解这些概念对于分析和设计高效的存储层次结构至关重要。

课程 P4:讨论课 1 - 数字表示法 💻

在本节课中,我们将学习计算机如何表示数字。理解数字表示法是学习计算机科学的基础,因为计算机中的所有信息,无论是程序代码还是数据文件,最终都以数字形式存储。我们将从不同进制之间的转换开始,然后探讨几种在计算机系统中表示数字的具体方法。

概述 📋

本次讨论课将涵盖以下核心内容:

  1. 二进制、十进制和十六进制之间的转换。
  2. 有符号数的几种表示方法:符号-数值表示法、反码和补码。
  3. 偏移编码的原理和应用。

讨论课是可选参加的,但我们强烈建议你参与。你可以尝试不同的讨论班,找到最适合自己学习风格的助教。所有讨论课的核心目标是一致的:帮助你掌握课程练习、复习知识,并为考试做好准备。


数字系统基础

在深入具体表示法之前,我们需要理解计算机使用的基本数字系统。计算机使用二进制,这意味着所有数字都以 2 为基数。

二进制 (Binary)

  • 定义:二进制数字中的每一位(bit)只能是两个值之一:01
  • 位值:一个二进制数中,每一位代表不同的 2 的幂次方。最右边的位是最低有效位(LSB),代表 2^0;最左边的位是最高有效位(MSB),代表 2^(n-1)(n 是总位数)。
  • 表示范围:一个 n 位的二进制数最多可以表示 2^n 个不同的数。
  • 单位
    • 4 位 = 1 个半字节 (nibble)
    • 8 位 = 1 个字节 (byte)
  • 前缀:二进制数通常以 0b 作为前缀,例如 0b1011

十六进制 (Hexadecimal)

  • 定义:十六进制以 16 为基数。它比二进制更紧凑,对人眼更友好。
  • 数字:使用数字 0-9 和字母 A-F(代表 10-15)。
  • 与二进制关系1 个十六进制数字正好等于 4 位二进制(1 个半字节)。这是两者转换的关键。
  • 前缀:十六进制数通常以 0x 作为前缀,例如 0x1A3F

进制转换

上一节我们介绍了二进制和十六进制的基本概念,本节中我们来看看如何在不同的进制之间进行转换。

二进制与十进制互转

二进制转十进制
方法是计算所有值为 1 的位所对应的 2 的幂次方之和。
公式十进制数 = Σ(位值_i × 2^i),其中 位值_i 是第 i 位(从右往左,从0开始计数)的值(0或1)。

示例:将 0b1101001 转换为十进制。

  1. 从右向左标记位索引和对应的幂:2^0, 2^1, 2^2, 2^3, 2^4, 2^5, 2^6
  2. 值为 1 的位是:2^6 (64), 2^5 (32), 2^3 (8), 2^0 (1)。
  3. 求和:64 + 32 + 8 + 1 = 105

十进制转二进制
方法是找到构成该十进制数的 2 的幂次方组合。
步骤

  1. 找到小于或等于该十进制数的最大 2 的幂次方。
  2. 将该数减去这个幂值。
  3. 对得到的差重复步骤 1 和 2,直到差为 0。
  4. 所有被用到的幂次方对应的位设为 1,其他位设为 0

示例:将 105 转换为二进制。

  1. 小于 105 的最大 2 的幂是 2^6 = 64105 - 64 = 41
  2. 小于 41 的最大 2 的幂是 2^5 = 3241 - 32 = 9
  3. 小于 9 的最大 2 的幂是 2^3 = 89 - 8 = 1
  4. 小于 1 的最大 2 的幂是 2^0 = 11 - 1 = 0
  5. 用到的幂是 2^6, 2^5, 2^3, 2^0。因此二进制表示为 0b1101001

二进制与十六进制互转

二进制转十六进制
关键是将二进制数从右向左(从 LSB 到 MSB)按 4 位一组(半字节)进行分组,然后将每个半字节转换为对应的十六进制数字。
步骤

  1. 从二进制数的最右边开始,每 4 位分成一组。如果最左边一组不足 4 位,则在左侧0
  2. 将每个 4 位二进制组转换为对应的十六进制数字(使用参考表)。

示例:将 0b01101100 转换为十六进制。

  1. 从右向左分组:1100 (C), 0110 (6)。左边不足4位,补0成为 0110
  2. 结果为 0x6C

十六进制转二进制
过程与上面相反:将每个十六进制数字扩展为对应的 4 位二进制形式,然后连接起来。
示例:将 0x6C 转换为二进制。

  1. 6 -> 0110, C -> 1100
  2. 连接起来:0b01101100

十进制与十六进制互转

通常以二进制作为中间桥梁进行转换。

  • 十进制转十六进制:先将十进制数转换为二进制,再将二进制数转换为十六进制。
  • 十六进制转十进制:先将十六进制数转换为二进制,再将二进制数转换为十进制。

有符号数的表示方法

我们已经学会了如何表示正整数(无符号数)。但在实际应用中,我们需要表示负数。以下是几种常见的表示方法。

符号-数值表示法 (Sign-Magnitude)

在这种方法中,最高有效位(MSB)被用作符号位(0 表示正,1 表示负),剩余的位表示数值的大小(绝对值)。

示例:用 4 位表示 -2+2

  • -2: 符号位为 1,绝对值 2 的二进制为 010。所以表示为 1010
  • +2: 符号位为 0,绝对值 2 的二进制为 010。所以表示为 0010

特点与问题

  • 范围:对于 n 位,可表示的范围是 -(2^(n-1)-1)+(2^(n-1)-1)
  • 存在两个零+0 (0000) 和 -0 (1000)。这浪费了一个编码,并且在硬件设计中需要额外处理。

反码表示法 (Ones‘ Complement)

反码现在较少使用,但理解它有助于学习补码。正数的反码与其二进制原码相同。负数的反码是其对应正数按位取反0110)的结果。

示例:用 4 位表示 -7

  1. +7 的二进制是 0111
  2. 按位取反得到 1000,这就是 -7 的反码表示。

特点与问题

  • 范围:与符号-数值法相同,-(2^(n-1)-1)+(2^(n-1)-1)
  • 同样存在两个零0000 (+0) 和 1111 (-0)。

补码表示法 (Two‘s Complement)

这是现代计算机中最常用的有符号整数表示方法。正数的补码与其二进制原码相同。负数的补码是其对应正数按位取反后加 1 的结果。

示例:用 4 位表示 -7

  1. +7 的二进制是 0111
  2. 按位取反:1000
  3. 加 1:1001。这就是 -7 的补码表示。

特点与优势

  • 只有一个零0000。所有位为 1 表示 -1 (1111)。
  • 范围:对于 n 位,可表示的范围是 -2^(n-1)+(2^(n-1)-1)。负数比正数多一个(因为 0 占用了正数区间的一个编码)。
  • 运算方便:加法和减法可以使用同一套加法器电路完成,无需特殊处理符号位。
  • 溢出判断:当两个正数相加结果为负,或两个负数相加结果为正时,发生了溢出。

补码的数值计算
在补码中,最高位(MSB)的权重是 -2^(n-1),其余位的权重与无符号二进制相同。
示例1001 (4位补码) 的值计算:
1 × (-2^3) + 0 × 2^2 + 0 × 2^1 + 1 × 2^0 = -8 + 1 = -7


偏移编码 (Biased Encoding)

偏移编码是一种通过添加一个固定偏移值(bias)来移动数字线的表示方法。它常用于浮点数标准的指数部分。

原理

  1. 选择一个偏移量 B
  2. 要表示的数值 X,其存储的二进制码 M 满足:M = X + B
  3. 解码时:X = M - B

目的

  • 将需要表示的数值范围(例如从 -127128)映射到无符号数的范围(0255)。
  • 这样,比较器电路可以直接对编码后的无符号数进行比较,结果与对原数的比较结果一致。

标准偏移量
对于 k 位,常见的标准偏移量是 B = 2^(k-1)2^(k-1)-1

  • B = 2^(k-1)-1(例如 8 位时 B=127),编码后的范围 0255 对应原值范围 -127128
  • B = 2^(k-1)(例如 8 位时 B=128),编码后的范围 0255 对应原值范围 -128127

示例:使用 8 位和偏移量 B=127 表示 -42

  1. 计算存储值:M = -42 + 127 = 85
  2. 85 以无符号二进制存储:01010101
  3. 解码时:X = 85 - 127 = -42

总结 🎯

本节课中我们一起学习了计算机中数字表示的核心知识:

  1. 进制转换:掌握了二进制、十进制和十六进制之间相互转换的方法,这是理解所有数字表示的基础。
  2. 有符号数表示:重点理解了最常用的补码表示法,它通过“取反加一”来表示负数,消除了“两个零”的问题,并简化了硬件运算。同时,我们也了解了符号-数值法和反码法作为历史背景。
  3. 偏移编码:学习了通过添加一个固定偏移量,将有符号数范围映射到无符号数范围的技术,这在浮点数表示中至关重要。

记住,相同的二进制位序列,在不同的表示法(无符号、补码、偏移编码)下,会解释为完全不同的数值。理解上下文和所使用的约定是关键。这些概念是后续学习计算机算术、数据存储和浮点数的基础。

课程 P40:第30讲:并行性 II - OpenMP 与共享问题 🔄

在本节课中,我们将学习线程级并行性的第二部分,重点介绍 OpenMP 这一并行编程库,并探讨在共享内存编程中可能遇到的“竞争条件”等核心问题。


并行编程语言概览 🗺️

上一节我们介绍了线程级并行性的基本概念。本节中我们来看看支持并行编程的不同语言层次。

并行编程可以在不同层次上进行,例如控制多台机器或控制单个机器的多个核心。目前存在大约40多种支持并行编程的语言。

以下是选择编程语言时需要考虑的一些因素:

  • 明确控制与高级抽象:像 C 这样的语言允许程序员明确控制硬件操作(例如,如何将数据从一个部分移动到另一个部分)。而像 Python 这样的高级语言则更关注“要做什么”,由编译器或运行时系统决定“如何做”。
  • 任务类型差异:不同的应用领域(如科学计算、网络服务器)对并行性的需求不同。
  • 输入/输出(I/O)处理:I/O 操作通常是异步和不可预测的,这本身就是一种并发问题,需要特定的语言特性来处理。

在 CS61C 课程中,我们重点使用 C 语言,并引入 OpenMP 库来帮助我们利用多核处理器进行并行计算。


什么是 OpenMP? 🧵

OpenMP 是一个支持共享内存并行编程的 C 语言扩展。它的主要优点是允许程序员通过最少的代码修改来实现并行化。

其核心思想是使用 编译指导语句。这些语句以 #pragma omp 开头,对于不支持 OpenMP 的编译器来说,它们会被忽略。只有当你包含了 OpenMP 头文件并启用了相应编译选项时,这些语句才会生效。

OpenMP 采用 fork-join 并行执行模型:

  1. 程序开始时只有一个主线程。
  2. 遇到并行区域时,主线程“派生”出一组工作线程。
  3. 所有线程在并行区域内执行代码。
  4. 并行区域结束时,所有工作线程“合并”,只剩下主线程继续执行。

线程是操作系统调度的软件实体,它们被映射到硬件核心上执行。为了获得准确的性能测试结果,应确保在运行并行程序时,系统负载较低。


使用 OpenMP 并行化循环 🔄

一个非常常见的并行模式是并行化 for 循环。假设我们有一个从 0 到 99 的循环,在拥有 4 个核心的系统上,理想情况是将迭代空间大致平均地分给 4 个线程执行。

使用 OpenMP 可以极其简单地实现这一点。以下是一个基本示例:

#include <stdio.h>
#include <omp.h>

int main() {
    omp_set_num_threads(4); // 建议使用4个线程
    int a[10];
    int n = 10;

    #pragma omp parallel for
    for (int i = 0; i < n; i++) {
        int thread_id = omp_get_thread_num();
        a[i] = i + thread_id * 10;
        printf("Thread %d: a[%d] = %d\n", thread_id, i, a[i]);
    }

    // 打印结果
    for (int i = 0; i < n; i++) {
        printf("%d ", a[i]);
    }
    printf("\n");
    return 0;
}

这段代码通过 #pragma omp parallel for 指令将后续的 for 循环并行化。每个线程会执行一部分迭代,omp_get_thread_num() 函数返回当前线程的 ID。运行程序时,迭代分配到线程的顺序是不确定的。


实战案例:并行计算 π 值 🥧

让我们通过一个计算 π 值的实例来深入理解 OpenMP。我们使用数值积分法,公式如下:

π = ∫₀¹ (4 / (1 + x²)) dx

我们可以用黎曼和来近似这个积分。以下是串行实现的代码:

#include <stdio.h>
#include <math.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_55.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_57.png)

static long num_steps = 1000000; // 积分步数
double step;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_59.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_61.png)

int main() {
    int i;
    double x, pi, sum = 0.0;
    step = 1.0 / (double) num_steps;

    for (i = 0; i < num_steps; i++) {
        x = (i + 0.5) * step; // 取中点
        sum = sum + 4.0 / (1.0 + x * x);
    }
    pi = step * sum;
    printf("Pi = %.15f\n", pi);
    return 0;
}

第一次尝试:简单的并行化及其问题

我们尝试直接添加并行指令:

#pragma omp parallel for private(x) reduction(+:sum)
for (i = 0; i < num_steps; i++) {
    x = (i + 0.5) * step;
    sum = sum + 4.0 / (1.0 + x * x);
}

但请注意,这里存在一个关键问题。如果像最初那样将 sum 声明为单个共享变量,多个线程同时读写 sum 会导致错误。这就是一种 竞争条件

正确的并行化:使用归约子句

正确的做法是使用 OpenMP 的 reduction 子句。它指示每个线程创建自己的 sum 变量私有副本,在循环结束时自动将所有私有副本的值相加到全局变量中。

#include <stdio.h>
#include <omp.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_73.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_75.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_77.png)

static long num_steps = 1000000;
double step;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_79.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_81.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/871db9a38be557766c273c0ebed8a644_83.png)

int main() {
    int i;
    double pi, sum = 0.0;
    step = 1.0 / (double) num_steps;

    #pragma omp parallel for reduction(+:sum)
    for (i = 0; i < num_steps; i++) {
        double x = (i + 0.5) * step;
        sum += 4.0 / (1.0 + x * x);
    }
    pi = step * sum;
    printf("Parallel Pi = %.15f\n", pi);
    return 0;
}

通过 reduction(+:sum) 子句,我们安全高效地实现了并行计算,并能获得显著的加速比。


理解竞争条件与锁机制 ⚠️🔒

当多个线程同时访问和修改同一共享资源(如变量)时,如果执行结果依赖于线程执行的特定顺序,就会发生 竞争条件。这会导致程序行为不确定和结果错误。

竞争条件示例

考虑两个线程同时执行一个简单的递增操作:counter = counter + 1;
假设 counter 初始值为 100。我们期望两个线程执行后结果为 102。

但由于操作不是原子的(可分解为加载、计算、存储),可能会发生以下交错执行:

  1. 线程 A 加载 counter (值 100)。
  2. 线程 B 加载 counter (值 100)。
  3. 线程 A 计算 100+1=101,并存储回 counter (现为 101)。
  4. 线程 B 计算 100+1=101,并存储回 counter (覆盖为 101)。
    最终结果是 101 而不是 102。这就是竞争条件导致的错误。

锁:一种同步机制

解决竞争条件的一种基本方法是使用 。锁确保一次只有一个线程可以进入被保护的代码区域(临界区)。

锁的基本逻辑是:

  • 加锁:线程在进入临界区前尝试获取锁。如果锁已被占用,则等待。
  • 执行:获得锁后,线程安全地执行临界区代码。
  • 解锁:执行完毕后释放锁,允许其他线程获取。

然而,实现一个正确的锁本身也需要硬件指令支持(如测试并设置指令),以避免在锁的实现上出现竞争条件。OpenMP 提供了高级的同步指令(如 critical 区域),避免了手动操作锁的复杂性。

#pragma omp critical
{
    // 这部分代码一次只能被一个线程执行
    shared_counter = shared_counter + 1;
}


总结 📚

本节课我们一起学习了:

  1. OpenMP 简介:一个通过编译指导语句实现共享内存并行的 C 语言扩展库,采用 fork-join 执行模型。
  2. 并行化循环:使用 #pragma omp parallel for 指令可以轻松地将循环迭代分配给多个线程执行。
  3. 实战计算 π:通过数值积分案例,学习了如何使用 reduction 子句来安全地处理并行计算中的累加问题,避免竞争条件。
  4. 竞争条件:理解了当多个线程无序访问共享资源时会导致不确定的程序行为和数据错误。
  5. 同步机制:认识了锁作为保护临界区、解决竞争条件的基本概念,并了解到 OpenMP 提供了更高级的同步原语。

OpenMP 以其对现有代码改动量小的特点,成为了共享内存并行编程的重要工具。然而,编写正确的并行程序需要仔细考虑数据共享与同步,以避免竞争条件等陷阱。

课程 P41:Lecture 31: 并行性 III: 缓存一致性、性能 🚀

在本节课中,我们将要学习并行计算中的两个核心问题:如何通过原子操作解决数据竞争,以及多核处理器中缓存一致性的工作原理。我们将从回顾数据竞争问题开始,然后深入探讨硬件级别的解决方案,最后分析共享内存系统中的缓存一致性挑战。


计算新闻 📰

我发现了一张关于量子计算的纸条。量子计算将彻底改变我们对计算机科学的看法。当它最终上线时,每个人都可以使用。

你可以通过亚马逊访问量子计算。这是一个新闻。有一家新的计算公司叫Q时代,它加入了亚马逊的云计算,让你可以访问。这是量子计算技术的另一个令人兴奋的提供者。

亚历山大·基斯林说,量子计算的冒险之一是你可以对量子比特层进行编程,所以你不必把它们都用完。也就是说你不必拥有整台电脑。当亚马逊负责排队这些请求时,你不必用整个东西。在以前的版本中,你必须保存整个东西。现在你可以说,让我们用这两个人和那四个人比赛。也许这个8对那个16个量子比特。他们在几年前达到了四个量子比特,所以现在达到二十六个量子比特是一件非常令人兴奋的事情。你可以构建不同原子的不同迷你集群来并行计算。

关于并行性的进一步讲座,考虑使用量子计算是非常令人兴奋的。如果你不知道量子计算的基础知识,所有的计算都是并行的,它们一起运行。所以不是一种线性算法。这些量子比特处于叠加状态。他们不是零就是一,他们在中间的某个地方。这真的是量子的一个基本问题。信息在中间,只有当你问它时,它才坍缩到零或一。但它处于叠加状态,直到你问它值多少钱。这真是令人着迷的东西。

如果你有一些量子比特,它在同时搜索所有可能性。所以如果我有一个3位的密码,我有3个量子比特,它实际上在同时搜索所有组合。有关于量子计算的高级课程,由我们的同事乌梅什·瓦齐拉尼教授讲授。我建议任何有兴趣了解更多量子计算的人去学习。


回顾与问题引入 🔄

上一节我们介绍了线程级并行性的基础。本节中我们来看看如何解决同步和数据竞争的核心问题。

上次我们离开时,我们对这种竞赛条件的问题感到震惊。如果你还记得,我们试着使用锁。你抓住锁,说如果我有锁,我可以访问读写变量。但问题是即使是抢锁也有竞争条件。这就是我们上次遇到的问题。


并行编程大图回顾 🖼️

以下是关于MP(一种C语言的简单并行扩展)的全面回顾。

你加一行 #pragma omp parallel for,整个for循环就是并行的。它并行在所有软件线程上,最终映射到硬件线程。真正美丽的是编译器如何做到这一点。

Pragma指示你在块中打破for循环。你将每个线程分配给一个单独的块。所以如果最大值是100,如果你在浏览一个数组,你会将0-49分配给一个线程,50-99分配给另一个线程。原因是你想让每个核心利用其缓存。你希望有时间局部性和空间局部性。你不想让他们随机抓取数组的一部分。给一个数组的连续部分,可以从缓存中受益。

对于支持OpenMP的编译器,你必须具有相对简单的循环结构。运行时需要能够确定有多少循环迭代分配给每个线程。你不能在并行区域内使用 gotobreakreturn 等跳出。在并行区域外做这件事。总的来说,只要用你的for循环非常干净地做就可以。


数据竞争与锁的困境 ⚔️

我们讨论了一场数据竞赛。我们有两个核心试图更新一个变量,就像圆周率的计算。每个人都想把他们的计算贡献加到圆周率上。

问题是 pi += my_contribution。因为你在读旧的值,你在给它增加新的价值,然后你把它放回去。两个人可以同时抢到旧的价值,将他们的作品添加到旧的价值中,然后都覆盖它。你将失去其中的一笔捐款。这就是我们上次在比赛条件下看到的。

我们必须通过同步来避免数据竞争。我们想要有确定性的行为。所以我们看到了锁,但即使是抢锁也有竞争条件。


硬件原子操作:根本解决方案 ⚛️

那么该怎么做?这是一个大想法,但除非你的硬件支持,否则不管用。任何软件级的解决方案在RISC-V架构下都不起作用。你必须添加一条新的指令,让你做一些叫做原子读取和写入的操作。它说我要在内存中做一些操作,没有人能阻止这一点。这是一个非常有力的想法。除非你有这个,否则你不能解决争用条件。

我们能够在一个指令中读写。在读和写之间不允许有其他操作。现在的关键是记住共享的东西。每个核心都有自己的寄存器集,自己的PC。他们可能有自己的L1、L2缓存,但L3通常是共享的。那么他们如何交流?唯一有保证的共同点就是内存。这是一个共享内存多处理器系统。所以内存必须是我们交流的方式。

典型的做法是使用所谓的原子“寄存器与内存交换”。这是我的本地寄存器的值。我想和内存中的值进行原子交换。其他人都不能夹在中间。内存中的东西会进入我的寄存器,我寄存器中的值会进入内存。这就是原子交换。

RISC-V在这方面有差异。我们将重点讨论原子内存操作(AMO)。这是一条新指令。它们原子地对内存中的操作数执行操作,并将目标寄存器设置为原始内存值。

例如,amo add。我想把一个贡献加到内存中的圆周率上。amo add 会获取内存的旧值,把它存在目标寄存器(左手),然后把我的贡献加到内存位置。谁也不能乱来。如果我不在乎旧值,我可以把它设为零。

但实际上更有用的是如何首先实现锁。因为一旦我有了一把锁,我可以做任何事。让我展示如何用交换操作实现锁。


实现自旋锁 🔐

假设锁在内存中。如果锁的值是1,锁就被设置好了,意味着锁是别人的。如果是0,锁就开了,意味着没有人拥有它。

这是一段所有核心共享的内存。所有人都想抓住那段记忆。如果指针指向的值为0,那现在就没人和那段记忆说话了,它可以被抓住。如果值是1,一定有什么东西被利用了。

就像有一盏灯亮着。当灯光亮起,有人在用它。当灯灭了,任何人都可以使用。零指向光在内存中的位置。

代码实现如下:

li t0, 1           # 我的临时值是1,我想把锁设置为1
spin_lock:
    amoswap.w.aq t1, t0, (a0)  # 原子交换:尝试把1存入锁的位置,旧值存入t1
    bnez t1, spin_lock         # 如果旧值不是0(说明锁被别人拿着),继续循环尝试
# 拿到锁了

a0 是锁在内存中的位置。t0 是我想储存在那里的值(1)。amoswap 会尝试在那里放一个1。左手(t1)永远是旧的价值。我要检查旧值是什么。如果它是1,说明别人拿走了锁,我继续尝试。如果它是0,说明我拿到了锁,跳出循环。

aq 表示我正处于获取阶段,这是一种在最低层次上正确排序的方法。

当我完成后,如何释放锁?我必须把锁关掉。

amoswap.w.rl x0, x0, (a0)  # 原子地向锁的位置存储0,不关心返回值

rl 表示释放。x0 是零寄存器,表示我不在乎返回值。我存储一个0,现在灯关掉了。

这种原子内存操作意味着不会有竞争条件。所有其他核心也在努力做到这一点,但只有一个会成功。


OpenMP 中的锁实践 🛠️

在OpenMP中,使用锁更简单。以下是步骤:

  1. 声明并初始化锁omp_lock_t my_lock; omp_init_lock(&my_lock);
  2. 在关键区域前设置锁omp_set_lock(&my_lock);
  3. 执行关键代码:只有拥有锁的线程可以执行这部分。
  4. 释放锁omp_unset_lock(&my_lock);
  5. 销毁锁omp_destroy_lock(&my_lock);

例如,解决圆周率计算的竞争条件:

#pragma omp parallel for reduction(+:pi)
for (i = 0; i < num_steps; i++) {
    double x = (i + 0.5) * step;
    #pragma omp critical
    pi += 4.0 / (1.0 + x * x);
}

#pragma omp critical 指令确保一次只有一个线程执行 pi += ... 操作。

OpenMP的目标是最小化你需要更改的代码量。基本上是一个工作的串行程序,加上几条编译指示,就使其并行。


死锁:当并行陷入僵局 🚧

即使有锁,另一个问题可能出现:死锁。死锁发生在两个或多个进程互相等待对方释放资源,导致所有进程都无法继续。

一个经典的例子是“哲学家就餐问题”。五位哲学家围坐,每人需要两把叉子才能吃饭。算法是:思考,拿起左叉子,拿起右叉子,吃饭,放下叉子。如果所有哲学家同时拿起左叉子,每个人都会等待右叉子,而右叉子被旁边的人拿着,导致所有人都无法吃饭,形成死锁。

解决死锁的方法

  • 序列化:最古老的哲学家先吃,吃完下一个再吃。
  • 增加资源:提供更多的叉子。
  • 引入超时和退让:如果等待一段时间还没拿到第二把叉子,就放下第一把,稍后再试。

关键思想是:如果你发现你在拖延时间,就释放你持有的部分资源,让其他人有机会进行。


性能测量 ⏱️

如果要计算代码运行的速度,可以使用 omp_get_wtime() 函数。

double start_time = omp_get_wtime();
// ... 要计时的代码 ...
double end_time = omp_get_wtime();
double elapsed = end_time - start_time; // 经过的秒数

omp_get_wtime() 返回从过去某个任意点开始的逝去时间(墙钟时间)。通过计算两个调用的差值,可以得到代码段的执行时间。


共享内存与缓存一致性 🧠

现在让我们谈谈大的话题:共享内存和缓存一致性。

你有一个多核处理器,每个核心有自己的缓存,它们通过互连网络连接到共享内存。这是一个共享内存多处理器系统。所有硬件线程在相同的内存空间中工作。

对于任何并行架构,你可以问三个问题:

  1. 它们如何共享数据?
  2. 它们如何协调工作?
  3. 能养活多少处理器(可扩展性)?

在我们的模型中:

  1. 通过单一的共享地址空间共享数据。
  2. 通过内存进行协调,使用锁和原子操作等同步原语。
  3. 瓶颈在于内存和互连网络。

使用缓存可以减少对主存的带宽需求。但问题来了:每个核心都有一个本地私有缓存。如果多个缓存拥有同一内存地址的副本,并且一个核心修改了它的副本,其他缓存中的副本就过时了。这就是缓存不一致

保持所有缓存副本同步的问题就是缓存一致性问题。


缓存一致性协议 🤝

硬件如何保持缓存的一致性?每个缓存跟踪缓存中每个块的状态。

关键机制:总线窥探

  • 当任何处理器写入其缓存时,它通过互连网络(如总线)通知其他处理器。
  • 其他缓存“窥探”这个共享的互连,检查它们的标签。
  • 如果另一个缓存有相同地址的已修改副本,它会使自己的副本无效(或更新它)。

常见的缓存块状态(以MESI协议为例)

  • 修改(Modified, M):缓存行是脏的,与主存不同。只有这个缓存有这个数据的最新副本。
  • 独占(Exclusive, E):缓存行是干净的,与主存相同。只有这个缓存有这个数据的副本。
  • 共享(Shared, S):缓存行是干净的,与主存相同。多个缓存可能有这个数据的副本。
  • 无效(Invalid, I):缓存行中的数据无效。

工作流程示例

  1. 核心A和核心B都读取内存地址X,值都是10。两个缓存都进入 共享(S) 状态。
  2. 核心A要写入X,值改为20。它发出“使无效”信号。
  3. 核心B的缓存窥探到这个信号,将其副本状态改为 无效(I)
  4. 核心A现在可以写入其缓存,状态变为 修改(M)。主存中的值(10)现在是过时的。
  5. 如果核心B后来要读X,它会发出读请求。核心A窥探到请求,将它的修改值(20)写回主存(或直接发给核心B),然后两个缓存的状态都变回 共享(S),值都是20。

一个更优化的变体是 MOESI协议(修改、拥有、独占、共享、无效),它引入了“拥有者(Owner)”状态,可以更高效地在缓存之间传输修改的数据,而不必立即写回主存。


伪共享:隐藏的性能杀手 🐌

缓存一致性还导致一个微妙的问题:伪共享

即使两个处理器访问的是同一个缓存块中的不同变量,系统也会认为它们在共享整个块。如果一个核心修改了它那部分,会导致另一个核心的整个缓存线无效,即使后者访问的是不同数据。这会导致缓存线在两个核心的缓存之间频繁“乒乓”,造成大量一致性失效,严重损害性能。

如何预防伪共享?

  • 调整数据结构,让被不同线程频繁访问的变量位于不同的缓存行中。
  • 某些编程语言或库提供对齐声明来帮助实现这一点。
  • 意识到块大小是关键因素。块越大,两个无关变量落入同一块的可能性就越大。


总结 📚

本节课中我们一起学习了并行计算中两个高级主题:

  1. 原子操作与锁:我们了解到软件级别的锁尝试本身存在竞争条件。根本解决方案需要硬件支持的原子内存操作,如 amoswap,它能确保读-修改-写操作不可分割。我们用它实现了自旋锁,并看到了在OpenMP中更简便的锁和临界区用法。
  2. 缓存一致性:在多核共享内存系统中,每个核心的私有缓存会导致数据副本不一致。硬件通过缓存一致性协议(如MESI)来解决,主要使用总线窥探机制来使无效或更新其他缓存中的副本。我们还讨论了由此引发的伪共享问题,即无关变量因位于同一缓存行而导致的性能下降。

理解这些底层机制对于编写正确、高效的多线程程序至关重要。它们解释了为何并行编程有时比预期更复杂,也提供了解决问题的思路和工具。

课程 P42:第 32 讲 - 虚拟内存 I:简介 🧠💾

在本节课中,我们将要学习操作系统的一个核心概念——虚拟内存。我们将探讨为什么需要虚拟内存,它是如何工作的,以及它如何让多个程序安全、高效地共享有限的物理内存资源。


计算机系统概览 🖥️

上一节我们回顾了计算机的基本组成。本节中我们来看看现代计算机如何同时运行多个程序。

我们要开始放幻灯片了。幻灯片加载可能有些缓慢,但它们很快就会被张贴出来。

所以今天我们要多谈谈这台我们一直在讨论的电脑。在过去的几周里,我们讨论了这种快速访问数据的方式(缓存),它不需要一路走到主存。我们也讨论了线程,即一个程序(进程)如何利用多个核心,这被称为多线程或多处理。

但让我们后退一步,谈谈计算机本身。

那么我这么说是什么意思?让我们回顾一下经典的 CS61C 课程中的机器结构图。你会看到我强调了一些部分:内存(我们将在本单元更多讨论)以及 I/O 系统(I 代表输入,O 代表输出)。我们还要假设存在某个操作系统(如 Mac OS X、Linux)。你会发现这确实超出了 CS61C 的范围,所以我们要把这些对我们正在讨论的事情很重要的部分拿出来。你现在可以在 CS162(操作系统)课程中了解更多。

如果我们看一下新的学校机器结构图,我试着在这里加入了一点内容:这里没有真正的方框,但我试着放入了这个小方框。我们将讨论并行程序,即可以同时运行多个程序(例如幻灯片、Slack 或电子邮件)的想法,即使是在单个处理器上。这就是我们将在本单元讨论的内容。


课程项目回顾与定位 📚

到目前为止我们在哪里?我从项目方面概述了一下。项目四不在这里,请再忍耐一下。

以下是我们在本课程中已经完成的项目:

  • 项目一 是关于 C 程序的思想。事实上,我确实把项目一给了一位同行,因为它是关于线程 C 程序的。你有一个单一程序完成一件事,然后也许你可以多线程化它以更快完成。
  • 项目二 讨论了 RISC-V,一个精简指令集架构。我们编写 RISC-V 代码,C 代码被编译成汇编,然后汇编再编译成机器代码。
  • 项目三 是构建自己的 CPU。CPU 是处理器,是真正执行指令的东西。它与两个项目有交互:一个是缓存(用于存放最近使用的数据),如果数据不在缓存中,则需要去访问更远的主存。

在这一点上你可能会想:等等,这在 Venus 模拟环境中是合理的。我知道如果我启动 Venus 运行一个程序,它会运行然后停止。但我的笔记本电脑看起来完全不同。

假设左边是我的笔记本电脑。第一,它有视觉元素,不仅仅是代码,还有屏幕、键盘、存储设备。这就是我们所说的输入/输出。注意,存储也在这个范畴内,我一会儿要讲一点。第二,你的笔记本电脑可以运行多个程序,而 Venus(至少在我们的迭代中)不能。第三,有一种叫做操作系统的软件,负责管理和协调所有这些程序。所以我们要稍微谈谈操作系统是如何工作的,以及哪些资源需要在所有这些程序之间共享。


物理硬件:内存与存储 🧩

现在你可能有点想知道,为什么 I/O(比如磁盘)在哪里?让我们看看另一台电脑:那是带屏幕和键盘的笔记本电脑。让我们来看看树莓派。树莓派是一台超级便宜的电脑。如果你没有焊接所有不同的端口,主板大约 35 美元。如果你在上面焊接东西,就可以插入显示器等设备,然后运行像 Linux 这样的系统。

那么让我们看看这里的主板上有什么:

  1. 我们从 CPU 和缓存开始。这里有一个美元符号图标,代表缓存(因为“cash”听起来像“cache”)。
  2. 第二件事是内存。请注意,它与 CPU 是完全分开的,但焊接在同一块板上。
  3. 还有许多不同的 I/O 设备通过端口焊接。例如,我们有一个 USB 端口、一个以太网端口、无线 WiFi、一个迷你 HDMI 端口可以连接到屏幕。
  4. 最后一件事是存储 I/O:一张微型 SD 卡。这就是当我说存储也是一种 I/O 设备时的意思,因为它通常是通过某种端口连接的。

即使在笔记本电脑里,例如我的笔记本电脑有 256 GB 的存储空间,那和 8 GB 的 RAM 不一样。那么 CS61C 和树莓派怎么样?如果你感兴趣,实际上可以用树莓派覆盖 CS61C 中的所有概念。这将是一个很好的冬季项目。

那么我们在哪里?这是我们看到的原图,但这次我们要谈的是内存、I/O 设备,以及如何管理在许多几乎同时运行的程序之间共享的资源集。


操作系统简介 🛡️

所以这就是我们今天所处的位置。在接下来的两节课中,我将介绍操作系统的基础知识。我们有整整一学期(16周)来学习操作系统,我们只是要涵盖基本部分,这样你就知道它是如何相互作用的,以及它如何允许你管理程序。

让我们首先谈谈一个高级概念:上下文切换。

我要谈谈操作系统。对你们很多人来说,操作系统可能像是一个神奇的东西。原来操作系统只是软件,是特权软件,可以运行其他用户程序无法运行的特殊功能。

让我们看看操作系统的代码规模。在数百万行代码中,你可以看到 Linux 内核(早期版本)有两百万行代码,Windows 在 1993 年有五百万行,Android 操作系统有 1200 万行,而 2014 年的 Mac OS X 有八千六百万行代码。所以这是操作系统的大小,它可能是你机器上运行的最重要、也是最大的软件。

所以我告诉过你这很重要。以下是操作系统所做事情的概述:

  • 它是计算机启动时运行的第一个软件。它启动各种服务(如文件系统、网络栈、键盘等)。
  • 它提供了计算机上运行的 I/O 设备和程序之间的交互。
  • 它管理不同的程序。它是一个管理其他软件的软件。它加载程序(通过加载器),并告诉 CPU 接下来运行哪个程序。

这里有几个关键术语:

  • 隔离:操作系统使每个进程感觉它是孤立的,可以控制所有不同的资源(内存等)。
  • 资源共享:同时运行的进程共享内存、I/O 设备等。
  • 时间共享:操作系统负责在所有不同程序之间分时使用 CPU。

这就是我们所说的多道程序设计,不要与多处理混淆。多处理是指有多个 CPU。多道程序设计是指多个程序可以“同时”运行在同一个单核上。我为什么给“同时”加上引号?因为 CPU 一次只能运行一件事(暂不考虑指令级并行)。从人类的角度看,它们大致同时运行,因为操作系统进行非常快速的上下文切换

上下文切换有效地完成以下任务:它将当前进程的状态(程序计数器、寄存器等)保存在某个地方,然后将下一个进程的状态加载到 CPU 上。注意:上下文切换切换内存。因为切换所有内存内容(需要访问主存)太慢了。上下文切换只改变 CPU 中的内容。因此,关于进程如何共享内存,需要有一些特殊的东西。


内存与存储的区别 📊

我们想知道:如果我们有内存,并试图切换上下文,但又不想总是为所有可能同时运行的不同进程切换内存,该怎么办?但首先,让我们了解一下内存和磁盘的区别。

在这个内存层次结构的金字塔中,我们算是垫底了。这里我们讨论的是主存,它实际上是随机存取存储器。在它的正下方是二级内存,或我们所说的存储磁盘。金字塔告诉我们,所有在更高层的东西实际上都是下层东西的副本或子集。

在树莓派上,你可以想象内存是芯片本身(但与 CPU 分开),而存储(如微型 SD 卡)是一种 I/O 设备。

让我们来谈谈你可能看到的一些术语:

  • 主存:你会听到我叫它 DRAM,代表动态随机存取存储器。它相当快(约 10 纳秒),每 GB 约 3 美元。“动态”是指其物理成分(使用电容器存储电荷,需要定期刷新)。“易失性”是指 DRAM 需要恒定电源。
  • 缓存:使用 SRAM,静态随机存取存储器。“静态”意味着没有电容器,通过晶体管表示位。它更快(约 0.5 纳秒),但更贵,密度较低。
  • 磁盘/存储:这是非易失性的(拔掉电源数据还在)。最常见的两种是:
    • 固态硬盘:使用闪存技术,访问时间约 40-100 微秒,每 GB 约 5 美分。
    • 机械硬盘:有旋转盘片和机械臂,更便宜,访问时间约 5-10 毫秒(比 SSD 慢约 1000 倍)。

所以我们的内存是有物理限制的,存储(辅助内存)也有物理限制。因此,我们需要某种方法来管理这些物理上有限的资源,并在所有不同的进程之间共享它们。这就是所谓的虚拟内存


虚拟内存的动机 🎯

我将用两个用例来激发虚拟内存的需求,这两个用例在思考多道程序设计时最明显。

动机一:主存较小
与程序地址空间相比,如果主存很小怎么办?左边,我们有 RISC-V 32 位地址空间。这意味着最多可寻址 4 GB(2^32 字节)。程序期望可以使用所有这些空间。但是,如果你的 DRAM(主存)只有 1 GB 呢?那么你将只有 2^30 字节的可寻址内存,相差四倍。那么你怎么做?你想尽力做好什么?一个解决方案是尝试映射块,但可能空间不够。洞察是:正在运行的程序可能很小,并不真的需要所有的可寻址内存。那么你如何计算出哪些字节要保留在实际内存中?

动机二:多个程序访问相同内存地址
假设我们有 4 GB RAM。你有两个程序,分别需要 1 GB 和 2 GB。但假设它们访问和覆盖相同的内存地址。例如,程序一(银行程序)将账户余额存储在地址 0x400,值为 42。程序二(游戏)将生命值存储在相同的地址 0x400,值为 10000。会发生什么?程序二覆盖了程序一的数据,这很糟糕。这就是我们所说的数据损坏。操作系统和虚拟内存需要处理这个问题:保护进程不受其他进程影响,使它们有不同的空间,甚至不知道彼此的存在,不会干扰彼此的运行和数据访问。


虚拟内存:概念与定义 📖

我们有……好吧,谈谈定义。虚拟内存是一个想法:进程拥有完整内存地址的想法或错觉。它是孤立的(不知道有其他进程在运行),并且认为它有足够的内存地址空间。这种管理解决了两件事:

  1. 保护:避免进程被其他进程损坏。
  2. 内存有限:假设我们只有 4 GB RAM,那么进程需要访问的其他地址就在磁盘中的某个地方。RAM 中存放正在使用的信息,暂时不用的放在磁盘上。

虚拟内存的好处:

  1. 按需分页:程序运行时,逐渐将更多信息从磁盘加载到内存。如果暂时不运行,内存可能被分配给其他进程。
  2. 保护:进程之间隔离。
  3. 抽象机器配置:即使不同电脑内存大小不同,程序都相信它们有完整的地址空间(如 2^32 字节)。

今天所有这三个好处中,哪个最有用?事实证明,实际上是不同进程之间的保护。RAM 变得如此之大,进程共享内存实际上没什么大不了的,但它们绝对不应该互相覆盖。

现在是术语课。我说过地址空间。现在我们实际上有两个地址空间:

  • 虚拟地址空间:这是 RISC-V 的世界,每个用户程序都认为它有从 0x00000000 到 0xFFFFFFFF 的地址空间。
  • 物理地址空间:这是可用的实际内存字节地址。

程序总是在虚拟地址空间中运行。有某种转换器(或内存管理器)将所有虚拟地址访问转换为物理地址访问(到内存或磁盘)。

让我用一个图表来说明这个想法。假设左边有多个进程同时运行。它们都认为有自己的虚拟地址空间(包括栈、堆、代码、数据)。但引擎盖下发生的事情是,这三个虚拟地址副本都映射到相同的、可能小得多的物理内存中。这个转换器(操作系统)所做的就是将这些虚拟地址映射到物理地址。目前,图中显示为连续的块,但实际上不是。这正是我们接下来要讨论的。


虚拟内存的工作原理:分页 🗂️

虚拟内存的概念真的很详细。但为了理解虚拟内存,我想让你回到两周前,忘掉你所学到的关于缓存的一切。现在的抽象是:如果 CPU 想要访问内存,它必须一直到主存(或磁盘),这里没有中间缓存。这会让下一节课更容易。然后我们将带回缓存并展示它是如何工作的。

虚拟内存管理器(转换器)有三个职责:

  1. 为每个进程将虚拟地址转换为物理地址。
  2. 有效地利用资源(管理内存和磁盘之间的权衡)。
  3. 实现保护,启用内存访问的排序,使每个进程隔离。

让我们来谈谈分页。虚拟内存实际上是在缓存出现之前发明的,所以它使用了一个稍微不同的术语。分页的意思是:内存中的所有东西实际上都是从磁盘中的某个东西复制和加载的。当任何东西从磁盘加载时,它实际上是以为单位加载的,页的大小挺大(现代操作系统上约 4 KB)。因此,如果你有 4 KB 的数据,加载到内存中,如何访问每个字节?你需要一个地址。你需要 12 位来索引每个字节(因为 4 KB = 2^12 字节)。我们称这 12 位为页内偏移

为什么这有用?第一步,我们希望内存管理器将虚拟地址转换为物理地址。诀窍是:让物理页和虚拟页的大小完全相同。翻译就变成了:哪个虚拟页码映射到哪个物理页码?然后地址的低位(页内偏移)都是一样的。这使得我们可以非常有效地进行翻译,只需要查找映射关系。

让我们来看一个例子。我们有一个页表,它是我们的查找表。假设我们要将一个字节加载到寄存器中,我们想通过 lw 指令加载地址 0xFFFFF001。程序在虚拟地址空间中工作,认为这确实是字节的地址。它尝试访问内存,然后操作系统偷偷翻译它。

计算机(实际上是操作系统)分几个步骤将这个虚拟地址转换为物理地址:

  1. 提取虚拟页码。对于 32 位地址空间,每页 4 KB(2^12 字节),页码将是前 20 位。
  2. 查询页表。页表是一个巨大的查找表,它说:对于这个虚拟页码,是哪个物理页码?假设我们查到物理页码是 0(第一页)。
  3. 确定页内偏移量。这是虚拟地址的较低的 12 位(0x001)。
  4. 形成物理地址。将物理页码(前 20 位)和原始页内偏移量(低 12 位)连接起来。在这个例子中,物理地址可能是 0x00000001
  5. 访问物理内存中的该地址,将数据返回给程序。

再举一个例子:假设程序要加载地址 0x00001030(十进制 4144)。同样,提取前 20 位作为虚拟页码(0x00001)。查询页表,发现它不在物理内存中(页表项可能标记为无效或指向磁盘)。那么操作系统会做什么?它会访问磁盘,将包含该数据的页加载到内存中,更新页表(现在该虚拟页映射到新的物理页,比如第二页),然后从内存中读取数据返回给进程。这里有两个内存访问:一个到磁盘,一个到主存。


页表与保护机制 🔒

那么页表是什么样子的?每个进程都有自己的页表。对于 32 位地址空间,4 KB 页,我们有 2^20 个虚拟页码。页表为每个虚拟页码设置一个条目。每个条目可能包含:物理页码(如果在内存中)、磁盘地址(如果不在内存中),以及一些状态位(如有效位、读写权限位)。

重要提示:页表不是缓存。它没有空间局部性的概念,它为每个虚拟页码都有一个条目。

这如何帮助实现保护?如果两个进程访问相同的内存,会发生什么?每个进程都有自己的页表。这些页表中的条目可以映射到物理内存中的相同页码。例如,系统数据可能被两个用户进程共享,并且都设置为只读。

这是如何通过状态位实现保护的呢?如果状态位正确,页面就受到保护。如果两个程序看到这个虚拟页,它们只能从该页读取。如果它们试图存储到那个页面,操作系统就会触发一个异常,不允许事情发生,甚至可能中止该进程。


总结 📝

本节课中我们一起学习了虚拟内存的基础知识。我们探讨了为什么需要虚拟内存(解决内存有限性和进程保护问题),介绍了虚拟地址空间和物理地址空间的概念。我们了解了虚拟内存通过页表将虚拟地址转换为物理地址的基本原理,以及如何使用分页机制在内存和磁盘之间交换数据。最后,我们看到了页表如何为每个进程提供独立的映射,从而实现进程间的隔离和保护。下节课我们将更深入地讨论虚拟内存的细节及其与缓存的交互。

记得投票,谢谢!

课程 P43:并行性、一致性及原子操作 🚀

在本节课中,我们将学习并行计算中的几个核心概念:线程级并行、数据级并行、缓存一致性以及原子操作。我们将通过具体的例子和公式来理解这些概念,并学习如何在代码中应用它们。


概述 📋

本节课将涵盖并行计算的基础知识。我们将首先探讨数据级并行,特别是单指令多数据技术。接着,我们会讨论线程级并行及其在实际编程中的应用。最后,我们会简要介绍缓存一致性的概念,以及为什么在多线程环境中保持数据一致性至关重要。


数据级并行与SIMD 🧮

上一节我们介绍了并行计算的基本概念,本节中我们来看看数据级并行。数据级并行是指一条指令同时操作多个数据元素的技术,这通常通过单指令多数据实现。

SIMD代表单指令多数据。它是一种指令级并行技术,允许我们对多个数据执行相同的操作。例如,如果我们有一个长数组,需要在每个元素上执行相同的计算,SIMD就能发挥作用。

以下是SIMD指令的一些关键点:

  • SIMD指令使用特殊的宽寄存器,例如可以容纳四个整数值或四个浮点值的128位寄存器。
  • 这些指令允许我们专门使用这些寄存器来加速向量化计算。

在代码中,SIMD操作可能看起来像是对寄存器进行“类型转换”,但这实际上是指定使用特定寄存器的方式,并非真正的数据类型转换。例如,__m128i 表示一个128位的整数向量寄存器。

SIMD优化示例

假设我们有一个函数,需要计算一个整数数组中所有元素的乘积。我们可以使用SIMD指令来优化它。

原始标量代码可能如下:

int product(int* a, int n) {
    int prod = 1;
    for (int i = 0; i < n; i++) {
        prod *= a[i];
    }
    return prod;
}

使用SIMD优化的思路是,每次循环处理四个元素。以下是优化步骤:

首先,我们需要处理数组中可以按4个元素一组进行迭代的部分。

for (i = 0; i < (n / 4) * 4; i += 4) {
    // SIMD操作:一次加载并计算4个元素的乘积
}

这里 (n / 4) * 4 是为了得到小于等于 n 的最大4的倍数,确保我们处理的是完整的SIMD宽度。

接着,我们需要一个“尾部循环”来处理剩余的元素(当 n 不是4的倍数时)。

for (; i < n; i++) {
    // 用标量方式处理剩余元素
}

在SIMD循环内部,我们需要使用特定的指令。例如,使用 _mm_load_si128 指令从内存加载4个整数到SIMD寄存器。

__m128i vec_a = _mm_load_si128((__m128i*)(a + i)); // 加载 a[i] 到 a[i+3]

然后,使用乘法指令(如 _mm_mullo_epi32)来计算这4个元素的乘积向量。最后,需要将SIMD寄存器中的4个部分积存储到内存中,以便后续合并。

int partial_prods[4];
_mm_store_si128((__m128i*)partial_prods, prod_vec); // 存储部分积
for (int j = 0; j < 4; j++) {
    result *= partial_prods[j]; // 合并部分积
}

线程级并行 🧵

在理解了数据级并行后,我们来看看线程级并行。TLP涉及在多个软件线程上同时执行代码,通常使用像 #pragma omp parallel 这样的编译指令。

#pragma omp parallel 指令可以放在一个代码块之前,指示编译器在多个线程上运行该代码块。#pragma omp parallel for 指令则专门用于并行化紧随其后的for循环,将循环迭代分配到不同线程中。

然而,使用TLP时需要格外小心数据依赖性和正确性。

并行化案例分析

以下是几个并行化场景的分析:

  1. 无效并行化:如果一个循环的每次迭代都计算整个数组的乘积,那么使用 #pragma omp parallel 包裹整个循环会导致每个线程都重复计算全部工作,这比串行执行更慢。
  2. 存在数据依赖:对于类似计算斐波那契数列的循环(a[i] = a[i-1] + a[i-2]),迭代之间存在严格的数据依赖。如果强行并行化,线程执行顺序不确定,几乎总会得到错误结果。
  3. 可安全并行化:对于像 a[i] = a[i] * b[i] 这样的循环,每次迭代只读写独立的数组元素 a[i],没有跨迭代依赖,因此可以安全地使用 #pragma omp parallel for 进行并行化。
  4. 指针操作风险:在并行区域内递增一个共享指针(如 ptr++)是危险的,因为多个线程会竞争修改该指针,导致其值不可预测,从而访问错误的内存地址。

缓存一致性 ⚙️

当我们在多核处理器上运行多线程程序时,每个核心通常有自己的缓存。这就引出了缓存一致性问题:如何确保不同缓存中的同一数据副本保持同步和最新?

缓存一致性协议通过为每个缓存行维护一个状态来实现。一个常见的简化模型使用三位元数据来描述状态:

  • 有效位:指示该缓存行是否包含有效数据。
  • 脏位:指示该缓存行中的数据是否已被修改,且与主内存中的数据不同。
  • 共享位:指示其他缓存中是否也存在该数据块的副本。

基于这些位的组合,缓存行可以处于多种状态,例如:

  • 修改:数据仅在此缓存中,且已被修改,主内存中的数据是过时的。
  • 独占:数据仅在此缓存中,但与主内存一致。
  • 共享:数据在此缓存中,也可能在其他缓存中,所有副本与主内存一致。
  • 无效:该缓存行数据无效。

当某个核心修改了其缓存中的数据时,一致性协议会通过总线或其他互联机制通知其他持有该数据副本的缓存,将它们的状态置为“无效”,或更新它们的数据。这确保了所有处理器看到的内存视图是一致的。


阿姆达尔定律 📈

在讨论并行加速时,阿姆达尔定律是一个重要概念。它描述了优化部分代码后,程序整体所能获得的最大加速比。

定律的公式如下:

整体加速比 = 1 / ((1 - P) + (P / S))

其中:

  • P 是程序可并行化部分所占的执行时间比例。
  • S 是可并行化部分因优化而获得的加速比。

例如,假设某个函数占程序总运行时间的30%(P=0.3),我们通过并行化使其速度提升了2倍(S=2)。那么,程序整体的加速比为:

整体加速比 = 1 / ((1 - 0.3) + (0.3 / 2)) = 1 / (0.7 + 0.15) ≈ 1.176

即整体性能提升了约17.6%。这个定律表明,即使我们极大地优化了部分代码,程序的串行部分(1-P)也会成为性能提升的瓶颈。


总结 🎯

本节课我们一起学习了并行计算的关键概念。

  • 我们探讨了数据级并行,了解了如何使用SIMD指令对多个数据执行相同操作来提升性能。
  • 我们研究了线程级并行,学习了如何使用OpenMP等工具并行化代码,并分析了哪些循环可以安全并行化。
  • 我们介绍了缓存一致性的基本原理,理解了多核系统中保持数据一致性的重要性。
  • 最后,我们通过阿姆达尔定律了解到,程序的整体加速受限于其串行部分的比例。

掌握这些概念对于编写高效、正确的并行程序至关重要。

课程 P44:第33讲:虚拟内存 II:缺页、多级页表、中断/异常 🧠💾

在本节课中,我们将深入学习虚拟内存的核心机制。我们将探讨当所需数据不在内存中时会发生什么(缺页),如何通过多级页表更高效地管理内存,以及操作系统如何通过中断和异常机制来接管处理。这些概念共同构成了现代计算机内存管理的基础。


回顾:虚拟内存与页表

上一节我们介绍了虚拟内存的基本概念,特别是页表的作用。现在我们来回顾一下核心要点。

即使计算机有多个处理器,我们通常也会运行成百上千个进程。操作系统通过上下文切换在可用的CPU核心上复用这些进程。然而,所有进程共享同一块物理内存(DRAM)。

虚拟内存的概念解决了这个问题。每个进程都认为自己拥有完整的、独立的地址空间(例如,在RISC-V中是2^32字节)。操作系统通过为每个进程维护一个页表,将进程的虚拟地址空间映射到共享的物理地址空间。

页表的核心功能是将虚拟页号(VPN) 转换为物理页号(PPN)。地址中的页内偏移量在转换过程中保持不变。

物理地址 = (物理页号 << 偏移量位数) | 页内偏移量

这种机制实现了进程间的隔离与保护。页表项中还包含保护位,可以控制进程对共享页面的读写权限。

为了简化当前讨论,我们暂时假设没有缓存,每次内存访问都直接与DRAM交互。


缺页处理 🔄

当进程试图访问的数据不在物理内存(DRAM)中,而是存储在磁盘上时,就会发生缺页。这是虚拟内存系统的关键事件之一。

页表项中的有效位指明了对应的数据页是否已加载到物理内存中。如果有效位为“无效”,则表示该页目前在磁盘上。

以下是缺页发生时的处理步骤:

  1. 触发缺页:CPU在访问内存时,通过页表发现目标页的有效位为无效。
  2. 操作系统介入:CPU触发一个异常,将控制权交给操作系统内核(运行在监管模式下)。
  3. 选择牺牲页:物理内存空间有限。如果内存已满,操作系统必须选择一个页面驱逐回磁盘。这涉及到页面替换策略(如LRU、FIFO等),该策略由软件实现。
  4. 执行换入:如果需要,先将选中的“牺牲页”写回磁盘(如果它是脏的,即被修改过)。然后,将所需的页面从磁盘读入物理内存的空闲位置。
  5. 更新页表:操作系统更新页表项,将其有效位置为“有效”,并填入新的物理页号。
  6. 重新执行指令:操作系统退出异常处理,CPU重新执行那条触发缺页的指令。此时,数据已在内存中,访问得以正常进行。

脏位:页表项中的另一个重要状态位。它标识该页自被调入内存后是否被修改过。如果被修改过(脏位为1),在它被驱逐出内存前,必须写回磁盘以保持数据一致性;如果未被修改(脏位为0),则可以直接丢弃,因为磁盘上已有副本。


多级页表结构 🏗️

单一的线性页表可能会占用过多内存。以一个32位地址空间、4KB页面的系统为例,每个进程的页表可能有2^20个条目,每个条目4字节,即占用4MB内存。如果有256个进程,仅页表就要消耗1GB内存,这显然效率低下。

然而,进程的地址空间使用通常是稀疏的,即它不会使用所有可能的虚拟页面。多级页表利用了这一特性,只为实际使用的地址区域创建页表,从而节省内存。

在RISC-V(rv32)架构中,虚拟地址被划分为多段用于多级查找。例如一个两级的页表结构:

虚拟地址 [31:22] | [21:12] | [11:0]
         一级索引    二级索引    页内偏移

以下是其工作原理:

  1. 一级页表:由CPU中的一个特殊寄存器(如satp,页表基址寄存器)指向。它包含多个条目,每个条目要么指向一个二级页表的物理页号,要么表示该区域未使用。
  2. 二级页表:由一级页表项引用。它的条目中存储着最终数据页的物理页号,以及有效位、保护位、脏位等信息。
  3. 地址翻译流程
    • CPU用虚拟地址的高10位(一级索引)查找一级页表,得到二级页表的物理页号。
    • 用虚拟地址的中间10位(二级索引)查找该二级页表,得到目标数据页的物理页号。
    • 将得到的物理页号与虚拟地址的低12位(页内偏移)组合,形成最终的物理地址。

多级页表的优势

  • 节省内存:只需为进程中实际使用的虚拟内存区域分配二级页表。大量未使用的地址区域在一级页表中只需一个“无效”条目即可表示。
  • 灵活管理:二级页表可以像数据页一样被换入换出磁盘,而只需保证一级页表常驻内存,进一步减少了内存常驻开销。

示例计算对比

  • 单级页表:16个进程,每个页表4MB,共需 64MB 内存存放页表。
  • 两级页表:一级页表仅需2^10个条目 * 4字节 = 4KB。16个进程的一级页表仅需 64KB。内存占用减少了约1000倍。


中断与异常机制 ⚙️

操作系统需要一种机制来响应像缺页这样的突发事件,并安全地接管CPU执行。这依赖于CPU的特权模式异常/中断机制。

用户模式 vs 监管模式

  • 用户模式:大多数用户进程运行在此模式下。在此模式下,CPU执行指令的权限受到限制,例如不能直接执行某些特权指令或访问所有物理内存。
  • 监管模式(内核模式):操作系统内核运行在此模式下。拥有最高权限,可以执行所有指令,访问所有内存,并管理硬件资源。从用户模式切换到监管模式通常由异常或中断触发。

异常

异常是同步事件,由当前正在执行的指令直接触发。例如:

  • 非法指令异常
  • 除以零异常
  • 缺页异常

当异常发生时:

  1. CPU会完成异常指令之前的所有指令。
  2. 将后续指令冲刷掉(在流水线CPU中)。
  3. 保存当前进程的上下文(如程序计数器PC)。
  4. 切换到监管模式,并跳转到预设的异常处理程序(一段操作系统代码)执行。
  5. 处理程序(例如,缺页处理程序)执行必要的操作(如从磁盘加载页面)。
  6. 操作完成后,恢复之前保存的上下文,切换回用户模式,并重新执行那条触发异常的指令。

中断

中断是异步事件,由外部硬件设备触发,与当前执行的指令无关。例如:

  • 定时器中断(用于时间片轮转调度)
  • 磁盘I/O完成中断
  • 网络数据包到达中断

中断的处理流程与异常类似,也会导致CPU切换到监管模式执行相应的中断服务程序。中断是操作系统实现多任务和响应外部事件的基础。

通过异常和中断,操作系统得以透明地管理硬件、处理错误,并为用户进程提供“无限内存”的抽象。进程无需关心数据是否在磁盘上,也感知不到自己被挂起等待I/O,这一切都由操作系统在背后通过特权模式切换来完成。


总结 📚

本节课我们一起深入探讨了虚拟内存系统的三个核心高级机制:

  1. 缺页处理:当访问的数据不在内存时,操作系统通过异常机制介入,从磁盘换入所需页面,并可能换出其他页面。有效位脏位在此过程中起着关键作用。
  2. 多级页表:为了解决单级页表内存消耗过大的问题,引入了层次化页表结构。它利用地址空间的稀疏性,极大地减少了页表本身的内存占用,是现代系统的标准设计。
  3. 中断与异常:这是操作系统获得CPU控制权的硬件基础。用户/监管模式的划分提供了必要的安全隔离,而异常(同步)和中断(异步)使得操作系统能够处理内部错误和外部事件,实现了对硬件资源的统一、安全管理。

这些机制共同工作,使得虚拟内存系统不仅高效、节省物理资源,而且安全、透明,为每个进程提供了稳定独立的运行环境。在下节课中,我们将把缓存重新引入这个体系,探讨缓存与虚拟内存如何协同工作。

课程 P45:Lecture 34: VM III: TLB 🧠💾

在本节课中,我们将学习虚拟内存(VM)的第三个核心部分:转换后备缓冲区(TLB)。我们将探讨TLB如何作为页表的缓存,以加速地址转换过程,并理解它与CPU缓存、异常处理以及上下文切换之间的关系。


概述:虚拟内存与性能挑战

上一节我们深入探讨了虚拟内存的页表结构。本节中,我们将退一步思考如何高效地实现虚拟内存,使其与操作系统其他部分(特别是我们之前搁置的缓存概念)协同工作。核心挑战在于:每次内存访问都需要进行虚拟地址到物理地址的转换,如果每次都访问内存中的页表,性能开销将非常大。


陷阱处理程序回顾 🚨

在深入TLB之前,我们需要简要回顾陷阱处理程序(Trap Handler)的工作机制,因为TLB未命中和页面错误等事件都需要它来处理。

陷阱处理程序是一个运行在监管模式(Supervisor Mode)下的程序,用于处理中断(Interrupt)异常(Exception)

  • 中断与当前程序执行异步发生(例如,键盘输入、鼠标点击)。
  • 异常与当前程序执行同步发生(例如,页面错误、无效指令)。

以下是陷阱处理程序的工作步骤:

  1. 完成与刷新:CPU完成故障指令之前的所有指令,并刷新故障指令之后所有未完成的指令(类似于处理流水线冒险时清空流水线)。
  2. 保存状态:陷阱处理程序保存当前进程的状态(所有寄存器、程序计数器PC等),以便后续可能恢复。
  3. 诊断原因:检查是何种异常或中断(例如,查看故障指令及其在流水线中的阶段)。
  4. 处理事件:根据原因执行相应操作(例如,从磁盘加载页面、终止程序)。
  5. 恢复或终止
    • 可能恢复原始程序执行(恢复保存的状态,CPU切换回用户模式)。
    • 也可能终止程序(释放其占用的所有资源)。

陷阱处理程序的应用实例包括:

  • 上下文切换(Context Switch):由定时器中断触发。处理程序保存当前进程的PC和页表寄存器(PTR)等状态,然后加载下一个进程的状态,实现进程快速切换。
  • 页面错误(Page Fault):当访问的页面不在DRAM中时触发。处理程序从磁盘加载所需页面到内存,更新页表,并可能在此期间切换到其他进程执行。
  • 系统调用(System Call):程序主动请求操作系统服务(如文件操作、malloc)。通过执行类似ecall的指令触发陷阱,陷入内核(监管模式)执行服务。


缓存与需求分页:术语对比 🔄

为了理解TLB,我们需要将缓存(Cache)的概念与虚拟内存的需求分页(Demand Paging)进行对比。两者都涉及将数据从低速存储器复制到高速存储器,但使用的术语和规模不同。

以下是核心概念的对比:

特性 缓存 (Cache) 需求分页 (Demand Paging)
内存单元 块(Block),较小(如64字节) 页(Page),较大(如4KB)
未命中术语 缓存未命中(Cache Miss) 页面错误(Page Fault)
映射策略 直接映射、组相联、全相联 通常使用全相联(页可放入内存任何位置)
替换策略 LRU(最近最少使用)、随机等 LRU、FIFO、随机等
写策略 写直达(Write-Through)、写回(Write-Back) 写回(Write-Back)

关键理解:在内存层次结构中,DRAM可以看作是磁盘页面的缓存。而页表本身并不是缓存,它只存储物理页码和状态位等映射信息,并不存储实际数据。


转换后备缓冲区(TLB) ⚡

地址转换是虚拟内存的核心,但每次访问都查询内存中的页表(页表遍历,Page Table Walk)太慢。例如,单级页表需要两次内存访问(一次查页表,一次取数据),多级页表则需要更多。

解决方案:为地址转换专门设立一个缓存——转换后备缓冲区(Translation Lookaside Buffer, TLB)

TLB 是什么?

TLB是一个小型、快速的硬件缓存,存储的是最近使用过的页表项(Page Table Entries, PTEs)

  • TLB 命中:虚拟页码在TLB中找到对应的PTE,转换仅需1个周期
  • TLB 未命中:需访问内存中的页表进行页表遍历,找到PTE后将其载入TLB。

TLB 与缓存的数据流

在61C的模型中,我们采用物理索引、物理标记(Physically Indexed, Physically Tagged)的缓存策略。这意味着:

  1. 先进行地址转换(通过TLB),得到物理地址。
  2. 再用物理地址访问数据缓存

访问顺序流程图如下:

CPU发出虚拟地址 (VA)
        ↓
    查询 TLB
        ↓
      /     \
  命中       未命中 (触发页表遍历)
    ↓               ↓
获取物理地址(PA)  从内存页表获取PTE并载入TLB
        ↓               ↓
用PA访问数据缓存       返回步骤“查询TLB”
        ↓
   命中/未命中
        ↓
  返回数据/从内存载入块

TLB 与保护机制

TLB在转换地址的同时,也负责实施内存保护(Protection)。PTE中的状态位(如读/写/执行权限)也保存在TLB中。如果访问违反权限(例如,尝试写入只读页),TLB会触发保护错误异常,由陷阱处理程序处理(通常导致段错误)。

上下文切换时的TLB

每个进程有自己的页表。当发生上下文切换时,新进程的虚拟地址映射关系不同。因此,操作系统通常会在切换时使旧进程的所有TLB条目失效(或给TLB条目打上进程ID标签)。新进程开始时TLB为空,随着执行逐渐填充,这被称为TLB冲刷(TLB Flush)


总结 🎯

本节课我们一起学习了虚拟内存的关键性能优化组件——转换后备缓冲区(TLB)。

  • 我们回顾了陷阱处理程序如何作为操作系统内核的入口,处理异常、中断和系统调用,并管理上下文切换与页面错误。
  • 我们对比了缓存需求分页的术语,理解了它们在不同内存层次上优化访问速度的相似理念。
  • 我们深入探讨了TLB作为页表缓存的核心角色。它通过缓存最近使用的页表项,将频繁的地址转换操作从缓慢的内存访问变为快速的缓存查询,极大提升了虚拟内存系统的性能。
  • 我们了解了地址转换的典型数据流(先TLB转换,后缓存访问),以及TLB如何与保护机制、上下文切换协同工作。

TLB是硬件与操作系统软件紧密配合以提升系统性能的完美例证。掌握TLB的工作原理,对于理解现代计算机如何高效、安全地管理内存至关重要。

课程 P46:第35讲 - 虚拟内存性能与I/O设备 🚀

在本节课中,我们将要学习如何评估虚拟内存系统的性能,并了解计算机如何与各种输入/输出设备进行交互。我们将从缓存与虚拟内存的对比开始,逐步深入到I/O设备的连接机制、数据传输方式,并最终理解现代计算机如何通过网络进行通信。

虚拟内存性能评估 🔍

上一节我们介绍了虚拟内存系统的工作原理和实现。本节中,我们来看看如何评估它的性能。评估虚拟内存性能的原则与评估缓存性能的原则相同。

以下是缓存系统与虚拟内存系统的关键参数对比:

  • 缓存块/行 对应于虚拟内存系统中的 页面
  • 缓存未命中 对应于 页面错误
  • 缓存块大小通常为32到64字节,而典型页面大小为KB级别,有时更小或更大(如8到16KiB),但通常处理4KiB页面。
  • 缓存中的放置策略可以是直接映射、组相联或全相联。在虚拟内存系统中,页面放置通常是 全相联 的。
  • 缓存中的替换策略可以是最近最少使用或随机等。在虚拟内存系统中,通常希望使用 最近最少使用 策略,但有时会用先进先出或随机策略来近似。
  • 缓存可以选用直写或回写策略。在虚拟内存系统中,我们只使用 回写,因为写入磁盘的惩罚非常高。

性能计算

虚拟内存是位于主存之下的一个内存层级。在我们之前的计算中,一切停止在主存级别。现在,虚拟内存通过分页将DRAM扩展到了磁盘中。

因此,每条指令的周期和平均内存访问时间的计算方式将被沿用,但这次我们将主存视为某种中级缓存。我们需要检查缓存命中/未命中,然后可能命中RAM或最终访问磁盘。

以下是我们关心的一些参数:

  • 在需求分页中,我们处理的是 页帧,而缓存处理的是32到64字节的块。
  • 缓存未命中率通常是个位数百分比(例如L1缓存为1%-20%)。而 缺页率必须低得多,通常为万分之一或十万分之一甚至更低。
  • 缓存命中通常在1个周期内完成。缓存未命中(对应访问RAM)需要几十到上百个周期。页面错误(对应访问磁盘)则可能需要约500万个时钟周期。

让我们看看分页对平均内存访问时间的影响。假设以下参数:

  • L1缓存:1个周期,命中率95%。
  • L2缓存:10个周期,命中率为剩余访问的60%(即总访问的 5% * 60% = 3%)。
  • 主存访问:100个周期(对应L2未命中,即总访问的 5% * 40% = 2%)。
  • 缺页(访问磁盘):10毫秒,假设为2千万个周期。

不分页时的平均内存访问时间(AMAT) 计算公式为:
AMAT = L1命中时间 + L1未命中率 * (L2命中时间 + L2未命中率 * 主存访问时间)
代入数值:1 + 0.05 * (10 + 0.4 * 100) = 1 + 0.05 * 50 = 3.5 个周期。
(注:原文计算为5.5,此处根据所述参数修正为3.5,教程保留原文逻辑进行后续计算)

添加分页后的平均内存访问时间 需要在上述基础上,加上访问主存时发生缺页的惩罚。公式为:
AMAT_with_paging = AMAT + (L2未命中率) * (1 - 内存命中率) * 缺页惩罚
其中,(1 - 内存命中率) 即缺页率。

让我们看看不同内存命中率下的影响:

  • 如果内存命中率为 99%(缺页率1%):5.5 + 0.02 * 0.01 * 20,000,000 = 5.5 + 4000 ≈ 4005.5 周期。这比原来慢了约700倍,性能非常差,这种情况被称为 系统颠簸
  • 如果内存命中率为 99.9%(缺页率0.1%):5.5 + 0.02 * 0.001 * 20,000,000 = 5.5 + 400 = 405.5 周期。仍然很慢。
  • 如果缺页率为 万分之一(0.01%):5.5 + 0.02 * 0.0001 * 20,000,000 = 5.5 + 40 = 45.5 周期。这个数字更为合理。

由此可见,极低的缺页率对于虚拟内存系统的性能至关重要。

I/O设备连接原理 💻

现在我们已经基本清楚了虚拟内存的工作原理,为了完成计算机系统,我们需要添加I/O设备。这样我们就可以连接键盘、鼠标、显示器,或许还可以连接到网络。

这里的关键是理解I/O设备如何连接的原则,这样我们就不必为每个设备编写特殊的程序。设备通常通过总线层次结构连接,我们可以将它们视为连接到处理器-存储系统的抽象实体。

设备与外界通信通常通过标准化接口,该接口由命令和状态寄存器以及数据寄存器组成。操作系统检查设备状态(是否准备好通信),然后编排进程如何访问这些设备。

内存映射I/O

我们如何通过指令集架构来支持这种I/O接口呢?有两种选择:

  1. 设计特殊的I/O指令。
  2. 使用内存映射I/O

现代系统通常采用第二种方式。在内存映射I/O中,地址空间的一部分(通常是低地址区域)被保留用于I/O。这些地址不对应于实际的DRAM,而是映射到各个I/O设备的命令、状态和数据寄存器。程序可以使用正常的加载和存储指令来访问这些地址(如果被允许),或者通过系统调用来访问。RISC-V就是这样做的。

例如,地址 0x000000000x7FFFFFFF 可能用于内存映射I/O,而程序和数据内存从 0x80000000 开始。

设备速度差异

需要记住的一点是,外部设备可能以非常不同的速度运行。一个现代处理器每秒可以进行数十亿次加载/存储操作,但许多设备无法匹配这个速度。

  • 键盘:输入速度约为每秒几个字节。
  • 音频设备:每秒几百KiB到几MB。
  • WiFi/以太网/硬盘:速度更高,但通常仍比处理器内存带宽低一个数量级,除非是Thunderbolt等高速接口。

常见的I/O设备通常无法以处理器速度传递或接收数据,因此我们需要有办法来适应这种速度差异。

I/O数据传输方式:轮询与中断 🔄

我们如何与设备交互呢?程序在CPU上运行,它可能想打印一些东西或接收键盘输入。在轮询方式中,处理器反复或定期检查设备的控制寄存器(其中的“就绪”位)。如果设备准备好,处理器就进行读写操作。

以下是一个轮询输入设备的简化代码示例(假设内存映射地址):

poll_input:
    lw t0, input_control_addr  # 加载输入控制寄存器值
    andi t0, t0, 1             # 检查就绪位(最低有效位)
    beq t0, zero, poll_input   # 如果未就绪,继续轮询
    lw a0, input_data_addr     # 就绪,加载数据
    # ... 处理数据 ...

输出设备的轮询逻辑类似,只是检查输出控制寄存器,并在设备就绪时写入数据。

轮询简单,但可能效率低下。让我们估算一下成本:假设一次轮询操作需要400个时钟周期。

  • 对于鼠标(每秒轮询30次):消耗 30 * 400 = 12,000 周期/秒。在10亿Hz的处理器上,这只占 0.0012% 的周期,负担很小。
  • 对于磁盘(假设每秒产生16MB数据,每次轮询获取16字节):需要每秒 1,048,576 次轮询。这将消耗 1,048,576 * 400 ≈ 419 million 周期/秒。在10亿Hz的处理器上,这占用了约 42% 的周期,使得处理器很难做其他事情。

因此,对于产生大量数据的设备,轮询不是好方法。更好的机制是中断

中断类似于门铃。当I/O设备有数据要发送或有事件要通知时,它会“按门铃”——即发出一个中断信号。这会使当前程序暂停,CPU将控制权转移给操作系统中的陷阱处理程序来处理该设备。处理完后,再恢复原程序。

中断的好处是,当设备没有活动时,CPU不会被浪费。它适用于键盘、鼠标等低速率设备。然而,中断本身也有开销(保存/恢复状态、缓存/TLB影响),对于高速率设备,频繁中断也不高效。

直接内存访问 🚀

对于需要传输大量数据的设备(如磁盘、网络接口),更常用的方法是直接内存访问。DMA允许一个专门的引擎(DMA控制器)在CPU的监督下,直接在I/O设备和主存之间移动数据,而无需CPU介入每一个字节的传输。

工作流程如下:

  1. 启动:CPU通过写入DMA控制器的寄存器来启动传输。这些寄存器包含内存起始地址、传输字节数、设备标识和传输方向。
  2. 传输:DMA控制器接管,直接与I/O设备协作,将数据块从设备缓冲区搬移到指定内存地址(或反之)。在此期间,CPU可以自由执行其他任务。
  3. 完成:当传输完成后,DMA控制器会中断CPU,通知其操作已完成。

DMA引擎在内存层次结构中的位置有两种主要选择:

  • 位于CPU和L1缓存之间:优点是缓存一致性由硬件自动维护。缺点是DMA传输可能会破坏CPU缓存中当前的工作集。
  • 位于最后一级缓存和主存之间:不会干扰CPU缓存。但需要额外的硬件机制来维护缓存一致性。这是更常见的做法。

网络通信简介 🌐

我们之前讨论的设备主要处理单台计算机内部的I/O。通过网络连接计算机也遵循同样的原则。网络最初是为了共享打印机等设备,后来发展为在计算机间传输文件和数据。

数据通过网络以数据包的形式传输。软件应用程序将数据准备好,网络接口卡(NIC)使用DMA将数据从主存传输到网络,或者将接收到的数据包通过DMA存入主存。接收端会检查数据包的校验和,确认无误后发送确认信息,否则请求重传。

现代网络接口卡都支持DMA,这使得高速网络通信成为可能。

总结 📚

本节课中我们一起学习了:

  1. 虚拟内存性能:通过对比缓存,我们理解了评估虚拟内存性能的方法。关键点是缺页率必须极低,否则性能会因访问磁盘的极高延迟而急剧下降,导致系统颠簸。
  2. I/O设备连接:设备通过内存映射I/O统一接入系统,CPU通过读写特定内存地址与设备寄存器通信。
  3. 数据传输方式
    • 轮询:简单,但CPU利用率低,尤其不适合高速设备。
    • 中断:设备主动通知CPU,适合低速率、不规则事件。
    • 直接内存访问:专门的DMA控制器在设备和内存间直接搬运大数据块,极大解放了CPU,是处理高速I/O(如磁盘、网络)的主要机制。
  4. 网络通信:网络I/O同样基于上述机制(尤其是DMA),实现了计算机间的数据包交换。

通过本模块的学习,我们不仅理解了虚拟内存如何创造大容量、高速存储的幻觉,还掌握了计算机如何与丰富的外部世界进行高效交互,从而构成了一个完整的计算系统视图。

课程 P47:第36讲 📚 MapReduce、Spark、阿姆达尔定律与数据级并行

在本节课中,我们将学习并行计算中的几个核心概念:阿姆达尔定律、请求级并行、数据级并行,以及处理大规模数据的强大抽象——MapReduce及其现代实现Spark。

阿姆达尔定律 💔

上一节我们提到了并行计算,但在深入之前,我们需要理解一个限制并行加速的基本定律——阿姆达尔定律。

阿姆达尔定律描述了对系统某部分进行增强后,整体性能提升的理论上限。模型如下:假设一个任务中,有一部分代码无法被并行化(串行部分),其占总执行时间的比例为 S(0 < S < 1)。剩余部分(比例为 1 - S)是可以被并行加速的。如果我们对可并行部分施加一个加速因子 P(P > 1),那么整体的加速比 Speedup 可以通过以下公式计算:

Speedup = 1 / (S + (1 - S) / P)

当并行加速因子 P 趋近于无穷大(即拥有无限多的处理器)时,最大加速比趋近于 1 / S。这意味着,即使可并行部分被无限加速,整体速度也受限于串行部分的比例。

以下是几个关键点:

  • 串行部分 S 是性能提升的瓶颈。
  • 即使投入大量资源加速可并行部分,整体收益也会被串行部分迅速稀释。
  • 为了最大化并行收益,必须尽量减少代码中的串行开销。

例如,如果一个程序80%的执行时间可以加速16倍(即 S=0.2, P=16),代入公式:
Speedup = 1 / (0.2 + 0.8/16) = 1 / (0.2 + 0.05) = 1 / 0.25 = 4
最终整体只获得了4倍的加速,而非16倍。这就是阿姆达尔定律“令人心碎”之处。

请求级并行与数据级并行 ⚙️

理解了性能上限后,我们来看看两种常见的并行范式。

上一节我们介绍了阿姆达尔定律,本节中我们来看看两种不同层面的并行:请求级并行和数据级并行。

请求级并行 常见于Web服务器等场景。其特点是:

  • 大量独立的请求同时到达服务器。
  • 每个请求的处理过程在很大程度上是独立的。
  • 请求之间通常只涉及读取操作,很少涉及复杂的跨请求数据写入或同步。
  • 易于在不同请求间进行分区处理。

例如,谷歌的搜索查询服务体系结构就利用了请求级并行。用户查询被分发到多个索引服务器并行处理,结果再被聚合返回。

数据级并行 则关注于对单一数据集进行操作。其核心思想是:

  • 拥有一个大型数据集(可能无法装入单机内存)。
  • 将数据分布到多个处理器或磁盘上。
  • 每个处理器同时对分配给自己的数据部分执行相同的操作。
  • 这超越了单指令多数据流(SIMD),是一种更高级的抽象。

MapReduce 抽象 🗺️➡️🧹

为了有效处理分布在多台机器上的海量数据,我们需要强大的编程抽象。MapReduce就是为此而生。

MapReduce是一个用于大规模数据处理的编程模型,设计目标是可扩展性和容错性。它将计算过程分为两个主要阶段:Map(映射)和Reduce(归约)。

以下是MapReduce的工作流程:

  1. Map阶段:读取输入数据(通常是键值对形式),对其中的每个元素应用用户定义的Map函数,生成一批中间键值对。
  2. Shuffle(洗牌)阶段:系统根据中间键对所有键值对进行排序和分组,确保相同键的数据被发送到同一个Reduce节点。
  3. Reduce阶段:对每个键对应的所有值集合,应用用户定义的Reduce函数,进行合并操作,最终生成输出结果。

一个经典例子是词频统计

  • Map函数:读取文本,每遇到一个单词,就输出一个中间键值对 <单词, 1>
  • Shuffle:将所有中间对按单词分组。
  • Reduce函数:接收一个单词及其对应的所有1的列表,将它们相加,输出最终结果 <单词, 出现次数>

MapReduce框架(如Hadoop)会自动处理数据分布、任务调度、节点间通信以及节点故障恢复(通过重新执行失败的任务),使开发者能专注于业务逻辑。

Spark:更快的MapReduce实现 ⚡

虽然MapReduce功能强大,但其基于磁盘的读写模式可能成为性能瓶颈。Spark是MapReduce模型的一个更现代、更高效的实现。

Spark的核心优势在于:

  • 内存计算:尽可能将数据保存在内存中进行处理,比基于磁盘的MapReduce快数十到上百倍。
  • 惰性求值:转换操作(如map、filter)不会立即执行,只有在遇到行动操作(如collect、count)时才会触发实际计算,这允许Spark进行整体优化。
  • 易用的API:提供Python、Scala、Java等多种语言的高级API,代码更简洁。

以下是使用Spark实现词频统计的Python代码示例,其简洁性对比传统MapReduce有显著提升:

# 假设sc是SparkContext,text_file是一个RDD(弹性分布式数据集)
words = text_file.flatMap(lambda line: line.split(" ")) # 分割单词
pairs = words.map(lambda word: (word, 1)) # 映射为(单词, 1)
word_counts = pairs.reduceByKey(lambda a, b: a + b) # 按键归约,相加
result = word_counts.collect() # 触发计算并收集结果

甚至可以使用链式调用写成更紧凑的一行代码:
text_file.flatMap(lambda line: line.split(" ")).map(lambda word: (word, 1)).reduceByKey(lambda a, b: a + b).collect()

总结 🎯

本节课中我们一起学习了并行计算中的几个关键概念。
我们首先认识了阿姆达尔定律,它揭示了串行代码对并行加速的根本限制。
接着,我们区分了请求级并行数据级并行两种不同的并行范式。
然后,我们深入探讨了用于处理海量数据的MapReduce编程模型,了解了其Map、Shuffle、Reduce三个阶段如何协作。
最后,我们介绍了Spark,作为MapReduce的现代演进,它通过内存计算和惰性求值等机制,提供了更高效、更易用的数据处理能力。
掌握这些概念和工具,是理解和开发大规模分布式应用的基础。

课程 P48:第37讲 - 可靠性、奇偶校验、纠错码与磁盘阵列 🛡️💾

在本节课中,我们将学习计算机体系结构中的第六个伟大思想:通过冗余实现的可靠性。我们将探讨为何可靠性至关重要,了解如何度量它,并学习几种利用冗余来检测和纠正错误、提高系统可用性的关键技术。


模块概述与重要性 🔍

这是一个关于可靠性的简短模块。我们之前已经讨论了计算机体系结构中的其他伟大思想,如抽象层、摩尔定律、内存层次结构和并行性。然而,我们尚未探讨第六个重要思想:通过冗余实现的可靠性

为什么可靠性如此重要?因为我们在日常生活中越来越依赖计算机系统。金融交易、汽车驾驶等关键领域都由计算机支持。计算机确实会发生故障,这些故障可能是瞬态的(如蓝屏死机后系统可恢复),也可能是永久性的(硬件永久损坏)。

本模块将重点讨论如何减轻这些硬件故障。其核心思想是冗余——即拥有多个副本或备用组件。例如:

  • 在存储系统中使用冗余内存。
  • 在多核处理器中,制造商可能会禁用有缺陷的核心,将其作为七核而非八核芯片出售。
  • 通过投票机制(如三取二)确保计算正确性。
  • 在数据中心使用磁盘阵列(RAID),当单个磁盘故障时,数据不会丢失。
  • 在内存(DRAM)中使用额外的奇偶校验位来检测错误。

接下来,我们将首先学习如何度量可靠性。


可靠性度量 📏

上一节我们介绍了冗余的基本概念。本节中,我们来看看如何衡量一个系统的可靠性。在计算机系统中,我们需要一些度量标准来判断是否以及改进了多少。

首先,理解计算机系统的运行状态:

  1. 正常运行:系统提供服务。
  2. 故障:系统中某个组件发生故障。故障不一定导致系统失效,这取决于该组件是否被使用以及是否存在冗余。
  3. 失效:系统从正常运行状态转移到服务中断状态。
  4. 恢复:系统从服务中断状态修复并回到正常运行状态。

关键度量指标包括:

  • 故障发生的频率
  • 从故障中恢复需要多长时间

冗余可以在空间时间两个维度上应用:

  • 空间冗余:拥有多个副本(如多个计算单元、多份数据副本、冗余设备或添加冗余位)。
  • 时间冗余:如果检测到计算失败或服务未完成,简单地重复执行操作。

以下是几个核心的可靠性度量指标:

  • 平均无故障时间 (MTTF):测量系统或组件在发生故障前平均能正常运行的时间。公式为:
    MTTF = 总正常运行时间 / 故障次数
  • 平均修复时间 (MTTR):测量修复故障并恢复服务所需的平均时间。
  • 平均失效间隔时间 (MTBF):指两次故障之间的平均时间,它是MTTF和MTTR之和。公式为:
    MTBF = MTTF + MTTR
  • 可用性 (Availability):系统处于可服务状态的时间比例。公式为:
    可用性 = MTTF / (MTTF + MTTR) = MTTF / MTBF

提高可用性的方法:增加MTTF(使组件更耐用或增加冗余)或减少MTTR(改进故障检测和修复工具)。


可用性与“9”的追求 🎯

上一节我们定义了可靠性的度量指标。本节中,我们深入看看可用性这个关键指标。

可用性通常以百分比形式表示,并且计算机系统追求极高的可用性,常用“几个9”来描述:

  • 90% 可用性:相当于一年有36.5天服务中断。
  • 99% 可用性:相当于一年有3.65天服务中断。
  • 99.9% 可用性(三个9):相当于一年有8.76小时服务中断。
  • 99.99% 可用性(四个9):相当于一年有52.6分钟服务中断。
  • 99.999% 可用性(五个9):相当于一年只有5.26分钟服务中断。

现代关键服务(如YouTube、云计算平台)通常设计为四个9或五个9的可用性。系统宕机的代价非常高昂,可能达到每分钟数百万美元的损失。

对于拥有大量组件的系统(如数据中心),我们还需要关注年化故障率 (AFR)。例如,一个拥有1000块磁盘的数据中心,若每块磁盘的MTTF为10万小时,则:
年化故障率 ≈ (1000 盘 * 8760 小时/年) / 100000 小时/盘 ≈ 87.6 盘/年
这相当于约8.8%的年化故障率,与实际研究中磁盘驱动器的故障率(1-3年约1.7%-8.6%)相符。

另一个度量是故障率 (FIT),常用于汽车电子等领域,表示十亿设备运行小时中预期的故障数。例如,MTBF = 10^9 / FIT

可靠性设计的一个关键原则是:避免单点故障。系统的整体可靠性受其最不可靠组件的制约。

接下来,我们将探讨如何检测这些故障。


错误检测:奇偶校验 🧮

上一节我们学习了如何度量可靠性。在尝试添加冗余之前,我们首先需要弄清楚如何检测系统中的错误或故障。

计算机系统中的错误可能发生在任何地方,但尤其常见于内存(DRAM)。DRAM中的每个比特都是一个微小的电容器。电荷可能受到电源扰动或宇宙射线等粒子的干扰而改变,导致存储的0变成1或反之。这种错误称为软错误。与之相对的是硬错误,即存储单元物理性永久损坏。

防御软错误的方法是使用错误检测与纠正码 (ECC)。其核心思想是在存储数据时添加一些冗余位,形成“码字”。如果错误导致一个有效码字变成无效码字,我们就能检测到问题。

理解这些代码需要一个重要概念:汉明距离。它指的是两个等长二进制串之间不同比特位的数量。例如:

  • 字 P = 0101, 字 Q = 0011, 汉明距离为 2。
  • 字 P = 0101, 字 Q = 1101, 汉明距离为 3。

如果我们存储的码字之间的最小汉明距离为2,那么发生单比特错误时,新产生的字将与任何有效码字都至少有一个比特的差异,从而能被检测为无效。

一个最常用的简单错误检测码是奇偶校验

  • 编码:对于一个数据字(例如8位),我们计算所有比特的异或(XOR),产生一个奇偶校验位,使得整个9位码字中1的个数为偶数(偶校验)。然后将这9位一起存入内存。
  • 检测:读取时,再次计算这9位的异或。如果结果为0,则奇偶性为偶,没有检测到错误(或发生了偶数个比特错误);如果结果为1,则检测到错误(发生了奇数个比特错误)。

奇偶校验码的最小汉明距离为2,因此它能检测任何单比特错误(以及任意奇数个比特错误),但无法检测偶数个比特错误,也无法纠正错误。


错误纠正:汉明码 🔧

上一节我们看到了如何使用奇偶校验位检测错误。但奇偶校验只能告诉我们“有错误”,却不知道错误在哪里,也无法纠正。本节中我们来看看如何添加更多冗余以执行纠错

要进行单比特错误的纠正,码字之间的最小汉明距离需要为3。这样,任何一个单比特错误产生的无效码字,都唯一地最接近某一个有效码字。通过将其“纠正”为这个最近的有效码字,我们就能恢复原始数据。

理查德·汉明提出的汉明码就是这样一种能实现单纠错、双检错 (SECDED) 的编码。其巧妙之处在于将奇偶校验位与数据位交织放置。

以下是汉明码编码的一个简化示例(对8位数据编码):

  1. 确定码字位位置(1 到 12)。
  2. 将数据位(D0-D7)放入位位置 3, 5, 6, 7, 9, 10, 11, 12。
  3. 奇偶校验位(P1, P2, P4, P8)放入位位置 1, 2, 4, 8(这些是2的幂次方位)。
  4. 每个奇偶校验位负责覆盖特定的一组位(包括一些数据位和其他奇偶位),确保该组的奇偶性为偶。
    • P1 覆盖所有位位置二进制表示中最低位为1的位(1, 3, 5, 7, 9, 11)。
    • P2 覆盖所有位位置二进制表示中次低位为1的位(2, 3, 6, 7, 10, 11)。
    • P4 覆盖所有位位置二进制表示中第三位为1的位(4, 5, 6, 7, 12)。
    • P8 覆盖所有位位置二进制表示中第四位为1的位(8, 9, 10, 11, 12)。

解码与纠错过程

  1. 读取码字后,重新计算四个奇偶校验组。
  2. 如果所有组校验都通过(偶校验),则没有错误。
  3. 如果某些组校验失败,将失败组的索引(如P2和P8)相加,其和(如2+8=10)直接指出了出错比特的位置
  4. 翻转该出错比特,即完成纠错。

汉明码仅用4个校验位就保护了8位数据,开销为50%,远优于简单的三重复制(开销200%)。更高级的系统(如服务器)可能会使用能纠正多比特错误的更强编码。


硬件冗余:磁盘阵列 (RAID) 💽

上一节我们学习了如何用比特级冗余(ECC)应对软错误。本节我们来看看当硬件发生永久性故障(如硬盘损坏)时该怎么办。此时,ECC可能不够用,我们需要组件级的冗余——备用件

一个经典的例子是廉价磁盘冗余阵列 (RAID)。它由一组廉价的普通磁盘组成,通过数据分布和冗余策略,提供更高的性能、容量和可靠性。

RAID 的核心思想之一是条带化:将文件分割成块,并行写入多个磁盘,从而提高I/O速度。同时,通过引入冗余磁盘,可以在某个磁盘故障时重建数据,保证服务不中断。

以下是几种常见的RAID级别:

  • RAID 0:仅条带化,无冗余。提高了速度,但没有容错能力。
  • RAID 1镜像。每个数据盘都有一个完整的备份盘。提供完全的冗余,但存储开销为100%(利用率50%)。
  • RAID 3/4带专用奇偶校验盘的条带化
    • 数据条带化分布在多个磁盘上。
    • 使用一个专用的磁盘存储所有数据条的奇偶校验信息(通过异或计算得出)。
    • 任何一个数据盘故障,都可以用剩余数据盘和奇偶校验盘的数据重建出来。
    • 缺点:奇偶校验盘成为写操作的瓶颈,因为每次写数据盘都需要更新它。
  • RAID 5分布式奇偶校验条带化
    • 与RAID 4类似,但奇偶校验信息不再集中于一个磁盘,而是均匀分布在所有磁盘上
    • 这消除了奇偶校验盘的瓶颈,因为写操作可以分散到多个磁盘上同时更新各自的奇偶校验块。
    • 这是最常用的RAID级别之一,在性能、容量和可靠性之间取得了良好平衡。

还有更高级的RAID级别(如RAID 6,能容忍两块磁盘同时故障),但基本原理相似。


总结 📚

在本节课中,我们一起学习了如何通过冗余来提高计算机系统的可靠性。

我们首先了解了为什么可靠性至关重要,并学习了度量可靠性的关键指标:平均无故障时间 (MTTF)平均修复时间 (MTTR)可用性。高可用性系统常以“几个9”为目标。

接着,我们探讨了两种主要的冗余形式:

  1. 空间冗余:添加额外的硬件(位、组件、磁盘)。
  2. 时间冗余:重复执行失败的操作。

在错误处理方面,我们深入学习了两种编码技术:

  • 奇偶校验:通过添加一个校验位,实现单比特错误检测(汉明距离为2)。
  • 汉明码:通过交织多个奇偶校验位,实现单比特错误纠正和双比特错误检测(汉明距离为3)。这是一种高效利用冗余的优雅方法。

最后,我们研究了硬件组件级的冗余实例——RAID磁盘阵列。它通过条带化提升性能,并通过镜像或奇偶校验提供冗余,从而在磁盘故障时保障数据可用性和系统可靠性。我们了解了RAID 1、RAID 4和RAID 5等不同级别的工作原理和权衡。

总而言之,冗余是构建可靠计算机系统的基石,它使我们能够在组件必然会发生故障的现实世界中,依然构建出高度可用的服务。

课程 P49:第38讲 数据中心与仓库级计算 🏢💻

概述

在本节课中,我们将学习数据中心和云计算的基本概念。我们将从计算机硬件的历史演变开始,探讨个人电脑时代如何过渡到后PC时代,并最终聚焦于支撑现代云服务的仓库级计算机。我们将了解其工作原理、设计考量、性能指标以及能效优化等核心主题。


计算机硬件时代简史 🕰️

上一节我们介绍了本课程的主题。本节中,我们来看看计算设备的发展历程。

计算机体系结构建立在一些核心思想之上,例如抽象层次和摩尔定律。摩尔定律在数十年间推动了计算机制造业的飞速发展。然而,大约在2005年,单线程性能的提升开始放缓,行业转向了多核架构。

我们探讨了从线程级并行到数据级并行的多个层次。像Spark和MapReduce这样的框架正是在云系统上高效运行数据级并行工作的典范。

在20世纪50-60年代,计算机是庞大且昂贵的机器,仅存在于大公司、研究实验室和大学。到了70年代,随着C语言和Unix系统的出现,计算机开始变得更小、更普及。

80年代中期到2000年代中期是个人电脑(PC)时代,计算机进入家庭和个人生活。编程语言如Java和操作系统如Windows、Mac OS成为主流。


后PC时代与智能设备 📱

上一节我们回顾了PC时代。本节中,我们进入后PC时代。

后PC时代以智能移动设备为特征,这些设备依赖无线网络和云计算服务。苹果、谷歌等公司制造了智能手机,我们使用C、Swift、Java等语言为其编程。

个人移动设备本身并不处理所有计算,它们依赖云端的数据中心提供数据和服务。例如,询问智能手表天气,其数据就来自云端的仓库级计算机。

编程模型也发生了变化,MapReduce和Ruby on Rails等框架被用于开发云应用。


仓库级计算机:架构与动机 🏗️

上一节我们提到了云端的数据中心。本节中,我们深入探讨仓库级计算机。

为什么云计算在近十年才蓬勃发展?早期,每家公司都有自己的IT部门和小型服务器机房。像谷歌、亚马逊这样的大型公司意识到,为了应对增长,他们需要远超传统机房的规模。

他们开始使用大量廉价的商用个人电脑来构建系统。虽然这些机器的故障率更高,但通过规模经济,其总体成本效益远高于维护一个中等规模的自建设施。此外,宽带互联网的普及和软硬件的标准化也促进了这一模式。

以下是租用云服务的一个例子:

  • 亚马逊AWS:提供多种配置的虚拟机实例。例如,一个配置较低的实例每小时仅需几美分。
  • 存储服务:提供弹性块存储,价格低至每GB每月几美分。

构建一个仓库级计算机意味着管理十万到百万台服务器。设计时需重点考虑成本效益、散热和电力供应。


仓库级计算机的设计与组件 🔧

上一节我们了解了建造仓库级计算机的动机。本节中,我们看看它的具体设计和内部组件。

仓库级计算机强调硬件的同质性和高可用性(例如“五个9”的可用性,即每年停机时间少于一小时)。它带来了新的挑战和机遇:

  • 大规模并行:极高的并行度,适合数据级并行处理。
  • 组件故障:由于机器数量巨大,硬盘等组件故障是常态,需要通过RAID等技术实现冗余。
  • 总拥有成本:电力、冷却、人力等持续运营成本远高于最初的硬件采购成本。

谷歌等公司甚至定制了自己的服务器。在一台典型的谷歌服务器中,你可能会发现:

  • 两个CPU
  • 内存条
  • 硬盘
  • 一个关键组件:电池
    这个电池作为本地化的不间断电源(UPS),比在建筑层面设置大型UPS更高效。


性能指标:延迟与吞吐量 ⚡

上一节我们讨论了硬件组件。本节中,我们思考如何衡量这类系统的性能。

性能可以从两个角度衡量:

  1. 响应时间/延迟:单个任务从开始到完成所需的时间。公式可表示为:延迟 = 完成时间 - 开始时间
  2. 吞吐量:在单位时间内完成的工作总量。公式可表示为:吞吐量 = 工作量 / 时间

用一个类比来说明:一辆法拉利跑车(低延迟)可以快速运送一个人,而一辆校车(高吞吐量)可以同时运送很多人。在计算机系统中,我们需要根据应用需求在延迟和吞吐量之间进行权衡。

对于一个由多个机架组成的服务器阵列,其总资源量是惊人的:

  • 计算核心:可达数万个。
  • 内存总量:可达数百TB。
  • 存储容量:可达数PB。

更重要的是,在分布式系统中,一台机器不仅可以访问自己的内存和磁盘,还能通过网络访问阵列中其他机器的资源。这引出了分布式文件系统和操作系统的概念。

访问不同类型存储的延迟差异巨大:

  • 写入本地DRAM:约 0.1微秒
  • 写入同一阵列中另一台机器的DRAM:约 10微秒
  • 写入本地磁盘:约 10毫秒

因此,像Spark这样的框架会尽量让计算在内存中进行,避免访问磁盘,从而获得高性能。


电力使用效率与优化 ⚡🌿

上一节我们探讨了性能。本节中,我们关注仓库级计算机的另一个关键方面:能效。

像Twitter这样的服务,其工作负载在一天内波动很大。但对于托管了多种服务的大型数据中心,由于大数定律,总体工作负载通常只会在2倍范围内变化。

仓库级计算机的软件开发面临诸多挑战:数据布局、容错、工作负载适应以及复杂的内存层次结构。

一个重要的指标是电力使用效率(PUE),其定义为:
PUE = 总设施耗电量 / IT设备耗电量
理想PUE值为1.0,表示所有电力都用于计算。实际值越高,意味着用于冷却、配电等辅助设施的“开销”电力越多。

研究表明,数据中心的平均PUE约为1.83,但有些设计可以做到1.2左右。

电力都消耗在哪里?除了服务器、网络等IT设备,很大一部分用于:

  • 配电单元(PDU)
  • 不间断电源(UPS)
  • 冷却系统(冷水机组、空调)

谷歌等公司通过实践总结出许多优化经验:

  • 控制气流:隔离冷热通道,防止空气混合。
  • 使用集装箱:将服务器封装在集装箱内,便于管理和气流控制。
  • 提高温度:适当提高冷通道温度(如80°F),仍能保证可靠性。
  • 本地化UPS:为每台服务器配备电池,提升效率。
  • 利用自然冷却:在气候温和的地区建设数据中心,使用外部空气或水进行冷却。
  • 分享最佳实践:行业间开放合作,共同提升能效。

尽管数据中心耗电量巨大(例如,谷歌的用电量曾相当于20万户家庭),但云服务通过提高社会运行效率,并积极购买可再生能源进行抵消,致力于实现环境友好。


总结 🎯

本节课我们一起学习了数据中心与仓库级计算的完整图景。

我们回顾了从大型机到个人电脑,再到移动设备和云计算的演变历程。我们深入探讨了仓库级计算机的架构、设计动机、以及其如何通过商用硬件和规模经济来提供强大的云服务。

我们分析了衡量其性能的两个关键指标——延迟和吞吐量,并理解了在分布式系统中数据布局的重要性。最后,我们聚焦于能效这一核心挑战,学习了PUE指标以及通过优化冷却、供电等方式来构建更绿色、更高效的数据中心。

Parallelism(并行)作为计算机体系结构的核心思想之一,贯穿了从指令级并行到仓库级并行的所有层次。正是后端这些强大的仓库级计算机,支撑着我们每天依赖的无数前端应用和服务。

课程 P5:第4讲 - C语言入门:指针、数组与字符串 📚

在本节课中,我们将学习C语言中三个核心且相互关联的概念:指针、数组和字符串。理解这些概念是掌握C语言编程的关键,它们提供了对计算机内存的直接操作能力。

内存抽象:一个巨大的数组 🧠

在深入细节之前,我们需要建立一个关于内存的抽象模型。我们可以将计算机的内存想象成一个巨大的字节数组。这个数组中的每一个字节都有一个唯一的地址。

  • 地址从0开始(通常用十六进制表示,如 0x0)。
  • 地址会一直递增(如 0x104)。
  • 每个地址处存储着一个具体的(例如,字节 23)。

核心区别:地址(位置)和存储在该地址的值是两个不同的概念。

指针:指向内存地址的变量 🎯

指针本身也是一个值,但它存储的是内存地址。我们可以把它想象成一个箭头,指向内存中的某个特定位置。

指针的声明与使用

在C语言中,我们使用星号 * 来声明和操作指针。

int x = 3;        // 声明一个整数变量x,值为3
int *p;           // 声明一个指针p,它将指向一个整数
p = &x;           // 使用 &(取地址)运算符,将x的地址赋值给p

  • int *p; 中的 * 用于声明 p 是一个指向 int 的指针。
  • p = &x; 中的 & 是取地址运算符,它获取变量 x 的内存地址。
  • 现在,指针 p “指向”变量 xp 中存储的值就是 x 的地址。

解引用:访问指针指向的值

我们可以通过指针来读取或修改它指向的值,这称为“解引用”。

printf("%d\n", *p); // 输出:3。*p 解引用p,获取它指向地址的值。
*p = 5;             // 将p指向的地址的值改为5。现在 x 的值也变成了5。
  • 这里的 *p 中的 *解引用运算符,意思是“跟随指针,获取它指向的值”。

为何使用指针?

上一节我们介绍了指针的基本语法,本节我们来看看指针为何如此重要。

  1. 实现“按引用传递”:C语言默认是“按值传递”函数参数。这意味着函数得到的是参数值的副本。如果想在函数内部修改外部变量的值,就需要传递该变量的指针(即地址)。

    // 按值传递 - 无法修改外部变量
    void increment(int y) {
        y = y + 1; // 修改的是副本
    }
    // 按“引用”传递(通过指针)- 可以修改外部变量
    void increment_ptr(int *ptr) {
        *ptr = *ptr + 1; // 解引用ptr,修改它指向地址的值
    }
    int main() {
        int a = 3;
        increment(a);      // a 仍然是 3
        increment_ptr(&a); // a 变成了 4
        return 0;
    }
    
  2. 提升效率:传递一个大型结构体或数组时,复制整个数据开销很大。传递指针(一个地址)则高效得多。

  3. 实现动态数据结构:链表、树等数据结构依赖于指针来连接各个节点。

注意:指针功能强大,但也是C程序错误的主要来源之一,使用时需格外小心。

指针的常见陷阱与高级话题 ⚠️

未初始化的指针(野指针)

声明指针变量时,它不会自动初始化。它可能指向内存中的任意位置(垃圾地址)。

void dangerous() {
    int *ptr; // ptr 包含垃圾地址
    *ptr = 5; // 灾难!向一个未知的内存地址写入5
}

向一个随机的内存地址写入数据可能导致程序崩溃、数据损坏或难以调试的错误。

空指针

C语言约定,地址为 0 的指针称为空指针,用 NULL 表示(类似于Python/Java中的 None)。

int *p = NULL;
if (p != NULL) {
    *p = 10; // 安全的解引用检查
}

尝试读写空指针(NULL)通常会导致程序崩溃(段错误),这是一种保护机制。

指针算术

指针可以执行加法和减法运算,这与数组紧密相关。指针算术的单位是它指向类型的大小

int arr[3] = {50, 60, 70};
int *q = arr; // q 指向数组第一个元素 (arr[0])
printf("%d\n", *(q + 1)); // 输出:60。q+1 指向下一个int(地址增加4字节)
printf("%d\n", q[1]);     // 输出:60。等价于 *(q+1)
  • q + n 表示:从地址 q 开始,向后移动 n * sizeof(所指向类型) 个字节。
  • 数组索引 arr[i] 在底层其实就是 *(arr + i)

双指针(指向指针的指针)

既然指针是变量,它也有地址。我们可以创建指向指针的指针。

int value = 100;
int *p = &value;
int **pp = &p; // pp 是一个指向(int指针)的指针
printf("%d\n", **pp); // 输出:100。先解引用pp得到p,再解引用p得到value

双指针常用于需要修改指针本身(而不仅仅是指针指向的值)的函数中。

数组:连续的内存块 📦

理解了指针算术后,数组就很好理解了。在C语言中,数组本质上是一块连续的内存

int arr[2]; // 分配两个连续的int大小的内存块
int arr_init[] = {10, 20}; // 声明并初始化,编译器自动推断大小为2

数组与指针的关系

数组名在大多数情况下会被编译器“退化”为指向其第一个元素的指针。

int arr[3] = {1, 2, 3};
int *p = arr; // arr 退化为 &arr[0]
printf("%d\n", *arr);   // 输出:1,等价于 *(arr + 0)
printf("%d\n", arr[2]); // 输出:3,等价于 *(arr + 2)

关键区别:数组名不是指针变量,它更像一个“标签”,代表那块内存的起始地址。因此,sizeof(arr) 会得到整个数组的字节大小,而 sizeof(p) 只会得到一个指针的字节大小。

数组的陷阱

  1. 未检查的数组边界:C不会检查数组访问是否越界。越界读写会破坏相邻内存的数据,导致不可预知的行为和安全漏洞(如缓冲区溢出攻击)。

    int small[10];
    small[100] = 99; // 严重错误!但编译器可能不报错。
    

  1. 数组作为函数参数会退化为指针:将数组传递给函数时,传递的只是其首地址,丢失了长度信息。因此,通常需要额外传递数组大小。

    void print_array(int *arr, unsigned int size) {
        for (int i = 0; i < size; i++) {
            printf("%d ", arr[i]);
        }
    }
    
  2. 函数内局部数组的生命周期:在函数内部声明的数组是局部变量。函数返回后,其内存可能被回收。因此,不要返回指向局部数组的指针

    int* bad_function() {
        int local_arr[10] = {1,2,3};
        return local_arr; // 错误!返回后 local_arr 已失效。
    }
    

字符串:以空字符结尾的字符数组 🔤

在C语言中,字符串并不是一种独立的数据类型,它就是一个字符数组,并且约定以特殊字符 \0(空终止符)结尾。

char str1[] = "abc"; // 编译器会自动创建包含 ‘a‘, ‘b‘, ‘c‘, ‘\0‘ 的数组
char str2[4] = {‘a‘, ‘b‘, ‘c‘, ‘\0‘}; // 与上面等价

字符串的特性

  • 空终止符 \0 标志着字符串的结束,其ASCII码值为0。
  • 标准库函数(如 strlen, strcpy)都依赖空终止符来工作。
  • 字符串的长度不包括末尾的空终止符。
#include <string.h>
char s[] = "hello";
int len = strlen(s); // len = 5
printf("%s\n", s);   // 输出:hello。printf 会一直打印直到遇到 ‘\0‘

内存对齐与字节序 🏗️

内存对齐

现代计算机体系结构为了高效访问内存,要求数据存储在特定地址倍数上(通常是4或8字节),这称为内存对齐。编译器会自动处理对齐,但了解它有助于理解内存布局。

  • 一个 int(假设4字节)的地址通常是4的倍数。
  • 结构体内部可能会有“填充字节”以满足每个成员的对齐要求。

字节序(Endianness)

字节序指的是多字节数据(如 int)在内存中字节的存储顺序。

  • 小端序:最低有效字节存储在最低地址。61C课程使用的机器通常是这种。
  • 大端序:最高有效字节存储在最低地址。

例如,整数 0x12345678 在小端序机器上的内存布局(从低地址到高地址)可能是:78 56 34 12


总结 📝

本节课我们一起学习了C语言的核心底层概念:

  1. 指针:存储内存地址的变量,通过 & 取址,通过 * 解引用。它实现了间接访问和高效的数据传递。
  2. 数组:连续的内存块,数组名可退化为指向首元素的指针。访问数组本质是指针算术。
  3. 字符串:以空字符 \0 结尾的字符数组,是C中处理文本的基础方式。

我们还探讨了与之相关的常见陷阱,如野指针、数组越界、以及内存对齐和字节序等底层细节。掌握这些知识是写出正确、高效C程序的基础。在接下来的课程和项目中,你将有机会大量练习这些概念。

课程 P50:第39讲 - 与苹果公司 James Percy 的客座讲座 🎤

在本节课中,我们将学习图形处理器(GPU)的基本架构、工作原理以及它与中央处理器(CPU)的区别。我们将从高层概念入手,逐步深入到图形渲染管线的各个阶段,并了解GPU强大的并行计算能力。最后,我们将通过一个编程示例,直观地对比CPU与GPU在处理大规模数据时的性能差异。

什么是GPU?🤔

要理解GPU,一个好方法是将它与CPU进行比较。CPU架构本身很复杂,但更容易理解。GPU也很复杂,但原因不同。

下图是一个高度程式化的对比。左侧是CPU,右侧是GPU,它们有颜色编码的相似组件。

  • CPU:有一系列指令,由执行单元执行数学运算和运行这些指令。它有内存支持,通常是分层缓存和存储系统。优化CPU性能的关键是最小化延迟,即减少从执行单元到内存的周期数。
  • GPU:同样有执行单元、控制单元和缓存。关键区别在于,GPU中的蓝色执行单元方块被大量复制。现代大型GPU上可以有超过100个这样的执行单元,而CPU核心通常只有几个。

因此,从GPU中获得最大性能的关键是最大化吞吐量,即尽可能多地利用这些并行处理单元。这与CPU优化延迟的目标不同。

CPU与GPU的设计哲学对比 ⚖️

上一节我们介绍了CPU和GPU的基本结构差异,本节我们来具体看看它们设计目标的对比。

  • 核心数量:CPU核心数较少,GPU核心数(执行单元)非常多。
  • 频率与吞吐量:CPU曾追求高频率,而GPU主要追求高吞吐量。
  • 推测执行与乱序执行:CPU有复杂的逻辑进行分支预测和指令重排以优化性能。GPU的这些操作趋于简单,因为在大规模执行单元阵列上进行此类优化非常复杂且扩展性不佳。
  • 一致性管理:在多核CPU中,硬件需要花费大量精力管理多个线程间的一致性,以提供简单的编程模型。在GPU上,编程模型更复杂,一致性倾向于由软件管理,即由程序员或开发者来管理线程间的同步和数据一致性。

最大化吞吐量与隐藏延迟 🚀

我们已经讨论过吞吐量,目标是尽可能多地使用执行单元。许多同学可能听说过SIMD这个概念,它代表“单指令多数据”。

这意味着你有一套指令,将在一个大数据集上执行。例如,屏幕上的每个像素都将执行相同的指令。这提供了线程级并行性。GPU的工作和复杂性在于管理如何在这些不同的执行单元之间安排工作,以获得最大的数据吞吐量。

另一个重要方面是内存带宽。GPU通常可以访问比CPU更高的内存带宽。例如在游戏中,每秒需要刷新屏幕多次,并渲染大量数据,因此需要高内存带宽来高效移动数据。GPU的许多优化都围绕着减少内存带宽需求,例如通过缓存层次结构来减少芯片外数据移动。

图形渲染管线概览 🎨

在高层面上,GPU图形管线主要由四到五个主要部分组成。

  1. 顶点处理:处理顶点并对其进行计算。
  2. 光栅化:将三角形转换为屏幕上的单个像素。
  3. 片段处理:在单个像素上运行程序以生成其颜色。
  4. 帧缓冲区处理:将像素输出到内存中的缓冲区,以便显示在屏幕上。

一个细节是,现代GPU使用相同的硬件(统一着色器核心)来运行顶点和片段处理程序。为了每秒处理数百万顶点和像素,需要一个强大的计算引擎来并行运行这些程序。在游戏中,屏幕上的每个像素都可能正在运行一个程序(像素着色器)来生成最终颜色。

深入图形管线 🔍

上一节我们概述了管线,本节我们来更详细地看看每个阶段。

顶点处理

在游戏中,世界或物体模型由三角形构成。程序员通过图形API输入模型顶点及其连接方式。

顶点处理阶段会将物体(如独角兽)放置到世界中的正确位置。这是通过顶点着色器完成的,它可以进行旋转、平移(移动)、缩放等变换。同时,它也会根据摄像机位置进行变换,以便从屏幕视角渲染。

与课程的联系:同学们正在研究更快的矩阵乘法。这正是顶点处理中发生的事:将许多顶点(可视为矩阵)与变换矩阵(4x4矩阵)相乘。GPU最初就是为了快速完成这类计算而优化的。

几何处理与图元组装

在此阶段,我们将顶点组装成三角形(图元)。并非所有现代GPU都具备的几何着色器阶段,可以将屏幕空间中的三角形分组,并估算场景的渲染复杂度(例如,毛发部分三角形多、成本高,背景三角形少、成本低)。这有助于分配工作,优化GPU吞吐量。

光栅化(扫描转换)

此过程将三角形转换为单个像素。一个简单的算法是:从三角形顶点开始,沿边缘移动,填充内部的像素。为了更高质量的图像(抗锯齿),可以沿着边缘创建更多样本。

片段处理与着色

现在我们有了像素位置,需要决定如何为它们上色。此阶段包括:

  • 深度测试:判断三角形是否可见。如果两个三角形相交,只渲染前面的那个,丢弃被遮挡的部分。
  • 着色:这是最有趣的部分。片段着色器(或像素着色器)允许你指定每个像素上运行的算法来进行光照计算。例如,计算光线角度、进行纹理查找等。茶壶上的每个像素都会并行运行相同的着色器程序,这正是GPU并行处理的威力所在。

现代着色语言非常强大,可以进行复杂数学运算、矩阵乘法,甚至包含用于神经网络或同步的特殊指令。

纹理映射 🖼️

在我们的示例中,背景的烟火和表面的“M”标志不是动态生成的,而是预先存储的图像,称为纹理。纹理映射将纹理图像上的像素映射到渲染物体的表面像素上。

这会产生一些挑战,例如纹理图像与屏幕渲染区域大小不匹配时的采样问题。GPU的纹理单元内置了硬件来处理各种滤波和采样模式,以生成高质量的图像。

输出合并

最后一个阶段是将所有处理过的像素写入内存中的帧缓冲区。通常,像素会先存储在芯片上的缓存中,然后被写入帧缓冲区。之后,通知显示器图像已渲染完成,可以显示。此阶段可能还包括混合测试、模板测试等操作。

GPU执行模型与线程级并行 ⚙️

我们提到要为每个像素执行片段着色器。这是通过线程级并行实现的,即对多个数据(像素)运行相同的指令,回到SIMD概念。

设想一个简单情况:一次处理一个像素很慢。SIMD机器的想法是让一组线程(例如2x2的像素块)以锁步方式同时执行相同指令。

更复杂的情况是,如果某个线程需要进行高延迟操作(如纹理获取),它会被挂起,GPU调度器会寻找其他可以执行的线程组来运行,以保持硬件忙碌。管理机器中运行的成千上万个线程,并以最佳方式填充硬件,是GPU复杂且有趣的部分。

CPU与GPU性能对比演示 💻

现在,我们将通过一个具体示例来对比CPU和GPU的性能。我们将运行一个简单的SAXPY算法(单精度αX加Y),即计算 y[i] = a * x[i]

我们将在CPU(单线程C程序)和GPU(使用Metal API)上分别实现这个计算,并比较处理不同大小数组所需的时间。

以下是演示的核心代码概念:

CPU端(C语言)伪代码

for (int i = 0; i < array_size; i++) {
    y[i] = a * x[i];
}

GPU端(Metal着色语言)伪代码

// 每个线程处理一个或多个数组元素
int i = thread_position_in_grid;
y[i] = a * x[i];

预期结果与分析

  • 当数组规模很小时,CPU表现更好。因为启动GPU有固定开销,且小规模任务无法充分利用并行性。
  • 当数组规模变得非常大时,GPU表现显著优于CPU。因为GPU可以以SIMD方式并行处理大量数据块(例如64个元素一组),并且拥有更高的内存带宽。

这个简单的程序展示了GPU在并行处理大规模数据时的优势。

总结与要点 🎯

本节课我们一起学习了GPU架构的核心知识。

如果你从这次演讲中只记住三点,那么它们是:

  1. GPU的效率和性能来自于最大化并行性,从而最大化吞吐量。
  2. GPU通过SIMD(单指令多数据)模型运行并行程序,在大量数据上执行相同指令。
  3. 现代GPU已高度通用化,不仅用于图形渲染,还广泛应用于需要并行计算的其他领域,如图像处理、计算摄影和机器学习(神经网络)

GPU已经成为现代计算中不可或缺的一部分,从游戏、自动驾驶到科学计算,其强大的并行处理能力正在驱动众多领域的发展。


注:本教程根据James Percy在伯克利大学的客座讲座内容整理,专注于技术概念讲解,已去除原讲座中的语气词和互动部分。

🧠 课程 P51:第40讲 - 总结与展望

在本节课中,我们将对CS61C课程的核心内容进行总结,并探讨计算机科学领域的未来发展方向。我们将回顾所学的重要概念,并了解如何将这些知识应用于前沿研究与创新。


📚 课程核心思想回顾

上一节我们介绍了本课程的目标与结构,本节中我们来看看贯穿整个课程的六个核心思想。

  1. 抽象
    抽象是所有工程领域中最强大的思想之一。计算机科学作为工程学的一部分,其力量在于构建层层抽象。我们从高级语言(如Python)开始,穿越指令集架构(ISA),最终抵达由晶体管构成的物理硬件。每一层都隐藏了下层的复杂性,例如,虚拟内存 让系统认为拥有磁盘大小的RAM,这是一种由内存层次结构(缓存与虚拟内存)实现的精妙幻觉。

  2. 摩尔定律与性能
    摩尔定律描述了集成电路上晶体管数量的指数增长。虽然其字面意义是关于晶体管密度,但它也驱动了计算性能的持续提升。我们学习了如何利用局部性原理来设计高速缓存,从而在内存层次结构中平衡速度与容量。

  1. 并行性
    我们探索了五种不同层次的并行性:
    • 门级并行:所有逻辑门同时工作。
    • 指令级并行(ILP):通过流水线技术,让多条指令的不同阶段重叠执行。
    • 数据级并行(SIMD):单条指令同时处理多个数据元素。
    • 线程级并行:多个线程在多个处理器核心上同时运行。
    • 任务级并行:例如MapReduce,在成千上万台计算机上分布式处理任务。

  1. 性能度量
    如果没有清晰、可量化的度量标准,我们就无法改进系统。我们讨论了关键指标,如吞吐量(单位时间完成的工作量)和延迟(完成单个操作所需的时间),以及如何设计基准测试来评估和优化系统。

  1. 通过冗余实现可靠性
    可靠性是系统设计的基石。我们学习了多种通过冗余实现可靠性的技术,例如:
    • RAID:使用多个磁盘驱动器,即使一个故障也不会丢失数据。
    • 纠错内存(ECC):能够检测和纠正内存中的位错误。
    • 双机热备:一个系统出现故障,另一个可以立即接管。

  1. 硬件与软件的协同设计
    本课程的一个独特之处在于跨越了硬件与软件的界限。我们不仅学习了如何用高级语言编程,还深入了解了指令如何在底层硬件上执行,以及如何通过理解硬件特性来编写更高效的软件。


🔬 从理论到实践:研究与应用

我们不仅学习理论,也看到了知识如何驱动突破性创新。以神经链接(Neuralink)为例,它展示了脑机接口领域的巨大潜力。

  • 严格的测试流程:在将设备应用于人体之前,必须进行极其严谨的测试。流程包括:

    1. 台面测试与加速寿命测试。
    2. 在模拟大脑环境的模型中进行测试。
    3. 在动物(如羊、猪、猴子)身上进行确认性实验,确保设备稳定、可靠且可长期工作。
  • 技术演示:猴子“清酒”能够仅通过意念控制屏幕光标,移动到高亮的按键上进行“心灵感应”打字。这项技术的目标是帮助四肢瘫痪患者恢复与世界的交互能力。

  • 伦理与动物福利:在进行此类研究时,确保动物参与者的福祉至关重要。动物在参与过程中获得奖励(如香蕉奶昔),并将任务视为一种游戏。

🗓️ 课程结束与后续安排

以下是关于课程结束的重要时间节点和信息:

  • 复习周:接下来的一周是复习周,供大家准备期末考试。
  • 期末考试:定于周一上午8点至11点举行。考试将重点覆盖期中考试之后的内容,难度设计上会比传统期末考试更友好,目标是大多数同学能在更短的时间内完成。
  • 考试允许携带的资料:可以携带两张手写(非打印)的“小抄”纸(正反面均可),但必须是自己独立完成的。
  • 课程成绩:CS61C不是按比例曲线划分等级的课程,而是采用绝对评分标准。


🚀 未来学习与参与机会

如果你热爱这门课的内容,有许多方式可以继续深入:

  • 成为课程工作人员:考虑申请成为学术实习生(AI)、导师或助教(TA)。这是一个深化理解、帮助他人并获得支持的绝佳途径。
  • 参与研究:与教授建立联系是进入研究领域的关键。可以通过参加他们的办公时间、讲座后交流或使用research.berkeley.edu等平台寻找机会。
  • 相关后续课程
    • CS 152:计算机体系结构与工程,深入CPU设计。
    • CS 162:操作系统。
    • EECS 151:数字设计导论,涉及FPGA和芯片设计。
    • Decal课程:如“游戏工匠”(博弈论与求解器开发)、iOS开发、UC Bug(计算机图形学)等由学生教授的特色课程。


🌌 计算机科学的未来展望

我们所处的时代正经历着深刻的技术变革,以下是一些令人兴奋的未来方向:

  • 无处不在的计算:计算设备融入环境(物联网)、可穿戴设备成为常态。
  • 量子计算:可能对密码学等领域产生革命性影响。
  • 纳米技术:在极小尺度上操纵物质,应用于医疗、材料等领域。
  • 人工智能的普及:AI不仅在模式识别(如图像、医疗诊断)上表现出色,更可能重塑教育、艺术创作等领域。
  • 并行革命与后PC时代:云计算和分布式计算使得个人能操控巨大的计算资源,改变了我们获取和处理信息的方式。
  • 脑机接口:正如神经链接所展示的,直接连接大脑与计算机将从医疗辅助开始,未来可能扩展人类认知与交互的边界。

🎯 总结与寄语

本节课中我们一起回顾了CS61C的核心思想:抽象、性能、并行性、度量、可靠性和软硬件协同。我们看到了这些知识如何从理论走向改变世界的实践。

最后,分享两条寄语:

  1. 永不放弃:无论遇到什么困难,坚持下去。就像加州大学伯克利分校橄榄球队那场著名的“The Play”所诠释的精神一样,奇迹往往发生在最后一刻。
  2. 创造未来:正如计算机科学家艾伦·凯所说:“预测未来最好的方式,就是发明它。” 不要被动地接受未来,主动学习、探索和创造,去定义属于你自己的、也是属于我们所有人的技术未来。

感谢大家本学期付出的努力!请通过课程评估系统提供你们的宝贵反馈,帮助我们让CS61C变得更好。

祝大家在期末考试中取得优异成绩,并拥有一个美好的假期!

课程 P6:Lecture 5 - 内存(错误)管理 🧠💥

在本节课中,我们将要学习 C 语言中内存管理的核心概念,包括内存的不同区域(如栈和堆)、动态内存分配函数(mallocfreerealloc)的使用,以及常见的内存错误类型。理解这些内容对于编写健壮、高效的 C 程序至关重要。

数组大小与指针衰减 📏➡️📌

上一节我们介绍了数组和指针的基本概念,本节中我们来看看一个关于数组大小的具体练习,它揭示了指针衰减的重要现象。

考虑以下代码,问题是:打印出的选项是什么(A 或 B)?

#include <stdio.h>

void mysterious(int arr[]) {
    printf(“%zu\n”, sizeof(arr));
}

int main() {
    short nums[] = {1, 2, 3, 4, 5};
    printf(“%zu\n”, sizeof(nums));
    printf(“%zu\n”, sizeof(nums) / sizeof(short));
    mysterious(nums);
    return 0;
}

正确答案是 B。让我们来解释原因。

  • sizeof 是一个编译时运算符。编译器在编译阶段就会确定其操作数的大小。
  • 对于数组 numssizeof(nums) 计算的是整个数组占用的字节数。这里 nums 是一个包含 5 个 short 类型元素的数组。假设 short 占 2 个字节,那么 sizeof(nums) 的结果是 5 * 2 = 10 字节。
  • sizeof(nums) / sizeof(short) 计算的是数组中元素的数量,即 10 / 2 = 5
  • 关键点在于 mysterious 函数。当数组 nums 作为参数传递给函数时,会发生“数组到指针的衰减”。这意味着在函数内部,形参 arr 不再被视为一个数组,而是一个指向数组首元素的指针(即 short* 类型)。
  • 因此,在 mysterious 函数内部,sizeof(arr) 计算的是指针 arr 本身的大小(例如,在 32 位系统上是 4 字节,在 64 位系统上是 8 字节),而不是原数组的大小。

理解 sizeof 在数组和指针上的不同行为,对于后续理解动态内存分配非常有帮助。

C 程序的内存布局 🗺️

在深入讨论动态内存之前,我们需要建立一个关于 C 程序地址空间的心智模型。我们可以将内存想象成一个非常长的字节数组。

以下是内存的四个主要部分:

  1. :用于存储局部变量、函数参数和返回地址。当函数被调用时,其栈帧被压入栈;函数返回时,栈帧被弹出。栈通常从高地址向低地址增长。
  2. :用于动态内存分配(通过 malloc 等函数)。堆从低地址向高地址增长,与栈的增长方向相反。堆的大小可以在程序运行时改变。
  3. 静态数据区:用于存储全局变量和静态变量。这个区域的大小在程序编译时确定,不会增长或缩小。
  4. 代码区(文本段):用于存储程序的可执行指令。

此外,地址 0(NULL)是一个特殊地址,对其进行读写通常会导致程序崩溃。

静态变量(在函数外声明)存储在静态数据区,其生命周期贯穿整个程序。局部变量(在函数内声明)存储在栈上,其生命周期仅限于其作用域。接下来我们将重点讨论栈和堆的管理。

栈内存管理 ⬇️

栈内存的管理是自动的,由编译器负责。每个函数调用都会在栈上创建一个新的“栈帧”,其中包含了该函数的参数、局部变量以及返回地址。

一个称为“栈指针”的寄存器始终指向当前栈帧的顶部。考虑一个调用链:main -> a -> b -> c -> d。随着每个函数被调用,栈指针下移(栈增长);随着每个函数返回,栈指针上移(栈收缩)。

关于栈指针,有一个重要的注意事项:将指针传递到更深的栈空间(即调用者的栈帧)通常是安全的,因为调用者的局部变量在调用期间依然存在。例如,main 函数中有一个缓冲区,将其地址传递给另一个函数 load_buffer 是可行的,因为 main 的栈帧在 load_buffer 执行期间仍然有效。

然而,返回指向栈内存的指针是灾难性的错误。如果一个函数返回了其局部变量的地址,当该函数返回后,其栈帧可能被后续的函数调用覆盖。此时,指向原栈地址的指针就变成了“悬空指针”,读取它可能得到垃圾数据,写入它则会破坏当前活跃函数的栈帧,导致未定义行为。

为了避免栈内存的问题,并实现跨函数调用的持久化数据存储,我们需要使用堆内存。

堆内存与动态分配 ⬆️

堆内存,也称为动态内存,在程序运行时通过特定函数进行分配和释放。这与 Java 中的 new 关键字作用类似。堆内存对于需要跨函数持久存在的数据非常有用,但它也是指针错误和内存泄漏的主要来源。

需要澄清的是,这里的“堆”是指内存区域,与数据结构中的“堆”是完全不同的概念。堆内存的分配不是连续的,两次连续的 malloc 调用返回的地址可能相距甚远。决定内存块具体位置的是“堆分配器”。

以下是用于堆内存管理的三个核心 C 库函数:

  • void* malloc(size_t size):分配指定字节数的未初始化内存块,并返回指向该内存块起始地址的 void* 指针。如果分配失败,则返回 NULL
  • void free(void* ptr):释放之前由 malloccallocrealloc 分配的内存块。参数 ptr 必须是之前这些函数返回的地址。
  • void* realloc(void* ptr, size_t new_size):调整之前分配的内存块的大小。它可能返回一个新的地址,并将原有数据复制到新位置。如果 ptrNULL,则其行为等同于 malloc(new_size)。如果 new_size 为 0,则其行为等同于 free(ptr)

与栈不同,堆内存只有在程序员显式调用 free 时才会被释放,否则将一直存在,直到程序结束。

malloc 使用示例

以下是使用 malloc 的两个常见例子:

  1. 为结构体分配内存
    struct TreeNode {
        int value;
        struct TreeNode* left;
        struct TreeNode* right;
    };
    struct TreeNode* tp = (struct TreeNode*)malloc(sizeof(struct TreeNode));
    
    注意,我们使用 sizeof(struct TreeNode) 而不是硬编码字节数,这保证了代码在不同平台上的可移植性。同时,我们将 malloc 返回的 void* 显式转换为 struct TreeNode*

  1. 为数组分配内存
    int* arr = (int*)malloc(20 * sizeof(int));
    
    这里我们分配了一个足以容纳 20 个 int 的连续内存块。同样,使用 sizeof(int) 确保了可移植性。

freerealloc 注意事项

使用 free 时,必须传入由分配函数返回的原始地址。违反此约定会导致未定义行为。

使用 realloc 时,必须注意其返回值可能是一个新的地址。因此,常见的模式是使用一个临时指针来接收 realloc 的结果,检查是否为 NULL(分配失败),然后再赋值给原指针。

int* new_arr = (int*)realloc(arr, 40 * sizeof(int));
if (new_arr != NULL) {
    arr = new_arr; // 更新指针
} else {
    // 处理分配失败,原 arr 指向的内存依然有效
}

链表操作示例 🔗

让我们通过一个在链表头部插入节点的例子,来综合运用动态内存分配和双指针的概念。

struct node {
    char* data;
    struct node* next;
};

void add_to_front(struct node** head_ref, char* new_data) {
    // 1. 为新节点分配堆内存
    struct node* new_node = (struct node*)malloc(sizeof(struct node));
    // 2. 为新节点的数据分配堆内存并复制字符串
    new_node->data = (char*)malloc(strlen(new_data) + 1);
    strcpy(new_node->data, new_data);
    // 3. 设置新节点的 next 指针指向原链表头
    new_node->next = *head_ref;
    // 4. 更新链表头指针,使其指向新节点
    *head_ref = new_node;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/4dde71387e03da961511e2352368038f_34.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/4dde71387e03da961511e2352368038f_35.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/4dde71387e03da961511e2352368038f_37.png)

int main() {
    struct node* head = NULL;
    add_to_front(&head, “abc”);
    // ... 后续操作
    return 0;
}

在这个例子中:

  • add_to_front 接收一个指向头指针的指针(双指针 head_ref),这样它就能修改调用者(main 函数)中的 head 变量。
  • 函数内两次调用 malloc:一次为 struct node 节点本身,一次为节点内的字符串数据。这两个内存块都在堆上,生命周期独立于函数调用。
  • 最后一步 *head_ref = new_node; 通过解引用双指针,直接修改了 main 函数中 head 变量的值,使其指向新创建的节点。

如果不使用双指针,函数将无法修改调用者的头指针,新节点在函数返回后就会丢失。

常见内存错误 🚨

堆内存管理不当会导致多种严重错误。我们可以将其类比为一份必须遵守的“教父合同”,违反合同将招致麻烦。

主要的错误类型包括:

  1. 内存泄漏:分配了内存(malloc),但在程序结束前忘记了释放它(free)。这会导致程序占用的内存不断增长,最终可能耗尽系统资源。泄漏通常不会立即导致崩溃,但会严重影响性能和稳定性。
  2. 释放后使用:在调用 free 释放了一块内存后,仍然通过指针访问该内存。这会导致读取到垃圾数据或写入时破坏其他数据(如果该内存区域已被重新分配),是安全漏洞的常见来源。
  3. 重复释放:对同一块内存多次调用 free。这会导致堆管理器的内部数据结构损坏,进而引发不可预知的崩溃。

此外,在使用 realloc 时也容易犯错:

  • 忘记更新指针realloc 可能返回新地址,如果忘记用返回值更新原指针,原指针就变成了悬空指针。
  • 直接使用原指针接收返回值:如 ptr = realloc(ptr, new_size);,如果 realloc 失败返回 NULL,则原指针丢失,导致内存泄漏。

错误识别练习与工具 🛠️

识别内存错误需要练习。考虑以下代码片段,其中包含了多个内存管理错误:

void some_func() {
    int f, h;
    int* ptr = &f;
    free(ptr); // 错误 1: 尝试释放栈地址
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs61c-arch/img/4dde71387e03da961511e2352368038f_51.png)

int main() {
    int* p = (int*)malloc(sizeof(int));
    free(p + 1); // 错误 2: 释放非 malloc 返回的原始地址
    free(p);     // 正确
    free(p);     // 错误 3: 重复释放
    return 0;
}

错误分析:

  1. 错误 1:ptr 指向栈上的局部变量 f,对其调用 free 是未定义行为。
  2. 错误 2:free 的参数必须是 malloc/calloc/realloc 返回的精确地址。p + 1 不是原始地址。
  3. 错误 3:对已经释放的指针 p 再次调用 free,属于重复释放。

为了帮助检测这些错误,我们可以使用名为 Valgrind 的工具。它可以检测内存泄漏、非法内存访问、使用未初始化的值等多种问题,是 C/C++ 程序员调试内存问题的利器。

堆分配器内部机制预览(可选)🔍

为什么堆分配不是连续的?堆分配器需要管理一系列空闲和已用的内存块。当收到分配请求时,它会在空闲块中寻找合适大小的空间。当内存被释放时,该块又变回空闲。这个过程可能导致内存碎片——即空闲内存分散在许多小块中,无法满足一个大的连续分配请求。分配器需要采用策略(如“首次适应”、“最佳适应”)来决定如何分配和合并内存块,以平衡性能和碎片化程度。


本节课中我们一起学习了 C 语言内存管理的核心知识。我们了解了栈和堆的区别,掌握了 mallocfreerealloc 等动态内存管理函数的使用方法,并通过链表示例实践了双指针的应用。最后,我们重点分析了内存泄漏、释放后使用和重复释放等常见错误及其危害。牢记“谁分配,谁释放”的原则,并善用 Valgrind 等工具进行检查,是写出高质量 C 代码的关键。

课程 P7:讨论课 2 - C 语言内存基础 🧠

在本节课中,我们将学习 C 语言中关于内存的核心概念。我们将回顾 C 的基础知识、内存的结构与行为、函数调用时的内存状态,并通过练习题来巩固理解。课程内容力求简单直白,适合初学者。


概述 📋

C 语言是静态类型的,这意味着我们需要明确指定数据的类型。类型决定了如何解释一系列比特。例如,八个比特可以解释为一个字符、一个整数或一个内存地址。

核心概念:类型转换(Casting)

int a = 10;
char c = (char)a; // 将整数 a 转换为字符类型

数据类型与大小 📏

一个字符(char)总是一个字节(在 61C 课程中定义为 8 位)。其他类型的大小可能因系统而异:

  • short:至少 2 个字节。
  • int:至少 4 个字节(通常是有符号的)。
  • long:至少 8 个字节。
  • 指针(内存地址):在 32 位系统中是 4 字节,在 64 位系统中是 8 字节。本课程默认使用 32 位系统。

指针的大小之所以变化,是因为它需要能够寻址系统所有可用的内存字节。32 位系统有 2^32 个可能的地址,因此需要 32 位(4 字节)来表示。


内存与数组 🧱

内存可以被视为一个巨大的连续字节数组。数组就是内存中一块连续的存储区域。

核心概念:数组索引与内存地址
假设有一个字符数组 char arr[5],它在内存中占据 5 个连续的字节。arr[0] 的地址是最低的。

关于字节在内存中的存储顺序,有两种方式:

  • 小端序(Little-endian):最低有效字节存储在最低的内存地址。这是 x86 等大多数现代系统使用的方式。
  • 大端序(Big-endian):最高有效字节存储在最低的内存地址。

指针详解 🎯

变量是存储在内存中某个地址的值的“昵称”。指针是一种特殊的变量,其存储的值是一个内存地址。

核心概念:声明与解引用

int x = 2;        // 一个整数变量
int *p = &x;      // p 是一个指针,其值是 x 的内存地址
int y = *p;       // 解引用 p:获取 p 所指向地址(即 x 的地址)存储的值,y 等于 2
  • & 运算符:获取变量的地址。
  • * 运算符(在类型后):声明一个指针。
  • * 运算符(在变量前):解引用,获取指针指向的值。

指针本身也存储在内存中,因此可以有指向指针的指针(int **pp = &p)。


内存的布局与分区 🗺️

程序的内存通常被划分为几个主要部分:

  1. 代码/文本区(Code/Text):存储可执行的机器指令。通常是只读(Read-Only)的。
  2. 静态/数据区(Static/Data):存储全局变量和静态变量。部分可能是只读的(如常量),部分是可读写的。
  3. 堆区(Heap):用于动态内存分配(如 malloc, calloc)。由程序员手动管理(分配和释放)。从低地址向高地址增长。
  4. 栈区(Stack):用于函数调用,存储局部变量、函数参数等。由系统自动管理。从高地址向低地址增长。

栈和堆相向生长,如果它们相遇,就会发生“堆栈溢出”。

权限说明

  • R:读
  • W:写
  • X:执行
    现代系统通常遵循“W^X”原则,即一块内存区域不能同时可写和可执行,以增强安全性。

动态内存分配 ⚙️

动态内存分配在堆上进行。如果分配的内存不再需要,必须手动释放,否则会导致“内存泄漏”。

核心函数

  • void *malloc(size_t size):分配指定字节数的内存,返回指向该内存的指针。内存内容是未初始化的“垃圾值”。
  • void *calloc(size_t num, size_t size):分配 num 个长度为 size 的连续内存,并初始化为 0
  • void *realloc(void *ptr, size_t new_size):重新调整之前分配的内存块大小。
  • void free(void *ptr):释放之前分配的内存。

内存碎片化:频繁分配和释放不同大小的内存块可能导致空闲内存不连续,即使总空闲空间足够,也可能无法满足大块内存的分配请求。


指针算术 ➕

可以对指针进行算术运算(加、减),其单位是指针所指向类型的大小

int arr[5];
int *p = &arr[0]; // p 指向 arr[0]
p = p + 1;        // 现在 p 指向 arr[1],地址实际增加了 sizeof(int)(通常是4)个字节

这比直接操作地址更方便,因为编译器会自动考虑类型大小。


字符串与字符数组 📝

在 C 语言中,字符串本质上是以空字符(\0)结尾的字符数组

重要区别

char str1[] = "Hello"; // 字符数组,内容在栈上,**可以修改**
char *str2 = "World";  // 指针指向字符串字面量,通常存储在只读数据区,**不可修改**

str1 是一个数组,你可以修改其中的字符。str2 是一个指针,指向一个字符串常量,尝试修改其内容可能导致运行时错误。

处理字符串时,请务必确保其以 \0 结尾,否则使用 strlenstrcpy 等函数时会出现错误。


按值传递与按引用传递 🔄

C 语言的函数参数传递默认是按值传递。这意味着函数内部获得的是实参值的一个副本。要修改原始变量,需要传递它的指针(即按引用传递)。

void increment(int x) {
    x++; // 只修改了副本,不影响外部变量
}
void real_increment(int *x) {
    (*x)++; // 解引用指针,修改原始变量
}

总结 🎓

本节课我们一起学习了 C 语言内存管理的核心知识:

  1. 数据类型与指针:理解了变量、内存地址和指针的关系,以及如何声明、初始化和使用指针。
  2. 内存布局:认识了代码区、静态区、堆区和栈区的不同作用与特点。
  3. 动态内存管理:学习了如何使用 mallocfree 等函数在堆上分配和释放内存,并了解了内存泄漏和碎片化的概念。
  4. 字符串本质:明确了 C 语言中字符串是以空字符结尾的字符数组,并区分了可修改与不可修改的字符串声明方式。
  5. 参数传递:掌握了 C 语言按值传递的特性,以及如何通过指针实现按引用传递。

掌握这些概念是理解 C 程序如何运作以及后续学习更复杂主题(如数据结构、系统编程)的基础。请务必通过练习题来巩固这些知识。

课程 P8:Lecture 6 - 浮点数表示法 🧮

在本节课中,我们将要学习计算机中如何表示带有小数部分的数字,即浮点数。我们将从简单的定点表示法开始,逐步过渡到更高效、更通用的浮点表示法,并深入探讨其背后的IEEE 754标准。

概述:从整数到小数

到目前为止,我们一直在讨论整数的表示方法。我们学习了无符号整数和有符号整数(特别是补码表示法)。然而,现实世界和计算中不仅仅只有整数,还有包含分数的大数或小数。例如,每千年的秒数(约3.1556×10¹⁰)或极小的物理常数(如5.29×10⁻¹¹)。这些数字无法用我们之前学过的整数格式来精确表示。

为了解决这个问题,我们需要一种能够表示整数部分和小数部分的方法。本节我们将首先探讨一种直观但有限的方法——定点表示法,然后引出更强大的浮点表示法。

定点表示法:一个简单的起点

为了表示同时包含整数和小数部分的数字(如2.66或2.5),我们首先考虑一种简单的方法:定点表示法。这种方法将二进制点的位置固定在某两位之间。

在定点表示法中,我们像处理无符号整数一样处理比特位,但二进制点左侧的位代表2的正幂,右侧的位代表2的负幂。

公式:对于一个定点数,其值可以计算为:
值 = Σ (位_i × 2^(位置_i)),其中位置_i可以是正数(整数部分)或负数(小数部分)。

例如,对于6位定点表示 101.010(二进制点在第三和第四位之间),其计算过程如下:

  • 1 × 2² = 4
  • 0 × 2¹ = 0
  • 1 × 2⁰ = 1
  • .(二进制点)
  • 0 × 2⁻¹ = 0
  • 1 × 2⁻² = 0.25
  • 0 × 2⁻³ = 0
    总和为 4 + 1 + 0.25 = 5.25(十进制)。

定点数的加法和乘法与十进制算术类似,只需对齐二进制点即可。然而,定点表示法有一个明显的缺点:为了同时表示非常大和非常小的数,我们需要非常多的位数。例如,表示一个10³⁴的大数可能需要左边34位,而表示一个10⁻¹¹的小数可能需要右边58位,总共需要92位,这非常低效。

浮点表示法:更聪明的方案

既然定点表示法在表示范围上效率低下,那么一定有更好的方法。这就是浮点表示法。其核心思想类似于科学计数法,让二进制点可以“浮动”,从而更有效地利用比特位。

在浮点表示中,一个数字被分为几个部分来存储。以下是一个将十进制数1.640625转换为二进制浮点表示的示例:
二进制表示为:1.101001(后跟许多0)。
关键部分在于:

  1. 有效数字:存储非零的有效数字部分,例如 101001
  2. 指数:存储二进制点需要移动的位数,例如,对于 1.101001,点需要左移1位(从 1.101001.1101001),所以指数为 -1(以2为底)。

通过分别存储有效数字和指数,我们可以用更少的位数表示更大范围的数字。

科学计数法与规范化形式

浮点表示本质上是二进制下的科学计数法。让我们回顾一下十进制科学计数法,它由两部分组成:

  • 有效数字:包含所有有效数字的部分,例如 1.640625。
  • 指数:以10为底的幂,例如 10⁻¹。

在科学计数法中,我们通常使用规范化形式,即确保小数点左边有一位非零数字。例如,3.0 × 10⁻⁹ 是规范化的,而 0.3 × 10⁻⁸30 × 10⁻¹⁰ 则不是。

在二进制中,规范化形式有一个非常重要的特性:由于二进制只有0和1,一个规范化的二进制数其小数点左边总是1。这意味着在存储时,我们可以隐含这个前导的1,从而节省一个比特位,提高存储效率。

IEEE 754 浮点标准

为了统一不同计算机体系结构中的浮点运算,IEEE制定了754标准。这是当今绝大多数计算机使用的浮点数表示和运算规范。我们将重点学习32位的单精度浮点格式。

一个单精度浮点数(float类型)的32位被划分为三个字段:

  1. 符号位:第31位(最高位)。0表示正数,1表示负数。
  2. 指数域:第30位到第23位,共8位。它采用偏置表示法,存储的值是 实际指数 + 127。这样设计是为了便于用整数比较硬件来快速比较浮点数的大小。
  3. 有效数字域:第22位到第0位,共23位。它存储的是规范化二进制小数点的小数部分(即去掉前导1之后的部分)。因为规范化数的前导1总是存在,所以被隐含存储,在计算时需要加上。

公式:一个单精度浮点数所表示的值可以用以下公式计算:
值 = (-1)^符号位 × (1 + 有效数字) × 2^(指数 - 127)

让我们通过一个例子来理解这个公式。假设32位模式为:1 10000001 01000000000000000000000

  • 符号位 = 1,表示负数。
  • 指数域 = 10000001(二进制) = 129(十进制)。实际指数 = 129 - 127 = 2。
  • 有效数字域 = 01000000000000000000000。隐含前导1后,完整的有效数字为 1.01(二进制)。
  • 计算数值:-1 × (1.01)₂ × 2²
    • (1.01)₂ = 1 + 0 × 2⁻¹ + 1 × 2⁻² = 1 + 0.25 = 1.25(十进制)。
    • 乘以 2² = 4,得到 1.25 × 4 = 5.0。
    • 最终结果为 -5.0

可表示的数字与步长

由于浮点数使用固定位数(如32位)表示,它无法表示实数轴上的所有数字。可表示的数字是离散的。两个相邻可表示浮点数之间的差值称为步长精度

步长的大小不是固定的,它取决于指数的大小。

  • 对于相同的指数,步长是恒定的,等于 2^(指数 - 127) × 2^(-23)
  • 当指数较大(表示很大的数)时,步长也较大。这对于科学计算是合理的,因为大数之间的微小差异相对不重要。
  • 当指数较小(表示很小的数,接近0)时,步长非常小。这提供了高精度,对于处理微小常数的科学计算至关重要。

这种可变步长的设计,使得浮点数能够在广泛的数值范围内(从极小的分数到极大的天文数字)保持相对合理的精度。

特殊值

IEEE 754标准预留了一些特定的指数和有效数字组合,用于表示特殊值,这对于健壮的数字计算至关重要。

以下是主要的特殊值:

  1. :指数域和有效数字域全为0。根据符号位不同,有 +0-0 两种表示。
  2. 无穷大:指数域全为1(二进制11111111),有效数字域全为0。用于表示溢出(如除以0)的结果,有 +∞-∞
  3. NaN:指数域全为1,有效数字域非零。表示“非数字”,用于处理无效操作的结果(如对负数开平方、0/0等)。NaN有很多种,有效数字域可以用来编码错误类型。
  4. 非规范化数:指数域全为0,但有效数字域非零。这些数没有隐含的前导1(即有效数字为 0.xxx)。它们用于表示那些比最小规范化正数还要接近0的数字,实现了渐进式下溢,避免了在0附近出现巨大的“精度空洞”。

溢出与下溢

  • 溢出:当一个数的绝对值太大,超过了最大规范化浮点数所能表示的范围时,发生溢出。结果通常被设置为 ±∞
  • 下溢:当一个数的绝对值太小,比最小规范化正数还要小,但又大于0时,发生下溢。在支持非规范化数的系统中,下溢的结果会以非规范化数的形式表示,精度逐渐降低;在不支持的系统中,可能直接归为0。

整数运算只有溢出(结果超出表示范围),而浮点运算既有溢出也有下溢,因为它需要表示非常接近0的数值。

总结

本节课我们一起学习了计算机中表示实数的方法——浮点数。我们从定点表示法的局限性出发,引入了类似于科学计数法的浮点思想。然后,我们深入探讨了业界通用的IEEE 754标准,了解了单精度浮点数的位布局、规范化形式以及其计算公式。我们还学习了浮点数可表示的数字范围、可变步长的概念,以及标准如何通过定义零、无穷大、NaN和非规范化数等特殊值来处理各种边界情况和算术错误,从而构建一个健壮的数值计算系统。理解浮点数的这些特性对于编写正确、可靠的数值计算程序至关重要。

P9:课程7:RISC-V汇编语言入门 🚀

在本节课中,我们将结束对浮点运算的讨论,并开始学习新的主题——RISC-V汇编语言。我们将首先介绍一个实用的浮点转换工具,然后深入探讨浮点数的精度、舍入模式以及运算特性。最后,我们将正式进入汇编语言的世界,了解RISC-V的基本概念、寄存器以及简单的算术指令。

浮点运算回顾与工具介绍 🔧

上一节我们讨论了浮点数的基本表示。本节中,我们来看看一个非常有用的在线工具,它可以帮助我们直观地理解浮点数的位表示。

这个工具名为“IEEE-754浮点转换器”,链接在幻灯片中。它能精确地展示比特位如何映射到单精度浮点数(32位)的三个部分(符号位、指数位、尾数位)。例如,你可以勾选尾数域中的各个位,观察数值如何变化。工具会显示有偏指数、实际指数、所表示的十进制小数以及二进制和十六进制表示。

以下是一个演示:假设我想表示一个非常大的数,例如 1.5 × 10^38。我可以在工具中输入这个十进制数,它会自动填充对应的各个比特位。工具还提供了“加一”和“减一”按钮,用于跳转到下一个可表示的浮点数。这很好地印证了我们上节课提到的概念:在给定的指数下,你只有2^23个可表示的数字。通过这个工具,你可以精确地计算出下一个可表示的数字是什么。

需要注意的是,这个工具目前仅适用于32位单精度浮点数。

浮点数的精度、准确度与其他格式 📊

接下来,我们讨论一些其他表示形式,并厘清两个重要概念:精度与准确度。

  • 精度 是指可用的比特位数。例如,单精度有32位(1位符号 + 8位指数 + 23位尾数)。
  • 准确度 是指计算机的表示值与你想要表示的高精度实际数值的接近程度。更多的比特位通常能带来更高的准确度,但这并非绝对保证。例如,无论你用多少位浮点数,都无法精确表示无理数π,只能得到它的近似值。

现在,让我们看看其他浮点格式:

  • 双精度浮点数:在C语言中称为 double,也称为binary64。它使用64位:1位符号、11位指数和52位尾数。指数偏移量是1023。与单精度相比,双精度的主要优势在于其尾数位更多,因此具有更高的精度,可以表示更大范围的数字(大约从10-308到10308)。
  • 其他精度:除了单精度和双精度,还存在其他格式,如半精度(16位)、四精度(128位)等。在一些特定领域(如机器学习)的架构中,可能会使用定制的浮点格式(如FP16, BFloat16)甚至整数来表示数据,这通常是为了在精度和效率(如功耗、速度)之间取得平衡。

浮点数的特性与运算 🧮

浮点数运算有一些不同于整数的特性,理解这些对编程至关重要。

首先,浮点加法不满足结合律。例如,考虑 (x + y) + zx + (y + z)。如果 y 是一个非常大的数(如 1.5e38),而 z 是一个很小的数(如 1),那么 y + z 的结果可能仍然是 y(因为 zy 的“步长”下无法被分辨)。这会导致两种计算顺序产生不同的结果。

由于浮点数不能表示所有实数,因此必须定义舍入模式来处理近似。硬件通常使用额外的精度位来决定如何舍入。常见的舍入模式有:

  • 向正无穷大舍入
  • 向负无穷大舍入
  • 向零舍入(截断)
  • 向最近偶数舍入(默认的“无偏”模式)

“向最近偶数舍入”规则是:当恰好处于两个可表示值的中间时,选择偶数那个。例如,2.5舍入为2,3.5舍入为4。这种模式平均而言不会总是向上或向下舍入,保证了计算的公平性。

浮点加法运算比整数加法更复杂,因为需要对齐指数。基本步骤是:

  1. 对阶:使两个操作数的指数相同。
  2. 尾数相加。
  3. 将结果规范化(调整尾数和指数)。

类型转换也需要特别注意:

  • float 转换为 int截断小数部分。
  • int 转换为 float 会舍入到最接近的可表示浮点数。
  • 注意,转换可能不是无损的。例如,一个大整数转换为 float 后再转回 int,可能因为 float 无法精确表示该整数而得到不同的值。同样,一个带小数的 float 转换为 int 再转回 float,小数信息会丢失。

一个著名的例子是验证码问题:“0.1 + 0.2 等于多少?” 如果你用双精度浮点数计算,结果可能是 0.30000000000000004,而不是精确的 0.3。这是因为 0.10.2 在二进制中无法精确表示,它们的和也存在微小的表示误差。

进入汇编语言与RISC-V的世界 ⚙️

现在,我们将“换低档”,从高级语言(如C)深入到更低的一层:汇编语言。我们将聚焦于一种名为 RISC-V 的指令集架构。

我们可以将计算机系统想象成一系列层次:

  • 高级语言(如C)
  • 汇编语言(如RISC-V)
  • 机器码(比特位)
  • 处理器(CPU)
  • 逻辑门

汇编语言是CPU能够直接理解的指令的一种人类可读表示。每一行汇编代码通常对应一条CPU指令。指令集架构 定义了CPU支持的所有指令和规则,它是硬件和软件之间的契约。常见的ISA有x86(Intel/AMD电脑)、ARM(手机)等。

RISC-V是一种 精简指令集计算机 架构。它的设计哲学源于20世纪80年代David Patterson、John Hennessy等人的工作,旨在通过保持指令集简单来简化硬件设计,而将创造复杂功能的智能留给编译器。这与当时主流的复杂指令集计算机 思想相反。2017年,Patterson和Hennessy因对RISC架构的贡献获得了图灵奖。

我们选择学习RISC-V是因为:

  • 它简单、优雅,易于教学。
  • 它是一个开源的、免版税的规范,正在被广泛采用。
  • 我们的教科书也以其为基础。

RISC-V基础:寄存器与算术指令 🧠

汇编语言与C语言的一个关键区别在于:C语言操作变量,而汇编语言操作寄存器

  • 变量(C语言):具有类型(如 int, float*),类型决定了操作的含义(例如,对指针加2意味着地址增加8字节)。
  • 寄存器(RISC-V):是CPU内部少量的、高速的存储位置。寄存器本身没有类型,它们只是存储比特位。是指令决定了如何解释这些比特位(如作为有符号整数、无符号整数或地址进行操作)。

RISC-V有32个通用寄存器,编号为 x0x31,每个寄存器宽度为32位(或64位,取决于变种)。寄存器 x0 是特殊的,它硬连线为值0,且向它写入数据不会产生任何效果(“无操作”)。

汇编指令的基本格式是:操作名 目标寄存器, 源寄存器1, 源寄存器2

  • 目标寄存器:存储操作结果的地方。
  • 源寄存器:提供操作数的寄存器。

让我们看两个基本算术指令:

  1. 加法指令 add

    add x1, x2, x3  # x1 = x2 + x3
    

    这条指令将寄存器 x2x3 中的值相加,结果存入 x1

  2. 减法指令 sub

    sub x4, x5, x6  # x4 = x5 - x6
    

    这条指令用 x5 减去 x6,结果存入 x4。注意操作数的顺序。

在汇编中,一行C代码可能对应多条指令。例如,C语句 f = (g + h) - (i + j) 可能被翻译成:

add x5, x1, x2   # 临时存储 g + h 到 x5
add x6, x3, x4   # 临时存储 i + j 到 x6
sub x10, x5, x6  # x10 = (g+h) - (i+j),假设f对应x10

此外,还有立即数加法指令 addi,它可以将一个常数直接加到寄存器上:

addi x3, x4, 10  # x3 = x4 + 10

RISC-V设计哲学是保持指令集最小化。因此,它没有 subi(立即数减法)指令,因为 x4 - 10 可以通过 addi x3, x4, -10 来实现。

利用 x0 寄存器,我们可以实现一些常用操作:

add x3, x4, x0   # x3 = x4 (将x4的值复制到x3)
addi x3, x0, 0xf # x3 = 15 (将常数15加载到x3)

总结 📝

本节课中,我们一起学习了以下内容:

  1. 使用在线工具可视化浮点数的位表示,并理解其离散性。
  2. 区分了浮点数的精度与准确度,并了解了双精度等其他浮点格式。
  3. 探讨了浮点数运算的重要特性,如不满足结合律、舍入模式以及类型转换的陷阱。
  4. 从高级语言下降到汇编语言层,了解了指令集架构的概念以及RISC-V的历史与设计哲学。
  5. 学习了RISC-V汇编语言的基础:32个无类型的寄存器、特殊的 x0 寄存器,以及 add, sub, addi 等基本算术指令的格式和用法。

通过本课,你已经开始接触计算机如何真正执行指令的底层表示。在接下来的课程中,我们将继续探索更多的RISC-V指令和编程概念。

posted @ 2026-02-04 18:20  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报