计算机系统篇之虚拟内存(4):再探 mmap

Author: stormQ

Created: Friday, 13. November 2020 07:13AM

Last Modified: Tuesday, 17. November 2020 12:39PM



摘要

本文通过示例的方式研究了参数 length 和 offset 对 mmap 函数行为的影响,从而更好地理解内存映射。

若参数 length 不是页大小的整数倍,那么 mmap 的行为?

参数length不是页大小的整数倍的情形可以细分为两种:1)length 小于页大小;2)length 大于页大小,但不是页大小的整数倍。这两种情况在本质上没有什么区别。因此,我们以第一种情况作为研究示例,最终结论同样适用于第二种情况。

如果参数length小于页大小时,我们通过研究以下问题来分析mmap函数的真正行为。

研究过程:

step 0: 准备研究示例

该示例首先使用mmap函数创建一个可读写、共享的虚拟内存区域,内存映射对象为 Linux 系统中的普通文件——a.txt。然后,写一些数据到该虚拟内存区域中大于length的部分。最后,调用msync函数显式地同步这些数据到 a.txt 文件中。

源码,vm3_main.cpp:

#include <unistd.h>
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
  if (argc < 4)
  {
    printf("Usage, needed args: backed-file length offset\n");
    return EXIT_FAILURE;
  }

  int fd = open(argv[1], O_RDWR);
  if (-1 == fd)
  {
    perror("open failed, reason:");
    return EXIT_FAILURE;
  }

  const auto length = atoi(argv[2]);
  const auto offset = atoi(argv[3]);
  auto addr = static_cast<int *>(mmap(NULL, length, 
    PROT_READ | PROT_WRITE, MAP_SHARED, fd, offset));
  if (MAP_FAILED == addr)
  {
    perror("mmap failed, reason:");
    return EXIT_FAILURE;
  }
  close(fd);

  printf("page size:%ld bytes\n", sysconf(_SC_PAGE_SIZE));

  const auto end = length + 10;
  for (int i = length; i < end; i++)
  {
    addr[i] = i;
  }
  printf("\n");

  msync(addr, end, MS_SYNC);

  return EXIT_SUCCESS;
}

编译:

$ g++ -o vm3_main vm3_main.cpp -g

查看 a.txt 文件的初始内容(ASCII 码,共 110 字节):

$ cat a.txt 
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345

使用od命令查看 a.txt 的所有内容:

$ od -A x -tc a.txt 
000000   1   2   3   4   5   1   2   3   4   5  \n   1   2   3   4   5
000010   1   2   3   4   5  \n   1   2   3   4   5   1   2   3   4   5
000020  \n   1   2   3   4   5   1   2   3   4   5  \n   1   2   3   4
000030   5   1   2   3   4   5  \n   1   2   3   4   5   1   2   3   4
000040   5  \n   1   2   3   4   5   1   2   3   4   5  \n   1   2   3
000050   4   5   1   2   3   4   5  \n   1   2   3   4   5   1   2   3
000060   4   5  \n   1   2   3   4   5   1   2   3   4   5  \n
00006e

注:最左侧一列为地址,选项-A x表示地址显示为十六进制。默认情况下,地址显示的是八进制。选项-tc表示文件内容以 ASCII 码显示,通过od命令可以看出文件中所有字节的值,包括换行符,共 110 字节。

step 1: 查看新创建的虚拟内存区域

1)使用 gdb 运行可执行目标文件——vm3_main

$ gdb -q --args ./vm3_main ./a.txt 10 0
Reading symbols from ./vm3_main...done.

2)启动并执行到“调用mmap”的下一条语句处(对应源文件中的第 26 行)

(gdb) start
Temporary breakpoint 1 at 0x4007b5file vm3_main.cpp, line 9.
Starting program: /home/test/vm/vm3_main ./a.txt 10 0

Temporary breakpoint 1main (argc=4, argv=0x7fffffffdae8at vm3_main.cpp:9
9      if (argc < 4)
(gdb) b 26
Breakpoint 2 at 0x400864file vm3_main.cpp, line 26.
(gdb) c
Continuing.

Breakpoint 2main (argc=4, argv=0x7fffffffdae8at vm3_main.cpp:26
26      if (MAP_FAILED == addr)

断点被击中时,表示我们已完成了创建内存映射的过程。

3)查看进程的内存映射

(gdb) i proc mappings 
process 17039
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
            0x400000           0x401000     0x1000        0x0 /home/test/vm/vm3_main
            0x600000           0x601000     0x1000        0x0 /home/test/vm/vm3_main
            0x601000           0x602000     0x1000     0x1000 /home/test/vm/vm3_main
      0x7ffff7a0d000     0x7ffff7bcd000   0x1c0000        0x0 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7bcd000     0x7ffff7dcd000   0x200000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7dcd000     0x7ffff7dd1000     0x4000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7dd1000     0x7ffff7dd3000     0x2000   0x1c4000 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7dd3000     0x7ffff7dd7000     0x4000        0x0 
      0x7ffff7dd7000     0x7ffff7dfd000    0x26000        0x0 /lib/x86_64-linux-gnu/ld-2.23.so
      0x7ffff7fd1000     0x7ffff7fd4000     0x3000        0x0 
      0x7ffff7ff6000     0x7ffff7ff7000     0x1000        0x0 /home/test/vm/a.txt
      0x7ffff7ff7000     0x7ffff7ffa000     0x3000        0x0 [vvar]
      0x7ffff7ffa000     0x7ffff7ffc000     0x2000        0x0 [vdso]
      0x7ffff7ffc000     0x7ffff7ffd000     0x1000    0x25000 /lib/x86_64-linux-gnu/ld-2.23.so
      0x7ffff7ffd000     0x7ffff7ffe000     0x1000    0x26000 /lib/x86_64-linux-gnu/ld-2.23.so
      0x7ffff7ffe000     0x7ffff7fff000     0x1000        0x0 
      0x7ffffffdd000     0x7ffffffff000    0x22000        0x0 [stack]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

从上面可以看出,我们所创建的虚拟内存区域的详细信息,比如:起始地址为 0x7ffff7ff6000、大小为 0x1000、内存映射对象为 /home/test/vm/a.txt 文件且偏移量为 0。

另外,我们可以查看mmap函数所返回的虚拟内存区域的起始地址,即局部变量addr的值也确实是 0x7ffff7ff6000:

(gdb) p/x addr
$1 = 0x7ffff7ff6000

也就是说,我们新创建的虚拟内存区域的地址范围为[0x7ffff7ff6000, 0x7ffff7ff7000)(前闭后开),大小为 0x1000 字节(即 4KB,也就是页面大小)。

因此,可以得出结论:如果参数length不是页大小的整数倍时,mmap函数实际创建的虚拟内存区域大小 = 不小于length的页大小的最小整数倍。

step 2: 修改该虚拟内存区域中大于length的部分,观察这些修改是否对其他进程可见

1)在另一个 shell 窗口中再次执行可执行目标文件——vm3_main(记为进程 B,步骤step 1中的记为进程 A)

$ gdb -q --args ./vm3_main ./a.txt 10 0
Reading symbols from ./vm3_main...done.
(gdb) start
Temporary breakpoint 1 at 0x4007b5file vm3_main.cpp, line 9.
Starting program: /home/test/vm/vm3_main ./a.txt 10 0

Temporary breakpoint 1main (argc=4, argv=0x7fffffffdae8at vm3_main.cpp:9
9      if (argc < 4)
(gdb) b 26
Breakpoint 2 at 0x400864file vm3_main.cpp, line 26.
(gdb) c
Continuing.

Breakpoint 2main (argc=4, argv=0x7fffffffdae8at vm3_main.cpp:26
26      if (MAP_FAILED == addr)

2)分别查看进程 A 和进程 B 的虚拟内存区域中length后面 40 字节(即 10 个int类型的值)的内容

进程 A 中:

(gdb) x/10wd addr+length
0x7ffff7ff6028:    171258931   875770417   858927413   822752564
0x7ffff7ff6038:    892613426   875770417   842074677   825570355
0x7ffff7ff6048:    892613426   858927370

进程 B 中:

(gdb) x/10wd addr+length
0x7ffff7ff6028:    171258931   875770417   858927413   822752564
0x7ffff7ff6038:    892613426   875770417   842074677   825570355
0x7ffff7ff6048:    892613426   858927370

可以看出,此时(即两个进程都未对各自的虚拟内存区域的内容进行修改)两个进程的各自虚拟内存区域中length后面 40 字节的内容是相同的。

3)进程 B 继续执行到“写一些数据到其虚拟内存区域中length后面 40 字节”的下一条语句处(对应源文件中的第 40 行),而进程 A 保持不动

进程 B 中:

(gdb) b 40
Breakpoint 3 at 0x4008d7: file vm3_main.cpp, line 40.
(gdb) c
Continuing.
page size:4096 bytes

Breakpoint 3, main (argc=4, argv=0x7fffffffdae8) at vm3_main.cpp:40
40      printf("\n");

4)再次查看进程 B 和进程 A 的虚拟内存区域中length后面 40 字节(即 10 个int类型的值)的内容

进程 B 中:

(gdb) x/10wd addr+length
0x7ffff7ff6028:    10  11  12  13
0x7ffff7ff6038:    14  15  16  17
0x7ffff7ff6048:    18  19

可以看出,进程 B 确实已经修改了其虚拟内存区域中length后面 40 字节的内容。

进程 A 中:

(gdb) x/10wd addr+length
0x7ffff7ff6028:    10  11  12  13
0x7ffff7ff6038:    14  15  16  17
0x7ffff7ff6048:    18  19

可以看出,进程 A 的虚拟内存区域中length后面 40 字节的内容与进程 B 的相同。但此时,进程 A 还未对其虚拟内存区域中length后面 40 字节的内容进行修改。

因此,可以得出结论:如果参数length不是页大小的整数倍且内存映射对象为普通文件时,一个进程修改该虚拟内存区域中大于length的部分,对其他也映射该区域的进程可见。

注意: 这里的所修改的大于length的部分仍在该虚拟内存区域的地址范围之内。如果超出了这个范围,可能会产生 coredump。

step 3: 调用msync函数显式地将进程 B 中所修改的数据进行同步操作,观察 a.txt 中是否有这些修改

1)在调用msync函数前,查看 a.txt 文件的当前内容(十进制)

$ od -tu4 a.txt
0000000  875770417  858927413  822752564  892613426
0000020  875770417  842074677  825570355  892613426
0000040  858927370  842085684         10         11
0000060         12         13         14         15
0000100         16         17         18         19
0000120  842085684  171258931  875770417  858927413
0000140  822752564  892613426  875770417       2613
0000156

从上面可以看出,内核已经自动地将进程 B 所修改的数据同步到 a.txt 中了。

因此,可以得出结论:如果参数length不是页大小的整数倍且内存映射对象为普通文件时,一个进程修改该虚拟内存区域中大于length的部分,这些被修改的内容会被内核同步到内存映射文件中。

注意: 上述示例中大于length的部分还未超出内存映射文件的大小范围。

于是,可以很自然地引申出这样一个问题:如果所修改的虚拟内存区域超出内存映射文件的大小范围时,那么默认情况下这些被修改的内容还会被内核同步到内存映射文件中吗?让我们继续研究。

2)在进程 B 中,修改其虚拟内存区域中起始地址为 addr+27,长度为 4 字节的内容(其中两个字节超出了内存映射文件的大小范围)

(gdb) p/x *(addr+27)=0x12345678
$10 = 0x12345678

再次查看 a.txt 文件的当前内容(十进制,且将每个元素看作 4 字节):

$ od -tu4 a.txt
0000000  875770417  858927413  822752564  892613426
0000020  875770417  842074677  825570355  892613426
0000040  858927370  842085684         10         11
0000060         12         13         14         15
0000100         16         17         18         19
0000120  842085684  171258931  875770417  858927413
0000140  822752564  892613426  875770417      22136
0000156

从上面的结果中可以看出,a.txt 文件中的最后一个值被修改了。

查看 a.txt 文件的当前内容(十六进制,且将每个元素看作 1 字节):

$ od -A x -tx1 a.txt
000000 31 32 33 34 35 31 32 33 34 35 031 32 33 34 35
000010 31 32 33 34 35 031 32 33 34 35 31 32 33 34 35
000020 031 32 33 34 35 31 32 000 00 00 000 00 00
000030 000 00 00 0d 00 00 00 000 00 00 0f 00 00 00
000040 10 00 00 00 11 00 00 00 12 00 00 00 13 00 00 00
000050 34 35 31 32 33 34 35 031 32 33 34 35 31 32 33
000060 34 35 031 32 33 34 35 31 32 33 34 78 56
00006e

从上面的结果中可以看出,只有 0x56、0x78 这两个字节的内容被同步到了 a.txt 文件。上述程序是在字节序为小端的机器上运行的。所以,起始地址为 addr+27 的后面 4 字节的内容依次为(从低地址到高地址):0x78、0x56、0x34、0x12。也就是说,0x34、0x12 这两个字节对应的地址超出了内存映射文件的大小范围。

因此,可以得出结论:如果所修改的虚拟内存区域超出内存映射文件的大小范围时,那么默认情况下这些被修改的内容不会被内核同步到内存映射文件中。

研究结论:

如果参数length不是页大小的整数倍时,mmap函数的真正行为:


参数 offset 取不同值时,对 mmap 的行为有何影响?

我们通过研究以下问题来分析参数offset取不同值时,mmap函数的真正行为。

研究过程:

step 0: 准备研究示例

研究示例源码采用上述的 vm3_main.cpp。

step 1: 研究参数offset的取值小于 0 时,mmap函数的行为

此处,a.txt 文件的大小为 110 字节。

$ ./vm3_main ./a.txt 1 -1
mmap failed, reason:: Invalid argument

从上面结果中可以看出,参数offset的取值小于 0 时,mmap函数执行失败,错误信息为Invalid argument(参数无效)。

step 2: 研究参数offset的取值等于 0 时,mmap函数的行为

此处,a.txt 文件的大小为 110 字节。

$ ./vm3_main ./a.txt 1 0
page size:4096 bytes

从上面结果中可以看出,当参数offset的取值小于 0 时,mmap函数执行成功,并且无运行时错误。

step 3: 研究参数offset的取值等于 1 时(即不是页面大小的整数倍),mmap函数的行为

此处,a.txt 文件的大小为 110 字节。

$ ./vm3_main ./a.txt 1 1
mmap failed, reason:: Invalid argument

从上面结果中可以看出,当参数offset的取值等于 1 时(即不是页面大小的整数倍),mmap函数也会执行失败,错误信息为Invalid argument(参数无效)。

step 4: 研究参数offset的取值等于 4096 时(即正好是页面大小的整数倍),mmap函数的行为

这里我们可以分成 4 种情况:1)内存映射文件的大小 < 页大小;2)内存映射文件的大小 = 页大小;3)内存映射文件的大小 > 页大小,且是页大小的整数倍;4)内存映射文件的大小 > 页大小,但不是页大小的整数倍。

1)内存映射文件的大小 < 页面大小时

此处,a.txt 文件的大小为 110 字节。

$ ./vm3_main ./a.txt 1 4096
page size:4096 bytes
Bus error (core dumped)

从从上面结果中可以看出,这种情况下mmap函数没有报错,即执行成功了。但进程在运行时会崩溃,原因是收到了SIGBUS信号。

下面我们分析下是哪行语句导致了SIGBUS信号的产生。

先复现SIGBUS信号产生时的现场:

$ gdb -q --args ./vm3_main ./a.txt 1 4096
Reading symbols from ./vm3_main...done.
(gdb) start
Temporary breakpoint 1 at 0x4007b5: file vm3_main.cpp, line 9.
Starting program: /home/test/vm/vm3_main ./a.txt 1 4096

Temporary breakpoint 1, main (argc=4, argv=0x7fffffffdae8) at vm3_main.cpp:9
9      if (argc < 4)
(gdb) b 26
Breakpoint 2 at 0x400864: file vm3_main.cpp, line 26.
(gdb) c
Continuing.

Breakpoint 2, main (argc=4, argv=0x7fffffffdae8) at vm3_main.cpp:26
26      if (MAP_FAILED == addr)
(gdb) n
31      close(fd);
(gdb) 
33      printf("page size:%ld bytes\n", sysconf(_SC_PAGE_SIZE));
(gdb) 
page size:4096 bytes
35      const auto end = length + 10;
(gdb) 
36      for (int i = length; i < end; i++)
(gdb) 
38        addr[i] = i;
(gdb) 

Program received signal SIGBUS, Bus error.
0x00000000004008d3 in main (argc=4, argv=0x7fffffffdae8) at vm3_main.cpp:38
38        addr[i] = i;

查看局部变量addri的值:

(gdb) p/x addr
$1 = 0x7ffff7ff6000
(gdb) p/d i
$2 = 1

查看进程的内存空间分布:

(gdb) i proc mappings 
process 28476
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
            0x400000           0x401000     0x1000        0x0 /home/test/vm/vm3_main
            0x600000           0x601000     0x1000        0x0 /home/test/vm/vm3_main
            0x601000           0x602000     0x1000     0x1000 /home/test/vm/vm3_main
            0x602000           0x623000    0x21000        0x0 [heap]
      0x7ffff7a0d000     0x7ffff7bcd000   0x1c0000        0x0 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7bcd000     0x7ffff7dcd000   0x200000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7dcd000     0x7ffff7dd1000     0x4000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7dd1000     0x7ffff7dd3000     0x2000   0x1c4000 /lib/x86_64-linux-gnu/libc-2.23.so
      0x7ffff7dd3000     0x7ffff7dd7000     0x4000        0x0 
      0x7ffff7dd7000     0x7ffff7dfd000    0x26000        0x0 /lib/x86_64-linux-gnu/ld-2.23.so
      0x7ffff7fd1000     0x7ffff7fd4000     0x3000        0x0 
      0x7ffff7ff6000     0x7ffff7ff7000     0x1000     0x1000 /home/test/vm/a.txt
      0x7ffff7ff7000     0x7ffff7ffa000     0x3000        0x0 [vvar]
      0x7ffff7ffa000     0x7ffff7ffc000     0x2000        0x0 [vdso]
      0x7ffff7ffc000     0x7ffff7ffd000     0x1000    0x25000 /lib/x86_64-linux-gnu/ld-2.23.so
      0x7ffff7ffd000     0x7ffff7ffe000     0x1000    0x26000 /lib/x86_64-linux-gnu/ld-2.23.so
      0x7ffff7ffe000     0x7ffff7fff000     0x1000        0x0 
      0x7ffffffdd000     0x7ffffffff000    0x22000        0x0 [stack]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

从上面的结果中可以看出,mmap函数的返回值(即局部变量addr的值)为新创建的虚拟内存区域的起始地址 0x7ffff7ff6000。但是,由于offset的值超过了内存映射文件的大小,即该地址在内存映射文件中没有对应的部分。因此,导致了SIGBUS信号的产生。

mmap(2) — Linux manual page 上关于这种情况(即SIGBUS信号产生的原因)是这么描述的:Attempted access to a portion of the buffer that does not correspond to the file (for example, beyond the end of the file, including the case where another process has truncated the file).

2)内存映射文件的大小 = 页大小时

此处,a.txt 文件的大小为 4096 字节。

在 Linux 系统中,我们可以通过$ truncate --size=<file-size-bytes> <file>命令很容易地创建一个大小为file-size-bytes的文件(文件内容默认填充全是二进制零)。比如:命令$ truncate --size=4096 test,用于创建一个名称为test,大小为 4097 字节,内容全是二进制零的文件。

$ ./vm3_main ./a.txt 1 4096
page size:4096 bytes
Bus error (core dumped)

这种情况下,也会产生SIGBUS信号,从而导致进程崩溃。

3)内存映射文件的大小 > 页大小,且是页大小的整数倍时

此处,a.txt 文件的大小为 8192 字节。

$ ./vm3_main ./a.txt 1 4096
page size:4096 bytes

这种情况下,mmap执行成功了,并且没有运行时错误。

4)内存映射文件的大小 > 页大小,但不是页大小的整数倍时

此处,a.txt 文件的大小为 8193 字节。

$ ./vm3_main ./a.txt 1 4096
page size:4096 bytes

这种情况下,mmap也执行成功了,并且没有运行时错误。

step 5: 研究参数offset的取值等于内存映射文件的大小减去 1 时,mmap函数的行为

此处,a.txt 文件的大小为 110 字节。

$ ./vm3_main ./a.txt 1 109
mmap failed, reason:: Invalid argument

从上面结果中可以看出,当参数offset的取值 = 内存映射文件的大小 - 1,且不是页大小的整数倍时,mmap函数也会执行失败,错误信息为Invalid argument(参数无效)。

step 6: 研究参数offset的取值等于内存映射文件的大小时,mmap函数的行为

此处,a.txt 文件的大小为 110 字节。

$ ./vm3_main ./a.txt 1 110
mmap failed, reason:: Invalid argument

从上面结果中可以看出,当参数offset的取值等于内存映射文件的大小时,mmap函数也会执行失败,错误信息为Invalid argument(参数无效)。

step 7: 研究参数offset的取值大于内存映射文件的大小时,mmap函数的行为

此处,a.txt 文件的大小为 110 字节。

$ ./vm3_main ./a.txt 1 111
mmap failed, reason:: Invalid argument

从上面结果中可以看出,当参数offset的取值大于内存映射文件的大小时,mmap函数也会执行失败,错误信息为Invalid argument(参数无效)。

研究结论:

mmap函数中,参数offset的取值必须同时满足以下两种条件:

最后,给出计算传递给mmap函数的实参offset的方法(需要包含头文件#include <unistd.h>):

const auto true_offset = offset & ~(sysconf(_SC_PAGE_SIZE) - 1);

注:


下一篇:计算机系统篇之虚拟内存(8):理解 glibc malloc 的工作原理(上)

上一篇:计算机系统篇之虚拟内存(3):如何使用内存映射文件在进程之间实现数据共享

首页