Lecture 1 - Introduction and Examples

简介和示例

欢迎来到6.S081 — 操作系统课程

本课程由Robert主讲,旨在帮助你深入理解操作系统的设计和实现。具体目标如下:

  1. 理解操作系统的设计与实现
    • 设计:指操作系统的整体结构,包括其架构、模块划分和功能布局。这部分内容将帮助你理解操作系统如何协调各种硬件和软件资源,提供稳定且高效的运行环境。
    • 实现:指操作系统设计的具体实现方式,包括代码结构、编程语言的选择、数据结构的使用等。我们将深入研究这些实现细节,帮助你掌握如何将复杂的系统设计转化为实际的代码。
  2. 动手实践与经验积累
    • 为了加深对操作系统工作原理的理解,课程将通过一个名为XV6的小型操作系统,提供实际动手实践的机会。XV6 是一个简化版的 UNIX 操作系统,专为教学目的设计。你将学习如何扩展 XV6,修改其内核代码,并优化其性能。此外,通过与课程配套的实验,你将掌握如何利用操作系统接口(如系统调用)编写系统软件,实现对硬件的高级抽象和控制。

操作系统的目标

操作系统作为计算机系统的核心,主要有以下几个目标:

  1. 抽象硬件

    • 计算机硬件如 CPU 和内存是非常底层的资源,直接操作它们既复杂又容易出错。操作系统通过提供高级抽象(如进程、文件系统等),屏蔽了底层硬件的复杂性。这些抽象不仅使得应用程序的开发更为便捷,还提高了软件的可移植性,因为程序无需直接依赖于特定的硬件架构。

    • 进程(Process):操作系统通过进程来管理 CPU 的执行时间。每个进程被看作是一个独立的运行环境,有自己独立的内存空间和执行权限。

    • 文件系统(File System):操作系统将物理存储设备抽象为文件和目录的形式,简化了数据存储和检索的操作。

        +-----------------------+
        |     Application       |  <-- 高级抽象层
        +-----------------------+
                 |
                 v
        +-----------------------+
        |     Operating System   |  <-- 提供抽象,如进程、文件系统
        +-----------------------+
                 |
                 v
        +-----------------------+
        |  Hardware Resources    |  <-- 底层资源,如CPU、内存
        +-----------------------+
      
  2. 硬件资源的多路复用(Multiplexing)

    • 操作系统的另一个关键任务是协调多个应用程序共享有限的硬件资源。通过机制如时间片轮转(time-sharing)、虚拟内存(virtual memory)等,操作系统确保各个程序能在彼此不干扰的前提下高效运行。例如,你可以同时运行文本编辑器、编译器和数据库等多个应用程序,而操作系统会负责管理它们的资源使用,防止冲突。

        +-----------------------------+
        |       Operating System      |
        +-----------------------------+
        |   [ Text Editor ]           |
        |   [ Compiler    ]   ---->   |  <--- 时间片轮转,虚拟内存
        |   [ Database    ]           |
        +-----------------------------+
                 |
                 v
        +-----------------------+
        |   CPU / Memory        |  <-- 硬件资源
        +-----------------------+
      
  3. 隔离性(Isolation)

    • 在操作系统中,通常会同时运行多个程序。即使某个程序发生故障,也必须确保其他程序不受影响。因此,隔离性变得至关重要。隔离性保证了不同的活动或进程之间不会相互干扰,每个进程都有自己独立的内存空间和资源管理,这样就算一个进程崩溃或出错,也不会影响其他进程的正常运行。
    • 实现隔离性的方法:操作系统通过多种机制来实现隔离性,例如内存保护(Memory Protection)、用户模式和内核模式的分离(User Mode vs Kernel Mode)、进程间通信(IPC)机制等。内存保护确保进程只能访问自己分配的内存区域,而不能读写其他进程的内存。用户模式和内核模式的分离保证了应用程序不能直接操作硬件,必须通过操作系统提供的系统调用接口来执行特权操作。
  4. 共享性(Sharing)
    • 尽管隔离性非常重要,但有时不同的进程或活动之间需要相互协作,进行数据交互。例如,当你通过文本编辑器创建了一个文件后,希望编译器能够读取这个文件并进行编译。在这种情况下,我们希望能够实现进程间的数据共享。

    • 共享性与隔离性的平衡:操作系统必须在隔离性与共享性之间找到平衡。一方面,它需要通过机制如共享内存(Shared Memory)、管道(Pipes)等,允许进程之间安全地共享数据;另一方面,操作系统也必须确保这种共享不会破坏系统的安全性和稳定性。

      +-----------+    +-----------+    +-----------+
      | Process 1 |--->|           |<---| Process 3 |
      |-----------|    |           |    |-----------|
      |  Memory   |    |  Shared   |    |  Memory   |
      |   Space   |    |  Memory   |    |   Space   |
      +-----------+    |           |    +-----------+
                       |  Shared   |
      +-----------+    |   Data    |    +-----------+
      | Process 2 |--->|           |<---| Process 4 |
      |-----------|    +-----------+    |-----------|
      |  Memory   |                     |  Memory   |
      |   Space   |                     |   Space   |
      +-----------+                     +-----------+
      
  5. 安全性与访问控制(Security and Access Control)

    • 虽然共享性是必要的,但在许多情况下,我们不希望数据被不必要的共享。例如,当你登录到一台公共计算机(如Athena)时,你不希望其他用户能够读取你的私人文件。因此,操作系统必须实现强大的安全机制,以保护用户的数据隐私和系统安全。
    • 访问控制系统(Access Control System):操作系统通过访问控制系统来管理和限制对资源的访问。典型的访问控制机制包括权限位(Permission Bits)、访问控制列表(ACL)、用户身份验证(Authentication)、和加密技术。这些机制确保只有被授权的用户或进程才能访问特定资源,从而保护用户的隐私和数据安全。
  6. 性能优化(Performance Optimization)

    • 对于用户而言,操作系统的一个重要目标是提供高性能。用户希望在硬件上投入的大量金钱能够得到充分的回报,应用程序能够充分利用硬件的性能潜力。然而,操作系统不仅不能成为应用程序性能的瓶颈,还需要通过优化资源管理、调度策略等手段,提升应用程序的运行效率。
    • 性能优化的方法:操作系统通过一系列技术手段提升性能,包括高效的调度算法(如多级反馈队列调度)、内存管理策略(如分页和分段)、I/O操作优化(如缓冲区管理)、以及CPU亲和性(CPU Affinity)等。这些技术确保操作系统不仅不会拖慢应用程序的速度,还能帮助应用程序获得最佳的硬件性能。
  7. 多样性支持(Diversity of Use Cases)
    • 现代操作系统必须支持大量不同类型的应用程序,这些应用可能运行在不同的硬件平台上,如笔记本电脑、服务器或云计算平台。无论是运行文本编辑器、玩游戏,还是处理数据库服务器或执行云计算任务,操作系统都需要具备足够的灵活性和通用性,以适应各种不同的用户需求和使用场景。

    • 跨平台支持与通用性:设计和构建一个操作系统的成本非常高,因此,人们通常希望一个操作系统能够支持尽可能多的应用场景。以Linux为例,它作为一个通用操作系统,不仅可以运行在个人计算机上,还广泛用于服务器、嵌入式系统和超级计算机。这种多样性支持不仅节省了开发和维护成本,还增强了操作系统的生态系统,使其更具适应性和生命力。

        +-----------------------------+
        |       Operating System      |
        +-----------------------------+
        |  [ PC Applications ]        |
        |  [ Server Applications ]    |
        |  [ Embedded Applications ]  |
        +-----------------------------+
                 |
                 v
        +-----------------------------+
        |Different Hardware Platforms |  <-- 笔记本、服务器、嵌入式系统
        +-----------------------------+
      

通过这些目标的实现,操作系统不仅能够提供一个稳定、安全、高效的运行环境,还能够满足多样化的用户需求,适应快速变化的技术发展。

操作系统的经典组织结构

过去几十年里,操作系统的设计逐渐引入了分层的结构思想,这种设计思想在实践中证明了其有效性。我将为你列出操作系统的经典组织结构,这也是这门课程的主要内容之一。这种组织结构在现代操作系统中非常常见。

当我们讨论操作系统的组织结构时,首先可以将其分解为几层,从底层的硬件到顶层的用户空间应用程序。

1. 硬件资源层

+-----------------------------------+
|         Hardware Resources        |  <-- 硬件资源,如CPU、内存、磁盘、网卡
|-----------------------------------|
|  CPU  |  Memory  |  Disk  |  NIC  |
+-----------------------------------+
  • 描述:在操作系统架构的最底层,我们有硬件资源。这些资源包括中央处理器(CPU)、内存(Memory)、磁盘(Disk)、网络接口卡(NIC)等。操作系统需要管理和协调这些底层硬件资源,使其为上层软件提供服务。

2. 内核(Kernel)层

+-----------------------------------+
|               Kernel              |  <-- 内核,管理硬件资源和系统服务
|-----------------------------------|
| Process Management | File System  |
| Memory Management  | I/O Control  |
+-----------------------------------+
  • 描述:内核(Kernel)位于硬件资源之上,是整个操作系统的核心组件。内核负责管理计算机资源,并提供底层服务,如进程管理、内存管理、文件系统和I/O控制。内核是第一个被启动的程序,它的职责是确保所有硬件资源被有效地分配和使用,同时提供必要的系统调用接口,使用户空间的程序可以安全地访问这些资源。

3. 用户空间(Userspace)层

+-----------------------------------+
|            Userspace              |  <-- 用户空间,运行应用程序
|-----------------------------------|
|  VI Editor  |  CC Compiler  | CLI |
|  Web Browser|  Shell        | etc.|
+-----------------------------------+
  • 描述:在内核之上,我们有用户空间(Userspace),这是运行各种应用程序的环境。用户空间包含了所有的用户应用程序,如文本编辑器(如VI)、编译器(如CC)、命令行界面(CLI)和其他工具。这些程序通过系统调用与内核交互,从而利用硬件资源。

整体架构示意图

以下是操作系统分层组织结构的整体示意图:

+-----------------------------------+
|            Userspace              |  <-- 用户空间:应用程序运行的环境
|-----------------------------------|
|  VI Editor  |  CC Compiler  | etc.|
+-----------------------------------+
           |
           v
+-----------------------------------+
|               Kernel              |  <-- 内核:管理和协调硬件资源
|-----------------------------------|
| Process Management | File System  |
| Memory Management  | I/O Control  |
+-----------------------------------+
           |
           v
+-----------------------------------+
|         Hardware Resources        |  <-- 硬件资源:CPU、内存、磁盘、网卡
+-----------------------------------+

最上层是用户空间,运行各种用户应用程序。这些应用程序通过系统调用接口与内核交互。内核则负责管理底层的硬件资源,并确保资源的有效分配和使用。硬件资源层是操作系统管理的基础,它们为整个系统的运作提供了支持。

在这门课程中,我们的主要关注点包括:内核(Kernel)、连接内核与用户空间程序的接口,以及内核内部的软件架构。重点是理解内核如何提供各种服务,以及这些服务如何与用户空间的程序交互。

内核中的关键服务

  1. 文件系统(File System)
    • 作用与结构:文件系统是内核提供的一个重要服务,其主要职责是管理文件的存储和组织。文件系统通常将磁盘划分为逻辑分区,并在这些分区上建立起一个层级的命名空间,每个文件都有唯一的文件名,这些文件名组织在目录结构中。文件系统不仅负责追踪每个文件在磁盘上的具体位置,还负责文件的读写操作,并确保数据的完整性和安全性。
    • 文件系统必须有效地管理磁盘空间,处理文件的创建、删除、读写等操作。为了提高性能,文件系统可能还会引入缓存机制,将频繁访问的数据保存在内存中,从而减少对磁盘的访问次数。此外,文件系统通常提供权限管理,通过访问控制列表(ACL)或其他机制,确保只有授权的用户或进程才能访问特定的文件。
  2. 进程管理系统(Process Management System)
    • 作用与结构:每一个用户空间的程序都被称为一个进程。进程管理系统的主要职责是管理这些进程的生命周期,包括进程的创建、调度、执行和终止。内核负责为每个进程分配独立的内存空间,并通过时间片轮转等机制在多个进程之间共享CPU时间。
    • 进程管理系统不仅要确保进程的独立性,还要在多个进程之间合理分配资源。例如,内核通过虚拟内存管理机制,使得每个进程看似拥有一个完整的内存空间,实际上多个进程共享了物理内存。内核还必须处理进程间的通信(IPC),确保进程可以安全有效地交换数据。
  3. 内存管理(Memory Management)
    • 作用与结构:内存管理是内核的核心功能之一,负责管理系统的内存资源。内存管理系统负责将物理内存分配给各个进程,并确保内存的高效使用。内存的分配必须动态调整,以满足不同进程的需求,同时避免内存碎片化。
    • 内存管理还涉及虚拟内存的实现,虚拟内存允许操作系统使用硬盘空间来扩展物理内存,从而运行超出实际物理内存容量的大型程序。内存管理系统还负责处理页面置换,当物理内存不足时,内核会将不常用的内存页面交换到硬盘上,并在需要时再将其调回内存。

安全性与访问控制(Access Control)

内核中的访问控制机制确保只有经过授权的进程或用户才能访问特定的资源。特别是在多用户系统中(如Athena系统),不同用户的进程需要遵循严格的访问控制规则。访问控制机制不仅适用于文件,还涉及内存、CPU时间和其他硬件资源的访问权限。

其他内核服务

在一个完备的操作系统中,内核提供的服务远不止文件系统和进程管理系统。以下是一些常见的内核服务:

  • 进程间通信(IPC):内核提供了多种机制,如消息队列、信号量和共享内存,允许不同的进程进行通信和数据交换。
  • 网络服务:包括TCP/IP协议栈,实现网络通信功能,使得操作系统能够通过网络进行数据传输。
  • 设备驱动程序:内核包含了支持各种硬件设备(如声卡、网卡、磁盘驱动器等)的驱动程序。这些驱动程序使操作系统能够与硬件进行交互,提供通用的硬件接口。
  • 系统安全性:内核还负责系统的整体安全性,包括防止未经授权的访问、确保数据的机密性和完整性,以及监控潜在的安全威胁。

内核与应用程序的接口

我们也非常关注应用程序与内核之间的交互方式,这个交互接口通常被称为内核的API。内核API决定了应用程序如何调用内核服务。最常见的方式是通过系统调用(System Call)。系统调用类似于普通的函数调用,但区别在于系统调用实际上会切换到内核模式,执行内核中的代码,并提供对底层硬件资源的访问。

  • 系统调用是用户空间程序访问内核服务的唯一合法途径。当应用程序发出系统调用请求时,处理器从用户模式切换到内核模式,确保系统资源的访问是在受控环境下进行的。这种机制不仅提供了安全性,还隔离了应用程序和内核,防止应用程序直接操作硬件导致系统崩溃。

在这门课程的后续内容中,我们将深入探讨系统调用的具体实现方式,并通过实际的编程实践来理解这些调用如何在操作系统中发挥作用。

系统调用的实际例子

在操作系统中,系统调用是用户空间程序与内核交互的关键机制。尽管系统调用在代码中看起来像普通的函数调用,但实际上它们触发了从用户模式到内核模式的切换,从而执行内核中的特定操作。

1. open 系统调用

当应用程序需要打开一个文件时,会调用名为 open 的系统调用,并将文件名作为参数传递给 open。例如,假设你要打开一个名为“out”的文件,你可以将文件名“out”作为参数传入 open 函数。此外,如果你希望对这个文件进行写操作,还需要传递一个额外的参数,这个参数的值可能是 1,表示你想要写入数据。

int fd = open("out", 1);

open 系统调用被触发后,内核切换到内核模式,处理传入的参数。内核查找文件在磁盘上的具体位置,并为该文件创建一个文件描述符(File Descriptor, fd),作为后续文件操作的句柄。文件描述符是一个整数,用于标识已经打开的文件。文件描述符简化了文件操作,使得后续的读写操作可以直接使用这个句柄,而不需要再次查找文件。

2. write 系统调用

如果你想要向一个已经打开的文件写入数据,可以使用 write 系统调用。调用 write 时,你需要传递几个参数:首先是由 open 返回的文件描述符,其次是指向要写入数据的指针(通常是 char 类型的序列),第三个参数是要写入的字节数。

write(fd, "Hello\n", 6);

在这个例子中,第二个参数 "Hello\n" 是一个字符串,它的地址实际上是内存中的位置。当你调用 write 时,实际上是在告诉内核,将从该内存地址开始的6个字节写入到文件中。内核在接收到 write 调用后,会先检查文件描述符的有效性,然后从指定的内存地址读取数据,并将其写入文件。写入完成后,内核还会更新文件的元数据,如文件大小和最后修改时间。

3. fork 系统调用

fork 是一个非常重要的系统调用,它可以创建一个与调用进程几乎完全相同的新进程。新进程被称为子进程(child process),而原进程被称为父进程(parent process)。fork 的返回值在父进程中是子进程的进程 ID(PID),而在子进程中返回值为 0。

pid_t pid = fork();
if (pid == 0) {
    // 子进程的代码
} else {
    // 父进程的代码
}

fork 被调用时,内核会为新进程分配独立的内存空间,并复制父进程的地址空间、文件描述符和环境变量。尽管子进程几乎完全复制了父进程的状态,但它们是两个独立的进程,可以分别执行不同的任务。fork 是实现进程并发和并行执行的基础,尽管它看起来像是简单地复制了一个进程,但实际上涉及内核中复杂的资源管理和进程控制机制。

系统调用的特性

这些系统调用虽然在代码中表现得像普通的函数调用,但其独特性在于调用后程序会切换到内核模式,实际执行内核中的操作。系统调用是用户空间与内核之间唯一的合法接口,通过它们,用户程序可以安全、受控地访问底层资源。这些只是系统调用的几个例子,接下来在课程中我们将深入探讨更多系统调用及其具体实现和应用场景。

为什么学习操作系统是挑战和乐趣并存的?

学习操作系统不仅具有挑战性,也充满乐趣。操作系统本质上是为其他程序提供基础设施的,因此它的开发环境与普通应用程序的开发环境大不相同。

1. 内核编程环境的复杂性

当你编写、修改或扩展操作系统内核时,你实际上是在构建一个基础层,让其他程序能够在其上运行。与编写普通应用程序不同,操作系统直接与硬件交互,而不再有操作系统作为支撑。这意味着开发操作系统时,你需要处理底层硬件资源,如CPU、内存、磁盘等,而这些硬件往往更难以控制和调试。

为了帮助我们应对这一挑战,在这门课程中,我们会使用一个叫做QEMU的硬件模拟器。QEMU模拟CPU和计算机硬件,提供了一个稍微简化的开发环境,虽然它比直接在真实硬件上开发要友好一些,但仍然具有一定的复杂性。编程错误可能会导致系统崩溃或无法启动,因此在调试内核代码时,精确和耐心是关键。

2. 设计操作系统时的矛盾需求

设计一个操作系统需要在多个相互矛盾的需求之间找到平衡。以下是几个主要的矛盾点:

  • 高效性与易用性
    • 操作系统需要在硬件层面进行高效的操作,这通常意味着操作系统要在底层(low-level)与硬件紧密交互。然而,为了使开发者能够轻松编写应用程序,操作系统还必须提供高层次(high-level)的抽象接口,这些接口应当尽可能地易用和可移植。因此,操作系统设计者必须在底层效率与高层抽象之间取得平衡。提供一个既高效又易用的接口需要深厚的设计技巧和对硬件的深入理解。
  • 强大功能与简单接口
    • 我们希望操作系统能够提供强大的服务,使其能够有效地管理和分担应用程序的负担。但是,如果内核接口过于复杂且难以理解,开发者在使用这些接口时可能会遇到困难。因此,操作系统设计者必须在提供强大功能和保持接口简洁之间找到平衡。理想情况下,一个好的API应该既简单明了,又能提供足够的功能性来满足各种复杂需求。
// 简单的API示例:打开文件并写入数据
int fd = open("example.txt", O_WRONLY | O_CREAT);
write(fd, "Hello, World!", 13);
close(fd);

在上面的例子中,open, writeclose 提供了简单且功能强大的接口,用于文件操作。这些系统调用尽管在表面上很简单,但内核在处理这些请求时却需要执行复杂的底层操作。

  • 灵活性与安全性
    • 操作系统需要为应用程序提供灵活的接口,使得开发者可以自由地实现各种功能。然而,这种灵活性不能是无限制的,因为它可能导致安全隐患。例如,如果程序员能够直接访问硬件,他们可能会无意中干扰其他程序的运行,甚至破坏系统的稳定性。因此,操作系统还必须在提供灵活接口的同时,确保系统的安全性和稳定性。这种平衡的实现需要通过精细的权限管理和访问控制机制。
// 示例:系统调用中涉及的权限管理
int fd = open("example.txt", O_RDONLY);
if (fd < 0) {
    perror("open failed");
    exit(1);
}

在这个例子中,如果没有足够的权限,open 系统调用会失败,并返回一个错误。这种机制确保了系统的安全性。

设计一个优秀的操作系统

尽管设计一个满足所有这些矛盾需求的操作系统具有挑战性,但它并非不可能。在接下来的课程中,我们将深入讨论如何在这些矛盾的需求之间找到平衡,并设计出一个既高效、强大,又灵活、安全的操作系统。这些设计原则不仅适用于操作系统的开发,也能为你在其他复杂软件系统的设计中提供有价值的参考。

系统调用与标准函数调用的区别

学生提问:系统调用跳到内核与标准的函数调用跳到另一个函数相比,有什么区别?

教授的回答

  • 内核代码总是具有特殊的权限。当计算机启动时,内核会被授予直接访问硬件资源的权限,例如磁盘、内存等。然而,普通的用户程序并不具备这些权限。因此,当你执行一个普通的函数调用时,你调用的函数并没有访问硬件的特殊权限。而当你触发一个系统调用时,内核代码会被执行,并利用这些特殊的权限来操作硬件资源。这种权限的差异使得系统调用成为操作系统安全性和稳定性的关键机制之一。

操作系统设计的复杂性与挑战

操作系统设计中的一个核心难点在于,它不仅需要提供丰富的特性和服务,还必须处理这些特性和服务之间的复杂交互。这种交互有时会以出人意料的方式发生,要求设计者进行深思熟虑。例如,即使在我们之前讨论的 openfork 系统调用中,也存在这样的交互。

系统调用的交互:openfork 的例子

假设一个应用程序通过 open 系统调用获得了一个文件描述符(fd),之后该应用程序调用了 fork 系统调用。fork 的语义是创建当前进程的一个副本,而这个副本通常被称为子进程。在这种情况下,操作系统需要确保子进程继承了父进程的资源,这包括了父进程已经打开的文件描述符。因此,fork 后,子进程能够访问 fork 之前由 open 获得的文件描述符 fd

int fd = open("file.txt", O_RDONLY);
if (fork() == 0) {
    // 子进程
    read(fd, buffer, sizeof(buffer));
} else {
    // 父进程
    wait(NULL);
}

在这个例子中,父进程打开了一个文件并获得了文件描述符 fd。当 fork 创建子进程时,子进程继承了这个文件描述符,并能够继续使用它来读取文件。这种交互表明操作系统在设计时需要考虑多个系统调用之间的关系,以确保系统的一致性和功能性。

适应不断变化的硬件和使用场景

操作系统的另一个挑战在于它必须适应广泛的使用场景和不断变化的硬件环境。一个操作系统可能需要在数据库服务器和智能手机上运行,而这两种设备有着截然不同的性能和需求。此外,硬件技术也在快速发展。例如,SSD存储设备的出现取代了机械硬盘,提供了更快的读写速度;多核CPU的普及和网络速度的飞跃也要求操作系统不断地进行设计优化和重新思考。这些变化意味着操作系统设计者必须具备前瞻性,能够预见并应对未来的技术挑战。

学习操作系统课程的实际原因

前面我们已经分析了学习这门课程的理性原因。现在让我们探讨一些更加实际的动机,来解释为什么选择这门课程是值得的。

  • 深入理解计算机的工作原理
    • 如果你对计算机的内部工作机制感兴趣,特别是你打开计算机后究竟发生了什么,那么这门课程将是你的不二选择。操作系统是计算机系统的核心,它管理所有硬件资源和软件进程,理解操作系统就意味着掌握了计算机的核心工作原理。
  • 基础架构的设计与实现
    • 如果你喜欢设计和构建基础架构,尤其是那些可以为其他程序提供服务的基础设施,那么这门课程正是你所需要的。操作系统本质上就是这样的基础设施,它为应用程序提供了运行所需的环境和服务。
  • 定位与解决应用程序的Bug和安全问题
    • 当你花费大量时间定位应用程序中的Bug或解决安全问题时,你会发现这往往需要深入理解操作系统的运作方式。操作系统不仅涉及多种安全策略,还在程序出错时负责“收拾残局”。通过学习操作系统,你将掌握定位和解决这些问题的技能。

学生提问:对于应用程序开发人员来说,他们会基于一些操作系统做开发,真正深入理解这些操作系统有多重要?他们需要成为操作系统的专家吗?

教授的回答

  • 你不必成为一个操作系统专家,但如果你花费大量时间开发、维护和调试应用程序,你最终会学到许多操作系统的知识。无论你是否有意,这些知识都会在你的开发过程中出现,你不得不去理解它们。

学生提问:对于一些高级编程语言(如Python),它们是直接执行系统调用,还是对系统调用进行了封装?

教授的回答

  • 许多高级编程语言确实与系统调用有一定距离。部分原因在于,这些语言旨在提供一个可移植的环境,使代码能够在多个操作系统上运行,因此它们不会依赖于特定操作系统的系统调用。换句话说,使用Python等高级语言时,你在某种程度上是与系统调用隔离的。不过,在Python的内部,最终还是需要通过系统调用来完成具体的工作。此外,Python和许多其他编程语言通常也提供了直接访问系统调用的方法,供有需要的开发者使用。

6.S081 课程结构与重点

这门课程涵盖了操作系统的基础知识,并通过理论与实践相结合的方式帮助学生深入理解操作系统的设计和实现。课程内容可以分为以下几个主要部分:

1. 理论授课

课程的理论部分是基础所在,主要包括以下几个方面:

  • 操作系统的基本概念: 我们将深入探讨操作系统的核心概念,包括进程管理、内存管理、文件系统、系统调用等内容。这些基础知识将为后续的实验和课程讨论打下坚实的基础。

  • 深入学习XV6: 我们将花费几节课专门研究XV6操作系统,这是一个小型教学操作系统。你将通过阅读代码和实际运行XV6,理解其工作原理和设计思路。每节课之前都会布置阅读任务,要求你阅读介绍XV6的相关材料,这样你可以在课堂讨论中更好地参与。

  • 辅助实验的专题课程: 为了帮助你更好地完成实验,我们还设置了一些专题课程。例如,我们会讲解C语言的工作机制以及RISC-V微处理器的架构,这些知识在实验中将会频繁使用。这些课程旨在确保你在动手实验时能够顺利实现所需功能。

  • 操作系统论文阅读与讨论: 在课程的后期,我们将带领你阅读和讨论一些经典的操作系统论文,这些论文不仅包括历史上的重要研究成果,也涵盖了一些最新的研究进展。通过这些讨论,你将对操作系统领域的前沿技术有更深入的理解。

2. 编程实验(Lab)

实验部分是课程的核心实践环节,每周你都将进行编程实验。实验的主要目标是通过实际操作加深对操作系统概念的理解,培养动手能力。

  • 实验内容: 实验从简单的系统调用实现开始,逐步深入到更复杂的操作系统功能实现,例如进程管理、内存管理等。最后,你将面临一个综合性实验,要求为XV6添加一个网络协议栈和网络驱动,使操作系统能够进行网络通信。

  • 实验指导与支持: 如果在实验过程中遇到问题,你可以在Piazza平台上提出,助教会在办公时间为你提供帮助。我们鼓励学生之间相互讨论实验思路和方法,但要注意所有的代码都必须独立编写,严格避免抄袭。

3. 评分标准

课程评分主要基于以下几个方面:

  • 实验(Lab): 实验部分占总成绩的70%。你的Lab代码将通过提供的测试用例进行评估,代码通过所有测试用例意味着你将获得满分。

  • 实验核查: 占总成绩的20%。我们会随机抽取某些实验进行核查,助教将会问你一些与实验相关的问题,以确保你真正理解了实验内容。

  • 作业与参与: 剩下的10%由作业完成情况、课堂参与度以及Piazza的活跃度决定。由于没有考试和测验,这部分将主要用于考察你对课程内容的理解和参与度。

为什么选择这门课程?

这门课程不仅提供了理论知识,还通过实践让你真正掌握操作系统的设计与实现。如果你对计算机系统的工作原理感兴趣,或者想要深入了解软件基础架构的设计与实现,这门课程将是你提升知识水平和技能的理想选择。此外,通过这些实验和理论学习,你将具备定位和解决应用程序Bug、处理安全问题的能力,这些都是在实际工作中不可或缺的技能。

系统调用的外观与功能

接下来,我们将讨论系统调用对于应用程序来说是如何表现的。系统调用是操作系统提供服务的接口,因此理解系统调用的形式、应用程序对其期望的返回值以及系统调用的工作原理非常重要。这不仅是理解操作系统功能的基础,也是在第一个实验中你将应用这些系统调用的关键。此外,在后续的实验中,你将会扩展和改进这些系统调用的内部实现。

系统调用的实例演示

我将展示一些简单的代码示例,这些示例将执行系统调用,并在XV6操作系统中运行它们。XV6 是一个简化版的类Unix操作系统。虽然Unix是一个已有几十年历史的操作系统,但它依然是现代许多操作系统(如Linux、OSX)的基础。因此,Unix的使用范围非常广泛,而XV6作为教学工具,在保留Unix核心结构的同时,进行了极大的简化。这种简化使得你可以在短时间内阅读并理解XV6的所有代码,掌握它的工作原理。

XV6运行在RISC-V微处理器上,这是一种在MIT 6.004课程中讲授的指令集架构。许多同学可能已经对RISC-V有所了解。虽然理论上你可以在实际的RISC-V硬件上运行XV6,但在这门课程中,我们会选择在QEMU模拟器上运行XV6。QEMU提供了一个虚拟化的硬件环境,包括内存、磁盘和控制台接口,这使得你无需特定硬件就可以运行XV6,并与其进行交互。

设置与运行XV6

我现在会展示如何在笔记本电脑上设置并运行XV6。首先,你需要使用命令 make qemu 来编译和运行XV6,这个命令你在实验中会经常用到。

make clean  # 清理上次编译的所有文件
make qemu   # 编译并运行XV6
  • 编译过程make clean 用于清理之前的编译文件,使你能够看到完整的编译过程。随后,make qemu 命令将会编译并构建XV6内核和所有用户进程,然后在QEMU模拟器中运行它们。XV6 是用 C 语言编写的,因此这个过程将涉及到标准的C编译流程,包括代码编译、链接和生成可执行文件。

编译完成后,XV6系统将会启动并运行。你将看到一个类似Unix Shell的命令行接口,标志为 $,这表示Shell已准备好接收命令。XV6自带了一小部分工具程序,例如 ls,用于列出文件系统中的所有文件。

ls  # 列出XV6系统中的所有文件

运行 ls 命令后,你会看到XV6中的所有文件,大约有20多个文件。XV6还包含了一些你可能熟悉的Unix程序,例如 grepkillmkdirrm。这些工具的存在使得XV6的操作方式与Unix非常相似,这不仅帮助你理解XV6的功能,也使你在学习和使用Unix类操作系统时更加得心应手。

课程实验与实际操作

在第一个实验中,你将使用我们刚刚讨论的系统调用来编写简单的应用程序。随着课程的深入,实验的难度也会逐步增加,最终你将扩展XV6的功能,甚至为它添加新的系统调用和服务。通过这些实验,你将获得操作系统开发的实际经验,从而更深入地理解操作系统的内部机制和设计原则。

示例1:copy.c

示例的第一个系统调用示例是一个名为 copy 的简单程序。这个程序的主要功能是从输入读取数据,然后将这些数据写入到输出。它的源代码非常简短,具体如下:


// copy.c: copy input to output.

#include "kernel/types.h"
#include "user/user.h"

int
main()
{
    char buf[64];

    while(1){
        int n = read(0, buf, sizeof(buf));
        if(n <= 0)
            break;
        write(1, buf, n);
    }
    exit(0);
}

这个程序从第8行的 main 函数开始,这是C语言程序的常见结构。程序的核心逻辑是在第12行进入了一个循环,在这个循环中,程序会执行以下操作:

  1. 读取输入数据
    • 在第13行,read 系统调用被调用。read 函数接收三个参数:
      • 第一个参数是文件描述符,这里是 0,代表标准输入(通常是键盘输入)。
      • 第二个参数是 buf,一个指向内存的指针,程序会将读取的数据存储到这个缓冲区中。
      • 第三个参数是要读取的最大字节数,这里使用 sizeof(buf) 表示最多读取64字节的数据。

    这行代码的作用是从标准输入读取最多64字节的数据,并将其存储在 buf 数组中。

  2. 处理读取的数据
    • read 的返回值存储在 n 中,表示实际读取的字节数。如果 n 的值为 0 或负值,表示输入结束或发生错误,程序会跳出循环。
    • 否则,程序会调用 write 系统调用(第16行),将 buf 中的数据写入到标准输出(文件描述符 1)。
  3. 程序终止
    • 当循环结束后,程序调用 exit(0) 终止。

这个 copy 程序非常简单,它直接从输入读取数据,然后将数据原封不动地写入输出。因此,如果你在XV6系统中运行这个程序,它会等待你的输入,并将你输入的内容显示在输出中。例如,如果你输入 “xyzzy”,程序会立即将 “xyzzy” 显示在控制台上。

代码解析

// copy.c: copy input to output.

这一行是代码的注释,简单描述了程序的功能:将输入复制到输出。这意味着程序从标准输入读取数据,然后将数据写入到标准输出。

#include "kernel/types.h"
#include "user/user.h"

这两行包含了程序运行所需的头文件:

  • kernel/types.h 定义了一些基本的数据类型,例如整数类型和其他操作系统相关的类型。
  • user/user.h 提供了用户空间使用的系统调用接口,比如 readwriteexit
int
main()
{
    char buf[64];

main() 是程序的入口函数,在这里,程序从此开始执行。char buf[64]; 声明了一个字符数组 buf,它在栈上分配了64字节的空间,用于存储从输入读取的数据。

    while(1){
        int n = read(0, buf, sizeof(buf));
        if(n <= 0)
            break;
        write(1, buf, n);
    }

这里是程序的核心部分,执行了一个无限循环,用于不断读取输入并将其输出:

  • read(0, buf, sizeof(buf));:这是 read 系统调用,它从文件描述符 0 (标准输入)读取数据,并将其存储在 buf 中。sizeof(buf) 表示最多读取64字节的数据。
    • 第一个参数 0 是文件描述符,表示标准输入(通常是键盘)。
    • 第二个参数 buf 是指向存储数据的内存缓冲区。
    • 第三个参数 sizeof(buf) 指定读取的最大字节数。
  • int n = read(0, buf, sizeof(buf)); 这个调用返回实际读取的字节数,并将其存储在 n 中。如果 n <= 0,则表示读取结束或发生错误,程序跳出循环。

  • write(1, buf, n);:这是 write 系统调用,它将缓冲区 buf 中的 n 字节数据写入到文件描述符 1 (标准输出)。
    • 第一个参数 1 是文件描述符,表示标准输出(通常是终端屏幕)。
    • 第二个参数 buf 是指向要写入数据的内存缓冲区。
    • 第三个参数 n 是要写入的字节数。
    exit(0);
}

当循环结束后,程序调用 exit(0) 终止程序,返回状态码 0,表示程序正常结束。

程序功能与行为

这个 copy.c 程序的功能非常简单:它从标准输入(例如键盘)读取数据,并将读取到的数据立即写入标准输出(例如终端)。因此,如果你在终端中运行这个程序并输入文本,它会立即将相同的文本输出在屏幕上。

例如,运行该程序后,终端可能如下所示:

$ ./copy
hello world
hello world

当你输入 “hello world” 并按下回车键时,程序会将 “hello world” 显示在屏幕上,因为它从标准输入读取了这一行并将其写入了标准输出。

copy.c 是一个简单但非常有效的示例,展示了如何使用系统调用来进行基本的输入输出操作。通过这个示例,你可以了解操作系统如何通过系统调用与应用程序交互,并体会到在C语言编程中进行系统级操作的细节和潜在的风险。这些基础知识将为你后续更复杂的操作系统开发奠定坚实的基础。

read 系统调用

  • read(0, buf, sizeof(buf)) 中,0 是文件描述符,指向标准输入。标准输入通常由Shell在启动程序时自动设置为控制台输入。buf 是存储读取数据的缓冲区,分配了64字节的空间(第9行)。sizeof(buf) 确保最多读取64字节的数据到缓冲区中。

错误处理

  • read 的返回值 n 是实际读取的字节数。如果读取失败或到达文件末尾,n 的值将为 0 或负数。在这种情况下,程序会跳出循环,结束读取过程。

write 系统调用

  • write(1, buf, n) 将读取到的数据写入标准输出(文件描述符 1),即控制台。这使得用户输入的内容能够立即显示在控制台上。

C语言的特性

  • copy 程序并不关心读取和写入的数据的格式,它只是简单地处理字节流。操作系统仅将数据视为一串8位字节,而如何解释这些字节完全取决于应用程序本身。这种设计为应用程序提供了极大的灵活性,同时也要求程序员非常小心,避免出现内存溢出等错误。

学生提问

学生提问:如果 read 的第三个参数设置为 1 + sizeof(buf) 会发生什么?

Robert教授的回答:如果你将第三个参数设置为65字节,操作系统会尝试拷贝65字节到你提供的内存区域(buf)。但是由于 buf 只有64字节,额外的字节会覆盖栈中其他数据,导致缓冲区溢出(buffer overflow)。这种错误可能导致程序崩溃或产生不可预测的行为。因此,作为程序员,必须非常谨慎,确保程序不会试图读取或写入超过分配内存范围的数据。C语言容易出现这种问题,因为编译器不会自动检查内存访问的安全性,这使得代码编写时需要特别小心。

示例2: open.c

在之前的 copy 程序中,我们假设文件描述符已经被设置好。然而,在实际开发中,通常需要通过系统调用 open 来创建文件描述符。下面我们来看一个名为 open.c 的程序,它展示了如何使用 open 系统调用来创建文件描述符并将数据写入文件。

// open.c: create a file, write to it.

#include "kernel/types.h"
#include "user/user.h"
#include "kernel/fcntl.h"

int
main()
{
    int fd = open("output.txt", O_WRONLY | O_CREATE);
    write(fd, "ooo\n", 4);
    exit(0);
}

这个程序的功能是创建一个名为 output.txt 的新文件,然后将字符串 “ooo\n” 写入文件,最后退出。因为这个程序只是将数据写入文件,并没有输出到控制台,所以你在执行它时不会看到任何输出结果。

代码解析与系统调用

  1. open 系统调用

    • 在第11行,程序使用 open 系统调用创建或打开一个文件:
      int fd = open("output.txt", O_WRONLY | O_CREATE);
      

      这里的 open 函数接收两个参数:

      • 第一个参数 "output.txt" 是文件名。如果文件存在,open 将打开它;如果文件不存在,open 将根据第二个参数的标志位 O_CREATE 创建它。
      • 第二个参数 O_WRONLY | O_CREATE 是标志位,告诉 open 函数我们希望以写入模式(O_WRONLY)打开文件,并在文件不存在时创建文件(O_CREATE)。

    open 函数返回一个文件描述符(fd),这是一个小整数,用于标识这个已打开的文件。在内核中,每个文件描述符对应一个文件表项,该表项保存着与该文件相关的各种信息。

  2. write 系统调用
    • 接下来,程序调用 write 函数将数据写入文件:
      write(fd, "ooo\n", 4);
      
      • 第一个参数是刚刚通过 open 获得的文件描述符 fd
      • 第二个参数是要写入的数据,这里是字符串 "ooo\n"
      • 第三个参数是要写入的字节数,在这个例子中是4个字节。

    write 函数将数据从内存写入到文件中,由 fd 所标识的文件表项负责管理这个操作的细节。

  3. 文件描述符的作用
    • 文件描述符实际上是内核为每个进程维护的一个文件表的索引。每个进程有自己独立的文件描述符空间,这意味着即使两个不同的进程使用相同的文件描述符编号(例如都为 3),它们指向的实际文件可以是完全不同的。这是因为内核为每个进程单独维护了一个文件描述符表。

通过这个 open.c 示例,你可以看到如何使用 openwrite 系统调用来创建文件并写入数据。文件描述符在操作系统中的作用至关重要,它们提供了一种与文件交互的抽象方式,使得操作系统能够有效地管理文件资源。理解这些基本的系统调用是深入学习操作系统的关键,为你后续的实验和开发奠定了坚实的基础。

学生提问:什么是字节流?

教授的回答:字节流指的是一段连续的数据,以字节为单位进行读取或写入。例如,如果一个文件包含数百万个字节的数据,每次 read 操作读取100个字节,那么第一次读取的是前100个字节,第二次读取的是第101到200个字节,依此类推。这就是字节流的概念。

学生提问:对于C语言不熟悉的学生来说,文件描述符与非C语言(如Python)中的文件操作有什么区别?在Python中是否更简单?

教授的回答:Python对文件操作提供了更高级的封装。与C语言不同,Python不使用指针,并且会为你自动处理错误检查。当你在Python中打开或写入文件时,尽管语法更为简洁和易用,最终这些操作还是通过系统调用完成的。因此,虽然Python简化了操作,但它背后的机制与C语言中的操作类似。

在之前的内容中,我们介绍了如何使用 openwrite 系统调用创建和写入文件。现在我们来讨论XV6中类Unix的Shell,以及Shell如何与操作系统和文件系统进行交互。

Shell 的基本功能与操作

XV6 提供了一个类似Unix的命令行接口,称为Shell。Shell 是用户与操作系统交互的重要工具,特别是在没有图形用户界面的系统中。它不仅允许用户运行程序,还提供了强大的文件管理和脚本编写功能。

运行程序

当你在Shell中输入一个命令时,如 ls,实际上你是在告诉Shell去运行一个名为 ls 的程序。这个程序在文件系统中作为一个可执行文件存在,里面包含了具体的计算机指令。当你输入 ls 时,Shell 找到并运行这个文件,将当前目录中的文件列表打印到屏幕上。

ls

在运行 ls 时,Shell 会在当前目录下查找 ls 可执行文件,并执行其包含的指令。执行后,ls 程序会输出当前目录的文件列表。例如,如果当前目录下存在一个名为 ls 的文件,这个文件中包含了列出目录内容的指令,那么输入 ls 后,Shell 就会调用该文件并执行其中的指令。

输入/输出重定向

除了直接运行程序,Shell 还允许用户重定向输入和输出。重定向的典型例子是将命令的输出保存到文件中,而不是直接显示在终端上。比如:

ls > out

这条命令告诉Shell运行 ls 命令,但将输出保存到 out 文件中,而不是显示在屏幕上。由于输出被重定向到文件 out,执行命令后你不会看到任何输出显示在屏幕上。这时,你可以使用 cat 命令查看 out 文件的内容:

cat out

通过 cat out 命令,你可以看到之前 ls 命令的输出内容,现在它被存储在 out 文件中。

使用 grep 命令过滤输出

Shell 还提供了强大的工具,如 grep,用于在文本中搜索特定的模式。例如,如果你想找到 out 文件中包含字母 “x” 的行,可以使用以下命令:

grep x out

这条命令会在 out 文件中搜索包含 “x” 的所有行并显示出来。由于 out 文件中包含了 ls 命令的输出,如果有文件名中包含字母 “x”,grep 会将这些行输出在终端上。

Shell 的重要性与历史背景

Shell 是最基础的Unix接口,它起源于Unix操作系统开发的早期阶段。当时,计算机系统主要是通过简单的终端接口进行操作,用户通过Shell与计算机进行交互。Unix 的设计目标之一是支持多用户分时共享系统,用户可以通过Shell提交任务,操作文件,运行程序。尽管现代操作系统中图形用户界面非常流行,但Shell依然是系统管理和高级操作不可或缺的工具。

编译器如何处理系统调用

关于编译器如何处理系统调用,这里有一个值得关注的技术细节。当你在C语言中调用系统调用(如 openwrite)时,虽然它们表现为C语言函数,但实际上,它们内部执行的是特定的机器指令。

学生提问:编译器如何处理系统调用?生成的汇编语言是不是会调用一些由操作系统定义的代码段?

Robert教授的回答:在RISC-V架构中,存在一个特殊的指令 ecall,用于触发系统调用。当程序运行C语言中的 openwrite 函数时,这些函数内部会执行 ecall 指令,将控制权交给内核。内核接收到控制权后,会检查进程的内存和寄存器,以确定系统调用的类型和参数。虽然 openwrite 在C语言中表现为普通函数,但它们的实现实际上依赖于汇编代码,特别是 ecall 指令,该指令在RISC-V中专门用于系统调用。

通过理解Shell的工作原理和编译器如何处理系统调用,你可以更好地掌握操作系统与用户交互的基本机制。Shell不仅是执行程序的工具,也是管理系统、操作文件、编写脚本的重要接口。理解系统调用背后的机制,如 ecall 指令,则有助于你深入了解操作系统的内部工作原理。这些知识对于全面掌握操作系统至关重要,尤其是在没有图形界面的环境中。

示例3:fork.c

fork 是操作系统中用于创建新进程的关键机制。

fork 系统调用的示例

// fork.c: create a new process

#include "kernel/types.h"
#include "user/user.h"

int
main()
{
    int pid;

    pid = fork();
    printf("fork() returned %d\n", pid);

    if(pid == 0){
        printf("child\n");
    } else {
        printf("parent\n");
    }
    exit(0);
}

代码解析与深入讲解

  1. fork 调用与进程复制
    • 在第12行,我们调用了 fork 系统调用。fork 的作用是创建一个与当前进程几乎完全相同的新进程,即子进程。fork 通过复制当前进程的整个内存空间(包括代码段、数据段、堆栈段)来实现这一点。结果是,我们得到两个几乎相同的进程:一个是原始的父进程,另一个是新创建的子进程。
  2. fork 的返回值
    • fork 系统调用在父进程和子进程中都会返回。在父进程中,fork 返回子进程的进程ID(通常是一个正整数);在子进程中,fork 返回 0。这一区别使得我们可以通过 fork 的返回值来区分父进程和子进程。
    printf("fork() returned %d\n", pid);
    

    这一行代码会输出 fork 的返回值。在父进程中,pid 是子进程的ID,而在子进程中,pid0

  3. 子进程与父进程的分支
    • 通过 if 语句,我们可以根据 pid 的值来区分和处理父进程和子进程:
    if(pid == 0){
        printf("child\n");
    } else {
        printf("parent\n");
    }
    
    • 如果 pid 等于 0,意味着这是子进程的执行路径,所以程序会打印 child
    • 否则,这就是父进程的执行路径,程序会打印 parent

    这两个进程虽然共享同样的代码和数据,但它们的执行是相互独立的。每个进程都有自己的地址空间和执行路径。

  4. 进程的并发执行
    • 由于 fork 创建了一个新的进程,这两个进程将会并发执行。在现代多核处理器或通过QEMU模拟器时,这两个进程可能会真正同时运行。因此,两个进程的输出可能会交错,特别是在没有同步机制的情况下。例如,如果两个进程同时尝试写入终端,你可能会看到两个进程的输出混合在一起。

    这就是为什么你有时会看到输出像 “pcahrileln”(child 和 parent 的输出混在一起),而不是按顺序分别打印 “child” 和 “parent”。

学生提问fork 产生的子进程是不是总是与父进程一样?它们有可能不一样吗?

教授的回答:在XV6中,除了 fork 的返回值,子进程和父进程几乎是完全一样的。两个进程的代码、数据、堆栈内容都是相同的。每个进程都有独立的地址空间,这意味着即使它们的内存布局相同,它们实际操作的物理内存是不同的。在更复杂的操作系统中,有一些其他因素(如信号处理、资源限制等)可能会导致父子进程在某些细节上有所不同,但在XV6中,除了 fork 的返回值,其他方面基本相同。

进一步的理解:文件描述符的继承

除了内存,fork 还会将父进程的文件描述符表复制给子进程。这意味着,如果父进程打开了一个文件,子进程也能通过相同的文件描述符访问该文件。虽然它们使用的文件描述符编号相同,但这两个文件描述符是相互独立的拷贝,各自操作不会互相影响。

exec 系统调用的引入

在Unix-like系统中,如XV6,Shell在运行命令时通常会执行 fork 以创建一个新进程,然后使用 exec 系统调用将该新进程替换为需要运行的命令。例如,当你在Shell中输入 ls 时,Shell会使用 fork 创建一个新进程,然后在该进程中调用 exec 加载并执行 ls 程序的指令。这样,新的进程就不再是Shell的代码,而是 ls 的代码。

通过这个 fork.c 示例,我们可以看到 fork 系统调用如何创建新的进程,并理解了父子进程如何通过 fork 的返回值进行区分。fork 是操作系统中并发执行的基础,理解它的工作原理对于掌握多进程编程和操作系统的进程管理至关重要。这种机制是现代操作系统能够同时运行多个程序的核心所在。

我们来详细解析 exec 系统调用的示例代码,并扩展对其工作原理的理解。

exec 系统调用的示例

图片中的代码展示了一个简单的使用 exec 系统调用的例子:

// exec.c: replace a process with an executable file

#include "kernel/types.h"
#include "user/user.h"

int
main()
{
    char *argv[] = { "echo", "this", "is", "echo", 0 };
    exec("echo", argv);
    printf("exec failed!\n");
    exit(0);
}

代码解析与深入讲解

  1. exec 系统调用的作用
    • exec 系统调用的功能是用一个新程序替换当前进程的内容。当 exec 被调用时,操作系统会从指定的可执行文件中读取指令,并将这些指令加载到当前进程的内存中,从而替换掉原来的指令和数据。简而言之,exec 将当前进程变成了一个新程序。
  2. 字符指针数组 argv[]
    • 在第10行,定义了一个字符指针数组 argv[],这个数组中的每个元素指向一个字符串。这些字符串代表要传递给 echo 程序的命令行参数。
    • argv[] 的最后一个元素是 0,表示数组的结束。这是C语言中的一个常见习惯,因为C语言本身没有内置的方法来确定数组的长度,所以需要手动标记数组的结束。
    char *argv[] = { "echo", "this", "is", "echo", 0 };
    

    这个数组等价于在命令行中输入:

    echo this is echo
    
  3. 调用 exec
    • 在第12行,调用了 exec 系统调用:
      exec("echo", argv);
      

    这一行的作用是将当前进程替换为 echo 程序,并将 argv 中的参数传递给 echo。执行 exec 后,当前进程的内存将被 echo 程序的代码和数据替换,原来的代码(包括这个 exec 调用本身)将不复存在。

  4. exec 的返回
    • 通常情况下,exec 系统调用不会返回,因为它会用新程序的内容完全替换当前进程的内容。如果 exec 返回了,说明发生了错误,例如指定的程序文件不存在或无法加载。在这种情况下,程序会执行 printf("exec failed!\n");,输出一条错误消息。
  5. 文件描述符的继承
    • 即使 exec 替换了进程的代码和数据,当前进程的文件描述符表仍然保持不变。这意味着,文件描述符0、1、2(分别对应标准输入、标准输出和标准错误输出)在新加载的程序中依然有效。这种行为允许新程序继续使用已经打开的文件或终端。

运行效果与理解

当你运行这个程序时,它实际上执行了 exec 系统调用,将当前进程的内容替换为 echo 程序,并输出如下内容:

$ exec
this is echo

学生提问argv 中最后一个 0 是什么意思?

Robert教授的回答argv 中的最后一个 0 用于标记数组的结束。在C语言中,数组的长度通常需要明确标记,而 0(即 NULL 指针)在这里起到了结束符的作用。内核在遍历 argv 时,会通过找到这个 0 来确定数组的结束位置。

exec 与 Shell 的关系

在Unix-like系统中,如XV6,Shell在运行命令时通常不会自己调用 exec 来替换自身。这是因为如果Shell进程被 exec 替换了,Shell将不再存在,无法继续接受新的用户命令。因此,Shell通常会先调用 fork 创建一个子进程,然后在子进程中调用 exec。这样,父进程(Shell)依然存在,可以继续处理后续的命令,而子进程则被替换为新程序并执行该程序的指令。

exec 是操作系统中非常重要的系统调用,它允许一个进程的内容被新的可执行文件完全替换。这在多进程操作系统中非常常见,尤其是在Shell中执行命令时,forkexec 常常被配合使用。通过理解 exec 的工作原理,你可以更好地理解操作系统是如何管理进程的,以及Shell如何在执行命令时保持自身的存在。这种知识对于深入理解操作系统的运行机制是非常关键的。

forkexec 的结合使用

在这段内容中,我们来看一个更复杂的例子,展示了如何结合使用 forkexec 系统调用来创建子进程并执行新程序。这是Unix-like操作系统中的一个典型编程模式。

第一个版本:正常执行 echo

#include "user/user.h"

// forkexec.c: fork then exec

int
main()
{
    int pid, status;

    pid = fork();
    if(pid == 0){
        char *argv[] = { "echo", "THIS", "IS", "ECHO", 0 };
        exec("echo", argv);
        printf("exec failed!\n");
        exit(1);
    } else {
        printf("parent waiting\n");
        wait(&status);
        printf("the child exited with status %d\n", status);
    }
    exit(0);
}
  1. fork 调用
    • 在第12行调用 fork 系统调用创建一个新的子进程。如果 fork 成功,pid 变量在父进程中会包含子进程的进程ID(一个正数),而在子进程中则会返回 0
  2. 子进程执行 exec
    • 如果 pid 等于 0(即在子进程中),程序会执行第14行至第17行。这里,子进程定义了一个字符指针数组 argv[],用于存储将传递给 echo 程序的参数。然后调用 exec 系统调用将当前子进程替换为 echo 程序。
    • 如果 exec 成功执行,echo 程序将取代子进程的代码,而 printf("exec failed!\n");exit(1); 将永远不会执行。
    • 如果 exec 失败(例如,指定的程序不存在),程序会执行 printf("exec failed!\n");,然后通过 exit(1); 退出子进程,并将状态码 1 传递给父进程。
  3. 父进程等待子进程
    • 如果 pid 大于 0(即在父进程中),程序会继续执行第19行至第22行。父进程会调用 wait 系统调用等待子进程结束,并通过 status 变量获取子进程的退出状态。
    • wait(&status); 会阻塞父进程,直到子进程终止,并将子进程的退出状态存储在 status 中。随后,父进程输出子进程的退出状态。

第二个版本:处理 exec 失败

在代码的第二个版本中,我们修改了 exec 调用,试图执行一个不存在的命令:

#include "user/user.h"

// forkexec.c: fork then exec

int
main()
{
    int pid, status;

    pid = fork();
    if(pid == 0){
        char *argv[] = { "echo", "THIS", "IS", "ECHO", 0 };
        exec("x*lsdklsdjklxecho", argv);
        printf("exec failed!\n");
        exit(1);
    } else {
        printf("parent waiting\n");
        wait(&status);
        printf("the child exited with status %d\n", status);
    }
    exit(0);
}
  1. 修改 exec
    • 在第14行,将原来的 exec("echo", argv); 修改为 exec("x*lsdklsdjklxecho", argv);,这个命令是不存在的,因此 exec 会失败。
  2. exec 失败时的处理
    • 因为 exec 无法找到并执行 x*lsdklsdjklxecho,它会返回并继续执行接下来的代码。这时程序会输出 "exec failed!\n" 并调用 exit(1);,退出子进程并将状态码 1 传递给父进程。
  3. 父进程的响应
    • 父进程依然会调用 wait(&status); 等待子进程结束。由于子进程是通过 exit(1); 退出的,父进程会收到子进程的退出状态 1,并输出 "the child exited with status 1"

运行效果分析

  • 在第一个版本中,子进程成功地用 echo 程序替换了自己,并传递了 "THIS IS ECHO" 作为参数。当 echo 执行完毕并成功退出后,父进程恢复控制并检测到子进程的退出状态为 0(表示成功)。

  • 在第二个版本中,由于 exec 调用失败,子进程未能替换为新的程序。相反,子进程输出 "exec failed!\n" 并以状态码 1 退出。父进程等待子进程退出,并打印出子进程的失败状态。

学生提问:在第15行调用 exec 后,代码是否可能继续执行第16、17行?

教授的回答:通常情况下,如果 exec 成功执行,代码将不会返回到调用 exec 的子进程中,因此第16、17行的代码不会被执行。然而,如果 exec 失败,例如当要执行的程序不存在时,exec 会返回,这时第16、17行的代码将被执行。

通过这两个示例,我们清楚地看到 forkexec 的结合使用如何实现进程的创建和程序的执行。这种模式广泛用于Unix-like操作系统,尤其是在Shell中,常用于执行用户命令。理解这一机制对于掌握操作系统进程管理和多任务处理非常重要。

优化 forkexec 结合使用的讨论

我们讨论了如何结合使用 forkexec 系统调用来创建新进程并执行新程序。然而,这种方法在某些情况下可能存在低效问题,特别是在处理大型程序时。我们现在深入探讨这些问题以及相应的优化方法。

forkexec 的低效问题

当你调用 fork 时,操作系统会复制当前进程的整个内存空间,包括代码段、数据段、堆栈等。然而,紧接着调用 exec 时,这些内存拷贝将被完全丢弃,因为 exec 会用新程序的内容替换子进程的内存。这种情况下,fork 的内存拷贝操作实际上是浪费的。

  • 假设你有一个大型的进程,它占用了几GB的内存。如果调用 fork,操作系统需要复制这几GB的数据到子进程中,这可能会消耗相当长的时间(例如几百毫秒到一秒)。然而,如果在 fork 之后立即调用 exec,这些拷贝的内存将被新程序的内容取代,前面的内存拷贝就毫无意义了。

解决方案:写时复制(Copy-on-Write)

为了解决这个低效问题,操作系统引入了一种称为写时复制(Copy-on-Write,COW)的技术。通过COW,fork 时父进程和子进程实际上不会立即复制所有内存,而是共享同一份内存。只有当父进程或子进程试图修改某段内存时,操作系统才会真正执行拷贝操作。

具体原理

  • 内存共享:当 fork 创建子进程时,父进程和子进程最初共享同一块内存区域。这些内存区域标记为只读。
  • 延迟拷贝:当父进程或子进程尝试写入共享内存时,操作系统会在此时执行内存的拷贝操作,将共享的只读内存复制一份,使得两个进程都拥有各自独立的可写内存。
  • 减少浪费:通过这种方式,如果子进程在 fork 后立即执行 exec,内存几乎不需要实际复制,大大减少了不必要的开销。

实际应用与实验

在这门课程的后面,你们将有机会实现这种写时复制的 fork。通过这种方式,你们可以大大提高 forkexec 组合使用时的效率,特别是在处理内存占用大的程序时。这一实验将涉及到虚拟内存管理的技巧,是一个非常有趣且有挑战性的任务。

提问与回答

  1. 为什么父进程在子进程调用 exec 之前就打印了“parent waiting”?

    教授的回答:这是因为父进程和子进程是并发运行的。exec 的执行涉及文件系统访问、磁盘I/O、内存分配等操作,这些操作可能需要一定时间。因此,父进程有可能在子进程开始执行 exec 之前完成自己的输出。这种情况并不罕见,尤其是在 exec 的操作较为耗时时。

  2. 子进程可以等待父进程吗?

    教授的回答:在Unix-like操作系统中,子进程无法直接等待父进程。wait 系统调用只能让当前进程等待它的子进程结束,而不能让子进程等待父进程。子进程调用 wait 会立即返回 -1,表示没有子进程可以等待。

  3. 当我们说子进程从父进程拷贝了所有内存,这具体指的是什么?

    教授的回答:在 fork 调用时,子进程会获得父进程内存的一个拷贝,这包括了程序的指令、数据和堆栈等。通过虚拟内存技术,这些内存可以被复制并独立于父进程进行操作。实际上,这些指令和数据就是在内存中的字节,fork 的操作就是将这些字节拷贝到子进程的地址空间中。

  4. 如果父进程有多个子进程,wait 是如何工作的?

    教授的回答:如果一个父进程调用了多次 fork,它会拥有多个子进程。为了确保所有子进程都被正确处理,父进程需要调用 wait 多次,每次 wait 都会等待一个子进程结束并返回该子进程的退出状态。如果有多个子进程并行运行,wait 返回时并不指定哪个子进程结束了,但会通过返回的进程ID告知具体是哪个子进程。

通过上述讨论,你可以理解 forkexec 组合使用时的潜在低效问题及其解决方案。写时复制是一种有效的优化技术,它极大地提高了进程创建的效率,特别是在内存占用大的程序中。同时,通过理解 forkexec 的运行机制,你可以更好地掌握Unix-like操作系统的进程管理。

最后一个例子:实现I/O重定向

在这个最后的例子中,我们将前面学习到的 forkexec、文件描述符操作等概念结合起来,实现一个简单的I/O重定向程序。我们来看代码,并详细讲解其工作原理。

代码解析与深入讲解

// redirect.c: run a command with output redirected

#include "user/user.h"

int
main()
{
    int pid;

    pid = fork();
    if(pid == 0){
        close(1);
        open("output.txt", O_WRONLY|O_CREATE);
        char *argv[] = { "echo", "this", "is", "redirected", "echo", 0 };
        exec("echo", argv);
        printf("exec failed!\n");
        exit(1);
    } else {
        wait((int *) 0);
    }
    exit(0);
}
  1. fork 调用
    • 在第12行,程序调用 fork 创建一个新的子进程。子进程用于执行 exec 系统调用来运行 echo 命令。父进程则会等待子进程完成。
  2. 关闭标准输出
    • 在第15行,子进程调用 close(1); 关闭文件描述符1(标准输出)。此时,子进程已经没有了标准输出,它接下来将重新打开一个文件,并将其绑定到文件描述符1上。
  3. 打开文件并重定向输出
    • 第16行,调用 open("output.txt", O_WRONLY|O_CREATE); 打开(或创建)一个名为 output.txt 的文件,并将其以只写模式打开。由于先前关闭了文件描述符1,open 系统调用将返回的文件描述符必然是1。因此,文件描述符1现在与 output.txt 文件关联。这样,接下来的任何写入操作都会定向到 output.txt 文件,而不是终端。
  4. 执行 exec
    • 在第18行,调用 exec("echo", argv); 运行 echo 程序,并传递参数 "this is redirected echo"。由于子进程的标准输出已被重定向到 output.txtecho 的输出将写入 output.txt 文件,而不会显示在终端。
  5. 处理 exec 失败的情况
    • 如果 exec 调用失败,程序会输出 "exec failed!\n" 并以状态码1退出,表明子进程运行失败。
  6. 等待子进程结束
    • 父进程在第23行调用 wait((int *) 0); 等待子进程完成执行。当子进程结束后,父进程继续运行,并最终退出。

I/O重定向的工作原理

这个例子展示了如何通过关闭标准输出文件描述符并重新打开一个文件来实现I/O重定向:

  • 文件描述符的重定向:当你关闭文件描述符1并重新打开一个文件时,新的文件描述符1会与新打开的文件关联。这意味着之后的所有写操作都会定向到这个文件,而不是终端。

  • forkexec 的结合使用:通过将 forkexec 分开使用,我们可以在 exec 运行新程序之前对子进程的文件描述符进行修改。这使得子进程的行为与父进程分离,从而实现了输出的重定向,而不影响父进程。

总结

通过这个重定向的例子,我们将多个系统调用结合起来,实现了复杂的功能。这个例子展示了Unix-like系统中 forkexec 的强大之处,即可以在创建新进程后,在执行新程序之前对其环境(如文件描述符)进行修改。这种机制为实现Shell中的I/O重定向和其他高级功能提供了基础。

你可以看到,尽管接口本身是简单的,但通过组合使用这些接口,可以实现非常强大的功能。这正是Unix哲学的一个核心:通过简单、强大的工具组合,解决复杂的问题。

在接下来的实验中,你将有机会使用类似的工具来实现更多的功能。请认真完成实验,我们下节再见。