Linux下的静态库和动态库

1. 库

当开发某个项目时,通常情况会创建很多个.cpp或者.c文件,经过gcc -c工具链处理得到很多个后缀名为.o的二进制文件,将所有.o的文件压缩打包形成的产物就被称为

库的作用就是可以在不提供源代码的情况下给别人使用,既完成了目标任务,又能够保护自己的知识资产。对于c/cpp文件采取反编译手段的话,反编译的效果在10%左右,对于其他语言,比如java、python的反编译效果可以达到90%左右。所以从反编译角度来看,对于采用c/cpp语言编写的程序安全系数会更高点。

将源代码打包成库以后,从使用者的角度来看是无法感知到源代码的,那使用者如何调用库中的接口呢?.c/.cpp文件中是对源代码逻辑的具体实现,而.h/.hpp文件是对外暴露的接口声明。库实际上是把.c/.cpp文件进行打包加密处理,头文件中定义的接口用于对外屏蔽内部的具体实现,但是头文件能够体现出接口的具体定义形式。所以要给使用者提供两个文件:制作的库和头文件

库分为两大类:静态库和动态库。在Windows下,静态库为xxx.lib,动态库为xxx.dll。在Linux下,静态库为xxx.a,动态库为xxx.so。本文主要针对Linux下做出详细讲解。

静态库 动态库
Windows xxx.lib xxx.dll
Linux xxx.a xxx.so

2. 静态库

2.1 命名规则

Linux下关于静态库的命名是有规范的,由三部分组成:前缀、库名字和后缀,格式:libxxx.a,前缀和后缀是固定的,只有xxx部分是可以根据自己的需要改动的。例如:libtest.a。其中,lib为前缀,test为库名字,.a为后缀。

2.2 制作静态库

制作静态库大致分为3步:原材料、生成二进制文件、打包。

2.2.1 原材料

这里以提供了加减乘除四种运算的Calc工程为例,Calc工程目录的结构如下。

Calc
├── include
│   └── head.h
├── lib
└── src
    ├── add.c
    ├── div.c
    ├── mul.c
    └── sub.c

对于上述.c.h文件的内容分别如下:代码存在bug请忽略,重点关注制作静态库的过程。

// add.c
int add(int a, int b)
{
    return a + b;
}

// sub.c
int sub(int a, int b)
{
    return a - b;
}

// mulc
int mul(int a, int b)
{
    return a * b;
}

// div.c
int div(int a, int b)
{
    return a / b;
}

// head.h
#ifndef __HEAD__H__
#define __HEAD_H__
int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int div(int a, int b);
#endif

2.2.2 生成二进制文件

通过gcc -c工具将.c/.cpp文件生成.o文件,也就是把源文件生成二进制文件。在src目录下执行下面的指令:别忘了指定头文件

# gcc -c add.c sub.c mul.c div.c -I ../include/
或者
# gcc -c *.c -I ../include/

执行后的目录结构如下:

Calc
├── include
│   └── head.h
├── lib
└── src
    ├── add.c
    ├── add.o
    ├── div.c
    ├── div.o
    ├── mul.c
    ├── mul.o
    ├── sub.c
    └── sub.o

2.2.3 打包

通过ar工具将生成的二进制.o文件打包,ararchive的缩写,是归档的意思。ar工具的指令格式:ar rcs 静态库的名字 原材料

# ar rcs libmycalc.a add.o sub.o mul.o div.o
-r:如果指定的文件已经存在于库中,则替换它。如果指定的文件不存在于库中,则在后面追加。
-c:禁止在创建库时产生的提示消息。
-s:无论ar命令是否修改库的内容都强制重新生成库符号表。

执行后的目录结构如下:

Calc
├── include
│   └── head.h
├── lib
└── src
    ├── add.c
    ├── add.o
    ├── div.c
    ├── div.o
    ├── libmycalc.a
    ├── mul.c
    ├── mul.o
    ├── sub.c
    └── sub.o

可以通过nm命令查看libmycalc.a库中的文件:

# nm libmycalc.a
add.o:
0000000000000000 T add
sub.o:
0000000000000000 T sub
mul.o:
0000000000000000 T mul
div.o:
0000000000000000 T div

2.2.4 发布静态库

接下来是将制作好的静态库libmyclac.a发布出去给别人用。把刚才制作好的静态库libmycalc.a移动到lib目录下。

# mv libmycalc.a ../lib

执行上述命令后的目录结构如下:

Calc
├── include
│   └── head.h
├── lib
│   └── libmycalc.a
└── src
    ├── add.c
    ├── add.o
    ├── div.c
    ├── div.o
    ├── mul.c
    ├── mul.o
    ├── sub.c
    └── sub.o

最后把includelib这两个目录提供给使用者即可。

2.3 静态库的使用

现在更换一下角色,刚才是静态库的制作者,现在是静态库的使用者。当使用者想要使用静态库时,现在手里有includelib这两个目录,如何使用库中的功能呢?为了体现出现在的身份为使用者,新建目录CalcUser,并在目录下创建main.c文件,具体目录结构如下:

CalcUser
├── include
│   └── head.h
├── lib
│   └── libmycalc.a
└── main.c

main.c文件中的内容如下:

#include <stdio.h>
#include "head.h"

int main()
{
    int sum = add(2, 24);
    printf("sum = %d\n", sum);

    return 0;
}

接下来是对main.c文件进行编译,由于头文件head.h只是提供了接口的声明,并没有具体的实现,所有在编译的时候需要明确指定静态库路径和静态库名字。

# gcc main.c -I ./include/ -L ./lib/ -lmycalc -o app
-I:指定头文件所在路径
-L:静态库路径
-l:静态库的名字,指的是掐头去尾后的静态库名字
-o:指定编译后最终可执行的文件名

执行上述命令后的目录结构如下:

CalcUser
├── app
├── include
│   └── head.h
├── lib
│   └── libmycalc.a
└── main.c

执行后的结果为:sum = 26,表明制作的库时可以被成功使用的。

2.4 静态库工作原理

静态库在链接阶段只会按需链接用到的xxx.o文件,不会把静态库中所有的xxx.o文件都一下子链接到app可执行文件中。查看刚才制作的静态库libmycalc.a中包含哪些.o文件,在测试静态库的程序中,我们只用到了add函数,add函数在add.c文件中定义的,对应的是静态库中的add.o文件,所以在链接的时候只会链接add.o文件和main.o文件到可执行程序中,最终只会把add.o文件和main.o文件一起打包到可执行程序app中。

# nm libmycalc.a
add.o:
0000000000000000 T add
sub.o:
0000000000000000 T sub
mul.o:
0000000000000000 T mul
div.o:
0000000000000000 T div

image

2.5 静态库优缺点

优点:

  • 静态库被打包到应用程序中加载速度快
  • 发布程序无需提供静态库,移植方便

缺点:

  • 更新部署比较麻烦。只要静态库有任何的修改,整个工程都需要重新编译。
  • 浪费系统内存和资源。假设有多个应用程序,每个应用程序都会在内存中拷贝一份静态库。例如,静态库大小为1MB,有2000个这样的程序,将占用接近2GB的内存空间。

3. 动态库

3.1 命名规则

Linux下动态库命名分为3部分:前缀、动态库名字、后缀,例如:libxxx.so,其中前缀lib和后缀.so都是固定的。有的动态库在.so后还会跟上数字,比如:xxx.so.1.7等,这里的1.7指的是动态库的版本。

3.2 制作动态库

制作动态库大致分为3步:原材料、生成二进制文件、打包。

3.2.1 原材料

制作动态库的第一步跟制作静态库的第一步是一样的,我这里直接复制过来,便于大家阅读,不用翻来翻去的。这里仍然以提供了加减乘除四种运算的Calc工程为例,Calc工程目录的结构如下。

Calc
├── include
│   └── head.h
├── lib
└── src
    ├── add.c
    ├── div.c
    ├── mul.c
    └── sub.c

对于上述.c.h文件的内容分别如下:代码存在bug请忽略,重点关注制作静态库的过程。

// add.c
int add(int a, int b)
{
    return a + b;
}

// sub.c
int sub(int a, int b)
{
    return a - b;
}

// mulc
int mul(int a, int b)
{
    return a * b;
}

// div.c
int div(int a, int b)
{
    return a / b;
}

// head.h
#ifndef __HEAD__H__
#define __HEAD_H__
int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int div(int a, int b);
#endif

3.2.2 生成二进制文件

通过gcc -c工具将.c/.cpp文件生成.o文件,也就是把源文件生成二进制文件。在src目录下执行下面的指令:别忘了指定头文件。这里跟制作静态库有差异,还需要指定参数-fpic

# gcc -fpic -c add.c sub.c mul.c div.c -I ../include/
或者
# gcc  -fPIC -c add.c sub.c mul.c div.c -I ../include/
或者
# gcc -fpic -c *.c -I ../include/

执行指令后的目录结构如下:

Calc
├── include
│   └── head.h
├── lib
└── src
    ├── add.c
    ├── add.o
    ├── div.c
    ├── div.o
    ├── mul.c
    ├── mul.o
    ├── sub.c
    └── sub.o

对于参数-fpic进行解释说明:-fPIC 作用于编译阶段,告诉编译器产生与位置无关代码(Position-Independent Code),则产生的代码中,没有绝对地址,全部使用相对地址,故而代码可以被加载器加载到内存的任意位置,都可以正确的执行。这正是共享库所要求的,共享库被加载时,在内存的位置不是固定的。

3.2.3 打包

制作动态库的打包工具不同于静态库的打包工具,有属于动态库自己的打包工具,这个打包工具是gcc工具链。需要指定参数-shared。指令如下:

# gcc -shared *.o -o libmycalc.so

执行指令后的目录结构如下:

Calc
├── include
│   └── head.h
├── lib
└── src
    ├── add.c
    ├── add.o
    ├── div.c
    ├── div.o
    ├── libmycalc.so
    ├── mul.c
    ├── mul.o
    ├── sub.c
    └── sub.o

可以通过nm命令查看libmycalc.so库中的文件:

00000000000010f9 T add
0000000000004020 b completed.8060
                 w __cxa_finalize
0000000000001040 t deregister_tm_clones
0000000000001111 T div
00000000000010b0 t __do_global_dtors_aux
0000000000003e88 d __do_global_dtors_aux_fini_array_entry
0000000000004018 d __dso_handle
0000000000003e90 d _DYNAMIC
0000000000001158 t _fini
00000000000010f0 t frame_dummy
0000000000003e80 d __frame_dummy_init_array_entry
0000000000002118 r __FRAME_END__
0000000000004000 d _GLOBAL_OFFSET_TABLE_
                 w __gmon_start__
0000000000002000 r __GNU_EH_FRAME_HDR
0000000000001000 t _init
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
0000000000001128 T mul
0000000000001070 t register_tm_clones
000000000000113f T sub
0000000000004020 d __TMC_END__

3.2.4 发布动态库

接下来是将制作好的动态库libmyclac.so发布出去给别人用。把刚才制作好的动态库libmycalc.so移动到lib目录下。

# mv libmycalc.so ../lib/

执行上述命令后的目录结构如下:

Calc
├── include
│   └── head.h
├── lib
│   └── libmycalc.so
└── src
    ├── add.c
    ├── add.o
    ├── div.c
    ├── div.o
    ├── mul.c
    ├── mul.o
    ├── sub.c
    └── sub.o

最后把includelib这两个目录提供给使用者即可。

3.3 动态库的使用

现在更换一下角色,刚才是动态库的制作者,现在是动态库的使用者。当使用者想要使用动态库时,现在手里有includelib这两个目录,如何使用库中的功能呢?为了体现出现在的身份为使用者,新建目录CalcUser,并在目录下创建main.c文件,main.c文件中的代码是参考头文件中的函数声明编写的测试程序,具体目录结构如下:

CalcUser
├── include
│   └── head.h
├── lib
│   └── libmycalc.so
└── main.c

main.c文件中的内容如下:

#include <stdio.h>
#include "head.h"

int main()
{
    int sum = add(2, 24);
    printf("sum = %d\n", sum);

    return 0;
}

接下来是对main.c文件进行编译,由于头文件head.h只是提供了接口的声明,并没有具体的实现,所有在编译的时候需要明确指定动态库路径和动态库名字。

# gcc main.c -I ./include/ -L ./lib/ -lmycalc -o app
-I:指定头文件所在路径
-L:动态库路径
-l:动态库的名字,指的是掐头去尾后的动态库名字
-o:指定编译后最终可执行的文件名

执行上述命令后的目录结构如下:

CalcUser
├── app
├── include
│   └── head.h
├── lib
│   └── libmycalc.so
└── main.c

如果此时欣喜若狂的执行代码验证自己制作的动态库是否能被使用的话,很遗憾会报错:

./app: error while loading shared libraries: libmycalc.so: cannot open shared object file: No such file or directory

报错的内容大概是:没有这个动态库libmycalc.so。看到No such file or directory这个提示的时候不要怀疑刚才制作的动态库是不是没有生成,不用怀疑,刚才的动态库确实已经成功生成了,否则编译会报错的。
究其原因是与动态库的工作模式有关,对于elf格式的可执行程序是由ld-linux.so*来完成的,它会先后搜索elf文件的DT_RPATH段->环境变量LD_LIBRARY_PATH->/etc/ld.so.cache文件列表->/lib/,/usr/lib目录,找到库文件后将其载入内存。那么问题来了如何查看可执行程序app是否为elf格式呢?Linux下所有的可执行程序都是elf格式的,可以通过指令file查看:

# file app
app: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=109a42e0018b37516420531eee79126e20617241, for GNU/Linux 3.2.0, not stripped

此外可以通过指令ldd查看可执行程序在运行的时候需要链接哪些动态库。执行下面的指令可以发现,我们自己制作的动态库libmycalc.so没有被加载到。(如果后面有地址就表示被加载到了,写着not found就表示没有加载到)

# ldd app
	linux-vdso.so.1 (0x00007ffdf9ce8000)
	libmycalc.so => not found
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2620058000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f2620263000)

不难发现,我们自己生成的动态库libmycalc.so没有被找到,接下来就是解决这个问题。

3.4 解决无法加载到动态库

动态链接器ld-linux.so*按照规定路径一直都没有查到的话就会报错not found,所以解决问题的思路就是将动态库添加到链接器查找的路径中。

3.4.1 动态库拷贝到/lib目录

这种解决方法最简单粗暴,但是不推荐,因为我们自己制作的库很容易跟别的库冲突,存在着互相覆盖干扰的问题。

# sudo cp ./libmycalc.so /lib

执行完上述指令后,通过ldd app来验证现在能不能找到我们自己制作的库。

# ldd app
	linux-vdso.so.1 (0x00007fff527ea000)
	libmycalc.so => /lib/libmycalc.so (0x00007f25efd1a000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f25efb28000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f25efd38000)

此时发现,不再是not found了,表明可以链接到了,执行./app发现代码能成功运行,打印sum = 26

3.4.2 添加环境变量

在环境变量中添加我们自己制作动态库的路径,分为两种方式:临时设置和永久设置。

3.4.2.1 临时设置

当我们写完代码想立刻验证一下功能是否符合预期,通常就采用这种临时设置的方法,这种方法只在当前终端有效,关闭当前终端或者在其他终端都是不生效的。在终端中执行如下指令:

# export LD_LIBRARY_PATH=./lib
上面的写法会覆盖原来的内容,建议采用下面的写法
# export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./lib

执行完上述指令后,通过ldd app来验证现在能不能找到我们自己制作的库。

# ldd app
	linux-vdso.so.1 (0x00007ffcffda9000)
	libmycalc.so => ./lib/libmycalc.so (0x00007f2934ec5000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2934cc1000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f2934ed1000)

此时发现,不再是not found了,表明可以链接到了,执行./app发现代码能成功运行,打印sum = 26

3.4.2.2 永久设置

永久配置就是把LD_LIBRARY_PATH环境变量写进配置文件中,永久配置又分为用户级别和系统级别,这两者的区别在于我们要修改的配置文件的路径不同。
用户级别:在配置文件~/.bashrc的末尾追加export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:绝对路径,这个文件会在当前用户打开一个终端时被自动调用。追加完成后需要重启终端或者执行source ~/.bashrc
系统级别:在配置文件/etc/profile/.bashrc的末尾追加export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:绝对路径,这个文件会对所有用户有效,追加完成后同样需要重启终端或者执行source /etc/profile/.bashrc

永久配置这种方法在配置文件中添加的路径都是绝对路径。

3.4.3 修改/etc/ld.so.cache文件

修改/etc/ld.conf配置文件,将动态库的绝对路径追加在配置文件的末尾。我的绝对路径为:/home/zys/Calc/lib。很明显,由于需要把动态库的绝对路径追加到文件中,所以这种方法适用于我们制作好动态库以后就不打算再移动库的位置的情况。

# sudo vim /etc/ld.so.conf
添加后的内容
include /etc/ld.so.conf.d/*.conf
/home/zys/Calc/lib

修改完配置文件后,需要执行下面的命令使其生效:

# sudo ldconfig -v
-v:显示配置链接库的过程

此时查看ldd app发现可以链接到动态库。

# ldd app
	linux-vdso.so.1 (0x00007ffd0779c000)
	libmycalc.so => /home/zys/Calc/lib/libmycalc.so (0x00007f7e6445a000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f7e64268000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f7e64478000)

最后通过执行./app验证动态库是否可以被成功调用,运行后发现输出sum = 26表明动态库可以被成功调用。

3.4.4 通过系统函数调用

可以通过Linux系统函数调用动态库,比如:dlopen、dlclose、dlsym

3.5 动态库工作原理

在使用动态库编译程序时,通过执行gcc main.c -I ./include/ -L ./lib/ -lmycalc -o app指令后获得可执行程序app,此时的动态库并不会被打包到app中,只是记录程序调用了动态库中的哪个函数而已。在执行./app可执行程序时动态库不会被加载,那么动态库在什么时候被加载到内存中的?只有在我们程序调用动态库中的函数的那个时刻,动态库才会被加载到内存中。比如,我们在测试程序中调用了动态库的add函数,在调用的那一刻才会把动态库加载到内存中。思考到这里,我认为可以通过实验来验证这个过程,修改一下测试程序main.c

#include <stdio.h>
#include "head.h"

int main()
{
    printf("code start run...\n");
    int sum = add(2, 24);
    printf("sum = %d\n", sum);
    printf("code run finish...\n");

    return 0;
}

执行代码前,我们将动态库链接弄到无法加载的状态,这么做的目的是观察加载动态库的时机。猜想的预期:如果动态库是在执行./app一开始的那一刻就加载的话,那么肯定会加载出错;如果动态库实在真正被调用的那一刻被加载的话,那么会在终端输出code start run...。运行结果如下:

./app: error while loading shared libraries: libmycalc.so: cannot open shared object file: No such file or directory

运行结果表明动态库是在执行./app一开始的那一刻就加载的。如果真是这样的话,那跟动态库的工作原理就相违背了(只有在我们程序中调用动态库的某个函数的那个时刻,动态库才会被加载到内存中)。究其原因,这种实验方法存在着错误的认知:只要采取动态库编译的程序在执行前都要去判断是否存在存在这个过程的,这个过程是固定的,所以并不能作为判断动态库加载时机的实验。真正加载动态库的是/lib64/ld-linux-x86-64.so.2链接器,这个链接器是被操作系统调用的,它会按照顺序在四个路径下搜索要用到的动态库,搜不到就会报错。

动态库在内存空间中只有一份,调用了该动态库中函数的程序,在编译后会有一个指向动态库函数的标记,但是不会复制动态库到内存中。
image

3.6 动态库优缺点

优点:

  • 升级更新方便
  • 程序员可以控制何时加载动态库,比如:dlopen、dlclose、dlsym

缺点:

  • 加载速度比静态库慢点
  • 发布程序需要提供配依赖的动态库

4. 总结

静态库是绝对地址,动态库是相对地址。编译后的二进制代码角度来看,静态库内嵌在代码中,动态库不会被嵌入到代码中。

posted @ 2022-01-16 14:28  _Carl  阅读(411)  评论(0编辑  收藏  举报