抽象内存布局
对于未初始化的全局变量和静态变量,因为编译器知道它们的初始值都是 0,因此便不需要再在程序的二进制映像中存放这么多 0 了,只需要记录他们的大小即可,这便是 BSS 段。BSS 段这个缩写名字是 Block Started by Symbol,但很多人可能更喜欢把它记作 Better Save Space 的缩写。
数据段和 BSS 段里存放的数据也只能是部分数据,主要是全局变量和静态变量,但程序在运行过程中,仍然需要记录大量的临时变量,以及运行时生成的变量,这里就需要新的内存区域了,即程序的堆空间跟栈空间。与代码段以及数据段不同的是,堆和栈并不是从磁盘中加载,它们都是由程序在运行的过程中申请,在程序运行结束后释放。

现代应用程序中还会包含其他的一些内存区域
- 存放加载的共享库的内存空间:如果一个进程依赖共享库,那对应的,该共享库的代码段、数据段、BSS 段也需要被加载到这个进程的地址空间中。
- 共享内存段:我们可以通过系统调用映射一块匿名区域作为共享内存,用来进行进程间通信。
- 内存映射文件:我们也可以将磁盘的文件映射到内存中,用来进行文件编辑或者是类似共享内存的方式进行进程通信。
磁盘的程序段 (Section) 与 内存程序段 (Segment)

对于磁盘的程序,每一个单元结构称为 Section。我们可以通过 readelf -S 的选项,来查看二进制文件中所有的 Section 信息。对于右边的内存镜像,每一个单元结构称为 Segment。我们可以通过 readelf -l 的选项,来查看二进制文件加载到内存之后的 Segment 布局信息。
- 往往多个 Section 会对应一个 Segment
- 对于磁盘二进制中一些辅助信息的 Section,例如.symtab、.strtab 等,不需要在内存中进行映射
Section 主要是指在磁盘中的程序段,而 Segment 则用来指代内存中的程序段,Segment 是将具有相同权限属性的 Section 集合在一起,系统为它们分配的一块内存空间。
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 的基础上增加堆地址的随机化。
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 进程内存布局:

对于 64 位的程序,你在查看 /proc/pid/maps 的过程中,会发现代码段跟数据段的中间还有一段不可以读写的保护段,它的作用也是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
申请堆空间
不管是 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。
- 在实际应用中,我们很少直接使用 sbrk 来申请堆内存,而是使用 C 语言提供的 malloc 函数进行堆内存的分配,然后用 free 进行内存释放。
- malloc 和 free 函数不是系统调用,而是 C 语言的运行时库。Linux 上的主流运行时库是 glibc,其他影响力比较大的运行时库还有 musl 等。C 语言的运行时库多是以动态链接库的方式实现的
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 代表文件内的偏移值。
prot 的值可以是以下四个常量的组合:
- PROT_EXEC,表示这块内存区域有可执行权限,意味着这部分内存可以看成是代码段,它里面存储的往往是 CPU 可以执行的机器码。
- PROT_READ,表示这块内存区域可读。
- PROT_WRITE,表示这块内存区域可写。
- PROT_NONE,表示这块内存区域的页面不能被访问。
flags
- MAP_SHARED:创建一个共享映射的区域,多个进程可以通过共享映射的方式,来共享同一个文件。这样一来,一个进程对该文件的修改,其他进程也可以观察到,这就实现了数据的通讯。
- MAP_PRIVATE:创建一个私有映射区域,多个进程可以使用私有映射的方式,来映射同一个文件。但是,当一个进程对文件进行修改时,操作系统就会为它创建一个独立的副本,这样它对文件的修改,其他进程就看不到了,从而达到映射区域私有的目的。
- MAP_ANONYMOUS:创建一个匿名映射,也就是没有关联文件。使用这个选项时,fd 参数必须为空。
- MAP_FIXED:一般来说,addr 参数只是建议操作系统尽量以 addr 为起始地址进行内存映射,但如果操作系统判断 addr 作为起始地址不能满足长度或者权限要求时,就会另外再找其他适合的区域进行映射。如果 flags 的值取是 MAP_FIXED 的话,就不再把 addr 看成是建议了,而是将其视为强制要求。如果不能成功映射,就会返回空指针。
fd
- 当参数 fd 不为 0 时,mmap 映射的内存区域将会和文件关联 (文件映射)
- 如果 fd 为 0,就没有对应的相关文件,此时就是匿名映射,flags 的取值必须为 MAP_ANONYMOUS。
mmap 有四种最常用的组合

共享匿名映射
#include <sys/mman.h>#include <stdlib.h>#include <stdio.h>#include <unistd.h>int main() {pid_t pid;char* shm = (char*)mmap(0, 4096, PROT_READ | PROT_WRITE,MAP_SHARED | MAP_ANONYMOUS, -1, 0);if (!(pid = fork())){sleep(1);printf("child got a message: %s\n", shm);sprintf(shm, "%s", "hello, father.");exit(0);}sprintf(shm, "%s", "hello, my child");sleep(2);printf("parent got a message: %s\n", shm);return 0;}
$ gcc -o mm mmap_shm.c$ ./mmchild got a message: hello, my childparent got a message: hello, father.
