使用mmap来学习/proc/pid/smaps

在很多年前我就断断续续使用过/proc/pid/smaps观测过进程的状态。

当时主要用于观测栈大小和一些文件映射的信息。

当需要特定信息时,会现查文档,因此虽然对于/proc/pid/smaps有模糊的认识,但是对于具体字段的含义认识并不深刻。

纸上得来终觉浅,最终我还是打算花一些时间来写几段代码来印证/proc/pid/smaps中文档的描述。

针对某映射地址范围08048000-080bc000(例如 /bin/bash),/proc/pid/smaps的一个典型条目如下:

08048000-080bc000 r-xp 00000000 03:02 13130      /bin/bash
Size:               1084 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                 892 kB
Pss:                 374 kB
Pss_Dirty:             0 kB
Shared_Clean:        892 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:          892 kB
Anonymous:             0 kB
KSM:                   0 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:           0
VmFlags: rd ex mr mw me dw

根据文档,这些字段的含义如下:

  • Size: 映射区域的大小(即虚拟内存大小)。
  • KernelPageSize: 每个VMA分配的页面大小,通常与页表条目中的大小相同。
  • MMUPageSize: MMU使用的页面大小(在大多数情况下与KernelPageSize相同)。
  • Resident Set Size (RSS): 进程驻留内存大小,指的是进程使用的物理内存大小
  • Proportional Set Size (PSS): 是指进程在内存中的页面数量,其中每个页面都按其共享的进程数进行划分。因此,如果一个进程有独占的1000页,和另一个进程共享1000页,它的PSS将为1500。
  • PSS_Dirty: 是由脏页组成的PSS的部分, Pss_Clean不包括在内,但可以通过从Pss中减去Pss_Dirty来计算
  • Shared_Clean: 共享干净页大小,指的是共享内存中未被修改的页大小
  • Shared_Dirty: 共享脏页大小,指的是共享内存中被修改的页大小
  • Private_Clean: 私有干净页大小,指的是私有内存中未被修改的页大小
  • Private_Dirty: 私有脏页大小,指的是私有内存中被修改的页大小
  • Referenced: 当前标记为已引用或访问的内存量
  • Anonymous: 不属于任何文件的内存量。即使与文件关联的映射也可能包含匿名页:当使用 MAP_PRIVATE 并且修改页面时,文件页面将被替换为私有的匿名副本。
  • LazyFree: 使用 madvise(MADV_FREE) 标记的内存量。内存不会立即使用 madvise() 释放。如果内存是干净的,它将在内存压力下释放。请注意,由于当前实现中使用的优化,打印的值可能低于实际值。如果不希望这样,请提交错误报告。
  • KSM: 其中有多少页面是 KSM 页面。请注意,KSM 放置的零页面不包括在内,只包括实际的 KSM 页面。
  • AnonHugePages: 由透明大页面支持的内存数量。
  • ShmemPmdMapped: 由大页面支持的共享 (shmem/tmpfs) 内存数量。
  • Shared_Hugetlb: 和 PrivateHugetlb: 显示历史上原因未计入 "RSS" 或 "PSS" 字段的由 hugetlbfs 页面支持的内存量。它们也不包含在 {Shared,Private}{Clean,Dirty} 字段中。
  • Swap: 显示有多少非匿名内存也使用,但已交换出去。
  • SwapPss: 此映射的比例交换份额。与 "Swap" 不同,这不考虑底层 shmem 对象的交换出页面。
  • Locked: 指示映射是否锁定在内存中。
  • THPeligible: 指示映射是否有资格分配任何当前启用的尺寸的自然对齐 THP 页面。如果是 1,否则为 0。
  • VmFlags: 该成员以两位字母编码方式表示与特定虚拟内存区域关联的内核标志。

文档还给了两点提示:

  • 读取 /proc/pid/maps or /proc/pid/smaps 是存在竞争的。在内存映射修改的同时做部分读取可能会遇到不一致性,但是Linux内核保证了部分输出的正确性。
  • 由于KERNEL配置和内核版本迭代,/proc/pid/smaps的输出以及字段可能会发生变化。各项内存配置可能会是依赖特定版本。

smaps中的字段过多,而且有一些内核和透明大页相关字段。

这里仅测试常归内存分配和非透明大页应用程序对smaps中字段的影响。

现代Linux系统都会使用ASLR技术来随机化进程空间的地址空间布局,这会使得恶意攻击者难以准确预测代码和数据在内存中的位置,从而增加了系统的安全性。

从另一个角度看,ASLR会干扰我们对smaps文件的分析。

让我们先使用sudo sh -c 'echo 0 > /proc/sys/kernel/randomize_va_space'来临时禁用掉ASLR


先来编写一段简单分配8M内存的代码,但并不对内存进行赋值,代码如下:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
void main(int argc, char *argv[])
{
        char *x = (char *)mmap(0, 8*1024*1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        printf("%p\n", x);
        for (;;) {
                usleep(1000);
        }
        munmap(x, 1000);
}

需要说明的是,这段代码并没有使用malloc,是因为malloc会重复使用分配过但是又释放的内存。

有可能使用malloc分配出来的内存,是之前代码已经使用过的,这会干扰我们的判断(比如虽然从本次分配开始,我们一直没有访问过这版内存,但是有可能在上一次分配时已经被进程访问过了)。

来看一下/proc/pid/smaps的输出(这里仅列出与上述代码相关的字段,无关字段已经省略):

...
7ffff75d1000-7ffff7dd4000 rw-p 00000000 00:00 0
Size:               8204 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   8 kB
Pss:                   8 kB
Pss_Dirty:             8 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         8 kB
Referenced:            8 kB
Anonymous:             8 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    1
VmFlags: rd wr mr mw me ac
...

smaps的输出可以看出Size字段正是我们分配的8M虚拟内存。

由于我们并没有对分配出来的内存有任何访问,所以RssPss都应该为0,这里却显示为8K

这可能是因为我这里的测试环境并不是标准的Linux系统,而是WSL

我在标准的Linux系统中,RssPss都会为0KB,这并不会影响我们接下来的分析。


接下来,我们读取一下使用mmap出来的内存, 再来观察smaps的输出:

diff --git a/a.c b/a.c
index 555efe5..bffd055 100644
--- a/a.c
+++ b/a.c
@@ -7,6 +7,7 @@ void main(int argc, char *argv[])
 {
         char *x = (char *)mmap(0, 8*1024*1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
         printf("%p\n", x);
+        printf("%d\n", x[8*1024*1024-1]);
         for (;;) {
                 usleep(1000);
         }
...
7ffff75d1000-7ffff7dd4000 rw-p 00000000 00:00 0
Size:               8204 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   8 kB
Pss:                   8 kB
Pss_Dirty:             8 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         8 kB
Referenced:            8 kB
Anonymous:             8 kB
...

smaps的输出可以看出,所有的值都没有增加。

这并不符合我们学习的操作系统知识,从MMU的角度来看,只要我们访问了一个不存的的虚拟内存地址,就必然会产生一个minor page fault, 进而造成操作系统为这个虚拟内存地址所在的虚拟页分配物理内存。

带着疑惑,我们去查看一下/proc/pid/pagemap, 这个虚拟文件可以给出某个进程的详细内存映射信息,由于信息量过大,这个文件内容是以二进制提供的。

GitHub随便找了个解析器, 输出结果如下:

...
0x7ffff7dd0000     : pfn 0                soft-dirty 0 file/shared 0 swapped 0 present 1 library
...

/proc/pid/pagemap输出上看,操作系统已经为我们访问的内存分配的物理内存,但是/proc/pid/smaps中却并没有体现。

其根本原因就是,当我们使用mmap分配一个匿名页(MAP_ANONYMOUS)时,操作系统会保证分配出来的内存全部由0填充。

为了优化填充效率,Linux中会维护一个ZeroPage, 这个内存页是由操作系统管理的一个由0填充的只读内存页。

当我们试图对这个ZeroPage进行写操作时,就会触发copy-on-write机制,这时操作系统才会真正为当前进程分配物理内存。

为了验证这一点,让我们将代码修改如下:

diff --git a/a.c b/a.c
index 4f7b123..f1cf401 100644
--- a/a.c
+++ b/a.c
@@ -8,6 +8,7 @@ void main(int argc, char *argv[])
         char *x = (char *)mmap(0, 8*1024*1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        printf("%p\n", x);
        printf("%d\n", x[8*1024*1024-1]);
+        x[8*1024*1024-1] = 1;
         for (;;) {
                 usleep(1000);
         }

smaps的输出如下:

...
7ffff75d1000-7ffff7dd4000 rw-p 00000000 00:00 0
Size:               8204 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  12 kB
Pss:                  12 kB
Pss_Dirty:            12 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:        12 kB
Referenced:           12 kB
Anonymous:            12 kB
...

果然RssPss都如预期增加了一个PageSize


到目前为止RssPss总是一样的,让我们来尝试一下两个进程共享内存,看看有什么不同。

我们先使用一个mmapMAP_PRIVATE的方式将一个8m.bin的文件映射到进程的虚拟空间,并启动一个进程, 代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
void main(int argc, char *argv[])
{
    int fd;
    char *x;
    struct stat file_info;
    // 打开文件
    fd = open("8m.bin", O_RDWR);
    if (fd == -1) {
        perror("open");
        return ;
    }
    // 获取文件信息
    if (fstat(fd, &file_info) == -1) {
        perror("fstat");
        close(fd);
        return ;
    }
    // 映射文件至内存
    x = mmap(0, file_info.st_size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
    if (x == NULL) {
        perror("mmap");
        close(fd);
        return ;
    }
    printf("%p\n", x);
    for (;;) {
                usleep(1000);
    }
    munmap(x, 1000);
    }

这时smaps的输出如下:

7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   0 kB
Pss:                   0 kB
Pss_Dirty:             0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:             0 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    0
VmFlags: rd wr mr mw me ac

由于我们并没有读写过mmap后的内存,并没有触发minor page fault,因此RssPss都为0KB


我们来读取一下文件的内容,将代码修改如下:

diff --git a/a.c b/a.c
index d356103..b3bbbe6 100644
--- a/a.c
+++ b/a.c
@@ -29,6 +29,7 @@ void main(int argc, char *argv[])
         return ;
     }
     printf("%p\n", x);
+    printf("%d\n", x[8*1024*1024-1]);
     for (;;) {
                 usleep(1000);
     }

这时smaps的输出如下:

...
$ cat /proc/59838/smaps  | grep -A30 7ffff74
7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  64 kB
Pss_Dirty:             0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:        64 kB
Private_Dirty:         0 kB
Referenced:           64 kB
Anonymous:             0 kB
...

Rss的字段可以看出,操作系统已经为这块虚拟内存分配了64kB物理内存。

由于我们只启动了一个进程来打开文件,所以PssRss的值是一样的。

由于我们只有一个进程来独占这块内存并且没有对文件内容进行过修改,所以分配的物理内存页被统计入了Private_Clean,而Private_Dirty为0。

我们来启动2个相同进程,smaps的输出如下:

7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  32 kB
Pss_Dirty:             0 kB
Shared_Clean:         64 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:           64 kB
Anonymous:             0 kB

由于我们启动了2个相同的进程并执行了相同的逻辑,因此Pss的值为Rss除以2

需要说明的是,RssPss并不总是存在比例关系

我们将上面的程序称为A, 然后A的基础上做出如下修改,得到程序B:

diff --git a/a.c b/a.c
index b3bbbe6..73315f5 100644
--- a/b.c
+++ b/b.c
@@ -29,7 +29,7 @@ void main(int argc, char *argv[])
         return ;
     }
     printf("%p\n", x);
-    printf("%d\n", x[8*1024*1024-1]);
+    printf("%d\n", x[8*1024*1024-1 - 128*1024]);
     for (;;) {
                 usleep(1000);
     }

同时运行程序AB, 再来看进程Asmaps文件内容:

...
7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  64 kB
Pss_Dirty:             0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:        64 kB
Private_Dirty:         0 kB
Referenced:           64 kB
Anonymous:             0 kB
...

可以看到虽然进程AB共享了同一个文件,但是此时Pss竟然等于Rss

这是因为Pss的统计粒度是Page

比如进程A被分配了3个物理页A,B,C。这三个物理页的共享进程数分别为A:1,B:2,C:3, 那么Pss的最终值就是PageSize*(1/1 +1/2+1/3)。

而当前进程AB并没有共享任何内存页。


让我们为程序A增加文件代码,并启动两个相同的程序,修改如下:

diff --git a/a.c b/a.c
index b3bbbe6..34c8a42 100644
--- a/a.c
+++ b/a.c
@@ -30,6 +30,7 @@ void main(int argc, char *argv[])
     }
     printf("%p\n", x);
     printf("%d\n", x[8*1024*1024-1]);
+    x[8*1024*1024-1] = 1;
     for (;;) {
                 usleep(1000);
     }

smaps的输出如下:

...
7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  34 kB
Pss_Dirty:             4 kB
Shared_Clean:         60 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         4 kB
Referenced:           64 kB
Anonymous:             4 kB
...

这次的输出是目前为止信息量最大的一次。

由于我们对内存的写入,导致Anonymous的内存增加了4kB,这是由于MAP_PRIVATE参数导致的。

当我们使用MAP_PRIVATE来打开文件时,我们所有对文件的修改都会触发copy-on-write, 为当前进程分配独占物理内存。

可想而知,以MAP_PRIVATE打开的文件,也不可能真的被修改。

下面让我们来手工模拟如何计算出上述参数的值。

Rss的值代表操作系统为我们的虚拟内存映射了64kB物理内存,Anonymous的值操作系统为我们的虚拟内存映射了4kB独占物理内存。

也就是说当前两个进程的共享内存一共为60kB,字段Shared_Clean也可以印证这一点。

由于60kB内存是由两个进程共享, 并且还有4kB的独占内存,因此Pss等于60/2+4=34kB

由于修改过的脏页是私有页(自己独占), 因此Private_Dirty的值为4kB, Pss_Dirty的值为4kB/1=4kB


让我们将MAP_PRIVATE换为MAP_SHARED再来启动两个相同进程,看看有什么不同。

diff --git a/a.c b/a.c
index 34c8a42..1493c60 100644
--- a/a.c
+++ b/a.c
@@ -22,7 +22,7 @@ void main(int argc, char *argv[])
         return ;
     }
     // 映射文件至内存
-    x = mmap(0, file_info.st_size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
+    x = mmap(0, file_info.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
     if (x == NULL) {
         perror("mmap");
         close(fd);

smaps的输出如下:

....
7ffff7400000-7ffff7c00000 rw-s 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  60 kB
Pss:                  30 kB
Pss_Dirty:             2 kB
Shared_Clean:         56 kB
Shared_Dirty:          4 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:           60 kB
Anonymous:             0 kB
...

这次操作系统已经不再为我们分配匿名内存了,整个文件映射内存全部是两进程共享,就连Pss_Dirty都变为了2kB

为了难证Pss_Dirty的值是怎么计算的,我们不再启动两个相同的进程,而是分别让两个进程写入两个不同的内存位置。

在上面代码的基础上,做出如下修改:

diff --git a/a.c b/a.c
index 1493c60..c8ebe28 100644
--- a/a.c
+++ b/a.c
@@ -29,8 +29,8 @@ void main(int argc, char *argv[])
         return ;
     }
     printf("%p\n", x);
-    printf("%d\n", x[8*1024*1024-1]);
-    x[8*1024*1024-1] = 1;
+    printf("%d\n", x[8*1024*1024-1 - 128*1024]);
+    x[8*1024*1024-1 - 128*1024] = 1;
     for (;;) {
                 usleep(1000);
     }

smaps中的输出如下:

7ffff7400000-7ffff7c00000 rw-s 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  60 kB
Pss:                  60 kB
Pss_Dirty:             4 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:        56 kB
Private_Dirty:         4 kB
Referenced:           60 kB
Anonymous:             0 kB

可以看到Pss_Dirty的值和和Pss的值没有直接比例关系,但是其计算方式很像。

Pss_Dirty只统计当前的共享脏页, 其中每个页面都按其共享的进程数按比例时计算。

需要说明的是,这种共享并不是说别的进程也修改了这个内存页,而是指这块内存被映射到了几个进程中去。

发表评论

nine + one =