运行时内存布局与栈帧结构

我们知道程序在运行时的机器码(程序代码)、栈、堆都是在内存(存储器)上的一段空间,然后CPU依着一定的规则读取、翻译指令,「指挥」计算机如何存取内存上的数据。本文以Linux上的C程序为例,简单介绍一下这其中的运行机制。

内存布局

每个Linux程序都有一个运行时的内存布局,主要结构如上图所示。在32位的Linux系统中,一个进程的虚拟地址空间最多为4GB,其中1GB(一般为地址空间的高位1G空间)是为内核保留的,剩下的3GB可为用户态所用。代码段总是从地址0x08048000初开始。数据段是在接下来的一个4KB对齐的地址处。运行时的堆在读/写段之后接下来第一个4KB对齐的地址处,并通过调用glibc库函数malloc往上增长。还有一段是为共享库保留的。用户栈总是从最大的合法用户地址开始,向下增长(向低地址方向增长)。从栈的上部开始的段是为内核的代码和数据保留的。

  • .init:在该段定义了一个小函数_init,程序的初始化代码会调用它。
  • .text:已编译程序的机器代码。
  • .rodata:只读数据。比如字符串字面值、switch的跳转表、C++中的虚函数表都存放此段。
  • .data:已初始化的全局变量。
  • .bss:未初始化的全局变量。
  • 运行时堆:一般通过调用glibc库函数malloc往上增长。
  • 共享库映射区域:一般是glibc或者其他动态链接库的映射区域,如printf等等。
  • 用户栈:维持程序运行时的栈帧结构。
  • 内核段:为内核态的代码和数据所保留的地址空间。

程序运行的时候是需要加载器加载的,当加载器运行时它创建如上图所示的内存布局映像,然后在可执行文件的段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来加载器跳转到程序的入口点,也就是符号_start的地址。在_start地址处的基本调用序列如下:

0x080480c0 <_start>:            /* Entry point int .text */
call __libc_init_first /* Startup code in .text */
call _init /* Startup code in .text */
call atexit /* Startup code in .text */
call main /* Application main routine */
call _exit /* Returns control to OS */

在从.text段中调用了初始化例程后,启动代码调用了atexit函数,这个程序附加了一系列在应用程序正常中止时应该调用的函数。然后启动代码调用main函数执行用户代码,在用户代码返回后,exit函数运行atexit所注册的函数,然后运行_exit函数将控制返回给操作系统。

那么加载器是如何工作的呢?操作系统中的每个程序都是运行在一个进程上下文中,有自己的虚拟地址空间。当外壳(如shell)运行一个程序时,父外壳进程fork一个子进程,然后调用exec函数,exec函数会通过系统调用启动加载器。加载器删除子进程现有的虚拟地址空间内存布局,并创建一组新的代码、数据、堆和栈段。然后通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件的内容。最后加载器跳转到_start地址处,调用上文所述的调用序列。除了一些头部信息,在加载的过程中没有任何从磁盘(硬盘)到存储器(内存)的数据拷贝,直到CPU引用一个被映射的虚拟页才会进行拷贝,此时,操作系统的页面调度机制自动将页面从磁盘(硬盘)拷贝到存储器(内存)。

sbrk与mmap

运行时堆一般是通过调用malloc向上(高地址)增长,如上图所示具体是通过sbrk函数移动内核中的一个叫做brk的指针来控制堆的。但是在实际的Linux中并不是所有通过malloc申请的动态内存都是通过sbrk增长堆来申请空间的。具体策略是:

  • 当malloc申请的内存小于128KB时,是通过sbrk增长运行时堆来获取内存空间的。
  • 当malloc申请的内存大于128KB时,是通过mmap函数进行系统映射获取内存空间的。
#include <unistd.h>
#include <sys/mman.h>

void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset)

mmap函数要求内核创建一个新的虚拟存储器区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片(chunk)映射到这个新的区域。连续的对象片大小为length字节,从距离文件开始处偏移量为offset字节的地方开始。start地址仅仅是一个暗示,通常被定义为NULL。自Linux内核2.6开始,在32位系统上,一般是从靠近3GB地址处的用户栈的下方开始往下(低地址)映射。

参数prot包含描述新映射的虚拟存储器区域的访问权限位(在相应区域结构中的vm_prot位):

  • PROT_EXEC:这个区域内的页面由可以被CPU执行的指令组成。
  • PROT_READ:这个区域内的页面可读。
  • PROT_WRITE:这个区域内的页面可写。
  • PROT_NONE:这个区域内的页面不能被访问。

参数flags由描述被映射对象类型的位组成:

  • MAP_ANON:表示被映射的对象是一个匿名对象,而相应的虚拟页面是请求二进制零的。
  • MAP_PRIVATE:表示被映射的对象是一个私有的、写时拷贝的对象。
  • MAP_SHARED:表示是一个共享对象。
#include <unistd.h>
#include <sys/mman.h>

void* munmap(void* start, size_t length)

就像malloc与free一样,相对应的mmap映射的内存区域可以用munmap释放。需要注意的是munmap不会影响到被映射的对象,也就是说不会使映射区的内容写到磁盘(硬盘)文件上。

X86的栈帧结构

在上文的运行时内存布局中谈到了用户栈就是维持程序运行时的栈帧结构,在X86架构下,其栈帧结构大致如下图:

IA32程序使用栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分叫做栈帧。如上图所示,栈帧的最顶端以两个指针为分界线,寄存器%ebp为帧指针,寄存器%esp为栈指针。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于栈指针的。

假设过程P(调用者)调用过程Q(被调用者),Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压入栈中,形成P的栈帧末尾。返回地址就是当程序从Q返回时应该继续执行的地方。Q的栈帧从保存帧指针(%esp)开始,后面是保存的其他寄存器的值。

过程Q也用栈来保存其他不能存放在寄存器中的局部变量,这样做的主要原因是:

  • 没有足够多的寄存器存放所有的局部变量。
  • 有些局部变量是数组、结构体或者如C++中的对象,因此必须通过数组,结构体或者对象引用来访问。
  • 有些局部变量是左值,如果用户代码对一个左值进行了取地址操作,就必须得为这样的局部变量生成一个地址。

另外,Q会用栈帧来存放它调用的其他过程的参数,即函数的实参。如上图所示,在被调用的时候,第一个参数存放在相对于%ebp偏移量为8的位置,剩下的参数(假设参数数据类型长度为4字节)存储在后续的4字节内存中,所以参数i就在相对于%ebp偏移量为4+4i的地方。较大的参数(如结构体或者对象)需要更大的栈上空间。

正如上文讲的那样,栈向低地址方向增长,栈指针%esp指向栈顶元素,可以利用pushl将数据存入栈中并利用popl从栈中取出。将栈指针的值适当减小可以给数据分配空间,相应的可以通过增加栈指针的值来释放空间。

X64的变化

在IA32架构的X86系统上,由于指针长度为32位只能寻址4GB的地址空间,内存很有限。到了X64时代,指针的长度变为64位,也就是可以寻址0 - (2^64-1)的地址空间了。而且相对于X86,不仅原来X86上的所有寄存器可存放的数据长度由的32位变成了64位,而且另外增加了8个通用目的寄存器。

主要有这些变化:

  • 没有帧指针。寄存器%rbp变为了普通的通用目的寄存器。作为代替,对栈位置的引用相对于栈指针。大多数函数在调用开始时分配所需的整个栈存储空间,并保持栈指针指向固定的位置。
  • 由于增加了通用目的寄存器的缘故,许多函数不需要栈帧。只有那些不能将所有的局部变量都存放在寄存器中的函数才需要在栈上分配空间。
  • 函数传参的实参(最多是前6个)通过寄存器传递,而不是在栈上。这消除了在栈上存储和检索值的开销。
  • 函数最多可以访问超过当前栈指针%rsp值128个字节的栈上空间(地址低于当前栈指针的值)。这就允许了一些函数将信息存储在栈上而无需修改栈指针。
  • callq指令将一个64位的返回地址压栈,而不是32位。

参考文献

  1. Randal E.Bryant, David O’Hallaron. 深入理解计算机系统(第2版). 机械工业出版社, 2010
  2. W.Richard Stevens. UNIX环境高级编程(第3版), 人民邮电出版社, 2014