Linux---动静态库的加载与链接
预备知识(简单了解)
在我们学习C语言的时候就知道,main函数是程序的入口,程序运行时是从main函数开始往后执行的,但真的是这样吗?其实在Linux系统里,_start才是真正的程序入口,它会做一些前置工作,比如说根据用户传入的命令行参数来判断返回main()还是返回main(int argc.....),也就是说,_start()调用了main函数之后程序才开始执行的。
值得一提的是,每一条汇编语句在经过汇编再到后边链接之后在可执行文件里大部分都会形成一条指令,就是一串二进制序列,这条指令由指令长度,操作码,数据构成,全是二进制的,只不过一串二进制序列的不同部分表示这个含义。一个可执行文件在OS看来就是一串二进制流,将来PC就是根据每条指令前边的指令长度去找到下一条指令的。具体怎么理解还是按照前边博客里写的理解方式,这块是编译链接的东西,不用搞太深。
在CPU内部有个叫指令集的东西,程序编译成了二进制之后本质上就是一串指令集,指令集的意思就是一套步骤,就像起身走到桌前拿水再走回来这一套拿水步骤一样,将指令集交给CPU,它才能帮我们完成任务。从硬件角度,不同OS上跑的程序不能跨平台的本质原因就是每个OS底层的指令集不一样,从软件角度,就叫系统调用。
静态库链接过程
静态链接的本质就是把库中相关的代码拷贝到你的程序中。先来证明一下这个事情。
1.通过验证我们知道.o文件里如果有函数调用,在没有链接之前,将里边的二进制转化为汇编语言之后发现其实call函数的时候那个地址它由于不明确就随便搞了一个地址过去。也可以用查符号表的方式去验证这一点,具体的指令就是readelf -s 文件名(没链接前查的就是.o文件的符号表)。反正不管用哪种方式,总而言之就是没链接前调用函数的地方不知道地址是什么就直接先以全0代替。
2.链接之后(可执行文件假设叫main)你再用反汇编去查main就发现两个.o文件被合并了,并且之前不知道的地址也被填上了。这个过程就是静态链接,就是将多个.o的代码段合并到一个可执行文件里,然后进行统一的编址,并且会重写之前.o里不明确的函数地址,最后就能call地址就代码能跑了。(修改call 00000000地址的过程也叫地址重定位)。
静态链接的时候有没有加载过程?没有加载的过程,它是直接合并多个.o文件到一个文件并且会重写函数地址,静态链接的链接过程在进行链接的时候,在加载之前就全部完成了。
动态库加载过程
进程创建的时候是先会去创建内核数据结构(PCB....),虚拟地址空间,页表之类的东西,然后再去加载程序,如今,我程序里需要用到动态库,比如说C标准动态库,那我是先加载库还是先加载程序的代码和数据呢?答案是先加载库,要加载库就得先找到库,库的本质就是.o文件的集合,所以库就在磁盘里存着呢,根据之前文件系统的知识,到磁盘里去找文件的第一步就是要有文件的路径,所以先要有库的路径,而动态库的路径在上一篇博客里给出了4种方法。至此,库被加载到了内存里,然后程序的代码和数据也加载了进来,虚拟地址被划分好了,页表被填充好了,程序就可以运行了。
动态库被加载到内存里,对应的虚拟地址是存在虚拟地址空间里的共享区的,在代码区里的程序拿到了比如说printf的虚拟地址之后就会去共享区里找对应函数的虚拟地址,然后就调用它。说白了就是动态库的链接就是得跳转到共享区才能找到函数的地址,而静态库就直接就在代码区里就直接找到了,因为全部合并到一起了。
现在情景换成多进程,我现在加入说有两个进程都用到了同一个动态库,那我比如说A进程在运行的时候已经将库加载到内存里了,且A进程由于时间片轮转等各种原因导致还没结束,则等到B进程运行的时候就不用去加载这个库第二次了,就直接根据库在内存里的物理地址和虚拟地址去填充自己的页表进而映射到虚拟地址空间里的共享区就可以了。此时这个动态库被AB两个进程共享了,所以动态库也叫共享库。
库里边本质也是代码和数据,正是因为它可以共享,所以多个进程需要用到的资源,只需要在内存中形成一份,节约了空间。OS里有非常多的进程,用到的重复的不重复的动态库都很多,它们都要加载到内存了才能用,OS就必须要对这些加载进来的库进行管理,先描述再组织!具体细节不要深究了,比较复杂。
动态链接实际上将链接的整个过程推迟到了程序加载的时候,什么意思?就是上文在说静态库的时候不是查过可执行程序的符号表嘛,你会发现符号表已经能够找到call的地址了,原因就是动态链接的过程有一步就是重定位地址。动态库则不是,动态链接即使形成了可执行文件了,里边依赖动态库里的方法依旧是UND(undefined的),你随便找一个可执行文件然后readelf -s,查一查它的符号表你就清楚了,里边的地址依旧是不知道的,链接之后你仅仅只是知道这个程序需要哪些库,函数依旧不知道地址。只有当动态库被加载到内存之后才会知道函数的地址。
编译器也会有依赖的库(叫/lib64/ld-linux-x86-64.so.2,ldd可以查),当程序执行的时候,这个库也会加载进来,里头有一个函数就是上文说的程序真正的开始_start()函数,它会干一件重要的事情就是动态链接,最重要的一步就是把上文说的那些在链接的时候不知道地址的函数的地址做重定位(上文说过地址重定位什么意思)。
细节补充:代码区是怎么找到共享区里的函数的?不是链接之后明明库函数的地址依旧是全0吗?动态库里是没有main函数的,动态库内部包含了大量的方法,每一个方法都要有自己的地址,跟可执行程序一样,库里的编址也是采用相对编址,就是逻辑偏移地址,平坦模式的0000...0000~FFFF....FFFF编址,里边的每一个地址实际表示的是相对起始地址的偏移量。
来看下边的一张图,mm_struct里每一个区域都有表示该区域起始和结束的变量,程序在运行的时候先创建内核数据结构,虚拟地址空间,页表,然后就要加载库了,先在共享区创建一块跟库大小一样的区域,从而得到库起始的虚拟地址,根据起始虚拟地址跟每一个函数的偏移量(当初是以全0作为起始地址来搞接下来的偏移地址的,所以库里的地址全都是偏移量,都不用计算了)(没加载到内存也能读取磁盘去知道库ELF文件的header的信息,从而就知道它的大小,就可以分配空间了),直接根据现在给库分配的起始地址+本来就有的偏移量就得到每一个函数的地址,库的路径我是知道的,在运行的时候可以通过环境变量,默认路径....方式知道,所以就根据路径做路径解析......找到inode里i_block所指向的数据块,将库从磁盘加载到内存里,加载进来之后就有起始物理地址了,根据起始物理地址加偏移量得到每一个库函数的物理地址,最后填充页表。有了这种起始地址+偏移量的方式,就算把库离散加载到内存的各个位置也依旧没问题,因为只要知道起始地址+偏移量就行。
综上:我们就知道了,OS实现动态链接的过程就是对于在代码区里要调用的库函数来说,我知道它的偏移量,然后在库加载的时候,OS会根据共享区的起始地址+偏移量从而去修改代码中call后的地址。动态链接的过程由OS完成,它会先加载内核数据结构,再加载动态库(只有先加载了库才会先知道库的起始虚拟地址),最后加载代码和数据(边加载边完成动态链接)。
细节补充:地址重定位就是在修改call后面的内容,就是在修改函数地址,但call在代码区,代码区是只读的啊,怎么修改啊?动态链接的做法是在ELF的.data节中存放函数的跳转地址,叫做全局偏移表got,就是.dot和.data是ELF中的两个节,它们最后会被合并到program的同一个段里,call后编的实际上是got地址+表中的偏移地址(就是告诉你got表的地址和函数在got表中的偏移量),got相当于一个数组,知道起始地址和偏移量就能找到某一个下标,从而找到里边的数据,在数据区里存了got,里边存着被调用的每一个库函数的偏移地址和对应动态库的起始虚拟地址,所谓的地址重定位改的是got表里存的地址。
