佛罗里达-COP3402-系统软件笔记-全-

佛罗里达 COP3402 系统软件笔记(全)

001:课程介绍与概述

在本节课中,我们将学习系统软件课程的目标、核心概念以及你将掌握的关键技能。课程旨在帮助你理解并构建连接应用程序与计算机硬件的底层软件工具。

欢迎与课程目标

欢迎来到系统软件课程。我是Paul Gzlow,现在是中佛罗里达大学计算机科学系的副教授。我的研究方向是软件工程、编程语言和安全性。如果你对我的研究、研究生院或其他相关话题感兴趣,可以通过我的网页联系我。我的实验室有很多本科生,如果你对本课程的任何内容感兴趣,你会发现我们的研究与课程内容高度相关。

那么,你为什么要学习这门课程?除了这是必修课之外,学习系统软件有什么意义?

学习系统软件的一个最重要原因是了解你的工具。以艾萨克·牛顿为例,他不仅是理论家,还亲自研磨望远镜镜片来制造工具。所有技术工作者都是如此:飞行员学习飞机和物理原理,有些木匠甚至自己制造工具。工具是由人制造的。了解你的工具至关重要,正如谚语所说:“拙匠常怪工具差”。你不想成为那个因为不了解工具而工作受阻的人。在工作中或研究实验室里,如果因为不熟悉工具而影响工作,没有人会帮助你,你甚至可能失去工作。因此,熟练掌握工具对你的职业发展大有裨益。

更深层的哲学原因是:工具是为人类服务的,而不是人类为工具服务。如果你曾感到困惑,心想“为什么机器要这样运行?”或者“我想这样编程,但它不按我的想法工作”,那么你可能会认为是机器在主宰你。我希望通过这门课程,让你摆脱这种思维定式。通过学习机器的工作原理和底层机制,你将获得更多的控制权,而不是被机器或其设计者的决定所驱使。正如奥尔德斯·赫胥黎和托马斯·默顿所说:“技术是为人类而造,而非为技术本身。”

另一个有启发性的观点来自我的父亲。小时候,当我试图建造或敲打东西时,他会说:“不要用手,用工具。”而且不仅仅是使用工具,还要使用正确的工具。不要用螺丝刀当锤子用,否则你会弄坏螺丝刀。因此,了解这些工具为何有用、如何设计,对于有效使用它们至关重要。同时,也要摆脱那种认为使用特定工具就能成为编程高手的思维定式。就像买了一辆快车,并不一定就能成为优秀的赛车手。职业赛车手可能用更慢的车也能打败你。

同样重要的是,你实际上可以修改你的工具。这些工具并非从天而降,而是由人编写的软件。我们将在本课程中看到的系统软件,我认为几乎都是开源的。你可以打开“引擎盖”,自己修改,自己构建。

什么是系统软件?

这就引出了一个问题:什么是系统软件?这是一个非常模糊的领域,很难精确定义。这里我给出一个功能性的定义:对我来说,任何桥接内核(机器上最低层的软件)和应用程序的软件,都可以被视为系统软件。它是连接我们日常想在计算机上使用的应用程序与由操作系统抽象出来的实际硬件之间的东西。

下图展示了这一结构:

  • 最底层是硬件。作为软件人员,我们视其为电气工程师提供的既定产物。
  • 硬件之上是最低层的软件,称为内核。你可以将其视为一种理想化的硬件,它提供了一个硬件的抽象视图,使得软件编写者无需关心闪存驱动器、固态硬盘、硬盘或网络套接字之间的区别。
  • 栈的顶层是应用程序,包括上网、看视频、编写其他软件、数据分析、听音乐等一切你想做的事情。
  • 系统软件正是桥接这一鸿沟的、广泛且定义模糊的中间层。

关于定义模糊性的一个例子是,在90年代,微软在与网景浏览器的竞争中,被裁定使用了反竞争手段。他们的论点是,浏览器是操作系统的一部分,就像系统软件(如打印对话框或文件资源管理器)一样。这个论点是否成立,你可以自行判断,但这恰恰说明了系统软件定义的模糊性。

那么,对于本课程,以下是一些我们将要涉及的系统软件示例:

  • :例如,如果你使用过 printfmalloc,这些都是C标准库或Unix标准库的一部分。
  • 编程工具链:将所有软件转换为机器代码的程序。
  • 编程环境:如编辑器(Vim, Emacs)、构建工具(make)和版本控制(Git)。

在本课程中,你将使用库,但我们不会编写库。相反,我们将实际编写一些系统软件组件。

课程核心能力目标

到课程结束时,我希望每个人都能掌握以下五项核心能力:

  1. 理解文件系统:通常,学生在学习计算机科学时,缺乏对分层文件系统的基本理解。部分原因是现代操作系统向你隐藏了这种复杂性。作业之一就是阅读文件系统的发展历程。我们将学习分层文件系统,确保每个人都能理解,而不是认为“我的机器替我管理文件,我只能听任机器摆布”。

  2. 掌握命令行:就像文件系统一样,你可能见过身边的人像忍者一样熟练地使用命令行。命令行几乎暴露了你机器和操作系统的全部能力,你能用它做的事情比专用应用程序多得多。从下周开始,我们将学习如何使用命令行,并了解与之相关的系统软件和操作系统设计。使用命令行允许你下载和运行更多他人编写的软件,还可以实现大量的自动化。例如,本课程将使用自动化工具来批改作业。

  3. 理解Unix哲学与GNU/Linux开发环境:与Unix命令行相伴的是Unix哲学。我们能够在不编写新软件的情况下构建复杂工具的原因之一,就在于这种哲学:拥有定义明确的小工具,可以将它们组合起来解决更大的问题,而无需重新发明轮子或编写代码。为了在命令行上进行所有开发,我选择的开发环境是GNU/Linux。GNU开发工具(如GCC, make)被广泛应用于Linux操作系统。我希望确保每个人都了解经典的开发工作流程,并知道如何在命令行上实现。这样,当你去工作时,无论使用什么IDE,你都能理解底层的开发过程。

  4. 动手构建系统软件:我们将构建一个非常简单的Shell、一些命令行工具,以及一个低级编译器。你将看到它们实际上是如何在底层工作的,并能够用C代码和你自己语言编写的代码互相调用。

  5. 培养优秀的软件开发习惯:我的一个潜在目标是让每个人都成为优秀的程序员。事实证明,这很大程度上与实际的编码关系不大,而与你的工作流程、编码环境和工具的了解密切相关。我希望向大家灌输一种开发哲学,特别是当你处理大规模、复杂项目时。

为了说明这种开发哲学,我使用一个寓言:龟兔赛跑。兔子和乌龟都面临一个具有挑战性的编程项目。

  • 兔子立即开始编码,通宵达旦,快速写出200行代码。但代码完成后,真正困难的工作才开始:调试。程序必然会出错。兔子开始猜测、修改代码、运行测试,陷入反复调试的循环,甚至可能因为修复一个错误而引入另一个错误。
  • 乌龟在写任何代码之前,先编写计划文档、测试用例和注释。它将问题分解成简单的部分,从最简单的“Hello World”开始,确保每一步都正确后再继续。乌龟逐行编写代码,边写边测试。

虽然兔子看起来更快地写出了代码,但乌龟的方法使得代码更容易编写、更容易写对。当面对未公开的测试用例时,乌龟的代码更可能通过。对于足够大和复杂的代码库,乌龟的方法实际上整体上更快。当然,对于小项目或非关键代码,快速原型(“hack”)也是可以的。但在本课程中,尤其是在进行底层系统软件编程时(这并不容易,你需要处理指针和错误处理),我鼓励大家像乌龟一样编程

如何实践“乌龟”哲学?

那么,如何具体实践这种“乌龟”哲学呢?以下是几个关键点:

  • 了解你的编程语言:不要猜测代码的行为。了解语言的工作原理,例如,在本课程中你将了解C语言指针在机器层面的实质。
  • 熟悉编程环境:编码不仅仅是关于C语言,也关于你使用的环境。建立快速运行测试和修改代码的工作流程。如果你觉得在命令行上操作很慢,本课程将教你一些提高效率的技巧。
  • 编码前分解问题:就像乌龟所做的那样。
  • 为正确的目的使用工具:使用编程语言工具来帮助你,例如,使用函数将程序分解成有用的模块。
  • 确保简单部分先正常工作:不要偷懒,认为“这只是读文件,能出什么错”。任何事情都可能出错。
  • 编写并保存你自己的测试:不要用新测试覆盖旧测试文件。使用一种称为“回归测试”的技术,重新运行旧测试,确保你的修改没有破坏之前已经通过的功能。
  • 培养良好的调试技能:调试就像编码一样,不要从代码开始,而要从测试开始。尝试将测试用例缩小到尽可能小,仍然能触发错误。这会使你更容易定位程序中的错误。在尝试修复之前,先理解错误发生的原因。

正如Unix早期开发者之一Brian Kernighan在《编程风格要素》中所说:“调试的难度是编写代码的两倍。因此,如果你在编写代码时已经竭尽所能地追求巧妙,那么你将如何调试它呢?”所以,请像乌龟一样,采用简单、清晰、正确的方式编写代码,这样调试起来会容易得多。

系统编程的价值与程序员的美德

系统编程将帮助你成为更好的程序员,因为你需要理解编程语言及其环境是如何实际实现的。你会知道工具在做什么,了解编程环境如何工作。

最后,我想分享Larry Wall提出的伟大程序员的三大美德

  1. 懒惰:这种懒惰不是兔子那种,而是乌龟那种——为了减少精力消耗,先做简单的事情,使用自动化。这正是了解编程环境的意义所在。
  2. 急躁:如果你觉得使用工具链很慢,那就寻找更快的方法。总有更快的方法。
  3. 傲慢:这是一种需要勇气的傲慢,敢于让别人查看你的代码。就像写作一样,你希望以他人能够理解的方式编写代码。

课程安排与评分

本课程是去年课程的完全重新设计,扩展了系统软件核心内容,项目更多且定义更明确。评分结构如下:

  • 作业:每堂课大约有一次,旨在帮助你跟上课程进度。在下次课前提交,不按正确性严格计分,主要考察认真完成的程度。
  • 项目:共8个。必须在截止日期前提交某些内容(即使不完整),否则将失去后续补交资格。在首次提交后,你可以多次重新提交以争取更高分数,但迟交会有微小扣分(0.5分/项目)。项目是个人作业。
  • 考试:一次期中考试和一次期末考试,均为纸质考试。考试内容基于作业、项目和课堂讲授。
  • 课堂参与:包括出勤、课堂提问/回答、在讨论板参与讨论、参加答疑时间等。
  • 加分项目:项目中将包含一些额外的挑战,完成它们可以获得额外分数,足以弥补迟交扣分。

所有课程资料(大纲、讲义、作业描述、项目描述)都将发布在课程网站上。作业通过Webcourses提交,项目在UCF提供的Eustis服务器上实现,并通过Git命令行工具提交到课程Git服务器。课程讨论将通过Ed Discussion进行。

总结

本节课我们一起学习了系统软件课程的核心目标:理解并构建连接应用与硬件的桥梁软件。我们探讨了学习系统软件的意义在于掌握工具理解Unix哲学,并培养像乌龟一样稳健的软件开发习惯。课程将涵盖文件系统、命令行、开发环境,并动手构建Shell、工具链和编译器。通过实践,你将获得对计算机系统更深层次的控制力和更高效的编程能力。

记住,工具为人服务。通过本课程的学习,你将不再受制于机器,而是成为驾驭它们的人。

002:文件系统 🗂️

在本节课中,我们将学习文件系统的基本概念。我们将了解文件是什么、目录如何工作,以及路径(绝对路径和相对路径)如何帮助我们定位文件系统中的数据。理解这些核心概念对于后续使用命令行和操作系统至关重要。


文件系统概述

上一节我们介绍了课程的基本信息,本节中我们来看看文件系统的核心思想。文件系统是现代操作系统的基石,它并非源于自然,而是由人设计的。在Unix系统中,文件系统被设计为一种层次化结构,这种设计至今仍被广泛使用。

什么是文件?📄

许多用户可能认为文件系统就是图形界面,可以点击文件、打开或运行程序。但这实际上是文件浏览器文件资源管理器,它是一个与文件系统交互的应用程序。真正的文件系统在操作系统层面提供了更底层的功能。

文件并非存储在磁盘上的物理实体,而是一种抽象。它的本质是将一系列二进制数据组合在一起。文件没有内置的名称、硬件依赖或特定格式。

文件抽象的目的是统一各种不同的数据存储硬件和I/O方式。无论是软盘、硬盘、SSD、U盘还是云存储,它们都有不同的物理特性和指令集。文件抽象将这些差异隐藏起来,提供了一个简单统一的接口,允许我们使用相同的命令(如读取和写入字节序列)来操作所有存储介质。

当然,文件操作还包括打开、关闭、设置权限等,但其核心始终是字节序列的读写。

文件内容与名称

为了说明文件抽象部分(内容)与其周边信息(如名称、存储位置)的区别,让我们查看一个文件的实际内容。

这是一个简单的“Hello World” C程序文件。从操作系统的角度看,该文件的内容就是一个字节序列。我们可以使用hexdump命令以十六进制形式查看这些字节。

hexdump -C helloworld.c

输出会显示一系列十六进制数字。如果使用-C标志,它会同时显示这些字节对应的ASCII字符。磁盘上存储的正是这些比特位,它们被解释为文本是因为我们遵循了ASCII编码规范。

关键点在于:文件内容(字节序列)与文件名没有必然联系。至少在Unix文件系统设计中,文件内部数据不包含其名称信息。

我们可以用file命令来验证这一点。该命令通过分析文件内容的“魔数”来判断文件类型,而不是看文件扩展名。

file helloworld.c
# 输出:C source, ASCII text

file song.mp3
# 输出:MPEG ADTS, layer III, v1, 128 kbps, 44.1 kHz, JntStereo

mv helloworld.c helloworld.mp3
file helloworld.mp3
# 输出:C source, ASCII text (类型未变,因为内容未变)

mv song.mp3 song.c
file song.c
# 输出:MPEG ADTS, layer III... (类型依然是MP3)

file命令通过识别文件开头的特定“魔数字节”来判断类型。例如,MP3文件通常以“ID3”开头。这种设计带来了灵活性,但也可能被恶意利用,例如通过隐藏真实扩展名来诱骗用户运行恶意程序。

目录与命名 📁

既然文件内容不包含名称,那么文件如何获得名字?答案是通过目录

目录本质上是一种将名称映射到文件的特殊文件。你可以将其类比为C语言中的指针(名称是指针,文件是被指向的数据)或电话簿(将人名映射到电话号码)。

操作系统为每个文件分配一个唯一的标识号,称为inode号。目录条目则存储了“文件名”到“inode号”的映射关系。

将名称与文件实体分离的设计有很多好处:

  • 易于重命名和移动:只需修改目录中的映射条目,无需触碰文件数据本身。
  • 支持硬链接:多个名称(在不同目录中)可以指向同一个inode(即同一个文件)。
  • 删除效率高:删除文件通常只是移除目录中的条目,文件数据可能仍留在磁盘上,直到被新数据覆盖。

目录本身也是文件,只不过其内容是名称到inode的映射表。这种设计使得目录可以包含其他目录,从而形成了层次化的文件系统树。

路径:定位文件 🧭

在层次化的文件系统中,我们需要一种方法来唯一标识一个文件,这就是路径

绝对路径

绝对路径从文件系统的根目录(用 / 表示)开始,逐级列出到达目标文件所需经过的所有目录。

例如,考虑以下文件树:

/ (根目录)
├── home/
│   ├── paul/
│   │   └── file.c
│   └── joe/
│       └── file.c
└── hello.txt
  • 文件 /home/paul/file.c 的绝对路径是:/home/paul/file.c
  • 文件 /home/joe/file.c 的绝对路径是:/home/joe/file.c

绝对路径总是以 / 开头,它能唯一确定一个文件(不考虑硬链接)。

相对路径与工作目录

相对路径不是从根目录开始,而是从当前工作目录开始。每个运行的程序(包括Shell)都有一个当前工作目录的概念。

相对路径不以 / 开头。系统会将其解释为相对于当前工作目录的路径。

继续使用上面的文件树例子:

  • 如果当前工作目录是 /home,那么:
    • paul/file.c 指向 /home/paul/file.c
    • ./paul/file.c 同样指向 /home/paul/file.c./ 代表当前目录)
    • ../hello.txt 指向 /hello.txt../ 代表父目录)

特殊目录:...

每个目录都包含两个特殊的条目:

  • . (点):指向目录自身。
  • .. (点点):指向父目录。

根目录的 .. 也指向它自身。

它们的用途包括:

  • .:常用于明确指定运行当前目录下的可执行文件(例如 ./myprogram),因为系统默认不会在当前目录搜索可执行文件。
  • ..:方便地导航到上级目录。

总结

本节课中我们一起学习了文件系统的核心概念:

  1. 文件是一种抽象,它是对字节序列的统一接口,隐藏了底层存储硬件的差异。
  2. 文件内容与文件名无关。文件名存储在目录中,通过inode号与文件实体关联。
  3. 目录是特殊的文件,它存储了文件名到inode号的映射。这种设计使得重命名、移动和链接操作非常高效。
  4. 路径用于定位文件。绝对路径从根目录开始,相对路径从当前工作目录开始。
  5. 特殊目录 ... 分别代表当前目录和父目录,是文件导航的重要工具。

理解这些基础概念是熟练使用命令行和深入理解操作系统工作原理的关键第一步。下一节我们将开始学习具体的文件操作命令。

003:使用命令行进行导航 🗺️

在本节课中,我们将学习如何使用命令行来浏览和修改文件系统。我们将从回顾上节课的作业开始,然后深入探讨一系列实用的命令和技巧,帮助你高效地在文件系统中穿梭。

作业回顾 📝

上一节课的作业是根据一系列路径来重建文件树。我们需要区分两种路径:绝对路径相对路径

  • 绝对路径/ 开头,从根目录开始定位文件。
  • 相对路径则需要结合当前工作目录来确定文件位置。

让我们逐一分析作业中的路径来构建完整的文件树。

以下是路径列表及其工作目录:

  • /home (工作目录:/home)
  • /dev (工作目录:/)
  • /tmp (工作目录:/)
  • /boot (工作目录:/)
  • user (工作目录:/)
  • paul (工作目录:/home)
  • ../home/paul/src (工作目录:/dev)
  • bin (工作目录:/user)
  • ./bin (工作目录:/home/paul)
  • home (工作目录:/home/paul)
  • paul (工作目录:/dev/../home/paul/home)

通过分析这些路径,我们构建出以下文件树结构:

  • / (根目录)
    • home
      • paul
        • src
        • bin
        • home
          • paul
    • dev
    • tmp
    • boot
    • user
      • bin

关键点在于理解 ..(父目录)和 .(当前目录)在相对路径中的作用,以及工作目录如何影响路径的解析。

命令行导航基础 🧭

现在,让我们将理论知识付诸实践,学习如何在命令行中导航。

首先,通过SSH登录到服务器。登录后,你会看到欢迎信息和命令提示符。

clear 命令或快捷键 Ctrl+L 可以清空终端屏幕。

有两个命令能帮助你快速定位自己在文件树中的位置:

  1. pwd (Print Working Directory):打印当前工作目录的绝对路径。
  2. ls (List):列出当前工作目录下的所有文件和子目录。

例如:

pwd
ls

ls 命令可以接受参数,用于查看其他目录的内容。例如,使用绝对路径查看 /usr/include 目录:

ls /usr/include

改变工作目录

要改变当前工作目录,使用 cd (Change Directory) 命令。

你可以使用绝对路径:

cd /usr/include

也可以使用相对路径。.. 代表父目录:

cd ..  # 切换到父目录
pwd    # 现在位于 /usr

要进入当前目录的子目录,可以直接使用子目录名:

cd include  # 假设当前在 /usr,这将进入 /usr/include

~ 符号是用户家目录的快捷方式:

cd ~  # 切换到你的家目录

提高效率的技巧 ⚡

为了更高效地使用命令行,以下是两个核心技巧。

1. Tab 自动补全

Tab 键是命令行中最有用的功能之一。它可以自动补全命令、文件名和目录名。

  • 唯一补全:输入部分名称后按 Tab,如果只有一个匹配项,系统会自动补全。
    • 例如:输入 ls /usr/include/g 后按 Tab,可能会补全为 ls /usr/include/gnu
  • 列出选项:如果按一次 Tab 没有反应,表示有多个匹配项。再按一次 Tab,系统会列出所有可能的选项供你选择。
  • 路径补全:在输入路径时,每输入一个目录部分后按 Tab,可以快速补全并确认该路径存在。

养成随时按 Tab 的习惯,可以极大提升输入速度和准确性。

2. 命令历史

命令行会记录你之前执行过的命令。利用历史记录可以快速重新运行或修改之前的命令。

  • 上/下箭头键Ctrl+P / Ctrl+N:在历史命令中上下浏览。
  • history 命令:列出所有保存的历史命令。
  • Ctrl+R:反向搜索历史命令。按下后输入关键词,会搜索包含该词的历史命令。

一个小技巧:在命令前加上 # 将其注释掉再执行,这条命令会被存入历史。之后你可以通过上箭头找到它,删除 # 并修改后重新运行。

常用文件操作命令 📁

现在,我们来看看如何修改文件系统。以下是一些基本命令。

首先,创建一个示例目录并进入:

mkdir example
cd example

touch:创建一个新的空文件。

touch regular_file

rm (Remove):删除一个文件。

rm regular_file

mv (Move):移动或重命名文件/目录。

  • 重命名文件
    touch file2
    mv file2 file_new_name
    
  • 移动文件到目录
    mkdir subdir
    mv file_new_name subdir/
    
  • 移动并重命名
    mv subdir/file_new_name subdir/renamed_again
    

cp (Copy):复制文件。

cp subdir/renamed_again regular_file_copy

rmdir (Remove Directory):删除一个目录。

rmdir subdir  # 失败,因为 subdir 非空
rm subdir/renamed_again  # 先删除目录内文件
rmdir subdir  # 成功删除空目录

要删除非空目录及其所有内容,rm 命令有一个危险的选项 -r (递归),请谨慎使用:

rm -r directory_name

实战:作业解析 🧩

本节课的作业是操作一个给定的文件树。你需要下载一个打包文件(.tar),解压后,使用学到的命令将原始文件树修改成目标结构。

步骤 1:下载和解压

# 下载作业文件 (URL 需替换为实际地址)
wget http://example.com/hw3.tar

# 解压文件
tar -xvf hw3.tar

# 进入解压后的目录
cd hw3

步骤 2:查看原始结构
使用 tree 命令查看目录树结构(如果系统未安装,可用 ls -R 替代)。

tree .

步骤 3:对比与修改
比较 tree 命令输出的原始结构和作业要求的目标结构。找出差异,例如:

  • 文件/目录名称不同。
  • 文件/目录位置(父子关系)不同。
  • 需要创建或删除某些文件/目录。

使用 mv, cp, rm, mkdir, touch 等命令,结合 Tab 补全,一步步将文件树修改成目标样子。

一个例子:假设目标是将目录 fs/ext2 改名为 fs/ext4

# 在 hw3 目录下执行
mv fs/ext2 fs/ext4

步骤 4:验证
修改完成后,再次运行 tree . 命令,确保生成的文件树结构与目标一致(注意:同一目录下子项的顺序无关紧要,关键是父子关系要正确)。

如果修改过程中出错,可以轻松重来:

cd ~  # 回到家目录
mv hw3 hw3_old  # 移走旧的尝试
tar -xvf hw3.tar  # 重新解压得到干净的 hw3 目录
cd hw3

核心命令总结 📋

本节课我们学习了一系列命令行工具和技巧,以下是需要掌握的核心内容:

  • 定位与查看

    • pwd:显示当前目录。
    • ls:列出目录内容。
    • cd:切换目录。
      • cd ~cd:回家目录。
      • cd -:返回上一个所在的目录。
      • cd ..:进入父目录。
  • 效率工具

    • Tab 键:自动补全路径、命令和参数。
    • 历史命令:使用上下箭头或 Ctrl+R 查找并重用之前的命令。
  • 文件操作

    • touch:创建空文件。
    • mkdir:创建目录。
    • mv:移动/重命名。
    • cp:复制。
    • rm:删除文件。
    • rmdir:删除空目录。
  • 文件查看

    • cat:快速查看整个文件内容。
    • more / less:分页查看长文件(按 q 退出)。

本节课中,我们一起学习了命令行导航的基础知识、提高效率的实用技巧以及基本的文件系统操作命令。通过完成作业,你将有机会亲手实践这些命令,巩固对文件树结构和路径操作的理解。熟练掌握这些技能是进行后续系统软件学习和开发的重要基础。

004:使用命令行进行导航 🗺️

在本节课中,我们将学习如何在命令行界面中导航文件系统。我们将回顾绝对路径和相对路径的核心概念,并通过一系列实用的命令来探索、查看和修改文件系统中的文件和目录。掌握这些技能对于高效地使用命令行至关重要。


上一节我们介绍了文件系统的基本结构。本节中,我们来看看如何在命令行中实际地移动和操作文件。

核心概念:绝对路径与相对路径

理解路径是导航文件系统的关键。路径分为两种:

  • 绝对路径:从文件系统的根目录(/)开始。它提供了到达目标文件或目录的完整、唯一的路径,与当前工作目录无关。
    • 公式/目录1/目录2/.../文件名
  • 相对路径:从当前工作目录开始。它依赖于当前所在的位置。
    • 公式目录/子目录/文件名./文件../父目录文件

关键区别:对于绝对路径,当前工作目录被忽略。对于相对路径,当前工作目录是路径解析的起点。

基础导航命令

以下是开始探索所需的最基本命令。

查看当前位置与内容

要查看你当前在文件系统中的位置,使用 pwd 命令。

pwd

该命令会打印出你当前所在目录的绝对路径

要查看当前目录下有哪些文件和子目录,使用 ls 命令。

ls

切换工作目录

要改变当前工作目录,使用 cd 命令,后跟目标目录的路径。

cd /usr/include

你可以使用特殊符号进行快速导航:

  • cd ..:切换到父目录。
  • cd ~ 或直接输入 cd:切换到你的家目录。
  • cd -:切换到上一个所在的目录。

高效使用命令行的技巧

在命令行中高效工作的两个最重要技巧是标签补全和命令历史。

1. 标签补全

在输入文件名、目录名甚至命令时,按下 Tab 键,系统会自动尝试补全你正在输入的内容。

  • 如果只有一个匹配项,它会直接补全。
  • 如果有多个匹配项,按两次 Tab 键会列出所有可能的选项。
  • 这是一个极好的工具,既能提高输入速度,也能验证路径是否正确。

2. 命令历史

Bash shell 会记录你之前执行过的命令。

  • 上箭头下箭头 键可以浏览历史命令。
  • Ctrl+R 可以反向搜索历史命令。
  • 输入 history 命令可以查看完整的历史记录列表。
    这可以避免重复输入长而复杂的命令。

查看文件内容

有时你需要查看文件里有什么。以下是几个有用的命令:

  • cat:快速打印整个文件的内容到屏幕。
    cat filename.txt
    
  • more:分页查看文件,按空格键翻页。
    more longfile.txt
    
  • less:比 more 更强大的分页查看器,支持上下滚动、搜索等。按 q 键退出。
    less longfile.txt
    

文件与目录操作命令

现在,我们来看如何创建、删除和移动文件与目录。这些是修改文件系统结构的基础。

操作文件

以下是操作文件的基本命令列表:

  • touch:创建一个新的空文件,或更新现有文件的时间戳。
    touch newfile.txt
    
  • rm:删除文件。
    rm file_to_delete.txt
    
  • mv:移动文件或为文件/目录重命名。
    mv oldname.txt newname.txt
    mv file.txt /target/directory/
    

操作目录

以下是操作目录的基本命令列表:

  • mkdir:创建一个新目录。
    mkdir new_directory
    
  • rmdir:删除一个目录。
    rmdir empty_directory
    
  • 要删除非空目录,通常使用 rm -r directory_name,但使用时要格外小心。

实践:作业示例

为了巩固所学,你将完成一个实践作业。你需要下载一个初始的文件树,然后使用今天学到的命令,将其修改成指定的目标文件树。

作业准备步骤:

  1. 登录 Eustis 服务器。
  2. 使用 wget 命令下载作业压缩包。
  3. 使用 tar 命令解压。
  4. 进入解压后的目录。

核心任务:
比较初始文件树和目标文件树的差异。例如,一个目录可能需要从 ext2 重命名为 ext4。这可以通过 mv 命令完成:

mv fs/ext2 fs/ext4

你需要找出所有此类差异,并组合使用 mkdir, rm, mv, touch 等命令来完成转换。完成后,将你使用的命令序列提交即可。


本节课中我们一起学习了命令行的核心导航技能。我们明确了绝对路径与相对路径的区别,掌握了 pwd, ls, cd 等基础命令,并学会了使用标签补全和命令历史来提升效率。最后,我们了解了如何查看文件内容以及使用 touch, rm, mv, mkdir 等命令来操作文件和目录。这些是你在命令行环境中进行有效工作的基石。

005:进程 (COP-3402 Fall 2024)

概述

在本节课中,我们将学习操作系统中的另一个核心抽象概念:进程。我们将探讨进程是什么、为什么需要它,以及如何在Unix/Linux命令行环境中查看和管理进程。我们还将回顾上一节关于文件系统的作业,并介绍标准输入/输出(Standard I/O)的概念及其在命令行中的强大用途。

回顾:文件系统导航

上一节我们介绍了在命令行中导航文件系统的基础知识。我们讨论了分层文件系统的本质,学习了路径的概念,包括绝对路径和相对路径。绝对路径总是从根目录(/)开始,而相对路径则是相对于当前工作目录(pwd)的。每个运行的程序(进程)都有一个由内核维护的工作目录。

以下是关于路径的一些核心概念:

  • 绝对路径:以根目录 / 开头的路径,例如 /home/user/file.txt
  • 相对路径:相对于当前工作目录的路径,例如 ./subdir/file../parent.txt
  • 当前工作目录:可以使用 pwd 命令查看。

作业回顾与文件操作

在深入新内容之前,我们先快速回顾一下上节课的作业。作业要求大家通过一系列文件操作,将一个给定的目录树结构修改为另一个目标结构。

一种方法是使用 rm 命令删除不需要的文件或目录。使用 rm -r 可以递归删除整个目录树及其内容,但需要格外小心,因为此操作不可逆。

另一种更安全的方法是组合使用 cd(改变目录)和相对路径来定位并操作特定文件。关键是要时刻清楚自己当前在目录树中的位置(使用 pwdls)。

进程抽象:运行中的程序

本节中我们来看看操作系统中的另一个基本抽象:进程

一个进程,简单来说,就是一个正在运行的程序。程序是存储在磁盘上的一系列指令(机器码),而进程则是这些指令被加载到内存中并由CPU执行的状态。

为什么需要“进程”这个抽象概念?主要原因有几点:

  1. 并发执行:现代计算机通常需要同时运行多个程序(例如,浏览器、音乐播放器、编辑器)。即使只有一个CPU,操作系统也可以通过让多个进程快速轮流使用CPU(称为时间片轮转),制造出它们同时运行的假象。
  2. 故障隔离:如果一个程序(进程)崩溃,操作系统可以终止它,而不会导致整个机器停止运行。这得益于进程间的隔离。
  3. 资源虚拟化:操作系统为每个进程提供一个虚拟的、独立的执行环境,让每个进程都感觉自己独占CPU和内存。内核则在背后管理这些虚拟资源到物理硬件的映射和调度。

进程的状态(如CPU寄存器值、内存映射等)由内核保存。当操作系统决定切换进程时,它会保存当前进程的状态,并恢复下一个进程的状态,这个过程称为上下文切换

Unix中的进程创建:Fork 与 Exec

在Unix哲学中,创建新进程主要涉及两个系统调用:forkexec

  • fork():此调用创建一个当前进程的副本(子进程)。子进程拥有与父进程几乎完全相同的内存映像、文件描述符等。调用 fork() 后,系统中就存在两个执行相同代码的进程。
  • exec():此调用用磁盘上的一个新程序替换当前进程的内存空间。调用 exec() 后,原进程的代码和数据被新程序覆盖,并开始执行新程序的 main 函数。

常见的模式是:父进程调用 fork() 创建子进程,然后在子进程中调用 exec() 来运行一个新程序,而父进程则继续执行原有代码。这就像是细胞分裂后,其中一个细胞转变成了全新的个体。

系统中所有的进程都构成一棵树。第一个进程(通常是 initsystemd)由内核在启动时创建,所有其他进程都是它的后代(通过 fork 产生)。例如,当你通过SSH登录并启动 bash shell后,在 bash 中运行的每个命令(如 ls, ps)都是 bash 进程 forkexec 出来的子进程。

在命令行中查看进程

我们可以使用命令行工具来查看和管理进程。

以下是几个常用的命令:

  • ps:显示当前终端会话启动的进程。
  • ps aux:显示系统中所有用户的全部进程信息。
  • ps axjf:以树状格式显示进程,清晰展示父子关系。

例如,运行 ps axjf | less 可以浏览整个系统的进程树。你可以看到 init 进程(PID 1)是所有进程的根,你的 bash shell及其运行的命令都是这棵树上的分支。

运行程序与 PATH 环境变量

bash shell中运行一个程序非常简单:只需键入程序名即可。例如,输入 lsbash 会找到 ls 程序,然后通过 fork()exec() 系统调用来创建并执行它。

那么,bash 如何知道 ls 程序在哪里呢?它依赖于一个叫做 PATH 的环境变量。PATH 是一个由冒号分隔的目录列表,bash 会按顺序在这些目录中查找你输入的命令。

你可以使用 which 命令来查看某个命令的完整路径:

which ls

这将输出类似 /bin/ls 的结果。

如果你想运行当前目录下的一个程序(比如你自己编译的 hello),你需要指定路径,例如 ./hello,因为当前目录(.)通常不在默认的 PATH 中。

标准输入/输出(Standard I/O)与重定向

Unix设计的另一个巧妙之处在于标准I/O。每个进程在创建时都会自动打开三个文件流:

  1. 标准输入 (stdin):文件描述符为0,默认从键盘读取输入。
  2. 标准输出 (stdout):文件描述符为1,默认输出到终端屏幕。
  3. 标准错误 (stderr):文件描述符为2,默认也输出到终端屏幕,用于错误信息。

C语言中的 printfscanf 函数就是分别向 stdoutstdin 进行读写。

bash shell 的强大功能之一是可以重定向这些标准流,而无需修改程序本身。

以下是重定向的几种方式:

  • 输出重定向到文件:使用 > 符号。这会创建新文件或覆盖已有文件。
    ls > file_list.txt
    
  • 输出追加到文件:使用 >> 符号。这会将内容添加到文件末尾。
    echo "New line" >> file_list.txt
    
  • 输入重定向来自文件:使用 < 符号。这会将文件内容作为程序的输入。
    grep "search_term" < input_file.txt
    
  • 组合重定向:可以同时重定向输入和输出。
    grep "include" < source_code.c > includes.txt
    

这种设计使得命令行工具能够像乐高积木一样通过管道(|,下节课内容)连接起来,形成强大的数据处理流水线。

总结

本节课我们一起学习了操作系统中进程这一核心抽象。我们明白了进程是运行中的程序,操作系统通过进程来管理并发执行、提供故障隔离和资源虚拟化。我们探讨了Unix创建进程的 fork-exec 模型,以及如何通过 ps 等命令查看进程树。此外,我们还深入了解了标准I/O的概念,并掌握了如何使用 >, <, >> 在命令行中进行输入输出重定向,这是实现强大命令行工作流的基础。掌握这些概念对于理解系统软件如何工作至关重要。

006:高级进程管理 🔧

在本节课中,我们将学习命令行中更高级的进程管理技术。我们将探讨如何通过管道连接多个程序,以及如何控制进程的前台/后台运行状态。这些技能能帮助你更高效地利用命令行工具,构建复杂的文本处理流程。

上一节我们介绍了进程的基本概念和标准输入/输出。本节中我们来看看如何让进程之间进行通信和协作。

Unix 哲学与进程通信

Unix 系统的设计哲学强调程序的模块化和协作。其核心思想之一是:让每个程序只做好一件事。这样,当面临新任务时,我们可以组合现有的工具,而不是每次都编写新程序。

另一个更微妙的哲学是:期望每个程序的输出都能成为另一个未知程序的输入。这意味着程序应该设计有良好定义的文本输入和输出,以便于串联。

以下是两个遵循此哲学的基础工具:

  • find:列出文件树中的所有文件。
  • grep:在输入文本中搜索指定字符串。

单独看,find 只是列出文件,grep 只是搜索文本。但将它们组合起来,我们就能创建一个在文件系统中搜索文件的强大工具。

管道:连接程序的桥梁

我们可以通过“管道”将多个命令串联起来,将一个命令的标准输出直接作为下一个命令的标准输入,而无需创建中间文件。

管道操作符是 |

示例:组合 findgrep

find . | grep "acl"

这条命令会先执行 find . 列出当前目录下所有文件,然后将结果直接传递给 grep "acl",最终输出包含 “acl” 字符串的文件路径。

示例:组合 catgrep

cat stdio.h | grep "include"

这条命令会先打印 stdio.h 文件的所有内容,然后过滤出包含 “include” 的行。

通过管道,我们可以像搭积木一样,将简单、专注的工具组合成能完成复杂任务的“程序”。例如,统计头文件中 #include 出现的次数:

cat stdio.h | grep "^#include" | wc -l

这里,wc -l 用于统计行数。

管道的工作原理

在操作系统层面,当 Shell 请求创建一个管道时,内核会生成一个特殊的“管道”对象。你可以将其理解为内核创建的两个虚拟文件:一个用于写入,一个用于读取。

当第一个命令(如 find)向管道写入数据时,内核会管理这些数据,并让第二个命令(如 grep)从管道的读取端获取数据。Shell 负责将命令的标准输出重定向到管道的写入端,并将管道的读取端重定向到下一个命令的标准输入。

高级管道技巧

使用 xargs 将输入转为参数

xargs 命令读取标准输入,并将每一行内容作为参数传递给指定的命令。

示例:对 find 找到的每个文件执行 file 命令

find . -name "*.h" | xargs file

这条命令会找到所有 .h 文件,然后对每个文件运行 file 命令以查看其类型。

组合多个工具进行复杂分析

通过串联多个工具,可以快速完成复杂的文本分析。例如,在 Linux 内核源码树中找出所有唯一的配置选项:

find . -name "Kconfig" | xargs grep "^config" | cut -d' ' -f2 | sort | uniq | wc -l

这个命令链依次执行:查找文件、搜索模式、切割文本、排序、去重、计数。

重定向标准错误

默认情况下,管道只传递标准输出。使用 |& 可以同时传递标准输出和标准错误。

示例:

some_command |& grep "error"

进程控制:前台、后台与挂起

在命令行中,我们可以灵活地控制进程的运行状态。

基本控制键

  • Ctrl + C:终止当前前台进程。
  • Ctrl + Z:挂起(暂停)当前前台进程。

管理挂起的进程

当一个进程被 Ctrl + Z 挂起后,它仍然存在。我们可以使用以下命令管理它:

  • fg:将最近挂起的进程恢复到前台继续运行。
  • bg:将最近挂起的进程放到后台运行。
  • jobs:列出当前 Shell 会话中的所有作业(进程)及其状态。

前台与后台的区别:前台进程可以与用户交互(接收输入、显示输出、响应 Ctrl+C 等信号)。后台进程则与终端分离,无法直接交互。

在命令后使用 &

在命令末尾加上 &,可以让该命令直接在后台启动。

示例:

find / > /dev/null &

这条命令会在后台启动 find,并将其输出丢弃。

使用 kill 强制终止进程

Ctrl + C 是请求进程终止,进程可以选择忽略。要强制终止进程,可以使用 kill -9 <PID> 命令,其中 <PID> 是进程 ID。

示例:

kill -9 12345

这会向 PID 为 12345 的进程发送不可中断的终止信号。

快速开发工作流示例

利用作业控制,可以建立一个快速的编辑-编译循环:

  1. 在编辑器中编写代码。
  2. Ctrl + Z 挂起编辑器。
  3. 在 Shell 中运行编译器进行编译。
  4. 如果发现错误,输入 fg 快速切回编辑器进行修改。
  5. 重复步骤 2-4。

终端复用器:byobu

对于远程系统开发,终端复用器(如 byobu,基于 screentmux)非常有用。它允许你在一个登录会话中创建和管理多个虚拟终端窗口。

基本用法:

  • 输入 byobu 启动。
  • F2 创建新窗口。
  • F3 / F4Alt + 左箭头 / Alt + 右箭头 在窗口间切换。
  • 在窗口内输入 exit 或按 Ctrl + D 关闭当前窗口。
  • 输入 byobu 重新连接会话(如果系统支持持久化会话)。

byobu 允许你同时保持编辑器、编译器和 Shell 等多个会话,并轻松在它们之间切换,极大提高了工作效率。

总结

本节课中我们一起学习了命令行下的高级进程管理技术。

我们首先回顾了 Unix 的“工具协作”哲学,然后深入探讨了管道的用法,它允许我们将多个简单程序的输入输出连接起来,构建复杂的数据处理流程。

接着,我们学习了如何控制进程的运行状态,包括使用 Ctrl+Z 挂起进程、用 fg/bg 在前台和后台之间切换进程、用 & 启动后台作业,以及用 kill 命令终止进程。这些技能对于管理长时间运行的任务或建立快速开发工作流至关重要。

最后,我们介绍了终端复用器 byobu,它是在远程服务器上同时管理多个 Shell 会话的利器。

掌握这些高级进程管理技巧,将使你能够在命令行环境中更加游刃有余地完成复杂的编程和系统管理任务。


下节课预习:命令行编辑器 📝

下节课我们将学习命令行编辑器。请提前完成以下准备作业:

请选择以下一个编辑器(推荐 emacs),并在 Eustace 系统上完成其内置教程:

  1. Emacs 教程
    • 在命令行运行:emacs -Q -nw --eval \"(progn (setq inhibit-startup-screen t) (switch-to-buffer \\\"*scratch*\\\") (find-file \\\"~/emacs-tutorial.txt\\\") (insert-file-contents \\\"/usr/share/emacs/*/etc/tutorial/TUTORIAL\\\"))\"
    • 跟随教程内的指示学习。完成后,按 Ctrl+X,然后 Ctrl+C 退出。
  2. Vim 教程
    • 在命令行运行:vimtutor
    • 跟随教程内的指示学习。完成后,输入 :q 并按回车退出。

请选择其中一个编辑器完成教程(约需30分钟),并准备在课后分享你的选择及原因。

007:编程环境与编辑器 🖥️

在本节课中,我们将学习编程环境中的一个核心部分:文本编辑器。我们将重点介绍两种命令行编辑器——Emacs和Vim——的基本使用方法,并了解如何将它们集成到高效的工作流中。掌握这些工具对于完成本课程的编程项目至关重要。

上一节我们介绍了命令行中进程的高级用法,如管道和多路复用器。本节中,我们来看看如何利用编辑器来实际修改和创建代码。

第一个编程项目:Hello World

在深入讲解编辑器之前,我们先了解一下第一个编程项目的要求。该项目是编写一个简单的“Hello World”程序,但有几个特殊要求。

以下是完成该项目的具体步骤:

  1. 使用SSH连接到Eustis服务器。
  2. 使用你选择的文本编辑器(Emacs或Vim),完全在命令行中编写“Hello World”程序。
  3. 使用 script 命令录制你的整个终端操作过程。

使用 script 命令录制会话

script 命令可以记录终端会话中的所有输入和输出。以下是录制过程:

script -T hello.timing hello.script

执行此命令后,终端会开始录制。接着,你可以使用编辑器创建并编写程序。完成后,输入 exit 退出录制的子shell,录制便停止。

你可以使用 scriptreplay 命令回放录制的内容,以作检查:

scriptreplay -t hello.timing hello.script 3

(末尾的数字 3 表示以3倍速回放)

该项目旨在帮助你练习使用命令行编辑器,并熟悉基本的开发流程。项目提交将通过Git进行,我们将在后续关于版本控制的课程中详细讲解。

高效开发工作流:结合终端多路复用器

在开始学习具体编辑器前,了解一个高效的工作流很有帮助。我们可以结合上一节学到的终端多路复用器(如 byobu)来提升效率。

使用多路复用器,你可以在同一个终端窗口中创建多个面板。例如,你可以在一个面板中打开编辑器编写代码,在另一个面板中运行编译命令,在第三个面板中执行测试。通过快捷键(如 Alt+左右方向键F3/F4)可以快速在这些面板间切换。

这种工作流使你无需在多个窗口间来回跳转,就能同时进行编辑、编译和测试,大大提升了开发效率。

编辑器介绍:Emacs

Emacs不仅仅是一个文本编辑器,它更是一个高度可定制和可扩展的编程环境。它的设计哲学使其能够胜任多种任务。

以下是Emacs的一些核心特性:

  • 自文档化:内置帮助系统,可以随时查询命令和函数用法。
  • 高度可定制:允许用户修改快捷键绑定和界面。
  • 功能扩展:通过Emacs Lisp语言,可以编写插件,实现邮件、IRC聊天、游戏等复杂功能。
  • 进程调用:可以在编辑器内部运行shell命令、编译程序(如 make)。
  • 版本控制集成:内置对Git等版本控制系统的强大支持。
  • 文件管理:可以管理目录、文件,进行重命名、复制等操作。
  • 多窗口管理:支持同时打开和编辑多个文件。

Emacs 基础操作

对于初学者,掌握以下基本命令即可开始使用Emacs完成“Hello World”项目:

  • 启动并编辑文件emacs hello.c
  • 光标移动:除了方向键,也可用 Ctrl+f (前)、Ctrl+b (后)、Ctrl+n (下)、Ctrl+p (上)。
  • 保存文件Ctrl+x Ctrl+s
  • 退出EmacsCtrl+x Ctrl+c

Emacs 进阶功能概览

当你熟悉基础操作后,可以探索更多强大功能:

  • 窗口管理
    • Ctrl+x 2:水平分割当前窗口。
    • Ctrl+x 3:垂直分割当前窗口。
    • Ctrl+x o:在多个窗口间切换焦点。
    • Ctrl+x 0:关闭当前窗口。
    • Ctrl+x 1:关闭其他所有窗口,只保留当前窗口。
  • 文件管理:在Emacs中打开一个目录,可以像图形界面一样进行复制(C)、重命名/移动(R)、新建目录(+)等操作。
  • Org模式:这是一个强大的笔记、文档组织和待办事项管理工具。本课程网站就是使用Org模式生成。
  • 版本控制:通过Magit等插件,可以在Emacs内完成Git提交、查看差异、管理分支等所有操作,无需离开编辑器。

编辑器介绍:Vim

Vim是另一个极受欢迎的命令行编辑器。它与Emacs的一个主要区别在于其模态编辑设计。

Vim有多种模式,最常用的是:

  • 普通模式:用于移动光标、删除、复制粘贴等操作。启动Vim后默认进入此模式。
  • 插入模式:用于输入和编辑文本。

Vim 基础操作

以下是Vim的基本使用流程:

  1. 启动Vimvim hello.c
  2. 从普通模式进入插入模式:按 i 键(在光标前插入)。
  3. 编辑文本:此时可以像普通编辑器一样输入代码。
  4. 返回普通模式:按 Esc 键。
  5. 保存文件:在普通模式下输入 :w 并回车。
  6. 保存并退出:输入 :wq 并回车。
  7. 不保存退出:输入 :q! 并回车。

重要提示:如果你在Vim中“卡住”,通常是因为处于插入模式而无法执行保存等命令。请多次按 Esc 键确保回到普通模式,然后使用 :q:q! 退出。

Vim 的导航与编辑

在普通模式下,Vim使用字母键进行高效导航和编辑:

  • 光标移动
    • h / l:左 / 右。
    • j / k:下 / 上。
    • w / e:移动到下一个单词的开头 / 结尾。
    • b:移动到上一个单词的开头。
    • 0:移动到行首。
    • $:移动到行尾。
    • gg:移动到文件开头。
    • G:移动到文件结尾。
  • 搜索文本:输入 /,然后输入搜索词,按回车。用 n 跳转到下一个匹配项,N 跳转到上一个。
  • 删除与复制粘贴
    • x:删除当前光标下的字符。
    • dw:删除一个单词。
    • dd:删除整行。
    • p:粘贴已删除或复制的内容。
    • u:撤销上一次操作。

Vim的学习曲线可能较陡,但一旦掌握,其编辑效率非常高。强烈建议通过运行 vimtutor 命令完成内置教程。


本节课中我们一起学习了编程环境中两个核心的文本编辑器——Emacs和Vim。我们了解了它们的基本操作模式、如何利用它们与终端多路复用器结合形成高效工作流,并明确了第一个“Hello World”编程项目的具体要求。请选择其中一个编辑器,通过完成项目来巩固练习。记住,熟练使用这些命令行工具将为你的系统软件开发打下坚实的基础。

008:构建、测试与调试环境 🛠️

在本节课中,我们将学习软件开发环境中的关键环节:构建自动化、测试与调试。我们将重点探讨如何使用 make 工具来管理多文件项目的编译过程,以提高开发效率。


构建自动化

上一节我们介绍了基本的编译过程。本节中我们来看看当项目包含多个源文件时,如何更高效地进行构建。

单独编译

为什么需要单独编译?当项目规模变大,例如拥有成千上万个源文件时,每次修改后都重新编译所有文件会非常耗时。单独编译允许我们只重新编译那些被修改过的文件及其依赖项。

以下是单独编译的基本步骤:

  1. 将每个源文件(.c)单独编译成目标文件(.o)。
    gcc -c main.c -o main.o
    gcc -c square.c -o square.o
    gcc -c exponent.c -o exponent.o
    
  2. 使用链接器将所有目标文件链接成一个可执行程序。
    gcc main.o square.o exponent.o -o main
    

这种方法在开发过程中可以节省大量时间。


使用 Makefile 实现自动化

手动执行上述命令仍然繁琐。make 工具和 Makefile 文件可以自动化这个过程。

Makefile 基础

一个 Makefile 由一系列“规则”组成。每条规则定义了如何构建一个特定的“目标”文件。

一条规则的基本语法如下:

目标文件: 依赖文件
<制表符> 构建命令

以下是一个简单的 Makefile 示例,它直接编译所有源文件:

main: main.c square.c exponent.c
    gcc main.c square.c exponent.c -o main

运行 make 命令(默认查找 Makefile 文件)即可执行构建。

利用依赖关系

make 的智能之处在于它能检查文件的时间戳。如果目标文件已经存在,并且比所有依赖文件都新,make 就不会重新构建它。

我们可以改进 Makefile,为每个目标文件建立规则:

main: main.o square.o exponent.o
    gcc main.o square.o exponent.o -o main

main.o: main.c
    gcc -c main.c -o main.o

square.o: square.c
    gcc -c square.c -o square.o

exponent.o: exponent.c
    gcc -c exponent.c -o exponent.o

现在,如果只修改了 exponent.c,运行 make 将只会重新编译 exponent.o 并重新链接 main,而不会处理 main.osquare.o

使用变量和模式规则

为了使 Makefile 更简洁、可维护,我们可以使用变量和通配符。

以下是使用变量和模式规则的改进版本:

# 定义变量
CC = gcc
CFLAGS = -c
SOURCES = main.c square.c exponent.c
OBJECTS = $(SOURCES:.c=.o)
EXECUTABLE = main

# 默认目标:构建可执行文件
all: $(EXECUTABLE)

# 链接目标文件生成可执行文件
$(EXECUTABLE): $(OBJECTS)
    $(CC) $(OBJECTS) -o $@

# 模式规则:将任何 .c 文件编译为 .o 文件
%.o: %.c
    $(CC) $(CFLAGS) $< -o $@

# 清理生成的文件
clean:
    rm -f $(OBJECTS) $(EXECUTABLE)

# 声明 `clean` 为伪目标,不代表实际文件
.PHONY: clean all

在这个 Makefile 中:

  • $@ 代表目标文件名。
  • $< 代表第一个依赖文件名。
  • %.o: %.c 是一个模式规则,为每个 .c 文件如何生成对应的 .o 文件提供了通用方法。

测试与调试哲学

构建自动化是稳健软件开发的基础。同样重要的是建立良好的测试和调试习惯。

  • 测试:编写系统的测试用例,确保代码修改不会引入错误。自动化测试可以集成到构建过程中。
  • 调试:使用调试器(如 gdb)来逐步执行程序、检查变量状态,而不是仅仅依赖 printf 语句。理解程序的运行时行为是解决问题的关键。

总结

本节课中我们一起学习了软件开发环境的核心工具。我们探讨了单独编译的优势,并深入了解了如何使用 Makefilemake 工具来实现构建自动化,从而高效管理多文件项目。掌握这些工具是成为高效软件工程师的重要一步。

009:版本控制 🗂️

在本节课中,我们将完成对编程环境的介绍,重点学习版本控制系统。我们将了解版本控制的基本概念、为什么它如此重要,并学习使用 git 这一核心工具来管理代码变更。

上一节我们介绍了构建系统 make,本节中我们来看看如何系统地记录和管理代码的变更历史。

概述:什么是版本控制?

版本控制系统是一种记录文件内容变化,以便将来查阅特定版本历史的工具。它就像代码的“时光机”,允许你:

  • 追踪变更:记录每一次代码修改的内容、时间和原因。
  • 恢复历史:轻松回退到任何一个历史版本。
  • 协作开发:支持多人同时在一个项目上工作,并管理代码合并。
  • 分支管理:创建独立的分支来开发新功能或修复bug,而不影响主线代码。

核心概念:变更即交易

理解版本控制的一个好方法是类比银行账户。你的账户余额(当前代码状态)本身并不能告诉你它是如何变成这样的。你需要查看交易记录(变更历史)。

  • 当前余额 = 初始金额 + 所有(存入 - 支出)交易的总和。
  • 当前代码 = 初始代码 + 所有提交(commit)的变更总和。

通过保存一系列带有时间戳和描述的变更记录,你可以重建项目在任意历史时刻的状态。版本控制系统(如 git)就是自动管理这些“交易记录”的工具。

基础工具:diffpatch

在深入 git 之前,需要了解两个底层工具,它们是许多版本控制系统的基石。

diff:比较文件差异

diff 工具用于逐行比较两个文本文件,并输出它们之间的差异。

命令示例:

diff file_old.c file_new.c

输出含义:

  • < 开头的行表示仅存在于第一个(左侧/旧)文件。
  • > 开头的行表示仅存在于第二个(右侧/新)文件。

更常用的格式是“统一差异格式”(-u 选项),它提供了变更的上下文。

diff -u file_old.c file_new.c

输出含义:

  • --- 表示旧文件,+++ 表示新文件。
  • @@ -1,3 +1,5 @@ 表示变更发生的位置(旧文件第1行开始的3行,新文件第1行开始的5行)。
  • - 开头的行表示在旧文件中被删除。
  • + 开头的行表示在新文件中被添加。

patch:应用文件差异

patch 工具是 diff 的逆操作。它接受一个旧文件和一个由 diff 生成的“补丁”文件,并应用这些差异来生成新文件。

命令示例:

# 生成补丁文件
diff -u file_old.c file_new.c > change.patch
# 应用补丁,将 file_old.c 更新为 file_new.c 的内容
patch file_old.c < change.patch

概念关系:
diff 负责记录变更,patch 负责应用变更。一个版本历史可以看作是一系列按顺序应用的补丁。

版本控制系统:Git 🚀

git 是一个分布式版本控制系统,它基于上述“变更即快照”的理念,但功能更强大,尤其擅长处理分支和协作。

Git 的核心区域

一个 Git 仓库包含三个主要区域:

  1. 工作目录:你当前看到和编辑的文件。
  2. 暂存区:一个中间区域,用于临时存放你打算提交的变更。
  3. Git 仓库:存储所有提交历史和数据的地方(位于 .git/ 目录中)。

文件在这三个区域间的状态流转如下图所示:

工作目录 --(git add)--> 暂存区 --(git commit)--> Git仓库
      <--(git checkout)--

基本 Git 工作流

以下是管理个人项目时最常用的命令序列。

首次配置(在 Eustis 上只需一次):

git config --global user.name "你的姓名"
git config --global user.email "你的邮箱@ufl.edu"
git config --global core.editor "vim"  # 或 "emacs"

初始化仓库并提交代码:

# 进入项目目录
cd ~/hello
# 初始化一个新的 Git 仓库
git init
# 将文件添加到暂存区
git add hello.c Makefile hello.script
# 创建提交,保存暂存区的内容到仓库历史
git commit

执行 git commit 后会打开你配置的编辑器(如 Vim 或 Emacs)来编写提交信息。第一行是简短摘要,空一行后可以写详细描述。保存并退出编辑器即完成提交。

查看状态与历史:

# 查看工作目录和暂存区的状态
git status
# 查看提交历史
git log
# 查看某次提交的具体变更
git show <commit-id>

.gitignore 文件

你不需要将编译生成的二进制文件(如 hello.o, hello)提交到仓库。.gitignore 文件用于指定哪些文件或目录应该被 Git 忽略。

创建 .gitignore 文件:

# 在项目根目录创建 .gitignore 文件,内容如下:
hello
hello.o
*.out
# 如果是 Emacs 用户,还可以忽略备份文件
*~

然后记得将 .gitignore 文件本身加入版本控制:

git add .gitignore
git commit -m “Add .gitignore file”

远程仓库与提交作业

对于本课程,你需要将本地仓库同步到课程提供的 GitLab 服务器上。

添加远程仓库并推送代码:

# 将课程 GitLab 仓库添加为远程源,命名为 ‘origin’
git remote add origin https://gitlab.com/COP3402F24/hello-<你的GatorID>.git
# 将本地 master 分支的提交推送到远程 origin 仓库
git push -u origin master

验证提交:
完成推送后,一个重要的自检步骤是克隆自己的仓库到一个新目录,测试是否能成功构建和运行。

cd ~
git clone https://gitlab.com/COP3402F24/hello-<你的GatorID>.git hello-test
cd hello-test
make
./hello

如果这个过程成功,那么评分系统也能以同样的方式构建和运行你的程序。

为什么版本控制至关重要?

以下是使用版本控制的主要优势:

  • 历史追踪与回滚:无需猜测,可以精确查看谁在何时修改了什么,并能轻松恢复到工作版本。
  • 分支与并行开发:可以在独立的分支上开发新功能或修复 Bug,而不会影响稳定的主线代码。
  • 协作基石:使多人同时在同一个项目上工作成为可能,系统能帮助合并(merge)不同人的修改。
  • 自动化支持:结合 make,可以实现从克隆到构建的完全自动化,这是现代软件分发的标准。
  • 专业实践:是所有正规软件开发团队的标准工具,掌握它是成为专业软件工程师的必备技能。

总结

本节课中我们一起学习了版本控制的核心思想与工具。我们了解到:

  1. 版本控制通过记录变更(类似交易记录)来管理文件历史。
  2. 基础工具 diffpatch 展示了记录与应用变更的原理。
  3. Git 是一个功能强大的分布式版本控制系统,它通过工作目录暂存区仓库三个区域来管理代码状态。
  4. 我们学习了基本的 Git 工作流:init -> add -> commit -> push,以及如何使用 .gitignore 排除不需要版本控制的文件。
  5. 最后,我们强调了版本控制对于代码管理、协作开发和专业实践的重要性。

请务必按照 Hello World 项目的要求,配置好 Git 环境,创建 Makefile,并使用 Git 提交你的作业。掌握这些工具将为你未来的编程和软件工程工作打下坚实的基础。

010:使用文件 🖥️📁

在本节课中,我们将开始系统编程部分的学习。我们将探讨如何复制一些核心工具,并了解内核中可用的一些常见系统调用。首先,我们会回顾上次的作业,然后深入讲解如何使用文件相关的系统调用,包括错误处理、文件描述符、目录读取等核心概念。

作业回顾与项目提交

上一节课的作业是阅读《Linux编程接口》一书中关于Linux编程的入门材料。核心问题是:如何检查系统调用抛出的错误类型?

答案是检查 errno 这个全局变量的值。但在此之前,你必须先判断是否有错误发生。对于许多函数,错误返回值是 -1。今天我们将学习如何阅读文档,它会明确告诉你返回值是什么以及错误条件是什么。在Unix哲学中,开发者需要自己检查这些错误。编写系统代码时很容易忘记错误检查,因为除非遇到段错误等问题,否则内核不会阻止你。今天我们将更详细地讨论错误检查,这在编程中是一个很好的练习,因为乐观编程而不考虑可能出错的情况很容易。在内核系统编程中,错误处理需要开发者非常明确和手动地进行。

关于项目提交,请记住:你必须在截止日期前至少提交一次(即使完全失败)。之后你可以重新提交。但如果你今天没有提交,则无法重新提交。这是为了确保每个人都能跟上课程进度。提交项目只需将代码推送到Git仓库,这与现实世界中的软件工程工作流程一致。

系统编程与内核简介 🧠

系统软件是运行在内核和硬件之上的应用程序,其特点是提供连接内核/硬件与应用程序的低级功能。当我们进行系统编程时,会大量使用内核提供的低级抽象。

内核就像一个库,提供了用户空间和硬件之间的抽象层。它统一了不同硬件的接口,使得编写跨硬件平台的代码变得更加容易。在现代硬件中,用户空间和内核空间之间的边界由硬件强制执行,这是现代计算机保护机制的基础。用户空间程序无法直接访问硬件,必须通过内核。通过特殊的软件中断(如Linux中的 int 0x80),程序可以切换到内核模式,执行系统调用,然后返回用户空间。

从程序员的角度看,系统调用就像C函数一样工作:你调用它们,它们返回一个值。C库中有一个“包装器”函数来处理参数传递、收集返回值和错误代码。一个主要区别在于错误处理的方式。

Unix风格的错误处理 ⚠️

在Unix世界中,错误处理的责任交给了开发者。基本模式是:每次进行系统调用(或库调用)后,都应检查其返回值以判断是否出错。

例如,open 系统调用的错误处理模式如下:

int fd = open(pathname, flags);
if (fd == -1) {
    perror("open");
    exit(EXIT_FAILURE);
}
  1. 运行系统调用。
  2. 检查其返回值(许多系统调用用 -1 表示错误,但需查阅文档确认)。
  3. 如果出错,全局变量 errno 会被设置为特定的错误代码。可以使用 perror() 函数将其转换为可读的错误信息。
  4. 根据情况处理错误(在本课程中,通常直接退出程序)。

每个系统调用都有自己可能返回的错误代码列表,定义在头文件中(如 EACCES, ENOENT 等)。使用 perror 可以方便地打印对应的错误信息。坚持对每个系统调用进行错误检查,可以避免程序出现静默失败,从而更容易调试。

文件操作的系统调用 📄

对于文件操作,核心的系统调用是 open, read, write, close。与之对应的C标准库函数是 fopen, fprintf, fscanf, fclose 等。库函数在系统调用的基础上提供了更便捷的功能(如格式化输出、缓冲等)。

以下是使用 openread 的基本流程:

  1. 打开文件:使用 open 系统调用获取一个文件描述符(一个整数)。
    int fd = open("filename.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    
  2. 读取文件:使用 read 系统调用从文件描述符读取原始字节数据。
    char buffer[1024];
    ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1); // 留一个位置给空字符
    if (bytes_read == -1) {
        perror("read");
        close(fd);
        exit(EXIT_FAILURE);
    }
    buffer[bytes_read] = '\0'; // 手动添加字符串终止符
    
    注意:read 返回的是实际读取的字节数,它处理的是原始字节,不假设内容是文本。
  3. 关闭文件:使用 close 系统调用释放文件描述符。
    if (close(fd) == -1) {
        perror("close");
        exit(EXIT_FAILURE);
    }
    

与使用 fgets 等库函数不同,使用系统调用需要开发者自己管理缓冲区、读取循环和字符串终止符。

目录读取 📂

为了列出目录内容(例如实现 ls 命令的功能),我们需要使用目录相关的函数。虽然它们不是纯粹的系统调用,而是库函数,但仍然属于低级接口。

主要函数有 opendir, readdir, closedir

  1. 打开目录opendir 返回一个指向 DIR 结构的指针。
    DIR *dirp = opendir(".");
    if (dirp == NULL) {
        perror("opendir");
        exit(EXIT_FAILURE);
    }
    
  2. 读取目录条目readdir 每次调用返回一个 struct dirent*,指向目录中的下一个条目。需要循环调用直到返回 NULL
    struct dirent *dp;
    while ((dp = readdir(dirp)) != NULL) {
        printf("%s\n", dp->d_name); // 打印文件名
    }
    
    struct dirent 结构至少包含 d_name 字段,即文件名。遍历顺序通常是目录条目在磁盘上的顺序。
  3. 关闭目录
    if (closedir(dirp) == -1) {
        perror("closedir");
        exit(EXIT_FAILURE);
    }
    

项目“my_ls”简介 🗂️

下一个项目是编写一个简化版的 ls 命令,名为 my_ls。要求如下:

  • 程序接受一个可选参数,即目录路径(相对或绝对)。如果没有提供参数,则使用当前工作目录。
  • 使用本节介绍的系统调用和目录函数来实现。
  • 输出目录中所有文件(包括 ...)的列表以及一些元数据信息(后续会通过 stat 系统调用获取)。
  • 必须进行严格的错误处理。

总结 🎯

本节课我们一起开始了系统编程的学习。我们首先回顾了错误处理的重要性及Unix风格的手动错误检查模式。接着,我们探讨了内核的角色以及用户空间与内核空间之间的交互。然后,我们深入学习了用于文件操作的核心系统调用(open, read, close)及其与C库函数的区别,并强调了使用原始字节接口时的注意事项。最后,我们介绍了如何读取目录内容(opendir, readdir, closedir),这是实现 my_ls 项目的基础。记住,系统编程的关键在于细致和明确,尤其是对每一次可能失败的操作进行错误检查。

011:进程创建 (COP-3402 Fall 2024)

在本节课中,我们将学习如何使用内核提供的系统调用来创建和管理进程。这是系统编程的核心部分,也是我们为编写命令行解释器(shell)项目做准备的关键一步。

上一节我们介绍了文件系统相关的系统调用。本节中,我们来看看进程管理,特别是如何使用 forkexec 系统调用来创建新的运行程序。

项目回顾:myLS

在深入进程之前,我们先简要回顾一下即将进行的项目:myLS。这个项目要求你编写一个简化版的 ls 命令。

以下是 myLS 需要实现的功能:

  • 程序接受一个可选的目录路径作为参数。如果未提供参数,则默认列出当前工作目录的内容。
  • 对于目录中的每个条目,输出以下信息:
    • 文件名:条目的名称。
    • 硬链接数:指向该文件的链接数量。
    • 文件类型:是目录(directory)还是普通文件(regular)。
    • 大小:如果是目录,输出其包含的条目数;如果是普通文件,输出其字节大小。
    • 预览:仅对普通文件,输出其内容的前16个字符。

为了完成这个项目,你需要使用 stat 系统调用来获取文件元数据,使用 opendir/readdir 来遍历目录,以及使用 open/read 来获取文件预览内容。请务必查阅相关手册页(例如 man 2 stat)来了解这些系统调用的具体用法和数据结构。

进程抽象

进程是正在运行的程序。为什么需要进程这个抽象概念?主要是为了实现资源共享时间分片。现代计算机通常只有少数几个处理器核心,但用户可能同时运行数十个程序。内核通过进程抽象来管理这些程序,在它们之间快速切换CPU使用权,给用户一种所有程序都在同时运行的错觉。

创建新进程:fork

在Unix-like系统中,创建一个新进程的基本方法是调用 fork 系统调用。

fork 的作用是复制当前正在运行的进程。调用 fork 后,会生成一个几乎完全相同的子进程,包括相同的代码、内存状态和打开的文件描述符。

那么问题来了:如果父进程和子进程完全相同,它们如何执行不同的任务呢?关键在于 fork 的返回值。

fork 的返回值规则如下:

  • 父进程中,fork 返回新创建的子进程的进程ID(PID)
  • 子进程中,fork 返回 0
  • 如果创建失败,则返回 -1

通过检查 fork 的返回值,程序就可以区分自己当前是运行在父进程还是子进程中,从而执行不同的代码路径。

以下是一个简单的 fork 示例代码:

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

int main() {
    pid_t pid = fork(); // 创建新进程

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

运行这段代码,你会看到来自父进程和子进程的两条输出信息,证明确实有两个进程在运行。

运行新程序:exec

fork 创建了一个新进程,但运行的是相同的程序。如果我们想运行一个全新的程序(例如,在shell中输入 ls 后运行 /usr/bin/ls),就需要使用 exec 系列系统调用。

exec 的作用是替换当前进程正在运行的程序。它不会创建新进程,而是将当前进程的代码段、数据段等替换为指定路径下新程序的代码和数据,然后从新程序的入口点开始执行。

关键特性:如果 exec 调用成功,它永远不会返回。因为原程序的代码已经被新程序完全覆盖。只有当 exec 调用失败(例如,找不到指定程序)时,它才会返回错误。

以下是一个简单的 exec 示例,它用 ls 命令替换掉自身:

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("This is my program. About to replace myself with ls...\n");
    fflush(stdout); // 确保输出被刷新

    // 使用 execvp 执行 ls 命令
    char *args[] = {"ls", "-l", NULL};
    execvp("ls", args);

    // 如果 execvp 成功,以下代码永远不会执行
    perror("execvp failed"); // 只有失败时才会运行到这里
    return 1;
}

组合使用 forkexec

forkexec 组合起来,就构成了在Unix系统中启动新程序的经典模式:

  1. 父进程调用 fork,创建一个子进程。
  2. 子进程调用 exec,将自己替换成想要运行的新程序。
  3. 父进程可以继续执行自己的逻辑,或者通过 wait 等系统调用等待子进程结束。

这种设计非常灵活,分离了“创建新进程”和“加载新程序”这两个操作。例如,shell程序就是利用这个模式来运行用户输入的所有命令的。

总结

本节课中我们一起学习了进程创建的核心机制。我们了解了进程作为运行中程序的抽象概念及其重要性。我们重点掌握了两个关键的系统调用:

  • fork():用于复制当前进程,创建子进程。通过其不同的返回值来区分父子进程。
  • exec():用于将当前进程替换为一个全新的程序。成功调用后不会返回。

理解 forkexec 是进行系统编程和实现像shell这样的工具的基础。在接下来的课程中,我们将学习进程间通信,以便让这些进程能够协同工作。

012:进程I/O与管道

在本节课中,我们将要学习系统编程中的一个核心概念:进程间通信。具体来说,我们将深入探讨如何使用管道(pipe)这一机制,让不同的进程能够相互传递数据。这是构建命令行工具(如Shell)的基础。

上一节我们介绍了如何使用 forkexec 创建和替换进程。本节中我们来看看,如何让这些进程“对话”。

课程概述与回顾

首先,我们快速回顾一下上节课的内容。进程是一个正在运行的程序。内核通过一个进程表来管理所有当前运行的进程。fork 系统调用用于创建一个新进程,它会复制当前进程(称为父进程)的状态,生成一个子进程。exec 系统调用则用于将当前进程的程序替换为一个全新的程序。

一个学生曾提问:这些进程之后还能相互通信吗?答案是肯定的,而管道就是实现这种通信的关键机制之一,这也将是我们下一个项目要用到的。

关于Hello项目的说明

Hello项目已经完成评分。绝大多数同学获得了满分(6/6)。有少数同学得了零分。如果你得了零分,通常意味着我们无法在你的Git仓库中找到提交,或者提交不符合要求(例如包含了二进制文件)。请务必按照项目要求使用Git提交。如果你对评分有疑问,请来我的办公时间讨论。

此外,评分脚本在检查程序是否正确运行时,错误地依赖了程序的退出码。项目要求并未涉及退出码,但请注意,main 函数应声明为 int main 而非 void main,以避免潜在的未定义行为。相关问题已重新评分。

预习:MySH项目

接下来,让我们预览一下即将开始的MySH项目。就像MyLS项目是 ls 命令的简化版一样,MySH将是一个简化版的命令行Shell。你将使用我们近期学习的系统调用,亲手构建一个能处理命令的程序。

你的Shell需要实现以下功能:

  • 读取并执行 exit 命令来终止自身。
  • 使用 chdir 改变工作目录。
  • 运行其他程序(例如 ls)。
  • 实现管道功能。

以下是管道在Shell中的一个例子:

ls | wc

这个命令会将 ls 的输出作为 wc(单词计数)命令的输入。你的任务就是理解并实现这种“管道”机制。

进程间通信与管道

为什么需要进程间通信?设想一下,当你使用浏览器访问网站时,你的浏览器进程需要与远程的Web服务器进程通信。管道就是一种在同一台机器上实现进程间通信的简单而强大的方式。

在Unix哲学中,“一切皆文件”。每个进程启动时,内核都会为它打开三个标准的文件描述符:

  • 标准输入 (stdin):文件描述符 0,用于读取输入。
  • 标准输出 (stdout):文件描述符 1,用于输出结果。
  • 标准错误 (stderr):文件描述符 2,用于输出错误信息。

printf 这样的函数,底层其实就是向文件描述符1(stdout)写入数据。管道利用了这种标准I/O的设计,在不修改程序本身的情况下,重定向它们的输入和输出。

那么,什么是管道?管道是一个由内核维护的、特殊的“文件”。它有两个端点:

  • 写入端:可以向其中写入数据。
  • 读取端:可以从其中读取数据。

数据从写入端流入,从读取端流出。这就像一根水管,连接了两个进程。Shell(如Bash)就是利用管道,将前一个命令(如 ls)的stdout,连接到后一个命令(如 wc)的stdin。

从程序员的角度看,网络套接字(socket)可以看作是一种跨越机器的、更复杂的管道。理解了管道,就为学习网络通信打下了基础。

管道系统调用:pipe

让我们看看如何创建管道。pipe 系统调用的函数原型如下:

int pipe(int pipefd[2]);
  • pipefd 是一个包含两个整数的数组。
  • 调用成功后,pipefd[0] 会成为管道的读取端的文件描述符,pipefd[1] 会成为管道的写入端的文件描述符。
  • 成功返回0,失败返回-1。

内核会负责缓冲写入管道的数据,直到有进程从读取端将其读出。

实践:在单个程序中使用管道

为了理解管道的基本用法,我们先来看一个简单的例子:在一个程序内创建父子进程,并通过管道传递消息。

以下是实现此功能的核心代码步骤和逻辑:

  1. 创建管道:使用 pipe(pipefd) 获取一对文件描述符。
  2. 创建子进程:使用 fork()。现在有两个进程(父进程和子进程),它们都拥有管道两端的文件描述符副本。
  3. 确定通信方向:通常,我们让一个进程只写,另一个进程只读,以避免混乱。
    • 子进程中,关闭不需要的写入端 (close(pipefd[1])),然后从读取端 (pipefd[0]) 读取数据。
    • 父进程中,关闭不需要的读取端 (close(pipefd[0])),然后向写入端 (pipefd[1]) 写入数据。
  4. 执行读写
    • 父进程使用 write(pipefd[1], buffer, size) 写入数据。
    • 子进程使用 read(pipefd[0], buffer, size) 读取数据,并可以将其输出到自己的标准输出。
  5. 等待子进程结束:父进程使用 wait(NULL) 等待子进程,防止子进程变成“僵尸进程”。

通过这样的设置,父进程写入管道的数据,被子进程读取并打印出来,实现了进程间通信。

管道在Shell中的实现原理

现在,我们来看Shell如何利用管道执行像 ls | wc 这样的命令。这个过程由Shell(父进程)协调多个子进程完成。

以下是其实现算法的概要:

  1. 创建管道:Shell调用 pipe() 创建管道,得到 pipefd[0](读端)和 pipefd[1](写端)。
  2. 创建第一个子进程(执行 ls
    • Shell调用 fork() 创建子进程1。
    • 在子进程1中:
      a. 关闭管道的读端 (close(pipefd[0]))。
      b. 使用 dup2(pipefd[1], STDOUT_FILENO)。这个调用将标准输出(文件描述符1)重定向到管道的写端。现在,任何写入stdout的数据都会进入管道。
      c. 关闭原始的管道写端描述符 (close(pipefd[1]))。
      d. 调用 exec() 执行 ls 程序。ls 会正常地向它的stdout输出,但这些输出已被重定向到管道。
  3. 创建第二个子进程(执行 wc
    • Shell再次调用 fork() 创建子进程2。
    • 在子进程2中:
      a. 关闭管道的写端 (close(pipefd[1]))。
      b. 使用 dup2(pipefd[0], STDIN_FILENO)。这个调用将标准输入(文件描述符0)重定向到管道的读端。现在,wc 将从管道读取数据作为输入。
      c. 关闭原始的管道读端描述符 (close(pipefd[0]))。
      d. 调用 exec() 执行 wc 程序。
  4. Shell等待:在父进程(Shell)中,关闭两个管道描述符,然后调用 wait() 等待两个子进程结束。

通过这一系列精巧的文件描述符重定向操作,ls 的输出流经管道,成为了 wc 的输入,而 lswc 这两个程序本身无需任何修改。

dup2 系统调用是这里的关键,它的作用是复制一个文件描述符到另一个指定的文件描述符编号上,如果目标编号已打开,则会先关闭它。这完美地实现了标准I/O的重定向。

总结

本节课中我们一起学习了进程间通信的重要机制——管道。

  • 我们回顾了进程的概念,以及使用 forkexec 进行进程管理。
  • 我们引入了管道的概念,它是由内核提供的、用于进程间通信的特殊文件,具有读端和写端。
  • 我们学习了 pipe() 系统调用的基本用法,并编写了一个父子进程通过管道传递消息的示例程序。
  • 最后,我们剖析了Shell实现命令管道(如 ls | wc)的核心算法,理解了如何通过 forkdup2 和文件描述符的重定向,将多个程序的输入输出连接起来。

掌握这些知识,是完成MySH项目、构建你自己简易Shell的基石。下节课我们将深入探讨 dup2 的细节并开始设计MySH的实现。

013:构建一个Shell (COP-3402 Fall 2024)

在本节课中,我们将学习如何构建一个简单的命令行Shell。我们将重点讲解进程创建、管道(pipe)和输入/输出重定向的核心概念,这些都是系统编程的基础。通过理解这些机制,你将能够实现一个可以运行命令、连接多个命令(管道)以及重定向输入/输出的基本Shell程序。


回顾与项目提醒

上一节我们介绍了系统编程的基础。现在,我们来看看当前的项目 myLS。这个项目旨在让你熟悉系统调用的使用,例如 readdirstat 等,来列出目录内容。

以下是关于 myLS 项目的一些要点澄清:

  • 项目使用 Git 提交,请遵循提交说明。
  • 你可以使用标准 C 库函数(如 malloc, strcat)和 ctype.h 中的函数(如 isprint)来检查可打印字符。
  • 核心算法是遍历目录,对每个文件使用 stat 系统调用获取信息(如硬链接数),然后格式化输出。
  • 建议采用“分步细化”的编程方法:先写算法步骤或注释,再逐个实现和测试,而不是一次性编写整个程序。
  • 程序架构可以自由设计,只要能够通过提供的 Makefile 正确构建和运行。

如果对项目有具体问题,请参加本周的答疑时间。


管道(Pipe)与进程间通信

在理解了基本的文件操作后,本节我们来看看如何实现进程间的通信,这是Shell实现管道的核心。

什么是管道?

在Unix内核中,管道是一种特殊的文件,它允许一个进程写入数据,另一个进程读取这些数据。内核在内存中维护一个缓冲区来存储这些数据。管道是实现命令行中 | 符号功能的基础。

如何创建管道?

在C语言中,使用 pipe 系统调用来创建管道。

代码示例:创建管道

int pipefd[2]; // pipefd[0] 用于读取,pipefd[1] 用于写入
if (pipe(pipefd) == -1) {
    // 错误处理
    perror("pipe");
    exit(EXIT_FAILURE);
}

pipe 调用成功后会填充一个包含两个文件描述符的数组:pipefd[0] 是管道的读取端pipefd[1] 是管道的写入端


Shell 中管道的实现算法

现在,我们探讨Shell如何利用 forkexecpipedup2 这些系统调用,来实现像 ls | wc -l 这样的管道命令。

我们的目标是编写一个名为 mySH 的Shell程序。假设我们要执行 ls | wc -l,以下是 mySH 需要完成的核心步骤:

  1. 创建管道:调用 pipe(pipefd)。此时,mySH 进程拥有了管道读写端的文件描述符。
  2. 创建第一个子进程(用于 ls
    • 调用 fork() 创建新进程。子进程是 mySH 的副本,继承了所有打开的文件描述符,包括管道。
    • 在子进程中:
      a. 使用 dup2(pipefd[1], STDOUT_FILENO) 将标准输出重定向到管道的写入端。这样,ls 的输出就不会打印到终端,而是进入管道。
      b. 关闭不需要的管道端(例如,关闭 pipefd[0])。
      c. 调用 execvp(“ls”, args) 来执行 ls 程序,替换当前子进程的代码。
  3. 创建第二个子进程(用于 wc
    • 在父进程(mySH)中,再次调用 fork() 创建第二个子进程。
    • 在第二个子进程中:
      a. 使用 dup2(pipefd[0], STDIN_FILENO) 将标准输入重定向到管道的读取端。这样,wc 将从管道读取数据,而不是从终端。
      b. 关闭不需要的管道端(例如,关闭 pipefd[1])。
      c. 调用 execvp(“wc”, args) 来执行 wc 程序。
  4. 父进程等待
    • 在父进程中,关闭所有管道端(因为父进程本身不需要读写管道)。
    • 调用 wait()waitpid() 系统调用,等待所有子进程执行完毕,防止它们变成“僵尸进程”。

关键点理解

  • fork() 复制整个进程,包括文件描述符表。因此,子进程可以访问父进程创建的管道。
  • dup2(old_fd, new_fd) 的作用是让文件描述符 new_fd 成为 old_fd 的一个副本(指向同一个打开的文件)。通过让 STDOUT_FILENO (通常是1) 成为管道写入端的副本,就实现了输出重定向。
  • exec() 系列函数用新程序替换当前进程的代码段,但保留进程的其他属性,如进程ID、打开的文件描述符表等。这正是重定向能生效的原因。
  • 文件重定向(如 ls > output.txt)的原理与此类似,只是将 dup2 的目标从一个管道文件描述符换成了一个普通文件的文件描述符。

分步实现与编程方法

上一节我们介绍了管道实现的整体算法,本节中我们来看看如何用代码具体实现,并介绍一种有效的编程方法:“分步细化”或“乌龟策略”。

不要试图一次性写出所有代码。相反,应该将问题分解,逐步实现和测试。

以下是实现管道第一步的示例思路:

  1. 从注释和核心调用开始:先写下你最确定要做的事。
    // 创建管道
    int pipefd[2];
    pipe(pipefd);
    
    // 创建第一个子进程运行 ls
    pid_t pid = fork();
    if (pid == 0) {
        // 在子进程中
        // 重定向标准输出到管道写端
        dup2(pipefd[1], STDOUT_FILENO);
        // 关闭不需要的管道端
        close(pipefd[0]);
        close(pipefd[1]);
        // 执行 ls
        char *args[] = {“ls”, NULL};
        execvp(“ls”, args);
        // 如果 execvp 失败
        perror(“execvp”);
        exit(EXIT_FAILURE);
    } else if (pid > 0) {
        // 在父进程中,继续创建第二个子进程...
    } else {
        // fork 失败
        perror(“fork”);
        exit(EXIT_FAILURE);
    }
    
  2. 先搭建框架,再填充细节:比如,你可能不确定 execvp 的参数如何从用户输入解析。你可以先硬编码一个命令(如 ls),确保管道和重定向部分能工作。
  3. 编译和测试每一个小步骤:每写完一小部分功能就立即编译运行,检查是否有语法错误或逻辑问题。使用工具如 tmuxscreen 来分屏编辑和测试。
  4. 处理用户输入:在管道逻辑正确后,再增加从标准输入读取命令行、解析命令和参数(例如使用 strtok 函数)的功能。
  5. 错误处理:为所有系统调用添加基本的错误检查(检查返回值是否为 -1 并使用 perror 打印错误信息)。

这种方法的优势在于,你将复杂任务分解为可管理的小块,每次只专注于解决一个问题,并通过即时测试快速反馈,避免在调试时面对大量混乱的代码。

对于 mySH 项目,你可以按照类似的步骤进行:首先实现运行单个命令,然后实现输入/输出重定向,最后实现管道连接多个命令。


总结

本节课中我们一起学习了构建一个简单Shell的核心机制。

  • 我们回顾了 myLS 项目,强调了分步实现和测试的重要性。
  • 我们深入讲解了管道的概念,它是内核提供的一种进程间通信机制。
  • 我们详细分析了Shell执行 ls | wc -l 这样的管道命令时,内部如何通过 forkexecpipedup2 系统调用协同工作,实现进程创建、输出重定向和输入重定向。
  • 最后,我们介绍了一种实用的“分步细化”编程方法,鼓励你先设计算法、写注释,再逐步编码和测试,从而更清晰、更高效地完成系统编程项目。

理解这些原理是完成 mySH 项目的基础。请务必动手实践,从运行单个命令开始,逐步增加功能。

014:期中复习 (COP-3402 Fall 2024)

在本节课中,我们将回顾期中考试所需的所有知识点。考试内容涵盖作业、项目和课堂讲授的内容。我们将逐一讲解考试中可能出现的各类问题,并确保你理解所有核心概念。

考试形式与规则

上一节我们介绍了课程概述,本节中我们来看看考试的具体形式和规则。

考试将于下周二线下进行。如果你有SAS的特殊考试安排,相关材料将被上传,你可以在指定地点完成考试。如果你需要调整考试时间,请务必提前与我沟通,我们将安排补考。

以下是考试的基本信息:

  • 考试内容:涵盖作业、项目和课堂讲授的所有内容。
  • 允许携带的笔记:你可以携带最多8张双面打印的A4纸大小的笔记。笔记内容不应包含完整的答案或试图猜测考题。
  • 考试结构:考试包含6道选择题、7道简答题和3道论述题。论述题可能涉及编写代码、描述算法或绘制文件树等。
  • 答题纸:考试将使用单独的答题纸,以便于批改。请务必在每一页答题纸上写上你的全名和学号。
  • 考试时长与分值:考试时长为80分钟,总分8分(占课程总成绩的8%)。每道题价值0.5分,评分时会考虑部分正确的情况。
  • 考试纪律:请严格遵守课程关于作弊和未经授权协助的政策。

文件系统

上一节我们了解了考试规则,本节中我们来看看文件系统的核心概念。

Unix文件系统是分层的。这意味着文件系统像一棵树一样组织,目录可以包含文件和其他目录。

  • 目录是一种特殊类型的文件,其内容是一个映射表(或字典),将文件名关联到对应的文件。
  • 由于目录本身也是文件,并且可以包含其他目录,因此可以构建出这种树形(或更准确地说是有向无环图)的层次结构。

在文件系统的上下文中,路径是定位文件的地址。

  • 绝对路径:从根目录(/)开始。例如:/home/paul/joe.txt
  • 相对路径:从当前工作目录开始,不以斜杠开头。例如:如果当前在/home/paul,那么joe.txt./joe.txt 指向同一个文件。

以下是路径相关的特殊符号:

  • . (点):代表当前目录本身。
  • .. (点点):代表父目录。

你可能会被要求根据给定的文件树结构,写出某个文件的绝对路径或相对路径。

命令行导航与操作

理解了文件系统结构后,我们需要知道如何在其中移动和操作文件。

以下是一些基本的Bash命令:

  • pwd:打印当前工作目录。
  • cd:改变当前工作目录。cd ~cd 可返回家目录。cd - 可返回上一个目录。
  • mv:移动或重命名文件。语法:mv <旧名称> <新名称>
  • rm:删除文件。注意,rm 只是从目录中移除条目,文件数据可能仍留在磁盘上,直到被覆盖。
  • ls:列出目录内容。
  • cat:连接并打印文件内容到标准输出。
  • grep:在文件中搜索匹配指定模式(字符串或正则表达式)的行。
  • wc:统计文件的行数、单词数和字节数。
  • touch:如果文件不存在则创建空文件;如果存在,则更新其访问和修改时间戳。

Tab补全是一个提高效率的技巧:输入命令或文件名的一部分后按Tab键,Bash会尝试自动补全。如果没有唯一匹配项,按两次Tab会显示所有可能的选项。

进程与输入/输出重定向

在命令行中,我们经常需要管理进程和数据流。

  • 重定向
    • command > file:将命令的标准输出重定向到文件(覆盖)。
    • command >> file:将命令的标准输出追加到文件。
    • command < file:将文件内容作为命令的标准输入。
  • 管道command1 | command2。将 command1 的标准输出作为 command2 的标准输入。例如:ls | wc -l 可以统计当前目录下的文件数量。
  • 编辑器:在命令行中编辑文件,常用 vimemacs。例如,用 vim filename 打开文件,在 vim 中按 :wq 保存并退出。

构建自动化 (Make)

对于多文件项目,手动编译很繁琐。make 工具可以自动化构建过程。

一个 Makefile 规则的基本语法如下:

target: prerequisites
    recipe
  • 目标:规则要生成的文件名(例如,可执行程序 main)。
  • 前置条件:生成目标文件所依赖的其他文件(例如,main.c, helper.c)。
  • 配方:生成目标文件需要执行的一系列shell命令(例如,gcc main.c helper.c -o main)。

make 会根据文件的时间戳判断哪些目标需要重新构建。一个常见的约定是定义一个 clean 目标,用于删除所有生成的文件:

clean:
    rm -f main *.o

版本控制 (Git)

版本控制系统帮助我们管理代码的变更历史。

以下是Git的基本命令:

  • git add <file>:将文件的更改添加到暂存区。
  • git commit -m "message":将暂存区的更改提交到本地仓库,并附上描述信息。
  • git push:将本地仓库的提交推送到远程仓库。
  • git pull:从远程仓库拉取更新到本地仓库。
  • git clone <url>:克隆一个远程仓库到本地。

系统调用

系统调用是程序与操作系统内核交互的接口。

打开文件:使用 open 系统调用。

int fd = open(“filepath”, O_RDONLY); // 以只读方式打开
if (fd == -1) {
    perror(“open failed”); // 错误处理
}

错误检查:系统调用(如 open, read, write)通常通过返回 -1 来表示失败。应检查返回值,并使用 perror 打印错误信息,而不是直接检查 errno

读取文件:使用 read 系统调用。

char buffer[200];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read == -1) { /* 处理错误 */ }
buffer[bytes_read] = ‘\0’; // 假设是文本

读取目录:使用 opendir, readdir, closedir 函数(它们是库函数,但底层使用系统调用)。

DIR *dir = opendir(“.”);
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
    printf(“%s\n”, entry->d_name);
}
closedir(dir);

获取文件信息:使用 stat 系统调用获取文件大小、权限等元数据。

进程管理

操作系统通过进程来运行程序。

创建新进程:在Unix中,创建新进程运行不同程序需要两个步骤:

  1. fork():复制当前进程,创建一个几乎完全相同的子进程。区分父子进程的依据是 fork() 的返回值:
    • 在子进程中返回 0
    • 在父进程中返回子进程的PID(>0)。
    • 出错时返回 -1
  2. exec() 系列函数:将当前进程的映像替换为新的程序文件。它不创建新进程,只是“换脑”。

示例:创建父子进程

pid_t pid = fork();
if (pid == 0) {
    // 子进程代码
    printf(“child\n”);
} else if (pid > 0) {
    // 父进程代码
    printf(“parent\n”);
} else {
    perror(“fork failed”);
}

管道:管道用于进程间通信。pipe() 系统调用创建一个管道,返回两个文件描述符:pipefd[0] 用于读,pipefd[1] 用于写。

int pipefd[2];
pipe(pipefd);
write(pipefd[1], “Hello”, 6);
char buf[10];
read(pipefd[0], buf, 10);

重定向标准输出:使用 dup2() 系统调用可以将一个文件描述符复制到另一个。例如,将标准输出(文件描述符1)重定向到一个文件:

int fd = open(“output.txt”, O_WRONLY | O_CREAT, 0644);
dup2(fd, STDOUT_FILENO); // 现在 printf 会写入文件
close(fd);

总结

本节课中我们一起学习了期中考试的核心知识点。我们回顾了:

  1. Unix文件系统的层次结构、路径和基本操作命令。
  2. 进程的概念、输入/输出重定向和管道。
  3. 使用 make 进行构建自动化。
  4. Git版本控制的基本工作流。
  5. 关键的系统调用,如 openreadwriteforkexecpipedup2,以及如何进行错误处理。
    请结合课堂代码片段、作业和项目进行复习,重点理解概念而非死记硬背细节。祝你考试顺利!

015:从源代码到运行程序

在本节课中,我们将学习编译器如何将人类可读的源代码(如C语言程序)转换为计算机可以执行的运行程序(进程)。我们将探讨编译器和解释器的核心区别,并通过一个简单的例子来理解翻译过程。

什么是程序与进程?

上一节我们介绍了系统软件的概览,本节中我们来看看程序是如何运行的。

首先,我们需要明确两个核心概念:程序进程

  • 程序:程序是一系列指令的集合,用于告诉计算机执行特定任务。在计算机体系结构中,这通常指机器指令。然而,我们编写的C语言源代码并不是直接面向物理处理器的机器指令,而是面向一种虚拟机器
  • 进程:进程是一个正在运行的程序。当程序被加载到内存中并由操作系统调度执行时,它就成为了一个进程。

核心问题在于:我们编写的C语言源代码(文本文件)如何最终变成一个在CPU上运行的进程?

编译与执行:GCC的作用

为了运行一个C程序,我们通常使用两个命令:

gcc hello.c -o hello
./hello

第一个命令gcc编译,第二个命令./hello执行

gcc本身也是一个程序(编译器),它的作用是读取你的C源代码,并将其翻译成一个可以在你的系统架构(如x86或ARM)上运行的可执行程序

这里有一个有趣的现象:编译器(如gcc)本身也是用C语言编写的。那么,第一个C编译器是如何被编译的呢?这引出了自举问题:通常,人们会先用汇编语言编写一个简单的C编译器,然后用这个编译器去编译一个功能更全的、用C语言编写的编译器。

编译器 vs. 解释器

实现编程语言主要有两种技术:编译器解释器

以下是两者的核心区别:

  • 编译器:编译器读取整个源代码程序,并将其翻译成另一种语言(通常是更低级的语言,如汇编或机器码)的程序。编译器本身不执行源程序。
    • 公式/核心行为编译器(源代码) -> 目标代码(如可执行文件)
  • 解释器:解释器读取源代码,并直接模拟或执行每一行代码的行为,同时接收程序的输入并产生输出。
    • 公式/核心行为解释器(源代码, 输入数据) -> 输出结果

以C语言为例,gcchello.c编译成hello可执行文件(机器码)。当我们运行./hello时,是CPU在“解释”执行这些机器码指令。因此,CPU可以看作机器码的硬件解释器。

像Python或JavaScript通常是解释型语言,但现代语言实现往往混合使用编译和解释技术(如即时编译JIT)以提高性能。

程序等价性

编译器必须保证翻译前后的程序是等价的。但这具体指什么?C语言中的for循环在汇编语言中并不存在,汇编中只有jumpbranch指令。

我们通常使用功能等价观测等价来定义:对于所有相同的输入,两个程序应产生完全相同的输出。

对于确定性的程序(我们课程中讨论的都是此类),这是一个可行的检验标准。然而,通过测试所有可能的输入来证明编译器正确性是不现实的(输入可能无限,且存在停机问题等理论限制)。在实践中,编译器开发者使用形式化验证和大量测试来保证正确性。

第一个简单的编译器示例

让我们通过一个极简的例子来理解编译器的工作。假设我们有一个只能处理个位数加减法的“语言”,输入格式为:一个数字 运算符 一个数字,例如 1+2

我们的编译器(用C编写)需要将这个字符串翻译成等价的x86汇编代码。

编译器代码思路

  1. 读取三个字符:左操作数、运算符、右操作数。
  2. 根据运算符,生成对应的汇编指令模板。

例如,对于输入 1+2,编译器应生成类似以下的汇编代码:

mov $1, %eax
mov $2, %ebx
add %ebx, %eax

这段汇编代码的功能是将1和2相加,结果存储在eax寄存器中。

关键点:编译器程序本身并不计算1+2的结果(那是解释器的工作)。它只是生成另一个会计算1+2的程序(汇编代码)。+号的意义是由我们的编译器定义赋予的,它决定了看到+时要生成add指令。

第一个简单的解释器示例

对于同样的“语言”,解释器的做法则不同。

解释器代码思路

  1. 同样读取三个字符。
  2. 立即将字符‘1‘‘2‘转换为整数12(例如,通过‘1‘ - ‘0‘)。
  3. 直接调用C语言的+运算符,计算1+2的结果。
  4. 打印出结果3

解释器在读取代码的同时,模拟了代码预期的行为并输出了结果。

从源代码到进程的完整流程

最后,我们俯瞰一下C程序从源代码到运行进程的完整旅程。gcc命令背后隐藏了多个步骤:

  1. 预处理:处理#include#define宏等指令,将多个文件合并或展开成一个完整的C源文件。
    • 工具:C预处理器 (cpp)
  2. 编译:将预处理后的纯C代码翻译成特定处理器架构(如x86)的汇编代码
    • 工具:编译器 (gcc -S)
  3. 汇编:将人类可读的汇编代码翻译成机器可识别的二进制目标代码.o文件)。目标文件包含机器码,但还不是完整的可执行程序。
    • 工具:汇编器 (as)
  4. 链接:将一个或多个目标文件,以及程序运行所需的标准库(如printf的实现)和C运行时库合并在一起,解决函数间的调用关系,生成最终的可执行文件。
    • 工具:链接器 (ld)
  5. 加载与执行:当你在终端输入./hello时,操作系统通过fork()exec()系统调用,将可执行文件加载到内存,创建进程,并从_start入口点开始执行(最终调用main函数)。

总结

本节课中我们一起学习了程序翻译的核心概念。

  • 我们区分了程序(静态指令集)和进程(动态执行实体)。
  • 我们理解了编译器作为翻译器的角色,它将源代码转换为等价的、更低级的目标代码(如机器码)。
  • 我们对比了解释器,它直接执行源代码并产生结果。
  • 我们通过一个简单的计算器例子,直观感受了编译器和解释器在实现上的根本区别:编译器生成代码,解释器执行代码。
  • 最后,我们梳理了从C源代码到运行程序的完整管线:预处理 -> 编译 -> 汇编 -> 链接 -> 加载执行。每一步都由特定的工具完成,gcc为我们自动化了这个复杂的过程。

理解这些基础概念,是后续深入理解系统软件和编程语言如何工作的关键。

016:语法分析 (COP-3402 Fall 2024)

在本节课中,我们将要学习编译器的基础知识,特别是语法分析(Parsing)部分。我们将探讨其背后的哲学思想和技术原理,并通过大量示例来了解如何构建解释器和编译器。

编译器结构概述

上一节我们介绍了从源代码到可执行程序的完整流程。本节中,我们来看看编译器的内部结构。

编译器通常分为两个主要部分:前端和后端。

  • 前端:处理输入语言,分析其语法,并生成一个语法树来表示程序的结构。
  • 后端:接收语法树,并利用它来生成目标机器的机器代码。

你可以将这个过程想象成树遍历。在CS1或CS2课程中学习的树遍历算法,在这里有了实际应用——我们通过遍历语法树来处理和翻译程序。

中间表示与LLVM

上一节我们介绍了编译器的基本两阶段结构。本节中,我们来看看一个更复杂的现实世界架构。

许多现代编译器引入了一个称为中间表示 的环节。前端将源代码编译成一种中间语言,然后由后端将这种中间语言翻译成目标机器的代码。

为什么需要这个额外的步骤?主要有以下几个原因:

  1. 支持多目标平台:如果你想为X86和ARM两种架构生成代码,你只需要编写一个前端(将源代码转为IR)和两个后端(分别将IR转为X86和ARM代码),而不是两个完整的编译器。
  2. 简化编译器开发:中间语言通常比真实的机器指令集更简单、更规整,编写针对它的编译器后端相对更容易。
  3. 实现优化:可以在中间表示这个层级进行大量与具体机器无关的代码优化,提高生成代码的效率。
  4. 支持解释执行:中间表示本身也可以被虚拟机直接解释执行,例如Python的字节码和Java的字节码。

LLVM项目是这种架构的一个著名例子。它定义了自己的中间表示,并围绕它构建了丰富的前端、优化器和后端生态系统。

符号与意义:哲学背景

在深入技术细节之前,让我们思考一个更根本的问题:符号本身有意义吗?

考虑这个算术表达式:1 + 2 * 3。对你来说,它的含义和结果是明确的。但符号1+2*3本身对计算机并没有内在意义。在计算机中,它们只是存储在内存中的特定ASCII码值(例如,字符‘1’的ASCII码是49)。

是什么赋予了符号意义?是我们定义的一套规则,这套规则解释了如何解读这些符号的组合。我们即将构建的编译器,本质上就是在为机器编写这样一套解释规则。编译器将符号(源代码)翻译成另一种符号(机器码),而机器码对硬件有直接的意义。

这种思想也体现在自然语言中。同一个词(如“store”)在不同的句子结构(“The store is closed” vs. “Squirrels store acorns”)中含义完全不同。语法结构在很大程度上决定了符号的意义。

语法树:从自然语言到编程语言

上一节我们讨论了符号和意义的关系。本节中,我们来看看如何用结构化的方式表示语法。

在自然语言中,我们可以通过句子成分分析来分解一个句子。例如,句子“The boy went to the store”可以分解为主语(The boy)、谓语(went)和宾语(to the store)。这种层次化的结构可以表示为一棵树。

编程语言的处理方式与此类似。我们使用语法树来明确表示程序的语法结构。树的每个节点代表一个语法结构(如“表达式”、“语句”),叶子节点代表基本的符号(如数字、运算符)。

让我们为一个简单的算术表达式1 + 2 * 3构建语法树。首先,我们需要定义这个简单语言的语法规则。

以下是该语言的语法规则示例:

E -> E op E | num
op -> + | - | * | /
num -> 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

其中:

  • E 代表表达式。
  • op 代表运算符。
  • num 代表数字。
  • | 表示“或”。
  • -> 表示“可以推导为”。

根据这些规则和运算优先级(先乘除后加减),表达式1 + 2 * 3对应的语法树如下:

        E (op: +)
       / \
      /   \
     /     \
    E      E (op: *)
   / \    / \
num  op num  op
 |    |  |    |
 1    +  2    *

这棵树明确表示了2 * 3作为一个子表达式先计算,然后其结果再与1相加。

解释语法树:赋予意义

我们有了表示语法结构的树,下一步是如何从中得到结果(或生成代码)?这需要通过遍历树并应用语义规则来实现。

一个简单的方法是后序遍历

  1. 当遍历到一个num叶子节点时,将其字符形式(如‘1’)转换为机器能理解的整数值(如1)。这可以表示为规则:num -> 将ASCII码转换为整数值
  2. 当遍历到一个op节点时,将其转换为对应的运算函数(如+转换为add函数)。
  3. 当遍历到一个E (op: +)这样的内部节点时,规则是:先得到左子树E的值,再得到右子树E的值,然后应用op对应的函数。用伪代码表示:E -> apply(op. function, left_E.value, right_E.value)

通过这种方式,我们从纯粹的符号结构出发,通过定义明确的解释规则,最终得到了表达式的计算结果。编译器的工作与此类似,只不过它“解释”的结果是生成另一套符号系统——机器代码。

课程总结与作业

本节课中,我们一起学习了编译器的基础概念。我们了解了编译器前后端的划分,认识了中间表示的作用,探讨了符号、语法和意义之间的关系,并学习了如何用语法树表示程序结构以及如何通过遍历语法树来解释程序。

以下是本节课的核心要点:

  • 编译器前端负责语法分析,生成语法树
  • 编译器后端根据语法树生成代码
  • 中间表示是连接多种源语言和目标架构的桥梁,便于优化和移植。
  • 符号的意义由我们定义的解释规则决定。
  • 语法树是程序语法结构的显式表示。
  • 通过树遍历并应用语义规则,我们可以解释或编译程序。

你的作业是尝试为逆波兰表示法编写类似的语法规则。你也可以开始计算器项目,下周我们将完成语法分析的讲解,并开始我们的编译器项目。

017:编译器基础与中间语言 (COP-3402 Fall 2024)

在本节课中,我们将完成对语法分析的讨论,并介绍我们将用于编译器的中间语言。我们将学习如何通过形式化语法来定义语言的语法和语义,并了解如何利用语法树进行解释或编译。

语法分析与语法树

上一节我们讨论了编译器的内部结构,以及符号与其含义之间的哲学关系。我们结束于对语法和语法树的讨论。

语法定义了语言中所有合法句子的结构。语法树则直观地展示了单个句子如何根据语法规则构建而成。

以下是关于语法的一些核心概念:

  • 终结符:语言中实际出现的单词或符号。在算术表达式语言中,终结符是数字(如 0, 1, 2)和运算符(如 +, -, *, /)。
  • 非终结符:用于描述语法结构的元符号,它们本身不出现在最终句子中。例如,E(表达式)和 N(数字)是非终结符。
  • 产生式:定义非终结符如何由其他符号(终结符或非终结符)组成的规则。例如,规则 E -> E op E 表示一个表达式可以由两个表达式中间夹一个运算符构成。

形式化语法的精妙之处在于,它能用有限的规则描述一个无限的可能句子集合。这类似于递归函数定义,一个规则可以生成无数个具体的实例。

解释语法树

一旦我们有了表示程序结构的语法树,就可以定义规则来解释或编译它。

以算术表达式 8 + 3 + 2 为例。其语法树如下所示:

        E
       /|\
      E + E
     /|\   \
    E + E   2
     \   \
      8   3

为了解释这个表达式(即计算其数值),我们可以为树中的每种节点类型定义语义规则。

以下是解释算术表达式的规则:

  1. 数字节点:如果一个表达式 E 直接匹配到一个数字 N,那么该节点的值就是该数字对应的整数值。用伪代码表示:
    E.value = ascii_to_int(N.symbol)
    
  2. 运算节点:如果一个表达式 E 的结构是 E1 op E2,那么该节点的值是对左右子表达式值应用运算符 op 所对应的数学运算的结果。用伪代码表示:
    E.value = apply_operator(op.symbol, E1.value, E2.value)
    

通过后序遍历这棵树并应用这些规则,我们就能计算出根节点的值,即整个表达式的结果 13。这个过程明确了如何从纯粹的符号(8, +)推导出具体的含义(数学值 13)。

编译:语法树到代码生成

解释器直接计算程序的值,而编译器则将程序翻译成另一种语言。我们可以使用相同的“语法规则加语义动作”框架来实现编译器。

以中缀表达式转后缀表达式为例。我们不再计算节点的值,而是定义规则来生成代表后缀表达式的字符串。

以下是中缀转后缀的编译规则:

  1. 数字节点:直接输出该数字符号。
    E.code = N.symbol
    
  2. 运算节点:先生成左子表达式的代码,再生成右子表达式的代码,最后附上运算符。
    E.code = E1.code + " " + E2.code + " " + op.symbol
    

将这些规则应用到 8 + 3 + 2 的语法树上,我们会自底向上生成代码:首先得到 "8 3 +",然后与 "2""+" 结合,最终得到后缀表达式 "8 3 + 2 +"

这清晰地展示了编译器的工作模式:识别输入程序的语法结构,然后为每个结构生成目标语言的代码片段。

引入中间语言:Simple IR

现在,我们来看将用于本课程项目的中间语言,称为 Simple IR。这是一种比 C 语言更简单、更接近汇编的语言,便于翻译成机器代码。

一个 Simple IR 程序文件对应一个函数。其基本结构如下:

function_name: local_var1, local_var2, ..., local_varN
parameters: param1, param2, ..., paramM
// 指令序列
return value_or_variable

以下是 Simple IR 支持的主要指令类型:

  • 赋值
    x = 5        // 常量赋值
    y = x        // 变量赋值
    
  • 算术运算(单操作,无嵌套表达式)
    z = x + y
    a = b * 2
    
  • 函数调用
    result = call read_int
    call print_int, x
    
  • 控制流(类似汇编)
    top:
    if x <= 0 goto end
    // ... 其他指令
    goto top
    end:
    
  • 指针操作
    p = &x       // 取地址
    y = *p       // 解引用(取值)
    *p = 10      // 解引用(赋值)
    

让我们看一个计算幂运算 base^exponent 的 Simple IR 程序示例:

power: base, exponent, result, temp
parameters: base, exponent
result = 1
top:
if exponent <= 0 goto end
temp = result * base
result = temp
temp = exponent - 1
exponent = temp
goto top
end:
return result

这个程序展示了如何使用标签、条件分支和无条件分支来实现循环逻辑。它非常类似于你将要生成的汇编代码结构。

总结

本节课中我们一起学习了编译器基础中的几个关键概念。我们探讨了如何用形式化语法描述语言结构,以及如何通过语法树进行解释(求值)和编译(翻译)。我们特别介绍了中缀转后缀作为编译的实例。最后,我们详细介绍了将在项目中使用的 Simple IR 中间语言,它简化了从高级结构到低级汇编的转换过程。

理解符号(语法)与其含义(语义)之间的区别,以及如何用明确的规则将它们联系起来,是编写编译器的核心思想。在接下来的课程中,我们将开始学习如何将 Simple IR 程序编译成实际的汇编代码。

018:函数 (COP-3402 Fall 2024)

在本节课中,我们将要学习编译器如何实现函数。我们将探讨函数在编程语言中的概念,以及如何将其映射到机器级别的汇编代码。理解函数调用机制,特别是局部状态的管理,是构建编译器的关键一步。

函数是什么?

上一节我们介绍了编译器的整体框架和中间表示。本节中我们来看看函数的基本概念。

在编程语言中,函数是封装了特定计算过程的命名代码块。它允许我们通过名称来调用一段计算,而无需重复编写代码。函数是代码抽象的一种方法,它允许程序员向语言中添加新的“术语”。

一个函数通常具有以下特征:

  • 命名:通过名称标识。
  • 输入/输出:接收参数并返回一个值。
  • 控制转移:调用函数时,控制权从调用者转移到被调用函数,调用者暂停执行,直到被调用函数返回结果。

这与简单的子程序不同,后者可能没有明确的输入/输出规范,或者只是进行文本替换。

函数的局部状态

函数的一个关键特性是拥有局部状态。这意味着函数内部声明的变量是独立于其他函数的,即使它们同名。

考虑以下两个C语言示例:

示例1:全局变量

int x; // 全局变量
void f() { x = 1; int y = g(); printf("%d %d", x, y); }
void g() { x = 2; return x; }

当调用 f() 时,输出是 2 2。因为 x 是全局变量,fg 操作的是同一内存位置。

示例2:局部变量

void f() { int x = 1; int y = g(); printf("%d %d", x, y); }
void g() { int x = 2; return x; }

当调用 f() 时,输出是 1 2。因为 fg 各有自己的局部变量 x,它们位于不同的内存位置。

编译器必须能够区分和管理这些同名的局部变量,为每个函数调用分配独立的内存空间。

递归调用与栈帧

当函数调用自身(递归)时,局部状态的管理变得尤为重要。每个函数调用实例都需要自己独立的一套局部变量副本。

考虑以下递归阶乘函数:

int factorial(int x) {
    int old_x = x; // 自动局部变量
    x = x - 1;
    if (x <= 0) return 1;
    return old_x * factorial(x);
}

如果 old_x 被声明为 static int old_x(静态局部变量),那么所有对 factorial 的调用将共享同一个 old_x 内存位置,导致递归计算错误。使用自动局部变量(默认)则能为每次调用分配新的 old_x

为了实现为每次函数调用动态分配局部存储空间,计算机体系结构使用了

以下是栈帧(Stack Frame)或激活记录(Activation Record)的关键组成部分:

  • 参数:调用者传递给函数的值。
  • 局部变量:函数内部定义的变量。
  • 返回地址:函数执行完毕后,应返回到调用者代码中的哪条指令继续执行。
  • 指向前一栈帧的指针:通常称为基址指针(Base Pointer),用于在函数返回后恢复调用者的栈帧。

调用过程如下:

  1. 调用者将参数和返回地址压栈。
  2. 执行 call 指令跳转到函数代码。
  3. 被调用函数在其序言中保存旧的基址指针,设置新的基址指针,并为局部变量分配栈空间。
  4. 函数执行其代码。
  5. 函数在其尾声中恢复旧的基址指针,然后执行 ret 指令,该指令从栈中弹出返回地址并跳转回去。

x86-64 汇编中的函数支持

x86-64架构为函数调用提供了一些特定的寄存器和指令支持:

  • %rbp (基址指针寄存器):通常用于指向当前栈帧的起始位置。
  • %rsp (栈指针寄存器):指向栈的当前顶部。
  • call 指令:将下一条指令的地址(返回地址)压入栈中,然后跳转到目标标签。公式表示:call label => push %rip; jmp label
  • ret 指令:从栈中弹出返回地址并跳转到该地址。公式表示:ret => pop %rip

需要注意的是,callret 只处理返回地址的保存和恢复。参数的传递、栈帧的建立(保存 %rbp、分配局部变量空间)和清理工作,都必须由编译器生成的代码(函数序言和尾声)来显式完成。

在System V AMD64 ABI(Unix/Linux系统采用的应用二进制接口)中,函数调用的约定更为复杂:

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

项目预览:编译器代码生成

下一个项目将要求你实现编译器的一部分功能:将包含函数定义和函数调用(无参数和局部变量)的中间表示(IR)编译成x86-64汇编代码。

你需要实现一个代码生成器,它必须能够处理:

  • 函数定义的开始和结束(生成序言和尾声)。
  • 函数调用指令(生成 call 指令和必要的栈管理代码)。
  • 函数返回指令(生成 ret 指令前的清理代码)。

项目的核心是理解并正确实现栈帧的管理,确保每次函数调用都有独立的状态,并且控制流能正确返回。

总结

本节课中我们一起学习了函数在编译器中的实现原理。我们探讨了函数作为抽象工具的概念,理解了局部状态对于函数独立性的重要性,特别是递归调用中的关键作用。我们深入了解了栈帧的机制,它是支持函数调用、参数传递、局部变量分配和返回地址管理的核心数据结构。最后,我们预览了x86-64汇编语言为函数提供的底层支持(call/ret指令,%rbp/%rsp寄存器)以及下一个项目中将面临的代码生成任务。掌握这些知识是构建一个能处理函数的工作编译器的坚实基础。

019:编译器实现 - 函数实现 🛠️

在本节课中,我们将学习如何在编译器层面实现函数。我们将探讨函数调用的底层机制,特别是栈帧的构建与销毁,以及如何生成遵循特定调用约定的汇编代码。


函数语义回顾

上一节我们介绍了函数在指令级别的模拟。本节中,我们来看看如何编写一个编译器,使其能生成实现我们期望函数行为的汇编代码。

每个函数调用都拥有自己独立的栈帧。这是实现递归和多次函数调用的关键。在C语言中,每次函数调用都会获得该函数的一份新的局部状态。

调用约定与应用程序二进制接口

为了实现函数的局部状态,我们需要在汇编层面遵循一套标准。这套标准被称为调用约定应用程序二进制接口。它规定了栈帧的布局、参数传递方式、返回值存放位置以及寄存器的保存责任。

调用约定是系统相关的。例如,在x86-64架构的System V ABI(Linux等系统使用)中,前六个整型参数通过寄存器传递,其余参数通过栈传递。

以下是System V x86-64 ABI的关键点:

  • 参数寄存器%rdi, %rsi, %rdx, %rcx, %r8, %r9
  • 返回值寄存器%rax
  • 栈指针寄存器%rsp
  • 基指针寄存器%rbp
  • 被调用者保存的寄存器:例如 %rbx, %r12-%r15(被调用函数必须在使用前保存其值,并在返回前恢复)

栈帧布局详解

理解栈帧的构建是理解函数实现的核心。栈帧的构建是调用者(caller)和被调用者(callee)协作完成的。

以下是调用一个函数 my_func(a, b, c, d, e, f, g, h) 时,栈帧的构建过程示意图:

高地址
+-------------------+
| 调用者的栈帧       | <--- %rbp (调用前)
+-------------------+
| 参数 h             | <--- 由调用者压栈
+-------------------+
| 参数 g             | <--- 由调用者压栈
+-------------------+
| 返回地址           | <--- `call` 指令自动压栈
+-------------------+
| 旧的 %rbp          | <--- 被调用者 prologue 压栈
+-------------------+
| 被调用者保存的寄存器| <--- 例如 push %rbx
+-------------------+
| 局部变量           |
| ...               |
+-------------------+
|                   | <--- %rsp (函数执行中)
低地址

构建过程分解:

  1. 调用者(绿色部分)

    • 将前六个参数(a-f)放入指定的寄存器。
    • 将剩余参数(g, h)压入栈中(%rsp 递减)。
    • 执行 call my_func 指令。该指令将下一条指令的地址(返回地址)压栈,然后跳转到 my_func 的代码。
  2. 被调用者 Prologue(蓝色部分)

    • push %rbp:将调用者的基指针保存到栈上。
    • mov %rsp, %rbp:将当前栈指针设置为新的基指针,从而建立当前函数的栈帧基准。
    • push %rbx(可选):保存需要被调用者保护的寄存器。

  1. 被调用者 Epilogue(函数返回前)
    • mov %rbp, %rsp:将栈指针恢复为基指针的值,从而“弹出”所有局部变量。
    • pop %rbp:恢复调用者的基指针。
    • ret:从栈中弹出返回地址,并跳转回去。返回值应已存放在 %rax 寄存器中。

关键点

  • 编译器在编译时就知道每个局部变量和参数相对于 %rbp 的偏移量,因此可以生成形如 movl $42, -8(%rbp) 的指令来访问变量 xx
  • %rbp 链(每个栈帧保存的上一个 %rbp)使得在调试时回溯调用栈成为可能。

汇编代码生成实例

现在,我们来看一个完整的、由编译器生成的简单汇编程序示例。它包含一个 main 函数和一个 func 函数,main 调用 func

以下是编译器需要为每个函数生成的核心汇编结构:

    .text
    .globl main
    .type main, @function
main:
    # Prologue
    push %rbp
    mov %rsp, %rbp
    push %rbx

    # 函数体:调用 func
    call func

    # Epilogue
    mov %rbp, %rsp
    pop %rbp
    ret

    .globl func
    .type func, @function
func:
    # Prologue
    push %rbp
    mov %rsp, %rbp
    push %rbx

    # 函数体:返回常量 5
    mov $5, %rax

    # Epilogue
    mov %rbp, %rsp
    pop %rbp
    ret

代码解释

  • .text 等以点开头的指令是汇编器伪指令,用于定义节区和元数据。
  • main:func: 是标签,代表函数的入口地址。
  • PrologueEpilogue 的代码对于每个函数都是固定的模板(在没有局部变量和参数的情况下)。
  • call func 生成函数调用。
  • mov $5, %rax 对应 return 5; 语句,将返回值放入 %rax

编译器项目实践

在本节的配套项目中,你需要实现一个简单的代码生成器。你只需要处理函数定义、函数调用和返回常量值。

你需要实现四个核心函数来生成对应的汇编代码片段:

  1. enter_unit: 生成汇编文件开头(已提供)。
  2. enter_function: 生成函数标签和 prologue。
  3. enter_call: 生成 call 指令。
  4. enter_return: 生成 mov $<value>, %rax 指令。
  5. exit_function: 生成 epilogue。

实现思路
你的编译器本质上是一个文本生成器。它遍历抽象语法树(AST),并在特定节点触发上述函数,将对应的汇编文本字符串写入输出文件。例如,在 enter_function 中,你需要根据 AST 节点中的函数名,生成 push %rbp\nmov %rsp, %rbp\npush %rbx 这样的字符串。


本节课中我们一起学习了函数在编译器层面的实现机制。我们深入探讨了调用约定、栈帧的构建与销毁过程,并了解了如何生成正确的 x86-64 汇编代码来实现函数调用。理解这些概念是编写能够处理函数和递归的编译器的关键基础。

020:编译器实现 - 局部变量 (COP-3402 Fall 2024)

在本节课中,我们将学习编译器如何实现函数中的局部变量。我们将探讨如何生成汇编代码,以便在运行时为局部变量分配内存,并实现对它们的读写操作。


回顾函数实现

上一节我们介绍了函数在计算世界中的抽象概念,以及如何在汇编层面实现函数调用。我们了解到,通过使用栈,可以为每次函数调用创建一个独立的栈帧,从而管理局部状态和实现递归。

本节中,我们来看看如何管理函数内部的局部变量,以及如何生成代码来读写与这些变量关联的内存位置。

以下是函数实现的核心组成部分回顾:

  • 序言:在函数开始时执行,用于建立新的栈帧。主要包括保存旧的基指针(RBP)、设置新的基指针以及保存调用者保存的寄存器。
  • 调用:使用 call 指令,它会将返回地址压入栈中,然后跳转到目标函数。
  • 返回:在高级语言中,return 语句对应汇编中的两步操作:首先将返回值移动到约定好的寄存器(如 RAX),然后执行收尾工作。
  • 收尾:在函数结束时执行,用于拆除当前栈帧并恢复调用者的上下文。主要包括恢复栈指针(RSP)、恢复旧的基指针(RBP),最后执行 ret 指令返回到调用者。

编译时与运行时

理解编译器和生成代码的运行方式之间的区别至关重要。

  • 编译时:这是编译器运行并生成汇编代码的阶段。编译器只是根据规则输出文本字符串,它本身并不执行这些代码,也不直接管理栈帧。
  • 运行时:这是由操作系统和CPU执行编译器生成的汇编代码的阶段。此时,根据生成的指令,才会动态地创建和管理栈帧。

编译器的巧妙之处在于,它生成的代码能够在运行时表现出我们期望的栈帧管理行为,尽管在编译时我们并不知道具体的栈地址。


局部变量的存储

在C语言风格的函数中,局部变量存储在栈帧上。根据我们使用的应用程序二进制接口(ABI),栈帧的结构是固定的。

一个典型的栈帧包含以下部分(从高地址到低地址增长):

  1. 调用者的栈帧信息。
  2. 返回地址(由 call 指令压入)。
  3. 旧的基指针(RBP,在序言中保存)。
  4. (可选)保存的寄存器。
  5. 局部变量区域
  6. (可选)参数区域(下次课讨论)。

由于每次函数调用时,基指针(RBP)都指向栈帧中一个固定的位置(保存旧RBP的地方),因此我们可以利用这个特性来访问局部变量。


符号表与地址计算

在编译时,我们无法知道运行时栈的具体地址。但是,我们知道每个局部变量相对于当前栈帧基指针(RBP)的偏移量。

编译器会维护一个符号表,它在编译时记录每个局部变量名称到其固定偏移量的映射。

例如,对于一个有三个局部变量 xyz 的函数,我们可能会分配:

  • xRBP - 8
  • yRBP - 16
  • zRBP - 24

这些偏移量在编译时就可以确定,因为局部变量的数量和大小(在我们的简单IR中,假设都是8字节)是已知的。


生成变量访问代码

有了符号表和固定的基指针偏移量,我们就可以将高级语言中的变量访问翻译成汇编指令。

X86汇编(AT&T语法)提供了灵活的内存寻址模式。形式为 偏移量(基址寄存器) 的操作数可以计算内存地址。

变量赋值(存储)

将立即数赋值给变量(例如 x = 1)需要两个步骤:

  1. 将值加载到临时寄存器。
  2. 将寄存器的值存储到变量的内存位置。

对应的汇编代码可能如下:

movq $1, %rax          # 将立即数1移动到RAX寄存器
movq %rax, -16(%rbp)   # 将RAX的值存储到地址为 RBP - 16 的内存中(假设x的偏移是-16)

变量间赋值(加载与存储)

将一个变量的值赋给另一个变量(例如 y = x)也需要两步:

  1. 从源变量的内存地址加载值到寄存器。
  2. 将寄存器的值存储到目标变量的内存地址。

对应的汇编代码可能如下:

movq -16(%rbp), %rax   # 从地址 RBP - 16 加载值到RAX(加载x的值)
movq %rax, -24(%rbp)   # 将RAX的值存储到地址 RBP - 24(存储到y)

关键点

  • 当变量作为(在赋值号右边)时,它对应一个加载操作,操作数在 mov 指令的左侧
  • 当变量作为目标(在赋值号左边)时,它对应一个存储操作,操作数在 mov 指令的右侧


更新函数序言

为了给局部变量预留栈空间,我们需要扩展函数的序言代码。在设置了RBP之后,我们需要调整栈指针(RSP)来“分配”空间。

这通常通过从RSP减去一个总字节数来实现:

subq $24, %rsp  # 为3个8字节的局部变量分配24字节空间

这条指令在运行时执行,会扩大当前栈帧,为局部变量 xyz 留出空间。


总结

本节课中我们一起学习了编译器实现局部变量的核心机制:

  1. 栈帧管理:通过函数序言和收尾,在运行时动态创建和销毁栈帧,为局部变量提供存储空间。
  2. 基指针关键作用:利用RBP作为栈帧的稳定参考点,所有局部变量都通过相对于RBP的固定偏移量来访问。
  3. 符号表:编译器在编译时维护一个数据结构,将变量名映射到其对应的栈帧偏移量。
  4. 代码生成模式
    • 存储(赋值):movq $value, %rax + movq %rax, offset(%rbp)
    • 加载(读取):movq offset(%rbp), %rax
    • 变量间赋值是加载和存储的组合。

通过这种方式,编译器能够生成不依赖于绝对内存地址的代码,这些代码在运行时能正确访问属于当前函数调用的局部变量,即使存在递归或多次调用,每个调用实例的变量都能被独立且正确地访问。下一节,我们将探讨如何为函数传递参数。

021:函数参数实现

在本节课中,我们将学习如何在汇编层面实现函数调用,特别是函数参数的传递机制。我们将基于System V ABI(应用程序二进制接口)的约定,详细讲解调用者(caller)和被调用者(callee)如何协作,通过寄存器和栈来传递参数。

上一节我们介绍了函数定义、序言(prologue)和尾声(epilogue)的基本结构。本节中,我们将重点探讨函数参数的传递规则。

概述:参数传递约定

在System V ABI(64位)中,函数参数通过寄存器和栈共同传递。具体规则如下:

  • 前六个参数通过指定的寄存器传递。
  • 第七个及之后的参数通过栈传递,并且按从右到左的顺序压入栈中。

以下是用于传递前六个参数的寄存器顺序:

RDI, RSI, RDX, RCX, R8, R9

栈帧回顾与参数位置

回忆一下函数调用时的栈帧布局。被调用函数的栈帧中,局部变量存储在基指针(RBP)的负偏移处,而传入的参数则存储在基指针(RBP)的正偏移处。

+-------------------+
| ...               | 高地址
+-------------------+
| 参数n (RBP+...)   | <-- 由调用者压入栈的参数
| ...               |
| 参数7 (RBP+16)    |
| 返回地址 (RBP+8)   |
| 旧的RBP (RBP)      | <-- 当前RBP指向这里
+-------------------+
| 局部变量1 (RBP-8)  |
| 局部变量2 (RBP-16) | <-- 被调用者分配的局部变量空间
| ...               |
+-------------------+

单个参数的传递示例

让我们通过一个简单的例子来理解整个过程。假设我们有一个函数 param_test,它接收一个参数,并将其赋值给局部变量 x,然后返回 x

Simple IR代码示例:

function param_test
  local x
  local in_param : param
  x = in_param
  return x
end

一个调用该函数的 main 函数如下:

function main
  local ret_val
  local param
  param = 7
  ret_val = param_test(param)
end

调用者(main)的汇编代码

调用者 main 负责设置参数并调用函数。

以下是 main 函数中与调用相关的关键汇编代码片段:

; ... 序言和局部变量分配代码 ...

; 1. 设置参数:将局部变量 `param` 的值(位于 RBP-32)加载到 RDI 寄存器
movq    -32(%rbp), %rdi

; 2. 调用函数
call    param_test

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/flrd-cop3402-syssw/img/ac4f7550dbb27f1025eb49558cd3136b_15.png)

; 3. 调用后清理栈(此例中无栈传参,所以加0)
addq    $0, %rsp

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/flrd-cop3402-syssw/img/ac4f7550dbb27f1025eb49558cd3136b_17.png)

; 4. 保存返回值:将 RAX 中的返回值存入局部变量 `ret_val`(位于 RBP-24)
movq    %rax, -24(%rbp)

; ... 尾声和返回代码 ...

代码解释:

  • movq -32(%rbp), %rdi: 根据约定,第一个参数放入 RDI 寄存器。这里我们将存储在栈偏移 -32 处的 param 变量的值复制到 RDI
  • call param_test: 执行调用指令,它会将返回地址压栈并跳转到 param_test
  • addq $0, %rsp: 如果通过栈传递了参数,调用后需要调整栈指针 RSP 来“弹出”这些参数。本例中没有,所以加0。
  • movq %rax, -24(%rbp): 根据约定,函数的返回值存储在 RAX 寄存器中。调用返回后,我们将 RAX 的值保存到局部变量 ret_val 中。

被调用者(param_test)的汇编代码

被调用者 param_test 负责从约定位置获取参数,执行操作,并设置返回值。

以下是 param_test 函数的关键汇编代码片段:

param_test:
    ; ... 序言代码 ...

    ; 为局部变量分配栈空间(此例中为16字节对齐后的24字节)
    subq    $24, %rsp

    ; 1. 获取参数:将第一个参数(在RDI中)保存到局部变量 `in_param`(位于RBP-16)
    movq    %rdi, -16(%rbp)

    ; 2. 执行赋值:将 `in_param` 的值赋给局部变量 `x`(位于RBP-8)
    movq    -16(%rbp), %rax
    movq    %rax, -8(%rbp)

    ; 3. 设置返回值:将 `x` 的值(位于RBP-8)放入 RAX 寄存器
    movq    -8(%rbp), %rax

    ; ... 尾声代码 ...
    ret

代码解释:

  • movq %rdi, -16(%rbp): 函数开始执行后,首先从约定好的 RDI 寄存器中取出调用者传递的第一个参数值,并将其存储到为参数 in_param 预留的栈空间(RBP-16)中。这是一种简单的实现策略,将所有参数都转存到栈上的局部变量空间,简化了编译器的寄存器分配管理。
  • 中间的赋值操作 x = in_param 通过通用寄存器 RAX 作为中转实现。
  • movq -8(%rbp), %rax: 在返回前,根据约定,需要将返回值放入 RAX 寄存器。这里我们把局部变量 x 的值存入 RAX

多个参数的传递示例

当参数超过6个时,超出的部分需要通过栈来传递。考虑一个接收8个参数的函数 param_test8

Simple IR代码示例:

function param_test8
  local x
  local a : param
  local b : param
  local c : param
  local d : param
  local e : param
  local f : param
  local g : param
  local h : param
  x = c
  return x
end

调用者设置多个参数

调用者需要按规则设置前6个寄存器,并将第7、8个参数压栈。

以下是调用 param_test8 的关键汇编代码片段:

; 设置前6个参数到寄存器
movq    -32(%rbp), %rdi  ; 参数 a -> RDI
movq    -40(%rbp), %rsi  ; 参数 b -> RSI
movq    -48(%rbp), %rdx  ; 参数 c -> RDX
movq    -56(%rbp), %rcx  ; 参数 d -> RCX
movq    -64(%rbp), %r8   ; 参数 e -> R8
movq    -72(%rbp), %r9   ; 参数 f -> R9

; 将第7、8个参数压栈(注意顺序:先压h,再压g)
pushq   -88(%rbp)        ; 参数 h
pushq   -80(%rbp)        ; 参数 g

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/flrd-cop3402-syssw/img/ac4f7550dbb27f1025eb49558cd3136b_52.png)

; 调用函数
call    param_test8

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/flrd-cop3402-syssw/img/ac4f7550dbb27f1025eb49558cd3136b_54.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/flrd-cop3402-syssw/img/ac4f7550dbb27f1025eb49558cd3136b_56.png)

; 调用后清理栈:弹出两个8字节参数
addq    $16, %rsp

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/flrd-cop3402-syssw/img/ac4f7550dbb27f1025eb49558cd3136b_58.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/flrd-cop3402-syssw/img/ac4f7550dbb27f1025eb49558cd3136b_60.png)

; 保存返回值
movq    %rax, -24(%rbp)

关键点:

  • 寄存器参数按固定顺序 (RDI, RSI, RDX, RCX, R8, R9) 设置。
  • 栈传参时,参数按从右向左的顺序压栈。即最后一个参数 h 先压栈,然后才是 g
  • 函数返回后,使用 addq $16, %rsp 将栈指针上移16字节,相当于弹出了刚才压入的两个参数,恢复了栈的平衡。

被调用者获取多个参数

被调用者需要从寄存器以及栈上的正确位置获取所有参数。

以下是 param_test8 函数获取参数的关键汇编代码片段:

param_test8:
    ; ... 序言和局部空间分配 ...

    ; 获取前6个寄存器中的参数
    movq    %rdi, -8(%rbp)   ; a
    movq    %rsi, -16(%rbp)  ; b
    movq    %rdx, -24(%rbp)  ; c
    movq    %rcx, -32(%rbp)  ; d
    movq    %r8,  -40(%rbp)  ; e
    movq    %r9,  -48(%rbp)  ; f

    ; 获取栈上的第7个参数 (g)
    movq    16(%rbp), %rax   ; 参数 g 在 RBP+16
    movq    %rax, -56(%rbp)

    ; 获取栈上的第8个参数 (h)
    movq    24(%rbp), %rax   ; 参数 h 在 RBP+24
    movq    %rax, -64(%rbp)

    ; ... 函数主体和返回 ...

关键点:

  • 寄存器中的参数被复制到栈帧中对应的局部变量位置(负偏移)。
  • 栈上传入的参数位于正偏移处。因为 call 指令压入了返回地址(RBP+8),所以第一个栈参数从 RBP+16 开始,第二个从 RBP+24 开始,以此类推。

总结

本节课中我们一起学习了函数参数传递的完整实现机制。

  1. 调用者(Caller)的职责

    • 将前6个参数放入指定的寄存器:RDI, RSI, RDX, RCX, R8, R9
    • 将第7个及之后的参数按从右向左的顺序压入栈中。
    • 使用 call 指令调用函数。
    • 函数返回后,调整栈指针 RSP 以清理通过栈传递的参数。
    • RAX 寄存器中获取函数的返回值。
  2. 被调用者(Callee)的职责

    • 在序言中建立自己的栈帧。
    • 从约定的寄存器(RDI等)和栈位置(RBP+16等)获取传入的参数值,通常将它们保存到为本地区量分配的栈空间中。
    • 将返回值存入 RAX 寄存器。
    • 在尾声恢复栈帧并执行 ret 指令返回。

理解并遵循这套ABI约定,是确保不同模块(甚至是用不同语言编写的模块)能够正确互操作的关键。这也为你的编译器生成能与C语言程序链接的代码奠定了基础。

022:编译器实现 - 算术与函数调用 🧠

在本节课中,我们将学习如何为简单IR语言中的算术运算和函数调用生成x86-64汇编代码。我们将回顾函数参数传递的约定,并通过具体示例展示如何将高级语言结构转换为底层的机器指令。


函数调用与参数传递 📞

上一节我们介绍了编译器的基础结构,本节中我们来看看如何处理函数调用和参数传递。我们使用Code Gen2项目中的示例来回顾带参数的函数。

有两个IR文件。一个函数名为 main,另一个函数名为 param_testmain 函数有多个局部变量,其中八个将作为参数传递给 param_test 函数。我们为这些参数分别赋值1到8,然后调用 param_test 并传递这八个参数,将返回值保存在变量 x 中。

以下是这个简单IR程序的行为:

  • main 调用 param_test
  • param_test 定义了自己的局部变量集。即使某些变量名与 main 中的相同,它们也是该函数局部的,并非同一变量。
  • 该函数有八个指定参数。这个版本的函数只是将 x 赋值为参数 c
  • 你可以通过将返回值赋给不同的参数(例如 gh,它们是栈分配的)来测试你的程序,确保正确的值被传回 main

这个最简单的程序用于验证函数参数是否正确传递以及返回值是否正确返回。


函数调用的代码生成

现在,让我们看看如何为这些函数生成代码。记住,对于同一个输入程序,存在许多(技术上可能是无限个)等价的汇编程序。我将展示一种我认为易于生成的方法,如果你遵循Code Gen项目中的实现建议,生成代码会更容易。

在简单IR中,当遇到函数声明时,编译器中的哪个Python函数会被调用?答案是 enter_functionenter_function 为语言中的这个结构生成代码。它生成函数的序言(prologue)。

以下是生成序言和局部变量空间的步骤:

  • 序言:由 enter_function 方法生成,处理函数声明和序言代码。
  • 局部变量enter_local_vars 函数生成在栈帧上为局部变量分配空间的代码。

栈帧 是什么?它是用栈实现的,包含了函数调用相关的所有数据:局部变量、参数、返回地址以及旧的基指针(base pointer)。旧的基指针存储起来是为了在返回时恢复调用者的基指针。

enter_local_vars 生成的代码会在栈帧上为所有局部变量分配空间。例如,如果有11个局部变量(每个8字节),并且考虑16字节对齐的要求,可能会分配88字节的空间。


处理参数

参数如何处理?在System V AMD64 ABI调用约定中,前六个整数或指针参数通过寄存器传递(RDI, RSI, RDX, RCX, R8, R9),其余的参数通过栈传递。

在编译器中,我推荐一种简单的实现方式:在函数入口处,立即将所有传入的参数(无论是来自寄存器还是栈)复制到为其分配的局部变量空间(栈帧上的特定偏移处)。这样,函数体内可以统一将所有参数视为局部变量来访问,简化了编译器的编写。

编译器通过一个符号表来跟踪每个变量(包括参数)相对于基指针 RBP 的偏移量。例如,第一个局部变量可能在 RBP-8,第二个在 RBP-16,依此类推。参数被复制到对应的偏移位置。


生成函数调用代码

当生成函数调用(如 call param_test)的代码时,需要遵循以下步骤:

以下是调用者(caller)需要完成的任务:

  1. 设置参数:根据调用约定,将前六个参数放入指定的寄存器,剩余的参数以降序压入栈中。
  2. 执行调用:使用 call 指令。call 指令会自动将返回地址压栈,并跳转到目标函数。
  3. 清理栈空间:调用返回后,调用者负责清理之前为超出寄存器数量的参数所压入的栈空间(例如,通过 add $16, %rsp 来弹出两个8字节参数)。
  4. 处理返回值:根据约定,返回值存储在 RAX 寄存器中。调用者可以将 RAX 的值保存到自己的局部变量中。

例如,对于八个参数的调用,前六个(a-f)通过寄存器传递,后两个(g, h)以降序(先h后g)压栈。调用后的 add $16, %rsp 就对应之前的两步 push 操作。


被调用者(Callee)的视角

从被调用函数(如 param_test)的角度看:

  • 序言:建立自己的栈帧(push %rbp; mov %rsp, %rbp),并为局部变量分配栈空间。
  • 获取参数:将寄存器中的参数(RDI等)和栈上的参数(位于 RBP+16, RBP+24 等偏移处)复制到为本函数局部变量预留的栈空间位置(负偏移处)。这样函数体内可以像使用局部变量一样使用它们。
  • 函数体:执行实际的操作(如赋值)。
  • 设置返回值:将返回值放入 RAX 寄存器。
  • 尾声(epilogue):恢复栈指针和基指针,然后 ret 返回。

栈上参数的偏移计算:RBP 指向当前栈帧基址,RBP+8 是返回地址,RBP+16 是第一个栈传递的参数,RBP+24 是第二个,以此类推。参数以逆序压栈确保了第一个栈参数具有最小的正偏移量。


与C语言的互操作性 🔗

遵循统一的调用约定的好处是可以实现跨语言调用。例如,我们可以用简单IR编写一个函数,然后从C程序中调用它,反之亦然。

考虑一个用简单IR编写的乘法函数 mult,它接收两个参数并返回它们的乘积。其生成的汇编代码会从 RDIRSI 获取参数,将结果存入 RAX

在C程序中,我们可以声明 int mult(int a, int b);,然后调用它。GCC编译C代码时,也会将参数放入 EDI/RDIESI/RSI,并使用 call mult。链接器会将两者链接起来,程序就能正确运行。

同样,我们也可以在简单IR程序中调用用C编写的辅助函数(如 print_intread_int),只要它们遵循相同的调用约定。这通过链接器解析未定义符号来实现,头文件(.h)仅用于C编译器进行类型检查。


算术运算的代码生成 ➕➖✖️➗

上一节我们探讨了函数调用,本节中我们来看看如何为算术运算生成代码。

简单IR中的算术运算语句格式如下:

x = a + b

其中:

  • x 是目标变量。
  • a 是第一个操作数(可以是变量或常量)。
  • + 是运算符(支持 +, -, *, /, %)。
  • b 是第二个操作数(可以是变量或常量)。

我们的任务是将这样的语句转换为x86-64汇编指令。大多数算术指令直接在寄存器上操作,因此需要“加载-运算-存储”三个步骤。


加法示例

x = a + b 为例,假设 aRBP-16bRBP-24xRBP-8

生成代码如下:

# 1. 加载操作数到寄存器
mov -16(%rbp), %rax   # 将变量 a 的值加载到 RAX
mov -24(%rbp), %rbx   # 将变量 b 的值加载到 RBX

# 2. 执行加法运算 (AT&T 语法:源, 目的)
add %rbx, %rax        # 效果:RAX = RAX + RBX

# 3. 将结果存储到目标变量
mov %rax, -8(%rbp)    # 将结果存入变量 x

关键点:在AT&T语法中,add %rbx, %rax 的含义是 RAX = RAX + RBX。第二个操作数(%rax)既是源操作数之一,也是目的寄存器。

对于常量操作数,如 x = a + 5,可以使用立即数:

mov -16(%rbp), %rax   # 加载 a
add $5, %rax          # RAX = RAX + 5
mov %rax, -8(%rbp)    # 存储到 x


减法和乘法的注意事项

减法和乘法与加法类似,但需要注意操作顺序,因为它们不满足交换律。

对于 x = a - b

mov -16(%rbp), %rax   # 加载 a 到 RAX
mov -24(%rbp), %rbx   # 加载 b 到 RBX
sub %rbx, %rax        # RAX = RAX - RBX (即 a - b)
mov %rax, -8(%rbp)

重要sub %rbx, %rax 执行的是 RAX - RBX。如果操作数顺序弄反,结果会不同。

乘法指令 imul 的使用方式类似,且满足交换律,但目的寄存器同样在右侧。


除法与取模运算

除法 (/) 和取模 (%) 运算在x86-64上使用 idiv 指令,它有特殊的寄存器要求:

  1. 被除数 必须放在 RAX 寄存器(对于64位除法,RDX:RAX 共同构成128位被除数,但对我们而言,通常只需 RAX)。
  2. 执行 cltq (CDQE) 或 cqto (CQO) 指令,将 RAX 符号扩展到 RDX,以准备有符号除法。
  3. 除数 作为 idiv 指令的唯一操作数给出。
  4. 结果:执行 idiv 后,商(quotient)RAX 中,余数(remainder)RDX 中。

因此:

  • 对于除法 x = a / b,取 RAX 中的商。
  • 对于取模 x = a % b,取 RDX 中的余数。

示例:x = a / b

mov -16(%rbp), %rax   # 加载被除数 a 到 RAX
cqto                   # 将 RAX 符号扩展到 RDX:RAX
mov -24(%rbp), %rbx   # 加载除数 b 到 RBX
idiv %rbx             # 有符号除法:RDX:RAX / RBX
                      # 商在 RAX,余数在 RDX
mov %rax, -8(%rbp)    # 将商存储到 x

示例:x = a % b

mov -16(%rbp), %rax   # 加载被除数 a 到 RAX
cqto                   # 将 RAX 符号扩展到 RDX:RAX
mov -24(%rbp), %rbx   # 加载除数 b 到 RBX
idiv %rbx             # 有符号除法
mov %rdx, -8(%rbp)    # 将余数(在 RDX 中)存储到 x

复杂表达式

简单IR一次只执行一个二元运算。在像C这样支持复杂表达式(如 x = a + b * c)的语言中,编译器需要将表达式分解为多个简单的二元运算序列,并引入临时变量来保存中间结果。这涉及运算符优先级和求值顺序的处理,在简单IR中我们不需要处理这种情况。


总结 📝

本节课中我们一起学习了编译器后端实现的两个核心部分:

  1. 函数调用:我们深入了解了System V AMD64调用约定,包括如何通过寄存器和栈传递参数,如何生成调用者与被调用者的代码(序言、参数处理、调用指令、尾声),以及如何通过遵守统一约定实现与C语言的互操作。

  2. 算术运算:我们学习了如何将简单IR的算术语句转换为x86-64汇编,重点是“加载-运算-存储”模式,以及加法、减法、乘法、除法和取模运算的具体指令生成,特别是除法指令 (idiv) 对寄存器的特殊要求。

掌握这些内容,你就能为包含函数和基本运算的简单IR程序生成正确的汇编代码了。在接下来的Code Gen项目中,你将动手实现这些功能。

023:编译器实现 - 分支处理 🧭

在本节课中,我们将学习如何为我们的中间表示(IR)编译器实现分支功能。我们将探讨如何将简单IR中的条件跳转和无条件跳转指令转换为Intel x86-64汇编代码。上一节我们介绍了算术运算的代码生成,本节中我们来看看更复杂的控制流处理。

概述 📋

编译器后端的主要任务之一是将高级控制流结构(如条件语句和循环)转换为目标机器的低级跳转指令。在简单IR中,我们使用 if-gotogoto 指令来表示分支。在x86-64汇编中,对应的概念是条件跳转和无条件跳转指令(如 JMP, JLE, JG 等)。实现分支的关键在于理解如何设置条件码(标志寄存器)以及如何根据这些标志生成正确的跳转指令。

1. 分支的基本概念与目标 🎯

我们的目标是将简单IR中的分支指令转换为等价的Intel汇编指令。

在简单IR中,分支指令如下所示:

L1:
    if x <= 10 goto L2
    x = 11
    goto L3
L2:
    x = x + 1
L3:

在x86-64汇编中,对应的结构使用标签和跳转指令:

L1:
    # 比较 x 和 10, 然后条件跳转
    ...
    jle L2
    # x = 11 的代码
    ...
    jmp L3
L2:
    # x = x + 1 的代码
L3:

核心机制:在底层,分支通过修改指令指针寄存器(RIP)来实现。jmpjle 等指令会计算一个新的RIP值(当前RIP + 偏移量),从而改变下一条要执行的指令地址。

2. 从高级条件语句到低级分支的转换 🔄

高级语言中的结构化控制流(如 if-else, while)在汇编级别并不存在,必须用一系列跳转指令来模拟。

以下是一个将C语言 if 语句转换为简单IR和汇编的思维过程示例:

C语言代码:

if (x > 10) {
    x = 11;
} else {
    x = 20;
}

一种常见的转换策略是“跳过”策略。我们检查相反的条件,如果为真,则跳过 if 块,直接执行 else 块。

对应的简单IR可能是:

    if x <= 10 goto ELSE_LABEL  # 检查相反条件
    x = 11                      # if 块
    goto END_LABEL              # 跳过 else 块
ELSE_LABEL:
    x = 20                      # else 块
END_LABEL:

关键点:编译器通常会将条件“反转”,以便让“不成立”的分支去执行跳转,让“成立”的分支顺序执行紧随其后的代码。这样可以减少不必要的跳转指令。

3. 循环的转换示例 🔁

循环(如 while)可以看作是一个带跳转的 if 语句。

C语言 while 循环:

while (x > 0) {
    sum = sum + 1;
    x = x - 1;
}

转换为简单IR的一种方式(将条件检查放在循环头部):

LOOP_HEAD:
    if x <= 0 goto LOOP_END   # 条件不成立则退出循环
    sum = sum + 1             # 循环体
    x = x - 1
    goto LOOP_HEAD            # 跳回头部进行下一次检查
LOOP_END:

另一种方式(do-while风格,将条件检查放在循环尾部):

LOOP_BODY:
    sum = sum + 1             # 循环体至少执行一次
    x = x - 1
    if x > 0 goto LOOP_BODY   # 条件成立则继续循环

4. x86-64汇编中的条件跳转 ⚙️

在x86-64汇编中,实现条件分支需要两个步骤:

  1. 比较操作:使用 CMP 指令设置标志寄存器。
  2. 条件跳转:使用如 JLE(小于等于时跳转)、JG(大于时跳转)等指令,根据标志寄存器的状态决定是否跳转。

简单IR的 if x <= y goto L 指令在汇编中的实现模板如下:

    movq    -8(%rbp), %rax    # 将变量 x 加载到 %rax
    movq    $10, %rbx         # 将立即数 10 加载到 %rbx
    cmpq    %rbx, %rax        # 比较 %rax 和 %rbx (计算 %rax - %rbx)
    jle     .L2               # 如果 %rax <= %rbx,则跳转到标签 .L2

公式解释

  • CMP a, b 指令在内部计算 b - a(AT&T语法),并根据结果设置标志位(如零标志ZF、符号标志SF、溢出标志OF等)。
  • 条件跳转指令(如 JLE)会检查这些标志位的组合,以判断比较结果是否满足“小于或等于”的条件。

以下是常用简单IR操作符对应的x86-64条件跳转指令:

简单IR操作符 x86-64跳转指令 含义
== je 相等则跳转
!= jne 不相等则跳转
< jl 小于则跳转
<= jle 小于等于则跳转
> jg 大于则跳转
>= jge 大于等于则跳转

5. 代码生成模板与项目实现 💻

在CodeGen3项目中,你需要实现分支功能的代码生成。核心是填充以下几个函数:

  • generate_label(ir): 生成汇编标签,如 L1:
  • generate_goto(ir): 生成无条件跳转指令 jmp L1
  • generate_if_goto(ir): 生成条件跳转。这是最复杂的部分,需要:
    1. 生成加载两个操作数到寄存器(如 %rax, %rbx)的代码。
    2. 生成 cmpq 指令。
    3. 根据IR中的比较操作符(如 <=),选择正确的条件跳转指令(如 jle)并附上目标标签。

以下是一个简化的Python伪代码示例,展示了 generate_if_goto 的逻辑:

def generate_if_goto(ir):
    left_operand = ir.left_operand  # 例如变量 ‘x‘
    right_operand = ir.right_operand # 例如常量 10
    op = ir.operator                # 例如 ‘<=‘
    label = ir.label                # 例如 ‘L2‘

    # 1. 加载左操作数到 %rax
    print(f"    movq    {get_operand_code(left_operand)}, %rax")
    # 2. 加载右操作数到 %rbx
    print(f"    movq    {get_operand_code(right_operand)}, %rbx")
    # 3. 比较
    print(f"    cmpq    %rbx, %rax")
    # 4. 根据操作符选择跳转指令
    jump_instruction = {
        ‘<‘: ‘jl‘,
        ‘<=‘: ‘jle‘,
        ‘>‘: ‘jg‘,
        ‘>=‘: ‘jge‘,
        ‘==‘: ‘je‘,
        ‘!=‘: ‘jne‘,
    }[op]
    # 5. 生成条件跳转
    print(f"    {jump_instruction}    {label}")

辅助函数 get_operand_code 需要能判断操作数是变量(如 -8(%rbp))还是立即数(如 $10),并返回相应的汇编代码字符串。

总结 🏁

本节课中我们一起学习了编译器后端中分支处理的实现。

  1. 我们理解了分支的底层原理是修改指令指针(RIP)。
  2. 我们探讨了如何将高级语言的结构化控制流(if-else, while)分解并转换为一系列低级跳转指令,并理解了“反转条件”以优化代码的常见策略。
  3. 我们掌握了x86-64汇编中实现条件分支的两步模式:CMP 指令设置标志位,然后条件跳转指令(如 JLE)根据标志位决定跳转。
  4. 最后,我们查看了为简单IR生成分支汇编代码的实用模板,这是完成CodeGen3项目的核心。

通过将控制流逻辑从高级的、结构化的表示逐步降低到机器级别的跳转指令,我们向构建一个完整可用的编译器又迈进了一步。

024:编译器实现 - 指针引用与解引用 🧠

在本节课中,我们将学习如何编译指针操作,特别是引用(&)和解引用(*)运算符。我们将从理解指针在C语言和汇编层面的语义开始,然后学习如何生成对应的汇编代码。通过将高级概念映射到具体的机器指令,你将更清晰地理解指针的工作原理。

概述 📋

指针是C语言中一个强大但容易混淆的概念。本质上,指针是一个存储内存地址的变量。本节课我们将聚焦于两个核心操作:

  1. 引用:获取一个变量的内存地址。
  2. 解引用:通过一个指针变量中存储的地址,访问该地址处存储的值。

为了编译这些操作,我们需要理解编译器如何管理变量(通过栈帧和基址指针RBP),并学习生成能正确计算地址和访问内存的汇编指令。


编译器测试包装器 🔧

上一节我们介绍了算术和分支语句的编译。本节中,我们来看看如何在不实现完整编译器(如函数定义、参数处理)的情况下,测试我们编写的代码生成部分。

为了测试CodeGen3(负责算术和分支),我提供了一个预编译的“包装器”。这个包装器本质上是一个完整的、可执行的汇编程序框架,它包含了函数序言(prologue)、调用print_int函数以及函数尾声(epilogue)。你只需要将你编译的算术和分支代码“插入”到这个框架的中间部分,就可以生成一个完整的可执行程序来测试。

以下是使用包装器的步骤:

  1. 下载并解压提供的包装器文件。
  2. 编辑主IR文件:在main.ir文件中标记为“在此添加你的代码”的位置,写入你想要测试的IR代码(例如 result = 10;)。请勿修改标记为“请勿更改”的部分。
  3. 使用Makefile构建:运行make命令,它会将你的代码与包装器的开头(wrapper_start.s)和结尾(wrapper_end.s)连接起来,生成最终的可执行文件。
  4. 运行测试:执行生成的可执行文件,它会打印出result变量的值。

你也可以手动完成这个过程:

# 1. 用你的编译器编译你的IR代码,输出汇编到 main.s.body
./your_compiler main.ir > main.s.body
# 2. 将包装器的开头、你的代码体、包装器的结尾拼接成完整的汇编文件
cat wrapper_start.s main.s.body wrapper_end.s > main.s
# 3. 汇编并链接(需要链接包含 print_int 的库)
gcc -no-pie main.s -o a.out io.o
# 4. 运行
./a.out

这个包装器允许你专注于实现和测试核心的表达式与分支逻辑,而无需处理函数调用约定等复杂问题。


指针操作语义回顾 🎯

在深入汇编之前,让我们在Simple IR(类似于C的子集)的层面上回顾指针操作的含义。

引用操作符 (&)

&操作符用于获取一个变量的内存地址。

T1 = &X; // T1 现在保存了变量 X 的内存地址

此时,T1中存储的数据类型是一个地址。在机器层面,地址就是一个代表内存中某个字节位置的数字。

解引用操作符 (*)

*操作符用于获取一个指针变量所指向地址处存储的值。

T2 = *T1; // 获取 T1 中地址对应的值,并存入 T2

如果T1保存的是X的地址,那么T2将得到X的值。这个过程称为“解引用”。

解引用赋值

我们还可以通过指针来修改它指向的值:

*T1 = 11; // 将值 11 存储到 T1 所指向的地址

如果T1指向X,那么这条语句执行后,X的值将变为11

核心概念:指针的特殊之处在于,存储在指针变量中的数据本身被意图用作一个内存地址。而普通变量中的数据(如整数8)则没有这层含义。在硬件层面,它们都是二进制位,区别在于程序如何使用它们。


栈帧与变量地址 🗺️

要编译指针操作,必须理解运行时变量在内存中是如何布局的。这通过栈帧来实现。

当一个函数被调用时,会在调用栈上分配一块内存区域,称为栈帧。它通常包含:

  • 返回地址
  • 旧的基址指针(RBP
  • 局部变量的空间

编译器会为每个局部变量计算一个相对于基址指针RBP固定偏移量。例如:

  • 变量 X 可能在 [RBP - 24]
  • 变量 T1 可能在 [RBP - 40]

基址指针RBP是一个寄存器,在函数执行期间,它始终指向当前栈帧中保存旧RBP的那个位置。这是一个关键的约定。

重要:在编译时,我们不知道X的绝对内存地址(例如0x7ffd56),因为每次程序运行或函数调用时,栈的起始位置可能不同。但我们知道两件事:

  1. RBP寄存器在运行时总是指向当前栈帧的基址。
  2. 变量X相对于RBP的偏移量是固定的(例如-24)。

因此,变量X的运行时地址可以通过公式 RBP + offset_of_X 计算出来。我们的编译器需要生成能在运行时执行这个计算的代码。


编译引用操作符 (&) ⚙️

现在,我们来看如何将 T1 = &X; 编译成汇编代码。我们的目标是生成能计算X地址的指令。

思路是:既然X的地址等于RBP + offset_of_X,我们就用汇编实现这个加法,并将结果存入T1

步骤分解

  1. 将基址指针RBP的值(一个地址)加载到一个临时寄存器(如RAX)中。
  2. 将变量X的偏移量(一个负数,如-24)加到RAX上。
  3. 现在,RAX中的值就是X的运行时地址。
  4. 将这个地址值存储到变量T1的内存位置([RBP - 40])。

对应的汇编代码

mov RAX, RBP          ; RAX = RBP (栈帧基址)
add RAX, -24          ; RAX = RAX + (-24) ,即计算 &X
mov [RBP - 40], RAX   ; T1 = RAX (将计算出的地址存入T1)

通过这段代码,我们成功地将“获取变量地址”这个高级操作,转换成了在运行时进行的地址计算。T1现在存储的就是一个可以用于后续解引用操作的指针值。


编译解引用操作符 (*) ⚙️

接下来,我们编译 T2 = *T1;。这个过程需要两步:首先获取指针T1中存储的地址,然后去该地址加载数据。

步骤分解

  1. 加载指针值:将变量T1中存储的值(一个地址)加载到临时寄存器RAX中。mov RAX, [RBP - 40]
  2. 解引用加载:将RAX中的值作为地址,去内存中加载该地址处的数据到另一个临时寄存器RBX。这是关键步骤,使用括号(RAX)表示。
  3. 存储结果:将RBX中的值(即解引用得到的数据)存入变量T2mov [RBP - 56], RBX

对应的汇编代码

mov RAX, [RBP - 40]   ; RAX = T1 (加载T1中存储的地址)
mov RBX, [RAX]        ; RBX = *RAX (解引用:将RAX的值作为地址,取该地址的值)
mov [RBP - 56], RBX   ; T2 = RBX (存储解引用得到的结果)

与普通赋值的区别:如果是普通赋值 T2 = T1;,汇编代码将是 mov RAX, [RBP - 40] followed by mov [RBP - 56], RAX,直接拷贝值。解引用多出了 mov RBX, [RAX] 这一步,即通过地址进行了一次额外的内存访问。

括号 [RAX] 是Intel汇编中解引用的语法。它告诉CPU:“不要用RAX里的数据,而是把它当成地址,去取那个地址里的数据。”


总结 🏁

本节课我们一起学习了指针核心操作的编译原理:

  1. 指针本质:指针是存储内存地址的变量。其特殊性在于存储的数据被用作地址。
  2. 地址计算:变量的绝对地址在编译时未知,但可通过 RBP + 固定偏移量 在运行时计算。编译引用操作符&就是生成执行此计算的汇编代码。
  3. 解引用编译:编译解引用操作符*需要两步汇编指令:先加载指针值到寄存器,再使用类似[REG]的语法将该寄存器值作为地址进行内存加载。
  4. 测试工具:我们介绍了使用预编译包装器来单独测试代码生成模块的方法,这有助于我们聚焦于核心逻辑。

理解这些内容的关键是将高级语言中的“指针”概念,分解为机器层面具体的地址加载、算术运算和内存访问指令。下一节课,我们将继续学习如何编译通过指针进行赋值(*ptr = value;)的操作。

通过将抽象概念映射到具体的机器指令,希望你对指针的理解变得更加清晰和牢固。

025:编译器实现 - 指针赋值 (COP-3402 Fall 2024)

欢迎回到系统软件课程。今天是课程主要内容的最后一讲,我们将完成编译器的实现,并讲解如何实现指针,以及如何编译使用指针的代码。

概述

在本节课中,我们将要学习如何为指针操作生成汇编代码。我们将重点讲解指针赋值(*ptr = value)的实现,这是指针操作中最核心的部分之一。我们将从复习指针的基本操作开始,然后深入探讨如何将高级语言中的指针赋值语句转换为底层的x86-64汇编指令。

上一节我们介绍了指针的引用(&)和解引用(*)操作在中间表示(IR)中的语义。本节中我们来看看如何将这些IR操作,特别是解引用赋值,翻译成具体的汇编代码。

复习:指针操作与简单IR

指针操作在简单IR中与C语言非常相似,主要包括三种操作:

  • 引用:获取变量的地址,例如 t1 = &x
  • 解引用:获取指针指向的值,例如 t2 = *t1
  • 解引用赋值:向指针指向的地址写入值,例如 *t1 = 11

这些操作的语义与C语言一致。

关于这些IR操作的语义有任何问题吗?我想大家已经在C语言中见过这些操作了,它们的语义是相同的。

关于栈帧和RBP寄存器:RBP是一个寄存器,它总是指向当前函数的栈帧起始地址。这是一个由编译器维护的不变量。只要调用约定被正确实现,RBP寄存器在任何时候都持有指向栈帧的指针。因此,在生成代码时,我们可以始终依赖RBP来定位栈帧中的变量。

关于数据类型:在简单IR和汇编层面,我们主要处理64位整数。字符和字符串本质上也是数字(字符是8位数字,字符串是字符数组)。机器层面没有内置的类型表示,类型是编程语言创造的概念。指令决定了如何解释寄存器或内存中的二进制数据(是作为地址、整数还是其他)。

关于调用C函数:由于我们遵循System V应用二进制接口(ABI),我们可以调用C函数(如malloc)并获取其返回值。malloc返回一个指针,我们可以像操作其他数字一样对这个指针进行加减运算。不过,由于数据类型宽度的差异,与C库的完全互操作可能会遇到一些问题。

计算变量的地址

让我们通过一个具体例子来理解如何访问变量及其地址。假设我们有一个栈帧,变量x的偏移量是-24(相对于RBP)。RBP寄存器的值是80(指向栈帧顶部)。

以下汇编指令的作用是访问变量x的值:

mov RAX, [RBP - 24]

这条指令的意思是:取RBP的值(80),减去24得到地址56,然后将内存地址56处存储的值加载到RAX寄存器中。这是一种间接加载操作。

那么,如何获取变量x的地址(即56)并将其存储到寄存器(例如RAX)中呢?我们需要的不是加载地址56处的值,而是计算地址56本身。

方法是直接计算RBP加上偏移量:

mov RAX, RBP
add RAX, -24

或者更简洁地:

lea RAX, [RBP - 24]

执行后,RAX寄存器将保存地址值56。这就是我们实现引用操作(&x)的方式:生成计算变量地址的代码,并将该地址存入目标变量(如t1)对应的栈槽中。

核心概念:获取变量地址的公式为 变量地址 = RBP + 变量偏移量

实现解引用操作

解引用操作(t2 = *t1)是加载指针指向的值。

假设t1中存储着地址56(即指向变量x)。实现解引用的汇编代码如下:

  1. 首先,将t1的值(地址)加载到寄存器(如RAX)。
    mov RAX, [RBP - 40] # 假设t1的偏移是-40
    
    RAX现在等于56。
  2. 然后,使用括号语法进行间接加载,将RAX所指向地址的值存入另一个寄存器(如RBX)。
    mov RBX, [RAX]
    
    这条指令会去访问内存地址56,将其存储的值(假设是8)加载到RBX中。
  3. 最后,将RBX中的值存储到t2对应的栈槽中。
    mov [RBP - 48], RBX # 假设t2的偏移是-48
    

核心概念:解引用加载的汇编模式为 mov 目标寄存器, [地址寄存器]

实现解引用赋值操作

本节的核心是解引用赋值操作(*t1 = 11*t1 = t2),即向指针指向的内存地址写入数据。

这个操作与解引用加载相反。我们不是从地址加载值,而是向地址存储值。语义是:取t1的值作为一个地址,然后将右侧的值存入该地址。

以下是实现 *t1 = 11 的汇编步骤:

  1. t1的值(地址)加载到RAX。
    mov RAX, [RBP - 40]
    
  2. 将右侧的立即数(11)加载到RBX。
    mov RBX, 11
    
  3. 使用间接存储,将RBX的值存入RAX所指向的地址。
    mov [RAX], RBX
    
    执行后,内存地址56处的值被更新为11。

如果右侧是一个变量,例如 *t1 = t2,步骤类似:

  1. t1的值(地址)加载到RAX。
    mov RAX, [RBP - 40]
    
  2. t2的值加载到RBX。
    mov RBX, [RBP - 48]
    
  3. 进行间接存储。
    mov [RAX], RBX
    

核心概念:解引用赋值的汇编模式为 mov [地址寄存器], 源操作数。这里的括号[ ]在汇编中就是解引用操作。

完整示例与状态变化

让我们通过一个完整的简单IR程序,跟踪其执行过程中栈帧和寄存器的状态变化。

考虑以下IR代码:

x = 8
y = 9
t1 = &x
t2 = *t1
*t1 = 11
t3 = x

假设栈帧起始地址(RBP)为80,变量偏移量为:x: -24, y: -32, t1: -40, t2: -48, t3: -56。

以下是编译器生成的可能汇编代码及状态分析:

  1. x = 8
    mov RAX, 8
    mov [RBP - 24], RAX
    
    • 状态:地址56(80-24)的值变为8。

  1. y = 9
    mov RAX, 9
    mov [RBP - 32], RAX
    
    • 状态:地址48(80-32)的值变为9。

  1. t1 = &x
    mov RAX, RBP
    add RAX, -24        ; RAX = 80 - 24 = 56
    mov [RBP - 40], RAX
    
    • 状态:地址40(80-40)的值变为56(x的地址)。

  1. t2 = *t1
    mov RAX, [RBP - 40] ; RAX = 56
    mov RBX, [RAX]      ; RBX = [56] = 8
    mov [RBP - 48], RBX
    
    • 状态:地址32(80-48)的值变为8。

  1. *t1 = 11
    mov RAX, [RBP - 40] ; RAX = 56
    mov RBX, 11
    mov [RAX], RBX      ; [56] = 11
    
    • 关键状态变化:地址56(x)的值从8变为11。注意,t1本身(地址40)存储的地址56并没有改变。

  1. t3 = x
    mov RAX, [RBP - 24] ; RAX = [56] = 11
    mov [RBP - 56], RAX
    
    • 状态:地址24(80-56)的值变为11。因为通过指针t1修改了x,所以此时读取的x值是11。

这个例子清晰地展示了指针如何使得通过一个变量(t1)间接修改另一个变量(x)的值成为可能。

关于CodeGen4项目

CodeGen4项目要求你实现编译器后端中处理指针操作的部分。模板与CodeGen3类似,你只需要实现三个函数来分别处理引用、解引用和解引用赋值操作。

需要生成的汇编模板本质上就是我们上面讨论的代码片段:

  • 引用:计算 RBP + offset 并存入目标地址。
  • 解引用:从源地址加载值到寄存器A,然后 mov 寄存器B, [寄存器A],最后存入目标地址。
  • 解引用赋值:将右值加载到寄存器B,将左值地址加载到寄存器A,然后执行 mov [寄存器A], 寄存器B

项目的代码量不大,但需要仔细理解何时加载变量值、何时进行间接内存访问。理解本节课的示例是完成项目的关键。

总结

本节课中我们一起学习了编译器实现中指针赋值的处理。我们回顾了指针的基本操作在IR中的表示,重点深入探讨了解引用赋值(*ptr = value)的汇编实现机制。

核心要点总结

  1. 指针的本质是存储地址的变量,在机器层面与整数没有区别。
  2. 访问栈帧变量依赖于 RBP寄存器固定偏移量
  3. 引用操作 &x 通过计算 RBP + offset_of_x 实现。
  4. 解引用操作 *ptr 通过汇编指令 mov 目标, [地址寄存器] 实现。
  5. 解引用赋值操作 *ptr = val 通过汇编指令 mov [地址寄存器], 源 实现。
  6. 括号 [ ] 在汇编中就是解引用操作,它根据寄存器的值进行内存加载或存储。

理解这些底层机制,不仅有助于完成编译器项目,也能让你更深刻地理解C语言中指针的行为、数组访问的本质以及缓冲区溢出等安全问题的根源。指针的强大和危险,都源于这种直接操作内存地址的能力。

026:期末考试复习 🎓

在本节课中,我们将复习系统软件课程期末考试涵盖的所有核心主题。我们将回顾从文件系统、版本控制到进程管理、编译过程,以及Simple IR和x86汇编语言的关键概念。课程内容旨在帮助你巩固知识,为考试做好准备。

文件系统导航

上一节我们介绍了课程概述,本节中我们来看看文件系统导航的基础知识。Unix文件系统是分层的,这主要是通过目录实现的。目录本身是一种特殊的文件,它包含了从名称到其他文件的映射关系。

以下是关于文件系统导航的核心知识点:

  • 绝对路径与相对路径:绝对路径从根目录(/)开始,而相对路径则从当前工作目录开始。
  • 工作目录:这是进程当前所在的目录。在shell中,pwd命令可以显示它。
  • 更改工作目录:在shell中使用 cd 命令,在系统调用中使用 chdir
  • 文件操作
    • 重命名文件:mv
    • 查看文件内容:cat
    • 删除文件:rm

版本控制

理解了文件系统后,我们转向软件开发中至关重要的工具——版本控制。它帮助你管理代码的变更历史。

以下是Git版本控制的核心操作:

  • git add:将工作目录中的新文件或变更添加到暂存区。
  • git commit:将暂存区的变更提交到本地仓库。
  • git push:将本地仓库的提交推送到远程仓库。
  • git pull:从远程仓库拉取提交到本地仓库。

记住这四个部分:工作目录、暂存区、本地仓库、远程仓库。

进程与I/O重定向

现在,我们来看看操作系统如何管理运行中的程序——进程,以及如何控制它们的输入输出。

在shell中,你可以重定向进程的标准输入和输出。

  • 重定向到文件
    • 输出重定向:ls > outfile> 像箭头指向文件,表示输出到文件)
    • 输入重定向:cat < infile< 像箭头指向命令,表示从文件输入)
  • 管道:使用 | 将一个命令的标准输出连接到另一个命令的标准输入,例如 find | wc -l。这在内核中通过一个特殊的内存文件实现。

在系统调用层面:

  • 创建进程:使用 fork 系统调用。
  • 执行程序:使用 exec 系列系统调用,它会用新程序覆盖当前进程的内存并开始执行。
  • 重定向文件描述符:使用 dupdup2 系统调用。
  • 创建管道:使用 pipe 系统调用,它创建一个内存中的临时文件,用于两个文件描述符之间的读写。

从源代码到可执行文件

一个C程序是如何变成可运行的程序呢?这个过程涉及多个步骤。

编译流程如下:

  1. 预处理器:处理宏和头文件包含。
  2. 编译器:将C代码编译成汇编代码。命令:gcc -S
  3. 汇编器:将汇编代码转换成机器码(目标文件)。命令:asgcc -c
  4. 链接器:将多个目标文件和库合并成一个可执行文件。命令:ld
  5. 加载器:当程序执行时,内核的加载器将可执行文件读入内存,并启动进程。

Simple IR 语言

接下来,我们深入课程的核心之一——Simple IR,这是一种用于教学的中级表示语言。

Simple IR 包含以下类型的指令:

  • 函数定义与调用func, call, return
  • 变量与赋值assign
  • 算术运算add, sub, mul, div
  • 分支跳转ifgoto (条件跳转), goto (无条件跳转)
  • 指针操作ref (取地址), deref (解引用赋值)

例如,一个简单的赋值语句 assign x 1 在底层需要被翻译成基于栈帧地址的访问。

函数与栈帧

在Simple IR和汇编中,函数调用需要管理局部状态。这是通过栈帧实现的。

在System V AMD64 ABI调用约定下,栈帧(从高地址到低地址)包含:

  1. 参数(超过6个的部分):由调用者压栈。
  2. 返回地址:由 call 指令压入。
  3. 旧的基指针:由被调用者压入。
  4. 局部变量:由被调用者分配空间。

函数序言设置栈帧,函数尾声清理栈帧。这使得函数可以拥有独立的局部变量空间,支持递归等功能。这与简单的汇编分支指令(jmp)有本质区别。

变量访问与算术运算

了解了栈帧结构,我们来看变量如何被访问。

在Simple IR中,变量名在编译时会被转换为相对于基指针(RBP)的偏移量。例如,变量 x 可能位于 RBP - 16 的位置。因此,assign x 1 会被翻译成类似 movq $1, -16(%rbp) 的汇编指令。

Simple IR的算术运算对应特定的x86汇编指令:

  • add -> addq
  • sub -> subq
  • mul -> imulq
  • div -> idivq (注意:除法操作较复杂)

x86汇编通常使用双操作数指令,如 addq %rbx, %rax 表示 rax = rax + rbx

分支逻辑实现

程序中的条件判断是如何在底层实现的呢?

Simple IR中的 ifgoto 语句在x86汇编中通过两个步骤实现:

  1. 比较:使用 cmpq 指令设置条件码。
  2. 条件跳转:根据比较结果,使用相应的条件跳转指令,如 jle (小于等于跳转)、jl (小于跳转)、jne (不等于跳转)等。

例如,实现 if (x <= 10) goto label; 的汇编代码可能是:

movq -16(%rbp), %rax # 将变量x的值加载到rax
cmpq $10, %rax       # 比较rax和10
jle .label           # 如果rax <= 10,跳转到.label

高级语言中的 if-elsewhile 循环都需要通过这种条件跳转和无条件跳转 (jmp) 的组合来构造。

指针操作

最后,我们复习最复杂的部分——指针。指针的核心是区分“地址”和“地址中存储的值”。

在Simple IR和汇编中:

  • 取地址ref t1 x 获取变量 x 的地址并存入 t1。对应汇编需要计算 x 的地址,例如 leaq -16(%rbp), %rax
  • 解引用赋值deref x t1t1 的值写入 x 所指向的地址。对应汇编需要先加载地址,再存储值,例如 movq (%rax), %rbx 然后 movq %rbx, (%rcx)
  • 指针变量访问:如果变量本身存储的是地址,那么直接使用该值进行内存访问。

关键语法:

  • movq (%rax), %rbx:将 rax 寄存器中值所代表的内存地址处的数据,加载到 rbx
  • movq %rbx, (%rax):将 rbx 中的值,存储到 rax 寄存器中值所代表的内存地址处。


本节课中我们一起复习了系统软件期末考试的广泛主题,包括:Unix文件系统操作、Git版本控制、进程创建与I/O重定向、从源代码到可执行文件的编译链接过程、Simple IR语言的语法和结构、函数栈帧的布局与管理、变量在汇编层的访问方式、算术与分支指令的翻译,以及指针操作的底层实现。请利用好允许携带的笔记,重点理解概念并辅以具体代码示例进行准备。祝你考试顺利!

posted @ 2026-03-29 09:36  布客飞龙I  阅读(2)  评论(0)    收藏  举报