看了c++ primer,写过一些C++程序后,对其中的编译链接原理总是不明就里,想来这也难怪,因为平常都是在VS上,什么都是封装好了的,隐藏了太多的细节。本着自己一贯来对底层实现探究的兴趣,结合借鉴他人的想法,记下自己对C/C++编译链接原理的一些理解,要是能给看到此文章的你带来一丁点帮助就欣慰了。
编译是把源文件经过预编译,优化,汇编翻译成机器语言的过程,这些机器语言代码数据以一定的格式COFF(Common Object File Format),OMF(Object Module Format),ELF(Executed linked Format),PE(Portable Executable)等存放于目标文件中。目标文件通常包含未解决符号表,导出符号表和地址重定向表。连接器具体说明如下:
-----编译和链接大致流程-----
编译器的工作不单单只有编译,事实上,它包括了从高级语言到机器语言的完整过程:
预编译-》编译-》汇编-》链接。
- 预编译
预编译过程主要是处理源代码文件中以#开始的预编译指令。主要处理规则如下:
1.1.将所有#define删除,并展开所有的宏定义。
1.2.处理所有条件编译指令,比如#ifdef、#else等。
1.3.处理#include,递归的将被包含的文件出入到该指令的位置。
1.4.删除所有注释。
1.5.添加行号和文件名标识,以便于编译器调试产生的行号消息和警告时显示的行号。
1.6.保留所有#pragma编译指令。#pragma指令是编译器参数。经过预编译之后,产生一个*.i文件。 - 编译
编译过程就是把预处理完成的*.i文件进行词法分析、语法分析、语义分析以及优化之后,产生相应的汇编代码文件。
2.1.词法分析利用扫描器和有限状态机算法,将源代码字符按照特定的字符标识分割成一系列记号。这些记号包含了以下几种分类:关键字、标识符、字面量(包括数字、字符串等)和特殊符号(加减乘除等)。
2.2.语法分析产生表达式语法树,但是不排查这个语句是否合法。
2.3.语义分析给语法树添加类型标识,并检查表达式是否合法。
2.4.中间代码生成。
2.5.目标代码生成和优化经过编译之后,产生一个汇编输出文件*.s文件。 - 汇编
汇编过程就是将汇编代码转变成机器代码文件*.o文件。这是个相对简单的过程,根据汇编指令和机器指令对照一一翻译就可以了。 - 链接
连接器将多个*.o文件彼此关联拼接到一起,最终产生一个可执行文件。分为静态链接和动态链接。
-----编译和链接大致流程-----
--------参考自http://blog.youkuaiyun.com/success041000/article/details/6714195----------
源文件:A.cpp
int n = 1;
void FunA() {
++n;
}
目标文件:A.obj
偏移量 内容 长度
0x0000 n 4
0x0004 FunA ??
注意:这只是说明,与实际目标文件的布局可能不一样,??表示长度未知,目标文件的各个数据可能不是连续的,也不一定是从0x0000开始。
FunA函数的内容可能如下:
0x0004 inc DWORD PTR[0x0000]
0x00?? ret
这时++n已经被翻译成inc DWORD PTR[0x0000],也就是说把本单元0x0000位置的一个DWORD(4字节)加1。
源文件:B.cpp
extern int n;
void FunB() {
++n;
}
目标文件:B.obj
偏移量 内容 长度
0x0000 FunB ??
这里为什么没有n的空间呢,因为n被声明为extern,这个extern关键字就是告诉编译器n已经在别的编译单元里定义了,在这个单元里就不要定义了。由于编译单元之间是互不相关的,所以编译器就不知道n究竟在哪里,所以在函数FunB就没有办法生成n的地址,那么函数FunB中就是这样的:
0x0000 inc DWORD PTR[????]
0x00?? ret
那怎么办呢?这个工作就只能由链接器来完成了。
为了能让链接器知道哪些地方的地址没有填好(也就是????),那么目标文件中就要有一个表来告诉链接器,这个表就是“未解决符号表”,也就是unresolved symbol table。同样,提供n的目标文件也要提供一个“导出符号表”也就是exprot symbol table,来告诉链接器自己可以提供哪些地址。
到这里我们就已经知道,一个目标文件不仅要提供数据和二进制代码外,还至少要提供两个表:未解决符号表和导出符号表,来告诉链接器自己需要什么和自己能提供些什么。那么这两个表是怎么建立对应关系的呢?这里就有一个新的概念:符号。在C/C++中,每一个变量及函数都会有自己的符号,如变量n的符号就是n,函数的符号会更加复杂,假设FunA的符号就是_FunA(C++标准并未定义,这取决于编译器的具体实现)。
A.obj的导出符号表
符号 地址
n 0x0000
_FunA 0x0004
A.obj的未解决符号表
为空(因为它没有引用别的编译单元里的东西)
B.obj的导出符号表
符号 地址
_FunB 0x0000
B.obj的未解决符号表
符号 地址
n 0x0001
这个表告诉链接器,在本编译单元0x0001位置有一个地址,该地址不明,但符号是n。
在链接的时候,链接在B.obj中发现了未解决符号,就会在所有的编译单元中的导出符号表去查找与这个未解决符号相匹配的符号名,如果找到,就把这个符号的地址填到B.obj的未解决符号的地址处。如果没有找到,就会报链接错误。在此例中,在A.obj中会找到符号n,就会把n的地址填到B.obj的0x0001处。
但是,这里还会有一个问题,如果是这样的话,B.obj的函数FunB的内容就会变成inc DWORD PTR[0x000](因为n在A.obj中的地址是0x0000),由于每个编译单元的地址都是从0x0000开始,那么最终多个目标文件链接时就会导致地址重复。所以链接器在链接时就会对每个目标文件的地址进行调整。在这个例子中,假如B.obj的0x0000被定位到可执行文件的0x00001000上,而A.obj的0x0000被定位到可执行文件的0x00002000上,那么实现上对链接器来说,A.obj的导出符号地地址都会加上0x00002000,B.obj所有的符号地址也会加上0x00001000。这样就可以保证地址不会重复。
既然n的地址会加上0x00002000,那么FunA中的inc DWORD PTR[0x0000]就是错误的,所以目标文件还要提供一个表,叫地址重定向表,address redirect table。
目标文件至少要提供三个表:未解决符号表,导出符号表和地址重定向表。
(1)未解决符号表:列出了本单元里有引用但是不在本单元定义的符号及其出现的地址。
(2)导出符号表:提供了本编译单元具有定义,并且可以提供给其他编译单元使用的符号及其在本单元中的地址。
(3)地址重定向表:提供了本编译单元所有对自身地址的引用记录。
链接器的工作顺序:
当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址。最后把所有的目标文件的内容写在各自的位置上,再作一些另的工作,就生成一个可执行文件。
说明:实现链接的时候会更加复杂,一般实现的目标文件都会把数据,代码分成好向个区,重定向按区进行,但原理都是一样的。明白了编译器与链接器的工作原理后,对于一些链接错误就容易解决了。
下面是C/C++中一些相关的特性:
extern:这就是告诉编译器,这个变量或函数在别的编译单元里定义了,也就是要把这个符号放到未解决符号表里面去(外部链接)。
static:如果该关键字位于全局函数或者变量的声明前面,表明该编译单元不导出这个函数或变量,因些这个符号不能在别的编译单元中使用(内部链接)。如果是static局部变量,则该变量的存储方式和全局变量一样,但是仍然不导出符号。
默认链接属性:对于函数和变量,默认链接是外部链接,对于const变量,默认内部链接。
外部链接的利弊:外部链接的符号在整个程序范围内都是可以使用的,这就要求其他编译单元不能导出相同的符号(不然就会报
duplicated external symbols)。
内部链接的利弊:内部链接的符号不能在别的编译单元中使用。但不同的编译单元可以拥有同样的名称的符号。
为什么头文件里一般只可以有声明不能有定义:头文件可以被多个编译单元包含,如果头文件里面有定义的话,那么每个包含这头文件的编译单元都会对同一个符号进行定义,如果该符号为外部链接,则会导致duplicated external symbols链接错误。
为什么公共使用的内联函数要定义于头文件里:因为编译时编译单元之间互不知道,如果内联被定义于.cpp文件中,编译其他使用该函数的编译单元的时候没有办法找到函数的定义,因些无法对函数进行展开。所以如果内联函数定义于.cpp里,那么就只有这个.cpp文件能使用它。
--------参考自http://blog.youkuaiyun.com/success041000/article/details/6714195----------
以上只是大体概念上的讲述,设计具体环境的编译链接过程细节及文件输出格式等再做补充。