前面几篇文章都是关于静态库,动态库相关的介绍。平时开发中接触并不是很多。从这篇开始进入和工作中关系比较大的部分,库和运行库。第一篇就从内存开始介绍。
本文导图
内存布局
现代应用程序都运行在一个内存空间里面,在32系统里面这个内存空间拥有4GB的寻址能力。16位时代是用段地址加偏移地址的寻址模式用尽不用了。如今的应用程序直接使用32位的地址进行寻址。这种方式叫做平坦的内存模型。整个内存是一个统一的地址空间,用户可以使用一个32位指针访问任何一个内存位置。
int *p=(int *)0x12345678;
++*p;
如上可以直接读写指定内存的数据。但是真真的情况下并不是这么平坦的。因为大多数操作系统会把内存空间的一部分给内核使用,应用程序时无法访问的,对应的内存空间也就叫做内核空间。相对内核空间,应用程序能够访问到的就是永恒空间。一般而言,在用户空间,很多区域有特殊含义。一个应用程序有如下默认的区域:
- 栈:用于维护函数调用的上下文,通常是在用户空间高地址除分配,有数兆字节大小
- 堆:堆用来容纳应用程序动态分配的内存区域。比如使用malloc、new分配内存的时候。堆通常位于栈的下方(低地址方向),并且堆一般没有统一的区域,而且范围也比栈大很多。
- 可执行文件映像:存储可执行文件在内存中的映像。之前介绍过,由装载器在装载的时候将可执行文件的内存读取或映射到这里。
- 保留区:不是一个单一的内存区域,而是对内存中受保护而禁止访问内存区域的总称。比如大多数操作系统绩效的地址通常不允许访问比如NULL,因为0地址正常情况不可能有有效的可访问数据。
如下图是Linux一个进程的内存布局:
注意还有一个动态链接库映射区,这个区域用于映射装载的动态链接库,比如在Linux下,如果有其他共享库链接,那么系统就会从0x4000 0000开始地址分配。
栈
栈最大的特点就是先进后出。在数据结构中,栈被定义为一个特殊的容器,可以压栈和出栈。在计算机系统中,栈则是一个具有数据结构中栈的属性的一个动态内存区域。
栈总是向下增长的,栈顶由称为esp(有些也叫sp)寄存器定位。
栈保存了一个函数调用所需要的维护信息,被称作为堆栈帧或者活动记录,堆栈帧一般有如下内容:
- 函数的返回地址和参数
- 临时变量,包括函数的非晶体局部变量及编译器自动生成的其他临时变量
- 保存的上下文,包括在函数调用前后保持不变的寄存器
在i386中,栈帧用ebp和esp这链两个寄存器划定范围。esp指向栈顶,ebp指向栈帧的一个固定位置,ebp又叫做帧指针。
比如:
在参数之后的数据就是函数的活动记录,ebp固定在其中一个位置,ebp不会随着函数的执行变化而变化,esp指向栈顶,会随着函数的执行变化而变化。ebp用来定位函数活动记录的各个数据。
ebp之前就是函数返回地址(ebp-4),再往前就是压入栈的参数 ,地址分配是ebp-8、ebp-12,根据参数大小不同而定。
ebp指向的数据是调用函数前的ebp的值,这样函数返回的时候ebp可以通过这个值恢复到调用前的值。
i386函数调用过程:
- 把所有或者一部分参数压入栈中,如果有些参数没有入栈则使用特定的寄存器保存。——参数
- 把当前指令的下一条指令地址压入栈中。——返回地址
- 跳转到函数体执行——上一步和这一步由指令call一起执行
- push ebp;将ebp压入栈中——old ebp
- mov ebp,esp ebp =esp——这个时候ebp指向栈顶,恰好这个时候栈顶就是old ebp
- sub esp,xxx——在栈上分配xxx字节的临时空间
- push xxx——如果有必要,保存名为xxx寄存器的内容
- pop xxx——将栈顶数据恢复到xxx寄存器
- mov esp,ebp——恢复esp,回收栈局部变量空间
- pop ebp——从栈中恢复保存的ebp的值
- ret ——从栈中取回返回地址,并跳转到该位置。
一个例子:
调用惯例(一种调用方和被调用的约定)
如果函数调用方决定利用寄存器传递参数,而函数本身却以栈的方式传递,那么函数无法获取正确的参数。调用方和被调用方对于函数如何调用有一个明确的约定,只有双方遵守同样的约定,函数才能被正确调用,这样的约定被称作调用惯例。
- 参数传递顺序和方式
- 栈的维护方式
- 名字修饰的策略
c语言中使用的cdel惯例:
看一个例子:
- 虚线表示指令执行后的栈状态
- 实线表示程序的跳转状态
最好按照执行过程走一遍,弄清楚整个流程。
函数返回值传递
函数返回值存储在eax中,返回后函数的调用方再读取eax。但是exa只有4个字节,那么如何返回大数据的返回值呢。
几乎所有的调用惯例都是采用eax和edx联合返回的方式进行。exa存储低1-4字节,edx存储高1-4字节。
可以通过反汇编来分析eax和edx
源代码:
分析结果:
如果返回值的尺寸太大,C语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值的对象会被拷贝两次,所以不到万不得已不要轻易返回大尺寸的对象。
堆
堆的内存管理比栈更为复杂,在任意时刻程序都可能发出请求,要么申请一段内存,要么释放一段已经申请过的内存。
栈在函数返回时数据就会被清空,所以无法将数据传递至函数外部,而全局变量没有办法动态产生,只能在编译的时候定义,所以堆是唯一的选择。
堆是一块巨大的空间,占有虚拟内存很大一部分。程序可以在堆上主动申请一块连续的内存,直到程序主动放弃。
int main(){
char* p=(char *)malloc(1000);
//p指针指向1000大小的数组
free(p);
}
malloc一种简单的实现是通过操作系统内核去完成的,把进程的内存管理给操作系统内核,系统提供一个系统调用。但是这样性能比较差,因为程序申请释放堆的操作比较频繁,每次都需要在用户太和内核态切换会消耗非常大的开销。比较好的做法就是把程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,往往是通过运行库来管理的。
运行库充当的而是像操作系统批发一块较大堆空间角色,然后零售给程序使用。当全部使用完或者程序有大量内存需求的时候,运行库再向操作系统进货。通过堆分配算法来保证分配的内存不会出现冲突。
Linux进程堆管理
Linux 提供了两种堆空间的分配方式(两种系统调用),一种是brk系统调用,一种是mmap系统调用。brk原型int brk(void* end_data_segment)
brk
brk作用实际上就是设置进程数据段的结束地址,扩大 或者缩小数据端,如果数据段结束地址向高地址移动那么扩大的部分就可以被使用。另一个和brk类似的是sbrk,只不过sbrk传入的是需要增加空间的大小(负数就是减小),返回时值数据段的结束为止,其实就是对brk的一次包装。
mmap
作用是向操作系统申请一段虚拟地址空间,这块虚拟地址空间可以映射到某个文件,当不再映射到某个文件的时候,又叫这个空间为匿名空间——虚拟内存和磁盘交换
声明如下:
字段 | 含义 |
---|---|
start | 申请空间的起始地址 |
length | 申请空间的大小,和start决定虚拟空间 |
prot | 设置申请空间的权限,可读、可写、可执行 |
flags | 映射类型,比如文件映射、匿名空间等 |
fd | 文件描述符 |
offset | 文件偏移 |
malloc
glibc中是通过malloc申请空间的,小于128kb的请求来讲会在现有的堆空间里面,安装堆分配算法分配一块空间并返回;如果大于128kb的请求会通过mmap申请一块匿名空间,然后在匿名空间中为用户分配空间。
申请空间的其实地址和代销都必须是系统页大小的整数倍。所以对于字节很小的请求用mmap的话,会非常浪费。
Windows进程堆管理
关于Windows的内容这里暂时先省略,有感兴趣的同学可以自己去看一看。
值得注意的几个问题
- 一个堆里面的空间,如果被多次释放,那么会在重复释放的时候产生错误
- 调用malloc首先会从系统分配给应用功能的堆里面去取,如果不够用才会通过系统调用再申请空间
- malloc申请的空间在进程结束后都不会存在了,不仅内存如此。凡是和进程相关的资源,比如地址空间,物理内存,打开的文件,网络链接等都会被系统回收
- malloc申请的空间在虚拟空间是连续的,在对应的物理空间不一定连续
堆分配算法
如何管理一大块连续的内存空间,能够按照需求 分配和释放,这就是堆分配算法。堆分配算法有很多种,这里介绍简单的几种
空闲链表
空闲链表就是把堆中各个空闲的块通过链表的方式联系起来,当前用申请一块空间的时候 ,可以遍历整个链表,直到找到合适大小的块,并将它拆分,当用户释放空间的时候将它合并到空闲链表中。
当找到了足够容纳请求的一个空闲快,会把块分为两个部分,一部分为程序申请的,一部分是没有用到剩下的的(申请的和块大小不一定完全一样)。下图表示申请了一块和空闲块2相等堆后的状态
在释放的时候,需要知道这个块的大小,那就在后面多加4个字节,用于存储分配的大小。这样在释放的时候就知道大小了。
位图
核心思想就是把整个堆分为大量的块,每个块的大小相同,当用户申请的时候,总是分配给整数个块给用户。第一个块称为分配区域的头,其余的我主体。那么所有的块只有三种状态头(H)、主体(B)、空闲(F)。所有可用两个二进制就能表示出来。
其中11表示H,10表示主体,00表示空闲
如上图,这个堆分配3个片内存,分别有2、4、1块。对应的位图是
- 速度快:整个堆空闲信息存储在一个数组内,隐藏访问数组时cache容易命中。
- 稳定性好
- 快不需要额外的信息
- 但是分配内存的时候容易造成随便,比如分配300字节,实际分配了3个快,也就是384.
对象池
上面两种是最为基本的。被分配对象大小是较为固定的几个值。对象池是一个对于这种场景更为高效的算法。
思路:如果一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,没次请求的时候只需要找到以小块就可以了。
核心在于与上面两种的区别是假定每次请求的都是一个固定的大小,因此实现起来容易。
实际中堆分配算法往往是结合起来的,比如glilbc,对于64字节空念的申请,采用的是类似于对象池的方式,对于大于512字节的采用最佳适配算法,对于大于128KB的,会直接使用mmap机制直接向操作系统申请。
小结
这一节总体看来不是特别复杂,因为这一层平时接触比较多。相对于编译连接,静态、动态库之类的要简单很多。
网友评论