我们知道,进程是操作系统进行资源分配的最小单位,而内存是进程运行必不可少的资源。现代操作系统为每个进程分配独享的内存空间,这个独享的内存空间只是虚拟内存空间,每次访问内存空间的某个地址 (虚拟地址),都需要把地址翻译成实际物理内存地址。进程要知道哪些虚拟地址上的数据在物理内存上,哪些不在?还有在物理内存上的哪里,需要用页表来记录;正因为每个进程都有一个自己的页表,使得相同的虚拟地址映射到不同的物理内存。每当切换到另一个进程时,就要通过设置 MMU 的某些寄存器来设置这个进程的页表,然后 MMU 就可以把 CPU 发出的虚拟地址转化到物理地址了。
本文将详细介绍现代操作系统为每个进程分配的独享虚拟内存地址空间的详细布局,从一开始的 32位模式到如今占绝大多数的 64位模式。
虚拟内存地址空间布局
32 位模式下它是一个 4GB 的内存地址块
图: 32 位系统下进程地址空间默认布局(左)和进程地址空间经典布局(右)
- 每个进程看到的地址空间都是一样的,比如
.text
都是从 0x80048000 开始,内核地址空间都是 0xC0000000~0xFFFFFFFF,然后用户栈都是向低地址增长,反过来堆都是向高地址扩展。- 0x00000000~0x08048000 (≈128MB)是不能给用户访问的,这里面是一些 C 运行库的内容,访问会报 segement fault 错误。
- 0xC0000000~0xFFFFFFFF 这段是内核的逻辑地址,在用户态时访问会出错,权限不够,如果想访问,需要切换到内核态 ,可以通过系统调用等方式:系统调用代表某个进程运行于内核,此时,相当于该进程可以访问这段内核虚拟地址了 (实际上只能访问该进程的某个 8KB 的内核栈)。
Random stack offset
、Random mmap offset
和Random brk offset
随机值意在防止恶意程序。这是一种安全机制:ASLR (Address Space Layout Randomization),主要防止缓冲区溢出攻击;
Linux 通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算获取访问栈、库函数等地址。- 内核空间下面就是用户栈 Stack 地址段,栈的最大范围,我们可以通过 prlimit 命令看到,默认的情况下是 8MB。
-
1
2
3gary@xxx:~$ prlimit -s
RESOURCE DESCRIPTION SOFT HARD UNITS
STACK max stack size 8388608 unlimited bytes
- 接下来的区域是 Memory Mapping Segment,这块地址是用来分配内存区域的,一般是用来把文件映射进内存用的,但是你也可以在这里申请内存空间来使用。
- mmap() 系统调用把一个文件映射到 Memory Mapping Segment 内存地址空间中;
- 也可以匿名直接申请一段内存空间使用:mmap() 不一定要在 Memory Mapping Segment 进行内存申请,你也可以指定任意的内存地址,当然只要不跟已有的虚拟地址冲突就好;这个地址也一定要是 000 结尾,才使得能页对齐 (1KB)。
start_brk
和brk
这两个值分别标识了堆的起始地址和结束地址,其中brk
又叫 program break。在 linux 中可以通过 brk() 和 sbrk() 这两个函数来改变 program break 的位置。- 当我们在程序中调用 malloc() 的时候,一般就是在内部调用 sbrk() 来调整图中
brk
标识的位置向上移动。 - 当调用 free() 来释放内存空间的时候,传递给 sbrk() 一个负值来使堆的
brk
标识向下移动。当然了,brk() 和 sbrk() 所做的工作远不是简单地移动brk
标识,还要处理将虚拟内存映射到物理内存地址等工作。 - glibc 中当申请的内存空间不大于
MMAP_THRESHOLD
的时候,malloc() 使用 brk()/sbrk() 来调整的brk
标识的位置,这个时候所申请到的空间确实位于图中的start_brk
和brk
之间;当所申请的空间大于这个阈值的时候,malloc()改用 mmap() 来分配空间,这个时候所申请到的空间就位于图中的 Memory Mapping Segment 这一段内。 - 习惯上还是将整个 Heap 段和 Memory Mapping Segment 段称为“堆”。
- 当我们在程序中调用 malloc() 的时候,一般就是在内部调用 sbrk() 来调整图中
- 接下来分别是 BSS Segment、Data Segment和 Text Segment
- BSS Segment存放未初始化的静态变量,所以也就是可以随意读写;
- Data Segment 存放的是静态常量,所以该地址段权限是只读;
- Text Segment 其实就是存放二进制可执行代码的位置,所以它的权限是读与可执行。
- 新的进程内存布局(默认进程内存布局)导致了栈空间的固定,而堆区域和 MMAP 区域共用一个空间,这在很大程度上增长了堆区域的大小。
64 位模式下虚拟地址空间布局
图: 64 位系统下进程地址空间默认布局(左)和进程地址空间经典布局(右)
- 对于x86_64 Linux,有效的地址区间是从 0x00000000 00000000$\sim$0x00007FFF FFFFFFFF 还有 0xFFFF8000 00000000$\sim$0xFFFFFFFF FFFFFFFF 两个地址区间。而每个地址区间都有 128TB 的地址空间可以使用,所以总共是 256TB 的可用空间。
- 对于64位 (x86_64 和 amd64) 架构来说,用户空间 text 段的起始地址为 0x0000000000400000,和 32位 (x86) 下的一样后面跟着的是 data 段和 bss 段。 heap 段和 bss 段之间、stack 段和 0x00007FFFFFFFF000 之间也可以设置一个由 ASLR 导致的 random brk offset,heap 段向上增长,stack 段向下增长。
- 经典布局下,Memory Mapping Segment 的起始地址通过页对齐之后从某一地址开始。由于 amd64 架构下页大小可以为 4K、2M 或者1G,不像 x86 (_64) 下页大小统一为 4K,所以 mmap 的起始范围根据系统的页大小也有不同
- x86_64 下,mmap 段的起始地址固定为 0x00002AAAAAAAB000(也可以设置 random mmap offset),向上增长
Linux 下控制虚拟地址空间布局
- Linux 下可以通过
vm.legacy_va_layout
、kernel.randomize_va_space
配置上面提到的虚拟地址空间布局。 -
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15# 是否使用经典的进程内存布局
gary@...~$ cat /proc/sys/vm/legacy_va_layout
# 0: 使用新的进程内存布局
0
gary@...~$ sysctl -w vm.legacy_va_layout=1
# 是否开启 ASLR 地址空间布局随机化
# When setting the value to 1, address space is randomized.
# the positions of the stack itself
# virtual dynamic shared object (VDSO) page
# shared memory regions.
# Setting the option to value 2 will be similar to 1, and add data segments as well.
gary@...~$ cat /proc/sys/kernel/randomize_va_space
2
gary@...~$ sysctl -w kernel.randomize_va_space=0- VDSO 是什么呢?
- 不修改配置直接查看 Linux 系统虚拟内存地址空间布局,确实采用的是上述默认地址空间布局
vm.legacy_va_layout
=0 &&kernel.randomize_va_space
=2- 前三行分别是 Text Segment (起始地址为 0x0000000000400000)、Data Segment 和 BSS Segment;
- 接下来是 Heap 地址段、Memory Mapping Segment (Stack 地址段以下,0x00007FFFFFFFF000 附近) 和 Stack 地址段 (和 0x00007FFFFFFFF000 间隔 random stack offset);
- 再下面就是 vvar,vdso 和 vsyscall 了:这三个东西都为了加速访问内核数据,比如读取时间 gettimeofday(),肯定不能频繁地进行系统调用陷入内核,所以就映射到用户空间了;所有程序都有这 3 个映射地址段。
关于 vvar,vdso 和 vsyscall
+ 先说 vsyscall,这东西出现最早,比如读取时间 gettimeofday(),内核会把时间数据和 gettimeofday() 的实现映射到这块区域,用户空间可以直接调用 (内核将一些本身应该是系统调用的直接映射到用户空间,这样对于一些使用比较频繁的系统调用,可以直接在用户空间调用以节省开销)。但是 vsyscall 区域太小了,而且映射区域固定,有安全问题;+ 后来又造出了 vdso (之所以保留 vsyscall 是为了兼容用户空间程序):vdso 相当于加载一个 linux-vd.so 库文件一样 (名字也由此而来),也就是把一些函数实现映射到这个区域;vvar 也就是存放数据的地方了,那么用户可以通过调用 vdso 里的函数,使用 vvar 里的数据,来获得自己想要的信息,而且地址是随机的,更安全。
- 通过 sysctl 设置成经典布局
vm.legacy_va_layout
=1 &&kernel.randomize_va_space
=0- vdso,vvar 随 Memory Mapping Segment 发生变动;
- Memory Mapping Segment 起始地址固定为 0x00002AAAAAAAB000,向上增长。
不同的 CPU 体系架构下的虚拟内存地址空间
这是一个值得讨论的话题…
- amd64 架构下的进程地址空间布局总体上来说和 x86(_64) 除了 32 位/64 位模式外,几乎是相同的;只不过 amd64 的页大小可以为4K、2M或者1G,不像 x86 下页大小统一为4K,造成经典布局下 mmap 段的固定起始地址存在偏差。
- ARM(64) 近似。
采用虚拟内存地址空间的好处
- 既然每个进程的内存空间都是一致而且固定的,所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际的内存地址,这是有独立内存空间的好处;
- 当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码 (共享),不同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存;
- 在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用碎片;
- 内存的延迟分配:只有在真正访问一个地址的时候才建立这个地址的物理映射,这是 Linux 内存管理的基本思想。
- Linux内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。
- 内核释放物理页面是通过释放线性区,找到其对应的物理页面,将其全部释放的过程。
本文我们详细介绍了 32 位/64 位模式下系统虚拟内存地址空间的布局情况,包括经典布局以及改进了经典布局,现代操作系统默认的新布局。后面,我们将对虚拟内存地址空间中的内核空间、用户空间中的各个地址段进行详细描述,以求更深入的了解。