4. 虚拟内存与内存映射
在第 2 章中,数据的搬运依赖于显式的 I/O 系统调用:分配用户态缓冲区、发起 read 将内核页缓存中的数据复制到该缓冲区、再经由 write 输出。本篇则转向另一种更为直接且优雅的交互范式——内存映射(Memory Mapping):请求内核直接将文件或设备关联到进程的虚拟地址空间中,此后代码仅需通过常规的指针解引用,即可像访问普通内存数组那样无缝读写持久化文件。
要在微观上驾驭内存映射,必须首先建立起对现代进程地址空间本质的清晰认知:为何同一个二进制程序每次重新执行时打印出的指针数值都在随机漂移?为何两个各自运行的并发进程即便打印出数值完全一致的地址,其内部存储的数据却彼此隔离、互不干扰?为何通过 malloc 成功申请到上百兆内存后,系统监控中进程实际占用的物理内存(驻留集 RSS)却毫无波澜?这些微观现象的背后,均统一收敛于现代处理器 MMU 与操作系统虚拟内存子系统的分页置换机制。
本篇将自底向上厘清这一核心版图:从解构 /proc/self/maps 中的虚拟内存区域(VMA)分布出发,深入剖析物理页框、缺页异常(Page Fault)以及按需调页(Demand Paging)的真实触发流程;接着剖析 mmap 的核心标志,理清共享映射(MAP_SHARED)与私有写时复制映射(MAP_PRIVATE)在多进程协作与文件修改上的行为边界;随后利用 mprotect 的页级访问控制,亲手构筑一个“一旦越界单字节即刻触发 SIGSEGV”的硬隔离内存分配器;最后全面权衡传统 read/write 与零拷贝 mmap 在性能开销模型与工程健壮性上的取舍法则。本页的程序都在 code/系统编程/4_虚拟内存与内存映射/ 下,正文只贴关键部分。
1. 每个进程一个地址空间
先看一个十几行的程序。它打印一个全局对象的地址、一个堆上对象的地址和一个局部对象的地址:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_counter = 1;
/* 带参数运行时把 g_counter 设成参数值,并在最后多停一秒,
* 这样同时启动两个进程时,两边的输出能对得上 */
int main(int argc, char *argv[]) {
int local = 2;
void *heap = malloc(16);
if (heap == NULL) {
return EXIT_FAILURE;
}
if (argc > 1) {
g_counter = atoi(argv[1]);
}
printf("&g_counter = %p(值 %d)\n", (void *)&g_counter, g_counter);
printf("heap = %p\n", heap);
printf("&local = %p\n", (void *)&local);
if (argc > 1) {
sleep(1);
}
free(heap);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
连续运行两次:
$ ./aslr
&g_counter = 0x55ca55ec5038(值 1)
heap = 0x55ca838332a0
&local = 0x7ffc3a0b8c24
$ ./aslr
&g_counter = 0x564c9a6b3038(值 1)
heap = 0x564cbd33c2a0
&local = 0x7ffc8e8170542
3
4
5
6
7
8
同一个可执行文件、同一段代码,两次打印的地址没有一处相同。这是地址空间布局随机化 (address space layout randomization, ASLR):内核在每次 exec 时给程序挑一个随机的起始位置,栈、共享库、堆都跟着平移。它让攻击者难以预知某个函数或某块数据落在哪里。
关掉随机化,地址就固定了。setarch 的 -R 选项做这件事:
$ setarch $(uname -m) -R ./aslr
&g_counter = 0x555555558038(值 1)
heap = 0x5555555592a0
&local = 0x7fffffffea34
$ setarch $(uname -m) -R ./aslr
&g_counter = 0x555555558038(值 1)
heap = 0x5555555592a0
&local = 0x7fffffffea342
3
4
5
6
7
8
两次的输出逐字节相同。地址不同是内核每次装载程序时摆放位置不同造成的。教程后面的程序有时要看具体地址,加上 setarch $(uname -m) -R 就稳定了;平时写程序不能依赖任何具体地址。
地址固定之后,还可以做一件事:同时运行两个进程,让它们打印同一批地址。same_addr.sh 做这个实验,两边都用参数把 g_counter 改成不同的值:
$ ./same_addr.sh
进程 1(g_counter = 11):
&g_counter = 0x555555558038(值 11)
heap = 0x5555555592a0
&local = 0x7fffffffea24
进程 2(g_counter = 22):
&g_counter = 0x555555558038(值 22)
heap = 0x5555555592a0
&local = 0x7fffffffea242
3
4
5
6
7
8
9
这一组实测结果直观揭示了虚拟内存体系的核心哲学:两组进程在用户态视角下观察到了数值完全相同的虚拟地址,然而修改其存储内容却绝不会发生交叉污染。程序中代码所打交道的全部指针,本质上都只是进程内部私有命名空间下的“虚拟地址”;该数值本身并不代表真实的物理内存插槽,而是由 CPU 内部的内存管理单元(MMU)依照当前进程所属的独立页表,动态翻译映射为机器硬件上的真实物理页框。这种虚拟化不仅消除了多任务程序对物理地址硬编码的依赖,更为操作系统提供了固若金汤的进程安全隔离保障。
虚拟地址空间
每个进程拥有一个独立的虚拟地址空间。程序里出现的指针值是虚拟地址 (virtual address),CPU 每次访问内存都要经过内存管理单元 (memory management unit, MMU) 把它翻译成物理地址 (physical address)。两个进程里数值相同的虚拟地址,可以对应不同的物理页;同一个物理页也可以同时出现在多个进程的地址空间里,mmap 的共享映射和第 5 章的写时复制都建立在这条性质上。
翻译以页为单位进行。内核把物理内存切成固定大小的页框 (page frame),把每个进程的地址空间切成同样大小的页 (page),页表 (page table) 记录每个虚拟页对应哪个物理页框。页的大小由体系结构决定,程序可以用 sysconf(_SC_PAGESIZE) 查到:
#include <unistd.h>
long page = sysconf(_SC_PAGESIZE); /* 本页的机器上是 4096 */2
3
这个值后面反复用到:mmap 的偏移量必须是它的整数倍,mprotect 的地址要对齐到它,缺页的次数也按它计算。程序里不写死 4096,总是查一次。
2. 读懂 /proc/self/maps
地址空间里有哪些段、每段从哪到哪、对应哪个文件,内核把这张表放在 /proc/PID/maps 里。自己的进程直接读 /proc/self/maps。下面的程序把几类典型对象的地址打印出来,再到 maps 里找出包含这个地址的那一行:
#define _POSIX_C_SOURCE 200809L
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int g_init = 42; /* .data:有初值的全局对象 */
int g_bss; /* .bss:没有初值的全局对象 */
static int s_bss; /* .bss:没有初值的静态对象 */
/* 在 /proc/self/maps 里找包含 addr 的那一行并打印 */
static void show_mapping(const char *what, const void *addr) {
FILE *fp = fopen("/proc/self/maps", "r");
if (fp == NULL) {
perror("fopen");
exit(EXIT_FAILURE);
}
char line[512];
while (fgets(line, sizeof line, fp) != NULL) {
unsigned long start;
unsigned long end;
if (sscanf(line, "%lx-%lx", &start, &end) != 2) {
continue;
}
unsigned long a = (unsigned long)addr;
if (a >= start && a < end) {
shorten_path(line); /* 把工作目录前缀换成 .,输出短一些 */
printf("%-8s %p %s", what, addr, line);
break;
}
}
fclose(fp);
}
int main(int argc, char *argv[]) {
int local = 7;
void *small = malloc(1000);
void *big = malloc(1024 * 1024);
if (small == NULL || big == NULL) {
return EXIT_FAILURE;
}
show_mapping("main", (const void *)main);
show_mapping("g_init", &g_init);
show_mapping("g_bss", &g_bss);
show_mapping("s_bss", &s_bss);
show_mapping("small", small);
show_mapping("big", big);
show_mapping("local", &local);
if (argc > 1) {
dump_all(); /* 加 -a 参数时打印完整的 maps */
}
free(big);
free(small);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
shorten_path 与后面 -a 用到的 dump_all 的实现在 code/系统编程/4_虚拟内存与内存映射/layout.c 里,前者只是把 maps 行里的绝对路径改成 ./layout,免得贴出来太长。用 setarch 关掉随机化再运行:
$ setarch $(uname -m) -R ./layout
main 0x555555555489 555555555000-555555556000 r-xp 00001000 08:04 2507160 ./layout
g_init 0x555555558088 555555558000-555555559000 rw-p 00003000 08:04 2507160 ./layout
g_bss 0x55555555809c 555555558000-555555559000 rw-p 00003000 08:04 2507160 ./layout
s_bss 0x5555555580a0 555555558000-555555559000 rw-p 00003000 08:04 2507160 ./layout
small 0x5555555592a0 555555559000-55555557a000 rw-p 00000000 00:00 0 [heap]
big 0x7ffff7cc0010 7ffff7cc0000-7ffff7dc4000 rw-p 00000000 00:00 0
local 0x7fffffffea1c 7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]2
3
4
5
6
7
8
设备号、i 节点号和最后一段的路径在不同机器、不同次运行时不同,地址一列在关掉随机化后是稳定的。
主函数落在一个 r-xp 映射里,这一段的偏移是 0x1000,说明它是可执行文件里第二个可加载段。字符串常量和 const 对象落在紧挨其上的 r--p 段里,只读、不可执行。g_init 有初值,g_bss 和 s_bss 没有初值,三者却落在同一行 rw-p 映射里:
555555558000-555555559000 rw-p 00003000 ... ./layout这是链接器和内核合作的结果。.data 和 .bss 在可执行文件里属于同一个可读写段,装载时整段映射一次,.bss 的部分在文件里不占空间,映射时补零。第一部分的 15.3 存储期与链接 从语言角度分了静态存储期和分配存储期,这里看到的是它在地址空间里的样子:静态存储期的对象在程序启动时就摆在固定的几段里,分配存储期的对象则出现在 [heap] 或匿名的映射里。
small 是 malloc(1000) 的结果,地址在 [heap] 里;big 是 malloc(1024 * 1024) 的结果,地址在一段没有名字的匿名映射里,两者相差很远。原因在第 5 节。local 在 [stack]。映射的这一段从 0x7ffffffde000 到 0x7ffffffff000,当前只有 132 KiB:栈向下增长,用到更低的地址时内核再把那一段接上,能长到多大由 ulimit -s 决定,本机是 8 MiB。
把整个 maps 打出来,还能看到共享库和两个特殊映射:
完整的 maps(layout -a,节选)
555555554000-555555555000 r--p 00000000 08:04 2507160 ./layout
555555555000-555555556000 r-xp 00001000 08:04 2507160 ./layout
555555556000-555555557000 r--p 00002000 08:04 2507160 ./layout
555555557000-555555558000 r--p 00002000 08:04 2507160 ./layout
555555558000-555555559000 rw-p 00003000 08:04 2507160 ./layout
555555559000-55555557a000 rw-p 00000000 00:00 0 [heap]
7ffff7cc0000-7ffff7dc4000 rw-p 00000000 00:00 0
7ffff7dc4000-7ffff7dec000 r--p 00000000 08:04 19925823 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7dec000-7ffff7f4f000 r-xp 00028000 08:04 19925823 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7f4f000-7ffff7fa5000 r--p 0018b000 08:04 19925823 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7fa5000-7ffff7fa9000 r--p 001e0000 08:04 19925823 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7fa9000-7ffff7fab000 rw-p 001e4000 08:04 19925823 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7fab000-7ffff7fb8000 rw-p 00000000 00:00 0
7ffff7fbf000-7ffff7fc1000 rw-p 00000000 00:00 0
7ffff7fc1000-7ffff7fc5000 r--p 00000000 00:00 0 [vvar]
7ffff7fc5000-7ffff7fc7000 r-xp 00000000 00:00 0 [vdso]
7ffff7fc7000-7ffff7fc8000 r--p 00000000 08:04 19925818 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffff7fc8000-7ffff7ff0000 r-xp 00001000 08:04 19925818 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffff7ff0000-7ffff7ffb000 r--p 00029000 08:04 19925818 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffff7ffb000-7ffff7ffd000 r--p 00034000 08:04 19925818 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffff7ffd000-7ffff7ffe000 rw-p 00036000 08:04 19925818 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
可执行文件和 libc.so.6 都被拆成好几段,权限各不相同。代码段是 r-xp,只读常量段是 r--p,可写数据段是 rw-p。同一块只读的代码页可以同时映射进几十个进程,物理内存里只有一份;可写的数据段则各自私有。
[vdso] 是内核映射进每个进程的一小段代码,clock_gettime 之类的调用从这里直接返回,不进内核,第 6 章会用到它。[vvar] 是 vDSO 读取内核数据用的页面。[heap] 是最初的堆,再往后的大块分配不一定还在这段里。
maps 每一列的含义
| 列 | 含义 |
|---|---|
| 地址范围 | 起始-结束,结束地址不含,单位是字节的十六进制 |
| 权限 | 四个字符:r 读、w 写、x 执行、- 无;第四个字符 p 表示私有(写时复制),s 表示共享 |
| 偏移 | 这段映射对应文件里的起始偏移,按页对齐 |
| 设备 | 文件所在设备的 主设备号:次设备号;匿名映射显示 00:00 |
| i 节点 | 文件在设备上的 i 节点号;匿名映射显示 0 |
| 路径 | 映射的文件名;方括号里的是内核给的伪路径 |
常见的伪路径有 [heap](堆)、[stack](主线程的栈)、[vdso]、[vvar]。匿名映射的路径一列是空的。文件被删除后仍在使用时,路径后面会加上 (deleted)。
g_init、g_bss、s_bss 三个对象的地址都在 0x5555555580xx 一带,彼此只差几个字节,说明编译器把它们排在了一起。.data 在前,.bss 在后,中间没有空隙。
3. 按需分配与缺页
malloc 返回一个指针,程序马上就能用,但内核在 malloc 返回的那一刻并没有把物理内存交出去。它只修改了进程的页表,记下这一段虚拟地址将来对应哪些页,物理页框等到真正访问时才分配。第一次访问某个虚拟页时,CPU 在页表里找不到有效的表项,触发一个缺页 (page fault),内核接管 CPU,补上映射,再从出错的那条指令继续执行。程序感觉不到这一停顿。
缺页分两种。所需的页已经在内存里(比如零页、页缓存里的文件页、或被写时复制过的页),内核只需要建立映射,这叫次要缺页 (minor fault);需要从磁盘把内容读进来,叫主要缺页 (major fault)。getrusage 把两种分别记在 ru_minflt 和 ru_majflt 里,/proc/self/status 里的 VmRSS 记录当前驻留集大小 (resident set size, RSS),即已经占了物理内存的部分。
下面的程序把这三样一起观察。它分配 32 MiB,先看 malloc 返回后的数字,再逐页写一个字节,再看一遍:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/resource.h>
#include <unistd.h>
#define SIZE (32UL * 1024 * 1024)
struct sample {
long vm_size; /* 虚拟地址空间大小,KiB */
long rss; /* 驻留集大小,KiB */
long minflt;
long majflt;
};
static struct sample take(void) {
/* 读 /proc/self/status 的 VmSize、VmRSS,再取 getrusage 的两个缺页计数 */
struct sample s = {0, 0, 0, 0};
struct rusage ru;
if (getrusage(RUSAGE_SELF, &ru) == -1) {
perror("getrusage");
exit(EXIT_FAILURE);
}
s.minflt = ru.ru_minflt;
s.majflt = ru.ru_majflt;
FILE *fp = fopen("/proc/self/status", "r");
if (fp == NULL) {
perror("fopen");
exit(EXIT_FAILURE);
}
char line[256];
while (fgets(line, sizeof line, fp) != NULL) {
if (strncmp(line, "VmSize:", 7) == 0) {
sscanf(line + 7, "%ld", &s.vm_size);
} else if (strncmp(line, "VmRSS:", 6) == 0) {
sscanf(line + 6, "%ld", &s.rss);
}
}
fclose(fp);
return s;
}
int main(void) {
long page = sysconf(_SC_PAGESIZE);
/* 关掉透明大页:这台机器的 THP 是 always,一次缺页可能装进 2 MiB,
* 那样缺页次数就对不上 4 KiB 的页数了 */
if (prctl(PR_SET_THP_DISABLE, 1, 0, 0, 0) == -1) {
perror("prctl");
return EXIT_FAILURE;
}
struct sample a = take();
char *p = malloc(SIZE);
if (p == NULL) {
perror("malloc");
return EXIT_FAILURE;
}
struct sample b = take();
for (size_t off = 0; off < SIZE; off += (size_t)page) {
p[off] = 1;
}
struct sample c = take();
report("启动时", a);
report("malloc 32 MiB 返回后", b);
report("每页写 1 字节后", c);
printf("页大小 %ld,共 %lu 页;写入阶段 minflt 增加 %ld\n", page,
SIZE / (unsigned long)page, c.minflt - b.minflt);
free(p);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
完整程序在 code/系统编程/4_虚拟内存与内存映射/demand.c,上面省掉了 report 的实现,它只是把一行数字排版打印出来。运行:
启动时:VmSize 2552 KiB,VmRSS 1464 KiB,minflt 79,majflt 0
malloc 32 MiB 返回后:VmSize 35324 KiB,VmRSS 1676 KiB,minflt 89,majflt 0
每页写 1 字节后:VmSize 35324 KiB,VmRSS 34440 KiB,minflt 8280,majflt 0
页大小 4096,共 8192 页;写入阶段 minflt 增加 81912
3
4
malloc 返回时,VmSize 一次涨了 32772 KiB,正好是 32 MiB 加上分配器多要的一个页;VmRSS 只涨了两百多 KiB,缺页只多了 10 次。32 MiB 的地址范围已经划进进程,物理内存一页也没有多占。逐页写一遍之后,VmRSS 涨到 34 MiB 左右,缺页增加 8191 次,正好比 32 MiB 除以 4096 少一页。
少的那一页是因为 malloc 返回时就已经碰过开头:分配器把自己的块头放在块的前几个字节,写块头时那一页已经驻留了。用 mincore(Linux 专有,_GNU_SOURCE)查一遍,malloc 刚返回时 8192 页里只有第 0 页在内存中。
按需分配
malloc 返回的地址范围已经属于进程,但对应的物理页框在第一次访问时才分配,这一步由缺页异常完成。判断一个进程实际占了多少内存要看 RSS,不能看申请了多少字节;申请成功也不代表内存已经到手。Linux 默认允许过量分配 (overcommit),若干进程一起把内存用超时,内核的 OOM killer 会挑一个进程杀掉。
上面程序里的 prctl(PR_SET_THP_DISABLE, 1, ...)(Linux 专有)是为了让缺页计数和 4 KiB 的页对得上。这台机器的透明大页 (transparent huge page, THP) 设置是 always,内核对连续的大块匿名内存可能一次映射 2 MiB,这时写满 32 MiB 只产生几十次缺页。关掉 THP 之后,一次缺页正好对应一个 4 KiB 页,计数才和页数一致。读者在自己机器上做实验时先看一眼 /sys/kernel/mm/transparent_hugepage/enabled,缺页次数远小于页数的就是这个原因。
4. 用 mmap 映射文件
缺页机制也服务于文件映射。内核可以把一个文件的一段接到地址空间里,访问这片内存就是访问文件里的对应位置,这份映射叫内存映射 (memory mapping)。
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);2
3
4
addr 传 NULL 让内核挑地址,length 是映射的字节数,fd 是打开的文件,offset 是文件里的起始位置。prot 和 flags 决定这片内存怎么用:
prot | 含义 |
|---|---|
PROT_READ | 可读 |
PROT_WRITE | 可写 |
PROT_EXEC | 可执行 |
PROT_NONE | 不可访问,任何访问都触发 SIGSEGV |
flags | 含义 |
|---|---|
MAP_SHARED | 修改对其他映射同一文件的进程可见,并会写回文件 |
MAP_PRIVATE | 写时复制,修改只在本进程可见,不写回文件 |
MAP_ANONYMOUS | 不关联文件,得到一段清零的内存,fd 传 -1 |
mmap 失败时返回 MAP_FAILED(值为 (void *)-1),不是 NULL,判断写成 p == MAP_FAILED。offset 必须是页大小的整数倍,否则得到 EINVAL。
最简单的用法是把整个文件映射进来数行数。mapcount 映射文件,逐字节数换行,然后与 wc -l 对照:
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
int fd = open(argv[1], O_RDONLY | O_CLOEXEC);
if (fd == -1) {
perror(argv[1]);
return EXIT_FAILURE;
}
struct stat st;
if (fstat(fd, &st) == -1) {
perror("fstat");
return EXIT_FAILURE;
}
char *p = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (p == MAP_FAILED) {
perror("mmap");
return EXIT_FAILURE;
}
long lines = 0;
for (off_t i = 0; i < st.st_size; i++) {
if (p[i] == '\n') {
lines++;
}
}
printf("%ld\n", lines);
if (munmap(p, (size_t)st.st_size) == -1) {
perror("munmap");
return EXIT_FAILURE;
}
close(fd);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
$ seq 1 200000 > big.txt
$ wc -l big.txt
200000 big.txt
$ ./mapcount big.txt
2000002
3
4
5
映射建立之后,文件内容按页读进内存,p[i] 的访问触发的是次要缺页(文件页已在页缓存里)或主要缺页(需要读盘),不再是 read 系统调用。filemap 把映射那一行从 maps 里找出来:
$ ./filemap big.txt
文件大小 1288895 字节,映射到 0x7f2fe4f03000
7f2fe4f03000-7f2fe503e000 r--p 00000000 08:04 2507167 ./big.txt2
3
文件有 1288895 字节,占不满 315 页。映射长度向上取整到 4096 的整数倍,于是 maps 里这一行正好是 315 页。最后一页里超出文件末尾的那几个字节读到 0,再往后的页根本不在映射里。
4.1 MAP_SHARED 与 MAP_PRIVATE
文件映射最要紧的选择是 MAP_SHARED 还是 MAP_PRIVATE。下面的程序以读写方式打开文件,按参数选择标志,改掉第一个字节,再打印本进程看到的内容:
int flags = shared ? MAP_SHARED : MAP_PRIVATE;
char *p = mmap(NULL, (size_t)st.st_size, PROT_READ | PROT_WRITE, flags, fd, 0);
if (p == MAP_FAILED) {
perror("mmap");
return EXIT_FAILURE;
}
printf("映射前看到:%c\n", p[0]);
p[0] = 'X';
printf("改完后本进程看到:%c\n", p[0]);
if (shared && msync(p, (size_t)st.st_size, MS_SYNC) == -1) {
perror("msync");
return EXIT_FAILURE;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
两种模式各跑一次,文件内容分别是:
$ printf 'hello world\n' > n1.txt
$ ./mapwrite shared n1.txt
映射前看到:h
改完后本进程看到:X
$ cat n1.txt
Xello world2
3
4
5
6
$ printf 'hello world\n' > n2.txt
$ ./mapwrite private n2.txt
映射前看到:h
改完后本进程看到:X
$ cat n2.txt
hello world2
3
4
5
6
同样的代码、同样把 p[0] 改成 X,MAP_SHARED 的改动出现在文件里,MAP_PRIVATE 的改动只留在本进程。前者写的是页缓存里的那一页,其他进程读同一个文件立刻能看到,msync 只负责把它写回磁盘;后者在第一次写时才复制出一份私有副本,之后的读写都在副本上。
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
p[0] = 'X'; /* cat 看到的仍是 hello world */2
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
p[0] = 'X'; /* cat 看到 Xello world */2
写时复制
MAP_PRIVATE 的映射在建立时并不复制文件内容,页表项先指向页缓存里的同一批页,并被标成只读。任何一方写入时触发缺页,内核复制这一页,把写方的页表项改为指向新页框并恢复可写,此后两边的修改彼此不可见。第 5 章的 fork 用同一套机制复制地址空间:父子进程先共享全部物理页,谁写谁复制。
MAP_PRIVATE 还有一个用处:即使文件以只读方式打开,也可以映射成 PROT_READ | PROT_WRITE,因为写的永远是私有副本。上面的 mapwrite 用 O_RDWR 打开是为了让 MAP_SHARED 也能跑。
映射建立之后文件被截短,再访问被截掉部分对应的页,进程收到 SIGBUS(总线错误),shell 报 Bus error,退出状态 135。这和数组越界的 SIGSEGV 不是同一个信号。
5. 匿名映射与 malloc 的内存来源
不关联文件的映射叫匿名映射 (anonymous mapping)。MAP_PRIVATE | MAP_ANONYMOUS 得到的是一段清零的内存,内核保证里面的字节全是 0,不需要 memset。这正是 malloc 拿大块内存时用的手段:
char *p = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return EXIT_FAILURE;
}2
3
4
5
anon 程序映射 16 KiB 后逐字节检查,非零字节数是 0:
$ ./anon
匿名映射 16384 字节,非零字节数 0
写入后读出:hello anonymous
munmap 返回 02
3
4
MAP_SHARED | MAP_ANONYMOUS 得到一段可以在父子进程之间共享的内存,这是 fork 之后最简单的共享手段。shared_counter 用参数选择两种标志,子进程写入 42,父进程等待后读同一个指针:
$ ./shared_counter shared
子进程写了 42,父进程看到 42
$ ./shared_counter private
子进程写了 42,父进程看到 02
3
4
MAP_SHARED 的匿名映射在第 7 章的共享内存里会正式用到,第 5 章讲 fork 时也会回到这里。
malloc 从内核拿内存有两条路:小块从堆顶扩展,大块直接 mmap。分界线由分配器的阈值决定,glibc 默认是 128 KiB。下面的程序依次分配 1000 字节、1 MiB、1 MiB,每次分配后立刻释放:
int main(void) {
static const size_t sizes[] = {1000, 1024 * 1024, 1024 * 1024};
for (size_t i = 0; i < sizeof sizes / sizeof sizes[0]; i++) {
char *p = malloc(sizes[i]);
if (p == NULL) {
return EXIT_FAILURE;
}
p[0] = 'x'; /* 碰一下,避免只看到分配、看不到实际使用 */
printf("malloc(%zu) = %p\n", sizes[i], (void *)p);
free(p);
printf("free 完成\n");
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./malloc_trace
malloc(1000) = 0x55b97c8302a0
free 完成
malloc(1048576) = 0x7fa2e580f010
free 完成
malloc(1048576) = 0x55b97c8316a0
free 完成2
3
4
5
6
7
第一次分配的地址以 0x55b9 开头,在堆里;第二次以 0x7fa2 开头,在一段匿名映射里;第三次又回到了堆。用 strace 只看内存相关的系统调用,能看清每一步:
brk(NULL) = 0x55ce1924d000
brk(0x55ce1926e000) = 0x55ce1926e000
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ff0683cb000
munmap(0x7ff0683cb000, 1052672) = 0
brk(0x55ce1936f000) = 0x55ce1936f0002
3
4
5
malloc(1000) 没有单独的 mmap,堆顶通过 brk 从 0x55ce1924d000 长到 0x55ce1926e000,涨了 132 KiB,够分配器切出很多小块。malloc(1 MiB) 直接 mmap 了 1052672 字节,比 1 MiB 多一个页,多出来的是分配器自己的块头。free 之后,这块用 munmap 还给了内核。第二次 malloc(1 MiB) 只让 brk 又涨了 1052672 字节,没有再 mmap。
原因在 glibc 的动态阈值。默认阈值是 128 KiB,大于等于它的分配走 mmap。这样一块被释放时,分配器把阈值提高到这块的大小,于是下一次同样大小的分配改从堆里切,不再 mmap。阈值最高能涨到 32 MiB(64 位机器上)。重复这个实验时,第二次分配的位置和第一次不同,就是这个机制在起作用,程序没有写错。
glibc 的 mmap 阈值与堆裁剪
malloc 判断走哪条路,看的是 M_MMAP_THRESHOLD,默认 128 KiB。释放一块由 mmap 得来、大小超过当前阈值但不超过上限的块时,阈值会提高到这块的大小;上限在 64 位机器上是 32 MiB,32 位机器上是 512 KiB。一旦用 mallopt 显式设置过 M_MMAP_THRESHOLD、M_TRIM_THRESHOLD、M_TOP_PAD 或 M_MMAP_MAX,动态调整就关闭。
堆顶的空闲空间要积到 M_TRIM_THRESHOLD(默认 128 KiB)以上,free 才会用 brk 把内存还给内核。所以大量小对象的程序释放之后,VmRSS 往往不会立刻下降。
这两个阈值既可以通过程序内的 mallopt 接口动态设定,也可以在进程启动前通过环境配置项 MALLOC_MMAP_THRESHOLD_、MALLOC_TRIM_THRESHOLD_ 进行外部注入。在通常的生产工程中,glibc 默认的自适应策略已经足够应对常规场景,唯有在极端苛刻地压缩内存碎片或调试特定生命周期时,才需要介入调优。
用环境参数关掉动态阈值
在进行内存分配行为溯源或基准测试时,若希望每次大块分配均严格走 mmap 独立映射,可以在命令前显式传入环境参数:MALLOC_MMAP_THRESHOLD_=131072 ./malloc_trace。一旦通过环境项锁定了初始阈值,分配器的动态递增逻辑便会被冻结,后续的 1 MiB 分配请求依然会稳定触发 mmap。
6. 内存保护与段错误
页表项里除了物理页框号,还有权限位。mprotect 改的就是这些位:
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);2
3
addr 必须是某一页的起始地址,len 会向上取整到整页。保护的粒度是页,不能只保护一个对象中间的几个字节。
下面的程序映射一页,先写一个字节,再把它改成只读,然后按参数决定写不写:
int do_write = argc > 1 && strcmp(argv[1], "write") == 0;
long page = sysconf(_SC_PAGESIZE);
char *p = mmap(NULL, (size_t)page, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return EXIT_FAILURE;
}
p[0] = 'A';
printf("写入前:p[0] = %c\n", p[0]);
fflush(stdout);
if (mprotect(p, (size_t)page, PROT_READ) == -1) {
perror("mprotect");
return EXIT_FAILURE;
}
printf("已改成只读,准备写入\n");
fflush(stdout);
if (do_write) {
p[0] = 'B';
printf("这行不会执行:p[0] = %c\n", p[0]);
}
printf("没有写入,正常退出\n");2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
两次运行的结果:
$ ./mprotect_segv
写入前:p[0] = A
已改成只读,准备写入
没有写入,正常退出
$ echo $?
0
$ ./mprotect_segv write
写入前:p[0] = A
已改成只读,准备写入
Segmentation fault
$ echo $?
1392
3
4
5
6
7
8
9
10
11
12
第二次运行在写 p[0] 的那条指令上被内核终止。页表项没有写权限,写操作触发缺页,内核一看是权限违例,直接给进程发 SIGSEGV (segmentation fault)。shell 打印 Segmentation fault,退出状态是 139,即 128 加 11。printf 排在写入之后的那些行永远执行不到,所以输出到「已改成只读」就断了。
保护以页为单位,这一点做实验时很容易碰到:把两页映射里的第二页改成只读,第一页照样能写。
$ ./pagegran
第一页可写:a
第一页改成 A,仍然可写
第二页读到 b
$ ./pagegran write
第一页可写:a
第一页改成 A,仍然可写
尝试写第二页
Segmentation fault2
3
4
5
6
7
8
9
这个性质被用来做保护页 (guard page):在一段分配的一端放一页 PROT_NONE,越界一个字节就会立刻崩溃,不会悄悄改坏旁边的数据。线程库给每根线程栈也放了这样一页,栈长过界时以 SIGSEGV 报出来,不会踩进相邻的映射。习题 40407 就是用这个办法抓出一个差一错误。
PROT_EXEC 控制可执行权限。现代的加固习惯是同一段内存不要同时可写和可执行,即 W^X,这样注入的代码无法直接运行;JIT 编译器会在写完代码后把页改成可执行。
7. 什么时候用 mmap
mmap 看上去比 read 省事,一个调用就能拿到整个文件,还少一次拷贝。实际写程序时,先看数据怎么用。
顺序读一个文件,用 read 加一个缓冲区就够了,代码短,也不慢。把第 2 章 3.2 节那个 mycat(64 KiB 缓冲区)和 mmapcat 放在一个 22 MiB 的文件上对比系统调用次数:
static int write_all(int fd, const void *buf, size_t len) { /* 同第 2 章 */ }
int main(int argc, char *argv[]) {
int fd = open(argv[1], O_RDONLY | O_CLOEXEC);
/* fstat 得到 st.st_size,省略错误检查 */
char *p = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (p == MAP_FAILED) {
perror("mmap");
return EXIT_FAILURE;
}
if (write_all(STDOUT_FILENO, p, (size_t)st.st_size) == -1) {
perror("write");
return EXIT_FAILURE;
}
munmap(p, (size_t)st.st_size);
close(fd);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mmapcat:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
0.00 0.000000 0 1 read
0.00 0.000000 0 1 write
------ ----------- ----------- --------- --------- ----------------
100.00 0.000000 0 2 total
mycat(缓冲区 64 KiB):
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
79.35 0.006409 18 352 read
20.00 0.001615 4 350 write
------ ----------- ----------- --------- --------- ----------------
100.00 0.008077 11 702 total2
3
4
5
6
7
8
9
10
11
12
13
14
15
mmapcat 只有一次 write,一次把 22 MiB 全交出去;那唯一一次 read 是动态链接器读 ELF 头时发出的,与文件内容无关。mycat 要读 352 次、写 350 次。系统调用次数差了两个数量级,但两者的实际耗时并不按这个比例差,因为 mmapcat 的每次缺页都要内核填页表,write 到管道时还要把数据从页缓存搬到管道缓冲区。系统调用次数只是故事的一部分。
管道和套接字不能映射
mmap 的第一个参数是文件描述符,但只有支持映射的对象才行。管道、套接字、终端这类没有文件偏移量的对象,mmap 返回 MAP_FAILED,errno 为 ENODEV。想把管道里的数据映射进来做不到,只能 read。
在工程实践中,关于 I/O 与内存映射的选型权衡准则如下:
- 顺序读、顺序写、一次性的处理,用
read加 64 KiB 缓冲区。实现简单,出错路径少,性能与mmap在同一量级。 - 大文件的随机访问,或者按偏移定位的数据文件,用
mmap。SQLite 的mmap_size选项就是这个思路:数据库按页组织,需要哪一页就映射哪一页,省掉页缓存到用户缓冲区的拷贝,项目案例的 6.3 B 树与存储引擎 讲了页式存储。注意mmap只对读密集的查询有明显好处,写入前 SQLite 仍然要把页复制到私有内存。 - 多个进程要看到彼此对同一份数据的修改,用
MAP_SHARED映射同一个文件。只读共享不必特别处理:MAP_PRIVATE映射同一个文件的多个进程也共用页缓存里的同一批页,物理内存里只有一份。 - 需要一块清零的大内存,用
MAP_PRIVATE | MAP_ANONYMOUS,不必经过malloc。
代价也要算进去。每次首次访问一页都是一次缺页,随机访问一个小文件时,缺页的开销可能超过拷贝;munmap 要拆掉页表项并让相关的 TLB 项失效,频繁映射、解除映射的小块不如直接用 malloc;文件在映射期间被别的进程截短会得到 SIGBUS,程序必须先想好这种情况怎么处理。
posix_madvise 可以把访问模式告诉内核,让预读更积极或更保守:
#include <sys/mman.h>
posix_madvise(p, len, POSIX_MADV_SEQUENTIAL); /* 顺序访问,预读积极一些 */2
3
它只是建议,内核可以不理,程序的正确性不能依赖它。真要把一段内存锁在物理内存里不让它换出,用 mlock,它受 RLIMIT_MEMLOCK 限制,普通用户能锁的量很小。
8. 习题
本页用到的程序在 code/系统编程/4_虚拟内存与内存映射/ 下,部分题目带初始代码在 exercises/ 子目录。
8.1 巩固
写一个 mmapcat FILE:打开文件,用 fstat 得到大小,把它整个映射成 PROT_READ | MAP_PRIVATE,用一次 write 把整片映射写到标准输出,然后 munmap、close。先不看正文第 7 节自己写一遍,写完再与 code/系统编程/4_虚拟内存与内存映射/mmapcat.c 对照。
要求:
- 映射前处理空文件:长度为 0 时不调用
mmap; - 写用第 2 章的
write_all,一次write可能只写一部分; - 用
cmp <(./mmapcat big.txt) big.txt验证输出与原文件一致; - 用
strace -c -e trace=read,write ./mmapcat big.txt > /dev/null和同样命令跑第 2 章的 mycat,把两张表贴出来,说明那唯一一次read是谁发出的; - 用
truncate -s 1G big1g.bin造一个 1 GiB 的空洞文件,分别用两个程序输出到/dev/null,在退出前打印getrusage(RUSAGE_SELF)的ru_maxrss(峰值驻留集,单位 KiB)。mmapcat并没有把 1 GiB 全读进来,它为什么要靠write一次交出去、这块内存什么时候才被释放,都在这一问里说明。
本节的 layout 程序只打印了每个对象所在的 maps 行。回答下面的问题,并修改程序验证:
- 在
layout.c里加一个字符串常量const char *msg = "hello";和一个const int g_const = 7;,打印它们的地址并查出所在映射。它们和main在同一段吗?权限是什么? - 为什么
g_init与g_bss落在同一行 maps 里,偏移都是0x3000? small与big的地址分别落在哪一段?两段的路径列有什么不同?- 把
local换成static int s_local;再运行,它落在哪一段?属于 15.3 讲的哪种存储期? - 用
readelf -lW ./layout查看可执行文件的段表,找出四个LOAD段各自的权限,与 maps 里的四行对照; - 去掉
setarch,连续运行两次./layout -a,把两份完整 maps 的地址列相减,看看哪些段的起始地址变了、哪些没变。结合第 1 节说明变化来自哪里。
缺页计数实验。分配 32 MiB 内存,然后按三种步长各写一遍:4096、8192、16384 字节。先预测 ru_minflt 的增量,再运行验证。
要求:
- 程序接受步长作为命令行参数,分配前和写完后各取一次
getrusage; - 用
prctl(PR_SET_THP_DISABLE, 1, 0, 0, 0)关掉透明大页,否则计数不可比; - 分别运行三个步长,记录增量,并解释三个数字与 8192 页的关系(本机实测为 8191、4095、2047);
- 把步长改成 1024,预测并验证,说明为什么结果与 4096 相同;
- 用
mincore(Linux 专有,_GNU_SOURCE)统计每页是否驻留,验证「一个 4096 字节的页只缺一次」。
8.2 编程
写一个 pmap_lite PID:读取 /proc/PID/maps,把每个映射按下面几类归类,统计每类的映射数和总字节数,最后打印一张表:
- 可执行文件本身(路径等于
/proc/PID/exe指向的文件); - 共享库(路径以
.so结尾,含libc.so.6的多个段); - 堆(路径为
[heap]); - 栈(路径为
[stack]); - 匿名映射(路径为空且 i 节点为 0);
- 其余(
[vdso]、[vvar]、其他文件)。
要求:
- 读取
/proc/PID/exe的符号链接目标,与 maps 里的路径比较;路径可能带(deleted)后缀,要先去掉; - 自己启动一个目标进程(例如
sleep 30),记下它的 PID,运行./pmap_lite PID,再用手工grep统计对照; - 把匿名映射按大小降序输出前五项,找出某个
malloc大块对应的一项; - 在报告里说明:为什么可执行文件的映射有好几行,统计时算作一个还是多个。
定长记录查看器。exercises/mkrec.c 生成一个由 64 字节记录组成的二进制文件,记录结构是 int64_t id; char name[48]; int32_t value; int32_t pad;。写一个 rec:
./rec FILE k打印第 k 条记录的三个字段;./rec FILE k NEW把第 k 条记录的name改成NEW。
要求:
- 用
mmap实现一版:读用MAP_PRIVATE,改的那一次用MAP_SHARED,改完msync; - 检查
k的范围,越界时打印错误并以非零状态退出,不能让进程被SIGBUS终止; - 用
pread/pwrite实现第二版,两版输出一致; - 用
strace -c -e trace=read,write,pread64,pwrite64,mmap,munmap比较两版的系统调用次数,说明第 k 条记录在两版里各需要多少 I/O; - 用
od -An -c -j $((k*64)) -N 64 recs.bin对照修改结果。
写一个 mmapcp IN OUT:映射源文件,创建目标文件并用 ftruncate 拉到同样长度,把目标也映射成 MAP_SHARED,用 memcpy 拷贝,msync 之后 munmap 两个映射。
要求:
- 处理源文件为空的情况,此时创建一个空的目标文件即可;
- 用
cmp IN OUT验证结果,再用一个 100 MiB 的文件测一遍; - 用
strace -c统计系统调用,与第 2 章的 copybuf 对比; - 在两个程序退出前打印
ru_maxrss,比较两种写法的峰值驻留集,解释为什么mmapcp的数字可能更高,而copybuf的 RSS 一直平稳; - 说明
mmapcp在哪些情况下会失败而copybuf不会,至少举一个(提示:目标文件所在的文件系统、磁盘空间)。
8.3 综合与挑战
护栏分配器。实现 Electric Fence 的核心思路:
void *guarded_alloc(size_t n); /* 分配 n 字节,末尾紧挨一页 PROT_NONE */要求:
- 用
mmap分配向上取整到页(n) + 4096字节,用mprotect把最后一页设成PROT_NONE,返回的指针让对象末尾正好贴住保护页; - 拿
exercises/buggy_string.c当被测代码。它实现的duplicate_trimmed只分配了n字节,没给结尾的'\0'留位置,用普通malloc时能正常跑完。把其中的malloc换成你的guarded_alloc,程序应当在处理某个样本时以SIGSEGV终止,退出状态 139; - 打印出错时的地址,定位到具体的函数(没有调试器时,在
duplicate_trimmed里加打印语句); - 修复
duplicate_trimmed,让它多分配一个字节,重新运行,确认三个样本都正常; - 讨论:护栏分配器为什么不能直接替换全局
malloc(每次分配至少占两页、free拿不到大小、对齐要求),它的适用场景是什么。参考文献里的 Electric Fence 是怎么做的。
写一个 mmapcat 与 mycat 的对比报告工具 iobench FILE:对同一个文件分别用三种方式输出到 /dev/null:read 加 4 KiB 缓冲、read 加 64 KiB 缓冲、mmap 加一次 write。每种跑 5 次,报告系统调用次数(用 strace -c 采集)与耗时。
要求:
- 不要用
clock()或time()测耗时,用第 6 章会讲的clock_gettime(CLOCK_MONOTONIC); - 文件用
truncate -s 256M造出来的空洞文件,以及一个真实的 256 MiB 文本文件各测一遍,比较「页缓存已热」与「首次读」两种情况; - 结论要写清楚:在这台机器、这个文件上,哪种方式最快,差距多少;说明为什么顺序读时
mmap的优势不明显; - 重复运行时结果会波动,报告中给出 5 次的分布而不是只给平均值。
给 mapcount 加上并行处理:用 fork 把文件按字节范围分给 N 个子进程,每个子进程映射自己那一段,统计换行数,父进程汇总。
要求:
- 每个子进程映射的
offset必须是页大小的整数倍,每段的首尾要落在页上,否则相邻两个子进程会把同一行数两遍或漏数; - 用
MAP_SHARED | MAP_ANONYMOUS的匿名共享映射传回每个子进程的计数(第 5 节),不要用管道; - 在 4 核上分别用 N = 1、2、4 运行一个 256 MiB 的文件,记录耗时和缺页数;
- 说明这里为什么用进程而不是线程,
fork之后子进程继承了哪些资源,mmap进来的文件页在父子进程里是不是共享的(第 8 章会从线程的角度再讲一遍)。
9. 参考资料
mmap(2)、munmap(2)、msync(2)、mprotect(2)、madvise(2)、posix_madvise(3)、mlock(2),man7.org:https://man7.org/linux/man-pages/man2/mmap.2.html、https://man7.org/linux/man-pages/man2/msync.2.html、https://man7.org/linux/man-pages/man2/mprotect.2.html、https://man7.org/linux/man-pages/man2/madvise.2.html、https://man7.org/linux/man-pages/man3/posix_madvise.3.htmlproc_pid_maps(5)(/proc/PID/maps各列与伪路径)、proc_pid_status(5)(VmSize、VmRSS):https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html、https://man7.org/linux/man-pages/man5/proc_pid_status.5.htmlgetrusage(2)中ru_minflt、ru_majflt的定义:https://man7.org/linux/man-pages/man2/getrusage.2.htmlmallopt(3)的M_MMAP_THRESHOLD、M_TRIM_THRESHOLD与动态调整规则:https://man7.org/linux/man-pages/man3/mallopt.3.htmlsetarch(8)的-R选项:https://man7.org/linux/man-pages/man8/setarch.8.html- Linux 内核文档《Memory Management Concepts》:https://docs.kernel.org/admin-guide/mm/concepts.html
- Linux 内核文档《Overcommit Accounting》:https://docs.kernel.org/mm/overcommit-accounting.html
- glibc Wiki 的《MallocInternals》,关于
brk、mmap与阈值的说明:https://sourceware.org/glibc/wiki/MallocInternals - SQLite 的内存映射 I/O 选项:https://www.sqlite.org/mmap.html
- Electric Fence(
libefence)的做法:https://linux.die.net/man/3/efence - The Linux Programming Interface,第 6 章(进程与程序)、第 7 章(内存分配)、第 49 章(内存映射)、第 50 章(虚拟内存操作):https://man7.org/tlpi/toc-detailed.html
- W. Richard Stevens、Stephen A. Rago,Advanced Programming in the UNIX Environment,第 3 版,14.8 节「内存映射」,Addison-Wesley,ISBN 978-0-321-63773-4