抽象内存布局
CPU 运行程序,实质就是在顺序执行该程序的机器码。一个程序的机器码会被组织到同一个地方,这个地方就是代码段。
程序在运行过程中必然要操作数据。对于有初值的变量,它的初始值会存放在程序的二进制文件中,这些数据部分也会被装载到内存中,即程序的数据段。数据段存放的是程序中已经初始化且不为 0 的全局变量和静态变量。
对于未初始化的全局变量和静态变量,因为编译器知道它们的初始值都是 0,因此便不需要再在程序的二进制映像中存放这么多 0 了,只需要记录他们的大小即可,这便是 BSS 段。
数据段和 BSS 段里存放的数据只是部分数据,主要是全局变量和静态变量,但程序运行过程中仍需要记录大量的临时变量,以及运行时生成的变量,这里就需要程序的堆空间跟栈空间。与代码段以及数据段不同的是,堆和栈并不是从磁盘中加载,而是由程序在运行的过程中申请,在程序运行结束后释放。
总的来说,一个程序想要运行起来所需要的几块基本内存区域:代码段、数据段、BSS 段、堆空间和栈空间。
除了上面所讲的基本内存区域外,现代应用程序中还会包含其他的一些内存区域,主要有以下几类:
- 存放加载的共享库的内存空间:如果一个进程依赖共享库,那对应的,该共享库的代码段、数据段、BSS 段也需要被加载到这个进程的地址空间中。
- 共享内存段:可以通过系统调用映射一块匿名区域作为共享内存,用来进行进程间通信。
- 内存映射文件:也可以将磁盘的文件映射到内存中,用来进行文件编辑或者是类似共享内存的方式进行进程通信
在上面的讨论中,我们并没有区分磁盘的程序段 (Section),以及内存程序段 (Segment) 的概念,这两个词在国内往往都被翻译成“段”,导致大多数同学会混淆它们。这里我来给你做一个区分。
左边是程序在磁盘中的文件布局结构,右边是程序加载到内存中的内存布局结构。
对于磁盘的程序,每一个单元结构称为 Section。对于内存镜像,每一个单元结构称为 Segment。可以看到,往往多个 Section 会对应一个 Segment,例如.text、.rodata 等一些只读的 Section,会被映射到内存的一个只读 / 执行的 Segment 里; 而.data、.bss 等一些可读写的 Section,则会被映射到内存的一个具有读写权限的 Segment 里。而一些辅助信息的 Section,例如.symtab、.strtab 等,不需要在内存中进行映射。
总的来说,Section 主要是指在磁盘中的程序段,而 Segment 则用来指代内存中的程序段,Segment 是将具有相同权限属性的 Section 集合在一起,系统为它们分配的一 块内存空间。
接下来我们具体看下linux系统下内存布局是怎样的
IA-32 机器上的 Linux 进程内存布局
在 32 位机器上,每个进程都具有 4GB 的寻址能力。Linux 系统会默认将高地址的 1GB 空间分配给内核,剩余的低 3GB 是用户可以使用的用户空间。下图是 32 位机器上 Linux 进程的一个典型的内存布局。在实践中,我们可以通过cat /proc/pid/maps来查看某个进程的实际虚拟内存布局。
从低地址到高地址依次来解释图中的布局情况。
首先,我们发现在 32 位 Linux 系统下,从 0 地址开始的内存区域并不是直接就是代码段区域,而是一段不可访问的保留区。这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
接下来,代码段从 0x08048000 的位置开始排布(以上地址需要 gcc 编译的时候不开启 pie 的选项)。代码段、数据段都是从可执行文件映像中装载到内存中;BSS 段则是根据所需大小,在加载时生成一段 0 填充的内存空间。
堆的空间里有一个向上的箭头,标明了堆地址空间的增长方向,即每次在进程向内核申请新的堆地址时,其地址值增大。与之对应的是栈空间,有一个向下的箭头,说明栈增长的方向是向低地址方向增长。
我们可以想象堆和栈分别由两个指针控制,指明了当前堆/栈空间的边界。当堆申请新的内存空间时,只需要将堆指针增加对应的大小,回收地址时减少对应的大小即可。而栈的申请刚好相反。这就是内核对堆跟栈使用的最根本的方式,堆指针叫做“Program break”,栈指针叫做 “Stack pointer”,也就是 x86 架构下的 sp 寄存器。
继续往下就到了内存映射区域,最常见的就是程序所依赖的共享库,例如 libc.so。共享库的代码段、数据段、BSS 段都会被装载到这里。
我们上述的布局分析都是基于 Linux 系统下关闭了进程地址随机化的选项。如果打开进程地址随机化的模式,其中的堆空间、栈空间和共享库映射的地址,在每次程序运行下都会不一样。这是因为内核在加载的过程中,会对这些区域的起始地址增加一些随机的偏移值,这能增加缓冲区溢出的难度。
sudo sysctl -w kernel.randomize_va_space=val val=0 表示关闭内存地址随机化;val=1 表示使得 mmap 的基地址、栈地址和 VDSO 的地址随机化;val=2 则 是在 1 的基础上增加堆地址的随机化。
到这里,我们对 32 位机器下 Linux 进程的内存布局有了一个清晰的认知。而 64 位系统的基本框架与 32 位架构是一致的,但在一些细节上,还是有所不同。
Intel 64 机器上的 Linux 进程内存布局
64 位系统理论的寻址范围是 2^64,也就是 16EB。但从目前来看,我系统和 用用不到这么庞大的地址空间。因此目前的 Intel 64 架构里定义了 canonical address 的概念,即在 64 位的模式下,如果地址位 63 到地址的最高有效位被设置为全 1 或全零,那么该地址被认为是 canonical form。目前,Intel 64 处理器往往支持 48 位的虚拟地址,这意味着 canonical address 必须将第 63 位到第 48 位设置 为零或一(这取决于第 47 位是零还是一)。
所以目前的 64 系统下的寻址空间是 2^48,即 256TB。而且根据 canonical address 的划分,地址空间天然地被分割成两个区间,分别是 0x0 - 0x00007fffffffffff 和 0xffff800000000000 - 0xffffffffffffffff。这样就直接将低 128T 的空间划分为用户空间,高 128T 划分为内核空间。下面这张图展示了 Intel 64 机器上的 Linux 进程内存布局:
在用户空间和内核空间之间有一个巨大的内存空洞。这块空间之所以用更深颜色来区分,是因为这块空间的不可访问是由 CPU 来保证的(这里的地址都不满足 Intel 64 的 Canonical form)。
代码段跟数据段的中间还有一段不可以读写的保护段,它的作用也是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
申请堆空间
不管是 32 位还是 64 位系统,内核都会维护一个变量 brk,指向堆的顶部, brk 的位置实际上就决定了堆的大小。Linux 提供两个系统调用修改堆的大小,分别是 sbrk 和 mmap。
sbrk
#include<unistd.h>
void* sbrk(intptr_t incr);
sbrk 通过给内核的 brk 变量增加 incr,来改变堆的大小。当 incr 为正数时,堆增大,当 incr 为负数时,堆减小。函数执行成功,那返回值就是 brk 的旧值;失败返回 -1,同时会把 errno 设置为 ENOMEM。
在实际应用中,我们很少直接使用 sbrk 来申请堆内存,而是使用 malloc 函数进行堆内存的分配,然后用 free 进行内存释放。malloc 和 free 函数不是系统调用,而是 C 语言的运行时库。C 语言的运行时库多是以动态链接库的方式实现的。
malloc 向程序提供分配一小块内存的功能,当运行时库的内存分配完后,它会使用 sbrk 方法向os再申请一块大的内存。可以将运行时库类比为零售商,它从os那里批发一块比较大的内存,然后再通过零售的方式一点点地提供给程序员使用。
mmap
#include<unistd.h>
#include<sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
- start 代表该区域的起始地址;
- length 代表该区域长度;
- prot 描述了这块新的内存区域的访问权限;
- flags 描述了该区域的类型;
- fd 代表文件描述符;
- offset 代表文件内的偏移值。
mmap 的功能非常强大,根据参数的不同,它可以用于创建共享内存,也可以创建文件映射区域用于提升 IO 效率,还可以用来申请堆内存。决定它的功能的,主要是 prot, flags 和 fd 这三个参数,我们分别来看看。
prot 的值可以是以下四个常量的组合:
- PROT_EXEC,表示这块内存区域有可执行权限,意味着这部分内存可以看成是代码段,它里面存储的往往是 CPU 可以执行的机器码
- PROT_READ,表示这块内存区域可读。
- PROT_WRITE,表示这块内存区域可写。
- PROT_NONE,表示这块内存区域的页面不能被访问。
flags 的值可取的常量比较多,只列举最重要的四种可取值常量:
- MAP_SHARED:创建一个共享映射的区域,多个进程可以通过共享映射的方式,来共享同一个文件。
- MAP_PRIVATE:创建一个私有的映射区域,多个进程可以使用私有映射的方式,来映射同一个文件。但是,当一个进程对文件进行修改时,os会为它创建一个独立的副本,这样它对文件的修改,其他进程就看不到了
- MAP_ANONYMOUS:创建一个匿名映射,也就是没有关联文件。使用这个选项 时,fd 参数必须为空。
- MAP_FIXED:一般来说,addr 参数只是建议os尽量以 addr 为起始地址进行内存映射,但如果os判断 addr 作为起始地址不能满足长度或者权限要求时, 就会另外再找其他适合的区域进行映射。如果 flags 的值取是 MAP_FIXED 的话,就不再把 addr 看成是建议了,而是将其视为强制要求。如果不能成功映射,返回空指针。
当参数 fd 不为 0 时,mmap 映射的内存区域将会和文件关联,如 果 fd 为 0,就没有对应的相关文件,此时就是匿名映射,flags 的取值必须为 MAP_ANONYMOUS。
mmap的应用场景
根据映射的类型,mmap 有四种最常用的组合:
最常见的用途:
- 私有匿名映射在堆上分配内存
- 私有文件映射加载动态链接库
- 共享映射创建共享内存,进程间通信
小结
一个进程的内存可以分为内核区域和用户区域。程序员最关心的是用户空间,用户空间大致可以分为栈、堆、bss 段、数据段和代码段:
- 代码段保存的是程序的机器指令,这一段区域的内存往往是可读可执行,但不可写;
- 数据段保存的是程序的静态变量和全局变量;
- bss 段用于无初值的变量区域;
- 堆是程序员可以自由申请的空间,当我们在写程序时要保存数据,优先会选择堆;
- 栈是函数执行时的活跃记录
这 5 个内存区域通常是由高地址向低地址顺序排列的。但这并不是绝对的