1 链接器
1.1 链接是什么
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。
- 链接可以执行与编译时,也就是源代码被翻译成机器代码时;
- 也可以执行与加载时,也就是在程序被加载器(loader)加载带内存并执行时
- 甚至也可执行于运行时,也就是有程序来执行
链接器在开发中是一个关键角色,因为它使得分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以分解成更小、更好管理的模块。可以单独修改和编译这些模块。理解链接有以下的好处:
理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到缺少模块、库或者不兼容版本引起的链接器错误,当你理解了链接器如何解析引用、什么是库以及链接器是如何使用库来解析引用时,你就能够较好的解决这类问题
理解链接器将帮助你避免一些危险的编程错误。Linux链接器解析引用时所做的决定可以不动声色影响你程序的正确性。在默认情况下,错误的定义多个全局变量的程序将通过链接器,而不产生任何警告信息。
理解链接器将帮助你理解语言的作用域规则是如何实现的。全局变量和局部变量、static变量和static函数,在低层到底有何区别?
理解链接器帮助你理解其他重要系统概念。链接器产生的可执行目标文件咋子系统功能中扮演关键角色,如加载运行程序、虚拟内存、分页、内存映射。
理解链接器将使你能够利用共享库。共享库和动态链接在现代操作系统日益重要,掌握如何动态链接原理极其重要。
1.2 编译器驱动程序
我们将以下面两个源程序main.c
和sum.c
作为例子,来说明静态链接器ld
的工作流程。
首先我们在
shell
输入以下命令来调用GCC驱动程序(下面分析以main.c
为例):下图概括了从源文件翻译成可执行文件时的行为(若想看这些步骤,加入1
gcc -Og -o prog main.c sum.c
-v
):- 输入上述语句后,会先执行C预处理器
cpp
,它将源程序main.c
经过宏替换、头文件展开后生成一个中间文件main.i
1
cpp [other arguments] main.c /tmp/main.i
- 接下来,运行C编译器
ccl
,它将预处理后的文件main.i
进行编译,生成汇编文件main.s
1
ccl /tmp/main.i -Og [other arguments] -o /tmp/main.s
- 接着,驱动程序运行汇编器
as
,它将汇编文件翻译成一个可重定位目标文件mian.o
1
as [other arguments] -o tmp.main.o /tmp/main.s
- 同样方式得到
sum.o
。最后,运行链接器程序ld
,将main.o
和sum.o
以及一些比如的系统目标文件组合起来,创建一个可执行文件prog
1
ld -o prog [system object files and args] /tmp/main.o tmp/sum.o
- 输入上述语句后,会先执行C预处理器
1.3 静态链接器
静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接、可加载运行的可执行文件作为输出。输入的可重定位目标文件由各种代码和数据节组成。在构造可执行文件中,链接器主要完成两个任务:
- 符号解析:每个符号对应一个函数、一个全局变量和一个静态变量,符号解析的目的就是将每个符号引用正好和一个符号定义关联起来。那么当然在不同目标文件中引用的同一全局变量或函数,链接器需要将其解析为同一个实体。**
- 重定位:地址重定位指的是在链接过程中,需要将不同目标文件中的函数和变量的地址进行调整,使得它们在最终的可执行文件中能够正确地链接到一起。生成可执行文件
目标文件有三种形式:
- 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行文件
- 可执行文件:包含二进制代码和数据,其形式可直接复制到内存并执行
- 共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
1.4 可重定位目标文件
.text
段存放编译好的机器代码。查看机器代码,需要使用反汇编工具objdump将机器代码转换成汇编代码。由于4字节对齐,.text
大小为0x51,实际占用空间大小为0x54。.data
段用来存放已初始化的全局变量和静态变量的值。.bss
段存放未初始化的全局变量和静态变量,需要注意的是,被初始化为0
的全局变量和静态变量也存放在bss中。bss
可理解为Better Save Space
。局部变量既不在data
中,也不在bss
中。实际上bss
段并不占据实际的空间,它仅仅是只是一个占位符。.rodata
段存放只读数据。例如printf
中的格式串和switch
语句中的跳转表以及const全局变量
就是存放在这个区域。.symtab
符号表,它存放在程序中定义和引用的函数和全局变量的信息
1.5 符号和符号表
每个可重定位模块m
都有一个符号表、它包含m
定义的和引用的符号的信息,在链接器上下文,有三种不同符号:
- 全局符号:由模块
m
定义并能被其他模块引用的去安居符号,其对应于非静态的函数和全局变量 - 外部符号:由其他模块定义被被模块
m
引用的去安居符号,称为外部符号,对应于其他模块定义的非静态C函数和全局变量 - 局部符号:只被模块m定义和引用的局部符号,对应于static函数、static全局变量。这些符号在模块
m
中可见,单不能被其他模块引用
局部变量由堆栈管理,因此链接器是不知道局部变量的,因此在链接器对变量的是对函数和全局变量
链接器解析多重定义的全局符号:对于全局符号,若有多个模块定义同名的全局符号的情况,会发生什么?
首先编译器向汇编器输出每个全局符号,这些符号或是强或是弱,汇编器把这个信息隐含的编码在可重定位文件的符号表里。函数和以初始化的全局变量是强符号,未初始化的全局变量是弱符号,Linux有以下规则:
- 不允许有多个同名的强符号
- 如果有一个强符号和多个弱符号同名,那么选择强符号
- 如果有多个弱符号同名,那么可任选一个
1.6 静态库
- 静态库:将所有编译好的目标模块(
sum.o\mul.o
等待)打包成一个单独的文件,这个文件就是静态库。它可以用作链接器的输入,当链接器构造一个可执行文件时,它只复制静态库里被应用程序引用的目标模块。一般都以libxxx.a
作为静态库标识
制作静态库:
- 首先编译处
xxx.o文件
- 接着利用AR工具打包
**.o
文件1
2gcc -c add.c mul.c
ar rcs libmath.a add.o mul.o - 使用第三方静态库 示例如下:
1
gcc -o main main.c -L. -lmath
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17trluper@DESKTOP-67ADUGH:/home/project/code/link$ ar rcs libmath.a mul.o sum.o Math.h
trluper@DESKTOP-67ADUGH:/home/project/code/link$ ls
Math.h libmath.a main.cpp mul.cpp mul.o sum.cpp sum.o
trluper@DESKTOP-67ADUGH:/home/project/code/link$ g++ -o main main.cpp -L. -lmath
trluper@DESKTOP-67ADUGH:/home/project/code/link$ ls
Math.h libmath.a main main.cpp mul.cpp mul.o sum.cpp sum.o
trluper@DESKTOP-67ADUGH:/home/project/code/link$ ./main
5 6
trluper@DESKTOP-67ADUGH:/home/project/code/link$ rm -rf Math.h
trluper@DESKTOP-67ADUGH:/home/project/code/link$ rm -rf main
trluper@DESKTOP-67ADUGH:/home/project/code/link$ g++ -o main main.cpp -L. -lmath
main.cpp:2:10: fatal error: Math.h: No such file or directory
^~~~~~~~
compilation terminated.
trluper@DESKTOP-67ADUGH:/home/project/code/link$ ls
libmath.a main.cpp mul.cpp mul.o sum.cpp sum.o - 从上面可看出,
-L.
表示从当前目录找该库; - 删除
Math.h
后出错,说明头文件寻找并不会在libmath.a
寻找
1.6.1 链接器如何使用静态库来解析引用
在符号解析阶段,链接器会安装我们键入的命令行顺序从左到右来扫描可重定位文件和静态库存档文件,在这次扫描中,链接器会维护三个集合E、U、D
E
:可重定位目标文件集合,在这个集合的可重定位目标文件最后会被合并起来形成可执行文件U
:一个未解析符号(即应用了但尚未定义的符号)集合,这个集合扫描完成后,应该是空的,否则会程序终止,出现解析错误D
:已经定义的符号集合
初始化上述的三个集合都为空,执行命令行:
- 对于命令的每个文件
f
,链接器先判断是一个目标文件还是库存档文件。- 若
f
是目标文件,则会把f
添加到E
,并且修改U\D
来来反映f
中的符号定义和引用 - 若
f
是静态库存档文件,那么链接器就尝试匹配U
中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m
,定义了一个符号解析U
中的一个引用,那么九将m
加入到E
中。对存档文件的成员目标文件依次进行这个过程,直到U\D
都不再变化。此时,任何不在E
中的成员目标文件被丢弃。
- 若
- 如果当前链接器完成命令行上输入文件的扫描后,
U
非空,链接器就会输出一个错误终止,否则,就是要E中目标文件构建合并可执行文件。
下面就是一个典型因命令行顺序不对,造成的解析引用错误(未定义引用错误) 1
2
3
4
5trluper@DESKTOP-67ADUGH:/home/project/code/link$ g++ -L. -lmath -o main main.cpp
/tmp/ccZFaj3X.o: In function `main':
main.cpp:(.text+0x13): undefined reference to `Sum(int, int)'
main.cpp:(.text+0x25): undefined reference to `Mul(int, int)'
collect2: error: ld returned 1 exit status
- 一般生成的可执行文件在最前面
- 接着是各种源文件
- 把库文件放在最后
1.7 共享库
链接器将目标文件和库文件的代码和数据全部拷贝到可执行文件中,形成一个独立的、包含所有必需代码和数据的可执行文件。在运行时,可执行文件不需要依赖外部库文件,所有需要的代码和数据都已经包含在可执行文件中,因此
- 优点:对运行环境的依赖性较小,具有较好的兼容性,方便分发和部署,不需要外部依赖
- 缺点:生成的程序比较大,需要更多的系统资源,在装入内存时会消耗更多的时间;库函数有了更新,必须重新编译应用程序
共享库是为了解决静态缺点的一种方法,共享库也是目标模块,在运行或加载时,可以加载带任意的内存地址,并行一个内存中的程序链接起来,这个过程称为动态链接。动态库在Linux当中未libxxx.so
,在windows中称为ddl
- 首先,在任何给定的文件系统中,对于一个库只有一个
so
文件,所有引用该库的可执行目标文件共享这个.so
文件的代码和数据,而不是像静态库的内容那样复制和嵌入带引用它们的可执行文件中 - 其次,在内存中,一个共享库的
.txt
节的一个副本可以被不同正在运行的进程共享。
构造共享库 1
gcc -shared -fpic -o libmath.so sum.cpp mul.cpp
1
2
3
4
5trluper@DESKTOP-67ADUGH:/home/project/code/link$ g++ -shared -fpic -o libmath.so sum.cpp mul.cpp
trluper@DESKTOP-67ADUGH:/home/project/code/link$ ls
Math.h libmath.a libmath.so main.cpp mul.cpp sum.cpp
trluper@DESKTOP-67ADUGH:/home/project/code/link$ g++ -o prog2 main.cpp ./libmath.so
trluper@DESKTOP-67ADUGH:/home/project/code/link$ ./prog2
1.8 从应用程序中加载和链接共享库
上面介绍的静态库和动态库链接都是在编译时链接到应用的,然而有些程序还可能在它运行时进行动态链接器加载和链接某个共享库,这就是运行时加载和链接。
- 分发软件:微软应用常常利用共享库来分发软件更新。
- 高性能Web服务器:许多We服务器生成动态内容,比如个性化的Web页面、和广告标语。
上面的思路是将每个生成动态内容的函数打包在共享库,当一个Web浏览器的请求到达时,服务器动态加载和链接适当的函数,然后直接调用它。
Linux系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库 dlsym
的函数参数是一个指向前面已经打开的共享库句柄和一个symbol,如果该名字存在,返回呼号地址,否则返回NULL
dlclose
果没有其他人在使用这个共享库,就关闭 dlerror
打印调用三个函数最近的错误
示例: