如果有过Linux编程的经验,基本上每个人都会看到过.o文件,.a文件,.so文件。一直没有认真的去了解过这些文件的区别。这些文件通常会在库文件中看到。比如说C语言的标准库。在任何一台Linux机器中,使用可以使用whereis
命令来查找各个库文件的路径。下面是我在WSL中一个例子:
为了保持文章的完整性,我将.o文件也放到这里来一起讲解。
.o文件
.o文件的正式名称叫做对象文件(object file)。一般来讲.o文件通常是可重定位文件(relocatable file),在.o文件中包含的信息主要包括:代码对应的机器码(通常由编译器或者汇编器产生),数据段(比如说文件中需要的字符串),还有其他的一些段(section),如符号表,字符串表等等。在Linux下,一个.o文件基本上都是ELF文件格式的。在windows下,对象文件的后缀一般是.obj。
经过汇编器(assembler)编译后的文件就是一个.o文件,此时.o文件还不能直接运行,接下来还需要经过链接器来重定位,如果引用了外部函数(并非定义在本文件之内的),还需要将外部文件一起链接起来才可以运行。
关于对象文件的其他资料可以参考:维基百科--Object file,维基百科---ELFformat
不过.o文件有很大的缺点,考虑以下情况。基本上任何的C语言程序都需要printf函数。printf位于C语言的标准库libc中。如果每个程序都将libc直接链接起来,无论libc中的函数是否都需要。这样一来,就会导致很大的硬盘空间被浪费。此外还有一个问题就是,如果开发这些库函数的人,对库函数做了一些改动,无论改动是多么的细微,都需要重新编译。
一个改善的方法是,我们将里面的函数都分模块编译出来,比如说printf函数就放到printf.o中,scanf放到scanf.o中。然后在编译的时候,手动的将这些.o文件都链接起来。
gcc main.c /usr/lib/printf.o /usr/lib/scanf.o
不过这也很容易出错,比如说文件名打错啦,或者所需要的函数超级多的时候一个一个打,很麻烦。于是乎,就出现了静态链接库的概念
.a文件
为了解决上面这个问题。将不同的函数编译为独立的.o文件,然后再将他们封装为一个单独的静态库文件。然后,应用程序可以通过在命令行上指定的文件名字来使用了定义在这些库中的函数。比如说使用使用标准库函数和数学库函数:
gcc main.c /usr/lib/libm.a /usr/lib/libc.a
在链接的时候,链接器只需要链接被程序所需要的.o文件。这样就减少了可执行文件的在硬盘和内存中的大小。比如说,我们在文件中使用libc中的printf函数,那么链接器就只会将printf.o链接进来。
我们可以使用ar -t来查看.a文件中包含那些.o文件。下面是lib.a中包含的.o模块:
可以看到,我们熟悉的printf就被包含在libc.a文件中。
.a文件的实验
下面使用最简答的例子来实际体会一下。
将下面两个文件组成为一个库函数
//add.c
int add(int a,int b) {
return a+b;
}
//sub.c
int sub(int a,int b) {
return a-b;
}
然后分别将它们编译为.o文件。gcc -c add.c sub.c
。接着再将这两个.o放一起编译为.a文件ar rcs libcaculate.a add.o sub.o
这里的参数我也不懂什么意思,直接抄写csapp里面的,有机会再补。可以通过ar -t
命令来查看我们的.a文件是否是我们所需要的函数。下面是我的实验结果(我这里的文件名不是libcaculate.a,但是问题不大哈哈):
可以看到,.a文件里确实应包含了这两个.o文件。
PS:按照Linux的习惯,在定义一个库文件的时候,通常叫做libgcc src-file.c -lm -lpthread
即可。
然后,我们将add和sub函数都放到一个caculate.h文件中,然后在主函数(main.c)中引用这个头文件。代码如下:
#include<stdio.h>
#include "caculate.h" //定义的头文件 ,声明了add和sub两个函数
int main() {
int result = add(1,2);
printf("result:%d\n",result);
}
接下来编译链接主函数。
gcc -c main.c
gcc -staic -o prog1 main.o ./libcaculate.a
#或者等同于下面的命令
#如果使用这种形式,库文件的名字需要改为libcaculate.a才行
gcc -static -o prog1 main.o -L. -lcaculate
然后来运行一下,结果如下:
我们从侧面来看一下,因为我们在代码中只引用了add函数。那么应该在代码中,不会出现sub函数的踪影。使用nm
来查看文件中的符号
先来看看是否有add,结果如下,确实引用了add
再来看看是否有sub,结果如下,可以看到sub没有出现在符号表中。
所以据此,可以认为sub.o没有连接到主函数中。这样就可以减少程序在硬盘中的空间。
.so文件
上面的文件都是用于静态链接。所谓静态链接,就是可执行文件在运行之前就编译好了。虽然通过上面的方法,将多个函数封装为独立的模块,在链接的时候,只选择需要的模块来连接到一块就行。但是老问题仍然存在,链接重复的模块仍然会导致浪费硬盘或者内存空间。当有上百个进程都在调用printf的时候,将会造成极大的空间浪费。所以,就引入了动态链接库的方法。
静态链接会将代码直接嵌入到可执行文件中,所以还是会不可避免的占据不少的硬盘空间。而且,任何对代码的修改都需要重新编译,然后再重新手动去连接。
因此,采用动态链接来解决这个问题。所谓动态链接,最直观的一点就是,将对库文件的链接推迟到要去加载程序的时候。并且,一旦将库文件加载到内存当中后,若未来还有程序要这个库文件,就不需要重复的从硬盘重新将库文件重新加载,直接将其链接起来就行。
.so文件就是用于动态链接的。.so文件的全称叫做共享库(shared library 或者shared object),它可以加载到内存的任何地方,并和一个在内存中的程序连接起来。这个过程叫做动态链接,加载动态链接库的过程是由一个叫做动态链接器的程序来完成的(dynamic linker)。在windows下使用了大量的共享库,它们称作DLL(dynamic link library)。
动态链接静态链接相比起来稍微复杂一些,接下来大概讲述一下动态链接的流程,主要都是引用自csapp的内容。
总体来讲,动态链接分为两个部分,如下所示:
一部分是属于静态链接,另一部分部分留到加载器去加载的时候才链接。动态链接,它不是简单的将其他.o文件直接嵌入到程序当中。而是在,程序程序被加载器加载的时候,再完成链接。这样,就不会浪费硬盘空间了。此外,当某个库文件(.so)被加载到内存当中以后,再有其他的程序需要该库文件的时候,就不需要重复从硬盘中加载了,只需要链接起来就行,这样一来就不会浪费内存了。
动态链接的基本思路是:
动态链接分为两个部分,第一个部分是静态链接的阶段,会对库文件的重定位和符号表信息链接起来,注意,这里并不包括对库文件的链接。第二个部分是,然后到程序被加载器加载的时候,加载器(loader)会注意到该程序有一个.interp 节(section)(这个节内部放着动态链接器的路径),它就知道了这是一个需要动态链接的程序。然后加载器会去加载这个动态链接器,接着跳转到动态链接器去执行。动态链接器会去完成加载.so文件到内存以及完成对.so中的符号重定位等工作。根据csapp,加载动态链接库到内存的工作是动态链接器的工作:
Up to this point, we have discussed the scenario in which the dynamic linker loads and links shared libraries when an application is loaded, just before it executes.
这似乎看来,在每一次运行程序的时候,都需要对库文件进行动态链接,会对性能上造成开销。不过根据《程序员的自我修养》这本书中的介绍,动态链接并不会对性能造成太大的影响,可以认为,动态链接带来的好处更加大。
.so文件的实验
还是使用上面的add和sub两个文件,用来构建一个共享库。gcc -shared -fpic -o libcaculate.so add.c sub.c
,然后将主函数连接起来。gcc -o prog2 main.c ./libcaculate.so
,运行即可。
PS:参数的说明:-shared表示要生成一个共享库,-fpic表示生成位置无关代码。不重要,这不是这里的话题以后再说。
接下来一个重点是动态链接带个我们最直观的好处,如果我们对库文件进行修改,不需要重新编译链接主函数,就可以更新代码。
如同上面的例子,我们修改一下add.c。
int add(int a,int b) {
return a + b + 1;
}
重新将其编译为.so文件,直接运行程序,代码更新的效果已经体现。
更新软件
如果我们需要软件的一些内容做一个更新,那么我们只需要将.so文件直接拷贝过来。不许将整个软件更新。当下一次用户运行软件的时候,新的代码已经加载进来了。
构建高性能web服务器
这一部分是摘自csapp的内容。早期的服务器根据fork和execv来动态的提供内容。现代的服务器可以使用基于动态链接的方法来更加高效的显示动态内容,相比fork和execve来说,它总是需要创建进程,然后加载之类的,所以可能性能较差。Linux提供了一组接口来实现动态加载的功能。如dlopen(),dlsym()等等,但是这并不是本次的话题。 动态链接和动态加载的区别在于:
- Dynamically linked at run time. The libraries must be available during compile/link phase. The shared objects are not included into the executable component but are tied to the execution.
- Dynamically loaded/unloaded and linked during execution (i.e. browser plug-in) using the dynamic linking loader system functions.
来自-----Static, Shared Dynamic and Loadable Linux Libraries
一个是在执行的时候加载的,一个是在执行之中加载的。非常多的语言都支持运行时加载.so文件的操作。比如说golang的plugin,java 的JNI,python也有这方面的支持。
Summary
将上面内容总结一下,简单的来说。
- 对象文件(object file): 就是c语言写好的文件经过编译后的文件。它还要经过进一步的链接才可以成为可执行文件,它内部主要是包含了一些对于程序的描述信息,比如说数据段在哪里,数据段的大小,程序中的变量名等内容。这些内容可以查看elf文件的内容来了解。缺陷就是,一个.o文件的重复链接会浪费存储空间,而且在多个.o文件在链接的时候万一打错了文件名字之类的。或者是对于库文件更新,都需要对程序重新编译链接,比较麻烦。
- archive file:有时候我们只需要库文件中的部分函数,而不是整个文件。那么将整个库文件链接进来,也是造成存储空间的浪费。所以将多个函数封装为.o文件,然后链接的时候只需要连接我们需要的函数就行。好了一些,然而,重复的链接.o文件还是存在浪费空间问题。
- shared library: 如果我们将对于库文件的链接放到程序运行的时候再去链接。问题就解决了,共享连接库不会占据存储空间。问题就是如果链接推迟到执行的时候再链接,会不会对程序造成性能开销呢。不过根据《程序员的自我修养》这本书提到,这个开销是可以接受的。