Linux动静态库的制作与使用
作者:春人.
一、静态库
对于我们自己写的一份源代码,别人如果想使用,可以有以下两种做法:第一种就是将我们的源代码直接给别人拷贝一份,但是如果你觉得自己的代码写的非常厉害,不想让别人知道,或者别人嫌拷贝太麻烦了,那么就需要采用第二种做法;第二种做法就是,将我们自己写的源代嘛想办法打包成库,然后将这个库和对应的头文件提供给使用者。注意,头文件是必不可少的,头文件就相当于该库中方法的使用说明书,如果不提供头文件,别人大概率是不知道该库是如何使用的。
1.1 静态库的制作
// add.h #pragma once int add(int x, int y);
// add.c #include "add.h" int add(int x, int y) { return x + y; }
// sub.h #pragma once int sub(int x, int y);
// sub.c #include "sub.h" int sub(int x, int y) { return x - y; }
// mul.h #pragma once int mul(int x, int y);
// mul.c #include "mul.h" int mul(int x, int y) { return x * y; }
// div.h #pragma once extern int myerrno; // 声明一个可以被其它源文件使用的变量 int div(int x, int y);
// div.c #include "div.h" int myerrno = 0; // 定义 int div(int x, int y) { if(y == 0) { myerrno = -1; return myerrno; } return x / y; }
1.2 静态库的生成
静态库的生成指令:
静态库本质上就是对 .o
文件进行打包,所以首先要将 .c
文件编译成 .o
文件,然后进行打包。ar
是 gnu 归档工具,可以使用它来生成一个静态库,它执行的工作就是把一个或者多个 .o
文件打包,生成一个 .a
库文件。-rc
表示 replace and creat,.a
库文件中如果有待打包的 .o
文件就替换,没有就创建。
静态库生成示意图:
1.3 静态库的发布
发布库:
发布库就是把 lib
目录拷贝给别人。
1.4 静态库的使用
首先创建一个 test
目录,先讲上面发布的 lib
目录拷贝到 test
目录中,然后在 test
目录中创建一个 main.c
进行测试。目录结构如下图所示:
// main.c #include "add.h" #include "sub.h" #include "mul.h" #include "div.h" #include <stdio.h> int main() { printf("1 + 2 = %d\n", add(1, 2)); printf("1 - 2 = %d\n", sub(1, 2)); printf("1 * 2 = %d\n", mul(1, 2)); printf("1 / 2 = %d\n", div(1, 2)); return 0; }
编译 main.c
时报错,说找不到对应的头文件。此时就需要再来认识一下包含头文件的两种方式了。在使用库中的头时,一般用 <>
来包含头文件,<>
表示到系统指定目录下去查找头文件。在使用自己写的头文件时,一般使用 ""
,表示在当前源文件的统计目录下查找头文件,找打了就用,没找到再去系统指定目录下进行查找,所以对于库提供的头文件我们也可以使用 ""
进行包含。但是上面代码中我们使用的就是 ""
,并且 add.h
就在 lib/include
目录下,lib
目录和 main.c
同处 test
目录下,为什么会报错呢?因为用 ""
包含的头文件,会告诉编译器在 main.c
的同级目录下进行查找,也就是在 test
目录下进行查找,并不会深入到 test
中的 lib
目录去查找。
解决上面报错的方法有三种。第一种,将我们发布的 lib
库中的头文件拷贝到系统的指定路径下;第二种,在代码中补全路径,如 #include "/lib/include/add.h"
;第三种,在执行 gcc
指令编译的时候加上 -I
选项,指定编译器搜索头文件的路径。
第三种解决方案示意图:
此时编译仍然没有成功,但是没有报头文件找不到的错误了。现在是链接出错,可以编译形成 .o
文件,如下图所示:
链接报错还是因为 gcc
在进行编译链接的时候,只会去默认路径下查找打包形成的库文件,不会去我们的 lib/mymathlib
目录下查找,这样就导致 gcc
编译器找不到我们打包的库 libmymath.a
,最终链接时就会报错。
解决链接有两种方法。方法一:将我们的库拷贝到系统的指定路径下,并不能完全解决,还需要指定库的名称,下面会讲;方法二:在使用 gcc
的时候添加对应的选项。方法二示意图,如下所示:
其中 -L
选项指定了库的搜索路径,-l
选项指定了待搜索的库的名称。 为什么在搜索头文件的时候仅需指定路径呢?因为在代码中已经写了头文件的具体名称,所以仅需指定头文件的路径即可。而一个路径下可以有多个库,如果只指定路劲,编译器还是不知道该去链接哪个库,因此还要在后面使用 -l
选项指定待链接的库的具体名称,注意:去掉前缀 lib
和 后缀 .a
才是一个库的名称,建议 -l
后面紧跟库的名称。一般在使用第三方库的时候,可能不需要带 -I
或者 -L
,但是 -l
指定库的名称是一定需要到,因为 gcc
默认只能找到系统调用和语言层面的库。
小Tips:在动态库和静态库都有的情况下,gcc
默认链接动态库,如果系统中只提供静态库,gcc
则只能对该库进行静态链接。如果有需要,gcc
可以链接多个库。
1.5 静态库的安装
库的安装本质上就是把头文件和库文件拷贝到系统的特定目录下。还可以通过在指定目录下创建软链接的方式,如下图所示:
小Tips:此时包含头文件前面应该加上软链接的名字,如:#include <myinc/add.h>
这种形式。
二、动态库
2.1 动态库的制作
// myprintf.h #pragma once #include <stdio.h> void Print();
// myprintf.c #include "myprintf.h" void Print() { printf("Hello Linux\n"); }
// mylog.h #pragma once #include <stdio.h> void Log(const char* info);
// mylog.c #include "mylog.h" void Log(const char* info) { printf("log: %s\n", info); }
2.2 动态库的生成
小Tips:在编译生成 .o
文件的时候,要加上 -fPIC
选项,该选项表示产生位置无关码(position independent code)。将 .o
文件打包生成动态库,继续使用 gcc
,需要带 -shared
选项,表示生成共享库格式。其次需要注意动态库的命名规则是 libxxx.so
。动态库是可执行程序的一种,将来是需要被加载到内存的,因此它带了 x
选项,而静态库的使用本质是把静态库中的二进制代码拷贝一份去使用,静态库是不需要被加载到内存的,因此静态库没有可执行权限。
2.3 动态库的发布
dy-lib=libmymethod.so static-lib=libmymath.a .PHONY:all all:$(dy-lib) $(static-lib) $(static-lib):add.o sub.o mul.o div.o ar -rc $@ add.o sub.o mul.o div.o $(dy-lib):myprintf.o mylog.o gcc -shared -o $@ $^ myprintf.o:myprintf.c gcc -fPIC -c $^ mylog.o:mylog.c gcc -fPIC -c $^ add.o:add.c gcc -c $^ sub.0:sub.c gcc -c $^ mul.o:mul.c gcc -c $^ div.o:div.c gcc -c $^ .PHONY:clean clean: rm -rf *.o *.a mylib *.so .PHONY:output output: mkdir -p mylib/include mkdir -p mylib/lib cp *.h mylib/include cp *.a mylib/lib cp *.so mylib/lib
小Tips:上面不是单纯的发布动态库,而是将动静态库同时发布。
2.4 动态库的使用
#include "myprintf.h" #include "mylog.h" #include <stdio.h> int main() { Print(); Log("Hello log function!"); return 0; }
上图中按照静态库的使用方法去使用动态库,可以成功生成可执行文件,但是可执行文件在运行的时候出错了。
在使用 ldd
查看可执行程序运行所需的共享库时发现,libmymethod.so
后面指向 not found
。为什么会这样呢?我们在使用 gcc
进行编译的时候,不是已经通过 -L
和 -l
选项告诉编译器动态库所在的路径和名字,为什么还是找不到呢?原因正如前面所述,我们仅仅是告诉了编译器所需的动态库在哪里,而可执行程序运行靠的是加载器,上面的 not found
表示加载器不知道动态库在哪里。这也从侧面印证了静态库是不会加载到内存中的,所以使用静态库只需要告诉编译器静态库在哪里即可。
解决该问题的方法有四种。第一种,将库文件拷贝到系统默认的库路径(/lib64
、/usr/lib64
);第二种,在系统默认的库路径(/lib64
、/usr/lib64
)下建立软链接;第三种,将自己库所在的路径,添加到系统的环境变量 LD_LIBRARY_PATH
中,该环境变量就是专门用来搜索动态库的;第四种,如果想让我们的库和系统、语言自带的库一样,在程序运行的时候可以自动被找到,那我们可以在 /etc/ld.so.conf.d
路径下添加一个 .conf
结尾的配置文件,该配置文件里面的内容就是我们自己动态库所在的路径。添加完后执行 ldconfig
指令,将所有的配置文件重现加载一下,然后程序就能够正常运行啦。
小Tips:这样添加,当系统重启后新添加的境变量就没有了,如果想让系统启动时自动添加该路径到 LD_LIBRARY_PATH
环境变量中,可以通过修改 ~/.bash_profile
中的配置去实现,具体如下图所示:
小Tips:加载器是不需要知道库的名字的,只需要知道库的路径即可。
2.5 动态库是如何被加载和共享的?
动态库在进程运行的时候是需要被加载到内存的,常见的动态库被所有的可执行程序(动态链接的),都要使用,因此,动态库在系统中加载之后,会被所有进程共享。
首先我们需要知道,一个进程可以链接多个动态库,同理,当系统中存在多个进程的时候,那么此时系统中一定是存在多个动态库的。操作系统一定会通过“先描述,再组织”的方式将系统中所有的动态库管理起来。所以对操作系统而言,所有库的加载情况,它非常清楚。A.exe 在编译链接的时候采用的是动态库,A 进程在运行的时候,CPU 按照从上往下的顺序执行代码,遇到了一个库函数,假设就为 printf
,此时操作系统发现 printf
所在的动态库并没有被加载到内存中,因此就会将这个动态库加载到内存,因为动态库也是文件,也有 inode,所以这本质上就是文件的加载,将动态库加载到内存之后,操作系统会在 A 进程的页表上建立该动态库与 A 进程地址空间中共享区的映射关系,然后 CPU 就又代码段跳转到共享区去执行动态库中关于 printf
的代码,执行完后跳转会代码段继续执行后续代码。与此同时,B.exe 经过编译链接(用动态库),然后被加载到内存,成为 B 进程,CPU 在执行 B 进程代码的时候,也遇到了 printf
函数,此时因为在 A 进程执行的时候,就把 printf
所在的动态库加载到了内存,所以此时操作系统并不会再去把这个动态库加载一遍,而是直接在 B 进程的页表中建立映射关系。此时一个动态库被加载到内存中,就同时被两个进程所使用,因此动态库也被叫做共享库。
一个问题:现在我们知道了动态库是可以被多个进程共享的。那动态库中的全局变量例如 errno
该怎么办?我们知道,errno
是 C 语言为我们提供的一个错误码,一般在调用库函数失败的时候,该错误码会被设置,那动态库是被共享的,岂不意味着 errno
也可能是被多个进程共享的,那在 A 进程中执行库函数失败,假设 errno
被设置成 1,在 B 进程中 errno
也是 1 嘛?这显然是不合理的。实际上,当要修改 errno
的时候,操作系统会通过引用计数去判断该动态库是否被多个进程共享,如果该库被多个进程共享,操作系统会发生写时拷贝。
三、再来认识地址
3.1 逻辑地址的引入
一个 .c
源文件在被编译成为 .exe
可执行程序的时候,会加上地址。可以这样来理解,一个 .c
源文件首先会编译成为汇编文件,将我们的 C 语言转化成一条条汇编指令,接着会把汇编指令转化成机器码,对应的汇编文件和机器码文件其实都已经加上了地址,最终 .exe
中也是包含地址的。.exe
文件本质上就是由各种段构成的,现如今的 .exe
文件中的编址都采用平坦模式,即 .exe
文件已经按照程序地址空间的格式进行分段编址。
3.2 CPU 是如何知道指令位置的
上面说过,可执行程序内部是有逻辑地址的,在可执行程序加载到内存之后,每一条指令还会有自己对应的物理地址,因为物理内存它本身就是有地址的,无论可执行程序是否加载到内存中。此时可执行程序已经被加载到了内存,CPU 是如何知道该可执行程序的第一条指令在哪儿的呢?在编译形成可执行程序的时候,除了形成代码段、数据段、.bss 段外,还会形成一个文件头,这里面就存储了可执行程序的入口地址,这个地址是逻辑地址(虚拟地址)。在 CPU 中有一个寄存器,一般管它叫做 PC 指针,它里面存储的就是接下来要执行指令的地址。实际上,最初并不急着把可执行程序全部加载到内存,只需要将可执行文件的头部加载到内存即可,CPU 通过头部获取到可执行程序的入口地址,然后拿着该地址去查页表,发现并没有建立内存映射,此时操作系统会发生缺页中断,将对应的程序加载到内存,接下来就好办了,CPU 通过内置的指令集,先天就知道每条指令的长度,然后他会按顺序往后执行,遇到函数调用指令,或者一些跳转指令,也是根据虚拟地址去页表中查找映射关系,发生缺页中断。因此可以得出一结论,CPU 是通过虚拟地址转物理地址去执行可执行程序中的指令,访问可执行程序中的变量。
3.3 一个库函数是如何被找到并且执行的
结合上面两点,可以得出,可执行程序内部有逻辑地址,CPU 是通过虚拟地址转物理地址去执行指令的。那一个可执行程序是如何加载并使用动态库的呢?以程序中调用 printf
函数为例,按照上面两小节的说法,在可执行程序中,printf
函数有一个固定的逻辑地址,假设为 0x11223344
,而 CPU 是通过虚拟地址查找物理地址去执行指令,即通过 0x11223344
这个虚拟地址去映射找到物理地址,然后执行 printf
函数,那是否意味着,在程序地址空间角度,动态库需要被加载到动态区的固定位置,这样才能保证 printf
函数的地址是 0x11223344
。如果按照上面两小节说的,对于一个进程来说,动态库是必须加载到固定的位置,但是这几乎是不可能的,因为一个可执行程序可能同时使用多个库,每个库的大小不一,并且每个库中都独立编址,可能该进程还使用了 B 库中的某个函数,该函数在 B 库中为编址也是 0x11223344
。所以很难做到将一个动态库加载到固定位置。因此我们需要想办法让库可以在虚拟内存中共享区的任意位置进行加载,实现方法是,在动态库内部,不采用绝对编址,而是采用相对编址,对于动态库中的函数只需要知道其在库中的偏移量即可。0x11223344
就不再表示 printf
的绝对地址,而是它相对于这个库起始位置的偏移量,此时就可以实现把库加载到虚拟内存共享区的任意位置。之后,操作系统只需要记住每一个库在虚拟内存中的起始地址即可,当要执行某个库函数的时候,只需要用该函数所在库的起始地址加上该函数的相对地址(也就是偏移量),就可以知道该函数在程序地址空间中的虚拟地址,然后再拿着这个虚拟地址去查页表,找到该函数在物理内存中的地址,然后执行库函数。-fPIC
选项,就是让编译器在形成动态库文件的时候,直接用偏移量对库中的函数进行编址。静态库是直接拷贝到可执行程序中的,无需加载到物理内存中,因此静态库中的函数就被当做了我们自己写的函数一样,直接采用绝对编址。
四、结语
以上就是Linux动静态库的制作与使用的详细内容,更多关于Linux动静态库的资料请关注脚本之家其它相关文章!