OS-抽象-地址空间

为什么我们需要虚拟化内存?

换句话说,虚拟化内存有什么好处?

一个很自然的想法是操作系统本身占用一部分内存,用户的程序(进程)直接操作内存占用剩余的内存空间。但这样会带来以下问题:

  1. 程序需要自己管理在物理内存上的地址。
  2. 自己的内存可能会被其他程序修改,操作系统应该避免这种情况。

为此,操作系统自然的被要求提供两种功能:

  1. 地址空间:为程序提供统一可控的内存地址,程序不用关心实际物理地址的问题。
  2. 保护:其他进程不应该能够修改自己的内存。

地址空间

为了提供一种易用的内存抽象,操作系统提供了地址空间的概念,这也就是内存的虚拟化。

同时,内存虚拟化有三个关键目标:

  • 透明性:进程无需感知物理内存分配细节,拥有连续私有地址空间假象。
  • 效率性:通过硬件加速地址转换,减少运行时开销。
  • 保护性:隔离进程地址空间,防止非法内存访问。

下面,通过一个简单的 C 程序结合 gdb 探究实际的地址空间到底是如何构成的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>
#include <stdlib.h>

int main() {
  volatile char *heap = (char *)malloc(sizeof(char) * 100);
  (void)heap;
  free((void *)heap);
  heap = NULL;
  return 0;
}

通过 mmap-md.py 扩展 gdb 后得到以下内容:

  • Process ID: 8631
  • Report Time: 2025-03-26 20:03:44
Start Address End Address Size Offset Permissions Mapped File
0x555555554000 0x555555555000 0x1000 0x0 r--p /os2025/temp
0x555555555000 0x555555556000 0x1000 0x1000 r-xp /os2025/temp
0x555555556000 0x555555557000 0x1000 0x2000 r--p /os2025/temp
0x555555557000 0x555555558000 0x1000 0x2000 r--p /os2025/temp
0x555555558000 0x555555559000 0x1000 0x3000 rw-p /os2025/temp
0x7ffff7da0000 0x7ffff7da3000 0x3000 0x0 rw-p [anonymous]
0x7ffff7da3000 0x7ffff7dcb000 0x28000 0x0 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7dcb000 0x7ffff7f53000 0x188000 0x28000 r-xp /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f53000 0x7ffff7fa2000 0x4f000 0x1b0000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa2000 0x7ffff7fa6000 0x4000 0x1fe000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa6000 0x7ffff7fa8000 0x2000 0x202000 rw-p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa8000 0x7ffff7fb5000 0xd000 0x0 rw-p [anonymous]
0x7ffff7fbd000 0x7ffff7fbf000 0x2000 0x0 rw-p [anonymous]
0x7ffff7fbf000 0x7ffff7fc3000 0x4000 0x0 r--p [vvar]
0x7ffff7fc3000 0x7ffff7fc5000 0x2000 0x0 r-xp [vdso]
0x7ffff7fc5000 0x7ffff7fc6000 0x1000 0x0 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fc6000 0x7ffff7ff1000 0x2b000 0x1000 r-xp /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ff1000 0x7ffff7ffb000 0xa000 0x2c000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffb000 0x7ffff7ffd000 0x2000 0x36000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x38000 rw-p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffffffdd000 0x7ffffffff000 0x22000 0x0 rw-p [stack]

程序的地址空间可以被分为以下部分;

程序加载区

也就是代码段和数据段:

  • 权限为r-xp(可读可执行)自然是代码段。
  • 权限为r--p(可读)只读数据段。
  • 权限为rw-p(可读可写)数据段。

匿名内存区(anonymous)

通常对应堆或其他动态分配内存。

映射区

用来链接其他动态库。

vvar和vdso

  • vvar:内核变量区,包含内核和用户空间共享的变量,例如:时间、CPU数。
  • vdso:动态共享对象,提供了一些系统调用的用户态实现。

以上区域表明,不是所有系统调用都会引起从用户态向内核态的切换。现实中,将一些不影响安全性的系统调用直接在用户态实现了。

存储局部变量和函数调用,位于高地址部分并向上生长。


mmap

mmap 用于在地址空间中创建一个内存映射。

1
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr:指定映射地址,如果为NULL,内核会自动选择一个地址。
  • length:映射区域的长度。当然,不是立即分配实际内存,而是通过缺页异常进行分配。
  • prot:内存权限,多个权限之间使用|连接。
  • flags:控制映射行为:
    • MAP_SHARED:共享映射,对文件的修改会同步到文件,同时其他进程可见。
    • MAP_PRIVATE:私有映射(写实复制),修改不会同步到文件,其他进程不可见。
    • MAP_ANONYMOUS:匿名映射,不关联文件,内容被初始化为 0 。
    • 还有一系列附加选项:MAP_FIXED, MAP_LOCKED, MAP_POPULATE 可通过 | 连接。
  • fd:文件描述符。匿名映射时设置为 -1。
  • offset:文件偏移量。匿名映射时设置为 0。

mmap 的功能

共享内存:通过设置为MAP_SHARED实现进程间通信。

对于文件映射,一个自然的问题:mmap 和 read 的区别是什么?

mmap 和 read 的区别是什么?

特性 mmap read
性能 高效,适合大文件和随机访问 适合小文件或连续读取
内存开销 按需加载,但占用虚拟地址空间 需要显式内存管理
共享内存 支持多个进程共享 不支持
适用场景 大文件、随机访问、共享内存 小文件、流式处理、顺序读取

内存开销:mmap 按需加载,并且直接将文件映射至地址空间。read 完全加载文件,同时需要将数据从内核态复制到用户空间缓冲区。但另一方面,mmap 的内存需要手动释放,而 read 可以精确控制载入字节流并且处理完成后可以直接释放内存。

高效随机访问:mmap 可以通过指针快速在文件中游动,read 需要 lseek 这个系统调用实现,效率较低。

一切皆文件:内存是字节序列,文件是字节序列,甚至存储设备也是字节序列。可以将一整块超大的硬盘用 mmap 映射到地址空间,同时得益于按需加载,即便硬盘比实际物理内存大很多也能实现。

mmap 和 malloc 的区别是什么?

同样,mmap可以使用匿名文件映射来达成分配内存的目的,那么 mmap 和 malloc 的区别是什么?

mmap

  • 系统调用:使用时发生用户态和内核态的切换,这一特点使其不适用于小内存的频繁分配。
  • 内存共享:mmap 能够设置为共享内存,以便进行进程间通信。
  • 保护和控制:可以为内存设置权限。

malloc

  • 小块内存的分配:malloc 通常对小块内存的分配进行优化,对于大量小内存的分配效率较高。

测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>

int main() {
  volatile char *heap = (char *)malloc(sizeof(char) * 100);
  (void)heap;
  free((void *)heap);
  heap = NULL;
  volatile char *big =
      (char *)mmap(NULL, sizeof(char) * 1024 * 1024 * 1024,
                   PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
  if (big == MAP_FAILED) {
    perror("mmap failed");
    return 1;
  }
  volatile char *pr = big + 1024;
  *pr = 1;
  return 0;
}
Start Address End Address Size Offset Permissions Mapped File
0x555555559000 0x55555557a000 0x21000 0x0 rw-p [heap]
0x7fffb7da0000 0x7ffff7da3000 0x40003000 0x0 rw-p [anonymous]

可以明显看见分配了一块大小为 0x40003000 的匿名内存映射。


madvise

为了控制 mmap 按需加载的内存,有时需要告知操作系统可以释放一段内存。

如果使用 munmap 将直接取消映射,一方面要求提供一个页对齐的地址和长度,另一方面如果用来释放中间的地址会导致出现“空洞”带来内存管理的复杂性。

madvise() 是一个系统调用,允许应用程序向内核提供关于指定内存区域使用模式的“建议”,以便内核能更好地优化内存管理。

  • 内存区域要求
    madvise() 操作的内存区域由参数 addr(起始地址)和 length(长度)指定。

    • 页对齐:addr 必须是页对齐的,length 会被向上舍入到页大小的整数倍。
    • 部分映射:如果指定的区域中有部分地址未映射,Linux 内核会忽略那些未映射的部分,但如果整段区域都未映射,则可能返回 ENOMEM。
  • 调用目的
    madvise() 不会改变应用程序对内存的访问语义,而只是向内核传递“使用建议”。这些建议主要用于:

    • 优化内核的页面预取或回收策略
    • 控制页缓存的行为
    • 在某些情况下,改变内存区域后续访问的效果(例如 MADV_DONTNEED 会使页面失效,下次访问时重新加载或零填充)

建议值

  • MADV_NORMAL

    • 表示“无特殊要求”,这是默认行为。内核按正常策略管理该内存区域。
  • MADV_RANDOM

    • 告诉内核:未来对该区域的访问是随机的。
    • 内核可能减少读前瞻(read-ahead)策略,因为顺序预读效果有限。
  • MADV_SEQUENTIAL

    • 告诉内核:将按顺序访问该区域。
    • 内核可以积极进行预读,并在访问完成后尽快回收页面,适用于顺序读文件等场景。
  • MADV_WILLNEED

    • 表示预计不久的将来会访问该区域。
    • 内核可能提前将页面加载到内存中,从而减少后续访问时的缺页延迟。
  • MADV_DONTNEED

    • 表示当前不需要该区域内的数据。
    • 内核可以立即或在适当时机释放该区域占用的物理内存。
    • 注意:对于文件映射或共享映射,之后的访问会重新从文件加载数据或返回零填充(对于匿名映射)。
updatedupdated2025-03-312025-03-31