当我们写好了一段代码然后编译运行后会生成可执行文件,该文件会存在磁盘的当前目录下,而当我们开始运行这段程序时,操作系统(加载器)需要将其从磁盘加载进内存然后执行相关操作,而对于用到动态库的程序,同时也会将动态库加载进内存中。
以下的讲解以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。
而内核区的存在就保证了内核不受用户的干扰。也就是当我们访问内核数据时需要访问通过内核区,然后调用内核区提供的接口从而进行内核数据的访问与接收等。而且每个进程虚拟地址空间的内核区都是独立的(物理上共一份),所以进程间不会进行互相干扰。
程序运行时操作系统的工作
创建进程与初始化
- 操作系统首先会创建一个进程,也就是会为进程分配一个唯一的进程PID,用于表示当前进程,以便操作系统能够更好地进行管理。
- 同时操作系统内核会在内存管理中划分专门的区域为该程序创建一个进程PCB(其中存放了进程状态、程序计数器、相关数据寄存器内容、进程优先级),从而对该进程进行管理。
- 为进程分配一个独立的虚拟地址空间,以便确定程序和数据在地址空间的布局。
- 进行初始化创建页表,建设虚拟地址与物理地址的映射框架(后期进行数据填充)。
程序加载和内存映射
- 操作系统加载器会将可执行程序文件从磁盘加载进内存。实际上就是通过解析磁盘可执行程序文件的ELF格式来确定程序的入口和代码段数据段的位置,按照其格式规定的布局加载程序进内存中,同时在地址空间为其分配地址范围。
- 如果程序链接了动态库的话,系统会会先进行检查内存中是否已经存在该动态库数据信息,如果存在的话后续就直接页表映射,不存在的话就加载进内存中。
- 进一步的完善页表的映射,将程序所在的虚拟页和实际的物理页框进行映射。我们的程序在编译链接形成可执行程序时,虽然此时还没有进行运行,也就没有加载进内存,但是其内部代码数据就已经采用逻辑地址的方式进行编址好了,也称作逻辑地址。当程序开始运行加载进内存以后,这些逻辑地址会被操作系统转换成地址空间的虚拟地址和物理内存的物理地址。所以此时页表也就构建出虚拟地址-物理地址的映射关系。但是页表并不是一次性完成所有页面的映射,对于动态分配的数据和还未访问的数据部分,其页表项并未填充。
CPU调度执行
- 操作系统在就绪队列中根据调度算法选择一个进程进行执行,此时程序才正式开始运行。
- 当CPU开始执行进程指令时,遇到内存访问指令的话,会先通过页表将虚拟地址转换成物理地址,进而去访问物理内存中的数据。如果是首次访问某个页面的话,则会发生缺页中断。此时操作系统会暂停处理当前运行的进程,去进行缺页中断处理,也就是访问磁盘空间,将所需页面加载进物理内存,重新在页表中构建映射关系,处理结束后就恢复进程的运行。
- 进程运行的过程中可能会进行系统调用,也就是访问内核空间,此时会进行用户态到内核态的切换。操作系统会根据系统调用的函数进行处理。
异常与中断处理
- 当程序运行的过程中如果出现了异常情况,如除零错误,越界访问等情况时,会产生一个异常信号,操作系统会暂停当前进程的运行,进而对信号进行相应的的处理。
- 外部设备产生中断,如键盘摁下Ctrl c或者网络数据到来等情况,操作系统会暂停当前进程的运行,进而处理中断,也就是根据中断调用对应的中断处理程序,后续处理结束也可能会恢复中断的进程,继续从上次中断的位置开始执行(PCB中的程序计数器会保存下一次的执行指令)。
动静态库对比
- 静态库在编译链接的过程中就直接将程序代码中所用到的静态库方法直接拷贝进可执行程序里。然后在运行的时候就将含有静态库方法的可执行程序一起加载进内存中。所以我们多个程序运行都用到了同一个静态库的话,那么内存中就会存在多份,此时对于内存空间就是一种极大的浪费。
- 动态库则是在程序运行的过程中被加载进物理空间中,而动态库中的方法被调用时才会将动态库加载进共享区。动态库在物理内存中只会存在一份,而每个使用了动态库的进程会在个地址空间的共享区中通过页表直接和物理内存中的动态库构建映射关系。动态库的共享区从而就节省了内存空间。
首先我们知道当形成动态库时,编译和静态库一样,需要包含库文件的路径与库名称,但是在运行时,静态库可以直接进行运行,而动态库运行时需要和库文件在同一目录下或者建立软链接,或者将库文件拷贝到lib64目录下,这样在运行时就会默认去该库下寻找所需库文件,或者直接去/exc/ld.so.conf.d目录下更改动态库配置文件,也就是创建一个文件,同时将我们库的绝对路径写进文件中。因为动态库并没有将库中的数据拷贝进代码中,所以在运行时需要寻找动态库的所在位置,然后才可以调用动态库中的方法。而且动态库形成过程时,生成.o文件时需要带上-fPIC(与位置无关码)。
地址空间中的函数调用
函数调用
在程序进行编译时,会通过程序入口地址确定函数的偏移地址。在链接阶段这些相对地址会在虚拟地址空间中转换成绝对地址。后续在虚拟地址的代码段中进行函数调用时直接通过存放下一条指令地址的寄存器找到函数入口地址直接进行调用函数。
动态库函数调用
动态库由于是在程序运行时才加载进虚拟地址空间的共享区,但是动态库在虚拟地址空间的加载位置是不确定的,所以说,动态库中的函数地址也是不确定的。而想要通过虚拟地址调用对应的动态库函数就与动态库生成与位置无关码有关联。
需要用到动态库的程序被编译生成可执行程序时,其会保存所用动态库函数的一个相对偏移量。也就是所调用的动态库函数相对于动态库的起始未知的偏移量。
而当程序运行时,动态库的信息和程序用到动态库函数的偏移量关系也会加载进内存中。
而且当程序的代码段中使用到了对应动态库中的函数方法时,动态链接器才开始将动态库加载进虚拟内存的共享区中的某个位置。因此就确定好了库的起始地址,那么通过偏移量就可以将库函数映射到共享区中的具体位置(那么说也就确定了绝对地址)。那么当程序调用动态库函数时,通过动态库起始地址+偏移量的方式进行调用对应动态库函数。