静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库。
下面我们实现两个简单的方法,加法和减法的计算方法,使用头文件和源文件分开的形式呈现出来,例如:
那么这一堆头文件和源文件该如何形成一个静态库给别人使用呢?注意,我们打包的库中是没有 main 函数的,我们也不能把 main 函数打入库中。
接下来我们在该目录下创建一个测试以上方法的主函数 TestMain.c:
假设现在我们需要将上面的所有文件形成一个可执行程序测试,该如何编译呢?使用 gcc!如:
如上图,为什么我们在编译的时候没有编译头文件呢?因为头文件在当前路径下,所以编译器 gcc 是可以直接找到的!
那么对于上面形成的可执行程序,我们也是可以正常运行的,如下:
其实实际上,我们形成可执行程序,我们不建议把所有的源文件直接全部编译,因为这样的话每一个源文件都要经过预处理、编译、链接等。所以一般情况下对于这种多文件项目时,要先把源文件编译成 .o
文件,然后再把所有的 .o
文件进行链接,形成可执行程序!
所以我们先创建一个 Makefile,进行对上面的实践:
如上图,我们解释一下各项的作用。首先第1、2行不需要解释,因为这是我们常用的语法,但是所有的 .o
文件在当前目录下并不存在,所以还需要形成下面的依赖关系。对于 %.o:%.c
,首先 %.c
就是将当前路径下的所有 .c
文件一个一个展开,经过 gcc -c $<
形成一个一个同名的 .o
文件。其中 $^
和 $<
的区别在于,$^
是将冒号右边的所有文件看作一个整体形成冒号右边的程序;而 $<
是将冒号右边的一个一个分开编译,形成一个一个的冒号右边的程序。
接下来我们编译一下,就形成了一堆的 .o
和可执行程序:
所以我们想要形成一个库,我们就需要将上面的所有源文件和 main 函数和 Makefile 全部删除,将剩下的所有 .o
和头文件打包即可。然后使用者就可以将它自己写的 main 函数编译成 .o
文件,和我们的 .o
文件一链接即可!
接下来我们将代码逻辑修改一下,首先新建一个目录 user 将用户写的主函数放进去:
接下来我们形成 .o
文件和头文件给到 user,我们暂时先不将这些文件进行打包:
所以使用者就可以自己将 main 函数形成 .o
文件再和我们的文件链接即可使用:
但是这样对于用户来说太麻烦了,所以我们需要将所有的 .o
文件打包生成库。首先现在我们先需要生成静态库,而生成静态库的命令为(假设以我们上面的文件打包为例):
ar -rc libmylib.a Add.o Sub.o
其中 ar
命令是将所有的 .o
文件形成库文件的过程;选项 -rc
代表如果 .o
文件存在则替换,不存在则创建。而 libmylib.a 是静态库,库要以 lib 开头,所以我们的库的真正名字是 mylib.
下面我们使用 Makefile 生成一个静态库:
static_lib=libmylib.a
$(static_lib):Add.o Sub.o
ar -rc $@ $^
%.o:%.c
gcc -c $<
.PHONY:clean
clean:
rm -f *.o *.a
如上图,首先我们为该静态库的名字设置一个变量 static_lib
,然后下面使用 .o
文件生成该静态库。
所以我们得出结论:静态库的本质就是将库中的源代码直接翻译成 .o
目标二进制文件,然后打包!
下面我们对静态库和头文件分别进行打包,对 Makefile 进行修改,如下:
static_lib=libmylib.a
$(static_lib):Add.o Sub.o
ar -rc $@ $^
%.o:%.c
gcc -c $<
.PHONY:output
output:
mkdir -p mylib/include
mkdir -p mylib/lib
cp -f *.h mylib/include
cp -f *.a mylib/lib
.PHONY:clean
clean:
rm -rf *.o *.a mylib
我们多加了一个伪目标,就是用来发布我们的静态库的,本质就是创建一个目录,把头文件放入 include 中,把库文件放入 lib 中。我们直接发布,会形成一个库:
我们使用 tree 查看一下该库:
生成了对应的静态库之后,我们需要给别人使用,所以我们也可以对该库进行打包:
然后将该打包的文件给别人即可。
上面我们可以形成静态库了,那么我们该如何使用别人的静态库呢?现在我们回到用户的角度,我们只有一个主函数:
我们现在需要用到库中的方法,直接编译是会报错的,因为我们还没有对应的库。所以我们先使用最朴素的方法,先不进行打包。我们将所有的头文件给到用户:
还需要将对应的静态库给到用户:
如上,我们对应的头文件和静态库都有了,所以我们尝试编译一下:
我们发现出现了链接错误,这是为什么呢?现在我们需要知道,我们自己写的库或者别人写的库,叫做第三方库,而 gcc 默认不认识!所以我们需要在 gcc 中加上 -l
选项,让编译器去链接指定的库!如下:
gcc TestMain.c -l mylib
其中,-l
后面带的是库的真正的名字,即去掉前面的 lib 和后缀 .a,我们尝试链接一下:
但是我们发现它还是找不到库。所以我们还需要加上一个选项 -L
,后面跟上该库的路径,如下:
如上图,我们就生成了可执行程序。
但是为什么我们以前生成可执行程序的时候,不需要指定库名称和库路径呢?因为 gcc 就是默认处理C语言的,所以C标准库不需要指定链接哪个库还有路径,它自身就会帮我们找到并链接。
接下来我们使用 ldd
查看该可执行程序依赖的库文件:
如上图,为什么我们的程序没有依赖到 mylib 的库呢?那是因为我们的可执行程序默认是动态链接的,ldd 是只能查动态库的!而静态库已经被拷贝到可执行程序里了!gcc 默认是动态链接的,但个别库,如果我们只提供 .a,gcc 也会局部性的把我们指定的 .a 进行静态链接,其它库正常动态链接,如果加上 -static 选项,gcc 就只能链接 .a。
我们在上面已经生成了一个打包好的静态库,现在我们将该压缩文件拿到用户这里:
然后对该压缩文件进行解压:
如上,我们就把静态库拿到手了。然后我们就可以将该库安装一下,怎么安装呢?系统搜索头文件默认是在 /usr/include
这样的目录下的;而库文件一般都是在 /lib64
目录下的;所以我们将第三方库安装,本质就是将所有的头文件拷贝到 /usr/include
路径下;将所有的库文件拷贝到 /lib64
路径下!
但是我们也可以选择不这样做,我们还有另一种方法,我们可以直接使用。首先我们先看看直接编译会有什么问题:
首先出现的问题是头文件找不到的问题,有一种方法可以直接在代码中使用头文件时带上路径,例如 #include "mylib/include/Add.h"
,但是我们不选择这样做,因为这样不太好。那么现在我们的头文件既不在当前目录下,也不在系统路径下,没关系,我们可以在 gcc 中带上选项 -I
,后面带上头文件的路径即可,意思就是告诉 gcc 编译器除了在上面两个路径下找之外,还需要在我们指定的路径下找!那么我们尝试编译一下:
那么现在就不会报头文件的错误了,而是链接报错了。那么这个报错我们在上面已经解决过了,只需要带上 -l
指定库名称和带上 -L
指定库文件的路径即可,如下:
gcc TestMain.c -I mylib/include/ -l mylib -L mylib/lib/
如上,我们就能使用别人制作的静态库了。如果我们将头文件和库文件都安装到系统中了,-I
和 -L
就不需要带了。
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
首先我们需要介绍一下生成动态库使用的指令是 gcc,带上 -shared
选项即可。另外在生成 .o
文件的时候,需要带上 -fPIC
选项,意思是产生位置无关码,这个我们后面再解释。动态库的库名规则:libxxx.so。
接下来我们修改一下 Makefile 文件,如下:
dynamic_lib=libmylib.so
$(dynamic_lib):Add.o Sub.o
gcc -shared -o $@ $^
%.o:%.c
gcc -fPIC -c $<
.PHONY:output
output:
mkdir -p mylib/include
mkdir -p mylib/lib
cp -f *.h mylib/include
cp -f *.so mylib/lib
.PHONY:clean
clean:
rm -rf *.o *.so mylib
接下来我们编译一下:
如上图,就多了一个动态库文件,接下来我们 make output 发布起来,当前目录就会生成一个动态库,我们可以 tree 查看一下:
如上,头文件就包含在 include 中;库文件就包含在 lib 中。
接下来我们就可以将该动态库给别人使用了,现在我们将该动态库拷贝到 user 目录下:
现在别人就可以使用我们的库了。
接下来我们按照使用静态库的方式尝试使用动态库,首先先生成可执行程序:
接下来我们开始运行:
我们会发现,报错了,报的是不能打开该动态库,找不到该文件或目录。这是为什么呢?我们不是将路径和库名称都告诉 gcc 了吗?
首先动态库是可执行程序和库分离开的,我们的可执行程序加载到内存中了,但是库还没有加载到内存中。而静态库是直接拷贝到可执行程序中的,所以它们会被一起加载到内存中。也就是说,动态链接非常依赖这个动态库!
而我们在上面将路径和库名称都告诉了编译器,但是程序已经形成了,编译器的工作周期已经结束了,接下来运行的时候,和编译器就没有关系了!那么接下来就和系统有关系了,所以当我们加载运行的时候,我们也要告诉系统动态库在哪里!
我们可以使用 ldd
观察一下:
我们发现我们的动态库是找不到的。
所以解决方法有如下几种:
既然在系统默认的搜索路径下找不到我们的库文件和头文件,我们就将它们拷贝到系统的默认搜索路径中。
先拷贝头文件:
sudo cp mylib/include/*.h /usr/include/
再拷贝库文件:
sudo cp mylib/lib/*.so /lib64
然后我们在系统的目录中可以查看一下我们拷贝的文件:
如上图,我们的库文件和头文件已经拷贝到系统中了。接下来我们可以重新生成可执行程序,现在我们就不需要带路径了,只需要指定库名称即可:
然后我们也可以直接运行可执行程序了:
ldd 也能看到它能找到对应的库了:
我们还可以通过在当前目录下建立软链接的方式找到库文件,从而执行可执行程序。如下:
ln -s mylib/lib/libmylib.so libmylib.so
我们发现,当前路径下的软链接是可以被找到的,那么就说明,动态链接默认会在当前路径下搜索库文件!
ldd 查看:
我们知道,系统在运行的时候会去帮我们找我们的库,去哪里找呢?除了系统默认库路径下去找,还会去 LD_LIBRARY_PATH
加载库的环境变量中去找!
所以我们将库文件所在的路径添加到 LD_LIBRARY_PATH
中即可。接下来我们尝试一下,首先我们需要找到该库对应的路径:
系统是知道我们需要链接哪一个库的,只是找不到它在哪里,所以只需要给它所在的路径即可,不需要包含库名字了。接下来我们导一下环境变量,再查看 LD_LIBRART_PATH
下的环境变量就可以找到了:
添加之后是即时生效的,这时候系统就知道我们的库的路径了:
接下来我们的可执行程序也就可以执行了:
但是当前这种方法导环境变量是内存级的,当我们退出后重新进入它就没了,所以我们想永久生效的话还需要去改有关环境变量的配置文件,这里就不再介绍了。
在系统中存在一个 /etc/ld.so.conf.d/
这样的一个配置文件目录,这是系统管理所有系统动态库加载相关的配置文件。如下:
我们可以任意查看一个文件内部的内容是什么:
我们会发现,它里面的内容只有一个路径,就是我们需要查找的动态库所对应的路径!所以我们想要自己的动态库永久有效,只需要在 /etc/ld.so.conf.d/
目录下创建一个文件,在该文件中写入我们动态库的路径即可!
下面我们尝试一下,现在 /etc/ld.so.conf.d/
目录下新建文件:
sudo touch /etc/ld.so.conf.d/temp.conf
然后进入该文件添加我们动态库的路径:
如上,我们添加成功,一般来说也是即时生效的。如果没有生效,我们使系统在加载配置上进行刷新:
sudo ldconfig
此时我们查看的时候就生效了:
此时可执行程序也可以运行了:
所以,我们从网上下载的第三方库的原理就是将一系列的头文件和库文件拷贝到系统的默认搜索路径下!
另外,如果别人给我们的库中既包含动态库也包含静态库,即同一个库中提供动静态两种库,gcc 默认使用动态库!
上面我们都是使用 Makefile 生成一个动态库和一个静态库,接下来我们要使用 Makefile 一次性生成动态库和静态库,下面直接参考 Makefile 文件:
static_lib=libmylib.a
dynamic_lib=libmylib.so
.PHONY:all
all:$(dynamic_lib) $(static_lib)
$(static_lib):Add.o Sub.o
ar -rc $@ $^
%.o:%.c
gcc -c $<
$(dynamic_lib):Add.o Sub.o
gcc -shared -o $@ $^
%.o:%.c
gcc -fPIC -c $<
.PHONY:output
output:
mkdir -p mylib/include
mkdir -p mylib/lib
cp -f *.h mylib/include
cp -f *.a mylib/lib
cp -f *.so mylib/lib
.PHONY:clean
clean:
rm -rf *.o *.so *.a mylib
我们上面在形成动态库时,还有一个问题没有讲,那就是 gcc -fPIC -c xxx.c
中的 -fPIC
选项,它的意思是与位置无关码,到底是什么意思呢?接下来我们需要了解一下。
首先我们要知道,在 Linux 下,形成的可执行程序是 ELF 格式的可执行程序,它其中包含有一张类似于符号表的东西,里面包含各种函数依赖的库以及地址,符号表就是动态链接这些动态库的。当我们需要将可执行程序加载到内存中时,动态链接的程序,不光光自己要加载,链接的库也要加载到内存中!
然后我们要知道,程序没有被加载到内存的时候,程序内部有地址吗?有的!当我们的程序编译成为二进制文件之后,变量名、函数名等,还有吗?没有了!编译的时候,对代码进行编址,基本遵守虚拟地址空间那一套!虚拟地址空间不仅仅是操作系统里面的概念,编译器编译的时候,也要按照这样的规则编译可执行程序,这样才能在加载的时候,进行从磁盘文件到内存,再进行映射。所以,在程序没有被加载到内存的时候,就已经具有了“虚拟地址”,通俗地说是逻辑地址,这种逻辑地址的概念就是基地址+偏移量。这种以基地址从0开始的我们把它叫做平坦模式。
那么基地址+偏移量就是可以确定一个段的,比如说代码区基地址从0开始,偏移量是200,那么 [0, 200] 这个段就是代码区。所以这个可执行程序中就会形成许多段,它就是采用偏移量的不同这种方式定位每个段的。
在计算机里面,对程序进行编址的时候,会有两种编址方式,一种是绝对编址,另一种是相对编址。
绝对地址比较依赖起始位置,比如说我们当前站在距离一棵树的20米处,我们的起始位置是0,那么绝对位置就是我们相对于起始位置的距离,但是我们的位置会因为起始位置的变化而发生变化。但是相对地址就是我们的位置相对于这个树的距离,当起始位置发生变化的时候,我们的位置相对于树也就没有变化,这就是相对地址。
绝对编址比较适合我们上面说的那一套可执行执行的逻辑地址;而相对编址比较适合形成库中函数的地址,因为库中我们把函数的地址形成之后,所有库中的函数里面只需要记录它自己相比较于库的起始的偏移量是多少,只记录偏移量,所以未来这个库在内存的任意位置加载,库里面的所有函数的地址都不变,所以这就叫做与位置无关码!
接下来我们回到地址空间中理解动态库的加载,首先磁盘中有我们的 ELF 可执行程序,可执行程序中的符号表中依赖了 libmylib.so 这样的动态库,如下图:
但是当我们将可执行程序加载到内存中后,我们也需要找到该动态库,数据和代码肯定是被加载到内存中了,而且经过页表映射关系也能建立好。但是动态库也要被加载到内存的,所以动态库被加载至内存后,也要经过页表映射,映射到地址空间中的共享区!所以进程可以通过地址空间找到代码和数据,并且可以在共享区找到动态库中的代码。可以结合下图理解:
也就是说,库被加载后,要被映射到指定使用了该库的进程的地址空间中的共享区部分。那么我们可以保证每次都能将动态库映射到固定的地址空间吗?并不是的。但是我们想做到让库在共享区的任意位置,都可以正确运行呢?
下面我们先了解一下,我们动态库中的方法是如何编址的,其实就是以 库名称+方法偏移量 来确定的。也就是当可执行程序用到动态库中的方法时,它只需要记录在哪个库里面,在这个库的偏移量是多少即可,例如下图:
当可执行程序加载到内存中,代码和数据也加载到内存中后,当执行执行的时候,发现需要用到库中的方法时,也要把该库加载到内存里,然后经过页表映射到进程地址空间中,一旦库加载之后,它在地址空间中的位置就是确定了,我们假设该库加载到地址空间后的地址为 0x1111,那么我们就可以将库中的符号用 0x1111 替换掉,如下图:
所以进程在执行代码的时候,当识别到库中的方法时,该怎么找到库中的方法呢?因为我们已经知道库在地址空间所在的位置,也知道该方法在库中的偏移量,所以就能在地址空间中跳转就可以找到该方法!
所以未来动态库在地址空间中的共享区中随意加载都可以了,因为我们库中的方法编址方式都是相对编址的方式,是相对于该库的偏移量是多少,所以无论该库的地址在共享区中如何变化,偏移量在该库中是不变的,所以我们就能很快地找到对应的方法!
所以动态库采用的就是一种相对编址的方式,然后就可以做到动态库中的与位置无关性,所以以前在 gcc 中形成动态库需要加上 fPIC,形成与位置无关码。
首先要知道,CUP 中有一个指令寄存器,当 CUP 需要执行指令时,只需要把正文部分的代码直接读到指令寄存器,然后让 CUP 执行指令。
我们也能从上面的引入概念中知道,程序没有被加载的时候,程序内部就已经有地址了,即在磁盘的时候就已经有虚拟地址了,已经被编译好了。也就是说在磁盘的时候,可执行程序已经将代码、数据编址好了,暂时不考虑动态库,因为牵扯动态链接。
假设我们的可执行程序中 main 函数的起始地址为 0x11111111;然后我们的代码中还调用了其它方法,假设有两个方法,地址分别为 0x2222 和 0x3333,如下图:
而上面的地址可以说是虚拟地址,其实是可以看成是起始地址+偏移量,只是统一编址的时候起始地址从0编址,也就是可以看成是 0: 11111111、0: 2222、0: 3333,我们一般把这种地址叫做逻辑地址,但它实质上还是地址空间上的虚拟地址。
当可执行程序加载到内存后,程序内的地址不变,那么可执行程序的代码加载到内存需要占据物理内存吗?要的,所以它一定要有自己对应的物理地址,所以该可执行程序的代码每一段都需要有自己的物理地址,如下图:
此时加载到内存之后,物理地址有了,那么页表的右侧就可以填上了。更重要的是,ELF 可执行程序会在特定的位置,记录下来自己程序的入口地址 entry;也就是,编译器在编译的时候,可执行程序在符号表中有专门的字段记录 main 函数的地址,供操作系统读取!
那么在程序加载进内存后,首个虚拟地址就有了,就是 main 函数的地址,那么,程序在加载进来的时候又有了物理地址,所以在最开始时,在页表中就可以构建最简单的 k-v 的映射关系。所以当 CUP 执行这个程序的时候,操作系统只需要将 main 函数的地址加载到指令寄存器中,指令寄存器拿到最开始的程序入口地址,它是虚拟地址 0x11111111,然后找到进程,找到进程地址空间,找到页表,进行虚拟到物理的转化,而在上面已经建立好了虚拟地址到物理地址的 k-v 映射关系,所以此时 CUP 就能读取第一条指令了。
然后读取指令时,读到了 call 0x2222,如果后续的代码和数据不在内存里,就重新加载,重新构建映射,因为起始地址都已经有了,剩下的虚拟地址都是连续的!因为正文代码区中的代码都是在一起的!当读取到 call 0x2222 方法,CUP 就读取对应的指令,call 方法就要做函数跳转,因为 0x2222 也是虚拟地址,它也加载到内存中了,所以可以根据页表的映射关系找到对应的 A函数,然后就可以找到代码和数据。所以整个程序运行时,整个虚拟到物理的转化就轮转起来了!