Linux系统下深究一个malloc/brk/sbrk新内存后的page fault问题

本文探讨了Linux系统中匿名页缺页中断的机制及其实现细节,包括read和write操作对物理内存管理的不同影响,以及如何利用底层系统调用理解和验证这一机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

有耳可听的,就应当听 —《马可福音》


周四的休假团建又没有去,不因别的,只因年前东北行休假太多了,想缓缓…不过真实原因也确实因为假期剩余无几了…思考了一些问题,写下本文。

  本文的缘起来自于和同事讨论一个关于缺页中断按需调页的讨论。真可谓是三人行必有我师,最近经常能从一些随意的比划或招架中悟出一丝意义,所以非常感谢周围的信息输出者!甚至从小小学校全员禁言的作业群里,我都能每天重温一首古诗词,然后循此生意,去故意制造另一种真实的意境,然后发个朋友圈?~

  感谢大家的信息输入,每次收到的好玩的东西,我都会即时整理并重新再输出。

内容简介

本文描述了一个非常显然但却又很少有人知道其所以然的问题,更重要的是分享一种解决问题的思路。PS:这个问题非常好玩。

  不搞悬念,本文解释一个事实,即匿名页缺页中断数量和物理页面的分配数量并不是一致的。即便不考虑共享内存的影响,也并非发生一次匿名页缺页中断,就一定会分配一个独立的物理页面。

问题

问题很简单,我把问题抽象成了下面的代码:

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

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        for (i = 0; i < SIZE; i++) {
                addrs[i] = malloc(4096);
        }
        // 问题:下面读取一系列malloc出来的内容时,会不会产生缺页中断从而调入物理内存??
        for (i = 0; i < SIZE; i++) {
            // 只读addrs[i],并不写入。
                memcpy(dest[i], addrs[i], 4096);
        }
        getchar();
}

答案看似是显然的,即会产生缺页并调入物理内存。但在同事那里的现象却并非如此,同事展示的结果是在读取malloc刚刚分配的addrs[i]这些内存时,根本没有任何物理内存调入。

  我的直觉是,malloc分配过程是C库控制的,细节比较复杂,可能在malloc之后紧接着该内存就被touch了,进而在读取的时候,内存已经被调入了。我建议使用brk以及不带LOCK flag的mmap这种底层系统调用去看个究竟,而不是用malloc。

  无论如何,该问题就此告一段落,毕竟我没有给出一个可以落地的解释,只是猜测一种可能性。我没有看过C库的实现,大致知道点C库里有一个链表维护了malloc类似伙伴系统那样的内存池,但细节我并不知,我也没有看过操作系统缺页中断处理的细节,大致知道个处理流程而已,所以我也真的无法给出进一步的解释。事情因此可以预见的结果就是,翻篇了。

  然而,事情正在悄悄地起变化…这是为什么?

一种解释

次日上午,同事拿这个问题去请教了一位泰斗级人物,大家无不敬佩的绝顶高手,即为什么read刚刚malloc的内存,却没有发生调页,而对其write一下再read就调页了?

  不愧是大师级人物,随即不假思索地给出了一个非常直接的答案所有的内存刚被分配时都被映射到了同一个全零页面,你读它时读的就是那个页面,你写它时会发生写时拷贝…大致就是这么个答案。

  我是事后知道这个答案的,但我第一感觉就是,这个答案是不合理的!既然有Lazy page fault机制,内存初始化时将它们映射到同一个页面的意义何在?净平添开销吗?为什么不让Lazy page fault机制统一处理(此时我还不知道Lazy策略是两个层面上的,即When What和How)。当然了,我指的是用户态进程的user内存,对于内核内存而言,确实有这个一个预先映射的机制,毕竟内核(这里说的是Linux内核)内存是常驻的。

  既然不认可这个答案,我的答案是什么呢?很简单,我认为这是一个常识,即内核不会为用户进程新分配但没有touch(read或者write)的内存映射任何物理页面。

  我决定抽空好好看看这个问题了。耗时下班晚饭后的一个半夜,总结出了下面的文字。

这里先剧透下 细节&答案:
0.谁的解释都没有错,表达的侧重不同,统一看待结果收获更多;
1.分配的虚拟地址空间只要没有被read和write过,内核便不会将其映射到任何物理页面;
2.分配的虚拟地址空间首先被read touch时会发生缺页中断,内核会将其映射到系统保留的zeropage页面,该页面没有写权限;
3.分配的虚拟地址空间第一次被write touch时会发生缺页中断,内核会分配一个独立的物理页面与之建立映射。

.

正确的表达应该是:
所有刚被分配的内存在第一次read的时候,page-fault会将其映射到了同一个全零页面,你读它时读的就是那个页面,你写它时会发生写时拷贝

测试分析的过程

为了便于步步深入,每一步都要很简单。因此我把上面的代码分解为3个步骤:

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

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        // step 1:先把addrs读的目标内存调入内存,消除误解
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);
        }
        getchar();
        // step 2:使用malloc分配一个page的内存
        printf("step 2\n");
        for (i = 0; i < SIZE; i++) {
                addrs[i] = malloc(4096);
        }
        getchar();
        // step 3:只读malloc分配出来的内存
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs[i], 4096);
        }
        getchar();
}

每一个步骤都用getchar来隔离,为了方便我们观测统计数据。编译的时候记着使用-O0,消除编译器带来的误解。现在我们运行./a.out

root@debian:/mnt/toy# ./a.out 
step 1

这个时候在另一个终端来看一下其page fault情况,再进一步观测我们的实验结果前,有必要先解释一下page fault的两种类型,即major page fault和minor page fault。

  那么到底major page fault(主缺页中断)和minor page fault(次缺页中断)有什么区别呢?区别在于从哪里把页面调入:

  • major page fault:缺页时,需要把数据从磁盘读入新分配的物理页面,比minor fault多了一个IO过程。
  • minor page fault:缺页时,仅仅为一个空闲物理页面增加一个映射,即分配一个匿名页面。

为了简单直白,我使用swapoff -a将所有交换关闭,这样便不会有换入换出的磁盘IO操作,并且我也不会去map文件,因此本文的实验将不会涉及major page fault。

  好的,让我们看看具体的page fault情况:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     79

然后在a.out界面敲入回车,再次观测malloc之后的统计数据:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    180

嗯,多了大概100个中断,调入了100个页面。此时我们可以通过/proc/ps -e|grep a.out|awk '{print $1}'/status中的vmRSS字段来确认物理内存的增持情况(如果你发现了异常,先不要惊慌,带着问题听我把故事讲完)。再次键入回车进入step 3后继续观测malloc的内存被read后的统计数据:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    181

确实在read malloc分配的内存时并没有新的缺页中断,因此便没有发生调页!

  发生了什么?为什么在malloc的时候发生了调页,而在memcpy的时候却没有?按照操作系统Lazy页面调度的原则来讲,只是malloc内存的话,并不会映射物理页面啊!为此我们打印出更多的细节:

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

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        char *last = NULL;
        // step 1:
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);
        }
        getchar();
        // step 2:
        printf("step 2\n");
        for (i = 0; i < SIZE; i++) {
                int j, gap;
                char *ae;
                addrs[i] = malloc(4096);
                ae = addrs[i];
                gap = last?(ae-last):0;
                // 我们看一下连续两次malloc的地址是否连续
                printf("alloc:%p gap:%0x\n", addrs[i], gap);
                // 如果不连续,我们看看中间有什么
                for (j = 0; i < 3 && j < gap; j++) {
                        printf("%0x ", ae[j]);
                }
                last = ae;
                printf("\n");
        }
        getchar();
        // step 3:
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs[i], 4096);
        }
        getchar();
}

打印出的结果如下(删减了大部分的0,不然太大):

root@debian:/mnt/toy# ./a.out 
step 1

step 2
alloc:0x55813ea46830 gap:0

alloc:0x55813ea47840 gap:1010
# 4096个字节属于malloc的内容
# 0x1010-0x1000 = 0x10个字节是什么?
0 0 0 0 0 0 0 0 ffffffc1 ffffffe7 1 0 0 0 0 0 
alloc:0x55813ea48850 gap:1010
...
0 0 0 0 0 0 0 0 ffffffb1 ffffffd7 1 0 0 0 0 0 
alloc:0x55813ea49860 gap:1010

alloc:0x55813ea4a870 gap:1010
...

我们发现一次malloc所用的内存并不仅仅是malloc参数所指示的那样,还多了16字节的元数据,这应该就是C库维护的malloc使用的内存池链表(应该是类似内核中伙伴系统那般被维护的)!显然,按照我们希望malloc一次分配一个页面的情况来讲,下图展示了虚拟内存的布局:
这里写图片描述

这个和我们上面的page fault测试统计数据完全符合!看样子是解释了问题。即malloc实际分配内存之后写C库内存池元数据导致了调页,待到后来read数据时,页面已经被调入了


  让我们再深入一点。

  既然C库的malloc使用的是已经从OS分配的现成的内存,因此进程heap(brk指示其边界)的扩展就是C库进行的咯,我们再次看个究竟:

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

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        // step 1:
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);
        }
        getchar();
        // step 2:
        printf("step 2\n");
        for (i = 0; i < SIZE; i++) {
                char *ae;
                addrs[i] = malloc(4096);
                // 获取当前的brk
                ae = sbrk(0);
                printf("alloc:%p brk now:%p\n", addrs[i], ae);
        }
        getchar();
        // step 3:
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs[i], 4096);
        }
        getchar();
}

运行结果是:

root@debian:/mnt/toy# ./a.out 
step 1

step 2
alloc:0x55b9fc3d8830 brk now:0x55b9fc3f9000
alloc:0x55b9fc3d9840 brk now:0x55b9fc3f9000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc3f6a10 brk now:0x55b9fc3f9000
alloc:0x55b9fc3f7a20 brk now:0x55b9fc3f9000
alloc:0x55b9fc3f8a30 brk now:0x55b9fc41a000
alloc:0x55b9fc3f9a40 brk now:0x55b9fc41a000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc417c20 brk now:0x55b9fc41a000
alloc:0x55b9fc418c30 brk now:0x55b9fc41a000
alloc:0x55b9fc419c40 brk now:0x55b9fc43b000
alloc:0x55b9fc41ac50 brk now:0x55b9fc43b000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc438e30 brk now:0x55b9fc43b000
alloc:0x55b9fc439e40 brk now:0x55b9fc43b000
alloc:0x55b9fc43ae50 brk now:0x55b9fc45c000
alloc:0x55b9fc43be60 brk now:0x55b9fc45c000


step 3

root@debian:/mnt/toy# 

把打印去掉,通过strace,我们可以看到同样的事实:

mprotect(0x7fa4d6916000, 16384, PROT_READ) = 0
mprotect(0x56298dbba000, 4096, PROT_READ) = 0
mprotect(0x7fa4d6b43000, 4096, PROT_READ) = 0
munmap(0x7fa4d6b32000, 54668)           = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 4), ...}) = 0
brk(NULL)                               = 0x56298dc70000
brk(0x56298dc91000)                     = 0x56298dc91000
write(1, "step 1\n", 7step 1
)                 = 7
fstat(0, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 4), ...}) = 0
read(0, 
"\n", 1024)                     = 1
write(1, "step 2\n", 7step 2
)                 = 7
# 注意!虽然调用了SIZE次malloc,但是C库函数仅仅发起了3次brk调用
brk(0x56298dcb2000)                     = 0x56298dcb2000
brk(0x56298dcd3000)                     = 0x56298dcd3000
brk(0x56298dcf4000)                     = 0x56298dcf4000
read(0, 

可以看得到,malloc完全受C库的管理而不是操作系统内存子系统的管理,C库会批量从操作系统申请内存纳入自己的管辖,其分配的地址永远处在当前brk之下的位置!总结来讲就是,操作系统内存子系统为C库服务,C库为编程者服务,编程者一般并不和操作系统内存子系统直接打交道。


让我们回到malloc内存缺页中断调页的问题。

  既然malloc受C库的管理,且可能在填充元数据的时候发生调页,那么我们直接使用brk系统调用呢?来试试:

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

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()      
{       
        int i;
        // step 1:
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);     
        }       
        getchar(); 
        // step 2:
        printf("step 2\n"); 
        for (i = 0; i < SIZE; i++) {
                char *ae;
                addrs[i] = sbrk(4096);
        }
        getchar();
        // step 3:
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs[i], 4096);
        }
        getchar();
}  

它将造就下面的虚拟内存布局:
这里写图片描述
我们把程序跑一下看看,3个阶段的page fault分别是:

# 阶段1
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     81

初始,调入了dest页面。

# 阶段2
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     82

brk直接分配内存,没有发生缺页中断,没有页面调入。

# 阶段3
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    182

只是读取了最新brk的页面,发生了缺页中断,最新的SIZE个brk页面被调入!

  可见,同样都是分配SIZE个页面,使用malloc和使用brk是完全不同的,前者不受自己控制,内存不连续,中间有管理元数据,而后者则完全是自己向操作系统获取的,不管是哪种方式,所获取的都是虚拟内存,只是C库的malloc在不经意间可能会由于其管理工作而调页,而brk则不会,只有在你写或者读(写和读的调页细节并不同,且听我把故事讲完)的时候才会触发缺页中断,Lazy调页。

继续分析下去

继续下去,还有得玩。

  我们来看一下使用malloc能不能故意造出自己想要的缺页中断效果,比如我就希望即使使用malloc也要在实际内存时才发生缺页。为此我们只需要营造下面的虚拟内存布局即可:

这里写图片描述

考虑到内存对齐因素,malloc内存池元数据16字节,那么按照16字节对齐是一个最小的选择了,因此为了达到读内存时必然发生缺页中断,比如读取至少1个页面才能让malloc内存池链表项的边界覆盖页面边界,即,比如至少读取4096字节的数据,此外,malloc分配的内存大小则为4096*2+4096/2-16字节。

  我们的预期是,在malloc执行的时候,会有100个缺页中断,因为此时写元数据会调入100个页面,然后在读这些malloc出来的内存时,会有50个缺页中断,因为有一半的页面已经在malloc的时候由于元数据写而调入了。让我们来看看是不是这样:

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

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        // step 1:
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);
        }
        getchar();
        // step 2:
        printf("step 2\n");
        for (i = 0; i < SIZE; i++) {
                addrs[i] = malloc(4096+4096/2-16);     
        }
        getchar();
        // step 3:
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs[i], 4096);
        }
        getchar();
}

三个阶段的输出分别为:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     80
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    181
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
root@debian:/mnt/toy# 

OK,符合预期!如果仅仅将代码中malloc换成calloc,我们预期会在step 2的时候调入所以的页面,因为calloc会即时将页面用0填充,会触发调页。来看下结果:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     80
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231

嗯,就是这个结果!

  现如今,我们已经可以按照自己的意愿来控制缺页中断调页的次数了,我们确实可以控制,而且还是在下面两个条件都满足的前提下做到的:

  • 没有看Linux内核源码;
  • 没有看glibc的malloc源码。

我们只是通过一些黑盒小trick做到的这一切。这很有意思,这也是我写作本文想分享的一种处理问题的方式。从最开始拿到问题,一直到这里,没有任何的source code,没有,一点也没有。

  好像还漏掉了什么,让我们来补齐。


嗯,这就是mmap。

  C库的malloc在分配超过一定大小的内存(MMAP_THRESHOLD)时,将不再通过其内存池预分配的链表(类似伙伴系统)中取,而是直接通过mmap系统调用来向操作系统内核来索要。详见malloc的manual page:

Normally, malloc() allocates memory from the heap, and adjusts the size of the heap as required, using sbrk(2). When allocating blocks of memory larger
than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as a private anonymous mapping using mmap(2). MMAP_THRESHOLD is 128 kB by
default, but is adjustable using mallopt(3). Allocations performed using mmap(2) are unaffected by the RLIMIT_DATA resource limit (see getrlimit(2)).

这种方式更进一步证明,在你操作实际内存前,malloc并没有对新内存进行任何映射,代码如下:

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

// 将SIZE从100加大到1000,更加容易在maps中观察到新的vma
#define SIZE    1000
char *addrs;
char dest[4096][SIZE];
int main()
{
        int i;
        // step 1:调入拷贝目标
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);
        }
        getchar();
        // step 2:分配内存
        printf("step 2\n");
         // 直接分配,大小已经足够大,用strace可以确认使用了mmap。
        addrs = malloc(4096*SIZE);
        getchar();
        // step 3:读内存
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs + i*4096, 4096);
        }
        getchar();
}

然后打印三个阶段的缺页中断统计:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    320
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    322
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0   1322
root@debian:/mnt/toy# 

符合预期!此时通过查看/proc/ps -e|grep a.out|awk '{print $1}'/maps,可以看出heap的变化和malloc分配小内存时的不同:

root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
... 
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
...
root@debian:/mnt/toy# 
root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
...
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
# 并没有扩展既有的heap,而是另辟途径,申请一块全新的vma
7f1e07f75000-7f1e0835e000 rw-p 00000000 00:00 0 
...
root@debian:/mnt/toy# 
root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
...
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
7f1e07f75000-7f1e0835e000 rw-p 00000000 00:00 0 
...                   
root@debian:/mnt/toy# 

以上可以看出,malloc大内存时,会直接使用mmap在进程的地址空间中开辟一个新的虚拟地址段,而反观malloc申请小内存,则会直接使用brk来延展当前的heap。

  应该快到结尾处了。通篇我们都在使用ps -o maj_flt -o min_flt这种方式来观测中断,我一直故意避开具体的调页情况的观测,就好像发生一次缺页中断就会调入一个独立的物理页面,从而造成空闲物理页面少一个一样,此外,我也一直都在用读操作触发缺页中断,写操作仅限于C库对元数据的写以及calloc的写,这么做是必要的,因为我不想复杂的事情一开始就揉在一起,理清了缺页中断,让我们进一步分析缺页中断后调页的情况。

  但是具体调页如何观测呢?

  一般而言,发生了缺页中断,就会调入内存一个页面,此时空闲物理内存就会少一个页面。但是这是错误的直觉!你可以通过free命令或者直接看相关进程的/proc/ps -e|grep a.out|awk '{print $1}'/status文件的VmRSS/RssAnon等字段的变化来观测调页情况。你会观察到下面的情况:

  • 如果是read造成的100个缺页中断,RssAnon并没有增加,这意味着没有新的独立物理页面被分配;
  • 如果是write造成的100个缺页中断,RssAnon会增加相应的个数,剩余空闲物理内存会减少。

为什么会这样?这是下面两个小节的内容。

  OK,写到这里,基本上本文的上半部分已经结束了,我们了解到page fault和虚拟内存分配读写的关系,后面,在本文的下半部分,我来解释本文的第二个论题,即新分配的虚拟地址空间的read操作和write操作对物理内存管理系统的影响有什么不同

深入到内核页表看个究竟

这个小节你可以看作是干货,也可以看成是废话。

  前置知识很重要。本文所有的用例都是基于X86-64平台,内核版本为3.10.0-862.2.3.el7.x86_64以及4.9.0-3-amd64,本节的用例在3.10.0-862.2.3.el7.x86_64内核做实验。因此有必要先简单说一下X86-64平台虚拟内存到物理内存的映射。

  和X86 32位平台非常类似,下面一张图就能说明:

这里写图片描述

理解了这个之后,我们就可以再来一个简单的用例,来观测一下进程虚拟内存的实际映射情况。

  直接上一个代码:

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

int main(int argc, char **argv)
{
        char *p1, *p2, *p3;
        char c;
        int i;

        p1 = sbrk(0);
        // 我们脱离C库的管理,直接切入内核。
        p2 = sbrk(4096);
        p3 = sbrk(4096);

        printf("p1:%p  p2:%p  p3:%p\n", p1, p2, p3);

        memset(p3, 'a', 4096);
        // 1.此处观察p3的地址对应的页表项以及实际读出物理页面确认内容是不是'a'
        // 2.观察和p3虚拟地址紧邻着的上一个4096大小的地址空间的页表项。
        getchar();

        c = p2[120];  // read p2
        // 3.再次实验上述的第2点
        getchar();

        memset(p2, 'b', 4096);
        // 4.再次实验上述的第2点
        getchar();
}

为了完成代码注释中的观测实验,可以写一个kernel module,然后在里面去dump特定进程的页表。然而有现成的工具不用,不是自找苦吃吗?嗯,这里将用crash来完成观测实验。

  crash如何安装这里不展开,我在Centos 7上的安装方法是:

  • 安装crash
yum install crash
  • 安装debuginfo
yum install yum-utils
debuginfo-install kernel

工具就绪后,运行编译好的上述代码:

[root@localhost toy]# ./a.out 
p1:0xc7d000  p2:0xc7d000  p3:0xc7e000

另起一个终端,运行crash:

[root@localhost ~]# crash 

crash 7.2.0-6.el7
.......

      KERNEL: /usr/lib/debug/lib/modules/3.10.0-862.2.3.el7.x86_64/vmlinux
    DUMPFILE: /dev/crash
        CPUS: 1
        DATE: Thu May 17 10:15:44 2018
      UPTIME: 04:21:57
LOAD AVERAGE: 0.29, 0.08, 0.07
       TASKS: 117
    NODENAME: localhost.localdomain
     RELEASE: 3.10.0-862.2.3.el7.x86_64
     VERSION: #1 SMP Wed May 9 18:05:47 UTC 2018
     MACHINE: x86_64  (2194 Mhz)
      MEMORY: 1 GB
         PID: 1765
     COMMAND: "crash"
        TASK: ffff9be1f9289fa0  [THREAD_INFO: ffff9be1f67b4000]
         CPU: 0
       STATE: TASK_RUNNING (ACTIVE)

crash> ps |grep a.out
   1764   1258   0  ffff9be1f9288000  IN   0.0    4224    472  a.out

好了,接下来我们来step by step看看代码中p2,p3指针内存页表的细节。

  开始!

  • step 1:找到进程的task_struct以及其pgd指示的CR3值:
crash> set 1764
    PID: 1764
COMMAND: "a.out"
   TASK: ffff9be1f9288000  [THREAD_INFO: ffff9be1eb3b4000]
    CPU: 0
  STATE: TASK_INTERRUPTIBLE 
crash> px ((struct task_struct *)0xffff9be1f9288000)->mm->pgd
$1 = (pgd_t *) 0xffff9be1f8e20000  # 这里的pgd是虚拟地址!
  • step 2:将pgd转换为物理地址,读出PGD表项:
crash> vtop -c 1764 -k 0xffff9be1f8e20000
VIRTUAL           PHYSICAL        
ffff9be1f8e20000  38e20000  # 注意,这个38e20000就是PGD的物理地址      

PML4 DIRECTORY: ffffffff9b00e000
PAGE DIRECTORY: 3e22f067
   PUD: 3e22fc38 => 3e230067
   PMD: 3e230e38 => 393a1063
   PTE: 393a1100 => 8000000038e20063
  PAGE: 38e20000

      PTE         PHYSICAL  FLAGS
8000000038e20063  38e20000  (PRESENT|RW|ACCESSED|DIRTY|NX)

      PAGE       PHYSICAL      MAPPING       INDEX CNT FLAGS
fffff3b700e38800 38e20000                0 ffff9be1f5f8ea40  1 1fffff00000000
crash>  px (0xc7e000>>39) & 0x1ff # 取a.out输出的p3指针,按照转换图取PGD索引
$2 = 0x0 # PGD索引为0
crash> px 0x38e20000+$2*8 # 找到PGD表项
$3 = 0x38e20000
crash> rd -p 0x38e20000 # 读出PGD表项
        38e20000:  8000000037f4d067                    g..7....
crash> pte 8000000037f4d067 # 将内存翻译成PTE格式
      PTE         PHYSICAL  FLAGS
8000000037f4d067  37f4d000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)

OK,此时我们知道,PUD是Present的。

  • step 3:循着PGD表项读出PUD表项:
crash> px (0xc7e000>>30) & 0x1ff # 按照转换图得到PUD索引
$4 = 0x0
crash> px 0x37f4d000+$4*8 # 找到PUD表项
$5 = 0x37f4d000
crash> rd -p 0x37f4d000 # 读出PUD表项
        37f4d000:  0000000037318067                    g.17....
crash> pte 0000000037318067 # 翻译PUD表项
  PTE     PHYSICAL  FLAGS
37318067  37318000  (PRESENT|RW|USER|ACCESSED|DIRTY)
  • step 4:循着PUD表项读出PMD表项:
# 这里不再给注释
crash> px (0xc7e000>>21) & 0x1ff
$6 = 0x6
crash> px 0x37318000+$6*8
$7 = 0x37318030
crash> rd -p 0x37318030
        37318030:  000000003883d067                    g..8....
crash> pte 000000003883d067
  PTE     PHYSICAL  FLAGS
3883d067  3883d000  (PRESENT|RW|USER|ACCESSED|DIRTY)
  • step 5:循着PMD表项读出PTE:
crash> px (0xc7e000>>12) & 0x1ff
$8 = 0x7e
crash> px 0x3883d000+$8*8
$9 = 0x3883d3f0
crash> rd -p 0x3883d3f0
        3883d3f0:  800000003b9e3867                    g8.;....
crash> pte 800000003b9e3867
      PTE         PHYSICAL  FLAGS
800000003b9e3867  3b9e3000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)

此时的地址0x3b9e3000就是物理页面的位置了,由于我们brk申请了整个页面,因此页面偏移为0,接下来就是直接dump内存了。(严谨点讲,需要用 addr&0x0fff 找出页面偏移的,但是0xc7e000这个地址是页面对齐的,也就不再费事了!)

  • step 6:dump整个页面的内存:
crash> rd -p 0x3b9e3000
        3b9e3000:  6161616161616161                    aaaaaaaa

哇!

  • step 7:看看p2对应的页表
    由于p2和p3在虚拟地址上相邻4096个字节(一个页面),因此只需要做最后一步即可,即将p3的PTE索引向前移1个单位。但是我们依然按照正规的方式来一遍:
crash> px (0xc7d000>>12) & 0x1ff
$10 = 0x7d
crash> px 0x3883d000+$10*8
$11 = 0x3883d3e8
crash> rd -p 0x3883d3e8
        3883d3e8:  0000000000000000                    ........
crash> pte 0000000000000000 
PTE  PHYSICAL  FLAGS
 0       0     (no mapping) # nothing!!

连页表项都没有,何来的内容!请问何来的内容?!现在让我们的a.out向前一步走,即在a.out的运行终端敲入回车,再次dump p2的页表项。

  • step 8:a.out读取一下p2指针4096字节范围内的内容之后再次看p2的PTE:
crash> rd -p 0x3883d3e8
        3883d3e8:  800000003df22225                    
crash> pte 800000003df22225
      PTE         PHYSICAL  FLAGS
800000003df22225  3df22000  (PRESENT|USER|ACCESSED|NX)

看看吧,只要读了一下p2的内容,PTE就Present了!这个时候,我们可以读一下其内容:

crash> rd -p 0x3df22000
        3df22000:  0000000000000000                    ........

内容为全0!

  • step 9:让a.out更进一步,拷贝’b’到p2后再次读取内容:
    在a.out的终端上敲入回车,然后看PTE指示页面的内容:
crash> rd -p 0x3df22000
        3df22000:  0000000000000000                    ........

这是为什么?为什么没有显示’b’字符,为什么还是全0?为此,不得不把最后一步重新来一遍了,即从读取PTE开始:

crash> px (0xc7d000>>12) & 0x1ff
$13 = 0x7d
crash> px 0x3883d000+$13*8 # 循着PMD找到PTE
$14 = 0x3883d3e8
crash> rd -p 0x3883d3e8
        3883d3e8:  800000002dac8867  # 注意!这个PTE和之前不同了
crash> pte 800000002dac8867
      PTE         PHYSICAL  FLAGS
800000002dac8867  2dac8000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
crash> rd -p 0x2dac8000 # 对p2进行写操作前后,其PTE指示的页面不同了!!
        2dac8000:  6262626262626262                    bbbbbbbb

重做一遍终于还是找到了,过程中PTE发生了变化。可以用vtop直接dump p2的虚拟地址看一下:

crash> vtop -c 1764 -u 0xc7d000
VIRTUAL     PHYSICAL        
c7d000      2dac8000        

   PML: 38e20000 => 8000000037f4d067
   PUD: 37f4d000 => 37318067
   PMD: 37318030 => 3883d067
   PTE: 3883d3e8 => 800000002dac8867
  PAGE: 2dac8000

      PTE         PHYSICAL  FLAGS
800000002dac8867  2dac8000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)

      VMA           START       END     FLAGS FILE
ffff9be1eb39f440     c7d000     c7f000 8100073 

      PAGE       PHYSICAL      MAPPING       INDEX CNT FLAGS
fffff3b700b6b200 2dac8000 ffff9be1c7c7eaf1      c7d  1 1fffff00080068 uptodate,lru,active,swapbacked
# 结果正是2dac8000!和手工dump的结果完全一致!

我们前面绕了那么大一圈,其实直接用vtop命令就可以把整个MMU转换过程看得一清二楚。但是通过上述手工dump PTE的过程,更加熟悉了不是吗?

  但是这里出现一个问题!为什么在对p2进行只读操作时,和对它进行写入之后,其PTE指示的物理页面是不同的页面?

# 对p2内存只读操作之后
crash> rd -p 0x3883d3e8
3883d3e8: 800000003df22225
————————————————
# 对p2内存写操作之后
crash> rd -p 0x3883d3e8
3883d3e8: 800000002dac8867

此外,其PTE对应的flags也不同,对其只读的时候,没有置入RW标志,即此时该页面是不可写的。

  是时候揭示谜底了!

  在对新分配的虚拟地址空间第一次读操作时,page fault确实会调入一个页面,然而对于这种读操作,所有的进程调入的都是同一个zeropage页面。对于这种第一次读操作,内核会将这同一个zeropage映射给被读的虚拟地址页面。这最大限度地发挥了Lazy策略的品性!

内核代码解释这一切(what->how)

到目前,我们已经知道所有事实,从用户态统计到内核crash工具分析,然而要想知道内核是如何做的,即从waht导出how,就要看一眼内核源码了,这里的目标非常明确,直接看do_anonymous_page的逻辑即可:

static int do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
        unsigned long address, pte_t *page_table, pmd_t *pmd,
        unsigned int flags)
{
    ...

    /* Use the zero-page for reads */
    if (!(flags & FAULT_FLAG_WRITE)) {
        // 只读情况直接从zeropage里拿即可。
        entry = pte_mkspecial(pfn_pte(my_zero_pfn(address),
                        vma->vm_page_prot));
        page_table = pte_offset_map_lock(mm, pmd, address, &ptl);
        if (!pte_none(*page_table))
            goto unlock;
        goto setpte;
    }
    ...
    // 非只读,才会实际从物理池里分配页面
    page = alloc_zeroed_user_highpage_movable(vma, address);
    ...
    // 如果读fault映射了zeropage,将不会递增AnonRSS计数器。
    inc_mm_counter_fast(mm, MM_ANONPAGES);
    page_add_new_anon_rmap(page, vma, address);
setpte:
    ...
}

我们看看什么是zeropage:

/*
 * ZERO_PAGE is a global shared page that is always zero: used
 * for zero-mapped memory areas etc..
 */
extern unsigned long empty_zero_page[PAGE_SIZE / sizeof(unsigned long)];
#define ZERO_PAGE(vaddr) (virt_to_page(empty_zero_page))
...
static int __init init_zero_pfn(void)
{
    zero_pfn = page_to_pfn(ZERO_PAGE(0));
    return 0;
}
...
static inline unsigned long my_zero_pfn(unsigned long addr)
{
    extern unsigned long zero_pfn;
    return zero_pfn;
}

所有谜底已经揭开!从一个实验用例,到统计分析,到crash工具分析内核状态,到内核源码确认,这就完成了一个解决问题的闭环,但貌似还缺少点什么…接下来还有一个形而上的分析。

how->why

之所以将第一次以read方式touch到的虚拟地址空间对应的物理页面映射到一个全局的zeropage,是在按需调页更进一步地加强了Lazy品性!从而更加有效地落实写时拷贝策略,将不得已而分配的物理页面真真地推迟到最后那一刻,从而将无谓的浪费行为降低到最少!

  如果说按需调页的page fault机制已经实现了Lazy品性,那么深究起来它做得还不够好,说它做得不够好是因为page fault机制忽略了按需调页两个层面中的一面:

  1. 当touch一个从未touch过的虚拟页面的时候,需要调入一个物理页面;
  2. 当调入一个物理页面时,是不是可以和其它的进程共享该物理页面;

第1点说的是按需分配,不得已时才分配,page fault做到了(注意,PTE本身也是按需调入的),第2点说的是尽力压缩,非要分配时,能不能尽量少分配,两者都做到了,Lazy策略才能达到真正按需调页思路的极致。

结论 & 评价

结论很明确:

Linux系统并不会对新分配未touch的虚拟内存映射任何物理页面;
以read方式首次touch时会映射共享的zeropage页面;
以write方式首次touch时会分配新的物理页面并映射之;
以write方式在首次read touch之后touch时,写时拷贝会分配新的物理页面并映射之。

为什么是这样可以考虑以下两点:

  • 基于虚拟地址空间的操作系统内存子系统采用的是按需调页策略,这是设计决定的。
  • 参考Linux内核的实现,Linux page fault处理区分对待了匿名页的read fault和write fault。

附:关于PTE的按需调入

在进程新fork出来初始化时,mm_init函数中会调用pgd_alloc,在pgd_alloc中会处理pgd的prepopulate,但是没有pte的populate,pte的populate是在do_page_fault中处理的,可见由于内存分布的局部稠密性全局稀疏性,PTE的Lazy分配时必要的。

  其次,我们看C库增持内存池内存需要通过系统调用进一步调用到的do_brk以及do_mmap,除非你使用了VM_LOCKED,否则就不会prepopulate任何页面。上面两点保证了至少在X86的32位/64位平台,不会对用户地址空间的虚拟内存有任何可读的预映射页面

  若要观测PTE本身的按需调入,参考下面的代码:

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

int main(int argc, char **argv)
{
        char *p1, *p2;
        char c;
        int i;

        p1 = sbrk(0);
        // 让p2离开更远的距离,防止p2的PTE和已分配的PTE在一个page内!
        for (i = 0; i < 4096*50; i++) {
                p2 = sbrk(4096);
        }

        printf("p1:%p  p2:%p\n", p1, p2);
        getchar();
}

接下来按照上文用crash工具去逐层dump PTE,然后你会发现PMD表项或者PTE所在的页面本身尚未被调入,因此按需调页在Linux的实现中是一个递归调入的过程。

后记

敲吧,门终究会开的! —《马太福音》

<think>嗯,用户想要深入了解Linux系统brk和mmap的内存申请与释放机制。从引用内容来看,用户已经查阅了相关资料,但要更系统的解释。用户可能正在学习Linux内存管理,或者是遇到相关开发问题要原理支持。考虑到引用中包含了glibc实现细节和代码示例,用户的技术水平应该在中高级层次,要深度机制说明而非基础概念。先拆解核心问题:1)brk/sbrk机制原理2)mmap/munmap机制原理3)两者差异对比4)释放机制的关键区别。要特别注意用户提到的“申请与释放机制”这个关键词,意味着要强内存操作的完整生命周期。引用[2]提供的重要信息是进程初始内存布局,这是理解brk的基础。引用[3]明确指出brk释放只整堆顶指针的特性,这个细节对理解glibc的free实现很关键。引用[4]展示的mmap函数原型要结合其使用场景说明。要注意纠正引用[3]的技术表述:现代glibc的free实现其实同时维护了brk和mmap两种释放策略,原文说释放用brk的说法不够准确。要补充说明阈值128KB的分配策略转变,这个在引用[4]里有暗示但未明说。特别要强mmap的非连续特性与brk的连续性,这关系到内存碎片问题。用户可能没意识到:频繁小内存分配更适合brk,但大块或独立释放的求必须用mmap。那些C代码示例(如引用[5]的malloc测试)可以转化为文字说明,避免代码块打断技术原理的连贯性。不过要保留关键参数:比如munmap必须完整释放mmap的整个区域,这点和brk的局部释放完全不同。最后结构化呈现时,把brk和mmap分成独立的原理章节,但对比表格要前置,让用户先建立整体认知框架。内存布局图示很必要,但文字描述要确保能独立传达空间关系。</think> 好的,我们来详细解析一下 Linux 系统中 `brk`/`sbrk` 和 `mmap`/`munmap` 这两种核心的内存申请与释放机制,理解它们是深入理解 `malloc`/`free` 等库函数工作原理的基础。 ## 核心概念:虚拟内存与物理内存 在理解具体机制前,必须明确区分: 1. **虚拟内存 (Virtual Memory)**:进程视角看到的连续内存地址空间。每个进程拥有自己独立的虚拟地址空间(如 0 到 4GB/128TB)。 2. **物理内存 (Physical Memory)**:实际的 RAM 芯片。 3. **内存映射 (Memory Mapping)**:操作系统通过**表 (Page Table)** 将进程的虚拟内存地址映射到物理内存地址或磁盘上的文件(交换空间/SWAP)。当进程访问虚拟地址时,硬件(MMU)根据表将其转换为物理地址。 4. **按 (Demand Paging)**:虚拟内存最初只有映射关系,没有分配物理内存。只有当进程真正访问该时,内核触发**缺异常 (Page Fault)** 才会分配物理帧并填充数据。 `brk` 和 `mmap` 都是用于**操作进程的虚拟内存布局**的系统用,用于 **请求操作系统建立或修改虚拟地址到物理内存(或文件)的映射关系**。释放机制则是撤销这些映射关系。 --- ## 一、 brk / sbrk 机制 ### 核心思想 * 通过整**程序间断点 (Program Break / `brk`) 的位置**来管理进程 **堆内存 (Heap)** 的大小。 * 程序间断点 (`brk`) 位于堆顶,初始位置紧挨着未初始化数据段(`.bss`)。`brk` 指针之前的内存(向上增长,低地址到高地址)是进程可用的堆内存空间。 * `brk`/`sbrk` 管理的是**单一片连续的虚拟内存区域**(堆)。下图展示了一个初始内存布局[^2]: ``` ... (低地址) | 代码段 (.text) | <- 只读程序代码 | 已初始化数据段 (.data)| <- 全局初始化变量 | 未初始化数据段 (.bss) | <- 全局未初始化变量 |-------------------| <- 初始 *brk* (&_edata)[^2] | 堆 (Heap) | <- brk/sbrk 操作区域 (向上增长) |-------------------| <- 当前 *brk* | (空白) | |-------------------| | 内存映射区域 | <- mmap 操作区域 (如库文件) |-------------------| | 栈 (Stack) | <- (向下增长) ... (高地址) ``` *(注:现代 Linux 中堆和内存映射区域(`mmap`)之间可能有其他段,且地址空间布局随机化(ASLR)会使位置偏移,但基本原理相同)* ### 1. 申请内存 (Allocation) * **`int brk(void *addr);`** * 将程序的间断点(`brk`)直接设置到指定的虚拟地址 `addr`。 * 如果 `addr` > 当前 `brk`,则**扩展堆空间**。扩展的区域仅仅是建立了**虚拟地址到“无内容”的映射**(此时物理通常尚未分配)。 * 如果 `addr` < 当前 `brk`,则**收缩堆空间**(释放内存)。 * 成功返回 0,失败返回 -1。 * **`void *sbrk(intptr_t increment);`** * 将程序的间断点增加 `increment` 字节(可以为负值)。 * 本质是 `sbrk(0)` 获取当前 `brk` 值,然后用 `brk(old_brk + increment)`。 * 成功返回**增加前**的旧 `brk` 值(即可用堆空间的起始地址),失败返回 `(void *)-1`。 * **如何分配物理内存?** * `brk`/`sbrk` 用只负责**扩展堆的虚拟地址范围**。内核仅为扩展的虚拟地址建立表项,标记为“未分配物理”。 * 当进程**第一次读写**分配堆内存区域的某个虚拟时,触发**缺异常 (Page Fault)**。 * 内核的缺处理程序检测到是合法访问(在 `brk` 范围内),则分配一个**空闲的物理帧 (Page Frame)**。 * 内核将缺发生的虚拟映射到分配的物理帧。 * 进程继续执行指令,就好像物理内存早已存在一样。 ### 2. 释放内存 (Deallocation) * 通过用 `brk` 或 `sbrk`,将 `brk` 指针 **下** 到之前某个分配的位置。 * **发生了什么?** * 内核将 `brk` 指针下后所包含的地址范围(从 `brk` 到旧 `brk`)的虚拟,标记为**无效**或**未映射**。 * 如果这些被释放的虚拟之前已经被映射到物理内存帧: * 内核会**移除**虚拟到物理帧的映射关系。 * 内核会将包含数据的物理帧(如果内容被修改过且未同步到后备存储)**回写到交换空间 (SWAP)**(如果是文件映射则回写到文件)。 * 最后,物理帧被**标记为空闲**,可供其他进程或本进程后续分配使用。 * **关键限制:释放必须连续且从堆顶开始** * `brk`/`sbrk` **只能释放堆顶部连续的内存区域**。不能释放堆中间已分配的内存块。 * 这导致堆的内部**容易产生碎片 (Fragmentation)**。即使堆中有很多小的空闲内存块,如果它们不在堆顶,也无法通过 `brk` 回收给操作系统,只能由 `malloc`/`free` 等库管理复用。 ### 3. 特点总结 * **管理区域**:堆内存。 * **虚拟内存布局**:单一、**连续**的虚拟地址区域(堆)。 * **申请特点**:通过上移 `brk` 指针扩展连续的虚拟地址范围(物理分配)。 * **释放特点**:通过下移 `brk` 指针收缩堆空间,**只能释放堆顶的连续内存**。 * **碎片问题**:内部碎片严重。只能回收堆顶空闲空间。 * **效率**:适合分配中、小型,生命周期不太短(减少频繁移动 `brk` 带来的开销)的对象。分配操作相对简单(移动指针),释放操作也简单(如果释放堆顶)。 * **锁开销**:操作单一 `brk` 指针通常要使用全局锁,在多线程环境中扩展堆时可能成为瓶颈。 * **常见使用者**:C 标准库 (`glibc`) 中的 `malloc` 对于 **中小块内存** (通常小于阈值,如 128KB) 的底层分配器。 --- ## 二、 mmap / munmap 机制 ### 核心思想 * 直接在进程的虚拟地址空间中 **创建或删除独立的、内存映射区域**。 * 映射区域 **不限于堆区**,通常位于堆区和栈区之间的 **“内存映射区域”** (参考上方图示)。 * 映射对象可以是: * **匿名内存 (Anonymous Memory)**:不关联任何文件,内容初始化为零(常见于 `malloc` 分配大块内存)。 * **文件 (File)**:将文件的一部分内容映射到内存(文件映射)。访问这段内存等同于读写文件。常见于动态库加载 (`ld.so`)、`mmap` 文件 I/O、共享内存等。 ### 1. 申请内存 (Allocation) * **`void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);`** [^1][^4] * `addr`: 建议的映射起始虚拟地址(通常设为 `NULL`,由内核决定)。 * `length`: 映射区域的长度(字节)。 * `prot`: 保护位,如 `PROT_READ`, `PROT_WRITE`, `PROT_EXEC`。 * `flags`: 关键标志位: * `MAP_ANONYMOUS` (`MAP_ANON`): 创建**匿名映射**(不关联文件)。此时 `fd` 被忽略(通常设为 -1)。 * `MAP_PRIVATE`: 创建写时复制(COW)映射,进程写操作不影响原文件或其他映射。 * `MAP_SHARED`: 共享映射(文件映射时写操作会更文件)。 * `MAP_FIXED`: 严格要求映射到 `addr` 指定的地址(使用谨慎)。 * `fd`: 被映射文件的文件描述符(匿名映射设为 -1)。 * `offset`: 文件的起始偏移量(字节)。 * **返回值**:成功时返回映射区域的起始虚拟地址,失败返回 `MAP_FAILED` (`(void *)-1`)。 * **物理内存分配?** * 内核在**内存映射区域**为进程分配指定 `length` 大小的**连续虚拟地址空间**。 * 内核建立虚拟地址到“无内容”的映射(匿名映射)或到文件位置(文件映射)的映射。 * **按分配物理**:同样是第一次访问该区域的虚拟时触发缺页中断分配物理(匿名初始化为零;文件从文件中读取数据填充物理)。 ### 2. 释放内存 (Deallocation) * **`int munmap(void *addr, size_t length);`** [^1] * `addr`: 要释放区域的起始地址(必须是 `mmap` 返回的地址)。 * `length`: 要释放区域的长度。 * **返回值**:成功返回 0,失败返回 -1。 * **发生了什么?** * 内核**解除**指定虚拟地址范围 (`[addr, addr + length - 1]`) 的映射关系。 * 对于之前映射了物理帧的虚拟: * 移除表项。 * 如果的内容被修改过(是脏)且是私有写时复制的,脏会被丢弃(不写回)。 * 如果是共享文件映射(`MAP_SHARED`)的脏,内核可能根据要将脏数据写回文件(不一定立即写回,有缓冲和回写策略)。 * 物理帧被释放(解除映射后其引用计数减一,变为零时被系统回收)。 * **关键优势:独立释放** * `munmap` **可以独立释放之前 `mmap` 分配的任何一个区域**,不论其位置(比如内存映射区域中间的一块)或大小。 * 这使得 **每个 `mmap` 分配的区域在生命周期和释放上都是独立的**,大大减少了碎片问题(外部碎片)。 ### 3. 特点总结 * **管理区域**:独立的内存映射区域(通常在堆和栈之间)。 * **虚拟内存布局**:创建**独立、不连续**的虚拟内存区域。 * **申请特点**:直接创建一个的、指定大小的虚拟内存映射区域(物理分配)。 * **释放特点**:通过 `munmap` 可以**独立释放任何一个映射区域**,无论其位置或大小。 * **碎片问题**:每个映射区域独立分配和释放,**有效减少外部碎片**。 * **效率**: * 建立和销毁映射有较高开销(涉及内核数据结构操作)。 * 适合分配**大块内存**(`glibc` `malloc` 大块阈值常为 128KB 或更高)或生命周期较长/独立的对象。 * 避免了对单一 `brk` 的争用锁,在某些多线程大内存分配场景下表现更好。 * **常见使用者**: * `glibc` `malloc` 用于分配**大块内存**。 * 显式文件 I/O (`mmap` 文件)。 * 动态库加载器 (`ld.so`) 加载共享库代码和数据。 * 进程间共享内存 (`MAP_ANONYMOUS | MAP_SHARED`)。 * 其他要独立内存区域的高级内存分配器。 --- ## 三、对比总结表 | 特性 | brk / sbrk | mmap / munmap | | :--------------------- | :-------------------------------------------- | :-------------------------------------------- | | **管理区域** | 堆 | 独立内存映射区 | | **虚拟内存布局** | **单一连续区域** (堆) | **多个独立不连续区域** (映射段) | | **申请 (扩张虚拟地址)**| 上移 `brk` 指针 | 创建映射区域 (`mmap`) | | **释放 (收缩虚拟地址)**| **只能下移 `brk` 释放堆顶内存** | **可独立释放任何区域** (`munmap`) | | **物理分配时机** | 第一次访问 (缺页中断) | 第一次访问 (缺页中断) | | **碎片问题** | **严重内部碎片**, 无法释放堆中非顶部空闲区 | **较少外部碎片**, 区域独立分配释放 | | **分配/释放效率** | 分配/释放(堆顶时)开销较小 | 分配/释放(系统用、管理结构)开销较大 | | **多线程争用** | 操作单一 `brk` 时存在锁争用 | 无争用(独立区域) | | **典型使用场景(`glibc`)** | **中小块内存分配** (< 阈值, 如128KB) | **大块内存分配** (>= 阈值)、文件映射、共享内存 | --- ## 四、与 `malloc`/`free` 的关系 C 标准库(如 `glibc`)的内存分配器 (`malloc`, `calloc`, `realloc`, `free`) 并不是操作系统原语,而是**构建在 `brk`/`sbrk` 和 `mmap`/`munmap` 之上的一套复杂的用户级管理库**。 1. **请求内存**: * 当程序用 `malloc(size)` 时: * 如果 `size` **小于**一个特定阈值(例如 128KB),`glibc` 的分配器通常从它用 `brk` 管理的堆空间中分配内存。它在这个大的连续堆空间内部进行精细管理(处理小块分配、回收和复用),避免频繁用 `sbrk` 扩张堆。 * 如果 `size` **大于等于**该阈值,`glibc` 通常会直接用 `mmap` 创建一个**的、独立的匿名映射区域**来满足这个大内存请求。这样 `free` 的时候可以直接用 `munmap` 归还整个区域。 2. **释放内存**: * 当程序用 `free(ptr)` 时: * 如果 `ptr` 是由 `mmap` 分配的(大内存块),那么 `glibc` 最终会用 `munmap` 来释放整个映射区域。 * 如果 `ptr` 是位于 `brk` 管理的堆中的小块内存,`glibc` 的分配器会将其标记为空闲,并放入其内部管理的**空闲链表 (Free List)** 中,以供后续分配复用。它**并不会立即**用 `brk` 来缩减堆大小给操作系统。 3. **堆缩减**: * `glibc` 的分配器有一个**内存整理**的机制(通常在 `free` 时触发)。如果它发现堆顶有一大块连续的空闲内存(称为 “top chunk” 的顶部空间)变得足够大,并且这块内存的大小超过了某个阈值,分配器**才可能**用 `brk` 将堆顶指针下移,把这部分物理内存真正释放给操作系统。 ### 结论 `brk` 和 `mmap` 是 Linux 内核提供的两种最基础的虚拟内存操作机制。`malloc`/`free` 等库函数利用它们实现高级的内存管理策略。了解 `brk`(连续堆管理,只适合尾部释放)和 `mmap`(独立区域管理,可独立释放)的核心差异,是理解现代内存分配器行为的关键。`glibc` 的分配器正是根据大小阈值(小内存用`brk`+内部管理,大内存用`mmap`)来平衡效率和碎片问题的精妙实现[^1][^2][^3][^4]。 [^1]: 引用内容: Linux 虚拟内存操作函数声明与概述。 [^2]: 引用内容: 进程内存布局基础,_edata 与堆顶。 [^3]: 引用内容: brk/sbrk 与 mmap/munmap 的简化使用特征对比。 [^4]: 引用内容: mmap 函数参数详解,重点在文件/匿名映射标志。
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值