为什么我们需要虚拟化内存?
换句话说,虚拟化内存有什么好处?
一个很自然的想法是操作系统本身占用一部分内存,用户的程序(进程)直接操作内存占用剩余的内存空间。但这样会带来以下问题:
- 程序需要自己管理在物理内存上的地址。
- 自己的内存可能会被其他程序修改,操作系统应该避免这种情况。
为此,操作系统自然的被要求提供两种功能:
- 地址空间:为程序提供统一可控的内存地址,程序不用关心实际物理地址的问题。
- 保护:其他进程不应该能够修改自己的内存。
地址空间
为了提供一种易用的内存抽象,操作系统提供了地址空间的概念,这也就是内存的虚拟化。
同时,内存虚拟化有三个关键目标:
- 透明性:进程无需感知物理内存分配细节,拥有连续私有地址空间假象。
- 效率性:通过硬件加速地址转换,减少运行时开销。
- 保护性:隔离进程地址空间,防止非法内存访问。
下面,通过一个简单的 C 程序结合 gdb 探究实际的地址空间到底是如何构成的:
|
|
通过 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 用于在地址空间中创建一个内存映射。
|
|
- 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 通常对小块内存的分配进行优化,对于大量小内存的分配效率较高。
测试代码:
|
|
| 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
- 表示当前不需要该区域内的数据。
- 内核可以立即或在适当时机释放该区域占用的物理内存。
- 注意:对于文件映射或共享映射,之后的访问会重新从文件加载数据或返回零填充(对于匿名映射)。