动静态库的简单制作
下面我们就进入动静态库的过程。
静态库的制作
我们通过下面的步骤来理解动静态库。
静态库不存在加载。
下面我们制作一个简单的库。
首先所有的静态库的名字一般都是libXXX.a而动态库的名字一般都是libYYYY.so。而和静态库相对存在一个叫做静态链接的东西,和动态库相对的还存在一个叫做动态链接的东西。
而静态链接是指在编译时将程序所需要的库文件(如函数库)的代码全部复制到可执行文件中,使得可执行文件不再依赖于外部库文件。这样做的好处是可以使得程序在不同的环境中运行时不需要再安装相应的库文件,也可以避免因为库文件版本不同而导致的兼容性问题。
而动态链接则是将我们调用的库函数(如printf等等)将这些调用的库函数的地址填写在调用处即可。而在gcc中默认使用的是动态链接。而库的命名中你将后缀(.a,.so去除),前缀(lib去除)之后的名字也就是库的名字。到现在为止我们对于库的理解只停留在表面,下面我们先来制作一个静态库。然后我们将设计的这个静态库使用一下。
首先我们建立两个文件(这里就简单实现一个+-*%的功能了)。将这个代码的声明放在头文件中,将实现放在源文件中。
我现在整体就是使用这两个简单的文件来形成一个库文件。
下面是头文件中的内容:
上面的errno是声明,下面再.c文件中才对这个变量进行了定义。
其中的stdio.h你加不加都可以。
下面是.c文件:
因为我们使用了全局的变量extern所以需要在.h文件中声明一下。
到这里我们就完成了一套方法的声明和实现。
下面如果有人想要使用我写的这个方法。有以下几种做法:
第一个我直接把源文件给别人,将.h和.c文件直接给别人。当然可以。
但是如果今天我不想要别人能够看到我们的源代码呢?
那么这就是第二个方法了把我们的源代码打包成一个库。
到时候将这个库和.h文件给别人使用一样能够让别人去使用我们的代码。
那么能否头文件都不给呢?答案自然是不能,因为在使用者看来.h文件就是告诉使用者这个库函数如何使用的,所以.h文件是一定需要给别人看的。头文件的本质就是你所提供的库函数的一份使用说明书。这就是为什么虽然C语言的库函数中已经存在了.a,.o等等的文件。但是还是需要给我们使用者提供一份.h文件。
那么下面我们简单分析一下:假设现在这里存在很多.c(我写的代码),然后使用者存在一个main.c的代码。在main.c中使用了我写的函数。下面如果我是将我的源代码直接给了使用者,那么就是使用者的机器会在编译阶段直接将这些.c文件和main.c文件一起编译形成可执行文件。
而静态库就很简单,反正你使用者也是需要将我的代码先编译成.o文件,然后在链接的时候将我库中代码形成的.o文件和main.c形成的.o文件链接起来。那么我们就提前将库中的.c文件编译形成.o文件,将这些.o文件集合成为一个静态库,到时候将这个静态库和头文件给使用者,然后使用者在链接的时候,拿我的这个静态库和mian.c编译形成的.o文件一起一样能够做到使用我的库函数的目的。原理图下面:
Makefile文件。
需要注意的是:上面//包括后面的内容都需要删除。不然无法运行
其中的ar命令用于生成静态库
上图就是使用add.o文件和sub.o文件打包形成一个libmymath.a的文件。如果add.o或者sub.o文件不存在会进行创建(带了rc选项)。
下面我们就来运行一下:
此时这里就存在了一个静态库,我们这里就是将mymath.o打包形成了一个静态库,如果你存在更多的.o文件你也是做一样的工作。将那些.o文件都打包形成一个静态库。
然后我们在完善一下Makefile文件中的清理选项。
那么现在库我们已经完成了,形成库的原理就是你将你写的.c文件编译形成.o文件,然后让.o文件(多个/单个)打包成为一个库文件就完成了。
发布静态库
下面我想将我的这个库,发布一下让别人能够直接使用我的静态库。
其实发布也就是创建几个文件夹然后将我们的.h文件和静态库移动过去即可,然后删除的时候子啊将lib删除了即可
下面我们如果想要形成一个库make一下即可,如果你想将这个静态库和头文件发步出来(将库和.h文件分开)直接make output一下即可。如果你想删除直接make clean一下即可
然后未来如果别人想要使用这个库函数,直接将这个lib给别人即可。
下面我们就来模拟别人使用一下我的这个库。
此时用户已经将我的静态库拿到了。
使用静态库
怎么使用呢?
我们这里创建了一个main.c,然后在main.c中使用库函数。
我们下面如何去编译呢?
此时如果你直接使用gcc去编译这里是无法通过的。
使用静态库的第一种方法
因为gcc在编译的时候,对于mymath.h文件,gcc在编译的时候,只会在当前的目录,或者是os能够进行搜索的那个目录下下去搜索,但是很显然这个文件在这两个地方是都不存在的。所以这里会出现这个无法找到的错误。
那么第一种做法就是gcc具有一个选项,这个选项-I这个-I选项就是告诉gcc如果你在当前目录和系统目录下都没有找到这个文件那你就去我指定的目录下去寻找这个文件。
此时虽然还是存在报错,但是这个错误很显然已经不是头文件找不到了。
当然你也可以直接在文件包含的时候直接将这个文件的绝对/相对路径写上面也是可以解决找不到头文件的问题的。
但是我这里还是建议在编译的时候,带上搜索选项。这样我们才能理解gcc在编译的时候其实是存在一个查找的动作的,而我们是可以控制这个查找的动作的。
然后通过上面的错误信息我们可以知道这是在链接的时候出现的错误。
此时虽然已经找到了add函数的声明但是add函数的实现gcc没有找到,所以会出现这个错误,即这里没有将我们的静态库中的.a文件拿去编译。即没有找到静态库,这就是之前的问题,他只会在系统和当前路径下找库,但是我们的库藏的很深。
因为我们进行编译.o文件是能够运行的:
所以我们需要再带上-L。
细节这里我们没有指定连接哪一个库即使这个文件中只有一个库。
我们再使用.c系统函数的时候,不需要指定这些,因为系统目录已经将这些文件放到了gcc能够搜索到的系统目录下了。
然后发现这个报错还是存在。此时我们虽然已经告诉了gcc这个头文件在哪里,库文件在哪一个路径下。但是在库文件的路径下可能还是存在很多库的,所以这里你需要显性的告诉gcc你要链接的是哪一个库。
那么为什么头文件不用给呢?因为在代码中已经告诉了gcc你要使用的头文件,但是库你只有告诉gcc这个库在的路径但是没有说明是哪一个库。
这里需要增加一个-l选项告诉gcc需要连接的库
但是还是报错,原因是静态库的真实的名字应该是去掉lib和.a,然后一般在-l后面紧跟上库的名字。
此时终于形成了可执行程序。到这里我们才将库给使用了起来。所以我们需要将库使用起来,需要带上这一大串的内容。
当然你不带这个选项也是存在方法的下面会说明。
总结一下就是在gcc处选项带上-I是告诉gcc编译器,这个代码需要的头文件在哪里,不需要写到底是哪一个头文件,因为在我们的代码中已经告诉了gcc编译器要使用的头文件是哪一个(所以-I选项只需要说明路径即可),而我们需要使用的静态库因为我们在代码中并没有告诉gcc要使用哪一个静态库,所以在前面说明了静态库所在的路径之后,需要在-l后面说明是哪一个静态库。
但是为什么我们在之前只使用c库中的函数的时候从来没有过这些指明的步骤呢?那是因为c/c++所提供的那些库本来就是一些默认的库,gcc/g++编译器在编译的时候本来就能找到,也必须能找到。总结一下除了os提供的系统接口(例如fork,wait等等)是由os提供的接口,以及语言提供的各种库,你不需要指明。除此之外我们使用的其它库,其实gcc大部分都不会认识。而这样的库我们也就可以理解成第三方库,那么第一和第二方的库,你可以理解成os和语言提供的库。除了前两者之外的库我们称之为第三方库,所以你在使用第三方库的时候必须使用-l选项指明你要连接的库名称。
下面回到之前我们继续讲解静态库的使用,我在这里使用以下:我的静态库中提供的除法:
这样运行出来的结果我们就知道是出现了除0错误:
因为此时的错误码不是0。这么做的原因是如果在某一个代码中并没有打印出除数和被除数,那么我们就不知道这个-1是正确结果还是一个错误结果,通过错误码我们就能够知道了这个-1是错误的结果。
不能使用下面的写法:
因为在c语言中printf函数是从右往左进行形参实例化的,所以这里就是先打印了errno为0,然后打印-1的时候errno才被改变了。而这个出错码的作用就是为了告诉我们用户,这里出现了除0错误。
这里也就是虽然我们自己写的这个静态库很小但是这个库也提供了一个全局变量给用户(errno),这个全局变量在头文件中被声明,在静态库中被定义,在整个库中只有一份。所以当用户出现了除0错误之后,这个全局变量就会被修改了。
静态库使用的小总结
以下就是两个小点
我们知道.o文件在形成可执行程序的时候,要么使用的是动态连接,要么使用的是静态连接,那么我们如何去看呢?这里使用的是ldd命令。
其中的第一行和第三行我们不需要理解第三行是Linux中的加载器,第二行是c标准库,但是为什么没有看到我们写的那个静态库mymath的连接呢?
首先第一个gcc在连接程序的时候默认使用的是动态的连接。使用ldd也证明了这一点。我们的a.out这个程序可以执行说明在这个可执行程序中已经连接了我们的静态库。由此我们能够得到一个结论,gcc在连接库的时候,如果你提供的库既具有动态库又具有静态库,那么gcc一定只会连接动态库(在gcc选项的时候不加static选项,即static只是一个建议选项只有在库既具有动态和静态是才有用),但是如果你提供的库只具有静态库,那么gcc在连接的时候才会使用静态链接库。
除此之外我们也看到了动态库和静态库是可以混合连接的。同时也能够得到一个结论:
使用静态库的第二种方法
那么如果我在连接的时候不想使用这么一大堆有没有什么方法呢?
答案当然是存在的。
我们只需要将自己的头文件和库文件拷贝到系统的默认搜索目录 就不需要加很大的一行了(不需要使用-I和-L)。
首先是头文件的拷贝,这里因为是往系统目录下拷贝所以需要提权。
然后是库文件的拷贝。
下面我们进行编译:
此时报出的错误很明显是头文件已经找到了,但是没有连接到库,原因自然是在lib64目录下,存在了太多的库,gcc不知道要连接哪一个,我们依旧要使用-l指明要连接的库是哪一个。
指明后编译成功了。
运行也就成功了
那么我们上面所做的两个cp是在做什么呢?很明显那个时候我们在做的事情就是库的安装。
所以所谓的安装,本质上就是将头文件拷贝到系统路径下,把库文件拷贝到系统路径下。此时这就是安装了,那么卸载就是删除特定路径下头文件和库文件了。我们也并不排除某些安装是很复杂的,可能还包含什么导入环境变量等等的操作。但是我们抛开这些去看本质所有的安装行为本质就是拷贝。虽然我们使用的库没有这么难,我们一般使用的库都是会提供一些脚本语言的安装文件,你直接去运行这个安装文件就可以完成上面的一系列的操作了(和我们使用的Makefile文件差不多)。而我们在上面所做的cp动作是所有的Linux中的安装脚本所作的核心的工作。即使你将第三方库已经安装在了系统路径下了,你在使用的时候也必须指明库名称。
但是我们自己测试时写的库还是不建议这么玩,如果你花费了很长的时间去写的库,你自己想也是可以这么搞的。
原因是我们自己测试时写的库可能会导致污染。
那么除了这种方法之外还有没有其它的方法呢?
当然存在。
使用静态库的第三种方法
当我们将库和头文件从系统路径下删除之后,再次编译main.c就会报出找不到头文件了。
原因自然是os找不到了。
那么第三种方法自然能使用软连接了。
我们在系统路径下建立一个指向我们自己头文件的一个软连接。
由此我们的代码也需要改变一下:
头文件已经处理完成了下面自然就是库文件的处理了。
依旧是在系统的路径下建立一个软连接。
此时就能够再次编译并且重新运行成功了。
由此我们使用软连接也完成了使用我们自己的静态库。动态库的使用也是一样的,你也可以使用这种建立软连接的方法。在系统的默认搜索路径下建立一个软连接。 此时不需要拷贝了。但是这种方法也是不推荐的。对于别人的第三方库别人是怎么使用的你也跟着使用就可以了。这里只是说明了一种软连接的使用方法而已。即软连接是有用的。你想快速找到某一个东西你就可以建立软连接。
以上我们就暂时的将静态库搞定了。下面我们就来学习动态库,那么动态库是怎么走的呢?
动态库
动态库的讲解方法和静态库一样我们依旧是先在制作者的角度将一个动态库制作出来,然后使用这个动态库,最后来讲解动态库的连接的知识点。
动态库的制作
这里我们制作动态库的时候,重新制作一批接口,并且让动态库和静态库不分开,让代码在连接的时候,能够动态库和静态库一起连接。
首先首先就是制作出一些动态库的文件了。
此时我们就可以将上面四个文件中的.c文件打包成为.c文件了(完成制作的工作后)
首先我们将头文件制作必须的几个文件写到两个头文件中。
然后我们每一个头文件提供的方法是不一样的。
这是myprint.h头文件提供的方法
下面是mylog.h提供的头文件
然后就是在.c文件中实现这些方法了
mylog.c
myprintf.c
此时我们做的工作只不过是完成了两个不同的方法的声明和定义而已。
下面我们需要将myprintf.c和mylog.c两个文件打包形成动态库。
思路其实是非常一致的。
首先无论是动态库还是静态库我们的第一步都是要将对应的源文件形成.o文件。因为无论是静态库还是动态库都是和使用者的main函数在连接的时候使用的。
和静态库唯一的不同就是在编译的时候我们需要给动态库的gcc增加一些选项,这些选项是什么意思,我们之后会说明。因为虽然动态库和静态库的核心思路是一样的,但是终究这是两个不同的库,所以形成的方法自然也是不同的。
我们先在命令行操作以下,之后在写到Makefile中。
什么是与位置无关码后面会解释。
下面是第一步形成对应的.o文件
使用gcc -fPIC -c <.c文件> 你不注明输出的文件名,那么形成的就是同名的.o文件
那么下一个工作就是将这两个.o文件进行打包形成库。如何打包呢?
ar命令是专门用来打包静态库的,而动态库的打包我们依旧是使用gcc来打包。
而这也是为什么gcc在具有动态库和静态库的文件,默认使用动态库的(除非用户强制),因为动态库就是使用gcc打包的。
使用的命令:
gcc -shared -o <形成的动态库名称> <使用的.o文件>
下面就是使用这一行的所有的.o文件打包成为了一个动态库
此时就形成了动态库,在gcc处使用-shared选项表明这是形成的不是一个可执行的程序,而是一个动态库。
动态库是不能主动执行的,虽然动态库具有x权限。因为动态库没有main函数的。
那么为啥那么动态库要具有可执行呢?而静态库没有可执行的权限呢?
很简单因为静态库以后就是用来提供二进制源代码的。所以当你使用静态库的时候,要形成主程序就是要将静态库中的二进制代码直接拷贝到对应的主程序中的。编译完成之后这个主程序和库就没有关系了,最重要的是静态库是不用加载到内存中的。动态库不一样,动态库最重要的是要和你的可执行程序产生关联。当你的可执行程序要访问动态库中的内容的时候,需要跳转到动态库中。因为需要跳转到动态库中执行也就注定了动态库必须被加载到内存中。而要想动态库能够被加载只有具有可执行权限,才能做到快速加载到内存中去(要和可执行程序一样)。由此os给动态库加上了可执行权限。
由此我们就知道了可执行权限其实就是我们当前的文件,是否会以可执行程序的形式加载到内存中去。这就叫做可执行权限,虽然动态库没有main方法但是具有方法,并且库以后会作为可执行程序的一部分,所以动态库也算是可执行权限的一种。由此动态库是具有可执行权限的,总结就是动态库不是不能执行而是不能单独的执行。动态库需要被人来使用。
下面我们将形成动态库的方法写到Makefile文件中。我们此时的Makefile不仅要形成静态库还要形成一个动态库。
下面就是Makefile文件:
需要注意最后需要将中文字删除才能正常的运行。
运行:
发布:
下面就是mylib
此时的这一个代码就能够形成了两个库。
此时我们就可以将我们的mylib打包给别人去使用了,别人通过头文件就能够知道我提供的方法如何使用了。你不能在你的库中改一个main函数如果你在库中搞了一个main函数,那么就没有人能够使用你的库了。
动态库的使用
下面我们就来使用一下动态库和静态库。
首先就是main函数
下面就是编译:
但是为什么会报错呢?明明我已经编译形成了动静态库了。
而我们常说的站在巨人的肩膀上,巨人的肩膀也就是-l后面的库了。这样我们就能够使用库中的函数了
如果你将动态库中的函数注释之后,再编译运行:
可以正常地运行这说明了,动态库和静态库的使用是存在不同点的。
因为动态库还存在一个加载的问题。
这里我们对这个a.out使用一个ldd命令查看一个这个可执行文件的动态库的文件。
我们发现了这个动态库的名字,证明了这个a.out确实是一个使用了动态库的可执行程序,但是发现我们写的那个动态库os并没有找到这就很奇怪了,我们不是在gcc编译的时候,告诉了编译器这个库所在的位置吗?为什么这里会出现这样的问题呢?这就是为什么会报出在加载这个库的时候,发现这个库不能被打开,原因没有找到这个库。这也和ldd中的not find是符合的。
那么为什么呢?我们不是在gcc编译的时候说明了吗?
但是当我们的程序形成之后,和编译器还有关系吗?答案自然是否定的,当你的程序形成之后,就和编译器没有关系了。但是os(加载器不知道)。
所以:
如何让可执行程序找到动态库
那么为什么会找不到呢?并且我们之前在使用c语言提供的标准库的时候,为什么c语言的动态库就能够找到呢(加载的时候)?
原因很简单因为加载的时候也需要对应的路径。
那么如何让os找到这个动态库呢?做法很多,其中的一种就是将我们的这个动态库拷贝到系统的lib64目录下面。
这里就不再演示了。
除了这种做法之外还有其它三种做法。
第二种做法自然就是建立软连接了。
当建立了一个软连接之后,a.out这个可执行程序就能够找到这个动态库了(注意这里我甚至没有重新编译)。自然也就能够运行了
而我们平时使用的c/c++的库函数,一般都是使用的动态库,而可执行程序能够找到就是在os的默认搜索路径下能够找到语言提供的动态库。此时我们才能够无障的去编程。
此时我们就找到了两个方法让系统能够找到对应的动态库:
那么除了这两种方法之外还有没有其它的做法呢?
还有一种做法:
在我们的os中是存在一个环境变量。
这个环境变量大概率在你的们没有配置过的Linux系统中是不存在这个系统变量的。如果你是云服务器那么这个环境变量默认是不会帮助我们建立的。不排除虚拟机上是存在的。
这个环境变量的作用是专门用来提供给用户,用以搜索用户自定义的库(动态库)路径的。
所以如果你想要将自己的库交给别人使用,你可以在别人的这个环境变量中增加我们对应的那个库的路径。增加之后,os就能够找到这个动态库了。
下面我们就来导入
注意这里只需要到达你需要使用的这个动态库所在的路径就可以了。因为可执行程序需要连接哪一个库可执行程序已经知道了
增加之后可执行程序也能够找到这个动态库所在的位置了。
由此这就存在了第三个方法了。
所以系统的环境变量是很重要的。对于os中的环境变量你见到的使用场景越多,你理解的也就越深刻。
所以某些环境变量就是用来提供给系统中的编译器,加载器等等,用于搜索需要的文件所在的路径的。
回到这里这里的这个LD_LIBRARY_PATH这个环境变量和静态库是没有任何的关系的。因为静态库只要在编译时能够找到那么它的历史任务也就完成了。这也更加验证了我们之前认为的静态库只需要将库中的二进制代码拷贝到可执行程序中就可以了。因为拷贝了之后,在加载可执行程序的时候,这个程序和静态库是没有关系的。所以静态库不需要加载因为,程序已经在我们使用静态库的那个可执行程序中了。这也是为什么默认静态库没有x的原因。
但是如果我这里将xshell给关闭了,再次打开这个环境变量就又不存在了。但是动态库一般而言是要被很多的软件使用的,如果你想要保证你的库是长久有效的话,你可以将上面的操作添加到系统启动时所对应的脚本里面。
正是因为这个脚本的存在在我们启动xshell的时候,我们的云服务器中才会具有这些环境变量。
如果你想要让你的路径一直存在于LD_LIBRARY_PATH这个环境变量中的话,你只需要在这个bash_profile文件中写就可以了。对应的脚本语言导入你的路径就可以了。
那么如果我们不在bash_profile中写对应的脚本。同时不再LD_LIBRARY_PATH这个环境变量中增加路径,有没有什么其它的方法呢?
当然存在我们去看一个文件夹的内容:
这个文件夹有什么作用呢?
这个文件夹中保存的都是各个动态库的路径,只要你在这个文件夹中建立一个.conf文件同时将你需要的那个动态库的路径放到这个.conf文件中。系统也就能够找到你对应的动态库的。
下面我关闭我的xshell客户端,然后重新登录之后:
可执行程序已经不能在找到我们对应的那个动态库了,因为在这个环境变量中已经不存在这个路径了。
我这里将用户切换成root之后,去到刚刚的.conf文件所在的文件夹中建立一个文件。
然后我在这个test.conf文件中写明我这个动态库所在的路径。
创建完成之后,使用ldconfig命令加载一下这个文件夹中所有的环境变量。
然后再去使用ldd去看a.out这个可执行程序我们就能够看到我们的动态库已经被a.out这个程序找到了
由此我们就能够得到第四个方法了。
但是如果以后这个mylib文件移动到了其它的地方那么.conf文件中的路径也需要更改,使用这个方法即使我将xshell关闭之后,再次打开a.out依旧能够运行。这里就是永久有效的,这个conf文件的名字你可以随意取。
在conf文件中不需要库的名字,因为可执行程序知道要连接的是哪一个库。
但是在下面的四种方法中最常用的就是第一种方法(大部分的第三方库)
这里只是为了讲解才说明的所有的方法。
以上就是动静态库在操作方面的问题。
下面如果我们将我们文件中的静态库删除,但是我们的代码仍然是可以运行的。原因静态库中的代码是被拷贝到可执行程序中的,即可执行程序在使用静态库编译之后,和静态库是没有关联了。那么如果我们删除我们的动态库呢?(注意这里实验的时候别删除系统自带的动态库,否则就会出现很多命令无法使用的情况)。
但是删除我们自己写的动态库是没有问题的。
首先我们使用我们的main.c形成两个可执行的程序。并且都是可以运行的
这两个可执行程序都有main函数使用的都是动态库中的方法。
这个其实就相当于os系统中的两个命令。一个是pwd命令,一个是ls命令。
当前的这两个库都依赖于同一个库。
那么如果我们将test和tkv这两个可执行程序依赖的动态库删除了会怎么样呢?
很明显在删除了动态库之后,依赖于这个动态库的所有的文件也就不能够运行了。
这说明了我们的一个共享库一定是会被我们的两个不相关的程序都会使用到的。
总结:
由此动态库也被称之为共享库。
由上面的两个点我们就能够知道一个结论了:
那么如果某两个进程都会使用lib.c这个动态库,那么系统会不会将lib.c这个动态库加载两次?
答案当然是不会。系统没有这么傻,如果这里存在十个进程都使用了这个动态库,然后系统将这个动态库加载了10次,那么这和静态库有什么区别。所以os只会加载一次这一个动态库,然后,os会想办法让所有使用这个库的进程实现共享这个库。这样你存在10/100个进程会使用这个动态库,但是动态库只存在一个这样就大大的减少了重复的代码和数据。由此我们知道了不光在硬盘上很多的可执行程序在形成的时候,会共享一个动态库,在加载到内存之后,这个动态库也是会被多个使用这个动态库的进程所共享的。这样能够让公共代码只有一份能够大大的节省内存。所以动态库也被叫做共享库。那么在os中肯定是存在很多的动态库的,既然存在很多的动态库,那么os就需要将这些动态库管理起来。如何管理,先描述再组织。
所以在os中会将所有的库描述起来,然后将所有的库使用链表的结构储存起来。那么此时对库的管理也就变成了对链表的管理。
动态库如何被加载的
由此我们就进入了最后一个步骤那就是动态库是如何被加载的。首先每一个进程都有自己的进程pcb。
然后每一个pcb都是具有自己的各个结构的:
在磁盘的exe文件中也是具有代码和数据的,通过一些方式(字典树)将磁盘中的代码和数据以4kb大小为一个页框加载到物理内存中以上都是没有任何的毛病的。
这里如果细说的话磁盘和如何将磁盘上的数据加载到物理内存上都是一个又一个的问题。如何理解请看上一篇文章(磁盘及文件系统)可执行程序也是文件,如何从磁盘上得到文件的属性和内容也在上一篇文章中说明过这里就不说明了。
可执行程序也是具有自己的inode的
回到上面的那张图如果现在我们又新启了一个进程。那么对于这个新的进程也会具有和上面一样的结构的(页表映射等等)。
由此我们能够知道两个不同的物理进程不仅在内核数据结构层面上是相互独立的,在数据层面上也是相互独立的。由此上面的那个进程挂了之后,是不会影响到下面的那个进程的。即便你是父子进程也不用担心,最后经过写时拷贝之后指向的同一片物理空间也是可以分开的。
那么这里我们假设一下如果1.exe和2.exe这两个文件执行的不是普通的代码而是两个操作系统的代码呢?我把操作系统当作是一个可执行程序自然也是可行的。
如果真的能够运行的话,那么1.exe对应的pcb被调度的时候,也就相当于1.exe对应的操作系统被执行了。因为进程之间是具有独立性的,所以1.exe对应的os是不会影响2.exe对应的os的。这也就是内核级别的虚拟机的原理。在Linux中这个也就被叫做Docker。
下面我们继续:
这里我们思考一下:动态库是文件吗?动态库当然是文件(这个文件也就是将所有的.o文件打包一下的一个文件)。
假设这个3.so是被1.exe和2.exe,两个可执行程序所依赖的。
当我们的1.exe被执行的时候,这个pcb就被调度了,但是当这个pcb执行到需要使用3.so这个动态库中的代码的时候(假设就是printf)。
但是此时的这个代码中是没有printf函数的实现的。而在这个程序被编译链接的时候,os已经知道了这个printf函数的实现是在对应的某一个库中的(甚至于在库中的哪一个位置os也是大概知道的)。所以此时就会触发缺页中断,将3.so这个库加载到内存中(这个库的加载当然可以只以4kb加载一部分),但是这里我假设整个库都别加载到物理内存中了。
但是此时我们的这个进程能够看到这个库吗?能使用这个库中的方法吗?当然不能,所以下面还要做一些操作。
这里我们将这个库的地址映射到页表中,然后页表也会在pcb的虚拟地址空间中的共享区找到一个位置供给pcb使用。此时就是将整个动态库映射到了共享区中。
此时就能够完成跳转到共享区中,执行库中的代码,在执行完成之后再跳转返回到正文代码。此时就能够实现在pcb的地址空间上对库的访问了。
当然这个过程中还是存在很多的细节问题的。下面会讲解。这里我们也就能够知道了在虚拟地址空间中的共享区是存在一个功能的就是用来映射对应的动态库的。由此我们就能够得到一个初步的结论:
什么意思呢?意思就是只要我们将动态库加载到物理内存中之后,我们的进程在执行代码的时候都是在自己对应的地址空间中运行的。也就是当我们将库的代码加载映射之后,进程只用在自己的地址空间中就能够完成对库的代码和自己代码的执行
这里面在执行动态库代码的时候无非就是需要进行一次代码跳转而已,这和这个进程在自己的代码内部进行跳转是没有任何区别的(只不过代码间隔很远而已)。
然后还存在一个事实:
回到上面的那张图:现在一个新的进程pcb(2.exe)这个进程也想要使用这个动态库。此时os就需要介入了。因为虽然这个3.so的库可能被加载了,也可能没有被加载,但是这个无论如何这个3.so这个库和这个pcb之间是没有任何的联系的。os介入之后发现这个库已经被加载了。那么就会做出下面的操作。
所以在os中只需要存在一份共享库就能够被所有的进程都共享使用了。共享库就是通过这个方法实现共享的。
但是现在存在一个问题了,我们知道有的库是会提供一个类似于errno那样的全局变量的。现在这个动态库已经被多个进程共享了。
那么如果现在其中一个进程修改了这个全局变量,然后有一个进程也修改了这个全局变量,那么此时不就出现了进程之间相互干扰的问题了吗?
答案是不会出现问题,因为我们使用的库在逻辑地址映射的地方是在堆栈之间的而堆栈之间的这一大片空间是被称之为用户空间的。而当我们对共享库中的全局变量进行写入的时候,会发生写实拷贝。所以我们不需要担心多个进程对共享库中的某个全局变量写入导致的互相干扰的问题。未来线程那一块可能是会存在问题的但是在多进程这里是不存在这个问题的。如何证明呢?我们知道在文件处存在一个struct FILE结构(这个结构体中封装了文件描述符等等),那么这个大写的struct FILE结构是由c标准库提供的,也就是在共享区中。所以我们一旦对file中进行刷新,也就发生了写时拷贝。所以最后才会出现两份。那么为什么会发生写时拷贝呢?原因非常的简单,因为当某一个动态库被多个进程映射的时候,这个动态库所在的页(page)所对应的引用计数就会++。
所以在用户空间映射的空间,在你写入的时候,大概率是会发生写时拷贝的。
为了解决这个问题,我们就需要再去理解一下下面的问题:
下面我们重新理解虚拟地址
重新理解虚拟地址/物理地址
首先我们结合上面的内容思考几个问题第一个我们的cpu肯定是要读取虚拟地址然后,去运行虚拟地址中映射的指令的,但是现在的问题就是存在那么多的虚拟地址os是怎么知道哪些地址具有指令哪些地址没有指令的呢?除此之外如果某一个进程和好几个共享库建立了联系。这些库都映射在了共享区,那么cpu在执行到某个地址的时候,如何判断这个地址是属于哪一个库的映射的呢?
以上的问题中最为核心的就是要弄懂上图中的两个问题。
然后在之前讲解生成动态库需要的.o文件的时候我们使用了一个选项-fPIC这个选项叫做生成和地址无关码。
这个又是什么意思呢?
并且为什么只有动态库才有而静态库又没有呢?
再次理解虚拟地址和物理地址
我们从下面三个方面来说明我们的地址的概念:
程序没有加载前的地址(程序)
首先我们思考一下,在我们的程序没有形成可执行程序之前,在编译的阶段有没有地址呢?
答案是有的:
在多态的虚函数表中就存在地址。 类似于下面这种:
这个地址在编译的时候就已经存在了。这个过程是一个历史发展的过程,在很早之前,我们可执行程序中的地址不是这种样式的他采用的是段地址加偏移量的方式。因为当年没有虚拟地址空间而cpu的架构就是段加上偏移量的。现在也是具有段的概念的,所以可执行程序天然的在没有加载到内存中的时候已经被分成了很多段(正文段,可读数据区等等都有了)。但是这种模式虽然现在虽然还有,但是只是为了兼容以前。实际上现在可执行程序内部的编址,已经变成了现在的平坦模式。那么什么是平坦模式呢?以4GB为例。在可执行程序没有加载到内存之前,内部就已经按照了虚拟地址空间的方式(最下面是正文代码,然后是初始化数据,未初始化数据),但是在exe文件的内部编号是从0x00开始一直到4GB的地址结束的,这就是平坦模式。也就是初始地址为0x00
总结:
由此在编译的时候整个exe文件中的代码就已经被划分成为了下面的几个区域。
然后每个区域的编号是从0x1开始往下的
每一个代码都是放在最开始编译的,这也是为什么c语言中的函数是需要提前声明的。
而这上面的地址已经是虚拟地址了。但是为了更好的区分,在没有加载到内存中时我们将这个可执行陈晓古中的地址称之为逻辑地址。在早期逻辑地址,和虚拟地址时不同的概念,但是现在所谓的逻辑地址和虚拟地址已经是同一个概念了。
下面我们写一小段简单的代码。
使用gcc生成了默认的可执行程序之后,我们使用下面的命令
objdump -S <可执行程序的名字>
这个命令的作用就是将我们二进制的可执行程序反汇编出来。
下面就是main函数那里的二进制
其中左边的编号就是每一条指令所在的地址,而因为第二条指令move所占据的是3个字节,所以下一个mov指令的开始地址是31。然后因为第三个mov所占据的字节是5个所以callq指令是从36位置开始的。以此类推。
这里我们要知道的是,每一个指令所在的开始地址例如40052d,这个地址的值是可以被替换的,但是每一条指令是具有自己的长度的。而我们在call指令那里可以看到一个400410,而这个数字就是cpu要执行的下一条指令所在的位置。这里除了我们需要知道每一条指令都是具有自己的长度的,并且我们的指令在调用的时候使用的就是在编址时使用的地址了。
这里地址的值是可以不出现的,因为在逻辑上我们只需要知道初始的地址为多少,然后再加上每一条指令的长度(初始地址+(你想运行的指令前)每一条指令的大小 = 任意一个你想运行指令)。此时在逻辑上我们的代码就可以跑起来了。
也就是每一条指令的地址其实是没有必要的,只需要具有起始的地址+每一条指令的大小。就能够运行了。至于call指令后面需要跳转的地址,我们可以根据整份代码的起始地址的偏移量来表示这个地址。但是这个地址在代码中起始是存在的,这里只是说明每一个指令的地址是可以不用出现的。但是其实每一个指令都是存在地址的,那么为什么要说上面的那些呢?首先这些指令其实最后都是要交给我们的cpu去执行的。
那么cpu是怎么帮助我们去执行这些指令的呢?
cpu在自己被制作的时候就已经被内置了很多能够认识这些基础指令的工作(push,mov等等),只不过不是以我们看到的push这种文字的方式认识的,push是具有自己对应的二进制序列的(比如push对应的是0001,而mov是0002等等),而类似于0001,0002这样的数字在cpu内部我们可以提前设置很多,也就是你向cpu中输入像0001这样的数字,在cpu内部是可以被当作对应的指令来对待的。不会将0001当作数据,所以cpu就能够认识你喂给他的这些短语汇编,短语汇编多起来了,然后cpu一查就成为了具体的动作。所以cpu你可以认为它很笨它只认识一些基本的指令,你可以将这些指令组合起来使用你就完成了一系列具体的动作。而cpu内置的这些指令我们就可以认为是cpu的指令集。而当代的cpu内部都会内置指令集,所以cpu的指令集在阵营上被分成了两个大类,一类叫做精简指令集,一类叫做复杂指令集。我们只需要知道在cpu被做好的时候,内部已经被硬件工程师写好了一些它能够认识的指令了。正是因为有了这样的指令,在历史上才会有人使用二进制去进行编程。因为cpu是能够分清楚那些二进制是指令而那些二进制是数据的。可以通过这种方式去给cpu喂指令和数据。然后后来才有了使用二进制编程,但是后来人们觉得使用二进制编程不方便,才有了对应的汇编。而上面的push,mov这样的字符其实就是助记符。这个助记符其实就是和对应的二进制码进行了简单的映射。做了映射之后我们就能够知道push和mov后面的二进制码是多少。这个工作是由汇编对应的编译器的工作。也即是计算机在执行汇编指令的时候已经非常接近底层了。既然cpu能够区分哪些二进制是指令哪些二进制是数据,那么自然能够计算出每一个指令对应的大小。由此当cpu执行到某一个指令之后,自然就能够知道下一个指令所在的位置。所以对于硬件我们需要具有上面这样的简单的认知。我们这里在回顾一下当我们的可执行程序在没有加载到内存中的时候,就已经按照从某一个地址到某一个地址,划分好了不同的区域。这就是所谓的逻辑地址的概念。
我们来思考下面的这个情景:
当我们将磁盘中的某一个可执行程序加载到内存之后,在可执行程序中的那些指令会不会占据物理内存的空间呢?答案自然是肯定会占据的。
既然是会占据内存的,那么每一条指令就天然的具有了物理地址。但是不要忘了在我们可执行程序编译的时候也是使用了自己对应的地址的。正如下图中的call指令,它在物理内存上具有一个物理地址但是在编译的时候内部也是存在一个编译时使用的地址的。
也就是每一个重要的指令都是具有两个地址的一个是加载到物理内存之前,编译器内部使用的地址,一个是在加载到物理内存之后自然所具有的物理地址。
同时当我们的可执行程序被加载到内存时也会具有自己所对应的pcb。同时每一个pcb还有自己对应的虚拟地址空间。
那么当未来我们的cpu执行代码的时候,我们的cpu要如何找到最初的那个指令呢?
着我们就要回到可执行程序在编译的时候。
从上图我们就已经看到了,在这个空间的一开始,就已经将这个程序的入口地址(entry)写好了。那么这个入口地址是不是物理地址呢?
注意这个表是在编译期间形成的,所以答案自然是不是这个entry中的地址并不是物理地址。因为这是一个可执行程序的地址,在可执行程序加载到物理内存之前是不存在物理地址的概念的。所以这个entry中的地址应该是在0到4GB这个地址空间中程序自己的main函数所在的逻辑地址。
那么我们的cpu要如何找到一个程序开始的地址呢?不要忘了在cpu中是存在一个寄存器的,这个寄存器叫做EIP/PC指针。
除此之外我们的进程是会记录自己的工作目录,以及自己的可执行程序所在的位置的。
所以当某一个可执行程序在启动但还没有被cpu执行前就已经形成了,对应的pcb,等等的结构
然后当cpu要执行这个进程中的代码的时候,就会将这个entry中的入口地址导入到cpu的EIP/PC指针中(上图)。
因为这个地址本来就是一个正确的虚拟地址所以我们的cpu就会跑到虚拟地址空间中的正文代码中的第一个位置开始。
既然这是一个虚拟地址自然就需要通过页表将我们的虚拟地址转化为正确的物理地址。但是当页表发现这个地址对应的物理地址没有就绪的时候,就会发生缺页中断将这个可执行程序对应的代码和数据load到物理内存去。然后页表会将这个入口地址和程序真正的入口指令所在的物理地址建立映射(每一条指令都是具有自己的物理和虚拟地址)。之后cpu就会拿到物理内存中对应的这个指令,去执行,当cpu执行完这个指令之后,根据当前指令的大小,再计数器上+1/+2/+3,就能够跳转到下一条指令上,由此不断地循环直到将整个代码执行完成。那么按照这个步骤不断读取,直到我们读取到了下面的这一个指令。
cpu读取之后发现这是一个函数调用的时候,此时使用到的这个地址4。
那么这里的问题就是这个4是什么地址呢?这个4因为已经加载到了内存之中没有了逻辑地址的概念,所以此时的这个4就是虚拟地址。至于读取4所对应的物理地址的方法自然和上面的步骤是一样的,经过页表映射等等。如果这个4所在的地址没有存在于物理内存中那么缺页中断一下即可。
这里就能够得到一个结论,无论是你第一次开始读取程序的起始地址,还是内部分析处理这个地址,再到往后继续读取的地址整个过程中cpu读取到的凡是指令当中的地址全部都是虚拟地址。
这里总结一下上面的整个过程,因为在可执行程序编译的时候,就已经形成了对应的逻辑地址,所以cpu在读取的时候读取到的就是虚拟地址(逻辑地址和虚拟地址在现在的计算机看来就是同一个),然后cpu因为要执行这个地址所在的指令,所以经过页表等等的硬件处理之后就能够将虚拟地址转化为物理地址,通过这个物理地址找到对应的指令之后,读取这个指令但是指令中还是存在对应的虚拟地址。此时就循环起来了。如果这个指令已经执行完成了那么cpu会让计数器++。让这个虚拟地址变成下一个指令的虚拟地址。由此就不断循环起来了。
而我们对应的可执行程序中的地址,和pcb虚拟地址空间中的地址可以说是一母同胞,而上面的过程也是我们的编译器和os互相协同的重要表现。(在可执行程序中的地址就是虚拟地址)。
所以编译器和os是有关系的。
也就是编译器在编译的时候就已经考虑到了os。
经过上面的步骤我们的可执行程序如何执行的圈就已经圆起来了。当然如果你还要细分的话对于磁盘中的可执行程序的管理,如何加载到内存中等等都是可以继续往下学习的内容。但是此时我们已经将脉络理清楚了。
由此我们就能够去学习下一组概念了。
动态库的地址
首先我们得可执行程序得编址,我们假设是下面这样,在这个可执行程序中是存在很多的位置的。
而这个11223344这样的地址相对于整个地址而言,我们称之为绝对地址。
我们可以这么理解,现在这里存在一个0到100米的跑道,而现在某一个人从0位置开始往后跑了30米,那么这个30相对于0到100的这个范围是唯一的,这也是这个人的绝对地址(相当于我们在0到4GB中编出来的某一个地址/地址空间中的某一个具体的地址)。然后在这个操场的40米处存在一棵大树。然后某一个人在跑到了操场的70米处。此时这个人具有两个地址一个是绝对地址70,而相对于树而言这个人的地址是30,而这也就是相对地址。
那么如果这个树不在40米处而是在开始处呢?此时我们的这个逻辑地址的偏移量就和绝对地址相同了。
这也是为什么我们在可执行程序中的逻辑地址可以称之为虚拟地址,因为逻辑地址是从0开始的也就和虚拟地址一样了。
首先下面的这张图表示了我们刚刚写的第一个过程:
经过上面的步骤我们的os就会开始往后执行代码。遇到了就执行,没加载就缺页中断。但是我们之前学过一个叫做libc.so的东西。那么当我们遇到要执行某些库中的方法的时候,我们对应的可执行程序中是没有这些方法的实现的。这里假设是printf,那么这里的printf就会变成对应的地址。这个地址能够帮助我们找到这个方法的是现在动态库中的位置。
现在已经将这个动态库加载到物理内存之后,会被映射到对应的共享区中。
但是问题就是
并且我这个进程可能不仅使用了一个这样的动态库。还有其它各种的库。
但是因为在编译的时候就已经将printf的虚拟地址确定了是0x1122,那么为了保证这个0x1122是一个线性的虚拟地址那么我们的共享库也必须被加载到这个0x1122这个位置上。如果不在这个位置,那么我们的程序在跳转的时候我们就找不到这个位置。那么也就是说我们的共享库被映射的位置一定是被固定了的。那么这就有些问题了,因为一个进程可能会使用多个库,并且每一个库加载的时间也是不同的,我们要如何保证每一个库都能映射到固定的位置呢?
所以我们发现了:
所以我们的库在加载的时候保证了下面的规则:
库如何做到呢?
库在形成的时候
所以这个0x1122并不是我们所谓的在虚拟地址空间中的绝对地址,这个0x1122是printf这个函数所在的位置相对于这个库一开始地址的偏移量。
那么此时这个动态库就可以随便放在共享区中的哪一个位置了
放好之后和物理内存建立好映射,我们的os只需要记住我们的这个库起始位置所在的虚拟地址(因为os会对库做管理{如何管理先描述再组织},所以os是知道某一个库被加载到了哪一个位置的)。那么之后当要访问某一个函数是库函数中的函数,例如printf就可以直接使用起始地址+0x1122就能够访问到printf的虚拟地址了。找到之后再返回到正文代码继续执行。那么至此之后我们对应的动态库就可以随便加了,只要你在加入完毕之后,记录每一个库对应的起始地址(在描述库的结构体中记录)就可以了。之后我们使用起始地址+偏移量就能够访问到所有的共享库中的代码了。
因为使用的是起始地址+偏移量的方法所以对应的可执行程序中共享库中的地址就可以不用变了。因为共享库可以放到共享区中的任意一个位置。
这里我们再去理解gcc -fPIC(形成和地址无关码)也就能够理解了。
所以使用与位置无关码能够保证我们的共享库能够在共享区的任意一个位置映射。
那么
静态库不谈加载因为静态库是直接将自己的代码拷贝到了可执行程序中的。
而静态库和位置有关,因为静态库中的程序是直接拷贝到了我们的主程序中的,而我们的主程序是和位置有关的,所以静态库是和位置有关的。
到这里我们就知道了一个共享库是如何加载的。
这其中还是存在很多细节的,例如虚拟如何转化为物理,以及页表的结构等等。我们在后面说明
写的不好请见谅,如果发现了任何的错误,欢迎指出。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!