版权声明:本文为博主原创文章,未经博主允许不得转载。
一、内存
关于程序的运行,不得不提到内存方面的内容,那么首先就对一个进程虚拟地址空间的布局用一张图来看清楚
这张图基于32位Linux系统,即起始地址为0x08048000,可以看到顺序为只读段(代码段等)、读写段(数据段、bss段等)、堆(向上即高地址扩展)、用于堆扩展的未使用空间、动态库的映射位置(0x40000000开始)、之后就是栈(向下即低地址扩展)以及用于栈扩展的未使用空间、最后是内核空间。
前面讲过了动态链接与静态链接的部分,那么对只读段、读写段以及动态库的部分都比较清楚了。接下来就是关于栈和堆的部分,也是我们编写程序时会直接使用到的部分。
1、栈
其实前面的一张图中已经说过了栈与堆的大致特点,其中栈最大的不同就是从高地址向低地址扩展,同时有esp指针一直指向栈顶(低地址),另外栈的原则是先入后出。
栈保存了函数调用需要维护的信息,这称为堆栈帧或活动记录
图中就是一个堆栈帧的实例图,即函数调用时会产生的一个在栈中保存的信息,其中使用ebp及esp两个寄存器来划定函数的活动记录。
esp始终指向栈顶部,即当前的函数的活动记录的顶部,而ebp则固定不动,其指向的是调用函数前ebp的数据,于是在调用完成后,ebp会重新获得调用前的值。
至于一个堆栈帧的结构是这样的原因就是因为调用函数时所需要进行的一系列操作导致的:
1、将所有参数或一部分参数入栈
2、将当前指令的下一条指令地址入栈(返回地址)
3、跳转到函数体执行,在函数体开始执行时还需要完成一部分操作:ebp入栈,将ebp指向esp(栈顶),分配所需字节的临时空间,保存寄存器
其中第2和3步在汇编代码中就是由call指令完成的,而在i386中函数体的开头的一般形式中可以看出第2和3步执行后还需要完成的那一部分操作
- 1
- 2
- 3
- 4
- 1
- 2
- 3
- 4
因此可以想象,实际的调用函数完成后的返回过程与之前相反:
1、将寄存器出栈,还原需要保存的寄存器值
2、将ebp的地址赋给esp,即回收临时空间
3、ebp出栈,还原ebp值
4、ret 执行原来保存的当前指令的下条指令
对应于具体的汇编代码就是这样
- 1
- 2
- 3
- 4
- 1
- 2
- 3
- 4
对于上面调用一个函数的堆栈帧的过程大多数都是这样的情形,不过出现同时满足下面两种情况的话则不一定
1、函数声明为static(即函数仅能在此编译单元内访问)
2、函数在本编译单元中仅被直接调用,即没有任何函数指针指向过这个函数
这种情况下编译器能够确定在别的编译单元中不需要使用到这个函数,那么就可以随意修改这个函数的各个方面,其中也包括了进入与退出指令序列,从而达到优化的目的。
调用惯例
什么是调用惯例?简单来说就是调用方与被调用方约定好的一些规则,比方说函数参数的传递顺序和方式,栈的维护方式以及名字修饰等等。
一般来说C语言使用cdecl这个调用惯例,它是c语言默认的调用惯例,它的具体内容是:
1、参数传递方式:从右至左的顺序压参数入栈(对于一个foo(int m,int n )这样一个函数来说,参数入栈时的顺序就是先n后m)
2、出栈方:由函数调用方将参数等出栈(因此在前面的函数调用收尾的代码中,并没有出现对于返回地址上面的参数部分的出栈工作,它是由调用方完成的,对于被调用函数来说对其进行任何操作)
3、名字修饰:直接在函数名称前加1个下划线”_”
2、堆与内存管理
光有栈对于面向过程的程序设计还远远不够:栈上的数据在函数返回时就会被释放掉,因此栈无法将数据传递至函数外部;而全局变量能够将数据传递至函数外部却无法动态产生,只能在编译时定义,缺乏表现力。那么堆就是唯一的选择了。
堆时常占据虚拟空间的绝大部分,程序可以在其中申请一块连续内存并自由使用。
大家基本都知道如何申请一个堆空间,无非就是使用malloc函数,那么它到底是如何实现的呢?
有一个很简单的预想方法,将进程的内存管理交给操作系统内核去做,那么就需要提供一个系统调用,可以让程序调用它申请内存。
的确这样的方法能够行得通,但是这样每次申请堆空间的时候都需要进行系统调用,而系统调用对性能方面的开销很大,如果对堆的操作比较频繁,必然会影响到程序的性能。
如何解决这个问题?其实很简单,对文件的读写时用到缓存的技术来解决性能的问题,对于堆空间的申请问题在我看来也是一样,提前向操作系统申请一块适当大小的堆空间由程序自己管理就行了。实际的运行中,管理堆空间分配的往往是程序的运行库。
这样对堆的分配就有了大概的了解了,运行库向操作系统“批发”了较大的堆空间,零售给程序使用,当全部使用完或者程序有大量内存需求时,再向操作系统“进货”,当然,一件货物不能卖两次,于是运行库就需要有算法来管理堆空间,避免一段空间连续分配,这就是堆的分配算法,一般来说比较简单的有空闲链表、位图以及对象池等,不过很多实际的运用中,堆的分配算法往往采用多种算法复合而成。
Linux进程堆管理
最后了解下linux进程堆如何管理。
首先,Linux下进程堆管理提供了两种堆空间分配的方式,即两个系统调用:brk( )及mmap( )
这就是前文中说的“批发”堆空间的手段或方法,那么两种有什么区别呢?
1、bar( )
brk的实际作用实际上是设置进程数据段的结束地址,即它可以扩大或者缩小数据。想象一下,我们将数据段的大小增大,那么数据段多出来的那部分我们就能够使用了,将这块空间作为堆空间是最常见的做法。
另外,glibc中还有个sbrk函数,其实就是bar的包装,通过brk实现。
2、mmap( )
mmap的作用就是向OS申请一段虚拟地址空间,当然这块空间可以映射到某个文件,但是当它不将地址空间映射到某个文件时,我们就将这块空间称为匿名空间。
来看下mmap( )系统调用
- 1
- 2
- 3
- 4
- 5
- 6
- 1
- 2
- 3
- 4
- 5
- 6
那么我们基本了解了brk及mmap这两个申请内存空间的系统调用,最后来看看malloc如何处理用户的空间请求,基于前面的信息可以看出对于一定大小以内的空间申请时,是不会使用以上两个系统调用的。
实际上malloc对于小于128KB的请求,会在现有的堆空间根据堆分配算法分配一块空间并返回;大于128KB的请求则会使用mmap函数为其分配一块匿名空间,然后在这个匿名空间中为用户分配空间。
但是这里要注意,mmap是基于系统虚拟空间来申请函数的,申请空间的起始地址和大小必须是系统页的大小的整数倍,另外申请的空间大小不能超出空闲内存+空闲交换空间的总和。
前面说过实际情况下,堆分配算法往往采用多种算法合成,在glibc中就运用了这样的方法,malloc分配空间时不只是判断是否小于128KB,具体情况为:小于64B,采用对象池方法;大于512B采用最佳适配算法;大于64B但小于512B,根据情况选择最佳的折中策略,会在空闲链表、位图以及对象池几种算法中选择一种方法;最后,大于128KB则使用mmap机制直接向操作系统申请空间。
运行库
什么是运行库?它们是在程序背后默默服务的团体,它们能够使得程序正常地启动,使得各种我们熟悉的函数发挥作用。
1、入口函数与程序初始化
main真的是程序的起始吗?
我们编写每一个C程序都需要编写main函数,之前也一直都说main函数是程序的开始,但是真的是这样吗?其实程序执行到main函数的第一行时候,很多事情都已经完成,比如全局变量的初始化,比如命令行的参数传递,比如堆和栈的初始化,更比如一些系统I/O的初始化(可以放心使用printf、malloc等函数)都已经完成了,这些都说明了main并不是我们执行一个程序的真正开始,那么main之前都做了什么呢?
首先了解一下atexit函数,这个函数的调用时机是在main结束后,它以一个函数指针作为参数,保证在程序正常退出(从main函数返回或者调用exit函数)时,函数指针指向的函数会被调用
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
可以看到结果是首先执行了第二句的end of main,然后在main函数返回后执行了atexit注册的foo函数。
于是推断真正的过程是:
OS装载程序后,首先运行的是别的代码,它们负责准备好main函数执行所需要的环境,并负责调用main函数,在main返回后,其会记录main函数的返回值,调用atexit注册的函数,最后结束进程。
运行这些代码的函数称为入口函数,实际上就是一个程序的初始化和结束部分,它往往是运行库的一部分。
入口函数的实现
glibc的真正程序入口为_start,需要下载glibc的源码才能够看到,下载完成并解压后在其目录下的sysdeps目录下有着各种CPU型号的实现,由于在32位环境下进行实验,因此进入i386目录下在start.S文件中可以看到_start的具体实现。
图中就是具体实现的步骤,为了简单起见,这里删除了一些共享情况下的代码,这些是静态链接情况下的_start的具体实现,主要分为两部分,即图中的线分隔开的两部分内容。
其实这部分的道理很简单,我们来一句一句看
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
这是第一部分的功能,可以看到其实功能就是ebp清0表示最外层函数,esi中存放argc,ecx指向argv(这里还要注意到其实ecx也指向了环境变量,毕竟环境变量就在命令行参数后面)
下面来看第二个部分,不过第二个部分实际上就是压参数然后调函数,先了解这个函数的名称及参数
具体的文件位于glibc目录下的csu/libc-start.c文件中
可以看到,总计7个参数,这里我们按照之前讲过的调用惯例,从右向左压入参数来对比下面的具体的第二部分代码,对具体每一句的功能进行解释
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
这样第二部分的功能就很清晰了
那么接下来看__libc_start_main的具体工作,由于代码较多所以直接列出主要的部分
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
最后简单看一下exit的实现
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
调用_exit后进程会直接结束。程序结束一般有两种情况,一个是main函数正常返回,另一个是程序中exit退出。实际上可以看到不管是哪种最后都会使用exit退出的情况,因此exit是进程正常退出的必经之路。
实际上glibc的入口函数写得不是很直观,我们没有从glibc的入口函数中了解多少内容,而在Windows下则能看得比较清楚。将Windows下Visual Studio中的默认的入口函数mainCRTStartup的功能进行一个简单的概括,主要流程就是:
1、初始化和操作系统版本有关的全局变量
2、初始化堆
3、初始化I/O
4、获取命令行参数和环境变量
5、初始化C库的一些数据
6、调用main并记录返回值
7、检查错误并将main的返回值返回
这也是一个入口函数的实现上比较清晰的思路。
2、C语言运行库
任何一个C程序,背后都有一套庞大的代码对其支持,让其能够正常运行。前面了解的入口函数以及一些初始化所要使用的函数都必须包含在这个代码集合中,这样的代码集合叫做运行时库,而C语言的运行库,称为C运行库简称CRT
一个C语言运行库大致包含以下功能:
1、启动与退出:即入口函数及入口函数依赖的其他函数
2、标准函数:C语言标准库拥有的函数实现
3、I/O:I/O功能的封装和实现
4、堆:堆的封装和实现
5、语言实现:语言中一些特殊功能的实现
6、调试:实现调试功能的代码
glibc
glibc即GNU C Library,是GNU下的C标准库,关于其的一些版本细节什么的就不说了,主要介绍下几个除了C标准库之外的辅助程序运行的运行库,它们是/usr/lib/crt1.o、/usr/lib/crti.o和/usr/lib/crtn.o
首先是crt1.o,它包含了程序的入口函数_start,负责调用__libc_start_main初始化libc并且调用main函数进入真正的程序主体。
另外crti.o和crtn.o这两个目标文件中包含的代码实际上是_init( )函数和_finit( )函数的开始和结尾部分,因此将这两个文件和其他目标文件链接起来以后就刚好形成了_init( )和_finit( )两个完整的函数,实际上也就是最终输出文件中的.init和.fini两个段。
crti.o反汇编代码
crtn.o反汇编代码
可以看到两个目标文件中都包含了.init和.fini的代码,分别构成了两段的头和尾部
在链接时除上面的三个文件以外还有着其他文件,但是它们实际上不属于glibc,是GCC的一部分都位于gcc的安装目录下
/usr/lib/gcc/i686-linux-gnu/4.8/crtbeginT.o
/usr/lib/gcc/i686-linux-gnu/4.8/libgcc.a
/usr/lib/gcc/i686-linux-gnu/4.8/libgcc_eh.a
/usr/lib/gcc/i686-linux-gnu/4.8/crtend.o
其中crtbeginT.o以及crtend.o是用于实现C++全局构造和析构的目标文件,实际上glibc只是一个C语言运行库,它对C++的实现并不了解,gcc才是C++的真正实现者,于是由它提供了这两个目标文件配合glibc实现C++的全局构造和析构。
GCC是支持诸多平台的,而处理不同平台间的差异性也是其的任务,而libgcc.a就是用来正确处理不同平台的差异的,主要包括整数运算、浮点数运算,最后libgcc_eh.a包含了支持C++的异常处理的平台相关函数。