用到动态库的程序运行过程
当我们写好了一段代码然后编译运行后会生成可执行文件,该文件会存在磁盘的当前目录下,而当我们开始运行这段程序时,操作系统(加载器)需要将其从磁盘加载进内存然后执行相关操作,而对于用到动态库的程序,同时也会将动态库加载进内存中。
以下的讲解以Linux操作系统为例。
进程地址空间
在此之前首先我们来认识一下进程地址空间:
首先我们要知道进程地址空间是每一个进程独有的一份虚拟地址空间,并不是物理上的地址空间,地址空间存在的意义就是更好的划分和管理进程数据, 而实际上是采用页表来与物理地址构建联系的。而当CPU处理虚拟地址中的数据时,操作系统的MMU(内存管理单元)主要负责虚拟地址和物理地址之间的转换。
代码段
我们程序其实就是从main函数开始的一个个函数集合。而这一个个函数(不包括变量)其实本质上就是一系列的指令组合,所以函数指令其实就是存储在代码段。而这函数指令是不可修改的,也就是只读权限。
数据段
数据段包括已初始化数据段和未初始化数据段。而初始化数据段又包括全局变量和静态变量,而未初始化数据段也就是未初始化的全局变量和静态变量。其实数据段也叫做全局区和静态区。
堆区(向上增长)
堆区主要负责动态内存分配,如我们C语言的malloc函数和C++的new运算符,都是常见的用来申请堆区空间的,而堆空间要记得使用过后释放,否则会造成内存泄漏的风险,最终导致程序内存不足而卡死。
共享区
共享区属于内存映射段的一种,而内存映射段主要是用于将磁盘文件内容映射到虚拟内存中的,这其中就设计到页表和缺页中断。而我们所说的共享区是主要用来进行共享库加载和进程间通信使用的。其实共享就是多个进程共享的数据,其实就是在内存中其实只有一份,但每个进程的虚拟地址空间都会映射一份。
栈区(向下增长)
栈区用于存储局部变量,函数参数以及函数调用的返回地址信息。而我们每当调用一个函数时,其所用到的这些数据都会存在栈区,而其他的函数指令就存在代码段。
命令行参数和环境变量
- 命令行参数其实就是我们main函数原型的参数:
int main(int argc,const char* agrv[])
而我们在运行可执行程序时附带的参数其实都会当作调用main函数的参数传递过去,而argc就是参数数量,argv就是参数内容。如:ls -a -l其实就是就是-a和-l就是参数,而argv[0]就是ls,也就是程序名。
- 环境变量存储量了用户定义的全局设置信息,如:PATH环境变量就定义了系统训中可执行文件的目录,如ls指令其实就是一个可执行程序,而我们自己写的可执行程序需要加上./才可以运行。而ls不需要的原因就是ls程序的所在路径已经设置进了PATH环境变量内部。
内核区
内核区是供给操作系统使用的区域,其中存放的是内核的代码和数据,如系统调用函数和内核全局变量等信息。而且内核区还负责管理进程的PCB。
而内核区的存在就保证了内核不受用户的干扰,也就是当我们调用内核数据时需要访问内核区,然后调用内核区的调用接口从而进行内核数据的访问与接收等。而且每个进程虚拟地址空间的内核区都是独立的(物理上共一份),所以程序间不会进行互相干扰。
程序运行时操作系统的工作
就绪工作
创建进程
操作系统内核会在内存管理中划分专门的区域为该程序创建一个进程PCB,从而对该进程进行管理。而PCB中的部分信息会被直接初始化,如:进程pid、进程状态(就绪态)、进程默认优先级、程序计数器(记录程序的入口地址)。并且会为进程分配一个独立的虚拟地址空间。
资源分配
初始的内存大小分配、CPU调度时间分配(具体时间取决于后期CPU调度时会根据调度算法进行时间分配)。
程序加载
就绪工作做完以后,加载器就会将程序从磁盘加载进内存,也就是创建地址空间。实际上就是通过解析磁盘文件的ELF格式来确定程序的入口和代码段数据段的位置,如果程序链接了动态库的话还会记录所用到的动态库函数在内存中的加载位置。然后开始按照程序文件信息,将程序的代码段、数据段分别加载到分配好的内存区域中。对于共享数据部分会先进行检查内存中是否已经存在,如果存在的话后续就直接页表映射,不存在的话就加载进内存中。
建立程序执行环境
也就是将PCB中的字段进行赋值的过程。将程序计数器设置为程序的入口地址。初始化栈指针ESP寄存器,为栈空间分配初始地址(寄存器中存的都是虚拟地址)。同时还会进行其他的寄存器数据的初始化。
页表操作
页表就是建立虚拟内存和物理内存的映射关系,新加载进内存的数据直接在页表中构建映射关系。对于映射关系中的物理地址如果不存在时则会发生缺页中断,根据页面信息在磁盘的位置进而找到对应页面,将其读进物理内存中,同时进行页表的更新。
我们的程序在编译链接形成可执行程序时,此时还没有进行运行,也就没有加载进内存,但是其内部代码数据就已经采用虚拟地址进行编址好了,也称作逻辑地址。当加载进内存以后,其内部代码数据就都会有对应的一个物理地址。所以此时页表也就构建出K-V结构的映射关系。但是页表并不是一次性完成所有页面的映射,对于动态分配的数据和还未访问的数据部分,其页表项并未填充。
调度执行
操作系统在就绪队列中根据调度算法选择一个进程进行执行,此时程序才正式开始运行。
动静态库对比
- 静态库在编译链接的过程中就直接将程序代码中所用到的静态库方法直接拷贝进可执行程序里。然后在运行的时候就将含有静态库方法的可执行程序一起加载进内存中。所以我们多个程序运行都用到了同一个静态库的话,那么内存中就会存在多份,此时对于内存空间就是一种极大的浪费。
- 动态库则是在程序运行的过程中被加载进物理空间中,而动态库中的方法被调用时才会将动态库加载进共享区。动态库在物理内存中只会存在一份,而每个使用了动态库的进程会在个地址空间的共享区中通过页表直接和物理内存中的动态库构建映射关系。动态库的共享区从而就节省了内存空间。
首先我们知道当形成动态库时,编译和静态库一样,需要包含库文件的路径与库名称,但是在运行时,静态库可以直接进行运行,而动态库运行时需要和库文件在同一目录下或者建立软链接,或者将库文件拷贝到lib64目录下,这样在运行时就会默认去该库下寻找所需库文件,或者直接去/exc/ld.so.conf.d目录下更改动态库配置文件,也就是创建一个文件,同时将我们库的绝对路径写进文件中。因为动态库并没有将库中的数据拷贝进代码中,所以在运行时需要寻找动态库的所在位置,然后才可以调用动态库中的方法。而且动态库形成过程时,生成.o文件时需要带上-fPIC(与位置无关码)。
地址空间中的函数调用
函数调用
在程序进行编译时,会通过程序入口地址确定函数的偏移地址。在链接阶段这些相对地址会在虚拟地址空间中转换成绝对地址。后续在虚拟地址的代码段中进行函数调用时直接通过存放下一条指令地址的寄存器找到函数入口地址直接进行调用函数。
动态库函数调用
动态库由于是在程序运行时才加载进虚拟地址空间的共享区,但是动态库在虚拟地址空间的加载位置是不确定的,所以说,动态库中的函数地址也是不确定的。而想要通过虚拟地址调用对应的动态库函数就与动态库生成与位置无关码有关联。
需要用到动态库的程序被编译生成可执行程序时,其会保存所用动态库函数的一个相对偏移量。也就是所调用的动态库函数相对于动态库的起始未知的偏移量。而当程序运行时,动态库的信息和程序用到动态库函数的偏移量关系也会加载进内存中。而且当程序的代码段中使用到了对应动态库中的函数方法时,动态链接器才开始将动态库加载进虚拟内存的共享区中的某个位置,从而就确定好了库的起始地址,那么通过偏移量就可以将库函数映射到共享区中的具体位置(那么说也就确定了绝对地址)。那么当程序调用动态库函数时,通过动态库起始地址+偏移量的方式进行调用对应动态库函数。