【转】C++编译与链接

大家知道计算机使用的一系列的 1 和 0 ,那个一个C++语言程序又是如何从一个个.h和.cpp文件变成包含 1 和 0 的可执行文件呢?

可以认为有以下的几个环节:源程序->预处理->编译和优化->生成目标文件->链接->可执行文件

在预处理的时候,.h头文件会被复制、扩展到包含它的.cpp文件里,然后编译器编译该.cpp文件为一个.obj文件,每个.cpp文件作为一个编译单元独立编译。当编译器将一个工程里的所有.cpp文件以分离的方式编译完毕后,再由链接器进行链接成为一个可执行文件。

预处理

C++的预处理是指在C++程序源代码被编译之前,由预处理器对C++程序源代码进行的处理。这个过程并不对程序的源代码进行解析。

预处理器主要负责以下的几处:

  • 宏的替换
  • 删除注释
  • 处理所有的条件预编译指令,如:#if #ifdef #elif #else #endif
  • 处理#include预编译指令,将被包含的文件插进到该指令的位置,这个过程是递归的
  • 添加行号与文件名标识,以便产生调试用的行号信息以及编译错误或警告时能够显示行号
  • 保留所有的#pragma编译器指令,因为编译器需要使用它们

编译、链接

把预处理完的cpp文件进行一系列词法分析、语法分析、语义分析及优化后生成汇编代码。之后汇编代码->机器指令。各文件的编译和汇编是独立的。如果编译通过,就会把对应的CPP转换成OBJ文件。

编译单元:根据C++标准,每一个CPP文件就是一个编译单元。每个编译单元之间是相互独立并且互相不可知。

目标文件:由编译所生成的文件,以机器码的形式包含了编译单元里所有的代码和数据,还有一些其他信息,如未解决符号表,导出符号表和地址重定向表等。目标文件是以二进制的形式存在的。

假设有一个A.cpp文件,如下定义:

1
2
3
4
5
6
int n = 1;

void FunA()
{
++n;
}

它编译出来的目标文件A.obj就会有一个区域(或者说是段),包含以上的数据和函数,其中就有n、FunA,以文件偏移量形式给出,可能就是下面这种情况:

1
2
3
偏移量    内容    长度
0x0000 n 4
0x0004 FunA ??

实际目标文件的布局可能不是这样,这里只是方便学习才这样表示,??便是未知。目标文件的各个数据可能不是连续的,也不一定是从0x0000开始。

有另外一个B.cpp文件,定义如下:

1
2
3
4
5
6
extern int n;

void FunB()
{
++n;
}

它对应的B.obj的二进制:

1
2
偏移量    内容    长度
0x0000 FunB ??

由于n被声明为extern,而extern关键字告诉编译器n已经在别的编译单元里定义了,在这个单元里不用定义。由于编译单元之间是互不相关的,所以编译器就不知道n究竟在哪里,所以在函数B.obj中就没有办法生成n的地址。

为了让各个编译单元结合起来,就需要链接器了。为了能让链接器知道哪些地方的地址没有填好(也就是还????),那么目标文件中就要有一个表来告诉链接器,这个表就是“未解决符号表”(unresolved symbol table)。同样,提供n的目标文件也要提供一个“导出符号表”(exprot symbol table),来告诉链接器自己可以提供哪些地址。

因此,一个目标文件不仅要提供数据和二进制代码,还要提供两个表:未解决符号表和导出符号表,来告诉链接器自己需要什么和自己能提供些什么。

那么这两个表是怎么建立对应关系的呢?

在C/C++中,每一个变量及函数都会有自己的符号,如变量n的符号就是n,函数的符号会更加复杂,根据编译器不同而不同。

A.obj的导出符号表为

1
2
3
符号    地址
n 0x0000
_FunA 0x0004

未解决符号为空。

B.obj的导出符号表为

1
2
符号    地址
_FunB 0x0000

未解决符号表为

1
2
符号    地址
n 0x0001

这个表告诉链接器,在本编译单元0x0001位置有一个地址,该地址不明,但符号是n。

在链接的时候,链接器在B.obj中发现了未解决符号,就会在所有的编译单元中的导出符号表去查找与这个未解决符号相匹配的符号名,如果找到,就把这个符号的地址填到B.obj的未解决符号的地址处。如果没有找到,就会报链接错误。在此例中,在A.obj中会找到符号n,就会把n的地址填到B.obj的0x0001处。然后解决地址重复的问题,对每个目标文件的地址进行调整,提供一个地址重定向表。

总结:

目标文件至少要提供三个表:未解决符号表,导出符号表和地址重定向表。

  1. 未解决符号表:列出了本单元里有引用但是不在本单元定义的符号及其出现的地址。
  2. 导出符号表:提供了本编译单元具有定义,并且可以提供给其他编译单元使用的符号及其在本单元中的地址。
  3. 地址重定向表:提供了本编译单元所有对自身地址的引用记录。

当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址。最后把所有的目标文件的内容写在各自的位置上,再做一些其他工作,就生成一个可执行文件。

说明:实现链接的时候会更加复杂,一般实现的目标文件都会把数据,代码分成好向个区,重定向按区进行,但原理都是一样的。

外部链接与内部链接

内部链接

如果一个名称对于他的编译单元是局部的,并且在链接时不会与其他的编译单元中同样的名字冲突,那么这个名称就拥有内部链接。这个实体有内部链接,他就不会与其他.cpp文件同名的实体冲突。换个说法,那些编译单元(.cpp)中不能向其他编译单元(.cpp)展示提供其定义的函数、变量就拥有内部链接。

那么哪些实体拥有内部链接?

  • 静态(static)全局变量定义.如: static int x;
  • 枚举类型定义.如: enum Boolean {NO,YES };
  • 类定义. 如: class Point { int d_x; int d_y; ... };
  • 内联函数定义.如: inline int operator==(const Point& left,const Point&right) { ... }
  • union的定义.
  • 名字空间中const常量定义

外部链接

一个多文件的程序中,一个实体可以在链接时与其他编译单元交互,那么这个实体就拥有外部链接。

换个说法,那些编译单元(.cpp)中能向其他编译单元(.cpp)提供其定义,让其他编译单元(.cpp)使用的函数、变量就拥有外部链接。

那么哪些实体拥有外部链接?

  • 非内联的类成员函数.如: Point& Point::operator+=(const Point& right) { ... }
  • 非内联、非静态的自由函数. 如: Point operator+(const Point& left, const Point& right) { ... }
  • 非静态的全局定义

几个经典的链接错误

unresolved external link..

这个很显然,是链接器发现一个未解决符号,但是在导出符号表里没有找到对应的项。

解决方案就是在某个编译单元里提供这个符号的定义。(注意,这个符号可以是一个变量,也可以是一个函数),也可以看看是不是有什么该链接的文件没有链接。

duplicated external simbols...

这个则是导出符号表里出现了重复项,因此链接器无法确定应该使用哪一个。这可能是使用了重复的名称,也可能有别的原因。

C/C++针对这些而提供的特性:

extern:告诉编译器,这个符号在别的编译单元里定义,也就是要把这个符号放到未解决符号表里去。(外部链接)

static:如果该关键字位于全局函数或者变量的声明的前面,表明该编译单元不导出这个函数/变量的符号。因此无法在别的编译单元里使用(内部链接)。如果是static局部变量,则该变量的存储方式和全局变量一样,但是仍然不导出符号。

外部链接的利弊:外部链接的符号,可以在整个程序范围内使用(因为导出了符号)。但是同时要求其他的编译单元不能导出相同的符号(不然就是duplicated external simbols)

内部链接的利弊:内部链接的符号,不能在别的编译单元内使用。但是不同的编译单元可以拥有同样名称的内部链接符号。

一些问题的解答

  1. 为什么头文件里一般只可以有声明不能有定义?
    头文件可以被多个编译单元包含,如果头文件里有定义,那么每个包含这个头文件的编译单元就都会对同一个符号进行定义,如果该符号为外部链接,则会导致duplicated external simbols。因此如果头文件里要定义,必须保证定义的符号只能具有内部链接。

  2. 为什么类的静态变量不可以就地初始化?
    所谓就地初始化就是类似于这样,由于class的声明通常是在头文件里,如果允许这样做,其实就相当于在头文件里定义了一个非const变量。

    1
    2
    3
    4
    class A
    {
    static char msg[] = "aha";
    };
  3. 为什么公共使用的内联函数要定义于头文件里?
    因为编译时编译单元之间互相不知道,如果内联函数被定义于.cpp文件中,编译其他使用该函数的编译单元时没有办法找到函数的定义,因此无法对函数进行展开。所以说如果内联函数定义于.cpp文件里,那么就只有这个cpp文件可以使用这个函数。

  4. 头文件里的内联函数被拒绝会怎样?
    记住,内联只是给编译器的一个建议,如果定义于头文件里的内联函数被拒绝,那么编译器会自动在每个包含了该头文件的编译单元里定义这个函数并且不导出符号。

参考

[1]. C++编译链接原理简介
[2]. C++编译与链接(1)-编译与链接过程

持续技术分享,您的支持将鼓励我继续创作!