链接
在C语言内部,会调用很多库函数,比如printf
,scanf
等等。那么C语言要如何拿到这个函数,并调用它呢?这就涉及到链接的过程。
链接的过程,就是把可执行程序与众多库关联起来的过程,此时就可以调用外部的函数,使用外部的变量等等。
在链接到库时,库分为两种:动态库
和静态库
。通过动态库实现的链接,叫做动态链接
,通过静态库实现的链接叫做静态链接
。
先简单讲解下两个基本指令ldd
和file
,可以用这两个指令来观察动静态库。
ldd
ldd
指令用来查看一个可执行文件动态链接了那些库
ldd 文件名
- 使用
ldd
观察一个动态链接的文件:
动态库文件一般带有
.so
后缀
比如第二行的libc.so
就是C语言的标准库,一个静态库的真实名称是:删掉头部的lib
和尾部的.so
。比如libc.so.6
这个动态库的真实名称是c
。
- 使用
ldd
观察一个静态连接的文件:
此时就会输出not a dynamic executable
,即不是一个动态链接的可执行文件。
file
file
指令用来查看一个可执行文件的链接情况
- 使用
file
指令观察一个静态链接的文件:
statically linked
字段就表示这是一个静态链接
的程序。
- 使用
file
指令观察一个动态链接的文件:
dynamically linked
字段就表示这是一个动态链接
的程序。
库的链接
静态链接
- 静态链接:编译链接时,把库文件的代码全部拷贝到可执行文件中,静态库文件的后缀为
.a
。
优点:只要形成了可执行文件,那么就脱离对库的依赖,可以自主运行,可移植性好
缺点:相同的资源拷贝多份,浪费资源,生成的文件比较大
如果我们想要生成静态链接的文件,在gcc
时额外加上选项-static
:
gcc -static -o test.exe test.c
这样test.exe
文件就是一个静态链接的文件了。
现在我将test.c
通过静态链接编译成sta-test.exe
,并对其使用ldd
:
我们得到了一个sta-test.exe
可执行文件,通过ldd
可以知道这确实是一个动态链接的程序。
其有一个特点,那就是占用的空间非常大,因为静态链接把包含的头文件,都在文件内部拷贝了一份。
动态链接
- 动态链接:在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,编译器会提供动态库的地址,当程序执行到指定的代码段,就会去动态库内部查找对应的内容。
优点:节省资源,整个操作系统所有使用动态库的程序,只需要一份库文件,内存中也只加载一份
缺点:一旦丢失,所有链接该库的程序都无法执行了
直接使用gcc
,不加任何额外选项,就是动态链接:
gcc -o test.exe test.c
也就是说:动态链接是编译器的默认行为。
现在我将test.c
通过静态链接编译成dyn-test.exe
,并对其使用ldd
:
成功创建dyn-test.exe
后,通过ldd
可以看出这是一个动态链接程序。相比于刚刚静态链接的sta-test.exe
,这个动态链接的文件明显小了很多。
库的封装
接下来我们看看库是如何封装出来的,也就是如何制作出动态库与静态库。
讲解这两个库的封装前,先来看看同时编译多个文件的方式:
当前目录下有以下文件:
现在要在test.c
中使用myMath.c
和myHello.c
的函数,myMath.c
和myHello.c
如下:
myMath.c
:
#include "myMath.h"
void add(int x, int y)
{
printf("%d + %d = %d\n", x, y, x + y);
}
void sub(int x, int y)
{
printf("%d + %d = %d\n", x, y, x - y);
}
myHello.c
:
#include "myHello.h"
void HelloWorld()
{
printf("Hello World!\n");
}
void HelloLinux()
{
printf("Hello Linux!\n");
}
两个头文件myMath.h
和myHello.h
如下:
myMath.h
:
#pragma once
#include <stdio.h>
void add(int x, int y);
void sub(int x, int y);
myHello.h
:
#pragma once
#include <stdio.h>
void HelloWorld();
void HelloLinux();
简单来说就是要通过头文件.h
,链接到两个.c
文件内部的函数:
在test.c
中:
#include "myHello.h"
#include "myMath.h"
int main()
{
add(3, 5);
sub(100, 88);
HelloLinux();
HelloWorld();
return 0;
}
首先引入了两个.h
头文件,这样编译时就可以通过头文件内部的声明,到对应的.c
文件中找到所需的函数了。
通过指令gcc -o test.exe test.c myMath.c myHello.c
同时编译三个.c
文件:
这样我们就实现了多个文件分开实现函数,最后让一个.c
文件可以调用其它文件内部的函数了。
但是一般来说,我们不会直接把.c
文件交给别人,因为.c
内部是源代码,源代码是不能随意暴露的。
因为多个文件之间的相互,是发生在链接
这个过程的,而链接的前一步文件状态是.o
目标文件。因此我们可以先把.c
通过编译变成.o
文件,此后再进行链接,同样可以链接成功。
相比于给出.c
文件,.o
文件内部已经是二进制了,无法直接看到源代码,因此可以保护代码。
可以通过gcc
的-c
选项将一个.c
文件编译为.o
文件:
将两个.o
文件与test.c
一起编译试试看:
通过指令gcc -o test.exe myMath.o myHello.o test.c
我们成功把test.exe
编译了出来,并成功执行了。
以上就是一个基本的多文件编译场景,但是大家不妨想一个问题,当引入的外部文件变多了,那这个gcc
指令后面不是要不停的加更多的文件名?这会导致编译变得很麻烦,每次都要手动链接很多外部文件。
因此我们决定把所有的
.o
文件进行打包,此时这个包就叫做库
!
现在我们就知道了库
是如何出现的了,那么我们要如何把这些文件打包为库呢?
在博客开头就说明过,库分为动态库和静态库,这两种库的封装是不同的,接下来一一讲解。
静态库
封装静态库需要用到指令ar
,打包静态库指令为:
ar -rc 静态库名 文件名
-r
:replace
,如果该库原先存在,则覆盖原先的库-c
:create
,如果该库原先不存在,则创建
通过指令把myHello.o
和myMath.o
封装为静态库libMyC.a
此时当前目录下就多出了一个libMyC,a
的静态库。
现在在gcc
编译时,链接到静态库libMyC,a
:
gcc -o test.exe test.c -l MyC -L ./
-l
:指明要连接的库,注意:libMyC.a
的真实名称要去掉前面的lib
和后缀.a
-L
:指明连接到的库所处的路径
执行指令后,运行test.exe
程序:
我们确实通过这个静态库成功编译了test.exe
,最后也成功执行了test.exe
,说明我们对库的封装是没问题的。
动态库
动态库的封装比较麻烦,从生成.o
文件时,就要开始准备动态库。
如果想把.o
文件封装为动态库,那么这个.o
文件中需要有位置无关码
,至于这个位置无关码的作用,在稍后会讲解。
在gcc -c
时,额外加上一个-fPIC
选项,即可生成位置无关码,此时还会自动生成同名.a
文件。
当前目录下没有任何.o
文件:
现在要通过-fPIC
生成位置无关码,指令如下:
gcc -c -fPIC myMath.c
gcc -c -fPIC myHello.c
以上指令中,不需要通过-o
来指定生成的文件名,因为会自动生成同名,o
文件。
执行结果:
目录中多出两个.o
文件,这两个文件就是带有位置无关码的目标文件了。
封装动态库需要用到gcc
的-shared
选项:
gcc -shared -o libMyC.so myMath.o myHello.o
执行结果:
这条指令的功能就是:利用myHello.o
和myMath.o
生成动态库libMyC.so
。
同样的,现在再次编译test.c
:
gcc -o test.exe test.c -l MyC -L ./
同样的,要通过-l
来指定动态库,通过-L
指定库的路径。
输出结果:
可以看到,我们成功编译了test.exe
出来,但是为什么无法执行呢?
使用dll
查看一下test.exe
的动态库:
其中libMyC.so
显示为not found
,为什么找不到呢?我们不是在编译时指明了动态库的路径是./
即当前目录吗?
在gcc -o test.exe test.c -l MyC -L ./
时,是为编译器指明了动态库的位置,但是程序运行时,依然不知道动态库的位置,程序运行时只会去系统指定的目录下找动态库。
解决方案就是,让这个静态库的路径进入到系统默认的库中。
- 修改环境变量
LD_LIBRARY_PATH
环境变量LD_LIBRARY_PATH
内部存储了一些路径,其用于辅助动态库查找,被记录到这个环境变量中的路径,都会变成默认的动态库查询路径。
我的当前路径为/home/box-he/CSDN/lib
,执行指令:
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/box-he/CSDN/lib
把当前路径写到环境变量中,现在就可以正常运行test.exe
了:
- 在
/lib64
中创建软链接
/lib64
是一个默认的动态库存储目录,在这个目录内部,创建一个指向自己的动态库libMyC.so
的软链接,就可以把自己的动态库放进默认的动态库了。
我的当前路径为/home/box-he/CSDN/lib
,执行指令:
sudo ln -s /home/box-he/CSDN/lib/libMyC.so /lib64/libMyC.so
此时在lib64
内部就有一个libMyC.so
的软链接了。
执行结果:
经过软链接后,在/lib64
下就多出一个软链接文件,随后test.exe
也可以正常执行了。
- 修改配置文件
在目录/etc/ld.so.conf.d/
下,允许自定义用户级的配置文件,我们随便在这个目录下创建一个以.conf
结尾的文件,然后再在文件内部写上动态库的路径即可,这个过程需要root
权限。
先创建一个test.conf
文件在该目录下:
在其内部写入动态库的路径:
我在内部写了/home/box-he/CSDN/lib/libMyC.so
,随后test.exe
就可以正常执行了。
库的加载
动态库与静态库的加载,符合以下规则:
- 如果同时提供动态库和静态库,默认使用动态库
- 如果只提供静态库,那么这个库使用静态库,其他库使用动态库
- 如果使用
-static
选项,所有库都尽量使用静态库- 如果使用
-static
选项,只有静态库而没有动态库,会报错
接下来我们从操作系统角度再来看看库是如何加载的。
对于静态库而言,其简单粗暴地把静态库的内容拷贝到自己的代码段中,此后静态库的代码就相当于进程自己的代码
而对于动态库,这个过程就比较复杂了,接下来主要讲解动态库的加载。
动态库在整个内存中只加载一份,所有调用动态库的进程共享动态库
因此动态库也叫做共享库
。
先看看CPU
是如何执行进程的代码的:
首先CPU
的一个叫做PC指针
的寄存器,会指向要被执行的代码地址,不过这个地址是虚拟地址。于是CPU
拿着虚拟地址去找页表,然后得到对应的物理地址,从而访问到内存中的代码,进而执行。
也就是说,执行代码的过程,需要拿到代码的虚拟地址,再通过页表拿到物理地址,最后执行代码。
动态库内部本质也都是代码,那么问题就是,动态库代码的虚拟地址在哪里?动态库代码的物理地址在哪里?
虚拟地址是在编译的时候就产生的,也就是说在程序执行前,就已经分配好了虚拟地址。当一个进程被加载到内存中时,会带着自己的虚拟地址,代码和数据等一起进入,随后拿虚拟地址去初始化进程地址空间,页表等等。
页表也要去创建自己的虚拟地址到物理地址的映射关系。此时就同时有了物理地址和虚拟地址了。
动态库的代码不是直接通过虚拟地址到物理地址的映射来访问的,而是通过起始地址 + 偏移量的方式来访问的。
在可执行程序的文件中,会给动态库预留一个起始地址的虚拟地址,随后通过页表去内存中找到那个动态库的物理地址,随后创建映射关系。而动态库内部会有很多函数,此时所有函数都以起始地址为标准,在可执行文件中存储的是偏移量。当要访问动态库的函数时,就根据动态库的起始地址与偏移量,来获取函数的物理地址,进而访问到函数。
因为动态库在内存中的位置是随机的,每次都可能不一样,而函数之间的偏移量是确定的,因此使用这种偏移量的方式,只需要确定动态库的起始地址,就知道整个动态库所有函数的地址。