"链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程",说的通俗一点(不准确),链接就是把编译生成的*.o文件整合成可执行文件的过程。通常编译器会帮我们把预编译、编译、汇编和链接的过程都给做了,我们不经常用到链接器,资料也少,下面就将笔者的体会和整理奉上。
注:广义的编译指的是预编译、编译、汇编和链接整个过程,狭义的编译指*.i文件生成*.o文件的过程。
一个源程序经过预编译、编译、汇编和链接成为可执行文件,相信大家已经很熟悉了,肯定有不少读者用gcc还原过这个过程,下面我们再还原一遍,重点关注链接这一步。
第一节 文件列表
1. 我们要编译的文件列表
├── func.c
└── main.c
2. 文件内容如下
main.c
void func();int main_global = 2;static int static_global = 3;int main(){ int local = 3; func(); return 0;}
func.c
extern int main_global;void func(){}
第二节 分解编译过程
看完上面的文件内容和关系,相信大家用一条命令就生成了可执行文件main:
# gcc -o main main.c func.c 注:此命令会生成可执行文件main
或者多用几条命令也可生成:
# gcc -c main.c 注:此命令会生成main.o
# gcc -c func.c 注:此命令会生成func.o
# gcc -o main main.o func.o 注:此命令会生成可执行文件main
下面我们把编译过程分解下:
第1步. 预编译(将宏、头文件等展开)
# gcc -E main.c -o main.i 注:此命令会生成main.i
# gcc -E func.c -o func.i 注:此命令会生成func.i
第2步. 编译(生成汇编语言)
# gcc -S main.i -o main.s 注:此命令会生成main.s
# gcc -S func.i -o func.s 注:此命令会生成func.s
第3步. 汇编(生成可重定位目标文件)
# gcc -c main.s -o main.o 注:此命令会生成main.o
# gcc -c func.s -o func.o 注:此命令会生成func.o
第4步. 链接(生成可执行文件)
# gcc -o main main.o func.o 注:此命令会生成可执行文件main
好了,至此,我们通过gcc将源文件编译成了可执行文件。
读者可能会问:说好的链接器ld呢?别急,链接器理应出现在第4步。现在,我们尝试用ld链接完成第4步。
第三节 用ld完成链接过程
1. 使用ld
我们man了下ld,用法如下:
ld files... [options] [-o outputfile]
于是,我们满怀信心,不假思索地写下了下面的语句:
# ld -o main main.o func.o
2. 出错
但是,结果并不如预期,出现了如下错误:
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000e8
3. 错误原因
错误提示说的很明确,找不到入口符号_start ,我们要在链接的时候指明程序入口。
4. 解决
既然如此,我们用-e选项指明程序入口。
# ld -o main main.o func.o -e main
链接没报错,但是当我们运行main时,提示Segmentation fault,这是因为链接时还缺少一些参数。
那么还缺少什么参数呢,让我们看下上一节中的第4步,即用gcc链接目标文件的命令:
# gcc -o main main.o func.o 注:此命令链接main.o func.o, 生成可执行文件main
不难猜测,上面的命令用到了链接器, 如何验证呢,其实熟悉gcc的读者很清楚,给gcc加个-v参数,就可以打印gcc的执行过程用到的命令。好了,让我们加个-v参数,一探究竟吧!
# gcc -v -o main main.o func.o
打印出如下信息:
...这里省略了一些打印信息...
/usr/local/libexec/gcc/x86_64-unknown-linux-gnu/4.8.2/collect2 --eh-frame-hdr -m elf_x86_64 -dynamic-linker /lib64/ld-linux-x86-64.so.2 -o main /usr/lib/../lib64/crt1.o /usr/lib/../lib64/crti.o /usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/crtbegin.o -L/usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2 -L/usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/../../../../lib64 -L/lib/../lib64 -L/usr/lib/../lib64 -L/usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/../../.. main.o func.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/crtend.o /usr/lib/../lib64/crtn.o
好了,看到/usr/local/libexec/gcc/x86_64-unknown-linux-gnu/4.8.2/collect2了吗,这就是个链接器,什么?不是ld吗?别慌,collect2只是ld的一个别名。看到-l参数了吧,后面跟的就是链接用到的库,-L参数是查找路径。我们用上面的命令完成最后的链接吧(当然,你可以把/usr/local/libexec/gcc/x86_64-unknown-linux-gnu/4.8.2/collect2换成ld):
/usr/local/libexec/gcc/x86_64-unknown-linux-gnu/4.8.2/collect2 --eh-frame-hdr -m elf_x86_64 -dynamic-linker /lib64/ld-linux-x86-64.so.2 -o main /usr/lib/../lib64/crt1.o /usr/lib/../lib64/crti.o /usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/crtbegin.o -L/usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2 -L/usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/../../../../lib64 -L/lib/../lib64 -L/usr/lib/../lib64 -L/usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/../../.. main.o func.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/local/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/crtend.o /usr/lib/../lib64/crtn.o
好了,至此,我们用完成了链接,并生成了可执行文件,下面就让我们看看链接是如何工作的。
第四节 目标文件(ELF)
为了便于理解,我们开篇就说了句通俗易懂的话:“链接就是把编译生成的*.o文件整合成可执行文件的过程”。那么是如何整合的呢,难道是把文件内容都拷贝到一个文件中吗,显然不是,但是我们可以说链接是有规则的拷贝,按照什么规则呢,要想知道,还得先了解下*.o文件的格式。
我们把*.o叫目标文件,实际上*.o文件只是目标文件的一种,目标文件的格式在Unix系统下被称为ELF格式(Executable and Linking Format,可执行和可链接格式),目标文件有三种:
1. 可重定位目标文件
上面我们产生的*.o文件即main.o和func.o被称为可重定位目标文件,它与其他可重定位目标文件合并后生成可执行目标文件。典型的(可能还有其他节)ELF可重定位目标文件格式如下:
ELF header、Segment header table和.init等被称为节,每个节的内容通过节的名字不难推测出来,此处不再赘述。我们重点来看下.symtab这个节,.symtab是一个符号表,里面存了一堆符号(变量、函数等),后面会讲到符号解析,说白点,就是查找这张表,找出表里的符号定义在哪里。每个可重定位目标文件在.symtab中都有一张符号表,需要注意的是,这张符号表不包含局部变量信息,每个可重定位目标文件obj都包含三种不同的符号:
在obj中定义的,被其他文件引用的全局符号(如本文的main_global就是在main.c中定义的,能被其他文件使用的符号);
由其他文件定义的,被obj使用的全局符号(如本文func函数就是这样的符号,它在func.c文件中定义,被main.c文件使用);
只被obj文件定义和使用的全局符号(如本文static_global就是main.c中定义和使用的全局符号,其他文件不能使用)。
好了,让我们看一下main.o的符号表,linux下我们用readelf命令来查看ELF文件格式信息。
命令:
# readelf -s main.o 参数是小写s,查看符号表信息
输出:
.symtab中并没有包含我们在main.c中定义的local变量,链接只关心全局的符号信息。下面命令可以查看ELF文件节的信息。
命令:
# readelf -S main.o 参数是大写s,查看节信息
输出:
2. 可执行目标文件
我们生成的main就是可执行目标文件,它可以被加载到内存中运行,它的格式和可重定位目标文件类似,如下图所示,需要注意的是,其头部包括程序的入口点(entry point),也就是文件被载入内存后要执行的第一条指令的地址。
我们来看下可执行目标文件中的节:
命令:
# readelf -S main 参数是大写s,查看节信息
输出:
我们可以看到Addr一列中,已经是非0值,说明可以载入内存了,而可重定位目标文件main.o中的Addr一列为0.
3. 共享目标文件
一种特殊的可重定位目标文件,可以在运行时被动态地加载到内存中链接,如一些动态库.so文件,此处不讨论。
第五节 链接干了啥
链接就是把一些相似的段合并到一起的过程,如下图所示:
这个合并要分两步完成,第一步是分析每个可重定位目标文件中段的属性、长度和位置,进行地址分配;第二步是重定位,就是把符号引用和符号定义关联起来。
第一步 分配地址空间:
我们来看下链接前后段属性的变化。
命令:
# objdump -h main.o
输出:
命令:
# objdump -h func.o
输出:
命令:
# objdump -h main
输出(省略了不关心的信息):
上面输出结果中VMA一列表示Virtual Memory Adress,即虚拟地址,我们看到main.o和func.o的VMA都是0,因为还没分配地址空间,而在可执行文件main中已经有了值0x0804****(32位从0x08048000开始,64位从0x00400000开始),说明分配了地址空间。
第二步 符号解析和重定位:
我们来看下main.o中的符号:
我们看到main.o中的符号func前面有个标志U,即Undefined未定义的,这是显然的,因为main.c中用到的函数func是在func.c中定义的,我们在编译main.c时,并不知道func在哪里,那么我么是什么时候知道它们在哪里呢,答案是链接后。我们来看下链接后的可执行文件main的符号:
命令:
# nm main
输出:
D:Global data 符号,T:Global text符号。在链接过后,可以找到符号了,关于链接的详细介绍参考本文最后给出的参考文献。
第六节 ld命令参数
(未完待续)
参考:
1. Randal E. Bryant..;<<Computer Systems: A Programmer's Perspective>>.
2.《程序员的自我修养:链接、装载和库》