UCD-ECS150-操作系统与系统编程笔记-全-

UCD ECS150 操作系统与系统编程笔记(全)

001:操作系统简介 🖥️

在本节课中,我们将要学习操作系统的基础概念。我们将从定义操作系统开始,了解它是什么,以及它在计算机系统中扮演的核心角色。接着,我们会探讨计算机硬件的基本构成,并深入分析操作系统需要管理的核心资源。最后,我们将回顾操作系统的历史演变,并展望其未来面临的挑战。

什么是操作系统?

操作系统是一种软件层,它位于应用程序和计算机硬件之间。这层软件负责为应用程序和用户管理计算机的资源。这个定义虽然目前看起来有些宽泛,但我们将通过本课程逐步探索其具体含义,例如这个软件层具体是什么,以及它需要管理哪些资源。

计算机硬件基础

为了理解操作系统需要管理什么,我们首先需要明确计算机硬件的基本构成。无论计算机是台式机、服务器还是嵌入式设备,其基本结构都包含以下核心组件:

  • 中央处理器:负责从内存读取指令并执行。它包含一个指令集(如 x86、ARM)和一组寄存器(用于临时存储数据),以及程序计数器状态寄存器
  • 内存:一个巨大的字节数组,每个字节都有一个地址。CPU通过提供地址来读写内存中的数据。内存层次结构包括CPU寄存器主内存CPU缓存(对CPU透明)和二级存储(如硬盘、SSD)。
  • 输入/输出设备:通常由设备控制器(CPU可直接交互的部分)和实际设备(执行具体功能的部分,如Wi-Fi天线)组成。CPU可以通过内存映射访问(主流方式)或端口映射访问(旧方式)与设备控制器通信。
  • 互连总线:连接CPU、内存和设备的通信通道,使它们能够相互通信。通常分为高速的CPU-内存总线和速度稍慢的CPU-设备总线。

操作系统的核心角色

上一节我们介绍了计算机硬件的基本构成,本节中我们来看看操作系统在管理这些资源时需要扮演的三个核心角色。

  1. 裁判员:操作系统需要管理计算机资源在不同应用程序间的共享。这包括资源的分配(如CPU时间、内存空间)和应用程序间的隔离(防止一个程序影响另一个)。同时,操作系统也需要在保证隔离的前提下,允许应用程序之间进行协作与通信
  2. 幻术师:操作系统需要向应用程序隐藏硬件的复杂性,并提供更简单、统一的抽象接口。例如,应用程序无需关心数据是存储在SSD还是硬盘上,也无需处理网络数据包丢失等底层细节,这些都由操作系统负责。
  3. 粘合剂:操作系统应为应用程序提供尽可能多的通用服务(如文件操作、网络通信),避免每个应用程序都去“重新发明轮子”,从而简化编程。

操作系统的设计原则

了解了操作系统的角色后,如果我们想要设计一个操作系统,应该遵循哪些核心原则呢?以下是关键的设计考量指标:

  • 可靠性:操作系统必须能够始终如一地按照其规格说明执行功能。任何微小的错误都可能在长期运行中累积,导致灾难性后果。
  • 可用性:系统应尽可能(理想情况下是始终)处于可用状态。可靠性和可用性紧密相关,但并非同一概念。一个系统可能可靠(不丢失数据)但不可用(频繁崩溃),也可能可用(从不崩溃)但不可靠(经常丢失数据)。
  • 安全性与隐私性
    • 安全性:防止外部攻击者未经授权进入系统。
    • 隐私性:确保系统内的数据只能被授权用户访问。这涉及到安全机制(如高墙、铁门)和安全策略(规定谁可以进出)的区别。
  • 可移植性:包含两个方面。一是操作系统应向应用程序提供易于使用的抽象,使应用程序不受硬件差异的影响。二是操作系统本身应尽可能易于移植到不同的硬件平台,避免为每种新处理器重写整个系统。
  • 性能:包含多个指标:
    • 开销:操作系统自身运行所消耗的资源,应尽可能小。
    • 公平性:资源分配应公平,但公平不等于绝对平均,高优先级任务理应获得更多资源。
    • 延迟:完成一个操作所需的时间(如移动鼠标后光标响应的速度)。
    • 吞吐量:单位时间内能完成的操作数量。通常与延迟存在矛盾(例如,卡车吞吐量大但延迟高,跑车延迟低但吞吐量小)。
    • 可预测性:性能随时间保持一致的特性。有时可预测性比平均高性能更重要(例如,稳定的微小延迟优于偶尔出现的严重卡顿)。

操作系统的历史与未来

我们已经从概念上了解了操作系统是什么、做什么以及如何设计。现在,让我们回顾一下操作系统的演变历程,并展望其未来的发展方向。操作系统和计算系统的发展大致可分为三个阶段:

  1. 第一阶段(1950年代中期-1960年代中期):计算机极其昂贵,人类劳动力相对廉价。此时没有真正的操作系统,所谓的“OS”更像是一个公共函数库,因为计算机一次只能运行一个程序,无需管理资源共享。
  2. 第二阶段(1960年代中期-1990年代):随着集成电路发展,计算机成本下降。出现了能运行多道程序的大型机,催生了真正的操作系统,如IBM OS/360(具备内存保护)和具有开创性的Multics系统(引入了动态链接、文件系统等现代OS核心概念)。同时,个人计算机兴起,Unix操作系统被发明,并衍生出BSD、System V等变体,后来通过POSIX标准实现了统一。
  3. 第三阶段(1990年代至今):计算机变得非常廉价且无处不在,处理、存储成本急剧下降。未来操作系统面临三大挑战:
    • 摩尔定律的终结:需要通过架构革新3D堆叠量子计算等新范式来持续提升性能。
    • 计算设备的两极分化:操作系统需要同时适应极小型、低功耗的物联网设备超大规模的数据中心
    • 异构计算:同一系统内集成多种不同类型的处理器(如大小核CPU、GPU、AI加速器),操作系统需要学会高效管理和调度这些异构资源。

总结

本节课中我们一起学习了操作系统的基础知识。我们首先将操作系统定义为介于应用程序与硬件之间的资源管理软件层。接着,我们剖析了计算机硬件的核心组件:CPU、内存、I/O设备和总线。然后,我们探讨了操作系统扮演的三大角色:资源分配的裁判员、隐藏复杂性的幻术师以及提供通用服务的粘合剂。在此基础上,我们分析了设计操作系统时应遵循的关键原则,包括可靠性、可用性、安全性、可移植性和各项性能指标。最后,我们回顾了操作系统的历史演变,并展望了其在后摩尔定律时代、设备两极分化和异构计算背景下面临的挑战。下一节课,我们将开始学习系统调用,探讨应用程序与操作系统之间是如何进行通信的。

002:系统调用(第一部分)

在本节课中,我们将要学习系统调用。系统调用是应用程序与操作系统之间的接口。我们将通过具体的代码示例,了解什么是系统调用,它们如何工作,以及为什么应用程序需要它们。

概述:什么是系统调用?

在开始详细定义系统调用之前,让我们直接看一个例子。以下是一个C语言程序:

// exec_detector.c
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        return 1;
    }
    char buffer[4];
    memset(buffer, 0, sizeof(buffer));
    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    read(fd, buffer, sizeof(buffer));
    close(fd);
    if (buffer[0] == 0x7f && buffer[1] == 'E' && buffer[2] == 'L' && buffer[3] == 'F') {
        printf("ELF binary executable\n");
    } else if (buffer[0] == '#' && buffer[1] == '!') {
        printf("Script\n");
    } else {
        printf("Not an executable\n");
    }
    return 0;
}

这个程序接收命令行参数,并期望至少有一个参数(文件名)。它打开文件,读取前4个字节,并根据这些字节判断文件类型(ELF二进制可执行文件、脚本或非可执行文件)。

这个程序本身很简单,但关键在于它调用了许多我们并未亲自编写的函数,例如 memsetopenreadcloseprintfperror。这些函数来自C标准库。

C库函数的分类

上一节我们看到了一个调用了多个库函数的程序。这些库函数可以根据其是否需要操作系统协助来完成工作,分为三类。

以下是这三类函数的简要说明:

  1. 无需特权的函数:这些函数可以完全在用户空间执行,不需要操作系统介入。例如 memset
  2. 总是需要系统调用的函数:这些函数的核心功能必须由操作系统执行,因为它们涉及特权操作或硬件访问。例如 openreadwrite
  3. 有时需要系统调用的函数:这些函数的行为取决于具体情况,有时会缓冲数据,只在特定条件下触发系统调用。例如 printf

第一类:无需特权的函数

memset 函数为例。它的功能是将一块内存区域设置为特定值。这个操作不涉及任何硬件访问或权限检查,完全可以在应用程序自己的内存空间内完成。

我们可以自己实现一个简单的 memset

void *my_memset(void *s, int c, size_t n) {
    unsigned char *p = s;
    for (size_t i = 0; i < n; i++) {
        p[i] = (unsigned char)c;
    }
    return s;
}

如你所见,这只是纯粹的C代码循环,不需要任何特殊权限。

第二类:总是需要系统调用的函数

现在,我们来看看第二类函数,例如 openread。为什么它们需要操作系统?

当调用 open(“file.txt”, O_RDONLY) 时,程序需要:

  • 检查文件是否存在(可能涉及访问硬盘、SSD或网络存储)。
  • 检查当前进程是否有权限读取该文件。
  • 根据文件类型和模式执行相应操作。

应用程序自身无法安全、可靠地执行这些检查,因为这需要访问受保护的系统资源和硬件。因此,open 函数必须请求操作系统代表它执行这些特权操作。这个请求机制就是系统调用

在C标准库中,read 函数可能只是一个简单的包装器:

ssize_t read(int fd, void *buf, size_t count) {
    return syscall(SYS_read, fd, buf, count);
}

syscall 函数内部会使用特定的CPU指令(例如 syscallint 0x80)从用户模式切换到内核模式,让操作系统内核接管执行。这个指令和具体的参数传递方式(使用哪些寄存器)取决于处理器架构(如x86-64或ARM)和操作系统。

第三类:有时需要系统调用的函数

最后,我们看看像 printf 这样的函数。为了提高性能,printf 通常不会在每次调用时都立即将数据发送到屏幕。相反,它会将输出缓存在内存中。

考虑以下两个程序:

程序A (使用 printf 缓冲):

#include <stdio.h>
#include <unistd.h>
int main() {
    printf(“Hello”);
    sleep(2);
    printf(“ World!\n”);
    return 0;
}

程序B (使用 write 直接系统调用):

#include <unistd.h>
#include <string.h>
int main() {
    write(STDOUT_FILENO, “Hello”, 5);
    sleep(2);
    write(STDOUT_FILENO, “ World!\n”, 8);
    return 0;
}

运行程序A,你会先等待2秒,然后一次性看到“Hello World!”输出。这是因为第一个 printf(“Hello”) 没有换行符 \n,字符串被缓冲了。直到第二个包含 \nprintf 被调用,缓冲区才被“刷新”,所有数据通过系统调用 write 一次性发送到内核。

运行程序B,你会立即看到“Hello”,等待2秒后,再看到“ World!”。因为 write 函数直接触发系统调用,没有缓冲。

系统调用的定义与流程

基于前面的例子,我们现在可以正式定义系统调用。

系统调用是应用程序请求操作系统内核为其执行特权操作的编程接口。你可以把它看作操作系统的API。

其工作流程如下:

  1. 用户程序:执行一个库函数调用(如 read(fd, buf, count))。
  2. C库包装器:库函数(如 read)准备参数,并执行一个特殊的软中断指令(如 syscall)。
  3. CPU切换模式:CPU捕获该指令,从用户模式切换到内核模式,并跳转到内核中预设的系统调用处理程序。
  4. 内核执行:内核根据系统调用号(例如,read 对应一个数字)查找对应的服务例程。该例程执行实际的底层操作(如从磁盘读取数据到内核缓冲区)。
  5. 返回结果:操作完成后,内核将结果(如读取的字节数或错误码)放入指定寄存器或内存,并执行返回指令。
  6. 切换回用户模式:CPU切换回用户模式,返回到库函数中系统调用指令之后的位置。
  7. 库函数返回:库函数将内核返回的结果处理成标准形式,返回给用户程序。

现代操作系统(如Linux)有数百个系统调用,涵盖了进程管理、文件操作、网络通信、进程间通信、内存管理等多个方面。

进程与进程管理

在深入讨论具体的进程管理系统调用之前,我们需要明确两个核心概念:程序进程

  • 程序:是一个静态的、存储在磁盘上的文件,包含执行特定任务的指令和数据。它就像一本菜谱。
  • 进程:是程序的一次动态执行实例。它是“正在运行的程序”。当你在终端输入命令并回车时,就创建了一个进程。它就像你按照菜谱实际烹饪的过程。一个程序可以同时有多个进程实例(例如,打开多个文本编辑器窗口)。

操作系统为每个运行的进程维护一个称为 进程控制块 的数据结构。PCB就像是进程的“身份证”和“档案”,记录了进程的各种信息,例如:

  • 进程ID:唯一的数字标识符。
  • 进程状态:运行中、就绪、阻塞等。
  • 程序计数器:下一条要执行的指令地址。
  • 寄存器值:CPU寄存器的内容。
  • 内存管理信息:如页表地址。
  • 打开文件列表:进程当前打开的文件描述符。
  • 所有者、优先级等信息

接下来,我们将关注用于管理进程的系统调用。主要包括:

  • 创建与执行fork, exec
  • 终止与等待exit, wait/waitpid
  • 获取信息getpid, getppid

fork 系统调用

fork 是Unix/Linux系统中创建新进程的主要方式。它的行为非常独特:复制当前进程

调用 fork 后,操作系统会创建一个与当前进程几乎完全相同的副本,这个新进程称为子进程,原始进程称为父进程。子进程获得父进程内存空间、寄存器值、打开文件描述符等的独立拷贝。

一个关键点是,fork 在父进程和子进程中各返回一次,但返回值不同:

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

这允许父进程和子进程在执行相同的代码后,根据返回值分道扬镳,执行不同的任务。

让我们看一个例子:

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

int main() {
    pid_t pid;
    printf(“Before fork\n”);
    pid = fork();
    if (pid > 0) {
        // 父进程执行这部分代码
        printf(“I am the parent process. My PID is %d, Child‘s PID is %d\n”, getpid(), pid);
    } else if (pid == 0) {
        // 子进程执行这部分代码
        printf(“I am the child process. My PID is %d\n”, getpid());
    } else {
        // fork失败
        perror(“fork”);
        return 1;
    }
    printf(“This line is printed by both parent and child (PID: %d)\n”, getpid());
    return 0;
}

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

Before fork
I am the parent process. My PID is 1234, Child‘s PID is 1235
This line is printed by both parent and child (PID: 1234)
I am the child process. My PID is 1235
This line is printed by both parent and child (PID: 1235)

重要说明fork() 之后,父进程和子进程是独立的,它们的执行顺序由操作系统的调度器决定,因此输出中父进程和子进程的消息可能交错出现,每次运行的结果也可能不同。

总结

本节课中我们一起学习了系统调用的基础。我们了解到系统调用是应用程序与操作系统内核交互的桥梁,用于执行需要特权的操作。我们将C库函数分为三类,并重点理解了 fork 系统调用的工作机制——它通过复制当前进程来创建新的子进程,并且父子进程通过 fork 的返回值来区分彼此,从而可以执行不同的代码路径。在下一节课中,我们将继续学习 exec 系列系统调用以及其他进程管理相关的调用。

003:系统调用(第二部分)

概述

在本节课中,我们将继续学习系统调用。我们将深入探讨进程管理相关的系统调用,包括 execexitwait,并了解它们如何与 fork 协同工作。随后,我们将转向文件与目录相关的系统调用,学习如何打开、读取、写入文件,并理解文件描述符的概念。

上一节我们介绍了 fork 系统调用,它用于创建一个当前进程的副本。本节中我们来看看其他几个关键的进程管理调用。

进程管理:exec 系列调用

exec 是一个极其重要的系统调用,它通常与 fork 配合使用。其核心思想是:一个正在运行某个程序的进程,可以通过调用 exec替换当前正在执行的程序,转而开始执行另一个全新的程序。进程本身(其PID)保持不变,但执行的代码完全改变了。

以下是 exec 的一个基本示例。注意,我们使用的是 execv 变体,它需要提供可执行文件的完整路径和参数数组。

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

int main() {
    printf("Before exec...\n");
    char *args[] = {"/bin/echo", "ECS 150", NULL};
    execv("/bin/echo", args);
    printf("This line will NOT be printed if exec succeeds.\n");
    return 0;
}

当程序运行到 execv 时,如果调用成功,进程将停止执行原程序,并开始执行 /bin/echo 程序,输出 “ECS 150”。原程序中 execv 调用之后的 printf 语句将永远不会执行,因为进程已经“变身”了。只有当 execv 调用失败(例如指定的命令不存在),程序才会返回到原代码继续执行。

进程管理:exit 调用

exit 系统调用用于终止一个进程。进程可以在代码的任何地方显式调用 exit 来结束自己,这常用于处理错误。

#include <stdlib.h>
if (file_open_failed) {
    exit(1); // 以状态码 1 退出,表示错误
}

此外,当 main 函数执行 return 语句时,C 标准库会隐式地调用 exit。进程退出时会返回一个整数值,称为退出状态码。按照惯例,返回 0 表示成功,非零值表示某种错误。

在 Shell 中,可以通过 $? 变量获取上一个命令的退出状态码。

$ ls /
bin boot dev ... # 列出根目录内容
$ echo $?
0                # ls 命令成功,返回 0

$ ls /nonexistent_directory
ls: cannot access '/nonexistent_directory': No such file or directory
$ echo $?
2                # ls 命令失败,返回非零值 2

退出状态码对于脚本编程非常重要,它允许脚本根据前一个命令的成功与否来决定后续执行不同的逻辑。

进程管理:wait 调用

既然子进程可以通过 exit 返回状态码,父进程如何获取这个值呢?这就要用到 wait(或更精确的 waitpid)系统调用。它们允许父进程等待其子进程结束,并收集子进程的退出状态。

以下是 wait 的一个简单示例:

#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程
        printf("Hello from the child!\n");
        exit(42); // 子进程退出,返回 42
    } else {
        // 父进程
        int status;
        wait(&status); // 等待子进程结束,status 将包含退出信息
        printf("Child exited with status: %d\n", WEXITSTATUS(status));
    }
    return 0;
}

运行此程序,输出顺序是确定的:

  1. Hello from the child!
  2. Child exited with status: 42

wait 是一个阻塞调用。父进程调用 wait 后,会被操作系统挂起,直到它的一个子进程结束。子进程的退出状态信息被包装在 status 整型变量中,需要使用 WEXITSTATUS 等宏来提取具体的退出码。这种机制在父进程和子进程之间建立了同步关系。

综合示例:forkexecwait

forkexecwait 组合起来,就构成了在程序中运行另一个程序的基础模式。这实际上是 Shell 运行命令的核心原理。

#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程:变身去执行 echo 命令
        char *args[] = {"/bin/echo", "ECS 150", NULL};
        execv("/bin/echo", args);
        // 如果 execv 失败,才会执行下面这行
        perror("execv failed");
        exit(1);
    } else {
        // 父进程:等待子进程结束
        int status;
        wait(&status);
        if (WIFEXITED(status)) {
            printf("Child exited with status: %d\n", WEXITSTATUS(status));
        }
    }
    return 0;
}

这个模式可以扩展成一个简单的 Shell 原型:

  1. 显示提示符,等待用户输入命令。
  2. 调用 fork 创建子进程。
  3. 在子进程中,调用 exec 执行用户输入的命令。
  4. 在父进程(Shell本身)中,调用 wait 等待子进程结束。
  5. 子进程结束后,父进程循环回到第1步。

进程关系:getpidgetppid

在进程树中,每个进程都有唯一的进程ID(PID)和父进程ID(PPID)。系统调用 getpid 用于获取当前进程的PID,getppid 用于获取其父进程的PID。

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

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程
        printf("Child: My PID is %d\n", getpid());
        printf("Child: My parent's PID is %d\n", getppid());
        exit(0);
    } else {
        // 父进程
        wait(NULL); // 等待子进程
        printf("Parent: My PID is %d\n", getpid());
        printf("Parent: My parent's PID (shell's PID) is %d\n", getppid());
    }
    return 0;
}

运行此程序,可以清晰地看到父子进程间的PID关系。父进程的父进程PID,通常就是启动它的Shell的PID。


上一节我们探讨了进程管理的核心系统调用。本节中我们来看看另一个重要类别:文件与目录操作。

文件与目录操作概述

在类Unix系统中,文件和目录被组织成树形结构,通过虚拟文件系统(VFS)进行管理。系统提供了丰富的系统调用来与文件和目录交互,包括打开、读写文件,以及遍历目录等。

基础文件操作:openreadlseekclose

以下是一个使用基础文件系统调用来读取文件内容的例子:

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

int main() {
    int fd = open("file_io.c", O_RDONLY); // 以只读方式打开文件
    if (fd < 0) {
        perror("open failed");
        return 1;
    }

    char c;
    read(fd, &c, 1); // 读取第一个字符
    printf("First char: %c\n", c);

    read(fd, &c, 1); // 读取下一个字符
    printf("Second char: %c\n", c);

    lseek(fd, -2, SEEK_END); // 将文件偏移量移动到末尾前2个字节
    read(fd, &c, 1); // 读取倒数第二个字符
    printf("Char before the last: %c\n", c);

    close(fd); // 关闭文件描述符
    return 0;
}

关键点

  • open 返回一个文件描述符,它是一个整数,是后续所有文件操作的句柄。
  • read 是顺序读取的。每次读取后,文件内部的“当前位置”指针会自动后移。
  • lseek 可以改变这个“当前位置”指针,实现随机访问(如例子中跳转到文件末尾)。
  • 操作完成后,必须使用 close 释放文件描述符。

理解文件描述符

文件描述符本质上是一个整数索引。每个进程都有一个打开文件表,文件描述符就是这个表的索引。当进程打开一个文件时,内核会在该表中找到一个空闲条目,将文件信息填入,并返回该条目的索引号给进程。

考虑以下代码:

int fd1 = open("file1.c", O_RDONLY);
int fd2 = open("file2.c", O_RDWR);
printf("fd1=%d, fd2=%d\n", fd1, fd2); // 可能输出 fd1=3, fd2=4

close(fd1); // 关闭文件描述符 3,释放该表条目

fd1 = open("another.c", O_RDONLY);
printf("New fd1=%d\n", fd1); // 很可能输出 New fd1=3

解释

  1. 前两个 open 调用可能返回 3 和 4,因为 0、1、2 通常被标准输入、输出、错误流占用。
  2. 关闭 fd1(值为3)后,索引3的条目变为空闲。
  3. 再次调用 open 时,内核会寻找最小的空闲索引,因此很可能再次使用3。

总结

本节课中我们一起学习了系统调用的第二部分。我们首先深入了解了进程管理相关的 execexitwait 调用,看到了它们如何与 fork 结合,构成了运行新程序(包括Shell工作原理)的基础。随后,我们转向了文件操作,学习了如何使用 openreadwritelseekclose 等系统调用来操作文件,并理解了文件描述符作为内核中打开文件表索引的核心概念。这些是操作系统为应用程序提供的基础且强大的服务。

004:系统调用 Part 3

在本节课中,我们将继续学习系统调用,重点探讨文件描述符、管道、信号和内存管理相关的系统调用。我们将了解如何通过系统调用实现进程间通信、信号处理以及动态内存分配。


文件描述符与标准流

上一节我们介绍了文件描述符的概念,它是一个指向进程打开文件表中某个条目的整数索引。本节中,我们来看看文件描述符0、1、2的特殊用途。

当进程启动时,前三个文件描述符(0、1、2)已被预分配,它们默认指向终端,分别称为:

  • 标准输入 (stdin):文件描述符 0,用于从用户获取输入(如 scanf)。
  • 标准输出 (stdout):文件描述符 1,用于向用户输出信息(如 printf)。
  • 标准错误 (stderr):文件描述符 2,用于输出错误信息。

我们可以通过多种方式向标准输出写入数据,以下是几种等效的方法:

write(STDOUT_FILENO, "hello ", 6); // 使用宏
write(1, "hello ", 6); // 直接使用文件描述符编号
fprintf(stdout, "hello "); // 使用格式化输出到 stdout
printf("hello "); // printf 默认输出到 stdout

同样,向标准错误写入数据可以这样做:

write(STDERR_FILENO, "world", 5);
fprintf(stderr, "world");

由于标准输入/输出/错误被当作文件处理,我们可以重定向它们。例如,在 shell 中运行程序时:

  • ./program > /dev/null 将标准输出重定向到空设备,不显示输出。
  • ./program 2> /dev/null 将标准错误重定向到空设备。
  • ./program | tr ‘h’ ‘j’ 使用管道将标准输出作为另一个程序 tr 的输入。
  • ./program > output.txt 2>&1 将标准输出和标准错误都重定向到同一个文件。

dup2 系统调用

dup2 系统调用允许复制一个文件描述符到另一个文件描述符编号,这常用于重定向标准流。

其函数原型为:

int dup2(int oldfd, int newfd);

dup2 会关闭 newfd 当前指向的文件(如果已打开),然后使其成为 oldfd 的一个副本,指向同一个文件。

以下是一个示例程序:

printf(“hello number 1\n”); // 输出到终端(stdout)
int fd = open(“myfile.txt”, O_WRONLY | O_CREAT, 0644);
dup2(fd, STDOUT_FILENO); // 将 stdout 重定向到文件
close(fd);
printf(“hello number 2\n”); // 输出到文件,而非终端

运行此程序,第一条 printf 会显示在终端,而执行 dup2 后,第二条 printf 的内容会被写入文件 myfile.txt

使用 dup2 而非先 close(1)open 的原因在于:

  1. 可以预先精心准备一个文件描述符,然后在最后时刻将其复制到目标位置。
  2. 可以方便地将同一个文件描述符复制到多个目标(例如同时重定向 stdoutstderr)。
  3. 在父子进程场景中(如项目一的 shell),父进程可以打开文件,子进程通过 dup2 进行重定向,操作分离更清晰。

文件信息与目录遍历

stat 系统调用用于获取文件的元数据信息。

其函数原型为:

int stat(const char *pathname, struct stat *statbuf);

struct stat 结构体包含了文件的类型、权限、大小、最后访问时间等信息。

以下程序可以判断命令行参数指定文件的类型:

struct stat sb;
if (stat(argv[1], &sb) == -1) { perror(“stat”); exit(1); }
// 判断文件类型
if (S_ISREG(sb.st_mode)) printf(“Regular file\n”);
else if (S_ISDIR(sb.st_mode)) printf(“Directory\n”);
// … 其他类型判断

对于目录操作,主要有以下几组系统调用:

  • 获取/更改当前工作目录getcwd, chdir
  • 读取目录内容opendir, readdir, closedir

以下程序演示了如何切换到用户指定的目录并列出其内容:

char cwd[PATH_MAX];
getcwd(cwd, sizeof(cwd)); // 获取当前目录
printf(“Current dir: %s\n”, cwd);
chdir(argv[1]); // 切换到参数指定的目录
DIR *dir = opendir(“.”); // 打开当前目录
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) { // 遍历目录项
    printf(“%s\n”, entry->d_name);
}
closedir(dir);

管道

管道是一种进程间通信机制,允许将一个进程的输出连接到另一个进程的输入,常用于组合简单的命令完成复杂任务。

例如,命令 du | sort -r | head -n 3 用于找出当前目录下占用空间最大的三个项目:

  1. du 列出各项目大小。
  2. sort -r 进行反向排序。
  3. head -n 3 取前三行。

在底层,管道是一个位于内核中的固定大小的缓冲区。当进程向已满的管道写入数据时会被阻塞,直到另一进程从管道中读取数据腾出空间。这种机制实现了进程间的隐式同步。

创建管道使用 pipe 系统调用:

int pipe(int pipefd[2]);

调用成功后,pipefd[0] 用于从管道读取,pipefd[1] 用于向管道写入。

一个简单的自通信示例如下:

int fd[2];
pipe(fd);
write(fd[1], “hello”, 6);
char buf[10];
read(fd[0], buf, 6);
printf(“%s\n”, buf); // 输出: hello

管道的强大之处在于连接不同进程。以下是一个创建父子进程并通过管道通信的框架:

int fd[2];
pipe(fd);
pid_t pid = fork();
if (pid > 0) { // 父进程
    close(fd[0]); // 关闭读端
    dup2(fd[1], STDOUT_FILENO); // 将 stdout 重定向到管道写端
    close(fd[1]);
    execlp(“ls”, “ls”, NULL); // 执行命令,输出进入管道
} else if (pid == 0) { // 子进程
    close(fd[1]); // 关闭写端
    dup2(fd[0], STDIN_FILENO); // 将 stdin 重定向到管道读端
    close(fd[0]);
    execlp(“wc”, “wc”, “-l”, NULL); // 执行命令,从管道读取输入
}

这个例子模拟了 ls | wc -l 命令的功能。Shell 实现管道时,需要 fork 两次来创建两个子进程并设置它们之间的管道,而 Shell 自身进程得以保留。


信号

信号是另一种进程间通信机制,用于通知进程发生了某个事件。例如:

  • SIGSEGV:段错误信号。
  • SIGINT:中断信号(通常由 Ctrl+C 触发)。

进程收到信号后的默认行为包括:终止进程、忽略信号、停止进程等。但进程可以改变大多数信号的处理方式:

  1. 忽略信号
  2. 捕获信号:为其注册一个信号处理函数。

与信号相关的主要系统调用有:

  • kill:向指定进程发送信号。
  • raise:向进程自身发送信号。
  • alarm:设置定时器,时间到后发送 SIGALRM 信号。
  • signal / sigaction:设置信号处理函数。
  • pause:挂起进程,直到收到一个信号。

需要注意的是,SIGKILLSIGSTOP 信号不能被捕获或忽略。

以下程序演示了忽略 SIGINT 和捕获 SIGALRM

signal(SIGINT, SIG_IGN); // 忽略 Ctrl+C 中断信号
signal(SIGALRM, handler); // 为 SIGALRM 设置处理函数
alarm(5); // 设置 5 秒定时器
pause(); // 挂起等待信号
raise(SIGKILL); // 向自己发送 SIGKILL 信号终止进程

内存管理:mallocfree

在 C 语言中,mallocfree 用于管理堆内存的动态分配与释放。

其工作原理是:mallocfree 管理的是操作系统已经分配给进程的内存区域(堆)。当 malloc 需要更多内存而进程的堆空间不足时,它会通过系统调用向操作系统请求更大的内存块。

历史上,扩展堆大小使用 brksbrk 系统调用。现代实现中,更常用的是 mmap 系统调用,它功能更强大,可以请求操作系统为进程映射新的内存区域。malloc 的内部实现会在需要时使用这些系统调用来获取大块内存,然后自己进行细粒度的分配管理。


本节课中我们一起学习了系统调用的核心部分。我们探讨了文件描述符的重定向、目录的遍历操作,深入理解了管道作为进程间通信通道的原理与实现,介绍了信号机制如何用于事件通知和处理,最后了解了 malloc/free 与操作系统内存管理的关系。掌握这些系统调用是进行系统级编程和深入理解操作系统行为的基础。

005:操作系统结构 🏗️

在本节课中,我们将要学习操作系统的软件架构,即其层次化结构。我们将从用户最熟悉的应用程序层开始,逐步深入到内核的核心部分,并探讨不同的内核设计模式。

概述

一个典型的计算系统由多个软件层次构成。最上层是用户直接交互的应用程序。应用程序通常依赖于各种库(如C标准库)。所有这些都运行在用户模式下。另一侧是操作系统的特权部分,称为内核。内核本身通常也分为至少两层:可移植层和机器依赖层。所有这些代码最终都运行在由CPU和各种设备组成的硬件之上。今天,我们将逐一审视这些层次。

应用程序层

应用程序通常是你编写的代码,例如C语言中的main函数,它执行多条语句并调用多个函数。应用程序就是你编译生成的可执行文件。

大多数情况下,编写应用程序时需要使用库。例如在C语言中,如果你想打印内容或进行数学计算,就需要使用库,因为你不会自己去重新实现printf这样的函数。使用printf需要包含stdio.h,使用malloc需要包含stdlib.h,进行数学计算则需要包含math.h

这些库和头文件只提供了库的API,即库所提供函数的列表。你可以像调用自己编写的函数一样调用它们。

这些库的代码通常以对象文件的形式直接提供,你通常不会直接处理其C源代码,而是使用已经编译好的版本。这个编译好的版本以对象文件的形式安装在计算机中,由编译器负责在你的代码(例如调用printf)和包含printf编译代码的库之间建立连接。这就是GCC所做的工作。

例如,当你在代码中包含math.h并使用一些数学函数时,你需要将你的代码与libm.so库链接。你必须告诉GCC你使用了数学库,以便建立链接。实际上,当你使用C标准库(如printf)时,从技术上讲也应该告诉GCC需要链接C库。但由于几乎所有应用程序都这样做,所以它是默认隐含的,你通常不需要特别指定。不过,如果你愿意,也可以在命令行中指定。

编译器提供了两种主要的工作方式。你可以告诉编译器将printf的代码真正包含在你的可执行文件中,这称为静态编译。或者,你可以进行动态编译,即告诉GCC你使用了C库,但不要把代码放进你的可执行文件,只需注明运行此代码需要该库。这意味着当应用程序最终被加载运行为进程时,我们也需要加载libc,这样应用程序才能完全工作。

编译工具链与库

让我们更深入地了解编译过程和库(静态库与动态库)。

编译工具链的工作流程如下:在一个C项目中,你通常从一个或多个C文件开始。你调用GCC,而GCC实际上由多个内部工具组成,它们几乎是相互独立的。这些工具包括:

  • CPP:预处理器。
  • CC:实际的编译器。
  • AS:汇编器。
  • LD:链接器。

这个过程最终会生成一个二进制目标文件(.o文件),最后阶段是将所有目标文件与你可能使用的库组合起来,由链接器将所有内容整合成一个最终的可执行文件。

如果我们放大这个过程:

  1. 从C文件(.c)开始,交给预处理器CPP。
  2. CPP解析所有的#include指令(包含头文件)和#define宏定义,生成一个自包含的文件。GCC内部会生成一个扩展的C代码文件(.i),这个文件包含了所有必需的代码。
  3. 这个.i文件(仍然是C代码)被交给实际的编译器CC。
  4. CC将C代码编译成人类可读的汇编代码(.s文件)。
  5. 汇编器AS接收这个.s文件,将人类可读的汇编指令转换成处理器可以理解的二进制等价物,输出目标文件(.o)。
  6. 所有.o文件的组合由链接器LD处理,生成最终的可执行文件。

如果你好奇,可以在运行GCC时使用-save-temps选项来访问这些中间文件。你应该尝试自己写一个简单的printf("Hello World")程序,用这个选项编译,然后在同一目录下查看生成的.i.s文件,观察编译的不同阶段,这非常有趣。

可执行文件与进程内存

最终,我们得到了一个可执行文件(这里通用地称为a.out)。那么,如果你想实际执行这个可执行文件并将其变为一个进程,会发生什么?

可执行文件本身只包含两样东西:

  1. 代码段:程序的指令序列。
  2. 数据段:全局变量。

这是可执行文件包含的全部内容。当你运行一个可执行文件并将其变为进程时,数据段和代码段直接从可执行文件中加载。

另外两个内存段————并不由可执行文件提供。这些内存段是在运行时,进程启动的时刻动态创建的。它们开始时完全是空的,并随着进程的执行而逐渐填充。堆会随着进程调用malloc等函数放入数据而增大;栈也是类似的,它从空开始,随着进程运行、创建栈帧、放入局部变量、调用函数等而增长。这两个是仅在运行时创建的动态段,不存在于可执行文件中。

你可能会问,为什么要把代码和数据分成两个不同的段?为什么不合并成一个段?另一个问题是,为什么堆和栈要分置两端,一个向高地址增长,一个向低地址增长?

对于第一个问题,我们稍后在讨论实际内存管理时会详细说明。目前的理解是,将它们分开是因为我们可能希望对它们进行不同的处理,例如赋予不同的权限。代码通常没有理由被应用程序自身修改,因此我们可能希望保护代码段,将其设置为只读,防止应用程序在运行时改变自己的代码。这可以防止潜在的攻击者控制应用程序并使其执行其他操作。而数据段显然因为包含变量,需要可读可写。

对于第二个问题,堆和栈分置两端并相向增长的原因是为了最大化空间利用率。如果你有一个房间,有两个可以增长的实体,最大化空间使用的最佳方式是将它们放在房间的两端,这样它们可以向中间增长。只有当它们最终相遇时,才意味着房间满了。如果我们把栈放在某个中间位置,并让它也向高地址增长,那么堆在增长到栈下方时就会被阻塞,即使栈很小,没有使用所有空间,也会造成空间浪费。通过让它们相向增长,可以确保只有当它们相遇时,才意味着内存真的用完了,从而实现了内存的良好利用。

加载器与动态依赖

在一个简单的世界里,一个可执行文件可以直接加载到内存中:加载代码、加载数据、创建堆、创建栈,然后跳转到代码段的第一条指令开始执行,应用程序就运行起来了。

然而,我们之前提到,应用程序可能需要一些库才能运行,这带来了一些复杂性。为了确保内核的工作保持相对简单(因为内核负责将应用程序加载到内存并运行为进程),工程师们发明了一种非常巧妙的方法,即使用加载器

设想我们有一个应用程序a.out,它使用了libm库。a.out被编译为动态应用程序,因此它实际上不包含libm的代码。我们可以使用ldd工具来检查一个可执行文件,它会列出该应用程序所依赖的库。应用程序本身不包含这些代码,只是声明:“为了工作,我需要这个库。”

因此,我们不能仅仅加载a.out就跳转执行,因为那还不够。我们需要加载a.out,还需要加载libm,并将它们连接起来,然后才能运行进程。我们需要加载应用程序及其使用的库。

问题是,让内核来处理这些依赖关系可能过于复杂或繁重,因为如果计算机有不同版本的库等情况,可能会变得相当复杂。因此,内核决定将这项工作交给其他程序,这个“其他程序”就是加载器

通常,我们看到a.out可执行文件也依赖于一个名为ld-linux-...的加载器。当你调用exec时(例如在shell中运行a.out,shell会fork自身并exec a.outexec是一个系统调用),最终内核会收到运行a.out的请求。内核会打开a.out,发现它实际上依赖于一个加载器。于是,内核不会直接加载a.out,而是加载这个加载器。加载器是一个非常简单的、完全自包含的程序,它本身不依赖其他库,因此极其容易加载。然后,内核将这个加载器作为用户进程加载到内存中,并跳转到该加载器。

接下来,就由加载器负责从a.out加载应用程序,并加载所有列为依赖项的其他库。这个过程可能相当复杂,但这没关系,因为加载器是一个用户进程。这样,所有的复杂性都被封装在这个应用程序中,而不是在内核里,从而避免了可能难以处理的大问题。

静态库、动态库与动态加载

让我再举一些关于库的例子,以及静态库和动态库的区别。

假设我们有一个非常简单的例子,只有一行代码的main函数,我们调用了printf(这意味着需要访问libc),并且还通过调用cosine函数使用了数学库。

编译这个应用程序的典型方式是使用GCC编译main.c文件。因为你使用了数学库,而数学库默认不包含,你必须明确指定链接数学库。GCC会完成编译,但只会将你的代码编译进a.out,并写下依赖项:为了这个应用程序工作,需要加载libm(数学库)和libc。为了做到这一点,还需要加载加载器。当我们运行这个程序时,就会发生前面描述的过程:内核只加载加载器,然后由加载器负责加载实际的可执行文件、libmlibc,并连接所有人,最后应用程序才能完全运行,执行printfcosine等操作。这是动态链接的方式,也是当今默认的工作方式。

还有另一种方式,称为静态链接。唯一的区别是,我们调用GCC时,告诉它使用数学库的静态版本。在这里,GCC不会说“我们需要libm才能工作”,而是会进入这个存档文件(静态库),真正获取与cosine对应的代码,并将其放入可执行文件本身。现在,可执行文件不再依赖libm,因为它已经从包含所有代码的存档中取出了所需的代码并放入了自身。运行ldd查看这个可执行文件时,libm不会出现,因为我们不再依赖它。代码运行效果是一样的。

最后一种处理库的方式称为动态加载库。我知道这有很多“动态”的概念(动态链接、动态加载库),听起来可能有点混淆,但它们实际上是不同的概念,请确保回到幻灯片理解每个“动态”指的是什么。

这里的想法很有趣:应用程序本身将通过执行代码来打开它需要的库。不是通过包含math.h然后直接使用cosine函数,而是通过代码打开库本身,获取与cosine函数对应的符号,然后运行代码。为了做到这一点,我们需要使用另一个名为dl的库,它也有头文件和API函数,也是一个需要应用程序连接的库。但在这里,你会看到应用程序在技术上使用了libm数学库,但它没有作为依赖项出现。应用程序没有说“我需要这个东西存在,我需要在我加载时加载这个东西”。因为这将在运行时由应用程序自身完成。

这实际上是应用程序实现插件的方式。插件的特点是,即使某些插件没有安装,应用程序也应该能够工作。只有当你安装它们后,应用程序才能说“我看到那个目录里有一个插件,你想让我加载它吗?”,如果你同意,插件就会被动态加载。突然间,你的应用程序就获得了以前没有的新功能。其工作原理就是应用程序自身能够动态地打开代码片段。如果库没有安装或找不到,dlopen会返回NULL,应用程序可以处理这个事件而不会崩溃。

回到前面的例子,在常规的动态链接方式中,如果你编译并链接了数学库,但数学库在计算机上不可访问,那么应用程序将无法被加载。

内核的可移植层与机器依赖层

现在,让我们回到不同的层次。我们已经讨论了应用程序层和库层,现在跳到内核侧,讨论那两个层次。

让我先给你一个小例子。我面前有一台运行Linux的笔记本电脑。我这里还有一部Android手机。正如你所知,Android也运行Linux。这意味着我的笔记本电脑和手机运行着相同的内核。尽管它们是相当不同的硬件设备,比如笔记本电脑和手机,它们有屏幕,可以交互,都是计算机,但它们有根本的不同。

在Linux中,有趣的是,Linux是一个庞大的代码体,但有些代码块没有理由在我的笔记本和手机之间不同,而有些代码块则完全有理由不同,因为它们是两种非常不同的设备。这就是为什么我们在内核内部有这两个不同的层。

可移植操作系统层 是那些在不同设备之间几乎不会改变的代码层。例如,虚拟文件系统——我的笔记本电脑看待文件的方式与我的手机看待手机上文件的方式是相同的。进程间通信(IPC)——一个进程通过发送信号或管道与另一个进程通信的方式,在笔记本和手机上完全相同。运行多个进程并进行调度的方式、虚拟内存、网络、声音处理方式等等,都是完全相同的。因此,即使设备不同,也没有理由拥有两套不同的代码。

显然,我们必须承认设备是不同的,它们有不同的硬件。因此,也有很多代码必须非常特定于设备。例如,我的笔记本电脑有一个x86处理器,所以它需要能在该处理器上运行的特定代码。而我的手机有一个ARM处理器(几乎地球上所有手机都是),所以我们需要知道如何处理ARM处理器的代码。这就是机器依赖层的内容,它是所有必须与硬件完全匹配的代码。这意味着计算机的启动方式、初始化方式、围绕异常的所有逻辑、设备驱动程序(显然,驱动程序必须匹配硬件上的实际设备)、内存管理的底层等等,以及一切与处理器相关的内容。

层间接口

这些层之间的接口实际上有各自的名称。让我们快速了解一下:

  • API:我相信你听说过它,这个名称在很多不同情境下常被滥用。在我看来,最贴切的应用方式是,API描述了一个库向应用程序提供什么。你有一个应用程序,想使用C库,C库有一个API,即它提供的函数集合。应用程序使用库提供的API。
  • 内核也有自己的API供进程使用,即内核可以代表进程执行的系统调用列表。虽然没有约定俗成的名称,但在Linux世界中,它通常被称为UAPI(用户API),我认为这是一个很好的名字。
  • 内核中可移植层和机器依赖层之间的接口通常称为HAL。这里的想法是,可移植层(如前所述,在我的笔记本和手机上是相同的)经常需要调用由机器依赖层提供的函数。这些函数的实现在手机和笔记本上会不同,但函数必须具有相同的签名(原型),只是实现因设备而异。因此,机器独立层向可移植层提供了一种API,这就是所谓的硬件抽象层。
  • 软件和硬件之间也有一种接口,实际上分为两部分:ISA。你在ECS 50或150B课程中肯定听说过,ISA是处理器理解的指令列表,有点像处理器的字典。因此,ISA在x86和ARM上是不同的。
  • 最后,还有另一个接口称为ABI。这是处理器告诉应用程序的一种方式:“这是你应该定义栈帧的方式。这是函数调用另一个函数时传递参数的约定。”这实际上是汇编级别的约定。

内核结构

现在我们已经理解了典型层次是如何架构的,让我们谈谈内核结构,因为正如你将看到的,内核并不总是这两层的叠加,它可以有其他结构。

实际上,第一种结构类型就是我们刚刚介绍的,称为单体内核。单体内核的想法是,内核显然由大量代码组成,并且全部编译在一起,形成一个单一的可执行文件。这就是加载到内存中的代码块,负责响应用户进程(应用程序)的所有系统调用。Linux、传统的Unix或BSD系统等通常就是这种类型。

是单体内核并不一定意味着它全是静态的。例如,在Linux中,可以有动态模块,这样你可以只将一个微小的内核加载到内存中,然后在计算机运行时根据需要添加功能(例如,如果需要尚未加载的驱动程序)。当一个新模块被加载时,它被加载到内核内部,并建立连接,几乎就像代码一直都在那里一样。

这种方法的最大优点是性能。因为所有这些内核代码都在一个可执行文件中,当一个函数调用另一个函数时,就像在应用程序中一样,只是跳转到内存中的另一个位置,执行指令,然后返回调用者。所以性能很好,因为没有开销。然而,这也意味着如果一个函数失败,整个内核都会失败,就像你写一个应用程序时,如果其中一个函数有bug导致段错误,整个应用程序就会崩溃一样。因此,它有点容易因故障而崩溃,因为任何故障都会导致整个系统宕机。

另一种根本不同的结构类型称为微内核。这里的想法是,内核部分、内核可执行文件只包含最小必要的内容。最小必要内容包括:

  1. IPC:应用程序通信的方式(如管道、共享内存、信号等)。
  2. 调度器:微内核可以调度不同的应用程序。
  3. 内存管理

其他东西,比如文件系统、设备驱动程序、网络等等,不需要成为内核的一部分。它们可以作为外部服务在内核之外运行,实际上是在用户进程级别。这样做的最大原因是故障隔离。如果现在这些服务中的一个出现故障(例如,一个设备驱动程序有bug并导致段错误),整个计算机不会宕机,因为内核仍在工作,而且刚刚崩溃的服务实际上是一个用户应用程序,它可以被重启。通过将这些服务导出到用户级别,提供了很好的故障隔离。

当然,这并非全是优点。问题是,现在当应用程序想要打开一个文件时,应用程序会调用open系统调用进入微内核,微内核会说:“哦,这是文件系统服务的事情。”于是微内核将请求转发给FS服务,FS服务处理它并创建文件描述符等,然后返回给微内核,微内核再返回给应用程序。现在你会看到,我们需要在用户和内核之间跨越边界四次,并在不同的用户进程之间传递消息,这需要一些时间。而在单体内核中,因为所有服务都在内核级别实现,应用程序可以进行系统调用,内核处理,然后返回,所以我们只有两次用户和内核之间的切换,这显然快得多。

这种微内核结构最早由非常流行的学术操作系统Minix实现之一,它实际上对Linus Torvalds创建Linux时产生了启发。另一个使用微内核结构的流行内核是Mach,它是现代Mac OS所用内核的灵感来源。一个非常流行的、当前使用微内核结构的操作系统实现是L4。你可能从未听说过这个名字,但事实证明L4非常流行,例如,它运行在所有高通的无线芯片中(用于Wi-Fi等)。截至2013年左右(甚至更早),高通已经售出了15亿颗这样的芯片,这意味着实际上有大量的L4实例运行在笔记本电脑和手机中。这不是用户必须直接交互的软件部分,它隐藏在机器深处,但仍然存在,并在许多设备上运行。

最后一种结构实际上相当有趣,因为它试图结合单体内核和微内核的优点,称为混合内核。这里的想法是,所有我们信任的、并且确实需要高性能的服务,都是内核本身的一部分(这里我们借鉴了单体内核,试图获得最佳性能)。而所有我们不信任的服务,都作为用户进程运行。因此,如果它们失败、有bug并崩溃,不会导致整个系统停止。这基本上是现代Windows和Mac所使用的方式。这样,当你有一台计算机并下载第三方设备驱动程序(例如,未经任何人严格验证的驱动程序)时,它们作为用户进程运行不会使你的计算机崩溃。

总结

本节课中,我们一起学习了操作系统的层次化结构。我们从用户空间的应用程序和库开始,了解了编译过程、静态与动态链接的区别,以及加载器的作用。然后,我们深入内核,探讨了可移植层与机器依赖层的划分,以及它们之间的接口(API、UAPI、HAL、ISA、ABI)。最后,我们分析了三种主要的内核结构:单体内核、微内核和混合内核,了解了它们各自的优缺点和设计哲学。通过理解这些架构概念,我们为后续深入学习内核的具体抽象和机制打下了坚实的基础。

操作系统与系统编程:6:内核抽象(第一部分)

在本节课中,我们将开始深入探讨内核接口,即进程与内核之间的交互是如何在硬件层面实际工作和实现的。我们将从进程的定义开始,逐步引入保护机制的概念,并解释硬件如何支持这种机制,特别是通过双模式操作来实现。


进程与程序的关系

上一节我们介绍了进程与内核交互的API。本节中,我们来看看进程的确切定义。

一个进程是一个正在执行的程序,或者说,是程序的一个实例。这意味着,从程序员的角度看,你开发的源代码和编译后的可执行文件都是存储在磁盘上的文件,我们称之为程序。它就像一本食谱,记录了步骤但尚未开始烹饪。

当你运行这个可执行文件时,程序就被实例化,成为了一个进程。此时,它被加载到内存中,由处理器执行,变得“活跃”起来。

从可执行文件中,我们得到两个部分:

  • 文本段:指令序列。
  • 数据段:已初始化的全局变量。

这两个部分在进程执行时被加载到内存。此外,进程执行还需要另外两个内存段:

  • :用于动态内存分配。
  • :用于局部变量和函数调用机制。

堆和栈并非来自可执行文件,而是在进程首次加载和运行时由内核创建的。

加载完成后,内核会设置处理器的栈指针指向栈的起始位置(假设栈向下增长),并将程序计数器设置为代码段的第一条指令。至此,处理器才开始真正执行该进程。

需要注意的是,操作系统内核本身也是一种特殊的进程。它同样拥有指令、全局变量、堆和栈。内核很可能用C语言编写,因此也需要栈来支持函数调用和局部变量。从这个角度看,内核与你已知的应用程序非常相似。

进程与程序之间并非简单的一一对应关系。在某些方面,进程比程序“大”,因为它包含了程序本身(指令和全局变量)以及运行时环境(堆、栈、寄存器状态)。这就像开始烹饪后,你拥有的东西(食材、厨具、进行中的步骤)比食谱本身更多。

此外,同一个程序可以对应多个进程。例如,你可以同时运行多个Chrome浏览器窗口,每个窗口都是一个独立的进程,但都执行着相同的“Chrome程序”。

程序也可能比进程“大”。例如,一个程序中如果包含 fork() 系统调用,就可以从同一个程序派生出多个进程。

理解进程与程序之间的这种抽象关系非常重要。


保护机制的必要性

现在我们来讨论一个非常重要的概念:保护。

想象一个场景,内存中同时加载了多个进程以及操作系统内核。这样做的目的是让CPU总有任务可执行,以最大化CPU利用率。CPU可以执行一点这个进程,再执行一点那个进程,偶尔运行内核以确保一切正常。

问题是,如果这些不同的进程(包括内核)只是简单地同时加载在内存中,就可能出现问题。假设这里有一个有缺陷的C程序(比如你为P1编写的、充满bug的shell)。如果没有任何保护措施,这个程序的一条错误指令可能会访问不属于它自己的内存,例如修改了内核数据段的一个值,这可能导致内核崩溃。

它也可能修改其他进程(比如防病毒软件)的栈值,导致其崩溃。更糟糕的是,如果程序陷入一个无限循环,永远不交还CPU,那么这个有缺陷的程序就会一直占用CPU,导致你的电脑完全卡死。

如果这个进程来自一个你从网上下载的、并非有缺陷而是故意恶意的程序,情况会更糟。它可能试图尽其所能地破坏你的计算机。

总而言之,我们需要一种系统来为进程设定边界或限制。这样,一个有缺陷的C程序就会被限制在自己的“车道”内,无法越界去破坏操作系统或其他进程。因此,我们需要重新审视对进程的理解,并加入边界、限制和保护的概念。

那么,我们如何做到这一点呢?


受限制的进程

让我们回到进程的定义:进程是正在执行的程序。现在,我们需要添加一个非常重要的点:当一个进程运行时,它是在有限权限下运行的

这意味着,我们本质上希望在进程周围放置一个“盒子”。在这个盒子内部,进程可以自由地做任何它想做的事情。但这个盒子非常坚固,使得进程无法越界去干扰另一个进程,也无法去干扰内核。

这主要意味着我们需要保护进程可以访问的内存。这也意味着进程不能直接访问硬盘或执行某些特权操作。例如,关于硬盘访问,内核会根据进程所属的用户(通过用户ID)或组(通过组ID)来授予其访问文件系统中特定文件的权限,而不是其他文件。

这里的难点在于,我们仍然希望保持高效。我们希望在进程周围放置这个盒子以限制其权限,但如果盒子过于坚固,我们很可能会在灵活性、功能性和性能方面损失很多。我们不希望这样,因为我们希望计算机尽可能快,希望进程能充分利用硬件。

我们还必须确保这个盒子,尽管很安全,但不会阻止进程之间以某种方式通信,也不会阻止进程与内核通信以请求操作。

那么,我们如何在技术上实现这种灵活的保护呢?


实现保护的两种方法

有两种方法可以实现这种灵活的保护。

第一种方法有点天真,但效果很好:不允许应用程序(进程)直接在硬件上运行。这就像你不让孩子开车,而是由你来驾驶。在这种模型下,你充当了一个解释器。

应用程序(孩子)仍然通过给出指令(方向)来参与,但每一个指令(在计算机中就是每一条指令)都需要由解释器(你)来审查、验证并执行或模拟。如果应用程序试图做任何未经授权的事情,解释器可以在这里阻止并终止它。

这种模型的问题在于,由于应用程序不能直接在硬件上运行,它所做的每一件事都必须由解释器模拟,因此速度非常慢。这就是解释型语言(如JavaScript或Python)所采用的模型。

我们将要详细讨论的是第二种方法。这里的想法是:应用程序应该能够直接在硬件上运行,因为这样才能达到真正的速度。应用程序和硬件之间没有翻译层,应用程序可以以原生速度运行。

然而,我们如何防止应用程序做未经授权的事情(比如“撞车”)呢?为了实现这一点,我们需要硬件的帮助。


双模式操作

本质上,如果你不是自己开车并审查孩子的指令,而是想把车交给孩子,理想情况下你希望车本身具有一些自我保护功能。这样,如果你的孩子试图在高速公路上掉头,汽车自己会说:“我不会那样做,因为你是孩子,我知道那不行。”

这在技术上是如何实现的呢?支持非特权应用程序直接在硬件上运行的概念被称为双模式操作

其核心思想是:硬件直接支持具有不同特权级别的执行模式。在内部,这可以很简单,例如处理器中的一个状态位,可以是0或1。如果它是0,处理器运行在所谓的内核模式。在这种模式下,处理器执行所有指令,没有任何保护。内核模式显然是内核执行的地方,内核是可信的,确切地知道该做什么。

当处理器运行在用户模式时(假设该状态位为1),处理器知道:“好的,我现在正在执行非特权代码。只要应用程序做的事情保持在它的‘车道’内,就没问题。但如果应用程序试图做危险的事情,我就会拒绝执行并停止。”

在大多数实际的处理器中,通常不止两种模式。例如,在MIPS处理器上,有两个比特位,这意味着有四种不同的模式(尽管只定义和使用了其中三种):内核模式、监管者模式和用户模式。在x86架构上,有四个特权级别,称为保护环(ring)。内核通常运行在ring 0(最高特权),用户进程运行在ring 3(最低特权)。ring 1和ring 2的意图是供设备驱动程序使用,但在实践中(如Linux和Windows),它们很少被使用。

无论如何,核心概念是处理器可以在两种不同的模式下运行:内核模式和用户模式。一种模式对硬件拥有完全权限,可以访问任何内存位置和设备;另一种模式则受到硬件限制,确保代码在定义的边界内运行。


硬件支持的四个方面

这种双模式操作需要硬件支持。仅仅有一个状态位告诉处理器当前处于用户模式还是内核模式是不够的,还需要更全面的硬件支持。具体体现在以下四个方面:

  1. 特权指令:处理器可以执行的所有指令中,有些指令可能比较“危险”。你不希望用户进程能够执行这些指令。例如,让处理器进入睡眠状态的指令。这个指令应该是一个特权指令,只有计算机上可信的软件层(内核)才能执行。
  2. 内存保护:我们之前已经提到过。本质上,当用户进程执行时,它应该只能访问属于它的内存段(代码、数据、堆和栈),而不能访问其他任何内存。这也需要硬件支持来确保。
  3. 定时器中断:当一个进程在CPU上运行时,它控制着CPU。一个进程可能有缺陷,或者有大量计算要做,可能会长时间甚至无限期地占用CPU。在这种情况下,内核需要一种方法,即使进程不愿意主动释放CPU控制权,也能重新获得CPU控制权。定时器中断将帮助实现这一点。
  4. 模式切换:如果我们有两种执行模式(内核模式和用户模式),就需要一种安全高效的方式在这两种模式之间切换。我们将讨论如何实现这一点。

在深入探讨这四个方面之前,我们先看看双模式操作在一个典型的五级流水线处理器中是如何实现的。


双模式操作在流水线中的实现

对于上过150B课程的同学,这可能是一张熟悉的图。如果没上过也不用担心,不要求完全理解,但快速了解其工作原理有助于理解双模式操作是如何在这种典型的五级流水线上实现的。

处理器通过流水线执行指令,就像汽车在装配线上制造一样。指令执行分为五个阶段:

  1. 取指:根据程序计数器的地址从内存中获取下一条要执行的指令。
  2. 译码:解析获取到的指令,确定其操作类型(如加法、减法、加载、存储等),并读取所需的寄存器值。
  3. 执行:执行指令的核心操作(如ALU运算、计算内存地址等)。
  4. 内存访问:仅对内存指令有效,执行实际的内存读取或写入操作。
  5. 写回:将指令执行的结果写回目标寄存器。

现在,考虑如何在这个流水线中实现双模式操作,即如何让相同的硬件能够区分当前是在执行内核代码还是用户模式(进程)代码。

这实际上并不太复杂。处理器中有一个状态寄存器,其中包含一个模式位(例如,0表示内核模式,1表示用户模式)。这个位在各个阶段都是已知的,以便我们可以相应地限制或允许执行。

  • 在取指阶段,模式位确保在用户模式下,程序计数器指向的地址必须属于进程被允许访问的代码段。
  • 在译码阶段,模式位确保从内存获取的指令是合法的,即用户进程有权执行该指令。如果用户进程包含特权指令(如让CPU睡眠的指令),处理器会在这里捕获并阻止。
  • 在执行和内存访问阶段,对于内存指令,模式位确保进程访问的内存地址属于它自己。
  • 在写回阶段,必须提供一种方式来改变这个模式位,以便进行模式切换。
  • 在任何阶段,如果用户进程试图执行它无权执行的操作,处理器必须停止执行该进程,并转而执行属于内核的异常处理程序,以便内核能够接管处理器并处理错误。

总结

本节课中,我们一起学习了内核抽象的第一部分。我们从进程与程序的关系入手,探讨了为何需要保护机制来隔离进程和内核。我们介绍了实现保护的两种思路,并重点讲解了现代操作系统依赖的双模式操作概念,即硬件提供内核模式和用户模式两种特权级别。我们还简要概述了支持双模式操作所需的四个关键硬件机制:特权指令、内存保护、定时器中断和模式切换。最后,我们了解了这些概念如何在处理器流水线中实现。下一节课,我们将更深入地探讨这四个方面的具体细节。

007:内核抽象(第二部分)

概述

在本节课中,我们将继续学习内核抽象,重点探讨硬件如何支持用户模式与内核模式的隔离。我们将详细讲解特权指令、内存保护、定时器中断以及模式切换这四大核心硬件支持机制。

上一节我们重新定义了进程的概念,强调了受保护执行的重要性。本节中,我们来看看实现这种保护的具体硬件机制。

特权指令

处理器能够执行多种指令,这构成了它的指令集架构。在指令集中,必须区分用户进程可以安全使用的指令和仅保留给内核使用的指令。后者被称为特权指令。如果用户进程试图让处理器执行特权指令,处理器会拒绝并引发异常,通知内核处理。

以下是特权指令的一个例子:

CLI ; 清除中断标志(X86指令)

这条指令会禁用硬件中断。如果用户进程能执行它,就可以让处理器无限期地运行,这是不被允许的。在X86处理器上,CLI是一条特权指令,用户进程尝试执行它会导致异常,内核通常会终止该进程。

其他特权指令的例子包括:尝试改变处理器模式(切换到内核模式)、修改自身内存保护设置以访问不属于它的内存、刷新处理器缓存或TLB等。本质上,所有应仅由内核执行的操作都是受保护的操作。

在MIPS 32处理器的指令手册中,有一个专门的表格列出了所有特权指令,数量大约在10到15条左右,例如禁用中断的DI指令、TLB管理指令以及让处理器进入低功耗状态的WAIT指令。

内存保护

我们希望进程只能访问内核允许其访问的内存,即包含该进程代码、数据、堆和栈的内存。硬件需要确保进程停留在这些内存边界内。如果进程试图越界访问,硬件应捕获此行为并引发异常,以便内核处理(通常是终止该进程)。

内存保护是一个深入的话题,我们将在课程后期详细讨论。这里先简要介绍两种思路:一种简单但低效,另一种更先进高效。

基础思路(基址-界限寄存器)
如果进程的内存是连续的一块,硬件可以通过检查每个由进程发出的物理内存地址是否位于一个基址和一个界限之间来实现保护。这需要一些硬件逻辑,由内核在运行进程前初始化基址和界限寄存器的值。每次进程访问内存时,硬件都会进行越界检查。这种方法效率不高且不灵活,例如难以扩展堆栈、难以让进程共享内存、容易导致内存碎片化。

现代方法(虚拟内存)
当前更高效的内存保护方式是虚拟内存。其核心思想是让进程运行在虚拟地址空间中,给它们一种可以访问全部内存的假象。实际上,进程发出的虚拟地址会在运行时通过硬件(MMU)翻译成物理地址。这种翻译机制提供了极大的灵活性:不同进程中相同的虚拟地址可以映射到不同的物理地址(便于运行同一程序的多个实例),而不同进程中的不同虚拟地址也可以映射到相同的物理地址(便于进程间共享内存)。虚拟内存是我们将在几周后深入探讨的重点。

定时器中断

要理解为什么需要定时器中断,让我们回顾一下计算机启动的过程。计算机启动时处于内核模式,执行BIOS代码,然后加载并启动操作系统内核。内核完成初始化后,会创建第一个用户进程(在Unix中是init)并将处理器切换到用户模式来执行它。

此时,内核就像把处理器的“钥匙”交给了用户进程。为了确保内核能重新夺回控制权,防止一个进程独占处理器,就需要定时器中断。定时器是一个硬件设备,会定期发送一个中断信号(电脉冲)给处理器。处理器收到后,会暂停当前执行的用户进程,切换到内核模式,让内核得以运行。

内核可以利用这个机会进行调度决策,例如继续让当前进程运行,或者切换到另一个进程以实现公平的CPU时间共享。此外,频繁的定时器中断(称为“滴答”)也帮助系统维持高精度的时间计数。

两次滴答之间的时间间隔常被称为一个“时间片”或“节拍”。在Linux桌面系统上,滴答频率通常是250Hz,即每4毫秒发生一次中断。在高性能服务器上,频率可能达到1000Hz,即每1毫秒一次,以提供更好的响应性。需要注意的是,内核处理中断的代码(图中黄色部分)应尽可能高效短小,以最大化用户进程的运行时间。

模式切换

既然处理器有两种执行模式,我们需要理解如何在它们之间安全、高效地切换。首先探讨切换的原因,然后讨论切换的具体细节。

从用户模式切换到内核模式的原因
有三种情况会导致从用户模式切换到内核模式:

  1. 异常:由进程执行中的错误或非法操作引发,是同步事件。例如:
    • 执行特权指令(如之前的CLI例子)。
    • 访问非法内存地址(如下面的代码试图解引用空指针)。
    int *p = NULL;
    *p = 5; // 这将触发异常
    
  2. 硬件中断:由I/O设备(如定时器、磁盘)异步触发,与处理器当前执行的指令无关。例如,磁盘完成读写操作后发送中断通知内核。中断机制比轮询高效得多,它允许处理器在I/O操作进行时执行其他任务。
  3. 系统调用:进程主动通过执行特殊的指令(如syscall)请求内核服务,是同步且有意为之的事件。

术语注意:不同资料可能对异常、中断和系统调用的归类有所不同,阅读时需注意上下文定义。

从内核模式切换回用户模式的原因
有四种情况内核会切换回用户模式:

  1. 恢复原进程:在处理完异常、中断或系统调用后,内核决定让被中断的同一个进程继续执行。
  2. 切换到另一进程:内核决定进行上下文切换,将处理器分配给另一个就绪的进程。
  3. 启动新进程:内核首次运行一个新创建的进程。
  4. 执行信号处理程序:如果进程收到了信号并且定义了处理程序,内核会在返回用户模式时先执行该信号处理程序,然后再恢复进程的正常执行。

关于“启动新进程”有一个巧妙的设计:内核希望用同一套“恢复进程”的代码逻辑来处理“恢复已运行进程”和“启动新进程”两种情况。为此,内核会为新进程精心构造一个“假的”过去执行状态(上下文),使得“恢复”操作实际上能让进程从起点开始执行。这体现了内核设计的简洁性。

总结

本节课我们一起学习了实现内核抽象的四大硬件支持机制。我们了解了特权指令如何限制用户进程的权限,内存保护(特别是虚拟内存的概念)如何隔离进程地址空间,定时器中断如何确保内核能定期接管处理器以实现调度和系统维护,最后我们分析了在用户模式和内核模式之间切换的各种原因。下一节课,我们将深入探讨模式切换的具体硬件实现细节。

008:内核抽象(第三部分) 🧠

在本节课中,我们将要学习内核抽象的最后一个核心部分:模式切换的详细机制。我们将探讨处理器如何在用户模式和内核模式之间安全、高效地切换,并理解其背后的关键概念。

上一讲我们介绍了模式切换的几种原因。本节中,我们来看看如何实现这种切换。

原子控制权转移 ⚛️

当处理器需要从用户模式切换到内核模式时,必须确保这个切换过程是原子的。这意味着,从切换开始到结束,整个过程必须作为一个不可分割的步骤完成,不能被中断。

以下是切换过程中需要完成的关键步骤:

  • 保存原因:处理器需要记录切换的原因(例如,是异常、中断还是系统调用)。
  • 保存程序计数器:保存当前正在执行的用户进程指令地址,以便后续恢复。
  • 更新程序计数器:将程序计数器指向内核的入口点。
  • 切换处理器模式:将处理器内部状态从用户模式改为内核模式,这通常会改变内存保护设置。
  • 禁用中断:进入内核后,处理器会自动禁用中断,防止内核自身被中断,确保内核执行的稳定性。

当从内核模式切换回用户模式时,过程则相反:

  • 恢复程序计数器:将其恢复到进程被中断时的指令地址。
  • 切换处理器模式:从内核模式切换回用户模式,恢复进程的内存保护。
  • 恢复中断:重新启用中断,使进程在运行时可以再次接收中断信号。

异常向量表 🚪

为了安全地进入内核,我们需要限制入口点的数量。这通过“异常向量表”来实现。异常向量表是一个函数指针表,每个指针指向一个能处理特定事件(如中断、系统调用或特定异常)的内核函数。

处理器处理异常向量表的方式主要有两种:

  1. 软件管理:处理器只有一个固定的内核入口地址。无论何种原因触发切换,都跳转到该地址。然后由该地址的软件代码读取系统寄存器,判断具体原因,再跳转到相应的处理函数。
    // 示例:MIPS处理器上的软件判断代码
    mfc0 k0, CP0_CAUSE      // 读取记录原因的系统寄存器
    andi k0, k0, 0x7C       // 提取原因码
    lw k0, exception_handlers(k0) // 根据原因码查找处理函数
    jr k0                    // 跳转到对应的处理函数
    
  2. 硬件管理:处理器硬件直接维护异常向量表。根据不同的触发原因,硬件会自动跳转到表中对应的处理函数地址,无需软件进行判断。

透明的、可恢复的执行 🔄

进程被中断时,应该对此毫无感知。为了实现这种透明性,内核需要在切换时保存和恢复进程的“上下文”。

进程的上下文主要指其使用的所有通用处理器寄存器的值。保存和恢复上下文的过程可以比喻为整理书桌:

  • 保存上下文:当进程被中断时,内核将其所有寄存器值保存到内核栈上(就像把桌上所有文件整齐收进文件夹,放到书架上)。
  • 内核执行:内核开始处理中断或系统调用。
  • 恢复上下文:处理完毕后,内核从栈上恢复之前保存的所有寄存器值(就像从书架上取回文件夹,把文件按原样放回桌面)。
  • 进程恢复:进程从被中断的指令处继续执行,所有寄存器状态与中断前完全一致,仿佛从未被中断过。

大多数现代操作系统(如Linux、Windows)采用软件管理上下文的方式,因为这提供了更大的灵活性。

内核栈的重要性 🗂️

内核拥有自己独立的栈空间,而不是复用用户进程的栈。这主要基于两个原因:

  1. 安全性:用户进程的栈指针可能被恶意或错误地设置。如果内核使用不可信的栈指针,可能导致崩溃。
  2. 数据隔离:内核的数据可能包含敏感信息。如果将其留在用户进程可访问的内存中,会造成信息泄露风险。

因此,每个进程通常都关联一个专属于它的内核栈,用于在内核模式下执行时使用。

多进程场景下的完整切换流程 🔀

最后,我们通过一个多进程场景来串联整个切换流程。假设初始时进程P2正在运行:

  1. 中断触发切换:定时器中断发生,P2从用户模式切换到内核模式。内核保存P2的上下文到P2的内核栈,然后处理定时器中断。
  2. 进程调度:定时器处理函数决定进行进程调度,选择运行进程P1。它调用context_switch函数。
  3. 上下文切换
    • 将当前内核执行状态(代表P2)保存到P2的内核栈。
    • 从P1的内核栈中恢复P1之前被切换出去时的内核执行状态。
  4. 返回用户模式:内核恢复P1的用户态上下文,处理器切换回用户模式,P1开始执行。
  5. 系统调用与等待:P1执行read系统调用读取键盘输入,但输入尚未就绪。内核保存P1上下文后,发现P1需要等待,于是再次调用context_switch切换回P2执行。
  6. 恢复执行:内核恢复P2的上下文,P2从之前被中断的地方继续执行。

这个流程涉及多次用户态/内核态切换以及进程间的上下文切换,是操作系统并发执行能力的核心体现。理解它可能需要时间,通过实践(例如实现上下文切换)会加深认识。


本节课中我们一起学习了模式切换的三大支柱:原子控制权转移、通过异常向量表的安全入口管理,以及实现透明性的上下文保存与恢复。我们还了解了内核独立栈的必要性,并梳理了多进程环境下完整的切换流程。这些机制共同保障了操作系统内核的安全性、稳定性和高效性。

009:进程调度(第一部分)

在本节课中,我们将深入学习进程的概念,并开始探讨一个核心的OS功能:如何调度多个进程在CPU上运行,以实现多任务处理。

进程概念回顾

上一节我们介绍了进程间通信,本节中我们来看看进程本身的几个核心方面。进程是程序的一个实例,是程序在有限执行权限下的执行过程。操作系统通过进程这个抽象来运行应用程序,它提供了保护、隔离等特性。

进程的抽象可以从三个重要方面来理解:地址空间、环境和执行流。

地址空间

每个进程都拥有自己独立的地址空间。这意味着一个进程无法直接访问另一个进程的内存。当进程通过 fork() 系统调用创建子进程时,子进程会获得父进程地址空间的一个完整副本。

以下是一个C语言程序示例,用于演示地址空间的独立性:

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

int i = 1; // 全局变量,位于数据段

int main() {
    int j = 10; // 局部变量,位于栈
    int *k = malloc(sizeof(int)); // k在栈上,指向堆中分配的内存
    *k = 4;

    pid_t pid = fork();

    if (pid != 0) { // 父进程
        i = i + 1;
        j = j - 1;
        *k = *k * 1;
        printf("Parent: i=%d, j=%d, *k=%d\n", i, j, *k);
    } else { // 子进程
        i = i + 2;
        j = j - 2;
        *k = *k * 2;
        printf("Child: i=%d, j=%d, *k=%d\n", i, j, *k);
    }
    return 0;
}

运行此程序,父进程和子进程会输出不同的变量值,但相同变量名(如 i, j)的地址是相同的。这是因为每个进程都有自己的虚拟地址空间,相同的虚拟地址映射到了不同的物理内存位置。

环境

进程的环境是指描述其运行状态的所有信息。这些信息由内核维护在进程控制块中。

以下是构成进程环境的主要元素列表:

  • 进程标识符:如进程ID、用户ID。
  • 进程关系:指向父进程的链接。
  • 内存管理:代码、栈、堆、数据段等内存段的列表。
  • 文件管理:打开文件的文件描述符表。
  • 上下文信息:当前工作目录等。

执行流

进程的执行是顺序的。CPU按顺序执行进程的指令(代码段中的语句)。虽然存在循环和条件分支可以改变执行路径,但整体上指令是串行执行的。信号处理是唯一可能暂时打断这种顺序流的情况,但信号处理函数本身也是顺序执行完毕后,进程再从中断点恢复。

调度简介

现在让我们进入今天的核心主题:调度。进程只有获得CPU时间才能执行指令、取得进展。调度就是决定在多个就绪进程中,哪一个接下来使用CPU的策略。

CPU爆发与I/O爆发

进程的执行行为通常呈现出一种模式,即在两种状态间交替。

以下是进程执行的两种主要爆发类型:

  • CPU爆发:进程密集执行计算指令,几乎不与I/O设备交互。例如进行科学计算。
  • I/O爆发:进程主要等待输入/输出操作完成,CPU执行量很少。例如从网络下载文件或等待用户键盘输入。

根据行为倾向,进程可分为:

  • CPU密集型:大部分时间处于CPU爆发状态。瓶颈在于CPU速度。
  • I/O密集型:大部分时间处于I/O爆发状态。瓶颈在于I/O设备速度。
  • 混合型:均衡地混合了两种爆发类型。例如编译器(读取文件是I/O,编译优化是CPU计算)。

调度目标与模型

调度的核心目标是实现多任务处理,即让单个CPU能够高效地服务于多个进程,最大化CPU利用率。当某个进程因I/O操作而阻塞时,调度器应迅速将CPU分配给其他就绪进程。

有两种主要的调度模型:

协作式调度
进程在执行过程中不会被强制打断。只有当进程主动调用 yield() 放弃CPU,或执行I/O操作进入阻塞状态时,CPU才会被让出。早期操作系统(如MS-DOS,经典Mac OS)采用此模型,缺点是如果一个进程不主动让出CPU,整个系统可能被“卡住”。

抢占式调度
这是现代操作系统的标准模型。操作系统通过一个硬件定时器进行干预。即使进程正在执行一个长的CPU爆发且未主动让出,定时器中断也会周期性地触发,将控制权交还给内核。内核的调度器可以据此决定暂停当前进程,将CPU分配给其他进程。这保证了CPU时间能在所有进程间更公平地共享。

进程生命周期与状态

理解调度需要清楚进程可能处于的各种状态及其转换。下图描绘了进程的典型生命周期:

以下是进程状态转换的说明:

  1. 创建:进程通过 fork() 创建,初始状态为就绪
  2. 就绪 -> 运行:调度器选择该进程,将其分配到CPU上执行。
  3. 运行 -> 就绪:在抢占式系统中,进程可能因时间片用完被定时器中断抢占;或在协作式系统中主动让出CPU。
  4. 运行 -> 阻塞:进程发起一个I/O请求(进入I/O爆发),并等待结果,从而进入阻塞状态。
  5. 阻塞 -> 就绪:等待的I/O操作完成,进程被移回就绪队列。
  6. 运行 -> 僵尸:进程通过 exit() 或信号终止。它释放大部分资源,但为了保留退出状态供父进程查询,会进入僵尸状态。
  7. 僵尸 -> 终止:父进程调用 wait() 收集了子进程的退出状态后,僵尸进程被系统彻底销毁。

孤儿进程与僵尸进程处理
如果父进程先于子进程终止,子进程会成为“孤儿进程”。在Unix-like系统中,孤儿进程会被PID为1的 init(或 systemd)进程收养。init 会负责等待这些收养的子进程,确保它们终止后能被正确清理,避免僵尸进程永久残留。

一个常见的现象是:从终端启动的程序,在关闭终端窗口时会随之结束。这是因为终端关闭时,会向从它启动的所有进程发送 SIGHUP(挂起)信号,默认行为是终止进程。而从图形界面(如文件浏览器)启动的程序,其父进程终止后,它会被 init 收养并继续运行。

总结

本节课中我们一起学习了进程的深入概念,包括其独立的地址空间、运行环境和顺序执行流。我们重点引入了CPU调度的核心思想,探讨了进程的CPU爆发与I/O爆发行为模式,以及协作式与抢占式两种调度模型。最后,我们详细分析了进程的生命周期状态图,理解了进程如何在不同状态间转换,以及孤儿进程和僵尸进程的处理机制。下一节课,我们将具体分析几种经典的CPU调度算法。

010:进程调度(第二部分)

在本节课中,我们将继续学习进程调度,深入探讨不同的调度策略与算法。我们将从回顾进程的基本概念和调度原理开始,然后介绍一系列调度算法,分析它们的优缺点及适用场景。

进程与调度回顾

上一节我们定义了进程及其特性。一个进程拥有独立的地址空间,包括代码段、栈、堆和数据段。进程之间在创建后,其内存空间是相互隔离的。

进程还拥有一个执行环境,由进程控制块(PCB)描述,其中包含进程ID(PID)、打开的文件列表、所属用户等信息。

在调度方面,进程的执行可分为两种阶段:CPU密集型阶段(CPU burst)和I/O密集型阶段(I/O burst)。CPU密集型进程主要进行计算,而I/O密集型进程则频繁与外部设备交互。

调度模型有两种:协作式调度抢占式调度。协作式调度依赖进程主动让出CPU,而抢占式调度则通过硬件定时器中断强制收回CPU控制权,以实现更公平的资源共享。

进程在其生命周期中会经历四种状态:就绪运行阻塞僵尸。僵尸状态是一个临时状态,表示进程已结束但尚未被其父进程回收。

调度相关术语

在深入讨论算法前,我们需要明确几个关键术语。以下是这些术语的定义:

  • 提交时间/到达时间:进程被创建并进入系统的时间。
  • 退出时间:进程完成执行并退出系统的时间。
  • 周转时间:从进程提交到进程完成所经历的总时间。公式为:周转时间 = 完成时间 - 到达时间
  • 响应时间:通常指从进程提交到它首次在CPU上开始运行所经历的时间。另一种定义是到用户能与进程开始交互的时间。
  • 等待时间:进程在就绪队列中等待被调度执行的总时间。

调度算法

接下来,我们逐一分析几种经典的调度算法。

先来先服务

先来先服务(FIFO/FCFS)是最简单直观的算法,类似于排队,先到的进程先执行。

以下是该算法的一个示例场景:

  • 假设有三个进程A、B、C几乎同时到达(到达时间≈0),其执行时长分别为10、10、10。
  • 调度顺序为A -> B -> C。
  • 计算平均周转时间:(10 + 20 + 30) / 3 = 20。

然而,FIFO算法存在护航效应问题。如果一个长进程先到达,即使后面有短进程,短进程也必须等待长进程完全执行完毕,导致平均周转时间变差。

最短作业优先

为了克服护航效应,提出了最短作业优先(SJF)算法。该算法优先调度预计执行时间最短的进程。

以下是该算法的一个示例场景:

  • 假设进程A、B、C几乎同时到达,执行时长分别为100、10、10。
  • SJF会优先调度B和C,最后调度A。
  • 这显著改善了平均周转时间。

SJF在理论上能提供最优的平均周转时间。但它存在两个主要问题:

  1. 不可预知性:操作系统很难预先准确知道进程的执行时间。
  2. 非抢占导致的护航效应:如果一个长进程已经开始执行,即使有更短的进程到达,也无法中断当前进程。

最短剩余时间优先

最短剩余时间优先(SRTF)是SJF的抢占式版本。系统会优先调度剩余执行时间最短的进程。

以下是该算法的一个示例场景:

  • 长进程A先开始执行。
  • 当短进程B和C到达时,系统比较发现B和C的剩余时间(10)比A的剩余时间(90)短。
  • 于是系统抢占A,先调度B和C执行,然后再继续执行A。
  • 这进一步优化了平均周转时间。

但SRTF可能导致饥饿现象。如果一个长进程不断被新到达的短进程抢占,它可能永远无法获得足够的CPU时间来完成。

时间片轮转

以上算法主要优化周转时间,适用于批处理系统。对于交互式系统,响应时间更为关键。时间片轮转(RR)算法为此设计。

RR算法为每个进程分配一个固定的时间片。进程每次只能运行一个时间片,然后被强制抢占,CPU交给就绪队列中的下一个进程。

以下是该算法的一个示例场景:

  • 假设时间片长度为2.5,三个进程A、B、C同时到达,执行时长均为10。
  • 调度顺序为:A(2.5) -> B(2.5) -> C(2.5) -> A(2.5) -> ... 如此循环。
  • 平均响应时间大幅改善,因为每个进程都能很快获得首次执行机会。

然而,RR算法通常会增加平均周转时间,因为进程需要被多次切换才能完成。时间片长度的选择至关重要:太短会导致过多的上下文切换开销;太长则会损害响应性。RR算法的一个优点是不会导致饥饿

多级队列调度

现代系统需要同时处理交互式和批处理进程。多级队列调度(MLQ)尝试结合不同算法的优点。

其核心思想是将进程分类(如系统进程、交互进程、批处理进程),并为不同队列分配不同的优先级和调度算法。

以下是该算法的一个示例配置:

  • 最高优先级队列(系统进程):使用FCFS,确保紧急任务立即执行。
  • 中优先级队列(交互进程):使用RR,保证良好的响应性。
  • 低优先级队列(批处理进程):使用SJF,优化周转时间。

MLQ的主要挑战在于如何准确分类进程,以及低优先级队列中的进程可能面临饥饿风险。

多级反馈队列

多级反馈队列(MLFQ)是对MLQ的改进,它通过反馈机制动态调整进程的优先级,而无需预先分类。

其工作流程如下:

  1. 所有新进程进入最高优先级队列。
  2. 如果一个进程在时间片用完前就主动阻塞(如进行I/O操作),说明它是交互式的,则其优先级保持不变
  3. 如果一个进程用完了整个时间片,说明它可能是CPU密集型的,则其优先级降低,进入下一级队列。
  4. 低优先级队列通常被分配更长的时间片,以减少上下文切换开销,让CPU密集型任务能更连续地运行。
  5. 为了防止饥饿,系统可以定期将长时间未得到执行的进程提升回较高优先级队列

MLFQ综合了响应性和吞吐量的优点,并能自适应进程行为的变化。它是许多现代操作系统(如Windows、Linux、macOS)所采用调度策略的基础。

总结

本节课我们一起深入学习了进程调度的第二部分。我们首先回顾了进程状态和调度模型,然后系统性地介绍了从FIFO、SJF、SRTF到RR、MLQ,最终到MLFQ的一系列调度算法。

每种算法都有其设计目标和权衡:FIFO简单但有护航效应;SJF优化周转时间但难以实现且可能导致饥饿;RR优化响应时间但增加周转时间;MLQ和MLFQ则尝试通过分类和反馈机制来兼顾不同类型进程的需求,其中MLFQ以其动态适应性成为现代操作系统的实践标准。理解这些算法的原理和折衷,是掌握操作系统资源管理核心思想的关键。

011:并发与线程(第一部分)

在本节课中,我们将开始学习一个与之前“进程与进程调度”主题紧密相关的新概念:并发线程。我们将探讨什么是并发,它与并行有何区别,以及为什么线程是实现并发的一种更高效的方式。

概述:什么是并发?

上一节我们介绍了进程调度,本节中我们来看看并发。并发是指多个任务或进程能够以重叠的方式独立运行。这意味着在同一段时间内,我们可以执行一点任务A,然后切换到任务B执行一点,再切换回任务A,如此交替进行。

并发与顺序执行直接对立。顺序执行意味着必须完整执行完第一个任务后,才能开始执行第二个任务。并发则允许任务执行过程存在重叠。

一个简单的类比是烹饪一顿三道菜的晚餐:

  • 顺序执行:先完整地做完开胃菜,然后完整地做主食,最后完整地做甜点。
  • 并发执行:可以先开始处理一点开胃菜,然后去预热烤箱(为主食准备),接着搅拌一下甜点的材料,再回来继续处理开胃菜。你在所有三道菜上交替推进,直到全部完成。

核心概念:并发 ≠ 并行。并行是并发的一种特殊类型。

  • 并发:指有多个任务需要处理,你通过交替执行的方式在所有任务上同时取得进展。
  • 并行:指你确实在同一时刻执行多个任务(例如,在厨房里,一只手处理开胃菜,另一只手处理主食)。并行需要多个物理处理单元(如多核CPU)。

进程并发

那么,并发如何应用于进程呢?进程是计算机上执行的程序。一个复杂的程序可能包含多个阶段。使用并发的一个思路是,将这个进程的不同阶段分离成多个独立的进程,每个进程只负责一个单一任务。这些进程需要通过进程间通信(IPC)来协作,例如通过文件、管道或信号。

实际上,你已经接触过进程并发:GCC编译器的工作方式就是一个例子。当你运行GCC编译源代码时,它默认会顺序执行四个独立的程序(进程):预处理器(CPP)、编译器(CC)、汇编器(AS)和链接器(LD)。但是,你可以使用 -pipe 选项让GCC并发地运行这四个进程,它们通过管道进行通信,由操作系统调度器决定何时执行哪个进程的一小部分。

# 顺序执行(默认)
gcc source.c -o program
# 并发执行(使用管道)
gcc -pipe source.c -o program

实现并发的几种方式

回到任务执行模型,一个进程的执行包含CPU突发(进行计算)和I/O突发(等待输入/输出)。以下是利用硬件实现并发的几种方式:

  1. CPU虚拟化:这是最常见的并发形式。单个CPU通过快速切换,交替执行多个任务的一小部分。虽然总执行时间没有减少,但它提高了系统的交互性响应能力,让用户感觉多个程序在同时运行。

    • 图示[T1片段] -> [T2片段] -> [T1片段] -> [T2片段]...
  2. 利用I/O重叠:当一个任务进入I/O等待(不占用CPU)时,CPU可以立即切换到另一个需要CPU的任务去执行。这种方式可以减少总执行时间

    • 图示T1: [CPU] -> [I/O等待];与此同时,T2: [CPU] -> [I/O等待]
  3. 并行:这是并发的一种特例,需要多个CPU核心。不同的任务真正在同一时刻于不同的CPU上执行。

    • 图示CPU1: [执行T1];同时,CPU2: [执行T2]

为什么需要线程?进程并发的局限性

进程并发虽然可行(如GCC的例子),但存在一些缺点:

  • 创建开销大fork() 一个进程需要复制父进程的地址空间、创建新的PCB等,消耗大量时间和资源。
  • 切换开销大:进程切换需要保存和恢复整个地址空间等大量上下文。
  • 通信复杂且慢:进程间必须通过IPC(如管道、文件)通信,这些操作涉及系统调用,速度较慢。

这就引出了线程的概念。线程的核心思想是:在同一个进程内部实现并发

大思路转变

  • 进程并发:将问题分解为子问题,每个子问题由一个独立的进程处理,进程间通过IPC通信(慢)。
  • 线程并发:将问题分解为子问题,但所有子问题都在同一个进程内,由该进程内的多个线程处理。

线程详解

什么是线程?

一个进程开始时,通常只有一个执行流,即主线程,它顺序执行代码。线程允许这个主线程创建额外的执行流。这些新线程运行在同一个进程的地址空间内,拥有自己的代码执行序列,但共享进程的大部分资源。

关键特性

  • 共享地址空间:所有线程访问相同的内存(全局变量、堆数据)。
  • 独立执行上下文:每个线程有自己的栈、程序计数器(PC)和寄存器状态。
  • 由内核调度:线程和进程一样,是CPU调度的基本单位。内核会在它们之间进行上下文切换。

线程的优势

  1. 提高响应性:对于图形界面程序,可以将耗时操作(如视频编码)放在后台线程,保持UI线程的响应。
  2. 充分利用多核:在多处理器系统上,多线程能实现真正的并行,大幅提升计算速度。
  3. 高效资源共享:线程间共享内存,通信和数据交换远比进程间IPC高效。
  4. 创建与切换开销小:线程比进程轻量得多。

线程应用示例

以下是两个利用线程提升性能的经典场景:

1. Web服务器

  • 问题:Web服务器需要处理大量并发请求。每个请求可能涉及CPU计算、磁盘I/O和网络I/O。
  • 单线程方案:顺序处理请求,当前请求完成前无法处理下一个,效率低下。
  • 多线程方案:创建一组工作线程。当一个线程因等待磁盘I/O而阻塞时,CPU可以调度另一个线程处理新请求。这样充分利用了I/O重叠和CPU虚拟化,显著提高了吞吐量。

2. 并行数组计算

  • 问题:计算 C[i] = A[i] + B[i],其中 i 从0到N-1。
  • 单线程方案:顺序循环N次。
  • 多线程方案(假设双核CPU):创建两个线程。线程1计算数组的前半部分(i=0N/2-1),线程2计算后半部分(i=N/2N-1)。两个线程在各自CPU核心上并行执行,理想情况下速度可提升近一倍。
    • 注意:此方案在单核CPU上不会更快,因为仍然是交替执行。同时,这种数据并行模式使用进程很难实现,因为进程间不共享数组内存。

线程与进程的数据结构

操作系统需要管理线程和进程:

  • 进程控制块:包含进程级信息,如进程ID、地址空间、文件描述符表、信号处理等。这是所有线程共享的环境
  • 线程控制块:每个线程独有,包含线程级信息,如线程ID、栈指针、程序计数器、寄存器状态、线程状态(运行、就绪、阻塞)等。

关系:一个PCB对应一个进程,一个进程包含一个或多个TCB(线程)。

线程与进程的关键区别

以下是线程与进程的核心区别总结:

特性 线程 进程
基本单位 CPU调度的基本单位 资源分配的基本单位
资源 共享所属进程的全部资源(内存、文件等) 拥有独立的地址空间和资源
通信 可直接读写共享内存,非常高效 必须通过IPC(管道、信号、共享内存等),相对较慢
创建开销 小,只需分配栈和TCB 大,需要复制地址空间、创建PCB等
切换开销 小,只需切换执行上下文(寄存器、栈) 大,需要切换地址空间等
独立性 一个线程崩溃可能导致整个进程崩溃 进程间相互隔离,一个崩溃通常不影响其他进程
比喻 一个车间(进程)里的多条流水线(线程) 多个独立的车间(进程)

总结

本节课中我们一起学习了并发线程的核心概念。我们首先明确了并发(任务重叠执行)与并行(任务同时执行)的区别。然后,我们探讨了基于进程实现并发的局限性,如创建和通信开销大。这自然引出了线程这一更轻量级的解决方案。线程在同一个进程内部提供多个执行流,共享内存空间,使得并发编程更高效、更直观。我们还分析了线程的优势、应用场景,以及它与进程在数据结构和特性上的关键区别。下一节,我们将继续深入线程,探讨线程的不同实现模型。

012:并发与线程(第二部分)🚀

在本节课中,我们将继续学习并发与线程的主题。我们将深入探讨线程的实现模型,比较内核级线程与用户级线程的优缺点,并了解在多线程编程中可能遇到的一些典型问题。

上一节我们介绍了并发与线程的基本概念,本节中我们来看看线程是如何具体实现的。

线程的实现模型

线程的实现主要有两种模型:内核级线程和用户级线程。理解这两种模型的区别对于掌握多线程编程至关重要。

内核级线程(一对一模型)

内核级线程意味着操作系统内核知晓并管理进程内部的线程。当进程调用类似 thread_create 的函数时,实际上是通过系统调用请求内核创建一个新的线程。内核会为每个线程维护一个线程控制块(TCB)和一个内核栈。

核心概念:在这种模型中,用户线程与内核线程是一一对应的关系。

以下是内核级线程工作方式的关键点:

  • 内核管理:线程的创建、销毁、调度和上下文切换都由内核负责。
  • 独立调度:如果某个线程因I/O操作而阻塞,内核可以调度同一进程内的其他线程继续执行。
  • 支持并行:在多CPU系统上,内核可以将同一进程的不同线程分配到不同的CPU上同时执行,实现真正的并行。
  • 开销较大:线程的每次操作(如创建、切换)都需要陷入内核态,带来一定的性能开销。

用户级线程(多对一模型)

用户级线程完全由运行在用户空间的线程库(如我们项目二中要实现的库)管理,操作系统内核对此一无所知。内核只知道有一个进程在运行,并为其维护一个进程控制块(PCB)。

核心概念:多个用户线程映射到同一个内核线程(即进程)。

以下是用户级线程工作方式的关键点:

  • 用户空间管理:线程的创建、调度和上下文切换全部在用户空间由库代码完成,无需陷入内核,速度极快。
  • 可定制调度器:线程库可以实现完全自定义的调度策略。
  • 阻塞影响全体:如果任何一个用户线程执行了阻塞式系统调用(如读文件),由于内核不知道其他线程的存在,它会将整个进程阻塞,导致所有线程都无法执行。
  • 难以实现并行:因为内核只看到一个“进程”,所以无法将用户线程分配到多个CPU上并行执行。

两种模型的对比与总结

为了更清晰地理解,让我们总结一下内核级线程与用户级线程的优缺点。

内核级线程的优点包括阻塞系统调用只影响单个线程、支持并行执行以及由内核提供高质量的调度。其主要缺点是线程操作需要内核介入,开销相对较大。

用户级线程的优点在于线程操作在用户空间完成,极其快速,并且允许完全自定义调度策略。其致命缺点是单个线程的阻塞会阻塞整个进程,且通常无法利用多核实现并行。

一个生动的例子是Java线程与Go语言的Goroutine。Java线程通常对应内核级线程,创建数量有限(几千个)。而Go的Goroutine是用户级线程,由Go运行时管理,因此可以轻松创建数百万个。

多线程编程中的问题

在编写多线程程序时,即使理解了线程模型,也会遇到一些棘手的问题。以下是几个典型的例子。

问题一:混合使用 fork 与多线程

当一个多线程进程调用 fork() 时,新创建的子进程通常只包含调用 fork() 的那个线程的副本,而不是复制整个进程的所有线程。这可能导致子进程的状态与预期不符,例如某些全局初始化工作可能尚未完成。因此,除非有特殊需求,通常应避免在多线程程序中调用 fork

问题二:共享数据的竞争

考虑两个线程并发执行,对同一个全局变量进行递增操作:

int a = 0;
void *thread_func(void *arg) {
    a++; // 这行代码可能引发问题
    return NULL;
}

理论上,两个线程执行后 a 的值应为2。但由于两个线程可能同时读取旧的 a 值,分别加1后写回,最终结果可能是1。这就是数据竞争,它引出了下一讲的核心主题——同步。没有适当的同步机制,多线程访问共享数据的结果是不可预测的。

问题三:信号处理

在多线程环境中,信号的传递变得复杂。有些信号(如由非法内存访问触发的 SIGSEGV)可以明确关联到引发它的特定线程。但有些信号(如用户按Ctrl+C产生的 SIGINT)目标是整个进程,内核会随机选择一个不阻塞该信号的线程来传递,这给编程带来了不确定性。

问题四:线程安全与可重入函数

许多传统的C库函数不是“线程安全”的,因为它们内部使用静态变量或全局变量来维护状态。例如 strtok 函数,它在首次调用时设置一个内部指针,后续调用依赖此指针。如果多个线程并发使用 strtok,它们会互相干扰这个内部状态。

解决方案

  1. 使用可重入版本函数,如 strtok_r,它要求调用者显式地传入状态指针。
  2. 使用线程局部存储。通过 _Thread_local 关键字(C11标准)声明全局变量,可以让每个线程拥有该变量的独立副本,从而避免共享导致的冲突。例如,每个线程都可以有自己的 errno 副本。

核心概念:编写多线程程序时,必须确保使用的函数是线程安全的,或者通过同步机制保护对非线程安全函数的调用。

本节课中我们一起学习了线程的两种主要实现模型——内核级线程和用户级线程,并分析了它们各自的优缺点。我们还探讨了在多线程编程实践中可能遇到的几个典型问题,包括fork的语义、数据竞争、信号处理以及线程安全函数的重要性。理解这些内容是进行正确、高效多线程编程的基础。下一讲,我们将深入探讨解决数据竞争问题的关键:线程同步

013:进程同步(第一部分)

在本节课中,我们将要学习进程同步的基础知识。上一节我们介绍了线程和并发,本节中我们来看看并发可能引发的问题,以及用于缓解这些问题的解决方案。这一切都围绕着“同步”展开。

线程与共享数据回顾

首先,让我们快速回顾一下线程。线程是同一进程内的执行流。它们运行时各自拥有CPU的控制权,拥有自己的CPU寄存器和私有栈,但共享相同的地址空间和全局变量。

关于数据共享,有两种类型:

  • 独立共享:线程访问共享数据,但各自修改不同的部分,互不冲突。
  • 协作共享:线程在共享数据的相同位置上进行协作和修改,这会使情况变得复杂。

并发问题:竞态条件

为了理解协作共享为何会出问题,我们来看一个简单的例子。

假设有一个全局变量 x,初始值为0。我们创建两个线程A和B,分别执行 x = x + 1x = x + 2

我们期望的最终结果是3。然而,在某些情况下,结果可能是2,甚至是1。这是如何发生的呢?

关键在于,像 x = x + 1 这样的C语言语句不是原子操作。它会被编译成多条汇编指令。例如,在MIPS汇编中,它可能被翻译为:

lw $8, 0($gp)   # 将x从内存加载到寄存器$8
addi $8, $8, 1  # 将寄存器$8的值加1
sw $8, 0($gp)   # 将寄存器$8的值存回内存中的x

线程B的代码类似,但加的是2。

由于线程的调度是不确定的,可能出现以下交错执行序列:

  1. 线程A执行 lw 指令,将 x(值为0)读入 $8
  2. 线程A被中断,切换到线程B。
  3. 线程B执行 lw 指令,也将 x(值仍为0)读入自己的 $8
  4. 线程B执行 addi,将 $8 变为2。
  5. 线程B被中断,切换回线程A。
  6. 线程A从上次中断处继续,执行 addi,将 $8 从0变为1。
  7. 线程A执行 sw,将1写回内存,x 变为1。
  8. 线程A结束。线程B恢复执行。
  9. 线程B执行 sw,将2写回内存,x 被覆盖为2。

最终,x 的结果是2,而不是3。这就是一个典型的竞态条件问题。

竞态条件产生的原因是,程序的输出依赖于线程间操作执行的顺序,就像线程在“赛跑”一样。这种问题难以调试,因为它可能极少发生,但一旦发生,后果可能很严重(例如,历史上曾有机床因竞态条件导致致命事故)。

除了调度,编译器优化和CPU硬件指令重排也可能加剧竞态条件的复杂性。

同步解决方案初探:买牛奶的例子

为了解决竞态条件,我们需要同步机制。让我们通过一个生活中的例子——“室友买牛奶”——来理解同步需要满足的性质,并尝试设计解决方案。

问题描述:两个室友共享牛奶。他们回家后都会检查冰箱。如果没牛奶了,就需要去商店购买。目标是:1) 安全性:最多只有一个人去买牛奶(避免买多);2) 活性:如果需要牛奶,最终必须有人去买。

方案一:留纸条(单标志位)

以下是第一种解决方案的伪代码思路:

// 全局变量
int milk = 0; // 0表示没牛奶
int note = 0; // 0表示没留纸条

// 室友1的代码
if (note == 0) {          // 如果没看到纸条
    if (milk == 0) {      // 如果没牛奶
        note = 1;         // 留下纸条“我去买”
        // 去买牛奶...
        milk = 1;         // 放好牛奶
        note = 0;         // 拿走纸条
    }
}
// 室友2的代码相同

分析:这个方案不安全。考虑以下交错执行:

  1. 室友1检查 note 为0,milk 为0,但在执行 note = 1 之前被中断。
  2. 室友2开始执行,检查 note 仍为0,milk 为0,于是进入购买流程。
  3. 随后,室友1恢复执行,也留下纸条并去购买。
    结果导致两个人都买了牛奶,违反了安全性。

方案二:各留各的纸条(双标志位)

以下是第二种解决方案的伪代码思路:

// 全局变量
int milk = 0;
int note1 = 0; // 室友1的纸条
int note2 = 0; // 室友2的纸条

// 室友1的代码
note1 = 1;                 // 宣布“我到家了”
if (note2 == 0) {          // 如果室友2没宣布到家
    if (milk == 0) {       // 如果没牛奶
        // 去买牛奶...
        milk = 1;
    }
}
note1 = 0;                 // 宣布“我事情办完了”

// 室友2的代码(对称)
note2 = 1;
if (note1 == 0) {
    if (milk == 0) {
        // 去买牛奶...
        milk = 1;
    }
}
note2 = 0;

分析:这个方案安全(最多一人买奶),但可能不满足活性。考虑以下交错:

  1. 室友1设置 note1 = 1,然后被中断。
  2. 室友2设置 note2 = 1,然后被中断。
  3. 室友1恢复,检查 note2 为1,认为室友2会处理,于是跳过购买环节,但在执行 note1 = 0 前又被中断。
  4. 室友2恢复,检查 note1 仍为1,也认为室友1会处理,同样跳过购买。
    结果两个人都没买牛奶,违反了活性。

方案三:非对称等待

以下是第三种解决方案的伪代码思路:

int milk = 0;
int note1 = 0, note2 = 0;

// 室友1的代码(“等待者”)
note1 = 1;
while (note2 == 1) { /* 忙等待 */ } // 如果室友2也在,就等他
if (milk == 0) {
    // 去买牛奶...
    milk = 1;
}
note1 = 0;

// 室友2的代码(“检查者”)
note2 = 1;
if (note1 == 0) {
    if (milk == 0) {
        // 去买牛奶...
        milk = 1;
    }
}
note2 = 0;

分析:这个方案既安全又活性。核心在于,无论调度顺序如何,通过 while (note2 == 1) 这个等待,可以保证在进入关键决策区(检查牛奶并购买)时,两个线程的执行状态是协调好的。但它的缺点是:

  1. 过于复杂:难以理解和证明正确性。
  2. 不对称:代码不统一,难以扩展到多于两个线程的情况。
  3. 忙等待:线程可能在循环中空转,浪费CPU资源。

存在更优雅、对称且可扩展的算法(如Peterson算法),但它们通常也比较复杂。我们需要更通用的同步原语。

关键概念与术语

在深入更通用的解决方案之前,我们先明确几个核心概念:

  • 临界区:一段访问共享资源(变量、设备等)的代码,必须被保护以防止多个线程同时进入。
  • 互斥:保证最多只有一个线程可以执行临界区的属性。
  • 同步机制的正确性属性
    1. 安全性(互斥):临界区内同时最多有一个线程。
    2. 活性(进展):如果没有线程在临界区内,那么一个想要进入的线程最终应该能够进入。
    3. 有限等待:一个线程在提出进入请求后,等待进入的时间应该是有限的。
    4. 容错性:一个在临界区内失败的线程不应阻止其他线程进入。

在“买牛奶”的例子中,操作 milk 变量的代码就是临界区。我们之前设计的各种“进入区”和“退出区”代码,就是为了实现互斥访问这个临界区。

总结

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

  1. 竞态条件的产生原因:由于线程调度、编译器优化、硬件重排导致非原子操作的交错执行,使得程序结果依赖于不确定的执行时序。
  2. 通过“买牛奶”的例子,我们探讨了实现同步的初步尝试,并理解了安全性活性这两个核心属性。
  3. 我们认识到,通过标志位和忙等待实现的同步方案虽然可能有效,但往往复杂、低效且难以扩展
  4. 最后,我们明确了临界区互斥以及同步机制应满足的正确性属性,为后续学习更强大的同步原语(如锁、信号量等)打下了基础。

下一节课,我们将开始探讨能够提供互斥保证的真正解决方案。

014:进程同步(第二部分)

概述

在本节课中,我们将继续学习进程同步。我们将重点介绍几种同步原语,它们是用于避免竞态条件的机制和实现。我们将从最简单的锁开始,逐步深入到更复杂的信号量,并探讨它们的工作原理、实现方式以及适用场景。

进程同步回顾

上一节我们介绍了竞态条件的概念。当两个或多个线程并发访问和操作同一个共享变量时,由于线程调度和指令交错执行的不确定性,可能导致最终结果不符合预期,这就是竞态条件。

我们通过“买牛奶”的例子,引出了临界区的概念——即需要被保护以防止并发访问的代码段。目标是确保互斥访问临界区,即同一时刻只有一个线程能执行临界区内的代码。我们之前看到的非对称解决方案虽然有效,但存在可扩展性差、代码复杂等问题。

本节中,我们来看看如何利用更通用和简洁的同步原语来解决这些问题。

锁:最简单的同步原语

锁是一种提供互斥访问的同步原语。它的思想类似于一个带锁的卫生间隔间:一次只允许一个人进入。锁有两种状态:

  • 锁定:已被占用,其他线程无法获取。
  • 空闲:未被占用,线程可以获取。

锁的API非常简单:

  • lock(): 尝试获取锁。如果锁空闲,则获取成功并进入临界区;如果锁已被占用,则调用线程必须等待。
  • unlock(): 释放锁。允许其他等待的线程获取它。

将锁应用于“买牛奶”问题,代码如下:

lock_t milk_lock; // 定义一个锁

void roommate() {
    lock(&milk_lock);   // 进入临界区前加锁
    if (noMilk) {       // 临界区:检查并购买牛奶
        buyMilk();
    }
    unlock(&milk_lock); // 离开临界区后解锁
}

现在,两个室友线程执行完全相同的代码。lock() 操作保证了检查冰箱和购买牛奶的代码(临界区)不会被两个线程同时执行,从而避免了竞态条件。这个方案易于理解,并且可以轻松扩展到多个线程。

锁的朴素实现及其问题

如何实现锁呢?一个朴素的想法是:既然竞态条件源于线程在执行过程中可能被随时中断,那么如果在临界区内禁用中断,不就可以保证不被抢占,从而串行执行了吗?

对应的实现可能是:

void lock() {
    disable_interrupts();
}
void unlock() {
    enable_interrupts();
}

然而,这个方法存在严重问题:

  1. 仅适用于单处理器系统:禁用中断只对当前CPU有效。在多处理器系统上,其他CPU上的线程仍然可以并发访问共享数据。
  2. 危险性高:禁用中断后,内核失去了通过时钟中断重新调度CPU的能力。如果临界区代码存在bug(如无限循环),整个系统可能失去响应。
  3. 用户态程序无法使用:禁用中断是特权操作,通常只有操作系统内核才能执行。

因此,禁用中断的方法不适合作为通用的用户级同步原语。

基于硬件支持的锁实现:自旋锁

实现锁需要硬件的帮助。现代处理器提供了一些特殊的原子指令,例如 Test-and-SetCompare-and-Swap 等。这些指令能保证“读取-修改-写入”内存这一系列操作作为一个不可分割的原子操作完成,期间不会被中断,也不会被其他处理器干扰。

以 Test-and-Set 指令为例(以下用C伪代码描述其逻辑,实际是一条CPU指令):

int TestAndSet(int *ptr, int new_value) {
    int old_value = *ptr; // 原子地读取旧值
    *ptr = new_value;     // 原子地写入新值
    return old_value;     // 返回旧值
}

我们可以利用这条指令实现一个自旋锁

typedef struct lock_t {
    int flag; // 0表示空闲,1表示占用
} lock_t;

void init(lock_t *lock) {
    lock->flag = 0;
}

void lock(lock_t *lock) {
    while (TestAndSet(&lock->flag, 1) == 1)
        ; // 自旋等待
}

void unlock(lock_t *lock) {
    lock->flag = 0;
}

工作原理如下:

  • 初始时,flag为0(锁空闲)。
  • 线程A调用lock()TestAndSet原子地将flag设为1并返回旧值0。由于返回0,while循环条件不成立,线程A成功获取锁并进入临界区。
  • 此时线程B调用lock()TestAndSet原子地读取flag(当前为1),将其设为1(无变化),并返回旧值1。由于返回1,while循环条件成立,线程B进入自旋状态,不断重试TestAndSet,直到flag变为0。
  • 线程A执行完临界区后调用unlock(),将flag设回0。
  • 线程B的TestAndSet终于读到0,将其设为1,返回0,从而退出自旋,成功获取锁。

自旋锁的优缺点:

  • 优点:在多处理器系统上,若锁被持有的时间很短,等待线程能在锁释放后立即获取,延迟极低。
  • 缺点:在单处理器上或锁被长时间持有时,等待线程会持续占用CPU进行“忙等待”,浪费计算资源。

改进等待策略:让出CPU与睡眠

为了减少CPU浪费,我们可以改进等待策略:

  1. 让出CPU:如果获取锁失败,线程主动调用yield()让出CPU,进入就绪队列末尾。希望当再次被调度时锁已可用。

    • 问题:仍然存在频繁的上下文切换开销,且可能发生“惊群效应”(很多线程让出后又同时被唤醒竞争锁)。
  2. 睡眠指定时间:获取锁失败后,睡眠一段时间再重试。

    • 问题:难以确定合适的睡眠时长。太短则浪费CPU,太长则增加不必要的延迟。

更好的方法是让线程阻塞在锁上,并由锁在可用时主动唤醒等待线程。这引出了更强大的同步原语——信号量

信号量:更通用的同步原语

信号量由Dijkstra提出,是一种功能更丰富的同步原语,常被称为“广义锁”。它内部维护一个整型计数器(count)和一个等待队列。

信号量的核心操作:

  • sem_init(sem_t *s, int count): 初始化信号量,设置初始计数值。
  • sem_wait(sem_t *s) (或 sem_down, P操作): 尝试获取资源。如果 count > 0,则将其减1并立即返回;如果 count == 0,则调用线程阻塞并加入等待队列。
  • sem_post(sem_t *s) (或 sem_up, V操作): 释放资源。将 count 加1。如果等待队列不为空,则唤醒其中一个等待线程。

信号量本身的操作(修改count和队列)也必须保证原子性,因此其内部实现通常会使用自旋锁进行保护。

信号量主要有两种使用方式:

1. 用作二进制信号量(互斥锁)

将信号量初始值设为1,即可将其用作互斥锁(Mutex)。

  • sem_wait() 相当于 lock()
  • sem_post() 相当于 unlock()
    当一个线程执行sem_waitcount从1减为0后,其他线程再调用sem_wait就会阻塞,直到第一个线程调用sem_postcount加回1并唤醒一个等待线程。

注意:互斥锁(Mutex)与二进制信号量类似,但通常有“所有权”概念(即哪个线程获得了锁),并且禁止非持有者释放锁,这能避免一些编程错误。

2. 用作计数信号量(协调线程)

这才是信号量威力的真正体现,它用于管理有限数量的同类资源或协调线程执行顺序。

典型例子:生产者-消费者模型

  • 一个生产者线程从网络接收数据包,放入队列。
  • 一个或多个消费者线程从队列取出数据包进行处理。
  • 我们使用一个信号量来同步它们。
    sem_t items; // 表示队列中可消费的数据包数量
    sem_init(&items, 0); // 初始队列为空,计数为0
    
    // 生产者线程
    void producer() {
        packet = receive_packet();
        enqueue(queue, packet);
        sem_post(&items); // 生产了一个数据包,计数加1,可能唤醒消费者
    }
    
    // 消费者线程
    void consumer() {
        sem_wait(&items); // 等待有数据包可消费(计数>0)
        packet = dequeue(queue);
        process(packet);
    }
    
    初始时items=0,消费者调用sem_wait会阻塞。当生产者放入一个数据包并调用sem_post后,items变为1,消费者被唤醒并消费。这完美地协调了生产者和消费者的步调。

总结

本节课我们一起学习了进程同步的核心原语。

  • 我们首先介绍了的基本概念,并分析了基于禁用中断的朴素实现及其局限性。
  • 接着,我们探讨了需要硬件支持的自旋锁实现,它利用原子指令(如Test-and-Set)保证了互斥,但存在忙等待的缺点。
  • 为了更高效地管理等待线程,我们引入了信号量。信号量通过一个计数器和一个等待队列,不仅能实现互斥锁(二进制信号量),还能解决更复杂的线程同步问题,如生产者-消费者模型(计数信号量)。

这些同步原语是构建正确、高效并发程序的基石。下一节课,我们将通过更多实际例子,来学习如何灵活运用这些工具。

015:进程同步(第三部分)

在本节课中,我们将学习进程同步的最后一部分内容,重点探讨两个经典的同步问题:生产者-消费者问题和读者-写者问题。我们将分析这些问题的核心挑战,并学习如何使用信号量等同步原语来构建正确且高效的解决方案。

上一节我们介绍了信号量这一强大的同步机制,它既能实现互斥,也能用于线程间的条件同步。本节中,我们来看看如何应用这些知识解决实际编程中的复杂同步场景。

生产者-消费者问题

生产者-消费者问题描述了一组生产者线程和一组消费者线程共享一个固定大小的缓冲区。生产者生成数据并放入缓冲区,消费者从缓冲区取出数据并进行处理。核心挑战在于:

  1. 当缓冲区满时,生产者必须等待。
  2. 当缓冲区空时,消费者必须等待。
  3. 必须保证对缓冲区(共享状态)的访问是互斥的。

初始实现与问题分析

首先,我们来看一个简单的、未加保护的实现。假设我们使用一个大小为 N 的循环数组 buffer 作为缓冲区,并用 inout 两个索引分别指向下一个可写入和可读取的位置。

int buffer[N];
int in = 0, out = 0;

void produce(int item) {
    buffer[in] = item;
    in = (in + 1) % N;
}

int consume() {
    int item = buffer[out];
    out = (out + 1) % N;
    return item;
}

这个实现存在两个主要问题:

  1. 竞态条件bufferinout 是共享变量。多个生产者或消费者同时执行 produceconsume 函数时,会导致数据覆盖或读取错误。
  2. 缺乏同步:消费者可能在缓冲区为空时调用 consume,读取到垃圾数据;生产者可能在缓冲区已满时覆盖未消费的数据。

使用信号量的解决方案

为了解决上述问题,我们需要同时使用互斥锁和条件同步。以下是解决方案的组件:

  1. 互斥信号量 (mutex):一个初始值为 1 的二进制信号量,用于保护对缓冲区 (buffer, in, out) 的访问,确保同一时刻只有一个线程(生产者或消费者)能修改这些共享变量。
  2. 计数信号量 (empty):用于跟踪缓冲区中空槽位的数量。初始值为 N(缓冲区大小)。生产者在写入前需要执行 down(empty) 来获取一个空位;如果 empty 为 0(缓冲区满),生产者将被阻塞。
  3. 计数信号量 (full):用于跟踪缓冲区中已填充数据的数量。初始值为 0。消费者在读取前需要执行 down(full) 来获取一个数据项;如果 full 为 0(缓冲区空),消费者将被阻塞。

以下是修正后的代码逻辑:

生产者线程执行顺序:

  1. down(empty) // 等待空槽位
  2. down(mutex) // 进入临界区,获取缓冲区访问权
  3. 将数据放入 buffer[in],更新 in
  4. up(mutex) // 离开临界区,释放缓冲区访问权
  5. up(full) // 增加一个数据项,可能唤醒等待的消费者

消费者线程执行顺序:

  1. down(full) // 等待数据项
  2. down(mutex) // 进入临界区,获取缓冲区访问权
  3. buffer[out] 取出数据,更新 out
  4. up(mutex) // 离开临界区,释放缓冲区访问权
  5. up(empty) // 增加一个空槽位,可能唤醒等待的生产者

这个方案巧妙地通过 emptyfull 两个信号量的“交叉”操作(生产者的 up(full) 对应消费者的 down(full),消费者的 up(empty) 对应生产者的 down(empty))实现了生产者和消费者之间的同步,同时用 mutex 保证了缓冲区内数据操作的原子性。

读者-写者问题

读者-写者问题描述了多个线程(读者和写者)访问一个共享资源(如数据库、文件)的场景。其要求是:

  1. 允许多个读者同时访问资源(因为读取操作不改变数据)。
  2. 一次只允许一个写者访问资源,且写者访问时需独占资源(排除所有读者和其他写者)。
  3. 写者操作完成后,读者才能继续访问。

初步尝试与改进

一个简单的想法是使用一个互斥锁 (rw_lock) 来保护共享资源。无论是读者还是写者,在访问前都先获取锁。但这会不必要地阻止多个读者并发读取。

为了允许读者并发,我们需要一个更精细的方案:

  • 引入一个读者计数器 read_count,记录当前正在读取的读者数量。
  • 引入一个互斥锁 mutex 来保护对 read_count 的修改。
  • 第一个进入的读者需要获取资源锁 rw_lock,最后一个离开的读者需要释放 rw_lock。中间的读者则无需操作 rw_lock

以下是读者侧的伪代码逻辑:

// 全局变量
int read_count = 0;
semaphore mutex = 1; // 保护 read_count
semaphore rw_lock = 1; // 保护共享资源

void reader() {
    down(mutex); // 进入修改 read_count 的临界区
    read_count++;
    if (read_count == 1) {
        down(rw_lock); // 第一个读者锁住资源
    }
    up(mutex); // 离开修改 read_count 的临界区

    // ... 执行读取操作 ...

    down(mutex); // 再次进入修改 read_count 的临界区
    read_count--;
    if (read_count == 0) {
        up(rw_lock); // 最后一个读者释放资源锁
    }
    up(mutex); // 离开修改 read_count 的临界区
}

void writer() {
    down(rw_lock); // 写者总是需要独占资源
    // ... 执行写入操作 ...
    up(rw_lock);
}

这个方案实现了基本功能,但存在“写者饥饿”的可能:如果不断有新的读者到达,read_count 可能永远不会降为 0,导致写者一直无法获取 rw_lock。更完善的解决方案需要额外的机制来保证公平性。

同步原语的演进与总结

信号量的发明者 Edsger Dijkstra 后来认为信号量过于通用(既可做互斥锁,也可做条件同步),API 简单但容易误用(例如错误地对二进制信号量多次执行 up 操作)。他倡导更清晰的抽象,从而催生了管程 这一同步原语。管程将互斥锁和条件变量明确分离,提供了更结构化的并发编程方式。尽管信号量存在这些理论上的批评,但在实践中仍被广泛使用。

最后,我们简要介绍另一种同步工具:同步屏障。它用于让一组线程在代码中的某个点集合,直到所有线程都到达该点后,才允许它们继续执行。这在并行计算中非常有用,例如确保所有子任务完成后再进入下一阶段。

同步知识体系总结

以下是进程同步相关技术的层次结构:

  • 硬件基础:计时器中断(导致抢占式调度)、多处理器(真正并行)是产生竞态条件的根源。
  • 硬件支持
    • 禁用中断(单处理器有效,但用户程序通常无权使用)。
    • 原子指令(如 测试并置位 test_and_set),适用于多处理器和用户程序。
  • 同步原语:基于硬件支持构建。
    • :用于互斥。
    • 信号量:兼具互斥与同步功能。
    • 管程:更高级的抽象,整合了互斥锁与条件变量。
  • 高级同步模式/对象:使用同步原语构建。
    • 有界缓冲区(生产者-消费者问题)。
    • 读写锁(读者-写者问题)。
    • 同步屏障

本节课中我们一起学习了如何运用信号量解决经典的生产者-消费者和读者-写者同步问题,并了解了同步原语的发展脉络。掌握这些模式是编写正确、高效并发程序的关键。下一讲,我们将开始探讨另一个并发编程中的重要主题:死锁。

016:死锁(第一部分)

在本节课中,我们将要学习操作系统中的一个重要概念——死锁。死锁是当多个进程或线程因竞争资源而陷入相互等待、无法继续执行的状态。我们将通过生活中的例子和代码示例来理解死锁的成因、必要条件以及初步的应对策略。


什么是死锁?🚗

上一节我们介绍了死锁的基本概念,本节中我们来看看一个生活中的类比。

一个典型的死锁例子是交通堵塞。观察下图,我们可以看到多辆汽车在一个十字路口相互阻塞,无法前进。每辆车都在等待前方的车辆移动,而前方的车辆又在等待其他车辆移动,形成了一个循环等待的链条,导致所有车辆都无法动弹。

在计算领域,死锁的本质与此类似:多个任务(如线程)各自持有部分资源,同时又在等待对方释放资源,从而形成一个循环依赖,导致所有任务都无法继续执行。


代码中的死锁示例 💻

理解了生活中的例子后,我们来看看在编程中死锁是如何发生的。

考虑以下场景:有两个线程(Thread 1 和 Thread 2)和两把锁(Lock 1 和 Lock 2)。两个线程都需要获取这两把锁来完成各自的计算(例如,访问共享变量的临界区)。

以下是可能引发死锁的代码逻辑:

  • Thread 1 先尝试获取 Lock 1,然后获取 Lock 2。
  • Thread 2 先尝试获取 Lock 2,然后获取 Lock 1。

这段代码可能运行数十亿次都正常,但在某一次特定的调度顺序下就会发生死锁。例如:

  1. Thread 1 成功获取 Lock 1,然后被调度器中断。
  2. Thread 2 开始执行,成功获取 Lock 2。
  3. Thread 2 尝试获取 Lock 1,但 Lock 1 已被 Thread 1 持有,因此 Thread 2 被阻塞,等待 Lock 1。
  4. 调度器切回 Thread 1,它继续执行,尝试获取 Lock 2。
  5. 但 Lock 2 已被 Thread 2 持有,因此 Thread 1 也被阻塞,等待 Lock 2。

此时,我们陷入了一个循环:Thread 1 等待 Thread 2 释放 Lock 2,而 Thread 2 等待 Thread 1 释放 Lock 1。两个线程都无法继续,这就是死锁。

一个简单的预防方法是规定一致的锁获取顺序。如果两个线程都约定先获取 Lock 1,再获取 Lock 2,那么上述死锁场景就不可能发生。因为当 Thread 1 持有 Lock 1 时,Thread 2 在第一步尝试获取 Lock 1 时就会被立即阻塞,从而让 Thread 1 能够顺利获取 Lock 2 并执行完毕。


哲学家就餐问题 🍝

死锁问题有一个非常经典的模型,即由 Dijkstra 提出的“哲学家就餐问题”。

假设有五位哲学家围坐在一张圆桌旁,桌上有五盘意大利面和五把叉子。每位哲学家只能做两件事:思考,或者吃面。吃面时需要同时使用他左手边和右手边的两把叉子。

用代码表示,每位哲学家(索引 i)的执行逻辑如下:

while (1) {
    think();
    pick_up_fork(i);       // 拿起右边的叉子
    pick_up_fork((i+1)%N); // 拿起左边的叉子
    eat();
    put_down_fork(i);      // 放下右边的叉子
    put_down_fork((i+1)%N);// 放下左边的叉子
}

这个模型可能在哪里发生死锁呢?考虑一种极端的调度情况:所有哲学家同时感到饥饿,并同时拿起了自己右边的叉子。此时,每位哲学家都持有一把叉子,并等待左边哲学家的叉子。由于叉子数量有限(资源有限),所有人都陷入等待,形成了循环依赖,导致死锁。


定义与形式化描述 📊

在深入解决方案之前,我们需要更精确地定义一些术语。

  • 饥饿:指一个或多个任务因无法获得所需资源而无法取得进展。例如,在哲学家问题中,可能只有一位哲学家永远吃不到面。
  • 死锁:是饥饿的一种特殊情况,指系统中所有涉及的任务都因相互等待资源而无法取得进展。

为了分析死锁,我们可以使用资源分配图。图中包含两种节点:

  • 圆形节点:代表任务(如线程、进程)。
  • 方形节点:代表资源(如锁、叉子)。

图中包含两种有向边:

  • 资源 -> 任务:表示该资源已被此任务持有
  • 任务 -> 资源:表示此任务正在请求该资源。

当图中出现有向环时,就意味着可能存在死锁。例如,任务A持有资源R并请求资源S,而任务B持有资源S并请求资源R,这就形成了一个循环等待的环。


死锁发生的四个必要条件 🔑

死锁的发生必须同时满足以下四个条件。如果打破其中任何一个,死锁就无法形成。

以下是四个必要条件:

  1. 互斥与资源有限:资源不能被共享,只能互斥访问,且资源数量有限。
  2. 持有并等待:任务在持有至少一个资源的同时,还在等待获取其他任务持有的额外资源。
  3. 不可抢占:任务已获得的资源在其使用完之前,不能被强制剥夺。
  4. 循环等待:存在一个任务和资源的循环链,链中的每个任务都在等待下一个任务所持有的资源。

以交通堵塞为例:

  1. 道路空间有限,一辆车占据一个位置后,其他车就不能同时占据该位置(互斥)。
  2. 每辆车都占着当前位置(持有),并想开到前方的位置(等待)。
  3. 你不能把一辆车凭空抬走以腾出空间(不可抢占)。
  4. 所有车首尾相接,互相等待前方车辆移动(循环等待)。

应对死锁的策略概览 🛡️

既然我们了解了死锁的成因,那么如何应对它呢?主要有四大类策略。

以下是四种主要的死锁处理策略:

  1. 鸵鸟算法:直接忽略死锁问题。就像鸵鸟把头埋进沙子里,假装问题不存在。在计算机中,这可能意味着系统死锁时直接重启。对于发生概率极低、修复成本过高的死锁,这不失为一种经济实用的策略。
  2. 检测与恢复:允许死锁发生,但系统会定期运行算法(如基于资源分配图的环检测算法)来检测死锁。一旦检测到,就采取恢复措施,例如终止一个任务、将任务回滚到之前的状态,或强制剥夺资源(尽管这通常很难实现)。
  3. 动态避免:在资源分配时进行动态决策,确保系统永远不会进入不安全状态(即可能导致死锁的状态)。这就像在十字路口安排一个交警,实时指挥交通,避免堵塞。
  4. 预防:通过系统设计,从根本上破坏死锁四个必要条件中的一个或多个,使死锁不可能发生。这就像重新设计十字路口,建立立交桥,使车辆永远不会相互阻塞。

策略一:检测与恢复 🔍

让我们先详细看看“检测与恢复”策略。该策略分为两步:首先检测死锁是否发生,然后采取措施从中恢复。

检测:核心是维护系统的资源分配图,并定期运行环检测算法来判断图中是否存在循环等待。

一个简单的深度优先搜索(DFS)环检测算法步骤如下:

  1. 维护一个当前访问路径的节点列表 L,初始为空。
  2. 从任意未访问节点开始深度优先遍历。
  3. 访问一个节点时,将其加入列表 L
  4. 沿着未标记的边访问下一个节点。
  5. 如果某个节点的所有边都已探索完毕,则将其从列表 L 中移除并回溯。
  6. 如果在尝试将一个节点加入列表 L 时,发现它已经在列表中,则说明发现了一个环,即检测到死锁。

恢复:检测到死锁后,需要打破循环等待。有以下几种方法:

  • 终止任务:强制终止环中的一个或多个任务,释放其占用的资源。需要谨慎选择终止哪个任务以最小化影响。
  • 任务回滚:将环中的一个任务回滚到之前的某个安全检查点,释放其新获取的资源,然后重新执行。
  • 资源抢占:强制从一个任务手中剥夺资源给另一个任务使用。这非常复杂,可能破坏程序逻辑,实践中很少使用。

一个关键问题是:检测算法应该多久运行一次?运行太频繁会影响性能,运行太少则死锁可能长时间未被发现。这需要在开销和响应速度之间取得平衡。


本节课中我们一起学习了死锁的基本概念、其发生的四个必要条件,并初步探讨了“鸵鸟算法”和“检测与恢复”这两种应对策略。下一节课,我们将继续学习另外两种更积极的策略:死锁避免和死锁预防。

017:死锁(第二部分)

在本节课中,我们将继续并完成关于死锁的讨论。我们将深入探讨处理死锁的不同策略,包括动态避免、预防以及一些实际应用中的技术,如无锁数据结构和事务内存。


概述回顾

上一节我们介绍了死锁的基本概念和四个必要条件。本节中,我们将看看如何通过不同的策略来应对死锁问题。

死锁是指在一个协作系统中的所有任务都被阻塞,无法继续执行。与段错误等每次执行都可能出现的错误不同,死锁和竞态条件非常难以通过测试发现,因为它们可能只在极罕见的情况下发生。因此,处理死锁通常需要在设计阶段就加以考虑。

处理死锁的策略

处理死锁主要有四种策略:鸵鸟算法(忽略)、检测与恢复、动态避免以及预防。上一节我们介绍了前两种,本节我们将重点讨论后两种。

动态避免 🛡️

动态避免策略的核心思想是,系统在分配资源时,会预先判断此次分配是否会导致系统进入“不安全状态”。只有在能保证系统始终处于“安全状态”的前提下,才会批准资源的请求。

这里引入一个关键算法:银行家算法。该算法要求每个任务预先声明其在整个执行过程中可能需要的最大资源数量。系统在收到资源请求时,会模拟分配后的状态,并检查是否存在一个能让所有任务都顺利完成执行的资源分配序列。如果存在,则批准请求;否则,让请求任务等待。

以下是银行家算法的一个简化示例:

假设系统有10个相同的资源,以及三个任务A、B、C。它们声明的最大需求分别是:

  • 任务A:最多需要9个。
  • 任务B:最多需要4个。
  • 任务C:最多需要7个。

当前分配情况是:A有3个,B有2个,C有2个,因此系统还剩3个可用资源。

现在,任务B请求2个资源。系统会模拟分配:

  1. 假设批准请求:B拥有4个(达到其最大需求),系统剩余1个可用资源。
  2. 考虑最坏情况:A和C同时请求其剩余的最大需求(A需要6个,C需要5个)。当前资源不足,A和C等待。
  3. 由于B已拥有其所需全部资源,它可以运行完毕,然后释放其拥有的4个资源。
  4. 系统现在有5个可用资源,可以满足C的请求(5个)。C运行完毕,释放7个资源。
  5. 系统现在有7个可用资源,可以满足A的请求(6个)。A运行完毕。

由于存在这样一个让所有任务都能完成的序列,系统处于安全状态,因此可以批准B的请求。

然而,如果任务A此时请求1个资源,模拟分配后会进入一个可能导致死锁的状态,因此系统会拒绝A的请求,让其等待。

动态避免的缺点是,很难准确预知任务的最大资源需求,且算法本身开销较大。在实际系统中,它主要用于数据库、银行系统等对正确性要求极高的特定领域。

预防 🔧

预防策略旨在通过系统设计,从根本上破坏死锁四个必要条件中的至少一个,使死锁无法发生。这通常需要改变应用程序或同步机制的代码。

以下是针对每个条件的预防方法:

1. 破坏“互斥与有限资源”条件

  • 增加资源数量:例如,在哲学家就餐问题中,增加一把餐叉即可预防死锁。
  • 虚拟化资源:不让任务直接持有物理资源。例如,打印任务提交到打印队列,由队列调度,避免了进程直接锁定打印机。
  • 使用无锁数据结构:设计无需加锁即可安全并发访问的数据结构,从根本上消除对互斥锁的需求。

2. 破坏“持有并等待”条件

  • 两阶段锁:任务在第一阶段尝试获取所需的所有资源,如果成功则进入第二阶段执行操作,最后释放所有资源。如果获取任何资源失败,则释放所有已获资源并重试。这避免了“持有一个,等待另一个”的情况。但可能导致饥饿和代码复杂度增加。

3. 破坏“不可抢占”条件

  • 让资源可以被系统强制收回。这通常很难实现,因为像锁这样的抽象概念无法被安全地“夺走”。仅适用于可以保存和恢复状态的特殊资源,如内存页(可被换出到磁盘)。

4. 破坏“循环等待”条件

  • 强制顺序获取资源:为所有资源赋予一个全局顺序(编号),要求所有任务必须严格按照资源编号递增的顺序申请资源。例如,在哲学家就餐问题中,让大多数哲学家先拿右手叉,但让最后一位哲学家先拿左手叉,这样就破坏了循环等待链。

现实中的策略与高级概念

活锁 💃

活锁与死锁类似,任务都无法取得进展,但区别在于任务并未阻塞,而是在不断执行某种“避让”逻辑,结果却相互阻碍,无法前进。就像两个人在走廊迎面相遇,同时向同一侧避让,结果仍然面对面,持续重复这个过程。活锁很难检测,因为任务看起来仍在“忙碌”。

实际系统中的应用

  • 鸵鸟算法:在许多普通应用程序中最为常见,即忽略死锁问题。
  • 动态避免实例:在某些系统调用中可见,如lockf(文件锁)和pthread_mutex_lock(当线程试图重复锁定同一个已拥有的互斥锁时,会返回EDEADLK错误)。
  • 预防策略:需要程序员精心设计代码,是编写高可靠性并发程序的关键。

无锁数据结构示例 🧱

无锁数据结构利用处理器的原子指令(如比较并交换)来实现并发安全,而无需使用互斥锁。

以无锁栈的push操作为例,其核心伪代码如下:

void stack_push(Stack* s, Data data) {
    Node* n = new Node(data);
    do {
        n->next = s->top; // 读取当前栈顶
    } while (!compare_and_swap(&(s->top), n->next, n)); // 尝试将栈顶更新为新节点
}

compare_and_swap原子指令检查s->top是否仍等于我们之前读到的n->next,如果是,则将其更新为新节点n;否则失败重试。这样,即使多个线程同时执行push,最终栈的状态也是一致的。

事务内存 💾

事务内存是一种乐观并发控制机制。任务将一段临界区代码声明为一个“事务”。事务执行期间,所有修改先保存在本地缓冲区。提交时,系统原子性地检查事务涉及的内存区域在事务执行期间是否被其他任务修改过。如果没有冲突,则提交所有修改;如果有冲突,则事务中止并重试。这类似于数据库的事务,避免了使用锁,提高了并发性。


总结

本节课我们一起学习了处理死锁的后两种核心策略:动态避免和预防。动态避免(如银行家算法)试图在资源分配时做出安全决策,而预防则通过破坏死锁的必要条件来根治问题。我们还了解了活锁的概念,并探讨了在实际编程中应用预防思想的先进技术,如无锁数据结构和事务内存。理解这些策略有助于我们设计出更健壮、更高效的并发系统。

018:存储系统(第一部分)📚

在本节课中,我们将开始学习一个全新的主题:存储系统。这是本课程的第三大主题。我们首先讨论了C语言和系统编程,然后探讨了同步与死锁,现在我们将聚焦于存储和文件系统。最后一个主题将是虚拟内存。

概述:存储层次结构

你可能从之前的课程中了解过内存层次结构的金字塔模型。信息处理始于CPU寄存器,CPU通过寄存器执行计算。CPU也能通过指令访问主内存。缓存的存在是为了加速CPU与主内存之间的通信,但对CPU是透明的。这个层次结构中的信息是易失性的,断电后会丢失,且容量较小、成本较高。

为了存储大量数据,我们需要扩展这个金字塔,引入二级存储,如闪存、磁盘或云存储。二级存储的容量可达TB级别,成本低廉,但访问速度远慢于主内存。此外,CPU不能像访问内存地址那样直接访问二级存储,必须通过控制器以数据块为单位进行读写。二级存储的优势在于其持久性,断电后数据不会丢失。

接下来,我们将讨论易失性内存技术和几种二级存储技术。

易失性内存技术

静态随机存取存储器

静态随机存取存储器是一种用于构建缓存的技术。一个SRAM单元由六个晶体管组成,用于存储一个比特。这种设计成本较高,但一旦写入数据,只要不断电,数据就会一直保持。其访问速度极快,非常适合用作缓存。

动态随机存取存储器

动态随机存取存储器用于主内存。一个DRAM单元仅由一个晶体管和一个电容器组成,因此可以在相同面积内集成更多比特。但电容器会漏电,导致存储的电荷随时间流失,因此需要定期刷新数据以保持信息。其访问速度不如SRAM快,但密度更高,成本更低,适合作为主内存。

持久性存储技术

上一节我们介绍了易失性内存,本节中我们来看看用于二级存储的持久性技术。

磁盘

磁盘是一种常见的二级存储设备。它通过磁头在旋转的金属盘片上读写磁化信息来存储数据。盘片表面被磁化为不同方向,用以表示0和1。这种技术存储密度极高,但需要机械运动来读取数据。CPU不能直接寻址磁盘上的数据,必须通过控制器以数据块为单位进行访问。其延迟在毫秒级,吞吐量也较低,但成本便宜,广泛用于台式机和数据中心。

固态硬盘

固态硬盘是另一种二级存储技术。它同样基于晶体管,但特殊之处在于,即使断电,晶体管中存储的信息也不会丢失,因此可以作为持久性存储。我们将在下一讲详细探讨闪存的工作原理。SSD可以非常密集地封装比特,其延迟和吞吐量性能远优于磁盘,但成本也更高。由于没有活动部件,它非常适合移动设备,如智能手机、笔记本电脑和相机。

在接下来的内容中,我们将深入探讨磁盘和闪存这两种持久性技术,分析它们的工作原理及优缺点。

深入解析磁盘结构 💽

现在,让我们深入了解磁盘的内部结构。

一个磁盘由多个盘片组成,盘片通常由玻璃、陶瓷或铝制成,表面覆盖一层薄薄的金属膜用于记录磁信息。每个盘片有上下两个表面。所有盘片都固定在一个由电机驱动的中心轴上,并以每分钟数千转的速度高速旋转。

每个盘面都有一个磁头,用于读写数据。磁头非常接近盘面,几乎但不接触,其间有一层空气垫。磁头安装在摇臂上,所有摇臂连接成一个摇臂组件,并同步移动。

盘面上的数据被组织成同心圆轨道,轨道又划分为扇区,扇区是磁盘上最小的数据单元。不同盘面上相同半径的轨道组成一个柱面。

磁盘的基本原理在过去近70年里没有根本性改变,但存储密度和容量已发生巨大变化。

轨道与扇区

每个盘面上有大量极细的轨道。典型的2.5英寸磁盘盘面可容纳约10万条轨道。为防止磁信息干扰,轨道间留有间隙。

由于内外轨道周长不同,存在两种处理方式:

  • 统一扇区划分:内外轨道划分相同数量的扇区,导致内外存储密度不同。这是旧硬盘采用的方式。
  • 区位记录:保持扇区物理长度相同,因此外轨道能容纳更多扇区,存储更多信息。这是现代硬盘采用的方式。

区位记录带来了速度问题:由于磁盘转速恒定,磁头在外轨道时,数据流经速度更快,带宽更高。

扇区结构

一个扇区通常分为三部分:

  1. 头部:包含扇区ID、坏扇区标记等元数据。
  2. 数据区:存储用户数据。传统上是512字节,现代硬盘多为4096字节。
  3. 尾部:包含纠错码,用于检测和修复数据错误。

寻址与编号

早期,系统需要指定柱面、磁头和扇区号来访问磁盘。现代方式则是线性逻辑块寻址,每个扇区有一个从0到N的编号,系统只需请求逻辑块号,由磁盘自身将其映射到物理位置。

为了提高顺序读取效率,相邻轨道的扇区编号会进行偏移。这样,当磁头读完一个轨道移动到下一个时,磁盘已旋转到合适位置,磁头能直接落到下一个轨道的起始扇区上。

坏扇区处理

扇区可能损坏无法存储数据。处理方式有两种:

  • 备用扇区替换:每个轨道预留备用扇区。坏扇区的数据被重映射到一个备用扇区,对用户透明,但可能破坏数据的物理顺序性。
  • 扇区滑动:将坏扇区之后的所有扇区向前滑动,逻辑上填补坏扇区的位置,从而保持顺序读取的效率。

磁盘访问性能分析 ⏱️

理解了磁盘结构后,我们来看看如何访问磁盘数据及其性能。

当操作系统请求读取某个逻辑扇区时,磁盘需要完成三个步骤:

  1. 寻道时间:移动摇臂,将磁头定位到包含目标扇区的轨道上方。
  2. 旋转延迟:等待磁盘旋转,使目标扇区转到磁头正下方。
  3. 传输时间:将扇区数据从盘面读入磁盘内部缓冲区,再传输到主机内存。

因此,访问一个扇区的总时间是这三者之和。

那么,这三者中哪个最慢、是性能瓶颈呢?让我们逐一分析。

寻道时间分析

寻道时间取决于磁头需要移动的距离。

  • 最大寻道时间:磁头从最外圈移动到最内圈,通常为10-20毫秒。
  • 最小寻道时间:磁头仅移动到相邻轨道,约1毫秒。
  • 平均寻道时间:统计上,平均移动距离约为总行程的三分之一。

旋转延迟分析

旋转延迟取决于磁盘转速。平均而言,磁头到达正确轨道后,需要等待半圈旋转才能使目标扇区到来。对于7200转/分钟的磁盘,旋转一周需8.3毫秒,平均旋转延迟约为4.15毫秒。

传输时间分析

传输时间分为两部分:

  1. 盘面传输时间:数据从盘面读入磁盘内部缓冲区。速度取决于磁头所在轨道位置(外圈更快)。
  2. 主机传输时间:数据从磁盘缓冲区传输到主机内存。速度取决于接口类型(如SATA、USB)。

性能实例计算 🧮

让我们通过一个具体例子来感受磁盘性能。

假设有一个硬盘参数如下:

  • 2个盘片(4个磁头)
  • 容量:未指定
  • 转速:7200 RPM
  • 平均读寻道时间:10.5毫秒
  • 平均写寻道时间:12毫秒
  • 最小寻道时间:1毫秒
  • 盘面传输速率:54 - 128 MB/秒(内外圈不同)
  • 主机传输速率:高(如SATA接口)
  • 设备缓冲区:16 MB

场景一:随机访问
操作系统发送500个读取随机分布扇区的请求。

  • 每次请求需支付:平均寻道时间 + 平均旋转延迟 + 传输一个扇区的时间。
  • 传输一个512字节扇区,按最低54 MB/s速率计算,约需9.5微秒。
  • 总时间 = 500 * (10.5ms + 4.15ms + 9.5μs) ≈ 7秒

可以看到,寻道时间是主要的性能瓶颈。

场景二:顺序访问
操作系统请求读取500个连续的扇区。

  • 只需支付一次寻道时间和一次旋转延迟。
  • 然后连续读取500个扇区。
  • 总时间 ≈ 寻道时间 + 旋转延迟 + 500 * 扇区传输时间。
  • 按外圈最高速率计算,总时间 ≈ 16毫秒
  • 按内圈最低速率计算,总时间 ≈ 19毫秒

对比可知,顺序访问的性能(毫秒级)比随机访问(秒级)高出数个数量级。

总结

本节课中,我们一起学习了存储系统的基础知识。我们首先回顾了内存层次结构,并指出需要大容量、持久且廉价的二级存储。接着,我们介绍了易失性内存技术SRAM和DRAM,以及持久性存储技术磁盘和SSD。

我们深入剖析了磁盘的机械结构,包括盘片、磁头、轨道、扇区和柱面,并解释了扇区结构、寻址方式以及坏扇区处理机制。

最后,我们分析了磁盘访问的三个关键时间:寻道时间、旋转延迟和传输时间。通过实例计算,我们清晰地看到,对于随机I/O,寻道时间是主要性能瓶颈,而顺序I/O能极大提升性能。这引出了一个重要问题:如何通过调度算法来减少寻道时间,从而提升磁盘整体性能。我们将在下一讲中探讨这些磁盘调度算法。

019:存储技术(第二部分)

在本节课中,我们将继续探讨存储技术。我们将完成关于机械硬盘的讨论,然后深入了解闪存的工作原理。我们将学习如何通过调度算法优化硬盘访问,并比较不同存储技术的性能差异。

回顾:存储技术概览

上一节我们介绍了不同的存储技术。SRAM(静态随机存取存储器)主要用于片上缓存,完全由晶体管构成。DRAM(动态随机存取存储器)用于主内存,由晶体管和电容器组成,需要定期刷新。我们还简要介绍了二级存储技术,如机械硬盘和闪存,本节课将对此进行深入探讨。

机械硬盘调度算法

上一节我们介绍了机械硬盘的访问时间由寻道时间、旋转延迟和传输时间三部分组成。其中,寻道时间和旋转延迟是主要开销。为了最小化这些开销,操作系统可以使用磁盘调度算法来重新排列I/O请求的顺序。

以下是几种常见的磁盘调度算法:

先来先服务(FCFS)

这是最简单的算法,按照请求到达的顺序进行处理。其优点是公平,但缺点也很明显:磁头可能会在盘片上来回大幅度移动(称为“摆动”),导致总寻道距离很长。

示例:假设磁头起始位置在53号磁道,请求队列为 [98, 183, 37, 122, 14, 124, 65, 67]。FCFS算法的磁头移动总距离为640个磁道。

最短寻道时间优先(SSTF)

此算法总是选择距离当前磁头位置最近的请求进行服务。这能显著减少磁头移动距离。

示例:使用相同的起始位置和请求队列,SSTF算法的磁头移动总距离降至236个磁道。然而,它存在“饥饿”问题:如果不断有新的请求到达磁头当前位置附近,那么远处的请求可能永远得不到服务。

电梯算法(SCAN)

为了解决饥饿问题,SCAN算法让磁头只朝一个方向移动,服务沿途的所有请求,直到到达该方向的尽头,然后调转方向继续服务。这类似于电梯的运行方式。

示例:SCAN算法进一步将总移动距离减少到208个磁道。但它对中间区域的请求服务更频繁,不够公平。

循环扫描算法(C-SCAN)

C-SCAN是SCAN的变体,旨在提高公平性。磁头只朝一个方向移动并服务请求,到达尽头后,立即快速返回起点(不服务请求),然后重新开始循环。

示例:C-SCAN算法的总移动距离为236个磁道(包括快速返回的行程)。一个优化版本称为C-LOOK,它只移动到最后一个请求的位置就返回,而不是移动到物理尽头,从而节省了时间。

其他算法变体

  • R-C-SCAN:考虑旋转延迟,可能选择先服务即将旋转到磁头下的请求,即使它不在最近磁道上。
  • F-SCAN:使用两个队列。磁头服务当前队列时,新到达的请求放入另一个队列,待当前队列清空后再交换。这防止了新请求“插队”影响旧请求。
  • N-SCAN:F-SCAN的多队列扩展版本。

算法选择策略

没有一种算法在所有情况下都是最优的。最佳选择取决于工作负载:

  • 低负载时,使用简单的FCFS即可。
  • 负载增加时,切换到SSTF以提高效率。
  • 负载极高,可能出现饥饿时,应切换到SCAN或C-SCAN等电梯算法以保证公平性。

调度效果示例

让我们量化调度算法的收益。回顾上节课的例子:服务500个随机读请求。

  • 使用FCFS(无调度)时,总时间超过7秒。
  • 使用C-LOOK算法后,我们可以估算平均寻道距离。假设请求均匀分布,磁头从一端移动到另一端服务所有请求,平均寻道距离很短。计算后,总服务时间降至约3.3秒,性能提升了一倍以上。

闪存技术

现在,让我们转向另一种重要的存储技术:闪存。与机械硬盘不同,闪存没有活动部件,完全由晶体管构成。这使得它的随机访问性能更好、功耗更低、抗物理冲击能力更强,但成本也更高。

闪存的工作原理

闪存的核心是浮栅晶体管。在标准晶体管(控制栅、源极、漏极)中,浮栅被绝缘体包围,可以捕获或释放电子。

存储原理

  1. 写入(编程):向控制栅施加高电压,使电子隧穿进入浮栅,改变晶体管的阈值电压。
  2. 读取:向控制栅施加一个中间电压。根据浮栅是否带电(即阈值电压不同),电流能否流过晶体管,从而判断存储的是0还是1。
  3. 擦除:施加反向高电压,将电子从浮栅中移除,使单元恢复初始状态。

现代闪存使用多级单元(MLC)技术,通过精确控制浮栅电荷量,在一个晶体管中存储多个比特(如2比特,表示00, 01, 10, 11)。

闪存的架构

闪存按层次组织:

  • :读写的基本单位,通常为4KB。
  • :由多个页组成(如128页),是擦除的基本单位。
  • 平面:包含多个块。
  • 芯片:包含多个平面,不同平面可以并行操作以提高吞吐量。

重要特性

  1. 读操作很快(约10微秒)。
  2. 写操作较慢(约100微秒),且只能写入空页。
  3. 擦除操作很慢(约1-3毫秒),且必须以块为单位进行。

闪存转换层与写入放大

由于不能覆盖写入,直接管理闪存效率很低。例如,要更新一个页,需要将整个块读入内存,擦除该块,再写回所有旧页和新页。这称为“写入放大”,性能极差。

解决方案是引入闪存转换层。FTL在逻辑地址(操作系统看到的)和物理地址(闪存实际的)之间建立动态映射。

工作流程

  1. 操作系统请求写入逻辑页A。
  2. FTL将数据写入一个空的物理页X。
  3. FTL更新映射表,将逻辑页A指向物理页X。原物理页A被标记为无效。
  4. 当块中无效页积累到一定程度,FTL会启动垃圾回收:将有效页复制到新块,然后擦除旧块以供重用。

这种方法将昂贵的块擦除成本分摊到多次写入中,大大提升了性能。

闪存的挑战与优化

闪存面临磨损和读干扰等问题:

  • 磨损:每个块有擦除次数限制(如1万次)。
  • 读干扰:读取可能影响相邻单元的电荷。

优化技术

  • 纠错码:检测和纠正比特错误。
  • 磨损均衡:FTL通过动态映射,确保所有块被均匀使用,避免部分块过早磨损。
  • 预留空间:固态硬盘的实际物理容量大于标称容量,多出的部分用于替换坏块和进行磨损均衡操作。

性能对比

让我们再次使用500个随机读请求的例子来对比性能。使用一个典型的固态硬盘规格:

  • 随机读取延迟:约25微秒
  • 计算:500 * 25 微秒 = 12.5 毫秒

这与机械硬盘的7秒多形成了天壤之别,这也是固态硬盘速度远超机械硬盘的原因。由于没有机械运动,固态硬盘通常不需要复杂的I/O调度算法,FCFS顺序处理即可。

总结

本节课我们一起学习了存储技术的后半部分。我们深入探讨了机械硬盘的磁盘调度算法,如FCFS、SSTF和SCAN,了解了它们如何通过减少寻道时间来提升性能。接着,我们转向闪存技术,学习了其基于浮栅晶体管的工作原理、独特的“写前擦除”特性以及通过闪存转换层(FTL)管理写入和磨损均衡的关键机制。最后,我们通过性能对比,直观地看到了固态硬盘在随机访问上的巨大优势。下一节课,我们将探讨操作系统如何利用这些存储设备来组织和管理文件系统。

020:文件系统抽象 📂

在本节课中,我们将学习文件系统的基本概念。文件系统是组织和管理大容量存储设备(如硬盘、SSD)上数据的核心抽象。我们将探讨其需求、核心组件(如文件、目录、路径)以及操作系统如何通过虚拟文件系统(VFS)来统一管理不同的存储设备和格式。


文件系统的需求 📋

上一节我们讨论了大容量存储设备。本节中,我们来看看我们对这些设备上数据的组织方式有何期望。

首先,大容量存储设备(如SSD或硬盘)能存储海量数据(例如TB级别)。我们需要一种简单的方法来查找数据。这就像一个拥有巨大空间的图书馆,需要确保读者能快速找到特定书籍,而不是花费数周时间翻阅所有藏书。

其次,典型的计算机会并发运行多个进程。这些进程可能同时访问存储设备上的数据,因此文件系统需要支持这种并发访问。

第三,在一些计算机(如服务器)上,可能有多个用户同时或在不同时间使用计算机和存储空间。我们需要确保这种空间共享是受控的。

第四,我们需要良好的性能。大容量存储设备速度相对较慢,我们需要策略来应对这种固有的延迟。

最后,我们需要良好的可靠性。大容量存储设备的目的是提供非易失性、持久化的存储。数据需要在计算机关机、重启甚至操作系统崩溃时得以保存。理想情况下,即使一个正在访问存储数据的进程意外终止,也不应导致数据丢失。

文件系统正是为了满足这些需求而设计的抽象。它将数据组织成文件和目录,提供访问控制、性能优化和一定的容错能力。


核心概念:文件与目录 📄

上一节我们列出了文件系统的需求。本节中,我们来深入了解其核心构建块:文件和目录。

文件

文件是一个抽象概念,它将逻辑上相关联的数据(如一个文本文档或一个MP3文件的所有字节)集合在一起,形成一个逻辑存储单元。存储设备上用户可交互的每一个字节都属于某个文件。

一个文件由两部分组成:

  1. 元数据:描述文件本身的属性。例如:
    • 文件大小
    • 创建时间、最后修改时间
    • 文件所有者
    • 访问权限
    • 文件内容在物理设备上的位置
  2. 内容:文件的实际数据,即一系列字节。

值得注意的是,文件本身并不“知道”自己的名字。文件名是由目录提供的。

目录

目录是另一个关键抽象,它为文件提供名称,并用于组织文件和其他目录,形成复杂的层次结构。

本质上,目录包含一个文件名到文件本身的映射列表。当你想通过某个文件名打开文件时,目录会告诉你该文件对应的位置(如文件编号),然后通过该位置访问元数据,最终找到存储在硬盘或SSD特定扇区上的实际数据。

这类似于互联网上的DNS服务器:你输入“google.com”,DNS服务器会告诉你对应的IP地址。


文件名、路径与链接 🛤️

上一节我们介绍了文件和目录的基本概念。本节中,我们来看看如何定位和引用它们,包括文件名约定、路径以及两种重要的链接类型。

文件名约定

文件名的具体规则因文件系统而异。历史上,Windows系统传统上是大小写不敏感的,而Linux系统是大小写敏感的。文件名长度也随时间变化,早期限制较严(如FAT文件系统为11个字符),现在通常可以很长(如最多255个字符)。

文件扩展名(如 .txt.mp3)过去是独立于文件名的一部分,并严格决定文件类型。现在,扩展名只是文件名的一部分,作为给操作系统的提示,以便在双击文件时知道用哪个程序打开它。你可以随意更改扩展名,但这不会改变文件的真实内容。

那么操作系统如何识别可执行文件呢?
对于二进制可执行文件(如C语言编译后的程序),操作系统会检查文件开头的几个字节,即“魔数”。例如,ELF格式的可执行文件开头就是“ELF”字符。
对于脚本文件(如Shell脚本),则依赖Shebang#!)行。Shebang位于脚本第一行,指定了解释器的路径。例如 #!/bin/bash 告诉系统用Bash解释器来运行此脚本。

路径

路径用于定位文件或目录。有两种类型:

  • 绝对路径:从根目录(在Unix系统中以 / 开头)开始的完整路径。
  • 相对路径:从进程的当前工作目录开始的路径。每个进程在PCB中都有一个当前工作目录,进程可以通过 chdir 函数来改变它。相对路径中,. 代表当前目录,.. 代表父目录。

硬链接

硬链接允许多个不同的文件名指向同一个文件。两个目录中的条目可以映射到相同的文件编号(即相同的元数据和数据)。

这带来一个复杂性:如果允许对目录创建硬链接,就可能创建出循环引用,使得遍历文件系统变得极其困难,因为无法区分“原始”链接和“副本”链接。

因此,为了简化,硬链接通常被禁止用于目录。所以,包含硬链接的文件系统结构不是一个树,而是一个有向无环图

软链接(符号链接)

软链接与硬链接不同。软链接本身是一个独立的文件,它的文件内容存储的是它所要指向的目标文件的路径

软链接在元数据中会被标记为链接类型。因此,遍历文件系统的程序可以识别并选择忽略它们,或者通过限制跟随的软链接数量(例如,POSIX标准通常限制为8次)来避免由软链接造成的循环问题。


卷、分区与挂载 💽

上一节我们讨论了如何引用文件。本节中,我们来看看操作系统如何管理物理存储设备本身,包括卷、分区和挂载的概念。

卷与分区

一个通常指一个格式化了、可用于存储文件和目录的存储空间。它可以是一个完整的物理设备(如一块1TB的SSD),也可以是设备的一个分区

分区是将一个物理存储设备划分为多个逻辑上独立的部分。例如,可以将一块1TB的硬盘分成两个512GB的分区,并分别格式化为NTFS和FAT文件系统。每个分区就像一个独立的“房间”,拥有自己的文件组织。

在传统硬盘上,第一个扇区(扇区0)称为主引导记录,它包含分区表。早期MBR支持最多4个主分区,后来通过扩展分区支持更多。现代系统使用GPT分区表,可以支持多达128个分区。

挂载:统一文件系统视图

当计算机有多个卷(来自不同磁盘或分区)时,操作系统有两种主要方式呈现它们:

  1. Windows方式:为每个卷分配一个独立的驱动器字母(如 C:D:)。各卷在文件浏览器中显示为独立的实体。
  2. Unix/Linux方式:通过虚拟文件系统将所有卷统一到一个单一的目录树中。这个过程称为挂载

例如,在CSIF计算机系统中:

  • 硬盘的第二个分区被挂载到根目录 /
  • 硬盘的第一个分区(引导分区)被挂载到 /boot
  • 存储用户文件的网络服务器被挂载到 /home
    这样,所有存储空间都嵌套在同一个目录树下,用户感觉是在访问一个统一的文件系统。

系统通过配置文件(如Linux的 /etc/fstab)来知道如何挂载各个卷。该文件指定了硬件设备、挂载点目录和文件系统格式。


文件系统架构与API 🏗️

上一节我们了解了物理存储如何被组织成卷并呈现给用户。本节中,我们从软件层面看看文件系统是如何工作的,以及应用程序如何与之交互。

软件层次

访问存储数据涉及多个软件层次:

  1. 应用程序/进程:调用如 openreadwrite 等系统调用。
  2. 内核与虚拟文件系统:系统调用进入内核,由虚拟文件系统子系统处理。VFS提供了一个统一的文件接口。
  3. 文件系统驱动:根据文件所在位置和卷的格式(如FAT、EXT4、NTFS),VFS将请求转发给相应的后端驱动。
  4. 设备驱动:最终由知道如何与具体硬件(如SATA硬盘控制器、NVMe SSD控制器)通信的驱动来访问物理扇区或页面。

此外,有些“文件”(如Linux中的 /proc/cpuinfo)并非真实存储在磁盘上,而是由内核动态生成,用于提供系统信息。

文件操作API

进程通过一组API与文件交互:

创建与删除文件

  • create(path, mode):创建一个具有指定名称和权限的空文件(包括元数据和目录映射)。现代编程中更常用 open(path, O_CREAT, mode) 来同时创建和打开文件。
  • link(oldpath, newpath):创建一个硬链接,即一个新的文件名指向一个已存在的文件(不创建新数据,只增加一个目录映射)。
  • unlink(path):删除一个文件名到文件的映射。只有当文件的最后一个硬链接被删除,即没有任何目录条目指向它时,文件的元数据和数据才会被真正从磁盘上移除。

打开文件
open(path, flags):打开一个已存在的文件。系统会检查权限,如果成功则返回一个文件描述符——这是一个用于后续读写操作的句柄。

打开文件时,内核内部会进行复杂的管理:

  • 每个进程有一个文件描述符表
  • 内核维护一个打开文件表,每个表项包含当前文件偏移量等信息。
  • 打开文件表项再指向文件元数据
  • 关键点:
    • 同一个进程多次 open 同一个文件,会创建不同的打开文件表项,拥有独立的偏移量。
    • 使用 dup2 复制文件描述符,会共享同一个打开文件表项,从而共享偏移量。
    • 子进程会继承父进程的文件描述符,指向相同的打开文件表项。

访问文件数据
获得文件描述符后,可以:

  • read(fd, buffer, size) / write(fd, buffer, size):进行顺序读写。
  • lseek(fd, offset, whence):改变当前文件偏移量,支持随机访问。
  • mmap(...):一个更强大的函数。它将文件内容直接映射到进程的地址空间。之后,进程可以像访问内存数组一样访问和修改文件内容,这为随机访问提供了极大的便利。

总结 📝

本节课中,我们一起学习了文件系统抽象的核心内容。

我们首先探讨了文件系统需要满足的需求:管理海量数据、支持并发访问、提供访问控制、优化性能以及确保可靠性。接着,我们深入分析了文件系统的核心构建块:文件(包含元数据和内容)和目录(提供文件名映射和组织结构)。

我们学习了如何通过路径定位文件,并区分了硬链接(多个文件名指向同一文件数据)和软链接(存储目标路径的独立文件)。然后,我们了解了物理存储如何被划分为分区,以及操作系统如何通过挂载将它们统一到一个虚拟文件系统视图中。

最后,我们从软件架构角度审视了文件系统,包括从应用程序到硬件驱动的各个层次,并概述了用于创建、打开、读写文件的核心API及其内部工作原理(如文件描述符、打开文件表)。

下一节课,我们将开始探讨文件系统的具体实现。

操作系统与系统编程:21:文件系统实现(第一部分)

在本节课中,我们将开始学习文件系统的具体实现。我们将探讨文件系统如何组织目录和文件,以及设计文件系统时需要考虑的各种目标和约束。


上一节我们回顾了文件系统的基本概念。本节中,我们来看看设计文件系统时希望达成的目标。

设计文件系统时,主要需要考虑以下几个目标:

  • 性能:存储设备(如硬盘)存在物理限制。为了获得最佳性能,尤其是对于机械硬盘,我们需要利用空间局部性,即尽量将相关联的数据(如一个文件的所有数据块)连续地存放在存储设备上。
  • 灵活性:文件系统需要支持各种类型、大小和访问模式的文件,包括小文件与大文件、顺序访问与随机访问的文件、频繁访问与极少访问的文件,以及存在时间极短或极长的文件。
  • 开销:除了用户可见的目录和文件,文件系统自身还需要维护一些内部数据结构(元数据)来进行组织管理,这部分开销应尽可能小。
  • 可靠性:文件系统应尽可能抵抗硬件故障或系统崩溃,确保数据的长期安全。

在深入实现细节之前,我们需要理解文件系统所面临的工作负载类型。以下是几个关键观察:

以下是几个典型的工作负载示例:

  • 文件大小分布:在典型的计算机系统中,大多数文件都很小(例如,平均每个可执行文件约35KB)。然而,少数大文件占据了绝大部分的存储空间(例如,一个虚拟机磁盘文件可能占用20GB)。
  • 文件访问模式大多数被访问的文件都很小(例如,启动Chrome浏览器一秒钟内可能打开超过500个文件)。然而,处理大文件时会产生最多的I/O传输(例如,复制一个20GB的文件可能需要62秒)。
  • 访问顺序大多数文件是顺序访问的(从头到尾读取,如配置文件或加载程序代码)。随机访问(如数据库操作)相对较少。
  • 文件大小预知性:有些文件在创建时就知道其大小(如下载文件)。而有些文件在创建时大小未知(如程序运行时的输出重定向文件)。

另一个重要的设计考虑是数据块的大小。存储设备(如硬盘)的基本读写单位是扇区(通常512字节)。操作系统通常会将多个连续的扇区组合成一个逻辑块进行管理。

选择块大小时需要权衡:

  • 大块:管理开销小(需要跟踪的块数少),性能好(一次读取连续扇区效率高)。但可能导致内部碎片,即块未完全利用,浪费空间。
  • 小块:空间利用率高,内部碎片少。但管理开销大(需要跟踪更多块),且文件数据可能分散在不连续的扇区上,性能较差。

一个常见的折中方案是将块大小设置为与内存页大小匹配(通常为4KB)。这样,从磁盘读取一个块时,可以方便地将其载入一个内存页。


综合以上讨论,我们对文件系统的需求有了更清晰的认识:

对于小文件:

  • 较小的块更合适,能更好地匹配文件大小。
  • 相关联的文件(如位于同一目录)的数据应尽量在物理存储上靠近存放,以利用空间局部性。

对于大文件:

  • 较大的块更高效,因为表示整个文件所需的块数更少。
  • 文件数据最好连续存放,以便快速顺序访问。
  • 需要支持高效的随机访问。

核心挑战在于:文件创建时,我们通常无法预知其最终大小、生命周期和访问模式


现在,让我们开始探讨文件系统的具体实现。文件系统的核心任务是:给定一个文件名和一个偏移量,能够定位到存储设备上对应的具体数据块。

为了实现这一目标,我们需要几种关键的数据结构:

  1. 目录结构:用于管理文件名到文件编号(如inode号)的映射。
  2. 索引结构:用于表示一个文件的数据块在物理磁盘上的位置。
  3. 空闲空间管理:用于跟踪磁盘上哪些数据块是可用的,以便为新文件分配空间。

此外,实现这些结构时,需要运用局部性启发式方法来优化文件和目录的组织,以提升访问性能。


首先,我们来看目录的实现。本质上,目录也是一个文件,只不过其内容具有特殊含义:它是一个包含文件名到文件编号映射的列表。

目录文件受到操作系统保护,用户程序只能通过特定的系统调用来查询,而不能直接读写其原始字节,以防止破坏文件系统结构。

文件系统可以采用不同的层次结构组织文件:

  • 单层扁平结构:只有一个根目录,所有文件都位于其中。简单但限制大,不适合多用户或多层次组织。
  • 每用户根目录:每个用户有自己的根目录。解决了用户隔离问题,但每个用户内部仍缺乏层次。
  • 多层树状结构:现代操作系统采用的通用结构。一个根目录下可以创建子目录,形成灵活的树状层次。

目录文件本身也有不同的组织方式:

1. 线性列表
目录文件是一个定长条目(如32字节)的数组。每个条目包含文件名和文件编号等信息。空闲条目有特殊标记。

  • 优点:实现极其简单。
  • 缺点:查找文件需要线性扫描整个目录,速度慢。

2. 链表
目录条目以链表形式组织。每个条目除了文件名和文件编号,还包含一个指向下一个有效条目的指针。

  • 优点:可以跳过空闲条目,略微提升遍历效率。文件创建和删除(管理空闲空间)相对简单。
  • 缺点:查找文件仍需遍历链表,时间复杂度仍是线性的。

3. 树形结构(如B+树)
将文件名哈希成一个键,然后用这个键在树(如B+树)中进行查找,最终在叶子节点找到文件名到文件编号的映射。

  • 优点:对于包含成千上万个文件的目录,查找效率高(对数复杂度)。
  • 缺点:实现和管理复杂。
  • 应用:现代文件系统如Linux的XFS、Windows的NTFS使用此类方法。

解决了文件名到文件编号的映射后,接下来需要解决如何通过文件编号找到文件数据。这就是索引结构的任务。

设计索引结构的目标包括:

  • 数据局部性:尽量让文件的数据块连续存储。
  • 支持随机访问:能够快速跳转到文件任意位置。
  • 支持各种大小的文件
  • 高效管理元数据

以下是几种基本的索引(分配)策略:

1. 连续分配
文件的元数据只需记录两个信息:起始块地址文件长度(块数)。文件的所有数据块在磁盘上连续存放。

  • 优点
    • 实现简单。
    • 顺序访问和随机访问性能极佳。要访问文件中间部分,可以轻松计算出对应块的位置:目标块地址 = 起始块地址 + (偏移量 / 块大小)
  • 缺点
    • 文件增长困难:如果文件需要扩大,但其后没有连续空闲空间,可能需要对整个文件进行耗时的迁移。
    • 外部碎片:磁盘上可能存在许多小的空闲空间碎片,它们总和很大,但因为没有足够大的连续空间,导致无法创建新的大文件。
  • 应用:CD-ROM、DVD等一次性写入的光学介质。

关于碎片化的补充说明

  • 内部碎片:分配给文件的块内部未使用的空间。与块大小选择有关。
  • 外部碎片:磁盘上空闲空间的总量足够,但它们不是连续的,因此无法满足需要连续空间的请求。连续分配策略会受此影响。

2. 链表分配
文件的元数据包含一个指向第一个数据块的指针。每个数据块的末尾,存储着指向下一个数据块的指针。这些数据块在磁盘上可以是分散的。

  • 优点
    • 实现相对简单。
    • 文件可以轻松增长,只需在空闲空间找一个新块,并修改链表指针即可。
    • 没有外部碎片,任何空闲块都可以使用。
  • 缺点
    • 顺序访问性能可能很差:因为数据块物理上不连续,磁头可能需要大幅移动。
    • 不支持高效的随机访问:要访问文件第N块,必须从第一块开始,沿着指针依次读取,直到第N块。
    • 每个数据块需要额外空间存储指针。
  • 应用:MS-DOS的FAT文件系统及本课程项目的基本思想与此类似。

本节课中,我们一起学习了文件系统实现的基础。我们首先明确了设计文件系统的目标(性能、灵活性等),并分析了典型的工作负载特征。然后,我们探讨了目录的几种实现方式(线性列表、链表、树)。最后,我们详细介绍了两种基本的数据块索引结构:连续分配(简单高效但易产生碎片)和链表分配(灵活但随机访问性能差)。

下一节课,我们将继续讨论更高级的索引结构,如FAT、inode等,它们旨在克服这些基本方法的局限性。

022:文件系统实现(第二部分)

在本节课中,我们将继续探讨文件系统的实现。我们将学习更多不同的索引结构,并通过研究微软FAT文件系统的工作原理来开始我们的第一个案例分析。

概述

上一节我们介绍了文件系统的基本概念和几种数据组织方式。本节中,我们将深入了解更复杂的索引分配策略,并分析一个经典文件系统——微软FAT的实现细节。

索引结构回顾与深入

文件系统的核心任务是从文件名和偏移量定位到磁盘上的物理数据。这涉及目录、文件元数据和索引结构。

直接分配法

直接分配法是一种策略,其核心思想是将指向数据块的指针数组直接存储在文件的元数据中。

  • 工作原理:文件元数据包含一个指针数组,数组中的每个元素直接指向文件的一个数据块。
  • 优点:支持真正的随机访问。要访问文件中间的数据,只需计算并访问数组中对应的指针即可。
  • 缺点:文件大小受限于元数据块的大小。为了支持大文件,元数据块本身会变得非常大,这通常不是理想的设计,因为元数据最好保持固定且较小的尺寸。

索引分配法

索引分配法是对直接分配法的改进,旨在解决元数据过大的问题。

  • 工作原理:文件元数据保持固定的小尺寸,只包含一个指针,指向一个专门的索引块。这个索引块是一个普通的数据块,但其内容不是文件数据,而是一个指针数组,数组中的每个指针指向文件的一个实际数据块。
  • 优点:元数据尺寸固定且较小。文件大小理论上受限于一个索引块能容纳的指针数量。
  • 缺点
    1. 文件大小仍受单个索引块容量限制。
    2. 对于小文件存在开销。即使文件只有几个字节,也需要分配一个完整的索引块和一个完整的数据块,造成空间浪费。

多级索引分配法

为了突破单个索引块的容量限制,引入了多级索引。

  • 工作原理:文件元数据指向一个一级索引块。这个一级索引块中的指针不直接指向数据块,而是指向多个二级索引块。每个二级索引块中的指针再指向实际的数据块。这样就形成了一个树状结构。
  • 优点:能够支持非常大的文件。例如,使用4KB块和32位指针,两级索引就能寻址4GB的文件,且最多只需两次磁盘访问(读取一级和二级索引块)就能定位到任意数据块,性能较好。
  • 缺点:对于小文件的开销更大。一个只有几个字节的文件,也需要分配一级索引块、二级索引块和数据块各一个,空间浪费更严重。

以下是多级索引的简化示意:

元数据 -> 一级索引块 -> [二级索引块1, 二级索引块2, ...]
每个二级索引块 -> [数据块1, 数据块2, ...]

混合策略

正如我们所见,从直接分配到多级索引的各种策略各有显著的优缺点。在计算机科学中,最佳策略往往是多种策略的混合。我们将在后续课程中看到,现代文件系统如何根据具体情况综合利用这些策略,以达到灵活性与性能的平衡。

案例分析:微软FAT文件系统

现在,让我们来看一个真实世界中的文件系统实例:微软的FAT(文件分配表)文件系统。它与链接分配法非常相似。

FAT简介

FAT是一个历史悠久的文件系统,诞生于20世纪70年代末,最初用于软盘。它曾是MS-DOS和早期Windows(如3.1、95、98)的主要文件系统。尽管年代久远,FAT因其极简的设计,至今仍在许多嵌入式系统、移动设备和USB闪存驱动器上广泛使用。
FAT的主要版本有:

  • FAT12: 支持最多约4000个文件,卷大小至多32MB。
  • FAT16: 支持最多约65,000个文件,卷大小至多2GB。
  • FAT32: 最流行的版本,支持最多约2.68亿个文件,卷大小至多2TB。
  • exFAT: 现代版本,支持超大卷(128PB)和大量文件。

核心:文件分配表

FAT系统的核心是文件分配表。它实现了链接分配法,但进行了一个关键改进:将“下一个数据块”的指针从数据块本身分离出来,集中存储在这个FAT表中。

  • 工作原理:FAT表是一个大数组,数组的每一项对应磁盘上的一个数据块(在FAT中称为“簇”)。数组的索引号就是簇号。每一项的内容指示了该簇的下一个簇是哪个。特殊的标记(如EOC)表示链的结束。
  • 双重功能
    1. 索引结构:通过遍历FAT表中的链,可以找到属于某个文件的所有簇。
    2. 空闲空间管理:FAT表项值为0表示对应的簇是空闲的,可用于分配。

以下是FAT表示例:

簇号:   0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19 ...
FAT项: [保留][保留] EOC  17  EOC  0   0   EOC  0   10  11  3   EOC  0   0   0   0   0   0   ...

对于文件foo.txt,目录项指出其起始簇是9。查看FAT表:

  • 簇9的项是10,指向下一个簇。
  • 簇10的项是11,指向下一个簇。
  • 簇11的项是3,指向下一个簇。
  • 簇3的项是EOC,表示文件结束。
    因此,foo.txt的数据存储在簇9、10、11、3中。

目录结构

在FAT中,目录被视作一种特殊文件,其内容是由固定长度(通常为32字节)的目录项组成的数组。

每个目录项包含以下信息:

  • 文件名和扩展名(早期为8.3格式)。
  • 文件属性(如是否只读、隐藏、系统文件、目录等)。
  • 创建/修改/访问时间戳
  • 起始簇号(文件第一个数据块的索引)。
  • 文件大小

从目录结构可以注意到FAT的两个特点:

  1. 无权限控制:目录项中没有文件所有者信息,因此FAT不支持基于用户的访问权限控制。
  2. 不支持硬链接:文件名直接与文件数据(通过起始簇号)关联,缺少“文件名->inode号->元数据”这层抽象,因此无法让多个目录项指向同一份文件数据。

磁盘布局

一个FAT格式化的磁盘或分区,其布局通常如下:

  1. 引导扇区/超级块: 存储文件系统参数(如块大小、FAT大小等)。
  2. FAT区域: 存放文件分配表(通常有备份)。
  3. 根目录区域: 存放根目录的目录项。
  4. 数据区域: 存放文件和子目录的实际数据。

性能与碎片整理

FAT的链接分配策略可能导致文件的数据块在物理上不连续(碎片化)。顺序读取一个碎片化的文件会导致磁头频繁移动,降低性能。

为了改善这种情况,需要进行碎片整理。这个过程会重新排列磁盘上的数据块,使每个文件的数据块尽可能连续排列,同时将空闲空间合并,以提高未来分配的连续性。

FAT的优缺点总结

优点:

  • 结构简单: 设计简洁,易于实现和理解。
  • 兼容性极广: 几乎被所有操作系统支持。
  • 无外部碎片: 只要有空闲簇,就可以分配给文件增长。

缺点:

  • 性能局限
    • FAT表内存占用: 对于大容量卷,FAT表本身可能非常大(例如400GB卷的FAT32表约需400MB内存),若无法完全缓存,性能下降。
    • 随机访问慢: 访问文件中间部分需要从头遍历FAT链。
    • 碎片化影响: 碎片化会严重影响顺序读写性能。
  • 功能局限
    • 无高级特性: 不支持硬链接、文件权限控制(ACL)。
    • 容量与文件大小限制: 例如FAT32最大卷为2TB,单个文件最大4GB。
    • 可靠性弱: 缺乏日志等机制,系统意外崩溃可能导致数据损坏或丢失。

总结

本节课我们一起深入学习了文件系统的索引结构,包括直接分配、索引分配和多级索引分配,并分析了它们各自的优缺点。随后,我们以微软FAT文件系统为案例,剖析了一个基于链接分配法的真实文件系统的设计、磁盘布局、工作原理及其显著的优缺点。FAT的简单性与广泛兼容性使其经久不衰,但其在性能、功能和可靠性上的局限性也催生了更现代的文件系统。下一节课,我们将研究另一个经典案例——主要用于UNIX系统的文件系统。

024:地址转换(第一部分)

在本节课中,我们将开始学习本课程的第四部分,也是最后一部分——虚拟内存。我们将探讨为什么需要虚拟内存,回顾早期计算机的内存管理模型,并深入分析实现多道程序设计的初步技术及其局限性。

概述:为什么需要虚拟内存?

理想情况下,计算机只需要一个CPU和一个主存储器。这个主存储器应该速度极快、容量巨大且是非易失性的。然而,现实中的主存储器速度不够快、容量有限且是易失性的。因此,我们需要一个内存层次结构:使用高速缓存来加速CPU与内存的交互,使用大容量持久存储来弥补主存的容量和易失性问题。虚拟内存的核心目标,就是高效地管理这个层次结构,以充分利用有限的主存资源。

上一节我们结束了关于文件系统的讨论,本节中我们来看看内存管理的开端。

从单道程序到多道程序

早期模型:单道程序设计

在早期的计算模型中,内存中一次只能加载一个程序(进程)。操作系统内核占据内存固定的一部分(通常在高端地址),用户进程则从地址0开始加载,并直接使用物理地址。

主要问题

  • CPU浪费:当单个进程因I/O操作被阻塞时,CPU会空闲。
  • 内存浪费:即使进程只占用部分内存,剩余空间也无法被其他进程使用。

这种模型被称为单道程序设计

改进尝试:交换技术

为了实现多任务处理的假象,出现了交换技术。其核心思想是:当一个进程被阻塞时,将其整个内存映像保存到磁盘(换出),然后将另一个进程从磁盘加载到内存(换入)并执行。

主要问题

  • 速度极慢:磁盘I/O速度远慢于内存,进程切换会带来数秒的延迟,严重影响用户体验。

因此,我们需要真正的多道程序设计

多道程序设计的需求

为了实现真正的多道程序设计,我们需要满足以下几个核心要求:

以下是实现多道程序设计必须满足的四个目标:

  1. 透明性:多个进程应能同时存在于内存中,且每个进程都无需感知内存被共享的事实,也无需关心自己被加载到哪个物理地址。
  2. 安全性:必须提供保护机制,防止进程之间或进程与操作系统内核之间相互破坏。
  3. 高效性:多道程序带来的性能开销应尽可能小,不能像交换技术那样引入巨大延迟。
  4. 灵活性:这包含多个方面:
    • 能够运行的进程总大小可以超过物理内存容量。
    • 单个进程的大小可以超过物理内存容量。
    • 进程之间应能共享部分内存。

接下来,我们将探讨实现这些需求的早期技术。

连续内存分配与动态重定位

为了实现多道程序,我们需要解决两个核心问题:重定位保护

  • 重定位:能够将程序加载到内存的任何位置,甚至能在运行时动态移动进程。
  • 保护:确保每个进程只能访问自己的内存区域,不能越界访问其他进程或内核。

一种早期策略是连续内存分配。每个进程被加载到一段连续的物理内存空间中。这需要通过重定位来调整进程内部的地址引用。重定位有两种方法:静态和动态。

静态重定位(不理想)

在静态重定位中,地址转换发生在程序加载到内存时。链接器生成的可执行文件默认从地址0开始。加载器根据进程实际被放置的基地址,一次性修改程序中所有内存地址(如跳转指令的目标地址、数据引用地址)。

公式物理地址 = 程序中的逻辑地址 + 基地址

主要缺点

  • 缺乏灵活性:一旦加载完成,进程就无法被移动到其他内存位置。
  • 缺乏保护:进程可能通过硬编码的地址访问到不属于它的内存区域。

动态重定位(更优方案)

动态重定位将地址转换推迟到运行时。进程运行时产生的地址称为虚拟地址,它认为自己是从0地址开始运行的。硬件在每次内存访问时,实时地将虚拟地址转换为物理地址。

实现这一机制需要两个特殊的CPU寄存器:

  • 基址寄存器:存储进程在物理内存中的起始地址。
  • 界限寄存器:存储进程的最大允许虚拟地址(即进程大小)。

转换与检查过程

  1. CPU发出一个虚拟地址 VA
  2. 硬件比较 VA界限寄存器的值。如果 VA > 界限值,则触发异常(保护错误)。
  3. 如果检查通过,硬件将 VA基址寄存器的值相加,得到物理地址 PA

公式

如果 VA > 界限寄存器值: 触发保护异常
否则: 物理地址 PA = VA + 基址寄存器值

优点

  • 灵活的重定位:只需修改基址寄存器的值,就能在运行时移动整个进程。
  • 基础保护:通过界限寄存器防止进程越界访问。

连续分配策略与碎片问题

在连续内存分配模型中,当需要为进程分配一段连续空间时,操作系统可以采用不同的策略:

以下是几种常见的内存分配策略:

  • 首次适应:从内存起始地址开始扫描,分配第一个足够大的空闲块。
  • 最佳适应:扫描所有空闲块,分配能满足需求且大小最小的那个空闲块,以减少空间浪费。
  • 最差适应:总是分配最大的那个空闲块。

外部碎片与压缩

连续分配会导致外部碎片问题:即内存中散布着许多小的空闲块,它们的总容量可能足够大,但由于不连续,无法分配给一个需要较大连续空间的进程。

解决方案:压缩
操作系统可以暂停所有进程,将已分配的内存块向一端移动,将所有空闲空间合并成一个大的连续块。由于采用了动态重定位(只需更新基址寄存器),移动进程是可行的,但这个过程本身有开销。

内部碎片

内部碎片是指分配给进程的内存块中,未被使用的部分。在连续分配模型中,我们必须在进程运行前就为其栈和堆区域预留空间。如果预留空间大于实际所需,就会在进程内部产生浪费。

动态重定位方案的总结与局限

本节课我们一起学习了实现多道程序设计的初步方案——基于连续内存分配的动态重定位。

优点

  • 实现了基本的重定位和保护。
  • 硬件支持简单(两个寄存器,一个加法器,一个比较器)。
  • 支持通过压缩来缓解外部碎片。

主要局限

  1. 外部碎片:需要连续的物理内存空间。
  2. 内部碎片:需要预先固定栈和堆的大小,可能造成浪费。
  3. 共享内存困难:难以让多个进程方便地共享同一块内存区域。
  4. 扩展性差:栈或堆空间不足时,难以动态扩展。
  5. 所有活动进程必须常驻内存:进程数量受物理内存容量严格限制。
  6. 性能开销:每次内存访问都需要进行地址加法与越界检查。
  7. 保护粒度粗:只能保护整个进程段,无法对段内的代码、数据、堆栈设置不同的访问权限(如代码段只读)。

尽管动态重定位方案让我们向多道程序设计迈进了一步,但其局限性仍然非常明显。在下节课中,我们将探讨更先进、更灵活的策略来克服这些缺点。

025:地址转换(第二部分)

概述

在本节课中,我们将继续学习虚拟内存。我们将回顾上次课介绍的连续内存分配策略及其局限性,然后深入探讨两种更先进的策略:内存分段和分页内存管理。我们将了解它们如何解决连续分配的问题,并提供更灵活、更高效的内存管理功能。

回顾:连续内存分配

上一节我们介绍了连续内存分配策略。其核心思想是,系统中的每个活动进程都被分配一段连续的物理内存空间,其中包含了进程的代码、数据、堆和栈。

通过使用两个寄存器——基址寄存器界限寄存器——我们能够实现动态重定位和内存保护。进程在虚拟地址空间中从地址0开始运行,处理器发出的虚拟地址会加上基址寄存器的值,从而访问到进程在物理内存中的实际位置。界限寄存器则确保进程不会访问超出其分配范围的内存。

这种策略的优点是硬件实现简单,仅需两个寄存器和少量逻辑电路。但它也存在一些明显的缺点。

以下是连续内存分配的主要缺点:

  • 外部碎片:由于进程必须被连续分配,可能导致物理内存中有足够的总空闲空间,但这些空间分散在多个不连续的小块中,无法满足新进程的连续内存需求。
  • 内部碎片:在一个进程的内存段内,堆和栈可能不会完全增长以填满整个分配的空间,导致段内空间浪费。
  • 无法共享内存:进程被严格限制在自己的基址和界限内,无法与其他进程共享内存区域。
  • 缺乏灵活性:一旦进程被加载并分配了内存段,其堆栈大小就固定了,难以动态扩展。如果预留空间不足,无法在运行时轻松调整。
  • 统一的权限控制:整个内存段只能有一套权限(如读、写、执行),无法为代码、数据、堆、栈等不同部分设置更细粒度的权限。

因此,连续内存分配的主要限制在于它将进程使用的全部内存视为一个整体的大块。为了获得更好的灵活性和效率,我们需要更细粒度的管理方法。

内存分段 🧩

为了解决连续内存分配的局限性,我们引入了内存分段策略。这种策略在20世纪80年代初出现,例如在Intel 80286处理器中就被使用。

内存分段的核心思想是:不再将进程的整个地址空间看作一个连续块,而是将其划分为多个逻辑段,例如代码段、数据段、堆段和栈段。每个段都有自己的基址和界限,在物理内存中可以独立、非连续地存放。

分段的工作原理

当进程在处理器上运行并发出虚拟地址时,地址的高位用于标识访问的是哪个段(例如,代码段、数据段)。系统根据这个段标识符在段表中找到对应的条目,该条目包含了该段的基址和界限。然后,将基址加到虚拟地址上形成物理地址,同时用界限寄存器检查访问是否越界。

分段的主要优势

与连续内存分配相比,内存分段带来了以下重要改进:

  • 独立的权限控制:每个段可以设置不同的保护权限。例如,代码段可以设置为只读,防止运行时被恶意修改,增强了安全性。
  • 动态扩展能力:堆段和栈段可以更容易地增长。如果当前段空间不足,操作系统可以寻找更大的空闲内存区域,移动段内容,并更新基址和界限寄存器。
  • 内存共享:不同进程的段表条目可以指向物理内存中的同一个段。例如,父进程和子进程可以共享只读的代码段,从而节省大量内存空间。

分段仍然存在的问题

尽管分段策略有了很大改进,但它仍有一些不足:

  • 段内仍需连续:每个段自身在物理内存中仍需连续存放。如果一个堆段需要1GB的连续空间,系统必须找到一整块1GB的连续空闲内存,这可能导致外部碎片问题。
  • 段数量有限:通常,一个进程只能有少数几个预定义的段(如四个)。如果进程需要创建额外的共享内存区域,可能会受到段表条目数量的限制,因为段表通常由硬件实现,大小固定。

从连续分配到分段,我们将内存管理的粒度从“整个进程”细化到了“段”。接下来,我们将探索一种粒度更细、更灵活的策略。

分页内存 📄

为了克服分段仍需连续分配和段数量有限的问题,我们引入了分页内存管理策略。其核心思想不是根据内容(如代码、数据)来划分,而是根据固定大小来划分容器本身。

分页的基本概念

在分页系统中:

  • 进程的虚拟地址空间被划分为大小固定的虚拟页
  • 物理内存被划分为同样大小的页框
  • 常见的页大小是4KB。

虚拟地址和物理地址都被视为二维结构:

  • 虚拟地址 = 虚拟页号 + 页内偏移量
  • 物理地址 = 物理页框号 + 页内偏移量

页内偏移量的位数由页大小决定。例如,对于4KB(2^12字节)的页,偏移量需要12位来表示。

地址转换示例

假设一个16位地址空间,页大小为512字节(2^9字节)。给定一个物理地址 0x0618,我们需要找出其对应的页框号和页内偏移。

  1. 将地址转换为二进制:0000 0110 0001 1000
  2. 页大小为512字节,所以偏移量占 log2(512) = 9 位(低9位)。
  3. 16 - 9 = 7 位为页框号。
  4. 因此:
    • 页框号(高7位 0000011) = 3
    • 页内偏移(低9位 000011000) = 24

所以,该地址位于第3号页框,框内偏移为24字节。

页表与地址转换

虚拟页到物理页框的映射关系存储在页表中。每个进程都有自己的页表。地址转换过程如下:

  1. 处理器发出一个虚拟地址。
  2. 内存管理单元 将虚拟地址拆分为虚拟页号和页内偏移。
  3. 使用虚拟页号作为索引,在进程的页表中查找对应的页表项
  4. 页表项中包含了该虚拟页对应的物理页框号,以及一些控制位(如有效位、读写权限位)。
  5. 将找到的物理页框号与原始的页内偏移量组合,形成最终的物理地址。
  6. 如果页表项中的有效位显示该页未映射(即不在物理内存中),则会触发一个页错误,由操作系统介入处理。

分页的优势

分页策略解决了分段的主要问题,并带来了更多好处:

  • 消除外部碎片:由于分配单位是固定大小的页框,任何空闲页框都可以分配给需要的进程,无需寻找连续空间。
  • 高度灵活的内存分配与共享:可以轻松地为进程分配或共享单个页,粒度更细。
  • 细粒度的权限保护:保护权限可以设置在每个页级别,提供了比分段更精细的安全控制。
  • 支持虚拟内存:这是分页最强大的特性之一。进程可以使用的虚拟地址空间可以远大于物理内存。并非所有虚拟页都需要同时驻留在物理内存中。当访问一个不在内存中的页时,操作系统通过页错误机制将其从磁盘调入。这种技术称为请求分页,它使得运行大型程序成为可能,即使物理内存有限。

当然,分页也会导致内部碎片,因为分配给进程的最后一个页可能未被完全使用。但考虑到它带来的巨大优势,这是一个可以接受的代价。

总结

本节课我们一起深入探讨了虚拟内存管理的演进。

我们首先回顾了连续内存分配的简单性及其在外部碎片和灵活性上的局限。

接着,我们学习了内存分段,它通过将进程地址空间划分为多个逻辑段(代码、数据、堆、栈),实现了更细粒度的管理、独立的权限控制和内存共享,但每个段内部仍需连续存放。

最后,我们重点介绍了分页内存管理。通过将虚拟和物理地址空间划分为固定大小的页,并使用页表进行映射,分页策略彻底消除了外部碎片,实现了极其灵活的内存分配、细粒度的保护,并为核心虚拟内存功能(如请求分页)奠定了基础。它为现代操作系统的内存管理提供了核心机制。

下一节课,我们将探讨如何优化分页和页表的性能,使其更加高效。

026:地址转换(第三部分)

在本节课中,我们将继续探讨虚拟内存实现中的地址转换问题。我们将回顾分段机制,并深入讨论分页机制,特别是如何解决线性页表过大的问题,以及如何通过多级页表和转换后备缓冲器(TLB)来优化性能。

分段机制回顾

上一节我们介绍了内存分段机制。其核心思想是,每个进程都认为自己拥有一个从地址0开始的连续内存布局(代码段、数据段、堆、栈等)。然而,实际上,这些段被分散地放置在物理内存的不同位置。

当进程发出一个虚拟地址时,处理器会通过一个段表来动态地将该地址转换为物理地址。段表记录了每个段在物理内存中的起始位置(基址)和大小(界限)。转换过程如下:

  1. 确定虚拟地址属于哪个段(例如,代码段)。
  2. 从段表中获取该段的基址和界限。
  3. 检查虚拟地址偏移量是否在界限内。
  4. 通过公式 物理地址 = 段基址 + 虚拟地址偏移量 计算出物理地址。

分段机制的优势在于支持段共享和保护,并且允许段(如栈)动态增长。但其主要缺点是可能导致外部碎片,因为每个段必须在物理内存中连续存放,并且段表的条目数量有限,限制了可用的段数。

分页机制及其挑战

为了克服分段的局限性,我们引入了分页机制。其核心思想是将虚拟和物理内存都划分为固定大小的块(页和页框),并通过一个页表来建立虚拟页到物理页框的映射。

这种方法非常灵活,因为一个段(如代码段)可以由多个不连续的虚拟页组成,这些页可以映射到物理内存中任意可用的页框上,从而避免了外部碎片。

然而,简单的线性页表存在一个严重问题:它可能非常庞大。以一个典型的32位系统为例,假设页大小为4KB:

  • 虚拟地址被分为20位的页号和12位的页内偏移。
  • 这意味着页表需要 2^20(约100万)个条目。
  • 如果每个条目占4字节,那么一个进程的页表就需要4MB内存。
  • 对于64位系统,问题会更加严重,线性页表的大小将变得完全不切实际。

显然,我们需要更高效的方法来组织页表映射信息。

优化页表:观察与思路

一个关键的观察是:大多数进程只使用了其虚拟地址空间的一小部分。例如,一个简单的“Hello World”程序,其代码、数据、堆栈等段只占用了整个64位地址空间中几个微小的区域,其余绝大部分地址空间都是未使用的。

因此,我们不需要为所有可能的虚拟页都创建页表条目。我们只需要为那些实际被使用的页保留映射信息。基于这个观察,有两种主要的优化策略。

策略一:段页式存储管理

段页式结合了分段和分页的思想。以下是其工作原理:

  1. 首先,像分段一样,识别出进程地址空间中的各个逻辑段(代码、数据、堆、栈)。
  2. 每个段在段表中有一个条目。
  3. 然后,将每个段内部进一步划分为固定大小的页。
  4. 为每个段维护一个独立的页表,负责该段内虚拟页到物理页框的映射。

当处理器发出虚拟地址时:

  • 先通过段表确定该地址属于哪个段。如果地址落在未使用的区域,则直接触发段错误。
  • 如果地址属于有效段,则再利用该段对应的页表进行地址转换。

这种方法可以快速丢弃大片的未使用地址空间。但其缺点仍然是受限于段表的条目数量。

策略二:多级页表

多级页表是更流行的解决方案。其核心思想是将页表本身也进行分页,形成树状结构。以两级页表为例:

  1. 第一级页表(页目录)将整个虚拟地址空间划分为大的区块(例如4MB)。
  2. 每个一级条目指向一个第二级页表。
  3. 第二级页表才管理该4MB区块内的具体4KB页。

其优势在于:

  • 如果一个大区块(如4MB)完全未被使用,那么在第一级页表中,对应的条目可以标记为“无效”。这样,我们就不需要为该区域分配任何第二级页表,从而节省了大量空间。
  • 只为实际使用的内存区域分配细粒度的页表。

现代64位系统通常使用更多级(如4级或5级)的页表来管理巨大的地址空间。

转换后备缓冲器(TLB)

多级页表带来了一个新的问题:每次内存访问现在可能需要多次访问内存来遍历页表(即“页表漫步”),这会造成巨大的性能开销。

为了解决这个问题,我们利用了程序的局部性原理:进程在短时间内倾向于反复访问相同的代码和数据页。因此,我们可以缓存最近使用过的地址转换结果。

转换后备缓冲器(TLB) 就是这样一个专用于缓存地址转换的高速缓存。它位于CPU内部,访问速度极快。

地址转换过程现在变为:

  1. CPU发出虚拟地址。
  2. 首先查询TLB。如果TLB中缓存了该虚拟页号对应的物理页框号(即TLB命中),则立即合成物理地址,访问内存。
  3. 如果TLB中没有该转换(即TLB未命中),则必须进行页表漫步来查找正确的映射。找到后,除了完成本次内存访问,还会将这个新的转换条目载入TLB,以备后续使用。

由于局部性,TLB命中率通常很高,使得绝大多数内存访问都能避免昂贵的页表漫步。

TLB管理细节

TLB像普通缓存一样,可以是全相联或组相联的。现代CPU通常具有多级TLB(如L1 TLB和L2 TLB),L1 TLB可能只有几十到几百个条目,但因其高命中率而非常有效。

当发生TLB未命中时,有两种处理方式:

  • 硬件处理:CPU的硬件逻辑自动执行页表漫步并填充TLB。这种方式速度快,但要求页表格式固定,且增加了CPU硬件的复杂性。
  • 软件处理:TLB未命中会触发一个异常,CPU切换到操作系统内核。由内核中的软件例程执行页表漫步,并将转换结果手动加载到TLB中,然后恢复被中断的进程。这种方式硬件简单且灵活,但速度较慢。

TLB的特殊问题

问题一:进程切换
每个进程都有自己的虚拟地址空间,相同的虚拟地址在不同进程中映射到不同的物理地址。因此,当从一个进程切换到另一个进程时,TLB中缓存的旧进程的转换条目就失效了。

解决方案是为每个TLB条目增加一个进程ID(PID)标签。这样,TLB在查找匹配时,不仅要比较虚拟页号,还要比较PID。从而,不同进程的转换条目可以共存于TLB中,进程切换时无需清空整个TLB。

问题二:一致性
操作系统可能修改页表(例如,更改某个页的访问权限)。此时,TLB中缓存的旧转换条目就与内存中的页表内容不一致了。

解决方案包括:

  • TLB刷新:当OS修改某个页的映射或权限后,可以指令CPU从TLB中使该特定条目无效
  • 多核TLB同步:在多处理器系统中,一个CPU修改了页表,需要通知其他所有CPU,让它们各自刷新自己TLB中的相关条目。这通常通过处理器间中断(IPI)来实现。

虚拟内存与地址转换的优势总结

本节课我们一起学习了地址转换的高级机制。通过分页和多级页表,我们能够高效地管理巨大的虚拟地址空间,而TLB则极大地缓解了页表漫步带来的性能损失。综合来看,虚拟内存和地址转换提供了以下关键优势:

  • 进程隔离:每个进程拥有独立的地址空间,无法干扰其他进程或内核。
  • 内存共享:通过将不同进程的虚拟页映射到相同的物理页框,可以实现内存页的共享。
  • 高效的内存分配:可以动态地分配和释放页来扩展或收缩堆、栈等区域。
  • 内存保护:可以设置页的读、写、执行权限,防止代码被意外修改,或用于实现调试断点。
  • 内存映射文件:可以将文件直接映射到进程地址空间,像访问内存一样访问文件内容。

在下一讲中,我们将探讨请求调页,这是虚拟内存系统的核心,它允许程序使用的内存总量超过物理内存容量,并通过在内存和磁盘之间交换页来实现。

027:请求分页 📄

在本节课中,我们将学习操作系统中的一项关键技术——请求分页。这项技术允许进程在运行时,不必将其全部代码和数据一次性加载到物理内存中,从而更高效地利用内存资源。

上一节我们介绍了地址转换机制,它允许进程的虚拟地址空间被灵活地映射到物理内存的非连续页帧上。然而,我们之前仍假设进程运行所需的全部内存都必须驻留在物理内存中。本节中,我们将探讨如何通过请求分页技术,进一步优化内存使用。

请求分页的基本原理

请求分页的核心思想是:仅当进程真正需要访问某个页面时,才将其从磁盘加载到物理内存中。这基于进程访问内存时表现出的局部性原理

进程通常会表现出两种局部性:

  • 时间局部性:如果一个内存项被访问,那么它在不久的将来很可能再次被访问。
  • 空间局部性:如果一个内存项被访问,那么其附近的内存项在不久的将来也很可能被访问。

因此,进程在任意时刻实际活跃使用的内存(称为工作集)通常只占其总虚拟地址空间的一小部分。请求分页正是利用了这一点。

请求分页的工作流程

以下是请求分页处理一次页面访问的基本步骤:

  1. 触发页错误:当进程试图访问一个尚未加载到物理内存的页面(称为非驻留页)时,会发生页错误。
  2. 内核介入:CPU陷入内核态,操作系统接管处理。
  3. 验证访问合法性:内核检查该虚拟地址是否属于进程的合法段(如代码段、数据段)。如果不是,则引发段错误;如果是合法访问但页面不在内存,则继续。
  4. 定位数据:内核在磁盘上(对于代码和初始数据在可执行文件中,对于堆栈等动态数据在交换空间)找到所需页面的数据。
  5. 分配页帧:内核在物理内存中寻找一个空闲页帧来存放即将加载的数据。如果内存已满,则需要执行页面置换算法选择一个“牺牲”页帧,将其内容换出。
  6. 加载数据:将所需页面的数据从磁盘读入分配的物理页帧。
  7. 更新页表:修改进程的页表,建立该虚拟页面到新物理页帧的映射关系。
  8. 恢复进程:内核让引发页错误的指令重新执行。这次,由于页表中已存在有效映射,内存访问将成功完成。

整个过程可能涉及多次TLB未命中和页表遍历,但由于页错误相对罕见(得益于局部性),其开销是可接受的。

页面置换算法

当物理内存已满,需要为新页面腾出空间时,操作系统必须决定将哪个现有页面换出。以下是几种常见的页面置换算法:

先进先出 (FIFO)

FIFO算法选择在内存中驻留时间最长的页面进行置换。

优点:实现简单。
缺点:性能可能很差,因为它可能换出频繁使用的页面。此外,它可能表现出Belady异常,即增加可用页帧数反而可能导致更多的页错误。

最优置换 (OPT/MIN)

OPT算法是一种理论上的最优算法,它选择在未来最长时间内不会被访问的页面进行置换。

公式表示: 选择满足 max(未来再次访问的时间距离) 的页面进行置换。

优点:提供了页错误数量的下界,用于衡量其他算法的优劣。
缺点:无法在实际中实现,因为它需要预知未来的页面访问序列。

最近最少使用 (LRU)

LRU算法选择在过去最长时间内没有被访问的页面进行置换。它是对OPT算法的一种近似。

优点:通常比FIFO性能更好,更符合程序的访问模式。
缺点:精确实现LRU的硬件开销较大。

时钟算法 (近似LRU)

由于精确LRU实现复杂,实践中常用时钟算法进行近似。它利用页表项中的访问位来实现。

算法描述

  1. 将所有页帧组织成一个环形链表(类似时钟面)。
  2. 有一个“指针”指向某个页帧。
  3. 当需要置换页面时,检查指针指向的页帧:
    • 如果其访问位为0,则选择该页帧进行置换。
    • 如果其访问位为1,则将其访问位置0,然后将指针移动到下一个页帧,重复此过程。

二次机会算法

这是对时钟算法的改进,同时考虑了访问位修改位。被修改过的页面(脏页)换出成本更高,因为它需要写回磁盘。

算法描述
在时钟算法的基础上,优先选择(访问位=0, 修改位=0)的干净页面进行置换。对于脏页,即使其访问位为0,也给予一次“第二次机会”,仅将其访问位置0而不立即换出,留待下一轮扫描。

修改位与访问位

硬件(通常通过TLB和页表)会协助操作系统跟踪页面的使用情况:

  • 修改位:当页面被写入(修改)时,该位被置1。这表明页面内容与磁盘副本不一致,在换出前必须写回磁盘。
  • 访问位:当页面被读取或写入(任何访问)时,该位被置1。操作系统可以定期清零此位,以判断页面在最近一段时间内是否被访问过,这对实现时钟等算法至关重要。

总结

本节课中,我们一起学习了请求分页技术。我们了解到,通过仅加载进程工作集所需的页面,可以极大地提高内存利用率和系统并发能力。当所需页面不在内存时,会触发页错误,由操作系统负责从磁盘加载。当物理内存不足时,需要使用页面置换算法(如FIFO、LRU及其近似实现时钟算法)来决定换出哪个页面。硬件提供的修改位和访问位为这些算法提供了关键支持。请求分页成功地为进程创造了一个比实际物理内存大得多的虚拟地址空间的假象。

028:代码构建脚本 🛠️

在本节课中,我们将学习如何使用 make 工具和 Makefile 来自动化编译和管理包含多个文件的C语言项目。我们将从一个简单的手动编译示例开始,逐步构建一个功能完善、高效且可维护的构建脚本。


项目示例与手动编译的挑战

首先,我们来看一个简单的项目示例。该项目包含多个文件:

  • main.c:一个前端程序,通过命令行参数接收一个数字。
  • fact.c:定义计算阶乘的函数。
  • fact.h:声明 fact.c 中函数的原型,供 main.c 包含。
  • README.md:一个Markdown文件,可转换为HTML。

如果手动编译这个项目,我们需要执行以下步骤:

  1. 将每个 .c 文件编译成 .o 目标文件。
    gcc -c main.c -o main.o
    gcc -c fact.c -o fact.o
    
  2. 将所有 .o 文件链接成一个可执行文件。
    gcc main.o fact.o -o myfact
    
  3. (可选)将Markdown文件转换为HTML。
    pandoc README.md -o README.html
    

手动编译存在几个问题:

  • 效率低下:每次修改源文件(如 fact.cmain.cfact.h)后,都需要重新执行相关步骤。
  • 容易出错:需要记住所有编译命令和依赖关系。
  • 难以维护:更改编译选项(如添加 -Wall 标志)时,需要修改多处命令。

为了解决这些问题,我们需要自动化构建过程,这正是 make 工具的用武之地。


Makefile 基础:规则与结构

make 是一个构建工具,它通过读取名为 Makefile 的配置文件来执行构建任务。一个典型的 Makefile 包含一系列规则

每个规则由三部分组成:

  1. 目标:规则要生成的文件(例如 myfact)。
  2. 先决条件:生成目标所依赖的文件(例如 main.o fact.o)。
  3. 命令:生成目标需要执行的具体命令,必须以一个真正的Tab字符开头

规则的基本语法如下:

目标: 先决条件
<Tab>命令

注释以 # 开头。

现在,让我们为示例项目编写一个基础的 Makefile


编写第一个 Makefile

以下是一个能完成基本构建任务的 Makefile

myfact: main.o fact.o
	gcc main.o fact.o -o myfact

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_42.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_44.png)

main.o: main.c fact.h
	gcc -c main.c -o main.o

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_46.png)

fact.o: fact.c fact.h
	gcc -c fact.c -o fact.o

README.html: README.md
	pandoc README.md -o README.html

在这个文件中:

  • 第一条规则说明 myfact 依赖于 main.ofact.o,并通过链接命令生成。
  • 第二、三条规则分别说明如何从 .c.h 文件生成对应的 .o 文件。
  • 最后一条规则说明如何从 .md 文件生成 .html 文件。

在终端运行 make 命令时,make 会查找当前目录下的 Makefile 文件,并执行其中的第一条规则(默认规则)。因此,运行 make 只会生成 myfact 可执行文件。

如果想生成 README.html,需要明确指定目标:make README.html


改进 Makefile:默认目标与清理规则

上一节我们介绍了基础规则,本节中我们来看看如何设置默认构建所有目标,并添加清理功能。

设置默认目标 all

为了让 make 默认构建所有目标(即可执行文件和HTML文件),我们可以添加一个名为 all伪目标。它本身不对应任何文件,但可以列出所有需要构建的真实目标作为其先决条件。

all: myfact README.html

myfact: main.o fact.o
	gcc main.o fact.o -o myfact

main.o: main.c fact.h
	gcc -c main.c -o main.o

fact.o: fact.c fact.h
	gcc -c fact.c -o fact.o

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_62.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_64.png)

README.html: README.md
	pandoc README.md -o README.html

现在,运行 make 就会同时生成 myfactREADME.html

添加清理规则 clean

我们还需要一个规则来清理所有生成的文件,保持目录整洁。

clean:
	rm -f myfact README.html main.o fact.o

运行 make clean 会删除可执行文件、HTML文件和所有目标文件。

至此,我们有了一个可用的 Makefile。但对于更复杂的项目,这个文件存在大量重复代码,难以维护。接下来,我们将对其进行优化。


优化 Makefile:变量与模式规则

优秀的程序员追求简洁和高效。当前的 Makefile 存在多处重复,例如:

  • 目标文件列表(main.o fact.o)出现了多次。
  • 编译 .o 文件的规则几乎完全相同。
  • 编译命令(gcc -c ...)被重复书写。

以下是优化方法:

1. 使用自动变量

自动变量可以在规则的命令中动态引用目标和先决条件。

  • $@:代表当前规则中的目标文件名。
  • $<:代表当前规则中的第一个先决条件文件名。
  • $^:代表当前规则中所有先决条件文件名。

使用自动变量后,链接规则可以简化为:

myfact: main.o fact.o
	gcc $^ -o $@

2. 使用模式规则

模式规则使用通配符 % 来匹配一组文件,从而将多个相似规则合并为一个。

例如,将两个生成 .o 文件的规则合并:

%.o: %.c fact.h
	gcc -c $< -o $@

这条规则表示:任何 .o 文件依赖于同名的 .c 文件和 fact.h 文件,并使用给定的命令编译。

我们也可以为Markdown转换创建模式规则:

%.html: %.md
	pandoc $< -o $@

3. 使用自定义变量

我们可以将常用的编译器、编译选项定义为变量,方便统一修改。

CC = gcc
CFLAGS = -Wall -Wextra

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_92.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_93.png)

%.o: %.c fact.h
	$(CC) $(CFLAGS) -c $< -o $@

定义变量后,在命令中使用 $(变量名) 来引用。这样,要修改编译选项(例如添加 -g 调试信息),只需在 CFLAGS 变量处更改一次。

4. 集中管理文件列表

将最终目标和中间对象文件列表也定义为变量。

TARGETS = myfact README.html
OBJECTS = main.o fact.o

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_97.png)

all: $(TARGETS)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_99.png)

myfact: $(OBJECTS)
	$(CC) $^ -o $@

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_101.png)

clean:
	rm -f $(TARGETS) $(OBJECTS)

现在,项目的主要配置信息都集中在文件开头的变量定义处,维护起来非常方便。

经过这些优化,Makefile 变得简洁而通用。接下来,我们让它看起来更专业。


美化输出与控制详细程度

默认情况下,make 会打印出它执行的每一条命令。输出可能显得冗长。我们可以美化输出,只显示简洁的编译进度信息。

隐藏命令回显

在命令前添加 @ 符号,可以阻止 make 将该命令本身打印到终端。

%.o: %.c fact.h
	@echo "CC $<"
	@$(CC) $(CFLAGS) -c $< -o $@

这样,终端只会显示 CC main.c 这样的提示,而不是完整的 gcc -Wall -Wextra -c main.c -o main.o 命令。

添加详细模式

虽然美化后的输出更清晰,但在调试时,我们可能需要看到完整的命令。我们可以通过条件变量来实现一个“详细模式”。

ifeq ($(V),1)
Q =
else
Q = @
endif

%.o: %.c fact.h
	$(Q)echo "CC $<"
	$(Q)$(CC) $(CFLAGS) -c $< -o $@
  • 当运行 makeV 未定义)时,Q 被赋值为 @,命令被隐藏。
  • 当运行 make V=1 时,Q 被赋值为空,命令会完整显示。

现在,我们的 Makefile 功能强大且外观专业。但它还有一个关键缺陷:头文件依赖处理不通用。


自动处理头文件依赖

当前的模式规则 %.o: %.c fact.h 硬编码了 fact.h 这个依赖。如果项目扩大,有的 .c 文件不包含 fact.h,或者包含了其他头文件,这个规则就不准确了。

错误的依赖关系会导致严重问题:修改头文件后,make 可能不会重新编译依赖它的 .c 文件,从而产生不一致的构建结果。

解决方案:让 GCC 生成依赖关系

幸运的是,GCC 编译器可以帮我们自动生成依赖关系。使用 -MMD 编译选项,GCC 会在编译 .c 文件生成 .o 文件的同时,生成一个对应的 .d 依赖文件。

例如,编译 fact.c 时:

gcc -MMD -c fact.c -o fact.o

这会额外生成一个 fact.d 文件,其内容类似于:

fact.o: fact.c fact.h

这正是我们需要的规则!

在 Makefile 中集成自动依赖

以下是集成自动依赖跟踪的步骤:

  1. 修改编译标志:在 CFLAGS 中添加 -MMD 选项。
    CFLAGS = -Wall -Wextra -MMD
    

  1. 生成依赖文件名:从对象文件列表推导出依赖文件列表。
    DEPS = $(OBJECTS:.o=.d)
    
    这行代码将 OBJECTS 变量中所有 .o 后缀替换为 .d

  1. 包含依赖文件:使用 -include 指令将生成的 .d 文件包含到 Makefile 中。
    -include $(DEPS)
    
    - 前缀表示即使某些 .d 文件暂时不存在(首次编译时),make 也不会报错。

  1. 修正模式规则:移除规则中硬编码的 fact.h
    %.o: %.c
    	$(Q)echo "CC $<"
    	$(Q)$(CC) $(CFLAGS) -c $< -o $@
    

工作原理

  1. 首次运行 make 时,没有 .d 文件,-include 被忽略。GCC 编译 .c 文件生成 .o 文件,同时生成 .d 文件。
  2. 后续运行 make 时,.d 文件被包含进来,提供了精确的头文件依赖规则。
  3. 当修改头文件(如 fact.h)后,对应的依赖规则会触发相关 .o 文件的重新编译,从而保证构建的正确性。


最终的高级 Makefile

结合以上所有技巧,我们得到一个功能完善、通用性强的高级 Makefile

# 工具和标志定义
CC = gcc
CFLAGS = -Wall -Wextra -MMD

# 详细输出控制
ifeq ($(V),1)
Q =
else
Q = @
endif

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_142.png)

# 文件列表定义
TARGETS = myfact README.html
OBJECTS = main.o fact.o
DEPS = $(OBJECTS:.o=.d) # 依赖文件列表

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_144.png)

# 默认目标
all: $(TARGETS)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_146.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_148.png)

# 链接可执行文件
myfact: $(OBJECTS)
	$(Q)echo "LD $@"
	$(Q)$(CC) $^ -o $@

# 编译C源文件为对象文件
%.o: %.c
	$(Q)echo "CC $<"
	$(Q)$(CC) $(CFLAGS) -c $< -o $@

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_150.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_152.png)

# 转换Markdown为HTML
%.html: %.md
	$(Q)echo "PANDOC $<"
	$(Q)pandoc $< -o $@

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_154.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_156.png)

# 清理生成文件
clean:
	$(Q)echo "CLEAN"
	$(Q)rm -f $(TARGETS) $(OBJECTS) $(DEPS)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucd-ecs150-os-sysprog/img/8038d42c0442aa53f884ced67aae4b8c_158.png)

# 包含自动生成的依赖关系
-include $(DEPS)


总结

本节课中我们一起学习了如何编写一个高效的 Makefile 来自动化C语言项目的构建过程。我们从手动编译的弊端出发,逐步引入了 make 工具的基础规则、变量、模式规则和自动变量。为了提升可维护性和用户体验,我们学习了如何设置默认目标、添加清理功能、美化输出以及通过条件变量控制详细程度。最后,我们解决了构建中的关键挑战——自动跟踪头文件依赖,利用GCC的 -MMD 选项自动生成并包含依赖关系,确保了构建的准确性和高效性。

通过本教程,你应该能够为你的项目创建结构清晰、功能强大且易于维护的构建脚本。

029:调试 🐛

在本节课中,我们将学习如何使用调试工具,特别是GDB,来诊断和修复程序中的错误。我们将从GDB的基础知识开始,逐步介绍如何设置断点、检查程序状态、跟踪执行流程,以及如何使用Valgrind检测内存问题。课程内容旨在简单明了,帮助初学者快速上手。


什么是GDB?🔍

GDB是GNU项目的一部分,由Richard Stallman于1983年在MIT发起。该项目旨在开发一个完全自由、可修改和可重新分发的完整软件栈,包括操作系统内核、应用程序和各种工具。GDB作为其中的调试器,支持多种语言,如C、C++、Go等,允许开发者检查程序的执行流程和数据状态。


如何使用GDB?🚀

要使用GDB,首先需要确保程序在编译时启用了调试支持。通常,我们使用GCC编译程序,并通过指定-g选项来启用调试信息。例如:

gcc -g -o my_program my_program.c

需要注意的是,在调试时应避免同时使用优化选项(如-O2),因为这可能会使调试过程变得复杂。为了在调试和发布版本之间切换,可以在Makefile中设置条件编译。例如:

ifeq ($(D),1)
    CFLAGS = -g
else
    CFLAGS = -O2
endif

通过命令行输入make D=1可以编译调试版本,而直接输入make则编译优化版本。


启动GDB 🖥️

启动GDB有两种方式:

  1. 直接运行gdb,然后在GDB提示符下使用file命令加载程序。
  2. 运行gdb并直接指定程序名称作为参数,例如:
    gdb my_program
    

在GDB中,可以使用run命令运行程序。如果程序需要命令行参数,可以在run后指定,例如:

run arg1 arg2

如果对GDB命令不熟悉,可以使用help命令查看帮助信息。例如,输入help breakpoints可以查看与断点相关的命令。


调试场景分析 🧩

程序运行时可能遇到三种场景:

  1. 程序运行正常,没有错误。
  2. 程序崩溃(如段错误),这是相对容易调试的情况。
  3. 程序运行但结果不正确,这是较难调试的情况。

接下来,我们将通过具体示例分析这些场景。


示例一:段错误分析 💥

以下是一个简单的程序,调用strlen函数计算字符串长度,但传入了一个空指针:

#include <stdio.h>
#include <string.h>

int full_len(char *s) {
    return strlen(s);
}

int main() {
    char *str = NULL;
    printf("Length: %d\n", full_len(str));
    return 0;
}

运行该程序会导致段错误。使用GDB调试时,首先运行程序:

gdb my_program
run

当程序崩溃时,可以使用backtrace(或bt)命令查看函数调用链:

bt

输出可能如下:

#0  strlen () at ...
#1  full_len (s=0x0) at ...
#2  main () at ...

从输出中可以看出,full_len函数接收了一个空指针,导致strlen函数崩溃。修复方法是在full_len函数中添加断言,确保指针不为空:

#include <assert.h>

int full_len(char *s) {
    assert(s != NULL);
    return strlen(s);
}

这样,当传入空指针时,程序会触发断言失败,而不是段错误,从而更容易定位问题。


示例二:数组越界访问 📊

以下程序尝试访问数组的非法索引:

#include <stdio.h>

int main() {
    unsigned int i;
    int tab[10];
    for (i = 9; i >= 0; i--) {
        tab[i] = i;
    }
    return 0;
}

由于i是无符号整数,当i减到0后再减1会变为最大值(约40亿),导致数组越界访问。使用GDB调试时,可以在程序崩溃后打印i的值:

print i

输出可能显示i的值为一个非常大的数,从而揭示问题所在。修复方法是将i改为有符号整数:

int i;

示例三:行为错误调试 🔄

以下程序尝试将字符串中的所有字符转换为大写,但最后一个字符未转换:

#include <stdio.h>
#include <ctype.h>

int main() {
    char str[] = "Hello World";
    for (int i = 0; str[i] != '\0'; i++) {
        str[i] = toupper(str[i]);
    }
    printf("%s\n", str);
    return 0;
}

程序运行后输出HELLO WORLd,最后一个字符仍为小写。这类错误不会导致程序崩溃,但结果不正确。使用GDB调试时,可以设置断点逐步执行程序,检查变量状态。


设置断点 🛑

GDB允许通过以下方式设置断点:

  1. 按行号设置断点:
    break file.c:10
    
  2. 按函数名设置断点:
    break main
    
  3. 按条件设置断点:
    break file.c:13 if i == 5
    

断点设置后,可以使用以下命令控制程序执行:

  • continue:继续执行直到下一个断点。
  • step:单步执行,进入函数内部。
  • next:单步执行,但不进入函数内部。

检查变量状态 📝

在GDB中,可以使用print命令检查变量值。例如:

print i

如果变量是指针,可以解引用查看其指向的值:

print *ptr

还可以指定输出格式,例如以十六进制显示整数:

print/x i

使用观察点 👀

观察点用于在变量值发生变化时中断程序执行。例如,设置观察点监视变量i

watch i

i的值发生变化时,GDB会中断并显示旧值和新值。


其他有用命令 🛠️

  • finish:执行完当前函数后中断。
  • until:执行完当前循环后中断。
  • info breakpoints:显示所有断点信息。
  • delete breakpoints:删除断点。

使用Valgrind检测内存问题 🧠

Valgrind是一个强大的内存检测工具,可以检测内存泄漏和非法内存访问。以下是一个存在内存问题的示例程序:

#include <stdlib.h>

void func() {
    int *arr = malloc(10 * sizeof(int));
    arr[10] = 42; // 非法访问
    // 内存泄漏:未释放分配的内存
}

int main() {
    func();
    return 0;
}

使用Valgrind检测内存问题:

valgrind --leak-check=full ./my_program

Valgrind会报告非法内存访问和内存泄漏的详细信息,帮助定位问题。


总结 📚

本节课我们一起学习了如何使用GDB和Valgrind调试程序。通过设置断点、检查变量状态、跟踪执行流程以及检测内存问题,我们可以更有效地诊断和修复程序中的错误。希望这些工具和技巧能帮助你在未来的项目中更轻松地应对调试挑战。

030:printf 函数详解 🖨️

在本节课中,我们将深入探讨C语言中一个看似简单但功能强大的函数——printf。我们将了解它的历史、工作原理以及如何从零开始实现一个简化版本。通过本教程,你将理解格式化输出的核心机制。


概述

printf 是C语言中最基础、最常用的输出函数之一。它能够处理从简单的字符串打印到复杂的格式化输出。本节课我们将剖析其背后的原理,并动手实现一个简化版的 printf 函数。


printf 的功能简介

printf 函数极其灵活,可以处理从简单到复杂的多种输出场景。

以下是其功能的几个例子:

  • 简单字符串打印:当不需要格式化时,printf 可以直接输出一个字符串。

    printf("Hello, world!\n");
    
  • 格式化输出:通过使用占位符(如 %s, %c, %d),printf 可以在运行时将参数动态插入到格式字符串中。

    printf("String: %s, Char: %c, Int: %d\n", "example", 'A', 42);
    
  • 特殊功能printf 还支持一些特殊功能,例如:

    • \b(退格符):将光标回退一格。
    • %n:将当前已输出的字符数写入一个整数变量(作为输出参数)。
    • 控制序列:可以输出控制终端颜色和字体的特殊字符序列。
    int i;
    printf("\b%n", &i); // i 将被赋值为 1(\b 是一个字符)
    printf("UC%c is number \033[1;31m%d\033[0m!\n", 'D', 1); // 输出红色加粗的 “1”
    

printf 的历史演变

上一节我们介绍了 printf 的功能,本节中我们来看看它的历史渊源。printf 的语法并非一蹴而就,而是经历了数十年的演变。

  • FORTRAN (1950年代中期):在最早的FORTRAN语言中,打印和格式化是两个独立的语句(WRITEFORMAT)。格式字符串的表示方式非常原始(使用Hollerith常量)。
  • BCPL (1960年代中期):这是C语言的前身之一。在BCPL中,打印和格式化被合并到同一个函数中,语法开始接近现代的 printf,使用了双引号和 % 作为占位符,但换行符是 *n
  • C (1970年代早期):C语言最终确立了我们现在熟悉的 printf 语法,包括 \n 换行符和各种占位符。这一语法被广泛接受,并影响了许多其他编程语言和命令行工具。

为什么需要打印输出?

理解了 printf 的“是什么”和“从哪来”,我们再来思考“为什么”。在计算机系统中,打印输出主要出于两个半目的:

  1. 与用户交互:这是最直观的目的,程序通过打印信息向用户展示结果、状态或提示。
  2. 进程间通信:程序可以通过标准输出(stdout)将数据传递给另一个程序(例如使用管道 |)。
  3. 程序内省与调试:开发者通过在代码中插入打印语句,可以了解程序的内部状态、执行流程,这对于查找错误至关重要。甚至在操作系统启动的最初阶段,打印功能也是不可或缺的调试工具。

打印输出的底层路径

那么,当我们在程序中调用 printf 时,字符是如何最终显示在屏幕上的呢?让我们看一个典型场景。

假设你在终端里运行 date 命令。date 程序内部的 printf 调用会产生一个系统调用,进入操作系统内核。内核中复杂的设备驱动和输出管理代码会识别出这些字符应该发送到哪个“输出设备”——在这里是你的终端模拟器(如 xterm)。最终,字符被传递给终端模拟器,由其渲染在窗口上。

对于没有屏幕的系统(如嵌入式设备)或屏幕尚未初始化的阶段(如系统启动时),输出通常会通过一个称为串行端口(UART) 的硬件接口发送。几乎所有计算机系统都具备这个简单的硬件,它通过调制电信号来发送字节。通过连接到此端口,开发者可以获取系统的调试信息。


实现 printf 的核心:putchar

现在,让我们进入最核心的部分——如何实现 printf。所有打印功能的基础是一个非常简单的函数:putchar

putchar 接收一个字符作为参数,并负责将它发送到正确的输出设备(例如串行端口)。它是整个 printf 基础设施的基石。有了它,我们就可以构建更复杂的输出函数。

以下是一个在典型 x86 架构上,通过串口发送一个字符的简化示例代码:

void putchar(char c) {
    // 等待串口准备好发送
    while ((inb(0x3F8 + 5) & 0x20) == 0);
    // 将字符发送到串口的数据寄存器
    outb(0x3F8, c);
}

实现简化版 printf

基于 putchar,我们可以开始构建自己的 printf 函数。首先,我们需要处理可变数量的参数。

在C语言中,要编写一个像 printf 这样参数数量可变的函数,需要使用 <stdarg.h> 头文件中定义的宏。

以下是使用可变参数的一个简单示例,该函数计算多个整数的和:

#include <stdarg.h>

int sum_ints(int count, ...) {
    va_list ap; // 声明一个参数列表变量
    va_start(ap, count); // 初始化,`count` 是最后一个固定参数

    int sum = 0;
    for (int i = 0; i < count; i++) {
        int arg = va_arg(ap, int); // 获取下一个 `int` 类型的参数
        sum += arg;
    }

    va_end(ap); // 清理工作
    return sum;
}
// 调用示例:sum_ints(3, 10, 20, 30); // 返回 60

逐步构建 printf

接下来,我们将分步实现一个支持 %s%c%d 的简化版 printf

第一步:基础框架和字符串打印
我们首先创建一个能遍历格式字符串并直接输出每个字符的版本。这已经可以处理没有占位符的简单情况。

void printf(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);

    for (const char *p = fmt; *p != '\0'; p++) {
        putchar(*p);
    }

    va_end(ap);
}

第二步:识别并处理占位符
现在,我们需要在遍历字符串时识别 % 符号。当遇到 % 时,我们查看下一个字符以确定占位符类型,并调用 va_arg 获取对应的参数。

void printf(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);

    for (const char *p = fmt; *p != '\0'; p++) {
        if (*p != '%') {
            putchar(*p);
            continue;
        }
        // 跳过 ‘%’
        p++;
        switch (*p) {
            case 's': { // 处理字符串
                char *s = va_arg(ap, char*);
                while (*s) putchar(*s++);
                break;
            }
            case 'c': { // 处理字符
                // 注意:`va_arg` 中 `char` 会被提升为 `int`
                int c = va_arg(ap, int);
                putchar(c);
                break;
            }
            case 'd': { // 处理整数(暂未实现转换)
                int d = va_arg(ap, int);
                // 接下来需要将整数 d 转换为字符串并打印
                break;
            }
        }
    }
    va_end(ap);
}

第三步:实现整数到字符串的转换
处理 %d 是最复杂的一步,因为我们需要将整数(如 45)转换为其字符表示(‘4‘, ’5‘)。算法如下:

  1. 创建一个足够大的字符缓冲区。
  2. 通过 d % 10 获取最低位数字,并将其转换为 ASCII 码(‘0‘ + 数字)。
  3. 将转换后的字符存入缓冲区(从后往前存或从前往后存再反转)。
  4. 通过 d /= 10 去掉已处理的最低位。
  5. 重复步骤2-4,直到 d 为 0。
  6. 将缓冲区中的字符按正确顺序打印出来。

以下是该算法的核心代码片段:

case 'd': {
    int d = va_arg(ap, int);
    char buffer[20];
    char *ptr = buffer;

    // 将整数转换为字符串(反向存储在buffer中)
    do {
        *ptr++ = '0' + (d % 10);
        d /= 10;
    } while (d != 0);

    // 将反向的字符串倒序打印出来
    while (--ptr >= buffer) {
        putchar(*ptr);
    }
    break;
}

将以上所有步骤组合起来,我们就得到了一个能够处理 %s%c%d 的简化版 printf 函数。


总结

本节课中我们一起学习了 printf 函数的强大功能、历史演变及其在系统编程中的重要性。我们深入探讨了打印输出的底层路径,并最终动手实现了一个简化版的 printf。关键点包括:

  1. printf 的核心是格式化字符串和可变参数处理。
  2. 所有输出的基础是 putchar 函数,它负责向底层硬件发送单个字符。
  3. 通过 <stdarg.h> 中的宏可以处理可变参数。
  4. 实现 printf 需要解析格式字符串,根据占位符类型获取相应参数,并进行必要的转换(尤其是整数转字符串)。

理解这些原理,不仅能让你更深入地掌握C语言,也为理解操作系统层面的I/O操作打下了基础。

031:C语言中的宏 🧩

在本节课中,我们将要学习C语言中一个非常强大但需要谨慎使用的特性:宏。我们将探讨宏的基本概念、常见用法、潜在陷阱以及如何安全有效地使用它们。

概述

宏是C语言预处理器提供的一种文本替换机制。它允许程序员定义一些名称,这些名称在编译前会被替换为指定的代码或值。宏可以用于定义常量、创建代码片段,甚至模拟函数行为。然而,由于其本质是文本替换,使用不当可能导致难以察觉的错误。

宏与枚举:定义常量

在C语言中,我们经常需要定义一些常量。宏和枚举是两种常见的方法。

使用宏定义常量

宏可以用来将一个名称与一个值关联起来。在代码中使用这个名称,预处理器会在编译前将其严格替换为对应的文本。这本质上是一种搜索和替换操作。

以下是一个使用宏定义线程状态的例子:

#define ST_RUNNING 0
#define ST_READY 1
#define ST_BLOCKED 2

在P2线程库中,这种方法非常适合用来确定线程控制块中线程的状态。

使用枚举定义常量

然而,当值从0开始并依次递增时,使用枚举可能比使用一堆宏定义更可取。

以下是使用枚举的相同例子:

enum thread_state {
    ST_RUNNING,
    ST_READY,
    ST_BLOCKED
};

使用枚举有几个优点:

  1. 自动编号:默认情况下,枚举的第一个值为0,后续值会自动递增。你无需手动指定,编译器会为你处理。
  2. 编译器检查:如果你在一个switch语句中使用了枚举类型的变量,编译器会检查你的switch是否覆盖了该枚举的所有可能值。如果使用宏,编译器则无法进行这种检查。
  3. 调试友好:使用GDB等调试器时,枚举的名称对调试器是有意义的。如果使用宏,你只能看到数值(如0,1,2),需要自己对照源代码查找对应的状态。

枚举的一个潜在缺点是,在C语言中,当你声明一个枚举类型的变量时,编译器会自行选择底层类型(可能是charintshort int等),你无法控制。C++提供了指定底层类型的能力,但C语言没有。

如何选择

  • 当值是连续的时,通常首选枚举。
  • 当值是任意的、非连续的时,宏是更合适的选择。例如,定义寄存器地址或特定频率值:
    #define HERTZ 100
    #define REG_STATUS 0x1F
    

宏的代码替换功能与陷阱

宏另一个极其流行的用法是进行代码替换。

基本示例与第一个陷阱

假设你的代码中需要多次将变量乘以2,你可以定义一个宏来复用代码:

#define TWICE(x) 2 * x

当你调用TWICE(a)时,它会被展开为2 * a

然而,这种简单的替换存在陷阱。考虑以下代码:

c = TWICE(a + 1) * 3;

你的本意是计算(a + 1) * 2 * 3。但预处理器会进行严格的文本替换,将其展开为:

c = 2 * a + 1 * 3;

由于乘法运算符*的优先级高于加法运算符+,实际计算的是(2 * a) + (1 * 3),这并非你的本意。

解决方法:始终将宏本身及其所有参数用括号括起来。

#define TWICE(x) (2 * (x))

现在,TWICE(a + 1) * 3会被正确展开为(2 * (a + 1)) * 3

第二个陷阱:多语句宏与分号

有时我们希望一个宏能执行多个操作。例如,在内存分配失败时打印错误信息并退出程序。

一个初版的宏可能如下:

#define DIE_PRINT(x) printf(“%s failed\n”, x); exit(1);

如果我们在if语句中使用它:

if (a == NULL)
    DIE_PRINT(“malloc”);

预处理器展开后,代码变为:

if (a == NULL)
    printf(“%s failed\n”, “malloc”); exit(1);
;

这会导致问题:由于if后没有花括号,只有printf属于if的条件体,exit(1)和后面的空语句;总是会被执行。

尝试改进:在宏定义中加入花括号。

#define DIE_PRINT(x) { printf(“%s failed\n”, x); exit(1); }

这有所改善,但调用宏时末尾的分号;会显得多余且奇怪,因为它并不结束任何语句,还可能引发其他语法上的混淆。

最佳解决方案:使用do { … } while(0)结构。

#define DIE_PRINT(x) \
    do { \
        printf(“%s failed\n”, x); \
        exit(1); \
    } while (0)

这个结构创建了一个只执行一次的循环。while(0)保证循环不会重复,而整个do…while语句需要一个分号;来结束,这正好与我们调用宏时习惯性添加的分号匹配。这是一种在C代码库中广泛使用的可靠模式。

第三个陷阱:参数多次求值

一个非常常见的宏是求两个值的最大值:

#define MAX(x, y) ((x) > (y) ? (x) : (y))

这个宏使用了括号,避免了优先级问题。但它仍有缺陷:参数xy可能会被求值两次。

考虑以下调用:

d = MAX(++a, b);

如果a原本是最大的,本意是a先自增1,然后与b比较,并将自增后的值赋给d。但宏展开后是:

d = ((++a) > (b) ? (++a) : (b));

如果++a > b为真,那么++a会被执行两次,d最终得到的是a自增两次后的值。

解决方案:使用复合语句和typeof关键字(GCC扩展,广泛支持)来创建只求值一次的临时变量。

#define MAX(x, y) ({ \
    typeof(x) _x = (x); \
    typeof(y) _y = (y); \
    _x > _y ? _x : _y; \
})

这个宏的原理是:

  1. ({ ... })是一个返回最后一个表达式值的语句表达式。
  2. typeof(x)获取参数x的类型,并以此声明临时变量_x_y。这使得宏可以处理任意类型。
  3. 参数xy只被求值一次,结果存入临时变量。
  4. 比较和返回值操作在临时变量上进行,安全无误。

让宏返回值

我们可以利用语句表达式({ ... })让宏返回一个值。这在创建一些复杂的、类似函数的宏时非常有用。

一个简单的例子:

int b = ({
    int local = 5;
    printf(“Inside macro\n”);
    local * 2; // 这个值会被返回并赋值给b
});

在这个复合语句块中,最后一个语句local * 2;的值(注意没有分号)将作为整个表达式的结果返回。

应用示例:安全的分配宏

我们可以创建一个“安全”的内存分配宏,它在分配失败时自动报错并退出。

#define XMALLOC(size) ({ \
    void *_ptr = malloc(size); \
    if (_ptr == NULL) { \
        fprintf(stderr, “malloc failed for size %zu\n”, (size_t)(size)); \
        exit(1); \
    } \
    _ptr; \
})

在代码中,你可以这样使用:int *arr = XMALLOC(10 * sizeof(int));。如果分配成功,arr获得指针;如果失败,程序会打印错误并退出。

宏 vs. 静态内联函数

对于XMALLOC这种功能,更现代和安全的做法是使用静态内联函数

static inline void *xmalloc(size_t size) {
    void *ptr = malloc(size);
    if (ptr == NULL) {
        fprintf(stderr, “malloc failed for size %zu\n”, size);
        exit(1);
    }
    return ptr;
}

两者对比

特性 静态内联函数
本质 预处理器文本替换 真正的函数
类型检查 无。参数和返回值类型是泛化的。 有。编译器会检查参数和返回值类型。
调试 较困难。展开后的代码融入调用处。 较容易。通常有函数符号。
灵活性 高。可以使用#(字符串化)、##(连接)等运算符,实现元编程。 低。遵循标准函数语法。
代码长度 可能增加。每次调用都展开完整代码。 由编译器决定是否内联展开。
适用场景 简单的代码复用、泛型操作、需要字符串化等特殊功能时。 行为类似函数、逻辑较复杂、需要类型安全时。

一个宏的特殊能力示例:字符串化

#define WARN_IF(expr) \
    do { \
        if (expr) \
            fprintf(stderr, “Warning: “ #expr “\n”); \
    } while (0)

调用WARN_IF(ptr == NULL)时,#expr会将参数ptr == NULL转换成字符串”ptr == NULL”,从而生成有意义的警告信息。这是函数无法直接做到的。

总结

本节课中我们一起深入探讨了C语言中的宏。

  • 我们学习了使用宏和枚举定义常量的适用场景与优劣。
  • 我们详细分析了使用宏进行代码替换时常见的三个陷阱:运算符优先级问题、多语句宏的分号问题、参数多次求值问题,并掌握了各自的解决方案(使用括号、do…while(0)结构、利用({})typeof创建临时变量)。
  • 我们了解了如何利用语句表达式让宏返回值,并实现了类似函数的功能。
  • 最后,我们对比了宏与静态内联函数,理解了它们各自的优缺点和适用场合。

宏是C语言中一把强大的双刃剑。它提供了无与伦比的灵活性和代码生成能力,但也因其文本替换的本质而暗藏风险。作为程序员,我们的目标是理解其原理,遵循最佳实践,在合适的场景下安全地利用它的力量,而在追求安全性和可维护性时,优先考虑使用函数(包括内联函数)。

posted @ 2026-03-29 09:28  布客飞龙II  阅读(13)  评论(0)    收藏  举报