抽象内存布局

对于未初始化的全局变量和静态变量,因为编译器知道它们的初始值都是 0,因此便不需要再在程序的二进制映像中存放这么多 0 了,只需要记录他们的大小即可,这便是 BSS 段。BSS 段这个缩写名字是 Block Started by Symbol,但很多人可能更喜欢把它记作 Better Save Space 的缩写。

数据段和 BSS 段里存放的数据也只能是部分数据,主要是全局变量和静态变量,但程序在运行过程中,仍然需要记录大量的临时变量,以及运行时生成的变量,这里就需要新的内存区域了,即程序的堆空间跟栈空间。与代码段以及数据段不同的是,堆和栈并不是从磁盘中加载,它们都是由程序在运行的过程中申请,在程序运行结束后释放。

image.png

现代应用程序中还会包含其他的一些内存区域

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

磁盘的程序段 (Section) 与 内存程序段 (Segment)

image.png

对于磁盘的程序,每一个单元结构称为 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来查看某个进程的实际虚拟内存布局。

image.png

我们上述的布局分析都是基于 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 进程内存布局:

image.png

对于 64 位的程序,你在查看 /proc/pid/maps 的过程中,会发现代码段跟数据段的中间还有一段不可以读写的保护段,它的作用也是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。

申请堆空间

不管是 32 位系统还是 64 位系统,内核都会维护一个变量 brk,指向堆的顶部,所以,brk 的位置实际上就决定了堆的大小。Linux 系统为我们提供了两个重要的系统调用来修改堆的大小,分别是 sbrk 和 mmap

sbrk

  1. #include <unistd.h>
  2. 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

  1. #include <unistd.h>
  2. #include <sys/mman.h>
  3. 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 有四种最常用的组合

image.png

共享匿名映射

  1. #include <sys/mman.h>
  2. #include <stdlib.h>
  3. #include <stdio.h>
  4. #include <unistd.h>
  5. int main() {
  6. pid_t pid;
  7. char* shm = (char*)mmap(0, 4096, PROT_READ | PROT_WRITE,
  8. MAP_SHARED | MAP_ANONYMOUS, -1, 0);
  9. if (!(pid = fork())){
  10. sleep(1);
  11. printf("child got a message: %s\n", shm);
  12. sprintf(shm, "%s", "hello, father.");
  13. exit(0);
  14. }
  15. sprintf(shm, "%s", "hello, my child");
  16. sleep(2);
  17. printf("parent got a message: %s\n", shm);
  18. return 0;
  19. }
  1. $ gcc -o mm mmap_shm.c
  2. $ ./mm
  3. child got a message: hello, my child
  4. parent got a message: hello, father.