diff --git a/notes/pics/proc-vm-layout.png b/notes/pics/proc-vm-layout.png
new file mode 100644
index 00000000..7802b1dc
Binary files /dev/null and b/notes/pics/proc-vm-layout.png differ
diff --git a/notes/计算机操作系统 - 内存管理.md b/notes/计算机操作系统 - 内存管理.md
index 98676db5..0f04c33b 100644
--- a/notes/计算机操作系统 - 内存管理.md
+++ b/notes/计算机操作系统 - 内存管理.md
@@ -14,6 +14,9 @@
* [段页式](#段页式)
* [分页与分段的比较](#分页与分段的比较)
* [附录:实测](#附录实测)
+ * [A1 页面置换模拟](#a1-页面置换模拟)
+ * [A2 缺页次数](#a2-缺页次数)
+ * [A3 进程的地址空间布局](#a3-进程的地址空间布局)
* [参考资料](#参考资料)
@@ -42,6 +45,18 @@
64 位系统的地址空间很大,如果用一张页表覆盖全部地址,页表本身就会非常大,因此使用多级页表,只为用到的地址分配下一级页表。例如 x86-64 通常使用 4 级页表,48 位虚拟地址分为 4 个 9 位的页表索引和 12 位的页内偏移。
+MMU 查页表时发现页不在内存中,或者这次访问违反了页的权限(例如写只读页),就会触发缺页异常(Page Fault),由操作系统处理:
+
+1. 保存现场,取得引发缺页的虚拟地址;
+2. 检查地址是否合法,地址不属于进程的任何区域或者权限不符(例如写代码段)时,向进程发送 SIGSEGV 信号,也就是常见的段错误;
+3. 按页的来源准备页框:第一次访问的堆、栈等匿名页分配一个页框并清零;代码和 mmap 映射的文件页先在页缓存中找,找到就直接映射,找不到才分配页框并读磁盘;换出到交换区的页从交换区读回;fork 之后写共享的页时复制一份(写时复制);
+4. 需要页框而内存不足时,先用页面置换算法换出一页,被修改过的页要先写回磁盘;
+5. 更新页表项,回到用户态重新执行引发缺页的那条指令。
+
+需要读磁盘的缺页称为主要缺页(Major Fault),进程要阻塞到 I/O 完成,期间 CPU 去运行其它进程;不需要 I/O 的称为次要缺页(Minor Fault),只需分配、清零或复制页框并修改页表。缺页异常属于异常,在执行某条指令的过程中同步产生,处理完后重新执行这条指令;外部中断由设备异步产生,处理完后继续执行下一条指令,见[中断分类](计算机操作系统%20-%20概述.md#中断分类)。
+
+例如用 mmap 申请 256 MB 的匿名内存后,进程的常驻内存(RSS)几乎不变;逐页写入一遍,触发 65536 次次要缺页(256 MB / 4 KB),RSS 增加到 257 MB,再写一遍就不再缺页。fork 出的子进程把这些页再写一遍,又触发 65536 次缺页,每次复制一页,父进程中的值不受影响(Ubuntu 24.04 实测,见[附录 A2](#a2-缺页次数))。这也说明申请内存只是分配了虚拟地址,真正用到时才占用物理内存。
+
## 页面置换算法
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
@@ -62,7 +77,7 @@
7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1
```
-开始运行时,先将 7、0、1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,会将页面 7 换出,因为页面 7 再次被访问的时间最长。按这个规则走完整个序列共缺页 9 次;同样的序列和 3 个物理块,LRU 缺页 12 次,FIFO 缺页 15 次(逐步模拟,见[附录:实测](#附录实测))。
+开始运行时,先将 7、0、1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,会将页面 7 换出,因为页面 7 再次被访问的时间最长。按这个规则走完整个序列共缺页 9 次;同样的序列和 3 个物理块,LRU 缺页 12 次,FIFO 缺页 15 次(逐步模拟,见[附录 A1](#a1-页面置换模拟))。
### 2. 最近最久未使用
@@ -134,8 +149,19 @@ FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问
需要注意的是,x86-64 的 64 位模式下分段基本不再使用,代码段、数据段的基址都按 0 处理(FS、GS 除外,用于线程局部存储),Linux 等系统只用分页来管理内存。
+虽然不用分段,Linux 仍然把进程的地址空间划分成代码、数据、堆、栈等区域,每个区域是一段连续的虚拟地址,区域之间的大片空白不分配页框。下图是 Ubuntu 上一个小程序打印出的各部分地址(实测,见[附录 A3](#a3-进程的地址空间布局)):
+
+
+
+- 代码段、数据段和 BSS 在低地址,大小在程序加载时就确定了,BSS 存放未初始化的全局变量,加载时清零;
+- 堆从低地址向高地址增长,malloc 申请小块内存时通过 brk 扩大堆,大块内存(glibc 的阈值初始为 128 KB)改用 mmap 单独映射;
+- 栈从高地址向低地址增长,Ubuntu 默认最大 8 MB(ulimit -s);
+- 共享库、mmap 映射的文件和大块内存放在堆和栈之间的内存映射区。
+
## 附录:实测
+### A1 页面置换模拟
+
用 Python 逐步模拟 3 个物理块下的页面置换,引用序列为 OPT 一节中的例子。Clock 的结果与新装入页面的 R 位取 0 还是 1、指针从哪里开始有关,这里装入时置 1:
```text
@@ -146,8 +172,73 @@ FIFO 缺页 15 次
Clock 缺页 14 次
```
+### A2 缺页次数
+
+Docker Desktop 中的 Ubuntu 24.04 容器(Apple M1 Pro,aarch64,内核 6.10.14-linuxkit,页大小 4 KB),gcc 13.3。用 mmap 申请 256 MB 匿名内存,用 getrusage 的 ru_minflt、ru_majflt 统计缺页次数,从 /proc/self/status 读 VmRSS:
+
+```c
+char *p = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+madvise(p, size, MADV_NOHUGEPAGE);
+for (size_t i = 0; i < size; i += page) p[i] = 1; // 逐页写入
+for (size_t i = 0; i < size; i += page) p[i] = 2; // 再写一遍
+if (fork() == 0) {
+ for (size_t i = 0; i < size; i += page) p[i] = 3; // 子进程写入,触发写时复制
+ _exit(0);
+}
+```
+
+```text
+页大小 4096 字节,共 65536 页,MADV_NOHUGEPAGE
+mmap 之后: 次要缺页 +0,RSS 1 MB
+逐页写入后:次要缺页 +65536,主要缺页 0,RSS 257 MB
+再写一遍: 次要缺页 +0
+子进程逐页写入(写时复制):次要缺页 +65536
+父进程中的值仍为 2
+```
+
+容器所在虚拟机的透明大页设置为 always,不调用 madvise 时内核用 2 MB 的大页映射,逐页写入只触发 128 次缺页(256 MB / 2 MB);Ubuntu 默认的设置是 madvise,不主动申请时使用 4 KB 的页,与上面的结果一致。
+
+### A3 进程的地址空间布局
+
+同一个容器,gcc 13.3 编译(默认生成位置无关的可执行文件),开启地址空间布局随机化,打印各部分的地址和 /proc/self/maps(节选):
+
+```c
+int inited = 1024; // 数据段
+int uninited; // BSS
+int main(void) {
+ int local = 0; // 栈
+ char *small = malloc(64); // 堆
+ char *big = malloc(1 << 20); // 超过 mmap 阈值,单独映射
+ printf("代码 main %p\n", (void *)main);
+ // ……依次打印 &inited、&uninited、small、big、printf、&local,再输出 /proc/self/maps
+}
+```
+
+```text
+代码 main 0xaaaaafbc0a18
+数据 inited 0xaaaaafbe0010
+BSS uninited 0xaaaaafbe0018
+堆 small 0xaaaacdcad2a0
+映射 big 0xffff9928f010
+共享库 printf 0xffff993e0590
+栈 local 0xffffcdcad8fc
+aaaaafbc0000-aaaaafbc1000 r-xp 00000000 00:40 841313 /tmp/layout
+aaaaafbe0000-aaaaafbe1000 rw-p 00010000 00:40 841313 /tmp/layout
+aaaacdcad000-aaaacdcce000 rw-p 00000000 00:00 0 [heap]
+ffff9928f000-ffff99390000 rw-p 00000000 00:00 0
+ffff99390000-ffff9952a000 r-xp 00000000 00:40 831743 /usr/lib/aarch64-linux-gnu/libc.so.6
+ffffcdc8d000-ffffcdcae000 rw-p 00000000 00:00 0 [stack]
+$ ulimit -s
+8192
+```
+
+再运行一次,各区域的起始地址都变了(例如 main 为 0xaaaabc070a18、local 为 0xffffd6a9311c),上下顺序不变。
+
## 参考资料
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.
- Silberschatz A, Galvin P B, Gagne G. Operating System Concepts [M]. Wiley, 2018.
- [Page Tables — The Linux Kernel documentation](https://docs.kernel.org/mm/page_tables.html)
+- [getrusage(2) — Linux manual page](https://man7.org/linux/man-pages/man2/getrusage.2.html)
+- [proc_pid_maps(5) — Linux manual page](https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html)
+- [mallopt(3) — Linux manual page](https://man7.org/linux/man-pages/man3/mallopt.3.html)