大多数操作系统会将内存空间分为内核空间和用户空间,而每个进程的内存空间又有如下的“默认”区域。
1、栈:栈用于维护函数调用的上下文,离开栈函数调用就会无法实现。栈通常在用户空间的最高地址处分配,通常有数兆字节。
2、堆:堆用来容纳应用程序动态分配的内存区域,我们使用malloc 或者new分配内存时,得到的内存来自堆里。堆通常存于栈的下方(低地址方向),堆一般比栈大很多,可以有几十至数百兆字节的容量。
3、可执行文件镜像:可执行文件由装载器在装载时将可执行文件读取到内存或者映射到内存。
3.1、代码段:代码段中存放可执行的指令,在内存中,为了保证不会因为堆栈溢出被覆盖,将其放在了堆栈段下面,
通常来讲代码段是共享的,这样多次反复执行的指令只需要在内存中驻留一个副本即可,比如 C 编译器,文本编辑器 等。代码段一般是只读的,这样程序执行时不能随意更改指令,也是为了进行隔离保护。
3.2、初始化数据段:
初始化数据段有时就称之为数据段。数据段是一个程序虚拟地址空间的一部分,包括一全局变量和静态变量,这些变量 在编程时就已经被初始化。数据段是可以修改的,不然程序运行时变量就无法改变了,这一点和代码段不同。数据段可 以细分为初始化只读区和初始化读写区。这一点和编程中的一些特殊变量吻合。比如全局变量 int global n = 1就被放在 了初始化读写区,因为 global 是可以修改的。而 const int m = 2 就会被放在只读区,很明显,m 是不能修改的。
4、保留区:保留区并不是一个单一的内存区域,而是对内存中受保护而禁止访问的内存区域的总称,例如,大多说操作系统中,极小的地址通常都是不允许访问的,如NULL。(顺便提一下,我们编程的时候经常会遇到‘段错误(segment fault)’或者‘非法操作,该内存地址不能read/write’的错误信息,其中一个原因就是我们初始化了一个指针为NULL但是没有给它赋合理的值就开始使用它。)
linux 进程内存空间布局如下图:
栈是由高地址向低地址增长。
堆是由低地址向高地址增长。
当栈或堆现有的大小不够用的时候,它将按照图中的增长方向扩大自身的尺寸,直到预留的空间被用完为止。
栈
一个栈的实例:
在经典的操作系统中,栈总是向下增长的,栈顶由称为esp的寄存器进行定位,压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。栈保存了一个函数调用所需的维护信息,常常被称为堆栈帧或者活动记录。堆栈帧一般包括如下几个方面:
- 函数的返回地址和参数。
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
一个函数调用函数的活动记录
一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针。函数调用时栈的操作如下:
- 把所有的或者一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。
- 把当前指令的下一条指令的地址压入栈中。
- 跳转到函数体执行。
- push ebp:把ebp压入栈中(称为old ebp)。为了在函数返回的时候便于恢复以前的ebp值。
- mov ebp , esp: ebp = esp (这时ebp指向栈顶,而此时栈顶就是old ebp)。
- 【可选】sub esp,XXX :在栈上分配XXX字节的临时空间。
- 【可选】push XXX:如果有必要,保存名为XXX的寄存器(可重复多个,由于编译器可能要求某些寄存器在调用前后保持不变,那么函数就在调用开始将这些寄存器的值压入栈中,在结束后取出)。
函数调用结束时
- 【可选】pop XXX:如果有必要,恢复保存过的寄存器(可重复多个)。
- mov esp, ebp:恢复ESP同时回收局部变量空间。
- pop ebp: 从栈中恢复保存的ebp的值。
- ret :从栈中取得返回地址,并跳转到该位置。
函数返回值传递
函数的返回值是通过eax寄存器返回的,但是eax寄存器只有4字节,如果返回值在5-8字节范围内,几乎所有的调用惯例都是采用eax和edx联合返回的,eax存储返回值的低4字节,其他的字节在edx中存储。但是大于8字节的返回值那?
先上代码:
typedef struct big_thing
{
char buf[128];
}big_thing;
big_thing return_test()
{
big_thing b;
b.buf[0] = 0;
return b;
}
int main()
{
big_thing n = return_test();
}
在调用return_test函数时,进行了如下操作:
- 首先main函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为temp。
- 将temp对象的地址作为隐藏参数传递给return_test函数。
- return_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出。
- return_test 返回之后,main函数将eax指向的temp对象的内容拷贝给n。
也就是如下伪代码:
void return_test(void *temp)
{
big_thing b;
b.buf[0] = 0;
memcpy(temp, &b, sizeof(big_thing));
eax = temp;
}
int main()
{
big_thing n;
big_thing temp;
return_test(&temp);
memcpy(&, eax, sizeof(big_thing));
}
所以我们在编程时尽量不要返回大于4字节的数据,避免两次拷贝,减小开销。
堆
栈上的数据在函数返回时就会被释放掉,所以无法将数据传至函数外部,而全局变量没有办法动态地产生,只能在编译的时候定义,在这种情况下,堆是唯一地选择。malloc是C语言申请堆空间的函数,但是它是怎么实现的那?
其实可以直接让操作系统的内核来管理进程的内存,但是每次申请内存都要经过系统调用,如果操作频繁会导致效率很低,程序性能降低。比较好的做法是程序向操作系统申请一块适当的堆空间,然后由程序的运行库根据算法管理堆空间的分配,当堆空间不够的时候再向操作系统申请堆空间。linux下提供两种堆空间分配方式:一个是brk()系统调用,另外一个是mmap()。
int brk (void *end_data_segment)
brk()的作用实际上就是设置进程数据段的结束地址,她可以扩大或者缩小数据段。
void mmap(void *start, size_t length, int port, int flags, int fd, off_t offset)
mmap的前两个参数分别指定需要申请的空间的起始地址和长度,如果其实地址设为0,那么操作系统会挑选合适的起始地址。port/flags这两个参数用于设置申请的空间的权限(可读,可写,可执行)以及映射类型(文件类型,匿名空间等),最后两个参数用于文件映射是指定文件的描述符和文件偏移。用mmap实现的malloc函数:
void *malloc(size_t nbytes)
{
void *ret = mmap(0, nbytes, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0 , 0);
if (ret == MAP_FAILED)
return 0;
return ret;
}
mmap()的作用是向操作系统申请一段虚拟空间,当这块虚拟空间可以映射到某个文件(也就是这个系统调用的最初的作用),当他不将地址空间映射到某个文件时,我们又称这块空间为匿名空间。
glibc的malloc 函数是这样处理用户的空间请求的:对于小于128KB的请求来说,它会在现有的堆空间里面,按照堆分配算法为它分配一块空间返回,对于大于128KB的请求来说,它会使用mmap()函数为它分配一块匿名空间,然后再这个匿名空间中为用户分配空间。(所以问一个很常见的问题,malloc申请的内存,进程结束以后还会不会存在? 答案是不存在)
堆分配算法
1、空闲链表法
空闲链表的方法是把堆中各个空闲的快按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,直到找到合适大小的快并且将它拆分,当用户释放空间时将它合并到空闲链表中。
2、位图
核心思想就是将整个堆划分为大量的块,每个块大小相同。当用户请求内存的时候总是分配整个块的空间给用户。第一个块我们称为已分配区域的头,其余的称为已分配区域的主体。而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此仅仅需要两位即可表示一个块,因此称为位图。
优点:速度快,稳定性好,容易管理。
缺点:容易产生碎片,浪费空间。
3、对象池
如果实际上在一些场合,被分配对象的大小是固定的几个值,我们可以采用对象池的方法。对象池思想就是,如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求只要找到一个空闲的小块就可以了。
实际上很多应用中,堆的分配算法往往是采取多种算法复合而成的,对于glibc来说,小于64字节的采用对象池的方法,对于大于512字节的采用最佳适配算法,对于64字节和512字节之间的采取最佳折中策略;对于大于128kb的申请,它会直接使用mmap向操作系统申请空间。