
在前边的课程里我们学习了计算机物理地址和虚拟地址的概念。有了虚拟地址之后运行在系统里的用户进程看到的地址空间范围都是虚拟地址空间范围32 位计算机的地址范围是 4G64 位计算机的地址范围是 256T。这样的话就不用再担心内存地址不够用以及与其他进程之间产生内存地址冲突的问题了。前面几节我们关注的是如何解决进程之间的冲突从这节课起我们一起来看下进程内部的虚拟内存布局或者说单一进程是如何安排自己的各种数据的。学习了这节你将理解全局变量和 static 变量在内存中的位置以及初始化时机在这个基础上你还将明白在栈上创建对象和在堆上创建对象有什么不同等问题。这些问题的核心都可以归结到“内存是如何布局的”这个问题上所以只有深刻地掌握了内存布局的知识你才能做到以不变应万变面对各种具体问题才有了分析的方向和思路进而你才能写出更加“内存安全”的代码。首先我们来看一下对于一个典型的进程来说它的内存空间是由哪些部分组成的每个部分又被安置在空间的什么位置抽象内存布局我们知道CPU 运行一个程序实质就是在顺序执行该程序的机器码。一个程序的机器码会被组织到同一个地方这个地方就是代码段。另外程序在运行过程中必然要操作数据。这其中对于有初值的变量它的初始值会存放在程序的二进制文件中而且这些数据部分也会被装载到内存中即程序的数据段。数据段存放的是程序中已经初始化且不为 0 的全局变量和静态变量。对于未初始化的全局变量和静态变量因为编译器知道它们的初始值都是 0因此便不需要再在程序的二进制映像中存放这么多 0 了只需要记录他们的大小即可这便是BSS 段。BSS 段这个缩写名字是 Block Started by Symbol但很多人可能更喜欢把它记作 Better Save Space 的缩写。数据段和 BSS 段里存放的数据也只能是部分数据主要是全局变量和静态变量但程序在运行过程中仍然需要记录大量的临时变量以及运行时生成的变量这里就需要新的内存区域了即程序的堆空间跟栈空间。与代码段以及数据段不同的是堆和栈并不是从磁盘中加载它们都是由程序在运行的过程中申请在程序运行结束后释放。总的来说一个程序想要运行起来所需要的几块基本内存区域代码段、数据段、BSS 段、堆空间和栈空间。下面就是内存布局的示意图这是程序运行起来所需要的最小功能集如果你尝试去看 Linux 0.11 的内核代码的话会发现它所支持的 a.out 文件格式和内存布局就是上边的样子。除了上面所讲的基本内存区域外现代应用程序中还会包含其他的一些内存区域主要有以下几类存放加载的共享库的内存空间如果一个进程依赖共享库那对应的该共享库的代码段、数据段、BSS 段也需要被加载到这个进程的地址空间中。共享内存段我们可以通过系统调用映射一块匿名区域作为共享内存用来进行进程间通信。内存映射文件我们也可以将磁盘的文件映射到内存中用来进行文件编辑或者是类似共享内存的方式进行进程通信。这样我们就初步了解了一个进程内存中需要哪些区域。在上面的讨论中我们并没有区分磁盘的程序段 (Section)以及内存程序段 (Segment) 的概念这两个词在国内往往都被翻译成“段”导致大多数同学会混淆它们。这里来做一个区分。上图从两个视角展示了应用程序的分布左边是程序在磁盘中的文件布局结构右边是程序加载到内存中的内存布局结构。对于磁盘的程序每一个单元结构称为 Section。我们可以通过 readelf -S 的选项来查看二进制文件中所有的 Section 信息。对于右边的内存镜像每一个单元结构称为 Segment。我们可以通过 readelf -l 的选项来查看二进制文件加载到内存之后的 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 段则是根据 BSS 段所需的大小在加载时生成一段 0 填充的内存空间。紧接着排在 BSS 段后边的就是堆空间了。在图中堆的空间里有一个向上的箭头这里标明了堆地址空间的增长方向也就是说每次在进程向内核申请新的堆地址时候其地址的值是在增大的。与之对应的是栈空间有一个向下的箭头说明栈增长的方向是向低地址方向增长也就是说每次进程申请新的栈地址时其地址值是在减少的。对此我们可以想象堆和栈分别由两个指针控制堆指针指明了当前堆空间的边界栈指针指明了当前栈空间的边界。当堆申请新的内存空间时只需要将堆指针增加对应的大小回收地址时减少对应的大小即可。而栈的申请刚好相反。这其实就是内核对堆跟栈使用的最根本的方式其中堆的指针叫做“Program break”栈的指针叫做“Stack pointer”也就是 x86 架构下的 sp 寄存器。我们在后续的课程中会分别展开堆空间跟栈空间的实现原理。继续往下看就到了内存映射区域这里最常见的就是程序所依赖的共享库例如 libc.so。共享库的代码段、数据段、BSS 段都会被装载到这里。这里要说明一点我们上述的布局分析都是基于 Linux 系统下关闭了进程地址随机化的选项。如果打开进程地址随机化的模式其中的堆空间、栈空间和共享库映射的地址在每次程序运行下都会不一样。这是因为内核在加载的过程中会对这些区域的起始地址增加一些随机的偏移值这能增加缓冲区溢出的难度。对于这个进程地址随机化选项我们可以通过 sudo sysctl -w kernel.randomize_va_spaceval的命令来设置。其中val0 表示关闭内存地址随机化val1 表示使得 mmap 的基地址、栈地址和 VDSO 的地址随机化val2 则是在 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。对于 64 位的程序你在查看 /proc/pid/maps 的过程中会发现代码段跟数据段的中间还有一段不可以读写的保护段它的作用也是防止程序在读写数据段的时候越界访问到代码段这个保护段可以让越界访问行为直接崩溃防止它继续往下运行。在所有的内存区域中程序员打交道最多、接触最广泛的就是堆空间。所以我们接下来重点关注操作系统所提供的用于管理堆的系统调用是怎样的。这里会先讲如何通过系统调用申请堆空间关于堆空间更精细的管理我们将在第 9 节介绍。申请堆空间其实不管是 32 位系统还是 64 位系统内核都会维护一个变量 brk指向堆的顶部所以brk 的位置实际上就决定了堆的大小。Linux 系统为我们提供了两个重要的系统调用来修改堆的大小分别是 sbrk 和 mmap。接下来我们来学习这两个系统调用是如何使用的。我们先来看 sbrk。sbrksbrk 函数的头文件和原型定义如下#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 的具体实现我们将在第 8 节课进行详细讲解。这里你要注意的是malloc 和 free 函数不是系统调用而是 C 语言的运行时库。Linux 上的主流运行时库是 glibc其他影响力比较大的运行时库还有 musl 等。C 语言的运行时库多是以动态链接库的方式实现的关于动态链接库的相关知识会在第 7 节加以介绍。在 C 语言的运行时库里malloc 向程序提供分配一小块内存的功能当运行时库的内存分配完之后它会使用 sbrk 方法向操作系统再申请一块大的内存。我们可以将 C 语言的运行时库类比为零售商它从操作系统那里批发一块比较大的内存然后再通过零售的方式一点点地提供给程序员使用。mmap另一个可以申请堆内存的系统调用是 mmap它是最重要的内存管理接口。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 效率还可以用来申请堆内存。决定它的功能的主要是 prot, flags 和 fd 这三个参数我们分别来看看。prot 的值可以是以下四个常量的组合PROT_EXEC表示这块内存区域有可执行权限意味着这部分内存可以看成是代码段它里面存储的往往是 CPU 可以执行的机器码。PROT_READ表示这块内存区域可读。PROT_WRITE表示这块内存区域可写。PROT_NONE表示这块内存区域的页面不能被访问。而 flags 的值可取的常量比较多你可以通过 man mmap 查看这里我只列举一下最重要的四种可取值常量MAP_SHARED创建一个共享映射的区域多个进程可以通过共享映射的方式来共享同一个文件。这样一来一个进程对该文件的修改其他进程也可以观察到这就实现了数据的通讯。MAP_PRIVATE创建一个私有的映射区域多个进程可以使用私有映射的方式来映射同一个文件。但是当一个进程对文件进行修改时操作系统就会为它创建一个独立的副本这样它对文件的修改其他进程就看不到了从而达到映射区域私有的目的。MAP_ANONYMOUS创建一个匿名映射也就是没有关联文件。使用这个选项时fd 参数必须为空。MAP_FIXED一般来说addr 参数只是建议操作系统尽量以 addr 为起始地址进行内存映射但如果操作系统判断 addr 作为起始地址不能满足长度或者权限要求时就会另外再找其他适合的区域进行映射。如果 flags 的值取是 MAP_FIXED 的话就不再把 addr 看成是建议了而是将其视为强制要求。如果不能成功映射就会返回空指针。通常我们使用私有匿名映射来进行堆内存的分配具体的原理我们会在第 9 节详细分析。我们再来看参数 fd。当参数 fd 不为 0 时mmap 映射的内存区域将会和文件关联如果 fd 为 0就没有对应的相关文件此时就是匿名映射flags 的取值必须为 MAP_ANONYMOUS。明白了 mmap 及其各参数的含义后你肯定想知道什么场景下才会使用 mmap我们又该怎么使用它。mmap 的其他应用场景mmap 这个系统调用的能力非常强大我们在后面还会经常遇到它。在这节里我们先来了解一下它最常见的用法。根据映射的类型mmap 有四种最常用的组合其中私有匿名映射常用于分配内存也就是我们上文讲的申请堆内存具体原理我们会在第 9 节讲解。而私有文件映射常用于加载动态库它的原理会在第 7 节和第 8 节进行分析。这里我们重点看看共享匿名映射。我们通过一个例子来了解一下 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; }在这个过程中我们先是用 mmap 方法创建了一块共享内存区域命名为 shm第 9 行代码接着又通过 fork 这个系统调用创建了子进程。从第 13 行到第 16 行代码是子进程的执行逻辑具体来讲子进程休眠一秒后从 shm 中取出一行字符并打印出来然后又向共享内存中写入了一行消息第 15 行。在子进程的执行逻辑之后是父进程的执行逻辑第 19 行以后父进程先写入一行消息然后休眠两秒等待子进程完成读取消息和发消息的过程并退出后父进程再从共享内存中取出子进程发过来的消息。这就是共享匿名映射在父子进程间通信的运用。我们使用 gcc 编译运行上面的例子可以得到这样的结果$ gcc -o mm mmap_shm.c $ ./mm child got a message: hello, my child parent got a message: hello, father.我想请你结合我刚才的讲解来分析一下这个程序运行的结果这样你就理解的更透彻了。关于共享匿名映射我们就讲到这里至于 mmap 的另一个组合共享文件映射。它的作用其实和共享匿名映射相似也可以用于进程间通讯。不同的是共享文件映射是通过文件名来创建共享内存区域的这就让没有父子关系的进程也可以通过相同的文件创建共享内存区域从而可以使用共享内存进行进程间通讯。更具体的原理分析放在了第 10 章。总结在这节中我们从抽象到具体逐步了解了程序运行时的内存布局模型。我们了解到一个进程的内存可以分为内核区域和用户区域。内核区域是由操作系统内核维护的我们通常并不关心这一块内存是如何使用的。程序员最关心的是用户空间用户空间大致可以分为栈、堆、bss 段、数据段和代码段代码段保存的是程序的机器指令这一段区域的内存往往是可读可执行但不可写数据段保存的是程序的静态变量和全局变量bss 段用于无初值的变量区域堆是程序员可以自由申请的空间当我们在写程序时要保存数据优先会选择堆栈是函数执行时的活跃记录这将是我们下一节要重点分析的内容。这 5 个内存区域通常是由高地址向低地址顺序排列的。但这并不是绝对的以后我们会看到各种反例比如代码段的位置完全可以比堆的位置还要高。接着我们以 Linux 为例分别研究了 IA-32 架构和 Intel64 架构上的内存布局。在这两种情况下各个段都是按照上述功能进行划分的区别在于 64 架构中地址空间更大而且内核空间和用户空间是不连续的。此外我们还初步学习了两个用于堆管理的系统调用 sbrk 和 mmap。其中mmap 的用法非常复杂根据调用时传的参数它有 4 种常见的用法分别是私有匿名映射、私有文件映射、共享匿名映射和共享文件映射。其中共享匿名映射是我们这节课的重点它可以用于父子进程之间的通讯。关于 mmap 的其他功能我们会在后面逐渐展开。在接下来的课程中会详细介绍内存布局中的堆跟栈这两块也是我们开发人员最常打交道的内存区域让你对程序运行时的环境和内存状态有一个更深入的理解。思考题一块内存区域的权限一般包括可读可写可执行三类请你思考一下代码段应该被授予怎么样的权限呢数据段和堆又该被授予怎样的权限呢