UIUC-CS361-操作系统笔记-全-
UIUC CS361 操作系统笔记(全)
001:动态链接原理 🖥️
在本节课中,我们将学习可执行文件如何从磁盘加载到内存中运行,并深入探讨现代程序加载的核心机制——动态链接。我们将了解静态链接与动态链接的区别,以及动态链接库如何通过位置无关代码、全局偏移表和过程链接表等技术实现高效、灵活的代码共享。
从磁盘到内存的加载过程
上一节我们介绍了课程概述,本节中我们来看看可执行文件从磁盘加载到内存的具体过程。

当程序存储在磁盘上时,它遵循 ELF(可执行与可链接格式)文件格式。这个格式规定了如何将磁盘文件中的一系列字节,转换成一个正在运行的进程内存空间中不同位置的一系列字节。最重要的区别在于,磁盘文件中的字节是紧密排列在一起的,而加载到内存后,它们会被放置到进程地址空间中非常分散的不同段里。
一个核心概念是地址空间。即使我们有从0到2^48-1的巨大地址范围,我们也不会实际使用每一个地址来存储数据。实际上,只有当前正在使用的部分(在幻灯片中显示为灰色区域)才会被加载到计算机的物理内存中,其余部分暂时并不存在。
加载器(loader)的首要任务是解析ELF文件,确定需要将文件的哪些段(segments)加载到运行进程内存空间的哪些地址上。这个内存布局通常被称为内存映像(memory image),它包括向下增长的栈、向上增长的内存映射区和堆等区域。

解析ELF文件头

上一节我们了解了加载的宏观概念,本节中我们来看看加载器如何通过解析ELF文件头来获取加载指令。
让我们通过一个简单的“Hello World”程序来实际观察。使用 readelf 命令可以查看ELF文件的头部信息,这些信息指导加载器工作。



以下是查看程序头(Program Headers)的命令,它列出了需要从文件加载到内存的各个段:
readelf -l hello_world
输出会显示多个程序头。例如,第一个可加载段指示加载器:从文件偏移量0开始,读取 0x6f0 个字节,并将其放入新进程内存地址 0x400000 处,并标记为可读且可执行。这通常就是程序的代码段(.text)。

另一个段指示从文件偏移 0xe10 开始,读取 0x220 字节,但实际在内存中分配 0x228 字节的空间,起始地址为 0x601e10。多出来的空间是为 .bss 段预留的,该段用于存储未初始化的全局变量,在文件中不占空间,但在内存中需要被分配并初始化为零。
ELF文件中包含两种重要的表:
- 程序头表:指导加载器如何将文件段映射到内存段。
- 节头表:描述了ELF文件内部的各种节(sections),如
.text(代码)、.data(已初始化数据)、.bss(未初始化数据)等。链接器(如GCC)关心这些节,但加载器只关心如何加载整个程序头指向的段。
程序的启动:_start 与 __libc_start_main
上一节我们看到了如何将程序加载到内存,本节中我们来看看程序加载后是如何开始执行的。

在ELF文件头中,有一个关键的字段叫做入口点地址。这是操作系统将控制权转交给新进程时,第一条要执行的指令的地址。通常,这个地址指向一个名为 _start 的函数。
_start 是一个用汇编语言写成的、与平台相关的非常简短的代码。它本身并不执行用户逻辑,其主要职责是调用C语言库中的 __libc_start_main 函数。__libc_start_main 则会进行一系列初始化工作:

- 设置
argc和argv(命令行参数)。 - 设置环境变量。
- 调用全局初始化函数(如果存在,例如用于性能分析工具的初始化)。
- 最终,调用我们编写的
main函数。 - 当
main函数返回后,__libc_start_main会进行一些清理工作,并将main的返回值传递给操作系统(通常是shell)。

通过调试器,我们可以观察到这一过程:在 _start 处设置断点,单步执行就会进入 __libc_start_main,并最终跳转到我们自己的 main 函数。
为何需要动态链接?
上一节我们追踪了程序的启动流程,本节中我们来探讨静态链接的局限性以及动态链接的必要性。
在静态链接中,所有库函数(如 printf)的代码都会被直接复制到最终的可执行文件中。这带来一个明显的问题:空间浪费。
例如,一个简单的动态链接的“Hello World”程序可能只有约11KB。而如果将其静态链接,由于需要包含整个C标准库的代码,其大小会膨胀到约828KB。如果系统上每个使用C库的程序都这样做,将极大地浪费磁盘空间。
更严重的是内存浪费。当多个静态链接的程序同时运行时,它们各自的内存中都有C标准库的完整副本,无法共享。这不仅消耗了大量物理内存,也挤占了宝贵的CPU缓存空间,导致程序运行变慢。
动态链接的核心思想就是共享。将常用的库(如C标准库)编译成独立的共享库文件(在Linux上是 .so 文件)。多个程序可以映射到内存中的同一份共享库代码,从而节省磁盘和内存空间,并提高缓存利用率。
动态链接的挑战与解决方案:位置无关代码
上一节我们认识了动态链接的优势,本节中我们来看看实现动态链接所面临的首要挑战及其解决方案。
挑战在于:一个共享库(例如 libc.so)可能被不同的程序加载到各自内存地址空间中的任意位置。库内部的代码(.text)在编译时并不知道自己最终会被放在哪里,但代码中可能包含访问自身全局变量(.data 或 .bss)的指令。如果使用绝对地址,一旦加载位置改变,这些指令就会指向错误的地方。
解决方案是使用位置无关代码。PIC的核心思想是:代码段中所有对内部数据或函数的引用,都使用相对于当前指令指针的偏移量来计算,而不是使用绝对地址。因为无论库被加载到何处,其代码段内部各指令之间、代码段与数据段之间的相对距离是固定不变的。
通过编译器选项(如 -fPIC)可以生成位置无关代码。这样,共享库就可以像“乐高模块”一样,被灵活地放置到进程地址空间的任何位置而正常工作。
处理外部引用:全局偏移表
上一节我们解决了共享库内部的引用问题,本节中我们来看看共享库如何访问其他库或主程序中的全局变量。
位置无关代码解决了内部引用,但共享库还需要访问外部的全局变量(例如,一个数学库可能需要访问 errno 这个在C库中定义的全局变量)。问题在于,编译共享库时,我们无法知道这些外部变量最终会在宿主程序的哪个地址。
解决方案是引入一个间接层:全局偏移表。GOT位于共享库自己的可读写数据段中。对于每一个需要引用的外部全局变量,GOT中都有一个对应的条目。
其工作原理如下:
- 在编译共享库时,所有访问外部变量的指令都被修改为:“去GOT中第X个条目里取出地址,然后访问那个地址指向的内存”。
- 在程序加载时,动态链接器(
ld-linux.so)会计算出所有外部变量的实际地址,并填写到GOT的相应条目中。 - 这样,当库代码运行时,它通过GOT间接地访问到了正确的变量。

这个过程发生在加载时,因为此时所有模块(主程序、各个共享库)都已确定在内存中的位置,动态链接器可以完成最终的地址解析。
延迟绑定:过程链接表
上一节我们了解了如何解析全局变量,本节中我们来看看动态链接中最精妙的部分——函数调用的延迟绑定。
对于函数调用,我们同样面临外部引用的问题。一个朴素的方案是在加载时像处理变量一样,通过GOT解析所有外部函数的地址并填好。但这存在效率问题:一个共享库可能包含数百个函数,而一个程序可能只调用其中的几个。在启动时就解析所有函数地址是一种浪费。
动态链接采用了延迟绑定策略:将函数地址的绑定推迟到该函数第一次被调用时。这通过过程链接表和GOT的协作来实现。
以下是PLT的工作机制:
- 当程序第一次调用
printf时,它实际上跳转到printf@plt(PLT中的一个条目)。 printf@plt的第一条指令是跳转到 GOT 中对应printf的条目所存储的地址。在第一次调用时,这个地址默认指向printf@plt条目中的第二条指令。- 接着,
printf@plt会将一个代表printf的标识符压栈,然后跳转到PLT[0](一个特殊的条目,它会调用动态链接器ld-linux.so)。 - 动态链接器根据栈上的标识符,找到
printf在内存中的实际地址,然后用这个真实地址覆盖掉 GOT 中对应printf的那个条目。 - 最后,动态链接器跳转到
printf的真实地址开始执行。 - 此后,任何对
printf的调用,都会再次走到printf@plt。但此时,GOT 中存储的已经是printf的真实地址。因此,printf@plt的第一条指令会直接通过 GOT 跳转到真实的printf函数,无需再经过动态链接器。


这种设计的精妙之处在于“使常见情况快速”。函数在第一次调用后,后续所有调用都只需一次间接跳转,速度极快。而“第一次调用”这个不常见的情况,则承担了地址绑定的开销。
总结
本节课中我们一起学习了可执行文件的加载与动态链接原理。
我们从ELF文件格式出发,了解了加载器如何将磁盘上的程序段映射到进程的内存空间。我们探讨了静态链接导致的空间浪费问题,从而引出了动态链接的必要性。
为了实现动态链接,我们学习了三项核心技术:
- 位置无关代码:使共享库能被加载到任意地址。
- 全局偏移表:通过间接访问解决外部全局变量的地址引用问题,绑定发生在加载时。
- 过程链接表:实现函数调用的延迟绑定,将函数地址解析推迟到第一次调用时,极大地优化了程序启动性能和内存使用。

动态链接是操作系统和编译工具链协同工作的一个经典范例,它通过在复杂度与性能、空间效率之间取得平衡,为现代软件的高效运行奠定了基础。
002:异常控制流 👨💻
在本节课中,我们将要学习异常控制流。这听起来可能有些枯燥,但其内涵远比表面深刻。我们将探讨计算机如何利用这一核心机制来实际运行程序。本质上,我们将学习操作系统如何通过快速挂起和恢复不同程序,来实现多任务并行、程序与外部世界交互等强大功能。
概述:从程序到进程
到目前为止,我们编写的代码主要操作内存地址和寄存器,通过ALU或FPU进行计算,结果存入寄存器或内存。这让我们能实现排序等算法,但最终结果仍只是内存中的不同数据。
那么,像“Hello World”这样的程序是如何与屏幕交互的呢?让我们看一个简单的例子:

#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
这段代码的核心是一个对printf的调用,它最终会通过一个特殊的指令与操作系统交互。程序本身只能访问自己的寄存器和内存,要影响外部世界(如屏幕、网络),必须借助操作系统这个更受信任的实体。异常控制流正是实现这种交互、让多个程序共存、独立写文件、在程序崩溃时不导致系统瘫痪等强大功能的基础。
核心概念定义


在深入探讨异常控制流之前,我们需要明确几个核心概念。
进程
一个进程是一个正在运行的程序实例。从本质上讲,一个进程由两部分构成:
- 一个内存布局。
- 一组存储在寄存器中的值。
对于一个正在运行的进程而言,这就是它的整个世界,也是该程序实例的运行状态。
内核与用户空间
每个运行中的进程都包含两部分:
- 用户级内存和代码:这是程序员编写的代码,例如Chrome或PowerPoint的代码。
- 内核级内存和代码:这是操作系统本身的代码,对用户进程是隐藏的。
一个关键点是:内核总是在某个特定用户级进程的上下文中运行。内核是一系列异常处理程序和特权代码的集合,它拥有访问硬件等额外权限。不存在一个独立于所有用户进程而运行的内核。
异常与异常控制流
异常控制流是我们本周课程的核心。它不仅包括像条件跳转这样的常规控制流改变,更重要的是指从运行用户级代码(非特权模式)跳转到运行内核代码(特权/监管者模式)的过程。这个过程就是通过异常来实现的。
异常控制流非常强大,它使我们能够接收网络数据包、创建新进程、终止进程、从磁盘读取信息、调试程序等。它让我们能利用计算机的全部能力,而不仅仅是汇编代码所抽象的内存和寄存器。
用户模式 vs. 监管者模式
上一节我们介绍了进程和内核的概念,本节中我们来看看权限级别的关键区别。
根本区别
这里需要区分两个容易混淆的概念:
- Root/管理员权限:这是由操作系统定义和管理的更高权限级别。拥有此权限的用户级进程可以执行如打开任意文件等操作。这仍然是用户模式的代码。
- 内核/监管者模式:这是由处理器本身定义和执行的更高权限级别。CPU有一个专门的位来标识当前是运行在特权(监管者)模式还是非特权(用户)模式。
简单来说,Root权限是操作系统层面的权限提升,而内核模式是CPU硬件层面的权限提升。当你运行一个root shell时,你并没有运行在内核中,你只是运行在一个拥有更高系统权限的用户级进程中。
一个类比
为了更好地理解,我们可以做一个类比:
- 用户级进程就像克拉克·肯特(普通人)。
- 监管者模式就像超人(拥有超能力)。
- CPU就像是这个人的身体。
在任何时刻,这个身体(CPU)要么表现为克拉克·肯特(执行用户代码),要么表现为超人(执行内核代码)。两者不能同时激活,一次只能处于一种模式。
异常控制流的工作原理
理解了权限模式后,我们来看看模式切换是如何发生的。
内核的被动性
一个重要的含义是:内核代码无法在用户代码运行时主动“夺取”控制权。当内核将控制权交给用户级代码后,它必须依赖CPU的协作才能重新获得控制权。
具体方法是:内核在交出控制权前,会设置一个定时器。当定时器触发时,CPU会中断当前正在执行的用户指令,强制将控制权交还给内核。这个过程就是异常控制流。它使得操作系统能够在一个进程运行一段时间后,“抢占”它并切换到另一个进程或处理其他事务。
中断向量表
内核本质上是一系列异常处理程序的集合。CPU根据发生的异常类型,知道该调用哪个处理程序。这些信息存储在一个叫做中断向量表的数据结构中。
以下是中断向量表的工作原理:
- 它是一个指针数组,每个条目对应一种特定类型的异常(如中断1、中断2等)。
- 当异常发生时,CPU会根据异常编号,跳转到中断向量表中对应的地址开始执行。
- 同时,CPU会将自己的状态位设置为监管者模式。
- 随后,内核代码开始运行,处理刚刚发生的异常。
异常的分类
异常有多种类型,它们用于不同目的,操作方式不同,产生的副作用也不同。异常主要分为以下四类:
以下是四种主要异常类型的简要说明:
| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
| 陷阱 | 有意的异常(如系统调用) | 同步 | 总是返回到下一条指令 |
| 故障 | 潜在可恢复的错误(如页缺失) | 同步 | 可能返回到当前指令 |
| 终止 | 不可恢复的错误(如硬件错误) | 同步 | 不会返回 |
我们将在接下来的课程中对这四类异常进行更详细的探讨。
总结

本节课中,我们一起学习了异常控制流的核心概念。我们明确了进程是运行中的程序实例,由内存布局和寄存器状态定义。我们区分了用户模式和监管者模式,指出后者是CPU硬件级别的特权状态。我们了解到内核总是代表某个用户进程运行,并通过与CPU协作(如定时器中断)来实现进程调度。最后,我们介绍了异常的四种基本类型:中断、陷阱、故障和终止,它们是程序与操作系统及外部世界交互的根本机制。理解这些概念是掌握操作系统如何管理资源和执行程序的关键一步。
003:异常处理的四种类型 🚨
在本节课中,我们将学习操作系统中的异常控制流,特别是从用户模式切换到内核模式的四种主要方式。我们将详细探讨每种异常的特点、触发原因以及处理方式。


概述
上一讲我们介绍了异常控制流的基本概念。本节中,我们将深入探讨四种不同类型的异常:中断、陷阱、故障和终止。理解这些差异对于掌握操作系统如何管理硬件和软件交互至关重要。
异常与过程调用的异同

异常与过程调用有相似之处,但存在关键区别。过程调用是在同一执行模式下,从一个栈帧跳转到另一个栈帧,从一个指令指针跳转到另一个指令指针。而异常则是从一种执行模式切换到另一种完全不同的执行模式,尽管仍在同一进程内,但运行的代码已完全不同。
用户级代码包括您自己编写的代码以及编译程序时引入的库代码。内核空间的代码和数据则并非由您编写或选择,它由操作系统(如Linux或Windows)提供。所有进程的内核空间部分都运行相同的代码,即操作系统内核的文本,并且共享内核数据,用于跟踪进程状态和管理内存等。
重要结论:当通过异常处理程序从单个指令进入内核时,类似于过程调用,我们会保存状态并跳转到另一个位置。但两者又非常不同,因为在许多情况下,我们并非只是执行一些工作然后返回。
四种异常类型详解
以下是四种主要的异常类型,每种都有其独特的触发机制和处理逻辑。
1. 中断(异步异常)
中断是异步异常,其触发与CPU当前正在执行的指令无关。中断来自外部事件,例如:
- 定时器触发。
- 用户按下键盘上的
Ctrl+C。 - 按下机器上的复位按钮。
- 网络数据包到达。
中断是计算机的正常操作行为。操作系统需要处理这些事件,例如从网卡读取数据或更新屏幕显示。通常,中断不会导致当前运行进程终止。操作系统处理完中断后,会让被中断的进程继续执行。
由于中断来自外部,它们不会破坏当前正在执行的指令。CPU会完成当前指令的流水线操作,然后将控制权转移给中断处理程序,而不是执行用户程序的下一条指令。处理完毕后,控制权会返回给用户级代码的下一条指令。
从用户级代码的视角看,似乎什么都没发生。它按顺序执行指令1、2、3、4。而从操作系统或观察者的视角看,可能在执行指令2时发生了中断,CPU转而执行中断处理程序,完成后又恢复执行用户级的指令3和指令4。
核心流程:
用户指令1 -> 用户指令2 -> [中断发生] -> 中断处理程序 -> 用户指令3 -> 用户指令4
2. 陷阱(同步异常 / 系统调用)
陷阱是同步且有意为之的异常,通常称为系统调用。这是用户程序主动请求操作系统内核执行特权操作的方式。
通过系统调用,用户程序可以将正确的信息存入寄存器和栈中,然后执行一条特殊的指令(如 syscall),从而“唤醒”内核。内核会验证请求的合法性,执行相应的特权操作(如写入文件、发送网络包、创建新进程等),然后将控制权返还给用户程序。
这也是正常且预期的行为。例如,printf 函数底层会通过系统调用请求内核将字符写入标准输出。处理完毕后,控制流会返回并继续执行系统调用之后的下一条用户指令。

一个特殊的系统调用是 exit,它用于终止程序自身。执行 exit 后,程序不会返回,因为已没有下一条指令需要执行。这算是一个例外,它证明了规则:其他系统调用在执行完服务后都会返回用户空间。
核心流程:
用户指令1 -> 用户指令2 -> `syscall`指令 -> 内核处理 -> 返回用户指令3
3. 故障(同步异常,可能可恢复)
故障是同步但非预期的异常,它由当前运行的指令直接导致,但有可能被修复。一个典型的例子是缺页异常。
当程序访问的虚拟内存页面尚未加载到物理内存(RAM)中时,就会发生缺页异常。操作系统会检测到这次访问无法完成,于是暂停当前进程,从磁盘(如SSD)中将所需的页面加载到内存中,然后重新执行那条触发异常的指令。这次,指令通常就能成功执行了。
故障的关键在于,内核负责判断问题是否可修复。如果可修复(如页面在磁盘上),则重试指令;如果不可修复(如访问了非法内存地址),则可能升级为终止。
核心流程:
用户指令1 -> 用户指令2(触发缺页)-> 内核加载缺失页 -> **重试指令2** -> 用户指令3
4. 终止(同步异常,不可恢复)
终止是同步、非预期且不可恢复的异常。它通常由严重的硬件错误(如内存校验错误、电源故障)引起。CPU可以确定发生此类错误后,程序绝无可能继续正常运行。
终止与不可恢复的故障(如访问非法地址导致的段错误)在结果上类似,都会导致进程终止。但区别在于原因:故障通常由软件错误(如程序bug)引起,而终止源于硬件故障。发生终止时,操作系统自身可能也难以恢复,最终可能导致系统崩溃(如蓝屏、内核恐慌)。
核心概念:所有终止都不可恢复,而部分故障可以。
四种异常的对比与记忆
为了更好地区分这四种异常,我们可以借助一些形象的比喻:

- 中断:就像谈话时被他人打断。打断来自外部,处理完后我们可以继续刚才的话题(执行下一条指令)。
- 陷阱:就像通过一扇暗门(陷阱门) intentionally 从一个房间进入另一个房间。这是程序主动、有意地进入内核空间的方式。
- 故障:就像一只站在悬崖边的猫,它可能会掉下去,也可能不会。在操作系统处理之前,结果未知。可能是可恢复的缺页,也可能是不可恢复的段错误。
- 终止:就像卡通里的歪心狼,无论他计划什么,最终总会失败,而且通常不是他代码的问题,而是外部硬件(相当于“阿克米公司”的产品)出了致命故障。终止永远不会成功恢复。
总结

本节课我们一起学习了异常控制流的四种基本类型:
- 中断:异步、来自外部、不影响当前指令、总是返回到下一条指令。
- 陷阱:同步、程序主动触发、用于系统调用、通常返回到下一条指令。
- 故障:同步、由错误指令触发、可能可恢复、可能需重试当前指令。
- 终止:同步、由硬件错误触发、不可恢复、进程被终止。

理解这些异常类型,是理解操作系统如何管理资源、处理错误以及为用户程序提供服务的基石。
004:Linux进程介绍 🖥️
在本节课中,我们将要学习操作系统中的一个核心概念:进程。我们将探讨进程是什么,操作系统如何管理多个进程,以及进程之间如何切换执行。理解这些概念是掌握系统编程和操作系统工作原理的基础。
进程的概念与抽象

上一节我们介绍了异常控制流,本节中我们来看看操作系统如何利用进程这一抽象来运行程序。进程是程序在计算机上执行的一个实例,它包含了程序代码、数据以及当前的执行状态。
操作系统和CPU向你的代码撒了一个最大的“谎言”:它让每个进程都以为自己独占了整个内存地址空间(从0到2^64-1),并且是唯一读写这些字节的实体。这显然不是事实,我们将在后续模块深入探讨如何高效地实现这一点。目前,我们需要理解的是,每个独立的运行进程都有自己独立的“世界观”,这简化了编程,使得进程无需刻意与机器上运行的其他进程协作。
实际上,内核与CPU协作,以多路复用的方式管理计算机资源,使得每个进程都能高效、简单地运行,而无需担心其他进程。
进程的构成与状态
当我们谈论一个进程时,它本质上由两部分构成:内存布局和寄存器状态。此外,从内核的视角看,所有进程都通过一个称为 task_struct 的数据结构来跟踪和管理,它被组织在一个进程数组中。
每个进程都有其状态。除了内存和寄存器内容,操作系统还会维护一些元数据,例如进程可以访问哪些内存区域,以及如何在其虚拟地址空间(一个“虚假”的视图)与实际存储字节的物理内存芯片地址之间进行转换。

此外,每个进程都处于以下三种状态之一:
- 运行:正在CPU上执行指令。
- 就绪:位于就绪队列中,一旦获得CPU访问权即可开始运行。
- 阻塞:进程已进入睡眠状态(主动或被动)。即使此时给它CPU,它也无法推进程序执行,因此没有理由让它占用CPU。
以下是进程状态转换的一个简单示意图:
+---------+ 调度/抢占 +--------+
| |<----------->| |
| 运行 | | 就绪 |
| |<--------+ | |
+---------+ | +--------+
| |
| I/O或事件等待 | I/O完成或事件就绪
| |
v |
+---------+ |
| |---------+
| 阻塞 |
| |
+---------+
进程进入阻塞状态的一个常见原因是等待I/O操作完成,例如从磁盘打开一个文件。在等待的十几毫秒内,CPU可以执行数百万条指令,因此让进程睡眠、等待I/O完成是更高效的做法。
多进程与调度策略
我们引入进程抽象和 task_struct 的目标是,在现代计算机(无论是单CPU还是多CPU)上同时运行多个程序实例。我们希望这些执行看起来是同时发生的,因为大多数现代计算并不会让CPU时刻保持100%负载,程序通常只在响应某些外部刺激(如鼠标点击或网络数据包)时才进行计算。
为了实现多进程并发执行,我们需要做出一些关键的设计决策,这些决策同样适用于服务器或数据库引擎等多任务场景。一个最重要的决策是采用抢占式多任务处理还是协作式多任务处理。
抢占式多任务处理
在抢占式多任务处理中,每个程序都认为自己始终完全控制着CPU和内存地址空间。这个抽象简化了程序的编写。例如,计算圆周率的程序只需不断执行计算,无需考虑其他任务。
然而,现实是操作系统会与CPU“合谋”,定期将你的代码从CPU上“拽下来”,让其他任务运行。程序自身无法控制何时被中断。抢占意味着在其他任务之前取得控制权并运行。

优点:对用户程序透明,编程简单。不同程序无需知道彼此的存在即可“协作”。
缺点:操作系统需要能够有效地“冻结”一个对此毫无察觉的进程,并在之后无误地恢复它。
几乎所有现代通用操作系统(如Linux、Windows、macOS)都采用抢占式多任务处理。
协作式多任务处理
在协作式多任务处理中,每个进程都知道(在某种程度上)还有其他任务需要运行。当它知道可能需要运行其他任务时,它需要自愿地让出处理器控制权。
某些语言环境(如传统的JavaScript)就采用这种方式。你的代码会一直运行直到它主动决定:“我已经运行了足够久,让我们检查一下是否需要运行其他东西。”
优点:进程永远不会在操作中途被意外打断。这有利于保持缓存局部性,实现高性能处理。现代Web服务器(如Node.js)从此模式中获益。
缺点:显著增加了复杂性,将多任务处理的职责耦合到了用户级代码中。用户代码必须包含主动让权的逻辑。
时间片与上下文切换
现在,我们了解了如何通过抢占或协作来共享CPU。从概念上讲,我们是在模拟一个“速度极快”的CPU,通过快速在进程1、进程2、进程3之间切换,给人同时运行的错觉。如果这些进程没有无限的工作要做,操作系统会给每个就绪进程分配一个称为时间片或量子的时间来运行指令。
内核会设置一个定时器中断。例如,它可能告诉CPU:“让我(内核)睡眠,启动进程1,但在10000个CPU时钟滴答后触发一个警报,将控制权交还给我。”届时,内核会检查当前进程是否用完了其公平份额的时间片。如果是,则切换到另一个就绪进程。
这种机制即使是在单CPU系统上,也能创造出并行计算的表象。当我们将此概念推广到多CPU系统时,本质是类似的:每个CPU核心都在独立地为其上运行的进程(或代表该进程运行的内核代码)执行时间片轮转。
上下文切换详解
从一个进程切换到另一个进程的过程称为上下文切换。这可能在进程用尽其时间片时发生,也可能在进程因等待I/O等长时间操作而主动让出CPU时发生。
上下文切换类似于过程调用,但规模更大:
- 保存状态:将当前进程的寄存器(包括程序计数器PC、栈指针SP等)保存到其内核栈中。
- 切换地址空间:切换CPU中用于虚拟地址到物理地址转换的页表。这是关键一步,它使得同一虚拟地址(如
0x600)在不同的进程中可以映射到不同的物理内存位置。 - 恢复状态:将下一个要运行进程的已保存寄存器状态从其内核栈加载到CPU寄存器中。
- 切换执行:将程序计数器设置为该进程的下一条指令地址,并可能从内核模式切换回用户模式。
核心概念示例:假设有两个独立的 bash 进程。它们都有一个全局变量 status,在各自的虚拟地址空间中地址都是 0x600。
- 在进程A中,虚拟地址
0x600映射到物理地址0x1000,存储着值4。 - 在进程B中,虚拟地址
0x600映射到物理地址0x2000,存储着值8。
上下文切换时,我们不是复制整个内存,而是切换页表映射。当CPU为进程A执行时,0x600->0x1000;切换到进程B后,0x600->0x2000。这样,后续的加载/存储指令就会访问正确的物理内存。
这个过程由CPU硬件(如MMU和TLB)高效支持,但对性能仍有影响,因为需要冲刷和重建缓存。
总结
本节课中我们一起学习了Linux进程的核心概念。我们了解到:
- 进程是程序执行的实例,拥有独立的内存空间和寄存器状态。
- 操作系统通过
task_struct管理进程,进程状态包括运行、就绪和阻塞。 - 现代操作系统通过抢占式多任务处理来实现多进程并发,为每个进程分配时间片。
- 上下文切换是进程调度的核心机制,涉及保存/恢复寄存器状态和切换虚拟内存映射,其原理类似于但复杂于过程调用。
- 即使是在多CPU系统上,每个CPU核心也遵循类似的时间片轮转逻辑来执行进程。

理解这些基础概念,是进一步学习进程间通信、同步和内存管理等高级主题的基石。
005:进程创建进阶 🚀
在本节课中,我们将学习如何在操作系统中创建新的进程。我们将从最简单的程序替换开始,逐步深入到更复杂的进程创建方法,包括 fork 和 posix_spawn。我们还将探讨这些方法背后的核心概念、它们的工作原理以及各自的优缺点。通过本节课,你将理解如何从一个进程衍生出多个进程,并掌握在Unix/Linux环境中创建和管理进程的基本工具。
进程与执行上下文回顾
上一节我们介绍了进程的基本概念,本节中我们来看看进程创建的具体方法。

进程是独立的执行上下文。它们被调度在CPU上运行指令,用于支持多用户、多任务,并允许操作系统和程序员将任务分解为独立的执行单元。
进程拥有自己的状态,包括:
- 内存布局:存储可执行代码、全局变量、堆和栈。
- 执行上下文:由寄存器和栈定义,记录了程序的执行历史。
- 操作系统配置:包括打开的文件、信号处理程序等元数据。
现代Linux操作系统采用抢占式调度。内核设置一个定时器,在时间片用完后,无论用户级代码是否自愿让出CPU,内核都会接管并决定下一个运行的线程。
这里需要区分几个术语:
- 线程/执行上下文:指一个独立的控制流。
- 进程:包含一个线程/执行上下文、其内存布局以及操作系统级别的配置(如信号处理)。进程封装了执行上下文,但概念上略有不同。
程序替换:exec 家族
在深入创建新进程之前,我们先看看如何将一个正在运行的程序替换为另一个完全不同的程序。这是进程“归纳”中的基础情况。
exec 系列系统调用用于此目的。最基本的版本是 execv。
#include <unistd.h>
int execv(const char *pathname, char *const argv[]);
它的作用是:用名为 pathname 的新程序,完全替换当前进程的代码、数据、堆和栈。新程序从它的 main 函数开始执行。
以下是 execv 的关键特性:
- 参数传递:
argv是一个以NULL结尾的字符串指针数组,类似于 C 程序的argv。它允许向新程序传递任意数量的参数。 - 环境继承:默认情况下,新进程会继承父进程的环境变量。也可以使用
execve指定新的环境。 - 成功不返回:如果
execv成功,它永远不会返回到调用它的代码,因为原进程的上下文已被完全替换。 - 失败处理:只有失败时(例如,指定的可执行文件不存在),
execv才会返回,返回值是-1,并设置全局变量errno来指示具体错误。
检查 exec 调用失败的标准模式是使用 perror,它能将 errno 的值转换为人类可读的错误信息。
if (execv("/bin/echo", args) == -1) {
perror("execv failed");
// 处理错误
}
总结:execv 替换当前进程,不创建新进程。若成功,永不返回;若失败,通过 errno 报告错误。
创建新进程:posix_spawn
上一节我们介绍了如何替换当前进程,本节中我们来看看如何创建一个全新的、独立的进程。posix_spawn 是一种更直观的方法。
posix_spawn 函数创建一个新的进程,并让其执行指定的程序。父进程和子进程是独立的,同时运行。
#include <spawn.h>
int posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]);
对于初学者,我们主要关注以下参数:
pid:指向一个变量的指针,创建成功后,该变量会被填入新子进程的进程ID。path:要执行的可执行文件路径。argv:传递给新程序的参数列表(类似execv)。envp:传递给新程序的环境变量列表。
创建新进程后,父进程通常需要等待子进程结束,这通过 wait 系统调用实现。
pid_t child_pid;
int status;
// 创建子进程执行 /bin/echo
posix_spawn(&child_pid, "/bin/echo", NULL, NULL, argv, environ);
// 父进程等待子进程结束
waitpid(child_pid, &status, 0);
工作原理:posix_spawn 在内部通过一系列系统调用(最终是 clone)创建新进程,设置其内存空间,然后让其执行 exec 来加载目标程序。父进程和子进程有各自独立的内存空间。
这种方式与Windows的 CreateProcess 思路类似:告诉系统“创建一个新进程,并直接运行这个程序”。
传统的进程创建:fork
最著名但也最令人困惑的Unix进程创建方式是 fork。它的核心特点是:调用一次,返回两次。
#include <unistd.h>
pid_t fork(void);
当 fork() 被调用时,操作系统会创建当前进程的一个几乎完全相同的副本。这个副本称为子进程。从 fork() 返回的那一刻起,系统中就存在两个独立的进程:父进程和子进程。
以下是 fork 的关键行为:
- 返回值区分角色:
- 在父进程中,
fork()返回新创建的子进程的进程ID。 - 在子进程中,
fork()返回 0。 - 如果创建失败,
fork()返回 -1。
- 在父进程中,
- 状态复制:子进程获得父进程当时状态的独立副本,包括:
- 完全相同的内存内容(代码、数据、堆、栈)。
- 相同的程序计数器(即将执行
fork()后的下一条指令)。 - 相同的打开文件描述符表、文件系统状态、信号处理设置等。
- 写时复制:为了提高效率,现代系统使用写时复制。父子进程最初共享物理内存页,只有当任一进程试图修改某个内存页时,系统才会为该进程创建该页的独立副本。这使得
fork通常很快。 - 执行顺序不确定:
fork()返回后,父进程和子进程谁先被操作系统调度执行是不确定的。不能依赖任何观察到的顺序。
一个典型的 fork 用法模式是:在子进程中调用 exec 来运行一个新程序,在父进程中等待子进程结束。
pid_t result = fork();
if (result == -1) {
perror("fork failed");
exit(1);
} else if (result == 0) {
// 子进程:运行 /bin/echo
execv("/bin/echo", argv);
perror("execv failed"); // 只有execv失败才会执行到这里
exit(1);
} else {
// 父进程:等待子进程
waitpid(result, NULL, 0);
}
fork 的问题:虽然功能强大,但 fork 的语义复杂。它复制了整个地址空间,但最常见的用途(紧跟着 exec)却会立即丢弃这个副本,造成不必要的开销。此外,在复制状态下运行自己的代码两份,容易引入逻辑错误。
底层机制:clone 系统调用
无论是 fork 还是 posix_spawn,在现代Linux中,其底层都通过一个更通用、更可配置的系统调用 clone 来实现。
clone 允许精细控制新进程(或线程)与父进程共享哪些资源。
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ... /* pid_t *ptid, void *newtls, pid_t *ctid */ );
flags 参数是一系列选项的位掩码,决定了共享行为:
CLONE_VM:父子进程共享同一个内存地址空间(用于创建线程)。CLONE_VFORK:父进程被挂起,直到子进程调用exec或exit。CLONE_FILES:共享文件描述符表。- 等等。
fork, posix_spawn 与 clone 的关系:
fork()相当于调用clone()时使用一系列默认标志(不共享内存,不挂起父进程),从而创建具有独立内存空间的子进程。posix_spawn()在实现时,可以使用CLONE_VM | CLONE_VFORK等标志调用clone()。这创建了一个与父进程共享内存的子进程,并立即挂起父进程。然后子进程立即调用exec(),在替换自己内存空间的同时,也解除了对父进程的挂起。这种方式避免了复制大量用不到的内存,效率更高。
clone 是Linux特有的,提供了极大的灵活性,是实现容器(如Docker)等技术的基础。
性能比较与实践建议
为了理解不同方法的效率,我们可以进行简单的性能分析。
考虑一个场景:一个进程已经分配了大量内存(例如,通过多次 malloc),然后它需要多次创建子进程来运行一个简单命令(如 /bin/true)。
以下是核心发现:

fork的内存开销:使用fork时,即使有写时复制优化,内核也需要为子进程复制内存映射结构(页表等)。父进程已分配的内存越多,fork调用的开销就越大,即使子进程立即exec丢弃这些内存。posix_spawn的效率:posix_spawn通过使用clone的CLONE_VFORK等标志,避免了复制父进程内存映射的开销。因此,其创建进程的时间开销更稳定,与父进程已分配的内存大小关系不大。- 实践建议:
- 对于简单的“创建并运行新程序”任务,
posix_spawn是更简单、更高效的选择,其API也更直观。 fork在需要完整复制进程状态(例如,实现服务器的工作进程池,其中子进程需要继承父进程的完整连接状态)时仍然有用,但这种情况相对较少。- 理解
fork的语义对于阅读遗留代码和深入理解Unix编程模型至关重要。
- 对于简单的“创建并运行新程序”任务,
总结与展望
本节课中我们一起学习了进程创建的核心机制。
我们首先回顾了进程作为独立执行上下文的概念。接着,我们学习了 exec 系列调用,它可以将当前进程替换为一个全新的程序,但不创建新进程。
然后,我们探讨了两种创建新进程的方法:
posix_spawn:一种更现代、更直观的接口,直接创建新进程并运行指定程序,效率通常更高。fork:传统的Unix方法,通过复制自身来创建新进程。它功能强大但语义复杂,在常见的“创建-执行”模式中可能效率较低。
最后,我们揭示了底层的 clone 系统调用,它是现代Linux中实现进程和线程创建的通用、可配置的基础。
我们还了解到,新创建的进程会继承父进程的许多状态,如打开的文件描述符和信号处理设置。这引出了下一个重要话题:进程间通信。
在下节课中,我们将学习如何利用这些继承的状态(特别是文件描述符)在进程之间建立通信渠道,以及如何管理信号,从而构建出像Shell那样能够协调多个进程协同工作的复杂程序。

本节课中我们一起学习了:进程的替换与创建。掌握了 exec、fork 和 posix_spawn 的基本用法与区别,理解了写时复制和 clone 系统调用的核心思想,并对不同方法的性能有了初步认识。这为我们构建多进程应用打下了坚实的基础。
006:Linux中的信号处理 🚦
在本节课中,我们将要学习Linux操作系统中的信号处理机制。信号是一种允许内核向进程发送简短消息的机制,用于通知进程发生了某些系统事件。理解信号对于掌握进程控制、异常处理和进程间通信至关重要。
信号的基本概念
上一节我们介绍了信号的整体作用,本节中我们来看看信号的具体定义和类型。
信号允许内核向进程发送一个简短的消息,这些消息对应着不同类型的系统事件。信号机制与我们之前学习的异常控制流(如中断)有相似之处,但也有关键区别:信号的处理通常发生在进程从内核模式切换回用户模式时,而不是立即中断当前指令。
以下是一些常见的信号及其含义:
SIGINT:当用户在终端按下Ctrl+C时发送,通常用于中断前台进程。SIGKILL:强制终止一个进程。此信号不能被进程捕获、阻塞或忽略。SIGSEGV:当进程进行非法内存访问(如段错误)时发送。SIGCHLD:当一个子进程终止或停止时,内核会向其父进程发送此信号。SIGSTOP/SIGCONT:分别用于停止(挂起)和继续执行一个进程。SIGUSR1/SIGUSR2:用户自定义信号,没有预定义含义,可由程序员自行定义其用途。
信号的发送与接收
上一节我们了解了信号的类型,本节中我们来看看信号是如何发送和接收的。
信号的发送和接收是异步的。当一个信号被发送给一个进程时,内核会在该进程的待处理信号集中设置相应的位,这类似于升起一个“小红旗”,表示有一个信号需要处理。但信号并不会立即被处理,而是要等到该进程从内核模式切换回用户模式时(例如,在系统调用返回后),内核才会检查待处理信号集并决定是否递送信号。
以下是信号发送与接收的关键点:
- 发送信号:使用
kill函数(注意:kill函数用于发送各种信号,不仅仅是SIGKILL)。 - 接收时机:信号在进程从内核模式返回用户模式时被检查和递送。
- 进程组:信号可以发送给单个进程,也可以发送给整个进程组。这在管理由多个进程组成的“作业”(如管道命令
yes | rm *)时非常有用。
安装信号处理程序
上一节我们知道了信号何时被处理,本节中我们来看看进程如何响应信号。

默认情况下,每个信号都有一个预定义的动作,例如终止进程、忽略或生成核心转储文件。然而,对于大多数信号(除了 SIGKILL 和 SIGSTOP),进程可以自定义其处理方式,即安装一个信号处理程序。
使用 signal 函数可以安装信号处理程序。请注意,signal 函数用于安装处理程序,而 kill 函数用于发送信号。

以下是安装处理程序的几种方式:
- 自定义处理函数:指定一个函数,当信号递送时调用该函数。
SIG_IGN:忽略该信号。SIG_DFL:恢复对该信号的默认处理动作。
示例代码:安装一个简单的信号处理程序
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
// 这是一个简单的信号处理函数
write(STDOUT_FILENO, "Signal received!\n", 17);
}
int main() {
// 安装信号处理程序,处理 SIGINT 信号(Ctrl+C)
signal(SIGINT, handler);
while(1) {
pause(); // 等待任何信号
}
return 0;
}
编写安全的信号处理程序

上一节我们学会了如何安装处理程序,本节中我们来看看编写处理程序时需要注意的安全问题。
编写信号处理程序是棘手的,因为信号是异步发生的,它会中断程序的主控制流。这引入了并发问题:你的处理程序可能会在主程序执行到任何位置时被调用,并修改共享的全局状态,导致难以预测的行为。


因此,信号处理程序应尽可能保持简单。以下是编写安全信号处理程序的指导原则:
- 处理程序要简单:最好只设置一个全局标志,由主程序稍后检查并处理。
- 只调用异步信号安全函数:在信号处理程序中,只能调用那些保证能在信号处理程序中安全执行的函数(如
write)。许多标准I/O函数(如printf)是不安全的。 - 保存和恢复
errno:许多函数在出错时会设置全局变量errno。在信号处理程序入口先保存errno,在返回前恢复它,可以避免干扰主程序的错误检查。 - 临时阻塞信号:如果处理程序需要访问共享数据结构,可以考虑在处理关键部分前阻塞相关信号,以防止重入。
- 使用
volatile声明全局标志:这告诉编译器该变量可能被异步修改,防止编译器进行不安全的优化(如将变量值缓存在寄存器中)。 - 使用
sig_atomic_t类型:对于像标志这样的简单全局变量,使用sig_atomic_t类型可以保证对该变量的读和写是原子的(即不可中断的)。


示例代码:一个安全的信号处理模式
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdatomic.h>

// 使用 volatile 和 _Atomic 确保安全访问
static volatile sig_atomic_t keep_running = 1;
void handler(int sig) {
// 安全操作:只设置一个标志
keep_running = 0;
}

int main() {
signal(SIGINT, handler);
// 主循环检查标志
while (keep_running) {
printf("Working...\n");
sleep(1);
}
printf("Shutting down gracefully.\n");
return 0;
}
阻塞与等待信号

上一节我们关注了处理程序本身,本节中我们来看看如何控制信号的递送时机。
进程可以显式地阻塞某些信号。被阻塞的信号会保持在待处理状态,直到进程将其解除阻塞。这用于保护代码的关键部分不被信号中断。

此外,有一些系统调用可以使进程主动等待信号:
pause():挂起调用进程,直到任何信号递送。sleep():使进程休眠指定的秒数,但如果任何信号递送,它可能会提前返回。返回值会指示剩余休眠时间或被中断。wait()系列函数:等待子进程状态改变,这通常与SIGCHLD信号相关。
需要注意的是,当信号导致一个慢速系统调用(如 read、write、sleep)中断时,该系统调用可能会提前返回,并设置 errno 为 EINTR(表示调用被中断)。健壮的程序需要能够处理这种情况。


总结



本节课中我们一起学习了Linux中的信号处理机制。我们了解了信号是内核通知进程系统事件的一种异步通信方式,学习了常见信号类型、信号的发送与接收时机,以及如何安装自定义信号处理程序。最重要的是,我们探讨了编写安全信号处理程序的挑战和最佳实践,包括处理并发访问、使用异步信号安全函数以及正确管理全局状态。信号是系统编程中一个强大但需要谨慎使用的工具,是理解进程控制和并发编程的基础。
007:Linux中的文件抽象 📁

在本节课中,我们将学习操作系统中的一个核心概念:文件。我们将探讨文件与内存的异同,理解文件作为顺序存储和持久化媒介的特性,并介绍文件描述符这一关键抽象。通过学习,你将明白文件如何作为通用接口,用于与磁盘、键盘、屏幕乃至网络进行通信。
文件与内存的异同
上一节我们介绍了课程概述,本节中我们来看看文件的基本概念。文件的核心操作与我们一直处理的内存操作在本质上是一致的:都是读取和写入字节。无论是从内存加载字节到寄存器,还是将数据写入文件,底层都是对字节流的操作。
然而,实现这些操作的方式存在根本性差异。内存是随机访问的,我们可以直接通过地址(例如 load [800])获取任意位置的字节。文件则被设计为顺序访问。当我们从文件读取时,系统会从一个“光标”位置开始,按顺序返回指定数量的字节,然后移动光标。
以下是导致这种差异的两个主要原因:
- 历史原因:早期存储介质(如磁带、磁盘)的物理特性决定了数据必须按顺序读写。跳转到遥远的位置需要物理移动(如移动磁盘磁头或快进磁带),速度极慢。
- 使用模式:我们通常按顺序解析文件内容(例如,逐行读取文本文件),随机访问特定偏移量的需求并不常见。
因此,文件的顺序字节流抽象非常实用,这也是我们将要探讨的一大区别。
文件的持久性与速度权衡
我们了解了访问方式的差异,现在来看看文件的另一个关键特性:持久性。与易失性的内存(RAM)不同,文件内容在计算机关闭后依然存在。这是一个巨大的优势,确保了数据不会因程序崩溃或断电而丢失。
但持久性也带来了责任和风险。存储在文件中的数据通常非常重要,数据损坏或丢失可能是灾难性的。因此,在操作文件时必须格外小心。
与此同时,文件的持久化带来了性能上的代价:文件操作比内存操作慢得多。从磁盘读取数据的速度远低于从内存读取。为了缓解这种缓慢,系统采用了多种技巧,其中最重要的是缓冲。
例如,当从硬盘读取数据时,驱动程序不会一次只读一个字节。它会一次性读取一个块(如1024或4096字节)到内存中的缓冲区。这样,当程序请求下一个字节时,可以直接从快速的内存缓冲区获取,而无需等待缓慢的磁盘操作。这与CPU缓存的思想类似,但关键区别在于,文件缓冲是由操作系统显式管理的,对程序员是可见的。
强大的流抽象:超越磁盘
文件不仅是磁盘数据的抽象,其顺序流的模型可以映射到多种通信场景,这体现了其设计的强大之处。
- 标准输入/输出:在程序中,我们通过读取名为
stdin的文件来从键盘获取输入,通过向名为stdout的文件写入来向屏幕输出内容。我们并不直接操作硬件缓冲区。 - 进程间通信:两个进程(如父进程与子进程)可以通过共享一个文件描述符进行通信。一个进程向“文件”写入,另一个进程从“文件”读取,实际上就完成了数据的传递。
- 网络通信:这种抽象甚至可以扩展到网络。一个网络套接字(socket)在程序中也被视为一个文件描述符。浏览器与远程服务器之间的数据传输,也可以使用
read和write这样的文件操作接口来完成。
这种统一的接口使得编程模型变得极其简洁和强大。无论数据源是本地文件、用户输入、另一个进程还是网络上的另一台计算机,我们都可以用同一套方式来处理。
核心机制:文件描述符
要实现上述所有强大的功能,操作系统使用了一个核心概念:文件描述符。文件描述符是一个由内核维护的、用于代表已打开文件的整数句柄。我们通过这个整数来执行所有的读写操作。
例如,在C语言中:
int file_descriptor = open("example.txt", O_RDONLY);
char buffer[100];
ssize_t bytes_read = read(file_descriptor, buffer, sizeof(buffer) - 1);
close(file_descriptor);
程序通过 open 系统调用获得一个文件描述符,然后使用 read、write 等系统调用通过该描述符操作文件,最后用 close 关闭。标准输入、输出和错误也有预定义的文件描述符(分别是0、1、2)。
文件描述符是连接用户程序与操作系统各种I/O资源(文件、管道、套接字等)的桥梁,正是它使得“一切皆文件”的哲学得以实现。我们将在后续课程中深入探讨文件描述符的细节。
总结
本节课中我们一起学习了Linux中文件的抽象。我们认识到:
- 文件与内存的核心操作都是字节的读写,但文件是顺序访问,而内存是随机访问。
- 文件具有持久化特性,能长期保存数据,但操作速度远慢于内存,因此系统广泛使用缓冲技术来提升性能。
- 文件的流抽象极其强大,可以统一表示磁盘文件、标准输入/输出、进程间通信通道乃至网络连接。
- 所有这些功能都通过文件描述符这一统一的整数句柄来访问和管理,这是Linux系统“一切皆文件”设计哲学的基石。

理解这些基础概念,是后续学习文件系统实现、I/O多路复用等高级主题的关键。
008:在C语言中与文件交互 📁


在本节课中,我们将学习如何在C语言程序中使用系统调用与文件进行交互。我们将重点介绍五个核心的API:open、close、read、write和lseek。理解这些基础操作是进行系统编程的关键。
概述:文件描述符与核心API
在Unix/Linux系统中,用户进程通过系统调用与操作系统通信,以操作文件。所有文件交互,无论是硬盘上的文件还是其他设备(如终端),都建立在我们即将介绍的几个核心API之上。这些API为我们提供了与文件系统交互的基础。
文件描述符:操作系统的“票据” 🎫

上一节我们介绍了文件交互的基础概念,本节中我们来看看其核心机制:文件描述符。
文件描述符是一个整数,它是操作系统分配给一个已打开文件的“票据”。当进程请求打开一个文件时,操作系统会返回一个文件描述符。此后,每当进程想对该文件进行读、写或定位操作时,只需将这个“票据”(即文件描述符)传递给相应的系统调用即可。
例如,标准输入(stdin)、标准输出(stdout)和标准错误(stderr)在程序启动时默认打开,其文件描述符分别是0、1和2。
打开文件:open 系统调用

了解了文件描述符的概念后,我们来看看如何获取它,即如何打开一个文件。


open 系统调用用于打开或创建一个文件,并返回一个文件描述符。其函数原型如下:
#include <fcntl.h>
int open(const char *pathname, int flags, mode_t mode);
pathname: 要打开的文件路径。flags: 指定打开文件的方式(例如,只读、只写、创建等)。这是一个位掩码,可以使用|操作符组合多个标志。mode: 可选参数。仅在创建新文件(使用了O_CREAT标志)时指定文件的初始权限。
open 调用成功时返回一个非负整数(即文件描述符),失败时返回 -1 并设置全局变量 errno 以指示错误类型。
文件打开标志 (flags)

以下是 open 系统调用中一些常用的标志:



O_RDONLY: 以只读方式打开。O_WRONLY: 以只写方式打开。O_RDWR: 以读写方式打开。O_CREAT: 如果文件不存在,则创建它。需要配合mode参数。O_TRUNC: 如果文件已存在且以可写方式打开,则将其长度截断为0。O_APPEND: 以追加模式打开。每次写操作前,文件偏移量都会被移动到文件末尾。

重要提示:默认情况下,打开文件进行读写时,操作都从文件开头(偏移量0)开始。O_APPEND 标志可以改变写操作的行为,确保总是在末尾添加数据。
文件权限模式 (mode)
当使用 O_CREAT 标志创建新文件时,mode 参数决定了文件的访问权限。Unix文件权限由三组“读(r)、写(w)、执行(x)”权限位控制,分别对应文件所有者、所属组和其他用户。
权限通常用八进制数表示,例如:
0644: 所有者可读写,组和其他用户只读。0755: 所有者可读写执行,组和其他用户可读执行。

可以使用 chmod 命令或在程序中调用 chmod 函数来修改现有文件的权限。
读取文件:read 系统调用
成功打开文件并获得文件描述符后,我们就可以从中读取数据了。
read 系统调用用于从打开的文件描述符中读取数据。其函数原型如下:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
fd: 文件描述符,指定从哪个文件读。buf: 指向内存缓冲区的指针,读取的数据将存放于此。count: 请求读取的字节数。
read 调用成功时返回实际读取的字节数,这个数字可能小于请求的 count。返回 0 表示已到达文件末尾(EOF)。返回 -1 表示发生错误。
关键点:read 的返回值是“短计数”是正常现象,可能发生在多种情况:例如从终端读取(用户未输入足够内容)、从网络套接字读取(数据尚未全部到达)或读取到文件末尾。因此,编程时必须循环读取,直到获取所需数量的数据或遇到EOF。

写入文件:write 系统调用
学会了读取,自然也要掌握如何写入。
write 系统调用用于向打开的文件描述符写入数据。其函数原型如下:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

fd: 文件描述符,指定向哪个文件写。buf: 指向包含待写入数据的内存缓冲区的指针。count: 请求写入的字节数。
write 调用成功时返回实际写入的字节数,这个数字可能小于请求的 count(例如,磁盘空间不足或管道缓冲区满)。返回 -1 表示发生错误。
与 read 类似,程序需要检查返回值以确保所有数据都已成功写入,必要时需重试未写完的部分。
文件偏移与定位:lseek 系统调用
有时我们不需要按顺序读写文件,而是希望跳转到特定位置。lseek 系统调用就是用来做这个的。


lseek 系统调用用于重新定位打开文件的读写偏移量。其函数原型如下:
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
fd: 文件描述符。offset: 偏移量。whence: 解释偏移量的基准位置,可以是:SEEK_SET: 偏移量设置为从文件开头算起的offset字节。SEEK_CUR: 偏移量设置为当前位置加上offset。SEEK_END: 偏移量设置为文件长度加上offset(常用于获取文件大小或定位到末尾)。
调用成功时返回新的文件偏移量,失败时返回 (off_t)-1。
注意:每次 read 或 write 操作都会自动更新文件的当前偏移量。lseek 允许我们显式地、随机地移动这个偏移量。

关闭文件:close 系统调用
最后,当我们完成文件操作后,必须关闭它以释放系统资源。
close 系统调用用于关闭一个文件描述符,释放其占用的资源。其函数原型如下:
#include <unistd.h>
int close(int fd);
fd: 要关闭的文件描述符。
调用成功时返回 0,失败时返回 -1。
重要提示:文件描述符是有限的系统资源。关闭不再需要的文件描述符是一种良好的编程习惯。此外,操作系统会复用已关闭的文件描述符数值,这要求程序员必须谨慎管理文件描述符的生命周期,避免出现使用已关闭描述符的错误。
缓冲的注意事项
文件操作通常涉及缓冲,这是为了提升I/O效率。缓冲行为因文件类型而异:
- 终端:通常采用行缓冲。输入时,直到用户按下回车键,数据才会被送达读取程序。输出时,遇到换行符或显式调用
fflush时,数据才会显示。 - 普通文件:通常采用块缓冲。数据会在内存中积累,直到达到一个块的大小(如4096字节)才进行实际的磁盘写入。
- 无缓冲:可以设置某些文件描述符为无缓冲模式,每个读写操作都立即生效,但这会降低效率。
理解缓冲行为对于调试和实现特定交互逻辑(如实时显示输入)非常重要。
总结

本节课中我们一起学习了在C语言中进行系统级文件操作的核心知识。我们掌握了五个关键的系统调用:open(打开/创建文件)、read(读取数据)、write(写入数据)、lseek(移动文件指针)和 close(关闭文件)。我们深入理解了文件描述符作为操作系统“票据”的核心概念,并探讨了文件权限、缓冲机制以及处理“短计数”返回值的重要性。这些内容是构建更复杂系统程序(如shell、网络服务器等)的基石。
009:是什么与为什么 🧠💾
在本节课中,我们将学习虚拟内存(Virtual Memory)的基本概念及其设计动机。我们将探讨为什么需要虚拟内存,它解决了哪些核心问题,以及其设计思想如何启发我们解决其他软件工程挑战。下一讲我们将深入探讨虚拟内存的实现机制(“如何做”)。
概述:虚拟内存的起源与目标
上一讲我们讨论了存储系统的层次结构。本节中,我们来看看操作系统如何管理内存,以支持多个程序高效、安全地运行。
早期的计算机运行速度相对较慢(例如1赫兹,每秒执行数千条指令),但与从磁盘加载数据的速度相比,CPU仍然快得多。磁盘寻道等操作(如移动磁头)会导致长时间的等待。考虑到这些计算机在当时价值数十万美元,让昂贵的CPU在等待慢速磁盘时闲置,是巨大的资源浪费。
因此,工程师们的核心目标是:最大化CPU利用率,即使存在像硬盘这样高延迟的外围设备。
内存管理的挑战与“坏”方案
我们已经知道,通过保存和恢复少量CPU寄存器的状态,可以快速地在不同程序间切换CPU使用权(多路复用)。然而,内存管理则复杂得多。
内存容量巨大(可达数百万字节),且访问速度(尤其在缓存层次结构的底层)相对较慢。核心挑战在于:如何让多个程序同时使用内存?
以下是两种不可行的“坏”方案:
-
完整交换方案:将整个程序的内存内容在物理内存和磁盘之间来回交换。
- 问题:磁盘速度极慢,且程序运行期间可能需要频繁访问磁盘。频繁进行大量数据交换会带来无法接受的性能开销。
-
静态分区方案:为每个程序静态分配一块固定的物理内存区域。
- 问题:这破坏了操作系统提供的隔离性。程序员需要协调所有其他程序的内存使用,以确保自己的程序有空间运行,这既不现实也不安全。我们希望程序能在一个“沙箱”中独立运行。
既然不能让程序“友好共处”,也不能整体搬移内存,我们该怎么办?
核心解决方案:引入间接层
我们应用软件工程中的一个基本原则(虽非严格定理,却是有效的经验法则):通过引入一个间接层来解决所有问题。
具体到内存管理,我们将在用户程序认为的内存地址(虚拟地址)和数据实际存储的物理内存地址之间,添加一个翻译层。
- 虚拟内存出现前:CPU直接使用物理地址访问内存。指令
LOAD [地址]会直接从该物理地址读取数据。 - 虚拟内存出现后:CPU发出的地址是虚拟地址。硬件(内存管理单元,MMU)和操作系统会通过一个页表,将此虚拟地址翻译成实际的物理地址,然后再进行访问。
现代计算机中,除了某些底层嵌入式系统,都具备这种内存管理单元(MMU)。
虚拟内存的工作原理与优势
其神奇之处在于:每个进程都拥有自己独立的虚拟地址空间和页表。
例如,我的Bash进程、你的Chrome进程、我的C程序,都可以认为自己的代码从地址 0x400000 开始。但实际上,通过各自独立的页表,它们被映射到了物理内存中完全不同的区域。
核心公式/概念:
物理地址 = 页表翻译(虚拟地址)
以下是虚拟内存带来的关键优势:
- 隔离与安全:一个进程无法直接访问或破坏其他进程的内存,因为它只能“看到”自己的虚拟地址空间。
- 简化编程:程序员和编译器可以假设程序拥有连续、独立的巨大内存空间,无需关心物理内存的实际布局。
- 高效的多路复用:无需在程序切换时搬运整个内存映像,只需切换页表(改变映射关系),或按需交换部分内存页。
实现虚拟内存面临的挑战
当然,引入间接层也带来了新的挑战,这将是下一讲的重点:
- 页表需要小巧高效:我们不会为每个字节建立映射(那会导致页表巨大),而是以页(例如4KB)为单位进行映射。我们需要设计高效的数据结构来管理这些映射。
- 翻译速度必须极快:每次内存访问(加载/存储)现在都需要先进行地址翻译。这相当于将一次内存操作变成了至少两次(查页表 + 访问数据)。我们必须通过硬件加速(如TLB,翻译后备缓冲器)等手段,将这种开销降至最低。
- 解锁新功能:这种间接性也带来了许多强大功能的可能性,例如:
- 按需分页:仅在实际需要时才将数据加载到物理内存。
- 内存共享:让不同进程的虚拟页面映射到相同的物理页面,以共享代码或数据。
- 写时复制:高效实现进程创建。
总结
本节课中,我们一起学习了虚拟内存的“是什么”与“为什么”。
- 我们回顾了早期计算机的瓶颈,理解了最大化CPU利用率的核心目标。
- 我们分析了简单内存交换和静态分区方案的缺陷。
- 我们掌握了虚拟内存的核心思想:通过页表在虚拟地址和物理地址之间引入一个间接层。
- 我们认识了虚拟内存带来的主要好处:进程隔离、编程简化和高效内存多路复用。
- 我们也预览了实现虚拟内存必须解决的挑战:页表大小、翻译速度以及如何利用间接性实现更高级的功能。


下一讲,我们将深入探讨虚拟内存的“如何做”,即页表的具体结构、翻译过程以及性能优化技术。
010:页面故障详解 🖥️
在本节课中,我们将深入探讨虚拟内存如何实现物理地址到虚拟地址的映射,以及如何处理页面命中和页面缺失。我们将从虚拟内存解决的核心问题开始,逐步讲解页表、页面命中和页面故障的具体机制。
概述
虚拟内存是现代操作系统的核心机制,它解决了程序运行时的地址定位、内存保护和空间管理三大问题。通过将物理内存和虚拟地址空间分离,操作系统可以为每个进程提供一个独立且受保护的地址视图。本节课将详细解释这一机制的工作原理,特别是当程序访问的页面不在物理内存中时,操作系统如何处理这种“页面故障”。
虚拟内存解决的三大问题
上一节我们介绍了虚拟内存的概念,本节我们来看看它具体解决了哪些核心问题。
1. 地址重定位问题
程序在编译和编写时,无法预知其代码和数据在物理内存中的具体位置。虚拟内存通过引入虚拟地址空间解决了这个问题。编译器和用户进程只需处理虚拟地址,而由CPU和操作系统的内存管理单元负责将虚拟地址动态翻译成物理地址。这个过程对程序是完全透明的。
2. 内存保护问题
操作系统需要隔离不同进程的内存空间,防止它们相互干扰。由于每个进程只能通过自己的虚拟地址空间访问内存,并且这个映射由操作系统控制,因此一个进程无法直接访问或描述另一个进程的物理内存。这自然实现了内存隔离和保护。
3. 空间管理问题
有时物理内存可能不足以容纳所有进程所需的数据。这时,操作系统需要将一些暂时不用的页面“换出”到磁盘,为新的页面腾出空间,这个过程称为页面置换。虽然在现代系统中,由于内存容量增大,频繁的页面置换已不常见,但它仍是虚拟内存设计的基本组成部分。
核心概念与定义
在深入机制之前,我们需要明确几个核心概念。
- 地址空间:每个进程都有自己独立的虚拟地址空间,范围通常从0到一个很大的数(例如 2^n - 1)。
- 虚拟地址:进程视角中的内存地址,只是一个数字。
- 物理地址:实际内存芯片上的硬件地址。
- 页表:由操作系统维护的核心数据结构,它存储了虚拟页到物理页的映射关系以及其他管理信息。页表条目是页表的基本单元。
一个简化的页表条目可能包含以下信息(索引是隐式的):
- 有效位:表示该映射当前是否有效。
- 物理页帧号:如果有效,指出对应的物理页是哪一个。
页面命中处理
当进程访问一个虚拟地址时,硬件(内存管理单元,MMU)会查询页表进行地址翻译。
以下是页面命中的处理流程:
- CPU发出一个虚拟地址。
- MMU根据虚拟地址中的页号部分,找到对应的页表条目。
- 检查该条目的有效位。如果为
1(有效),则获取其中的物理页帧号。 - MMU将物理页帧号与虚拟地址中的页内偏移量组合,得到最终的物理地址。
- 使用该物理地址访问物理内存,完成读/写操作。
这个过程完全由硬件完成,速度非常快。操作系统负责在进程启动或需要时,正确设置页表中的这些有效映射。
页面故障处理
如果MMU在页表条目中发现有效位为0(无效),则会发生一次页面故障。这是一种由硬件检测并触发的特殊异常,它会中断当前用户进程的执行,并陷入操作系统内核。
以下是页面故障的处理流程:
- 触发故障:CPU执行指令时,试图访问一个无效的虚拟地址。
- 陷入内核:CPU自动保存当前进程状态,并跳转到操作系统预设的页面故障处理程序。
- 操作系统介入:
- 操作系统检查故障原因。通常是因为所需的页面尚未加载到物理内存中(可能还在磁盘上)。
- 操作系统从磁盘(或交换区)读取所需的页面数据。
- 在物理内存中找到一个空闲页帧。如果内存已满,则可能需要选择一个现有页面进行置换,将其写回磁盘。
- 将磁盘上的页面数据载入找到的物理页帧。
- 更新页表,使对应的虚拟页映射到新的物理页帧,并将有效位置为
1。
- 恢复执行:操作系统恢复之前被中断的进程状态,并重新执行那条引发故障的指令。这次,指令将遇到一个有效的页表条目,从而正常完成页面命中流程。
需要强调的是,页面故障机制并不仅仅用于内存不足时的页面置换。它更常见的作用是实现按需分页。
按需分页与零页
按需分页是一种“惰性加载”策略,它极大地优化了系统性能。
- 惰性加载的优势:操作系统在启动一个进程时,并不立即将其所有代码和数据加载进内存,而只是为其设置好虚拟地址到磁盘文件的映射(但标记为无效)。只有当进程实际访问某一部分时,才会触发页面故障,从而将该部分加载进内存。这避免了加载可能永远用不到的代码(例如错误处理例程),节省了内存和启动时间。
- 零页的妙用:当进程通过
malloc或brk申请大量内存时,操作系统并不会立即分配真实的物理页面。它只是将这段新虚拟地址空间映射到一个特殊的、全零的零页,并标记为“只读”。当进程首次尝试写入这块内存时,会触发页面故障。此时,操作系统再分配一个全新的、私有的物理页面,复制零页的内容,并更新映射。这避免了对大量未初始化内存进行不必要的清零操作。
虚拟内存的其他优势
除了解决问题和实现按需分页,虚拟内存还带来了其他重要好处:
- 内存共享:多个进程可以将自己虚拟地址空间中的不同区域映射到同一个物理页面。这常用于共享只读的代码段(如C标准库
libc.so)或进程间通信。这显著节省了物理内存。 - 简化内存管理:程序员和编译器无需关心物理内存的布局,只需在连续的虚拟地址空间中工作。内存分配、保护和共享的复杂性都由操作系统和硬件通过虚拟内存机制透明地处理。
总结


本节课我们一起深入学习了虚拟内存中页面故障的详细机制。我们回顾了虚拟内存如何解决地址重定位、内存保护和空间管理三大核心问题。我们明确了页面命中时,由硬件MMU通过页表快速完成地址翻译。重点在于,当发生页面缺失时,会触发页面故障,操作系统介入,负责将所需页面从磁盘加载到内存,更新页表,并重启指令。这一机制不仅是处理内存不足的手段,更是实现“按需分页”这一关键优化策略的基础,它通过惰性加载和零页技术,极大地提升了内存利用率和程序启动速度。最后,我们还看到了虚拟内存如何优雅地支持内存共享,从而进一步优化系统资源的使用。
011:页面表 📄

在本节课中,我们将学习操作系统如何利用页面表,将虚拟内存地址转换为物理内存地址。我们将探讨页面表的结构、地址翻译过程、相关的保护机制,以及如何通过翻译后备缓冲器(TLB)来加速这一过程。
概述

内存地址翻译是CPU与操作系统协同工作的结果。CPU借助内存管理单元(MMU)为每一次加载和存储操作执行翻译,而操作系统则负责设置MMU用于翻译的页面表项。这是一个与具体架构相关的操作。
地址翻译的基本流程
上一节我们概述了CPU与操作系统的分工,本节中我们来看看地址翻译的具体步骤。
当运行用户级代码时,指令通过虚拟页号访问内存。MMU需要找到对应的物理地址。首先,它需要知道页面表在物理内存中的位置,这由操作系统设置的页面表基址寄存器负责。
每个进程都有自己独立的页面表。通过更改基址寄存器的值,就可以切换整个地址空间的映射,这非常高效。
翻译过程如下:MMU使用虚拟页号作为索引,在页面表这个数组中找到对应的页面表项。每个表项大小固定,最重要的信息之一是有效位,用于判断此次映射是否有效。如果有效,MMU就能获得对应的物理页号。
公式:物理地址 = 物理页号 * 页大小 + 页内偏移
需要注意的是,页内偏移在虚拟地址和物理地址中是完全相同的,无需转换。
页面命中与页面故障
现在,让我们深入了解翻译过程中可能发生的两种情况:页面命中和页面故障。
当CPU执行加载或存储指令时,它会将虚拟地址发送给MMU。MMU首先需要从内存中获取对应的页面表项,检查其有效性。如果有效(页面命中),MMU则根据表项中的物理页号,向内存层次结构发起真正的数据访问请求。在整个页面命中过程中,操作系统内核没有执行任何指令,完全由硬件完成。
如果MMU发现页面表项标记为无效,则会触发一个页面故障。此时,CPU会暂停当前用户级指令的执行,将控制权转交给操作系统的异常处理程序(内核代码)。
内核的页面故障处理程序会分析故障原因。原因可能多种多样:可能是非法的访问(如用户程序试图访问内核内存),也可能是合法的但数据尚未载入内存(例如,访问一个被换出到磁盘的页面,或一个文件映射的某部分尚未加载)。
以下是内核处理可恢复页面故障的典型步骤:
- 检查访问权限:确认此次访问是否合法。
- 选择牺牲页(可选):如果物理内存已满,需要选择一个页面换出到磁盘。
- 载入新页面:从磁盘(或创建新的空白页)将所需数据载入物理内存。
- 更新页面表:建立虚拟页到新物理页的映射,并标记为有效。
- 重启指令:故障处理完毕后,重新执行最初触发故障的那条用户级指令。之后,该指令通常会经历一次页面命中流程,成功完成。
页面表项中的元数据
页面表项不仅包含物理页号,还包含重要的元数据,用于实施内存保护和访问控制。
核心的元数据位包括:
- 有效位:指示该映射是否有效。
- 可写位:指示该页内容是否允许写入。
- 用户可访问位:指示用户模式(非内核)代码是否可以访问此页。
- 可执行位:指示CPU是否可以将此页中的内容作为指令来执行。
虚拟页号并不直接存储在表项中,而是通过其在页面表数组中的索引来隐含表示。
不同内存区域的保护位设置
了解了保护位的含义后,我们来看看如何为不同类型的内存区域设置这些位。
以下是典型设置:
- 用户级代码(文本段):
- 可写位:0(不应在运行时修改代码)
- 用户可访问位:1
- 可执行位:1
- 用户级栈:
- 可写位:1
- 用户可访问位:1
- 可执行位:0(栈不应存储可执行代码)
- 用户级堆:
- 可写位:1
- 用户可访问位:1
- 可执行位:0
- 内核代码:
- 可写位:0
- 用户可访问位:0
- 可执行位:1
- 内核栈/堆:
- 可写位:1
- 用户可访问位:0
- 可执行位:0
这些保护规则由CPU和MMU硬件在每次内存访问时进行验证。如果违反规则,硬件会触发异常,由操作系统内核决定如何处理(例如,终止违规进程)。
缓存与内存管理单元的交互
地址翻译过程需要访问内存中的页面表,这自然会涉及到缓存系统。
MMU在获取页面表项或访问用户数据时,都会经过标准的CPU缓存层次结构(L1、L2、L3等)。页面表项本身也会被缓存。这意味着,如果所需的页面表项或用户数据已经在高速缓存中,访问速度会大大加快。
需要记住的关键点是:缓存位于整个CPU(包括MMU)和主内存之间,MMU的访问同样受益于缓存。
翻译后备缓冲器:加速地址翻译 🚀
即使有缓存,每次内存访问仍需两次内存查找(先找页面表项,再找数据),这仍然很慢。为了解决这个问题,现代CPU在MMU内部集成了一个专用于缓存页面表项的特殊高速缓存,称为翻译后备缓冲器。
你可以将TLB理解为MMU专用的页面表项缓存。它容量很小,但利用程序访问的局部性原理,能极大地加速翻译过程。TLB通常采用组相联缓存结构。
工作原理:
- 将虚拟页号划分为标签和索引两部分。
- 使用索引位在TLB中找到对应的组。
- 在该组中,比较所有表项的标签是否与虚拟页号的标签匹配。
- 如果找到匹配项(TLB命中),则直接使用缓存的页面表项完成地址翻译,无需访问主内存中的页面表。
- 如果未找到(TLB未命中),则MMU需要像之前描述的那样,通过缓存/内存获取页面表项,在用于翻译的同时,将其载入TLB以备后续使用。
TLB命中率通常很高(>95%),因此在大多数情况下,地址翻译的开销几乎可以忽略不计。
页面表的大小问题
TLB解决了翻译速度问题,但页面表本身的大小也可能成为问题。让我们计算一下。
假设一个现代64位系统使用48位虚拟地址空间和4KB页大小:
- 页内偏移占12位。
- 虚拟页号占
48 - 12 = 36位。 - 这意味着最多有
2^36个虚拟页。 - 每个页面表项大小通常是64位(8字节)。
- 因此,一个进程的页面表最大可能占用
2^36 * 8 字节 ≈ 512 GB。
这显然是不现实的,每个进程都需要自己的页面表,而物理内存通常远小于这个数字。这个“不方便的真相”引出了多级页表的解决方案,我们将在下一讲中详细讨论。
总结

本节课中我们一起学习了:
- 虚拟地址到物理地址的翻译是CPU(MMU)与操作系统协同工作的过程。
- 页面表是存储映射关系的核心数据结构,每个进程独有。
- 翻译过程可能产生页面命中(硬件直接处理)或页面故障(操作系统介入处理)。
- 页面表项中的保护位(可写、用户可访问、可执行)用于实现内存安全和访问控制。
- 翻译后备缓冲器是MMU内部专用的缓存,能极大加速地址翻译过程。
- 简单的单级页表在大的地址空间下会占用巨大内存,因此需要更复杂的结构(如多级页表)来解决。
012:多级页表 📚


在本节课中,我们将学习多级页表。这是一种解决单级页表在大型地址空间中占用内存过多问题的关键技术。我们将了解其工作原理、优势以及在现代处理器中的实际应用。
概述



上一节我们介绍了单级页表在大型地址空间(如48位虚拟地址)下会变得极其庞大(例如512GB)的问题。本节中,我们来看看多级页表如何通过分层结构,只分配实际使用的内存区域对应的页表项,从而高效地解决这个问题。
多级页表的工作原理
多级页表的核心思想是将虚拟地址中的页号(VPN)部分进行分层解析,而不是用一个巨大的线性表来映射。

- 单级页表映射:
虚拟页号 (VPN) -> 物理页号 (PPN) - 多级页表映射:
VPN 第一部分 -> 二级页表基址->VPN 第二部分 -> 物理页号 (PPN)
在两级页表示例中,第一级页表项(PTE)不再直接指向物理页,而是指向一个第二级页表。然后,我们使用虚拟地址的另一部分作为索引,在第二级页表中查找,最终找到物理页号。
这种设计的最大优势在于,我们只需要为进程实际使用的内存区域分配第二级(及更下级)页表。如果某个大的地址范围(例如数十亿字节)完全没有被使用,那么对应的第一级页表项可以设置为空(NULL),从而完全不需要分配下级页表,节省了大量内存。
以下是多级页表查找过程的简化步骤:

- 获取顶级页表基址:从页表基址寄存器(PTBR)中获取第一级(顶级)页表的物理地址。这个顶级页表必须常驻在物理内存中。
- 索引第一级页表:使用虚拟地址的最高几位作为索引,在第一级页表中找到一个页表项(PTE)。
- 检查并进入下一级:
- 如果该PTE有效,它包含的是下一级页表的物理基址。
- 如果该PTE为空(NULL),则表示这个虚拟地址区域尚未分配,触发页错误(Page Fault)。
- 重复索引过程:使用虚拟地址的下一组位作为索引,在刚找到的下一级页表中进行查找。此过程可能重复多次(现代系统通常有4-5级)。
- 获取物理页号:在最后一级页表中,找到的PTE包含的不再是页表地址,而是最终的物理页号(PPN)。
- 组合物理地址:将物理页号(PPN)与虚拟地址中的页内偏移量(Offset) 组合,得到完整的物理地址。偏移量在翻译过程中保持不变。

现代系统中的实例
以现代64位Linux系统(使用48位虚拟地址)为例,其多级页表结构通常如下:
- 虚拟地址划分:48位地址被划分为
4个9位的页表索引和 1个12位的页内偏移。- 公式表示:
虚拟地址 = [L4索引 (9位)][L3索引 (9位)][L2索引 (9位)][L1索引 (9位)][偏移量 (12位)]
- 公式表示:
- 页表层级:对应4级页表(PMD4, PUD, PMD, PTE)。每一级页表都是一个包含
2^9 = 512个条目的数组。 - 物理地址:最终得到的物理页号(PPN)通常是40位,与12位的偏移量组合成52位的物理地址。

性能与TLB
多级页表听起来会导致多次内存访问(4-5次),从而拖慢速度。但实际上,我们依靠转址旁路缓冲器(TLB) 来解决这个问题。


TLB是MMU中的一个高速缓存,存储最近使用过的虚拟页到物理页的映射。当CPU需要翻译一个虚拟地址时:
- 首先查询TLB。
- 如果命中(TLB Hit),则直接获得物理页号,过程仅需一个时钟周期,极其快速。
- 如果未命中(TLB Miss),才需要执行完整的多级页表遍历。这次遍历的结果会被存入TLB,以备下次使用。
由于程序访问具有局部性(倾向于访问附近的内存),TLB的命中率通常很高,这使得多级页表在提供巨大地址空间和节省内存的同时,保持了高效的地址翻译速度。

虚拟内存的其他优势
多级页表所依赖的虚拟内存机制,还带来了其他重要好处:

- 内存共享:不同的进程可以将各自的虚拟页映射到同一个物理页(例如,共享的C库
glibc)。这样,物理内存中只需保存一份代码副本,所有进程共享,节省了大量内存。 - 内存保护:页表项中的权限位(读、写、执行)使得操作系统可以精细控制每个内存页的访问权限,防止进程越界访问或执行恶意代码。
- 简化内存管理(操作系统视角):对于操作系统内核而言,管理一大块连续的“段”(Segment)或“区域”(Area,如代码段、数据段、堆栈、共享库区)比管理无数个独立的4KB页更为方便。Linux使用
vm_area_struct结构体的链表来跟踪每个进程的虚拟内存区域。当发生页错误时,内核根据这个区域信息来决定如何分配和设置新的页表项,而不是直接操作庞大的页表数组。
总结
本节课中我们一起学习了多级页表。我们了解到,通过将单一大页表拆分为多层结构,系统可以只为实际使用的虚拟地址区域分配页表,从而极大地减少了内存开销。虽然理论上多层查找会增加延迟,但借助TLB缓存,地址翻译在绝大多数情况下都能快速完成。
我们还回顾了虚拟内存带来的关键优势:内存共享提高了物理内存利用率;内存保护增强了系统安全性;从操作系统内核角度看,基于内存区域(段) 的管理模型比纯页式管理更为简洁高效。

虚拟内存是现代操作系统的核心概念之一,它让每个进程都仿佛独占了整个地址空间,同时由硬件和操作系统在幕后高效、安全地管理着有限的物理资源。理解多级页表是掌握虚拟内存工作原理的重要一步。
操作系统:第13讲:动态内存分配器 🧠
概述
在本节课中,我们将学习构建动态内存分配系统的动机与核心概念。动态内存分配器设计涉及多种权衡,关键在于明确需要优化的场景。这是我们在本课程中一直努力构建的系统设计思维的绝佳范例。我们将从问题定义入手,探讨如何实现一个高效的内存分配器。
动态内存分配的必要性
我们知道,动态内存分配是必要的,因为栈和全局变量在编译时就已经确定。程序运行时的工作负载可能要求我们使用更多内存,而这些需求在编译时无法预知。因此,动态内存分配允许我们在运行时申请所需数量的内存。

从高层次看,内存分配包含两个层面:操作系统提供的粗粒度系统,以及本章将要讨论的细粒度系统。
细粒度分配系统与API
我们的细粒度系统使用以下API,这在显式内存分配器中非常常见:
malloc:用于申请新的内存块。free:用于释放不再使用的内存块。
这与Java、JavaScript或Python等语言不同,那些语言的运行时会自动跟踪对象。C语言则允许直接操作原始指针。只要指针指向一个有效的内存区域,就可以访问它,没有任何限制。因此,我们需要显式地管理malloc和free。虽然这很容易出错,但它赋予了我们完全的控制权。
这里需要明确一个术语:当我们谈论一个“块”(block)时,指的是从malloc返回的有效载荷(payload),以及围绕它的任何填充(padding)和元数据(metadata)。因此,一个块的大小指的是整个实体的尺寸,而不仅仅是用户请求的内部部分。

系统调用基础:sbrk与mmap
malloc和free是我们想要实现的细粒度API,但它们建立在更底层的系统调用之上。
malloc通常构建在**sbrk**系统调用之上。每次调用sbrk都会在用户模式和内核模式之间切换,产生一定的开销。因此,我们并不希望在每次内存分配时都调用操作系统。一个在用户层处理字节级分配的库会高效得多,它可以在不切换模式的情况下分配和回收内存。
这种粗粒度与细粒度的划分不仅是设计上的复杂化,也是空间和时间效率上的必要。操作系统只按页(page)的粒度管理内存,而频繁的系统调用在时间上也不划算。现代分配器通常结合使用**sbrk**和**mmap**来获得最佳性能。
sbrk:以页为粒度,调整堆的边界,决定哪些堆地址是合法的。mmap:允许我们将文件映射到内存的任意地址,或者请求操作系统分配一块填充为零的新内存区域。许多malloc实现使用这种方法来提升性能。


综上所述,malloc和free是我们要实现的用户层API,而sbrk和mmap是它们所依赖的系统调用。malloc作为一个库函数,利用这些系统调用来获取大块内存(例如几KB、几MB),然后再将其细分并分配给用户程序。
分配器的设计目标
要让分配器良好工作,需要满足几个关键目标:
1. 正确性
这是最重要的部分。分配器必须是正确的。一旦分配了一块内存给用户,在该用户明确释放之前,这块内存必须保持有效且不被其他任何人触碰。正如一句经验之谈:让程序更快能让你获得赞誉,但让程序正确才能让你保住工作。
2. 时间效率
我们希望分配和释放操作尽可能快,以最小化对用户程序的干扰。这里的“快”不仅指平均时间或最短时间,还包括避免出现巨大的延迟峰值(jitter)。例如,在Java或Python等使用隐式内存管理(垃圾回收)的语言中,有时会出现“垃圾回收暂停”,程序会突然停止运行以整理内存。如果一个通常只需几微秒的free操作突然需要20毫秒,这种差异是巨大的。对于需要低延迟响应的系统(如股票交易平台),这种抖动是致命的。
因此,在专业环境中,我们常关注第95、99或99.9百分位的响应时间(即最慢的那部分请求的耗时),这被称为尾部响应时间。我们希望即使在最坏情况下,操作也能在可接受的时间内完成。
3. 空间效率
我们同样不希望占用过多内存。这涉及到我们上周讨论过的内部碎片和外部碎片问题。
假设操作系统通过sbrk给了我们一大块连续内存空间。我们将其中的部分分配出去后,剩余的空间可能被分割成许多小块。当一个新的内存请求到来时,可能没有任何一个空闲块足够大来满足它,尽管所有空闲块的总和是足够的。这就是外部碎片。
一旦我们将地址交给用户程序,在它们主动释放之前,我们无法收回。因此,我们需要精心安排内存块的放置位置,以更高效地利用内存,减少向操作系统申请更多内存的次数。
核心权衡:时间 vs. 空间
我们需要在时间效率和空间效率之间进行权衡。
- 某些设计可能非常快,但会浪费大量空间(例如,采用最简单、最快的策略)。
- 反之,某些设计可能最大限度地利用了空间,但分配和释放操作较慢。
由于我们无法预知程序将如何请求内存,碎片化几乎必然会发生。在实际的内存分配器设计中,开发者通常会针对常见的、特定的使用模式进行优化。虽然我们在课程中不会深入这些细节,但了解真实系统(如glibc的malloc)如何工作是一个很好的学习方向。
课程内容预告
接下来的课程将涵盖以下内容:
1. 抽象设计与实现
首先,我们将从抽象层面学习如何设计一个显式内存分配器。在获得操作系统提供的内存后,我们如何组织它,以实现时间和空间上的高效。
2. 具体实现剖析
接着,我们将更具体地了解如何利用sbrk来增长和收缩堆,并通过操作字节和位来实现malloc和free功能。我们将看到其中的各个组成部分,以及如何优化空间和时间,在保证正确性的前提下做出设计决策。
3. 作业相关演示
我们还将通过代码演示,更仔细地了解作业4(构建一个分配器)是如何工作的。
4. 补充学习材料
此外,上周的讨论课深入讲解了垃圾收集器的工作原理。结合本周的实验课,你将能很好地理解完成作业所需的知识。建议在开始作业前回顾这些内容。

总结
本节课我们一起学习了动态内存分配器的核心概念。我们明确了动态分配的必要性,介绍了用户层的malloc/free API及其底层的系统调用(sbrk/mmap)。我们深入探讨了优秀分配器的三大设计目标:正确性、时间效率和空间效率,并理解了在时间与空间之间进行权衡的重要性。最后,我们预览了后续课程将如何引导我们一步步设计和实现一个自己的内存分配器。
014:分配器设计 🧠
在本节课中,我们将学习动态内存分配器的设计与实现。我们将探讨如何高效地管理从操作系统获取的大块内存,将其分割并分配给用户程序,同时确保正确性、时间效率和空间效率。
上一节我们介绍了分配器在时间和空间效率上的权衡。本节中,我们来看看实现一个内存分配器所需的各种核心组件。
块头与块尾 📦
当调用 malloc 时,我们返回一个指向有效载荷(payload)起始位置的指针。实际上,在这个指针之前的四个字节存储着特殊信息,称为块头。为了效率,在块的末尾也会复制这四个字节的信息,称为块尾。
一个块的总大小包括有效载荷、为对齐到8字节所需的填充(padding)、块头和块尾。每次分配时,我们都会在块头和块尾存储这些额外信息,以便追踪内存状态。
核心概念:块大小总是8字节的整数倍。这意味着块大小的最低三位总是0。我们可以利用这三位来存储其他信息,例如用最低有效位表示块是否已分配。
// 假设 header 是一个指向块头(整数)的指针
size_t block_size = *header & ~0x7; // 屏蔽低三位得到块大小
int is_allocated = *header & 0x1; // 检查最低位判断是否已分配
隐式空闲列表 🔗
为了高效地追踪内存块而不使用额外的数据结构,我们使用隐式空闲列表。我们将从操作系统获得的大块内存(例如4KB的倍数)分割成连续的块。每个块的头部都存储了其大小。
通过将块大小加到当前块头的指针上,我们可以找到下一个块的头部。这样,我们就在堆中隐式地创建了一个链表。
- 块头:存储块大小和分配状态。
- 遍历:从堆起始位置开始,读取块头大小,指针向前移动该大小,到达下一个块头。
- 结束标记:在堆的末尾设置一个特殊的尾声块,其大小标记为0,表示堆的结束。
这种设计允许我们在不维护独立列表的情况下遍历堆中的所有块。
分配策略:首次适应、最佳适应与下次适应 🎯
当收到分配请求时,我们需要决定使用哪个空闲块。以下是几种常见策略:
以下是主要的分配策略比较:
- 首次适应:从堆起始处开始扫描,选择第一个足够大的空闲块。优点是速度快,但可能导致堆前部产生大量小碎片(外部碎片)。
- 最佳适应:扫描整个空闲列表,选择大小最接近请求的空闲块。这能最大程度减少浪费的空间,但扫描整个列表的时间开销较大。
- 下次适应:从上一次分配结束的位置开始扫描。旨在避免首次适应总是从堆头开始扫描的开销。虽然可能更快,但往往会导致更严重的碎片化,因此实际应用不广。
这些策略体现了在分配速度(时间效率)和内存利用率(空间效率)之间的经典权衡。
合并:对抗碎片化 🧩
频繁的分配和释放会导致堆中出现许多小的空闲块,即使总空闲空间足够,也可能无法满足较大的分配请求(外部碎片)。合并是解决此问题的关键技术。
当我们释放一个块时,我们检查其物理上相邻的前后块是否也是空闲的。如果是,则将它们合并成一个更大的空闲块。
合并操作仅在释放时进行,并且只需检查紧邻的块。这保证了每次合并只需常数时间,避免了复杂的级联合并。
合并只有三种可能情况:
- 仅释放当前块(前后块都已分配)。
- 与前面的空闲块合并。
- 与后面的空闲块合并。
- 同时与前后空闲块合并。
通过在每次释放时立即合并,我们可以保持堆中空闲块的大尺寸,为后续的大分配请求做好准备。
显式空闲列表 ⚡
隐式空闲列表的缺点是,为了寻找空闲块,我们必须遍历所有块(包括已分配的)。为了优化,我们可以维护一个显式空闲列表,其中只链接空闲块。
在空闲块的有效载荷区域(因为已释放,可供分配器使用),我们存储指向前一个和后一个空闲块的指针。这样,寻找空闲块的时间就只与空闲块的数量成正比,而不是堆中块的总数,从而大大提升了分配速度。
核心概念:显式空闲列表将搜索操作从 O(所有块) 优化到 O(空闲块)。
// 显式空闲列表中的节点结构(存储在空闲块内)
struct free_block_node {
size_t size; // 块大小与状态
struct free_block_node* next; // 下一个空闲块
struct free_block_node* prev; // 上一个空闲块
};
此外,由于我们完全控制空闲块的组织方式,我们还可以将其实现为更高效的数据结构,如平衡树或分离空闲列表,以进一步优化特定场景下的性能。


本节课中我们一起学习了动态内存分配器的核心设计思想。我们了解了如何通过块头/块尾来组织内存,利用隐式空闲列表来遍历堆。我们探讨了首次适应、最佳适应等分配策略在时间与空间上的权衡,学习了通过合并来减少内存碎片,以及通过显式空闲列表来加速空闲块的查找。这些组件共同构成了一个正确、高效内存分配器的基础。
015:malloc实现 🧠
在本节课中,我们将通过一个动态内存分配器的实际演示,来学习其工作原理,并了解如何开始完成相关的编程作业。我们将使用GDB调试器来观察内存的内部状态,理解分配、释放、合并以及垃圾回收等核心概念。
概述
我们将分析一个使用自定义内存分配器的简单程序。这个分配器比标准库的malloc简单,但包含了理解动态内存管理所需的核心思想:堆的布局、块头/脚信息、分配与释放策略(如首次适应)以及内存合并。
内存布局与系统malloc的差异


上一节我们介绍了动态内存分配的基本目标。本节中,我们来看看我们实现的简单分配器与系统malloc(如glibc中的实现)在内存布局上的关键区别。
系统malloc为了支持高性能和多线程,实现非常复杂。它会根据分配大小,将内存块放在不同的区域(例如,小对象在堆上,大对象使用mmap映射独立的内存页)。
在我们的简单分配器中,我们一次性向操作系统申请一大块内存(例如20MB)作为“模拟堆”,并在这块连续的内存中管理所有分配。我们使用一个单向空闲链表,采用首次适应算法,并支持内存合并。
以下是查看进程内存映射的命令,它显示了堆和内存映射区域的起始地址:
cat /proc/<PID>/maps

观察内存分配状态
现在,让我们进入GDB调试器,在程序执行到特定位置时暂停,并检查内存的实际内容。这是验证你对内存布局理解是否正确的关键步骤。
我们将使用x命令来检查内存。例如,x/40x ptr会从ptr地址开始,以十六进制格式显示40个单元(每个单元的大小取决于后缀,这里x代表字)的内存内容。
以下是检查内存的示例命令:
(gdb) x/40x ptr

通过观察,我们可以看到:
- 分配给用户的数据区(充满了我们设置的测试模式,如
0x41414141,即字符‘AAAA’)。 - 每个内存块头部和脚部的信息,其中编码了块的大小和分配状态。

分析内存块头信息
每个内存块都有一个头部(header)和可能有的脚部(footer)。头部存储了块的总大小以及分配状态位。
在我们的实现中:
- 块大小:存储在头部的值中,但最低有效位用于表示分配状态。
- 分配状态:如果最低位为1,表示块已分配;为0表示块空闲。
因此,要获取实际的块大小,需要屏蔽掉状态位。公式如下:
实际块大小 = 头部值 & ~0x1
例如,如果头部值是0x19(二进制00011001),则:
- 分配状态:
0x19 & 0x1 = 1(已分配) - 实际大小:
0x19 & ~0x1 = 0x18(十进制24字节)
遍历堆内存块
我们的堆本质上是一个由内存块组成的隐式链表。每个块的头部指明了下一个块的起始位置。
遍历堆的算法伪代码如下:
current_block = heap_start
while current_block 的 size != 0: // 遇到大小为0的块表示链表结束
处理 current_block
current_block = current_block + current_block.size // 移动到下一个块
在GDB中,我们可以手动模拟这个过程:
- 从堆起始地址(
heap_start)读取头部值。 - 屏蔽状态位得到大小
size。 - 当前块地址加上
size,得到下一个块的头部地址。 - 重复此过程。
实现 is_ptr 函数
is_ptr函数是垃圾回收器(mark-and-sweep)的关键部分。它的作用是:给定一个可能是指针的值,判断它是否指向堆中某个已分配块的内部。如果是,则返回该块的头部指针。
其逻辑步骤如下:
- 边界检查:判断给定地址是否位于堆的全局边界(
mem_heap_lo和mem_heap_hi)之内。 - 块查找:从堆起始地址开始遍历每个内存块。
- 范围判断:对于每个块,检查给定地址是否落在
[块起始地址, 块起始地址 + 块大小)这个区间内。 - 返回结果:如果找到包含该地址的块,则返回该块的头部地址;否则返回
NULL。
查找过程的代码逻辑核心如下:
void *current = mem_heap_lo();
while (获取当前块大小(size) > 0) {
if (ptr > current && ptr < (current + size)) {
// ptr 指向当前块内部
return current;
}
current = current + size; // 移动到下一个块
}
return NULL; // 未找到
调试技巧与常见陷阱
在编写内存分配器时,手动验证和理解内存状态至关重要。以下是一些实用的建议:
- 使用清晰的测试模式:像
0x41(‘A‘)或0x42(’B‘)这样的值在内存中很容易被识别,有助于快速定位你的数据。 - 内存不会自动初始化:从
malloc或栈上获取的内存包含旧的、未定义的数据,而不是零。忘记初始化变量是导致难以调试的错误的常见原因。 - 逐步验证:像我们演示的那样,在GDB中逐步执行代码,并在每个关键步骤后检查内存,确保其状态符合你的预期。
- 从概念到代码:先用自己的话描述清楚算法(例如“如何找到包含这个地址的块”),然后在GDB中手动执行每一步来验证逻辑,最后再将这个逻辑翻译成C代码。
总结
本节课中我们一起学习了动态内存分配器的内部工作原理。我们通过GDB调试工具,直观地观察了:
- 简单分配器与系统
malloc在内存布局上的差异。 - 内存块头/脚部信息的编码方式,以及如何解析块大小和分配状态。
- 如何遍历由内存块组成的隐式链表。
is_ptr函数的核心算法,它是实现标记-清除式垃圾回收的基础。- 在系统编程中调试和验证内存状态的重要方法与常见陷阱。

理解这些概念对于完成内存分配器作业至关重要,也是应对相关考试内容的基础。
016:mmap机制 🧠💾
在本节课中,我们将要学习内存映射(mmap)机制。我们将探讨页表如何通过提供一层间接性,来实现诸如按需分页、写时复制和高效文件I/O等强大功能。理解CPU、页表、操作系统和用户代码之间的交互关系,是掌握这些概念的关键。
页表与共享内存
上一节我们介绍了虚拟内存的基本概念,本节中我们来看看页表如何实现内存共享。
页表将进程的虚拟页面映射到物理内存页。这种映射关系是单向的:给定一个虚拟地址,页表可以找到对应的物理地址。这种间接性带来了一个关键特性:多个进程可以将自己不同的虚拟页面映射到同一个物理页面,从而实现内存共享。
例如,进程A的虚拟页面1和进程B的虚拟页面2可以同时映射到物理页面X。这样,两个进程就能看到并操作同一份物理数据。这种共享对于只读的代码段(text)非常高效,因为系统中运行同一程序的多个实例可以共享同一份物理代码拷贝。
按需分页与惰性加载
了解了共享的基础后,我们来看看虚拟内存最巧妙的技巧之一:按需分页。
所有页面最初都被标记为由磁盘支持,并且在映射到内存时被标记为“无效”。这意味着虚拟地址和文件中的特定偏移量之间建立了映射关系,但实际的字节数据还没有被加载到内存中。
这种机制实现了惰性加载。只有当程序真正尝试访问(读取或写入)一个“无效”页面时,才会触发一个缺页异常。此时,操作系统介入,将所需的页面从磁盘加载到物理内存,并更新页表将其标记为“有效”。这样做的好处是,我们避免了将那些最终可能根本用不到的代码或数据加载到内存中,从而节省了宝贵的资源。
内存布局:文件支持与私有区域
在深入更高级的特性之前,我们先明确虚拟内存的布局。
虚拟内存区域主要分为两类:
- 文件支持区域:其内容初始来自磁盘上的文件(如可执行文件的代码段、数据段)。
- 私有(匿名)区域:其内容初始不来自特定文件,例如栈、堆和BSS段(存储未初始化的全局变量)。
BSS段和栈等区域最初被映射到一个特殊的“按需零页”。这是一个内容全为零的物理页面。多个进程的BSS段可以共享同一个“零页”,直到某个进程尝试写入该区域。
使用mmap进行文件I/O
传统上,我们使用 open、read、write 等系统调用来操作文件。每次 read 或 write 都是一次用户态到内核态的切换,对于大量小规模IO操作来说,开销不可忽视。
mmap 提供了一种替代机制。通过 mmap 系统调用,我们可以将一个文件直接“映射”到进程的虚拟地址空间的一片区域。之后,程序可以像访问普通内存一样,通过指针来读取或修改文件内容,而无需频繁调用 read/write。
其核心优势在于:初始的 mmap 调用之后,对映射区域的访问就像访问内存一样高效,只有在发生缺页(数据未加载)或页面被换出时,操作系统才会介入。这对于需要随机访问大文件(如数据库)的场景尤其高效。
以下是使用 mmap 的一个简单概念对比:
// 传统方式:多次系统调用
fd = open("file.txt", O_RDWR);
read(fd, buffer, size);
write(fd, buffer, size);
close(fd);
// mmap方式:一次映射,内存式访问
fd = open("file.txt", O_RDWR);
void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 现在可以直接通过 addr 指针读写文件内容
memcpy(addr, new_data, data_size); // 相当于写入文件
munmap(addr, size);
close(fd);
共享内存映射(MAP_SHARED)
mmap 的一个重要用途是创建进程间共享内存。通过指定 MAP_SHARED 标志,映射的内存区域可以在父子进程间共享。
当一个进程调用 fork() 创建子进程后,如果父进程在 fork 前已经通过 mmap 创建了一个 MAP_SHARED 映射区域,那么子进程将继承这个映射。父子进程对应的虚拟页面将指向同一个物理页面。
因此,任何一个进程对该共享内存的修改,都会立即被另一个进程看到。这为进程间通信提供了一种非常高效(但也是较底层)的方式,就像一个共享的黑板。
写时复制(Copy-on-Write)
然而,并非所有内存都适合共享。例如,可执行文件的数据段(存储全局变量)虽然初始内容相同,但每个进程都可能修改自己的全局变量,我们不希望一个进程的修改影响到另一个进程。
写时复制机制完美地解决了这个矛盾。其核心思想是:在真正需要之前,始终共享页面;只有在某个进程试图写入时,才为该进程创建该页面的独立副本。
以下是其工作原理:
- 初始状态:父子进程的页表项都指向同一个物理页面,并且页面被标记为只读(即使程序认为它是可写的)。
- 当任一进程尝试写入该页面时,由于页面被标记为只读,会触发一个缺页异常。
- 操作系统检查异常,发现该页面是写时复制页面。于是,它会:
- 分配一个新的物理页面。
- 将旧页面的内容复制到新页面。
- 修改当前进程的页表项,使其指向新页面,并将权限改为可写。
- 另一个进程的页表项保持不变,仍然指向原来的只读页面。
- 操作系统返回到用户程序,重新执行那条引发异常的写入指令,此时写入会成功作用于新的私有副本。
通过这种方式,只有在实际发生写入时才会进行物理内存的复制,最大限度地节省了内存。
fork、exec与进程创建
写时复制是 fork 系统调用高效工作的基础。
当调用 fork() 时,操作系统并不会复制父进程的所有物理内存,那样做开销太大。相反,它:
- 创建子进程的页表,并将其设置为父进程页表的副本。
- 将所有可写的页面标记为只读(并标记为写时复制)。
- 将只读的页面(如代码段)保持共享。
这样,fork 调用可以迅速返回。之后,无论是父进程还是子进程,只要它们尝试写入自己的内存,写时复制机制就会为它们创建独立的副本,从而保证进程间的内存隔离。
当调用 exec() 来执行一个新程序时,进程的地址空间会被完全重置。新的页表被建立,其中包含了新程序的代码段、数据段等到磁盘文件的映射。同样,采用按需分页策略,只有实际访问的页面才会被加载到内存,这体现了惰性加载的优势。
mmap与进程创建优化(posix_spawn)
最后,我们看到了 mmap 和写时复制思想如何影响更高级的进程创建接口。
传统的 fork() 后跟 exec() 模式,即使有写时复制优化,仍然需要复制页表结构。如果父进程地址空间很大,但子进程只是要运行一个像 echo 这样的小程序,这个复制开销就显得不必要。
posix_spawn() 函数(内部可能使用 clone 系统调用)就是为了优化这种场景而设计的。它允许更精细地控制新进程的创建过程,可以避免复制不必要的页表,直接为执行新程序做好准备,从而在特定场景下比 fork/exec 组合更高效。


本节课中我们一起学习了 mmap 内存映射机制。我们看到了页表提供的间接层如何实现内存共享、按需分页和写时复制等关键特性。我们还比较了使用 mmap 进行文件I/O与传统 read/write 方式的区别,并了解了 MAP_SHARED 共享内存的用法。最后,我们探讨了写时复制如何使 fork 调用变得高效,以及这些概念如何影响现代进程创建机制。掌握这些知识,有助于你理解操作系统如何高效地管理内存和进程。

浙公网安备 33010602011771号