抽象内存布局
CPU 运行一个程序,实质就是在顺序执行该程序的机器码,一个程序的机器码会被组织到代码段。
另外,程序在运行过程中必然要操作数据,对于有初始值且不为的全局变量和静态变量,它的初始值会存放在可执行文件的数据段中,并且数据段的数据也会被装载到内存中;对于未初始化的全局变量和静态变量,因为编译器知道它们的初始值都是 0,因此便不需要再在程序的二进制映像中存放这么多 0 了,只需要记录他们的大小即可,这便是 BSS 段(Block Started by Symbol 或 Better Save Space 的缩写)。
数据段和 BSS 段里存放的数据也只能是部分数据,主要是全局变量和静态变量,但程序在运行过程中,仍然需要额外的栈空间和堆空间来记录大量的临时变量和运行时生成的变量。与代码段以及数据段不同的是,堆区和栈区并不是从磁盘中加载,它们都是由程序在运行的过程中申请,在程序运行结束后释放。
总的来说,一个程序想要运行起来所需要的几块基本内存区域:代码段、数据段、BSS 段、堆空间和栈空间。下面就是内存布局的示意图:
除了上面所讲的基本内存区域外,现代应用程序中还会包含其他的一些内存区域,主要有以下几类:
- 存放加载的共享库的内存空间:如果一个进程依赖共享库,那对应的,该共享库的代码段、数据段、BSS 段也需要被加载到这个进程的地址空间中;
- 共享内存段:通过系统调用映射一块匿名区域作为共享内存,用来进行进程间通信;
- 内存映射文件:将磁盘的文件映射到内存中,用来进行文件编辑或者是使用类似共享内存的方式进行进程通信。

左边是程序在磁盘中的文件布局结构,右边是程序加载到内存中的内存布局结构。
对于磁盘中的布局结构,每一个单元结构称为 Section,可以通过 readelf -S 的选项,来查看二进制文件中所有的 Section 信息;对于内存中的布局结构,每一个单元结构称为 Segment,可以通过 readelf -l 的选项,来查看二进制文件加载到内存之后的 Segment 布局信息。
往往多个 Section 会对应一个 Segment,Segment 是将具有相同权限属性的 Section 集合在一起,并在内存中占据一块空间,例如.text、.rodata 等一些只读的 Section,会被映射到内存的一个只读 / 执行的 Segment 里;而.data、.bss 等一些可读写的 Section,则会被映射到内存的一个具有读写权限的 Segment 里,而对于磁盘二进制文件中一些辅助信息的 Section,例如.symtab、.strtab 等,不需要在内存中进行映射。
IA-32 机器上的 Linux 进程内存布局
在 32 位机器上,每个进程都具有 4GB 的寻址能力。Linux 系统会默认将高地址的 1GB 空间分配给内核,剩余的低 3GB 是用户可以使用的用户空间。下图是 32 位机器上 Linux 进程的一个典型的内存布局,可以通过cat /proc/pid/maps来查看某个进程的实际虚拟内存布局。
上述的布局分析都是基于 Linux 系统下关闭了进程地址随机化选项的结果(通过sudo sysctl -w kernel.randomize_va_space=val的命令来设置。其中,val=0 表示关闭内存地址随机化;val=1 表示使得 mmap 的基地址、栈地址和 VDSO 的地址随机化;val=2 则是在 1 的基础上增加堆地址的随机化)。如果打开进程地址随机化的模式,其中的堆空间、栈空间和共享库映射的地址,在每次程序运行下都会不一样。这是因为内核在加载的过程中,会对这些区域的起始地址增加一些随机的偏移值,这能增加缓冲区溢出的难度。
在 32 位 Linux 系统下,从 0 地址开始的内存区域并不是直接就是代码段区域,而是一段不可访问的保留区。这是因为大多数的系统都认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
代码段从 0x08048000 的位置开始排布(需要注意的是,以上地址需要 gcc 编译的时候不开启 pie 的选项)。代码段、数据段都是从可执行文件映像中装载到内存中;BSS 段则是根据 BSS 段所需的大小,在加载时生成一段由 0 填充的内存空间。
堆和栈分别由两个指针控制,堆指针指明了当前堆空间的边界,栈指针指明了当前栈空间的边界。当堆申请新的内存空间时,只需要将堆指针增加对应的大小,回收地址时减少对应的大小即可。而栈的申请刚好相反。这其实就是内核对堆跟栈使用的最根本的方式,其中,堆的指针叫做“Program break”,栈的指针叫做“Stack pointer”,也就是 x86 架构下的 sp 寄存器。
Intel 64 机器上的 Linux 进程内存布局
目前的 64 系统下的寻址空间是 2^48,即 256TB。而且根据 canonical address (在 64 位的模式下,如果地址位 63 到地址的最高有效位之间的位被设置为全 1 或全零,那么该地址被认为是 canonical form)的划分,地址空间天然地被分割成两个区间,分别是 0x0 - 0x00007fffffffffff 和 0xffff800000000000 - 0xffffffffffffffff。这样就直接将低 128T 的空间划分为用户空间,高 128T 划分为内核空间,而中间的一块巨大的非 canonical 内存空洞是不可访问的并且是由 CPU 来保证的。下面这张图展示了 Intel 64 机器上的 Linux 进程内存布局:
对于 64 位的程序,代码段跟数据段的中间还有一段不可以读写的保护段,它的作用也是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行
申请堆空间
不管是 32 位系统还是 64 位系统,内核都会维护一个变量 brk,指向堆的顶部,所以,brk 的位置实际上就决定了堆的大小。Linux 系统为我们提供了两个重要的系统调用来修改堆的大小,分别是 sbrk 和 mmap。
sbrk
#include <unistd.h>void* sbrk(intptr_t incr);
sbrk 通过给内核的 brk 变量增加 incr,来改变堆的大小,incr 可以为负数。当 incr 为正数时,堆增大,当 incr 为负数时,堆减小。如果 sbrk 函数执行成功,那返回值就是 brk 的旧值;如果失败,就会返回 -1,同时会把 errno 设置为 ENOMEM。
mmap
#include <unistd.h>#include <sys/mman.h>void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);// addr 代表该区域的起始地址;length 代表该区域长度;prot 描述了这块新的内存区域的访问权限;flags 描述了该区域的类型;fd 代表文件描述符;offset 代表文件内的偏移值
mmap 的功能非常强大,根据参数的不同,它可以用于创建共享内存,也可以创建文件映射区域用于提升 IO 效率,还可以用来申请堆内存

