1.5.7 内存管理
什么是内存
为了使用户程序在运行时具有一个私有的地址空间、有自己的 CPU,就像独占了整个计算机一样,现代操作系统提出了虚拟内存的概念。
虚拟内存的主要作用主要为三个:
它将内存看做一个存储在磁盘上的地址空间的高速缓存,在内存中只保存活动区域,并根据需要在磁盘和内存之间来回传送数据。
它为每个进程提供了一致的地址空间。
它保护了每个进程的地址空间不被其他进程破坏。
现代操作系统采用虚拟寻址的方式,CPU 通过生成一个虚拟地址(Virtual Address(VA))来访问内存,然后这个虚拟地址通过内存管理单元(Memory Management Unit(MMU))转换成物理地址之后被送到存储器。

前面我们已经看到可执行文件被映射到了内存中,Linux 为每个进程维持了一个单独的虚拟地址空间,包括了 .text、.data、.bss、栈(stack)、堆(heap),共享库等内容。
32 位系统有 4GB 的地址空间,其中 0x08048000~0xbfffffff 是用户空间(3GB),0xc0000000~0xffffffff 是内核空间(1GB)。

栈与调用约定
栈
栈是一个后入先出(Last In First Out,LIFO)的容器。用于存放函数返回地址及参数、临时变量和有关上下文的内容。程序在调用函数时,操作系统会自动通过压栈和弹栈完成保存函数现场等操作,不需要程序员手动干预。
栈由高地址向低地址增长,栈保存了一个函数调用所需要的维护信息,称为堆栈帧(Stack Frame)在 x86 体系中,寄存器 ebp 指向堆栈帧的底部,esp 指向堆栈帧的顶部。压栈时栈顶地址减小,弹栈时栈顶地址增大。
PUSH:用于压栈。将esp减 4,然后将其唯一操作数的内容写入到esp指向的内存地址POP:用于弹栈。从esp指向的内存地址获得数据,将其加载到指令操作数(通常是一个寄存器)中,然后将esp加 4。
x86 体系下函数的调用总是这样的:
把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。
把当前指令的下一条指令的地址压入栈中。
跳转到函数体执行。
其中第 2 步和第 3 步由指令 call 一起执行。跳转到函数体之后即开始执行函数,而 x86 函数体的开头是这样的:
push ebp:把ebp压入栈中(old ebp)。mov ebp, esp:ebp=esp(这时ebp指向栈顶,而此时栈顶就是old ebp)[可选]
sub esp, XXX:在栈上分配 XXX 字节的临时空间。[可选]
push XXX:保存名为 XXX 的寄存器。
把ebp压入栈中,是为了在函数返回时恢复以前的ebp值,而压入寄存器的值,是为了保持某些寄存器在函数调用前后保存不变。函数返回时的操作与开头正好相反:
[可选]
pop XXX:恢复保存的寄存器。mov esp, ebp:恢复esp同时回收局部变量空间。pop ebp:恢复保存的ebp的值。ret:从栈中取得返回地址,并跳转到该位置。
栈帧对应的汇编代码:
函数调用后栈的标准布局如下图:

我们来看一个例子:源码
使用 gdb 查看对应的汇编代码,这里我们给出了详细的注释:
这里我们在 Linux 环境下,由于 ELF 文件的入口其实是 _start 而不是 main(),所以我们还应该关注下面的函数:
函数调用约定
函数调用约定是对函数调用时如何传递参数的一种约定。调用函数前要先把参数压入栈然后再传递给函数。
一个调用约定大概有如下的内容:
函数参数的传递顺序和方式
栈的维护方式
名字修饰的策略
主要的函数调用约定如下,其中 cdecl 是 C 语言默认的调用约定:
cdecl
函数调用方
从右到左的顺序压参数入栈
下划线+函数名
stdcall
函数本身
从右到左的顺序压参数入栈
下划线+函数名+@+参数的字节数
fastcall
函数本身
两个 DWORD(4 字节)类型或者占更少字节的参数被放入寄存器,其他剩下的参数按从右到左的顺序压入栈
@+函数名+@+参数的字节数
除了参数的传递之外,函数与调用方还可以通过返回值进行交互。当返回值不大于 4 字节时,返回值存储在 eax 寄存器中,当返回值在 5~8 字节时,采用 eax 和 edx 结合的形式返回,其中 eax 存储低 4 字节, edx 存储高 4 字节。
堆与内存管理
堆

堆是用于存放除了栈里的东西之外所有其他东西的内存区域,由动态内存分配器负责维护。分配器将堆视为一组不同大小的块(block)的集合来维护,每个块就是一个连续的虚拟内存器片(chunk)。当使用 malloc() 和 free() 时就是在操作堆中的内存。对于堆来说,释放工作由程序员控制,容易产生内存泄露。
堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储空闲内存地址的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
如果每次申请内存时都直接使用系统调用,会严重影响程序的性能。通常情况下,运行库先向操作系统“批发”一块较大的堆空间,然后“零售”给程序使用。当全部“售完”之后或者剩余空间不能满足程序的需求时,再根据情况向操作系统“进货”。
进程堆管理
Linux 提供了两种堆空间分配的方式,一个是 brk() 系统调用,另一个是 mmap() 系统调用。可以使用 man brk、man mmap 查看。
brk() 的声明如下:
参数 *addr 是进程数据段的结束地址,brk() 通过改变该地址来改变数据段的大小,当结束地址向高地址移动,进程内存空间增大,当结束地址向低地址移动,进程内存空间减小。brk()调用成功时返回 0,失败时返回 -1。 sbrk() 与 brk() 类似,但是参数 increment 表示增量,即增加或减少的空间大小,调用成功时返回变化前数据段的结束地址,失败时返回 -1。
在上图中我们看到 brk 指示堆结束地址,start_brk 指示堆开始地址。BSS segment 和 heap 之间有一段 Random brk offset,这是由于 ASLR 的作用,如果关闭了 ASLR,则 Random brk offset 为 0,堆结束地址和数据段开始地址重合。
例子:源码
开启两个终端,一个用于执行程序,另一个用于观察内存地址。首先我们看关闭了 ASLR 的情况。第一步初始化:
数据段结束地址和堆开始地址同为 0x56558000,堆结束地址为 0x56579000。
第二步使用 brk() 增加堆空间:
堆开始地址不变,结束地址增加为 0x5657a000。
第三步使用 sbrk() 增加堆空间:
第四步减小堆空间:
再来看一下开启了 ASLR 的情况:
可以看到这时数据段的结束地址 0x56640000 不等于堆的开始地址 0x5788c000。
mmap() 的声明如下:
mmap() 函数用于创建新的虚拟内存区域,并将对象映射到这些区域中,当它不将地址空间映射到某个文件时,我们称这块空间为匿名(Anonymous)空间,匿名空间可以用来作为堆空间。mmap() 函数要求内核创建一个从地址 addr 开始的新虚拟内存区域,并将文件描述符 fildes 指定的对象的一个连续的片(chunk)映射到这个新区域。连续的对象片大小为 len 字节,从距文件开始处偏移量为 off 字节的地方开始。prot 描述虚拟内存区域的访问权限位,flags 描述被映射对象类型的位组成。
munmap() 则用于删除虚拟内存区域:
例子:源码
第一步初始化:
第二步 mmap:
第三步 munmap:
可以看到第二行第一列地址从 f76ef000->f76ee000->f76ef000 变化。0xf76ee000-0xf76ef000=0x1000=4096。
通常情况下,我们不会直接使用 brk() 和 mmap() 来分配堆空间,C 标准库提供了一个叫做 malloc 的分配器,程序通过调用 malloc() 函数来从堆中分配块,声明如下:
示例:
运行结果:
使用 gdb 查看反汇编代码:
关于 glibc 中的 malloc 实现是一个很重要的话题,我们会在后面的章节详细介绍。
Last updated
Was this helpful?