链接(linking)在计算机系统文献中处于一个相当尴尬的地位,它处在编译器、计算机体系结构和操作系统的交叉点上,因此没有一个特别好的领域能够描述它。而要理解链接,就得理解代码生成、机器语言编程、程序实例化和虚拟内存的内容。典型的属于事倍功半的学习部分。
那为什么还要理解链接器呢,全都丢给编译器不好么?
因为理解链接器有助于:
1.帮助构造大型程序
2.避免一些危险的编程错误
3.理解语言的作用域
4.理解共享库
0xFF 部分来自C/C++/Java程序员的疑问
- gcc命令到底做了什么?为什么编译的时候有一大堆的.o文件,是干什么用的?
- 为什么
Foo::bar(int, long)
会被编码为bar__3Fooil
?为什么编译时bar__3Fooil
这类内容有时候会出现“未解析”的情况? - 为什么编译的时候会出现
Linux> gcc p.o libx.a liby.a libx.a
的情况?为什么指定静态库有时候会需要固定顺序,而有时候还会需要出现重复的库?我在程序中也引用了几个体积不小的库,为什么最后打出来的包也没那么大? - Linux上的
.a
.so
a.out
和win上的.lib
.dll
.exe
有什么区别?动态库的原理是什么? - 可执行文件内我们都知道是机器码,但是具体是什么结构?因为我们都知道执行shell脚本的时候由开头的
#!/bin/sh
指定解释器路径,那我们执行./a.out
的时候,由什么机制来指定? - 为什么引用多重定义且未经初始化的全局变量十分危险?
- 代码中的
static
是在哪一步生效的?
0x00 全文总结(放开头)
链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有三种不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到存储器中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dopen库的函数时。
链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终存储器地址,并修改对那些目标的引用。
静态链接器是由像GCC这样的编译驱动器调用的。
多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引入的微妙错误。
0x01 编译器驱动程序
一句话内容:什么是gcc,gcc = [cpp + cc1 + as] + ld,前三个用于生成可重定位文件.o,最后一个用于组合创建一个可执行目标文件a.out
先来看两段c代码是如何生成可执行文件的。
code/link/main.c
int sum (int *a, int n);
int array[2] = {1,2};
int main () {
int val = sum(array , 2);
return val;
}
code/link/sum.c
int sum (int *a, int n) {
int i, s = 0;
for(i = 0; i < n; i++){
s += a[i];
}
return s;
}
在linux上我们可以通过系统提供的gcc编译器将其编译成可执行文件prog。命令如下:
linux> gcc -Og -o prog main.c sum.c
然后我们很自然的跑结果
linux> ./prog
那么gcc
命令做了什么事情?事件上这是很多个子脚本的合集(编译的时候可以gcc -v
选项来查看这些步骤)。
首先c预处理器(cpp)把源码翻译成一个ASCII码中间文件main.i
cpp [args] main.c /tmp/main.i
/tmp/main.i
# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "main.c"
int sum(int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}
运行C编译器(cc1)再f翻译成一个ASCII汇编语言文件main.s
cc1 /tmp/main.i -Og [args] -o /tmp/main.s
cc1执行时返回语法分析(我这里cc1的路径在/usr/local/libexec/gcc/x86_64-pc-linux-gnu/8.2.0/cc1
,可以用gcc -v
具体执行时或者find
命令来找到,做一下aliased
转义即可)
$ cc1 /tmp/main.i -Og -o /tmp/main.s
main
Analyzing compilation unit
Performing interprocedural optimizations
<*free_lang_data> <visibility> <build_ssa_passes> <opt_local_passes> <targetclone> <free-fnsummary> <whole-program> <profile_estimate> <fnsummary> <inline> <pure-const> <free-fnsummary> <static-var> <single-use> <comdats>Assembling functions:
<materialize-all-clones> <simdclone> main
Time variable usr sys wall GGC
phase setup : 0.00 ( 0%) 0.00 ( 0%) 0.00 ( 0%) 1243 kB ( 86%)
phase parsing : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 50%) 135 kB ( 9%)
phase opt and generate : 0.01 (100%) 0.00 ( 0%) 0.01 ( 50%) 61 kB ( 4%)
parser function body : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 50%) 2 kB ( 0%)
final : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 50%) 1 kB ( 0%)
initialize rtl : 0.01 (100%) 0.00 ( 0%) 0.00 ( 0%) 12 kB ( 1%)
TOTAL : 0.01 0.00 0.02 1449 kB
/tmp/main.s
.file "main.i"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $2, %esi
movl $array, %edi
call sum
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.globl array
.data
.align 8
.type array, @object
.size array, 8
array:
.long 1
.long 2
.ident "GCC: (GNU) 8.2.0"
.section .note.GNU-stack,"",@progbits
然后执行一个汇编器(as)翻译成一个可重定位目标文件main.o
as [args] -o /tmp/main.o /tmp/main.s
/tmp/main.o
sum.o
由上述同样的方法生成,最后Linux运行链接器程序ld,将两个.o文件和一些必要的系统目标程序组合起来
ld -o prog [files and args] /tmp/main.o /tmp/sum.o
$ ls -lh
total 20K
-rwxr-xr-x. 1 root root 11K Dec 24 10:24 prog
-rw-r--r--. 1 root root 101 Dec 24 10:52 main.c
-rw-r--r--. 1 root root 90 Dec 24 10:16 sum.c
(我这里的可执行目标文件大概11k大小左右)
然后我们在shell
上执行我们的结果。
linux> ./prog
可执行目标文件后面会详解,简单的说就是此时shell
调用了操作系统一个叫做loader
的函数,它把prog
中的代码和数据复制到内存,然后控制转移到这个程序的开头。
0x02 静态链接
一句话内容:链接器的主要功能就是符号解析(关联所有的定义和引用)和重定位(把所有引用都指向正确的内存)
像Linux LD
这样的静态链接器(static linker),它以一组可重定位目标文件和命令行参数作为输入,生成一个完全可链接的、可以加载的和运行的目标文件为输出。输入的可重定位目标文件由各种数据节(section)构成,指令、初始化的全局变量、未初始化的全局变量分别在不同的数据节中。
链接器在构造可执行文件时主要有两个任务:
-
符号解析(symbol resolution)。识别目标文件定义和引用的
符号
,每个符号对应一个函数,一个全局变量或者一个静态变量(static)。符号解析的目的是将每个符号
引用正好和一个符号
定义关联起来。 -
重定位(relocation)。编译器和汇编器生成从0开始的代码和数据节。链接器就是把每个符号定义和一个内存位置关联起来,从而实现
重定位
,即修改所有对这个符号的引用,让引用都指向这个内存位置。
(未写完 2020/12/22 by xana)
(续 2020/12/24 by xana)
0x03 目标文件
一句话内容:不同平台的目标文件格式不同,但是概念是类似的
忽略编译过程中的临时文件,我们关注的目标文件主要有三种:
- 可重定位目标文件,包含二进制代码和数据,编译时可以合并成一个可执行目标文件
- 可执行目标文件,包含二进制代码和数据,可以被直接复制到内存并执行
- 共享目标文件,一种特殊类型的可重定位目标文件,可以加载或者运行动态加载入内存并链接
前面提到过,编译器和汇编器生成的是可重定位目标文件(包括共享目标文件),而链接器生成共享目标文件,他们的格式都是特定的,各不相同。贝尔实验室第一个Unix系统使用的是a.out格式;Windows使用PE格式,MacOS使用Mach-O格式,x86-64的Linux和Unix使用ELF格式。他们的概念是类似的。
0x04 可重定位目标文件
一句话:这一节主要讲.o
文件的格式,建议看书
典型的ELF可重定位目标文件格式:
节(除节头部表) | 说明 |
---|---|
ELF头 | 16byte的系统字大小和和字节顺序 + ELF头大小,目标文件类型(.o),机器类型(x86),节头部表的文件偏移和条目的大小和数量 |
.text | 已编译程序的机器码 |
.rodata | read-only data(比如printf的字符串,和swtich的跳转表) |
.data | init过的 global & static c-vars |
.bss | 未init过或init(0)的 global & static c-vars(纯占位,省空间用) |
.symtab | 符号表,存放定义和引用的 funciton 和 global vars 的信息 |
.rel.text | .text节位置的列表,再和其他.o文件组合时修改 |
.rel.data | 被引用或定义的 global vars 的重定位信息 |
.debug | 调试符号表,-g选项才获得 |
.line | C代码行号和.text映射表,-g选项才获得 |
.strtab | 字符串表,包含.symtab和.debug的符号表,以及节头部中的节名字 |
节头部表 | 描述不同节的位置和大小 |
(.bss
与其说是 Block Storage Start,不然说是 Better Save Space,以此来区分和 .data
的区别~)
0x05 符号和符号表
https://blog.csdn.net/xiaohaopei/article/details/82503334
每个重定位目标模块m都有一个符号表,包含定义和引用的信息。有三种符号:
-
全局符号。由m定义且能被其他模块引用。对应了
not-static
的c-function
和global-var
。 -
外部符号。由其他模块定义且被m引用。对应了其他模块的
not-static
的c-function
和global-var
。 -
局部符号。只被m定义且被m引用。对应了
static
的c-function
和global-var
,不能被其他模块引用。
注意:在C中,任何带有static属性的全局变量和函数都是模块私有的,不带static属性的全局变量和函数都是公共的,可以被其他模块访问。尽可能使用static来保护你的变量和函数是很好的习惯。
补充阅读:[C/C++] static在C和C++中的用法和区别
(续 2020/12/26 by xana)
0x06 符号解析
一句话:符号解析遵循强符号>弱符号的规则,所以变量初始化是一个好习惯。
以上面出现过的main.c
为例(见0x01),在ld
执行之前都能顺利进行,而在ld
执行的时候返回了一个undefined reference
的错误,意思是链接器无法解析sum
函数的引用,这就涉及到链接器的一个重要功能,符号解析(见0x02)
$ gcc main.c
/tmp/ccI0aogY.o: In function `main':
main.c:(.text+0x13): undefined reference to `sum'
collect2: error: ld returned 1 exit status
在相同模块内的定义和引用,逻辑是清晰明了的,但对全局符号的解析就棘手得多,因为多个目标文件可能会有相同的符号名字,此时链接器要么标记一个错误,要么抛弃其他的定义,如果不够熟悉的程序员可能就会在这里犯错。
简单的说,Linux编译系统在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)符号,或者是弱(weak)符号,记录在.o
文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。Linux链接器用下面的规则来处理多重定义:
- 不允许同名强符号。
- 存在一个强符号和多个弱符号,选择强符号。
- 存在多个弱符号,任选一个弱符号。
比如我们继续执行下面的逻辑:
$ cp main.c main0.c
$ gcc main.c main0.c
/tmp/ccTVhznk.o:(.data+0x0): multiple definition of `array'
/tmp/cctGNo8T.o:(.data+0x0): first defined here
/tmp/ccTVhznk.o: In function `main':
main0.c:(.text+0x0): multiple definition of `main'
/tmp/cctGNo8T.o:main.c:(.text+0x0): first defined here
/tmp/cctGNo8T.o: In function `main':
main.c:(.text+0x13): undefined reference to `sum'
/tmp/ccTVhznk.o: In function `main':
main0.c:(.text+0x13): undefined reference to `sum'
collect2: error: ld returned 1 exit status
可以看到链接器ld
会抛出multiple definition
的错误,意思是重定义了强符号(array,main)
(待更新内容)
网友评论