CMU-15-213-计算机系统导论笔记-全-

CMU 15-213 计算机系统导论笔记(全)

P01:课程概述 🖥️

在本节课中,我们将学习CMU 15-213/15-513/14-513《计算机系统导论》课程的核心内容、课程目标、学术诚信政策以及课程的基本安排。本课程旨在帮助你理解计算机系统底层的工作原理,为后续高级课程和实际编程工作打下坚实基础。

课程简介与目标

欢迎来到2017年秋季学期的15-213课程,以及18-213和15-513课程。这门课程有三个不同的编号,研究生版本(513)和本科生版本(213)之间略有差异,但总体上我们将其作为一个大型的、统一的课程来运行。

这门课程被称为“计算机系统导论”,其核心理念是,许多同学之前接触计算机的方式可能比较抽象,例如使用Python或其他高级语言编程,距离机器的实际运行(如比特位的设置、字节的存储位置、网络数据包的传输等)较远。本课程的目的就是引导你开始理解计算机内部的实际运作机制。

即使这是你一生中唯一一门系统课程,你也能从中获得非常有用的知识,这些知识可以应用于你的工作或进一步的学习。你将更深入地理解系统,知道当程序出现异常时如何排查和修复代码。对于那些将继续学习其他课程(如计算机网络、编译器、操作系统、分布式系统、计算机图形学、嵌入式系统、计算机体系结构等)的同学来说,这门课程是一个关键的入门点。多年来,这些高级课程都建立在假设你已经掌握了本课程内容的基础上。

课程核心主题与示例

在理想世界中,我们或许不需要了解所有这些底层细节,程序应该按照预期运行。但现实是,事情并不总是按计划进行。如果你想修复错误、确保系统安全或添加新功能,就必须理解系统的工作原理。计算机科学和所有工程学科一样,都建立在原则性抽象之上。这些抽象在大多数情况下有效,但并非总是成立。本课程将探索程序员通常使用的抽象与硬件、系统软件底层实际运行之间的边界。

以下是本课程将涵盖的几个核心主题示例:

整数(int)并非数学整数

在C语言中声明一个int类型变量时,它并不完全等同于数学上的整数。例如,根据代数知识,一个数的平方应该总是大于或等于零。让我们测试一下这个假设。

我写了一个非常简单的程序 sq.c,它计算 x * x。当我们用 40,000 运行时,结果是 1,600,000,000,符合预期。但当我们用 50,000 运行时,结果却是 -1,794,967,296。这看起来不像任何数的平方,它是负数,而且没有零。这是因为在这台使用32位字长的Linux机器上,所有int类型的数字都用32位表示。50,000的平方太大,无法用这种表示法容纳,导致了溢出,从而改变了位模式。

代码示例:

// sq.c
int square(int x) {
    return x * x;
}

这可能导致严重问题,例如在控制火箭时,如果期望计算50,000的平方却得到一个负值,后果不堪设想。事实上,历史上确实发生过因数值溢出导致火箭失控的例子。Python等语言通过使用可变长度的数字表示方式避免了这个问题,但C语言没有。理解不同语言如何处理数字表示是成为高效系统程序员的重要部分。

浮点数的非结合性

对于浮点数,持续对一个数平方,结果总是正数。但算术的结合律在浮点数中并不总是成立。结合律是指对于加法和乘法,可以改变括号的位置而最终结果不变。

例如,使用课程中将熟悉的GDB调试器内置的解释器进行计算。计算 (1e20 + -1e20) + 3.14 得到 3.14(尽管3.14被显示为3.13...)。但如果改变括号顺序为 1e20 + (-1e20 + 3.14),结果却是0。原因在于,在科学记数法中只保留小数点后一定位数,1e20这个巨大的数使得3.14在求和时被“淹没”了,右边的和计算结果就是-1e20,再加上1e20就得到了0。

浮点数可以处理更大的数值范围,但其代价是精度有限,可能导致有效信息的丢失。如果不理解这一点,在进行数值计算时可能会遇到麻烦。本课程将花相当多的时间学习数字表示及其实际格式和属性,因为这是计算机科学许多领域的基石。几年前,计算机图形学课程将本课程设为先修课,就是希望学生能理解浮点数运算。

内存访问与程序错误

另一个重要部分是理解程序如何使用大量内存。现代计算机提供了可以访问海量内存的抽象,但物理内存是有限的,并且具有层次结构(从处理器芯片内到外部芯片,再到磁盘驱动器)。操作系统和硬件协同工作,让你感觉像是在访问大量内存,但这会根据你的内存访问模式带来不同的性能特征。

在C或C++等语言中,引用内存的方式可能导致严重且难以调试的错误。以下是一个简单的结构体示例:

代码示例:

struct {
    int a[2];
    double d;
} struct_t;

double fun(int i) {
    volatile struct_t s;
    s.d = 3.14;
    s.a[i] = 1073741824; // 可能越界写入
    return s.d;
}

这个函数将double d设置为3.14,然后向数组a的任意位置i写入一个数。在Java等语言中,只允许引用a[0]a[1],但C语言没有数组边界检查,它会允许引用a[39],从而在内存中存储一个与正常情况无关的数。

运行这个程序会发现,当i为0或1时,返回3.14。当i为2时,返回值不再是3.14。当i为3时,3.14变成了2。当i为4时,程序崩溃,因为系统检测到这是无效的内存访问。在其他情况下,这种错误可能不会立即导致崩溃,程序可能运行数分钟、数小时甚至数周后才出现严重错误,且难以定位错误注入点。

理解内存布局、数据结构如何排列,至少能让这些现象变得可理解。这是使用C/C++编程的现实之一,很多人因此选择其他语言。但现实是,世界上有大量代码是用C/C++编写的,这是其特性之一。本课程将帮助你理解这些底层机制。

程序性能与内存层次结构

本课程还将关注程序性能,从多个层面进行分析。像15-122这样的课程中,你学习了大O表示法和渐近复杂度的概念。但很多时候,性能问题不仅仅是算法复杂度的问题。例如,运行视频编码器时,如果帧率只能达到15帧/秒而非30帧/秒,这就不是渐近复杂度问题,而是需要让代码运行速度翻倍。工程世界中,常数因子也常常很重要。如果你足够理解程序执行过程,通常可以找到优化性能的空间。

内存系统和性能的结合可以通过以下两个函数来体现,它们执行完全相同的操作:将一个2048x2048的源数组复制到目标数组。

代码示例:

// 行优先复制
void copyij(int src[2048][2048], int dst[2048][2048]) {
    int i, j;
    for (i = 0; i < 2048; i++)
        for (j = 0; j < 2048; j++)
            dst[i][j] = src[i][j];
}

// 列优先复制
void copyji(int src[2048][2048], int dst[2048][2048]) {
    int i, j;
    for (j = 0; j < 2048; j++)
        for (i = 0; i < 2048; i++)
            dst[i][j] = src[i][j];
}

两个程序的唯一区别是内层循环的顺序:一个是按行遍历,一个是按列遍历。逻辑结果完全相同。但在实际机器上运行,性能差异可能高达20倍,行优先方式要快得多。这是内存层次结构的一个例子:行优先访问更符合内存层次结构对访问模式的预期,而列优先访问则打乱了这种模式。教材封面的图片就解释了为什么行优先优于列优先。我们将理解这种现象,并称之为“内存山”。

网络与系统视角

除了编写操作数字或访问内存的小程序,计算机系统还有许多其他有趣的部分,特别是计算机通过网络相互通信。这涉及许多复杂的层次、标准、协议、硬件和软件。我们不会深入探讨,你需要学习网络或分布式系统课程才能真正掌握。但本课程会给你一个起点,让你更好地理解计算机在使用无线网络或浏览器获取网页时发生了什么。

本课程与大多数系统课程(包括你将在此之后学习的高级课程)的不同之处在于,大多数系统课程围绕如何构建系统来设计。例如,在CMU的操作系统课程中,你会花大量时间编写操作系统的核心代码;在编译器课程中,编写编译器;在计算机体系结构课程中,设计微处理器。这些都是非常有趣的实践。

但我们认为这不是入门的最佳方式。我们喜欢以程序员为中心的方式来介绍系统。本课程中,“程序员”主要指C程序员。你将学习如何编写代码来运用、利用和与系统的不同部分交互,以及如何变得更高效、更出色。这为你提供了一个视角,当你之后学习操作系统课程时,他们已经教你如何编写信号处理程序,而你已经从本课程中了解了信号处理程序是什么及其在应用程序中的用途和价值。

我们主要关注应用程序代码。这将为你提供一个起点,当你进入更高级的系统编程和系统设计时,你会理解你要实现的目标。这种用户视角或程序员视角非常适合入门,也符合我们的目标:即使你不再学习其他系统课程,也能从本课程中学到有用的东西。因为实际上,去设计缓存内存的公司工作的人非常少。设计微处理器、缓存内存、实现操作系统的人只占使用这些技术(在不同复杂程度上)的总人数的一小部分。

因此,我们更倾向于为你更广泛的编程生涯做准备。我们所涵盖的许多材料,历史上人们往往是在工作中、从朋友那里、通过摸索、听说或在互联网上阅读而学到的。我们试图做的是将其系统化并集中在一个地方,这样即使你没有强烈的自学欲望,也可以通过我们来学习。

学术诚信政策 ⚖️

现在,我想谈谈本课程中一个不那么愉快的部分:学术诚信问题。学术诚信是一个广义的术语,涵盖各种形式的作弊和剽窃。我知道你们在其他课程和地方已经听过很多关于这方面的内容。但这在计算机科学中尤其是一个问题,因为有许多工具和技术使得以不正当方式获取信息变得非常容易和诱人。

这对本课程和其他课程来说一直是一个严重的问题,我们非常重视。部分原因在于,我们作为教师感到有特殊的义务:如果你从这门课程进入操作系统等后续课程,我们希望确保你真正掌握了这些材料,而后续课程的教师也期望你已经掌握。如果你通过不正当手段完成,很可能并没有真正掌握,这会成为问题。

这不仅关乎个人诚信,也关乎我们对学生的责任。处理学术诚信问题也是我们工作中最不愉快的部分之一。总的来说,教学大纲中对允许和不允许的行为有相当深入的讨论,我建议你仔细阅读。

一般来说,本课程的所有作业(共8个实验)都需要独立完成。你可以使用教科书、课程网页上的信息,可以与助教或教师讨论,但不应以任何重要方式相互协作完成作业。现实是,网络上有很多资料可能绕过我们课程的学习目标。这本教科书在全球320所学校使用,一些实验虽然我们进行了调整,但与教科书中的实验并非完全相同,但网络上仍有许多隐藏的地方可能有与你的作业相关的材料。

因此,这可能很容易诱人,有时甚至感觉有必要使用这些信息,但我们真的希望避免这种情况。总的来说,以不正当方式从他人或网络获取信息是不允许的。另一方面,我们也不希望你向本课程或未来版本课程的学生提供信息。你现在和将来都有义务不泄露与本课程实验相关的信息。

你应该意识到,如果你查看大学的学术诚信政策,其中一条是“无追诉时效限制”。这意味着,即使你通过了这门课程,没有被发现有不端行为,并获得了不错的成绩,你并非完全安全。如果我们发现过去有未经授权的行为,我们可以追溯,通过程序更改你的成绩,甚至可能撤销你的学位。这听起来可能有点吓人,但这是真的。我们也确实这样做过。

GitHub存在一个特殊问题。很多学生喜欢使用Git,这是一个进行版本控制的好方法。但GitHub上,你的账户可能当前是私有的,但一段时间后会变为公开,因此你可能会无意中留下一些材料,这实际上相当于向未来的学生泄露信息。

什么不是作弊?

那么,什么不是作弊呢?我在这里交替使用“作弊”和“剽窃”这两个术语。

  • 如果其他学生在代码编译或调试器使用等系统层面遇到问题,与他们合作是可以的。
  • 讨论非常高级的设计问题也可以,但人们常常误解“高级设计”的含义。高级设计意味着一般性的讨论,例如“我发现按行访问数组比按列访问更好”。如果你需要在白板上写下伪代码来解释,那就不再是高级了,伪代码也是代码。
  • 当然,你可以使用教科书网页、课程网页等提供的代码。

教学大纲对此有相当详细的说明。

检测与后果

我们非常擅长追踪这些行为。我可能和你们一样擅长使用网络搜索。如果我们发现爱荷华州某个学生的代码副本,我们很可能已经找到了那份代码并保存了副本。我们还保存了这门课程历史上每一份提交的作业。我们运行称为“作弊检查器”的程序,它们将所有曾经创建的作业汇集在一起,然后加入你的代码进行分析比较。这些程序不会被更改注释、变量名或缩进等方式愚弄,它会返回匹配结果。其中一些匹配是偶然的,但另一些在我们看来会非常可疑,这时我们就会开始更仔细地审查。

实际上,要欺骗这些程序相当困难。一旦发生这种情况,我们几个人会开始更仔细地审查代码。如果可疑,我们会叫你开会,这可能是一次非常不愉快的会议。作为后果,我们的最低处罚总是该作业扣100%,这比根本没交任何东西还要糟糕。但当我们认为存在情有可原的情况,或者你诚实承认时,会有其他处理方式。事实上,默认情况是,我们给予该作业不及格,并记录在你的永久记录中。

所有这些都会被大学标记为AIV(学术诚信违规)。大学的规定是:一次AIV,希望你吸取教训;两次AIV,你将被带到由教师和其他学生组成的委员会面前进行解释,他们可以施加处罚。我们作为教师可以施加的最高处罚是课程不及格,但委员会可以施加开除学籍的处罚,而且他们确实这样做过。我参加过几次针对第二次违规学生的听证会,他们被开除了。

举个例子,两年前,我们有大约20名学生(约占课程学生总数的5%)因此未能通过课程,其中至少有一名随后被大学开除。几年前,我们发现许多人在一个实验中的实现代码来源相同,于是追溯了几年的提交记录,在许多人的作业中找到了同一来源。我们通过一个涉及多位教授和大学学生事务院长的程序,实际上对11名已经修完课程的学生(其中一些已经从CMU毕业)进行了处罚、重新评分并分配了新成绩。我们真的非常认真地对待“无追诉时效限制”这一点。

我个人想说,我不喜欢做这些,甚至不喜欢谈论这些。我觉得花时间查看某人的作业,试图弄清楚它是否是GitHub上另一份代码的副本,花时间与学生交谈,听他们闪烁其词直到最终承认,经历整个过程,这非常痛苦。这对我来说在情感上实际上非常困难,对学生来说则更加困难。

我知道在座的所有人都没有在课程中作弊的意图。但问题是,当截止日期临近,而你还没有完成作业,并且你知道网上有一份副本时,诱惑太大了,你可能会越界,明知不对但还是做了。这种情况确实会发生。但后果真的非常、非常严重。

关于误报

关于误报的问题很好。MOSS(一种代码相似性检测工具)会产生很多匹配。确实有很多误报。也有过几次,我们与学生开会,他们给出了一些解释,然后就没有后续了。但你必须意识到,我们作为教师不需要你的供认就可以施加后果。我们可以说:“我认为你作弊了,我要给你这个处罚。你有权申诉。”如果你确实被冤枉了,申诉是件好事,我们尽量避免误判。但我们不是法官或陪审团,我们不需要“排除合理怀疑”的证据。我们可以说:“我相信这是我认为的情况。”你可以申诉。我本人没有遇到过需要申诉的情况,但确实有一些学生来开会并给出了解释,那样也没问题。

场景示例

让我们看几个例子:

  • 如果你搜索“213 bomb lab”,实际上会出现一些有趣的YouTube视频。我看过那些视频。我们大致知道上面有什么,但你不应该那样做,这显然是在试图获取信息来完成作业,不是技术信息,而是有助于你找到解决方案的东西。
  • 一般来说,提供代码是相当明显的行为,给文件也是。但如前所述,在白板上写伪代码也是信息共享。
  • 另一方面,如果你需要关于某些库函数或特定程序的参考资料,那是可以的。与助教讨论总是可以的,我们有很多办公时间。

为了节省时间,我跳过一些场景,但我建议你查看教学大纲中的一些典型场景,这些是我经常遇到的情况。正如我所说,截止日期临近,你必须提交作业,但代码不工作,你在GitHub上找到某个地方学生写的或多或少能工作的代码,复制一份,尝试修改,然后提交。我们会发现这些情况,然后进行我描述过的会议。

更多场景:

  • Alice正在做一个实验,但代码不工作。Bob坐在她旁边,他已经完成了。Bob旁边是Charlie,Charlie做得不太好。Charlie起身休息,Bob打印出自己的代码放在Charlie的椅子上。谁作弊了?Bob。Charlie显然此时不知情。但后来Charlie发现了这份副本,看了看,复制了一个函数并进行了修改。那么Charlie也作弊了。
  • Bob和Alice坐在一起,一起检查她的代码,找出问题。谁作弊了?Bob和Charlie都算,他们在协作。
  • Charlie看着Bob留下的屏幕,看到了内容。谁作弊了?显然是Charlie。Bob当然也不应该让屏幕无人看管,但在这个世界上,你对隐私有一定期望,不必锁住所有东西或设置密码。但总的来说,Bob把屏幕留在那里不好。
  • Alice在使用GDB设置断点时遇到麻烦,Bob向她展示。这没问题,是可以的。
  • Joy去找助教寻求帮助。这没问题。

版本控制与Git

我们从上个春季学期开始做一件事,因为很多学生使用GitHub作为代码仓库。事实上,我自己也是GitHub的粉丝,但GitHub最好的是Git,而不是Hub。Git是一个通用的版本控制程序,只要你能找到支持Git的服务器,就可以使用它,不一定非得使用商业版的GitHub。ECE运行了一个Git服务器,我们真的强烈建议你使用那个,而不是GitHub。

这里有点“胡萝卜加大棒”的意思:一方面,使用版本控制是一个好习惯。当你修改程序时,希望能够恢复,如果你做了某个更改后发现是个坏主意,可以回到之前的状态。你可以创建不同的分支,不必担心电脑崩溃丢失文件等。你可以记录你的更改,这些都是Git或任何版本服务器的特性,但Git尤其出色。我们希望你们使用这个的另一个原因是,这实际上可以成为你在这个实验中付出努力的记录,如果我们开始询问,这可能非常重要。

去年春季和夏季都发生过多次这样的情况:学生被叫来开会,因为我们觉得他们的代码有些可疑。一些学生实际上有相当完整的Git工作记录,可以证明他们一直在持续努力,因此被免除了任何不当行为。但另一些学生突然说:“哦,我没保存任何东西”,或者说“我保存了一堆东西,但突然发生了巨大的变化”,看起来像是导入了新代码。

你可以把Git看作是你的记录,我们可以查看。我们可以查看你在这门课程ECE服务器上的代码的Git日志作为记录。因此,我们真的希望你们使用它,既是为了作为程序员自身的利益,也是作为一种可以证明这确实是你自己一直在编写的代码的记录。

课程安排与后勤 📅

本课程有三位教师。对于本科生,我们有讲座和复习课。我们将引入从去年夏季开始使用的Canvas(新的Blackboard系统)。它具有进行课堂小测验的功能,这不是在纸上写然后传递的那种测验,而是几个选择题。这对教师来说非常有用,可以实时了解班级的理解程度,并动态调整教学。如果你上过使用答题器的课,概念类似,只是你将使用笔记本电脑或智能手机参与。

我们不会真的给这些小测验评分,但最终在评分时,如果某个学生处于临界情况(例如在两个字母等级之间),我们看到他们积极参与的记录(作为他们参与课程程度的指标)可能会影响成绩。这不是一个具体的数值。如果你缺课,不用担心,我们有视频,网上有很多材料,我们不会以任何正式方式考勤,但你的参与可能对你的成绩有益,当然也能让你更好地学习材料。

教材与资源

教材请购买第三版,不要买其他版本,也不要买国际版,它们真的搞砸了。中文版还可以,我们很了解译者。我们还强烈推荐你购买Kernighan和Ritchie写的C语言书。Ritchie是贝尔实验室C语言的创始人之一。这本书真的很旧,有点过时,因为C语言已经发展,但它仍然是最好的书,因为它不仅讲语言本身,还提供了一些非常好的小程序示例来展示风格和编程思路,比我们见过的任何其他书都好。

本课程将有8个实验(以前是7个)。实验是这门课程的主要部分,也是你学习的主要方式。你们可能已经从朋友那里听说了。有两次考试,都使用在线考试系统进行,没有纸质试卷。课程网页上有很多资源。我们使用Piazza进行学生提问和发布许多通知,所以你应该在Piazza上关注。默认情况下,你的帖子对教师是私有的,我们建议你保持这样。如果某个问题反复出现,并且值得全班知道,我们可能会以适当的方式将你的私人帖子转为公开,但一般来说,这是一种你可以联系到我们或任何20位助教的私人方式。

另一方面,不要在Piazza上发布代码,不是因为我们不喜欢代码,而是这不是正确的方式。我们使用Autolab提交作业,所以如果你需要代码帮助,应该提交到Autolab,然后告诉我们,这样我们可以去Autolab获取并查看你的代码,而不是处理大文件。对于所有作业,你可以随意提交多次,没有任何处罚。尽早并经常提交,这是计算机的一大优点。

特殊机器与支持

我们有一些为本课程保留的特殊机器,称为Shark机器。本课程的大部分工作你可以在任何Linux机器(甚至一些非Linux机器)上完成。但评分系统Autolab实际上运行在计算设施维护的一组Linux服务器上。我们有这些非常特定的Shark机器,我们确保软件配置正确并能正常运行。所以如果有疑问,请使用这些机器进行你的工作。

关于作业提交策略:确保你最后提交的内容是你希望被评分的,因为那是我们实际评分的唯一内容。

宽限日与评分政策

我们有一些政策:我们有“宽限日”让你处理日程安排。在整个学期中,每个作业都有截止日期,你总共可以累积5个“宽限日”。有些作业允许最多2天,有些不允许,有些允许1天,具体安排都在课程表中。我们希望你们在时间紧迫时使用这些宽限日,例如你有多个课程、需要去参加求职面试、周末必须回家给祖母过生日等。这样我们就不必在这个规模的班级中处理每一个特殊情况。

如果你用完了宽限日(这些是自动分配的,你无法控制将宽限日用于哪些作业,一旦作业迟交就开始消耗宽限日),我们通常还会为作业提供最多总共3天的迟交时间,但会有递增的扣分处罚。因此,我们期望这能处理几乎所有人们可能无法按时完成作业的情况。如果你的生活中发生了非常严重的事情,影响的不只是这门课,而是你所有课程的参与,那么你需要去找你的课程顾问。顾问可以联系我们,基本上制定一个重新跟上进度的计划。

这门课(或任何课程)的一个困难之处在于,课程有很强的惯性。一旦你开始落后,真的很难再赶上,你会感到沮丧,然后进一步落后。我的主要建议是:第一,除非真的需要,否则不要使用宽限日,尽量把它们留到课程最后几个最重要的作业。第二,你永远不知道会发生什么可能阻止你按时完成事情,所以尽量保持进度,在整个课程中跟上节奏。

课堂行为与评分构成

一般来说,在课堂上使用笔记本电脑是可以的,但我们不希望你做其他事情,希望你做有成效的工作,而不是上Facebook。课程成绩一半是考试,一半是实验。实验有不同的分值,你可以在作业页面上看到不同作业的分数分布。我们倾向于按标准评分量表评分,不进行分数调整。我们通常会有相当多的A和B,如果你展示了对材料的掌握,我们很乐意给你好成绩。如前所述,如果学生处于临界状态,并且有某些情况支持,我们有时会提高他们的成绩。

我想提一下,今天有一个新的实验(Lab 0)发布,你可以从课程页面获取。如果你在昨天中午之前注册了这门课程,应该都已经有了Autolab账户。这个新实验叫“Web Ze”,是一个C编程小练习。如果你做过任何C编程,或者上过15-122,这应该是一个非常简单的练习,我们希望如此。但我们发现,有些进入课程的学生要么C语言较弱,要么有些生疏,或者由于某些原因没怎么做过C编程,他们发现自己卡住了。问题在于,问题不是发生在前几个实验,而是在课程进行大约一个月后,当你必须开始编写更重要的程序时。Lab 0是我们新的尝试,旨在帮助你进行评估。它在你的总成绩中只占2分,应该只需要大约一小时完成。你可以马上开始做,截止日期是一周后。其他实验是我们在这门课程中沿用了一段时间的,尽管我们不断以各种方式调整和改进它们。

实验是这门课程的重要组成部分,我相信如果你和以前上过这门课的人聊过,这是他们记忆最深的。动手实践是学习材料的好方法。我们使用一个叫“Project Zone”的系统分发实验的讲义。这个系统会随着时间慢慢揭示讲义内容,只有当你回答完之前看到的问题,到达最后时,你才会得到一个小密码(一组数字和字母),然后你将其作为提交的一部分,这样才能提交到Autolab。我们这样做是因为我们经常在Piazza上收到问题,而我们的回答是“请仔细阅读讲义”。这是一种更强烈地鼓励你在开始作业前仔细阅读讲义的方式。

启动营与Git服务器

最后我想说的是,本周一(虽然是假期)晚上7点在Raid礼堂将有一个“启动营”,由助教们主持,内容是关于熟悉Linux和Git。这对很多人来说不是强制性的,但如果你对使用Linux、命令行工具、Git有点生疏,他们会涵盖很多内容,所以我建议你把它列入日程。此外,我们将有一个Git服务器。

总结

本节课我们一起学习了CMU《计算机系统导论》课程的全貌。我们了解了课程旨在弥合高级编程抽象与底层系统硬件之间的鸿沟,通过具体示例(如整数溢出、浮点数精度、内存访问错误和性能优化)展示了理解系统底层的重要性。我们深入探讨了严肃的学术诚信政策及其严重后果,并熟悉了课程的基本安排、资源和使用工具(如Git版本控制)。本课程将通过讲座、实验和你的积极参与,为你打开计算机系统世界的大门,为未来的学习和职业生涯奠定坚实的基础。

02:位、字节与整数 I

在本节课中,我们将开始学习计算机系统的基础——位、字节与整数。我们将探讨信息在计算机中如何以二进制形式表示,以及如何通过不同的解释赋予这些比特串不同的含义。这是理解后续所有计算机系统概念的核心基础。

课程公告与安排

上一讲我们介绍了课程概述和一些后勤信息,本节中我们来看看一些具体的课程安排。

  • 下周一(劳动节)没有课,因此也没有习题课。
  • 我们将在周一晚上7点安排一个可选的“Linux 训练营”,内容涵盖Linux命令行、代码运行、文件编辑、Git使用等基础操作。
  • 课程网站上发布了名为“Web Zero”的新实验,这是一个C语言编程入门练习,旨在评估你是否具备课程所需的最低编程背景。该实验不适用任何宽限期政策。
  • 关于课程候补名单的问题,请直接联系CS或ECE的课程管理员,而非授课教师。

信息的二进制表示

我们生活在一个比特无处不在的时代。但你是否想过,为什么计算机使用二进制(0和1)?这并非唯一的方式(例如早期的ENIAC计算机就使用十进制),但从系统设计的角度看,二进制有几个关键优势:它能更好地处理电路中的噪声和不确定性,并能稳定地存储信息。

更重要的是,比特本身没有内在含义。一个比特串可以代表一个数字、一个字符串、一个浮点数或一段代码。是我们赋予这些比特的解释方式决定了它们的含义。这个抽象概念将在本课程中反复出现。

例如,数字 15213 可以用二进制比特串表示。我们也可以将其视为一个带二进制小数点的浮点数,例如 15213.0 可以表示为 1.1101101101101(二进制)乘以 2^13

十六进制表示法

直接书写长串的二进制数字非常繁琐。通常,我们会将比特每4位一组,用单个十六进制数字表示。

以下是十六进制表示法的规则:

  • 使用前缀 0x0X 表示十六进制数。
  • 使用数字 0-9 和字母 a-f(或 A-F)表示0到15的值。

例如,二进制数 0011 1011 0110 1101 可以分组转换为十六进制:001131011B011061101D,因此其十六进制表示为 0x3B6D。掌握二进制与十六进制之间的快速转换是一项在本课程中非常有用的技能。

字长与数据大小

当我们讨论机器时,常会听到“32位”或“64位”这样的术语。但需要注意的是,这些定义并不绝对。它实际上是由操作系统、编译器生成的代码以及硬件本身共同决定的。例如,现代的Intel处理器可以支持64位操作,也可以运行在向后兼容的32位模式下。

在C语言中,基本数据类型的大小也与此相关。在32位和64位模式下,像 int 这样的类型通常保持4字节不变,但 long 类型和指针(*)的大小会从4字节变为8字节。64位地址提供了远大于4GB(约2^32字节)的寻址范围,这是现代计算机需要更多内存的必然结果。

布尔代数与位运算

信息论的奠基人香农在其著名的硕士论文中,将乔治·布尔提出的布尔代数(用于命题逻辑)与数字电路设计联系起来。在布尔代数中,我们将 0 视为“假”,将 1 视为“真”。

以下是基本的布尔运算:

  • 与(&)A & B,当且仅当A和B都为真时结果为真。
  • 或(|)A | B,当A或B至少一个为真时结果为真。
  • 非(~)~A,取反。
  • 异或(^)A ^ B,当A或B其中一个为真(但不同时为真)时结果为真。

我们可以将这些运算从单个比特推广到位向量(即比特串,如32位或64位字)。运算是按位进行的,即对每一位独立应用上述规则。

位运算的一个实际应用是表示和操作集合。对于一个最多有32个元素的集合,我们可以用一个32位的位向量来表示,某一位为1表示对应元素在集合中。此时:

  • 与(&) 操作对应集合的交集
  • 或(|) 操作对应集合的并集
  • 异或(^) 操作对应集合的对称差

这种表示方法在系统编程中非常实用,例如用于管理网络连接的状态集合。位向量中值为1的位常被称为“掩码(mask)”,用于筛选出感兴趣的位。

在C语言中,我们使用 &|~^ 这些符号进行位级运算。但务必注意,它们与逻辑运算符 &&||! 完全不同。逻辑运算符将任何非零值视为“真”,结果只产生 01,并且具有短路求值特性。例如,表达式 p && *pp 为空指针(0)时是安全的,因为 && 发现左侧为假后会直接停止求值,不会解引用 p

移位运算

移位运算包括左移和右移。

  • 左移(<<)x << kx 的位向左移动 k 位,右侧空位补0,左侧移出的位丢弃。
  • 右移:有两种类型。
    • 逻辑右移(>>> in some languages)x >> k(对于无符号数)将位向右移动 k 位,左侧空位补0,右侧移出的位丢弃。
    • 算术右移x >> k(对于有符号数)将位向右移动 k 位,但左侧空位用最高位(符号位)的副本填充,右侧移出的位丢弃。

算术右移对于有符号数非常有用,它可以实现除以2的幂的运算(向零舍入)。在C语言中,对有符号数使用 >> 通常是算术右移,但对无符号数使用 >> 是逻辑右移,但这取决于编译器和机器。此外,C语言标准未定义负移位或过大移位的行为,这属于“未定义行为”,不同平台可能产生不同结果。

整数表示:无符号与有符号

在计算机中,我们主要使用两类整数:无符号数有符号数

  • 无符号数(Unsigned):所有位都用于表示非负值。对于一个w位的无符号数,其值范围为 0 到 2^w - 1
  • 有符号数(Signed):最常用的是二进制补码(Two‘s Complement)表示法。最高位(符号位)权重为 -2^(w-1),其余位权重为正的2的幂。对于一个w位的补码数,其值范围为 -2^(w-1) 到 2^(w-1) - 1

例如,对于4位表示:

  • 无符号范围:0 (0000) 到 15 (1111)。
  • 补码范围:-8 (1000) 到 7 (0111)。

注意,补码的范围是不对称的,负数的绝对值范围比正数大1(因为需要表示0)。同时,全1的比特串在无符号表示中是最大值(2^w - 1),在补码表示中则是 -1。

类型转换与符号扩展

在C语言中,有符号数和无符号数之间的转换非常常见,但规则可能出人意料。

关键规则是:有符号数与无符号数之间的转换,比特模式保持不变,只是解释这些比特的方式改变了。

例如,int 类型的 -1(比特模式为全1)转换为 unsigned int 后,会被解释为一个很大的正数(2^w - 1)。反之亦然。

这种转换经常隐式发生。例如,当一个运算中同时出现有符号和无符号操作数时,C语言会将有符号数隐式转换为无符号数,然后进行运算。这同样适用于比较操作(<, >, == 等)。

这可能导致一些反直觉的结果,例如:

  • -1 > 0U 的结果是 1(真),因为 -1 被转换为无符号数后变成了一个很大的正数。
  • 2147483647 > -2147483647-1 的结果是 1(真)(正数大于负数)。
  • 2147483647U > -2147483647-1 的结果是 0(假),因为后者被转换为无符号数后变成了一个更大的正数。

这种隐式转换是程序中难以察觉的Bug来源。一个经典的例子是使用无符号数作为循环变量进行倒计时,循环条件 i >= 0 将永远为真,导致无限循环或内存访问错误。

扩展与截断

当我们需要在不同大小的整数表示之间转换时,需要进行扩展或截断。

  • 扩展(小转大)
    • 无符号数:进行零扩展,在高位补0。
    • 有符号数(补码):进行符号扩展,在高位重复复制符号位。这可以保持数值不变。
  • 截断(大转小):无论有无符号,都是简单地丢弃高位多余的比特。这可能会改变数值,甚至改变符号(对于有符号数)。只有当原始值在目标类型的表示范围内时,数值才能被正确保持。

本节课中我们一起学习了计算机信息表示的基石:比特、字节与整数。我们理解了比特本身没有含义,其解释取决于上下文。我们探讨了无符号数和二进制补码有符号数的表示方法、范围及其转换规则,并学习了布尔代数、位运算和移位运算。特别需要注意的是有符号与无符号数之间隐式转换可能带来的陷阱。掌握这些概念对于理解计算机如何存储和处理数据至关重要。下一讲,我们将继续深入整数的运算。

03:位、字节与整数 II

在本节课中,我们将继续学习整数在计算机中的表示方式,并深入探讨对它们进行的算术运算。我们将涵盖无符号数和补码数的加法、乘法、移位操作,以及字节在内存中的存储顺序。理解这些底层概念对于编写高效且正确的程序至关重要。

算术运算

上一节我们介绍了整数在内存中的两种基本表示方式:无符号数和补码数。本节中,我们来看看对这些表示形式进行的基本算术运算。

无符号加法

无符号加法遵循模运算规则。当两个W位的无符号数相加时,其和可能需要W+1位来表示。但计算机硬件会丢弃最高位的进位(即溢出位),只保留低W位作为结果。这相当于执行了模 2^W 的加法。

公式:对于W位无符号数 uv,其和 s = (u + v) mod 2^W

例如,使用8位(W=8)表示,223 + 213 = 436。但8位能表示的最大值是255,因此结果会溢出。丢弃进位后,我们得到 436 - 256 = 180。

补码加法

补码加法的硬件实现与无符号加法完全相同。我们同样将两个W位数相加,并丢弃任何超出W位的进位。然而,对结果的解释取决于我们将其视为补码数。

补码加法可能导致两种溢出:

  • 正溢出:两个正数相加,结果太大,变成了负数。
  • 负溢出:两个负数相加,结果太小(太负),变成了正数。

核心概念:补码加法的位级行为与无符号加法一致,但数值解释不同。

乘法

两个W位数相乘,其精确乘积可能需要最多2W位来表示。与加法类似,硬件通常只保留乘积的低W位,丢弃高位部分。

重要规则:对于补码乘法和无符号乘法,乘积的低W位是相同的。只有高位部分可能不同。因此,在大多数只关心低W位结果的场景下,可以使用同一条乘法指令。

乘以常数与移位

编译器经常使用移位操作来优化乘以2的幂次方的运算。将一个数左移k位,等价于将其乘以 2^k。

代码示例

x * 8  可以优化为  x << 3
x * 24 可以优化为 (x << 4) + (x << 3) // 因为 24 = 16 + 8

除以2的幂次方

对于无符号数,使用逻辑右移(>>)可以实现除以2的幂次方,结果向零舍入。

对于补码数,使用算术右移(>>)可以实现除以2的幂次方,但对于负数,结果是向下(向负无穷)舍入,而不是向零舍入。为了得到向零舍入的结果,编译器会在移位前对负数加上一个偏置值 (2^k - 1)

内存中的字节表示

现在,让我们看看多个字节的数据是如何在内存中组织和访问的。

虚拟内存与地址空间

程序将内存视为一个巨大的字节数组,称为虚拟内存。每个运行的进程都有自己独立的虚拟地址空间,这提供了内存保护和隔离。地址的大小(例如32位或64位)决定了可寻址的内存范围。

字节顺序(字节序)

当一个多字节数据(如32位整数)存储在内存中时,其各个字节的排列顺序有两种主要约定:

  • 小端序:最低有效字节存储在最低内存地址。
  • 大端序:最高有效字节存储在最低内存地址。

x86和ARM架构(在常见操作系统下)通常使用小端序。网络协议则通常使用大端序,因此在网络编程中需要进行字节序转换。

示例:32位十六进制数 0x12345678 在内存中的存储:

  • 大端序:地址增长方向 12 34 56 78
  • 小端序:地址增长方向 78 56 34 12

字符串表示

C语言中的字符串与字节序无关。它们被表示为一个以空字符(\0)结尾的字节数组(ASCII或UTF-8编码),每个字符按顺序存储。

实用技巧与注意事项

以下是编程中与整数表示相关的一些重要技巧和易错点。

取负操作

对一个补码数 x 取负(计算 -x)的常见方法是:按位取反,然后加1。这被称为“取补加一”。

注意特殊情况

  • -0 等于 0
  • -TMin(最小补码数)等于 TMin 自身,这是一个正溢出的例子。

无符号数的陷阱

在C语言中,当无符号数和有符号数混合运算时,有符号数会被隐式转换为无符号数,这可能导致非直观的结果。

一个典型错误是使用无符号数作为循环计数器进行递减循环:

// 错误示例:死循环
unsigned int i;
for (i = 10; i >= 0; i--) {
    // 当 i 为 0 时,i-- 会变成 UMax,永远 >= 0
}

安全建议:如果要递减计数,可以检查计数器是否小于初始值,而不是是否大于等于零。

// 更安全的写法
size_t i;
for (i = count; i < count; i--) { // 当 i 为 0 后,下一次会变成很大的数,从而 i < count 为假,循环退出
    // ...
}

思考题

以下是一些检验理解的判断题(回答“总是成立”或“存在反例”):

  1. x < 0,则 x * 2 < 0存在反例x = TMin)。
  2. 无符号数 ux >= 0总是成立
  3. ux > -1总是不成立-1 转换为无符号数是 UMax)。
  4. x > y,则 -x < -y存在反例y = TMin)。
  5. x * x >= 0存在反例(溢出可能导致结果为负)。
  6. 两个正数相加,结果是否一定为正? 存在反例(正溢出)。
  7. 无符号数右移3位等于除以8? 总是成立(向零舍入)。

总结

本节课中我们一起学习了整数运算的底层细节。我们看到了无符号数和补码数的加法、乘法如何通过模运算和截断来实现,并理解了溢出行为。我们探讨了如何使用移位高效地进行乘除2的幂次方的运算。此外,我们还了解了数据在内存中的组织方式,特别是字节顺序(字节序)的概念,以及它在跨平台和网络编程中的重要性。最后,我们回顾了一些常见的编程陷阱和实用技巧。掌握这些关于位、字节和整数的知识,是理解计算机系统如何工作以及编写健壮代码的基石。

04:浮点数

在本节课中,我们将要学习浮点数的表示方法,特别是二进制小数和IEEE 754浮点数标准。我们将探讨其特性、舍入规则、加法与乘法运算,并了解其在C语言中的实现。

二进制小数

上一节我们介绍了整数在计算机中的表示,本节中我们来看看如何表示小数。二进制小数的原理与十进制小数类似,只是基数从10变成了2。

二进制数 1011.101 可以这样理解:小数点左边的部分 1011 表示 1*8 + 0*4 + 1*2 + 1*1 = 11。小数点右边的部分 101 表示 1*(1/2) + 0*(1/4) + 1*(1/8) = 5/8。因此,整个数表示 11 + 5/8 = 11.625

通用公式如下:
对于一个二进制数 b_m b_{m-1} ... b_1 b_0 . b_{-1} b_{-2} ... b_{-n},其值为:
值 = Σ_{i=-n}^{m} b_i * 2^i

以下是更多例子:

  • 101.11 = 5 + 3/4
  • 10.111 = 2 + 7/8
  • 1.0111 = 1 + 7/16

观察这些例子,将一个数除以2,其二进制表示只需向右移动一位。乘以2则向左移动一位。

二进制表示存在一些固有的限制:

  1. 基数决定了可精确表示的数。例如,在二进制中,可以精确表示 1/21/43/4 等,但像 1/31/10 这样的数会变成无限循环的二进制序列,无法用有限位精确表示。
  2. 小数点的位置固定了数值范围。如果小数点位置固定,则能表示的数字范围会受到限制,无法同时表示极大和极小的数。

IEEE 754浮点数标准

为了解决上述限制,并统一硬件实现,IEEE在1985年制定了754浮点数标准。这是一个对硬件实现要求苛刻但为软件提供了优秀数学属性的标准。

一个浮点数通常由三部分组成:
值 = (-1)^s * M * 2^E

  • s:符号位,0表示正数,1表示负数。
  • M:尾数(或有效数字),是一个在范围 [1.0, 2.0) 内的二进制小数。
  • E:指数,表示2的幂次。

在内存中,浮点数被编码为三个字段:1位符号位(s)、k位指数位(exp)和n位小数位(frac)。

常见的两种精度是:

  • 单精度(32位):1位符号位,8位指数位,23位小数位。
  • 双精度(64位):1位符号位,11位指数位,52位小数位。

根据指数位(exp)的值,浮点数被分为三类:

  1. 规格化数:当 exp 的位模式既不全为0,也不全为1时。这是最常见的情况。
  2. 非规格化数:当 exp 的位模式全为0时。
  3. 特殊值:当 exp 的位模式全为1时。

规格化数

对于规格化数,其指数 E 和尾数 M 的解释方式如下:

  • 指数 E = exp - Bias。其中 Bias 是一个偏置值,对于单精度是127,对于双精度是1023。这使得指数可以表示负数(小数字)和正数(大数字)。例如,单精度的 exp 范围是1到254,对应的 E 范围是-126到127。
  • 尾数 M = 1.xxx...。其中 xxx... 就是小数位(frac)部分。因为隐含了前导的1,所以实际尾数范围在 1.0 <= M < 2.0。这节省了1个存储位。

示例:将十进制数 -5.0 表示为单精度浮点数。
-5.0 的二进制是 -101.0,科学计数法表示为 -1.01 * 2^2

  • 符号位 s = 1(负数)。
  • 指数 E = 2,所以 exp = E + Bias = 2 + 127 = 129。129的二进制是 10000001
  • 尾数 M = 1.01,去掉隐含的1,小数位 frac = 01,后面补零到23位。
    因此,其32位表示为:1 10000001 01000000000000000000000

非规格化数

当 exp 全为0时,表示非规格化数。它们用于表示非常接近0的数,并提供了表示数值0的方法。

  • 指数 E = 1 - Bias(注意不是 0 - Bias)。对于单精度,E = 1 - 127 = -126。
  • 尾数 M = 0.xxx...。即隐含前导0,而不是1。这使得数值可以平滑地从最小的规格化数过渡到0。

特殊值

当 exp 全为1时,表示特殊值。

  • 如果小数位(frac)全为0,则表示无穷大(Infinity)。符号位决定正负,例如 1.0/0.0 得到 +∞-1.0/0.0 得到 -∞
  • 如果小数位(frac)不全为0,则表示非数(NaN, Not a Number)。用于表示无效操作的结果,如 sqrt(-1)∞ - ∞

浮点数的可视化与属性

浮点数在数轴上的分布是不均匀的。越靠近0,可表示的数越密集;离0越远,间隔越大。非规格化数填补了0附近的空白区域。

浮点数表示具有一些有用的属性:

  • 与整数0的位模式一致:所有位为0表示 +0.0
  • 可作为无符号整数比较(在大多数情况下):如果将浮点数的位模式解释为无符号整数,那么对于规格化正数、非规格化正数、+∞NaN,其大小顺序与对应的浮点数值顺序一致。但需要注意 -0.0+0.0 以及 NaN 的比较规则。

舍入

由于浮点数位数有限,运算结果常常需要舍入以适应目标格式。IEEE标准定义了多种舍入模式,默认是向最接近的偶数舍入(Round-to-Nearest-Even)。

考虑将一些价格舍入到最接近的美元:

  • $1.40 -> $1 (因为更接近1)
  • $1.60 -> $2 (因为更接近2)
  • $1.50 -> $2 (这是一个“中间值”,$2是偶数,所以舍入到2)
  • $2.50 -> $2 (这是一个“中间值”,$2是偶数,所以舍入到2)

在二进制中,规则类似。硬件实现时,通常使用三个辅助位来高效决策:保护位(G)、舍入位(R)和粘滞位(S)。通过检查这三个位的组合,可以确定是否需要向上舍入。

浮点运算

乘法

两个浮点数相乘:(s1 * M1 * 2^{E1}) * (s2 * M2 * 2^{E2}) = (s1 ^ s2) * (M1 * M2) * 2^{E1+E2}

  1. 计算精确的尾数乘积和指数和。
  2. 如果尾数乘积 M >= 2.0,则将其右移一位,并将指数加1。
  3. 如果指数超出范围,则发生溢出,结果为无穷大。
  4. 将调整后的尾数舍入到指定位数。

加法

两个浮点数相加,需要先对齐小数点(即调整指数使两者相同):

  1. 将指数较小的数的尾数右移,使其指数与较大的数一致。
  2. 将尾数相加。
  3. 如果结果尾数 M >= 2.0,则右移一位,指数加1。如果结果尾数 M < 1.0(在减法后可能发生),则左移直到规格化,并相应减少指数。
  4. 检查指数溢出,并对尾数进行舍入。

数学属性

浮点运算保留了实数运算的许多重要属性(除了涉及无穷大和NaN的情况):

  • 封闭性:浮点数加/乘的结果(舍入后)仍是浮点数。
  • 交换律a + b = b + aa * b = b * a
  • 单位元a + 0.0 = aa * 1.0 = a
  • 单调性:若 a >= b,则对于非负c,有 a+c >= b+ca*c >= b*c

然而,有两个关键属性不满足

  • 结合律(a + b) + c 不一定等于 a + (b + c)。例如,(3.14 + 1e10) - 1e10 可能得到 0.0,而 3.14 + (1e10 - 1e10) 得到 3.14。这是因为大数会“吸收”小数。
  • 分配律a * (b + c) 不一定等于 a*b + a*c。同样是由于舍入和溢出的影响。

C语言中的浮点数

在C语言中:

  • float 对应单精度,double 对应双精度。
  • 类型转换:
    • double/float -> int:向零舍入,截断小数部分。超出整数范围或遇到NaN时行为未定义(通常得到 TMin)。
    • int -> double:只要int的位数不超过53位(双精度尾数有效位),转换是精确的。
    • int -> float:可能发生舍入,因为float的精度有限。
  • 常量:2/3 是整数除法,结果为0。2.0/3.02/3.0 是浮点数除法,结果为 0.666...

总结

本节课中我们一起学习了IEEE 754浮点数标准。这是一个精心设计的标准,虽然对硬件实现要求高,但为数值计算提供了稳定、可预测的基础。我们了解了其三种数字表示(规格化、非规格化、特殊值),学习了默认的“向最近偶数舍入”规则,并探讨了浮点加法和乘法的过程及其数学属性。最重要的是,我们认识到浮点运算虽然近似于实数运算,但由于精度有限和舍入,不满足结合律和分配律,这是在编写数值计算程序时必须牢记的关键点。

05:机器级编程基础

在本节课中,我们将要学习机器级编程的基础知识。我们将从英特尔处理器架构的历史讲起,然后介绍基本的汇编语言概念,包括寻址模式和算术运算,最后了解C语言、汇编语言和机器代码是如何协同工作的。

处理器架构历史

上一节我们概述了课程内容,本节中我们来看看英特尔处理器架构的演进历史。

我们课程中使用的是英特尔x86架构。主要原因在于,x86架构在市场上占据主导地位,因此是大家最常接触到的架构。x86架构有着长达三十多年的向后兼容发展历史,英特尔在保持兼容性的同时不断进行增量改进,这种设计被称为“进化式设计”。

x86是一种CISC架构,即复杂指令集计算机。与之相对的是RISC架构,即精简指令集计算机。CISC架构拥有可变长度的指令和多种寻址模式,指令集非常庞大。而RISC架构的指令集则非常简化,指令长度统一。

英特尔最初采用CISC架构是因为早期内存非常紧张,需要尽可能紧凑的代码。可变长度的指令可以让常用指令更短,从而节省空间。虽然如今内存不再是主要瓶颈,但紧凑的指令格式在今天仍有优势,例如可以减少将指令送入处理器所需的带宽。

一旦复杂的CISC指令进入处理器内部,它们会被转换成一系列更简单的指令,称为微操作。因此,处理器内部实际上像是一个RISC机器,但对外仍呈现CISC的应用程序接口。

以下是英特尔处理器发展的关键节点:

  • 1978年:8086处理器,29,000个晶体管,主频5-10 MHz。
  • 1985年:80386处理器,晶体管数量增长约9倍。
  • 2004年:首款64位x86处理器,拥有1.25亿个晶体管,主频达2.8-3.8 GHz。
  • 2006年:转向多核设计,推出首款酷睿2处理器。
  • 2008年:推出酷睿i7架构,拥有四个核心。

随着晶体管数量指数级增长,处理器功耗和发热问题日益严重。行业因此转向多核设计,即使用多个性能稍低的核心并行工作,而不是一味提高单核频率。如今,单个服务器芯片可以拥有多达256个核心。

同时,制造工艺也在不断进步。晶体管尺寸从1995年的600纳米缩小到2008年的45纳米,再到2014年的14纳米。更小的尺寸意味着更高的能效和性能,但技术挑战也越大。

英特尔处理器的代号通常取自俄勒冈州的地名(如Haswell、Skylake),这是因为地名属于公共领域,可以避免版权问题。这些处理器会推出移动版、桌面版和服务器版等不同型号。

英特尔的主要竞争对手早期是AMD,后者曾率先推出64位x86处理器。如今,英特尔在服务器市场占据主导,而移动设备领域的主要竞争对手是基于ARM架构的处理器。英特尔也曾尝试全新的安腾架构,但由于编译器技术未能兑现承诺,该项目最终失败。

机器级编程基础

上一节我们回顾了历史,本节中我们来看看机器级编程的基本概念和视图。

当我们谈论处理器架构时,我们指的是对汇编代码或机器代码程序员可见的部分,这包括指令集和寄存器等。而微架构则是处理器内部用于实现指令集架构的具体设计,它影响性能、功耗等,但不影响程序的正确性。

以下是汇编代码中常见的数据类型:

  • 整数:1字节、2字节、4字节和8字节。
  • 地址:被视为8字节整数。
  • 浮点数:4字节(单精度)、8字节(双精度)和10字节(扩展精度)。
  • 向量数据:用于SIMD指令,长度为8、16、32或64字节。

汇编层没有C语言中的数组或结构体等高级概念,内存只是一系列连续的字节。

x86-64架构有16个整数寄存器,它们的名称和用途如下:

64位寄存器 低32位 低16位 低8位 主要用途
%rax %eax %ax %al 返回值,累加器
%rbx %ebx %bx %bl 被调用者保存
%rcx %ecx %cx %cl 第4个参数
%rdx %edx %dx %dl 第3个参数
%rsi %esi %si %sil 第2个参数
%rdi %edi %di %dil 第1个参数
%rbp %ebp %bp %bpl 被调用者保存,基指针
%rsp %esp %sp %spl 栈指针
%r8 %r8d %r8w %r8b 第5个参数
%r9 %r9d %r9w %r9b 第6个参数
%r10 %r10d %r10w %r10b 调用者保存
%r11 %r11d %r11w %r11b 调用者保存
%r12 %r12d %r12w %r12b 被调用者保存
%r13 %r13d %r13w %r13b 被调用者保存
%r14 %r14d %r14w %r14b 被调用者保存
%r15 %r15d %r15w %r15b 被调用者保存

注意%rsp寄存器专门用于跟踪栈的位置。函数调用时,参数按顺序存入%rdi, %rsi, %rdx, %rcx, %r8, %r9寄存器,返回值通常存入%rax

数据移动指令

上一节我们介绍了寄存器和数据类型,本节中我们来看看如何在寄存器和内存之间移动数据。

最基本的指令是mov指令,其格式为movq S, D,其中q代表四字(8字节),表示操作数大小。源操作数在前,目的操作数在后

操作数类型有三种:

  1. 立即数:在汇编代码中以$开头,例如$0x4
  2. 寄存器:例如%rax
  3. 内存引用:根据寄存器中的值访问内存,例如(%rax)表示访问地址为%rax值的内存。

mov指令示例如下:

  • movq $0x4, %rax:将立即数4存入寄存器%rax。C语言类比:long temp = 4;
  • movq $-147, (%rax):将立即数-147存入%rax值所指向的内存地址。C语言类比:*p = -147;(假设p的值在%rax中)。
  • movq %rax, %rdx:将%rax的值复制到%rdx。C语言类比:temp2 = temp1;
  • movq %rax, (%rdx):将%rax的值存入%rdx值所指向的内存地址。C语言类比:*dp = temp;
  • movq (%rax), %rdx:将%rax值所指向的内存内容加载到%rdx。C语言类比:temp = *ap;

注意:一条mov指令不能直接在两个内存位置之间传输数据,必须通过寄存器中转。

寻址模式

上一节我们看了基本的数据移动,本节中我们来看看CISC架构中丰富的内存寻址模式。

除了基本的(%rax)模式,还有以下常见寻址模式:

  • 位移模式D(R),有效地址 = R + D。例如8(%rbp),常用于访问数组元素或栈帧中的变量。
  • 基址+变址模式(Rb, Ri),有效地址 = Rb + Ri
  • 基址+变址+位移模式D(Rb, Ri),有效地址 = Rb + Ri + D
  • 比例变址模式(, Ri, S)D(, Ri, S),有效地址 = Ri * S + D。其中S可以是1, 2, 4, 8。

最通用的寻址模式公式为:D(Rb, Ri, S),其有效地址计算方式为:
Mem[Reg[Rb] + Reg[Ri] * S + D]

例子:假设%rdx = 0xf000, %rcx = 0x0100

  • 0x8(%rdx):地址 = 0xf000 + 0x8 = 0xf008
  • (%rdx, %rcx):地址 = 0xf000 + 0x0100 = 0xf100
  • (%rdx, %rcx, 4):地址 = 0xf000 + 0x0100 * 4 = 0xf400
  • 0x80(, %rdx, 2):地址 = 0x0 + 0xf000 * 2 + 0x80 = 0x1e080

地址计算与算术运算

上一节介绍了复杂的寻址模式,本节中我们来看一个特殊的指令lea和一些算术运算。

lea指令是“加载有效地址”指令,格式为leaq S, D。它计算源操作数S的有效地址(但不进行内存访问),然后将这个地址值存入目的操作数D。它常被编译器巧妙地用于进行简单的算术运算。

例如,leaq (%rdi, %rdi, 2), %rax 计算 %rdi + %rdi * 2 = 3 * %rdi,并将结果存入%rax。这用一条指令实现了乘以3的操作。

常见的算术和逻辑指令如下,它们遵循OPq S, D格式,执行D = D OP S操作:

  • addq S, DD = D + S
  • subq S, DD = D - S
  • imulq S, DD = D * S
  • salq k, DD = D << k (左移,算术/逻辑相同)
  • sarq k, DD = D >> k (算术右移,填充符号位)
  • shrq k, DD = D >> k (逻辑右移,填充0)
  • xorq S, DD = D ^ S
  • andq S, DD = D & S
  • orq S, DD = D | S

还有一元和二元操作指令:

  • incq DD = D + 1
  • decq DD = D - 1
  • negq DD = -D
  • notq DD = ~D

从C代码到汇编

上一节我们学习了指令,本节中我们来看看C代码是如何变成汇编代码和机器代码的。

使用GCC编译器,以下命令可以将C代码转换为汇编代码:
gcc -Og -S mycode.c

  • -Og:使用优化级别0,生成最直接、易于调试的汇编代码。
  • -S:生成汇编文件(.s后缀)。

汇编代码中包含以.开头的汇编器指令,用于指导链接和布局,阅读时通常可以暂时忽略,重点关注指令部分。

要从汇编代码生成可重定位的目标文件(.o),使用:
gcc -Og -c mycode.c

要查看机器代码(十六进制),可以使用反汇编工具objdump
objdump -d mycode.o

在GDB调试器中,也可以使用disas命令来反汇编当前函数或指定地址的代码。

学习阅读汇编代码对于调试和性能分析至关重要。当程序出现bug或性能不佳时,查看编译器生成的汇编代码可以帮助你理解代码的实际行为,从而找到问题根源。

总结

本节课中我们一起学习了机器级编程的基础知识。我们回顾了英特尔x86处理器架构从CISC设计到多核时代的发展历史。我们了解了汇编层的基本视图,包括寄存器、数据类型和基本的mov指令。我们深入探讨了x86丰富的寻址模式,以及lea指令和算术运算指令的用法。最后,我们了解了从C源代码到汇编代码和机器代码的编译工具链。掌握这些基础知识是理解后续关于控制流、过程调用等更复杂机器级编程概念的关键。

06:机器编程 - 控制流 🎛️

在本节课中,我们将学习机器编程中的控制流。我们将探讨条件码、条件分支、循环以及C语言中switch语句的实现方式。理解这些概念对于编写和理解底层代码至关重要。

概述

处理器通过指令集架构(ISA)提供机器级编程的抽象接口。无论底层处理器如何变化,只要正确编程到此API,程序的正确性就不会受到影响。本节课,我们将深入探讨控制流在机器层面的实现。

条件码

上一节我们介绍了数据操作,本节中我们来看看控制流的基础——条件码。条件码是处理器中一组特殊的单比特寄存器,用于记录最近一次算术或逻辑操作的结果属性。它们被后续的条件分支指令隐式地使用。

在x86-64架构中,有四个主要的条件码:

  • CF(进位标志):记录操作是否产生了最高位的进位或借位。对于无符号运算,这表示溢出。
  • ZF(零标志):记录操作结果是否为0。
  • SF(符号标志):记录操作结果的符号位(最高位)。对于有符号数,1表示负数,0表示正数。
  • OF(溢出标志):记录有符号运算是否发生了溢出(例如,两个正数相加得到负数)。

算术指令(如addqsubq)和某些数据传送指令会设置这些标志。但leaq(加载有效地址)指令是个例外,它不设置任何条件码。

条件码的设置与使用

条件码由指令隐式设置,但也可以通过显式指令来设置,以便进行条件判断。

比较和测试指令

以下是两种显式设置条件码的指令:

  • cmpq b, a:该指令类似于计算 a - b,但不存储结果,只根据计算结果设置条件码。它用于比较两个操作数。
  • testq b, a:该指令计算 a & b(按位与),同样不存储结果,只设置条件码。它常用于测试某个值是否为零或特定位的状态。

一个常见的用法是 testq %rax, %rax,用于测试 %rax 寄存器中的值是零、正数还是负数。

条件设置指令

条件设置指令根据条件码的组合,将目标寄存器的低字节设置为0或1。

例如:

  • sete:如果相等(ZF=1),则设置为1。
  • setne:如果不相等(ZF=0),则设置为1。
  • setg:如果大于(有符号),则设置为1。
  • setl:如果小于(有符号),则设置为1。

这些指令只修改目标寄存器的最低字节,高位字节保持不变。通常需要配合 movzbl 等指令进行零扩展以得到完整的结果。

条件分支与跳转

条件码最常见的用途是实现条件分支,即根据条件决定执行哪一段代码。

跳转指令

跳转指令分为无条件跳转和条件跳转:

  • 无条件跳转jmp label,总是跳转到指定标签处。
  • 条件跳转:根据条件码决定是否跳转。例如:
    • je label:如果相等(ZF=1)则跳转。
    • jne label:如果不相等(ZF=0)则跳转。
    • jg label:如果大于(有符号)则跳转。
    • jl label:如果小于(有符号)则跳转。

条件跳转通常跟在 cmptest 指令之后。

条件分支的实现模式

C语言中的 if-else 语句可以翻译成两种主要的汇编模式:

  1. 传统条件跳转模式:通过比较和条件跳转,在代码中创建不同的执行路径。

    cmpq %rsi, %rdi   # 比较 x (rdi) 和 y (rsi)
    jle .L4           # 如果 x <= y,跳转到.L4执行y-x
    movq %rdi, %rax
    subq %rsi, %rax   # 否则,执行x-y
    ret
    .L4:
    movq %rsi, %rax
    subq %rdi, %rax   # 执行y-x
    ret
    
  2. 条件传送模式:使用 cmov 指令族,可以避免分支预测错误带来的性能损失。它先计算两个分支的结果,然后根据条件选择其中一个。

    // C语言思想类比
    result = then_expr;
    eval = else_expr;
    if (test) result = eval; // 这条对应cmov指令
    

    对应的汇编可能如下:

    movq %rdi, %rax      # result = x (假设为then_expr)
    subq %rsi, %rax      # result = x - y
    movq %rsi, %rdx      # eval = y
    subq %rdi, %rdx      # eval = y - x
    cmpq %rsi, %rdi      # 比较 x 和 y
    cmovle %rdx, %rax    # 如果 x <= y, result = eval (即y-x)
    

注意:条件传送并非万能。在以下情况可能不适用:

  • 分支中的表达式计算开销非常大。
  • 分支中的表达式可能引发错误(如空指针解引用)。
  • 分支中的表达式有副作用(如修改全局变量)。

循环的实现

理解了条件分支,我们就可以构建循环。C语言中的三种循环都可以翻译成汇编中的条件跳转结构。

Do-While 循环

do-while 循环的汇编结构最直接:先执行循环体,然后进行条件测试,如果满足条件则跳回循环开始。

C语言示例:

do {
    // 循环体
} while (test);

汇编模式:

loop_start:
    // 循环体指令
    cmpq ...          // 执行测试
    jxx loop_start    // 如果条件满足则跳转

While 循环

while 循环可以通过在 do-while 结构前增加一个初始跳转来实现,或者通过将条件判断复制到循环开始和结束处来优化(避免循环内的跳转)。

C语言示例:

while (test) {
    // 循环体
}

一种汇编实现模式(跳转到中间):

    jmp test
loop_start:
    // 循环体指令
test:
    cmpq ...          // 执行测试
    jxx loop_start    // 如果条件满足则跳转

For 循环

for 循环可以看作是 while 循环的语法糖,包含初始化、测试和更新三部分。它很容易被翻译成等价的 while 循环形式,进而翻译成汇编。

C语言示例:

for (init; test; update) {
    // 循环体
}

等价于:

init;
while (test) {
    // 循环体
    update;
}

Switch 语句的实现

switch 语句用于多路分支。当 case 值分布在一个紧凑的范围内时,编译器会使用一种非常高效的实现方式:跳转表

跳转表原理

编译器会创建一个数组(跳转表),其中每个元素是一个代码块的起始地址。数组的索引对应 case 的值。

执行过程如下:

  1. 检查 switch 值是否在有效的 case 范围内。如果不在,直接跳转到 default 代码块。
  2. switch 值减去范围下限,得到索引。
  3. 以该索引访问跳转表,获取目标代码块的地址。
  4. 进行间接跳转(jmp *table_address)到该地址执行。

处理特殊情况

  • 多个case指向相同代码块:跳转表中对应的多个条目存储相同的地址。
  • case值不连续(有缺失):跳转表中缺失的条目指向 default 代码块的地址。
  • fall-through(贯穿):通过安排代码块内的标签和跳转,可以让一个 case 执行完后继续执行下一个 case 的代码,而无需重复判断。

这种实现方式在 case 值密集时效率很高,因为只需要一次范围检查、一次内存访问和一次跳转。如果 case 值非常稀疏,编译器可能会改用一系列 if-else 链来实现。

总结

本节课我们一起学习了机器编程中控制流的核心概念。我们了解了条件码如何作为指令执行的副产品被设置,以及如何通过 cmptestset 和条件跳转指令来利用它们实现条件判断。我们探讨了 if-else 语句的两种编译模式(条件跳转和条件传送),并分析了各自的优缺点。接着,我们将高级语言中的 do-whilewhilefor 循环结构还原为底层的条件跳转模式。最后,我们剖析了 switch 语句高效实现的秘密——跳转表,理解了它如何通过一次间接跳转处理多路分支。掌握这些控制流的机器级表示,是理解程序运行机制和进行底层优化的关键一步。

07:机器编程 - 过程 📖

在本节课中,我们将要学习机器编程中关于“过程”(或称为函数)的核心机制。我们将探讨如何通过汇编代码实现过程调用,包括控制流的转移、数据的传递以及局部数据的管理,并深入了解递归的实现。这一切都建立在“栈”这一关键数据结构之上。

栈与栈帧 🧱

上一节我们介绍了条件码和比较指令,本节中我们来看看过程调用的基础——栈。在x86-64架构中,栈是一段用于管理过程调用数据的内存区域,其地址从高向低增长。栈顶由寄存器 %rsp 指向。

以下是两个专门用于栈操作的核心指令:

  • pushq src:将源操作数压入栈。
    • 效果:%rsp 减8,然后将 src 的值写入 %rsp 指向的新地址。
    • 公式:R[%rsp] ← R[%rsp] - 8; M[R[%rsp]] ← src
  • popq dest:从栈顶弹出一个值。
    • 效果:从 %rsp 指向的地址读取值到 dest,然后 %rsp 加8。
    • 公式:dest ← M[R[%rsp]]; R[%rsp] ← R[%rsp] + 8

每个过程调用都会在栈上分配一个独立的区域,称为“栈帧”,用于存储其返回地址、局部变量以及需要保存的寄存器值。

控制转移:调用与返回 🔄

过程调用的核心是控制流的转移。这通过 callret 指令实现。

  • call Labelcall *Operand:调用过程。
    • 效果:将下一条指令的地址(返回地址)压入栈,然后跳转到目标地址。
    • 公式:pushq %rip 的下一条指令地址; jmp 目标地址
  • ret:从过程返回。
    • 效果:从栈顶弹出返回地址,并跳转到该地址。
    • 公式:popq %rip

数据传递:参数与返回值 📦

为了高效传递数据,x86-64定义了一套调用约定(Calling Convention)。

  • 参数传递:前6个整数或指针参数依次通过寄存器 %rdi, %rsi, %rdx, %rcx, %r8, %r9 传递。更多的参数则通过栈传递。
  • 返回值:整数或指针类型的返回值通常存放在寄存器 %rax 中。

寄存器使用约定 📝

由于寄存器是共享资源,调用者和被调用者必须遵守约定,确保寄存器值不被意外破坏。寄存器分为两类:

以下是调用者保存寄存器,如果调用者希望这些寄存器的值在过程调用后保持不变,它必须自己负责保存(例如压入栈中):

  • %rax
  • %rdi, %rsi, %rdx, %rcx, %r8, %r9
  • %r10, %r11

以下是被调用者保存寄存器,如果被调用者要使用这些寄存器,它必须在使用前保存其原始值,并在返回前恢复:

  • %rbx, %rbp, %r12, %r13, %r14, %r15
  • 栈指针 %rsp 也必须被维护。

栈帧详解与递归示例 🌀

一个完整的栈帧可能包含以下内容(从高地址到低地址):

  1. 调用者栈帧(如之前的局部变量)。
  2. 参数构造区(用于第7个及以后的参数)。
  3. 返回地址(由 call 指令压入)。
  4. 被调用者保存的寄存器(如 %rbp, %rbx)。
  5. 局部变量。
  6. 临时空间。

递归是过程调用的一个特例。由于每次调用都会创建新的栈帧,各次调用的局部变量和参数相互独立,因此递归可以自然工作。

考虑以下计算整数二进制表示中1的个数(popcount)的递归函数:

long pcount(long x) {
    if (x == 0) return 0;
    else return (x & 1) + pcount(x >> 1);
}

其对应的汇编代码核心部分展示了递归调用时栈帧和寄存器的管理:

pcount:
    movl    $0, %eax           # 设置默认返回值0
    testq   %rdi, %rdi         # 测试参数x是否为0
    je      .L6                # 如果为0,跳转到结尾返回
    pushq   %rbx               # 保存被调用者保存寄存器%rbx
    movq    %rdi, %rbx         # 将x保存到%rbx
    andl    $1, %ebx           # 计算x & 1,即最低位
    shrq    %rdi               # x = x >> 1
    call    pcount             # 递归调用pcount(x>>1)
    addq    %rbx, %rax         # 将(x & 1)加到返回值上
    popq    %rbx               # 恢复%rbx
.L6:
    ret                        # 返回

在这段代码中,%rbx 被用来保存当前层计算的 (x & 1) 值。因为 %rbx 是被调用者保存寄存器,递归调用不会破坏它,从而保证了每一层递归的中间结果安全。

总结 🎯

本节课中我们一起学习了机器级编程中过程(函数)的实现机制。我们了解了栈和栈帧的概念,掌握了 call/ret 指令如何实现控制流的转移,熟悉了通过寄存器和栈传递参数的调用约定,并明确了调用者与被调用者保存寄存器的分工。最后,我们看到这些机制如何自然地支持递归调用。所有这些约定共同构成了应用程序二进制接口(ABI),确保了不同模块间能够正确、高效地协作。

08:机器级编程 - 数据

在本节课中,我们将要学习机器级编程中关于数据的表示和处理。我们将重点讨论数组(包括一维和多维数组)、结构体的内存布局与对齐,并简要介绍浮点数的汇编表示。理解这些概念对于编写高效、正确的底层代码至关重要。

数组基础

上一节我们介绍了程序的基本执行流程,本节中我们来看看数据在内存中的基本组织形式——数组。

在C语言中,数组是一系列相同类型元素的连续存储。数组的起始地址加上元素大小乘以索引,即可计算出任意元素的地址。

以下是数组内存布局的示例:

  • char A[12]:起始地址为 x,结束地址为 x + 12
  • int B[5]:每个 int 占4字节,结束地址为 x + 20
  • double C[3]:每个 double 占8字节,结束地址为 x + 24
  • char *D[3]:这是一个指针数组,分配了三个指针(每个8字节)的空间,但指针所指向的字符串内容尚未分配。

对于数组 int val[5] = {1, 5, 2, 1, 3};,其内存布局和引用方式如下:

  • val[4] 引用的是索引为4的元素,即值 3
  • val 本身代表数组起始地址的指针,类型为 int*
  • val + 1 是指针运算,地址为 val + 4(跳过一个 int 的大小)。
  • &val[2] 获取第二个元素的地址,即 val + 8
  • val[5] 是越界访问,行为未定义。
  • 通用公式:val[i] 的地址为 val + 4*i

数组操作示例

理解了数组的内存布局后,我们来看看如何在汇编层面操作数组。

以下是一个获取数组中特定元素的C函数及其对应的汇编代码:

int get_digit (int *zip, int dig) {
    return zip[dig];
}

对应的汇编核心指令为:

movl (%rdi, %rsi, 4), %eax

这条指令计算地址 %rdi + 4 * %rsi,并将该地址处的值(一个 int)移动到 %eax 作为返回值。

另一个例子是遍历数组并对每个元素加1的循环。其汇编代码通过比较索引与数组边界,并使用地址计算指令 addl $1, (%rdi, %rcx, 4) 来更新数组元素。

指针与数组的辨析

指针和数组的声明有时看起来很相似,但含义截然不同,这是初学者常混淆的地方。

以下是两个关键声明及其辨析:

  • int a1[3];:这直接分配了一个包含3个整数的连续内存块。sizeof(a1) 的结果是 12(3 * 4字节)。
  • int *a2[3];:这分配了一个包含3个整型指针的数组,但指针所指向的整数内存并未分配。sizeof(a2) 的结果是 24(3 * 8字节)。

对于更复杂的声明,理解其类型至关重要:

  • int (*a3)[3];a3 是一个指向包含3个整数的数组的指针。sizeof(*a3)12
  • int *(a4[3]);:等同于 int *a4[3];,是指针数组。

多维数组

上一节我们讨论了一维数组,本节中我们来看看多维数组在内存中的布局。

多维数组(嵌套数组)在内存中按“行优先”顺序连续存储。对于一个声明为 int A[R][C] 的数组,元素 A[i][j] 的地址计算公式为:
Address = A + (i * C + j) * 4

假设有一个二维数组 int pgh[4][5],要访问 pgh[i][j],编译器生成的汇编代码会高效地计算 (i*5 + j)*4 的偏移量。这通常通过 LEA(加载有效地址)指令完成,例如 leaq (%rdi, %rdi, 4), %rax 计算 i*5,再乘以4得到最终偏移。

多层数组

除了嵌套数组,我们还可以使用指针数组来构建多层数组,这在动态或非连续存储时很有用。

考虑以下声明:

int *univ[3] = {mit, cmu, ucb};

这里 univ 是一个指针数组,每个指针指向一个一维整数数组(如 mit, cmu)。访问 univ[i][j] 需要两次内存引用:首先获取 univ[i] 得到行指针,然后在该行中索引 j。这比嵌套数组的直接地址计算效率更低。

结构体

现在我们将注意力从数组转移到另一种重要的复合数据类型——结构体。

结构体将不同类型的成员打包在一起。成员在内存中按照声明的顺序排列。例如:

struct rec {
    int a[4];
    int i;
    struct rec *next;
};

假设结构体起始地址为 r,则成员偏移如下:ar+0ir+16nextr+24。访问 r->a[i] 的地址为 r + 4*i

在汇编中,访问结构体成员需要计算基址加上固定的偏移量。例如,在链表遍历中,更新 r->a[i] 的代码需要先取得 r->i 的值,然后计算 r + 4*i 的地址进行存储。

数据对齐

你可能会注意到,在上面的结构体例子中,int i(4字节)之后有4字节的空白,然后才存储 next 指针。这并非错误,而是出于数据对齐的要求。

数据对齐要求:任何K字节的基本对象,其地址必须是K的倍数。对齐的好处是简化处理器设计、提升内存访问速度(避免跨缓存行或内存页访问)。

对齐规则如下:

  • char(1字节):地址可为任意值。
  • short(2字节):地址最低位必须为0(偶数地址)。
  • intfloat(4字节):地址最低两位必须为00。
  • doublelong、指针(8字节):地址最低三位必须为000。

编译器会自动在结构体成员之间插入“填充字节”以满足每个成员的对齐要求。同时,整个结构体的大小会被填充为最大成员对齐要求的整数倍,以确保结构体数组中的每个元素也都正确对齐。

为了最小化填充空间,一个有效的经验法则是:在结构体声明中,按照成员类型大小降序排列。

浮点数数据

最后,我们简要介绍浮点数在机器级的表示和操作。

x86-64架构使用独立的寄存器组(XMM寄存器)和指令集(SSE或AVX)来处理浮点运算。XMM寄存器长度为16字节(128位),可以存放多个整型或浮点型数据,支持单指令多数据(SIMD)并行操作。

以下是浮点操作的一些特点:

  • 函数调用时,浮点参数通过 %xmm0%xmm7 传递。
  • 浮点返回值存放在 %xmm0 中。
  • 浮点寄存器称为调用者保存寄存器。
  • 内存引用和运算有对应的浮点版本指令,例如 movsd 用于移动双精度浮点数,addps 用于单精度浮点数的并行加法。

本节课中我们一起学习了机器级编程中数据的核心概念。我们探讨了一维和多维数组的内存布局与地址计算,辨析了指针与数组的微妙差别,了解了结构体的内存排列和对齐规则,并简要介绍了浮点数的处理方式。掌握这些知识是理解程序如何在底层与内存交互的关键。

09:机器级编程进阶(上篇)

在本节课中,我们将学习与机器级编程相关的几个高级主题,重点关注内存的组织方式以及程序如何利用内存的不同部分。我们将特别探讨一类常见的程序漏洞——缓冲区溢出,并了解攻击者如何利用这些漏洞获取系统访问权限。

🗺️ 内存布局总览

上一节我们介绍了程序的基本执行流程,本节中我们来看看程序在内存中是如何组织的。下图展示了Linux系统中,从汇编/机器级程序视角看到的内存整体布局。

尽管这是一个64位地址空间,但用户空间大约只占用到247位地址。硬件本身支持248位,但地址空间的上半部分被操作系统内核占用,用于存放进程所需但用户无法直接访问的数据。

在内存空间的最高端是共享库代码,例如 mallocfreeprintf 等函数。这些代码通常在所有执行进程之间共享。

紧接着共享库下方是栈。在典型机器上,栈的大小被限制为8兆字节。需要特别注意的是,此图并非按比例绘制。8兆字节(约223字节)相对于247字节的地址空间来说微不足道。限制栈大小的目的是为了防止程序因无限递归而不断进行函数调用,最终可能耗尽整个内存。如果你尝试访问超过8兆字节的栈空间,将会导致段错误,程序会出错退出。

在地址空间的低端,你会看到第一个地址范围未被使用。我相信这与降低某些攻击的脆弱性有关。黄色区域称为文本段。尽管名字叫“文本”,但它并非指字符串,而是指由编译器和链接器生成的可执行代码。这个内存区域通常被标记为可读和可执行,但不可写,以防止程序在执行过程中被无意或恶意修改。

粉色区域是数据区,用于存放程序中声明的全局变量。带有箭头的部分是堆。堆是由 malloc 等程序管理的区域,是动态分配和释放的大型数据结构存放的地方。

这些区域之间的边界实际上会根据代码量、全局变量量、堆和栈的使用量而变化。

📍 内存地址示例

以下是我们编写的一段代码,用于生成各种数据结构并打印其地址。

// 示例代码:打印不同类型数据的地址
// 包含全局变量、数组、函数、malloc分配的数据等

这段代码在一次执行中展示了不同数据实际存放的区域。局部变量(图中紫色部分)最终位于栈的某个位置,其地址通常以 0x7f... 开头。指针是堆分配的,它们位于堆区域的某个位置。在这个特定例子中,非常大的数组被分配在堆中较高的地址,紧挨着栈的下方,而较小的数组则被分配在堆中较低的地址,靠近数据区。操作系统如何分配并没有固定规则,这会因机器或操作系统的不同而变化。

static 或全局分配的数组存储在数据区。列出的两个函数则位于文本段。

重要的是要认识到,所有这些都只是同一地址空间中的不同区域,因此一个指针可以指向其中任何一个区域。如果你尝试访问图中白色的、尚未分配的区域,通常会导致段错误,这意味着操作系统报告了一个无效地址错误。

🚫 栈空间限制示例

我之前提到栈被限制为8兆字节,这里有一个示例来演示这一点。这是一个递归调用函数。

int recursive(int x) {
    char big_array[128 * 1024]; // 分配约128KB的栈帧
    if (x <= 0) return -x;
    return recursive(x - 1);
}

如果你仔细看这个程序,它应该返回 -x,但递归深度会达到 x 的数值。每次递归调用都会分配一个约128KB的栈帧,占用相当大的内存。你会发现,在大约46次深度递归调用后,就会遇到段错误。

这提醒我们,在C编程实验中不允许使用递归来遍历链表,原因正在于此。如果你有一个非常大的数据结构并尝试递归遍历,会产生巨大而深的递归嵌套调用,栈空间无法容纳。因此,执行此类功能时,必须使用迭代。

⚠️ 内存越界访问的隐患

让我们回到第一节课,你会记得我们展示了一个可能产生不同结果的例子。

double fun(int i) {
    volatile double d = 3.14;
    volatile int a[2];
    a[i] = 1073741824; // 可能越界写入
    return d;
}

尽管看函数 fun,它应该返回值3.14。它对数组 a 的其他操作实际上并未被读取。但由于对数组 a 的内存引用,根据 i 的值,你可能会访问正常的数组 a,也可能破坏内存的其他部分。

具体来说,我们发现调用 fun(0)fun(1) 没有问题。但如果调用 fun(2),它会修改双精度数 d 的低4个字节,从而损害浮点数表示中的低位数字。如果调用 fun(3),则会覆盖双精度数 d 的高位字节,从而完全破坏该值。我们发现 fun(4) 会导致段错误。在我进行的一些实验中,你会发现运行 fun(8) 也能正常返回,因为它修改了内存中某些对这个程序来说似乎不关键的部分。

总的来说,栈帧的布局是典型方式,只是我们绘制时把低地址放在了底部。首先是 a[0],然后是 a[1],接着是8字节的双精度数据 d。之后是4个字节一组的关键状态区域,当你破坏它们时,就会得到段错误。在位置8,我们发现可以修改它而不破坏程序,这可能是因为程序运行时间很短无关紧要,或者那是一些未使用的空间,很难确切知道。

然而,这通常是一种可怕的情况,因为内存在整个程序中是共享的。程序某一部分的无意写入可能会破坏另一部分。当代码编写方式允许缓冲区或数组超出预期溢出时,这就可能成为一个漏洞。从C语言本身来看,没有什么能阻止这种情况发生。接下来,我们将看看这个缺陷如何成为可能被攻击者利用的安全漏洞。

🚨 危险的库函数:gets

让我们回顾一下这个名为 gets 的Unix库函数。如果你现在尝试在代码中使用它,编译器会给出各种警告,因为它确实是一个危险的函数。我想明确展示它,以便你理解危险所在。下图展示了一个 gets 的示例实现(不一定是真实的,但功能类似)。

gets 的想法是从标准输入读取一个字符序列,一旦遇到文件结束符或换行符就停止。但在此之前,它会一直将结果写入目标缓冲区。这里的问题是,gets 没有参数来指定缓冲区有多大。因此,gets 会愉快地一直写入,直到遇到文件结束符或换行符。所以,如果你传递一个非常长的字符串,比如1000个、10000个甚至一百万个字符,它都会一直写入,直到结束或出现错误。

这种未检查的复制在许多标准库函数中都很常见,例如 strcpystrcat,以及某些版本的 scanf 格式化字符串函数等,它们都会一直写入字符串,直到遇到换行符或空字符。

🧪 漏洞代码示例

以下是一个使用 gets 的非常脆弱的代码示例。它只有一个大小为4的缓冲区,这并不大,并且设计用于回显输入。

void echo() {
    char buf[4]; // 仅4字节的缓冲区
    gets(buf);   // 危险!未检查边界
    puts(buf);
}

如果你访问课程网站上链接的代码,会找到一个名为 buf_demo_nsp 的函数(NSP代表无栈保护)。你会发现,你可以输入一个最多23个字符的字符串,它会正常打印并退出。但是,如果你尝试输入一个只多一个字符的字符串(24个字符),它仍然会回显,puts 工作正常,但当 echo 函数试图返回时,会导致段错误。让我们试着理解那里发生了什么,以及这一切意味着什么。

🔍 分析缓冲区溢出

为了理解,我们必须查看 echocall_echo 的反汇编代码。


echo 在栈上分配了一些空间。十六进制 0x18 相当于十进制24,即在栈上分配了24字节。它将栈指针设置为 gets 的参数,然后 gets 调用 puts,之后释放空间并完成。

这里的关键部分是分配了24字节。栈上为缓冲区分配了多少空间?在 call_echo 中,唯一有趣的是它的返回地址是这个十六进制代码 0x4006f6。所以当 call_echo 调用 echo 时,它会将这个地址压入栈中。

因此,在调用 gets 之前,栈的布局是这样的:我们压入了返回地址,为缓冲区分配了24字节,但我们只打算使用其中的4个字节,所以有20字节是未使用的。为什么存在这些空间,我不完全清楚,但它确实存在,使得这个程序实际上可以接受比声明更大的字符串。

对于 call_echo,它将特定的返回地址放在栈上。如果我输入一个只有23个或更少字符的字符串,你会发现它不仅填满了提供的4字节缓冲区,还填入了未使用的区域,但不会破坏栈上的返回地址。在这种情况下,它只是在字符串的末尾插入空字符。这就是为什么23是一个神奇的数字,而不是24。我可以输入23个字符并以空字符结尾,这将适合这个分配的区域。

另一方面,如果我有一个24个字符的字符串,那么空字符将覆盖返回地址的低位字节。因此,它将不会返回到原始的调用位置,而是跳转到地址 0x400600 处恰好存放的任何代码。在这个例子中,它完全失控并导致了段错误。

⚔️ 漏洞利用:栈溢出攻击

漏洞是指可能导致程序行为异常的东西。作为攻击者,兴趣在于利用这些漏洞来获取系统访问权限。因此,有一类曾经非常常见的攻击称为“栈溢出攻击”,它利用未检查的缓冲区来覆盖机器状态(特别是返回指针),从而获得系统控制权。

其思想是,P正常调用Q,会将地址A压入栈,然后为Q设置栈帧。如果Q包含一些易受攻击的代码(如 gets),它可能会溢出其栈帧并修改A的值。

如果我的程序中有某个函数(比如位于地址S的函数 smash)不是正常的返回目标,那么如果漏洞利用可以覆盖返回地址并将其从A改为S,那么当函数返回时,它将不会返回到原始调用点,而是会跳转到函数S的第一条指令,本质上就是调用了那个函数。

为了进行这种攻击,我需要创建一个字符串,其中有足够的填充字符以达到我想要的位置,然后在字符串中插入我希望它返回的地址(在这个例子中是函数 smash 的地址 0x4006fb)。

我可以演示这一点。

通常这有点棘手,因为字符串可能包含非标准ASCII字符。但我可以写一个函数。我写了一个名为 hexify 的程序,它将这种十六进制字节形式的字符串直接转换成原始格式。我创建了一个名为 smash.txt 的字符串文件,末尾看起来有点奇怪,因为它实际上包含了一些无法在普通终端上打印的字符。

现在,如果我将该字符串通过管道传输到我的 buf_demo 程序,你会看到它最终调用了名为 smash 的函数,该函数打印出“I've been smashed!”。因此,通过向这个程序提供一个巧妙设计的字符串,我能够使程序跳转到一个位置并执行一个不在程序正常控制流中的函数。

这是一种简单的攻击,但你可以看到,我现在能够控制系统并执行它原本不打算做的事情。


💉 代码注入攻击

攻击者可以更进一步:为什么不只是调用一个碰巧在那里的函数,而是调用一个我自己编写代码的函数呢?这可以通过一种称为“代码注入”的技术来实现。

其思想是在我们提供的字符串中,不仅构建用于填充的随机数字,还要构建编码了我们想要执行的指令的字节(即可执行代码)。然后,想法是用这个漏洞利用代码的起始地址覆盖返回地址。

现在,在函数调用中,如果我能将这个组合注入到栈中,那么当程序执行正常返回时,它实际上会跳转到栈中的某个位置并开始执行那里的代码。当它执行完后,可能会在漏洞利用代码中找到一个返回指令,这将使程序返回到原始程序的某个地方。但关键在于,我创造了让系统执行我作为输入提供的代码的能力,并且如果做得巧妙,操作系统本身完全无法检测。

这有点可怕,而且过去有很多易受攻击的代码和很多可以利用的地方。

🛡️ 防御机制

因此,人们采取了多种方法来减少这种情况,使攻击者更难得手。一是强制代码编写者遵守更好的规范,避免留下这些未受保护的缓冲区。另一种是使用保护措施来增加攻击难度,我们将展示其中的两种:一些由操作系统完成,一些由编译器完成。

以下是几种主要的防御技术:

  1. 使用安全函数
    作为程序员,你应该采用的第一种方法是永远不要使用可能对缓冲区进行无限制写入的函数。应该始终给出边界限制。例如,用 fgets 代替 getsfgets 的参数中包含最大长度,如果达到该长度(即使尚未遇到换行符或文件结束符),函数就会停止。

  2. 栈随机化(地址空间布局随机化 - ASLR)
    系统可以做的另一种技术是所谓的栈随机化,或更一般地称为基于地址的布局随机化。其思想是,每次程序执行时,栈的起始位置都与之前略有不同。这样做的目的是利用这样一个事实:这种特定的漏洞利用要求我知道注入到栈中的代码块的起始位置。如果这个位置每次都在变化,那么我就很难利用它。这个格式不太好的十六进制数字系列显示,对于一个特定程序,它运行了多次,每次全局变量的地址都不同,这是因为整个栈在每次运行之间都发生了偏移。一般来说,避免攻击的一个好方法是使用难以预测、难以被攻击者利用的随机化事物。

  3. 不可执行栈(NX位)
    另一种相对较晚才添加到Intel处理器中的技术是将程序的某些部分标记为可读和可写,但不可执行。特别是,有充分的理由需要读写栈,但没有很好的理由让程序能够执行位于栈上的代码。因此,如果我将这些内存部分简单地标记为不可执行,那么任何跳转到那里执行代码的尝试都会导致错误(段错误),基本上,你违反了操作系统强制执行的权限。

  4. 栈金丝雀
    第三种技术称为栈金丝雀,由编译器插入,试图使易受攻击的代码不那么脆弱。这个术语源于19世纪80年代英格兰的煤矿。矿工们会带着关在笼子里的金丝雀下井。如果金丝雀死了,就表明矿井中有有毒气体,必须立即撤离。因此,金丝雀(有时你会听到“煤矿中的金丝雀”这个说法)的想法是一个能检测到问题并采取必要措施的非常敏感的对象。栈金丝雀的想法是在栈上放置一些小标记,以便能够轻松可靠地检测缓冲区是否溢出。

这已成为GCC的默认设置。为了编译那段代码(buf_demo_nsp),我禁用了该保护。你会发现,对于相同的代码,如果我给出一个超过7位数字的字符串,它会给出错误信息。

让我为你演示一下。buf_demo_s 表示启用了栈保护。所以,7个字符没问题。8个字符输入后,程序崩溃,并返回各种关于检测到栈破坏的错误信息。


所以,它没有强制执行我的8字节限制,但不知何故它检测到8(实际上是8加1,即9字节的数据)正在破坏栈,超出了程序应该支持的范围。

这是 echo 函数的代码,其中红线是用于实现栈金丝雀的部分。一般方案是:在内存的某个通常无法访问的部分,有一个字节序列,它是该特定进程运行时专门随机生成的。这成为一种密钥。程序的前三行会读取该密钥并将其放入栈中,就在缓冲区区域的上方。然后,稍后调用可能破坏栈的函数。在所有操作完成后,通过从栈中读回这个值并确保它等于最初写入时的值,来检查栈是否被破坏。如果不相等,就表明出了问题。请注意,每次执行都必须以不同且随机的方式进行,否则攻击者可能计算出它是什么,并伪装起来,修改金丝雀然后再恢复它。

因此,总体方案是:我有一个缓冲区,假设是4字节长。我添加了另外4字节的“金丝雀”值。任何溢出该金丝雀的操作都会被检测到。

🧩 面向返回的编程(ROP)攻击

攻击者非常聪明,如果他们看到像我们刚才展示的新防御措施,他们会试图找出新的攻击方法。特别是,我们看到地址空间布局随机化使得很难准确预测任何代码块或缓冲区的位置。不可执行栈意味着我无法在栈中注入代码。

因此,开发出了一种称为“面向返回的编程”(ROP)的技术。其思想是尝试将现有程序(数据段或共享库中)的代码片段串联起来,组装成一个可用于执行我想要的任何操作的序列。

通常你找不到一个完全符合我需求的函数,所以我必须以某种方式用一堆更小的片段来构建这个函数,而不是让它全部存在那里。

因此,我将组装一个称为“小工具”的集合,它们是指令序列,每个都以返回指令结束。在x86机器上,字节 0xc3 编码了 ret(返回)指令。其前的字节则可能是一个小工具的一部分。

一个小工具将是一个以 0xc3 结尾的字节序列。例如,这段代码的前四个字节,如果被执行,将产生将寄存器 rdirdx 相加并将结果存储在寄存器 rax 中的效果,然后有一个返回。或者我可能找到像这样的代码,其中有一些来自某个指令的字节序列,但它恰好编码了我希望拥有的指令,比如这个三字节序列编码了将寄存器 rax 的内容移动到 rdi 的指令,并以 0xc3 结尾。

一般来说,攻击者会用一系列小工具的地址加载栈。记住,每个小工具都以返回指令结束。所以现在,如果我能设置好,并且我能执行一个返回指令,那么接下来会发生的是:我将开始从栈中弹出地址,跳转到那个地址,那将是一些小工具代码,执行完后遇到另一个返回,导致弹出、执行代码、返回,如此循环,直到我完成整个序列。

因此,我可以获取这些小工具的任何序列(其中一些可能只做一件小事),并将它们组装在一起,基本上创建出任意代码。例如,如果我想实现将 rdirdx 相加的效果,我可以将一些填充字符和该代码片段的起始地址 0x4004de4 压入栈。你必须小心排序。然后,当我执行返回时,它将跳转到代码的这部分并产生那个效果。这个很快就结束了,但你可以想象通过将一系列小工具串联起来做更有趣的事情。

📝 结构体内存布局练习

对于在课堂上学习这门课的人,我们有一个小测验。我建议你暂停一下,做一个小练习来测试你对机器代码的理解。

这里有一张幻灯片,是测验中的一个问题,学生们觉得很难,值得讲解。

问题是这样的:有两个结构体 S1S2S1 包含一个4字符的数组和一个整数。S2 包含一个4字符的数组,然后内嵌了一个 S1 的副本。记住,对于结构体,当你内嵌它们时,你确实拥有该结构体的确切字节放在更大的结构体内部。如果是指向结构体的指针,情况就不同了,你会有指针。但在这种情况下,我们拥有的是实际的结构体本身。然后还有一个双精度数。

现在暂停,尝试使用这个图,标记出这两个结构体的不同字段使用了哪些字节。

好的,我们回来了。你发现了什么?我将直接跳到解决方案,希望你能理解。

首先,S1 总共8字节:需要4字节存放四个字符(四个单字节),4字节存放整数 i。关于结构体,要记住的一个重要点是它们既有大小又有对齐要求。这对于任何数据结构都是如此。大小是使用的总字节数。对齐要求是结构体内最大字段的对齐要求(如果是像 intdouble 这样的简单原始数据类型,则对齐要求就是其大小)。在这种情况下,我的对齐要求是4,因为 i 是一个4字节对象。你会看到,我不必在这个结构体中添加任何填充,因为按照声明方式,i 会在4的倍数地址上开始。

另一方面,对于结构体 S2,你会看到我有4字节用于 y,然后我可以直接将 S1 内嵌在它后面,因为 S1 有4字节对齐要求。所以我会用接下来的8个字节存放它。之后,我必须插入4字节的填充,以满足双精度数 qqq 的对齐要求。如果我不这样做,qqq 将从地址12开始,这不是16的倍数。通过添加那额外的4字节,现在 qqq 从字节16跨越到字节23。内部存在这种浪费的空间。

这类问题在考试中经常出现,我们很容易生成。非常重要的是,你要了解、理解并完全熟悉结构体的概念:它们是如何布局的,它们的对齐要求是什么,它们有多大,以及它们是如何组织的。

📚 本节课总结

在本节课中,我们一起学习了机器级编程中与内存相关的核心概念。我们首先了解了程序内存空间的整体布局,包括栈、堆、数据段和文本段。然后,我们深入探讨了缓冲区溢出这一常见漏洞的原理,通过 gets 函数示例展示了如何因缺乏边界检查而导致栈数据被覆盖。我们分析了攻击者如何利用此漏洞修改返回地址,执行非预期的代码,甚至通过代码注入和面向返回的编程(ROP)技术实施更复杂的攻击。最后,我们介绍了多种防御机制,包括使用安全函数、栈随机化(ASLR)、不可执行栈(NX)和栈金丝雀,以增强程序的安全性。理解这些底层机制对于编写安全、健壮的系统软件至关重要。

10:机器编程高级部分 B

概述

在本节课中,我们将要学习C语言中一种特殊的数据聚合方式——联合(union)。我们将了解联合与之前学过的数组和结构体有何不同,探讨其内存布局、使用场景,并通过实例理解其如何揭示字节序等底层概念。

联合(Union)的基本概念

上一节我们介绍了结构体,本节中我们来看看联合。在C语言中,有三种聚合数据以形成更大数据结构的方式:数组结构体联合。联合的声明语法与结构体非常相似,但其含义却截然不同。

结构体包含一系列字段,程序可以在任意时刻使用所有字段。因此,编译器必须分配足够的内存,并处理填充字节和对齐问题,以确保所有字段都可用。

联合则是一种声明方式,它表示:“在任意时刻,我只想使用这些字段中的一个。它们将分配在内存的同一区域,并且都从相同的地址开始。”每个字段的大小根据其自身类型决定。

因此,可以认为:

  • 联合的总大小是其所有字段中最大的那个。
  • 结构体的总大小是其所有字段的总和,加上必要的填充字节。

联合的用途

为什么程序需要使用联合?它听起来似乎没什么用。实际上,联合有一些巧妙的用途。

其中之一在数据实验(Data Lab)中已经出现过。回想一下,你编写了处理浮点数的函数,但将数字视为无符号整数,以便操作其各个位,然后神奇地将其转换回浮点数用于测试。实现这一功能的代码就使用了联合。

以下是 bit2float 函数的示例,它接收一个无符号数并创建一个具有相同位表示的浮点数:

float bit2float(unsigned u) {
    union {
        unsigned u;
        float f;
    } temp;
    temp.u = u;
    return temp.f;
}

这与普通的类型转换不同。它的工作原理是:将一个无符号值写入联合,然后以浮点数的形式读取它。由于两者共享相同的四个字节,我们只是改变了看待这些字节的方式——从无符号整数序列变为浮点数。反向转换同样可以。

联合与字节序

使用联合的另一个作用是,它能让我们观察到特定机器使用的字节序。我们之前提到过字节序的概念,即对于多字节数据,在内存中如何排序:是小端序(最低有效字节在前)还是大端序(最高有效字节在前)。

大多数现代机器(如Intel处理器和常见的ARM系统)采用小端序。然而,在互联网传输中,多字节数据通常采用大端序。

当你在联合中使用不同数据类型的字段时,字节序以及不同机器上字长的差异,会影响你写入一个字段后读取另一个字段时,数据模式如何相互作用。

以下是一个示例,展示了不同机器和字节序下,联合行为可能产生的差异:

#include <stdio.h>

int main() {
    union {
        int i;
        char c[sizeof(int)];
    } u;
    u.i = 0x12345678;

    for (int j = 0; j < sizeof(int); j++) {
        printf("%.2x ", u.c[j]);
    }
    printf("\n");
    return 0;
}

在小端序机器上,输出可能是 78 56 34 12;而在大端序机器上,输出则是 12 34 56 78。此外,在32位和64位机器上,int 类型的大小可能不同,也会影响结果。

复合数据类型的总结

正如所提到的,创建复合数据类型有三种方式:数组结构体联合。它们有相似之处,但在其他方面又非常不同。

以下是它们的关键点:

  • 所有三种方式都涉及一块连续的内存区域
  • 数组:创建单一类型的多个副本,允许你使用整数索引访问元素 i
  • 结构体:创建一系列具有名称的字段,这些字段可以是不同的类型。
  • 联合:在单一内存区域上叠加多个不同的字段,为同一块内存提供不同的解释。

这些类型都可以递归地嵌套。例如,你可以有包含联合的结构体数组,或者包含数组的联合,等等。关键在于理解每个对象都有其大小(总字节分配量)和对齐要求(起始地址必须是某个2的幂的倍数,以满足其内部所有数据的对齐要求)。

安全启示

课程材料中还涉及一些现实生活中的缓冲区溢出攻击示例。虽然这些例子有些过时,但缓冲区溢出至今仍然是一个安全问题。如今,最大的安全漏洞来源可能是社会工程学攻击,但技术层面的缓冲区溢出问题依然存在。业界已做了大量工作使其更难以被利用,但理解其原理仍然非常重要。

总结

本节课中,我们一起学习了C语言中的联合。我们了解了联合与结构体在内存布局上的根本区别,探讨了联合在数据类型位级操作和揭示字节序方面的实用价值,并回顾了数组、结构体和联合这三种复合数据类型的特点。理解这些底层概念对于编写安全、高效的代码至关重要。

11:代码优化 🚀

在本节课中,我们将要学习代码优化的核心概念。我们将探讨编译器如何自动优化代码,以及哪些因素会阻碍编译器进行优化。我们还将了解如何利用现代处理器的指令级并行能力来提升程序性能,并讨论条件分支对性能的影响。通过学习这些内容,你将能够理解代码的性能瓶颈,并学会如何编写更高效的代码。

编译器优化概述

上一节我们介绍了课程主题,本节中我们来看看编译器通常执行的一些通用优化。

编译器会分析代码,寻找并消除冗余计算。例如,当你使用GCC编译时,如果不开启优化选项,生成的汇编代码会与你编写的C代码有更清晰的对应关系。但通常,使用-O3优化级别时,编译器会进行大量变换,例如内联函数等,以加速你的代码。

代码移动

代码移动是一种常见的优化。其核心思想是,如果循环内的某个计算是循环不变量(即其值在循环迭代中不改变),那么就没有必要在每次迭代中都重新计算它,可以将其移出循环。

以下是一个C代码示例:

for (int i = 0; i < n; i++) {
    result = n * i; // 如果n在循环中不变,这个乘法可以移出
}

优化后:

int temp = n;
for (int i = 0; i < n; i++) {
    result = temp * i; // 现在循环内只使用预先计算好的值
}

在汇编层面,编译器会自动进行这种优化,将类似n * (n-1)的计算置于循环之外。

强度削弱与公共子表达式消除

编译器还会尝试将昂贵的操作替换为更廉价的操作。例如,将乘以2的幂次转换为移位操作,或者利用LEA指令进行巧妙的计算。

另一个常见优化是公共子表达式消除。考虑一个在二维网格中计算上下左右邻居位置的程序:

up = (i - 1) * n + j;
down = (i + 1) * n + j;
left = i * n + (j - 1);
right = i * n + (j + 1);

这里重复计算了i * n。优化后,我们可以先计算base = i * n,然后:

up = base - n;
down = base + n;
left = base - 1;
right = base + 1;

这样就将三次乘法减少为一次,提升了效率。编译器在生成代码时会自动应用此类优化。

优化示例:冒泡排序

上一节我们介绍了一些通用优化,本节中我们通过一个具体的例子——冒泡排序算法,来观察编译器如何逐步优化代码。

我们从一个简单的、假设数组索引从1开始的冒泡排序C代码开始。编译器首先会将其转换为一种中间表示,这是一种直接的、机械的翻译。

初始生成的代码效率不高。例如,在计算数组元素地址a[j]a[j+1]时,存在冗余的加1和减1操作。编译器首先会进行局部优化,消除这些明显的冗余。

接下来,编译器会发现,在交换元素时,a[j]a[j+1]的地址已经被计算并存储在临时变量中,因此可以复用这些值,避免重复计算地址和加载值。

进一步的优化是观察循环。在内层循环中,变量j每次递增1,而基于j的地址计算每次递增4(假设int为4字节)。因此,我们可以直接使用地址指针进行循环,完全消除对j的依赖。初始化时设置好起始地址指针,每次循环时指针增加4,并通过比较地址指针来判断循环结束。

经过这一系列优化,内层循环的指令数显著减少。所有这些变换都由编译器在确保程序语义正确的前提下自动完成。

编译器的优化限制

上一节我们看到了编译器强大的优化能力,本节中我们来看看哪些因素会限制编译器进行优化。

编译器优化必须保证安全性。它只能基于对程序已知的信息进行不会破坏程序正确性的变换。一个例外是,如果程序使用了非标准的语言特性,编译器可能被允许采取更自由的行为,因为编译器作者不需要为所有非标准特性负责。

编译器优化的主要限制包括:

  • 过程内分析:传统编译器通常只在一个函数(过程)内部进行分析优化。进行跨函数的全局分析非常昂贵,尤其是对于大型程序。
  • 静态信息:像C这样的语言,编译器优化仅基于代码的静态文本,它不知道程序运行时的输入数据,因此必须做出保守的假设。
  • 内存别名:当两个指针可能指向同一内存位置时,会阻碍优化。编译器必须假设这种可能性存在,从而无法安全地进行某些代码重排或复用。

优化受阻示例:小写转换函数

考虑一个将字符串转换为小写的函数:

void lower(char *s) {
    for (int i = 0; i < strlen(s); i++) {
        if (s[i] >= 'A' && s[i] <= 'Z') {
            s[i] -= ('A' - 'a');
        }
    }
}

这段代码的性能是O(n²),而非预期的O(n)。原因是每次循环都调用strlen(s),而strlen需要遍历字符串直到遇到空字符。

为什么编译器不能将strlen(s)移出循环?因为strlen是一个函数调用,编译器无法确定每次调用它是否返回相同的值(例如,字符串内容可能在循环中被其他方式修改)。因此,编译器必须保守地假设每次都需要重新计算。

解决方案是进行手动代码移动,在循环前计算一次长度:

void lower(char *s) {
    int len = strlen(s);
    for (int i = 0; i < len; i++) {
        if (s[i] >= 'A' && s[i] <= 'Z') {
            s[i] -= ('A' - 'a');
        }
    }
}

对于短小的函数,编译器可以尝试内联它,从而看到函数内部逻辑并进行优化。但最直接的方法还是程序员自己进行这种显而易见的优化。

优化受阻示例:行求和函数

另一个例子是计算矩阵各行和的函数:

void sum_rows(float *a, float *b, long n) {
    for (int i = 0; i < n; i++) {
        b[i] = 0;
        for (int j = 0; j < n; j++) {
            b[i] += a[i*n + j];
        }
    }
}

在内层循环中,代码反复读写b[i](加载、相加、存储)。看起来编译器可以优化为使用一个寄存器临时累加,循环结束后再写回b[i]

但编译器不能这样做,因为存在内存别名的可能性:参数ab指向的数组可能重叠。例如,如果b恰好是a的一部分,那么在内层循环中更新b[i]可能会意外地改变后续要读取的a中的值。编译器必须考虑这种极端情况,因此无法进行优化。

解决方案是引入局部变量,明确告诉编译器我们的意图:

void sum_rows(float *a, float *b, long n) {
    for (int i = 0; i < n; i++) {
        float val = 0;
        for (int j = 0; j < n; j++) {
            val += a[i*n + j];
        }
        b[i] = val;
    }
}

这样,累加过程完全在寄存器中进行,消除了不必要的内存访问,性能得到提升。

利用指令级并行

上一节我们讨论了优化障碍,本节中我们来看看如何利用现代处理器的指令级并行来突破性能瓶颈。

现代CPU内部有多个功能单元(如加载单元、存储单元、多个算术逻辑单元等),并且采用流水线设计。这意味着在理想情况下,多个操作可以同时进行。例如,当一条指令在执行时,下一条指令已经在解码,再下一条指令在取指。

性能界限

程序的性能受两个关键因素限制:

  1. 延迟:完成一个操作所需的时间(周期数)。
  2. 吞吐量:单位时间内可以启动多少个该操作。

有些操作(如整数乘法、浮点运算)具有多个流水线阶段。虽然完成一次操作的延迟可能较长(例如3个周期),但由于流水线化,其吞吐量可以很高(例如每个周期可以启动一个新的乘法操作),前提是这些操作之间没有数据依赖。

案例分析:合并运算函数

我们通过一个“合并运算”函数(可执行累加或累乘)来探索性能提升。初始的简单实现性能不佳,因为累积操作acc = acc OP data[i]形成了长的依赖链,后一次操作必须等待前一次操作的结果,无法利用流水线。

以下是逐步优化策略:

第一步:循环展开
一次处理两个元素,但采用相同的累积方式acc = (acc OP data[i]) OP data[i+1]。这并没有打破依赖链,帮助有限。

第二步:重结合变换
改变结合顺序,计算acc = acc OP (data[i] OP data[i+1])。这样,data[i] OP data[i+1]可以独立计算,与acc的更新并行,减少了依赖,提高了吞吐量。

第三步:多路累积
创建两个独立的累积变量,分别累加偶数索引和奇数索引的元素:

acc0 = acc0 OP data[0];
acc1 = acc1 OP data[1];
acc0 = acc0 OP data[2];
acc1 = acc1 OP data[3];
...
result = acc0 OP acc1;

这完全打破了顺序依赖,允许两个累积流完全并行。结合循环展开,可以充分利用多个功能单元。

通过实验不同的展开因子和累积路数,可以找到特定硬件和操作类型下的最优组合。最终,性能可以非常接近由硬件功能单元数量决定的理论吞吐量极限,相比原始代码有数十倍的提升。

向量化

现代CPU还支持SIMD(单指令多数据)指令集(如x86的SSE、AVX)。这些指令可以同时对向量寄存器中的多个数据元素(如8个32位整数)执行同一个操作,从而在指令级并行之上,进一步实现数据级并行,获得更高的吞吐量。

条件分支的性能影响

上一节我们探讨了如何利用并行性,本节中我们来看看条件分支如何成为性能提升的障碍。

为了保持流水线充满,处理器需要提前取指和解码指令。当遇到条件分支时,处理器在真正执行到该指令之前,并不知道程序会走向哪个分支(“taken”还是“not taken”)。为了不使流水线停滞,处理器必须进行分支预测

处理器会根据历史信息(保存在分支预测缓冲器中)来预测分支的走向。如果预测正确,程序继续高速执行。如果预测错误,处理器必须丢弃所有在错误路径上已经进行的推测执行结果,并回到正确的分支起点重新开始,这会导致严重的性能损失(流水线清空)。

编写分支友好型代码

对于循环,处理器通常能很好地预测“继续循环”,只有在循环退出时才会预测错误一次。

对于if-then-else结构,处理器在没有任何历史记录时的默认预测是“分支不跳转”(即执行then块之后的代码)。因此,一个有用的编程技巧是:将更常见的条件放在if中,使得最常见的情况是“不跳转”的路径(fall-through)。例如,检查指针是否非空比检查是否为空更常见。

总结与建议

本节课中我们一起学习了代码优化的核心思想。我们了解了编译器能够自动进行的优化(如代码移动、强度削弱、公共子表达式消除),也认识了限制编译器优化的因素(如内存别名、过程间分析限制)。

为了编写高性能代码,建议:

  1. 警惕隐藏的算法低效:像在循环内调用strlen这样的操作。
  2. 帮助编译器:使用局部变量避免内存别名问题,进行必要的手动代码移动。
  3. 关注热点:将优化精力集中在程序最耗时的部分(通常是深层嵌套的循环)。
  4. 促进指令级并行:通过循环展开和多路累积等技术,减少操作间的数据依赖。
  5. 考虑分支预测:组织代码结构,使最常见执行路径成为分支预测的默认路径。
  6. 保持代码清晰:在追求性能的同时,不应过度牺牲代码的模块化和可读性。

通过理解底层系统如何工作,你可以更好地与编译器协作,写出既优雅又高效的代码。

12:存储层次结构 🗂️

在本节课中,我们将要学习计算机系统中的存储层次结构。我们将从存储技术和发展趋势开始,然后探讨局部性原理和缓存的基本概念,为后续深入学习缓存技术打下基础。

期中考试安排

上一节我们介绍了课程的基本信息,本节中我们来看看期中考试的具体安排。

我们将于下周初发布考试报名表。学生需要在周二、周三、周四晚上或周五的四个小时时间段内选择报名。

同时,我们将发布额外的练习题供学生练习。考试在教室进行,采用在线形式。学生通过电子方式接收和提交问题。

考试预计耗时约一个半小时,但我们提供了四小时的时间段,以确保无人需要匆忙完成。此外,我们将在考试周的周一安排复习课,并在前一天晚上举办一次考前辅导。

关于期末考试,它同样由课程自行安排,不通过学校注册办公室。期末考试也将在机房在线进行,形式与期中考试类似,学生有大约四天的时间报名参加。

存储技术与趋势

现在,让我们进入今天的主题:存储层次结构。我们将首先讨论存储技术及其发展趋势。

我们通常认为的内存是随机存取存储器。它以双列直插内存模块的形式出现,可以插入计算机主板上的插槽。

从技术角度看,内存由存储单个比特的单元组成。现代技术,特别是在闪存领域,尝试在一个单元中存储多个比特,但传统上每个单元只存储一个比特。

随机存取存储器主要有两种类型:动态随机存取存储器和静态随机存取存储器。

以下是两者的主要区别:

  • 动态随机存取存储器:每个存储单元使用一个晶体管。它需要定期刷新以保持数据,因为数据以电压形式存储,会随时间泄漏。它速度较慢,但成本远低于静态随机存取存储器。
  • 静态随机存取存储器:每个存储单元需要四到六个晶体管。它访问速度比动态随机存取存储器快约一个数量级,且不需要刷新。它通常用于处理器内部的高速缓存。

动态随机存取存储器的基本单元设计自1966年以来没有改变。多年来出现了不同规格的动态随机存取存储器,例如DDR系列,它已成为过去七年来服务器和桌面系统的标准。

除了易失性存储器,还有非易失性存储器,它们在断电后不会丢失信息。

以下是几种非易失性存储器:

  • 只读存储器:在生产过程中编程,用于存储启动代码等。
  • 可编程只读存储器:可编程一次。
  • 可擦除可编程只读存储器:可多次编程,但擦除操作繁重。
  • 电可擦除可编程只读存储器:可通过电子方式擦除和编程。
  • 闪存:一种电可擦除可编程只读存储器,允许以较大的块为单位进行擦除。它常见于固态硬盘中,但缺点是每个单元只能写入有限次数(约10万次)。

最新的非易失性存储器技术,如相变存储器、阻变随机存取存储器和自旋转移矩随机存取存储器,正在兴起。例如,英特尔的傲腾技术使用了他们称为3D XPoint的技术。这些技术旨在弥合动态随机存取存储器和固态硬盘之间的性能差距。

历史上,非易失性存储器用于固件程序,如启动计算机的BIOS、磁盘控制器和网卡。在固态硬盘中,它因其低能耗、持久性、抗冲击性和更小的外形尺寸而被采用。在大型数据中心,固态硬盘因其低能耗而有助于降低运营成本。

内存访问机制

上一节我们介绍了不同的存储技术,本节中我们来看看处理器如何与内存交互。

系统通过系统总线和内存总线连接。所有基于内存的设备都可以连接到内存总线上。

当中央处理器执行加载操作时,它首先将地址和读取指示放到内存总线上。内存随后将对应地址的值传回总线,中央处理器将其存入寄存器。

对于存储操作,中央处理器首先发送地址,然后发送要存储的数据字。内存控制器接收这些信息并将数据存入对应位置。

内存内部有更复杂的机制。例如,当请求某个字时,内存实际上会读取一整行单元格到行缓冲区中,希望利用局部性原理,以便快速响应后续对该行数据的访问。

硬盘驱动器

现在,让我们看看传统的硬盘驱动器。

硬盘驱动器内部包含多个盘片用于存储数据,一个主轴带动盘片旋转,一个悬臂用于读取数据,以及一个通过传动器控制悬臂的机制。

以下是硬盘的关键组成部分:

  • 盘片:数据存储的介质。
  • 磁道:盘片上的同心圆。
  • 扇区:磁道被划分成的小块。
  • 分区:磁道被分成称为记录区的不相交子集。每个区内的所有磁道具有相同数量的扇区。

硬盘的容量可以通过以下公式计算:
容量 = 每扇区字节数 × 平均每磁道扇区数 × 每盘面磁道数 × 每盘片盘面数 × 每磁盘盘片数

硬盘通过移动悬臂到特定磁道,并等待所需扇区旋转到磁头下方进行读取。访问时间包括寻道时间、旋转延迟和传输时间。

平均访问时间的计算公式为:
平均访问时间 = 平均寻道时间 + 平均旋转延迟 + 平均传输时间

例如,一个转速为7200转/分钟、平均寻道时间为9毫秒、平均每磁道扇区数为400的硬盘,其旋转延迟约为4毫秒,单个扇区传输时间约为0.2毫秒,总访问时间约为13毫秒。

与动态随机存取存储器的约60纳秒访问时间相比,硬盘的访问速度慢了约25万倍。与静态随机存取存储器的约4纳秒相比,则慢了约400万倍。

逻辑块与物理扇区

当通过文件系统访问磁盘时,我们使用逻辑块号进行读写,而不是直接处理物理扇区。磁盘控制器维护着从逻辑块到物理扇区的映射。

这种抽象层提供了灵活性,允许磁盘制造商将逻辑数据映射到磁盘的不同部分以优化访问模式。它还提供了容错能力,因为如果某个物理扇区损坏,可以将其重新映射到备用扇区。

格式化容量和最大容量之间的差异就是由于预留了备用扇区等因素造成的。

输入/输出与直接内存访问

处理器通过输入/输出总线与磁盘等设备通信。磁盘控制器位于磁盘前端。

当中央处理器需要从磁盘读取数据时,它将命令、逻辑块号和目标内存地址写入磁盘控制器对应的端口。然后,磁盘控制器执行直接内存访问,将数据直接传输到主内存,而无需中央处理器干预。

传输完成后,磁盘控制器会触发一个中断,通知中央处理器数据已就绪。操作系统随后处理该中断。

固态硬盘

固态硬盘的工作方式有所不同。它们通过相同的输入/输出总线连接,并使用逻辑块号。数据以页为单位组织,多个页组成一个块。

固态硬盘的一个特点是,在写入某个页之前,必须擦除其所在的整个块。当需要释放空间时,会进行垃圾回收,将有效数据复制到其他块,然后擦除原块。每个块在经历约10万次写入周期后会磨损。

固态硬盘制造商通过磨损均衡技术来延长寿命,即不断在物理块之间进行数据重映射,以避免对特定块进行过度写入。

尽管有块擦除的特性,固态硬盘通过闪存转换层将随机写入转换为顺序日志写入,从而实现了较高的随机写入吞吐量。早期的固态硬盘在垃圾回收时可能出现性能骤降,但新一代产品已经改善了这一点。

固态硬盘没有机械部件,更坚固,功耗更低,速度更快。但按每字节计算,它们比硬盘驱动器更昂贵。然而,考虑到总体拥有成本和性能优势,它们在数据中心中越来越受欢迎。

存储层次结构与局部性

我们讨论了磁盘速度远慢于动态随机存取存储器,而静态随机存取存储器的速度更快。固态硬盘的性能介于两者之间,而新兴的非易失性内存技术正在进一步缩小这个差距。

为了应对快速处理器和慢速存储设备之间的巨大速度差异,我们依赖于局部性原理。

程序倾向于重复使用相同或邻近地址的数据和指令。重复使用相同地址称为时间局部性。使用邻近地址称为空间局部性。

例如,在累加数组元素的程序中,顺序访问数组元素体现了空间局部性,而反复引用累加变量则体现了时间局部性。循环中的指令访问也同时体现了空间和时间局部性。

编译器会尝试为程序创造更好的局部性。作为程序员,了解代码的局部性也很重要。例如,在行优先存储的数组中,按列顺序访问可能 locality 较差,但如果数组列数很小,则可能仍在缓存中。

在多层嵌套循环中,调整循环顺序可以改善局部性。通常,我们希望最内层循环遍历连续的内存地址。

缓存的基本原理

计算机系统采用存储层次结构来利用局部性。最顶层是寄存器,速度最快,容量最小。然后是处理器内部的多级缓存,接着是主内存,最后是本地和远程磁盘等存储设备。

随着层次下降,存储设备容量变大,速度变慢,单位成本降低。反之,则容量小,速度快,成本高。

缓存存储着来自下一级存储的部分数据块。当处理器请求数据时,如果在缓存中找到,则称为命中。如果未找到,则称为未命中,需要从下一级获取。

缓存设计涉及两个关键策略:

  • 放置策略:决定将数据块放入缓存的哪个位置。
  • 替换策略:当缓存已满且需要放入新数据时,决定替换掉哪个旧数据块。如果被替换的数据已被修改,则需要写回下一级存储。

有三种主要的缓存未命中类型:

  • 强制性未命中:第一次访问某个数据块时必然发生。
  • 容量未命中:程序的工作集大小超过了缓存容量。
  • 冲突未命中:在直接映射等缓存中,多个数据块映射到同一个缓存位置,导致即使缓存未满也发生替换。

最简单的缓存是直接映射缓存,它根据数据块地址的模运算结果决定其存放位置。

缓存和存储层次结构的概念广泛应用于计算机系统,例如转换后备缓冲器用于缓存地址转换信息。

总结

在本节课中,我们一起学习了计算机系统中的存储层次结构。我们探讨了处理器与内存、磁盘速度之间的持续差距,以及如何通过局部性原理和缓存技术来弥合这一差距。理解这些概念对于编写高效程序和深入理解系统性能至关重要。

13:缓存存储器 🧠

在本节课中,我们将要学习缓存存储器的组织结构和性能影响。我们将回顾局部性的概念,探讨缓存的组织方式(如直接映射缓存和组相联缓存),并分析如何编写对缓存友好的代码以提升程序性能。

局部性与存储层次结构回顾

上一节我们介绍了计算机系统的基本概念,本节中我们来看看存储层次结构中的关键思想:局部性。

程序倾向于访问最近使用过的数据或代码,或者访问与它们邻近的数据或代码。这被称为局部性。我们区分了两种类型的局部性:

  • 时间局部性:在不久的将来再次访问相同的位置。
  • 空间局部性:访问与最近访问过的位置邻近的位置(例如,在同一个缓存行内)。

存储层次结构可以形象地表示为一个金字塔。顶部是更小、更快、但每字节成本更高的存储设备。越往底部,存储设备变得更大、更慢,但每字节成本更低。数据在相邻的层级之间移动。

层次结构中的任何两个相邻层级,都可以将上层视为下层的缓存。缓存比其下层的内存小得多。内存被划分为,块的大小是数据在层级间传输的单位。

缓存的基本概念

上一节我们介绍了存储层次结构,本节中我们来看看缓存操作中的几个核心概念。

当访问数据时,如果数据已经在缓存中,这被称为命中。如果数据不在缓存中,则发生缺失,需要从下一层获取数据。获取数据后,需要将其放入缓存。

这引出了两个策略:

  • 放置策略:决定将新数据放在缓存的哪个位置。
  • 替换策略(或驱逐策略):当缓存已满且需要放入新数据时,决定移除(驱逐)哪个旧数据块(称为牺牲块)。

缓存缺失有三种类型:

  • 强制性缺失(冷缺失):因为缓存初始为空,第一次访问任何数据块时必然发生的缺失。任何系统都无法避免。
  • 容量缺失:当缓存的大小小于程序活跃工作集(程序正在使用的数据集)时,会因为空间不足而驱逐未来还需要的数据,导致缺失。
  • 冲突缺失:由于放置策略的限制,即使缓存中还有其他空闲位置,多个数据项被映射到缓存的同一个位置(例如,同一个组),导致它们相互驱逐。

缓存的组织与寻址

了解了缓存的基本操作后,我们现在深入探讨缓存是如何组织的,以及处理器如何根据地址找到数据。

缓存位于CPU芯片上,通常有多级(L1, L2, L3)。L1缓存通常分为指令缓存和数据缓存。它们通过总线接口访问主内存。

缓存可以按多种方式组织。我们用三个参数来描述:

  • S的数量。
  • E:每个组中的数。
  • B:每个缓存(行)的字节数。

S、E、B都是2的幂。一个缓存行不仅包含数据(B字节),还包含一个有效位(指示此行是否包含有效数据)和一个标记(用于识别数据块)。

地址被划分为三个字段:

  • 块偏移b位,用于定位块内的具体字节。B = 2^b
  • 组索引s位,用于选择具体的组。S = 2^s
  • 标记t位,是地址中剩余的部分。t = w - (s + b),其中w是地址位数。

查找过程如下:

  1. 使用组索引位找到对应的组。
  2. 在该组的所有行中,并行比较标记位。
  3. 如果找到匹配的标记该行的有效位为1,则命中。然后使用块偏移从数据块中取出所需字节。
  4. 如果没有匹配的标记或有效位为0,则缺失。

直接映射缓存

缓存组织的一种简单形式是直接映射缓存,它是理解更复杂缓存的基础。

在直接映射缓存中,每个组只有一行(E = 1)。这意味着每个内存块只能被放置到缓存中唯一的一个特定位置。

其寻址方式与通用模型一致。由于每组只有一行,找到组后,只需比较该行的一个标记。如果标记匹配且有效,则命中;否则缺失。

在直接映射缓存中,替换策略很简单:新数据总是替换掉当前占据目标位置的那个旧数据块。这种简单性使得它容易实现,但也更容易导致冲突缺失

组相联缓存

为了减少冲突缺失,大多数现代处理器使用组相联缓存。

在组相联缓存中,每个组有多行(E > 1,通常为2、4、8、16等)。一个内存块可以被放置到其对应组中的任何一行

寻址方式不变:用组索引找到组。但在该组内,需要检查所有E个行的标记以寻找匹配。这提供了更大的灵活性,减少了多个数据块竞争同一个缓存位置的情况。

当发生缺失且组已满时,需要替换掉其中一行。常见的替换策略最近最少使用(LRU):驱逐组内最久未被访问的行。其他策略还包括随机替换、先进先出(FIFO)等。

缓存写操作

到目前为止我们主要讨论了读操作。写操作需要特别处理,因为它会修改数据。

当CPU执行写操作时:

  • 如果写命中缓存,有两种主要策略:
    • 直写:同时更新缓存和下一级存储(如下一级缓存或内存)。这保证了数据一致性,但每次写操作都很慢。
    • 写回:只更新缓存中的数据。被修改的缓存行被标记为(通过一个额外的脏位)。只有当该行被驱逐时,才将其写回下一级存储。这是更常见的策略,因为它将多次写操作合并为一次更新,利用了时间局部性。
  • 如果写缺失缓存,也有两种策略:
    • 写分配:将所写地址对应的数据块加载到缓存中,然后像命中一样更新它。这通常与写回策略配合使用。
    • 非写分配:直接写入下一级存储,不将数据块载入缓存。这通常与直写策略配合使用。

缓存性能分析

理解了缓存的组织和操作后,我们来量化分析其对程序性能的影响。

衡量缓存性能的关键指标包括:

  • 缺失率:内存访问中发生缺失的比例。L1缓存的典型缺失率在3%-10%之间。
  • 命中时间:从缓存中读取数据所需的周期数。L1命中通常需要4个周期。
  • 缺失惩罚:处理缺失并从下一级获取数据所需的额外周期数。从主内存获取数据可能需要上百个周期。

平均内存访问时间(AMAT)的公式为:
AMAT = HitTime + MissRate * MissPenalty

即使缺失率看起来很小(例如从1%上升到3%),由于缺失惩罚很大,平均访问时间也可能显著增加(例如翻倍)。因此,关注缺失率比命中率更能揭示性能问题。

编写缓存友好的代码

缓存对性能有巨大影响,因此编写能够有效利用缓存的代码至关重要。目标是最大化缓存命中率,尤其是内层循环的命中率。

以下是编写缓存友好代码的核心原则:

  • 关注内层循环:这是程序执行最频繁的部分。
  • 尽量使用局部变量:编译器会将其放入寄存器,访问最快。
  • 步长为1的引用模式:顺序访问内存(如遍历数组)能最好地利用空间局部性。例如,一个64字节的缓存行可以容纳8个double类型数据。顺序访问时,只有第一个元素会引发缺失,后续7个都是命中。
  • 重新排列循环:改变嵌套循环的顺序可以极大地改变数据访问模式,从而影响缓存命中率。例如,在矩阵乘法中,将循环顺序从i-j-k改为k-i-j,可以使对矩阵B和C的访问变得连续,大幅降低缺失率。
  • 使用分块技术:对于像矩阵乘法这样的计算,将大矩阵分割成能放入缓存的小块进行处理,可以确保在块内操作时数据始终驻留在缓存中,显著减少容量缺失和冲突缺失。

实例分析:内存山与矩阵乘法

理论需要实践验证。通过“内存山”实验可以直观展示缓存的影响。

内存山图形展示了读吞吐量(每秒读取的字节数)如何随数据大小访问步长变化。图形中的“山脊”对应不同级别的缓存(L1, L2, L3)。当数据能完全放入某级缓存时,吞吐量达到峰值;当数据超出缓存容量时,吞吐量因访问更慢的存储而下降。步长越大,空间局部性越差,吞吐量也越低。

在矩阵乘法的具体例子中,我们分析了不同循环顺序下的缓存缺失率。理论分析和实验都表明,k-i-j(或k-j-i)的顺序能产生最佳的缓存访问模式,而i-k-j的顺序则因为对矩阵A和C的访问都是列优先(大步长)而导致很差的性能。这强调了循环顺序对性能的关键影响。

总结

本节课中我们一起学习了缓存存储器的核心知识。我们回顾了局部性原理和存储层次结构,深入探讨了缓存的组织方式,包括直接映射缓存和组相联缓存的寻址机制。我们分析了缓存读写的策略,以及如何量化评估缓存的性能指标(如缺失率、AMAT)。最后,我们重点学习了如何通过关注内层循环、采用顺序访问、重新排列循环和使用分块技术来编写缓存友好的代码,从而显著提升程序性能。理解并应用这些概念,是进行高效系统编程的关键。

14:链接 🔗

在本节课中,我们将要学习编译过程中的一个关键但常被忽视的环节——链接。链接负责将多个编译后的文件以及各种库代码组合在一起,形成一个可执行的程序。理解链接对于避免编程错误和成为高级用户至关重要。

链接的基本概念

上一节我们介绍了链接的总体目标。本节中,我们来看看链接的具体任务。

想象一个程序由两个不同的文件组成:main.csum.cmain.c 中的 main 函数调用了一个名为 sum 的函数,但 sum 函数只在 main.c 中声明,其定义在 sum.c 中。链接器的任务就是将这两个文件(以及必要的库信息)合并,生成一个名为 prog 的可执行程序。

链接的核心活动是符号解析。符号可以是变量或函数的名称。链接器需要确定每个符号的定义位置,并将其与所有引用该符号的地方关联起来。

为什么需要链接器?

以下是链接器存在的几个主要原因:

  • 分离编译:大型项目包含大量代码,分离编译允许我们独立编译单个文件,而无需在每次修改一行代码时重新编译整个项目。
  • 库的使用:程序经常使用如 printfmalloc 等库函数。链接器负责将这些库函数的代码引入到我们的程序中。
  • 效率:链接器帮助创建高效的可执行程序,避免在运行时产生大量调用外部函数的开销。同时,通过动态链接,可以避免在每个可执行程序中重复复制整个C标准库,从而节省磁盘和内存空间。

目标文件与符号

编译器将 .c 源文件编译成 .o 可重定位目标文件。这些文件包含了机器代码,但其中引用外部符号(如其他文件中定义的函数或变量)的地址尚未确定。

链接器将这些 .o 文件合并,并解析其中的符号。只有具有全局作用域的变量和函数才会成为符号,局部变量(如函数内的临时变量)不会出现在符号表中。

我们可以使用 objdump 工具来查看目标文件中的符号和代码。

objdump -t main.o  # 查看 main.o 的符号表
objdump -d -r main.o # 反汇编代码并显示重定位信息

在反汇编输出中,你会看到类似 R_X86_64_PC32 的条目,这表示该处指令需要链接器填入正确的地址(例如,调用 sum 函数或访问全局数组 array 的地址)。

链接器不是编译器。它假设代码已经编译完成,其工作仅仅是根据符号表信息,在代码的特定字节处覆写正确的地址,然后将所有部分拼接成一个完整的可执行文件。

ELF 文件格式

可重定位目标文件(.o)、可执行文件(a.out 或自定义名称)和共享库文件(.so)都采用一种称为 ELF 的标准格式。

一个ELF文件包含多个节(section),每个节存储不同类型的数据:

  • ELF头:包含文件类别、字节序等通用信息。
  • .text节:存放已编译的机器代码。
  • .rodata节:存放只读的全局数据,例如字符串常量。
  • .data节:存放已初始化的全局变量。
  • .bss节:存放未初始化的全局变量。该节在文件中不占实际空间,程序加载时会被初始化为零,以节省磁盘空间。
  • .symtab节:符号表。
  • .rel.text节:代码部分的重定位信息。
  • .rel.data节:数据部分的重定位信息。
  • .debug节:调试信息(供GDB等调试器使用)。

符号解析的规则

上一节我们了解了ELF文件的结构。本节中,我们深入探讨链接器如何解析跨文件的符号。

每个符号被分类为强符号弱符号

  • 强符号:函数定义,或已初始化的全局变量。
  • 弱符号:未初始化的全局变量(或通过 extern 声明的变量)。

链接器按以下规则处理多重定义的符号:

  1. 不允许有多个同名的强符号。如果存在,链接器会报错。
  2. 如果有一个强符号和多个弱符号同名,则选择强符号。
  3. 如果只有多个弱符号同名,链接器会任意选择其中一个。这可能导致难以调试的问题。

关键点:链接器不进行类型检查。如果两个文件中同名全局变量的类型不同(例如 intdouble),链接器可能不会报错,但程序运行时会出现数据解释错误,导致诡异的结果。

为了避免这类问题,应遵循以下最佳实践:

  • 尽量使用 static 关键字将变量和函数的作用域限制在文件内。
  • 对于需要在文件间共享的全局变量,总是在头文件(.h)中用 extern 声明,并在且仅在一个 .c 文件中定义。

static 关键字在文件作用域和函数作用域有不同的含义:

  • 在函数外使用 static:定义了一个文件内全局可见,但对其他文件不可见的变量或函数。
  • 在函数内使用 static:定义了一个在多次函数调用间保持其值的局部变量。

静态库与动态库

到目前为止,我们主要讨论了如何链接我们自己编写的多个文件。链接器的另一个重要角色是链接库代码。

静态链接

静态库是一组预先编译好的目标文件(.o)的归档文件,后缀为 .a(例如,C标准库的静态版本是 libc.a)。使用静态链接时,链接器会从静态库中拷贝程序实际用到的目标文件,将它们直接合并到最终的可执行文件中。

创建和使用静态库的命令如下:

# 创建静态库 libvector.a,包含 addvec.o 和 multvec.o
ar rcs libvector.a addvec.o multvec.o

# 编译程序,并静态链接 libvector.a
gcc -o prog2c main2.o -L. -lvector

静态链接的缺点:

  1. 代码膨胀:相同的库代码会被复制到每个使用它的可执行程序中。
  2. 更新困难:如果库中发现安全漏洞,所有静态链接了该库的程序都需要重新编译和分发。

动态链接(共享库)

为了解决静态链接的问题,现代系统广泛使用共享库(在Linux上后缀为 .so,在Windows上为 .dll)。

共享库在链接时不会被复制到可执行文件中。相反,可执行文件中只记录它依赖哪个共享库以及所需的符号。在程序加载时运行时,动态链接器才会将共享库的代码和数据映射到进程的内存空间中。

创建和使用共享库的命令如下:

# 编译位置无关代码(-fPIC 是关键)
gcc -c -fPIC addvec.c multvec.c

# 创建共享库 libvector.so
gcc -shared -o libvector.so addvec.o multvec.o

# 编译程序,并动态链接 libvector.so
gcc -o prog2 main2.o ./libvector.so

# 使用 ldd 命令查看程序的动态库依赖
ldd prog2

位置无关代码(PIC) 是共享库的关键特性,它使得库代码可以被加载到内存的任何地址而不需要修改其内部指令。

运行时链接

除了加载时链接,程序还可以在运行过程中显式地请求加载和链接共享库,这称为运行时链接或动态加载。通过 dlopendlsym 等函数实现。

#include <dlfcn.h>
void *handle = dlopen("./libvector.so", RTLD_LAZY);
void (*addvec)(int*, int*, int*, int) = dlsym(handle, "addvec");
// ... 使用 addvec 函数指针 ...
dlclose(handle);

链接器技巧:拦截(Interpositioning)

拦截是一种强大的技术,它允许你“劫持”对标准库函数(如 mallocfree)的调用,并将其替换为自己的代码。这在性能分析、内存调试(如 Valgrind 的原理)和测试中非常有用。

主要有三种实现拦截的方式:

  1. 编译时拦截:通过预处理器宏实现。

    // mymalloc.h
    #define malloc(size) mymalloc(size)
    #define free(ptr) myfree(ptr)
    
  2. 链接时拦截:使用链接器的 --wrap 标志。

    // 定义包装函数
    void *__wrap_malloc(size_t size) { ...调用 __real_malloc... }
    void __wrap_free(void *ptr) { ...调用 __real_free... }
    
    // 编译链接命令
    gcc -Wl,--wrap,malloc -Wl,--wrap,free -o prog ...
    
  3. 运行时拦截:通过设置 LD_PRELOAD 环境变量,让动态链接器优先加载你自定义的共享库。

    // 编译包含自定义 malloc/free 的共享库
    gcc -shared -fPIC -o mylib.so mylib.c
    
    // 运行程序时拦截
    LD_PRELOAD="./mylib.so" ./any_program
    

    这种方法甚至可以对没有源代码的二进制程序进行拦截和分析。

总结

本节课中我们一起学习了链接的完整过程。我们从链接的基本需求出发,了解了符号解析的规则和潜在陷阱。然后,我们深入探讨了目标文件的ELF格式。接着,我们比较了静态库和动态库(共享库)的优缺点及创建方法,并介绍了运行时链接。最后,我们探讨了强大的拦截技术,它展示了深入理解链接器所能带来的可能性。

理解链接不仅能帮助你避免“未定义符号”或“多重定义”这类常见错误,更能使你掌握构建复杂程序、优化程序体积和性能、以及进行高级调试和剖析的工具与思想。

15:异常控制流与进程

概述

在本节课中,我们将学习计算机系统中的异常控制流。你将了解到程序并非总是按部就班地执行,有时会发生一些“意外”事件,例如程序错误(如段错误)或外部事件(如鼠标移动、网络数据包到达),这些事件会打断正常的程序执行流程。操作系统和硬件会协同处理这些事件,并决定下一步做什么。我们还将学习进程的概念,即一个正在执行的程序实例,以及如何使用系统调用(如 fork)来创建和管理进程。


什么是异常控制流?🤔

上一节我们介绍了计算机系统的基本执行模型。正常情况下,程序按照指令顺序一条一条地执行,这种流程是可预测的。然而,计算机系统还需要处理许多不可预测的事件。

这些事件可能是程序内部的错误,例如尝试访问无效的内存地址(导致段错误)。也可能是外部事件,例如移动鼠标、敲击键盘或网络数据包到达。这些事件会打断程序当前的执行流程,迫使处理器去处理它们。这种因事件而改变的控制流,就称为异常控制流

处理这些事件的关键在于计算机的速度。人类最快的反应时间大约是10毫秒(0.01秒)。而现代处理器的时钟频率通常在1吉赫兹(GHz)以上,这意味着一个时钟周期只有1纳秒(10⁻⁹秒)。在人类反应所需的10毫秒内,处理器可以执行超过一千万条指令!正是这种巨大的速度差,使得计算机能够在“瞬间”处理大量外部事件(如跟踪鼠标坐标、更新屏幕),同时还能运行多个程序,让用户感觉所有事情都在“同时”发生。


异常处理机制 ⚙️

当程序正常执行时,如果发生异常事件,控制权会从用户程序转移到操作系统内核。内核是操作系统的核心部分,拥有更高的权限,可以访问用户程序无法触及的系统数据结构和硬件资源。

异常处理的基本流程如下:

  1. 程序执行:用户代码正常运行。
  2. 事件发生:内部错误(如除零、非法内存访问)或外部事件(如定时器中断、I/O完成)触发异常。
  3. 陷入内核:处理器暂停当前程序,跳转到内核中预先定义好的异常处理程序
  4. 处理决策:内核根据异常类型(由一个数字ID标识)查找异常表,决定如何处理。这就像一个巨大的 switch 语句。
  5. 采取行动:处理完成后,可能有几种结果:
    • 返回并重试:修复问题后(如为未分配的内存页分配空间),重新执行导致异常的指令。
    • 返回并继续:处理完事件后(如处理完一次鼠标中断),继续执行下一条指令。
    • 终止程序:如果错误无法恢复(如严重的段错误),则终止当前程序。

以下是异常处理流程的示意图:

用户程序执行中 -> 发生异常 -> 跳转至内核异常处理程序 -> 根据异常ID处理 -> 采取行动(返回/终止等)

异常的类型 🔍

异常可以根据其来源分为两大类:

  • 异步异常(中断):由处理器外部的外部事件引起,与当前正在执行的指令无关。

    • 定时器中断:操作系统用来实现多任务切换,例如每1或10毫秒触发一次,让内核有机会决定是否切换到另一个程序运行。
    • I/O中断:例如,当磁盘读取完成或网络数据包到达时,硬件会通知处理器。
  • 同步异常:由当前执行的指令直接触发,与程序本身相关。

    • 陷阱有意触发的异常,通常用于实现系统调用(如打开文件、创建进程)。程序通过执行一条特殊指令(如 syscall)主动进入内核。
    • 故障:潜在可恢复的错误,例如页错误。当程序访问一个尚未加载到物理内存的虚拟内存页时,会触发页错误。内核可以按需将该页从磁盘加载到内存,然后让程序重新执行那条指令。
    • 终止:不可恢复的致命错误,例如硬件错误或非法指令。通常会导致程序被强制终止。

对于应用程序开发者来说,最常打交道的同步异常是陷阱(用于系统调用)和故障(如段错误)。


系统调用:与内核通信 📞

系统调用是用户程序请求操作系统内核为其服务的编程接口。在底层,它们通过一条特殊的处理器指令(在x86-64上是 syscall)实现。

以下是一个使用 syscall 指令实现 open 系统调用的示例:

mov    $0x2, %eax       # 系统调用号:2 代表 open
mov    %rdi, %ebx       # 第一个参数:文件名指针
mov    %rsi, %ecx       # 第二个参数:打开标志
syscall                 # 执行系统调用,陷入内核
# 内核处理完毕后,返回值会放在 %rax 中

执行 syscall 指令后,处理器会切换到内核模式,内核根据 %eax 寄存器中的编号(这里是2,代表 open)执行相应的服务代码。完成后,控制权返回用户程序,就像从一次普通函数调用返回一样。

常见的系统调用包括:read, write, open, close, fork, exec, exit 等。


进程:执行的程序 🧬

一个关键的抽象概念是进程程序是存储在磁盘上的静态代码和数据,而进程是程序的一次动态执行实例。进程拥有独立的地址空间、寄存器状态(包括程序计数器PC)和系统资源。

  • 一个程序可以对应多个进程(例如,同时打开多个终端窗口都运行 bash)。
  • 进程是系统进行资源分配和调度的基本单位。

操作系统通过维护一个进程表来管理所有进程。每个进程都有一个唯一的进程ID

使用 topps 命令可以查看系统中正在运行的进程。你会发现,即使在你觉得“空闲”的计算机上,也可能运行着上百个进程(如后台服务、守护进程等)。大多数进程大部分时间处于“睡眠”状态,等待事件(如用户输入)唤醒它们。


并发与上下文切换 🔄

现代操作系统创造了每个进程都独享整个CPU的假象,这称为并发。实际上,在单核CPU上,任何时刻只有一个进程的指令在CPU上执行。操作系统通过一种称为上下文切换的机制来实现多进程的并发执行。

上下文切换的步骤如下:

  1. 当前进程因系统调用、中断或定时器到期而进入内核。
  2. 内核将当前进程的上下文(即CPU寄存器的状态,包括PC、栈指针等)保存到该进程的进程控制块中。
  3. 内核从另一个就绪进程的进程控制块中恢复其上下文,加载到CPU寄存器。
  4. 内核将控制权交给这个新恢复的进程,使其开始执行。

这个过程发生得非常快(通常在微秒级别),以至于人类无法感知,从而产生了多个程序“同时”运行的错觉。

两个进程的执行时间在时间线上有重叠,就称它们是并发的。并发可能带来进程间的交互和竞争条件,这是编写正确并发程序的挑战所在。


创建进程:fork 系统调用 🐣

在Unix/Linux系统中,创建新进程的主要方式是使用 fork() 系统调用。fork() 的行为非常独特:它被调用一次,但返回两次

fork() 的工作原理:

  1. 调用 fork() 的进程称为父进程
  2. 内核创建一个与父进程几乎完全相同的副本,称为子进程。这包括复制代码段、数据段、堆、栈以及所有打开的文件描述符。
  3. 在子进程中,fork() 返回 0
  4. 在父进程中,fork() 返回子进程的进程ID(一个大于0的正整数)
  5. 此后,父进程和子进程并发地执行 fork() 调用之后的代码,它们拥有各自独立的内存空间。

下面是一个简单的 fork 示例程序 fork.c

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork(); // 调用一次,返回两次

    if (pid == 0) {
        // 这段代码只在子进程中执行
        printf("Hello from the child process! (PID: %d)\n", getpid());
    } else if (pid > 0) {
        // 这段代码只在父进程中执行
        printf("Hello from the parent process! (PID: %d), my child's PID is %d\n", getpid(), pid);
    } else {
        // fork 失败
        perror("fork failed");
        return 1;
    }
    return 0;
}

可能的输出(顺序不定):

Hello from the parent process! (PID: 1234), my child‘s PID is 1235
Hello from the child process! (PID: 1235)

关键点:由于父进程和子进程并发执行,printf 语句的输出顺序是不确定的,取决于操作系统的调度。这是一个并发编程的基本特性。


进程图:分析并发程序 🕸️

对于复杂的 fork 调用序列,我们可以使用进程图来分析程序的执行流程和可能的输出顺序。进程图是一个有向无环图,其中:

  • 节点代表程序语句或指令。
  • 边代表执行顺序(控制流)。
  • fork() 调用会创建一个分支,产生两条并发的边(父进程和子进程)。

通过遍历进程图所有可能的路径(遵循边的方向),我们可以枚举出程序所有可能的输出序列。这有助于理解并发行为并调试程序。

例如,一个连续调用两次 fork() 且没有条件判断的程序,会创建总共4个进程(包括最初的父进程),并打印出4条“Hello”信息。进程图可以清晰地展示这个“进程树”的生成过程。


进程终止 🛑

进程可以通过以下方式终止:

  1. main 函数 return
  2. 调用 exit(status) 函数。status 是退出状态码,0 表示成功,非0 表示错误。
  3. 收到一个无法处理或导致终止的信号(如 SIGSEGV 段错误信号)。

exit() 函数永远不会返回到它的调用者,它会终止整个进程。

当一个进程终止时,内核会释放其占用的所有资源(内存、打开的文件等),并通知其父进程。父进程可以通过 wait()waitpid() 系统调用来获取子进程的终止状态信息。我们将在下一讲详细讨论进程间通信和同步。


总结

本节课我们一起学习了计算机系统中的异常控制流进程管理。

  • 我们了解到,异常控制流是处理程序内外突发事件的机制,是操作系统实现多任务和响应用户交互的基础。
  • 我们区分了异步中断同步异常(陷阱、故障、终止),并理解了系统调用 syscall 是程序主动利用陷阱与内核通信的方式。
  • 我们掌握了进程作为程序执行实例的核心概念,知道了操作系统通过快速的上下文切换来实现多进程的并发执行。
  • 我们重点学习了 fork() 系统调用,它通过复制自身来创建新进程,是Unix/Linux中进程创建的基石。fork() 调用一次,返回两次的特性以及由此带来的并发不确定性,是编写系统程序时需要仔细处理的关键点。

理解这些概念,对于后续学习进程间通信、信号处理以及编写复杂的并发应用程序至关重要。在接下来的课程中,我们将深入探讨如何让进程之间进行协作和通信。

16:输入/输出 (I/O) 🖥️

在本节课中,我们将要学习计算机系统中的输入/输出(I/O)机制。我们将从操作系统底层的Unix I/O开始,了解文件描述符、读写操作等核心概念。接着,我们会探讨更常用的标准I/O库,以及它如何通过缓冲机制提高效率。最后,我们将介绍一个专为网络编程设计的可靠I/O(RIO)包。理解这些不同层次的I/O对于后续的Shell实验和期末项目至关重要。

Unix I/O:底层视角 🔧

Unix系统对文件有一个非常简单的抽象:文件就是一个字节序列。这个“文件”的概念不仅用于存储在磁盘上的数据,也用于I/O设备、网络连接等,为各种资源提供了一个统一的访问框架。

核心操作与文件描述符

在Unix I/O层面,对文件的操作非常基础,主要包括打开、关闭、读取和写入。

  • 打开文件 (open):通过指定文件路径和标志(如只读 O_RDONLY)来打开一个文件。成功后会返回一个文件描述符,这是一个小的非负整数(如3, 4, 5),作为该打开文件的标识符。每个进程启动时自动拥有三个文件描述符:0(标准输入)、1(标准输出)、2(标准错误)。
  • 关闭文件 (close):使用完毕的文件描述符需要通过 close 系统调用关闭。
  • 读取文件 (read):从指定的文件描述符读取数据到缓冲区。其函数原型为 ssize_t read(int fd, void *buf, size_t count)。它尝试读取最多 count 个字节,但实际返回的字节数可能小于请求值,这被称为短计数
  • 写入文件 (write):将缓冲区的数据写入指定的文件描述符。其函数原型为 ssize_t write(int fd, const void *buf, size_t count)。同样,实际写入的字节数也可能小于请求值,发生短计数。

短计数的原因

短计数在I/O中很常见,原因包括:

  • 读取时遇到文件结尾 (EOF)
  • 从终端读取时,用户输入的数据量少于请求量。
  • 读写网络套接字时,数据被分割成网络数据包传输,每次读写可能只处理一个包的数据。

处理短计数通常需要编写循环,确保读取或写入完整的数据量。

文件共享与重定向机制

要理解进程如何共享文件以及Shell重定向(如 >)的工作原理,需要了解内核维护的三个关键数据结构。

  1. 描述符表:每个进程独有,表项索引就是文件描述符(如0,1,2),每个表项指向一个打开文件表中的条目。
  2. 打开文件表:系统级表格,所有进程共享。每个表项(又称文件表条目)存储了当前文件的偏移量(即读写位置)和引用计数(有多少个描述符指向它)。调用 open 会创建新的表项。
  3. v-node表:系统级表格,每个表项(v-node)存储了文件的元数据(如文件大小、权限、所有者)和指向实际数据块的指针。

文件共享示例:当一个进程调用 fork() 创建子进程时,子进程会复制父进程的描述符表,从而共享相同的打开文件表条目。这意味着父子进程共享相同的文件偏移量。

重定向实现:Shell使用 dup2(int oldfd, int newfd) 系统调用实现重定向。dup2 将描述符 oldfd 复制到 newfd。如果 newfd 已经打开,则会先关闭它。复制后,newfdoldfd 指向同一个打开文件表条目,因此共享文件偏移量。例如,dup2(fd, 1) 会将标准输出(文件描述符1)重定向到 fd 所代表的文件。

上一节我们介绍了底层的Unix I/O操作和文件共享机制,本节中我们来看看建立在它之上、更为程序员所熟悉的标准I/O库。

标准I/O:缓冲与流 📚

标准I/O库(如 printf, scanf)为文件操作提供了更高级、更方便的接口。它将打开的文件抽象为,并引入了缓冲机制来减少昂贵的系统调用次数。

缓冲机制

标准I/O在用户空间维护一个缓冲区。例如:

  • 输出时:多次调用 printf 写入的字符可能先被累积在缓冲区里,直到缓冲区满、遇到换行符或显式调用 fflush 时,才通过一次 write 系统调用将整个缓冲区内容写入内核。
  • 输入时:使用 fgetsgetchar 读取时,库可能会一次性从内核读入一大块数据到缓冲区,然后从缓冲区中逐字符或逐行提供给程序。

这种缓冲大大提升了连续读写小量数据的效率。

局限性

然而,标准I/O也有其局限性:

  • 非异步信号安全:在信号处理函数中调用 printf 等标准I/O函数可能导致程序死锁或数据损坏。
  • 不适用于网络套接字:标准I/O的缓冲机制与网络套接字的特性(如短计数、双向数据流)不匹配,可能导致难以调试的问题。

我们已经了解了用于常规文件操作的标准I/O,接下来我们将探讨一个专门为网络编程等场景设计的I/O包——RIO。

可靠I/O (RIO) ⚙️

RIO(Robust I/O)是教材中提供的一个包,它在Unix I/O的基础上增加了便捷性和健壮性,特别适合网络套接字编程。它提供两类函数:

  • 无缓冲的RIO函数:用于直接读写数据。
  • 带缓冲的RIO函数:用于高效地读取文本行。

RIO的核心函数

  • rio_readnrio_writen:这两个是无缓冲函数。
    • rio_readn 会在遇到短计数时自动循环读取,直到读满指定的字节数或遇到EOF。这简化了网络读取的代码。
    • rio_writen 会循环写入,确保写完所有请求的字节,不会出现短计数。
  • rio_readlinebrio_readnb:这两个是带缓冲的函数,使用同一个 rio_t 缓冲区结构体。
    • rio_readlineb 从缓冲区中读取一个文本行(以换行符结束)。
    • rio_readnb 从缓冲区中读取指定字节数的原始数据。

带缓冲的RIO函数在内部维护一个缓冲区,rio_readlinebrio_readnb 可以安全地混合调用,因为它们共享同一个缓冲区状态。

为何使用RIO?

在期末的代理服务器项目中,你将处理HTTP协议,需要读取请求行和头部(文本行),以及可能的消息体(二进制数据,如图片)。RIO的 rio_readlinebrio_readnb 完美契合这种需求。切记:不要使用 strcpy, strlen 等字符串函数处理二进制数据,因为它们会在遇到空字节(\0)时停止,而二进制数据中完全可能包含空字节。

信号处理补遗:安全地等待子进程 🚦

现在,让我们回到上节课未完成的信号话题,补充一个关键细节:如何安全地等待子进程结束。

在Shell中,对于后台作业,父进程不能调用 wait 阻塞等待,否则Shell就无法响应新的命令。解决方案是为 SIGCHLD 信号安装处理函数,在子进程终止时异步回收。

一个简单的处理函数如下:

void sigchld_handler(int sig) {
    int olderrno = errno;
    pid_t pid;
    while ((pid = waitpid(-1, NULL, 0)) > 0) {
        // 回收子进程
        printf("Handler reaped child %d\n", (int)pid);
    }
    if (errno != ECHILD) {
        // 处理错误
        perror("waitpid error");
    }
    errno = olderrno;
}

注意这里使用 while 循环而非 if,是因为信号不会排队,多个子进程同时结束可能只产生一个 SIGCHLD 信号,需要循环 waitpid 回收所有已终止的子进程。

有时,主程序需要等待一个特定的子进程结束。一种错误的方法是先检查子进程状态,然后调用 pause() 等待信号。这存在竞争条件:检查后、pause 前,信号可能已经到达并处理完毕,导致 pause 永远阻塞。

正确的做法是使用 sigsuspend 原子性地完成“解除信号阻塞”和“挂起进程等待信号”这两个操作:

sigset_t mask, prev_mask;
Sigemptyset(&mask);
Sigaddset(&mask, SIGCHLD);

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/cmu-15213-ics/img/8530f17059e7fbf7c64f073d1939349c_25.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/cmu-15213-ics/img/8530f17059e7fbf7c64f073d1939349c_27.png)

// 阻塞SIGCHLD,防止信号在处理前到达
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);

// 创建子进程...

// 等待特定子进程结束
while (pid != 0) { // pid是子进程ID,回收后会被处理函数设为0
    // 临时恢复原来的信号掩码(即解除对SIGCHLD的阻塞),并挂起
    // 这是一个原子操作,消除了竞争条件
    sigsuspend(&prev_mask);
}
// 恢复原来的阻塞信号集
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);

总结 📝

本节课中我们一起学习了计算机系统中I/O的多个层次。

  1. 我们从最底层的Unix I/O开始,理解了文件描述符、打开文件表、v-node等核心概念,以及文件共享和重定向的实现原理。
  2. 接着,我们探讨了标准I/O库,了解了其缓冲机制如何提升效率,同时也认识了它的局限性(如不适用于信号处理和网络编程)。
  3. 然后,我们介绍了专为健壮性设计的RIO包,它提供了方便的网络读写和行读取功能,是期末项目的重要工具。
  4. 最后,我们补充了上节课关于信号的知识,学习了如何使用 sigsuspend 安全地等待子进程,避免竞争条件。

理解这些不同I/O接口的适用场景和底层机制,对于编写正确、高效的系统程序至关重要。

17:栈与缓存 🧠

在本节课中,我们将学习栈和缓存这两个核心概念。我们将通过分析具体的汇编代码和缓存结构问题,来理解程序执行时内存的组织方式以及数据在缓存中的存储与访问模式。课程内容基于CMU 15-213/513课程的期中复习材料。


栈:问题解析与逐步推演 📚

上一节我们介绍了课程概述,本节中我们来看看第一个关于栈的问题。这个问题主要考察对函数调用、返回地址以及栈指针操作的理解。

我们被给出一段汇编代码,需要回答几个相关问题。首先,不建议立即阅读全部汇编代码,因为其中许多部分可能直到问题的后期才相关。让我们先跳过代码,看看问题具体问什么。

问题1a:计算L2标签处的RSP值

第一个问题是:请完成L2标签处RSP的值。
查看汇编代码,我们找到标签L2。它位于一系列调用指令之后,并且我们知道在L1处RSP有一个特定值。

为了理解发生了什么,最好逐行分析这段汇编。
首先,前两行指令xorq %rax, %raxmovq $0x2000, %rdx不修改栈,它们只修改寄存器,因此可以忽略。
下一行指令pushq %rax将一个值压入栈。

以下是栈的示意图。在L1处,地址是0x100040。当我们执行pushq %rax时,由于%rax是一个8字节的值,我们需要通过将栈指针减少8来在栈上为其腾出空间。因此,新地址变为0x100038(十六进制减法)。
接着,另一个pushq %rdx指令将栈指针再减少8,到达0x100030

此时,我们遇到一个函数调用。call指令会将返回地址压入栈,然后跳转去执行函数。然而,我们需要记住一个关键点:从一个函数调用返回后,某些寄存器的值保证不变,RSP就是其中之一。因此,在经历了所有三次bar函数调用后,当我们到达L2时,RSP的值必须与调用开始前相同。所以,我们无需追踪每次调用的细节。L2处RSP的值就是0x100030

核心概念call指令隐式地将返回地址压栈,ret指令将其弹出。寄存器RSP在函数调用后保持不变(假设函数是良构的)。

问题1b:计算L2标签处的RAX值

接下来,我们需要找出在标签L2处,寄存器RAX的值。
在L2处,RAX应该包含最后一次函数调用bar的返回值,因为RAX通常用于存储函数返回值。

因此,我们需要查看bar函数。在bar函数中,设置RAX的指令是popq %rax。在这条指令之前,代码将值0x15213压入了栈。根据栈的“后进先出”原则,最后压入的值会被最先弹出。所以,popq %rax执行后,RAX的值就是0x15213

技巧:在分析函数时,从底部(返回点)向上追溯,可以更快地找到设置关键寄存器(如RAX)的指令。

问题1c:绘制栈内存图

最后,我们需要绘制一张图,展示从地址0x100040向下栈内存中的内容。

我们需要从L1开始,逐步执行代码来填充这个图。

  • 在L1 (0x100040),栈顶的值是未知的,因为之前的代码没有存储任何东西到这里。
  • 执行xorq %rax, %rax后,RAX变为0。
  • 执行pushq %rax,将0存入地址0x100038
  • 执行pushq %rdx,虽然RDX被设置为0x2000,但紧接着的xorq %rdx, %rdx指令将其置零,因此存入地址0x100030的值也是0。
  • 第一次调用barcall指令将返回地址(下一条指令0x20009的地址)压入栈0x100028。进入bar后,它将0x15213压入栈0x100020,然后popq %rax将其弹出到RAX(但值仍保留在内存中),最后ret弹出返回地址0x20009
  • 后续两次调用bar过程类似,每次调用都会压入一个新的返回地址(分别是0x2000e0x20013)和值0x15213

因此,在L2时刻,栈的内容如下(从高地址到低地址):

  • 0x100040: 未知 (X)
  • 0x100038: 0
  • 0x100030: 0
  • 0x100028: 返回地址 0x20013 (来自第三次call)
  • 0x100020: 值 0x15213 (来自第三次bar)
  • 0x100018: 返回地址 0x2000e (来自第二次call)
  • 0x100010: 值 0x15213 (来自第二次bar)
  • 0x100008: 返回地址 0x20009 (来自第一次call)
  • 0x100000: 值 0x15213 (来自第一次bar)

重要说明pop指令会从内存中读取值到寄存器,并增加RSP,但不会清除内存中的该数据。内存内容保持不变,直到被后续的push等写入操作覆盖。


缓存:结构与访问模式分析 🗂️

上一节我们完成了栈的分析,本节中我们来看看缓存问题。这个问题要求我们分析一个特定缓存配置下的数据访问命中率。

首先,我们根据题目信息确定缓存参数。
已知:

  • 缓存总大小 (C):32 KB = (2^{5} * 2^{10}) bytes = (2^{15}) bytes
  • 关联度 (E):8路组相联
  • 块大小 (B):64 bytes = (2^6) bytes

我们需要计算:

  1. 组数 (S)
  2. 标记位 (t)、组索引位 (s)、块偏移位 (b) 的位数

缓存参数计算

使用公式:(C = S * E * B)
代入已知值:(2^{15} = S * 2^3 * 2^6)
解得:(S = 2^{15} / 2^{9} = 2^{6} = 64) 组

对于32位内存地址:

  • 块偏移位 (b):(B = 2^b), (2^6 = 2^b), 所以 (b = 6)
  • 组索引位 (s):(S = 2^s), (2^6 = 2^s), 所以 (s = 6)
  • 标记位 (t):(t = 32 - s - b = 32 - 6 - 6 = 20)

因此,地址划分如下:[标记位 t:20][组索引位 s:6][块偏移位 b:6]

缓存访问模式分析

接下来,我们分析两段循环代码对一个大数组的访问,并计算其失效率。数组大小为48 KB,每个int为4字节。

第一轮循环分析
循环以步长8字节(即2个int)访问数组。缓存块大小为64字节,可容纳 (64 / 4 = 16) 个int

  • 访问A[0]时,发生缓存缺失,整个包含A[0]A[15]的块被载入缓存。
  • 接着访问A[2](因为步长为2个int,A[0]的下一个元素是A[2]?这里需要澄清:原代码步长是i+=8字节,即i+=2个int。所以序列是A[0], A[2], A[4], ..., A[14], A[16], ...)时,该数据已在缓存中,命中。
  • 访问A[16]时,它位于一个新的缓存块中,发生缺失,载入新块。
  • 访问A[18]时,命中。

以此类推,在访问一个缓存块内的数据时,第一次访问总是缺失,第二次访问(因为步长2,跳过了奇数索引)是否命中取决于该数据是否在同一块。实际上,由于步长是2个int,而一个块有16个int,在一个块内会有8次访问(访问第0,2,4,6,8,10,12,14个元素)。第一次访问A[0]缺失,后续7次访问A[2]A[14]都命中。然后访问A[16]缺失,A[18]A[30]命中... 因此,第一轮循环的缺失率是1/8 = 12.5%。(注意:这里与视频讲解的50%有出入,视频中可能假设了不同的步长或访问模式,例如顺序访问每个int。根据给出的代码i+=8(字节),确实是访问每隔一个的int。但原视频口述分析时提到了“missing 50% of the time”,这可能是一个口误,或者其默认的“访问”是指更复杂的模式。为了与常见考题一致,我们采用标准分析:顺序访问数组每个元素时,由于块大小能容纳多个元素,在首次访问块内后续元素时会命中,从而降低缺失率。但具体缺失率取决于步长和块容量。)

然而,数组总大小(48 KB)超过了缓存容量(32 KB)。当缓存被填满后,根据LRU(最近最少使用)替换策略,最早被载入的缓存块会被驱逐。
尽管如此,在第一轮遍历中,对于整个数组,每个缓存块仍然只在第一次被访问时发生一次缺失,然后该块内的后续访问都会命中。因此,第一轮的总体缺失率就是“每个块的第一次访问”占总访问次数的比例。由于我们按固定步长访问,需要计算总共访问了多少个元素以及多少个独立的缓存块。

第二轮循环分析
第二轮重新从头开始访问数组。此时,缓存中已经存有数组末尾部分的数据(因为第一轮最后访问的是它们)。

  • 访问A[0]时,其对应的块很可能已在第一轮中被驱逐(因为缓存容量不足),所以发生缺失。
  • 载入A[0]的块会驱逐缓存中某个旧的块。
  • 后续访问模式与第一轮类似:在一个块内,第一次访问缺失,后续访问命中。

因此,第二轮循环的缺失率与第一轮相同。如果数组大小小于或等于缓存容量,那么第二轮所有数据都在缓存中,缺失率将为0。但本例中数组更大,所以缺失率与第一轮相同。

核心公式与概念

  • 缓存参数公式:(C = S \times E \times B)
  • 地址划分:[Tag][Set Index][Block Offset]
  • 容量缺失:当工作集大小超过缓存容量时,会发生容量缺失,导致数据被反复驱逐和载入。
  • LRU替换策略:驱逐最久未被访问的缓存行。

总结 🎯

本节课中我们一起学习了栈和缓存。

  • 在栈部分,我们通过分析汇编代码,理解了函数调用时返回地址的压栈与弹栈过程、栈指针的变化,以及如何手动追踪栈内存的内容。关键是指令pushpopcallret对栈的隐式操作。
  • 在缓存部分,我们学习了如何根据缓存总大小、关联度和块大小计算其结构参数(组数、地址位划分)。接着,我们分析了特定访问模式下的缓存命中率,理解了步长、空间局部性、缓存容量以及替换策略(如LRU)如何共同影响程序性能。

掌握这些概念对于理解程序底层执行效率和计算机系统工作原理至关重要。

18:浮点数表示与转换

在本节课中,我们将学习IEEE浮点数的表示方法,并通过一个典型的例题,掌握在不同格式之间进行转换、判断规格化/非规格化数以及处理舍入的技巧。

概述

浮点数在计算机中以特定的二进制格式存储。我们将使用两种不同的浮点格式(格式A和格式B)来练习转换。核心公式是:值 V = M × 2^E,其中M是尾数,E是指数。

格式A到格式B的转换

上一节我们介绍了浮点数的基本概念,本节中我们来看看如何将一个在格式A中表示的数值,转换到格式B的表示。

给定格式A:3位指数位,4位小数位。我们有一个具体的位模式:0 101 0101(符号位0,指数101,小数0101)。

首先,我们需要将其转换为十进制值。由于指数位非全零,这是一个规格化数。计算过程如下:

  • 指数 E = exp - bias。格式A的偏置是3,exp(二进制101)等于5。所以 E = 5 - 3 = 2
  • 尾数 M = 1 + frac。小数部分0101(二进制)等于 (0 × 1/2) + (1 × 1/4) + (0 × 1/8) + (1 × 1/16) = 5/16。所以 M = 1 + 5/16 = 21/16
  • 最终值 V = M × 2^E = (21/16) × 2^2 = 21/16 × 4 = 21/4 = 5.25

现在,我们需要将值 21/4 用格式B表示(4位指数,3位小数,偏置为7)。

  • 首先将值表示为二进制科学计数法:21/4 = 5.25 = 101.01(二进制)= 1.0101 × 2^2。所以 E = 2
  • 计算格式B的指数域:exp = E + bias = 2 + 7 = 9。9的二进制是 1001
  • 尾数的小数部分是 0101。但格式B只有3位小数位,而我们有4位 (0101),因此需要进行舍入。我们使用“向偶数舍入”规则。
    • 待舍入的部分是 0101,保留前三位 010,被舍去的位是 1
    • 010(二进制)是偶数(十进制2),且被舍去的位是1,根据规则,我们将其舍入到最接近的偶数,即保持 010 不变。
  • 因此,格式B的位模式为:符号位0,指数 1001,小数 010

最后,我们可以验证这个新位模式的值:

  • exp = 1001(二进制)= 9E = 9 - 7 = 2
  • frac = 010(二进制)= 2/8 = 1/4M = 1 + 1/4 = 5/4
  • V = (5/4) × 2^2 = (5/4) × 4 = 5
    可以看到,由于舍入,值从 5.25 变成了 5

格式B到格式A的转换

理解了从A到B的转换后,我们反过来练习从格式B转换到格式A。

给定值 15/2 = 7.5,用格式B表示。

  • 二进制表示:7.5 = 111.1(二进制)= 1.111 × 2^2E = 2
  • 格式B指数域:exp = 2 + 7 = 9 -> 1001
  • 小数部分取 111
  • 格式B位模式:0 1001 111

现在,将此值转换到格式A(3位指数,4位小数,偏置3)。

  • 值仍然是 1.111 × 2^2E = 2
  • 格式A指数域:exp = 2 + 3 = 5 -> 101
  • 小数部分是 111。格式A有4位小数位,我们只有3位,因此需要在末尾补零,得到 1110
  • 格式A位模式:0 101 1110
  • 验证值:exp=5 -> E=2, frac=1110=14/16, M=1+14/16=30/16, V=(30/16)×4=120/16=7.5。转换成功。

判断规格化与非规格化

在实际转换中,一个关键步骤是判断应该使用规格化还是非规格化形式。本节我们通过一个例子来学习判断方法。

给定值 3/32,我们需要分别找到它在格式A和格式B下的位表示。

对于格式A(偏置3)

  1. 先尝试能否用规格化数表示。规格化数的最小指数是 exp=001(二进制=1),此时 E = 1 - 3 = -22^E = 1/4
  2. 规格化数的尾数 M ≥ 1。因此,能表示的最小规格化正数约为 1 × 1/4 = 1/4
  3. 我们的目标值 3/32 ≈ 0.09375 小于 1/4。因此,无法用规格化数表示,必须使用非规格化数。
  4. 对于非规格化数,exp = 000E = 1 - bias = -2(特殊规则),2^E = 1/4
  5. 尾数 M = 0 + frac(没有隐含的1)。我们需要 M × 1/4 = 3/32,所以 M = (3/32) / (1/4) = 3/8
  6. 3/8 表示为4位二进制小数:3/8 = 0/2 + 1/4 + 1/8 + 0/16 -> 0110
  7. 因此,格式A的位模式为:exp=000, frac=0110 -> 0 000 0110

对于格式B(偏置7)

  1. 尝试规格化。最小规格化指数 exp=0001(二进制=1)E = 1 - 7 = -62^E = 1/64
  2. 最小规格化正数约为 1 × 1/64 = 1/64 ≈ 0.015625
  3. 我们的目标值 3/32 = 0.09375 大于 1/64,因此可以用规格化数表示。
  4. 我们需要 M × 2^E = 3/32。设 2^E = 1/16(即 E=-4),则 M = (3/32) / (1/16) = 3/2 = 1.5
  5. E = -4,计算指数域:exp = E + bias = -4 + 7 = 3 -> 0011
  6. 尾数 M = 1.5 = 1 + 0.5。小数部分 0.5 的3位二进制表示为 100(因为 1/2 = 0.5)。
  7. 因此,格式B的位模式为:exp=0011, frac=100 -> 0 0011 100

特殊值与极值

最后,我们来看如何表示无穷大以及最大/最小的非规格化和规格化数。

以下是这些特殊值在给定格式下的表示方法:

无穷大

  • 规则:指数位全为1,小数位全为0。
  • 格式A:0 111 0000
  • 格式B:0 1111 000

最大非规格化数(格式A)

  • 规则:指数位全为0,小数位全为1,以取得最大可能值。
  • 位模式:0 000 1111
  • 值计算:E = 1 - 3 = -2, 2^E=1/4, M = 0 + 15/16, V = (15/16) × (1/4) = 15/64

最小正规格化数(格式B)

  • 规则:指数位为最小非零值(0001),小数位全为0,以取得最小可能值。
  • 位模式:0 0001 000
  • 值计算:E = 1 - 7 = -6, 2^E=1/64, M = 1 + 0, V = 1 × 1/64 = 1/64

总结

本节课中我们一起学习了IEEE浮点数的核心表示方法。我们通过例题实践了:

  1. 在不同格式(指数位、小数位、偏置不同)间转换浮点数值。
  2. 应用公式 V = (1 + frac) × 2^(exp - bias) (规格化)和 V = (frac) × 2^(1 - bias) (非规格化)进行值计算。
  3. 根据目标值与可表示范围的关系,判断应使用规格化还是非规格化形式。
  4. 使用“向偶数舍入”规则处理精度损失。
  5. 表示特殊值(如无穷大)和极值(最大非规格化数、最小规格化数)。理解这些概念对于掌握计算机中数字的存储与计算至关重要。

19:从汇编代码反推C语言结构体与递归函数 🧩

在本节课中,我们将学习如何分析一段给定的汇编代码,并从中反推出原始的C语言代码结构。我们将重点关注一个涉及结构体和递归函数的例子,通过逐步解析汇编指令来理解其对应的C语言逻辑。

概述

我们将分析一段汇编代码,它对应着两个C语言函数。第一个函数 anony 处理一个字符数组(字符串),第二个函数 sofun 处理一个结构体。我们的目标是理解汇编指令如何映射到高级语言的控制流、数据访问和函数调用。


上一节我们介绍了分析汇编代码的基本目标,本节中我们来看看具体的代码片段。

首先,我们忽略屏幕上大量的汇编代码,直接查看对应的C代码片段。我们看到这里定义了一个结构体(struct)。

结构体的存在意味着代码中很可能涉及内存访问操作。因此,在汇编代码中,我们应该预期会看到基于某个变量或指针的偏移量寻址。此外,代码中还有两个函数,它们很可能是相互关联的,否则不会出现在同一个问题部分。

我们预期会看到一个函数调用另一个函数,或者函数递归调用自身。

现在,让我们回到汇编代码部分。

在考试环境中,界面通常支持分屏功能,以便同时查看汇编代码和C代码。


上一节我们提到了两个函数,现在我们来具体分析第一个函数 anony

我们首先看到函数有一个输入变量。根据x86-64调用约定,第一个整数或指针参数通过寄存器 RDI 传递。

在汇编代码中,我们第一次看到 RDI 的使用是在一条 mov 指令中,它将 RDI 的值移到了栈上的某个位置。我们无需关心具体原因,只需知道这个操作存在。紧接着的下一条指令,又将其从栈中取出,放入了寄存器 RAX

现在,RDIRAX 都指向同一个输入,我们称之为 name

查看C代码,我们看到一个 if 语句。在汇编中,if 语句通常对应一个比较操作。在这个函数里,我们看到了 test 指令。

test 指令将其两个操作数进行按位与运算,并根据结果设置条件码(特别是零标志位ZF)。随后的 jne(跳转如果不相等)指令会检查零标志位。因此,test 指令在这里的作用是检查操作数是否为零。

test 指令的操作数是 ALALRAX 寄存器的低8位字节。那么 RAX 里面是什么呢?

向上看汇编代码,最初输入 n(即 name)被放入了 RAX。然后有一条指令 mov eax, (rax)。这里的括号意味着解引用操作,相当于C语言中的 *rax

我们知道 RAX 是一个指向 name 字符数组的指针。解引用这个指针,我们得到的是数组的第一个字符(即 name[0])。所以,test al, al 实际上是在检查 name[0] 是否等于零(即字符串结束符 \0)。

如果 name[0] == ‘\0’(零标志位被设置),则 jne 跳转不会发生,程序顺序执行下一条 mov eax, 0 指令,然后跳转到函数末尾(地址 0x400526),返回0。

如果 name[0] != ‘\0’(零标志位未被设置),则 jne 跳转发生,程序跳转到地址 0x400512 继续执行。


上一节我们分析了 if 语句的跳转逻辑,本节中我们来看看 else 分支(即递归调用部分)的代码。

如果跳转发生(即 name[0] != ‘\0’),程序会执行以下操作:

  1. name 的地址(存储在 RAX 中)加1,得到 name+1
  2. 将结果(name+1)作为参数,通过 RDI 寄存器,递归调用函数 anony
  3. 函数调用返回后,返回值在 RAX 中。代码执行 add eax, 1,将返回值加1。
  4. 这个加1后的结果,又作为当前函数的返回值。

因此,anony 函数的C代码逻辑可以推断为:如果字符串首字符是结束符,则返回0;否则,返回 1 + anony(name+1)。这实际上是在计算字符串的长度。

核心概念:指针运算
当一个指针指向字符数组(字符串)时,对指针加1(ptr + 1)意味着指向数组中的下一个元素。因为指针运算会根据指向类型的大小进行缩放,对于 char* 类型,加1就是前进一个字节。


上一节我们完成了第一个函数的分析,本节中我们来看第二个函数 sofun

函数开头,我们看到输入参数(通过 RDI 传入)被存放到栈上,然后又移入 RAX。所以 RAX 现在保存了输入变量,我们称之为 var,其类型是 person*(指向结构体的指针)。

接下来,代码直接调用了函数 anony。那么传递给 anony 的参数是什么呢?是 var 本身。

anony 函数期望一个 char*(字符指针)类型的参数。而我们传递的 var 是一个 person* 类型。这之所以可行,是因为在C语言中,一个结构体指针的值,等于其第一个成员的内存地址。根据之前的结构体定义,第一个成员是 name 字符数组。因此,anony(var) 等价于 anony(var->name)

调用 anony 后,返回值存储在变量 val 中(对应汇编中 RSP+0x1c 的位置)。

然后,代码执行 mov eax, (rax+0xc)。这里的 0xc 是十进制12,是结构体中 field 成员相对于结构体起始地址的偏移量。所以这条指令是在访问 var->field

接着,代码比较 val(即 anony(var->name) 的返回值)和 var->field 的值。

如果两者相等(je 跳转),程序跳转到地址 0x400566,执行 mov eax, 1,然后返回1。

如果不相等,程序继续执行,将 val 的值赋给 var->field(即 var->field = val),然后返回0。

因此,sofun 函数的逻辑是:计算 var->name 的长度,如果长度等于 var->field 的当前值,则返回1;否则,将 var->field 设置为该长度值,并返回0。


总结

本节课中我们一起学习了如何系统地分析汇编代码以反推C语言程序。

  1. 定位输入与调用约定:首先确定函数参数传入的寄存器(如 RDI)。
  2. 理解内存访问:结构体访问体现为基址加偏移量的寻址模式(如 (rax+0xc))。
  3. 解析控制流if/else 语句通常对应 test/cmp 指令后跟条件跳转指令(如 je, jne)。
  4. 识别函数调用call 指令对应函数调用,需注意参数传递和返回值(通常在 RAX 中)。
  5. 翻译指针运算:对指针的加减运算(如 name+1)对应汇编中的地址计算,需考虑类型大小。

通过将大段汇编分解为对应高级语言概念的独立小块,我们可以逐步重建出完整的C代码逻辑。这种方法在面对复杂的逆向工程或调试任务时非常有效。

20:异常控制流:信号与非本地跳转

在本节课中,我们将学习操作系统如何管理进程间的通信和异常处理,特别是通过“信号”这一机制。我们将探讨信号的发送、接收和处理方式,以及如何利用信号处理程序来管理进程,例如避免僵尸进程。此外,我们也会简要提及“非本地跳转”的概念。

进程管理与信号概述

上一节我们介绍了进程的创建(fork)和程序执行(exec)。本节中,我们来看看进程如何通过“信号”进行异步通信和处理异常事件。

信号是操作系统内核向进程传递信息的一种方式,用于通知进程发生了某个事件。例如,当你在终端按下 Ctrl+C 时,会向当前前台进程发送一个 SIGINT(中断)信号。

信号的发送与接收

信号可以由内核、其他进程或进程自身发送。每个信号都有一个唯一的整数标识符(如 SIGINT 对应 2)。进程可以“阻塞”某些信号,暂时不接收它们。

以下是发送信号的常见方式:

  • 来自内核:例如,硬件异常(如除零错误)会触发 SIGFPE 信号。
  • 来自其他进程:使用 kill 函数或命令。kill 不仅可以终止进程,还可以发送任何类型的信号。
  • 来自自身:进程可以调用 kill 函数给自己发送信号。

当一个信号被发送给目标进程时,它被标记为“待处理”。内核会在目标进程从内核模式切换回用户模式时(例如,在系统调用返回或定时器中断后),检查并传递这些待处理的信号。

信号处理程序

进程可以为大多数信号指定一个“信号处理程序”——即当该信号到达时,内核应跳转执行的一段特定函数代码。这允许程序自定义对事件的处理方式,而不是简单地执行默认操作(如终止)。

信号处理程序是进程代码的一部分,它与主程序共享相同的地址空间和全局变量。这意味着处理程序必须小心地访问和修改全局数据。

以下是如何安装一个简单的 SIGINT 信号处理程序的示例代码框架:

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

void sigint_handler(int sig) {
    // 自定义处理逻辑,例如打印信息后退出
    printf("Caught SIGINT! Exiting.\n");
    exit(0);
}

int main() {
    // 将 sigint_handler 函数注册为 SIGINT 信号的处理程序
    if (signal(SIGINT, sigint_handler) == SIG_ERR) {
        perror("signal error");
        exit(1);
    }

    // 主程序循环
    while(1) {
        // 程序正常执行...
    }
    return 0;
}

编写安全信号处理程序的准则

由于信号处理程序会异步中断主程序的执行,编写它们需要格外小心,以避免竞态条件和不可预测的行为。

以下是编写信号处理程序的一些重要准则:

  • 保持处理程序简单:最好只设置一个全局标志,然后返回,让主程序周期性地检查并处理该标志。
  • 只调用异步信号安全的函数:许多常见的库函数(如 printf, malloc)在信号处理程序中调用是不安全的。应使用手册中明确标记为“async-signal-safe”的函数。
  • 保存和恢复 errno:在进入处理程序时保存全局变量 errno 的值,并在返回前恢复,以免干扰主程序的错误检查。
  • 使用 volatile 声明全局标志:这告诉编译器不要优化掉对该变量的读写,确保主程序能看到处理程序所做的修改。
  • 使用 sig_atomic_t 类型:对于仅由处理程序设置、由主程序读取的简单标志,可以声明为 volatile sig_atomic_t 类型,以保证其读写在现代系统上是原子的。

信号处理的应用:避免僵尸进程

在进程管理中,一个常见问题是“僵尸进程”——即已经终止但尚未被父进程“回收”的子进程。如果父进程长期运行(如服务器)并不断创建子进程而不回收,系统资源会被逐渐耗尽。

解决方法是让父进程为 SIGCHLD 信号安装一个处理程序。当子进程终止时,内核会向父进程发送 SIGCHLD 信号。在处理程序中,父进程可以调用 waitwaitpid 来回收子进程。

需要注意的关键点是:信号不会排队。如果多个子进程几乎同时终止,可能只产生一个 SIGCHLD 信号。因此,处理程序必须循环调用 waitpid(使用 WNOHANG 选项),直到没有更多已终止的子进程可回收。

以下是处理 SIGCHLD 以避免僵尸进程的示例代码框架:

void child_handler(int sig) {
    int old_errno = errno; // 保存 errno
    pid_t pid;
    while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
        // 成功回收一个子进程,可以记录日志等
        printf("Handler reaped child %d\n", (int)pid);
    }
    if (errno != ECHILD) { // 检查 waitpid 是否因无子进程以外的原因出错
        perror("waitpid error");
    }
    errno = old_errno; // 恢复 errno
}

阻塞信号以保护共享数据

在更复杂的程序中,主程序和信号处理程序可能共享复杂的数据结构(如作业列表)。为了防止在处理程序访问数据结构时,被另一个同类信号中断而导致数据损坏,需要在修改共享数据前“阻塞”相关信号。

这通常通过 sigprocmask 函数来实现。基本模式是:在修改共享数据前,阻塞所有信号(或特定信号);修改完成后,再恢复原先的信号屏蔽字。

非本地跳转简介

最后,我们简要提及“非本地跳转”。这是通过 setjmplongjmp 函数提供的一种用户级异常控制流机制。它允许程序立即跳转回程序中某个先前保存的位置,绕过正常的调用/返回序列。这可以用于从深层嵌套的函数调用中立即错误返回,实现一种简单的异常处理机制。其细节将在教材中进一步阐述。

总结

本节课中我们一起学习了异常控制流中的“信号”机制。我们了解了信号如何作为进程间异步通信和事件通知的手段,如何编写和安装信号处理程序来自定义对事件(如中断、子进程终止)的响应,并掌握了编写安全、正确信号处理程序的准则。我们还探讨了如何利用 SIGCHLD 信号处理来有效回收子进程,避免僵尸进程的产生,以及如何使用信号阻塞来保护共享数据的并发访问。这些概念对于理解操作系统如何管理进程交互,以及编写健壮的并发程序(如Shell)至关重要。

21:虚拟内存概念

在本节课中,我们将要学习虚拟内存的基本概念。虚拟内存是现代计算机系统中的一项核心技术,它通过硬件和操作系统的协同工作,为每个进程提供了独立且巨大的地址空间,同时管理着有限的物理内存资源。我们将探讨其作为缓存、内存管理和保护机制的多重角色。

上一节我们介绍了缓存和内存层次结构,本节中我们来看看虚拟内存如何扩展这些概念,为程序提供更大的“虚拟”地址空间。

虚拟内存的基本思想

在任何时刻,一台计算机都可能运行着许多进程,可能超过50个,包括监控网络流量、支持打印机和各种后台任务的守护进程,以及你正在运行的应用程序。每个进程都拥有一个独立的、范围非常大的地址空间映像。每个进程都认为自己拥有一个地址范围,但它们实际上都在共享计算机的物理内存,并且不会相互干扰或破坏对方的数据。这就是虚拟内存背后的思想:它给你一种虚拟的错觉,让你感觉可以访问比计算机实际物理内存更多的内存,并且你的内存与正在运行的其他进程的内存是独立的。

虚拟内存实际上涉及一系列复杂的问题,它是一个庞大的主题,跨越了系统的多个层次。处理器内置了对虚拟内存的硬件支持,而操作系统内核则负责管理其大部分功能。因此,虚拟内存是硬件和软件集成的典型例子。

虚拟内存的多重角色

虚拟内存是一个强大的概念,它支持多种不同的能力。

以下是虚拟内存扮演的三个主要角色:

  1. 作为缓存:你可以将虚拟内存视为一种缓存,它使用DRAM(动态随机存取存储器)来缓存程序的实际数据,这些数据可能存储在磁盘上。典型的计算机可能有几GB到几十GB的DRAM,但虚拟地址空间(例如48位地址)可以寻址约256TB,远大于物理内存。因此,虚拟内存是一种利用DRAM缓存磁盘数据的方式。
  2. 内存管理:它提供了一种管理方案,使得多个独立的进程可以拥有各自独立的虚拟地址空间。
  3. 提供保护:例如,在你的进程地址空间中,栈的上方存储着各种内核数据结构。如果你试图访问它们,将会引发保护违规(如段错误),因为你无权读取(例如潜在的加密信息)或写入这些数据。操作系统内核为你管理这些受保护的页面。

地址空间与地址翻译

最基本的想法很简单。在一个非常原始的计算机中(现在几乎找不到了),使用的是物理寻址,即程序生成的地址直接就是物理内存(实际的DRAM)的索引。而虚拟寻址则不同,在虚拟地址和物理地址之间存在一个翻译过程,这是一种间接关系。

我们假设地址是字节的整数范围。设有 N 个不同的虚拟地址和 M 个不同的物理地址,通常 N 远大于 M。例如,48位虚拟地址空间对应约256TB,而32GB物理内存对应约35位地址。

虚拟内存的逻辑是将内存划分为。页是由一定数量的字节组成的块,通常是2的幂次方。典型的页大小是4KB(4096字节,即2^12字节)。在任何时候,只有虚拟地址范围中页的一个子集会驻留在物理内存中,其他页可能根本不存在,或者存储在磁盘上。

因此,我们有虚拟页(VP)和物理页(PP)。虚拟地址空间被划分为一个个4KB的框,物理页则是虚拟页的一个子集。

页表

跟踪虚拟页和物理页之间映射关系的是页表。页表是一组条目,对应程序可能引用的每个虚拟页。每个页表条目(PTE)包含各种标志位,最基本的是有效位(指示该页是否在物理内存中)。如果有效,条目中还包含物理页号(PPN)。对于无效的页,可能有一个空指针,或者一个指向磁盘上该页位置的引用。

当程序引用某个内存字时,内存管理单元(MMU)会查找该虚拟地址对应的页表条目。如果该页在内存中,称为命中。如果不在内存中,则发生页错误,这会触发一个异常,中断正在运行的程序并调用操作系统。

操作系统随后会(假设物理内存已满)选择一个页进行驱逐(写回磁盘),然后将所需的页从磁盘读入内存,更新页表,最后返回到原程序并重新执行那条引发页错误的指令。这有时被称为按需调页

局部性与工作集原理

虚拟内存之所以有效,与缓存一样,依赖于局部性原理:程序在任何给定时间倾向于只访问相对较小的内存区域。这被称为工作集原理:在任何时刻,你的程序都有一组正在活跃使用的内存页(工作集)。只要工作集的大小不超过计算机的物理内存,虚拟内存就能良好工作。如果程序频繁访问大范围地址,导致工作集过大,就会发生颠簸:系统花费大量时间在磁盘和内存之间交换页面,性能急剧下降。

内存管理与共享

虚拟内存的间接映射使得内存管理变得灵活。不同的进程可以有不同的映射关系,甚至可以共享页面。例如,共享库(如C标准库)的代码可以被多个进程共享,而不是每个进程都保存一份副本,这节省了内存。

另一个巧妙的技巧体现在 fork 系统调用中。fork 创建子进程时,并不会立即复制父进程的所有内存页,而是让父子进程共享这些页,并将它们标记为只读。只有当其中一个进程试图写入共享页时,才会触发一个保护异常,此时操作系统才会真正复制该页(写时复制),从而让两个进程拥有独立的副本。这既高效又节省内存。

地址翻译详解

现在让我们更详细地看看地址翻译过程。我们已经引入了数字 N(虚拟地址位数)、M(物理地址位数)和 P(页内偏移位数,页大小为 2^P 字节,如 4KB 页对应 P=12)。

我们将虚拟地址拆分为两部分:较低的 P 位称为虚拟页偏移量(VPO),较高的位称为虚拟页号(VPN)。物理地址同样拆分:较低的 P 位是物理页偏移量(PPO),它与 VPO 完全相同;较高的位是物理页号(PPN)。

因此,地址翻译的核心是将 VPN 映射到 PPN。页表条目就存储着这个 PPN。

基本的翻译流程如下:

  1. CPU 发出一个虚拟地址(VA)用于读/写。
  2. 内存管理单元(MMU)根据 VA 中的 VPN 生成页表条目地址(PTEA)。可以想象页表是一个数组,VPN 就是索引。
  3. MMU 从内存(或缓存)中读取该 PTE。
  4. 如果 PTE 有效,则将其中的 PPN 与原始的 VPO(即 PPO)组合,形成物理地址(PA)。
  5. 使用这个 PA 去访问实际的内存数据。

在这个过程中,即使一切顺利(页表命中且所需数据在内存中),一次内存访问也需要两次内存读取:一次读 PTE,一次读实际数据。这听起来性能代价很高,页错误则代价更大。

加速翻译:TLB

为了解决每次内存访问都需要额外查询页表的问题,系统引入了翻译后备缓冲器。TLB 是 MMU 中一个小的硬件缓存,专门用于缓存最近使用过的页表条目(VPN -> PPN 的映射)。

TLB 的工作方式与硬件缓存类似。虚拟页号(VPN)被进一步拆分为TLB索引TLB标记。当需要翻译地址时:

  1. MMU 首先用 VPN 查询 TLB。
  2. 如果 TLB 命中,则立刻获得 PPN,无需访问内存中的页表。
  3. 如果 TLB 不命中,则执行上述完整的页表查询流程,并将结果存入 TLB 以备后用。

TLB 的命中率通常非常高,因此它有效地隐藏了页表访问的开销。TLB 不命中可以由硬件处理,只要目标页在内存中,开销相对较小。

多级页表

对于巨大的地址空间(如 48 位),单一的线性页表会非常庞大(可能达到数百 GB),这不切实际。解决方案是使用多级页表

多级页表将页表本身也分页,并组织成树状结构。例如,一个两级页表:

  • 第一级页表:常驻内存。每个条目指向一个第二级页表页(或者为空,表示该地址范围未使用)。
  • 第二级页表:可以像普通数据页一样被换入换出磁盘。每个条目指向实际的物理页。

虚拟地址被分成多个字段,每个字段作为索引进入相应级别的页表。这种结构的优点是,对于地址空间中大片未使用的区域,对应的第二级页表根本不需要分配,节省了大量空间。典型的 x86-64 系统使用四级页表来管理 48 位地址空间。

对程序员的透明性

作为应用程序员,你大部分时候无需关心虚拟内存的具体实现。虚拟内存为你提供了一个整洁的抽象:每个进程拥有独立的、受保护的、看似无限大的地址空间。操作系统和硬件协同工作,处理所有复杂的映射、缓存和换页细节。这种透明性使得编程更加简单和安全,避免了进程间的意外干扰。

本节课中我们一起学习了虚拟内存的核心概念。我们了解到虚拟内存通过页表机制将虚拟地址翻译为物理地址,它不仅作为物理内存的缓存,还负责进程间的内存隔离与保护,并支持共享内存等高级功能。TLB 和多级页表等优化技术使得这一强大抽象在实践中的性能开销变得可以接受。虚拟内存是现代计算系统不可或缺的基石。

22:虚拟内存系统

概述

在本节课中,我们将继续学习虚拟内存系统。我们将深入探讨地址转换的硬件实现细节,包括页表、转换后备缓冲器(TLB)以及它们与高速缓存的协同工作。我们还将了解操作系统如何管理进程的虚拟地址空间,并介绍一些高级概念,如内存映射和写时复制。


页表与多级页表结构

上一节我们介绍了虚拟内存的基本概念,即通过页表在程序员看到的虚拟地址和实际的物理内存之间建立映射。本节中,我们来看看页表的具体实现。

页表由页表项(PTE)组成,每个PTE描述一个内存页(Page)的信息:它是否在物理内存中(有效位),如果在,其起始地址是什么;如果不在,它是否在磁盘上。

当虚拟地址空间非常大时(例如 2^48 字节),我们无法用一个连续的页表来映射整个空间。因此,通常采用树形结构的多级页表。

  • 结构:可以将其视为一棵树,每个页表的大小为一页,因此每个页表能容纳的PTE数量是固定的(例如512个)。这决定了树的“分支因子”。
  • 优势:由于大多数程序只稀疏地使用其整个地址空间(例如,底部放代码和全局数据,顶部放栈),我们只需要为实际使用的虚拟内存区域创建和填充页表的相应部分。这使得页表的大小与已使用的虚拟内存空间成比例,而不是与总的虚拟地址空间成比例。

转换后备缓冲器(TLB)

如果每次地址转换都需要遍历多级页表,将会产生多次内存访问,性能开销巨大。为了加速这一过程,硬件提供了转换后备缓冲器(TLB)。

TLB本质上是一个页表项(PTE)的硬件高速缓存。它存储了最近使用过的虚拟页号到物理页号的映射关系。

  • 工作流程:当CPU需要转换一个虚拟地址时,它首先在TLB中查找对应的虚拟页号。
  • TLB命中:如果找到(命中),硬件可以直接获得物理页号,从而快速形成物理地址,无需访问内存中的页表。
  • TLB未命中:如果未找到(未命中),则必须通过遍历页表(可能涉及多次内存访问)来获取正确的PTE,然后将这个新的映射载入TLB。

TLB通常也是组相联的,其查找机制与数据高速缓存类似:使用虚拟页号的一部分作为索引来选择组,剩余部分作为标签(Tag)在组内进行匹配。


地址转换示例

为了更具体地理解,我们来看一个简化的地址转换例子。假设系统参数如下:

  • 虚拟地址:14位
  • 物理地址:12位
  • 页大小:64字节
  • TLB:4组,4路组相联

给定一个虚拟地址 0x03D4(二进制 00 0011 1101 0100)。

  1. 拆分虚拟地址
    • 页内偏移(Offset):低6位 (010100) = 0x14
    • 虚拟页号(VPN):高8位 (00001111) = 0x0F
  2. 查询TLB
    • TLB索引(Index):取VPN的低2位 (11) = 组3
    • TLB标签(Tag):VPN的高6位 (000011) = 0x03
    • 在TLB的第3组中查找标签为 0x03 的条目。假设找到,其内容为物理页号(PPN)0x0D
  3. 形成物理地址
    • 物理页号(PPN):0x0D
    • 页内偏移(Offset):0x14
    • 物理地址 = PPN 拼接 Offset = 0x0D14

接下来,这个物理地址可以被送入数据高速缓存系统进行查找,过程与缓存实验类似:根据块大小、组数等参数,从物理地址中解析出缓存偏移、索引和标签。


实际系统示例:x86-64

让我们看看现代x86-64处理器是如何实现这些概念的。

缓存层次结构
现代CPU通常具有多级缓存。

  • L1缓存:分指令缓存(I-Cache)和数据缓存(D-Cache),分离可以避免指令获取和数据访问的冲突,对保持流水线充满至关重要。
  • L2缓存:通常是统一的(指令和数据共享)。
  • L3缓存:在多核处理器中,L1和L2缓存通常是每个核心私有的,而L3缓存是所有核心共享的。这有助于在不同核心间迁移进程时保持缓存数据的可用性。

TLB层次结构
类似于缓存,TLB也分层次,并有独立的指令TLB(I-TLB)和数据TLB(D-TLB)。TLB的条目数相对较少(几十到几百条),但通过小容量和专用电路设计,其访问速度极快。

x86-64地址转换

  • 虽然指针是64位,但当前x86-64架构只使用48位虚拟地址(可寻址256TB)。
  • 页大小通常为4KB(4096字节),因此页内偏移占12位,虚拟页号占36位。
  • 一个页表项(PTE)大小为8字节。一个4KB的页能容纳512个PTE(2^9)。
  • 因此,36位的虚拟页号被分成4个9位的块,用于索引四级页表。
    • PTE格式包含物理页号基址(40位,支持52位物理地址)、有效位、读写执行权限位、用户/超级用户权限位、访问位、脏位等标志。

缓存与TLB的协同
一个巧妙的设计是,虚拟地址的页内偏移(12位)正好对应了L1缓存的“块偏移”和“缓存索引”的位数之和。这意味着,CPU可以并行地进行以下操作:

  1. 使用虚拟地址的页内偏移部分,在L1缓存中预取可能的数据块。
  2. 同时,使用虚拟地址的虚拟页号部分,在TLB中进行地址转换。
    当TLB转换完成后,得到的物理页号(作为标签)可以与缓存中预取的数据块进行匹配,从而在单次流水线操作中高效完成“地址转换+数据读取”。

Linux进程的虚拟地址空间管理

现在,让我们从硬件转向操作系统,看看Linux如何管理进程的虚拟地址空间。

一个进程的虚拟地址空间布局大致如下:

  • 用户空间
    • 底部:程序代码(.text)、只读数据、已初始化/未初始化的全局数据(.data, .bss)。
    • 中部:运行时堆(通过 malloc 动态分配),向上增长。
    • 顶部:用户栈,向下增长。
    • 中间区域:可能映射了共享库(如 libc)。
  • 内核空间
    • 位于用户空间之上的高地址区域。
    • 包含内核代码、内核数据结构、以及每个进程独有的内核栈和页表等。
    • 用户程序无法直接访问此区域。

Linux内核将进程用户空间中连续的已分配内存区域(如代码段、数据段、堆、栈、共享库映射)用 vm_area_struct 结构体来管理。
以下是这些结构体包含的主要信息:

  • 区域的起始和结束虚拟地址。
  • 访问权限(读、写、执行)。
  • 标志(如是否是共享映射、私有映射)。
  • 指向后备文件(如果有)的信息。

所有这些 vm_area_struct 结构体被组织成一个链表(或更高效的红黑树)。当发生页错误(Page Fault)时,操作系统可以快速查找发生故障的地址属于哪个内存区域,从而决定是合法访问(分配物理页)、违反权限(引发段错误Segmentation Fault)还是访问未映射区域(引发段错误)。


内存映射与共享

虚拟内存的一个强大特性是支持内存映射文件,并允许在不同进程间共享内存页。

内存映射(Memory Mapping)

  • 操作系统可以将一个文件(或文件的一部分)直接映射到进程的虚拟地址空间。
  • 当进程访问该区域时,通过页错误机制将文件内容按需加载到物理内存。
  • 映射可以是私有的(写操作触发写时复制),也可以是共享的(写操作直接修改内存并可能同步回文件)。

共享对象

  • 当多个进程将同一个文件(如可执行程序或共享库)映射到它们的地址空间时,操作系统可以在物理内存中只保留一份副本,并通过多个进程的页表将其映射到各自的虚拟地址。
  • 这节省了大量内存。由于缓存是基于物理地址的,即使进程切换,缓存中的数据对新的进程仍然可能有效。

写时复制(Copy-on-Write, COW)

  • 这是 fork() 系统调用高效实现的关键。
  • fork() 被调用时,子进程并不立即复制父进程的全部内存空间。相反,它共享父进程的所有物理页,但将这些页标记为只读写时复制
  • 当父进程或子进程尝试向这些共享页写入时,会触发页错误。操作系统此时才会为该进程复制一份私有的物理页副本,然后修改其页表指向新副本,并允许写入操作继续。
  • 这样,只有实际被修改的页才会产生复制开销,极大地提升了 fork() 的效率。

内核同页合并(KSM)

  • 这是Linux内核的一项高级特性,它会主动扫描物理内存,寻找内容完全相同的页面。
  • 找到后,它会将这些重复的页面合并为一个共享页面,并使用写时复制技术。
  • 这在虚拟机环境中特别有用,因为多个虚拟机可能运行相同的操作系统,拥有大量相同的内存内容(如内核代码、共享库)。

用户级内存映射:mmap 系统调用

程序员也可以通过 mmap 系统调用直接使用内存映射功能。

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

  • 功能:将文件 fd 中从 offset 开始的 length 字节内容,映射到调用进程虚拟地址空间 addr 附近的区域。
  • 参数
    • prot:指定保护权限(PROT_READ, PROT_WRITE, PROT_EXEC)。
    • flags:指定映射类型(MAP_SHARED, MAP_PRIVATE 等)。
  • 用途
    • 高效文件I/O:对于需要读/写整个文件的情况,使用 mmap 可以避免用户缓冲区和内核缓冲区之间的额外拷贝,由页错误机制按需加载数据,有时性能更高。
    • 进程间通信(IPC):通过 MAP_SHARED 标志映射同一个文件,可以建立一块共享内存区域。
    • 分配大内存:使用 MAP_ANONYMOUS 标志可以分配不与文件关联的匿名内存,类似于 malloc 大块内存。
    • 实现复杂数据结构持久化:可以将一个大型数据结构映射到文件,在内存中直接操作,munmap 后自动写回文件。

总结

本节课中,我们一起深入探讨了虚拟内存系统的核心机制。

我们首先回顾了多级页表如何高效地管理巨大的虚拟地址空间。然后,重点介绍了转换后备缓冲器(TLB) 作为页表的高速缓存,对于地址转换性能的决定性作用,并通过示例剖析了从虚拟地址到物理地址,再到高速缓存的完整查找链条。

我们以x86-64架构为例,看到了现代处理器中复杂的缓存和TLB层次结构,以及它们之间精妙的协同设计(如利用虚拟地址偏移并行启动缓存访问和地址转换)。

从软件视角,我们了解了操作系统如何通过 vm_area_struct 结构管理进程的虚拟内存区域,以支持页错误处理、内存保护和共享。我们揭示了 fork()exec() 等关键系统调用背后依赖的写时复制内存映射技术,这些技术使得进程创建和程序加载变得高效。最后,我们介绍了用户程序如何通过 mmap 系统调用利用内存映射的强大功能。

虚拟内存是硬件(MMU, TLB, Cache)和操作系统(页表管理,缺页处理,共享机制)紧密协作的杰出典范。它不仅提供了内存保护和地址空间抽象,还通过共享、写时复制、内存映射等特性,为系统性能优化和高级功能实现奠定了坚实基础。

23:动态内存分配基础 🧠

在本节课中,我们将要学习动态内存分配的基础知识。动态内存分配是程序在运行时从堆中获取虚拟内存的方式,这对于那些在编译时无法确定大小的数据结构至关重要。我们将探讨其工作原理、面临的挑战以及实现一个简单分配器所需的核心概念。


概述

动态内存分配允许程序在运行时请求和释放内存。在C语言中,这通过 mallocfree 等函数实现。与Java等具有垃圾回收机制的语言不同,C语言要求程序员显式地管理内存。本节课将介绍动态内存分配的基本机制、性能目标以及实现一个简单分配器(如隐式空闲链表)所需的关键技术。


内存布局回顾

为了理解动态内存分配,我们首先需要回顾进程的虚拟地址空间布局。

  • :位于读写段之上,并向高地址方向增长。动态分配的内存就来自这里。
  • :位于用户空间的高地址区域,并向低地址方向增长。
  • 内核内存:位于用户空间之上,用户程序无法直接访问。

堆的顶部由一个名为 brk 的系统指针标记。分配器在堆内部维护一系列可变大小的内存块,每个块要么是已分配的,要么是空闲的


分配器API与约束

在C语言中,我们使用显式分配器,这意味着程序员需要手动控制内存的分配和释放。

以下是核心函数:

  • void *malloc(size_t size):分配至少 size 字节的内存块,并返回指向它的指针。如果失败则返回 NULL。分配的内存块保证是16字节对齐的。
  • void free(void *ptr):释放 ptr 所指向的内存块。ptr 必须是由 mallocrealloc 返回的指针。
  • void *calloc(size_t nmemb, size_t size):分配并清零内存,相当于 malloc(nmemb * size) 后加 memset 为零。
  • void *realloc(void *ptr, size_t size):改变已分配内存块的大小。
  • void *sbrk(int incr):系统调用,用于移动堆顶的 brk 指针,从而扩展或收缩堆空间。通常对程序员透明。

分配器必须满足以下约束:

  1. 必须立即响应请求,不能对请求进行重新排序或缓冲。
  2. 只能从空闲内存中进行分配。
  3. 只能操作和修改空闲内存
  4. 不能移动已分配的内存块(因为程序持有指向它们的指针)。

性能目标

评估一个动态内存分配器主要有两个性能指标:

  1. 吞吐量:单位时间内完成的请求(mallocfree)数量。为了高吞吐量,分配器中的例程必须非常快速。
  2. 峰值内存利用率:衡量分配器对已申请堆空间的有效利用程度。

峰值内存利用率 U_k 在经历 k+1 个请求后定义为:

U_k = ( max_{i≤k} P_i ) / H_k

其中:

  • P_i 是第 i 个请求后,所有仍被分配的块的有效载荷(payload)总和。
  • H_k 是到当前时刻的堆大小峰值(即 brk 指针曾到达过的最大位置)。

高利用率意味着分配器能更有效地利用从系统获得的堆内存。


内存碎片

低内存利用率主要由两种碎片导致:

  1. 内部碎片

    • 发生在已分配块内部的浪费空间。
    • 原因包括:为了满足对齐要求而进行的填充、分配器元数据开销(如头部信息)、以及分配器选择了比请求稍大的块。
    • 内部碎片的大小在分配时即可确定。
  2. 外部碎片

    • 内存中分散着许多小的空闲块,它们的总容量足够满足一个请求,但没有一个单独的空闲块足够大。
    • 原因取决于之前请求的顺序和大小,以及分配器放置块的策略。
    • 外部碎片难以量化,因为它依赖于未来的请求模式。

实现隐式空闲链表

上一节我们介绍了碎片的概念,本节中我们来看看如何实现一个简单的分配器。我们将重点介绍隐式空闲链表方法。

知道块的大小

free(void *ptr) 只接收一个指针,分配器如何知道要释放多少内存?
解决方案:在分配给用户的有效载荷之前,分配器存储一个头部信息。

头部是一个字(word),至少包含:

  • 块大小:包括有效载荷和头部本身的总大小。
  • 分配状态位:标记该块是已分配(通常为1)还是空闲(通常为0)。

由于对齐要求(例如块地址总是8或16的倍数),块大小的最低几位总是0。我们可以利用其中一位(如最低有效位)来存储分配状态。当需要实际大小时,用掩码操作屏蔽掉状态位。

// 示例:从头部 word 中提取大小和分配状态
size_t block_size = header_word & ~0x1; // 屏蔽最低位得到大小
int is_allocated = header_word & 0x1;   // 检查最低位得到状态

隐式链表结构

通过在每个块的头部存储大小,我们可以遍历所有块:

  1. 从堆的起始位置开始。
  2. 读取当前头部,得到当前块的大小 size
  3. 将当前指针增加 size,即可到达下一个块的头部。

这种链表之所以称为“隐式”,是因为我们并没有显式存储“下一个”块的指针,而是通过块大小计算出来的。一个特殊的结束块(头部大小为0,标记为已分配)用于终止遍历。

寻找空闲块:放置策略

malloc 请求到来时,分配器需要扫描隐式链表,寻找一个足够大的空闲块。以下是几种策略:

  • 首次适配:从头开始搜索,选择第一个足够大的空闲块。
    • 优点:速度快,搜索很快停止。
    • 缺点:可能在链表头部留下许多小碎片,增大外部碎片。
  • 下一次适配:从上一次搜索结束的地方开始搜索。
    • 优点:比首次适配更快,避免了重复扫描链表前部。
    • 缺点:内存利用率可能比首次适配更差。
  • 最佳适配:搜索整个链表,选择满足请求的最小空闲块。
    • 优点:最大限度地减少请求后剩余的空闲部分,提高内存利用率。
    • 缺点:速度慢,需要遍历整个空闲链表。

分割空闲块

如果找到的空闲块比请求大很多,分配器通常会进行分割:将空闲块的一部分分配给用户,剩余部分形成一个新的、更小的空闲块。

分割操作涉及:

  1. 设置已分配部分的头部(包含大小和已分配标记)。
  2. 在剩余部分创建新的头部,将其标记为空闲。
  3. 更新链表遍历逻辑(隐式链表中,这通过大小字段自动处理)。

合并空闲块:边界标记

free 被调用时,仅仅将块标记为空闲可能会产生许多小的、相邻的空闲块(外部碎片)。为了形成更大的空闲块,分配器需要合并相邻的空闲块。

合并需要知道相邻块的状态。一个经典的方法是 Knuth 边界标记

  • 在每个块的头部和尾部都存储大小和分配状态。
  • 尾部使得我们可以通过 当前块地址 - 尾部大小 来找到前一个块的头部。
  • 这允许我们检查并合并前一个和/或后一个块(如果它们也是空闲的)。

然而,边界标记的缺点是增加了内部碎片(每个块都需要额外的尾部空间)。

优化的边界标记

一个优化是:只为空闲块维护头部和尾部。对于已分配块,我们只保留头部。
那么,如何找到前一个块并判断其状态呢?
我们可以在当前块的头部中,除了当前块的分配位,再利用一个空闲位(同样来自对齐保证)来存储前一个块的分配状态

这样,在释放当前块时:

  • 通过头部中的“前一块分配位”,可以立即知道前一块是否空闲。如果空闲,则可以通过前一块的尾部(它存在,因为它是空闲的)找到其大小并进行合并。
  • 通过当前块的大小找到后一块的头部,检查其分配状态,决定是否合并。

这种方法减少了已分配块的开销,同时仍支持有效的合并。


分配器策略总结

实现一个分配器涉及多种策略选择:

  1. 放置策略:首次适配、下一次适配、最佳适配。在吞吐量和碎片化之间权衡。
  2. 分割策略:决定何时分割一个大的空闲块。过于激进的分割会增加内部碎片;过于保守则可能导致无法满足后续请求。
  3. 合并策略
    • 立即合并:在 free 时立即合并相邻空闲块。简化了 free,但可能使 free 变慢。
    • 推迟合并:直到分配失败或扫描链表时才进行合并。可以将开销转移到非关键路径上。

隐式空闲链表的评价

  • 优点:概念简单,是实现更复杂分配器的基础教学模型。
  • 缺点
    • 分配时间:在最坏情况下是线性的(O(n)),需要扫描所有块。
    • 内存利用率:取决于放置策略,但通常不是最优。
      因此,隐式空闲链表在实践中很少使用,但理解其概念对于掌握分割、合并等核心技术至关重要,这些技术是所有现代分配器的核心。

总结

本节课中我们一起学习了动态内存分配的基础知识。我们了解了堆在内存布局中的位置、分配器的API和约束条件,以及衡量分配器性能的吞吐量和内存利用率目标。我们重点探讨了内存碎片的两种类型:内部碎片和外部碎片。

随后,我们深入研究了隐式空闲链表这一简单分配器的实现。我们学习了如何通过头部信息管理块大小和状态,如何实现首次适配、下一次适配和最佳适配等放置策略,以及如何进行块的分割。最后,我们详细讨论了合并空闲块以减少外部碎片的重要性,并介绍了边界标记及其优化版本。

虽然隐式链表本身效率不高,但它为我们理解动态内存分配的核心挑战和解决方案奠定了坚实的基础。在接下来的课程中,我们将探索更高效的分配器结构,如显式空闲链表和分离空闲链表。

24:高级分配器与垃圾回收 🧠

在本节课中,我们将学习动态内存分配的高级主题,包括显式空闲链表、分离空闲链表以及垃圾回收的基本概念。我们还将探讨与内存相关的常见陷阱和错误。


概述 📋

上一讲我们介绍了动态内存分配的基础,特别是隐式空闲链表。我们讨论了分配器如何管理堆内存,以及分配、释放、分割和合并等基本操作。在本讲中,我们将深入探讨更高效的分配策略,并了解自动内存管理(垃圾回收)的基本原理。


显式空闲链表 📊

上一节我们介绍了隐式空闲链表,本节中我们来看看显式空闲链表。显式空闲链表通过在每个空闲块中存储前驱和后继指针,将所有空闲块组织成一个显式的双向链表。

数据结构

在显式空闲链表中,每个空闲块不仅包含头部信息(大小和分配位),还包含两个指针:

  • pred:指向前一个空闲块。
  • succ:指向后一个空闲块。

这使得分配器可以直接遍历空闲块,而无需检查已分配的块。

分配操作

当需要分配内存时,分配器在显式空闲链表中搜索合适的空闲块。如果找到的块比请求的大小大,则进行分割:一部分用于满足请求(变为已分配块),剩余部分作为一个新的、更小的空闲块留在链表中。这涉及到更新链表指针,将新空闲块正确链接。

释放与合并操作

释放一个块时,需要将其插入到空闲链表中。与隐式链表类似,释放时可能发生合并。以下是四种合并情况:

  1. 前后块都已分配:只需将释放的块插入链表(例如,插入到链表头部)。
  2. 后一个块空闲:将释放的块与后一个空闲块合并,形成一个更大的空闲块,然后将其插入链表。
  3. 前一个块空闲:将释放的块与前一个空闲块合并,形成一个更大的空闲块,然后将其插入链表。
  4. 前后块都空闲:将释放的块与前后两个空闲块合并,形成一个更大的空闲块,然后将其插入链表。

合并操作需要将被合并的旧空闲块从链表中“拼接”出去,然后将新合并的大块插入链表。

性能与权衡

与隐式链表相比,显式空闲链表的优势在于:

  • 分配更快:搜索只遍历空闲块,而非所有块,尤其在内存使用率高时优势明显。
  • 释放稍复杂:由于需要维护链表指针,释放(包括合并)操作比隐式链表稍复杂。
  • 内存开销:每个空闲块需要额外的空间存储两个指针,这可能增加内部碎片。

分离空闲链表 🗂️

为了进一步提升性能,现代分配器常使用分离空闲链表。其核心思想是将空闲块按大小分类,每个大小类维护一个独立的空闲链表。

工作原理

分配器预先定义一系列大小类(例如,1-2字节、3字节、4字节、5-8字节、9+字节等)。当收到分配请求时:

  1. 根据请求大小确定其所属的大小类。
  2. 在该大小类的空闲链表中搜索合适的块。
  3. 如果找到,则进行分配(可能分割)。
  4. 如果当前大小类的链表中没有合适块,则向更大的大小类搜索,直到找到可用块或向操作系统申请更多堆内存(通过sbrk)。

释放块时,将其插入对应大小类的空闲链表,并检查合并机会。合并后产生的大块可能属于另一个大小类,需要移动到相应的链表中。

优势

分离空闲链表结合了多种策略的优点:

  • 近似最佳适配:通过按大小分类,首次适配搜索在各自大小类内近似于最佳适配,减少了外部碎片。
  • 对数级搜索时间:如果按2的幂次划分大小类,搜索时间是对数级的。
  • 减少搜索开销:每个链表更短,搜索更快。

垃圾回收 🗑️

对于像Java这样的语言,程序员无需手动释放内存,而是由垃圾回收器自动回收不再使用的内存(垃圾)。

基本概念

垃圾回收器将内存视为一个有向图:

  • 节点:堆上的每个内存块。
  • :块内的指针。
  • 根节点:指向堆内存的指针的存储位置,如寄存器、栈变量、全局变量。这些是程序访问堆的唯一起点。

如果一个堆内存块无法从任意根节点通过指针路径到达,则该块被视为垃圾,可以被安全回收。

标记-清扫算法

标记-清扫是一种经典的垃圾回收算法,分为两个阶段:

  1. 标记阶段:从所有根节点开始,遍历所有可达的内存块,并标记它们(例如,设置一个标记位)。
  2. 清扫阶段:线性扫描整个堆。对于每个块,如果它被标记,则清除标记位(为下一轮回收准备)。如果它未被标记且是已分配的,则将其释放(即,视为垃圾回收)。

保守式垃圾回收(针对C/C++)

C/C++语言没有严格的类型信息,指针可以伪装成整数,也可以进行指针运算。保守式垃圾回收器假设:

  • 任何看起来像指针的位模式都被视为指针。
  • 如果指针指向一个块的内部(而非头部),则整个块都被认为是可达的。
  • 它不能移动内存块。

其正确性依赖于程序行为良好(例如,指针运算不越界访问其他无关内存块)。为了找到指针所属块的头部,可以使用平衡二叉树等数据结构,以块起始地址为键进行快速查找。


常见的内存相关陷阱与错误 ⚠️

以下是动态内存管理中常见的七类错误:

  1. 解引用错误指针:例如,向scanf传递变量的值而非地址。
  2. 读取未初始化的内存malloc不初始化内存,直接使用分配的内存可能导致未定义行为。
  3. 覆盖内存:例如,分配不足的空间(malloc(sizeof(int))误写为malloc(sizeof(int*))),导致写入越界。
  4. 缓冲区溢出:经典错误,如使用strcpy时未为目标字符串分配足够的空间(忘记为终止符\0分配空间)。
  5. 误解指针运算:指针加减是以指向类型的大小为单位的。
  6. 引用不存在的变量:返回指向局部变量的指针。
  7. 内存管理错误
    • 重复释放:多次释放同一块内存。
    • 释放错误地址:例如,在复杂的指针操作后释放了错误的指针。
    • 内存泄漏:分配内存后忘记释放。
    • 不完整的释放:例如,只释放了链表的头节点,而未释放链表中的后续节点。

调试工具

  • GDB:用于调试,但对某些内存错误检测能力有限。
  • Valgrind:强大的二进制插桩工具,可以检测内存泄漏、越界访问、使用未初始化内存等多种错误,无需源代码。
  • 自定义检查器:为特定数据结构(如堆)编写一致性检查函数。
  • Glibc malloc 检查:设置环境变量(如MALLOC_CHECK_)可以让glibc的malloc实现进行一些基本的错误检查。

总结 🎯

本节课中我们一起学习了动态内存分配的高级技术。我们从显式空闲链表开始,了解了如何通过维护显式的双向链表来提高分配效率。接着,我们探讨了分离空闲链表,这是一种通过按大小分类管理空闲块来近似实现最佳适配并提升性能的策略。然后,我们介绍了垃圾回收的基本思想,特别是标记-清扫算法,以及如何将其应用于C/C++程序的保守式垃圾回收。最后,我们回顾了动态内存编程中常见的陷阱和错误,并了解了一些用于检测和调试这些错误的工具。掌握这些概念和技术对于编写正确、高效和健壮的系统程序至关重要。

第25:Malloc 实验指导

在本节课中,我们将学习如何实现一个动态内存分配器(malloc),涵盖核心概念、设计决策和调试技巧。我们将从基础概念开始,逐步深入到更高级的优化策略。

概述

动态内存分配用于在程序运行时,当所需内存大小在编译时未知的情况下申请内存。本实验要求你实现 malloccallocreallocfree 函数。我们将讨论堆的结构、碎片化、块的分裂与合并,以及如何通过不同的数据结构和策略来提高分配器的吞吐量和内存利用率。

动态内存分配基础

当我们在编译时无法确定所需内存大小时,就需要使用动态内存。这涉及到 malloccallocreallocfree 等函数调用,这些正是你需要在实验中实现的。

核心概念包括:

  • 堆(Heap):动态内存分配的区域。
  • 有效载荷(Payload):分配给用户的实际内存区域。
  • 碎片化(Fragmentation):分为内部碎片(块内浪费的空间)和外部碎片(堆中分散的、无法使用的空闲空间)。
  • 分裂(Splitting):将一个大的空闲块分割,一部分用于分配,剩余部分作为新的空闲块。
  • 合并(Coalescing):将相邻的空闲块合并成一个更大的空闲块。

堆的初始结构与扩展

mm_init 函数中,堆的初始结构包含一个序言块(Prologue)和一个尾声块(Epilogue),它们作为堆的边界标记。所有分配和释放的块都位于这两个块之间。

当堆中没有足够大的空闲块来满足分配请求时,我们需要扩展堆。这是通过 sbrk 系统调用来完成的。

关键点

  • 整个块的大小(包括头部和脚部)必须是 16 字节对齐的。
  • 有效载荷的大小不必是 16 的倍数。

扩展堆时,你需要决定每次调用 sbrk 请求的字节数(chunk_size)。这是一个重要的权衡:

  • 如果 chunk_size 太小,你会频繁调用昂贵的 sbrk,降低吞吐量。
  • 如果 chunk_size 太大,你可能申请了远多于实际分配的内存,导致内存利用率得分很低。

目前没有确定的公式来计算最优的 chunk_size,需要通过实验(尝试不同的值并观察评分)来找到合适的数值。

显式空闲链表

基线代码使用了隐式空闲链表,它需要遍历堆中的所有块(包括已分配块)来寻找空闲块。为了提高效率,你应该实现显式空闲链表

在显式空闲链表中,我们只维护一个空闲块的链表。每个空闲块除了存储大小和分配状态的头部/脚部外,还需要存储指向链表中下一个和前一个空闲块的指针(nextprev),形成一个双向链表。

优势:分配时只需遍历空闲块链表,跳过了所有已分配块,显著提高了搜索速度(吞吐量)。

块的分裂策略

当找到一个足够大的空闲块来满足分配请求时,你需要决定是分配整个块,还是将其分裂。

分裂规则:除非剩余空间不足以形成一个最小块(包含头部、脚部、指针等元数据所需的最小空间),否则就应该分裂。如果剩余空间小于最小块大小,则分配整个块,避免产生无法使用的碎片。

块的合并策略

合并(Coalescing)在显式链表中的逻辑与隐式链表类似,但需要注意:你必须检查物理上相邻的前后块,而不是链表中逻辑相邻的(next/prev指向的)块。

合并时有四种情况需要考虑:

  1. 前后块都已分配:无法合并。
  2. 前一块空闲,后一块已分配:与前一块合并。
  3. 前一块已分配,后一块空闲:与后一块合并。
  4. 前后块都空闲:将三块合并为一个大空闲块。

合并时,除了更新合并后块的头部和脚部大小及分配位,还必须更新显式链表中的 nextprev 指针,以保持链表的一致性。

设计考量与优化

1. 放置策略

寻找空闲块时,你有几种策略选择:

  • 首次适配(First Fit):选择第一个足够大的块。速度快,但可能导致外部碎片增多
  • 最佳适配(Best Fit):选择大小最接近请求的块。内存利用率高,但需要遍历整个空闲链表,速度慢
  • 近似适配(Good Enough Fit):一种折中方案。例如,在查找一定数量的块后,选择一个足够好的块。这需要在吞吐量和利用率之间进行权衡,需要通过实验调整。

2. 空闲链表顺序

当你释放一个块并将其插入空闲链表时,需要决定插入的位置:

  • 按地址顺序插入
  • 按大小顺序插入
  • 总是插入到链表头部

不同的插入策略会影响后续分配的效率,这也是一个可以实验优化的点。

3. 合并时机

你可以选择在释放块时立即合并相邻空闲块(立即合并),也可以推迟合并,例如在分配失败搜索空闲块时再进行合并。不同的时机对性能有不同影响。

高级优化策略(供最终提交参考)

对于检查点,你主要关注实现显式链表并提高吞吐量。对于最终提交,重点将转向大幅提高内存利用率。

1. 分离空闲链表

这是显式链表的一种推广。我们维护多个空闲链表,每个链表负责一个特定大小范围(大小类)的空闲块。

例如

  • 链表1:大小 1-32 字节的块
  • 链表2:大小 33-64 字节的块
  • 链表3:大小 65-256 字节的块
  • ...

当分配一个大小为 size 的块时,你只需在对应的 size 所在的大小类链表(以及可能更大的链表)中搜索,避免了遍历大量不相关的小块,极大提升了搜索速度

实现注意

  • 你需要决定大小类的划分(例如2的幂次)。
  • 全局变量空间有限(128字节),需合理存储多个链表的头指针。
  • 合并块时,块的大小会改变,需要将其从旧的大小类链表中移除,并插入到新的大小类链表中。

2. 无脚部优化

在已分配的块中,脚部(Footer)可能不是必需的。脚部主要用于合并时查找前一个块的大小。但如果前一个块是已分配的,我们根本不需要与它合并,因此也就不需要它的脚部信息。

优化方法:在块的头部中用一个额外的位(例如,借用大小字段的未用位)来指示前一个块是否空闲

  • 如果前一个块空闲(该位为0),则可以通过当前块指针减去前一个块的脚部中存储的大小来找到前一个块起始位置,并进行合并。
  • 如果前一个块已分配(该位为1),则无需任何操作。

这样,已分配块可以省去脚部,将节省的8字节用于有效载荷,对于分配大量小对象(如链表节点)的情况,能显著减少内部碎片,提高利用率。

3. 减小最小块大小

基线实现的最小块大小(如32字节)对于分配非常小的对象(如8字节)会造成严重的内部碎片。为了减少内部碎片,可以尝试减小最小块大小。

挑战:更小的块可能无法容纳显式链表所需的两个指针(nextprev)。
可能的解决方案

  • 对于小块,使用单链表而非双链表(牺牲删除速度)。
  • 重新设计元数据,例如尝试去掉小块的脚部甚至头部(需要非常精巧的设计)。
  • 接受对小块的O(N)删除操作,因为小块链表可能很短。

代码模块化与调试

代码模块化的重要性

  • 编写通用函数:为链表操作(插入、删除)编写通用函数,而不是在每个需要的地方复制粘贴代码。这在实现分离链表时尤为重要。
  • 避免重复代码:如果你有复制代码的冲动,应该将其重构为函数。
  • 使用常量数组:对于大小类等配置,使用常量数组并通过循环判断,而不是一连串的 if-else 语句。
  • 好处:模块化代码更易于阅读、调试和修改,能降低心智负担,让你更专注于算法逻辑。

使用GDB进行调试

动态内存分配的Bug常常难以定位,printf 在大量分配下不实用。GDB是你最强大的调试工具

  • 基本命令run, break, step, next, backtrace, frame, print
  • 条件断点break ... if condition,只在特定条件下暂停。
  • 观察点watch variablewatch *address,监控变量或内存地址的变化,当值被修改时暂停。这对于发现元数据被意外覆盖非常有用。
  • 在GDB中调用函数:你可以在GDB中直接调用你编写的堆打印函数,而不需要重新编译程序,便于动态检查状态。

实现堆检查器

一个强大、全面的堆检查器(heap checker)可以节省你大量的调试时间。

堆检查器应详尽地验证你实现的所有不变量,它不需要高效,仅用于调试。应在开发早期就编写,并随着实现更新。

需要检查的不变量包括:

块级别

  • 块地址对齐(16字节)。
  • 有效载荷在堆边界内。
  • 头部和脚部的大小、分配位信息匹配。
  • 没有两个连续的空闲块(确保合并正确)。

链表级别

  • 双向链表指针的一致性(从A通过next到B,则从B通过prev应能回到A)。
  • 空闲链表中的块计数与堆中实际遍历得到的空闲块数一致。
  • (对于分离链表)每个空闲块都位于正确的大小类链表中。

堆整体级别

  • 所有块的大小之和等于通过sbrk申请的堆总大小。
  • 序言块和尾声块位于堆的起始和末尾。

如何有效求助

当遇到问题寻求帮助时(如课程助教),请提供具体信息:

  1. 描述清晰:不要只说“我的程序段错误”。说明在运行哪个测试、进行什么操作时发生错误。
  2. 提供上下文:使用GDB的 backtrace 给出函数调用栈。
  3. 展示你的检查器:说明你的堆检查器在哪个环节报告了哪个不变量被违反。这表明你已经做了深入的调试。
  4. 使用“橡皮鸭调试法”:向他人(甚至一个玩偶)解释你的代码逻辑,往往能在解释过程中自己发现错误。

总结

本节课我们一起学习了 malloc 实验的核心内容。我们从动态内存分配的基本概念和堆结构出发,详细探讨了显式空闲链表的实现,包括块的分裂与合并策略。我们还分析了影响分配器性能的多种设计考量,如放置策略、链表顺序和合并时机。

对于追求更高性能的最终提交,我们介绍了分离空闲链表无脚部优化减小最小块大小等高级优化技术,这些能显著提升内存利用率。

最后,我们强调了代码模块化的重要性,并深入讲解了如何使用 GDB 和编写堆检查器 来高效调试复杂的内存分配错误。请务必尽早开始实验,留出充足时间进行迭代、测试和优化。祝你好运!

26:网络编程基础与客户端-服务器模型

在本节课中,我们将要学习网络编程的基础知识,特别是客户端-服务器模型、网络层次结构、核心协议以及如何使用套接字接口进行简单的网络通信。这是完成课程最后一个实验所需的核心内容。

客户端-服务器模型

上一节我们介绍了课程概述,本节中我们来看看网络应用中最典型的模型——客户端-服务器模型。

在这个模型中,有一个服务器进程在运行,它管理着某种资源,并利用该资源提供服务。同时,有一个或多个客户端主机。当客户端发送一个请求时,服务器处理该请求,并将响应返回给客户端。服务器是被动激活的,只有在客户端发出请求时才会行动。可以将其想象成一个自动售货机:在你投入硬币并按下按钮之前,它只是静止不动。

需要注意的是,这些是进程。通常,客户端运行在一台主机上,服务器运行在另一台主机上。但也可以有多个客户端进程运行在同一台主机上,或者客户端与服务器运行在同一台主机上。

网络硬件层次结构

上一节我们介绍了客户端-服务器模型,本节中我们来看看网络是如何从硬件层面构建的。

网络被视为一个由盒子和线路组成的层次结构。

  • 系统区域网络:这种网络可以跨越一个集群或一个机房。
  • 局域网:这种网络可以跨越整个校园或建筑物,例如以太网。
  • 广域网:这种网络可以跨越全国乃至全球。

互联网 是指这些相互连接的网络集合。在本课程中,我们区分小写 internet(泛指连接多个局域网的通用网络)和大写 Internet(我们熟悉的全球IP互联网)。

从下至上构建网络

以下是构建网络的基本组件:

  • 以太网段与集线器:主机通过双绞线连接到集线器。每个以太网适配器都有一个唯一的48位地址(MAC地址)。主机发送称为的数据包。集线器只是简单地广播:它接收到的任何帧都会发送给所有主机。如今,集线器已不常用。
  • 网桥:网桥连接一系列集线器或主机。它比集线器更智能,能够判断哪些主机可以通过哪些端口到达,从而只将信息广播到目标主机所在的路径。
  • 路由器:路由器负责将流量从一个局域网路由到另一个局域网。不同的局域网可能使用完全不同的技术(如以太网、T1链路)。路由器的协议需要能够处理这些差异并进行转换。

一个由多个局域网和路由器连接起来的网络就构成了一个 internet。主机之间通过逐跳路由进行通信。两点之间可能存在多条路径,这有助于应对拥塞或故障。因此,数据包可能通过不同路径到达,需要上层协议来确保接收顺序。

网络协议的需求

上一节我们看到了不同网络硬件的互联,本节中我们来看看为什么需要统一的协议来管理通信。

由于内部网络可能不兼容,我们需要一个总体协议来允许每个网络与该协议通信。协议是一套规则,规定了主机和路由器在传输数据时应如何协作,从而抽象或隐藏不同节点之间的差异。

为了实现通信,我们需要两样东西:

  1. 命名方案:一种让一台主机能够识别另一台主机的方法,基于主机地址。每个主机地址唯一标识一台主机。一台机器可以有多个地址,但一个地址不能对应多台机器。
  2. 传递机制:一种实际将数据包从源主机传送到目的主机的方法。

数据包由头部有效载荷组成。有效载荷是数据,头部包含路由信息,如数据包大小、源地址和目的地址。

数据包与帧的封装

以下是数据在局域网和互联网中传输时的封装过程:

当客户端在局域网内发送数据时,协议软件会添加一个互联网数据包头部。然后,针对该局域网,会再添加一个帧头部。这个帧头部用于在局域网内部进行路由。数据被发送到路由器。

路由器需要知道如何进行转换。它需要将帧头部转换为第二个局域网使用的格式(可能完全不同),同时解释数据包头部以正确路由。最终,数据到达目标局域网适配器,协议软件会剥离数据包头部和帧头部,将数据传递给主机。

数据包头部包含全局路由所需的信息(如源地址和目的地址)。帧头部包含的信息与特定局域网的内部路由协议相关,可能使用仅在本地有意义的命名约定,而不需要全局唯一。

全球互联网与协议栈

上一节我们讨论了通用网络,本节中我们来看看我们熟悉的全球互联网所基于的具体协议。

大写 Internet 基于 TCP/IP 协议族。

  • IP(互联网协议):提供基本的命名方案(主机到主机)和传递数据包(称为数据报)的能力。它是不可靠的“尽力而为”服务。
  • UDP(用户数据报协议):在 IP 之上,提供进程到进程的通信。它也是不可靠的,但开销比 TCP 小,适用于能容忍一定丢包的高性能应用。
  • TCP(传输控制协议):在 IP 之上,提供可靠的、面向连接的、进程到进程的通信。它确保数据按顺序送达,类似于电话呼叫。

在 Unix 系统中,网络通信被映射到文件接口。我们将使用套接字接口,它看起来就像读写文件一样,但实际上是对网络连接进行操作。

IP地址与域名

从程序员的角度看,主机使用32位IP地址进行映射。IP地址通常以点分十进制表示法书写,例如 128.2.203.179

一部分IP地址被映射到互联网域名,例如 www.cs.cmu.edu。域名系统采用层次结构,例如根域下有 .edu.com 等,其下是 cmu.edu,再下是 cs.cmu.edu

DNS(域名系统) 维护着IP地址和域名之间的映射关系。它是一个主机条目集合,允许进行双向查询。

  • gethostbyname 函数:根据域名获取IP地址。
  • 特殊地址 127.0.0.1:这个地址总是指向本地主机自身,便于编写可移植的代码。
  • gethostname 函数:获取本地主机的真实域名。

需要注意的映射关系:

  • 多个域名可以映射到同一个IP地址(如 cs.mit.edueecs.mit.edu)。
  • 一个域名可以映射到多个IP地址(如大型网站用于负载均衡)。
  • 可能存在有效的域名但没有映射到任何IP地址。

IP地址的注册和管理由中心机构(如ICANN)和注册商(如GoDaddy)负责。

连接、端口与套接字

TCP连接是点对点的(如客户端到服务器),是全双工的(双向通信),并且是可靠的。它确保源头发送的字节流最终被接收,并且顺序得以保持。这是通过为数据包添加序列号来实现的。

套接字 是连接的端点。每个连接的两端各有一个套接字。套接字地址 由IP地址和一个16位的端口号组成,端口号用于标识主机上的特定进程。

端口分为两类:

  • 知名端口:用于约定俗成的服务,例如:
    • 80: HTTP (Web服务)
    • 25: SMTP (电子邮件)
    • 22: SSH
    • 21: FTP
    • 7: Echo服务(常用于教学示例)
  • 临时端口:由操作系统自动分配给客户端连接,用后即弃。

一个连接由客户端套接字地址(客户端IP:客户端临时端口)和服务器套接字地址(服务器IP:服务器知名端口)唯一标识。内核根据目的端口号将传入的数据包路由到正确的服务器进程。

套接字接口

套接字接口提供了一组函数,使得网络通信看起来像文件操作。客户端和服务器都有文件描述符,通过读写这些描述符来进行网络通信。这与普通文件I/O的主要区别在于建立连接的步骤更多。

该接口自20世纪80年代初就成为Berkeley Unix发行版的一部分,并且已被所有主流操作系统采用。

一个简单的Echo服务器示例

我们将通过一个简单的Echo服务器/客户端示例来演示网络通信。Echo服务器接收客户端发送的消息,并将其原样发送回客户端。

会话流程

  1. 服务器启动,被动等待连接。
  2. 客户端启动,指定服务器主机名和端口(如Echo服务的端口7),发起连接请求。
  3. 服务器接受连接,建立连接。服务器打印出客户端的主机名和端口。
  4. 客户端循环:从终端读取一行文本,通过套接字发送给服务器。
  5. 服务器通过套接字读取该行,然后立即将其写回同一套接字。
  6. 客户端从套接字读取回显的行,并将其打印到终端。
  7. 客户端输入结束(如EOF),关闭连接。服务器检测到EOF,也关闭该连接,然后返回等待状态,准备接受新连接。

客户端代码逻辑(简化)

  1. 根据主机和端口打开连接,获取客户端文件描述符 clientfd
  2. 初始化Rio读取缓冲区。
  3. 循环:
    • 使用 Fgets 从标准输入读取一行到缓冲区。
    • 使用 Rio_writen 将缓冲区内容写入 clientfd(发送给服务器)。
    • 使用 Rio_readlinebclientfd 读取一行(接收服务器的回显)。
    • 使用 Fputs 将该行输出到标准输出。
  4. 循环结束(输入为空),关闭 clientfd 并退出。

服务器端代码逻辑(简化)

  1. 打开一个监听文件描述符 listenfd,准备接受连接。
  2. 进入循环,等待连接。
  3. 当连接到达时,调用 accept 函数,接受连接并获取一个用于该连接的已连接文件描述符 connfd,以及客户端地址信息。
  4. 使用 getnameinfo 等函数翻译客户端地址,以便打印连接信息。
  5. 使用 Rio_readlinebconnfd 读取一行(来自客户端)。
  6. 使用 Rio_writen 将同一行写回 connfd(发送回客户端)。
  7. 重复读/写,直到读取到EOF。
  8. 关闭 connfd,结束对该客户端的服务,返回步骤2等待下一个连接。

总结

本节课中我们一起学习了网络编程的基础。我们首先了解了客户端-服务器模型,然后探讨了网络硬件的层次结构以及互联网的构建方式。我们明确了网络协议的必要性,并深入研究了全球互联网所依赖的TCP/IP协议栈,包括IP、TCP和UDP。我们学习了IP地址、域名系统以及端口和套接字的概念。最后,我们通过一个Echo服务器/客户端的实际例子,演示了如何使用套接字接口建立连接并进行双向通信。这些知识为后续更复杂的网络编程任务奠定了基础。

27:网络编程(第二部分)📡

在本节课中,我们将继续学习网络编程,重点探讨套接字地址结构、客户端与服务器的建立连接过程,以及一个简单的Web服务器(Tiny Web Server)如何工作。我们将通过具体的函数调用和代码示例,理解网络通信的底层机制。


套接字地址结构

上一节我们介绍了套接字的基本概念。本节中,我们来看看用于标识通信端点的地址结构。

套接字地址结构的设计非常通用。其通用版本包含两个字段:协议族和地址数据。

struct sockaddr {
    sa_family_t sa_family;    // 协议族
    char        sa_data[14];  // 地址数据
};

我们使用类型转换,将通用结构 struct sockaddr 转换为特定的地址结构,例如 struct sockaddr_in

对于本课程重点关注的IPv4,其特定的地址结构如下:

struct sockaddr_in {
    sa_family_t    sin_family; // 地址族,例如 AF_INET
    in_port_t      sin_port;   // 端口号(网络字节序)
    struct in_addr sin_addr;   // IP地址(网络字节序)
    unsigned char  sin_zero[8]; // 填充字节
};

sin_family 字段设置为 AF_INET 即表示使用IPv4协议,并定义了其他字段的含义。


地址信息转换

为了在字符串形式的地址(如主机名、服务名)和套接字地址结构之间进行转换,我们使用 getaddrinfogetnameinfo 函数。

getaddrinfo 函数将主机名和服务名转换为套接字地址结构。它的主要优点是协议无关且可重入,适合多线程程序使用。

int getaddrinfo(const char *host, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **result);

调用 getaddrinfo 会返回一个指向链表(struct addrinfo)的指针。链表中每个节点都包含一个符合查询条件的套接字地址结构。

以下是 struct addrinfo 链表的结构示意图:

+-------------+    +-------------+    +-------------+
| addrinfo    | -> | addrinfo    | -> | addrinfo    |
| ai_next     |    | ai_next     |    | ai_next     |
| ai_addr     |    | ai_addr     |    | ai_addr     |
| ...         |    | ...         |    | ...         |
+-------------+    +-------------+    +-------------+

客户端或服务器可以遍历此链表,依次尝试每个地址,直到成功建立连接或绑定。

getnameinfo 函数是 getaddrinfo 的逆过程,它将套接字地址转换回对应的主机名和服务名字符串。

int getnameinfo(const struct sockaddr *sa, socklen_t salen,
                char *host, size_t hostlen,
                char *serv, size_t servlen, int flags);

客户端-服务器连接建立过程

现在,我们来详细看看客户端和服务器如何建立连接。整个过程如下图所示,涉及多个关键的系统调用。

服务器端: socket() -> bind() -> listen() -> accept() -> read()/write() -> close()
客户端:   socket() -> connect() -> read()/write() -> close()

1. 创建套接字(Socket)

双方首先都需要创建一个套接字文件描述符。

int socket(int domain, int type, int protocol);

例如,客户端调用 socket(AF_INET, SOCK_STREAM, 0) 创建一个用于IPv4 TCP通信的套接字。

2. 绑定地址(Bind)- 仅服务器

服务器需要调用 bind 函数,将套接字与一个特定的地址(IP地址和端口号)关联起来。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

3. 监听连接(Listen)- 仅服务器

listen 函数将主动套接字转换为监听套接字,使其可以接受客户端的连接请求。

int listen(int sockfd, int backlog);

参数 backlog 提示内核在开始拒绝新连接前,可以排队等待的连接请求的最大数量。

4. 接受连接(Accept)- 仅服务器

服务器调用 accept 函数,阻塞并等待客户端的连接请求。

int accept(int listenfd, struct sockaddr *addr, socklen_t *addrlen);

当连接建立时,accept 会返回一个新的、专用于此连接的套接字文件描述符(连接描述符)。原始的监听描述符继续用于接受其他新连接。

5. 发起连接(Connect)- 仅客户端

客户端调用 connect 函数,主动向服务器发起连接请求。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

6. 数据交换

连接建立后,双方使用 readwrite(或 Rio 包中的安全版本)在连接描述符上进行数据读写。

7. 关闭连接

通信完成后,调用 close 关闭连接描述符。服务器的监听描述符保持打开以接受新连接。


客户端代码示例:open_clientfd

以下是客户端建立连接的典型代码模式,它封装了 getaddrinfosocketconnect 的过程。

int open_clientfd(char *hostname, char *port) {
    struct addrinfo hints, *listp, *p;
    int clientfd;

    // 初始化 hints 结构
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM; // 面向连接的套接字
    hints.ai_flags = AI_NUMERICSERV | AI_ADDRCONFIG; // 使用数字端口,仅配置地址
    getaddrinfo(hostname, port, &hints, &listp);

    // 遍历地址链表,尝试连接
    for (p = listp; p; p = p->ai_next) {
        // 创建套接字描述符
        if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
            continue; // 创建失败,尝试下一个地址

        // 尝试连接到服务器
        if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1)
            break; // 连接成功,跳出循环

        close(clientfd); // 连接失败,关闭套接字,尝试下一个地址
    }

    freeaddrinfo(listp); // 释放地址链表
    if (!p) // 所有尝试都失败
        return -1;
    else
        return clientfd; // 返回成功的连接描述符
}

服务器代码示例:open_listenfd

以下是服务器端准备监听连接的典型代码模式。

int open_listenfd(char *port) {
    struct addrinfo hints, *listp, *p;
    int listenfd, optval=1;

    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM; // 面向连接的套接字
    hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG | AI_NUMERICSERV; // 被动打开,用于服务器
    getaddrinfo(NULL, port, &hints, &listp); // 主机名填 NULL,表示接受任意地址的连接

    for (p = listp; p; p = p->ai_next) {
        // 创建套接字描述符
        if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
            continue;

        // 消除“地址已在使用”错误
        setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval , sizeof(int));

        // 将套接字绑定到地址
        if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
            break; // 绑定成功

        close(listenfd); // 绑定失败,关闭套接字,尝试下一个地址
    }

    freeaddrinfo(listp);
    if (!p) // 所有尝试都失败
        return -1;

    // 将套接字转换为监听套接字
    if (listen(listenfd, LISTENQ) < 0) {
        close(listenfd);
        return -1;
    }
    return listenfd; // 返回监听描述符
}


Web 服务器基础

理解了基本的套接字通信后,我们将其应用于Web服务器。Web内容通过HTTP协议在TCP连接上传输。

HTTP 请求与响应

一个HTTP请求的格式如下:

GET /path/to/resource HTTP/1.1\r\n
Host: www.example.com\r\n
[其他头部...]\n
\r\n

一个HTTP响应的格式如下:

HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length: 1234\r\n
\r\n
[响应体内容...]

空行\r\n\r\n)用于分隔头部和正文。

静态内容与动态内容

  • 静态内容:预先存储在文件中的内容,如HTML、图片。
  • 动态内容:由服务器上的程序在请求时动态生成的内容。通常通过CGI(通用网关接口) 机制实现。

在Tiny Web Server中,通过检查请求的URI是否包含字符串 "cgi-bin" 来判断是否为动态内容请求。


Tiny Web Server 剖析

Tiny Web Server是一个简单的教学用Web服务器,代码仅约250行,但展示了处理静态和动态请求的核心路径。

处理静态内容

  1. 解析请求,确定请求的文件。
  2. 检查文件类型(通过后缀名,如 .html, .jpg)。
  3. 构建HTTP响应头(状态行、Content-Type, Content-Length 等)。
  4. 发送响应头和一个空行。
  5. 将请求的文件内容(如通过内存映射 mmap)发送给客户端。

处理动态内容(CGI)

  1. 解析请求,提取CGI程序名和参数(参数通过URL中的 ?& 传递)。
  2. 创建一个子进程(fork)。
  3. 在子进程中:
    • 设置环境变量 QUERY_STRING 为客户端传递的参数。
    • 使用 dup2 将子进程的标准输出重定向到连接套接字。
    • 执行(execve)CGI程序。该程序产生的输出会直接发送给客户端。
  4. 父进程等待子进程结束。

关键点:对于动态内容,HTTP响应头(包括 Content-Length)必须由CGI程序自己生成并输出,因为服务器在运行程序前无法预知输出的大小和类型。


总结

本节课中我们一起学习了网络编程的核心实践部分。我们从套接字地址结构出发,详细追踪了客户端与服务器建立TCP连接的每一步:socket, bind, listen, accept, connect。我们分析了使用 getaddrinfo 进行协议无关编程的客户端和服务器示例代码。最后,我们将这些知识应用于Web上下文,剖析了一个简单的Web服务器(Tiny Web Server)如何处理静态和动态HTTP请求,特别是通过fork-exec和重定向标准输出来执行CGI程序的机制。这些概念和代码模式是完成后续网络编程相关实验(如代理服务器)的重要基础。

28:并发编程 🧵

在本节课中,我们将要学习并发编程的核心概念。并发编程允许计算机同时处理多个任务,这在多核处理器和需要响应异步事件(如网络、传感器输入)的现代系统中至关重要。然而,编写正确的并发程序颇具挑战性,因为需要考虑事件发生的顺序、资源共享以及避免各种并发错误。

并发编程的挑战与概念

上一节我们介绍了并发编程的重要性,本节中我们来看看并发编程中常见的几种问题和核心概念。

竞争条件

竞争条件是指当两个或多个实体试图同时使用同一资源时,如果没有妥善处理,可能导致程序行为出错。这就像两辆车争抢同一个停车位。

死锁

死锁是指两个或多个执行单元互相等待对方释放资源,导致所有单元都无法继续执行。一个典型的例子是十字路口的交通堵塞:每辆车都占用了另一辆车需要的道路空间,形成了一个循环依赖。

饥饿

饥饿是指某个执行单元因为优先级较低或资源分配策略问题,长时间无法获得所需资源,从而无法取得进展。例如,一条繁忙的主干道让支路上的车辆永远无法通过。

信号处理程序与 printf 的陷阱

在信号处理程序中使用 printf 等标准I/O函数是危险的,这可能导致死锁。原因在于 printf 内部使用了锁来保护其共享的全局状态(例如时区管理相关的变量)。

当主程序调用 printf 并获取了内部锁之后,如果此时一个信号到达并触发了信号处理程序,而该处理程序也调用了 printf,那么它会尝试获取同一个锁。由于锁被主程序持有,信号处理程序会等待。而主程序又需要等待信号处理程序返回后才能释放锁,这就形成了一个死锁。

虽然这种死锁在测试中可能很少出现,但在高频率发送信号的极端情况下,几乎必然会发生。因此,在信号处理程序中应避免使用非异步信号安全的函数,如 printf

解决方案:一种可行的方法是在每次调用 printf 前后阻塞和恢复信号,但这需要在代码中所有调用 printf 的地方都进行此操作,而不仅仅是在信号处理程序中。

迭代服务器的局限性

我们之前实现的迭代式回声服务器有一个明显的缺点:它一次只能处理一个客户端连接。当第一个客户端连接并通信时,其他客户端只能等待,直到当前连接关闭。

以下是其工作流程的简化描述:

  1. 服务器在监听套接字上等待连接。
  2. 客户端A发起连接,服务器接受并建立连接。
  3. 服务器进入循环,处理客户端A的请求并回显。
  4. 在此期间,客户端B发起连接。虽然 connect 调用可能返回,但其后续的 writeread 调用会被阻塞,因为服务器正在忙于处理客户端A。
  5. 只有等到客户端A断开连接,服务器才会回到 accept 调用,接受客户端B的连接。

这种模型无法满足需要同时服务大量客户端的现实需求(例如谷歌服务器)。

实现并发服务器

为了解决迭代服务器的局限性,我们需要实现并发服务器。主要有三种方法:基于进程、基于事件和基于线程。

1. 基于进程的并发服务器

这种方法为每个新客户端连接创建一个新的子进程。主服务器进程只负责接受连接和创建子进程,子进程则负责处理具体的客户端通信。

关键细节

  • 文件描述符管理fork() 会复制所有打开的文件描述符。因此,父进程必须关闭已交给子进程处理的连接描述符,子进程也必须关闭不需要的监听描述符,以防止文件描述符泄漏。
  • 回收子进程:服务器必须设置 SIGCHLD 信号处理程序,并使用 waitpid 来回收已终止的子进程,避免产生大量僵尸进程。

优缺点

  • 优点:模型相对简单,进程间隔离性好。
  • 缺点:创建进程开销较大;进程间共享状态(如共享数据库)比较困难。

2. 基于事件的并发服务器

这种方法使用像 selectepoll 这样的I/O多路复用函数。服务器在一个单线程中维护一个活动文件描述符(包括监听套接字和所有连接套接字)的集合。select 调用会阻塞,直到集合中有一个或多个描述符准备好进行I/O操作(例如,有新的连接请求或客户端数据到达),然后服务器再顺序处理这些就绪的事件。

优缺点

  • 优点:逻辑是顺序的,易于理解和调试;资源消耗少。
  • 缺点:代码复杂度可能较高;无法充分利用多核CPU;如果某个客户端的操作(如读取一行没有换行符的数据)发生阻塞,可能会影响整个服务器。

3. 基于线程的并发服务器 🧵

这是本节课的重点。线程是运行在同一个进程内的独立控制流,它们共享进程的全局数据、堆和代码,但每个线程拥有自己独立的栈和寄存器上下文。

与进程相比,线程的创建和管理开销要小得多,并且共享数据非常方便(有时过于方便,导致了新的问题)。

Pthreads 基础

C语言使用Pthreads库进行多线程编程。一个简单的“Hello World”线程程序如下:

#include <pthread.h>
void *thread(void *vargp); // 线程例程原型

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, thread, NULL); // 创建新线程
    pthread_join(tid, NULL); // 等待指定线程终止
    return 0;
}

void *thread(void *vargp) { // 线程例程
    printf("Hello, world!\n");
    return NULL;
}
  • pthread_create: 创建一个新线程,并指定其要执行的函数(线程例程)。
  • pthread_join: 等待一个线程终止(类似于 waitpid)。

线程化回声服务器

以下是线程化回声服务器的核心思路:

int main() {
    while (1) {
        int connfd = Accept(listenfd, ...);
        int *connfdp = Malloc(sizeof(int)); // 关键步骤:在堆上分配内存
        *connfdp = connfd;
        pthread_create(&tid, NULL, echo_thread, connfdp); // 传递指针
    }
}

void *echo_thread(void *vargp) {
    int connfd = *((int *)vargp); // 从堆内存中取出连接描述符
    Pthread_detach(pthread_self()); // 设置为分离状态,无需被join
    Free(vargp); // 释放堆内存
    // ... 处理echo ...
    Close(connfd);
    return NULL;
}

为什么需要 Malloc
这是一个关键点,用于避免竞争条件。如果我们将主线程栈上局部变量 connfd 的地址传递给新线程,考虑以下情况:

  1. 主线程接受连接A,connfd 被设为A的描述符。
  2. 主线程创建线程1,传递 &connfd
  3. 在线程1来得及读取 *connfd 之前,主循环继续,接受连接B,connfd覆盖为B的描述符。
  4. 线程1读取 connfd,实际得到的是B的描述符,导致错误。

通过在堆上为每个连接描述符分配独立的内存,并将这块内存的指针传递给线程,我们确保了每个线程访问的是自己专属的数据,消除了这种数据竞争。

线程模型:可连接 vs. 分离

  • 可连接线程:创建者需要调用 pthread_join 来等待其结束并回收资源。否则会产生类似“僵尸线程”的资源滞留。
  • 分离线程:系统在线程终止时自动回收其资源,创建者无需也不应调用 join。对于像服务器这样不关心线程返回状态的情况,使用分离线程更合适。

总结

本节课中我们一起学习了并发编程的基本概念和挑战,包括竞争条件、死锁和饥饿。我们分析了迭代服务器的局限性,并探讨了三种实现并发服务器的方法:基于进程、基于事件和基于线程。我们重点深入了基于线程的方法,学习了Pthreads的基本用法,并理解了如何通过在线程间正确传递数据(如在堆上分配内存)来避免常见的竞争条件错误。线程提供了轻量级的并发执行和便捷的数据共享能力,但同时也引入了同步和正确性方面的复杂性,这是我们后续课程将继续探讨的主题。

29:同步基础 🧵

在本节课中,我们将要学习多线程编程中的核心概念——同步。我们将探讨为什么当多个线程并发执行时,程序可能无法正常工作,并介绍如何使用信号量(Semaphore)这一基本工具来协调线程,确保数据的一致性和程序的正确性。


线程与共享内存

上一节我们介绍了多线程的基本概念,本节中我们来看看线程之间如何共享数据。

一个进程的传统视图包含其虚拟内存中的所有内容(代码、全局数据、堆、栈)以及CPU寄存器等状态。在多线程模型中,每个线程拥有自己独立的寄存器集合和栈,但它们共享进程的虚拟地址空间,包括代码、全局数据和堆。

这意味着,任何线程理论上都可以通过指针访问虚拟地址空间中的任何位置,包括其他线程栈上的局部变量。因此,判断一个变量是否被“共享”,关键在于是否有多个线程引用了该变量的同一个实例

以下是判断变量是否在线程间共享的规则:

  • 全局变量:在函数外定义。所有线程共享同一个实例。
  • 局部自动变量:在函数内定义(非static)。每个线程拥有自己的私有副本。
  • 局部静态变量:在函数内用static关键字定义。所有调用该函数的线程共享这一个实例。

关键在于,即使变量在栈上(局部变量),如果其地址被传递给其他线程(例如通过全局指针),那么该变量实际上也被共享了。


竞态条件与不安全的执行轨迹

当多个线程共享数据并尝试修改它时,如果没有协调机制,就可能出现竞态条件,导致结果错误。

考虑一个简单的例子:两个线程并发地对一个全局变量cnt执行cnt++操作。在C语言层面,这看似是一个操作,但在机器指令层面,它通常分解为三个步骤:

  1. Load (L):将cnt的值从内存加载到寄存器。
  2. Update (U):递增寄存器中的值。
  3. Store (S):将寄存器的值存回cnt所在的内存。

如果两个线程的L、U、S指令序列交错执行,就可能出现问题。

错误交错示例

  1. 线程1:L (读取cnt=0到寄存器R1)
  2. 线程1:U (R1 = 1)
  3. 线程2:L (读取cnt=0到寄存器R2) // 问题:线程2读取了旧值
  4. 线程1:S (将R1=1写入内存,cnt=1)
  5. 线程2:U (R2 = 1)
  6. 线程2:S (将R2=1写入内存,cnt=1)

最终cnt的值为1,而不是正确的2。导致cnt++这个操作并非原子性的。

我们可以用进度图来可视化这个问题。以两个线程为例,横轴和纵轴分别代表两个线程的执行进度。线程的完整执行路径(轨迹)是从左下角到右上角的一条路径。图中存在一个“不安全区”,任何穿越该区域的轨迹都会导致错误的cnt值。


使用信号量实现同步

为了解决竞态条件,我们需要确保一次只有一个线程能进入修改共享变量的代码区域,这个区域称为临界区。实现这种互斥访问的经典工具是信号量

信号量s是一个非负整型变量,只能通过两个特殊的原子操作来访问:

  • P(s) (或wait): 如果s > 0,则将s减1并立即继续;如果s == 0,则挂起线程等待,直到s > 0
  • V(s) (或post): 将s加1。如果有线程在P(s)上等待,则其中一个会被唤醒并完成其P操作。

其核心保证是:s的值永远不会为负

用信号量保护临界区
我们可以初始化一个信号量mutex = 1,并将其视为一把锁。线程在进入临界区前执行P(mutex)(尝试获取锁),在离开临界区后执行V(mutex)(释放锁)。

#include <semaphore.h>
sem_t mutex; // 声明信号量
Sem_init(&mutex, 0, 1); // 初始化为1

// 线程函数
for (i = 0; i < niters; i++) {
    P(&mutex); // 获取锁
    cnt++;     // 临界区
    V(&mutex); // 释放锁
}

通过这种方式,我们为进度图中的“不安全区”创建了一个“禁止区”,任何执行轨迹都无法进入,从而保证了结果的正确性。

性能注意:互斥锁会强制部分代码串行执行,可能显著降低程序速度,但这是保证正确性所必需的代价。

互斥锁 (Mutex):二进制信号量(值仅为0或1)的使用非常普遍,因此Pthreads等库提供了专门的互斥锁数据类型(pthread_mutex_t)及lockunlock操作。其原理与二进制信号量相同,但通常有更高的优化效率。


总结

本节课中我们一起学习了多线程同步的基础知识。

  1. 理解共享:线程间共享数据不仅限于全局变量。任何被多个线程通过指针引用的内存区域(包括其他线程的栈)都是共享的,这可能导致复杂的交互。
  2. 认识竞态条件:当多个线程非同步地读写共享数据时,由于指令交错的随机性,会产生不可预测且通常错误的结果。cnt++这样的操作不是原子的。
  3. 使用信号量/互斥锁:为了确保正确性,我们必须使用同步原语。信号量(特别是作为互斥锁使用时)提供了P/V(或wait/post)这一对原子操作,能够保证临界区的互斥访问,从而消除竞态条件。

同步是多线程编程中最核心也最具挑战性的部分之一。下一讲我们将探讨信号量的其他用途,例如用于调度共享资源的访问顺序。

30:高级同步技术

在本节课中,我们将继续探讨同步技术,特别是信号量的高级应用,包括生产者-消费者问题、读者-写者问题,以及线程安全等核心概念。我们将学习如何使用这些技术来协调多个线程,避免竞态条件和死锁。

信号量回顾

上一节我们介绍了同步的基本概念和信号量这一原语。本节中,我们来看看信号量更复杂的应用。

信号量由荷兰计算机科学家Edsgar Dijkstra提出,它是一个保证始终大于或等于零的特殊变量。对信号量有两种操作:P(或称为wait)和V(或称为signal)。

  • P操作:等待直到信号量值非零,然后将其减一。这个操作是原子的,意味着在检查信号量值和递减操作之间,其他线程无法介入。
  • V操作:将信号量值加一。这个操作同样是原子的。

之前展示的P操作代码使用了忙等待循环。在实际实现中,如果线程执行P操作时信号量为零,调度器会将该线程从核心上移出,并调度其他线程运行,从而提高效率。

信号量最基本的用途是实现互斥,保护临界区——我们不希望多个线程同时执行的代码区域,通常涉及对共享数据结构(如队列或列表)的更新。

生产者-消费者问题

现在,我们来看信号量的另一种经典应用:生产者-消费者模型。

在这个模型中,一部分代码(生产者)生成数据,另一部分代码(消费者)使用这些数据。我们需要协调它们的行为,使得消费者在生产者生成数据之前等待,有时也需要生产者等待消费者处理完数据后再填充新数据。

以下是使用两个信号量实现一个单槽缓冲区的示例:

  • empty:表示缓冲区是否为空(1为空,0为非空)。
  • full:表示缓冲区是否已满(1为满,0为非满)。

初始化时,缓冲区为空,所以empty初始化为1,full初始化为0。

生产者线程的伪代码逻辑如下:

P(empty); // 等待缓冲区为空
将数据放入缓冲区;
V(full);  // 通知缓冲区已满

消费者线程的伪代码逻辑如下:

P(full);  // 等待缓冲区有数据
从缓冲区取出数据;
V(empty); // 通知缓冲区为空

这种模式可以推广到多个生产者和消费者,以及容量为N的缓冲区。对于N槽缓冲区,我们使用三个信号量:

  • mutex:保护对缓冲区数据结构的访问(互斥锁)。
  • slots:计数可用的空槽数量,初始值为N。
  • items:计数缓冲区中已填充的项目数量,初始值为0。

向N槽缓冲区插入数据的逻辑如下:

P(slots);    // 等待至少有一个空槽可用
P(mutex);    // 获取互斥锁以安全访问缓冲区
将数据放入缓冲区;
V(mutex);    // 释放互斥锁
V(items);    // 通知有一个新项目可用

从N槽缓冲区移除数据的逻辑如下:

P(items);    // 等待至少有一个项目可用
P(mutex);    // 获取互斥锁以安全访问缓冲区
从缓冲区取出数据;
V(mutex);    // 释放互斥锁
V(slots);    // 通知有一个新空槽可用

关键点P(slots)P(mutex)的操作顺序至关重要。如果先获取互斥锁再等待空槽,可能导致死锁。想象生产者持有互斥锁但无空槽可用而等待,此时消费者因无法获取互斥锁而不能消费数据释放空槽。

读者-写者问题

接下来,我们探讨另一个经典同步问题:读者-写者问题。这是互斥锁的一种泛化。

考虑一个场景,例如管理航班可用座位的数据。许多“读者”可能只想查看座位情况(不修改),而“写者”则需要更新座位信息。我们希望允许多个读者同时读取,但写者必须独占访问(即,要么只有一个写者,要么有多个读者且没有写者)。

第一类解决方案(读者优先)
这个方案简单,但可能导致写者饥饿(长时间等待)。它使用两个信号量:

  • mutex:保护读者计数readcnt
  • w:作为读写锁,由第一个进入的读者获取,由最后一个离开的读者释放,阻止写者进入。

读者代码逻辑

P(mutex);
readcnt++;
if (readcnt == 1) // 我是第一个读者
    P(w);         // 阻止写者
V(mutex);

执行读操作;

P(mutex);
readcnt--;
if (readcnt == 0) // 我是最后一个读者
    V(w);         // 允许写者
V(mutex);

写者代码逻辑

P(w);         // 获取读写锁
执行写操作;
V(w);         // 释放读写锁

这个方案的问题在于,只要持续有读者到达,写者可能被无限期阻塞。

第二类解决方案(写者优先)
这个方案更复杂,通过引入额外的信号量来让写者优先,防止读者饿死写者,但可能导致读者饥饿。其核心思想是,当有写者等待时,阻止新的读者进入。

还有第三类公平解决方案,例如使用队列(FIFO)来管理请求,确保读者和写者按照到达顺序被服务,避免任何一方饥饿。

竞态条件、死锁与线程安全

在并发编程中,我们必须警惕几个关键问题。

竞态条件是指程序的结果依赖于两个或多个事件不可预测的执行顺序。并非所有竞态都是错误,但许多会导致程序状态不一致,例如之前演示的未受保护的计数器递增操作。

死锁是指一组线程彼此等待对方持有的资源,导致所有线程都无法继续执行。一个典型的死锁例子是多个线程以不同的顺序获取锁。

例如,线程A先锁lock1再锁lock2,而线程B先锁lock2再锁lock1。如果执行时机不当,两者可能各自持有一把锁并等待另一把,形成死锁。

避免死锁的黄金法则:在所有线程中以相同的全局顺序获取锁。

线程安全是指代码可以在多线程环境中安全使用,即使多个线程并发调用它。许多旧版库函数不是线程安全的,原因包括:

  1. 访问未受保护的共享全局或静态变量。
  2. 返回指向静态缓冲区的指针(如旧版inet_ntoa函数)。

使非线程安全代码变得安全的技术包括:

  • 加锁-复制:用互斥锁保护非安全函数,并将结果复制到线程私有内存。
  • 使用可重入版本:可重入函数不依赖任何共享数据,仅通过参数传递状态(如rand_r对比rand)。所有可重入函数都是线程安全的,但线程安全函数不一定是可重入的(例如,通过加锁实现的线程安全函数)。

现代标准库函数(如malloc, free)通常是线程安全的,它们内部使用锁来实现。在编写或使用库函数时,需要注意其线程安全性,特别是在多线程程序中。

总结

本节课中我们一起学习了同步技术的高级主题。我们深入探讨了如何使用信号量解决生产者-消费者读者-写者这两个经典并发问题,理解了不同解决方案的权衡。我们还明确了竞态条件死锁的危害,并学习了通过按固定顺序获取锁来避免死锁。最后,我们讨论了线程安全的重要性,区分了线程安全函数与可重入函数,并介绍了使非线程安全代码变得安全的技术。掌握这些概念对于编写正确、高效的多线程程序至关重要。

31:线程级并行

在本节课中,我们将学习线程级并行。我们将简要回顾并行硬件架构,然后讨论多线程环境下的内存一致性模型。最后,我们将探讨如何通过在一个应用程序中使用多个线程来编程,以获得性能提升,并使用一些简单的例子来说明其中的一些问题。

并行硬件架构回顾

上一节我们介绍了课程概述,本节中我们来看看并行硬件的基础结构。

下图展示了一个多核处理器的简化模型。每个核心都有自己的一组寄存器、L1数据缓存和L1指令缓存。它们共享一个最后一级缓存(LLC),并连接到主内存。

[核心1: 寄存器 -> L1 D$ / L1 I$] \
                                    -> 共享LLC -> 主内存
[核心2: 寄存器 -> L1 D$ / L1 I$] /

我们还讨论过乱序处理器的结构。其核心思想是,指令不是按顺序一条条执行,而是被解码后放入一个操作队列。这个队列中的指令会在功能单元可用时被分派执行。例如,一个处理器可能有两个整数单元、一个浮点单元和一个加载/存储单元。加载/存储单元可以访问数据缓存。这种设计提高了性能,因为多个功能单元可以并行工作,并且当一条指令(如缓存未命中)被阻塞时,后续不依赖它的指令可以继续执行。硬件会跟踪所有依赖关系,以决定哪些指令可以乱序执行。

超线程技术

超线程是并行的一种特定形式。在典型的Intel处理器上,通常是双路超线程,如下图所示。其基本思想是,在一个物理核心上复制指令控制机制(如程序计数器和操作队列),从而模拟出两个逻辑处理器。

逻辑处理器A: [PC, 操作队列, 寄存器文件]
逻辑处理器B: [PC, 操作队列, 寄存器文件]
                共享功能单元和缓存

那么,为什么在已经有了乱序执行和多个功能单元之后,还需要超线程呢?原因在于,当一个线程因为等待I/O或发生页错误等长时间操作而停滞时,单个指令流中可能没有足够多的独立指令来保持所有功能单元忙碌。此时,切换到第二个线程可以更好地利用硬件资源。硬件设计者发现,对于大多数工作负载,双路超线程是性价比最高的选择,超过两个线程带来的收益会递减。我们的实验机器(Shark)就支持双路超线程,并且有8个物理核心。

内存一致性模型

现在,我们进入今天的核心话题之一:内存一致性模型。当我们有多个线程在同一个地址空间内并发地读写内存时,如果没有协调机制,结果将是一片混乱。为了给程序员提供一个可以推理的抽象模型,硬件和软件之间需要约定一个“契约”,这就是内存一致性模型。

不一致性的问题

假设没有缓存一致性机制(例如某些GPU的暂存内存),考虑以下场景:主内存初始值 A=1, B=100

  • 线程1执行:A = 2; print(B);
  • 线程2执行:B = 200; print(A);

可能发生的情况是:线程1将A=2写入自己的缓存,线程2将B=200写入自己的缓存。由于缓存不协调,当线程2读取A时,它可能从主内存拿到旧的A=1。同样,线程1可能读到旧的B=100。因此,打印结果可能是 1100,这显然不符合程序员的预期。

顺序一致性

顺序一致性是最直观、对程序员最友好的内存模型。它规定:所有线程的所有内存操作看起来像是按某种顺序一个接一个地执行的,并且这个顺序与每个线程自身的程序顺序一致

对于上面的例子,由于每个线程内部的操作必须按程序顺序(写在前,读在后),所以可能的全局执行顺序是有限的。例如,写A -> 写B -> 读B -> 读A 是一个有效的顺序,结果是打印 2002。而 读B -> 读A -> 写A -> 写B 则不是一个有效的顺序,因为它违反了线程的程序顺序。不可能出现两个读操作都发生在任何写操作之前的情况,因此打印出 1100 在顺序一致性下是被禁止的。

内存一致性模型是硬件对软件的承诺,它限制了内存状态可能出现的混乱情况。

现实世界的挑战:弱一致性模型

然而,维护严格的顺序一致性会带来巨大的性能开销,因为它限制了硬件(尤其是乱序执行)和缓存系统的优化。因此,现实中的处理器(如Intel和ARM)通常提供弱于顺序一致性的一致性模型。

乱序执行处理器可能会为了性能而重排指令,即使缓存是协调的。例如,线程2的指令队列可能先执行 print(A),再执行 B = 200,导致打印出旧值。

为了在弱一致性模型中强制保证特定顺序,程序员需要使用内存屏障(或栅栏)指令。例如,在写操作和读操作之间插入 sfence 指令,可以告诉硬件这两个操作的顺序必须被遵守。

// 线程1
A = 2;
sfence(); // 内存屏障
print(B);

// 线程2
B = 200;
sfence(); // 内存屏障
print(A);

在理想世界中,硬件能快速提供顺序一致性。但在现实中,为了性能,我们不得不在代码中谨慎地使用内存屏障。

缓存一致性协议:窥探缓存

虽然处理器可能采用弱一致性模型,但它们通常会保证缓存的一致性,即所有核心看到的内存视图最终是一致的。一种经典的方法是窥探缓存协议。

每个缓存行除了数据,还有一个状态标签。一个重要状态是独占:表示该核心拥有该缓存行的唯一、可写的副本。

  1. 写操作:当核心1想写变量A时,它首先通过总线请求A所在缓存行的独占权。获得后,在本地缓存中修改A。
  2. 读操作:当核心2后来想读A时,它发现本地缓存没有A(或不是有效副本),于是发送读请求到总线。
  3. 窥探与响应:核心1的缓存控制器一直在“窥探”总线,看到对A的读请求后,意识到自己持有独占副本。于是,它将A的最新值提供给总线,并将自己的缓存行状态从“独占”改为“共享”。核心2收到这个值,也将A以“共享”状态存入缓存。
  4. 共享状态:现在两个核心都以“共享”状态持有A。在共享状态下,多个核心可以同时持有该缓存行的只读副本。
  5. 再次写操作:如果某个核心(如核心1)想再次写A,它必须重新请求独占权。这个请求会使其他核心(如核心2)中A的缓存行失效。核心1获得独占权后,才能进行写操作。

通过这种方式,即使缓存是分散的,系统也能保证所有核心最终看到一致的数据。当最后一个持有缓存行副本的核心需要将其驱逐出缓存时,如果它是被修改过的(处于独占或已修改状态),它必须将数据写回主内存。

因此,要确保顺序一致性,我们需要正确的缓存一致性行为,同时也需要线程内的顺序约束(有时需要通过插入内存屏障来实现)。

线程级并行编程实践

接下来,我们通过一个简单的例子来学习如何编写多线程程序以提升性能。我们的目标是并行求和:计算从0到N-1所有整数的和。

我们将创建K个线程,每个线程负责求和一段连续的子范围。如果N不能被K整除,最后剩余的部分由主线程串行处理。

方法一:更新全局变量(三种变体)

第一种尝试是让所有线程直接更新一个全局变量 global_sum

以下是三种实现方式:

  1. 无同步:直接累加 global_sum += i
  2. 使用信号量:在累加操作前后使用信号量进行加锁/解锁。
  3. 使用互斥锁:使用二值信号量(即互斥锁)进行保护。

性能结果

  • 无同步:速度有提升(最高约2.86倍加速比),但得到了错误的答案。原因是 global_sum += i 不是原子操作,多个线程同时读写导致数据竞争。
  • 使用信号量/互斥锁:得到了正确结果,但性能极差(从2.5秒变为约10分钟)。原因是在紧密循环中频繁调用锁操作开销巨大。

显然,让所有线程竞争更新一个共享变量不是好办法。

方法二:局部累加到数组

我们改进策略:每个线程将结果累加到一个全局数组中自己独有的位置 psum[thread_id]。最后,主线程再将这些部分和相加。

我们引入一个 spacing 参数,用于控制 psum 数组中各元素之间的间隔。

  • spacing = 1 时,psum[0], psum[1], psum[2]... 在内存中是连续的。
  • spacing > 1 时,例如 spacing = 8,每个线程的结果会被存储到相隔较远的位置。

性能结果

  • spacing = 1(绿色曲线):获得了正确的答案,并且有了约5倍的加速比。因为每个线程写的是独立的内存位置,没有数据竞争,所以不需要锁。
  • spacing = 8(红色曲线):性能进一步提升,获得了约13倍的加速比。

为什么 spacing 会影响性能? 这引出了两个重要概念:

  • 真共享:多个线程读写同一个内存位置。我们的方法二已经避免了这一点。
  • 伪共享:多个线程频繁更新同一个缓存行中的不同变量。当 spacing=1 时,不同线程的 psum 元素很可能位于同一个64字节的缓存行中。线程0更新 psum[0] 会使该缓存行在其核心中处于独占状态,导致线程1更新 psum[1] 时发生缓存失效和 coherence miss,引发严重的“缓存乒乓”现象,损害性能。将 spacing 设为8(假设缓存行64字节,psum 为8字节),可以确保每个线程的累加器位于不同的缓存行,从而消除伪共享。

方法三:寄存器累加

更优的方法是让每个线程在寄存器中累加局部变量 local_sum,只在计算结束后将结果一次性写入全局数组 psum。这完全避免了在循环中访问共享内存。

性能结果(绿色曲线):比方法二中最好的情况(spacing=8)还要快约2倍,获得了约7.5倍的加速比。

性能评估注意事项:衡量并行加速比时,应该与最优的串行实现进行比较,而不是与“并行代码但只运行一个线程”的情况比较。因为并行代码本身可能带有额外开销(如线程创建、管理),只有当线程数足够多时,其收益才能覆盖这些开销并体现出优势。

本节编程实践总结

  • 共享内存访问开销大,应尽量避免。
  • 注意真共享(数据竞争)和伪共享(缓存行竞争)。
  • 尽可能使用寄存器或局部变量。
  • 利用局部性,提高缓存命中率。
  • 公平地划分任务,妥善处理剩余部分。
  • 评估性能时,与最优串行算法对比。

更复杂的例子:并行快速排序

现在,我们来看一个更复杂的例子:并行快速排序。快速排序本身具有分治特性,天然适合并行化。

串行快速排序回顾

  1. 选择一个基准值。
  2. 将数组划分为两部分:小于等于基准值的 L 和大于基准值的 R
  3. 递归排序 LR

并行化策略

并行化的思路很直接:在递归划分后,对两个子数组 LR 的排序可以并行进行。

  • 任务队列:将每个排序任务(即对一个子数组排序)视为一个独立任务,放入任务队列。
  • 线程池:工作线程从队列中取出任务执行。
  • 递归生成任务:如果一个任务要排序的数组大小超过阈值,它就进行划分,并创建两个新的子任务(分别排序 LR)放入队列。
  • 串行基线:当子数组大小小于某个阈值时,直接调用高效的串行排序算法(如插入排序),以避免创建过多微小任务带来的开销。

性能与阿姆达尔定律

即使采用了并行递归排序,其加速比仍然有限(实验中最佳为6.84倍,而机器有8核16线程)。瓶颈主要在于划分操作本身是串行的。在算法的顶层,第一次划分是串行的;在下一层,两个划分可以并行;再下一层,四个划分可以并行,依此类推。

这引出了阿姆达尔定律,它描述了并行加速比的上限:
加速比 = 1 / ((1 - P) + P / S)
其中:

  • P:可被并行化的部分所占比例。
  • S:并行部分的加速比。

假设快速排序中,划分部分(不可并行)占10%,排序部分(可并行)占90%。即使排序部分能获得完美的16倍加速(S=16),整体加速比也只有 1 / (0.1 + 0.9/16) ≈ 5.9。如果划分部分占比更大,瓶颈将更严重。

为了突破阿姆达尔定律的限制,必须对划分操作本身进行并行化。这可以通过更复杂的算法实现,例如:

  1. 选择多个基准值进行桶划分。
  2. 使用并行前缀和等算法进行高效的分区合并。

在更大的机器或不同的问题规模上,这些高级并行算法可以带来接近线性的加速比。

课程总结

本节课中我们一起学习了线程级并行的核心知识:

  1. 硬件基础:回顾了多核、乱序执行、超线程等硬件并行支持。
  2. 内存模型:理解了顺序一致性的理想模型,以及现实中弱一致性模型带来的挑战(需要内存屏障)。
  3. 缓存一致性:学习了窥探缓存等协议如何保证多核缓存的数据一致性。
  4. 并行编程:通过求和的例子,实践了如何避免数据竞争、伪共享,并利用局部性优化性能。
  5. 实际案例:分析了并行快速排序的实现和性能瓶颈,认识了阿姆达尔定律对并行加速比的根本性限制。

关键要点:编写高效并行程序需要综合考虑算法、数据布局、硬件特性和同步开销。在当今多核普及的时代,理解这些原理对于开发高性能软件至关重要。

32:计算的未来 I

在本节课中,我们将探讨计算机硬件技术的发展趋势,特别是摩尔定律的历史、现状及其面临的物理和经济极限。我们还将对比超级计算机与数据中心这两种高性能计算模式的特点与挑战。

摩尔定律的起源与影响

上一节我们介绍了课程即将结束的背景。本节中,我们来看看计算技术发展的一个核心驱动力:摩尔定律。

戈登·摩尔是英特尔公司的联合创始人之一。早在1965年,他在《电子学》杂志上发表文章,基于当时有限的数据点(1962年和1965年)做出了一个大胆预测。他乐观地估计,到1975年,单个芯片上能够集成的晶体管数量将达到65,000个。他最初认为晶体管数量每年会翻一番,但不久后修正为每两年翻一番。这个预测并非自然法则,而是基于技术可行性和经济成本的权衡。芯片制造商可以在一定技术极限内提高电路密度,但超越某个点后,制造成本会急剧上升。

摩尔定律的核心是:在保持单位制造成本大致不变的前提下,芯片上可容纳的晶体管数量大约每两年增加一倍。

摩尔定律的验证与演变

以下是自1971年首款微处理器(Intel 4004)问世以来,不同类型芯片的晶体管数量增长趋势:

  • 嵌入式系统:如早期的计算器芯片和如今的智能手机处理器。
  • 桌面系统:个人电脑的中央处理器。
  • 图形处理单元:用于高性能计算的GPU。
  • 服务器:数据中心使用的处理器。

将这些数据绘制在半对数坐标图上,可以看到一条清晰的增长趋势线。实际数据与摩尔最初的预测线基本平行,证实了晶体管数量大约每两年翻一番的规律,这一趋势已持续了50多年。

技术进步的规模效应

这种指数级增长带来了惊人的性能对比。1976年的Cray-1超级计算机被认为是当时的巅峰之作,重约5,000公斤,功耗115千瓦,售价900万美元,总共生产了80台。相比之下,如今的一部智能手机(如搭载A11芯片的iPhone)重量仅几百克,峰值功耗约5瓦,售价约1000美元,其主芯片集成了约43亿个晶体管。仅在发布后的首个黑色星期五,就有约900万部此类手机售出。

这种规模经济效应至关重要。巨大的销售量产生了巨额利润,这些利润又被重新投资于技术研发,从而生产出更强大的产品,进一步刺激消费,形成一个良性循环。如今,消费电子产品(如智能手机)而非小众的超级计算机,已成为驱动半导体行业前进的主要力量。

未来的挑战:尺寸与密度

如果我们简单地将摩尔定律的趋势外推到2065年(即自1965年起100年后),会得到约10^17个晶体管的数字。然而,仅靠二维平面缩放无法实现这一目标。

首先,芯片面积不能无限增大。如果保持当前芯片面积的增长趋势,到2065年芯片将大如几张美元钞票,这不适合便携设备。其次,更关键的是晶体管尺寸的缩小。目前最先进的制造工艺号称达到10纳米级别。如果继续按历史趋势缩小线宽,到2065年将达到243皮米的尺度,这已经小于氢原子(约74皮米)和硅原子(约500皮米)的间距,违反了物理规律。

因此,未来的增长必须寻求新的维度。

三维集成与新的可能性

一种可能的解决方案是三维堆叠技术。假设我们允许一个封装体积相当于三枚叠放的镍币(约5毫米高,20毫米见方),并能在其中集成10万个逻辑层(每层物理厚度约50纳米),那么要达到10^17个晶体管,所需的平面线宽约为20纳米。这虽然比当前技术小一个数量级,但至少不违背已知物理定律。

然而,三维集成面临多重严峻挑战:

  1. 制造成本:当前芯片制造需要约60道光刻步骤。如果层数增加到百万级,沿用现有逐层光刻的方法,成本将无法承受。需要革命性的制造技术,例如类似现代3D NAND闪存的多层同步加工技术。
  2. 缺陷容忍:当器件数量达到10^17,层数达到百万级时,保证所有层都没有缺陷几乎不可能。需要系统架构能够容忍大量故障单元。
  3. 功耗与散热:这是最根本的挑战。便携设备功耗必须很低(例如几瓦),否则会产生无法忍受的热量。人脑功耗约15瓦,但神经元工作频率极低(约100赫兹)。未来技术可能需要在架构和材料上进行根本性革新以实现超低功耗。

经济与产业集中化

技术进步严重依赖巨大的资本投入。一张图表显示,有能力制造最先进芯片的公司数量已从过去的十几家减少到如今的仅四家:英特尔、三星、台积电和格罗方德。产业高度集中源于建设一座先进晶圆厂需要数百亿美元投资,只有通过海量出货(数百万乃至数十亿颗芯片)才能摊薄成本。这种集中化可能削弱市场竞争,对未来创新构成风险。

登纳德缩放定律的终结

除了密度缩放,另一个关键趋势是登纳德缩放定律的终结。罗伯特·登纳德在1974年提出,当晶体管尺寸按比例缩小K倍,同时工作电压也降低K倍时,芯片性能将提升K倍,而功耗保持不变。这解释了为何过去能在提升性能的同时控制功耗。

然而,大约在2004年,由于硅材料的物理限制,电压无法继续降低,登纳德缩放失效了。这导致单核处理器的主频停止增长(停留在2-4 GHz范围)。为了继续利用日益增多的晶体管,行业转向了多核架构。这不是因为人们迫切需要多核,而是因为无法让单核跑得更快。

高性能计算的两种范式

现在让我们转向计算的另一个前沿:高性能计算。当今最大的计算系统主要分为两类,它们的目标和架构截然不同。

超级计算机(如“泰坦”)专为解决最复杂的计算问题(如宇宙模拟、流体动力学、流行病传播建模)而设计。其编程模型通常是“整体同步并行”,将计算域划分为网格,分配到各个节点,循环进行“计算-通信”步骤。这种模式对负载均衡极其敏感,且编程复杂,通常需要混合使用MPI(节点间通信)、多线程(节点内CPU核心)及CUDA等(GPU编程)。

数据中心(如谷歌、亚马逊的云设施)则旨在服务海量用户,处理多样化的任务,核心是数据管理和提取价值。它们由大量商用硬件组成,强调可扩展性、成本效益和快速部署,而非极致的单任务性能。其编程模型(如MapReduce)更关注数据流和容错。

融合的趋势与挑战

目前,这两种范式呈现出融合趋势。模拟科学需要融入更多观测数据进行分析,而数据分析(特别是深度学习训练)的计算强度越来越高,越来越像超级计算任务。

然而,将两者硬件和软件体系融合面临巨大挑战。超级计算机的同步模型无法容忍数据中心硬件(如磁盘)的高延迟和易故障特性;而将超级计算机的专用编程模型移植到松散耦合的数据中心集群上也异常困难。如何构建能同时高效处理大规模模拟和海量数据分析的统一平台,是当前的重要挑战。

总结

本节课中我们一起学习了计算硬件的未来。我们回顾了摩尔定律的历史性成功,以及它当前在物理尺寸、功耗和经济成本方面遇到的根本性极限。未来增长可能需要依赖三维集成等新范式。我们还探讨了高性能计算领域超级计算机与数据中心两种范式的区别、各自的挑战以及它们正在融合的趋势。这些发展预示着,未来的计算技术、架构和编程方式都可能与我们今天所熟悉的截然不同。

33:计算的未来 II 🚀

在本节课中,我们将探讨大规模机器学习(或称“大学习”)的未来,特别是其独特的计算特性,以及如何利用这些特性来构建更高效的计算系统。我们将从系统设计的角度,分析现有框架的局限性,并介绍一系列旨在加速大学习任务的新颖优化技术。


概述:大学习的独特之处 🤔

在上一节中,我们讨论了计算领域的未来趋势。本节我们将继续这一主题,重点关注近年来主导云计算和数据中心计算周期的大规模机器学习,特别是深度网络。我们将探讨这种计算有何特殊之处,使其区别于过去几十年的其他计算,以及如何利用这些特性来构建更好的计算系统。


什么是大数据?📊

许多人过去十年都听说过“大数据”。大数据现象带来了巨大的炒作,但也蕴含着巨大的价值。然而,我们将在整个大数据领域中,专注于“对大数据进行机器学习”这一部分。

以下是一些属于这类应用的例子:

  • 推荐系统:根据你的喜好推荐电影或书籍,这被称为协同过滤。
  • 主题建模:将新闻文章等集合根据其中的词汇聚类成不同主题。
  • 分类问题:通常可以建模为回归问题,特别是多类回归问题。
  • 深度网络:如上节课讨论的卷积神经网络。
  • 其他迭代式数据分析:如计算网页的PageRank。

我们将从计算系统的角度来看待这个主题。目标是建立一个框架,让机器学习专家能够轻松表达他们想要进行的计算,并且该框架能在大型和小型集群上都能提供良好的性能。


现有框架回顾 🔄

上一节我们介绍了一些现有框架。这里我们快速回顾一下,以便大家跟上进度。

Hadoop 🗺️

Hadoop本质上是一个分布式文件系统,附带资源调度器。它使用MapReduce计算风格,可以轻松地将大型数据计算映射到多台机器上。

以下是其工作原理:系统中有多个节点,每个节点上都有输入数据。它们首先执行用户提供的Map函数,生成中间数据。然后,所有信息进行交换,使得同类数据最终位于同一个节点上。接着,在该同类数据上执行用户提供的Reduce函数,产生最终输出。

Hadoop大约从2006年开始成为一种非常成功的方法。许多基础的大数据分析问题都可以套用这个框架。它之所以成功,主要是因为易用性,而不是因为它提供了任何合理的性能。

GraphLab 📈

GraphLab也由Randy在上节课提到过,它出自CMU。它引入的一个概念是,将图上的计算视为在由节点和边组成的图上进行的计算。节点和边都有属性,你可以更新它们。

计算以“像顶点一样思考”的框架来表达:从一个节点的视角出发,基于其邻居节点的值以及连接边的权重进行一些计算。这种编程风格非常适合某些类型的计算,比如PageRank,你需要查看邻居页面的排名,并基于这些排名更新自己的排名。

GraphLab取得成功的关键在于,这种图并行抽象(特别是“像顶点一样思考”的抽象)对于特定风格的计算来说,比MapReduce更容易使用。另一个原因是它的性能显著更好。早期的一个三角形计数任务结果显示,在Hadoop上需要1500台机器运行7小时的任务,在GraphLab上只需64台机器运行1.5分钟。

GraphLab 2.0解决了高度数顶点(如拥有大量Twitter关注者的人)的问题。它将高度数顶点分割成多个子顶点,使得整体度数降低,然后应用“收集-累积-散射”过程来处理。

GraphChi项目则探索了在单机上运行这些大数据问题。它重新设计了机器学习算法的处理方式,使其即使在需要扫描SSD时也能保持良好的性能。结果显示,在一台数据存储在SSD上的Mac mini上,其性能几乎与一个10节点的Hadoop安装相当。这使得即使在单机上也能进行快速的大学习。

Spark ⚡

Spark的出现是为了解决Hadoop运行速度过慢的问题。Hadoop速度慢的原因之一是,它在每个Map和Reduce阶段之后都会将数据写入磁盘并从磁盘读取,这是为了在机器崩溃时确保工作不丢失。

Spark团队发现,还有其他方法可以获得持久性。他们提出,如果我们将计算限制在那些对其输入进行确定性转换的操作上(即每次对特定输入运行Map都会得到相同结果),那么我们只需跟踪操作的“谱系图”。如果某台机器崩溃,我们只需重新运行受影响的部分,而其他部分仍然完好。通过这种方式,可以实现快速恢复,并且完全在内存中操作。

Spark项目的另一个有趣之处在于社区对其突破性进展的响应速度。他们在2009年提出研究想法,2011年发布第一个开源代码,2014年就被纳入所有主要的Hadoop发行版中。

从路线图来看,成功的关键在于:首先,要有足够显著的改进以吸引人们关注;其次,持续进行研发创新以保持领先;然后,通常会衍生出初创公司来生产高质量的代码;最后,与加入的工业伙伴合作,因为开源特性对工业伙伴有吸引力。

这应该对你们所有人都是一个鼓励,无论是在你们未来的职业生涯中,还是在你们的高级项目里,在计算系统和软件领域产生影响的门槛可能从未如此之低。


大学习的独特计算特性 🧠

与刚才描述的系统相比,我们决定探索这些计算任务本身的特性。我们的想法是,尝试发现大学习(特别是训练算法)有何独特之处。训练算法是那些需要运行数天、数周甚至数月的算法。那么,这些算法有什么特别之处,能让我们做出比现有方法更智能的事情呢?

数学视角:模型拟合 📐

要从系统角度思考大学习的特殊性,首先需要从数学角度审视这些计算在做什么。这些计算的关键组成部分是尝试将模型拟合到某些数据上。因为如果你能将模型拟合到训练数据,那么假设当你获得真实数据或测试数据时,它也能很好地预测。

你有一堆带标签的训练数据和一个模型。模型只是一个数学表达式或方程组,其中包含参数。以最简单的线性回归为例:你有一堆点,想画一条线穿过这些点以最小化误差。模型规定它必须是一条线,而参数决定了这条线的斜率。

然后,有一个目标函数,你试图最小化或最大化它。对于线性回归,目标函数通常是最小化所有点到这条线的距离平方和。对于分类问题,你试图根据一条分类线将点分成两组,目标可能是最小化错误分类的项目数量。

与线性回归情况不同,这些问题通常没有封闭形式的解。因此,算法是迭代的:它们遍历训练数据,将其与模型进行比较,查看哪些参数可以调整,以使得模型更好地拟合训练数据。

这类问题通常使用随机梯度下降的多种变体来解决。你可以将随机梯度下降视为在参数选择上的优化函数。你从某个初始解开始,通过一系列迭代步骤向最小值移动,每一步都根据训练数据微调参数以改善拟合。

上节课的幻灯片强调了这些是迭代数值算法。如果你线性地增加模型大小,同时也线性地增加训练数据,那么训练工作量会呈二次方增长。这就是为什么这些任务可能需要10天甚至更长时间的原因之一。


挑战与机遇:坏消息与好消息 ⚖️

坏消息:计算与通信的挑战 🐢

首先谈谈坏消息。坏消息是,需要大量的计算和内存。你需要进行多次迭代,反复遍历数据。这些模型很大,参数众多。对于这些非常大的模型,你实际上需要将它们分布到多台机器上,在云中或数据中心运行。这带来了通信和同步的成本。不同节点之间的通信成本远高于单机内核心之间的通信。

如果你必须广泛地拆分计算,而拆分后的部分又不需要相互通信,那问题还不大。但不幸的是,这些模型的工作方式导致参数之间相互影响,存在大量互联。因此,这不是一个可以完美划分的案例,对通信和同步的需求很大。

这种组合意味着训练非常缓慢,即使在多台机器上也需要数小时、数天甚至数周。这对研究人员来说,挑战即是机遇。

好消息:算法的固有特性 🎯

好消息在于这些训练算法的工作方式。每个工作线程(每台机器上的每个工作线程)拥有一些训练数据。它查看那部分训练数据,读取与之相关的模型参数,然后将其向期望的方向“微调”。具体来说,它提供一个增量(delta),比如给这个参数加1,或给那个参数减0.5,而不是直接指定新值应为7。

因为这些总是增量,所以更新是可交换的(忽略浮点问题,在这个场景下可以忽略,因为我们只是沿着凸函数的斜坡向下走,浮点误差不会阻止我们到达目标)。

另一个有趣的思考点是,你可以选择最直接的路径到达底部,也可以选择一条不那么直接的路径。就像滑雪下山一样,无论哪种方式你最终都会到达底部,只是来回次数不同。从系统角度来看,这意味着实际上不需要像某些大型科学模拟那样严格同步参数更新。参数更新存在对“惰性一致性”的自然容忍度。

如果我读取参数时使用的是稍旧的值,也没关系。我们还识别出了其他一些特性。关键在于,由于这些好消息,我们可以克服一些性能挑战,使整个训练过程比原本快几个数量级。


参数服务器:一种新的编程抽象 🖥️

在上节课和本节课中,我们都讨论了用于大规模计算的各种编程抽象和框架。我们提到了MapReduce,提到了图并行(“像顶点一样思考”),这里我们介绍一个在CMU发明的称为“参数服务器”的抽象。

参数服务器是一种分布式共享内存编程风格。在这种风格中,工作线程将参数集视为存储在某种共享内存中,允许它读取和更新。例如,如果你在单机上的代码是这样的:读取变量的旧值,应用某个函数来计算如何改变该变量,然后将增量加回变量。在参数服务器模型中,代码看起来完全一样,只是我们将这些读写操作包装成了参数服务器读取和参数递增/更新调用。

因此,即使代码跨越多个节点,其编写方式对于任何习惯在单机上编写并行代码的人来说都非常自然。更重要的是,你可以像数学公式那样编写代码,机器学习算法的步骤通常就是用数学定义的,你不需要翻译成其他格式。

解决同步问题:有界陈旧性 🕰️

但我们仍然不希望有这种批量同步的问题。既然我们说过不需要沿着最陡的路径下山,可以曲折前进,因此对一致性要求不那么严格。那么,一个直接的想法是:如果这是真的,为什么不干脆移除所有同步,完全异步运行呢?

这听起来是个好主意,但实际发生的情况是整个过程会发散。你不仅没有下山,反而可能冲下悬崖。在实践中,事情往往会失控,根本不收敛。此外,大多数算法都有某种收敛保证,批量同步的方法具有这个特性,但完全异步的方法没有,你无法确信算法会收敛,事实上它常常不收敛。

因此,我们提出了一种介于这两个极端之间的方法。我们引入了“陈旧性界限”的概念。它规定最快和最慢的线程最多只能相差S次迭代,这是可以接受的。但如果任何线程比最慢的线程领先超过S次迭代,那么这个线程就需要等待,或者去读取最新的值以赶上进度。

这样做的好处是,至少它节省了通信和同步的开销,因为你只需要每S步关心一次同步。但实际上,它比这更好。想象一场势均力敌的赛马。在整个比赛过程中,最快的马从未领先最慢的马超过一个固定距离。同样,在这种情况下,如果“赛马”始终保持紧凑,我们可能在整个计算过程中都不需要被迫等待。

协议是这样的:每个工作线程维护一个参数值的本地缓存。当它想读取一个参数时,先查看缓存。缓存条目带有迭代编号的标记。如果陈旧度(领先的迭代数)超过界限S,则认为它太旧了,需要从网络中读取最新版本;否则,可以直接使用缓存版本,无需任何协调。

选择S值是一个寻找“甜点”的过程。这既利用了更新可交换的特性,也利用了对惰性一致性的容忍度。甜点之所以出现,是因为最直接的路径(最陡下降)要求你一直同步。整个系统每一步都进展良好,但每一步都被最慢的线程拖累。所以,步数最少的路径反而是最慢的路径。

图表显示,如果采用批量同步,每次迭代都很慢。从迭代速度来看,最快的是完全异步(此时S为无穷大),迭代飞速进行,因为没有人同步;而批量同步的迭代则缓慢爬行,因为你每次都必须同步。另一方面,如果你一直同步,那么每次迭代在目标函数上的进展效果是最好的。你只需要更少的步骤就能下山,但每一步都更慢。相反,完全异步的方法(在这个例子中看起来收敛了)每次迭代的进展最小。

当你结合这两个竞争因素时,会发现像S=3这样的中间值能带来最佳结果。


利用可预测的访问模式 🎯

接下来我想讨论的是重复的参数数据访问模式。我将以PageRank为例来说明。

假设有三个页面,页面之间存在链接。算法非常简单:将排名初始化为一些随机值,然后重复循环直到达到某种收敛概念。对于每个从I到J的链接,读取I的排名,并用它来更新J的排名。

处理都是关于边或链接的。你将所有链接分配给所有工作线程。在这个简单例子中,我们有两个工作线程。工作线程0负责链接0和1。从工作线程0的角度看,循环意味着:首先处理链接0,它需要读取页面2的排名,并用它来更新页面0的排名;然后处理链接1,读取页面1的排名,更新页面2的排名。

关键是,尽管更新值取决于排名的实际值,但访问模式是不变的。每次都是读取这个,更新那个。从计算系统的角度来看,这是理想模式,因为它完全可预测。

因此,我们可以利用这种完全可预测的访问模式,在系统中玩一些技巧。例如,预取:在实际需要读取值之前,提前将数据取到缓存中。你还可以决定缓存中保留什么。既然确切知道访问模式,就不必依赖LRU等启发式方法,可以在需要驱逐某些内容时做出智能决策。

优化数据放置 🗂️

另一个技巧是跨机器的预测性数据放置。参数服务器的实现方式是将键空间(参数空间)随机分片,分布到不同的机器上(通常与工作线程在同一台机器上)。每个机器上的参数服务器分片负责存储全局参数服务器的一部分参数。

既然我们知道访问模式(例如,这个工作线程读取黄色、红色、蓝色;那个工作线程读取绿色、红色、蓝色),我们就可以进行优化:将绿色参数放在经常访问绿色的机器上,将红色参数放在经常访问红色的机器上。这样可以减少需要通过网络发送的数据量。

当我们实施这些优化时,获得了相当不错的加速。例如,在一个协同过滤任务(矩阵分解,推荐电影)中,我们在8台机器上运行。未优化前,训练耗时152秒;应用了所有利用访问模式知识的优化后,时间降至36秒。与GraphLab在相同任务上的507秒相比,我们有显著优势。经过99次迭代后,我们仍然保持领先。最终,这一系列优化使速度提升了4.5倍,比GraphLab快11倍。


处理掉队者问题 🐌

接下来谈谈掉队者问题。我说过,采用有界陈旧性模型在减少同步和通信量方面是一个巨大的胜利。如果“赛马”势均力敌,你可能永远不需要停止。但如果赛马不势均力敌呢?如果10万匹马中,总有一匹落后呢?那么,即使你每S次迭代才等待一次,你仍然会被拖慢。

因此,我们提出了另一种技术来解决这个问题。最简单的做法是:如果某个工作线程运行落后,我们将其部分工作卸载给其他线程,这样它需要做的工作减少,就不会拖慢整个迭代。在Hadoop和Spark中,通常的做法是克隆:当你发现某个工作线程变慢时,你启动另一个工作线程执行完全相同的任务,如果第二个也慢了,就启动第三个,依此类推。你赌的是其他工作线程不会成为掉队者。

但问题在于,克隆打破了这些机器学习算法的一些数学假设。虽然这些计算对某些事情有很大的灵活性,但对其他类型的事情则没有容忍度。克隆技术因为这一点而无效。我们需要一种能确保没有两个副本同时运行的方法。

基于迭代进度的动态负载均衡 ⚖️

我们将利用以下特性:在一般的分布式计算中,很难知道你落后了多少,因为存在各种变化和控制流。但在这个场景中,你每次都在遍历相同的输入序列(训练数据)。遍历训练数据的进度是衡量你进展速度的一个非常好的指标。

我们的做法是:当某个工作线程完成了其训练数据的75%时,它会联系几个邻居节点,询问它们的进度。如果发现某个节点远远落后(比如只完成了40%),我们不会等到所有其他节点都完成才发现它慢,而是立即介入解决。

第二个关键是,在一般的计算中,你需要按特定顺序执行任务,不能随意将计算块交给其他工作线程。但在这个世界里,训练数据就是训练数据。工作线程可以以任何顺序处理训练数据,这无关紧要。因此,我可以轻松地识别出:“把我最后10%的训练数据给你。”然后另一个工作线程开始处理那部分。如果它完成后发现原工作线程仍然落后,可以再接手20%,依此类推。这完全是乱序处理,但没关系。

这使我们能够实现非常简单的动态负载均衡。当我们这样做时,可以看到每次迭代的时间显著减少,新方法比原始方法快大约两倍。在不同的EC2机器类型和Azure实例上都观察到了类似的结果。


参数更新的重要性优先级 📈

下一个特性是参数更新的重要性。我有所有这些想要通信的更新,但机器之间的网络带宽有限,是瓶颈。在一般计算中,你很难知道如何优先处理机器之间的消息集合。但在这个世界里,有一个有趣的现象:更新量的大小是重要性一个非常好的信号。

这意味着,如果我是一个工作线程,正在考虑要发送哪些更新,我应该从最大的更新开始发送。那些是最重要、需要最快传递的更新。因此,我们优先传输较大的参数变更,同时确保仍在陈旧性界限内,并且不超过带宽限制。

这样做带来了不错的改进。图表显示,采用我们开发的名为“Bosen”系统的技术(绿线),相比之前仅使用有界陈旧性的方法(红线),以及完全异步的方法(虽然最终收敛但耗时很长),性能有所提升。


深度神经网络与GPU 🧠➡️🎮

深度神经网络用于语音翻译、图像和视频中的物体识别、人脸检测等任务。和之前一样,每个工作线程获得一部分训练数据(如图像和标签)。我们有M个工作线程,以及一个网络模型。参数服务器在概念上持有这个网络。

上节课提到,每一层执行的功能类似于密集矩阵乘法,因此非常适合GPU。通常这些任务在GPU上运行。现在我们希望在GPU上运行参数服务器。

在训练过程中,图像在底部,你进行前向传播,计算各层的值。关键点是,它是成对层进行的:你使用一层的输出作为下一层的输入,计算新值,一直向上传播到顶层进行分类,比较误差,然后基于这些误差将更新反向传播到这些边上的权重(即参数)。然后重复这个过程。

需要注意的重要一点是,它是成对层进行的,你不需要同时将模型的所有参数都放在GPU内存中。因此,我们的做法是利用GPU内存,一次只将成对的层调入调出,而模型的其余部分则存放在通常比GPU内存大得多的CPU内存中。我们在节点间运行参数服务器,获得了相当不错的结果。

当时最先进的技术是Caffe的单GPU性能,他们还没有分布式实现。我们能够在16台机器上获得接近线性的加速(13倍),虽然没有完全达到16倍,但已经非常可观。


跨数据中心训练与不显著更新过滤 🌐

到目前为止,机器学习分布式深度学习训练都是在单个数据集、单个数据仓库、单个数据中心内进行的。你处理的是受控数据中心的特性。但是,数据、视频、图像等并非在数据中心生成,而是在智能手机、监控设备等世界各地生成。现有方法忽略的一步是:如何将世界各地生成的所有数据汇集到你的数据中心?

事实证明,随着视频和摄像头越来越多,这样做变得极其昂贵和缓慢。此外,由于国际隐私法规(如欧盟关于数据跨境传输的规定),通常甚至不允许将原始数据(如图像、医疗图像)发送到数据中心。

因此,你必须做的是跨广域网进行训练,而不仅仅是在数据中心内部。数据将留在原地,遍布全球。问题在于,广域网的速度极其缓慢,带宽与数据中心内部相比非常有限。即使在大型数据中心之间,带宽平均也只有数据中心内部的1/15到1/80,延迟更高,而且费用昂贵。

我们的关键想法是,在数据中心内部和跨数据中心之间使用不同的同步模型。在数据中心内部,我们像之前一样划分训练数据,运行参数服务器,使用我们讨论过的模型。但不同之处在于,我们得出的模型只是近似正确的(以一种明确定义的方式),因为我们不经常看到其他数据中心和其他数据对相同参数的更新。

关键想法是,在跨数据中心通信时,我们要更加节约。我们将利用之前提到的特性:最大的更新是最重要的。关键观察是,我们跟踪每个参数的累积增量。只有当累积增量达到参数总值的1%时,我们才发送它。任何小于1%的更新,我们都会累积,直到达到1%才发送。这就是我们所谓的“消除不显著更新”。

好消息是,事实证明,工作线程想要进行的大约97%的更新都小于1%的变化。所以,当我们运行这些参数服务器时,它们大部分时间都在通信,但说的都是“微调”。如果我们只要求它们在变化至少达到1%时才通信,那么我们可以消除95%的通信。

当我们这样做时,得到了如下结果:在11个EC2数据中心上跨广域网运行(蓝条)是基线。我们采用新技术后(橙条)的速度要快得多(平均快3-6倍)。我们几乎达到了仿佛在单个数据中心内运行的速度,尽管我们是在11个性能很差的数据中心上运行。我们达到了单个数据中心运行速度的40%以上。关于成本,传输数据的费用比计算本身更昂贵。通过减少数据传输,我们节省了2到8倍的成本。


利用现货实例降低成本 💰

最后要讨论的是利用现货实例。像Amazon EC2这样的数据中心提供商,如果你想要保证资源使用,需要按需付费。但他们也有一些闲置资源,可以以极低的价格(比按需价格低85-90%)提供给你,但不保证资源不会被随时收回。

价格曲线显示,如果你想要确保机器永不消失,你需要支付按需价格。但如果你参与现货市场,你可以以低得多的价格获得资源。你出价某个价格,一旦市场价格超过你的出价,你就会被驱逐。

我们在这个名为“Icarus”的系统中,首先有一种处理机器大规模被驱逐的方法。其次,我们学习了一个模型,指导我们如何出价以实现目标:以尽可能低的价格在合理的时间内完成计算。

系统有一个特点:如果你在持有资源的第一小时内被驱逐,它是免费的。所以我们的目标和出价策略是:出一个价格,使我们能完成大部分工作,然后在接近结束时被驱逐。这样我们就能获得免费计算。通常,我们可以通过这个技巧获得大约30%的免费计算。

总体来看,我们的系统将成本从虚线降低到实线水平,运行时间也略有改善。我们通过积极竞标廉价免费资源,并采用跨机器类型多样化等标准市场策略来确保不会一次性失去所有资源。


总结 🎓

本节课中,我们一起探索了大学习的许多独特之处。从计算系统的视角,我们看到了如何在不改变机器学习算法本身的前提下,利用其固有特性(如更新可交换性、对陈旧的容忍度、可预测的访问模式、更新重要性差异等)来显著加速训练过程。我们回顾了参数服务器、有界陈旧性、动态负载均衡、优先级通信、跨数据中心优化以及成本优化等一系列技术。希望本课程对你们来说是时间的良好利用,并且相信这门课所学的知识,将对你们未来在CMU或其他任何地方学习任何计算机系统课程都有所帮助。

祝大家期末考试顺利,圆满完成本学期的学习!

线程与同步:第34:线程死锁问题分析与解决 🧵

在本节课中,我们将学习一个关于线程和同步的经典问题。我们将分析一个模拟助教(TA)办公时间的场景,识别其中可能出现的死锁情况,并探讨如何通过调整资源获取顺序来避免死锁。

问题描述

假设有一个新的助教办公时间设置。学生进入房间后,会看到一张桌子上放着若干支笔和若干本笔记本。学生需要遵循以下流程:

  1. 等待被叫到。
  2. 领取一支笔和一个笔记本。
  3. 用笔在笔记本上写下问题。
  4. 写完问题后,释放笔。
  5. 拿着笔记本,等待助教提供帮助。
  6. 与助教讨论后,再次用笔在笔记本上写下解决方案。
  7. 完成后,释放笔记本和笔,然后离开。

这个场景中有多种资源:笔、笔记本和助教。学生是使用这些资源的线程。我们需要设计一种使用锁的算法来管理这些资源,确保不会发生死锁。

初始代码与死锁现象

主函数 main 的职责是初始化资源并创建学生线程。其逻辑如下:

  • 初始化资源:例如,5支笔,5个笔记本,3位助教,50名学生。
  • 创建50个线程来代表50名学生。
  • 启动这些线程并等待它们全部完成。

以下是主函数的简化表示:

int main() {
    // 初始化资源:5支笔,5个笔记本,3位助教
    initialize_resources(5, 5, 3);
    // 创建50个学生线程
    for (int i = 0; i < 50; i++) {
        pthread_create(&student_threads[i], NULL, student_routine, NULL);
    }
    // 等待所有学生线程结束
    for (int i = 0; i < 50; i++) {
        pthread_join(student_threads[i], NULL);
    }
    return 0;
}

学生线程(即工作线程)的初始实现大部分时间运行正常,但偶尔会发生死锁。我们的核心任务是分析这个死锁的根源。

死锁原因分析

问题在于:死锁是由哪对资源之间的竞争引起的?

  • A:死锁由笔和笔记本引起。
  • B:死锁由笔记本和助教引起。
  • C:死锁由助教和笔引起。

让我们逐一分析。

上一节我们介绍了三种可能的死锁组合,本节中我们来详细分析每一种情况。

分析选项C(助教与笔)
观察学生线程的代码流程:先获取笔锁,释放笔锁,然后获取助教锁,释放助教锁,最后再次获取笔锁。这里的关键是,获取笔锁和获取助教锁的操作没有重叠。这意味着,一个正在与助教交谈的学生(持有助教锁)不可能同时又在等待笔锁;反之,一个持有笔锁的学生也不可能在等待助教锁。因此,这两者之间无法形成循环等待,不会死锁。

分析选项B(笔记本与助教)
代码中,获取笔记本锁和获取助教锁的操作存在重叠。一个持有笔记本的学生可能在等待助教。但是,一个正在与助教交谈的学生(持有助教锁)已经拥有了笔记本,因此不会再去等待笔记本。同样,这无法构成“持有并等待”的循环链,所以也不会死锁。

分析选项A(笔与笔记本)
这是唯一存在重叠且可能形成循环等待的组合。一个持有笔记本的学生可能在等待笔;同时,一个持有笔的学生也可能在等待笔记本。尽管有5支笔和5个笔记本,在极端并发情况下仍可能发生死锁。

死锁场景推演

为了更清晰地理解选项A,让我们推演一个具体的死锁发生场景。

假设前5名学生进入房间,他们拿走了全部5支笔和全部5个笔记本。

  1. 这5名学生写完问题后,释放笔,并开始等待助教。
  2. 此时,后续的5名学生进入,他们拿到了刚刚被释放的5支笔,但他们需要等待笔记本(因为笔记本还被前5名学生持有)。
  3. 前5名学生与助教讨论完毕,需要再次拿笔来写答案。但他们发现笔已被后5名学生持有。
  4. 后5名学生需要笔记本才能继续,但笔记本被前5名学生持有。
  5. 于是形成僵局:前5名学生持有笔记本,等待笔;后5名学生持有笔,等待笔记本。双方互相等待,无人能继续执行,死锁发生

因此,正确答案是 A。死锁发生在笔和笔记本资源之间。

解决方案:锁顺序化

既然我们找到了死锁根源,那么如何在不改变学生操作流程的前提下修复这个问题呢?

解决方案是实施锁的顺序化。我们规定一个全局的、一致的资源获取顺序。例如,我们要求所有线程都必须先获取笔记本锁,再获取笔锁。相应地,释放锁时则按相反顺序进行(先释放笔锁,再释放笔记本锁)。

以下是修改后的学生线程逻辑示意:

void* student_routine(void* arg) {
    // 1. 先获取笔记本锁
    pthread_mutex_lock(¬ebook_lock);
    // 2. 再获取笔锁
    pthread_mutex_lock(&pen_lock);
    // ... 使用笔和笔记本写问题 ...
    pthread_mutex_unlock(&pen_lock); // 先释放笔
    // ... 等待并咨询助教 ...
    pthread_mutex_lock(&pen_lock);   // 再次获取笔(仍需遵循先笔记本后笔的顺序,但此时已持有笔记本锁)
    // ... 使用笔写答案 ...
    pthread_mutex_unlock(&pen_lock); // 释放笔
    pthread_mutex_unlock(¬ebook_lock); // 最后释放笔记本
    return NULL;
}

通过强制规定“先笔记本,后笔”的获取顺序,我们打破了循环等待的条件。现在,一个持有笔的学生必然已经持有了笔记本,因此不可能出现“持笔等本”的情况。虽然获取助教锁的操作与它们仍有重叠,但关键的笔-笔记本循环链已被消除。

核心原则与总结

本节课中我们一起学习了如何分析并解决一个经典的线程死锁问题。

核心原则

  1. 死锁分析:当代码中不同锁的获取范围存在重叠时,需要警惕可能形成的循环等待链。分析时,要检查一个线程在持有锁A时,是否会去请求锁B;同时,另一个线程在持有锁B时,是否会去请求锁A。
  2. 死锁预防:一种有效的方法是锁顺序化。为所有锁定义一个全局的获取顺序(例如,锁A的优先级始终高于锁B),并要求所有线程都严格遵守这个顺序。这可以预防循环等待的发生。相应的,释放锁通常按相反顺序进行以保持代码清晰。

总结
我们从一个模拟办公时间的多线程程序出发,通过逐步分析锁的获取与释放重叠区域,定位到死锁发生在笔和笔记本资源之间。通过推演具体并发场景,我们证实了死锁发生的可能性。最后,我们通过引入“先获取笔记本锁,后获取笔锁”的全局顺序,成功解决了死锁问题。掌握锁顺序化这一原则,对于编写正确、高效的多线程程序至关重要。

35:虚拟内存问题解析

在本节课中,我们将学习如何解析典型的虚拟内存问题。我们将从虚拟地址出发,逐步将其转换为物理地址,并最终获取数据。整个过程涉及地址位的划分、TLB和页表的查询,以及缓存的访问。


地址划分基础

虚拟内存问题通常始于对系统配置的描述。这包括虚拟地址位数、物理地址位数、页大小、TLB描述以及可能的缓存描述。

首先,我们需要确定虚拟地址空间和物理地址空间的大小。有时题目会直接给出总大小,此时只需计算以2为底的对数即可得到位数。一旦确定了位数,就可以进入下一步。

通常,题目会提供三个不同的表格,它们在后续解题过程中至关重要。


虚拟地址的分解

作为热身,我们通常需要标记地址的不同部分。一个虚拟地址主要包含四个重要部分:VPOVPNTLBITLBT

VPO 代表页内偏移量。要确定表示所有可能偏移量所需的位数,我们需要查看页大小。例如,如果页大小为512字节,则需要9位来表示,因为 2^9 = 512。

VPN 是虚拟页号。在确定了VPO之后,地址的剩余部分就是VPN。

然而,我们还需要为TLB进一步分解VPN。TLB本质上是一个用于地址转换的缓存,因此VPN需要被分解为 TLBITLBT

TLBI 是TLB索引。我们需要知道TLB有多少个索引项。例如,如果TLB有2个索引,则只需要1位来表示TLBI。

TLBT 是TLB标签,它是VPN中除去TLBI后剩余的部分。

这个过程与期中考试中解析缓存地址的过程完全相同,只是数字不同。


物理地址的构建与缓存解析

我们的最终目标是将虚拟地址转换为物理地址,并可能获取数据。因此,我们也需要标记物理地址的组成部分:PPOPPN缓存偏移量缓存索引缓存标签

PPOVPO 完全相同,可以直接复制过来。

PPN 是物理页号,即物理地址中除去PPO后的部分。

接下来,我们需要根据缓存结构来分解物理地址。假设缓存有4个组,每组有4字节数据。那么:

  • 表示4字节数据需要 2位 缓存偏移量。
  • 表示4个组需要 2位 缓存索引。
  • 剩余的所有高位就是 缓存标签

如果能正确完成以上地址划分,就完成了80%的工作,剩下的就是查表。


实战演练:地址转换与数据访问

让我们通过一个例子来实践。首先,将给定的十六进制虚拟地址转换为二进制表示。在紧张的考试中,建议写下这个转换过程,以避免低级错误。

假设虚拟地址是 0x6A4

  1. 提取VPN:根据之前划分的位域,提取出VPN部分,并转换回十六进制,例如得到 0x6A
  2. 查询TLB:使用TLBI位在TLB表中找到对应的行,然后检查该行的TLBT是否匹配。如果匹配且有效位为1,则TLB命中,我们可以获得对应的PPN(例如 0x3)。
  3. 组合物理地址:将得到的PPN与原始的VPO(即PPO)组合,就形成了完整的物理地址。
  4. 访问缓存获取数据:如果问题要求获取该地址的数据,我们需要进一步分解物理地址。
    • 使用缓存偏移量位确定缓存行内的字节位置。
    • 使用缓存索引位确定缓存组。
    • 在对应的缓存组中,检查缓存标签是否匹配且有效位为1。如果命中,则根据缓存偏移量读取相应的字节值(例如 0xFF)。

整个过程可以总结为以下几个步骤:获取地址、将其分解为小块、使用不同的部分查询不同的表格,最后以新的方式将所有部分重新组合起来。


深入讨论与常见问题

在解析过程中,可能会遇到一些特殊情况。

TLB未命中和页表查询:如果TLB未命中(标签不匹配或有效位为0),则需要查询页表。使用VPN在页表中查找对应的页表项。如果该页表项有效,则获取PPN并更新TLB(如果需要)。如果页表项无效或不存在,则发生页错误

重要提示:不能仅因为页表(题目给出的部分页表)中没有某个VPN,就断定会发生页错误。必须先在TLB中检查。只有TLB未命中在完整的页表中也找不到有效项时,才发生页错误。

物理地址的缓存解析:在获得物理地址后,我们将其视为一个全新的地址进行缓存解析。这与虚拟地址的TLB解析是独立的步骤。缓存偏移量和索引来自于物理地址的低位,而标签来自于物理地址的高位。


总结

本节课中,我们一起学习了虚拟内存问题的完整解析流程。我们从系统配置描述开始,学习了如何划分虚拟地址和物理地址的各个位域,包括VPO、VPN、TLBI、TLBT、PPO、PPN以及缓存的偏移量、索引和标签。我们通过一个例子,演练了从虚拟地址转换到物理地址,再到通过缓存获取数据的每一步。最后,我们讨论了TLB未命中、页表查询和页错误等关键概念。掌握这个流程,你就能系统地解决考试中遇到的虚拟内存相关问题。

36:I/O与进程 🖥️

在本节课中,我们将学习I/O操作与进程管理之间的交互。核心内容包括理解文件描述符、dup2系统调用、文件偏移量以及fork创建子进程时这些概念如何表现。我们将通过一个具体的代码示例来分析这些交互行为。


文件描述符与文件偏移量 📄

上一节我们介绍了进程的基本概念,本节中我们来看看进程如何通过文件描述符进行I/O操作。

每个进程都有一个文件描述符表,它是一个指向文件对象的指针数组。文件对象存储了文件的偏移量等信息。文件本身则存储在磁盘上。

当我们执行readwrite系统调用时,内核会:

  1. 根据文件描述符(如fd1)在进程的文件描述符表中找到对应的文件对象。
  2. 从该文件对象中读取当前的文件偏移量
  3. 从磁盘文件中该偏移量处读取或写入指定字节数。
  4. 更新文件对象中的偏移量(增加已读/写的字节数)。

核心公式新偏移量 = 旧偏移量 + 读取/写入的字节数


代码示例分析 🔍

现在,我们通过一个具体例子来分析I/O与进程的交互。假设有一个文本文件foo.txt,内容是从az的字母。

以下是示例代码的核心逻辑:

// 打开同一个文件三次,获得三个文件描述符
fd1 = open("foo.txt", O_RDONLY);
fd2 = open("foo.txt", O_RDONLY);
fd3 = open("foo.txt", O_RDONLY);

char c;

// 从fd1读取一个字符
read(fd1, &c, 1); // c = 'a'
// 从fd2读取一个字符
read(fd2, &c, 1); // c = 'a' (独立偏移量)
// 从fd3读取一个字符
read(fd3, &c, 1); // c = 'a' (独立偏移量)

// 再次从fd2读取
read(fd2, &c, 1); // c = 'b' (fd2的偏移量前进了)

关键点

  • 每次open调用都会创建一个新的文件对象,拥有独立的文件偏移量。因此,fd1fd2fd3的初始偏移量都是0。
  • read操作会更新对应文件对象的偏移量。

fork与文件描述符的共享 👥

当我们调用fork()创建子进程时,子进程会获得父进程文件描述符表的一份副本。这意味着父子进程的文件描述符指向相同的文件对象

以下是需要理解的行为:

  • 文件偏移量共享:由于指向同一个文件对象,父子进程中任一进程的read/write操作都会影响该对象的偏移量,从而影响另一个进程。
  • 文件描述符操作独立dup2close等操作只修改调用进程自身文件描述符表中的指针,不影响另一个进程。

类比:想象父子进程各自拿着一张地图(文件描述符表)的复印件,地图上标记着宝藏(文件对象)的位置。如果他们中的一人移动了宝藏(修改了文件偏移量),另一张地图上标记的位置虽然没变,但根据标记去找时,会发现宝藏已经移动了。但如果一人把自己的地图上的标记擦掉或改标到另一个宝藏上(dup2close),这完全不会影响另一人的地图。


结合dup2的复杂场景分析 🧩

dup2(oldfd, newfd)系统调用使newfd成为oldfd的副本,即两者指向同一个文件对象。之后操作newfdoldfd效果相同。

考虑在fork后,父子进程执行以下操作(接续之前的代码):

pid_t pid = fork();
if (pid == 0) {
    // 子进程
    read(fd1, &c, 1); putchar(c); // 输出?受之前父进程读取影响。
    dup2(fd1, fd2); // 使fd2也指向fd1的文件对象
    read(fd3, &c, 1); putchar(c); // 输出?fd3之前通过dup2指向fd2。
} else {
    // 父进程
    // 假设父进程先执行
    read(fd2, &c, 1); putchar(c);
    read(fd2, &c, 1); putchar(c);
    read(fd1, &c, 1); putchar(c);
    wait(NULL);
}

分析此类问题的步骤:

  1. 绘制状态图:为每个进程(父、子)列出其文件描述符表,标明每个描述符指向哪个文件对象,并记录每个文件对象的当前偏移量。
  2. 跟踪操作:按代码顺序(注意并发时顺序可能任意)逐步执行readwritedup2close
    • read/write:更新被操作文件描述符所指向的文件对象的偏移量。
    • dup2(a, b):将进程自身描述符表中b的条目改为指向a当前所指向的文件对象。
    • close:将进程自身描述符表中的对应条目置为空。
  3. 注意并发:若无wait,父子进程输出可能交错。考试中通常会问“某个特定输出是否可能”。

核心要点总结 📝

本节课中我们一起学习了I/O与进程交互的核心机制:

  1. 三层抽象:文件描述符 -> 文件对象(含偏移量)-> 磁盘文件。
  2. open与偏移量:每次open创建新文件对象,偏移量从0开始。
  3. fork的行为:子进程复制父进程的文件描述符表,因此共享相同的文件对象
  4. 操作的影响
    • read/write影响文件对象(偏移量),对所有共享该对象的进程可见。
    • dup2close只影响调用进程自身的文件描述符表
  5. 分析方法:通过绘制文件描述符、文件对象和偏移量的关系图,可以清晰地分析复杂的I/O与进程交互问题。

理解这些概念对于掌握系统级编程中资源的管理与共享至关重要。

37:Malloc 基础概念与算法 🧠

在本章中,我们将学习动态内存分配器(如 malloc)的核心概念。我们将了解不同的分配算法、内存碎片化问题以及如何组织空闲内存块。这些知识是理解内存管理的基础。

概述

动态内存分配是程序运行时从堆中请求和释放内存的过程。malloc 是实现这一功能的库函数。理解其工作原理需要掌握分配算法、内存块的组织方式以及碎片化的概念。

分配算法

分配算法决定了如何从空闲内存块列表中选择一个块来满足分配请求。以下是几种常见的算法:

  • 首次适应:从空闲列表的头部开始扫描,选择第一个足够大的块。
  • 下一次适应:从上一次搜索结束的地方开始扫描,选择第一个足够大的块。
  • 最佳适应:扫描整个空闲列表,选择大小最接近请求大小的块。
  • 足够适应:选择第一个足够大的块,但不一定是最佳的。

在代码实现中,这些算法体现在 find_fit 函数中,通过遍历空闲链表并应用不同的选择策略来实现。

内存碎片化

即使有足够的空闲内存总量,也可能因为内存被分割成许多小块而无法满足一个大的分配请求,这种现象称为碎片化。

  • 内部碎片:发生在已分配的内存块内部。这是由于块头开销、对齐填充或最小块大小限制导致的浪费。例如,请求 20 字节,但分配器由于对齐要求给出了 32 字节的块,那么就有 12 字节的内部碎片。
    • 计算公式内部碎片 = 分配的块总大小 - 用户实际请求的有效载荷大小
  • 外部碎片:发生在已分配的内存块之间。这是由分散在堆中各处的空闲内存块造成的。即使所有空闲块的总和足够大,但由于它们不连续,也无法用于一个大的连续分配请求。

空闲块组织方式

分配器需要高效地跟踪空闲内存块。主要有三种组织方式:

  • 隐式空闲链表:通过每个块头部的大小字段,将所有块(已分配和空闲)串联成一个链表。要找到空闲块,需要顺序扫描所有块。
  • 显式空闲链表:只将空闲块通过 prevnext 指针连接成双向链表。这使得搜索空闲块更快。
  • 分离空闲链表:维护多个显式空闲链表,每个链表中的块大小属于一个特定范围(例如,小尺寸、中尺寸、大尺寸)。这可以进一步加快搜索速度。

实例分析:首次适应算法

现在,我们通过一个具体例子来应用上述概念。假设我们有一个动态内存分配器,其规则如下:

  • 按 16 字节对齐。
  • 立即合并被释放的块。
  • 已分配块没有脚部(footer)。
  • 最小块大小为 32 字节(包含头部开销后)。
  • 使用首次适应算法。

我们执行以下操作序列(“分配A(32)”表示分配 32 字节的有效载荷):

  1. 分配A(32)
  2. 分配B(16)
  3. 分配C(16)
  4. 分配D(40)
  5. 释放C
  6. 释放A
  7. 分配E(16)
  8. 释放D
  9. 分配F(48)
  10. 释放B

逐步分析:

  1. 分配A(32):请求 32 字节。加上 8 字节头部后为 40 字节。40 不是 16 的倍数,向上取整为 48 字节。由于最小块大小为 32,且 48 > 32,因此分配一个 48 字节的块。状态:[A:48]
  2. 分配B(16):请求 16 字节。加上头部为 24 字节。24 < 最小块大小 32,因此向上取整为 32 字节。分配一个 32 字节的块。状态:[A:48] [B:32]
  3. 分配C(16):与 B 相同,分配 32 字节块。状态:[A:48] [B:32] [C:32]
  4. 分配D(40):请求 40 字节。加上头部为 48 字节。48 是 16 的倍数,因此直接分配 48 字节块。状态:[A:48] [B:32] [C:32] [D:48]
  5. 释放C:C 是第三个块(32字节)。将其释放并标记为空闲。由于是第一个被释放的块,它位于空闲列表头部。状态:[A:48] [B:32] [C:32-free] [D:48],空闲列表:C
  6. 释放A:A 是第一个块(48字节)。将其释放。根据规则(立即合并),检查A前后是否有空闲块。A前面是堆起始,后面是已分配的B,因此无法合并。将A放入空闲列表头部。状态:[A:48-free] [B:32] [C:32-free] [D:48],空闲列表:A -> C
  7. 分配E(16):请求 16 字节。需要块大小 = 向上取整(16+8) = 32 字节。使用首次适应:检查空闲列表头部 A(48字节)。48 >= 32,可以分配。尝试分割:剩余部分 = 48 - 32 = 16 字节。16 < 最小块大小 32,因此不分割,将整个 48 字节块分配给 E。状态:[E:48] [B:32] [C:32-free] [D:48],空闲列表:C
  8. 释放D:D 是第四个块(48字节)。将其释放。检查合并:D 的前一个块 C 是空闲的(32字节),因此立即向后合并,形成一个 80 字节的大空闲块。状态:[E:48] [B:32] [CD:80-free],空闲列表:CD
  9. 分配F(48):请求 48 字节。需要块大小 = 向上取整(48+8) = 64 字节(因为56不是16的倍数)。使用首次适应:检查空闲列表头部 CD(80字节)。80 >= 64,可以分配。尝试分割:剩余部分 = 80 - 64 = 16 字节。16 < 最小块大小 32,因此不分割,将整个 80 字节块分配给 F。状态:[E:48] [B:32] [F:80],空闲列表:(空)
  10. 释放B:B 是第二个块(32字节)。将其释放。检查合并:B 前面是已分配的 E,后面是已分配的 F,无法合并。状态:[E:48] [B:32-free] [F:80],空闲列表:B

最终堆布局[E:48] [B:32-free] [F:80]

碎片化分析(最终状态)

  • 内部碎片:查看已分配块 E 和 F。
    • 块 E:请求 16 字节,实际分配 48 字节。内部碎片 = 48 - (16 + 8头部) = 24 字节?等等,这里需要澄清:用户请求的是16字节有效载荷。我们分配的48字节是整个块的大小,其中包含了8字节头部。因此,浪费的空间 = 块总大小 - 头部 - 请求大小 = 48 - 8 - 16 = 24字节。这是内部碎片。
    • 块 F:请求 48 字节有效载荷,实际分配 80 字节块。浪费空间 = 80 - 8 - 48 = 24字节。
    • 总内部碎片 = 24 + 24 = 48字节。
  • 外部碎片:查看空闲块 B。它是一个 32 字节的空闲块,夹在两个已分配块之间。即使这 32 字节是空闲的,但由于它不连续,如果下一个请求需要大于 32 字节的连续空间,它也无法被利用。因此,当前外部碎片 = 32字节。

算法对比:首次适应 vs. 最佳适应

上一节我们使用首次适应算法完成了一个例子。现在,我们看看如果从第7步(分配E)开始改用最佳适应算法,会发生什么变化。

前6步与首次适应完全相同。状态:[A:48-free] [B:32] [C:32-free] [D:48],空闲列表:A(48) -> C(32)

  1. 分配E(16)(使用最佳适应):需要块大小 = 32 字节。扫描空闲列表:
    • A: 48字节
    • C: 32字节
      最佳匹配是 C(32字节),因为它的大小与请求完全一致。因此,我们从C分配。状态:[A:48-free] [B:32] [E:32] [D:48],空闲列表:A(48)
  2. 释放D:释放 48 字节的 D。它无法与前面的已分配块E合并,因此作为一个新空闲块插入空闲列表头部。状态:[A:48-free] [B:32] [E:32] [D:48-free],空闲列表:D(48) -> A(48) (假设插入头部)
  3. 分配F(48)(使用最佳适应):需要块大小 = 64 字节。扫描空闲列表:
    • D: 48字节 (不够)
    • A: 48字节 (不够)
      没有足够大的空闲块。因此,分配器必须向操作系统扩展堆,申请新的内存来创建一个 64 字节的块。状态:[A:48-free] [B:32] [E:32] [D:48-free] [F:64],空闲列表:D(48) -> A(48)
  4. 释放B:释放 32 字节的 B。状态:[A:48-free] [B:32-free] [E:32] [D:48-free] [F:64],空闲列表:B(32) -> D(48) -> A(48) (假设B插入头部)

最终堆布局(最佳适应)[A:48-free] [B:32-free] [E:32] [D:48-free] [F:64]

碎片化分析(最佳适应最终状态)

  • 内部碎片:查看已分配块 E 和 F。
    • 块 E:请求16字节,分配32字节块。浪费 = 32 - 8 - 16 = 8字节。
    • 块 F:请求48字节,分配64字节块。浪费 = 64 - 8 - 48 = 8字节。
    • 总内部碎片 = 8 + 8 = 16字节。(比首次适应的48字节好)
  • 外部碎片:现在有三个空闲块 A(48), B(32), D(48),总计 128 字节空闲。但是,它们被已分配块 E 和 F 隔开。最大的连续空闲空间是 48 字节。如果下一个请求需要 64 字节连续空间,即使总空闲有128字节,也无法满足,这就是外部碎片。外部碎片总量可以认为是所有空闲块的总和(128字节),但有效连续空闲更小。

总结

在本章中,我们一起学习了动态内存分配的核心知识:

  1. 分配算法:如首次适应、最佳适应等,决定了如何选择空闲块。
  2. 内存碎片化:分为内部碎片(块内浪费)和外部碎片(块间不连续的空闲空间)。
  3. 空闲块组织:包括隐式链表、显式链表和分离链表,影响搜索效率。
  4. 实践分析:通过一个完整的例子,我们逐步演练了在特定规则下内存的分配、释放、合并与分割过程,并计算了不同算法下的碎片化情况。

关键要点是,没有一种算法在所有情况下都是最优的。最佳适应可能减少内部碎片,但可能产生更多小碎片,加剧外部碎片问题。理解这些权衡对于设计和分析内存分配器至关重要。

38:信号处理详解

在本节课中,我们将深入探讨操作系统中的信号机制。信号是一种进程间通信和异常处理的方式,理解其行为对于掌握系统编程至关重要。我们将通过分析具体的代码示例,来揭示信号处理中的关键概念和常见陷阱。

概述

信号,也称为异常控制流,是操作系统通知进程某个事件已发生的一种机制。例如,用户按下Ctrl+C会向进程发送SIGINT信号。信号的处理涉及生成、发送、阻塞、挂起和处理等多个环节,这些环节的交互使得信号机制变得复杂。

信号处理的基本流程

首先,我们来看一个基础的信号处理示例。以下代码注册了一个信号处理函数,并向自身进程发送信号。

#include <signal.h>
#include <unistd.h>

void handler(int sig) {
    while(1) {
        // 无限循环
    }
}

int main() {
    signal(SIGUSR1, handler);
    kill(0, SIGUSR1);
    return 0;
}

上一节我们介绍了信号的基本概念,本节中我们来看看这段代码的执行逻辑。

核心行为分析

  1. signal(SIGUSR1, handler); 这行代码为SIGUSR1信号注册了处理函数handler
  2. kill(0, SIGUSR1); 这行代码向当前进程(进程ID为0表示自身)发送SIGUSR1信号。

这里有一个关键规则:当进程使用kill函数向自身发送信号时,如果该信号未被阻塞,那么操作系统保证在该kill函数返回之前,该信号会被处理完毕。

因此,程序流程如下:main函数调用kill -> 触发SIGUSR1信号 -> 操作系统中断main函数的执行,转而调用handler函数 -> handler函数进入无限循环,永不返回 -> 由于信号处理未完成,kill函数也永不返回 -> 程序卡死,无法终止。

这个例子说明了信号处理如何中断正常的程序控制流。

信号的阻塞与挂起

现在,我们来看一个更微妙的例子。代码几乎相同,但处理函数的行为发生了变化。

#include <signal.h>
#include <unistd.h>

void handler(int sig) {
    kill(0, SIGUSR1); // 处理函数中再次发送相同信号
}

int main() {
    signal(SIGUSR1, handler);
    kill(0, SIGUSR1);
    return 0;
}

上一节我们看到信号处理如何中断主流程,本节中我们来分析当处理函数内部再次发送信号时会发生什么。

核心概念

  • 阻塞:当一个信号被阻塞时,即使它被发送给进程,进程也不会立即处理它。
  • 挂起:一个信号已被生成但尚未被处理(可能是因为被阻塞,也可能是尚未被调度处理),这种状态称为挂起。

有一个重要且隐式的规则:当进程开始执行某个信号的处理函数时,该信号会自动被阻塞,直到处理函数执行完毕。代码中并没有显式设置阻塞,这是操作系统的默认行为。

因此,让我们逐步分析程序执行过程:

  1. main函数调用kill(0, SIGUSR1),向自身发送SIGUSR1
  2. 由于信号未阻塞,根据规则,系统必须在kill返回前处理它。于是调用handler函数。
  3. 进入handler函数,此时SIGUSR1被自动阻塞
  4. handler函数内部调用kill(0, SIGUSR1),再次尝试发送SIGUSR1给自身。
  5. 关键点:此时SIGUSR1处于阻塞状态!因此,这个新生成的信号不会被立即处理,而是被标记为挂起。然后,kill函数立即返回(因为它无法处理被阻塞的信号)。
  6. handler函数执行完毕并返回。
  7. 当信号处理函数返回,控制权交还给操作系统内核时,内核会执行一个清理步骤,其中包含解除对该信号的阻塞
  8. 这里有另一个关键规则:当解除对一个信号的阻塞,并且该信号恰好处于挂起状态时,系统保证会立即处理至少一个该类型的挂起信号。
  9. 于是,挂起的SIGUSR1被处理,再次调用handler函数。
  10. 这个过程(发送->阻塞->挂起->返回->解除阻塞->处理)将无限循环下去,导致程序无法终止。

关于挂起信号的另一个重要特性是:对于特定类型的信号,内核只维护一个“挂起”标志位,而不是队列。这意味着,如果某个信号已经处于挂起状态,后续发送的相同类型信号不会排队,该信号会保持挂起状态,不会产生多个副本。

以下是理解此过程的关键点列表:

  • 信号处理时的阻塞:进入信号处理程序后,该信号自动被阻塞。
  • kill 的保证条件:仅当向自身发送信号未被阻塞时,kill才保证在返回前处理信号。
  • 解除阻塞与处理:解除信号阻塞时,若有该信号挂起,则至少处理一个。
  • 挂起信号的唯一性:同类型信号最多只有一个挂起实例。

总结与备考建议

本节课中我们一起学习了信号处理的核心机制。我们通过两个例子深入分析了:

  1. 信号如何中断正常控制流。
  2. 信号的阻塞、挂起状态及其对处理流程的影响。
  3. kill函数向自身发送信号时的特殊保证及其前提条件。

信号是系统编程中复杂且容易出错的部分,历史上也是考试中的难点。请务必通过复习课程讲义、回顾习题课示例以及亲手实践代码来巩固理解。在备考时,记住先独立解决问题再看答案,并亲手整理复习笔记,这对加深记忆非常有帮助。


本节教程基于CMU 15-213/513课程中关于信号的复习材料整理而成,旨在澄清核心概念。

posted @ 2026-03-28 12:22  布客飞龙V  阅读(1)  评论(0)    收藏  举报